[DataChannel 딥다이브] Ordered와 Unordered 전송은 언제 갈라지는가
DataChannel 옵션에서 ordered를 처음 봤을 때는 순서를 지켜 주는 쪽이 당연히 더 안전하다고 생각했다. 메시지가 보낸 순서대로 도착하면 애플리케이션이 덜 헷갈릴 것 같았고, 반대로 unordered는 뭔가를 포기한 설정처럼 보였다. 그런데 실시간 전송을 조금만 오래 들여다보면 이 감각이 바로 흔들린다. 어떤 데이터는 순서가 생명이고, 어떤 데이터는 늦게라도 순서대로 오는 순간 이미 쓸모가 줄어든다. Ordered만 있으면 나머지는 따라올 거라고 믿었다.

Ordered가 문제가 된 지점
코드로 돌아간다면 나는 채널을 만들기 전에 로그 이름부터 적을 것 같다. control.sent, control.applied, chunk.received, chunk.gap, event.staleDropped처럼 사건을 작게 쪼갠다. 화면 문구도 같은 상태 전이를 바라보게 한다. 내부에서는 bufferedAmount와 sequence를 보더라도 사용자는 상대 기기가 받는 속도에 맞추는 중, 일부 조각을 다시 확인하는 중 같은 말을 보게 된다. 순서 정책은 작은 예제에서는 차이가 잘 보이지 않는다. 10KB 메시지 몇 개를 보내는 데모라면 ordered 하나로도 충분해 보인다.
하지만 5GB 파일을 보내면서 progress, cancel, retry, preview 이벤트를 같이 처리하면 이야기가 달라진다. 큰 chunk가 줄을 만들고, 사용자의 취소가 늦게 반영되고, 진행률 이벤트가 뒤늦게 도착해 화면을 흔들 수 있다. PonsWarp 같은 파일 전송에서는 제어 메시지가 파일 chunk 뒤에 갇히지 않는 것이 중요하다. 취소, 일시정지, 재시도, 경로 전환은 작은 메시지지만 사용자 경험을 크게 바꾼다.
PonsLink의 이벤트 흐름도 마찬가지다. 모든 이벤트가 같은 순서를 요구하지 않는다. 어떤 것은 반드시 차례대로 처리해야 하고, 어떤 것은 늦게 오면 조용히 버려야 한다. 내가 헷갈렸던 지점은 ordered=true와 ordered=false의 이름이 아니라 메시지마다 다른 책임이었다. 채팅 메시지나 상태 명령은 순서가 뒤집히면 사용자가 이상한 화면을 본다. 반면 실시간 위치, 진행률, 순간 이벤트는 최신 값이 더 중요할 때가 많다.
파일 조각도 단순히 순서대로 받는다고 끝나지 않는다. offset, sequence, checksum, 재조립 정책이 있으면 네트워크가 건넨 순서와 애플리케이션이 복원하는 순서를 분리할 수 있다.

처음에는 순서 보장이 무조건 좋을 줄 알았다
네트워크 다이어그램을 그리다 보면 모든 화살표가 비슷해 보인다. 나는 여기서 자주 실수했다. 후보 교환, 인증, 세션 상태, 실제 파일 바이트가 모두 같은 선처럼 보이면 장애가 났을 때도 같은 곳만 의심한다. DataChannel을 볼 때는 약속을 나르는 선과 payload를 나르는 선을 분리해서 그려야 한다. 내가 적어 두는 기준은 단순하다. 제어 메시지와 파일 chunk는 분리한다.
최소한 envelope에서 type을 분명히 나눈다. 순서가 필요한 메시지에는 sequence를 둔다. 최신성만 필요한 이벤트에는 version이나 timestamp를 둬서 오래된 값을 버린다. 파일 payload에는 offset과 길이, checksum을 넣어 재조립 책임을 명확히 한다. 파일 전송에서는 offset과 sequence가 특히 중요하다. 네트워크 순서대로 조립하면 구현은 쉽지만 한 군데가 막힐 때 전체 흐름이 같이 굳는다. offset 기반으로 저장하면 뒤 chunk를 먼저 받아 둘 수 있다.
대신 누락 구간을 추적하고, checksum으로 최종 무결성을 확인하고, 필요한 경우 재요청해야 한다. 순서 책임을 네트워크에서 애플리케이션으로 옮긴 만큼 코드와 로그가 더 단단해야 한다. 순서 정책을 나누고 나면 바로 reliable과 partially reliable이 따라온다. 순서와 신뢰성은 비슷해 보이지만 같은 문제가 아니다. 순서가 필요 없어도 반드시 도착해야 하는 데이터가 있고, 순서가 맞아도 늦으면 버리는 편이 나은 데이터가 있다.에서 이어에서 계속 정리한다.
세션을 만들고 권한을 확인하는 흐름은 순서가 중요하다. 누가 방에 들어왔는지, 누가 전송을 시작했는지, 취소가 먼저인지 완료가 먼저인지가 뒤집히면 안 된다. 반대로 payload는 경우에 따라 애플리케이션이 재조립할 수 있다. 파일 chunk는 offset이 있으면 12번이 11번보다 먼저 와도 임시 저장소에 놓아둘 수 있다. 하지만 이때도 누락 구간을 찾는 방법과 재요청 정책이 있어야 한다.

순서가 독이 된 순간
그래서 이 노트는 Ordered와 Unordered를 기능 이름으로 외우지 않고, 어떤 데이터가 어떤 기다림을 만들고 어떤 실패를 남기는지 적어 보려는 글이다. PonsLink나 PonsWarp 같은 제품 이야기는 끝부분에서만 짧게 붙인다. 먼저 DataChannel 안에서 순서 보장이 실제로 무슨 책임을 옮기는지부터 정리하는 편이 덜 헷갈린다. 나는 먼저 메시지를 네 묶음으로 나눠 보는 편이다. 첫째, 반드시 순서가 필요한 명령이다.
세션 시작, 취소, 완료, 권한 변경 같은 이벤트가 여기에 들어간다. 둘째, 무결성이 중요한 payload다. 파일 chunk는 여기에 가깝다. 셋째, 최신성이 중요한 상태다. 커서, preview, 진행률, 임시 센서 값처럼 오래된 값은 버릴 수 있다. 넷째, 운영과 관찰을 위한 로그성 이벤트다. 이들은 사용자 화면과 운영 로그 양쪽에서 해석할 수 있어야 한다. 여기서 중요한 점은 ordered가 좋고 unordered가 나쁘다는 식으로 나누지 않는 것이다.
순서 보장은 지연을 만들 수 있고, 순서 포기는 애플리케이션 책임을 늘린다. 어느 쪽이든 공짜는 아니다. 그래서 옵션을 고르기 전에 메시지의 성격을 먼저 적어야 한다. 이 메시지는 늦게 도착해도 가치가 남는가. 이전 메시지가 빠졌을 때 뒤 메시지를 처리해도 되는가. 뒤집힌 이벤트를 버릴 기준이 있는가. 질문이 이 정도로 작아져야 설정값도 의미가 생긴다.
unordered 채널은 이 순서 보장을 내려놓는다. 뒤 메시지가 먼저 애플리케이션에 도착할 수 있다. 위험해 보이지만 모든 데이터에 순서가 필요한 것은 아니다. 예를 들어 현재 마우스 위치를 초당 여러 번 보내는 화면 공유 보조 이벤트라면, 300ms 전 위치가 정확히 순서대로 도착하는 것보다 지금 위치가 빨리 오는 편이 낫다. 진행률도 비슷하다. 41% 뒤에 40%가 늦게 도착하면 버리면 된다.
대신 파일 조각이나 결제 상태처럼 순서와 무결성이 중요한 데이터에는 별도의 기준이 있어야 한다. 좋은 네트워크에서 두 브라우저를 띄우고 테스트하면 ordered도 unordered도 다 그럴듯해 보인다. 문제는 회사 방화벽, 모바일 핫스팟, 오래 켜 둔 탭, 느린 디스크가 섞일 때 나온다. 이때 전송이 느리다라고만 기록하면 다음 디버깅이 거의 처음부터 다시 시작된다.
느림의 원인이 순서 대기인지, 버퍼 적체인지, 수신 저장 지연인지, relay 전환인지 구분해야 한다. DataChannel을 하나만 만들고 모든 메시지를 그 위에 태우면 처음에는 편하다. 연결 객체도 하나고 로그도 하나다. 하지만 파일 chunk, 제어 명령, 진행률, presence 이벤트가 같은 순서 정책을 공유하면 곧 문제가 생긴다. 파일 chunk는 크고 오래 걸린다. 제어 명령은 작지만 늦으면 화면 상태가 틀어진다.
진행률은 오래된 값이 쌓이면 오히려 UI를 뒤로 끌고 간다. DataChannel를 설명할 때 서버/클라이언트/사용자 중 누구 문장으로 말하는지 먼저 고르면 논쟁이 줄었다.
Ordered와 Unordered 경계 나누기
제품 화면까지 같이 그리면 판단이 더 빨라진다. 파일 chunk 때문에 제어 메시지가 밀리는 동안 사용자는 무엇을 보나. unordered 상태 이벤트가 늦게 와서 버려졌을 때 화면은 조용히 최신 값을 유지하나. sequence 없이 재조립하다 누락을 발견하면 재전송을 요청하나, 전체를 실패 처리하나. 이런 질문을 그림 옆에 붙여 두면 기술 옵션이 사용자 경험과 이어진다.
ordered 채널은 메시지를 보낸 순서대로 애플리케이션에 넘긴다. 앞 메시지가 늦어지면 뒤 메시지가 이미 도착했어도 기다릴 수 있다. 이 현상을 head-of-line blocking이라고 부른다. 이름은 딱딱하지만, 사용자가 보는 장면은 단순하다. 큰 파일 chunk 하나가 막혀서 뒤에 있는 작은 제어 메시지까지 늦어지는 상황이다. 그래서 숫자를 볼 때는 평균 속도보다 p95 지연과 최대 대기 시간을 먼저 본다.
특정 chunk가 얼마나 오래 앞을 막았는지, 제어 메시지가 payload 뒤에서 얼마나 기다렸는지, 오래된 이벤트를 몇 개나 버렸는지 확인한다. unordered를 선택했다면 버린 메시지 수와 버린 이유도 로그에 남겨야 한다. 그래야 성능 개선인지 데이터 손실인지 구분할 수 있다. 테스트할 때도 성공 화면만 보지 않는다. 일부러 큰 chunk를 보내는 중에 취소를 눌러 보고, progress 이벤트를 늦게 도착시켜 보고, 같은 sequence가 두 번 들어오는 경우를 만든다.
이때 화면이 뒤로 가지 않고 로그가 원인을 말해 주면 순서 정책이 어느 정도 제품 안으로 들어온 것이다. 반대로 내부 콘솔을 열어야만 상태를 이해할 수 있다면 아직 사용자를 위한 설계가 아니다.
마치며
채널을 둘 이상 쓰는 것도 자연스러운 선택이다. control 채널은 ordered reliable로 두고, progress나 cursor 계열은 별도 채널에서 더 느슨한 정책을 쓸 수 있다. 파일 chunk 채널은 ordered를 켜더라도 backpressure와 재시도 정책을 같이 봐야 한다. 모든 걸 하나의 옵션으로 해결하려고 하면 메시지 성격이 서로 발목을 잡는다. PonsLink나 PonsWarp를 떠올리면 이 구분이 더 현실적으로 느껴진다. 방, 권한, 요청, 취소, 완료 같은 상태는 중앙에서 일관되게 관리해야 한다.
사용자가 보는 약속이기 때문이다. 반면 파일 바이트 자체는 직접 경로로 흐를 수 있고, 경우에 따라 offset 기반으로 재조립할 수 있다. 이 둘을 같은 순서 정책에 묶으면 데모는 편하지만 제품은 쉽게 답답해진다. 실패 이름은 운영용 표현으로도 이어진다. ordered channel HOL 대기, stale progress drop, control message delayed, chunk gap detected처럼 내부 로그에 남길 이름이 있어야 한다. 화면에는 그대로 보여 주지 않더라도, 운영자가 사용자의 문의와 브라우저 로그를 같은 사건으로 묶을 수 있다.
기술적으로 맞는 설정보다 나중에 설명할 수 있는 상태가 더 중요할 때가 많다. Ordered 설정에서 흔한 실패는 큰 메시지가 작은 메시지를 막는 경우다. 예를 들어 100번 chunk가 지연되어 101번 이후가 기다린다면 사용자는 전송이 멈춘 것처럼 느낀다. unordered 설정에서는 반대로 순서가 필요한 명령이 뒤집힐 수 있다. pause보다 resume이 먼저 처리되거나, 오래된 progress 이벤트가 최신 화면을 덮는 식이다.
그래서 unordered를 쓰려면 늦은 이벤트를 버릴 기준과 최신 버전을 판별할 값이 필요하다. 이렇게 나누면 ordered와 unordered는 채널 옵션 하나가 아니라 설계 결과가 된다. 제어 메시지는 ordered reliable 채널을 쓰고, 최신 상태 이벤트는 unordered나 partially reliable 후보로 둔다. 파일 chunk는 순서만 믿지 말고 offset과 checksum을 payload에 넣어 재조립한다. 한 채널로 시작하더라도 envelope에 type, sequence, offset, createdAt 정도는 넣어야 나중에 늦은 메시지를 버릴 수 있다.
'[DataChannel 딥다이브 Ordered와 Unordered 전송은 언제 갈라지는가'을 관통하는 질문은 이 섹션에서도 같았고, 답만 더 구체적이 됐다.