오늘의 코드카타
- [1차] 캐시
- 전화번호 목록
- 튜플
{{a1}, {a1, a2}, {a1, a2, a3}, {a1, a2, a3, a4}, ... {a1, a2, a3, a4, ..., an}}와 같은 방식으로 표현된 문자열이 주어질 때, (a1, a2, ... , an)이 되는 튜플 반환하는 문제
- 각 집합의 순서는 섞여있고, 집합 내부의 값도 섞여있다.
- 먼저 문자열에서 숫자 부분만 2차원 배열에 넣었고, 배열의 크기에 따라 오름차순으로 정렬했다.
- 이후에
unordered_set을 활용해서 새로 추가된 값을 알아내서 정답에 추가하는 방식으로 문제를 해결했다.
- https://school.programmers.co.kr/learn/courses/30/lessons/64065
- 다른 사람의 풀이를 보니
unordered_set을 사용하지 않고, 숫자가 나온 횟수를 계산해서 많이 나온 순서대로 정렬하는 문제 풀이 방식도 있었다.
팀 프로젝트 진행 내용
1. 카메라 매니저 초기화 시 오류 수정
- 패키징 시 클라에서 오류 발생

[2025.12.19-11.04.12:990][367]LogWindows: Error: Assertion failed: PlayerCharacter [File:C:\UNP\3rd_4th-Team4-CH6-Project\Source\AO\Private\Player\PlayerController\AO_PlayerController_InGameBase.cpp] [Line: 482]
[2025.12.19-11.04.12:990][367]LogWindows: Error: Character not found
[2025.12.19-11.04.12:990][367]LogWindows: Error:
[2025.12.19-11.04.12:990][367]LogWindows: Error:
[2025.12.19-11.04.12:990][367]LogWindows: Error: [Callstack] 0x00007ff65eaed178 AO.exe!FDebug::CheckVerifyFailedImpl2() []
[2025.12.19-11.04.12:990][367]LogWindows: Error: [Callstack] 0x00007ff6686dcb7e AO.exe!AAO_PlayerController_InGameBase::InitCameraManager() [C:\UNP\3rd_4th-Team4-CH6-Project\Source\AO\Private\Player\PlayerController\AO_PlayerController_InGameBase.cpp:482]
- 아래와 같은 동작을 하는데,
checkf(PlayerCharacter, TEXT("Character not found")); 라인에서 Assert가 발생했다.
- 카메라 매니저를 초기화하는 과정으로 서버에서는
OnPossess()에서 초기화를 진행하고, 서버에서는 OnRep_Pawn()에서 초기화를 진행했다.
void AAO_PlayerController_InGameBase::OnRep_Pawn()
{
Super::OnRep_Pawn();
if (!IsLocalController())
{
return;
}
InitCameraManager();
}
void AAO_PlayerController_InGameBase::InitCameraManager()
{
checkf(CameraManagerComponent, TEXT("CameraManagerComponent not found"));
AAO_PlayerCharacter* PlayerCharacter = Cast<AAO_PlayerCharacter>(GetPawn());
checkf(PlayerCharacter, TEXT("Character not found"));
CameraManagerComponent->BindCameraComponents(PlayerCharacter->GetSpringArm(), PlayerCharacter->GetCamera());
CameraManagerComponent->PushCameraState(FGameplayTag::RequestGameplayTag(FName("Camera.Default")));
if (UAbilitySystemComponent* ASC = PlayerCharacter->GetAbilitySystemComponent())
{
CameraManagerComponent->BindToASC(ASC);
}
}
OnRep_Pawn을 찾아보니까 Controller.h 파일에 있었는데, Pawn.Possess()가 발생하면 불리는 Replication 함수이다. 하지만 밑에 줄에 보니까, 포인터가 확실하지는 않으니 사용할 때, 안전하게 사용하라는 주석이 달려있었다. 그렇다면 클라에서 OnRep_Pawn이 불리는 시점에서 해당 Pawn이 정확하게 내가 요청한 DefaultPawnClass가 아닐 수도 있다는 생각이 들었다.

- 이를 확인해보기 위해서 아래와 같은 로그를 추가해서,
OnRep_Pawn()이 호출되는 시기에서의 Pawn을 검사해보고자 했다.
AO_LOG(LogKH, Warning, TEXT("Pawn=%s Controller=%s CanCast=%s"),
*GetNameSafe(GetPawn()),
*GetNameSafe(GetPawn() ? GetPawn()->GetController() : nullptr),
Cast<AAO_PlayerCharacter>(GetPawn()) ? TEXT("true") : TEXT("false"));
- 해당 로그를 클라에서 확인해보니,
Pawn이 Possess가 일어날 때 한번만 호출될 것으로 예측을 했는데, 총 3번이나 호출되는 것을 알 수 있었다. 처음에는 PlayerController 생성자가 호출되고, BeginPlay()가 호출하기 전에 한 번 호출되었다. 두 번째는 PlayerState가 생성되고 부착되기 전에 호출되었다. 세 번째는 레벨 이동 시에 ServerTravel()이 호출되고 PlayerController가 생성자가 호출되기도 전에 호출되었다.
- 세 번째 과정에서
Pawn과 Controller가 None으로 출력되어서 이 때 문제가 발생한 것으로 보인다. 나는 애초에 OnRep_Pawn()이 Pawn이 지정되었을 때, 한 번만 호출되는 줄 알았지만 멀티 환경에서는 PlayerState 초기화할 때나 레벨을 이동할 때 등 예측하지 못한 상황에서도 호출이 되었다.
- 따라서 내가 원하는
Pawn이 정확하게 Possess가 발생하고, 이를 클라에서 인식하는 시점에 InitCameraManager()를 호출하게끔 수정하고자 했다. 찾아보니 PlayerController에 AcknowledgePossession()이라는 함수가 Pawn이 Possess가 완료되었다는 것을 보장해주는 함수였다. 또한 테스트를 해보니 리슨 서버 환경에서 서버에서도 동작하는 것을 확인해서 해당 함수에서 InitCameraManager()를 호출하게끔 수정했다.


2. 관전 시 끊기는 현상 수정
- 패키징 테스트 시에 관전하는 경우, 캐릭터가 많이 끊겨보이는 현상이 있었다.
- 이를 해결하기 위해서 관전하는 구조를 다시 보았다. 캐릭터 관전 컴포넌트는 현재 관전자가 있는 경우, 0.033초(30hz)마다 현재 플레이어의 카메라 위치를 업데이트해주고 있었다.
// AO_DeathSpectateComponent.cpp
void UAO_DeathSpectateComponent::StartCameraSyncTimer()
{
...
World->GetTimerManager().SetTimer(
TimerHandle_CameraSync,
this,
&UAO_DeathSpectateComponent::SendCameraViewToServer,
0.033f,
true);
}
void UAO_DeathSpectateComponent::SendCameraViewToServer()
{
check(OwnerCharacter);
if (!OwnerCharacter->IsLocallyControlled())
{
return;
}
if (!bStreamEnabled)
{
return;
}
if (APlayerController* PC = Cast<APlayerController>(OwnerCharacter->GetController()))
{
if (PC->PlayerCameraManager)
{
FRepCameraView V;
V.Location = PC->PlayerCameraManager->GetCameraLocation();
V.Rotation = PC->PlayerCameraManager->GetCameraRotation();
V.FOV = PC->PlayerCameraManager->GetFOVAngle();
ServerRPC_UpdateCameraView(V);
}
}
}
void UAO_DeathSpectateComponent::ServerRPC_UpdateCameraView_Implementation(const FRepCameraView& NewView)
{
RepCameraView = NewView;
}
- 이를 캐릭터의
CalcCamera()에서 외부에서 SetViewTarget()을 했을 때, 보여주는 방식을 통해 구현했었다.
- 관전 요청을 하면 해당 캐릭터를 직접 볼 수 있게끔 구현을 했는데, 이를 보면 업데이트해주는 것이
Unreliable 방식을 취하고 있는데, 결국 30hz 미만의 속도로 카메라가 업데이트되고 있었다.
- 그렇다면 캐릭터는 정상적으로 움직여도 카메라가 상당히 끊겨보일 수 있을 것으로 추측됐다.
// AO_PlayerCharacter.cpp
void AAO_PlayerCharacter::CalcCamera(float DeltaTime, struct FMinimalViewInfo& OutResult)
{
if (IsLocallyControlled())
{
if (Camera)
{
Camera->GetCameraView(DeltaTime, OutResult);
}
else
{
Super::CalcCamera(DeltaTime, OutResult);
}
return;
}
if (DeathSpectateComponent)
{
FRepCameraView V;
DeathSpectateComponent->GetRepCameraView(V);
OutResult.Location = V.Location;
OutResult.Rotation = V.Rotation;
OutResult.FOV = V.FOV;
return;
}
}
- 따라서 이를 해결하기 위해
PlayerController에서 별도로 CameraActor를 가지고 있고, 해당 CameraActor가 직접 컴포넌트에서 데이터를 받아와서 클라이언트에서 보간하는 방식을 통해 문제를 해결하고자 했다.
// AO_PlayerController_Stage.h
UCLASS()
class AO_API AAO_PlayerController_Stage : public AAO_PlayerController_InGameBase
{
...
// == 관전 카메라 스무딩용 로직 == //
// 관전 카메라 보간 로직
void UpdateSpectateCamera(float DeltaSeconds);
void EnsureSpectateCameraActor();
void ResetSpectateSmoothing();
UPROPERTY(Transient)
TObjectPtr<ACameraActor> SpectateCameraActor = nullptr;
// 스무딩된 현재값
FVector SmoothedLoc = FVector::ZeroVector;
FRotator SmoothedRot = FRotator::ZeroRotator;
float SmoothedFOV = 90.f;
// 타겟 (복제된 값)
FVector TargetLoc = FVector::ZeroVector;
FRotator TargetRot = FRotator::ZeroRotator;
float TargetFOV = 90.f;
bool bSpectateCamInitialized = false;
// 보간 속도
UPROPERTY(EditDefaultsOnly, Category = "AO|Spectate|Smoothing")
float PosInterpSpeed = 12.f;
UPROPERTY(EditDefaultsOnly, Category = "AO|Spectate|Smoothing")
float RotInterpSpeed = 12.f;
UPROPERTY(EditDefaultsOnly, Category = "AO|Spectate|Smoothing")
float FovInterpSpeed = 8.f;
bool bIsSpectating = false;
...
}
- 먼저 관전을 하고 있다면, Tick에서 해당 관전 카메라를 보간하는 로직을 통해 업데이트하게끔 구현했다.
// AO_PlayerController_Stage.cpp
void AAO_PlayerController_Stage::Tick(float DeltaSeconds)
{
Super::Tick(DeltaSeconds);
if (!IsLocalController())
{
return;
}
if (bIsSpectating)
{
UpdateSpectateCamera(DeltaSeconds);
}
}
- 해당 함수는 관전하고 있다면
Tick마다 돌면서 계속 보간되도록 하기 위한 로직이다.
- 처음에 타겟이 세팅됐을 때는 빠른 이동을 위해서 바로 이동하게끔 구현했고, 타겟이 세팅되고 카메라가 이동하는 경우에 보간이 되도록 구현했다.
// AO_PlayerController_Stage.cpp
void AAO_PlayerController_Stage::UpdateSpectateCamera(float DeltaSeconds)
{
if (!CurrentSpectateTarget)
{
return;
}
if (!ensure(SpectateCameraActor))
{
return;
}
if (AAO_PlayerCharacter* TargetChar = Cast<AAO_PlayerCharacter>(CurrentSpectateTarget))
{
if (UAO_DeathSpectateComponent* Comp = TargetChar->FindComponentByClass<UAO_DeathSpectateComponent>())
{
FRepCameraView RepView;
if (Comp->GetRepCameraView(RepView))
{
TargetLoc = RepView.Location;
TargetRot = RepView.Rotation;
TargetFOV = RepView.FOV;
if (!bSpectateCamInitialized)
{
SmoothedLoc = TargetLoc;
SmoothedRot = TargetRot;
SmoothedFOV = TargetFOV;
bSpectateCamInitialized = true;
SpectateCameraActor->SetActorLocation(SmoothedLoc);
SpectateCameraActor->SetActorRotation(SmoothedRot);
if (UCameraComponent* Camera = SpectateCameraActor->GetCameraComponent())
{
Camera->SetFieldOfView(SmoothedFOV);
}
return;
}
SmoothedLoc = FMath::VInterpTo(SmoothedLoc, TargetLoc, DeltaSeconds, PosInterpSpeed);
SmoothedRot = FMath::RInterpTo(SmoothedRot, TargetRot, DeltaSeconds, RotInterpSpeed);
SmoothedFOV = FMath::FInterpTo(SmoothedFOV, TargetFOV, DeltaSeconds, FovInterpSpeed);
SpectateCameraActor->SetActorLocation(SmoothedLoc);
SpectateCameraActor->SetActorRotation(SmoothedRot);
if (UCameraComponent* Camera = SpectateCameraActor->GetCameraComponent())
{
Camera->SetFieldOfView(SmoothedFOV);
}
}
}
}
}
- 처음에 CameraActor를 생성하고 해당 CameraActor가 있음을 보장하기 위한 함수와 관전 대상이 변경되거나 없어진 경우에 카메라 위치를 초기화하는 함수도 부가적으로 구현했다.
- 카메라 액터를 생성하는 함수는 관전을 처음할 때 실행할까 하다가 그보다는 시작할 때, 실행시켜서 미리 스폰하는 것이 좋을 것으로 보여서
BeginPlay()에서 실행시켰다.
// AO_PlayerController_Stage.cpp
void AAO_PlayerController_Stage::EnsureSpectateCameraActor()
{
if (SpectateCameraActor)
{
return;
}
UWorld* World = GetWorld();
checkf(World, TEXT("World is null"));
FActorSpawnParameters Params;
Params.Owner = this;
Params.SpawnCollisionHandlingOverride = ESpawnActorCollisionHandlingMethod::AlwaysSpawn;
SpectateCameraActor = World->SpawnActor<ACameraActor>(ACameraActor::StaticClass(), Params);
if (SpectateCameraActor)
{
SpectateCameraActor->SetActorHiddenInGame(true);
}
}
void AAO_PlayerController_Stage::ResetSpectateSmoothing()
{
bSpectateCamInitialized = false;
SmoothedLoc = FVector::ZeroVector;
SmoothedRot = FRotator::ZeroRotator;
SmoothedFOV = 90.f;
TargetLoc = FVector::ZeroVector;
TargetRot = FRotator::ZeroRotator;
TargetFOV = 90.f;
}
- 기존에는 캐릭터 자체를 ViewTarget으로 지정했었는데, 관전용 카메라 액터를 ViewTarget으로 지정해서 해당 카메라 액터가 캐릭터를 따라다니면서 보게끔 구조를 변경했다.
void AAO_PlayerController_Stage::ClientRPC_SetSpectateTarget_Implementation(APawn* NewTarget, int32 NewPlayerIndex)
{
// 기존 로직
// SetViewTargetWithBlend(NewTarget, BlendTime);
bIsSpectating = true;
ResetSpectateSmoothing();
if (SpectateCameraActor)
{
const float BlendTime = 0.25f;
SetViewTargetWithBlend(SpectateCameraActor, BlendTime);
}
...
}
3. 관전 종료 로직 추가
- 기존에는 별도로 관전 종료하는 함수를 구현하지 않고, 게임 모드에서 이전에 컷하는 방식으로 하고자 했는데, 게임 내에서 관전을 하다가 부활하게 되면 관전을 종료하는 상황이 발생해서 별도의 함수를 통해 관전 종료 로직을 구현하고자 했다.
- 혹시 클라이언트가 다른 관전자를 찾지 못하거나, 관전자가 게임을 종료하게 되면 다른 타겟을 찾거나 해야하기 때문에
Enum을 통해 관전을 종료하는 이유를 저장해서 동작을 다르게 처리하고자 했다.
// AO_PlayerController_Stage.h
UENUM()
enum EAO_SpectateEndReason : uint8
{
Revived,
NoValidTarget,
TargetDestroyed
};
- 외부에서 호출할 수 있는 관전 종료 함수를 작성했다.
- 관전하고 있는 사람이 있다면 관전 컴포넌트에서 관전 종료한다고 알려줘야 하기 때문에 관전자 등록을 해제하고, 로컬에서 관전을 정리하게끔 구현했다.
// AO_PlayerController_Stage.h
UFUNCTION(BlueprintCallable, Category = "AO|Spectate")
void RequestStopSpectate(EAO_SpectateEndReason Reason);
UFUNCTION(Server, Reliable)
void ServerRPC_StopSpectate();
UFUNCTION(Client, Reliable)
void ClientRPC_StopSpectate(EAO_SpectateEndReason Reason);
// AO_PlayerController_Stage.cpp
void AAO_PlayerController_Stage::RequestStopSpectate(EAO_SpectateEndReason Reason)
{
if (!IsLocalController())
{
return;
}
// 서버 Spectator 등록 해제
ServerRPC_StopSpectate();
// 로컬 정리
StopSpectate(Reason);
}
void AAO_PlayerController_Stage::ServerRPC_StopSpectate_Implementation()
{
if (!HasAuthority())
{
return;
}
ServerRPC_SetSpectateTarget(nullptr);
CurrentSpectateTarget = nullptr;
PrevSpectateTarget = nullptr;
CurrentSpectatePlayerIndex = INDEX_NONE;
}
void AAO_PlayerController_Stage::ClientRPC_StopSpectate_Implementation(EAO_SpectateEndReason Reason)
{
if (!IsLocalController())
{
return;
}
StopSpectate(Reason);
}
- 관전을 종료할 때는 내가 가지고 있던
Pawn으로 ViewTarget을 지정하고 관련 UI 처리하는 로직을 넣었다.
- 부활할 때는 HUD 클래스를 다시 띄워야 하고, 잘못된 값이 들어오거나 해서 관전을 그만 둬야 할 때는 다시 관전하기 버튼이 나오게끔 설정했다.
// AO_PlayerController_Stage.h
void StopSpectate(EAO_SpectateEndReason Reason);
// AO_PlayerController_Stage.cpp
void AAO_PlayerController_Stage::StopSpectate(EAO_SpectateEndReason Reason)
{
bIsSpectating = false;
CurrentSpectateTarget = nullptr;
CurrentSpectatePlayerIndex = INDEX_NONE;
ResetSpectateSmoothing();
// ViewTarget 복구
if (APawn* MyPawn = GetPawn())
{
SetViewTargetWithBlend(MyPawn, 0.25f);
}
// 관전 UI 정리
if (SpectateWidget)
{
SpectateWidget->RemoveFromParent();
}
// 이유별 UI 처리
switch (Reason)
{
case EAO_SpectateEndReason::Revived:
{
if (HUDWidget)
{
HUDWidget->RemoveFromParent();
HUDWidget = nullptr;
}
if (HUDWidgetClass)
{
HUDWidget = CreateWidget<UUserWidget>(this, HUDWidgetClass);
if (HUDWidget)
{
HUDWidget->AddToViewport();
}
}
FInputModeGameOnly InputMode;
SetInputMode(InputMode);
bShowMouseCursor = false;
}
break;
case EAO_SpectateEndReason::NoValidTarget:
case EAO_SpectateEndReason::TargetDestroyed:
default:
{
if (DeathWidgetClass && !DeathWidget)
{
DeathWidget = CreateWidget<UUserWidget>(this, DeathWidgetClass);
}
if (DeathWidget)
{
DeathWidget->AddToViewport();
}
FInputModeUIOnly InputMode;
SetInputMode(InputMode);
bShowMouseCursor = true;
}
break;
}
}
- 관전했을 때 오류가 발생했을 때는 유효하지 않은 타겟이라고 이유로 지정하고 관전을 종료했고, 살아났을 때는 부활 이유로 관전을 종료하게끔 기존의 코드에 추가했다.
EndPlay()에도 관전 종료를 해서 레벨 이동 시의 오류를 제거하고자 했다.
void AAO_PlayerController_Stage::ClientRPC_SetSpectateTarget_Implementation(APawn* NewTarget, int32 NewPlayerIndex)
{
if (!IsValid(NewTarget))
{
bIsSpectating = false;
CurrentSpectateTarget = nullptr;
CurrentSpectatePlayerIndex = INDEX_NONE;
ClientRPC_StopSpectate(EAO_SpectateEndReason::NoValidTarget);
return;
}
...
}
void AAO_PlayerController_Stage::Client_OnRevived_Implementation()
{
if (bIsSpectating)
{
RequestStopSpectate(EAO_SpectateEndReason::Revived);
}
...
}