7 min read

[DevLog] Node.js로 스트리밍 서버 만들다 포기하고 Nginx로 갈아탄 후기 (feat. Range Request)

[DevLog] Node.js로 스트리밍 서버 만들다 포기하고 Nginx로 갈아탄 후기 (feat. Range Request)
Photo by Claudio Schwarz / Unsplash

최근 사이드 프로젝트로 웹 기반 뮤직 플레이어를 개발하면서 겪었던 백엔드 서빙 이슈와 Nginx 최적화 과정을 공유합니다.

처음에는 단순히 "MP3 파일을 S3나 서버에 두고 src에 넣으면 되는 거 아냐?"라고 생각했지만, 오디오 탐색(Seeking), 대용량 파일 버퍼링, 그리고 CORS 정책이라는 현실적인 벽에 부딪혔습니다. 이 글에서는 Node.js 스트림 처리 방식에서 Nginx 정적 파일 서빙으로 아키텍처를 변경한 이유와 구체적인 설정법을 다룹니다.


1. 문제의 시작: "음악이 중간부터 재생이 안 돼요"

처음에는 Node.js(Express) 서버에서 fs.createReadStream으로 파일을 읽어 프론트엔드로 보내주는 방식을 고려했습니다. 하지만 단순히 파일을 통째로 보내면 다음과 같은 치명적인 문제가 발생합니다.

  1. 탐색(Seeking) 불가: 사용자가 3분 0초로 타임라인을 옮겨도, 브라우저는 처음부터 데이터를 다 다운로드해야만 해당 위치를 재생할 수 있습니다.
  2. 서버 부하: 10MB짜리 곡을 수백 명이 동시에 요청하면 Node.js의 이벤트 루프와 메모리에 큰 부하가 걸립니다.

이를 해결하려면 HTTP Range Request(206 Partial Content)를 직접 구현해야 합니다. 헤더를 파싱해서 "0바이트부터 1MB까지만 줘"라는 요청을 처리해야 하는데, 이걸 직접 구현하는 건 버그를 양산할 가능성이 높았습니다.

2. 해결책: Nginx에게 정적 파일 서빙 위임하기

고민 끝에 "파일 서빙은 파일 서빙 전문가에게 맡기자"는 결론을 내렸습니다. 제가 사용 중인 VPS 환경에는 이미 Nginx Proxy Manager(NPM)가 도커로 떠 있었기 때문에, 이를 단순 프록시가 아닌 정적 파일 서버(Static File Server)로 활용하기로 했습니다.

Nginx는 sendfile 시스템 콜을 사용하여 커널 레벨에서 파일을 전송하므로, Node.js를 거치는 것보다 압도적으로 빠르고 CPU 사용량도 적습니다. 무엇보다 Range Request를 알아서 완벽하게 처리해 줍니다.


3. Nginx Proxy Manager 설정 과정 (삽질의 기록)

Node.js 백엔드를 거치지 않고 Nginx가 직접 /stream/ 경로로 들어오는 요청을 처리하도록 설정했습니다.

3.1. Docker Volume 마운트

가장 먼저 NPM 컨테이너가 호스트(VPS)의 음악 파일 폴더를 볼 수 있게 docker-compose.yml을 수정했습니다.

YAML

services:
  app:
    image: 'jc21/nginx-proxy-manager:latest'
    volumes:
      - ./data:/data
      - ./letsencrypt:/etc/letsencrypt
      # [핵심] 호스트의 업로드 폴더를 컨테이너 내부의 /music으로 매핑
      - /home/user/my-music-app/uploads:/music

3.2. Custom Location 설정 (502 에러와의 싸움)

NPM 관리자 페이지에서 /stream/ 경로에 대한 처리를 정의했습니다. 이 과정에서 502 Bad Gateway 에러를 정말 많이 만났는데, 원인은 주로 파일 권한이나 잘못된 경로 매핑으로 인해 Nginx가 파일을 못 찾고 백엔드(Upstream)로 요청을 토스해버리기 때문이었습니다.

최종적으로 성공한 설정(Advanced 탭 사용 권장)은 다음과 같습니다.

Nginx

location /stream/ {
    # 1. 경로 매핑 (끝에 슬래시 필수!)
    alias /music/;
    
    # 2. 파일이 없으면 프록시 타지 말고 바로 404 리턴 (502 방지)
    if (!-f $request_filename) {
        return 404;
    }

    # 3. 디렉토리 목록 노출 방지
    autoindex off;

    # 4. 캐싱 및 변조 방지 (중요!)
    expires 30d;
    add_header Cache-Control "public, no-transform";

    # 5. CORS 설정 (필요 시 특정 도메인만 허용)
    add_header 'Access-Control-Allow-Origin' 'https://music.rootly.kr';
    add_header 'Access-Control-Allow-Methods' 'GET, HEAD, OPTIONS';
    add_header 'Access-Control-Expose-Headers' 'Content-Length,Content-Range';
    
    # sendfile() 시스템 호출 사용 설정
    sendfile on;

    # TCP 패킷을 효율적으로 전송 (sendfile과 함께 사용 권장)
    tcp_nopush on;
}

4. 핵심 포인트 & 트러블슈팅

4.1. try_files vs if (!-f ...)

NPM 환경에서 try_files $uri $uri/ =404;를 사용했을 때, 의도치 않게 메인 호스트 설정과 충돌하며 502 에러가 발생하는 경우가 있었습니다. 이를 방지하기 위해 파일이 없을 경우 명시적으로 404를 리턴하는 if 문을 사용하여 Custom Location 블록 내에서 로직이 종결되도록 격리했습니다.

4.2. no-transform의 중요성

통신사 프록시나 데이터 절약 모드가 있는 브라우저에서는 트래픽 감소를 위해 오디오 파일을 멋대로 압축하거나 변환하는 경우가 있습니다. 이렇게 파일 크기가 변조되면 Range Request가 계산한 바이트 위치가 틀어져서 탐색(Seeking) 기능이 고장납니다.

Cache-Control "public, no-transform" 헤더를 추가하여 "캐싱은 하되, 원본은 건드리지 마라"고 선언함으로써 이 문제를 예방했습니다.

4.3. CORS와 Visualizer

단순 재생(<audio src="...">)은 CORS 정책이 느슨하지만(Opaque Response), 만약 Web Audio API로 오디오 시각화(Visualizer)를 구현하려면 반드시 올바른 CORS 헤더가 필요합니다.

  • 개발 환경(Localhost)과 배포 환경 모두를 지원하기 위해 동적으로 Origin을 확인하여 헤더를 내려주는 방식도 고려해볼 만합니다.

5. 마치며: Frontend와의 통합

프론트엔드(React)에서는 Howler.js를 사용하여 구현했습니다.

  • 서버 음악: https://api.domain.com/stream/song-uuid.mp3 형태의 URL을 직접 재생.

Nginx가 무거운 스트리밍 처리를 전담해주니, Node.js 백엔드는 가벼운 API 응답만 처리하면 되어 전체적인 앱 성능이 크게 향상되었습니다.

[요약]

  1. 오디오 스트리밍은 Node.js보다 Nginx 정적 서빙이 효율적이다.
  2. alias 사용 시 경로 끝 슬래시(/) 처리에 주의하자.
  3. 502 에러가 뜬다면 Nginx가 파일을 못 찾아 백엔드로 넘기고 있는 것이다. (권한/경로 확인 필수)
  4. no-transform 헤더는 스트리밍 서비스의 필수 보험이다.

혹시 비슷한 구조로 미디어 서비스를 구축하시는 분들께 도움이 되길 바랍니다.

참고

Serve Static Content
Configure NGINX and F5 NGINX Plus to serve static content, with type-specific root directories, checks for file existence, and performance optimizations.