Replies: 2 comments 5 replies
-
잘 읽었습니다 고생하셨습니다
|
Beta Was this translation helpful? Give feedback.
5 replies
-
해당 내용에 대해 더 더 자세히 다룬 메리의 첫 블로그 글이 있습니다! |
Beta Was this translation helpful? Give feedback.
0 replies
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
-
이벤트에서 트랜잭션을 분리할 때 어떤 어노테이션을 사용해야 할까?
기존 디스커션(이벤트와 트랜잭션 관련)에서 추가적인 고민이 필요한 부분에 대한 추가 설명입니다.
기존 디스커션에서 가졌던 의문점들이 있어서 수정하기보단 2편 느낌으로 새로 작성해봤습니다!
문제 상황
예외가 발생해서 try-catch로 잡아주었는데도 롤백되는 문제가 발생했습니다.
messageService.create()
→notificationService.send()
를 호출messageService.create()
에서notificationService.send()
호출 시 try-catch를 통해 예외를 잡아주고 있음.첫 번째 시도
단순히 트랜잭션 전파 옵션을 사용해 트랜잭션을 분리해주는 것입니다.
트랜잭션을 분리해준다면 새로운 트랜잭션에서 예외가 발생해 트랜잭션이 롤백되더라도 외부 트랜잭션에는 영향을 미치지 않아야 한다고 생각했습니다. 하지만 트랜잭션 전파와 트랜잭션 예외 전파는 다르다는 것을 인지하지 못했고, 따라서 새로운 트랜잭션에서 예외가 발생한다면 기존 트랜잭션에도 예외가 전파됩니다. 따라서 발생하는 예외를 try-catch로 잡아주어야 했습니다.
(실습 자료 참고)
따라서 이벤트 리스너에서 try-catch문을 추가해주게 되었습니다.
이때 이벤트를 발행한 메서드에서 예외 핸들링을 해주지 않은 이유는 예외를 핸들링해주는 역할은 이벤트 리스너에게 종속된다고 판단했기 때문입니다. 관심사 분리를 위해 이벤트 사용한 만큼 이벤트를 발행하는 쪽에서는 ‘이벤트 발행’까지만 책임을 가지고, 이후에 발생하는 일들에 대해서는 트랜잭션이 롤백되거나 심지어 예외가 발생하는 것조차 몰라야 한다고 생각했습니다. 그리고 이것이 이벤트를 사용하는 목적이라고 생각했기 때문입니다.
결론
Propagation.REQUIRES_NEW
사용두 번째 시도
지토의 질문으로 인해 트랜잭션 커밋 시점에 대해 공부하게 되었습니다.
@EventListener
를 사용할 경우 이벤트를 발행한 트랜잭션의 커밋 시점은 발행한 이벤트가 모두 종료된 이후입니다. 알림 기능에서는 추가적인 DB 쓰기 작업이 발생하지 않음에도 불구하고 메시지 저장 트랜잭션은 알림이 API 호출로 인해 네트워크 요청이 발생하고 종료될 때까지 커밋하지 못한 상태로 대기해야 했습니다.이때 두 가지 문제점이 있습니다.
메시지 전송 트랜잭션의 커밋 이후 알림 전송한다는 로직이 보장되지 않습니다.
메시지 전송 트랜잭션이 알림 전송이 종료될 때까지 기다린 이후에 커밋됩니다. 따라서 알림 전송이 발생한 이후에 예기치 못한 에러로 메시지 전송 트랜잭션이 롤백된다고 해도 이미 사용자에게는 알림 전송이 완료된 상태가 됩니다. 즉, DB에 저장된 데이터와 사용자가 전달받게 될 데이터의 정합성이 깨지는 것입니다.
지토가 남겨주신 의견처럼 메시지 알림은 롤백된 데이터에 대한 알림이 가더라도 사용자에게 의문을 불러일으킬 뿐 금전적인 문제로 이어지지는 않습니다. 하지만 입찰의 경우는 다릅니다. 상위 입찰자가 발생했을 때 사용자는 상위 입찰자 발생 알림을 받게됩니다. 상위 입찰자가 발생했다고 생각해 추가적인 입찰을 위해 경매글을 조회했지만 상위 입찰자가 존재하지 않는 경우에는 금전적인 문제로 이어져 메시지보다 더 비즈니스적으로 심각한 문제가 될 수 있습니다.
따라서 입찰 생성 트랜잭션이 정상적으로 커밋이 완료된 이후에 알림을 전송할 수 있도록 기존 커밋을 보장해야 합니다.
불필요하게 트랜잭션이 길어집니다.
앞서 언급한 것처럼 이벤트를 발행한 트랜잭션이 커밋되는 시점은 알림 기능이 종료된 이후입니다. 이때 알림 기능은 네트워크 통신이 발생하는 로직까지 포함하고 있고, 메시지 전송 트랜잭션은 커밋되기까지 그만큼의 시간이 소요되는 것이기 때문에 불필요하게 트랜잭션을 길게 유지하게 되어 비효율적인 트랜잭션이 유지됩니다.
해결 방법
@TransactionalEventListener
어노테이션을 사용합니다. 해당 어노테이션의 실행 시점 기본값은AFTER_COMMIT
입니다. 즉, 이벤트 리스너가 실행되는 시점은 기존 트랜잭션인 메시지 전송 트랜잭션이 커밋된 이후라는 의미입니다. 따라서 이벤트 리스너에서 수행되는 기능에서 예외가 발생하더라도 이미 커밋된 트랜잭션은 롤백되지 않기 때문에 메시지 전송 기능 트랜잭션에서 알림 전송 기능을 완전히 분리할 수 있게 되는 것입니다.다른 말로
@TransactionalEventListener
어노테이션만으로 메시지 전송 기능 트랜잭션에서 알림 전송 기능을 완전히 분리할 수 있게 되었기 때문에 트랜잭션 분리를 위해 사용했던 트랜잭션 전파 옵션Propagation.REQUIRES_NEW
는 더이상 불필요하게 됩니다.여기서 헷갈리지 않아야 하는 점은,
@TransactionalEventListener
를 사용했을 때 트랜잭션이 커밋되는 것이지 트랜잭션이 완전히 종료되는 것은 아닙니다. 따라서 기존 트랜잭션에서 사용한 커넥션으로 DB 조회 작업은 여전히 가능합니다.주의할 점
저희 서비스에서는 현재 알림 로그를 저장하지 않고 있습니다. 즉, 이벤트 리스너에서 호출한 서비스 트랜잭션에서 DB 쓰기 작업이 발생하지 않습니다. 그런데 만약
@TransactionalEventListener
으로 기존 트랜잭션의 커밋 이후 쓰기 작업이 발생하는 트랜잭션을 생성했을 때 생성한 트랜잭션의 전파 속성이REQUIRED
라면 생성한 트랜잭션은 정상적으로 커밋되지 않습니다.그 이유는 이벤트 리스너에서 사용하는 트랜잭션은
REQUIRED
옵션으로 인해 기존 트랜잭션에 참여하게 되기 때문입니다. 그런데 한 번 커밋된 트랜잭션은 이후에 어떤 일이 발생하더라도 롤백되거나 다시 커밋되지 않습니다. 그 이유는 트랜잭션 격리 수준을 유지하기 위해 한 번 커밋된 트랜잭션은 DB에 영구적으로 저장되어야 하기 때문이라고 합니다. (이 부분은 조금 더 근거가 필요할 것 같긴한데.. 우선 현재 내용에선 이정도로도 충분할 것 같아요!)기존 트랜잭션이 커밋된 이후에 실행된다는 점에서 이후 로직과 독립적으로 트랜잭션 커밋이 충분히 보장되지만, 다른 말로 하면 트랜잭션이 종료되었음을 의미합니다. DB 읽기 작업에서는 커밋을 하지 않아도 되기 때문에 종료된 기존 커넥션을 조회 작업에서 사용할 수 있지만, 쓰기 작업은 커밋이 필요하기 때문에 기존에 사용하던 커넥션 사용이 불가능해집니다. 따라서 이후 DB 쓰기 작업을 위해 트랜잭션 커밋이 필요하다면 새로운 트랜잭션을 열어주어야 합니다.
결론적으로
@TransactionalEventListener
를 사용한 경우, 이벤트 리스너에서 추가적인 쓰기 작업이 발생한 경우 트랜잭션을 분리해주어야 하고, 트랜잭션 분리를 위해Propagation.REQUIRES_NEW
옵션을 사용하면 되는 것입니다!(질문.. 영속성컨텍스트에 대해 잘 아시는분..)그런데 이때 주의해야할 점이 있습니다. 새로운 트랜잭션을 열어주어 영속성 컨텍스트가 달라지더라도 기존 트랜잭션을 커밋했다는 점에서 새로운 트랜잭션에서 기존 트랜잭션을 확인할 수 있을 것이라고 생각했는데, 새로운 트랜잭션을 열어준다는 점에서 영속성 컨텍스트가 달라지기 때문에 기존 트랜잭션에서 커밋한 내역을 볼 수 없게 된다고 합니다.
따라서 영속성 컨텍스트가 달라져 기존 커밋 내역을 확인할 수 없게 되어 발생할 수 있는 문제점들을 잘 고려해야합니다. 대표적으로 트랜잭션 분리로 인해 테스트가 깨졌던 사례가 있습니다.
아무튼! 이 부분은 저희 프로젝트에는 해당되지 않는 부분이지만, 추후 이벤트 리스너를 사용해 또다른 쓰기 작업을 진행하게 될 때 고려하면 좋을 것 같습니다.
세 번째 시도
이건 두 번째 시도의 주의점과 관련된 부분이고, 프로젝트와는 관련이 없습니다.
@TransactionalEventListener
에 대해 공부하다 알게 된 내용이라 공유합니다.@TransactionalEventListener
또는@EventListener
를 사용하고 추가적인 쓰기 작업을 위해Propagation.REQUIRES_NEW
옵션을 지정할 경우 데드락 문제가 발생할 수 있습니다.트랜잭션 전파 옵션으로 트랜잭션을 분리한 경우, 트랜잭션이 물리적으로 분리된 것이 아닌 논리적으로 분리된 것입니다. 트랜잭션이 물리적으로 분리되는 것은 트랜잭션의 분리가 곧 스레드의 분리가 되는 것을 의미하고, 트랜잭션이 논리적으로 분리되었다는 것은 새로운 트랜잭션이 생성될 때 새로운 스레드가 생성되는 것이 아닌 새로운 커넥션을 점유하게 되는 것입니다. 즉, 하나의 스레드에서 두 개의 커넥션을 점유하게 되는 것입니다.
이때 데드락이 발생할 수 있습니다. 커넥션 풀에 저장된 커넥션의 개수가 5개라고 가정해봅시다. 이때 메시지 전송 트랜잭션을 위해 5개의 스레드가 동시에 접근해 커넥션을 각 1개씩 점유하게 됩니다. 그리고 이벤트를 발행해
Propagation.REQUIRES_NEW
옵션에 의해 논리적인 트랜잭션이 생성되고, 이를 위해 새로운 커넥션을 가져오려고 시도합니다. 하지만 남아있는 커넥션이 존재하지 않아 5개의 트랜잭션이 모두 다른 트랜잭션이 종료된 이후 커넥션이 반납되기를 기다리다 데드락이 발생하게 됩니다.이처럼
Propagation.REQUIRES_NEW
옵션을 사용해 새로운 트랜잭션을 생성하는 방식은 하나의 스레드에서 두 개의 커넥션을 점유하게 되어 데드락으로 이어질 수 있다는 단점이 있습니다.@TransactionalEventListener
또는@EventListener
모두 이벤트를 수행하는 동안 이벤트를 호출한 쪽의 트랜잭션이 유지되기 때문입니다. (앞서 언급한 것처럼@TransactionalEventListener
을 사용하더라도 트랜잭션 커밋이 완료될 뿐이지 트랜잭션은 유지됩니다.)해결 방법
이를 해결하기 위해
@Async
를 사용할 수 있습니다.@Async
는 물리적으로 트랜잭션을 분리해버리기 때문에 새로운 스레드에 의해 커넥션을 점유하게 됩니다. 따라서 기존 트랜잭션이 새로운 트랜잭션의 종료시점까지 기다릴 필요가 없어지고, 따라서 기존 트랜잭션 하나만 종료되어 커넥션을 반납한다면 새로운 트랜잭션은 기존 트랜잭션이 커넥션을 반납할 때까지 기다렸다 커넥션을 점유하면 되는 것입니다. 따라서 데드락 발생을 피할 수 있게 됩니다.이때 동기적으로 수행되어야 하는 로직의 경우에는
@TransactionalEventListener
를 사용해 기존 트랜잭션이 커밋된 이후 새로운 트랜잭션을 생성할 수 있도록 해야 합니다. (이 부분은 테스트 필요@TransactionalEventListener
와@Async
를 함께 사용한 경우 진짜 커밋될 때까지 기다리는지 테스트를 해봐야 할 것 같아요)Beta Was this translation helpful? Give feedback.
All reactions