[C언어 50강] 43강. 분할 컴파일: 여러 .c/.h 빌드 흐름, Makefile 기초
규모가 작은 실습에서는 main.c 하나만 컴파일해도 충분합니다. 하지만 기능이 늘어나면 한 파일에 모든 코드를 몰아넣는 방식은 곧 유지보수 한계에 부딪힙니다. 이번 강의에서는 C 프로젝트를 여러 .c/.h 파일로 나누는 분할 컴파일(separate compilation) 개념과, 그 빌드 과정을 자동화하는 Makefile 기초를 개념 중심으로 정리합니다. 코드가 돌아가는 것보다 중요한 것은, 왜 그렇게 나눠야 하고 어떤 규칙을 지켜야 팀 개발에서 덜 깨지는지 이해하는 것입니다.
핵심 개념
- C 빌드는 보통
전처리 → 컴파일(오브젝트 생성) → 링크(실행 파일 생성)단계로 진행되며, 분할 컴파일은 컴파일과 링크를 파일 단위로 분리해 관리한다. - 헤더(
.h)는 “외부에 공개할 약속(인터페이스)”, 소스(.c)는 “실제 구현”이다. 이 경계를 명확히 해야 변경 영향 범위가 줄어든다. - Makefile은 "무엇을 어떤 조건에서 다시 빌드할지"를 선언하는 규칙 파일이며, 증분 빌드(incremental build)의 핵심 도구다.
개념 먼저 이해하기
분할 컴파일을 이해하려면 먼저 C 컴파일러가 한 번에 전체 프로젝트를 이해하는 존재가 아니라는 점을 받아들여야 합니다. C 컴파일러는 기본적으로 번역 단위(translation unit) 단위로 동작합니다. 쉽게 말해 a.c를 컴파일할 때는 a.c에 포함된 헤더와 코드만 본 뒤 a.o를 만들고, b.c는 별도로 b.o를 만듭니다. 이 둘을 마지막에 링커가 합쳐 실행 파일로 만드는 구조입니다. 그래서 분할 컴파일은 “코드를 나눈다”를 넘어서, “컴파일 시점의 독립성”을 확보하는 설계 전략입니다.
이 독립성이 왜 중요할까요? 첫째, 빌드 시간이 줄어듭니다. 큰 프로젝트에서 파일 하나를 고쳤는데 전체를 매번 다시 컴파일하면 개발 속도가 급격히 느려집니다. 분할 컴파일 구조에서는 수정된 파일과 그 파일에 의존하는 일부만 재컴파일하면 되므로, 피드백 루프가 짧아집니다. 둘째, 변경 영향이 줄어듭니다. math_utils.c 내부 구현을 고쳐도 헤더의 함수 시그니처가 변하지 않았다면 다른 모듈은 재컴파일 없이 링크만 다시 하면 되는 경우가 많습니다. 즉, 인터페이스 안정성이 생산성과 직결됩니다.
여기서 가장 자주 발생하는 오해가 있습니다. “헤더는 선언만 모아둔 파일이니까 아무거나 다 넣어도 된다”는 생각입니다. 실제로는 반대입니다. 헤더는 최소 공개 원칙을 지켜야 합니다. 외부에서 알아야 할 타입/함수 선언만 두고, 내부 구현 디테일은 .c에 감춰야 합니다. 헤더에 구현 세부 구조체를 과도하게 노출하면, 작은 내부 변경이 연쇄 재컴파일과 API 파손으로 이어집니다. 그래서 실무에서는 헤더를 API 문서처럼 다룹니다.
또 하나 중요한 포인트는 링크 단계에서의 오류 감각입니다. 컴파일은 통과했는데 링크에서 undefined reference가 터지는 순간이 초보자에게 가장 혼란스럽습니다. 원인은 대부분 세 가지입니다. (1) 선언은 있는데 구현이 없음, (2) 구현은 있는데 빌드 목록에서 해당 .c를 빼먹음, (3) 함수 시그니처 불일치. 즉 “문법이 맞는지”와 “전체 프로그램이 연결 가능한지”는 다른 문제입니다. 분할 컴파일을 다룰 때는 컴파일 에러와 링크 에러를 구분해서 해석하는 습관이 꼭 필요합니다.
Makefile은 이 과정을 사람이 실수 없이 반복하게 만드는 자동화 장치입니다. 단순 스크립트처럼 보이지만 본질은 “타깃 파일이 소스보다 오래됐는가?”를 판단해 필요한 작업만 실행하는 의존성 엔진입니다. 예를 들어 main.o는 main.c와 calc.h에 의존한다고 선언해두면, calc.h가 바뀐 경우에도 자동으로 main.o를 재생성할 수 있습니다. 반대로 의존성을 빼먹으면 오래된 오브젝트가 남아 버그 재현이 꼬입니다. 그래서 Makefile 작성은 문법 암기보다 의존성 모델링 능력이 핵심입니다.
결론적으로 분할 컴파일은 “파일 분리 기술”이 아니라 “변경 비용을 통제하는 설계 원칙”입니다. 모듈 경계(헤더/소스), 빌드 단계(컴파일/링크), 자동화 규칙(Makefile)을 함께 이해해야 실무에서 흔들리지 않습니다. 이 세 축이 잡히면 코드베이스가 커져도 빌드가 예측 가능해지고, 팀 협업에서 충돌 비용도 눈에 띄게 줄어듭니다.
기본 사용
예제 1) 헤더/소스 분리의 최소 구조
/* calc.h */
#ifndef DEVLAB_CALC_H
#define DEVLAB_CALC_H
int add(int a, int b);
int sub(int a, int b);
#endif
/* calc.c */
#include "calc.h"
int add(int a, int b) {
return a + b;
}
int sub(int a, int b) {
return a - b;
}
/* main.c */
#include <stdio.h>
#include "calc.h"
int main(void) {
printf("add=%d\n", add(10, 3));
printf("sub=%d\n", sub(10, 3));
return 0;
}
설명:
calc.h는 외부 공개 인터페이스(함수 선언)만 노출하고, 구현은calc.c가 담당합니다.main.c는 구현 내부를 몰라도calc.h약속만 지키면 함수 사용이 가능합니다.- 이런 구조가 모듈 경계를 만들며, 구현 변경 시 파급을 줄입니다.
예제 2) 컴파일/링크 단계를 명시적으로 분리하기
/* 터미널 명령 예시(개념 코드 블록) */
clang -std=c11 -Wall -Wextra -Wpedantic -c calc.c -o calc.o
clang -std=c11 -Wall -Wextra -Wpedantic -c main.c -o main.o
clang main.o calc.o -o app
./app
설명:
-c옵션은 링크를 하지 않고 오브젝트(.o)만 만듭니다.- 마지막 줄에서 링커가
main.o,calc.o를 결합해 실행 파일을 생성합니다. - 링크 에러가 나면 “어떤 오브젝트에 필요한 심볼이 없는지” 관점으로 추적해야 합니다.
예제 3) Makefile로 증분 빌드 자동화
# Makefile
CC = clang
CFLAGS = -std=c11 -Wall -Wextra -Wpedantic -O0 -g
TARGET = app
OBJS = main.o calc.o
all: $(TARGET)
$(TARGET): $(OBJS)
$(CC) $(OBJS) -o $(TARGET)
main.o: main.c calc.h
$(CC) $(CFLAGS) -c main.c -o main.o
calc.o: calc.c calc.h
$(CC) $(CFLAGS) -c calc.c -o calc.o
clean:
rm -f $(OBJS) $(TARGET)
설명:
main.o: main.c calc.h처럼 헤더 의존성을 명시해야 헤더 변경 시 재컴파일이 발생합니다.make는 변경된 파일에 연결된 타깃만 다시 생성하므로 빌드 시간이 줄어듭니다.clean타깃은 재현 가능한 빌드를 위한 기본 안전장치입니다.
자주 하는 실수
실수 1) 헤더에 함수 구현까지 넣어 중복 정의를 유발함
- 원인: 편하다는 이유로
.h에 일반 함수 본문을 작성하고 여러.c에서 include함. - 해결: 헤더에는 선언 중심으로 유지하고, 구현은
.c로 이동한다. 예외적으로static inline만 목적을 분명히 해서 사용한다.
실수 2) include guard를 빼먹어 재정의 에러를 만듦
- 원인: 작은 프로젝트에서 한 번은 우연히 통과해도, 파일 수가 늘면 중복 포함이 즉시 문제로 드러남.
- 해결: 모든 헤더에 고유 가드(
PROJECT_MODULE_H) 또는#pragma once를 일관되게 적용한다.
실수 3) 링크 대상에서 소스 파일 하나를 누락함
- 원인: 수동 빌드 명령에서
calc.o등을 빼먹고main.o만 링크함. - 해결: Makefile의
OBJS변수를 단일 진실원(SSOT)으로 관리하고, 수동 링크를 줄인다.
실수 4) 헤더 변경 시 의존성을 선언하지 않아 오래된 오브젝트를 재사용함
- 원인: Makefile에
main.o: main.c만 적고calc.h의존성을 빼먹음. - 해결: 각 오브젝트 타깃에 관련 헤더를 명시하고, 프로젝트가 커지면 자동 의존성 생성(
-MMD -MP)을 도입한다.
실무 패턴
- 헤더는 공개 API, 소스는 구현이라는 경계를 강하게 유지한다.
- 모듈 단위로 파일을 묶는다. 예:
net.h/net.c,parser.h/parser.c,store.h/store.c. - 빌드 플래그는 Makefile 변수(
CFLAGS,LDFLAGS)로 통합 관리해 팀원 환경 차이를 줄인다. - 경고를 오류로 승격하는 정책(
-Werror)은 CI에서 먼저 적용하고, 로컬에서는 단계적으로 도입한다. - 디렉터리 구조가 커지면 상위 Makefile + 하위 모듈 Makefile로 분리하되, 타깃 이름 규칙을 팀 표준으로 고정한다.
- 빌드 실패 로그를 공유할 때는 컴파일 단계 실패인지 링크 단계 실패인지 먼저 분류해 커뮤니케이션 비용을 줄인다.
오늘의 결론
한 줄 요약: 분할 컴파일은 코드를 나누는 기술이 아니라, 인터페이스와 의존성을 설계해 변경 비용을 통제하는 실무 습관이다.
연습문제
math_ext.h/.c를 만들어mul,div_safe(0으로 나눔 방지) 함수를 분리 구현하고,main.c에서 호출하라.- 의도적으로 Makefile에서
math_ext.o를OBJS에서 제거한 뒤 발생하는 링크 에러 메시지를 기록하고 원인을 설명하라. calc.h에 새 함수 선언을 추가한 뒤 관련.c구현을 누락했을 때 컴파일/링크 중 어느 단계에서 실패하는지 비교하라.
이전 강의 정답
42강 연습문제(전처리기) 정답 요약:
CLAMP(x, lo, hi)는 괄호 규칙을 지켜#define CLAMP(x, lo, hi) ((x) < (lo) ? (lo) : ((x) > (hi) ? (hi) : (x)))처럼 작성해야 연산자 우선순위로 인한 오작동을 막을 수 있다.- DEBUG 로그 매크로는
#ifdef DEBUG분기로fprintf(stderr, ...)를 쓰고, 비활성 시((void)0)로 치환하는 패턴이 안전하다.-DDEBUG유무로 실행 출력 차이가 명확해야 한다. MAX는 매크로보다static inline버전이 부작용 인자(i++)에 안전하다. 매크로는 인자 재평가 위험이 있어 예측 불가능한 증가를 만들 수 있다.
실습 환경/재현 정보
- 컴파일러: Apple clang 17.x 이상 또는 GCC 13+
- 컴파일 옵션:
-std=c11 -Wall -Wextra -Wpedantic -O0 -g - 실행 환경: macOS (arm64), zsh 터미널
- 재현 체크:
make실행 후 바이너리 정상 생성 확인touch calc.h뒤make실행 시 관련 오브젝트 재컴파일 확인OBJS에서 항목 제거 시undefined reference발생 확인make clean && make로 클린 빌드 재현성 확인