API설계2026년 3월 28일5분 읽기

한 API에 너무 많은 걸 담았을 때

환자 목록·메타·결과처럼 서로 연관 없는 조회를 한 API에 다 묶어 내려주던 구조가 데이터가 쌓이자 무너졌다. API 분리, No-Offset 페이징, 병렬 조회로 풀어낸 과정.

#API설계#페이징#NoOffset#비동기#DB성능#Backend

한 API에 너무 많은 걸 담았을 때

상황

SNSB-3 플랫폼은 병원마다 검사 데이터 양이 크게 달랐다. 소규모 병원은 환자 수십 명, 대형 병원은 수천 명의 검사 이력이 쌓여 있었다.

어느 날 대형 병원에서 연락이 왔다. 검사 이력 화면을 열면 브라우저가 멈추거나 타임아웃이 난다고 했다. 확인해 보니 같은 화면이 소규모 병원에서는 1초 안에 뜨고, 그 대형 병원에서는 10초가 넘어도 응답이 없었다.

API 구조를 뜯어보니

문제의 API는 이렇게 생겼다.

kotlin
// 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에서 한 번에 내려주는 구조였다. 개발 초기에는 편했다. 프론트엔드가 한 번만 호출하면 화면에 필요한 모든 데이터가 왔다.

문제는 데이터가 쌓이면서 나타났다.

  1. N+1 쿼리: 환자 1,000명이면 메타 조회 1,000번 + 결과 조회 1,000번 = 쿼리 2,001번
  2. Full Scan: 페이징 없이 전체 데이터를 한 번에 가져옴
  3. 직렬 처리: 각 환자의 메타와 결과를 순서대로 조회
  4. 단일 응답 블로킹: 모든 처리가 끝날 때까지 클라이언트가 대기

환자가 100명일 때는 이 문제들이 숨어 있었다. 1,000명이 되자, 환자 수에 비례해 늘어난 비용이 한꺼번에 드러났다.

초기엔 합리적인 선택이었다

이 구조를 처음 짠 걸 잘못이라고 보긴 어렵다. 병원 한두 곳, 환자 수십 명 규모에선 한 번의 호출로 화면을 다 채우는 게 프론트도 백엔드도 단순하고 빠른 출시에도 유리했다. 비즈니스 초기의 기민함과 데이터 성장기의 안정성은 애초에 다른 구조를 요구한다. 처음 검증한 환경과 실제 운영 환경의 데이터 규모가 달랐고, 그 차이가 성능 장애로 터졌을 뿐이다. 그래서 데이터를 줄이는 임시방편 대신 구조를 고치기로 했다.

무거운 API를 하나씩 쪼갰다

한 번에 다 뜯어고친 게 아니라 순서대로 하나씩 적용했다. 목록과 상세를 나누고, 페이징을 No-Offset으로 바꾸고, 상세 조회를 병렬로 돌리고, 마지막으로 엑셀 다운로드를 배치로 뺐다.

목록과 상세를 나눈다

화면 요구사항을 다시 봤다. 목록 화면에서 실제로 필요한 건 환자 이름, 검사 일시, 상태 정도였다. 상세 결과는 특정 항목을 클릭했을 때만 필요했다.

text
[변경 전]
GET /api/examinations → 환자 전체 + 메타 + 결과 (한 번에)

[변경 후]
GET /api/examinations      → 목록 (id, 이름, 일시, 상태만)
GET /api/examinations/{id} → 단건 상세 (클릭 시 호출)

목록 API는 가벼운 데이터만 반환하고 상세는 필요할 때만 호출한다. 한 번에 내려가는 데이터 양이 크게 줄었다.

페이징을 No-Offset으로

목록 API에 페이징을 붙였다. 사실 그때 Offset 페이징을 제대로 알고 시작한 건 아니었다. 그냥 "화면에서 마지막으로 본 환자 다음부터 보여주면 되는 거 아닌가" 싶어서 마지막 ID 기준 부등호 쿼리(id < :lastId)부터 박았다. 나중에 보니 이게 No-Offset(커서) 페이징이었고, 이 케이스엔 마침 그게 맞는 선택이었다. Offset 방식을 알고 나서 둘을 비교해보니 차이가 분명해서 정리해둔다.

sql
-- Offset 방식
SELECT * FROM examinations ORDER BY created_at DESC LIMIT 20 OFFSET 1000;

OFFSET 1000은 처음 1,000건을 읽고 버린 뒤 20건을 반환한다는 뜻이다. 뒤 페이지로 갈수록 읽고 버리는 행이 늘어난다. 페이지 1은 20건 읽기, 페이지 50은 1,020건 읽기다. 페이지 번호가 커질수록 느려진다.

sql
-- No-Offset (Cursor 방식)
SELECT * FROM examinations
WHERE id < :lastId  -- 마지막으로 본 ID 기준
ORDER BY id DESC
LIMIT 20;

마지막으로 받은 항목의 ID를 기준으로 다음 20건을 가져온다. 앞에 몇 건이 있든 상관없이 항상 인덱스를 타서 일정한 속도가 나온다.

kotlin
// 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이 맞았다.

상세 데이터를 병렬로

목록과 상세를 분리해도 상세 API에서 메타와 결과를 순서대로 가져오는 건 여전히 느렸다. 이걸 Non-blocking 비동기 호출로 바꿨다.

kotlin
// 변경 전: 순차 처리
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 쿼리가 서로 독립적이면 병렬로 돌릴 수 있다.

엑셀 다운로드: 배치로 분리

한 가지 더 있었다. 검사 이력 전체를 엑셀로 내보내는 기능이다. 대형 병원은 수천 건을 엑셀로 받는다. 이걸 단일 요청으로 처리하면 타임아웃이 난다.

엑셀 다운로드는 배치로 분리했다.

text
요청 → "생성 중" 즉시 응답
      → 백그라운드 배치 실행
      → 청크 단위로 데이터 읽기 + 엑셀 작성
      → S3 업로드 완료

조회 → S3에서 파일 다운로드 제공

청크 단위 처리는 Spring Batch의 Chunk-oriented processing을 썼다. 1,000건씩 읽어서 쓰고, 실패 시 해당 청크부터 재시작한다.

kotlin
@Bean
fun excelExportStep(): Step = stepBuilderFactory.get("excelExportStep")
    .chunk<ExaminationData, ExcelRow>(1000)  // 1000건 단위 처리
    .reader(examinationReader())
    .processor(examinationProcessor())
    .writer(excelWriter())
    .build()

결과

숫자로 보면 이렇다.

  • 대형 병원에서 10초+ 타임아웃이 나던 목록 화면이 200~500ms로 떨어졌다. 환자 수만큼 날아가던 쿼리 2,001번이 1번으로 줄었으니 당연한 결과다.
  • 상세 조회는 순차 400ms에서 병렬 200ms로 절반이 됐다.
  • 페이지를 아무리 뒤로 넘겨도 속도가 일정하다. Offset이었다면 뒤로 갈수록 느려졌을 구간이다.
  • 수천 건짜리 엑셀 내보내기는 더 이상 타임아웃이 아니라, 백그라운드에서 만들어두고 받아가는 방식이 됐다.
  • 브라우저가 멈추던 증상도 사라졌다.

숫자보다 중요했던 건 저사양 PC에서도 화면이 그냥 떴다는 점이다. 병원 현장은 최신 장비가 아닌 경우가 많은데, 느린 응답은 화면 렌더링 부하로 이어져 PC 자체가 느려 보이는 효과를 낸다. 그게 사라지면 같은 장비인데도 체감 속도가 확 달라진다.

마무리

서로 연관도 없는 조회를 한 요청에 다 묶어두면, 그 부담은 초기엔 안 보인다. 데이터가 쌓이고 사용자가 늘어야 드러난다. 그래서 문제가 생기기 전에 대비하기도 어렵다.

내가 이런 구조를 볼 때 제일 먼저 보는 건 하나다. 환자가 두 배로 늘면 쿼리도 두 배로 늘어나는 구조인가. 그렇다면 지금 멀쩡해도 시간문제다. 그래서 요즘은 이런 API를 보면 응답에 화면이 안 쓰는 필드가 섞여 있는지, 페이지를 뒤로 넘길수록 느려지는지부터 본다. 둘 중 하나라도 걸리면 데이터가 더 쌓이기 전에 손대는 편이다. 이번 건도 대형 병원에서 먼저 터졌을 뿐, 안 고쳤으면 작은 병원도 순서대로 무너졌을 거다.

#API설계#페이징#NoOffset#비동기#DB성능#Backend

황호민

Backend Engineer · Java/Kotlin · Spring Boot · Next.js