0. Repo
https://github.com/ckaanf/media-lab
GitHub - ckaanf/media-lab: From HTTP Range Request to WebRTC: A progressive study on media streaming architecture with variable
From HTTP Range Request to WebRTC: A progressive study on media streaming architecture with variable server FW - ckaanf/media-lab
github.com
1. 개요
[다운로드와 스트리밍의 결정적 차이]
대용량 미디어 서비스를 구축할 때, 단순히 파일을 다운로드하게 하는 것과 '스트리밍'하는 것의 기술적 차이는 무엇일까요?
핵심은 바로 Random Access(임의 접근) 가능 여부에 있다고 생각합니다.
일반적으로 대량의 데이터를 가져올 때는 I/O 비용을 줄이기 위해 Random Access를 최대한 줄이는 방식으로 최적화를 진행합니다. 그러나 미디어에서는 조금 다른 관점으로 바라봐야 합니다.
일반적인 파일 전송(HTTP 200 OK)은 파일의 시작부터 끝까지 순차적인 데이터 흐름을 전제합니다.
반면, 스트리밍 서비스는 사용자가 타임라인을 이동(Seeking)할 때, 앞부분의 데이터를 건너뛰고 해당 지점의 데이터(Offset)로 즉시 점프해야 합니다.
이번 포스팅에서는 RFC 9110(HTTP Semantics) 표준에 정의된 Range Request를 Spring Boot 환경에서 구현하고,
이를 CDN 아키텍처 관점에서 어떻게 Origin Server로 활용할 수 있는지, 그리고 Blocking I/O 구조가 갖는 물리적 한계는 무엇인지 분석해 봅니다
2. HTTP 표준과 Range Request 메커니즘
브라우저와 서버는 미디어 재생 시 Range Request를 통해 통신하며, 이는 IETF RFC 9110 Section 14에 정의된 표준 메커니즘입니다
2.1 Header 동작 메커니즘
구현 과정에서 패킷을 분석한 결과, 표준 문서에 명시된 다음과 같은 헤더 교환을 확인했습니다
- Request (Browser -> Server): Range 헤더를 통해 특정 바이트 범위를 요청합니다
- Range: bytes=0- (초기 요청)
- Range: bytes=1048576- (Offset 이동 시: 약 1MB 지점부터 데이터 요청)
- Range: bytes=0- (초기 요청)
- Response (Server -> Browser): 서버는 반드시 206 Partial Content 상태 코드로 응답해야 합니다
만약 200 OK로 응답할 경우 브라우저는 이를 일반 다운로드로 처리하여 탐색(Seeking) 기능을 비활성화합니다- Content-Range: bytes 0-1048576/150000000 (전체 150MB 중 0~1MB 구간만 전송)
- Content-Range: bytes 0-1048576/150000000 (전체 150MB 중 0~1MB 구간만 전송)
2.2 Stateless 프로토콜의 한계 - 영상 시청 완료는 어떻게 판단할까?
HTTP는 무상태(Stateless) 프로토콜이므로, 서버는 클라이언트가 영상을 '실제로 다 봤는지' 알 수 없습니다.
상용 서비스에서는 이를 보완하기 위해 HTTP 프로토콜 레벨이 아닌 애플리케이션 레벨에서, 클라이언트가 주기적으로 시청 시점을 전송하는 Heartbeat/Beacon을 구현해서 활용합니다.
3. 구현: Spring ResourceRegion과 메모리 모델
서버 구현 시 가장 치명적인 문제는 OOM(Out Of Memory)입니다.
1GB 영상을 힙 메모리에 모두 로드(byte [])하는 방식은 JVM 힙 공간을 급격히 소진시킵니다.
3.1 Chunked Processing과 User Space Buffering
Spring Framework는 이를 위해 ResourceRegion 클래스를 제공합니다.
이 클래스는 데이터를 모두 메모리에 올리는 대신, 내부적으로 파일의 특정 **오프셋(position)**과 길이(count) 정보만을 유지합니다.
실제 데이터 전송 로직은 다른 클래스에 존재합니다.
ResourceRegionHttpMessageConverter라는 클래스 안에 다양한 메서드들이 있지만, 그 안에서도 실제로 봐야 할 곳은
StreamUtils.copyRange()입니다.
/**
* Copy a range of content of the given InputStream to the given OutputStream.
* <p>If the specified range exceeds the length of the InputStream, this copies
* up to the end of the stream and returns the actual number of copied bytes.
* <p>Leaves both streams open when done.
* @param in the InputStream to copy from
* @param out the OutputStream to copy to
* @param start the position to start copying from
* @param end the position to end copying
* @return the number of bytes copied
* @throws IOException in case of I/O errors
* @since 4.3
*/
public static long copyRange(InputStream in, OutputStream out, long start, long end) throws IOException {
Assert.notNull(in, "No InputStream specified");
Assert.notNull(out, "No OutputStream specified");
long skipped = in.skip(start);
if (skipped < start) {
throw new IOException("Skipped only " + skipped + " bytes out of " + start + " required");
}
long bytesToCopy = end - start + 1;
byte[] buffer = new byte[(int) Math.min(StreamUtils.BUFFER_SIZE, bytesToCopy)];
while (bytesToCopy > 0) {
int bytesRead = (bytesToCopy < buffer.length ? in.read(buffer, 0, (int) bytesToCopy) :
in.read(buffer));
if (bytesRead == -1) {
break;
}
out.write(buffer, 0, bytesRead);
bytesToCopy -= bytesRead;
}
return (end - start + 1 - bytesToCopy);
}
위 코드 안에서도 in.skip() 메서드를 통해 파일의 포인터를 이동시켜 해당 범위를 읽습니다.
이 방식은 애플리케이션 레벨에서 버퍼링을 수행하므로 메모리 사용량을 O(1)로 제어할 수 있습니다.
그러나 나중에 얘기를 하겠지만 파일을 애플리케이션 레벨에서 다루는 것은 ( read() -> write())
디스크 - 커널 - 유저 - 커널 - 소켓으로 4번 복사됩니다.
이러한 부분을 개선하기 위해 sendfile()을 통해 zerocopy로 개선할 수 있습니다.
4. 한계 분석: Tomcat Thread Model의 구조적 제약
기능 구현은 성공했으나, Apache Tomcat의 스레드 모델 특성상 대규모 트래픽 환경에서 구조적 한계가 존재함을
부하 테스트 시나리오로 확인했습니다
4.1 실험 시나리오: Thread Pool Hell
Spring Boot의 내장 Tomcat은 기본적으로 Thread Per Request 모델(Max Threads=200)을 따릅니다.
만약 200명의 사용자가 네트워크 지연(High Latency) 환경에서 접속 중이라면 다음과 같은 현상이 발생합니다.
- I/O Blocking: 스레드는 OutputStream.write() 메서드 호출 시, 클라이언트가 데이터를 수신할 때까지 Blocked 상태로 대기합니다.
- Thread Exhaustion: CPU 자원이 충분함에도 불구하고, 201번째 요청을 처리할 가용 스레드가 없어 Connection Timeout이 발생합니다.
위와 같은 이유로 대량의 동시 시청을 지원해야 하는 미디어 서버는 물론 5항에서 얘기할 CDN을 두겠지만,
Origin Server 역할을 원활하게 수행하기 위해선 Nonblocking I/O의 필요성을 체감합니다.
5. 아키텍처의 확장: Origin Server와 CDN
우리가 구현한 서버는 미디어 아키텍처에서 Origin Server(원본 서버)의 역할을 담당합니다.
실제 대규모 서비스에서는 CDN(Content Delivery Network)이 결합되어 트래픽을 분산합니다.
5.1 CDN과 Range Request의 시너지
CDN이 도입되어도 Origin Server의 Range Request(206 Partial Content) 지원은 필수적입니다.
CDN 에지 서버(Edge Server)는 Lazy Loading 방식으로 동작하기 때문입니다.
사용자가 영상의 50분 지점(500MB~)을 요청하면, CDN은 Origin Server에게 정확히 "500MB부터 보내줘"라고 Range Request를 보냅니다.
이때 Origin이 이를 지원하지 못하면, CDN은 불필요한 앞부분 데이터까지 다운로드해야 하는 비효율이 발생합니다.
번외.
저의 혼자만의 생각인데, 아마 모든 탐색 구간에 캐시를 걸 순 없을 겁니다. 그래서 구간 단위 캐시히트를 하지 않을까 합니다. 예를 들어 2시간짜리의 영상을 다양한 사람이 본다면, 탐색이 모든 초에 걸릴 수 있을 겁니다.
그렇기 때문에 제 생각에는 예를 들어 10분의 영상을 요청한 사람과 10분 5초의 영상을 요청한 사람을 전처리를 통해
9분 55초 ~ 10분 5초 사이의 영상으로 캐시히트 시키지 않을까 생각해 봅니다.
5.2 보안: Signed URL
CDN을 사용할 경우 클라이언트가 Origin을 우회하게 되므로, 통제권을 잃는 것처럼 보일 수 있습니다.
이를 해결하기 위해 Signed URL 방식을 사용합니다.
- 사용자는 Origin(API) 서버에서 인증 토큰이 포함된 URL을 발급받습니다.
- CDN은 요청 시 토큰의 유효성을 검증하고, 유효하지 않으면 Origin으로 요청을 보내지 않고 403 Forbidden 처리합니다.
즉, 데이터 전송(Traffic)은 CDN이 담당하지만, 접근 권한(Authority)은 여전히 Origin Server가 통제하는 구조입니다.
6. Conclusion: High-Performance Origin을 향하여
이번 프로젝트를 통해 RFC 9110 기반의 미디어 스트리밍 로직을 구현하고, CDN과 연동 가능한 표준 Origin Server의 기능을 확보했습니다.
하지만 Servlet 기반의 Blocking I/O 모델은 잦은 컨텍스트 스위칭과 스레드 점유 비용으로 인해,
CDN의 Cache Miss가 동시에 발생하는 Thundering Herd 상황이나 라이브 스트리밍 처리에 한계가 있음을 확인했습니다.
따라서 다음 단계에서는 프레임워크의 추상화를 벗어나 C++ 시스템 프로그래밍으로 전환합니다.
리눅스 커널의 epoll (I/O Multiplexing)과 sendfile (Zero Copy) 시스템 콜을 직접 제어하여, 단일 스레드로 수천 개의 연결을 처리하는 고성능 Origin Server를 밑바닥부터 구현해 보겠습니다.
그리고 epoll과 sendfile에 대해서 공부를 하다 보면 각각의 기술을 극한으로 끌어올린 대표적인 두 기술
- Redis는 어떻게 싱글 스레드인데 빠를까?
- Kafka는 어떻게 처리량이 그렇게 높을까?
에 대한 답도 충분히 할 수 있습니다.
References