좋은 코드 작성하는 법
긴 매개변수 목록 (Long Parameter List)
- 매개변수가 많으면 함수를 이해하고 쓰기가 너무 불편해짐.
- 필요한 정보만 간결하게 전달할 수 있도록 묶거나 축소하자.
- 중복된 정보가 있는지 확인하고, 불필요한 인수는 제거하면 됨.
나쁜 예시
void InitWeapon(FString Name, float Damage, float FireRate, int32 AmmoCount, float ReloadTime, USkeletalMesh* Mesh, USoundBase* Sound)
{
// 와, 많다 ...
}
InitWeapon("AK47", 42.0f, 0.25f, 30, 2.5f, MeshAsset, FireSound);
좋은 예시
// 구조체로 묶자
struct FWeaponData
{
FString Name;
float Damage;
float FireRate;
int32 AmmoCount;
};
// 구조체로 또 묶자
struct FWeaponAssets
{
USkeletalMesh* Mesh;
USoundBase* Sound;
};
void InitWeapon(const FWeaponData& InData, const FWeaponAssets& InAssets)
{
// 훨씬 깔끔!
}
// 이제 이렇게 호출해서 쓰면 됨
FWeaponData WeaponInfo = { "AK47", 42.0f, 0.25f, 30, 2.5f };
FWeaponAssets Assets = { MeshAsset, FireSound };
InitWeapon(WeaponInfo, Assets);
전역 데이터의 남용 (Global Data)
- 전역 데이터의 남용은 프로그램의 악취중 가장 독한 악취 중의 하나
- 어디서든 접근 가능해 디버깅과 유지보수가 복잡해짐.
- 값이 바뀔 때 추적이 어려워 에러가 숨어들기 쉬움.
- 데이터 범위를 최소화하고, 꼭 필요한 곳에서만 사용하도록 통제하자.
나쁜 예시
// 글로벌 관리자
UGameManager* GGameManager; // 전역 변수!
// 아무 함수에서나 직접 접근해 값 변경
void IncreaseScore()
{
GGameManager->Score += 10;
}
좋은 예시
// 언리얼 Subsystem을 이용한 예
UCLASS()
class UScoreSystem : public UGameInstanceSubsystem
{
GENERATED_BODY()
private:
int32 Score;
public:
void AddScore(int32 Amount)
{
Score += Amount;
// 점수가 변경됐음을 알리는 로직
}
int32 GetScore() const { return Score; }
};
// 사용은 이렇게 함.
void AEnemy::OnDefeated()
{
if (UGameInstance* GI = GetGameInstance())
{
// GetSubsystem<UScoreSystem>() 쓰는 곳만 접근 가능
if (UScoreSystem* ScoreSys = GI->GetSubsystem<UScoreSystem>())
{
ScoreSys->AddScore(50);
}
}
}
- Subsytem 공식 문서: https://dev.epicgames.com/documentation/ko-kr/unreal-engine/programming-subsystems-in-unreal-engine
1. 접근 경로의 명확성 (Traceability)
단순 전역 변수는 GGameManager->Score = 100; 처럼 아무 데서나 값을 수정할 수 있어 범인을 찾기 어렵습니다.
- 서브시스템 방식: 반드시 GetSubsystem<UScoreSystem>()을 거쳐야 합니다. 이 호출부만 검색해도 이 데이터를 건드리는 모든 코드를 한눈에 파악할 수 있습니다.
2. 캡슐화의 강제 (Encapsulation)
전역 변수는 데이터 자체가 노출되지만, 서브시스템은 클래스이므로 Setter/Getter나 Delegate를 활용해 데이터 변화를 감시할 수 있습니다.
// 서브시스템 내부
void AddScore(int32 Amount) {
Score += Amount;
OnScoreChanged.Broadcast(Score); // 값이 바뀔 때 이벤트를 날려 디버깅을 쉽게 함
}
3. 생명 주기의 자동화 (Cleanup)
여기서 가장 끔찍한 점은 "이전 게임의 데이터가 다음 게임에 남아있는 것"입니다.
- 전역 변수: 수동으로 초기화 안 하면 이전 판 점수가 그대로 남습니다.
- 서브시스템: 맵이 바뀌거나 게임이 꺼지면 엔진이 인스턴스 자체를 파괴하고 새로 만듭니다. '데이터 오염'을 원천 봉쇄합니다. 서브시스템은 게임 인스턴스와 다르게 생명주기라는 게 있음. 즉 서브시스템은 생명주기가 있는 게임 인스턴스라고 생각하면 됨. 이거를 사용해서 원하는 순간에 초기화가 될 수 있도록 만들어주는
가변 데이터 (Mutable Data)
- 값이 자주 바뀌면 예기치 못한 오류나 복잡도가 증가한다.
- 변경 가능한 범위를 최소화하고, 가급적 불변 데이터를 활용하자.
- 수정이 필요한 부분을 명확히 나누는 습관
나쁜 예시
class APlayerCharacter : public ACharacter
{
public:
// 마음대로 바꿀 수 있는 공공재(!)
float Health;
int32 Level;
};
void SomeRandomFunc(APlayerCharacter* Player)
{
Player->Health = 99999.f;
Player->Level = 999;
// 이걸 발견하면, 팀원들 열받음.
}
좋은 예시
class APlayerCharacter : public ACharacter
{
private:
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category="Stats") // 언리얼 예시
float Health;
int32 Level;
public:
float GetHealth() const { return Health; }
int32 GetLevel() const { return Level; }
void TakeDamage(float Amount)
{
Health = FMath::Max(0.0f, Health - Amount);
// 데미지 받은 로직은 여기에만!
}
void LevelUp()
{
Level++;
Health = 100.f * Level;
}
};
TObjectPtr
TObjectPtr은 UE5에서 UObject* 원시 포인터를 대체하기 위해 도입된 템플릿 기반 포인터 래퍼다.
핵심 기능은 지연 로딩(Lazy Loading)과 액세스 트래킹 두 가지다.
지연 로딩은 수만 개의 에셋이 있는 프로젝트를 열었을 때 모든 에셋을 한꺼번에 메모리에 올리지 않도록, 실제로 접근하는 시점에 로드를 미루는 기능이다. 접근 시점이란 멤버 함수 호출, 다른 변수에 대입, 조건문 검사 등 코드가 실제로 해당 객체를 건드리는 순간이다.
MyMesh->GetName(); // 멤버 함수 호출 시 로드
UStaticMesh* Raw = MyMesh; // 대입 시 로드
if (MyMesh != nullptr) { } // 조건 검사 시 로드
액세스 트래킹은 가비지 컬렉션과 연결된 기능이다.
언리얼의 GC는 UPROPERTY로 선언된 변수들을 추적해서 어떤 객체가 사용 중인지 판단하는데, TObjectPtr은 여기에 더해 에디터 내에서 이 객체가 어디서 참조되고 있는지를 더 정밀하게 추적할 수 있게 해준다.
여기서 로컬 변수나 매개변수에 TObjectPtr을 쓰지 않아도 되는 이유가 나온다.
로컬 변수는 이미 메모리에 올라온 객체를 잠깐 가리키는 경우가 대부분이고, 함수가 끝나면 사라지므로 GC 추적 대상도 아니다.
지연 로딩이나 액세스 트래킹이 개입할 이유가 없기 때문에 원시 포인터가 더 적합하다.
// UPROPERTY 멤버 변수 — TObjectPtr 권장
UPROPERTY(EditAnywhere)
TObjectPtr<USceneComponent> RootComponent;
// 로컬 변수 / 매개변수 — 원시 포인터로 충분
USceneComponent* Temp = GetRootComponent();
주의할 점으로, TObjectPtr은 성능 최적화 도구가 아니다.
최종 패키징 시 자동으로 원시 포인터로 변환되며 지연 로딩, 액세스 트래킹 기능도 함께 사라진다.
에디터와 개발 단계에서의 불확실성에 대비하는 도구로 이해하는 게 맞다.
런타임 최적화가 필요하다면 레벨 스트리밍이나 소프트 포인터를 사용한다.
TArray 같은 컨테이너를 순회할 때는 auto* 대신 auto&를 쓰는 습관을 들이는 게 좋다.
// auto* — 매번 내부 로직 실행 (로드 확인, 주소 계산 후 복사)
for (auto* Component : Components) { }
// auto& — TObjectPtr 자체를 직접 참조, 내부 로직 중복 실행 없음
for (auto& Component : Components) { }
포인터는 주소값을 복사해오는 것이고 레퍼런스는 원본을 직접 가리키는 것이라는 차이를 생각하면, auto* 방식은 배열 원소에서 매번 주소를 새로 뽑아내는 과정이 생기는 반면 auto&는 배열 안의 TObjectPtr 그 자체를 그대로 참조하므로 불필요한 내부 로직이 생략된다.
TObjectPtr이 담긴 컨테이너 순회할 때는 auto& 사용하자!
TSubclassOf
TSubclassOf는 UClass를 대체하기 위해 사용하는 템플릿 래퍼로, 특정 클래스와 그 하위 클래스만 담을 수 있도록 범위를 제한한 바구니다.
앞서 다룬 TObjectPtr이 레벨에 배치된 인스턴스(오브젝트)를 담는 것이었다면, TSubclassOf는 콘텐츠 브라우저에 있는 클래스 그 자체를 담는다.
SpawnActor를 호출할 때 어떤 클래스를 스폰할지 지정하는 Class 핀에 꽂아주는 값이 바로 이것이다.
// UE4 방식 — 아무 클래스나 들어올 수 있음
UPROPERTY(EditAnywhere)
UClass* MyClass;
// UE5 권장 방식 — UActorComponent 하위 클래스만 허용
UPROPERTY(EditAnywhere)
TSubclassOf<UActorComponent> MyClass;
UClass로 받으면 에디터 블루프린트 단에서 관계없는 클래스까지 전부 노출되어 잘못된 클래스를 고를 가능성이 생긴다. TSubclassOf를 쓰면 템플릿 인자로 지정한 클래스의 하위 클래스만 필터링되어 노출된다.
타입 안전성은 두 단계에서 작동한다. 빌드 시점에는 맞지 않는 타입을 넣으려 하면 컴파일 자체가 실패하고, 런타임에는 잘못된 값이 들어오면 nullptr로 처리한다.
코드에서 특정 클래스를 직접 지정해서 넣고 싶을 때는 StaticClass()를 사용한다.
// AActor 클래스를 직접 지정해서 스폰
GetWorld()->SpawnActor<AActor>(AActor::StaticClass());
StaticClass()는 블루프린트 핀에 클래스를 직접 꽂아주는 것과 같은 동작이라고 보면 된다.
TArray / TSet / TMap
언리얼 엔진의 컨테이너는 세 가지가 주축이다.
어떤 걸 쓸지는 구조에서 오는 성질 차이를 이해하면 자연스럽게 판단이 된다.
TArray는 메모리가 연속으로 붙어있는 구조다.
C++ STL의 vector와 동일하다고 보면 된다.
인덱스로 바로 접근할 수 있는 이유가 여기 있다.
1번 인덱스를 달라고 하면 시작 주소에서 한 칸 건너뛰면 끝이라 O(1)이다.
반면 중간 삽입이나 삭제는 뒤에 있는 원소를 전부 밀거나 당겨야 하므로 O(n)이 된다.
탐색도 정렬이 보장되지 않으면 처음부터 끝까지 순회해야 하니 O(n)이다.
맨 뒤에 붙이는 Add/Push는 이 단점에서 자유롭다.
TSet과 TMap은 해시 기반 구조다.
해시는 키를 계산식에 넣으면 해당 값이 어디 있는지 바로 알 수 있는 구조다.
사물함 번호를 알고 있으면 다른 칸을 열어볼 필요가 없는 것과 같다.
탐색, 삽입, 삭제가 모두 O(1)인 이유다.
대신 해시 구조는 메모리가 연속적이지 않다.
TSet에서 원소를 삭제하면 그 자리에 구멍이 생기고, 새 원소를 추가할 때 그 구멍을 재사용한다.
그래서 순서가 보장되지 않고 인덱스 접근이 불가능하다.
TSet은 여기에 중복 방지가 추가된다. 동일한 값이 들어오면 무시한다.
TMap은 Key-Value 쌍으로 저장한다. Key로 Value를 바로 꺼낼 수 있다. 구조 자체는 TSet과 같다.
| 구분 | TArray | TSet / TMap |
|------------|----------------|-------------------|
| 접근 | O(1) 인덱스 | O(1) 해시 계산 |
| 탐색 | O(n) | O(1) |
| 삽입 | O(n) 중간 삽입 | O(1) |
| 삭제 | O(n) | O(1) |
| 순서 보장 | O | X |
| 중복 허용 | O | X (TSet만 방지) |
| 인덱스접근 | O | X |

- 순서가 중요하거나, 인덱스로 접근해야 하거나, 순회가 많으면 → TArray
- "이거 있어?" 를 빠르게 확인하거나, 중복을 막아야 하면 → TSet
- Key로 Value를 바로 꺼내야 하면 → TMap
- 게임 예시로 보면, 인벤토리 슬롯 순서가 중요하면 TArray
- 이미 획득한 아이템 목록(중복 방지)은 TSet
- 아이템 ID로 아이템 정보를 바로 찾아야 하면 TMap
AddUnique는 배열 전체를 순회해서 중복을 확인하므로 O(n)이다. 중복 방지가 필요하다면 TSet을 쓰는 게 맞다.
TArray
앞에 붙는 T는 템플릿의 약자로, 어떤 타입이 들어와도 동작하도록 설계되었다는 의미다.
초기화는 Init으로 한다. 아래는 10으로 채워진 크기 5짜리 배열을 만들고 전부 출력하는 예시다.
TArray<int32> IntArray;
IntArray.Init(10, 5);
for (const int32 i : IntArray)
{
GEngine->AddOnScreenDebugMessage(-1, 100.f, FColor::Green, FString::FromInt(i));
}
삽입은 Add와 Emplace 두 가지가 있다.
IntArray.Add(5);
IntArray.Emplace(6);
Add는 값을 복사해서 넣고, Emplace는 복사 없이 바로 생성한다.
성능만 보면 Emplace가 유리하지만 내부에서 생성되는 방식이라 의도치 않은 암시적 변환이 개입될 여지가 있다.
Add는 이미 존재하는 객체를 복사하는 것이라 동작이 명확하다.
엔진 가이드라인은 성능을 쥐어짜야 하는 부분에 Emplace, 그렇지 않은 곳에 Add를 권장한다.
Insert는 지정한 인덱스 위치에 값을 넣는다.
IntArray.Insert(500, 3); // 3번 인덱스에 500 삽입
원소 개수는 Num()으로 확인하고, 인덱스 기반 순회에도 활용한다.
IntArray.Num(); // 원소 개수 반환
for (int32 i = 0; i < IntArray.Num(); i++) { }
인덱스로 직접 접근하는 것도 가능하다. TSet과 TMap은 이게 안 된다.
IntArray[5] = 1000000;
탐색은 두 가지 방향이 있다.
IntArray.Find(값); // 인덱스 반환, 없으면 -1
IntArray.Contains(값); // true / false 반환
FilterByPredicate는 조건에 맞는 원소만 골라 새 TArray로 반환한다.
인벤토리처럼 조건 필터링이 자주 필요한 곳에 유용하다.
TArray<int32> Filtered =
IntArray.FilterByPredicate([](const int32 Value) { return Value < 9; });
정렬은 기본 오름차순과 람다를 이용한 커스텀 정렬이 가능하다.
IntArray.Sort(); // 오름차순
IntArray.Sort([](int32 A, int32 B) { return A > B; }); // 내림차순
Sort의 두 번째 방식은 Predicate(조건 함수)를 받는다.
람다는 함수를 별도로 정의하지 않고 그 자리에서 바로 쓰는 방법이다.
(람다를 쓰지 않으면 아래처럼 구조체를 따로 만들어야 한다.
struct FMyCustomSorter
{
bool operator()(const int32 A, const int32 B) const
{
return A > B;
}
};
람다의 대괄호는 캡처 목록으로, 람다 바깥의 변수를 가져올 때 사용한다.)
[&]는 외부 변수를 참조로 가져온다. [=]는 외부 변수를 복사로 가져온다.
비워두면 외부 변수를 캡처하지 않고 매개변수만 사용한다.
[this]는 클래스 멤버 변수에 명시적으로 접근할 때 사용한다.
삭제 함수는 기준이 세 가지로 나뉜다.
IntArray.Remove(10); // 값 기준, 10 전부 삭제
IntArray.RemoveSingle(10); // 값 기준, 처음 찾은 10 하나만 삭제
IntArray.RemoveAt(인덱스); // 인덱스 기준 삭제
IntArray.RemoveAll([](int32 Value) { return Value == 5; }); // 조건 기준 전부 삭제
IntArray.Empty(); // 전체 삭제
RemoveAt은 잘못된 인덱스를 건드릴 수 있으므로 IsValidIndex로 먼저 확인하는 습관이 필요하다.
if (IntArray.IsValidIndex(Index))
{
IntArray.RemoveAt(Index);
}
실습
TArray를 하나 만들어서 이곳에 30개의 내용물이 찰 때까지 랜덤값을 넣어줄 겁니다.
FMath::RandRange(0,1000)
TArray에 들어갈 수 있는 조건이 있습니다. 값이 100 이하여야 합니다.
그리고 다 넣으셨다면 마지막에 50이라는 숫자보다 작으면 지워주고 순서대로 정렬해주세요.
실습 정답
100 이하인 값만 골라 30개를 채우고, 50 미만을 제거한 뒤 오름차순 정렬한다.
while (IntArray.Num() < 30)
{
int32 RandomValue = FMath::RandRange(0, 1000);
if (RandomValue <= 100)
{
IntArray.Add(RandomValue);
}
}
IntArray.RemoveAll([](int32 Value)
{
return Value < 50;
});
IntArray.Sort();
TSet
추가는 TArray와 동일하게 Add를 사용한다.
TSet<int32> IntArraySet;
IntArraySet.Add(100);
탐색은 Contains와 Find 두 가지다.
TArray의 Find는 인덱스를 반환했지만, TSet은 인덱스가 없으므로 포인터를 반환한다.
if (IntArraySet.Contains(20)) { } // true / false 반환
int32* FoundPtr = IntArraySet.Find(20); // 해당 값의 포인터 반환, 없으면 nullptr
순회는 Iterator를 사용한다.
TSet은 메모리가 연속적이지 않아서 다음 값이 있는 곳을 찾아가야 하기 때문에 컨테이너 전용 포인터인 Iterator가 필요하다.
for (TSet<int32>::TConstIterator It = IntArraySet.CreateConstIterator(); It; ++It)
{
GEngine->AddOnScreenDebugMessage(-1, 100.f, FColor::Green,
FString::Printf(TEXT("Number : %d"), *It));
}
타입이 길어서 auto로 대체할 수 있다.
다만 auto는 타입이 명확한 경우에만 제한적으로 쓰는 것이 원칙이고, 언리얼 공식 문서도 확실한 경우가 아니면 지양하도록 안내한다. Iterator 순회처럼 타입이 명백한 경우가 허용 범위에 해당한다.
순회 중 수정이 필요하면 TConstIterator 대신 TIterator를 사용한다.
RemoveCurrent()로 현재 위치의 원소를 삭제할 수 있다.
for (TSet<int32>::TIterator It = IntArraySet.CreateIterator(); It; ++It)
{
if (*It < 60)
{
It.RemoveCurrent();
}
GEngine->AddOnScreenDebugMessage(-1, 100.f, FColor::Green,
FString::Printf(TEXT("Number : %d"), *It));
}
집합 연산은 두 가지를 지원한다.
TSet<int32> SetA = { 1, 2, 3 };
TSet<int32> SetB = { 3, 4, 5 };
TSet<int32> SetC = SetA.Intersect(SetB); // 교집합 — 공통 원소만 추출 {3}
TSet<int32> SetD = SetA.Union(SetB); // 합집합 — 중복 없이 전부 합침 {1,2,3,4,5}
삭제를 반복하면 구멍이 쌓여 쓸모없는 메모리 공간이 늘어난다. Compact와 Shrink로 정리한다.
SetC.Compact(); // 구멍을 뒤로 모아 빈 공간을 한곳에 집결
SetC.Shrink(); // 모인 빈 공간을 메모리에서 반납
TMap
TMap은 Key와 Value를 쌍으로 저장하는 컨테이너다.
사물함에 비유하면, Key는 사물함 번호이고 Value는 그 안의 내용물이다.
Key만 알고 있으면 값을 즉시 꺼낼 수 있다.
추가는 Add와 Emplace를 사용한다. 중복 Key가 들어오면 기존 Value를 덮어쓴다.
TMap<int32, FString> ItemMap;
ItemMap.Add(101, TEXT("Sword"));
ItemMap.Add(102, TEXT("Shield"));
ItemMap.Emplace(103, TEXT("Potion"));
탐색은 세 가지 방식이 있다.
Contains와 Find를 조합하는 안전한 방식이다.
Find는 Value의 포인터를 반환하고, 없으면 nullptr을 반환한다.
if (ItemMap.Contains(101))
{
FString* FoundItem = ItemMap.Find(101);
if (FoundItem)
{
GEngine->AddOnScreenDebugMessage(-1, 100.f, FColor::Green,
FString::Printf(TEXT("Found : %s"), *(*FoundItem)));
}
}
FindOrAdd는 Key가 없으면 빈 값으로 생성해서라도 반환한다.
FString& ItemRef = ItemMap.FindOrAdd(104);
ItemRef = TEXT("Bow");
GEngine->AddOnScreenDebugMessage(-1, 100.f, FColor::Green,
FString::Printf(TEXT("Found : %s"), *ItemRef));
[] 연산자는 Key가 없으면 즉시 크래시가 나므로 Contains로 먼저 확인하는 것이 필수다.
// Key가 없으면 크래시
FString Name = ItemMap[105];
// 안전한 방식
if (ItemMap.Contains(105))
{
FString Name = ItemMap[105];
}
순회는 범위 기반 for문과 Iterator 두 가지 방식이 있다.
// 범위 기반 for
for (const TPair<int32, FString>& i : ItemMap)
{
GEngine->AddOnScreenDebugMessage(-1, 100.f, FColor::Green,
FString::Printf(TEXT("Key : %d Value : %s"), i.Key, *i.Value));
}
// Iterator
for (TMap<int32, FString>::TIterator It = ItemMap.CreateIterator(); It; ++It)
{
if (It.Key() == 103)
{
It.RemoveCurrent();
}
GEngine->AddOnScreenDebugMessage(-1, 100.f, FColor::Green,
FString::Printf(TEXT("Key : %d Value : %s"), It->Key, *It->Value));
}
두 방식 모두 순회가 가능하지만, 순회 중 삭제가 필요할 때는 반드시 Iterator를 사용해야 한다.
일반 순회 중 삭제를 하면 다음 값을 찾지 못해 길을 잃을 수 있기 때문이다.
Iterator는 삭제 후에도 다음 위치를 안전하게 추적하는 가이드라인 역할을 한다.
삭제는 Key 기준으로 한다.
ItemMap.Remove(102);
삭제가 반복되면 구멍이 쌓이므로 TSet과 동일하게 Compact와 Shrink로 정리한다.
ItemMap.Compact(); // 빈 공간을 뒤로 모음
ItemMap.Shrink(); // 모인 빈 공간을 메모리에서 반납
'학습 > Unreal' 카테고리의 다른 글
| Dedicated Server, 서버-클라이언트 구조의 핵심 특징 (0) | 2026.06.17 |
|---|---|
| TArray, TMap, TSet 사용해서 가방에 아이템 담기 (0) | 2026.06.09 |
| 언리얼 접두사 규칙과 모듈 구조 (1) | 2026.06.02 |
| 리플렉션, GC, CDO, 메모리 (0) | 2026.05.28 |
| 언리얼 C++ 헤더 파일 패턴: USTRUCT, UENUM, 비트플래그, 델리게이트 (0) | 2026.05.26 |
