나만의 언리얼 코딩 표준
이 글을 쓰게된 이유
팀 작업이나 팀 프로젝트를 경험해보고 성공적으로 수행하기 위해서는 모든 팀원이 코드의 일관성을 유지하며 협력할 수 있는 기반이 중요하다고 생각이 들었다.
코딩 표준은 이러한 협업의 핵심 요소같고, 소통을 원활히 해주고 유지보수를 효율적으로 만든다.
내가 할 팀작업에도 사용하기 위해서이다.
출처
이 글은 Epic Games의 Unreal Engine C++ 코딩 표준을 참고하여 작성되었습니다.
에픽게임즈에서 코딩 규칙이 중요한 이유를 알려주는데,
그 중에 유지보수의 중요성, 개발자의 지속성 부족, 가독성의 가치가 와닿았다.
나만의 명명 규칙
일반적으로 파스칼 표기법을 사용하고 스네이크 표기법(언더스코어), 카멜 표기법을 사용하지 않습니다. ex)
AttackDamage는 올바르지만,attackDamage또는attack_damage는 올바르지 않습니다.타입 이름에는 추가적으로 대문자로 이루어진 접두사를 포함하여 변수 이름과 구분합니다. ex)
FWeapon- 템플릿 클래스에는 접두사 T를 포함합니다.
1
class TInfo
- UObject에서 상속하는 클래스에는 접두사 U를 포함합니다.
1
class UWeaponContainer
- AActor에서 상속하는 클래스에는 접두사 A를 포함합니다.
1
class AWeapon
- SWidget에서 상속하는 클래스에는 접두사 S를 포함합니다.
1
class SWeaponWidget
- 추상적 인터페이스인 클래스에는 접두사 I를 포함합니다.
1
class ICombatSystem
- 열거형에는 접두사 E를 포함합니다.
1 2 3 4 5 6
enum class EWeaponType { EWT_Sword, EWT_Spear, EWT_Gun };
- 부울 변수에는 접두사 b를 포함합니다.
1
bool bIdle
- 그 외 대부분의 클래스는 접두사 F를 포함합니다. 그러나 일부 서브시스템은 다른 글자를 사용하기도 합니다.
- Typedef의 경우도 타입에 적합한 접두사를 사용합니다.
ex)typedef TArray<FWeaponData> FWeaponDatas;
변수, 메서드, 클래스 이름은 다음과 같아야 합니다.
- 명확함
- 확실함
- 내용을 파악할 수 있음 이름의 범위가 넓을수록 올바르고 내용을 파악할 수 있는 이름을 사용해야 합니다. 과도한 약어는 피합니다.
부울을 반환할 모든 함수는 Is,Has,Should를 사용해야합니다.
- Is : 상태를 나타낼 때 사용됩니다. 보통 Is는 어떤 조건이 참인지 거짓인지를 묻는 질문 형태로 사용됩니다.
- IsIdle: 캐릭터가 유휴 상태인지 확인합니다.
- IsDead: 캐릭터가 죽었는지 확인합니다.
- IsVisible: 객체가 화면에 보이는지 확인합니다.
- Has : 보유 여부를 확인할 때 사용됩니다. 일반적으로 어떤 속성이나 아이템을 갖고 있는지 체크할 때 사용됩니다.
- HasWeapon: 캐릭터가 무기를 가지고 있는지 확인합니다.
- HasHealth: 캐릭터가 아직 체력이 있는지 확인합니다.
- HasKey: 플레이어가 열쇠를 가지고 있는지 확인합니다.
- Should : 어떤 조건에 따른 예상되는 행동이나 상태를 나타낼 때 사용됩니다. 보통 조건을 만족하면 어떤 동작을 수행해야 할지를 나타냅니다.
- ShouldReload: 총알이 부족할 때 리로드를 해야 하는지 확인합니다.
- ShouldAttack: 공격할 필요가 있는지 확인합니다.
- ShouldSaveGame: 게임을 저장해야 할지 확인합니다.
함수 파라미터 이름에 접두사 “In/Out” 추가 방식
| 구분 | 타입 | 전달 방법 | 설명 |
|---|---|---|---|
| In | const 참조, 값 | const참조(const Type&) | 함수가 파라미터의 값을 수정하지 않고 읽기만 할 때 사용. |
| Out | 참조, 포인터 | Type&또는 Type* | 함수가 파라미터의 값을 수정만 하고, 반환값을 호출자에게 전달할 때 사용. |
| InOut | 참조, 포인터 | Type&또는 Type* | 함수가 파라미터를 수정하고 수정된 값을 호출자에게 전달할 때 사용. |
- In)
1 2 3 4 5 6 7 8 9 10 11
void CheckPlayerStatus(const APlayerCharacter* Player) { if (Player->IsDead()) { UE_LOG(LogTemp, Log, TEXT("Player is dead!")); } else { UE_LOG(LogTemp, Log, TEXT("Player is alive with Health: %d"), Player->GetHealth()); } }
- Out)
1 2 3 4
void GetPlayerLocation(FVector& OutLocation) { OutLocation = GetActorLocation(); // 호출자에게 위치 전달 }
- InOut)
1 2 3 4
void ReducePlayerHealth(int32& Health, int32 Damage) { Health -= Damage; // 체력 감소 }
최신 C++언어 문법
Override 및 Final
상속받은 클래스에서 함수 재정의를 할 때 override를 꼭 사용하고,
자식 클래스에서 가상 함수를 재정의 할 때 virtual 키워드를 사용합니다. (필자는 이게 가독성을 높인다고 생각이 들기때문이다. 사용하지 않아도 똑같이 가상함수로 적용됨)
상속이나 오버라이딩을 제한하는 클래스 및 함수에는 final을 꼭 사용합니다.
Nullptr
nullptr은 모든 경우 C 스타일 NULL 매크로 대신 사용합니다.
Auto
아래 몇 가지 예외를 제외하면 C++ 코드에서 auto 를 사용해서는 안 됩니다. 초기화하려는 타입은 항상 명시해 주어야 합니다. 즉, 읽는 사람에게 타입이 명확하게 보여야 한다는 뜻입니다.
auto 를 사용 가능한 경우는 다음과 같습니다.
- 변수에 람다를 바인딩해야 하는 경우. 람다 타입은 코드로 표현할 수 없기 때문입니다.
- 이터레이터 변수의 경우. 단, 이터레이터 타입이 매우 장황하여 가독성에 악영향을 미치는 경우에 한합니다.
- 템플릿 코드에서 표현식의 타입을 쉽게 식별할 수 없는 경우. 고급 사용 사례입니다.
범위 기반 For
범위 기반 for 문을 사용할 수 없는 경우 ex) 인덱스가 필요할 때
아닌 경우 코드의 가독성과 유지보수성 향상에 도움이 되므로 사용을 권합니다.
- 단순 순회나 읽기 전용 처리에는 범위 기반 for 문을 사용하는 것이 좋습니다.
- 수정이 필요한 경우에도 참조(&)를 이용하면 효율적으로 수정할 수 있습니다.
- 인덱스나 특정 조건에 따른 처리가 필요하다면 전통적인 for문을 사용하는 것이 더 적합할 수 있습니다.
1 2 3 4 5 6 7 8 9 10 11
{ // TArray에 몇 개의 정수값을 저장합니다. TArray<int32> Numbers = { 10, 20, 30, 40, 50 }; // 범위 기반 for 문을 사용하여 TArray의 각 요소를 순회하며 출력합니다. for (const int32& Num : Numbers) { // 각 요소를 출력 UE_LOG(LogTemp, Log, TEXT("Number: %d"), Num); } }
코드 포맷
중괄호
다음 예시와 같이 단일 구문 블록에도 항상 중괄호를 포함시켜 주세요.
1
2
3
4
if(bIdle)
{
return;
}
If-Else
각 if 또는 else 문에서 실행할 블록은 중괄호 {}로 반드시 묶어야 합니다. 중괄호를 사용하지 않으면, 코드 작성 시 의도치 않은 오류가 발생할 수 있습니다. 예를 들어, 중괄호 없이 작성된 코드에서 새로운 줄을 추가하면, 그 줄은 if/else 블록 외부에 포함되어 예상치 못한 동작을 초래할 수 있습니다. 이를 방지하려면 항상 중괄호를 사용하세요.
1
2
3
4
5
6
7
8
if(bDead)
{
PlayDead();
}
else
{
TakeDamage();
}
if 문에 여러 else if와 else가 포함될 때는 각각의 블록이 동일한 들여쓰기를 갖도록 하여 코드의 구조가 명확하게 보이도록 해야 합니다. 들여쓰기를 일관되게 유지하면, 코드의 흐름을 쉽게 이해할 수 있습니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
if (HP < 10)
{
UE_LOG(LogCategory, Log, TEXT("Low HP"));
}
else if (HP < 100)
{
UE_LOG(LogCategory, Log, TEXT("Medium HP"));
}
else
{
UE_LOG(LogCategory, Log, TEXT("High HP"));
}
탭 및 들여쓰기
1. 실행 블록별 들여쓰기
- 각 실행 블록은 반드시 들여쓰기를 해야 합니다. 들여쓰기를 통해 코드의 계층 구조를 명확히 나타낼 수 있습니다. 예를 들어,
if,else,for,while,switch등에서의 코드 블록은 실행 블록 안의 코드를 들여쓰기로 구분해야 합니다.
2. 탭 사용
- 줄 시작 부분의 공백에는 스페이스 대신 탭을 사용합니다. 이 표준은 코드 작성자가 탭 크기를 설정한 만큼 들여쓰기를 하도록 보장합니다. 탭 크기는 4자로 설정합니다. 이는 대부분의 개발 환경에서 기본으로 설정되는 크기이며, 이를 기준으로 코드가 정렬됩니다.
3. 탭과 스페이스 혼용
- 탭을 기본으로 사용하지만, 때때로 스페이스를 사용할 필요가 있을 수 있습니다. 예를 들어, 탭을 사용했을 때 코드 줄을 맞추기 어려운 경우가 있을 수 있습니다. 이런 경우에는 스페이스를 사용할 수 있습니다.
Switch 문 사용
1. 빈 케이스 제외
- 빈 케이스(동일한 코드를 갖는 다중 케이스)는 피하는 것이 좋습니다. 각 케이스는 독립적으로 다뤄져야 하며, 중복 코드를 피해야 합니다.
2. 명시적인 ‘break’ 또는 ‘falls through’
switch문에서 다음 케이스로 넘어갈 때는 명시적으로 밝혀줘야 합니다. 예를 들어,break문을 사용하거나,// falls through와 같은 주석을 달아야 합니다. 이는 코드 흐름을 명확하게 이해할 수 있도록 도와줍니다.
3. 디폴트 케이스 추가 디폴트 케이스는 항상 포함시켜야 합니다. 디폴트 케이스는 새로운 케이스가 추가될 때, 예상하지 못한 값에 대한 처리를 할 수 있게 해 줍니다. 디폴트 케이스 뒤에 break를 추가하여 다른 케이스가 추가되더라도 예상치 못한 동작을 방지할 수 있습니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
switch (EState)
{
case 1:
// 실행 코드
// falls through
case 2:
// 실행 코드
break;
case 3:
// 실행 코드
return; // 조건에 맞는 값을 반환
case 4:
case 5:
// 실행 코드 (case 4와 5가 동일한 처리를 할 때)
break;
default:
// 기본 처리
break;
}
캡슐화
캡슐화는 객체 지향 프로그래밍에서 중요한 개념으로, 데이터(변수)와 그 데이터를 처리하는 메소드(함수)를 하나의 단위로 묶어 외부에서 직접 접근할 수 없도록 보호하는 기법입니다. 이를 통해 데이터의 무결성을 보장하고, 외부에서 직접 데이터를 변경하는 것을 방지하여 안전하고 예측 가능한 코드를 만들 수 있습니다.
Getter는 객체의 private 또는 protected 필드 값을 읽는 메소드이고, Setter는 해당 필드 값을 설정하는 메소드입니다. 외부에서 객체의 상태를 직접 수정하는 대신, getter와 setter를 통해 값을 읽고 쓸 수 있게 하여 객체의 상태를 제어합니다.
사용 이유:
- 데이터 보호: 객체의 필드를 직접 수정하는 것을 방지하여, 그 값이 유효한 범위 내에서만 변경되도록 제어할 수 있습니다.
- 유효성 검증: 데이터를 설정할 때 유효성 검사를 수행하거나, 값이 변경될 때마다 추가 작업을 수행할 수 있습니다.
- 유지보수 용이: 필드의 내부 구현을 변경해도 Getter와 Setter 메소드만 수정하면 되므로, 외부 코드에 영향을 미치지 않습니다.
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
class AMyCharacter : public ACharacter
{
GENERATED_BODY()
private:
// 내부 변수 (private)
int32 Health;
public:
// Getter: Health 값을 반환
int32 GetHealth() const
{
return Health;
}
// Setter: Health 값을 설정
void SetHealth(int32 NewHealth)
{
// 유효성 검사: Health 값이 0 이상이어야 한다
if (NewHealth >= 0)
{
Health = NewHealth;
}
else
{
UE_LOG(LogTemp, Warning, TEXT("Health cannot be negative"));
}
}
};
클래스 정렬 순서 및 묶음
이 정렬 순서는 필자의 개인적인 가독성 향상을 위한 방식입니다. public -> protected -> private 순으로 작성하는 것을 추천합니다.
- public 섹션
- 생성자는 객체 생성 및 초기화 과정을 한눈에 확인할 수 있도록 클래스의 가장 상단에 배치합니다.
- Getter/Setter 메소드는 객체의 상태를 외부에서 쉽게 접근하고 수정할 수 있게 도와주기 때문에, public 섹션의 상위에 배치하는 것이 효과적입니다.
- 기능 관련 메소드는 클래스의 핵심 동작을 담당하므로 public 섹션에서 중요한 순서로 배치합니다. 이러한 메소드들은 클래스의 주된 역할을 수행하는 메소드들입니다.
- 인터페이스 메소드는 상대적으로 중요도가 낮은 메소드일 수 있지만, 외부와의 상호작용을 담당하므로 기능 관련 메소드 아래에 배치합니다.
- RPC 메소드는 네트워크와 관련된 메소드로 중요한 역할을 하지만, 인터페이스 메소드와 유사한 카테고리로 묶을 수 있습니다.
- protected 섹션
- protected 메소드는 클래스 내에서만 접근 가능하며, 상속된 클래스에서만 사용할 수 있습니다. 이 섹션에는 클래스의 핵심 기능에 관계된 메소드가 주로 위치합니다.
- protected 섹션은 클래스의 내부 구현에 중요한 메소드들을 배치하는 곳입니다. 이러한 메소드들은 다른 클래스에서는 접근할 수 없지만, 상속받은 클래스에서는 필요한 기능을 구현하는 데 사용됩니다.
- private 섹션
- 변수 (Private 변수)
private섹션에는 객체의 상태를 관리하는 중요한 데이터들이 위치합니다. 이 데이터는 클래스 외부에서 직접 접근하거나 수정할 수 없고, 메서드를 통해 간접적으로 조작됩니다.- 예시로,
PawnExtComponent,HealthComponent,CameraComponent와 같은 변수들은 객체 상태를 나타내며, 외부에서 수정될 수 없게private으로 선언됩니다.
- 델리게이트 관련 메서드
- 델리게이트(Delegate)는 특정 이벤트나 상태 변경을 외부에 전달하는 방법입니다. 델리게이트 메서드는 클래스 내부에서 특정 조건이 발생했을 때 다른 객체나 클래스에 알리기 위해 사용됩니다.
OnTeamChangedDelegate와 같은 델리게이트는 주로 게임의 특정 이벤트에 반응하거나 UI 업데이트, 상태 변화를 반영하는 데 사용됩니다.
OnRep_메서드 (네트워크 복제 관련 메서드)OnRep_메서드는 네트워크 복제에서 변수가 변경될 때 호출되는 특별한 메서드입니다. 이 메서드는 변수가 네트워크에서 복제되었을 때 클라이언트 쪽에서 상태를 업데이트하거나 UI/애니메이션 등을 갱신할 때 사용됩니다.OnRep_ReplicatedAcceleration,OnRep_MyTeamID는 각각 복제된 데이터에 대한 처리를 담당하는 메서드로, 이를 통해 네트워크에서 상태가 일관되게 유지됩니다.
- 기타 헬퍼 메서드
private섹션에 선언된 기타 헬퍼 메서드들은 특정 기능을 캡슐화하여 외부에서 접근하지 않도록 하며, 객체의 내부적인 작업을 처리합니다.- 예를 들어,
OnControllerChangedTeam메서드는 팀 변경 이벤트를 처리하고, 이와 관련된 로직을 내부적으로 관리합니다.
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
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
UCLASS()
class MYGAME_API AMyCharacter : public AActor
{
GENERATED_BODY()
public:
// 1. 생성자 (상단에 위치)
AMyCharacter();
// 2. 객체 상태 관련 메소드 (Getter/Setter)
UFUNCTION(BlueprintCallable, Category = "Character")
FVector GetCharacterLocation() const;
UFUNCTION(BlueprintCallable, Category = "Character")
void SetCharacterHealth(float Health);
// 3. 기능 관련 메소드
UFUNCTION(BlueprintCallable, Category = "Character")
void Jump();
UFUNCTION(BlueprintCallable, Category = "Character")
void Attack();
// 4. 인터페이스 메소드
virtual void OnInteract() override;
// 5. RPC 메소드
UFUNCTION(Server, Reliable, WithValidation)
void ServerMoveCharacter(const FVector& NewLocation);
protected:
// 보호된 메소드
virtual void BeginPlay() override;
virtual void Tick(float DeltaTime) override;
// 팀 관련 메소드 (예시)
void SetTeamID(int32 NewTeamID);
private:
// 1. 개인적인 변수들
UPROPERTY()
UMyAbilityComponent* AbilityComponent;
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Character", meta = (AllowPrivateAccess = "true"))
float Health;
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Character", meta = (AllowPrivateAccess = "true"))
FVector CharacterLocation;
private:
// 델리게이트 관련 메서드 (주로 이벤트 처리)
UFUNCTION()
void OnControllerChangedTeam(UObject* TeamAgent, int32 OldTeam, int32 NewTeam);
// 네트워크 복제 관련 메서드
UFUNCTION()
void OnRep_ReplicatedAcceleration();
UFUNCTION()
void OnRep_MyTeamID(FGenericTeamId OldTeamID);
};