1. 순환 의존성이란
보통 순환 의존성은 처음날, 파일이 하나인 main.cpp와 몇 개의 cout만 있을 때보단, 코드를 파일 단위로 정리하고 여러 타입으로 데이터를 모델링하기 시작할 때 나타납니다.
순환은 A.hpp 헤더가 B.hpp를 포함하고, B.hpp가 A.hpp를 포함하는 상황(직접이거나 체인을 통해)이 있을 때입니다. 형식적으로는 사이클이 있는 방향성 종속 그래프입니다. 실제로는 컴파일러가 갑자기 incomplete type, unknown type name 또는 field has incomplete type 같은 이상한 문구를 출력하는 상황입니다.
다이어그램에서 어떻게 보이는가
flowchart TD
A[A.hpp] --> B[B.hpp]
B --> A
왜 include guards가 구해주지 못하는가
초보자가 자주 하는 실수는 이렇게 생각하는 것입니다: “두 파일 모두에 #pragma once가 있으니 사이클이 없어질 거야”. 아닙니다. #pragma once와 include guards는 다른 문제를 해결합니다: 하나의 번역 단위에 같은 파일이 중복 포함되는 것을 막습니다. 즉 파일의 무한한 '삽입'을 방지합니다.
하지만 그것들이 불완전한 타입을 완전하게 만들지는 못합니다. #include는 본질적으로 "여기에 파일 내용을 붙여넣는다"처럼 동작하고, #pragma once는 반복 삽입을 멈출 뿐입니다. 컴파일러가 특정 라인에서 완전한 타입(예: 객체의 크기를 알기 위해)이 필요할 때, 어떤 include 보호도 그 크기를 "추정"해주지 않습니다.
전형적인 결과는 다음과 같은 오류입니다: 필드가 불완전한 타입이라는 메시지. 컴파일러가 다음 라인까지 도달했기 때문입니다: Project project;, 그러나 Project는 아직 정의되지 않았습니다.
컴파일 오류로 사이클을 어떻게 인식할까
순환 의존성에서 발생하는 오류가 항상 직접적으로 “사이클이 있습니다”라고 말하지는 않습니다. 그 오류들은 종종 헤더를 빠뜨렸거나 타입 이름을 잘못 썼다는 식으로 보입니다. 따라서 이러한 오류의 “전형적인 모습”을 아는 것이 유용합니다.
아래는 작은 힌트표입니다: 컴파일러 메시지에서 무엇을 찾아야 하고 보통 어디서 고치는지에 대한 안내입니다.
| 컴파일러 메시지(대략적 의미) | 보통 의미하는 바 | 원인 찾을 위치 |
|---|---|---|
|
이 위치에서 타입 X를 볼 수 없음 | 올바른 #include 또는 올바른 forward declaration이 필요한 namespace |
|
타입 X는 선언은 되어 있지만 정의되지 않았고, 당신은 그것을 값으로 저장하려 함 | 헤더 사이클이 있거나 include가 부족함 |
|
X의 필드나 메서드에 접근하려 했으나 X가 불완전함 | 구현에서 정의를 포함하는 #include가 필요함 (.cpp) |
|
트랜지티브(전이적) 의존성이 있거나 사이클이 있음 | 헤더가 자급자족하지 못하거나 사이클이 존재함 |
강한 사이클의 신호는 “논리적으로 모두 포함되어 있는 것 같은데도 오류가 계속해서 필드에서 타입이 불완전하다고 말할 때” 입니다.
2. 예제: Task와 Project가 빠르게 사이클을 만든다
추상적으로 논의만 하는 대신, 학습용 CLI 프로젝트를 계속하겠습니다. 미니 태스크 스케줄러를 가정합니다: Task(작업)와 Project(프로젝트)가 있습니다.
자연스러운(그리고 매우 인간적인) 욕구는 작업이 자신의 프로젝트를 알고, 프로젝트가 자신의 작업들을 아는 것입니다. 만약 지금 “그게 당연하지”라고 생각했다면 축하합니다: 당신은 개발자처럼 생각합니다… 하지만 5분 뒤에는 순환 #include를 보게 될 가능성이 큽니다.
문제를 일으키는, 자연스럽게 보이는 버전부터 시작해봅시다.
task.hpp (직관적이지만 문제 있음)
// task.hpp
#pragma once
#include "project.hpp"
struct Task {
int id{};
Project project; // 객체를 값으로 저장하려 함
};
project.hpp (직관적이지만 문제 있음)
// project.hpp
#pragma once
#include <vector>
#include "task.hpp"
struct Project {
int id{};
std::vector<Task> tasks; // Task를 값으로 저장하려 함
};
의미적으로는 괜찮습니다: 프로젝트는 작업을 가지고, 작업은 프로젝트를 가집니다. 그러나 C++ 관점에서는 "필요 조건의 닫힌 루프"를 만들었습니다: Task를 정의하려면 완전한 Project가 필요하고, Project를 정의하려면 완전한 Task가 필요합니다. 컴파일러는 누가 먼저인지 추측할 수 없습니다.
3. 사이클을 끊는 방법
사이클을 끊는 것은 실제로 필요하지 않은 곳에서 '완전 타입'을 요구하는 것을 멈추고, 헤더를 가능한 가볍게 유지하는 것입니다. 아래는 세 가지 흔히 쓰이는 전략입니다.
방법 1: forward declaration + 포인터 또는 레퍼런스
가장 직접적인 방법은 헤더에서 완전 타입을 요구하지 않도록 하는 것입니다. 값으로 객체를 저장할 때 완전 타입이 필요합니다 (Project project;). 하지만 포인터 (Project* project;) 또는 레퍼런스 (Project& project;) 를 사용하면 헤더에서는 타입의 존재만 알면 충분합니다. 즉 forward declaration으로 충분합니다.
핵심은 간단합니다: 포인터는 "어딘가에 프로젝트가 있고, 작업은 그 주소를 가진다"라고 말합니다. 이것은 반드시 소유권을 의미하지 않습니다. 단순한 연관 관계입니다.
task.hpp (사이클을 끊음)
// task.hpp
#pragma once
struct Project; // forward declaration
struct Task {
int id{};
Project* project{}; // OK: 불완전한 타입에 대한 포인터
};
이제 task.hpp는 더 이상 project.hpp를 포함하지 않습니다. 사이클은 이미 깨졌습니다.
project.hpp (여전히 task.hpp를 포함할 수 있음)
// project.hpp
#pragma once
#include <vector>
#include "task.hpp"
struct Project {
int id{};
std::vector<Task> tasks; // OK: Task는 여기서 완전함, task.hpp를 포함했기 때문
};
그렇다면 project->...에 접근하는 코드는 어디서 작성할까요? 완전한 정의가 있는 곳, 즉 .cpp 파일에서 작업합니다.
task.cpp (Project의 필드에 접근해야 할 때)
// task.cpp
#include "task.hpp"
#include "project.hpp"
int get_project_id(const Task& t) {
return t.project ? t.project->id : -1;
}
주의할 점: .cpp에서는 더 많은 #include를 허용할 수 있습니다. 이는 헤더의 공용 인터페이스를 부풀리지 않기 때문입니다. 헤더에서는 의존성을 최소화하고, .cpp에서는 구현을 컴파일할 수 있도록 필요한 것을 포함합니다.
방법 2: #include를 .hpp에서 .cpp로 옮기기
사이클은 종종 타입들이 실제로 서로 소유 관계에 있지 않아서가 아니라, 우리가 '혹시 모르니'라는 이유로 불필요하게 포함했기 때문에 발생합니다. 예를 들어 헤더에 선언된 함수가 const Project&를 인자로 받는데, 실제로는 forward declaration으로 충분한 경우에도 project.hpp를 포함해 버리는 식입니다.
아이디어는 간단합니다: 헤더는 선언을 위해 최소한만 포함하고, 구현에 필요한 모든 것은 .cpp로 옮깁니다.
예를 들어 작업을 출력하는 함수가 있고 실수로 project.hpp를 task.hpp에서 끌어온 경우를 보겠습니다.
나쁜 버전 (헤더에 불필요한 의존성)
// task.hpp
#pragma once
#include "project.hpp"
struct Task {
int id{};
Project* project{};
};
void print_task(const Task& t);
개선된 버전 (선언은 가볍게, 구현은 무겁게)
// task.hpp
#pragma once
struct Project; // 포인터만 있으므로 충분
struct Task {
int id{};
Project* project{};
};
void print_task(const Task& t);
// task.cpp
#include "task.hpp"
#include "project.hpp"
#include <iostream>
void print_task(const Task& t) {
std::cout << "Task #" << t.id << "\n";
}
논리는 다음과 같습니다: 헤더는 구현 세부를 공개할 필요가 없으면 Project를 완전히 알 필요가 없습니다. 반면에 .cpp는 함수 본문에 필요한 모든 것을 포함해야 합니다.
방법 3: 모델을 바꾼다 — 객체 대신 식별자를 저장
때로는 포인터나 레퍼런스가 모델 수준에서 원하지 않는 경우가 있습니다. 예를 들어 작업이 프로젝트를 참조하되, 프로젝트가 삭제되었을 때의 처리나 객체 수명에 대해 고민하고 싶지 않을 수 있습니다.
간단하고 현실적인 대안은 객체를 저장하는 대신 식별자를 저장하는 것입니다.
작은 CLI 프로젝트에서는 이것이 가장 안정적인 해결책인 경우가 많습니다: 결합도가 낮고 include도 줄며 빌드 문제도 줄어듭니다.
task.hpp (project를 id로 참조)
// task.hpp
#pragma once
struct Task {
int id{};
int project_id{}; // 숫자로 관계를 저장, include 불필요
};
project.hpp
// project.hpp
#pragma once
struct Project {
int id{};
};
이제 헤더들을 서로 포함할 이유가 거의 없습니다. 연결은 논리 레벨에서 이루어집니다: 예를 들어 저장소(가령 storage.cpp)에서 project_id로 프로젝트를 찾습니다. 물론 조회 단계가 하나 늘어나지만, 모듈 결합도가 크게 줄고 순환 포함 문제를 거의 제거할 수 있습니다.
4. 미니 리팩터링: 우연히 사이클이 생기기 어려운 구조 만들기
헤더를 리팩터링할 때 좋은 습관은 의존성이 한 방향으로 흐르도록 구조를 만드는 것입니다: 모델 → 로직 → UI/출력. 대규모 아키텍처를 아직 만들지 않더라도 이 습관은 프로젝트를 규율합니다.
모델을 분리하고 출력 코드를 따로 두면 구조는 대략 다음과 같이 될 수 있습니다(개념도):
flowchart TD
TaskH[task.hpp] --> TaskCpp[task.cpp]
ProjectH[project.hpp] --> ProjectCpp[project.cpp]
TaskCpp --> ProjectH
PrintCpp[print.cpp] --> TaskH
PrintCpp --> ProjectH
여기서 중요한 점은: .cpp는 내부 구현을 위해 많은 것을 포함할 수 있고, .hpp는 가볍고 자급자족하도록 노력해야 한다는 것입니다. 사이클이 생기더라도 즉시 눈에 띄게 되고, 전이적 의존성으로 인해 "어쩌다 생긴" 문제가 되지 않습니다.
또 다른 실용적인 점검 방법: 각 .cpp 파일에서 첫 번째 include로 자신의 헤더를 포함하는 것입니다.
// task.cpp
#include "task.hpp"
#include "project.hpp"
만약 task.hpp가 자급자족하지 못하면(필요한 선언을 스스로 포함하지 못하면) 바로 컴파일 오류가 발생합니다. 의존성이 너무 무거우면 빌드가 느려지고, 한 헤더를 변경할 때마다 많은 것이 재빌드되는 것을 느낄 수 있습니다.
5. 순환 의존성 해결 시 흔한 실수
실수 1: include guards가 사이클을 완전히 치료한다고 생각하기.
Include guards와 #pragma once는 중복 포함을 막지만 타입을 '완전'하게 만들지는 않습니다. 그래서 "사이클이 없어졌는데도 타입이 불완전하다"는 상황이 흔합니다. 해결 방법은 guards 강화가 아니라 의존성을 끊는 것입니다: forward declaration, include를 .cpp로 옮기기, 또는 저장 모델 변경 등입니다.
실수 2: forward declaration을 해놓고 여전히 값으로 타입을 저장한다.
흔한 초보자의 실수는 struct Project;를 써놓고 스스로 승리했다고 느끼다가, 구조체에 Project project;를 남겨두는 것입니다. 그러나 컴파일러는 필드 레이아웃을 위해 Project의 크기가 필요하며 forward declaration은 그 정보를 제공하지 않습니다. 값으로 포함하려면 정의된 헤더를 포함해야 합니다. 의존성을 줄이고 싶다면 포인터, 레퍼런스 또는 project_id로 전환하십시오.
실수 3: 헤더에서 불완전 타입에 대해 필드 접근을 요구하는 inline 구현을 작성한다.
포인터로 멤버를 가지고 있더라도, 헤더에 inline 구현으로 project->id처럼 접근하면 안 됩니다. 그 위치에서 Project가 아직 불완전하면 컴파일러는 invalid use of incomplete type를 발생시킵니다. 그런 로직은 .cpp에 두어야 합니다.
실수 4: 사이클을 "고치기" 위해 더 많은 #include를 추가한다.
오류를 보고 모든 곳에 닥치는 대로 포함문을 추가하면 단기적으로는 문제가 사라질 수 있으나 장기적으로는 프로젝트를 취약하고 느리게 만듭니다. 의존성이 늘어나고 사이클은 더 복잡해지며 빌드 시간이 증가합니다. 보통 먼저 묻는 것이 좋습니다: "이 include는 선언을 위해 필요한가, 아니면 구현을 위해서만 필요한가?"
실수 5: 포인터로 사이클을 끊고 나서 '포인터가 비어 있을 수 있음'에 대한 상태를 고려하지 않는다.
Project* project{}로 바꾸면 프로젝트가 없을 수 있는 상황을 허용하게 됩니다. 실제 시나리오에서 프로젝트가 항상 존재한다고 가정할 수 있더라도, 사용 지점에서 nullptr 검사를 하는 것이 코드를 더 안전하고 명확하게 만듭니다. 그렇지 않으면 컴파일러와의 싸움에는 이겨도 런타임에서 문제가 발생할 수 있습니다.
GO TO FULL VERSION