[TIL/15] 최고의난관, 돌아온 그놈...

안건우·2025년 10월 2일

sparta_til

목록 보기
14/26

대화 선택지 데이터 유실

내 프로젝트의 대화 기능에 대한 보충을 진행하고 있었다. 원래대로라면 게임 내 대화가 끝난 후 선택지가 자동으로 노출되어야 했지만, 선택지가 전혀 노출되지 않는 문제가 발생했다.
선택지 제시 기능 자체는 이전프로젝트에서 완성을 했었기에 오랫동안 테스트를 진행하지않고 방치해둔 기능이었다.
애초에 로직자체도 복잡하지 않았기에 그렇게 걱정을 하지 않았지만... 이 놈은 결국 내 하루를 전부 빼앗아가버렸다.
결국 개발초기부터 나를 괴롭히고 또 괴롭혔던 DataImport 그 놈이 범인이었기때문이다.

초기 의심(09~11시)

  1. 어드레서블

    • 가장 먼저 어드레서블 로딩 문제을 의심했다. 도입 이후 나를 가장 괴롭힌것 녀석이었기 때문이다.
      GameResourceManager와 StoryManger를 점검해보았지만에셋 로딩 자체에는 아무런 문제가 없었다.
      ActionSequence를 담고있는 StoryEventData의 참조값을 문자열에서 AssetReferenceGameObject로 변경 해보았지만 역시나 여전했다.
      다시 한번 데이터를 확인해 보니, 에셋 임포트 시점까지는 SO(ScriptableObject) 데이터 내에 선택지 데이터 참조가 정상적으로 존재했다.
      그런데 에디터 실행과 동시에 해당 참조가 유실되는걸 확인했다. 이는 어드레서블 로딩 문제가 아니라, 에셋 데이터의 '지속성(Persistence)' 문제임을 시사했다. 에디터가 실행되면 뭔가 참조를 날려버리고 있다는 생각이 들었다.
      그리고 바로 아찔해지기 시작했다. 지금까지 내가 Importer기능을 구현하기위해 Depricated된 클래스파일만 거의 10개가 넘어갔기 때문이었다. 고난의 길이 앞에 있다는걸 직감하고 마음의 준비를 시작했다.
  2. 커스텀 에디터 로직 점검

    • 문제의 원인이 에셋 임포트 후 에디터 실행 시점에 있다고 판단하고, 기존에 작성했던 커스텀 에디터 스크립트, 특히 AssetPostprocessorDataImportContainer (임포트된 SO 데이터와 참조 정보를 관리하는 커스텀 SO) 로직을 면밀히 검토하기 시작했다. PendingReference라는 커스텀 클래스를 통해 후처리로 참조를 연결하는 구조였는데, 이 부분에서 문제가 발생하고 있을 것이라는 직감이 들었다.

다양한 시도와 실패(11시~18시)

  1. AssetPostprocessor의 오작동?

    • 에디터 실행 시 AssetPostprocessor가 데이터를 덮어쓰고 있는 건 아닐까 하는 의심이 들었다. 디버깅을 잡아보았지만 AssetPostprocessor는 문제없이 임포트, 삭제, 이동, 변경시에만 호출되는것을 확인했다. 에디터 실행 시에는 호출되지 않았으므로, 이게 문제일 리 없었다.
  2. 수동 실행 시도:

    • AssetPostprocessor 로직을 자동으로 실행시키는 대신, 에디터 메뉴에 기능을 추가하여 수동으로 실행시켜 보았다.
      결과는 동일했다. 100% 똑같은 현상이 발생했고, 참조는 여전히 유실되었다. 이 테스트는 "로직 실행 자체의 문제가 아니라, 데이터를 저장하는 방식 자체에 근본적인 문제가 있다"는 것을 확신시켜 주었다.
  3. hideFlags 설정:

    • Unity 에디터에서 객체의 생명 주기나 표시 방식을 제어하는 hideFlags가 문제일 수도 있다는 생각이 들었다.
      특히 pending.targetObject.hideFlagsNone으로 설정해 보면 어떨까 싶었다.
      하지만 이것 또한 어림없는 시도였다. HideFlags는 에디터에서의 표시나 가비지 컬렉션 대상 여부 등에 영향을 줄 뿐, 직렬화된 데이터가 디스크에 올바르게 기록되지 않는 근본적인 문제를 해결할 수는 없었다.
      이 시도는 문제의 핵심에서 벗어나고 있었다.
  4. 참조 방식 변경:

    • PendingReference가 아닌 StoryEventData 에 객체를 직접 담는 것이 문제일 수도 있다는 생각이 들었다. 그래서 대상이 될 Asset의 ID만 담아서 참조함으로써 불변성을 유지하는 방향으로 변경해 보았다.
      그러나 이것 역시 어림없는 시도였다. 애초에 requiredIds를 통해 ID를 저장하고 있던 행위의 뎁스를 한 단계 끌어올린 것일 뿐, 필드에 값을 할당하는 방식의 한계를 넘어서지 못했다. 근본적인 해결책이 될 리 없었다. 슬슬 시계가 5시를 넘어가고있었다

핵심 원인: Unity 직렬화 시스템에 대한 이해 부족

결국 핵심 문제는 Unity의 에셋 직렬화 시스템에 대한 이해 부족에 있었다.

  • 나는 기존에 FieldInfo.SetValue를 사용하여 ScriptableObject 필드에 다른 에셋의 참조값을 할당하고 있었다.

  • 하지만 FieldInfo.SetValue는 단순히 C# 객체의 메모리 상 값을 변경하는 역할을 한다. 이 변경은 런타임에 유효하지만, Unity 에디터가 이 변경 사항을 에셋 파일에 '영구적으로 직렬화하여 저장'하도록 트리거하지 않는다는 것이다.

  • Unity 에디터는 특정 방식으로 변경된 데이터만 '더티' 상태로 인식하고 저장한다. FieldInfo.SetValue를 통한 변경은 이 직렬화 시스템의 감지 범위를 벗어나는 행위였던 것이다.

  • 따라서 에디터가 재시작되거나, 에셋이 다시 로드될 때, FieldInfo.SetValue로 인해 변경된 내용은 직렬화되어 저장되지 않았으므로, 유실되었던 것이다.

  • EditorUtility.SetDirty()의 함정 : 이 과정에서 SetDirty()를 호출하는 것 역시 완벽한 함정이었다. 이 함수는 에셋이 변경되었다고 '알려주는' 역할만 할 뿐, Unity가 인지하지 못하는 메모리상의 변경(FieldInfo.SetValue)을 디스크에 '어떻게 저장할지'는 알려주지 못했다. 결국 Unity는 무엇이 바뀌었는지 모르는 채 저장할 수 없었던 것이다. Unity는 자신이 인지하고 추적할 수 있는 방식으로 데이터가 변경되었을 때만 이 플래그를 올바르게 해석하여 저장할 수 있다. 따라서 SetDirty() 플래그가 세워져 있더라도, 막상 Unity가 저장(직렬화)을 시도할 때가 되면, 자신이 이해할 수 없는 방식으로 변경된 데이터를 어떻게 파일에 써야 할지 모른다. 결국 Unity는 가장 마지막으로 자신이 알고 있던 '안전한' 상태, 즉 FieldInfo.SetValue가 적용되기 이전의 상태로 에셋을 저장해버리는 것이다.

✅ 최종 해결책

Unity 에디터 스크립트에서 에셋의 데이터를 영구적으로 변경하려면 반드시 SerializedObjectSerializedProperty를 사용해야 한다고 한다. 이것이 Unity 에디터가 에셋 파일에 변경사항을 저장하는 공식적인 방식인 것이다.

  1. SerializedObject로 에셋 래핑:
    대상 ScriptableObject 에셋을 using (var serializedObject = new SerializedObject(targetAsset)) 형태로 래핑하여, Unity의 직렬화 시스템이 해당 에셋을 관리하도록 했다.

  2. SerializedProperty를 통한 값 변경:
    참조 값을 할당하는 메서드를 FieldInfo.SetValue 대신 SerializedProperty를 사용하도록 변경했다.
    SerializedProperty prop = serializedObject.FindProperty("fieldName"); // 참조를 설정할 필드 이름
    prop.objectReferenceValue = referenceAsset; // 할당할 참조 에셋 (Object 타입)
    이 방식은 Unity 직렬화 시스템이 "필드의 값이 변경되었다"고 인식하고 추적할 수 있도록 한다.

  3. 변경사항 적용:
    수정된 내용을 에셋 파일에 반영하기 위해 serializedObject.ApplyModifiedProperties();를 호출했다. 이 호출이 실제 디스크에 변경된 데이터를 저장하도록 트리거하는 핵심적인 역할을 했다.
    (EditorUtility.SetDirty()와 유사하지만, SerializedObject의 변경사항을 직접 커밋하는 역할이라고 이해했다.)

  4. 실행 시점 안정화:
    이 후처리 로직의 실행 시점을 [InitializeOnLoad] 어트리뷰트와 EditorApplication.delayCall을 활용하여 에디터 로딩 완료 후 지연 호출되도록 변경했다.
    이는 모든 에셋이 완전히 로드되고 AssetDatabase가 안정적인 상태에서 참조 연결 작업이 이루어지도록 하기 위함이었다.
    InitializeOnLoad는 에디터 시작 시 한 번 실행되지만, 에셋 로딩이 완전히 끝난 후 실행되도록 delayCall을 활용하는 것이 더 견고한 방식이라고 한다.

지금까지 만났던 트러블 슈팅 중 최고의 난이도였다.

웹개발을 하다가 게임개발로 넘어오면서 느낀 가장 큰 차이점은 바로 디버깅의 난이도다.
짧은 경력으로 웹개발의 모든것을 판단하려는것은 아니지만 최소한 내가 개발을 하던 환경은 문제점을 찾기만 수정까지는 큰 문제없이 진행할수 있다는것이다.
데이터가 유실된다면 유실되는 곳을 눈으로 파악하고 유실되는 원인을 파악하면 된다.
하지만 이 부분에서 Unity는 내가 눈으로 파악할 수 없는 내부 로직이 복잡하게 얽히고설켜있다.
오늘 내가 겪은 트러블슈팅처럼 에디터API들의 정확한 작동방식이나 타이밍을 이해하는것이 필수적이라는것이다. 눈에 직접적으로 보이는 에러나 문제 방식이 없어도 깊은 이해가 없으면 이렇게 원인조차 파악할수 없는 에러들이 수두룩하게 발생하는것이다. 특히 모듈을 지웠다 새로깔기, 유니티껐다가 새로키기로 이슈가 해결될때는.... 해결했으니 다행이라는 감각보다는 대체 이 속에 뭐가 있는거지..? 라는 두려움만이 쌓여간다... 두렵구나 유니티

0개의 댓글