Redis 완벽 가이드: 데이터 구조부터 Spring Boot 캐싱 전략까지
Redis 완벽 가이드: 데이터 구조부터 Spring Boot 캐싱 전략까지
이전 글에서 데이터베이스 인덱스, 정규화, 트랜잭션 격리 수준을 다루면서 RDBMS의 핵심 개념을 정리했다. 그러나 실무에서는 RDBMS만으로 해결하기 어려운 문제가 있다. 반복적인 조회 쿼리로 인한 DB 부하, 세션 관리, 실시간 랭킹, 분산 환경에서의 동시성 제어 — 이런 문제를 해결하기 위해 등장한 것이 Redis이다.
Redis는 단순한 캐시 서버가 아니다. 다양한 자료구조를 지원하는 인메모리 데이터 스토어이며, 올바르게 활용하면 시스템의 성능과 확장성을 극적으로 향상시킬 수 있다.
1. Redis란 무엇인가
1.1 정의와 특징
Redis(Remote Dictionary Server)는 키-값(Key-Value) 구조의 인메모리 데이터 스토어이다. Salvatore Sanfilippo가 2009년에 만들었으며, 현재 가장 널리 사용되는 인메모리 데이터베이스이다.
1
2
3
4
5
6
7
8
9
10
11
12
13
┌────────────────────────────────────────────────────────┐
│ Redis │
│ │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ In-Memory │ │ Single Thread│ │ 다양한 자료구조 │ │
│ │ (RAM 기반) │ │ (이벤트 루프) │ │ (5+ types) │ │
│ └──────────────┘ └──────────────┘ └──────────────┘ │
│ │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ 영속성 지원 │ │ 복제/클러스터 │ │ Pub/Sub │ │
│ │ (RDB, AOF) │ │ (HA 구성) │ │ (메시징) │ │
│ └──────────────┘ └──────────────┘ └──────────────┘ │
└────────────────────────────────────────────────────────┘
핵심 특징을 정리하면 다음과 같다.
| 특징 | 설명 |
|---|---|
| In-Memory | 모든 데이터를 RAM에 저장하여 O(1) 수준의 읽기/쓰기 성능 |
| Single-Threaded | 명령 처리는 단일 스레드로 수행, 원자성 보장 |
| 다양한 자료구조 | String, List, Set, Sorted Set, Hash, Bitmap, HyperLogLog 등 |
| 영속성 | RDB 스냅샷, AOF 로그를 통한 데이터 영구 저장 |
| 복제와 클러스터 | Master-Replica 복제, Redis Cluster를 통한 수평 확장 |
| TTL 지원 | 키 단위로 만료 시간을 설정하여 자동 삭제 |
1.2 왜 빠른가 — 성능의 비밀
Redis가 초당 10만 건 이상의 연산을 처리할 수 있는 이유는 세 가지이다.
첫째, 메모리 기반 연산이다. 디스크 I/O 없이 RAM에서 직접 데이터를 읽고 쓴다. HDD의 랜덤 읽기 지연 시간은 약 10ms, SSD는 약 0.1ms인 반면, RAM은 약 100ns이다. 메모리는 디스크보다 약 1,000~100,000배 빠르다.
1
2
3
4
5
6
7
8
9
접근 속도 비교:
┌────────────┬──────────────┬───────────────────────┐
│ 저장 매체 │ 지연 시간 │ 상대 속도 │
├────────────┼──────────────┼───────────────────────┤
│ CPU 캐시 │ ~1ns │ 1x (기준) │
│ RAM │ ~100ns │ 100x │
│ SSD │ ~100μs │ 100,000x │
│ HDD │ ~10ms │ 10,000,000x │
└────────────┴──────────────┴───────────────────────┘
둘째, 싱글 스레드 이벤트 루프이다. Redis는 명령 처리를 단일 스레드로 수행한다. 직관에 반할 수 있지만, 이것이 오히려 빠른 이유가 있다. 멀티스레드 환경에서 필요한 락(Lock) 오버헤드, 컨텍스트 스위칭 비용이 없다. 인메모리 연산은 워낙 빠르기 때문에 단일 스레드로도 충분한 처리량을 달성한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
Redis 이벤트 루프:
클라이언트 요청
│
▼
┌─────────────────┐
│ I/O 멀티플렉싱 │ ← epoll/kqueue로 수천 개의 연결을 비차단 처리
│ (이벤트 감지) │
└────────┬────────┘
│
▼
┌─────────────────┐
│ 명령 큐에 추가 │
└────────┬────────┘
│
▼
┌─────────────────┐
│ 단일 스레드로 │ ← 락 없이 순차 처리 → 원자성 보장
│ 순차 실행 │
└────────┬────────┘
│
▼
응답 반환
Redis 6.0부터 I/O 멀티스레딩이 도입되었지만, 이는 네트워크 읽기/쓰기(소켓 I/O)만 멀티스레드로 처리하는 것이다. 실제 명령 실행은 여전히 단일 스레드이다. 따라서 명령의 원자성은 그대로 보장된다.
셋째, 최적화된 자료구조이다. Redis는 각 데이터 타입에 대해 크기에 따라 내부 인코딩을 자동으로 전환한다. 작은 데이터에는 메모리 효율적인 ziplist를, 큰 데이터에는 성능 최적화된 hashtable/skiplist를 사용한다.
1.3 Redis vs Memcached
둘 다 인메모리 캐시지만 차이가 크다.
| 비교 항목 | Redis | Memcached |
|---|---|---|
| 자료구조 | String, List, Set, Sorted Set, Hash 등 | String만 |
| 영속성 | RDB, AOF 지원 | 없음 (순수 캐시) |
| 복제 | Master-Replica 지원 | 없음 |
| 클러스터 | Redis Cluster | 클라이언트 측 샤딩 |
| 스레드 모델 | 단일 스레드 (명령 실행) | 멀티스레드 |
| Pub/Sub | 지원 | 미지원 |
| Lua 스크립팅 | 지원 | 미지원 |
| 트랜잭션 | MULTI/EXEC | 미지원 |
면접에서 “왜 Memcached 대신 Redis를 선택했는가?”라는 질문에는 다양한 자료구조 지원, 영속성, 복제/클러스터 지원이 핵심 답변이다.
2. Redis 데이터 구조
Redis가 단순한 캐시를 넘어선 데이터 스토어인 이유는 다양한 자료구조를 네이티브로 지원하기 때문이다.
2.1 String
가장 기본적인 타입이다. 문자열, 숫자, 직렬화된 객체, 바이너리 데이터 등 무엇이든 저장할 수 있다. 최대 512MB.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
# 기본 SET/GET
SET user:1:name "홍길동"
GET user:1:name # "홍길동"
# 숫자 증감 (원자적 연산)
SET counter 0
INCR counter # 1
INCRBY counter 10 # 11
DECR counter # 10
# 만료 시간 설정
SET session:abc123 "user_data" EX 3600 # 1시간 후 만료
TTL session:abc123 # 남은 시간 확인
# NX: 키가 없을 때만 설정 (분산 락의 기본)
SET lock:order:123 "worker-1" NX EX 30 # 키 없으면 설정, 30초 후 만료
# MSET/MGET: 여러 키를 한 번에 처리
MSET user:1:name "홍길동" user:1:age "28" user:1:city "서울"
MGET user:1:name user:1:age user:1:city
활용 사례: 세션 저장, 캐시, 카운터, 분산 락, Rate Limiter
2.2 List
순서가 있는 문자열 목록이다. 양쪽 끝에서 삽입/삭제가 O(1)이다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
# LPUSH/RPUSH: 왼쪽/오른쪽 삽입
RPUSH notifications:user:1 "주문 접수" "결제 완료" "배송 시작"
# LRANGE: 범위 조회
LRANGE notifications:user:1 0 -1 # 전체 조회
# LPOP/RPOP: 왼쪽/오른쪽 꺼내기
LPOP notifications:user:1 # "주문 접수"
# LLEN: 길이
LLEN notifications:user:1 # 2
# BRPOP: 블로킹 Pop (메시지 큐처럼 사용)
BRPOP task:queue 30 # 30초 동안 대기, 데이터 오면 꺼냄
활용 사례: 최근 N개 목록(최근 본 상품, 최신 알림), 메시지 큐, 타임라인
2.3 Set
중복 없는 문자열 집합이다. 집합 연산(교집합, 합집합, 차집합)을 지원한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# SADD: 요소 추가
SADD user:1:tags "java" "spring" "redis"
SADD user:2:tags "java" "python" "docker"
# SMEMBERS: 전체 조회
SMEMBERS user:1:tags # {"java", "spring", "redis"}
# SISMEMBER: 포함 여부 확인 O(1)
SISMEMBER user:1:tags "java" # 1 (true)
# 집합 연산
SINTER user:1:tags user:2:tags # {"java"} — 교집합
SUNION user:1:tags user:2:tags # {"java","spring","redis","python","docker"} — 합집합
SDIFF user:1:tags user:2:tags # {"spring","redis"} — 차집합
# SRANDMEMBER: 랜덤 추출
SRANDMEMBER user:1:tags 2 # 랜덤 2개
활용 사례: 태그 시스템, 좋아요/팔로우 관계, 중복 체크, 랜덤 추출(이벤트 당첨자)
2.4 Sorted Set (ZSet)
Set과 비슷하지만 각 요소에 score(점수)가 부여되어 자동으로 정렬된다. 내부적으로 Skip List + Hash Table로 구현되어 삽입/삭제/조회 모두 O(log N)이다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
# ZADD: 요소 추가 (score + member)
ZADD leaderboard 150 "player:1"
ZADD leaderboard 230 "player:2"
ZADD leaderboard 180 "player:3"
ZADD leaderboard 300 "player:4"
# ZRANGE: score 오름차순 조회
ZRANGE leaderboard 0 -1 WITHSCORES
# player:1 150, player:3 180, player:2 230, player:4 300
# ZREVRANGE: score 내림차순 (랭킹)
ZREVRANGE leaderboard 0 2 WITHSCORES # TOP 3
# ZRANK/ZREVRANK: 순위 조회 O(log N)
ZREVRANK leaderboard "player:2" # 1 (0-indexed, 2등)
# ZINCRBY: 점수 증감
ZINCRBY leaderboard 50 "player:1" # 150 → 200
# ZRANGEBYSCORE: 점수 범위 조회
ZRANGEBYSCORE leaderboard 100 200 # 100~200점 사이의 멤버
활용 사례: 실시간 랭킹/리더보드, 지연 작업 큐(score를 실행 시간으로), Rate Limiter(Sliding Window), 인기 검색어
2.5 Hash
필드-값 쌍의 맵이다. 하나의 키 안에 여러 필드를 저장할 수 있어 객체를 표현하기에 적합하다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
# HSET: 필드 설정
HSET user:1 name "홍길동" age 28 email "hong@example.com" role "ADMIN"
# HGET: 특정 필드 조회
HGET user:1 name # "홍길동"
# HGETALL: 전체 필드 조회
HGETALL user:1
# name "홍길동" age "28" email "hong@example.com" role "ADMIN"
# HMGET: 여러 필드 한 번에 조회
HMGET user:1 name email # "홍길동" "hong@example.com"
# HINCRBY: 숫자 필드 증감
HINCRBY user:1 age 1 # 29
# HDEL: 필드 삭제
HDEL user:1 role
# HEXISTS: 필드 존재 여부
HEXISTS user:1 name # 1 (true)
활용 사례: 사용자 프로필, 상품 정보, 세션 데이터, 설정값
2.6 Bitmap과 HyperLogLog
Bitmap: 비트 단위로 데이터를 저장한다. 대량의 Boolean 값을 극도로 메모리 효율적으로 관리한다.
1
2
3
4
5
6
7
8
9
10
11
12
# 출석 체크 (1월 1일 = 0번째 비트)
SETBIT attendance:user:1:2026-03 0 1 # 3월 1일 출석
SETBIT attendance:user:1:2026-03 1 1 # 3월 2일 출석
SETBIT attendance:user:1:2026-03 11 1 # 3월 12일 출석
# 특정 일자 출석 여부
GETBIT attendance:user:1:2026-03 11 # 1 (출석함)
# 이번 달 총 출석 일수
BITCOUNT attendance:user:1:2026-03 # 3
# 1억 명의 출석 데이터 → 약 12.5MB (1억 bit)
HyperLogLog: 유일한 원소의 개수(cardinality)를 확률적으로 추정한다. 최대 12KB의 메모리로 2^64개의 유일 원소 수를 0.81% 오차로 추정할 수 있다.
1
2
3
4
5
6
7
8
9
# 일별 방문자 수 추적
PFADD visitors:2026-03-12 "user:1" "user:2" "user:3"
PFADD visitors:2026-03-12 "user:1" "user:4" # user:1 중복 → 무시
PFCOUNT visitors:2026-03-12 # 4 (유일 방문자 수)
# 여러 날의 합집합
PFMERGE visitors:week visitors:2026-03-10 visitors:2026-03-11 visitors:2026-03-12
PFCOUNT visitors:week
활용 사례: Bitmap — 출석 체크, 기능 플래그, 일일 활성 사용자(DAU). HyperLogLog — UV(Unique Visitor) 카운트, 유니크 검색어 수 추적.
2.7 데이터 구조 선택 가이드
1
2
3
4
5
6
7
8
9
요구사항 → 데이터 구조
──────────────────────────────────────────────
단일 값 저장/캐싱 → String
객체의 여러 필드 저장 → Hash
순서가 있는 목록 → List
중복 없는 집합, 집합 연산 → Set
점수 기반 정렬/랭킹 → Sorted Set
대량 Boolean (출석, 플래그) → Bitmap
유일 원소 수 추정 → HyperLogLog
3. Redis 영속성 (Persistence)
Redis는 인메모리 데이터베이스이지만, 서버 재시작 시 데이터 유실을 방지하기 위한 영속성 메커니즘을 제공한다.
3.1 RDB (Redis Database) — 스냅샷
특정 시점의 메모리 전체를 디스크에 바이너리 파일(.rdb)로 저장한다.
1
2
3
4
5
6
7
8
9
10
11
RDB 동작 과정:
Redis 메인 프로세스 자식 프로세스
│ │
│ fork() ───────────────────────► │
│ │
│ 계속 요청 처리 ◄──── COW ────► │ 메모리 스냅샷 → dump.rdb
│ (Copy-on-Write) │
│ │ 완료 → 기존 rdb 교체
│ ◄─────── 완료 통지 ────────── │
▼ ▼
1
2
3
4
5
6
7
# redis.conf 설정
save 900 1 # 900초(15분) 동안 1개 이상 키 변경 시 스냅샷
save 300 10 # 300초(5분) 동안 10개 이상 키 변경 시 스냅샷
save 60 10000 # 60초(1분) 동안 10,000개 이상 키 변경 시 스냅샷
dbfilename dump.rdb
dir /var/lib/redis/
장점: 컴팩트한 바이너리 파일, 백업/복원이 빠르다, 복구 시 AOF보다 빠르다. 단점: 스냅샷 간 데이터 유실 가능(마지막 스냅샷 이후 변경 손실), fork() 시 메모리 사용량 일시 증가.
3.2 AOF (Append Only File) — 명령 로그
모든 쓰기 명령을 로그 파일에 순차적으로 기록한다. 재시작 시 로그를 재실행하여 데이터를 복원한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
AOF 파일 내용 예시:
*3
$3
SET
$5
user:1
$6
홍길동
*3
$3
SET
$5
user:2
$6
김영희
1
2
3
4
5
6
7
8
# redis.conf 설정
appendonly yes
appendfilename "appendonly.aof"
# fsync 정책
appendfsync always # 매 명령마다 디스크 동기화 (가장 안전, 가장 느림)
appendfsync everysec # 1초마다 동기화 (권장, 최대 1초 데이터 유실)
appendfsync no # OS에 위임 (가장 빠름, 유실 위험)
AOF 재작성(Rewrite): AOF 파일이 커지면 같은 키에 대한 중간 명령을 제거하고 최종 상태만 남기는 최적화를 수행한다.
1
2
3
4
5
재작성 전: 재작성 후:
SET counter 0 SET counter 100
INCR counter (100번의 INCR이 최종값 하나로)
INCR counter
...INCR counter (×100)
장점: 데이터 유실 최소화(everysec 기준 최대 1초), 사람이 읽을 수 있는 로그. 단점: RDB보다 파일 크기가 큼, 복구 시간이 RDB보다 느림.
3.3 혼합 전략 (RDB + AOF)
Redis 4.0부터 혼합 영속성(aof-use-rdb-preamble)이 도입되었다. AOF 재작성 시 RDB 형식의 스냅샷을 앞에 넣고, 그 이후의 명령만 AOF 형식으로 기록한다. 복구 시간과 데이터 안전성을 모두 확보할 수 있다.
1
2
3
4
5
6
7
혼합 AOF 파일 구조:
┌──────────────────────┐
│ RDB 스냅샷 (바이너리) │ ← 빠른 복구
├──────────────────────┤
│ AOF 명령 로그 │ ← 스냅샷 이후 변경분
└──────────────────────┘
1
2
# redis.conf
aof-use-rdb-preamble yes # Redis 4.0+ 기본값 yes
3.4 영속성 전략 선택
| 시나리오 | 권장 설정 |
|---|---|
| 순수 캐시 (유실 허용) | RDB/AOF 모두 OFF |
| 일반적인 운영 | AOF (everysec) + RDB 백업 |
| 데이터 유실 불가 | AOF (always) — 성능 저하 감수 |
| 빠른 재시작 우선 | RDB + AOF 혼합 (aof-use-rdb-preamble) |
4. 캐싱 전략
Redis를 캐시로 활용할 때 가장 중요한 것은 캐싱 전략의 선택이다. 애플리케이션의 읽기/쓰기 비율, 데이터 정합성 요구 수준에 따라 적합한 전략이 달라진다.
4.1 Cache Aside (Look Aside) — 가장 일반적
애플리케이션이 직접 캐시를 관리한다. 읽기 시 캐시를 먼저 확인하고, 없으면 DB에서 조회하여 캐시에 저장한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
Cache Aside 읽기 흐름:
Client Application Cache DB
│ │ │ │
│ GET /user/1 │ │ │
│ ────────────► │ │ │
│ │ GET user:1 │ │
│ │ ────────────► │ │
│ │ │ │
│ │ Cache Miss │ │
│ │ ◄──────────── │ │
│ │ │ │
│ │ SELECT * FROM user WHERE id=1 │
│ │ ──────────────────────────────►│
│ │ │
│ │ User 데이터 │
│ │ ◄──────────────────────────────│
│ │ │ │
│ │ SET user:1 │ │
│ │ ────────────► │ │
│ │ │ │
│ User 응답 │ │ │
│ ◄──────────── │ │ │
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
@Service
@RequiredArgsConstructor
public class UserService {
private final UserRepository userRepository;
private final RedisTemplate<String, User> redisTemplate;
public User getUser(Long userId) {
String key = "user:" + userId;
// 1. 캐시 조회
User cached = redisTemplate.opsForValue().get(key);
if (cached != null) {
return cached; // Cache Hit
}
// 2. DB 조회 (Cache Miss)
User user = userRepository.findById(userId)
.orElseThrow(() -> new UserNotFoundException(userId));
// 3. 캐시에 저장
redisTemplate.opsForValue().set(key, user, Duration.ofMinutes(30));
return user;
}
public User updateUser(Long userId, UserUpdateRequest request) {
User user = userRepository.findById(userId)
.orElseThrow(() -> new UserNotFoundException(userId));
user.update(request);
userRepository.save(user);
// 캐시 무효화 (Invalidation)
redisTemplate.delete("user:" + userId);
return user;
}
}
장점: 구현이 단순하고 직관적, 캐시 장애 시에도 DB에서 직접 조회 가능(장애 격리). 단점: 첫 요청은 항상 Cache Miss(Cold Start), DB와 캐시 간 일시적 데이터 불일치 가능.
4.2 Read Through
캐시 라이브러리/프록시가 DB 조회까지 대신 수행한다. 애플리케이션은 캐시에만 요청하면 된다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
Read Through 흐름:
Application Cache DB
│ │ │
│ GET user:1 │ │
│ ──────────► │ │
│ │ Cache Miss 시 직접 DB 조회 │
│ │ ──────────────────────► │
│ │ │
│ │ DB 결과 → 캐시 저장 │
│ │ ◄────────────────────── │
│ │ │
│ User 데이터 │ │
│ ◄────────── │ │
Cache Aside와의 차이는 DB 조회 책임이 캐시 계층에 있다는 것이다. 애플리케이션 코드가 깔끔해지지만, 캐시 계층에 DB 접근 로직을 구현해야 한다.
4.3 Write Through
쓰기 시 캐시와 DB를 동기적으로 모두 업데이트한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
Write Through 흐름:
Application Cache DB
│ │ │
│ SET user:1 │ │
│ ──────────► │ │
│ │ DB 쓰기 │
│ │ ──────────► │
│ │ │
│ │ DB 완료 │
│ │ ◄────────── │
│ │ │
│ 완료 │ │
│ ◄────────── │ │
장점: 캐시와 DB가 항상 일치한다(Strong Consistency). 단점: 쓰기 지연이 증가한다(DB + 캐시 두 번 쓰기). 읽히지 않는 데이터도 캐시에 저장된다.
4.4 Write Behind (Write Back)
쓰기 시 캐시에만 먼저 쓰고, DB 쓰기는 비동기적으로 나중에 처리한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
Write Behind 흐름:
Application Cache Background Worker DB
│ │ │ │
│ SET user:1 │ │ │
│ ──────────► │ │ │
│ │ │ │
│ 즉시 완료 │ │ │
│ ◄────────── │ │ │
│ │ │ │
│ │ 비동기 배치 쓰기 │ │
│ │ ──────────────► │ │
│ │ │ DB 쓰기 │
│ │ │ ──────────────────► │
장점: 쓰기 성능이 가장 빠르다, DB 부하를 분산할 수 있다. 단점: 캐시 장애 시 아직 DB에 반영되지 않은 데이터가 유실될 수 있다. 구현 복잡도가 높다.
4.5 전략 비교
| 전략 | 읽기 성능 | 쓰기 성능 | 데이터 정합성 | 구현 복잡도 | 적합한 경우 |
|---|---|---|---|---|---|
| Cache Aside | 높음 (Hit 시) | 보통 | Eventual | 낮음 | 범용, 가장 일반적 |
| Read Through | 높음 | 보통 | Eventual | 중간 | 읽기 중심 |
| Write Through | 보통 | 낮음 | Strong | 중간 | 정합성 중요 |
| Write Behind | 보통 | 높음 | Weak | 높음 | 쓰기 빈번, 유실 허용 |
면접 팁: “캐싱 전략이 뭐가 있나요?”라는 질문에 Cache Aside만 답하면 표면적이다. 네 가지 전략을 비교하고, “실무에서는 Cache Aside가 가장 많이 사용되며, 쓰기 시 캐시 무효화(invalidation)를 기본으로 한다”고 덧붙이면 깊이가 보인다.
5. 캐시 문제와 해결
캐시를 운영하면 반드시 마주치는 세 가지 클래식 문제가 있다.
5.1 Cache Stampede (Thundering Herd)
인기 있는 캐시 키가 만료되는 순간, 대량의 요청이 동시에 DB로 몰리는 현상이다.
1
2
3
4
5
6
7
8
9
10
정상 상태:
요청 1 ──► Cache Hit ──► 응답
요청 2 ──► Cache Hit ──► 응답
요청 3 ──► Cache Hit ──► 응답
캐시 만료 후 (Stampede):
요청 1 ──► Cache Miss ──► DB 조회 ──┐
요청 2 ──► Cache Miss ──► DB 조회 ──┤ DB 과부하!
요청 3 ──► Cache Miss ──► DB 조회 ──┤
요청 4 ──► Cache Miss ──► DB 조회 ──┘
해결법 1: 분산 락 (Mutex)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
public User getUserWithLock(Long userId) {
String key = "user:" + userId;
String lockKey = "lock:" + key;
User cached = redisTemplate.opsForValue().get(key);
if (cached != null) {
return cached;
}
// 락 획득 시도
Boolean acquired = redisTemplate.opsForValue()
.setIfAbsent(lockKey, "1", Duration.ofSeconds(5));
if (Boolean.TRUE.equals(acquired)) {
try {
// 더블 체크 (락 대기 중 다른 스레드가 캐시를 채웠을 수 있음)
cached = redisTemplate.opsForValue().get(key);
if (cached != null) return cached;
User user = userRepository.findById(userId).orElseThrow();
redisTemplate.opsForValue().set(key, user, Duration.ofMinutes(30));
return user;
} finally {
redisTemplate.delete(lockKey);
}
} else {
// 락 획득 실패 → 잠시 대기 후 재시도
Thread.sleep(50);
return getUserWithLock(userId);
}
}
해결법 2: 만료 시간 분산 (Jitter)
1
2
3
4
// TTL에 랜덤 값을 추가하여 동시 만료 방지
int baseTtl = 1800; // 30분
int jitter = ThreadLocalRandom.current().nextInt(0, 300); // 0~5분 랜덤
redisTemplate.opsForValue().set(key, user, Duration.ofSeconds(baseTtl + jitter));
5.2 Cache Penetration
DB에도 존재하지 않는 데이터를 반복 요청하여, 매번 Cache Miss → DB 조회가 발생하는 현상이다. 악의적인 공격으로 이용될 수 있다.
1
2
3
4
Cache Penetration:
GET /user/99999999 ──► Cache Miss ──► DB (데이터 없음) ──► null 반환
GET /user/99999999 ──► Cache Miss ──► DB (데이터 없음) ──► null 반환
(반복 → DB에 불필요한 부하)
해결법 1: Null 캐싱
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public User getUser(Long userId) {
String key = "user:" + userId;
ValueOperations<String, Object> ops = redisTemplate.opsForValue();
Object cached = ops.get(key);
if (cached != null) {
if (cached instanceof NullValue) return null; // 캐싱된 null
return (User) cached;
}
User user = userRepository.findById(userId).orElse(null);
if (user != null) {
ops.set(key, user, Duration.ofMinutes(30));
} else {
// null도 캐싱 (짧은 TTL)
ops.set(key, NullValue.INSTANCE, Duration.ofMinutes(5));
}
return user;
}
해결법 2: Bloom Filter
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// Bloom Filter로 존재하지 않는 키를 사전 차단
@PostConstruct
public void initBloomFilter() {
// 모든 유효한 사용자 ID를 Bloom Filter에 등록
List<Long> allUserIds = userRepository.findAllIds();
allUserIds.forEach(id -> bloomFilter.add("user:" + id));
}
public User getUser(Long userId) {
String key = "user:" + userId;
// Bloom Filter에 없으면 확실히 존재하지 않음
if (!bloomFilter.mightContain(key)) {
return null; // DB 조회 자체를 하지 않음
}
// 이후 일반적인 Cache Aside 로직
// ...
}
5.3 Cache Avalanche
대량의 캐시 키가 동시에 만료되거나, Redis 서버 자체가 다운되어 모든 요청이 DB로 몰리는 현상이다.
1
2
3
4
5
6
7
8
9
10
11
Cache Avalanche:
┌─────────┐
│ 대량 키 │ ──── 동시 만료 ────► 모든 요청이 DB로!
│ 동시 만료 │ DB 과부하 → 장애
└─────────┘
또는:
┌─────────┐
│ Redis │ ──── 서버 다운 ────► 모든 요청이 DB로!
│ 장애 │ DB 과부하 → 장애
└─────────┘
해결법:
- TTL 분산: 만료 시간에 랜덤 jitter를 추가
- Redis 고가용성: Sentinel 또는 Cluster 구성으로 단일 장애점 제거
- 회로 차단기(Circuit Breaker): Redis 장애 시 DB 직접 조회에 제한을 두고, 서킷 브레이커로 DB 과부하 방지
- 로컬 캐시(Caffeine 등): Redis 앞에 애플리케이션 로컬 캐시를 두어 2차 방어선 구성
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
// 다단계 캐시: Local Cache (Caffeine) → Redis → DB
@Service
public class MultiLevelCacheService {
private final Cache<String, User> localCache = Caffeine.newBuilder()
.maximumSize(10_000)
.expireAfterWrite(Duration.ofMinutes(5))
.build();
private final RedisTemplate<String, User> redisTemplate;
private final UserRepository userRepository;
public User getUser(Long userId) {
String key = "user:" + userId;
// L1: 로컬 캐시
User user = localCache.getIfPresent(key);
if (user != null) return user;
// L2: Redis
user = redisTemplate.opsForValue().get(key);
if (user != null) {
localCache.put(key, user);
return user;
}
// L3: DB
user = userRepository.findById(userId).orElseThrow();
redisTemplate.opsForValue().set(key, user, Duration.ofMinutes(30));
localCache.put(key, user);
return user;
}
}
6. TTL과 Eviction Policy
6.1 TTL (Time To Live)
키에 만료 시간을 설정하면 해당 시간이 지난 후 Redis가 자동으로 삭제한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
# 키 생성 시 TTL 설정
SET session:abc "data" EX 3600 # 3600초 후 만료
SET session:abc "data" PX 3600000 # 3600000밀리초 후 만료
# 기존 키에 TTL 설정
EXPIRE user:1 600 # 600초 후 만료
PEXPIRE user:1 600000 # 밀리초 단위
# TTL 확인
TTL user:1 # 남은 초
PTTL user:1 # 남은 밀리초
# TTL 제거 (영구 키로 변환)
PERSIST user:1
Redis의 만료 처리 방식:
- Lazy Expiration: 키에 접근할 때 만료 여부를 확인하고 삭제
- Active Expiration: 100ms마다 랜덤으로 20개의 TTL이 설정된 키를 검사하여 만료된 키를 삭제. 만료된 비율이 25%를 넘으면 반복
6.2 Eviction Policy (메모리 제거 정책)
Redis의 메모리가 maxmemory에 도달하면 Eviction Policy에 따라 기존 키를 제거한다.
1
2
3
# redis.conf
maxmemory 4gb
maxmemory-policy allkeys-lru
| 정책 | 설명 |
|---|---|
| noeviction | 메모리 초과 시 쓰기 명령에 에러 반환 (기본값) |
| allkeys-lru | 전체 키 중 LRU(Least Recently Used) 기준으로 제거 |
| allkeys-lfu | 전체 키 중 LFU(Least Frequently Used) 기준으로 제거 |
| allkeys-random | 전체 키 중 랜덤 제거 |
| volatile-lru | TTL이 설정된 키 중 LRU 기준 제거 |
| volatile-lfu | TTL이 설정된 키 중 LFU 기준 제거 |
| volatile-random | TTL이 설정된 키 중 랜덤 제거 |
| volatile-ttl | TTL이 가장 짧은 키부터 제거 |
선택 가이드:
- 순수 캐시 용도: allkeys-lru (가장 일반적)
- 캐시 + 영구 데이터 혼재: volatile-lru (TTL 없는 영구 키 보호)
- 접근 빈도 기반: allkeys-lfu (인기 데이터 유지에 유리)
Redis의 LRU는 근사(approximate) LRU이다. 전체 키를 스캔하는 것이 아니라 랜덤 샘플(
maxmemory-samples, 기본 5)에서 가장 오래된 키를 제거한다. 샘플 수를 늘리면 정확도가 올라가지만 CPU 비용도 증가한다.
7. Spring Boot에서 Redis 활용
7.1 의존성과 설정
1
2
3
4
// build.gradle
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
}
1
2
3
4
5
6
7
8
9
10
11
12
13
# application.yml
spring:
data:
redis:
host: localhost
port: 6379
password: ""
lettuce:
pool:
max-active: 8
max-idle: 8
min-idle: 2
max-wait: -1ms
7.2 RedisTemplate 설정
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
@Configuration
public class RedisConfig {
@Bean
public RedisConnectionFactory redisConnectionFactory() {
return new LettuceConnectionFactory();
}
@Bean
public RedisTemplate<String, Object> redisTemplate(
RedisConnectionFactory connectionFactory) {
RedisTemplate<String, Object> template = new RedisTemplate<>();
template.setConnectionFactory(connectionFactory);
// 키는 String 직렬화
template.setKeySerializer(new StringRedisSerializer());
template.setHashKeySerializer(new StringRedisSerializer());
// 값은 JSON 직렬화 (GenericJackson2 사용)
ObjectMapper objectMapper = new ObjectMapper();
objectMapper.registerModule(new JavaTimeModule());
objectMapper.activateDefaultTyping(
objectMapper.getPolymorphicTypeValidator(),
ObjectMapper.DefaultTyping.NON_FINAL
);
GenericJackson2JsonRedisSerializer jsonSerializer =
new GenericJackson2JsonRedisSerializer(objectMapper);
template.setValueSerializer(jsonSerializer);
template.setHashValueSerializer(jsonSerializer);
template.afterPropertiesSet();
return template;
}
}
7.3 RedisTemplate 사용
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
@Service
@RequiredArgsConstructor
public class ProductCacheService {
private final RedisTemplate<String, Object> redisTemplate;
// String 타입
public void cacheProduct(Product product) {
redisTemplate.opsForValue()
.set("product:" + product.getId(), product, Duration.ofHours(1));
}
public Product getProduct(Long id) {
return (Product) redisTemplate.opsForValue().get("product:" + id);
}
// Hash 타입
public void cacheProductAsHash(Product product) {
String key = "product:hash:" + product.getId();
redisTemplate.opsForHash().put(key, "name", product.getName());
redisTemplate.opsForHash().put(key, "price", product.getPrice().toString());
redisTemplate.expire(key, Duration.ofHours(1));
}
// Sorted Set (랭킹)
public void addToRanking(Long productId, double score) {
redisTemplate.opsForZSet()
.add("ranking:products", "product:" + productId, score);
}
public Set<Object> getTopProducts(int count) {
return redisTemplate.opsForZSet()
.reverseRange("ranking:products", 0, count - 1);
}
// Set (좋아요)
public void likeProduct(Long userId, Long productId) {
redisTemplate.opsForSet()
.add("likes:product:" + productId, userId.toString());
}
public Long getLikeCount(Long productId) {
return redisTemplate.opsForSet().size("likes:product:" + productId);
}
public boolean hasLiked(Long userId, Long productId) {
return Boolean.TRUE.equals(redisTemplate.opsForSet()
.isMember("likes:product:" + productId, userId.toString()));
}
}
7.4 @Cacheable / @CacheEvict
Spring Cache Abstraction을 활용하면 어노테이션만으로 캐싱을 적용할 수 있다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
@Configuration
@EnableCaching
public class CacheConfig {
@Bean
public CacheManager cacheManager(RedisConnectionFactory connectionFactory) {
RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig()
.entryTtl(Duration.ofMinutes(30))
.serializeKeysWith(
RedisSerializationContext.SerializationPair
.fromSerializer(new StringRedisSerializer()))
.serializeValuesWith(
RedisSerializationContext.SerializationPair
.fromSerializer(new GenericJackson2JsonRedisSerializer()))
.disableCachingNullValues();
return RedisCacheManager.builder(connectionFactory)
.cacheDefaults(config)
.withCacheConfiguration("users",
config.entryTtl(Duration.ofHours(1)))
.withCacheConfiguration("products",
config.entryTtl(Duration.ofMinutes(10)))
.build();
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
@Service
@RequiredArgsConstructor
public class UserService {
private final UserRepository userRepository;
@Cacheable(value = "users", key = "#userId")
public User getUser(Long userId) {
// Cache Miss 시에만 실행
return userRepository.findById(userId)
.orElseThrow(() -> new UserNotFoundException(userId));
}
@CachePut(value = "users", key = "#result.id")
public User updateUser(Long userId, UserUpdateRequest request) {
User user = userRepository.findById(userId).orElseThrow();
user.update(request);
return userRepository.save(user);
}
@CacheEvict(value = "users", key = "#userId")
public void deleteUser(Long userId) {
userRepository.deleteById(userId);
}
@CacheEvict(value = "users", allEntries = true)
public void clearUserCache() {
// 전체 캐시 삭제
}
}
7.5 직렬화 주의사항
1
2
3
4
5
6
7
8
9
10
직렬화 방식 비교:
┌─────────────────────┬──────────────┬──────────────┬──────────────┐
│ 직렬화 방식 │ 크기 │ 가독성 │ 호환성 │
├─────────────────────┼──────────────┼──────────────┼──────────────┤
│ JdkSerializable │ 큼 │ 바이너리 │ 클래스 변경 취약│
│ Jackson2Json │ 중간 │ JSON 읽기 가능│ 필드 변경 유연 │
│ GenericJackson2Json │ 중간 (타입 포함)│ JSON + 타입 │ 다형성 지원 │
│ StringRedisSerializer│ 작음 │ 문자열 │ String만 가능 │
└─────────────────────┴──────────────┴──────────────┴──────────────┘
실무 권장: 키는 StringRedisSerializer, 값은 GenericJackson2JsonRedisSerializer. JDK 직렬화는 클래스 경로 변경에 취약하고 바이너리라 디버깅이 어려우므로 피한다.
8. Redis Pub/Sub
Redis는 간단한 메시지 브로커 기능도 제공한다. 게시자(Publisher)가 채널에 메시지를 발행하면, 해당 채널을 구독(Subscribe)한 모든 클라이언트가 메시지를 수신한다.
1
2
3
4
5
6
7
8
9
10
Pub/Sub 구조:
Publisher Channel Subscribers
│ │ │
│ PUBLISH │ │
│ "chat:room1" │ │
│ "안녕하세요" │ │
│ ──────────────► │ ──────────────► │ Subscriber 1
│ │ ──────────────► │ Subscriber 2
│ │ ──────────────► │ Subscriber 3
1
2
3
4
5
# 터미널 1: 구독
SUBSCRIBE chat:room1
# 터미널 2: 발행
PUBLISH chat:room1 "안녕하세요"
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
// Spring에서 Pub/Sub 사용
@Configuration
public class RedisPubSubConfig {
@Bean
public MessageListenerAdapter messageListener() {
return new MessageListenerAdapter(new RedisMessageSubscriber());
}
@Bean
public RedisMessageListenerContainer container(
RedisConnectionFactory connectionFactory,
MessageListenerAdapter listenerAdapter) {
RedisMessageListenerContainer container = new RedisMessageListenerContainer();
container.setConnectionFactory(connectionFactory);
container.addMessageListener(listenerAdapter,
new ChannelTopic("chat:room1"));
return container;
}
}
public class RedisMessageSubscriber implements MessageListener {
@Override
public void onMessage(Message message, byte[] pattern) {
String channel = new String(message.getChannel());
String body = new String(message.getBody());
log.info("채널: {}, 메시지: {}", channel, body);
}
}
// 메시지 발행
@Service
@RequiredArgsConstructor
public class ChatService {
private final RedisTemplate<String, String> redisTemplate;
public void sendMessage(String room, String message) {
redisTemplate.convertAndSend("chat:" + room, message);
}
}
Pub/Sub의 한계: 메시지가 영속화되지 않는다. 구독자가 없으면 메시지는 소실된다. 구독자가 다운되었다가 복구되면 그 사이의 메시지를 받을 수 없다. 이런 경우에는 Redis Streams(Redis 5.0+)나 Kafka 같은 메시지 브로커를 사용해야 한다.
8.1 Redis Streams (간략 소개)
Redis 5.0에서 도입된 Streams는 Kafka와 유사한 영속적 메시지 로그이다. Consumer Group을 지원하여 메시지의 안정적인 처리를 보장한다.
1
2
3
4
5
6
7
8
9
10
11
# 메시지 추가
XADD orders * user_id 1 product_id 100 amount 2
# Consumer Group 생성
XGROUP CREATE orders order-processors $ MKSTREAM
# 메시지 소비
XREADGROUP GROUP order-processors worker-1 COUNT 1 BLOCK 5000 STREAMS orders >
# 메시지 처리 완료 확인
XACK orders order-processors 1678886400000-0
9. 분산 락 (Distributed Lock)
분산 환경에서 여러 서버가 동시에 같은 자원에 접근할 때, Redis를 이용한 분산 락으로 동시성을 제어할 수 있다.
9.1 SETNX 기반 단순 분산 락
1
2
3
4
5
6
7
8
9
10
# 락 획득 시도
SET lock:order:123 "worker-1" NX EX 30
# NX: 키가 없을 때만 설정
# EX 30: 30초 후 자동 해제 (데드락 방지)
# 작업 수행 ...
# 락 해제 (본인이 잡은 락만 해제)
# Lua 스크립트로 원자적 확인 + 삭제
EVAL "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end" 1 lock:order:123 "worker-1"
왜 Lua 스크립트로 해제하는가? GET과 DEL을 별도 명령으로 수행하면, GET 직후 다른 클라이언트가 락을 획득할 수 있다. Lua 스크립트는 Redis에서 원자적으로 실행되므로 안전하다.
9.2 Redisson을 활용한 분산 락
Redisson은 Java에서 Redis 분산 락을 안전하고 편리하게 사용할 수 있는 라이브러리다. Watch Dog 메커니즘으로 락 갱신을 자동 처리한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
@Service
@RequiredArgsConstructor
public class OrderService {
private final RedissonClient redissonClient;
private final OrderRepository orderRepository;
private final StockRepository stockRepository;
public void createOrder(Long productId, int quantity) {
RLock lock = redissonClient.getLock("lock:product:" + productId);
try {
// 락 획득 시도 (최대 10초 대기, 락 유지 30초)
boolean acquired = lock.tryLock(10, 30, TimeUnit.SECONDS);
if (!acquired) {
throw new RuntimeException("락 획득 실패");
}
// 재고 확인 및 차감 (동시성 보장)
Stock stock = stockRepository.findByProductId(productId);
if (stock.getQuantity() < quantity) {
throw new InsufficientStockException();
}
stock.decrease(quantity);
stockRepository.save(stock);
// 주문 생성
Order order = Order.create(productId, quantity);
orderRepository.save(order);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new RuntimeException("락 대기 중 인터럽트", e);
} finally {
// 락 해제 (본인이 잡은 락만 해제)
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
Redisson Watch Dog 메커니즘:
락 획득 (leaseTime 미지정 시 기본 30초)
│
├──── 작업 실행 중 ────┐
│ │
│ 10초마다 자동 갱신 │ ← Watch Dog이 백그라운드에서
│ (lockWatchdogTimeout │ 만료 시간을 30초로 재설정
│ / 3 = 10초 간격) │
│ │
├──── 작업 완료 │
│ │
└──── unlock() │
│
Watch Dog 중단 ◄───────────┘
9.3 AOP를 활용한 분산 락 추상화
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface DistributedLock {
String key(); // SpEL 표현식
long waitTime() default 5; // 락 대기 시간 (초)
long leaseTime() default 10; // 락 유지 시간 (초)
TimeUnit timeUnit() default TimeUnit.SECONDS;
}
@Aspect
@Component
@RequiredArgsConstructor
public class DistributedLockAspect {
private final RedissonClient redissonClient;
private final ExpressionParser parser = new SpelExpressionParser();
@Around("@annotation(distributedLock)")
public Object around(ProceedingJoinPoint joinPoint,
DistributedLock distributedLock) throws Throwable {
String key = parseKey(distributedLock.key(), joinPoint);
RLock lock = redissonClient.getLock("lock:" + key);
try {
boolean acquired = lock.tryLock(
distributedLock.waitTime(),
distributedLock.leaseTime(),
distributedLock.timeUnit()
);
if (!acquired) {
throw new RuntimeException("분산 락 획득 실패: " + key);
}
return joinPoint.proceed();
} finally {
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}
private String parseKey(String expression, ProceedingJoinPoint joinPoint) {
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
EvaluationContext context = new StandardEvaluationContext();
String[] paramNames = signature.getParameterNames();
Object[] args = joinPoint.getArgs();
for (int i = 0; i < paramNames.length; i++) {
context.setVariable(paramNames[i], args[i]);
}
return parser.parseExpression(expression).getValue(context, String.class);
}
}
// 사용 예시
@Service
public class StockService {
@DistributedLock(key = "'product:' + #productId")
public void decreaseStock(Long productId, int quantity) {
Stock stock = stockRepository.findByProductId(productId);
stock.decrease(quantity);
stockRepository.save(stock);
}
}
10. 세션 저장소로서의 Redis
10.1 왜 Redis에 세션을 저장하는가
1
2
3
4
5
6
7
8
9
문제 상황: 다중 서버 환경에서의 세션
Client ──► Server A (세션 생성)
Client ──► Server B (세션 없음! → 로그인 풀림)
해결: Redis를 세션 저장소로 사용
Client ──► Server A ──► Redis (세션 저장)
Client ──► Server B ──► Redis (세션 조회 → 정상)
10.2 Spring Session + Redis
1
2
3
4
// build.gradle
dependencies {
implementation 'org.springframework.session:spring-session-data-redis'
}
1
2
3
4
5
6
7
# application.yml
spring:
session:
store-type: redis
redis:
namespace: spring:session
timeout: 1800 # 30분
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
@Configuration
@EnableRedisHttpSession(maxInactiveIntervalInSeconds = 1800)
public class SessionConfig {
// 별도 설정 없이도 HttpSession이 자동으로 Redis에 저장
}
// 사용 예시 — 기존 HttpSession 코드 그대로
@RestController
public class LoginController {
@PostMapping("/login")
public ResponseEntity<?> login(HttpSession session,
@RequestBody LoginRequest request) {
User user = authService.authenticate(request);
session.setAttribute("user", user);
return ResponseEntity.ok("로그인 성공");
}
@GetMapping("/me")
public ResponseEntity<?> me(HttpSession session) {
User user = (User) session.getAttribute("user");
if (user == null) {
return ResponseEntity.status(401).body("인증 필요");
}
return ResponseEntity.ok(user);
}
}
11. Redis 아키텍처
11.1 Standalone
단일 Redis 인스턴스. 개발/테스트 환경에 적합하다. 서버 다운 시 서비스 중단이므로 운영 환경에서는 부적합하다.
11.2 Master-Replica (복제)
1
2
3
4
5
6
7
8
Master-Replica 구조:
쓰기 요청 ──► [Master]
│
│ 비동기 복제
├──────────► [Replica 1] ◄── 읽기 요청
│
└──────────► [Replica 2] ◄── 읽기 요청
- 쓰기는 Master에서만, 읽기는 Replica에서도 가능 (읽기 분산)
- 비동기 복제이므로 Master 쓰기 직후 Replica에서 읽으면 이전 데이터가 나올 수 있다
- Master가 다운되면 수동으로 Replica를 Master로 승격해야 한다
11.3 Sentinel (자동 장애 조치)
1
2
3
4
5
6
7
8
9
10
11
12
Sentinel 구조:
[Sentinel 1]──────[Sentinel 2]──────[Sentinel 3]
│ │ │
│ 모니터링 │ │
▼ ▼ ▼
[Master] ◄──복제── [Replica 1] [Replica 2]
Master 다운 시:
→ Sentinel들이 과반수 합의 (Quorum)
→ Replica 중 하나를 자동으로 Master로 승격
→ 클라이언트에 새 Master 주소 통지
- 자동 페일오버: Master 다운 시 Sentinel이 자동으로 Replica를 승격
- 모니터링: 주기적으로 Master/Replica 상태 감시
- 최소 3개의 Sentinel 필요 (홀수로 구성하여 과반수 판정)
- 데이터 샤딩은 지원하지 않음 (단일 Master의 메모리 한계)
11.4 Redis Cluster (수평 확장)
1
2
3
4
5
6
7
8
9
10
11
12
13
Redis Cluster:
┌──────────────────────────────────────────────────┐
│ │
│ [Master A]──[Replica A'] 슬롯 0~5460 │
│ │
│ [Master B]──[Replica B'] 슬롯 5461~10922 │
│ │
│ [Master C]──[Replica C'] 슬롯 10923~16383 │
│ │
└──────────────────────────────────────────────────┘
키 → CRC16(key) % 16384 → 해당 슬롯의 Master로 라우팅
- 16,384개의 해시 슬롯을 여러 Master에 분배
- 수평 확장 가능 (Master 추가로 용량/처리량 증가)
- 각 Master에 Replica를 두어 HA 보장
- 멀티 키 연산 제한: 서로 다른 슬롯에 있는 키에 대한 트랜잭션/Lua 스크립트 불가.
{hashtag}접두사로 같은 슬롯에 키를 모을 수 있다.
1
2
3
4
# 해시 태그로 같은 슬롯에 배치
SET {user:1}:name "홍길동"
SET {user:1}:age "28"
# {user:1}이 해시 태그 → 두 키 모두 같은 슬롯
11.5 아키텍처 선택 가이드
| 구성 | 장점 | 단점 | 적합한 경우 |
|---|---|---|---|
| Standalone | 단순, 저비용 | SPOF, 확장 불가 | 개발/테스트 |
| Master-Replica | 읽기 분산 | 수동 페일오버 | 읽기 많은 소규모 운영 |
| Sentinel | 자동 페일오버 | 쓰기 확장 불가 | 중소규모 운영 |
| Cluster | 수평 확장, HA | 복잡, 멀티키 제한 | 대규모 운영 |
12. 면접에서 자주 나오는 Redis 질문과 답변
Q1: Redis가 싱글 스레드인데 어떻게 빠른가요?
Redis가 빠른 이유는 세 가지입니다. 첫째, 모든 데이터가 메모리에 있어 디스크 I/O가 없습니다. 둘째, 싱글 스레드라서 락 오버헤드와 컨텍스트 스위칭이 없습니다. 셋째, I/O 멀티플렉싱(epoll/kqueue)으로 수천 개의 동시 연결을 효율적으로 처리합니다. 메모리 연산 자체가 100ns 수준으로 빠르기 때문에 단일 스레드로도 초당 10만+ 연산을 처리할 수 있습니다.
Q2: Redis에서 사용할 수 있는 데이터 구조는 무엇이 있나요?
String, List, Set, Sorted Set, Hash가 기본 5가지입니다. 추가로 Bitmap, HyperLogLog, Stream, Geospatial 등이 있습니다. 실무에서는 String(캐시, 세션), Hash(객체 저장), Sorted Set(랭킹, Rate Limiter), Set(중복 체크, 관계), List(큐, 최근 목록)를 자주 사용합니다.
Q3: Cache Aside 패턴을 설명하고, 왜 가장 많이 사용되는지 말해주세요.
Cache Aside는 애플리케이션이 직접 캐시를 관리하는 패턴입니다. 읽기 시 캐시를 먼저 확인하고, Cache Miss면 DB에서 조회 후 캐시에 저장합니다. 쓰기 시에는 DB를 업데이트하고 캐시를 무효화합니다. 가장 많이 사용되는 이유는 구현이 단순하고, 캐시 장애 시에도 DB로 직접 조회할 수 있어 장애 격리가 되기 때문입니다. 단점은 첫 요청이 항상 Cache Miss라는 것과, 캐시 무효화와 DB 업데이트 사이에 짧은 불일치가 발생할 수 있다는 것입니다.
Q4: Cache Stampede가 무엇이고, 어떻게 해결하나요?
인기 있는 캐시 키가 만료되는 순간, 대량의 요청이 동시에 DB로 몰리는 현상입니다. 해결법으로는 분산 락(뮤텍스)으로 하나의 요청만 DB 조회하게 하는 방법, TTL에 랜덤 jitter를 추가하여 동시 만료를 방지하는 방법, 그리고 만료 전에 백그라운드에서 미리 갱신하는 방법이 있습니다.
Q5: RDB와 AOF의 차이를 설명하세요.
RDB는 특정 시점의 메모리 전체를 바이너리 파일로 스냅샷하는 방식이고, AOF는 모든 쓰기 명령을 로그로 기록하는 방식입니다. RDB는 파일이 컴팩트하고 복구가 빠르지만 스냅샷 간 데이터 유실이 있고, AOF는 데이터 유실이 최소화되지만 파일이 크고 복구가 느립니다. 실무에서는 둘을 혼합하여 사용하며, Redis 4.0+에서는 AOF 재작성 시 RDB preamble을 포함하는 혼합 모드를 지원합니다.
Q6: Redis의 Eviction Policy 중 allkeys-lru와 volatile-lru의 차이는?
allkeys-lru는 메모리가 가득 차면 전체 키 중 가장 오래 사용되지 않은 키를 제거합니다. volatile-lru는 TTL이 설정된 키에 한해서만 LRU 기준으로 제거합니다. 순수 캐시 용도라면 allkeys-lru가 적합하고, 캐시 데이터와 영구 데이터가 혼재되어 있다면 volatile-lru로 영구 키를 보호할 수 있습니다.
Q7: 분산 락을 구현할 때 주의할 점은?
세 가지 주의점이 있습니다. 첫째, 락 해제 시 본인이 잡은 락만 해제해야 합니다. GET과 DEL을 별도로 수행하면 다른 클라이언트의 락을 해제할 위험이 있어 Lua 스크립트로 원자적으로 처리해야 합니다. 둘째, 락에 반드시 TTL을 설정하여 데드락을 방지해야 합니다. 셋째, 작업 시간이 TTL보다 길어질 수 있으므로 Redisson의 Watch Dog처럼 자동 갱신 메커니즘을 사용하는 것이 안전합니다.
Q8: Redis를 세션 저장소로 사용하는 이유는?
다중 서버 환경에서 톰캣 내장 세션은 서버별로 독립적이므로 로드밸런싱 시 세션 불일치가 발생합니다. Redis에 세션을 저장하면 모든 서버가 동일한 세션 저장소를 바라보므로 어떤 서버로 요청이 가더라도 세션이 유지됩니다. Spring Session + Redis를 사용하면 기존 HttpSession 코드를 수정하지 않고도 투명하게 Redis 세션을 적용할 수 있습니다.
Q9: Redis Sentinel과 Cluster의 차이는?
Sentinel은 Master-Replica 구성에 자동 페일오버를 추가한 것으로, Master가 다운되면 Sentinel이 Replica를 자동으로 승격합니다. 그러나 쓰기는 단일 Master에서만 가능하므로 쓰기 확장이 불가능합니다. Cluster는 해시 슬롯 기반으로 여러 Master에 데이터를 분산하여 수평 확장이 가능합니다. 소규모에서는 Sentinel, 대규모에서는 Cluster가 적합합니다.
Q10: Redis에서 O(N) 명령이 위험한 이유는?
Redis는 싱글 스레드이므로 하나의 명령이 오래 걸리면 뒤의 모든 명령이 블로킹됩니다. KEYS *, FLUSHALL, 큰 Set/Hash의 SMEMBERS, HGETALL 등 O(N) 명령은 키가 많을수록 처리 시간이 길어져 전체 서비스에 지연을 유발합니다. 대안으로 KEYS 대신 SCAN(커서 기반 점진적 조회), 큰 컬렉션은 SSCAN/HSCAN을 사용해야 합니다.
Q11: Redis를 캐시로만 쓰는 건가요?
아닙니다. Redis는 캐시 외에도 세션 저장소, 분산 락, 실시간 랭킹(Sorted Set), Rate Limiter(Sliding Window), 메시지 큐(List, Stream), Pub/Sub, 지리 좌표 처리(Geospatial) 등 다양한 용도로 활용됩니다. “Redis = 캐시”라는 인식은 Redis의 활용 범위를 좁게 보는 것입니다.
Q12: 캐시와 DB의 데이터 정합성은 어떻게 보장하나요?
Cache Aside 패턴에서는 DB 업데이트 후 캐시를 무효화(delete)하는 것이 일반적입니다. 캐시를 업데이트(update)하는 것보다 무효화하는 것이 더 안전한데, 동시 업데이트 시 캐시에 더 오래된 값이 남는 race condition을 방지할 수 있기 때문입니다. 그래도 DB 업데이트와 캐시 무효화 사이에 짧은 불일치 구간이 존재하며, 이것은 대부분의 서비스에서 허용 가능한 수준입니다. 강한 정합성이 필요하면 Write Through나 CDC(Change Data Capture) 기반 캐시 갱신을 고려합니다.
정리
Redis의 핵심을 정리하면 다음과 같다.
기본 원리: Redis는 메모리 기반, 싱글 스레드 이벤트 루프로 동작하는 인메모리 데이터 스토어이다. 디스크 I/O가 없고 락 오버헤드가 없어 초당 10만+ 연산을 처리할 수 있다. String, List, Set, Sorted Set, Hash 등 다양한 자료구조를 네이티브로 지원하며, 각 자료구조는 실무에서 캐시, 랭킹, 분산 락, 세션, 큐 등 다양한 용도로 활용된다.
영속성: RDB(스냅샷)와 AOF(명령 로그) 두 가지 메커니즘을 제공하며, 혼합 사용이 권장된다. 순수 캐시 용도라면 영속성을 끄는 것도 선택지이다.
캐싱 전략: Cache Aside가 가장 일반적이며, 쓰기 시 캐시 무효화를 기본으로 한다. Cache Stampede, Penetration, Avalanche 세 가지 캐시 문제를 이해하고 대비해야 한다.
운영: Eviction Policy(allkeys-lru 권장), TTL 분산, O(N) 명령 회피가 운영의 핵심이다. 고가용성은 Sentinel(자동 페일오버)이나 Cluster(수평 확장)로 확보한다.
Spring 연동: Spring Data Redis + RedisTemplate 또는 @Cacheable 어노테이션으로 쉽게 통합할 수 있다. 직렬화는 JSON 기반을 권장하며, JDK 직렬화는 피한다.