Skip to main content

여러 파일로 프로그램 나누기

Views 5약 11분생성형 AI 활용
Summary

코드가 길어지면 생기는 문제

처음 C를 배울 때는 보통 main.c 하나로 충분합니다. printf를 써서 문장을 출력하고, 변수 몇 개를 만들고, 조건문과 반복문을 연습하는 정도라면 한 파일 안에서 모든 것을 볼 수 있는 편이 오히려 편합니다.

하지만 프로그램이 조금만 커져도 상황이 달라집니다. 예를 들어 학생 점수를 입력받고, 평균을 계산하고, 최고점을 찾고, 파일로 저장하는 프로그램을 만든다고 해 봅시다. 처음에는 main.c 하나에 모든 함수를 넣을 수 있습니다.

#include <stdio.h>

void print_menu(void) {
    printf("1. 점수 입력n");
    printf("2. 평균 출력n");
    printf("3. 최고점 출력n");
    printf("0. 종료n");
}

int get_max_score(int scores[], int size) {
    int max = scores[0];

    for (int i = 1; i < size; i++) {
        if (scores[i] > max) {
            max = scores[i];
        }
    }

    return max;
}

double get_average(int scores[], int size) {
    int sum = 0;

    for (int i = 0; i < size; i++) {
        sum += scores[i];
    }

    return (double)sum / size;
}

int main(void) {
    int scores[5] = {90, 80, 75, 100, 95};

    print_menu();
    printf("평균: %.2fn", get_average(scores, 5));
    printf("최고점: %dn", get_max_score(scores, 5));

    return 0;
}

이 정도는 아직 괜찮습니다. 그런데 여기에 학생 이름, 과목, 파일 저장, 검색, 정렬, 삭제 기능까지 붙으면 어떻게 될까요? main.c는 금세 수백 줄, 수천 줄이 됩니다.

파일 하나가 너무 길어지면 세 가지 문제가 생깁니다.

  • 원하는 함수를 찾기 어려워집니다.

  • 여러 사람이 동시에 작업하기 어려워집니다.

  • 수정할 때 관련 없는 코드까지 함께 건드리기 쉬워집니다.

C 실력이 늘어난다는 것은 단순히 for문이나 포인터 문법을 더 많이 안다는 뜻만은 아닙니다. 코드가 커졌을 때 어디를 나누고, 무엇을 공개하고, 무엇을 숨길지 판단할 수 있어야 합니다. 작은 프로그램은 문법으로 만들 수 있지만, 조금 큰 프로그램은 구조로 버팁니다.

이번 장에서는 하나의 C 프로그램을 여러 파일로 나누는 방법을 배웁니다. 여기서 중요한 질문은 이것입니다.

"이 함수는 어느 파일에 있어야 할까?"

"다른 파일에서 이 함수를 쓰려면 무엇을 알려 줘야 할까?"

"컴파일러와 링커는 나뉜 파일들을 어떻게 다시 하나로 묶을까?"

이 질문에 답할 수 있으면, C 프로그램을 바라보는 시야가 한 단계 넓어집니다.

여러 파일로 프로그램 나누기

파일을 나누는 가장 단순한 기준

파일을 나누는 기준은 처음부터 복잡하게 생각할 필요가 없습니다. 가장 쉬운 기준은 기능입니다. 서로 관련 있는 함수들을 하나의 .c 파일에 모읍니다.

예를 들어 학생 점수 프로그램이라면 다음처럼 나눌 수 있습니다.

project/
  main.c
  score.c
  score.h

main.c는 프로그램의 시작점입니다. 메뉴를 보여 주고, 사용자의 선택에 따라 적절한 함수를 호출합니다.

score.c는 점수 계산과 관련된 함수들을 담습니다. 평균 계산, 최고점 찾기, 최저점 찾기 같은 함수가 여기에 들어갑니다.

score.h는 score.c에 들어 있는 함수 중 다른 파일에서 사용할 함수의 목록을 알려 줍니다.

여기서 .c 파일과 .h 파일의 역할을 한 문장으로 정리하면 다음과 같습니다.

.c 파일에는 실제 동작을 적고, .h 파일에는 밖에서 사용할 약속을 적습니다.

이 문장이 이번 장의 핵심입니다.

선언과 정의를 다시 구분하기

여러 파일로 프로그램을 나누려면 먼저 선언과 정의를 정확히 구분해야 합니다.

함수 선언은 "이런 함수가 어딘가에 있다"고 알려 주는 문장입니다.

int add(int a, int b);

이 선언은 함수의 이름, 매개변수, 반환형을 알려 줍니다. 하지만 함수가 실제로 어떤 계산을 하는지는 적혀 있지 않습니다. 끝에 세미콜론이 붙어 있습니다.

반면 함수 정의는 함수의 실제 몸체까지 포함합니다.

int add(int a, int b) {
    return a + b;
}

정의에는 중괄호 블록이 있고, 그 안에 실행할 코드가 들어 있습니다.

두 코드는 비슷해 보이지만 컴파일과 링킹 과정에서는 완전히 다르게 취급됩니다. 선언은 약속입니다. 정의는 실체입니다.

가게 간판으로 비유해 봅시다. 선언은 "이 건물 안에 빵집이 있습니다"라는 안내판입니다. 정의는 실제로 빵을 굽는 가게입니다. 안내판은 여러 곳에 붙어 있어도 됩니다. 하지만 같은 건물 안에 똑같은 이름의 빵집이 두 개 있다고 주장하면 문제가 생깁니다.

C에서도 마찬가지입니다. 함수 선언은 여러 파일에서 반복해서 볼 수 있습니다. 하지만 일반 함수 정의는 프로그램 전체에서 보통 하나만 있어야 합니다.

첫 번째 분리: score.h와 score.c 만들기

이제 점수 계산 함수를 별도 파일로 나누어 보겠습니다.

먼저 score.h를 만듭니다.

#ifndef SCORE_H
#define SCORE_H

int get_max_score(int scores[], int size);
double get_average(int scores[], int size);

#endif

이 파일에는 함수 선언만 들어 있습니다. 함수가 실제로 어떻게 동작하는지는 없습니다.

#ifndef, #define, #endif는 이전 장에서 배운 include guard입니다. 같은 헤더 파일이 여러 번 포함되어도 내용이 한 번만 처리되도록 막아 줍니다.

이제 score.c를 만듭니다.

#include "score.h"

int get_max_score(int scores[], int size) {
    int max = scores[0];

    for (int i = 1; i < size; i++) {
        if (scores[i] > max) {
            max = scores[i];
        }
    }

    return max;
}

double get_average(int scores[], int size) {
    int sum = 0;

    for (int i = 0; i < size; i++) {
        sum += scores[i];
    }

    return (double)sum / size;
}

score.c는 맨 위에서 score.h를 포함합니다.

"어차피 score.c 안에 함수 정의가 있는데 왜 자기 헤더를 포함할까?"라는 의문이 들 수 있습니다. 이유는 간단합니다. 선언과 정의가 서로 일치하는지 컴파일러가 확인하게 하기 위해서입니다.

예를 들어 score.h에는 이렇게 선언해 놓고,

int get_max_score(int scores[], int size);

score.c에는 실수로 이렇게 정의했다고 해 봅시다.

double get_max_score(int scores[], int size) {
    return 0.0;
}

반환형이 다릅니다. score.c에서 #include "score.h"를 해 두면 컴파일러가 이 불일치를 발견할 수 있습니다. 헤더는 다른 파일을 위한 안내문이기도 하지만, 구현 파일 자신이 약속을 지키고 있는지 확인하는 검사표이기도 합니다.

이제 main.c에서 score.h를 포함하고 함수를 사용합니다.

#include <stdio.h>
#include "score.h"

int main(void) {
    int scores[5] = {90, 80, 75, 100, 95};

    printf("평균: %.2fn", get_average(scores, 5));
    printf("최고점: %dn", get_max_score(scores, 5));

    return 0;
}

main.c는 get_average와 get_max_score의 내부 코드를 알 필요가 없습니다. 함수 이름, 매개변수, 반환형만 알면 호출할 수 있습니다. 그 정보를 score.h가 제공합니다.

이 구조가 바로 C 프로젝트의 기본 형태입니다.

main.c    → score.h를 보고 함수를 호출한다.
score.c   → score.h의 약속대로 함수를 구현한다.
score.h   → 밖에서 사용할 함수의 선언을 제공한다.

꺾쇠괄호와 큰따옴표 include의 차이

여러 파일을 다루다 보면 #include에 두 가지 모양이 있다는 것을 자주 보게 됩니다.

#include <stdio.h>
#include "score.h"

<stdio.h>처럼 꺾쇠괄호를 쓰면 보통 컴파일러가 알고 있는 표준 헤더 또는 시스템 헤더 경로에서 파일을 찾습니다. stdio.h, stdlib.h, string.h 같은 표준 라이브러리 헤더를 포함할 때 사용합니다.

반면 "score.h"처럼 큰따옴표를 쓰면 보통 현재 소스 파일이 있는 위치나 프로젝트의 include 경로에서 먼저 찾습니다. 직접 만든 헤더 파일을 포함할 때 많이 씁니다.

즉, 아주 단순하게 기억하면 됩니다.

#include <...>   // 표준 라이브러리나 시스템 헤더
#include "..."   // 내가 만든 프로젝트 헤더

물론 실제 검색 경로는 컴파일러 옵션과 환경에 따라 조금씩 달라질 수 있습니다. 하지만 초보 단계에서는 이 구분만으로도 충분합니다.

헤더 파일에는 무엇을 넣어야 할까?

헤더 파일은 다른 .c 파일들이 함께 보는 공용 문서입니다. 그래서 아무 코드나 넣으면 안 됩니다. 헤더에 넣을 것과 넣지 않을 것을 구분하는 습관이 중요합니다.

일반적으로 헤더 파일에는 다음과 같은 내용을 넣습니다.

  • 다른 파일에서 호출할 함수 선언

  • 여러 파일이 함께 사용할 구조체, 열거형, typedef 선언

  • 여러 파일이 함께 사용할 매크로 상수

예를 들어 학생 한 명의 정보를 나타내는 구조체를 여러 파일에서 함께 써야 한다면 헤더에 둘 수 있습니다.

#ifndef STUDENT_H
#define STUDENT_H

#define NAME_LENGTH 50

typedef struct {
    char name[NAME_LENGTH];
    int score;
} Student;

void print_student(Student s);
int is_passed(Student s);

#endif

그리고 student.c에는 실제 함수 정의를 둡니다.

#include <stdio.h>
#include "student.h"

void print_student(Student s) {
    printf("이름: %s, 점수: %dn", s.name, s.score);
}

int is_passed(Student s) {
    return s.score >= 60;
}

다른 파일에서는 student.h만 포함하면 Student 자료형과 관련 함수들을 사용할 수 있습니다.

반대로 헤더에 넣지 않는 편이 좋은 것도 있습니다.

// 좋지 않은 예: 헤더 파일에 일반 함수 정의를 넣음
int add(int a, int b) {
    return a + b;
}

이런 코드를 헤더에 넣고 여러 .c 파일에서 포함하면, 각 .c 파일마다 add 함수 정의가 생깁니다. 나중에 링커가 파일들을 합치려고 할 때 "같은 함수가 여러 번 정의되었다"고 화를 냅니다.

따라서 기본 원칙은 명확합니다.

헤더에는 선언을 둡니다. 구현은 .c 파일에 둡니다.

물론 예외도 있습니다. 매크로 함수나 static inline 함수처럼 헤더에 정의를 두는 경우도 있습니다. 하지만 지금 단계에서는 예외보다 원칙을 먼저 몸에 익히는 것이 좋습니다.

컴파일 단위란 무엇인가?

이제 컴파일러의 눈으로 여러 파일 프로젝트를 바라보겠습니다.

우리가 다음과 같은 프로젝트를 가지고 있다고 합시다.

project/
  main.c
  score.c
  score.h

main.c의 내용은 다음과 같습니다.

#include <stdio.h>
#include "score.h"

int main(void) {
    int scores[5] = {90, 80, 75, 100, 95};

    printf("평균: %.2fn", get_average(scores, 5));
    printf("최고점: %dn", get_max_score(scores, 5));

    return 0;
}

컴파일을 시작하면 전처리기가 먼저 #include를 처리합니다. 아주 단순하게 말하면, #include "score.h" 자리에 score.h의 내용이 복사되어 들어간다고 생각할 수 있습니다.

전처리 후의 main.c는 대략 이런 모양이 됩니다.

#include <stdio.h>

int get_max_score(int scores[], int size);
double get_average(int scores[], int size);

int main(void) {
    int scores[5] = {90, 80, 75, 100, 95};

    printf("평균: %.2fn", get_average(scores, 5));
    printf("최고점: %dn", get_max_score(scores, 5));

    return 0;
}

실제로는 stdio.h의 내용도 포함되고, 전처리 결과는 훨씬 길어집니다. 하지만 핵심은 이것입니다. 컴파일러는 main.c를 컴파일할 때 score.c의 내용을 자동으로 들여다보지 않습니다.

이 점이 매우 중요합니다.

main.c를 컴파일할 때 컴파일러가 아는 것은 score.h에 적힌 선언뿐입니다. get_average 함수의 실제 몸체가 score.c에 있다는 사실은 아직 중요하지 않습니다. 컴파일러는 이렇게 생각합니다.

"get_average라는 함수가 있긴 하다고 선언되어 있군. 호출 방식도 맞네. 실제 함수 몸체는 나중에 누군가 연결해 주겠지."

이때 각각의 .c 파일은 독립적으로 컴파일됩니다. C에서는 이렇게 독립적으로 컴파일되는 단위를 컴파일 단위, 또는 번역 단위라고 부릅니다.

main.c  + 포함된 헤더들 → 하나의 컴파일 단위
score.c + 포함된 헤더들 → 또 다른 컴파일 단위

컴파일러는 각 컴파일 단위를 따로 처리하여 오브젝트 파일을 만듭니다.

main.c  → main.o
score.c → score.o

Windows 환경의 MSVC에서는 .obj 확장자를 많이 쓰고, GCC나 Clang 계열에서는 .o 확장자를 많이 씁니다. 이름은 다르지만 역할은 비슷합니다. 오브젝트 파일은 아직 완성된 실행 파일이 아니라, 기계어 코드 조각과 연결에 필요한 정보가 들어 있는 중간 결과물입니다.

컴파일과 링킹의 차이

초보자가 자주 헷갈리는 부분이 바로 컴파일과 링킹입니다. 우리는 보통 다음처럼 명령 하나로 프로그램을 만듭니다.

gcc main.c score.c -o app

이 명령은 간단해 보이지만 내부에서는 크게 두 단계가 일어납니다.

첫 번째는 컴파일입니다.

gcc -c main.c
gcc -c score.c

-c 옵션은 "컴파일만 하고 아직 실행 파일은 만들지 말라"는 뜻입니다. 이 명령을 실행하면 다음 파일이 생깁니다.

main.o
score.o

두 번째는 링킹입니다.

gcc main.o score.o -o app

링킹은 여러 오브젝트 파일을 묶어 하나의 실행 파일을 만드는 단계입니다. 링커는 main.o 안에서 "get_average 함수가 필요하다"는 표시를 발견하고, score.o 안에서 그 함수의 실제 정의를 찾습니다. 찾으면 둘을 연결합니다.

프로그램을 건물로 비유하면 컴파일은 각 부품을 따로 만드는 과정입니다. 벽, 문, 창문, 계단을 공장에서 따로 제작하는 것입니다. 링킹은 그 부품들을 현장에서 조립해 하나의 건물로 만드는 과정입니다.

이제 다음 흐름을 기억해 봅시다.

소스 파일(.c)
   ↓ 컴파일
오브젝트 파일(.o 또는 .obj)
   ↓ 링킹
실행 파일

헤더 파일 .h는 이 흐름에서 독립적으로 컴파일되지 않습니다. 헤더는 .c 파일에 포함되어 함께 처리됩니다. 그래서 보통 이렇게 컴파일하지 않습니다.

# 잘못된 생각
# gcc score.h -o score

헤더는 실행되는 코드 조각이라기보다, 여러 .c 파일이 공유하는 선언 문서에 가깝습니다.

직접 나누어 빌드해 보기

작은 프로젝트를 하나 만들어 보겠습니다. 폴더 이름은 multi_file_demo라고 하겠습니다.

multi_file_demo/
  main.c
  score.c
  score.h

score.h를 작성합니다.

#ifndef SCORE_H
#define SCORE_H

int get_max_score(int scores[], int size);
int get_min_score(int scores[], int size);
double get_average(int scores[], int size);

#endif

score.c를 작성합니다.

#include "score.h"

int get_max_score(int scores[], int size) {
    int max = scores[0];

    for (int i = 1; i < size; i++) {
        if (scores[i] > max) {
            max = scores[i];
        }
    }

    return max;
}

int get_min_score(int scores[], int size) {
    int min = scores[0];

    for (int i = 1; i < size; i++) {
        if (scores[i] < min) {
            min = scores[i];
        }
    }

    return min;
}

double get_average(int scores[], int size) {
    int sum = 0;

    for (int i = 0; i < size; i++) {
        sum += scores[i];
    }

    return (double)sum / size;
}

main.c를 작성합니다.

#include <stdio.h>
#include "score.h"

int main(void) {
    int scores[] = {90, 80, 75, 100, 95};
    int size = sizeof(scores) / sizeof(scores[0]);

    printf("평균: %.2fn", get_average(scores, size));
    printf("최고점: %dn", get_max_score(scores, size));
    printf("최저점: %dn", get_min_score(scores, size));

    return 0;
}

이제 터미널에서 다음 명령을 실행합니다.

gcc main.c score.c -o app

Linux나 macOS에서는 다음처럼 실행합니다.

./app

Windows에서 MinGW를 사용한다면 보통 다음처럼 실행합니다.

app.exe

예상 출력은 다음과 같습니다.

평균: 88.00
최고점: 100
최저점: 75

이번에는 컴파일과 링킹을 나누어 실행해 보겠습니다.

gcc -c main.c
gcc -c score.c
gcc main.o score.o -o app

첫 번째 명령은 main.c를 컴파일해서 main.o를 만듭니다. 두 번째 명령은 score.c를 컴파일해서 score.o를 만듭니다. 세 번째 명령은 두 오브젝트 파일을 링킹해서 실행 파일 app을 만듭니다.

처음에는 귀찮아 보일 수 있습니다. 하지만 프로젝트가 커지면 이 방식이 중요해집니다. 왜냐하면 수정한 파일만 다시 컴파일하고, 나머지는 기존 오브젝트 파일을 재사용할 수 있기 때문입니다.

예를 들어 main.c만 수정했다면 이론적으로는 score.c를 다시 컴파일할 필요가 없습니다.

gcc -c main.c
gcc main.o score.o -o app

이런 빌드 흐름을 사람이 매번 직접 입력하기는 번거롭습니다. 뒤에서 Makefile을 사용해 이 과정을 자동화해 보겠습니다.

함수별로 나눌까, 기능별로 나눌까?

파일을 나누기 시작하면 새로운 고민이 생깁니다. "그럼 함수 하나당 파일 하나를 만들어야 할까?"

대부분의 경우 답은 아닙니다. 함수 하나마다 파일을 만들면 파일 수가 너무 많아져 오히려 관리가 어려워집니다. 보통은 기능별로 묶습니다.

예를 들어 주소록 프로그램이라면 다음처럼 나눌 수 있습니다.

address_book/
  main.c
  contact.c
  contact.h
  file_storage.c
  file_storage.h
  menu.c
  menu.h

contact.c에는 연락처 추가, 삭제, 검색 같은 핵심 데이터 처리 함수를 둡니다. file_storage.c에는 파일 저장과 불러오기 함수를 둡니다. menu.c에는 메뉴 출력과 사용자 선택 입력 함수를 둘 수 있습니다.

좋은 분리 기준은 "같은 이유로 바뀌는 코드끼리 모으는 것"입니다.

파일 저장 방식을 텍스트에서 바이너리로 바꾼다고 해서 메뉴 출력 코드까지 바뀔 필요는 없습니다. 점수 평균 계산 방식을 바꾼다고 해서 사용자 입력 함수까지 수정할 필요는 없습니다. 서로 다른 이유로 바뀌는 코드는 서로 다른 파일에 두는 편이 좋습니다.

물론 처음부터 완벽하게 나누려고 하면 손이 멈춥니다. 초보 단계에서는 다음 기준만 기억해도 충분합니다.

  • main.c에는 프로그램의 큰 흐름을 둡니다.

  • 계산, 문자열 처리, 파일 처리처럼 성격이 다른 기능은 별도 .c 파일로 뺍니다.

  • 다른 파일에서 사용할 함수와 자료형은 .h 파일에 선언합니다.

코드를 나누는 능력은 한 번에 생기지 않습니다. 처음에는 조금 어색하게 나누고, 프로그램을 고치면서 "아, 이 함수는 저쪽 파일에 있는 편이 낫겠다" 하고 옮기면 됩니다. 구조는 처음부터 완성되는 것이 아니라, 프로그램과 함께 자랍니다.

공개할 함수와 숨길 함수

모든 함수를 헤더에 선언해야 하는 것은 아닙니다. 다른 파일에서 직접 호출할 필요가 없는 함수라면 헤더에 공개하지 않는 편이 좋습니다.

예를 들어 점수 배열이 비어 있는지 검사하는 내부 함수가 있다고 해 봅시다.

int is_valid_size(int size) {
    return size > 0;
}

이 함수는 score.c 안에서만 사용하고, main.c에서는 직접 호출할 필요가 없을 수 있습니다. 이런 함수까지 score.h에 넣으면 외부에서 호출할 수 있는 함수가 늘어나고, 프로그램의 사용 방식이 복잡해집니다.

C에서는 파일 안에서만 사용할 함수를 만들 때 static을 붙일 수 있습니다.

#include "score.h"

static int is_valid_size(int size) {
    return size > 0;
}

double get_average(int scores[], int size) {
    if (!is_valid_size(size)) {
        return 0.0;
    }

    int sum = 0;

    for (int i = 0; i < size; i++) {
        sum += scores[i];
    }

    return (double)sum / size;
}

여기서 is_valid_size는 score.c 안에서만 보이는 함수가 됩니다. 다른 .c 파일에서 같은 이름의 함수를 만들더라도 서로 충돌하지 않습니다.

이전에 스코프를 배울 때 변수의 범위가 중요하다고 했습니다. 여러 파일로 프로그램을 나누면 함수에도 비슷한 감각이 필요합니다. 밖에서 써야 하는 함수는 헤더에 공개합니다. 안에서만 쓸 보조 함수는 static으로 숨깁니다.

이렇게 숨기는 습관은 프로젝트가 커질수록 중요해집니다. 공개된 함수가 많을수록 다른 코드가 그 함수에 의존할 가능성이 커집니다. 반대로 내부 함수는 마음 놓고 이름을 바꾸거나 구현을 고칠 수 있습니다.

전역 변수는 어떻게 나눌까?

여러 파일을 다룰 때 전역 변수도 주의해야 합니다. 결론부터 말하면, 전역 변수는 되도록 줄이는 것이 좋습니다. 여러 파일에서 같은 전역 변수를 마음대로 바꾸기 시작하면 프로그램의 흐름을 추적하기 어려워집니다.

그래도 꼭 여러 파일에서 공유해야 하는 전역 변수가 있다면 선언과 정의를 구분해야 합니다.

예를 들어 프로그램 전체에서 현재 학생 수를 공유한다고 해 봅시다. 헤더 파일에 다음처럼 쓰면 문제가 생깁니다.

// 좋지 않은 예: count.h
#ifndef COUNT_H
#define COUNT_H

int student_count = 0;

#endif

이 헤더를 main.c와 student.c가 모두 포함하면 어떻게 될까요? 전처리 후에는 두 .c 파일에 각각 int student_count = 0;이라는 정의가 들어갑니다. 링커 입장에서는 같은 이름의 전역 변수가 여러 곳에 정의된 셈입니다. 그래서 중복 정의 오류가 날 수 있습니다.

올바른 방식은 헤더에는 extern 선언을 두고, 실제 정의는 .c 파일 하나에만 두는 것입니다.

// count.h
#ifndef COUNT_H
#define COUNT_H

extern int student_count;

#endif
// count.c
#include "count.h"

int student_count = 0;

extern int student_count;는 "이 변수는 어딘가에 정의되어 있다"고 알려 주는 선언입니다. 실제 메모리 공간을 만드는 정의는 count.c의 int student_count = 0;입니다.

하지만 다시 강조하겠습니다. 전역 변수를 공유하는 방식은 편리하지만 위험합니다. 가능하다면 전역 변수 대신 함수 매개변수, 구조체, 포인터를 사용해 필요한 데이터를 명확하게 전달하는 편이 좋습니다.

void add_score(int scores[], int *size, int value);

이런 함수는 어떤 데이터를 바꾸는지 매개변수에서 드러납니다. 반면 전역 변수를 함수 안에서 몰래 바꾸면, 나중에 코드를 읽는 사람이 "이 값이 대체 어디서 바뀌었지?" 하고 프로그램 전체를 뒤지게 됩니다.

링크 오류 읽는 법: undefined reference

여러 파일 프로젝트를 만들면 새로운 종류의 오류를 만나게 됩니다. 컴파일 오류는 문법이 틀렸을 때 주로 발생합니다. 링크 오류는 컴파일은 되었지만 오브젝트 파일들을 연결하는 과정에서 문제가 생길 때 발생합니다.

가장 흔한 링크 오류 중 하나는 undefined reference입니다.

예를 들어 score.h에 다음 선언이 있다고 합시다.

int get_max_score(int scores[], int size);
int get_min_score(int scores[], int size);
double get_average(int scores[], int size);

그리고 main.c에서 get_min_score를 호출합니다.

printf("최저점: %dn", get_min_score(scores, size));

그런데 score.c에 get_min_score 정의를 깜빡하고 작성하지 않았거나, 빌드 명령에서 score.c를 빼먹었다고 해 봅시다.

gcc main.c -o app

이 경우 main.c 자체는 컴파일될 수 있습니다. 왜냐하면 score.h에 get_min_score 선언이 있기 때문입니다. 컴파일러는 "이런 함수가 있겠구나" 하고 넘어갑니다.

하지만 링커는 실행 파일을 만들기 위해 실제 함수 정의를 찾아야 합니다. 그런데 어디에도 get_min_score의 몸체가 없습니다. 그러면 다음과 비슷한 오류가 납니다.

undefined reference to `get_min_score'

이 메시지는 이렇게 읽으면 됩니다.

"이 이름을 쓰겠다고 한 곳은 있는데, 실제 정의를 찾지 못했다."

원인은 보통 다음 중 하나입니다.

  • 함수 정의를 작성하지 않았다.

  • 함수 이름을 선언과 다르게 작성했다.

  • 빌드 명령에 해당 .c 파일 또는 .o 파일을 포함하지 않았다.

예를 들어 헤더에는 get_min_score라고 선언했는데 구현 파일에는 실수로 get_min_scores라고 썼다면 링커는 둘을 같은 함수로 보지 않습니다.

// score.h
int get_min_score(int scores[], int size);
// score.c
int get_min_scores(int scores[], int size) {
    return 0;
}

사람 눈에는 비슷해 보이지만, 컴파일러와 링커에게는 완전히 다른 이름입니다.

undefined reference를 만나면 먼저 세 가지를 확인하세요.

1. 헤더의 선언과 .c 파일의 정의 이름이 정확히 같은가?
2. 함수의 매개변수와 반환형이 일치하는가?
3. 컴파일 또는 링크 명령에 구현 파일이 포함되어 있는가?

특히 초보자는 세 번째를 자주 놓칩니다.

# score.c가 빠져 있음
gcc main.c -o app

# score.c를 함께 넣어야 함
gcc main.c score.c -o app

함수 선언은 초대장입니다. 함수 정의는 실제 손님입니다. 초대장만 있고 손님이 오지 않으면 파티는 열리지 않습니다.

링크 오류 읽는 법: multiple definition

반대 상황도 있습니다. 정의가 없는 것이 아니라 너무 많아서 생기는 오류입니다. 대표적인 메시지는 multiple definition입니다.

예를 들어 math_utils.h에 다음처럼 함수 정의를 넣었다고 해 봅시다.

#ifndef MATH_UTILS_H
#define MATH_UTILS_H

int add(int a, int b) {
    return a + b;
}

#endif

그리고 main.c와 test.c가 모두 이 헤더를 포함합니다.

#include "math_utils.h"

전처리기는 각 .c 파일 안에 add 함수 정의를 복사해 넣습니다. 그러면 컴파일 결과는 대략 이런 상황이 됩니다.

main.o 안에도 add 함수가 있음
test.o 안에도 add 함수가 있음

링커는 둘을 합치다가 당황합니다.

"add라는 함수가 하나여야 하는데 왜 여러 개지?"

그래서 다음과 비슷한 오류가 납니다.

multiple definition of `add'

해결 방법은 간단합니다. 헤더에는 선언만 둡니다.

// math_utils.h
#ifndef MATH_UTILS_H
#define MATH_UTILS_H

int add(int a, int b);

#endif

그리고 .c 파일 하나에만 정의를 둡니다.

// math_utils.c
#include "math_utils.h"

int add(int a, int b) {
    return a + b;
}

전역 변수도 비슷한 문제를 일으킵니다.

// 나쁜 예: config.h
int debug_mode = 0;

이 헤더를 여러 .c 파일에서 포함하면 debug_mode 정의가 여러 개 생길 수 있습니다. 앞에서 본 것처럼 헤더에는 extern 선언을 두고, 실제 정의는 .c 파일 하나에만 둬야 합니다.

// config.h
#ifndef CONFIG_H
#define CONFIG_H

extern int debug_mode;

#endif
// config.c
#include "config.h"

int debug_mode = 0;

multiple definition 오류를 보면 이렇게 생각하세요.

"같은 이름의 실체가 여러 곳에 만들어졌구나."

undefined reference가 "실체가 없다"는 오류라면, multiple definition은 "실체가 너무 많다"는 오류입니다.

헤더를 포함했는데 왜 구현 파일도 필요할까?

초보자가 자주 하는 질문이 있습니다.

"main.c에서 #include "score.h"를 했는데 왜 컴파일할 때 score.c도 같이 적어야 하나요?"

좋은 질문입니다. 답은 헤더가 함수의 몸체를 가져오지 않기 때문입니다.

score.h에는 이런 선언만 있습니다.

double get_average(int scores[], int size);

이 선언은 main.c에게 "이 함수는 이렇게 호출하면 된다"고 알려 줍니다. 하지만 실제 계산 코드는 score.c에 있습니다.

double get_average(int scores[], int size) {
    int sum = 0;

    for (int i = 0; i < size; i++) {
        sum += scores[i];
    }

    return (double)sum / size;
}

따라서 빌드할 때는 선언을 본 파일과 실제 구현이 들어 있는 파일을 모두 연결해야 합니다.

gcc main.c score.c -o app

헤더는 지도입니다. .c 파일은 실제 장소입니다. 지도를 봤다고 해서 목적지가 자동으로 내 앞에 나타나지는 않습니다. 목적지까지 실제로 연결해야 합니다. 그 일을 링커가 합니다.

파일 이름과 함수 이름을 꼭 맞춰야 할까?

C에서는 파일 이름과 함수 이름이 반드시 같을 필요는 없습니다. score.c 안에 get_average 함수가 있어도 되고, utils.c 안에 get_average 함수가 있어도 됩니다. 컴파일러는 파일 이름보다 함수 이름과 선언, 정의, 링킹 정보를 중요하게 봅니다.

하지만 사람은 다릅니다. 사람은 파일 이름을 보고 내용을 추측합니다. 그래서 관련 있는 이름을 붙이는 것이 좋습니다.

score.c       → 점수 계산 함수
score.h       → 점수 계산 함수의 선언
student.c     → 학생 데이터 처리 함수
student.h     → 학생 구조체와 관련 함수 선언
file_io.c     → 파일 저장과 읽기 함수
file_io.h     → 파일 입출력 함수 선언

좋은 이름은 설명을 줄여 줍니다. 나쁜 이름은 주석을 늘립니다.

utils.c라는 이름은 처음에는 편리해 보입니다. 온갖 잡다한 함수를 다 넣을 수 있기 때문입니다. 하지만 시간이 지나면 utils.c는 프로그램의 창고가 됩니다. 계산 함수, 문자열 함수, 파일 함수, 디버그 함수가 뒤섞이고, 결국 아무도 손대고 싶지 않은 파일이 됩니다.

처음에는 작은 utils.c를 만들어도 괜찮습니다. 하지만 그 안의 함수들이 특정 주제로 모이기 시작하면 적절한 이름의 파일로 나누는 것이 좋습니다.

작은 프로젝트 구조 만들기

이번에는 조금 더 프로젝트다운 구조를 만들어 보겠습니다. 학생 정보를 출력하고 합격 여부를 판단하는 프로그램입니다.

student_project/
  main.c
  student.h
  student.c
  score.h
  score.c

student.h입니다.

#ifndef STUDENT_H
#define STUDENT_H

#define NAME_LENGTH 50

typedef struct {
    char name[NAME_LENGTH];
    int score;
} Student;

void print_student(Student s);
int is_passed(Student s);

#endif

student.c입니다.

#include <stdio.h>
#include "student.h"

void print_student(Student s) {
    printf("이름: %s, 점수: %dn", s.name, s.score);
}

int is_passed(Student s) {
    return s.score >= 60;
}

score.h입니다.

#ifndef SCORE_H
#define SCORE_H

#include "student.h"

double get_student_average(Student students[], int size);
int get_highest_score(Student students[], int size);

#endif

여기서 score.h가 student.h를 포함합니다. 왜냐하면 Student 자료형을 함수 선언에서 사용하기 때문입니다. 컴파일러가 Student가 무엇인지 알아야 함수 선언을 이해할 수 있습니다.

score.c입니다.

#include "score.h"

double get_student_average(Student students[], int size) {
    int sum = 0;

    for (int i = 0; i < size; i++) {
        sum += students[i].score;
    }

    return (double)sum / size;
}

int get_highest_score(Student students[], int size) {
    int max = students[0].score;

    for (int i = 1; i < size; i++) {
        if (students[i].score > max) {
            max = students[i].score;
        }
    }

    return max;
}

마지막으로 main.c입니다.

#include <stdio.h>
#include "student.h"
#include "score.h"

int main(void) {
    Student students[] = {
        {"Jisu", 90},
        {"Minho", 55},
        {"Yuna", 82}
    };

    int size = sizeof(students) / sizeof(students[0]);

    for (int i = 0; i < size; i++) {
        print_student(students[i]);

        if (is_passed(students[i])) {
            printf("결과: 합격n");
        } else {
            printf("결과: 불합격n");
        }

        printf("n");
    }

    printf("평균 점수: %.2fn", get_student_average(students, size));
    printf("최고 점수: %dn", get_highest_score(students, size));

    return 0;
}

빌드는 다음처럼 합니다.

gcc main.c student.c score.c -o student_app

실행 결과는 다음과 비슷합니다.

이름: Jisu, 점수: 90
결과: 합격

이름: Minho, 점수: 55
결과: 불합격

이름: Yuna, 점수: 82
결과: 합격

평균 점수: 75.67
최고 점수: 90

이제 main.c는 프로그램의 큰 흐름을 담당합니다. 학생을 출력하고 합격 여부를 판단하는 구체적인 코드는 student.c에 있습니다. 점수 통계를 계산하는 코드는 score.c에 있습니다.

이 구조의 장점은 수정할 때 드러납니다. 합격 기준을 60점에서 70점으로 바꾸고 싶다면 student.c의 is_passed 함수만 보면 됩니다. 평균 계산 방식을 바꾸고 싶다면 score.c를 보면 됩니다. main.c 전체를 뒤질 필요가 없습니다.

의존 관계를 너무 복잡하게 만들지 않기

여러 파일로 나누면 파일 사이에 의존 관계가 생깁니다. 어떤 파일이 다른 파일의 헤더를 포함한다는 것은 "나는 그 파일의 선언을 알아야 한다"는 뜻입니다.

간단한 프로젝트에서는 크게 문제 되지 않지만, 파일이 많아지면 의존 관계가 복잡해질 수 있습니다.

main.c      → student.h, score.h
score.h     → student.h
score.c     → score.h
student.c   → student.h

이 정도는 괜찮습니다. score 기능이 Student 자료형을 사용하므로 score.h가 student.h를 포함하는 것은 자연스럽습니다.

하지만 모든 헤더가 서로를 마구 포함하기 시작하면 문제가 생깁니다. 한 파일을 조금만 바꿔도 많은 파일을 다시 컴파일해야 하고, 순환 포함 때문에 이해하기 어려운 오류가 생길 수 있습니다.

초보 단계에서는 다음 습관을 가지면 좋습니다.

  • 헤더에는 꼭 필요한 헤더만 포함합니다.

  • .c 파일에서만 필요한 헤더는 .c 파일에 포함합니다.

  • 헤더끼리 서로 물고 물리는 구조를 피합니다.

예를 들어 student.c에서만 printf를 쓴다면 stdio.h는 student.c에 포함하면 됩니다. student.h에 굳이 stdio.h를 넣을 필요는 없습니다.

// student.h
#ifndef STUDENT_H
#define STUDENT_H

#define NAME_LENGTH 50

typedef struct {
    char name[NAME_LENGTH];
    int score;
} Student;

void print_student(Student s);

#endif
// student.c
#include <stdio.h>
#include "student.h"

void print_student(Student s) {
    printf("이름: %s, 점수: %dn", s.name, s.score);
}

이렇게 하면 student.h를 포함하는 다른 파일들이 불필요하게 stdio.h에 의존하지 않습니다. 작은 차이처럼 보이지만 큰 프로젝트에서는 빌드 시간과 코드 이해도에 영향을 줍니다.

Makefile이 필요한 이유

소스 파일이 세 개 정도일 때는 다음 명령을 직접 입력해도 괜찮습니다.

gcc main.c student.c score.c -o student_app

하지만 파일이 열 개, 스무 개로 늘어나면 어떨까요?

gcc main.c student.c score.c file_io.c menu.c validation.c search.c sort.c -o app

점점 길어집니다. 옵션까지 붙으면 더 복잡해집니다.

gcc -Wall -Wextra -std=c11 main.c student.c score.c file_io.c menu.c -o app

매번 이 명령을 외우고 입력하는 것은 번거롭습니다. 어떤 파일을 수정했을 때 어떤 파일을 다시 컴파일해야 하는지도 직접 판단해야 합니다.

Makefile은 이런 빌드 과정을 기록해 두는 파일입니다. make라는 도구는 Makefile을 읽고 필요한 명령을 대신 실행합니다.

Makefile을 사용하면 다음처럼 간단히 빌드할 수 있습니다.

make

실행 파일과 중간 파일을 지울 때는 다음처럼 할 수 있습니다.

make clean

Makefile은 C 문법은 아니지만, C 프로젝트를 다룰 때 자주 만나는 중요한 도구입니다. 지금은 깊게 파고들기보다 "여러 파일을 한 번에 빌드하는 방법" 정도로 맛보겠습니다.

가장 단순한 Makefile

앞에서 만든 프로젝트를 다시 보겠습니다.

student_project/
  main.c
  student.h
  student.c
  score.h
  score.c

가장 단순한 Makefile은 다음처럼 쓸 수 있습니다.

student_app: main.c student.c score.c
	gcc main.c student.c score.c -o student_app

주의할 점이 하나 있습니다. 두 번째 줄의 gcc 앞에는 스페이스가 아니라 탭 문자가 와야 합니다. Makefile에서 명령 줄은 탭으로 시작해야 합니다. 이 규칙 때문에 처음 Makefile을 작성할 때 오류를 자주 만납니다.

이제 같은 폴더에서 다음 명령을 실행합니다.

make

make는 현재 폴더의 Makefile을 읽고 student_app이라는 대상을 만들기 위해 필요한 명령을 실행합니다.

Makefile의 기본 형태는 다음과 같습니다.

대상: 필요한 파일들
	실행할 명령

여기서 student_app은 만들고 싶은 대상입니다. main.c, student.c, score.c는 그 대상을 만드는 데 필요한 파일입니다. 아래의 gcc 명령은 실제로 실행할 빌드 명령입니다.

조금 더 정리하면 다음과 같습니다.

app: main.c student.c score.c
	gcc main.c student.c score.c -o app

이 Makefile은 단순하지만 한계가 있습니다. 소스 파일이 조금만 늘어나도 명령이 길어집니다. 컴파일 옵션도 반복해서 쓰기 어렵습니다. 그래서 보통 변수와 오브젝트 파일을 사용합니다.

오브젝트 파일을 사용하는 Makefile

조금 더 실제 프로젝트에 가까운 Makefile을 작성해 보겠습니다.

CC = gcc
CFLAGS = -Wall -Wextra -std=c11
TARGET = student_app
OBJS = main.o student.o score.o

$(TARGET): $(OBJS)
	$(CC) $(OBJS) -o $(TARGET)

main.o: main.c student.h score.h
	$(CC) $(CFLAGS) -c main.c

student.o: student.c student.h
	$(CC) $(CFLAGS) -c student.c

score.o: score.c score.h student.h
	$(CC) $(CFLAGS) -c score.c

clean:
	rm -f $(OBJS) $(TARGET)

하나씩 보겠습니다.

CC = gcc
CFLAGS = -Wall -Wextra -std=c11
TARGET = student_app
OBJS = main.o student.o score.o

여기서는 변수를 정의합니다. CC는 사용할 컴파일러, CFLAGS는 컴파일 옵션, TARGET은 만들 실행 파일 이름, OBJS는 오브젝트 파일 목록입니다.

-Wall과 -Wextra는 컴파일러가 더 많은 경고를 보여 주도록 하는 옵션입니다. 경고는 오류는 아니지만, 버그가 될 가능성이 높은 코드를 알려 줍니다. C를 배울 때는 경고를 귀찮은 잔소리로 여기지 말고 친절한 조언으로 받아들이는 것이 좋습니다.

다음 부분은 최종 실행 파일을 만드는 규칙입니다.

$(TARGET): $(OBJS)
	$(CC) $(OBJS) -o $(TARGET)

student_app은 main.o, student.o, score.o가 필요합니다. 이 오브젝트 파일들이 준비되면 링킹해서 실행 파일을 만듭니다.

다음은 main.o를 만드는 규칙입니다.

main.o: main.c student.h score.h
	$(CC) $(CFLAGS) -c main.c

main.o는 main.c, student.h, score.h에 의존합니다. 즉, 이 파일들 중 하나라도 바뀌면 main.o를 다시 만들어야 합니다.

student.o와 score.o도 마찬가지입니다.

student.o: student.c student.h
	$(CC) $(CFLAGS) -c student.c

score.o: score.c score.h student.h
	$(CC) $(CFLAGS) -c score.c

마지막의 clean은 빌드 결과물을 지우는 규칙입니다.

clean:
	rm -f $(OBJS) $(TARGET)

Linux와 macOS에서는 이 명령이 잘 동작합니다. Windows의 기본 명령 프롬프트에서는 rm이 없을 수 있습니다. 그 경우 MinGW, MSYS2, Git Bash 같은 환경을 사용하거나 Windows 명령에 맞게 바꿔야 합니다.

clean:
	del *.o student_app.exe

다만 Windows에서 del은 환경에 따라 동작 방식이 다를 수 있으므로, 처음에는 clean 규칙을 꼭 완벽히 만들려고 애쓰지 않아도 됩니다. 중요한 것은 Makefile이 "어떤 파일을 어떻게 빌드할지 적어 둔 설명서"라는 점입니다.

make는 무엇을 다시 빌드할까?

Makefile의 재미있는 점은 필요한 것만 다시 빌드한다는 것입니다.

예를 들어 처음 make를 실행하면 오브젝트 파일이 없으므로 모두 컴파일하고, 마지막에 링킹합니다.

main.c    → main.o
student.c → student.o
score.c   → score.o
main.o + student.o + score.o → student_app

그런데 이후에 score.c만 수정하고 다시 make를 실행하면 어떻게 될까요? make는 파일의 수정 시간을 보고 score.o만 다시 만들면 된다고 판단할 수 있습니다.

score.c   → score.o 다시 생성
main.o + student.o + score.o → student_app 다시 생성

main.c와 student.c는 바뀌지 않았으므로 다시 컴파일하지 않아도 됩니다. 프로젝트가 작을 때는 차이가 거의 없지만, 파일이 많고 컴파일 시간이 길어질수록 큰 차이가 납니다.

헤더 파일이 바뀌었을 때도 의존 관계가 중요합니다. 예를 들어 student.h에서 Student 구조체를 수정했다면 student.h에 의존하는 파일들을 다시 컴파일해야 합니다.

main.o: main.c student.h score.h
student.o: student.c student.h
score.o: score.c score.h student.h

이 의존 관계 덕분에 make는 어떤 오브젝트 파일을 다시 만들어야 하는지 판단할 수 있습니다.

처음에는 Makefile의 문법이 낯설 수 있습니다. 하지만 핵심은 단순합니다.

"이 파일을 만들려면 무엇이 필요하고, 어떤 명령을 실행해야 하는가?"

Makefile은 그 질문에 대한 답을 적어 둔 파일입니다.

자주 만나는 실수들

여러 파일 프로젝트에서는 특정 실수가 반복해서 나타납니다. 오류 메시지를 무작정 두려워하기보다, 어떤 실수가 어떤 오류로 이어지는지 연결해서 이해하면 훨씬 편합니다.

첫 번째 실수는 헤더에는 선언을 써 놓고 구현 파일을 빌드 명령에 넣지 않는 것입니다.

# score.c가 빠짐
gcc main.c -o app

이 경우 보통 undefined reference가 납니다. 해결은 구현 파일을 함께 컴파일하거나 오브젝트 파일을 링킹하는 것입니다.

gcc main.c score.c -o app

두 번째 실수는 헤더에 함수 정의를 넣는 것입니다.

// bad.h
int add(int a, int b) {
    return a + b;
}

여러 .c 파일에서 이 헤더를 포함하면 multiple definition 오류가 날 수 있습니다. 헤더에는 선언만 두고, 정의는 .c 파일 하나에 둡니다.

세 번째 실수는 include guard를 빼먹는 것입니다.

// bad_score.h
int get_max_score(int scores[], int size);

함수 선언만 있는 간단한 헤더에서는 당장 문제가 안 보일 수도 있습니다. 하지만 구조체 정의나 typedef가 들어가면 같은 헤더가 여러 번 포함될 때 재정의 오류가 생길 수 있습니다. 습관적으로 include guard를 넣는 편이 좋습니다.

#ifndef SCORE_H
#define SCORE_H

int get_max_score(int scores[], int size);

#endif

네 번째 실수는 선언과 정의의 모양이 다른 것입니다.

// score.h
double get_average(int scores[], int size);
// score.c
int get_average(int scores[], int size) {
    return 0;
}

반환형이 다릅니다. 이런 실수는 score.c에서 #include "score.h"를 해 두면 컴파일러가 더 잘 잡아 줍니다. 구현 파일은 자기 헤더를 포함하는 습관을 들이세요.

다섯 번째 실수는 Makefile 명령 줄 앞에 스페이스를 넣는 것입니다.

app: main.c
    gcc main.c -o app

겉보기에는 들여쓰기가 된 것처럼 보이지만, Makefile은 명령 앞에 탭을 요구합니다. 스페이스를 넣으면 다음과 비슷한 오류가 날 수 있습니다.

missing separator

이 오류를 보면 "혹시 명령 줄 앞에 탭 대신 스페이스를 넣었나?"를 먼저 확인하세요.

선언은 약속이고, 링킹은 약속을 맞추는 과정이다

지금까지의 내용을 한 번 큰 그림으로 정리해 보겠습니다.

main.c는 score.h를 포함합니다. 그래서 get_average라는 함수를 호출해도 된다는 사실을 압니다. 하지만 score.h에는 함수 몸체가 없습니다.

score.c에는 get_average 함수의 실제 정의가 있습니다. 이 파일을 컴파일하면 score.o 안에 함수의 기계어 코드가 들어갑니다.

main.c를 컴파일하면 main.o가 생깁니다. main.o 안에는 "나는 get_average라는 함수가 필요하다"는 정보가 남아 있습니다.

링커는 main.o와 score.o를 살펴보며 필요한 이름과 제공되는 이름을 맞춥니다.

main.o  : get_average가 필요함
score.o : get_average를 제공함

링커   : 둘을 연결함

만약 필요한 이름을 어디에서도 찾지 못하면 undefined reference가 됩니다.

main.o  : get_average가 필요함
score.o : get_average가 없음

링커   : undefined reference

반대로 같은 이름을 여러 곳에서 제공하면 multiple definition이 됩니다.

main.o  : add를 제공함
utils.o : add를 제공함

링커   : multiple definition

이제 링크 오류가 조금 덜 무섭게 느껴질 것입니다. 오류 메시지는 컴퓨터의 불평이 아니라, 연결 과정에서 어떤 약속이 깨졌는지 알려 주는 신호입니다.

작은 프로그램도 프로젝트처럼 다루기

처음에는 파일 하나로 작성하는 편이 쉽습니다. 하지만 어느 순간부터는 일부러라도 파일을 나누어 보는 것이 좋습니다. 작은 프로그램을 프로젝트처럼 다루는 연습이 나중에 큰 차이를 만듭니다.

예를 들어 다음과 같은 기준으로 연습해 볼 수 있습니다.

  • 계산 함수는 math_utils.c, math_utils.h로 분리합니다.

  • 문자열 처리 함수는 string_utils.c, string_utils.h로 분리합니다.

  • 메뉴 출력 함수는 menu.c, menu.h로 분리합니다.

처음에는 파일이 늘어나는 것이 귀찮게 느껴질 수 있습니다. #include도 챙겨야 하고, 컴파일 명령도 길어집니다. 하지만 이 과정을 통해 C 프로그램이 실제로 어떻게 만들어지는지 이해하게 됩니다.

Python이나 JavaScript에서는 파일을 나누고 가져오는 과정이 비교적 자동으로 느껴질 때가 많습니다. C에서는 그 연결 과정이 훨씬 더 노골적으로 드러납니다. 어떤 파일을 컴파일할지, 어떤 오브젝트 파일을 링킹할지, 어떤 선언을 헤더에 둘지 직접 신경 써야 합니다.

처음에는 번거롭지만, 바로 그 번거로움 덕분에 컴퓨터가 프로그램을 만드는 과정을 더 선명하게 볼 수 있습니다.

C를 배우는 이유는 단지 문법을 외우기 위해서가 아닙니다. 값이 메모리에 놓이고, 함수가 호출되고, 파일들이 컴파일되고, 링커가 조각들을 묶어 실행 파일을 만드는 과정을 이해하기 위해서입니다.

이번 장에서 배운 여러 파일 구조는 앞으로 디버깅, 자료구조, 최종 프로젝트에서 계속 등장합니다. 특히 최종 프로젝트에서는 구조체, 동적 메모리, 파일 입출력 기능을 여러 .c와 .h 파일로 나누어 구성하게 될 것입니다.

그때 당황하지 않으려면 지금 이 감각을 꼭 붙잡아 두세요.

헤더에는 약속을 둡니다.

소스 파일에는 구현을 둡니다.

컴파일러는 파일을 따로 번역합니다.

링커는 번역된 조각들을 하나로 연결합니다.

이 네 문장을 이해했다면, 이제 여러분은 C 프로그램을 "한 파일짜리 코드"가 아니라 "여러 부품이 맞물려 돌아가는 프로젝트"로 보기 시작한 것입니다.

책 목록

처음 만나는 C언어: 메모리와 포인터로 배우는 컴퓨터의 원리

전체 목차 보기

이 노트는 보도·비평·교육·연구 목적으로 작성되었습니다. 저작권 문의가 있으시면 에서 알려주세요.

틸노트는 사용자가 AI를 활용해 노트를 쉽게 작성하거나 편집할 수 있는 서비스입니다.

AI 스퀘어 단톡방에 함께해요

AI 활용과 틸노트 사용 팁을 나누는 오픈채팅방입니다. 입장 비밀번호는 tilnote입니다.

참여하기