🤖 AI AGENT | 캐싱 최적화 핵심 개념
- ETag (Entity Tag):
- 서버가 보낼 데이터의 "버전 식별 해시값"입니다. 응답 헤더에
ETag: "해시값"형태로 담아 보냅니다.- If-None-Match:
- 클라이언트(브라우저)가 캐싱된 데이터를 재요청할 때, "내가 가진 해시값이 이건데, 서버 데이터가 여전히 똑같니?" 하고
If-None-Match: "해시값"헤더를 실어 보냅니다.- 304 Not Modified:
- 서버는 이 헤더를 확인하고 서버의 현재 해시값과 동일하다면, 데이터를 새로 만들거나 전송하지 않고 오직 HTTP 304 상태 코드(본문 없음) 만 즉시 응답합니다.
- 이로써 네트워크 대역폭 전송량은 0에 수렴하게 되며, 서버 CPU/메모리 자원 소모도 극적으로 차단됩니다.
실무에서 ETag를 생성하는 2가지 보편적인 기법
기법 A: 응답 바디 전체를 해싱하기 (강한 ETag / Strong ETag)
- 방식: 비즈니스 로직(DB 조회, FFI 연산 등)을 모두 실행하여 완성된 최종 응답 데이터(JSON 문자열 등)의 MD5/SHA-1 해시값을 떠서 ETag로 삼습니다.
- 장점: 데이터가 단 1바이트라도 바뀌면 해시가 변하므로 완벽하게 안전하고 정확합니다.
- 단점: 어쨌든 서버 내에서 데이터 조회와 무거운 가공 로직을 끝까지 실행해야 하므로, 네트워크 전송량은 줄일 수 있어도 서버 측 CPU/DB 연산 자원은 절약하지 못합니다.
기법 B: 메타데이터 기반 해싱하기 (약한 ETag / Weak ETag - 실무 권장 🌟)
- 방식: 무거운 응답 본문을 다 생성하는 대신, 데이터의 변경 여부를 초고속으로 알 수 있는 가벼운 메타데이터들을 엮어서 해싱합니다.
- 예: DB에서 최종 수정 시각(updated_at) + 총 레코드 개수(count)만 가볍게 조회하여 ETag 생성 ➔ etag = hash(updated_at + count)
- 장점: 실제 무거운 조인 쿼리나 파일 가공 연산을 돌리기 전에, 가벼운 메타데이터 쿼리만 해서 즉시 304를 뱉고 종료할 수 있습니다. 서버의 연산 자원과 네트워크 대역폭을 모두 극적으로 아낄 수 있습니다.
실무에서의 하이브리드 캐싱 시나리오
시나리오 1. 데이터베이스(DB) 중심의 CRUD API ➔ 기법 B (약한 ETag) 우선
- 대상: 회원 정보, 게시글 리스트, 최근 주문 목록 등
- 전략: DB의 특정 레코드 혹은 테이블의
updated_at(최종 수정 시각) +total_count등을 쿼리하여 가볍게 해시값(약한 ETag, 예:W/"hash_val")을 만듭니다.- 이유: 무거운 DB 조인 연산과 시리얼라이제이션(Pydantic 변환)을 돌리기 전에, 가벼운 메타데이터 쿼리 한 번으로 캐시 일치 여부를 판별해 서버 CPU와 DB 커넥션 풀을 완벽히 절약할 수 있기 때문입니다.
시나리오 2. 연산 집약적 / 외부 API 연동 API ➔ 기법 A (강한 ETag) 우선
- 대상: 대용량 이미지 변환, AI 모델 추론 결과, 외부 날씨/주식 API 호출 결과 등
- 전략: 일단 무거운 연산을 거쳐 생성된 최종 응답 바이너리 혹은 텍스트 전체의 MD5 해시값을 구해 ETag를 제공합니다.
- 이유: 이 데이터들은 DB 테이블처럼
updated_at같은 변경 시점의 기준 메타데이터를 가볍게 가져올 곳이 마땅치 않으므로, 완성된 데이터 자체의 해시값을 대조하는 것이 가장 안전하기 때문입니다.시나리오 3. 극한의 최적화: 2단계 하이브리드 검증 (고급 기술 🌟)
실무에서는 한 API 안에서 두 기법을 결합하기도 합니다.
- 1단계 (기법 B - 약한 검증): DB의 최종 수정 시각만 초고속으로 조회하여
If-None-Match와 다르면 캐시 미스로 처리해 다음 단계로 넘어갑니다.- 2단계 (기법 A - 강한 검증): 연산을 완료하고 응답을 만들기 직전, 최종 본문의 해시를 한 번 더 구합니다. (수정 시각은 변경되었으나 실제 반환할 핵심 데이터 본문은 이전과 똑같을 수 있기 때문입니다.) 이 해시마저 일치하면 결국 304를 뱉어 네트워크 전송량(대역폭)이라도 아끼는 세이프티 넷을 구축합니다.
여기에서는 일단 기법 A로 캐싱을 적용하고
이후 DB가 적용된 부분에서는 기법 B를 사용하도록 하겠다.
gateway/app/main.pyimport logging from contextlib import asynccontextmanager from fastapi import FastAPI, HTTPException, Request, Response from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.gzip import GZipMiddleware from brotli_asgi import BrotliMiddleware import hashlib import fast_text_engine from .config import settings from .schemas import HealthResponse, TextDataResponse # (중략) @app.get("/api/data") async def get_text_data( request: Request, response: Response, lines: int = 10000 ) -> TextDataResponse: """Rust FFI 엔진을 호출하여 대용량 더미 텍스트 데이터를 생성하고, 실제 텍스트 콘텐츠의 해시값을 기반으로 ETag 캐싱을 처리합니다. Args: request (Request): HTTP 요청 객체. response (Response): HTTP 응답 객체. lines (int): 생성할 텍스트의 라인 수 (기본값: 10000). Returns: TextDataResponse: 총 라인 수와 텍스트 리스트를 포함한 응답 객체. """ try: text_list = fast_text_engine.generate_dummy_data(lines) logger.info(f"대용량 텍스트 데이터 생성 완료: {lines} 라인") except Exception as e: logger.error(f"대용량 텍스트 데이터 생성 실패: {e}") raise HTTPException(status_code=500, detail="Rust FFI 엔진에서 데이터를 생성하는 중 에러가 발생했습니다.") content_hash = hashlib.md5("".join(text_list).encode()).hexdigest() etag_val = f'"{content_hash}"' if_none_match = request.headers.get("If-None-Match") if if_none_match == etag_val: logger.info(f"콘텐츠 해시 캐시 히트! (304 Not Modified) - Lines: {lines}") return Response(status_code=304) response.headers["ETag"] = etag_val response.headers["Cache-Control"] = "public, max-age=0, must-revalidate" return TextDataResponse( lines=len(text_list), data=text_list )
이제 응답받은 해시값을 사용하여 요청했을 때 변동사항이 없을 경우
본문을 다시 전달하지 않고 304 Not Modified를 전달한다.
[터미널 A | 게이트웨이 실행]
~/workspace/fast-text-search/gateway$ uv run uvicorn app.main:app --reload
[터미널 B | 응답 확인]
~$ curl -H "Accept-Encoding: br" -I -X GET "http://localhost:8000/api/data?lines=10000" HTTP/1.1 200 OK date: Tue, 16 Jun 2026 02:32:13 GMT server: uvicorn content-length: 12464 content-type: application/json etag: "3598986fc98ff636eca54446cffcbfd3" cache-control: public, max-age=0, must-revalidate vary: Accept-Encoding, Accept-Encoding content-encoding: br~$ curl -H 'If-None-Match: "3598986fc98ff636eca54446cffcbfd3"' -H "Accept-Encoding: br" -I -X GET "http://localhost:8000/api/data?lines=10000" HTTP/1.1 304 Not Modified date: Tue, 16 Jun 2026 02:32:49 GMT server: uvicorn
대용량 데이터를 비동기 요청하여 저장하는 상태 변수를 선언하고 화면에 출력한다.
frontend/src/route/+page.svelte<script lang="ts"> import { PUBLIC_API_URL } from "$env/static/public"; let isLoading = $state(false); let healthData = $state<{ status: String; engine: String } | null>(null); let textData = $state<{ lines: number; data: string[] } | null>(null); let errorMessage = $state<string | null>(null); let linesInput = $state(10000); async function checkHealth(): Promise<void> { isLoading = true; errorMessage = null; healthData = null; textData = null; try { const response = await fetch(`${PUBLIC_API_URL}/health`); if (!response.ok) { throw new Error(`HTTP 에러 발생: ${response.status}`); } healthData = await response.json(); } catch (e) { if (e instanceof Error) { errorMessage = e.message; } else { errorMessage = "알 수 없는 에러가 발생했습니다"; } console.error("Health check failed:", e); } finally { isLoading = false; } } async function loadTextData(lineCount: number): Promise<void> { isLoading = true; errorMessage = null; healthData = null; textData = null; try { const response = await fetch(`${PUBLIC_API_URL}/api/data?lines=${lineCount}`); if (!response.ok) { throw new Error(`HTTP 에러 발생: ${response.status}`); } textData = await response.json(); } catch (e) { if (e instanceof Error) { errorMessage = e.message; } else { errorMessage = "알 수 없는 에러가 발생했습니다."; } console.error("Text data load failed:", e); } finally { isLoading = false; } } </script> <main class="container"> <div class="card"> <h1 class="title">대용량 텍스트 검색 최적화 엔진</h1> <p class="subtitle">Rust + FastAPI + Elasticsearch + SvelteKit</p> <div class="control-panel"> <div class="input-group"> <label for="lines-input" class="input-label">생성될 줄 수</label> <input id="line-input" type="number" class="input-field" min="0" max="1000000" bind:value={linesInput} disabled={isLoading} /> </div> <div class="btn-group"> <button class="btn" onclick={() => loadTextData(linesInput)} disabled={isLoading}> {#if isLoading} 데이터 로딩 중... {:else} 대용량 텍스트 요청 {/if} </button> <button class="btn outline" onclick={checkHealth} disabled={isLoading}> {#if isLoading} 서버 응답 대기 중... {:else} 서버 헬스 체크 실행 {/if} </button> </div> </div> <div class="results-area"> {#if isLoading} <div class="status-msg loading">FastAPI 게이트웨이 및 Rust 연산 엔진 통신을 테스트하는 중입니다...</div> {:else if healthData} <div class="status-box success"> <h2>연결 성공</h2> <ul> <li> <strong>API Gateway:</strong> <span class="badge success">{healthData.status}</span> </li> <li> <strong>Rust FFI Engine:</strong> <span class="badge success">{healthData.engine}</span> </li> </ul> </div> {:else if textData} <div class="status-box success"> <h2>데이터 수신 완료 (총 {textData.lines}줄)</h2> <div class="text-preview"> {#each textData.data.slice(0, 10) as line} <p class="preview-line">{line}</p> {/each} {#if textData.lines > 10} <p class="more-text"> ... 외 {textData.data.length - 10}줄의 데이터가 브라우저에 캐싱되었습니다. </p> {/if} </div> </div> {:else if errorMessage} <div class="status-box error"> <h2>연결 실패</h2> <p>{errorMessage}</p> </div> {/if} </div> </div> </main> <style> .container { display: flex; justify-content: center; align-items: center; width: 100%; padding: 20px; box-sizing: border-box; } .card { background-color: var(--bg-secondary); border: 1px solid rgba(255, 255, 255, 0.05); border-radius: var(--border-radius); padding: 40px; width: 100%; max-width: 480px; box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.3), 0 8px 10px -6px rgba(0, 0, 0, 0.3); text-align: center; } .title { font-size: 1.8rem; margin: 0 0 10px 0; font-weight: 700; } .subtitle { color: var(--text-secondary); font-size: 0.95rem; margin: 0 0 30px 0; } .control-panel { display: flex; flex-direction: column; gap: 15px; margin-bottom: 25px; } .input-group { display: flex; flex-direction: column; align-items: flex-start; gap: 6px; } .input-label { font-size: 0.85rem; color: var(--text-secondary); font-weight: 600; } .input-field { width: 100%; padding: 12px; box-sizing: border-box; border-radius: var(--border-radius); border: 1px solid rgba(255, 255, 255, 0.1); background-color: rgba(0, 0, 0, 0.2); color: var(--text-primary); font-size: 1rem; font-weight: 500; transition: var(--transition-smooth); } .input-field:focus { outline: none; border-color: var(--color-primary); box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.2); } .input-field:disabled { opacity: 0.5; cursor: not-allowed; } .btn-group { display: flex; gap: 10px; margin-bottom: 20px; } .btn { flex: 1; background-color: var(--color-primary); color: var(--text-primary); border: none; padding: 14px 24px; font-size: 1rem; font-weight: 600; border-radius: var(--border-radius); cursor: pointer; width: 100%; transition: var(--transition-smooth); } .btn:hover:not(:disabled) { background-color: var(--color-primary-hover); } .btn:disabled { opacity: 0.6; cursor: not-allowed; } .btn.outline { background-color: transparent; border: 2px solid var(--color-primary); color: var(--text-primary); } .btn.outline:hover:not(:disabled) { background-color: rgba(59, 130, 246, 0.1); } .results-area { margin-top: 30px; min-height: 120px; text-align: left; } .status-msg.loading { color: var(--text-secondary); text-align: center; font-size: 0.9rem; padding: 20px 0; } .status-box { padding: 20px; border-radius: var(--border-radius); font-size: 0.95rem; } .status-box h2 { margin: 0 0 12px 0; font-size: 1.1rem; } .status-box.success { background-color: rgba(16, 185, 129, 0.1); border: 1px solid rgba(16, 185, 129, 0.2); } .status-box.success ul { list-style: none; padding: 0; margin: 0; } .status-box.success li { margin-bottom: 8px; display: flex; justify-content: space-between; align-items: center; } .status-box.error { background-color: rgba(239, 68, 68, 0.1); border: 1px solid rgba(239, 68, 68, 0.2); color: #fca5a5; } .status-box.error p { margin: 0; line-height: 1.5; } .badge { padding: 4px 8px; font-size: 0.8rem; font-weight: 700; text-transform: uppercase; border-radius: 4px; } .badge.success { background-color: var(--color-success); color: var(--text-primary); } .text-preview { background-color: rgba(0, 0, 0, 0.2); padding: 15px; border-radius: 6px; font-family: monospace; font-size: 0.85rem; max-height: 200px; overflow-y: auto; } .preview-line { margin: 0 0 8px 0; color: var(--text-primary); word-break: break-all; } .more-text { margin: 0; color: var(--text-secondary); font-style: italic; } </style>
[처음 요청했을 때]
[다시 요청했을 때]
🤖 AI AGENT | 학습 회고
Phase 1
- 완료일: 2026-06-16
- 성과:
- Rust FFI 엔진에서 데이터를 받아오는
/api/data게이트웨이 엔드포인트를 구축했습니다.- Brotli 및 GZip 압축 미들웨어를 중첩 적용하여 대용량 전송 대역폭을 비약적으로 축소시켰습니다.
- 실제 데이터 내용물의 고유성을 검증하는 기법 A (강한 ETag / Strong ETag) 기반 캐싱 및 304 Not Modified 조건부 응답을 구현했습니다.
- Svelte 5 프론트엔드에 숫자 입력 폼을 추가해 동적 데이터 요청 UI를 구성하고, Safari 웹 검사기(DevTools)를 통해 압축 및 304 캐시 재활용 성능을 최종 입증했습니다.
- 성능 개선 지표 (1만 줄 텍스트 기준):
- 원본 데이터 크기:
1,808,914바이트 (약 1.8 MB)- GZip 압축 전송:
30,402바이트 (약 30 KB, 약 98.3% 절감)- Brotli 압축 전송:
12,464바이트 (약 12 KB, 약 99.3% 절감)- ETag 캐시 재요청:
0바이트 (네트워크 전송 오버헤드 완전 소멸)- 배운 점 & 트러블슈팅:
curl -I는 HTTPHEAD요청을 전송하여 GET 전용 라우터에서405 Method Not Allowed가 발생한다는 네트워크 스펙을 배웠습니다.- 여러 압축 미들웨어가 중첩 동작할 때
Vary헤더에 중복 누적되는 흐름을 규명했습니다.- Svelte 5의
{#if}템플릿 조건 분기가 참을 만나는 첫 갈래만 타는 문제를, 함수 진입점 상호 상태 초기화를 통해 해결했습니다.