왜 네트워크 열화 테스트가 필요한가
대부분의 서비스는 개발자의 유선 LAN이나 같은 리전 내부처럼 빠르고 안정적인 네트워크에서 검증된다. 그러나 실제 사용자는 3G/LTE, 혼잡한 카페 와이파이, 다른 대륙의 API를 거친다. 이 간극 때문에 로컬에서 멀쩡하던 코드가 프로덕션에서 타임아웃, 커넥션 풀 고갈, 재시도 폭주를 일으킨다. RTT가 5ms에서 200ms로 늘어나는 순간 동기 호출 체인의 지연은 선형이 아니라 배수로 누적된다. 이런 문제를 사전에 재현하려면 커널 레벨에서 대역폭과 지연을 인위적으로 주입할 수 있어야 하고, 리눅스에서는 tc(traffic control)가 그 표준 도구다.
tc의 기본 구조: qdisc, class, filter
tc는 인터페이스의 송신 큐에 qdisc(queueing discipline)를 붙여 패킷 처리 정책을 결정한다. qdisc는 크게 두 종류다. netem은 지연·손실·재정렬을 흉내 내고, tbf나 htb는 대역폭을 제한한다. 주의할 점은 tc가 기본적으로 egress(송신) 방향만 제어한다는 것이다. 수신 방향을 조이려면 IFB(Intermediate Functional Block) 가상 장치로 트래픽을 리다이렉트해야 한다.
지연 주입: netem으로 RTT 흉내 내기
가장 흔한 시나리오는 순수 지연 추가다. 실제 네트워크는 고정 지연이 아니라 지터(jitter)를 동반하므로 편차와 상관계수를 함께 주는 것이 현실적이다.
# eth0 송신에 100ms ± 20ms 지연, 0.1% 패킷 손실 추가
sudo tc qdisc add dev eth0 root netem delay 100ms 20ms 25% loss 0.1%
# 현재 적용 상태 확인
tc qdisc show dev eth0
# 파라미터 변경(add 대신 change)
sudo tc qdisc change dev eth0 root netem delay 200ms 40ms
# 원복(테스트 후 반드시 삭제)
sudo tc qdisc del dev eth0 root
여기서 delay 100ms 20ms 25%는 평균 100ms, 표준편차 20ms, 다음 패킷과의 상관 25%를 뜻한다. 상관값을 주면 지연이 뚝뚝 끊기지 않고 실제 무선망처럼 완만하게 요동친다.
대역폭 셰이핑: htb로 상한 걸기
지연과 대역폭을 함께 재현하려면 계층 구조가 필요하다. htb로 대역폭 상한을 두고, 그 하위에 netem을 붙이는 조합이 가장 유연하다.
# 루트에 htb, 전체 상한 1mbit
sudo tc qdisc add dev eth0 root handle 1: htb default 10
sudo tc class add dev eth0 parent 1: classid 1:10 htb \
rate 1mbit ceil 1mbit burst 15k
# 셰이핑된 클래스 아래에 지연 주입
sudo tc qdisc add dev eth0 parent 1:10 handle 20: \
netem delay 80ms 15ms
# 특정 목적지 포트(8080)만 이 클래스로 필터링
sudo tc filter add dev eth0 protocol ip parent 1:0 prio 1 \
u32 match ip dport 8080 0xffff flowid 1:10
필터를 쓰면 SSH 세션은 그대로 두고 애플리케이션 트래픽만 열화시킬 수 있어, 원격 서버에서 실수로 자기 접속을 끊는 사고를 막는다.
qdisc 선택 비교
| qdisc | 주 용도 | 특징 |
|---|---|---|
| netem | 지연·손실·재정렬 | 대역폭 제한 기능은 약함, 열화 시뮬레이션 전용 |
| tbf | 단순 대역폭 제한 | 설정이 간단, 단일 상한만 필요할 때 |
| htb | 계층적 대역폭 배분 | 클래스·필터로 세밀 제어, netem과 조합 용이 |
실제 검증: 재현 결과 측정하기
tc를 걸었으면 의도한 값이 실제로 나오는지 확인해야 한다. 아래처럼 반복 측정으로 지연 분포를 뽑아두면 회귀 테스트에도 쓸 수 있다.
import subprocess, statistics, re
samples = []
for _ in range(20):
out = subprocess.run(
["ping", "-c", "1", "-W", "2", "10.0.0.5"],
capture_output=True, text=True).stdout
m = re.search(r"time=([\d.]+)", out)
if m:
samples.append(float(m.group(1)))
print(f"n={len(samples)} avg={statistics.mean(samples):.1f}ms "
f"p95={sorted(samples)[int(len(samples)*0.95)]:.1f}ms")
운영 시 주의점
- 반드시 원복하라. 테스트 후
tc qdisc del dev eth0 root를 잊으면 서버가 상시 느려진 채 방치된다. 스크립트에trap이나finally로 삭제를 걸어두는 것이 안전하다. - 루프백은 대상이 아니다.
127.0.0.1트래픽은 eth0을 타지 않으므로 셰이핑되지 않는다. 컨테이너 간 통신이면 해당 veth나 브리지 인터페이스를 지정해야 한다. - 수신 방향은 IFB가 필요하다. egress만으로는 다운로드 대역폭이 재현되지 않는다.
- 공유 호스트 주의. 인터페이스 단위로 적용되므로 같은 장비의 다른 서비스까지 영향받는다. 격리가 필요하면 network namespace 안에서 tc를 거는 편이 깔끔하다.
tc는 애플리케이션 코드 변경 없이 커널에서 네트워크 조건을 재현하므로, 타임아웃 값과 재시도 정책을 실증적으로 검증하는 가장 저렴한 방법이다. 다만 강력한 만큼 원복 누락이 곧 장애로 이어진다는 점을 항상 염두에 두어야 한다.