C언어로 객체 지향 프로그래밍의 핵심 개념인 ‘접근 제어’를 어떻게 모방할 수 있는지 간략히 살펴봅니다.
C언어로 구현 가능한 접근 제어 방식
C언어에는 접근 제한 키워드(private, protected 등)가 없기 때문에, 구조체와 함수를 조합해 접근 제어를 모방할 수 있습니다. 전역 변수 대신 구조체를 사용하고, 해당 구조체에 직접 접근하기보다는 지정된 함수만을 통해 데이터를 변경하도록 설계합니다. 이렇게 하면 외부에서 함부로 구조체 내부에 접근하지 못하게 되어, 객체 지향 프로그래밍의 기본 개념인 ‘캡슐화’와 흡사한 접근 제어를 구현할 수 있습니다.
static 키워드를 활용한 내부 함수 제한
static 키워드는 변수나 함수를 선언할 때 해당 선언의 범위를 제한해 줍니다. 예컨대, 구조체 외부에서 직접 호출할 필요가 없는 함수를 static으로 선언하면, 해당 함수는 선언된 소스 파일 안에서만 유효해집니다. 이를 통해 구조체와 함께 제공되는 내부 함수를 외부 접근으로부터 차단하는 간단한 ‘접근 제어’ 효과를 낼 수 있습니다.
구조체와 헤더 파일 분리로 은닉화 강화
구조체 정의를 헤더 파일 대신 소스 파일에 숨기고, 헤더에는 구조체의 포인터 타입만 노출하는 방법도 있습니다. 이렇게 하면 구조체의 내부 멤버 변수를 감춘 채로, 외부에서는 구조체 포인터를 전달받아 제공된 함수를 통해서만 데이터에 접근하도록 유도할 수 있어 접근 제어가 어느 정도 보장됩니다.
구조체와 캡슐화를 통한 기초 구현
C언어에서 구조체는 데이터와 이를 다루는 함수를 하나로 묶는 데 유용한 도구입니다. 객체 지향 언어처럼 ‘private’ 키워드를 사용해 접근을 제한할 수는 없지만, 구조체 안에 정의된 멤버 변수에 직접 접근하지 못하도록 숨기고, 그 대신 함수만을 통해 데이터를 다루도록 하면 캡슐화 효과를 얻을 수 있습니다.
구조체 내부 멤버 감추기
헤더 파일에는 구조체 포인터만 노출하고, 구조체 정의는 소스 파일에만 존재하게 만드는 방식으로 멤버 변수를 숨길 수 있습니다. 이렇게 하면 외부에서는 구조체가 어떤 멤버를 가지고 있는지 알 수 없으며, 제공된 함수를 통해서만 접근이 가능합니다.
기본 예시 코드
아래는 구조체를 감추고, 함수를 통해 접근하는 간단한 예시입니다.
// example.h
typedef struct _Example Example;
// 외부에서 구조체 내부 멤버를 직접 볼 수 없도록 하고
// 필요한 인터페이스 함수만 공개합니다.
Example* CreateExample(int initialValue);
void DestroyExample(Example* obj);
int GetValue(const Example* obj);
void SetValue(Example* obj, int newValue);
// example.c
#include <stdio.h>
#include <stdlib.h>
#include "example.h"
// 구조체 정의를 소스 파일에만 두어 외부에서 접근 불가능하게 만듭니다.
struct _Example {
int value;
};
Example* CreateExample(int initialValue) {
Example* obj = (Example*)malloc(sizeof(Example));
if (obj) {
obj->value = initialValue;
}
return obj;
}
void DestroyExample(Example* obj) {
if (obj) {
free(obj);
}
}
int GetValue(const Example* obj) {
return obj ? obj->value : -1;
}
void SetValue(Example* obj, int newValue) {
if (obj) {
obj->value = newValue;
}
}
이 예시처럼 외부에서는 Example 구조체의 내부 멤버를 전혀 알 수 없고, GetValue와 SetValue 같은 함수를 통해서만 상태를 읽고 수정하게 됩니다. 이를 통해 C언어에서도 객체 지향 언어에서 말하는 ‘캡슐화’와 유사한 접근 제어를 어느 정도 실현할 수 있습니다.
함수 포인터를 이용한 가상 함수 흉내내기
C언어에는 객체 지향 언어처럼 ‘virtual’ 키워드가 존재하지 않습니다. 그러나 함수 포인터를 이용하면 “가상 함수(Virtual Function)” 개념과 유사하게 여러 가지 구현을 동적으로 할당할 수 있어 다형성을 흉내낼 수 있습니다. 이 기법은 구조체의 첫 멤버로 함수 포인터 테이블(혹은 vtable 비슷한 구조)을 배치하고, 자식 역할을 하는 구조체에서 해당 포인터를 자신에게 맞게 재정의하여 사용합니다.
함수 포인터 테이블의 기본 구조
가상 함수 테이블을 모방하기 위해선 기본 구조체에 ‘함수 포인터’를 저장하는 공간이 필요합니다. 각 함수 포인터가 ‘기본 동작’을 가리키도록 초기화해 두고, 이를 상속하는 구조체(혹은 자식 구현)에서 필요하다면 함수를 재할당해 오버라이딩할 수 있습니다.
예시 코드
다음은 ‘도형(Shape)’을 예로 들어 다형성을 흉내 내는 간단한 코드입니다.
// shape.h
#ifndef SHAPE_H
#define SHAPE_H
typedef struct _Shape Shape;
// 함수 포인터 테이블
typedef struct _ShapeVTable {
void (*draw)(Shape* shape);
} ShapeVTable;
struct _Shape {
ShapeVTable* vtable;
// 공통 속성 예: 좌표, 색상 등
int x;
int y;
};
void Shape_Init(Shape* shape, ShapeVTable* vtable, int x, int y);
void Shape_Draw(Shape* shape);
#endif // SHAPE_H
// shape.c
#include "shape.h"
#include <stdio.h>
void Shape_Init(Shape* shape, ShapeVTable* vtable, int x, int y) {
if (shape && vtable) {
shape->vtable = vtable;
shape->x = x;
shape->y = y;
}
}
void Shape_Draw(Shape* shape) {
if (shape && shape->vtable && shape->vtable->draw) {
shape->vtable->draw(shape);
}
}
// circle.h
#ifndef CIRCLE_H
#define CIRCLE_H
#include "shape.h"
typedef struct _Circle {
Shape base;
int radius;
} Circle;
Circle* Circle_Create(int x, int y, int radius);
#endif // CIRCLE_H
// circle.c
#include "circle.h"
#include <stdio.h>
#include <stdlib.h>
static void Circle_Draw(Shape* shape);
static ShapeVTable circleVTable = {
Circle_Draw
};
Circle* Circle_Create(int x, int y, int radius) {
Circle* c = (Circle*)malloc(sizeof(Circle));
if (c) {
Shape_Init((Shape*)c, &circleVTable, x, y);
c->radius = radius;
}
return c;
}
static void Circle_Draw(Shape* shape) {
Circle* c = (Circle*)shape;
printf("Draw circle at (%d, %d) with radius %d\n",
c->base.x, c->base.y, c->radius);
}
위 코드에서 Circle 구조체는 Shape 구조체를 포함(‘상속’에 해당)하고, Circle 전용 함수 포인터 테이블을 사용해 draw 함수를 Circle_Draw로 오버라이딩합니다. 이렇게 함으로써 Shape_Draw
함수를 호출했을 때, Circle 객체에서는 Circle_Draw가 자동으로 실행되어 다형성을 구현할 수 있습니다.
함수 포인터 테이블은 재사용 가능성과 유지보수성을 높여주며, 다양한 종류의 ‘자식’ 구조체가 각자의 vtable을 갖게 함으로써 C언어에서도 유연한 객체 지향적 설계를 어느 정도 달성할 수 있습니다.
응용 예시: 간단한 은닉화 예제 코드
C언어에서 구조체와 함수 포인터 등을 활용해 캡슐화와 은닉화를 구현하는 방법을 좀 더 구체적으로 살펴보겠습니다. 이번 예시에서는 ‘Student’ 구조체를 예로 들어, 구조체 멤버를 외부에 노출하지 않고, 공개된 인터페이스 함수를 통해서만 내부 상태를 다루도록 구성해 보겠습니다.
예제 구조
student.h
: Student 구조체 포인터 타입과 인터페이스 함수를 선언student.c
: Student 구조체의 실제 정의와 함수 구현
이렇게 분리하면, 외부에서는 Student 구조체의 내부 멤버를 볼 수 없으며, 헤더에 선언된 함수를 통해서만 접근하도록 강제할 수 있습니다.
예제 코드
// student.h
#ifndef STUDENT_H
#define STUDENT_H
typedef struct _Student Student;
/**
* Student 객체 생성
* @param name 학생 이름
* @param grade 학생 성적
* @return Student 포인터
*/
Student* CreateStudent(const char* name, float grade);
/**
* Student 객체 파괴
* @param s Student 포인터
*/
void DestroyStudent(Student* s);
/**
* 학생 이름 가져오기
* @param s Student 포인터
* @return 이름 문자열 (읽기 전용)
*/
const char* GetStudentName(const Student* s);
/**
* 학생 성적 가져오기
* @param s Student 포인터
* @return 학생 성적
*/
float GetStudentGrade(const Student* s);
/**
* 학생 성적 수정
* @param s Student 포인터
* @param newGrade 새로운 성적
*/
void SetStudentGrade(Student* s, float newGrade);
#endif // STUDENT_H
// student.c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include "student.h"
// 외부에 노출되지 않도록 구조체의 실제 정의를 .c 파일에만 둡니다.
struct _Student {
char* name;
float grade;
};
Student* CreateStudent(const char* name, float grade) {
Student* s = (Student*)malloc(sizeof(Student));
if (s) {
// name을 동적으로 복사해서 저장
s->name = (char*)malloc(strlen(name) + 1);
if (s->name) {
strcpy(s->name, name);
}
s->grade = grade;
}
return s;
}
void DestroyStudent(Student* s) {
if (s) {
if (s->name) {
free(s->name);
}
free(s);
}
}
const char* GetStudentName(const Student* s) {
return s ? s->name : NULL;
}
float GetStudentGrade(const Student* s) {
return s ? s->grade : -1.0f;
}
void SetStudentGrade(Student* s, float newGrade) {
if (s) {
s->grade = newGrade;
}
}
위 코드에서 struct _Student
의 내부 멤버 변수인 name
과 grade
는 .c
파일에만 정의되어 있으며, .h
파일에는 이를 전혀 노출하지 않습니다. 따라서 외부 모듈은 오직 함수 인터페이스(CreateStudent
, GetStudentGrade
등)를 통해서만 Student 객체의 데이터를 읽거나 수정할 수 있습니다.
사용 예시
// main.c
#include <stdio.h>
#include "student.h"
int main(void) {
Student* st = CreateStudent("Alice", 89.5f);
if (st) {
printf("Name: %s, Grade: %.2f\n", GetStudentName(st), GetStudentGrade(st));
SetStudentGrade(st, 92.3f);
printf("Updated -> Name: %s, Grade: %.2f\n", GetStudentName(st), GetStudentGrade(st));
DestroyStudent(st);
}
return 0;
}
이처럼 C언어에서도 구조체 멤버를 감추고, 공개된 함수를 통해서만 접근하도록 만들면 객체 지향의 핵심 개념인 은닉화와 접근 제어를 흉내낼 수 있습니다. 유지보수 및 보안 측면에서도 데이터 무결성을 보장하기 쉽고, 인터페이스가 명확해집니다.
사용자 레벨별 접근 제한 모델
C언어로 객체 지향적 접근 제어를 흉내 낼 때, 시스템 사용자나 특정 역할에 따라 접근 가능한 데이터와 기능을 구분하는 방안을 고려할 수 있습니다. 예를 들어, 관리자(Admin), 일반 사용자(User), 게스트(Guest) 같은 레벨을 두고, 각 레벨에서 호출할 수 있는 함수나 읽고 쓸 수 있는 구조체 멤버를 제한하는 방식입니다. 이러한 모델은 보안과 무결성, 그리고 유지보수 측면에서 큰 이점을 제공합니다.
등급별 권한 분리
가장 간단한 접근 방식은 사용자 레벨을 표현하는 열거형을 정의하고, 구조체에 사용자 레벨 정보를 담는 형태입니다. 열거형으로 레벨을 정의하면, 각 레벨별로 호출 가능한 함수나 접근 권한을 쉽게 분기 처리할 수 있습니다. 예를 들어, 관리자에게만 허용되는 연산을 일반 사용자나 게스트는 호출할 수 없도록 설계함으로써, 의도치 않은 데이터 변경이나 보안상의 문제가 발생하지 않도록 막습니다.
권한 체크 로직 구현
구조체 함수 구현부에서 현재 사용자 레벨을 확인하고, 권한이 충분하지 않을 경우 함수를 조기 종료하거나 에러 코드를 반환하도록 할 수 있습니다. 이러한 방식으로 C언어에서의 “private” 접근 제어에 해당하는 로직을 효과적으로 보완할 수 있습니다. 접근 제어 로직을 함수 포인터 테이블에 통합하거나, 각 함수의 시작 부분에서 간단한 if 문으로 권한을 검사하는 방법 등 여러 가지 응용이 가능합니다.
확장 가능성
추가로, 프로젝트 규모가 커질수록 접근 권한을 세분화하거나, 권한 그룹별 정책을 적용해야 할 수도 있습니다. 이때도 C언어로 작성된 권한 체크 함수를 단계별로 확장해 나가면, 객체 지향 언어에서 말하는 접근 제한 모델과 유사한 구조를 지속적으로 유지할 수 있습니다. 이렇게 등급별 접근을 모듈화해 두면, 새로운 기능이 추가될 때마다 관련 함수의 접근 권한만 수정해주면 되므로 유지보수가 용이합니다.
연습 문제
C언어에서 접근 제어와 캡슐화를 직접 연습해 볼 수 있는 문제를 제시합니다. ‘Employee’ 구조체를 만들고, 외부에서 멤버를 직접 수정할 수 없도록 설계해 봅니다. 구조체는 사원 번호와 이름, 부서 정보, 급여를 포함합니다.
문제 요구사항
- employee.h
typedef struct _Employee Employee;
로 구조체 포인터 타입만 공개합니다.CreateEmployee
,DestroyEmployee
등 필요한 함수를 선언합니다.
- employee.c
_Employee
구조체를 정의하되,.c
파일에만 두어 멤버를 외부에 노출하지 않도록 합니다.- 사원 번호, 이름, 부서, 급여를 멤버로 가집니다.
- 외부에서는 제공된 함수로만 Employee 데이터를 수정하도록 합니다.
- 주요 함수
CreateEmployee(int id, const char* name, const char* department, float salary)
DestroyEmployee(Employee* emp)
GetEmployeeId(const Employee* emp)
,GetEmployeeName(const Employee* emp)
,GetEmployeeDepartment(const Employee* emp)
,GetEmployeeSalary(const Employee* emp)
SetEmployeeName(Employee* emp, const char* name)
,SetEmployeeDepartment(Employee* emp, const char* department)
,SetEmployeeSalary(Employee* emp, float salary)
추가 과제
- 권한 레벨
열거형Role { ADMIN, USER }
를 정의하여, ADMIN만 급여를 수정할 수 있도록 제한해 봅니다. CreateEmployee
시Role
을 인자로 받아서 Employee 내부에 저장합니다.SetEmployeeSalary
함수에서는Role
이 ADMIN일 때만 급여를 수정하도록 합니다.Role
이 USER라면 에러 메시지(또는 에러 코드)를 반환합니다.
테스트 코드 예시
다음과 같이 테스트 코드를 작성해, Employee 구조체의 생성과 수정이 올바르게 동작하는지 확인해 봅니다.
// main.c
#include <stdio.h>
#include "employee.h"
int main(void) {
// Employee 생성 (ADMIN 권한 예시)
Employee* empAdmin = CreateEmployee(1001, "Kim", "R&D", 3000.0f, ADMIN);
if (empAdmin) {
printf("ID: %d, Name: %s, Dept: %s, Salary: %.2f\n",
GetEmployeeId(empAdmin),
GetEmployeeName(empAdmin),
GetEmployeeDepartment(empAdmin),
GetEmployeeSalary(empAdmin));
// ADMIN 권한이므로 급여 수정 가능
SetEmployeeSalary(empAdmin, 3500.0f);
printf("Updated Salary: %.2f\n", GetEmployeeSalary(empAdmin));
DestroyEmployee(empAdmin);
}
// Employee 생성 (USER 권한 예시)
Employee* empUser = CreateEmployee(1002, "Lee", "Marketing", 2500.0f, USER);
if (empUser) {
printf("ID: %d, Name: %s, Dept: %s, Salary: %.2f\n",
GetEmployeeId(empUser),
GetEmployeeName(empUser),
GetEmployeeDepartment(empUser),
GetEmployeeSalary(empUser));
// USER 권한으로 급여 수정 시도
SetEmployeeSalary(empUser, 2700.0f);
printf("After Attempted Update -> Salary: %.2f\n", GetEmployeeSalary(empUser));
DestroyEmployee(empUser);
}
return 0;
}
위 예시를 통해 구조체 멤버를 직접 노출하지 않고, C언어에서 접근 제어 및 권한 분기를 적용하는 실습을 할 수 있습니다. 객체 지향 프로그래밍의 개념을 C언어에서 구현함으로써, 유지보수성과 보안성을 높이는 접근법을 익혀봅니다.
디버깅 및 문제 해결 팁
C언어에서 접근 제어 로직을 모방할 때 발생할 수 있는 일반적인 문제와 해결 방법을 알아봅니다. 데이터 은닉을 위해 구조체 정의를 감추고, 함수로만 조작하도록 구성했을 때는 디버깅 과정도 해당 구조체 정보를 직접 확인하기 어렵다는 점이 주의사항입니다. 다음은 몇 가지 유용한 팁입니다.
1. 로그 출력으로 내부 상태 확인
구조체 내부를 공개하지 않는 대신, 디버깅용 함수를 추가해 필요한 정보를 로그로 출력할 수 있습니다. 예를 들어, ‘ShowEmployeeInfo’ 같은 함수를 만들어 사원 정보나 접근 권한 등을 확인하면 구조체를 직접 열람하지 않고도 문제를 추적할 수 있습니다.
2. 컴파일 옵션과 디버거 활용
-g 옵션 등을 사용해 컴파일하면, GDB 같은 디버거에서 함수 스택이나 변수 값을 추적할 수 있습니다. 구조체 전체가 숨겨져 있더라도 포인터 주소 등을 통해 어느 부분에서 문제가 발생했는지 추정할 수 있습니다.
3. 접근 함수로 문제 추적
데이터 접근이 전부 함수 인터페이스를 통해 이뤄진다면, 이 함수를 집중적으로 살펴보면 문제 원인을 빠르게 찾을 수 있습니다. 예컨대 SetEmployeeSalary
함수 안에서 권한을 체크해 조건이 만족되지 않을 때 에러 처리를 어떻게 하는지 면밀히 확인하면, 오작동 원인을 쉽게 추적할 수 있습니다.
4. 오버플로우와 메모리 누수 점검
멤버 변수를 직접 다루지 않는다고 해서 메모리 문제가 발생하지 않는 것은 아닙니다. 동적 할당과 문자열 처리는 여전히 주의가 필요합니다. 구조체 안에 동적으로 할당된 메모리를 해제하지 않거나, 문자열을 복사하는 과정에서 오버플로우가 발생할 수 있으므로 방어적 코드를 작성해야 합니다.
5. 조건부 컴파일로 임시 디버깅 코드 추가
디버깅이 필요할 때, 매크로를 사용해 #ifdef DEBUG
같은 형태로 임시 코드를 활성화할 수 있습니다. 구조체 내부 멤버를 출력하거나, 함수 호출 횟수를 기록하는 코드를 삽입해 문제 해결 후에는 다시 비활성화하면 됩니다.
이러한 접근은 코드를 깔끔하게 유지하면서도, 필요 시 내부 상태를 추적하는 유연성을 제공합니다. 접근 제어와 캡슐화가 잘 되어 있을수록 문제가 생긴 부분을 찾는 데 시간이 걸릴 수 있지만, 체계적인 로그와 디버깅 기법을 사용하면 문제 해결에 큰 도움이 됩니다.
실제 프로젝트에서의 활용 전략
C언어에서 객체 지향적 접근 제어를 흉내 내는 기법은 단순한 예제 코드를 넘어, 다양한 실제 프로젝트에서도 충분히 적용될 수 있습니다. 특히 라이브러리 구조나 모듈 단위 설계를 할 때, 데이터 보호와 유지보수 측면에서 큰 이점을 얻을 수 있습니다.
1. 라이브러리 내부 구조 감추기
라이브러리로 제공되는 기능을 설계할 때 구조체 정의를 .c
파일에 두고, 헤더 파일에는 구조체 포인터 타입만 노출하는 방식을 활용하면 라이브러리 사용자가 구조체 내부 멤버에 직접 접근하는 위험을 줄일 수 있습니다. 이를 통해 라이브러리 개발자는 내부 구현을 자유롭게 변경해도, 인터페이스만 유지한다면 호환성을 유지할 수 있습니다.
2. 모듈 단위 설계와 유지보수
프로젝트가 커지면, 특정 기능을 수행하는 모듈을 분리하고 이 모듈이 제공하는 인터페이스만 노출하는 방식이 적합합니다. 이때 C언어 접근 제어 기법을 적용하면, 각 모듈이 자체적인 데이터와 함수를 캡슐화해 관리할 수 있어, 다른 모듈과의 결합도를 줄이고 유지보수를 용이하게 합니다.
3. 테스트 주도 개발(TDD)과의 결합
C언어 접근 제어 기법은 테스트 코드를 작성할 때에도 도움이 됩니다. 구조체 내부가 감춰져 있더라도, 공개된 함수 인터페이스만 테스트하면 되므로 API 레벨 테스트가 자연스럽게 이루어집니다. 한편, 테스트 과정에서 필요한 디버깅이나 세부 정보는 디버깅 모드에서만 노출하는 ‘임시 디버깅 함수’를 추가하는 식으로 구현할 수 있습니다.
4. 단계적 접근 통제
접근 권한이 다른 여러 사용자나 프로세스가 동시에 사용하는 시스템을 설계할 때, 열거형 Role
또는 Level
을 사용해 권한을 명확히 분리하면 안전성을 높일 수 있습니다. 중요한 데이터 조작이나 시스템 자원 접근은 높은 권한이 필요하도록 설계하고, 일반 사용자나 게스트 권한에는 읽기 전용 인터페이스만 제공하는 식입니다.
5. 유지보수와 확장성
구조체 멤버를 외부에 노출하지 않으면, 내부 구현을 교체해야 할 때도 외부 인터페이스에 큰 변화를 주지 않고 유연하게 대처할 수 있습니다. 예를 들어, 배열 기반 구현을 동적 리스트 구조로 변경해도 함수 시그니처만 그대로 두면 기존 사용자 코드를 대부분 그대로 유지할 수 있습니다. 이는 대규모 프로젝트에서 필수적인 유지보수성 향상에 도움이 됩니다.
객체 지향 언어에서 제공하는 접근 제어 키워드 없이도, C언어에서는 구조체와 함수, 함수 포인터 테이블 등을 조합하는 방식으로 상당 부분 동일한 효과를 얻을 수 있습니다. 이러한 전략을 꾸준히 적용하면, 모듈 간 의존성을 줄이고, 개발자 간 협업과 유지보수를 용이하게 만드는 탄탄한 코드 구조를 구축할 수 있습니다.
요약
C언어에서 구조체와 함수 포인터 등을 활용하면 객체 지향 언어와 유사한 접근 제어를 흉내낼 수 있습니다. 데이터 은닉, 캡슐화, 권한 분리 등 핵심 기법을 적용하면, 유지보수와 보안성이 한층 강화된 구조를 구현할 수 있습니다.