@TransactionalEventListener는 왜 조용히 무시될까

기술 블로그

어느 날 검색 색인에 빠진 문서가 하나 발견됐습니다. 저장은 분명히 됐고, 화면에도 정상으로 떠 있는데, 검색으로는 잡히지 않았습니다. 저장이 끝나면 색인을 갱신하도록 이벤트를 발행하고, 그 이벤트를 받는 리스너가 색인을 다시 쓰게 돼 있었습니다. 그런데 이 문서에 한해서는 그 리스너가 실행된 흔적이 없었습니다. 예외도 없고, 실패 로그도 없었습니다. 리스너 안에 찍어 둔 로그조차 남지 않았으니, 리스너가 실패한 게 아니라 애초에 호출되지 않은 것이었습니다.

코드는 그대로였고, 바로 전날까지 같은 경로로 저장된 문서들은 멀쩡히 색인돼 있었습니다. 다른 점은 하나였습니다. 문제의 문서는 평소의 저장 경로가 아니라, 운영자가 콘솔에서 직접 상태를 바꾼 경로로 저장됐습니다. 같은 저장 메서드를 호출했는데, 어느 경로로 들어왔느냐에 따라 뒤따르는 색인이 실행되기도 하고 조용히 사라지기도 한 것입니다. 그 갈림을 만든 것은 @TransactionalEventListener라는 한 줄이었습니다.


'커밋 이후'가 아니라 '트랜잭션이 있을 때만'

@TransactionalEventListener(phase = AFTER_COMMIT)을 처음 볼 때 대부분은 이렇게 읽습니다. "이 리스너는 트랜잭션이 커밋된 다음에 실행된다." 부수효과를 본 작업과 분리하고 싶을 때 자연스러운 선택입니다. 결제가 확정된 뒤에 알림을 보내고, 저장이 커밋된 뒤에 색인을 갱신하는 식으로요. 커밋 전에 미리 움직였다가 정작 본 작업이 롤백되면 알림만 헛나가는 사고를 막아 줍니다.

그런데 이 어노테이션의 실제 계약은 "커밋 이후에 실행"이 아닙니다. "활성 트랜잭션이 있을 때만 등록" 입니다. 이벤트가 발행되는 순간 진행 중인 트랜잭션이 있으면, 리스너를 그 트랜잭션의 커밋 이후 단계에 콜백으로 걸어 둡니다. 그리고 커밋이 끝나면 실행합니다. 여기까지는 우리가 읽은 대로입니다.

문제는 발행 시점에 진행 중인 트랜잭션이 없을 때입니다. 걸어 둘 커밋 단계가 없으니, 프레임워크는 이 리스너를 등록하지 않고 그대로 반환합니다. 기본 설정에서는 예외를 던지지도, 경고를 남기지도 않습니다. 내부적으로 남기는 것은 추적(trace) 수준의 로그 한 줄뿐이라, 평소 로그 레벨에서는 보이지 않습니다. 이벤트는 발행됐는데 아무도 받지 않았고, 그 사실을 알려 주는 신호가 어디에도 없습니다.


같은 메서드가 두 경로로 불릴 때 갈라진다

이 계약이 조용한 이유는, 한 메서드가 항상 트랜잭션 안에서만 불린다면 아무 문제가 없기 때문입니다. 늘 트랜잭션이 있으니 리스너는 늘 등록되고 늘 실행됩니다. 함정은 같은 메서드가 두 경로로 불릴 때 열립니다.

앞의 색인 사건이 그랬습니다. 문서를 저장하는 메서드는 두 곳에서 불렸습니다. 하나는 일반 저장 흐름으로, 이 경로는 @Transactional로 감싸여 있어 저장 시점에 트랜잭션이 살아 있었습니다. 그래서 색인 리스너가 커밋 이후로 등록됐고, 정상 실행됐습니다. 다른 하나는 운영자가 콘솔에서 상태를 직접 바꾸는 경로였는데, 이쪽은 트랜잭션 래핑 없이 저장 메서드를 곧장 불렀습니다. 발행 시점에 트랜잭션이 없었으니 리스너는 등록되지 않았고, 색인은 그대로 누락됐습니다.

두 경로는 같은 코드를 지나갑니다. 같은 저장 메서드, 같은 이벤트 발행. 겉으로 드러나는 코드만 보면 두 경로가 다르게 동작할 이유가 없어 보입니다. 차이는 코드가 아니라 호출자가 트랜잭션을 열었는지 여부라는, 그 메서드 바깥의 문맥에 있습니다. 그래서 리스너 코드를 아무리 들여다봐도 원인이 보이지 않습니다.


그렇다고 같은 트랜잭션으로 받으면 다른 함정이 있다

여기서 자연스럽게 드는 생각은 "그럼 트랜잭션 단계를 신경 쓰지 않는 평범한 @EventListener로 받으면 되지 않나"입니다. 트랜잭션이 있든 없든 발행되는 즉시 받으니, 무증상 누락은 확실히 사라집니다. 하지만 이 선택은 다른 함정으로 걸어 들어가는 길입니다.

평범한 @EventListener는 발행이 일어난 트랜잭션 안에서 동기로 실행됩니다. 즉 본 작업과 같은 트랜잭션에 합류합니다. 문제는 리스너가 하는 일이 실패할 수 있을 때입니다. 색인 갱신이나 외부 호출이 예외를 던지면, 그 리스너를 감싼 코드에서 runCatching으로 예외를 삼켜 로그만 남기더라도, 이미 그 트랜잭션은 롤백 전용(rollback-only)으로 표시된 뒤입니다. 예외는 삼켰지만 표시는 남습니다. 그 결과 정작 커밋하려는 순간 바깥에서 UnexpectedRollbackException이 터지고, 실패한 것은 부수효과 하나뿐인데 본 작업 전체가 함께 뒤집힙니다.

그래서 부수효과의 성격에 따라 계약을 갈라 골라야 합니다. 본 작업과 운명을 함께해야 하는, 즉 실패하면 본 작업도 되돌려야 하는 부수효과라면 같은 트랜잭션으로 묶는 @EventListener가 맞습니다. 반대로 본 작업은 이미 확정됐고 부수효과가 실패해도 그 확정을 되돌려선 안 되는 경우라면, 커밋 이후로 미루는 @TransactionalEventListener(AFTER_COMMIT)가 맞습니다. 색인 갱신은 후자입니다. 저장은 이미 끝난 사실이고, 색인이 한 박자 늦거나 재시도로 메워질지언정 저장 자체를 뒤집을 이유는 없으니까요.


무증상 누락을 닫는 한 글자

계약을 커밋 이후로 골랐다면, 남은 것은 앞의 무증상 누락을 막는 일입니다. 이걸 닫는 것이 fallbackExecution 옵션입니다.

@TransactionalEventListener(phase = AFTER_COMMIT, fallbackExecution = true)로 두면, 발행 시점에 활성 트랜잭션이 없을 때 리스너를 버리지 않고 그 자리에서 즉시 실행합니다. 트랜잭션이 있으면 원래대로 커밋 이후에 실행하고, 없으면 발행 즉시 실행합니다. 두 경로 모두 리스너가 돌게 되니, 운영 콘솔처럼 트랜잭션 밖에서 들어온 경로에서도 색인이 누락되지 않습니다. 어노테이션 속성 하나가, 코드 어디에도 드러나지 않던 조용한 누락 경로를 닫습니다.

이 배선의 값어치는 지금 트랜잭션 밖 경로가 있다는 사실보다, 나중에 생길 경로까지 미리 막는다는 데 있습니다. 오늘은 저장 메서드가 트랜잭션 안에서만 불릴 수 있습니다. 하지만 반년 뒤 누군가 배치 스크립트나 새 운영 도구에서 트랜잭션 없이 같은 메서드를 부르는 순간, fallbackExecution이 없다면 그 경로의 부수효과는 예외도 로그도 없이 사라집니다. 그리고 그 사실은 사용자가 "저장은 됐는데 검색에 안 잡힌다"고 문의하기 전까지 아무도 모릅니다. fallbackExecution = true는 그 미래의 무증상 버그를 지금 배선으로 못 박아 두는 방어입니다.

색인처럼 몇 번을 실행해도 결과가 같은 부수효과에서는, 이 옵션이 트랜잭션 밖 경로의 누락을 깔끔히 막아 줍니다. 문제는, 이 옵션이 안전해 보이는 이유가 지금 이 코드의 우연한 생김새에 기대고 있다는 데 있습니다.


반년 뒤, 누군가 발행 밑에 한 줄을 더 답니다

저장 메서드는 이렇게 끝나고 있었습니다. 마지막 줄에서 이벤트를 발행하고, 그게 메서드의 끝이었습니다. 그러다 반년 뒤, 기능을 조금 손보던 누군가가 그 발행 밑에 코드를 한 줄 답니다. 발행한 다음에 상태 플래그를 하나 더 세우는, 지극히 평범한 한 줄입니다. 리뷰에서도 걸릴 게 없습니다. 발행하고, 마무리 한 줄 하고, 끝. 순서가 자연스럽습니다.

그런데 이 한 줄이 트랜잭션 밖 경로에서 시한폭탄이 됩니다. fallbackExecution이 하는 일을 다시 보면, 그건 "누락을 막는다"기보다 "트랜잭션 밖 발행을 그 자리에서 즉시, 발행 스레드에서 인라인으로 실행해 버린다"입니다. 그래서 트랜잭션 밖 경로에서는 발행 그 줄에서 리스너가 곧바로 끝까지 돌고, 그 다음에야 방금 추가한 마무리 한 줄이 실행됩니다. 리스너가 그 마무리를 보지 못한 채, 그리고 커밋이라 부를 것도 없는 상태에서 먼저 달려 버린 것입니다.

같은 발행 지점인데 두 경로의 실행 순서가 정반대가 됩니다. 트랜잭션 안 경로였다면 리스너는 AFTER_COMMIT이라 그 마무리 줄과 커밋을 모두 지나서 실행됐을 겁니다. 우리가 AFTER_COMMIT을 고른 이유가 바로 그것, "변경이 다 끝난 다음에 실행된다"는 보장이었으니까요. 그런데 트랜잭션 밖 경로에서는 그 보장이 조용히 뒤집힙니다. 코드에는 발행이 여전히 맨 밑처럼 보이는데, 리스너는 맨 위에서 도는 셈입니다.

리스너가 방금 쓴 데이터를 다시 읽거나, "이 작업이 확정됐다"는 신호를 외부로 보내는 종류라면, 이 순서 역전은 그대로 정합성 사고입니다. 아직 확정되지 않은 상태를 확정된 것으로 착각하고 움직이니까요. 이때 fallbackExecution은 누락을 막아 준 게 아닙니다. 눈에 보이던 누락을, 눈에 안 보이는 오작동으로 바꿔 놓았을 뿐입니다. 그리고 이 오작동은 그 한 줄을 추가한 사람이 @TransactionalEventListener 근처를 쳐다볼 이유가 전혀 없다는 점에서, 처음의 색인 누락보다 훨씬 찾기 어렵습니다.


그래서 리스너의 성질이 처방을 가릅니다

그러니 먼저 물어야 할 것은 옵션이 아니라 리스너의 성질입니다. 이 리스너가 커밋된 상태에 의존하는가, 아니면 몇 번 실행되든 결과가 같은 멱등한 부수효과인가.

색인 갱신처럼 멱등한 부수효과라면 fallbackExecution = true가 맞습니다. 트랜잭션 밖에서 한 박자 일찍 실행되더라도 색인은 다시 계산하면 그만이고, 얻는 것은 "트랜잭션 밖 발행 경로가 새로 생겨도 누락되지 않는다"는 가용성입니다. 이 경우 지켜야 할 규율은 두 가지입니다. 발행을 메서드의 마지막에 두어 뒤에 딸린 로직이 없게 하고, "이 리스너는 멱등이며 커밋 상태에 의존하지 않는다"는 전제를 주석으로 고정하는 것입니다. 그 전제가 깨지는 순간 이 선택도 함께 무너지기 때문입니다.

반대로 리스너가 커밋된 결과를 읽거나 외부에 확정 신호를 보내는 종류라면, 트랜잭션 밖 발행은 누락이 아니라 애초에 일어나선 안 되는 일입니다. 이때는 그 발행을 즉시 실행으로 삼킬 게 아니라, 발행 지점에서 트랜잭션이 살아 있는지 검사해 없으면 예외로 터뜨리는 편이 낫습니다. TransactionSynchronizationManager.isActualTransactionActive()로 발행 직전에 확인하면, 트랜잭션 밖 발행이라는 규약 위반이 사용자 문의가 아니라 그 자리의 예외로 즉시 드러납니다. 무증상 누락을 조용히 메우는 대신, 잘못된 호출 자체를 배선 단계에서 막는 것입니다. 저희 팀에도 반대 방향으로 "이 작업은 트랜잭션 밖에서만 돌아야 한다"를 같은 방식으로 못 박아 둔 코드가 있어, 이 검사는 낯선 기법이 아니라 이미 쓰던 도구를 방향만 바꿔 적용하는 일이었습니다.

두 처방은 대립하지 않습니다. fallbackExecution은 가용성(누락 방지)을 지키고, 발행 지점의 트랜잭션 검사는 정합성(위상 계약 위반의 조기 노출)을 지킵니다. 어느 쪽을 쓸지는 리스너가 커밋에 의존하는지 하나로 갈립니다.


다시, 검색에 잡히지 않던 문서

처음의 문서로 돌아가면, 색인이 누락된 것은 리스너가 실패해서가 아니었습니다. 리스너가 애초에 등록되지 않아서였고, 등록되지 않은 이유는 그 저장이 트랜잭션 밖에서 일어났기 때문이었습니다. @TransactionalEventListener를 "커밋 이후에 실행"으로 읽으면 이 경로는 설명되지 않습니다. "활성 트랜잭션이 있을 때만 등록"으로 읽어야, 트랜잭션이 없던 그 한 경로에서 왜 아무 일도 일어나지 않았는지가 보입니다.

색인 갱신은 멱등한 부수효과라, 저희는 fallbackExecution = true로 트랜잭션 밖 경로의 누락을 막았고, 같은 문서를 운영 콘솔에서 저장해도 색인은 정상으로 갱신됩니다. 다만 이 선택이 맞았던 것은 색인이 멱등이었기 때문이지, fallbackExecution이 모든 리스너의 누락을 안전하게 메워 주기 때문은 아닙니다. 커밋된 상태를 전제로 움직이는 리스너였다면, 같은 옵션이 오히려 트랜잭션 밖 발행을 조기에 드러냈어야 할 자리를 조용히 덮었을 것입니다.

어느 쪽이든 출발점은 같습니다. @TransactionalEventListener를 "커밋 이후에 실행"이 아니라 "활성 트랜잭션이 있을 때만 등록"으로 정확히 읽는 것. 그 한 줄의 계약을 정확히 읽고 나서야, 트랜잭션 밖 발행을 누락으로 볼지 규약 위반으로 볼지, 그래서 즉시 실행으로 메울지 예외로 막을지를 리스너의 성질에 맞게 고를 수 있습니다.


🚀플렉스팀 채용페이지 바로가기

☕flex Private Talk 신청하기

글이 마음에 드셨나요?
공유하기