
라이브 스트리밍에서 중요하게 여겨야 할 점 중 하나는 "지금 진짜 방송 중인가?" 를 시청자가 믿을 근거다.
그래서 방송 상태를 단순히 버튼 클릭으로 바꾸지 않고, RTMP 송출 이벤트로 LIVE/ENDED를 확정하는 방식으로 설계했다.
스트리밍 상태를 아래처럼 단순한 상태 머신으로 정의했다.
OFF: 방송 없음READY: 스트리머가 방송 제목/소개/카테고리 세팅 완료(송출 전)LIVE: 실제로 RTMP 송출이 시작됨 (OBS → RTMP 서버)ENDED: 실제 송출 종료됨즉, LIVE는 프론트 버튼만으로 바꾸면 신뢰도가 떨어진다.
유저는 "방송 시작"을 눌렀는데 OBS가 송출을 안 했을 수도 있고, RTMP 서버가 죽었을 수도 있고, 온갖 변수가 있다.
READY로 둔다.onPublish 훅 호출 -> 백엔드가 READY → LIVE 확정백엔드는 RTMP 서버(Nginx-RTMP 등)에서 송출 시작/종료 이벤트가 발생하면 호출되는 엔드포인트를 제공한다.
// 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();
}
}
RTMP 훅이 호출될 때, 스트림명/키만으로는 "어떤 채널인지" 매핑이 애매해질 수 있다.
일단 MVP에서는 채널단위로 상태를 바꾸는게 가장 직관적이라 channelId를 직접 전달했다.
또한 스트림 키 기반 매핑(키 로테이션/만료/중복 처리 등)을 MVP에서 먼저 억지로 끌고 오면 복잡도가 커질 수 있다.
그래서 채널 단위 식별은 channelId로 단순화하고, 대신 streamKey를 함께 받아 이중 검증(채널의 streamKey = 요청 streamKey 일치 여부)을 수행했다.
스트림 키를 검증하는 부분은 공통 메서드로 분리해서 중복 제거 + 정책(검증 규칙) 단일화 및 유지보수를 용이하게 만들었다.
// 스트림키 검증 메서드
private void verifyStreamKeyOrThrow(Channel ch, String reqStreamKey) {
if (ch.getStreamKey() == null || !ch.getStreamKey().equals(reqStreamKey)) {
throw new BusinessException(ErrorCode.INVALID_STREAM_KEY);
}
컨트롤러는 그냥 RTMP 이벤트가 들어온 사실을 전달할 뿐이고,
상태 전이의 규칙은 서비스에서 강제해야 한다.
서비스 구현에서 반드시 지켜야 하는 최소 규칙:
onPublish(channelId):READY 일 때만 LIVE로 전이OFF/ENDED/LIVE 에서 들어오면 무시하거나 예외onDone(channelId):LIVE 일 때만 ENDED로 전이OFF/ENDED/READY 에서 들어오면 무시하거나 예외해당 가드가 없으면 상태 머신은 그냥 희망 사항이 된다.
현실에서는 이벤트 순서가 깨질 수도 있고 중복 호출 가능성도 있기 때문에 확실하게 처리해줘야한다.
실제 테스트 상황에서 해당 부분 일반 return 을 사용했기 때문에 200OK를 띄우는 문제가 발생해서 바로 예외처리 해주었다.
RTMP 훅으로 LIVE를 확정했을 시, 프론트에서는 해당 상태를 믿고 UI를 분기하면 된다.
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: 영상 + 채팅UIREADY: 방송 준비중OFF/ENDED: 오프라인/종료POST http://localhost:8080/api/live-streaming/rtmp/on_publish?name=...
POST http://localhost:8080/api/live-streaming/rtmp/on_publish_done?name=...
GET http://localhost:8080/api/streams/1/status
GET http://localhost:8080/api/streams/statuses?status=LIVE
GET http://localhost:8080/api/streams/statuses?status=LIVE&channelIds=1,2
READYLIVE/ENDED해당 설계의 목표는
"시청자가 들어갔는데 방송이 없어서 서비스 품질이 깨지는 일을 줄이기" 이다.