API 캐시 전략 실전 — 응답 속도와 데이터 신선도를 함께 잡는 설계

게시일: 2025년 4월 15일 · 15분 읽기

Redis 붙이면 끝이라고 생각했는데, 캐시 무효화가 진짜 지옥이었다.

우리 팀이 음성 채팅 앱의 백엔드에서 경험한 캐시 전략 실전기를 공유한다.

문제: 캐시가 구원이라고 생각했다

초기 설계:

"모든 API 응답을 1시간 캐시하면 DB 부하가 90% 줄어들겠지?"

결과:

사용자 A가 설정을 바꿈 → 데이터 업데이트
사용자 B가 같은 설정을 읽음 → 1시간 전 캐시된 데이터를 받음
혼돈, 버그 리포트, 야식 디버깅

캐시는 쌍날 검이었다.

캐시 전략 1: Cache-Aside (Most Common)

구조:

클라이언트
  ↓
API 서버
  ├→ Redis (캐시)에 데이터가 있나?
  │   YES: 반환
  │   NO: DB에서 읽고, Redis에 저장, 반환
  ↓
Redis / Database

구현:

async function getUserProfile(userId: string) {
    // 1. 캐시 확인
    const cached = await redis.get(`user:${userId}:profile`);
    if (cached) {
        return JSON.parse(cached);
    }

    // 2. 캐시 미스: DB에서 읽기
    const user = await db.users.findOne(userId);

    // 3. 캐시에 저장 (TTL: 1시간)
    await redis.setex(
        `user:${userId}:profile`,
        3600,  // seconds
        JSON.stringify(user)
    );

    return user;
}

장점:

단점:

캐시 전략 2: Write-Through (데이터 일관성 중요)

구조:

쓰기 요청
  ↓
Redis에 쓰기
  ↓
DB에 쓰기 (순차 또는 병렬)
  ↓
응답

구현:

async function updateUserProfile(userId: string, data: any) {
    // 1. 캐시에 업데이트
    const cacheKey = `user:${userId}:profile`;
    await redis.setex(cacheKey, 3600, JSON.stringify(data));

    // 2. DB에 업데이트
    await db.users.update(userId, data);

    return data;
}

장점:

단점:

캐시 전략 3: SWR (Stale-While-Revalidate)

최신 데이터가 반드시 필요하지 않을 때:

async function getMessages(conversationId: string) {
    const cached = await redis.get(`messages:${conversationId}`);

    if (cached) {
        // 1. 즉시 반환 (오래된 데이터라도)
        const data = JSON.parse(cached);

        // 2. 백그라운드에서 업데이트 (사용자 체감 없음)
        redis.setex(
            `messages:${conversationId}:updating`,
            1,
            '1'  // 업데이트 중 표시
        ).then(async () => {
            const fresh = await db.messages.findMany(conversationId);
            await redis.setex(
                `messages:${conversationId}`,
                300,  // 5분 TTL
                JSON.stringify(fresh)
            );
        });

        return data;  // 사용자는 바로 받음
    }

    // 캐시 미스: 기존 cache-aside
    const data = await db.messages.findMany(conversationId);
    await redis.setex(
        `messages:${conversationId}`,
        300,
        JSON.stringify(data)
    );

    return data;
}

장점:

단점:

실전: 캐시 무효화 패턴

문제: 데이터가 업데이트되면 캐시를 지워야 한다.

패턴 1: 명시적 무효화

async function updateUserName(userId: string, newName: string) {
    // 1. DB 업데이트
    await db.users.update(userId, { name: newName });

    // 2. 캐시 무효화 (명시적)
    await redis.del(`user:${userId}:profile`);
    await redis.del(`user:${userId}:settings`);
    // ...
}

문제: 연관된 모든 캐시 키를 기억해야 한다.

해결:

// 패턴: 네임스페이스 관리
async function invalidateUserCache(userId: string) {
    // 와일드카드로 모든 user 캐시 삭제
    const keys = await redis.keys(`user:${userId}:*`);
    if (keys.length) {
        await redis.del(...keys);
    }
}

패턴 2: TTL에 의존

// ✅ TTL에 따라 자동 무효화
await redis.setex(
    `messages:${conversationId}`,
    300,  // 5분 후 자동 삭제
    JSON.stringify(messages)
);

// 실시간 업데이트가 아니어도 괜찮은 데이터에 사용

패턴 3: 버전 기반

// 캐시 키에 버전 포함
const version = user.updatedAt.getTime();
const cacheKey = `user:${userId}:profile:v${version}`;

await redis.setex(cacheKey, 86400, JSON.stringify(user));

// 데이터 업데이트 시 새 버전의 키가 생김
// 이전 캐시는 자연스럽게 오래됨

HTTP 캐시 헤더

또 다른 계층의 캐싱: 브라우저

// Express.js 예시
app.get('/api/user/:id', (req, res) => {
    const user = getUser(req.params.id);

    res.set('Cache-Control', 'public, max-age=3600');  // 1시간
    res.set('ETag', `"${user.id}-${user.updatedAt}"`);

    res.json(user);
});

효과:

Redis 구성: 성능과 안정성

우리 구성:

// Redis 클러스터
- 마스터: 1개 (쓰기)
- 복제본: 2개 (읽기)
- 센티널: 3개 (고가용성)

// 메모리 정책
maxmemory-policy: allkeys-lru
maxmemory: 16gb  // 가용 메모리의 70%

// 지속성
save 900 1      // 900초마다 최소 1개 키 변경 시 저장
appendonly yes  // AOF (Append-Only File) 사용

캐시 워밍업 (Cache Warming)

앱 시작 시 자주 사용되는 데이터를 미리 캐싱:

// App startup
async function warmUpCache() {
    // 1. 인기 있는 대화 미리 로드
    const popularConversations = await db.conversations
        .find({ messageCount: { $gt: 100 } })
        .limit(1000);

    for (const conv of popularConversations) {
        const messages = await db.messages.findMany(conv.id);
        await redis.setex(
            `messages:${conv.id}`,
            3600,
            JSON.stringify(messages)
        );
    }

    console.log(`Warmed up cache for ${popularConversations.length} conversations`);
}

// main.ts
app.listen(3000, async () => {
    await warmUpCache();
    console.log('Server started');
});

모니터링

// 캐시 히트율 추적
let cacheHits = 0;
let cacheMisses = 0;

async function getWithMetrics(key: string) {
    const cached = await redis.get(key);
    if (cached) {
        cacheHits++;
        return JSON.parse(cached);
    }
    cacheMisses++;
    // ...
}

// 1분마다 로그
setInterval(() => {
    const hitRate = cacheHits / (cacheHits + cacheMisses);
    console.log(`Cache hit rate: ${(hitRate * 100).toFixed(2)}%`);
}, 60000);

결론: 캐시 전략 선택

전략속도데이터 일관성사용처
Cache-Aside빠름느슨함읽기 위주 (게시글)
Write-Through느림높음일관성 중요 (계좌)
SWR매우 빠름중간실시간 선호 (메시지)

우리 음성 채팅 앱:

캐시는 은탄환이 아니다. 전략을 잘 선택하면 성능과 데이터 일관성을 둘 다 얻을 수 있다.

iL
ian.lab

실무 개발자입니다. 현장에서 겪은 문제와 해결 과정을 기록합니다. 오류 제보는 연락처로 보내주세요.