유니티 사화
조선을 공격하는 마법 세력을 물리치고, 침식된 조선의 사방신을 해방시키는 여정을 담은 게임입니다.
적을 공격해 ‘덧칠’을 축적하고, 일정량의 덧칠이 쌓인 적을 처형할 수 있는 액션 시스템을 축으로 합니다.

프로젝트 개요
장르 및 플랫폼
- - 3D 액션 어드벤처
- - PC(Windows)
- - Mobile(Android)
엔진 및 언어
- - Unity 2022.3
- - C#
개발 기간 및 인원
- - 2024.06 ~ 2025.06 (개발 시작 ~ 출시)
- - 프로그래밍(2) / 기획(1) / 아트(4)
- - 플레이어 · UI · 최적화 담당
출시
- - Steam
- - Google Play Store
출시 실적
- - 누적 다운로드 18,802
- - Steam 1,659명 / Google Play 385명
해외 유저 비율
- - Steam 94.5% (유럽·중남미 중심)
- - Google Play 93% (동남아 중심)
주요 활동
최적화
PC 타겟 게임을 모바일로 전환하면서 메모리 부족과 프레임 드롭이 발생했습니다. Profiler로 분석한 결과 하이폴리곤 메쉬, 이펙트, 카메라 컬링 미적용이 GPU 병목의 주요 원인이었습니다. Galaxy S10 기준 50 FPS를 목표로 해상도 조정, 폴리곤 감소, 씬 분리, 프리로딩을 적용했습니다.
- ⇒ 모든 구역 평균 50~60 FPS 달성 및 순간 프레임 드롭 개선
문제
Google Play 출시를 위한 모바일 플랫폼 전환 중 문제 발생
모바일 성능 문제
- 폴리곤이 밀집된 구역과 다수 캐릭터가 등장하는 전투 상황에서 프레임 드롭 발생
- 새로운 지역 진입 시 리소스 로딩으로 인한 순간적인 병목 발생
- 모바일 사양 대비 과도한 오브젝트 퀄리티·수량으로 GPU 병목 발생
작업 방식 문제
- 모바일 전환이 개발 후반에 결정됐으나, 이미 PC 기준 고퀄리티 에셋으로 작업이 진행된 상태
- 잦은 레벨 디자인 변경으로 단일 씬 방식을 채택 → 모바일에서는 레벨 용량은 물론 카메라에 렌더링되는 오브젝트가 과함
문제 분석
Unity Profiler로 원인을 세분화
- 몬스터 리소스 또는 대용량 파일 로딩 과정에서 순간적인 병목
- GPU 렌더링 완료까지 CPU가 대기하면서 발생하는 병목
- 전투 중 여러 캐릭터가 동시에 움직이며 메시 스키닝 연산이 증가해 발생하는 병목
- 그 외 이펙트·배경 오브젝트·그림자·후처리 등 렌더링 요소 전반의 부하
몬스터 로딩 — Texture.AwakeFromLoad
- 게임 시작 후 기믹에 의해 몬스터가 처음으로 활성화됨
- 실시간 리소스 로딩 및 초기화 과정에서 병목 발생
KillTrigger.ExecuteEventWithDelay()
└─ GameObject.Activate
└─ GameObject.ActivateAwakeRecursively
└─ Enemy.Awake()
└─ Loading.ReadObject
└─ IntegrateAllThreadedObjects
└─ Loading.AwakeFromLoad
└─ Texture.AwakeFromLoad대용량 파일 로딩 — SoundManager.LoadFMODSound
- 새로운 지역 진입 시 배경음악이 실시간으로 로드되면서 병목 발생
- 대용량 사운드 파일을 메모리로 처음 로드하는 과정에서 프레임 드롭 발생
Update.ScriptRunDelayedDynamicFrameRate
└─ CoroutinesDelayedCalls
└─ EnterTrigger.ExecuteEventWithDelay
└─ GameObject.Activate
└─ GameObject.ActivateAwakeRecursively
└─ SoundManager.GetHandle
└─ SoundManager.LoadFMODSound
└─ AsyncReadManager.SyncRequest
└─ File.ReadGPU 연산 대기 — Semaphore.WaitForSignal
- GPU가 렌더링을 완료할 때까지 CPU가 대기하는 시간이 길어 병목 발생
- 맵 이동, 전투 등 여러 상황에서 반복적으로 등장
카메라에 렌더링되는 오브젝트의 폴리곤 수·셰이더·텍스처가 과도해 GPU 처리 능력을 초과하면서 발생한 병목입니다.
PostLateUpdate.FinishFrameRendering
└─ Gfx.WaitForPresentOnGfxThread
└─ Semaphore.WaitForSignal메시 스키닝 — 다수 캐릭터 전투 시 연산 부하
- 캐릭터 뼈대가 움직일 때 메시를 변형하는 작업에서 병목 발생
- 전투 중 여러 캐릭터가 동시에 움직이면 연산이 순간적으로 증가
RenderPipelineManager.DoRenderLoop_Internal()
└─ Inl_UniversalRenderTotal
└─ Inl_RenderCameraStack
└─ UniversalRenderPipeline.RenderSingleCameraInternal: Main Camera
└─ CullScriptable
└─ CullResults.CreateSharedRendererScene
└─ EndRenderQueueExtraction
└─ QueuePrepareIntegrateMainThreadObjects
└─ MeshSkinning.UpdateImmediate
└─ MeshSkinning.SkinOnGPU
└─ ComputeSkinningDispatch렌더링 전반의 부하
카메라에서 불투명 오브젝트를 QuadTree 기반으로 렌더링하는 과정에서 병목이 발생했습니다.
RenderPipelineManager.DoRenderLoop_Internal()
└─ UniversalRenderPipeline.RenderSingleCameraInternal: Main Camera
└─ DrawOpaqueObjects
└─ RenderLoop.ScheduleDraw
└─ RenderLoop.Draw
└─ QuadTreeNode.Render그림자, 불투명·반투명 오브젝트, 후처리 효과 등이 종합적으로 겹친 병목이었습니다.
RenderPipelineManager.DoRenderLoop_Internal()
└─ UniversalRenderPipeline.RenderSingleCameraInternal: Main Camera
└─ ScriptableRenderer.Execute: Ultra_PipelineAsset_ForwardRenderer
├─ MainLightShadow
├─ DrawOpaqueObjects
├─ DrawTransparentObjects
└─ RenderPostProcessing Effects추가로 발견한 문제
- 카메라 렌더링 거리가 길어 맵 뒤에 숨겨진 오브젝트까지 모두 렌더링되고 있음
- PC 기준 해상도가 모바일에서는 과도한 GPU 연산을 유발
개선
GPU 렌더링 부하를 줄이는 방향으로, 학습 시간이 적고 효과가 큰 방법부터 단계적으로 적용했습니다. 한국 기준 저사양 기기(Galaxy S10)에서 50~60 FPS 유지를 목표로 잡았습니다.
게임 해상도 조정 — 연산량 약 56% 감소
해상도(픽셀 수)가 클수록 3D 정보를 2D 픽셀로 변환하는 연산량이 늘어 GPU 부담이 커집니다.
- 개선 전: PC용 해상도(1920 × 1080)를 모바일에 그대로 적용
- 모바일에서 1280 × 720으로 낮춰도 체감 품질 차이가 크지 않다고 판단
- 2,073,600 → 921,600 픽셀, 약 56% 연산량 감소
디바이스가 안드로이드로 감지되면 타겟 해상도를 자동 적용하고, 화면 비율을 유지하면서 타겟 픽셀 수에 맞춰 재계산합니다.
private void AdjustResolution()
{
int targetWidth;
int targetHeight;
#if UNITY_ANDROID // 모바일
targetWidth = 1280;
targetHeight = 720;
#else // PC
targetWidth = 1920;
targetHeight = 1080;
#endif
float deviceAspectRatio = (float)Screen.width / Screen.height;
int targetPixelCount = targetWidth * targetHeight;
float adjustedHeight = Mathf.Sqrt(targetPixelCount / deviceAspectRatio);
float adjustedWidth = adjustedHeight * deviceAspectRatio;
Screen.SetResolution((int)adjustedWidth, (int)adjustedHeight, true);
}로우폴리곤화 — 폴리곤 수 최대 1/10
폴리곤이 많을수록 버텍스 수가 늘고, 버텍스마다 연산이 반복되어 GPU 부담이 커집니다.
- 하이폴리곤 오브젝트와 다수 배치된 자연물을 대상으로 진행 (기획자 담당)
- 폴리곤 수를 최대 1/10까지 감소
- LOD 적용도 검토했으나 용량 증가 문제로 보류 (3D 모델러 부재)
씬 분리 — 다른 맵 오브젝트의 동시 렌더링 차단
지형이 이어진 오픈형 맵 구조라 Occlusion Culling 같은 카메라 컬링 적용이 제한적이었고, 화면에 보이지 않는 다른 맵의 오브젝트까지 렌더링되고 있었습니다.
- 맵 디자인이 계속 바뀌던 상황이라, 하나의 씬에서 맵 제작을 완성한 뒤 분리하는 방식으로 진행
- 오브젝트를 맵별로 그룹화해 씬을 분리하고 재배치, 씬 전환 로직은 별도 구현
그 외 — 그림자, 텍스처, 사운드 프리로딩
그림자 렌더링 최적화
- 간단한 그림자를 인위적으로 만들거나, 그림자가 필요한 오브젝트를 분류해 설정
- Shadow Max Distance 설정으로 원거리 그림자 렌더링 차단
- Light Probe 베이킹도 테스트했으나 학습 비용과 베이킹·테스트 시간 문제로 보류
텍스처 크기 표준화
- 배경 에셋의 비일관적인 텍스처 해상도(1024~8192)를 1024·2048로 통일 (시각적 품질 차이 없음)
사운드 프리로딩
런타임 중 대용량 오디오 파일을 로딩하며 CPU 병목이 발생했습니다. 게임 시작 시 미리 활성화해 런타임 병목을 해소했고, 이펙트·몬스터·환경 오브젝트도 함께 미리 활성화해 렌더링 병목을 예방했습니다.
[SerializeField] private AudioSource[] audios;
[SerializeField] private GameObject[] playerEffects;
[SerializeField] private GameObject[] environmentObjects;
[SerializeField] private GameObject[] Enemies;
private IEnumerator AudioPreload()
{
if (audios.Length <= 0)
{
yield break;
}
yield return null;
bool[] isInitiallyActive = new bool[audios.Length];
float[] initialVolumes = new float[audios.Length];
for (int i = 0; i < audios.Length; i++)
{
isInitiallyActive[i] = audios[i].gameObject.activeSelf;
initialVolumes[i] = audios[i].volume;
audios[i].gameObject.SetActive(true);
audios[i].volume = 0;
audios[i].Play();
}
yield return new WaitForSeconds(1f);
for (int i = 0; i < audios.Length; i++)
{
audios[i].gameObject.SetActive(isInitiallyActive[i]);
audios[i].volume = initialVolumes[i];
}
}같은 방식으로 PlayerPreload(), EnvironmentPreload(), EnemyPreload()를 구현해 이펙트·환경 오브젝트·몬스터를 미리 활성화했습니다.
결과
- 해상도 조정, 폴리곤 감소, 씬 분리 적용 후 모든 지역에서 평균 50~60 FPS 달성
- 새로운 지역·이벤트 진입 시 발생하던 순간적인 프레임 드롭은 프리로딩으로 개선
빌드 디버깅
빌드 발표 당일, 실행 파일에서 플레이어 입력이 동작하지 않는 문제가 발생했습니다. 텍스트 출력과 Log Viewer 에셋으로 빌드 환경에서 직접 디버깅해, 수치 데이터(ScriptableObject) 에셋이 빌드에 포함되지 않으면서 생긴 참조 문제를 포착했습니다.
- ⇒ Resources 폴더로 경로를 변경해 당일 해결
- ⇒ 이후 재분석으로 에디터 전용 코드가 버그를 가렸던 구조적 문제까지 파악
문제
베타 빌드 발표 당일 오전, 실행 파일에서 플레이어 이동·스킬 입력이 전부 먹통이 됐습니다.
- 유니티 엔진에서는 정상 작동하나, 빌드에서만 키 입력이 되지 않음
- ESC·Space는 정상 작동, Idle 애니메이션도 실행 중
- 몬스터 타겟 UI가 몬스터가 없어도 활성화된 채 유지됨
문제 분석
엔진에서 재현이 불가능해 빌드 환경에서 직접 디버깅했습니다.
정상 작동하는 UI·카메라 기능은 제외하고 입력·이동 문제에 집중. 관련 클래스 메서드 중간중간에 디버그 코드를 심어 화면에 Text로 출력하도록 구현 → 특정 메서드들이 매 프레임 반복되다 중간에 끊기는 것을 확인. 특정 지점에서 코드 실행이 멈추고 있다고 판단
빌드 환경에서 콘솔처럼 로그를 띄우는 Log Viewer 에셋으로 문제 발생 위치를 특정 → 해당 위치의 수치 데이터(ScriptableObject) 참조 코드가 참조에 실패하고 있었음 →
Resources.Load로 파일을 불러오도록 구현했으나, 정작 파일은 다른 경로에 생성되도록 되어 있었음. 처음부터 빌드에서는 참조할 수 없는 구조
문제가 된 수치 데이터 코드
- 엔진에서:
Resources.Load실패 →#if UNITY_EDITOR블록 실행 →GameResource폴더에서 파일을 찾거나 생성 → instance 할당 성공 - 빌드에서:
Resources.Load실패 →#if UNITY_EDITOR블록 제외 → instance null →NullReferenceException
즉 에디터 전용 코드가 버그를 가리고 있었습니다.
public partial class PlayerCommonData : ScriptableObject
{
private const string SettingFileDirectory = "Assets/GameResource/ControlData";
private const string SettingFilePath = "Assets/GameResource/ControlData/Player Common Mask Data.asset";
private static PlayerCommonMaskData instance;
public static PlayerCommonMaskData Instance
{
get
{
if (instance != null)
{
return instance;
}
instance = Resources.Load<PlayerCommonMaskData>("Player Common Mask Data");
#if UNITY_EDITOR
if (instance == null)
{
if (!AssetDatabase.IsValidFolder(SettingFileDirectory))
{
AssetDatabase.CreateFolder("GameResource", "ControlData");
}
instance = AssetDatabase.LoadAssetAtPath<PlayerCommonMaskData>(SettingFilePath);
if (instance == null)
{
instance = CreateInstance<PlayerCommonMaskData>();
AssetDatabase.CreateAsset(instance, SettingFilePath);
}
}
#endif
return instance;
}
}
}개선
Resources 폴더에 수치 데이터 파일을 저장하도록 경로를 변경했습니다.
이 선택의 근거는 다음과 같습니다.
- 경로 변경만으로 파일 누락 문제를 가장 빠르게 해결할 수 있음
- 수치 데이터라 빌드 시 용량이 작음 (1~3KB)
- Resources 폴더에 수치 데이터 외의 파일이 존재하지 않음
- 초기화 시
Resources.Load이후 instance로 계속 참조하는 구조라, 런타임 중 Resources 폴더에 접근하는 일이 없음
수정한 수치 데이터 코드
public partial class PlayerCommonData : ScriptableObject
{
private const string SettingFileDirectory = "Assets/Resources";
private const string SettingFilePath = "Assets/Resources/PlayerCommonMaskData.asset";
private static PlayerCommonData instance;
public static PlayerCommonData Instance
{
get
{
if (instance != null)
{
return instance;
}
instance = Resources.Load<PlayerCommonData>("PlayerCommonMaskData");
// 에디터 타임에 자동 생성되도록. 런타임에는 무조건 존재해야 함
#if UNITY_EDITOR
if (instance == null)
{
if (!AssetDatabase.IsValidFolder(SettingFileDirectory))
{
AssetDatabase.CreateFolder("Assets", "Resources");
}
instance = AssetDatabase.LoadAssetAtPath<PlayerCommonData>(SettingFilePath);
if (instance == null)
{
instance = CreateInstance<PlayerCommonData>();
AssetDatabase.CreateAsset(instance, SettingFilePath);
}
}
#endif
return instance;
}
}
}결과 및 회고
빌드 발표는 실패했으나, 문제 발견 즉시 당일 해결 완료
회고
- 엔진에서 돌아가고 빌드가 만들어진다고 게임이 제대로 돌아가는 것이 아님을 체감함
- 현재 상황에서 가장 합리적인 선택을 하려면 많은 문제를 직접 겪어봐야 한다고 생각하게 됨
- 시각적 디버깅(인게임 텍스트, Log Viewer)이 직접 접근이 어려운 환경에서 문제를 이해하는 데 효과적임을 확인
먹 이펙트 구현
카메라 각도에 따라 2D 트레일 이펙트가 평면으로 노출되는 문제가 있었습니다. 3D 메쉬와 Shader Graph로 전환하고 텍스처 RGB 채널로 먹의 흐름과 소멸을 표현했습니다. 파티클 커스텀 데이터와 셰이더 변수를 외부로 노출해 아트 쪽 작업 편의성도 높였습니다.
- ⇒ 빌드 발표 기한 내 1주일 만에 개선 및 적용
문제
- 카메라 각도와 애니메이션 동작에 따라 2D 트레일 이펙트가 평면으로 노출되는 문제 발생
- ‘먹으로 칠한다’는 느낌을 살리려면 입체감과 표현력을 모두 갖춘 이펙트가 필요한 상황
개선
텍스처를 활용한 먹의 흐름 표현
- 텍스처 표현 방법에 대해 외부 FX 아티스트(Zeng Ryan)에게 직접 메일로 자문을 구하고, 텍스처를 받아 활용
- 셰이더 그래프에서 텍스처의 R·G 채널을 시간에 따라 이동시켜 물결치는 흐름을 표현
- B 채널과 파티클 시스템 Custom Data로 알파값을 제어해 서서히 사라지는 먹 느낌을 표현
- 파티클 시스템 Custom Data로 시간에 따라 텍스처의 Tiling을 제어해, 메시 경로대로 텍스처가 흘러가는 잉크 느낌을 표현
3D 메시를 적용해 입체감 형성
Blender로 임시 메시를 직접 제작하며 이펙트를 다듬는 동시에, 모델러·원화·기획 인원이 해당 이펙트를 기반으로 최종 메시 형태와 활용 방향을 논의했습니다. 일주일 내 완성 (2024.08.05 ~ 08.11)
결과 및 회고
빌드 발표 기한 내 1주일 만에 개선 및 적용 완료
회고
- 이펙트, 셰이더, Blender 모두 처음이었지만 직접 부딪혀 보니 낯선 분야 자체는 큰 장벽이 아님을 확인
- 막히면 전문가에게 직접 물어보는 것이 생각보다 강력한 무기임을 경험했고, 모르면 두려워하지 말고 물어보면 된다는 자세를 갖게 됨
- 메시 작업이 지연되는 상황에서 기다리는 대신 임시 메시를 직접 만들어 먼저 움직였고, 일정이 걸린 상황에서는 기다리기보다 내가 먼저 움직이는 것이 팀에도 나에게도 낫다는 것을 배움
출시
팀원과 역량 문제로 프로젝트가 종료될 위기였습니다. 게임을 완성하고, 유저 평가를 받고, 전체 개발 과정을 경험하기 위해 Steam과 Google Play 동시 출시를 결정했습니다.
- ⇒ 다운로드 18,802 / 해외 유저 94% / 유저 플레이 영상 10+
- ⇒ 유저 평가는 이후의 업데이트·리팩토링의 원동력이 됨
출시 계기
- 팀원 대부분이 이탈하면서 프로젝트가 종료될 위기였음에도, 아무 결과 없이 끝내고 싶지 않았습니다
- 처음부터 출시까지의 과정을 겪고 유저의 평가를 받을 기회는 쉽게 오지 않는다고 생각했습니다
- 기존 목표였던 Steam 출시뿐 아니라 모바일 출시도 과감하게 진행하기로 결정했습니다
과정
- 모바일 기기 사양에 따른 최적화를 비롯해, 모바일 입력 방식과 카메라 회전 방식 수정
- 출시 전 주변 지인들에게 게임 테스트 및 피드백 진행
- 플랫폼별(Steam, Google Play) 개발자 등록 및 게임 게시 절차 진행
결과 및 회고
기능과 기획 모두 축소되었으나, 각 플랫폼에서 플레이할 수 있는 게임을 출시했습니다.
- 출시 후 유저 평가를 바탕으로 업데이트 진행
- 다운로드 18,802 / 해외 유저 94% / 유저 플레이 영상 10+
회고
- 게임에 익숙해진 상태로 테스트·출시를 하다 보니 생각지도 못한 버그를 유저들이 많이 찾아냄. QA 테스트의 필요성을 크게 느낌
- 업데이트 과정에서 코드 수정이 어려웠음. 유지보수가 어려운 구조 탓에 업데이트가 중단됨. 읽기 쉽고 쓰기 좋은 코드의 필요성을 체감
- 기능과 리소스가 추가될수록 목표 프레임을 유지하지 못하는 경우가 발생. 특히 해외 저사양 기기 유저를 고려하면 최적화는 지속적으로 고려해야 함을 깨달음
- 현재 ‘기능만 작동하는 수준의 코드’라는 한계를 인식. 게임 전체 코드를 분석하고 규모에 맞는 구조로 리팩토링하기로 결정
그 외 활동
플레이어 입력 개선
스킬 실행 중 입력이 차단되는 구간 때문에 입력 누락이 발생해 조작감이 나빴습니다. 입력을 Stack에 저장했다가 적절한 타이밍에 자동 실행하는 방식으로 바꿨습니다.
⇒ 타이밍이 늦어도 다음 스킬로 자연스럽게 이어지는 조작감 확보
수치 데이터 조정
수치 변수가 씬 곳곳의 오브젝트에 흩어져 하나하나 찾아다니며 수정해야 했습니다. ScriptableObject 에셋으로 통합하고, 변수를 구조체로 묶어 그룹화했습니다.
⇒ 에셋 한 곳에서 바로 테스트·수정 가능, 변수명 가독성 개선
UI 개선을 위한 리팩토링 설계
ESC 키 하나를 처리하는 데 7단계 이상의 if 문을 읽어야 하는 구조였습니다. Stack 기반 팝업 매니저와 IPopup 인터페이스로 재설계했습니다.
⇒ 팝업이 추가되어도 복잡도가 늘지 않고 순서가 자동으로 보장되는 구조
문제
- 스킬 실행 중 특정 구간에서 bool 변수로 입력을 차단해 스킬이 끊어지지 않도록 구현
- 입력이 차단된 구간을 피해서 입력해야 하는 구조라, 타이밍이 정확하지 않으면 입력 누락이 발생 → 조작감 악화 및 유저 피로도 증가
PlayerController.cs — 입력 감지 및 행동 함수 호출
private void Update()
{
// 상태에 따른 행동 함수 호출 제어
if (playerState.doNotAct || player.IsPerformingHitAction)
{
return;
}
if (Input.GetKeyDown(saveManager.InputKeys[KeyAction.ATTACK_NORMAL]))
{
// 행동 함수(스킬) 호출
humanMaskSkill.NormalAttack();
}
}HumanMaskSkill.cs — 사람탈 캐릭터 스킬
// CoFirstAttack()을 실행
public void NormalAttack() {}
public IEnumerator CoFirstAttack()
{
while (isPerformingFirstAttack)
{
// bool 변수 'doNotAct'로 행동 함수 호출을 제어
playerState.RestrictPlayer(humanData.firstNormalAttackRestrict, firstAttackStartTime);
}
}PlayerState.cs — 행동 함수 호출을 제어하는 상태 클래스
public void RestrictPlayer(RestrictStruct restrictStruct, float skillStartTime)
{
// 일정 시간 동안 doNotAct를 true로 하여 다른 행동의 호출을 방지
if (restrictStruct.actRestrictDuration != 0)
{
if (Time.time >= skillStartTime + restrictStruct.actRestrictWaitTime + restrictStruct.actRestrictDuration)
{
doNotAct = false;
}
else if (Time.time >= skillStartTime + restrictStruct.actRestrictWaitTime)
{
doNotAct = true;
}
}
}개선
《갓 오브 워: 고스트 오브 스파르타》의 입력 방식을 참고해 입력 저장 및 자동 실행 방식을 구현했습니다.
- 스킬 실행 시 코루틴으로 추가 입력을 저장할 수 있는 시간 범위를 활성화
- 해당 범위 안에서 입력된 스킬을 Stack에 저장해 적절한 타이밍에 자동 실행
- 타이밍이 다소 늦어도 다음 스킬로 자연스럽게 이어지는 조작감 구현
PlayerController.cs — 입력을 저장하도록 변경
private void Update()
{
if (Input.GetKeyDown(saveManager.InputKeys[KeyAction.ATTACK_NORMAL]))
{
// 입력을 저장
playerSkillInput.StoreInput(PlayerStateType.HUMAN_NORMALATTACK);
// 스킬 실행 중이 아니라면 바로 실행
if (!playerState.isPerfomingSklill) humanMaskSkill.NormalAttack();
}
}HumanMaskSkill.cs — 저장 가능 구간 활성화
// CoFirstAttack()을 실행
public void NormalAttack() {}
public IEnumerator CoFirstAttack()
{
// 지정된 시간 동안 입력된 스킬을 저장
playerSkillInput.ProcessInput(humanData.firstNormalAttackInput, firstAttackStartTime);
while (isPerformingFirstAttack)
{
playerState.RestrictPlayer(humanData.firstNormalAttackRestrict, firstAttackStartTime);
}
}PlayerSkillInput.cs — 입력 저장 및 실행
public void StoreInput(PlayerStateType inputValue)
{
// 저장 가능할 때만 Stack<PlayerStateType>에 저장
if (canStoreInputValue)
{
playerSkillInputValue.Push(inputValue);
}
}// CoProcessInput() 실행
public void ProcessInput(PlayerSkillInputStruct skillInput, float startTime) {}
public IEnumerator CoProcessInput(PlayerSkillInputStruct skillInput, float startTime)
{
while (!stopCoroutine)
{
// 지정된 타이밍 이후 마지막으로 저장된 스킬값 실행
else if (Time.time >= startTime + skillInput.executeWaitTime)
{
if (playerSkillInputValue.Count > 0)
{
ExecuteStoredInput(playerSkillInputValue.Pop());
yield break;
}
}
// bool 제어로 지정된 시간 동안만 입력이 저장되도록
if (Time.time >= startTime + skillInput.storeWaitTime + skillInput.storeDuration)
{
canStoreInputValue = false;
}
else if (Time.time >= startTime + skillInput.storeWaitTime)
{
canStoreInputValue = true;
}
}
}결과 및 회고
회고
- 스킬 커맨드 기능 추가를 고려해 Stack으로 저장하도록 구현했으나, 해당 기능이 게임에 포함되지 않으면서 불필요한 구현이 됨. 확정된 요구사항에 집중해야 한다는 것을 반성
- 스킬 입력과 저장을 담당하는 클래스들이 강하게 결합되어 있어, 스킬 추가 시 여러 클래스를 동시에 수정해야 하는 번거로움이 있음
- 인터페이스 기반으로 재설계해 스킬을 클래스 단위로 분리하면 확장성을 개선할 수 있을 것으로 판단
문제
- 수치 데이터 변수를 클래스에
[SerializeField]로 선언하고 Inspector에서 조정하며 테스트하는 방식으로 구현 - 개발이 진행될수록 수치 데이터가 많아지면서 씬 곳곳의 오브젝트에 흩어짐
- 오브젝트를 하나하나 찾아다니며 수정해야 하는 번거로움이 생겼고, 비슷한 기능의 변수가 많아지면서 변수명 가독성도 떨어짐
개선
ScriptableObject 에셋을 통한 수치 데이터 통합 관리
- 기획 데이터 테이블과 개발 중 추가된 수치 변수를 통합 관리
- Hierarchy에서 수치 데이터를 찾을 필요 없이 ScriptableObject 에셋에서 바로 테스트·수정 가능
- 변수를 구조체 타입으로 구성해 세부 그룹화 → 변수명 정리 및 가독성 개선
결과 및 회고
회고
- 변수가 많아질수록 찾기 번거로운 문제는 여전히 존재. Google Sheet의 행렬 구조, 탭 분류, 검색 기능 등을 참고해 구현한다면 개발 편의성을 더 높일 수 있을 것으로 판단
문제
팝업 열기/닫기 로직이 if-else 중첩으로 복잡해졌습니다.
- ESC 키 하나를 처리하는 데 7단계 이상의 if 문을 읽어야 함
- 팝업창 수에 비례해 if 문이 늘어나고, 메서드 수정이 복잡해짐
- 실행 순서를 바꾸면 예상과 다르게 동작할 수 있음 (순서 의존적)
메뉴 클래스의 MenuSwitch() — 리팩토링 전
public void MenuSwitch()
{
if (loadingUI.LoadBG.activeSelf)
{
return;
}
else if (settingWindow.activeSelf)
{
if (soundWindow.activeSelf)
{
soundUI.SaveVolumeData();
}
else if (inputKeyWindow.activeSelf)
{
if (inputKeyUI.completeEditingKey)
{
inputKeyUI.CompleteEditingKey(false);
return;
}
mouseUI.SaveMouseData();
}
settingWindow.SetActive(false);
}
else if (loadSlotWindow.activeSelf)
{
SaveManager.instance.SelectedIndex = null;
if (loadWindow.activeSelf)
{
loadWindow.SetActive(false);
}
else
{
loadSlotWindow.SetActive(false);
}
}
else if (quitWindow.activeSelf)
{
quitWindow.SetActive(false);
}
else if (pauseMenu.activeSelf)
{
if (goToMainMenuWindow.activeSelf)
{
goToMainMenuWindow.SetActive(false);
}
else
{
gameTimeScale.SetTimeScale(1);
if (!timelineHelper.IsTimelinePlaying())
{
isPlayerControlDisabled = false;
playerSound.TogglePlayingAudioPause(false);
if (PlatformSwitcher.instance.IsPCPlatform)
{
Cursor.visible = false;
Cursor.lockState = CursorLockMode.Locked;
}
else
{
Cursor.visible = true;
Cursor.lockState = CursorLockMode.Confined;
}
}
else
{
Cursor.visible = true;
Cursor.lockState = CursorLockMode.Confined;
}
pauseMenu.SetActive(false);
ActivateLetterBox(false);
}
}
else if (mainMenu.activeSelf)
{
SaveManager.instance.SelectedIndex = null;
if (settingWindow.activeSelf)
{
settingWindow.SetActive(false);
}
else if (newGameWindow.activeSelf)
{
newGameWindow.SetActive(false);
}
}
else
{
if (!canShowPauseMenu)
{
return;
}
gameTimeScale.SetTimeScale(0);
isPlayerControlDisabled = true;
playerSound.TogglePlayingAudioPause(true);
pauseMenu.SetActive(true);
ActivateLetterBox(true);
Cursor.visible = true;
Cursor.lockState = CursorLockMode.Confined;
}
}개선 (리팩토링 설계)
팝업창 수가 늘어날수록 복잡해지는 문제를 해결하기 위해 Stack 기반 팝업 관리 구조로 설계했습니다.
팝업 매니저
- Stack으로 팝업을 관리해 순서를 자동으로 보장하고, 팝업이 추가되어도 복잡도가 증가하지 않도록 관리
- Dictionary에 팝업을 미리 등록하면서 팝업의 이벤트를 구독해, 활성화/비활성화가 자동으로 Stack에 반영되도록 구현
IPopupManager.cs
public IReadOnlyCollection<IPopup> ActivePopupStack { get; }
public IReadOnlyDictionary<PopupTypeEnum, IPopup> PopupRegistry { get; }
public void RegisterPopup(PopupTypeEnum popupTypeEnum, IPopup popup);
public void UnRegisterPopup(PopupTypeEnum popupTypeEnum, IPopup popup);
public void OpenPopup(PopupTypeEnum popupTypeEnum);
public void ClosePopup();PopupManager.cs
activePopupStack은 popupRegistry에서 팝업을 가져와 활성화·비활성화하고, popupRegistry는 초기에 의존성 주입으로 팝업 구현체를 등록하면서 해당 팝업의 열기/닫기 이벤트를 구독합니다.
public IReadOnlyCollection<IPopup> ActivePopupStack
{
get { return activePopupStack; }
}
private Stack<IPopup> activePopupStack = new Stack<IPopup>();
private bool hasMainMenu;
public IReadOnlyDictionary<PopupTypeEnum, IPopup> PopupRegistry
{
get { return popupRegistry; }
}
private Dictionary<PopupTypeEnum, IPopup> popupRegistry = new Dictionary<PopupTypeEnum, IPopup>();등록과 동시에 이벤트를 구독하고, 해제와 동시에 구독을 끊습니다.
public void RegisterPopup(PopupTypeEnum popupTypeEnum, IPopup popup)
{
popupRegistry.Add(popup.PopupID, popup);
popup.PopupOpened += OpenPopup;
popup.PopupClosed += ClosePopup;
if (popup.PopupID == PopupTypeEnum.MainMenu)
{
hasMainMenu = true;
}
}
public void UnRegisterPopup(PopupTypeEnum popupTypeEnum, IPopup popup)
{
popupRegistry.Remove(popupTypeEnum);
popup.PopupOpened -= OpenPopup;
popup.PopupClosed -= ClosePopup;
}외부 요청 신호(사용자 입력, UI 버튼 등)로 팝업 메서드가 호출됩니다. OpenPopup()은 스택에 팝업을 추가하고 활성화 함수를 호출하며, ClosePopup()은 맨 위 팝업의 비활성화 함수를 호출하고 제거합니다.
public void OpenPopup(PopupTypeEnum popupTypeEnum)
{
if (!popupRegistry.ContainsKey(popupTypeEnum))
{
Debug.LogWarning("팝업을 찾을 수 없습니다: " + popupTypeEnum);
return;
}
activePopupStack.Push(popupRegistry[popupTypeEnum]);
activePopupStack.Peek().ActivatePopup();
}
public void ClosePopup()
{
if (activePopupStack.Count <= 0)
{
if (!hasMainMenu)
{
OpenPopup(PopupTypeEnum.PauseMenu);
}
return;
}
activePopupStack.Peek().DeactivatePopup();
activePopupStack.Pop();
}팝업창
IPopup인터페이스를 구현해 PopupManager와 느슨하게 연결. 사용자 입력으로 열기/닫기 이벤트가 발생하면 PopupManager가 자동으로 반응- 팝업창은 활성화/비활성화 메서드를 각각 구현해, 팝업마다 개별적인 기능이 실행되도록 함
IPopup.cs
public PopupTypeEnum PopupID { get; }
public bool IsActive { get; }
public event Action<PopupTypeEnum> PopupOpened;
public event Action PopupClosed;
public void ActivatePopup();
public void DeactivatePopup();
public void SetTransform();PopupBase.cs
PopupOpened는 팝업을 열어달라는, PopupClosed는 닫아달라는 외부 신호로 작용하는 이벤트입니다.
public PopupTypeEnum PopupID
{
get { return popupType; }
}
[SerializeField] PopupTypeEnum popupType;
public bool IsActive
{
get { return isActive; }
}
private bool isActive;
public event Action<PopupTypeEnum> PopupOpened;
public event Action PopupClosed;
[SerializeField] Vector3 rectPosition = new Vector2(0, 0);
[SerializeField] Vector2 rectSize = new Vector2(0, 0);
[SerializeField] Vector2 minAnchors = new Vector2(0, 0);
[SerializeField] Vector2 maxAnchors = new Vector2(0, 0);
[SerializeField] Vector2 pivot = new Vector2(0, 0);public void ActivatePopup()
{
isActive = true;
this.gameObject.SetActive(true);
SetTransform();
}
public void DeactivatePopup()
{
isActive = false;
this.gameObject.SetActive(false);
}
public void SetTransform()
{
var rectTR = this.transform.GetComponent<RectTransform>();
rectTR.anchoredPosition = rectPosition;
rectTR.sizeDelta = rectSize;
rectTR.anchorMin = maxAnchors;
rectTR.anchorMax = minAnchors;
rectTR.pivot = pivot;
}protected void OpenPopup(PopupTypeEnum popupTypeEnum)
{
PopupOpened.Invoke(popupTypeEnum);
}
protected void ClosePopup()
{
PopupClosed.Invoke();
}PopupPauseMenu.cs (예시)
PopupBase를 상속받은 자식 클래스로, 팝업 관련 UI 요소들의 이벤트를 연결합니다.
[SerializeField] Button openPopupA;
[SerializeField] Button openPopupB;
[SerializeField] Button openPopupC;
[SerializeField] Button closePopup;
private void Start()
{
AddOpenPopupEvent(openPopupA, PopupTypeEnum.A);
AddOpenPopupEvent(openPopupB, PopupTypeEnum.B);
AddOpenPopupEvent(openPopupC, PopupTypeEnum.C);
AddClosePopupEvent(closePopup);
}
private void AddOpenPopupEvent(Button btn, PopupTypeEnum popupTypeEnum)
{
btn.onClick.AddListener(() =>
{
OpenPopup(popupTypeEnum);
});
}
private void AddClosePopupEvent(Button btn)
{
btn.onClick.AddListener(() =>
{
ClosePopup();
});
}