Toxiproxy로 Redis 장애를 주입해 캐시 fallback 검증하기 — 테스트 범위의 카오스 엔지니어링

@yunhobb· March 20, 2026 · 4 min read

저희 서비스는 조회 캐시와 분산락을 Redis로 처리합니다. Redis 장애로 timeout이 났을 때 조회 캐시는 원본(RDB)으로 우회하는 fallback(장애 시 대체 경로)을 두었는데, 이 fallback이 실제 장애에서 동작하는지는 확인하지 못했습니다. mock으로 예외를 던지는 테스트는 제가 의도한 예외를 확인할 뿐이었습니다.

그래서 카오스 엔지니어링(chaos engineering)의 방식을 적용했습니다. 정상 상태(steady state)를 정의하고, "이 장애가 나도 정상 상태는 유지된다"를 가설로 세운 뒤 장애를 주입해 가설이 유지되는지 관찰하는 절차입니다. 원래는 운영 환경 전체를 대상으로 하지만, 여기서는 "Redis만 장애, DB는 정상"으로 범위를 좁히고 주입하는 장애도 Redis 지연·타임아웃 하나로 한정해 테스트 수준에서 적용했습니다.


1. 왜 toxiproxy인가

"Redis가 장애일 때"를 테스트 안에서 만드는 흔한 두 방법에는 빈틈이 있었습니다.

  • mock으로 RedisConnectionFailureException을 던지면, 제가 고른 예외 타입을 확인할 뿐입니다. 실제 장애에서 Lettuce가 어떤 예외를 내는지는 검증하지 못합니다.
  • Redis 컨테이너를 내리면 연결 거부(connection refused)로 즉시 실패합니다. 정작 곤란한 "연결은 살아 있는데 응답이 오지 않는" 지연·타임아웃은 만들 수 없습니다.

toxiproxy는 애플리케이션과 Redis 사이에 두는 TCP 프록시입니다. 평소엔 트래픽을 통과시키다가, API로 toxic(지연·타임아웃·대역폭 제한 같은 장애 효과)을 런타임에 주입하면 그때부터 그 장애를 재현합니다. 코드도 Redis도 건드리지 않고 그 사이 네트워크만 조작합니다. 검증하려던 지연·타임아웃을 코드 변경 없이 주입할 수 있어 toxiproxy를 골랐습니다.


2. 인프라 구성: docker-compose로 redis + toxiproxy

toxiproxy를 Redis 앞에 세우고, 테스트는 Redis가 아니라 toxiproxy에 접속합니다.

# docker-compose.yml
services:
  redis:
    image: redis:7-alpine
    ports:
      - "6479:6379"

  toxiproxy:
    image: ghcr.io/shopify/toxiproxy:2.9.0
    ports:
      - "6474:8474"   # toxiproxy 제어 API
      - "6480:6380"   # toxiproxy가 노출하는 Redis 프록시
    depends_on:
      - redis

toxiproxy는 포트를 두 개 씁니다. 하나는 toxic을 주입·해제하는 제어 API(8474), 다른 하나는 Redis 트래픽이 지나가는 프록시 포트입니다. 프록시는 테스트 코드에서 만듭니다.

redisProxy = toxiproxyClient.createProxy(
    "redis-proxy",
    "0.0.0.0:6380",   // toxiproxy가 listen 하는 주소 (컨테이너 내부)
    "redis:6379");    // 실제로 전달할 대상 (컨테이너 네트워크의 redis)

주소는 모두 컨테이너 네트워크 기준이라 호스트에서 도는 테스트의 연결 경로는 다음과 같습니다.

테스트(host) → localhost:6480 → toxiproxy(:6380) → redis(:6379)
                localhost:6474 ┘ (제어 API로 toxic 주입/해제)

평소에는 localhost:6480으로 보낸 명령이 Redis까지 도달하고, localhost:6474로 toxic을 추가하면 그 순간부터 이 구간이 막히거나 느려집니다.


3. 테스트 와이어링: 수동 와이어링을 택한 이유

목표는 "부분 실패"입니다. Redis만 장애로 만들고 DB는 정상으로 둬야 fallback 경로가 정확히 동작하는지 볼 수 있습니다. @SpringBootTest는 무겁고 @DataRedisTest는 "Redis만 toxiproxy 포트에 연결하고 DB 레포지토리는 목으로 대체"하는 구성을 넣기 어색했습니다. 그래서 필요한 객체만 직접 생성해 연결한 통합 테스트로 구성했습니다. Redis는 toxiproxy를 거쳐 실제로 띄우고, DB 레포지토리는 Mockito로 목킹합니다.

@Disabled("별도의 docker 환경필요")
@ExtendWith(MockitoExtension.class)
class RedisFallbackToxiProxyTest {

  @BeforeAll
  static void setup() {
    // toxiproxy 제어 클라이언트 + 프록시 생성 (생략)

    RedisStandaloneConfiguration redisConfig = new RedisStandaloneConfiguration();
    redisConfig.setHostName("localhost");
    redisConfig.setPort(6480);                 // ← Redis(6379)가 아니라 toxiproxy 프록시 포트

    LettuceClientConfiguration clientConfig = LettuceClientConfiguration.builder()
        .commandTimeout(Duration.ofSeconds(5)) // ← 타임아웃 만료 경로를 타게 하는 핵심값
        .build();

    connectionFactory = new LettuceConnectionFactory(redisConfig, clientConfig);
    connectionFactory.afterPropertiesSet();
    // redisTemplate / stringRedisTemplate 구성 (직렬화 포함, 생략)
  }
}

핵심은 LettuceConnectionFactory가 Redis 원본(6379)이 아니라 toxiproxy 프록시 포트(6480)에 연결되게 하는 것입니다. 애플리케이션에서는 일반 Redis 연결과 동일하고, Redis로 가는 트래픽만 toxiproxy를 거칩니다. DB 레포지토리는 @Mock으로 두고, 검증 대상 서비스는 목 레포지토리와 실제 Redis 레포지토리를 함께 받아 직접 생성합니다.

clueCacheRedisRepository = new ClueCacheRedisRepository(redisTemplate, objectMapper);
clueDeviceService = new ClueDeviceService(clueDeviceRepository, clueCacheRedisRepository);
//                                          ↑ @Mock(DB)        ↑ 실제(toxiproxy 경유)

ObjectMapper와 직렬화 설정은 운영과 맞췄습니다. 포맷이 다르면 장애가 아니라 직렬화 차이로 테스트가 실패할 수 있기 때문입니다.


4. 장애 주입: timeout toxic 0의 의미

장애는 toxic 하나로 켜고 끕니다.

void disableRedis() throws IOException {
  redisProxy.toxics().timeout("redis-timeout", ToxicDirection.DOWNSTREAM, 0);
}

void enableRedis() throws IOException {
  redisProxy.toxics().get("redis-timeout").remove();
}

toxiproxy의 timeout은 "지정한 시간(ms) 동안 데이터를 멈췄다가 연결을 끊는" toxic인데, 값을 0으로 주면 연결은 유지하되 데이터를 전달하지 않습니다. 이 상태에서 Redis 명령을 보내면 Lettuce는 응답을 기다리다 commandTimeout(5초)이 만료되는 순간 예외를 냅니다. 컨테이너 종료의 "연결 거부(즉시 실패)"와 달리 "연결은 살아 있는데 응답이 없는" 상황을 그대로 재현합니다.

테스트 간 격리는 @AfterEach에서 toxic을 모두 제거하고 캐시를 비워 맞춥니다.

@AfterEach
void cleanToxicsAndRedis() throws IOException {
  for (Toxic toxic : redisProxy.toxics().getAll()) {
    toxic.remove();
  }
  // flushAll로 캐시 비움 (생략)
}

5. 두 가지 가설 검증

같은 방식으로 Redis 장애를 주입한 상태에서 두 경로를 확인했습니다. 경로마다 정상 상태의 정의가 다릅니다. 디바이스 조회는 "Redis가 멈춰도 정상 응답이 나온다"가, QR Seed는 "Redis가 멈추면 예외가 전파된다"가 정상 상태입니다. 같은 장애가 두 경로에서 어떻게 갈리는지가 핵심입니다.

5.1 디바이스 조회 — DB fallback

캐시에 없으면 DB에서 읽어 채우는 경로입니다. Redis가 멈춰도 DB로 우회해 정상 응답이 나와야 합니다.

@Test
void Redis_장애시_DB_fallback으로_정상_응답한다() throws IOException {
  when(clueDeviceRepository.findBySerialNo(serialNo)).thenReturn(Optional.of(entity));

  disableRedis();   // ← Redis만 장애 상태, DB(목)는 정상

  ClueDeviceRedisObject result = clueDeviceService.checkDeviceExistsAndDoCache(serialNo);

  assertThat(result).isNotNull();
  assertThat(result.getDeviceName()).isEqualTo("Fallback Door");
}

캐시 조회가 타임아웃으로 실패해도 DB 목이 응답을 돌려주므로 호출자는 정상 객체를 받습니다.

5.2 QR Seed — 현재는 예외 전파

QR Seed는 의도적으로 다릅니다. DB로 우회하지 않고 예외를 그대로 전파합니다.

@Test
void Redis_장애시_RedisException이_전파된다() throws IOException {
  disableRedis();

  assertThatThrownBy(() -> clueQrSeedService.getQrSeedRedisObject(1L))
      .isInstanceOf(DataAccessException.class);
}

QR Seed는 DB에 대응되는 원본이 없는 데이터에 가깝습니다. 캐시 미스를 메울 원본이 없으니, 틀린 값으로 우회하기보다 예외를 그대로 전파하는 편이 안전할 수 있습니다. 다만 최종 처리 방식은 아직 검토 중이라, 여기서는 "현재 동작은 예외 전파"라는 사실까지만 기록합니다.

@yunhobb
녹차 주도 개발