C와 커널 개발에서의 객체 지향 디자인 패턴
(oshub.org)- 객체 지향 디자인 패턴은 C 언어로 작성된 커널에서도 다형성과 모듈성을 구현해 유연한 시스템 설계를 가능하게 함
- vtable(가상 함수 테이블)을 사용해 장치와 서비스의 인터페이스를 표준화, 런타임 동적 변경으로 다양한 동작 지원
- 커널 서비스와 스케줄러는 vtable을 통해 시작, 중지, 재시작 같은 일관된 인터페이스를 제공하며 구현 세부 사항을 캡슐화
- 커널 모듈과의 결합으로 동적 드라이버 로딩을 지원, 재컴파일 없이 시스템 확장 가능
- 이 접근법은 유연성과 실험적 자유를 제공하지만, 복잡한 구문과 명시적 객체 전달로 인한 번잡함이 단점으로 작용
OS 개발에서의 자유와 객체 지향 패턴
- 자신만의 OS 개발은 협업이나 실제 응용의 제약 없이 자유로운 실험 가능
- 보안 취약점, 코드 유지보수, 릴리스 부담에서 자유로움
- 이는 OS 개발의 매력으로, 비표준 프로그래밍 패턴 탐구 가능
- LWN 기사 “Object-oriented design patterns in the kernel”에서 Linux 커널이 C로 객체 지향 원칙을 구현한 사례 소개
- 함수 포인터를 포함한 구조체로 다형성 구현
- 캡슐화, 모듈성, 확장성을 통해 저수준 커널에서도 객체 지향 이점 활용
vtable의 기본 개념
-
vtable은 함수 포인터를 포함한 구조체로, 객체의 인터페이스 정의
- 예: 장치 동작을 위한 구조체
struct device_ops { void (*start)(void); void (*stop)(void); }; struct device { const char *name; const struct device_ops *ops; };
- 예: 장치 동작을 위한 구조체
- 서로 다른 장치(예:
netdev
,disk
)가 동일한 API를 사용, 구현은 다름-
netdev.ops->start()
는 네트워크 장치,disk.ops->start()
는 디스크 장치 동작 호출
-
-
런타임 변경: vtable을 동적으로 교체해 호출자 코드 변경 없이 동작 변경
- 적절한 동기화로 깔끔한 동적 동작 진화 제공
OS에서의 적용 사례
서비스 관리
- 커널 서비스(네트워킹 매니저, 워커 풀, 윈도우 서버 등)를 일관된 인터페이스로 관리
- 서비스 구조체:
struct service_ops { void (*start)(void); void (*stop)(void); void (*restart)(void); }; struct service { pid_t pid; const struct service_ops *ops; };
- 서비스 구조체:
- 각 서비스는 고유한 동작을 구현하되, 터미널에서 시작/중지/재시작을 표준화된 방식으로 실행
- 코드와 서비스 간 결합도 감소, 관리 간소화
스케줄러
-
스케줄러는 라운드 로빈, 최단 작업 우선, FIFO, 우선순위 스케줄링 등 다양한 전략 지원
- 인터페이스는
yield
,block
,add
,next
로 단순화 - vtable로 정의해 런타임에 스케줄링 정책 교체 가능
- 커널 나머지 부분 수정 없이 전체 정책 변경 가능
- 인터페이스는
파일 추상화
- Linux의 file_operations 구조체는 “모든 것이 파일” 철학 구현
- 예: https://elixir.bootlin.com/linux/v6.15/source/include/linux/fs.h
struct file_operations { struct module *owner; loff_t (*llseek)(struct file *, loff_t, int); ssize_t (*read)(struct file *, char __user *, size_t, loff_t *); ssize_t (*write)(struct file *, const char __user *, size_t, loff_t *); ... };
- 예: https://elixir.bootlin.com/linux/v6.15/source/include/linux/fs.h
- 소켓, 장치, 텍스트 파일 모두 동일한 read/write 인터페이스 제공
- 사용자 공간 코드는 구현 세부 사항을 알 필요 없이 일관된 방식으로 동작
커널 모듈과의 결합
-
커널 모듈은 vtable 교체로 동적 드라이버나 훅 로딩 지원
- Linux 모듈처럼, 재컴파일이나 재부팅 없이 커널 확장 가능
- 새로운 기능 추가 시 기존 구조체의 vtable만 업데이트
단점
-
구문 복잡성:
-
object->ops->start(object)
처럼 객체를 명시적으로 전달해야 함 - C++의 암묵적 전달에 비해 번잡함
- 함수 시그니처도 장황:
static void object_start(struct object* this) { this->id = ... }
-
-
장점: 명시적 전달로 함수의 의존성 명확, 객체와 동작 간 결합 투명
- 커널 코드에서 복잡성과 명확성 간의 적절한 tradeoff
시사점
- vtable은 유연성을 유지하며 복잡성을 줄이는 간단한 방법 제공
- 런타임 동작 교체, 일관된 인터페이스 유지, 새로운 기능 추가 용이
- C 언어로 객체 지향 설계를 구현하는 새로운 방식 제공, OS 개발의 실험적 재미 강조
- 추가 자료: xine 프로젝트(https://xine.sourceforge.net/hackersguide#id324430)는 vtable로 비공개 변수 관리 방법 소개
- OS 개발은 창의적 실험의 장으로, 객체 지향 패턴이 저수준 시스템에서도 강력한 도구임을 증명
Hacker News 의견
- 리눅스 커널이 C로 작성되었음에도 불구하고 함수 포인터를 구조체에 활용하여 다형성을 구현하는 등 객체 지향 원리를 받아들였다는 내용의 글에 대해 이야기함. 이런 테크닉은 객체 지향 프로그래밍보다 훨씬 이전부터 있었으며, '추상 데이터 타입(ADT)' 또는 데이터 추상화라고 부름. ADT와 OOP의 핵심 차이는 ADT에서는 함수 구현을 생략할 수 있지만, OOP에서는 항상 구현이 필요함. OOP에서 선택적 함수가 필요하면, 선택적 함수마다 추가 클래스를 만들어야 하며, 이를 구현할 때마다 다중 상속을 통해 함께 상속받고 런타임에 그 객체가 해당 추가 클래스의 인스턴스인지 확인해야 하는 번거로움이 있음. 반면 ADT에서는 함수 포인터가 NULL인지 간단히 확인하면 됨
- Smalltalk와 Objective-C에서는 런타임에 객체가 메시지에 응답할 수 있는지 간단히 확인함이 전통적인 OOP 방식임. OOP가 C++와 Java의 과도하게 클래스 중심 설계 패턴으로 인해 본질이 변질된 점이 아쉬움
- 대부분 동의하며, C에서도 이런 패턴을 쓰는데, 전통적인 OOP에서는 베이스에 기본(default)이나 스텁 구현을 넣는 것이 흔한 접근임을 언급함. 최신 OOP나 개념 지향 언어에서는 필요한 API의 부분집합만 사용하는 인터페이스로 캐스팅하는 방법도 있음. Go 언어가 좋은 예임
- 이러한 테크닉이 객체 지향 프로그래밍보다 먼저라는 주장에 대해, OOP가 오히려 기존 패턴과 패러다임을 공식화(formalization)한 것으로 표현하고 싶음
- Java, C# 등 대다수 OOP 언어에서도 지금은 람다(lambda)를 쓸 수 있어 C에서와 똑같이 구현 가능함. 람다는 함수 포인터에 불과하기 때문에 인스턴스 변수에 직접 할당할 수 있음. (Java가 람다를 도입하는 데 10년 넘게 걸렸고 Sun Microsystems가 과거에는 Microsoft를 상대로 자바에 람다를 추가하려던 시도를 두고 소송까지 했던 우스운 옛 일화도 있음)
- 상속은 필수가 아님. 합성(composite) 패턴을 쓰면 됨. Python도 비슷하게 self/this/object 포인터를 명시적으로 전달해야 하므로, C 스타일 데이터 추상화와 닮음
- 몇 년 전 Peterpaul이 C 위에 쾌적하게 쓸 수 있는 경량 객체 지향 시스템을 개발한 적 있음(repo). 객체를 명시적으로 넘길 필요 없고, 문서는 부족하지만 풀 테스트 슈트가 있음(테스트1, 테스트2)
- carbon의 문법적 설탕 없이 어떤 모습인지 궁금하다면 여기에서 볼 수 있음. 파라메트릭 다형성은 지원하지 않는 듯 보임
- Vala도 이 틈새 영역에 적합한 시도를 하고 있다는 생각임
- 본인은 이 부분 잘 모르지만, OP가 커널 개발자들이 한 것과 다르게 하고 있는 것 같음. OP가 링크한 글을 읽어보면 vtable에는 타입 함수 포인터가 있지만, OP는 void 포인터를 쓴다는 인상이 듦. 또 커널 개발자 글에서 언급된 주요 이점은 각 구조체 인스턴스에 여러 함수 포인터를 두지 않고 vtable 포인터만 하나 두어 메모리를 아낀다는 점임. 즉, 메모리 절약이 주요 포인트인데, OP는 이 vtable을 런타임에 메서드 교체 및 다형성 구현을 위한 간접화로 쓰고 있음. 이 패턴은 커널 개발자가 얘기한 내용과는 다름
- OP는 void 포인터가 아니라 void(무인자, 반환값 없는 함수)라 표현한 것임. vtable은 다형성 구현을 위해 쓰는 것임. 다형성이 없으면 vtable 자체를 안쓰니 메모리는 더 아끼는 셈임
- 객체를 매번 명시적으로 넘겨야 하는 게 불편하다는 의견에 대해, 본인은 오히려 암묵적 this 사용이 싫음. 실제로 this 인스턴스를 계속 넘기고 있으며, 명시적 this는 변수 소속이 인스턴스인지, 전역 또는 다른 곳에서 온 건지 헷갈릴 일이 없음
- C++(그리고 Java) OOP 문법에서 인스턴스 멤버를 참조할 때 this를 의무적으로 쓰지 않는 것이 큰 실수 중 하나라고 생각함
- 작가는 아래와 같이 object->ops->start(object)에서 객체를 두 번 명시해야 하는 부분을 지적한다고 생각함. 한 번은 vtable 해석을, 한 번은 C 함수 구현에 객체를 넘기기 위해 필요함
- 변수 소속을 명확히 하려고 멤버 변수에 mFoo, m_Foo, foo_, 등 네이밍 컨벤션을 주로 사용함. foo_가 this->foo보다 간결해서 선호함. 물론 C++에서 명시적으로 this를 쓸 수도 있음
- 암묵적 this가 코딩을 더 간결하게 해주고, 실제 메서드를 쓰면 함수마다 struct 접두사를 반복할 필요가 없어짐. 예를 들어 mystruct_dosmth(s); 대신 s->dosmth();처럼 더 자연스러워짐
- 매크로를 활용하면 좀 더 영리하게 처리할 수도 있음
- Tmux 발표자료(자료)에서 C에서 이런 패턴을 처음 배웠음. 이 개념에 대해 본인도 정리한 글이 있음(tmux 오브젝트지향 커맨드 글)
- 대학 시절 몇몇 소규모 프로젝트에서 이런 방식을 구현해본 적 있음. C에서 OOP와 유사한 느낌을 내는 게 즐거웠지만 주의하지 않으면 금세 문제가 크게 생길 수 있음
- 이건 오브젝트 전체가 아니라 인터페이스(즉 vtable, 함수 포인터 테이블)를 활용하는 패턴임을 주의해야 함. 클래스, 상속 등 다른 객체지향 기능들은 오히려 비용이 크고, 따르기 힘든 점이 많음
- 상속이란 결국 vtable의 합성 형태임. 클래스란 것도 vtable과 스코프 변수의 결합일 뿐임
- C에서는 struct를 첫 멤버로 캐스팅하면 필드 상속이 생각보다 자연스러움
- vtable에는 보통 this 포인터를 받는 함수가 들어감. struct file_operations 예시는 this 포인터를 받지 않는 함수 포인터라 실제 vtable이라 보기 어려움
- vtable 함수에 인라인 래퍼를 만들어서 thing->vtable->foo(thing, ...) 대신 foo(thing, ...)처럼 쓸 수 있게 함
- 이런 패턴이 왜 새 C 표준에는 포함되지 않는지 항상 궁금했음. 분명 많은 사람들이 똑같은 패턴을 반복해서 구현하고 있음
- 문법적 설탕(번거로움을 줄여주는 문법 요소)을 추가하면, 공식적으로 허용되는 사용법과 뭔가 빠진 듯한 fallback(기존 방식)이 동시에 생겨야 함. C는 동적 복잡성을 숨기지 않는 점이 장점임. 동적 디스패치가 일어날 때 항상 명확함. 이미 많은 언어들이 이런 공식화를 제공하고 있지만, C의 고유 장점은 복잡성이 드러난다는 것임. 그래서 진짜로 동적 디스패치가 필요할 때만 쓰게 됨. 또한 문법도 어렵지 않음
- 아마 High C Compiler 쪽에서는 어느 정도 이런 방향이 시도된 것으로 보임
- 절대 이 패턴을 쓰지 말라는 강한 경험에서 우러난 조언임. 이 구조로 짜여진 대규모 코드를 유지보수하는 악몽에 시달렸음. 가독성도 끔찍하고, 컴파일러가 포인터로 인한 호출을 최적화하지 못하며 툴링도 전혀 지원하지 않음. 문법도 어색하고, 신입들은 C++ 컴파일러 내부를 통달해야 겨우 코드를 읽을 수 있음. 무엇보다 OOP 도입의 미심쩍은 이점에 비해, 장기적으로 유지보수를 망칠 수 있음. 정말 필요하면 그냥 C++을 써야 함
- 구체적으로 어떤 부분이 악몽이었냐는 질문에, 문법적 설탕이 적은 쪽이 오히려 함수 호출이 동적 디스패치인지 명확히 보여줘서 가독성이 좋다고 생각함. 그래서 동적 디스패치가 필요한 곳에만 제한적으로 쓸 수 있음. 그리고 C의 다이나믹 코드는 함수 포인터가 적어 최적화가 쉽다는 블로그 글을 본 적도 있음. C++ 컴파일러를 똑같이 재구현하라는 게 아니고, 단순히 OOP 본질을 이해하면 자연스럽게 구현이 가능함. 마지막으로, 'C를 조잡한 C++으로 만들지 말라'는 주장에 대해, 이게 오히려 C다운 방식이고, 원하는 곳에 적절히 다이내믹을 집어넣기 쉽다는 점이 선택의 이유임.