Steam에서 판매 중인 「도와주세요! 사토리님」 을 Unity로 모작하면서, 단순히 게임 기능 구현을 넘어 Unity 프로젝트 구조와 시스템 설계 측면에서 여러 문제점을 경험하게 되었습니다.
본 프로젝트에서는 기본적으로 Odin Inspector, DoTween Pro와 같은 외부 플러그인을 사용하였고, 그 외 기능들은 Unity 내장 시스템(Object Pooling, Addressables 등) 을 중심으로 구현하였습니다.
모작을 진행하는 과정에서 기능 자체를 구현하는 데에는 큰 어려움이 없었지만, 프로젝트가 커질수록 구조적인 한계와 생산성 저하 문제가 점점 뚜렷하게 드러났습니다.
이러한 문제를 해결하기 위해 여러 기술 문서와 사례를 찾아보았고, 필요한 경우 AI의 도움을 받아 다양한 설계 방식을 비교, 검토해 보았습니다.
이 글에서는 게임의 콘텐츠나 연출보다는, Addressables 사용 방식, 네임스페이스 설계, 싱글톤 구조로 인한 결합도 문제 등 Unity 시스템과 아키텍처 관점에서 느낀 문제점과 개선 방향을 중심으로 정리하고자 합니다.
https://store.steampowered.com/app/1914970/_/
Closure Problem은 반복문 내부에서 람다식이나 익명 함수를 사용할 때 자주 발생하는 문제입니다.
반복문 변수는 각 반복마다 새로 생성되는 값처럼 보이지만, 실제로는 하나의 동일한 변수가 반복해서 변경됩니다.
이 변수를 람다식이 캡처하면, 각 람다는 변수를 값이 아닌 참조(reference) 로 저장하게 되고, 결과적으로 반복문이 끝난 뒤의 마지막 값만을 참조하게 됩니다.
Example of Closure Problem in Unity C#
for (int i = 0; i < buttons.Length; i++)
{
// 클로저 문제 발생
buttons[i].onClick.AddListener(() => Debug.Log(i));
}
// 모든 버튼을 클릭하면 마지막 값인 buttons.Length가 출력된다
함수와 그 함수가 선언될 당시의 외부 환경(변수, 상태)을 함께 캡슐화한 것을 의미합니다.
클로저를 사용하면, 함수가 자신이 생성된 스코프의 변수를 기억하고
해당 스코프가 종료된 이후에도 그 변수에 접근할 수 있습니다.
이는 람다식(Lambda Expression)이나 익명 함수(Anonymous Function)에서 자주 사용됩니다.
지역 변수를 새로 선언하여 각 클로저가 고유한 값을 참조하도록 합니다.
Solution of Closure Problem in Unity C#
for (int i = 0; i < buttons.Length; i++)
{
int index = i; // 클로저 문제 해결을 위한 변수 복사
buttons[i].onClick.AddListener(() => OnButtonClick(index));
}
버튼 클릭 시 고유한 인덱스 값이 출력됩니다.
의미 그대로 Build시 Sprite만 안보이는 현상입니다. 첫번 째 사진이 제대로 Sprite가 안올라오는 문제입니다.
당시 상황을 설명하자면, 처음 빌드했을 때 Sprite가 정상적으로 로드되지 않는 문제가 발생했습니다.
에디터 환경에서는 문제가 없었고, 빌드된 실행 파일에서만 Sprite가 표시되지 않았기 때문에 처음에는 Addressables 설정을 잘못한 것이 아닐까 의심했습니다.
특히 같은 방식으로 관리하던
- Sound(AudioClip)
- SO(ScriptableObject)
는 모두 정상적으로 로드되고 있었기 때문에, Sprite만 문제가 발생한다는 점이 더 혼란스러웠습니다.
이 문제를 해결하기 위해, Addressables 설정 문제를 가정하고 다음과 같은 방법들을 순차적으로 시도해 보았습니다.
먼저 Sprite 자체의 Import 설정 문제를 의심했습니다.
- Texture Type을 Sprite (2D and UI) 로 변경
- Sprite Mode를 Single로 설정
일반적으로 Sprite가 빌드에서 보이지 않을 경우 가장 먼저 확인하는 항목이기 때문에 우선적으로 적용해 보았지만, 문제는 여전히 해결되지 않았습니다.
다음으로 Addressables 빌드 캐시 문제를 의심했습니다.
Addressables Groups -> Build -> Clear Build Cache 를 실행한 뒤, 다시 빌드를 진행했지만 Sprite 로딩 문제는 그대로 발생했습니다.
마지막으로 Addressables 패키지 자체의 문제일 가능성을 고려해, Addressables 패키지를 제거 Unity Package Manager를 통해 재설치
Addressables 설정을 다시 구성하는 과정을 거쳤습니다.
하지만 이 역시 근본적인 해결책이 되지는 못했습니다. 이때 정말 시간을 시간대로 날리고 스트레스를 너무 받아서 컴퓨터를 부술 뻔 했지만, 200만원 가까히 맞춰기에 다음 날을 기약했습니다.
다음 날 화를 추스리며, Asset들을 Load 및 Save를 담당하는 AssetManager를 천천히 보았습니다.
const string soundLabel = "Sound";
const string KrLabel = "KR";
const string JpLabel = "JP";
const string EnLabel = "EN";
const string ZhLabel = "ZH";
const string CommomLabel = "Common";
bool isLoad = false;
[Header("Dictionary Data")]
[DictionaryDrawerSettings(DisplayMode = DictionaryDisplayOptions.Foldout)]
[SerializeField] Dictionary<string, AudioClip> soundDic = new ();
[Space(25f)]
[DictionaryDrawerSettings(DisplayMode = DictionaryDisplayOptions.Foldout)]
[SerializeField] List<Dictionary<string, Sprite>> spritesDic = new();
[Space(25f)]
[DictionaryDrawerSettings(DisplayMode = DictionaryDisplayOptions.Foldout)]
[SerializeField] List<Dictionary<string, CSVData>> csvDic = new();
변수 자체에는 큰 문제가 없어 보였고, 같은 방식으로 로드하던 Sound(AudioClip)와 SO(ScriptableObject) 는 빌드 환경에서도 정상적으로 동작하고 있었습니다.
유독 Sprite만 빌드에서 로드되지 않는 현상이 발생했기 때문에, Sound와 SO를 로드하는 코드와 Sprite 로딩 코드를 하나씩 비교해 보았지만 당시에는 코드 상에서 눈에 띄는 차이점을 발견하지 못했습니다.
public void LoadScriptableObject()
{
AsyncOperationHandle<IList<CSVData>> handleScriptobj = Addressables.LoadAssetsAsync<CSVData>(KrLabel, null);
AsyncOperationHandle<IList<CSVData>> handleCommon = Addressables.LoadAssetsAsync<CSVData>(CommomLabel, null);
for (int i = 0; i <= (int)Language.Common; i++)
csvDic.Add(new Dictionary<string, CSVData>());
StartCoroutine(LoadScriptableObjCoroutine(handleScriptobj, (int)Language.kr));
StartCoroutine(LoadScriptableObjCoroutine(handleCommon, (int)Language.Common));
}
IEnumerator LoadScriptableObjCoroutine(AsyncOperationHandle<IList<CSVData>> handle, int index)
{
yield return handle;
if (handle.Status == AsyncOperationStatus.Succeeded)
{
foreach (CSVData csv in handle.Result)
{
csvDic[index].Add(csv.name, csv);
}
}
isLoad = true;
handle.Release();
}
이로 인해 문제의 원인이 코드가 아닌 Addressables 설정이나 빌드 과정에 있다고 판단했고, 관련 문서들을 찾아보며 원인을 추적하기 시작했습니다.
그러던 중, 다음 코드 한 줄에 의문을 가지게 되었습니다.
handle.Release();
당시 저는 handle.Release()를 C/C++의 포인터 해제(delete, free)와 유사한 개념으로 이해하고 있었습니다.
즉, LoadAssetAsync()로 메모리를 할당했고 사용이 끝났다면 반드시 100% 해제해야 한다
그 결과 로딩 직후에도 무조건 Release()를 호출하는 구조를 사용하고 있었습니다.
var handle = Addressables.LoadAssetAsync<Sprite>(key);
image.sprite = handle.Result;
// 메모리 누수를 막기 위해 즉시 해제
Addressables.Release(handle);
표면적으로 보면 메모리 관리 측면에서 오히려 더 안전해 보이는 코드였고, 에디터 환경에서는 실제로도 문제가 없어 보였습니다.
하지만 Addressables는 포인터가 아니다
문제는 Addressables의 Release()가 단순한 메모리 해제가 아니라, 참조 카운트 기반의 수명 관리 시스템이라는 점을 간과했다는 것이었습니다.
- handle.Release()가 호출됩니다.
- 해당 리소스의 참조 카운트가 감소됩니다.
- 참조 카운트가 0이 되는 즉시 리소스는 메모리에서 해제됩니다.
Sprite는 UI(Image)나 SpriteRenderer가 직접 복사하지 않고 참조하는 구조이기 때문에, Addressables에서 해제되는 순간 렌더링에 사용되던 리소스 자체가 사라지게 됩니다.
이로 인해 에디터에서는 문제없이 보이지만 빌드 환경에서는 Sprite가 표시되지 않거나 특정 시점 이후 사라지는 현상이 발생했습니다.
Q1, 왜 Sound와 SO는 괜찮아 보였을까?
이 점이 문제를 더 늦게 발견하게 만든 원인이었습니다. AudioClip은 재생 시 내부 버퍼로 Load되는 경우가 많고, ScriptableObject는 Runtime 동안 메모리에 유지되는 경우가 많아 즉시 문제가 드러나지 않았습니다.
반면 Sprite는 렌더링 시점에 실제 리소스를 직접 참조하기 때문에 Release()의 영향이 즉각적으로 나타났습니다. 저의 경우 혼자서 진행하는 소규모 프로젝트였기에
Addressables.Release(handle);
을 주석 처리하여 해결했습니다. 하지만 해당 방법보다는 아래 두가지 방법을 권장합니다.
- 실제 사용이 끝났을 때 Release
- AssetReference 사용

Memo System을 구현하면서, 질문을 선택하면 Memo Tab에 Memo가 생성되고, 필요 시 삭제되는 구조를 만들었습니다. Memo의 생성과 삭제는 Unity에서 제공하는 내장 Object Pooling 시스템을 사용하여 처리했고, 등장 연출은 DOTween을 이용해 구현했습니다.
문제는 Memo가 처음 생성될 때는 DOTween 연출이 정상적으로 실행되었지만, 두 번째부터는 Memo는 정상적으로 생성되지만 연출이 전혀 실행되지 않는 현상이 발생했습니다.

처음에는 DOTween 설정 문제나 Init 로직이 호출되지 않는 문제를 의심했습니다. 인스펙터를 확인해 본 결과, Memo 오브젝트가 매번 새로 생성되는 것이 아니라, 최초 한 번만 Origin 위치에서 생성되고 이후에는 이미 생성된 오브젝트를 재사용하고 있었습니다.
즉, Object Pooling이 정상적으로 동작하고 있었고, 그로 인해 DOTween 연출이 한 번만 실행되는 것처럼 보였던 것입니다
당시 문제의 Code는 아래와 같습니다. 본 글을 보시는 분들이라면, Code을 보자마자 이런 실수를 한다고? 하실겁니다. 저 또한 몇 시간동안 헤메는 저의 모습이 부끄러울 지경이었습니다.
memoBtnPlayer가 생성시 참조가 계속 바뀌기에 DOTween연출이 한 번만 적용된 것이었습니다.
[SerializeField] MemoBtnPlayer memoBtnPlayer;
IObjectPool<MemoBtnPlayer> pool;
public void InitMemo(string txt)
{
MemoBtnPlayer obj;
obj = pool.Get();
obj.TxtQA.text = txt;
}
아래와 같이 참조를 없애주면 금방 해결되는 가벼운 문제였습니다.
public void InitMemo(string txt)
{
pool.Get();
memoBtnPlayer.TxtQA.text = txt;
}

앞서 언급한 세 가지 문제는 프로젝트를 진행하며 직접 삽질을 하면서 겪었던 시행착오들입니다.
반면, 이후에 다룰 두 가지 주제는 개발 생산성을 높이기 위해 고민하고 적용한 방법들입니다.
먼저 Unity C#을 다루면서 namespace의 존재를 몰랐던 것은 아닙니다. Unity Client만 다루고, 소규모 협업만 했을 때 아래와 같이 namespace을 쓸 상황이 있을까?라는 생각이 들어서 지금까지 굳이 컨벤션이 아니라면 사용을 안했습니다.
namespace Game.Single
{
public class PlayerController
{
...
}
}
namespace Game.Multi
{
public class PlayerController
{
...
}
}
모작을 진행하며 프로젝트 규모가 커질수록, 단순한 폴더 정리만으로는 의존성 관리와 가독성에 한계가 있다는 점을 체감하게 됐습니다. 이를 해결하기 위한 방법 중 하나가 C# Namespace였습니다.
특히 Feature, Layer, System 중심 설계를 채택한다면 namespace는 단순한 네이밍 규칙이 아니라 아키텍처 그 자체라고 볼 수 있습니다. 아래는 모작 프로젝트의 UI Namespace Structure입니다.
namespace Game.UI
{
public abstract class UIBase : MonoBehaviour
{
...
}
}
namespace Game.UI.Button
{
public class UIButton : MonoBehaviour
{
...
}
}
namespace Game.UI.Main
{
public class UIMainView : UIView
{
...
}
public class UIPlaywayView : UIView
{
...
}
}
namespace Game.UI.Title
{
public class UITitleView : UIView
{
...
}
public class UIStaffroleView : UIView
{
...
}
}
namespace Game.UI.InGame
{
public class UIInGameView : UIView
{
...
}
public class UIStageClearView : UIView
{
...
}
...
}
이번 모작 프로젝트를 통해, 단순히 게임 기능을 구현하는 것과 확장 가능한 Unity 프로젝트를 설계하는 것 사이에는 분명한 간극이 존재한다는 점을 체감할 수 있었습니다.
Closure 문제, Addressables의 참조 수명 관리, Object Pooling과 연출 로직의 관계 등은
각각 개별적으로 보면 사소한 실수처럼 보일 수 있지만, 공통적으로 Unity 내부 동작 방식과 C# 언어 특성을 정확히 이해하지 못했을 때 발생하는 문제였습니다.
특히 Addressables와 Object Pooling 사례를 통해 느낀 점은, Unity에서 제공하는 고수준 시스템일수록 “편리함 뒤에 숨겨진 책임”을 함께 이해하지 않으면 오히려 디버깅 비용과 스트레스가 크게 증가할 수 있다는 것이었습니다.
또한 프로젝트 규모가 커질수록, Assets 폴더 정리만으로는 코드의 의존성과 책임을 표현하는 데 한계가 있었고, 이를 보완하기 위한 수단으로 C# Namespace의 중요성을 다시금 체감하게 되었습니다.
다만, namespace를 도입하면서 또 하나의 문제를 명확히 인식하게 되었습니다. 바로 Unity 프로젝트에서 흔히 사용되는 Singleton 패턴의 강한 결합도 문제입니다.
Manager 클래스들이 Singleton으로 난립하면서 namespace로 구조를 나누더라도 실제로는 전역 접근과 의존성이 프로젝트 전반에 퍼지는 상황을 피하기 어려웠습니다. 이는 구조를 정리하려는 의도와는 반대로, 오히려 테스트와 확장, 유지보수를 어렵게 만드는 요인이 되었습니다.
다음 글에서는 이러한 Singleton 중심 구조가 왜 강한 커플링을 유발하는지, 그리고 이를 완화하기 위해 Singleton을 최소화하는 방법 / Context 또는 System 단위로 책임을 묶는 방식에 대해 실제 코드와 함께 정리해 보고자 합니다.
이번 글이 Unity 모작이나 개인 프로젝트를 진행하면서 “기능은 잘 동작하지만 구조가 불안하다”는 느낌을 받은 분들께 작은 참고 자료가 되기를 바랍니다.