1. 왜 i++는 멀티스레드에서 동작하지 않을까?
고전적인 과제로 시작해 봅시다. 카운터 변수가 하나 있고, 예를 들어 처리된 요청의 개수나 다운로드된 파일 수를 센다고 하겠습니다. 여러 스레드가 이 카운터를 증가시키길 원합니다. 그냥 i++라고만 쓰면 무엇이 잘못될 수 있을까요?
예: 증가 연산에서의 데이터 레이스
public class Counter {
public int count = 0;
public void increment() {
count++; // 원자적이지 않음!
}
}
두 개의 스레드가 동시에 increment()를 호출한다고 가정해 봅시다. 두 스레드 모두 이전 값을 읽고, 둘 다 그 값을 1 증가시키며, 둘 다… 똑같은 새 값을 기록합니다! 그 결과 증가 연산 하나가 “사라집니다”. 이것을 여러 번 반복하면 최종 값이 기대보다 작아집니다.
왜 이런 일이 발생할까요?
연산 i++는 사실 세 단계로 이루어집니다:
- 변수의 값을 읽는다(예: 5).
- 그 값을 1만큼 증가시킨다.
- 새 값을 메모리에 다시 기록한다.
멀티스레드 환경에서는 이 단계들 사이에 다른 스레드가 변수를 변경할 수 있습니다. 결과적으로 데이터 레이스(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개의 스레드가 동시에 이 메서드를 호출하더라도 증가가 잃어버려지지 않습니다.
유용한 메서드:
| 메서드 | 설명 |
|---|---|
|
현재 값 가져오기 |
|
값 설정하기 |
|
1 증가시키고 새 값 반환 |
|
현재 값을 반환한 뒤 1 증가 |
|
delta만큼 증가시키고 새 값 반환 |
|
현재 값이 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, AtomicBoolean — long 및 boolean용.
- 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) 같은 특수 클래스를 사용하세요.
GO TO FULL VERSION