본문으로 건너뛰기
HY Devlog
뒤로 가기

Release를 먼저 부르면 Completed는 무효해진 핸들을 받는다

언리얼 엔진을 공부하다가 런타임에 에셋을 불러오는 기능이 있다는 걸 보고, 유니티에도 같은 게 있나 찾다가 닿은 2023년 글이다. Addressables를 직접 써보고 정리한 기록이라 밀도가 높다. 등록 절차, 로드와 해제, Resources와의 비교, 그리고 본인이 겪은 버그까지 들어 있다.

글머리에 “개인의 공부 정리용”, “Unity 2021.3.15f1 기준”이라고 스스로 범위를 적어뒀다. 그래서 대조해볼 값어치가 있다. 지금 기준으로 어디가 맞고 어디가 움직였는지 확인했다.

세 군데가 나왔다. 하나는 문서가 원문보다 더 세게 말해주는 곳이고, 하나는 원문이 헤맸다는 문제의 답이 문서에 한 줄로 있는 곳이고, 하나는 예제 코드 자체의 문제다. 마지막에 출발점이었던 언리얼 쪽과 나란히 놓아봤는데, 거기서 이 글의 첫 번째 문제가 더 분명해졌다.

목차

목차

예제 코드의 Release가 너무 이르다

원문의 로드 예제를 그대로 옮기면 이렇다.

void Start()
{
    // 마우스 커서 텍스처 바꾸기
    AsyncOperationHandle handle = Addressables.LoadAssetAsync<Texture2D>("Cursor_Mining");
    // 완료 시점에 실행할 내용 callback으로 등록
    handle.Completed += (op) =>
    {
        Cursor.SetCursor(handle.Result as Texture2D, Vector2.zero, CursorMode.Auto);
        Debug.Log("Complete");
    };
    // 해제
    Addressables.Release(handle);
}

세 줄이 순서대로 같은 프레임에 실행된다. 로드를 걸고, 콜백을 등록하고, 해제한다. LoadAssetAsync는 비동기이므로 이 시점에 로드는 아직 끝나지 않았다. 그런데 Release가 벌써 불린다.

문서가 Release의 효과를 이렇게 적어놨다.

오퍼레이션 핸들을 릴리스하면 그 오퍼레이션이 로드한 에셋들의 참조 카운트가 감소하고, 오퍼레이션 핸들 객체 자체가 무효화(invalidate)된다.

뒷부분이 문제다. 람다가 handle캡처해서 나중에 handle.Result를 읽는데, 그때 handle은 이미 무효화된 뒤다. 순서를 풀어 쓰면 이렇다.

시점일어나는 일
Start()로드 시작 → 콜백 등록 → Release 실행
몇 프레임 뒤로드 완료 → Completed 발화 → 무효화된 핸들의 Result 접근

설령 값을 읽어낸다 해도 그 직후가 더 곤란하다. 참조 카운트가 0이 되면 텍스처는 해제되는데, 커서는 그 텍스처를 가리키고 있다.

원문도 바로 다음 문단에서 “나중에 필요할 때 해제하려면 반환값을 AsyncOperationHandle 타입 변수에 저장해두는 것이 좋겠습니다” 라고 적는다. 의도는 맞았고 예제에서 줄 위치만 어긋난 것으로 보인다. 다만 코드만 복사해 가면 그 의도가 따라오지 않는다.

Release더 이상 이 에셋을 쓰지 않을 때 불러야 한다. 커서 텍스처처럼 씬이 살아 있는 동안 계속 쓰는 리소스라면 OnDestroy가 그 자리다.

한 가지 더. 예제는 비제네릭 AsyncOperationHandle로 받아 Result as Texture2D로 캐스팅하는데, 문서가 제네릭 쪽을 권한다.

Addressables의 대부분의 메서드는 제네릭 AsyncOperationHandle<T> 구조체를 반환하며, 이는 AsyncOperationHandle.Completed 이벤트와 AsyncOperationHandle.Result 객체에 대한 타입 안전성을 제공한다.

비제네릭 핸들을 잘못된 타입의 제네릭 핸들로 캐스팅하려 하면 런타임 예외가 발생한다.

AsyncOperationHandle<Texture2D>로 받으면 Result가 이미 Texture2D라 캐스팅이 필요 없다.

“비동기만 지원한다”는 말은 2021년에 이미 틀렸다

원문이 단점을 꼽으면서 이렇게 적는다.

우선 비동기 로딩 방식만을 지원하므로, 동기적으로 로딩하려면 추가적인 코드 작성이 필요합니다.

동기 로드용 API가 따로 있다. AsyncOperationHandle.WaitForCompletion()이다. 문서 설명이 이렇다.

오퍼레이션의 WaitForCompletion 메서드를 호출하면, 양보(yield)하거나 이벤트를 기다리거나 async await을 쓰지 않고도 오퍼레이션이 끝나기를 기다릴 수 있다.

opHandle = Addressables.LoadAssetAsync<GameObject>(address);
opHandle.WaitForCompletion(); // 오퍼레이션이 끝나면 반환한다

if (opHandle.Status == AsyncOperationStatus.Succeeded)
{
    Instantiate(opHandle.Result, transform);
}

시점이 중요하다. 이 기능은 1.17.4-preview(2021-01-27) 에 들어갔다. 체인지로그의 문장이 “AsyncOperationHandles에 WaitForCompletion()을 추가했다. 이를 통해 비동기 오퍼레이션을 동기적으로 실행할 수 있다”이다.

원문은 2023년 7월Unity 2021.3.15f1 기준으로 쓰였다. 그 시점에 이미 2년 넘게 존재하던 기능이다. 개인 공부 기록이니 놓칠 수 있는 일이지만, 이 한 줄 때문에 “Addressables는 동기 로드가 안 되니 Resources를 쓰자”는 결론까지 가면 판단이 달라진다.

다만 공짜는 아니다. 문서가 경고를 여러 개 붙여놨다.

주의문서의 서술
프레임 끊김상당한 시간이 걸리는 오퍼레이션, 예를 들어 데이터를 다운로드해야 하는 경우에는 WaitForCompletion 호출을 피하라”
연쇄 대기호출 시 활성화된 모든 에셋 로드 오퍼레이션이 완료되므로 예상치 못한 멈춤이 생길 수 있다
데드락Awake에서 씬 로딩 중에 호출하면 메인 스레드를 막아 다른 오퍼레이션이 끝나지 못한다. 연속된 씬을 이렇게 로드하면 에디터나 플레이어가 데드락에 걸릴 수 있다
플랫폼WebGL은 WaitForCompletion을 지원하지 않는다

정리하면 “동기 로드가 안 된다”가 아니라 “동기 로드는 되지만 비싸고, WebGL엔 없다” 가 맞다. 판단의 근거가 바뀐다.

중복 키 문제는 문서에 한 줄로 있다

원문에서 제일 값어치 있는 부분은 본인이 겪은 버그 기록이다.

일부 무기의 이미지와 오디오를 같은 이름으로 등록하는 바람에 해당 무기들의 sfx가 로드되지 않는 이슈가 발생했습니다. 별도의 예외가 발생하지도 않고 (…) 원인을 찾느라 한참 헤맸습니다.

그리고 “단일 로드할 리소스의 키는 중복되지 않게 등록하는 것이 좋겠습니다” 로 끝난다. 맞는 결론이고, 현행 문서가 이 동작을 명시하고 있다.

키가 두 개 이상의 에셋으로 해석되면, 가장 먼저 발견된 에셋만 로드된다.

여러 에셋에 적용된 레이블로 이 메서드를 호출하면, Addressables는 그중 가장 먼저 찾은 것을 반환한다.

“예외가 발생하지 않는다”가 버그가 아니라 설계된 동작이라는 뜻이다. 키가 주소 하나만 가리키는 게 아니라 레이블도 될 수 있기 때문에, 여럿에 걸리는 건 정상 상황이고 그중 하나를 고른다.

그래서 규칙은 이렇게 정리된다.

원문이 한참 헤맸다는 그 증상 — 로드가 끝났는데 배열 일부가 null — 이 정확히 이 동작의 결과다.

Resources 비교는 맞다, 문서가 더 세게 말한다

원문의 Resources 비판은 두 갈래다. 둘 다 맞고, Unity 매뉴얼의 표현이 오히려 더 단정적이다.

첫째, 쓰지 않는 것까지 빌드에 들어간다.

Resources 폴더 안의 에셋은 무엇에도 참조되지 않더라도 항상 플레이어 빌드에 포함된다.

원문이 “10GB 중 1GB만 써도 10GB가 전부 포함된다”고 예를 든 그대로다. 매뉴얼은 여기에 하나를 더 붙인다.

Resources 폴더에 에셋이 많으면 애플리케이션을 빌드하고 시작하는 데 오랜 시간이 걸릴 수 있다.

빌드 용량만이 아니라 시작 시간이다. 원문의 빌드 용량 비교 스크린샷에는 안 잡히는 비용이라 짚어둘 만하다. 매뉴얼은 콘텐츠가 많은 애플리케이션이라면 콘텐츠 디렉터리, 에셋 번들, Addressables 패키지 같은 대안을 고려하라고 직접 권한다.

둘째, 경로 대신 키를 쓴다.

원문의 표현이 정확하다 — 등록한 객체를 옮기면 경로가 자동으로 갱신되므로 “객체의 위치를 바꾸어도 로드하는 부분을 수정할 필요가 없습니다.” 이쪽은 지금도 그대로다.

빌드에 무엇이 들어가는지를 관리하는 이야기는 Unity 코드 최적화 문서 쪽의 관리 메모리 항목과도 이어진다.

언리얼 쪽에서는 어떻게 하나

애초에 이 기능을 찾게 된 출발점이 언리얼이었으니, 나란히 놓아본다.

언리얼은 참조를 하드와 소프트로 나눈다. 문서의 정의가 이렇다.

하드 참조는 오브젝트 A가 오브젝트 B를 참조하면서, A가 로드될 때 B도 로드되게 하는 참조다.

소프트 참조는 오브젝트 A가 오브젝트 경로의 문자열 형태 같은 간접적인 수단으로 B를 참조하는 것이다.

Unity의 Resources 문제와 모양이 같다. 하드 참조는 주인이 올라올 때 딸려 올라오고, Resources 폴더의 에셋은 참조되지 않아도 빌드에 들어간다. 둘 다 해법이 같다 — 참조를 문자열로 간접화한다. 언리얼은 TSoftObjectPtr, Addressables는 키다.

TSoftObjectPtr에 대한 문서 설명도 익숙하다 — 프로퍼티를 문자열로 저장 하고, IsPending()으로 로드 여부를 확인할 수 있으며, “쓰려고 할 때 직접 에셋을 로드해야 한다.”

로드하는 쪽은 FStreamableManager다. 문서의 예제를 그대로 옮기면 이렇다.

Streamable.RequestAsyncLoad(ItemsToStream,
  FStreamableDelegate::CreateUObject(this,
    &UGameCheatManager::GrantItemsDeferred));

비동기 로드를 걸고 완료 델리게이트를 넘긴다. LoadAssetAsync + Completed와 같은 모양이다. 대응을 정리하면 이렇다.

언리얼Addressables
간접 참조TSoftObjectPtr / FSoftObjectPath키(주소) 문자열
비동기 로드FStreamableManager::RequestAsyncLoadAddressables.LoadAssetAsync<T>
완료 통지FStreamableDelegateAsyncOperationHandle.Completed
살아 있게 하는 것하드 참조 (+ GC)핸들의 참조 카운트

마지막 줄이 갈리는 지점이고, 여기서 이 글 첫 절의 버그가 더 선명해진다. 언리얼 문서의 문장을 보자.

StreamableManager델리게이트가 호출될 때까지 자신이 로드한 에셋들에 하드 참조를 유지한다. 그래서 비동기로 로드하려던 오브젝트들이 델리게이트 호출 전에 가비지 컬렉트되지 않는다는 것을 안전하게 알 수 있다. 델리게이트 호출 이후에는 그 참조들을 놓기 때문에, 계속 남아 있게 하려면 다른 곳에서 하드 참조를 잡아야 한다.

두 엔진 다 “콜백 이후까지 살려두는 것은 내 책임” 인데, 놓는 시점이 반대다.

첫 절의 예제가 정확히 후자다. 언리얼의 감각으로 “일단 해제 호출을 적어두자”고 하면 안 되는 이유가 이것이다. Addressables에서 Release정리 습관이 아니라 수명의 끝을 뜻한다.

어디에 왜 쓰나

정리하면 로드는 쉽고 해제가 어렵다. 참조 카운트를 내가 맞춰야 하고, 문서도 못을 박는다.

Unity는 참조된 에셋을 자동으로 로드하거나 해제하지 않는다. Addressables API를 사용해 직접 로드하고 해제해야 한다.

로드부터 해제까지 한 컴포넌트에서

핸들의 수명을 컴포넌트 수명에 묶으면 빠뜨릴 일이 줄어든다.

using System;
using UnityEngine;
using UnityEngine.AddressableAssets;
using UnityEngine.ResourceManagement.AsyncOperations;

/// <summary>
/// 커서 텍스처를 Addressables로 로드하고, 컴포넌트가 사라질 때 함께 해제한다.
/// </summary>
public class CursorLoader : MonoBehaviour
{
    [Header("Addressables")]
    [SerializeField, Tooltip("Addressables에 등록한 키. 단일 로드이므로 중복되지 않아야 한다")]
    private string _cursorKey = "Cursor_Mining";

    // 제네릭 핸들로 받는다. Result가 이미 Texture2D라 캐스팅이 필요 없다.
    private AsyncOperationHandle<Texture2D> _handle;
    private bool _hasHandle;

    public event Action<Texture2D> OnCursorReady;

    private void Start()
    {
        _handle = Addressables.LoadAssetAsync<Texture2D>(_cursorKey);
        _hasHandle = true;
        _handle.Completed += HandleCompleted;
    }

    private void OnDestroy()
    {
        // 여기가 해제 자리다. 로드를 건 직후가 아니다.
        if (_hasHandle)
        {
            Addressables.Release(_handle);
            _hasHandle = false;
        }
    }

    private void HandleCompleted(AsyncOperationHandle<Texture2D> op)
    {
        if (op.Status != AsyncOperationStatus.Succeeded)
        {
            Debug.LogWarning(
                $"[{nameof(CursorLoader)}] '{_cursorKey}' 로드 실패. " +
                "키가 존재하는지 확인할 것.", this);
            return;
        }

        Cursor.SetCursor(op.Result, Vector2.zero, CursorMode.Auto);
        OnCursorReady?.Invoke(op.Result);
    }
}

몇 가지 의도를 적어둔다.

동기 로드가 필요할 때

앞 절의 경고를 감안한 형태다. 작고 로컬에 있는 에셋에 한정한다.

AsyncOperationHandle<GameObject> handle = Addressables.LoadAssetAsync<GameObject>(key);
handle.WaitForCompletion();

if (handle.Status == AsyncOperationStatus.Succeeded)
{
    Instantiate(handle.Result, transform);
}

WebGL 빌드가 대상에 있다면 이 경로를 쓰면 안 된다. 문서가 지원하지 않는다고 명시한다. 원격에서 내려받아야 하는 콘텐츠에도 쓰지 말라고 한다.

쓰지 말아야 할 자리

정리

원문은 스스로 “공부 정리용”이라 적어둔 글이고, 실제로 겪은 버그까지 남겨둔 덕에 대조할 거리가 많았다. 앞서 CrossFade 글에서도 비슷한 모양을 봤는데, 패턴이 같다. 오래된 기술 글은 대개 틀려서가 아니라, 그 사이 조건이 움직여서 다시 봐야 한다.


참고

이 글의 출발점이 된 자료는 sam0308 — [Unity] Addressable 기능 (2023-07-27, Unity 2021.3.15f1 기준)이다. 원문이 Addressables 1.x 시절 글이고, 인용과 동작은 현행 3.1 문서로 다시 대조했다.


이 글 공유하기:

이전 글
OverlapSphere는 Ignore Raycast 레이어를 무시하지 않는다
다음 글
[MenuItem]은 Editor 폴더로 가야 하는데, MonoBehaviour는 거기 못 간다