[PART3.연산자와 표현식(4/11)] 대입·복합 대입 연산자 — =, +=, 그리고 그 뒤에 숨은 것들
대입은 문이 아니라 표현식이다 / 복합 대입은 좌변을 한 번만 평가한다 / 이벤트·ref·Unity struct 복사까지
목차
1. 문제 제기 — "그냥 값을 넣는 것" 아닌가요?
Unity 신입 개발자가 다음 코드를 작성했다고 가정해봅시다.
// 매 프레임 플레이어의 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는 실행만 되는 것이 아니라 값을 가진 식이며, 그 값은 "좌변에 저장된 값"입니다. 타입은 좌변의 타입이고요.
비유하자면 영수증과 같습니다. 계산대에 돈을 건네면 금전함에 돈이 들어갈 뿐 아니라, 점원이 영수증(대입한 값) 을 돌려줍니다. 그 영수증을 바로 다음 주머니에 넣을 수도 있습니다.

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;
}
}
위 패턴이 가장 유용한 곳은 파일·네트워크 읽기 루프입니다.
using var reader = new StreamReader("input.txt");
string? line;
while ((line = reader.ReadLine()) != null) // 읽기 + null 체크를 한 줄에
{
Console.WriteLine(line);
}
컴파일러는 이 표현식 값을 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= y는x = x op y와 같아 보이지만, 좌변x가 단 한 번만 평가됩니다. 여기에 더해 일부 타입에서는 암시적 캐스팅이 자동 삽입되는 완화 규칙이 추가됩니다.
예시:count += 1;/mask &= 0xFF;/shift <<= 2;
"한 번만 평가"라는 문구는 다음 섹션(내부 동작)에서 그 차이가 드러납니다.
3. 내부 동작 — 좌변은 정확히 한 번 평가된다
3.1 arr[GetIndex()] += 1 vs arr[GetIndex()] = arr[GetIndex()] + 1
두 코드는 의미가 같아 보입니다. 하지만 좌변에 부수 효과가 있는 식이 들어가면 이야기가 달라집니다.

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에서 이 차이는 명확하게 드러납니다.
// 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
// 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을 짝지어 전체 명령어를 한눈에 보세요.
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(); // 참조 타입 배열 요소
}
}
// 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는 네트워크 패킷이나 메모리 절약을 위해 자주 사용됩니다. 그런데 다음 코드는 컴파일 에러입니다.
// ❌ Before — CS0266
short hp = 100;
hp = hp + 1; // error CS0266: Cannot implicitly convert type 'int' to 'short'
이유는 C#의 산술 연산자가 int 미만 타입에 대해 정의되어 있지 않기 때문입니다. short + int는 자동으로 int로 확장되어 결과가 int가 되고, 이걸 다시 더 좁은 short에 넣으려면 데이터 손실 위험이 있어 명시적 캐스팅을 요구합니다.
반면 복합 대입은 같은 연산을 허용합니다.
// ✅ After — 정상 컴파일
short hp = 100;
hp += 1; // OK
언어 사양 §11.18.4는 복합 대입에 대해 "명시적 변환이 존재하면 허용한다"는 완화 규칙을 둡니다. 즉 hp += 1;은 컴파일러가 내부적으로 hp = (short)(hp + 1);로 변환합니다. IL로 직접 확인해봅시다.
public class ShortAdd
{
public static void Plus(short s)
{
s += 1;
}
}
// 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로 변환)가 컴파일러가 몰래 넣어준 캐스팅입니다. 이 완화 규칙은 편리하지만 함정도 있습니다.
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);
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만 남습니다.
// 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 접근자 내부는 스레드 안전하게 대리자 리스트를 갱신합니다.
// 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지역 변수와= refref int r = ref target;이라고 쓰면r은target의 값을 복사한 새 변수가 아니라,target자체에 대한 별칭이 됩니다.r에 쓰는 모든 값은target으로 직접 들어갑니다.
예시:ref int r = ref arr[0]; r = 100;—arr[0]이 100이 됩니다.
public class RefDemo
{
public static void Use(int[] arr)
{
ref int r = ref arr[0];
r = 100; // arr[0] 이 직접 100이 된다 (복사본이 아님)
}
}
IL에서는 ldelema(슬롯 주소 로드) + stind.i4(주소에 저장) 조합으로 드러납니다.
// 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 참조 타입 복사
대입이 메모리에 어떻게 반영되는지는 좌변·우변의 타입에 따라 전혀 다릅니다.

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# 개발자가 가장 먼저 마주치는 에러가 이겁니다.
// ❌ 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# 컴파일러는 이 쓸모없는 코드를 원천 차단하기 위해 컴파일 에러로 만들어 둡니다.

올바른 방법은 세 가지가 있습니다.
// ✅ 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")로 변환되어 매번 새 문자열을 힙에 할당합니다.
// ❌ Before — Unity 핫패스에서 피해야 할 패턴
public class Logger : MonoBehaviour
{
string _log = "";
void Update()
{
if (Input.GetKeyDown(KeyCode.Space))
_log += $"[{Time.frameCount}] space\n";
}
}
IL로 확인해봅시다.
public class StringDemo
{
public static string Build()
{
string s = "";
for (int i = 0; i < 3; i++)
s += "x";
return s;
}
}
// 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이 프레임마다 튀는 것을 목격한다면 이 패턴을 의심해야 합니다.
// ✅ 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 신입이 자주 하는 실수 중 하나입니다.
// ❌ Before
public class Spawner : MonoBehaviour
{
public GameObject enemyPrefab; // 인스펙터에서 프리팹 지정
void Start()
{
enemyPrefab = Instantiate(enemyPrefab); // 씬의 인스턴스로 덮어씀
}
public void SpawnMore()
{
// 이 시점의 enemyPrefab은 "씬에 한 번 생성된 복사본"이다.
// 새 적을 찍어내려 해도 프리팹 원본에 대한 참조를 잃음.
Instantiate(enemyPrefab);
}
}
참조 타입 대입은 같은 객체를 가리키는 포인터 복사입니다. enemyPrefab = Instantiate(...)라고 쓰는 순간, 원래 인스펙터가 꽂아준 프리팹 참조는 로컬 필드에서 사라지고 씬 인스턴스 참조가 들어앉습니다. 이후 Instantiate(enemyPrefab)은 프리팹이 아니라 이미 찍혀 나온 인스턴스를 다시 찍는 엉뚱한 동작을 합니다.
// ✅ 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을 한 줄로
// ❌ 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);
}
}
// ✅ 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>() 같은 할당 비용을 아끼는 것이 핵심이고요.
// 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) 이전에는 ?. 연산자를 대입의 좌변에 쓸 수 없었습니다.
// ❌ Before (C# 13 이전)
if (target != null)
target.Name = "Alice";
// ✅ After (C# 14+)
target?.Name = "Alice"; // target이 null이면 "Alice" 평가조차 생략
target?.Count += 1; // 복합 대입도 지원
중요한 규칙 두 가지입니다.
- 좌변이 null이면 우변은 평가되지 않습니다.
target?.Name = ExpensiveCompute();에서target이 null이면ExpensiveCompute()는 호출되지 않습니다. 부수 효과가 있는 우변을 넣을 때 주의가 필요합니다. - 전체 식의 값은
TResult?가 됩니다. 즉var r = target?.Name = "Alice";의r타입은string?.
Unity에서는 옵셔널 참조를 자주 다루기 때문에(GetComponent<T>() 결과, 프리팹의 transform.parent 등) null 체크 보일러플레이트를 줄이는 데 큰 도움이 됩니다.
// Unity 예시
GetComponent<Rigidbody>()?.velocity = Vector3.zero;
7. 정리
핵심을 다섯 가지로 압축합니다.
- [ ]
=은 표현식입니다. 값(좌변에 대입된 값)을 돌려주므로 조건식·반복 조건·체인 대입에서 활용할 수 있고, IL 레벨에서는dup으로 구현됩니다. - [ ]
x op= y는x = 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# 런타임의 저장소 모델 전체가 그 안에 담겨 있습니다. 다음 글(증감 연산자)에서도 같은 "표현식으로서의 연산자" 관점을 이어갑니다.
'C# 기초' 카테고리의 다른 글
| [PART3.연산자와 표현식(6/11)] 삼항(조건) 연산자 — `조건 ? A : B` (0) | 2026.04.25 |
|---|---|
| [PART3.연산자와 표현식(5/11)] 증감 연산자 — `++`, `--` (전위 vs 후위) (0) | 2026.04.25 |
| [PART3.연산자와 표현식(3/11)] 비트 연산자 — `& | ^ ~ << >> >>>`의 동작 원리와 Unity 실전 (0) | 2026.04.25 |
| [PART3.연산자와 표현식(2/11)] 단락 평가(Short-Circuit) — `&&`와 `||`가 뒤 조건을 평가 안 하는 경우 (0) | 2026.04.25 |
| [PART3.연산자와 표현식(1/11)] 산술·관계·논리 연산자 — 가장 흔하게 밟는 입문자 함정 정리 (0) | 2026.04.25 |