예제 서비스: 작은 쇼핑 서비스
마지막 프로젝트는 작은 쇼핑 서비스를 만든다고 가정한다. 모든 기능을 완성하는 것이 목표는 아니다. Redis를 어디에 두고, 어떤 데이터를 어떤 자료구조로 표현하며, 장애가 났을 때 어떻게 회복할지 설계하는 것이 목표다.
서비스에는 네 가지 Redis 기능이 들어간다.
로그인 사용자의 세션 저장
상품 상세 조회 캐시
상품 조회수 기반 실시간 랭킹
주문·알림 처리를 위한 이벤트 큐
관계형 데이터베이스는 여전히 원본 데이터 저장소다. 사용자, 상품, 주문의 최종 상태는 DB에 저장한다. Redis는 빠른 조회, 임시 상태, 실시간 집계, 비동기 처리를 맡는다. 이 경계를 흐리게 만들면 장애 대응이 어려워진다.
AI 생성 이미지
전체 구조
요청 흐름은 다음처럼 나눌 수 있다.
사용자가 로그인하면 애플리케이션은 세션 정보를 Redis Hash에 저장한다.
상품 상세 페이지를 요청하면 먼저 Redis 캐시를 조회한다.
캐시에 없으면 DB에서 상품을 읽고 Redis에 다시 저장한다.
상품이 조회될 때마다 Sorted Set의 점수를 증가시켜 실시간 인기 상품 랭킹을 만든다.
주문이 생성되면 DB에 주문을 먼저 저장하고, 이후 Redis Stream에 이벤트를 발행해 알림·재고 후처리·분석 작업을 비동기로 처리한다.
이 구조에서 중요한 원칙은 하나다. Redis가 빠르다고 해서 모든 것을 Redis에 넣는 것이 아니라, Redis에 어울리는 책임만 맡긴다.
키 설계 문서
프로젝트를 시작할 때는 명령어보다 키 설계 문서를 먼저 만든다. Redis는 테이블 스키마가 없기 때문에 키 이름과 TTL 정책이 사실상 스키마 역할을 한다.
| 기능 | 키 예시 | 자료구조 | TTL | 설명 |
|---|---|---|---|---|
| 사용자 세션 | session:{token} | Hash | 30분 | 로그인 사용자 ID, 권한, 마지막 접근 시간 저장 |
| 상품 캐시 | cache:product:{productId} | String(JSON) | 5~15분 | 상품 상세 응답 캐시. TTL에 랜덤 값을 섞어 동시 만료를 줄임 |
| 없는 상품 캐시 | cache:product:none:{productId} | String | 30~60초 | 존재하지 않는 상품 반복 조회 방지 |
| 일간 상품 랭킹 | rank:product:view:daily:{yyyyMMdd} | Sorted Set | 3일 | 상품 ID를 member로, 조회수를 score로 저장 |
| 주문 이벤트 | stream:order-events | Stream | 길이 제한 | 주문 생성 후 비동기 처리 이벤트 저장 |
| 처리 중 이벤트 | processing:event:{eventId} | String | 수분 | 같은 이벤트를 동시에 처리하지 않기 위한 짧은 잠금 |
| 처리 완료 이벤트 | processed:event:{eventId} | String | 1일 이상 | 이벤트 중복 처리 방지용 멱등성 키 |
| 캐시 무효화 재시도 | stream:cache-invalidate | Stream | 길이 제한 | 캐시 삭제 실패나 지연 삭제를 재시도 |
키 이름에는 도메인, 목적, 식별자가 드러나야 한다. product:1001보다 cache:product:1001이 낫다. 전자는 원본처럼 보이고, 후자는 캐시임을 명확히 말한다. 운영 중 Redis를 확인할 때 이 차이는 크다.
TTL도 함께 문서화해야 한다. 어떤 키는 반드시 사라져야 하고, 어떤 키는 장애 복구를 위해 충분히 오래 남아 있어야 한다. 특히 멱등성 키의 TTL은 "중복 이벤트가 다시 들어올 수 있는 시간"보다 길어야 한다.
사용자 세션 구현
세션은 짧은 생명주기를 가진 데이터다. 사용자가 로그아웃하거나 일정 시간 동안 활동하지 않으면 사라져야 한다. Hash를 사용하면 세션 안의 필드를 나눠 저장할 수 있다.
HSET session:abc123 userId 1001 role USER loginAt 2026-05-30T10:00:00Z lastAccessAt 2026-05-30T10:00:00Z
EXPIRE session:abc123 1800요청이 들어오면 애플리케이션은 토큰으로 세션을 조회한다.
HGETALL session:abc123활동할 때마다 세션 만료 시간을 연장하는 방식을 슬라이딩 만료라고 한다. 이 방식을 쓸 때 모든 요청마다 EXPIRE를 호출하면 Redis 쓰기 부하가 커질 수 있다. 그래서 마지막 연장 시각을 보고 5분 이상 지났을 때만 TTL을 갱신하는 식으로 조절한다.
HSET session:abc123 lastAccessAt 2026-05-30T10:05:00Z
EXPIRE session:abc123 1800세션 장애 정책도 정해야 한다. Redis가 잠시 응답하지 않을 때 모든 사용자를 즉시 로그아웃시킬 것인지, 짧은 시간 재시도할 것인지, 일부 읽기 전용 기능만 허용할 것인지 결정해야 한다. 세션은 사용자 경험과 보안이 함께 걸린 기능이므로 실패 시 정책을 코드에 명확히 남겨야 한다.
상품 조회 캐시 구현
상품 상세 조회는 캐시의 대표 사례다. 같은 상품을 많은 사용자가 반복해서 보기 때문이다. 여기서는 Cache-Aside 패턴을 사용한다.
흐름은 단순하다.
cache:product:{productId}를 조회한다.값이 있으면 바로 응답한다.
값이 없으면 DB에서 상품을 조회한다.
조회 결과를 Redis에 저장하고 응답한다.
DB에도 없으면 짧은 TTL의 negative cache를 저장한다.
GET cache:product:1001
SET cache:product:1001 '{상품 JSON}' EX 720
SET cache:product:none:9999 '1' EX 60TTL은 고정값보다 범위를 두는 편이 안전하다. 모든 상품 캐시를 10분으로 설정하면 특정 시점에 많은 키가 함께 만료될 수 있다. 예를 들어 기본 600초에 0~300초의 랜덤 값을 더하면 만료 시점이 분산된다.
상품이 수정될 때는 DB를 먼저 갱신하고 캐시를 삭제한다.
DEL cache:product:1001왜 캐시를 갱신하지 않고 삭제할까? Cache-Aside에서는 다음 조회가 DB에서 최신 값을 읽어 다시 캐시를 만들게 하는 편이 단순하다. 캐시 갱신 중 실패하거나 오래된 값으로 덮어쓰는 문제도 줄일 수 있다.
단, DEL도 실패할 수 있다. 네트워크 오류, Redis 장애, 배포 중 연결 문제는 언제든 생긴다. 그래서 상품 수정 트랜잭션이 성공하면 DB의 outbox 테이블이나 Redis Stream에 캐시 무효화 이벤트를 남긴다. 별도 워커는 이 이벤트를 읽어 DEL cache:product:{id}를 재시도한다.
XADD stream:cache-invalidate * type product productId 1001 reason updatedTTL은 마지막 안전망이다. 캐시 삭제가 실패해도 일정 시간이 지나면 오래된 값은 사라진다. 일관성이 중요한 상품 가격이나 판매 상태라면 TTL을 짧게 잡고, 응답 속도가 더 중요한 설명·이미지 정보는 조금 길게 둘 수 있다.
실시간 랭킹 구현
상품 조회수 랭킹은 Sorted Set이 잘 맞는다. 상품 ID를 member로 저장하고, 조회가 발생할 때마다 score를 1씩 증가시킨다.
ZINCRBY rank:product:view:daily:20260530 1 1001
EXPIRE rank:product:view:daily:20260530 259200인기 상품 상위 10개는 다음처럼 조회한다.
ZRANGE rank:product:view:daily:20260530 0 9 REV WITHSCORES랭킹 키는 날짜 단위로 나누는 것이 좋다. 하나의 rank:product:view 키에 계속 누적하면 오래된 인기도가 남아 현재 흐름을 보기 어렵고, 키가 계속 커진다. 일간·시간별 키로 나누면 TTL로 자연스럽게 정리할 수 있다.
트래픽이 매우 큰 서비스라면 하나의 랭킹 키가 Hot Key가 될 수 있다. 이때는 조회 이벤트를 바로 ZINCRBY하지 않고 Stream에 쌓은 뒤 워커가 묶어서 반영하거나, rank:product:view:daily:{date}:shard:{n}처럼 여러 키로 나눠 기록한 다음 주기적으로 합산하는 방식을 검토한다. 단순한 구조에서 시작하되, 병목이 확인되면 분산한다.
랭킹 값의 정확도도 미리 정해야 한다. 화면에 보여 줄 "지금 인기 있는 상품"이라면 약간의 지연이나 일부 누락을 허용할 수 있다. 하지만 정산이나 보상에 쓰는 통계라면 Redis 랭킹만 원본으로 삼으면 안 된다. 이 경우 조회 이벤트를 별도 로그 저장소나 DB에 남기고 Redis는 빠른 조회용 집계로 사용한다.
이벤트 큐 구현
주문이 생성되면 알림 발송, 쿠폰 사용 처리, 분석 로그 적재 같은 후속 작업이 필요하다. 이 작업을 주문 API 안에서 모두 처리하면 응답이 느려지고 실패 지점이 많아진다. Redis Stream을 사용하면 주문 생성과 후속 처리를 분리할 수 있다.
주문 API는 DB에 주문을 저장한 뒤 이벤트를 추가한다.
XADD stream:order-events * eventId evt-10001 type ORDER_CREATED orderId 5001 userId 1001운영에서 주문 이벤트가 반드시 발행되어야 한다면 여기서 한 단계 더 안전하게 설계한다. 주문 저장과 outbox 기록을 같은 DB 트랜잭션으로 묶고, 별도 릴레이가 outbox를 읽어 Redis Stream에 발행한다. 단순 예제에서는 API가 바로 XADD해도 되지만, 중요한 주문 흐름에서는 "DB 저장 성공, 이벤트 발행 실패" 상태를 복구할 수 있어야 한다.
워커는 Consumer Group으로 이벤트를 나눠 처리한다.
XGROUP CREATE stream:order-events order-workers $ MKSTREAM
XREADGROUP GROUP order-workers worker-1 COUNT 10 BLOCK 5000 STREAMS stream:order-events >처리에 성공하면 반드시 ACK를 보낸다. 이때 ACK할 ID는 XREADGROUP으로 받은 실제 메시지 ID다.
XACK stream:order-events order-workers 1748580000000-0Stream 처리는 중복 실행될 수 있다고 가정해야 한다. 워커가 작업을 끝낸 뒤 ACK 전에 죽으면 같은 이벤트가 다시 처리될 수 있다. 반대로 처리하기 전에 "처리 완료" 표시부터 남기면, 워커가 중간에 죽었을 때 실제 작업은 하지 않았는데 다음 워커가 이미 처리된 이벤트로 오해할 수 있다.
그래서 멱등성은 보통 두 단계로 나눈다. 먼저 짧은 TTL의 처리 중 잠금을 잡아 같은 이벤트가 동시에 실행되는 것을 막고, 실제 후속 작업이 성공한 뒤 처리 완료 키를 남긴다.
SET processing:event:evt-10001 worker-1 NX EX 300이 명령이 성공하면 현재 워커가 처리권을 얻은 것이다. 실패하면 다른 워커가 처리 중일 가능성이 있으므로 잠시 뒤 다시 확인하거나 건너뛴다. 작업이 성공하면 처리 완료 키를 남기고 ACK한다.
SET processed:event:evt-10001 1 EX 86400
DEL processing:event:evt-10001
XACK stream:order-events order-workers 1748580000000-0이 방식도 완벽한 "정확히 한 번" 처리를 보장하는 것은 아니다. Redis Stream은 적어도 한 번 처리될 수 있다고 보고 설계해야 한다. 결제 승인, 포인트 지급, 쿠폰 사용처럼 한 번만 실행되어야 하는 작업은 DB의 고유 제약, 상태 전이 조건, 외부 API의 idempotency key를 함께 사용해야 한다. Redis의 멱등성 키는 중복 실행 가능성을 줄이고 빠르게 판별하게 해 주는 장치다.
ACK되지 않은 메시지는 Consumer Group의 pending 목록에 남는다. 특정 워커가 죽어 pending 메시지가 오래 남으면 다른 워커가 회수해야 한다. 운영 워커는 XPENDING으로 오래된 pending 메시지를 확인하고, XAUTOCLAIM이나 XCLAIM으로 가져와 다시 처리하는 절차를 가져야 한다.
처리에 계속 실패하는 이벤트는 무한 재시도하지 않는다. 재시도 횟수를 필드나 별도 저장소에 기록하고, 기준을 넘으면 DLQ 역할의 Stream으로 보낸다.
XADD stream:order-events:dlq * eventId evt-10001 reason notification_failedDLQ는 실패를 숨기는 곳이 아니다. 운영자가 원인을 보고 재처리하거나 데이터 보정 여부를 판단하는 대기열이다.
일관성과 복구 흐름
이 프로젝트의 원본은 DB다. Redis 데이터는 대부분 다시 만들 수 있어야 한다. 이 원칙을 기준으로 복구 전략을 세운다.
상품 캐시는 DB에서 재생성할 수 있다. Redis가 비어 있으면 처음에는 캐시 미스가 늘고 DB 부하가 증가한다. 이를 대비해 인기 상품 목록을 기준으로 배포 직후나 장애 복구 직후 캐시를 미리 채우는 warm-up 작업을 둘 수 있다.
랭킹은 완전한 원본이 아닐 수 있다. 조회 이벤트가 Redis에만 있고 별도 로그가 없다면 Redis 장애 시 일부 집계가 사라진다. 정확한 통계가 필요하다면 조회 이벤트를 로그 저장소나 DB에도 남기고, Redis 랭킹은 빠른 화면 표시용 집계로 취급한다.
주문 이벤트는 손실되면 안 된다. 중요한 이벤트라면 DB outbox를 함께 사용한다. 주문 저장과 outbox 기록을 같은 DB 트랜잭션으로 묶고, 별도 릴레이가 outbox를 읽어 Redis Stream에 발행한다. 이렇게 하면 주문은 저장됐지만 이벤트 발행은 실패한 상태를 나중에 복구할 수 있다.
세션은 정책에 따라 다르다. 보안이 중요하면 Redis 장애 시 재로그인을 요구할 수 있다. 사용자 경험이 중요하면 짧은 시간 재시도하거나 다중 Redis 구성을 사용한다. 어떤 선택이든 장애 시 동작을 미리 정해야 한다.
부하 테스트로 병목 찾기
구현이 끝났다면 감으로 튜닝하지 않는다. 작은 부하 테스트를 먼저 수행한다.
테스트 시나리오는 다음 네 가지면 충분하다.
로그인 후 세션 조회를 반복한다.
인기 상품 상세 조회를 높은 비율로 요청한다.
여러 상품 상세 조회를 섞어 캐시 hit ratio를 확인한다.
주문 이벤트를 초당 일정량 발행하고 워커 처리 지연을 측정한다.
관측할 지표는 Redis와 애플리케이션을 함께 봐야 한다.
| 영역 | 확인할 지표 |
|---|---|
| 애플리케이션 | p95/p99 응답 시간, 에러율, DB fallback 비율 |
| 캐시 | hit ratio, cache miss 급증 여부, negative cache 비율 |
| Redis | used_memory, connected_clients, instantaneous_ops_per_sec |
| 지연 | SLOWLOG GET, LATENCY DOCTOR, 네트워크 왕복 시간 |
| Stream | pending 개수, 가장 오래된 pending 시간, DLQ 증가량 |
| 랭킹 | 특정 키 요청 집중, ZCARD 크기, 조회 범위 |
병목이 보이면 원인을 작게 나눠 본다. Redis CPU가 높은지, 네트워크 왕복이 많은지, Big Key를 읽고 있는지, DB fallback이 너무 많은지 구분해야 한다.
예를 들어 상품 상세 페이지에서 상품, 리뷰 요약, 판매자 정보를 각각 GET한다면 네트워크 왕복이 늘어난다. 함께 조회해도 되는 캐시는 MGET이나 pipeline을 검토한다. 반대로 너무 많은 정보를 하나의 JSON 캐시에 넣으면 작은 변경에도 큰 값을 다시 저장해야 한다. 읽기 패턴과 변경 패턴을 같이 보고 결정한다.
랭킹 키가 커지면 상위 10개만 조회하더라도 쓰기 집중이 문제가 될 수 있다. 이때는 조회 이벤트를 Stream으로 받고 워커가 배치 반영하도록 바꾼다. 즉시성은 조금 낮아지지만 API 요청 지연은 줄어든다. 이것이 Redis 설계의 핵심이다. 빠른 기능 하나를 얻기 위해 어떤 일관성, 즉시성, 운영 복잡도를 지불하는지 판단해야 한다.
최종 점검 체크리스트
배포 전에는 다음 질문에 답할 수 있어야 한다.
각 Redis 키의 소유 기능과 삭제 정책이 명확한가?
임시 데이터에는 TTL이 설정되어 있는가?
상품 수정 시 캐시 무효화 실패를 재시도할 수 있는가?
캐시가 모두 사라져도 DB에서 다시 만들 수 있는가?
Stream 이벤트는 중복 처리되어도 안전한가?
처리 완료 표시를 실제 작업 성공 이후에 남기는가?
ACK되지 않은 메시지를 회수하는 절차가 있는가?
DLQ에 쌓인 이벤트를 누가, 언제, 어떻게 처리하는가?
Redis 장애 시 세션, 캐시, 랭킹, 큐 기능은 각각 어떻게 동작하는가?
운영에서
KEYS, 무제한HGETALL, 큰 범위ZRANGE를 사용하지 않는가?INFO,SLOWLOG, Stream pending, 메모리 사용량을 관측하고 있는가?
이 체크리스트는 문서로만 남기면 효과가 작다. 배포 파이프라인, 대시보드, 알림, Runbook에 연결해야 한다.
Redis를 조합하는 감각
Redis 학습의 끝은 명령어를 많이 아는 것이 아니다. 문제를 보고 적절한 자료구조, TTL, 키 이름, 장애 정책을 함께 떠올리는 것이다.
세션은 Hash와 TTL로 표현한다. 상품 조회는 String 캐시와 무효화 흐름으로 처리한다. 랭킹은 Sorted Set으로 만들되 Hot Key 가능성을 살핀다. 이벤트 처리는 Stream, Consumer Group, ACK, 멱등성, DLQ를 함께 설계한다.
이 네 가지를 하나의 서비스 안에서 연결해 보면 Redis가 단순한 캐시 서버가 아니라는 점이 분명해진다. Redis는 빠른 상태 저장소이자 실시간 자료구조 엔진이며, 때로는 메시징 시스템이다. 하지만 그만큼 잘못 쓰면 데이터 불일치와 장애 전파의 중심이 될 수 있다.
좋은 Redis 설계는 빠르면서도 지울 수 있고, 다시 만들 수 있으며, 실패했을 때 복구 경로가 있다. 이 기준을 지키면 Redis는 서비스의 성능을 높이는 도구를 넘어, 복잡한 실시간 기능을 단순하게 만드는 강력한 기반이 된다.
이 노트는 보도·비평·교육·연구 목적으로 작성되었습니다. 저작권 문의가 있으시면 에서 알려주세요.