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): 모든 요소를 가져오고, -1 index는 마지막 요소를 의미합니다.

사용 시점: 최근 활동 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 레이어에서 복잡한 로직을 짜지 않고도 활용할 수 있습니다.

rediscachingspring-bootnosqlperformance