C++ 프로그램은 어떻게 실행 파일이 될까? 컴파일과 링크 과정

C++ 소스 파일에 코드를 작성했다고 해서 컴퓨터가 그 내용을 바로 실행할 수 있는 것은 아닙니다.

우리가 작성한 C++ 코드는 빌드 과정을 거쳐 기계어로 변환되고, 다른 소스 파일 및 라이브러리와 결합된 뒤 하나의 실행 파일이 됩니다.

이 과정을 이해하면 다음과 같은 질문에 답할 수 있습니다.

  • 왜 문법 오류와 링크 오류는 서로 다른 시점에 발생할까?
  • 컴파일러는 왜 다른 .cpp 파일에 작성한 함수를 자동으로 찾지 못할까?
  • 함수 선언은 헤더 파일에, 함수 정의는 소스 파일에 작성하는 이유가 무엇일까?
  • .obj 파일은 무엇이고 언제 만들어질까?

이번 글에서는 C++ 소스 코드가 실행 파일로 만들어지는 전체 과정을 살펴보겠습니다.

목차

  1. C++ 프로그램의 빌드 과정
  2. 전처리 단계
  3. 컴파일 단계
  4. 어셈블 단계
  5. 링크 단계
  6. 소스 파일 하나만 컴파일하기
  7. 여러 개의 .cpp 파일 사용하기
  8. 함수 선언과 정의
  9. 함수 선언을 헤더 파일로 분리하기
  10. 컴파일 오류와 링크 오류 비교
  11. 정리

C++ 프로그램의 빌드 과정

C++ 소스 코드가 실행 파일로 만들어지는 과정은 교육 목적상 다음 네 단계로 구분할 수 있습니다.

  1. 전처리
  2. 컴파일
  3. 어셈블
  4. 링크
C++ 소스 코드
    ↓ 전처리
번역 단위
    ↓ 컴파일
어셈블리 수준의 코드
    ↓ 어셈블
목적 파일(.obj)
    ↓ 링크
실행 파일(.exe)

실제 빌드 도구는 일부 단계를 내부에서 연속으로 처리할 수 있습니다. 컴파일러가 어셈블리 파일을 디스크에 별도로 저장하지 않고 바로 목적 파일을 만들기도 합니다.

하지만 각 단계의 역할을 구분해 두면 C++ 프로그램의 구조와 오류가 발생하는 원인을 이해하는 데 도움이 됩니다.


1. 전처리 단계

전처리기는 컴파일에 앞서 소스 코드에 작성된 전처리 지시문을 처리합니다.

대표적인 전처리 지시문은 다음과 같습니다.

  • #include: 지정한 헤더의 내용을 포함합니다.
  • #define: 매크로를 정의합니다.
  • #if, #ifdef: 조건에 따라 컴파일할 코드를 선택합니다.

전처리 단계의 주요 작업은 다음과 같습니다.

  • #include로 지정한 헤더의 내용 포함
  • 매크로 확장
  • 조건부 컴파일 처리
  • 주석을 공백으로 대체
  • 컴파일러가 처리할 번역 단위 생성

예를 들어 다음 코드가 있다고 가정해 보겠습니다.

#include <iostream>

#define MESSAGE "Hello World"

int main()
{
    std::cout << MESSAGE << '\n';
}

전처리기는 iostream 헤더를 포함하고, 코드에 작성된 MESSAGE"Hello World"로 확장합니다.

주의: 전처리기의 주된 역할은 C++ 문법을 검사하는 것이 아닙니다. C++ 문법과 타입의 유효성은 주로 컴파일 단계에서 검사합니다. 전처리 지시문 자체에 문제가 있다면 전처리 단계에서도 오류가 발생할 수 있습니다.


2. 컴파일 단계

컴파일러는 전처리 결과로 만들어진 번역 단위를 분석합니다.

컴파일 단계에서는 주로 다음 작업을 수행합니다.

  • C++ 문법 검사
  • 이름과 타입 검사
  • 함수 호출에 필요한 선언 확인
  • 소스 코드를 대상 시스템의 명령으로 변환
  • 어셈블러가 처리할 코드 생성

이 단계에서 세미콜론을 빠뜨리거나, 선언되지 않은 이름을 사용하거나, 함수의 매개변수에 맞지 않는 값을 전달하면 컴파일 오류가 발생합니다.

번역 단위란?

.cpp 파일은 자신이 포함한 헤더와 함께 하나의 번역 단위(Translation Unit)가 됩니다.

Main.cpp + Main.cpp가 포함한 헤더 → 하나의 번역 단위

Log.cpp + Log.cpp가 포함한 헤더 → 하나의 번역 단위

각 번역 단위는 기본적으로 독립적으로 컴파일됩니다.

따라서 컴파일러가 Main.cpp를 처리하는 동안 프로젝트의 다른 .cpp 파일을 자동으로 검색해 함수나 클래스의 정보를 확인하지 않습니다.

이 특성은 이후에 살펴볼 함수 선언과 헤더 파일의 역할을 이해하는 핵심입니다.


3. 어셈블 단계

어셈블러는 컴파일러가 생성한 어셈블리 수준의 코드를 대상 시스템에서 실행할 수 있는 기계어로 변환합니다.

그 결과 각 소스 파일에 대응하는 목적 파일이 만들어집니다.

Main.cpp → Main.obj
Log.cpp  → Log.obj

Windows 환경의 Visual Studio에서는 일반적으로 목적 파일의 확장자로 .obj를 사용합니다. 다른 개발 환경에서는 .o 확장자를 사용하기도 합니다.

목적 파일에는 기계어 코드가 들어 있지만 아직 완성된 프로그램은 아닙니다.

현재 목적 파일에서 호출한 함수가 다른 목적 파일이나 라이브러리에 정의되어 있다면 그 함수의 최종 주소가 아직 결정되지 않았을 수 있습니다.


4. 링크 단계

링커는 여러 목적 파일과 필요한 라이브러리를 결합해 최종 실행 파일을 만듭니다.

Main.obj + Log.obj + 필요한 라이브러리 → 실행 파일(.exe)

예를 들어 Main.obj에서 Log 함수를 호출하고 Log.obj에 해당 함수가 정의되어 있다면 링커가 두 코드를 연결합니다.

링커의 주요 작업은 다음과 같습니다.

  • 각 목적 파일 결합
  • 필요한 라이브러리 연결
  • 외부 함수와 변수의 정의 탐색
  • 코드와 데이터의 최종 주소 결정
  • 실행 파일 생성

전처리, 컴파일, 어셈블 단계는 각 .cpp 파일을 기준으로 진행됩니다. 링크 단계에서는 각 파일에서 만들어진 결과물이 하나의 프로그램으로 결합됩니다.


소스 파일 하나만 컴파일하기

Visual Studio에서는 프로젝트 전체를 빌드하지 않고 특정 .cpp 파일만 컴파일할 수 있습니다.

  1. 솔루션 탐색기에서 컴파일할 .cpp 파일을 선택합니다.
  2. Ctrl + F7을 누르거나 빌드 → 컴파일을 선택합니다.
  3. 출력 창에서 컴파일 결과를 확인합니다.
  4. 프로젝트의 중간 디렉터리에서 생성된 .obj 파일을 확인합니다.

Ctrl + F7은 현재 선택한 소스 파일을 컴파일하지만 프로젝트 전체를 링크해 실행 파일을 만들지는 않습니다.

따라서 정상적으로 컴파일되었다면 .obj 파일은 만들어지지만 새로운 .exe 파일은 만들어지지 않을 수 있습니다.

헤더 파일만 선택했거나 프로젝트에 포함되지 않은 파일을 선택하면 기대한 결과가 나오지 않을 수 있습니다. 반드시 프로젝트에 포함된 .cpp 파일을 선택합니다.


여러 개의 .cpp 파일 사용하기

작은 프로그램은 모든 코드를 하나의 .cpp 파일에 작성할 수도 있습니다.

#include <iostream>

void Log(const char* message)
{
    std::cout << message << '\n';
}

int main()
{
    Log("Hello World");
    std::cin.get();
}

Log 함수의 정의가 호출 코드보다 앞에 있으므로 컴파일러는 함수의 이름, 반환형, 매개변수를 확인할 수 있습니다.

하지만 프로그램의 규모가 커지면 기능에 따라 코드를 여러 파일로 분리해야 합니다.

함수 정의를 다른 파일로 이동하기

Log 함수의 정의를 Log.cpp 파일로 이동해 보겠습니다.

Log.cpp

#include <iostream>

void Log(const char* message)
{
    std::cout << message << '\n';
}

Main.cpp

#include <iostream>

int main()
{
    Log("Hello World");
    std::cin.get();
}

이 상태에서 Main.cpp를 컴파일하면 오류가 발생합니다.

Main.cpp를 하나의 번역 단위로 컴파일하는 시점에는 Log라는 함수가 선언되어 있지 않기 때문입니다.

Log.cpp가 같은 프로젝트에 들어 있어도 컴파일러가 그 파일을 자동으로 열어 함수 정보를 찾아주지는 않습니다.


함수 선언과 정의

Main.cpp에서 Log 함수를 호출하려면 컴파일러가 함수의 정보를 확인할 수 있도록 선언을 제공해야 합니다.

#include <iostream>

void Log(const char* message);

int main()
{
    Log("Hello World");
    std::cin.get();
}

다음 코드가 Log 함수의 선언입니다.

void Log(const char* message);

함수 선언은 컴파일러에 다음 정보를 알려줍니다.

  • 함수 이름: Log
  • 반환형: void
  • 매개변수 형식: const char*

함수 선언을 확인한 컴파일러는 Log 함수의 실제 코드가 현재 번역 단위에 없어도 함수 호출을 컴파일할 수 있습니다.

프로젝트 전체를 빌드하면 두 소스 파일이 각각 목적 파일로 만들어지고, 링크 단계에서 함수 호출과 정의가 연결됩니다.

Main.cpp → Main.obj ┐
                    ├→ 링커 → 실행 파일
Log.cpp  → Log.obj  ┘

Main.cpp의 코드를 검사하고 목적 파일로 변환하는 일은 컴파일러가 담당합니다. Main.cpp에서 호출한 Log 함수와 Log.cpp의 함수 정의를 연결하는 일은 링커가 담당합니다.

선언만 있고 정의가 없다면?

다음과 같이 함수 선언이 있으면 Main.cpp 자체는 컴파일될 수 있습니다.

void Log(const char* message);

그러나 프로젝트의 어느 곳에도 이 선언과 일치하는 함수 정의가 없다면 링커가 연결할 코드를 찾지 못합니다.

Visual Studio에서는 이때 일반적으로 확인할 수 없는 외부 기호 오류가 발생합니다.

선언과 정의가 서로 다르다면?

선언과 정의의 매개변수 형식이 서로 다르면 링커는 두 코드를 같은 함수로 취급하지 않습니다.

// Main.cpp에 작성한 선언
void Log(const char* message);

// Log.cpp에 작성한 정의
void Log(int message)
{
}

첫 번째 코드는 const char* 값을 받는 함수를 선언하고, 두 번째 코드는 int 값을 받는 함수를 정의합니다.

두 함수의 이름은 같지만 매개변수 형식이 다르므로 서로 다른 함수입니다. 따라서 Log(const char*)에 일치하는 정의가 없다는 링크 오류가 발생합니다.


함수 선언을 헤더 파일로 분리하기

여러 .cpp 파일에서 같은 함수 선언을 사용한다면 각 파일에 선언을 반복해서 작성하는 것보다 헤더 파일로 분리하는 것이 좋습니다.

Log.h

#pragma once

void Log(const char* message);

Log.cpp

#include "Log.h"

#include <iostream>

void Log(const char* message)
{
    std::cout << message << '\n';
}

Main.cpp

#include "Log.h"

#include <iostream>

int main()
{
    Log("Hello World");
    std::cin.get();
}

이 구조에서 각 파일은 다음 역할을 담당합니다.

  • Log.h: 여러 소스 파일이 함께 알아야 하는 Log 함수의 선언을 제공합니다.
  • Log.cpp: Log 함수가 실제로 수행할 동작을 정의합니다.
  • Main.cpp: 헤더를 통해 함수 선언을 확인하고 Log 함수를 호출합니다.

함수 정의가 들어 있는 Log.cpp에서도 Log.h를 포함하는 것이 좋습니다.

이렇게 구성하면 헤더의 선언과 소스 파일의 정의가 일치하는지 컴파일러가 확인할 수 있습니다. 여러 파일에 같은 선언을 직접 반복해서 작성하는 일도 줄어듭니다.

#include <iostream>은 입출력 기능을 실제로 사용하는 .cpp 파일에 포함합니다. Log.h에서는 std::cout을 사용하지 않으므로 이 예제의 헤더에 iostream을 포함할 필요가 없습니다.


컴파일 오류와 링크 오류 비교

컴파일 오류와 링크 오류는 모두 프로젝트의 빌드를 실패하게 하지만 발생하는 단계와 원인이 다릅니다.

구분 컴파일 오류 링크 오류
발생 단계 컴파일 단계 링크 단계
의미 현재 번역 단위의 코드를 컴파일할 수 없음 컴파일된 목적 파일을 하나의 프로그램으로 연결할 수 없음
대표 원인 문법 오류, 선언되지 않은 이름, 자료형 불일치 함수 정의 누락, 라이브러리 누락, 중복 정의
목적 파일 생성 문제가 발생한 소스 파일의 목적 파일이 정상 생성되지 않을 수 있음 각 소스 파일의 목적 파일은 생성될 수 있음

대표적인 컴파일 오류

  • 세미콜론과 같은 C++ 문법 누락
  • 선언되지 않은 함수나 변수 사용
  • 맞지 않는 자료형 사용
  • 함수 호출 인자와 선언된 매개변수의 불일치

대표적인 링크 오류

  • 함수를 선언하고 호출했지만 일치하는 정의가 없음
  • 필요한 라이브러리를 링크하지 않음
  • 같은 함수나 전역 변수를 여러 곳에 중복 정의함

오류 메시지를 확인할 때 컴파일 오류인지 링크 오류인지를 먼저 구분하면 문제의 범위를 빠르게 좁힐 수 있습니다.

컴파일 오류라면 현재 .cpp 파일과 그 파일이 포함한 헤더를 먼저 확인합니다. 링크 오류라면 함수나 변수의 정의가 실제로 존재하는지, 선언과 정의가 일치하는지, 필요한 목적 파일과 라이브러리가 링크 대상에 포함되어 있는지를 확인합니다.


정리

  • C++ 프로그램의 빌드 과정은 교육 목적상 전처리, 컴파일, 어셈블, 링크의 네 단계로 구분할 수 있습니다.
  • 전처리기는 헤더 포함, 매크로 확장, 조건부 컴파일과 같은 전처리 작업을 수행합니다.
  • C++ 문법과 타입의 유효성은 주로 컴파일러가 검사합니다.
  • .cpp 파일은 포함한 헤더와 함께 하나의 번역 단위가 됩니다.
  • 각 번역 단위는 기본적으로 독립적으로 컴파일됩니다.
  • 어셈블 단계에서는 기계어 코드를 담은 목적 파일이 만들어집니다.
  • 목적 파일에 다른 파일에서 정의된 함수의 최종 주소가 아직 연결되지 않았을 수 있습니다.
  • 링커는 여러 목적 파일과 라이브러리를 결합해 실행 파일을 만듭니다.
  • 함수를 호출하려면 컴파일 시점에 함수 선언을 확인할 수 있어야 합니다.
  • 함수 선언만 있고 일치하는 정의가 없다면 링크 오류가 발생합니다.
  • 여러 소스 파일에서 공유하는 선언은 일반적으로 헤더 파일에 작성합니다.
  • 컴파일 오류와 링크 오류를 구분하면 문제의 원인을 더 빠르게 찾을 수 있습니다.

이전 글: Visual Studio로 첫 C++ 프로그램 만들기

다음 글: C++ 인라인 함수

댓글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다