블로그에 AI 기능 붙이기 (3편) - 영구 캐싱으로 비용 99% 줄이기
📚 시리즈: 블로그에 AI 기능 붙이기
3 / 3편- 11편 - 월 35원으로 OpenAI + Netlify Functions
- 22편 - MCP 서버로 Claude 교육시키기
- 33편 - 영구 캐싱으로 비용 99% 줄이기지금 읽는 중
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
처음엔 세 가지 선택지를 고민했습니다.
| 항목 | LocalStorage | Redis | Supabase | 선택 이유 |
|---|---|---|---|---|
| 비용 | 무료 | 월 $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 호출 99% 감소 (두 번째 요청부터 캐시)
- 비용 거의 제로
- 응답 속도 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 용량이 부족하지 않나요?”
맞는 말입니다. 저도 이 부분이 걱정됐거든요. 그래서 스마트 정리 시스템을 추가했습니다.
정리 전략
사용하는 캐시는 보존, 사용하지 않는 캐시만 삭제
- 6개월 미사용 캐시:
hit_count = 0+ 생성 후 6개월 경과 - 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,
-- 비용 절감 (GPT-4o-mini: ~200 tokens = $0.001)
SUM(tokens_used) as total_tokens_saved,
ROUND(SUM(tokens_used) * 0.001 / 200.0, 2) as estimated_cost_saved_usd,
-- 정리 가능 캐시
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%)
total_tokens_saved: 42,600 tokens
estimated_cost_saved_usd: $0.21
cleanable_old_unused: 0개2주 운영 기준, 이미 $0.21 절감했습니다. 연간으로 환산하면 약 $5.5 정도 절약할 수 있겠네요.
실전 적용 후기
캐시 히트율
실제로 적용해보고 2주간 운영해본 결과입니다.
pie title 2주 운영 결과
"캐시 히트" : 142
"API 호출" : 472주 운영 결과:
- 총 요청: 189회
- 캐시 히트: 142회 (약 75%)
- OpenAI 호출: 47회 (약 25%)
히트율 75%면 꽤 성공적이라고 봅니다. 블로그 글은 한번 쓰면 계속 읽히거든요. 특히 인기 글일수록 캐시 히트율이 높아집니다.
응답 시간 개선
| 구분 | 응답 시간 | 개선 효과 |
|---|---|---|
| OpenAI API 호출 | 약 2초 | - |
| Supabase 캐시 조회 | 약 200ms | 10배 정도 빠름 |
캐시 히트 시 응답 속도가 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;결과:
| 언어 | 히트 | 코드 미리보기 |
|---|---|---|
| java | 23회 | orders.stream().filter(... |
| javascript | 18회 | const [value, setValue] = useState(0) |
| java | 15회 | @Transactional... |
| sql | 12회 | SELECT * FROM users WHERE... |
| java | 11회 | 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
