TL;DR —
MonoBehaviour와GameManager.Instance에 직접 결합된 비즈니스 로직은EditMode테스트에서 즉시 막힙니다. 구매 규칙을UseCase로 분리하고 외부 의존성을IShopRepository(Port)로 추상화하면,InMemory구현체로 빠르고 안정적인 테스트를 구성할 수 있습니다. 이 방식은 테스트 통과 자체보다 실패 시나리오에서 상태 불변성을 검증하는 데 특히 효과적입니다.
Table of contents
Open Table of contents
들어가며
Unity로 상점 기능을 구현하면서 UI 이벤트 핸들러 안에 구매 규칙을 함께 작성한 적이 있습니다. 기능은 동작했지만, EditMode 테스트로 검증하려는 순간 MonoBehaviour 인스턴스 생성과 싱글턴 초기화 문제로 바로 막혔습니다. 이 글은 그 문제를 어떻게 분해했고, UseCase 패턴으로 어떤 구조적 이점을 얻었는지 학습 관점에서 정리한 기록입니다.
이 글에서 다루는 내용:
MonoBehaviour+ 싱글턴 결합 구조가EditMode테스트를 막는 이유UseCase/Port/Adapter로 구매 로직을 분리하는 방법EditMode에서 성공/실패 경로를 빠르게 검증하는 테스트 전략
사전 지식: Unity 기본 컴포넌트 구조, C# 인터페이스,
NUnit기반 테스트 작성 경험을 전제로 합니다.
1. 결합 구조에서 테스트가 막히는 이유를 확인했습니다
1.1 ShopUI에 비즈니스 규칙이 함께 있었습니다
초안의 첫 단계 구조는 ShopUI가 GameManager.Instance를 직접 읽고 수정하는 형태였습니다.
public bool OnClick_Buy(ShopItem item)
{
if (GameManager.Instance.Gold < item.Price)
return false;
if (GameManager.Instance.Inventory.Count >= GameManager.Instance.MaxInventorySize)
return false;
GameManager.Instance.Gold -= item.Price;
GameManager.Instance.Inventory.Add(item.Name);
return true;
}
이 방식은 구현 속도는 빠르지만, UI 이벤트 처리와 도메인 규칙이 강하게 결합됩니다.
1.2 EditMode 테스트에서 실패 지점이 명확했습니다
EditMode에서는 씬 생명주기를 사용하지 않으므로 싱글턴 초기화가 보장되지 않습니다.
[Test]
public void Buy_WithEnoughGold_ShouldSucceed()
{
var shopUI = new ShopUI();
var item = new ShopItem("Sword", 50);
bool result = shopUI.OnClick_Buy(item);
Assert.IsTrue(result);
}
위 테스트는 실제로는 GameManager.Instance == null 상태에서 NullReferenceException으로 종료됩니다. 즉, 검증하려는 대상이 구매 규칙인데, 테스트는 Unity 런타임 의존성에서 먼저 실패합니다.
| 문제 | 영향 |
|---|---|
MonoBehaviour 생성 제약 | 테스트 픽스처 구성이 복잡해집니다 |
GameManager.Instance 의존 | EditMode에서 로직 검증 이전에 실패합니다 |
| UI + 로직 결합 | 규칙 재사용과 변경 영향 범위가 커집니다 |
2. UseCase 패턴으로 관심사를 분리했습니다
2.1 목표 구조를 먼저 고정했습니다
분리의 핵심은 “규칙은 순수 C#, 런타임 접근은 어댑터”로 책임을 나누는 것입니다.
[Command] -> [UseCase] -> [Result]
|
v
[IShopRepository] (Port)
/ \
InMemoryShopRepository RuntimeShopRepository
2.2 Port에 필요한 데이터 접근만 선언했습니다
public interface IShopRepository
{
bool HasItem(string name);
int GetItemPrice(string name);
int GetGold();
int GetInventoryCount();
int GetMaxInventorySize();
void DeductGold(int amount);
void AddToInventory(string name);
}
UseCase는 GameManager나 MonoBehaviour를 모른 채, 규칙 실행에 필요한 계약만 사용합니다.
2.3 UseCase를 순수 규칙 클래스로 옮겼습니다
public class BuyItemUseCase
{
private readonly IShopRepository repository;
public BuyItemUseCase(IShopRepository repository)
{
this.repository = repository;
}
public BuyItemResult BuyItem(BuyItemCommand command)
{
if (!repository.HasItem(command.itemName))
return BuyItemResult.Failure("Item not found");
if (repository.GetInventoryCount() >= repository.GetMaxInventorySize())
return BuyItemResult.Failure("Inventory is full");
int price = repository.GetItemPrice(command.itemName);
if (repository.GetGold() < price)
return BuyItemResult.Failure("Not enough gold");
repository.DeductGold(price);
repository.AddToInventory(command.itemName);
return BuyItemResult.Success(repository.GetGold());
}
}
이 클래스는 Unity 엔진 타입에 의존하지 않으므로 new로 즉시 생성하여 테스트할 수 있습니다.
2.4 ShopUI는 UseCase에 위임하도록 바꿨습니다
UseCase 분리 후에는 UI가 규칙을 직접 실행하지 않고, 입력 전달과 결과 표시만 담당합니다.
public class ShopUI : MonoBehaviour
{
private BuyItemUseCase buyItemUseCase;
private void Awake()
{
var repository = new RuntimeShopRepository();
buyItemUseCase = new BuyItemUseCase(repository);
}
public void OnClick_Buy(string itemName)
{
BuyItemResult result = buyItemUseCase.BuyItem(new BuyItemCommand(itemName));
if (!result.success)
{
ShowError(result.message);
return;
}
RefreshGold(result.remainingGold);
RefreshInventory();
}
}
ShopUI는 “버튼 클릭을 명령으로 변환하고 결과를 화면에 반영하는 역할”에 집중하고, 구매 조건 검증과 상태 변경은 BuyItemUseCase로 이동했습니다.
2.5 Adapter를 실행 환경별로 분리했습니다
- 테스트 경로:
InMemoryShopRepository를 사용해 상태를 빠르게 구성합니다. - 런타임 경로:
RuntimeShopRepository만GameManager.Instance를 참조하도록 제한합니다.
이렇게 분리하면 싱글턴 접근 지점이 한 곳으로 모여, 디버깅 범위도 줄어듭니다.
3. EditMode 테스트 전략을 정리했습니다
3.1 픽스처에서 상태를 명시적으로 구성했습니다
[SetUp]
public void SetUp()
{
repo = new InMemoryShopRepository(gold: 100, maxInventorySize: 3);
repo.RegisterShopItem("Sword", 30);
repo.RegisterShopItem("Shield", 50);
useCase = new BuyItemUseCase(repo);
}
이 방식은 씬 로드 없이도 초기 상태를 완전히 통제할 수 있어 테스트 실행이 빠릅니다.
3.2 성공/실패 모두에서 상태 변화를 검증했습니다
| 시나리오 | 검증 포인트 |
|---|---|
| 골드 충분, 인벤토리 여유 | success == true, 잔여 골드 차감, 아이템 추가 |
| 골드 부족 | success == false, 골드 미차감 |
| 인벤토리 가득 참 | success == false, 아이템 미추가 |
| 존재하지 않는 아이템 | success == false, 상태 변경 없음 |
특히 실패 경로에서 “상태가 바뀌지 않았는지”를 확인하면 회귀 버그를 조기에 발견할 수 있습니다.
3.3 EditMode와 PlayMode의 역할을 분리했습니다
| 구분 | EditMode | PlayMode |
|---|---|---|
| 실행 속도 | 빠릅니다 | 상대적으로 느립니다 |
| Unity 생명주기 의존 코드 | 부적합합니다 | 검증 가능합니다 |
| 추천 대상 | 도메인 규칙, 순수 계산 | 씬 통합, 실제 오브젝트 상호작용 |
저는 구매 규칙 검증은 EditMode, UI 버튼 바인딩과 씬 연동은 PlayMode로 분리하는 기준을 사용합니다.
4. 실무 적용 기준을 정리했습니다
작은 기능이라도 아래 기준을 먼저 잡으면 나중에 구조 변경 비용이 줄어듭니다.
- UI 클래스는 입력 수집과 결과 표시만 담당합니다.
- 비즈니스 규칙은
UseCase클래스 한 곳에 모읍니다. - 외부 상태 접근은
Port인터페이스로 제한합니다. - 테스트에서는
InMemory어댑터를 우선 사용합니다. - 실패 시 상태 불변성(assert no side effect)을 필수 검증 항목으로 둡니다.
정리하며
핵심 요약:
MonoBehaviour와 싱글턴 결합은EditMode테스트의 가장 큰 방해 요인입니다.UseCase/Port/Adapter분리만으로도 도메인 규칙을 빠르게 검증할 수 있습니다.- 테스트 품질은 성공 케이스보다 실패 케이스의 상태 보존 검증에서 크게 올라갑니다.
참고 자료
- Unity Test Framework manual —
EditMode/PlayMode테스트 구성과 실행 방식 문서입니다. - Unity Learn: Test your code with the Unity Test Framework — Unity 공식 학습 경로에서 테스트 작성 흐름을 설명합니다.
- Clean Architecture —
UseCase중심 분리 원칙을 이해하는 데 도움이 된 참고서입니다.
이 게시물은 학습한 내용을 바탕으로 초안을 작성한 뒤, LLM의 도움을 받아 내용을 검수하고 다듬어 완성되었습니다.