아무튼, 쓰기
@Transactional에서 try-catch를 썼는데 500 에러? 본문
[문제 발견: catch로 잡았는데 왜 500 에러가 발생할까?]
좋아요 중복 요청 시 요구사항은 다음과 같았습니다.
- 중복 요청 시 데이터베이스 트랜잭션은 롤백되어야 한다.
- 이벤트 발행 등 후속 로직이 실행되지 않아야 한다.
- 클라이언트에게는 200 OK를 응답해야 한다. (멱등성 보장)
초기 코드는 단순한 try-catch 구조였습니다.
@Transactional
public void createDiaryLike(Long memberId, Long diaryId) {
final Member member = memberRepository.findByIdOrElseThrow(memberId);
final ReadingDiary readingDiary = readingDiaryRepository.findByIdOrElseThrow(diaryId);
try {
readingDiaryLikeRepository.save(new ReadingDiaryLike(member, readingDiary));
eventPublisher.publishEvent(new LikeEvent(...));
} catch (DataIntegrityViolationException e) {
log.warn("좋아요 중복 저장 시도 발생 (정상 처리)");
}
}
하지만 테스트 결과, 클라이언트는 200 OK가 아닌 500 에러를 받았고, 로그에는 다음과 같은 예외가 찍혔습니다.
org.springframework.transaction.UnexpectedRollbackException: Transaction silently rolled back because it has been marked as rollback-only
분명히 예외를 catch로 잡았는데도, Spring은 트랜잭션이 비정상적으로 롤백되었다고 판단했습니다. 이 문제를 해결하기 위해 Spring AOP의 트랜잭션 메커니즘을 깊이 파고들기 시작했습니다.
[가설 수립]
문제의 흐름은 다음과 같았습니다.
- save() 호출 시 유니크 제약 조건 위반으로 DataIntegrityViolationException ( RuntimeException의 하위 클래스) 발생
- Spring의 트랜잭션 매니저는 RuntimeException이 발생하면, 트랜잭션을 즉시 globalRollbackOnly 상태로 마킹합니다.
- catch 블록이 예외를 잡았기 때문에, createDiaryLike 메서드 자체는 정상 종료됩니다.
- 메서드가 정상 종료되자, Spring AOP는 트랜잭션 commit을 시도합니다.
- commit 시점에 트랜잭션 매니저는 "메서드는 정상 종료됐는데, 트랜잭션은 globalRollbackOnly 상태네?"라고 판단합니다.
- 이 불일치 상황을 개발자에게 알리기 위해 UnexpectedRollbackException을 발생시킵니다.
이 가설을 검증하기 위해 Spring의 AbstractPlatformTransactionManager.commit() 소스 코드를 확인했습니다.
// AbstractPlatformTransactionManager.commit()
public final void commit(TransactionStatus status) throws TransactionException {
DefaultTransactionStatus defStatus = (DefaultTransactionStatus)status;
if (defStatus.isLocalRollbackOnly()) {
// 1. 개발자가 명시적으로 setRollbackOnly() 호출한 경우
if (defStatus.isDebug()) {
this.logger.debug("Transactional code has requested rollback");
}
this.processRollback(defStatus, false); // 조용히 롤백 (예외 없음)
} else if (!this.shouldCommitOnGlobalRollbackOnly() && defStatus.isGlobalRollbackOnly()) {
// 2. 예외로 인한 자동 rollback-only 마킹인 경우 (우리의 케이스)
if (defStatus.isDebug()) {
this.logger.debug("Global transaction is marked as rollback-only but transactional code requested commit");
}
this.processRollback(defStatus, true); // 여기서 UnexpectedRollbackException 발생!
} else {
this.processCommit(defStatus);
}
}
핵심은 Spring이 두 가지 rollback-only 플래그를 구분하여 처리한다는 것이었습니다.
플래그 의미 설정 방법 Spring의 반응
| localRollbackOnly | 개발자의 명시적 롤백 요청 | TransactionAspectSupport.currentTransactionStatus().setRollbackOnly() | processRollback(defStatus, false) 호출. 조용히 롤백 후 정상 종료. |
| globalRollbackOnly | 예외로 인한 자동 마킹 | RuntimeException 발생 | processRollback(defStatus, true) 호출. UnexpectedRollbackException 발생. |
우리의 초기 코드는 globalRollbackOnly 플래그만 true로 만들었고, commit 시점에 두 번째 else if 문에 걸려 예외가 발생했던 것입니다.
[해결 방안 도출 및 비교]
방안 1(채택안함): REQUIRES_NEW로 트랜잭션 분리
좋아요 저장 로직을 서비스와 REQUIRES_NEW 전파 속성을 가진 새 트랜잭션으로 분리합니다. save()가 실패하면 내부 트랜잭션만 롤백되고, 예외는 외부로 전파됩니다. 외부 트랜잭션은 rollback-only로 마킹되지 않기 때문에, catch 블록에서 예외를 잡고 정상 커밋(혹은 롤백)할 수 있습니다. 그러나, 이 문제를 해결하기 위해 클래스를 새로 분리하는 것이 조금 무겁게 느껴졌고, 빈번한 '좋아요' 요청마다 기존의 2배의 DB 커넥션을 사용해야 한다는 점이 단점입니다.
방안 2(채택): setRollbackOnly()로 명시적 의도 전달
catch 블록에서 localRollbackOnly 플래그를 true로 설정하여, Spring이 첫 번째 if문에 진입하도록 유도합니다. 이는 "예외가 발생했지만, 이건 내가 의도한 롤백이니 조용히 처리해 줘"라고 Spring에게 명시적으로 알려주는 것입니다. 결국 setRollbackOnly 방식을 채택했습니다. 좋아요는 빈번한 작업이므로, 트랜잭션과 커넥션을 1개만 사용하는 것이 오버헤드 최소화에 유리하고, 만약 이 메서드에 다른 비즈니스 로직이 추가되어 실패 격리가 필요해진다면, 그때 리팩토링하는 것이 더 실용적일 거라고 판단했기 때문입니다.
[최종 구현]
완성된 코드는 다음과 같습니다.
@Transactional
public void createDiaryLike(Long memberId, Long diaryId) {
final Member member = memberRepository.findByIdOrElseThrow(memberId);
final ReadingDiary readingDiary = readingDiaryRepository.findByIdOrElseThrow(diaryId);
try {
readingDiaryLikeRepository.save(new ReadingDiaryLike(member, readingDiary));
// 예외 발생 시 이 라인은 실행되지 않음
eventPublisher.publishEvent(new LikeEvent(...));
} catch (DataIntegrityViolationException e) {
// Spring에게 개발자가 의도한 롤백임을 명시적으로 전달
TransactionAspectSupport.currentTransactionStatus().setRollbackOnly();
log.warn("좋아요 중복 저장 시도 발생 (정상 처리)");
}
}
동작 검증:
- 중복 save() 호출 → DataIntegrityViolationException 발생
- catch 블록 진입 → setRollbackOnly() 호출 (localRollbackOnly = true 설정)
- eventPublisher 라인에 도달하지 않아 이벤트 발행 방지
- 메서드 정상 종료
- Spring commit() 메서드에서 isLocalRollbackOnly()가 true이므로 processRollback(defStatus, false) 호출
- 트랜잭션은 조용히 롤백되고, 예외는 발생하지 않음
- 클라이언트에게 200 OK 응답 (멱등성 보장)
[결론 및 인사이트]
이 경험을 통해 단순한 좋아요 기능 멱등성 확보와 더불어, Spring 트랜잭션의 깊은 내부 동작을 학습할 수 있었습니다.
- local vs global RollbackOnly: Spring이 개발자의 명시적 롤백과 예외로 인한 자동 롤백을 구분하여 처리하는 설계 의도를 이해했습니다.
- 예외를 비즈니스 흐름 제어에 사용하더라도, 트랜잭션의 상태 전이는 개발자가 해 명확히 의도를 지정해야 함을 깨달았습니다.
- 현재 문제에 가장 적합하고 단순한 해결책을 선택(**YAGNI)**하고, 필요할 때 리팩토링하는 것이 실용적인 접근 방식임을 다시 한번 확인했습니다.
'스프링' 카테고리의 다른 글
| [디프만, 밥토리] Hibernate 벡터, "묻고 double[]로 가!" 가 아니라.. float[]로 가! (0) | 2025.11.07 |
|---|---|
| 100명 동시 요청 25분 → 1분, Gemini API 병목 96% 개선기 (feat. Virtual Thread의 함정) (0) | 2025.11.06 |
| Spring 이벤트, 혹시 이렇게 쓰고 계신가요? (Fat Event 피하기) (0) | 2025.10.23 |
| (오픈소스 기여까지)Spring JDBC 배치 처리 최적화: 27초에서 1초로 (1) | 2025.10.09 |
| 피드 조회 API 응답 속도 8.9초에서 200ms로 단축시킨 성능 개선기 (1) | 2025.07.12 |