Featured image of post [Programming] n++ vs n = n + 1: 성능과 최적화의 진실

[Programming] n++ vs n = n + 1: 성능과 최적화의 진실

n++와 n = n + 1은 현대 컴파일러와 JIT에서 거의 동일한 기계어로 최적화된다. 오래된 속설의 배경, C++ 반복자에서 ++i 권장 이유, 멀티스레드 원자성, 올바른 마이크로벤치마크 방법과 성능 우선순위(알고리즘·캐시·메모리)를 정리한 프로그래밍 가이드이다.

개발 커뮤니티에서는 오래전부터 **"n++n = n + 1보다 빠르다"**는 속설이 회자됩니다. 그러나 현대 컴파일러와 런타임(JIT)은 두 표현을 거의 동일한 기계어(예: x86의 add reg, 1)로 낮추므로, 기본 정수형에서는 체감 가능한 성능 차이가 사실상 없습니다. 그럼에도 이 믿음이 남아 있는 이유는 과거 ISA 인코딩 차이, 플래그 처리, 그리고 언어별 의미론 차이에서 비롯된 오해가 적지 않기 때문입니다.

이 글에서는 속설의 배경(CPU/ISA 관점의 inc vs add, 컴파일러/JIT 최적화), 언어별 주의사항(C++ 반복자에서 ++i 권장 이유, 멀티스레드 원자성), 올바른 마이크로벤치마크 방법, 그리고 실무에서의 성능 우선순위(알고리즘·캐시·메모리 레이아웃)를 정리합니다. 핵심 요지는 하나입니다. 기본 정수형 증가에서는 연산자 형태가 성능을 좌우하지 않으며, 가독성과 코드 의미가 먼저이고, 진짜 성능은 더 큰 구조적 결정이 좌우합니다.


요약

  • 결론: 기본 정수형에서는 n++n = n + 1(또는 n += 1)이 현대 컴파일러/JIT에서 동일한 기계어로 최적화되어 성능 차이가 없다.
  • 이유: x86 등에서 모두 add reg, 1로 생성되는 경우가 일반적이며, 과거의 inc/dec 미세 차이는 플래그 처리 차이 때문에 오늘날엔 오히려 잘 쓰이지 않는다.
  • 예외: C++ 반복자·사용자 정의 타입에서는 i++(후위)가 이전 값 보존을 위해 불필요한 복사 비용이 생길 수 있어 **++i(전위)**를 권장한다.

소스 표현에서 기계어까지: 동일 최적화 흐름

같은 의미의 증가 연산은 현대 툴체인에서 하나의 기계어로 수렴합니다. 개념적으로는 아래와 같습니다.

flowchart LR
  subgraph source["소스 코드"]
    A["n++"]
    B["n += 1"]
    C["n = n + 1"]
  end
  Compiler["컴파일러
또는 JIT"] SameAsm["동일 기계어
add 한 번"] source --> Compiler Compiler --> SameAsm
  • 노드 ID: source, A, B, C, Compiler, SameAsm — 공백 없음, 예약어 미사용.
  • 라벨: 수식·등호가 있는 경우 큰따옴표로 감쌌고, 줄바꿈은 </br> 사용.

오래된 속설과 현대 최적화

과거에는 “n++가 더 빠르다"는 주장이 있었지만, 현대의 C/C++/Java/C#/Go/Rust 컴파일러와 런타임 JIT는 간단한 정수 증가를 동일한 수준의 기계어로 축약합니다. 특히 x86에서는 상태 플래그 처리 특성 때문에 inc보다 add가 더 일관된 선택이며, 대부분의 컴파일러가 **add reg, 1**을 선호합니다.


같은 의미면 같은 코드가 나온다

아래 C/C++ 예시는 최적화를 켠 경우 보통 동일한 코드로 컴파일됩니다.

1
2
3
4
5
6
7
8
9
int f(int n) {
  n++;
  return n;
}

int g(int n) {
  n = n + 1;
  return n;
}

둘 다 대개 레지스터에 대해 add 한 번으로 표현됩니다. Java/C# 같은 JIT 환경에서도 JIT 워밍업 후 핫 패스에 동일한 증분이 배치됩니다. 생성된 어셈블리는 Compiler Explorer에서 쉽게 확인할 수 있습니다.


언제 차이가 날 수 있나: C++ 반복자와 사용자 정의 타입

기본 정수형과 달리, 반복자나 사용자 정의 타입에서는 후위 증가(i++)가 이전 값을 반환해야 하므로 임시 객체 생성·복사 비용이 발생할 수 있습니다. 이 때문에 C++ 커뮤니티에서는 전위 증가(++i) 습관을 권장합니다. 단, 기본 정수형에서는 i++++i가 같은 코드가 되어 성능 차이는 없습니다.

flowchart TD
  IsFundamental{"기본 정수형
인가?"} UseEither["아무 것이나 사용
가독성 우선"] PreferPreInc["전위 증가 권장
++i"] IsFundamental -->|"예"| UseEither IsFundamental -->|"아니오"| PreferPreInc
1
2
3
4
// vector<int>::iterator에서 ++i 권장 관례
for (auto it = v.begin(); it != v.end(); ++it) {
  // ...
}

마이크로벤치마크 시 주의 사항

미세 차이를 직접 재려고 하면, 측정 자체가 더 큰 오차를 만듭니다. 올바른 방법은 다음과 같습니다.

항목권장 사항
최적화 옵션C/C++는 -O2/-O3 사용, 릴리즈 빌드에서 측정
워밍업JIT 언어(Java/C#)는 워밍업 후 steady-state 측정
프레임워크Java: JMH, .NET: BenchmarkDotNet, C++: Google Benchmark
소거 방지결과를 관측하거나 DoNotOptimize/ClobberMemory 등으로 DCE(dead code elimination) 방지

이 주제는 알고리즘 선택이나 메모리 접근 패턴 같은 큰 요인에 비해 영향이 미미합니다.


실무 추천 가이드

  • 기본 정수형: 가독성에 맞게 아무 것이나 사용해도 무방.
  • C++ 반복자/사용자 정의 타입: 관례적으로 ++i 사용.
  • 성능 최적화 우선순위: 자료구조 선택, 캐시 적중률, 분기 예측, 메모리 레이아웃 개선이 훨씬 중요.

CPU/ISA 관점: INC vs ADD, 왜 ADD가 보편적인가

현대 x86에서 정수 1 증가는 대개 **add reg, 1**로 표현됩니다. 과거에는 inc reg가 더 짧은 인코딩을 제공해 미세하게 유리하다는 인식이 있었지만, 최근 컴파일러는 아래 이유로 add를 선호합니다.

  • 플래그(FLAGS) 일관성: inc/dec는 **CF(Carry Flag)**를 변경하지 않지만, add/sub는 변경합니다. 많은 최적화와 패턴 매칭이 CF를 포함한 전체 플래그를 전제로 하므로 add가 더 예측 가능합니다.
  • 마이크로아키텍처: 일부 마이크로아키텍처에서 inc/dec는 부분 플래그 업데이트로 인해 플래그 의존성 추적이 까다로울 수 있습니다. add는 일반적으로 플래그 처리 경로가 잘 최적화되어 있습니다.
  • 다른 ISA: AArch64(ARM64)에는 별도 inc가 없고, 즉치수 더하기(add xN, xN, #1)로 표현합니다. 결국 “증가 = 더하기"가 보편적입니다.

정리하면, 오늘날 대부분의 컴파일러/어셈블러가 간결성과 일관성을 위해 add를 선택하고, 그 결과 n++, n += 1, n = n + 1같은 기계어로 수렴합니다.


컴파일러/JIT 관점: 어떤 코드가 생성되나

  • GCC/Clang/MSVC: 최적화(-O2/-O3)에서 단순 정수 증가를 add 한 번으로 내보냅니다. 인라이닝·레지스터 할당에 따라 메모리 대신 레지스터에서 수행됩니다.
  • HotSpot(Java), RyuJIT(.NET): JIT 워밍업 후 핫 루프에서 동일하게 “더하기 1"로 낮아집니다. 초기 인터프리터·tiered JIT 단계에서는 다소 다른 형태가 보일 수 있으나, steady-state에서는 동일해집니다.
  • 확인 방법: Compiler Explorer에서 생성 어셈블리를 바로 확인할 수 있습니다.

언어별 주의사항

  • C/C++: 서브식에서의 중복 수정/접근은 정의되지 않은 동작이 될 수 있습니다. 예: a = a++는 피해야 합니다. 반복자·사용자 정의 타입은 i++가 이전 값 보존을 위해 임시를 만들 수 있어 ++i 권장.
  • Java/C#: n++원자적이지 않습니다. 다중 스레드에서 원자적 증가가 필요하면 AtomicInteger.incrementAndGet()(Java), Interlocked.Increment 또는 Interlocked 계열(C#)을 사용하세요. 성능상 n++ vs n = n + 1 차이는 JIT 후 사실상 없습니다.
  • Go/Rust: Rust는 ++ 연산자가 없고 n += 1을 씁니다. Go는 n++가 문(statement)이며 표현식에 쓸 수 없습니다. 의미 차이를 이해하고 스타일 가이드를 따릅니다.
  • Python/JS: 동적·고수준 언어에서는 객체 불변성(Python의 int)과 런타임 오버헤드가 지배적이므로, 미시적 연산자 선택은 의미가 거의 없습니다.

마이크로벤치마크 템플릿

아래 예시들은 “같은 의미면 같은 코드가 나온다"를 직접 확인하는 데 도움을 줍니다. 릴리즈 빌드워밍업을 반드시 고려하세요.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
// Google Benchmark (C++)
#include <benchmark/benchmark.h>

static void BM_PostInc(benchmark::State& state) {
  int n = 0;
  for (auto _ : state) {
    benchmark::DoNotOptimize(n++);
  }
}
BENCHMARK(BM_PostInc);

static void BM_AddAssign(benchmark::State& state) {
  int n = 0;
  for (auto _ : state) {
    benchmark::DoNotOptimize(n = n + 1);
  }
}
BENCHMARK(BM_AddAssign);

BENCHMARK_MAIN();
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
// JMH (Java)
import org.openjdk.jmh.annotations.*;
import org.openjdk.jmh.infra.Blackhole;

@State(Scope.Thread)
public class IncBench {
  int n;

  @Benchmark
  public void postInc(Blackhole bh) { bh.consume(n++); }

  @Benchmark
  public void addAssign(Blackhole bh) { bh.consume(n = n + 1); }
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
// BenchmarkDotNet (.NET)
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;

public class IncBench {
  private int n;

  [Benchmark]
  public int PostInc() => n++;

  [Benchmark]
  public int AddAssign() => n = n + 1;
}

public class Program { public static void Main() => BenchmarkRunner.Run<IncBench>(); }

FAQ

질문답변
무엇이 더 빠른가?기본 정수형에서는 보통 동일하다. 컴파일러/JIT가 같은 기계어로 만든다.
그럼 왜 ++i가 권장되나?반복자/사용자 정의 타입에서 후위 증가가 임시를 만들 수 있기 때문이다. 정수형에는 영향이 거의 없다.
원자적 증가가 필요한가?언어별 원자 연산 API(Java AtomicInteger, C# Interlocked 등)를 사용하라. n++ 자체는 원자적이지 않다.
오버플로는?C/C++에서 부호 있는 정수 오버플로는 정의되지 않은 동작이다. 연산자 형태와 무관하게 주의해야 하며, 필요하면 더 넓은 타입이나 명시적 모듈러 연산을 고려하라.

핵심 정리

  • 같은 의미라면 오늘날 컴파일러/JIT는 같은 기계어로 수렴한다.
  • 반복자/사용자 정의 타입에서는 **++i**를 습관적으로 사용하자.
  • 마이크로 차이보다 알고리즘·캐시·메모리 레이아웃이 성능을 좌우한다.

참고 문헌

  1. Stack Overflow: Why does n++ execute faster than n=n+1? — 현대 컴파일러에서는 둘 다 동일한 어셈블리로 컴파일된다는 설명과 GCC 어셈블리 예시.
  2. Compiler Explorer (godbolt.org) — 다양한 컴파일러·옵션으로 C/C++/Rust 등 소스와 생성 기계어를 비교할 수 있는 도구.
  3. isocpp.org: Efficiency of postincrement v.s. preincrement in C++ — 기본 타입 vs 복잡 타입, 전위/후위 증가의 이론적·실질적 차이 정리.