GStreamer User Book #3

본 연재는 SK Telecom의 후원으로 진행하는 “책책책 책을 만듭시다!” 프로그램으로 기획되었으며, 연재 종료 후 도서로 출간될 예정입니다. 연재 내용 중에 저자의 주관적 의견이 노출되는 경우, 해당 의견은 SK Telecom의 의견이 아님을 미리 밝힙니다.

GStreamer 사용을 위해 준비해야하는 것들

앞서 간단히 언급하였던 것처럼, GStreamer는 GLib 2.0 Object Model, 즉 GObject를 기반으로 하는 객체 지향 구조로 설계되었다. 그렇기 때문에 GStreamer의 기본 단위인 Element를 사용하기 위해서는, GObject를 이해하고 이와 더불어서 GLib에서 제공하는 기능들에 익숙해지는 것은 필수적이라 할수 있다.

GLib과 GObject를 설명하려면 먼저 GNOME 프로젝트를 언급할 수 밖에 없다. 일반적으로 GNOME 데스크탑과 GNOME 프로젝트를 구분하지 않고 사용하기 때문에, GNOME은 리눅스용 데스트탑 환경을 제공하기 위한 프로젝트로 알려져 있기도 하지만, 오픈 소스 소프트웨어의 시각에서 보면 리눅스 커널이 구동을 하는 시점부터 화려한 GUI를 제공하는 데스크탑 환경까지 필요한 모든 소프트웨어적인 기반을 제공하고 있는 거대한 플랫폼이기도 하다.

GNOME 프로젝트는 이러한 플랫폼을 완성하기까지 필요한 핵심 기능과 자료구조, 탐색 알고리즘, 파일 입출력, 네트워크 등 전반적인 기능을 제공하는 다수의 프로젝트들의 집합이라고 할 수 있고, 그중에 GLib은 핵심적인 기능들을 제공하는 라이브러리라고 할 수 있다. 엄밀히 말하자면, GObject는 GLib 라이브러리 내에서 제공하는 기능 중에 하나이며, GLib 라이브러리가 함수 수준의 기능을 제공하고 있다면, 이를 좀 더 유연하고 재사용하기 편리한 구조로 제공하기 위해서 고민한 결과가 객체 지향 모델인 GObject가 되는 것이다.

사실 GObject의 개념과 작성하는 방법에 대해서는 GObject Reference Manual에 상세하게 기술 되어 있고 최신 버전에 대한 보다 자세한 정보를 얻을 수 있다. 그러나 본 서적에서는 GStreamer 애플리케이션을 작성하기 위해 혹은 GObject에 대한 보다 쉬운 접근 방법을 제공하고자 보다 간결하게 설명을 하고자 한다.

필요한 개발 패키지 설치

리눅스와 Mac OS X에서는 pkg-config를 이용하여 헤더와 라이브러리의 위치를 확인할 수 있다. GObject의 pkg-config 모듈 이름은 gobject-2.0으로 다음 명령을 이용하여 설치된 라이브러리의 버전을 확인할 수 있다.

$ pkg-config --modversion gobject-2.0
2.60.4

만약 위 명령을 통해서 다음과 같이 버전 정보대신 패키지 정보를 찾을 수 없다고 한다면, 개발용 패키지가 설치되지 않은 것이므로 우분투 리눅스의 경우에는 libglib2.0-dev 패키지를 설치하고, Mac OS X의 경우라면 glib을 설치하면 된다.

$ pkg-config --modversion gobject-2.0
Perhaps you should add the directory containing `gobject-2.0.pc'
to the PKG_CONFIG_PATH environment variable
No package 'gobject-2.0' found

GObject 작성하기

C 언어를 처음 배우고 어느정도 프로그래밍을 할수 있다고 생각할 무렵에 공통적으로 묻는 질문 중의 하나가 어떤 기준으로 소스 파일을 나누어서 함수들을 배치해야 올바른 선택인가 일 것이다. 개발자의 선호도나 프로젝트 진행 중에 시행착오를 통해서 .c 파일을 기능별로 나누게 되는데, GObject를 이용하여 소스를 작성하는 경우에는 이름 규칙과 소스 파일 생성 규칙을 제안하고 있어, 어느 정도 이 문제에 대한 가이드를 제공해주고 있다. 이에 대한 GObject의 공식적인 가이드는 GType Conventions를 통해서 제공되고 있으며, 주요 규칙은 다음과 같다.

  • 타입 혹은 객체의 이름은 3글자 이상의 영어 대소문자 및 _로 해야한다.
  • 함수 이름은 객체 이름을 포함하여 정의한다. 예, object_method
  • 네임스페이스 혼동을 방지하기 위해서 prefix를 사용한다.
  • PREFIX_TYPE_OBJECT 매크로를 반드시 정의한다.
  • G_DECLARE_FINAL_TYPE 혹은 G_DECLARE_DERIVABLE_TYPE를 이용하여 객체를 정의한다. (GLib 2.44 이상 사용하는 경우)

이러한 규칙을 실제 적용하여 객체를 작성해 보기 위해서 본 예제에서는 가상의 프로젝트와 객체 이름을 다음과 같이 정의하기로 한다. 이때 대소문자에 유의하여야 하며, 대소문자의 역할이 어떻게 코드로 연결되는지를 살펴보는 것이 이번 챕터의 목적이다.

  • 프로젝트명: Helloworld
  • 프로젝트 약어: HLW
  • 객체 이름: HelloApp

또한 정의한 객체는 다음과 같은 멤버 변수와 함수를 가지고 있는 것으로 정의한다.

App 클래스 다이어그램

또한 HelloApp 클래스의 hello 함수는 문자열을 입력받아, 멤버 변수 name에 설정된 이름과 조합을 하여 두번째 인자에 기록하고, 조합한 문자의 길이를 리턴하는 기능으로 정의한다.

이렇게 정의한 프로젝트와 객체 정보를 이용하여 GObject의 권고안을 따른다면, 다음과 같은 디렉토리와 파일 구조를 가지게 된다.

  helloworld
    +- helloworld
      +- hello-app.c
      +- hello-app.h

헤더 파일 구성하기

GObject를 사용할때 이러한 이름 규칙을 특별히 강요하지는 않지만, 이름 규칙을 따르게 되면 미리 정의된 매크로를 사용하여 GObject를 구성하기 위해 필요한 함수를 일일이 나열하지 않아도 되는 편리함이 있다.

먼저 위에서 정의한 HelloApp 클래스를 헤더파일을 이용하여 구성한다면, 다음과 같이 정의할 수 있다.

#include <glib-object.h>

#ifndef __HLW_HELLO_APP_H__
#define __HLW_HELLO_APP_H__

G_BEGIN_DECLS

#define HLW_TYPE_HELLO_APP (hlw_hello_app_get_type ())
G_DECLARE_FINAL_TYPE (HlwHelloApp, hlw_hello_app, HLW, HELLO_APP, GObject)

HlwHelloApp     *hlw_hello_app_new      (void);
gint             hlw_hello_app_hello    (HlwHelloApp *self,
                                         const gchar *msg,
                                         gchar       *hello_msg);

G_END_DECLS

#endif /* __HLW_HELLO_APP_H__ */

아마도 GLib을 경험하지 않고 위 코드를 처음 본 독자라면, 매크로의 난무로 인하여 당황할 수 있겠지만, 위의 코드는 GObject를 기반으로 코드를 작성할때에는 가장 기본이 되는 매크로들로만 이루어진 지극히 사실적인 코드이다.

#ifndef __HLW_HELLO_APP_H__의 경우에는 일반적으로 헤더 파일의 중첩 사용을 방지하기 위하여 정의하는 매크로일뿐이지만, 프로젝트 내에서 혹은 다른 프로젝트와 우연히라도 중복되지 않도록 하기 위하여 프로젝트명의 약어(prefix)와 클래스명을 이용하여 정의할 것을 권고하고 있다.

또한 모든 명칭에 CamelCase를 사용하는 경우, 각 대문자를 음절로 간주하고 소문자로 변환하는 경우 _를 추가하는 규칙을 사용하고 있다. 위 코드에서 클래스 이름이 HelloApp이기 때문에 여기에 해당 규칙을 적용하여 소문자로 변환하면 hello_app으로 정의하게 된다.

G_BEGIN_DECLSG_END_DECLS의 경우에는 컴파일러 호환성을 위하여 GLib에서 정의한 매크로이다. C 컴파일러의 경우에는 아무런 역할을 하지 않고, C++ 컴파일러를 사용하는 경우 extern "C" { } 구문으로 변환되는 매크로이다. 사실 이 책의 코드는 모두 C 컴파일러를 사용하기 때문에 필요없는 선언일 수 있으나, 실제 프로젝트에 사용하는 경우에 혹시나 모르는 컴파일러 호환성 확보를 위하여 헤더의 시작과 끝에 정의하는 것을 습관화하는 것도 나쁘지는 않다.

클래스를 정의할 때 각 클래스를 구분하는 기준은 내부적으로 GType을 이용하여 구분하게 되며 그 값은 prefix_object_name_get_type 으로 정의한 함수를 통해서 얻어올 수 있을것으로 가정하며, 해당 함수의 구현은 .c에서 정의하게 된다. 이러한 함수는 매크로에서 사용되고는 있으나, 컴파일 단계 중 매크로를 해석하는 시점에는 함수 원형을 정의하지 않고 있기 때문에 직접 함수 명칭을 사용할수는 없다. 그래서 #define HLW_TYPE_HELLO_APP (hlw_hello_app_get_type ())와 같이 정의하여, 프리프로세서에 의해서 온전한 코드가 만들어질 수 있도록 정의하여야한다.

G_DECLARE_FINAL_TYPEBoilerplate 매크로이며, 다음과 같은 코드를 이름 규칙을 이용하여 생성하는 역할을 한다.

typedef struct _HlwHelloApp HlwHelloApp;

#define HLW_IS_HELLO_APP(obj)           (G_TYPE_CHECK_INSTANCE_TYPE((obj),HLW_TYPE_HELLO_APP)
#define HLW_IS_HELLO_APP_CLASS(klass)   (G_TYPE_CHECK_CLASS_TYPE((klass),HLW_TYPE_HELLO_APP)
#define HLW_HELLO_APP_GET_CLASS(obj)    (G_TYPE_INSTANCE_GET_CLASS((obj), HLW_TYPE_HELLO_APP, HlwHelloAppClass))
#define HLW_HELLO_APP(obj)              (G_TYPE_CHECK_INSTANCE_CAST((obj), HLW_TYPE_HELLO_APP, HlwHelloApp))
#define HLW_HELLO_APP_CLASS(klass)      (G_TYPE_CHECK_CLASS_CAST((klass), HLW_TYPE_HELLO_APP, HlwHelloAppClass)
#define HLW_HELLO_APP_CAST(obj)         ((HlwHelloApp *)(obj))

struct _HlwHelloAppClass {
    GObjectClass    parent_class;
}

GType hlw_hello_app_get_type (void);

G_DECLARE_FINAL_TYPE가 없다면, 위에 나열한 코드를 객체를 정의할 때마다 기록해주어야한다. 유감스럽게도 위 매크로를 정의할때 이름 규칙을 준수하지 않는다면, 컴파일 에러가 아닌 주로 런타임 에러가 발생하게 되고 의도치 않은 버그로 이어질 수 있기 때문에 이러한 매크로의 도움을 받는 것이 좋다.

G_DECLARE_FINAL_TYPE외에도 G_DECLARE_DERIVABLE_TYPE 매크로도 존재한다. 두 매크로의 차이점은 C++이나 JAVA에서 클래스를 선언할때 final 키워드의 사용 여부와 동일하다. 다만 C 언어의 특성상 컴파일된 바이너리의 호환성에서 차이가 발생한다. G_DECLARE_FINAL_TYPE으로 선언한 클래스를 추후 G_DECLARE_DERIVABLE_TYPE로 변경하게 되더라도 ABI(Application Binary Interface) 호환성은 유지되나 반대의 경우에는 바이너리간 호환이 되지 않는 문제가 있다.

그렇기 때문에 GObject를 이용하여 클래스를 정의하는 경우 특별한 이유가 없다면 G_DECLARE_FINAL_TYPE을 사용하도록 권고 하고 있다.

한가지 주의할점은 위에서 언급한것처럼 G_DECLARE_FINAL_TYPEG_DECLARE_DERIVABLE_TYPE와 같은 Boilerplate 매크로는 GLib 2.44 버전 이후에 등장하였기 때문에, 이전 버전의 GLib과 호환성을 고려한다면, 어쩔수 없이 위에 제시한 매크로들을 기록해주는 수 밖에 없다. GStreamer 경우 그 이전 버전의 GLib을 이용하여 작성해왔고 호환성을 유지하고 있기 때문에 위 매크로를 를 사용하지 않고 있다.

소스 파일 구성하기

위에서 준비한 hello-app.h를 기반으로하는 hello-app.c는 다음과 같이 구성할 수 있다. 다른 객체지향 언어에서 보는 클래스의 개념처럼 struct 정의하고 내부에 멤버를 구성한다. 본 예제에서는 gchar * 타입의 name 멤버를 선언하였다.

#include "hello-app.h"

struct _HlwHelloApp {
    GObject      parent;

    gchar       *name;
};

G_DEFINE_TYPE (HlwHelloApp, hlw_hello_app, G_TYPE_OBJECT)

HlwHelloApp *
hlw_hello_app_new (void)
{
    return g_object_new(HLW_TYPE_HELLO_APP, NULL);
}

gint
hlw_hello_app_hello (HlwHelloApp *self, const gchar *msg, gchar *hello_msg)
{
    return g_sprintf(hello_msg, "%s!, %s", self->name, msg);
}

위 소스 코드는 일단 헤더에서 노출된 함수에 대한 내용을 채운것에 지나지 않는다. 이대로라면 객체를 구성하기 위한 Contructor가 없기 때문에 때문에 빌드가 되지 않는다. HlwHelloApp이 온전한 객체가 되기 위해서는 G_DEFINE_TYPE 매크로에서 이름 규칙을 사용하여 정의한 함수를 구성해서 온전한 객체가 되도록 해야한다.

static void
hlw_hello_app_class_init (HlwHelloAppClass *klass)
{
}

static void
hlw_hello_app_init (HlwHelloApp *self)
{
}

C 언어의 한계 때문에 객체와 객체의 클래스를 초기화를 하는 코드는 매크로와 이름 규칙을 이용하여 정의할 수 밖에 없었지만, 위 초기화 함수를 진입점으로 하여 가상 함수를 오버라이드할 수 있게 된다. 실제로 GObject의 Destructor는 prefix_object_class_init 함수에서 인자로 전달받은 klass 구조체에 정의된 함수 포인터를 설정하는 방식으로 오버라이딩을 구현하고 있다. 위 예제에 적용하면 다음과 같이 hlw_hello_app_dispose를 정의하고 사용할 수 있다.

static void
hlw_hello_app_dispose (GObject *object)
{
  HlwHelloApp *self = HLW_HELLO_APP (object);
  
  g_clear_pointer (&self->name, g_free);
  
  G_OBJECT_CLASS (hlw_hello_app_parent_class)->dispose (object);
}

static void
hlw_hello_app_class_init (HlwHelloAppClass *klass)
{
  GObjectClass *object_class = G_OBJECT_CLASS (klass);
  
  object_class->dispose = hlw_hello_app_dispose;
}

Contructor와 Destructor

객체 인스턴스를 생성하거나 제거할 때에는 g_object_new()g_object_unref() 함수를 이용할 수 있다. 소스 파일에서 정의한 hlw_hello_app_new()를 사용하지 않고 g_object_new()를 사용해서 객체를 생성할 수도 있지만, GObject를 만들때 객체 생성을 위한 함수를 명시해주는 것을 권고하고 있다.

Destructor의 경우에는 앞서 설명한대로 가상 함수 오버라이딩을 통해서 처리할 수 있다. GObject는 객체 생성과 소멸을 내부의 레퍼런스 카운터를 이용하여 처리하게 되는데, g_object_new() 함수로 생성된 함수의 레퍼런스 카운터는 g_object_ref() 함수를 이용하여 증가하고, g_object_unref()를 통하여 감소하게 된다.

g_object_unref()를 호출하여 레퍼런스 카운터가 0이 되었을때는 더이상 해당 객체를 사용하지 않는 것으로 판단하고 인스턴스를 정리하는 절차를 진행하게 되며, 이때 호출되는 것이 GObjectClass에 정의된 dispose() 함수이다.

레퍼런스 카운터에 의해서 객체의 라이프사이틀을 관리하기 때문에 GOject의 객체의 소멸에 관여하는 절차는 조금 복잡한데, 해당 절차를 좀더 명시적으로 관리하기 위하여 GObjectClassdispose()finalize() 두 함수를 정의하고 있다. 앞서 기술한대로 dispose()는 레퍼런스 카운터가 0가 되었을때 호출되는 함수이고, finalize()는 객체가 소멸할때 호출되는 함수이다. 호출 순서는 dispose()가 호출 된 다음에 finalize()가 호출되며, 두 순서가 변경되거나 무시하는 경우는 없다.

그런 이유 때문에 실질적으로 사용할때 dispose()finalize()를 구분없이 사용하는 경우가 많지만, dispose()의 경우 레퍼런스 카운터 값이 트리거가 되기 때문에 멀티 스레드 환경이라면 여러번 호출되는 경우가 발생할 수 있다. 반면에 finalize()는 단 한번만 호출된다.

dispose()가 여러번 호출될때를 방지하기 위해서 본 예제에서는 멤버 변수인 name의 메모리를 해제하는데 g_clear_pointer()를 이용하고 있다. 이는 다음 코드와 동일한 역할을 하여, name의 double free를 방지한다.

  if (self->name != NULL) {
    g_free (self->name);
    self->name = NULL;
  }

또한 dispose()finalize() 함수 모두 부모 클래스에서 정의하고 할당하고 있는 자원을 해제할 수 있도록 명시적으로 호출해주어야한다. 규칙은 해당 객체의 자원 해제를 모두 처리한 후, 부모 클래스의 가상 함수를 반드시 호출하도록 하는 것(chain up)이다. 이때 부모 클래스가 해당 가상 함수를 구현했는가 검사할 필요없이 바로 사용할 수 있다.

    G_OBJECT_CLASS (hlw_hello_app_parent_class)->dispose (object);

위 구문에서 hlw_hello_app_parent_class의 등장으로 의아할 수 있으나, 이는 G_DEFINE_TYPE 매크로에서 정의한 것으로 본 예제에서는 부모 클래스가 GObject이므로 GObjectClass 타입을 갖게 된다.

빌드하기

hello-app.hhello-app.c를 빌드하기 위해서는 pkg-config의 도움으로 필요한 헤더와 라이브러리 이름을 사용할 수 있다.

 $ cc -o hello-app.o -c hello-app.c `pkg-config --cflags gobject-2.0`

Meson Build

프로젝트를 빌드하는 고전적인 방법으로는 Makefile을 떠올릴수 있다. 이를 좀더 체계화한것이 GNU Autotools이며, GStreamer는 이 도구를 이용하여 발전해왔다. 그러나 최적화를 위해 추가된 Autotools 매크로는 소스코드만큼이나 복잡해지고 종종 소스코드 디버깅과 별개로 빌드 스크립트와 매크로를 디버깅하는 상황을 연출하곤 한다. 그렇기 때문에 오픈 소스 커뮤니티에서는 이를 대체하려는 시도가 있었고, 각 프로젝트와 오픈 소스 그룹간의 목적에 따라 극적으로 합의한 도구가 MesonBuild이다. 메이슨 혹은 메종으로 읽히는데, 주요 공헌자들이 프랑스어를 모국어로 사용해서 대게 메종으로 읽는다.

GNU Autotools나 CMake 대비 Meson의 가장 큰 이점은, 병렬 빌드에 있어서 기존 빌드 시간을 대폭 단축한다는 것에있다. GStreamer 모듈 코드를 일반적인 랩탑에서 전부 빌드한다고 하면 Autotools를 이용할 경우 한 두시간에 걸치는 작업이 되겠지만, Meson을 이용할 경우 수 십분 정도로 대폭 단축된다. 또한 프로그래머 입장에서 보면, python3 구문에 익숙한 경우 큰 거부감 없이 meson 스크립트를 작성할 수 있어 빌드 스크립트 자체를 디버깅하기 위해 학습해야는 시간이 별도로 필요하지 않다는 정도일 것이다.

Meson 설치하기

Meson은 python3를 기반으로 하기 때문에, 리눅스 배포본에 설치 되어 있지 않더라도 pip3를 이용하여 최신 버전을 이용할 수 있다. Meson은 빌드 스크립트를 읽어서, 사용하는 backend에 맞는 빌드 환경을 구성하는 역할을한다. 리눅스 혹은 Mac OSX에서는 Ninja를 backend로 사용하기 때문에 meson과 ninja를 같이 설치해주어야한다.

 $ pip3 install --user meson
 $ pip3 install --user ninja

GStreamer는 1.16 버전부터 meson을 이용한 빌드를 지원하며, 전체 소스코드를 빌드하기 위해서는 Repository Aggregator 프로젝트인 gst-build를 사용할 수 있다.

 $ git clone https://gitlab.freedesktop.org/gstreamer/gst-build.git
 $ cd gst-build
 $ meson build
 $ ninja -C build

간단한 프로젝트 만들기

이번 챕터에서는 이전에 정의한 helloworld-app을 소스 코드가 위치한 디렉토리가 아닌 빌드를 지원하는 프로젝트로 구성하는 방법에 대해서 기술한다.

Comments