RTMP: 스트리밍 상태 전이 설계 & 구현

Lui.Slki·2026년 2월 21일

개발 성장일지

목록 보기
5/6

버튼은 UX/UI 일 뿐, RTMP 이벤트는 실제로 일어나야한다.

라이브 스트리밍에서 중요하게 여겨야 할 점 중 하나는 "지금 진짜 방송 중인가?" 를 시청자가 믿을 근거다.
그래서 방송 상태를 단순히 버튼 클릭으로 바꾸지 않고, RTMP 송출 이벤트로 LIVE/ENDED를 확정하는 방식으로 설계했다.

목표: 시청자가 믿을 수 있는 상태 만들기

스트리밍 상태를 아래처럼 단순한 상태 머신으로 정의했다.

  • OFF: 방송 없음
  • READY: 스트리머가 방송 제목/소개/카테고리 세팅 완료(송출 전)
  • LIVE: 실제로 RTMP 송출이 시작됨 (OBS → RTMP 서버)
  • ENDED: 실제 송출 종료됨
    여기서 핵심은
  • READY는 사람이 누르는 버튼 으로 만들 수 있다(의지)
  • LIVE/ENDED는 실제 송출이 시작/종료됐다는 물리 이벤트로만 확정해야 한다.(사실)

즉, LIVE는 프론트 버튼만으로 바꾸면 신뢰도가 떨어진다.
유저는 "방송 시작"을 눌렀는데 OBS가 송출을 안 했을 수도 있고, RTMP 서버가 죽었을 수도 있고, 온갖 변수가 있다.


전체 흐름 요약

  1. 스트리머가 방송 세팅 → 상태를 READY로 둔다.
  2. OBS가 RTMP 서버로 송출 시작
  3. RTMP 서버가 onPublish 훅 호출 -> 백엔드가 READY → LIVE 확정
  4. 시청자는 상태 조회 API를 통해(status/statuses) LIVE인 채널만 라이브로 믿고 진입
  5. 송출 종료 시 on_publish_done 훅 호출 → LIVE → ENDED

상태 전이 트리거: RTMP 훅을 백엔드 앤드포인트로 연결

백엔드는 RTMP 서버(Nginx-RTMP 등)에서 송출 시작/종료 이벤트가 발생하면 호출되는 엔드포인트를 제공한다.

RtmpController

   // OBS 송출 시작 시: READY -> LIVE 확정
    @PostMapping("/on_publish")
    public ResponseEntity<Void> onPublish(@RequestParam("channelId") Long channelId) {
        log.info("on_publish channelId={}", channelId);
        liveStreamingService.onPublish(channelId);
        return ResponseEntity.ok().build();
    }

    // OBS 송출 종료 시: LIVE -> ENDED
    @PostMapping("/on_publish_done")
    public ResponseEntity<Void> onDone(@RequestParam("channelId") Long channelId) {
        log.info("on_publish_done channelId={}", channelId);
        liveStreamingService.onDone(channelId);
        return ResponseEntity.ok().build();
    }
}

왜 channelId를 쿼리로 받나?

RTMP 훅이 호출될 때, 스트림명/키만으로는 "어떤 채널인지" 매핑이 애매해질 수 있다.
일단 MVP에서는 채널단위로 상태를 바꾸는게 가장 직관적이라 channelId를 직접 전달했다.

또한 스트림 키 기반 매핑(키 로테이션/만료/중복 처리 등)을 MVP에서 먼저 억지로 끌고 오면 복잡도가 커질 수 있다.
그래서 채널 단위 식별은 channelId로 단순화하고, 대신 streamKey를 함께 받아 이중 검증(채널의 streamKey = 요청 streamKey 일치 여부)을 수행했다.

  • channelId: "어떤 채널의 상태를 바꿀지" 특정
  • streamKey: 그 채널에 대한 송출이 맞는지
    즉, channelId는 매핑용이고 streamKey 는 인증/검증용이다.

스트림 키를 검증하는 부분은 공통 메서드로 분리해서 중복 제거 + 정책(검증 규칙) 단일화 및 유지보수를 용이하게 만들었다.


    // 스트림키 검증 메서드
    private void verifyStreamKeyOrThrow(Channel ch, String reqStreamKey) {
        if (ch.getStreamKey() == null || !ch.getStreamKey().equals(reqStreamKey)) {
            throw new BusinessException(ErrorCode.INVALID_STREAM_KEY);
        }

서비스 단에서 "가드(guard)"로 상태 머신 지키기

컨트롤러는 그냥 RTMP 이벤트가 들어온 사실을 전달할 뿐이고,
상태 전이의 규칙은 서비스에서 강제해야 한다.

서비스 구현에서 반드시 지켜야 하는 최소 규칙:

  • onPublish(channelId):
    • 현재 상태가 READY 일 때만 LIVE로 전이
    • OFF/ENDED/LIVE 에서 들어오면 무시하거나 예외
  • onDone(channelId):
    • 현재 상태가 LIVE 일 때만 ENDED로 전이
    • OFF/ENDED/READY 에서 들어오면 무시하거나 예외

해당 가드가 없으면 상태 머신은 그냥 희망 사항이 된다.
현실에서는 이벤트 순서가 깨질 수도 있고 중복 호출 가능성도 있기 때문에 확실하게 처리해줘야한다.
실제 테스트 상황에서 해당 부분 일반 return 을 사용했기 때문에 200OK를 띄우는 문제가 발생해서 바로 예외처리 해주었다.


시청자/프론트가 믿는 근거: 상태 조회 API

RTMP 훅으로 LIVE를 확정했을 시, 프론트에서는 해당 상태를 믿고 UI를 분기하면 된다.

StreamStatusController

package com.example.going.domain.liveStreaming.controller;

import com.example.going.common.enums.StreamStatus;
import com.example.going.domain.liveStreaming.dto.StreamStatusRes;
import com.example.going.domain.liveStreaming.service.LiveStreamingService;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import java.util.List;

@RestController
@RequiredArgsConstructor
@RequestMapping("/api/streams")
public class StreamStatusController {

    private final LiveStreamingService liveStreamingService;

    // 방송 준비중 / 방송중 스트리밍 단일 조회
    @GetMapping("/{channelId}/status")
    public ResponseEntity<StreamStatus> getStatus(@PathVariable Long channelId) {
        StreamStatus status = liveStreamingService.getStatus(channelId);
        return ResponseEntity.ok(status);
    }

    // 방송 준비중 / 방송중 스트리밍 리스트 (+channelId 검색 가능)
    @GetMapping("/statuses")
    public ResponseEntity<List<StreamStatusRes>> getStatuses(
            @RequestParam(required = false) StreamStatus status,
            @RequestParam(required = false) List<Long> channelIds
    ) {
        return ResponseEntity.ok(liveStreamingService.getStatuses(status, channelIds));
    }
}

이렇게 해두면 프론트는:

  • LIVE: 영상 + 채팅UI
  • READY: 방송 준비중
  • OFF/ENDED: 오프라인/종료
    같이 상태 기반으로 정직하게 화면을 그릴 수 있다.

테스트 시나리오: "진짜 LIVE로 바뀌는지" 확인

  1. RTMP 훅을 직접 로컬에서 실행
POST http://localhost:8080/api/live-streaming/rtmp/on_publish?name=...
POST http://localhost:8080/api/live-streaming/rtmp/on_publish_done?name=...
  1. 상태 조회로 확인
GET http://localhost:8080/api/streams/1/status
  1. LIVE 리스트 조회
GET http://localhost:8080/api/streams/statuses?status=LIVE
GET http://localhost:8080/api/streams/statuses?status=LIVE&channelIds=1,2

정리: 왜 이 방식이 안전한가?

  • 프론트 버튼은 "방송 의지" 만 표현한다 → READY
  • 실제 송출 여부는 RTMP 서버가 제일 정확히 안다 → LIVE/ENDED
  • 시청자는 상태 조회 API만 믿으면 된다
  • 서비스 가드로 상태 머신을 강제하면, 이벤트가 이상하게 들어와도 시스템이 덜 죽는다.

해당 설계의 목표는
"시청자가 들어갔는데 방송이 없어서 서비스 품질이 깨지는 일을 줄이기" 이다.

0개의 댓글