Project/phonebid

[리팩토링] 알림 발송 로직을 동기에서 이벤트 기반 비동기로 개선하기 (feat. @TransactionalEventListener)

쉬지마 이굥진 2026. 4. 23. 19:05

필자는 현재 스마트폰 역경매 플랫폼 bidr(비더)를 개발 중이다. (5월 런칭 목표)

 

bidr는 구매자가 원하는 스마트폰 사양과 희망 가격 등의 견적을 올리면, 판매자들이 역으로 입찰을 넣는 구조다. 구매자 입장에선 여러 판매자의 견적을 한 번에 받아볼 수 있고, 판매자 입장에선 구매 의사가 확실한 고객에게 직접 제안할 수 있다.

 

이 구조에서 알림은 꽤 중요한 역할을 한다. 구매자가 견적을 등록하면 승인된 판매자 전원에게 카카오 알림톡으로 새 견적이 올라왔다는 알림이 발송되고, 구매자가 마음에 드는 입찰을 선택해 계약이 체결되면 구매자와 판매자 양쪽 모두에게 알림이 발송된다. 판매자가 빠르게 인지하고 입찰에 참여해야 서비스가 돌아가고, 계약 체결 순간에도 양쪽이 즉각적으로 인지해야 이후 결제와 배송 흐름이 자연스럽게 이어지기 때문이다.

 

이 포스팅에서는 알림 기능을 구현하면서 동기 방식으로 짜여진 코드 때문에 겪었던 문제와 해결한 방식에 대해 써보려한다.

(스압주의.. 꽤나 긴 여정이었습니다,,🥲)


개요

알림 발송 기능을 구현하고 직접 테스트해보는데, 견적 등록 요청 후 응답이 생각보다 오래 걸린다는 걸 느꼈다. 이상하다 싶어서 코드를 다시 들여다봤더니, 카카오 알림톡 발송이 트랜잭션 안에 동기적으로 묶여 있었다.

 

당시 구조를 그려보면 이렇다.

[클라이언트 요청]
       │
       ▼
┌──────────────────────────────────────────────────┐
│                  트랜잭션 시작                    │
├──────────────────────────────────────────────────┤
│  1. 견적 등록 DB 저장                  (약 10ms) │
│  2. 카카오 API → 판매자 #1 알림      (약 500ms) ← 대기 │
│  3. 카카오 API → 판매자 #2 알림      (약 500ms) ← 대기 │
│  4. 카카오 API → 판매자 #3 알림      (약 500ms) ← 대기 │
│  ...                                             │
│  N. 카카오 API → 판매자 #N 알림      (약 500ms) ← 대기 │
├──────────────────────────────────────────────────┤
│                  트랜잭션 커밋     (N × 500ms 후) │
└──────────────────────────────────────────────────┘
       │
       ▼
[클라이언트 응답] ← 한참 후에야 수신

 

수신 대상마다 외부 API 응답을 순차적으로 기다리는 구조였으니, 응답이 느릴 수밖에 없었다.

 

문제는 두 가지였다.

1️⃣첫째, 이 구조는 수신 대상 수에 선형으로 비례한다. 지금은 테스트 계정 몇 개로만 돌리고 있지만, 실제 서비스라면 판매자가 수백 명도 될 수 있다. 판매자가 100명이고 알림 발송이 평균 500ms라면 — 트랜잭션이 50초 동안 열려 있게 된다.

판매자 수     트랜잭션 점유 시간    DB 커넥션 점유
──────────────────────────────────────────────
10명      →   5초                5초 동안 점유
50명      →   25초               25초 동안 점유
100명     →   50초               50초 동안 점유  ⚠️
500명     →   250초              250초 동안 점유 💀

그 시간 동안 DB 커넥션은 반환되지 않고, 동시 요청이 몰리면 커넥션 풀이 고갈되어 서비스 전체가 멈출 수 있는 구조였다.

 

2️⃣둘째, 외부 API 장애가 핵심 비즈니스 로직까지 영향을 줄 수 있다. 카카오 API가 다운되면 알림 발송이 실패하고, 그 실패가 트랜잭션 안에 있으니 거래의 핵심 순간인 계약 체결 자체가 롤백될 수 있는 구조였다.

 

알림은 부가 기능인데, 부가 기능의 실패가 핵심 기능을 망가뜨리는 셈이었다.

 

문제 상황

기존 코드는 이렇게 생겼다.

// 변경 전
@Transactional
public void createAndSendNotification(User user, NotificationType type,
                                     List<NotificationChannel> channels, UUID referenceId) {
    for (NotificationChannel channel : channels) {
        Notification notification = notificationFactory.createNotification(...);
        Notification savedNotification = notificationRepository.save(notification);

        // ❌ 트랜잭션 내부에서 카카오 API 동기 호출
        sendNotification(savedNotification);
    }
}

private void sendNotification(Notification notification) {
    NotificationSender sender = findSender(notification.getChannel());
    sender.send(notification); // 외부 API 호출 — 500ms~3000ms
}

 

얼핏 보면 문제없어 보이지만, 실제로 어떤 일이 일어나는지 그려보면 이렇다.

[트랜잭션 시작]
  ├─ DB 저장 (10ms)
  ├─ 카카오 API #1 호출 (500ms) ← 대기
  ├─ 카카오 API #2 호출 (500ms) ← 대기
  ├─ ...
  └─ 카카오 API #100 호출 (500ms) ← 대기
[트랜잭션 커밋] ← 50초 후

 

구매자가 견적을 등록하면 승인된 판매자 전원에게 알림을 발송해야 한다. 판매자가 100명이고 알림 발송이 평균 500ms라면, 트랜잭션이 50초 동안 열려 있게 된다.

 

 

이건 세 가지 문제를 동시에 발생시킨다.

  1. DB 커넥션 점유 문제: 트랜잭션이 살아있는 동안 DB 커넥션은 반환되지 않는다. 판매자가 100명이면 50초, 동시 요청이 10개면 커넥션 풀이 순식간에 고갈된다.
  2. 응답 지연 문제: 사용자는 견적 등록 버튼을 눌렀는데 50초 동안 로딩만 보게 된다. 이탈률이 올라가고, 인내심 없는 사용자는 중복 클릭을 시도할 수도 있다.
  3. 장애 전파 문제: 카카오 API가 다운되면 알림 발송이 실패하고, 그 실패가 트랜잭션 안에 있으니 핵심 비즈니스 로직인 견적 등록까지 영향을 받을 수 있는 구조였다.

 

해결 방향 - 단순히 비동기로 분리하면 될까?

처음엔 @Async 로 발송 메서드만 감싸면 되는 거 아닌가? 하는 생각을 했다.

 

당연히 안된다. (^-^)

 

@Async만 쓰면 트랜잭션 커밋 전에 알림이 발송될 수 있다. 이말인 즉 DB 저장이 아직 커밋되지 않은 상태에서 알림이 먼저 나가버린다는 말이다. (= 수신자가 알림을 보고 앱을 열었는데 데이터가 없는 상황이 생길 수 있다)

 

필요한 건 두 가지를 동시에 만족시키는 방식이었다.

  • 트랜잭션 커밋 이후에만 알림을 발송
  • 별도 스레드에서 비동기로 처리

이걸 동시에 해결해주는 게 바로 @TransactionalEventListener 다.

@Async만 사용했을 때 (❌)
──────────────────────────────────────────────
[트랜잭션 시작]
  ├─ DB 저장 (아직 커밋 안 됨)
  └─ @Async → 별도 스레드에서 알림 발송 시작 ← 커밋 전에 실행!

수신자: 알림 수신 → 앱 열기 → 데이터 없음 💀
[트랜잭션 커밋] ← 알림 발송보다 나중에 됨
@TransactionalEventListener(AFTER_COMMIT) 사용했을 때 (✅)
──────────────────────────────────────────────
[트랜잭션 시작]
  ├─ DB 저장
  └─ 이벤트 발행 (등록만 해둠)
[트랜잭션 커밋] ← DB 반영 완료
  │
  ▼
커밋 완료 신호 → 별도 스레드에서 알림 발송 시작
수신자: 알림 수신 → 앱 열기 → 데이터 있음 ✅

1차 해결 - 이벤트 기반 비동기 분리

[구조 설계]

흐름을 이렇게 바꿨다.

[트랜잭션 시작]
  ├─ DB 저장 (10ms)
  └─ 이벤트 발행 (1ms) ← 즉시 반환
[트랜잭션 커밋] ← 0.01초 후

          ↓ 커밋 완료 신호

[별도 스레드 — notificationExecutor]
  ├─ 이벤트 수신
  ├─ 카카오 API #1 (500ms)
  ├─ 카카오 API #2 (500ms)
  └─ ... (사용자와 무관하게 백그라운드 처리)

 

[코드 구현]

1. 알림 발송 이벤트 추가 (NotificationSendEvent)

/**
 * 알림 발송 이벤트
 * 알림이 DB에 저장된 후 발송을 위해 발행되는 이벤트
 */
@Getter
public class NotificationSendEvent extends ApplicationEvent {
    private final Notification notification;

    public NotificationSendEvent(Object source, Notification notification) {
        super(source);
        this.notification = notification;
    }
}

 

2. NotificationService - 동기 발송 제거, 이벤트 발행으로 교체

// 변경 전
private final List<NotificationSender> notificationSenders;     // ← 제거
private final RetryableNotificationSender retryableNotificationSender; // ← 제거

@Transactional
public void createAndSendNotification(...) {
    Notification savedNotification = notificationRepository.save(notification);
    sendNotification(savedNotification); // ← 동기 API 호출
}

// 변경 후
private final ApplicationEventPublisher eventPublisher; // ← 추가

@Transactional
public void createAndSendNotification(...) {
    Notification savedNotification = notificationRepository.save(notification);
    
    // 이벤트 발행 — 즉시 반환, 실제 발송은 커밋 후 별도 스레드에서
    eventPublisher.publishEvent(new NotificationSendEvent(this, savedNotification));
}

 

3. 이벤트 리스너 추가

@Slf4j
@Component
@RequiredArgsConstructor
public class NotificationSendListener {

    private final List<NotificationSender> notificationSenders;
    private final RetryableNotificationSender retryableNotificationSender;

    @Async("notificationExecutor")           // 별도 스레드 풀에서 실행
    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) // 커밋 후에만 실행
    public void handleNotificationSend(NotificationSendEvent event) {
        Notification notification = event.getNotification();

        NotificationSender sender = findSender(notification);
        if (sender == null) {
            log.warn("지원하지 않는 채널: channel={}", notification.getChannel());
            return;
        }

        // 외부 API 연동이 필요한 채널은 재시도 메커니즘 적용
        boolean success;
        if (notification.getChannel().requiresExternalApi()) {
            success = retryableNotificationSender.sendWithRetry(sender, notification);
        } else {
            success = sender.send(notification);
        }

        if (!success) {
            log.warn("알림 발송 실패: notificationId={}", notification.getId());
        }
    }
}

 

4. 알림 전용 스레드 풀 설정

@Bean(name = "notificationExecutor")
public Executor notificationExecutor() {
    ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
    executor.setCorePoolSize(5);
    executor.setMaxPoolSize(10);
    executor.setQueueCapacity(100);
    executor.setThreadNamePrefix("notification-async-");
    executor.setWaitForTasksToCompleteOnShutdown(true);
    executor.setAwaitTerminationSeconds(60);
    executor.initialize();
    return executor;
}
구성 요소 역할 실행 위치 실행 스레드
NotificationService 알림 저장 후 이벤트 발행 트랜잭션 내부 요청 처리 스레드
NotificationSendEvent 발송할 알림 정보 전달 - -
NotificationSendListener 이벤트 수신 후 실제 발송 트랜잭션 커밋 후 notificationExecutor 스레드풀

 

이렇게 짜면 트랜잭션은 DB 저장 + 이벤트 발행만 하고 즉시 커밋된다. 카카오 API 호출은 커밋 이후 별도 스레드에서 처리되니, DB 커넥션 점유 구간에서 외부 API 호출이 완전히 사라지도록 만들었다.

 

그런데 여기서 문제가 몇개 더 생겼다.


추가 문제 발생🔥

문제 1. 트랜잭션이 끝난 엔티티를 그대로 넘기면 생기는 일

 

1차 리팩토링 내용을 PR로 올렸는데, CodeRabbit이 아래와 같은 리뷰를 남겨줬다.

 

처음 읽었을 땐 잘 이해가 안 돼서 실행 흐름에 따라 3단계로 쪼개 생각해봤다.

💡detached 상태란?
JPA에서 엔티티는 영속성 컨텍스트(EntityManager)가 관리하는 동안 managed 상태다. 트랜잭션이 커밋되면 영속성 컨텍스트가 닫히고, 그 안에서 관리되던 엔티티는 detached 상태가 된다. 쉽게 말해 "JPA의 관리권 밖으로 떨어진 상태"다.

managed 상태에서는 엔티티의 변경사항이 자동으로 DB에 반영되지만, detached 상태에서는 그렇지 않다. detached 엔티티를 save()하면 JPA는 DB에서 최신값을 가져오는 게 아니라, detached 시점의 스냅샷을 그대로 DB에 덮어쓴다.

 

1. 정상적으로 보이는 1차 코드의 흐름 

[트랜잭션 A 시작]
  │
  ├─ notification DB 저장 (managed 상태)
  │
  └─ 이벤트 발행 → NotificationSendEvent(notification 엔티티 통째로 담음)
  
[트랜잭션 A 커밋] ← 여기서 영속성 컨텍스트 종료
  │
  ▼
  notification 엔티티 → detached 상태로 전환 ⚠️

 

2. 문제가 발생하는 지점

트랜잭션이 커밋되는 순간 영속성 컨텍스트가 닫힌다. 이벤트 객체가 들고 있던 notification 엔티티는 이 시점부터 detached 상태, 즉 JPA가 더 이상 관리하지 않는 상태가 된다.

리스너 실행 (별도 스레드)
  │
  ├─ @Transactional → 새 트랜잭션 B 시작
  │   └─ 새 EntityManager 생성
  │       ❌ event.getNotification()은 관리 대상이 아님
  │
  ├─ notification.markAsSent() 호출
  │
  └─ notificationRepository.save(notification) 호출
      └─ JPA: "detached 엔티티니까 merge 해야겠다"
              │
              ▼
         DB에서 최신값을 가져오는 게 아니라
         detached 스냅샷을 그대로 덮어씀 ⚠️

 

3. 그래서 실제로 어떤 값이 덮어써지는지

타임라인
──────────────────────────────────────────────────────
t=0   트랜잭션 A 커밋
      notification 상태: { isRead: false, updatedAt: 10:00:00 }

t=1   사용자가 알림 읽음 처리 (다른 스레드)
      DB 상태: { isRead: true, updatedAt: 10:00:01 } ✅

t=2   리스너에서 detached 엔티티로 save() 호출
      merge 시 스냅샷: { isRead: false, updatedAt: 10:00:00 } ← t=0 시점 값
      DB 상태: { isRead: false, updatedAt: 10:00:00 } 💀 ← 읽음 처리가 사라짐!
──────────────────────────────────────────────────────

 

정리하면 이렇다.

상황 문제
리스너 실행 전 다른 스레드가 isRead 변경 detached 스냅샷으로 덮어써져 변경사항 유실
리스너 실행 전 다른 스레드가 updatedAt 변경 이전 시간으로 되돌아감
리스너 실행 전 다른 스레드가 updatedBy 변경 이전 값으로 덮어써짐

 

결국 '누가 언제 알림을 읽었는지' 같은 데이터가 조용히 사라질 수 있는 구조였다. 에러가 나지 않으니 인지하기도 어렵다는 게 더 무서운 점..!! 솔직히 1차 작업 당시엔 이 부분을 놓쳤다. 코드래빗이 짚어주지 않았다면 추후 문제가 생긴 뒤에 인지했을 수도 있었음😅 (정말 붙이기 잘했다)


 

문제 2. 같은 이벤트가 두 번 처리되면?

 

그리고 코드를 다시 들여다보면서 한 가지 더 눈에 걸리는 게 있었다. 멱등성이 전혀 보장되지 않는다는 점이었다.

💡멱등성(Idempotency)이란?
같은 작업을 여러 번 수행해도 결과가 동일한 성질을 말한다. 예를 들어 알림 발송 이벤트가 어떤 이유로 두 번 처리되더라도, 알림은 딱 한 번만 발송되어야 한다.

 

1차 코드에는 이런 보호 장치가 없었다.

이벤트가 두 번 처리될 수 있는 시나리오 (Spring Events 기준)
──────────────────────────────────────────────────────
1. 서버 재시작 타이밍
   이벤트 처리 중 → 서버 재시작 → 이벤트 재처리 → 중복 발송 💀

2. 트랜잭션 재시도 로직
   트랜잭션 재시도 → 이벤트 재발행 → 중복 발송 💀

3. 향후 메시지 브로커 전환 시
   Kafka/RabbitMQ 도입 시 at-least-once 보장으로 중복 처리 가능 💀
   → 지금 멱등성을 보장해두면 브로커 전환 시에도 안전

 

수신자 입장에서는 같은 알림이 두 번, 세 번 오는 상황이 생길 수 있었다. 지금은 테스트 환경이라 실제로 발생하지 않았지만, 운영 환경에서는 충분히 일어날 수 있는 케이스다.


2차 개선 - ID 기반으로 교체 + 멱등성 보장

앞에서 인지한 문제들을 이제 해결해보자. detached 문제는 CodeRabbit이 문제를 짚어주면서 해결 방향도 함께 제시해준 걸 참고했다.

"표준 해결책은
@TransactionalEventListener(AFTER_COMMIT) + @Transactional(propagation = REQUIRES_NEW)를 함께 사용하고, 리스너 내부의 새 트랜잭션 안에서 repository.findById(notificationId)로 최신 엔티티를 다시 조회하는 것입니다."

 

핵심은 두 가지다!

 

1. 이벤트에는 엔티티 전체 대신 ID만 전달한다. (이벤트 경량화)

detached 문제의 근본 원인은 트랜잭션이 끝난 엔티티를 이벤트 객체에 담아서 다른 스레드로 넘기는 것이었다. 엔티티 대신 ID만 넘기면 이 문제 자체가 생기지 않는다. 또한 이벤트가 가벼워지기 때문에, 나중에 Kafka나 RabbitMQ 같은 메시지 브로커로 전환할 때도 직렬화가 훨씬 수월해진다.

// 변경 전
public class NotificationSendEvent extends ApplicationEvent {
    private final Notification notification; // ← detached 위험
}

// 변경 후
public class NotificationSendEvent extends ApplicationEvent {
    private final UUID notificationId; // ← ID만 전달

    public NotificationSendEvent(Object source, UUID notificationId) {
        super(source);
        this.notificationId = notificationId;
    }
}
@Transactional
public void createAndSendNotification(...) {
    Notification savedNotification = notificationRepository.save(notification);

    // 엔티티 전체 대신 ID만 전달 ⭐
    eventPublisher.publishEvent(
        new NotificationSendEvent(this, savedNotification.getId())
    );
}

 

2. 리스너에서 새 트랜잭션을 열고 최신 managed 엔티티를 직접 조회한다. (리스너 개선)

ID만 받았으니 리스너에서 repository.findById()로 DB에서 최신 엔티티를 다시 조회한다. 이렇게 하면 새 트랜잭션의 EntityManager가 해당 엔티티를 managed 상태로 관리하게 되어, detached 스냅샷 덮어쓰기 문제가 사라진다. 조회 시점의 최신 상태를 기준으로 동작하기 때문에 다른 스레드의 변경사항도 안전하게 보존된다.

@Slf4j
@Component
@RequiredArgsConstructor
public class NotificationSendListener {

    //...생략

    @Async("notificationExecutor")
    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    @Transactional(propagation = Propagation.REQUIRES_NEW) // ← 새 트랜잭션
    public void handleNotificationSend(NotificationSendEvent event) {
        UUID notificationId = event.getNotificationId();

        // 최신 managed 엔티티 조회 (동시성 안전성 보장)
        Notification notification = notificationRepository.findById(notificationId)
                .orElseThrow(() -> new IllegalStateException(
                    "Notification not found: " + notificationId));

        //.. 생략
}

 

3. 멱등성 보장

해결 방법은 단순하다. 발송을 시도하기 전에 이미 발송된 알림인지 먼저 확인하는 것이다. Notification 엔티티에는 sendStatus 필드가 있고, 발송 완료 시 SENT로 업데이트된다. 이걸 이용하면 된다.

// NotificationSendListener 클래스에 추가
// 멱등성 보장 — 이미 발송된 알림은 재발송하지 않음
if (notification.isSent()) {
    log.debug("알림이 이미 발송됨, 스킵: notificationId={}", notificationId);
    return;
}

 

isSent() 체크로 이미 발송된 알림은 재발송하지 않도록 막았다. 흐름으로 보면?

이벤트 수신
    │
    ▼
DB에서 최신 엔티티 조회
    │
    ▼
isSent() 체크 ──── true ──→ 스킵 (중복 발송 방지) ✅
    │
   false
    │
    ▼
알림 발송
    │
    ▼
markAsSent() → DB 저장
    │
    ▼
같은 이벤트 재처리 시 → isSent() = true → 스킵 ✅

 

이렇게 하면 같은 이벤트가 몇 번 처리되든 알림은 딱 한 번만 발송된다. 단순해 보이지만 이벤트 기반 시스템에서는 필수적인 안전장치이므로 추후 이벤트 기반 시스템을 구현할 때 꼭 미리 생각하고 넘어갈 문제다.

 

 이렇게 ID 기반 조회와 멱등성 체크를 함께 써서 더 안전한 이벤트 기반 시스템을 구현했다!

문제 해결책 효과
detached 엔티티 덮어쓰기 ID 기반 최신 엔티티 조회 동시성 안전
중복 이벤트 처리 isSent() 체크 중복 발송 방지
=> 두 문제의 조합 ID 조회 후 isSent() 체크 최신 발송 상태 기준으로 판단 ✅

 

ID 기반으로 최신 엔티티를 조회하기 때문에, 혹시 다른 스레드에서 이미 SENT로 업데이트했다면 isSent()가 true를 반환해서 중복 발송을 막아준다. 두 가지 개선이 서로를 보완하는 구조라고 생각하면 된다.


 

이렇게 1차 리팩토링 후 2차 리팩토링까지 마쳐봤다. 최종 구조를 요약해보면 아래와 같다. 1차 개선 사항과 2차 개선 사항을 한번에 비교해볼 수 있다.

 

최종 구조 요약

항목 변경 전 1차 개선 2차 개선
발송 방식 동기 메서드 호출 이벤트 발행 ID 기반 이벤트 발행
실행 시점 트랜잭션 내부 커밋 후 커밋 후
이벤트 데이터 - Notification(알림) 엔티티 UUID
엔티티 상태 managed detached ⚠️ managed ✅
동시성 안전성 - ❌ 덮어쓰기 위험 ✅ 안전
멱등성 ✅ isSent() 체크
DB 커넥션 점유 50초+ 0.01초 0.01초

 

결과

[성능 개선]

API 응답 시간

시나리오 변경 전 변경 후 개선율
판매자 10명 알림 5초 약 0.1초 수준 98%
판매자 100명 알림 50초 약 0.1초 수준 99.8%
판매자 1000명 알림 500초 약 0.1초 수준 99.98%

 

DB 커넥션 점유 시간 (추정)

판매자 100명, API 평균 응답 500ms 기준:
변경 전: 100 × 500ms = 약 50초
변경 후: DB 저장만 → 약 수십ms 수준
개선율: 99.98% 감소

 

DB 커넥션 점유 시간이 50초에서 0.01초 수준으로 줄어들면서, 커넥션 풀을 훨씬 효율적으로 사용할 수 있게 됐다. 기존에는 커넥션 하나가 50초 동안 묶여 있었으니 동시 처리 가능한 요청 수가 극히 제한적이었지만, 변경 후에는 커넥션이 즉시 반환되어 같은 커넥션 풀로 훨씬 많은 요청을 처리할 수 있는 구조가 됐다.

 

비즈니스 관점에서 이 개선이 가지는 의미는 조금 더 크다.

 

1️⃣트랜잭션에서 외부 API 호출을 제거했다는 건 단순히 빨라졌다는 게 아니다. 카카오 API가 다운되더라도 견적 등록은 정상적으로 완료된다. 알림 발송 실패가 핵심 비즈니스 로직의 롤백을 유발하지 않도록 책임이 완전히 격리됐다.

 

2️⃣또한 판매자 수가 늘어날수록 응답 시간이 선형으로 증가하는 구조에서, 판매자가 몇 명이든 응답 시간이 일정하게 유지되는 구조로 바뀌었다. 서비스가 성장해도 이 부분에서 병목이 생기지 않는다.

 

이 경험을 통해 기술적으로는 아래와 같은 부분들을 배웠다.

 

1. @Async 만으로는 부족하다

단순히 비동기로 분리하는 것과, 트랜잭션 커밋 이후에 실행을 보장하는 것은 다른 문제다. 커밋 전에 알림이 나가버리면 데이터 정합성이 깨질 수 있다. @TransactionalEventListener(phase = AFTER_COMMIT)로 해결할 수 있었다.

 

2. 이벤트에 엔티티 전체를 담으면 위험하다

트랜잭션이 끝나면 엔티티는 detached 상태가 된다. 이걸 다른 스레드에서 그대로 쓰면 동시성 문제가 생길 수 있다. 이벤트는 가능한 한 가볍게 (예를 들면 ID만) 전달하고 리스너에서 새 트랜잭션으로 최신 상태를 다시 조회하는 패턴이 훨씬 안전하다.

 

3. 멱등성은 의식적으로 처음부터 고려하자

1차 개선에서 놓쳤던 부분이다. 이벤트 기반 시스템에서는 같은 이벤트가 두 번 처리될 수 있는 가능성을 항상 염두에 두어야 한다. isSent() 체크 하나로 중복 발송을 방지하는 건 단순해 보이지만, 설계 단계부터 의식적으로 넣지 않으면 빠뜨리기 쉽다.

 

4. 외부 API 호출은 트랜잭션 밖으로

당연한 말이지만, 이건 이번 경험에서 가장 크게 와닿은 부분이다. 외부 API는 언제든 느려지거나 실패할 수 있다. 그 불확실성을 트랜잭션 안에 가두면 DB 커넥션과 응답 시간이 외부 서비스에 종속된다. 외부 API 호출은 트랜잭션 바깥에서 격리된 방식으로 처리하는 것이 원칙이라는 걸 이번에 확실히 체감했다 🙄

 

[UX 관점에서의 변화]

숫자 개선과 기술적 리팩토링보다 중요한 것은 그래서 이걸로 결국 사용자가 실제로 어떤 변화를 체감하느냐라고 생각한다.

변경 전 사용자 경험
──────────────────────────────────────────────
사용자: "견적 등록" 버튼 클릭
    │
    ▼
[━━━━━━━━━━━━━━━━━━━━━━━━━━] 로딩 중... (50초)
    │
    ▼
결과: "등록 완료"

→ 사용자 이탈 가능성 높음
→ "버튼이 안 눌렸나?" 싶어서 중복 클릭 시도
→ 서비스 신뢰도 하락
변경 후 사용자 경험
──────────────────────────────────────────────
사용자: "견적 등록" 버튼 클릭
    │
    ▼
[━] 0.1초
    │
    ▼
결과: "등록 완료! 판매자들에게 알림 발송 중입니다."

→ 즉각적인 피드백
→ 중복 클릭 없음
→ 서비스 신뢰도 유지
항목 변경 전 변경 후
버튼 클릭 후 응답 시간 판매자 수 × 500ms 약 0.1초 (판매자 수 무관)
사용자 대기 체감 수십 초 로딩 즉각 응답
중복 클릭 가능성 높음 (응답 느려서) 낮음
외부 API 장애 시 사용자 영향 견적 등록 실패에 노출 견적 등록은 정상 완료
서비스 성장 시 응답 시간 판매자 늘수록 선형 증가 일정하게 유지

 

지금은 테스트 환경이라 판매자가 몇 명 없지만, 서비스가 성장해서 판매자가 수백 명이 되어도 사용자가 느끼는 응답 시간은 동일하다. 알림 발송이 아무리 늘어나도 사용자 경험에 영향을 주지 않는 구조로 바뀌었다.

 

향후 개선 방향

이번 리팩토링으로 당장의 문제는 해결했지만, 아직 개선할 여지가 남아있다.

 

1. 메시지 큐 도입

현재는 Spring Events 기반의 인메모리 방식이라 서버가 재시작되면 처리 중이던 이벤트가 유실될 수 있다. Kafka나 RabbitMQ 같은 메시지 브로커를 도입하면 서버 재시작 상황에서도 알림 유실 없이 처리할 수 있다.

이번에 이벤트에 ID만 담도록 리팩토링 한 건 메시지 브로커 전환을 더 쉽게 할 수 있도록 도와준다. 엔티티 전체를 직렬화할 필요 없이 ID만 전달하면 되니, 메시지 브로커로의 전환이 훨씬 수월하다.

 

2. Circuit Breaker 적용

카카오 API가 일시적으로 불안정한 상황에서 계속 호출을 시도하면 오히려 시스템 전체에 부하를 줄 수 있다. Circuit Breaker를 적용해서 일정 횟수 이상 실패 시 호출을 차단하고, 일정 시간 후 다시 시도하는 방식으로 외부 서비스 장애가 내부로 전파되지 않도록 격리할 예정이다. (별도 포스팅으로 정리할 예정)

 

3. 배치 발송

현재는 수신 대상마다 API를 개별 호출하는 구조다. 일정 시간 동안 발송 대상을 모아서 한 번에 처리하는 배치 방식으로 전환하면 API 호출 횟수 자체를 줄일 수 있다.

 

마무리

처음엔 단순히 '알림 발송이 느리네, 비동기로 바꾸면 되겠다'라고 생각했던 작업이었다. 그런데 막상 파고들다 보니 트랜잭션 경계, JPA 엔티티 생명주기, 멱등성까지 꼬리에 꼬리를 무는 문제들이 나왔다.

1차에서 놓쳤던 detached 문제를 코드리뷰 봇이 짚어준 덕분에 2차 개선까지 이어질 수 있었는데, 코드리뷰 봇 없이 혼자 개발했다면 운영 환경이나 QA때 조용히 데이터가 오염되는 걸 한참 뒤에야 발견했겠다 싶다.. 자동화된 코드리뷰의 중요성을 새삼 느꼈다 😊

 

긴 글 읽어주셔서 감사합니다 🙇‍♀️