Unity RenderTexture 임시 버퍼 반환 누락을 추적하는 GPU 메모리 누수 디버깅 툴 만들기

Unity RenderTexture.GetTemporary와 ReleaseTemporary의 짝을 추적하는 C# 디버깅 툴을 구현합니다. 호출 위치, 프레임, 해상도, 포맷을 기록해 임시 버퍼 반환 누락과 GPU 메모리 증가 원인을 빠르게 찾고 RenderTexture 풀 재사용과 실제 누수의 차이까지 검증합니다.

✓ 복사 완료!
챕터GPU & 드로우콜/배칭 최적화
난이도중급
상태● 검토 완료
대상 엔진
Unity
최종 업데이트
Unity RenderTexture 임시 버퍼 반환 누락을 추적하는 GPU 메모리 누수 디버깅 툴 만들기

핵심 요약

Unity의 RenderTexture.GetTemporary는 반드시 같은 실행 경로에서 RenderTexture.ReleaseTemporary로 반환해야 합니다. 가장 안전한 방법은 임시 RenderTexture를 직접 다루지 않고 IDisposable 래퍼로 감싼 뒤 토큰, 프레임 번호, 해상도, 포맷, 호출 스택을 함께 기록하는 것입니다.

이 글에서는 반환 누락을 프레임 단위로 감지하고 호출 위치까지 출력하는 개발용 디버깅 툴을 구현합니다. 단, GetTemporary는 내부 풀을 사용하므로 반환 후에도 GPU 메모리가 즉시 0으로 줄지 않을 수 있으며 실제 누수와 풀의 고수위 예약을 구분해야 합니다.

Unity RenderTexture 임시 버퍼 반환 누락은 왜 발생하는가?

후처리, 블러, 그림자, UI 마스킹처럼 중간 화면을 저장해야 하는 기능은 임시 RenderTexture를 자주 사용합니다.

var temporary = RenderTexture.GetTemporary(descriptor);
Graphics.Blit(source, temporary);
Graphics.Blit(temporary, destination);
RenderTexture.ReleaseTemporary(temporary);

문제는 중간에 예외가 발생하거나 조건문에서 조기 반환할 때 생깁니다.

var temporary = RenderTexture.GetTemporary(descriptor);

if (!isSupported)
    return;

Graphics.Blit(source, temporary);
RenderTexture.ReleaseTemporary(temporary);

위 코드는 isSupported가 false인 경우 반환 호출에 도달하지 않습니다. 프레임마다 이런 경로가 반복되면 임시 버퍼가 풀로 돌아가지 않고 GPU 메모리 사용량이 증가할 수 있습니다.

또한 다음 리소스는 서로 다른 반환 규칙을 사용합니다.

호출 유형올바른 반환 방법이 글의 래퍼 적용 여부
RenderTexture.GetTemporaryRenderTexture.ReleaseTemporary(texture)적용
CommandBuffer.GetTemporaryRT같은 커맨드 버퍼에서 ReleaseTemporaryRT(id)별도 추적 필요
RTHandleRTHandle.Release() 또는 풀의 수명 정책별도 추적 필요
new RenderTextureRelease() 후 소유자 수명에 맞춰 정리별도 추적 필요

특히 URP와 커스텀 렌더 패스에서 CommandBuffer.GetTemporaryRT를 사용한다면 이 글의 CPU 측 래퍼로 감싸서는 안 됩니다. 커맨드 버퍼에 기록된 생성과 반환 명령의 순서를 검사해야 합니다.

반환 누락을 어떻게 추적할까?

추적기는 다음 정보를 보관합니다.

기록 항목용도
토큰RenderTexture 인스턴스가 풀에서 재사용되어도 요청 단위를 구분
라벨Bloom.Downsample, Minimap.Copy처럼 기능별 원인 식별
획득 프레임몇 프레임 동안 반환되지 않았는지 계산
RenderTextureDescriptor해상도, 포맷, 깊이 버퍼 등 메모리 규모 추정
호출 스택누락된 요청을 만든 실제 C# 코드 위치 확인

GetTemporary가 내부적으로 풀링을 사용하기 때문에 GetInstanceID()만 저장하면 이전 요청과 새 요청을 혼동할 수 있습니다. 따라서 요청마다 증가하는 토큰을 별도로 부여합니다.

flowchart TD
    A[GetTemporary 호출] --> B[Acquire가 토큰과 호출 위치 기록]
    B --> C[Lease 사용]
    C --> D{Dispose 실행?}
    D -->|예| E[ReleaseTemporary 호출]
    D -->|아니오| F[프레임 지연 후 오류 로그]

C# IDisposable 래퍼로 디버깅 툴 구현하기

아래 코드는 Unity 2021.3 LTS 이상에서 사용할 수 있는 개발용 예시입니다. 호출 스택은 에디터와 Development Build에서만 수집해 일반 배포 빌드의 비용을 줄입니다.

using System;
using System.Collections.Generic;
using System.Diagnostics;
using UnityEngine;

public static class TempRenderTextureDebug
{
    private sealed class Record
    {
        public long Token;
        public string Label;
        public RenderTexture Texture;
        public RenderTextureDescriptor Descriptor;
        public int AcquiredFrame;
        public string StackTrace;
    }

    private static readonly Dictionary<long, Record> Active = new Dictionary<long, Record>();
    private static long nextToken;

    public sealed class Lease : IDisposable
    {
        private long token;
        private RenderTexture texture;

        internal Lease(long token, RenderTexture texture)
        {
            this.token = token;
            this.texture = texture;
        }

        public RenderTexture Texture
        {
            get
            {
                if (texture == null)
                    throw new ObjectDisposedException(nameof(Lease));

                return texture;
            }
        }

        public void Dispose()
        {
            if (texture == null)
                return;

            var releasedTexture = texture;
            texture = null;
            Release(token, releasedTexture);
        }
    }

    public static Lease Acquire(RenderTextureDescriptor descriptor, string label)
    {
        var texture = RenderTexture.GetTemporary(descriptor);
        var token = ++nextToken;

        Active.Add(token, new Record
        {
            Token = token,
            Label = label,
            Texture = texture,
            Descriptor = descriptor,
            AcquiredFrame = Time.frameCount,
            StackTrace = CaptureStackTrace()
        });

        return new Lease(token, texture);
    }

    private static string CaptureStackTrace()
    {
#if UNITY_EDITOR || DEVELOPMENT_BUILD
        return new StackTrace(2, true).ToString();
#else
        return string.Empty;
#endif
    }

    private static void Release(long token, RenderTexture texture)
    {
        if (!Active.Remove(token))
        {
            UnityEngine.Debug.LogError(
                $"[TempRT] 중복 또는 미등록 반환: {texture.name}");
            return;
        }

        RenderTexture.ReleaseTemporary(texture);
    }

    public static void ReportLeaks(int olderThanFrames = 2)
    {
        var currentFrame = Time.frameCount;

        foreach (var pair in Active)
        {
            var record = pair.Value;
            if (currentFrame - record.AcquiredFrame < olderThanFrames)
                continue;

            UnityEngine.Debug.LogError(
                $"[TempRT] 미반환: {record.Label}, " +
                $"frame={record.AcquiredFrame}, " +
                $"size={record.Descriptor.width}x{record.Descriptor.height}, " +
                $"format={record.Descriptor.graphicsFormat}, " +
                $"depth={record.Descriptor.depthBufferBits}\\n" +
                record.StackTrace);
        }
    }
}

핵심은 Lease.Dispose()가 한 번만 실행되도록 만드는 것입니다. texture를 먼저 null로 바꾸므로 같은 객체에 대해 Dispose()가 여러 번 호출되어도 ReleaseTemporary가 중복 실행되지 않습니다.

또한 로그에 호출 스택을 포함하면 단순히 RenderTexture 개수가 증가했다는 사실을 넘어 어떤 기능이 반환을 놓쳤는지 확인할 수 있습니다.

임시 RenderTexture를 사용하는 코드를 어떻게 바꿀까?

기존의 직접 호출을 Acquire와 using 블록으로 감쌉니다.

public void Downsample(RenderTexture source, RenderTexture destination)
{
    var descriptor = source.descriptor;
    descriptor.width = Mathf.Max(1, descriptor.width / 2);
    descriptor.height = Mathf.Max(1, descriptor.height / 2);
    descriptor.msaaSamples = 1;
    descriptor.useMipMap = false;
    descriptor.autoGenerateMips = false;

    using (var bloom = TempRenderTextureDebug.Acquire(
        descriptor, "Bloom.Downsample"))
    {
        Graphics.Blit(source, bloom.Texture);
        Graphics.Blit(bloom.Texture, destination);
    }
}

using 블록을 빠져나갈 때 정상 종료와 예외 발생 여부에 관계없이 Dispose()가 호출됩니다. Unity 프로젝트의 C# 설정이 최신 문법을 지원한다면 다음처럼 using var를 사용할 수도 있지만 기능 범위를 명확히 하려면 블록 형태가 디버깅 코드에서 읽기 쉽습니다.

using (var mask = TempRenderTextureDebug.Acquire(descriptor, "UI.Mask"))
{
    RenderMask(mask.Texture);
}

임시 버퍼를 여러 개 사용할 때도 각 요청을 별도의 using으로 감싸면 반환 순서와 소유 범위가 명확해집니다.

누수 리포트를 언제 실행할까?

추적기는 임시 버퍼를 자동으로 검사하지 않습니다. 개발용 오브젝트의 LateUpdate나 테스트 시나리오의 종료 지점에서 ReportLeaks()를 호출합니다.

#if UNITY_EDITOR || DEVELOPMENT_BUILD
using UnityEngine;

public sealed class TempRTLeakReporter : MonoBehaviour
{
    [SerializeField] private int interval = 60;

    private void LateUpdate()
    {
        if (interval > 0 && Time.frameCount % interval == 0)
            TempRenderTextureDebug.ReportLeaks(2);
    }
}
#endif

olderThanFrames는 기능의 예상 수명보다 조금 크게 설정합니다. 블룸 중간 버퍼처럼 한 프레임 안에 끝나야 하는 객체는 2프레임만 지나도 의심할 수 있습니다. 반대로 여러 프레임에 걸쳐 사용하는 버퍼라면 이 규칙으로 오탐이 발생하므로 임시 버퍼 대신 명시적인 영구 소유권을 가진 RenderTexture를 사용하는 편이 낫습니다.

GPU 메모리 사용량은 어떻게 검증할까?

RenderTexture의 대략적인 컬러 메모리는 다음처럼 추정할 수 있습니다.

M≈W×H×B×S×L×NM \approx W \times H \times B \times S \times L \times N
  • W, H: 텍스처의 가로와 세로 크기
  • B: 픽셀당 바이트 수. 예를 들어 RGBA32는 이론적으로 4바이트
  • S: MSAA 샘플 수
  • L: 배열 슬라이스나 큐브 면 등 텍스처 레이어 수
  • N: 동시에 살아 있는 버퍼 개수

밉맵을 사용하면 추가 메모리가 필요하며 이상적인 2D 밉맵 기준으로는 다음과 같이 근사할 수 있습니다.

Mmip≈W×H×B×43M_{mip} \approx W \times H \times B \times \frac{4}{3}

실제 사용량은 그래픽 API의 정렬, 타일 메모리, 압축 포맷, 깊이 버퍼, 드라이버 풀 정책에 따라 달라집니다. 따라서 이 계산은 누수 여부를 확정하는 값이 아니라 어떤 버퍼가 위험한지 우선순위를 정하는 기준입니다.

검증 절차는 다음과 같습니다.

  1. 동일한 해상도와 그래픽 품질 설정으로 재현 장면을 고정합니다.
  2. 개발 빌드에서 ReportLeaks()를 실행하고 Active 항목이 프레임 종료 후 0으로 돌아오는지 확인합니다.
  3. Unity Profiler와 Memory Profiler에서 재현 전후의 RenderTexture, Graphics Buffer, 텍스처 메모리를 비교합니다.
  4. 반환 로그가 정상인데 메모리가 높은 상태로 유지되면 RenderTexture 풀의 고수위 예약인지 확인합니다.
  5. 해상도 변경이나 씬 전환 후에도 메모리가 계속 증가하고 활성 요청이 누적되면 실제 반환 누락으로 판단합니다.

ReleaseTemporary는 버퍼를 즉시 파괴한다는 의미가 아니라 Unity의 임시 리소스 풀에 반환한다는 의미입니다. 따라서 반환 개수가 정상이어도 풀에 일정량의 메모리가 남을 수 있습니다. 중요한 지표는 단일 프레임의 메모리 숫자가 아니라 같은 시나리오를 반복했을 때 활성 요청 수와 메모리 상한이 안정되는지 여부입니다.

디버깅 툴을 운영 코드에 넣을 때 주의할 점

  • 호출 스택 수집은 개발 빌드에서만 활성화합니다. StackTrace 생성은 반복 호출이 많은 렌더링 경로에서 비용이 될 수 있습니다.
  • 이 예시는 Unity 메인 스레드에서 호출하는 것을 전제로 합니다. Unity의 RenderTexture API와 Time.frameCount를 워커 스레드에서 직접 다루지 않습니다.
  • RenderTexture.ReleaseTemporary 대신 texture.Release()를 호출하지 않습니다. 임시 버퍼는 전용 반환 API를 사용해야 합니다.
  • Destroy(texture)로 임시 버퍼를 정리하지 않습니다. 소유권과 풀 상태가 꼬일 수 있습니다.
  • CommandBuffer.GetTemporaryRT와 RTHandle은 별도의 생명주기를 가집니다. 이 래퍼를 모든 렌더 텍스처 관리 방식에 억지로 적용하지 않습니다.
  • Graphics.ExecuteCommandBuffer 이후까지 GPU 작업이 진행되는 구조라면 반환 시점을 커맨드 실행 순서에 맞춰야 합니다. 비동기 렌더링 경로에서는 CPU의 using 범위만 보고 안전하다고 판단하면 안 됩니다.

자주 묻는 질문 (FAQ)

ReleaseTemporary를 호출했는데 GPU 메모리가 줄지 않는 이유는 무엇인가요?

임시 RenderTexture는 풀에 반환된 뒤 재사용을 위해 메모리를 유지할 수 있습니다. 활성 추적 항목이 0이고 반복 테스트에서 메모리가 일정한 상한에 도달한다면 누수보다 풀 캐시일 가능성이 큽니다.

GetTemporary와 new RenderTexture를 같은 방식으로 해제해도 되나요?

아니요. GetTemporary는 ReleaseTemporary를 사용하고 new RenderTexture는 소유자가 Release()와 Destroy()를 관리해야 합니다. 생성 방식과 반환 방식을 한 쌍으로 유지해야 합니다.

한 프레임보다 오래 임시 RenderTexture를 유지해도 되나요?

가능하지만 일반적인 임시 버퍼의 목적과 맞지 않습니다. 여러 프레임 동안 필요하다면 명시적인 소유자와 해상도 변경 시 재할당 정책을 가진 영구 RenderTexture 또는 RTHandle로 관리하는 편이 안전합니다.

정리

RenderTexture 반환 누락은 단순한 호출 실수처럼 보이지만 후처리와 UI 렌더링이 겹치는 장면에서는 GPU 메모리 증가로 나타나 원인을 찾기 어렵습니다. IDisposable 래퍼로 임시 버퍼의 소유 범위를 제한하고 토큰, 프레임, 디스크립터, 호출 스택을 기록하면 누락된 경로를 재현 즉시 확인할 수 있습니다.

마지막으로 ReleaseTemporary 호출 여부만으로 검증을 끝내지 말고 Memory Profiler에서 반복 실행 후 메모리 상한이 안정되는지 함께 확인해야 합니다. 반환 누락과 RenderTexture 풀의 정상적인 캐시를 구분하는 것이 정확한 GPU 메모리 최적화의 출발점입니다.