대화기능을 먼저 작성하여 스토리작업을 함께 진행하고 싶은 욕심에 GameScene의 구현을 먼저 시작했더니 코드가 꼬이기 시작했다. GameLifetimeScope와 GameInitializer에 임시 코드나 나중에 삭제해야 할 코드가 계속해서 늘어나고 있었다. 이대로면 내 통제에서 벗어나게 될것임에 분명했다. 또한 미숙한 Addressables Vcontainer 사용 역시 시간을 계속해서 뺏어가고 있었다. 결국 사용자의 실제 플레이 플로우를 따라가는 것이 낫다고 판단하여, 개발 순서를 변경해 MainMenuScene부터 만들기로 결정했다.
AsSelf vs AsImplementedInterfacesMainMenuLifetimeScope를 구성하는 과정에서 MainMenuView에 MainMenuController를 주입할 때, MainMenuController 의 인터페이스를 생성하여AsImplementedInterfaces를 쓸지 아니면 어차피 재사용성이 떨어지는 컨트롤러이니 인터페이스 구현없이 AsSelf를 써버릴지 고민이 생겼다.
AsSelf와AsImplementedInterfaces의 차이
AsSelf: 구체 클래스 타입(MainMenuController)으로 직접 주입을 요청할 때 사용한다.AsImplementedInterfaces: 클래스가 구현한 인터페이스 타입(IMainMenuController)으로 주입을 요청할 때 사용한다.
MainMenuView 테스트 시, 복잡한 MainMenuController 대신 간단한 Mock 객체를 주입하여 View의 동작만 순수하게 테스트할 수 있다.AsSelf()는 이런 경우를 위해 존재한다.IMainMenuController라는 인터페이스를 생성하고, MainMenuView가 이를 주입받도록 AsImplementedInterfaces를 사용하기로 결정했다. 재사용성이 떨어지는 인터페이스임은 분명하지만, 이유는 다음과 같다.
※ 이 과정에서 추가로 공부한
[Inject]용례
MonoBehaviour에서는 생성자 주입이 불가능하므로 필드/프로퍼티 주입을 위해 필수로 사용해야 한다.- 일반 클래스에서 생성자가 여러 개일 경우, DI 컨테이너가 어떤 생성자를 사용해야 할지 알려주는 지시자 역할을 한다.
그렇게 MainMenuScene을 실행하니 페이드 아웃이 되지않고 검은색 트랜지션 캔버스가 화면을 덮고 있었다. 원인은 금방 찾을 수 있었다. TransitionService는 FadeAndLoadSceneAsync 라는 메서드를 사용해서 InitializerScene의 TransitionCanvas를 조절하여 페이드인/아웃과 씬 전환 기능을 수행한다. 게임 시작 시 GameInitializer -> GameManager 순으로 로직이 호출되는데, BootstrapSceneLoader가 MainMenuScene을 로드할 때 TransitionService를 거치지 않고 Addressables 에셋을 직접 로드하고 있었다. 당연히 TransitionCanvas의 알파 값은 조절되지 않았다.
이 지점에서 근본적인 의문이 들었다.
"TransitionService가 페이드 효과와 씬 전환, 두 가지 역할을 동시에 맡는 것은 SRP(단일 책임 원칙) 위반이 아닌가?"
이 의문은 PlayerDataRepository의 패턴을 통해 해소되었다.
내 프로젝트의 PlayerDataRepository는 Inventory와 PlayerStat 두 테이블을 동시에 관리한다. 엄격한 SRP 관점에서 보면, 이는 InventoryRepository와 PlayerStatRepository로 분리해야 하는 설계다.
하지만 '단일 기능'의 경계를 어떻게 설정하느냐에 따라 관점이 달라질 수 있다.
테이블 관리라는 개별 기술로 보면 이는 분명한 SRP 위반이다. 하지만 도메인의 개념으로 보면 PlayerDataRepository의 책임은 'PlayerData'라는 도메인 개념을 구성하는 것이다. 이 관점에서 보면 Inventory와 PlayerStat은 'PlayerData'를 구성하는 하위 요소일 뿐이다. 이를 TransitionService에 적용하면, 이 서비스의 책임은 '페이드 효과'와 '씬 로딩'이라는 개별 기능이 아니라, '씬과 씬 사이의 연결'이라는 하나의 도메인을 책임지는 것이라고 볼 수 있다.
처음에는 GameInitializer, GameManager, BootstrapSceneLoader 중 어디에서 TransitionService를 호출할지 고민했다. 하지만 훨씬 간단한 해결책을 선택했다. TransitionCanvas의 초기 알파 값을 0으로 변경했다. 이는 편의를 위한 해결이 아니었다. MainMenuScene은 다른 씬에서 '전환'되는 단계가 아닌 '시작' 단계다. 내가 만든 FadeAndLoadSceneAsync 메서드는 이름 그대로 화면과 화면의 연결을 담당해야지, 초기 화면을 시작하는 메서드가 아니다. 만약 추후 MainMenuScene 앞에 인트로 씬이 생긴다면, 시작 씬을 인트로 씬으로 변경하고 그곳에서 FadeAndLoadSceneAsync("MainMenuScene")을 호출하면 된다. 사실 최근에 VContainer와 Addressables을 사용하며 수도없이 복잡한 문제와 복잡한 해결책을 마주했어서 이렇게 해결을 하려고 하니 조금 찝찝한 감이 들었다. 하지만 아무리 생각해도 이것이 도메인 관점에서 더 논리적인 메서드 활용법이라는 결론을 내렸다.