모듈과 불투명 타입 - 헤더로 경계를 긋는 법
이 장에서 배우는 것
C 언어는 객체지향 언어처럼 클래스의 접근 제어자(public, private)를 문법으로 제공하지 않는다. 하지만 헤더 파일과 몇 가지 키워드를 잘 조합하면, 모듈의 내부 구현을 완벽에 가깝게 숨기고 외부에는 필요한 인터페이스만 노출할 수 있다. 이번 장에서는 도서 관리 모듈을 만들면서 안전한 경계를 긋는 방법을 알아본다.
static키워드를 활용해 모듈 내부의 변수와 함수를 숨긴다.- 인클루드 가드(include guard)로 헤더 파일의 다중 포함 문제를 막는다.
- 불투명 타입(opaque type) 구조체로 데이터 구조를 감추어 결합도를 낮춘다.
- 여러 목적 파일을 묶어 하나의 정적 라이브러리(static library) 파일로 만든다.
문제 상황
작은 도서관 대출 관리 프로그램을 작성하는 중이다. 초기에는 도서 정보를 담는 구조체를 헤더 파일에 모두 정의하고, 여러 소스 파일에서 이 구조체 멤버에 직접 접근해 대출 상태를 변경했다.
그런데 프로그램이 커지면서 문제가 생겼다. 도서 검색을 담당하는 모듈에서 실수로 도서의 고유 ID를 덮어쓰는 버그가 발생했다. 도서 반납을 처리하는 모듈에서는 대출 횟수를 증가시키는 코드를 빼먹기도 했다. 더 큰 문제는 도서 구조체에 '반납 예정일'이라는 필드 하나를 추가하자, 이 헤더 파일을 포함하고 있던 모든 소스 파일이 다시 컴파일되어 빌드 시간이 크게 늘어난 것이다.
구조체의 모든 내부 데이터가 노출되어 있으니 어느 모듈에서든 값을 임의로 바꿀 수 있었고, 구조체의 모양이 바뀔 때마다 프로그램 전체가 영향을 받았다. 데이터를 보호하면서도 변경의 여파를 최소화할 구조적인 경계가 필요하다.
모듈화와 정보 은닉
C 언어에서 모듈은 보통 하나의 헤더 파일(.h)과 하나의 소스 파일(.c) 쌍으로 이루어진다. 헤더 파일은 모듈이 외부로 제공할 기능들의 목록(인터페이스)을 담고, 소스 파일은 그 기능이 실제로 동작하는 코드(구현)를 담는다.
인클루드 가드로 헤더 파일 보호하기
헤더 파일은 여러 소스 파일에서 포함할 수 있다. 한 소스 파일이 다른 헤더 파일들을 포함하다 보면, 같은 헤더 파일이 두 번 이상 겹쳐서 포함될 수 있다. 구조체 정의가 두 번 나타나면 컴파일러는 재정의 오류를 낸다. 이를 막기 위해 헤더 파일 전체를 전처리기 조건문으로 감싸는 것을 인클루드 가드라고 부른다.
헤더 파일의 첫머리에 #ifndef와 #define을 쓰고, 맨 끝에 #endif를 둔다. 매크로 이름은 파일명과 프로젝트 이름을 조합하여 고유하게 짓는다. 이렇게 하면 첫 번째 포함 때 매크로가 정의되고, 두 번째부터는 조건문이 거짓이 되어 내용이 무시된다.
static과 extern의 올바른 사용
하나의 프로그램은 여러 개의 소스 파일을 각각 컴파일하여 목적 파일(.o)로 만든 뒤, 링커가 이들을 하나로 묶어 실행 파일을 만든다. 이때 파일 간에 이름(심벌)이 어떻게 공유되는지가 중요하다.
소스 파일 전역에 선언된 변수나 함수는 기본적으로 외부 연결성(external linkage)을 가진다. 즉, 다른 소스 파일에서 extern 키워드로 선언만 하면 그 변수나 함수를 끌어다 쓸 수 있다. 헤더 파일에 함수의 원형을 적는 행위가 바로 이 extern 선언을 대신해 주는 것이다.
반대로 소스 파일에서 전역 변수나 함수 앞에 static을 붙이면 내부 연결성(internal linkage)을 가지게 된다. 해당 소스 파일 안에서만 사용할 수 있고 링커는 다른 파일에서 이 이름을 찾지 못한다. 모듈 내부에서만 쓰는 도우미 함수나, 모듈의 전역 상태를 유지할 때는 반드시 static을 붙여 외부에 노출되는 것을 막아야 한다.
불투명 타입으로 구현 감추기
모듈의 내부 상태뿐 아니라, 데이터를 담는 구조체의 모습 자체를 숨기고 싶을 때 불투명 타입 패턴을 쓴다. 헤더 파일에는 구조체의 이름만 선언(typedef struct Book Book;)하고, 구조체가 어떤 멤버 변수들을 가지고 있는지(정의)는 소스 파일에 작성한다.
이렇게 하면 외부 모듈에서는 이 구조체의 크기를 알 수 없으므로 값으로 변수를 만들 수 없다. 오직 포인터(Book*) 형태로만 다룰 수 있다. 외부에서는 포인터를 모듈이 제공하는 함수에 넘겨주는 방식으로만 조작하므로, 구조체의 내부 멤버에 직접 접근하여 값을 망가뜨리는 일을 원천 차단할 수 있다.
또한 구조체에 새로운 필드가 추가되어도, 헤더 파일의 내용은 변하지 않았으므로 이를 사용하는 다른 소스 파일들은 다시 컴파일할 필요가 없다.
정적 라이브러리 만들기
잘 만들어진 모듈이 여러 개 있다면, 이를 하나의 파일로 묶어서 다른 프로젝트에 제공할 수 있다. 유닉스 환경에서는 ar 명령을 사용하여 여러 목적 파일을 하나의 .a 확장자를 가진 파일로 묶는다. 이를 정적 라이브러리라고 부른다.
정적 라이브러리를 사용해 실행 파일을 만들면, 링커는 라이브러리 안에 있는 목적 파일 중 프로그램이 실제로 호출하는 코드가 들어 있는 파일만 골라서 실행 파일에 복사해 넣는다. 따라서 라이브러리 전체를 링크하더라도 실행 파일이 불필요하게 커지지 않는다.
완성 코드
도서 대출을 관리하는 모듈을 불투명 타입으로 작성하고, 메인 프로그램에서 이를 사용하는 코드다.
book_manager.h
#ifndef BOOK_MANAGER_H
#define BOOK_MANAGER_H
/* 불투명 타입 선언 */
typedef struct Book Book;
/* 모듈 인터페이스 (함수 원형) */
Book* Book_Create(int id, const char* title);
void Book_Destroy(Book* book);
int Book_Checkout(Book* book);
int Book_Return(Book* book);
void Book_PrintStatus(const Book* book);
#endif
book_manager.c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include "book_manager.h"
/* 구조체의 실제 정의 (모듈 내부에 숨김) */
struct Book {
int id;
char* title;
int is_borrowed;
int borrow_count;
};
/* 모듈 내부에서만 사용하는 전역 상태 */
static int total_books_created = 0;
/* 모듈 내부에서만 사용하는 도우미 함수 */
static void LogAction(const char* action, int book_id) {
printf("[LOG] 도서 %d번 %s\n", book_id, action);
}
Book* Book_Create(int id, const char* title) {
Book* new_book = (Book*)malloc(sizeof(Book));
if (new_book == NULL) {
return NULL;
}
new_book->id = id;
size_t title_len = strlen(title) + 1;
new_book->title = (char*)malloc(title_len);
if (new_book->title == NULL) {
free(new_book);
return NULL;
}
strcpy(new_book->title, title);
new_book->is_borrowed = 0;
new_book->borrow_count = 0;
total_books_created++;
LogAction("등록됨", id);
return new_book;
}
void Book_Destroy(Book* book) {
if (book != NULL) {
LogAction("삭제됨", book->id);
free(book->title);
free(book);
}
}
int Book_Checkout(Book* book) {
if (book == NULL || book->is_borrowed) {
return 0;
}
book->is_borrowed = 1;
book->borrow_count++;
LogAction("대출됨", book->id);
return 1;
}
int Book_Return(Book* book) {
if (book == NULL || !book->is_borrowed) {
return 0;
}
book->is_borrowed = 0;
LogAction("반납됨", book->id);
return 1;
}
void Book_PrintStatus(const Book* book) {
if (book == NULL) return;
printf("도서 [%d] %s - 상태: %s (누적 대출: %d회)\n",
book->id, book->title,
book->is_borrowed ? "대출 중" : "대출 가능",
book->borrow_count);
}
main.c
#include <stdio.h>
#include "book_manager.h"
int main(void) {
/* 불투명 타입이므로 Book 구조체의 크기나 멤버를 모름 */
/* Book my_book; // 컴파일 오류 발생 */
Book* book1 = Book_Create(101, "C 프로그래밍");
Book* book2 = Book_Create(102, "자료구조");
Book_Checkout(book1);
Book_Checkout(book1); /* 이미 대출 중이므로 실패 */
Book_PrintStatus(book1);
Book_PrintStatus(book2);
Book_Return(book1);
Book_PrintStatus(book1);
Book_Destroy(book1);
Book_Destroy(book2);
return 0;
}
줄별 해설
book_manager.h의typedef struct Book Book;: 구조체의 껍데기 이름만 선언한다. 이 헤더를 포함하는 곳에서는 구조체 내부 구조를 모르더라도 구조체를 가리키는 포인터를 선언하고 넘길 수 있다.book_manager.c의struct Book { ... };: 실제 데이터를 담는 구조체를 소스 파일 안에 정의한다. 외부 파일은 이 파일의 컴파일 결과를 목적 파일로만 만나기 때문에 소스 코드의 정의를 들여다볼 수 없다.book_manager.c의static int total_books_created = 0;: 이 모듈 안에서만 유효한 전역 변수다. 다른 소스 파일에서 같은 이름의 전역 변수를 쓰더라도 링커가 충돌을 일으키지 않는다.book_manager.c의static void LogAction(...): 모듈 내부용 도우미 함수다. 외부 인터페이스인 헤더 파일에는 선언되지 않았으며, 외부 소스 파일이 직접 호출할 수 없다.main.c의 주석 처리된Book my_book;: 컴파일러는Book타입의 크기를 알지 못하므로 메모리를 얼마나 잡아야 할지 계산할 수 없다. 불완전한 타입(incomplete type)이라며 컴파일 오류를 낸다. 오직 포인터로만 사용할 수 있다.
실행 결과
모듈을 컴파일하고, 정적 라이브러리로 묶은 뒤, 메인 프로그램과 링크하여 실행하는 과정이다.
$ cc -std=c17 -Wall -Wextra -c book_manager.c
$ ar rcs libbook.a book_manager.o
$ cc -std=c17 -Wall -Wextra main.c -L. -lbook -o library_app
$ ./library_app
[LOG] 도서 101번 등록됨
[LOG] 도서 102번 등록됨
[LOG] 도서 101번 대출됨
도서 [101] C 프로그래밍 - 상태: 대출 중 (누적 대출: 1회)
도서 [102] 자료구조 - 상태: 대출 가능 (누적 대출: 0회)
[LOG] 도서 101번 반납됨
도서 [101] C 프로그래밍 - 상태: 대출 가능 (누적 대출: 1회)
[LOG] 도서 101번 삭제됨
[LOG] 도서 102번 삭제됨
-c옵션은 링크를 수행하지 않고 목적 파일(.o)만 만든다.ar rcs는 목적 파일을 묶어 아카이브 파일(.a)을 생성하거나 갱신한다.-L.은 현재 디렉터리에서 라이브러리를 찾으라는 뜻이고,-lbook은libbook.a를 링크하라는 뜻이다.
실무에서 자주 틀리는 것
헤더 파일에 변수 정의하기
헤더 파일에 인터페이스를 제공한다고 전역 변수를 직접 정의하는 실수를 자주 한다.
/* 틀린 코드: header.h */
#ifndef HEADER_H
#define HEADER_H
int global_count = 0; /* 헤더에 직접 정의 */
#endif
이 헤더를 두 개의 소스 파일에서 포함하면, 각 목적 파일에 global_count 변수가 따로 생성된다. 나중에 링커가 이 두 파일을 합칠 때 같은 이름의 변수가 두 번 정의되었다며 다중 정의 오류(multiple definition error)를 낸다.
/* 고친 코드: header.h */
#ifndef HEADER_H
#define HEADER_H
extern int global_count; /* 선언만 제공 */
#endif
/* 고친 코드: source.c */
#include "header.h"
int global_count = 0; /* 단 한 번 정의 */
헤더 파일에는 extern으로 변수의 존재만 알리고, 실제 메모리 할당을 수반하는 정의는 단 하나의 소스 파일에 두어야 한다.
불투명 타입 값 복사 시도
불투명 타입은 포인터로만 다루어야 하는데, 함수 매개변수나 반환형으로 값 자체를 넘기려 하면 컴파일되지 않는다.
/* 틀린 코드 */
Book GetBookCopy(Book* original) {
return *original; /* 크기를 알 수 없는 타입을 값으로 반환 불가 */
}
불투명 타입의 내용을 복사해야 한다면 복사를 수행하여 새 포인터를 반환하는 모듈 함수를 별도로 만들어 제공해야 한다.
매크로 이름 충돌
인클루드 가드에 사용하는 매크로 이름은 고유해야 한다. 단순히 FILE_H라고 지으면, 여러 서브 디렉터리에 같은 이름의 파일이 있을 때 엉뚱한 헤더가 무시되는 버그가 생긴다.
/* 틀린 코드 */
#ifndef CONFIG_H
#define CONFIG_H
/* ... */
#endif
/* 고친 코드 */
#ifndef LIBRARY_BOOK_MANAGER_H
#define LIBRARY_BOOK_MANAGER_H
/* ... */
#endif
프로젝트 이름, 모듈 이름, 파일 이름을 모두 조합하여 짓는 것이 안전하다.
한눈에 보기
| 구분 | 선언/정의 위치 | 연결성 | 설명 |
|---|---|---|---|
extern 선언 |
헤더 파일 (.h) |
외부 | 다른 소스 파일에 정의된 함수나 변수를 사용할 수 있게 함 |
| 일반 전역 정의 | 소스 파일 (.c) |
외부 | 모든 소스 파일에서 링커를 통해 접근 가능 (전역 상태) |
static 전역 정의 |
소스 파일 (.c) |
내부 | 해당 소스 파일 안에서만 접근 가능 (정보 은닉) |
| 불투명 구조체 | 헤더에 선언, 소스에 정의 | (타입) | 외부에서는 멤버 접근 불가, 오직 포인터로만 취급 |
| 확장자 | 이름 | 설명 |
|---|---|---|
.c |
소스 파일 | 컴파일러가 읽어서 기계어로 변환하는 대상 |
.h |
헤더 파일 | 함수와 타입의 선언을 모아두어 다른 소스에서 포함하는 파일 |
.o |
목적 파일 | 하나의 소스 파일이 컴파일된 기계어 조각 (링크 전) |
.a |
정적 라이브러리 | 여러 개의 목적 파일을 묶어 놓은 보관함 파일 |
연습 문제
- 소스 파일 최상단에
static int user_count = 0;이라고 적었다. 이 변수는 다른 소스 파일에서extern int user_count;라고 선언하면 정상적으로 접근할 수 있는가? 그 이유는 무엇인가? - 인클루드 가드(include guard)를 작성할 때
#ifndef와#define에 사용하는 매크로 이름을 짓는 좋은 습관은 무엇인가? - 불투명 타입으로 구조체를 구현할 때, 구조체의 멤버가 변경되어도 이 헤더 파일을 포함하는 다른 모듈들을 다시 컴파일하지 않아도 되는 이유는 무엇인가?
정답과 해설
- 접근할 수 없다.
static키워드가 전역 변수에 붙으면 내부 연결성을 가지게 되어 링커가 다른 파일에서 이 변수의 이름을 찾지 못하도록 숨기기 때문이다. 컴파일은 통과할지 몰라도 링크 단계에서 심벌을 찾을 수 없다는 오류가 발생한다. - 프로젝트 이름, 모듈(디렉터리) 이름, 파일 이름을 모두 조합하여 다른 파일의 가드 매크로와 겹치지 않도록 길고 고유하게 짓는 것이 좋다. (예:
MYPROJ_NET_SERVER_H) - 헤더 파일에는
typedef struct Name Name;처럼 타입의 이름만 명시된 불완전한 선언만 들어 있기 때문이다. 멤버 구조는 소스 파일에만 감춰져 있으므로, 멤버가 추가되거나 삭제되어도 헤더 파일의 내용은 변하지 않는다. C 언어 빌드 시스템은 보통 헤더 파일이 변할 때만 이를 포함한 소스 파일들을 다시 컴파일한다.