Redis 4가지 활용 패턴 — SNS 6편

2026-05-04Spring Boot SNS 마이크로서비스 포트폴리오

Spring Boot SNS 포트폴리오 시리즈 6편. Redis 한 인프라가 캐시(Read-Through + 이벤트 무효화)·Sorted Set 랭킹·JWT 블랙리스트·Redisson 분산 락 네 가지 역할을 어떻게 동시에 수행하는지, Spring Batch + Redis Pipeline로 랭킹을 주기 재계산하는 흐름까지 한 글에 정리합니다.

📚 Spring Boot SNS 마이크로서비스 포트폴리오 · 6편 — Redis 4가지 활용 패턴 종합

이 글은 Spring Boot SNS 마이크로서비스 포트폴리오 시리즈의 6편입니다. 1~5편에서 부서별로 흩어져 있던 Redis 활용을 한 글에 모아 정리합니다. 캐시, Sorted Set 랭킹, JWT 블랙리스트, Redisson 분산 락 — 한 인프라가 네 가지 다른 역할을 동시에 맡는 이유와 각 패턴의 함정을 함께 짚어 갑니다.

비유는 1~5편을 그대로 이어 가요 — 회사 복도 한쪽에 놓인 다목적 캐비닛 하나. 한 칸은 자주 쓰는 서류 사본(캐시), 한 칸은 인기 게시글 순위 보드(Sorted Set), 한 칸은 분실 사원증 명단(블랙리스트), 한 칸은 회의실 예약판(분산 락). 같은 캐비닛 하나에 네 가지 칸이 칸막이로 나뉘어 있어 부서들이 다 함께 씁니다.

프로젝트 SNS는 이메일·비밀번호와 구글·깃허브 OAuth2 로그인, 게시글·댓글·좋아요·랭킹·구독·알림·한국어 검색까지 흔한 기능을 한 번씩 다 다뤄 보면서, 그 평범한 기능들을 마이크로서비스 패턴(Database-per-Service · API Gateway · Outbox + CDC · Redisson 분산 락)과 인프라(Kafka·Redis·Elasticsearch·LocalStack S3)로 어떻게 묶어 내는가를 직접 손으로 짜 보는 학습용 포트폴리오입니다.

왜 Redis 한 인프라가 네 가지 역할을 맡는가

처음 Redis 코드를 보면 막히는 지점이 두 가지예요.

첫째, 자료구조마다 명령어가 다 달라요. GET/SET(캐시), ZADD/ZREVRANGE(랭킹), EXPIRE/TTL(블랙리스트), RLock(분산 락) — 같은 인프라인데 명령어가 30종이 넘습니다. 어떤 일에 어떤 자료구조를 쓰는지가 안 짚이면 코드 보기가 답답해요.

둘째, StringRedisTemplateRedissonClient 두 클라이언트가 동시에 등장합니다. 둘 다 Redis 클라이언트인데 왜 두 개를 쓰는지가 처음엔 혼란스러워요. 답은 단순한데 — StringRedisTemplate은 일반 키-값 + Sorted Set 같은 기본 명령에, RedissonClient는 분산 락 같은 고수준 추상에. 역할이 달라서 둘 다 띄워 둡니다.

해결법은 한 단계예요. "어떤 일이면 어떤 자료구조와 어떤 클라이언트" 한 표를 머리에 박는 것. 그러면 4가지 패턴이 한눈에 보입니다.

Redis가 맡은 4가지 역할 한 표로

키 패턴자료구조클라이언트용도TTL
cache:posts:{id}StringStringRedisTemplate게시글 상세 캐시5분
cache:posts:listStringStringRedisTemplate게시글 목록 캐시이벤트 기반 삭제
ranking:postsSorted SetStringRedisTemplate인기 게시글 랭킹영구
session:blacklist:{jti}StringStringRedisTemplate (게이트웨이는 Reactive)JWT 블랙리스트토큰 만료까지
lock:post:create:{userId}(Redisson 내부)RedissonClient게시글 생성 분산 락Watchdog

키 네이밍 규칙 한 줄로 — <용도>:<엔티티>:<식별자> 형식. cache:posts:7은 게시글 7번 캐시, ranking:posts는 게시글 랭킹, session:blacklist:abc-uuid는 JWT 블랙리스트. 이 패턴만 지키면 다른 부서 사람이 봐도 키 이름만 보고 용도를 짐작할 수 있어요.

캐시 — Read-Through + Cache-Aside (목록은 이벤트 무효화)

가장 익숙한 패턴부터. 게시글 상세 조회 흐름은 단순해요.

요청
  │
  ▼
Redis 캐시 확인
  ├─ 히트 → 즉시 반환 (DB 조회 없음)
  └─ 미스 → DB 조회 → afterCommit에서 Redis에 저장 → 반환
String cacheKey = "cache:posts:" + id;
String cached = redisTemplate.opsForValue().get(cacheKey);
if (cached != null) {
    return objectMapper.readValue(cached, PostResponse.class);  // 캐시 히트
}

// 캐시 미스 → DB 조회 → afterCommit 콜백에서 캐시 저장
Post post = postRepository.findById(id).orElseThrow(...);
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
    @Override
    public void afterCommit() {
        redisTemplate.opsForValue().set(cacheKey, serialized, Duration.ofMinutes(5));
    }
});

여기서 시험 함정이 하나 있어요. 목록 캐시는 TTL이 아니라 이벤트로 무효화합니다.

// 게시글 상세는 5분 TTL
"cache:posts:{id}"     // TTL 5분

// 목록은 즉시 무효화 (TTL 안 씀)
"cache:posts:list"     // 새 글 생성 시 redisTemplate.delete(...)

이유 한 줄로 — 상세 조회는 살짝 오래된 값이라도 큰 문제 없지만, 목록은 "방금 작성한 게시글이 안 보임" 이 사용자에게 큰 답답함이 됩니다. 그래서 목록은 새 글이 생기는 순간 Redis에서 즉시 삭제하고, 다음 요청에서 DB로 다시 채워 옵니다. 이걸 이벤트 기반 캐시 무효화(Cache Invalidation by Event)라고 해요.

목록 캐시 무효화는 PostService.doCreatePost() 안에 한 줄로 들어가 있어요.

@Transactional
PostResponse doCreatePost(Long userId, PostRequest request) {
    Post saved = postRepository.save(post);
    redisTemplate.delete("cache:posts:list");  // ← 목록 캐시 즉시 삭제
    // ...
}

목록 캐시 삭제는 트랜잭션 안에서 하더라도 위험이 적어요(롤백되면 다음 요청에서 어차피 DB가 정확한 데이터를 줄 거니까). 정말 까다로운 건 캐시 SET이지 캐시 DELETE는 아니에요.

Sorted Set 랭킹 — score = like × 2 + view

Redis Sorted Set이 인기 게시글 랭킹의 자료구조예요. 키 하나(ranking:posts)에 여러 게시글 ID와 점수를 묶어 두면, Redis가 항상 점수 순으로 정렬을 유지합니다.

key: "ranking:posts"
─────────────────────────────
score    │  member (postId)
─────────────────────────────
  25.0   │  "7"   (좋아요 10 × 2 + 조회 5)
  18.0   │  "3"
  12.0   │  "1"
─────────────────────────────
// 랭킹 점수 업데이트
double score = likeCount * 2.0 + viewCount;
redisTemplate.opsForZSet().add("ranking:posts", String.valueOf(postId), score);

// 상위 10개 조회 (내림차순)
Set<String> top10 = redisTemplate.opsForZSet().reverseRange("ranking:posts", 0, 9);

여기서 정말 중요한 시험 함정 — Sorted Set 명령어의 시간복잡도가 모두 O(log N) 또는 O(log N + M)이라 수백만 건이 있어도 밀리초 안에 처리됩니다.

명령어시간복잡도용도
ZADDO(log N)점수 추가/갱신
ZINCRBYO(log N)점수 증감
ZRANGE / ZREVRANGEO(log N + M)상위 M개 조회
ZRANK / ZREVRANKO(log N)특정 멤버 순위 조회
ZSCOREO(1)특정 멤버 점수 조회

DB로는 정렬·인덱싱·캐싱을 다 거쳐도 수백 ms 걸리는 작업이 Redis Sorted Set으로는 1ms 안에 끝납니다. SNS의 인기 글 랭킹·인기 검색어·인기 상품 같은 곳에 Sorted Set이 자연스러운 선택이에요.

🎯 한 줄 정리

Sorted Set = 점수 순 정렬 자동 유지. ZREVRANGE O(log N + M)로 수백만 건도 밀리초. 랭킹·인기 검색어·인기 상품에 자연스러운 선택.

JWT 블랙리스트 — TTL이 토큰 만료 시간과 같은 트릭

2편에서 한 번 본 패턴인데, 6편에서 다시 짚어 보면 핵심 트릭이 한 줄에 보여요.

// 로그아웃 시 등록
redisTemplate.opsForValue().set(
    "session:blacklist:" + jti,
    "logout",
    ttl,                    // 토큰 만료까지 남은 시간
    TimeUnit.MILLISECONDS
);

// 게이트웨이에서 확인 (리액티브)
redisTemplate.hasKey("session:blacklist:" + jti)  // Mono<Boolean>

여기서 시험 함정이 하나 있어요. TTL을 토큰 만료 시간과 정확히 일치시킨다는 게 핵심이에요. 토큰이 어차피 15분 후 자연 만료되면 그 이후엔 블랙리스트에 있을 필요가 없거든요(서명 검증에서 어차피 떨어지니까). Redis가 정확히 그 시점에 키를 자동 삭제해 주니, 저장소가 무한히 커지지 않아요.

사용자 로그아웃
  → Redis: SET session:blacklist:abc-uuid "logout" EX 850
  → 14분 10초 후 Redis가 자동 DELETE

다음 요청에 그 토큰을 쓰면 (블랙리스트 살아 있는 동안)
  → 게이트웨이: GET session:blacklist:abc-uuid → "logout" 발견 → 401

이 패턴 덕분에 1억 명이 로그아웃해도 15분 뒤엔 다 정리됩니다. TTL을 무한으로 잡으면 블랙리스트가 무한히 커지는데, 만료 시간만큼만 잡으니 자연 정리되는 거죠. JWT의 stateless 장점을 일부 포기하는 대신 로그아웃을 안전하게 처리하는 트레이드오프예요.

게이트웨이는 WebFlux라 ReactiveStringRedisTemplate을 쓰고, 일반 부서(Spring MVC)는 StringRedisTemplate을 쓴다는 차이도 함께 기억해 두면 좋아요.

분산 락 — Redisson RLock + Watchdog

분산 락은 다른 캐시·랭킹과 달리 RedissonClient가 따로 필요해요. Spring Data Redis의 기본 클라이언트로는 분산 락 같은 고수준 추상이 없거든요.

RLock lock = redissonClient.getLock("lock:post:create:" + userId);
boolean acquired = lock.tryLock(
    0,               // waitTime=0: 즉시 실패 (대기 없음)
    -1,              // leaseTime=-1: Watchdog 활성화
    TimeUnit.SECONDS
);
if (!acquired) throw new DuplicatePostRequestException(userId); // HTTP 409
try {
    return doCreatePost(userId, request);
} finally {
    if (lock.isHeldByCurrentThread()) lock.unlock();
}

여기서 정말 중요한 시험 함정 — Watchdog 메커니즘이 분산 락 안전성의 핵심이에요. leaseTime=-1이면 Redisson 백그라운드 스레드가 10초마다 락 TTL을 자동 갱신합니다. 작업이 오래 걸리면 락이 자동으로 연장되고, unlock() 호출 시 또는 서버가 비정상 종료되면 Watchdog 스레드도 같이 끝나서 락이 자연 해제돼요.

시나리오 A: 작업이 정상 완료 (3초)
  락 획득 → 작업 → unlock() → Watchdog 종료 → 락 즉시 해제

시나리오 B: 작업이 오래 걸림 (1분)
  락 획득 → 작업 → 10초마다 Watchdog가 TTL 갱신 → 작업 완료 → unlock()
  → 다른 스레드가 끼어들 틈 없음

시나리오 C: 서버 비정상 종료
  락 획득 → 작업 중 서버 다운 → Watchdog 스레드도 종료
  → 락 TTL이 더 이상 연장 안 됨 → 30초 후 락 자연 해제

다른 노드의 서버가 죽어도 락이 영원히 잡혀 있는 일이 없으니, 운영에서 정말 안전한 패턴이에요. 4편에서 봤듯이 락은 반드시 @Transactional 메서드의 바깥에서 잡아야 commit 이후에 안전하게 해제됩니다.

Spring Batch + Redis Pipeline — 랭킹 주기 재계산

랭킹 점수는 게시글 조회·좋아요 토글마다 afterCommit에서 갱신되지만, Sorted Set이 어떤 이유로든 어긋나는 경우가 운영에서 가끔 일어납니다(Redis 재시작·Watchdog 실패·중간에 발생한 예외 등). 그래서 주기적으로 전체 게시글의 랭킹을 DB 기준으로 재계산하는 Spring Batch Job이 따로 돕니다.

@Bean
public Step rankingUpdateStep(RepositoryItemReader<Post> reader) {
    return new StepBuilder("rankingUpdateStep", jobRepository)
            .<Post, Post>chunk(100, transactionManager)  // 100건씩 처리
            .reader(reader)           // JPA Repository로 페이지 단위 읽기
            .processor(item -> item)  // 변환 없이 통과
            .writer(items -> {
                // Redis Pipeline으로 한 번에 전송 (N번 왕복 → 1번)
                redisTemplate.executePipelined((RedisCallback<Object>) connection -> {
                    for (Post post : items) {
                        double score = post.getLikeCount() * 2.0 + post.getViewCount();
                        connection.zSetCommands().zAdd(
                            "ranking:posts".getBytes(),
                            score,
                            String.valueOf(post.getId()).getBytes()
                        );
                    }
                    return null;
                });
            })
            .build();
}

여기서 시험 함정이 두 개 박혀 있어요.

첫째, Chunk 처리 — 전체 데이터를 한 번에 메모리에 올리면 OOM. 100건씩 읽고-처리-쓰고를 반복합니다. 100만 건 게시글이 있어도 메모리 사용량은 100건 분량으로 일정하게 유지돼요.

둘째, Redis Pipeline — 100건을 개별 ZADD로 전송하면 100번 네트워크 왕복(Round Trip). 각 왕복이 1ms씩만 걸려도 100ms 부담이에요. executePipelined()로 묶어 한 번에 전송하면 1번 왕복으로 처리 — 100배 가까운 처리 속도가 나옵니다. 대량 작업에서 거의 필수적인 패턴.

spring:
  batch:
    job:
      enabled: false          # 앱 시작 시 배치 Job 자동 실행 방지
    jdbc:
      initialize-schema: never  # Spring Batch 메타 테이블 자동 생성 비활성화

initialize-schema: never로 두는 이유는 Spring Batch 메타 테이블(BATCH_JOB_INSTANCE 등)을 Flyway 마이그레이션으로 직접 관리하기 위함이에요. always로 하면 DB가 잠깐 불안정할 때 앱 시작 자체가 실패할 수 있어 운영에 위험합니다. user-service에도 비슷한 Batch Job(ExpiredTokenCleanupJob)이 있어 만료된 RefreshToken을 주기적으로 청소합니다.

🎯 한 줄 정리

Spring Batch = 100건 청크 단위 + Redis Pipeline로 N→1 왕복 압축. 메타 테이블은 Flyway로 직접 관리(initialize-schema=never).

시리즈 다음 편 — Elasticsearch + S3

여기까지가 6편입니다. Redis 한 인프라가 캐시·Sorted Set 랭킹·JWT 블랙리스트·분산 락 네 가지 역할을 어떻게 동시에 맡는지, Spring Batch + Pipeline로 랭킹을 주기 재계산하는 패턴까지 봤어요. 같은 인프라를 자료구조와 클라이언트만 다르게 써서 네 가지 다른 문제를 푼다는 그림이 핵심.

7편에서는 시리즈의 마지막 편 — Elasticsearch 한국어 형태소 검색 + S3 Presigned URL 파일 업로드를 다룹니다. nori 토크나이저가 어떻게 한국어를 쪼개는지, ES 인덱싱 실패가 게시글 저장에 영향을 주지 않게 분리하는 패턴, S3 Presigned URL로 브라우저가 LocalStack S3에 직접 업로드하는 흐름까지 한 글에 정리합니다.

공식 문서: Spring Data Redis 공식 가이드Redisson 분산 락 가이드Spring Batch 공식 가이드에 이 글의 코드가 어디서 왔는지가 자세히 정리돼 있어요.

시리즈 다른 편

시험 직전 한 번 더 — Redis 함정 압축 노트

  • Redis 4가지 역할 = 캐시 · Sorted Set 랭킹 · JWT 블랙리스트 · Redisson 분산 락
  • 두 가지 클라이언트 = StringRedisTemplate(기본) + RedissonClient(분산 락)
  • 게이트웨이는 WebFlux → ReactiveStringRedisTemplate (블로킹 X)
  • 키 네이밍 = <용도>:<엔티티>:<식별자> (예: cache:posts:7, lock:post:create:42)
  • 캐시 = Read-Through + Cache-Aside, 상세 5분 TTL
  • 목록 캐시 = TTL 안 씀 → 새 글 생성 시 redisTemplate.delete() 즉시 무효화
  • 캐시 SET은 afterCommit() 안에서 — 트랜잭션 일관성
  • 캐시 DELETE는 트랜잭션 안에서도 OK — 다음 요청에서 DB가 정답 줌
  • Sorted Set 명령어 시간복잡도 = O(log N) ~ O(log N + M) — 수백만 건 밀리초
  • ZADD O(log N) / ZREVRANGE O(log N + M) / ZSCORE O(1)
  • 랭킹 점수 = likeCount × 2 + viewCount
  • 인기 검색어·인기 상품·실시간 순위에도 Sorted Set이 자연스러운 선택
  • JWT 블랙리스트 키 = session:blacklist:{jti}
  • TTL = 토큰 남은 만료 시간 → Redis 자동 삭제로 저장소 무한 증가 방지
  • Redis 죽으면 블랙리스트 조회 실패 → 운영은 Sentinel·Cluster
  • Redisson RLock = 분산 락 전용 클라이언트
  • tryLock(0, -1, SECONDS) = 즉시 실패 + Watchdog 활성화
  • Watchdog = 10초마다 TTL 자동 갱신, unlock() 또는 서버 종료 시 같이 종료
  • 락은 @Transactional 메서드의 바깥에서 — 안에 두면 commit 전 풀림
  • finally에서 isHeldByCurrentThread() 확인 후 unlock()
  • 분산 락 키 = lock:<리소스>:<식별자> (예: lock:post:create:42)
  • Spring Batch Chunk 처리 = 100건씩 읽고-처리-쓰기, OOM 방지
  • Redis Pipeline = executePipelined() 로 N번 왕복 → 1번 왕복으로 압축
  • 100건 ZADD 개별 = 100번 왕복, Pipeline = 1번 왕복 → 100배 빠름
  • spring.batch.job.enabled: false = 앱 시작 시 자동 실행 방지
  • spring.batch.jdbc.initialize-schema: never = 메타 테이블 Flyway로 직접 관리
  • always 설정 = DB 불안정 시 앱 시작 실패 가능 → 운영에서 위험
  • ExpiredTokenCleanupJob (user-service) = 만료된 RefreshToken 주기 청소

다음 글(7편 마지막 편)에서는 Elasticsearch nori 한국어 형태소 검색 + S3 Presigned URL 직접 업로드를 코드 한 줄씩 따라가며 풀어 갑니다.

※ 이 포스팅은 쿠팡 파트너스 활동의 일환으로, 이에 따른 일정액의 수수료를 제공받습니다.

답글 남기기

error: Content is protected !!