❄️ 트러블 슈팅

[트러블슈팅] Redis 호출 5번을 Pipeline으로 1번에 끝내기

le2donguk 2026. 3. 23. 20:28

공고 상세 조회 API에서 Redis 명령을 개별로 5~6번 호출하던 구조를 Pipeline으로 개선한 과정을 정리해 보겠습니다.

이전에 공식문서를 통해 Redis를 공부하고 있었을 때 Pipeline이라는 것을 알게 되었습니다 

Redis 공식문서의 예제 코드는 다음과 같습니다 

//pop a specified number of items from a queue
List<Object> results = stringRedisTemplate.executePipelined(
  new RedisCallback<Object>() {
    public Object doInRedis(RedisConnection connection) throws DataAccessException {
      StringRedisConnection stringRedisConn = new DefaultStringRedisConnection(connection);
      for(int i=0; i< batchSize; i++) {
        stringRedisConn.rPop("myqueue");
      }
    return null;
  }
});

 

이번에는 제가 직접 구현하면서 얻었던 점을 정리해 보겠습니다


문제 상황

공고 상세 조회 API를 구현하면서 두 가지 Redis 작업을 처리하고 있었습니다.

최근 본 공고 (Redis List) — LREM, LPUSH, LTRIM, EXPIRE 총 4번

인기 공고 (Redis Sorted Set) — ZINCRBY, EXPIRE 총 2번

공고 하나를 조회할 때마다 Redis 명령이 총 5~6회 네트워크 왕복으로 수행되는 구조였습니다.

 
 
java
if (memberId != null) {
    String currentAnnounceKey = MEMBER_RECENT_ANNOUNCE_KEY.formatted(memberId);
    stringRedisTemplate.opsForList().remove(currentAnnounceKey, 0, stringAnnounceId);
    stringRedisTemplate.opsForList().leftPush(currentAnnounceKey, stringAnnounceId);
    stringRedisTemplate.opsForList().trim(currentAnnounceKey, 0, 4);
    stringRedisTemplate.expire(currentAnnounceKey, Duration.ofDays(7));
}

stringRedisTemplate.opsForZSet().incrementScore(HOME_POPULAR_ANNOUNCE_KEY, stringAnnounceId, 1);
stringRedisTemplate.expire(HOME_POPULAR_ANNOUNCE_KEY, Duration.ofHours(24));

원인 분석

Redis 자체는 메모리 기반이라 명령 실행 속도가 매우 빠릅니다. 문제는 명령 실행 속도가 아니라 네트워크 왕복(RTT) 비용입니다.

명령 하나를 실행할 때마다 클라이언트 → Redis → 클라이언트 왕복이 한 번 발생합니다. 이게 요청 한 번에 5~6번 반복되는 구조입니다.

트래픽이 적을 땐 체감이 안 되지만 동접이 늘어나면 Redis 호출 횟수 급증, 네트워크 latency 누적, connection contention이 발생하면서 Redis가 병목이 됩니다.


해결 방법 검토

Pipeline — 여러 명령을 버퍼에 모아 한 번에 전송. 구현 난이도 낮고 RTT 감소. 단, 원자성 보장 안 됨.

Lua Script — Redis 내부에서 여러 명령을 원자적으로 처리. 원자성 보장, Race condition 방지. 단, 구현 복잡하고 유지보수 부담.

 

위 두 방법 중 어떤 방식으로 해결할까 고민하다 결국 다음과 같은 결론을 내렸습니다.

최근 본 공고와 인기 공고는 통계성/UX 보조 데이터라 완벽한 정합성이 필수는 아니다. 따라서 데이터 불일치가 서비스 장애로 이어질 가능성도 낮아 Pipeline으로 충분하다고 판단했습니다.


Pipeline 적용

Spring Data Redis는 executePipelined를 통해 Pipeline을 지원합니다.

처음에는 low-level connection 객체를 직접 사용했습니다.

 
 
java
stringRedisTemplate.executePipelined((RedisCallback<Object>) connection -> {
    byte[] recentKey = currentAnnounceKey.getBytes();
    byte[] popularKey = HOME_POPULAR_ANNOUNCE_KEY.getBytes();
    byte[] value = stringAnnounceId.getBytes();

    if (memberId != null) {
        connection.lRem(recentKey, 0, value);
        connection.lPush(recentKey, value);
        connection.lTrim(recentKey, 0, 4);
        connection.expire(recentKey, 60 * 60 * 24 * 7);
    }

    connection.zIncrBy(popularKey, 1, value);
    connection.expire(popularKey, 60 * 60 * 24);

    return null;
});

 

byte []를 직접 다루는 게 불편해서 공식 문서를 다시 보니 DefaultStringRedisConnection으로 감싸면 String으로 편하게 사용할 수 있었습니다.

 
 
java
stringRedisTemplate.executePipelined((RedisCallback<Object>) connection -> {
    StringRedisConnection redis = new DefaultStringRedisConnection(connection);

    if (memberId != null) {
        String currentAnnounceKey = MEMBER_RECENT_ANNOUNCE_KEY.formatted(memberId);
        redis.lRem(currentAnnounceKey, 0, stringAnnounceId);
        redis.lPush(currentAnnounceKey, stringAnnounceId);
        redis.lTrim(currentAnnounceKey, 0, 4);
        redis.expire(currentAnnounceKey, 60 * 60 * 24 * 7);
    }

    redis.zIncrBy(HOME_POPULAR_ANNOUNCE_KEY, 1, stringAnnounceId);
    redis.expire(HOME_POPULAR_ANNOUNCE_KEY, 60 * 60 * 24);

    return null;
});

중요한 주의사항

Pipeline 안에서는 opsForList(), opsForZSet() 같은 high-level API를 사용할 수 없습니다.

반드시 connection.lPush(), connection.zIncrBy() 같은 low-level API를 사용해야 합니다.

처음에 이걸 모르고 기존 코드를 그대로 옮겼다가 에러를 만났습니다 😅


Pipeline 동작 원리

Pipeline은 명령을 즉시 전송하지 않고 버퍼에 쌓아뒀다가 콜백이 끝나는 시점에 한 번에 Redis 서버로 전송합니다.

executePipelined의 콜백 안에서 명령을 호출하면 버퍼에 기록되고, 콜백이 return null을 반환하는 순간 모든 명령이 한 번에 날아갑니다. return null은 각 명령의 결과를 지금 당장 사용하지 않겠다는 의미입니다.

익명 클래스로 풀어서 쓰면 아래처럼 됩니다.

 
 
java
RedisCallback<Object> callback = new RedisCallback<Object>() {
    @Override
    public Object doInRedis(RedisConnection connection) throws DataAccessException {
        StringRedisConnection redis = new DefaultStringRedisConnection(connection);

        redis.lPush("key", "value");
        redis.expire("key", 3600);

        return null; // 결과 지금 안 씀
    }
};

stringRedisTemplate.executePipelined(callback);

람다 표현식은 이 익명 클래스를 간결하게 줄인 것입니다.

 

단계별 의미

  1. RedisCallback <Object>
    • 인터페이스로, RedisConnection을 받아서 원하는 작업을 수행하도록 약속함
    • Object는 doInRedis의 반환값 타입
  2. doInRedis(RedisConnection connection)
    • 실제 Redis 서버와 통신할 때 사용되는 커넥션 객체
    • 모든 Redis 명령은 이 connection을 통해 호출
  3. DefaultStringRedisConnection redis = new DefaultStringRedisConnection(connection);
    • RedisConnection은 byte [] 단위로 작동
    • 문자열(String) 명령(lPush, lRem, zIncrBy)을 쉽게 쓰려고 래퍼(wrapper) 씌운 것
    • 이제 redis.lPush("key", "value")처럼 바로 문자열 사용 가능
  4. Redis 명령 호출
    • lPush, lTrim, lRem, zIncrBy, expire 등을 pipeline 안에서 실행
    • pipeline 안에서 호출된 명령들은 실제 서버에 즉시 보내지지 않고 버퍼에 모임
    • pipeline 종료 시 한 번에 서버로 전송 → 성능 최적화
  5. return null
    • doInRedis는 반환값이 필요하지만 pipeline에서는 결과를 바로 안 쓸 때 null 반환 가능
    • pipeline이 반환하는 건 각 명령의 결과 리스트지만, 여기서는 사용 안 함

 

그림으로  이해하기 


향후 개선 방향

현재 Redis 작업이 @Transactional 내부에 묶여 있는 구조가 남아있는 문제입니다. Redis 서버에 에러가 발생하면 정작 중요한 DB 로직까지 영향을 받을 수 있습니다.

Spring Event를 활용하면 DB 트랜잭션이 성공적으로 끝난 시점에 비동기로 Redis 로직을 실행할 수 있습니다. 이 구조로의 전환을 다음 개선 과제로 두고 있습니다.

 


정리

Redis는 단순히 빠른 저장소가 아닙니다. 네트워크 비용까지 고려한 호출 설계가 중요합니다. 명령 하나하나는 빠르지만 매번 따로 보내면 네트워크가 병목이 됩니다.

캐시 데이터의 중요도에 따라 전략도 달라집니다. 정합성이 중요하면 Lua Script, 성능 개선이 목적이고 정합성이 덜 중요하면 Pipeline, Redis 로직이 DB 트랜잭션과 엮여 있으면 Spring Event 기반 비동기 처리를 고려하면 됩니다.