배경 및 목표
수백 명 지원자를 일괄로 AI 서류 평가 요청할 때, 단건 처리 방식으로는 트랜잭션 시간이 급증하고 외부 AI 서비스에 순간 부하가 발생했습니다. 건당 AI 평가에 수 분이 소요되어, 대량 요청 시 외부 시스템 장애 위험도 있었습니다.
graph LR subgraph problem["❌ 기존: 단건 처리"] C["Client"] --> S["Service"] S -->|"단건 INSERT x N"| DB[("DB")] S -->|"동기 호출 x N"| AI["AI 평가"] end
목표
- 수백 건 일괄 요청에도 트랜잭션 시간이 급증하지 않도록 처리한다.
- 외부 AI 서비스에 순간 부하를 주지 않도록 요청을 분산한다.
해결 방법과 해결 후보군
1. No Offset 페이징으로 조회 최적화
offset 기반 페이징은 OFFSET N만큼 행을 건너뛰는 연산이 필요하여 데이터가 많을수록 느려집니다. 대신 클러스터링 인덱스(PK)를 직접 활용하는 No Offset 방식을 적용했습니다.
fun sliceMemberBy(lastId: Int): Slice<Int> {
val result = from(member)
.select(member.id)
.where(member.id.gt(lastId))
.limit(CHUNK_SIZE + 1) // hasNext 판단용 +1
.orderBy(member.id.asc())
.fetch()
return SliceImpl(
result.take(CHUNK_SIZE),
PageRequest.ofSize(CHUNK_SIZE),
result.size > CHUNK_SIZE // hasNext
)
}WHERE id > lastId ORDER BY id LIMIT 100 형태로 쿼리하면 인덱스를 직접 탐색하여 데이터량과 무관한 일정한 성능을 보장합니다. chunk size + 1로 조회하여 hasNext 여부도 추가 쿼리 없이 판단합니다.
graph LR Q1["lastId=0"] -->|"id > 0"| R1["1~100"] R1 -->|"lastId=100"| Q2["id > 100"] Q2 --> R2["101~200"]
2. JdbcTemplate 벌크 INSERT
JPA saveAll()은 내부적으로 건별 INSERT를 수행하므로 200건이면 200개의 쿼리가 실행됩니다. JdbcTemplate.batchUpdate()를 사용하면 1개의 배치 쿼리로 200건을 한 번에 처리합니다.
fun bulkInsert(requests: List<ReviewRequest>) {
val sql = """
INSERT INTO review_request(id, member_id, status)
VALUES (?, ?, ?)
"""
jdbcTemplate.batchUpdate(sql, object: BatchPreparedStatementSetter {
override fun setValues(ps: PreparedStatement, i: Int) {
ps.setLong(1, requests[i].id)
ps.setLong(2, requests[i].memberId)
ps.setString(3, requests[i].status)
}
override fun getBatchSize() = requests.size
})
}| 방식 | 200건 기준 | 쿼리 수 |
|---|---|---|
| JPA saveAll() | 느림 | 200개 |
| JdbcTemplate batchUpdate | 80ms | 1개 |
3. Kafka Pull 방식으로 외부 부하 분산
동기 호출 대신 Kafka에 메시지만 발행하고, AI 서비스가 처리 가능한 만큼만 Pull하도록 했습니다. 프로듀서(자사)는 빠르게 메시지를 적재하고, 컨슈머(AI 서비스)는 자기 처리 속도에 맞게 가져가므로 순간 부하가 발생하지 않습니다.
graph TB C["Client"] -->|"1. API 요청"| S["Service"] S -->|"2. 이벤트 발행"| AE["EventPublisher"] AE -->|"3. 핸들러"| EH["EventHandler"] EH -->|"4. No Offset + 벌크 INSERT"| DB[("DB")] EH -->|"5. 단건 메시지 발행"| K["Kafka"] AI["AI 서비스"] -->|"6. Pull (처리 가능한 만큼)"| K
전체 처리 흐름은: 클라이언트 요청 → 이벤트 발행 → 청크 단위 페이징 → 벌크 INSERT → Kafka 메시지 발행 → AI 서비스가 Pull 방식으로 처리. 처리량 확장이 필요하면 파티션 증설로 수평 확장할 수 있습니다.
결과
| 구간 | 처리 시간 |
|---|---|
| 클라이언트 응답 | 40~50ms |
| 벌크 연산 (200건) | 80ms |
| 외부 시스템 부하 | Pull 방식으로 부담 없음 |
모니터링
- 벌크 INSERT 처리 시간과 Kafka 컨슈머 랙(Pull 처리 적체)을 관측한다.
- 외부 AI 요청 성공률·처리 지연을 관측한다.