라이브러리 Redisson의 학습 테스트 도중 네트워크 장애가 발생했을때 어떻게 동작하는지 파악할 필요가 생김
TestContainer를 사용했기에 pause() 와 같은 일시정지를 사용하여 테스트가 가능했지만, 몇몇 케이스는 stop()을 해야만 진행할 수 있었다.(RedisConnectionException)
TestContainer의 경우 stop() 이후 start()를 시작한 경우, Port 충돌을 방지하기 위해 랜덤 Port를 할당하게 되는데 그렇게 될 경우 Spring을 시작할 때 설정한 Redisson의 Redis 주소와 달라져서 이후 다른 테스트에 영향을 주었다.
따라서 다른 방안이 필요했고 그렇다가 찾은 것이 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 프록시 포트 → (장애 주입 가능) → Redis/MySQL 실제 포트
TestContainer는 이미 설정되어있다고 가정
dependencies {
...
testImplementation("org.testcontainers:testcontainers-toxiproxy:2.0.2")
...
}
해당 라이브러리에 ToxiproxyContainer와 ToxiproxyClient가 포함
@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()
)
}
}
}
toxiNetworkredisinit {
// 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()
)
}
지연 추가
latency(name: String, direction: ToxicDirection, latency: Long)
DOWNSTREAM: 서버 → 클라이언트 (응답)UPSTREAM: 클라이언트 → 서버 (요청)// 응답에 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 사이타임아웃 발생
timeout(name: String, direction: ToxicDirection, timeout: Long)
0: 즉시 타임아웃
> 0: 해당 시간 후 타임아웃
// 즉시 타임아웃
proxy.toxics().timeout("instant-timeout", ToxicDirection.DOWNSTREAM, 0)
// 5초 후 타임아웃
proxy.toxics().timeout("delayed-timeout", ToxicDirection.DOWNSTREAM, 5000)
대역폭 제한
bandwidth(name: String, direction: ToxicDirection, rate: Long)
// 다운로드 속도를 100 KB/s로 제한
proxy.toxics().bandwidth("slow-download", ToxicDirection.DOWNSTREAM, 100)
// 업로드 속도를 50 KB/s로 제한
proxy.toxics().bandwidth("slow-upload", ToxicDirection.UPSTREAM, 50)
연결 종료 지연
slowClose(name: String, direction: ToxicDirection, delay: Long)
// 연결 종료를 3초 지연
proxy.toxics().slowClose("delayed-close", ToxicDirection.DOWNSTREAM, 3000)
// 클라이언트가 연결 종료를 요청해도 3초간 유지됨전송 데이터량 제한
limitData(name: String, direction: ToxicDirection, bytes: Long)
// 1KB만 전송 후 연결 종료
proxy.toxics().limitData("cut-response", ToxicDirection.DOWNSTREAM, 1024)
// 큰 파일 업로드 중단 시뮬레이션
proxy.toxics().limitData("upload-fail", ToxicDirection.UPSTREAM, 500_000)
데이터 패킷을 의도적으로 작은 조각들로 나누어 전송
slicer(name: String, direction: ToxicDirection, averageSize: Long, sizeVariation: Long, delay: Long)
// 데이터를 작은 조각으로 나눠 천천히 전송
proxy.toxics().slicer(
"slow-stream",
ToxicDirection.DOWNSTREAM,
64, // 평균 64바이트씩
32, // ±32바이트 변동
10000 // 10ms 간격
)
resetPeer(name: String, direction: ToxicDirection, timeout: Long)
// 즉시 연결 강제 종료 (RST 패킷)
proxy.toxics().resetPeer("connection-reset", ToxicDirection.DOWNSTREAM, 0)
// 2초 후 연결 강제 종료
proxy.toxics().resetPeer("delayed-reset", ToxicDirection.DOWNSTREAM, 2000)
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