idea·blog
웹 도구소켓 응용 5단계 · 2/5

소켓 응용 2/5 · 흐름 제어 — 큰 파일을 안전하게 보내기

파일을 청크로 나누고, 느린 수신자 앞에서 backpressure로 멈추며, 끊긴 지점부터 이어받고 체크섬으로 검증하는 흐름을 배웁니다.

2026.07.20작성 idea-blog 운영자· 약 4
소켓 응용 2/5 · 흐름 제어 — 큰 파일을 안전하게 보내기 스크린샷

작은 메시지는 한 번에 보내도 문제가 잘 드러나지 않습니다. 하지만 2GB 파일을 보내는 동안 수신자가 디스크에 천천히 기록한다면 이야기가 달라집니다. 송신자가 계속 빠르게 밀어 넣을수록 중간 버퍼에 데이터가 쌓입니다.

수신자가 느리면 송신자가 잠시 멈춰야 전체 시스템이 안전합니다.

파일 전송 데모에서 계속 밀어 넣기를 먼저 실행하세요. 대기 데이터와 메모리 사용이 계속 늘다가 제한 시간으로 연결이 종료되고, 수신자는 5/8 청크만 저장한 불완전 파일을 검증에서 거부합니다. 흐름 제어 적용으로 바꾸면 버퍼가 찰 때 멈추고, 연결이 끊겨도 6번 청크부터 이어서 완성합니다.

TCP는 파일 경계를 모릅니다

TCP는 순서가 있는 바이트 스트림을 제공합니다. 한 번의 write()가 상대의 한 번의 read()와 같은 크기로 돌아온다고 가정하면 안 됩니다.

보낸 쪽 write:   [header + chunk 1] [header + chunk 2]
받는 쪽 read:    [header + chu] [nk 1 + header] [chunk 2]

따라서 애플리케이션 프로토콜이 다음 정보를 붙여야 합니다.

필드역할
transferId어느 파일 전송인지 구분
chunkNo청크 순서와 이어받기 위치
length이번 payload의 끝을 찾음
totalSize전체 크기 검증
checksum완성 파일 무결성 확인

길이 프리픽스나 명확한 프레임 형식 없이 read() 결과를 파일 하나로 해석하면 데이터가 합쳐지거나 잘린 것처럼 보이는 버그가 생깁니다.

backpressure는 “잠깐만”이라는 신호입니다

송신 애플리케이션이 초당 8MB를 만들고, 네트워크나 수신 디스크가 초당 2MB만 처리한다고 가정해 봅시다. 차이인 초당 6MB는 어디엔가 쌓여야 합니다. 쌓일 곳은 보통 사용자 공간 스트림 버퍼와 운영체제 소켓 버퍼입니다.

backpressure는 버퍼가 정한 상한에 도달했을 때 생산자에게 다음처럼 말하는 흐름입니다.

보내기 → 버퍼 상한 도달 → 생산 멈춤

수신자가 소비함 ← 버퍼가 빔 ← 쓰기 가능 신호

                    생산 다시 시작

Node.js 스트림에서는 write()의 반환값과 drain 이벤트, Python asyncio에서는 writer.drain() 같은 런타임 기능으로 이 신호를 처리합니다. 이름은 달라도 핵심은 버퍼가 비기 전에 다음 데이터를 무한히 만들지 않는 것입니다.

high-water mark에 도달했다고 TCP가 애플리케이션 청크를 임의로 버리는 것은 아닙니다. 송신 코드가 backpressure를 무시하면 대기 데이터가 사용자 공간 메모리에 계속 쌓일 수 있고, 결국 메모리 압박, 제한 시간 또는 프로세스 종료로 전송이 중단될 수 있습니다. 그 결과 수신한 파일이 불완전하다면 전체 길이와 체크섬 검증이 완료 처리를 막아야 합니다.

청크 크기는 균형 문제입니다

청크가 너무 작으면 헤더, 시스템 호출과 프레임 처리 비용이 커집니다. 애플리케이션이 청크마다 ACK를 주고받는 설계라면 그 비용도 함께 늘어납니다. 너무 크면 재전송 단위가 커지고 메모리 점유가 늘어납니다. 정답 하나를 외우기보다 측정 가능한 기준을 두는 편이 낫습니다.

  • 네트워크 대역폭과 왕복 지연
  • 런타임 스트림의 high-water mark
  • 수신 디스크 쓰기 속도
  • 메시지 크기 제한
  • 한 청크 재시도 비용

데모는 이해를 위해 파일을 여덟 조각으로 단순화했습니다. 실제 크기는 환경에서 부하 테스트해 정합니다.

이어받기는 “받음”보다 “저장 완료”를 기준으로 합니다

수신자가 5번 청크를 메모리에서 봤지만 디스크에 기록하기 전 죽을 수 있습니다. 그래서 체크포인트는 단순 수신 시점이 아니라 안전하게 저장이 확정된 시점으로 잡습니다.

{
  "type": "RESUME",
  "transferId": "upload-8f",
  "lastCommittedChunk": 5
}

송신자는 6번부터 다시 보냅니다. 이미 받은 청크가 중복 도착할 수도 있으므로 수신자는 transferId + chunkNo를 기준으로 같은 위치에 한 번만 반영해야 합니다.

전송 완료와 파일 완료는 다릅니다

마지막 청크를 보냈다는 사실만으로 성공을 확정하지 않습니다. 수신 측에서 다음 순서로 검증합니다.

  1. 약속한 청크가 모두 있는지 확인합니다.
  2. 합친 크기가 totalSize와 같은지 확인합니다.
  3. 전체 파일 체크섬을 계산합니다.
  4. 송신자가 알려준 체크섬과 같을 때만 완료 처리합니다.
  5. 실패하면 임시 파일을 완성본 이름으로 바꾸지 않습니다.

임시 경로에 쓴 뒤 검증 성공 시 원자적으로 이름을 바꾸면 다른 프로그램이 불완전한 파일을 완성본으로 읽는 위험도 줄일 수 있습니다.

실무에서 함께 정할 제한

  • 최대 파일 크기와 청크 크기
  • 동시에 허용할 전송 개수
  • 연결·청크·전체 전송 제한 시간
  • 전송별 메모리 상한
  • 저장 경로와 남은 디스크 공간 확인
  • 허용 파일 형식과 악성 콘텐츠 검사
  • 실패한 임시 파일의 만료·정리 정책

흐름 제어는 성능 최적화만이 아니라 메모리 고갈을 막는 안전 장치입니다. 여러 사용자가 동시에 파일을 보내는 상황에서는 전송 하나의 상한과 서버 전체 상한을 모두 둬야 합니다.

데모에서 볼 순서

  1. 계속 밀어 넣기에서 송신 8개와 수신 5개의 차이를 봅니다.
  2. 대기 데이터가 쌓여 연결이 종료되고 5/8 파일이 검증에서 거부되는지 확인합니다.
  3. 흐름 제어 적용으로 바꾸고 버퍼 72%에서 PAUSE가 켜지는지 봅니다.
  4. 연결이 끊긴 뒤 6번 청크부터 이어가는지 확인합니다.
  5. 수신 8/8과 체크섬 MATCH를 함께 확인합니다.

요약

  • TCP 바이트 스트림 위에는 애플리케이션 프레임 경계가 필요합니다.
  • 수신 속도가 느리면 송신 생산자를 잠시 멈춰야 합니다.
  • 이어받기는 수신자가 안전하게 저장한 마지막 청크부터 시작합니다.
  • 전체 길이와 체크섬이 맞을 때만 파일 완료를 확정합니다.

다음 편은 바이트가 아니라 화면의 정답을 맞춥니다. 상태 동기화에서 이벤트를 놓친 사용자가 최신 상태로 돌아오는 방법을 이어서 살펴봅니다.

자료: Node.js — Backpressuring in Streams, Python asyncio Streams, RFC 9293 — Transmission Control Protocol

#네트워크#소켓#TCP#스트림#backpressure#파일전송#학습도구

관련 글