제가 예전부터 개발 지식을 익혔을 때 만들어보고 싶었던 것이 3가지가 있는데
- Video Streaming 서비스
- 아두이노 등 기계와 연결하여 모래시계 무한으로 뒤집기
- 게임 제작
입니다.
그중 하나인 Video Streaming 서비스를 만들었던 과정을 정리하려고 합니다.
나머지는 아마 먼 미래에 할 수 있지 않을까 싶습니다.
이 작업 내용들은 github에서 확인할 수 있습니다.
https://github.com/sglee487/loopin-cloud-stack
GitHub - sglee487/loopin-cloud-stack
Contribute to sglee487/loopin-cloud-stack development by creating an account on GitHub.
github.com
https://github.com/sglee487/loopin-server
GitHub - sglee487/loopin-server
Contribute to sglee487/loopin-server development by creating an account on GitHub.
github.com
https://github.com/sglee487/loopin-client
GitHub - sglee487/loopin-client
Contribute to sglee487/loopin-client development by creating an account on GitHub.
github.com
컴퓨터 대여하기


오라클 클라우드에서 Arm64 컴퓨터를 무료로 대여해 줍니다.
한 계정 당 서버 개수 상관없이 ARM OCPU 4개, RAM 24GB까지 무료로 빌려줍니다.
하지만 그만큼 경쟁이 치열했습니다. 매크로까지 동원했고, 전 리전을 도쿄로 하여 그나마 경쟁률이 낮아 다행히 대여하였습니다.
매크로 하는 방법은 검색하면 나오는 방법으로 하였습니다.
정석으로는 노드 최소 2개 이상으로 구성하여, 서버 1대가 죽어도 끊김이 없도록 하는 거지만, 설정 난이도가 너무 높고 데이터베이스들도 로컬에 넣을 건데 예상할 수 없는 문제를 피하기 위해 그냥 노드 1개로 했습니다.
Kubernetes 구축
Kubernetes 구축하는 방법도 특별한 방법을 사용한 건 아니고, 검색해서 나오는 것 따라 했습니다.
구축 자체도 힘들었지만, 가장 힘들었던 점은 DNS와 ingress 연동 설정이었습니다.
이 서버 및 프로젝트가 정말 회사 엔터프라이즈용으로 실행하는 것이 아니고, 서버 하나에서 구축하는 건데 k8s는 구축 코스트가 너무 높고 리소스도 모자라서 k8s의 경량화 버전인 k3s를 설치하였습니다.
K3s
Perfect for Edge K3s is a highly available, certified Kubernetes distribution designed for production workloads in unattended, resource-constrained, remote locations or inside IoT appliances.
k3s.io
여기에서 발생한 문제가, k3s은 ingress controller로 traefik이라는 걸 사용합니다. 대부분의 문서가 nginx 기준으로 설명되어 있어 처음엔 nginx로 교체하려고 했지만 교체하면 작동되지 않았고, traefik으로 ingress구축하는 문서는 양이 적었습니다. 결론적으론 이것저것 검색 많이 하고 GPT를 고문시켜서 어떻게든 원하는 대로 동작하게 했습니다. 역시 뭔가를 굳이 건드리지 말고 기본값으로 있는 걸 활용하는 게 좋은 것 같습니다.
도메인은 제정 문제 및 학습을 위해 오라클 클라우드 대신 Cloudflare를 사용했고, 서버에 접속하는 공개 IP를 Cloudflare에서 도메인 구매 후 등록하여 진행했습니다.
타 Streaming 서비스 조사
개발일을 하며 드는 생각이, 내가 직접 만드는 것보다 남이 잘 만들어놓은 걸 사용하고, 비즈니스에 집중하는 것이 효율적인 것 같습니다.
저만의 생각이 아닌 것이, 모바일 앱 개발해야 해서 UI/UX 책 찾아봤었을 때 대기업이 해놓은 거 베끼라고 되어있고, 기술 블로그들 찾아보면 직접 큐 구현 등보다는 Kafka를 사용하는 등의 방식을 취하고 있습니다. 이미 저보다 훌륭하신 분들이 충분한 조사 뒤에 사용하였고, 실제로 큰 기업에서 운영하며 사용자들에게 제공이 되는 것 자체가 안정성 등이 어느 정도 보장되어 있다는 뜻이라서 그 외 저만의 핵심 비즈니스 기술에만 집중할 수 있습니다. 물론 사용하기 전에 충분히 조사하고 도입해야겠지요.
아무튼 Streaming도 시청도 다른 곳에선 어떻게 하는지 알아보기 위해 조사를 했습니다.
Youtube
유튜브 같은 경우, 어디에도 없는 자신만의 독자적인 방식을 만들어 사용하고 있습니다.
사실 무슨 표준이 만들어지기 전부터 자기들끼리 만들어서 사용했을 테고,
만들어졌어도 실무와는 거리가 멀었을 테지요.



크롬 개발자 도구로 네트워크를 확인해 보면 뭔지도 모를 파일을 거의 2~3초에 한번씩 다운로드하는 것을 알 수 있습니다. 받아오기 위한 HTTP 요청인데도 보안적인 사항이 있는지 query parameter와 별도로 body에 뭔가를 넣어서 보내며 (byte로 추정) octet-stream으로 byte 파일을 받아옵니다. 열어서 보면 segment가 몇 번째인지 등의 정보 및 영상 내용이 들어있는 듯합니다. 역시 독자적 규격이라 그런지. ts,. mp4 등으로 변형해도 재생은 안됩니다.
덤으로 라이브 채팅 메시지를 2초에 1~4개 정도로 계속 GET요청을 통해 받아오고 있다는 것을 알았습니다. 나중에 채팅 기능을 구현할 때 따라 할 수도 있겠네요.



아프리카 tv
애플에서 만든 Streaming 규격 HLS를 따르며, 저는 이걸 채택했습니다.
간단하게 설명하면, 처음에 해당 Streaming index로 어떤 주소로 요청하면 되는지 알아내고 해당 주소의 segment 숫자를 +1씩 올려가며 받아오는 매우 간단한 구조입니다. 영상 확장자는. ts입니다.

치지직
비교적 최근에 생긴 서비스이며, 추적해 보니 그냥 제 3자 서비스인 akamaized를 사용하고 있습니다.
뭔가 제일 싱거우면서도, 일단 남의 서비스 가져와서 사용해 비즈니스에 집중하는 게 제 생각이 맞다는 증거가 추가된 느낌이라 기분이 좋습니다.
직접 구현했을 때 화질별로 영상을 변환하여 다시 저장하는 과정에서 CPU 자원을 정말 많이 먹던데, 이것까지 구현하기엔 트위치 한국 서비스가 종료되어 갑자기 사이즈가 커져서 외부 서비스 이용한 거 아닐까..라고 하기엔 대기업이니 예전부터 조사 후 진행했겠죠? 제정 문제도 영상 처리할 거대한 CPU 몇 개를 운영하는 것보다 싸다고 판단했을 수도 있고요.
...라고 작성했는데, 글 작성을 위해 다시 해보니 최근에 자체적으로 Streaming 서비스를 진행하도록 변경했나 보네요. 아프리카 tv처럼 HLS 규격으로 진행합니다. 다른 점은. m4v를 사용하네요. 혼합해서 사용하다 25년 초에 완전히 이전한 것 같습니다.



그 외
줌 같은 화상 회의엔 화질 조절이나 여러 사용자에게 보여주는 것보다는 정말 실시간 소통이 중요하니 WebRTC나 소켓을 이용한다고 합니다.
이 외에도 RTMP 같은 다른 옛날 방법론들도 있지만... 안 쓰이는 데에는 이유가 있겠지요?
결론
다른 제3사 서비스를 사용할 순 없고, 문서도 제일 많으며 따라 하기 쉬운 애플 HLS 규격을 사용하기로 했습니다.
서버 아키텍처 및 구성
주목적은 학습 및 당시 배우던 서적 실습 목적이어서 서버는 Kotlin + Spring boot에 WebFlux를, 아키텍처는 한참 공부하던 MSA로 구성하였습니다.
https://www.yes24.com/Product/Goods/125491840
클라우드 네이티브 스프링 인 액션 | 토마스 비탈레 | 제이펍 - 예스24
클라우드 환경에 스프링 애플리케이션을 구축하는 효과적인 방법 클라우드 기술이 발달하면서 많은 애플리케이션이 클라우드에서 서비스되고 있다. 이 책에서는 가상의 온라인 서점 시스템을
www.yes24.com
서버 간의 통신은 그냥 일반적인 HTTP로 하였는데, 사내에서 gRPC + protoBuf를 사용했지만 비직관적이고 유지보수가 힘든 데다가 어차피 컴퓨터 한 대에 정보를 주고받는 건데 굳이 필요 없을 것 같아 사용하지 않았습니다. 생각나는 단점으로는 서버 간 통신이 비교적 느려질 순 있겠는데, 어차피 bottleneck은 영상 처리하는 ffmpeg 관련 쪽과 외부에 영상 전달하는 통신 쪽일 것이니 여기까지의 최적화는 나중에 생각해도 됩니다.
ffmpeg를 직접 사용할 거면 다른 언어로 제작하는 것도 방법이겠으나, 어차피 ffmpeg로 Streaming 하는 목적으로 만든 다른 공개 서비스를 사용할 거라 그냥 Spring 사용했습니다. 사실 이게 2번째 만드는 건데, 처음엔 진짜로 ffmpeg 파일을 서버 내에 넣어서 직접 실행하는 방식으로 사용했었는데 이때도 서버를 Kotlin + Spring으로 했었어서 억지로 Spring에 넣어서 사용했지만, 만약 이 때 MSA를 알았더라면 영상 변환 담당은 golang 등으로 처리하게 끔 하지 않았을까 싶습니다.
HLS 규격으로 영상 재생하는 것부터 구성하고, OBS를 통해 Streaming으로 영상을 송신하는 부분은 나중에 구현했습니다.
Apple HLS
영상을 작은 단위로 쪼개 HTTP으로 영상을 주기적으로 받아 Live Streaming이 가능하게 합니다.

https://developer.apple.com/documentation/http-live-streaming
HTTP Live Streaming | Apple Developer Documentation
Send audio and video to iOS, tvOS, and macOS devices.
developer.apple.com
미리 저장된 영상을 미리 쪼개서 저장해 놓고 제공하면 일반적인 영상 재생이 되는 것이고, 들어오는 영상을 실시간으로 쪼개서 제공하면 실시간 Streaming이 됩니다.
반드시 필요한 파일은 특정 segment를 받으려면 어느 위치로 요청하면 될지 알려주는 index.m3u8와 실제 segment 영상이 있으면 됩니다. master.m3u8은 화질 별로 다른 index.m3u8 파일 위치 주소가 나오므로, 선택요소입니다.
실제 요청 및 안의 내용입니다. 처음엔 master.m3u8을 요청해서 화질을 선택하고 해당 index.m3u8을 계속 불러오며 진행합니다.
master.m3u8:


index.m3u8:


segment (. ts):


전 HLS 설정을 다음과 같이 구성하였습니다.
hls {
enabled on;
hls_path /usr/local/srs/objs/nginx/html;
hls_fragment 2;
hls_window 12;
hls_ctx on;
hls_ts_ctx on;
hls_m3u8_file [app]/[stream]/index.m3u8;
hls_ts_file [app]/[stream]/[seq].ts;
hls_acodec aac;
hls_vcodec h264;
hls_cleanup on;
hls_dispose 30;
hls_wait_keyframe off;
}
HLS 주 설정 내용은
hls_fragment 2: 각 segment 하나당 2초
hls_window 12: 12초 분량의 영상 저장. 즉 12 / 2 = 6 ~ 7개의 segment를 저장
hls_dispose 30: volume에 파일을 30초 동안 저장. 이후 파일들은 삭제
자세한 내용은 하단의 문서를 참고하면 됩니다.
https://ossrs.net/lts/en-us/docs/v6/doc/hls
HLS | SRS
HLS is the best streaming protocol for adaptability and compatibility. Almost all devices in the world support HLS,
ossrs.net
https://developer.apple.com/library/archive/technotes/tn2288/_index.html
Technical Note TN2288: Example Playlist Files for use with HTTP Live Streaming
Technical Note TN2288 Example Playlist Files for use with HTTP Live Streaming Important: This document is no longer being updated. For the latest information about Apple SDKs, visit the documentation website. This technote describes several example playli
developer.apple.com
Streamer → RTMP 서버 → SRS-server
영상을 쏴주는 Streamer는 RTMP 서버를 통해 영상을 주기적으로 보내게 됩니다.

저는 srs-server라는 것을 사용했습니다.
nginx + ffmpeg 구성에 관련 기능을 제공합니다. 직접 구축하는 시간을 단축하기 위해 사용했습니다.
https://ossrs.net/lts/en-us/docs/v6/doc/getting-started
Docker | SRS
Please run SRS with docker.
ossrs.net
apiVersion: apps/v1
kind: Deployment
metadata:
name: srs-server
namespace: loopin-production
spec:
replicas: 1
selector:
matchLabels:
app: srs-server
template:
metadata:
labels:
app: srs-server
spec:
serviceAccountName: srs-sa
containers:
- name: srs
image: ossrs/srs:6
ports:
- containerPort: 1935
name: rtmp
- containerPort: 1985
name: api
- containerPort: 8080
name: http
resources:
requests:
memory: "256Mi"
cpu: "100m"
limits:
memory: "512Mi"
cpu: "500m"
volumeMounts:
- name: hls-storage
mountPath: /usr/local/srs/objs/nginx/html
- name: config
mountPath: /usr/local/srs/conf/srs.conf
subPath: srs.conf
volumes:
- name: hls-storage
persistentVolumeClaim:
claimName: hls-storage-pvc
- name: config
configMap:
name: srs-config
docker 생성 후 Kubernetes에서 service 및 ingress를 연결해 줍니다. Kubernetes에서 rtmp 포트를 열기 위한 작업을 따로 해줘야 합니다.
DNS 서버에서 Proxy도 따로 꺼야 합니다.
kubectl patch deployment traefik -n kube-system --type=json -p='[
{
"op": "add",
"path": "/spec/template/spec/containers/0/args/-",
"value": "--entryPoints.rtmp.address=:1935/tcp"
}
]' 2>/dev/null
kubectl patch deployment traefik -n kube-system --type=json -p='[
{
"op": "add",
"path": "/spec/template/spec/containers/0/ports/-",
"value": {
"name": "rtmp",
"containerPort": 1935,
"protocol": "TCP"
}
}
]' 2>/dev/null
apiVersion: v1
kind: Service
metadata:
name: srs-service
namespace: loopin-production
spec:
type: ClusterIP
selector:
app: srs-server
ports:
- name: srs-service-1935-1935
port: 1935
protocol: TCP
targetPort: 1935
- name: srs-service-1985-1985
port: 1985
protocol: TCP
targetPort: 1985
- name: srs-service-8080-8080
port: 8080
protocol: TCP
targetPort: 8080
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: loopin-system
namespace: loopin-production
spec:
rules:
- host: ingest.loopin.bid
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: srs-service
port:
number: 1935
이제 ingress로 등록, OBS나 PRISM 등으로 해당 주소에 Stream을 보내면 srs-server docker container 안에 설정한 대로 index.m3u8 및 segment 영상 파일들이 저장됩니다.
srs-server에서 transcode로 설정한 url로 OBS나 PRISM 등의 프로그램으로 RTMP Streaming을 보내면 화질 변환까지 돼서 저장됩니다.
transcode live {
enabled on;
ffmpeg /usr/local/srs/objs/ffmpeg/bin/ffmpeg;
engine 720p {
enabled on;
iformat flv;
vcodec libx264; vbitrate 1750; vfps 60; vwidth 1280; vheight 720;
vparams { g 240; sc_threshold 0; }
vprofile main; vpreset ultrafast;
acodec aac; abitrate 128; asample_rate 44100; achannels 2;
oformat flv;
output rtmp://127.0.0.1:[port]/abr/[app]?vhost=[vhost]/[stream]_720p;
}
engine 360p {
enabled on;
iformat flv;
vcodec libx264; vbitrate 300; vfps 30; vwidth 640; vheight 360;
vparams { g 60; sc_threshold 0; }
vprofile main; vpreset ultrafast;
acodec aac; abitrate 64; asample_rate 44100; achannels 2;
oformat flv;
output rtmp://127.0.0.1:[port]/abr/[app]?vhost=[vhost]/[stream]_360p;
}
}



이제 일반 유저가 영상을 조회할 때, 이 정적으로 저장된 index.m3u8 및 영상들을 nginx의 정적 파일 요청으로 가져오게 경로를 구성하면 됩니다. 전 성능을 고려하여 PVC를 사용하여 srs-server에 직접 요청하지 않고 가져오도록 구성했습니다.
Streaming-Service (Streaming 방송 관리 서비스)
Streaming-Service는 현재 Streaming을 시작한 방을 표시합니다.

Streamer는 서버에서 자신만의 스트림 키를 제공받고 해당 스트림 키로 RTMP 서버에 방송을 시작하면 Streaming-Service에 해당 스트림 키로 방송을 시작했다고 알립니다. Streaming-Service는 이를 받아 해당 방송상태를 변경합니다.
vhost __defaultVhost__ {
hls {
enabled on;
hls_path /usr/local/srs/objs/nginx/html;
hls_fragment 4;
hls_window 20;
hls_m3u8_file [app]/[stream]/index.m3u8;
hls_ts_file [app]/[stream]/[seq].ts;
}
http_hooks {
enabled on;
on_publish http://streaming-service/api/v1/hooks/on_publish;
on_unpublish http://streaming-service/api/v1/hooks/on_unpublish;
}
}
@PostMapping("/on_publish")
fun onPublish(@RequestBody request: SrsWebhookDto): Mono<Map<String, Any>> {
val streamKey = request.stream
if (streamKey == null) {
logger.warn("Stream key is null from IP: ${request.ip}")
return Mono.just(mapOf("code" to 1, "message" to "Stream key required"))
}
// Extract base stream key (remove resolution suffix like _360p, _720p)
val baseStreamKey = streamKey.substringBefore("_")
logger.info("Stream publish attempt: $streamKey (base: $baseStreamKey) on app: ${request.app} from IP: ${request.ip}")
return streamService.getStreamByKey(baseStreamKey)
.flatMap { stream ->
logger.info("Stream publish authorized: $streamKey (base: $baseStreamKey)")
// Generate master.m3u8 using public ID
generateMasterPlaylist(stream.publicId, baseStreamKey)
streamService.startStream(baseStreamKey)
.map { mapOf<String, Any>("code" to 0) }
}
.switchIfEmpty(
Mono.fromCallable {
logger.warn("Invalid or unauthorized stream key attempted: $streamKey (base: $baseStreamKey) from IP: ${request.ip}")
mapOf<String, Any>("code" to 1, "message" to "Invalid stream key")
}
)
.onErrorResume { error ->
logger.error("Error processing publish webhook for $streamKey", error)
Mono.just(mapOf("code" to 1, "message" to "Stream validation failed"))
}
}
@PostMapping("/on_unpublish")
fun onUnpublish(@RequestBody request: SrsWebhookDto): Mono<Map<String, Any>> {
val streamKey = request.stream
if (streamKey == null) {
return Mono.just(mapOf("code" to 0))
}
// Extract base stream key (remove resolution suffix like _360p, _720p)
val baseStreamKey = streamKey.substringBefore("_")
logger.info("Stream unpublish: $streamKey (base: $baseStreamKey) on app: ${request.app}")
return streamService.stopStream(baseStreamKey)
.map { mapOf<String, Any>("code" to 0) }
.onErrorResume { error ->
logger.error("Error processing unpublish webhook for $streamKey", error)
Mono.just(mapOf("code" to 0))
}
}

Viewer → Streaming 조회
현재 Streaming 중인 목록을 불러옵니다.

@GetMapping
fun getLiveStreams(
@RequestParam(defaultValue = "0") page: Int,
@RequestParam(defaultValue = "20") size: Int
): Flux<ViewerResponseDto> {
return streamService.getLiveStreams(page, size)
}

Viewer → HLS
srs-server에 의해 생성된 영상을 불러옵니다.
최적화를 위해 영상이 저장되는 위치를 공통 volume으로 묶었기 때문에 직접 index.m3u8, .ts 파일을 불러올 수 있습니다.

고유 id가 Streaming중인 public id, streamer id, 유저의 stream key 3가지를 가지고 있는데, volume에는 stream key로 저장되어 있으며, 가려야 합니다.
그러므로 Streaming-Service에서 public id -> stream key로 변환 후 불러옵니다.
원래 srs-server로 http 요청을 하여 영상을 가져와야 하지만, volume에서 직접 파일을 가져오는 게 성능 상 나을 것 같아 앞에 nginx를 붙이고 X-Accel-Redirect 기능을 이용하였습니다.

@GetMapping("/hls/{slug}/seg/{variant}/{file}")
fun seg(@PathVariable slug: String, @PathVariable variant: String, @PathVariable file: String)
: Mono<ResponseEntity<Void>> {
logger.info("HLS seg request: slug=$slug, variant=$variant, file=$file")
return mapSlugToSecret(slug).map { secret ->
logger.info("HLS seg request: secret=$secret")
val internalPath = "/_internal/hls/abr/live/${secret}_${variant}/$file"
logger.info("HLS seg request: internalPath=$internalPath")
// m3u8 파일은 캐싱하지 않고, ts 파일은 캐싱 허용
val responseBuilder = ResponseEntity.ok()
.header("X-Accel-Redirect", internalPath)
if (file.endsWith(".m3u8")) {
responseBuilder
.header("Cache-Control", "no-cache, no-store, must-revalidate")
.header("Pragma", "no-cache")
.header("Expires", "0")
}
responseBuilder.build()
}
}
주의해야 할 점은 index.m3u8 내용은 지속적으로 갱신되어야 하지만 nginx가 기본적으로 client cache를 사용하기 때문에 cache를 비활성화해야 합니다. 그렇지 않으면 계속 같은 segment 만 보여주게 되어 영상이 제대로 재생되지 않습니다.
# m3u8 파일: 캐싱 최소화 (라이브 스트리밍)
location ~ \.m3u8$ {
add_header Cache-Control "no-cache, no-store, must-revalidate" always;
add_header Pragma "no-cache" always;
add_header Expires "0" always;
add_header Access-Control-Allow-Origin "*" always;
add_header Content-Type application/vnd.apple.mpegurl;
}
HLS Player
플레이어는 사실 특별한 게 없습니다.
요즘은 웹 브라우저 자체 내에서 hls 재생 기능이 들어가 있고, 문서도 많이 있습니다. index.m3u8 url을 주소창에 넣으면 알아서 합니다.


문제 및 개선할 점
1. 변환 과정에서 CPU를 너무 많이 사용합니다. 이것도 원래 240p, 360p, 720p, 1080p 4개 화질로 변환되던 걸 2개로 줄인 건데도 방송 하나에 거의 CPU 한 개를 사용합니다.




굳이 화질 별로 변환하지 않고 바로 제공하는 것도 하나의 방법이겠습니다만.. 사실 뒷단에서 고생할수록 유저가 편해지는 경향이 있어 화질 변환 기능이 반드시 필요하다면 다른 외부 서비스를 사용하는 등의 방법을 사용해야겠습니다.
2. 현재 일반 유저가 방송을 볼 때 Public Id -> Stream Key 매칭을 위해 HLS Player -> nginx-accel -> Streaming-Service -> PostgreSQL -> nginx-accel -> PVC를 거치는데, 일단 구현만 해놓자는 목적으로 매 번 DB를 조회하게 끔 만들었습니다. DB 대신 로컬 스토리지 등을 사용하면 더 단축할 수 있겠습니다.
3. 글을 작성하면서 다시 확인해 보니 영상이 자주 끊기는데, hls 설정을 더 조절해야겠습니다.
다른 고려해야 할 점
1. srs-server를 사용한 이유가 RTMP 서버 구축 및 ffmpeg 변환 기능 작성하기 귀찮아서 가져다 사용한 건데, 오히려 더 복잡해진 감이 있습니다. 직접 구현하는 것이 더 나을 수 있겠습니다.
2. 현재 Streamer가 방송하기 위해선 Stream Key에만 의존하는데, 아이디 및 비밀번호 입력 등 추가 보안이 필요해 보입니다. 이를 위해서라도 직접 구현이 더 낫겠네요.
3. 여기선 그냥 volume을 사용했지만 아마 실전에선 S3등을 사용하지 않을까 합니다. 영상을 여러 개로 쪼개는 것에 아이디어를 얻어 MongoDB에 저장도 해봤었는데.. 좀 복잡하긴 하더라고요.