배경 및 목표

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초 이하)과 커넥션 점유 시간을 관측합니다.
  • 사전 배치 처리 성공률, 지연을 관측합니다.