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 원칙을 적용한 사례를 살펴보겠습니다.
- 단일 책임 원칙(SRP)
센서 데이터를 읽는 기능과 데이터 처리 로직을 별도의 모듈로 분리했습니다.
SensorReader
모듈은 데이터 읽기를 전담.DataProcessor
모듈은 데이터를 처리하고 분석.
- 개방-폐쇄 원칙(OCP)
새로운 센서 타입 추가 시 기존 코드 수정을 최소화하기 위해 인터페이스를 활용했습니다.
typedef struct {
void (*readData)(struct Sensor*);
} Sensor;
- 리스코프 치환 원칙(LSP)
여러 센서 타입이 동일한 인터페이스를 사용하도록 설계했습니다.
typedef struct {
Sensor base;
int temperature;
} TemperatureSensor;
- 인터페이스 분리 원칙(ISP)
데이터 저장소 인터페이스를 작은 기능 단위로 분리하여 클라이언트가 필요하지 않은 기능에 의존하지 않도록 설계했습니다.
typedef struct {
void (*save)(const char*);
} Storage;
- 의존성 역전 원칙(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 원칙 적용:
- SRP: 엔터티 관리와 렌더링 로직을 분리.
- OCP: 새로운 엔터티 타입 추가 시 기존 코드 수정 없이 확장 가능하도록 설계.
- LSP: 모든 엔터티가 공통 인터페이스를 구현하도록 설계.
- ISP: 엔터티는 자신이 사용하는 기능만 포함된 인터페이스를 구현.
- 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 기반 프로젝트에서도 더 나은 소프트웨어 설계를 실현할 수 있습니다.