최근 Spring Transaction 전파에 대해 깊이있게 학습하며 예전 알림 문제를 어떻게하면 해결할 수 있을지 계속 고민이 들었다.
과연 에전에 내가 한 방법이 정말 올바른 방법이였을까?
2024.04.20 - [대외활동] - 알림기능, 문제의 시작
알림기능, 문제의 시작
기존 서비스저희 게시판 서비스는 HoneyTip(꿀팁공유해요 게시판)과 HoneyTipComment(꿀팁공유해요 댓글)이 연관관계 매핑이 되어있습니다. HoneyTipComment는 Comment를 단일테이블 전략으로 상속받고 있습
toychip.tistory.com
예제를 아주 간단하게 세팅해서 문제를 명확히 파악해보자.
Entity
/* Entity */
@Getter
@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Comment {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String content;
private String writer;
private Comment(final String content, final String writer) {
this.content = content;
this.writer = writer;
}
public static Comment of(final String content, final String writer) {
return new Comment(content, writer);
}
}
@Getter
@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Log {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
private Comment comment;
private String content;
private Log(final Comment comment, final String content) {
this.comment = comment;
this.content = content;
}
public static Log of(final Comment comment, final String content) {
return new Log(comment, content);
}
}
Service
/* CommentService */
@Service
@RequiredArgsConstructor
public class CommentService {
private final CommentRepository commentRepository;
private final LogService logService;
@Transactional
public void registerComment(Comment comment) {
commentRepository.save(comment);
logService.registerLog(comment);
}
}
@Service
@RequiredArgsConstructor
public class LogService {
private final LogRepository logRepository;
@Transactional
public void registerLog(Comment comment) {
String logUuid = "log + " + UUID.randomUUID();
String logUuidSubString = logUuid.substring(0, 15);
Log log = Log.of(comment, logUuidSubString);
logRepository.save(log);
}
}
1. 같은 논리트랜잭션 참여
현재, 아래와 같은 상황이다.
LogService가 CommentService의 트랜잭션에 참여한다.
자 이제 이상황에서 LogService에서 예외가 발생한다면 모두 롤백이 될 것이다.

/* CommentService */
@Transactional
public void registerCommentLogException(Comment comment) {
commentRepository.save(comment);
logService.registerLogException(comment);
}
/* LogService */
@Transactional
public void registerLogException(Comment comment) {
String logUuid = "log + " + UUID.randomUUID();
String logUuidSubString = logUuid.substring(0, 15);
Log log = Log.of(comment, logUuidSubString);
logRepository.save(log);
if (true) {
throw new RuntimeException("예외 발생! 롤백 !");
}
}
테스트가 통과하는 것을 볼 수 있다.

2. REQUIRES_NEW, 트랜잭션 분리
LogService에서 예외가 발생한다. 과연 어떻게 될까?
/* CommentService */
@Transactional
public void registerCommentLogExceptionRequiresNew(Comment comment) {
commentRepository.save(comment);
logService.registerLogCatchError(comment);
}
/* LogService */
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void registerLogExRequiresNew(Comment comment) {
String logUuid = "log + " + UUID.randomUUID();
String logUuidSubString = logUuid.substring(0, 15);
Log log = Log.of(comment, logUuidSubString);
logRepository.save(log);
if (true) {
throw new RuntimeException("예외 발생! 롤백 !");
}
}
현재 아래와 같은 상황이다. 어떻게 될까?

테스트 코드로 돌려보자.
우리는 트랜잭션을 분리했기 때문에 댓글은 생성되고, Log는 생성되지 않았을 거라 생각한다.

하지만 테스트를 실패함을 볼 수 있다.
결과적으로 Comment까지 함께 롤백이 됨을 알 수 있다.
그 이유는 뭘까?

전체 롤백이 발생한 이유
- LogService.registerLog() 메서드가 새로운 트랜잭션으로 시작되었지만, 이 트랜잭션 내에서 예외가 발생하면 해당 예외가 상위 메서드(CommentService.registerComment())로 전파된다.
- 상위 메서드인 registerComment()는 새로운 트랜잭션을 생성했기 때문에, 이 트랜잭션 내에서도 예외 발생 시 롤백이 일어난다.
- 즉, @Transactional(propagation = Propagation.REQUIRES_NEW)로 별도의 트랜잭션을 만들었더라도, 그 트랜잭션 내에서 발생한 예외가 상위 트랜잭션으로 전파되며, 상위 트랜잭션도 롤백된다.
- 그렇기 때문에 예외를 잡아서 없애줘야한다. 예외를 먹어야한다!
3. REQUIRES_NEW, 트랜잭션 분리, 예외 Catch
/* CommentService */
@Transactional
public void registerCommentLogExRequiresNewCatchEx(Comment comment) {
commentRepository.save(comment);
logService.registerLogCatchError(comment);
}
/* LogService */
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void registerLogCatchError(Comment comment) {
try {
String logUuid = "log + " + UUID.randomUUID();
String logUuidSubString = logUuid.substring(0, 15);
Log log = Log.of(comment, logUuidSubString);
if (true) {
throw new RuntimeException("예외 발생! 롤백 !");
}
logRepository.save(log);
} catch (Exception e) {
System.out.println("에러 잡음! 정상 흐름 변환");
}
}


테스트를 성공함을 알 수 있다. 그를 저장하다 예외가 발생하더라도 현재 댓글은 정상적으로 생성된다.
하지만 필자는 이것을 몰랐을 때 예외를 캐치하지 않으면서 예외를 터뜨렸다.
왜 안될까 이것저것 시도해보다가 구글링을 해보니 @Async로 해결할 수 있다고 했다.
그래서 @Async와 @TransationalEventListener를 알게 되었고 이를 적용했다.
/* CommentService */
@Transactional
public void registerCommentAsyncTxEventListener(Comment comment) {
commentRepository.save(comment);
// logService.registerLogCatchError(comment);
eventPublisher.publishEvent(comment);
}
/* LogService */
@Async
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void registerLogTxEventListener(Comment comment) {
log.info("registerCommentAsyncTxEventListener 호출");
String logUuid = "log + " + UUID.randomUUID();
String logUuidSubString = logUuid.substring(0, 15);
Log log = Log.of(comment, logUuidSubString);
if (true) {
throw new RuntimeException("예외 발생! 롤백 !");
}
logRepository.save(log);
}

테스트가 성공함을 알 수 있다.

실행 동작은 위와 같다. 댓글이 정상적으로 생성되었을 때 이벤트를 발행하고, LogService는 그 이벤트를 기다리고 있다가 본인만의 별도의 물리 트랜잭션을 시작한다.
나는 이전에 이렇게 분리를 해서 알림 기능을 완성했었다.
실행 동작을 더 자세히 살펴보자. 댓글이 생성되는 메서드는 아래와 같이 트랜잭션이 걸려 있다.
@Transactional
public void registerComment(Comment comment) {
commentRepository.save(comment);
eventPublisher.publishEvent(new CommentEvent(comment));
}
@TransactionalEventListener에서 커밋이 안되는 이유
// 1. 트랜잭션 시작 지점
TransactionSynchronizationManager.initSynchronization();
// → ThreadLocal에 빈(empty) List<TransactionSynchronization> 생성
Connection conn = DataSourceUtils.getConnection(dataSource);
// → 커넥션 풀에서 Connection 획득
ConnectionHolder holder = new ConnectionHolder(conn);
TransactionSynchronizationManager.bindResource(dataSource, holder);
// → ThreadLocal에 <dataSource, holder> 바인딩
트랜잭션이 시작되면 ThreadLocal에 빈 리스트가 생성되고, 데이터베이스에서 얻어온 커넥션이 스레드에 바인딩된다.
// 2. 도메인 이벤트 발행 시점
applicationEventPublisher.publishEvent(new CommentEvent(comment));
// → TransactionalApplicationListener.onApplicationEvent() 호출
TransactionalApplicationListener listener = new TransactionalApplicationListener(...);
listener.onApplicationEvent(event);
// → 내부에서 다음을 실행해 afterCommit 콜백 등록
TransactionSynchronizationManager.registerSynchronization(listener);
// → ThreadLocal에 listener 인스턴스가 List<TransactionSynchronization>에 추가
이벤트가 발행되면, 스프링은 트랜잭션이 커밋된 직후 실행할 콜백을 등록한다.
// 3. 트랜잭션 커밋 (DataSourceTransactionManager.commit)
for (TransactionSynchronization sync : TransactionSynchronizationManager.getSynchronizations()) {
sync.beforeCommit(false);
}
conn.commit();
// → DB에 댓글 저장 커밋 완료
for (TransactionSynchronization sync : TransactionSynchronizationManager.getSynchronizations()) {
sync.afterCommit();
// → afterCommit 콜백 → eventListener(event) 실행
}
여기서 중요한 부분이 등장한다. 트랜잭션이 DB에 커밋되었지만, 아직 트랜잭션 리소스는 ThreadLocal에서 제거되지 않은 상태이다. 따라서 이때 @Transactional 메서드를 호출하면 기존 트랜잭션에 참여하게 되지만 이미 커밋이 완료된 트랜잭션이므로 정상적인 추가 저장이 불가능하고, 풀 반환 시 롤백된다. 그러므로 @TransactionalEventListener 범위 내에서 @Transactional을 사용하면 기본 값이 REQUIRED 이기 떄문에 이미 커밋이 완료된 트랜잭션에 합류하여 무조건 롤백이 된다. 아래를 자세히 살펴보자.
이 문제를 해결하기 위해 아래와 같이 @Async를 사용했다.
@Async
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void eventListener(CommentEvent event) {
logService.saveLog(event.getComment());
}
@Async를 사용하면 기존 스레드와 별도의 새로운 스레드에서 실행되기 때문에 기존 트랜잭션 컨텍스트가 영향을 주지 않는다. 따라서 새 스레드에서는 트랜잭션 컨텍스트가 존재하지 않기 떄문에 새로운 커넥션 풀을 가져와 새로운 트랜잭션을 시작한다. 다시 말해, 새로운 물리적 트랜잭션이 시작되어 정상적으로 커밋될 수 있다.
// 4. 트랜잭션 정리 (afterCompletion)
for (TransactionSynchronization sync : TransactionSynchronizationManager.getSynchronizations()) {
sync.afterCompletion(TransactionSynchronization.STATUS_COMMITTED);
}
TransactionSynchronizationManager.clearSynchronization();
// → ThreadLocal에서 List 제거
// 5. 커넥션 언바인드 & 풀 반환
ConnectionHolder conHolder = (ConnectionHolder)
TransactionSynchronizationManager.unbindResource(dataSource);
// → ThreadLocal에서 <dataSource, holder> 제거
DataSourceUtils.releaseConnection(conn, dataSource);
// → conn.close() → 커넥션 풀에 반환
즉, Async의 사용 목적은 단순히 성능 향상이 아니라 트랜잭션 이벤트 리스너 내부의 트랜잭션 참여 문제를 정확히 해결하기 위한 것이었다. 결과적으로 댓글 저장과 로그 저장 트랜잭션을 완벽하게 분리하여, 트랜잭션이벤트 리스너를 통한 안정적인 비동기 처리를 달성하였다.
참고 자료
https://xxeol.tistory.com/55
[Spring] REQUIRES_NEW와 데드락 위험성
Requires_new is king of side effects. c.c) spring-framework 이슈 “REQUIRES_NEW는 부작용 대장이다.”라는 말에는 그 이유가 있다. REQUIRES_NEW 전파 속성은 새로운 트랜잭션을 시작하는 기능을 가지며, 그로 인해
xxeol.tistory.com
https://seongonion.tistory.com/166
왜 @TransactionalEventListener에서 커밋이 안될까?
문제상황@TransactionalEventListner를 사용하는 코드에서 문제가 발생했다. 문제를 유발한 코드는 아래와 유사했다.@Service@RequiredArgsConstructorpublic class GroupService { private final GroupRepository groupRepository; pri
seongonion.tistory.com
'프로젝트 > 똑립' 카테고리의 다른 글
| 똑립 3. 멀티쓰레드 환경에서 동시성 제어. 분산락 적용 (0) | 2024.09.08 |
|---|---|
| 똑립 1. 알림기능, 문제의 시작 (0) | 2024.04.20 |