우리가 흔히 배우는 교과서에서 C 언어는 순차적으로 코드를 실행하는 대표적인 '절차지향 언어'라고 소개됩니다. 반면 C++, Java, Python 등은 '객체지향 언어'라고 명확히 선을 긋곤 하죠. 이 때문에 많은 초보 개발자가 C 언어로는 객체지향 아키텍처를 설계할 수 없다고 오해합니다. 하지만 C++이나 Java 같은 언어가 세상에 태어나기 전에도 선배 개발자들은 C 언어의 기본 문법을 조합해 완벽한 객체지향 개념을 코드로 구현해 사용해 왔습니다. 심지어 지금 이 순간에도 전 세계 컴퓨터 운영체제의 기반이 되는 '리눅스 커널(Linux Kernel)' 내부 코드는 구조체와 함수 포인터를 활용한 객체지향 기술로 가득 차 있습니다. 언어적 한계를 뛰어넘어 C 언어를 클래스처럼 다루는 OOP 설계 비법을 공유해 드리겠습니다.
핵심 요약 3줄
- 메서드를 담은 구조체: C 언어에는 클래스(Class)가 없지만, 구조체 내부에 '함수 포인터'를 멤버로 선언하면 객체의 상태(데이터)와 행동(메서드)을 하나로 묶는 캡슐화를 이뤄낼 수 있습니다.
- 메모리 정렬을 이용한 상속: 자식 구조체의 맨 첫 번째 멤버로 부모 구조체를 그대로 포함시키면, 메모리 시작 주소가 완벽히 일치하는 C 언어 특유의 정적 상속 구조가 완성됩니다.
- 추상화와 다형성의 완성: 동일한 부모 구조체 포인터 인터페이스를 공유하면서, 개별 자식 객체들이 서로 다른 함수 주소를 가리키게 만들면 런타임 다형성을 구현할 수 있습니다.
1. 객체지향 4대 핵심 요소의 C 언어 변환 매핑
객체지향 프로그래밍의 핵심 철학들이 C 언어 문법에서 각각 어떻게 매칭되어 구현되는지 표로 명확하게 비교해 드립니다.
| OOP 핵심 개념 | 일반적인 객체지향 언어의 표현 | C 언어에서의 영리한 대체 구현 메커니즘 | 실무적 달성 효과 |
| 캡슐화 (Encapsulation) | class 키워드 내 변수와 메서드 배치 | 구조체(Struct) + 함수 포인터 멤버 조합 | 데이터와 제어 로직을 하나의 객체 단위로 묶어 관리 |
| 상속성 (Inheritance) | class Dog : public Animal 형식 선언 | 구조체 중첩 (Embedding) (자식 맨 앞에 부모 배치) | 부모 속성을 그대로 재사용하며 새로운 멤버 확장 |
| 다형성 (Polymorphism) | 가상 함수(virtual) 및 오버라이딩 | 함수 포인터 바인딩 및 부모 타입 강제 형 변환 | 하나의 인터페이스 포인터 배열로 다른 동작들 유도 |
| 추상화 (Abstraction) | 인터페이스(Interface) 및 추상 클래스 | 소스 파일(.c)과 헤더 파일(.h)의 정보 은닉 | 구체적인 내부 연산은 숨기고 필요한 API 주소만 노출 |
2. C 언어로 구현하는 OOP 3대 실전 예제
2.1 캡슐화 (Encapsulation)
데이터와 함수를 한 몸으로 묶어, 마치 자바의 인스턴스 메서드를 호출하듯 구조체를 제어하는 방식입니다.
#include <stdio.h>
#include <string.h>
// 1. 클래스 역할을 할 구조체를 정의합니다.
typedef struct Person {
char name[50];
int age;
// 자기 자신을 매개변수로 받는 함수 포인터(메서드)를 선언합니다.
void (*print)(struct Person *self);
} Person;
// 2. 실제 본체 역할을 할 전용 함수를 정의합니다.
void printPerson(Person *self) {
printf("이름: %s, 나이: %d\n", self->name, self->age);
}
int main() {
Person person;
strcpy(person.name, "홍길동");
person.age = 30;
// 구조체 멤버에 실제 함수의 주소를 연결(바인딩)합니다.
person.print = printPerson;
// 객체지향 스타일로 메서드를 대리 호출합니다.
person.print(&person);
return 0;
}
2.2 상속성 (Inheritance)
C 언어 구조체는 메모리를 선언 순서대로 정렬합니다. 이 특성을 이용하면 부모의 속성을 그대로 물려받을 수 있습니다.
#include <stdio.h>
#include <string.h>
// 부모 클래스 역할
typedef struct {
char name[50];
void (*speak)(void);
} Animal;
// 자식 클래스 역할
typedef struct {
Animal base; // [핵심] 부모 구조체를 맨 첫 번째 멤버로 배치합니다.
int legs;
} Dog;
void dogSpeak(void) {
printf("멍멍! 🐾\n");
}
int main() {
Dog my_dog;
strcpy(my_dog.base.name, "바둑이");
my_dog.base.speak = dogSpeak;
my_dog.legs = 4;
// 부모로부터 물려받은 속성과 메서드에 접근합니다.
printf("강아지 이름: %s\n", my_dog.base.name);
my_dog.base.speak();
return 0;
}
2.3 다형성 (Polymorphism)
자식 객체들의 주소를 부모 타입 포인터 하나로 전부 흡수하여, 하나의 반복문 안에서 각자 다르게 구동시키는 고급 기법입니다.
#include <stdio.h>
// 도형 추상 인터페이스 역할
typedef struct Shape {
void (*draw)(struct Shape *self);
} Shape;
// 사각형 객체
typedef struct {
Shape base; // 부모 포함
int width, height;
} Rectangle;
// 원형 객체
typedef struct {
Shape base; // 부모 포함
int radius;
} Circle;
void drawRectangle(Shape *self) {
// 부모 포인터를 자식 포인터 타입으로 강제 변환하여 자식 멤버에 접근합니다.
Rectangle *rect = (Rectangle *)self;
printf("📐 사각형 그리기 (가로: %d, 세로: %d)\n", rect->width, rect->height);
}
void drawCircle(Shape *self) {
Circle *circ = (Circle *)self;
printf("🔮 원 그리기 (반지름: %d)\n", circ->radius);
}
int main() {
Rectangle rect = { {drawRectangle}, 10, 20 };
Circle circ = { {drawCircle}, 5 };
// [다형성의 핵심] 자식들의 주소를 부모인 Shape 포인터 배열로 묶어버립니다.
// 구조체 맨 앞 주소가 부모이므로 (Shape *) 캐스팅이 안전하게 성립합니다.
Shape *shapes[] = { (Shape *)&rect, (Shape *)&circ };
printf("--- 다형성 루프 실행 ---\n");
for (int i = 0; i < 2; i++) {
shapes[i]->draw(shapes[i]); // 하나의 인터페이스 코드가 상황에 따라 다르게 작동합니다.
}
return 0;
}
C 언어 객체지향 설계를 위한 개발 팁 (Tip)
- 불완전 구조체(Incomplete Type)로 완벽한 private 멤버를 구현하세요: C 언어 구조체의 멤버들은 기본적으로 외부 소스 파일에 전부 노출(public)되어 있습니다. 내부 데이터를 외부에서 함부로 조작하지 못하게 은닉하고 싶다면, 헤더 파일(.h)에는 typedef struct Person Person; 명칭만 선언해 두고, 실제 상세 멤버 구조는 소스 파일(.c) 안에만 숨겨서 정의하세요. 이렇게 하면 외부 사용자는 전용 생성 함수 인터페이스를 통해서만 객체를 다룰 수 있어 완벽한 데이터 추상화가 달성됩니다.
- 함수 포인터 오버헤드가 걱정된다면 가상 함수 테이블(VTable)을 모방하세요: 구조체 인스턴스를 수만 개 생성해야 하는 상황에서 구조체마다 함수 포인터 멤버를 주렁주렁 달아두면 메모리 낭비가 극심해집니다. C++이 내부적으로 처리하는 것처럼, 함수 주소들만 따로 모아둔 구조체 테이블(ShapeInterface)을 단 하나만 static으로 선언해 두고, 개별 객체 구조체는 이 테이블의 주소 딱 하나만 가리키게 설계하면 메모리를 획기적으로 아낄 수 있습니다.
- 매개변수로 늘 self 주소를 전달하는 규칙을 세우세요: C++이나 자바는 메서드를 호출할 때 보이지 않게 this 포인터가 자동으로 전달되지만, 오리지널 C 언어는 그런 자동화가 없습니다. 따라서 캡슐화된 함수 포인터를 설계할 때는 void (*update)(struct Object *self, int data); 처럼 항상 제어할 자기 자신의 구조체 주소를 명시적 첫 번째 인자로 넘겨받도록 설계해야 함수 내부에서 상태 값을 바꿀 수 있습니다.
초보자가 흔히 하는 실수 (Mistakes)
- 부모 구조체를 첫 번째 멤버로 배치하지 않는 대참사: C 언어에서 자식 구조체가 부모 구조체를 상속받도록 흉내 낼 때, Animal base; 멤버를 구조체의 맨 처음이 아닌 중간이나 끝에 배치하면 절대 안 됩니다. 컴파일러가 구조체 주소를 해석할 때 부모 포인터 타입으로 강제 캐스팅((Shape *)&rect)을 할 수 있는 이유는 자식 구조체의 시작 주소와 첫 번째 멤버의 시작 주소가 물리적으로 100% 일치하기 때문입니다. 순서가 뒤틀리면 엉뚱한 메모리 번지를 찔러 바로 프로그램이 다운됩니다.
- 함수 포인터 연결(바인딩) 누락으로 인한 널 포인터 크래시: 구조체 인스턴스를 선언하거나 동적 할당으로 생성한 뒤, person.print = printPerson; 처럼 멤버 함수 포인터에 실제 함수의 주소를 대입해 주는 과정을 깜빡 잊는 경우가 많습니다. 주소가 비어있는(NULL) 상태에서 person.print(&person);을 호출하면 운영체제는 즉시 세그멘테이션 폴트(Segmentation Fault)를 발생시키며 프로그램을 강제 종료시킵니다. 생성자 함수를 따로 만들어 대입 과정을 자동화하는 것이 안전합니다.
- 얕은 복사(Shallow Copy)로 인한 함수 포인터 소멸 오류: 함수 포인터 멤버가 들어있는 구조체 객체를 다른 구조체에 대입 연산자(personA = personB;)로 단순 복사하면 주소값만 그대로 복사됩니다. 만약 자식 객체가 동적 할당된 자원을 가리키고 있었거나 런타임에 주소가 바뀌는 구조였다면, 한쪽 객체가 소멸(free)되거나 변경되었을 때 다른 쪽 구조체의 함수 포인터가 미아 상태가 되는 '댕글링 포인터(Dangling Pointer)' 오류가 발생할 수 있습니다.
맺음말
C 언어를 활용한 객체지향 프로그래밍 기법은 단순한 문법적 유희가 아닙니다. 하드웨어 리소스를 극도로 아껴 쓰면서도 대규모 소프트웨어의 복잡성을 통제해야 하는 임베디드 시스템 아키텍처나 운영체제 커널 개발, 대형 게임 엔진의 코어 레이어에서 지금까지도 생생하게 살아 숨 쉬는 최고의 실무 최적화 기법입니다. 언어가 객체지향을 지원하지 않는다고 해서 절망할 필요가 전혀 없습니다. C 언어의 유연한 포인터 체계를 이해하고 있다면 세상의 그 어떤 프로그래밍 패러다임도 내 입맛대로 재조립해 낼 수 있습니다.
C 언어의 고난도 문법 응용 능력을 장착하신 것을 진심으로 축하드립니다!
구조체 포인터 형 변환 연산이 헷갈리거나 다향성 루프 구성 중에 컴파일러 경고가 발생한다면 언제든 소프트웨어 공장 댓글 창을 두드려 주세요!
'Core Programming > C for Systems Engineering' 카테고리의 다른 글
| C언어 임베디드 하드웨어 제어: volatile 레지스터 포인터와 비트 연산 예제 총정리 (0) | 2024.12.15 |
|---|---|
| C언어 멀티스레드 프로그래밍 완벽 정리: pthread 함수 활용과 동기화(뮤텍스, 조건 변수) 예제 (0) | 2024.12.15 |
| C언어 표준 라이브러리 총정리: 필수 헤더 파일 5가지와 핵심 함수 예제 (0) | 2024.12.14 |
| C언어 전처리기 총정리: #define 매크로 함수 부작용부터 조건부 컴파일 인클루드 가드까지 (0) | 2024.12.14 |
| C언어 고급 포인터 완벽 정리: 이중 포인터부터 함수 포인터, 포인터 배열 vs 배열 포인터 차이점 (0) | 2024.12.14 |