1. 분리 컴파일과 번역 단위
학습할 때는 파일 하나로 충분합니다: 전환이 적고, 복사하기 쉽고, 과제를 제출하기도 간편합니다. 하지만 프로그램이 하루 이상 살아남기 시작하면 전형적인 상황이 생깁니다: main.cpp가 마치 "다운로드" 폴더처럼 커집니다. 한 곳에는 입력 처리, 다른 곳에는 계산, 옆에는 모델 출력이 얽혀 있어 불필요한 공백 하나도 건드리기 두렵게 됩니다.
다중 파일 프로젝트는 의미에 따라 코드를 나눌 수 있게 합니다: "로직" 따로, "입출력" 따로, "데이터 모델" 따로. 중요한 점: 우리는 코드를 컴파일러용이 아니라 사람을 위해 분리한다. 솔직히 컴파일러는 거대한 파일도 잘 처리하지만, 그 안에서 당신이 유지보수하기는 어렵습니다.
오늘 주제부터 우리는 교육용 프로젝트를 점차 더 성숙한 형태로 정리할 것입니다. 작은 콘솔 애플리케이션 "할 일 목록"(todo)이 있다고 가정해 봅시다: 작업을 std::vector<std::string>에 저장하고 목록을 출력하며 새 작업을 추가할 수 있습니다. 지금까지는 이 모든 것이 하나의 main.cpp에 있었습니다. 오늘은 일부 함수를 별도의 .cpp로 옮겨 보며 강의의 핵심 아이디어를 잡아보겠습니다.
컴파일러는 각 .cpp를 별도로 컴파일합니다
오늘의 핵심은 거의 우스울 정도로 단순합니다: 컴파일러는 각 .cpp를 별도로 컴파일합니다. 기본적으로 컴파일러는 "프로젝트 전체"를 한 덩어리로 열지 않습니다. 하나의 .cpp를 골라 처리하고, 중간 결과(단순화하면 "오브젝트 파일")를 만들고, 다음 .cpp를 처리합니다.
왜 이렇게 했을까요? 큰 프로젝트에서 편하고 빠르기 때문입니다. 한 파일의 함수 하나를 수정했다면 모든 것을 다시 컴파일하는 것은 이상합니다. 분리 컴파일은 변경된 부분만 다시 빌드할 수 있게 해 줍니다.
하지만 이 모델에는 직접적인 귀결이 있어, 초보자들이 파일로 코드를 분리할 때 처음에 자주 걸려 넘어집니다: 한 .cpp의 코드가 자동으로 다른 .cpp에서 보이지 않습니다. 즉, "옆 파일에 함수가 있다"는 사실만으로는 충분하지 않습니다. 호출하는 곳에서 적어도 그 함수의 선언을 컴파일러가 봐야 합니다.
두 강의실에 두 개의 시험지가 있다고 상상해 보세요. 강의실 №1에는 훌륭한 공식을 적어두었습니다. 하지만 강의실 №2에서는 다른 시험지를 보고 있는 채점자가 첫 강의실에서 당신이 뭘 적었는지 모릅니다. 서로 다른 .cpp 파일은 대략 그런 식으로 동작합니다: 각각 자신만의 삶을 삽니다.
번역 단위란 무엇인가
이제 핵심 용어를 도입합니다: 번역 단위 (translation unit).
컴파일러가 "파일 main.cpp를 컴파일한다"고 할 때, 실제로는 main.cpp의 텍스트뿐만 아니라 #include로 들어온 모든 내용도 함께 컴파일합니다. #include는 단순히 다른 파일의 텍스트를 여기에 "붙여넣는" 아주 단순하지만 유용한 연산으로 생각할 수 있습니다. 따라서 번역 단위는 다음과 같습니다:
현재의 .cpp + 그 안에 포함된(직접 또는 간접적으로) 모든 헤더의 텍스트
따라서 만약 main.cpp에서 어떤 함수나 타입의 선언을 보이게 하고 싶다면, 그 선언은 main.cpp의 번역 단위 안에 있어야 합니다. 가장 흔한 방법은 헤더(.hpp)를 포함하는 것입니다만, 오늘은 우선 원칙 자체를 확립하겠습니다.
C++ 표준에는 소스 코드를 "번역"하는 공식 단계들이 있으며, 그중 전처리기가 끝난 뒤 컴파일러가 번역 단위로 처리하는 결과가 형성되는 단계가 있습니다. 표준의 작업 문서에서는 이 과정을 변환 단계와 연결하여 translation unit 개념이 바로 이 단계와 관련 있음을 강조합니다.
유용한 미니 다이어그램(아주 단순화했지만 머릿속 정리에 충분함):
flowchart TD
A[main.cpp + #include ...] --> B[프리프로세서: 헤더 텍스트 삽입]
B --> C[번역 단위]
C --> D[컴파일: 문법/타입 검사, 오브젝트 코드 생성]
D --> E[... 그런 다음 모든 부분을 프로그램으로 "링크"함]
오늘 우리가 중요하게 생각해야 할 것은 바로 "번역 단위"라는 경계입니다: 이는 한 파일을 컴파일하는 단계에서의 가시성의 경계입니다.
3. 실습: 왜 .cpp들이 서로를 '보지' 못하는가
최소 예제: 함수는 존재하지만 컴파일러가 모르는 경우
문제를 보여 주는 가장 최소한의 예제를 만들어 봅시다.
옵션 A: "될 것 같은데"… 그러나 안 됨
math.cpp:
int add(int a, int b) {
return a + b;
}
main.cpp:
#include <iostream>
int main() {
std::cout << add(2, 3) << '\n'; // 5를 원함
}
초보자의 논리는 이해됩니다: "함수가 있으니 옆 파일에 있으니까 된다". 그러나 컴파일러는 main.cpp를 별도로 컴파일합니다. main.cpp의 번역 단위에는 이름 add가 없습니다. 따라서 오류는 대략 다음과 같을 것입니다(컴파일러/IDE에 따라 문구는 다릅니다): "error: 'add' was not declared in this scope".
즉: "나는 add가 무엇인지 모른다"는 뜻입니다.
옵션 B: 선언을 추가하면 컴파일러가 만족함
이제 선언을 main.cpp에 한 줄 추가해 봅시다(몸체 없이):
main.cpp:
#include <iostream>
int add(int a, int b); // 선언: "이러한 함수가 존재함"
int main() {
std::cout << add(2, 3) << '\n'; // 5
}
math.cpp는 변함없음:
int add(int a, int b) {
return a + b;
}
이제 main.cpp를 컴파일할 때 컴파일러는 add(int, int)의 시그니처를 알기 때문에 호출의 올바름을 검사할 수 있습니다. 이 단계에서는 함수의 몸체를 볼 필요가 없습니다 — 선언만으로 충분합니다.
여기서 우리가 앞으로 발전시킬 규율이 나옵니다: 호출은 한 .cpp에, 구현은 다른 .cpp에, 그리고 선언은 호출하는 곳에서 접근 가능해야 한다.
옵션 C: 왜 "옆 .cpp를 #include로 그냥 붙이면 안 되는가
어떤 학생은 이렇게 생각합니다: "#include가 텍스트를 붙이니까 #include "math.cpp"로 하면 되지 않을까?". 그것은 실제로 컴파일러가 코드를 "보게" 만들지만, 분리 컴파일 모델을 망가뜨립니다. 왜 안 되는지 아래에서 다루겠지만 우선 한 가지 규칙을 확정합시다: .cpp 파일은 별도로 컴파일되도록 설계된 것이지 다른 파일에 "붙여넣는" 용도가 아닙니다.
미니 예제: 할 일 출력 기능을 별도 파일로 분리하기
조금 더 현실적인 예제로 해 봅시다. Todo 애플리케이션에서 목록 출력과 새 항목 추가 기능을 분리해 보겠습니다. 이전에는 모든 것이 main.cpp에 있었지만 이제 목록 출력만 todo_print.cpp로 옮깁니다.
1단계: main.cpp에서 출력 함수를 호출
main.cpp:
#include <iostream>
#include <string>
#include <vector>
void print_tasks(const std::vector<std::string>& tasks); // 선언
int main() {
std::vector<std::string> tasks = {"Buy milk", "Learn C++"};
print_tasks(tasks);
std::cout << "Done!\n"; // Done!
}
스타일에 주목하세요: 선언을 먼저 적고 사용합니다. 이렇게 하면 main.cpp가 독립적으로 컴파일될 수 있습니다.
2단계: 구현을 todo_print.cpp에 둡니다
todo_print.cpp:
#include <iostream>
#include <string>
#include <vector>
void print_tasks(const std::vector<std::string>& tasks) {
for (std::size_t i = 0; i < tasks.size(); ++i) {
std::cout << (i + 1) << ") " << tasks[i] << '\n';
}
}
이 코드는 단순하지만 중요한 점을 보여 줍니다: 각 .cpp는 자신이 필요로 하는 것을 포함(include)해야 합니다. todo_print.cpp는 std::vector, std::string, std::cout를 사용하므로 <vector>, <string>, <iostream>를 포함해야 합니다. main.cpp에 이미 포함되어 있으니 괜찮을 것이라고 기대하면 안 됩니다.
만약 #include <vector>를 깜빡하면, 컴파일러가 todo_print.cpp를 처리할 때 프로젝트 어딘가에 <vector>가 포함되어 있었다고 "기억"할 의무가 없습니다. 각 파일은 별도의 번역 단위이기 때문입니다.
정리: 머릿속에 남은 개념
지금은 "임시" 스타일(선언을 main.cpp에 직접 적는 방식)을 사용하고 있습니다. 이는 처음 개념을 이해하기에는 괜찮습니다. 다음 강의에서는 선언을 헤더(.hpp)로 옮겨서 중복을 피하고 올바르게 구성하는 방법을 배울 것입니다.
지금 가장 중요한 것은 핵심 메커니즘을 느끼는 것입니다: main.cpp와 todo_print.cpp는 서로 다른 세계로 컴파일되며, 두 세계 사이에서 정보를 주고받는 유일한 방법은 필요한 선언들이 양쪽 번역 단위에 포함되도록 하는 것입니다.
4. 왜 #include "something.cpp"을 하면 안 되는가와 '이름을 찾을 수 없음' 오류 고치기
왜 #include "something.cpp"는 나쁜 아이디어인가
많은 초보자가 빠른 해결책을 찾습니다: 만약 main.cpp가 math.cpp의 함수를 보지 못하면, 그냥 math.cpp를 #include로 집어넣자고 합니다. 형식적으로는 동작할 수 있습니다: math.cpp의 텍스트가 main.cpp의 번역 단위에 들어가고 컴파일러가 정의를 보게 되어 일시적으로 문제는 해결됩니다.
문제는 그렇게 하면 프로젝트가 "조각을 붙여 만든 한 덩어리 파일"로 바뀐다는 것입니다. 그리고 이는 여러 면에서 해를 끼칩니다.
첫째, 분리 컴파일의 이점을 잃습니다: "붙여넣은 .cpp"를 수정하면 그것을 포함하는 모든 파일을 다시 컴파일해야 합니다. 둘째, 중복의 위험이 생깁니다: 서로 다른 두 .cpp가 같은 "붙여넣은 .cpp"를 포함하면 링크 단계에서 충돌이 발생할 수 있습니다(세부 사항은 여기서 다루지 않습니다만 결과는 고통스럽습니다).
정답은 아키텍처적 관점에서 간단합니다: .cpp는 별도로 컴파일되고, 공통 선언은 포함 가능한 헤더에 둡니다. 헤더의 구조를 오늘 자세히 다루지는 않지만 "에러를 피하려면 .cpp를 포함하지 마라"는 규칙은 초보자가 기억해 둘 만한 안전 수칙입니다.
컴파일러가 '이름을 찾을 수 없음'이라고 할 때 어떻게 생각할 것인가
코드를 파일로 나누기 시작하면 "선언되지 않음" 유형의 오류가 더 자주 발생합니다. 이는 정상입니다: 프로젝트 모델을 바꾸고 있고 뇌가 아직 적응하지 못한 것입니다.
컴파일러가 다음과 같이 말할 때:
- was not declared in this scope
- use of undeclared identifier
- "식별자 ...를 찾을 수 없음"
대부분의 경우 두 가지 상황 중 하나입니다.
첫째 상황 — 같은 파일 내에서 사용보다 선언을 빼먹은 경우입니다. 이는 함수 시대로부터 익숙한 고전적인 실수입니다: main에서 foo()를 호출했는데 foo의 선언이 아래쪽에 있어 컴파일러가 보지 못하는 경우입니다.
둘째 상황 — 오늘의 주제와 관련된 경우: 이름은 프로젝트 어딘가에 존재하지만 현재 .cpp의 번역 단위에는 들어오지 않았습니다. 예를 들어 다른 .cpp에 선언이 있더라도, 컴파일러는 현재 파일을 처리할 때 옆 파일을 읽지 않습니다. 즉 현재 파일(또는 포함한 헤더)에 그 선언이 있어야 합니다.
간단한 점검용 미니 표(무리하지 말고 가이드로만 쓰세요):
| 선언/정의는 어디에 있는가? | main.cpp 컴파일 시 컴파일러가 보는가? | 이유 |
|---|---|---|
| main.cpp 자체의 위쪽 | 예 | 같은 번역 단위이기 때문 |
| .hpp 헤더에 있고 main.cpp에서 포함되었는가? | 예 | #include가 번역 단위에 텍스트를 삽입했기 때문 |
| 다른 .cpp에 있는가? | 아니오 | 다른 .cpp는 다른 번역 단위이기 때문 |
또 하나의 실무적 사고 트릭: 오류가 발생하면 항상 스스로에게 물어보세요: "컴파일러는 지금 어떤 파일을 처리하고 있는가?" 오류 메시지는 거의 항상 파일과 줄 번호를 가리킵니다. 이는 중요합니다. 그 힌트는 "이 번역 단위에 필요한 이름이 없다"는 것을 알려 줍니다.
5. 흔한 실수들
실수 #1: 프로젝트에 함수가 있으면 어디서든 보일 것이라고 기대하기.
이것은 "한 파일" 모델에서의 직관입니다: 그 모델에서는 모든 것이 한 텍스트 안에서 보입니다. 다중 파일 모델에서는 더 이상 성립하지 않습니다. 각 .cpp는 별도로 컴파일되므로, 사용하는 곳에서는 해당 이름의 선언이 있어야 합니다(보통은 헤더를 통해).
실수 #2: 선언을 여러 곳에 수작업으로 복사해 넣고 실수로 서로 다르게 만드는 것.
초보자들은 시그니처를 손으로 복사합니다: 한 파일에는 int add(int, int);가 있고, 다른 파일에는 갑자기 long add(int, int);가 있거나 const가 붙는 등 차이가 생길 수 있습니다. 컴파일러는 이상한 오류를 내고 여러분은 "거의 동일한데 왜"라고 생각하게 됩니다. 올바른 습관은 선언을 하나의 장소(헤더)에 두고 다른 파일들은 그것을 포함하게 하는 것입니다.
실수 #3: "더 빠르니"라는 이유로 .cpp를 #include하는 것.
작을 때는 요령처럼 보이지만, 프로젝트가 커지면 빌드 시간이 길어지고 충돌을 유발하며 프로젝트 구조를 난해하게 만듭니다. 규율은 간단합니다: 선언은 헤더에, .cpp는 별도로 컴파일합니다.
실수 #4: 각 .cpp가 사용하는 타입에 대한 #include를 자신의 파일에 넣는 것을 잊는 것.
종종 함수가 utils.cpp로 옮겨졌는데 그 함수가 std::string을 사용하면 컴파일러가 갑자기 std::string을 모른다고 합니다. "하지만 main.cpp에는 <string>이 있는데!"라고 생각할 수 있지만, utils.cpp는 별도의 번역 단위입니다. 그 파일에서 타입을 사용한다면 해당 표준 헤더를 그 파일에서 포함해야 합니다.
실수 #5: 공황 상태에서 #include <bits/stdc++.h>나 불필요한 수십 개의 헤더를 넣어 문제를 무차별적으로 덮는 것.
임시방편으로 인클루드를 잔뜩 넣어 문제를 잠깐 덮을 수 있지만, 이는 누수 보수의 왕관을 얹는 것과 같습니다. 더 유익한 접근은 어떤 이름이 누락되었는지, 어떤 .cpp에서 사용되는지, 그리고 그 번역 단위에 어떤 선언이 들어가야 하는지를 파악하는 것입니다. 그러면 정확히 필요한 것을 추가하게 되어 코드 관리가 쉬워집니다.
GO TO FULL VERSION