RedisTemplate (3/3)

이전 편: [Redis] 2. 자료구조별 Operations

이 문서가 시리즈의 마지막 편입니다.

1편에서 기본 사용법을, 2편에서 자료구조별 연산을 봤다. 이번 편에서는 실무에서 자주 쓰이는 패턴들을 다룬다. 좋아요 수 캐싱, multiGet 벌크 조회, Pipeline, 분산 락까지 — RedisTemplate을 실제 서비스에 적용하는 방법이다.

multiGet — 벌크 조회

왜 필요한가

피드 목록을 조회할 때, 피드 20개의 좋아요 수를 한 번에 가져와야 한다. get을 20번 호출하면 Redis 왕복이 20번 발생한다.

편의점에서 물건을 사는 상황을 떠올려 보자.

물건 하나를 사고, 계산하고, 밖에 나갔다가 다시 들어와서 또 하나를 사고, 계산하고... 이걸 20번 반복한다. 시간 낭비다. 장바구니에 20개를 담아서 한 번에 계산하면 된다.

flowchart TD subgraph Single ["단건 조회 20회"] direction TB S1["GET feed:like:1 → 왕복"] --> S2["GET feed:like:2 → 왕복"] S2 --> S3["..."] S3 --> S4["GET feed:like:20 → 왕복"] S4 --> SR["총 20번 왕복"] end subgraph Multi ["multiGet 1회"] direction TB M1["MGET feed:like:1
feed:like:2
...
feed:like:20"] M1 --> MR["총 1번 왕복"] end style Single fill:#FFE6E6,stroke:#F44336,color:#000 style Multi fill:#E8F8E8,stroke:#4CAF50,color:#000 style S1 fill:#FFE6E6,stroke:#F44336,color:#000 style S2 fill:#FFE6E6,stroke:#F44336,color:#000 style S3 fill:#FFE6E6,stroke:#F44336,color:#000 style S4 fill:#FFE6E6,stroke:#F44336,color:#000 style SR fill:#FFE6E6,stroke:#F44336,color:#000 style M1 fill:#E8F8E8,stroke:#4CAF50,color:#000 style MR fill:#E8F8E8,stroke:#4CAF50,color:#000

장바구니가 multiGet이다. 여러 키를 한 번의 Redis 왕복으로 조회한다.

기본 사용법

List<String> keys = feedIds.stream()
    .map(id -> "feed:like:" + id)
    .toList();

List<Object> values = redisTemplate.opsForValue().multiGet(keys);

첫 번째 인자 — 키 리스트

조회할 키들의 리스트다. Redis의 MGET key1 key2 key3 ... 명령에 대응한다.

왜 키를 미리 만들어야 하는가? multiGet은 Redis 키의 패턴을 모른다. "feed:like:로 시작하는 키 전부"가 아니라, 정확한 키 목록을 넘겨야 한다. 그래서 feedId 리스트에서 키를 미리 조립한다.

반환값 — 순서 보장

반환된 리스트는 입력 키 리스트와 같은 순서다. keys[0]에 대한 값이 values[0]에 있다. 존재하지 않는 키는 null이 들어간다.

// keys:   [feed:like:aaa, feed:like:bbb, feed:like:ccc]
// values: [42,            null,           7]
//                         ↑ 이 키는 Redis에 없음

캐시 히트 + 미스 분리 패턴

multiGet의 핵심 패턴이다. 캐시에 있는 것은 바로 쓰고, 없는 것만 DB에서 조회한다.

public Map<UUID, Long> getLikeCounts(List<UUID> feedIds) {
    // 1. 키 조립
    List<String> keys = feedIds.stream()
        .map(id -> "feed:like:" + id)
        .toList();

    // 2. multiGet으로 한 번에 조회
    List<Object> cached = redisTemplate.opsForValue().multiGet(keys);

    // 3. 히트/미스 분리
    Map<UUID, Long> result = new HashMap<>();
    List<UUID> missedIds = new ArrayList<>();

    for (int i = 0; i < feedIds.size(); i++) {
        if (cached.get(i) != null) {
            result.put(feedIds.get(i), ((Number) cached.get(i)).longValue());
        } else {
            missedIds.add(feedIds.get(i));
        }
    }

    // 4. 미스분만 DB 조회
    if (!missedIds.isEmpty()) {
        Map<UUID, Long> dbResult = feedLikeRepository.countByFeedIn(missedIds)
            .stream()
            .collect(Collectors.toMap(
                row -> (UUID) row[0],
                row -> (Long) row[1]
            ));

        // 5. DB 결과를 Redis에 저장
        dbResult.forEach((feedId, count) -> {
            redisTemplate.opsForValue()
                .set("feed:like:" + feedId, count, Duration.ofMinutes(5));
            result.put(feedId, count);
        });

        // 6. DB에도 없는 feedId는 0으로 처리
        missedIds.stream()
            .filter(id -> !dbResult.containsKey(id))
            .forEach(id -> {
                redisTemplate.opsForValue()
                    .set("feed:like:" + id, 0L, Duration.ofMinutes(5));
                result.put(id, 0L);
            });
    }

    return result;
}
sequenceDiagram autonumber participant App as 서버 participant Redis as Redis participant DB as DB rect rgb(232, 248, 232) Note over App, Redis: 캐시 조회 (1회 왕복) App->>Redis: MGET feed:like:a, feed:like:b, feed:like:c Redis-->>App: [42, null, 7] Note over App: a=42 ✅, b=miss ❌, c=7 ✅ end rect rgb(255, 243, 224) Note over App, DB: 미스분만 DB 조회 App->>DB: SELECT ... WHERE feed_id IN ('b') DB-->>App: [(b, 15)] end rect rgb(232, 244, 248) Note over App, Redis: 미스분 캐시 저장 App->>Redis: SET feed:like:b 15 EX 300 Note over App: 결과: {a=42, b=15, c=7} end

초록 영역에서 Redis 왕복 1회, 주황 영역에서 DB 쿼리 1회, 파란 영역에서 캐시 저장. 최악의 경우(전부 미스)에도 Redis 1회 + DB 1회 = 2회다. 최선의 경우(전부 히트)에는 Redis 1회로 끝난다.

왜 DB에도 없는 feedId에 0을 캐시하는가

GROUP BY 집계 쿼리는 좋아요가 0인 피드에 대해 row를 반환하지 않는다. 이걸 캐시하지 않으면, 좋아요가 없는 피드를 조회할 때마다 매번 DB까지 가서 빈 결과를 받는다. 0을 캐시해두면 다음 조회에서 Redis에서 바로 0을 반환한다.

이 패턴이 특히 잘 먹히는 조건

피드 목록이 무한스크롤이고 모든 유저가 같은 목록을 본다면, 첫 번째 유저가 1페이지를 조회할 때 캐시가 채워지고 이후 유저는 전부 Redis 히트다. 최상위 피드일수록 조회 빈도가 높아서 캐시 히트율이 극도로 높아진다.

Pipeline — 명령 일괄 전송

multiGet과 뭐가 다른가

multiGet같은 연산(GET)을 여러 키에 적용한다. Pipeline은 서로 다른 연산을 한 번의 왕복으로 보낸다.

우체국 비유로 이해해보자.

편지 3통을 보내야 한다. 우체국에 가서 첫 번째 편지를 보내고, 집에 돌아왔다가 다시 가서 두 번째를 보내고, 또 돌아왔다가 세 번째를 보낸다. 한 번에 3통을 들고 가면 왕복 1번이다.

flowchart TD subgraph NoPipe ["Pipeline 없이"] direction TB N1["SET key1 → 왕복"] --> N2["INCR key2 → 왕복"] --> N3["DEL key3 → 왕복"] N3 --> NR["3번 왕복"] end subgraph Pipe ["Pipeline 사용"] direction TB P1["SET key1
INCR key2
DEL key3"] P1 --> PR["1번 왕복"] end style NoPipe fill:#FFE6E6,stroke:#F44336,color:#000 style Pipe fill:#E8F8E8,stroke:#4CAF50,color:#000 style N1 fill:#FFE6E6,stroke:#F44336,color:#000 style N2 fill:#FFE6E6,stroke:#F44336,color:#000 style N3 fill:#FFE6E6,stroke:#F44336,color:#000 style NR fill:#FFE6E6,stroke:#F44336,color:#000 style P1 fill:#E8F8E8,stroke:#4CAF50,color:#000 style PR fill:#E8F8E8,stroke:#4CAF50,color:#000

이걸 Redis에서는 Pipeline이라고 한다. 여러 명령을 버퍼에 모았다가 한 번에 전송하고, 응답도 한 번에 받는다.

기본 사용법

List<Object> results = redisTemplate.executePipelined((RedisCallback<Object>) connection -> {
    StringRedisSerializer serializer = new StringRedisSerializer();

    connection.stringCommands().set(
        serializer.serialize("key1"),
        serializer.serialize("value1")
    );
    connection.stringCommands().incr(
        serializer.serialize("counter")
    );
    connection.keyCommands().del(
        serializer.serialize("temp-key")
    );

    return null;  // Pipeline에서는 항상 null 반환
});

executePipelined

Pipeline 세션을 연다. 콜백 안에서 호출하는 모든 명령이 버퍼에 쌓였다가, 콜백이 끝나면 한 번에 전송된다.

왜 콜백 패턴인가? Pipeline은 "명령 모으기 → 전송 → 응답 받기"의 3단계다. 콜백 안에서 명령을 모으고, 콜백이 끝나면 자동으로 전송과 응답 수집이 일어난다. 이 생명주기를 RedisTemplate이 관리하기 위해 콜백으로 감싼다.

return null

Pipeline 콜백은 항상 null을 반환해야 한다. 개별 명령의 결과는 콜백 내부에서 받을 수 없고, executePipelined의 반환값List<Object>로 일괄 수집된다.

안 지키면 어떻게 되는가? null이 아닌 값을 반환하면 Pipeline이 아니라 일반 실행으로 폴백된다. Pipeline의 성능 이점이 사라진다.

SessionCallback으로 더 편하게

위의 RedisCallback은 저수준이라 직렬화를 직접 해야 한다. SessionCallback을 쓰면 RedisTemplate의 고수준 API를 그대로 쓸 수 있다.

List<Object> results = redisTemplate.executePipelined(new SessionCallback<>() {
    @Override
    public Object execute(RedisOperations operations) {
        operations.opsForValue().set("key1", "value1");
        operations.opsForValue().increment("counter");
        operations.delete("temp-key");
        return null;
    }
});

redisTemplate 대신 operations를 쓰는가? 콜백 안에서 redisTemplate을 직접 호출하면 Pipeline 밖에서 실행된다. operations 파라미터를 통해야 Pipeline 안에서 실행된다.

Pipeline vs multiGet

특성multiGetPipeline
연산 종류GET만아무 연산이나 섞을 수 있음
사용 편의성간단콜백 패턴 필요
결과 수집List<Object> 바로 반환List<Object>로 일괄 수집
적합한 상황같은 타입 벌크 조회서로 다른 연산 조합

단순 벌크 조회는 multiGet이 편하고, 조회 + 저장 + 삭제를 한 번에 보내야 할 때 Pipeline을 쓴다.

좋아요 수 캐싱 패턴

앞서 나온 내용들을 조합해서, 피드 좋아요 수를 Redis로 캐싱하는 전체 패턴을 설계한다.

구조 설계

sequenceDiagram autonumber participant User as 사용자 participant App as 서버 participant Redis as Redis participant DB as DB rect rgb(232, 248, 232) Note over User, DB: 피드 목록 조회 User->>App: GET /feeds App->>Redis: MGET feed:like:a, feed:like:b, ... Redis-->>App: [42, null, 7, ...] App->>DB: 미스분만 IN 쿼리 DB-->>App: [(b, 15)] App->>Redis: SET feed:like:b 15 EX 300 App-->>User: 피드 목록 (좋아요 수 포함) end rect rgb(255, 243, 224) Note over User, Redis: 좋아요 누르기 User->>App: POST /feeds/a/like App->>DB: INSERT FeedLike App->>Redis: INCR feed:like:a Note right of Redis: 42 → 43 (원자적) App-->>User: 성공 end rect rgb(255, 230, 230) Note over User, Redis: 좋아요 취소 User->>App: DELETE /feeds/a/like App->>DB: DELETE FeedLike App->>Redis: DECR feed:like:a Note right of Redis: 43 → 42 (원자적) App-->>User: 성공 end

핵심 원칙이 세 가지다.

조회는 multiGet + 미스분 IN

초록 영역이다. multiGet으로 Redis에서 한 번에 가져오고, 미스난 것만 DB에서 조회한다. 피드 20개를 조회해도 Redis 1회 + DB 최대 1회다.

@Cacheable이 아닌가? @Cacheable은 메서드 반환값 전체를 키 하나에 캐시한다. 피드 목록 쿼리 결과가 통째로 캐시되므로, 좋아요 수 하나가 바뀌어도 목록 전체 캐시가 무효화된다. feedId 단위로 개별 캐시하면 변경된 피드의 캐시만 갱신하면 된다.

쓰기는 DB + increment

주황 영역이다. DB에 FeedLike를 저장한 뒤, Redis에서 increment로 카운트를 1 올린다. 전체 캐시를 무효화하는 게 아니라 해당 피드의 카운트만 1 증가시킨다.

왜 캐시 무효화 대신 increment인가? 무효화하면 다음 조회 시 DB에서 다시 COUNT를 해야 한다. increment는 DB 조회 없이 Redis 내에서 즉시 처리된다. 좋아요가 초당 수백 건 발생하는 인기 피드에서 차이가 크다.

삭제도 DB + decrement

분홍 영역이다. 좋아요 취소 시 increment-1을 넘기거나 decrement를 호출한다. 원리는 쓰기와 동일하다.

DB와 Redis의 정합성

increment/decrement 패턴에서 주의할 점이 있다. DB 저장은 성공했는데 Redis increment가 실패하면 어떻게 되는가?

flowchart TD A["DB INSERT 성공"] --> B{"Redis INCR"} B -->|성공| C["DB=43, Redis=43 ✅"] B -->|실패| D["DB=43, Redis=42 ❌"] D --> E["TTL 만료 후
DB에서 재조회 시 복구"] style A fill:#E8F8E8,stroke:#4CAF50,color:#000 style B fill:#FFF3E0,stroke:#FF9800,stroke-width:2px,color:#000 style C fill:#E8F8E8,stroke:#4CAF50,color:#000 style D fill:#FFE6E6,stroke:#F44336,color:#000 style E fill:#E8F4F8,stroke:#2196F3,color:#000

DB와 Redis는 별도의 시스템이므로 트랜잭션으로 묶을 수 없다. 불일치가 발생할 수 있다. 이 불일치는 TTL이 만료되면 자연스럽게 복구된다. DB에서 다시 COUNT를 해서 캐시를 채우기 때문이다.

이 "일시적 불일치 → TTL 후 복구" 방식을 결과적 일관성이라고 한다. 좋아요 수가 잠깐 1~2개 차이 나는 건 사용자 경험에 거의 영향이 없으므로, 이 수준의 일관성이면 충분하다.

try-catch로 Redis 실패를 삼키기

```java

feedLikeRepository.save(FeedLike.create(feed, user));

try {

redisTemplate.opsForValue().increment("feed:like:" + feed.getId());

} catch (Exception e) {

log.warn("Redis increment 실패, TTL 만료 후 복구 예정: feedId={}", feed.getId());

}

```

Redis 장애가 좋아요 기능 자체를 깨뜨리면 안 된다. DB 저장은 정상 처리되고, Redis는 TTL 후 자동 복구된다.

분산 락 패턴

왜 필요한가

1편에서 setIfAbsent를 봤다. 이걸 활용한 분산 락은, 여러 서버에서 동시에 같은 작업을 실행하는 것을 방지한다.

은행 ATM을 떠올려 보자.

두 사람이 동시에 같은 계좌에서 출금하려 한다. ATM이 하나면 한 사람씩 순서대로 처리된다. ATM이 여러 대(서버가 여러 대)이면, 동시에 잔고를 읽어서 동시에 출금할 수 있다. 잔고가 음수가 된다.

flowchart TD subgraph NoLock ["락 없음"] direction TB A1["서버 A: 잔고 읽기 → 1000"] --> A2["서버 B: 잔고 읽기 → 1000"] A2 --> A3["서버 A: 800 출금 → 잔고 200"] A3 --> A4["서버 B: 500 출금 → 잔고 500"] A4 --> A5["실제 잔고: -300 ❌"] end subgraph WithLock ["분산 락"] direction TB B1["서버 A: 락 획득 ✅"] --> B2["서버 B: 락 시도 → 실패 ❌"] B2 --> B3["서버 A: 출금 처리"] B3 --> B4["서버 A: 락 해제"] B4 --> B5["서버 B: 재시도 → 락 획득 ✅"] end style NoLock fill:#FFE6E6,stroke:#F44336,color:#000 style WithLock fill:#E8F8E8,stroke:#4CAF50,color:#000 style A1 fill:#FFE6E6,stroke:#F44336,color:#000 style A2 fill:#FFE6E6,stroke:#F44336,color:#000 style A3 fill:#FFE6E6,stroke:#F44336,color:#000 style A4 fill:#FFE6E6,stroke:#F44336,color:#000 style A5 fill:#FFE6E6,stroke:#F44336,color:#000 style B1 fill:#E8F8E8,stroke:#4CAF50,color:#000 style B2 fill:#FFF3E0,stroke:#FF9800,color:#000 style B3 fill:#E8F8E8,stroke:#4CAF50,color:#000 style B4 fill:#E8F8E8,stroke:#4CAF50,color:#000 style B5 fill:#E8F8E8,stroke:#4CAF50,color:#000

이 분산 락을 Redis에서는 SET NX(setIfAbsent)로 구현한다.

기본 구현

public boolean tryLock(String lockKey, String lockValue, Duration timeout) {
    return Boolean.TRUE.equals(
        redisTemplate.opsForValue().setIfAbsent(lockKey, lockValue, timeout)
    );
}

public void unlock(String lockKey, String lockValue) {
    String currentValue = (String) redisTemplate.opsForValue().get(lockKey);
    if (lockValue.equals(currentValue)) {
        redisTemplate.delete(lockKey);
    }
}

lockKey

락의 대상을 식별하는 키다. lock:feed:abc-123처럼 어떤 리소스를 보호하는지 키 이름에 드러나야 한다.

lockValue

누가 락을 잡았는지 식별하는 값이다. 보통 UUID를 생성해서 넣는다.

왜 값이 필요한가? unlock할 때 자기가 잡은 락인지 확인하기 위해서다. 서버 A가 잡은 락을 서버 B가 해제하면 안 된다. 값을 비교해서 자기 것일 때만 삭제한다.

timeout

락의 TTL이다. 락을 잡은 서버가 비정상 종료하면 unlock이 호출되지 않는다. TTL이 없으면 락이 영원히 풀리지 않는다. TTL을 설정하면 서버가 죽어도 일정 시간 후 락이 자동 해제된다.

사용 예시

String lockKey = "lock:feed:" + feedId;
String lockValue = UUID.randomUUID().toString();

if (tryLock(lockKey, lockValue, Duration.ofSeconds(5))) {
    try {
        // 보호해야 할 작업 수행
    } finally {
        unlock(lockKey, lockValue);
    }
} else {
    // 락 획득 실패 — 이미 다른 서버가 처리 중
    throw new ConcurrentModificationException("다른 서버에서 처리 중입니다.");
}
이 구현의 한계

unlock에서 GET + 비교 + DELETE가 원자적이지 않다. 극히 드물지만 GET과 DELETE 사이에 다른 서버가 끼어들 수 있다. 운영 환경에서는 Lua 스크립트나 Redisson 같은 라이브러리를 사용해서 원자적 unlock을 보장한다. 위 구현은 원리를 이해하기 위한 기본형이다.

자주 하는 실수

multiGet 결과에서 null 처리를 빠뜨리는 실수

multiGet은 키가 없으면 해당 위치에 null을 넣는다. null 체크 없이 바로 캐스팅하면 NullPointerException이 터진다. 반드시 null 체크 후 처리한다.

[!DANGER] Pipeline 안에서 redisTemplate을 직접 호출하는 실수

SessionCallback 안에서 redisTemplate.opsForValue()를 호출하면 Pipeline 밖에서 실행된다. 콜백 파라미터인 operations를 통해 호출해야 Pipeline에 포함된다. 이 실수는 기능적으로 동작하기 때문에 발견이 어렵다. 성능만 나빠진다.

[!DANGER] increment 후 TTL을 설정하지 않는 실수

increment는 키가 없으면 자동 생성한다. 이때 TTL이 설정되지 않는다. 최초 set에서 TTL을 건 키라도, 만료 후 increment로 재생성되면 TTL 없이 영구 키가 된다.

```java

// 위험: increment만 호출

redisTemplate.opsForValue().increment("feed:like:" + feedId);

// 안전: TTL 확인 후 필요하면 재설정

Long ttl = redisTemplate.getExpire("feed:like:" + feedId);

if (ttl != null && ttl == -1) {

redisTemplate.expire("feed:like:" + feedId, Duration.ofMinutes(5));

}

```

[!DANGER] 분산 락에서 TTL 없이 setIfAbsent를 쓰는 실수

setIfAbsent("lock:key", "value")만 호출하면 TTL이 없다. 락을 잡은 서버가 비정상 종료하면 락이 영원히 풀리지 않는다. 반드시 Duration을 함께 넘긴다. 작업 예상 시간의 2~3배를 TTL로 설정하면 안전하다.

면접 Q&A

Redis의 multiGet과 Pipeline의 차이는?

multiGet은 같은 연산(GET)을 여러 키에 적용하는 편의 메서드다. 내부적으로 MGET 명령 하나를 보낸다. Pipeline은 서로 다른 연산(SET, INCR, DEL 등)을 버퍼에 모았다가 한 번에 전송하는 메커니즘이다. 네트워크 왕복을 줄이는 목적은 같지만, Pipeline이 더 범용적이다.

[!QUESTION] Redis를 캐시로 쓸 때 DB와의 정합성은 어떻게 보장하나?

함정이 있다. 완벽한 정합성은 보장할 수 없다. Redis와 DB는 별도 시스템이므로 하나의 트랜잭션으로 묶을 수 없다. 대신 결과적 일관성으로 접근한다. TTL을 설정해서 캐시가 주기적으로 만료되면 DB에서 최신 데이터로 다시 채워진다. 좋아요 수가 잠깐 1~2개 차이 나는 건 허용하고, 분 단위로 자연 복구되는 구조를 만든다. 엄격한 정합성이 필요한 데이터(결제, 재고)는 캐시 대신 DB 직접 조회를 써야 한다.

[!QUESTION] Redis 분산 락의 한계는?

기본 SET NX는 단일 Redis 인스턴스에서만 안전하다. Redis가 마스터-슬레이브 구조일 때, 마스터에 락을 잡은 직후 마스터가 다운되면 슬레이브가 승격되면서 락 정보가 사라질 수 있다. 이를 해결하기 위해 Redis 공식 알고리즘인 Redlock이 있고, Redisson 라이브러리가 이를 구현하고 있다. 또한 unlock의 GET + DELETE가 원자적이지 않은 문제는 Lua 스크립트로 해결한다.

[!QUESTION] increment가 원자적인 이유는?

Redis는 싱글 스레드로 명령을 처리한다. INCR 명령이 실행되는 동안 다른 명령이 끼어들 수 없다. 읽기-증가-쓰기가 하나의 명령으로 처리되므로, 동시에 100개의 요청이 와도 결과가 정확하다. 이것은 Java의 AtomicLong과 비슷하지만, 분산 환경에서 여러 서버가 같은 카운터를 안전하게 조작할 수 있다는 점이 다르다.