[PART3.연산자와 표현식(10/11)] 사용자 정의 복합 할당 연산자 — +=·-= 를 직접 정의 (C# 14)
왜 C# 13까지는 += 가 항상 새 객체를 만들었는가 / C# 14 void operator += 로 in-place 수정을 돌려받는 방법 / Unity 벡터·컨테이너 연산에서의 GC 효과
목차
1. 문제 제기 — 매 프레임 벡터 덧셈이 힙에 남기는 흔적
Unity 에서 가장 흔한 코드 한 줄을 떠올려 봅시다.
void Update()
{
velocity += gravity * Time.deltaTime; // (1)
position += velocity * Time.deltaTime; // (2)
}
초당 60~144 프레임, 씬 안의 모든 MonoBehaviour 가 이런 연산을 여러 번 수행합니다. "그냥 더하는 건데 뭐가 문제인가" 싶지만, C# 13 까지는 += 가 새 Vector3 를 만들어 원래 변수에 복사해 넣는 구조로 돌아갑니다. 즉 매 프레임 수백 번의 임시 값 타입 복사가 발생합니다.
값 타입이면 힙 할당은 없으니 GC 와 무관한 것 아니냐고 생각할 수 있지만, 커스텀 컨테이너·큰 구조체로 시야를 넓히면 그림이 달라집니다. 업계에서 공개된 한 시뮬레이션은 같은 로직을 C# 13 방식으로 돌렸을 때 힙에 134 개의 임시 객체가 쌓였지만, C# 14 의 사용자 정의 += 를 사용하자 10 개로 줄었다고 보고했습니다. 객체 할당이 92 % 감소한 것이죠.
이 차이가 어디서 왔는지, 왜 C# 13까지는 그 차이를 만들 수 없었는지, C# 14 가 어떤 문법을 새로 열어 주었는지를 이 글에서 따라가 봅니다.
2. 개념 정의 — += 는 원래 "새 값을 만들어 바꿔치기"였다
2.1 비유 — 메모지와 화이트보드
+= 를 두 가지 방식으로 구현할 수 있습니다.
- 메모지 방식 (C# 13까지): 기존 메모지를 버리고, 새 메모지에 "기존 값 + 추가 값" 을 적은 뒤, 원래 자리에 새 메모지를 둡니다. 메모지가 매번 하나씩 소모됩니다.
- 화이트보드 방식 (C# 14 신규): 같은 화이트보드 위에 새로 계산한 값으로 덮어 씁니다. 화이트보드는 하나뿐이고, 메모지는 쓰이지 않습니다.
C# 13 까지는 이 "메모지 방식" 만 허용되었고, C# 14 부터 "화이트보드 방식" 을 직접 선택할 수 있게 되었습니다.
2.2 시각화 — 두 방식의 메모리 흐름

2.3 기본 C# 코드 — C# 13 방식 (자동 파생)
// C# 13 까지: operator + 만 정의하면 += 는 자동으로 제공됨
public struct Vec13
{
public float X, Y, Z;
public Vec13(float x, float y, float z) { X = x; Y = y; Z = z; }
public static Vec13 operator +(Vec13 a, Vec13 b)
=> new Vec13(a.X + b.X, a.Y + b.Y, a.Z + b.Z);
}
var v = new Vec13(1, 2, 3);
var d = new Vec13(0.1f, 0.2f, 0.3f);
v += d; // 컴파일러가 v = v + d 로 확장
operator +— 이항 산술 연산자 오버로딩 사용자 정의 타입에+를 적용할 때 호출되는static메서드다. 반환 타입은 보통 타입 자신이며, 새 인스턴스를 만들어 돌려준다.
예시:public static Vec operator +(Vec a, Vec b) => new(a.X+b.X, a.Y+b.Y, a.Z+b.Z);두Vec을 받아 새Vec을 반환
2.4 기본 C# 코드 — C# 14 방식 (직접 정의)
// C# 14: += 를 별도로, 인스턴스 메서드로 직접 정의
public struct Vec14
{
public float X, Y, Z;
public Vec14(float x, float y, float z) { X = x; Y = y; Z = z; }
public static Vec14 operator +(Vec14 a, Vec14 b)
=> new Vec14(a.X + b.X, a.Y + b.Y, a.Z + b.Z);
// static 없음, 반환 타입 void — 인스턴스 자신을 직접 수정
public void operator +=(Vec14 other)
{
X += other.X;
Y += other.Y;
Z += other.Z;
}
}
var v = new Vec14(1, 2, 3);
var d = new Vec14(0.1f, 0.2f, 0.3f);
v += d; // 컴파일러가 v.op_AdditionAssignment(d) 로 바로 디스패치
void operator +=— 사용자 정의 복합 할당 연산자 (User-defined compound assignment operator, C# 14)static이 아닌 인스턴스 메서드로 선언하고 반환 타입을void로 지정한다.this를 직접 수정하는 방식으로+=의 의미를 재정의한다.a = a + b로 확장되지 않는다.
예시:public void operator +=(Vec other) { X += other.X; Y += other.Y; }this의 필드를 그 자리에서 갱신
2.5 쉬운 설명
- C# 13까지:
+=는 "덧셈 결과로 변수를 바꿔치기" 다. - C# 14 부터:
+=를 "내 상태를 바로 갱신하는 연산" 으로 바꿀 수 있다. - 바뀌는 건 의미 가 아니라 구현 전략 이다. 외부에서 보는 결과 값은 같아야 한다.
2.6 기술 정의
사용자 정의 복합 할당 연산자는 컴파일러가 a += b 등의 복합 할당 표현식을 만났을 때, 타입에 직접 정의된 op_AdditionAssignment (및 같은 계열) 인스턴스 메서드를 호출하도록 지시하는 C# 14 의 새 선언 형태입니다. 반환 타입은 반드시 void 이며, 메서드는 this 를 변경해 결과를 내보냅니다. 해당 메서드가 정의되어 있지 않으면 컴파일러는 기존 규칙대로 a = a + b 로 확장해 처리합니다.
3. 내부 동작 — IL 이 스택 위에서 무엇을 하는가
3.1 IL 차이의 한눈 비교

3.2 실제 컴파일 결과 — C# 13 방식
다음 C# 을 .NET 10 · LangVersion=preview 로 컴파일해 ilspycmd 로 디스어셈블한 결과입니다.
public static void UseVec13()
{
var v = new Vec13(1, 2, 3);
var d = new Vec13(0.1f, 0.2f, 0.3f);
v += d;
}
.method public hidebysig static void UseVec13 () cil managed
{
.locals init (
[0] valuetype Vec13
)
// v = new Vec13(1, 2, 3)
IL_0000: ldc.r4 1
IL_0005: ldc.r4 2
IL_000a: ldc.r4 3
IL_000f: newobj instance void Vec13::.ctor(float32, float32, float32)
IL_0014: ldloca.s 0
// d = new Vec13(0.1, 0.2, 0.3) — 그 위에 이어짐
IL_0016: ldc.r4 0.1
IL_001b: ldc.r4 0.2
IL_0020: ldc.r4 0.3
IL_0025: call instance void Vec13::.ctor(float32, float32, float32)
// v + d 그리고 v 에 재저장
IL_002a: ldloc.0 // v 를 스택에 복사
IL_002b: call valuetype Vec13 Vec13::op_Addition(valuetype Vec13, valuetype Vec13)
IL_0030: pop // (예시에서는 결과를 저장하지 않고 버리는 호출 지점)
IL_0031: ret
}
핵심 관찰:
op_Addition은static이며 값 두 개를 받아 값 하나를 반환 합니다.- 호출 사이트에서 결과를 다시 로컬에 되돌리려면
stloc이 한 번 더 필요합니다. (위 예시는 결과를 사용하지 않아pop으로 대체됨.) +=한 번마다 임시Vec13이 IL 스택에 만들어집니다.
3.3 실제 컴파일 결과 — C# 14 방식
같은 로직을 C# 14 방식으로 선언하고 v += d 를 한 IL.
.method public hidebysig specialname
instance void op_AdditionAssignment (valuetype Vec14 other) cil managed
{
.custom instance void System.Runtime.CompilerServices.CompilerFeatureRequiredAttribute::.ctor(string)
= ( "UserDefinedCompoundAssignmentOperators" )
// X += other.X
IL_0000: ldarg.0 // this 포인터
IL_0001: ldarg.0
IL_0002: ldfld float32 Vec14::X
IL_0007: ldarg.1
IL_0008: ldfld float32 Vec14::X
IL_000d: add
IL_000e: stfld float32 Vec14::X // this.X 에 바로 기록
// Y, Z 도 같은 패턴 (생략)
IL_0039: ret
}
.method public hidebysig static void UseVec14 () cil managed
{
.locals init (
[0] valuetype Vec14,
[1] valuetype Vec14
)
// v, d 초기화
IL_0000: ldloca.s 0
IL_0011: call instance void Vec14::.ctor(float32, float32, float32)
IL_0016: ldloca.s 1
IL_0027: call instance void Vec14::.ctor(float32, float32, float32)
// v += d
IL_002c: ldloca.s 0 // v 의 주소만 로드
IL_002e: ldloc.1 // d
IL_002f: call instance void Vec14::op_AdditionAssignment(valuetype Vec14)
IL_0034: ret
}
해설:
- 메서드 이름은
op_AdditionAssignment이며, 컴파일러가[CompilerFeatureRequired("UserDefinedCompoundAssignmentOperators")]를 자동으로 붙입니다. 이 속성은 "이 멤버를 이해하려면 C# 14 이상 컴파일러여야 한다" 는 메타데이터 표시로, 구버전 컴파일러가 실수로 호출하지 못하게 막는 안전 장치입니다. - 호출 사이트는
ldloca.s 0으로 주소만 올립니다.stloc이 없습니다. - 메서드 본문은
this를 받아stfld로 필드 세 개를 직접 갱신합니다. 반환값은 없습니다. - 결과적으로 임시
Vec14가 만들어지지 않고,+=이 "원본 인스턴스를 직접 수정" 으로 귀결됩니다.
3.4 디스패치 규칙
컴파일러는 a += b 를 만나면 다음 순서로 메서드를 찾습니다.
a의 타입에op_AdditionAssignment가 정의되어 있는가?- 있으면 그것을 호출.
- 없으면 기존 방식대로
a = a + b로 확장.- 이 경로에서
op_Addition이 필요하며, 없으면 컴파일 에러.
- 이 경로에서
두 가지가 모두 정의되어 있어도 C# 14 경로가 우선 선택됩니다. 같은 타입에 operator + 와 void operator += 를 함께 정의하는 것도 허용됩니다. 이때 두 경로가 외부에서 보기에 동일한 의미를 갖도록 맞춰 주는 것은 구현자의 몫입니다.
3.5 대상 연산자 전체
모든 복합 할당 연산자에 void operator X= 형식을 쓸 수 있고, 산술·시프트에는 checked 버전을 추가로 선언할 수 있습니다.
| 연산자 | 인스턴스 메서드 이름 | checked 변종 |
|
|---|---|---|---|
+= |
op_AdditionAssignment |
op_CheckedAdditionAssignment |
|
-= |
op_SubtractionAssignment |
op_CheckedSubtractionAssignment |
|
*= |
op_MultiplyAssignment |
op_CheckedMultiplyAssignment |
|
/= |
op_DivisionAssignment |
op_CheckedDivisionAssignment |
|
%= |
op_ModulusAssignment |
op_CheckedModulusAssignment |
|
&= |
op_BitwiseAndAssignment |
없음 | |
| `\ | =` | op_BitwiseOrAssignment |
없음 |
^= |
op_ExclusiveOrAssignment |
없음 | |
<<= |
op_LeftShiftAssignment |
없음 | |
>>= |
op_RightShiftAssignment |
없음 | |
>>>= |
op_UnsignedRightShiftAssignment |
없음 |
비트 연산자는 오버플로 개념이 없으므로 checked 변종이 존재하지 않습니다.
operator checked +=— checked 복합 할당 연산자 (C# 14)checked컨텍스트 안에서+=가 사용될 때 호출되도록 별도로 선언하는 변종이다. 오버플로가 발생하면OverflowException을 던지도록 구현한다. 일반void operator +=가 "정상 모드" 구현이라면 이것은 "안전 모드" 구현이다.
예시:public void operator checked +=(Money b) { Cents = checked(Cents + b.Cents); }checked { m += x; }블록 안에서 이 버전이 선택됨
3.6 checked 버전 IL 도 실제로 확인됨
public struct Money
{
public int Cents;
public void operator +=(Money b) { Cents += b.Cents; } // add
public void operator checked +=(Money b) { Cents = checked(Cents + b.Cents); } // add.ovf
}
.method public hidebysig specialname
instance void op_AdditionAssignment (valuetype Money other)
{
IL_000d: add // 오버플로 감지 없음
IL_000e: stfld int32 Money::Cents
}
.method public hidebysig specialname
instance void op_CheckedAdditionAssignment (valuetype Money other)
{
IL_000d: add.ovf // 오버플로 시 OverflowException
IL_000e: stfld int32 Money::Cents
}
checked { m += x; } 블록 안에서 m 이 Money 타입이면 컴파일러가 op_CheckedAdditionAssignment 를 선택합니다. unchecked 또는 기본 컨텍스트에서는 op_AdditionAssignment 를 선택합니다. add 와 add.ovf 의 IL 명령어 차이가 그대로 반영되는 점이 C# 11 의 operator checked + 와 동일한 방식입니다.
4. 실전 적용 — Before / After 와 Unity 핫패스
4.1 Before — 누적 컨테이너가 매번 새 객체를 만든다
// Before: operator + 만 정의 → += 는 자동 파생
public class Basket
{
public List<string> Items = new();
public static Basket operator +(Basket a, string item)
{
var copy = new Basket();
copy.Items.AddRange(a.Items); // 내부 List 도 새로 생성
copy.Items.Add(item);
return copy;
}
}
var basket = new Basket();
foreach (var item in stream) // 1,000 회 반복
{
basket += item; // 매 반복마다 새 Basket + 새 List
}
매 루프마다 Basket 과 내부 List<string> 이 힙에 새로 할당됩니다. 1,000 회 반복이면 최소 2,000 개 의 관리 객체가 생겼다 사라집니다. 그만큼 Gen0 GC(Garbage Collector 의 0세대 수집, 가장 수명이 짧은 객체들을 회수하는 단계) 가 자주 호출됩니다.
List<T>내부 재할당List<T>.AddRange는 대상 리스트의 내부 배열 용량이 모자라면 현재 크기의 두 배로 새 배열을 만들고 기존 요소를 복사한다. 여기서 "매번 새 Basket" 이 만들어진다는 건 매번 새List<string>과 그 내부 배열도 새로 만들어진다는 뜻이다.
4.2 Before 쪽 IL 에서 드러나는 할당
public static void AppendBefore(Basket basket, string item)
{
basket += item;
}
// 간단화한 IL
IL_0000: ldarg.0 // basket
IL_0001: ldarg.1 // item
IL_0002: call class Basket Basket::op_Addition(class Basket, string)
IL_0007: starg.s basket // basket = 반환값
IL_0009: ret
// op_Addition 내부
// newobj Basket::.ctor() ← 새 Basket
// newobj List<string>::.ctor() ← 새 List
// ... AddRange / Add
핵심은 newobj 가 두 번 찍힌다는 점입니다. newobj 는 힙에 새 객체를 할당합니다. 반복 호출되면 그만큼 Gen0 가 가득 차고, GC 가 더 자주 실행됩니다.
4.3 After — void operator += 로 in-place 추가
public class Basket
{
public List<string> Items = new();
// 같은 Basket 인스턴스에 직접 추가
public void operator +=(string item)
{
Items.Add(item);
}
}
var basket = new Basket();
foreach (var item in stream)
{
basket += item; // 매 반복마다 Items.Add 만 호출
}
1,000 번 반복해도 Basket 인스턴스는 하나, 내부 List 도 하나 입니다. List.Add 가 내부 배열을 확장할 때 생기는 재할당을 빼면 힙 할당이 사실상 사라집니다. 공개된 벤치마크에서 보고된 "객체 할당 92% 감소" 의 근본 원인이 바로 이 차이입니다.
4.4 After 쪽 IL — newobj 가 사라진다
public static void AppendAfter(Basket basket, string item)
{
basket += item;
}
IL_0000: ldarg.0 // basket
IL_0001: ldarg.1 // item
IL_0002: callvirt instance void Basket::op_AdditionAssignment(string)
IL_0007: ret
newobj가 없습니다. 기존basket인스턴스의 메서드를callvirt로 호출할 뿐입니다.starg도 없습니다.basket을 다시 덮어쓸 필요가 없기 때문입니다.- 클래스(참조 타입)는
call이 아니라callvirt를 통해 호출되는 점이 자연스럽습니다. 구조체(값 타입)는call이 쓰입니다.
4.5 Unity Vector3 핫패스 — 필드 vs 프로퍼티
Vector3 가 언젠가 C# 14 += 를 도입한다고 가정하고 두 패턴을 비교해 봅시다.
필드 기반 (이득 있음)
public class PlayerMover : MonoBehaviour
{
Vector3 velocity; // 인스턴스 필드
[SerializeField] Vector3 gravity = new(0, -9.81f, 0);
void Update()
{
velocity += gravity * Time.deltaTime; // 필드에 직접 += → in-place
}
}
velocity 는 필드이므로 컴파일러가 ldflda (필드 주소 로드) 로 주소를 만들 수 있습니다. Vector3 에 op_AdditionAssignment 가 있다면 그 주소로 직접 호출 되고, 임시 Vector3 복사가 사라집니다.
Transform.position 기반 (이득 제한)
void Update()
{
transform.position += Vector3.forward * speed * Time.deltaTime;
}
Transform.position 은 프로퍼티 입니다. 프로퍼티 대상의 += 는 다음처럼 확장됩니다.
var tmp = transform.get_position(); // 복사본 얻기 (반드시 발생)
tmp += Vector3.forward * speed * Time.deltaTime; // 임시 변수에 +=
transform.set_position(tmp); // 다시 write (엔진 쪽 반영)
Vector3 가 op_AdditionAssignment 를 제공하더라도 한가운데의 += 한 번 만 in-place 가 되고, 앞뒤의 get/set 복사는 그대로 남습니다. 게다가 C# 14 시점에서도 "프로퍼티 좌변일 때 사용자 정의 += 를 호출할지" 자체는 언어 사양상 특별 규칙을 따르는데, 프로퍼티는 주소를 만들 수 없기 때문에 일반적으로 이 경로를 탈 수 없고, 기존 a = a + b 로 fallback 됩니다.
결론: Transform.position 같은 프로퍼티를 좌변에 둔 += 는 C# 14 혜택이 사실상 없습니다. 혜택을 받으려면 좌변이 필드 또는 ref 지역 변수여야 합니다.
4.6 Unity 실전 체크리스트
Vector3 velocity같은 필드는 혜택 직행.transform.position += delta는 여전히 get/set 복사 발생 — 호출 횟수를 줄이는 게 더 중요.List<T>·커스텀 버퍼 같은 가변 컨테이너의 누적 루프에서 GC 감소 효과가 크다.- IL2CPP(Unity 의 AOT 변환기, IL 을 C++ 로 번역해 네이티브로 컴파일) 로 빌드해도 IL → C++ 변환 시 같은 디스패치 결정이 유지된다. C++ 코드 상에서는 이 메서드가 인라인되기 쉬워 성능 혜택이 더 커질 수 있다.
5. 함정과 주의사항
5.1 좌변이 읽기 전용이면 둘 다 컴파일 에러다
readonly 필드·in 파라미터·readonly 지역변수는 수정할 수 없습니다. 사용자 정의 += 든 기존 a = a + b 든 결국 대입이 일어나야 하므로 두 경로 모두 막힙니다.
// ❌ readonly 필드를 수정 시도
public class Counter
{
public readonly Vec Position; // readonly 필드
public void AddDelta(Vec d)
{
Position += d; // CS0191 / CS8331 — readonly 필드 수정 불가
}
}
// ✅ 수정 가능한 필드로 두거나, 생성자 안에서만 초기화
public class Counter
{
public Vec Position; // 수정 가능
public void AddDelta(Vec d)
{
Position += d;
}
}
5.2 readonly struct 는 선언 자체는 가능하지만 필드를 못 고친다 (실제 검증)
직관적으로 "readonly struct 에는 void operator += 선언 자체가 막혀야 한다" 고 생각하기 쉽지만, 실제 .NET 10 preview 컴파일러로 확인한 결과는 다음과 같습니다.
// 선언은 통과됨
public readonly struct RoVec
{
public readonly float X;
public RoVec(float x) { X = x; }
public void operator +=(RoVec other) { } // ✅ 컴파일 통과 (본문이 비어 있음)
}
그러나 본문에서 필드를 수정하려 하면 거기서 막힙니다.
public readonly struct RoVec
{
public readonly float X;
public RoVec(float x) { X = x; }
public void operator +=(RoVec other)
{
X += other.X; // ❌ error CS0191: 읽기 전용 필드에는 할당할 수 없습니다
}
}
즉 "readonly struct 에는 선언 불가" 라는 단정은 이 컴파일러 버전에서 정확하지 않습니다. 정확한 표현은 "readonly struct 안에서 void operator += 로 상태를 바꾸는 구현이 불가능" 입니다. 결국 쓸모 있는 형태로는 정의할 수 없기 때문에 실무적으로는 "readonly struct 에는 사용하지 않는다" 가 맞는 결론이지만, 컴파일 에러 문구는 다르게 나옵니다.
관찰 사실: .NET 10 preview 컴파일러 기준. 정식 릴리스에서 더 엄격한 검사로 바뀔 가능성은 있음(추정).
5.3 의미 충돌 — + 와 += 가 다른 결과를 내면 안 된다
// ❌ 의미가 깨지는 구현
public struct Counter
{
public int N;
public static Counter operator +(Counter a, int delta) => new Counter { N = a.N + delta };
public void operator +=(int delta) { N += delta * 2; } // 왜?? +=의 의미를 자의적으로 바꿈
}
var c = new Counter { N = 10 };
var x = c + 5; // x.N == 15
c += 5; // c.N == 20 — 외부에서 두 식이 다른 결과를 낸다
사용자는 c += 5 가 c = c + 5 와 같은 결과를 낼 것이라 기대합니다. 양쪽 경로의 관찰 가능한 결과를 일치시키는 것은 구현자의 책임입니다. 다르게 동작하면 디버깅이 악몽이 됩니다.
5.4 프로퍼티 좌변에서는 여전히 get/set 복사가 남는다
4.5 에서 본 것처럼 transform.position += delta 는 C# 14 가 끼어들 자리가 없습니다. "프로퍼티 좌변 = 자동 파생 fallback" 이라고 기억해 두면 됩니다.
// ❌ 혜택이 없다고 오해하면 안 되는 케이스
transform.position += delta; // get → 임시에 += → set. get/set 복사 상수 2회
// ✅ 필드 또는 지역 변수에 누적 후 한 번만 set
var pos = transform.position;
pos += delta; // 지역변수면 op_AdditionAssignment 경로 가능
transform.position = pos;
5.5 this 부수효과 — 스레드 안전성 착각 금지
void operator += 는 결국 this 를 수정하는 일반 인스턴스 메서드입니다. 여러 스레드가 같은 인스턴스를 동시에 += 하면 경쟁 조건이 생깁니다. Interlocked 나 락 없이 "+= 니까 원자적이겠지" 라고 생각하면 안 됩니다.
// ❌ 멀티스레드에서 안전하지 않음
public class Counter
{
public int N;
public void operator +=(int d) => N += d;
}
// 여러 스레드가 동일 counter 에 += → 잃어버린 갱신 발생
6. C# 버전별 변화
C# 1.0 ~ C# 10
이항 연산자(+, -, *, ...) 를 static 으로만 정의할 수 있었습니다. 복합 할당 += 는 항상 a = a + b 로 자동 파생됩니다. 값 기반 의미론 고정.
C# 11 — operator checked 등장
이항 연산자에 대해 operator checked + 같은 "안전 모드" 변종을 정의할 수 있게 되었습니다. 이때 IL 메서드 이름은 op_CheckedAddition 등이 됩니다. 다만 복합 할당 자체는 여전히 자동 파생입니다.
// C# 11
public struct Money
{
public static Money operator +(Money a, Money b) => ...;
public static Money operator checked +(Money a, Money b) => ...; // C# 11
}
C# 14 — 복합 할당 연산자 자체를 선언 가능
void operator +=(...) 형식이 언어에 들어왔습니다. this 를 in-place 수정하는 의미를 명시적으로 표현할 수 있고, 컴파일러가 op_AdditionAssignment 를 우선 선택합니다. operator checked += 도 함께 선언 가능합니다.
// C# 14
public struct Money
{
public static Money operator +(Money a, Money b) => ...;
public static Money operator checked +(Money a, Money b) => ...;
public void operator +=(Money b) => Cents += b.Cents; // NEW
public void operator checked +=(Money b) => Cents = checked(Cents + b.Cents); // NEW
}
Before / After 한눈 요약
// Before (C# 13)
public struct Vec { public float X;
public static Vec operator +(Vec a, Vec b) => new Vec { X = a.X + b.X };
}
// v += d → v = v + d → 임시 Vec 생성 → v 에 stloc
// After (C# 14)
public struct Vec { public float X;
public static Vec operator +(Vec a, Vec b) => new Vec { X = a.X + b.X };
public void operator +=(Vec other) => X += other.X;
}
// v += d → v.op_AdditionAssignment(d) → stfld 로 X 직접 갱신
operator + 를 남겨두는 이유: var sum = a + b; 같은 이항 식 에서는 여전히 op_Addition 이 필요하기 때문입니다. += 는 += 만의 경로를 따로 쓰지만, 이항 + 를 대체하지는 않습니다.
7. 정리
이것만 기억하면 충분합니다.
- C# 14 부터
+= · -= · *= ...를 인스턴스 메서드(void operator +=) 로 직접 정의할 수 있다.static이 아니고, 반환 타입은void다. - 컴파일러는 타입에 사용자 정의 복합 할당 연산자가 있으면 그것을 우선 호출하고, 없으면
a = a + b로 확장한다 (자동 파생). - IL 이름은
op_AdditionAssignment·op_SubtractionAssignment·... 이며, 산술·시프트 계열은op_Checked...Assignment변종도 쓸 수 있다. - 호출 사이트에서 값 타입은
ldloca로 주소가, 참조 타입은 참조 자체가 넘어가므로 임시 인스턴스 없이 in-place 로 수정 된다. 큰 값 타입·누적 컨테이너에서 할당·복사 비용이 의미 있게 감소한다. - 좌변이
readonly·in·readonly struct필드·프로퍼티면 C# 14 경로를 탈 수 없다. 특히transform.position += delta는 여전히 get/set 복사가 일어나므로 성능 혜택 없음. +와+=를 동시에 제공할 때는 두 경로의 결과 값이 반드시 같아야 한다. 의미를 자의적으로 바꾸면 디버깅 재앙이다.- Unity 에서 실익이 가장 큰 곳은
MonoBehaviour필드 형태의Vector3/Matrix4x4누적 과 가변 컨테이너(커스텀List·버퍼) 의+=루프 다.transform.*같은 프로퍼티는 혜택 대상이 아님을 기억하자.
'C# 기초' 카테고리의 다른 글
| [PART4.제어문: 분기와 반복(1/13)] if · else if · else (0) | 2026.04.26 |
|---|---|
| [PART3.연산자와 표현식(11/11)] 문자열 보간과 기본 포맷팅 — `$"..."` 한 줄이 만들어내는 차이 (0) | 2026.04.25 |
| [PART3.연산자와 표현식(9/11)] checked · unchecked — 정수 오버플로 검사 제어 (0) | 2026.04.25 |
| [PART3.연산자와 표현식(8/11)] 연산자 우선순위와 결합성 — 괄호는 왜 거의 항상 옳은가 (0) | 2026.04.25 |
| [PART3.연산자와 표현식(7/11)] null 관련 연산자 — `?.` · `??` · `??=` · Null 조건부 대입(C# 14) (0) | 2026.04.25 |