AtomicInteger는 어떻게 동시성을 보장할까? - CAS 알고리즘 깊이 파헤치기

2026. 1. 14. 18:57·Back end/Java
반응형

들어가며

이런 질문을 받았습니다.

"일반 int에 synchronized를 걸지 않고 100개 스레드가 각각 +1을 하면 결과가 100이 안 나올 수 있습니다. 하지만 AtomicInteger.addAndGet()을 사용하면 락 없이도 정확히 100이 보장됩니다. 어떻게 이게 가능한가요?

당시에는 제대로 답변하지 못했지만, 이후 학습을 통해 그 원리를 정리해보았습니다.


문제 상황: Race Condition

먼저 문제를 명확히 해봅시다.

// 문제가 되는 코드
private int counter = 0;

// 100개의 스레드가 동시에 실행
public void increment() {
    counter++; // 이 한 줄이 사실은 3개의 작업!
}

counter++는 원자적(atomic) 연산이 아닙니다. 실제로는:

  1. READ: 메모리에서 counter 값을 읽음
  2. MODIFY: 값을 1 증가
  3. WRITE: 증가된 값을 메모리에 씀

두 스레드가 동시에 실행되면:

Thread A: READ(0) -> MODIFY(1) -> WRITE(1)
Thread B: READ(0) -> MODIFY(1) -> WRITE(1)
결과: 2가 아니라 1!

기존 해결책: synchronized와 Lock

synchronized 방식

private int counter = 0;

public synchronized void increment() {
    counter++;
}

장점: 안전하고 간단합니다.

단점: 성능 오버헤드가 큽니다.

  • 스레드가 락을 획득하지 못하면 블로킹됨
  • 컨텍스트 스위칭 비용 발생
  • 경쟁이 심할수록 성능 저하

AtomicInteger의 마법: CAS (Compare-And-Swap)

AtomicInteger는 락을 사용하지 않고도 동시성을 보장합니다. 그 비밀은 CAS 알고리즘입니다.

CAS란?

CAS는 하드웨어 레벨에서 지원하는 원자적 연산입니다.

CAS(메모리_주소, 예상값, 새값) {
    if (메모리_주소의_값 == 예상값) {
        메모리_주소의_값 = 새값;
        return true;
    } else {
        return false;
    }
}

이 비교와 교환이 하나의 CPU 명령어로 수행되므로 중간에 다른 스레드가 끼어들 수 없습니다.

AtomicInteger 내부 구조

public class AtomicInteger extends Number implements java.io.Serializable {
    private static final Unsafe U = Unsafe.getUnsafe();
    private static final long VALUE
        = U.objectFieldOffset(AtomicInteger.class, "value");

    private volatile int value;
    
    public final int addAndGet(int delta) {
        return U.getAndAddInt(this, VALUE, delta) + delta;
    }
}

// Unsafe 클래스의 실제 구현
public final class Unsafe {
    @IntrinsicCandidate
    public final int getAndAddInt(Object o, long offset, int delta) {
        int v;
        do {
            v = getIntVolatile(o, offset);
        } while (!weakCompareAndSetInt(o, offset, v, v + delta));
        return v;
    }
}

주요 포인트:

  • getIntVolatile(): 객체 o의 offset 위치에서 volatile 읽기 수행
  • weakCompareAndSetInt(): CAS 연산 수행
  • @IntrinsicCandidate: JVM이 이 메서드를 네이티브 CPU 명령어로 대체할 수 있음
  • 메모리 offset을 직접 사용하여 성능 최적화

동작 과정 예시

100개 스레드가 동시에 addAndGet(1)을 호출할 때:

Thread A:
1. current = 0을 읽음
2. next = 1 계산
3. CAS(0, 1) 시도 -> 성공! (아직 0이므로)
4. 값이 1로 변경됨

Thread B:
1. current = 0을 읽음 (거의 동시에)
2. next = 1 계산
3. CAS(0, 1) 시도 -> 실패! (이미 1로 변경됨)
4. 루프 재시작
5. current = 1을 읽음
6. next = 2 계산
7. CAS(1, 2) 시도 -> 성공!

getIntVolatile()은 volatile 읽기를 수행하여
CPU 캐시 일관성 프로토콜을 통해 최신 값을 보장받고,
읽기/쓰기 재정렬을 방지합니다.


 

낙관적 락(Optimistic Lock)과의 유사성

낙관적 락의 특징

  • 충돌이 드물 것이라 낙관적으로 가정
  • 락을 미리 획득하지 않음
  • 업데이트 시점에 충돌 검사
  • 충돌 시 재시도

CAS도 동일한 철학

  • 락 없이 작업 진행 (낙관적)
  • 마지막에 값이 변경되었는지 확인
  • 변경되었다면 재시도
  • 충돌이 적을수록 효율적

직접 구현해보기

CAS를 직접 구현할 수는 없지만 (하드웨어 명령어 필요), 개념적으로 어떻게 100을 보장하는지 시뮬레이션해봅시다.

import java.util.concurrent.atomic.AtomicInteger;

public class CustomAtomicCounter {
    private final AtomicInteger value = new AtomicInteger(0);
    
    /**
     * CAS 기반 증가 연산
     */
    public int increment() {
        while (true) {
            int current = value.get();
            int next = current + 1;
            
            // CAS: 현재 값이 여전히 current라면 next로 변경
            if (value.compareAndSet(current, next)) {
                return next; // 성공
            }
            // 실패하면 루프 재시작 (다른 스레드가 값을 변경함)
        }
    }
    
    public int get() {
        return value.get();
    }
}

테스트 코드

import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

public class ConcurrencyTest {
    public static void main(String[] args) throws InterruptedException {
        CustomAtomicCounter counter = new CustomAtomicCounter();
        int threadCount = 100;
        
        ExecutorService executor = Executors.newFixedThreadPool(threadCount);
        CountDownLatch latch = new CountDownLatch(threadCount);
        
        // 100개 스레드가 각각 1씩 증가
        for (int i = 0; i < threadCount; i++) {
            executor.submit(() -> {
                counter.increment();
                latch.countDown();
            });
        }
        
        latch.await();
        executor.shutdown();
        
        System.out.println("최종 값: " + counter.get()); // 항상 100!
    }
}

synchronized vs AtomicInteger 성능 비교

// 간단한 벤치마크
public class PerformanceTest {
    private static final int ITERATIONS = 1_000_000;
    
    // synchronized 버전
    private int syncCounter = 0;
    public synchronized void syncIncrement() {
        syncCounter++;
    }
    
    // AtomicInteger 버전
    private AtomicInteger atomicCounter = new AtomicInteger(0);
    public void atomicIncrement() {
        atomicCounter.incrementAndGet();
    }
}

일반적인 결과:

  • 경쟁이 적을 때: AtomicInteger가 2-5배 빠름
  • 경쟁이 심할 때: 성능 차이가 줄어들지만 여전히 AtomicInteger가 유리
  • synchronized는 블로킹되지만, CAS는 바쁘게 재시도 (스핀)

CAS의 장단점

장점

  1. 락 프리(Lock-Free): 데드락 불가능
  2. 높은 성능: 경쟁이 적을 때 매우 빠름
  3. 논블로킹: 스레드가 블로킹되지 않음

단점

  1. ABA 문제: A→B→A로 변경될 때 감지 못함 (AtomicStampedReference로 해결)
  2. CPU 사이클 낭비: 재시도 중 스핀으로 CPU 사용
  3. 경쟁이 심할 때: 재시도가 많아지면 오히려 비효율적

volatile의 역할

AtomicInteger 내부의 volatile int value에 주목해야 합니다.

private volatile int value;

volatile이 보장하는 것:

  1. 가시성(Visibility): 한 스레드의 변경이 즉시 다른 스레드에게 보임
  2. 재정렬 방지: 컴파일러/CPU의 최적화로 인한 명령어 순서 변경 금지

CAS만으로는 부족하고, volatile이 함께 있어야 올바른 동작이 보장됩니다.


실무에서 언제 사용할까?

AtomicInteger를 사용하면 좋은 경우

  • 단순 카운터, ID 생성기
  • 경쟁이 낮거나 중간 정도
  • 락 오버헤드를 피하고 싶을 때

synchronized를 사용해야 하는 경우

  • 복잡한 다단계 연산 (단일 원자성 보장 필요)
  • 경쟁이 매우 심할 때
  • 블로킹이 문제 없는 경우
// Atomic으로는 불가능한 예시
public synchronized void transferMoney(Account from, Account to, int amount) {
    from.balance -= amount;
    to.balance += amount;
    // 두 연산이 함께 원자적이어야 함
}

정리

  1. AtomicInteger는 CAS 알고리즘을 사용합니다.
  2. CAS는 하드웨어 레벨의 원자적 연산으로, 비교와 교환을 한 번에 수행합니다.
  3. 실패 시 재시도하는 낙관적 접근으로 100을 보장합니다.
  4. volatile과 함께 사용되어 가시성도 보장합니다.
  5. 직접 구현한다면 AtomicInteger를 활용한 CAS 루프를 사용하면 됩니다.

참고 자료

  • Java Concurrency in Practice
  • Java Memory Model (JMM) Specification
  • CPU의 Compare-and-Swap 명령어 (x86의 CMPXCHG)
반응형

'Back end > Java' 카테고리의 다른 글

[Java] JDBC의 Statement와 PreparedStatement  (2) 2026.01.22
[Java] static import 활용법  (0) 2025.07.07
[Java] 자바 데이터 타입 (기본형, 참조형)  (0) 2025.05.27
[Java] 상수(Constant)  (0) 2025.05.26
[Java] 변수(variable)  (0) 2025.05.26
'Back end/Java' 카테고리의 다른 글
  • [Java] JDBC의 Statement와 PreparedStatement
  • [Java] static import 활용법
  • [Java] 자바 데이터 타입 (기본형, 참조형)
  • [Java] 상수(Constant)
Kim-SooHyeon
Kim-SooHyeon
개발일기 및 알고리즘, 블로그 운영에 대한 글을 포스팅합니다. :) 목표: 뿌리 깊은 개발자 되기
    반응형
  • Kim-SooHyeon
    soo_vely의 개발로그
    Kim-SooHyeon
  • 전체
    오늘
    어제
    • 분류 전체보기 (258)
      • 알고리즘 (108)
        • 자료구조 (3)
        • Java (104)
        • Python (1)
      • Back end (73)
        • Spring Project (28)
        • Java (23)
        • API (1)
        • Python (0)
        • Django (3)
        • Linux (1)
        • 서버 (2)
        • 에러로그 (11)
        • 부스트 코스 (1)
      • Front end (9)
        • HTML, CSS (4)
        • JavaScript (4)
        • JQuery (0)
      • 기타 프로그래밍 (4)
        • Android Studio (1)
        • Arduino (2)
        • Azure Fundamental(AZ-900) (1)
      • 개발도구 (24)
        • IntelliJ (2)
        • Git (12)
        • SVN (0)
        • Eclipse (2)
        • 기타 Tool (8)
      • Database (17)
        • Oracle (11)
        • MySQL (0)
        • H2 Database (3)
        • ORM & JPA (1)
      • 자격증 (10)
        • 컴활 1급 (7)
        • 컴활 2급 (2)
        • SQLD (1)
      • 기타 (13)
        • 블로그 운영 (6)
        • 문서 (1)
        • 기타 (6)
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
  • 링크

  • 공지사항

  • 인기 글

  • 태그

    spring
    jpa
    오라클
    알고리즘
    구현
    Git
    백준알고리즘
    백준 자바
    백준
    for문
    java
    1차원 배열
    BOJ
    배열
    springboot
    solved.ac
    문자열
    github
    Oracle
    단계별풀기
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.3
Kim-SooHyeon
AtomicInteger는 어떻게 동시성을 보장할까? - CAS 알고리즘 깊이 파헤치기
상단으로

티스토리툴바