배경 및 목표
ORP 인적성 검사 연동은 검사 요청과 결과 조회 두 지점 모두에서 동기 처리로 인한 지연이 있었습니다.
요청 지점에서는 100명의 인적성 검사를 동기로 처리하면 2~3분 대기가 발생했습니다. 건당 외부 API 호출 시간이 누적되어 채용담당자가 오랜 시간 기다려야 했고, 부분 실패 시 이미 요청된 건을 취소할 수 없는 구조였습니다.
조회 지점에서는 채용담당자가 인적성 검사 결과를 조회할 때 외부 서비스에서 실시간으로 파일을 가져와 처리하는 구조였습니다. 최초 조회 시 파일 다운로드, 파싱에 6초가 걸렸고, 그 동안 DB 커넥션을 점유하여 다른 요청에도 영향을 주었습니다.
목표
- 100명 규모 동기 처리의 2~3분 대기를 없애고 즉시 응답합니다.
- 부분 실패 시에도 외부 메일 오발송 없이 데이터 정합성을 보장합니다.
- 최초 조회 시 6초 지연, 커넥션 점유를 없애고 즉시 응답합니다.
- 롱 트랜잭션 없이 결과지를 사전 적재합니다.
해결 방법과 해결 후보군
검사 요청과 결과 조회 두 축을 각각 비동기화했습니다. 요청은 Kafka로 즉시 응답하고, 결과는 Pull에서 Push로 전환해 사전 적재합니다.
후보군 비교
검사 요청 축입니다.
| 방식 | 설명 | 한계 |
|---|---|---|
| 동기 순차 처리 | 건별 순차 호출 | 100명 2~3분, 부분 실패 취소 불가 |
| 스레드풀 병렬 | 애플리케이션 내 병렬 | 외부 rate limit 부담, 유실 위험 |
| Kafka 비동기 (채택) | 건별 메시지 발행 후 1건씩 순차 소비 | 즉시 응답, 건별 정합성, 재처리 (안정성 우선) |
검사 결과 축입니다.
| 방식 | 설명 | 한계 |
|---|---|---|
| 실시간 폴링 | 조회 시점에 외부 처리 | 6초 지연, 커넥션 점유 |
| 조회 결과 캐싱 | 첫 조회 후 캐시 | 첫 조회는 여전히 6초 |
| 사전 배치 적재 (채택) | 미리 가져와 DB 적재 | 조회 즉시 응답, 롱 트랜잭션 제거 |
1. 검사 요청 비동기화 (Kafka 즉시 응답, 건별 트랜잭션, 진행률)
100명을 순차 호출하는 대신, 작업 정보를 저장하고 작업 ID를 즉시 반환합니다. 이후 Kafka에 건별 메시지를 발행하고, 컨슈머는 안정성을 우선해 메시지를 1건씩 순차 소비합니다.
fun requestExam(targetIds: List<Long>): Long {
val taskId = taskRepository.save(...) // 작업 정보 저장
targetIds.forEach { id ->
kafkaTemplate.send("queue.external.request", RequestMessage(id))
}
return taskId // 클라이언트에 작업 ID 즉시 응답, 실제 처리는 컨슈머에서
}
@KafkaListener(topics = ["queue.external.request"])
fun consume(message: RequestMessage) {
externalClient.request(message.targetId) // 1건씩 순차 처리 (안정성 우선)
}컨슈머가 각 건을 독립적인 트랜잭션으로 처리합니다. 1건이 실패해도 나머지는 정상 처리되며, 실패 건은 별도로 기록하여 재처리할 수 있습니다.
클라이언트는 작업 ID를 받은 뒤 처리 현황을 주기적으로 조회해 실시간 진행률을 확인합니다.
@GetMapping("/exam-requests/{taskId}/progress")
fun getProgress(@PathVariable taskId: Long): ProgressResponse {
return taskRepository.findProgressBy(taskId) // 현재 진행률 반환
}sequenceDiagram participant C as Client participant API as API participant K as Kafka participant W as Worker participant EXT as 외부 서비스 participant DB as DB C->>API: 검사 요청 (100명) API->>DB: 작업 정보 저장 API-->>C: 즉시 응답 (작업 ID) API->>K: 건별 메시지 발행 Note over K,W: 순차 처리 (1건씩 소비, 안정성 우선) loop 메시지 소비 (1건씩) K->>W: 메시지 소비 W->>EXT: 외부 API 호출 EXT-->>W: 응답 W->>DB: 결과 저장 & 커밋 end Note over C,API: 진행률 확인 loop 주기적 조회 C->>API: 처리 현황 조회 API-->>C: 현재 진행률 응답 end
2. 검사 결과 사전 적재 (Pull → Push 전환)
조회 시점에 외부 서비스를 호출하는 Pull 방식 대신, 배치 스케줄러가 5분마다 미처리 검사 건을 Kafka에 발행하고, 워커가 이를 소비해 외부 결과를 미리 가져와 DB에 적재하는 Push 구조로 변경했습니다. 파일 다운로드, 파싱 같은 무거운 처리를 사용자 요청 경로에서 백그라운드 워커로 옮겨, 조회 시점에는 적재된 데이터만 반환합니다.
// 배치 스케줄러: 5분마다 미처리 건을 Kafka에 발행
@Scheduled(fixedDelay = 5 * 60 * 1000)
fun dispatchPendingResults() {
resultRepository.findPendingIds().forEach { id ->
kafkaTemplate.send("queue.exam.result.preload", PreloadMessage(id))
}
}
// 결과 처리 워커: 외부 조회, 가공 후 사전 적재
@KafkaListener(topics = ["queue.exam.result.preload"])
fun consume(message: PreloadMessage) {
val result = externalClient.fetchResult(message.examId) ?: return
resultRepository.save(result.toEntity())
}채용담당자가 결과를 조회하면, 이미 적재된 데이터를 반환하므로 외부 호출이 불필요하고 DB 커넥션 점유 문제도 해소되었습니다.
결과
| 지표 | 기존 | 개선 |
|---|---|---|
| 요청 응답 | 2~3분 대기 | 즉시 (작업 ID 반환) |
| 처리 방식 | 동기 순차 | 비동기 순차 (1건씩 소비, 안정성 우선) |
| 부분 실패 | 전체 영향 | 건별 독립 |
| 진행률 확인 | 불가능 | 실시간 확인 가능 |
| 결과 조회 | 6초 | 1초 이하 (83% 단축) |
| 커넥션 점유 | 길음 | 해소 |
| 롱 트랜잭션 | 발생 | 제거 |
모니터링
- 검사 요청 큐의 컨슈머 랙과 건별 처리 성공/실패를 관측합니다.
- 외부 API 응답 지연, 실패율을 관측합니다.
- 결과지 조회 응답시간(6초→1초 이하)과 커넥션 점유 시간을 관측합니다.
- 사전 배치 처리 성공률, 지연을 관측합니다.