반응형

[PART12.메모리 관리와 성능(3/10)] using — 세 가지 using의 차이

이름은 같지만 역할이 다른 세 문법 / 컴파일러가 생성하는 try·finally / Unity 네이티브 리소스 해제의 표준 패턴

<a id="section-1"></a>

1. 문제 제기 — 같은 키워드, 다른 세상

Unity 프로젝트를 처음 열면 거의 모든 C# 파일 맨 위에 이런 줄이 있습니다.

C#
using System;
using UnityEngine;
using Unity.Collections;

그런데 Job System 예제를 읽다 보면 이런 코드도 등장합니다.

C#
using (var positions = new NativeArray<float3>(100, Allocator.TempJob))
{
    // ...
}

또 최근 오픈소스 예제에서는 이런 형태도 심심치 않게 보입니다.

C#
using var buffer = new GraphicsBuffer(GraphicsBuffer.Target.Structured, 1024, 16);

셋 다 using이라는 같은 키워드를 쓰지만, 문법적 위치·컴파일 결과·런타임 동작이 전혀 다릅니다. 신입 개발자 시점에서는 이 셋이 어떻게 다른지 구분이 안 되면 다음과 같은 문제에 부딪힙니다.

  • 파일 맨 위에 쓰는 using은 왜 ;로 끝나는데 블록 안의 using() 뒤에 중괄호가 오는가?
  • using var ...; 는 언제부터 가능했고, 기존 using (...) 문과 뭐가 다른가?
  • Unity 에디터가 뱉는 *"A Native Collection has not been disposed"* 경고는 왜 using을 써야 사라지는가?
  • await using은 또 뭐가 다른가?

Unity 모바일 클라이언트의 핫패스에서는 NativeArray, GraphicsBuffer, ComputeBuffer처럼 GC(Garbage Collector, 관리 힙의 객체를 자동으로 수거하는 런타임 구성요소)가 추적하지 않는 네이티브 메모리를 자주 다룹니다. 여기서 using을 잘못 쓰면 Editor가 죽거나, 빌드에서 VRAM(Video RAM, GPU 전용 메모리)이 조용히 새어 나갑니다. 세 가지 using을 구분하지 못하면 버그 원인을 찾는 데 몇 시간씩 낭비합니다.

이 글의 목표는 다음과 같습니다.

같은 글자 using이 C#에서 수행하는 세 가지 역할을 구분하고, 각각이 IL로 어떻게 컴파일되는지 직접 확인한 뒤, Unity 실전에서 어떤 패턴으로 써야 하는지까지 정리합니다.

<a id="section-2"></a>

2. 개념 정의 — 세 가지 using을 한눈에

2.1 세 얼굴의 using

C#에서 using은 이름만 같을 뿐 완전히 다른 세 가지 문법에 쓰입니다.

using 지시문 (Directive)
using — 세 역할을 가진 키워드 C#에서 using은 세 가지 다른 문법에 중복 사용됩니다. 각각 등장 위치·세미콜론 유무·컴파일 결과로 구분합니다.
예시: using System; → 지시문 (파일 맨 위) using (var r = ...) { } → 문 (블록) using var r = ...; → 선언 (세미콜론으로 끝)

2.2 using 지시문 — 컴파일러를 위한 약속

using System;처럼 파일 맨 위 또는 namespace 선언 안쪽에 적는 형태입니다. 컴파일러에게 "이 네임스페이스의 타입들은 짧은 이름으로 써도 된다"고 알려주는 문법적 편의입니다. 런타임에는 흔적이 남지 않습니다.

C#
// 1) 네임스페이스 가져오기
using System;

// 2) 정적 멤버 직접 호출 (C# 6.0)
using static System.Math;

// 3) 타입 별칭 (C# 1.0 이상, C# 12에서 any type 허용)
using Vec2Int = System.Collections.Generic.Dictionary<int, int>;

// 4) global — 프로젝트 전체에 적용 (C# 10.0)
global using UnityEngine;

쉽게 말해 에디터에서 Ctrl+. 를 눌렀을 때 추가되는 그 줄입니다. Console.WriteLine()이라고 쓰면 컴파일러가 "이건 사실 System.Console.WriteLine()이지"라고 해석해 줍니다. 컴파일이 끝난 .dll 안에는 항상 전체 이름(Fully Qualified Name)만 남습니다.

기술적으로는 어휘 범위(lexical scope) 안에서의 이름 해석 규칙(name resolution rule)을 추가하는 선언입니다. C# 컴파일러가 심볼 테이블을 빌드할 때만 참조하고, IL 출력에는 직접 반영되지 않습니다.

2.3 using 문 — IDisposable을 결정적으로 해제

파일·소켓·DB 연결처럼 CLR(Common Language Runtime, .NET 코드를 실행하는 가상 기계)이 추적하지 못하는 자원을 쓰는 객체는 IDisposable을 구현합니다. 그리고 사용이 끝나면 Dispose()를 호출해 해당 자원을 반납해야 합니다. using 문은 이 호출을 잊지 않도록 강제하는 문법입니다.

C#
// Unity에서 파일을 읽는 전형적인 상황
void LoadSaveData()
{
    using (var stream = new FileStream("save.dat", FileMode.Open))
    {
        var header = new byte[4];
        stream.Read(header, 0, 4);
    } // 여기서 stream.Dispose() 자동 호출 → OS 파일 핸들 반납
}

블록 { } 안에서 예외가 터지든, return으로 빠져나가든 상관없이 블록을 떠날 때 반드시 Dispose()가 실행됩니다. 쉽게 말해 "이 블록을 나가는 모든 출구에 Dispose 호출을 붙여 준다"는 규칙입니다.

기술적으로는 컴파일러가 try { ... } finally { obj?.Dispose(); } 구조를 자동으로 생성합니다. 자세한 IL은 다음 장에서 확인합니다.

2.4 using 선언 — 중괄호를 벗은 C# 8.0

여러 리소스를 동시에 쓰면 using 문은 중첩 피라미드가 됩니다.

C#
// Before — 중첩 3단
using (var src = File.OpenRead("in.dat"))
{
    using (var dst = File.Create("out.dat"))
    {
        using (var gz = new GZipStream(dst, CompressionMode.Compress))
        {
            src.CopyTo(gz);
        }
    }
}

C# 8.0의 using 선언은 중괄호 없이 한 줄로 선언합니다. 리소스는 그 변수가 속한 스코프가 끝날 때 해제됩니다.

C#
// After — 평탄한 한 줄씩
void Compress()
{
    using var src = File.OpenRead("in.dat");
    using var dst = File.Create("out.dat");
    using var gz = new GZipStream(dst, CompressionMode.Compress);
    src.CopyTo(gz);
} // 메서드 끝에서 gz → dst → src 순서로 Dispose()

해제 순서는 선언 역순입니다. 위에서 마지막으로 선언된 gz가 먼저 Dispose()되고, 그다음 dst, 마지막으로 src가 해제됩니다. 이는 중첩 using 문의 안쪽 블록이 먼저 끝나는 것과 동일한 동작입니다.

쉽게 말해 "메서드 끝까지 살 리소스를 깔끔하게 선언하는 방법"입니다. 기술적으로는 using 문과 같은 try/finally IL로 컴파일되며, try 블록이 변수 선언 시점부터 스코프 끝까지 확장됩니다.

2.5 기본 패턴 코드

세 형태를 나란히 놓고 봅니다.

C#
using System;

public sealed class ClassResource : IDisposable
{
    public void Dispose() { }
}

public class Sample
{
    // (1) using 문 — 명시적 블록
    public void UsingStatement()
    {
        using (var r = new ClassResource())
        {
            Console.WriteLine("work");
        }
    }

    // (2) using 선언 — C# 8.0
    public void UsingDeclaration()
    {
        using var r = new ClassResource();
        Console.WriteLine("work");
    }
}

두 메서드가 만들어 내는 IL이 얼마나 닮았는지는 바로 다음 장에서 직접 비교합니다.


<a id="section-3"></a>

3. 내부 동작 — 컴파일러가 만드는 try·finally

3.1 why — 결정적 해제라는 약속

GC는 관리되지 않는 자원(파일 핸들, 소켓, GPU 버퍼)을 언제 해제할지 모릅니다. 파이널라이저(finalizer)가 있어도 실행 시점을 보장하지 못합니다. 그 사이 파일이 잠겨 있거나, VRAM이 점유되어 다음 씬이 로드되지 않습니다. using"이 시점에 반드시 Dispose 호출"을 컴파일러가 보장하도록 만듭니다.

3.2 using 문의 IL — 교과서적 try/finally

using (var r = new ClassResource()) { ... }

실제 컴파일러 출력을 확인합니다. 앞서 2.5의 UsingStatement 메서드를 ilspycmd로 뜯으면 다음과 같습니다.

IL
.method public hidebysig instance void UsingStatement () cil managed
{
    .locals init (
        [0] class ClassResource
    )

    IL_0001: newobj     instance void ClassResource::.ctor()       // 힙에 ClassResource 할당
    IL_0006: stloc.0                                                // 지역 변수 r 에 저장
    .try
    {
        IL_0008: ldstr      "work"
        IL_000d: call       void [System.Console]System.Console::WriteLine(string)
        IL_0014: leave.s    IL_0021                                 // try 정상 탈출
    }
    finally
    {
        IL_0016: ldloc.0                                            // r 로드
        IL_0017: brfalse.s  IL_0020                                 // null 이면 skip
        IL_0019: ldloc.0
        IL_001a: callvirt   instance void [System.Runtime]System.IDisposable::Dispose()  // Dispose 호출
        IL_0020: endfinally
    }
    IL_0021: ret
}

IL 분석 포인트

  1. .try { } finally { } 구조
  2. brfalse.s로 null 체크
  3. callvirt IDisposable::Dispose

3.3 using 선언의 IL — try 블록이 스코프 끝까지

2.5의 UsingDeclaration 메서드를 같은 방식으로 뜯으면 놀랄 만큼 비슷합니다.

IL
.method public hidebysig instance void UsingDeclaration () cil managed
{
    .locals init (
        [0] class ClassResource
    )

    IL_0001: newobj     instance void ClassResource::.ctor()
    IL_0006: stloc.0
    .try
    {
        IL_0007: ldstr      "work"
        IL_000c: call       void [System.Console]System.Console::WriteLine(string)
        IL_0012: leave.s    IL_001f
    }
    finally
    {
        IL_0014: ldloc.0
        IL_0015: brfalse.s  IL_001e
        IL_0017: ldloc.0
        IL_0018: callvirt   instance void [System.Runtime]System.IDisposable::Dispose()
        IL_001e: endfinally
    }
    IL_001f: ret
}

IL 분석 포인트

  1. IL이 사실상 동일
  2. try 시작 위치 차이
  3. 선언 순서 역순 Dispose

3.4 struct 리소스의 IL — 패턴 기반 dispose

Unity의 NativeArray<T>는 struct입니다. ClassResource 대신 StructResource를 쓰면 IL이 미묘하게 달라집니다.

C#
public struct StructResource : IDisposable
{
    public void Dispose() { }
}

public void UsingStruct()
{
    using var r = new StructResource();
    Console.WriteLine("work");
}

IL:

IL
.method public hidebysig instance void UsingStruct () cil managed
{
    .locals init (
        [0] valuetype StructResource                         // 값 타입이 스택에 올라감 (힙 할당 없음)
    )

    IL_0001: ldloca.s    0                                   // r 의 주소 로드
    IL_0003: initobj     StructResource                      // 스택에서 0으로 초기화 (newobj 아님)
    .try
    {
        IL_0009: ldstr      "work"
        IL_000e: call       void [System.Console]System.Console::WriteLine(string)
        IL_0014: leave.s    IL_0025
    }
    finally
    {
        IL_0016: ldloca.s    0                               // r 의 주소 로드
        IL_0018: constrained. StructResource                 // struct 전용 제약 호출
        IL_001e: callvirt   instance void [System.Runtime]System.IDisposable::Dispose()
        IL_0024: endfinally
    }
    IL_0025: ret
}

IL 분석 포인트

  1. newobj 대신 initobj + ldloca.s
  2. constrained. StructResource 프리픽스
  3. ldloca.s로 주소 전달

3.5 using 지시문은 IL에 남지 않는다

확인차 빈 파일에 using System;만 쓰고 컴파일해도 .dll에는 네임스페이스 선언이 없습니다. 지시문은 소스 파일 안의 이름 해석 규칙이지, 어셈블리 메타데이터가 아니기 때문입니다.

// 결론 — IL 출력에 using 지시문의 흔적은 0바이트

<a id="section-4"></a>

4. 실전 적용 — Unity 핫패스와 네이티브 리소스

4.1 판단 기준 — 언제 무엇을 쓰는가

상황 권장 문법 이유
네임스페이스 import using System; (지시문) 기본
전역적으로 자주 쓰는 네임스페이스 global using UnityEngine; 프로젝트 파일별 중복 제거
이름 충돌·제네릭 약칭 using Path = System.IO.Path; (alias) UnityEngine.Path와 구분
짧은 스코프에서만 리소스 사용 using (...) { } (문) 해제 시점을 눈으로 강조
메서드 전체에서 리소스 사용 using var ...; (선언) 중괄호 중첩 제거
비동기 dispose (DB 연결 등) await using var ...; flush·핸드셰이크 비동기 보장

4.2 Before / After — NativeArray 리소스 누수 제거

Unity Job System의 가장 흔한 실수 패턴입니다. 예외가 한 번만 터져도 Dispose()가 호출되지 않습니다.

C#
// ❌ Before — 예외 경로에서 누수 발생
void ScheduleJob()
{
    var positions = new NativeArray<float3>(1024, Allocator.TempJob);

    var job = new MyJob { Positions = positions };
    JobHandle handle = job.Schedule();
    handle.Complete();

    // Schedule 또는 Complete에서 예외가 터지면 아래 줄은 실행되지 않음
    positions.Dispose();
}
IL
; Before (발췌) — try/finally 없음
call      valuetype Unity.Collections.NativeArray`1<float3> Unity.Collections.NativeArray`1::.ctor(int32, valuetype Allocator)
stloc.0
; ... Schedule / Complete ...
ldloca.s  0
call      instance void Unity.Collections.NativeArray`1<float3>::Dispose()   // 예외 시 호출 안 됨
ret

해설: IL에 .try/finally가 없습니다. 중간에서 예외가 던져지면 실행 흐름이 메서드를 벗어나 버리므로 Dispose() 호출이 영영 실행되지 않습니다. Unity Editor는 "A Native Collection has not been disposed"를 경고하지만, 빌드 런타임은 조용히 네이티브 메모리가 누수됩니다.

C#
// ✅ After — using 선언으로 finally 보장
void ScheduleJob()
{
    using var positions = new NativeArray<float3>(1024, Allocator.TempJob);

    var job = new MyJob { Positions = positions };
    JobHandle handle = job.Schedule();
    handle.Complete();
} // scope 끝에서 positions.Dispose() 자동 호출
IL
; After (발췌) — try/finally 생성 + constrained 호출
.locals init ([0] valuetype Unity.Collections.NativeArray`1<float3>)

; ... NativeArray 생성 ...
.try
{
    ; Schedule / Complete
    leave.s IL_END
}
finally
{
    ldloca.s  0
    constrained. valuetype Unity.Collections.NativeArray`1<float3>        // struct 박싱 없음
    callvirt  instance void [System.Runtime]System.IDisposable::Dispose()
    endfinally
}

해설: 컴파일러가 .try/finally 블록을 자동 생성하고, constrained. 프리픽스로 박싱 없이 struct의 Dispose()를 호출합니다. 예외 경로·정상 경로 모두 finally가 실행되어 네이티브 메모리가 확실히 반납됩니다.

4.3 GraphicsBuffer / ComputeBuffer — VRAM을 누수하지 말 것

Compute Shader에 데이터를 넘기는 핫패스입니다. 모바일 GPU는 VRAM이 적기 때문에 누수가 치명적입니다.

C#
// ❌ Before — 예외 시 GPU 버퍼 누수, 몇 프레임 만에 OOM
public void DispatchParticles(ComputeShader shader, Vector4[] particles)
{
    var buffer = new GraphicsBuffer(GraphicsBuffer.Target.Structured,
                                    particles.Length, sizeof(float) * 4);

    buffer.SetData(particles);
    shader.SetBuffer(0, "_Particles", buffer);
    shader.Dispatch(0, particles.Length / 64, 1, 1);

    buffer.Dispose();  // 예외 시 건너뜀
}
C#
// ✅ After — using 선언, 중첩 리소스도 평탄하게
public void DispatchParticles(ComputeShader shader, Vector4[] particles)
{
    using var buffer = new GraphicsBuffer(GraphicsBuffer.Target.Structured,
                                          particles.Length, sizeof(float) * 4);
    using var args = new GraphicsBuffer(GraphicsBuffer.Target.IndirectArguments,
                                        1, sizeof(uint) * 5);

    buffer.SetData(particles);
    shader.SetBuffer(0, "_Particles", buffer);
    shader.Dispatch(0, particles.Length / 64, 1, 1);
}

해설: using 선언이 둘 있으면 컴파일러는 중첩 finally 블록을 생성합니다. args가 먼저 Dispose되고, 그다음 bufferDispose됩니다. IL은 3.3과 동일한 구조의 try/finally가 두 겹 쌓입니다.

4.4 await using — 비동기 해제

네트워크 스트림이나 DB 커넥션을 끊을 때 flush·ACK(Acknowledgement, TCP 확인 응답) 대기가 필요한 경우 IAsyncDisposable을 구현합니다. using만 쓰면 DisposeAsync의 Task가 fire-and-forget 되어 소켓이 비정상 종료될 수 있습니다.

await using — 비동기 리소스 해제 IAsyncDisposable을 구현한 객체를 사용할 때, 스코프 종료 시점에 DisposeAsync()를 await 합니다. 네트워크 flush 같은 I/O가 완료되기 전에 메서드가 반환되는 것을 막습니다.
예시: await using var conn = new SqlConnection(cs); 스코프 끝에서 await conn.DisposeAsync()가 실행됨
C#
// Unity Addressables/네트워크 다운로드 의사 코드
async Task FetchAsync()
{
    await using var stream = await httpClient.GetStreamAsync(url);
    var buffer = new byte[8192];
    int read;
    while ((read = await stream.ReadAsync(buffer)) > 0) { /* ... */ }
} // 여기서 await stream.DisposeAsync() — flush 완료까지 대기

Unity 관점

  • async Task를 쓰는 async state machine(비동기 상태 기계) 생성 비용(힙 할당)은 여전히 발생합니다. Update 루프에서 반복 호출은 피하고, 로딩·씬 전환 같은 경계에서 써야 합니다.
  • Unity의 UnityWebRequest는 IAsyncDisposable을 직접 구현하지 않지만, 래핑 라이브러리(UniTaskIUniTaskAsyncDisposable)에서 유사 패턴을 제공합니다.
  • Unity의 기본 GC는 Boehm GC(보수적 Mark-Sweep) 기반이어서 네이티브 메모리는 GC 대상이 아닙니다. IL2CPP 빌드에서도 using의 try/finally는 C++의 try/catch로 변환되어 동일하게 finally가 보장됩니다. 단, 예외 throw 자체의 비용이 Mono보다 크므로 예외 경로에 의존하지 않는 것이 좋습니다.

4.5 using 지시문 활용 — 프로젝트 일관성

C# 10의 global using은 Unity 2022 LTS + C# 9 지원 환경부터 사용 가능합니다. 아래와 같이 전용 파일을 하나 만들어 두면 모든 .cs에 같은 네임스페이스를 반복 import하지 않아도 됩니다.

C#
// GlobalUsings.cs
global using System;
global using System.Collections.Generic;
global using UnityEngine;
global using Unity.Mathematics;

Alias는 Unity의 이름 충돌 상황에서 특히 유용합니다.

C#
// UnityEngine.Debug 와 System.Diagnostics.Debug 둘 다 쓰고 싶을 때
using UDebug = UnityEngine.Debug;
using SDebug = System.Diagnostics.Debug;

<a id="section-5"></a>

5. 함정과 주의사항

5.1 using 선언의 스코프 오해

C#
// ❌ Wrong — DB 연결이 필요 이상으로 오래 살아 있음
async Task SaveAsync(UserData u)
{
    using var conn = new SqlConnection(cs);  // 메서드 시작 직후 열림
    await conn.OpenAsync();
    await InsertAsync(conn, u);

    // 이후 200ms 걸리는 후처리 — conn은 메서드 끝까지 열려 있음
    var log = CompressLog();
    await UploadToAnalyticsAsync(log);
}
C#
// ✅ Right — 연결이 필요한 범위만 명시적 using 문
async Task SaveAsync(UserData u)
{
    using (var conn = new SqlConnection(cs))
    {
        await conn.OpenAsync();
        await InsertAsync(conn, u);
    } // 여기서 conn 닫힘

    var log = CompressLog();
    await UploadToAnalyticsAsync(log);
}

IL 수준에서 차이: using var는 try 범위가 메서드 끝까지 확장되는 반면, using ( ) { } 는 명시적 블록까지만 try가 열립니다. 연결 풀 고갈이 걱정되면 필요한 구간만 블록으로 감싸는 using 문을 써야 합니다.

5.2 struct 리소스와 C# 7.3 이하 — 박싱 지옥

옛날 Unity 프로젝트(C# 7.3 고정)에서는 struct의 IDisposable이 매 using마다 박싱되었습니다.

C#
// C# 7.3 이하에서 using (NativeArray<float>...) 를 쓰면
// IL에 box 명령이 등장 → 매 프레임 힙 할당

IL 레벨의 문제점: callvirt IDisposable::Dispose에 struct를 직접 넘길 수 없어 box 명령으로 힙 객체를 만들어 호출했습니다. Update 루프에서 반복되면 GC 스파이크의 직접적인 원인이 됩니다.

해결: Unity 2020.1 이상 (C# 8 지원). 자동으로 constrained. callvirt 패턴이 생성되어 박싱이 사라집니다.

5.3 using 문 안에서 변수 재할당

C#
// ❌ Wrong — 할당된 새 객체는 Dispose되지 않음
using (var s = new FileStream("a.dat", FileMode.Open))
{
    s = new FileStream("b.dat", FileMode.Open); // 컴파일 에러: using 변수는 readonly
}

C# 8부터 using 선언/문의 변수는 내부적으로 readonly입니다. IL 수준에서는 finally 블록이 원래 할당된 지역 변수만 참조하기 때문에, 재할당을 허용하면 첫 번째 객체가 영영 누수됩니다. 컴파일러가 이를 원천 차단합니다.

C#
// ✅ Right — 별도 using 선언으로 교체
using (var s1 = new FileStream("a.dat", FileMode.Open)) { /* ... */ }
using (var s2 = new FileStream("b.dat", FileMode.Open)) { /* ... */ }

5.4 using 선언과 조건부 해제

C#
// ❌ Wrong — if 바깥에서 선언되면 조건과 무관하게 항상 Dispose 시도
NativeArray<int> arr = default;
if (needBuffer)
    arr = new NativeArray<int>(1024, Allocator.TempJob);

using (arr) // arr가 default(NativeArray)면 IsCreated=false → Dispose에서 오작동 가능
{
    // ...
}
C#
// ✅ Right — 조건 내부에서만 using
if (needBuffer)
{
    using var arr = new NativeArray<int>(1024, Allocator.TempJob);
    ProcessJob(arr);
}

NativeArray의 경우 default(NativeArray<T>).Dispose()는 Unity 버전에 따라 경고·예외를 낼 수 있어, 필요한 분기에서만 할당·해제하는 편이 안전합니다.

5.5 using static 남용

C#
// ❌ Wrong — Math, MathF, Mathf 섞어서 뭐가 뭔지 모호
using static System.Math;
using static UnityEngine.Mathf;

void Foo() { var r = Sqrt(2) + Sin(x); } // 어느 Sqrt인지 코드에서 안 보임
C#
// ✅ Right — 클래스명 유지
void Foo() { var r = Mathf.Sqrt(2) + Mathf.Sin(x); }

using static은 IL에 아무 영향이 없지만, 코드 리뷰에서 심볼 출처를 파악하기 어려워집니다. Unity에서는 Mathf/Math/MathF 셋이 공존하므로 특히 위험합니다.


<a id="section-6"></a>

6. C# 버전별 변화

6.1 개괄

C# 버전 변화 Unity 지원 시점
1.0 using 지시문, using 문 초기부터
2.0 alias의 제네릭 지원 초기부터
6.0 using static Unity 2017.1+
7.3 (기존) struct Dispose 시 박싱 발생 Unity 2018.3
8.0 using 선언, await using, IAsyncDisposable, 패턴 기반 dispose (constrained.) Unity 2020.1+
10.0 global using, file-scoped namespace Unity 2022.2+ (C# 9 지원 후 부분)
12.0 alias any type (튜플, 배열, 포인터) 최신 SDK / .NET 8 런타임

6.2 Before C# 8 / After C# 8 — struct 박싱 제거

C#
// C# 7.3 이하에서의 동작 (개념적 IL 재현)
public void UsingStruct_Old()
{
    StructResource r = new StructResource();
    try { Console.WriteLine("work"); }
    finally
    {
        // ❌ struct → interface 박싱 발생
        // box StructResource → callvirt IDisposable::Dispose
    }
}
IL
; Before (C# 7.3 발췌)
ldloc.0
box        StructResource                              ; 힙에 복사본 생성 — GC 부담
callvirt   instance void [System.Runtime]System.IDisposable::Dispose()
C#
// C# 8.0 이상 — 동일한 C# 코드가 박싱 없이 컴파일됨
public void UsingStruct_New()
{
    using var r = new StructResource();
    Console.WriteLine("work");
}
IL
; After (C# 8.0+ 실제 IL)
ldloca.s    0
constrained. StructResource                           ; 박싱 없이 struct 메서드 직접 호출
callvirt   instance void [System.Runtime]System.IDisposable::Dispose()

해설: C# 8부터 컴파일러는 "이 타입이 IDisposable을 구현하거나 Dispose() 메서드를 갖고 있기만 하면" 박싱 없이 호출합니다. Unity NativeArray<T>·NativeList<T>·JobHandle 같은 struct 리소스를 Update에서 반복 사용해도 GC 압박이 없게 된 결정적인 변화입니다.

6.3 Before C# 8 / After C# 8 — using 선언 등장

C#
// Before (C# 7.3) — 중첩 블록
public void Compress()
{
    using (var src = File.OpenRead("in.dat"))
    {
        using (var dst = File.Create("out.dat"))
        {
            src.CopyTo(dst);
        }
    }
}
C#
// After (C# 8.0) — 평탄한 선언
public void Compress()
{
    using var src = File.OpenRead("in.dat");
    using var dst = File.Create("out.dat");
    src.CopyTo(dst);
}

IL 레벨에서는 try/finally가 두 겹으로 중첩되는 구조가 똑같이 생성됩니다. 바뀌는 것은 소스 가독성과 들여쓰기 뎁스뿐입니다. 컴파일된 바이너리 크기도 동일합니다.

6.4 C# 10 — global using

C#
// GlobalUsings.cs (프로젝트에 한 번만 작성)
global using System;
global using System.Collections.Generic;
global using UnityEngine;

SDK 스타일 csproj에서 <ImplicitUsings>enable</ImplicitUsings>를 켜면 System, System.Linq 등이 자동 포함됩니다. Unity는 asmdef 기반이라 자동 포함 기능은 제한적이지만, global using은 직접 작성 가능합니다.


<a id="section-7"></a>

7. 정리

  • 같은 키워드 using세 가지 완전히 다른 문법에 쓰인다. 등장 위치와 세미콜론 유무로 구분한다.
  • 지시문(using System;) 은 컴파일 타임에만 작용한다. IL 출력에 남지 않는다.
  • 문(using ( ) { }) 은 블록 끝에서 Dispose를 호출한다. 컴파일러가 try/finally로 변환한다.
  • 선언(using var ...;) 은 C# 8에서 도입. 스코프 끝에서 Dispose. IL 구조는 using 문과 동일하다.
  • C# 8부터 struct 리소스는 박싱 없이 constrained. callvirt 로 호출된다. Unity Update 루프에서 NativeArray·GraphicsBuffer를 using으로 다뤄도 GC 부담이 없다.
  • IAsyncDisposable + await using은 네트워크·DB 등 비동기 해제가 필요한 경우에만 쓴다.
  • Unity 실전 규칙: 네이티브 메모리를 쓰는 모든 자원은 using 선언 또는 using 문으로 감싼다. 예외 경로에서도 finally가 보장되어 Editor/Build 양쪽의 누수를 막는다.
  • 리소스 수명을 짧게 유지해야 하면 using var 대신 명시적 using ( ) { } 블록을 쓴다. 메서드 전체에서 쓰이면 using var로 중첩을 제거한다.
  • using static은 IL엔 영향 없지만, 코드 리뷰 가독성을 해칠 수 있으므로 Math / Mathf 등 혼동 가능 타입에서는 피한다.
반응형

+ Recent posts