성능, 보안, 안정성을 모두 잡는 리팩토링 여정기
안녕하세요! 오늘은 많은 백엔드 시스템이 공통적으로 가지고 있는 '오래된 이미지 정리' 기능을 어떻게 더 똑똑하고 안전하게 만들 수 있는지에 대한 리팩토링 과정을 설명해보고자 합니다.
처음 만든 저희 시스템의 ImageCleanupScheduler는 그럭저럭 잘 동작하는 것처럼 보였습니다. 하지만 코드 깊은 곳에는 성능 저하, 데이터 불일치, 그리고 심각한 보안 취약점이라는 시한폭탄이 숨어있었습니다.
이 글에서는 총 4단계에 걸쳐, 평범했던 스케줄러 코드를 고성능, 고도의 보안, 실패에 안전하며 유지보수하기 좋은 코드로 진화시킨 과정을 상세히 보여드립니다.
#1: 성능 최적화 - "N+1 쿼리의 덫에서 벗어나기"
첫 번째 문제는 비효율적인 데이터베이스 접근이었습니다. 스케줄러는 삭제할 이미지 종류별로 SELECT 쿼리를 실행하고, 조회된 이미지 개수(N개)만큼 DELETE 쿼리를 루프 안에서 실행하고 있었습니다.
Before: 잠들지 못하는 데이터베이스
// ImageCleanupScheduler.java (Old)
@Transactional
public void deleteExpiredImages() {
LocalDateTime cutoff = LocalDateTime.now().minusHours(1);
List<Image> expired = new ArrayList<>();
// 쿼리 1: 임시 이미지 조회
expired.addAll(imageRepository.findByStatusAndCreatedAtBefore(ImageStatus.TEMP, cutoff));
// 쿼리 2: 삭제 보류 이미지 조회
expired.addAll(imageRepository.findByStatusAndPendingDeleteAtBefore(ImageStatus.PENDING_DELETE, cutoff));
// 쿼리 3: 거절된 이미지 조회
expired.addAll(imageRepository.findByStatus(ImageStatus.REJECTED));
// N번의 DELETE 쿼리 발생
for (Image image : expired) {
imageStorage.delete(image.getStoredPath());
imageRepository.delete(image); // 1. 이미지 개수만큼 DELETE 실행
}
}
삭제할 이미지가 1,000개라면, 총 1,003번(SELECT 3번 + DELETE 1,000번) 의 쿼리가 DB로 전송됩니다. 이는 DB에 엄청난 부하를 주며, 분산 락(@SchedulerLock)의 타임아웃을 유발할 수 있습니다.
After: 단 두 번의 쿼리로 모든 것을 해결
벌크(Bulk) 연산을 도입하여 이 문제를 해결했습니다. 먼저 3개의 SELECT를 단 하나의 최적화된 쿼리로 통합하고, DELETE 역시 단 한 번의 쿼리로 처리하도록 변경했습니다.
1. Repository에 통합 쿼리 추가
// ImageRepository.java (New)
@Query("SELECT i FROM Image i WHERE " +
"(i.status = 'TEMP' AND i.createdAt < :cutoff) OR " +
"(i.status = 'PENDING_DELETE' AND i.pendingDeleteAt < :cutoff) OR " +
"i.status = 'REJECTED'")
List<Image> findDeletableImages(@Param("cutoff") LocalDateTime cutoff);
@Modifying
@Query("DELETE FROM Image i WHERE i.id IN :imageIds")
void deleteBulkByIds(@Param("imageIds") List<Long> imageIds);
2. 스케줄러 로직 변경
// ImageCleanupScheduler.java (New)
@Transactional
public void deleteExpiredImages() {
// ...
// 1. 단 한 번의 SELECT
List<Image> expiredImages = imageRepository.findDeletableImages(cutoff);
// ...
List<Long> imageIds = expiredImages.stream().map(Image::getId).collect(Collectors.toList());
// ... 파일 삭제 로직 ...
// 2. 단 한 번의 DELETE
imageRepository.deleteBulkByIds(imageIds);
}
이제 삭제할 이미지가 1,000개라도 DB는 단 2번(SELECT 1번, DELETE 1번) 의 쿼리만 처리하면 됩니다. DB 부하가 획기적으로 줄고 스케줄러 실행 시간도 매우 짧아졌습니다.
#2: 보안 강화 : 경로조작 취약점
다음으로 발견된 문제는 LocalImageStorage의 경로 조작(Path Traversal) 취약점이었습니다. 공격자가 파일 경로에 ../ 같은 문자를 포함하면, 이미지 저장용 디렉토리를 벗어나 서버의 민감한 위치(예: /etc/, /bin/)에 악성 파일을 쓸 수 있었습니다.
Before: 무방비로 열려있던 파일 시스템
// LocalImageStorage.java (Old)
@Override
public String store(MultipartFile file, String relativePath) {
try {
// relativePath를 아무 검증 없이 사용!
File target = new File(baseDir + relativePath);
target.getParentFile().mkdirs();
// ... 파일 쓰기 ...
} catch (IOException e) { /* ... */ }
return relativePath;
}
만약 relativePath가 ../../evil.sh 라면, 서버의 상위 디렉토리에 끔찍한 스크립트가 저장될 수 있습니다.
After: 빗장을 걸어 잠근 스토리지
파일을 저장하기 전, 최종 경로가 반드시 허용된 기본 디렉토리(baseDir) 내에 있는지 철저히 검사하도록 로직을 수정했습니다.
// LocalImageStorage.java (New)
private String storeBytes(byte[] imageBytes, String relativePath) {
// 1. 원천적으로 경로 조작 문자열 차단
if (relativePath.contains("..")) {
throw new ImageException(ImageErrorCode.INVALID_IMAGE_PATH);
}
try {
Path targetPath = this.baseDir.resolve(relativePath).normalize();
// 2. 최종 경로가 baseDir로 시작하는지 검증하여 탈출 시도 방지
if (!targetPath.startsWith(this.baseDir)) {
throw new ImageException(ImageErrorCode.INVALID_IMAGE_PATH);
}
Files.write(targetPath, imageBytes);
} catch (IOException e) { /* ... */ }
return relativePath;
}
이제 Path 객체를 사용하여 경로를 안전하게 해석하고, 최종 결과물이 의도된 디렉토리를 벗어날 수 없도록 원천적으로 차단했습니다.
#3: 안정성 확보 - "서버가 멈춰도 데이터는 깨지지 않는다"
세 번째 문제는 데이터 정합성이었습니다. 만약 스토리지에서 파일들을 삭제한 직후, DB 레코드를 지우기 전에 서버가 멈추면 어떻게 될까요? 파일은 없는데 DB 데이터는 남아 깨진 이미지 링크가 영원히 남게 됩니다. @Transactional은 DB만 롤백할 뿐, 파일 시스템의 삭제를 되돌리진 못합니다.
Before: 실패를 용납하지 않는 불안한 로직
// ImageCleanupScheduler.java (Old - Optimized)
// 1. 스토리지에서 모든 파일 삭제
storedPaths.forEach(imageStorage::delete);
// <--- 만약 이 지점에서 서버가 멈춘다면?
// 2. DB에서 모든 레코드 삭제
imageRepository.deleteBulkByIds(imageIds);
After: 실패에 안전한(Fault-Tolerant) 설계
"파일 삭제에 성공한 것만 DB에서 지운다" 라는 원칙을 적용했습니다.
// ImageCleanupScheduler.java (New - Fault Tolerant)
public void deleteExpiredImages() {
// ...
List<Image> expiredImages = imageRepository.findDeletableImages(cutoff);
// 1. '삭제 성공'한 ID만 담을 리스트
List<Long> deletedImageIds = new ArrayList<>();
for (Image image : expiredImages) {
// 2. 파일 삭제를 시도하고, 성공 여부를 확인
if (imageStorage.delete(image.getStoredPath())) {
// 3. 성공한 경우에만 ID를 리스트에 추가
deletedImageIds.add(image.getId());
} else {
log.warn("파일 삭제 실패: {}", image.getStoredPath());
}
}
// 4. 성공적으로 삭제된 파일에 해당하는 DB 레코드만 제거
if (!deletedImageIds.isEmpty()) {
imageRepository.deleteBulkByIds(deletedImageIds);
}
}
이제 스케줄러가 중간에 멈춰도, 다음 실행 때 처리하지 못했던 나머지 이미지들을 대상으로 작업을 안전하게 이어갈 수 있습니다. 데이터 불일치 가능성이 사라졌습니다.
#4: 유지보수성 향상
마지막으로, 코드를 더 깔끔하고 관리하기 쉽게 다듬었습니다.
Before: 하드코딩된 설정과 불분명한 계약
// 1. 변경하려면 재배포가 필요한 스케줄러 설정
@Scheduled(cron = "0 5 0 * * *")
// 2. 성공/실패를 알 수 없는 delete 메서드
public interface ImageStorage {
void delete(String relativePath);
}
After: 유연한 설정과 명확한 계약
1. 설정 분리: 스케줄러의 cron 표현식을 application.yml로 옮겨, 코드 수정 없이 설정을 바꿀 수 있게 했습니다.
# application.yml
app:
scheduler:
image-cleanup-cron: "0 5 0 * * *"
// ImageCleanupScheduler.java (New)
@Scheduled(cron = "${app.scheduler.image-cleanup-cron}")
2. 명확한 인터페이스: delete 메서드가 boolean을 반환하도록 변경하여, 성공/실패 여부를 명확히 알 수 있게 했습니다.
// ImageStorage.java (New)
public interface ImageStorage {
boolean delete(String relativePath);
}
이로 인해 delete를 호출하는 쪽에서는 반환 값을 확인하여 후속 조치를 결정하게 되므로, 코드가 훨씬 더 안정적이고 예측 가능해집니다.
결론
단순해 보였던 이미지 정리 기능이 네 번의 리팩토링을 거치며 성능, 보안, 안정성, 유지보수성이라는 네 마리 토끼를 모두 잡은 견고한 서비스로 다시 태어났습니다.
여러분의 코드 속에도 혹시 잠자고 있는 시한폭탄은 없으신가요? 이 글이 여러분의 시스템을 더 건강하게 만드는 데 작은 영감이 되기를 바랍니다.
긴 글 읽어주셔서 감사합니다
'BindProject' 카테고리의 다른 글
| 클린코드를 위한 여정 : 빈혈증에 걸린 도메인 모델(Anemic Domain Model) 처리하기: USER_PROFILE (3) | 2025.07.26 |
|---|---|
| 클린코드를 위한 여정 : 가독성을 높힌 클린코드 리팩토링 : BandRoom 서비스... (2) | 2025.07.26 |
| 페이지네이션 완전 정복: `COUNT` 내 서비스에 맞는 최적의 전략 찾기 (1) | 2025.07.24 |
| 전역 에러 핸들러로 try-catch를 걷어내기(2)_모듈간 참조 해결 (0) | 2025.07.23 |
| 전역 에러 핸들러로 try-catch를 걷어내기 (3) | 2025.07.23 |