파일 하나로 끝나던 코드가 여러 파일로 갈라지는 순간 새로운 종류의 에러가 나타나요. 문법은 멀쩡한데 빌드가 안 되는 에러요. 이 글은 그 세계의 규칙이에요.
시작은 #include가 텍스트 복사라는 사실이에요. 컴파일러는 각 소스 파일을 독립된 단위로 처리하는데, 그 단위 안에 include된 내용이 통째로 펼쳐진 상태로 봅니다. 여기서 대부분의 규칙이 파생돼요.
에러 코드 앞글자가 단계를 말해요
빌드 실패는 두 단계 중 하나에서 나요.
- 컴파일 단계 — 형태가 틀렸다. 문법 오류, 타입 불일치
- 링크 단계 — 형태는 맞는데 실체가 없다. 선언은 봤는데 몸통을 못 찾음
이 구분만 붙어도 어디를 볼지가 정해져요. 링크 에러가 나는 흔한 이유는 셋이에요.
첫째, 몸통이 든 소스 파일을 빌드에 안 넣은 경우예요. 헤더는 include로 딸려오지만 소스는 딸려오지 않아요. 가장 흔합니다.
둘째, 소스에서 클래스 이름을 빼먹은 경우예요. 그러면 멤버 함수가 아니라 전역 함수를 새로 정의한 게 돼요.
셋째, 선언과 정의의 형태가 미묘하게 다른 경우예요. 헤더에는 const가 붙었는데 소스에는 빠진 식이죠. C++에서 const 유무는 다른 함수예요. 그래서 선언한 쪽의 몸통을 못 찾아요.
세 줄짜리 주문
헤더 첫 줄에 붙는 것들이에요.
#ifndef BASE_ROBOT_H // 이 이름이 아직 정의 안 됐다면 아래를 포함
#define BASE_ROBOT_H // 이제 정의한다 (다음번엔 위 조건이 거짓)
class BaseRobot { ... };
#endif왜 필요하냐면 include가 텍스트 복사이기 때문이에요. 어떤 파일이 헤더를 직접 include하고, 같이 include한 다른 헤더가 내부에서 또 그걸 include하면, 펼쳐진 결과에 같은 클래스가 두 번 나타나요. 가드가 있으면 두 번째는 통째로 건너뛰어요.
매크로 이름은 파일마다 달라야 해요. 두 헤더가 같은 이름을 쓰면 나중에 include된 쪽이 통째로 사라져요. 그래서 관례가 경로를 반영한 긴 이름이에요. 한 줄짜리 대체 문법도 있는데, 표준은 아니지만 주요 컴파일러가 다 지원하고 이름 충돌 사고가 원천적으로 없어요.
가드가 막는 건 한 단위 안에서의 중복뿐이라는 점도 중요해요. 두 소스가 각각 같은 헤더를 include하는 건 정상이에요. 선언은 여러 곳에 있어도 되거든요. 반면 헤더에 함수 정의를 넣으면 두 단위에 몸통이 하나씩 생겨 이미 정의되어 있다는 링크 에러가 떠요. 가드로는 못 막아요.
초기화와 대입은 겉보기만 같아요
생성자 뒤에 붙는 콜론 문법이 있어요.
Robot() : speed(0), isRunning(true) { }객체는 생성될 때 멤버들이 먼저 전부 만들어지고 그다음 생성자 본문이 실행돼요. 초기화 리스트는 만들어지는 그 순간의 값을 지정하고, 본문의 대입은 이미 만들어진 것에 덮어쓰기예요.
| 초기화 리스트 | 본문 대입 | |
|---|---|---|
| 동작 | 생성 시점에 그 값으로 | 기본 생성 후 덮어쓰기 |
| 클래스 타입 멤버 | 생성자 1회 | 기본 생성 + 대입 = 2회 |
const 멤버 | 가능 | 불가 |
| 참조 멤버 | 가능 | 불가 |
기본 타입만 있으면 취향 문제지만, const 멤버나 참조 멤버가 끼는 순간 초기화 리스트가 유일한 방법이 돼요.
참조 멤버는 흥미로운 방식으로 걸려요. “대입이 안 된다”가 아니라 “초기화를 안 했다”로 나와요. 참조는 만들어지는 순간 대상이 정해져야 하는 별명이라 본문에 들어갈 때는 이미 실패가 확정된 상태거든요.
그리고 본문에서 참조 멤버에 대입하면 참조를 다시 묶는 게 아니라 이미 가리키는 대상에 값을 복사해요. 참조를 바꿨다고 생각하고 쓰면 엉뚱한 객체를 덮어써요.
그래서 관례는 처음부터 전부 초기화 리스트로 쓰는 거예요. 나중에 멤버 타입이 바뀌어도 생성자를 안 고쳐도 되니까요.
함정이 하나 있어요. 초기화 순서는 리스트에 쓴 순서가 아니라 클래스에 선언한 순서예요. 평소엔 무해하지만 한 멤버가 다른 멤버로 자기를 초기화할 때 아직 초기화 안 된 쓰레기값을 읽어요. 선언 순서와 리스트 순서를 항상 일치시켜야 하고, 경고 옵션으로도 잡혀요.
값으로 담으면 파생 부분이 잘려요
상속에서 만나는 함정 하나만 짚을게요. 기반 클래스 타입 변수에 파생 객체를 값으로 대입하면, 기반 부분만 복사되고 파생이 추가한 멤버는 사라져요. 컴파일 에러도 경고도 없어요.
Robot r = arm; // arm이 ArmRobot이어도 Robot 부분만 남는다기반 타입 변수의 크기가 애초에 기반 부분만큼이라 그 이상을 담을 자리가 없거든요. 그래서 다형성이 필요한 컨테이너는 값이 아니라 포인터를 담아요. 그 이야기는 C++ 인터페이스와 가상 함수 — 플러그인이 로드되는 원리에 있어요.
템플릿만 헤더에 정의를 둬요
앞에서 헤더에 정의를 넣으면 안 된다고 했는데, 유일한 예외가 템플릿이에요.
이유는 템플릿이 코드가 아니라 코드를 찍어내는 틀이기 때문이에요. 컴파일러는 실제로 쓰인 타입을 보고 그때 코드를 생성해요. 그런데 어떤 타입으로 쓰였는지는 사용하는 쪽 파일에서만 알 수 있어요.
소스에 정의를 두면 그 파일을 컴파일할 때 컴파일러는 “누가 어떤 타입으로 쓸지” 모르니 아무것도 못 만들어요. 그래서 정의가 헤더에 있어야 하고, 쓰는 쪽에서 함께 펼쳐져야 그 자리에서 찍어낼 수 있어요.
증상은 이렇게 나타나요. 템플릿 정의를 소스에 넣고 다른 파일에서 쓰면 컴파일은 통과하는데 링크에서 몸통을 못 찾는다고 해요. 문법이 아니라 구조 문제라 코드를 아무리 봐도 안 보여요.
헤더에 정의를 둬도 이미 정의되어 있다는 에러가 안 나는 것도 특별 규칙이에요. 여러 단위에서 같은 템플릿이 만들어지면 링커가 하나만 남기고 나머지를 버려요. 일반 함수에는 없는 예우예요.
정리하면
파일이 갈라지면서 생기는 규칙은 몇 개 안 돼요. 헤더에는 선언, 소스에는 정의, 템플릿만 예외. 가드는 한 단위 안의 중복만 막고, 여러 단위 사이의 중복은 정의를 헤더에 넣지 않는 것으로 막아요.
그리고 빌드가 실패하면 에러 코드 앞글자부터 봐요. 컴파일이면 형태가 틀린 것이고, 링크면 형태는 맞는데 실체가 없는 것이니 볼 곳이 완전히 달라요.
빌드 도구 쪽에서 같은 종류의 조용한 누락이 어떻게 생기는지는 C++ 도구와 디버깅 — 경고, sanitizer, gdb에 있어요.