서비스에서 새로운 문제가 등록되거나 자신의 풀이에 대해 댓글이 달릴 경우, 사용자에게 알림이 가도록 설계를 했다. 그래서 댓글 작성 Service에 알림 전송 메서드를 호출하는 로직이 추가되어 있는데, 댓글 작성 Service 계층에서 @Transactional
을 쓰는게 맞을지 의구심이 들었다. 댓글은 정상적으로 등록 됐는데 알림 전송이 안됐다고 트랜잭션을 rollback 시키는게 과연 맞을까? 알림의 중요성을 판단했을 때 나는 같이 rollback 하는 건 옳지 않다고 생각했다. 그래서 댓글 작성에 대해서만 트랜잭션을 어떻게 분리할지 고민했던 내용을 적고자 한다.
먼저 필요한 시나리오는 아래와 같다. (문제 등록에 대해서도 마찬가지로 적용되게 할 것이다.)
1. 댓글 작성 성공, 알림 전송 성공 → 모두 커밋
2. 댓글 작성 실패 → 댓글 작성 롤백, 알림 전송 롤백
3. 댓글 작성 성공, 알림 전송 실패 → 댓글 작성 커밋, 알림 전송 롤백
첫 번째로 생각한 방법은 문제 등록과 알림 전송 두 개의 독립적인 트랜잭션으로 나누는 것으로 생각했다. 그렇게 발견한 것이 @Transactional
의 propagation 개념이었다.
기존 트랜잭션을 진행 중일 때 추가적인 트랜잭션을 진행해야 하는 경우, 추가 트랜잭션 진행을 어떻게 할지 결정하는 개념이 @Transactional
의 propagation 속성이다. 속성에 따라 원래 트랜잭션에 포함되거나 별도의 트랜잭션으로 수행될 수도 있다.
특정 트랜잭션이 다른 트랜잭션을 만나면 위와 같은 구조로 수행된다.
트랜잭션은 DB 영역이기에 커넥션 객체로 처리된다. 따라서 하나의 트랜잭션 당 하나의 커넥션 객체를 이용함을 의미한다. 이를 ‘물리 트랜잭션’이라고 한다.
transaction propagation에 따라, 외부 트랜잭션과 내부 트랜잭션이 동일한 트랜잭션을 사용할 수도 있게 되는데, 그렇게 되면 Spring 입장에서는 트랜잭션 매니저로 2곳을 처리해야 한다. 이로 인해 DB 트랜잭션과 Spring이 처리하는 트랜잭션 영역을 구분하기 위해 ‘논리 트랜잭션’ 개념이 발생한 것이다.
위처럼 외부 트랜잭션과 내부 트랜잭션이 하나의 커넥션 객체를 사용하는 구조가 된다. 트랜잭션 전파 없이 1개의 커넥션 객체만 이용되면 물리 트랜잭션만 존재하게 되고, 트랜잭션 전파가 사용되면 논리 트랜잭션이 사용 되는 것이다.
- 물리 트랜잭션 : 실제 DB에 적용되는 트랜잭션, 커넥션 통해 커밋/롤백 되는 단위
- 논리 트랜잭션 : Spring이나 트랜잭션 매니저 통해 트랜잭션을 처리하는 단위
두 개념을 함께 사용하기 위한 규칙은 아래와 같다.
- 모든 논리 트랜잭션이 커밋 되어야 물리 트랜잭션이 커밋 됨
- 하나의 논리 트랜잭션이라도 롤백되면 물리 트랜잭션은 롤백 됨
default 속성으로, 내부 트랜잭션은 기존의 외부 트랜잭션에 참여하게 되는 성질이다. 각 로직 별 논리 트랜잭션이 되고, 두 논리 트랜잭션을 하나로 묶는 물리 트랜잭션이 있는 것이다.
Spring의 트랜잭션 매니저에 의해 관리되는 논리 트랜잭션이 존재하기에, 내부 커밋 한 번과 외부 커밋 한 번까지 총 2회 실행된다. 내부 커밋은 외부 트랜잭션이 최종 커밋 될 때 실제로 커밋 된다.
롤백의 경우, 내부 트랜잭션에서 롤백 돼도 물리 트랜잭션에서 롤백 될 때 실제로 롤백 된다. 논리 트랜잭션들 중 하나라도 롤백 되면 롤백 된다.
외부 트랜잭션과 내부 트랜잭션을 완전히 분리하는 속성으로, 각 논리 트랜잭션 당 물리 트랜잭션을 사용하게 되어 총 2개의 물리 트랜잭션이 사용된다.
내부 트랜잭션의 롤백 여부가 외부 트랜잭션의 롤백에 영향을 주지 않는다. 내부 트랜잭션이 처리 중일때는 외부 트랜잭션이 대기하는데, 이 때 DB 커넥션을 고갈시킬 수 있으므로 조심해서 사용해야 한다. REQUIRES_NEW 속성의 대안이 존재한다면 그 대안을 사용하는 것이 좋다.
나머지 속성은 위 두개의 활용이므로 나머지를 간단히 설명하면 다음과 같다.
- SUPPORTS
- 트랜잭션이 있으면 지원
- 기존 트랜잭션이 없는 경우 → 트랜잭션 없이 진행
- 기존 트랜잭션이 있는 경우 → 기존 트랜잭션에 참여
- MANDATORY
- 트랜잭션이 반드시 필요
- 기존 트랜잭션이 없는 경우 → IllegalTransactionStateException 예외
- 기존 트랜잭션이 있는 경우 → 기존 트랜잭션에 참여
- NOT_SUPPORTED
- 트랜잭션 없이 진행
- 기존 트랜잭션이 없는 경우 → 트랜잭션 없이 진행
- 기존 트랜잭션이 있는 경우 → 기존 트랜잭션을 보류, 트랜잭션 없이 진행
- NEVER
- 기존 트랜잭션도 허용 안함
- 기존 트랜잭션이 없는 경우 → 트랜잭션 없이 진행
- 기존 트랜잭션이 있는 경우 → IllegalTransactionStateException 예외 발생
- NESTED
- 중첩(자식) 트랜잭션을 생성
- 기존 트랜잭션이 없는 경우 → 새로운 트랜잭션 생성
- 기존 트랜잭션이 있는 경우 → 중첩 트랜잭션 만듦
- 독립적인 트랜잭션을 만드는 REQUIRES_NEW와는 다름!!
- NESTED에 의한 중첩 트랜잭션은 부모 트랜잭션의 커밋/롤백 영향 받지만, 중첩 트랜잭션이 외부에 영향을 주진 않음
- 중첩 트랜잭션이 롤백 돼도 외부 트랜잭션은 커밋 가능
- 외부 트랜잭션이 롤백되면 중첩 트랜잭션은 함께 롤백
- JDBC savepoint 기능을 사용하는데 DB 드라이버가 이를 지원하는지 확인 필요
- JPA에서는 사용 불가
트랜잭션을 분리해야하는 코드는 아래와 같다.
@Transactional
public void createComment(User user, CreateCommentRequest request) {
Solution solution = checkSolutionValidation(user, request.solutionId());
commentRepository.save(Comment.builder()
.user(user)
.solution(solution)
.content(request.content())
.createdAt(LocalDateTime.now())
.build());
String message;
if(request.content().length()<35)
message = request.content();
else
message = request.content().substring(0,35)+"...";
notificationService.send(solution.getUser().getEmail(),
user.getNickname()+"님이 코멘트를 남겼습니다.",
solution.getProblem().getStudyGroup(),
message);
log.info("success to create comment");
}
@Transactional
public void send(String receiver, String message, StudyGroup studyGroup, String subContent){
Notification notification = createNotification(receiver, message, studyGroup, subContent);
notificationRepository.save(notification);
Map<String,SseEmitter> sseEmitter = emitterRepository.findAllEmitterStartWithByEmail(receiver);
sseEmitter.forEach(
(key,emitter) -> {
emitterRepository.saveEventCache(key, notification);
sendToClient(emitter, key, notification);
}
);
}
먼저 별도의 트랜잭션으로 빼줄 send()
의 @Transactional
어노테이션에 propagation으로 REQUIREDS_NEW
를 설정해줬다.
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void send(String receiver, String message, StudyGroup studyGroup, String subContent){
try{
Notification notification = createNotification(receiver, message, studyGroup, subContent);
notificationRepository.save(notification);
Map<String,SseEmitter> sseEmitter = emitterRepository.findAllEmitterStartWithByEmail(receiver);
sseEmitter.forEach(
(key,emitter) -> {
emitterRepository.saveEventCache(key, notification);
sendToClient(emitter, key, notification);
}
);
}catch (Exception e){
log.error("알림 전송에 실패했습니다.",e);
}
}
예외를 알림 전송 메서드에서 catch를 반드시 해야하는데, 만약 하지 않는다면 호출했던 메서드까지 예외가 전파되므로 결국 부모 트랜잭션의 실패로 이어지기 때문이다. 그래서 알림 전송 메서드에서 예외를 잡아주어야 한다.
원래 REQUIRES_NEW
속성으로 propagation을 사용해보려 했다. 하지만 우리 서비스에서 적용되어야 할 시나리오를 다시 보자.
- 댓글 작성 성공, 알림 전송 성공 → 모두 커밋
- 댓글 작성 실패 → 댓글 작성 롤백, 알림 전송 롤백
- 댓글 작성 성공, 알림 전송 실패 → 댓글 작성 커밋, 알림 전송 롤백
댓글 작성 메서드가 부모 트랜잭션으로 존재해야 한다. 알림 전송에서 propagation을 REQUIRES_NEW
로 설정한 경우를 생각해보자. 그렇게 되면 3번 시나리오에서 엣지케이스가 발생한다.
알림 전송 후 댓글 작성 메서드가 완료되기 직전에 예외가 발생하게 된다면, 알림 전송은 댓글 작성과 별개 트랜잭션이기에 그대로 커밋 되고 댓글 작성만 롤백 된다. 즉, 알림 전송 이후 시점에 댓글 작성이 실패되면, 작성 실패한 댓글에 대해 알림은 그대로 전송되는 것이다.
그래서 propagation은 시나리오대로 진행 되지 않을 것이라 판단했고, createComment()
내에서 알림을 전송하는 메서드를 try-catch
로 묶어주는 것도 괜찮은 방법이라고 생각했다. 부모의 영향은 받지만 알림 전송의 성공/실패 여부가 부모에게 영향을 끼치진 않는.. 내가 원하는 시나리오대로 돌아갈 것 같았다.
나중에 전송되지 않은 알림의 경우는 따로 스택에 모아두었다가 추적해서 알림 기록을 해두는 예외 처리를 해야하지 않을까 싶다.
@Transactional
public void createComment(User user, CreateCommentRequest request) {
Solution solution = checkSolutionValidation(user, request.solutionId());
commentRepository.save(Comment.builder()
.user(user)
.solution(solution)
.content(request.content())
.createdAt(LocalDateTime.now())
.build());
String message;
if(request.content().length()<35)
message = request.content();
else
message = request.content().substring(0,35)+"...";
try { // 알림 전송 시도
notificationService.send(solution.getUser().getEmail(),
user.getNickname() + "님이 코멘트를 남겼습니다.",
solution.getProblem().getStudyGroup(),
message);
}catch (Exception e) { // 알림 전송 실패 시 예외 처리
log.info("failed to send comment notification", e);
}
log.info("success to create comment");
}
알림은
RuntimeException
발생으로 실패하고, 댓글은 작성 성공하는 테스트도 통과 됐다.
Transaction propagation은 알림 외에도 다른 기능에서 쓰일만 해 보여서 공부한 김에 정리해보고자 했다. 트랜잭션 관리에 대해서 추가적으로 공부를 더 하고 사용해봐야겠다..!
참고
https://mangkyu.tistory.com/269
https://sh-hyun.tistory.com/143
https://kth990303.tistory.com/387