Unity 2018.3 버전 이후 사용할 수 있는 C# 스크립팅 소개 글에서 C# 7.3 까지 정리했었습니다. 그 뒤로 유니티는 2020.2 에서 C# 8, 2021.2 에서 C# 9 를 지원하기 시작했고, Unity 6 까지 C# 9 와 .NET Standard 2.1 을 유지하고 있습니다.

2026년에는 유니티 스크립팅 런타임이 Mono 에서 CoreCLR 로 바뀝니다. 하지만 Path to CoreCLR, 2026: Upgrade Guide 기준으로 언어 버전은 여전히 C# 9 / .NET Standard 2.1 입니다. 그래서 지금 시점에 유니티에서 쓸 수 있는 최신 문법은 C# 8, 9 이고, 이 글에서는 이 두 버전의 문법을 정리합니다. CoreCLR 전환에 따른 마이그레이션은 Unity CoreCLR 전환 대비 마이그레이션 체크리스트 에서 다룹니다.

구분 목차
1 Unity 버전별 C# 지원 1.1 사용할 수 없는 기능
2 C#8.0 2.1 Readonly members
2.2 Default interface methods
2.3 Switch expressions
2.4 Property patterns
2.5 Tuple patterns
2.6 Positional patterns
2.7 Using declarations
2.8 Static local functions
2.9 Disposable ref structs
2.10 Nullable reference types
2.11 Asynchronous streams
2.12 Asynchronous disposable
2.13 Indices and ranges
2.14 Null-coalescing assignment
2.15 Unmanaged constructed types
2.16 Stackalloc in nested expressions
2.17 Enhancement of interpolated verbatim strings
3 C#9.0 3.1 Records
3.2 Init only setters
3.3 Top-level statements
3.4 Pattern matching enhancements
3.5 Native sized integers
3.6 Function pointers
3.7 Suppress emitting localsinit flag
3.8 Target-typed new expressions
3.9 Static anonymous functions
3.10 Target-typed conditional expressions
3.11 Covariant return types
3.12 Extension GetEnumerator support for foreach loops
3.13 Lambda discard parameters
3.14 Attributes on local functions
3.15 Module initializers
3.16 New features for partial methods

Unity 버전별 C# 지원

Unity 버전 C# 버전 API 호환 수준 비고
2017.1 C# 6 .NET 4.x (실험적) Roslyn 컴파일러 도입
2018.3 C# 7.3 .NET Standard 2.0 / .NET 4.x 이전 글
2020.2 ~ 2021.1 C# 8.0 .NET Standard 2.0 / .NET 4.x 일부 C# 8 기능 미지원
2021.2 ~ Unity 6 C# 9.0 .NET Standard 2.1 / .NET Framework 4.8 C# 8 전체 지원, 일부 C# 9 기능 미지원
Unity 6.8 / 7.0 (CoreCLR) C# 9.0 .NET Standard 2.1 런타임만 CoreCLR 로 교체, 7.0 에서 .NET 10 기반 BCL

2021.2 에서 유니티에 포함된 Mono 가 업그레이드되고 .NET Standard 2.1 API 가 추가되면서, 그 전까지 막혀 있던 C# 8 기능들도 모두 사용 가능해졌습니다.

사용할 수 없는 기능

아래 기능은 사용하면 컴파일 에러가 납니다.

  • 2020.2 ~ 2021.1 (C# 8) : Default interface methods, Indices and ranges, Asynchronous streams, Asynchronous disposable. 런타임이나 .NET Standard 2.1 의 타입이 필요한 기능들입니다.
  • 2021.2 이후 (C# 9) : Init only setters, Covariant return types, Module initializers, Suppress emitting localsinit flag, 함수 포인터의 확장 호출 규약(extensible calling conventions).

Records 와 Init only setters 는 IsExternalInit 타입을 직접 선언하면 사용 할 수 있습니다. 자세한 내용은 Records 에서 다룹니다.


C#8.0

Readonly members

구조체의 메서드나 프로퍼티에 readonly 를 붙여 구조체의 상태를 바꾸지 않는다고 표시 할 수 있습니다. 컴파일러가 수정 여부를 체크해 주고, in 파라미터나 readonly 필드에서 호출할 때 방어적 복사를 줄여 줍니다.

public struct Stat
{
    public int Base;
    public int Bonus;

    // 상태를 바꾸지 않는 멤버
    public readonly int Total => Base + Bonus;

    public readonly override string ToString() => $"{Total} ({Base} + {Bonus})";

    public void AddBonus(int value) => Bonus += value;

    // public readonly void Reset() => Bonus = 0; // 컴파일 에러
}

Default interface methods

인터페이스에 메서드의 기본 구현을 넣을 수 있습니다. 이미 여러 클래스가 구현한 인터페이스에 새 메서드를 추가해도, 기존 클래스를 고치지 않아도 됩니다. 2021.2 부터 사용 가능합니다.

public interface IDamageable
{
    int Health { get; }
    void TakeDamage(int amount);

    // 기본 구현
    bool IsDead => Health <= 0;
    void Kill() => TakeDamage(Health);
}

public class Enemy : MonoBehaviour, IDamageable
{
    public int Health { get; private set; } = 100;
    public void TakeDamage(int amount) => Health -= amount;
}

IDamageable target = enemy;
target.Kill();             // 인터페이스 타입으로만 호출
Debug.Log(target.IsDead);  // true
// enemy.Kill();           // 컴파일 에러 : 클래스에 Kill 없음

Switch expressions

switch 를 문(statement)이 아닌 식(expression)으로 쓸 수 있습니다. case, break 없이 값을 바로 리턴해서 훨씬 짧아집니다.

public enum Rarity { Common, Rare, Epic, Legendary }

// C#7.3
Color GetColor(Rarity rarity)
{
    switch (rarity)
    {
        case Rarity.Common: return Color.white;
        case Rarity.Rare: return Color.blue;
        case Rarity.Epic: return Color.magenta;
        default: return Color.yellow;
    }
}

// C#8.0
Color GetColor(Rarity rarity) => rarity switch
{
    Rarity.Common => Color.white,
    Rarity.Rare => Color.blue,
    Rarity.Epic => Color.magenta,
    _ => Color.yellow,
};

_ 는 default 와 같습니다. 모든 경우를 처리하지 않으면 컴파일러가 경고를 줍니다.

Property patterns

객체의 프로퍼티 값으로 패턴 매칭을 할 수 있습니다. { } 는 “null 이 아님” 을 뜻합니다.

string Describe(Collider other) => other switch
{
    { isTrigger: true } => "트리거",
    { attachedRigidbody: { isKinematic: true } } => "키네마틱 바디",
    { attachedRigidbody: { } } => "리지드바디",
    _ => "정적 콜라이더",
};

유니티 주의사항 : 패턴의 null 검사({ }, null)는 유니티가 재정의한 == null 을 거치지 않습니다. Destroy 된 오브젝트도 null 이 아닌 것으로 판단하므로, 파괴됐을 수 있는 오브젝트는 != null 로 먼저 체크하세요.

Tuple patterns

여러 값을 튜플로 묶어서 한 번에 매칭 할 수 있습니다. 두 개 이상의 상태 조합을 다룰 때 유용합니다.

public enum Hand { Rock, Paper, Scissors }

string Play(Hand me, Hand other) => (me, other) switch
{
    (Hand.Rock, Hand.Scissors) => "Win",
    (Hand.Paper, Hand.Rock) => "Win",
    (Hand.Scissors, Hand.Paper) => "Win",
    _ when me == other => "Draw",
    _ => "Lose",
};

Positional patterns

Deconstruct 메서드가 있는 타입은 위치(순서)로 분해해서 매칭 할 수 있습니다. 확장 메서드로 만든 Deconstruct 도 사용 할 수 있습니다.

public static class VectorExtensions
{
    public static void Deconstruct(this Vector2 v, out float x, out float y)
    {
        x = v.x;
        y = v.y;
    }
}

string GetDirection(Vector2 input) => input switch
{
    (0, 0) => "Idle",
    (0, > 0) => "Up",
    (0, < 0) => "Down",
    (> 0, _) => "Right",
    _ => "Left",
};

> 0 같은 관계 패턴은 C# 9 문법입니다. 위 예제는 2021.2 이상에서 컴파일됩니다.

Using declarations

using 뒤에 블록을 쓰지 않고 변수 선언으로 쓸 수 있습니다. 변수가 속한 스코프가 끝날 때 Dispose 가 호출되어 중첩이 줄어듭니다.

// C#7.3
void Save(string path, string json)
{
    using (var stream = File.OpenWrite(path))
    {
        using (var writer = new StreamWriter(stream))
        {
            writer.Write(json);
        }
    }
}

// C#8.0
void Save(string path, string json)
{
    using var stream = File.OpenWrite(path);
    using var writer = new StreamWriter(stream);
    writer.Write(json);
} // writer, stream 순서로 Dispose

Static local functions

로컬 함수에 static 을 붙이면 바깥 변수를 캡처 할 수 없습니다. 실수로 캡처해서 생기는 할당이나 버그를 컴파일 단계에서 막아 줍니다.

int CalculateScore(int kills, int deaths)
{
    int bonus = 100;
    return Score(kills, deaths);

    static int Score(int k, int d) => k * 10 - d * 5;
    // static int Score2(int k) => k * bonus; // 컴파일 에러 : bonus 캡처 불가
}

Disposable ref structs

ref struct 는 인터페이스를 구현 할 수 없어서 IDisposable 을 붙일 수 없었습니다. 이제 public void Dispose() 메서드만 있으면 using 에 쓸 수 있습니다.

public ref struct ProfilerScope
{
    public ProfilerScope(string name) => UnityEngine.Profiling.Profiler.BeginSample(name);
    public void Dispose() => UnityEngine.Profiling.Profiler.EndSample();
}

void Update()
{
    using var scope = new ProfilerScope("Enemy.Update");
    // 측정할 코드
}

Nullable reference types

참조 타입에도 null 이 될 수 있는지(string?) 없는지(string)를 표시하고, 컴파일러가 null 위험을 경고해 줍니다. 기본으로 꺼져 있고, 파일 맨 위에 #nullable enable 을 쓰거나 csc.rsp 에 -nullable:enable 을 넣어 켤 수 있습니다.

#nullable enable
public class Inventory
{
    private readonly Dictionary<string, Item> items = new Dictionary<string, Item>();

    public Item? Find(string id) => items.TryGetValue(id, out var item) ? item : null;

    public void Use(string id)
    {
        var item = Find(id);
        // item.Use();      // 경고 : null 가능
        if (item != null)
            item.Use();     // OK
    }
}

유니티 주의사항 : 유니티 API 는 nullable 주석이 없고, [SerializeField] 필드는 인스펙터에서 할당되기 때문에 컴파일러가 알 수 없어 경고가 많이 납니다. 또 Destroy 된 오브젝트는 컴파일러 입장에서 null 이 아닙니다. 유니티 오브젝트보다는 순수 C# 로직 코드에 적용하는 것을 추천합니다.

Asynchronous streams

IAsyncEnumerable<T> 와 await foreach 로 값을 비동기로 하나씩 만들고 소비 할 수 있습니다. async 메서드 안에서 yield return 을 쓸 수 있습니다. 2021.2 부터 사용 가능합니다.

async IAsyncEnumerable<int> CountdownAsync(int from)
{
    for (int i = from; i > 0; i--)
    {
        await Task.Delay(1000);
        yield return i;
    }
}

async void Start()
{
    await foreach (var count in CountdownAsync(3))
        Debug.Log(count); // 3, 2, 1 (1초 간격)
    Debug.Log("Start!");
}

Asynchronous disposable

IAsyncDisposable 과 await using 으로 비동기 정리 작업을 할 수 있습니다. 파일이나 네트워크 연결을 닫을 때 기다려야 하는 경우에 씁니다. 2021.2 부터 사용 가능합니다.

public class SaveSession : IAsyncDisposable
{
    public async ValueTask DisposeAsync()
    {
        await FlushToDiskAsync(); // 남은 데이터 저장
    }
}

async Task SaveAsync()
{
    await using var session = new SaveSession();
    // 저장 작업
} // DisposeAsync 대기

Indices and ranges

^ 로 뒤에서부터 인덱스를 지정하고, .. 로 범위를 잘라 낼 수 있습니다. 2021.2 부터 사용 가능합니다.

int[] scores = { 10, 20, 30, 40, 50 };

Debug.Log(scores[^1]);        // 50 (마지막)
Debug.Log(scores[^2]);        // 40

int[] top3 = scores[..3];     // 10, 20, 30
int[] middle = scores[1..^1]; // 20, 30, 40

string fileName = "player_save.json";
string ext = fileName[^4..];  // "json"

배열과 string 은 범위를 지원하지만, List<T> 는 ^1 같은 인덱스만 되고 [1..3] 같은 범위는 컴파일 에러입니다. 배열 범위는 새 배열을 복사해서 만든다는 점도 기억하세요.

Null-coalescing assignment

??= 는 변수가 null 일 때만 값을 할당합니다.

private List<Enemy> enemies;

public List<Enemy> Enemies => enemies ??= new List<Enemy>();

유니티 주의사항 : ??= 도 유니티의 == null 재정의를 거치지 않습니다. 아래 코드는 캐시된 컴포넌트가 Destroy 된 뒤에도 다시 찾지 않습니다. 유니티 오브젝트는 if (body == null) 로 체크하세요.

private Rigidbody body;

// 위험 : Destroy 후에도 재조회 안 함
public Rigidbody Body => body ??= GetComponent<Rigidbody>();

Unmanaged constructed types

C# 7.3 의 unmanaged 제약에 제네릭 구조체도 들어갈 수 있습니다. 필드가 모두 unmanaged 라면 Pair<int> 같은 생성된 제네릭 타입도 unmanaged 로 취급됩니다. NativeArray<T> 같은 네이티브 컨테이너에 제네릭 구조체를 넣을 때 유용합니다.

public struct Pair<T> where T : unmanaged
{
    public T First;
    public T Second;
}

public static unsafe int SizeOf<T>() where T : unmanaged => sizeof(T);

Debug.Log(SizeOf<Pair<int>>());      // 8
Debug.Log(SizeOf<Pair<Vector3>>());  // 24

Stackalloc in nested expressions

stackalloc 의 결과가 Span<T> 이면 다른 식 안에서 바로 쓸 수 있습니다. 2021.2 부터는 .NET Standard 2.1 에 Span<T> 가 포함되어 unsafe 없이 사용 가능합니다.

Span<int> numbers = stackalloc[] { 1, 2, 3, 4, 5, 6 };

// 인자 안에서 stackalloc
int index = numbers.IndexOfAny(stackalloc[] { 4, 6 });
Debug.Log(index); // 3

Enhancement of interpolated verbatim strings

보간 문자열과 축자 문자열을 같이 쓸 때 $@ 순서만 허용되던 것이 @$ 도 허용됩니다.

string path = $@"C:\Saves\{slot}.json"; // C#7.3 부터 가능
string path2 = @$"C:\Saves\{slot}.json"; // C#8.0 부터 가능

C#9.0

Records

record 는 값 기반의 동등성(equality)을 가진 불변 데이터 타입입니다. 생성자, 프로퍼티, Equals, GetHashCode, ToString, 분해(Deconstruct)를 컴파일러가 만들어 줍니다. with 식으로 일부 값만 바꾼 복사본을 만들 수 있습니다.

public record ItemData(string Id, string Name, int Price);

var potion = new ItemData("potion", "Potion", 100);
var sale = potion with { Price = 50 };  // Price 만 바꾼 복사본

Debug.Log(potion == new ItemData("potion", "Potion", 100)); // true (값 비교)
Debug.Log(sale); // ItemData { Id = potion, Name = Potion, Price = 50 }

유니티에서 쓰려면 : 레코드는 내부적으로 init 전용 setter 를 쓰는데, 여기에 필요한 IsExternalInit 타입이 .NET 5 이후에만 있어서 그대로 쓰면 컴파일 에러가 납니다. 프로젝트에 아래 코드를 추가하면 됩니다.

namespace System.Runtime.CompilerServices
{
    internal static class IsExternalInit { }
}

레코드는 유니티 직렬화를 지원하지 않습니다. 인스펙터에 보여야 하는 데이터가 아니라, 이벤트 메시지나 서버 응답 같은 런타임 데이터에 쓰세요.

Init only setters

set 대신 init 을 쓰면 객체 초기화 시점에만 값을 넣을 수 있고, 그 뒤로는 읽기 전용이 됩니다. 유니티 문서에는 미지원으로 되어 있지만, Records 와 같은 IsExternalInit 선언을 추가하면 사용 할 수 있습니다.

public class SpawnRequest
{
    public string PrefabId { get; init; }
    public Vector3 Position { get; init; }
}

var request = new SpawnRequest { PrefabId = "slime", Position = Vector3.zero };
// request.PrefabId = "bat"; // 컴파일 에러 : 초기화 이후 수정 불가

Top-level statements

Main 메서드와 클래스 선언 없이 파일 맨 위에 바로 코드를 쓸 수 있습니다. 유니티 스크립트에는 진입점이 없으므로 쓸 일이 없지만, 유니티와 별도로 도는 콘솔 툴을 만들 때 편합니다.

// Program.cs 전체
using System;

Console.WriteLine("Asset converter start");
foreach (var file in args)
    Console.WriteLine(file);

Pattern matching enhancements

패턴에 관계 연산자(<, >, <=, >=)와 논리 연산자(and, or, not)를 쓸 수 있습니다. 괄호로 묶을 수도 있습니다.

string GetGrade(int damage) => damage switch
{
    < 0 => throw new ArgumentOutOfRangeException(nameof(damage)),
    0 => "Miss",
    > 0 and < 50 => "Normal",
    >= 50 and < 100 => "Strong",
    _ => "Critical",
};

bool IsVowel(char c) => c is 'a' or 'e' or 'i' or 'o' or 'u';

if (item is not null)
    item.Use();

유니티 주의사항 : is not null 과 is null 도 유니티의 == null 재정의를 거치지 않습니다. 유니티 오브젝트에는 != null 을 쓰세요.

Native sized integers

nint, nuint 는 플랫폼에 따라 32비트 또는 64비트가 되는 정수 타입입니다. 네이티브 플러그인과 포인터 크기 값을 주고받을 때 씁니다.

nint size = IntPtr.Size;     // 64비트 플랫폼에서 8
nint offset = 16;
nint next = size + offset;   // IntPtr 과 달리 산술 연산 가능

Function pointers

delegate* 로 함수 포인터를 선언하고 호출 할 수 있습니다. 델리게이트 객체를 만들지 않아 할당과 호출 비용이 적습니다. unsafe 코드가 필요하고, unmanaged[Cdecl] 같은 확장 호출 규약은 지원하지 않습니다.

public static unsafe class MathOps
{
    static int Add(int a, int b) => a + b;

    public static void Run()
    {
        delegate*<int, int, int> op = &Add; // static 메서드만 가능
        Debug.Log(op(1, 2)); // 3
    }
}

Suppress emitting localsinit flag

[SkipLocalsInit] 로 지역 변수를 0으로 초기화하는 작업을 생략해 성능을 높이는 기능입니다. 유니티에서는 지원하지 않습니다.

[System.Runtime.CompilerServices.SkipLocalsInit] // 유니티에서는 컴파일 에러
static void Fill() { /* ... */ }

Target-typed new expressions

타입을 알 수 있으면 new 뒤의 타입 이름을 생략 할 수 있습니다. 긴 제네릭 타입 선언이 훨씬 짧아집니다.

// C#8.0
private Dictionary<string, List<Enemy>> enemiesByZone = new Dictionary<string, List<Enemy>>();
Vector3 offset = new Vector3(0, 1, 0);

// C#9.0
private Dictionary<string, List<Enemy>> enemiesByZone = new();
Vector3 offset = new(0, 1, 0);

Static anonymous functions

람다에 static 을 붙이면 바깥 변수를 캡처 할 수 없습니다. 캡처가 없는 람다는 델리게이트를 캐시해서 재사용하므로, Update 처럼 자주 불리는 곳에서 할당을 막는 데 유용합니다.

int threshold = 10;

var weak = enemies.Where(static e => e.Health < 10);
// var weak2 = enemies.Where(static e => e.Health < threshold); // 컴파일 에러 : 캡처 불가

Target-typed conditional expressions

조건 연산자 ?: 의 두 결과 타입이 달라도, 받는 쪽 타입으로 변환 할 수 있으면 컴파일됩니다.

// C#8.0 : (int?)null 캐스팅 필요
int? bonus = isCritical ? 100 : null;

IEnumerable<int> values = useDefault ? new List<int> { 1, 2 } : new int[0];

Covariant return types

상속받은 메서드를 override 할 때 더 구체적인 타입을 리턴 할 수 있는 기능입니다. 유니티에서는 지원하지 않습니다.

public abstract class Weapon
{
    public abstract Weapon Clone();
}

public class Sword : Weapon
{
    public override Sword Clone() => new Sword(); // 유니티에서는 컴파일 에러
}

Extension GetEnumerator support for foreach loops

foreach 가 확장 메서드로 만든 GetEnumerator 도 인식합니다. 직접 수정 할 수 없는 타입도 foreach 로 돌 수 있게 만들 수 있습니다.

public static class RangeExtensions
{
    public static IEnumerator<int> GetEnumerator(this Range range)
    {
        for (int i = range.Start.Value; i < range.End.Value; i++)
            yield return i;
    }
}

foreach (var i in 0..5)
    Debug.Log(i); // 0, 1, 2, 3, 4

Lambda discard parameters

쓰지 않는 람다 파라미터를 _ 로 버릴 수 있습니다. 이벤트 핸들러에서 파라미터가 필요 없을 때 깔끔합니다.

// C#8.0
SceneManager.sceneLoaded += (scene, mode) => Debug.Log("Loaded");

// C#9.0
SceneManager.sceneLoaded += (_, _) => Debug.Log("Loaded");

Attributes on local functions

로컬 함수에도 어트리뷰트를 붙일 수 있습니다.

float Evaluate(float t)
{
    return Ease(t) * 2f;

    [MethodImpl(MethodImplOptions.AggressiveInlining)]
    static float Ease(float x) => x * x * (3f - 2f * x);
}

Module initializers

[ModuleInitializer] 로 어셈블리가 로드될 때 한 번 실행되는 메서드를 만드는 기능입니다. 유니티에서는 지원하지 않습니다. 대신 유니티의 초기화 어트리뷰트를 쓰세요.

// 유니티에서는 컴파일 에러
[System.Runtime.CompilerServices.ModuleInitializer]
internal static void Init() { }

// 유니티 대안
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)]
static void InitRuntime() { }

#if UNITY_EDITOR
[UnityEditor.InitializeOnLoadMethod]
static void InitEditor() { }
#endif

New features for partial methods

이전에는 partial 메서드가 void 리턴만 가능했고 접근 한정자를 쓸 수 없었습니다. 이제 리턴값, out 파라미터, 접근 한정자를 쓸 수 있습니다. 대신 이런 partial 메서드는 반드시 구현해야 합니다. 소스 제너레이터가 만든 코드와 직접 쓴 코드를 나눌 때 주로 쓰입니다.

// PlayerStats.cs
public partial class PlayerStats
{
    public partial int GetMaxHealth();
}

// PlayerStats.Generated.cs (생성된 코드)
public partial class PlayerStats
{
    public partial int GetMaxHealth() => 100;
}

참고사이트