코드는 “동작하는 것”에서 끝나는 것이 아니라, 읽기 쉽고 수정하기 쉬우며 실수하기 어려운 구조로 작성해야 한다.

특히 Unreal Engine / C++ 프로젝트에서는 클래스, 컴포넌트, 데이터 구조, Subsystem, 상속 구조가 쉽게 커지기 때문에 코드 스멜을 초기에 발견하고 리팩터링하는 습관이 중요하다.

기이한 이름

함수, 변수, 클래스 이름은 역할과 의도가 드러나야 한다.

void AttackEnemy(int DamageAmount);    // DamageAmount로 Enemy를 Attack하는 함수

float CurrentHealth; // 현재 체력
int EnemyCount;    // 적 수

이름이 잘 떠오르지 않는다면 단순히 작명 문제가 아니라,
책임 분리가 덜 되었거나 설계가 모호한 상태일 수 있음.

중복 코드

같은 로직을 여러 곳에 복사하면 수정 시 누락이 발생하기 쉬움.

void A::Func()
{
        // LONG CODE
        // LONG CODE
        // LONG CODE
}

void B::Func2()
{
        // LONG CODE
        // LONG CODE
        // LONG CODE

        DoSome2();
}

void A::Func()
{
        // LONG CODE
        // LONG CODE
        // LONG CODE
}
void B::Func()
{
        A::Func();
        DoSome2();
}

공통 처리와 개별 처리를 분리하면 유지보수성이 좋아진다.

긴 함수

Tick() 안에 이동, 점프, 공격, 애니메이션, 상태 체크가 모두 들어가면 읽기 어려워짐

void Tick(float DeltaTime)
{
    // 이동
    // 점프
    // 공격
    // 버프
    // 체력 체크
    // 애니메이션
    // ...
}

좋은 방향:

void Tick(float DeltaTime)
{
    HandleMovement(DeltaTime);
    HandleJump();
    HandleAttack();
    UpdateAnimation();
}

주석이 많이 필요해지는 구간은 함수로 분리해야 하는 신호이다.

긴 매개변수 목록

매개변수가 많으면 함수 호출부가 복잡해지고 실수하기 쉬움.

void InitWeapon(FString Name, float Damage, float FireRate, int32 AmmoCount, float ReloadTime, USkeletalMesh* Mesh, USoundBase* Sound);

관련 데이터를 구조체로 묶어 사용할 수 있음.

struct FWeaponData
{
    FString Name;
    float Damage;
    float FireRate;
    int32 AmmoCount;
    float ReloadTime;
};

struct FWeaponAssets
{
    USkeletalMesh* Mesh;
    USoundBase* Sound;
};

데이터 뭉치

여러 함수에서 항상 같이 다니는 값들은 하나의 개념으로 묶어야 한다.

예를 들어 Damage, Range, Accuracy가 항상 함께 쓰인다면 FWeaponStats로 묶는 것이 좋다.

struct FWeaponStats
{
    float Damage;
    float Range;
    float Accuracy;
};

기본형 집착

float Health, float MaxHealth처럼 원시 타입만으로 복잡한 개념을 표현하면 규칙이 흩어진다.

class FHealth
{
public:
    void ApplyDamage(float Amount);
    float Get() const;

private:
    float Current;
    float Max;
};

이렇게 하면 체력 감소, Clamp, 사망 판단 같은 규칙을 한 곳에 모을 수 있음

전역 데이터 남용

전역 변수는 어디서든 접근 가능하기 때문에 디버깅이 어려움

UGameManager* GGameManager;

접근 범위를 통제하는 것이 좋다.

UScoreSystem* ScoreSys = GI->GetSubsystem<UScoreSystem>();
ScoreSys->AddScore(50);

가변 데이터

모든 필드를 public으로 열어두면 아무 곳에서나 값을 바꿀 수 있음

public:
    float Health;
    int32 Level;

좋은 방향:

private:
    float Health;

public:
    float GetHealth() const;
    void TakeDamage(float Amount);

뒤엉킨 변경

하나의 클래스가 여러 이유로 계속 수정된다면 책임이 너무 많은 것.

샷건 수술

반대로, 작은 변경 하나를 위해 여러 클래스를 동시에 고쳐야 한다면 관련 로직이 너무 흩어진 것.

데미지 계산이 캐릭터, 무기, 게임모드에 흩어져 있다면 UDamageSystem 같은 곳으로 모을 수 있음

기능 편애

어떤 함수가 자기 클래스보다 다른 클래스의 데이터를 더 많이 사용한다면 위치가 잘못된 것

UDamageCalculatorCharacter->GetHealth(), GetArmor(), GetMaxHealth()를 계속 호출한다면, 그 계산은 캐릭터 쪽에 있는 것이 더 자연스러울 수 있습니다.

float AMyCharacter::CalculateDamageReduction(float Damage) const;

메시지 체인

객체를 줄줄이 타고 들어가는 코드는 결합도를 높임.

Inventory->EquippedWeapon->SoundData->AttackSound

Inventory->GetAttackSound();

호출자는 내부 구조를 몰라도 되게 만들어야 한다.

내부자 거래

한 클래스가 다른 클래스의 내부 데이터를 직접 조작하면 결합도가 높아진다.

Player->CurrentHealth -= Damage;
Player->PlayerHUD->UpdateHealthBar(...);

Player->ReceiveDamage(AttackDamage);

거대한 클래스

하나의 캐릭터 클래스가 이동, 전투, 인벤토리, 퀘스트, 대화까지 모두 처리하면 유지보수가 어려워진다.

UMovementComponent* MovementComp;
UCombatComponent* CombatComp;
UInventoryComponent* InventoryComp;
UQuestComponent* QuestComp;

임시 필드

특정 타입의 적에게만 필요한 필드가 AEnemy에 모두 들어가 있으면 불필요한 필드가 늘어난다.

원거리 적만 쓰는 ProjectileSpeed, 텔레포트 적만 쓰는 TeleportCooldown은 각각 컴포넌트로 분리할 수 있음

URangedAttackComponent;
UTeleportComponent;

반복되는 스위치문

switch (WeaponType)
{
    case Sword:
    case Bow:
    case Gun:
}

class AWeapon
{
public:
    virtual void Attack();
};

각 무기가 자기 공격을 직접 처리하게 하면 캐릭터는 다음처럼 단순해진다.

CurrentWeapon->Attack();

서로 다른 인터페이스의 대안 클래스들

비슷한 역할을 하는 클래스들이 서로 다른 함수명을 가지고 있으면 교체하기 어려움

원거리 무기는 FireProjectile(), 근접 무기는 PerformAttack()을 사용하면 캐릭터가 무기 종류를 알아야 합니다.

공통 인터페이스를 사용

virtual void Attack() = 0;

상속 포기

부모 클래스의 기능이 자식 클래스에 맞지 않는다면 상속 구조가 잘못된 것

예를 들어 AWeaponReload()가 있는데 근접 무기는 재장전이 필요 없다면,
Reload()를 부모에 강제로 넣지 않는 것이 좋음

게으른 요소

실질적인 역할 없이 단순히 다른 함수를 호출만 하는 클래스나 함수는 제거 대상

void Launch()
{
    LaunchProjectile();
}

중간 함수가 의미를 추가하지 않는다면 직접 처리

중재자

클래스를 전달 하면 중간 단계를 제거할 수 있음

PlayerController가 모든 입력을 단순히 Character에게 넘기기만 한다면,
캐릭터가 직접 필요한 입력 바인딩을 처리하도록 구조를 단순화할 수 있다.

추측성 일반화

과도하게 확장 가능한 구조를 만들면 오히려 짐이 된다.
현재 필요 없는 기능은 처음부터 넣지 않는 것이 좋다.
필요해졌을 때 확장하기

반복문

반복문 안에 필터링, 계산, 효과 적용 등 여러 단계가 뒤섞여 있을 때 아래와 같이 바꿀 수 있음.

TArray<UItem*> HeavyItems = GetHeavyItems(Items);
float TotalWeight = GetTotalWeight(HeavyItems);

if (IsTooHeavy(TotalWeight))
{
    ApplySlowEffect();
}

주석

// 플레이어 위치 확인
// 시야 거리 확인
// 각도 확인
// 라인트레이스 확인
// 공격 또는 순찰

if (CanSeePlayer())
{
    EngagePlayer();
}
else
{
    PatrolArea();
}

코드 자체가 설명이 되도록 함수명을 짓는 것이 가장 좋다.


전체 요약

분류 핵심 내용
이름 이름만 보고 역할이 드러나야 한다
함수 길면 쪼개고, 주석 대신 함수명으로 의도를 표현한다
데이터 함께 쓰이는 값은 구조체/클래스로 묶는다
상태 전역 데이터와 public 가변 데이터는 최소화한다
책임 하나의 클래스가 여러 이유로 바뀌지 않게 한다
의존성 다른 객체의 내부 구조를 직접 파고들지 않는다
상속 억지 상속보다 인터페이스와 다형성을 활용한다
구조 필요 없는 중간 계층과 미래 대비 코드는 줄인다
Unreal 설계 Subsystem, Component, 다형성을 적극 활용한다

'TIL' 카테고리의 다른 글

Unreal EQS(Environment Query System)  (0) 2026.06.08
Unreal Engine AI Controller  (0) 2026.06.05
ProjectFT 시작, 초기 설정  (0) 2026.06.04
Unreal Component  (0) 2026.06.02
TEXT RPG(DIABL5) 발표하며 회고...  (0) 2026.05.29