[Sprint 4] Design Pattern - SWPP-2025SPRING/team-project-for-2025-spring-swpp-team-10 GitHub Wiki

디자인 패턴 적용 보고서

1. 개요

이 보고서에서는 Sprint 4에서 Mission Hampossible 프로젝트에 적용된 디자인 패턴을 분석하고, 해당 패턴이 각 기능에 어떻게 활용되었는지를 설명한다.

2. Cinematic Maneger

2.1 Strategy Pattern

Cinematic 시스템은 오프닝, 굿 엔딩, 배드 엔딩 등의 다양한 영상 시퀀스를 관리해야 한다. 기존에는 OpeningManager, EndingManager 등 개별 매니저가 각기 다른 방식으로 영상 재생을 처리하였고, 이를 통합적으로 관리하기 어렵고 중복 로직이 많았다. 이에 따라, 영상 시퀀스의 구조(초기화, 재생, 스킵 등)는 동일하되, 내부 동작 방식이 달라지는 특성을 고려해 Strategy Pattern을 도입하였다. Strategy 패턴을 활용하여 시네마틱 신의 영상 시퀀스를 CinematicSequence.cs 추상 클래스를 통해 구현했으며, 이를 통해 시네마틱 신에서 오프닝 영상, 굿 엔딩 영상, 배드 엔딩 영상을 재생해야 할 때 서로 독립적인 전략으로 정의하고, 필요에 따라 쉽게 전환할 수 있도록 설계했다.

// Interface
public abstract class CinematicSequence : MonoBehaviour
{
    protected CinematicCommonObject Common;
    protected CinematicTownObject Town;
    protected CinematicTrackObject Track;
    protected CinematicCageObject Cage;
    public Sequence CamSequence;
    
    public abstract IEnumerator Run();
    public abstract void Skip();
    protected abstract void Init();

    ...
}

// Implementation 
public class OpeningManager : CinematicSequence { public override IEnumerator Run() { ... } }
public class GoodEndingManager : CinematicSequence { public override IEnumerator Run() { ... } }
public class BadEndingManager : CinematicSequence { public override IEnumerator Run() { ... } }
// Client Code 
public class CinematicSceneManager : RuntimeSingleton<CinematicSceneManager>
{
    private CinematicSequence _cinematicSequence;

    private void Init()
    {
        // 전략 객체 주입
        _cinematicSequence = FindObjectOfType<OpeningManager>();
        _cinematicSequence.Init(...);
    }

    private void SkipCinematicScene()
    {
        _cinematicSequence.Skip(); // 전략에 따라 다른 Skip 구현 가능
    }
}

2.2 SRP

Cinematic 모듈의 리팩터링은 단일 책임 원칙(Single Responsibility Principle, SRP)를 잘 따르게 리팩토링 되었다. 기존 구조에서는 OpeningManager, EndingManager 등의 클래스가 햄스터의 움직임 제어, 카메라 전환, 페이드 연출, UI 캔버스 조작 등 여러 역할을 동시에 수행하고 있었으며, 이로 인해 유지보수 및 디버깅의 어려움이 존재했다. 특히 영상 연출의 흐름과 세부 연출 로직이 하나의 거대한 코루틴 안에 밀집되어 있어 코드 가독성 또한 낮았다.

리팩터링 이후에는 각 책임이 분리된 구조로 재편되었다. 영상 흐름을 제어하는 역할은 OpeningManager, BadEndingManager, GoodEndingManager 등 각각의 연출 컨텍스트에서 담당하고, 햄스터의 이동이나 회전 연출은 CinematicHamsterController로 이동하였다. 영상에서 사용하는 오브젝트 참조 또한 각각 CinematicTownObject, CinematicTrackObject, CinematicCommonObject 등으로 분리되어, 초기화 시 필요한 매개변수들을 하나의 객체로 통합하여 넘길 수 있게 되었다. 이는 명확히 SRP 원칙을 지키는 구조로, 클래스마다 하나의 책임만을 갖고 있으므로 기능별로 독립적인 수정이 가능해졌다.

  • CinematicSceneManager: 영상 전체 흐름 제어 및 전략 객체 관리 (Context 역할)
  • OpeningManager, BadEndingManager, GoodEndingManager: 각 시퀀스에 맞는 연출 순서 정의 (Strategy 역할)
  • CinematicHamsterController: 햄스터 캐릭터의 위치, 회전 등 움직임 연출만 전담
  • CinematicCommonObject, TownObject, TrackObject 등: 필요한 객체의 구성요소 캡슐화 (Parameter Object)

3. ItemManager & PlayerSkillController

3.1 Observer Pattern

ItemManager는 다음과 같이 이벤트 기반 시스템을 구성하여 옵저버 패턴을 구현했으며, 이에 따라 이벤트 구독자는 PlayerSkillController와 같이 아이템 레벨 변경 사항에 실시간으로 반응할 수 있으며, 이 구조는 아이템과 스킬 간의 느슨한 결합(loose coupling) 을 실현한다. 예를 들어, PlayerSkillController는 ItemManager.Instance.OnItemLevelChange.AddListener(OnItemLevelChange);를 통해 이벤트에 연결되며, 이에 따라 아이템 효과가 능력치에 자동으로 반영된다. 결과적으로, 다양한 시스템 간의 연결을 인터페이스가 아닌 이벤트를 통해 유연하게 조정할 수 있는 구조를 제공한다.

// ItemManager.cs
   public bool TryPurchaseItem(UserItem item)
    {
        if (item == null)
        {
            HLogger.General.Error("null 아이템을 구매하려고 시도했습니다.", this);
            return false;
        }

        if (!CanPurchaseItem(item))
        {
            return false;
        }

        if (!SpendCoin(item.GetCurrentPrice()))
        {
            HLogger.Player.Warning($"코인 부족으로 아이템 구매 실패: {item.item.name} (ID: {item.item.id})", this);
            return false;
        }

        item.isEquipped = true; // 구매 시 자동으로 장착
        _userItems = _userItems.Select(ui => ui.item.id == item.item.id ? item : ui).ToList();

        HLogger.Player.Info($"아이템 구매 성공: {item.item.name} (ID: {item.item.id})", this);
        OnInventoryChanged?.Invoke();
        OnItemLevelChange?.Invoke(item);
        return true;
    }

// PlayerSkillController.cs
    private void Awake()
    {
        ResetSkills();

        ItemManager.Instance.OnItemLevelChange.AddListener(OnItemLevelChange);
    }

4. PersistentDataManager.cs

4.1 Singleton Pattern

PersistentDataManager 클래스는 Singleton 패턴을 활용해 게임 전반에 걸쳐 하나의 인스턴스만 유지되도록 설계되었다. 이를 통해 Scene이 전환되더라도 플레이어 이름, 클리어 시간, 씬 정보 등의 데이터를 전역에서 일관되게 접근하고 관리할 수 있다. 특히 PersistentSingleton를 상속받음으로써 Unity 환경에서 씬 전환 시에도 객체가 파괴되지 않고 지속되며, PlayerPrefs와 JSON 직렬화를 통해 게임 기록을 저장하는 기능을 중앙에서 담당한다. 이와 같은 Singleton 패턴의 적용은 데이터 중복을 방지하고, 게임 상태를 안정적으로 유지할 수 있다.

public class PersistentDataManager : PersistentSingleton<PersistentDataManager>
{
    public string playerName;
    public int mainSceneIndex; // 0: 처음부터, 1: 거실, 2: 욕실, 3: 지하 구역
    public float clearTime; // 시작 ~ 클리어까지 시간

    public void SaveScore()
    {
        SaveScore(mainSceneIndex, playerName, clearTime);
    }

    public void SaveScore(int mainSceneIndex, string playerName, float clearTime)
    {
        string key = $"MainScene{mainSceneIndex}";

        // 기존 기록 불러오기
        string json = PlayerPrefs.GetString(key, "");
        StageScoreData stageData = string.IsNullOrEmpty(json) ? new StageScoreData() : JsonUtility.FromJson<StageScoreData>(json);

        // 새로운 기록 추가
        stageData.scores.Add(new ScoreEntry
        {
            playerName = playerName,
            clearTime = clearTime
        });

        // 저장
        string updatedJson = JsonUtility.ToJson(stageData);
        PlayerPrefs.SetString(key, updatedJson);
        PlayerPrefs.Save();
    }
}

5. InteractionDialogueControllerTests.cs

5.1 Proxy Pattern

Proxy Pattern이 테스트 환경에서 중요한 역할을 수행하였다. 실제 게임에서 사용되는 UIManager, PlayerManager, CheckpointManager 등의 싱글톤 객체를 직접적으로 사용할 경우, 테스트에서 외부 의존성과 복잡도가 증가할 수 있다. 이를 방지하기 위해 TestUIManager, MockPlayerManager, MockCheckpointManager 등 **프록시 객체(대리 객체)**를 생성하여 실제 객체의 역할을 일부 혹은 무시한 채로 대체하였다. 이처럼 Proxy Pattern은 원래 객체에 대한 접근을 제어하거나 동작을 위임하는 구조로, 테스트와 같이 제어된 환경에서 매우 유용하다.

#region --- 모의(Mock) 객체 선언 ---

public class MockPlayerManager : PlayerManager
{
    public override void SetMouseInputLockDuringSeconds(float seconds) { /* 아무것도 안 함 */ }
    public override void SetInputLockDuringSeconds(float seconds) { /* 아무것도 안 함 */ }
}

public class TestUIManager : UIManager
{
    public bool WasDoDialogueCalled_FileName { get; private set; }
    public bool WasDoDialogueCalled_Custom { get; private set; }
    public bool WasDoDialogueCalled_Index { get; private set; }
    public override void DoDialogue(string fileName) { WasDoDialogueCalled_FileName = true; }
    public override void DoDialogue(string character, string text, float lifetime, int faceIdx) { WasDoDialogueCalled_Custom = true; }
    public override void DoDialogue(int index) { WasDoDialogueCalled_Index = true; }
}

public class MockCheckpointManager : CheckpointManager
{
    private int _mockIndex = -1;
    public void SetMockIndex(int index) { _mockIndex = index; }
    public override int GetCurrentCheckpointIndex() => _mockIndex;
}

#endregion