네트워크 장애 환경 구현하기 : Toxiproxy

이준우·2025년 12월 9일

개요

라이브러리 Redisson의 학습 테스트 도중 네트워크 장애가 발생했을때 어떻게 동작하는지 파악할 필요가 생김

TestContainer를 사용했기에 pause() 와 같은 일시정지를 사용하여 테스트가 가능했지만, 몇몇 케이스는 stop()을 해야만 진행할 수 있었다.(RedisConnectionException)

TestContainer의 경우 stop() 이후 start()를 시작한 경우, Port 충돌을 방지하기 위해 랜덤 Port를 할당하게 되는데 그렇게 될 경우 Spring을 시작할 때 설정한 Redisson의 Redis 주소와 달라져서 이후 다른 테스트에 영향을 주었다.

따라서 다른 방안이 필요했고 그렇다가 찾은 것이 Toxiproxy이다.

Toxiproxy란?

https://github.com/Shopify/toxiproxy
Toxiproxy is a framework for simulating network conditions. It's made specifically to work in testing, CI and development environments, supporting deterministic tampering with connections, but with support for randomized chaos and customization. Toxiproxy is the tool you need to prove with tests that your application doesn't have single points of failure.

Toxiproxy는 네트워크 조건을 시뮬레이션하기 위한 프레임워크입니다. 이 프레임워크는 테스트, CI 및 개발 환경에서 작동하도록 특별히 설계되었으며, 연결에 대한 결정론적 변조를 지원하지만 무작위 혼돈과 커스터마이징을 지원합니다. Toxiproxy는 애플리케이션에 단일 장애 지점이 없다는 것을 테스트를 통해 증명해야 하는 도구입니다.

  • 결정론적이란? : 무작위 장애가 아닌 예측 가능한 방식으로 장애를 재현할 수 있어 테스트 결과가 일관적임
  • 단일 장애 지점 검증이란? : 특정 인프라에 장애가 발생해도 전체 시스템이 다운되지 않는지 검증
  • 여기서 말하는 Toxic이란? : 네트워크 장애를 시뮬레이션하는 특정 유형의 장애 효과 또는 장애 시나리오를 의미
    • Latency (지연)
    • Bandwidth (대역폭 제한)
    • Slow close (느린 연결 종료)
    • Timeout (타임아웃)
    • Slicer (패킷 분할)
    • Limit data (데이터 제한)

어떻게 동작하는가?

Redis, MySQL 같은 인프라 앞단에 TCP 프록시 역할을 하는 Toxiproxy Server(Container)가 배치됩니다.

구성:

  • Toxiproxy Server는 Redis/MySQL의 실제 포트를 내부적으로 바인딩(포트 포워딩)하여 프록시 포트를 노출합니다.
  • 애플리케이션은 Redis/MySQL에 직접 연결하지 않고, Toxiproxy가 노출한 프록시 포트로 연결합니다.

제어 방식:

  • ToxiproxyClient를 통해 Toxiproxy Server에 명령을 내려 네트워크 장애(Toxic)를 주입하거나 제거할 수 있습니다.
  • 예: 지연 추가, 연결 끊기, 대역폭 제한 등

연결 흐름: 애플리케이션 → Toxiproxy 프록시 포트 → (장애 주입 가능) → Redis/MySQL 실제 포트

설정

TestContainer는 이미 설정되어있다고 가정

dependencies {
		...
    testImplementation("org.testcontainers:testcontainers-toxiproxy:2.0.2")
    ...
}

해당 라이브러리에 ToxiproxyContainerToxiproxyClient가 포함

@Configuration
class ToxiproxyTestContainerConfig {
    companion object {
		    // ToxiproxyContainer 내부에 Redis를 바인딩할 포트 번호
        const val TOXI_REDIS_PORT = 8666

				// ToxiproxyContainer 설정을 위해서는 같은 네트워크여야 함
        val toxiNetwork: Network = Network.newNetwork()

        val toxiproxy = ToxiproxyContainer("ghcr.io/shopify/toxiproxy")
            .apply {
                withNetwork (toxiNetwork) // 네트워크 설정
                withReuse(true)
                start()
            }

				// Toxiproxy 제어를 위한 클라이언트 (관리 포트: 8474)
        val toxiproxyClient = ToxiproxyClient(toxiproxy.host, toxiproxy.controlPort)
    }
}
@Configuration
class RedisTestContainersConfig() {
    companion object {
        private const val REDIS_PORT = 6379

        private val redisContainer = GenericContainer(DockerImageName.parse("redis:7-alpine"))
            .apply {
                withExposedPorts(REDIS_PORT)
                withNetwork(toxiNetwork) // ToxiproxyContainer와 같은 네트워크
                withNetworkAliases("redis") // 네트워크 내 별칭 설정
                withReuse(true)
                start()
            }

				// Toxiproxy에 Redis 프록시 생성
        val redisProxy = toxiproxyClient.createProxy(
            "redis",                      // 프록시 이름
            "0.0.0.0:$TOXI_REDIS_PORT",  // Toxiproxy 내부 listen 주소
            "redis:$REDIS_PORT"           // Redis 컨테이너 주소 (네트워크 별칭 사용)
        )
        
        init {
		        // ToxiproxyContainer의 host
            System.setProperty("spring.data.redis.host", toxiproxy.host)
            // ToxiproxyContainer의 내부 포트 TOXI_REDIS_PORT(8666)에 바인딩 된 외부 포트
            System.setProperty(
                "spring.data.redis.port",
                toxiproxy.getMappedPort(TOXI_REDIS_PORT).toString()
            )
        }
    }
}
  • 네트워크 구성
    • Toxiproxy와 Redis는 같은 네트워크를 공유 → toxiNetwork
    • 이를 통해 Redis 컨테이너를 네트워크 별칭으로 참조할 수 있다. → redis
  • 연결 흐름
    • 애플리케이션 → ToxiproxyContianer (호스트 랜덤 포트 → 내부 포트(8666) → Redis (redis:6379)
init {
    // ToxiproxyContainer의 host
    System.setProperty("spring.data.redis.host", toxiproxy.host)
    // ToxiproxyContainer의 내부 포트 TOXI_REDIS_PORT(8666)에 바인딩 된 외부 포트
    System.setProperty(
        "spring.data.redis.port",
        toxiproxy.getMappedPort(TOXI_REDIS_PORT).toString()
    )
}
  • 해당 부분을 보면 RedisContainer로 직접 연결하는것이 아닌 ToxiproxyContainer로 접근
  • 이렇게 설정을 해야지 Toxiproxy의 네트워크 장애 시뮬레이션을 활용할 수 있다.

ToxiproxyClient의 ToxiproxyContainer 제어 메서드 with Claude

toxics().latency()

지연 추가

latency(name: String, direction: ToxicDirection, latency: Long)
  • name: Toxic 식별자
  • direction: 트래픽 방향
    • DOWNSTREAM: 서버 → 클라이언트 (응답)
    • UPSTREAM: 클라이언트 → 서버 (요청)
  • latency: 지연 시간 (밀리초)
    // 응답에 1000ms 지연 추가
    proxy.toxics().latency("slow-response", ToxicDirection.DOWNSTREAM, 1000)
    
    // 요청에 500ms 지연 추가
    proxy.toxics().latency("slow-request", ToxicDirection.UPSTREAM, 500)
  • 추가 속성:
    val toxic = proxy.toxics().latency("lag", ToxicDirection.DOWNSTREAM, 1000)
    toxic.setJitter(200) // ±200ms 랜덤 변동
    // 실제 지연: 800ms ~ 1200ms 사이

toxics().timeout()

타임아웃 발생

timeout(name: String, direction: ToxicDirection, timeout: Long)
  • name: Toxic 식별자
  • direction: 트래픽 방향
  • timeout: 타임아웃 발생까지 시간 (밀리초)
    • 0: 즉시 타임아웃

    • > 0: 해당 시간 후 타임아웃

      // 즉시 타임아웃
      proxy.toxics().timeout("instant-timeout", ToxicDirection.DOWNSTREAM, 0)
      
      // 5초 후 타임아웃
      proxy.toxics().timeout("delayed-timeout", ToxicDirection.DOWNSTREAM, 5000)

toxics().bandwidth()

대역폭 제한

bandwidth(name: String, direction: ToxicDirection, rate: Long)
  • name: Toxic 식별자
  • direction: 트래픽 방향
  • rate: 대역폭 (KB/s)
    // 다운로드 속도를 100 KB/s로 제한
    proxy.toxics().bandwidth("slow-download", ToxicDirection.DOWNSTREAM, 100)
    
    // 업로드 속도를 50 KB/s로 제한
    proxy.toxics().bandwidth("slow-upload", ToxicDirection.UPSTREAM, 50)
    

toxics().slowClose()

연결 종료 지연

slowClose(name: String, direction: ToxicDirection, delay: Long)
  • name: Toxic 식별자
  • direction: 트래픽 방향
  • delay: 연결 종료 지연 시간 (밀리초)
    // 연결 종료를 3초 지연
    proxy.toxics().slowClose("delayed-close", ToxicDirection.DOWNSTREAM, 3000)
    // 클라이언트가 연결 종료를 요청해도 3초간 유지됨

toxics().limitData()

전송 데이터량 제한

limitData(name: String, direction: ToxicDirection, bytes: Long)
  • name: Toxic 식별자
  • direction: 트래픽 방향
  • bytes: 전송할 최대 바이트 수
    // 1KB만 전송 후 연결 종료
    proxy.toxics().limitData("cut-response", ToxicDirection.DOWNSTREAM, 1024)
    
    // 큰 파일 업로드 중단 시뮬레이션
    proxy.toxics().limitData("upload-fail", ToxicDirection.UPSTREAM, 500_000)
    

toxics().slicer()

데이터 패킷을 의도적으로 작은 조각들로 나누어 전송

slicer(name: String, direction: ToxicDirection, averageSize: Long, sizeVariation: Long, delay: Long)
  • name: Toxic 식별자
  • direction: 트래픽 방향
  • averageSize: 평균 슬라이스 크기 (바이트)
  • sizeVariation: 크기 변동폭 (바이트)
  • delay: 슬라이스 간 지연 (마이크로초)
// 데이터를 작은 조각으로 나눠 천천히 전송
proxy.toxics().slicer(
    "slow-stream",
    ToxicDirection.DOWNSTREAM,
    64,    // 평균 64바이트씩
    32,    // ±32바이트 변동
    10000  // 10ms 간격
)

toxics().resetPeer()

resetPeer(name: String, direction: ToxicDirection, timeout: Long)
  • name: Toxic 식별자
  • direction: 트래픽 방향
  • timeout: 리셋 발생까지 시간 (밀리초)
// 즉시 연결 강제 종료 (RST 패킷)
proxy.toxics().resetPeer("connection-reset", ToxicDirection.DOWNSTREAM, 0)

// 2초 후 연결 강제 종료
proxy.toxics().resetPeer("delayed-reset", ToxicDirection.DOWNSTREAM, 2000)

ToxicDirection 상세

enum class ToxicDirection {
    UPSTREAM,    // 클라이언트 → 서버 (요청)
    DOWNSTREAM   // 서버 → 클라이언트 (응답)
}

사용 예시:

// 응답만 느리게
proxy.toxics().latency("slow-response", ToxicDirection.DOWNSTREAM, 1000)

// 요청과 응답 둘 다 느리게
proxy.toxics().latency("slow-request", ToxicDirection.UPSTREAM, 500)
proxy.toxics().latency("slow-response", ToxicDirection.DOWNSTREAM, 500)

실제 테스트에서 활용

@Test
fun `batch() 중 네트워크 Timeout 장애가 발생했을 경우`() {
    val key = "product:1"

    val batch = redissonClient.createBatch(
        BatchOptions.defaults().executionMode(BatchOptions.ExecutionMode.IN_MEMORY)
            .responseTimeout(1, TimeUnit.SECONDS)
    )
    batch.getAtomicLong(key).addAndGetAsync(1L)

    // DOWNSTREAM -> 응답 자체가 안옴
    val toxi = redisProxy.toxics().timeout("timeout", ToxicDirection.DOWNSTREAM, 0)

    val error = assertThrows<RedisTimeoutException> { batch.execute() }
    logger.info { "error message: ${error.message}" }

    toxi.remove()

    val result = redissonClient.getAtomicLong(key).get()
    // 1회 + 재시도 기본값 4회 = 5회
    assertEquals(5L, result)
}

방향을 DOWNSTRAM으로 설정하여 요청은 갔지만 응답이 안오는 경우를 테스트하였다.

그 결과, 애플리케이션은 RedisTimeoutException 예외를 던졌지만, Redis에는 재시도 횟수까지 총 5회 명령어가 입력되어 결괏값이 5가 되었다.


@Test
fun `batch() 중 네트워크 connection 장애가 발생했을 경우`() {
    val key = "product:1"

    val batch = redissonClient.createBatch(
        BatchOptions.defaults().executionMode(BatchOptions.ExecutionMode.IN_MEMORY)
            .responseTimeout(1, TimeUnit.SECONDS)
    )
    batch.getAtomicLong(key).addAndGetAsync(1L)

    redisProxy.disable()

    val error = assertThrows<RedisConnectionException> { batch.execute() }
    logger.info { "error message: ${error.message}" }

    redisProxy.enable()

    val result = redissonClient.getAtomicLong(key).get()
    assertEquals(0L, result)
}

Redis 서버가 아에 죽은 상황을 가정하여 프록시 서버를 disable() 처리 하였다.

이번에는 서버가 데이터 자체를 받지 못해서 0L을 반환하였다.

  • getAtomicLong(key)의 반환값인 RAtomicLong은 키가 없을 경우 0을 반환한다는데 진짜 인지 확인이 필요하다.

결론

하나의 테스트케이스를 위해 Toxiproxy 라는 라이브러리를 추가해야하나 말아야하나 고민이 꽤 컸다.
그래서 최대한 TestContainer를 활용하여 Docker에 명령어를 보내 네트워크 장애와 유사한 환경을 만들기 위해서 노력했지만 잘 안됐다.(stop()/start() , 방화벽 설정, iptable 설정 등등…)

결국 마지막 남은 해결책으로 Toxiproxy를 설정하기로 결정하였고, 무엇을 할 수 있는지 어떻게 동작하는지 학습했다. ToxiproxyClient에는 많은 메서드가 존재하는데 이런 메서드가 왜 존재하는지 생각을 해보면 네트워크에는 그만큼 많은 변수가 존재한다는 의미로 해석할 수 있다.

ToxiproxyClient의 메서드를 통해서 발생할 수 있는 네트워크 예외 유형에 대해 공부할 수 있어서 나름 뜻깊었던 시간이었다.

참고자료

https://java.testcontainers.org/modules/toxiproxy/

https://github.com/Shopify/toxiproxy

Claude

profile
잘 살고 싶은 사람

0개의 댓글