-
@Transactional REQUIRES_NEW 그냥 써도 되나?응애개발일기/Spring 2024. 12. 3. 03:31
문제 상황
[ERROR] [main] [o.h.e.jdbc.spi.SqlExceptionHelper] - Lock wait timeout exceeded; try restarting transaction org.springframework.dao.PessimisticLockingFailureException: could not execute statement [Lock wait timeout exceeded; try restarting transaction]NHN 아카데미에서 프로젝트를 진행하던 중, 회원가입 포인트 적립 기능을 구현하며 발생한 문제이다.
상황은 이렇다.
회원가입 로직이 마무리되고 포인트가 적립이 된다.
만약 회원가입은 성공적으로 마쳤지만 포인트 적립 로직에서 에러가 발생한다면? 현재 Transaction을 Rollback 해야 할까?
팀원들과 함께 회의를 통해 '회원가입', '포인트 적립' 두 가지 로직을 각각 다른 트랜잭션으로 분리하고자 하였다.
이를 통해 적립이 실패해도 회원가입 Transaction이 commit 될 수 있도록 하고 싶었다.
@Transactional 어노테이션의 Propagation type 중, 부모 트랜잭션으로부터 새로운 트랜잭션을 만드는 Requires_new를 적용하여 구현해 보았다.
이론상 완벽했다.
그러나 웬걸 말로만 들어보던 Lock wait timeout이 발생해 버렸다. 그 이유를 알아보자!
문제 상황을 간단하게 구현해 보았다.
// MemberService.java @Service @RequiredArgsConstructor public class MemberService { private final MemberRepository memberRepository; private final PointService pointService; @Transactional public Member createMember(){ // 회원 가입으로 인한 생성. Member member = memberRepository.save(new Member()); // 회원 가입 성공 후 포인트 적립 함수 호출 pointService.createPointRequiresNew(member); return member; } }// PointService.java @Service @RequiredArgsConstructor public class PointService { private final PointRepository pointRepository; @Transactional(propagation = Propagation.REQUIRES_NEW) public void createPointRequiresNew(Member member){ // 방금 생성된 따끈따끈한 멤버를 받아서 500포인트를 적립해준다. pointRepository.save(new Point(500, member)); } }// MemberServiceTest.java @SpringBootTest class MemberServiceTest { @Autowired private MemberService memberService; @Test void createMember_timeOutFailed(){ // 회원가입 서비스 호출 memberService.createMember(); } }
해결 방법
본론부터 말하자면 2가지 해결 방법이 있다.(사실 더 있을 수도 있따..)
1. Point Transaction(자식 Transaction)에 @Async 어노테이션을 붙여 비동기 동작을 하도록 한다.
자식 Thread에서는 부모 Thread의 작업이 끝나 Pessimistic Lock(비관적 Lock)으로 인한 DeadLock이 풀렸을 때 해당 entity에 접근할 수 있다.@Async @Transactional(propagation = Propagation.REQUIRES_NEW) public void createPoint(Member member){ pointRepository.save(new Point(500, member)); }2. @TransactionalEventListner(phase = TransactionPhase.AFTER_COMMIT) 어노테이션을 이용한다.
부모로부터 호출된 Event를 EventHandler가 잡아 자식 Transaction의 로직을 실행해준다. 부모 Transaction이 Commit된 후 자식 Transaction이 실행되어 DeadLock을 피할 수 있다. EventHandler Bean을 새로 구현해주어야 한다.
※ 주의 - 자식 Transaction은 REQUIRES_NEW 선언을 해주어야 한다! 아직 정확한 이유는 모르겠지만 Default Propagation 타입으로 진행한다면 자식 Transaction은 Commit이 되지 않는다..! 이유는 알아볼 예정!@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) public void createPoint(CreatedEvent event) { Member member = event.getMember(); pointService.createPoint(member); }
문제 원인
흔히 CS 공부할 때 배우는 DeadLock 문제이다.
1. 부모 Transaction에서 새로운 데이터 생성을 위해 Member Table Lock을 건다.
2. 해당 Transaction이 끝나지 않아 Lock이 걸려있는 채로 자식 Transaction이 새로 생긴 Member 정보를 가져오기 위해 Member Table에 접근한다.
3. 그렇게 서로의 작업이 끝나기만을 기다리며 DeadLock 상태로 빠지게 된다.
이를 해결해주기 위해 부모 Transaction의 작업이 끝나길 기다려준 후 자식 Transaction의 작업이 실행되어야한다.
끝나길 기다리기 위해 비동기 방식을 적용하거나 TransactionalEventListner 어노테이션의 AfterCommit phase를 이용하여야한다.
회고
Transaction을 공부하던 중 발생한 문제였다.
공부를 하며 ThreadPool 문제로도 TimeWait이 걸렸다는 글을 보았고 에러를 읽지 않고 당연히 해당 문제라고 단정 지었다.
그렇게 삽질을 하고 나니 하나씩 에러 문구가 보이기 시작했다.
다행히 EventLister나 비동기 처리 방식도 고민하고 있던 터라 바로 적용하여 문제를 해결할 수 있었다.
덕분에 아는 문제여도 에러 메시지를 무조건 읽어야겠다는 다짐을 다시 하게 만들어주었다.
그리고 말로만 듣던 DeadLock 상황을 직접 만들어 보니 뭔가 뿌듯했다.
공부 더 해야겠다...