C로 하는 OOP

C 언어는 절차 지향의 대표주자지만, 객체 지향 프로그래밍(OOP)의 강력한 개념들을 C에서도 구현해볼 수 있을까요? 놀랍게도, 창의적인 접근과 약간의 기법을 통해 C 언어에서도 OOP의 핵심 개념들을 구현하는 것이 가능합니다. 이 글에서는 C 언어로 OOP 스타일의 설계를 어떻게 시도할 수 있는지, 그리고 그 과정에서 중요한 객체 지향의 핵심 원칙들을 어떻게 구현할 수 있는지 살펴봅니다.

왜 C에서 객체 지향인가?

객체 지향 프로그래밍은 클래스, 객체, 상속, 다형성, 은닉화 등의 개념을 통해 복잡한 시스템을 모듈화하고 유지보수를 용이하게 합니다. C++이나 Java 같은 언어에서는 이러한 기능들이 내장되어 있지만 C에서는 그렇지 않습니다.

그럼에도 불구하고 C로 객체 지향의 철학을 적용하는 이유는 다음과 같습니다.

  • 세밀한 제어 필요: 임베디드 시스템이나 시스템 프로그래밍 등 메모리 제어나 성능 측면에서 세밀한 관리가 필요할 때 유용합니다.
  • 기존 C 프로젝트 활용: 이미 C로 작성된 시스템에 모듈성과 유연성을 더하고자 할 때 적용될 수 있습니다.
  • OOP 원리 학습: 언어의 도움 없이 OOP 개념을 직접 구현해보며 그 원리를 깊이 이해할 수 있습니다.

C에서 객체 지향 핵심 원칙 구현하기

객체 지향의 근간을 이루는 핵심 원칙인 은닉화, 상속, 다형성을 C언어에서 어떻게 구현할 수 있는지 알아봅시다. 이는 C++과 같은 네이티브 지원이 아닌, C의 기능을 활용한 구현임을 기억하는 것이 중요합니다.

  • 은닉화 (encapsulation)
    • 정의: 데이터와 해당 데이터를 처리하는 함수(메소드)를 하나의 단위로 묶고, 외부에는 불필요한 내부 구현 상세를 감추는 기법입니다. 정보 은닉(information hiding)과 관련이 깊습니다.
    • C 구현:
      • struct를 사용하여 관련 데이터를 묶습니다.
      • 관련 함수들은 해당 구조체를 다루도록 설계하며, 함수 포인터를 구조체 멤버로 포함시켜 ‘메소드’처럼 동작하게 할 수 있습니다.
      • 헤더 파일(.h)에는 구조체 선언(때로는 opaque 포인터 기법 활용)과 함수 프로토타입(공개 인터페이스)만 노출하고, 실제 구현은 소스 파일(.c)에 작성하여 내부 구현을 감춥니다. .c 파일 내에서 static 키워드를 사용하여 파일 범위 내에서만 접근 가능한 함수나 변수를 만들어 정보 은닉을 강화할 수 있습니다. (주의: C에는 접근 제어자가 없어 완전한 은닉은 어렵습니다. 아래 ‘고려할 점’ 참고)
  • 상속 (inheritance)
    • 정의: 기존 객체(부모 클래스)의 속성과 기능을 새로운 객체(자식 클래스)가 물려받아 코드 중복을 줄이고 확장성을 높이는 방식입니다.
    • C 구현:
      • 구조체 포함: 파생(자식) 구조체의 첫 번째 멤버로 기반(부모) 구조체 변수를 포함시킵니다. 이렇게 하면 메모리 레이아웃상 자식 구조체 포인터를 부모 구조체 포인터로 안전하게 형 변환(casting)할 수 있어, 부모의 멤버에 접근하거나 부모 타입을 요구하는 함수에 자식 객체를 전달하는 등 상속을 구현할 수 있습니다.
  • 다형성 (polymorphism)
    • 정의: 동일한 인터페이스(함수 호출 방식)를 사용하되, 실제 객체의 타입에 따라 실행 시점에 서로 다른 동작을 수행하게 만드는 특성입니다.
    • C 구현:
      • 함수 포인터 (function pointer): 구조체 내에 함수 포인터 멤버를 포함시킵니다. (이를 활용해 ‘vtable’ 또는 가상 함수 테이블을 구현하기도 합니다). 객체를 생성할 때 해당 객체 타입에 맞는 실제 함수의 주소를 이 포인터에 할당합니다. 이후, 공통 인터페이스(예: 부모 구조체 포인터)를 통해 이 함수 포인터를 호출하면, 객체의 실제 타입에 맞는 함수가 실행됩니다.

이 원칙들을 C에서 구현하면 코드의 모듈화와 유연성을 향상시킬 수 있습니다.

코드 예제로 보는 C 언어의 객체 지향

이제 앞서 설명한 원칙들이 실제 코드로 어떻게 구현되는지 간단한 ShapeCircle 예제를 통해 살펴보겠습니다.

#include <stdio.h>
#include <stdlib.h>

// 기본 "클래스": Shape
typedef struct _Shape Shape; // 전방 선언
struct _Shape {
  // 가상 함수 역할의 함수 포인터 (다형성)
  void (*draw) (Shape* self);
  // 공통 데이터 (은닉화의 대상)
  int color;
};

// Shape의 기본 draw 함수 (오버라이드될 수 있음)
void shape_draw (Shape* self)
{
  printf ("Drawing Shape with color %d\n", self->color);
}

// Shape의 생성자 역할을 수행하는 함수 (은닉화)
// 객체 상태를 초기화하므로 Shape* 타입의 포인터를 사용
// 이 예제에서는 편의상 별도의 shape_new 함수는 구현하지 않음
void shape_init (Shape* self, int color)
{
  self->draw  = shape_draw; // 기본 draw 함수 연결
  self->color = color;
}

// 파생 "클래스": Circle
typedef struct {
  Shape  base;   // 상속 구현: Shape 구조체를 첫 번째 멤버로 포함
  double radius; // Circle만의 데이터
} Circle;

// Circle의 draw 함수: Shape의 draw를 오버라이드 (다형성)
void circle_draw (Shape* self)
{
  Circle* circle = (Circle*) self; // 자식 타입으로 다운캐스팅
  printf ("Drawing a circle with radius: %.2f and color %d\n",
          circle->radius, circle->base.color); // 부모 멤버 접근
}

// Circle 생성자 역할 함수: 동적 메모리 할당 및 vtable 역할 초기화
Circle* circle_new (double radius, int color)
{
  Circle* circle = (Circle*) malloc (sizeof (Circle)); // 메모리 할당

  if (circle == NULL)
  {
    fprintf (stderr, "메모리 할당 실패\n");
    abort ();
  }

  shape_init ((Shape*) circle, color); // 부모  초기화 (중요!)
  // 다형성: Circle의 draw 함수로 재지정 (오버라이드)
  circle->base.draw = circle_draw;
  circle->radius    = radius; // 자식 초기화

  return circle;
}

// 메모리 해제 함수 (소멸자 역할)
void circle_free (Circle* circle)
{
  // 만약 Circle 내부에 동적 할당된 다른 멤버가 있다면 여기서 해제해야 함
  free (circle); // 객체 자체의 메모리 해제
}

// 예제 사용
int main ()
{
  // 객체 생성
  Circle* myCircle = circle_new (5.0, 1); // 반지름 5.0, 색상 1인 원 생성

  /* 다형성을 이용하여 base 포인터로 draw 호출 */
  // myCircle을 Shape* 타입으로 전달
  Shape* shape = (Shape*) myCircle;

  if (shape && shape->draw)
    shape->draw(shape); // 실제로는 circle_draw가 호출됨

  // 메모리 해제
  circle_free (myCircle);

  return 0;
}Code language: PHP (php)

설명:

  • Shape 구조체는 공통 데이터(color)와 동작(draw 함수 포인터)을 가지며 기본 인터페이스 역할을 합니다. (은닉화, 다형성 기반)
  • Circle 구조체는 Shape를 첫 멤버로 포함하여 Shape의 속성과 기능을 ‘상속‘받는 효과를 냅니다. (상속 구현)
  • circle_new 함수는 객체를 생성하고 draw 함수 포인터를 circle_draw로 설정하여, Shape 포인터를 통해 draw를 호출해도 Circle의 동작이 수행되도록 합니다. (다형성)
  • shape_init, circle_new, circle_free 함수는 객체의 생성과 소멸을 관리하며, 객체의 생명 주기를 관리하는 역할을 합니다.

C로 OOP 구현 시 고려할 점

  • 수동 메모리 관리: C는 가비지 컬렉션이 없으므로 malloc, free 등을 사용한 명시적 메모리 관리가 필수적입니다. 객체 생성 시 할당하고, 소멸 시 반드시 해제해야 합니다. 특히, 객체 내부에 또 다른 동적 할당 멤버가 있거나 복잡한 상속 구조에서는 소멸(_free) 함수의 역할과 호출 순서가 중요해지며 관리가 복잡해질 수 있습니다.
  • 접근 제한자의 부재 (은닉화의 한계): private, protected 같은 접근 제한자가 없습니다. 헤더 파일에는 공개할 인터페이스만 선언하고 내부 구현은 소스 파일에 숨기는 방식(.c 파일 내 static 활용 등)으로 정보 은닉을 구현할 수는 있지만, C++처럼 언어 차원에서 엄격하게 강제되는 것이 아닙니다. 즉, 여전히 외부 코드에서 구조체 멤버에 직접 접근하는 것을 막을 수 없어 완전한 은닉화는 어렵고, 프로그래머의 관례(convention)에 의존하게 됩니다.
  • 코드 복잡성 증가: 상속, 다형성 등을 수동으로 구현하면 보일러플레이트 코드가 늘어나고 구조가 복잡해질 수 있습니다. 팀 내 코딩 관례(예: 부모 구조체는 항상 첫 번째 멤버로, 함수 명명 규칙 등)을 정하고 주석, 문서화를 통해 구현 방식을 명확히 하는 것이 중요합니다.
  • 타입 안정성 부족: 컴파일 타임에 엄격한 타입 체크가 이루어지지 않을 수 있으며, 특히 포인터 캐스팅 시 주의가 필요합니다.

결론 및 더 나아가기

C 언어로 객체 지향 프로그래밍을 구현하는 것은 가능하지만, 언어 자체의 지원이 아니므로 C++ 등에 비해 더 많은 노력과 주의가 필요하며 명확한 한계점을 가집니다. 하지만 이 과정은 C 언어의 깊은 이해와 설계 능력 향상에 큰 도움이 될 수 있습니다.

특히 임베디드 시스템이나 성능이 중요한 환경에서 객체 지향 3원칙(은닉화, 상속, 다형성)을 C로 구현하는 경험은 모듈화되고 재사용 가능한 코드를 작성하는 데 유용할 수 있습니다.

여기서 더 나아가, 함수 포인터 테이블(vtable)을 명시적으로 사용하여 다형성을 구현하는 좀 더 정교한 방법이나, 싱글턴 패턴, 팩토리 메소드 패턴 등 다양한 디자인 패턴을 C 언어로 구현해보는 것도 좋은 학습이 될 것입니다. C 언어의 제약 속에서도 높은 수준의 설계 원칙을 실현하는 방법을 고민하며 프로그래밍 역량을 키워나가시길 바랍니다.


게시됨

카테고리

작성자

태그:

댓글

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다