Spring Event로 파일 저장 로직 분리하기

배경

이번 sprint mission 11에서는 파일 업로드 로직을 더 안정적인 구조로 바꾸는 작업을 했다.

디스코드잇에서는 파일을 저장할 때 두 가지 데이터가 생긴다.

  • 파일 이름, 크기, 타입 같은 메타 데이터
  • 실제 이미지나 첨부파일의 바이트 데이터

메타 데이터는 DB에 저장되고, 실제 바이트 데이터는 로컬 파일 시스템이나 S3 같은 저장소에 저장된다.

처음에는 이 두 작업을 하나의 흐름 안에서 처리해도 괜찮아 보인다. 하지만 실제 파일 저장은 오래 걸릴 수 있다. 특히 S3처럼 네트워크를 타는 저장소라면 더 오래 걸릴 수 있다.

문제 상황

기존 구조에서는 파일 메타 데이터를 저장하는 트랜잭션 안에서 실제 파일 저장까지 같이 처리했다.

흐름을 단순하게 쓰면 다음과 같다.

사용자 요청
  -> DB에 BinaryContent 메타 데이터 저장
  -> 파일 시스템 또는 S3에 실제 바이트 저장
  -> 트랜잭션 종료

이 구조의 문제는 실제 파일 저장이 끝날 때까지 DB 트랜잭션이 오래 열린다는 점이다.

트랜잭션은 DB 작업을 안전하게 묶기 위한 장치다. 그런데 파일 저장처럼 DB와 직접 관련 없는 느린 작업까지 트랜잭션 안에 넣으면 DB 연결과 락을 오래 잡을 수 있다.

초보자 입장에서는 이렇게 생각하면 쉽다.

DB 작업은 이미 끝났는데,
파일 저장이 느려서 DB 작업까지 같이 기다리는 상태

이건 트래픽이 늘어날수록 좋지 않다.

해결 방향

이번에는 Spring Event를 사용해서 두 작업을 분리했다.

핵심은 BinaryContentCreatedEvent이다.

이 이벤트는 “BinaryContent 메타 정보가 DB에 저장되었다”는 사실을 표현한다.

DB에 메타 데이터 저장
  -> BinaryContentCreatedEvent 발행
  -> 트랜잭션 커밋
  -> 이벤트 리스너가 실제 바이트 저장

즉, 서비스 코드는 실제 저장소를 직접 호출하지 않고 이벤트만 발행한다.

왜 커밋 이후에 실행해야 할까

파일 저장 리스너는 @TransactionalEventListener(phase = AFTER_COMMIT) 방식으로 동작하게 했다.

이 설정의 의미는 “메인 트랜잭션이 정상적으로 커밋된 뒤에 실행하라”는 뜻이다.

만약 DB 저장이 실패했는데 파일만 저장되면 이상한 상태가 된다.

DB에는 파일 정보가 없음
하지만 저장소에는 실제 파일이 있음

반대로 DB 저장이 확실히 성공한 뒤 파일 저장을 시도하면 흐름이 더 자연스럽다.

업로드 상태가 필요한 이유

파일 저장을 트랜잭션 밖으로 빼면 사용자는 응답을 더 빨리 받을 수 있다. 하지만 새로운 문제가 생긴다.

응답이 먼저 나간 시점에는 실제 파일 저장이 아직 끝나지 않았을 수도 있다.

그래서 BinaryContent에 업로드 상태를 추가했다.

  • PROCESSING: 업로드 중
  • SUCCESS: 업로드 성공
  • FAIL: 업로드 실패

처음 메타 데이터를 만들 때는 PROCESSING 상태로 시작한다.

이후 실제 바이트 저장이 성공하면 SUCCESS, 실패하면 FAIL로 바꾼다.

핵심 흐름

이번 구조를 정리하면 다음과 같다.

1. 사용자가 파일을 포함한 요청을 보낸다.
2. 서버가 BinaryContent 메타 데이터를 DB에 저장한다.
3. 상태는 PROCESSING으로 시작한다.
4. BinaryContentCreatedEvent를 발행한다.
5. DB 트랜잭션이 커밋된다.
6. 이벤트 리스너가 실제 바이트 데이터를 저장한다.
7. 저장 성공 시 SUCCESS로 상태를 바꾼다.
8. 저장 실패 시 FAIL로 상태를 바꾼다.

테스트와 확인 방법

이 작업 후에는 전체 테스트를 실행했다.

./gradlew.bat test

또 실제 화면에서는 파일 업로드 후 업로드 상태가 반영되는지 확인할 수 있다.

PR 설명에서는 다음을 적으면 된다.

BinaryContent 메타 데이터 저장과 실제 바이트 저장 로직을 Spring Event로 분리했습니다.
메타 데이터 트랜잭션이 커밋된 이후 실제 파일 저장을 수행하며,
저장 결과에 따라 BinaryContent 상태를 PROCESSING, SUCCESS, FAIL로 관리합니다.

배운 점

트랜잭션 안에 모든 코드를 넣는다고 항상 좋은 것은 아니다.

DB 작업은 트랜잭션으로 묶어야 하지만, 파일 저장처럼 오래 걸릴 수 있고 외부 시스템에 의존하는 작업은 분리하는 편이 더 좋을 수 있다.

Spring Event는 같은 애플리케이션 안에서 작업 흐름을 느슨하게 분리할 때 사용할 수 있다.

이번 작업에서 중요한 점은 이벤트 자체보다 “언제 실행할 것인가”였다.

파일 저장은 DB 커밋 이후에 실행되어야 한다. 그래야 DB에는 없는 파일이 저장소에만 남는 이상한 상태를 줄일 수 있다.