1편에서 기본 사용법을, 2편에서 자료구조별 연산을 봤다. 이번 편에서는 실무에서 자주 쓰이는 패턴들을 다룬다. 좋아요 수 캐싱, multiGet 벌크 조회, Pipeline, 분산 락까지 — RedisTemplate을 실제 서비스에 적용하는 방법이다.
multiGet — 벌크 조회
왜 필요한가
피드 목록을 조회할 때, 피드 20개의 좋아요 수를 한 번에 가져와야 한다. get을 20번 호출하면 Redis 왕복이 20번 발생한다.
편의점에서 물건을 사는 상황을 떠올려 보자.
물건 하나를 사고, 계산하고, 밖에 나갔다가 다시 들어와서 또 하나를 사고, 계산하고... 이걸 20번 반복한다. 시간 낭비다. 장바구니에 20개를 담아서 한 번에 계산하면 된다.
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;
}
초록 영역에서 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번이다.
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
| 특성 | multiGet | Pipeline |
|---|---|---|
| 연산 종류 | GET만 | 아무 연산이나 섞을 수 있음 |
| 사용 편의성 | 간단 | 콜백 패턴 필요 |
| 결과 수집 | List<Object> 바로 반환 | List<Object>로 일괄 수집 |
| 적합한 상황 | 같은 타입 벌크 조회 | 서로 다른 연산 조합 |
단순 벌크 조회는 multiGet이 편하고, 조회 + 저장 + 삭제를 한 번에 보내야 할 때 Pipeline을 쓴다.
좋아요 수 캐싱 패턴
앞서 나온 내용들을 조합해서, 피드 좋아요 수를 Redis로 캐싱하는 전체 패턴을 설계한다.
구조 설계
핵심 원칙이 세 가지다.
조회는 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가 실패하면 어떻게 되는가?
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개 차이 나는 건 사용자 경험에 거의 영향이 없으므로, 이 수준의 일관성이면 충분하다.
```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이 여러 대(서버가 여러 대)이면, 동시에 잔고를 읽어서 동시에 출금할 수 있다. 잔고가 음수가 된다.
이 분산 락을 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을 넣는다. 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
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과 비슷하지만, 분산 환경에서 여러 서버가 같은 카운터를 안전하게 조작할 수 있다는 점이 다르다.