해당 문제에 대한 유력한 원인은 생각할 수 있는 원인은 트랜잭션과 @Async의 동작 시점 문제 (동시성 문제)이다.
왜냐하면 에러 로그가 따로 찍히지 않았기 때문에 뱃지 발급 로직에서 동작하는 두 스레드에서는 둘 다 정상으로 처리되었거고, 그렇다면 에러가 날 수 있는 부분은 각 조건이 제대로 체크되지 않은 부분이기 때문이다.
즉 기존의 구현 방식 (async 방식) 에 따르면 ReviewService (트랜잭션 A)는 badgeService.giveReviewBadge()를 호출하고, 호출했다는 사실만 인지한 채 자신의 나머지 일을 계속하게 되고,BadgeService (트랜잭션 B)는 호출을 받자마자 별도의 스레드에서 즉시 실행을 시작하려고 한다.
async 로 동작하기 때문에 저 두 작업은 완전히 별개의 흐름으로 처리되며, 트랜잭션 B가 트랜잭션 A 이후에 시작한다는 보장이 없다.
따라서 트랜잭션 B가 DB에서 리뷰 개수를 조회할 때, 트랜잭션 격리 수준에 의해 아직 커밋되지 않은 트랜잭션의 데이터를 읽지 못하여 현재 기능에서 버그가 발생한 것이다.
이를 해결하기 위해서는 커밋 이후에 실행됨을 보장해주는 장치 가 필요하다. 몇 가지 해결 방안은 아래와 같다.
@TransactionalEventListener: Spring의 내장 이벤트 리스너를 사용하여 트랜잭션 커밋 이후에 비동기 로직을 실행하는 방법.
트랜잭션 아웃박스 패턴: 발행할 이벤트를 DB 내 outbox 테이블에 원자적으로 함께 저장한 뒤, 별도의 프로세스가 이를 읽어 발행하는 매우 신뢰성 높은 패턴.
데이터베이스 트리거: DB 레벨에서 데이터 변경을 감지해 로직을 실행하는 방법. (현대 애플리케이션에서는 비즈니스 로직이 DB에 종속되어 거의 사용하지 않음)
최종적으로 @TransactionalEventListener 와 데이터 보정을 위한 배치 작업을 조합하는 방식을 선택하였다. 그 이유는 다음과 같다.
뱃지/업적 기능은 서비스의 핵심 기능이 아니며, 약간의 지연이 사용자 경험이 치명적이지 않다. 특히나 많은 다른 서비스에서 “바로 반영되지 않을 수 있습니다”라는 문구를 사용한다.
현재 1인 프로젝트에 가까운 상황에서, 아웃박스 패턴이나 메시지 큐 같은 새로운 인프라를 도입하는 것은 개발 및 운영 리소스 측면에서 부담이 크다. @TransactionalEventListener 와 데이터 보정을 위한 배치 작업을 조합하는 방식을 사용하면 Spring 의 내장 기능만으로 구현할 수 있다.
아웃박스 패턴은 모든 이벤트에 대해 추가적인 DB INSERT와 주기적인 SELECT(폴링) 부하를 발생시킨다. 1. 처럼 서비스의 핵심 기능이 아닌 기능이 성능 부하를 발생시키는건 적절하지 않다.
기존 로직에서 발생했었던 문제를 시스템의 동작 구조를 수정함으로서 해결하였다. 특히나 그 과정에서 스템의 결합도를 낮추고 확장성을 확보할 수 있었다. 이를 다이어그램으로 나타내면 다음과 같다.
개선 전
개선 후
또한 이번 이슈를 통해 트랜잭션의 생명주기와 격리 수준이 비동기 처리와 만났을 때, 우리의 코드 로직과 다르게 동작할 수 있다는 점을 확인하였고, 비동기 작업의 수행은 예측 불가능하는 일이 많기 때문에, 데이터 정합성을 깨뜨리지 않기 위한 패턴들을 조사하며, 작업에 대한 안전 장치를 마련해야 한다는 것울 수 있었다.
TransactionalEventListener + 배치 작업으로 회원 뱃지 발급 기능 구현하기