목표 : 키 입력을 프레임 단위로 해석해서 게임 로직에 전달할 수 있게 처리

1. Input 클래스 추가

// 이전 프레임에는 키가 안눌렸다가 현재 프레임에 눌렸을때 1번만 호출
bool GetKeyDwon(int keyCode);

// 이전 프레임에는 키가 눌렸다가 현재 프레임에 키 입력이 해제되면 1번만 호출
bool GetKeyUp(int keyCode);

// 현재 프레임에 입력이 눌리면 계속 호출.
bool GetKey(int keyCode);

static Input& Get();

 

KeyState 구조체 생성

// 키 입력 상태 구조체.
struct KeyState 
{
	// 현재 프레임에 키가 눌렸는지.
	bool isKeyDown = false;
	// 이전 프레임에 키가 눌렸는지.
	bool wasKeyDown = false;
};

 

입력 수집방식(Windows API)

Input::ProcessInput():
keyStates[ix].isKeyDown = (GetAsyncKeyState(ix) & 0x8000) != 0;

의미:

- GetAsyncKeyState의 최상위 비트로 눌림 여부 확인

- 0~255 전체 키를 매 프레임 스캔

 

2. Engine 클래스 

 

ProcessInput  : 현재 프레임에 어떤 키값이 입력됐는지 업데이트처리하고

SavePreviousInputStates  : 다음 프레임에 이전 프레임에 어떤 키가 입력되어있는지 처리

void Engine::ProcessInput()
{
    assert(input);
    input->ProcessInput();
}

void Engine::SavePreviousInputStates()
{
    assert(input);
    input->SavePreviousStates();
}

 

1. Level 클래스 :

Level 엔진이 관리하는 요소, Level안에는 오브젝트의 가장 기본인 Actor들을 관리하도록

	void Level::ProcessAddAndDestoryActors()
	{
		// 액터 제거 처리.
		for (auto iterator = actorList.begin(); iterator != actorList.end();)
		{
			//제거 요청 여부.
			if ((*iterator)->HasExpired())
			{
				iterator = actorList.erase(iterator);
				continue;
			}

			++iterator;
		}

		// 액터 추가 처리.

		// 추가요청된 목록이 있는지 확인.
		if (addRequestedActorList.empty())
		{
			return;
		}

		for (const std::shared_ptr<Actor>& actor : addRequestedActorList)
		{
			actorList.emplace_back(actor);
		}

		// 정리.
		addRequestedActorList.clear();
	}

 

2. Actor 클래스 :

Actor가 프레임 이벤트를 받음

	void Actor::BeginPlay()
	{
		// 중복 호출 방지를 위해 설정.
		hasBeganPlay = true;
	}
	void Actor::Tick(float deltaTime)
	{
	}
	void Actor::Draw()
	{
	}
	void Actor::Destroy()
	{
		// 액터 삭제 예약
		// 다음 프레임에 엑터가 레벨에서 제거됨.
		hasExpired = true;
	}
	void Actor::QuitGame()
	{
		// 엔진 종료 요청.
		Engine::Get().Quit();
	}

	std::shared_ptr<Craft::Level> Actor::GetOwner()
	{
		return owner.lock();
	}

	void Actor::SetOwner(std::weak_ptr<Level> newOwner)
	{
		owner = newOwner;
	}

 

 

3. Engine 코드 수정

주요함수 : ProcessInput(), OnInitialized(). BeginPlay(), Tick(), Draw(). 

 

mainLevel과 nextLevel로 관리하여 안전하게 레벨 교체 처리

// 레벨 전환 처리
if (nextLevel)
{
 기존 레벨 정리.
if (mainLevel)
{
	mainLevel.reset();
}
	// 이전 프레임에 전환 요청된 레벨을 메인 레벨로 설정
	mainLevel = std::move(nextLevel);
	// 정리.
	nextLevel.reset();
}
if (mainLevel)
{
	mainLevel->ProcessAddAndDestoryActors();
}

 

 

4. 프로젝트 구조.

Engine -> Level -> Acotr 이벤트 전달구조

 

5. 학습요소

- CPP는 프로젝트 상대경로 안되는 경우

속성 -> C/C++ -> 일반 -> 추가 포함 디렉터리 : $(ProjectDir)\;

 

-  액터 생성 함수 템플릿

Actor 파생 타입만 생성하도록 제한하고, 생성자 인자를 전달해서 shared_ptr로 만든 뒤, 관리 리스트에 넣고 Owner까지 설정하는 Factory 함수

template<typename T, typename ...Args,
typename = std::enable_if_t<std::is_base_of<Actor, T>::value>> std::shared_ptr<T> SpawnActor(Args... args)
{
	// 새로운 액터 객체 생성
	std::shared_ptr<T> newActor = std::make_shared<T>(std::forward<Args>(args)...);

	//추가 요청 목록에 추가
	addRequestedActorList.emplace_back(newActor);

	// 오너십 설정.
	newActor->SetOwner(shared_from_this());

	return newActor;
}

Args... : 생성자 인자를 가변 개수로 받을 수 있음

enable_if + is_base_of : T가 Actor를 상속한 클래스일 때만 이 함수가 컴파일 가

enable_shared_from_this : 객체 자기 자신을 가리키는 shared_ptr을 안전하게 얻기위한 도우미 클래스 (객채 자체가 shared_ptr으로 관리되고 있어야함)

1. CPP 기반 프로젝트생성

2. 최종빌드 경로, 중간 파일 경로 지정

3. 메인 루프 기본 함수 구성

namespace Craft
{
	class Engine {

		public:
			Engine();
			virtual ~Engine();

			// 게임 루프 실행 함수
			void Run();

			// 엔진 종료함수
			void Quit();

		protected:
			// 입력 처리 함수.
			void ProcessInput();
			
			// 초기화 함수
			// 레벨 초기화 함수.
			// 엑터 초기화 함수.
			void OnInitialized();

			// 업데이트 함수.
			void BeginPlay();

			// 렌더링.
			void Tick(float deltaTime);

			// 이전 입력을 저장하는 함수.
			void Draw();

			// 엔진 종료 시 정리 함수.
			void SavePreviousInputStates();

			// 엔진 종료 시 정리
			void ShutDown();
	};
}

 

Engine::Run() 함수

더보기
	Engine.cpp
    
    void Engine::Run()
	{
		// 윈도우즈가 제공하는 고해상도 타이머(하드웨어 타이머)

		// QueryPerformanceFrequency : 타이머의 해상도
		// ex: 밀리세컨트 (1/1000)해상도 = 1000.
		LARGE_INTEGER frequency;
		QueryPerformanceFrequency(&frequency);

		// 현재 시간 확인.
		LARGE_INTEGER counter;
		QueryPerformanceCounter(&counter);

		// 프레임 시간 계산을 위한 변수.
		int64_t currentTime = counter.QuadPart;
		int64_t previousTime = currentTime;

		// 프레임 고정.
		float oneFramTime = 1.0f / setting.framerate;

		while (!isQuit)
		{
			// 입력 처리.
			ProcessInput();

			// 현재 시간 확인.
			QueryPerformanceCounter(&counter);

			// 현재 시간 저장.
			currentTime = counter.QuadPart;

			// 프레임 시간 계산.
			float deltaTime = static_cast<float>(currentTime - previousTime) / static_cast<float>(frequency.QuadPart);

			if (deltaTime >= oneFramTime)
			{
				//레벨 초기화 이벤트 함수.
				OnInitialized();

				//레벨의 액터 초기화 이벤트 함수.
				BeginPlay();

				// 레벨의 액터 업데이트 함수.
				Tick(deltaTime);

				// 업데이트된 결과를 화면에 그리는 함수.
				Draw();

				// 처리된 입력을 이전 프레임 입력으로 저장.
				SavePreviousInputStates();

				// 이전 프레임 시간
				previousTime = currentTime;
			}

		}

		// 정리.
		ShutDown();
	}

알아둘 것

더보기

- QueryPerformanceCounter()가 반환하는 값은 ms, sec가 아니라 tick 개수

- float oneFramTime = 1.0f / setting.framerate : 프레임 고정을 위한 처리

- Engine클래스를 전역 객체를 instance로 처리 (싱글턴)

- struct EngineSetting : 구조체, 엔진 세팅값등을 정의 ( framerate 등등)

 

 

4. 실행 결과물

빌드파일

\ConsoleGameProject\Bin\Debug\CraftEngine

exe파일

중간파일

\ConsoleGameProject\Intermediate\Debug\CraftEngine

 

강의 : 인프런 C++로 만드는 게임 엔진 프레임워크

 

 

언리얼 네트워크 종류

1. 리슨형 : Server / Client 역할을 동시에 

2. 데디케이트 서버 : 서버 전용 인스턴스

 

1. 게임모드의 주요 함수

PreLogin : 클라이언트의 접속 요청을 처리

Login : 접속을 허용한 클라이언트에 대응하는 플레이어 컨트롤러를 만드는 함수

PostLogin : 플레이어 입장을 위해 플레이어에 필요한 기본 설정을 모두 마무리 하는 함수

StartPlay :  게임의 시작을 지시하는 함수

BeginPlay : 게임 모드의 StartPlay를 통해 게임이 시작될 때 모든 액터에서 호출하는 함수 

 

 

Aos 수동 빌드를 성공했고,

그다음은, AOS패키징을 BuildCookRun 명령으로 패키징되도록 해보는것이다.

목표

1. 에디터에서 성공한 설정을 유지

2. 같은 결과를 UAT BuildCookRun으로 뽑기

3. 명령어를 bat파일로 고정

4. 나중에 Jenkins가 그 bat를 호출하게 만들기

UAT(Unreal Automation Tool) : 패키징 자동화

BuildCookRun : 빌드, 쿠킹, 패키징, 배포/실행까지 한 흐름으로 처리할 수 있다.

 

Android 패키징 흐름

1. Build : 코드 컴파일 : 실행 가능한 바이너리 생성
2. Cook : 에셋을 타깃 플랫폼용으로 변환 (텍스처, 머티리얼, 맵 등 Android용으로 변환, Saved/Cooked/Android) 

3. Stage : 배포용 파일 모으기 (실제 배포에 필요한 파일을 한 폴더로 정리)

4. Package : APK/AAB 생성 (Gradle 호출)

 

결과물을 패키징을 위해, 기준이 있어야한다. (결정 값)

- Engine 경로

- Project 경로

- Config : Development

- Texture : ASTC

- Output : APK

- Map : 기본 맵

해당 기준을 정해 놓고, 이상태로 패키징이 됐는지 여부로 성공을 정하기로 했다. 최종 결과물인 Jenkins 자동화에서는 결정 값을 선택할 수 있도록 하는게 목표다.

 

BuildCook할 수 있는 .bat를 만든다.

@echo off
echo ===== UAT BUILD START =====

call "D:\UnrealEngine\UE_5.6\Engine\Build\BatchFiles\RunUAT.bat" BuildCookRun ^
-project="D:\UnrealEngine\Hamsterland\Hamsterland.uproject" ^
-noP4 ^
-platform=Android ^
-targetplatform=Android ^
-clientconfig=Development ^
-build ^
-cook ^
-stage ^
-package ^
-archive ^
-archivedirectory="D:\BuildOutput\Hamsterland" ^
-cookflavor=ASTC

echo.
echo EXIT CODE: %ERRORLEVEL%
pause

 

 

빌드중..

 

오류

* What went wrong:
Could not open settings generic class cache for settings file 'Z:\settings.gradle' (C:\Users\gkswl\.gradle\caches\7.5\scripts\356clt8w0kvd14538qat92yfl).
> BUG! exception in phase 'semantic analysis' in source unit '_BuildScript_' Unsupported class file major version 65

 

UE 5.4 Android 빌드에서 Gradle 7.5가 Java 21 캐시때문에 발생한 원인

 

원인 분석

Unsupported class file major version 65 에서 버전 65는 Java 21을 의미한다.

cook.bat에 JAVA_HOME을 별도로 지정하지 않으면, 시스템에 설치된 Java 또는 Android Studio는 JBR(JetBrains Runtime)이라는 자체 DK를 번들로 포함하고 있다.

 

Android Studio가 업데이트되면서 내장 JBR도 Java 21로 올라갔는데, UE 5.4가 사용하는 Gradle 7.5는 Java 17까지만 지원하기 때문에 충돌이 발생한다.

 

구분 버전
Android Studio 내장 JBR Java21 호환 안됨
Gradle 7.5 지원 최대 Java Java17

 

해결 방법

Eclipse Adoptium JDK 17 설치하고, cook.bat 상단에 JAVA_HOME을 명시했다.

@echo off
set JAVA_HOME=C:\Program Files\Java\jdk-17
set PATH=%JAVA_HOME%\bin;%PATH%

echo ===== UAT BUILD START =====

call "C:\Program Files\Epic Games\UE_5.4\Engine\Build\BatchFiles\RunUAT.bat" BuildCookRun ^
-project="D:\UnrealEngine\Hamsterland\Hamsterland.uproject" ^
-noP4 ^
-platform=Android ^
-targetplatform=Android ^
-clientconfig=Development ^
-build ^
-cook ^
-stage ^
-package ^
-archive ^
-archivedirectory="D:\BuildOutput\Hamsterland" ^
-cookflavor=ASTC ^
-stdout ^
-fullstdoutlogoutput

echo.
echo EXIT CODE: %ERRORLEVEL%
pause

 

set JAVA_HOME을 bat 안에서 지정하는 방식이기 때문에, 시스템 환경변수를 건드리지 않아도 된다.

bat 실행 범위 안에서만 적용된다.

 

빌드 성공

BUILD SUCCESSFUL in 1m 11s
68 actionable tasks: 67 executed, 1 up-to-date
...
AutomationTool exiting with ExitCode=0 (Success)
EXIT CODE: 0

 

  • Hamsterland-arm64.apk — 기기에 설치할 APK
  • Hamsterland-arm64.obb — 게임 데이터 파일
  • Install_Hamsterland-arm64-Development.bat — 기기에 자동 설치해주는 배치파일

 

Unreal 프로젝트 AOS빌드에서 발생한 오류

현재 빌드에 사용된 Java 버전이 너무 최신이라서 Gradle/Unreal 조합이 못 읽는 상황 65(JAVA21)

현재 프로젝트가 Android Studio 내장 jbr또는 환경 변수 JAVA_HOME이 Java21쪽을 가리키고 있는 이슈

 

해결방법

1. JDK 17 설치

2. 환경변수 수정, JAVA_HOME 을 JDK 17 설치 경로로 변경, Path 에 %JAVA_HOME%\bin 등록

3. Unreal 내부 JDK Path도 동일하게 변경(Project Settings -> Android의 JDK Path)

 

안드로이드 스튜디오 설치


언리얼 에디터 경로연결

SDK, NDK, JDK

SDK : 최종 결과물 만드는 역할 (Android API)

NDK : C++ -> Android용 바이너리(.so)로 변환 (게임 코드 컴파일)

JDK : 빌드 실행 엔진 (Gradle 실행, 전체 Android 빌드 프로세스 담당) 


타겟 플랫폼(Target Platforms)  설치


빌드

 

 

 

[Unreal Engine] -> C++ 코드(NDK) => .so 생성 -> Android 프로젝트 구성(SDK) => (JDK + Gradle) = APK 생성

 

 

#ASTC

텍스처 압축 방식(이미지 압축 포맷)

이번에는 Unity Timeline을 사용해서
두 캐릭터가 이동 → 짧은 전투 → 마무리까지 이어지는 컷씬을 만들어봤다.

동영상 서비스가 종료되어 해당 콘텐츠를 재생할 수 없습니다.

 

캐릭터 2명 동시 제어

카메라는 1개만 사용

컷씬 중에 오디오 / 파티클 / UI 페이드 / 컬링 레이어까지 같이 제어

전체 구성 요약

이번 컷씬 구성은 아래 기준으로 잡았다.

각 캐릭터는 Animator Track을 분리해서 관리하여
서로 다른 타이밍의 공격 / 피격 애니메이션을 독립적으로 제어 하도록 했다.

 

카메라

여러 카메라를 전환하는 방식도 있지만, 이번에는 메인 카메라 1개만 유지하는 구조로 갔다.

대신, 카메라 위치 / 각도 애니메이션 필요 시 컬링 레이어 변경

시작전, 컷씬 종료 후에 처리 해줘야한다.

사운드

Timeline의 Audio Track을 사용해서: 전투 시작 사운드, 공격 타격음 효과음 반복 구간을 전부 시간 축 기준으로 배치했다.

이펙트

Control Track을 활용해서: 타격 이펙트, 히트 파티클 를 처리했다. 코드에서 Play() 호출하는 구조보다
연출 수정이 빠르다고 생각했다.

UI Fade & 컷씬 전환 처리

컷씬 시작/종료 시: UI Fade In / Out, 컷씬 진입 시 입력 차단, 컷씬 종료 후 게임 상태 복구

이런 부분은 Timeline → 코드 트리거 방식으로 처리했다.

 

작업하면서 고민했던 포인트들

1. 캐릭터 이동은 Timeline vs 코드?

  • 이동 경로가 연출의 일부 → Timeline
  • 게임 로직 기반 이동 → 코드

이번 컷씬은 연출 중심이라
이동도 애니메이션 + Timeline으로 처리했다.

 

2. 왜 카메라를 하나만 썼나?

실무 기준으로 보면: 카메라 여러 개 → 전환 버그, 상태 복구 이슈 증가

 

느낀점

이번 작업에서 가장 중요한 포인트는 이거였다.

Timeline을 ‘연출 도구’가 아니라 시스템의 일부’로 설계했다는 점

캐릭터 / 카메라 / 사운드 / UI가 분리돼 있고 imeline은 이들을 시간 축에서 조합 하고

컷씬 종료 후에도 기존 게임 흐름에 영향 최소화 하도록 했다.

 

다음에는 TimeLine 중간에 UI버튼 인터렉션(Skip, QTE) 하는 기능을 넣어 볼 예정이다.

 

Unity Timeline

에디터에서 만든 컷씬 데이터를 런타임에서 PlayableGraph로 실행하는 시스템

 

 

간단한 예제부터, 컷씬 만들어보기

1. Timeline으로 Directional Light 색생변경

큐브 충돌 시 라이트 조명을 변경되도록 했다.

 

public class LightControlAsset : PlayableAsset
{
    public ExposedReference<Light> light;
    public Color color = Color.white;
    public float intensity = 1f;

    public override Playable CreatePlayable(PlayableGraph graph, GameObject owner)
    {
        var playable = ScriptPlayable<LightControlBehaviour>.Create(graph);

        var behaviour = playable.GetBehaviour();
        behaviour.light = light.Resolve(graph.GetResolver());
        behaviour.color = color;
        behaviour.intensity = intensity;
        return playable;
    }
}

ScriptPlayable은 사용자 정의 PlayableBehaviour를 PlayableGraph에서 실행 가능하게 감싸는 래퍼
graph.GetResolver는 Timeline에서 ExposedReference로 바인딩된 실제 씬 오브젝트를 런타임에 해석하기 위한 컨텍스다

[Serializable]
public class LightControlBehaviour : PlayableBehaviour
{
    public Light light;
    public Color color = Color.white;
    public float intensity = 1f;
    public float bounceIntensity = 1f;
    public float range = 10f;

    public override void ProcessFrame(Playable playable, FrameData info, object playerData)
    {
        //Light light = playerData as Light;
        if (light != null)
        {
            light.color = color;
            light.intensity = intensity;
            light.bounceIntensity = bounceIntensity;
            light.range = range;
        }
    }
}

 

2. 간단 연출 컷씬

 

 

추가적으로 알야할것

주요 키워드정리

PlayableAsset : 컷씬 데이터 (에셋, 직렬화 대상)
PlayableBehaviour  : 실행 로직 (런타임 객체)
Playable : Behaviour를 감싼 실행 단위
PlayableGraph  : 전체 실행 그래프 (Timeline 재생기)
Track : Playable을 어떻게 묶고 연결할지 정의
FrameData : 매 프레임 실행 컨텍스트 정보

PlayableAsset / PlayableBehaviour 씬 오브젝트가 아니다.

PlayableAsset -> ScriptableObject (에셋, 디스크에 있음), 에셋메모리

PlayableBehaviour -> 타임에 새로 생성되는 객체 (Managed Heap)

GC 대상은 PlayableBehaviour, 컷씬 종료 시 Graph 파괴, 같이 정리된다.

ProcessFrame은 언제(호출 주기) 호출될까?

PlayableGraph 평가 시
→ Director.Update()
→ Graph.Evaluate(deltaTime)
→ 각 Playable의 ProcessFrame 호출

MonoBehaviour.Update랑 같지 않음

 

ExposedReference란?

씬 오브젝트를 에셋에서 직접 참조하기 위한 우회 통로

 

 

참고

https://unity.com/kr/blog/engine-platform/extending-timeline-practical-guide

시스템 콜이란?

나무위키에 따르면

사용자 프로그램이 운영체제 커널에게 하드웨어,OS 자원에 대한 작업을 요청하는 공식적인 인터페이스이다.


이전에 User Mode, Kernel Mode를 알아야한다.

유저모드 User Mode 란?

일반 애플리케이션이 실행되는 모드이고 제한된 권한만 가진다.

게임 클라이언트, 브라우저, 에디터, 일반 프로그램

 

커널모드 Kernel Mode 란?

운영체제 핵심 코드가 실행되는 모드, 최고 권한 보유

메모리관리, 프로세스 스케줄링, 파일 시스템, 네트워크제어

모드를 2개로 나누는 이유

보안, 안정성, 자원 관리다.

만약에 모든 프로그램이 CPU,메모리,디스크, 네트워크에 직접 접근할 수 있다면

어떤 일이 벌이질까?

 

잘못된 포인터와 메모리 관리를 잘못하게 된다면,

OS전체 메모리에 오염될 수 있다.

즉, 한 프로그램의 버그가 시스템 전체가 망가질 수 있다는 얘기다.

 

근데, 프로그램을 개발하다보면, 디스크 접근해서 파일도  읽어오고,

네트워크도 쓰고있다. 

 

분명 유저모드에서는 디스크, 네트워크 접근은 안된다고 했는데??

 

시스템 콜 (System Call)의 역할

시스템 콜은 유저 모드 프로그램이 커널모드에게 요청을 전달하는 공식 통로라고 생각하면 된다.

read(fd, buffer, size);

 

이 한 줄의 코드는 아래와 같은 일이 발생한다.

1. 유저 모드에서 read()호출

2. CPU명령어를 통해 시스템 콜 발생

3. CPU 실행 모드 전환(User -> Kernel)

4. 커널이 요청 검증

5. 실제 디스크 접근 및 데이터 복사

6. 결과 반환 후 UserMode 복귀

 

시스템 콜은 비용이 비싸다

시스템 콜은 단순한 함수 호출이 아니다.

CPU모드 전환, 레지스터 저장/복구, 보안검사가 진행되는데

그래서 비용이 비싸다.

 

시스템 콜이 자주 일어나면?

- 성능저하 : 모드 전환 비용 누적, 캐시 오염, 파이프라인 플러시

- 컨텍스트 스위칭 증가 : IO대기, 블로킹 시스템 콜

- 프레임 드랍/ 지연발생 : 파일 IO / 네트워크 send/recv

 

시스템 콜을 줄이는 방법

시스템 콜은 "필요할 때만" 써야 하고, 핵심은 횟수를 줄이거나(배치/캐시), 메인 스레드에서 빼거나(비동기/스레드 분리)

커널 진입 자체를 피하는것이다.

 

1. 배치 처리 / 버퍼링 : 작은 작업을 여러 번 호출하지 말고 모아서 한번에 요청하도록 한다.

2. 캐시로 "확인/조회" 호출 줄이기 : 파일 존재 확인도 시스템 콜이 될 수 있음, 매프레임 조회X -> 이벤트기반으로 변경

3. 비동기 IO + 작업 스레드로 메인 루프에서 제거 : 시스템 콜을 없애지는 못해도, 메인 프레임 블락을 막지 않도록

4. 메모리 매핑(mmap)활용 

'STUDY > 운영체제' 카테고리의 다른 글

[OS] 데이터 레이스, 동기화와 락  (0) 2026.01.08
[컴퓨터 구조] 캐시이론 Locality  (0) 2021.09.23
운영체제) 교착상태 (deadlock)  (0) 2019.08.16

캐시 라인에서 False sharing까지 공부하다가 자주 언급되는 키워드들이 있어서

AoS와 SoA를 알게 되었다, 약자만 봐서는 무슨 내용인지 몰랐다. 

 

내용은 간단하게 설명하자면, 메모리 배치 방식의 따라 캐시효율과 성능이 달라진다는 점이다.

 

AoS (Array of Structures)

struct Entity
{
    float x, y, z;
    float vx, vy, vz;
};

vector<Entity> entities[N];

구조체를 담은 배열이다. 프로그래밍할때 자연스럽게 설계하는 방식이며,

특히 객체지향 프로그래밍에 적합한 방식이다. 

 

객체 단위 구조라 코드 가독성이 좋다는 특징이 있다.

다만 SoA보다 캐시 효율이 떨어질 수 있다.

 

SoA (Structure of Arrays)

struct Entities
{
    float x[N], y[N], z[N];
    float vx[N], vy[N], vz[N];
};

데이터 단위 구조이며 동일 타입 데이터가 연속으로 배치되어

캐시 효율이 좋은 구조이다.

하지만 코드 가독성이 매우 낮고, 객체 단위 접근이 불편하다는 특징이 있다.

 


SoA는 왜 AoS보다 캐시 효율이 좋을까?

예를 들어, 모든 엔티티의 x 좌표에 vx를 더하는 연산을 생각해 보자.

 

AoS

//AoS
for (int i = 0; i < N; ++i)
    entities.x[i] += entities.vx[i];

AoS방식은 x값을 변경하게 된다면,

캐시라인에는 x, y, z, vx, vy, vz까지 같이 존재해 더 많은 캐시라인을 불러와야 한다.

캐시 낭비, 캐시 라인 교체가 빈번해질 수 있다.

//AoS
| x y z vx vy vz | x y z vx vy vz | ...

SoA

//SoA
for (int i = 0; i < N; ++i)
    x[i] += vx[i];

 

반면에, SoA방식은 캐시라인에 연속적으로 존재하는 메모리들을 빠르게 접근할 수 있고,

x에 해당하는 캐시라인과 vx에 해당하는 캐리사인만 필요로 하기 때문에 캐시효율이 좋다는 것이다.

//SoA
| x x x x x x | y y y y y y | ...

캐시특징인 공간 지역성이 극대화된 구조이다.

 

즉,

SoA  :연속 접근 -> 캐시 히트 높음

AoS : 불필요 데이터 포함 -> 캐시낭비


캐시 라인과 파편화 관점

CPU는 메인 메모리 접근 비용을 줄이기 위해 캐시 라인 단위로 데이터를 로드한다.

이때 캐시는 연속된 메모리에 접근할수록, 그리고 같은 캐시 라인을 오래 재사용할수록 높은 효율을 낸다.

 

반대로 메모리 상에서 데이터 흩어져 있거나, 접근 패턴이 불규칙한 경우에는 캐시 라인이

자주 교체되며 캐시미스가 증가한다.

 

AoS는 위 설명과 같이 불필요한 데이터 캐시로드와 라인 활용도가 떨어지고,

SoA 구조는 동일한 속성의 데이터가 메모리 상에 연속적으로 배치되어 CPU가 필요한

데이터만 효율적으로 캐시에 적재해, 캐시 공간 지역성이 이점을 가져간다.


언제 어떤 방식을 선택해야 할까?

SoA설계방식이 AoS보다 더 캐시친화적이고, 성능이 좋다고 하지만

가독성이나 OOP프로그래밍에는 적합하지 않은 부분이 많다. 성능보다는

생산성이나 유지보수성이 더 중요한 경우가 대부분이다.

 

당연히 사용용도에 맞게 적합한 방법을 선택하는 것이 중요하겠다.

일반적인 게임 로직이나 객체관리가 더 중요한 부분에서는 AoS를,

대랑 연산, 데이터 지향 코드나 극도의 성능을 요구하는 부분에서는 부분 SoA를 사용하는 것이

현실적인 선택일 것 같다.

 

 

'STUDY' 카테고리의 다른 글

2D충돌 알고리즘 AABB, OBB, SAT  (0) 2025.12.09
visual SVN 설치  (0) 2019.06.17

어떤 프로세스 안에서 생성된 스레들은 같은 메모리 공간을 공유한다.

스레드가 같은 자원을 공유할 수 있다는 점은 장점이기도 하지만,

반대로 두 스레드가 같은 자원을 동시에 변경하는 경우에는 문제가 된다.

 

동기화 이슈를 강제로 발생시켜보고

어떻게 막으면 좋을지 한번 찾아보았다. 

 

데이터 레이스 예시

//AI에게 부탁한 코드샘플.
static constexpr int kThreads = 8;
static constexpr int kItersPerT = 2'000'000;

volatile bool gStart = false;     // 동시 시작 타이밍 맞추기(간단용)
std::int64_t gShared = 0;         // 의도적 데이터 레이스 대상

void RaceWorker()
{
    while (!gStart) 
    { }

    for (int i = 0; i < kItersPerT; ++i)
    {
        ++gShared; // 데이터 레이스!
    }
}

int main()
{
    std::vector<std::thread> threads;
    threads.reserve(kThreads);

    for (int i = 0; i < kThreads; ++i)
        threads.emplace_back(RaceWorker);

    gStart = true; //가시성

    for (auto& t : threads)
        t.join();

    const std::int64_t expected = static_cast<std::int64_t>(kThreads) * kItersPerT;

    std::cout << "Expected : " << expected << "\n";
    std::cout << "Actual   : " << gShared << "\n";

    if (gShared != expected)
        std::cout << "Race detected (lost updates)\n";
    else
        std::cout << "No mismatch this run (race may still exist)\n";

    return 0;
}

 

 

결과는 8개의 스레드가 동시에 접근하면서 데이터 레이스가 발생했다.

이러한 이슈들을 막기 위해 동기화 도구들이 필요하다.

 

복합연산 이 연산은 단순한 더하기 연산 같지만, 

++gShared; // 데이터 레이스!

 

내부적으로는 아래 load, add, store 3단계가 존재한다.

1. 메모리에서 gShared 읽기 (load)

2. 값 + 1 계산 (add)

3. 메모리에서 다시 쓰기 (store)

 

예를들어, 스레드A가 gShared를 add하고 있는 상태에서

또 스레드B가 add를 할 수 있는 상황이 발생 할 수 있다.

그러면 데이터 레이스가 생긴다.

 

Lock

대표적으로 락이다. 공유 자원에 붙이면 해당 자원에 대한 접근을 동기화할 수 있다.

스레드가 해당 자원을 접근하려면 우선 그 자원에 붙어 있는 락을 획득 해야한다.

 

락은 한 스레드만 쥐고 있을 수 있다.

그래서 공유 자원은 한 번에 한 스레드만 사용할 수 있게 하는것이다.

 

lock은 스레드 전환에 컨텍스트 스위칭과 데드락에 위험이 존재한다.

 

std::mutex gMutex;

void RaceWorker()
{
    while (!gStart) 
    { }

    for (int i = 0; i < kItersPerT; ++i)
    {
        std::lock_guard<std::mutex> lock(gMutex);
        ++gShared; 
    }
}

 

 

 

atomic 연산자

std::atomic<std::int64_t> gShared = 0;

void RaceWorker()
{
    while (!gStart) 
    { }

    for (int i = 0; i < kItersPerT; ++i)
    {
        ++gShared; // 데이터 레이스!
    }
}

 

아토믹 연산자를 사용하면 lock없이도 데이터 레이스를 방지할 수 있다.

아토믹 연산자는 load, add, store하는 3단계가 아닌

CPU레벨에서 하나의 원자 연산으로 보장된다.

 

lock없이도 데이터 레이스에 피할 수 있지만,

여러 변수,조건 + 분기, 컨테이너 조작에 대해서는 적합하지 않다.

 

오늘 포스팅한 샘플처럼 카운트 수정이나

단일 변수 수정에 적합하다.

'STUDY > 운영체제' 카테고리의 다른 글

OS) 시스템 콜이란? (System Call)  (0) 2026.01.22
[컴퓨터 구조] 캐시이론 Locality  (0) 2021.09.23
운영체제) 교착상태 (deadlock)  (0) 2019.08.16

리플렉션이란 프로그램이 런타임에 자기 자신을 조사하는 기능이다.

C++에는 리플렉션이란 기능이 없다. 그래서 언리얼이 리플렉션을 제공한다.

아무래도 개발편의성 측면에서 필요한 시스템이라 판단해서 넣지 않았을까..?

 

언리얼에 리플렉션은, 런타임과 동시에 에디터에서 타입 정보를 보일 수 있게 하고, 조작도 할 수 있게 해주는 시스템이다.

자주쓰는 [UCLASS],[USTRUCT],[UENUM],[UPROPERTY],[UFUCTION]같은 매크로가 대표적인 예이다.

 

언리얼에 리플렉션에는 가능한것들이 에디터에서 변수 노출/수정, 디테일 패널, 직렬화 / 세이브|로드, 블루프린트 노출, GC추적, FindFunction, ProcessEvent같은 동적 호출/ 바인딩 등등이 있다.

 

UHT(Unreal Header Tool) 이 매크로들을 분석하여 코드를 자동으로 생성한다.

이때 생성된 코드가 타입에 관련된 메타정보이다.

 

UHT에 정의를 찾아보면

UHT는 언리얼 빌드 과정에서 C++헤더를 스캔하여, 리플렉션이 필요한 타입의 메타데이터 코드를 자동으로 생성하는 전처리 도구이며 C++컴파일러 이전 단계에서 .h(헤더파일)을 분석하여 정보를 생성한다.

 

그런데 UHT는 어떻게 이런 매크로를 탐지하여 코드를 자동으로 생성할까?

빌드 시 build.cs에서 UBT(Unreal Build Tool)가 실행된다. 그런 다음에 UHT는 위에 언급한 매크로처럼 작성된 패턴을 찾는다. 

이 매크로들은 C++매크로 역할과 UHT인식용 표식 역할을 동시에한다.

 

UCLASS()
class AMyActor : public AActor

 

UHT는 이런 코드를 보고, 이 클래스는 리플렉션 대상이구나. 판단하게된다.

 

그럼 UHT가 생성하는 코드는 무엇일까?

UHT는 .generated.h파일을 생성한다. (ex MyActor.generated.h)

이 안에는 다음과 같은 코드들이 들어간다. 

타입 등록코드(클래스 이름, 프로퍼티 목록, 함수 목록을 엔진 내부 리플렉션 시스템에 등록), 

UPROPERTY : 변수 타입, 이름, 오프셋, 메타 태그 GC추적 여부

UFUNCTION : 메타정보, 호출래핑

RPC / Replication 관련 코드 : 어떤 변수가 어떤 조건에서 어떤 네트워크 정책으로 동기화되는지 정의

GC 지원 코드 : UObject포인터를 UPROPERTY로 표시했는지, 참조 그래프에서 포함시켜 Mark & Sweep 대상으로 만듦

 

즉, UHT는 빌드 시점에 헤더 파일을 검사해서 UCLASS, UPROPERTY같은 매크로가 붙은 타입을 찾아 리플렉션용 메타데이터와 접착 코드(C++과 엔진의 공용 시스템을 연결해주는 중간 코드)를 생성한다.

이렇게 생성된 .generated.h, gen.cpp에는 타입 등록, 프로퍼티 정보, RPC/블루프린트 호출 래퍼,GC추적을 위한 코드가 포함된다.

 

이를 통해 언리얼은 C++타입을 에디터와 런타임에서 동적으로 다룰 수 있게된다.

https://school.programmers.co.kr/learn/courses/30/lessons/42587?language=csharp

 

프로그래머스

SW개발자를 위한 평가, 교육의 Total Solution을 제공하는 개발자 성장을 위한 베이스캠프

programmers.co.kr

 

using System;
using System.Collections.Generic;
public class Solution {
    public int solution(int[] priorities, int location) {
            Queue<(int priority, int index)> q = new Queue<(int, int)>();

            for (int i = 0; i < priorities.Length; i++) 
            {
                q.Enqueue((priorities[i], i));
            }

            int count = 0;

            while (q.Count > 0) 
            {
                var cur = q.Dequeue();

                bool isFind = false;

                foreach (var item in q) 
                {
                    if (cur.priority < item.priority)
                    {
                        isFind = true;
                        break;
                    }
                }

                if (isFind)
                {
                    q.Enqueue(cur);
                }
                else
                {
                    count++;
                    if(cur.index == location)
                        return count;
                }
            }

            return count;
    }
}

 

 

큐를 이용한 시물레이션 문제로,

1. 큐에는 원래 위치와, 우선 순위를 저장한다.

2. 맨앞으로 꺼낸뒤 그 뒤에 우선순위가 더 높은 값이 있는지 찾아본다

3. 우선순위가 높은 문서가 있다면 다시 큐에 넣는다.

4. 없다면 꺼낸 갯수를 증가 시킨다.

5. 위 과정을 큐가 0이 될때까지 반복한다. 

https://school.programmers.co.kr/learn/courses/30/lessons/42586

 

풀이

 List<int> answer = new List<int>();

 int n = progresses.Length;
 int[] days = new int[n];

 for (int i = 0; i<n; i++)
 {
     int remain = 100 - progresses[i];
     days[i] = (remain + speeds[i] - 1) / speeds[i];
 }

 int count = 1;
 int curReleaseDay = days[0];
 for (int i = 1; i< n; i++)
 {
     if (curReleaseDay >= days[i])
     {
         count++;
     }
     else
     {
         answer.Add(count);
         curReleaseDay = days[i];
         count = 1;
     }
 }

 answer.Add(count);

 return answer.ToArray();

 

잘못되 접근 방식 : 시뮬레이션 방식으로 하루 하루 더해서 완료가 되는 시점으로 계산하려고 했음

(문제 자체를 시간에 흐름으로 처리하려고 했음)

 

올바른 접근방식 : 

하루가 지남에 따라 완료 시점을 그때마다 확인하는 것이 아니라, 각 기능이 언제 완료되는가를 먼저 구하는 방식이 맞다.

 

이번 문제에서 알아야할 나눗셈 처리

 

        for (int i = 0; i<n; i++)
        {
            int remain = 100 - progresses[i];
            days[i] = (remain + speeds[i] - 1) / speeds[i];
        }

 

1을 빼는 이유는???

왜 단순히 remain / speed를 쓰면 안될까?

C#의 정수 나눗셈은 항상 소수점을 버린다.(내림)

ex) remain = 80, speed = 30일때, remain / speed = 2가 나온다.

 

Day 1 : 0 -> 30

Day 2 : 30 -> 60

Day 3 : 60 -> 90 (완료)

 

하지만 완료되는 시점은 3이다.

그러면 remain에다가 speed만 더하고 나누면 되지 않나? 생각하지만,  그렇다면 -1은 왜필요할까? 

위 예시 remain = 80, speed = 30인 경우는 맞다.

 

하지만, 나눗셈이 딱 떨어지는경우

ex) remain = 60, speed, 30일때는 맞지 않다.

Day 1 : 0 -> 30

Day 2 : 30 -> 60(완료)  2일걸림

 

그래서 나눠 떨어지지 않는 경우와 나눠 떨어지는 경우

모두 고려해서 -1처리가 필요하다.

 

문제를 마무리하면서 느낀점은 과정을 시뮬레이션 하지말고, 결과가 결정되는 시점을 계산하는 방식으로 접근하자..!!!

헷갈리는 키워드를 정리하기 위해 포스팅하게됐다.

1. async / await

- 비동기 프로그래밍 문법

- 실행 흐름을 잠깐 끊었다가, 끝나면 다시 이어서 실행하는 문법

using System.Threading.Tasks;
using UnityEngine;

public class LoginExample : MonoBehaviour
{
    public async Task<string> LoginAsync(string username, string password)
    {
        Debug.Log("로그인 시작");

        // 1초 대기 (메인 스레드를 블로킹하지 않음)
        await Task.Delay(1000);

        Debug.Log("1초 뒤 실행");

        return "Token";
    }
}

- Task.Delay동안 메인스레드는 멈추지 않는다.

- await는 잠시 대기 일뿐이다.

- await가 끝나면 다시 원래 스레드로 돌아간다. 

즉, 이 함수에서는 메인 스레드 외의 다른 워커 스레드를 사용하지 않고 있다.

 

C# Task는 Unity에서 쓰기에는 적합하지 않다.

- GC/할당부담

- Unity 오브젝트 라이프사이클과 매칭

- 유니티 프레임루프와 연결이 느슨함

그래서 선택지는 UniTask다

 

2. UniTask

UniTask는 유니티의 프레임루프 기준으로 동작하도록 만들어진

Unity친화적인 비동기 라이브러리다.

public async UniTask LoginAsync()
{
    await UniTask.Delay(1000);

    // 여기부터 다시 Unity 메인 스레드
    Debug.Log("1초 뒤 실행");
}

가장 큰 특징중에 하나는 await이후에 다시 유니티 메인스레드로 복귀 한다는 점이다.

(await이후 unity api 접근 가능하다는 말)

 

3. 언제 멀티스레드가 되나?

비동기를 사용하면 멀티스레드가 된다고 생각했는데, 아니었다.

명시적으로 워커 스레드를 사용했을때만 멀티스레드가 된다. 

await UniTask.Delay(1000);
//메인스레드
await UniTask.RunOnThreadPool(() =>
{
     //워커스레드
    HeavyWork();
});

//메인 스레드 복귀

 

4. 비동기와 멀티스레드 비교

비동기

- 메인 스레드를 막지 않고 대기 방식이라는것이 핵심이다

- 스레드는 하나 일 수 있다

- 주로 네트워크나 파일I/O, 타이머

=> 기다리는 방식

 

멀티스레드

- 병렬 실행이 핵심

- 스레드가 여러개

- 실행로직을 분리해 무거운 연산에 적합하다.

=> 실행을 나누는 방식

 

5. 코루틴과 비교

코루틴은 비동기가 아니다. 단지 메인스레드에서 비동기적 흐름을 흉내낸것이 맞다. 유니티에서는

유니티 메인스레드에서 실행흐름을 쪼개서 비동기처럼 보이게 만드는 구조이다.

 

 

6. 멀티스레드는 만능인가?

복잡한 연산을 멀티스레드로 구현하면, 플레이에 프리징을 줄일 수 있다.

하지만 만능은 아님

- 스레드 관리비용

- 컨텍스트 스위칭 오버헤드

- 모바일 기기에서는 발열

- CPU 스레드 수 제한등

이런것들을 잘 고려하지 못한다면 오히려 더 나빠질 수 있다.

 

BoundingBox

https://www.peopleforai.com/glossary/bounding-box/

객체간에 충돌을 검사하기 위한 단순한 모양인 박스,구,캡슐 형태를 말한다.

AABB

AABB(Axis Aligned Bounding Box) : 정렬된 축의 박스끼리의 충돌

AAB는 아래 2가지 조건이 모두 충족해야한다.

1. 두 사각형 중점 x거리(너비) < (두 사각형 가로 HalfSize 합)

2. 두 사각형 중점 y거리(너비) < (두 사각형 세로 HalfSize 합)

1. 세로, 2. 가로, 3. 가로세로 충돌

 

비교적 간단한 연산으로 정렬된 축으로 이루어진 충돌박스만 존재할때 적절하다. 

이런 상황에서는 AABB 충돌검사로 애매한 상황이 발생한다.

빨간색 사각형에 BB는 초록색 BB와 충돌했지만, 우리에게 보여지는 빨간색 사각형은 아직 충돌하지 않은 상태다.

이 문제점을 보완한 OBB+SAT로 좀 더 정교한 충돌체크를 할 수 있다.

 

OBB

OBB(Oriented Bounding Box) : 박스와 함께 축도 회전

OBB충돌 여부는 SAT를 통해 충돌 여부를 판단한다. 

 

SAT란?

'두 도형이 충돌하지 않는 분리 축이 단 하나라도 존재하면, 두 도형은 충돌하지 않는다'라는 이론이다.

즉 모든 기준 축들에서 투영구간이 겹치면 충돌이다라는 말이다.

 

여기서 기준 축은 각 박스가 가지고 있는 고유한 방향의 수직벡터이다. (쉽게 Up벡터와 Right벡터이다.)

 

OBB vs OBB SAT 충돌검사 과정

1. 검사 축 4개 구하기 

박스 A의 Right 방향, 박스 A의 Right 방향, 박스 B의 Up 방향, 박스 B의 Right 방향

 

2. 충돌 검사 방법

1) 각 축을 단위벡터로 정규하한다. (크기는 필요없기 때문에)

2) 두 박스의 모든 꼭지점을 그 축에 dot연산으로 투영한다. 결과는 스칼라 값

 

3) 두 박스의 투영 구간 [minA, maxA], [minB,maxB]를 비교한다.

 

4) 한 축에서라도 겹치지 않는 구간이 발생하면 충돌하지 않는다.

5) 모든 축에서 겹친다면 충돌 상태이다.

'STUDY' 카테고리의 다른 글

AoS vs SoA란? (Array of Structures vs Structure of Arrays)  (0) 2026.01.10
visual SVN 설치  (0) 2019.06.17

옵션 세팅 UI

게임에서 필요한, 기본적인 옵션을 세팅할 수 있는 UI를 넣어보았다.

옵션내용은 사용자가 입력한 내용그대로 데이터로 저장되고, 다시 되돌리는 리셋 구조로 만들었다.

DataAsset -> SubSystem -> SaveGame -> Widget

 


동작

1. 게임 실행 시 자동로드

- DataAsset 기본값

- SaveGame 사용자 값 저장, 둘 중에서 자동으로 선택

2. UI 반영 (UI 오픈 시)

- 그래픽옵션, 사운드 옵션, 카메라 옵션 Save데이터로 반영

3. 저장, 초기화

- SaveGame 저장

- DataAsset기반 초기값으로 복귀


FPC_OptionData - 옵션 데이터 모델


OptionConfigDataAsset – 프로젝트 기본값 저장

- DefaultOption : 프로젝트 초기값

- SvaeOption : 런타임 중 실시간 값 저장 가능

기획에서 자주 건드리는 영역은 DataAsset기반이 좋다.


OptionSubsystem – 옵션 로드/저장

초기화 흐름

void UPC_OptionSubsystem::Initialize(...)
{
    LoadOption();
    ApplyGraphicsOptions();
    ApplyAudioOptions();
}

- 게임 시작 시 SaveGame -> 없으면 DataAsset (디폴트)

- 읽은 즉시 옵션 적용

 

저장

void UPC_OptionSubsystem::SaveOption()
{
	UPC_OptionSaveGame* SaveObj = Cast<UPC_OptionSaveGame>(
	UGameplayStatics::CreateSaveGameObject(UPC_OptionSaveGame::StaticClass()));

	SaveObj->SavedOption = CurrentOption;
	UGameplayStatics::SaveGameToSlot(SaveObj, SaveSlotName, SaveUserIndex);

	if(ConfigAsset)
	{
		ConfigAsset->SaveOption = CurrentOption;
	}
}

 

리셋

void UPC_OptionSubsystem::RestOption()
{
	UPC_OptionSaveGame* SaveObj = Cast<UPC_OptionSaveGame>(
		UGameplayStatics::CreateSaveGameObject(UPC_OptionSaveGame::StaticClass()));

	if (ConfigAsset)
	{
		SaveObj->SavedOption = ConfigAsset->DefaultOption;
		CurrentOption = ConfigAsset->DefaultOption;
	}

	UGameplayStatics::SaveGameToSlot(SaveObj, SaveSlotName, SaveUserIndex);
}

 

적용

void UPC_OptionSubsystem::ApplyAndSaveOption(const FPC_OptionData& NewOption)
{
	CurrentOption = NewOption;

	ApplyGraphicsOptions();
	ApplyAudioOptions();

	SaveOption();
}

OptionSettingWidget – UI 연결

초기화

//TextBlock, Slider, CheckBox 세팅
// ....
    if (ResetButton)
		ResetButton->OnClicked.AddDynamic(this, &UPC_OptionSettingWidget::UPC_OptionSettingWidget::RestSetting);
	
	if (UGameInstance* GameInstance = GetGameInstance())
	{
		if (UPC_OptionSubsystem* subsystem = GameInstance->GetSubsystem<UPC_OptionSubsystem>())
		{
			OptionSubsystem = subsystem;
		}
	}

	if (OptionSubsystem.IsValid())
	{
		RefreshSetting();
	}

- 텍스트블럭,체크박스, 슬라이더 세팅(현재 데이터로)

- 현재 옵션Config 혹은 디폴트 옵션Config 맞게 적용

 

저장버튼

void UPC_OptionSettingWidget::SaveSetting()
{
	OptionSubsystem->ApplyAndSaveOption(ApplyOption);
}

 

- ApplyOption은 현재 사용자가 인터렉션으로 적용중인 최신 데이터

 

리셋버튼

void UPC_OptionSettingWidget::RestSetting()
{
	OptionSubsystem->RestOption();
	RefreshSetting();
}

 


구현결과

저장

로드

리셋

데이터반영

로그인 버튼 클릭 시, 내부 동작 흐름과 결과

이번엔 실제 로그인 버튼을 클릭했을 때

VContainer가 주입한 객체들이 어떻게 연결되어 동작하는지 살펴보자

 

1. LoginPanel(UI진입점)

로그인 시작은 OnLogin()부터

private void OnLoginButtonClick()
{
    onLogin();
}

 

2. Onlogin() 입력 검증과 로그인 시도

private async UniTask onLogin()
{
    if (_isLoggingIn)
    {
        ToastMsg.Show("로그인 처리 중입니다.");
        return;
    }

    string id = usernameInput.text;
    string pw = passwordInput.text;

    if (string.IsNullOrWhiteSpace(id))
    {
        ToastMsg.Show("아이디를 입력해 주세요.");
        return;
    }
    if (string.IsNullOrWhiteSpace(pw))
    {
        ToastMsg.Show("비밀번호를 입력해 주세요.");
        return;
    }

    _isLoggingIn = true;

    var loginParam = new LoginParam { UserID = id, Password = pw };
    var result = await _loginWork.ExecuteWorkAsync(loginParam);

 

샘플프로젝트이기에 간단한 입력공백만 체크하고

LoginParm을 통해 로그인에 필요한 정보를 넘기면서 로그인을 시도한다.

3. LoginWork 주입된 서비스 호출(비동기)

public class LoginWork : IWork<LoginParam, LoginResult>
{
    private readonly IAuthService _authService;
    private readonly IUserService _userService;
    private LoginResult _loginResult;

    public LoginWork(IAuthService authService, IUserService userService)
    {
        _authService = authService;
        _userService = userService;
    }

    public async UniTask<LoginResult> ExecuteWorkAsync(LoginParam param)
    {
        Debug.Log("로그인 시작");

        string token = await _authService.LoginAsync(param.UserID, param.Password);
        if (token == null)
        {
            return new LoginResult(null, LoginErrorCode.InvalidToken, "Invalid Token");
        }

        UserData user = await _userService.LoadUserDataAsync(param.UserID, token);
        if (user == null)
        {
            return new LoginResult(null, LoginErrorCode.InvalidUserData,"Invalid UserData" );
        }
        
        await UniTask.Delay(500);
        
        Debug.Log($"로그인 성공 : {user.NickName}, {user.Level}");
        return _loginResult = new LoginResult(user, LoginErrorCode.None,"Success" );
    }
}

 

- IAuthService.LoginAsync() : 주입받은 Srvice로 로그인을 시도한다.

LoginAsync() 내부에서는 현재 세팅한 서버주소로 접속하여 Token을 확인하고 리턴한다.

- IUserService.LoadUserDataAsync() : 유저정보를 로드한다. 

 

여기서 샘플코드이기에 정해놓은 딜레이 시간을 넣었지만 실제 로그인은 네트워크 통신이 포함되고 여러 작업들이 동반되기에, 스레드를 막지 않도록 비동기(UniTask)사용했다. 이 구조를 사용하면 중복 클릭 방지·로딩 표시 같은 UI 제어가 자연스럽고, 타임아웃, 취소, 재시도 같은 확장도 쉽게 적용할 수 있다.

 

4. loginResult  결과 반환

return _loginResult = new LoginResult(user, LoginErrorCode.None, "Success");

 

UI는 내부 복잡한 로직을 몰라도된다. LoginResult.IsSuccess만 보고 처리하면된다.

로그인에 성공여부를 사용자에게 보여주면된다.

var result = await _loginWork.ExecuteWorkAsync(loginParam);

if (result.IsSuccess)
{
    // 성공 처리 → LogoutScene 로드
}
else
{
    // 실패 Toast 표시
}

 

정리하자면 아래 표와 같다.

UI(LoginPanel) 버튼 클릭, 입력값 수집, Work 실행만 담당
LoginWork 로그인 로직 핵심을 담당하는 “도메인 작업 단위”
IAuthService 로그인 요청을 라우팅
IUserService 유저 데이터 로딩 담당
VContainer LoginWork, AuthServiceRouter, UserService 생성·주입

 

LoginWork는 "하나의 로그인 시나리오(Work)"를 책임지는 객체이고

DI + VContainer구조에서 UI와 서비스 사이를 분리하는 레이어이다.

 

배열, 함수, const 표현식 등이 값이 자동으로 변환되는 현상

 

몇가지 예시를 통해 어떠한 상황에 발생되는지 알아봤다.

 

배열 타입이 함수 인자로 넘겨질때 포인터 변환되는 현상

void func1(int *p)
{
    cout << *p << endl;
    // 출력 '1'
}


int main()
{
    int arr[5] = {1,2,3,4,5};
    func1(arr);
    return 0;
}

 

즉 int arr[10]; -> int* 로 바뀐다.

 

배열을 변수에 대입할때

int arr[5] = {1,2,3,4,5};
int* p = arr;   // 배열 → 포인터로 decay

 

 

배열을 템플릿 인자로 전달할 때

template <typename T>
void foo(T t)
{
    // ...
}

int arr[5];
foo(arr);   // T는 int* 로 추론됨 (int[5] 아님)

템플릿은 배열 타입을 그대로 받지 못한다.

함수 이름을 변수에 대입할 때

void Hello(int) {}

auto p = Hello;  // Hello는 함수포인터(void(*)(int))로 decay

함수는 값으로 복사가 불가능한타입라서 이경우에도 포인터로 decay된다.

auto 선언 시 const 제거

const int x = 10;
auto y = x;   // y는 int  (const 제거됨)

auto는 값복사를 전제로 타입을 추론한다.

값을 복사할때 top-level const는 의미가 없으므로 const는 제거된다.

Decay 주의점

Decay는 자동으로 타입이 단순화되는 과정이지만, 주의해야할 것들이 있다.

 

배열 크기 정보 삭제

void PrintSize(int arr[])   // 사실 int* arr
{
    cout << sizeof(arr); // 8byte (포인터 크기)
}

 

특징은, 포인터로 넘어오기 때문에 배열의 크기 정보는 사라진다.

sizeof는 배열에 크기가 아닌 포인터 크기를 반환한다.

 

오버로드

void process(int* p) { }
void process(int arr[5]) { }   // 사실 동일한 시그니처!

int a[5];
process(a); // 둘 다 int* 로 취급됨

 

Vector에 배열 Push 과정

std::vector<int*> v;
int arr[3];
v.push_back(arr);   // arr → int* decay됨

 

배열을 return하려고 할때

int arr[3];
return arr;    // int* 로 반환

+) arr은 지역변수라, 객체가 사라질때 댕글링 포인터

+ Recent posts