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 코드로 옮기는 흐름과 디버깅 방법을 정리합니다.


핵심 개념

filesrc, decodebin, audioconvert, autoaudiosink Element가 src pad와 sink pad로 이어지고 caps 협상으로 공통 포맷을 정하는 파이프라인

그림: 파이프라인에서 데이터는 소스에서 싱크 방향으로 흐르고, Element는 src pad와 sink pad로 이어진다. 두 Pad는 caps 목록을 교환해 공통 포맷을 고르며, 공통 포맷이 없으면 not-negotiated 에러가 난다.

파이프라인(Pipeline)

파이프라인은 서로 연결된 Element들을 담는 컨테이너입니다. 데이터는 소스에서 필터를 거쳐 싱크 방향으로 흐릅니다.

[ filesrc ] → [ decodebin ] → [ audioconvert ] → [ autoaudiosink ]
   (파일)      (디코딩)         (포맷 맞춤)           (스피커)

Element는 파일 읽기, 디코딩, 변환, 출력 같은 실제 동작 단위입니다. Pad는 Element의 입출력 포트로, src pad는 데이터를 내보내고 sink pad는 받습니다. Caps(capabilities)는 Pad가 주고받을 수 있는 데이터 형식을 기술한 것으로(video/x-raw, 샘플레이트, 픽셀 포맷 등), 두 Pad를 연결하려면 서로 호환되는 caps가 있어야 합니다.

playbin, decodebin, uridecodebin 같은 bin은 내부에 여러 Element를 담고 입력에 맞는 디코더를 골라 Pad를 동적으로 만들어 줍니다. 처음에는 이들로 동작하는 경로를 먼저 만들고, 필요한 부분만 개별 Element로 바꿔 가는 편이 시행착오가 적습니다.

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으로 시작하기

gst-launch-1.0은 파이프라인 문자열을 바로 실행해 플러그인·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

!는 앞뒤 Element의 Pad를 링크하라는 뜻입니다. 대화형 bash에서 !는 히스토리 확장 문자라서, 공백 없이 다른 문자에 붙여 쓰면 event not found 에러가 날 수 있습니다. 불안하면 !만 따옴표로 감싸 '!'처럼 쓰거나 \!로 이스케이프하면 됩니다. 파이프라인 전체를 따옴표 하나로 묶어 한 인자로 넘기는 방식은 권하지 않습니다. gst-launch-1.0은 명령줄 인자를 하나씩 토큰으로 받기 때문에, GStreamer 1.24.2(Ubuntu 24.04 컨테이너)에서 gst-launch-1.0 "fakesrc num-buffers=1 ! fakesink"는 WARNING: erroneous pipeline: syntax error로 실패했고, fakesrc num-buffers=1 '!' fakesink처럼 !를 따로 넘기면 정상 종료했습니다.

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 컨테이너에서는 창을 띄울 수 없어 에러가 나거나(플러그인이 없으면 The autovideosink element is missing) 화면 없이 조용히 끝나므로, 파이프라인 동작만 확인할 때는 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;
  gchar *uri;
  if (argc < 2) {
    g_printerr("Usage: %s <file path or URI>\n", argv[0]);
    return -1;
  }
  gst_init(&argc, &argv);
  /* playbin은 URI만 받으므로 파일 경로는 file:// URI로 변환 */
  if (gst_uri_is_valid(argv[1])) {
    uri = g_strdup(argv[1]);
  } else {
    uri = gst_filename_to_uri(argv[1], NULL);
    if (!uri) {
      g_printerr("경로를 URI로 바꿀 수 없습니다: %s\n", argv[1]);
      return -1;
    }
  }
  pipeline = gst_element_factory_make("playbin", "play");
  if (!pipeline) {
    g_printerr("playbin 생성 실패. 플러그인 설치 상태를 확인하세요.\n");
    g_free(uri);
    return -1;
  }
  g_object_set(pipeline, "uri", uri, NULL);
  g_free(uri);
  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 sample.mp4
./play file:///home/user/sample.mp4

playbin의 uri 속성은 이름 그대로 URI만 받습니다. 파일 경로를 그대로 넣으면 재생이 시작되지 않고 Invalid URI 계열 에러가 나므로, 위 코드는 gst_uri_is_valid()로 URI인지 확인하고 아니면 gst_filename_to_uri()로 변환합니다. 이 함수는 상대 경로를 현재 작업 디렉터리 기준의 절대 경로로 바꿔 줍니다.

이 예제의 구조는 GStreamer 앱의 기본 뼈대입니다. gst_element_set_state()는 상태 전환을 요청만 하고, 실제 재생 중 발생하는 에러(파일 없음, 디코더 없음, caps 협상 실패)는 스트리밍 스레드에서 일어나 Bus 메시지로만 전달됩니다. 다만 모든 에러가 Bus로만 오는 것은 아닙니다. 위 C 예제에 존재하지 않는 파일 경로를 넘겨 보면 PLAYING 상태 전환 실패.가 출력되며 set_state가 곧바로 GST_STATE_CHANGE_FAILURE를 돌려주었고, 플러그인 없이 실행하면(gstreamer1.0-plugins-good 없이 base만 설치) Bus의 ERROR로 The autovideosink element is missing.이 보고되었습니다. 그래서 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에서도 같은 API를 씁니다.

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 상태 표시에 씁니다. 메인 스레드를 막지 않으려면 Python 예제처럼 add_signal_watch와 GLib 메인 루프를 쓰거나, 별도 스레드에서 C 예제처럼 gst_bus_timed_pop_filtered로 메시지를 꺼냅니다.

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

레벨 1은 ERROR, 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를 그림으로 볼 수 있습니다.

흔한 실수

가장 흔한 실수는 동적 Pad를 고려하지 않는 것입니다. decodebin은 입력 스트림을 분석한 뒤에야 출력 Pad를 만들기 때문에, 파이프라인을 만드는 시점에는 연결할 Pad가 아직 없습니다. gst-launch-1.0은 이를 어느 정도 자동으로 처리해 주지만, C나 Python 코드에서는 pad-added 시그널을 받아 그때 다음 Element와 링크해야 합니다.

두 번째는 caps 불일치입니다. 픽셀 포맷이 다르면 videoconvert, 해상도가 다르면 videoscale, 오디오 샘플 포맷이나 채널 구성이 다르면 audioconvert, 샘플레이트가 다르면 audioresample을 중간에 넣어 맞춥니다. audioconvert는 샘플레이트를 바꾸지 못한다는 점을 자주 헷갈립니다.

URI 형식도 자주 틀립니다. 로컬 파일 URI는 file:// 뒤에 절대 경로가 오므로 file:///home/...처럼 슬래시가 세 개가 되고, Windows에서는 file:///C:/Videos/... 형태입니다. 마지막으로 플러그인이 설치되어 있지 않으면 no element 에러가 나는데, 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)를 지우고 다시 실행해 보세요.


참고



같이 보면 좋은 글