icon
1장 : C++ 소개와 개발 환경

컴파일과 실행 과정

이전 장에서 첫 C++ 프로그램을 성공적으로 만들고 실행하셨을 것입니다.

단순히 코드를 작성하고 실행하는 것을 넘어 우리가 작성한 소스 코드가 컴퓨터에서 실제로 동작하는 실행 파일이 되기까지 어떤 과정을 거치는지 이해하는 것은 C++ 프로그래밍을 깊이 있게 이해하는 데 매우 중요합니다.

이 장에서는 C++ 소스 코드가 프로그램으로 변환되고 실행되는 전반적인 과정, 즉 컴파일(Compilation)과 링크(Linking) 과정에 대해 상세히 알아보겠습니다.


왜 컴파일이 필요한가?

우리가 작성하는 C++ 코드는 사람이 이해하기 쉬운 고급 언어(High-level Language)입니다.

하지만 컴퓨터의 중앙처리장치(CPU)는 0과 1로 이루어진 기계어(Machine Code)만을 직접 이해하고 실행할 수 있습니다.

따라서 우리가 작성한 C++ 코드를 컴퓨터가 이해할 수 있는 기계어로 변환해 주는 과정이 필수적으로 필요합니다.

이 역할을 수행하는 것이 바로 컴파일러(Compiler) 입니다.

컴파일러는 C++ 소스 코드를 읽어 들여 문법 오류를 검사하고, 오류가 없다면 이를 기계어로 번역하여 목적 파일(Object File)을 생성합니다.

목적 파일은 아직 완전한 실행 파일이 아니며, 여러 조각의 기계어 코드와 참조 정보들을 담고 있습니다.


컴파일 과정의 단계별 이해

C++ 컴파일 과정은 크게 네 가지 단계로 나누어 볼 수 있습니다.

여러분이 g++ hello_world.cpp -o hello_world와 같은 명령어를 입력할 때, 내부적으로 이 모든 과정이 순차적으로 진행됩니다.

  1. 전처리 (Preprocessing)

    • 역할: 소스 코드를 컴파일러가 처리하기 전에 미리 처리하는 단계입니다. #으로 시작하는 전처리기 지시자(Preprocessor Directive)를 처리합니다.
    • 주요 작업:
      • #include: 지정된 헤더 파일(*.h, *.hpp 또는 확장자가 없는 표준 라이브러리 파일)의 내용을 현재 소스 코드에 삽입합니다. 예를 들어, <iostream>을 포함하면 iostream 헤더 파일의 내용이 우리 코드에 실제로 복사됩니다.
      • #define: 매크로를 정의하고 치환합니다. 예를 들어 #define PI 3.141592가 있다면, 코드 내의 모든 PI3.141592로 변경합니다.
      • 주석 제거: ///* ... */로 작성된 모든 주석을 제거합니다. 주석은 컴파일러가 처리할 필요가 없기 때문입니다.
    • 결과물: 전처리된 소스 코드 파일(일반적으로 .i 또는 .ii 확장자를 가집니다). 이 파일은 아직 C++ 언어 형태를 유지하고 있습니다.

    예시 명령어 (전처리까지만 수행)

    g++ -E hello_world.cpp -o hello_world.i
  2. 컴파일 (Compilation)

    • 역할: 전처리된 소스 코드를 어셈블리어(Assembly Language)로 번역합니다. 어셈블리어는 기계어와 1:1로 대응되는 저수준 언어이지만, 사람이 읽고 이해할 수 있는 형태를 가집니다.
    • 주요 작업: C++ 문법에 오류가 없는지 검사하고, 코드를 어셈블리어 명령어로 변환합니다.
    • 결과물: 어셈블리 코드 파일(일반적으로 .s 확장자를 가집니다).

    예시 명령어 (컴파일까지만 수행)

    g++ -S hello_world.i -o hello_world.s

    또는 (전처리부터 컴파일까지 한 번에)

    g++ -S hello_world.cpp -o hello_world.s
  3. 어셈블 (Assembly)

    • 역할: 어셈블리 코드를 기계어 명령어로 직접 번역하여 목적 파일(Object File)을 생성합니다. 목적 파일은 CPU가 이해할 수 있는 이진 형태의 코드이지만, 아직 완전한 실행 파일은 아닙니다. 다른 목적 파일이나 라이브러리와 연결되어야 합니다.
    • 주요 작업: 어셈블리 코드를 컴퓨터가 직접 실행할 수 있는 바이너리 형식으로 변환합니다.
    • 결과물: 목적 파일(일반적으로 .o 확장자를 가집니다. Windows에서는 .obj).

    예시 명령어 (어셈블까지만 수행)

    g++ -c hello_world.s -o hello_world.o

    또는 (전처리, 컴파일, 어셈블까지 한 번에)

    g++ -c hello_world.cpp -o hello_world.o
  4. 링크 (Linking)

    • 역할: 하나 이상의 목적 파일과 필요한 라이브러리(표준 라이브러리, 외부 라이브러리 등)를 하나로 묶어 최종 실행 가능한 프로그램(Executable File)을 생성합니다.
    • 주요 작업:
      • 목적 파일들 간의 참조를 해결합니다. 예를 들어, main 함수에서 std::cout을 호출할 때, std::cout의 실제 구현이 어디에 있는지 찾아 연결합니다.
      • 프로그램에서 사용하는 표준 라이브러리 함수(예: std::cout, std::endl)와 같은 외부 코드들을 연결합니다.
      • 이 과정에서 필요한 모든 코드가 연결되지 않거나, 중복되는 정의가 있을 경우 링크 오류가 발생합니다.
    • 결과물: 최종 실행 파일(Windows에서는 .exe, macOS/Linux에서는 확장자 없음).

    예시 명령어 (링크까지만 수행)

    g++ hello_world.o -o hello_world

    이전에 우리가 사용했던 g++ hello_world.cpp -o hello_world 명령어는 위에서 설명한 네 가지 과정을 모두 포함하고 있습니다. 컴파일러 드라이버(compiler driver)가 내부적으로 이 모든 단계를 자동으로 처리해 주는 것입니다.


정적 링크와 동적 링크

링크 과정에서 라이브러리를 연결하는 방식은 크게 두 가지로 나눌 수 있습니다.

  • 정적 링크 (Static Linking)

    • 라이브러리 코드가 최종 실행 파일 안에 직접 포함됩니다.
    • 장점: 실행 파일 하나만 배포하면 되므로, 다른 컴퓨터에서 실행할 때 라이브러리 유무를 신경 쓸 필요가 없습니다. 성능상 약간의 이점이 있을 수 있습니다.
    • 단점: 실행 파일의 크기가 커집니다. 동일한 라이브러리를 사용하는 여러 프로그램이 있더라도 각각의 실행 파일에 라이브러리 코드가 중복되어 포함됩니다. 라이브러리가 업데이트되면 실행 파일도 다시 컴파일 및 링크해야 합니다.
  • 동적 링크 (Dynamic Linking)

    • 라이브러리 코드가 실행 파일에 직접 포함되지 않고, 실행 시점에 운영체제에 의해 메모리에 로드됩니다. 실행 파일에는 라이브러리의 위치 정보만 포함됩니다.
    • 장점: 실행 파일의 크기가 작습니다. 동일한 라이브러리를 여러 프로그램이 공유할 수 있어 메모리 효율적입니다. 라이브러리가 업데이트되어도 실행 파일을 다시 컴파일할 필요 없이, 업데이트된 라이브러리를 교체하는 것만으로 적용됩니다.
    • 단점: 실행 파일이 동작하려면 해당 라이브러리 파일(Windows의 .dll, macOS의 .dylib, Linux의 .so)이 시스템에 존재해야 합니다. 라이브러리 버전에 따라 호환성 문제가 발생할 수도 있습니다 (DLL Hell).

대부분의 현대 운영체제에서는 동적 링크를 기본으로 사용합니다. 이는 디스크 공간과 메모리 효율성, 그리고 라이브러리 업데이트의 용이성 때문입니다.


오류의 종류: 컴파일 오류 vs. 런타임 오류

프로그래밍을 하다 보면 필연적으로 오류(Error)를 만나게 됩니다.

오류는 크게 두 가지 주요 범주로 나눌 수 있습니다.

  1. 컴파일 오류 (Compile-time Error)

    • 발생 시점: 컴파일러가 소스 코드를 기계어로 번역하는 과정에서 발생합니다.
    • 원인: 주로 C++ 언어의 문법 규칙을 위반했거나, 오타, 선언되지 않은 변수 사용 등 컴파일러가 코드를 이해할 수 없을 때 발생합니다.
    • 특징: 컴파일러가 오류 메시지를 통해 어떤 파일의 몇 번째 줄에서 어떤 종류의 오류가 발생했는지 상세하게 알려줍니다. 컴파일 오류를 모두 해결해야만 실행 파일이 생성됩니다.
    • 예시: 세미콜론(;) 누락, 오타(std::coutstd::coutt으로 작성), 변수 선언 없이 사용 등.
  2. 런타임 오류 (Run-time Error)

    • 발생 시점: 프로그램이 성공적으로 컴파일되어 실행된 후에 발생합니다.
    • 원인: 주로 프로그램의 논리적인 오류, 잘못된 데이터 처리, 메모리 접근 오류, 0으로 나누기, 존재하지 않는 파일 열기 등 프로그램이 예상치 못한 상황에 직면했을 때 발생합니다.
    • 특징: 컴파일 시점에는 아무런 문제가 없었기 때문에 컴파일러가 잡아낼 수 없습니다. 프로그램 실행 도중 갑자기 비정상적으로 종료되거나, 예상과 다른 결과가 나오거나, 시스템 오류 메시지를 발생시킵니다.
    • 예시: 배열의 범위를 벗어난 접근, 동적 할당된 메모리를 해제하지 않아 발생하는 메모리 누수, 잘못된 포인터 사용 등.

컴파일 오류는 컴파일러의 친절한 메시지를 통해 비교적 쉽게 수정할 수 있지만, 런타임 오류는 프로그램의 동작을 분석하고 디버깅(Debugging) 도구를 사용하여 원인을 찾아내야 하므로 해결하기가 더 까다로울 수 있습니다.