TIL
07.09 TIL
2026. 7. 9. 21:17

일정표

시간 할 일 비고
09:00~10:00 코드카타  
10:00~13:00 팀 프로젝트  
14:00~18:00 2시 스크럼, 팀프로젝트  
19:00~21:00 저녁 스크럼, 팀프로젝트  
21:00~22:00 운동  
22:00~23:30 개인 프로젝트  

*오늘의 코드카타*

 

문제. 모의고사

더보기

수포자는 수학을 포기한 사람의 준말입니다. 수포자 삼인방은 모의고사에 수학 문제를 전부 찍으려 합니다. 수포자는 1번 문제부터 마지막 문제까지 다음과 같이 찍습니다

1번 수포자가 찍는 방식: 1, 2, 3, 4, 5, 1, 2, 3, 4, 5, ...

2번 수포자가 찍는 방식: 2, 1, 2, 3, 2, 4, 2, 5, 2, 1, 2, 3, 2, 4, 2, 5, ..

3번 수포자가 찍는 방식: 3, 3, 1, 1, 2, 2, 4, 4, 5, 5, 3, 3, 1, 1, 2, 2, 4, 4, 5, 5, ...

1번 문제부터 마지막 문제까지의 정답이 순서대로 들은 배열 answers가 주어졌을 때, 가장 많은 문제를 맞힌 사람이 누구인지 배열에 담아 return 하도록 solution 함수를 작성해주세요.

#include <vector>
#include <algorithm>

using namespace std;

vector<int> solution(vector<int> answers) {
    vector<vector<int>> patterns = {
        {1, 2, 3, 4, 5},
        {2, 1, 2, 3, 2, 4, 2, 5},
        {3, 3, 1, 1, 2, 2, 4, 4, 5, 5}
    };
    
    vector<int> scores(3, 0);
    for (int i = 0; i < answers.size(); ++i) {
        for (int p = 0; p < 3; ++p) {
            if (answers[i] == patterns[p][i % patterns[p].size()]) {
                scores[p]++;
            }
        }
    }
    
    int max_score = max({scores[0], scores[1], scores[2]});
    vector<int> answer;
    for (int i = 0; i < 3; ++i) {
        if (scores[i] == max_score) {
            answer.push_back(i + 1);
        }
    }
    
    return answer;
}

 

패턴 순환: 수포자의 패턴은 고정된 길이만큼 반복된다. 전체 문제 수와 상관없이 인덱스를 패턴의 길이로 나눈 나머지(%)를 사용하면, 배열을 벗어나지 않고 무한히 반복되는 패턴을 참조할 수 있다.

max({a, b, c}): 여러 값 중 최댓값을 한 번에 찾을 수 있다.

만약 수포자가 3명 이상으로 늘어나도 patterns 벡터에 패턴만 추가하면 똑같은 로직으로 해결할 수 있다.

 

 

 


*팀 프로젝트*

 

1. 작업한 내용들

 

[1. UI 전역 매니저 및 서브 시스템 : ParcelHUDWidget]

#pragma once

#include "CoreMinimal.h"
#include "Blueprint/UserWidget.h"
#include "ParcelHUDWidget.generated.h"

class AParcelGameState;
class AParcelPlayerState;

/**
 *  UParcelWidget
 *  인게임 HUD 요소의 이벤트 바인딩하고 관리하는 베이스 위젯
 *  담당자 : JYW
 */
UCLASS()
class PARCEL_KNIGHT_API UParcelHUDWidget : public UUserWidget
{
	GENERATED_BODY()
	
protected:
	// 위젯의 BeginPlay 함수
	virtual void NativeConstruct() override;
	
	// 로컬 플레이어와 데이터를 바인딩
	void TryBindUIEvents();
	
	// WBP에서 사용할 수 있도록 열어두는 이벤트 (델리게이트 -> 브로드캐스트. K2는 Kismet2 약자입니다)
	UFUNCTION(BlueprintImplementableEvent, Category = "UI|Health")
	void K2_OnHPChanged(float CurrentHP, float MaxHP);
	
	UFUNCTION(BlueprintImplementableEvent, Category = "UI|Health")
	void K2_OnTeamScoreChanged(int32 NewTeamScore);
	
	UFUNCTION(BlueprintImplementableEvent, Category = "UI|Health")
	void K2_OnPersonalScoreChanged(int32 NewPersonalScore);
	
	UFUNCTION(BlueprintImplementableEvent, Category = "UI|Health")
	void K2_OnOnRemainingTimeChanged(float RemainingTime);
	
private:
	// 안전한 접근을 위해 캐싱
	UPROPERTY()
	TWeakObjectPtr<AParcelGameState> CachedGameState;
	
	UPROPERTY()
	TWeakObjectPtr<AParcelPlayerState> CachedPlayerState;
	
	FTimerHandle RetryBindTimerHandle;
};
#include "UI/ParcelHUDWidget.h"
#include "GameFramework/GameStateBase.h"
#include "Core/ParcelGameState.h"
#include "Core/ParcelPlayerState.h"
#include "Core/TeamScoreComponent.h"
#include "Core/PlayerStatComponent.h"

void UParcelHUDWidget::NativeConstruct()
{
	Super::NativeConstruct();
	TryBindUIEvents();
}

void UParcelHUDWidget::TryBindUIEvents()
{
	// GameState와 TeamScore 바인딩
	if (!CachedGameState.IsValid())
	{
		CachedGameState = Cast<AParcelGameState>(GetWorld()->GetGameState());
		if (CachedGameState.IsValid())
		{
			// Todo : CachedGameState->GetTeamScoreComponent()->OnTeamScoreChanged.AddDynamic(this, &UParcelHUDWidget::K2_OnTeamScoreChanged);
		}
	}

	// 2. PlayerState 바인딩
	if (!CachedPlayerState.IsValid())
	{
		CachedPlayerState = Cast<AParcelPlayerState>(GetOwningPlayerState());

		if (CachedPlayerState.IsValid())
		{
			// Todo : CachedPlayerState->GetPlayerStatComponent()->OnPersonalScoreChanged.AddDynamic(this, &UParcelHUDWidget::K2_OnPersonalScoreChanged);
			
			GetWorld()->GetTimerManager().ClearTimer(RetryBindTimerHandle);
		}
		else
		{
			// [방어 코드] : 멀티플레이 환경의 안정성을 위해, Widget 호출을 0.1초마다 추가로 반복함
			if (!RetryBindTimerHandle.IsValid())
			{
				GetWorld()->GetTimerManager().SetTimer(RetryBindTimerHandle, this, &UParcelHUDWidget::TryBindUIEvents, 0.1f, true);
			}
		}
	}
}

 

1) K2는 'Kismet 2(키스멧 2)'의 약자이고, 언리얼 엔진에서 "이 함수는 블루프린트(WBP)와 연동하기 위한 전용 이벤트/함수다"라는 것을 명시하기 위해 쓰는 언리얼 엔진의 전통적인 네이밍 관례이다.

2) NativeConstruct() 적극 활용: UI의 초기화와 이벤트 바인딩은 반드시 클라이언트 사이드에서 확보되는 시점에 수행해야 안전하다.

3) GetOwningPlayerState() 사용: 리슨 서버나 멀티플레이 환경에서 플레이어의 정보를 정확하게 가져오도록 하기 위해(다른 플레이어의 정보를 가져오는 버그 차단) GetPlayerController(0)를 지양하고 소유자 기반의 함수를 사용한다.

4) 비동기 복제 방어 코드: 네트워크 지연으로 인해 클라이언트 화면에 UI가 먼저 켜지고 PlayerState 데이터가 0.01초 뒤에 들어오는 상황이 빈번한데,  이를 해결하기 위해 RetryBindTimerHandle을 활용해 바인딩을 완료하도록 설계했다.

 

이 구조를 베이스로 삼고, 블루프린트 위젯(WBP_InGameHUD 등)을 만들어 이 C++ 클래스를 상속받게 하면, K2_OnHPChanged 같은 이벤트 노드를 통해 UI 그래픽(ProgressBar, Text)을 아주 깔끔하게 갱신할 수 있게 된다.

 

이제 팀원들이 만들어둔 UTeamScoreComponentUPlayerStatComponent 쪽을 살펴보고, UI를 위한 멀티캐스트 델리게이트(BlueprintAssignable)가 선언되어 있다면 바로 연결할 수 있다. 만약 없다면 팀원에게 요청하거나, 미리 작업 내용을 알리고 수정하면 된다.

 

[2. TeamScoreComponent, PlayerStatComponent 수정]

// TeamScoreComponent.h

// [UI]
DECLARE_DYNAMIC_MULTICAST_DELEGATE_OneParam(FOnTeamScoreChangedSignature, int32, NewTeamScore);
DECLARE_DYNAMIC_MULTICAST_DELEGATE_OneParam(FOnRemainingTimeChangedSignature, float, RemainingTime);

	// [UI] HUD 위젯 바인딩 BroadCast 변수
	UPROPERTY(BlueprintAssignable, Category = "ParcelUI|Events")
	FOnTeamScoreChangedSignature OnTeamScoreChanged;

	UPROPERTY(BlueprintAssignable, Category = "ParcelUI|Events")
	FOnRemainingTimeChangedSignature OnRemainingTimeChanged;
	
	// [UI] 남은 시간 복제 수신 콜백 함수
	UFUNCTION()
	void OnRep_RemainingTime();
    
// TeamScoreComponent.cpp

float UTeamScoreComponent::GetComboMultiplier()
{
	return FMath::Clamp(MinComboMultiplier + ComboCount * 0.1f, MinComboMultiplier, MaxComboMultiplier);
}

// ========================= 점수 =========================

void UTeamScoreComponent::AddTeamScore(int32 Amount)
{
	if (GetOwner()->HasAuthority())
	{
		TeamScore += Amount * GetComboMultiplier();
		GAMERULE_LOG(Log, TEXT("[서버] TeamScore 변경 → %d, ComboCount → %d"), TeamScore, ComboCount);
	}
	
	// [UI] 호스트 UI 대시보드 동기화
	OnRep_TeamScore();
}

void UTeamScoreComponent::InitRemainingTime(float InTimeLimit)
{
	if (GetOwner()->HasAuthority())
		RemainingTime = InTimeLimit;
	
	// [UI] 초기화 시간 반영
	OnRep_RemainingTime();
}

void UTeamScoreComponent::DecreaseRemainingTime(float Amount)
{
	if (GetOwner()->HasAuthority())
		RemainingTime -= Amount;
	
	// [UI] 매 초 타이머UI 동기화
	OnRep_RemainingTime();
}

void UTeamScoreComponent::OnRep_TeamScore()
{
	GAMERULE_LOG(Log, TEXT("[클라이언트] TeamScore 수신 → %d"), TeamScore);
	
	// [UI] 수신 시점에 위젯 델리게이트 브로드캐스트
	if (OnTeamScoreChanged.IsBound())
	{
		OnTeamScoreChanged.Broadcast(TeamScore);
	}
}

// [UI] 타이머 복제 알림
void UTeamScoreComponent::OnRep_RemainingTime()
{
	if (OnRemainingTimeChanged.IsBound())
	{
		OnRemainingTimeChanged.Broadcast(RemainingTime);
	}
}
// PlayerStatComponent.h

// [UI]
DECLARE_DYNAMIC_MULTICAST_DELEGATE_OneParam(FOnPersonalScoreChangedSignature, int32, NewPersonalScore);
	
    // [UI] HUD 위젯 바인딩 브로드캐스트 변수
	UPROPERTY(BlueprintAssignable, Category = "ParcelUI|Events")
	FOnPersonalScoreChangedSignature OnPersonalScoreChanged;

	// [UI] 개인 점수 복제 수신 콜백 함수
	UFUNCTION()
	void OnRep_PersonalScore();

// PlayerStatComponent.cpp

void UTeamScoreComponent::AddTeamScore(int32 Amount)
{
	if (GetOwner()->HasAuthority())
	{
		TeamScore += Amount * GetComboMultiplier();
		GAMERULE_LOG(Log, TEXT("[서버] TeamScore 변경 → %d, ComboCount → %d"), TeamScore, ComboCount);
	}
	
	// [UI] 호스트 UI 대시보드 동기화
	OnRep_TeamScore();
}

void UTeamScoreComponent::InitRemainingTime(float InTimeLimit)
{
	if (GetOwner()->HasAuthority())
		RemainingTime = InTimeLimit;
	
	// [UI] 초기화 시간 반영
	OnRep_RemainingTime();
}

void UTeamScoreComponent::DecreaseRemainingTime(float Amount)
{
	if (GetOwner()->HasAuthority())
		RemainingTime -= Amount;
	
	// [UI] 매 초 타이머UI 동기화
	OnRep_RemainingTime();
}

void UTeamScoreComponent::OnRep_TeamScore()
{
	GAMERULE_LOG(Log, TEXT("[클라이언트] TeamScore 수신 → %d"), TeamScore);
	
	// [UI] 수신 시점에 위젯 델리게이트 브로드캐스트
	if (OnTeamScoreChanged.IsBound())
	{
		OnTeamScoreChanged.Broadcast(TeamScore);
	}
}

// [UI] 타이머 복제 알림
void UTeamScoreComponent::OnRep_RemainingTime()
{
	if (OnRemainingTimeChanged.IsBound())
	{
		OnRemainingTimeChanged.Broadcast(RemainingTime);
	}
}

 

멀티플레이 환경(리슨 서버)에서 위젯이 실시간으로 반응하려면, 기존의 단순 복제(Replicated) 변수 중 UI가 감시해야 하는 핵심 변수들을 복제 콜백(ReplicatedUsing) 구조로 전환하여 클라이언트 단에서 델리게이트를 뿜어내게 만드는 것이 핵심이다. 또한 서버 호스트(방장)의 UI도 동기화되도록 값 변경 시점에 콜백을 수동 호출해 주는 안전장치만 가미했다.

 

1) TeamScoreComponent : 상단에 UI용 델리게이트 선언을 추가하고, TeamScore 외에 UI가 매초 감시해야 하는 RemainingTime도 ReplicatedUsing 콜백 구조로 전환. 또한, 값이 서버에서 바뀔 때 리슨 서버 호스트 본인의 UI도 즉시 갱신되도록 OnRep 계열 함수들을 수동 호출해 주고, 콜백 내부에서 Broadcast() 수행.

 

2) PlayerStatComponent : 개인 스탯 UI용 델리게이트와 PersonalScore 변화를 감지할 OnRep_PersonalScore 함수를 주입.

 

 

[3-1. UI 연동 : WBP_InGameHUD]

1단계: 위젯 블루프린트(WBP) 생성 및 C++ 클래스 상속

 

에디터 콘텐츠 브라우저에서 우클릭 후 User Interface -> Widget Blueprint를 생성 (이름 예시: WBP_InGameHUD)

2단계: 디자인 뷰포트 구성 (변수화 필수)

3단계: 이벤트 그래프(Event Graph) 연동 로직 짜기

 

1. 팀 점수 & 개인 점수 연동

Event K2 On Team Score Changed 노드 배치 ➔ Txt_TeamScore 변수를 겟(Get)으로 그래프에 배치 ➔ Set Text (Text) 노드를 연결하고 New Team Score 인자를 넘겨줘야 함.

 

Event K2 On PersonalScore Changed 노드 배치 ➔ Txt_PersonalScore 변수 배치 ➔ Set Text (Text) 노드를 연결하고 New Personal Score 인자를 넘겨줘야 함.

 

2. 타이머 포맷팅 연동 (MM:SS 변환)

Event K2 On Remaining Time Changed 노드를 배치.

 

Remaining Time 값을 Truncate(절삭) 노드에 연결해 정수(초)로 만듬.

정수가 된 총 초 단위를 Total Seconds / 60 해서 분(Minutes)을 구함.

정수가 된 총 초 단위를 Total Seconds % 60 (나머지 연산) 해서 초(Seconds)를 구함.

Format Text 노드를 생성한 뒤, Text 칸에 {Min}:{Sec} 라고 적어줌.

입력 핀이 생기면 각각 분과 초를 연결해 줌. (이때 디테일 패널에서 최소 자릿수를 2자리로 세팅해주면 05:09 처럼 이쁘게 나옵니다.)

최종 출력을 Txt_Timer 컴포넌트의 Set Text 노드에 연결함.

4단계: 생성한 HUD 위젯을 플레이어 화면에 띄우기가장 정석적인 방법은 AParcelPlayerController의 BeginPlay 시점에 로컬 플레이어인 경우에만 위젯을 생성해 주는 것.

위젯을 다 만들었어도 화면에 그리는 코드가 없다면 보이지 않겠죠? 프로젝트 구조상 플레이어 컨트롤러(AParcelPlayerController)나 HUD 클래스(AParcelHUD)에서 이를 스폰해 주어야 함.

C++에서 남은 시간을 float(초 단위)로 매초 던져줌. 이걸 유저가 보기 편하게 02:30 형태로 포맷팅해 줌.

 

내부 델리게이트를 통해 신호가 오면, C++ 핸들러가 블루프린트 이벤트를 호출해 줍니다. 그리고 다음과 같이 노드를 배치했다.

 

동기화 확인: 서버나 클라이언트가 상자를 배송 구역에 넣었을 때, 두 플레이어의 화면 상단 팀 점수가 동시에 실시간으로 똑같이 올라가는지 확인.

오너십 확인: 내가 상자를 배송했을 때 내 화면의 개인 점수만 올라가고, 옆에 있는 다른 플레이어의 개인 점수판은 조용히 유지되는지 확인

 

 

 

[3-2. UI 연동 : ParcelPlayerController]

// Fill out your copyright notice in the Description page of Project Settings.

#pragma once

#include "CoreMinimal.h"
#include "GameFramework/PlayerController.h"
#include "ParcelPlayerController.generated.h"

/**
 *
 */
UCLASS()
class PARCEL_KNIGHT_API AParcelPlayerController : public APlayerController
{
	GENERATED_BODY()
	
protected:
	virtual void BeginPlay() override;
	
	virtual void SetupInputComponent() override;
	
	// [Editor] HUD 위젯 클래스 지정
	UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "ParcelUI")
	TSubclassOf<UUserWidget> HUDWidgetClass;
	
	// HUD 위젯 인스턴스
	UPROPERTY(Transient, BlueprintReadOnly, Category = "ParcelUI")
	TObjectPtr<UUserWidget> HUDWidgetInstance;
};

#include "Core/ParcelPlayerController.h"
#include "Blueprint/UserWidget.h"

void AParcelPlayerController::BeginPlay()
{
	Super::BeginPlay();
	
	if (IsLocalController())
	{
		FInputModeGameOnly InputMode;
		SetInputMode(InputMode);
		bShowMouseCursor = false;
		
		// HUD
		if (IsLocalController())
		{
			if (HUDWidgetClass)
			{
				HUDWidgetInstance = CreateWidget<UUserWidget>(this, HUDWidgetClass);
				
				if (HUDWidgetInstance)
				{
					HUDWidgetInstance->AddToViewport();
				}
			}
		}
	}
}

void AParcelPlayerController::SetupInputComponent()
{
	Super::SetupInputComponent();
}

TSubclassOf<UUserWidget>는 "UUserWidget을 상속받은 블루프린트 클래스(WBP_ 등)의 '유형(Type)'만 안전하게 골라서 담겠다"라는 의미를 가진 템플릿 클래스다. 일반 오브젝트 포인터와 달리 에디터 인스펙터 창에서 아무 액터나 에셋이 지정되는 걸 원천 차단하고, 오직 위젯 블루프린트만 드롭다운 메뉴에 뜨도록 필터링해 주는 기능을 한다.

 

ParcelPlayerController.cpp

소스 파일에서는 오직 로컬 플레이어(화면의 주인)일 때만 에디터에서 받아온 클래스 정보(HUDWidgetClass)를 기반으로 위젯을 스폰(CreateWidget)한다.

 

ParcelPlayerController.h

헤더 파일에서 TSubclassOf 변수를 선언한다. 에디터에서 원형(Defaults)에 한 번만 지정해 두면 되므로 EditDefaultsOnly 매크로를 사용하는 것이 가장 깔끔하다.

 

[3-3. UI 연동 : 에디터 세팅]

 

 

블루프린트 컨트롤러 생성:

콘텐츠 브라우저에서 우클릭 후 Blueprint Class를 생성한다.

부모 클래스 검색창에 우리가 만든 ParcelPlayerController를 검색해 상속받는 블루프린트를 만든다 (이름 예시: BP_ParcelPlayerController).

 

WBP 클래스 할당, 게임모드에 등록, Player Controller Class 칸에 순수 C++ 클래스가 아닌, 방금 WBP를 매핑해 둔 BP_ParcelPlayerController를 등록해 주면 된다.

 

 

[3-4. UI 연동 : 디버깅]

지금 HUD자체는 화면에 잘 출력이 되는데, 문제는 남은시간, 팀 점수, 개인 점수 텍스트가 뜨지를 않는다. 문제를 해결해보자.

 

1) 이벤트 기반 UI의 함정 (초기 갱신 누락): 델리게이트(AddDynamic)는 값이 "바뀌는 순간(Broadcast)"에만 신호를 보낸다. 게임이 시작된 직후 점수가 0점 그대로이거나 타이머가 가동되기 전이라면, 컴포넌트는 아무런 신호도 쏘지 않는다. 신호가 안 오니 위젯의 Txt_Timer나 Txt_TeamScore는 에디터 디자인 창에 적어둔 기본 텍스트(예: "Text Block", "0") 상태로 멈춰 있는 것이다.

복제(Replication) 타이밍 이슈: 멀티플레이 환경에서는 클라이언트 컴퓨터가 위젯을 만들고 이벤트를 묶는(Binding) 속도보다, 서버가 초기 데이터(점수 0점, 시간 300초 등)를 복제해 주는 속도가 미세하게 더 빠를 수 있다. 즉, 신호탄이 이미 터진 다음에 라디오 주파수를 맞춘 격이 되어 초기 화면 갱신을 놓치게 된다.

 

해결 방법: 바인딩 성공 직후 "명시적 초기화(Pull)" 추가우리가 작성했던 ParcelHUDWidget.cpp 소스 코드를 아래와 같이 보완해보았다.

TryBindUIEvents() 함수에서 AddDynamic으로 주파수를 맞춘 직후, 컴포넌트에 들어있는 현재 값(Current Value)을 강제로 한 번 긁어와서 UI를 채워주는 코드를 심는다.

 

코드를 고쳤는데도 안 나왔다. 이러면 WBP 내부 노드가 끊겼을 가능성이 있으므로, 다음을 체크해보았다.

 

Text 컴포넌트의 Bind 기능 해제 확인: 텍스트 블록 디테일 패널의 Text 칸 옆에 있는 Bind(바인딩) 드롭다운 메뉴를 통해 함수를 강제로 연결해두었는지 확인한다. C++ 델리게이트 방식을 쓸 때는 그 Bind 칸은 무조건 None이어야 한다. 

 

이벤트 노드와 Set Text 연결 확인: WBP 이벤트 그래프에서 Event K2 On Remaining Time Changed 노드가 타겟 텍스트 블록의 Set Text (Text) 노드까지 하얀색 실행 선(Execute Line)이 끊어지지 않고 완벽하게 이어져 있는지 다시 한번 확인해보았다.

 

이후, 다음 멤버 함수를 UFUNCTION() 매크로와 함께 추가. AddDynamic에 연결할 네이티브 핸들러들은 반드시 UFUNCTION()이 붙어야 하고, 델리게이트가 쏘아주는 인자(int32, float 등)와 완벽히 형식이 일치해야 심볼 에러가 나지 않는다.

	UFUNCTION()
	void HandleOnTeamScoreChanged(int32 NewTeamScore);

	UFUNCTION()
	void HandleOnRemainingTimeChanged(float RemainingTime);

	UFUNCTION()
	void HandleOnPersonalScoreChanged(int32 NewPersonalScore); 

 

이후, 이벤트 바인딩 과정에서 두가지의 방법 중 하나를 선택해야 했다.

 

최종 출시(Production) 단계에서는 '게터(Getter) 함수'를 쓰는 것이 더 안정적이지만, 지금처럼 파트가 나뉜 '협업 개발(Development)' 단계에서는 FindComponentByClass를 활용한 방식이 훨씬 유연하고 안정적임을 고려한다.

 

게터(Getter) 함수 방식:

 

ParcelGameState 내부에 FORCEINLINE UTeamScoreComponent* GetTeamScoreComponent() const 같은 함수를 뚫어놓고 가져오는 방식.

 

장점 (안정성 측면): 컴파일러가 정적 검증을 해준다. 성능도 포인터 주소를 즉시 참조하므로 가장 빠르다.

 

단점 (협업 측면): 백엔드 팀원의 작업 속도에 UI 파트가 종속된다. 게터 함수가 헤더에 구현되어 있지 않으면 내 UI 코드 전체가 컴파일 에러로 묶여서 테스트 조차 못 하는 교착 상태에 빠지기 쉽다.

 

FindComponentByClass 방식:

 

액터가 가지고 있는 컴포넌트 리스트를 엔진 시스템이 자동으로 뒤져서 찾아오는 방식.

 

장점 (협업 측면): 파트 간의 간섭이 완벽하게 차단된다. 수현 팀원의 프레임워크 클래스(GameState 등)에 게터 함수가 있든 없든, 그 액터에 컴포넌트가 부착만 되어 있다면 UI 담당자 선에서 독자적으로 이벤트를 묶고 WBP 화면 연동 테스트까지 끝낼 수 있다

단점 (안정성 측면): 만약 컴포넌트가 액터에 누락되었거나 이름이 아예 바뀌면, 컴파일은 성공하지만 게임 속에서 UI가 조용히 먹통이 되는(실행 시 nullptr 반환) '소리 없는 버그'가 발생할 수 있다.

 

 

"지금은 FindComponentByClass로 개발 속도를 올리고, 마일스톤 마감 직전에 게터로 리팩터링하자" 는 결론을 내렸다. 나중에 프로젝트 빌드가 다 같이 안정화되었을 때, 게터 함수만 넣고 리팩토링하기로 했다.

 

더보기
비교 항목 게터 함수 방식 FindComponentByClass 방식
에러 발견 시점 컴파일 타임 런타임 (nullptr 반환)
실행 성능 O(1). 주소로 즉시 접근 O(n). 컴포넌트 배열을 순회함.
협업 독립성 낮음(백엔드 코드와 결합도가 높아짐) 높음(백엔드 수정 없이 UI 파트 진행)
의존성 강한 결합 느슨한 결합

 

추가 디버깅 :

레벨 타이머 문제와 팀 점수/개인 점수 텍스트 출력 오류 문제 해결하기.

 

1) 

다음과 같이 블루프린트 에디터에서 위젯 Graph를 수정함. Format Text를 사용해야 기존에 원하던 모양의 문구를 잘 출력할 수 있음.

 

2) C++ 의 핸들러 함수를 이용해서 블루프린트 노드 사용을 간편하게 할 수 있음.

// 헤더파일
	// WBP에서 사용할 수 있도록 열어두는 이벤트 (델리게이트 -> 브로드캐스트. K2는 Kismet2 약자입니다)
	UFUNCTION(BlueprintImplementableEvent, Category = "UI")
	void K2_OnHPChanged(float CurrentHP, float MaxHP);
	
	UFUNCTION(BlueprintImplementableEvent, Category = "UI")
	void K2_OnTeamScoreChanged(const FText& DisplayText);

	UFUNCTION(BlueprintImplementableEvent, Category = "UI")
	void K2_OnPersonalScoreChanged(const FText& DisplayText);
	
	UFUNCTION(BlueprintImplementableEvent, Category = "UI")
	void K2_OnRemainingTimeChanged(const FText& DisplayText);
    
    
// 소스 파일

// 핸들러 함수

void UParcelHUDWidget::HandleOnTeamScoreChanged(int32 NewTeamScore)
{
	FText FormattedText = FText::Format(FText::FromString(TEXT("팀 점수 : {0}")), FText::AsNumber(NewTeamScore));
	K2_OnTeamScoreChanged(FormattedText);
}

void UParcelHUDWidget::HandleOnPersonalScoreChanged(int32 NewPersonalScore)
{
	FText FormattedText = FText::Format(FText::FromString(TEXT("개인 점수 : {0}")), FText::AsNumber(NewPersonalScore));
	K2_OnPersonalScoreChanged(FormattedText);
}

void UParcelHUDWidget::HandleOnRemainingTimeChanged(float RemainingTime)
{
	int32 TotalSeconds = FMath::Max(0, FMath::FloorToInt(RemainingTime));
	int32 Minutes = TotalSeconds / 60;
	int32 Seconds = TotalSeconds % 60;

	// 언리얼 내장 포맷 옵션
	FNumberFormattingOptions TwoDigitOptions;
	TwoDigitOptions.MinimumIntegralDigits = 2;

	FText MinText = FText::AsNumber(Minutes, &TwoDigitOptions);
	FText SecText = FText::AsNumber(Seconds, &TwoDigitOptions);
	
	FText FormattedTime = FText::Format(
		FText::FromString(TEXT("남은시간 {0} : {1}")), 
		MinText, 
		SecText
	);
	
	K2_OnRemainingTimeChanged(FormattedTime);
}

// 음수 시간이 나오지 않도록 방어하고 정수로 절삭

// 9분을 09분으로, 5초를 05초로 만들기 위한 언리얼 내장 포맷 옵션 + // 최소 2자리 보장

// 중괄호 {0}, {1} 자리에 분, 초 텍스트를 쏙 집어넣어 최종 문장 조립

// 블루프린트(WBP)에는 조립이 끝난 FText만 패스

 

3) 남은 시간 타이머가 작동하지 않는 이유

작성한 TeamScoreComponent 코드는 ReplicatedUsing=OnRep_RemainingTime 스위치를 아주 올바르게 켜두었고, 이 스위치는 서버에서 RemainingTime 변수의 값이 "바뀌는 순간"에만 클라이언트로 신호를 보낸다.

점수는 상자를 넣을 때마다 값이 변하니까 복제가 빵빵 터지는데, 타이머가 전혀 움직이지 않고 멈춰 있다면 서버의 마스터 루프(GameMode나 DeliveryRuleComponent)에서 DecreaseRemainingTime() 함수를 매 초마다 구동해 주지 않고 있기 때문이다. 서버에서 값이 변하지 않으니 클라이언트들에게 복제 신호(OnRep)가 단 한 번도 전송되지 않는 것이다.

 

조치 방법

 

UDeliveryRuleComponent 내부의 매 초 작동하는 타이머 루프에서 TeamScoreComp->DecreaseRemainingTime(1.0f);를 실제로 호출하고 있는지 체크해보아야 한다. 지금처럼 매 초마다 float 변수를 서버에서 클라이언트로 계속 네트워크 복제(Replication)하는 방식은 서버 대역폭을 낭비하고 클라이언트 화면에서 타이머가 뚝뚝 끊겨 보이는 현상(Jitter)을 유발할 수 있다. 나중에 리팩토링하게 된다면, 매 초 시간이 깎이는 게 아니라 라운드가 종료되는 절대 시간(종료 타임스탬프)을 딱 한번 복제해 두고, 클라이언트 UI 위젯이 자체적으로 남은 시간을 계산하게 만드는 것이 라이라 및 전 세계 게임 아키텍처의 표준이다.

 

백엔드(DeliveryRuleComponent)를 수정하면, 제한 시간 데이터는 매 초 복제되는 방식이 아니라 "라운드가 끝나는 절대 시간(GetWorld()->GetTimeSeconds() + 제한시간)"이라는 하나의 float 변수(예: RoundExpirationTime)로 TeamScoreComponent에 단 한 번만 복제되어 들어오게 된다. 그 구조가 작동할 때, UI 위젯은 서버 소식을 기다리지 않고 자기 PC의 시계와 비교하며 스스로 타이머를 굴려야 한다.

 

OnRemainingTimeChanged 델리게이트는 UTeamScoreComponent에 들어있었다.

 

컴포넌트 위치: UTeamScoreComponent (Core 폴더 소속)

부착되는 부모 액터: AParcelGameState (게임의 전역 전광판 역할)

이제 ParcelHUDWidget에서 GameState를 캐싱한 뒤 이 컴포넌트를 찾아 주파수(AddDynamic)를 맞추면 된다.

 

그 다음으로 핸들러 함수를 타임스탬프 + 블루프린트 에디터 작업을 위한 코드로 수정해보자.

 

블루프린트 이벤트(K2_)가 FText 대신 다시 순수 데이터인 float을 받도록 롤백

핸들러에서는 복잡한 문자열 조립(FText::Format)을 과감히 지우고, 계산된 RawRemainingTime만 블루프린트로 뿜어준다.

// 헤더 파일
protected:

	// WBP에서 사용할 수 있도록 열어두는 이벤트 (델리게이트 -> 브로드캐스트. K2는 Kismet2 약자입니다)
	UFUNCTION(BlueprintImplementableEvent, Category = "UI")
	void K2_OnHPChanged(float CurrentHP, float MaxHP);
	
	UFUNCTION(BlueprintImplementableEvent, Category = "UI")
	void K2_OnTeamScoreChanged(const FText& DisplayText);

	UFUNCTION(BlueprintImplementableEvent, Category = "UI")
	void K2_OnPersonalScoreChanged(const FText& DisplayText);
	
	UFUNCTION(BlueprintImplementableEvent, Category = "ParcelUI")
	void K2_OnRemainingTimeChanged(const FText& DisplayText, float RawRemainingTime);
    
   	// [Timestamp] 핸들러(종료 시각)
	UFUNCTION()
	void HandleOnExpirationTimeChanged(float NewExpirationTime);
	
	// [Timestamp] (남은 시간)
	void UpdateLocalTimer();
    
    // [Timestamp] UI 자체 로컬 타이머 핸들 및 종료 시간 저장 변수
	FTimerHandle UILocalTimerHandle;
	float CachedExpirationTime = 0.0f;
 
 
// 소스 파일
void UParcelHUDWidget::HandleOnExpirationTimeChanged(float NewExpirationTime)
{
	CachedExpirationTime = NewExpirationTime;

	GetWorld()->GetTimerManager().ClearTimer(UILocalTimerHandle);

	if (CachedExpirationTime > 0.0f)
	{
		// 0.2초마다 내 컴퓨터 시계 기준으로 로컬 타이머 가동 (렉 제로)
		GetWorld()->GetTimerManager().SetTimer(UILocalTimerHandle, this, &UParcelHUDWidget::UpdateLocalTimer, 0.2f, true);
	}
}

void UParcelHUDWidget::UpdateLocalTimer()
{
	float CurrentWorldTime = GetWorld()->GetTimeSeconds();
	float RawRemainingTime = CachedExpirationTime - CurrentWorldTime;

	if (RawRemainingTime <= 0.0f)
	{
		GetWorld()->GetTimerManager().ClearTimer(UILocalTimerHandle);
		RawRemainingTime = 0.0f;
	}
	
	// Min Sec 쪼개기
	int32 TotalSeconds = FMath::FloorToInt(RawRemainingTime);
	int32 Minutes = TotalSeconds / 60;
	int32 Seconds = TotalSeconds % 60;
	
	// 언리얼 내부 함수를 사용해서 2자리 수 맞추기
	FNumberFormattingOptions TwoDigitOptions;
	TwoDigitOptions.MinimumIntegralDigits = 2;
	
	FText MinText = FText::AsNumber(Minutes, &TwoDigitOptions);
	FText SecText = FText::AsNumber(Seconds, &TwoDigitOptions);
	
	// Format Text 문자열 조립
	FText FormattedTime = FText::Format(
		FText::FromString(TEXT("남은시간 {0} : {1}")), 
		MinText, 
		SecText
	);
	
	K2_OnRemainingTimeChanged(FormattedTime, RawRemainingTime);
}

이제 블루프린트 이벤트 그래프에서 K2_OnRemainingTimeChanged 노드를 배치하면 우측 핀으로 Remaining Time (Float)이 들어온다. 내 컴퓨터 시계로 계산된 값이라 뚝뚝 끊기지 않고 부드럽게 들어오게 된다.

 

Set Color and Opacity는 3 종류가 있었는데, 구분지어서 살펴보고 넘어가보자.

 

 

 

 

1) Set Color and Opacity (Target is Text)

 

용도: UI에 배치된 Text Block(글자)의 색상과 투명도를 바꿀 때 사용

 

2) Set Color and Opacity (Target is Widget)

용도: 일반적인 위젯 컴포넌트나 특정 UI 요소 전체의 틴트(Tint) 색상을 조절할 때 사용

 

3) Set Color and Opacity (Target is Border)

용도: UI의 배경판(Border)이나 다른 특정 UI 컴포넌트의 색을 바꿀 때 나오는 노드야. 당연히 텍스트에는 쓸 수 없음.

 

 

 

[4. ParcelGameState, ParcelHUDWidget 수정]

여전히 텍스트가 제대로 출력되지 않는 이유는 ParcelGameState에서 클라이언트에 의해 복제를 허용하는 SetIsReplicated(true)가 빠져있기 때문이었다. 따라서 GameState 생성자에 다음과 같이 복제를 허용하도록 코드를 추가하였다.

AParcelGameState::AParcelGameState()
{
    TeamScoreComp = CreateDefaultSubobject<UTeamScoreComponent>("TeamScoreComponent");
    
    TeamScoreComp->SetIsReplicated(true); 
}

 

또한 멀티플레이를 위해 ParcelHUDWidget에 안전장치 코드를 추가하였다. 누락된 코드를 발견해서 이를 보완했다.

				TeamScoreComp->OnTeamScoreChanged.AddDynamic(this, &UParcelHUDWidget::HandleOnTeamScoreChanged);
				HandleOnTeamScoreChanged(TeamScoreComp->GetTeamScore());

				// 라운드 만료 시간 강제 수신
				TeamScoreComp->OnRemainingTimeChanged.AddDynamic(this, &UParcelHUDWidget::HandleOnExpirationTimeChanged);
				HandleOnExpirationTimeChanged(TeamScoreComp->GetRemainingTime());


void UParcelHUDWidget::HandleOnExpirationTimeChanged(float NewExpirationTime)
{
	if (GetWorld() && NewExpirationTime > 0.0f)
	{
		CachedExpirationTime = GetWorld()->GetTimeSeconds() + NewExpirationTime;

		GetWorld()->GetTimerManager().ClearTimer(UILocalTimerHandle);
		GetWorld()->GetTimerManager().SetTimer(UILocalTimerHandle, this, &UParcelHUDWidget::UpdateLocalTimer, 0.2f, true);
	}
}

// 3. 멀티플레이 안전장치
	if (CachedGameState.IsValid() && CachedPlayerState.IsValid())
	{
		GetWorld()->GetTimerManager().ClearTimer(RetryBindTimerHandle);
		UE_LOG(LogTemp, Log, TEXT("[UI] 모든 요소가 안전하게 바인딩되어 재시도 타이머를 해제."));
	}
	else
	{
		if (!RetryBindTimerHandle.IsValid() && GetWorld())
		{
			GetWorld()->GetTimerManager().SetTimer(RetryBindTimerHandle, this, &UParcelHUDWidget::TryBindUIEvents, 0.1f, true);
			UE_LOG(LogTemp, Warning, TEXT("[UI] 일부 액터가 nullptr입니다. 복제 동기화를 기다리며 0.1초 후 재시도합니다"));
		}
	}

 

이후 ParcelGameMode를 수정했다. GameMode에 시간 카운트가 시작하는 기능이 구현되어있지 않았다. 

겸사겸사 서브시스템과 스폰지점을 연결시켜 놓아서 게임모드가 해당 스폰지점을 인지하고, BoxData에 있는 박스 정보를 인식하고 집어넣을 수 있도록 했다. 먼저 DeliveryBoxSpawner를 만들었다.

#pragma once

#include "CoreMinimal.h"
#include "GameFramework/Actor.h"
#include "DeliveryBoxSpawner.generated.h"

UCLASS()
class PARCEL_KNIGHT_API ADeliveryBoxSpawner : public AActor
{
	GENERATED_BODY()
	
public:	
	ADeliveryBoxSpawner();

protected:
	UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Components")
	TObjectPtr<USceneComponent> RootComp;

};

#include "Delivery/DeliveryBoxSpawner.h"

ADeliveryBoxSpawner::ADeliveryBoxSpawner()
{
	PrimaryActorTick.bCanEverTick = false;

	RootComp = CreateDefaultSubobject<USceneComponent>(TEXT("RootComponent"));
	SetRootComponent(RootComp);
}

 

그리고 GameMode를 수정했다. StageData를 읽어올 수 있도록 UPROPERTY매크로를 이용했고, StartRound를 하게 되면 타이머가 시작되고, 서브시스템이 초기화되고 월드 스폰 포인트가 추적되며, 에디터에서 배치해놓은 박스 스포너 액터를 추적하게 했다.

	UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "GameRules", meta = (AllowPrivateAccess = "true"))
	TObjectPtr<UStageData> DefaultStageData;
    
    
void AParcelGameMode::StartRound(UStageData* InStageData)
{
	if (!InStageData) return;
	
	// 타이머 시작
	if (DeliveryRuleComp)
	{
		DeliveryRuleComp->StartRound(InStageData->TimeLimit, InStageData->TargetScore);
	}
	
	// 서브시스템 초기화 + 월드 스폰 포인트 추적
	if (GetWorld())
	{
		UDeliverySubsystem* DeliverySubsystem = GetWorld()->GetSubsystem<UDeliverySubsystem>();
		if (DeliverySubsystem)
		{
			// 에디터에서 배치해놓은 박스 스포너 액터 추적
			DeliverySubsystem->InitializeStage(InStageData);
			
			TArray<ADeliveryBoxSpawner*> SpawnPoints;

			if (GetWorld())
			{
				for (ADeliveryBoxSpawner* Spawner : TActorRange<ADeliveryBoxSpawner>(GetWorld()))
				{
					if (Spawner)
					{
						SpawnPoints.Add(Spawner);
					}
				}
			}

			if (SpawnPoints.IsEmpty())
			{
				GAMERULE_LOG(Error, TEXT("[GameMode] 레벨에 배정된 ADeliveryBoxSpawner가 하나도 없습니다. 임시 좌표에 스폰합니다."));
			}

			int32 SpawnPointIndex = 0;
			int32 TotalSpawnedCount = 0;
			
			// StageData의 목록을 받아와 상자 스폰 + 위치 매핑
			for (const FStageBoxSpawnInfo& SpawnInfo : InStageData->BoxDataList)
			{
				if (!SpawnInfo.BoxTypeTag.IsValid()) continue;

				for (int32 i = 0; i < SpawnInfo.SpawnCount; ++i)
				{
					FVector SpawnLocation;
					FRotator SpawnRotation = FRotator::ZeroRotator;

					// 배치된 스폰 포인트가 있다면 그 위치를 쓰고, 부족하면 기본 공중 좌표 제공(예외 처리)
					if (SpawnPoints.IsValidIndex(SpawnPointIndex))
					{
						SpawnLocation = SpawnPoints[SpawnPointIndex]->GetActorLocation();
						SpawnRotation = SpawnPoints[SpawnPointIndex]->GetActorRotation();
						// 다음 상자는 다음 스폰 포인트로
						SpawnPointIndex++;
					}
					else
					{
						// 스폰 포인트 개수보다 스폰할 상자가 더 많을 때의 예외 안전장치
						SpawnLocation = FVector(0.f, 0.f, 400.f) + FVector(i * 100.f, 0.f, 0.f);
					}

					// 서브시스템을 통해 배송 상자 스폰
					AActor* NewBox = DeliverySubsystem->SpawnBox(SpawnInfo.BoxTypeTag, SpawnLocation, SpawnRotation);
					if (NewBox)
					{
						TotalSpawnedCount++;
					}
				}
			}
			GAMERULE_LOG(Log, TEXT("[GameMode] 레벨 스폰 포인트를 활용해 총 %d개의 상자 스폰 완료"), TotalSpawnedCount);
		}
	}
}

 

 


2. 게임 플레이 태그 Action과 State 구분하기

 

래그돌은 게임 개발에서 "명령(Action)으로 시작해서 지속적인 조건(State)으로 유지되고, 다시 다른 명령(Action)으로 끝나는" 가장 대표적이고 복잡한 예시이다. 하나의 개념이 행동과 상태 모두에 걸쳐 있을 때는 "수명 주기(Lifecycle)"를 쪼개서 태그를 분리하는 것이 라이라(Lyra) 스타일 및 현업의 정석 표준이다.

 

1. 래그돌의 수명 주기 분해 (태그 설계)

 

래그돌은 단 하나의 태그로 퉁치는 게 아니라, 3가지 단계로 나누어 태그를 정의해야 충돌이 나지 않는다.

 

Character.Action.TriggerRagdoll (행동)

의미: "래그돌을 발동시켜라!" 하는 순간적인 명령(트리거)이다.

수명: 함정에 부딪히거나 테스트 키를 누르는 그 '찰나'의 순간에만 켜졌다가 꺼진다.

 

Character.State.Ragdoll (상태)

의미: "지금 이 캐릭터는 물리 시뮬레이션으로 바닥에 굴러다니는 중이다."라는 지속적인 상태이다.

수명: 캐릭터가 제어권을 잃고 바닥에 누워있는 전체 시간 동안 계속 유지된다. 다른 모든 조작(이동, 점프, 상호작용)을 차단하는 기준 조건이 된다.

 

Character.Action.GetUp (행동)

의미: "바닥에서 일어나는 애니메이션을 재생하는 중이다."라는 일시적인 행동이다.

수명: 래그돌이 끝나고 '일어나기 몽타주'가 재생되는 1~2초 동안만 유지된다. (이때는 무적 판정을 주거나, 조작은 여전히 막아야 하므로 액션 태그로 관리)

 

2. 실제 게임 플레이 흐름에서의 태그 작동 예시

 

① [발동 시점] 함정 충돌 또는 키 입력

플레이어가 돌부리에 걸림 ➔ Character.Action.TriggerRagdoll 태그를 캐릭터에게 부여.

이 액션 태그가 들어오면 C++의 StartRagdoll() 함수가 실행됨.

 

② [지속 시점] 물리 시뮬레이션 중 (바닥에 누워있음)

StartRagdoll() 내부에서 즉시 Character.Action.TriggerRagdoll을 제거(Remove)함과 동시에 Character.State.Ragdoll 태그를 부여.

이 상태가 유지되는 동안:

히어로 컴포넌트(입력 파트)는 Character.State.Ragdoll이 있는 걸 보고 Move(), StartJump()를 전부 return;으로 차단함.

카메라 컴포넌트는 이 태그를 보고 '래그돌 전용 카메라 모드(높이 낮추고 멀어지기)'로 전환함.

 

③ [종료 시점] 다시 일어나기

속도가 멈추고 지면이 감지되어 StopRagdoll()이 호출됨.

바닥에 누워있던 상태가 끝났으므로 Character.State.Ragdoll 태그를 제거함.

그리고 일어나기 몽타주를 틀면서 Character.Action.GetUp 태그를 부여함.

주의: 이때 상태 태그가 사라졌더라도 Character.Action.GetUp 액션 태그가 켜져 있기 때문에, 입력 컴포넌트에서는 여전히 "아직 일어나는 중이니까 움직이지 마" 하고 조작을 막아줄 수 있음.

애니메이션이 완전히 끝나면(Notify 또는 블렌드 완료시) Character.Action.GetUp 태그를 제거 ➔ 이제 완벽한 일반 상태로 복귀

 

어떤 시스템의 '시작 버튼'과 '종료 연출'은 Action 태그로 관리하고, 그 사이에 '유지되는 조건 판정'은 State 태그로 관리한다



*오늘의 총평*

UI와 게임모드, 게임스테이트, 서브시스템, 딜리버리 파트, 캐릭터 파트를 오가고 솔로플레이 게임과는 다른 구현 방식 / 디버깅 방식 때문에 시간을 많이 소모했다. 하지만 그만큼 얻는 것도 배우는 것도 많았던 하루였다.

'TIL' 카테고리의 다른 글

07.13 TIL  (0) 2026.07.13
07.10 TIL  (0) 2026.07.10
07.08 TIL  (0) 2026.07.08
07.07 TIL  (0) 2026.07.07
07.06 TIL  (0) 2026.07.06