프로젝트 명 : GunFire_Paragon
개발 일정 : 2025.02.17 ~ 2025.03.07 (3Weeks)
개발 장르 : FPS, 로그라이크
개발 환경 :
- C++ 17 (v14.38)
- MSVC v143
- Unreal Engine 5.5
- Visual Studio 2022
- Git LFS
개발 특징 :- Unreal 3D Shooting Game
- 객체 프로그래밍 형식 OOP 기반
- 추후 멀티플레이어 확장성까지 고려한 프로그래밍
- Git Lfs와 Cmd Git, Git Convention 을 활용한 협업구조 형성 (Rebase Merge 형식)
- Object Pooling 및 GC를 통한 최적화된 코드 및 효율적임 메모리 사용
내가 프로젝트에서 맡은 역할은 게임 기획, GameMode전반 로직 구현 (GameState, GameMode, GameInstance), PortalClass(ClearPortal, TrapPortal 구현), PassiveCard 구현, 맵 디자인을 담당하였다.
👉 GunfireReborn 게임을 모티브로 하여 기획하였다.
나의 작업 브랜치 : https://github.com/NbcampUnreal/1st-Team1-CH3-Project/tree/gamemode/04
👉 Map Design / Enemy Object Pooing(DataTable) / GameInstance(Level Conversion Property Save & Load) / Portal Implement / GameReward System
언리얼 내의 ModelingMode
를 활용하여 디자인
언제든지 원하는 Texture과 Material을 적용할 수 있어서 외부 Assets 과 톤 앤 매너를 유지하는데 더 자유로움
생각보다 AI와 Player이 맵이 끼는 버그가 자주 발생
CubeGrid를 적용할 때 액터를 선택한 뒤 추가하거나 삭제하면 Static Mesh에 곧바로 적용됨.
뷰포트 Layout Directory를 항상 정리하면서 Mesh설정해야 나중에 복잡하지 않고 찾기 쉬움
DataTable을 활용하여 적의 종류와 개체 수를 Stage마다 다르게 Spawn & Pooling 되도록 함.
첫 번째 사진은 EnemyPool에 얼마만큼의 적을 저장할 지 Initialize를 설정하는 DataTable
두 번째 사진은 저장된 ObjectPool에서 얼마만큼의 적을 GetPool 할지 나타내는 DataTable
TMap<TSubclassOf<ABaseEnemy>, int32> AFPSGameMode::GetPoolInitializationData()
{
TMap<TSubclassOf<ABaseEnemy>, int32> PoolData;
if (!AIDataTable) return PoolData;
static const FString ContextString(TEXT("PoolInitializationContext"));
TArray<FAIEnemyPoolRaw*> AllRows;
AIDataTable->GetAllRows(ContextString, AllRows);
UE_LOG(LogTemp, Warning, TEXT("Sucess DT"));
for (FAIEnemyPoolRaw* Raw : AllRows)
{
if (Raw)
{
PoolData.Add(Raw->EnemyClass, Raw->InitEnemyCount);
}
UE_LOG(LogTemp, Warning, TEXT("Find Row"));
}
return PoolData;
}
----------------------------------------------------------------------------------------------
TMap<TSubclassOf<ABaseEnemy>, int32> AFPSGameMode::GetEnemySpawnData(int32 StageNumber)
{
TMap<TSubclassOf<ABaseEnemy>, int32> EnemyData;
if (!EnemySpawnTable) return EnemyData;
static const FString ContextString(TEXT("EnemySpawnContext"));
TArray<FAIEnemySpawnRaw*> AllRows;
EnemySpawnTable->GetAllRows(ContextString, AllRows);
UE_LOG(LogTemp, Warning, TEXT("Sucess Get DTspawnData! AllRows.Num : %d"), AllRows.Num());
for (FAIEnemySpawnRaw* Row : AllRows)
{
if (Row && Row->StageNumber == StageNumber)
{
if (EnemyData.Contains(Row->EnemyClass))
{
EnemyData[Row->EnemyClass] += Row->EnemyCount;
}
else
{
EnemyData.Add(Row->EnemyClass, Row->EnemyCount);
}
}
}
return EnemyData;
}
위의 함수는 첫 번째 사진의 DataTable에서 데이터를 받아 TMap
으로 선언된 PoolData에 저장하는 로직이다.
InitializeObjectPool()
메소드에서 ObjectPoolInstance
에 PoolData를 넘겨준다.두 번째 함수는 StageNumber을 매개변수로 들고와서 두 번째 사진의 DataTable의 StageNumber과 비교하여 같으면 해당 Raw의 EnemyClass와 GetPool할 EnemyCount를 EnemyData에 저장하는 로직이다.
SpawnEnemiesForStage(int32 StageNumber)
에서 호출되어 ObjectPoolInstance
에 값을 넘겨준다.적이 많이 소환되면 재사용할 수 있는 ObjectPooling 방식을 통해 적 AI를 Spawn
void AAIObjectPool::InitializePool(TMap<TSubclassOf<ABaseEnemy>, int32> EnemyClasses)
{
.
.
.
FActorSpawnParameters SpawnParams;
SpawnParams.SpawnCollisionHandlingOverride =
ESpawnActorCollisionHandlingMethod::AdjustIfPossibleButAlwaysSpawn;
for (int32 i = 0; i < Count; i++)
{
ABaseEnemy* NewEnemy = GetWorld()->SpawnActor<ABaseEnemy>(
EnemyClass,
SpawnParams
);
if (NewEnemy->IsValidLowLevelFast())
{
NewEnemy->SetCanAttack(false);
NewEnemy->SetActorHiddenInGame(true);
NewEnemy->SetActorEnableCollision(false);
NewEnemy->SetActorTickEnabled(false);
if (Pool.IsValid())
{
Pool->Add(NewEnemy);
UE_LOG(LogTemp, Warning, TEXT("Pool Save Data %d"), Pool->Num())
}
}
}
}
-----------------------------------------------------------------------------------------
ABaseEnemy* AAIObjectPool::GetPooledAI(ASpawnVolume* SpawnVolume, TSubclassOf<ABaseEnemy> EnemyClass)
{
if (!SpawnVolume || !EnemyClass) return nullptr;
if (EnemyPools.Contains(EnemyClass))
{
UE_LOG(LogTemp, Warning, TEXT("EnemyPools Contains BaseEnemyClass"));
Pool = EnemyPools[EnemyClass];
if (Pool.IsValid())
{
UE_LOG(LogTemp, Warning, TEXT("Pool Is Valid"));
for (ABaseEnemy* Enemy : *Pool)
{
if (!Enemy->IsActorTickEnabled())
{
FVector SpawnLocation = SpawnVolume->GetSafeSpawnPoint();
Enemy->SetActorLocation(SpawnLocation);
Enemy->SetCanAttack(true);
Enemy->ResetEnemy();
UE_LOG(LogTemp, Log, TEXT("%s spawned from pool"), *EnemyClass->GetName());
return Enemy;
}
}
}
else
{
UE_LOG(LogTemp, Warning, TEXT("Pool is Nullptr"));
}
}
return nullptr;
}
첫 번째 메소드는 ObjectPoolInstance에서 InitializePool
하는 단계.
두 번째 메소드는 Pool에서 월드상에 활성화를 시켜주는 로직.
GetSafeSpawnPoint()
를 호출하여 위치 조정.🔥 필요한 순간에 Pool에서 꺼내어 활성화 되었다가 죽으면 다시 Pool에 저장하고 비활성화 되는 방식으로 새로운 Enemy를 스폰하는 것이 아니라 재사용이 되는 것으므로 최적화에 매우 유리
🔥 또한 AIObjectPool을 관리하는 클래스를 따로 만들어서 코드 수정과 재사용성이 높아짐
언리얼에서는 기본 레벨 오프너 함수인 OpenLevel
을 사용하여 Level이 전환되면 Engine에서 관리하는 최상위 객체인 GameInstance 외엔 모두 초기화가 된다. 기획에 따라 Stage마다 레벨이 전환 되므로 PlayerStatus, WeaponStatus, LevelIndex 등 유지가 필요한 Property들은 GameInstance 에서 저장하고 로드 시켜줘야 한다.
void UFPSGameInstance::SavePlayerStats(ACharacter* PlayerCharacter)
{
if (PlayerCharacter)
{
APlayerCharacter* Char = Cast<APlayerCharacter>(PlayerCharacter);
if (Char)
{
MaxHP = Char->GetMaxHealth();
MaxShield = Char->GetMaxShield();
ShieldRegenDelay = Char->GetShieldRegenDelay();
ShieldRegenRate = Char->GetShieldRegenRate();
NormalSpeed = Char->GetNormalSpeed();
DashCoolDown = Char->GetDashCoolDown();
}
}
}
------------------------------------------------------------------------------------
void UFPSGameInstance::LoadPlayerStats()
{
UWorld* World = GetWorld();
APlayerController* PlayerController = World->GetFirstPlayerController();
if (PlayerController)
{
AMyPlayerController* FPSPlayerController = Cast<AMyPlayerController>(PlayerController);
if (FPSPlayerController)
{
APlayerCharacter* PlayerCharacter = Cast<APlayerCharacter>(FPSPlayerController->GetPawn());
if (PlayerCharacter)
{
APlayerCharacter* Char = Cast<APlayerCharacter>(PlayerCharacter);
if (Char)
{
Char->SetMaxHealth(MaxHP);
Char->SetMaxShield(MaxShield);
Char->SetShieldRegenDelay(ShieldRegenDelay);
Char->SetShieldRegenRate(ShieldRegenRate);
Char->SetNormalSpeed(NormalSpeed);
Char->SetDashCoolDown(DashCoolDown);
}
}
}
}
}
PassiveCard로 인해 변하는 Player Property들은 Instance에 저장한다.
LoadPlayerStats()
은 Player.cpp
의 BeginPlay() 에서 호출되어 레벨이 전환될 때 GameInstance에서 저장된 값들을 로드한다.
void UFPSGameInstance::SaveWeaponStats(APlayerCharacter* Player)
{
{
if (!Player) return;
if (Player->Inventory[0])
{
PrimaryWeaponClass = Player->Inventory[0]->GetClass();
PrimaryGunDamage = Player->Inventory[0]->GetGunDamage();
PrimaryDelay = Player->Inventory[0]->GetGunDelay();
PrimaryAmmo = Player->Inventory[0]->GetMaxAmmo();
}
if (Player->Inventory[1])
{
SecondaryWeaponClass = Player->Inventory[1]->GetClass();
SecondGunDamage = Player->Inventory[1]->GetGunDamage();
SecondDelay = Player->Inventory[1]->GetGunDelay();
SecondAmmo = Player->Inventory[1]->GetMaxAmmo();
}
}
}
--------------------------------------------------------------------
void UFPSGameInstance::LoadWeaponStats(APlayerCharacter* Player)
{
if (!Player) return;
UWorld* World = Player->GetWorld();
if (!World) return;
if (PrimaryWeaponClass)
{
ACGunBase* NewPrimaryWeapon = World->SpawnActor<ACGunBase>(PrimaryWeaponClass);
if (NewPrimaryWeapon)
{
Player->Inventory[0] = NewPrimaryWeapon;
Player->CurrentWeapon = NewPrimaryWeapon;
Player->CurrentWeaponSlot = 0;
Player->AttachWeaponToHand(NewPrimaryWeapon, 0);
Player->Inventory[0]->SetGunDamage(PrimaryGunDamage);
Player->Inventory[0]->SetGunDelay(PrimaryDelay);
Player->Inventory[0]->SetMaxAmmo(PrimaryAmmo);
}
}
if (SecondaryWeaponClass)
{
ACGunBase* NewSecondaryWeapon = World->SpawnActor<ACGunBase>(SecondaryWeaponClass);
if (NewSecondaryWeapon)
{
Player->Inventory[1] = NewSecondaryWeapon;
Player->Inventory[1]->SetGunDamage(SecondGunDamage);
Player->Inventory[1]->SetGunDelay(SecondDelay);
Player->Inventory[1]->SetMaxAmmo(SecondAmmo);
}
}
}
Weapon은 플레이어에서 2개를 장착하므로 따로 저장
Player->Inventory[n]->
를 통해 Weapon의 변수를 가져온다.LoadWeaponStats()
은 Weapon클래스에 있는 Setter 함수를 통해 설정하고 Player의 BeginPlay함수에서 호출하여 장착 무기에 저장한 변수들이 로드 될 수 있도록 함.
🔥 처음엔 어떻게 저장을 해야될까 막막했지만 막상 Instance에 저장하는 연습을 하니 그렇게 어렵지 않고 레벨 전환 시에 어떻게 게임상황을 유지할지 알게 되었다.
🔥 멀티 플레이어 환경에서는 SeamlessTravel, SeverTravel, PlayerState 를 통해서도 유지가 가능하다고 한다.
레벨 전환 시 Portal로 이동하는 형식 게임의 큰 흐름을 이어주는 ClearPortal과 스테이지 중간중간 들어갈 수 있는 TrapPortal로 나뉘어짐
적을 처치하면 ClearPortal이 생성되게 설계
적이 Pooling되면 GameState에서 관리하는 RemainEnemyCount가 올라가고
모두 처치하면 OnStageClear()이 호출되면서 ClearPotal이 생성되는 방식
ClearPortal의 생성위치를 코드에서 지정해주게 되면 맵이 수정되거나, 포탈의 위치를 옮길 때
매번 코드를 수정해야하는 불편함
void AFPSGameMode::SpawnPortal()
{
bPortalSpawned = true;
AActor* PortalSpawnPoint = nullptr;
TArray<AActor*> FoundActors;
UGameplayStatics::GetAllActorsOfClass(GetWorld(), AClearPortalPoint::StaticClass(), FoundActors);
for (AActor* Actor : FoundActors)
{
if (Actor && Actor->ActorHasTag(FName("ClearPortalPoint")))
{
PortalSpawnPoint = Actor;
break;
}
}
if (PortalSpawnPoint)
{
UE_LOG(LogTemp, Warning, TEXT("Portal Spawn!"));
AActor* SpawnedPortal = GetWorld()->SpawnActor<AClearPortal>(
PortalClass,
PortalSpawnPoint->GetActorLocation(),
FRotator::ZeroRotator
);
}
}
TargetPoint의 HasTag를 찾아서 해당 위치에서 Spawn하도록 설계된 로직
ClearPortal은 Overlap되면 OpenLevel이 호출되도록 함
Stage 중간 숨겨진 방에 TrapPortal 구현
TrapLevel 들어가기전에 PlayerLocation을 저장하고 GameInstance에 저장.
UENUM(BlueprintType)
enum class ETrapPortalTypes : uint8
{
TravelToTrap UMETA(DisplayName = "TravelToTrap"),
ReturnToStage UMETA(DisplayName = "ReturnToStage")
};
UENUM(BlueprintType)
enum class ETrapPortalAction : uint8
{
Entry UMETA(DisplayName = "Entry"),
Exit UMETA(DisplayName = "Exit")
};
void AFPSGameMode::SpawnTrapPortals()
{
UFPSGameInstance* FPSGameInstnace = Cast<UFPSGameInstance>(UGameplayStatics::GetGameInstance(this));
if (!FPSGameInstnace) return;
AActor* TrapPortalSpawnPoint = nullptr;
TArray<AActor*> FoundActors;
UGameplayStatics::GetAllActorsOfClass(GetWorld(), ATrapPortalPoint::StaticClass(), FoundActors);
for (AActor* Actor : FoundActors)
{
if (Actor && Actor->ActorHasTag(FName("TrapPortalPoint")))
{
UE_LOG(LogTemp, Warning, TEXT("Found Actor: %s"), *Actor->GetFName().ToString());
TrapPortalSpawnPoint = Actor;
break;
}
}
if (TrapPortalSpawnPoint)
{
FActorSpawnParameters SpawnParams;
ATrapPortal* SpawnedPortal = GetWorld()->SpawnActor<ATrapPortal>(
TrapPortalClass,
TrapPortalSpawnPoint->GetActorLocation(),
FRotator::ZeroRotator,
SpawnParams
);
if (SpawnedPortal)
{
if (ATrapPortalPoint* TrapPortalPoint = Cast<ATrapPortalPoint>(TrapPortalSpawnPoint))
{
if (TrapPortalPoint->PortalAction == ETrapPortalAction::Entry)
{
SpawnedPortal->PortalType = ETrapPortalTypes::TravelToTrap;
}
else if (TrapPortalPoint->PortalAction == ETrapPortalAction::Exit)
{
SpawnedPortal->PortalType = ETrapPortalTypes::ReturnToStage;
}
}
}
}
}
🔥 차후 역할이 늘어나도 따로 클래스를 생성하지 않고 Enum에 추가만 하면 되므로 코드가 간결해지고 확장성이 용이
enum class ECardEffectType : uint8
{
AttackPowerIncrease UMETA(DisplayName = "Attack Power Increase"),
AttackSpeedIncrease UMETA(DisplayName = "Attack Speed Increase"),
MoveSpeedIncrease UMETA(DisplayName = "Move Speed Increase"),
ShieldAmountIncrease UMETA(DisplayName = "Shield Amount Increase"),
ShieldRegeneTimeDecrease UMETA(DisplayName = "Shield Regene Time Decrease"),
ShieldRateIncrease UMETA(DisplayName = "Shield Rate Increase"),
MaxHealthIncrease UMETA(DisplayName = "Max Health Increase"),
DashCoolTimeDecrease UMETA(DisplayName = "Dash CoolTime Decrese"),
LastIndex UMETA(Hidden) // Rand 시 최대값 탐색용
};
UENUM(BlueprintType)
enum class EEffectValueType : uint8
{
FlatValue UMETA(DisplayName = "Flat Value"),
Percentage UMETA(DisplayName = "Percentage"),
Duration UMETA(DisplayName = "Duration")
};
UENUM(BlueprintType)
enum class ECardRarity : uint8
{
Common UMETA(DisplayName = "Common"),
Rare UMETA(DisplayName = "Rare"),
Legendary UMETA(DisplayName = "Legendary"),
LastIndex UMETA(Hidden)
};
USTRUCT(BlueprintType)
struct FCardEffect
{
GENERATED_BODY()
UPROPERTY(EditAnywhere, BlueprintReadWrite)
ECardEffectType EffectType;
UPROPERTY(EditAnywhere, BlueprintReadWrite)
EEffectValueType ValueType;
UPROPERTY(EditAnywhere, BlueprintReadWrite)
float EffectValues;
FCardEffect()
: EffectType(ECardEffectType::AttackPowerIncrease)
, ValueType(EEffectValueType::FlatValue)
, EffectValues(0.0f) {
}
FCardEffect(ECardEffectType InEffectType, EEffectValueType InValueType, float InEffectValues)
: EffectType(InEffectType)
, ValueType(InValueType)
, EffectValues(InEffectValues) {
}
};
USTRUCT(BlueprintType)
struct FCardDataTable : public FTableRowBase
{
GENERATED_BODY()
UPROPERTY(EditAnywhere, BlueprintReadWrite)
FString CardName;
UPROPERTY(EditAnywhere, BlueprintReadWrite)
FString CardDescription;
UPROPERTY(EditAnywhere, BlueprintReadWrite)
ECardRarity Rarity;
UPROPERTY(EditAnywhere, BlueprintReadWrite)
FCardEffect CardEffect;
};
UCardData* AFPSGameMode::GetRandomCard()
{
if (AllCardPool.Num() == 0)
{
return nullptr;
}
TArray<UCardData*> CommonCards;
TArray<UCardData*> RareCards;
TArray<UCardData*> LegendaryCards;
for (UCardData* Card : AllCardPool)
{
switch (Card->Rarity)
{
case ECardRarity::Common:
CommonCards.Add(Card);
break;
case ECardRarity::Rare:
RareCards.Add(Card);
break;
case ECardRarity::Legendary:
LegendaryCards.Add(Card);
break;
}
}
float Roll = FMath::FRand();
if (Roll < 0.6f && CommonCards.Num() > 0)
{
return CommonCards[FMath::RandRange(0, CommonCards.Num() - 1)];
}
else if (Roll < 0.95f && RareCards.Num() > 0)
{
return RareCards[FMath::RandRange(0, RareCards.Num() - 1)];
}
else if (LegendaryCards.Num() > 0)
{
return LegendaryCards[FMath::RandRange(0, LegendaryCards.Num() - 1)];
}
return nullptr;
}
TArray<UCardData*> AFPSGameMode::GetRandomCards(int32 CardCount)
{
TArray<UCardData*> SelectedCards;
for (int32 i = 0; i < CardCount; i++)
{
UCardData* RandomCard = GetRandomCard();
if (RandomCard)
{
SelectedCards.Add(RandomCard);
}
}
return SelectedCards;
}
void APlayerCharacter::ApplyCardEffect(const FCardEffect& SelectedCard)
{
//if (!SelectedCard) return;
FCardEffect Effect = SelectedCard;//->CardEffect;
float AppliedValue = Effect.EffectValues;
switch (Effect.EffectType)
{
case ECardEffectType::DashCoolTimeDecrease:
DashCoolDown *= (1.0f - AppliedValue * 0.01f);
break;
case ECardEffectType::MoveSpeedIncrease:
NormalSpeed *= (1.0f + AppliedValue * 0.01f);
break;
case ECardEffectType::ShieldAmountIncrease:
MaxShield += AppliedValue;
OnShieldChanged.Broadcast(CurrentShield, MaxShield);
break;
case ECardEffectType::ShieldRateIncrease:
ShieldRegenRate += AppliedValue;
break;
case ECardEffectType::MaxHealthIncrease:
MaxHealth += AppliedValue;
OnHealthChanged.Broadcast(CurrentHealth, MaxHealth);
break;
case ECardEffectType::AttackPowerIncrease:
case ECardEffectType::AttackSpeedIncrease:
ApplyEffectToGun(Effect);
AppliedCardEffects.Add(Effect);
break;
case ECardEffectType::ShieldRegeneTimeDecrease:
//미구현
break;
}
}
void APlayerCharacter::ApplyEffectToGun(FCardEffect Effect)
{
ACGunBase* EquippedGun = GetEquippedGun();
if (!EquippedGun) return;
float AppliedValue = Effect.EffectValues;
if (Effect.EffectType == ECardEffectType::AttackPowerIncrease)
{
EquippedGun->SetGunDamage(EquippedGun->GetGunDamage() + AppliedValue);
}
else if (Effect.EffectType == ECardEffectType::AttackSpeedIncrease)
{
EquippedGun->SetGunDelay(EquippedGun->GetGunDelay() * (1.0f - (AppliedValue * 0.01f)));
}
}
🔥 Enum을 활용하여 DataTable을 만들고 해당 DataTable을 에서 값을 받아서 Widget에 연동하고 카드를 고르면 Player와 Weapon의 Status에 적용되도록 설계
처음에 레벨 전환을 하더라도 Enemy를 저장하는 Pool이 계속 유지를 하면 좋겠다는 생각으로 GameInstance에서 ObjectPoolInstance를 관리를 하였다. 그러나 GameInstance가 게임프레임워크 순서로 인해 Pool이 계속 EnemyClass를 못받아오는 문제가 생겼다. Wolrd가 생성되기도 전에 GameInstance의 Init이 먼저 호출되면서 발생한 문제라고 추측
👉 아쉽지만 레벨이 전환되어 Enemy가 삭제되고 다시 생성이 되더라도 재사용될 수 있다는 의의를 가지고 GameMode에서 ObjectPoolInstance를 관리를 하였다. 그러니 정상적으로 Pool에 Enemy가 들어가면서 해결이 되었음.
GameMode에서 TrapLevel 클리어 후 원래의 Level이 오픈될 때 TrapLevel 보상으로 CardSelectionUI가 뜨지만 입력이 되지 않는 문제가 발생 했었다. BeginPlay()에서 바로 CreateWidget을 하면, UI가 생성되긴 하지만 아직 입력 처리가 활성화되기 전에 띄워졌을 가능성이 있을거라고 착안 라이프사이클의 지식이 부족해서 일어난 문제라고 생각했다.
👉 단순하게 UI를 생성하는 함수를 따로 만들고 TimeManager를 활용하여 0.2초 뒤에 UI를 생성하도록 하니깐 문제가 해결되었다. GameMode의 프레임워크와 액터, Object의 라이프사이클을 공부해야 겠다는 생각이 들었다.
게임 시작 후 최초의 레벨 GameState에 ObjectPoolInstance를 만들어서 GameMode의 BeginPlay에서 Initialize 하여 사용한 뒤 차후 레벨에서는 GameInstance의 ObjectPoolInstance를 사용하는 방법이 괜찮지 않을까 생각한다.
적 Enemy Skill은 어떤 식으로 동작할 것인지, 체력을 회복하는 매커니즘, 데미지 팝업 방식 등 기획 단계에서 조금 더 구체적으로 각 담당마다 세부적으로 어떻게 구현할지 계획을 수립해야한다.
UML의 경우 처음에 기획을 구체적으로 함으로써 예상되는 Property와 Method를 구상한 다음 관계도를 빠르게 정립하는 것이 일이 효율적으로 돌아갈 것이라 생각한다.
Property들은 Private에 선언하고 Getter, Setter 함수를 사용하는 것이 안전할 것이다. 또한 다른 곳에서 호출이 되지 않는 Method도 Protected에 선언하는게 좋겠다.
TroubleShooting시 즉각적으로 해당 이슈를 메모에 적고 해결진행 상황을 체크하여 기록해두는 것이 공부와 발표에 도움이 많이 될 것 같다.