배경 및 목표
동기 처리에서는 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 컨슈머 랙과 메일 발송 실패율을 관측한다.