반응형

[PART3.연산자와 표현식(4/11)] 대입·복합 대입 연산자 — =, +=, 그리고 그 뒤에 숨은 것들

대입은 문이 아니라 표현식이다 / 복합 대입은 좌변을 한 번만 평가한다 / 이벤트·ref·Unity struct 복사까지


1. 문제 제기 — "그냥 값을 넣는 것" 아닌가요?

Unity 신입 개발자가 다음 코드를 작성했다고 가정해봅시다.

C#
// 매 프레임 플레이어의 HP를 1씩 회복
playerStats[GetActivePlayerIndex()] += 1;

// 문자열 로그를 누적해서 쌓기
logBuffer += $"[{Time.frameCount}] hit\n";

// 공격 버튼을 구독
attackButton.onClick += HandleAttack;

// 위치 X만 살짝 바꾸기
transform.position.x = 0f;

네 줄 모두 =+=를 쓰지만, 각각의 동작 방식은 완전히 다릅니다.

  • 첫 줄은 GetActivePlayerIndex()를 몇 번 호출할까요? 한 번일까요, 두 번일까요?
  • 둘째 줄은 매 프레임 힙에 새 문자열을 만들어 GC(Garbage Collector, 메모리를 자동으로 회수하는 런타임 구성 요소) 스파이크를 유발할 수 있습니다.
  • 셋째 줄은 +=처럼 보이지만 실제로는 메서드 호출로 컴파일됩니다.
  • 넷째 줄은 컴파일 에러입니다(CS1612).

=은 가장 먼저 배우는 연산자지만, C# 언어 사양에서 차지하는 공간은 생각보다 큽니다. 대입이 문(statement)이 아니라 표현식(expression) 이라는 점, 복합 대입이 x = x + y의 단순한 축약이 아니라는 점, 이벤트·ref·string·struct 프로퍼티에서 서로 다른 규칙이 적용된다는 점을 알아야 위 네 줄의 차이가 눈에 들어옵니다.

이 글에서는 =·+=·-=·*=·/=·%=·&=·|=·^=·<<=·>>= 그리고 ??=·= ref·x?.y = z까지 대입 관련 연산자 전체를 언어 사양 → IL(Intermediate Language, C# 컴파일러가 생성하는 중간 언어) → Unity 실전 순서로 짚어봅니다.

문법 용어 정리
  • 문(statement): 실행 단위. int x = 1; 전체가 문입니다.
  • 표현식(expression): 값을 만들어내는 단위. 1 + 2, x = 1 모두 값을 가진 표현식입니다.
  • 부수 효과(side effect): 식을 평가하는 과정에서 외부 상태를 바꾸는 일. 메서드 호출·필드 쓰기 등.

2. 개념 정의 — 대입은 "표현식"입니다

2.1 =이 만들어내는 값

C# 언어 사양은 대입 연산자를 assignment_expression으로 분류합니다. 즉 x = 5는 실행만 되는 것이 아니라 값을 가진 식이며, 그 값은 "좌변에 저장된 값"입니다. 타입은 좌변의 타입이고요.

비유하자면 영수증과 같습니다. 계산대에 돈을 건네면 금전함에 돈이 들어갈 뿐 아니라, 점원이 영수증(대입한 값) 을 돌려줍니다. 그 영수증을 바로 다음 주머니에 넣을 수도 있습니다.

대입식 x = 5 의 두 가지 결과
C#
using System;
using System.IO;

public class AssignExpr
{
    static int F() => 42;

    public static void Demo()
    {
        int x;

        // (x = F()) 는 x에 42를 저장함과 동시에 "42" 라는 값을 돌려준다
        if ((x = F()) > 0)
            Console.WriteLine(x);

        // 대입은 우결합이어서 b에 5를 넣은 결과(5)가 a에도 흘러간다
        int a, b;
        a = b = 5;
    }
}

위 패턴이 가장 유용한 곳은 파일·네트워크 읽기 루프입니다.

C#
using var reader = new StreamReader("input.txt");
string? line;
while ((line = reader.ReadLine()) != null)   // 읽기 + null 체크를 한 줄에
{
    Console.WriteLine(line);
}

컴파일러는 이 표현식 값을 IL에서 어떻게 만들까요?

IL
// AssignExpr::Demo - if ((x = F()) > 0)
.locals init (
    [0] int32    // 지역 변수 x
)

IL_0000: call int32 AssignExpr::F()   // F() 호출 결과(42)를 스택에 push
IL_0005: dup                          // 핵심: 스택 최상단 값을 복제
IL_0006: stloc.0                      // 복제된 값 하나를 x에 저장 (대입의 부수 효과)
IL_0007: ldc.i4.0                     // 비교용 0
IL_0008: ble.s IL_0010                // 남아있는 원본과 0을 비교
IL_000a: ldloc.0
IL_000b: call void [System.Console]System.Console::WriteLine(int32)
IL_0010: ret

dup 명령이 열쇠입니다. 저장(stloc.0) 직전에 스택에 있는 값을 복제해 두 벌로 만들고, 하나는 저장, 다른 하나는 비교식에 넘깁니다. 대입이 값을 만들어낸다는 사양이 IL 레벨에서 바로 이 dup으로 실현됩니다. a = b = c 같은 체인 대입도 같은 방식이고요.

2.2 복합 대입의 기본형 — x op= y

복합 대입은 산술(+=, -=, *=, /=, %=), 비트(&=, |=, ^=), 시프트(<<=, >>=) 전체가 동일한 규칙을 따릅니다.

op= (복합 대입 연산자) x op= yx = x op y와 같아 보이지만, 좌변 x가 단 한 번만 평가됩니다. 여기에 더해 일부 타입에서는 암시적 캐스팅이 자동 삽입되는 완화 규칙이 추가됩니다.
예시: count += 1; / mask &= 0xFF; / shift <<= 2;

"한 번만 평가"라는 문구는 다음 섹션(내부 동작)에서 그 차이가 드러납니다.


3. 내부 동작 — 좌변은 정확히 한 번 평가된다

3.1 arr[GetIndex()] += 1 vs arr[GetIndex()] = arr[GetIndex()] + 1

두 코드는 의미가 같아 보입니다. 하지만 좌변에 부수 효과가 있는 식이 들어가면 이야기가 달라집니다.

GetIndex() 호출 횟수 비교
C#
using System;

public class CompoundArr
{
    static int _count = 0;
    public static int GetIndex() { _count++; return 1; }

    public static void Compound(int[] arr)
    {
        arr[GetIndex()] += 1;                    // _count == 1
    }

    public static void Expanded(int[] arr)
    {
        arr[GetIndex()] = arr[GetIndex()] + 1;   // _count == 2
    }
}

같은 배열을 넘기고 호출해보면 _count가 서로 다르게 증가합니다. GetIndex()가 로그를 남기거나 데이터베이스를 조회하는 메서드였다면, 단순 "축약"이라고 생각하고 리팩터링했다가 프로덕션에서 이중 로그·이중 쿼리가 되는 것입니다.

IL에서 이 차이는 명확하게 드러납니다.

IL
// CompoundArr::Compound - arr[GetIndex()] += 1
IL_0000: ldarg.0                                   // arr 로드
IL_0001: call int32 CompoundArr::GetIndex()        // GetIndex() 1회 호출
IL_0006: ldelema [System.Runtime]System.Int32      // arr[i] 슬롯의 "주소" 를 스택에
IL_000b: dup                                       // 주소를 복제 (읽기용 + 쓰기용)
IL_000c: ldind.i4                                  // 주소에서 현재 값 읽기
IL_000d: ldc.i4.1
IL_000e: add
IL_000f: stind.i4                                  // 같은 주소에 결과 저장
IL_0010: ret
IL
// CompoundArr::Expanded - arr[GetIndex()] = arr[GetIndex()] + 1
IL_0000: ldarg.0
IL_0001: call int32 CompoundArr::GetIndex()        // GetIndex() 1회 호출 (저장 대상 인덱스)
IL_0006: ldarg.0
IL_0007: call int32 CompoundArr::GetIndex()        // GetIndex() 또 호출! (읽을 인덱스)
IL_000c: ldelem.i4
IL_000d: ldc.i4.1
IL_000e: add
IL_000f: stelem.i4
IL_0010: ret

Compound 쪽은 ldelema로 주소를 한 번 얻고 dup으로 복제해 읽기·쓰기에 재사용합니다. Expanded 쪽은 ldelem.i4(읽기)와 stelem.i4(쓰기)에 필요한 인덱스를 위해 GetIndex()를 두 번 호출합니다.

3.2 IL 저장 명령어 지도

대입은 저장소 종류에 따라 쓰는 IL 명령이 달라집니다. 다음 코드와 IL을 짝지어 전체 명령어를 한눈에 보세요.

C#
public class StorageDemo
{
    public int X;           // 인스턴스 필드
    public static int Y;    // 정적 필드

    public void SetX(int v) { X = v; }
    public static void SetY(int v) { Y = v; }
}

public class ArrayStore
{
    public void Store(int[] a, object[] b)
    {
        a[0] = 100;              // 값 타입 배열 요소
        b[0] = new object();     // 참조 타입 배열 요소
    }
}
IL
// StorageDemo::SetX - X = v
IL_0000: ldarg.0                 // this
IL_0001: ldarg.1                 // v
IL_0002: stfld int32 StorageDemo::X   // 인스턴스 필드 저장

// StorageDemo::SetY - Y = v
IL_0000: ldarg.0                 // 정적 메서드의 0번 인자는 v 자체
IL_0001: stsfld int32 StorageDemo::Y  // 정적 필드 저장

// ArrayStore::Store
IL_0000: ldarg.1                 // a
IL_0001: ldc.i4.0                // 인덱스 0
IL_0002: ldc.i4.s 100
IL_0004: stelem.i4               // int 배열 요소 저장
IL_0005: ldarg.2                 // b
IL_0006: ldc.i4.0
IL_0007: newobj instance void [System.Runtime]System.Object::.ctor()
IL_000c: stelem.ref              // 참조 타입 배열 요소 저장
대상 저장소 IL 명령 언제 쓰이나
지역 변수 stloc.0~3, stloc.s N, stloc N 메서드 내부 int x; x = ...;
메서드 인자 starg.s N void M(int x) { x = ...; } (드문 패턴)
인스턴스 필드 stfld this.X = v; — 앞에 대상 객체 필요
정적 필드 stsfld ClassName.Y = v;
배열 요소(값 타입) stelem.i4, stelem.i8, … arr[i] = 100;
배열 요소(참조 타입) stelem.ref list[0] = obj;
간접(ref/pointer) stind.i4, stind.ref ref·unsafe 포인터 저장
스택 복제 dup 체인 대입, 조건식 속 대입

"대입 한 줄"의 정체는 결국 이 명령어 중 하나라는 점을 기억해두면, 나중에 Unity의 IL2CPP(IL을 C++로 변환한 뒤 네이티브로 컴파일하는 Unity의 AOT 빌드 파이프라인) 산출물이나 디버거의 디스어셈블리를 읽을 때 훨씬 편해집니다.


4. 실전 적용 — 복합 대입의 특수 규칙

4.1 좁은 정수 타입과 암시적 캐스팅

Unity에서 short·byte·ushort는 네트워크 패킷이나 메모리 절약을 위해 자주 사용됩니다. 그런데 다음 코드는 컴파일 에러입니다.

C#
// ❌ Before — CS0266
short hp = 100;
hp = hp + 1;   // error CS0266: Cannot implicitly convert type 'int' to 'short'

이유는 C#의 산술 연산자가 int 미만 타입에 대해 정의되어 있지 않기 때문입니다. short + int는 자동으로 int로 확장되어 결과가 int가 되고, 이걸 다시 더 좁은 short에 넣으려면 데이터 손실 위험이 있어 명시적 캐스팅을 요구합니다.

반면 복합 대입은 같은 연산을 허용합니다.

C#
// ✅ After — 정상 컴파일
short hp = 100;
hp += 1;       // OK

언어 사양 §11.18.4는 복합 대입에 대해 "명시적 변환이 존재하면 허용한다"는 완화 규칙을 둡니다. 즉 hp += 1;은 컴파일러가 내부적으로 hp = (short)(hp + 1);로 변환합니다. IL로 직접 확인해봅시다.

C#
public class ShortAdd
{
    public static void Plus(short s)
    {
        s += 1;
    }
}
IL
// ShortAdd::Plus - s += 1
IL_0000: ldarg.0
IL_0001: ldc.i4.1
IL_0002: add                     // int 덧셈 결과(= int)
IL_0003: conv.i2                 // 컴파일러가 자동으로 short로 좁혀주는 변환
IL_0004: starg.s s               // 매개변수 s에 저장
IL_0006: ret

conv.i2(16비트 signed integer로 변환)가 컴파일러가 몰래 넣어준 캐스팅입니다. 이 완화 규칙은 편리하지만 함정도 있습니다.

C#
short s = short.MaxValue;      // 32767
s += 1;                        // unchecked: 래핑되어 -32768 (silent overflow)
checked { s += 1; }            // OverflowException

기본(unchecked) 컨텍스트에서는 조용히 오버플로가 됩니다. 네트워크 패킷에서 증가 카운터를 short로 쓰면 MaxValue 직후 음수로 튀는 버그를 만나기 쉽습니다.

4.2 이벤트에서의 +=, -= — 완전히 다른 의미

일반 변수의 +=는 "더하기"이지만, 이벤트에 붙는 +=는 덧셈이 아니라 add_XXX 접근자 호출로 변환됩니다. -=remove_XXX로요.

event+= / -= 이벤트 멤버는 컴파일러가 자동으로 add_EventName / remove_EventName 접근자 메서드를 생성합니다. 외부에서 이벤트 += 핸들러라고 쓰면 C# 컴파일러는 이를 접근자 호출로 바꿉니다.
예시: button.Click += OnClick; → 실제로는 button.add_Click(OnClick);
C#
using System;

public class Button
{
    public event EventHandler? Click;
    public void Raise() => Click?.Invoke(this, EventArgs.Empty);
}

public class Subscriber
{
    public void Subscribe(Button b)
    {
        b.Click += OnClick;
    }

    public void Unsubscribe(Button b)
    {
        b.Click -= OnClick;
    }

    void OnClick(object? s, EventArgs e) { }
}

Subscribe의 IL을 보면 "덧셈"의 흔적은 전혀 없고 callvirt만 남습니다.

IL
// Subscriber::Subscribe - b.Click += OnClick
IL_0000: ldarg.1                                              // b
IL_0001: ldarg.0                                              // this (OnClick의 target)
IL_0002: ldftn instance void Subscriber::OnClick(object, class [System.Runtime]System.EventArgs)
IL_0008: newobj instance void [System.Runtime]System.EventHandler::.ctor(object, native int)
IL_000d: callvirt instance void Button::add_Click(...)        // += 는 add_Click 호출로 변환
IL_0012: ret

그리고 컴파일러가 자동 생성한 add_Click 접근자 내부는 스레드 안전하게 대리자 리스트를 갱신합니다.

IL
// Button::add_Click (자동 생성) - 스레드 안전한 리스트 갱신
IL_0000: ldarg.0
IL_0001: ldfld class [System.Runtime]System.EventHandler Button::Click
IL_0006: stloc.0
// loop 시작
    IL_0007: ldloc.0
    IL_0008: stloc.1
    IL_0009: ldloc.1
    IL_000a: ldarg.1
    IL_000b: call class [System.Runtime]System.Delegate [System.Runtime]System.Delegate::Combine(...)
    IL_0010: castclass [System.Runtime]System.EventHandler
    IL_0015: stloc.2
    IL_0016: ldarg.0
    IL_0017: ldflda class [System.Runtime]System.EventHandler Button::Click   // 필드 주소
    IL_001c: ldloc.2
    IL_001d: ldloc.1
    IL_001e: call !!0 [System.Threading]System.Threading.Interlocked::CompareExchange<...>(!!0&, !!0, !!0)
    IL_0023: stloc.0
    IL_0024: ldloc.0
    IL_0025: ldloc.1
    IL_0026: bne.un.s IL_0007
// loop 끝
IL_0028: ret

핵심은 Delegate.Combine(리스트에 추가)과 Interlocked.CompareExchange(원자적 교체)입니다. 즉 이벤트의 +=는 단순 대입이 아니라 "멀티캐스트 대리자 리스트에 새 항목을 스레드 안전하게 추가하는 메서드 호출" 입니다.

이벤트 = (덮어쓰기)는 선언 클래스 외부에서는 컴파일 에러입니다. 핸들러 목록을 통째로 버리는 실수를 막기 위함이죠.

4.3 참조 할당 = ref x — 변수 자체를 별칭으로

C# 7.0에서 ref 지역 변수가 도입되고, 7.3부터 재할당이 허용되면서 참조 할당 문법이 언어 차원에서 완성되었습니다.

ref 지역 변수와 = ref ref int r = ref target; 이라고 쓰면 rtarget의 값을 복사한 새 변수가 아니라, target 자체에 대한 별칭이 됩니다. r에 쓰는 모든 값은 target으로 직접 들어갑니다.
예시: ref int r = ref arr[0]; r = 100;arr[0]이 100이 됩니다.
C#
public class RefDemo
{
    public static void Use(int[] arr)
    {
        ref int r = ref arr[0];
        r = 100;                 // arr[0] 이 직접 100이 된다 (복사본이 아님)
    }
}

IL에서는 ldelema(슬롯 주소 로드) + stind.i4(주소에 저장) 조합으로 드러납니다.

IL
// RefDemo::Use
IL_0000: ldarg.0
IL_0001: ldc.i4.0
IL_0002: ldelema [System.Runtime]System.Int32   // arr[0]의 "주소" (관리 포인터)
IL_0007: ldc.i4.s 100
IL_0009: stind.i4                               // 그 주소에 직접 100 저장
IL_000a: ret

중간에 복사가 한 번도 일어나지 않습니다. Span<T>·Unsafe.As<T> 같은 고성능 API와 조합해 큰 struct 배열을 Zero-copy로 처리할 때 필수입니다. Unity 측에서는 NativeArray<T>의 인덱서 슬롯에 ref로 접근할 때도 같은 원리가 쓰입니다.

4.4 값 타입 복사 vs 참조 타입 복사

대입이 메모리에 어떻게 반영되는지는 좌변·우변의 타입에 따라 전혀 다릅니다.

대입 시 메모리에서 일어나는 일
C#
using UnityEngine;

public class AssignSemantics
{
    public struct HpComponent { public int Hp; }     // 값 타입
    public class Enemy { public int Hp; }            // 참조 타입

    public static void Demo()
    {
        var c1 = new HpComponent { Hp = 100 };
        var c2 = c1;                                  // 비트 복사
        c2.Hp = 0;
        UnityEngine.Debug.Log(c1.Hp);                 // 100 (독립)

        var e1 = new Enemy { Hp = 100 };
        var e2 = e1;                                  // 참조만 복사
        e2.Hp = 0;
        UnityEngine.Debug.Log(e1.Hp);                 // 0 (같은 객체)
    }
}

값 타입은 대입 시 멤버를 바이트 단위로 복사해 완전히 독립된 두 개의 데이터가 됩니다. Vector3(12 byte)·Matrix4x4(64 byte) 같은 큰 구조체를 빈번히 대입하면 복사 비용이 쌓입니다. 참조 타입은 4 또는 8 byte 참조만 복사하므로 복사 비용은 거의 없지만, 두 변수가 같은 객체를 공유하기 때문에 한쪽 수정이 다른 쪽에도 보입니다.


5. 함정과 주의사항

5.1 transform.position.x = 0; — Unity 신입의 1호 컴파일 에러

Unity를 처음 만진 C# 개발자가 가장 먼저 마주치는 에러가 이겁니다.

C#
// ❌ Before — CS1612
transform.position.x = 0f;
// Cannot modify the return value of 'Transform.position' because it is not a variable

transform.position프로퍼티입니다. 즉 내부적으로 Transform.get_position() 메서드가 호출되어 Vector3(struct) 복사본이 스택에 반환됩니다. 이 복사본의 x 필드를 바꿔도 원본 Transform 내부 상태는 변하지 않습니다. C# 컴파일러는 이 쓸모없는 코드를 원천 차단하기 위해 컴파일 에러로 만들어 둡니다.

transform.position.x = 0; 가 막히는 이유

올바른 방법은 세 가지가 있습니다.

C#
// ✅ After-1: 프로퍼티 전체를 재대입
transform.position = new Vector3(0f, transform.position.y, transform.position.z);

// ✅ After-2: 로컬 변수에 복사 → 수정 → 다시 대입
var p = transform.position;
p.x = 0f;
transform.position = p;

// ✅ After-3: 확장 메서드나 WithX 유틸 (프로젝트에 흔히 두는 패턴)
public static class Vector3Ext
{
    public static Vector3 WithX(this Vector3 v, float x) => new Vector3(x, v.y, v.z);
}
transform.position = transform.position.WithX(0f);

5.2 매 프레임 s += "x" — GC 스파이크의 원인

string은 참조 타입이지만 불변(immutable)으로 설계되어 있습니다. 따라서 s += "x"는 기존 버퍼에 덧붙이는 것이 아니라 string.Concat(s, "x")로 변환되어 매번 새 문자열을 힙에 할당합니다.

C#
// ❌ Before — Unity 핫패스에서 피해야 할 패턴
public class Logger : MonoBehaviour
{
    string _log = "";

    void Update()
    {
        if (Input.GetKeyDown(KeyCode.Space))
            _log += $"[{Time.frameCount}] space\n";
    }
}

IL로 확인해봅시다.

C#
public class StringDemo
{
    public static string Build()
    {
        string s = "";
        for (int i = 0; i < 3; i++)
            s += "x";
        return s;
    }
}
IL
// StringDemo::Build - s += "x" 을 3회 반복
IL_0000: ldstr ""
IL_0005: stloc.0
IL_0006: ldc.i4.0
IL_0007: stloc.1
IL_0008: br.s IL_001a
// loop 시작
    IL_000a: ldloc.0
    IL_000b: ldstr "x"
    IL_0010: call string [System.Runtime]System.String::Concat(string, string)   // 매 반복 Concat 호출
    IL_0015: stloc.0                                                              // 매 반복 새 문자열을 s에 대입
    IL_0016: ldloc.1
    IL_0017: ldc.i4.1
    IL_0018: add
    IL_0019: stloc.1

    IL_001a: ldloc.1
    IL_001b: ldc.i4.3
    IL_001c: blt.s IL_000a
// loop 끝
IL_001e: ldloc.0
IL_001f: ret

String::Concat이 매 반복마다 새 문자열을 할당합니다. N번 반복이면 총 복사 비용이 O(N²)으로 커지고, Unity의 Boehm GC(혹은 IL2CPP 런타임의 할당자)에 반복적으로 부담을 주어 GC 스파이크를 유발합니다. Unity Profiler에서 GC.Alloc이 프레임마다 튀는 것을 목격한다면 이 패턴을 의심해야 합니다.

C#
// ✅ After — StringBuilder 재사용
public class Logger : MonoBehaviour
{
    readonly System.Text.StringBuilder _log = new(capacity: 4096);

    void Update()
    {
        if (Input.GetKeyDown(KeyCode.Space))
            _log.Append('[').Append(Time.frameCount).Append("] space\n");
    }

    public string Snapshot() => _log.ToString();
}

StringBuilder는 내부 char[] 버퍼를 보유하고 용량이 부족할 때만 2배로 확장합니다. Append는 버퍼에 직접 쓰므로 반복 연결 비용이 O(N)으로 줄고, 최종 ToString() 한 번만 문자열을 할당합니다.

주의: 컴파일 타임 상수끼리의 연결("a" + "b")은 컴파일러가 미리 접어("ab") 실행 시 비용이 없습니다. 경계해야 할 것은 변수끼리의 반복 += 입니다.

5.3 인스펙터 참조 대입 — 프리팹이 사라지는 버그

Unity 신입이 자주 하는 실수 중 하나입니다.

C#
// ❌ Before
public class Spawner : MonoBehaviour
{
    public GameObject enemyPrefab;   // 인스펙터에서 프리팹 지정

    void Start()
    {
        enemyPrefab = Instantiate(enemyPrefab);   // 씬의 인스턴스로 덮어씀
    }

    public void SpawnMore()
    {
        // 이 시점의 enemyPrefab은 "씬에 한 번 생성된 복사본"이다.
        // 새 적을 찍어내려 해도 프리팹 원본에 대한 참조를 잃음.
        Instantiate(enemyPrefab);
    }
}

참조 타입 대입은 같은 객체를 가리키는 포인터 복사입니다. enemyPrefab = Instantiate(...)라고 쓰는 순간, 원래 인스펙터가 꽂아준 프리팹 참조는 로컬 필드에서 사라지고 씬 인스턴스 참조가 들어앉습니다. 이후 Instantiate(enemyPrefab)은 프리팹이 아니라 이미 찍혀 나온 인스턴스를 다시 찍는 엉뚱한 동작을 합니다.

C#
// ✅ After — 원본과 인스턴스 필드를 분리
public class Spawner : MonoBehaviour
{
    [SerializeField] GameObject enemyPrefab;   // 원본 (읽기 전용 의도)
    GameObject? firstInstance;                  // 찍힌 인스턴스는 별도 필드

    void Start()
    {
        firstInstance = Instantiate(enemyPrefab);
    }

    public void SpawnMore()
    {
        Instantiate(enemyPrefab);   // 항상 프리팹 원본 사용
    }
}

기준: 인스펙터가 주입하는 참조를 재대입하지 말 것. 작업용 인스턴스는 별도 필드로 분리하세요.


6. C# 버전별 변화

대입 계열 연산자는 C# 역사 전반에 걸쳐 조금씩 확장되어 왔습니다.

버전 연도 추가된 기능
C# 1.0 2002 =, +=, -=, *=, /=, %=, &=, ` =, ^=, <<=, >>=` 전체
C# 7.0 2017 ref 지역 변수·반환값 (ref int r = ref x;)
C# 7.3 2018 ref 지역 변수 재할당 허용 (r = ref y;)
C# 8.0 2019 ??= null 병합 대입
C# 11 2022 >>>= 부호 없는 우측 시프트 복합 대입
C# 14 2025 x?.y = z null 조건부 대입, x?.y op= z null 조건부 복합 대입

6.1 ??= (C# 8) — lazy init을 한 줄로

C#
// ❌ Before (C# 7 이전)
public class Cache
{
    private List<int>? _items;

    public void Add(int v)
    {
        if (_items == null) _items = new List<int>();
        _items.Add(v);
    }
}
C#
// ✅ After (C# 8+)
public class Cache
{
    private List<int>? _items;

    public void Add(int v)
    {
        _items ??= new List<int>();
        _items.Add(v);
    }
}

IL은 실제로 분기를 만들어서 좌변이 null이 아니면 우변을 평가조차 하지 않습니다. new List<int>() 같은 할당 비용을 아끼는 것이 핵심이고요.

IL
// NullCoalesceAssign::Add - _cache ??= new List<int>();
IL_0000: ldarg.0
IL_0001: ldfld class ... NullCoalesceAssign::_cache
IL_0006: brtrue.s IL_0013                   // null이 아니면 점프 — 우변 평가 생략
IL_0008: ldarg.0
IL_0009: newobj instance void class ...List`1<int32>::.ctor()
IL_000e: stfld class ... NullCoalesceAssign::_cache
IL_0013: ldarg.0
IL_0014: ldfld ...
IL_0019: ldarg.1
IL_001a: callvirt instance void ...List`1<int32>::Add(!0)
IL_001f: ret

brtrue.s로 null이 아닌 경우 분기하고, null일 때만 newobj·stfld를 실행합니다. 조건이 좌변의 null 여부 하나뿐이기 때문에 이 패턴은 재차 if를 쓸 때보다 실행 IL 크기가 작고, 실수로 "두 번 생성"되는 레이스 버그도 줄어듭니다(물론 멀티스레드에서는 여전히 Lazy<T> 등 별도 동기화가 필요합니다).

6.2 x?.y = z (C# 14) — null 조건부 대입

C# 14(2025) 이전에는 ?. 연산자를 대입의 좌변에 쓸 수 없었습니다.

C#
// ❌ Before (C# 13 이전)
if (target != null)
    target.Name = "Alice";
C#
// ✅ After (C# 14+)
target?.Name = "Alice";     // target이 null이면 "Alice" 평가조차 생략
target?.Count += 1;         // 복합 대입도 지원

중요한 규칙 두 가지입니다.

  1. 좌변이 null이면 우변은 평가되지 않습니다. target?.Name = ExpensiveCompute();에서 target이 null이면 ExpensiveCompute()는 호출되지 않습니다. 부수 효과가 있는 우변을 넣을 때 주의가 필요합니다.
  2. 전체 식의 값은 TResult? 가 됩니다. 즉 var r = target?.Name = "Alice";r 타입은 string?.

Unity에서는 옵셔널 참조를 자주 다루기 때문에(GetComponent<T>() 결과, 프리팹의 transform.parent 등) null 체크 보일러플레이트를 줄이는 데 큰 도움이 됩니다.

C#
// Unity 예시
GetComponent<Rigidbody>()?.velocity = Vector3.zero;

7. 정리

핵심을 다섯 가지로 압축합니다.

  • [ ] =표현식입니다. 값(좌변에 대입된 값)을 돌려주므로 조건식·반복 조건·체인 대입에서 활용할 수 있고, IL 레벨에서는 dup으로 구현됩니다.
  • [ ] x op= yx = x op y와 달리 좌변을 정확히 한 번만 평가합니다. arr[Heavy()] += 1에서 Heavy()는 1회 호출됩니다.
  • [ ] 좁은 정수 타입(byte·short·ushort·char)에서 s = s + 1은 CS0266이지만 s += 1은 허용됩니다. 컴파일러가 conv.i2 같은 캐스팅을 몰래 넣어주지만, 오버플로 시 조용히 래핑되는 점을 기억하세요.
  • [ ] 이벤트의 +=·-=는 덧셈이 아니라 add_EventName / remove_EventName 접근자 호출로 변환됩니다. 같은 대리자여야 -=로 제거할 수 있으므로 람다 구독 시 변수에 저장해두어야 합니다.
  • [ ] 대입의 의미는 좌변 저장소 종류와 피연산자 타입에 의해 결정됩니다. 값 타입은 비트 복사, 참조 타입은 참조 복사, string 반복 +=는 O(N²), transform.position.x = 0은 컴파일 에러. 헷갈리면 IL 저장 명령어(stloc·stfld·stsfld·stelem.*·stind.*)로 환원해 생각해보세요.

대입은 가장 평범해 보이는 연산이지만, 제대로 들여다보면 C# 런타임의 저장소 모델 전체가 그 안에 담겨 있습니다. 다음 글(증감 연산자)에서도 같은 "표현식으로서의 연산자" 관점을 이어갑니다.

반응형

+ Recent posts