블로그 서비스를 운영하면서 Redis가 필요한 순간들이 생겼다.
1. JWT 토큰 관리
2. 브루트포스 방어
3. 조회수 중복 방지
이런 요구사항의 공통점은 임시 데이터를 빠르게 읽고 써야 하고, 일정 시간 후 자동 삭제되어야 한다는 점이다. RDB로 구현하면 매번 DB 조회가 필요하고, 만료 데이터를 정리하는 배치 작업도 별도로 만들어야 한다.
Redis는 인메모리 저장소라 조회가 빠르고, TTL로 자동 만료를 지원한다.
Redis에 저장하는 데이터를 용도별로 정리했다.
키 이름은 도메인:식별자 또는 도메인:속성:식별자 형태로 통일했다.
refresh:user@example.com - 해당 사용자의 Refresh Tokenpost:view:123:ip:192.168.1.1 - 포스트 123을 192.168.1.1이 조회한 기록콜론(:)으로 계층을 구분하면 Redis CLI에서 패턴 검색이 쉽고, 키 용도를 한눈에 파악할 수 있다.
키: refresh:{email}
값: JWT Refresh Token 문자열
TTL: 14일
사용자가 로그인하면 Refresh Token을 생성해서 Redis에 저장한다. 같은 키에 새 토큰을 저장하면 기존 토큰은 자동으로 삭제된다.
사용자당 하나의 토큰으로 설계한 이유는 보안과 단순성 때문이다. 다른 기기에서 로그인하면 이전 기기는 자동 로그아웃된다. 토큰 탈취 시에도 정상 사용자가 재로그인하면 탈취된 토큰이 무효화된다.
키: 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분이 지나면 카운터가 사라지고 다시 시도할 수 있다.
포스트 상세 페이지는 조회가 잦고 데이터 변경은 드물다. 캐싱의 전형적인 대상이다.
조회 요청
│
├── Redis 캐시 확인
│ ├── 있음 (Cache Hit): 캐시 데이터 반환
│ │
│ └── 없음 (Cache Miss):
│ ├── DB에서 조회
│ ├── Redis에 저장 (TTL 5분)
│ └── 데이터 반환
│
수정 요청
│
├── DB 업데이트
└── Redis 캐시 삭제 (무효화)
Look-Aside 패턴을 선택한 이유는 구현이 단순하고 캐시 장애에 강하기 때문이다. Redis가 다운되어도 DB에서 직접 조회하면 서비스는 계속된다. 느려질 뿐 멈추지 않는다.
캐시 TTL을 결정할 때 고려한 것들:
포스트 수정은 자주 일어나지 않고, 수정 시 캐시를 즉시 무효화한다. 5분은 DB 부하를 줄이면서도 데이터 정합성 문제가 거의 없는 수준이다.
실제로 수정 없이 5분이 지나 캐시가 만료되어도 다음 조회에서 다시 캐싱되므로 사용자 경험에 영향이 없다.
포스트가 수정되거나 삭제되면 해당 캐시를 삭제한다. 조회수가 증가해도 캐시를 무효화한다.
캐시 무효화 트리거:
- 포스트 내용 수정 (제목, 본문, 카테고리 등)
- 포스트 삭제
조회수를 신뢰성 있게 집계하려면 중복을 방지해야 한다. 같은 사용자가 새로고침하거나 뒤로가기 했을 때 조회수가 무한정 늘어나면 안 된다.
키: post:view:{postId}:ip:{ip}
값: "1"
TTL: 30분
포스트 조회 시 해당 키가 있는지 확인한다. 없으면 조회수를 증가시키고 키를 생성한다. 있으면 이미 최근에 본 것이므로 조회수를 증가시키지 않는다.
30분 TTL은 "30분 내 재방문은 중복으로 처리"라는 정책이다. 30분이 지나면 같은 IP라도 새로운 조회로 인정한다.
로그인한 사용자는 ID로, 비로그인 사용자는 IP로 구분하는 게 더 정확할 수 있다. 하지만 두 가지 이유로 IP만 사용했다.
같은 공유 IP(회사, 카페 등)에서 여러 사람이 접속하면 첫 번째 사람만 조회수에 반영되는 문제가 있다. 정확한 통계가 중요한 서비스라면 fingerprinting 등 더 정교한 방법이 필요하지만, 개인 블로그 수준에서는 IP로 충분하다.
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 로직을 추가할 수 있다.