GStreamer 실전 가이드 | 파이프라인·C/Python·gst-launch로 멀티미디어 다루기

이 글의 핵심

GStreamer 1.x 파이프라인(Element·Pad·Caps), gst-launch-1.0 CLI, C gst_parse_launch·Bus 처리, Python PyGObject 재생 예제와 GST_DEBUG 디버깅을 한 흐름으로 정리합니다.

들어가며

GStreamer는 오디오·영상을 처리하는 오픈소스 멀티미디어 프레임워크입니다. 재생·녹화·인코딩·스트리밍·효과 처리까지 파이프라인이라는 그래프로 조립해 동작합니다. FFmpeg가 “입력 파일을 받아 출력 파일을 만드는” 일괄 변환 도구에 가깝다면, GStreamer는 애플리케이션 안에 살아 있는 미디어 그래프를 두고 실행 중에 소스를 바꾸거나, 가지를 나눠 화면 표시와 녹화를 동시에 하거나, 프레임을 앱 코드로 꺼내 분석하는 데 강합니다. GNOME의 동영상 플레이어, 여러 임베디드 카메라 SDK, NVIDIA DeepStream 같은 영상 분석 프레임워크가 GStreamer 위에 만들어진 이유가 이 구조입니다.

대신 진입 장벽이 있습니다. 개념(Element, Pad, Caps, 상태, Bus)을 모르면 에러 메시지가 not-negotiated, Internal data stream error 같은 한 줄로만 나와서 무엇이 잘못됐는지 짐작하기 어렵습니다. 이 글은 GStreamer 1.x 기준으로 그 개념을 최소한으로 잡고, CLI로 검증한 파이프라인을 C와 Python 코드로 옮기는 흐름과 디버깅 방법을 정리합니다.


핵심 개념

파이프라인(Pipeline)

파이프라인은 연결된 Element들의 집합입니다. 데이터가 소스 → 필터 → 싱크 방향으로 흐릅니다.

[ filesrc ] → [ decodebin ] → [ audioconvert ] → [ autoaudiosink ]
   (파일)      (디코딩)         (포맷 맞춤)           (스피커)
  • Element: 실제 동작 단위(읽기, 디코딩, 변환, 출력).
  • Pad: Element의 입·출력 포트. Src pad는 데이터를 보내고 Sink pad는 받습니다.
  • Caps(Capabilities): 포맷 협상에 쓰이는 설명(예: video/x-raw, 샘플레이트, 픽셀 포맷). Pad가 연결될 때 호환 가능한 caps가 맞아야 합니다. decodebin, playbin, uridecodebin 같은 빈(bin)은 내부에 여러 Element를 넣고 Pad를 동적으로 만들어 줍니다. 따라서 초보자는 이 셋부터 쓰면 삽질이 줄어듭니다.

Caps 협상은 GStreamer에서 가장 자주 부딪히는 개념입니다. 두 Element를 연결하면 양쪽 Pad가 “나는 이런 포맷을 주고받을 수 있다”는 caps 목록을 교환해 공통 포맷 하나를 고릅니다. 예를 들어 디코더가 video/x-raw, format=NV12만 내보내는데 싱크가 RGB만 받는다면 공통 포맷이 없어 협상이 실패하고, 버스에는 streaming stopped, reason not-negotiated (-4) 에러가 올라옵니다. 그래서 파이프라인 중간에 videoconvert·audioconvert 같은 변환 Element를 끼워 넣는 것이 관례이고, 변환이 필요 없으면 이들은 데이터를 그대로 통과시키므로(passthrough) 넣어 두는 비용은 작습니다.

Element에는 상태(NULL → READY → PAUSED → PLAYING)가 있습니다. READY에서 장치나 파일을 열고, PAUSED에서 첫 버퍼가 싱크까지 도달해 준비(preroll)되며, PLAYING에서 클럭에 맞춰 흐르기 시작합니다. gst_element_set_state()로 PLAYING을 요청하면 이 단계를 차례로 거치는데, 네트워크 소스처럼 준비에 시간이 걸리면 결과가 ASYNC로 돌아오고 실제 전환은 나중에 끝납니다.


gst-launch-1.0으로 시작하기

CLI로 파이프라인 문자열을 바로 실행해 플러그인·URI·caps를 검증합니다. 코드를 쓰기 전에 원하는 파이프라인이 gst-launch-1.0에서 동작하는지 먼저 확인해 두면, 이후 코드에서 문제가 생겼을 때 “파이프라인 설계 문제”인지 “코드 문제”인지 바로 구분할 수 있습니다. -v 옵션을 붙이면 각 링크에서 협상된 caps가 출력되어 실제로 어떤 포맷이 흐르는지도 볼 수 있습니다.

간단 재생

# 파일 재생(자동 디코딩·출력 선택)
gst-launch-1.0 playbin uri=file:///C:/Videos/sample.mp4
# HTTP 스트림
gst-launch-1.0 playbin uri=https://example.com/stream.mp4

명시적 체인 예시

오디오만 wav로 디코딩해 스피커로:

gst-launch-1.0 filesrc location=music.wav ! wavparse ! audioconvert ! audioresample ! autoaudiosink
  • !: Pad를 링크한다는 뜻(문자열 파싱 시).
  • bash에서 !는 히스토리 확장 문자라서, 앞뒤에 공백 없이 붙여 쓰면 event not found 에러가 날 수 있습니다. 불안하면 따옴표로 파이프라인 전체를 감싸세요. gst-launch-1.0은 인자를 공백으로 이어 붙여 파싱하므로 전체를 한 문자열로 넘겨도 똑같이 동작합니다.
gst-launch-1.0 "filesrc location=test.wav ! wavparse ! audioconvert ! audioresample ! autoaudiosink"

테스트 소스

설치가 됐는지 확인할 때 유용합니다.

gst-launch-1.0 videotestsrc ! autovideosink
gst-launch-1.0 audiotestsrc ! autoaudiosink

autovideosink와 autoaudiosink는 실행 환경에서 쓸 수 있는 출력(X11/Wayland, Windows의 Direct3D, PulseAudio/WASAPI 등)을 자동으로 고릅니다. 화면이 없는 서버나 Docker 컨테이너에서는 창을 띄울 수 없어 에러가 나므로, 파이프라인 동작만 확인할 때는 fakesink나 fakevideosink로 바꿔 끝까지 흐르는지만 보면 됩니다. gst-launch-1.0은 어디까지나 테스트·프로토타입 도구라서, 공식 문서도 운영 애플리케이션에서 이것을 서브프로세스로 호출하는 대신 API를 직접 쓰라고 권합니다.


C 예제

playbin에 URI를 넣고 Bus에서 EOS/ERROR를 기다리는 최소 재생 예제입니다.

#include <gst/gst.h>
int main(int argc, char *argv[]) {
  GstElement *pipeline;
  GstBus *bus;
  GstMessage *msg;
  GstStateChangeReturn ret;
  if (argc < 2) {
    g_printerr("Usage: %s <file path or URI>\n", argv[0]);
    return -1;
  }
  gst_init(&argc, &argv);
  pipeline = gst_element_factory_make("playbin", "play");
  if (!pipeline) {
    g_printerr("playbin 생성 실패. 플러그인·PKG_CONFIG_PATH 확인.\n");
    return -1;
  }
  g_object_set(pipeline, "uri", argv[1], NULL);
  ret = gst_element_set_state(pipeline, GST_STATE_PLAYING);
  if (ret == GST_STATE_CHANGE_FAILURE) {
    g_printerr("PLAYING 상태 전환 실패.\n");
    gst_object_unref(pipeline);
    return -1;
  }
  bus = gst_element_get_bus(pipeline);
  msg = gst_bus_timed_pop_filtered(
      bus,
      GST_CLOCK_TIME_NONE,
      GST_MESSAGE_ERROR | GST_MESSAGE_EOS);
  if (GST_MESSAGE_TYPE(msg) == GST_MESSAGE_ERROR) {
    GError *e = NULL;
    gchar *dbg = NULL;
    gst_message_parse_error(msg, &e, &dbg);
    g_printerr("에러: %s\n", e->message);
    if (dbg) g_printerr("Debug: %s\n", dbg);
    g_clear_error(&e);
    g_free(dbg);
  }
  gst_message_unref(msg);
  gst_object_unref(bus);
  gst_element_set_state(pipeline, GST_STATE_NULL);
  gst_object_unref(pipeline);
  return 0;
}

빌드 예 (Linux, pkg-config 사용):

gcc -o play play.c $(pkg-config --cflags --libs gstreamer-1.0)
./play file:///home/user/sample.mp4

playbin의 uri 속성은 이름 그대로 URI만 받습니다. ./play sample.mp4처럼 파일 경로를 넘기면 재생이 시작되지 않고 Invalid URI 계열 에러가 납니다. 사용자에게 경로를 받는다면 gst_filename_to_uri()로 변환한 뒤 넘기는 것이 안전합니다.

이 예제의 구조는 GStreamer 앱의 기본 뼈대입니다. gst_element_set_state()는 상태 전환을 요청만 하고, 실제 재생 중 발생하는 에러(파일 없음, 디코더 없음, caps 협상 실패)는 스트리밍 스레드에서 일어나 Bus 메시지로만 전달됩니다. 그래서 set_state의 반환값만 검사하고 Bus를 보지 않으면, 에러가 나도 프로그램은 아무 출력 없이 멈춘 것처럼 보입니다. 처음 GStreamer 코드를 짤 때 가장 흔히 겪는 증상이 바로 “아무 에러도 없이 화면이 안 나온다”인데, 거의 항상 Bus의 ERROR 메시지를 읽지 않아서입니다.

종료 시 GST_STATE_NULL로 되돌린 뒤 unref하는 순서도 중요합니다. PLAYING 상태의 파이프라인을 바로 해제하면 스트리밍 스레드가 아직 동작 중이라 Trying to dispose element ..., but it is in PLAYING instead of the NULL state. 경고가 나고, 장치나 파일 핸들이 제대로 닫히지 않을 수 있습니다.


Python(PyGObject) 예제

GStreamer는 GObject Introspection으로 Python에서도 동일한 개념을 씁니다.

import sys
import gi
gi.require_version("Gst", "1.0")
from gi.repository import Gst, GLib
def main():
    Gst.init(None)
    if len(sys.argv) < 2:
        print("Usage: python play.py <URI>")
        sys.exit(1)
    playbin = Gst.ElementFactory.make("playbin", None)
    playbin.set_property("uri", sys.argv[1])
    loop = GLib.MainLoop()
    bus = playbin.get_bus()
    bus.add_signal_watch()
    def on_message(bus, message):
        t = message.type
        if t == Gst.MessageType.EOS:
            loop.quit()
        elif t == Gst.MessageType.ERROR:
            err, dbg = message.parse_error()
            print(f"Error: {err.message}", file=sys.stderr)
            loop.quit()
    bus.connect("message", on_message)
    playbin.set_state(Gst.State.PLAYING)
    loop.run()
    playbin.set_state(Gst.State.NULL)
if __name__ == "__main__":
    main()

주의: 시스템에 python-gi와 GStreamer 1.0 typelib가 맞게 설치되어 있어야 합니다. Debian/Ubuntu라면 python3-gi와 gir1.2-gstreamer-1.0 패키지이고, 없으면 ValueError: Namespace Gst not available 에러가 납니다. pip install PyGObject는 빌드에 C 개발 헤더가 필요하고 GStreamer 자체는 설치해 주지 않으므로, 가상환경을 쓸 때 가장 많이 막히는 부분입니다.

C 예제와 달리 여기서는 bus.add_signal_watch()와 GLib.MainLoop를 씁니다. 버스 메시지가 GLib 메인 루프를 통해 콜백으로 전달되므로, GUI 앱(GTK 등)처럼 이미 메인 루프가 있는 환경에 그대로 녹아듭니다. 메인 루프를 돌리지 않고 add_signal_watch()만 호출하면 콜백이 영원히 호출되지 않는다는 점을 주의해야 합니다. Python 콜백은 GIL을 잡고 실행되므로, 프레임마다 호출되는 appsink의 new-sample 같은 콜백에서 무거운 처리를 하면 파이프라인 전체가 느려집니다.


Bus 메시지와 종료 처리

앱에서 빠지기 쉬운 부분이 Bus 루프입니다.

  • EOS: 스트림 끝. 재생 완료 시 정상 종료 신호.
  • ERROR: 파이프라인이 복구 불가일 때 많이 옵니다. parse_error로 문자열을 꼭 출력하세요.
  • STATE_CHANGED: 디버깅·UI 연동에 사용. 메인 스레드를 막지 않으려면 add_signal_watch + GLib 메인 루프 또는 별도 스레드에서 pop 패턴을 선택합니다.

ERROR 메시지에는 두 문자열이 있습니다. err.message는 사용자용 요약이고, dbg(debug 문자열)에는 에러를 낸 Element 경로와 소스 코드 위치가 들어 있어 실제 원인을 찾는 데 훨씬 유용합니다. 예를 들어 Internal data stream error라는 요약만으로는 알 수 없는 원인이 debug 문자열에서 reason not-negotiated로 드러나는 식입니다. 또 네트워크 스트림을 다룬다면 BUFFERING 메시지도 처리해야 합니다. 버퍼가 100%가 되기 전에는 PAUSED로 두었다가 다 차면 PLAYING으로 돌리는 처리가 없으면 재생이 뚝뚝 끊깁니다.


디버깅과 자주 나는 실수

GST_DEBUG

로그 레벨과 카테고리를 지정하면 Pad 링크 실패 원인을 빠르게 좁힐 수 있습니다.

GST_DEBUG=3 gst-launch-1.0 playbin uri=file:///path/to/file.mp4

자주 쓰는 패턴:

GST_DEBUG=*:2,*decode*:5

레벨 2는 WARNING, 3은 FIXME, 4는 INFO, 5는 DEBUG입니다. 전체를 5 이상으로 켜면 초당 수만 줄이 쏟아져 오히려 원인을 찾기 어렵습니다. 기본을 2~3으로 두고 의심 가는 카테고리만 올리는 것이 요령이고, 카테고리 이름은 gst-launch-1.0 --gst-debug-help로 확인할 수 있습니다. 로그가 많을 때는 GST_DEBUG_FILE=/tmp/gst.log로 파일에 저장합니다. 파이프라인 구조가 복잡하다면 GST_DEBUG_DUMP_DOT_DIR=/tmp를 설정해 두고 gst-launch-1.0을 실행하면 상태가 바뀔 때마다 .dot 그래프 파일이 생성되어, Graphviz로 실제 연결 구조와 협상된 caps를 그림으로 볼 수 있습니다.

흔한 실수

  1. Pad 미연결: decodebin 뒤는 동적 Pad가 생깁니다. pad-added 시그널에서 다음 Element와 링크해야 하는 경우가 많습니다(직선 ! 체인만으로 안 될 때).
  2. Caps 불일치: 샘플레이트·채널·픽셀 포맷이 안 맞으면 audioconvert / videoscale / videoconvert 등으로 중간에 맞춰 줍니다.
  3. 잘못된 URI: file://는 절대 경로 3슬래시(file:///home/...) 형태에 익숙해지세요.
  4. 플러그인 누락: gst-inspect-1.0 요소이름으로 존재 여부를 확인합니다.
gst-inspect-1.0 playbin

플러그인은 품질과 라이선스에 따라 base, good, bad, ugly, libav 세트로 나뉘어 배포됩니다. H.264 디코딩(avdec_h264)은 libav 세트, x264enc는 ugly 세트에 있는 식이라, 개발 머신에서는 되던 파이프라인이 최소 설치된 서버에서 no element "x264enc" 에러로 실패하는 일이 흔합니다. 배포 환경의 패키지 목록을 개발 환경과 맞추고, 앱 시작 시 필요한 Element를 gst_element_factory_find()로 미리 확인해 두면 원인 파악이 빨라집니다. 새 플러그인을 설치했는데도 찾지 못하면 레지스트리 캐시(~/.cache/gstreamer-1.0/registry.*.bin)를 지우고 다시 실행해 보세요.


마무리

GStreamer는 “Element를 조립해 데이터가 흐르게 만든다”는 한 가지 원리만 잡아도 입문이 쉬워집니다. 실무에서는 playbin / uridecodebin으로 먼저 성공 경로를 만든 뒤, 필요한 지점만 수동 파이프라인으로 바꿔 가는 방식이 안전합니다. 더 나아가려면 Pad 템플릿, 요소 상태 머신, 시각/클럭 동기화, appsrc/appsink로 애플리케이션 버퍼를 직접 붙이는 패턴을 문서와 튜토리얼로 확장해 보세요.


자주 묻는 질문 (FAQ)

Q. decodebin 뒤에 요소를 !로 이었는데 링크가 안 되는 이유는 무엇인가요?

A. decodebin은 입력 스트림을 분석한 뒤에야 출력 Pad를 만드는 동적 Pad 요소라서, 파이프라인을 만드는 시점에는 연결할 Pad가 아직 없습니다. gst-launch-1.0에서는 어느 정도 자동으로 처리되지만 C나 Python 코드에서는 pad-added 시그널을 받아 그때 다음 요소와 링크해야 합니다. 원인을 좁힐 때는 GST_DEBUG=3 정도로 로그를 켜면 Pad 링크 실패나 Caps 불일치 메시지를 확인할 수 있습니다.

참고


같이 보면 좋은 글