BindProject

이미지 모듈 리팩토링 여정

dding-shark 2025. 7. 24. 12:51
728x90

 

성능, 보안, 안정성을 모두 잡는 리팩토링 여정기

안녕하세요! 오늘은 많은 백엔드 시스템이 공통적으로 가지고 있는 '오래된 이미지 정리' 기능을 어떻게 더 똑똑하고 안전하게 만들 수 있는지에 대한 리팩토링 과정을 설명해보고자 합니다. 

 

 

처음 만든 저희 시스템의 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를 호출하는 쪽에서는 반환 값을 확인하여 후속 조치를 결정하게 되므로, 코드가 훨씬 더 안정적이고 예측 가능해집니다.

 

 

 

결론

단순해 보였던 이미지 정리 기능이 네 번의 리팩토링을 거치며 성능, 보안, 안정성, 유지보수성이라는 네 마리 토끼를 모두 잡은 견고한 서비스로 다시 태어났습니다.

여러분의 코드 속에도 혹시 잠자고 있는 시한폭탄은 없으신가요? 이 글이 여러분의 시스템을 더 건강하게 만드는 데 작은 영감이 되기를 바랍니다.

긴 글 읽어주셔서 감사합니다

728x90