지난 글에서는 HTTP Session의 한계와 Redis를 세션 저장소로 활용하는 방법을 살펴봤다. Redis가 세션 관리에 적합한 이유는 In-Memory 저장소 특성상 매 요청마다 조회되는 세션 데이터를 빠르게 처리할 수 있기 때문이었다.
이번 글에서는 Redis를 단순히 "빠른 저장소"로만 보는 시각에서 벗어나, 왜 Redis가 다양한 데이터 타입을 지원하는지에 대해 이야기한다. 글을 읽으면서 스스로 이런 질문을 던져보자.
Redis가 String 하나만 지원한다고 가정해보자. 게임 랭킹 Top 10을 저장하려면 어떻게 해야 할까?
// Redis에 이런 식으로 저장해야 한다
SET game:ranking "player1:2000,player2:1800,player3:1500,..."
이렇게 하면 "1위부터 3위까지만 조회해줘"라는 요청이 들어왔을 때 어떤 일이 벌어지는가?
1. Redis에서 전체 String을 꺼낸다
2. 서버단에서 파싱한다 (split, 정규식 등)
3. 필요한 부분만 추출한다
4. 결과를 반환한다
Redis의 가장 큰 장점은 속도다. 그런데 이 방식은 무거운 처리를 서버단으로 넘기는 순간, 그 장점을 상쇄시킨다. 더 심각한 문제는 동시성이다. 서버 100대가 동시에 같은 데이터를 읽고 수정하려 한다면 Race Condition이 발생한다.
Redis가 다양한 데이터 타입을 지원하는 핵심 이유는 하나다.
데이터 구조에 맞는 타입을 쓰면, 파싱 없이 Redis 안에서 직접 원하는 데이터만 꺼낼 수 있다.
Redis의 String은 이름과 달리 단순한 문자열 저장소가 아니다. 내부적으로 모든 데이터를 바이트(byte) 로 저장하기 때문에, Java의 int, long 같은 타입 구분이 없다. "123"이나 "hello"나 Redis 입장에서는 똑같이 바이트 덩어리다.
이 특성 덕분에 String은 범용 저장소에 가깝다.
String의 강력한 활용 중 하나는 카운터다.
// 좋아요 수 증가
Long likes = redisTemplate.opsForValue().increment("item:123:likes");
// 페이지 뷰 감소
Long views = redisTemplate.opsForValue().decrement("page:views");
서버단에서 직접 구현한다면 이런 순서가 필요하다.
1. Redis에서 "123" 꺼냄
2. Long으로 파싱
3. +1
4. 다시 String으로 저장
이 과정에서 서버 100개가 동시에 같은 키에 접근하면 Race Condition이 발생한다. 100명이 좋아요를 눌렀는데 결과가 3이 되는 상황이다.
Redis의 increment는 이 문제를 해결한다. Redis는 Single Thread로 동작하기 때문에, increment 명령어는 원자적(Atomic) 으로 실행된다. get → +1 → set이 쪼개지지 않고 하나의 명령으로 처리되어, 100명이 동시에 눌러도 정확히 100이 나온다.
| 항목 | 내용 |
|---|---|
| 저장 구조 | Key → Value (단일 바이트) |
| 주요 활용 | 단순 캐싱, 카운터, 세션 상태 |
| 핵심 강점 | Atomic 연산으로 동시성 문제 없음 |
List는 삽입 순서를 유지하는 데이터 구조다. 양쪽 끝(left, right)에서 데이터를 넣고 뺄 수 있어서, 어느 쪽을 선택하느냐에 따라 Queue도 되고 Stack도 된다.
Queue (FIFO): 한쪽에서 넣고 → 반대쪽에서 꺼냄
Stack (LIFO): 같은 쪽에서 넣고 → 같은 쪽에서 꺼냄
// Queue (FIFO): left push → right pop
redisTemplate.opsForList().leftPushAll("tasks", "Task1", "Task2", "Task3");
// Redis 상태: [Task3, Task2, Task1]
String task = redisTemplate.opsForList().rightPop("tasks"); // Task1
// 최근 메시지 전체 조회
List<String> messages = redisTemplate.opsForList().range("chat:room1", 0, -1);
| 항목 | 내용 |
|---|---|
| 저장 구조 | Key → [Value1, Value2, ...] (순서 유지) |
| 주요 활용 | 작업 대기열, 채팅 메시지 기록, 최근 활동 내역 |
| 핵심 강점 | 양방향 입출력으로 Queue/Stack 모두 구현 가능 |
Set은 중복을 허용하지 않는 데이터 구조다. 같은 값을 여러 번 추가해도 한 번만 저장된다.
"중복 제거는 Java의 HashSet으로도 되는데, 왜 Redis Set을 써야 하는가?"라는 의문이 생길 수 있다.
Java HashSet은 서버 메모리에 올라간다. 서버가 3대라면 HashSet도 3개가 존재한다. 분산 환경에서는 각 서버가 서로 다른 상태를 가지게 되어 데이터 불일치가 발생한다.
Redis Set은 중앙 저장소에 하나만 존재한다. 모든 서버가 동일한 집합을 바라보기 때문에, 분산 환경에서도 일관성이 보장된다.
Set의 또 다른 강점은 교집합, 합집합, 차집합 연산을 Redis 안에서 직접 처리한다는 점이다.
redisTemplate.opsForSet().add("event1:users", "User1", "User2", "User4");
redisTemplate.opsForSet().add("event2:users", "User2", "User3", "User4");
// 두 이벤트 모두 참여한 유저
Set<String> common = redisTemplate.opsForSet().intersect("event1:users", "event2:users");
// 결과: {"User2", "User4"}
// 이벤트1에만 참여한 유저
Set<String> onlyEvent1 = redisTemplate.opsForSet().difference("event1:users", "event2:users");
// 결과: {"User1"}
서버단에서 이 연산을 직접 구현한다면, 전체 데이터를 꺼내서 반복문으로 비교하는 복잡한 코드가 필요하다. Redis는 명령어 하나로 끝낸다.
| 항목 | 내용 |
|---|---|
| 저장 구조 | Key → {Value1, Value2, ...} (중복 없음, 순서 없음) |
| 주요 활용 | 고유 사용자 목록, 태그 관리, 이벤트 참여자 |
| 핵심 강점 | 분산 환경에서 중앙 집합 역할 + Redis 내 집합 연산 |
Hash는 하나의 Key 아래에 필드-값 쌍을 여러 개 저장하는 구조다. 관계형 DB의 레코드 한 행과 유사하다.
String으로도 사용자 프로필을 저장할 수 있다. user:123 키에 JSON 문자열로 저장하면 된다. 그런데 이메일만 변경하려면 어떻게 해야 하는가?
1. JSON String 전체를 꺼낸다
2. 파싱한다
3. 이메일 필드를 수정한다
4. 다시 JSON으로 직렬화한다
5. 저장한다
Hash라면 다르다.
// Before: JSON String 방식
redisTemplate.opsForValue().set("user:123", "{\"name\":\"John\",\"email\":\"old@email.com\",\"age\":30}");
// 이메일 변경: 전체 파싱 후 재직렬화 필요
// After: Hash 방식
redisTemplate.opsForHash().put("user:123", "name", "John Doe");
redisTemplate.opsForHash().put("user:123", "email", "john@example.com");
redisTemplate.opsForHash().put("user:123", "age", "30");
// 이메일만 변경: 한 줄로 끝
redisTemplate.opsForHash().put("user:123", "email", "new@email.com");
// 특정 필드만 조회
String email = (String) redisTemplate.opsForHash().get("user:123", "email");
업데이트가 잦고 사용자가 많은 서비스에서 이 차이는 성능에 직접적인 영향을 준다.
| 항목 | 내용 |
|---|---|
| 저장 구조 | Key → {field1: value1, field2: value2, ...} |
| 주요 활용 | 사용자 프로필, 상품 속성, 객체 데이터 |
| 핵심 강점 | 필드 단위 읽기/쓰기로 불필요한 파싱 제거 |
Sorted Set은 Set과 마찬가지로 중복을 허용하지 않지만, 각 값에 Score(점수) 를 함께 저장한다. 저장된 요소들은 이 Score를 기준으로 자동 정렬된다.
일반 Set으로 랭킹을 구현하려면 전체 데이터를 서버단으로 꺼내서 직접 정렬해야 한다. Sorted Set은 Redis 안에서 정렬이 이미 되어 있기 때문에, 범위 조회 명령어 하나로 Top N 결과를 바로 가져올 수 있다.
// 점수와 함께 저장
redisTemplate.opsForZSet().add("game:ranking", "Player1", 1500.0);
redisTemplate.opsForZSet().add("game:ranking", "Player2", 2000.0);
redisTemplate.opsForZSet().add("game:ranking", "Player3", 1800.0);
// Top 2 조회 (점수 높은 순)
Set<String> top2 = redisTemplate.opsForZSet().reverseRange("game:ranking", 0, 1);
// 결과: [Player2, Player3]
// 점수 업데이트
redisTemplate.opsForZSet().incrementScore("game:ranking", "Player1", 100.0);
// Player1: 1500.0 → 1600.0
// 특정 점수 범위 조회
Set<String> inRange = redisTemplate.opsForZSet().rangeByScore("game:ranking", 1600.0, 1900.0);
// 결과: [Player1, Player3]
| 항목 | 내용 |
|---|---|
| 저장 구조 | Key → {Value: Score, ...} (Score 기준 자동 정렬) |
| 주요 활용 | 게임 리더보드, 실시간 랭킹, 시간 기반 데이터 |
| 핵심 강점 | Redis 내 자동 정렬 + 범위/순위 조회 |
| 타입 | 저장 구조 | 핵심 강점 | 대표 활용 |
|---|---|---|---|
| String | Key → Value | Atomic 연산, 동시성 안전 | 캐싱, 카운터, 세션 상태 |
| List | Key → [순서 있는 목록] | 양방향 입출력 (Queue/Stack) | 작업 대기열, 메시지 기록 |
| Set | Key → {중복 없는 집합} | 분산 중앙 집합 + 집합 연산 | 고유 사용자, 태그, 참여자 |
| Hash | Key → {필드: 값} | 필드 단위 읽기/쓰기 | 객체 데이터, 사용자 프로필 |
| Sorted Set | Key → {값: 점수} | Score 기반 자동 정렬 | 랭킹, 실시간 순위 |
Redis가 다양한 데이터 타입을 지원하는 이유는 단순히 "편의성" 때문이 아니다. 각 타입은 서버단에서 처리하면 속도 저하와 동시성 문제가 생기는 작업을 Redis 안에서 직접 처리하기 위해 설계된 도구다.
데이터 구조에 맞는 타입을 선택한다면, 서버 코드는 단순해지고 Redis의 속도 이점은 극대화된다.