0. 들어가며
읽는 방법
이 글은 책을 읽듯 자연스럽게 읽히도록 쓰려 노력했다. 기술 책을 읽다가 모르는 개념이 나오면 읽던 흐름을 끊고 찾아본 뒤 돌아와야 하는데, 그러면 앞뒤 맥락이 이어지지 않는 경험을 누구나 한 번쯤 해봤을 것이다. 그 불편함을 최소화하기 위해, 이 글에서 등장하는 개념들은 가능한 한 그 자리에서 바로 설명하고 넘어간다.
이 글은 다음과 같은 것들을 필요로 할때 도움이 된다.
- 동시성이 추상적으로 다가오는 경우
synchronized나AtomicInteger를 왜 쓰는지 모를때- 경합, CAS, Lock에 대해서 모르거나, 근본부터 이해하고 싶을 때
이 글은 왜(Why)를 먼저, 어떻게(How)를 나중에 설명하는 방식으로 작성했다. 각 제목이 다음 제목의 기반이 되기 때문에, 순서대로 읽는 것을 권장한다.
글을 읽기 위해 필요한 지식들
- 기본적인 프로그래밍 경험 / Java 코드 예시가 등장하지만, 개념 자체는 언어에 독립적이다.
- 대략적인 프로세스/스레드 개념 - “스레드가 병렬로 돌아간다” 정도
1. 컴퓨터는 어떻게 프로그램을 실행하는가
경합을 이해하려면 메모리 구조부터
프로세스란 무엇인가
프로그램은 디스크에 저장된 정적인 코드 덩어리다. 이걸 실행하면 OS가 메모리에 올리고, CPU가 명령을 하나씩 처리하기 시작한다. 이 실행 중인 프로그램의 인스턴스를 프로세스(Process) 라고 한다.
프로세스는 단순히 코드만 올라간 게 아니다. OS는 프로세스에게 독립적인 메모리 공간을 할당해준다. 이 공간은 크게 네 구역으로 나뉜다.
프로세스 메모리 구조 (코드, 데이터, 힙, 스택)
높은 주소
┌─────────────────────────────┐
│ 스택 (Stack) │ ← 함수 호출 시 자동 생성/소멸
│ ↓ grows down │
├─────────────────────────────┤
│ ... │
├─────────────────────────────┤
│ ↑ grows up │
│ 힙 (Heap) │ ← 동적 할당 (new, malloc)
├─────────────────────────────┤
│ 데이터 (Data) │ ← 전역변수, static 변수
├─────────────────────────────┤
│ 코드 (Text) │ ← 실행할 명령어들
└─────────────────────────────┘
낮은 주소
각 구역의 역할은 다음과 같다.
- 코드(Text): 실제 실행할 명령어가 담긴 영역. 읽기 전용이다.
- 데이터(Data): 전역 변수나
static변수가 저장되는 곳. 프로그램 시작 시 할당되고 종료 시 해제된다. - 힙(Heap): 런타임에 동적으로 할당하는 메모리 영역. Java에서
new로 객체를 만들면 여기에 올라간다. - 스택(Stack): 함수 호출 시 지역 변수, 매개변수, 리턴 주소 등이 쌓이는 영역. 함수가 끝나면 자동으로 해제된다.
스택은 왜 스레드마다 따로 있는가
스택이 하는 일을 생각해보면 자연스럽게 이해된다.
스택에는 지금 실행 중인 함수의 지역 변수와 리턴 주소가 들어있다. 스레드가 독립적으로 함수를 호출하고 실행하려면, 자신만의 실행 흐름을 추적할 공간이 필요하다.
스레드 A가 calculateA()를 호출하는 동안, 스레드 B는 calculateB()를 호출하고 있을 수 있다. 이 두 함수의 지역 변수가 같은 스택을 공유한다면 서로 덮어쓰는 재앙이 발생한다. 따라서 스택은 스레드마다 독립적으로 존재해야 한다.
Thread A의 스택 Thread B의 스택
┌──────────────┐ ┌──────────────┐
│ calculateA() │ │ calculateB() │
│ x = 10 │ │ y = 20 │
│ 리턴주소 │ │ 리턴주소 │
└──────────────┘ └──────────────┘
완전 독립 완전 독립
힙은 왜 공유되는가
힙에 올라가는 것들을 생각해보자. Java 기준으로 new로 만든 모든 객체가 힙에 올라간다.
// 이 객체는 힙에 올라간다
Counter counter = new Counter();
이 counter 객체에 여러 스레드가 접근하는 시나리오는 매우 자연스럽다. 웹 서버에서 동시 요청을 처리하는 스레드들이 같은 DB 커넥션 풀 객체에 접근하는 것처럼. 힙은 프로세스 안의 모든 스레드가 공유하는 공간이기 때문에, 스레드 간 데이터 공유가 가능해진다.
그리고 바로 이 공유 때문에 문제가 생긴다.
2. 스레드 경합은 어디서 오는가
근본 원인 파헤치기
스레드란 무엇인가 — 프로세스 안의 실행 흐름
프로세스는 메모리 공간을 포함한 하나의 독립된 실행 환경이다. 스레드는 그 프로세스 안에서 실제로 코드를 실행하는 실행 단위다.
프로세스 하나에 스레드가 여럿 존재할 수 있고, 이들은 힙과 코드 영역을 공유하면서 스택만 따로 가진다.
프로세스
├── 힙 (공유)
├── 코드 (공유)
├── Thread A → 스택 A (독립)
├── Thread B → 스택 B (독립)
└── Thread C → 스택 C (독립)
counter++ 한 줄이 사실은 세 줄이다
이제 핵심이다. Java에서 이렇게 쓴다.
counter++;
한 줄처럼 보이지만, CPU가 실제로 수행하는 작업은 세 단계다.
1. READ : 메모리에서 counter 값을 읽어 레지스터에 올린다
2. MODIFY: 레지스터 값을 1 증가시킨다
3. WRITE : 레지스터 값을 다시 메모리에 쓴다
이 세 단계는 원자적(atomic)이지 않다. 즉, 1번 이후 3번 이전에 다른 스레드가 끼어들 수 있다.
Race Condition 발생 시나리오 직접 그려보기
counter = 5인 상황에서 스레드 A와 B가 동시에 counter++를 시도한다.
시간 →
Thread A: [READ: 5] [MODIFY: 6] [WRITE: 6]
Thread B: [READ: 5] [MODIFY: 6] [WRITE: 6]
메모리: 5 6
두 스레드가 각각 증가시켰으니 결과가 7이어야 하는데, 6이 나온다. B가 A의 쓰기 결과를 덮어써버렸기 때문이다. 이게 Race Condition이다.
왜 싱글코어에서도 경합이 발생하는가 (컨텍스트 스위치)
“코어가 하나면 동시에 실행이 안 되니까 안전한 거 아닌가?” 라고 생각할 수 있다. 아니다.
OS는 여러 스레드를 번갈아가며 실행한다. 스레드 A를 잠깐 실행하다 멈추고, B를 실행하다 멈추고, 다시 A를 실행하는 식이다. 이걸 컨텍스트 스위치(Context Switch) 라고 한다.
싱글코어에서도:
시간 →
CPU: [Thread A: READ=5] [Context Switch!] [Thread B: READ=5, WRITE=6] [Thread A: WRITE=6]
A가 READ한 뒤 컨텍스트 스위치가 일어나면, B가 먼저 쓰고 나서야 A가 쓰게 된다. 싱글코어도 안전하지 않다.
왜 멀티코어에서 더 심각한가 (진짜 동시 실행)
멀티코어에서는 말 그대로 두 스레드가 물리적으로 동시에 실행된다.
멀티코어에서:
Core 1 (Thread A): [READ=5] ──────────────── [WRITE=6]
Core 2 (Thread B): [READ=5] [WRITE=6]
컨텍스트 스위치를 기다릴 필요도 없이 충돌이 발생한다. 발생 빈도가 훨씬 높아진다.
CPU 캐시가 만드는 또 다른 경합 — 가시성 문제 (Visibility)
Race Condition과는 다른 문제가 하나 더 있다. 쓰기 자체는 성공했는데, 다른 스레드가 그 값을 못 보는 상황이다.
이건 CPU 캐시 때문인데, 다음 챕터에서 자세히 다룬다.
3. 가시성 문제 — 경합의 숨은 주범
Race Condition과는 다른 차원의 문제
CPU 캐시 구조 (L1/L2/L3)
CPU가 메모리에서 값을 읽을 때마다 매번 RAM에 접근하면 너무 느리다. 그래서 CPU는 자체 캐시를 가지고 있다.
CPU Core 1 CPU Core 2
┌──────────┐ ┌──────────┐
│ L1 캐시 │ │ L1 캐시 │ ← 코어마다 독립
│ L2 캐시 │ │ L2 캐시 │ ← 코어마다 독립
└────┬─────┘ └─────┬────┘
│ │
└──────┬─────────────┘
L3 캐시 ← 공유 (있는 경우)
│
RAM
접근 속도 차이는 극적이다. L1 캐시는 약 1~4 사이클, RAM은 200~300 사이클이 걸린다.
스레드 A가 쓴 값을 스레드 B는 왜 못 보는가
스레드 A가 Core 1에서 실행되며 counter = 1을 썼다. 이 값은 우선 Core 1의 L1 캐시에 올라간다. RAM에 즉시 반영되지 않을 수 있다.
스레드 B는 Core 2에서 실행된다. counter를 읽으면 Core 2의 캐시나 RAM에서 읽는데, A가 쓴 값이 아직 전파되지 않았다면 이전 값을 읽게 된다.
Core 1이 counter=1 씀
→ Core 1의 L1 캐시에만 반영
→ Core 2는 아직 counter=0을 보고 있음
이게 가시성(Visibility) 문제다. 값이 잘못 덮어써지는 Race Condition과는 다른 버그다.
volatile 키워드가 하는 일
Java에서 volatile을 변수에 선언하면 두 가지를 보장한다.
- 모든 읽기는 메인 메모리(RAM)에서 직접 읽는다.
- 모든 쓰기는 즉시 메인 메모리에 반영된다.
volatile boolean running = true;
// Thread A
running = false; // 즉시 RAM에 반영됨
// Thread B
while (running) { // 항상 RAM에서 읽어옴
doWork();
}
volatile만으로는 Race Condition을 막지 못한다는 점에 주의하자. counter++를 volatile로 선언해도 read-modify-write의 원자성은 보장되지 않는다. 가시성만 보장한다.
Memory Barrier / Memory Fence란 무엇인가
CPU는 성능 최적화를 위해 명령어 순서를 재배열(reordering)하기도 한다. 이게 가시성 문제를 더욱 복잡하게 만든다.
Memory Barrier(메모리 장벽)는 CPU에게 “이 시점에서 모든 메모리 작업을 완료하고 캐시를 플러시하라”고 지시하는 명령이다.
volatile 변수에 대한 쓰기는 내부적으로 Memory Barrier를 포함한다. 그래서 쓴 값이 즉시 다른 코어에 보이게 된다.
Happens-before 관계
Java 메모리 모델(JMM)은 happens-before 관계를 통해 가시성 보장을 형식화한다.
“A happens-before B”는 A의 결과가 B에게 반드시 보인다는 보장이다. 주요 규칙은 다음과 같다.
- 같은 스레드 안에서, 앞의 작업은 뒤의 작업보다 happens-before
volatile쓰기는 이후의volatile읽기보다 happens-beforesynchronized블록의 해제는 이후 획득보다 happens-beforeThread.start()는 시작된 스레드의 모든 작업보다 happens-before
4. 경합을 해결하는 세 가지 접근
Lock, Lock-free, Wait-free
Mutual Exclusion (상호 배제) — 한 번에 하나만 들어가
가장 직관적인 방법이다. 임계 구역(Critical Section)에 한 번에 하나의 스레드만 들어가도록 잠금(Lock) 을 건다.
Thread A → [Lock 획득] → [임계 구역 실행] → [Lock 해제]
Thread B → [대기... ] ──────────────────→ [Lock 획득] → [임계 구역 실행]
구현이 단순하고 정확성을 보장하기 쉽다. 하지만 대기하는 스레드가 블로킹되므로 성능 손실이 있다.
Lock-free — 누군가는 반드시 진행됨을 보장
Lock을 사용하지 않으면서 다음을 보장한다: 전체 스레드 중 적어도 하나는 항상 진행 중이다.
CAS(Compare-And-Swap)가 대표적인 구현 방법이다. 개별 스레드가 실패(재시도)할 수 있지만, 시스템 전체가 멈추는 일은 없다.
Lock-free는 Deadlock이 발생하지 않는다는 큰 장점이 있다.
Wait-free — 모두가 유한 시간 안에 진행됨을 보장
Lock-free보다 더 강한 조건이다. 모든 스레드가 유한한 시간 안에 반드시 완료된다.
구현이 매우 복잡하고 현실에서 사용되는 경우는 드물다. 이론적으로는 가장 이상적인 모델이다.
세 가지의 트레이드오프 비교
| 특성 | Lock (Mutual Exclusion) | Lock-free | Wait-free |
|---|---|---|---|
| 구현 복잡도 | 낮음 | 중간 | 매우 높음 |
| Deadlock 가능 | 있음 | 없음 | 없음 |
| 개별 스레드 기아 | 가능 | 가능 | 불가능 |
| 성능 (경합 없을 때) | 중간 | 높음 | 높음 |
| 성능 (경합 많을 때) | 낮음 | 중간 | 높음 |
현실에서는 Lock과 Lock-free를 상황에 따라 선택하고, Wait-free는 특수 목적 시스템에서만 쓰인다.
5. CAS — Lock 없이 경합을 해결하다
CPU 명령어 레벨의 마법
Optimistic Lock vs Pessimistic Lock 개념
경합에 대한 두 가지 철학이 있다.
Pessimistic Lock (비관적 락): “어차피 경합이 일어날 거야, 미리 잠그자.”
→ 임계 구역에 들어가기 전에 무조건 Lock을 건다. 전통적인 synchronized가 이 방식이다.
Optimistic Lock (낙관적 락): “아마 경합 안 일어나겠지, 일단 해보고 충돌나면 재시도하자.” → Lock 없이 작업하고, 완료 시점에 누가 먼저 썼는지 확인한다. CAS가 이 방식이다.
경합이 드물수록 Optimistic이 유리하고, 경합이 빈번할수록 Pessimistic이 안정적이다.
CAS (Compare-And-Swap) 의 작동 원리
CAS는 세 개의 인자를 받는다.
CAS(메모리주소, 기대값, 새값)
→ 메모리주소의 값이 기대값과 같으면, 새값으로 교체하고 true 반환
→ 다르면, 아무것도 하지 않고 false 반환
의사코드로 표현하면 이렇다.
function CAS(addr, expected, newVal):
if *addr == expected:
*addr = newVal
return true
else:
return false
중요한 점은 이 비교-교체 동작 전체가 원자적으로 실행된다는 것이다. 중간에 아무도 끼어들 수 없다.
왜 CAS는 atomic한가 — CPU 명령어(LOCK CMPXCHG) 레벨로
CAS는 소프트웨어 레벨의 추상이 아니다. x86 CPU는 LOCK CMPXCHG라는 하드웨어 명령어를 제공한다.
LOCK CMPXCHG [메모리주소], 새값
; AL/AX/EAX에 기대값이 들어있다고 가정
; LOCK 접두어가 버스를 잠가서 다른 코어의 접근을 차단
LOCK 접두어는 해당 명령어 실행 동안 메모리 버스를 독점한다. 다른 코어는 이 시간 동안 해당 메모리에 접근할 수 없다. 이로써 비교와 교체가 완전한 원자성을 갖는다.
Java에서 sun.misc.Unsafe나 VarHandle이 내부적으로 이 명령어를 호출한다.
CAS 성공/실패 시나리오
초기값: counter = 5
Thread A: CAS(counter, 기대=5, 새값=6)
→ counter가 5이면 6으로 바꿈 → 성공 ✓
Thread B (동시 시도): CAS(counter, 기대=5, 새값=6)
→ counter가 이미 6 (A가 바꿨음) → 실패 ✗
→ counter 값 다시 읽음: 6
→ CAS(counter, 기대=6, 새값=7) → 성공 ✓
실패 시 현재 값을 다시 읽고 재시도한다. 이를 CAS loop 또는 spin 이라고 한다.
CAS 기반 직접 구현해보기
Java에서 AtomicInteger가 내부적으로 CAS를 사용한다. 직접 흉내내보면 이렇다.
// AtomicInteger 내부와 유사한 구현
public class CasCounter {
private volatile int value;
public int incrementAndGet() {
while (true) {
int current = value; // 1. 현재 값 읽기
int next = current + 1; // 2. 새 값 계산
if (compareAndSet(current, next)) { // 3. CAS 시도
return next;
}
// 실패하면 처음부터 다시
}
}
// 실제로는 Unsafe.compareAndSwapInt()를 호출
private boolean compareAndSet(int expected, int update) {
// CPU의 LOCK CMPXCHG 명령어가 실행됨
return UNSAFE.compareAndSwapInt(this, valueOffset, expected, update);
}
}
Lock이 전혀 없지만, 여러 스레드가 동시에 접근해도 값이 정확하게 증가한다.
ABA 문제 — CAS의 치명적 약점
CAS는 값이 기대값과 같은지만 본다. 중간에 값이 변경되었다가 다시 원래 값으로 돌아온 경우를 구분하지 못한다.
counter = A (값 A)
Thread A: READ → 5 (기대값으로 저장)
Thread B: 5 → 10 → 5 (값을 바꿨다가 다시 원래대로)
Thread A: CAS(기대=5, 새값=6) → 성공! (중간에 변화가 있었는데도)
단순 숫자 카운터에서는 문제없지만, 포인터 기반 자료구조(연결 리스트 등) 에서는 심각한 버그를 유발할 수 있다.
ABA 해결법 — Stamped Reference, Version Counter
핵심 아이디어: 값과 함께 버전 번호(스탬프)를 같이 비교한다.
(값=5, 버전=1) → (값=10, 버전=2) → (값=5, 버전=3)
버전이 달라졌기 때문에, Thread A의 CAS는 (기대=5, 버전=1)으로 체크하면 실패한다.
Java에서는 AtomicStampedReference가 이를 제공한다.
AtomicStampedReference<Integer> ref =
new AtomicStampedReference<>(5, 0); // (초기값, 초기스탬프)
int[] stampHolder = new int[1];
int current = ref.get(stampHolder); // 값과 스탬프 함께 읽기
int stamp = stampHolder[0];
ref.compareAndSet(current, current + 1, stamp, stamp + 1);
// 값과 스탬프 모두 일치해야 교체 성공
6. Lock — OS와 협력하는 동기화
Busy-wait와 Sleep, 무엇이 나은가
Mutex, Semaphore, Monitor 개념 정리
Mutex (Mutual Exclusion Lock)
오직 한 스레드만 잠글 수 있고, 잠근 스레드만 해제할 수 있다. 가장 기본적인 동기화 도구다.
Mutex m;
m.lock(); // 다른 스레드는 여기서 대기
// 임계 구역
m.unlock(); // 대기 중인 스레드 깨움
Semaphore
내부에 정수 카운터를 가지고 있다. N개의 자원을 동시에 N개의 스레드가 사용할 수 있도록 제한한다. Mutex는 Semaphore(N=1)의 특수한 경우다.
Semaphore sem = new Semaphore(3); // 최대 3개 동시 접근 허용
sem.acquire(); // 카운터 감소, 0이면 대기
// 자원 사용
sem.release(); // 카운터 증가, 대기 중인 스레드 깨움
Monitor
Java의 synchronized가 Monitor 방식이다. Lock과 Condition Variable(대기/통지 메커니즘)을 함께 제공하는 고수준 추상화다. 모든 Java 객체는 내부에 Monitor를 가진다.
Busy-wait (스핀락) — CPU를 태워서 빠르게
스핀락은 Lock이 해제되기를 기다리는 동안 루프를 돌면서 계속 확인한다.
// 스핀락 의사코드
while (!lock.tryAcquire()) {
// 아무것도 안 함, 그냥 계속 체크
}
// Lock 획득 성공
언제 유리한가
Lock이 아주 짧은 시간 안에 해제될 것이 확실할 때 유리하다. OS에 스레드를 재우고 깨우는 컨텍스트 스위치 비용(수천~수만 사이클)보다, 잠깐 스핀하는 게 오히려 빠를 수 있다.
커널 공간에서, 인터럽트 핸들러에서, 실시간 시스템에서 많이 사용된다.
언제 독이 되는가
Lock이 오래 유지될 때는 최악이다. CPU 코어 하나를 100% 점유하면서 아무 일도 못 하고 기다린다. 다른 스레드가 그 코어를 사용할 수 없으니, Lock을 가진 스레드가 더 느려지는 역효과도 날 수 있다. 배터리 소모와 발열도 증가한다.
Notify 방식 (슬립락) — OS한테 맡기기
슬립락은 Lock을 획득하지 못하면 OS에게 스레드를 재워달라고 요청한다. Lock이 해제되면 OS가 깨워준다.
Thread B가 Lock 획득 실패
→ OS에 "나 재워줘" 요청
→ Thread B는 WAITING 상태로 전환
→ 다른 스레드가 CPU 사용
→ Thread A가 Lock 해제 + OS에 "B 깨워줘" 요청
→ OS가 Thread B를 RUNNABLE 상태로 전환
→ Thread B가 Lock 획득 성공
컨텍스트 스위치 비용이란
스레드를 전환할 때 OS는 현재 스레드의 레지스터 상태, 프로그램 카운터 등 실행 컨텍스트를 저장하고, 새 스레드의 컨텍스트를 복원해야 한다. 이 과정이 수천~수만 사이클을 소모한다. 캐시도 오염된다(Cache Thrashing).
깨어나는 데 얼마나 걸리는가
슬립 → 깨어남의 지연 시간은 OS 스케줄러에 달려있다. 일반적인 Linux에서 약 10~100 마이크로초다. 이 지연이 허용 가능한지에 따라 슬립락의 적합성이 달라진다.
Adaptive Lock — 둘을 섞은 현실의 선택
실제 VM과 OS는 두 방식을 섞는다. 이를 Adaptive Lock 또는 Hybrid Lock이라 한다.
Lock 획득 시도
→ 실패
→ 짧게 스핀 (예: 수십~수백 번)
→ 그 사이 Lock 해제됨 → 획득 성공 (스핀락 승리)
→ 여전히 잠겨있음 → OS에 슬립 요청 (슬립락으로 전환)
Java synchronized 내부 동작
Java의 synchronized는 JVM이 내부적으로 Biased Lock → Thin Lock → Fat Lock 순으로 전환한다.
- Biased Lock: 경합이 없는 경우, CAS조차 안 하고 스레드 ID만 마킹. 거의 비용 없음.
- Thin Lock: 경합이 생기면 CAS 기반 스핀. 가볍게 처리.
- Fat Lock: 스핀으로 안 되면 OS Mutex 사용. 슬립/깨움 발생.
JVM이 런타임에 경합 패턴을 보고 자동으로 전환한다.
Linux futex 동작 원리
futex(fast userspace mutex)는 Linux의 핵심 동기화 기본 요소다.
경합이 없을 때는 유저 공간에서만 처리(시스템 콜 없음)하고, 경합이 생길 때만 커널에 내려간다.
Lock 획득:
1. 유저 공간에서 원자적으로 Lock 상태 변경 시도
2. 성공하면 끝 (커널 진입 없음)
3. 실패하면 futex_wait() 시스템 콜로 커널에 내려가 대기
Lock 해제:
1. 유저 공간에서 Lock 상태 변경
2. 대기자가 있으면 futex_wake() 시스템 콜로 깨움
불필요한 시스템 콜을 최소화해서 성능을 극대화하는 설계다.
Lock의 문제들: Deadlock, Starvation, Priority Inversion
Deadlock (교착 상태)
두 스레드가 서로가 가진 Lock을 기다리며 영원히 멈추는 상황이다.
Thread A: Lock1 보유, Lock2 대기
Thread B: Lock2 보유, Lock1 대기
→ 둘 다 영원히 대기
해결책: 항상 같은 순서로 Lock을 획득하거나, 타임아웃을 설정한다.
Starvation (기아 상태)
특정 스레드가 Lock을 영원히 획득하지 못하는 상황이다. 다른 스레드들이 계속 먼저 획득하면 발생한다. Fair Lock(공정 락)으로 해결할 수 있다.
Priority Inversion (우선순위 역전)
높은 우선순위 스레드가 낮은 우선순위 스레드가 가진 Lock을 기다리는 상황이다. 결과적으로 낮은 우선순위 스레드가 먼저 실행된다. 실시간 시스템에서 치명적이며, Priority Inheritance(우선순위 상속)로 해결한다.
7. 현실에서의 선택 — 언제 무엇을 쓰는가
상황별 선택 기준
짧은 임계구역 → 스핀락
임계 구역 실행 시간이 컨텍스트 스위치 비용(수천 사이클)보다 짧다면 스핀락이 유리하다. 커널 드라이버, 인터럽트 핸들러, 고성능 서버의 핫패스에서 사용된다.
긴 임계구역 → 슬립락
임계 구역이 I/O를 포함하거나 긴 계산이 있다면 슬립락이 맞다. 기다리는 동안 CPU를 다른 스레드에게 양보해야 한다.
단순 카운터 → Atomic (CAS)
단일 변수의 원자적 증감이라면 Lock 자체가 오버킬이다. AtomicInteger 등의 Atomic 클래스를 사용한다. Lock-free이면서도 thread-safe하다.
복잡한 자료구조 → Lock-free 자료구조 or Lock
여러 변수를 함께 원자적으로 변경해야 한다면 CAS만으로는 한계가 있다. ConcurrentLinkedQueue같은 Lock-free 자료구조를 사용하거나, ReentrantLock으로 임계 구역을 직접 관리한다.
Java 기준 실전 선택 가이드
| 상황 | 선택 | 이유 |
|---|---|---|
| 단순 숫자 증감 | AtomicInteger |
Lock-free, 빠름 |
| 단순 플래그 | volatile boolean |
가시성만 필요 |
| 간단한 임계 구역 | synchronized |
간단하고 JVM이 최적화 |
| 타임아웃 필요 | ReentrantLock |
tryLock(timeout) 제공 |
| 읽기가 압도적으로 많음 | ReadWriteLock |
읽기 동시성 허용 |
| 컬렉션 동시 접근 | ConcurrentHashMap 등 |
내부적으로 최적화된 동기화 |
| 고성능 카운터 | LongAdder |
경합 시 AtomicLong보다 빠름 |
LongAdder 참고: 경합이 많을 때 AtomicLong보다 LongAdder가 빠른 이유는, 스레드마다 독립적인 카운터를 두고 최종 합산하기 때문이다. CAS 충돌을 내부적으로 분산시킨다.
// 단순 카운터
AtomicInteger count = new AtomicInteger(0);
count.incrementAndGet();
// 고경합 카운터
LongAdder adder = new LongAdder();
adder.increment();
long total = adder.sum();
// 복잡한 임계 구역
ReentrantLock lock = new ReentrantLock();
if (lock.tryLock(1, TimeUnit.SECONDS)) {
try {
// 임계 구역
} finally {
lock.unlock(); // finally에서 반드시 해제
}
}
8. 마치며
핵심 흐름 한 장으로 정리
힙은 공유된다
↓
여러 스레드가 동시에 읽고 쓰면
↓
Race Condition (값 유실)
CPU 캐시 불일치 (Visibility 문제)
↓
해결 방법 세 갈래
├── volatile → 가시성만 해결
├── CAS (Atomic) → 단일 변수 Lock-free 원자 연산
└── Lock → 임계 구역 보호
├── Spin Lock → 짧은 대기, CPU 소모
├── Sleep Lock → 긴 대기, OS 협력
└── Adaptive → 상황 따라 자동 전환 (현실의 선택)
동시성의 복잡함은 결국 하나의 질문으로 귀결된다.
“공유 자원에 동시에 접근하는 상황에서, 어떻게 정확성과 성능을 함께 잡을 것인가?”
정답은 없다. 상황을 이해하고, 트레이드오프를 알고, 맥락에 맞는 도구를 선택하는 것이 전부다.
더 공부할 것들
이 글을 이해했다면 다음 주제들이 자연스럽게 이어진다.
LMAX Disruptor Lock-free Ring Buffer 기반의 초고성능 이벤트 처리 라이브러리. CAS와 캐시 라인 최적화를 극단적으로 활용한다. “왜 이게 이렇게 빠른가”를 분석하면 지금까지 배운 개념이 실전에 어떻게 적용되는지 보인다.
STM (Software Transactional Memory) 데이터베이스 트랜잭션처럼 메모리 접근을 트랜잭션으로 추상화하는 방식. Haskell의 STM이 유명하다. Lock 없이 복잡한 원자성을 표현하는 또 다른 접근법이다.
Actor 모델 공유 메모리 자체를 포기하는 접근법. 각 Actor는 독립적인 상태를 가지고, 메시지로만 통신한다. Erlang, Akka가 대표적이다. 경합 자체가 생기지 않는 구조를 설계하는 사고방식을 배울 수 있다.