반응형

[PART7.클래스와 객체 입문(10/21)] Target-typed new — 왼쪽 타입을 반복하지 않는 생성 (C# 9)

좌변·인자·반환의 "문맥"으로 타입을 추론한다 / var는 변수 타입을 생략, new()는 생성 타입을 생략 / IL은 일반 new T()와 완전히 동일


1. [문제 제기] — 같은 타입을 두 번 쓰는 게 정상인가

Unity에서 데이터 테이블이나 풀(Pool)을 짜다 보면 이런 코드를 한 줄에 욱여넣게 됩니다.

C#
Dictionary<string, List<EnemySpawnInfo>> waveTable = new Dictionary<string, List<EnemySpawnInfo>>();

왼쪽에 한 번 쓴 Dictionary<string, List<EnemySpawnInfo>>를 오른쪽에 똑같이 한 번 더 씁니다. 타이핑이 두 배 늘어나고, 줄이 길어져 한 화면에 들어오지 않습니다. 더 큰 문제는 리팩토링입니다. EnemySpawnInfo를 다른 타입으로 바꾸려면 좌변 한 곳만 바꿔도 될 것 같은데 우변까지 같이 고쳐야 합니다. IDE가 자동으로 잡아주긴 하지만 코드를 읽는 사람 머릿속에서는 같은 정보가 두 번 처리됩니다.

해결책으로 var를 떠올리는 분이 많습니다.

C#
var waveTable = new Dictionary<string, List<EnemySpawnInfo>>();

지역 변수에서는 var로 좌변을 줄일 수 있습니다. 그런데 필드·프로퍼티에서는 var를 쓸 수 없습니다.

C#
public class WaveManager : MonoBehaviour
{
    // ❌ 컴파일 에러 — 필드는 명시적 타입이 필수
    var waveTable = new Dictionary<string, List<EnemySpawnInfo>>();

    // 어쩔 수 없이 타입을 두 번 적는다
    Dictionary<string, List<EnemySpawnInfo>> waveTable = new Dictionary<string, List<EnemySpawnInfo>>();
}

여기에 var로는 풀 수 없는 또 다른 케이스가 있습니다 — 메서드 인자, 반환문, 컬렉션 초기화 안의 요소. 이런 곳에서는 좌변이 이미 명확하지만 우변에서 타입을 또 적어야 합니다.

C# 9 가 도입한 Target-typed new 는 정확히 이 문제를 해결합니다. 좌변(또는 매개변수·반환·요소 타입)이 이미 알려져 있으면, 우변의 new 뒤에서 타입 이름을 생략할 수 있습니다.

C#
Dictionary<string, List<EnemySpawnInfo>> waveTable = new();

이 글에서는 컴파일러가 어떻게 타입을 추론하는지, 어디서 쓸 수 있고 어디서 못 쓰는지, IL 레벨에서는 어떻게 보이는지, 그리고 Unity 신입이 자주 만나는 함정을 차례로 봅니다.


2. [개념 정의] — 문맥이 타입을 알려준다

비유 — "그거 두 잔 주세요"

카페에서 친구가 카푸치노를 시킨 직후 내가 "그거 두 잔 주세요" 라고 하면 직원은 카푸치노 두 잔을 내옵니다. 내가 카푸치노라는 단어를 입에 올리지 않았는데도 메뉴가 결정됩니다. 앞의 문맥이 "그거"를 카푸치노로 해석시켰기 때문입니다.

Target-typed new 도 같은 원리입니다. new() 자체에는 어떤 타입을 만들지 적혀 있지 않습니다. 주변의 문맥(좌변 변수, 매개변수, 반환 타입 등)이 타입을 알려주면 컴파일러가 그 타입의 생성자를 호출하는 코드로 변환합니다.

구조 — 추론의 방향

var vs Target-typed new — 추론 방향이 반대

핵심: var는 우변에서 좌변을 추론하고, new()는 좌변에서 우변을 추론합니다. 추론의 방향이 정반대라서 둘을 섞으면(var x = new();) 컴파일러가 타입을 결정할 단서를 어디에서도 찾지 못합니다.

new() — Target-typed new 표현식 (C# 9) 객체 생성 시 new 키워드 뒤의 타입 이름을 생략하고, 좌변 변수·매개변수·반환 타입 등 대상(target) 타입으로부터 컴파일러가 자동 추론하게 한다. 인자가 있으면 new("A", 10), 없으면 new() 형태로 쓴다.
예시: Player p = new("A", 10); 좌변이 Player이므로 new Player("A", 10)과 동일하게 컴파일된다.

기본 코드 — new T()new() 비교

Unity에서 이런 상황이 생긴다. 적 캐릭터(Player) 한 명을 만들고 적 리스트와 길드 테이블을 같이 초기화한다.

C#
public class Player
{
    public string Name { get; }
    public int Hp { get; }
    public Player(string name, int hp) { Name = name; Hp = hp; }
}

public class Demo
{
    // 기존 방식 — 타입을 두 번 적는다
    public static void Old()
    {
        Player p = new Player("A", 10);
        List<Player> list = new List<Player>();
        list.Add(p);
        Dictionary<string, List<Player>> dict = new Dictionary<string, List<Player>>();
    }

    // C# 9 — 타입 이름을 생략한다
    public static void Newer()
    {
        Player p = new("A", 10);
        List<Player> list = new();
        list.Add(p);
        Dictionary<string, List<Player>> dict = new();
    }
}

위처럼 동작하기 때문에 좌변에 한 번만 타입을 적으면, 우변의 new()는 같은 타입의 생성자를 호출하는 코드로 변환됩니다. 인자가 있으면 인자에 맞는 생성자가, 없으면 매개변수 없는 생성자가 선택됩니다.

이 두 메서드의 IL을 직접 비교해보겠습니다.

IL
// === Old() — 기존 방식 ===
.method public hidebysig static void Old () cil managed
{
    .maxstack 2
    .locals init (
        [0] class Player
    )

    IL_0000: ldstr "A"                                                      // 문자열 "A" 푸시
    IL_0005: ldc.i4.s 10                                                    // 정수 10 푸시
    IL_0007: newobj instance void Player::.ctor(string, int32)              // Player 생성자 호출
    IL_000c: stloc.0                                                        // 지역변수 p에 저장
    IL_000d: newobj instance void class [...].List`1<class Player>::.ctor() // List<Player> 생성
    IL_0012: ldloc.0                                                        // p 푸시
    IL_0013: callvirt instance void class [...].List`1<class Player>::Add(!0) // list.Add(p)
    IL_0018: newobj instance void class [...].Dictionary`2<...>::.ctor()    // Dictionary 생성
    IL_001d: pop
    IL_001e: ret
}

// === Newer() — Target-typed new ===
.method public hidebysig static void Newer () cil managed
{
    .maxstack 2
    .locals init (
        [0] class Player
    )

    IL_0000: ldstr "A"                                                      // 동일
    IL_0005: ldc.i4.s 10                                                    // 동일
    IL_0007: newobj instance void Player::.ctor(string, int32)              // 동일
    IL_000c: stloc.0                                                        // 동일
    IL_000d: newobj instance void class [...].List`1<class Player>::.ctor() // 동일
    IL_0012: ldloc.0                                                        // 동일
    IL_0013: callvirt instance void class [...].List`1<class Player>::Add(!0) // 동일
    IL_0018: newobj instance void class [...].Dictionary`2<...>::.ctor()    // 동일
    IL_001d: pop
    IL_001e: ret
}

IL 해설. 두 메서드의 IL은 바이트 단위로 완전히 동일합니다. 코드 크기도 둘 다 0x1f(31바이트)이고, 명령어 시퀀스도 똑같습니다. Target-typed new컴파일 타임 문법 설탕(syntactic sugar) 이라서, 컴파일러가 타입을 추론한 뒤 일반 new T() 로 치환해 IL을 생성합니다. 따라서 런타임 비용·박싱·할당 패턴 모두 동일합니다.

IL(Intermediate Language, 중간 언어) — C# 코드를 컴파일하면 곧바로 기계어가 되는 게 아니라, .NET 런타임이 이해하는 중립 명령어(IL)로 변환됩니다. 실행 시점에 JIT(Just-In-Time, 실행 직전 컴파일)이 IL을 기계어로 바꿉니다.
newobj 명령 — 객체 인스턴스를 힙에 할당하고 지정된 생성자를 호출하는 IL 명령어입니다. C#의 new T(...)는 항상 이 한 줄로 변환됩니다.

3. [내부 동작] — 컴파일러가 대상 타입을 결정하는 순서

추론 알고리즘의 흐름

new() 표현식을 만난 컴파일러는 다음 순서로 처리합니다.

컴파일러의 Target-typed new 처리 흐름

이 다섯 단계 중 어느 하나라도 실패하면 컴파일 에러가 발생합니다. 특히 ② 단계에서 후보가 없거나, ③ 단계에서 후보가 둘 이상이면(메서드 오버로드 모호성) 에러입니다.

기본 생성자 vs 인자 있는 생성자

Unity에서 이런 상황이 생긴다. 매개변수 없는 생성자를 가진 컬렉션과, 인자가 있는 도메인 객체를 함께 다룬다.

C#
public class Order
{
    public int Id { get; }
    public Order(int id) { Id = id; }
}

public class Demo
{
    public static void Run()
    {
        // 매개변수 없는 생성자 → new()
        List<Order> orders = new();

        // 인자 있는 생성자 → new(인자)
        Order o = new(42);

        // 컬렉션 초기화 안에서도 동일
        List<Order> presetOrders = new()
        {
            new(1),
            new(2),
            new(3),
        };
    }
}

위처럼 동작하기 때문에 인자가 있는 케이스(new(42))는 Order(int) 시그니처 생성자를, 인자가 없는 케이스(new())는 List<Order>의 매개변수 없는 생성자를 호출합니다. 컬렉션 이니셜라이저 안에서는 요소 타입(Order)이 대상이 되어 new(1)new Order(1)로 해석됩니다.

어디서 타입을 가져오는가 — 사용 가능한 문맥

문맥 예시 추론되는 타입
변수 선언 Player p = new(...) Player
필드 초기화 private List<Item> items = new(); List<Item>
자동 프로퍼티 초기화 public Inventory Inv { get; set; } = new(); Inventory
메서드 인자 Process(new(...)) (Process(Order o) 호출) Order
반환문 return new(0, 10, 0); (반환 타입 Vector3) Vector3
컬렉션 요소 List<Order> = new() { new(1), new(2) }; Order
캐스트 (Vector3)new(0, 0, 0) Vector3
삼항 연산자 Enemy e = isBoss ? new BossEnemy() : new GruntEnemy(); 좌변 타입에 맞춰
default 대용은 불가 var x = new(); ❌ — var와는 못 씀

특히 자동 프로퍼티 기본값 초기화var로는 절대 줄일 수 없는 영역이라서 new() 의 가치가 가장 큽니다.

C#
public class GameSettings
{
    // 프로퍼티는 var를 못 쓴다 → new()로 우변만 줄인다
    public List<WaveData> Waves { get; private set; } = new();
    public Dictionary<string, int> ItemCounts { get; private set; } = new();
}

4. [실전 적용] — Before/After 와 Unity 패턴

4-1. 긴 제네릭 타입 초기화

Unity에서 이런 상황이 생긴다. 스폰 테이블처럼 중첩 제네릭이 깊은 자료구조를 필드로 가진다.

Before — 타입 중복

C#
public class WaveManager : MonoBehaviour
{
    private Dictionary<string, List<EnemySpawnInfo>> waveTable
        = new Dictionary<string, List<EnemySpawnInfo>>();

    private Queue<Func<float, IEnumerator>> pendingRoutines
        = new Queue<Func<float, IEnumerator>>();
}

After — new() 로 우변 축약

C#
public class WaveManager : MonoBehaviour
{
    private Dictionary<string, List<EnemySpawnInfo>> waveTable = new();
    private Queue<Func<float, IEnumerator>> pendingRoutines = new();
}

위처럼 동작하기 때문에 좌변의 타입 정보가 우변으로 그대로 흘러갑니다. 이후 EnemySpawnInfo 의 이름이 바뀌면 좌변 한 군데만 수정하면 됩니다.

4-2. 메서드 인자에서 즉석 객체 만들기

Unity에서 이런 상황이 생긴다. API 호출 결과를 즉석으로 DTO에 담아 넘긴다.

Before

C#
public class EventBus
{
    public void Publish(GameEvent evt) { /* ... */ }
}

public class Player : MonoBehaviour
{
    void OnDeath()
    {
        eventBus.Publish(new GameEvent("PlayerDied", transform.position));
    }
}

After

C#
public class Player : MonoBehaviour
{
    void OnDeath()
    {
        // Publish의 매개변수 타입(GameEvent)에서 추론
        eventBus.Publish(new("PlayerDied", transform.position));
    }
}

이때 두 코드의 IL을 비교하면 newobj instance void GameEvent::.ctor(string, valuetype Vector3) 한 줄로 동일하게 컴파일됩니다. 호출 경로·할당량·GC 압박이 완전히 같습니다.

DTO(Data Transfer Object, 데이터 전송 객체) — 계층·시스템 사이에서 데이터만 실어 나르기 위한 단순 객체. 메서드나 동작 없이 필드/프로퍼티만 가진 경우가 많습니다.

4-3. 팩토리 메서드의 반환문

Unity에서 이런 상황이 생긴다. 기본 설정으로 새 인벤토리를 생성하는 팩토리.

Before

C#
public static Inventory CreateDefault()
{
    return new Inventory(new List<Item>(), 100);
}

After

C#
public static Inventory CreateDefault()
{
    // 반환 타입(Inventory)과 매개변수 타입(List<Item>) 양쪽에서 추론
    return new(new(), 100);
}

주의. new(new(), 100) 처럼 중첩 시킬수록 가독성이 급격히 떨어집니다. 안쪽 new() 가 무엇을 의미하는지 한눈에 안 들어오면 이 단계에서는 안쪽만 풀어 쓰는 편이 좋습니다.

C#
// 절충안 — 바깥은 줄이고 안은 풀어 쓴다
public static Inventory CreateDefault()
{
    return new(new List<Item>(), 100);
}
Unity 핫패스(hot path) 적용성Target-typed new 는 IL 레벨에서 차이가 없으므로 핫패스에서 사용해도 GC 스파이크나 추가 할당이 발생하지 않습니다. 다만 new 자체의 힙 할당은 그대로이므로, 매 프레임 호출되는 Update() 에서 List<T>·Dictionary<TKey,TValue>·StringBuilder 같은 참조 타입을 만드는 패턴은 new() 로 줄여 쓰더라도 여전히 GC 압박의 원인입니다. 핫패스에서는 풀링·재사용을 우선합니다.

4-4. 컬렉션 이니셜라이저 가독성 극적 개선

Unity에서 이런 상황이 생긴다. 디자이너가 준 표를 코드로 옮긴다.

Before

C#
List<EnemySpawnInfo> wave1 = new List<EnemySpawnInfo>
{
    new EnemySpawnInfo(EnemyType.Grunt, 5, 0.5f),
    new EnemySpawnInfo(EnemyType.Ranger, 3, 1.0f),
    new EnemySpawnInfo(EnemyType.Brute, 1, 2.0f),
};

After

C#
List<EnemySpawnInfo> wave1 = new()
{
    new(EnemyType.Grunt, 5, 0.5f),
    new(EnemyType.Ranger, 3, 1.0f),
    new(EnemyType.Brute, 1, 2.0f),
};

위처럼 동작하기 때문에 표의 데이터 구조(타입 6번 반복)가 사라지고 데이터 자체(매 행의 값)에 시선이 집중됩니다. 데이터 시트 변환 코드의 가독성이 가장 크게 개선되는 패턴입니다.


5. [함정과 주의사항]

5-1. var x = new(); — 가장 흔한 실수

잘못된 코드

C#
var enemy = new();  // CS8754: 'new()'에는 대상 형식이 있어야 합니다.
IL
// 컴파일조차 되지 않으므로 IL 없음 — CS8754 에러

올바른 코드

C#
Enemy enemy = new();        // OK — 좌변이 타입을 정함
// 또는
var enemy = new Enemy();    // OK — 우변이 타입을 정함

왜 안 되는가. var 는 우변의 타입을 보고 좌변을 결정하고, new() 는 좌변의 타입을 보고 우변을 결정합니다. 둘을 같이 쓰면 어느 쪽도 시작점을 제공하지 않아 추론이 막힙니다.

5-2. 메서드 오버로드 모호성

Unity에서 이런 상황이 생긴다. 같은 이름의 오버로드 메서드를 호출할 때 인자 타입이 모호하다.

잘못된 코드

C#
using System.IO;

class BadDemo
{
    static void Process(Stream s) { }
    static void Process(MemoryStream ms) { }

    static void Caller()
    {
        Process(new());  // CS0121: 'Process(Stream)'과 'Process(MemoryStream)' 사이의 호출이 모호함
    }
}

Stream 도 가능하고 MemoryStream 도 가능하므로 컴파일러가 후보를 하나로 좁히지 못합니다.

올바른 코드

C#
static void Caller()
{
    // 명시적으로 타입을 지정해 모호성 제거
    Process(new MemoryStream());

    // 또는 변수에 먼저 받아서 호출
    Stream s = new MemoryStream();
    Process(s);
}

5-3. 인터페이스·추상 클래스로는 추론 못 함

잘못된 코드

C#
IList<int> list = new();   // CS8754: 'IList<int>'는 인스턴스화할 수 없는 인터페이스 타입
IDamageable target = new(); // 추상 클래스/인터페이스도 동일하게 실패

좌변이 인터페이스나 추상 클래스이면 어떤 구현체를 만들어야 할지 컴파일러가 알 수 없습니다.

올바른 코드

C#
IList<int> list = new List<int>();  // 구현체를 명시

5-4. Unity 라이프사이클 객체에는 못 씀 — MonoBehaviour / ScriptableObject

잘못된 코드

C#
public class GameManager : MonoBehaviour
{
    void Start()
    {
        Player p = new();  // ❌ Player가 MonoBehaviour를 상속한다면 런타임에 정상 동작 안 함
    }
}

MonoBehaviourScriptableObject 는 Unity 엔진이 라이프사이클(Awake → Start → ... → OnDestroy)을 관리합니다. new 자체로 만들면 안 되는 클래스입니다 — 이건 Target-typed new 의 한계라기보다 모든 new 가 부적절한 경우입니다.

올바른 코드

C#
public class GameManager : MonoBehaviour
{
    void Start()
    {
        // MonoBehaviour: GameObject에 컴포넌트 추가
        Player p = gameObject.AddComponent<Player>();

        // ScriptableObject: 전용 팩토리 메서드 사용
        var settings = ScriptableObject.CreateInstance<GameSettings>();
    }
}

순수 C# 클래스(POCO, MonoBehaviour 비상속) · 구조체 · Vector3·Color·WaitForSeconds 같은 일반 타입에서는 new() 가 자유롭게 동작합니다.

POCO(Plain Old CLR Object) — Unity 엔진의 라이프사이클이나 특수한 베이스 클래스에 의존하지 않는 순수 C# 객체. 데이터 클래스·매니저 클래스·DTO 등이 여기에 해당합니다.

5-5. 가독성 함정 — new() 가 멀리 떨어진 타입에서 추론될 때

Unity에서 이런 상황이 생긴다. 50줄짜리 메서드의 끝에서 return new(); 만 있는 코드.

가독성 떨어지는 코드

C#
public PlayerStats LoadFromCache()
{
    // ... 40줄의 처리 ...
    if (cacheMiss)
    {
        return new();  // 반환 타입이 50줄 위에 있음 — 한눈에 안 보임
    }
    // ...
}

new() 만 보고 무엇이 만들어지는지 알려면 메서드 시그니처(PlayerStats LoadFromCache)까지 거슬러 올라가야 합니다.

명시적이 더 나은 코드

C#
public PlayerStats LoadFromCache()
{
    // ...
    if (cacheMiss)
    {
        return new PlayerStats();  // 멀리 떨어진 위치에서는 타입 명시
    }
}

경험칙. new() 가 만드는 타입이 같은 화면(15~20줄 이내)에 보이지 않으면 풀어 쓰는 편이 낫습니다.

5-6. 코드 리뷰 과정에서 "어떤 타입인지 모르겠음" 피드백을 받았을 때

코드 리뷰에서 동료가 new() 의 타입을 묻는다면 그 자리는 풀어 쓰는 게 답입니다. 언어가 허용한다고 해서 항상 써야 하는 건 아닙니다 — 문맥이 짧고 명확할 때만 쓰는 도구입니다.


6. [C# 버전별 변화]

C# 1.0 ~ 8.0 — 두 번 적기

객체 생성 시 좌변·우변 모두 타입을 명시해야 합니다.

C#
// C# 8.0까지의 정석
List<Item> inventory = new List<Item>();
Dictionary<string, int> counts = new Dictionary<string, int>();

C# 3.0 의 var지역 변수에서만 좌변을 줄일 수 있었고, 필드·프로퍼티·매개변수·반환 타입에서는 도움이 되지 않았습니다.

C# 9.0 — Target-typed new 도입

좌변·매개변수·반환 등 대상 타입이 명확하면 우변의 타입 이름을 생략할 수 있습니다.

C#
List<Item> inventory = new();
Dictionary<string, int> counts = new();

// 메서드 인자, 반환, 컬렉션 이니셜라이저까지 적용 가능
Process(new("first"));

같은 시점에 도입된 record 타입과 결합하면 선언형 데이터 모델링이 가능해집니다.

C#
public record Person(string Name, int Age);

Person[] people =
{
    new("Alice", 30),
    new("Bob", 25),
};

C# 10.0 — 구조체 매개변수 없는 생성자와의 조합

C# 10 부터 구조체에서 명시적 매개변수 없는 생성자를 정의할 수 있게 되었고, new() 가 이 사용자 정의 기본 생성자를 호출하도록 동작합니다.

C#
public struct Point
{
    public int X;
    public int Y;
    public Point() { X = 1; Y = 1; }   // C# 10 — 명시적 기본 생성자
}

Point p = new();   // X=1, Y=1 (default(Point) 와 다름)
defaultnew() 의 미묘한 차이 — 구조체에서 default(T)모든 필드를 0으로 초기화한 인스턴스를 만들지만, new T() 또는 Target-typed new()사용자 정의 매개변수 없는 생성자가 있으면 그것을 호출합니다. C# 10 이전에는 차이가 없었지만 이후로는 결과가 달라질 수 있습니다.

C# 11 / 12 / 13 — 문법 자체는 그대로

C# 11 의 required 멤버, C# 12 의 Primary Constructor, C# 13 의 컬렉션 표현식([1, 2, 3])이 도입됐지만 Target-typed new 자체의 문법은 C# 9 이후 변화 없이 유지되고 있습니다. 컬렉션 표현식이 등장하면서 일부 컬렉션 초기화 패턴은 new() 보다 [..] 가 더 간결해졌습니다.

C#
// C# 12 까지 — Target-typed new
List<int> nums = new() { 1, 2, 3 };

// C# 12 (preview) / 13 — 컬렉션 표현식
List<int> nums = [1, 2, 3];

7. [정리] — 핵심 요약 체크리스트

  • [ ] Target-typed new 는 좌변·매개변수·반환 타입 등 "대상 타입"으로부터 우변의 타입을 추론합니다. new 뒤의 타입 이름을 생략할 수 있게 합니다.
  • [ ] var 와는 추론 방향이 정반대입니다. var 는 우변에서 좌변을, new() 는 좌변에서 우변을 추론합니다. 둘을 동시에 쓰는 var x = new(); 는 항상 컴파일 에러입니다.
  • [ ] 사용 가능한 문맥: 변수 선언, 필드·프로퍼티 초기화, 메서드 인자, 반환문, 컬렉션 이니셜라이저, 캐스트.
  • [ ] 사용 불가능한 문맥: var 와 결합, 인터페이스·추상 클래스 좌변, 메서드 오버로드 모호성이 있는 호출.
  • [ ] IL 레벨에서 일반 new T() 와 완전히 동일합니다. 컴파일러가 타입 추론 후 같은 newobj 명령을 생성하므로 런타임 비용·할당·GC 압박이 동일합니다.
  • [ ] Unity 에서는 MonoBehaviour·ScriptableObject 에 사용 불가합니다. 이들은 new 자체를 쓰면 안 되며 AddComponent<T> / ScriptableObject.CreateInstance<T> 를 써야 합니다.
  • [ ] 가독성 경계: 타입이 같은 화면(~20줄)에 보일 때만 줄입니다. 멀리 떨어진 위치에서는 풀어 쓰는 편이 더 명확합니다. 중첩 new(new(), ...) 도 피합니다.
  • [ ] 가장 큰 효용: 필드·프로퍼티에서 긴 제네릭 타입 초기화, record 배열·딕셔너리 초기화, 디자이너 표 데이터를 코드로 옮기는 컬렉션 이니셜라이저.
반응형

+ Recent posts