Comet5 블로그
개발·운영

API 지연 분석: DB·외부 호출·직렬화 시간 나누기

Comet5 · 최초 발행 · 수정 · 약 5분

요청 경로별 소요시간을 측정해 N+1 조회와 외부 호출 병목을 구분하는 예제입니다.

이 글의 목차

API가 느릴 때 SQL 하나만 고치거나 서버 수를 늘리기 전에 요청이 어디에서 시간을 쓰는지 나누어야 합니다. DB 조회, 외부 호출, 데이터 변환, 응답 전송은 다른 병목이며 각각 다른 개선이 필요합니다.

구간별로 시간을 기록하기

다음은 경과 시간 측정 형태를 보여 주는 독립 실행 예제입니다. 실제 API 성능 수치가 아니며 출력값은 실행 환경에 따라 달라집니다.

from time import perf_counter

started = perf_counter()
rows = [{"id": i} for i in range(100)]
after_load = perf_counter()
result = [row["id"] for row in rows]
finished = perf_counter()
assert len(result) == 100
print({
    "load_ms": (after_load - started) * 1000,
    "transform_ms": (finished - after_load) * 1000,
})

실제 요청에서는 DB와 외부 서비스 구간을 나누되 로깅 자체의 비용과 민감 데이터 노출도 고려합니다. 동시에 실행한 작업의 시간을 단순 합산하면 전체 경과 시간보다 커질 수 있으므로 구간의 겹침을 이해해야 합니다.

목록 크기에 비례하는 조회 찾기

목록 10개에서 SQL 11회, 50개에서 51회처럼 증가한다면 항목별 추가 조회를 의심할 수 있습니다. 이는 진단용 가상 패턴입니다. ORM의 관계 로딩 방식을 확인하고 필요한 필드와 관련 데이터를 적절히 함께 가져오는 방식을 비교합니다.

모든 데이터를 한 번에 가져오는 방식도 메모리와 큰 조인을 유발할 수 있습니다. 쿼리 수와 실행 시간, 반환 크기를 함께 기록하고 실행 계획으로 확인합니다.

외부 호출과 응답 크기

외부 API 대기가 길면 타임아웃, 연결 재사용, 허용 동시성, 결과 캐싱 가능성을 검토합니다. 실패한 호출을 여러 번 재시도하면 사용자가 기다리는 총시간은 더 길어집니다. 사용자 응답에서 꼭 필요한 작업인지도 구분합니다.

직렬화 시간이 크면 필요 없는 필드를 응답에 포함하는지 살펴봅니다. 항목 수와 본문 크기에 제한을 두고, 페이지를 나눌 때는 정렬과 커서 규칙을 함께 설계합니다.

개선 전후 같은 요청으로 비교하기

평균뿐 아니라 느린 요청의 분포와 오류율을 확인합니다. 캐시가 빈 상태와 따뜻한 상태, 작은 응답과 큰 응답을 구분합니다. 데이터를 덜 반환해서 빨라졌다면 기존 클라이언트 계약을 깨지 않았는지도 검사합니다.

수정 후에는 ‘서버가 빨라졌다’보다 어떤 구간의 시간이 줄고 어떤 비용이 늘었는지 기록합니다. 성능 변경이 결과의 정확성이나 권한 검사를 생략하는 방식이 되어서는 안 됩니다.

N+1을 메모리 DB로 재현하기

별도 서버 없이 Python의 SQLite로 쿼리 개수가 늘어나는 구조를 볼 수 있습니다. 주문 세 건이 각각 고객 이름을 조회하는 구현과 JOIN 구현을 비교합니다. 새로 만든 메모리 DB만 사용하며 운영 데이터에 연결하지 않습니다.

import sqlite3

with sqlite3.connect(":memory:") as db:
    db.executescript("""
        CREATE TABLE customers (id INTEGER PRIMARY KEY, name TEXT NOT NULL);
        CREATE TABLE orders (id INTEGER PRIMARY KEY, customer_id INTEGER NOT NULL);
        INSERT INTO customers VALUES (1, '가상 고객 A'), (2, '가상 고객 B');
        INSERT INTO orders VALUES (10, 1), (11, 1), (12, 2);
    """)
    statements = []
    db.set_trace_callback(statements.append)
    orders = db.execute("SELECT id, customer_id FROM orders ORDER BY id").fetchall()
    slow = [(oid, db.execute("SELECT name FROM customers WHERE id=?", (cid,)).fetchone()[0])
            for oid, cid in orders]
    individual_count = len(statements)
    statements.clear()
    joined = db.execute("""
        SELECT o.id, c.name FROM orders AS o
        JOIN customers AS c ON c.id=o.customer_id ORDER BY o.id
    """).fetchall()
    assert slow == joined
    assert individual_count == 4 and len(statements) == 1
    print(individual_count, len(statements), joined)

예상 결과는 조회 횟수 4 1과 동일한 주문·고객 목록입니다. 여기서 증명하는 것은 이 입력에 대한 결과 동등성과 쿼리 수이며, 운영 지연이 4분의 1이 된다는 뜻은 아닙니다. 실제 비용은 연결 지연과 실행 계획, 반환 크기에 따라 달라집니다.

고객이 없는 주문도 표시해야 한다면 INNER JOIN으로 바꾼 예제가 적절하지 않습니다. 원래의 누락 처리 계약을 정하고 LEFT JOIN이나 오류 처리를 선택해야 합니다. Django에서는 관계에 따라 select_related와 prefetch_related를 검토하되 결과를 실제로 평가하는 구간까지 쿼리 수를 측정합니다.

참고 자료