CodeGym /행동 /JAVA 25 SELF /AtomicInteger, AtomicReference: 원자적 연산

AtomicInteger, AtomicReference: 원자적 연산

JAVA 25 SELF
레벨 53 , 레슨 3
사용 가능

1. 왜 i++는 멀티스레드에서 동작하지 않을까?

고전적인 과제로 시작해 봅시다. 카운터 변수가 하나 있고, 예를 들어 처리된 요청의 개수나 다운로드된 파일 수를 센다고 하겠습니다. 여러 스레드가 이 카운터를 증가시키길 원합니다. 그냥 i++라고만 쓰면 무엇이 잘못될 수 있을까요?

예: 증가 연산에서의 데이터 레이스

public class Counter {
    public int count = 0;

    public void increment() {
        count++; // 원자적이지 않음!
    }
}

두 개의 스레드가 동시에 increment()를 호출한다고 가정해 봅시다. 두 스레드 모두 이전 값을 읽고, 둘 다 그 값을 1 증가시키며, 둘 다… 똑같은 새 값을 기록합니다! 그 결과 증가 연산 하나가 “사라집니다”. 이것을 여러 번 반복하면 최종 값이 기대보다 작아집니다.

왜 이런 일이 발생할까요?
연산 i++는 사실 세 단계로 이루어집니다:

  1. 변수의 값을 읽는다(예: 5).
  2. 그 값을 1만큼 증가시킨다.
  3. 새 값을 메모리에 다시 기록한다.

멀티스레드 환경에서는 이 단계들 사이에 다른 스레드가 변수를 변경할 수 있습니다. 결과적으로 데이터 레이스(race condition)가 발생합니다.

원자적 연산이란?

원자적 연산은 전부 수행되거나 전혀 수행되지 않으며, 다른 어떤 스레드도 연산 중간에 끼어들 수 없는 불가분의 동작입니다.

Java에는 프리미티브와 참조에 대해 이런 연산을 제공하는 클래스들이 있습니다. 이들은 java.util.concurrent.atomic 패키지에 위치합니다. 가장 많이 쓰이는 것들은 다음과 같습니다.

  • AtomicInteger — 원자적 정수 타입.
  • AtomicLong — 원자적 long.
  • AtomicBoolean — 원자적 boolean.
  • AtomicReference<T> — 임의 타입 객체에 대한 원자적 참조.

2. AtomicInteger: 스레드 안전한 카운터

선언과 기본 사용법

import java.util.concurrent.atomic.AtomicInteger;

public class AtomicCounter {
    private final AtomicInteger count = new AtomicInteger(0);

    public void increment() {
        count.incrementAndGet(); // 원자적으로 증가
    }

    public int get() {
        return count.get();
    }
}

여기서 incrementAndGet()은 “증가하고 새 값을 반환”을 하나의 불가분한 연산으로 수행합니다. 100개의 스레드가 동시에 이 메서드를 호출하더라도 증가가 잃어버려지지 않습니다.

유용한 메서드:

메서드 설명
get()
현재 값 가져오기
set(int value)
값 설정하기
incrementAndGet()
1 증가시키고 새 값 반환
getAndIncrement()
현재 값을 반환한 뒤 1 증가
addAndGet(int delta)
delta만큼 증가시키고 새 값 반환
compareAndSet(expect, update)
현재 값이 expect와 같으면 update로 설정(CAS)

예: 멀티스레드 카운터

채팅에서 처리된 메시지 수를 세는 클래스가 있다고 합시다.

public class MessageStatistics {
    private final AtomicInteger messageCount = new AtomicInteger(0);

    public void onMessageReceived() {
        int newCount = messageCount.incrementAndGet();
        System.out.println("총 메시지: " + newCount);
    }

    public int getMessageCount() {
        return messageCount.get();
    }
}

내부: AtomicInteger는 어떻게 동작할까?

AtomicInteger 내부에서는 프로세서의 특별한 명령인 CAS(Compare-And-Swap, 비교-교환)를 사용합니다. 이는 현재 값이 기대 값과 같은지 비교하고, 같다면 새 값을 기록하는 원자적 연산입니다. 그 사이에 다른 스레드가 값을 변경했다면 연산은 수행되지 않고, 시도를 다시 반복합니다.

동작 방식:

1. 현재 값을 읽는다(예: 5)
2. 기대 값(5)과 비교한다
3. 같다면 새 값(6)을 기록한다
4. 다르면 다시 시도한다

이 모든 것은 매우 빠르게, 그리고 락 없이(lock‑free) 이루어집니다. 따라서 원자 클래스는 특히 스레드가 많을 때 synchronized보다 더 빠른 경우가 많습니다.

3. AtomicReference: 객체에 대한 원자적 참조

AtomicReference<T>는 어떤 객체든 담을 수 있는 범용 원자 컨테이너입니다. 서로 다른 스레드에서 객체 참조를 안전하게 바꿀 수 있게 합니다.

예: 참조를 스레드 안전하게 갱신하기

import java.util.concurrent.atomic.AtomicReference;

public class AtomicReferenceExample {
    private final AtomicReference<String> latestMessage = new AtomicReference<>("");

    public void updateMessage(String message) {
        latestMessage.set(message);
    }

    public String getLatestMessage() {
        return latestMessage.get();
    }
}

compareAndSet 사용

가장 흥미로운 연산은 compareAndSet(expected, newValue)입니다. 마지막으로 읽은 이후 값이 바뀌지 않았을 때만 값을 갱신할 수 있게 해 줍니다.

public void safeUpdate(String oldValue, String newValue) {
    boolean success = latestMessage.compareAndSet(oldValue, newValue);
    if (success) {
        System.out.println("업데이트가 성공했습니다!");
    } else {
        System.out.println("누군가 이미 값을 변경했습니다. 다시 시도하세요.");
    }
}

이는 불필요한 락을 피하는 비차단 알고리즘의 토대입니다. 큐와 스택부터 캐시까지 다양한 곳에서 쓰입니다.

4. 애플리케이션에서의 사용 예

예 1: 멀티스레드 메시지 카운터

public class ChatRoom {
    private final AtomicInteger messageCount = new AtomicInteger(0);

    public void receiveMessage(String message) {
        // ... 메시지 처리 ...
        int count = messageCount.incrementAndGet();
        System.out.println("새 메시지: " + message + ". 총 메시지: " + count);
    }
}

예 2: 마지막 메시지 참조를 안전하게 갱신하기

public class ChatRoom {
    private final AtomicReference<String> lastMessage = new AtomicReference<>("");

    public void receiveMessage(String message) {
        lastMessage.set(message);
        // ... 처리 ...
    }

    public String getLastMessage() {
        return lastMessage.get();
    }
}

마지막 메시지가 바뀌지 않았을 때만 참조를 갱신해야 한다면(동시 갱신으로 인한 “유실”을 피하려고), compareAndSet을 사용하세요.

5. 제한 사항과 함정

원자 클래스가 만능은 아닐 때

원자 변수는 인크리먼트, 값 설정, 비교 후 교체 같은 단순 연산에 매우 적합합니다. 하지만 여러 변수를 한 번에 갱신해야 한다면 더 이상 원자성이 보장되지 않습니다. 예컨대 카운터 두 개를 하나의 연산으로 동시에 증가시키고 싶다면 synchronized나 다른 동기화 메커니즘이 필요합니다.

잘못된 사용 예

// 원자적이지 않음!
if (ref.get() == null) {
    ref.set("Hello");
}

get()set(...) 사이에 다른 스레드가 값을 바꿀 수 있어, 조건이 이미 거짓이 될 수 있습니다. 이런 경우에는 compareAndSet을 사용하세요.

원자 클래스 ≠ 스레드 안전한 객체

AtomicReference가 가리키는 객체가 스레드 안전하지 않다면, 참조의 교체는 원자적이지만 객체의 필드 변경은 그렇지 않습니다. 예를 들어 AtomicReference<List<String>>에 일반 ArrayList를 넣어도 리스트 자체가 thread‑safe가 되는 것은 아닙니다.

6. 고급 원자 클래스

java.util.concurrent.atomic 패키지에는 다른 유용한 클래스들도 있습니다:

  • AtomicLong, AtomicBooleanlongboolean용.
  • AtomicIntegerArray, AtomicReferenceArray — 배열에 대한 원자적 연산.
  • LongAdder, LongAccumulator — 높은 경쟁의 카운터용.

LongAdder와 LongAccumulator

스레드가 매우 많아 일반 AtomicInteger가 “병목”이 된다면(모든 스레드가 하나의 변수에 경쟁), LongAdder를 사용하세요. 이 클래스는 카운터를 여러 내부 셀로 분산하고 값 조회 시 합산하여, 경쟁이 높을 때 성능 이점을 제공합니다.

import java.util.concurrent.atomic.LongAdder;

public class FastCounter {
    private final LongAdder adder = new LongAdder();

    public void increment() {
        adder.increment();
    }

    public long getCount() {
        return adder.sum();
    }
}

7. 원자 변수 사용 시 흔한 실수

실수 1: 복합 연산까지 원자적이라고 기대함.
값에 대해 여러 동작을 수행해야 한다면 원자 클래스만으로는 충분하지 않습니다 — 그 사이에 다른 스레드가 데이터를 바꿀 수 있습니다. 복합 연산에는 compareAndSet 또는 동기화를 사용하세요.

실수 2: 참조가 가리키는 객체의 스레드 안전성을 무시함.
AtomicReference에 일반 객체가 들어 있다면, 그 객체의 메서드와 필드는 스레드 안전해지지 않습니다. 원자적인 것은 참조 교체뿐입니다.

실수 3: 필요 없는데도 원자 클래스를 사용함.
단일 스레드 코드에서는 원자 타입이 불필요하며, 추가 검증 때문에 일반 변수보다 약간 느립니다.

실수 4: 성급한 최적화.
특히 여러 변수를 동시에 다루는 복잡한 로직에서는 synchronized가 더 간단하고 신뢰할 수 있습니다. 항상 lock‑free 해법이 정답인 것은 아닙니다.

실수 5: ABA 문제를 잊음.
드물지만 중요한 경우입니다. 값이 A에서 B로, 다시 A로 바뀌면 compareAndSet은 아무것도 바뀌지 않았다고 판단할 수 있습니다. 이런 시나리오에는 AtomicStampedReference(또는 AtomicMarkableReference) 같은 특수 클래스를 사용하세요.

코멘트
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION