Redis 기초: 데이터 타입과 사용 시점
1. 개요
Redis는 메모리 기반 데이터 저장소로, “캐시용으로만 쓰인다”는 오해를 자주 받습니다. 실제로 Redis는 다양한 데이터 타입(data structure)을 제공하며, 각 타입은 서로 다른 문제 영역에 적합합니다.
이 글을 통해 Redis의 가장 널리 사용되는 5가지 데이터 타입, Spring Data Redis를 통한 사용 방법, 그리고 더 중요한 것은 언제 어떤 타입을 선택해야 하는지 배우게 됩니다. 이 글을 다 읽으면 “모든 것에 String 사용”이라는 고전적인 실수를 피할 수 있고, 데이터 업데이트할 때 스스로 고생하는 일을 막을 수 있습니다.
2. 환경 설정
프로젝트에 Spring Data Redis dependency를 추가합니다:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
application.yml에서 연결 정보를 선언합니다:
spring:
data:
redis:
host: localhost
port: 6379
Docker로 Redis를 빠르게 실행합니다:
docker run -d --name redis-dev -p 6379:6379 redis:7-alpine
아래 예제들은 Spring Boot가 자동으로 구성하는 RedisTemplate<String, String>을 사용합니다. 수동으로 bean을 선언할 필요가 없습니다.
3. String: 가장 단순한 타입
String은 기본 타입으로, 한 개의 key에 한 개의 value를 저장합니다. value는 텍스트, 숫자, 또는 직렬화된 JSON이 될 수 있습니다.
@Service
public class SessionTokenService {
private final RedisTemplate<String, String> redisTemplate;
public SessionTokenService(RedisTemplate<String, String> redisTemplate) {
this.redisTemplate = redisTemplate;
}
public void saveToken(String userId, String token) {
redisTemplate.opsForValue()
.set("session:" + userId, token, Duration.ofMinutes(30));
}
public String getToken(String userId) {
return redisTemplate.opsForValue().get("session:" + userId);
}
}
set(key, value, ttl): token을 30분 TTL과 함께 저장하고, Redis가 만료 시 자동으로 삭제합니다.get(key): key가 없거나 만료되었으면null을 반환합니다.
만료된 token으로 getToken을 호출했을 때의 출력:
null
사용 시점: session token, 미리 직렬화된 object 캐싱(JSON), 간단한 counter(INCR). String은 원자적 증가도 지원합니다:
Long visits = redisTemplate.opsForValue().increment("page:views:home");
주의: 큰 object를 저장한 후 단 1개 필드만 업데이트하기 위해 전체를 직렬화/역직렬화하지 마세요 — 이는 Hash로 전환해야 한다는 신호입니다.
4. Hash: 여러 필드를 가진 object
Hash는 한 key 내에 Map<String, String>과 같은 구조로, 전체 object를 읽고 쓰지 않고도 각 필드를 독립적으로 업데이트할 수 있습니다.
@Service
public class UserProfileCache {
private final RedisTemplate<String, String> redisTemplate;
public UserProfileCache(RedisTemplate<String, String> redisTemplate) {
this.redisTemplate = redisTemplate;
}
public void saveProfile(String userId, Map<String, String> fields) {
redisTemplate.opsForHash().putAll("user:" + userId, fields);
}
public void updateEmail(String userId, String newEmail) {
redisTemplate.opsForHash().put("user:" + userId, "email", newEmail);
}
public Map<Object, Object> getProfile(String userId) {
return redisTemplate.opsForHash().entries("user:" + userId);
}
}
putAll: 여러 필드를 한 번에 쓰고,HSET과 유사합니다.put한 필드:email필드만 업데이트하고, 다른 필드(name, age…)는 영향을 받지 않습니다.entries: hash의 모든 필드-값을 가져옵니다.
String + JSON과 비교하면: email을 바꿀 때마다 전체 JSON을 GET하고, 파싱하고, 수정하고, 직렬화한 후 SET해야 합니다. Hash는 단 하나의 HSET 명령입니다.
사용 시점: 여러 필드를 가진 object로 독립적 업데이트가 필요한 경우 — user profile, product detail, tenant별 config.
5. List: 큐와 순서가 있는 히스토리
List는 순서가 있는 목록으로, 양쪽 끝에서 push/pop을 지원합니다. 큐나 가장 최근 N개 항목 저장에 적합합니다.
@Service
public class RecentActivityService {
private final RedisTemplate<String, String> redisTemplate;
public RecentActivityService(RedisTemplate<String, String> redisTemplate) {
this.redisTemplate = redisTemplate;
}
public void logActivity(String userId, String activity) {
String key = "activity:" + userId;
redisTemplate.opsForList().leftPush(key, activity);
redisTemplate.opsForList().trim(key, 0, 9); // 가장 최근 10개 항목만 유지
}
public List<String> getRecentActivities(String userId) {
return redisTemplate.opsForList().range("activity:" + userId, 0, -1);
}
}
leftPush: 새 activity를 list의 앞에 추가합니다.trim(key, 0, 9): list를 10개 요소만 유지하도록 잘라내고, list가 무한정 커지는 것을 방지합니다.range(0, -1): 모든 요소를 가져오고,-1index는 마지막 요소를 의미합니다.
사용 시점: 최근 활동 timeline, 간단한 job queue(BLPOP으로 consumer가 대기), undo stack.
주의: List는 value로 검색하거나 임의 위치에 자주 접근해야 하면 적합하지 않습니다 — 긴 list에서 index로 접근하는 복잡도는 O(n)입니다.
6. Set: 중복 없는 모음, 순서 없음
Set은 고유한 값만 저장하며, set 연산(union, intersection, difference)을 매우 빠르게 지원합니다.
@Service
public class TagService {
private final RedisTemplate<String, String> redisTemplate;
public TagService(RedisTemplate<String, String> redisTemplate) {
this.redisTemplate = redisTemplate;
}
public void addTags(String articleId, String... tags) {
redisTemplate.opsForSet().add("article:tags:" + articleId, tags);
}
public boolean hasTag(String articleId, String tag) {
return redisTemplate.opsForSet()
.isMember("article:tags:" + articleId, tag);
}
public Set<String> commonTags(String articleId1, String articleId2) {
return redisTemplate.opsForSet()
.intersect("article:tags:" + articleId1, "article:tags:" + articleId2);
}
}
add: 여러 tag를 추가하고, Redis가 자동으로 중복을 제거합니다.isMember: O(1) 복잡도로 존재 여부를 확인합니다 — List를 순회하는 것보다 훨씬 빠릅니다.intersect: 두 기사의 공통 tag를 찾습니다 — 관련 기사 추천에 사용됩니다.
사용 시점: tag/category, 일별 고유 방문자 목록(SADD visitors:2026-10-05 userId), 빠른 membership 확인(예: 사용자가 이미 이벤트에 등록했는지).
7. Sorted Set: 점수 기반 순위 매기기
Sorted Set(ZSet)은 Set처럼 작동하지만 각 member가 score를 가져 정렬됩니다. 리더보드 문제에 가장 강력한 타입입니다.
@Service
public class LeaderboardService {
private final RedisTemplate<String, String> redisTemplate;
public LeaderboardService(RedisTemplate<String, String> redisTemplate) {
this.redisTemplate = redisTemplate;
}
public void addScore(String playerId, double score) {
redisTemplate.opsForZSet().add("leaderboard:weekly", playerId, score);
}
public Long getRank(String playerId) {
// 0-based rank, 존재하지 않으면 null
return redisTemplate.opsForZSet().reverseRank("leaderboard:weekly", playerId);
}
public Set<String> getTop10() {
return redisTemplate.opsForZSet()
.reverseRange("leaderboard:weekly", 0, 9);
}
}
add(key, member, score): player를 추가하거나 점수를 업데이트합니다.reverseRank: 점수가 높은 순서대로 rank를 가져옵니다(점수 높은 것이 rank 1).reverseRange(0, 9): top 10을 가져오고, Redis가 자동으로 정렬해줍니다 — application에서 sort할 필요 없습니다.
getTop10() 호출 시 출력 예:
["playerA", "playerC", "playerB"]
사용 시점: 리더보드, priority queue(score = priority), time window 기반 rate limiting(score = timestamp).
8. JUnit 5와 Testcontainers를 사용한 동작 검증
Redis 관련 로직을 테스트할 때 실제 데이터에 영향을 주지 않으려면 Testcontainers를 사용합니다.
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
@SpringBootTest
@Testcontainers
class LeaderboardServiceTest {
@Container
static GenericContainer<?> redis = new GenericContainer<>("redis:7-alpine")
.withExposedPorts(6379);
@DynamicPropertySource
static void redisProps(DynamicPropertyRegistry registry) {
registry.add("spring.data.redis.host", redis::getHost);
registry.add("spring.data.redis.port", () -> redis.getMappedPort(6379));
}
@Autowired
private LeaderboardService leaderboardService;
@Test
void shouldReturnTopPlayersInOrder() {
leaderboardService.addScore("playerA", 100);
leaderboardService.addScore("playerB", 50);
leaderboardService.addScore("playerC", 80);
Set<String> top = leaderboardService.getTop10();
assertThat(top).containsExactly("playerA", "playerC", "playerB");
}
}
@Container는 각 test class마다 Docker에서 Redis를 실행합니다.@DynamicPropertySource는 Spring Boot가 올바른 container에 연결하도록 host/port를 재정의합니다.containsExactly는 정확한 순서를 확인합니다 — Sorted Set은 항상 score 내림차순으로 결과를 보장합니다.
9. 빠른 비교표
| 타입 | 구조 | 사용 시점 |
|---|---|---|
| String | key → value | object 캐싱, counter, session token |
| Hash | key → field-value map | 여러 필드 object, 부분 업데이트 |
| List | key → 순서 있는 list | queue, 최근 항목, timeline |
| Set | key → 중복 없는 모음 | tag, membership check, set 연산 |
| Sorted Set | key → score 있는 member | 리더보드, rate limiting, priority queue |
10. 흔한 실수들
- 부분 업데이트가 필요한 데이터에 String 사용: 여러 thread가 JSON을 동시에 읽고-수정-쓸 때 race condition이 발생합니다. Hash로 전환해서 각 필드를 원자적으로 업데이트하세요.
- List를
trim하지 않음: list가 무한정 커져서 RAM을 낭비하고, 시간이 지날수록LRANGE명령이 느려집니다. - TTL 설정 안 함: Redis는 기본적으로 key를 자동 삭제하지 않습니다 — 쓰레기 데이터가 계속 쌓입니다. session, cache 같은 임시 데이터는 항상
expire을 고려하세요. - 순서가 필요한 데이터에 Set 사용: Set은 반복 순서를 보장하지 않습니다. 정렬이 필요하면 Sorted Set을 사용하세요.
주의: Redis는 메모리 기반이므로 모든 데이터가 RAM에 있습니다. 큰 데이터, 장기 데이터, 또는 복잡한 ACID transaction이 필요한 데이터(예: 은행 계좌 잔액)는 Redis를 유일한 source of truth로 사용하면 안 됩니다.
11. 결론
Redis는 “큰 캐시”가 아니라 유연한 data structure 도구 모음입니다. 올바른 데이터 타입을 선택하세요 — 단순 값은 String, 여러 필드 object는 Hash, 큐는 List, membership은 Set, 순위는 Sorted Set — 그러면 code가 간결해지고, 명령 수가 줄어들고, Redis의 원자적 연산의 힘을 application 레이어에서 복잡한 로직을 짜지 않고도 활용할 수 있습니다.