대용량 텍스트 검색 및 전송 최적화 엔진 (3) 대용량 페이로드 최적화 (下)

Pt J·2026년 6월 16일
post-thumbnail

대용량 텍스트 검색 및 전송 최적화 엔진 (3) 대용량 페이로드 최적화 (下)

🤖 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를 뱉어 네트워크 전송량(대역폭)이라도 아끼는 세이프티 넷을 구축합니다.

ETag 해싱 적용

Python

여기에서는 일단 기법 A로 캐싱을 적용하고
이후 DB가 적용된 부분에서는 기법 B를 사용하도록 하겠다.

gateway/app/main.py

import 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

E2E 브라우저 검증

Svelte

대용량 데이터를 비동기 요청하여 저장하는 상태 변수를 선언하고 화면에 출력한다.

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 게이트웨이 엔드포인트를 구축했습니다.
    • BrotliGZip 압축 미들웨어를 중첩 적용하여 대용량 전송 대역폭을 비약적으로 축소시켰습니다.
    • 실제 데이터 내용물의 고유성을 검증하는 기법 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는 HTTP HEAD 요청을 전송하여 GET 전용 라우터에서 405 Method Not Allowed가 발생한다는 네트워크 스펙을 배웠습니다.
    • 여러 압축 미들웨어가 중첩 동작할 때 Vary 헤더에 중복 누적되는 흐름을 규명했습니다.
    • Svelte 5의 {#if} 템플릿 조건 분기가 참을 만나는 첫 갈래만 타는 문제를, 함수 진입점 상호 상태 초기화를 통해 해결했습니다.
profile
Peter J Online Space - since July 2020 | 아무데서나 채용해줬으면 좋겠다 (지금은 학생 때 하던 거 아무거나 공부하고 있고요, 취업시켜 주시면 그 분야로 공부할게요)

0개의 댓글