독서 기록 생성 및 삭제 API를 구현하는데, 독서 기록에는 사진도 있고 내용도 존재한다.
지금 문제는, s3에 올라간 사진 delete를 하게 되면 DB 트랜잭션이랑 별개이기 때문에 @Transactional로 설정해둔 메서드 안에서 s3를 지우게 되면, 실패 시 롤백이 불가능하다..
보통 이러한 경우에 @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)를 사용하는 경우가 많은 것 같아서, 사용해보고자 한다.
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) 이게 뭐냐면
트랜잭션이 성공적으로 commit 후에만 실행하도록 설정하는 것이다.
따라서 s3 delete는 트랜잭션 안에서 진행하면 롤백이 안되기 때문에, db 트랜잭션이 성공적으로 실행이 된다면 s3에서도 삭제를 진행하도록 해야 한다는 것이다.
내부적으로 어떻게 동작하는지 알아보자. 지금 상황이 딱 이런 식이다.
@Transactional
public void save() {
repository.save(entity);
eventPublisher.publishEvent(new Event());
}
DB 저장 -> 이벤트 실행(사진을 s3에 저장) -> commit or rollback
지금 실행 흐름이 이렇게 되는데, rollback 이 되더라도 사진은 s3에 저장된 상태라는 것이다.
AFTER_COMMIT을 사용하게 되면, 흐름은 다음과 같다.
DB 저장 -> commit 성공 -> 이벤트 실행(사진 저장)
이렇게 된다면, DB에서 저장된 다음 rollback이 되더라도 사진이 쓸데없이 저장되는 일이 생기지 않는다.
TransactionPhase 종류는 다음과 같다.
| phase | 실행 시점 | commit 성공 시 | rollback 시 |
|---|---|---|---|
AFTER_COMMIT | commit 이후 | 실행됨 | 실행 안됨 |
BEFORE_COMMIT | commit 직전 | 실행됨 | 실행 안됨 |
AFTER_ROLLBACK | rollback 이후 | 실행 안됨 | 실행됨 |
AFTER_COMPLETION | 트랜잭션 종료 후 | 실행됨 | 실행됨 |
| 상황 | 선택 |
|---|---|
| 데이터 확정 후 실행해야 함 | AFTER_COMMIT |
| 실패했을 때만 처리 | AFTER_ROLLBACK |
| 성공/실패 상관없이 실행 | AFTER_COMPLETION |
| commit 전에 꼭 필요 | BEFORE_COMMIT |
afterCommit doc을 읽어 보면, NOTE에 사용 팁이 간단하게 나와있다.
default void afterCommit() {
}
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
문서를 요약해보면,
"data access code will still participate in original transaction"
이 부분을 보면, 그대로 해석을 해본다면 기존 트랜잭션에서 DB save 코드를 구현한다면 기존 트랜잭션에 참여를 한다는 것이다. 이게 무슨 뜻이냐면,
트랜잭션이 끝나도 DB 커넥션과 영속성 컨텍스트는 살아있다는 것이다.
즉 이 afterCommit을 사용한다면 안에서 repository.save()를 하면, 실제로 commit이 되지 않으며, DB에 반영이 안될 수도 있다.
그래서 note 부분에서는 PROPAGATION_REQUIRES_NEW를 사용하는 것을 추천한다. AFTER_COMMIT 안에서 DB 작업을 하게 되면 commit이 안될 수 있기 때문이다.
PROPAGATION_REQUIRES_NEW@Transactional 안에서는 기존 트랜잭션이 있고, 또 다른 트랜잭션이 있을 수 있다. propagtion은 기존 트랜잭션이 있을 때, 어떻게 행동할지를 정하는 옵션이다. 트랜잭션에 같이 들어갈지, 새로운 트랜잭션을 만들지, 트랜잭션 없이 실행할지 등의 처리 방식을 결정하는 것이다.
종류가 여러가지 있는데, 자주 쓰이는 것은 세 개 정도이다.
| 옵션 | 의미 |
|---|---|
REQUIRED | 있으면 참여, 없으면 생성 |
REQUIRES_NEW | 무조건 새로 생성 |
NESTED | 중첩 트랜잭션처럼 처리 |
REQUIRES_NEW를 여기서 왜 써야 하냐면, 지금 독서 기록 저장 & 이미지를 S3에 저장이라는 트랜잭션 두개가 있다. 여기서는 REQUIRED 처럼 있으면 참여하면 롤백이 되지 않는다. 무조건 새로 만들어야 한다!
afterCommit은 실행 타이밍을 제어해서, 메인 트랜잭션이 성공한 뒤에 실행하는 것을 보장해 주는 것이라면,
REQUIRES_NEW 옵션을 붙이면 @TransactionalEventListener 안의 DB작업을 새로운 트랜잭션으로 따로 commit을 하게 해준다. 즉 독립적으로 commit 및 rollback을 해주는 것이다.
최종적으로 두 옵션을 같이 사용해 줘야 한다. 그리고 delete이벤트를 매개변수로 받는 메서드를 만들어서 사용을 해준다.
ApplicationEventPublisher는 스프링 내부 이벤트를 발행하는 객체다. 스프링에 이벤트가 발생했음을 알리면, 해당 이벤트를 듣고 있는 리스너들이 실행되는 방식이다.
eventPublisher.publishEvent() : 이벤트가 발생했음을 알리는 메서드이다.
private final ApplicationEventPublisher eventPublisher;
public record RecordDeletedEvent(
Long recordId,
List<String> imageKeys
) {
}
// 이벤트가 발행되면 실행하는 리스너
@Slf4j
@Component
@RequiredArgsConstructor
public class RecordDeletedEventListener {
private final PresignedUrlService presignedUrlService;
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void handle(RecordDeletedEvent event) {
for (String imageKey : event.imageKeys()) {
try {
presignedUrlService.deleteFile(imageKey);
} catch (Exception e) {
log.warn("[RECORD] R2 스토리지에서 기록 이미지 삭제 실패 recordId={}, key={}", event.recordId(), imageKey, e);
}
}
}
}
// 실제 삭제 처리
public void deleteRecord(
RecordDeleteEvent event
) {
// record 조회, 삭제 대상
Record record = recordRepository.findById(event.recordId())
.orElseThrow(() -> new CustomException(RecordErrorCode.RECORD_NOT_FOUND));
// 권한 체크
if (!record.getLibrary().getUser().getId().equals(event.userId())) {
throw new CustomException(RecordErrorCode.RECORD_NOT_AUTHORIZED);
}
// 삭제할 이미지 key 추출
List<String> keysToDelete = record.getImages().stream()
.map(RecordImage::getKey)
.filter(Objects::nonNull)
.toList();
// DB에서 삭제
record.getImages().clear();
recordRepository.delete(record);
// 기록 삭제 이벤트 발행
eventPublisher.publishEvent(new RecordDeletedEvent(event.recordId(), keysToDelete));
}
삭제 처리를 하는 deleteRecord 메서드에서 기록 삭제 이벤트가 발행되면, RecordDeletedEventListener에서 RecordDeletedEvent를 listen하고 있으므로 실행이 된다.
이렇게 하면 메인 트랜잭션이 성공적으로 commit된 이후 실행되며, 기존 트랜잭션과는 완전히 분리된 채로 싱행이 된다.
@Transactional 주요 속성| 속성 | 의미 | 핵심 역할 |
|---|---|---|
propagation | 기존 트랜잭션과 관계 | 참여/분리 여부 |
isolation | 트랜잭션 간 데이터 격리 수준 | 동시성 제어 |
timeout | 실행 시간 제한 | 장기 트랜잭션 방지 |
readOnly | 읽기 전용 여부 | 성능 최적화 |
rollbackFor | 롤백 기준 예외 지정 | 롤백 정책 제어 |
noRollbackFor | 롤백 제외 예외 | 예외 커스터마이징 |
보통 readOnly를 제일 많이 사용하고, rollbackFor, propagation은 간간히 사용한다.
readOnly읽기 전용 트랜잭션으로, 더티 체킹을 하지 않으므로 읽기 전용이라면 성능이 향상된다. 단 이 트랜잭션 안에서 쓰기를 하게 된다면 적용이 되지 않는다.
예를 들어서.. JPA 내부 동작 방식은 다음과 같다.
여기서 4,5번 변경감지 -> UPDATE 실행 이 단계가 flush라고 한다. 영속성 컨텍스트에 쌓여 있던 변경 내용을, DB에 SQL문으로 반영을 하는 과정이다. 즉 커밋 직전에 flush가 일어난다.
더티 체킹은 JPA가 관리 중인 엔티티를 보고 있다가, 트랜잭션이 끝날 때 원래 값과 비교를 하게 된다.
이때 달라지만 UPDATE 쿼리를 날리는데, 이걸 더티 체킹이라고 한다. 직접 SQL문을 날리지 않아도 변경 사항을 자동 반영을 해주는 것이다.
그런데 단순 조회만 하는 메서드에서 이 변경 감지를 하는 것은 쓸모가 없지 않느냐는 것이다.
그래서 readOnly=true로 설정을 해준다면, 이 트랜잭션이 수정이 없을 것이라고 믿고, 쓰기 기능을 최대한 줄이는 방식으로 가서 flush mode가 덜 적극적으로 사용이 된다.
그래서 우리가 조회 메서드에서는 readOnly=true로 해주고, 쓰기 메서드에서는 생략하는 것이다.
근데 그렇다고 아예 이 옵션 안에서 쓰기가 불가능하냐.. 그건 아니다.
그렇지만 트랜잭션 안에서 쓰기를 수행하지 않겠다 라고 하는 것이기 때문에, 별도 트랜잭션에서 호출, JDBC 직접 실행, native query 직접 실행.. 이러한 방법들로 우회하는 것이라면 또 가능할 수는 있다. 그렇지만 권장되는 것은 아니다.