배경 및 목표

동기 처리에서는 50명이 한계(100초 대기)였고, 부분 실패 시 롤백해도 이미 발송된 메일을 되돌릴 수 없었습니다. 채용담당자가 대량 탈락 처리를 할 때마다 오래 기다려야 했고, 중간에 실패하면 어디까지 처리되었는지 파악도 어려웠습니다.

목표

  • 동기 처리의 50명 제한을 없애고 대량 벌크 액션을 처리한다.
  • 부분 실패 시에도 메일 오발송 없이 건별로 정합성을 보장한다.

해결 방법과 해결 후보군

후보군 비교

방식설명한계
동기 + 타임아웃 확대대기 시간만 늘림여전히 느림, 부분 실패 시 메일 롤백 불가
스레드풀 비동기애플리케이션 내 병렬 처리서버 재시작 시 유실, 진행률 추적 어려움
Kafka 큐 (채택)건별 메시지 발행부분 실패 격리 + 진행률 추적 + 수평 확장

1. Kafka 비동기 전환으로 즉시 응답

클라이언트 요청을 받으면 Kafka에 건별 메시지만 발행하고 즉시 jobId를 반환합니다.

fun bulkReject(targetIds: List<Long>): String {
    val jobId = UUID.randomUUID().toString()
    targetIds.forEach { id ->
        kafkaTemplate.send("queue.domain.reject", 
            RejectMessage(jobId, id))
    }
    return jobId  // 즉시 반환
}

2. 건별 트랜잭션으로 부분 실패 허용

기존에는 전체를 하나의 트랜잭션으로 묶었기 때문에, 1건 실패 시 전체가 롤백되거나, 전체 성공이지만 메일은 이미 발송되어 되돌릴 수 없었습니다. 메시지 1건 = 트랜잭션 1건으로 분리하여, 1건 실패해도 나머지는 정상 처리됩니다.

@KafkaListener(topics = ["queue.domain.reject"])
fun consume(message: RejectMessage) {
    try {
        domainService.reject(message.targetId)  // 개별 트랜잭션
        emailService.sendNotification(message.targetId)
        progressService.update(message.jobId, SUCCESS)
    } catch (e: Exception) {
        progressService.update(message.jobId, FAILED)
        // 1건 실패해도 나머지 메시지는 정상 처리
    }
}

3. 진행률 추적 API

jobId로 진행률을 조회할 수 있는 API를 제공했습니다. 워커가 각 건을 처리할 때마다 진행 상태를 업데이트하여, 채용담당자가 실시간 진행률을 확인할 수 있게 했습니다.

sequenceDiagram
    participant C as Client
    participant API as API
    participant K as Kafka
    participant W as Worker
    C->>API: 벌크 탈락 요청 (500명)
    API-->>C: 즉시 응답 (jobId)
    API->>K: 건별 메시지 발행
    loop 건별 처리
        K->>W: 메시지 처리
        W->>W: 탈락 + 메일 발송
        W->>API: 진행률 업데이트
    end
    C->>API: 진행률 조회 (jobId)
    API-->>C: 350/500 완료

결과

지표기존개선
처리 규모50명500명 (10배)
응답100초 대기즉시
진행률확인 불가실시간

모니터링

  • 벌크 작업의 진행률·성공/실패 건수를 추적 API로 관측한다.
  • Kafka 컨슈머 랙과 메일 발송 실패율을 관측한다.