[Java] 낙관적 락과 비관적 락 / AtomicInteger

hwhyeons·2026년 1월 15일

동시성 제어 방식에는 여러 방법들이 있고, 그 중에서 익숙한 Lock이라는 방법이 있다.

Locking은 비관적 락(Pessimistic Lock)과 낙관적 락(Optimistic Lock)으로 구분해볼 수 있다.


오늘은 이 비관적 락과 낙관적 락의 차이를 알아보고 낙관적 락 매커니즘을 사용하는 Java의 AtomicInteger에 대해 좀 더 알아볼 것이다.
(이 글을 쓰게 된 이유도 AtomicInteger은 어떤식으로 동시성 제어를 하는지에 대한 궁금증에서 시작된 내용이기 때문이다)


먼저 낙관적 락과 비관적 락에 대해서 알아보자.



낙관적 락(Optimistic Lock)

먼저 낙관적 락은, 이름은 Lock이지만 엄밀히 말하면 Lock을 걸지는 않는 방식이다.

일단 락을 걸지 않고 동시에 작업을 허용하다가, 업데이트를 수행하는 시점에서만 혹시 내가 읽은 데이터랑 다른 데이터로 이미 업데이트가 되지는 않았는지를 검사하는 방식이다.


예를들어 공유 변수 A가 있고 현재 A값이 100이라고 해보자.

어떤 트랜잭션에서 A의 값에다가 10을 더하기 위해 A의 값을 읽었더니 100이다.
이제 이 100에서 10을 더하기 위해 110을 만들고 저장을 하려고 한다.
이 상황에서 검사를 수행한다.

"내가 처음에 읽은 값은 100이고 110으로 수정하려고 해. 여전히 100이야?"

만약에 이 과정 사이에서 어떤 다른 트랜잭션이 공유변수 A값을 200으로 수정했다고 해보자.
그러면 이제 110으로 수정하려는 행위는 반영되지 않는다.


낙관적 락에서 낙관적이라는 표현이, 바로 이렇게 일단은 충돌이 없을거라고 가정하고, 낙관적으로 바라보고
다 풀어놓다가 진짜로 충돌이 있다면 그때만 반영하지 않는 방식이다.


장점은 락을 걸어서 계속 접근을 제한하지 않기 때문에 속도 측면에서 유리하다.
단점은 충돌 발생시 처리다. 충돌이 발생했다면 그냥 "반영 안한다?"하고 나몰라라 끝내버릴 수는 없다.

다시 재시도를 하든, 아니면 마치 티켓팅, 예약 할 때 분명히 빈 좌석이여서 클릭한건데
"이미 예약이 완료된 좌석입니다. 새로고침 해주세요.”이렇게 메시지를 띄운다든지
충돌이 일어났을 때의 그 재시도 로직이 따로 필요하다는 단점이 있다.

또한 재시도를 자동으로 시도하더라도, 계속 재시도를 하는데 계속 충돌이 발생한다면
이는 CPU 소모 등의 문제로 이어질 수 있다.



비관적 락(Pessimistic Lock)

비관적 락은, 우리가 Lock하면 떠오르는 그 가장 익숙한 락 방식 중에 하나다.

데이터 접근 시에 락을 거는 방식인데, 물론 이 비관적 락도 Read할 때는 Lock을 안걸고 Write할 때만 건다든지
세부 분류가 있기는 하지만 (공유 락(Shared Lock)과 배타 락(Exclusive Lock))
결국 충돌이 일어나지 않게 미리 Lock을 걸어서 접근 자체를 차단한다고 대기시킨다는 점에서 낙관적 락과는 차이가 있다.

장점은 역시 충돌 자체를 차단하기 때문에 안전하다는 점, 그리고 낙관적 락처럼 재시도 로직이 따로 필요없다는 점이 있고,
단점은 계속 락을 걸어야하기 때문에 성능적으로 낙관적 락에 비해 안좋을 수 밖에 없다.



Java에서 사용되는 낙관적 락(Optimistic Lock)과 비관적 락(Pessimistic Lock)

나는 이 lock 방식에 대해서는 학교에서는 데이터베이스 수업 때 배웠다.

하지만 DB가 아니더라도 익숙한 언어들에서도 이 락 매커니즘은 사용되는데,
Java를 이용해서 확인해보려고 한다.


Java에서 낙관적 락의 대표적인 예시로는 AtomicInteger가 있고,
비관적 락의 대표적인 예시로는 synchronized 키워드가 있다.

synchronized는 자바에서 나름 자주볼 수 있는 키워드다.

메소드 자체에 synchronized을 걸어서 메소드 접근을 하나의 스레드만 접근이 가능하게 만들 수도 있고,
synchronized 블럭을 만드는 방법도 있다.

(이 글에서는 AtomicInteger에 대해 자세히 알아보는게 목표라 따로 코드를 적어놓지는 않았다)

AtomicInteger은 동시성 환경에서 안정성이 보장되는 Integer변수라고 보면 된다.

일반 int랑 비슷하지만 동시성을 위한 추가 메소드 및 기능들이 제공된다.



AtomicInteger을 사용하는 이유

(참고로 Java Atomic에는 AtomicInteger말고도 AtomicBoolean, AtomicLong 등 여러가지가 있지만 이 글에서는 AtomicInteger을 기준으로 설명하겠다)

AtomicInteger이 정확히 왜 필요한지에 대해 보기위해 일반 int변수를 멀티스레드에서 사용하면
안되는걸 보여주는 예시 코드를 작성해봤다.


public class Main {
    // 여러 스레드가 공유할 일반 int 변수
    private static int count = 0;

    public static void main(String[] args) throws InterruptedException {
        // 1. 10,000번 증가시키는 작업을 정의
        Runnable task = () -> {
            for (int i = 0; i < 10000; i++) {
                count++; // 이 한 줄에서 충돌이 발생합니다!
            }
        };

        // 2. 두 개의 스레드 생성
        Thread thread1 = new Thread(task);
        Thread thread2 = new Thread(task);

        // 3. 스레드 시작
        thread1.start();
        thread2.start();

        // 4. 두 스레드가 모두 끝날 때까지 대기
        thread1.join();
        thread2.join();

        // 5. 최종 결과 출력 (기대값: 20000)
        System.out.println("실제 실행 결과: " + count);
    }
}

실제 결과는 매번 돌릴 때마다 다르다.

만약에 동시성 문제를 배제하고 딱 코드만 분석했을 때 나오는 값은 당연히 count=20000이지만 절대 그렇지 않다.

직접 돌려보면 12000부터 15000대까지 다양하게 출력되는 것을 볼 수 있다.


이번에는 이 코드를 AtomicInteger을 이용해서 해결해보도록 하겠다.

import java.util.concurrent.atomic.AtomicInteger;

public class Main {
    // 일반 int 대신 AtomicInteger를 선언하고 0으로 초기화
    private static AtomicInteger count = new AtomicInteger(0);

    public static void main(String[] args) throws InterruptedException {
        Runnable task = () -> {
            for (int i = 0; i < 10000; i++) {
                count.incrementAndGet();
            }
        };

        // 스레드 생성 및 시작
        Thread thread1 = new Thread(task);
        Thread thread2 = new Thread(task);

        thread1.start();
        thread2.start();

        // 두 스레드가 끝날 때까지 대기
        thread1.join();
        thread2.join();

        // 4. 최종 결과 출력
        System.out.println("실제 실행 결과: " + count.get());
    }
}

이제는 계속 실행해봐도 의도한대로 20000이 출력되는 것을 볼 수 있다.



AtomicInteger의 낙관적 락

왜 AtomicInteger을 사용하는지 알아봤으니 좀 더 구체적으로 AtomicInteger이 동작하는지,
그리고 어떤 메소드들이 제공되는지 등을 알아보자.

아까 말한 것처럼 AtomicInteger은 낙관적 락방식을 이용한다.

링크에서

A small toolkit of classes that support lock-free thread-safe programming on single variables. Instances of Atomic classes maintain values that are accessed and updated using methods otherwise available for fields using associated atomic VarHandle operations.

라고 설명되어있다.

AtomicInteger가 내부적으로 어떻게 짜여있는지는 아래에서 compareAndSet()과 updateAndGet()에 대해 본 다음에 보도록 하겠다.



compareAndSet()과 updateAndGet()

AtomicInteger에서 사용할 수 있는 메소드 중에서 compareAndSet()과 updateAndGet()을 알아보겠다.

compareAndSet()은 어쩌면 낙관적 락에서 사용하는 충돌 대응 방법을 그대로 표현한 메소드 이름이다.

"내가 처음에 읽었던 값과 비교해서 값을 같을 때만 갱신하겠다"


updateAndGet()은 좀더 하이레벨한 방식이라고 보면 된다.
compareAndSet()은 만약에 동시성 충돌로 업데이트 실패하면 그냥 끝인데,
updateAndGet()은 업데이트가 될 때까지 계속 시도한다고 보면 된다.


이 개념을 이용해서, current라는 값이 계속 100이 안넘을 때까지만 계속 10씩 더하는 코드를 작성해본다고 하자.

만약 compareAndSet()으로 조금 low하게 작성하면, 충돌이 일어났을 때의 대응을 직접 작성해야한다
(아래 코드에서 compareAndSet()이 false인 경우)

// 로우 레벨 방식 (직접 루프)
int current;
do {
    current = ai.get();
    if (current > 90) break;
} while (!ai.compareAndSet(current, current + 10));

이거를 updateAndGet()을 이용하면 충돌 감지 뿐만 아니라, 값 업데이트 함수도 람다식을 이용해서
좀 더 간단하게 작성이 가능하다.

// 하이 레벨 방식 (getAndUpdate)
ai.updateAndGet(val -> val <= 90 ? val + 10 : val);


AtomicInteger의 내부 동작 코드

compareAndSet()과 updateAndGet()이 뭔지 알아봤으니, 내부적으로 실제 코드가 어떻게 되어있는지도 보자.

이번에는 AtomicInteger의 incrementAndGet()을 보도록하겠다.
(참고로 JDK 17 기준이다.)

인텔리제이 등의 IDE에서 incrementAndGet()을 자세하게 들어가보면

public class AtomicInteger extends Number implements java.io.Serializable {
		// ... 생략
		private static final Unsafe U = Unsafe.getUnsafe();
		// ... 생략
    public final int incrementAndGet() {
        return U.getAndAddInt(this, VALUE, 1) + 1;
    }

이렇게 되어있다.
참고로 getAndAddInt는 Unsafe 클래스에 정의 되어있으니 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;
    }

여기서 weakCompareAndSetInt()을 타고 들어가고, 한번 더 타고 들어가면

    @IntrinsicCandidate
    public final native boolean compareAndSetInt(Object o, long offset,
                                                 int expected,
                                                 int x);

native 메소드까지 마주친다.

조금 더 욕심을 내서 native C++ 구현체 코드까지 찾아가보자.

https://github.com/openjdk/jdk17/blob/master/src/hotspot/share/prims/unsafe.cpp 에서 코드를 확인해보자.

UNSAFE_ENTRY(jboolean, Unsafe_CompareAndSetInt(JNIEnv *env, jobject unsafe, jobject obj, jlong offset, jint e, jint x)) {
  oop p = JNIHandles::resolve(obj);
  if (p == NULL) {
    volatile jint* addr = (volatile jint*)index_oop_from_field_offset_long(p, offset);
    return RawAccess<>::atomic_cmpxchg(addr, e, x) == e;
  } else {
    assert_field_offset_sane(p, offset);
    return HeapAccess<>::atomic_cmpxchg_at(p, (ptrdiff_t)offset, e, x) == e;
  }
} UNSAFE_END

여기서 atomic_cmpxchg()부분이 중요한데,

이 cmpxchg은 자바에서 단독으로 사용하는 용어가 아니다.

cmpxchg은 Compare and Exchange의 약자로, x86 아키텍처(Intel, AMD 등) CPU에서 제공하는 명령어이다.


즉, 이제 여기서부터는 하드웨어의 영역이다.

자바 코드에서 compare하고 동일하면 바꾸겠다가 아니라 CPU에서 제공하는 명령어를 이용하는 것이다.


나는 이 AtomicInteger에 대해 공부할 때 들었던 의문이 있었다.

낙관적 락은
"처음에 읽은 값과 나중에 쓸 값이 다르면 그때는 반영하지 않는다"라고 이해했는데,
그러면 아래와 같은 과정으로 진행할 것이다.

  1. 현재 값 read
  2. 현재값과 개발자가 인자로 넘긴 비교값과 비교
  3. 같으면 set, 다르면 return False
    이 세가지 과정 사이에서 만큼은 Lock이 필요한거 아니야?

내가 생각한 문제는 바로 1번과 2번 지점 사이에서 다른 스레드에서 값을 수정해버린다면??
애초에 Compare하는 것 자체가 의미없는 행동이 되는거 아닌가 싶었고,
그러면 결국 어차피 저 1~3번 과정을 수정하는 과정에서 또 다시 Lock을 걸어야하는거 아닌가?
그러면 이게 왜 Lock-Free라고 불리는거지?라는 의문이 들었다.

하지만 이 의문점은 네이티브 코드까지 따라가보니 해결할 수 있었다.


그렇다. 사실은 Lock이 있다.
그러나, 그 Lock은 우리가 소프트웨어적인 그 스레드 Lock이 전혀 아니다.
하드웨어상에서 처리하는 Lock이지, Java가 개입하고 OS가 개입하는 그런 Lock이 아니라
하드웨어에서 일어나는 아주 미미한 Lock이라는 것이다.

우리가 생각하는 Lock은, 스레드 사이에서 동작하는 Lock이다.
이런 경우에는 락이 걸려있어서 어떤 스레드가 작업하지 못하면 다음 스레드로 넘어가고 이러는 과정에서
Context-Switching 비용이 발생한다.

이게 비관적 락의 예시로 언급했던 Java의 synchronized가 하는 Lock이고,
AtomicInteger에서 사용하는 아주 잠깐의 Lock은 하드웨어에서 걸리는 Lock이다.
그러므로 이런걸 Lock-Free라고 표현하는 것으로 보인다.

Lock-Free라는 용어에 대해 좀 더 찾아봤는데,
Lock이 아예 없다기보다는, 어떤 스레드가 멈춰도, 시스템 전체는 멈추지 않고 계속 진행된다는 의미에 가까운 것 같다.


AtomicInteger, AtomicLong 같은 클래스말고 LongAdder, DoubleAdder 같은 것들도 있는데 이는 링크에서 확인해볼 수 있다.


참고한 링크 https://stackoverflow.com/questions/8878655/performance-difference-of-atomicinteger-vs-integer

0개의 댓글