2022년 12월에 런칭된 서비스 "어랏"을 개발하며 발생한 버그에 대한 경험을 작성한 글이다.
본인인증과 관련된 꽤 중요한 기능의 버그였는데, 런칭 전에 찾아내어 다행이었다.
아래 링크들을 참고하여 작성하였다.
- https://kwonnam.pe.kr/wiki/springframework/transaction/transactional_event_listener
발단
어랏에서는 사용자가 본인인증을 성공적으로 완료하면, 사내의 여러 서비스(유저, 포인트, 블록체인 등)에 사용자를 온보딩시킨다. 그래야 정상적인 어랏 서비스 이용이 가능하기 때문이다.
최초 본인인증 기능을 구현할 때에는 온보딩 시킬 사내 서비스가 하나였다. 그래서 본인인증이 완료되면 사내 서비스 온보딩 메서드를 직접 호출하는 동기 방식으로 구현했었다.
이후 다른 팀원들이 내가 만든 본인인증 로직에 온보딩 시킬 다른 사내 서비스를 하나 둘 추가하기 시작했다.
점점 가독성이 떨어지고, 한 메소드에서 너무 많은 일들을 처리하게 되어 강결합이 되었다.
런칭 전에는 개선되어야 할 것 같아 백엔드 팀원들과 상의하였고, spring event 방식을 사용하여 리팩토링하기로 했다.
본인인증 트랜잭션 commit이 성공적으로 완료된 후 온보딩 로직이 진행되길 원했기 때문에 @TransactionalEventListener를 사용했고, 리팩토링 작업 자체는 그닥 어렵지 않았다.
대강 아래처럼 동작하도록 개선되었다.
/**
* 본인인증 메소드
* 사용자가 NICE를 통해 본인인증을 성공적으로 완료하면 NICE 측에서 어랏 서버를 콜백
*/
public void verifyIdentity(..) {
// 개인 정보 관련 작업
...
// 본인인증 완료 이벤트 발행
applicationEventPublisher.publishEvent(new IdentityVerificationCompleted(..));
}
/**
* 사내 서비스 온보딩 메소드
* IdentityVerificationCompleted 이벤트 수신
*/
@TransactionalEventListener
public void registerUsers(IdentityVerificationCompleted event) {
// Feign을 통해 사내 서비스 온보딩
RegisterUserRequest request = RegisterUserRequest.prepare(..);
ClientResponse<Void> response = client.registerUsers(request);
// 사용자의 어랏 포인트 계좌 생성 후 저장
PointAccount pointAccount = PointAccount.create(..);
pointAccountRepository.save(pointAccount);
log.info("[Identity Verification] receive IdentityVerificationCompleted event in registering user");
}
리팩토링 후 로컬 환경에서 이것저것 테스트 해보고, 코드 리뷰를 받고, 개발 환경으로 머지시켰다. (-> 문제점)
전개
며칠 뒤, QA에서 신규 계정으로 본인인증을 했는데 앱 내에서 포인트를 확인할 수 없다는 버그 티켓이 올라왔다.
이슈를 파악해보니 포인트 계좌 뿐만 아니라, 본인인증을 하면 어랏 서버 내에 저장되어야 하는 데이터가 생성되지 않았다.
리팩토링 전후로 바뀐 부분은 메소드 호출 방식뿐이라, 가장 먼저 @TransactionalEventListner가 의심되었다.
문제는 @TransactionalEventListner를 팀원들과의 리팩토링 과정 논의 자리에서 처음 듣고 사용해본 것이라, 어디서부터 어떻게 디버깅할지 감이 안잡혔다.
일단 이전에 자주 사용해보았던 @EventListner로 바꿔봤더니 잘 동작했다, 오잉..?!
확실히 @TransactionalEventListner가 원인임은 알게 되었다.
다시 @TransactionalEventListner로 돌려놓고, TransactionSynchronizationManager의 isActualTransactionActive() 메서드를 통해 실제로 트랜잭션이 활성화 되어 있는 상태를 확인했다.
응 활성화 되어있다..
열심히 헤매고 있는데, 팀원 분이 권남님 블로그를 찾아주셨고 아래와 같은 내용을 볼 수 있었다.
주의 사항
- AFTER_COMMIT phase 에서 JPA Entity에 수정을 가해봐야 반영이 안 된다. 이미 커밋한 상태이고 트랜잭션자체는 기존 것을 유지하고 있다. 기존 JPA Entity 뿐만 아니라 신규 트랜잭션도 실행되지 않는다.
즉, @TransactionalEventListner의 옵션 중 phase의 default 값인 AFTER_COMMIT을 사용하면,
이벤트 발행 시의 트랜잭션 내용은 모두 commit 되지만 트랜잭션 리소스 자체는 계속 살아있고,
이벤트 수신 측에서는 해당 트랜잭션 리소스를 사용하지만 그 뒤 어떠한 데이터 변경이 일어나도 commit이 되지 않는다고 한다.
@TransactionalEventListener의 공식 문서도 뒤져보았다.
phase 옵션의 타입인 TransactionPhase 문서에서 AFTER_COMMIT은 AFTER_COMPLETION의 구체화 된 기능이고,
AFTER_COMPLETION을 좀 더 자세히 알고자 하면 TransactionSynchronization이라는 인터페이스의 afterCompletion(int) 문서를 살펴보라고 작성되어 있어 가보니..
아래와 같은 NOTE가 적혀있었다.
NOTE: The transaction will have been committed or rolled back already, but the transactional resources might still be active and accessible. As a consequence, any data access code triggered at this point will still "participate" in the original transaction, allowing to perform some cleanup (with no commit following anymore!), unless it explicitly declares that it needs to run in a separate transaction. Hence:
Use PROPAGATION_REQUIRES_NEW for any transactional operation that is called from here.
대강 요약하자면,
트랜잭션 리소스는 계속 살아있으며 접근 가능하고, 이 시점에서의 모든 데이터 변경 코드 역시 기존 트랜잭션에 계속 참여되지만,
이러한 데이터 변경은 별도의 트랜잭션에서 진행되어야 함을 명시적으로 선언하지 않는 한, 변경 내역은 commit되지 않는다.
그러므로 PROPAGATION_REQUIRES_NEW를 사용해야 한다고 한다.
결말
해결 방법은 아래 네 가지 정도가 있었다.
우리는 본인인증 트랜잭션이 성공적으로 commit 되어야 한다는 전제가 필요했기 때문에 공식 문서에 적혀있던 것처럼 세번째 방법으로 해결했다.
p.s. 더 시간이 지난 후에는 4번 방식을 사용했다.
(1) @TransactionalEventListner 대신 @EventListner를 사용한다.
(2) @TransactionalEventListner(phase = BEFORE_COMMIT)을 사용한다.
- BEFORE_COMMIT 옵션을 사용하면 이벤트 발행 시의 트랜잭션 내용이 commit 되지 않은 상태로 넘어와, 이벤트 수신 측 코드가 모두 진행된 후에야 commit 된다.
(3) @Transactional(propagation = Propagation.REQUIRES_NEW)를 사용한다.
(4) 비동기(@Aync) 방식을 사용한다.
본문 중 (문제점)이라고 작성한 부분에 대해 좀 더 이야기 해보고 글을 마무리 하고자 한다.
리팩토링 후 로컬 환경에서 이것저것 테스트 해보고, 코드 리뷰를 받고, 개발 환경으로 머지시켰다.(-> 문제점 )
다른 팀원들이 추가한 로직들에 대해 충분히 알아보지 않았기 때문에 완결성이 높은 테스트를 진행하지 못했다.
물론 덩치가 점점 커져가는 서비스의 모든 로직을 다 알기는 힘들지만, 최소 리팩토링 하기 전에 영향을 받을 부분에 대해서는 신중하고 꼼꼼하게 알아봤어야 했다.
설령 그러지 못했더라도, 코드 리뷰를 오프라인 미팅으로 잡는 등의 추가적으로 적극적인 액션이 있었어야 했던 것 같다.
(아무래도 프리랜서 분들이 절반이나 계시다 보니 코드 리뷰가 제대로 이루어지지 않는 어려움이 있기도.. ㅜㅜ)
끝!