C 언어에서 객체 지향 설계 원칙(SOLID) 적용법

C 언어는 절차적 프로그래밍 언어로 널리 알려져 있지만, 객체 지향 설계 원칙(SOLID)을 적용함으로써 유지보수성과 확장성을 크게 향상시킬 수 있습니다. 이 기사에서는 SOLID 원칙이 무엇인지 간단히 소개하고, C 언어에서 이를 적용하는 구체적인 방법과 예제를 통해 객체 지향 설계를 실무에 어떻게 도입할 수 있는지 알아봅니다.

목차

SOLID 원칙이란?


SOLID는 객체 지향 설계의 다섯 가지 주요 원칙을 나타내는 약어로, 소프트웨어 개발에서 코드의 유지보수성과 확장성을 높이는 데 중요한 역할을 합니다. 이 원칙은 다음과 같습니다.

단일 책임 원칙(SRP)


모듈이나 클래스는 하나의 책임만 가져야 하며, 이 책임은 명확히 정의되어야 합니다.

개방-폐쇄 원칙(OCP)


소프트웨어 엔터티는 확장에는 열려 있고, 수정에는 닫혀 있어야 합니다.

리스코프 치환 원칙(LSP)


하위 클래스는 상위 클래스의 기능을 대체할 수 있어야 하며, 프로그램의 정확성을 깨뜨려서는 안 됩니다.

인터페이스 분리 원칙(ISP)


클라이언트는 사용하지 않는 메서드에 의존하지 않아야 하며, 작고 응집력 있는 인터페이스를 선호해야 합니다.

의존성 역전 원칙(DIP)


고수준 모듈은 저수준 모듈에 의존해서는 안 되며, 둘 다 추상화에 의존해야 합니다.

이 원칙들은 객체 지향 프로그래밍에 유용하지만, C 언어에서도 설계 방식을 조정하면 효과적으로 적용할 수 있습니다. 다음 항목에서는 각각의 원칙에 대해 C 언어에서의 구체적인 구현 방법을 다룹니다.

C 언어로 단일 책임 원칙(SRP) 구현하기


단일 책임 원칙(SRP)은 “모든 모듈이나 클래스는 하나의 책임만 가져야 한다”는 설계 원칙입니다. C 언어에서는 구조체와 함수를 조합하여 SRP를 구현할 수 있습니다.

SRP를 위반한 예시


하나의 함수가 여러 가지 역할을 수행하는 경우 SRP를 위반하게 됩니다. 예를 들어:

void processEmployeeData(const char* fileName) {
    // 파일 읽기
    FILE* file = fopen(fileName, "r");
    // 데이터 처리
    // 화면 출력
    fclose(file);
}


이 함수는 파일 읽기, 데이터 처리, 출력이라는 여러 책임을 포함하고 있습니다.

SRP 준수 설계


책임을 분리하면 다음과 같은 구조로 개선할 수 있습니다.

typedef struct {
    char name[50];
    int id;
} Employee;

void readEmployeeData(const char* fileName, Employee* employees, int* count) {
    FILE* file = fopen(fileName, "r");
    if (!file) return;
    // 데이터 읽기 로직
    fclose(file);
}

void processEmployee(Employee* employees, int count) {
    // 데이터 처리 로직
}

void displayEmployeeData(const Employee* employees, int count) {
    for (int i = 0; i < count; ++i) {
        printf("ID: %d, Name: %s\n", employees[i].id, employees[i].name);
    }
}

설계의 이점

  • 파일 읽기, 데이터 처리, 데이터 출력이라는 각각의 역할이 별도의 함수로 분리됩니다.
  • 각 함수는 단일 책임만 수행하므로 수정과 유지보수가 용이합니다.

SRP를 적용하면 코드는 읽기 쉬워지고, 변경 시 다른 기능에 영향을 미치지 않도록 설계할 수 있습니다.

개방-폐쇄 원칙(OCP) 적용하기


개방-폐쇄 원칙(OCP)은 “소프트웨어 엔터티는 확장에는 열려 있고, 수정에는 닫혀 있어야 한다”는 원칙입니다. C 언어에서는 함수 포인터와 모듈화된 설계를 활용하여 OCP를 구현할 수 있습니다.

OCP를 위반한 설계


아래의 코드는 특정 데이터 처리 로직이 하드코딩되어 있어 새로운 기능을 추가하려면 기존 코드를 수정해야 합니다.

void processData(int* data, int count) {
    for (int i = 0; i < count; ++i) {
        data[i] *= 2; // 데이터 처리 로직 고정
    }
}

OCP 준수 설계


함수 포인터를 사용하여 처리 로직을 확장 가능하게 설계하면 OCP를 준수할 수 있습니다.

typedef void (*DataProcessor)(int*);

void doubleData(int* value) {
    *value *= 2;
}

void squareData(int* value) {
    *value *= *value;
}

void processData(int* data, int count, DataProcessor processor) {
    for (int i = 0; i < count; ++i) {
        processor(&data[i]); // 유연한 데이터 처리
    }
}

사용 예시


다양한 처리 로직을 동적으로 선택할 수 있습니다.

int main() {
    int data[] = {1, 2, 3, 4, 5};
    int count = sizeof(data) / sizeof(data[0]);

    printf("Doubling data:\n");
    processData(data, count, doubleData);
    for (int i = 0; i < count; ++i) {
        printf("%d ", data[i]);
    }

    printf("\nSquaring data:\n");
    processData(data, count, squareData);
    for (int i = 0; i < count; ++i) {
        printf("%d ", data[i]);
    }

    return 0;
}

설계의 이점

  • 데이터 처리 로직을 새로운 함수로 추가하는 것만으로 확장 가능합니다.
  • 기존 processData 함수는 수정하지 않고도 동작을 변경할 수 있습니다.

OCP를 적용하면 변경의 위험을 최소화하면서 요구사항 변화를 효과적으로 수용할 수 있습니다.

리스코프 치환 원칙(LSP) 구현하기


리스코프 치환 원칙(LSP)은 “하위 타입은 상위 타입으로 대체 가능해야 한다”는 원칙입니다. C 언어에서는 구조체와 함수 포인터를 조합하여 LSP를 구현할 수 있습니다.

LSP를 위반한 설계


하위 타입에서 상위 타입의 예상 동작을 변경하면 LSP를 위반하게 됩니다. 예를 들어:

typedef struct {
    int type; // 0: Circle, 1: Rectangle
} Shape;

void drawShape(const Shape* shape) {
    if (shape->type == 0) {
        printf("Drawing a circle\n");
    } else if (shape->type == 1) {
        printf("Drawing a rectangle\n");
    }
}

위 설계는 새로운 형태의 도형이 추가될 때마다 drawShape 함수에 조건문을 추가해야 하므로 LSP를 위반합니다.

LSP 준수 설계


구조체와 함수 포인터를 사용하여 각 타입의 고유한 동작을 분리함으로써 LSP를 준수할 수 있습니다.

typedef struct Shape {
    void (*draw)(const struct Shape*); // 공통 인터페이스
} Shape;

typedef struct {
    Shape base; // 상위 타입을 포함
    int radius;
} Circle;

typedef struct {
    Shape base; // 상위 타입을 포함
    int width;
    int height;
} Rectangle;

void drawCircle(const Shape* shape) {
    const Circle* circle = (const Circle*)shape;
    printf("Drawing a circle with radius %d\n", circle->radius);
}

void drawRectangle(const Shape* shape) {
    const Rectangle* rectangle = (const Rectangle*)shape;
    printf("Drawing a rectangle with width %d and height %d\n", rectangle->width, rectangle->height);
}

void renderShape(const Shape* shape) {
    shape->draw(shape); // 다형성을 이용한 호출
}

사용 예시


각 도형의 구현체를 사용해도 상위 타입의 함수로 대체 가능합니다.

int main() {
    Circle circle = {{drawCircle}, 5}; // Circle 초기화
    Rectangle rectangle = {{drawRectangle}, 10, 20}; // Rectangle 초기화

    Shape* shapes[] = {(Shape*)&circle, (Shape*)&rectangle};

    for (int i = 0; i < 2; ++i) {
        renderShape(shapes[i]); // 다형적 동작
    }

    return 0;
}

설계의 이점

  • 하위 타입을 상위 타입처럼 처리할 수 있어 다형성을 지원합니다.
  • 새로운 도형을 추가해도 기존 코드 수정 없이 확장 가능합니다.

LSP를 준수하면 코드의 확장성과 재사용성을 극대화할 수 있습니다.

인터페이스 분리 원칙(ISP) 적용법


인터페이스 분리 원칙(ISP)은 “클라이언트는 사용하지 않는 메서드에 의존해서는 안 된다”는 원칙으로, 인터페이스를 작고 응집력 있게 설계해야 함을 강조합니다. C 언어에서는 함수 포인터를 이용한 인터페이스 정의로 ISP를 구현할 수 있습니다.

ISP를 위반한 설계


모든 기능을 하나의 인터페이스로 묶으면 필요 없는 기능에 대한 의존성이 발생할 수 있습니다.

typedef struct {
    void (*read)(void);
    void (*write)(void);
    void (*update)(void);
    void (*delete)(void);
} Database;

void dummyFunction() {
    printf("Not implemented\n");
}

위 설계에서 모든 클라이언트가 Database 구조체를 사용해야 하지만, 특정 클라이언트는 일부 기능만 필요로 할 수도 있습니다.

ISP 준수 설계


필요한 기능만 포함된 작고 독립적인 인터페이스를 정의합니다.

typedef struct {
    void (*read)(void);
} Readable;

typedef struct {
    void (*write)(void);
} Writable;

typedef struct {
    void (*update)(void);
} Updatable;

typedef struct {
    void (*delete)(void);
} Deletable;

void readOperation() {
    printf("Performing read operation\n");
}

void writeOperation() {
    printf("Performing write operation\n");
}

ISP를 구현한 예시


필요한 기능만 구현하도록 설계합니다.

int main() {
    Readable reader = {readOperation};
    Writable writer = {writeOperation};

    // Reader만 사용하는 클라이언트
    reader.read();

    // Writer만 사용하는 클라이언트
    writer.write();

    return 0;
}

설계의 이점

  • 클라이언트는 자신에게 필요한 기능만 사용할 수 있어 코드 의존성을 줄입니다.
  • 인터페이스의 수정이 필요한 경우 영향을 받는 범위가 작아집니다.

ISP를 적용하면 기능의 분리와 재사용성을 높일 수 있으며, 클라이언트와 인터페이스 간의 결합도를 줄일 수 있습니다.

의존성 역전 원칙(DIP) 준수하기


의존성 역전 원칙(DIP)은 “고수준 모듈이 저수준 모듈에 의존해서는 안 되며, 둘 다 추상화에 의존해야 한다”는 원칙입니다. C 언어에서는 함수 포인터와 추상화를 이용해 DIP를 구현할 수 있습니다.

DIP를 위반한 설계


고수준 모듈이 저수준 모듈의 세부 구현에 직접 의존하면 DIP를 위반하게 됩니다.

typedef struct {
    char data[100];
} FileStorage;

void saveToFile(FileStorage* storage, const char* data) {
    strcpy(storage->data, data);
    printf("Data saved to file: %s\n", storage->data);
}

위 설계에서 saveToFile 함수는 특정 FileStorage 구현에 강하게 결합되어 있습니다.

DIP 준수 설계


추상화를 도입해 고수준 모듈이 저수준 모듈의 세부 사항에 의존하지 않도록 설계합니다.

typedef struct Storage {
    void (*save)(struct Storage*, const char*);
} Storage;

typedef struct {
    Storage base;
    char data[100];
} FileStorage;

void fileSave(Storage* storage, const char* data) {
    FileStorage* fileStorage = (FileStorage*)storage;
    strcpy(fileStorage->data, data);
    printf("Data saved to file: %s\n", fileStorage->data);
}

typedef struct {
    Storage base;
    char data[100];
} MemoryStorage;

void memorySave(Storage* storage, const char* data) {
    MemoryStorage* memoryStorage = (MemoryStorage*)storage;
    strcpy(memoryStorage->data, data);
    printf("Data saved to memory: %s\n", memoryStorage->data);
}

사용 예시


고수준 모듈은 Storage 인터페이스를 사용해 저장소 타입에 관계없이 데이터를 저장할 수 있습니다.

void saveData(Storage* storage, const char* data) {
    storage->save(storage, data);
}

int main() {
    FileStorage fileStorage = {{fileSave}};
    MemoryStorage memoryStorage = {{memorySave}};

    saveData((Storage*)&fileStorage, "File data");
    saveData((Storage*)&memoryStorage, "Memory data");

    return 0;
}

설계의 이점

  • 고수준 모듈이 구체적인 구현 대신 추상화에 의존하므로 확장성이 높아집니다.
  • 새로운 저장소 타입을 추가해도 고수준 모듈을 수정할 필요가 없습니다.

DIP를 적용하면 모듈 간의 결합도를 줄이고, 코드의 유연성과 재사용성을 극대화할 수 있습니다.

SOLID 원칙과 실무 적용


SOLID 원칙을 실무에서 효과적으로 활용하면 소프트웨어의 유지보수성과 확장성을 크게 향상시킬 수 있습니다. 이 섹션에서는 C 언어 기반의 프로젝트에 SOLID 원칙을 적용하는 실제 사례와 그 결과를 설명합니다.

모듈화된 설계의 필요성


실무에서는 하나의 시스템이 여러 하위 모듈로 나뉘어 개발됩니다. SOLID 원칙을 따르지 않으면 다음과 같은 문제가 발생할 수 있습니다:

  • 기능 간의 강한 결합으로 인한 수정 난이도 증가
  • 새로운 요구사항을 추가할 때 전체 코드베이스 수정
  • 테스트의 복잡성 증가

SOLID 원칙을 적용하면 이러한 문제를 완화하고 안정적인 설계를 구현할 수 있습니다.

적용 사례: IoT 데이터 처리 시스템


C 언어로 작성된 IoT 데이터 처리 시스템에서 SOLID 원칙을 적용한 사례를 살펴보겠습니다.

  1. 단일 책임 원칙(SRP)
    센서 데이터를 읽는 기능과 데이터 처리 로직을 별도의 모듈로 분리했습니다.
  • SensorReader 모듈은 데이터 읽기를 전담.
  • DataProcessor 모듈은 데이터를 처리하고 분석.
  1. 개방-폐쇄 원칙(OCP)
    새로운 센서 타입 추가 시 기존 코드 수정을 최소화하기 위해 인터페이스를 활용했습니다.
   typedef struct {
       void (*readData)(struct Sensor*);
   } Sensor;
  1. 리스코프 치환 원칙(LSP)
    여러 센서 타입이 동일한 인터페이스를 사용하도록 설계했습니다.
   typedef struct {
       Sensor base;
       int temperature;
   } TemperatureSensor;
  1. 인터페이스 분리 원칙(ISP)
    데이터 저장소 인터페이스를 작은 기능 단위로 분리하여 클라이언트가 필요하지 않은 기능에 의존하지 않도록 설계했습니다.
   typedef struct {
       void (*save)(const char*);
   } Storage;
  1. 의존성 역전 원칙(DIP)
    데이터 저장 로직이 특정 저장소 구현(File, Memory 등)에 의존하지 않도록 추상화를 도입했습니다.
   void saveData(Storage* storage, const char* data) {
       storage->save(data);
   }

적용 결과

  • 유지보수 시간 단축: 새로운 센서를 추가하거나 기존 로직을 수정할 때 변경 영향이 최소화되었습니다.
  • 테스트 용이성 증가: 각각의 모듈을 독립적으로 테스트할 수 있어 테스트 커버리지가 높아졌습니다.
  • 확장성 개선: 새로운 요구사항 추가 시 기존 코드를 거의 수정하지 않고도 기능을 확장할 수 있었습니다.

결론


실무에서 SOLID 원칙을 적용하면 초기 개발 시간은 다소 증가할 수 있으나, 장기적으로 코드의 유지보수성과 확장성을 크게 향상시킬 수 있습니다. C 언어와 같은 절차적 프로그래밍 언어에서도 설계 원칙을 적극적으로 도입하여 보다 안정적이고 유연한 소프트웨어를 개발할 수 있습니다.

연습 문제 및 응용 예시


SOLID 원칙을 학습하고 실무에 적용하기 위해 연습 문제와 응용 예시를 제공합니다. 이를 통해 각 원칙을 실제 코드에 구현하며 개념을 명확히 할 수 있습니다.

연습 문제 1: 단일 책임 원칙(SRP) 적용


아래 코드에서 SRP를 위반한 부분을 찾아 수정하세요.

void manageUser(const char* username) {
    // 사용자 생성
    printf("User %s created.\n", username);
    // 로그 기록
    FILE* file = fopen("log.txt", "a");
    fprintf(file, "User %s created.\n", username);
    fclose(file);
    // 알림 전송
    printf("Notification sent to %s.\n", username);
}

목표: 사용자 생성, 로그 기록, 알림 전송 기능을 각각의 함수로 분리.

연습 문제 2: 개방-폐쇄 원칙(OCP) 구현


여러 데이터 포맷(XML, JSON)을 처리하는 프로그램에서 OCP를 준수하도록 아래 코드를 확장 가능한 구조로 변경하세요.

void processData(const char* format) {
    if (strcmp(format, "XML") == 0) {
        printf("Processing XML data.\n");
    } else if (strcmp(format, "JSON") == 0) {
        printf("Processing JSON data.\n");
    }
}

목표: 각 데이터 포맷에 대한 처리 로직을 별도의 함수로 분리하고 인터페이스 기반 설계 적용.

응용 예시: SOLID 원칙을 적용한 간단한 게임 엔진


문제 정의:
간단한 2D 게임 엔진을 개발하며, 플레이어, NPC, 적 등의 다양한 엔터티를 관리하는 코드를 작성하세요.

SOLID 원칙 적용:

  1. SRP: 엔터티 관리와 렌더링 로직을 분리.
  2. OCP: 새로운 엔터티 타입 추가 시 기존 코드 수정 없이 확장 가능하도록 설계.
  3. LSP: 모든 엔터티가 공통 인터페이스를 구현하도록 설계.
  4. ISP: 엔터티는 자신이 사용하는 기능만 포함된 인터페이스를 구현.
  5. DIP: 게임 엔진이 특정 렌더링 엔진에 의존하지 않도록 추상화 도입.

코드 예시:

typedef struct {
    void (*update)(void*);
    void (*render)(void*);
} Entity;

typedef struct {
    Entity base;
    int x, y;
} Player;

void updatePlayer(void* self) {
    Player* player = (Player*)self;
    player->x += 1;
    player->y += 1;
    printf("Player moved to (%d, %d)\n", player->x, player->y);
}

void renderPlayer(void* self) {
    Player* player = (Player*)self;
    printf("Rendering player at (%d, %d)\n", player->x, player->y);
}

int main() {
    Player player = {{updatePlayer, renderPlayer}, 0, 0};
    Entity* entities[] = {(Entity*)&player};

    for (int i = 0; i < 1; ++i) {
        entities[i]->update(entities[i]);
        entities[i]->render(entities[i]);
    }

    return 0;
}

활용 방법


위의 문제를 해결하며 SOLID 원칙의 핵심 개념을 체득하고, 실제 프로젝트에서 적용할 수 있는 기반을 다질 수 있습니다. 다양한 상황에 맞는 연습을 통해 SOLID 원칙을 익히세요.

요약


C 언어에서도 객체 지향 설계 원칙인 SOLID를 효과적으로 적용할 수 있습니다. 각 원칙은 코드의 유지보수성과 확장성을 향상시키는 데 기여하며, 구조체와 함수 포인터, 추상화를 활용한 설계를 통해 절차적 프로그래밍 언어에서도 구현 가능합니다. 이번 기사에서는 SOLID 원칙의 정의부터 C 언어에서의 구체적인 구현 방법, 실무 적용 사례, 연습 문제와 응용 예시까지 다뤘습니다. 이를 통해 C 기반 프로젝트에서도 더 나은 소프트웨어 설계를 실현할 수 있습니다.

목차