컴퓨터 과학에서 원자적 연산(atomic operation)은 더 이상 나눌 수 없는 최소 단위로 수행되는 연산을 의미합니다. 즉, 연산이 중간에 중단되지 않고 완전히 실행되거나 전혀 실행되지 않는 특성을 가지고 있습니다. 이러한 원자적 연산은 멀티스레드 환경에서 다른 스레드의 간섭 없이 안전하게 처리되어 데이터의 일관성과 무결성을 보장합니다. 원자적 연산은 동기화나 락 없이도 안전한 동시성 처리를 가능하게 하며, 예를 들어 변수에 대한 단일 읽기 또는 쓰기, CPU가 지원하는 특정 명령어 등이 있습니다.
예를 들어서 다음과 같은 필드가 있을 때
volatile int i = 0; // 오른쪽의 값 1 을 왼쪽 변수 i 에 대입한다.
- i = 1; 은 둘로 쪼갤 수 없는 원자적 연산입니다.
- 이 연산은 한 번에 한 단계로 실행되기 때문입니다.
i = i + 1;
- 하지만 다음 연산 i = i + 1; 은 원자적 연산이 아닙니다.
- 이 연산은 여러 단계로 나누어 실행되기 때문입니다.
- 오른쪽에 있는 i 의 값을 읽는다. (예: 10)
- 읽은 값에 1을 더한다. (10 + 1 = 11)
- 더한 값을 왼쪽 변수 i 에 대입한다.
원자적 연산은 멀티스레드 환경에서 문제가 발생하지 않습니다.
하지만 원자적이지 않은 연산은 synchronized 블록이나 Lock 등을 사용해 안전한 임계 영역을 만들어야 합니다.
순서대로 실행: 2개의 스레드가 해당 로직을 수행하는데, 하나가 완전히 끝나고 나서, 나머지 하나가 수행된다고 가정
처음에 i = 0이라고 가정
스레드1: i = i + 1 연산 수행
스레드1: i의 값을 읽는다. i는 0이다.
스레드1: 읽은 0에 1을 더해서 1을 만든다.
스레드1: 더한 1을 왼쪽의 i변수에 대입한다.
결과: i의 값은 1이다.
스레드2: i = i + 1 연산 수행
스레드2: i의 값을 읽는다. i는 1이다.
스레드2: 읽은 1에 1을 더해서 2을 만든다.
스레드2: 더한 2을 왼쪽의 i변수에 대입한다.
결과: i의 값은 2이다.
- 2개의 스레드가 각각 한 번 연산을 수행했으므로 변수i의 값은 0 → 2가된다.
동시에 실행: 2개의 스레드가 해당 로직을 동시에 함께 수행하면, 문제가 발생한다.
처음에 i = 0이라고 가정
스레드1: i = i + 1 연산 수행
스레드2: i = i + 1 연산 수행 스레드1: i의 값을 읽는다. i는 0이다.
스레드2: i의 값을 읽는다. i는 0이다.
스레드1: 읽은 0에 1을 더해서 1을 만든다.
스레드2: 읽은 0에 1을 더해서 1을 만든다.
스레드1: 더한 1을 왼쪽의 i변수에 대입한다.
스레드2: 더한 1을 왼쪽의 i변수에 대입한다. 결과: i의 값은 1이다.
- 2개의 스레드가 각각 한 번 연산을 수행했지만 변수i의값은 0 → 1이된다. 한 번의 연산이 사라진 것이다.
🤔 그렇다면 i++ 연산은 원자적 연산일까?
i++ 연산은 원자적 연산처럼 보이지만 실제로는 원자적이지 않습니다.
이 연산은 앞서 설명한 i = i + 1 의 축약형으로, 내부적으로 같은 순서의 세 단계로 나누어 실행됩니다.
따라서 i++ 도 i = i + 1 과 마찬가지로 멀티스레드 환경에서는 안전하지 않습니다.
원자적이지 않은 연산을 멀티스레드 환경에서 실행하면, 값이 올바르게 증가하지 않는 문제가 발생할 수 있습니다.
예를 들어, 숫자를 하나씩 증가시키는 기능을 제공하며, 접속자 수 같은 값을 세는 클래스를 만들어봅시다.
하지만 이 클래스는 여러 스레드가 동시에 증가시키면 예상과 다르게 값이 덜 증가할 수 있습니다.
어떤 문제가 발생하는지 코드로 알아보겠습니다.
public interface IncrementInteger {
void increment(); // 값을 하나 증가
int get(); // 값을 조회
}
public class BasicInteger implements IncrementInteger{
private int value;
@Override
public void increment() {
value++;
}
@Override
public int get() {
return value;
}
}
- increment() 메서드는 value++ 연산으로 값을 하나 증가시킨다.
- value는 인스턴스 필드로 여러 스레드가 공유할 수 있다.
- 공유 자원에 ++ 같은 원자적이지 않은 연산을 사용하면 멀티스레드 환경에서 문제가 발생할 수 있다.
public class IncrementThreadMain {
public static final int THREAD_COUNT = 1000;
public static void main(String[] args) throws InterruptedException {
test(new BasicInteger());
}
private static void test(IncrementInteger incrementInteger) throws InterruptedException {
Runnable runnable = new Runnable() {
@Override
public void run() {
sleep(10); // 너무 빨리 실행되기 때문에, 다른 스레드와 동시 실행을 위해 잠깐 쉬었다가 실행
incrementInteger.increment();
}
};
List<Thread> threads = new ArrayList<>();
for (int i = 0; i < THREAD_COUNT; i++) {
Thread thread = new Thread(runnable);
threads.add(thread);
thread.start();
}
for (Thread thread : threads) {
thread.join();
}
int result = incrementInteger.get();
System.out.println(incrementInteger.getClass().getSimpleName() + " result: " + result);
}
}

실행 결과를 보면 기대한 1000이 아니라 다른 숫자가 보입니다.
아마도 실행 환경에 따라서 다르겠지만 1000이 아니라 조금 더 적은 숫자가 보일 것입니다.
이 문제는 앞서 설명한 것 처럼 여러 스레드가 동시에 원자적이지 않은 value++ 을 호출했기 때문에 발생합니다.
그럼 혹시 volatile 을 적용하면 되지 않을까?
private volatile int value;
volatile은 변수 값을 메인 메모리에서 직접 읽고 쓰도록 보장해 캐시 일관성 문제는 해결하지만, 연산 자체가 나누어져 있는 경우에는 동시성 문제가 여전히 발생합니다. 예를 들어 value++는 읽기, 계산, 쓰기 세 단계로 나뉘기 때문에, 여러 스레드가 동시에 실행하면 값이 꼬일 수 있습니다.
즉, volatile은 메모리 가시성은 보장하지만 연산의 원자성은 보장하지 않기 때문에, 이런 경우에는 synchronized, Lock, 또는 AtomicInteger 등을 사용해야 안전합니다.
@Override
public synchronized void increment() {
value++;
}
@Override
public synchronized int get() {
return value;
}
synchronized를 통해 value++ 연산을 임계 영역 안에서 수행하자, 1000개의 스레드가 동시에 접근해도 값의 꼬임 없이 정확히 1000이라는 결과가 나왔습니다. 이는 모든 스레드가 순차적으로 안전하게 value++ 연산을 수행했음을 의미합니다.
자바는 멀티스레드 환경에서 안전한 증가 연산을 수행할 수 있도록 AtomicInteger 클래스를 제공합니다. 이름 그대로 원자적(Atomic) 으로 int 값을 다룰 수 있는 클래스입니다. 예를 들어 synchronized 키워드를 사용하지 않아도, AtomicInteger는 내부적으로 원자적 연산을 보장합니다.
public class MyAtomicInteger implements IncrementInteger {
AtomicInteger atomicInteger = new AtomicInteger(0);
@Override
public void increment() {
atomicInteger.incrementAndGet();
}
@Override
public int get() {
return atomicInteger.get();
}
}
- new AtomicInteger(0) : 초기값을 지정한다. 생략하면 0 부터 시작한다.
- incrementAndGet() : 값을 하나 증가하고 증가된 결과를 반환한다.
- get() : 현재 값을 반환한다.
실행 결과를 보면 AtomicInteger 를 사용한 MyAtomicInteger 클래스도 정확히 1000이라는 결과를 출력함을 확인할 수 있습니다.
이는 1000개의 스레드가 동시에 증가 연산을 수행해도 충돌 없이 안전하게 처리되었음을 의미합니다. 참고로 자바는 AtomicInteger 외에도 AtomicLong, AtomicBoolean, AtomicReference 등 다양한 AtomicXxx 클래스를 제공하여 다양한 타입의 원자적 연산을 지원합니다.
그렇다면 이번에는 원자적 연산의 성능과 안정성을 확인하기 위해 다음 5가지 클래스를 비교해봅시다.
- BasicInteger – 아무 동기화 없이 value++만 수행
- VolatileInteger – volatile 키워드로 변수 선언, 동시성 문제는 해결되지 않음
- SyncInteger – synchronized 블록으로 임계 영역 보호
- MyAtomicInteger – AtomicInteger를 내부에서 사용
이 다섯 가지를 같은 조건에서 테스트하여, 멀티스레드 환경에서 성능 차이와 정확도를 비교 분석할 예정입니다.
이를 통해 volatile 과 동기화, 그리고 원자적 연산이 어떻게 다르고, AtomicInteger 가 왜 멀티스레드에 적합한지 명확히 알 수 있습니다.
public class IncrementPerformanceMain {
public static final long COUNT = 100_000_000;
public static void main(String[] args) {
test(new BasicInteger());
test(new VolatileInteger());
test(new SyncInteger());
test(new MyAtomicInteger());
}
private static void test(IncrementInteger incrementInteger) {
long startMs = System.currentTimeMillis();
for (long i = 0; i < COUNT; i++) {
incrementInteger.increment();
}
long endMs = System.currentTimeMillis();
System.out.println(incrementInteger.getClass().getSimpleName()
+ ": ms=" + (endMs - startMs));
}
}
BasicInteger
- 가장 빠르다.
- CPU 캐시를 적극 사용한다. CPU 캐시의 위력을 알 수 있다.
- 안전한 임계 영역도 없고, volatile도 사용하지 않기 때문에 멀티스레드 상황에서는 사용할 수 없다.
- 단일 스레드가 사용하는 경우에 효율적이다.
VolatileInteger
- volatile을 사용해서 CPU 캐시를 사용하지 않고 메인 메모리를 사용한다.
- 안전한 임계 영역이 없기 때문에 멀티스레드 상황에서는 사용할 수 없다.
- 단일 스레드가 사용하기에는 BasicInteger보다 느리다. 그리고 멀티스레드 상황에도 안전하지 않다.
SyncInteger
- synchronized를 사용한 안전한 임계 영역이 있기 때문에 멀티스레드 상황에서도 안전하게 사용할 수 있다.
- MyAtomicInteger보다 성능이 느리다.
MyAtomicInteger
- 자바가 제공하는 AtomicInteger를 사용한다. 멀티스레드 상황에서 안전하게 사용할 수 있다.
- 성능도 synchronized, Lock(ReentrantLock)을 사용하는 경우보다 1.5~2배 정도 빠르다.
SyncInteger처럼 락을 사용하는 경우보다 AtomicInteger가 더 빠른 이유는 AtomicInteger가 내부적으로 락을 사용하지 않고
하드웨어 수준의 원자적 명령어(예: CAS, Compare-And-Swap)를 이용해 연산을 처리하기 때문입니다.
i++ 연산은 원자적이지 않아 여러 스레드가 동시에 수행할 경우 데이터 경쟁 문제가 발생할 수 있습니다.
일반적으로 synchronized 나 Lock(ReentrantLock) 같은 락을 사용해 안전한 임계 영역을 만들어 해결하지만, 이 방식은 락 획득과 해제에 따른 오버헤드가 존재합니다.
반면 AtomicInteger의 incrementAndGet() 메서드는 락 없이도 원자적인 증감 연산을 가능하게 하여, 락 기반 동기화보다 훨씬 가볍고 빠른 성능을 제공합니다. 이러한 비차단(non-blocking) 동기화 방식은 멀티스레드 환경에서 높은 성능과 확장성을 보장합니다.
요약하면, AtomicInteger는 하드웨어 지원을 활용한 원자적 연산으로 락 오버헤드를 줄이고 멀티스레드 환경에서 효율적인 동기화를 제공하기 때문에, SyncInteger처럼 락을 사용하는 경우보다 성능이 뛰어납니다.
CAS 연산
SyncInteger와 같은 클래스는 데이터를 보호하기 위해 락을 사용합니다. 여기서 말하는 락은 synchronized, Lock(ReentrantLock) 등을 의미합니다. 락은 특정 자원에 대한 스레드의 접근을 제한하여 동시에 여러 스레드가 해당 자원에 접근하는 것을 방지합니다. 락이 걸려 있는 동안에는 다른 스레드가 그 자원에 접근하지 못하고, 락이 해제될 때까지 대기해야 합니다. 락 기반 접근 방식은 락을 획득하고 해제하는 데 시간이 소요됩니다.
예를 들어, 락을 사용하는 연산은 다음과 같은 과정을 반복합니다.
- 락이 있는지 확인한다.
- 락을 획득하고 임계 영역에 진입한다.
- 작업을 수행한다.
- 락을 반납한다.
이 과정은 1만 번의 연산이 있다면 1만 번 모두 반복됩니다. 이처럼 락을 사용하는 방식은 직관적이고 이해하기 쉽지만, 락 획득과 해제에 따른 비용 때문에 상대적으로 무거운 방식입니다.
이런 문제를 해결하기 위해 락을 걸지 않고도 원자적인 연산을 수행할 수 있는 방법이 있습니다.
이를 CAS(Compare-And-Swap, 또는 Compare-And-Set) 연산 이라고 합니다.
CAS는 락을 사용하지 않기 때문에 락 프리(lock-free) 기법 에 속합니다.
다만 CAS 연산이 락을 완전히 대체하는 것은 아니며, 주로 작은 단위의 일부 영역에 적용됩니다.
기본적으로는 락을 사용하되, 특별한 경우에 CAS를 적용한다고 생각하면 됩니다.
public class CasMainV1 {
public static void main(String[] args) {
AtomicInteger atomicInteger = new AtomicInteger(0);
System.out.println("start value = " + atomicInteger.get());
boolean result1 = atomicInteger.compareAndSet(0, 1);
System.out.println("result1 = " + result1 + ", value = " + atomicInteger.get());
boolean result2 = atomicInteger.compareAndSet(0, 1);
System.out.println("result2 = " + result2 + ", value = " + atomicInteger.get());
}
}

compareAndSet(0, 1);
- atomicInteger 가 가지고 있는 값이 현재 0이면 이 값을 1로 변경하라는 매우 단순한 메서드입니다.
- 만약 atomicInteger 의 값이 정확히 0이라면, 해당 값은 1로 변경되고 true 를 반환합니다.
- 반면, atomicInteger 의 값이 이미 0이 아니라면 값은 변경되지 않고 false 를 반환합니다.
여기서 가장 중요한 점은 이 메서드가 원자적으로 실행된다는 것입니다.
그리고 이 메서드가 제공하는 핵심 기능이 바로 CAS(Compare-And-Set) 연산입니다.
CAS는 멀티스레드 환경에서도 안전하게 값을 비교하고, 조건에 맞을 경우 변경까지 단번에 수행할 수 있는 원자적 연산입니다.

AtomicInteger 의 compareAndSet(0, 1) 은 내부 value 값이 0이면 1로 바꾸겠다는 의미입니다.
여기서 왼쪽 매개변수는 기대하는 값(0), 오른쪽은 변경할 값(1)입니다.
즉, 메모리 상의 값이 0일 때만 1로 바꾸고, 그 외에는 아무 일도 하지 않습니다.
하지만, 언뜻 보면 compareAndSet() 연산은
- 메모리에서 값을 읽고
- 값이 기대한 값이면 새 값으로 바꾸는
두 단계로 나뉘는 비원자적 연산처럼 보입니다.
하지만 이 연산은 CPU 하드웨어의 도움을 받아 원자적으로 수행됩니다.
CAS(Compare-And-Swap) 연산은 CPU 차원에서 값을 비교하고, 조건이 맞으면 변경하는 연산을 하나의 명령어로 수행합니다. 이것은 소프트웨어가 제공하는 기능이 아니라 하드웨어가 제공하는 기능입니다. 대부분의 현대 CPU들은 CAS 연산을 위한 명령어를 제공합니다. 예를 들어 아래 두 동작을 CPU가 하나의 원자적 명령어로 처리합니다.
- 메모리 주소 x001 에 있는 값을 읽는다.
- 그 값이 0이면 1로 바꾼다.
CPU는 두 과정을 하나의 원자적인 명령으로 만들기 위해 1번과 2번 사이에 다른 스레드가 x001 의 값을 변경하지 못하게 막습니다.
참고로 1번과 2번 사이의 시간은 CPU 입장에서 보면 아주 잠깐 찰나의 순간입니다. 그래서 성능에 큰 영향을 끼치지 않습니다.
지금까지의 설명을 보면, CAS 연산이 기대하는 값을 확인한 뒤, 해당 값이 일치하면 새로운 값으로 바꾸는 두 단계를 원자적으로 처리한다는 점은 이해했을 것입니다. 그렇다면 이러한 기능이 어떻게 일부 상황에서 락을 대신할 수 있는 것일까요?
CAS 락 구현
CAS의 필요성을 이해하기 위해 먼저 CAS 없이 락을 직접 구현해보겠습니다.
public class SpinLockBad {
private volatile boolean lock = false;
void lock() {
log("락 획득 시도");
while (true) {
if (!lock) { // 1. 락 사용 여부 확인
sleep(100); // 문제 상황 확인용, 스레드 대기
lock = true; // 2. 락의 값 변경
break;
} else {
// 락을 획득할 때 까지 스핀 대기(바쁜 대기) 한다.
log("락 획득 실패 - 스핀 대기");
}
}
log("락 획득 완료");
}
public void unlock() {
lock = false;
log("락 반납 완료");
}
}
- 스레드가 락을 획득하면 lock의 값이 true가 된다.
- 스레드가 락을 반납하면 lock의 값이 false가 된다.
- 스레드가 락을 획득하면 while문을 탈출한다.
- 스레드가 락을 획득하지 못하면 락을 획득할 때까지 while문을 계속 반복 실행한다.
public class SpinLockMain {
public static void main(String[] args) {
SpinLockBad spinLock = new SpinLockBad();
Runnable task = new Runnable() {
@Override
public void run() {
spinLock.lock();
try {
// critical section
log("비즈니스 로직 실행");
// sleep(1);
} finally {
spinLock.unlock();
}
}
};
Thread t1 = new Thread(task, "Thread-1");
Thread t2 = new Thread(task, "Thread-2");
t1.start();
t2.start();
}
}
실행 결과를 보면 기대와는 다르게 Thread-1과 Thread-2가 동시에 락을 획득하고 비즈니스 로직을 동시에 수행해버립니다.
이 문제는 두 스레드가 거의 동시에 lock == false 조건을 확인하고, 각자 락을 획득했다고 판단하여 동시에 critical section 에 진입했기 때문에 발생합니다. 이처럼 단순한 조건문(if)으로 락을 구현하면 멀티스레드 환경에서는 동기화가 보장되지 않으므로 원자적 연산을 보장할 수 있는 CAS가 필요합니다.
CAS 연산을 사용하면 두 개의 연산을 하나로 묶어 원자적으로 처리할 수 있습니다. 즉, 락이 사용 중인지 확인하고, 기대한 값과 일치하면 값을 변경하는 작업을 하나의 연산처럼 처리할 수 있습니다. 이 구조는 CAS(compare-and-set) 연산의 핵심 개념과 완전히 일치합니다.
참고로, 락을 반납하는 작업은 단일 대입 연산으로, 그 자체가 원자적입니다. 따라서 여러 스레드가 동시에 실행하더라도 문제가 발생하지 않습니다. 그렇다면 이번에는 CAS 연산을 지원하는 AtomicBoolean을 사용해서 락을 구현해봅시다.
public class SpinLock {
private final AtomicBoolean lock = new AtomicBoolean(false);
void lock() {
log("락 획득 시도");
while (!lock.compareAndSet(false, true)) {
// 락을 획득할 때 까지 스핀 대기(바쁜 대기) 한다.
log("락 획득 실패 - 스핀 대기");
}
log("락 획득 완료");
}
public void unlock() {
lock.set(false);
log("락 반납 완료");
}
}
락을 획득할 때 중요한 점은 두 연산을 하나로 묶어 원자적으로 실행해야 한다는 것입니다.
CAS 연산은 이 두 과정을 lock.compareAndSet(false, true) 한 번의 원자적 연산으로 처리해줍니다.
즉, 이 두 과정이 하나로 묶여 안전하게 실행됩니다.
- lock 값이 false인지 확인하고
- lock 값을 true로 변경합니다.
락을 획득하기 위해 lock 값이 false인 것을 확인한 순간부터, true로 변경할 때까지 lock 값은 절대 바뀌지 않아야 하며,
중간에 다른 스레드가 값을 변경하면 여러 스레드가 동시에 임계 영역에 진입하는 동시성 문제가 발생합니다.
원자적인 연산은 스레드 입장에서 쪼갤 수 없는 하나의 연산이므로, 여러 스레드가 동시에 실행해도 안전합니다. CAS를 이용해 이러한 원자적 연산을 구현함으로써, 무거운 동기화 작업 없이 가벼운 락을 만들 수 있었습니다. 동기화 락을 사용할 때는 락을 획득하지 못한 스레드가 BLOCKED나 WAITING 상태로 전환되고, 대기 중인 스레드를 깨우는 복잡하고 무거운 작업이 필요하여 성능이 상대적으로 느려질 수 있습니다. 반면 CAS를 활용한 락 방식은 실제로 락이 존재하지 않고, 단순히 while문을 반복하면서 대기하는 스레드가 RUNNABLE 상태를 유지해 가볍고 빠르게 동작합니다.
CAS 단점
CAS는 단점만 있을까요? 사실 CAS를 활용한 반복문 기반 락에도 한계가 있습니다. 다음 예제에서 sleep(1) 주석을 해제하고 실행해보면 어떤 문제가 발생하는지 알 수 있는데, 이를 통해 CAS 방식의 단점과 주의할 점을 함께 살펴보겠습니다.
Runnable task = new Runnable() {
@Override
public void run() {
spinLock.lock();
try {
// critical section
log("비즈니스 로직 실행");
sleep(1);
} finally {
spinLock.unlock();
}
}
};
이 방식은 락을 기다리는 스레드가 BLOCKED, WAITING 상태로 빠지지 않고 RUNNABLE 상태로 while문을 반복 실행한다는 점에서 차이가 있습니다. 즉, 락을 획득할 때까지 CPU를 계속 사용하며 대기하는 것입니다. BLOCKED나 WAITING 상태의 스레드는 CPU를 거의 사용하지 않지만, RUNNABLE 상태로 while문을 돌리는 방식은 CPU 자원을 계속 소모합니다. 반면, 동기화 락을 사용하면 스레드가 RUNNABLE 에서 BLOCKED, WAITING 상태로 전환되었다가 다시 돌아오면서 CPU 사용을 최소화할 수 있습니다. 따라서 이 방식은 스레드 상태 변화 없이 빠르게 락을 획득하고 즉시 실행해야 할 때 더 효율적입니다.
🤔 그렇다면 언제 이 방식을 사용하는 게 좋을까요?
- 임계 영역이 매우 짧고 빠르게 끝나는 연산일 때 효과적입니다.
- 예를 들어, 숫자 값 증가나 자료구조에 데이터 추가처럼 CPU 사이클이 짧은 작업에 적합합니다.
반대로 데이터베이스 쿼리 결과를 기다리거나 외부 서버 응답을 대기하는 등 오래 걸리는 작업에는 적합하지 않습니다.
이 경우에는 CPU를 계속 사용하며 대기하는 비효율이 심해질 수 있기 때문입니다.
💡 스핀락
스핀 락은 스레드가 락이 해제될 때까지 반복문으로 계속 확인하는 방식입니다. 마치 제자리에서 빙글빙글 도는 것처럼 보여서 이렇게 부릅니다. 이때 스레드는 락을 얻을 때까지 계속 기다리는데, 이를 스핀 대기(spin-wait) 또는 바쁜 대기(busy-wait)라고 합니다. 이 방식은 CPU 자원을 계속 사용하며 대기하는 특징이 있습니다. 스핀 락은 매우 짧은 시간 동안 락을 점유하는 연산에 적합합니다. 만약 오래 대기해야 하는 상황에서 사용하면 오히려 CPU 자원을 불필요하게 많이 소모할 수 있습니다. 정리하면, 스핀 락은 락을 획득하기 위해 반복적으로 락 상태를 확인하며 CPU 자원을 사용하는 락 메커니즘이며, 보통 CAS 연산을 활용해 구현됩니다.
정리하면, 스핀 락은 락을 획득하기 위해 반복적으로 락 상태를 확인하며 CPU 자원을 사용하는 락 메커니즘이며, 보통 CAS 연산을 활용해 구현됩니다.
락 VS CAS 사용 방식
멀티스레드 환경에서 자원 충돌을 막기 위해 보통 synchronized, Lock(ReentrantLock) 같은 동기화 락 을 사용합니다.
하지만 락을 사용하지 않고도 자원 보호가 가능한 방식이 있습니다. 바로 CAS를 활용한 락 프리(lock-free) 방식입니다.
지금부터 이 두 방식의 장단점을 비교해보겠습니다.
✅ CAS의 장점
- 낙관적 동기화: CAS는 충돌이 자주 발생하지 않을 것이라 가정하고, 락 없이 값을 안전하게 업데이트합니다. 충돌이 적은 환경에서는 높은 성능을 기대할 수 있습니다.
- 락 프리(Lock-Free): CAS는 락을 사용하지 않기 때문에, 스레드가 락을 획득하기 위해 대기하지 않습니다. 스레드가 블로킹되지 않으므로 병렬 처리 성능이 뛰어납니다.
❌ CAS의 단점
- 충돌이 잦은 경우의 성능 저하: 여러 스레드가 동시에 동일한 변수에 접근해 값을 변경하려고 하면 충돌이 발생합니다. 이 경우 CAS는 실패하고 루프를 돌며 재시도하게 되는데, 이 과정에서 CPU 자원이 계속 소모되고 성능이 저하될 수 있습니다.
- 스핀락과 유사한 오버헤드: 반복적인 CAS 실패로 인해 재시도 루프가 계속되면, 마치 스핀락처럼 CPU를 낭비하는 상황이 발생할 수 있습니다. 충돌 빈도가 높을수록 이 문제가 심각해집니다.
✅ 동기화 락의 장점
- 충돌 관리에 용이: 락은 하나의 스레드만 자원에 접근하도록 제한하므로 충돌이 발생하지 않습니다. 여러 스레드가 동시에 접근해도 안정적으로 동작할 수 있습니다.
- 안정성 보장: 복잡한 상황에서도 일관성 있는 실행 흐름을 보장하며, 동기화된 방식으로 코드를 관리할 수 있습니다.
- 낮은 CPU 사용률: 락을 기다리는 스레드는 WAITING, BLOCKED 상태로 대기하게 되어 CPU를 거의 사용하지 않습니다.
❌ 동기화 락의 단점
- 락 획득 대기 시간: 여러 스레드가 동시에 자원을 요청하면 락을 얻기 위해 대기해야 하고, 이 대기 시간이 길어질 수 있습니다.
- 컨텍스트 스위칭 오버헤드: 스레드가 락을 기다렸다가 실행되기 위해 상태 전환이 발생하며, 이 과정에서 컨텍스트 스위칭이 일어나고 성능 오버헤드가 생길 수 있습니다.
AS는 충돌 가능성이 낮은 환경에서는 매우 효율적으로 동작하지만, 충돌이 자주 발생하는 환경에서는 오히려 성능이 저하될 수 있습니다. 이러한 경우에는 상황에 맞는 적절한 동기화 전략을 선택하는 것이 중요합니다. 경우에 따라서는 락 기반의 방식이 더 나은 성능을 보일 수도 있으며, CAS가 항상 더 우수하다고 단정할 수는 없습니다. 따라서 각 방식의 특성과 동작 원리를 이해하고, 애플리케이션의 요구사항과 환경에 맞춰 적절한 동기화 방법을 선택하는 것이 바람직합니다.
'Java' 카테고리의 다른 글
[JAVA] Executor 프레임워크 (3) | 2025.07.01 |
---|---|
[JAVA] 동시성 컬렉션 (1) | 2025.06.30 |
[JAVA] ReentrantLock (1) | 2025.06.24 |
[JAVA] synchronized 동기화 (0) | 2025.06.21 |
[JAVA] happens-before 관계 (1) | 2025.06.20 |