Computer >> 컴퓨터 >  >> 프로그래밍 >> Redis

SQL 성능 향상: Drizzle ORM 쿼리에 Upstash Redis 캐싱 적용하기

얼마 전, 저희는 Drizzle ORM과 협업할 좋은 기회를 얻었습니다.

이 TypeScript ORM이 커뮤니티에서 폭발적인 사랑을 받고 있다는 점을 고려하면, 저희가 "예스 😳"라고 답한 것은 당연한 선택이었습니다.

SQL 성능 향상: Drizzle ORM 쿼리에 Upstash Redis 캐싱 적용하기

이 글에서는 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)

진짜 마법은 쓰기 작업에서 일어납니다. 관계형 데이터베이스의 데이터를 수정하면, 저희 통합 레이어가 자동으로 다음을 처리합니다.

  1. 의존성 식별: 수정된 테이블에 의존하는 캐시된 쿼리가 어떤 것인지 파악합니다.
  2. 일괄 무효화: 영향을 받는 모든 캐시 항목을 한꺼번에 제거합니다.
// 이 insert는 usersTable에 의존하는 모든 캐시된 쿼리를 자동으로 무효화합니다
await db.insert(usersTable).values({ 
 email: 'new@user.com', 
 status: 'active' 
});

가장 단순한 캐시 구현: "나이브(Naive)" 접근 방식

먼저 가장 단순한 형태의 구현부터 살펴보며, 저희 캐싱 통합이 해결하는 문제들을 이해해 보겠습니다. 쿼리 결과를 캐싱하려면 다음 두 가지가 필요합니다.

  1. 캐시 값 자체를 저장
  2. 무효화를 위해 해당 쿼리가 어떤 테이블에 의존하는지 추적

단순 캐시 저장

// 캐시에 항목을 추가할 때
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 연산이 필요합니다.

  1. 먼저 SUNION을 호출해 삭제할 키 목록을 가져옵니다.
  2. 그런 다음 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 구현체