반응형

[PART3.연산자와 표현식(8/11)] 연산자 우선순위와 결합성 — 괄호는 왜 거의 항상 옳은가

컴파일러가 식을 묶는 순서 / 좌결합과 우결합 / Unity 레이어 마스크·벡터 연산에서 터지는 함정


문제 제기 — 이 코드는 왜 컴파일 에러가 나는가

Unity에서 신입 개발자가 충돌 레이어를 체크하려고 다음과 같은 코드를 작성했습니다.

C#
// 의도: mask에 현재 layer 비트가 포함되어 있는지 확인
if (mask & 1 << gameObject.layer == 0)
{
    // 충돌하지 않는 레이어 처리
}

컴파일러는 다음과 같은 오류를 출력합니다.

CS0019: '&' 연산자는 'int' 및 'bool' 형식의 피연산자에 적용할 수 없습니다.

분명히 int끼리만 비트 연산을 했는데 왜 bool이 등장할까요. 원인은 컴파일러가 이 식을 개발자의 의도와 전혀 다르게 해석했기 때문입니다. 실제로 파싱된 식은 다음과 같습니다.

C#
mask & (1 << (gameObject.layer == 0))

==&보다 우선순위가 높고, <<&보다 높습니다. 그래서 gameObject.layer == 0이 먼저 평가되어 bool이 튀어나오고, 그 다음 bool로 시프트를 시도하다가 타입이 깨집니다.

이런 버그는 Unity의 레이어 마스크, 물리 계산, 벡터 연산에서 매일 발생합니다. 그리고 더 무서운 건, 운 좋게 컴파일이 되지만 런타임에 엉뚱한 값이 나오는 경우입니다. 컴파일러가 이런 식을 어떻게 해석하는지, 언제 괄호를 써야 하는지 알지 못하면 원인 모를 버그를 며칠씩 추적하게 됩니다.

이 글은 C#이 식을 어떻게 묶고 어떤 순서로 평가하는지, 그리고 신입 Unity 개발자가 실전에서 반드시 알아야 할 함정들을 다룹니다.


개념 정의 — 우선순위와 결합성이란 무엇인가

연산자 우선순위(precedence)와 결합성(associativity)은 "괄호가 없는 식"을 컴파일러가 일관되게 해석하도록 만드는 문법 규칙입니다.

비유로 이해하기

수학 시간에 배운 "곱셈·나눗셈 먼저, 덧셈·뺄셈 나중"이 바로 우선순위입니다. 3 + 4 × 53 + 20 = 23으로 계산하지 (3 + 4) × 5 = 35로 계산하지 않는 이유는, 곱셈의 우선순위가 덧셈보다 높다고 약속했기 때문입니다.

결합성은 같은 우선순위끼리 만났을 때의 방향을 정합니다. 8 ÷ 4 ÷ 2(8 ÷ 4) ÷ 2 = 1로 계산하지 8 ÷ (4 ÷ 2) = 4로 계산하지 않는 것은 왼쪽부터 묶는 좌결합 관례 때문입니다.

C#은 이 규칙을 더 넓은 연산자 집합(16개 레벨)에 대해 엄격하게 정의합니다.

시각화 — 우선순위 표

C# 연산자 우선순위 — 위에서 아래로 낮아짐

표를 외우려고 하지 않아도 됩니다. 핵심만 기억하면 됩니다.

  • 단항이 이항보다 높다-x * y(-x) * y.
  • 산술이 시프트보다 높다a + b << 1(a + b) << 1.
  • 시프트가 관계·동등보다 높다a << 1 == b(a << 1) == b.
  • 비트 연산은 비교보다 낮다a & b == ca & (b == c).
  • 논리 연산은 비트 연산보다 낮다a & b || c(a & b) || c.
  • 대입이 가장 낮다 — 식 전체를 계산한 뒤 마지막에 변수에 넣는다.

기본 코드로 확인

곱셈이 덧셈보다 우선순위가 높다는 사실을 눈으로 확인해 봅니다.

C#
public static int MulFirst(int a, int b, int c)
{
    return a + b * c;   // 컴파일러는 a + (b * c)로 해석
}

public static int AddFirst(int a, int b, int c)
{
    return (a + b) * c; // 괄호로 강제
}

a=2, b=3, c=4일 때:

  • MulFirst(2, 3, 4)2 + (3 * 4) = 14
  • AddFirst(2, 3, 4)(2 + 3) * 4 = 20

같은 숫자, 같은 연산자를 썼는데 결과가 다릅니다. 괄호가 없어도 우선순위가 결과를 결정한다는 증거입니다.


내부 동작 — 컴파일러는 어떻게 식을 해석하는가

우선순위와 결합성이 실제로 코드에 어떤 흔적을 남기는지는 IL(Intermediate Language, 중간 언어)을 보면 분명해집니다. IL은 C# 컴파일러가 생성하는 플랫폼 중립 바이트코드로, 나중에 JIT(Just-In-Time, 실행 직전 네이티브로 변환하는 컴파일러)이 CPU 명령어로 바꿉니다.

시각화 — 소스 코드에서 IL까지

1. 소스 코드

우선순위는 컴파일 시점에 AST(Abstract Syntax Tree, 추상 구문 트리)를 만들 때 단 한 번 적용됩니다. 트리의 모양이 결정되면, 컴파일러는 트리를 후위 순회(post-order)하면서 IL 명령어를 순서대로 방출합니다. 런타임은 이 순서를 그대로 따르기만 할 뿐 "어느 게 우선인가"를 다시 판단하지 않습니다.

곱셈 우선 vs 덧셈 우선 — IL로 증명

앞서 작성한 MulFirstAddFirst를 컴파일해 IL을 비교해 봅니다.

C#
public static int MulFirst(int a, int b, int c)
{
    return a + b * c;   // (b * c)가 먼저
}

public static int AddFirst(int a, int b, int c)
{
    return (a + b) * c; // (a + b)가 먼저
}
IL
.method public hidebysig static int32 MulFirst (int32 a, int32 b, int32 c) cil managed
{
    IL_0000: nop
    IL_0001: ldarg.0       // 스택: [a]
    IL_0002: ldarg.1       // 스택: [a, b]
    IL_0003: ldarg.2       // 스택: [a, b, c]
    IL_0004: mul           // 스택: [a, b*c]  ← 곱셈 먼저
    IL_0005: add           // 스택: [a+(b*c)] ← 덧셈 나중
    IL_0006: stloc.0
    IL_0009: ldloc.0
    IL_000a: ret
}

.method public hidebysig static int32 AddFirst (int32 a, int32 b, int32 c) cil managed
{
    IL_0000: nop
    IL_0001: ldarg.0       // 스택: [a]
    IL_0002: ldarg.1       // 스택: [a, b]
    IL_0003: add           // 스택: [a+b]     ← 덧셈 먼저
    IL_0004: ldarg.2       // 스택: [a+b, c]
    IL_0005: mul           // 스택: [(a+b)*c] ← 곱셈 나중
    IL_0006: stloc.0
    IL_0009: ldloc.0
    IL_000a: ret
}

IL 분석 포인트

1. 명령어 순서 자체가 "해석 결과"다

MulFirstldarg.0, ldarg.1, ldarg.2, mul, add 순서입니다. 스택에 a, b, c를 모두 쌓은 뒤 mul로 위에 있는 두 개(b, c)를 곱해 b*c를 남기고, 그 다음 addab*c를 더합니다. 괄호 없이 적었는데도 컴파일러가 알아서 b*c를 먼저 계산하도록 순서를 고정한 것입니다.

반면 AddFirstldarg.0, ldarg.1, add, ldarg.2, mul 순서입니다. 프로그래머가 괄호로 강제했기 때문에 컴파일러는 a+b를 먼저 만들고 뒤에 c를 곱합니다.

2. IL에는 "우선순위"라는 개념이 없다

두 메서드의 IL을 보면 .maxstack 값만 다를 뿐(MulFirst는 3, AddFirst는 2), 어느 한쪽도 "괄호 표시"나 "우선순위 힌트" 같은 것이 없습니다. 명령어가 등장하는 순서만 있습니다. 이것은 Unity가 사용하는 Mono 런타임이나 IL2CPP(Unity가 IL을 C++로 변환해 네이티브 컴파일하는 빌드 옵션)에서도 마찬가지입니다. 즉, 한 번 잘못된 우선순위로 컴파일된 코드는 어느 플랫폼에서 실행해도 똑같이 잘못된 결과를 냅니다.

3. Unity 핫패스에서의 의미

모바일 게임의 Update에서 Vector3 v = a + b * scalar; 같은 식은 매 프레임 수백 번 호출됩니다. IL 관점에서 보면 단순히 muladd 명령 두 개이지만, 연산자 오버로딩이 섞이면(Vector3 * float) 내부적으로는 Vector3.op_Multiply라는 정적 메서드 호출로 바뀝니다. 이때도 우선순위 규칙은 그대로 적용됩니다. 괄호를 잘못 넣거나 빼면 전혀 다른 IL이 생성되고, Unity Profiler 상에서 원인 모를 동작으로 나타납니다.


실전 적용 — 반드시 괄호를 붙여야 하는 상황

이론은 위에서 봤으니, 이제 실전에서 "언제 괄호를 쓰고 언제 안 써도 되는지" 판단 기준을 세웁니다.

기준 — "서로 다른 범주의 연산자가 섞이면 괄호"

같은 범주(모두 산술, 모두 비트, 모두 논리)의 연산자만 섞이면 괄호가 불필요한 경우가 많습니다. 반면 서로 다른 범주의 연산자가 섞이면 거의 항상 괄호를 쓰는 것이 옳습니다. 특히 아래 조합은 지뢰입니다.

위험한 조합 예시 실제 파싱 의도된 파싱
비트 & + 동등 == a & b == c a & (b == c) (a & b) == c
비트 `\ + 동등 ==` `a == 1 \ b == 2` `a == (1 \ b) == 2` `(a == 1) \ (b == 2)`
산술 + + 시프트 << a + b << 1 (a + b) << 1 a + (b << 1)
as + ?. obj as Foo?.Name 구문 오류 (obj as Foo)?.Name
삼항 중첩 a ? b : c ? d : e a ? b : (c ? d : e) 같음(우결합)

Before / After — 시프트와 덧셈이 섞인 함정

a + b << 1은 "b를 1비트 시프트한 뒤 a에 더한다"고 읽히기 쉽습니다. 실제는 다릅니다.

C#
// Before — 우선순위 실수로 다른 값이 나옴
public static int ShiftPitfall(int a, int b)
{
    return a + b << 1;      // 실제: (a + b) << 1로 파싱됨
}

// After — 괄호로 의도 고정
public static int ShiftIntended(int a, int b)
{
    return a + (b << 1);    // 의도: a + (b*2)
}

a=1, b=3일 때:

  • ShiftPitfall(1, 3)(1 + 3) << 1 = 4 << 1 = 8
  • ShiftIntended(1, 3)1 + (3 << 1) = 1 + 6 = 7

값이 아예 다릅니다. IL로 원인을 확인해 봅니다.

IL
// Before: ShiftPitfall
.method public hidebysig static int32 ShiftPitfall (int32 a, int32 b) cil managed
{
    IL_0001: ldarg.0       // [a]
    IL_0002: ldarg.1       // [a, b]
    IL_0003: add           // [a+b]        ← 덧셈이 먼저 들어감
    IL_0004: ldc.i4.1      // [a+b, 1]
    IL_0005: shl           // [(a+b) << 1] ← 시프트가 나중
    IL_0006: stloc.0
    IL_0009: ldloc.0
    IL_000a: ret
}

// After: ShiftIntended
.method public hidebysig static int32 ShiftIntended (int32 a, int32 b) cil managed
{
    IL_0001: ldarg.0       // [a]
    IL_0002: ldarg.1       // [a, b]
    IL_0003: ldc.i4.1      // [a, b, 1]
    IL_0004: shl           // [a, b<<1]    ← 시프트가 먼저
    IL_0005: add           // [a + b<<1]   ← 덧셈이 나중
    IL_0006: stloc.0
    IL_0009: ldloc.0
    IL_000a: ret
}

IL 분석 포인트

1. add vs shl의 배치 순서가 전부다

Before 버전은 addshl보다 먼저 등장합니다. 즉 "(a+b)를 만든 뒤 그것을 시프트"하는 IL입니다. After 버전은 반대로 shladd보다 먼저입니다. 한 번 IL로 방출된 순서는 런타임에 절대 뒤바뀌지 않습니다. 컴파일 시점에 우선순위를 잘못 읽은 순간 버그가 바이너리에 박혀 버립니다.

2. Unity에서 실제로 터지는 경우

비트 연산은 Unity의 레이어 마스크, 플래그 enum, 압축된 상태 비트 등에서 매일 쓰입니다. 예: int compactState = baseState + activeFlag << 3. 이 코드는 "activeFlag를 3비트 왼쪽으로 밀어 올린 뒤 baseState와 합친다"로 읽히지만, 실제로는 (baseState + activeFlag) << 3이 되어 전혀 엉뚱한 비트가 나옵니다. 게임 로직에서 상태 비트가 꼬이면 캐릭터가 갑자기 점프하지 못하거나, 저장 파일이 깨지거나, 네트워크 동기화가 무너집니다.

삼항 연산자의 우결합 — 의도적 활용

?: — 조건 연산자 (Ternary conditional operator) 조건이 참이면 콜론 앞 값을, 거짓이면 콜론 뒤 값을 반환한다. 우결합이므로 여러 개를 체인으로 이으면 오른쪽부터 묶인다.
예시: int sign = x > 0 ? 1 : x < 0 ? -1 : 0; x > 0 이면 1, 아니면 (x < 0 ? -1 : 0)이 평가되어 -1 또는 0.

삼항 연산자는 우결합이라, a ? b : c ? d : e는 자동으로 a ? b : (c ? d : e)가 됩니다.

C#
// 우결합을 활용한 분기 체인
public static string GradeLabel(int score)
{
    // score ≥ 90 → "A"
    // 80 ≤ score < 90 → "B"
    // 70 ≤ score < 80 → "C"
    // 그 외 → "F"
    return score >= 90 ? "A"
         : score >= 80 ? "B"
         : score >= 70 ? "C"
         :               "F";
}

이 코드는 우결합 덕분에 괄호 없이도 "위에서 아래로" 조건을 내려가며 평가됩니다. 하지만 세 단계를 넘기면 가독성이 떨어지니 switch 식을 고려하세요.

Unity 실전 — 레이어 마스크 조합

C#
// Before — 신입이 자주 쓰는 잘못된 형태
void CheckCollision(int mask, GameObject obj)
{
    // 의도: mask에 obj의 레이어 비트가 없으면 무시
    if (mask & 1 << obj.layer == 0)   // CS0019 컴파일 오류
    {
        return;
    }
}

// After — 괄호로 의도 명시
void CheckCollision(int mask, GameObject obj)
{
    if ((mask & (1 << obj.layer)) == 0)  // 명확하고 안전
    {
        return;
    }
}

Before 버전이 컴파일 오류가 나는 이유는 obj.layer == 0이 먼저 bool로 평가된 뒤 1 << bool 같은 존재하지 않는 연산을 시도하기 때문입니다. 운이 나빠 컴파일에 성공하는 구조라면 런타임에 엉뚱한 비트가 나와 충돌 검사가 무너집니다. After 버전은 괄호로 "시프트 먼저, 비트 AND 다음, 동등 비교 마지막"이라는 순서를 명시해 의도와 실제 동작이 일치합니다.


함정과 주의사항 — 신입이 자주 저지르는 실수

함정 1 — 연쇄 비교 1 < x < 10

수학 교과서에서 자주 보던 1 < x < 10 표기는 C#에서는 금지입니다. 컴파일러는 관계 연산자를 좌결합으로 파싱해 (1 < x) < 10으로 해석하고, 첫 비교의 결과가 bool이 되어 뒤의 < 10에서 타입이 깨집니다.

C#
// ❌ 잘못 — CS0019 컴파일 오류
bool InRange(int x)
{
    return 1 < x < 10;
}

// ✅ 올바름 — && 로 명시적으로 연결
bool InRange(int x)
{
    return 1 < x && x < 10;
}

// ✅ C# 9.0 이후 — 패턴 매칭 활용
bool InRange(int x)
{
    return x is > 1 and < 10;
}
is — 패턴 매칭 연산자 (Pattern matching) 값이 특정 패턴에 일치하는지 검사한다. C# 9.0부터 and, or, not 같은 논리 패턴과 >, < 같은 관계 패턴을 결합할 수 있다.
예시: if (x is > 0 and < 100) { ... } x가 0 초과 100 미만이면 참.

함정 2 — as 뒤의 ?. 호출

C#
// ❌ 잘못 — 구문 오류
string name = obj as Player?.Name;
// 파싱: obj as (Player?.Name)
// Player?.Name은 타입이 아니라 식이므로 as 오른쪽에 올 수 없음

// ✅ 올바름
string name = (obj as Player)?.Name;

?.는 Primary 레벨(1)이고 as는 관계·타입 레벨(6)입니다. 괄호가 없으면 컴파일러가 Player?.Name을 먼저 묶으려 시도하다 실패합니다. (obj as Player)로 캐스트 결과를 먼저 만든 뒤 null 조건부 접근을 해야 합니다.

함정 3 — 복합 대입 연산자와 식의 순서

C#
// ❌ 잘못 이해하기 쉬운 예
int[] arr = { 1, 2, 3 };
int i = 0;
arr[i++] += 10;

// 의도: arr[0]을 10만큼 증가 → { 11, 2, 3 }
// 실제도 그렇게 동작하지만, 후위 증가가 언제 일어나는지 헷갈림

C# 사양은 복합 대입 a[i++] += 10에서 i++한 번만 평가한다고 규정합니다. 따라서 위 코드는 arr[0] = arr[0] + 10; i = 1; 순서로 실행되어 의도대로 동작합니다. 하지만 C/C++ 출신 개발자는 후위 증가가 두 번 평가될 거라고 오해하기 쉽습니다. 이런 경우 명시적으로 분리하는 것이 안전합니다.

C#
// ✅ 올바름 — 의도가 명확한 분리
arr[i] += 10;
i++;

함정 4 — 단항 연산자의 우결합

C#
// 이중 부정으로 bool을 normalize
bool flag = !!someValue;   // !(!someValue)
// !가 우결합이라 오른쪽부터 평가: !(someValue) → !false → true

이 자체는 버그가 아니지만, !~x 같은 형태에서 헷갈립니다. 단항은 우결합이라 !~x!(~x)입니다. int의 비트 반전을 한 뒤 bool로 변환하려는 의도였다면 컴파일 오류가 나고, 작성자는 당황합니다.

Unity 함정 — 벡터 연산자 오버로딩

C#
// ❌ 잘못된 물리 공식 이해
Vector3 midpoint = a + b / 2;
// 파싱: a + (b / 2)
// 의도가 "두 점의 중점"이었다면 완전히 다른 결과

// ✅ 올바른 중점 공식
Vector3 midpoint = (a + b) / 2;

Vector3/ 연산자는 스칼라 나누기로 오버로딩되어 있지만, 우선순위는 int/와 똑같이 곱셈·나눗셈 레벨입니다. 덧셈보다 우선순위가 높기 때문에 괄호가 없으면 b만 2로 나눈 뒤 a에 더하게 됩니다. 캐릭터가 두 점 사이의 중간에 서야 할 때 엉뚱한 위치로 이동하는 버그가 여기서 나옵니다.


C# 버전별 변화 — 우선순위 체계는 어떻게 발전했는가

우선순위 규칙 자체는 C# 1.0부터 거의 그대로지만, 새로 추가된 연산자들이 어느 레벨에 끼어들었는지 살펴봅니다.

C# 6.0 — null 조건부 연산자 ?.

Primary 레벨에 추가되어 x.y와 같은 우선순위를 가집니다. 매우 높은 우선순위이므로 다른 연산자와 섞일 때 주의가 필요합니다.

C#
// C# 6.0 이전 — 수동 null 체크
string name = (player != null) ? player.Name : null;

// C# 6.0 이후
string name = player?.Name;  // ?.는 Primary, 우선순위 최고

C# 8.0 — null 병합 대입 ??=

대입 레벨(15)에 추가되었습니다. 우결합 규칙을 따르며, 기존 ?? 와 궁합이 잘 맞습니다.

C#
// Before (C# 7.0까지)
if (list == null) list = new List<int>();

// After (C# 8.0)
list ??= new List<int>();
??= — null 병합 대입 연산자 왼쪽 변수가 null일 때만 오른쪽 값을 대입한다. 기본값 초기화에 유용하다.
예시: cache ??= new Dictionary<string, int>(); cache가 null이면 새 Dictionary 할당, 아니면 그대로 유지.

C# 9.0 — 패턴 결합자 and, or, not

패턴 컨텍스트 안에서만 동작하는 새 키워드입니다. is 식 안에서 논리 연산자처럼 쓰입니다.

C#
// Before — && 로 범위 비교
if (x >= 1 && x <= 10) { ... }

// After — 패턴 결합자
if (x is >= 1 and <= 10) { ... }

// 복합 패턴
if (obj is Player { Health: > 0 } and not Bot) { ... }

C# 11 — 부호 없는 우측 시프트 >>>

기존 >>는 부호 있는 시프트라 음수에서 최상위 비트가 1로 채워집니다. >>>는 항상 0으로 채웁니다. 우선순위는 >>와 동일한 시프트 레벨.

C#
int x = -8;
int y1 = x >> 1;   // -4 (부호 유지)
int y2 = x >>> 1;  // 2147483644 (0으로 채움)

정리 — 새 연산자도 기존 레벨에 자연스럽게 편입

C# 언어 팀은 새 연산자를 추가할 때 기존 범주의 연장선상에 배치해 규칙이 깨지지 않도록 설계합니다. 그래서 "이 연산자는 어디쯤 들어가는가"는 대부분 직관적으로 맞습니다. 하지만 정확한 위치는 여전히 C# Language Specification을 확인하는 것이 원칙입니다.


정리 — 이것만 기억하라

  • 괄호는 거의 항상 옳다. 서로 다른 범주의 연산자가 섞이면 무조건 괄호를 쓴다. 성능 손실은 0, 가독성 이득은 크다.
  • 비트 & |는 동등 == !=보다 우선순위가 낮다. 비교 결과와 비트 연산을 섞으려면 반드시 괄호로 감싸야 한다. 이것이 Unity 레이어 마스크 버그의 최다 원인.
  • 산술 + -는 시프트 << >>보다 우선순위가 높다. 직관과 반대이므로 시프트가 섞이면 항상 괄호로 보호한다.
  • 관계 연산자는 연쇄 비교가 불가능하다. 1 < x < 10은 컴파일 오류. 1 < x && x < 10 또는 C# 9.0 이후 x is > 1 and < 10을 쓴다.
  • 대입과 조건(?:, ??)은 우결합이다. x = y = zx = (y = z), a ?? b ?? ca ?? (b ?? c). 직관대로 동작한다.
  • 우선순위는 컴파일 시점에 단 한 번 적용된다. AST가 만들어지면 IL에는 순서만 남고, 런타임은 그 순서를 뒤집지 않는다. 한 번 잘못 쓴 식은 절대 저절로 고쳐지지 않는다.
  • 실무 체크리스트:
    • [ ] 비트 연산(&, |, ^)과 비교(==, <)가 같은 식에 있으면 괄호.
    • [ ] 시프트(<<, >>)가 다른 산술과 섞이면 괄호.
    • [ ] as?.을 연달아 쓰면 캐스트 결과에 괄호.
    • [ ] Unity Vector3 / float 같은 오버로드 연산은 괄호로 그룹 표시.
    • [ ] 삼항 중첩이 세 단계 넘어가면 switch 식으로 전환.
  • Roslyn 스타일 규칙: 팀 .editorconfigdotnet_style_parentheses_in_arithmetic_binary_operators = always_for_clarity:suggestion 추가를 권장. 코드 리뷰에서 우선순위 논쟁이 사라진다.
반응형

+ Recent posts