REST API가 느릴 때: 병목을 찾는 순서
“API가 느려요"는 백엔드 개발자가 가장 자주 받는 신고이자, 범위가 가장 넓은 말이기도 합니다. 느린 곳은 데이터베이스일 수도, 직렬화일 수도, 외부 API일 수도, 네트워크일 수도 있습니다. 이 글은 그 후보들을 감이 아니라 순서로 좁히는 방법을 정리합니다. 서버·인프라 레벨의 진단은 서버가 느린 이유 시리즈에서 다뤘고, 이 글은 그 위층, 애플리케이션 코드의 시각입니다.
0단계: “느리다"를 숫자로 바꿉니다 #
시작은 측정입니다. 두 가지를 확정합니다.
- 어느 엔드포인트가, 얼마나: APM이나 액세스 로그 집계로 엔드포인트별 응답 시간 순위를 뽑습니다. 체감 신고와 실제 범인이 다른 경우가 많습니다.
- 평균이 아니라 분위수로: p50이 100ms라도 p99가 5초면 사용자 100명 중 1명은 5초를 겪습니다. 그리고 그 1%는 대개 무거운 데이터를 가진 헤비 유저, 즉 가장 중요한 사용자입니다. 목표도 “p99 < 500ms"처럼 분위수로 겁니다.
1단계: 구간을 쪼갭니다 #
엔드포인트를 특정했으면, 요청 하나의 시간을 구간별로 나눕니다. 트레이싱 도구가 있으면 스팬으로, 없으면 구간 로그 몇 줄로도 시작할 수 있습니다. 나눠 보면 대부분 이렇게 분해됩니다.
전체 1,240ms
├─ 미들웨어·인증 18ms
├─ DB 쿼리 (23회) 780ms ← 여기
├─ 외부 API 호출 (2회) 310ms ← 그리고 여기
├─ 직렬화(JSON 변환) 95ms
└─ 기타 로직 37ms이 분해가 진단의 절반입니다. 아래 단골 4곳 중 어디가 부풀어 있는지가 바로 보이기 때문입니다.
단골 병목 ① 데이터베이스: 개수부터, 그다음 속도 #
DB 구간이 크면 두 가지를 순서대로 봅니다. 쿼리 개수가 먼저입니다. 위 예시의 “23회"가 전형인데, 목록을 돌며 항목마다 쿼리를 날리는 N+1 패턴이 대부분입니다. 쿼리 하나하나는 빨라서 슬로우 쿼리 로그에는 안 잡히고, 개수를 세어야 보입니다. ORM의 eager loading(select_related·prefetch_related 류)으로 몇 개로 합치는 것이 처방이고, 자세한 내용은 장고 고급 #3에서 다뤘습니다.
개수가 정상인데 느리면 개별 쿼리의 속도입니다. EXPLAIN으로 실행 계획을 보는 이 영역은 데이터베이스가 느려질 때에서 정리했습니다. 인덱스가 왜 이 문제의 중심인지는 다음 글에서 따로 다룹니다.
단골 병목 ② 외부 호출: 내 코드가 아니라 남의 시간 #
결제, 알림, 검색, LLM 같은 외부 API 호출이 응답 경로 안에 있으면, 내 API의 지연시간 하한은 남의 서비스가 정합니다. 점검 순서는 이렇습니다.
- 응답 경로에서 뺄 수 있는가: 결과를 즉시 돌려줄 필요가 없는 호출(알림 발송, 로그 적재)은 큐에 넣고 응답부터 반환합니다. 가장 효과가 큰 처방입니다.
- 병렬로 묶을 수 있는가: 서로 의존하지 않는 호출 두 개를 순차로 기다리는 코드는 왕복의 곱셈을 직접 만들어 내는 셈입니다.
- 타임아웃과 폴백이 있는가: 속도 문제이자 안정성 문제입니다. 외부가 느려질 때 내 API가 같이 느려지는 결합을 타임아웃, 캐시된 값 폴백으로 끊습니다.
단골 병목 ③ 직렬화와 페이로드: 데이터가 큰 만큼 느립니다 #
수천 건의 객체를 JSON으로 바꾸는 직렬화는 CPU를 쓰는 코드라서, 페이로드가 커질수록 정직하게 느려집니다. 응답이 수 MB라면 질문은 “직렬화를 어떻게 빠르게"가 아니라 “왜 이걸 다 보내는가“입니다. 페이지네이션(무한 스크롤이면 커서 기반), 필드 선별(목록에는 요약만), 압축(gzip/brotli는 전송 시간을 줄입니다)이 순서대로의 처방입니다. 특히 “전체 조회 후 애플리케이션에서 자르기"처럼 DB에서 이미 큰 데이터를 가져오는 패턴은 DB 구간과 직렬화 구간을 동시에 부풀립니다.
단골 병목 ④ 그리고 반복 계산: 캐시가 답인 경우 #
같은 입력에 같은 출력을 매번 다시 계산하고 있다면(집계, 랭킹, 설정 데이터), 캐시가 처방입니다. 다만 캐시는 마지막에 씁니다. N+1이나 페이로드 문제를 캐시로 덮으면 캐시가 식을 때마다(만료, 배포, 재시작) 원래 문제가 스파이크로 돌아오기 때문입니다. 구조를 고친 뒤, 그래도 비싼 계산에 캐시를 얹는 순서가 맞습니다. 캐시 저장소로 흔히 쓰는 Redis가 왜 이 용도에 맞는지는 별도 글에서 다룹니다.
여기까지 해도 느리면: 아래층으로 #
애플리케이션 구간이 다 정상인데 전체가 느리면, 문제는 코드 밖입니다. 워커·커넥션 풀 고갈(요청이 처리 전에 줄을 서는 경우), GC 정지, 서버 자원 부족이 후보가 됩니다. 이때부터는 서버가 느린 이유 #1의 진단 순서로 내려갑니다. 파이썬 서버라면 프로파일러로 코드 안을 직접 들여다보는 방법을 별도 글에서 다룹니다.
정리 #
- 시작은 측정입니다. 엔드포인트별 순위와 분위수(p99)로 “느리다"를 숫자로 바꾸고, 요청 하나를 구간으로 쪼갭니다.
- DB 구간은 쿼리 개수(N+1)부터 보고, 그다음 개별 쿼리 속도(EXPLAIN)를 봅니다.
- 외부 호출은 응답 경로에서 빼고(큐), 병렬화하고, 타임아웃·폴백으로 결합을 끊습니다.
- 큰 페이로드는 직렬화와 DB를 동시에 부풀립니다. 페이지네이션과 필드 선별이 먼저, 캐시는 구조를 고친 뒤입니다.
- 애플리케이션 구간이 정상인데 느리면 아래층(워커 풀, 서버 자원)으로 내려갑니다.