[전문가를 위한 C] Chapter 6. OOP와 캡슐화

YUSHIN KIM·2026년 9월 3일

전문가를 위한 C

목록 보기
6/14

Chapter 6. OOP와 캡슐화

C의 절차지향적 문법 특성상 객체지향을 특징으로 삼을 수 없다. 그러므로 클래스, 상속, 가상 함수와 같은 객체지향 개념은 직접 지원되지 않는다.

하지만 모든 범용 프로그래밍 언어에는 자료형을 확장(extend)하는 방법이 존재하기 때문에, C는 간접적인 방식으로 객체지향 요소들을 구현할 수 있다.


6.1 객체지향적 사고

6.1.1 정신적 개념

코드 박스 6-1 명명 규칙에 따라 student 접두어를 갖고, 학새 10명의 정보를 담는 4개의 배열

char* student_first_names[10];
char* student_surnames[10];
int student_ages[10];
double student_marks[10];

위 코드는 student라는 동일한 개념으로 변수 여러 개를 하나의 그룹으로 묶었다. 명명은 이러한 방식으로 일어난다.

코드 박스 6-2 학생 10명의 정보를 담을 임시 이름을 갖는 4개의 배열

char* aaa[10];
char* bbb[10];
int ccc[10];
double ddd[10];

위 코드는 반대로 명명 규칙을 전혀 준수하지 않은 코드이다.

객체지향적 사고는 개념과 개념 사이 관계의 관점에서 사고하는 것이다. 서로 연관된 객체와 이에 대응하는 개념 및 관계를 제대로 이해해야 한다. 객체지향 지도는 여러 개념들 간의 상호 관계로 형성된다.


6.1.2 마인드맵과 객체 모델

이해 단계(understanding phase)에서는 개념과 개념 사이의 관계에 대한 마인드맵을 구성한다. 객체지향적 프로그램에서 개념, 마인드맵은 각각 객체, 객체 모델에 대응된다.


6.1.3 코드에는 없는 객체

객체지향 설계(OOD: Object-Oriented Design) 단계에서는 프로그램이 실행되어 객체가 생성되기 전 객체들 간의 관계에 대해 세부 정의 및 명령어를 수립한다.


6.1.4 객체 속성

객체는 속성 집합을 가질 수 있다. 모든 속성에 할당된 값들을 총체적으로 상태(state)라 한다. 수명(lifetime) 동안 상태가 수정될 수 있는 객체는 가변(mutable), 그렇지 않은 객체를 불변(immutable)이라 한다. 무상태(stateless) 객체 또한 존재할 수 있다.


6.1.5 도메인

도메인은 엔지니어의 작업 경계를 구분짓는 단위이다. 사전 정의된 구체적인 용어집(terminology, glossary)의 범위 내에서 도메인이 확정된다.


6.1.6 객체 사이의 관계

객체는 상호 연관되어 객체 모델을 형성한다. 두 객체 간 명시적인 관계가 형성된다면 해당 객체의 상태는 변경된다. 그리고 이것이 객체의 가변성에 영향을 준다.


6.1.7 객체지향 작업

OOP 언어는 프로그램에서 런타임에 객체 생성, 소멸, 상태 변경을 계획할 수 있게 해준다.

객체 생성(construction)을 계획하는 방법은 두 가지가 있다.

  1. 빈 객체 또는 최소 속성 집합을 갖는 객체 생성 (프로토타입 기반의 OOP, prototype-based OOP)
    • 런타임에 더 많은 속성이 결정·추가된다. 같은 프로그램을 다르게 두 번 실행하면 같은 객체도 다른 속성 집합을 가질 수 있다.
    • 각각의 객체는 별도의 개체(entity)로 취급된다. 같은 클래스에 속하는 것처럼 보이는 두 객체는 런타임 동안 상이한 속성 집합을 가질 수 있다.
    • 대다수의 경우 인터프리터 프로그래밍 언어(interpreted programming language)에서 사용된다. 속성은 맵(map) 또는 해시(hash)로 관리된다.
  2. 속성이 사전에 결정되어 실행 도중 변경되지 않는 객체 생성 (클래스 기반의 OOP)
    • 런타임에 객체는 사전 정의된 속성 집합을 유지한다. 가변 객체일 경우 속성 값 변경만 가능하다.
    • 사용자는 객체의 런타임 속성 집합에 대한 명세인 객체 템플릿 또는 클래스를 사전 설계하고 컴파일하여 런타임에 제공해야 한다.

객체 또는 인스턴스(instance)는 메모리상에 객체가 할당된 실제 위치를 참조하는 데 사용된다. 참조(reference)는 객체를 참조하는 포인터와 같다.

객체 모델의 참조 무결성(referential integrity) 준수를 위해 객체의 수명 동안 할당된 모든 리소스는 객체가 파괴될 때 해제되어야 한다.

객체 수정 또는 상태 변경은 기존의 값을 변경하거나 속성을 추가·삭제하여 구현할 수 있다. 불변 객체의 상태를 변경하는 것은 보통의 객체지향 언어에서는 금지된 행위이다.


6.1.8 행위를 갖는 객체

모든 객체는 속성 집합과 함께 기능 집합도 갖는다. OOP에서 기능 집합은 항상 도메인의 요구사항에 따라 정의된다. 모든 기능은 객체의 속성 값을 변경하여 상태를 변경할 수 있다.


6.2 C가 객체지향이 아닌 이유

객체지향은 인간의 사고 과정과 친숙한 방식, 절차지향은 CPU의 연산 과정과 친숙한 방식이다. C는 객체지향적으로 작성된 고수준 로직을 저수준의 절차지향적 명령어로 변환하는 레이어로서, 객체지향과 절차지향 방법론 사이의 경계에 위치해 있다.


6.3 캡슐화

캡슐화(encapsulation) 과정을 통해 속성과 기능 집합은 객체라는 개체(entity)에 삽입된다.


6.3.1 속성 캡슐화

캡슐화를 위해 변수명을 사용해야 하고, 상이한 변수를 묶어 같은 객체 내에 그룹화해야 한다.

코드 박스 6-3 두 픽셀을 나타내는 여러 변수를 이름으로 그루핑하기

int pixel_p1_x		= 56;
int pixel_p1_y		= 34;
int pixel_p1_red	= 123;
int pixel_p1_green	= 37;
int pixel_p1_blue	= 127;
int pixel_p2_x		= 212;
int pixel_p2_y		= 994;
int pixel_p2_red	= 127;
int pixel_p2_green	= 127;
int pixel_p2_blue	= 0;

위 코드는 암묵적(implicit) 객체인 p1, p2에서 변수를 그루핑할 때 명명 규칙을 사용한 예이다. 개발자만이 객체의 존재를 알고 있기에 암묵적이라고 한다. 개발자와 프로그래밍 언어 둘 다 캡슐(객체)의 존재를 안다면 명시적(explicit) 속성 캡슐화를 제공했다고 말할 수 있다.

C는 구조체를 통해 속성에 대한 명시적 캡슐화는 제공하지만, 기능에 대해선 제공하지 않는다. 그러므로 기능 캡슐화에 관해선 암묵적인 방식을 고안해야 한다.

코드 박스 6-4 pixel_t 구조체와 pixel_t 변수 2개를 선언하기

typedef struct {
	int x, y;
    int red, green, blue;
} pixel_t;
pixel_t p1, p2;

p1.x = 56;
p1.y = 34;
p1.red = 123;
p1.green = 37;
p1.blue = 127;

p2.x = 212;
p2.y = 994;
p2.red = 127;
p2.green = 127;
p2.blue = 0;
  • 속성 캡슐화는 x, y, red, green, blue 속성을 사용자 정의 자료형인 pixel_t에 넣을 때 발생한다.
  • 캡슐화는 언제나 새로운 자료형을 만든다.
  • p1, p2는 런타임에 명시적 객체가 된다.
  • 새 자료형인 pixel_t는 클래스 또는 객체 템플릿의 유일한 속성이다. 하지만 C는 명시적 속성 캡슐화만 제공하므로 구조체가 클래스에 일대일로 대응되지는 않는다.
  • 템플릿(pixel_t)은 객체 생성 시 사전 결정된 속성 집합을 갖는다.
  • 객체 선언 시 메모리 할당(creation), 기본 값을 사용한 생성(construction)이 동시에 발생한다. C에서의 기본 정수 값은 0이다.

6.3.2 행위 캡슐화

객체는 속성과 메서드의 캡슐이다. 속성은 값(value)을 전달하고, 메서드는 행위(behavior)를 전달한다.

C++ 같은 클래스 기반의 객체지향 언어에서는 속성과 기능을 클래스 안에 묶을 수 있고, JavaScript 같은 프로토타입 기반의 객체지향 언어에서는 빈 객체(ex nihilo)를 사용하거나 복제할 수 있다.

코드 박스 6-5 자바스크립트에서 client 객체를 생성하기

// construct empty object
var clientObj = {};

// set property
clientObj.name = "John";
clientObj.surname = "Doe";

// add method
clientObj.orderBankAccount = function() {
  ...
}
  
// method call
clientObj.orderBankAccount();

프로토타입 기반 프로그래밍 언어에서는 런타임에 속성과 기능이 추가된다. 특히 orderBankAccount를 추가하는 부분은 익명 함수(anonymous function)을 객체의 orderBankAccount 속성에 할당한 것이다.

코드 박스 6-6 C++에서 client 객체 생성하기

class Client {
public:
	void orderBankAccount() {
    	...
    }
    std::string name;
    std::string surname;
};
...
Client clientObj;
clientObj.name = "John";
clientObj.surname = "Doe";
...
clientObj.orderBankAccount();

클래스 기반 프로그래밍 언어에서는 컴파일 타임에 속성(data member)과 기능(member function)이 명세되어 있어야 한다.

오픈 소스 또는 유명 C 프로젝트에서는 공통적으로 암묵적 캡슐화(implicit encapsulation) 기법을 통해 행위 캡슐화를 수행한다.

  • 객체의 속성을 저장하기 위해 C 구조체를 사용한다. 이 구조체는 속성 구조체(attribute structure)라고 한다.
  • 행위 캡슐화를 위해 C 함수를 사용한다. 이 함수는 행위 함수(behavior function)라고 한다. 행위 함수는 속성 구조체 외부에 존재해야 한다.
  • 행위 함수는 속성 구조체의 포인터를 인자로 받아야 한다.
  • 행위 함수는 일관적인 명명 규칙을 준수해야 한다.
  • 일반적으로 속성 구조체의 선언이 있는 헤더 파일에서 행위 함수를 선언한다. 이 헤더 파일은 선언 헤더(declaration header)라고 한다.
  • 일반적으로 선언 헤더를 포함하는 하나 이상의 소스 파일에서 행위 함수를 정의한다.

코드 박스 6-7 [예제 6-1] Car 클래스의 속성 구조체와 행위 함수의 선언

#ifndef EXTREME_C_EXAMPLES_CHAPTER_6_1_H
#define EXTREME_C_EXAMPLES_CHAPTER_6_1_H

// this structure has all attributes related to the Car object
typedef struct {
    char name[32];
    double speed;
    double fuel;
} car_t;

// this function declarations correspond to the behaviors of the Car object
void car_construct(car_t*, const char*);
void car_destruct(car_t*);
void car_accelerate(car_t*);
void car_brake(car_t*);
void car_refuel(car_t*, double);

#endif

암묵적 캡슐화 기법에서 각각의 객체는 고유한 속성 구조체 변수를 갖지만, 동일한 행위 함수를 공유한다. 이는 준객체지향(semi-object-oriented) 코드라고도 할 수 있다.

코드 박스 6-8 [예제 6-1] Car 클래스의 행위 함수에 관한 정의

#include <string.h>

#include "6_1.h"

// function definitions
void car_construct(car_t* car, const char* name) {
    strcpy(car->name, name);
    car->speed = 0.0;
    car->fuel = 0.0;
}

void car_destruct(car_t* car) {
    // no need to do anything
}

void car_accelerate(car_t* car) {
    car->speed += 0.05;
    car->fuel -= 1.0;
    if (car->fuel < 0.0) {
        car->fuel = 0.0;
    }
}

void car_brake(car_t* car) {
    car->speed -= 0.07;
    if (car->speed < 0.0) {
        car->speed = 0.0;
    }
    car->fuel -= 2.0;
    if (car->fuel < 0.0) {
        car->fuel = 0.0;
    }
}

void car_refuel(car_t* car, double amount) {
    car->fuel = amount;
}

함수가 속성 구조체의 포인터를 인자로 받지 않는다면 평범한 C 함수로 간주된다. 유지보수성을 위해 속성 구조체와 행위 함수의 선언을 같은 헤더 파일에 묶고, 행위 함수 정의는 별도의 소스 코드로 분리한다.

코드 박스 6-9 [예제 6-1]의 main 함수

#include <stdio.h>

#include "6_1.h"

// main function
int main(int argc, char** argv) {

    // create an object variable
    car_t car;

    // construct the object
    car_construct(&car, "Renault");

    // main algorithm
    car_refuel(&car, 100.0);
    printf("Car is refueled, the correct fuel level is %f\n", car.fuel);
    while (car.fuel > 0) {
        printf("Car fuel level: %f\n", car.fuel);
        if (car.speed < 80) {
            car_accelerate(&car);
            printf("Car has been accelerated to the speed: %f\n", car.speed);
        } else {
            car_brake(&car);
            printf("Car has been slowed down to the speed: %f\n", car.speed);
        }
    }

    printf("Car ran out of the fuel! Slowing down ...\n");
    while (car.speed > 0) {
        car_brake(&car);
        printf("Car has been slowed down to the speed: %f\n", car.speed);
    }

    // destruct the object
    car_destruct(&car);

    return 0;
}

main 함수가 행위 함수가 아님에도 car 객체의 속성은 공개(public) 속성이기 때문에 접근이 가능하다.

다음은 같은 예제를 C++로 작성한 것이다.

코드 박스 6-10 [예제 6-2] C++에서 Car 클래스의 선언

#ifndef EXTREME_C_EXAMPLES_CHAPTER_6_2_H
#define EXTREME_C_EXAMPLES_CHAPTER_6_2_H

class Car {
public:
    // constructor
    Car(const char*);
    // destructor
    ~Car();

    // member function
    void Accelerate();
    void Brake();
    void Refuel(double);

    // data member
    char name[32];
    double speed;
    double fuel;
};

#endif

C++는 클래스를 이해하기 때문에 위 코드는 속성과 행위 모두에 대한 명시적 캡슐화를 표현한다. 또한, 행위 함수는 모든 데이터 멤버에 접근 가능하기 때문에 포인터 매개변수가 없다.

코드 박스 6-11 [예제 6-2] C++에서 Car 클래스의 정의

#include <string.h>

#include "6_2.h"

Car::Car(const char* name) {
    strcpy(this->name, name);
    this->speed = 0.0;
    this->fuel = 0.0;
}

Car::~Car() {
    // no need to do anything
}

void Car::Accelerate() {
    this->speed += 0.05;
    this->fuel -= 1.0;
    if (this->fuel < 0.0) {
        this->fuel = 0.0;
    }
}

void Car::Brake() {
    this->speed -= 0.07;
    if (this->speed < 0.0) {
        this->speed = 0.0;
    }
    this->fuel -= 2.0;
    if (this->fuel < 0.0) {
        this->fuel = 0.0;
    }
}

void Car::Refuel(double amount) {
    this->fuel = amount;
}

this 포인터 덕분에 C++는 행위 함수가 포인터 인자를 필요로 하지 않는다.

코드 박스 6-12 [예제 6-2]의 main 함수

#include <iostream>

#include "6_2.h"

// main function
int main(int argc, char** argv) {

    // create an object variable and call its constructor
    Car car("Renault");

    // main algorithm
    car.Refuel(100.0);
    std::cout << "Car is refueled, the correct fuel level is " << car.fuel << std::endl;
    while (car.fuel > 0) {
        std::cout << "Car fuel level: " << car.fuel << std::endl;
        if (car.speed < 80) {
            car.Accelerate();
            std::cout << "Car has been accelerated to the speed: " << car.speed << std::endl;
        } else {
            car.Brake();
            std::cout << "Car has been slowed down to the speed: " << car.speed << std::endl;
        }
    }

    std::cout << "Car ran out of the fuel! Slowing down ..." << std::endl;
    while (car.speed > 0) {
        car.Brake();
        std::cout << "Car has been slowed down to the speed: " << car.speed << std::endl;
    }
    std::cout << "Car is stopped!" << std::endl;

    // the object will be automatically destructed returning main function
    return 0;
}

C++에서 소멸자 함수는 객체가 스택 가장 위에 할당되어 스코프를 떠나려 할 때 다른 스택 변수처럼 자동으로 호출된다. 이는 메모리 누수를 방지해주는 핵심 로직이다.


6.3.3 정보 은닉

정보 은닉(information-hiding)이란 특정 속성과 행위를 보호하여 외부 세계에 노출하지 않는 캡슐화의 결과이다. 기본적으로 모든 속성은 비공개로 두어 코드 변경에 따른 하위 호환성(backward compatibility) 손상을 방지하고, 구현의 세부 사항을 은닉해야 한다.

비공개 속성에는 공개 API(public API) 또는 공용 인터페이스(public interface)를 통해 접근하도록 하는 것이 바람직하다. 즉, 코드는 속성이 아닌 공용 인터페이스에 의존해야 한다.

코드 박스 6-13 [예제 6-3] List 클래스의 공용 인터페이스

#ifndef EXTREME_C_EXAMPLES_CHAPTER_6_3_H
#define EXTREME_C_EXAMPLES_CHAPTER_6_3_H

#include <unistd.h>

// attribute structure with no public attribute
struct list_t;

// allocation function
struct list_t* list_malloc();

// constructor and destructor function
void list_init(struct list_t*);
void list_destroy(struct list_t*);

// public behavior function
int list_add(struct list_t*, int);
int list_get(struct list_t*, int, int*);
void list_clear(struct list_t*);
size_t list_size(struct list_t*);
void list_print(struct list_t*);

#endif

속성을 은닉하기 위해 헤더 파일에 구조체의 선언만 두었다. 이처럼 헤더 파일을 통해 공개할 범위를 정할 수 있다.

코드 박스 6-14 [예제 6-3] List 클래스의 정의

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

#define MAX_SIZE 10

// alias of bool_t type definition
typedef int bool_t;

// list_t type definition
typedef struct {
    size_t size;
    int* items;
} list_t;

// private behavior checking if the list is full
bool_t __list_is_full(list_t* list) {
    return (list->size == MAX_SIZE);
}

// private behavior checking index
bool_t __check_index(list_t* list, const int index) {
    return (index >= 0 && index <= list->size);
}

// memory allocation for the list object
list_t* list_malloc() {
    return (list_t*) malloc(sizeof(list_t));
}

// constructor for the list object
void list_init(list_t* list) {
    list->size = 0;
    // allocate from the heap memory
    list->items = (int*) malloc(MAX_SIZE * sizeof(int));
}

// destructor for the list object
void list_destroy(list_t* list) {
    // free the allocated memory
    free(list->items);
}

int list_add(list_t* list, const int item) {
    // how to use for the private behavior
    if (__list_is_full(list)) {
        return -1;
    }
    list->items[list->size++] = item;
    return 0;
}

int list_get(list_t* list, const int index, int* result) {
    if (__check_index(list, index)) {
        *result = list->items[index];
        return 0;
    }
    return -1;
}

void list_clear(list_t* list) {
    list->size = 0;
}

size_t list_size(list_t* list) {
    return list->size;
}

void list_print(list_t* list) {
    printf("[");
    for (size_t i = 0; i < list->size; ++i) {
        printf("%d ", list->items[i]);
    }
    printf("]\n");
}

위 코드의 모든 정의는 비공개이다. 링커는 비공개 정의를 공개 선언으로 가져와 작업 프로그램을 만든다. 하지만 헤더 파일을 불러오는 사용자 입장에서 list_t 객체의 속성은 접근 불가능하다.

코드 박스 6-15 [예제 6-3]의 main 함수

#include <stdlib.h>

#include "6_3.h"

int reverse(struct list_t* source, struct list_t* dest) {
    list_clear(dest);
    for (size_t i = list_size(source) - 1; i >= 0; --i) {
        int item;
        if (list_get(source, i, &item)) {
            return -1;
        }
        list_add(dest, item);
    }
    return 0;
}

int main(int argc, char** argv) {
    struct list_t* list1 = list_malloc();
    struct list_t* list2 = list_malloc();

    // construct
    list_init(list1);
    list_init(list2);

    list_add(list1, 4);
    list_add(list1, 6);
    list_add(list1, 1);
    list_add(list1, 5);

    list_add(list2, 9);

    reverse(list1, list2);
    list_print(list1);
    list_print(list2);

    // destruct
    list_destroy(list1);
    list_destroy(list2);

    free(list1);
    free(list2);

    return 0;
}

main, reverse 함수는 List 클래스의 공개 API(공용 인터페이스)만을 사용해 작성되었다.

헤더 파일에서 포함하는 list_t 자료형은 불완전한 형식(incomplete type)이기 때문에 malloc 함수를 직접 사용할 수 없다. 실제 구조체의 크기가 링크 시점에 결정되기 때문에 list_malloc 함수를 별도로 정의해 사용해야 한다.

셀 박스 6-1 [예제 6-3] 컴파일하기

$ gcc -c 6_3.c -o private.o
$ gcc -c 6_3.main.c -o main.o

셀 박스 6-2 main.o만으로 [예제 6-3]을 링크하기

$ gcc main.o -o 6_3.out
/usr/bin/x86_64-linux-gnu-ld.bfd: main.o: in function `reverse':
6_3.main.c:(.text+0x2b): undefined reference to `list_clear'
/usr/bin/x86_64-linux-gnu-ld.bfd: 6_3.main.c:(.text+0x37): undefined reference to `list_size'
/usr/bin/x86_64-linux-gnu-ld.bfd: 6_3.main.c:(.text+0x57): undefined reference to `list_get'
/usr/bin/x86_64-linux-gnu-ld.bfd: 6_3.main.c:(.text+0x82): undefined reference to `list_add'
/usr/bin/x86_64-linux-gnu-ld.bfd: main.o: in function `main':
6_3.main.c:(.text+0xa8): undefined reference to `list_malloc'
/usr/bin/x86_64-linux-gnu-ld.bfd: 6_3.main.c:(.text+0xb1): undefined reference to `list_malloc'
/usr/bin/x86_64-linux-gnu-ld.bfd: 6_3.main.c:(.text+0xc1): undefined reference to `list_init'
/usr/bin/x86_64-linux-gnu-ld.bfd: 6_3.main.c:(.text+0xcd): undefined reference to `list_init'
/usr/bin/x86_64-linux-gnu-ld.bfd: 6_3.main.c:(.text+0xde): undefined reference to `list_add'
/usr/bin/x86_64-linux-gnu-ld.bfd: 6_3.main.c:(.text+0xef): undefined reference to `list_add'
/usr/bin/x86_64-linux-gnu-ld.bfd: 6_3.main.c:(.text+0x100): undefined reference to `list_add'
/usr/bin/x86_64-linux-gnu-ld.bfd: 6_3.main.c:(.text+0x111): undefined reference to `list_add'
/usr/bin/x86_64-linux-gnu-ld.bfd: 6_3.main.c:(.text+0x122): undefined reference to `list_add'
/usr/bin/x86_64-linux-gnu-ld.bfd: 6_3.main.c:(.text+0x141): undefined reference to `list_print'
/usr/bin/x86_64-linux-gnu-ld.bfd: 6_3.main.c:(.text+0x14d): undefined reference to `list_print'
/usr/bin/x86_64-linux-gnu-ld.bfd: 6_3.main.c:(.text+0x159): undefined reference to `list_destroy'
/usr/bin/x86_64-linux-gnu-ld.bfd: 6_3.main.c:(.text+0x165): undefined reference to `list_destroy'
collect2: error: ld returned 1 exit status

셀 박스 6-3 [예제 6-3]을 링크하고 실행하기

$ gcc main.o private.o -o 6_3.out
$ ./6_3.out
[4 6 1 5 ]
[5 1 6 4 ]

만약 비공개 로직에 변경이 발생했을 때 매번 링크 단계를 반복하고 싶지 않다면, 이를 공유 라이브러리로 만들어 런타임에 링크하면 된다.

profile
안녕하세요

0개의 댓글