💭회고
🫧Clean Code :: 클린 코드 가이드
💭회고개발에서 가장 중요한 역량 중 하나인 '클린 코드'에 대해 정리해보았다. 특히 언리얼 엔진 환경에서 어떻게 코드 품질을 높일 수 있는지, 실무에서 자주 만나는 문제점
raindrovvv.tistory.com
게임 개발에서 코드 리팩터링(코드 내부 구조를 개선하는 과정)은 프로젝트의 생명력과 확장성을 결정짓는 핵심 요소이다.
많은 리팩터링 기법 중에서 게임 개발자가 알아야 할 5가지 핵심 결정 원칙을 학습해보았다.
좋은 코드는 한 번에 완성되지 않는다...! 지속적인 리팩토링을 통해 코드베이스를 건강하게 유지하는 것이 장기적으로 개발 속도와 품질을 높이는 요령(?)이자 방법이라고 한다.
➡️ 핵심은 컴포넌트 구조!
📒학습 내용
1. 코드 세분화 vs. 코드 통합 ⚖️
코드 세분화 기법
코드 세분화는 복잡한 코드를 더 작고 관리하기 쉬운 단위로 나누는 기법이다.
함수 추출하기 (Extract Function)
// Before
void Player::Update()
{
if(health < 0)
{
isAlive = false;
animation.Play("Death");
sound.Play("DeathSFX");
gameManager.NotifyPlayerDied(this);
}
}
// After
void Player::Update()
{
if(health < 0)
{
HandleDeath();
}
}
void Player::HandleDeath()
{
isAlive = false;
animation.Play("Death");
sound.Play("DeathSFX");
gameManager.NotifyPlayerDied(this);
}
💡 실무 팁: 함수 추출 시 함수명은 '무엇을 하는지'가 명확히 드러나도록 작성해야 한다. 언리얼에서는 Handle-, Process-, Update-, Calculate- 등의 접두사를 활용하여 함수의 역할을 명확히 표현하는 것이 좋다.
변수 추출하기 (Extract Variable)
// Before
float damage = baseDamage * (1 + criticalChance * 2.0f);
// After
float critMultiplier = 1 + criticalChance * 2.0f;
float damage = baseDamage * critMultiplier;
클래스 추출하기 (Extract Class)
// Before
class Player
{
FString Name;
FString Address;
FString Email;
};
// After
class ContactInfo
{
FString Address;
FString Email;
};
class Player
{
FString Name;
ContactInfo Contact;
};
코드 통합 기법
코드 통합은 불필요하게 분리된 코드를 하나로 합치는 기법이다.
함수 인라인하기 (Inline Function)
// Before
bool Player::IsDead()
{
return health <= 0;
}
void Player::Update()
{
if(IsDead())
{
HandleDeath();
}
}
// After
void Player::Update()
{
if(health <= 0)
{
HandleDeath();
}
}
변수 인라인하기 (Inline Variable)
// Before
float critMultiplier = 1 + criticalChance * 2.0f;
float damage = baseDamage * critMultiplier;
// After
float damage = baseDamage * (1 + criticalChance * 2.0f);
클래스 인라인하기 (Inline Class)
// Before
class Position
{
public:
float X, Y;
};
class Enemy
{
Position Pos;
};
// After
class Enemy
{
public:
float X, Y;
};
🤔결정 기준
세분화가 적합한 상황 | 통합이 적합한 상황 |
코드가 복잡하고 이해하기 어려울 때 | 추상화가 오히려 복잡성을 증가시킬 때 |
재사용 가능성이 있을 때 | 간단한 로직이 불필요하게 분리되어 있을 때 |
변경 가능성이 높은 부분 | 사용되는 곳이 한 곳뿐인 단순한 코드 |
의도와 구현을 분리할 필요가 있을 때 | 함수/변수 이름이 실제 로직에 가치를 더하지 않을 때 |
항상 가독성과 유지보수성을 최우선으로 고려. 코드는 컴퓨터가 실행하기 위한 것이기도 하지만, 궁극적으로는 다른 개발자들이 읽고 이해하기 위한 것이다.
2. 객체 세분화 vs. 객체 통합 🧩
객체 세분화 기법
매개변수 객체 만들기 (Introduce Parameter Object)
// Before
void CreateQuest(FString title, FString description, int rewardGold, int exp);
// After
struct FQuestData
{
FString Title;
FString Description;
int RewardGold;
int Exp;
};
void CreateQuest(const FQuestData& Quest);
💡 실무 팁: 언리얼 엔진에서는 구조체 앞에 'F' 접두사를, 클래스 앞에는 'U' 또는 'A' 접두사를 붙이는 명명 규칙을 따른다. 이는 타입의 특성을 쉽게 구분할 수 있게 해준다.
단계 쪼개기 (Split Phase)
// Before
void LoadAndDisplayItem()
{
Item item = LoadItemFromDisk("item.json");
RenderItemOnScreen(item);
}
// After
Item LoadItem();
void DisplayItem(const Item& item);
Item item = LoadItem();
DisplayItem(item);
반복문 쪼개기 (Split Loop)
// Before
for(const Item& item : Inventory)
{
item.CalculateWeight();
item.ApplyDurabilityDecay();
}
// After
for(const Item& item : Inventory)
{
item.CalculateWeight();
}
for(const Item& item : Inventory)
{
item.ApplyDurabilityDecay();
}
객체 통합 기법
여러 함수를 클래스로 묶기 (Combine Functions into Class)
// Before
void StartTimer();
void StopTimer();
float GetElapsedTime();
// After
class Timer
{
public:
void Start();
void Stop();
float GetElapsed() const;
private:
float StartTime;
float EndTime;
};
여러 함수를 변환 함수로 묶기 (Combine into Transform Function)
// Before
float GetBaseDamage();
float GetCritBonus();
float GetTotalDamage()
{
return GetBaseDamage() + GetCritBonus();
}
// After
float CalculateTotalDamage()
{
return baseDamage + (baseDamage * critChance * 2.0f);
}
🤔결정 기준
세분화가 적합한 상황 | 통합이 적합한 상황 |
책임이 명확히 분리될 때 | 강한 응집력이 필요할 때 |
다른 용도로 재사용 가능할 때 | 관련 데이터와 동작이 함께 있는 것이 자연스러울 때 |
독립적으로 테스트하고 싶을 때 | 공유 상태에 대한 관리가 필요할 때 |
복잡한 단계를 나누어 명확히 하고 싶을 때 | 여러 작은 함수들이 항상 함께 사용될 때 |
3. 간접 접근 vs. 직접 접근 💊
간접 접근 기법
변수 캡슐화하기 (Encapsulate Variable)
// Before
player->health = 100;
// After
player->SetHealth(100);
void Player::SetHealth(int NewHealth)
{
health = FMath::Clamp(NewHealth, 0, maxHealth);
}
레코드 캡슐화하기 (Encapsulate Record)
// Before
struct PlayerData
{
FString Name;
int Level;
};
PlayerData player;
player.Level = 5; // 아무나 막 건드림
// After
class Player
{
public:
int GetLevel() const { return Level; }
void SetLevel(int NewLevel) { Level = FMath::Max(1, NewLevel); }
private:
int Level;
};
컬렉션 캡슐화하기 (Encapsulate Collection)
// Before
TArray<Item*> Inventory;
Inventory.Add(Sword);
Inventory.Add(Potion);
// After
class InventorySystem
{
public:
void AddItem(Item* NewItem) { Items.Add(NewItem); }
const TArray<Item*>& GetItems() const { return Items; }
private:
TArray<Item*> Items;
};
💡 실무 팁: 언리얼 엔진에서 컬렉션을 다룰 때는 TArray, TMap, TSet 등 언리얼만의 컨테이너를 활용하는 것이 좋다. 이들은 언리얼의 가비지 컬렉션 시스템과 잘 통합되어 있으며, 메모리 관리가 더 효율적이다.
직접 접근 기법
중개자 제거하기 (Remove Middle Man)
// Before
FString name = gameManager->GetPlayerManager()->GetMainPlayer()->GetName();
// After
FString name = gameManager->GetMainPlayerName();
FString GameManager::GetMainPlayerName() const
{
return PlayerManager->GetMainPlayer()->GetName();
}
위임 숨기기 (Hide Delegate)
// Before
FString zip = player.ContactInfo.Address.ZipCode;
// After
FString zip = player.GetZipCode();
FString Player::GetZipCode() const
{
return ContactInfo.Address.ZipCode;
}
🤔결정 기준
간접 접근이 적합한 상황 | 직접 접근이 적합한 상황 |
데이터 검증이나 부가 처리가 필요할 때 | 과도한 래퍼가 복잡성만 증가시킬 때 |
변경 추적이 필요할 때 | 성능이 중요한 핫스팟일 때 |
향후 구현 변경 가능성이 있을 때 | 단순한 데이터 구조에서 |
중복된 접근 로직이 여러 곳에 있을 때 | 위임 체인이 너무 길어질 때 |
캡슐화는 특히 중요...! 게임 개발은 요구사항이 자주 변경되는 분야이므로, 적절한 캡슐화를 통해 내부 구현 변경이 외부 코드에 미치는 영향을 최소화해야 한다.
4. 조건문 vs. 다형성 🔄
조건문 기법
조건문 분해하기 (Decompose Conditional)
// Before
if(player.HasKey() && player.IsAlive() && !player.IsStunned())
{
OpenDoor();
}
// After
bool Player::CanOpenDoor() const
{
return HasKey() && IsAlive() && !IsStunned();
}
if(player.CanOpenDoor())
{
OpenDoor();
}
조건식 통합하기 (Consolidate Conditional Expression)
// Before
if(player.IsDead())
{
return false;
}
if(player.IsDisconnected())
{
return false;
}
return true;
// After
return !(player.IsDead() || player.IsDisconnected());
💡 실무 팁: 언리얼 엔진에서 게임 로직을 작성할 때는 가독성을 위해 복잡한 조건식을 함수로 추출하는 것이 좋다. 특히 블루프린트에서 재사용할 가능성이 있는 조건문은 UFUNCTION(BlueprintPure) 형태로 만들어 두자.
다형성 기법
조건부 로직을 다형성으로 바꾸기 (Replace Conditional with Polymorphism)
// Before
float Enemy::GetDamage()
{
if(Type == "Orc") return Strength * 1.2f;
if(Type == "Goblin") return Strength * 0.8f;
return Strength;
}
// After
class Enemy { virtual float GetDamage() const = 0; };
class Orc : public Enemy
{
float GetDamage() const override { return Strength * 1.2f; }
};
class Goblin : public Enemy
{
float GetDamage() const override { return Strength * 0.8f; }
};
특이 케이스 추가하기 (Introduce Special Case)
// Before
if(player)
{
ShowPlayerName(player->Name);
}
else
{
ShowPlayerName("Guest");
}
// After
class NullPlayer : public Player
{
public:
FString GetName() const override { return "Guest"; }
};
// 사용
ShowPlayerName(player->GetName());
🤔결정 기준
조건문이 적합한 상황 | 다형성이 적합한 상황 |
단순한 분기 로직일 때 | 타입별 동작 차이가 뚜렷할 때 |
일회성이거나 지역적인 결정일 때 | 타입 추가가 자주 발생할 때 |
성능이 매우 중요한 곳일 때 | 타입별 코드가 반복적으로 나타날 때 |
타입 배열이 고정적일 때 | 동작이 확장될 가능성이 높을 때 |
언리얼 엔진에서는 다형성을 활용한 설계가 특히 중요! Actor, Component 등 언리얼의 핵심 시스템이 모두 다형성을 기반으로 하기 때문에, 언리얼의 철학에 맞게 코드를 설계하면 엔진과의 통합이 더 자연스러워지고 편하다.
5. 상속 vs. 위임 💰
상속 기법
메서드 올리기 (Pull Up Method)
// Before
class Orc : public Enemy
{
void Die() { /* 공통 죽음 처리 */ }
};
class Goblin : public Enemy
{
void Die() { /* 공통 죽음 처리 */ }
};
// After
class Enemy
{
public:
virtual void Die() { /* 공통 죽음 처리 */ }
};
필드 올리기 (Pull Up Field)
// Before
class Orc : public Enemy
{
protected:
int Health;
};
class Goblin : public Enemy
{
protected:
int Health;
};
// After
class Enemy
{
protected:
int Health;
};
슈퍼클래스 추출하기 (Extract Superclass)
// Before
class Player
{
FString Name;
FVector Position;
};
class NPC
{
FString Name;
FVector Position;
};
// After
class ActorBase
{
FString Name;
FVector Position;
};
class Player : public ActorBase {};
class NPC : public ActorBase {};
위임 기법
서브클래스를 위임으로 바꾸기 (Replace Subclass with Delegate)
// Before
class Enemy
{
virtual FString GetSound() const = 0;
};
class Orc : public Enemy
{
FString GetSound() const override { return "Roar"; }
};
// After
class SoundBehavior
{
public:
virtual FString GetSound() const = 0;
};
class Enemy
{
TUniquePtr<SoundBehavior> Sound;
FString MakeSound() const { return Sound->GetSound(); }
};
슈퍼클래스를 위임으로 바꾸기 (Replace Superclass with Delegate)
// Before
class UIWidget
{
public:
void Render();
};
class ScoreWidget : public UIWidget
{
void Render() override;
};
// After
class WidgetRenderer
{
public:
void Render();
};
class ScoreWidget
{
private:
WidgetRenderer Renderer;
public:
void Show() { Renderer.Render(); }
};
💡 언리얼 엔진에서 위임 패턴을 구현할 때는 UActorComponent 시스템을 활용하는 것이 좋다. 컴포넌트 기반 설계는 상속보다 유연하며, 런타임에 동적으로 동작을 변경할 수 있는 장점이 있다.
🤔결정 기준
💊다중 상속 쓰지 마라...!
상속이 적합한 상황 | 위임이 적합한 상황 |
명확한 "is-a" 관계가 있을 때 | "has-a" 관계나 행동 공유일 때 |
공통 기능이 많고 타입 계층이 필요할 때 | 런타임에 동작을 변경해야 할 때 |
다형성을 활용한 확장이 자연스러울 때 | 다중 상속과 같은 효과가 필요할 때 |
코드 재사용이 수직적일 때 | 기존 클래스 변경 없이 기능 확장이 필요할 때 |
리팩터링 의사 결정 체크리스트 ✅
- 코드의 목적은 무엇인가? (의도 파악)
- 이 코드가 변경될 가능성은 얼마나 되는가? (변경 가능성)
- 이 코드가 재사용될 가능성은 얼마나 되는가? (재사용성)
- 성능에 민감한 부분인가? (성능 고려)
- 코드의 가독성과 유지보수성을 어떻게 높일 수 있는가? (가독성)
'Dev. > UE 언리얼 엔진' 카테고리의 다른 글
템플릿을 활용한 게임 개발 :: HUD_CharacterSelectPanel, HUD_CharacterInfo, HUD_MapTile (0) | 2025.04.04 |
---|---|
템플릿을 활용한 게임 개발 :: HUD_Menus, MainMenu // BP_WidgetMacros (1) | 2025.04.03 |
[UE/Tip] 유용한 팁 : ⚙️언리얼 엔진 플러그인 만들기 + (0) | 2025.03.27 |
🎮언리얼 엔진 주요 클래스 가이드(복습) (0) | 2025.03.26 |
[TIL_250325] UE5로 숫자 야구 게임 만들기🔄️ (0) | 2025.03.25 |