ss -ti에서 sacked는 바로 오르는데 retrans는 왜 늦게 오를까부하가 걸린 연결에서 ss -ti를 찍어보면 sacked 카운터가 먼저 오르고 나서야 한참 뒤에 retrans가 따라 오르는 구간이 있다. SACK를 켜놓았으니 손실난 세그먼트를 receiver가 알려주는 순간 바로 재전송될 거라 생각했는데, 실제로는 그 사이에 눈에 보이지 않는 지연이 낀다. 이 지연이 버그가 아니라 설계라는 걸 알려면, SACK 옵션(RFC 2018)과 그 정보로 "무엇을 재전송할지" 정하는 알고리즘(RFC 6675)이 서로 다른 문서, 다른 책임이라는 걸 먼저 짚어야 한다.
RFC 2018이 정의하는 건 receiver가 "지금까지 비연속으로 받은 블록이 어디부터 어디까지인가"를 sender에게 advisory하게 알려주는 옵션 포맷뿐이다. 이 정보를 받은 sender가 "그래서 무엇을 손실로 볼지, 무엇을 먼저 재전송할지"를 정하는 절차는 RFC 6675(옛 RFC 3517)가 별도로 정의한다. 두 문서를 하나로 뭉뚱그리면 SACK 블록이 도착한 순간 재전송이 나가야 한다고 기대하게 되는데, 실제로는 sender가 scoreboard라는 내부 상태에 SACK 정보를 누적하고, 충분한 증거가 쌓여야 IsLost()가 손실을 확정한다.
SACK 옵션은 receiver가 수신 큐를 advisory하게 알려주는 통신 규약일 뿐이고, sender는 그 정보를 scoreboard에 쌓아 별도의 판정 절차를 돌린다.
SACK가 "확정"이 아니라 "참고 정보"로 취급되는 이유도 여기서 나온다. receiver가 SACK했던 데이터를 나중에 폐기(renege)해도 프로토콜 위반이 아니라서, RFC 6675는 cumulative ACK가 오기 전까지 그 데이터를 재전송 버퍼에서 지우면 안 된다고 못박는다.
IsLost(SeqNum)은 두 조건 중 하나만 참이면 true다: 그 위로 비연속 SACKed 구간이 DupThresh(기본 3)개 이상 쌓였거나, SACKed된 바이트 수가 (DupThresh-1) * SMSS를 넘겼거나. 이 두 번째 조건 때문에 "구간이 3개 모이기 전에도 손실이 확정될 수 있다"는 게 잘 알려지지 않은 지점이다.
SMSS 1000바이트, seq 1부터 10개 세그먼트(1~1000, 1001~2000, …)를 보냈다고 하자. 세그먼트 3(2001~3000)과 세그먼트 6(5001~6000)만 유실됐다.
| 도착 순서 | 받은 세그먼트 | seq 2001 위 SACKed 바이트 | 비연속 구간 수 | IsLost(2001) |
|---|---|---|---|---|
| 1 | 4 (3001~4000) | 1000 | 1 | false |
| 2 | 5 (4001~5000) | 2000 (병합돼 1구간) | 1 | false |
| 3 | 7 (6001~7000) | 3000 | 2 | true (바이트 조건) |
구간 수는 3에 한 번도 못 미치지만, 세그먼트 7이 도착한 순간 SACKed 바이트가 2000을 넘어서면서 바이트 조건으로 손실이 확정된다. 세그먼트 6(5001~6000) 쪽은 사정이 다르다. 세그먼트 7, 8이 도착해도 위로 쌓인 바이트가 2000을 못 넘고, 세그먼트 9(8001~9000)가 도착해야 3000바이트로 넘어가 확정된다 — 같은 버스트 안에서도 뒤쪽 손실은 앞쪽보다 한 세그먼트만큼 늦게 확정되는 셈이다. ss -ti의 sacked가 먼저 뛰고 retrans가 늦게 뛰는 그 간격이 바로 이 누적 구간이다.
NextSeg()가 재전송 대상을 고르는 5단계IsLost()가 true를 반환해도 그 세그먼트가 곧바로 나가는 건 아니다. NextSeg()가 매 전송 기회마다 다음 순서로 후보를 찾는다.
1) HighRxt보다 크고 SACK 범위 안이고 IsLost()==true인 가장 작은 세그먼트
→ 재전송 (손실 확정)
2) 1이 없고 새 데이터 + 윈도우 여유 있음 → 새 데이터 전송
3) 1,2 없지만 SACK 범위 안인데 IsLost는 false인 세그먼트
→ "SHOULD" 재전송 (last resort)
4) 그마저 없고 un-SACKed 데이터가 남음
→ recovery 진입당 1회, rescue retransmission
5) 다 실패 → 반환 없음
위 표의 예시로 이어가면, 세그먼트 7이 도착한 시점에 NextSeg()는 1번 규칙으로 세그먼트 3을 골라 재전송하고 HighRxt를 3000으로 올린다. 세그먼트 6은 아직 IsLost()가 false라서 이 시점엔 후보가 아니고, 3번 규칙(last resort)의 대상일 뿐이다. 세그먼트 9가 도착해 세그먼트 6도 손실로 확정되면 그제서야 1번 규칙으로 재전송된다. "SACK 정보 → scoreboard 갱신 → 규칙 평가"가 매 ACK마다 반복되는 이 구조 때문에, 같은 recovery 구간 안에서도 세그먼트별로 재전송 시점이 갈린다.
boolean isLost(long seqNum, NavigableMap<Long, Long> sackedRanges,
int dupThresh, int smss) {
int discontiguous = 0;
long sackedBytesAbove = 0;
for (var e : sackedRanges.tailMap(seqNum, false).entrySet()) {
discontiguous++;
sackedBytesAbove += e.getValue() - e.getKey();
}
return discontiguous >= dupThresh
|| sackedBytesAbove > (long) (dupThresh - 1) * smss;
}
SACK 블록이 도착한 즉시 재전송이 나가지 않는 건 버그가 아니라, "advisory 정보 → scoreboard 누적 → IsLost()의 이중 조건 판정 → NextSeg()의 우선순위 평가"라는 4단계 파이프라인이 매번 돌기 때문이다. 두 조건(구간 수, 바이트량) 중 어느 쪽이 먼저 넘느냐에 따라 같은 버스트 안에서도 세그먼트별 확정 시점이 달라진다. ss -ti로 sacked와 retrans가 벌어지는 구간을 보면 이 파이프라인이 눈앞에서 도는 걸 확인할 수 있다. 이 손실 복구는 "무엇을 재전송할지"만 정하고 "얼마나 보낼지"(ssthresh/cwnd 조정)는 RTO 계산과 같은 별도의 혼잡 제어 계층에 넘긴다는 점도 원문이 반복해서 선을 긋는 부분이다. 다음에 더 볼 만한 지점은 D-SACK(RFC 2883, "잘못된 재전송"을 알리는 반대 방향 확장)과, 이 pipe 계산이 CUBIC·BBR 같은 실제 혼잡 제어 알고리즘과 어떻게 맞물리는지다.