계산하지 않는 API — 라우터 37개를 읽기 전용으로 유지하는 법

2026. 9. 18. 03:37ㆍ카테고리 없음

화면 뒤의 데이터 판단 · 서빙 계층

계산하지 않는 API — 라우터 37개를 읽기 전용으로 유지하는 법

DB가 한 번 멈춘 뒤 정한 규칙: 사용자 요청이 도착한 다음에는 아무것도 계산하지 않는다

API 서버를 만들 때 가장 자연스러운 구조는 "요청이 오면 DB를 조회해 계산하고 응답한다"입니다. 코드가 단순하고 데이터가 항상 최신입니다. 이 서비스도 그렇게 시작했습니다.

그러다 데이터베이스가 통째로 멈추는 일을 겪었습니다. CPU도 메모리도 여유가 있었는데 디스크 입출력 예산이 먼저 바닥났습니다. 조건 없는 전체 조회가 요청 경로에 있으면 사용자가 새로고침할 때마다 같은 스캔이 반복됩니다. 예산이 소진되자 통계 화면뿐 아니라 앱의 모든 화면이 무한 로딩이 됐습니다. 인덱스로는 막히지 않는 종류의 문제였습니다.

그 뒤 규칙을 하나로 정했습니다. 요청 경로에서는 계산도, 외부 호출도 하지 않는다. 계산은 전부 배치가 끝내고 결과를 저장해 두면, API는 그것을 읽어 내보내기만 합니다.

계층 하는 일 파일
배치 수집 · 모델 · 집계를 끝내고 화면이 그대로 쓸 형태로 저장 scheduler/ · processor/
저장소 키-값 캐시 테이블 + 원본 테이블 database/repositories.py
API 읽기만 한다. 캐시가 비었을 때만 예외적으로 즉석 계산 api/routers/ (37개)
화면 받은 JSON을 그리기만 한다 templates/ · static/js/

표 1. 라우터 주석에 "app_cache 적재 → 요청은 캐시만(계산 0)"이 그대로 적혀 있다.

1. 왕복을 줄이는 세 겹

읽기만 한다고 해도 읽는 횟수가 많으면 같은 문제가 돌아옵니다. 그래서 요청 하나가 저장소까지 가는 경로에 세 겹을 뒀습니다.

겹 무엇을 막나 구현
브라우저 · CDN 같은 값을 다시 받는 것 자체 ETag + 304 · Cache-Control
서버 메모리 짧은 시간에 몰리는 같은 조회 스레드 안전 TTL 캐시
쿼리 키마다 따로 왕복하는 것 여러 캐시 키를 1쿼리로 묶어 조회

응답 헤더는 공용 헬퍼가 붙입니다. 응답 내용을 해시해 ETag를 만들고, 브라우저가 보낸 값과 같으면 본문 없이 304만 돌려줍니다.

# 응답 내용 자체를 해시해 ETag 를 만든다 — 값이 그대로면 태그도 그대로다.
etag = '"' + hashlib.md5(json.dumps(data, sort_keys=True, default=str).encode()).hexdigest() + '"'
headers = {
    'etag': etag,
    # max-age=브라우저, s-maxage=CDN, stale-while-revalidate=갱신 중 옛 값 허용
    'cache-control': f'public, max-age={max_age}, s-maxage={s_maxage}, stale-while-revalidate={swr}',
}
if request.headers.get('if-none-match') == etag:
    return Response(status_code=304, headers=headers)   # 본문 0바이트

2. 메모리 캐시가 메모리를 먹던 문제

TTL 캐시는 흔히 "시간이 지나면 miss"로만 구현합니다. 처음엔 그렇게 만들었고, 만료된 항목을 조회만 실패시키고 딕셔너리에는 남겨 뒀습니다.

문제는 키가 무한히 늘어나는 캐시였습니다. 종목 검색처럼 사용자가 입력한 문자열이 키가 되면 서로 다른 키가 계속 쌓입니다. 만료돼도 지워지지 않으니 프로세스가 사는 내내 메모리가 커졌습니다.

지금은 세 가지를 함께 합니다. ① 만료된 값은 실제로 버린다 · ② 최대 개수를 넘으면 만료분부터 치운다 · ③ 그래도 넘치면 오래된 것부터 버린다. 캐시는 성능 장치이면서 동시에 누수 지점이기도 합니다.

3. 배치가 멈춘 걸 API가 눈치채게

읽기 전용 구조의 약점은 분명합니다. 배치가 멈추면 API는 옛날 값을 아무렇지 않게 계속 내보냅니다. 에러가 나지 않으니 화면도 멀쩡해 보입니다.

그래서 배치가 만든 묶음에는 생성 날짜를 함께 적고, 꺼낼 때 나이를 봅니다. 정해진 일수보다 낡았으면 그 값을 주지 않고 호출부가 라이브 계산으로 넘어가게 합니다.

def bundle_part(bundle, key, max_age_days):
    # 배치가 며칠 멈춘 걸 눈치채는 규칙. 낡으면 None → 호출부가 라이브로 간다.
    gen = str(bundle.get('generated') or '')
    if (date.today() - date.fromisoformat(gen)).days > max_age_days:
        return None
    return bundle.get(key)

이 함수는 원래 두 라우터가 각자 똑같이 들고 있었습니다. 한쪽만 고치면 다른 쪽은 낡은 값을 계속 내보내는 구조였고, 그래서 공용 모듈 한 곳으로 합쳤습니다. 중복 코드가 위험한 이유는 코드가 길어져서가 아니라 고칠 때 하나를 빠뜨리기 때문입니다.

4. 첫 화면은 서버가 그리되, 절대 기다리지 않는다

진입 직후 보이는 지수·지표는 HTML에 값을 심어 보내는 편이 빠릅니다. 그런데 그 값을 만드는 계산은 싸지 않았습니다. 실측으로 지역당 150~600ms가 들었고, HTML 응답 경로에서 이걸 부르면 첫 화면을 앞당기려다 전체를 늦추게 됩니다.

그래서 계산 결과만 짧게 담아 두고, HTML은 이미 담겨 있는 것만 읽습니다. 규칙은 세 줄입니다.

  • 비어 있으면 기다리지 않는다 — 값을 비우고 내보낸 뒤 채우는 일은 백그라운드로 넘긴다. 그 섹션은 기존 JS 경로로 채워진다.
  • 낡은 값이라도 먼저 준다 — 프론트가 곧 최신 값으로 덮어쓰므로 빈 화면보다 낫다.
  • 다음 진입부터 붙는다 — 서버 렌더는 최적화이지 필수 경로가 아니다.

5. 읽기 전용이어도 지켜야 하는 것

"읽기만 한다"는 말이 "아무나 읽어도 된다"는 뜻은 아닙니다. 서빙 계층에서 따로 지키는 것이 셋 있습니다.

항목 규칙
인증 보호 API는 서버가 검증한 토큰의 사용자 ID로만 조회·수정한다. 클라이언트가 보낸 id·email·요금제 값은 신뢰하지 않는다. 검증은 공개 키(JWKS)로 하므로 비밀키를 서버에 두지 않는다.
결제 상태 구독 여부는 앱이 알려주는 게 아니라 결제사 웹훅이 서버 DB를 갱신한다. 서버가 진실원천이다.
응답 위생 계산 불가능한 값(NaN·무한대)은 서빙 직전에 한 번 더 걸러낸다. JSON 표준에 없는 값이라 프론트에서 파싱이 깨진다.

6. 이 구조의 값

얻은 것은 분명합니다. 사용자가 늘어도 조회 비용이 선형으로 늘지 않고, 화면 응답이 계산 시간이 아니라 네트워크 시간으로만 결정됩니다.

대신 치른 값도 있습니다. 새 지표를 추가할 때 손이 두 번 갑니다 — 배치에 계산을 넣고, 그 결과를 읽는 라우터를 따로 만듭니다. 그리고 배치가 멈추면 화면이 조용히 낡습니다. 그래서 나이 검사와 갱신 감시가 선택이 아니라 기본 장치가 됐습니다.

정리

  • 요청 경로에 계산을 두지 않는다 — 전 기간 집계는 배치가 하루 한 번 계산하고, 요청은 그 결과만 읽는다.
  • 인덱스는 전체 스캔을 못 막는다 — 조건 없는 조회가 요청 경로에 있으면 디스크 예산이 먼저 바닥난다.
  • 캐시는 세 겹으로 — 브라우저·CDN(ETag/304), 서버 메모리(TTL), 쿼리 묶음. 각 겹이 막는 것이 다르다.
  • TTL 캐시는 만료값을 실제로 버려야 한다 — 키가 무한히 늘어나는 캐시에서는 만료 표시만으로 메모리가 샌다.
  • 캐시에 나이를 붙인다 — 배치가 멈춘 걸 API가 눈치채지 못하면 옛 값이 조용히 계속 나간다.
  • 같은 규칙이 두 곳에 있으면 합친다 — 중복의 위험은 길이가 아니라 고칠 때 하나를 빠뜨리는 것이다.
  • 서버 렌더는 최적화이지 필수 경로가 아니다 — 값이 없으면 기다리지 말고 비워 보낸다.
  • 읽기 전용이어도 권한·위생은 서버가 책임진다 — 검증된 사용자 ID로만 조회하고, NaN·무한대는 내보내지 않는다.

라우터 37개 · 등록된 엔드포인트 묶음 35개 기준 · 코드 인용은 실제 서빙 계층 헬퍼에서 발췌