숙박과 장기 임대 도메인에서 개발을 하다 보면 거주자와 숙박 이용자에게 알림을 보낼 일이 자주 있습니다. 그중에서도 계약 관련 알림은 일부 누락되어도 되는 정도가 아니라 반드시 고객에게 도달해야 하는 알림입니다. 계약 갱신·만료 같은 통지가 누락되면 분쟁의 소지가 됩니다.
그런데 "DB에 계약 상태를 저장하는 것"과 "고객에게 알림을 발행하는 것"은 서로 다른 시스템입니다. 이 둘을 어떻게 원자적으로 묶어 알림을 '반드시' 내보낼 것인가 — 이 문제의 해법으로 자주 언급되는 것이 Transactional Outbox 패턴입니다.
저는 학습 대상으로 namastack-outbox를 골랐습니다. Spring Modulith가 2.1부터 outbox 기반 이벤트 외부화(externalization) 백엔드로 공식 채택한 라이브러리입니다. (Spring Modulith 2.1 M2 릴리스 노트에 spring-modulith-starter-namastack로 소개됩니다.) 모듈러 모놀리스에서 outbox 전용 코드 없이 트랜잭션 이벤트 보장을 얻을 수 있다는 점 때문에 내부 구현을 직접 확인하고 싶었습니다. (컬리 기술블로그에서도 언급이 있었습니다.)
코드를 읽다가 버그 하나를 발견하고 두 개의 PR을 머지하게 된 기록입니다.
1. 배경: Transactional Outbox는 왜 필요할까
먼저 패턴을 정리합니다. 흔한 시나리오는 다음과 같습니다.
1. 계약 상태를 DB에 저장한다 (트랜잭션 커밋)
2. "계약 갱신 안내" 알림을 고객에게 발행한다 (메시지 브로커 등)문제는 이 둘이 서로 다른 시스템이라는 점입니다. 하나의 비즈니스 동작 안에서 DB와 메시지 브로커, 두 곳에 각각 써야 하는데 microservices.io는 이를 이중 쓰기 문제(dual write problem) 라고 부릅니다. 1번 커밋 직후 애플리케이션이 죽으면 2번이 실행되지 않아 DB엔 계약 변경이 남았는데 고객은 안내를 못 받고, 반대로 2번을 먼저 보내고 1번이 롤백되면 실제로는 바뀌지도 않은 계약에 대한 유령 알림이 나갑니다.
둘을 하나의 분산 트랜잭션으로 묶는 방법은 현실적이지 않습니다. DB와 메시지 브로커를 아우르는 2PC(2단계 커밋)는 적절한 선택지가 아닙니다. Kafka 같은 브로커가 XA 트랜잭션을 제대로 지원하지 않을뿐더러, 2PC 자체가 가용성을 떨어뜨립니다. 필요한 보장은 다음과 같습니다.
DB 트랜잭션이 커밋되면 메시지는 반드시 발행되고, 롤백되면 절대 발행되지 않는다. (microservices.io의 표현으로는 "메시지는 DB 트랜잭션이 커밋된 경우에 한해(if and only if) 발행된다.")
Transactional Outbox는 이 요구를, 메시지 브로커를 트랜잭션 바깥으로 빼내 충족합니다.
- 비즈니스 데이터와 발행할 메시지(outbox 레코드)를 같은 로컬 트랜잭션 안에서
OUTBOX테이블에 함께 저장한다. 둘 다 같은 DB이므로, 하나의 ACID 트랜잭션으로 원자적으로 커밋된다. - 메시지 릴레이(Message Relay) 라 불리는 별도 프로세스가
OUTBOX테이블을 읽어, 저장된 레코드를 실제 브로커로 발행(혹은 핸들러 실행)한다. - 성공하면 레코드를 완료 처리하고, 실패하면 재시도 정책에 따라 다시 시도한다.
핵심은 "무엇을 할지"를 일단 DB에 안전하게 적어두고, "실행"은 메시지 릴레이가 나중에 별도로 보장한다는 것입니다. 메시지 릴레이를 구현하는 방식은 크게 두 가지입니다.
| 방식 | 동작 | 특징 |
|---|---|---|
| Polling Publisher | 릴레이가 OUTBOX 테이블을 주기적으로 폴링해 미처리 레코드를 발행 |
구현이 단순. 폴링 주기만큼의 지연과 DB 부하 |
| Transaction Log Tailing (CDC) | DB 트랜잭션 로그(binlog 등)를 추적해 outbox 삽입을 감지·발행 | 지연이 적음. Debezium 같은 CDC 인프라 필요 |
이번에 뜯어본 namastack-outbox는 이 중 Polling Publisher 방식입니다.
한 가지 주의할 점이 있습니다. 이 패턴이 보장하는 것은 at-least-once(최소 한 번) 입니다. 릴레이가 메시지를 발행한 뒤 "완료" 처리 직전에 죽으면, 재시작 후 같은 레코드를 한 번 더 발행할 수 있습니다. 그래서 microservices.io도 "메시지 릴레이가 메시지를 두 번 이상 발행할 수 있다"고 명시하며, 컨슈머를 멱등(idempotent)하게 설계할 것을 전제로 둡니다.
이번 글의 버그는 이 ID와 관련됩니다. outbox 레코드는 "이 레코드를 처리할 핸들러가 누구인지"를 문자열 ID로 들고 있습니다. 폴링으로 꺼낸 레코드를 올바른 핸들러로 연결하려면 이 ID가 언제나 같은 값으로 안정적이어야 합니다. 버그는 이 지점에 있었습니다.
2. 문제: 핸들러 ID가 프록시 이름을 쓰고 있다
namastack-outbox에서 핸들러 ID를 만드는 코드는 BaseHandlerMethod에 있었습니다.
// io.namastack.outbox.handler.method.BaseHandlerMethod
protected fun buildId(): String {
val className = bean::class.java.name // ← 클래스명을 런타임 클래스에서 가져온다
val methodName = method.name
val paramTypes = method.parameterTypes.joinToString(",") { it.name }
return "$className#$methodName($paramTypes)"
}ID는 클래스명#메서드명(파라미터타입) 형태입니다. 클래스명을 bean::class.java.name으로 가져오고 있었는데, 핸들러 빈에 @Transactional이 붙어 있으면 이 bean은 원본 객체가 아니라 CGLIB 프록시일 수 있다는 점이 문제였습니다.
Spring은 @Transactional 같은 AOP가 적용된 빈을 프록시(Proxy) 로 감쌉니다. 이 프록시를 만드는 방식은 두 가지입니다.
| 방식 | 언제 쓰이나 | bean::class.java.name이 돌려주는 값 |
|---|---|---|
| JDK 동적 프록시(JDK Dynamic Proxy) | 빈이 인터페이스를 구현하고 proxyTargetClass=false일 때 |
jdk.proxy2.$Proxy123 — 원본과 전혀 무관한 이름 |
| CGLIB 프록시 | 인터페이스가 없거나 proxyTargetClass=true일 때 |
com.example.MyHandler$$SpringCGLIB$$0 — 원본을 상속한 서브클래스 |
JDK 동적 프록시는 같은 인터페이스를 구현한 별도 객체를, CGLIB는 원본 클래스를 상속한 서브클래스를 런타임에 만들어 냅니다. Spring Boot는 2.0부터 proxyTargetClass=true가 기본값이라 대개 CGLIB이 쓰이지만, 중요한 건 어느 쪽이든 bean::class.java.name이 원본 클래스명을 돌려주지 않는다는 점입니다. CGLIB이면 원본 이름 뒤에 접미사가 붙고, JDK 프록시면 아예 com.sun.proxy.$Proxy123처럼 핸들러와 무관한 이름이 나옵니다.
이 글의 핸들러 빈은 CGLIB로 감싸진 경우라, bean::class.java.name은 이런 값을 돌려주고 있었습니다.
com.example.MyHandler$$SpringCGLIB$$0이게 왜 문제냐면, 이 ID는 DB에 그대로 저장되어 outbox 레코드가 핸들러를 찾는 키로 쓰이기 때문입니다. 핸들러 ID가 안정적이지 않으면 다음과 같은 일이 벌어집니다.
@Transactional을 새로 붙이거나 떼면 → 프록시 적용 여부가 바뀌고 → 클래스명이 바뀌고 → ID가 바뀝니다.- 그러면 이미 DB에 쌓여 있던 outbox 레코드의 ID(
...$$SpringCGLIB$$0#...)와 새로 등록된 핸들러의 ID가 어긋나, 재시도 정책 조회(retryPolicyRegistry.getByHandlerId())가 깨집니다.
$$SpringCGLIB$$0의 숫자 접미사는 프록시 생성 순서 등에 따라 달라질 수 있는 불안정한 식별자입니다. 영속화되는 ID의 재료로 쓰기에는 적합하지 않습니다. 환경에 따라 가끔 발생하는 버그가 아니라, AOP 설정을 변경하는 순간 조용히 깨지는 문제였습니다.
3. 첫 번째 PR (#237): 진짜 클래스 이름을 사용하자
이 라이브러리 안에는 이미 프록시를 벗겨내는 유틸이 있었습니다. Spring이 제공하는 AopProxyUtils.ultimateTargetClass()를 감싼 ReflectionUtils.getTargetClass()입니다. ID를 만들 때 이것을 쓰도록 한 줄만 바꾸면 됩니다.
// AS-IS
val className = bean::class.java.name
// TO-BE
val className = ReflectionUtils.getTargetClass(bean).name // ← 프록시 뒤의 실제 타겟 클래스이렇게 하면 @Transactional이 붙든 안 붙든 핸들러 ID는 언제나 원본 클래스명(com.example.MyHandler#handle(java.lang.String))으로 안정적으로 고정됩니다.
테스트는 "CGLIB 프록시로 감싼 빈과 원본 빈이 같은 ID를 만드는가"를 검증하도록 작성했습니다.
@Test
fun `handler ID uses target class name not CGLIB proxy class name`() {
val proxiedBean = createCglibProxy(targetBean)
val handlerFromTarget = TypedHandlerMethod(targetBean, method)
val handlerFromProxy = TypedHandlerMethod(proxiedBean, method)
assertThat(handlerFromProxy.id).isEqualTo(handlerFromTarget.id)
}
@Test
fun `handler ID contains original class name not proxy class name`() {
val proxiedBean = createCglibProxy(targetBean)
val handlerFromProxy = TypedHandlerMethod(proxiedBean, method)
assertThat(handlerFromProxy.id).contains("TestHandler")
assertThat(handlerFromProxy.id).doesNotContain("CGLIB")
assertThat(handlerFromProxy.id).doesNotContain("$$")
}이슈(GH-238)를 먼저 등록하고, 본문 한 줄 + 테스트로 PR을 올렸습니다. 변경량은 적었지만 리뷰 과정에서 추가로 확인할 점이 있었습니다.
4. 리뷰에서 날아온 지적: 작업이 하위 호환을 깨는 변경이다
첫 PR(#237)이 머지된 직후, 메인테이너(@rolandbeisel)가 제가 놓치고 있던 문제를 짚었습니다.
"이건 사실 breaking change입니다. 이미 옛 ID(CGLIB 프록시 이름)를 참조하는 outbox 레코드가 DB에 남아 있는 상태에서 업데이트 후 애플리케이션을 재시작하면, 그 옛 ID로는 핸들러를 못 찾습니다."
저는 "앞으로 만들어질 ID"만 고려했고, 이미 DB에 쌓여 있는, 아직 처리되지 않은 레코드를 놓치고 있었습니다.
- 업그레이드 전 저장된 레코드의 ID:
com.example.MyHandler$$SpringCGLIB$$0#handle(...)(옛 프록시 이름) - 업그레이드 후 등록되는 핸들러의 ID:
com.example.MyHandler#handle(...)(안정적인 새 이름)
둘이 매칭되지 않으니, 업그레이드 시점에 미처리 상태로 남아 있던 레코드들은 핸들러를 찾지 못합니다. ID 생성 규칙을 바꾼 것이 기존 데이터와의 단절을 만든 것입니다.
저는 두 가지 해법을 제안하고 후속 PR을 열겠다고 했습니다.
- 등록 시 두 ID(프록시 이름 + 실제 이름)를 모두 저장해 옛 레코드도 매칭되게 한다.
- 1차 조회 실패 시 CGLIB 접미사를 떼고 재조회하는 fallback을 둔다.
메인테이너는 1번 방향을 권했습니다. "안정적인 ID와 레거시 ID 둘 다로 핸들러를 등록하고, 조회는 안정 ID를 먼저 본 뒤 없으면 레거시 ID로 떨어지게 하자." 협력자(@Alek96)는 한 가지를 더 짚어줬습니다. *"핸들러 레지스트리뿐 아니라 fallback 레지스트리도 같은 ID를 쓰니 둘 다 처리해야 한다."*
5. 두 번째 PR (#240): 레거시 ID를 별칭으로 함께 등록
방향이 정해진 뒤 구현은 명확했습니다. 먼저 BaseHandlerMethod에 레거시 ID(런타임 클래스명 = 프록시 이름 기반)를 별도로 계산해 들고 있게 했습니다. 안정 ID는 그대로 두었습니다.
/** 런타임 클래스명 기반 ID. CGLIB 접미사($$SpringCGLIB$$0 등)가 포함될 수 있다. */
val legacyId: String = buildLegacyId()
protected fun buildLegacyId(): String {
val className = bean::class.java.name // 프록시면 프록시 이름 그대로
val methodName = method.name
val paramTypes = method.parameterTypes.joinToString(",") { it.name }
return "$className#$methodName($paramTypes)"
}그리고 각 레지스트리에 registerAlias()를 추가했습니다. 일반 register()와 달리, ID 기반 조회 맵에만 별칭을 추가합니다. (타입별 핸들러 목록 등에 중복으로 넣지 않기 위해서입니다.)
// OutboxHandlerRegistry
internal fun registerAlias(aliasId: String, handlerMethod: OutboxHandlerMethod) {
check(handlersById.putIfAbsent(aliasId, handlerMethod) == null) {
"Duplicate alias ID detected: $aliasId"
}
}마지막으로 빈을 스캔해 등록하는 OutboxHandlerBeanPostProcessor에서, 빈이 프록시일 때만(handler.id != handler.legacyId) 레거시 별칭을 모든 레지스트리에 함께 등록하도록 했습니다.
// d. 빈이 AOP 프록시일 때만 레거시 별칭 등록 (하위 호환)
if (handler.id != handler.legacyId) {
registerLegacyAliases(handler.legacyId, result)
}private fun registerLegacyAliases(legacyId: String, result: HandlerScanResult) {
val handler = result.handler
handlerRegistry.registerAlias(legacyId, handler)
result.fallback?.let { fallback ->
fallbackHandlerRegistry.registerAlias(legacyId, fallback) // ← Alek96이 짚어준 부분
}
retryPolicyScanners
.mapNotNull { it.scan(handler) }
.forEach { policy -> retryPolicyRegistry.registerAlias(legacyId, policy) }
}여기서 한 가지를 추가로 처리했습니다. 메인테이너 토론에서는 핸들러 레지스트리와 fallback 레지스트리만 언급됐지만, 레거시 ID를 가진 레코드는 재시도 정책 조회도 동일하게 작동해야 합니다. 그래서 OutboxRetryPolicyRegistry에도 registerAlias()를 더했습니다. PR 본문에 "불필요하면 빼겠다"고 적어 두었는데, 협력자가 "Good catch" 로 승인해 주었습니다.
이렇게 하면 업그레이드 후에도 흐름이 끊기지 않습니다.
업그레이드 후, 옛 레코드(ID = ...$$SpringCGLIB$$0#handle)가 처리될 때
1. 안정 ID로 핸들러 조회 → 없음
2. 레거시 ID(별칭)로 조회 ← 별칭으로 등록해 둔 덕분
3. 핸들러 + fallback + 재시도 정책 모두 정상 해석테스트로는 ▲프록시 빈일 때 두 ID가 같은 핸들러로 해석되는지 ▲프록시가 아닌 빈엔 별칭이 안 생기는지 ▲fallback·재시도 정책 별칭이 제대로 도는지를 검증했고, 커버리지 100%로 1.2.0 마일스톤에 머지됐습니다.
6. 마무리
이번 기여에서 정리한 점은 다음과 같습니다.
- 변경 전에 영향도를 파악한다. 이번 수정은
bean::class.java.name한 줄을 바꾸는 것이 전부였습니다. 그러나 이 한 줄이 만든 ID는 outbox 레코드로 DB에 저장되고, fallback 레지스트리와 재시도 정책 조회(retryPolicyRegistry.getByHandlerId()) 의 키로도 쓰이고 있었습니다. ID 생성 규칙을 바꾸면 이미 저장된 레코드의 옛 ID와 새로 등록되는 핸들러의 ID가 어긋나, 그 레코드들은 핸들러를 찾지 못합니다. 그래서 고치기 전에 "이 값이 DB에 저장되는가? 어떤 레지스트리가 키로 쓰는가? 이미 저장된 값은 그대로 매칭되는가?" 를 먼저 확인해야 했습니다. - 리뷰가 설계를 보강했다. 첫 PR의 breaking change, fallback 레지스트리 별칭, 재시도 정책 별칭 — 이 셋은 제가 아니라 리뷰어(
@rolandbeisel,@Alek96)가 짚어준 지점입니다. 리뷰가 코드 검사를 넘어 설계 보강 역할을 했습니다. - PR #237: Fix handler ID instability with CGLIB proxies
- PR #240: Add legacy alias support for backward compatibility with CGLIB proxy handler IDs