개발

Redis를 활용한 캐싱과 상태 관리 전략

2026년 01월 23일
68

Redis를 도입한 이유

블로그 서비스를 운영하면서 Redis가 필요한 순간들이 생겼다.

1. JWT 토큰 관리

  • Refresh Token 저장소 필요 (14일 유효)
  • 로그아웃된 Access Token 블랙리스트 관리

2. 브루트포스 방어

  • 로그인 실패 횟수 추적
  • 일정 횟수 초과 시 일시 차단

3. 조회수 중복 방지

  • 같은 사용자가 새로고침해도 조회수 1회만 증가
  • IP 기반 중복 체크

이런 요구사항의 공통점은 임시 데이터를 빠르게 읽고 써야 하고, 일정 시간 후 자동 삭제되어야 한다는 점이다. RDB로 구현하면 매번 DB 조회가 필요하고, 만료 데이터를 정리하는 배치 작업도 별도로 만들어야 한다.

Redis는 인메모리 저장소라 조회가 빠르고, TTL로 자동 만료를 지원한다.


Redis 데이터 구조 설계

Redis에 저장하는 데이터를 용도별로 정리했다.

alt text

키 네이밍 컨벤션

키 이름은 도메인:식별자 또는 도메인:속성:식별자 형태로 통일했다.

  • refresh:user@example.com - 해당 사용자의 Refresh Token
  • post:view:123:ip:192.168.1.1 - 포스트 123을 192.168.1.1이 조회한 기록

콜론(:)으로 계층을 구분하면 Redis CLI에서 패턴 검색이 쉽고, 키 용도를 한눈에 파악할 수 있다.


인증 데이터: Refresh Token과 블랙리스트

Refresh Token 저장

키: refresh:{email}
값: JWT Refresh Token 문자열
TTL: 14일

사용자가 로그인하면 Refresh Token을 생성해서 Redis에 저장한다. 같은 키에 새 토큰을 저장하면 기존 토큰은 자동으로 삭제된다.

사용자당 하나의 토큰으로 설계한 이유는 보안과 단순성 때문이다. 다른 기기에서 로그인하면 이전 기기는 자동 로그아웃된다. 토큰 탈취 시에도 정상 사용자가 재로그인하면 탈취된 토큰이 무효화된다.

Access Token 블랙리스트

키: blacklist:{access_token}
값: "logout"
TTL: 토큰의 남은 만료 시간

로그아웃 시 Access Token을 블랙리스트에 등록한다. TTL을 토큰의 남은 만료 시간으로 설정하면 어차피 만료될 토큰을 영구 저장하지 않아도 된다.

예를 들어 1시간 유효한 토큰을 발급받고 30분 후 로그아웃하면, 블랙리스트 TTL은 30분이 된다. 30분이 지나면 토큰도 만료되고 블랙리스트에서도 삭제된다.


보안 데이터: 로그인 실패 카운터

브루트포스 공격을 방어하기 위해 로그인 실패 횟수를 추적한다.

사용자별 카운터: login:user:fail:{email}
IP별 카운터: login:ip:fail:{ip}
TTL: 5분
임계값: 5회

왜 두 가지 카운터인가

사용자별 카운터만 있으면 공격자가 여러 IP에서 같은 계정을 공격할 수 있다. 각 IP에서 4번씩만 시도하면 차단되지 않는다.

IP별 카운터만 있으면 공격자가 하나의 IP로 여러 계정을 무차별 공격할 수 있다. 계정마다 4번씩 시도하면서 취약한 비밀번호를 찾을 수 있다.

두 카운터를 모두 사용하면 어느 쪽이든 5회 초과 시 차단된다.

카운터 동작 방식

로그인 시도
    │
    ├── 성공: 해당 email과 IP의 실패 카운터 삭제
    │
    └── 실패:
        ├── login:user:fail:{email} 증가 (INCR)
        ├── login:ip:fail:{ip} 증가 (INCR)
        └── 각 키에 TTL 5분 설정 (첫 실패 시)

Redis의 INCR 명령은 원자적이라 동시 요청에서도 카운터가 정확히 증가한다. 키가 없으면 0에서 시작해서 1이 된다.

5분 TTL은 "5분 동안 5회 실패"라는 조건을 자연스럽게 구현한다. 5분이 지나면 카운터가 사라지고 다시 시도할 수 있다.


캐싱 데이터: 포스트 상세 조회

포스트 상세 페이지는 조회가 잦고 데이터 변경은 드물다. 캐싱의 전형적인 대상이다.

Look-Aside 패턴

조회 요청
    │
    ├── Redis 캐시 확인
    │   ├── 있음 (Cache Hit): 캐시 데이터 반환
    │   │
    │   └── 없음 (Cache Miss):
    │       ├── DB에서 조회
    │       ├── Redis에 저장 (TTL 5분)
    │       └── 데이터 반환
    │
수정 요청
    │
    ├── DB 업데이트
    └── Redis 캐시 삭제 (무효화)

Look-Aside 패턴을 선택한 이유는 구현이 단순하고 캐시 장애에 강하기 때문이다. Redis가 다운되어도 DB에서 직접 조회하면 서비스는 계속된다. 느려질 뿐 멈추지 않는다.

TTL 5분의 근거

캐시 TTL을 결정할 때 고려한 것들:

  • 너무 짧으면: Cache Miss가 자주 발생해서 캐싱 효과 감소
  • 너무 길면: 수정 후에도 오래된 데이터가 보임

포스트 수정은 자주 일어나지 않고, 수정 시 캐시를 즉시 무효화한다. 5분은 DB 부하를 줄이면서도 데이터 정합성 문제가 거의 없는 수준이다.

실제로 수정 없이 5분이 지나 캐시가 만료되어도 다음 조회에서 다시 캐싱되므로 사용자 경험에 영향이 없다.

캐시 무효화 시점

포스트가 수정되거나 삭제되면 해당 캐시를 삭제한다. 조회수가 증가해도 캐시를 무효화한다.

캐시 무효화 트리거:
- 포스트 내용 수정 (제목, 본문, 카테고리 등)
- 포스트 삭제

통계 데이터: 조회수 중복 방지

조회수를 신뢰성 있게 집계하려면 중복을 방지해야 한다. 같은 사용자가 새로고침하거나 뒤로가기 했을 때 조회수가 무한정 늘어나면 안 된다.

IP 기반 중복 체크

키: post:view:{postId}:ip:{ip}
값: "1"
TTL: 30분

포스트 조회 시 해당 키가 있는지 확인한다. 없으면 조회수를 증가시키고 키를 생성한다. 있으면 이미 최근에 본 것이므로 조회수를 증가시키지 않는다.

30분 TTL은 "30분 내 재방문은 중복으로 처리"라는 정책이다. 30분이 지나면 같은 IP라도 새로운 조회로 인정한다.

왜 로그인 사용자 ID가 아닌 IP인가

로그인한 사용자는 ID로, 비로그인 사용자는 IP로 구분하는 게 더 정확할 수 있다. 하지만 두 가지 이유로 IP만 사용했다.

  1. 구현 단순성: 로그인 여부에 따라 분기하지 않아도 됨
  2. 블로그 특성: 대부분의 방문자가 비로그인 상태로 글을 읽음

같은 공유 IP(회사, 카페 등)에서 여러 사람이 접속하면 첫 번째 사람만 조회수에 반영되는 문제가 있다. 정확한 통계가 중요한 서비스라면 fingerprinting 등 더 정교한 방법이 필요하지만, 개인 블로그 수준에서는 IP로 충분하다.


CacheService: 공통 캐시 로직 추상화

Redis 연산을 직접 사용하지 않고 CacheService를 통해 접근한다.

CacheService
├── get(key): String 조회
├── get(key, type): 객체로 역직렬화해서 반환
├── set(key, value, ttl): TTL과 함께 저장
├── delete(key): 단일 키 삭제
├── deleteByPattern(pattern): 패턴 매칭 삭제
├── hasKey(key): 존재 여부 확인
├── increment(key): 카운터 증가
└── expire(key, ttl): TTL 재설정

이렇게 추상화한 이유는 두 가지다.

1. 일관된 직렬화: 객체를 JSON으로 직렬화/역직렬화하는 로직을 한 곳에서 관리한다.

2. 에러 처리 통일: Redis 연결 실패 시 처리를 공통화한다. 현재는 예외를 그대로 던지지만, 필요하면 fallback 로직을 추가할 수 있다.


현재 구조의 장단점

장점

  • 빠른 응답: 캐시 히트 시 DB 조회 없이 밀리초 단위 응답
  • 자동 정리: TTL로 만료 데이터 자동 삭제, 별도 배치 불필요
  • 단순한 구현: Look-Aside 패턴으로 이해하기 쉬운 캐시 로직

단점

  • 단일 장애점: Redis 장애 시 인증/캐싱 모두 영향
  • 정합성 이슈: 캐시 무효화 누락 시 stale 데이터 가능성
  • 메모리 한계: 캐시할 데이터가 늘어나면 메모리 관리 필요

댓글을 불러오는 중...