📕학습 개요

오늘는 유니티(Unity)로 튜토리얼 시스템을 기획하고 구현하는 과정에서 발생한 여러 문제와 해결책을 학습했다. 특히 데이터 관리의 유연성과 이벤트 처리의 중요성에 대해 깊이 이해할 수 있었다.


📖학습 내용

1. 튜토리얼 데이터 관리와 다형성(Polymorphism) 문제

  • 문제점: JsonUtility를 사용해 JSON 데이터를 역직렬화할 때, List<BaseClass> 형태의 리스트는 자식 클래스(DialogueStep, ActionStep)의 고유 필드를 제대로 불러오지 못한다. 시스템은 모든 요소를 BaseClass로만 인식하여 데이터가 유실된다.
  • 해결책: ‘상속 대신 포함(Composition over Inheritance)’ 패턴을 적용했다.
    • 베이스 클래스가 자식 클래스를 상속하는 대신, 각 타입에 맞는 데이터를 담는 Payload 클래스를 멤버 변수로 포함하도록 구조를 변경했다.
    • TutorialStepData 클래스 내에 DialogueStepPayloadActionStepPayload를 두고, type enum 값에 따라 필요한 Payload를 선택적으로 사용했다.
    • 이 방법은 JsonUtility와 완벽하게 호환되며 데이터 구조가 명확해진다.
[Serializable]
public class DialogueStepPayload // Dialogue 전용 데이터를 담는 클래스
{
    public string npcName;
    public string dialogueText;
    public int leftSpriteID;
    public int rightSpriteID;
}

[Serializable]
public class ActionStepPayload // Action 전용 데이터를 담는 클래스
{
    public int targetButtonKey;
    public string actionLog;
    public int indicatorPosition;
}

[Serializable]
public class TutorialStepData // 메인 데이터 (상속 구조 제거)
{
    public int key;
    public TutorialStepType type;
    public float startDelay;

    // 각 타입에 맞는 데이터. JSON 상에서 하나만 채워짐.
    public DialogueStepPayload dialoguePayload;
    public ActionStepPayload actionPayload;
}

2. 동적 데이터 참조와 로딩

  • 문제점: ArgumentException: Could not cast from System.Int64 to Data.TutorialStepData. 오류가 발생했다.
  • 원인: 튜토리얼 흐름을 정의하는 데이터에서 실제 스텝 데이터 객체(TutorialStepData)의 리스트 대신, 스텝의 ID(key)값 리스트를 참조하고 있었기 때문이다.
  • 해결책: 데이터 로딩 파이프라인을 2단계로 구성했다.
    1. 모든 개별 스텝 데이터(TutorialStepData)를 Dictionary<int, TutorialStepData> 형태로 먼저 로드한다.
    2. 튜토리얼 흐름 데이터(TutorialData)에서는 List<int> stepKeys를 참조하도록 클래스 구조를 변경한다.
    3. 튜토리얼 실행 시, stepKeys 리스트를 순회하며 Dictionary에서 실제 스텝 데이터를 꺼내와 사용한다. 이로써 데이터의 정의와 흐름을 완벽히 분리하여 유연성을 확보했다.

3. 유연한 UI 이벤트 처리

  • 요구사항: ButtonToggle 컴포넌트 모두의 클릭 이벤트를 동일한 방식으로 처리하고 싶었다.
  • 해결책: TryGetComponent를 사용하여 GameObject로부터 Button 또는 Toggle 컴포넌트가 있는지 확인 후 분기 처리했다.

    • ButtononClick 이벤트를 사용.
    • ToggleonValueChanged 이벤트를 사용하되, isOn == true일 때만 동작하도록 람다식으로 감싸 UnityAction과 호환되도록 만들었다.

4. ‘단 한 번만 실행되는’ 이벤트 리스너 구현

  • 문제점: 이벤트 리스너를 추가한 뒤, 완료 시점에 수동으로 RemoveListener를 호출해야 하는 번거로움과 실수의 여지가 있었다. 특히 람다식으로 추가한 리스너는 제거가 까다롭다.
  • 해결책: ‘래퍼(Wrapper) 액션’ 패턴을 사용했다.

    • 리스너에 등록할 UnityAction을 새로 정의한다.
    • 이 래퍼 액션은 1. 원래 실행하려던 로직을 호출하고, 2. 자기 자신을 이벤트에서 제거(RemoveListener)하는 두 가지 역할을 모두 수행한다.
    • 이로써 RemoveListener를 외부에서 신경 쓸 필요 없이, 리스너가 스스로를 정리하는 깔끔한 구조를 만들었다.

      public void AddListener(UIButtonType type, UnityAction action)
      {
          // 1. 딕셔너리에서 GameObject를 가져오기
          if (!tutorialButtons.TryGetValue(type, out GameObject uiObject))
          {
              return;
          }
      
          // 2. Button 컴포넌트가 있는지 확인하고 리스너를 추가
          if (uiObject.TryGetComponent<Button>(out Button button))
          {
              UnityAction wrapperAction = null;
              wrapperAction = () =>
              {
                  action?.Invoke();
                  button.onClick.RemoveListener(wrapperAction);
              };
              button.onClick.AddListener(wrapperAction);
              return;
          }
      
          // 3. Button이 없다면, Toggle 컴포넌트가 있는지 확인하고 리스너를 추가
          if (uiObject.TryGetComponent<Toggle>(out Toggle toggle))
          {
              // Toggle은 onValueChanged 이벤트가 bool 값을 전달하므로, 람다식으로 감싸줌.
              // 여기서는 토글이 켜질 때(isOn == true)만 액션을 실행하도록 처리
              UnityAction<bool> wrapperToggleAction = null;
              wrapperToggleAction = (isOn) =>
              {
                  if (isOn)
                  {
                      action.Invoke();
                      toggle.onValueChanged.RemoveListener(wrapperToggleAction);
                  }
              };
              toggle.onValueChanged.AddListener(wrapperToggleAction);
              return; // 토글을 찾았으므로 함수 종료
          }
      
          Debug.LogWarning($"[TutorialManager] No Button or Toggle component found on object for type: {type}");
      }
      

댓글남기기