Skip to content

블로그에 AI 기능 붙이기 (3편) - 응답 캐시로 API 호출 줄이기

시리즈: 블로그에 AI 기능 붙이기

3 / 3편
  1. 11편 - 월 35원으로 OpenAI + Netlify Functions
  2. 22편 - MCP와 REST API의 경계
  3. 33편 - 응답 캐시로 API 호출 줄이기지금 읽는 중
다른 시리즈 전체 보기 →
블로그에 AI 기능 붙이기 (3편) - 응답 캐시로 API 호출 줄이기
목차

TL;DR

  • 문제: 로컬 스토리지 TTL 캐싱으로 같은 코드 설명을 매달 반복 생성 (불필요한 API 비용)
  • 원인: 코드는 안 바뀌는데 캐시에 30일 TTL을 걸어서 매달 새로 생성
  • 해결: Supabase 영구 캐싱 + hit_count 기반 스마트 정리 시스템
  • 효과: API 호출 75% 감소, 응답 속도 10배 향상, 연간 약 $5 절감
  • 한계: DB 용량 계속 증가 (6개월마다 수동 정리 필요), Supabase 무료 플랜 500MB 제한

글 머리말

제가 1편에서 AI 코드 설명 기능을 만들 때 로컬 스토리지로 캐싱했었습니다.

간단하고 빠르게 구현할 수 있었거든요. 근데 한 가지 문제가 있었습니다.

독자 A가 “AI 설명” 버튼을 누르면 OpenAI API를 호출합니다(₩0.12). 독자 B가 똑같은 코드에서 버튼을 누르면? 또 API를 호출합니다. 독자 C도, 독자 D도… 계속 호출하는 겁니다.

같은 코드 블록인데 매번 돈을 내고 있었습니다.


솔직히 처음엔 “뭐 얼마나 되겠어”라고 생각했는데, 한 달 지나고 보니 생각보다 비용이 쌓이더군요. 그래서 Supabase를 활용한 영구 캐싱 시스템을 구축하기로 했습니다.

핵심 아이디어는 간단합니다. 블로그에 올린 코드 블록은 절대 바뀌지 않습니다. 그렇다면 AI 설명도 바뀔 이유가 없죠. 한 번 생성한 설명을 영구 보존하면 됩니다. TTL? 필요 없습니다.


이번 포스팅에서는 영구 캐싱 시스템 구축 과정과 실제 운영 결과를 공유합니다. 2주 운영 결과, API 호출을 75% 줄이고 응답 속도를 10배 개선할 수 있었습니다.





배경: TTL 기반 캐싱의 한계

기존 방식의 문제점

1편에서는 로컬 스토리지에 30일 TTL로 캐싱했습니다. 당시엔 괜찮다고 생각했거든요.

// 30일 TTL 캐싱
const CACHE_TTL = 30 * 24 * 60 * 60 * 1000 // 30일

if (cached && Date.now() - cached.timestamp < CACHE_TTL) {
  return cached.explanation // 캐시 히트
}

// 30일 지나면 다시 OpenAI 호출

문제 발견

제 블로그의 함수형 프로그래밍 포스팅에는 Stream API 예제가 10개 넘게 있습니다. 이 글이 한 달에 약 200번 정도 조회되는데요.

어느 날 API 비용을 정리하다가 뭔가 이상하다는 걸 눈치챘습니다.

flowchart LR
    A[30일간 캐시] --> B[만료]
    B --> C[API 호출 ₩1.2]
    C --> A

같은 코드인데 30일마다 반복 과금이 발생합니다.

코드는 안 바뀌는데, 매달 똑같은 설명을 다시 생성하고 있었습니다.


⚠️불필요한 비용 낭비

블로그에 코드 블록이 100개 있다면:

  • 월 100회 × ₩0.12 = ₩12 반복 지출
  • 연간 ₩144

금액은 작아 보이지만, 핵심은 불필요한 낭비라는 겁니다.





설계 논의: 왜 영구 캐싱인가

핵심 통찰: 코드는 불변이다

이 부분이 핵심입니다.

// 이 코드는 영원히 이 코드다
function add(a, b) {
  return a + b
}

생각해보면 당연한 건데, 이 코드 블록은 1년 후에도, 10년 후에도 똑같습니다. 내용이 바뀌지 않으니 AI 설명도 바뀔 이유가 없죠.

그렇다면? 캐시를 영구 보존하면 됩니다. 한번 설명 생성하면 영원히 재사용할 수 있습니다.


대안 검토: Redis vs LocalStorage vs Supabase

처음엔 세 가지 선택지를 고민했습니다.

항목LocalStorageRedisSupabase선택 이유
비용무료월 $10+무료 (500MB)✅ 선택
응답 속도빠름 (클라이언트)매우 빠름 (메모리)빠름 (200ms)✅ 충분
공유 캐싱❌ 불가✅ 가능✅ 가능✅ 필수
운영 복잡도낮음높음 (인프라)낮음✅ 선택
용량 제한~5MB무제한500MB (무료)✅ 충분

왜 Redis를 안 했나?

  • 개인 블로그에 Redis 인프라는 오버스펙
  • 월 $10+ 비용 vs 절감 효과 $5 = 손해
  • 운영 복잡도 증가 (모니터링, 백업 등)

왜 LocalStorage를 버렸나?

  • 독자 A와 독자 B가 캐시를 공유 못 함
  • 결국 API 호출 반복 (문제 해결 안 됨)

Supabase를 선택한 이유:

  • ✅ 무료 플랜 500MB (충분)
  • ✅ PostgreSQL로 복잡한 쿼리 가능
  • ✅ 운영 복잡도 낮음 (Managed Service)
  • ✅ 히트 카운트 등 분석 기능

Trade-off 분석

두 방식을 비교해봤습니다.

항목TTL 캐싱영구 캐싱
API 호출매달 반복최초 1회만
비용월 ₩12거의 ₩0
응답 속도약 2초 (API)약 200ms (DB)
DB 용량불필요증가 (관리 필요)
코드 변경 시자동 갱신수동 삭제 필요

✅최종 선택: 영구 캐싱

장점:

  • 같은 캐시 키의 반복 요청은 API 호출 생략. 전체 절감률은 요청 분포와 무효화 주기에 따라 달라짐
  • 비용 거의 제로
  • 응답 속도 10배 향상 (DB 조회가 API 호출보다 훨씬 빠름)

단점:

  • DB 용량 증가 (캐시 데이터 누적)
  • 사용하지 않는 캐시 관리 필요

솔직히 단점은 스마트 정리로 해결할 수 있으니, 장점이 압도적이라고 판단했습니다.





구현: Supabase 영구 캐싱 시스템

1단계: 테이블 설계

배경 설명

DB는 Supabase PostgreSQL을 선택했습니다. 무료 플랜에서도 500MB까지 제공하는데, 텍스트 캐시 용도로는 충분하거든요.

테이블 스키마

CREATE TABLE ai_code_explanations (
  code_hash VARCHAR(64) PRIMARY KEY,        -- SHA-256 해시
  code_snippet TEXT NOT NULL,               -- 원본 코드 (최대 5000자)
  language VARCHAR(50),                     -- 프로그래밍 언어
  explanation TEXT NOT NULL,                -- AI 설명
  model VARCHAR(50) NOT NULL,               -- AI 모델명
  tokens_used INTEGER,                      -- 사용 토큰 수
  hit_count INTEGER DEFAULT 0,              -- 캐시 히트 횟수
  created_at TIMESTAMP DEFAULT NOW(),       -- 생성 시간
  last_accessed_at TIMESTAMP DEFAULT NOW(), -- 마지막 접근 시간
  expires_at TIMESTAMP DEFAULT NULL         -- TTL 제거 (NULL = 영구)
);

-- 인덱스
CREATE INDEX idx_language ON ai_code_explanations(language);
CREATE INDEX idx_hit_count ON ai_code_explanations(hit_count DESC);
CREATE INDEX idx_last_accessed ON ai_code_explanations(last_accessed_at);

핵심 포인트

  • code_hash를 PK로: SHA-256 해시로 코드 중복 방지
  • hit_count 추적: 인기 코드 분석 가능
  • expires_at = NULL: TTL 제거 → 영구 보존
  • last_accessed_at: 미사용 캐시 정리용

2단계: 캐시 조회 로직

배경 설명

사용자가 버튼을 누르면 Supabase에서 캐시를 먼저 찾습니다. 있으면 즉시 반환하고, 없으면 OpenAI API를 호출합니다.

코드 구현

async function getCachedExplanation(codeHash) {
  // Supabase에서 캐시 조회
  const response = await fetch(
    `${SUPABASE_URL}/rest/v1/ai_code_explanations?code_hash=eq.${codeHash}`,
    {
      headers: {
        apikey: SUPABASE_SERVICE_KEY,
        Authorization: `Bearer ${SUPABASE_SERVICE_KEY}`,
      },
    }
  )

  const data = await response.json()

  if (data && data.length > 0) {
    const cached = data[0]

    // TTL 체크 제거: 영구 캐싱
    // 히트 카운트만 증가 (비동기)
    incrementHitCount(codeHash)

    console.log(`Cache HIT (hits: ${cached.hit_count + 1})`)

    return {
      explanation: cached.explanation,
      model: cached.model,
      cached: true,
      hitCount: cached.hit_count + 1,
    }
  }

  console.log('Cache MISS')
  return null
}

변경 사항

  • ~TTL 체크 로직 제거~
  • 히트 카운트만 증가
  • 캐시가 있으면 무조건 반환 (영구)

3단계: 캐시 저장 로직

코드 구현

async function saveCachedExplanation(
  codeHash,
  code,
  language,
  explanation,
  model,
  tokensUsed
) {
  const cacheData = {
    code_hash: codeHash,
    code_snippet: code.substring(0, 5000),
    language: language || 'unknown',
    explanation,
    model,
    tokens_used: tokensUsed,
    hit_count: 0,
    created_at: new Date().toISOString(),
    last_accessed_at: new Date().toISOString(),
    expires_at: null, // 영구 보존
  }

  const response = await fetch(`${SUPABASE_URL}/rest/v1/ai_code_explanations`, {
    method: 'POST',
    headers: {
      apikey: SUPABASE_SERVICE_KEY,
      Authorization: `Bearer ${SUPABASE_SERVICE_KEY}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify(cacheData),
  })

  return response.ok
}

핵심 포인트

  • expires_at: null: 영구 보존
  • hit_count: 0: 초기값, 이후 증가
  • code_snippet: 최대 5000자 제한

전체 흐름

flowchart TD
    A[AI 설명 요청] --> B[SHA-256 해시 생성]
    B --> C[Supabase 캐시 조회]
    C --> D{캐시 존재?}
    D -->|Yes| E[히트 카운트 +1]
    E --> F[캐시 반환 200ms]
    D -->|No| G[OpenAI API 호출 2초]
    G --> H[Supabase 저장]
    H --> I[AI 설명 반환]




스마트 정리 시스템

문제: DB 용량 관리

“캐시가 계속 쌓이면 DB 용량이 부족하지 않나요?”

맞는 말입니다. 저도 이 부분이 걱정됐거든요. 그래서 스마트 정리 시스템을 추가했습니다.


정리 전략

💡핵심 원칙

사용하는 캐시는 보존, 사용하지 않는 캐시만 삭제

  1. 6개월 미사용 캐시: hit_count = 0 + 생성 후 6개월 경과
  2. 1년 미접근 캐시: hit_count < 3 + 1년간 접근 없음

정리 함수

CREATE OR REPLACE FUNCTION cleanup_unused_cache()
RETURNS TABLE(deleted_count bigint) AS $$
DECLARE
  deleted bigint;
BEGIN
  -- 정리 대상
  DELETE FROM ai_code_explanations
  WHERE
    (hit_count = 0 AND created_at < NOW() - INTERVAL '6 months')
    OR
    (hit_count < 3 AND last_accessed_at < NOW() - INTERVAL '1 year');

  GET DIAGNOSTICS deleted = ROW_COUNT;
  RAISE NOTICE '🧹 Cleaned up % unused cache entries', deleted;
  RETURN QUERY SELECT deleted;
END;
$$ LANGUAGE plpgsql;

이렇게 하면:

  • 인기 캐시는 영구 보존
  • 쓸모없는 캐시만 삭제
  • DB 용량 관리 자동화

통계 뷰

개인적으로 캐시 효율을 추적하고 싶어서 통계 뷰도 만들어봤습니다.

CREATE VIEW ai_cache_stats AS
SELECT
  COUNT(*) as total_cached,
  SUM(hit_count) as total_hits,
  AVG(hit_count) as avg_hits_per_cache,

  -- 사용률
  COUNT(CASE WHEN hit_count > 0 THEN 1 END) as used_cache_count,
  COUNT(CASE WHEN hit_count = 0 THEN 1 END) as unused_cache_count,

  -- 최초 생성 시 저장한 토큰과 추정 반복 회피량을 구분
  SUM(tokens_used) AS tokens_in_cached_responses,
  SUM(tokens_used * hit_count) AS estimated_reused_tokens,


  -- 정리 가능 캐시
  COUNT(CASE
    WHEN hit_count = 0 AND created_at < NOW() - INTERVAL '6 months'
    THEN 1
  END) as cleanable_old_unused

FROM ai_code_explanations;

실제로 조회해보면:

total_cached: 47개
total_hits: 142회
avg_hits_per_cache: 3.0회
used_cache_count: 28개 (59.6%)
unused_cache_count: 19개 (40.4%)
tokens_in_cached_responses: 최초 생성 응답에 기록된 토큰의 합
estimated_reused_tokens: 응답별 토큰 × hit_count의 합 (추정)
cleanable_old_unused: 0개

이 집계만으로 달러 비용 절감을 확정할 수는 없습니다. 입력·출력 토큰 단가를 구분하고, 회피한 호출의 토큰 수와 실제 청구 내역을 비교해야 합니다. 이전의 $0.21 및 연간 환산은 단가와 계산 근거가 일치하지 않아 제거했습니다. 캐시 DB 운영 비용도 별도입니다.





캐시가 오래 남아도 되는 조건

코드만 해시하면 모델·프롬프트·설명 언어가 바뀌어도 옛 답을 돌려줄 수 있습니다. 캐시 키에는 코드와 언어뿐 아니라 모델 식별자, 프롬프트 버전, 출력 형식 버전을 포함해야 합니다. 잘못된 답변은 삭제·재생성할 수 있어야 하며, 민감 정보가 있는 코드는 공용 캐시에 저장하지 않습니다. “영구”는 무효화 정책이 필요 없다는 뜻이 아닙니다.

실전 적용 후기

캐시 히트율

실제로 적용해보고 2주간 운영해본 결과입니다.

pie title 2주 운영 결과
    "캐시 히트" : 142
    "API 호출" : 47

2주 운영 결과:

  • 총 요청: 189회
  • 캐시 히트: 142회 (약 75.1%)
  • OpenAI 호출: 47회 (약 25%)

히트율 75%면 꽤 성공적이라고 봅니다. 블로그 글은 한번 쓰면 계속 읽히거든요. 특히 인기 글일수록 캐시 히트율이 높아집니다.


응답 시간 개선

구분응답 시간개선 효과
OpenAI API 호출약 2초-
Supabase 캐시 조회약 200ms10배 정도 빠름

캐시 히트 시 응답 속도가 10배 정도 빨라졌습니다. 사용자 입장에서는 거의 즉시 설명이 표시되는 느낌이에요.


인기 코드 분석

재밌는 건 hit_count를 추적하니까 어떤 코드가 인기 있는지 알 수 있다는 겁니다.

SELECT
  language,
  hit_count,
  LEFT(code_snippet, 50) as code_preview
FROM ai_code_explanations
ORDER BY hit_count DESC
LIMIT 5;

결과:

언어히트코드 미리보기
java23회orders.stream().filter(...
javascript18회const [value, setValue] = useState(0)
java15회@Transactional...
sql12회SELECT * FROM users WHERE...
java11회Optional.ofNullable(...

역시나 Stream API와 함수형 프로그래밍 관련 코드가 인기가 많네요.





예상치 못한 문제들

운영하면서 몇 가지 질문을 받았는데, 정리해봤습니다.

문제 1: 해시 충돌 걱정

“SHA-256 해시 충돌이 발생하면 어떡하죠?”

✅현실적으로 불가능합니다

SHA-256 충돌 확률은 2^256분의 1입니다. 우주의 모든 원자 개수보다 많다고 하네요.

블로그 코드 블록 100개 정도로는 절대 충돌하지 않으니 걱정 안 해도 됩니다.


문제 2: 코드 미세 변경

“코드를 한 글자만 바꿔도 해시가 달라지지 않나요?”

맞습니다. 그게 의도입니다.

// 이 코드와
function add(a, b) {
  return a + b
}

// 이 코드는 다른 해시 (공백 제거)
function add(a, b) {
  return a + b
}

코드가 다르면 설명도 달라야 하니까요. 정확하게 같은 코드만 캐시에서 반환합니다.


문제 3: AI 모델 업그레이드

“GPT-4o-mini에서 GPT-5로 업그레이드하면 캐시는?”

💡모델별 캐시 삭제 함수
CREATE FUNCTION cleanup_cache_by_model(target_model VARCHAR)
RETURNS TABLE(deleted_count bigint) AS $$
DECLARE
  deleted bigint;
BEGIN
  DELETE FROM ai_code_explanations
  WHERE model = target_model;

  GET DIAGNOSTICS deleted = ROW_COUNT;
  RETURN QUERY SELECT deleted;
END;
$$ LANGUAGE plpgsql;

-- 사용 예시
SELECT cleanup_cache_by_model('gpt-4o-mini');

모델 업그레이드 시 기존 캐시를 일괄 삭제하고, 새 모델로 다시 캐싱하면 됩니다.





내 프로젝트에 바로 적용하기

체크리스트

  • Supabase 계정과 프로젝트를 생성했는가?
  • 테이블 스키마를 생성했는가?
  • 인덱스를 추가했는가?
  • 환경 변수에 SUPABASE_URL과 KEY를 등록했는가?
  • 스마트 정리 함수를 생성했는가?

주의사항

🚨절대 하지 말 것

❌ Supabase Service Key를 클라이언트에 노출

  • Service Key는 서버에서만 사용
  • 클라이언트에서는 Anon Key만 사용

❌ 인덱스 없이 운영

  • code_hash 인덱스는 필수
  • 조회 속도가 100배 차이

❌ 정리 시스템 없이 무한 증가

  • 6개월마다 cleanup_unused_cache() 실행
  • 또는 cron job으로 자동화

추천 설정

✅검증된 설정값

테이블 설정:

-- PK로 code_hash 사용
-- expires_at은 NULL (영구 보존)
-- hit_count로 인기 추적

정리 주기:

- 6개월마다 미사용 캐시 정리
- 또는 DB 용량 80% 도달 시

모니터링:

-- 매월 통계 확인
SELECT * FROM ai_cache_stats;

트러블슈팅

Q. “캐시 조회가 느려요”

  • 인덱스를 확인하세요 (idx_code_hash)
  • Supabase 무료 플랜은 제한이 있을 수 있습니다

Q. “DB 용량이 빠르게 차요”

  • cleanup_unused_cache() 실행하세요
  • code_snippet을 5000자로 제한했는지 확인하세요

Q. “히트율이 낮아요”

  • 초기엔 낮을 수 있습니다
  • 2-3주 후 안정화됩니다




이 접근의 아쉬운 점

1. DB 용량은 계속 증가한다

영구 캐싱의 가장 큰 문제입니다.

Supabase 무료 플랜: 500MB

현재 (2주 운영):

  • 캐시 데이터: 약 5MB
  • 예상 증가율: 월 2~3MB

6개월 후 예상:

  • 약 20~30MB
  • 여유롭지만, 1년 후엔?

해결책:

  • 6개월마다 cleanup_unused_cache() 수동 실행
  • 또는 cron job으로 자동화 (별도 인프라 필요)

2. 코드 블록 변경 시 수동 삭제

블로그 글을 수정하면?

// 기존 코드 (이미 캐싱됨)
function add(a, b) {
  return a + b
}

// 수정된 코드
function add(a, b, c) {
  return a + b + c
}

문제:

  • 기존 코드의 AI 설명이 DB에 남아있음
  • 새 코드는 다른 해시 → 새로 캐싱됨
  • 결과: 불필요한 캐시 2개

해결책:

  • 글 수정 시 수동으로 기존 캐시 삭제
  • 또는 “글 ID + 코드 블록 순서”로 캐시 키 설계 (복잡)

3. Supabase 의존성

Supabase가 문제 생기면?

  • 무료 플랜 변경 (500MB → 100MB?)
  • 서비스 장애
  • 가격 정책 변경

대응책:

  • PostgreSQL 백업 (주 1회)
  • 필요 시 다른 DB로 마이그레이션 준비 (SQL 표준 준수)

4. 개선할 수 있는 방법들 (하지만 안 했다)

방법 1: Redis + RDB Persistence

장점: 메모리 캐시 + 영구 저장
단점: 운영 복잡도 급증, 월 $10+ 비용

왜 안 했나?
블로그 트래픽(하루 500~1000명)에는 오버스펙입니다.

방법 2: CDN Edge Function + KV Store

장점: 글로벌 분산 캐싱
단점: Cloudflare Workers KV = 월 $5+

왜 안 했나?
비용이 절감 효과를 초과합니다.

방법 3: 완벽한 캐시 무효화 시스템

장점: 글 수정 시 자동 삭제
단점: CMS와 통합 필요, 개발 복잡도 ↑

왜 안 했나?
글 수정 빈도가 낮아서 수동 삭제로 충분합니다.


5. 결국 “충분히 좋은 해결”을 선택했다

이 시스템은 완벽하지 않습니다. 하지만:

  • ✅ 개인 블로그 규모에는 충분
  • ✅ 운영 복잡도가 낮음
  • ✅ 비용이 거의 안 듦
  • ✅ 6개월마다 정리하면 됨

만약 하루 방문자 1만 명 이상이라면?
그때는 Redis나 CDN을 고민해야 할 겁니다.

지금은 이 정도로 충분합니다.





시스템 점검 체크리스트

저도 배포 전에 이 항목들을 꼭 확인합니다. Supabase 영구 캐싱을 사용한다면 참고하시면 좋을 것 같습니다.

  • code_hash 인덱스: 캐시 조회 속도를 위해 PK 인덱스가 제대로 설정되었는가?
  • hit_count 모니터링: 캐시 히트율이 50% 이상인가? (2~3주 후 안정화)
  • DB 용량 관리: 6개월마다 미사용 캐시 정리 계획이 있는가?
  • code_snippet 길이 제한: 5000자로 제한하여 DB 용량 폭발을 방지했는가?
  • Service Key 보안: Supabase Service Key가 서버 환경변수에만 있고, 클라이언트에 노출되지 않는가?




마무리

TTL 기반 캐싱을 영구 캐싱으로 전환한 경험을 정리해봤습니다.

핵심은 이겁니다. “코드는 불변이다 → 캐시도 불변이다 → TTL이 필요 없다”


실제 효과:

  • OpenAI API 호출 약 75% 감소
  • 응답 속도 10배 정도 향상
  • 연간 약 $5 비용 절감
  • 덤으로 인기 코드 분석 가능

Trade-off 해결:

  • DB 용량 관리 → 스마트 정리로 해결
  • 모델 업그레이드 → 일괄 삭제 함수로 해결

솔직히 금액만 보면 작아 보입니다. 하지만 원칙이 중요하다고 생각해요. “불필요한 API 호출을 하지 않는다”는 원칙이 쌓이면 결국 큰 차이를 만들거든요.

블로그에 AI 기능을 추가하고 있거나, 이미 1편을 구현하신 분들께 도움이 되었으면 합니다. 로컬 스토리지 캐싱에서 한 단계 더 나아가면 비용을 꽤 줄일 수 있습니다.





참고 :

https://supabase.com/docs
https://www.postgresql.org/docs/current/sql-createview.html
https://platform.openai.com/docs/pricing




읽어주셔서 감사합니다.🖐


Ramsbaby
Written byRamsbaby
이 블로그는 직접 개발/운영하는 블로그이므로 당신을 불쾌하게 만드는 불필요한 광고가 없습니다.

#My Github#소개 페이지#Blog OpenSource Github