얼마 전, 저희는 Drizzle ORM과 협업할 좋은 기회를 얻었습니다.
이 TypeScript ORM이 커뮤니티에서 폭발적인 사랑을 받고 있다는 점을 고려하면, 저희가 "예스 😳"라고 답한 것은 당연한 선택이었습니다.

이 글에서는 Upstash Redis와 Drizzle의 캐싱 통합이 어떻게 SQL 성능을 개선하는지, 그리고 Lua 스크립트와 해시(Hash) 자료구조를 활용해 이 통합을 어떻게 최적화했는지 살펴보겠습니다.
도전 과제: 현대 애플리케이션에서의 SQL 성능
전통적인 SQL 데이터베이스는 강력한 일관성과 복잡한 관계 모델링에 탁월하지만, 다음과 같은 상황에서는 어려움을 겪을 수 있습니다.
- 높은 지연 시간: 분산 환경에서 발생하는 latency
- 연결 풀링 제약: 서버리스 함수 환경의 connection pooling 한계
- 반복적인 쿼리 오버헤드: 자주 조회되는 데이터에 대한 중복 비용
- 확장성 병목: 높은 읽기 부하 상황에서의 scaling 한계
그렇다면 해결책은 무엇일까요? 바로 데이터 관계를 이해하고 캐시 무효화를 자동으로 관리해주는 캐싱 계층입니다.
Upstash × Drizzle 캐싱의 작동 방식
읽기 성능 개선: 캐시 우선(Cache-First) + 폴백(Fallback)
Drizzle 캐싱을 활성화한 상태로 쿼리를 실행하면, 저희 통합 레이어가 먼저 Redis에 캐시된 결과가 있는지 확인합니다.
- 캐시 미스(Cache Miss): 캐시에 데이터가 없으면 데이터베이스에서 직접 읽어온 뒤, 그 결과를 의존 테이블 정보와 함께 Redis에 저장합니다.
- 캐시 히트(Cache Hit): 이후 동일한 쿼리는 관계형 데이터베이스를 거치지 않고 Redis에서 즉시 반환됩니다.
// 이 쿼리는 먼저 Redis를 확인하고, 필요한 경우에만 데이터베이스를 읽습니다
const users = await db.select().from(usersTable)
.where(eq(usersTable.status, 'active'))
.$withCache();
쓰기 작업을 위한 스마트 무효화(Smart Invalidation)
진짜 마법은 쓰기 작업에서 일어납니다. 관계형 데이터베이스의 데이터를 수정하면, 저희 통합 레이어가 자동으로 다음을 처리합니다.
- 의존성 식별: 수정된 테이블에 의존하는 캐시된 쿼리가 어떤 것인지 파악합니다.
- 일괄 무효화: 영향을 받는 모든 캐시 항목을 한꺼번에 제거합니다.
// 이 insert는 usersTable에 의존하는 모든 캐시된 쿼리를 자동으로 무효화합니다
await db.insert(usersTable).values({
email: 'new@user.com',
status: 'active'
});
가장 단순한 캐시 구현: "나이브(Naive)" 접근 방식
먼저 가장 단순한 형태의 구현부터 살펴보며, 저희 캐싱 통합이 해결하는 문제들을 이해해 보겠습니다. 쿼리 결과를 캐싱하려면 다음 두 가지가 필요합니다.
- 캐시 값 자체를 저장
- 무효화를 위해 해당 쿼리가 어떤 테이블에 의존하는지 추적
단순 캐시 저장
// 캐시에 항목을 추가할 때
await redis.set(itemHash, cachedValue);
await Promise.all(
dependentTables.map((table) => redis.sadd(table, itemHash))
);
이 방식은 캐시 결과를 키-값(key-value) 쌍으로 저장하고, 의존 테이블 이름으로 된 Set에 아이템 해시를 추가하는 방식으로 의존성을 추적합니다.
단순 캐시 무효화
// 테이블 변경에 따라 무효화할 때
const hashesToInvalidate = await redis.sunion(dependentTables);
await redis.del(...hashesToInvalidate);
수정된 테이블에 의존하는 모든 캐시 항목을 찾아낸 후 삭제하는 방식입니다.
"나이브" 접근 방식의 문제점
기술적으로는 동작하지만, 이 나이브한 구현에는 두 가지 성능 문제가 있습니다.
문제 1: 여러 번의 네트워크 왕복(Round Trip)
무효화 과정에는 두 번의 별도 Redis 연산이 필요합니다.
- 먼저
SUNION을 호출해 삭제할 키 목록을 가져옵니다. - 그런 다음 1번의 결과를 인자로
DEL을 호출합니다.
두 번째 연산이 첫 번째 연산의 완료를 기다려야 하는 왕복 의존성(round-trip dependency)이 발생합니다.
문제 2: 느린 대량 삭제
무효화해야 할 키가 많아지면 DEL 명령이 병목 지점이 될 수 있습니다.
// 수천 개의 키를 삭제할 수도 있습니다
await redis.del(...hashesToInvalidate);
users처럼 수백 개의 캐시된 쿼리가 참조하는 인기 테이블이 있다면, 단 한 번의 업데이트만으로도 수백 개의 Redis 키 삭제가 트리거될 수 있습니다. 키가 수백~수천 개에 달하면 삭제 속도가 너무 느려질 수 있습니다.
해결책 1: Lua 스크립트
Upstash Redis는 Lua 스크립트 실행을 완벽하게 지원합니다.
Lua 스크립트는 여러 Redis 명령을 서버 측에서 한 번에 실행함으로써 왕복(round trip) 문제를 해결합니다.
-- SUNION과 DEL을 결합한 무효화 스크립트
local tables = KEYS -- 테이블 이름이 key로 전달됩니다
local keysToDelete = {}
if #tables > 0 then
-- 이 테이블들에 의존하는 모든 해시를 가져옵니다
local hashesToInvalidate = redis.call('SUNION', unpack(tables))
-- 삭제 준비
for _, hash in ipairs(hashesToInvalidate) do
keysToDelete[#keysToDelete + 1] = hash
end
-- 테이블 Set 자체도 삭제 목록에 추가합니다
for _, table in ipairs(tables) do
keysToDelete[#keysToDelete + 1] = table
end
-- 단일 원자적(atomically) 삭제
if #keysToDelete > 0 then
redis.call('DEL', unpack(keysToDelete))
end
end
Lua 스크립트의 장점
- 단일 왕복: 모든 연산이 서버 측에서 처리됩니다.
- 지연 시간 감소: 연산 간 네트워크 오버헤드가 없습니다.
- 일관성 보장: 네트워크 문제로 인한 부분 업데이트(partial update) 위험이 없습니다.
해결책 2: 효율적인 삭제를 위한 해시 기반 저장
Lua 스크립트를 사용하더라도, 수백 개의 개별 키를 삭제하는 것은 생각보다 느릴 수 있습니다. Redis 해시(Hash)를 사용하면 훨씬 더 효율적으로 처리할 수 있습니다.
해시 기반 접근 방식
캐시된 쿼리를 각각 별도의 Redis 키로 저장하는 대신, 같은 테이블에 의존하는 쿼리들을 해시로 묶습니다.
// 기존 방식: 쿼리마다 고유한 키를 가짐
await redis.set('query_hash_1', result1);
await redis.set('query_hash_2', result2);
await redis.set('query_hash_3', result3);
// 새로운 방식: 테이블 의존성 기준으로 쿼리를 그룹화
const compositeKey = 'users,posts'; // users와 posts 테이블용 해시 키
await redis.hset(compositeKey, {
'query_hash_1': result1,
'query_hash_2': result2,
'query_hash_3': result3
});
해시가 훨씬 빠른 이유
users 테이블에 의존하는 쿼리를 무효화할 때를 비교해 보겠습니다.
// 기존 방식: 개별 키를 많이 삭제 (느림)
await redis.del('query_hash_1', 'query_hash_2', /* ...수백 개 더... */);
// 새로운 방식: 해시 테이블 전체를 삭제 (빠름)
await redis.del('__CT__users,posts');
성능상 이점
- 단일 삭제 연산:
DEL명령 한 번으로 수백 개의 캐시된 쿼리를 제거합니다. - 메모리 효율: Redis가 전체 해시 테이블을 한 번의 연산으로 해제할 수 있습니다.
- 원자적 정리: 관련된 모든 쿼리가 한꺼번에 무효화됩니다.
최종적으로 완성된 Lua 스크립트가 궁금하다면, Drizzle 저장소의 구현 코드를 확인해 보세요.
캐시 태그(Cache Tag)를 활용한 세분화된 제어
테이블 기반 무효화 외에도, Drizzle은 더 세밀한 캐시 제어를 위해 커스텀 태그를 지원합니다.
// 커스텀 태그와 함께 캐시
const premiumUsers = await db.select().from(usersTable)
.where(eq(usersTable.plan, 'premium'))
.$withCache({ tag: 'premium_users' });
// 나중에 이 특정 쿼리만 무효화
await db.$cache?.invalidate({ tags: 'premium_users' });
자동 무효화 vs 수동 무효화
자동 무효화(Automatic Invalidation, 기본값): 의존 테이블이 변경되면 쿼리가 자동으로 무효화됩니다. 데이터 일관성이 보장되지만, 캐시가 더 공격적으로 정리됩니다.
수동 무효화(Manual Invalidation): eventual consistency(최종 일관성)로 충분한 시나리오라면 자동 무효화를 끄고, 캐시를 비울 시점을 직접 제어할 수 있습니다.
// 자동으로 무효화되지 않음 - 분석(analytics) 데이터에 적합
const monthlyStats = await db.select()
.from(analyticsTable)
.$withCache({ autoInvalidate: false });
// 필요할 때 수동으로 무효화 (예: 매일 배치 작업)
await db.$cache?.invalidate({ tables: ['analyticsTable'] });
실전 활용 사례
여기까지 통합의 기술적인 부분을 살펴봤으니, 이제 이 개념들이 실제 애플리케이션에서 어떻게 활용되는지 알아보겠습니다.
이커머스 상품 카탈로그
// 자동 무효화와 함께 상품 목록 캐시
const products = await db.select()
.from(productsTable)
.where(eq(productsTable.active, true))
.$withCache({ tag: 'active_products' });
// 재고가 변경되면 캐시가 자동으로 무효화됩니다
await db.update(productsTable)
.set({ stock: newStock })
.where(eq(productsTable.id, productId));
콘텐츠 관리(CMS)
// 수동 무효화와 함께 게시된 아티클 캐시
const articles = await db.select()
.from(articlesTable)
.where(eq(articlesTable.status, 'published'))
.$withCache({
autoInvalidate: false,
tag: 'published_articles'
});
// 콘텐츠가 업데이트될 때 수동으로 무효화
await db.$cache?.invalidate({ tags: 'published_articles' });
결론
Upstash Redis와 Drizzle의 캐싱 통합은 (꽤) 최소한의 코드 변경만으로 SQL 쿼리 성능을 획기적으로 개선하고 데이터베이스 부하를 줄일 수 있습니다.
캐시를 활성화하면 다음과 같은 효과를 기대할 수 있습니다.
- 극적으로 빨라진 캐시 데이터의 쿼리 응답 속도
- 감소한 데이터베이스 부하와 향상된 확장성
Upstash Redis의 글로벌 분산 아키텍처와 서버리스 우선(serverless-first) 설계, 그리고 종량제(pay-as-you-go) 요금제는 현대적인 애플리케이션에 훌륭한 기반이 됩니다.
이커머스 플랫폼, 분석 대시보드, 콘텐츠 관리 시스템 등 어디에든 딱 맞는 선택입니다.
더 읽을거리
더 깊이 알아보고 싶으신가요? 아래 자료들을 추천합니다.
- Upstash Redis & Drizzle 통합 가이드
- Drizzle 캐싱 공식 문서
- Upstash Redis 시작하기
- Upstash Rate Limit SDK (TypeScript) - Lua 스크립트를 활용해 최적의 성능을 내는 또 다른 강력한 SDK
- Upstash Rate Limit SDK (Python) - Rate Limit SDK의 Python 구현체