본문 바로가기
개발

[Spring] WebSocket 이벤트 발행 ApplicationEventPublisher 적용기

by Mong-_- 2026. 3. 14.

배경

토이 프로젝트 중 WebSocket STOMP 기반 실시간 통신 기능을 구현하면서, 방 입장/퇴장 시 참여자들에게 실시간으로 상태 변경을 알려주는 로직을 개발하고 있었습니다.

기능 자체는 단순했습니다. 사용자가 방에 입장하거나 퇴장하면, 해당 방의 참여자들에게 WebSocket 메시지를 보내면 됩니다.

처음에는 Service 레이어에서 비즈니스 로직 처리와 WebSocket 메시지 발행을 순차적으로 수행했습니다.

@Service
class RoomService(
    private val roomRepository: RoomRepository,
    private val messagingTemplate: SimpMessagingTemplate,
) {
    @Transactional
    fun joinRoom(roomId: Long, userId: Long) {
        val room = roomRepository.findById(roomId).orElseThrow()
        room.addParticipant(userId)

        // 비즈니스 로직 처리 직후 바로 WebSocket 메시지 발행
        messagingTemplate.convertAndSend(
            "/topic/room/$roomId",
            RoomEvent.joined(userId)
        )
    }
}


얼핏 보면 문제가 없어 보이지만, 여기에는 꽤 치명적인 문제가 숨어있었습니다.

문제

트랜잭션이 커밋되기 전에 WebSocket 메시지가 발행되는 문제가 발생했습니다.

@Transactional 메서드 내부에서 messagingTemplate.convertAndSend()를 호출하면, 아직 트랜잭션이 커밋되지 않은 상태에서 클라이언트에게 메시지가 전달됩니다.

이것이 왜 문제가 되냐면:

1. 사용자 A가 방에 입장하면, `addParticipant()` 후 WebSocket으로 참여자들에게 "A가 입장했습니다" 메시지가 발행됩니다
2. 메시지를 받은 참여자 B의 클라이언트는 로컬 참여자 목록에 A를 추가합니다
3. 그런데 커밋 시점에 DB 제약 조건 위반이나 낙관적 락 충돌 등으로 트랜잭션이 롤백됩니다
4. DB에는 A의 입장이 저장되지 않았지만, B의 화면에는 A가 참여자로 표시되어 있습니다
5. 이후 B가 페이지를 새로고침 하면 A가 사라지는 클라이언트와 서버 간 데이터 불일치가 발생합니다

또한 비즈니스 로직이 복잡해져서 `convertAndSend()` 이후에 추가적인 DB 작업이나 검증 로직이 들어가는 경우에도, 메시지는 이미 발행된 상태에서 이후 로직의 실패로 롤백이 발생하면 같은 문제가 생깁니다.

물론 위 코드처럼 `convertAndSend()`가 메서드 마지막 줄이라면 직후에 커밋이 이루어지므로, 이 경쟁 조건이 발생할 확률은 매우 낮습니다.

더 심각한 문제는 커밋 시점에 예외가 발생하여 롤백되는 상황입니다. DB 제약 조건 위반이나 낙관적 락 충돌 등으로 커밋이 실패하면, WebSocket 메시지는 이미 발행된 상태이므로 클라이언트는 실제로 일어나지 않은 이벤트를 수신하게 됩니다. 또한 비즈니스 로직이 복잡해져서 `convertAndSend()` 이후에 추가 로직이 들어가는 경우에도, 이후 실패로 롤백이 발생하면 같은 문제가 생깁니다.

정리하면:

 

이 문제는 근본 원인은 트랜잭션의 원자성(Atomicity)이 외부 I/O까지 보장되지 않는다는 점입니다. 트랜잭션은 DB 작업에 대해서만 "전부 성공하거나 전부 실패"를 보장합니다. `convertAndSend()`트랜잭션에 참여하지 않는 외부 I/O이므로, 한 번 실행되면 트랜잭션이 롤백되더라도 이미 전송된 메시지를 되돌릴 수 없습니다.

해결 ApplicationEventPublisher + @TransactionalEventListener

Spring이 제공하는 ApplicationEventPublisher와 @TransactionalEventListener를 조합하면, 트랜잭션 커밋이 완료된 후에 이벤트를 처리할 수 있습니다. 

 

(`@EventListener` 도 도 이벤트 기반으로 관심사를 분리할 수 있지만, publishEvent() 호출 시점에 즉시 실행되어 커밋 전에 메시지가 전송되는 문제가 그대로 남아있고, 리스너에서 예외가 발생하면 호출부의 트랜잭션까지 롤백되므로 이번 케이스에서는 적합하지 않았습니다.)


비즈니스 로직에서는 "이런 일이 일어났다"는 이벤트만 발행하고, 실제 WebSocket 메시지 전송은 트랜잭션 커밋이 완료된 후에 실행합니다.

 

`@TransactionalEventListener`는 내부적으로 `TransactionSynchronizationManager`를 활용합니다.

`publishEvent()`가 호출되면 Spring은 현재 활성 트랜잭션이 있는지 확인하고, 있다면 이벤트를 즉시 실행하지 않고 `TransactionSynchronization` 콜백으로 등록해 둡니다. 이후 트랜잭션이 커밋되면 `afterCommit()` 콜백이 실행되면서 등록된 이벤트 리스너가 비로소 호출되는 구조입니다.

즉, `publishEvent()` 시점에는 이벤트가 큐에 등록만 되고, 실제 실행은 커밋 완료 이후에 이루어집니다. 

1. 이벤트 클래스 정의

data class RoomJoinedEvent(
    val roomId: Long,
    val userId: Long,
)

data class RoomLeftEvent(
    val roomId: Long,
    val userId: Long,
)



2. Service에서 이벤트 발행

@Service
class RoomService(
    private val roomRepository: RoomRepository,
    private val eventPublisher: ApplicationEventPublisher,
) {
    @Transactional
    fun joinRoom(roomId: Long, userId: Long) {
        val room = roomRepository.findById(roomId).orElseThrow()
        room.addParticipant(userId)

        // WebSocket을 직접 호출하지 않고, 이벤트만 발행
        eventPublisher.publishEvent(RoomJoinedEvent(roomId, userId))
    }

    @Transactional
    fun leaveRoom(roomId: Long, userId: Long) {
        val room = roomRepository.findById(roomId).orElseThrow()
        room.removeParticipant(userId)

        eventPublisher.publishEvent(RoomLeftEvent(roomId, userId))
    }
}


Service는 더 이상 `SimpMessagingTemplate`을 알지 못합니다. 비즈니스 로직에만 집중할 수 있게 됩니다.

3. 이벤트 리스너에서 WebSocket 메시지 전송

@Component
class RoomEventListener(
    private val messagingTemplate: SimpMessagingTemplate,
) {
    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    fun handleRoomJoined(event: RoomJoinedEvent) {
        messagingTemplate.convertAndSend(
            "/topic/room/${event.roomId}",
            RoomEvent.joined(event.userId)
        )
    }

    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    fun handleRoomLeft(event: RoomLeftEvent) {
        messagingTemplate.convertAndSend(
            "/topic/room/${event.roomId}",
            RoomEvent.left(event.userId)
        )
    }
}


핵심은 `@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)` 부분입니다.

이 어노테이션은 트랜잭션이 성공적으로 커밋된 후에만 리스너 메서드를 실행합니다. 트랜잭션이 롤백되면 리스너는 실행되지 않습니다.

여기서 한 가지 더 알아두면 좋은 점은, 리스너는 기본적으로 동기(synchronous)로, 커밋을 수행한 같은 스레드에서 실행된다는 것입니다. 리스너에서 오래 걸리는 작업을 하면 응답 지연이 발생할 수 있으므로, 무거운 작업이라면 `@Async`와 조합하는 것을 고려해야 합니다. 다만 `@Async`를 사용하면 별도 스레드에서 실행되므로 SecurityContext나 ThreadLocal 기반 컨텍스트가 전파되지 않는다는 점을 유의해야 합니다.

주의할 점

트랜잭션이 없는 메서드에서 이벤트를 발행하면 리스너는실행되지 않습니다.

`@TransactionalEventListener`는 활성 트랜잭션이 있을 때만 TransactionSynchronization 콜백을 등록하므로, 트랜잭션이 없으면 콜백 등록 자체가 일어나지 않기 때문입니다. 

 

이 경우 `fallbackExecution = true` 옵션을 사용하면 트랜잭션이 없어도 즉시 실행됩니다.

 
@TransactionalEventListener(
    phase = TransactionPhase.AFTER_COMMIT,
    fallbackExecution = true
)


다만 `fallbackExecution = true`를 사용하면 트랜잭션이 없을 때는 `@EventListener`와 동일하게 즉시 실행되므로, 의도하지 않은 동작이 발생할 수 있습니다. 트랜잭션 유무에 따라 실행 시점이 달라진다는 점을 인지하고 사용해야 합니다.

 

TransactionPhase 옵션 정리
`@TransactionalEventListener`는 `AFTER_COMMIT` 외에도 다양한 phase를 지원합니다. 상황에 따라 적절한 phase를 선택할 수 있습니다.

 

BEFORE_COMMIT 커밋 직전 | 커밋 전 유효성 검증
AFTER_COMMIT 커밋 완료 후 | 알림 발송, 외부 API 호출
AFTER_ROLLBACK 롤백 후 | 실패 로그 기록, 보상 트랜잭션
AFTER_COMPLETION 커밋/롤백 상관없이 완료 후 | 리소스 정리


`ApplicationEventPublisher`와 `@TransactionalEventListener`의 조합은 WebSocket뿐만 아니라, 트랜잭션 커밋 후에 실행되어야 하는 모든 부수 효과(알림 발송, 외부 API 호출, 캐시 갱신 등)에 적용할 수 있는 패턴입니다.

결국 이 패턴의 본질은 "비즈니스 로직의 완료"와 "부수 효과의 실행" 사이의 타이밍을 Spring이 보장해준다는 것입니다. 

 

 

평소에 몰랐던 기술을 익히면 글로 정리해야겠다고 생각만 해두고, 흐지부지 넘어가며 글을 못 썼었다. 

그러다 요즘 회사 일이 익숙해지면서 여유가 생겼고,

토이 프로젝트를 진행하는 김에 정리하는 목적으로 글을 작성하게 됐다.

오랜만에 글을 쓰니 뿌듯하다!