Kafka를 적용하게 된 개인적인 문제의식과 수평적 확장성을 보장하는 이벤트 기반 비동기 작업 처리 구조 구축 과정, 그리고 이를 이용해 알림 기능을 구현한 사례를 기록하고자 한다. 이 과정에서 Kafka에 대해 공부하고 개인적으로 정리한 학문적인 내용까지 포함한다.
Spring 프로젝트에서 알림 기능을 담당한 적이 있다. 당시 기능 구현에 있어서 기초적인 스키마는 다음과 같다.
알림은 멤버 단위로 관리되므로 멤버 엔티티와 관계 설정이 필요하다. 멤버를 직접 참조하는 알림 엔티티 구조를 사용할 경우, 모든 멤버에게 동일한 내용을 전달하는 공통 알림의 메시지가 레코드마다 중복 저장되고 심각한 공간 낭비가 발생한다. 따라서 메시지와 몇 개의 메타 정보를 저장한 별도의 알림 엔티티를 두고, 알림의 수신 관계를 표현하는 중간 엔티티를 함께 운영하는 구조로 설계하였다. 추천 알림처럼 개인마다 다른 메시지를 전송하게 되는 경우에만 중간 엔티티에 메시지를 저장함으로써 데이터 중복을 최소화했다.

게시물, 댓글, 새 콘텐츠, 등록 승인·거절, 예매, 공지, 추천 알림 등 다양한 상황에서 알림이 생성되므로, 재사용성을 고려하여 Notice 도메인을 별도로 분리하고 관련 로직을 구현하였다. 핵심 도메인 로직과 그에 파생되는 알림 기능 간의 관심사 분리를 위해 각 상황에서 알림 서비스를 직접 호출하는 대신 Spring의 ApplicationEventPublisher를 통해 이벤트를 발행하고, EventListener에서 이를 수신하여 알림 서비스를 호출하도록 구현하였다.
다음은 등록 승인으로부터 파생되는 세 종류의 알림 생성 로직을 구현한 아주 기초적인 이벤트 처리 구조이다.
ApplicationEventPublisher + EventListener
@Transactional
public AdminAmateurShowSummaryResponseDTO approveShow(Long showId) {
AmateurShow show = amateurShowRepository.findById(showId)
.orElseThrow(() -> new GeneralException(ErrorStatus.AMATEURSHOW_NOT_FOUND));
//show의 상태 필드를 APPROVED로 UPDATE
show.approve();
Member performer = show.getMember();
//등록 승인 이벤트 발행
eventPublisher.publishEvent(
new ApproveShowEvent(show.getId(), performer.getId()
)
);
return AdminAmateurShowSummaryResponseDTO.from(show);
}
@Service
@RequiredArgsConstructor
public class ApproveShowEventListener {
private final NoticeService noticeService;
@EventListener
public void notifySubscribers(ApproveShowEvent event) {
//새 공연 알림
noticeService.notifyNewShow(event);
}
@EventListener
public void notifyPerformer(ApproveShowEvent event) {
//등록 승인 알림
noticeService.notifyApproval(event);
}
@EventListener
public void notifyOthers(ApproveShowEvent event) {
//공연 추천 알림
noticeService.notifyRecommendation(event);
}
}
등록이 승인되면 등록자 계정을 구독한 멤버에게는 새 공연 등록 알림을, 공연 등록자에겐 등록 승인 알림을, 나머지 멤버에게는 공연 추천 알림을 전송해야 한다. 이를 구현하기 위해 공통 이벤트 ApprovalShowEvent를 발행하고 @EventListener로 받아 각자 담당하는 알림 전송 메서드를 호출하도록 했다.

위는 ApplicationEventPublisher + EventListener 실행 흐름을 그림으로 나타낸 것이다. 노란색은 메인 기능, 초록색이 파생 기능에 해당한다. 물론 이 이벤트 처리 구조는 제대로 동작하긴 하나 몇 가지 치명적인 한계를 갖고 있다.
기능성 문제
위와 같이 Spring의 자체 event-driven model만으로 알림 기능을 구현할 경우, 몇 가지 기능적인 문제들을 마주하는데 이는 Spring의 작동방식에 기인한다.
응답 지연 문제
Spring 이벤트는 기본적으로 동기(synchronous)로 처리되며 publishEvent()는 모든 EventListener가 완료될 때까지 리턴하지 않는다.
위 매커니즘에 의해 리스너들은 approveShow와 동일한 스레드에서 실행되며, 모든 리스너가 완료될 때까지 publishEvent()가 수행 중이므로 approveShow도 종료될 수 없다. 이로 인해 알림 전송은 공연 승인과 논리적으로 독립된 기능임에도 불구하고, 공연 상태를 APPROVED로 변경하는 단순한 UPDATE 트랜잭션의 커밋 과정에서 불필요하게 시간을 소모시킨다.(응답 지연 문제)
순서 문제, 롤백 전파 문제
Spring에서 @Transactional은 메서드 반환 직전에 트랜잭션 커밋을 시도하고, JPA는 커밋 직전에 자동 flush를 수행하면서 더티 체킹을 통해 변경 사항을 DB에 반영한다.
이 매커니즘으로 인해 공연 승인 트랜잭션이 알림 트랜잭션보다 항상 나중에 커밋되는데, 이 순서 역전에 의해 심각한 비정합성 문제가 발생할 수 있다. 예를 들어 오래 걸리는 리스너가 마지막에 실행된다면 첫 번째 알림 트랜잭션과 승인 트랜잭션 간의 시차가 커진다. 이 때, 발송된 알림을 받은 사용자가 새 공연을 조회하려고 하면 아직 승인이 반영되지 않아 접근이 제한되는 심각한 비일관성 상태가 발생할 수 있다.(순서 문제)
또한 트랜잭션의 원자성(Atomicity)에 의해 여러 이벤트 리스너 중 하나라도 실패할 경우 전체 트랜잭션이 롤백되는 구조적 비효율성이 존재한다. 이는 공연 승인이라는 핵심 도메인과 관심사 분리(SoC)가 보장되어야 할 알림이나 추천과 같은 후속 작업의 실패가 핵심 도메인 로직의 실패로 전파될 수 있음을 의미한다.(롤백 전파 문제)
확장성 문제
사용자 수 증가와 서비스 규모 확장에 따라 게시글, 댓글 작성, 주문 생성, 등록 승인과 같은 핵심 도메인 작업보다 알림, 통계 수집, 추천같은 파생 작업의 워크로드가 급격히 늘어난다. 이런 오버헤드를 줄이기 위해 Spring에서 제공하는 @Async를 활용해서 부가 작업을 비동기적으로 처리하도록 설계하면 일정 수준의 확장성을 확보할 수 있다.
처리량 문제, 내구성 문제
Spring 애플리케이션은 근본적으로 단일 Spring Container와 JVM 내부에서 동작한다.
이로 인해 @Async를 활용한 비동기 처리 로직이 처리할 수 있는 이벤트의 규모는 JVM이 감당할 수 있는 수준으로 제한된다. 예를 들어 추천 알림처럼 다수의 사용자에게 알림을 전송하는 경우, 사용자가 증가할수록 비동기적으로 처리되는 작업들이 CPU, 스레드 풀, GC, 메모리 등의 자원을 과도하게 점유하게 되고, 이는 애플리케이션의 처리량과 성능을 저하시킨다. (처리량 문제)
또한 이벤트 전달과 처리가 애플리케이션 생명주기에 종속되므로 장애나 재시작 시 복구나 재처리가 불가능하다. 서비스 규모가 커질수록 시스템 전반의 안전성이 저하되는데, 이러한 점은 작업의 내구성 보장 관점에서 구조적인 한계를 드러낸다. (내구성 문제)
이제부터 이 다섯 종류의 문제들을 다음과 같이 정의하겠다.
응답 지연 문제 : 핵심 도메인 메서드가 그로부터 파생되는 후속 작업들을 동기적으로 호출함으로써, 해당 작업들이 모두 완료될 때까지 메서드가 리턴하지 못하고 대기하는 문제
순서 문제 : 핵심 도메인 메서드의 트랜잭션이 그로부터 파생되는 후속 트랜잭션보다 나중에 커밋되면서 발생할 수 있는 비일관성 문제
롤백 전파 문제 : 핵심 도메인 메서드의 트랜잭션과 그로부터 파생되는 작업들이 동일한 트랜잭션 경계에 포함됨으로써, 후속 작업 중 하나라도 실패할 경우, 그 실패가 핵심 도메인 트랜잭션까지 전파되어 전체 작업이 롤백되는 문제
처리량 문제 : 핵심 도메인 메서드로부터 파생되는 후속 작업들이 단일 스프링 컨테이너와 JVM 내부 자원(CPU, 스레드 풀, 메모리 등)에 의존하여 실행됨으로써, 시스템이 동시에 처리할 수 있는 작업의 양이 제한되고 트래픽 증가 시 성능 저하가 발생하는 문제
내구성 문제 : 작업의 실행 및 처리 상태가 애플리케이션 내부 메모리와 실행 흐름에 종속되어 외부에 영속적으로 저장되지 않음으로써, 서버 장애나 재시작 시 작업의 복구 및 재처리가 보장되지 않는 문제
Spring에서는 이러한 문제를 다룰 수 있는 몇 가지 기능들을 제공한다. 그 중 @Async와 @TransactionalEventListener를 활용한 이벤트 처리 구조를 알아보자.
@Async
순효과
앞서 언급했듯이 @Async를 사용하면 이벤트 리스너의 실행 방식을 비동기로 바꿈으로써 기존 구조의 응답 지연 문제, 롤백 전파 문제를 해결할 수 있다. 각 리스너는 별도의 스레드에서 독립적으로 실행되므로 publishEvent()는 리스너의 완료까지 기다리지 않고, 이벤트 발행 직후 리턴한다. 따라서 approveShow는 트랜잭션 커밋 후 바로 응답을 반환할 수 있다. 또한, 이 구조에서는 approveShow의 트랜잭션은 물론, 리스너 각각의 트랜잭션 간에도 롤백이 전파되지 않는다.(공간적 분리)

역효과
그러나 리스너들이 각자의 스레드에서 독립적으로 실행되면서 각 트랜잭션의 커밋 순서는 불확실해진다. approveShow의 업데이트 트랜잭션과 각 알림 생성 트랜잭션 중 어떤 것이 먼저 완료되는지에 따라 참조하는 DB 상태가 달라질 수 있다. Async는 실행 순서를 비결정적으로 만들기 때문에 race condition을 야기하여 순서 문제가 오히려 심화될 수 있다.
@TransactionalEventListener(phase=AFTER_COMMIT)
순효과
@TransactionalEventListener는 이벤트 리스너의 실행 시점을 제어함으로써 롤백 전파 문제와 순서 문제를 해결할 수 있다. AFTER_COMMIT 설정을 사용하면 approveShow의 트랜잭션이 커밋된 직후에 이벤트 리스너가 실행되도록 보장한다. 이를 통해 승인 트랜잭션 후 알림 트랜잭션이라는, 도메인 로직상 자연스러운 순서를 고정할 수 있으며, 리스너가 실패하더라도 이미 커밋된 승인 트랜잭션에는 영향을 주지 않게 된다.(시간적 분리)

역효과
그러나 @TransactionalEventListener를 @Async없이 사용하면, 리스너가 동기적으로 실행되므로 approveShow는 커밋이 끝났음에도 응답을 리턴하지 않고 리스너의 로직이 끝날 때까지 대기하는 응답 지연 문제가 발생한다. 또한 커밋 순서를 고정해 리스너가 approveShow 트랜잭션에 롤백을 전파하지는 않지만 같은 스레드에서 순차적으로 실행되는 리스너들끼리는 여전히 롤백이 전파될 수 있다.
@Async + @TransactionalEventListener(phase = AFTER_COMMIT)
따라서 @Async와 @TransactionalEventListener를 함께 사용해야 서로의 단점을 상호보완하며 기존 구조에서의 응답 지연 문제, 롤백 전파 문제, 순서 문제를 해결할 수 있다.
@Service
@RequiredArgsConstructor
public class ApproveShowEventListener {
private final NoticeService noticeService;
// 새 공연 알림
@Async
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void notifySubscribers(ApproveShowEvent event) {
noticeService.notifyNewShow(event);
}
// 등록 승인 알림 (공연자)
@Async
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void notifyPerformer(ApproveShowEvent event) {
noticeService.notifyApproval(event);
}
// 공연 추천 알림
@Async
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void notifyOthers(ApproveShowEvent event) {
noticeService.notifyRecommendation(event);
}
}

@Async로 리스너의 실행을 비동기로 처리해서 approveShow가 리스너 리턴까지 기다리지 않고 바로 응답을 반환한다. 추가로 리스너들은 각각 다른 스레드를 점유해서 독립적으로 실행되므로 approveShow의 트랜잭션은 물론, 서로의 트랜잭션에도 롤백을 전파하지 않는다.(공간적 분리)
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)으로 approveShow 트랜잭션 커밋 후에야 리스너가 실행된다. 이는 승인 후 알림이라는 순서를 고정하고, 리스너의 실패가 approveShow 트랜잭션의 롤백으로 전파되지 않도록 한다.(시간적 분리)
이 구조로 ApplicationEvent의 처리 방식, @Transactional의 작동 방식에 기인한 기능성 문제(응답 지연 문제, 순서 문제, 롤백 전파 문제)는 상당 부분 해결할 수 있다. 즉, 비동기 이벤트 처리 구조 자체는 기능적으로 충분히 구현 가능하다.
그러나 이 구조는 다음 두가지 특징 때문에 확장성 문제(처리량 문제, 내구성 문제)를 근본적으로 해결할 수 없다.
Spring 자체 비동기 처리 구조의 한계
전달보다는 호출이라는 기능적 한계
Spring은 이벤트 발행 직후 수신 가능한 EventListener를 탐색하고 조건이 맞는 EventListener를 즉시 호출한다.
이벤트 발행 시점에 실행 대상과 호출 시점이 이미 결정되며 리스너의 실행 시점을 동적으로 제어할 수 없다. Spring의 이벤트 처리 방식은 메시지 전달을 중심으로 구성 요소가 분리되는 Producer–Message–Consumer 모델과 달리, 이벤트를 발행하는 순간 리스너 호출이 결정되는 Publisher→Listener로의 흐름에 집중한 실행 모델이다. 즉, 전달하는 모델이 아니라 실행 흐름을 분기시키는 호출 메커니즘이다.
Spring의 이벤트는 EventListener 실행 트리거이자 호출 인자이다.
이벤트는 생성과 동시에 리스너 호출을 위한 인자로 전달되고, 호출이 끝나면 더 이상 존재할 필요가 없는 일회성 객체이다. Spring에서 이벤트는 생성 → 유지 → 소비 → 소멸의 고유한 생명주기를 갖는 독립적인 전달 대상이 아니라, 생성과 동시에 전달되고 즉시 소멸하는 일회성 인자의 역할을 수행한다. 전달 시점과 대상이 이미 고정된 실행 흐름 제어용 객체이므로 상태를 저장하거나 전달 여부를 추적할 필요가 없고, Spring도 이를 제공하지 않는다.
Spring의 EventListener는 이벤트 발행과 동시에 실행 대상이 되고 이벤트는 리스너 호출과 함께 소멸한다. 이벤트가 발행되어 리스너가 결정되면 곧바로 호출하여 이벤트를 소모해버린다. 이벤트 객체는 발행 직후 소모되는 일회성 인자로, 저장이나 관리의 대상이 아니므로 따로 작업 내용을 보관했다가 추후 재처리하는 것이 불가능하다. 따라서 리스너가 이미 호출된 시점에서 일시적인 자원 부족이나 장애로 인해 실행을 완료하지 못하면 해당 작업은 그대로 유실된다.
단일 프로세스 아키텍처의 구조적 한계
Spring의 이벤트 처리는 단일 애플리케이션의 생명주기에 귀속된다.
Spring의 내부 메커니즘은 단일 프로세스 아키텍처에 최적화되어 있어, 트랜잭션 관리와 데이터 일관성을 확보한 대신 물리적 격리와 수평적 확장에는 구조적인 한계가 있다. 실제로 스프링 애플리케이션은 구동 중인 프로세스의 CPU와 메모리 자원만 사용하므로, 서버 한 대의 성능이 한계에 도달하면 더 이상 처리량을 올릴 수 없다. 따라서 대규모 서비스에서는 기능별로 프로세스(서버)를 분리하여 확장성을 확보하는데, Spring에서는 인 메모리 큐에 저장된 이벤트 데이터를 외부 프로세스와 통신하는 네트워크 통신 기능(IPC)을 지원하지 않는다.
예를 들어, 서비스 규모가 너무 방대해 공연 관리와 알림 기능을 별도의 프로세스로 분리한 MSA(Micro Service Architecture)로 운영한다고 하자. 이 경우 공연 관리 마이크로서비스에서 등록 승인에 대한 이벤트를 발행해도, 물리적으로 분리된 알림 마이크로서비스에게 이를 직접 전달할 수 없다. 결국 알림 서비스는 주기적으로 데이터베이스를 조회하여 상태 변경을 감지하는 폴링(Polling) 방식 등 비효율적인 구조로 동작할 수 밖에 없다.
추가로, 이벤트 객체를 기능적으로 영속하도록 설계하더라도 JVM 위에서 동작하는 애플리케이션이 이벤트를 인메모리 방식으로 처리하는 한, 물리적인 휘발성에서 자유로울 수 없다. Spring 컨텍스트 안에서 관리되는 모든 객체는 해당 프로세스의 생명주기에 종속되므로 만약 작업을 처리하기 전에 서버가 꺼지거나 에러로 재시작되면, 모든 미처리 데이터는 증발한다.
Spring 자체적으로 @Async를 지원하긴 하지만 분산 시스템의 높은 처리량과 병렬성과 비교했을 때 단일 애플리케이션 내의 비동기는 명확한 한계가 있다. 자원이 한정적인 JVM 위에서 비동기 작업 처리 구조는 응답속도 개선이라는 그 본래 목적도 달성하지 못할 수 있다. 비동기 처리는 본질적으로 오버헤드와 처리량 사이의 트레이드오프에서 전자를 감수하고 후자를 취하는 선택이다. 그러나 별도의 장애 복구나 효율적인 자원 관리 메커니즘 없이 무분별하게 도입된 내부 비동기 방식은 그로 인해 얻는 병렬성이 오히려 시스템 안정성을 위협할 수 있다.
예를 들어, 다음의 예시 상황을 가정해보자.
1. 등록 승인 이벤트 발행 직후, 추천 알림을 생성하는 리스너가 가장 먼저 스레드를 할당받아 실행된다. 이 과정에서 모든 멤버를 조회하며 상당한 IO 자원을 소모한다.
2. 동시에 새 공연 알림을 생성하는 리스너 또한 유사한 IO 작업을 수행하면서, 시스템의 IO 자원이 일시적으로 고갈된다.
3. 이후 비교적 늦게 호출되었지만 병렬 실행 구조 덕에 즉시 스레드를 할당받은 등록 승인 알림 리스너는 단일 멤버 조회만으로 충분함에도 IO 자원을 확보하지 못해 정상적으로 실행되지 못한다.
4. 등록 승인 알림 리스너가 비동기 처리를 위한 추가 자원(GC, 메모리, 스레드 등과 그 관리 비용)을 할당받으면서 기존에 작업 중이던 추천 알림 리스너는 동적 메모리 할당에 실패하여 에러를 발생시킨다.(힙 영역은 스레드끼리 공유)
이 상태에서 장애가 발생하여 애플리케이션이 재시작되면 해당 작업은 그대로 유실되어 등록자는 등록 승인 알림을, 추천 대상자는 추천 알림을 영원히 수신하지 못하게 된다.
단일 프로세스에서 작동되는 서비스더라도 규모가 커질수록 JVM 자원의 효율적 관리가 중요해지므로 현재 구조만으로는 한계가 명확하다. 이 구조에서 안정성과 성능 간 트레이드오프는 확장성이 더해질수록, 마치 한쪽이 내려간 대신 반대쪽이 올라간 시소에서 결국 양쪽 다 내려온 망가진 시소가 되는 것과 같다. 따라서 스프링만으로 구현한 비동기 구조는 대규모 이벤트 처리 환경에서 안전성(내구성)과 성능(처리량)을 모두 놓치게 된다.
Spring은 이벤트의 전달과 리스너의 호출을 연속된 흐름으로 처리하므로 이벤트의 전달 시점과 리스너의 실행 시점을 분리하거나 이벤트를 별도로 보관·재처리하는 것이 기능적, 구조적으로 불가능하다. 이런 환경에서 구현된 비동기 작업 처리 구조는 오히려 시스템 확장성을 제한한다.
확장성 문제 해결을 위한 외부 분산 메시지 로그의 필요성
사용자 수가 일정 단위를 넘어가면, 모든 멤버에게 알림을 발송하는 이벤트 리스너 작업 하나하나가 막대한 자원을 소모하게 된다. 이 작업들은 각자의 스레드를 할당받아 비동기적으로 처리되지만, 단일 스프링 컨테이너 내의 한정된 자원만으로는 폭증하는 처리량을 감당하기 어려워 처리량 문제와 내구성 문제가 발생할 수 밖에 없다. 특히 알림 전송과 같은 부가 기능이 서비스의 핵심인 등록 승인 로직보다 더 많은 자원을 점유하는 배보다 배꼽이 더 큰 상황은 시스템의 안정성을 심각하게 위협한다.
따라서 단순히 이벤트가 발생하는 즉시 자원을 할당하고 실행시키는 방식으로는 비동기 작업 처리 구조에 수평적 확장성을 확보할 수 없다. 프로세스의 자원 상태(CPU, 스레드풀, 큐, 메모리 등)를 고려해서 작업을 효율적으로 분배할 수 있어야 한다. 즉, 작업이 실행 대상으로 등록되는 시점과 실제 실행 시점을 분리하고 부하를 조율하는 백프레셔 및 오케스트레이션 로직이 필요하다.
만약 서비스의 규모가 더 커져 여러 프로세스에 분리해서 돌려야 하는 분산 시스템 구조로 전환된다면 이런 로드 밸런싱은 훨씬 더 복잡한 과제가 된다. 물리적으로 분리된 환경에서 데이터의 순차적 처리와 정합성을 보장하고, 네트워크 장애 상황에도 지속적이고 복구 가능한 마이크로서비스를 위해서는 결국 애플리케이션 외부와 통신하는 메시지 시스템과 그 저장 구조가 뒷받침되어야 한다.
특히, 일시적인 네트워크 장애나 스토리지 다운 시, 완료되지 못한 작업을 기억하고 재처리할 수 있는 복구 메커니즘 확보를 위해 데이터의 영속 저장과 복제는 필수적이다. 즉, 메시지(이벤트)를 로그에 영속적으로 저장함으로써 장애 상황에서도 데이터 유실을 방지하고, 안정적인 복구 및 재처리를 보장하는 분산 로그 시스템과 서비스 간 결합도를 낮추고 확장성을 보장하는 분산 메시징 시스템의 도입이 요구된다.
작업을 외부에 안전하게 저장하고, 순서를 보장하며, 비동기 분산 처리를 지원하는 메시지 로그 시스템을 활용하면 처리량 문제와 내구성 문제를 외부에서 해소하는 안정적인 오케스트레이션을 시스템 전반에 적용할 수 있다.
분산 네트워크 시스템으로서 Kafka가 어떤 구조로 동작하는 지는 다음 시간에 알아보자.
'dev > infra' 카테고리의 다른 글
| Kafka - 이벤트 기반의 비동기 작업 처리 구조로 알림 기능 구현하기 (4) (0) | 2026.01.22 |
|---|---|
| Kafka - 이벤트 기반의 비동기 작업 처리 구조로 알림 기능 구현하기 (3) (0) | 2026.01.22 |
| Kafka - 이벤트 기반의 비동기 작업 처리 구조로 알림 기능 구현하기 (2) (1) | 2026.01.21 |
