안녕하세요 오랜만입니다. 바빠서 간만에 돌아왔습니다.
오늘도 뜬금없이 새로운 주제로 찾아왔습니다.
“대용량 파일 다운로드를 구현하려면 어떻게 해야 할까?” 얼마 전 친구가 던진 질문입니다.
특정 OS나 프레임워크에 국한된 질문이었다면 구글링을 통해 바로 해결했겠지만
그렇지 않았기에 질문의 의도를 곰곰이 생각해 보았습니다.
어떤 상황에서도 적용될 수 있는 공통적인 해결책을 요구한다고 생각했고, CS 지식에 기반한 해결책을 고민해 보았습니다.
고민해 보면서 질문이 꽤 재밌다고 느껴 직접 구현해 보았습니다.
참고로 안드로이드에선 DownloadManager를 사용하면 쉽게 구현할 수 있습니다
추가로 notebook lm이라는 재밌는 기능이 생겼길래 만들어봤습니다. 이번 글로 만든 AI 팟캐스트입니다.
대용량 파일 다운로드. 뭐가 다를까?
우선 질문을 다시 살펴봅시다.
대용량 파일 다운로드를 어떻게 구현할 수 있을까?
대용량 파일은 우리가 일반적으로 주고받는 데이터와 무엇이 다르길래 이런 질문이 나왔을까요?
우리는 일반적으로 HTTP(S)를 사용해서 네트워크 통신을 통해 데이터를 주고받습니다.
커뮤니티를 개발한다면 게시물과 댓글, 쇼핑몰을 개발한다면 상품 목록과 같은 데이터를 주고받을 것입니다.
안드로이드 개발자라면 OkHttp를 사용해서 네트워크 통신을 하고,
JSON 형식의 데이터를 Kotlinx.serialization을 사용해서 직접 정의한 객체 형식으로 변환해서 사용할 것입니다.
또한 백엔드 개발자라면 Tomcat이 받은 요청에 포함된 데이터를 Jackson을 통해서 원하는 객체 타입으로 변환할 것입니다.
그리고 우리는 이렇게 변환된 객체를 메모리 위에서 사용합니다.
그럼, 대용량 파일 다운로드는 위 방식으로 구현하면 문제가 생길까요?
질문에서 왜 대용량 파일이라고 특정했을까요?
극단적으로 생각해 봅시다. 주고받는 데이터의 크기가 4GB라고 가정해 봅시다.
당신 스마트폰의 램이 8GB라고 한다면 벌써 프로세스의 힙 영역만으로도 메모리의 절반을 요구하게 됩니다.
그러므로 4 GB 데이터를 한꺼번에 메모리에 올리면 JVM의 초기 힙 한도(일반적으로 물리 RAM의 25 %)를 훌쩍 넘기게 됩니다.
이렇게 힙 한도를 초과하면 JVM은 OutOfMemoryError를 던지고, 시스템이 프로세스를 강제 종료할 수도 있습니다.
물론 JVM 힙 한도를 직접 크게 설정했다면 가능할 수 있습니다. 그러나 하나의 프로세스가 램을 절반이나 차지한다면 다른 프로그램을 동시에 사용하는데 문제가 생길 수 있습니다. Android Studio, 10개의 크롬 창, 카카오톡, 슬랙 등을 켜고 있는 자신의 노트북을 떠올려봅시다.
우리는 여기서 질문을 좀 더 구체화해 볼 수 있습니다.
아, “대용량 파일”이란 전체 데이터를 메모리에 올릴 수 없는 크기를 갖는 파일을 말하나보다!
대용량 파일을 전부 메모리에 올리는 것은 불가능하다는 것을 알았습니다.
그럼, 대용량 파일을 다운로드하려면 어떻게 해야 할까요?
그래서 우리는 “받을 때마다” 저장장치에 저장해야 합니다.
받을 때마다 저장장치에 저장한다고요? 그게 무슨 말이죠?
TCP와 흐름제어
자 이제 네트워크 시간입니다. 가장 먼저 TCP를 알아야 합니다.
TCP란 OSI 7계층에서 4계층에 속하며 포트 정보를 포함합니다.
4계층에는 TCP와 UDP가 있으며 TCP는 UDP와 다르게 오류제어, 혼잡 제어 등 신뢰성을 보장하는 여러 기능을 제공합니다.
그중 하나가 바로 “흐름 제어”입니다.
흐름 제어란 송신 측에서 수신 측이 받을 수 있는 만큼의 데이터만 보내는 개념입니다.
조금 더 자세히 말하자면 수신 측이 처리할 수 있는 데이터의 양을 초과하지 않도록 전송 측의 속도를 조절하는 것으로
송신 측이 데이터를 너무 빠르게 보내서 수신 쪽이 감당하지 못하는 상황(버퍼 오버플로우)을 방지하는 기술입니다.
그리고 이것이 이뤄지기 위해 “수신 윈도우”가 존재합니다.
수신 윈도우는 수신 측에서 현재 얼마만큼의 데이터를 더 받을 수 있는지를 나타내는 값입니다.
수신 윈도우 == Receive Window == rwnd
아직 이해되지 않을 수 있습니다. 그래서 잠시 TCP 흐름제어에 관해 설명하고 넘어가려고 합니다.
TCP는 응용 계층(HTTP)에서 보낸 데이터를 작게 쪼개고 여러 번에 걸쳐서 전송합니다.
이때 나누어진 데이터를 헤더로 감싼 하나의 조각을 “세그먼트”라고 합니다.
데이터를 한 번에 보내지 않고 여러 조각으로 나누는 이유까지 설명하면 글의 주제와 멀어지기 때문에 생략하겠습니다.
그럼, TCP 세그먼트를 잠시 살펴보겠습니다.
세그먼트는 전송할 데이터인 바디와 TCP 관련 정보인 헤더로 구성되어 있습니다.
그리고 다음은 TCP 헤더입니다.
전송 계층의 존재 이유인 포트 정보가 있으며, TCP의 특징인 신뢰성 보장을 위한 여러 값이 존재합니다.
그중에서 우리가 관심 있는 것은 바로 Window Size입니다.
앞서 살펴봤던 “수신 윈도우”를 나타냅니다.
앞서 TCP는 데이터를 여러 조각으로 나누어 보낸다고 했습니다.
그럼, 데이터를 받는 쪽도 여러 조각으로 나누어 받을 것입니다.
TCP로 통신하는 두 호스트는 연결마다 개별 수신 버퍼를 설정합니다.
나누어 전송된 데이터들은 수신 측 호스트의 수신 버퍼에 쌓이게 됩니다.
그러나 해당 연결의 주체인 애플리케이션 프로세스는 수신 버퍼에 도달한 데이터를 바로 읽지 않을 수 있습니다.
애플리케이션은 이 연결을 처리하는 것 외에도 해야 할 작업이 많기 때문에 자신이 가능할 때 데이터를 읽게 됩니다.
예를 들어 커뮤니티에서 게시물 상세 보기 화면에 들어갔다고 했을 때, 커뮤니티 프로세스는 게시물 데이터를 요청하고 로딩 화면을 그리다가 틈 나는 대로 수신 버퍼에서 게시글 데이터를 읽어올 것입니다.
만약 애플리케이션이 데이터를 읽는 속도보다 송신 측 호스트가 데이터를 전송하는 속도가 더 빠르다면 어떻게 될까요?
수신 버퍼에서 버퍼 오버플로우가 발생할 것입니다.
수신 측 호스트의 수신 버퍼에서 버퍼 오버플로우가 발생하지 않도록 방지하는 것이 TCP의 흐름제어입니다.
흐름제어 동작 방식
이제 흐름제어가 어떻게 이뤄지는지 예시를 통해 확인해 봅시다.
데이터를 주고받으려는 호스트 A와 B가 있습니다. 호스트 A가 호스트 B에게 대용량 파일을 전송한다고 가정합시다.
호스트 B는 앞서 말했듯 이 연결에 수신 버퍼를 할당합니다. 그 크기를 RcvBuffer라고 합시다.
그리고 호스트 B가 다음 두 개의 변수를 유지합니다.
- LastByteRead: 호스트 B의 애플리케이션 프로세스가 버퍼로부터 읽은 마지막 바이트 번호
- LastByteRcvd: 네트워크로부터 도착해 호스트 B의 수신 버퍼에 저장된 마지막 바이트 번호
이때 호스트 B에서 버퍼 오버플로우가 발생하지 않는 조건은 다음과 같습니다.
LastByteRcvd - LastByteRead ≤ RcvBuffer
그러므로 버퍼 오버플로우가 발생하지 않는 버퍼의 여유 공간 rwnd를 다음과 같이 정의할 수 있습니다.
rwnd = RcvBuffer - (LastByteRcvd - LastByteRead)
정리하면 RcvBuffer는 호스트 B에서 설정한 정적인 버퍼 크기이고
rwnd는 해당 버퍼의 여유 공간으로 동적입니다.
아래 그림을 보면 이해가 좀 더 쉽습니다.
호스트 B는 호스트 A에게 보내는 모든 세그먼트 헤더의 Window size 필드에 rwnd를 담아서 보냅니다.
그럼, 호스트 A는 다음 두 변수를 유지합니다.
- LastByteSent: 호스트 A가 마지막으로 전송한 마지막 바이트 번호
- LastByteAcked: 호스트 B로부터 수신했다고 확인된 마지막 바이트 번호
LastByteAcked는 TCP 헤더의 Acknowlegment number 필드를 통해 알 수 있습니다.
그럼 LastByteSent - LastByteAcked는 호스트 A가 전송했지만, 호스트 B로부터 전송 확인 응답이 안 된 데이터의 양입니다.
그러므로 호스트 A는 LastByteSent - LastByteAcked ≤ rwnd를 유지하면 호스트 B에 버퍼 오버플로우가 나지 않는다고 확실할 수 있습니다.
그러므로 호스트 A는 위 수식을 어기지 않는 선에서 데이터를 전송합니다.
위 내용이 이해가 안 가실 분들을 위해 쉽게 말하면,
수신 측이 송신 측으로부터 데이터를 전송받을 때마다 현재 여유 공간 정보를 담아 잘 받았다는 응답을 보내고
송신 측은 수신 측의 여유 공간을 초과하지 않는 크기의 데이터만 전송합니다.
그래서 대용량 파일 다운로드는 어떻게 한다고?
우린 이제 흐름 제어를 알았습니다.
지금까지 해왔던 네트워크 통신이 다르게 보이지 않나요?
평소에 해왔던 데이터 송수신에서는 라이브러리가 조각난 데이터를 수신 버퍼로부터 여러 차례 받아와 전부 모이면 전달해 주었다는 것을 알 수 있습니다.
그럼, 대용량 파일 다운로드에서 OOM이 발생하지 않으려면 어떻게 해야 할까요? 이제는 알 것 같지 않나요?
제가 생각한 방법은 이렇습니다.
- 메모리에 (byte 배열 형식으로) 부담이 되지 않을 n만큼의 공간을 할당합니다.
- 수신 버퍼로부터 메모리로 n만큼의 데이터를 읽어옵니다.
- 메모리에 읽어온 데이터를 저장장치에 저장합니다.
- 수신 버퍼로부터 모든 데이터를 읽어올 때까지 2~3을 반복합니다.
이것이 앞서 말했던 “받을 때마다” 저장하는 방법입니다.
모든 데이터를 메모리에 모으는 것이 아니라 조금씩 받을 때마다 저장장치에 저장하며 메모리는 버퍼의 역할을 수행합니다.
그럼 이제 코드를 작성할 차례입니다!
다만 글이 너무 길어지는 관계로 코드는 2편에서 계속 됩니다!
'학습' 카테고리의 다른 글
대용량 파일 다운로드 구현하기 - 2편 (3) | 2025.06.04 |
---|---|
BitSet을 아시나요? (백준 2458 키 순서) (1) | 2025.04.13 |
Jetpack Compose의 State와 MutableState 2편 (부제: mutableStateOf의 내부 구현은?) (0) | 2025.02.14 |
Jetpack Compose의 State와 MutableState 1편 (부제: RecomposeScope) (0) | 2025.01.26 |
RecyclerView Drag&Drop, Swipe 1편 (3) | 2025.01.16 |