병원 이름을 수정한 뒤 원본 테이블에는 새 이름이 보이지만 검색 테이블에는 이전 이름이 남는다고 하자. 서비스 메서드가 정상 반환했다는 사실만으로 검색 갱신의 실행과 커밋을 알 수는 없다. 어노테이션을 하나 더 붙이기 전에 호출이 어느 경계를 통과했는지 나눠야 한다.

질문은 원본 변경이 커밋된 뒤 검색 데이터를 별도 트랜잭션으로 갱신하려면 어떤 실행 조건이 필요한가다. Kotlin/JVM과 Servlet 기반 Spring AOP·JPA, 같은 DB의 검색 테이블을 예로 든다.

서비스 진입과 advice 실행을 따로 확인한다

확인 지점을 서비스 진입, advice 진입, 이벤트 발행·처리, 검색 갱신 커밋으로 나눈다. 같은 요청 식별자와 병원 ID를 연결하고 각 지점의 트랜잭션 활성 여부를 본다. 갱신 결과는 최초 영속성 컨텍스트의 객체 대신 새 트랜잭션에서 읽는다.

첫 번째 빈틈은 서비스 내부 호출이다.

@Target(AnnotationTarget.FUNCTION)
@Retention(AnnotationRetention.RUNTIME)
annotation class RefreshHospitalIndex

@Service
class HospitalService {
    fun updateFromImport(id: Long) {
        update(id) // 같은 객체 내부 호출
    }

    @RefreshHospitalIndex
    @Transactional
    fun update(id: Long): HospitalInfo = changeHospital(id)
}

이 예제는 kotlin-spring이 @Service·@Component 클래스와 메서드를 open 처리하는 조건이다. 플러그인 없이 클래스 기반 프록시를 쓰면 클래스와 가로챌 메서드를 open으로 둔다. RefreshHospitalIndex는 함수 대상·런타임 유지 어노테이션이며 changeHospital과 도메인 타입의 구현은 생략했다.

외부 호출이 updateFromImport()의 프록시를 거쳐도 그 안에서 this.update()를 부르면 내부 호출은 프록시로 다시 들어가지 않는다. 이 경로에서는 update()에 붙은 AOP advice와 프록시 기반 트랜잭션 처리가 실행되지 않을 수 있다. Spring의 프록시 호출 설명이 이 차이를 보여준다.

변경 메서드를 별도 빈의 공개 메서드로 분리하고 주입받은 빈을 통해 호출하면 경계가 드러난다. 직접 생성해 Spring 등록을 거치지 않은 객체인지, 인터페이스 프록시의 계약에 메서드가 포함되는지도 확인한다. 클래스 기반 프록시에서는 final·private 메서드의 제약까지 살핀다.

반환값 바인딩이 맞아도 커밋은 별개다

다음 advice는 변경 메서드의 반환값에서 병원 ID를 꺼내 이벤트를 발행한다.

@Aspect
@Component
class RefreshHospitalIndexAspect(private val events: ApplicationEventPublisher) {
    @AfterReturning(
        pointcut = "@annotation(refresh)",
        returning = "result",
        argNames = "refresh,result",
    )
    fun afterReturning(
        jp: JoinPoint,
        refresh: RefreshHospitalIndex,
        result: HospitalInfo,
    ) {
        events.publishEvent(HospitalChanged(result.id))
    }
}

returning="result"는 advice의 result 매개변수와 연결된다. 반환 타입이 HospitalInfo인 실행을 대상으로 하므로 값을 반환하지 않는 Kotlin Unit 메서드(JVM의 void)를 같은 방식으로 기대하면 안 된다. argNames와 매개변수 이름·타입, 빌드의 이름 보존 설정도 함께 확인한다. 이 예제의 첫 JoinPoint는 Spring의 특별 매개변수 규칙을 사용한다. advice 바인딩 문서를 기준으로 진단한다.

@AfterReturning은 대상 메서드의 정상 반환에 붙는다. 트랜잭션 advice와의 순서에 따라 커밋 전후 위치가 달라질 수 있으므로 이름만 보고 커밋 완료로 해석하지 않는다. 반환 이후 원본 트랜잭션이 실패할 가능성과 이벤트가 발행되는 트랜잭션을 확인해야 한다.

변경 사실은 활성 트랜잭션 안에서 발행한다

명시적인 이벤트 발행은 다음처럼 변경 책임과 가까운 곳에 둔다.

data class HospitalChanged(val hospitalId: Long)

@Service
class HospitalCommandService(
    private val repository: HospitalRepository,
    private val events: ApplicationEventPublisher,
) {
    @Transactional
    fun rename(id: Long, name: String) {
        val hospital = repository.findById(id).orElseThrow()
        hospital.rename(name)
        events.publishEvent(HospitalChanged(id))
    }
}

HospitalChanged는 엔티티가 아닌 이벤트 payload이며 변경 대상의 ID를 전달한다. HospitalInfo.id도 Kotlin 프로퍼티다. 저장소는 Optional을 반환하는 Spring Data 계약으로 표현했다.

Hospital의 엔티티 매핑은 생략했다. Kotlin JPA 엔티티에는 무인자 생성 조건을 준비하고, 지연 로딩용 클래스 프록시를 사용한다면 open 여부도 맞춘다. kotlin-jpa의 무인자 생성과 kotlin-spring의 서비스 open 처리는 다른 설정이다. 엔티티의 어노테이션 대상도 접근 방식에 맞춰 @field:Id·@field:Column 또는 getter 대상으로 일관되게 둔다.

변경 대상의 ID를 전달하므로 리스너가 커밋된 원본을 다시 읽어 검색 데이터를 만들 수 있다. JPA 엔티티 전체를 넘겨 다른 실행 경계에서 지연 로딩에 의존하는 방식보다 필요한 계약이 분명하다.

@TransactionalEventListener의 기본 단계는 AFTER_COMMIT이다. 활성 트랜잭션 없이 발행하면 기본적으로 처리하지 않는다. fallbackExecution=true는 이를 바꾸지만, 트랜잭션이 없는 호출에도 커밋 뒤 실행이라는 의미를 만들어주지는 않는다. 트랜잭션 이벤트 문서의 단계와 무트랜잭션 동작을 각각 적용한다.

커밋 뒤 쓰기에는 새 트랜잭션을 연다

리스너가 실행됐는데 DB 쓰기가 남지 않는다면 커밋 이후 자원 상태를 확인한다. 기존 자원이 아직 접근 가능하다고 해서 그 트랜잭션을 다시 커밋할 수 있는 것은 아니다. TransactionalEventListener API는 이 시점의 쓰기 경계를 경고한다.

@Component
class HospitalIndexListener(private val writer: HospitalIndexWriter) {
    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    fun onChanged(event: HospitalChanged) {
        writer.refresh(event.hospitalId)
    }
}

@Service
class HospitalIndexWriter(
    private val hospitalRepository: HospitalRepository,
    private val indexRepository: HospitalIndexRepository,
) {
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    fun refresh(id: Long) {
        val source = hospitalRepository.findById(id).orElseThrow()
        indexRepository.upsert(IndexDocument.from(source))
    }
}

IndexDocument.from은 원본을 검색 문서로 바꾸는 companion factory 계약이며 구현은 생략했다. 리스너는 별도 빈의 refresh()를 호출하고 writer는 REQUIRES_NEW로 독립 트랜잭션을 연다. writer 내부 호출로 바꾸면 다시 프록시 문제가 생긴다. 검색 upsert는 원본을 새로 읽는 범위 안에 들어간다.

아래 그림에서는 원본 커밋과 검색 커밋을 따로 본다. 활성 트랜잭션 안의 이벤트 발행과 별도 빈을 거친 writer 호출이 두 경계를 연결한다.

원본 트랜잭션의 변경과 이벤트 발행을 커밋한 뒤 AFTER_COMMIT 리스너가 별도 빈을 호출해 REQUIRES_NEW에서 원본 조회와 검색 쓰기를 커밋하는 흐름

그림 1 원본과 검색의 독립 커밋 — 커밋 뒤 리스너는 별도 빈의 새 트랜잭션으로 검색 쓰기를 확정한다.

검색 갱신의 실패로 이미 끝난 원본 커밋이 되돌아가지는 않는다. 또한 이 그림은 리스너의 실행 시점을 설명하며 프로세스 종료 뒤의 전달 복구를 보장하지 않는다. 새 연결의 자원과 재처리 정책을 함께 정한다.

REQUIRES_NEW의 자원 설명에 따라 기존 트랜잭션 자원을 유지하는 동안 새 커넥션이 필요할 수 있다. 동시 실행 수와 풀 용량을 확인한다. 새 검색 트랜잭션의 실패로 이미 끝난 원본 커밋을 되돌릴 수 없다는 점도 복구 정책에 반영한다.

원본과 검색 테이블이 같은 DB·트랜잭션 매니저를 쓰고 반드시 함께 확정되어야 한다면 BEFORE_COMMIT에서 갱신하는 선택도 있다. 대신 원본 트랜잭션이 길어지고 검색 실패가 원본 실패로 이어진다. 예외를 삼키면 함께 확정한다는 계약이 깨진다. 외부 검색 엔진까지 같은 DB 트랜잭션으로 묶을 수 있다고 확대하지 않는다.

실행 시점과 전달 보장을 분리한다

메모리 이벤트는 프로세스가 원본 커밋 직후 종료되는 간격을 영속 기록으로 메우지 않는다. @Async도 실행 스레드와 예외 경로를 바꿀 뿐 전달을 영속화하지 않는다.

재색인으로 복구 가능한 데이터라면 갱신 실패 기록과 재처리 정책을 둘 수 있다. 변경 유실을 허용할 수 없다면 원본 변경과 outbox 기록을 같은 트랜잭션에 넣고 별도 처리자가 읽는 설계를 검토한다. 중복 전달에는 멱등 갱신이, 서로 다른 변경의 역전에는 버전 비교가 필요하다. 이 선택은 이벤트 어노테이션보다 검색 데이터의 복구 요구에 달려 있다.

실제 커밋을 포함해 확인한다

테스트의 바깥 트랜잭션이 롤백되면 AFTER_COMMIT을 검증하지 못한다. 다음 예제는 명령 트랜잭션을 종료한 뒤 별도 읽기 트랜잭션에서 검색 결과를 확인하는 형태다.

commandTx.executeWithoutResult {
    commandService.rename(hospitalId, "변경된 이름")
} // 여기에서 실제 커밋

readTx.executeWithoutResult {
    assertThat(indexRepository.findName(hospitalId))
        .isEqualTo("변경된 이름")
}

commandTx와 readTx의 실제 전파 설정을 확인하고 테스트 메서드에 추가 트랜잭션을 두지 않는다. 정상 커밋 외에도 롤백, 트랜잭션 없는 발행, 내부 호출, 검색 쓰기 실패, 두 변경의 역전 조건을 나눠 확인한다.

서비스 반환, advice 실행, 이벤트 처리, 검색 커밋은 서로 다른 증거다. 호출 경계와 갱신의 확정 조건을 연결해야 검색 데이터가 왜 남거나 사라지는지 설명할 수 있다.