API 하나가 병원 전체를 멈췄다 — 단일 API 비대화의 함정
환자+메타+결과를 한 API에서 다 주던 구조가 왜 무너졌는지. No-Offset 페이징, API 분리, 비동기 전환까지 실제 개선 과정.
API 하나가 병원 전체를 멈췄다 — 단일 API 비대화의 함정
상황
SNSB-3 플랫폼은 병원마다 검사 데이터의 양이 크게 달랐다. 소규모 병원은 환자 수십 명, 대형 병원은 수천 명의 검사 이력이 쌓여 있었다.
어느 날 대형 병원에서 연락이 왔다. 검사 이력 화면을 열면 브라우저가 멈추거나 타임아웃이 난다는 것이었다. 우리 쪽에서 확인해 보니 같은 화면이 소규모 병원에서는 1초 안에 뜨고, 해당 대형 병원에서는 10초가 넘어도 응답이 없었다.
API 구조를 뜯어보니
문제의 API는 이렇게 생겼다.
// GET /api/examinations
fun getExaminations(): List<ExaminationResponse> {
val patients = patientRepository.findAll() // 전체 환자 조회
return patients.map { patient ->
val meta = metaRepository.findByPatientId(patient.id) // N번 조회
val results = resultRepository.findByPatientId(patient.id) // N번 조회
ExaminationResponse(patient, meta, results)
}
}환자 목록, 메타 정보, 검사 결과를 하나의 API에서 한 번에 내려주는 구조였다. 개발 초기에는 편리했다. 프론트엔드가 한 번만 호출하면 화면에 필요한 모든 데이터가 왔다.
문제는 데이터가 쌓이면서 나타났다.
발생하는 문제들
- N+1 쿼리: 환자 1,000명이면 메타 조회 1,000번 + 결과 조회 1,000번 = 쿼리 2,001번 발생
- Full Scan: 페이징 없이 전체 데이터를 한 번에 가져옴
- 직렬 처리: 각 환자의 메타와 결과를 순서대로 조회
- 단일 응답 블로킹: 모든 처리가 끝날 때까지 클라이언트가 기다림
환자가 100명일 때는 이 문제들이 숨어 있었다. 1,000명이 되자 선형으로 증가한 비용이 한꺼번에 터졌다.
왜 초기 구조가 합리적으로 보였나
이 구조를 만든 사람을 탓하기 어렵다.
개발 초기 병원 1~2곳을 대상으로 테스트할 때는 환자 수가 적었다. 화면에 필요한 데이터를 한 번의 API 호출로 해결하는 건 프론트엔드 입장에서 편리하고, 백엔드 구조도 단순하다. 빠른 출시를 위한 합리적인 선택이었다.
문제는 이 구조가 데이터 크기에 선형으로 민감하다는 것이다. 처음에 검증한 환경과 실제 운영 환경의 데이터 규모가 달랐고, 그 차이가 성능 문제로 폭발했다.
개선 방향: 세 가지 분리
1. API 분리: 목록과 상세를 나눈다
화면 요구사항을 다시 분석했다. 목록 화면에서 실제로 필요한 데이터는 환자 이름, 검사 일시, 상태 정도였다. 상세 결과는 특정 항목을 클릭했을 때만 필요했다.
[변경 전]
GET /api/examinations → 환자 전체 + 메타 + 결과 (한 번에)
[변경 후]
GET /api/examinations → 목록 (id, 이름, 일시, 상태만)
GET /api/examinations/{id} → 단건 상세 (클릭 시 호출)
목록 API는 가벼운 데이터만 반환하고, 상세는 필요할 때만 호출한다. 한 번에 내려가는 데이터 양이 극적으로 줄었다.
2. No-Offset 페이징: Offset의 함정
목록 API에 페이징을 붙이기로 했다. 그런데 일반적인 Offset 페이징에는 숨겨진 비용이 있다.
-- Offset 방식
SELECT * FROM examinations ORDER BY created_at DESC LIMIT 20 OFFSET 1000;OFFSET 1000은 처음 1,000건을 읽고 버린 뒤 20건을 반환한다는 의미다. 뒤 페이지로 갈수록 읽고 버리는 행이 늘어난다. 페이지 1은 20건 읽기, 페이지 50은 1,020건 읽기다. 페이지 번호가 커질수록 느려진다.
No-Offset 방식은 이 문제를 피한다.
-- No-Offset (Cursor 방식)
SELECT * FROM examinations
WHERE id < :lastId -- 마지막으로 본 ID 기준
ORDER BY id DESC
LIMIT 20;마지막으로 받은 항목의 ID를 기준으로 다음 20건을 가져온다. 앞에 몇 건이 있든 상관없이 항상 인덱스를 타서 일정한 속도가 나온다.
// No-Offset 페이징 적용
fun getExaminations(lastId: Long?, size: Int = 20): List<ExaminationSummary> {
return if (lastId == null) {
examinationRepository.findTopN(size)
} else {
examinationRepository.findByIdLessThan(lastId, size)
}
}단, No-Offset은 "페이지 5로 바로 가기" 같은 특정 페이지 이동이 안 된다. 무한 스크롤이나 "더보기" UI에 적합하다. 이 플랫폼은 목록을 위에서 아래로 탐색하는 패턴이어서 No-Offset이 맞았다.
3. 비동기 전환: 상세 데이터를 병렬로
목록/상세를 분리했어도 상세 API에서 메타와 결과를 순서대로 가져오는 건 여전히 느렸다. 이걸 Non-blocking 비동기 호출로 바꿨다.
// 변경 전: 순차 처리
fun getExaminationDetail(id: Long): ExaminationDetail {
val meta = metaRepository.findByExaminationId(id) // 대기
val results = resultRepository.findByExaminationId(id) // 대기
return ExaminationDetail(meta, results)
}
// 변경 후: 병렬 처리 (Kotlin Coroutine)
suspend fun getExaminationDetail(id: Long): ExaminationDetail = coroutineScope {
val metaDeferred = async { metaRepository.findByExaminationId(id) }
val resultsDeferred = async { resultRepository.findByExaminationId(id) }
val meta = metaDeferred.await()
val results = resultsDeferred.await()
ExaminationDetail(meta, results)
}메타와 결과를 동시에 조회한다. 각각 200ms가 걸렸다면 순차는 400ms, 병렬은 200ms다. DB 쿼리가 서로 독립적이라면 병렬로 돌릴 수 있다.
엑셀 다운로드: 배치로 분리
한 가지 더 있었다. 검사 이력 전체를 엑셀로 내보내는 기능이었다. 대형 병원은 수천 건의 데이터를 엑셀로 받는다. 이걸 단일 요청으로 처리하면 타임아웃이 난다.
엑셀 다운로드는 배치로 분리했다.
요청 → "생성 중" 즉시 응답
→ 백그라운드 배치 실행
→ 청크 단위로 데이터 읽기 + 엑셀 작성
→ S3 업로드 완료
조회 → S3에서 파일 다운로드 제공
청크 단위 처리는 Spring Batch의 Chunk-oriented processing을 활용했다. 1,000건씩 읽어서 쓰고, 실패 시 해당 청크부터 재시작한다.
@Bean
fun excelExportStep(): Step = stepBuilderFactory.get("excelExportStep")
.chunk<ExaminationData, ExcelRow>(1000) // 1000건 단위 처리
.reader(examinationReader())
.processor(examinationProcessor())
.writer(excelWriter())
.build()결과
| 항목 | 개선 전 | 개선 후 |
|---|---|---|
| 목록 API 응답 시간 (대형 병원) | 10초+ (타임아웃) | 200~500ms |
| 상세 API 응답 시간 | 400ms (순차) | 200ms (병렬) |
| 목록 API 쿼리 수 | N+1 (환자 수만큼) | 1 |
| 페이지 50 응답 시간 | Offset 방식으로 지속 증가 | No-Offset으로 일정 유지 |
| 엑셀 다운로드 | 단일 요청 타임아웃 | 배치 처리, 완료 후 다운로드 |
| 브라우저 프리징 | 발생 | 해소 |
체감 응답이 2~3배 개선됐다는 것 외에, 저사양 PC에서도 안정적으로 동작하게 됐다는 게 중요한 지점이었다. 병원 현장은 최신 장비가 아닌 경우가 많다. 느린 응답은 화면 렌더링 부하로 이어져 PC 자체가 느려 보이는 효과가 생긴다.
마무리
단일 API 비대화 문제는 대부분 초기에는 보이지 않는다. 데이터가 쌓이고 사용자가 늘어야 드러난다. 그래서 문제가 생기기 전에 대비하기도 어렵다.
그런데 몇 가지 신호가 있다.
- API 응답 크기가 화면에 필요한 것보다 크다 — 쓰지 않는 필드가 내려오고 있는지 확인
- 응답 시간이 데이터 양에 비례해서 느려진다 — 선형 복잡도 구조일 가능성
- 페이지 뒤로 갈수록 느려진다 — Offset 페이징 문제
이 신호들이 보이면 일찍 분리하는 게 낫다. 데이터가 10배가 됐을 때 10배 느려지는 구조인지, 아닌지가 시스템 안정성의 차이를 만든다.
목록은 가볍게, 상세는 필요할 때만, 무거운 작업은 비동기로. 이 세 원칙이 이번 개선의 핵심이었다.