포스트

헤더와 소스 - .h/.cpp 분리와 컴파일의 이해 - 유니티 개발자를 위한 언리얼 C++ #2

헤더와 소스 - .h/.cpp 분리와 컴파일의 이해 - 유니티 개발자를 위한 언리얼 C++ #2
TL;DR — 핵심 요약
  • C++은 .h(선언)와 .cpp(정의)로 파일을 분리한다 - C#의 .cs 하나에 다 넣는 방식과 근본적으로 다르다
  • #include는 C#의 using과 다르다 - 파일 내용을 물리적으로 복사/붙여넣기하는 전처리기 지시문이다
  • 전방선언(forward declaration)은 헤더를 가볍게 유지하는 핵심 기법 - 포인터/참조만 쓸 때는 #include 대신 전방선언
  • 언리얼의 .generated.h는 반드시 마지막 #include여야 한다 - UHT가 자동 생성하는 리플렉션 코드 파일이다
Visitors

이 코드, 읽을 수 있나요?

언리얼 프로젝트에서 새 클래스를 만들면 파일이 2개 생깁니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// ─── MyWeapon.h ───
#pragma once

#include "CoreMinimal.h"
#include "GameFramework/Actor.h"
#include "MyWeapon.generated.h"

class AMyCharacter;  // 전방 선언

UCLASS()
class MYGAME_API AMyWeapon : public AActor
{
    GENERATED_BODY()

public:
    AMyWeapon();

    UPROPERTY(EditAnywhere, Category = "Weapon")
    float Damage = 10.0f;

    void Equip(AMyCharacter* Owner);

protected:
    virtual void BeginPlay() override;

private:
    AMyCharacter* OwnerCharacter;
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// ─── MyWeapon.cpp ───
#include "MyWeapon.h"
#include "MyCharacter.h"

AMyWeapon::AMyWeapon()
{
    Damage = 10.0f;
    OwnerCharacter = nullptr;
}

void AMyWeapon::Equip(AMyCharacter* Owner)
{
    OwnerCharacter = Owner;
    UE_LOG(LogTemp, Display, TEXT("Weapon equipped! Damage: %f"), Damage);
}

void AMyWeapon::BeginPlay()
{
    Super::BeginPlay();
}

유니티 개발자라면 이런 의문이 듭니다:

  • 왜 파일이 2개야? .cs 하나면 되는데?
  • #pragma once는 뭐지?
  • #include가 3줄이나 있는데, using이랑 뭐가 다른 거지?
  • .generated.h는 내가 만든 적 없는데 왜 include 하는 거지?
  • .h에서는 class AMyCharacter;라고만 쓰고 .cpp에서는 #include "MyCharacter.h"를 쓰네?

이번 강에서 이 모든 의문을 해결합니다.


서론 - C#에는 없는 “컴파일 모델”

유니티에서 C# 스크립트를 작성할 때, 우리는 파일 구조를 신경 쓸 필요가 거의 없습니다. PlayerController.cs 하나에 클래스를 작성하면 됩니다. using UnityEngine; 한 줄이면 엔진의 모든 기능에 접근할 수 있고, 같은 프로젝트의 다른 스크립트는 별도의 import 없이 바로 사용할 수 있습니다.

C++은 완전히 다릅니다. “내가 쓰고 싶은 것을 하나하나 알려줘야” 하는 언어입니다.

flowchart LR
    subgraph CSharp["C# (Unity)"]
        direction TB
        CS1["PlayerController.cs"]
        CS2["하나의 파일에\n선언 + 구현"]
        CS3["using으로 네임스페이스 참조\n같은 프로젝트는 자동 인식"]
        CS1 --> CS2 --> CS3
    end

    subgraph CPP["C++ (Unreal)"]
        direction TB
        CP1["PlayerController.h\n+\nPlayerController.cpp"]
        CP2[".h = 선언 (설계도)\n.cpp = 정의 (구현)"]
        CP3["#include로 파일 직접 포함\n명시적으로 모든 의존성 선언"]
        CP1 --> CP2 --> CP3
    end

왜 이런 구조일까요? C++이 만들어진 1980년대에는 컴퓨터 메모리가 매우 적었고, 컴파일러는 소스 파일 하나를 위에서 아래로 한 번만 읽을 수 있었습니다. “다른 파일에 어떤 클래스가 있는지” 자동으로 알 방법이 없었기 때문에, 개발자가 직접 “이 파일에서 이런 것들을 쓸 거야”라고 알려줘야 했습니다. 그 유산이 지금까지 이어지고 있습니다.


1. #include - “using이 아니라 복사/붙여넣기다”

1-1. #include의 실체

C#의 using네임스페이스를 참조하는 것입니다. 파일을 가져오는 게 아닙니다.

C++의 #include는 완전히 다릅니다. 해당 파일의 내용을 그 자리에 물리적으로 복사/붙여넣기합니다. 진짜로요.

1
2
3
4
5
// Weapon.h
class Weapon
{
    float Damage;
};
1
2
3
4
5
6
7
// Player.cpp
#include "Weapon.h"   // ← Weapon.h의 내용이 이 자리에 복사됨

class Player
{
    Weapon* MyWeapon;  // 이제 Weapon을 알고 있음
};

전처리기(preprocessor)가 #include를 처리하면, 컴파일러가 실제로 보는 코드는 이렇게 됩니다:

1
2
3
4
5
6
7
8
9
10
// 컴파일러가 실제로 보는 Player.cpp (전처리 후)
class Weapon          // ← Weapon.h에서 복사됨
{
    float Damage;
};

class Player
{
    Weapon* MyWeapon;
};

이게 바로 #includeusing근본적으로 다른 점입니다.

C# usingC++ #include
네임스페이스를 참조파일 내용을 복사/붙여넣기
컴파일 속도에 거의 영향 없음include가 많으면 컴파일 느려짐
순서 상관없음순서가 중요할 수 있음
중복해도 상관없음중복하면 재정의 에러 (가드 없으면)

💬 잠깐, 이건 알고 가자

Q. 그러면 #include를 100개 하면 파일이 엄청 커지나요?

네, 정말로 그렇습니다. 언리얼에서 CoreMinimal.h 하나만 include해도, 전처리 후에는 수만 줄이 됩니다. 이 파일이 또 다른 헤더를 include하고, 그 헤더가 또 다른 헤더를 include하고… 연쇄적으로 확장됩니다. 그래서 불필요한 #include를 줄이는 것이 컴파일 시간에 직결됩니다.

Q. #include <iostream>#include "MyFile.h" 의 차이는?

<> 꺾쇠는 시스템/표준 라이브러리 경로에서 찾고, "" 따옴표는 프로젝트 내 파일에서 먼저 찾습니다. 언리얼에서는 대부분 "" 따옴표를 사용합니다.


1-2. #pragma once - 중복 include 방지

#include가 복사/붙여넣기라면, 같은 파일을 2번 include하면 클래스가 2번 정의되어 에러가 발생합니다.

1
2
3
// 이런 상황
#include "Weapon.h"
#include "Weapon.h"  // ← Weapon 클래스가 2번 정의됨 → 컴파일 에러!

“직접 2번 include할 일이 있나?” 싶지만, A.h가 Weapon.h를 include하고, B.h도 Weapon.h를 include하면, 두 헤더를 모두 사용하는 파일에서 자동으로 2번 포함됩니다.

이를 방지하는 것이 #pragma once입니다.

1
2
3
4
5
6
7
// Weapon.h
#pragma once    // ← "이 파일은 한 번만 포함해!" (중복 방지)

class Weapon
{
    float Damage;
};

#pragma once가 있으면, 전처리기가 이미 포함한 파일을 다시 만나면 건너뜁니다.

언리얼의 모든 .h 파일은 첫 줄에 #pragma once가 있습니다. 보이면 “아, 중복 include 방지구나” 하고 넘기면 됩니다.

💬 잠깐, 이건 알고 가자

Q. #pragma once 말고 include guard라는 것도 있다던데?

전통적인 C++ 방식입니다:

1
2
3
4
5
6
7
// 전통적 방식 (include guard)
#ifndef WEAPON_H
#define WEAPON_H

class Weapon { ... };

#endif

#pragma once가 더 간편하고 현대적이어서, 언리얼을 포함한 대부분의 현대 C++ 프로젝트는 #pragma once를 사용합니다. 오래된 오픈소스 프로젝트에서 #ifndef 스타일을 볼 수 있는데, 같은 역할이라고 생각하면 됩니다.


2. 헤더(.h)와 소스(.cpp) - 왜 분리하는가

2-1. 선언과 정의의 분리

C++에서 가장 중요한 개념 중 하나가 선언(declaration)과 정의(definition)의 분리입니다.

  • 선언 = “이런 것이 존재한다” (설계도, 인터페이스)
  • 정의 = “이것은 이렇게 동작한다” (실제 구현)
1
2
3
4
5
6
7
8
// ─── MyWeapon.h (선언) ───
// "이런 클래스가 있고, 이런 멤버와 함수가 있다"
class AMyWeapon : public AActor
{
    float Damage;
    void Attack();        // 선언만 (세미콜론으로 끝)
    float GetDamage();    // 선언만
};
1
2
3
4
5
6
7
8
9
10
11
12
13
// ─── MyWeapon.cpp (정의) ───
// "그 함수들은 이렇게 동작한다"
#include "MyWeapon.h"

void AMyWeapon::Attack()         // 클래스이름::함수이름
{
    UE_LOG(LogTemp, Display, TEXT("Attack! Damage: %f"), Damage);
}

float AMyWeapon::GetDamage()
{
    return Damage;
}

.cpp에서 함수를 정의할 때 AMyWeapon::Attack() 처럼 클래스이름::함수이름 형태로 작성합니다. :: 은 스코프 해석 연산자(scope resolution operator)로, “이 함수는 AMyWeapon 클래스에 속한 함수야”라고 알려줍니다.

C#에서는 이런 분리가 없습니다. 메서드 본문을 바로 클래스 안에 작성하죠:

1
2
3
4
5
6
7
8
9
10
// C# - 선언과 구현이 하나
public class MyWeapon : MonoBehaviour
{
    float damage;

    public void Attack()    // 선언과 동시에 구현
    {
        Debug.Log($"Attack! Damage: {damage}");
    }
}

2-2. 왜 분리하는 걸까?

이유 1: 컴파일 속도

C++ 컴파일러는 .cpp 파일을 하나씩 독립적으로 컴파일합니다. PlayerController.cpp가 변경되면 PlayerController.cpp만 다시 컴파일하면 됩니다. 하지만 PlayerController.h가 변경되면, 이 헤더를 include하는 모든 .cpp 파일이 다시 컴파일됩니다.

flowchart TB
    subgraph Headers[".h 파일 변경의 영향"]
        H["Weapon.h 수정"]
        H --> C1["Player.cpp 재컴파일"]
        H --> C2["Enemy.cpp 재컴파일"]
        H --> C3["Inventory.cpp 재컴파일"]
        H --> C4["Shop.cpp 재컴파일"]
    end

    subgraph Source[".cpp 파일 변경의 영향"]
        S["Weapon.cpp 수정"]
        S --> C5["Weapon.cpp만 재컴파일"]
    end

그래서 헤더는 가볍게, 소스는 무겁게라는 원칙이 중요합니다. 헤더에 구현 코드를 넣으면, 한 줄만 고쳐도 연쇄적으로 재컴파일이 발생합니다.

이유 2: 인터페이스와 구현의 분리

.h 파일만 보면 클래스의 “인터페이스”(무엇을 할 수 있는지)를 한눈에 파악할 수 있습니다. 구현 세부사항에 방해받지 않고, 클래스의 전체 구조를 빠르게 읽을 수 있습니다.

실제로 언리얼 프로젝트에서 다른 사람의 코드를 읽을 때 .h 파일부터 보는 습관을 들이면 훨씬 빠르게 파악할 수 있습니다.

이유 3: 정보 은닉

라이브러리를 배포할 때 .h 파일만 제공하고 .cpp는 컴파일된 바이너리로 제공할 수 있습니다. “무엇을 할 수 있는지”는 공개하되, “어떻게 하는지”는 숨기는 거죠. 언리얼 엔진 자체도 이런 방식으로 구성되어 있습니다.

특성.h 파일 (헤더).cpp 파일 (소스)
역할선언 (인터페이스)정의 (구현)
변경 시 영향include하는 모든 파일 재컴파일자기만 재컴파일
공개 범위다른 파일이 볼 수 있음자기만 볼 수 있음
원칙가볍게 유지무거워도 OK

2-3. 언리얼 헤더 파일의 기본 구조

언리얼에서 새 클래스를 만들면 항상 같은 패턴의 헤더가 생성됩니다. 각 줄의 의미를 하나씩 해부해봅시다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// MyCharacter.h

#pragma once                              // ① 중복 include 방지

#include "CoreMinimal.h"                  // ② 언리얼 핵심 타입 (int32, FString 등)
#include "GameFramework/Character.h"      // ③ 부모 클래스 헤더 (필수)
#include "MyCharacter.generated.h"        // ④ UHT 자동 생성 (반드시 마지막!)

class USpringArmComponent;                // ⑤ 전방 선언 (include 대신)
class UCameraComponent;                   // ⑤

UCLASS()                                  // ⑥ 언리얼 리플렉션 매크로
class MYGAME_API AMyCharacter : public ACharacter  // ⑦ 모듈 API + 상속
{
    GENERATED_BODY()                      // ⑧ 리플렉션 코드 삽입 지점

public:
    AMyCharacter();

protected:
    UPROPERTY(VisibleAnywhere)
    USpringArmComponent* SpringArm;       // 포인터 → 전방선언으로 충분

    UPROPERTY(VisibleAnywhere)
    UCameraComponent* Camera;

    virtual void BeginPlay() override;
};
번호코드역할
#pragma once중복 include 방지
#include "CoreMinimal.h"언리얼 기본 타입과 매크로 (거의 모든 헤더에 포함)
#include "GameFramework/Character.h"부모 클래스의 완전한 정의가 필요 (상속하니까)
#include "MyCharacter.generated.h"UHT가 생성하는 리플렉션 코드 (반드시 마지막 #include)
class USpringArmComponent;전방 선언 — 포인터로만 쓸 거니까 #include 불필요
UCLASS()이 클래스를 언리얼 리플렉션에 등록
MYGAME_API다른 모듈에서도 이 클래스를 사용할 수 있게 내보내기
GENERATED_BODY()UHT가 생성한 코드가 여기에 삽입됨

💬 잠깐, 이건 알고 가자

Q. .generated.h는 왜 반드시 마지막이어야 하나요?

UHT(Unreal Header Tool)가 이 파일을 자동 생성할 때, 그 위에 선언된 모든 include와 전방선언을 기반으로 코드를 만듭니다. .generated.h가 중간에 오면 아래에 있는 선언을 참조하지 못해 에러가 납니다.

Q. CoreMinimal.h는 뭐가 들어있나요?

int32, float, FString, FVector, TArray, UObject, check()언리얼에서 가장 많이 쓰는 기본 타입과 매크로가 들어있습니다. 과거에는 Engine.h를 include했는데, 너무 무거워서 경량화된 CoreMinimal.h로 대체되었습니다. 이것이 언리얼의 IWYU(Include What You Use) 원칙입니다.

Q. MYGAME_API는 뭔가요?

모듈 내보내기 매크로입니다. MYGAME은 프로젝트 이름(모듈 이름)이 대문자로 변환된 것입니다. 이게 없으면 다른 모듈에서 이 클래스를 사용할 수 없습니다. 지금은 “항상 붙이는 것”이라고만 기억하면 됩니다.


2-4. 대응하는 .cpp 파일의 구조

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// MyCharacter.cpp

#include "MyCharacter.h"                            // ① 자기 자신의 헤더 (반드시 첫 번째!)
#include "GameFramework/SpringArmComponent.h"       // ② 구현에 필요한 헤더들
#include "Camera/CameraComponent.h"                 // ②

AMyCharacter::AMyCharacter()                        // ③ 생성자 정의
{
    SpringArm = CreateDefaultSubobject<USpringArmComponent>(TEXT("SpringArm"));
    SpringArm->SetupAttachment(RootComponent);

    Camera = CreateDefaultSubobject<UCameraComponent>(TEXT("Camera"));
    Camera->SetupAttachment(SpringArm);
}

void AMyCharacter::BeginPlay()                      // ④ 함수 정의
{
    Super::BeginPlay();                              // ⑤ 부모 함수 호출
    UE_LOG(LogTemp, Display, TEXT("MyCharacter BeginPlay!"));
}
번호코드역할
#include "MyCharacter.h"자기 헤더를 첫 번째로 — 빠진 의존성을 바로 발견하기 위함
#include "...Component.h".h에서 전방선언만 했던 클래스의 실제 헤더 (멤버 접근 필요)
AMyCharacter::AMyCharacter()클래스이름::함수이름 형태로 정의
void AMyCharacter::BeginPlay()같은 패턴
Super::BeginPlay()부모 클래스의 같은 함수 호출 (C#의 base.Method()와 동일)

핵심 패턴: .h에서 전방선언한 클래스는, .cpp에서 실제 #include를 합니다. 헤더는 가볍게, 소스에서 필요한 것을 include하는 구조입니다.


3. 전방선언 - 헤더를 가볍게 유지하는 핵심 기법

3-1. 전방선언이란?

전방선언(forward declaration)은 클래스의 완전한 정의 없이, “이런 클래스가 존재한다”고만 알려주는 것입니다.

1
2
3
4
5
6
7
// 전방선언 - "AEnemy라는 클래스가 있을 거야" (크기, 멤버는 모름)
class AEnemy;

// vs

// 완전한 include - AEnemy의 모든 것을 알게 됨 (크기, 멤버, 함수 등)
#include "Enemy.h"

3-2. 왜 전방선언을 쓰는가?

C#에서는 using을 아무리 많이 써도 컴파일 속도에 영향이 없습니다. 하지만 C++에서 #include는 파일을 물리적으로 복사하므로, include가 많을수록 컴파일러가 처리할 코드가 기하급수적으로 늘어납니다.

flowchart TB
    subgraph Bad["❌ 전방선언 없이 (include만 사용)"]
        direction TB
        A1["MyCharacter.h"] -->|"#include"| B1["SpringArmComponent.h"]
        A1 -->|"#include"| C1["CameraComponent.h"]
        A1 -->|"#include"| D1["Weapon.h"]
        A1 -->|"#include"| E1["HealthComponent.h"]
        B1 -->|"또 #include"| F1["SceneComponent.h"]
        C1 -->|"또 #include"| F1
        F1 -->|"또 #include"| G1["ActorComponent.h"]
    end

    subgraph Good["✅ 전방선언 사용"]
        direction TB
        A2["MyCharacter.h"] -->|"전방선언"| B2["class USpringArmComponent;"]
        A2 -->|"전방선언"| C2["class UCameraComponent;"]
        A2 -->|"전방선언"| D2["class AWeapon;"]
        A2 -->|"전방선언"| E2["class UHealthComponent;"]
    end

전방선언은 1줄이지만, #include는 해당 파일과 그 파일이 include하는 모든 파일을 연쇄적으로 가져옵니다. 프로젝트가 커질수록 이 차이가 빌드 시간에 큰 영향을 미칩니다.


3-3. 전방선언으로 할 수 있는 것 / 없는 것

핵심 규칙: 포인터(*)나 참조(&)로만 사용할 때는 전방선언으로 충분합니다.

왜? 포인터는 그냥 메모리 주소(8바이트)일 뿐이라 클래스의 크기나 구조를 몰라도 됩니다. 하지만 객체를 직접 생성하거나 멤버에 접근하려면 완전한 정의가 필요합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
class AEnemy;  // 전방선언

// ✅ 가능한 것 (크기를 몰라도 됨)
AEnemy* EnemyPtr;                    // 포인터 선언
void SetTarget(AEnemy* Enemy);       // 포인터 파라미터
AEnemy* GetTarget() const;           // 포인터 반환
void Process(AEnemy& Enemy);         // 참조 파라미터

// ❌ 불가능한 것 (크기나 멤버를 알아야 함)
AEnemy EnemyInstance;                // 객체 직접 생성 → 크기를 모름!
EnemyPtr->TakeDamage(10.f);         // 멤버 접근 → 멤버를 모름!
sizeof(AEnemy);                      // 크기 요청 → 크기를 모름!
class ABoss : public AEnemy {};      // 상속 → 구조를 모름!
상황전방선언#include
포인터 멤버 변수 선언 (AEnemy*)
참조 파라미터 (const AEnemy&)
포인터 반환 타입
객체 직접 생성
멤버 접근 (ptr->Function())
상속 (class B : public A)
sizeof()

3-4. 언리얼에서의 전방선언 실전 패턴

언리얼 프로젝트에서 가장 많이 보는 패턴입니다:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// ─── MyCharacter.h ───
#pragma once
#include "CoreMinimal.h"
#include "GameFramework/Character.h"     // 부모 클래스는 반드시 #include
#include "MyCharacter.generated.h"

// 전방선언 (포인터로만 쓸 것들)
class USpringArmComponent;
class UCameraComponent;
class UInputMappingContext;
class UInputAction;
class AWeapon;

UCLASS()
class MYGAME_API AMyCharacter : public ACharacter
{
    GENERATED_BODY()

protected:
    UPROPERTY(VisibleAnywhere)
    USpringArmComponent* SpringArm;      // 포인터 → 전방선언 OK

    UPROPERTY(VisibleAnywhere)
    UCameraComponent* Camera;            // 포인터 → 전방선언 OK

    UPROPERTY()
    AWeapon* CurrentWeapon;              // 포인터 → 전방선언 OK

public:
    void EquipWeapon(AWeapon* Weapon);   // 포인터 파라미터 → 전방선언 OK
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// ─── MyCharacter.cpp ───
#include "MyCharacter.h"                            // 자기 헤더 (첫 번째!)
#include "GameFramework/SpringArmComponent.h"       // 이제 실제로 생성하니까 include
#include "Camera/CameraComponent.h"                 // 멤버 접근하니까 include
#include "Weapon.h"                                 // 멤버 함수 호출하니까 include

AMyCharacter::AMyCharacter()
{
    // CreateDefaultSubobject → 객체 생성 → 완전한 정의 필요 → #include 필수
    SpringArm = CreateDefaultSubobject<USpringArmComponent>(TEXT("SpringArm"));
    Camera = CreateDefaultSubobject<UCameraComponent>(TEXT("Camera"));
}

void AMyCharacter::EquipWeapon(AWeapon* Weapon)
{
    CurrentWeapon = Weapon;
    if (CurrentWeapon)
    {
        CurrentWeapon->AttachToActor(this, FAttachmentTransformRules::KeepRelativeTransform);
    }
}

패턴 정리:

1
2
.h 파일:  class AEnemy;              ← 전방선언 (가볍게)
.cpp 파일: #include "Enemy.h"        ← 실제 include (무겁지만 .cpp니까 OK)

4. 순환 의존성 - 전방선언이 해결하는 문제

4-1. 문제 상황

Player가 Enemy를 참조하고, Enemy도 Player를 참조하는 상황. 게임에서 매우 흔합니다.

1
2
3
4
5
6
7
8
// ❌ 이렇게 하면 무한 루프!
// Player.h
#include "Enemy.h"      // Enemy.h를 가져옴
class APlayer { AEnemy* Target; };

// Enemy.h
#include "Player.h"     // Player.h를 가져옴 → Player.h가 다시 Enemy.h를 가져옴 → 무한!
class AEnemy { APlayer* Target; };

#pragma once가 무한 루프 자체는 막아주지만, 순서에 따라 한쪽이 정의되기 전에 참조하게 되어 컴파일 에러가 발생합니다.

4-2. 전방선언으로 해결

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// ✅ Player.h
#pragma once
#include "CoreMinimal.h"
#include "GameFramework/Character.h"
#include "Player.generated.h"

class AEnemy;    // 전방선언! (#include "Enemy.h" 대신)

UCLASS()
class MYGAME_API AMyPlayer : public ACharacter
{
    GENERATED_BODY()

protected:
    UPROPERTY()
    AEnemy* TargetEnemy;    // 포인터니까 전방선언으로 충분

public:
    void AttackTarget();
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// ✅ Enemy.h
#pragma once
#include "CoreMinimal.h"
#include "GameFramework/Character.h"
#include "Enemy.generated.h"

class AMyPlayer;  // 전방선언!

UCLASS()
class MYGAME_API AEnemy : public ACharacter
{
    GENERATED_BODY()

protected:
    UPROPERTY()
    AMyPlayer* TargetPlayer;    // 포인터니까 전방선언으로 충분

public:
    void TakeDamage(float Damage);
};
1
2
3
4
5
6
7
8
9
10
11
// ✅ Player.cpp - 여기서 include
#include "Player.h"
#include "Enemy.h"      // 이제 Enemy의 멤버에 접근 가능

void AMyPlayer::AttackTarget()
{
    if (TargetEnemy)
    {
        TargetEnemy->TakeDamage(10.f);  // 멤버 함수 호출 → #include 필요
    }
}
flowchart LR
    subgraph Headers[".h 파일 (전방선언으로 연결)"]
        PH["Player.h\nclass AEnemy;"]
        EH["Enemy.h\nclass AMyPlayer;"]
        PH -.->|"전방선언\n(가벼운 연결)"| EH
        EH -.->|"전방선언\n(가벼운 연결)"| PH
    end

    subgraph Sources[".cpp 파일 (#include로 연결)"]
        PC["Player.cpp\n#include Enemy.h"]
        EC["Enemy.cpp\n#include Player.h"]
    end

    PH --> PC
    EH --> EC

5. C++ 컴파일 과정 - 전체 그림

유니티에서 스크립트를 저장하면 에디터가 자동으로 컴파일합니다. C++의 컴파일 과정은 좀 더 복잡합니다.

flowchart TB
    subgraph Step1["1. 전처리 (Preprocessing)"]
        S1A["#include → 파일 내용 복사"]
        S1B["#pragma once → 중복 제거"]
        S1C["#define, #ifdef → 매크로 처리"]
    end

    subgraph UHT["0. UHT (언리얼 전용)"]
        U1["UCLASS/UPROPERTY 스캔"]
        U2[".generated.h/.cpp 생성"]
        U1 --> U2
    end

    subgraph Step2["2. 컴파일 (Compilation)"]
        S2A["각 .cpp → 기계어로 변환"]
        S2B[".obj 파일 생성 (각각 독립)"]
        S2A --> S2B
    end

    subgraph Step3["3. 링크 (Linking)"]
        S3A["모든 .obj 합치기"]
        S3B["함수 호출 ↔ 함수 정의 연결"]
        S3C["최종 실행 파일 생성"]
        S3A --> S3B --> S3C
    end

    UHT --> Step1 --> Step2 --> Step3
단계하는 일C# 비교
0. UHTUCLASS 등 매크로 스캔 → .generated.h 생성없음 (리플렉션이 런타임에 처리됨)
1. 전처리#include 확장, 매크로 치환없음
2. 컴파일.cpp를 독립적으로 기계어로 변환 (.obj).cs → IL 코드 변환
3. 링크모든 .obj를 합쳐 실행 파일 생성IL → JIT 컴파일 (실행 시)

💬 잠깐, 이건 알고 가자

Q. 언리얼 빌드가 왜 이렇게 느린가요?

몇 가지 이유가 있습니다:

  1. #include 연쇄 — 하나의 헤더가 수십 개의 헤더를 연쇄적으로 끌어옴
  2. 템플릿 인스턴스화TArray<AEnemy*> 같은 템플릿이 사용될 때마다 새로운 코드 생성
  3. UHT 단계 — 일반 C++ 컴파일 전에 추가 단계가 필요
  4. 헤더 변경의 연쇄 효과 — 많이 include되는 헤더를 수정하면 수백 개의 .cpp가 재컴파일

그래서 전방선언으로 #include를 줄이는 것이 빌드 시간에 직접적인 영향을 줍니다.

Q. “링커 에러”는 뭔가요?

.h에 함수를 선언해놓고 .cpp에 정의(구현)를 안 하면 컴파일은 되지만, 링크 단계에서 “이 함수의 구현을 찾을 수 없다”는 에러가 납니다. C#에서는 볼 수 없는 에러 유형이라 처음엔 당황스럽습니다. unresolved external symbol이라는 메시지가 나오면 “어딘가에 구현이 빠져있구나”라고 생각하면 됩니다.


6. 언리얼 헤더 include 순서 규칙

언리얼에서 #include 순서에는 권장 규칙이 있습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
// MyCharacter.cpp

// ① 자기 자신의 헤더 (반드시 첫 번째!)
#include "MyCharacter.h"

// ② 언리얼 엔진 헤더
#include "GameFramework/SpringArmComponent.h"
#include "Camera/CameraComponent.h"
#include "Components/CapsuleComponent.h"

// ③ 같은 프로젝트의 다른 클래스 헤더
#include "Weapon/MyWeapon.h"
#include "Components/HealthComponent.h"

자기 헤더를 첫 번째로 두는 이유: 자기 헤더에 빠진 #include가 있으면 바로 컴파일 에러가 발생합니다. 다른 헤더가 먼저 오면 그 헤더가 우연히 필요한 것을 포함하고 있어서 에러가 숨겨질 수 있습니다.


정리 - 2강 체크리스트

이 강을 마치면 언리얼 코드에서 다음을 읽을 수 있어야 합니다:

  • #includeusing이 아니라 파일 복사라는 것을 안다
  • #pragma once가 왜 모든 헤더에 있는지 안다
  • .h.cpp가 왜 분리되어 있는지 설명할 수 있다
  • 클래스이름::함수이름 형태의 .cpp 정의를 읽을 수 있다
  • class AEnemy; 전방선언이 뭔지, 왜 쓰는지 안다
  • 전방선언으로 가능한 것(포인터, 참조)과 불가능한 것(객체 생성, 멤버 접근)을 구분할 수 있다
  • .generated.h가 왜 마지막 include인지 안다
  • CoreMinimal.h가 뭔지 안다
  • MYGAME_API가 뭔지 대략 안다
  • Super::BeginPlay()가 C#의 base.Method()와 같다는 것을 안다

다음 강 미리보기

3강: 포인터 입문 - C#에는 없는 그것

유니티에서 GetComponent<Rigidbody>()로 컴포넌트를 가져오면 그냥 .으로 접근합니다. 하지만 언리얼에서는 FindComponentByClass<UStaticMeshComponent>()로 가져온 결과에 -> 화살표로 접근합니다. *, &, ->, nullptr — C#에는 없는 포인터의 세계로 들어갑니다.

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.