개발 관련 지식

Clean Architecture 정리 - 2부 5장 객체지향 프로그래밍

고감귤 2025. 6. 8. 22:10

5장. 객체지향 프로그래밍

C언어는 객체지향 언어가 아니다.

하지만, 객체지향 프로그래밍의 핵심 개념 일부는 C언어 시대부터 이미 사용되고 있었었다. 

 

이 글에서는 객체지향의 대표적인 세 축인 캡슐화, 상속, 다형성이 C언어에서 어떻게 구현될 수 있는지를 살펴본다.


캡슐화: 정보를 숨기고, 인터페이스만 공개하기

C언어는 private, public 같은 접근 제어자를 지원하지 않는다.

하지만 캡슐화처럼 설계할 수 있는 패턴은 존재한다.

 

✅ 방법 요약

  • 헤더 파일 (.h) : 구조체 내용을 숨기고, 외부에 함수 시그니처만 공개
  • 소스 파일 (.c) : 구조체 정의와 함수 구현 작성
  • 외부에서는 구조체 내부를 알 수 없고, 제공된 함수로만 조작 가능
// animal.h

// 구조체 내부는 공개하지 않음
typedef struct Animal Animal;

Animal* animal_create(int id);
void animal_print(const Animal* a);
void animal_destroy(Animal* a);
// animal.c

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

struct Animal {
    int id;
};

Animal* animal_create(int id) {
    Animal* a = malloc(sizeof(Animal));
    a->id = id;
    return a;
}

void animal_print(const Animal* a) {
    printf("Animal ID: %d\n", a->id);
}

void animal_destroy(Animal* a) {
    free(a);
}

이렇게 하면 외부에서는 Animal 구조체의 필드에 직접 접근할 수 없고, 반드시 함수 인터페이스를 통해서만 데이터를 다룰 수 있다.

즉, 캡슐화 효과를 달성할 수 있다.

 

객체지향 언어에서는 이 캡슐화 과정을 문법적으로 지원한다. 구조체의 멤버를 private, protected, public 등으로 명시할 수 있고, 외부에서의 접근을 컴파일러가 제어해준다.


상속: 멤버 구조를 동일하게 구성하여 업캐스팅 가능하게 만들기

C에는 상속이 없다.

하지만 다음 패턴을 사용하면 마치 상속처럼 동작하게 만들 수 있다.

 

✅ 방법 1: 상위 구조의 멤버를 동일하게 포함

// "상위 클래스"
typedef struct {
    int id;
} Animal;

// "하위 클래스"
typedef struct {
    int id;           // Animal과 동일한 구조
    int leg_count;
} Dog;

 

✅ 방법 2: 업캐스팅(Casting)

void print_animal(Animal* a) {
    printf("Animal ID: %d\n", a->id);
}

int main() {
    Dog d = { .id = 42, .leg_count = 4 };
    print_animal((Animal*)&d); // 업캐스팅
    return 0;
}

⚠️ 주의할 점

  • 상위 타입의 멤버 변수들을 동일한 순서와 타입으로 직접 포함해야 메모리 레이아웃이 일치함
  • 컴파일러는 이를 진짜 상속으로 인식하지 않음 → 사용자 책임이 큼

객체지향 언어는 이와 같은 메모리 배치, 포인터 변환, 함수 호출을 개발자가 직접 신경 쓰지 않도록 처리해준다. 따라서 실수할 여지가 줄어든다.


다형성: 공통 인터페이스와 함수 포인터

C언어에서는 구조체 내부에 함수 포인터를 포함시켜서, 마치 "가상 함수"처럼 동작하도록 만들 수 있다.

이는 다형성을 수동으로 흉내내는 방식이다.

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

// 공통 인터페이스
typedef struct Plugin {
    const char* name;
    void (*execute)(void);
} Plugin;

// PluginA 정의
void plugin_a_execute(void) {
    printf("Plugin A executed\n");
}

Plugin* create_plugin_a() {
    Plugin* p = malloc(sizeof(Plugin));
    p->name = "plugin_a";
    p->execute = plugin_a_execute;
    return p;
}

// PluginB 정의
void plugin_b_execute(void) {
    printf("Plugin B executed\n");
}

Plugin* create_plugin_b() {
    Plugin* p = malloc(sizeof(Plugin));
    p->name = "plugin_b";
    p->execute = plugin_b_execute;
    return p;
}

int main() {
    // 플러그인 배열 구성
    Plugin* plugins[] = {
        create_plugin_a(),
        create_plugin_b()
    };

    // 모든 플러그인 실행
    for (int i = 0; i < 2; ++i) {
        printf("Running %s...\n", plugins[i]->name);
        plugins[i]->execute();
        free(plugins[i]);
    }

    return 0;
}

이 구조는 플러그인 아키텍처에서 자주 사용되며, 런타임에 다양한 동작을 동적으로 조합할 수 있다는 장점이 있다.

 

하지만:

  • 함수 포인터를 직접 수동으로 초기화해야 하며,
  • 잘못된 초기화나 NULL 포인터 참조로 인해 런타임 에러가 발생할 수 있다.
  • 인터페이스의 일관성을 강제할 수 없어, 컴파일 타임에서 오류를 잡기 어렵다.

객체지향 언어는 이런 다형성 구현에 필요한 보일러플레이트 코드와 실수 가능성을 줄여준다. 

virtual, override, interface 등의 키워드로 안정성을 높이며, 컴파일러가 구조를 검증한다.


의존성 자율화와 배포 독립성

글쓴이가, 객체지향 프로그래밍에서 강조한 부분이다.

객체지향 프로그래밍의 또 다른 장점은 쉽게 의존성의 방향을 제어할 수 있다는 데 있다.

이는 객체들이 서로를 직접 참조하기보다는, 추상화된 인터페이스에 의존하도록 만드는 원칙(Inversion of Control, DIP 등)을 언어와 설계 관점에서 쉽게 적용할 수 있기 때문이다.

 

예를 들어, 업무 규칙이 데이터베이스와 사용자 인터페이스(UI)에 의존하는 경우라고 생각해보자.

이 경우, UI나 DB에 변경이 생기면 그 영향을 업무 규칙까지 반영해야 하며, 전체를 재컴파일하거나 재배포하는 일이 자주 발생한다.

즉, 컴포넌트 간 결합도가 높아 독립적인 배포가 어렵다.

반대로, 데이터베이스와 UI가 업무 규칙에 의존하도록 만든다. 

이렇게 하면 UI와 데이터베이스는 업무 규칙의 '플러그인'이 된다.

이 구조에서는 업무 규칙을 구성하는 소스코드가 UI나 DB에 전혀 의존하지 않고, 결과적으로 업무 규칙은 UI나 DB의 영향을 받지 않고 독립적으로 배포 가능해진다.

UI나 DB에서 변경된 사항도 업무 규칙에 영향을 주지 않기 때문에 개별적이고 독립적으로 배포가 가능해진다.

 

※ 본 글은 『Clean Architecture』(로버트 C. 마틴 저) 2부의 5장을 기반으로 학습 목적으로 요약한 글입니다.

※ 이 글은 책의 내용을 요약한 것으로, 원문 없이 읽을 경우 오해의 여지가 있을 수 있습니다. 정확한 이해를 위해 원서의 정독을 권장합니다.