[모두의 플리] DB 트랜잭션과 썸네일 파일 작업의 경계 정리하기

@Transactional이면 모두 롤백될까

관리자 콘텐츠 등록·수정 API는 콘텐츠 정보와 썸네일 파일을 함께 처리한다.

처음에는 서비스 메서드에 @Transactional을 붙이면 DB 저장과 파일 업로드가 하나의 작업처럼 안전하게 처리될 것이라고 생각하기 쉽다.

하지만 Spring의 DB 트랜잭션이 롤백할 수 있는 것은 데이터베이스 작업이다. 로컬 디스크에 생성된 파일이나 S3에 업로드된 객체는 자동으로 되돌릴 수 없다.

썸네일 업로드 성공
-> DB 저장 실패
-> DB는 롤백
-> 업로드된 썸네일은 그대로 남음

반대 방향도 문제가 된다.

DB 수정 성공
-> 기존 썸네일 삭제 실패
-> DB는 새 썸네일을 가리킴
-> 기존 파일은 저장소에 남음

서로 다른 자원을 하나의 로컬 트랜잭션처럼 취급한 것이 문제의 시작이었다.

고려한 방법

파일을 먼저 저장하고 실패하면 보상 삭제

새 파일 업로드
-> DB 트랜잭션 실행
-> DB 실패 시 새 파일 삭제

구현이 비교적 단순하고 DB에는 실제로 업로드된 URL만 저장할 수 있다. 다만 보상 삭제 자체도 실패할 수 있다.

DB를 먼저 저장하고 COMMIT 후 파일 업로드

DB 트랜잭션은 짧아지지만 업로드가 실패하면 DB가 존재하지 않는 파일을 가리킨다. 임시 상태와 재시도 절차가 추가로 필요하다.

임시 저장소와 Outbox 사용

파일을 임시 경로에 저장하고 DB COMMIT 후 확정하거나, Outbox 이벤트를 통해 비동기로 처리할 수 있다. 장애 복구에는 강하지만 현재 프로젝트 범위에 비해 구현과 운영 복잡도가 크다.

이번에는 파일을 먼저 업로드하고 DB 실패 시 새 파일을 보상 삭제하는 방식을 선택했다.

트랜잭션 범위를 코드로 명시하기

메서드 전체에 선언적으로 트랜잭션을 적용하는 대신 TransactionTemplate로 DB 구간을 명확하게 감쌌다.

1. 새 썸네일 업로드
2. TransactionTemplate 안에서 Content 수정
3. DB COMMIT
4. Content 캐시 무효화
5. 썸네일이 교체되었다면 기존 파일 삭제

DB 트랜잭션 중에는 새 썸네일 URL과 key를 Content에 반영하고, 응답 DTO와 기존 썸네일 key를 결과 객체로 반환한다.

private record ContentUpdateOutcome(
    ContentDto contentDto,
    String previousThumbnailKey,
    boolean thumbnailChanged
) {
}

이 결과를 사용해 COMMIT 이후 작업을 수행한다. 트랜잭션 밖에서 영속 엔티티의 변경 가능한 상태를 다시 읽지 않아도 되고, 이후 단계에 필요한 값도 명확해졌다.

수정 실패 시 새 썸네일 정리

새 파일은 업로드됐지만 입력 검증이나 DB 처리에서 예외가 발생하면 새 파일을 삭제한다.

새 썸네일 업로드 성공
-> 콘텐츠 조회 또는 수정 실패
-> 새 썸네일 보상 삭제
-> 예외 전달
-> 기존 썸네일과 DB 상태 유지

반대로 DB 수정이 성공한 뒤에는 새 썸네일을 삭제하면 안 된다. 이 시점부터 DB가 새 파일을 사용하기 때문이다.

기존 썸네일은 왜 COMMIT 후 삭제할까

기존 파일을 먼저 삭제하고 DB 수정이 실패하면 DB는 더 이상 존재하지 않는 파일을 가리키게 된다.

따라서 기존 썸네일은 DB가 새 썸네일 정보를 확정한 이후에만 삭제한다.

잘못된 순서
기존 파일 삭제 -> DB 수정 실패 -> 기존 화면 이미지 손실

선택한 순서
DB 수정 COMMIT -> 기존 파일 삭제

썸네일을 전달하지 않은 PATCH 요청에서는 기존 URL과 key를 그대로 유지하고 파일 삭제도 실행하지 않는다.

삭제 실패는 비즈니스 결과를 되돌리지 않기

DB COMMIT이 끝난 뒤 기존 썸네일 삭제가 실패했다고 이미 성공한 콘텐츠 수정을 실패 응답으로 바꾸면 문제가 더 커진다.

클라이언트는 실패한 줄 알고 같은 요청을 다시 보낼 수 있지만 DB는 이미 변경되어 있다. 응답과 실제 상태가 달라지는 것이다.

그래서 저장소 구현은 삭제 실패를 예외로 알리고, ContentThumbnailService가 이를 받아 WARN 로그를 남긴 뒤 비즈니스 흐름은 유지하도록 했다.

try {
  contentThumbnailStorage.delete(thumbnailKey);
} catch (RuntimeException e) {
  log.warn(
      "Content thumbnail delete failed. thumbnailKey={}, errorType={}",
      thumbnailKey,
      e.getClass().getSimpleName(),
      e
  );
}

책임을 이렇게 나누었다.

Local·S3 Storage
  -> 실제 삭제 수행
  -> 실패를 예외로 전달

ContentThumbnailService
  -> 삭제 실패 로그 기록
  -> 콘텐츠 생성·수정 성공 결과 유지

삭제 실패 파일이 남을 수 있다는 단점은 받아들였다. 현재는 WARN 로그로 추적하고, 반복적으로 문제가 발생하면 실패 key 재처리나 고아 파일 정리 배치를 별도 고도화할 수 있다.

로컬 저장소 경로 이탈 차단

로컬 파일 삭제에서는 전달받은 key를 저장소 루트와 결합한 뒤 정규화한다.

저장 루트: uploads/contents/thumbnails
요청 key: ../outside.jpg

정규화된 경로가 저장 루트 밖이라면 삭제를 시도하지 않고 예외를 발생시킨다. 단순히 로그만 남기고 무시하면 호출자는 삭제가 정상 처리되었다고 오해할 수 있기 때문이다.

미사용 메서드와 중복 책임 정리

트랜잭션 경계를 정리한 뒤 현재 호출 경로를 다시 확인했다.

ContentMapper에는 과거 URL 문자열만 받던 생성 메서드와 URL·key를 함께 받는 메서드가 공존했다. 현재 저장소는 URL과 key를 모두 반환하므로 이전 오버로드를 제거했다.

ContentStatsServicegetStatsByContents도 조회 캐시 고도화 이후 사용되지 않았다. 목록 조회는 ContentCacheService와 watcherCount 벌크 조회가 담당하므로 미사용 메서드와 그 전용 테스트를 함께 제거했다.

리팩토링은 코드를 짧게 만드는 작업이 아니라 현재 책임과 맞지 않는 과거 경로를 없애는 작업이었다.

테스트한 경계 상황

정상 흐름뿐 아니라 실패 경계를 중심으로 테스트했다.

  • 썸네일 교체 후 DB 성공 시 기존 파일만 삭제
  • 썸네일을 전달하지 않으면 기존 파일 유지
  • DB 처리 실패 시 새로 업로드한 파일 보상 삭제
  • DB 실패 시 캐시 무효화를 실행하지 않음
  • Local 저장소의 정상 삭제
  • Local 저장소 루트 밖 key 차단
  • S3 삭제 실패를 저장소 예외로 변환
  • ThumbnailService가 삭제 실패를 삼키고 비즈니스 흐름 유지
  • 콘텐츠 도메인 전체 테스트

남아 있는 한계

보상 삭제도 네트워크 장애로 실패할 수 있으므로 파일과 DB 사이에 완전한 원자성을 제공하는 구조는 아니다.

현재 선택은 구현 복잡도와 서비스 일관성 사이의 균형이다. 운영에서 고아 파일이 실제로 누적된다면 다음 단계를 검토할 수 있다.

삭제 실패 key 저장
-> 재시도 가능한 작업 큐 또는 Outbox 기록
-> 주기적인 정리 Job
-> 최종 실패 메트릭과 알림

배운 점

@Transactional은 메서드 안의 모든 작업을 마법처럼 되돌려주지 않는다.

DB, 파일 시스템, S3처럼 서로 다른 자원을 함께 다룰 때는 어느 작업이 먼저 수행되고, 각 단계가 실패하면 무엇을 보상하며, COMMIT 이후 실패를 사용자에게 어떻게 보여줄지 명시적으로 설계해야 한다.