멀티스레드에서 반복되는 핵심 문제
멀티스레드 버그는 보통 가시성 문제에서 시작합니다.
가시성 문제는 한 스레드의 변경이 다른 스레드에 제때 보이지 않는 현상입니다.
원인은 대부분 동기화 규칙 부재입니다.
자바 메모리 모델의 기본 구조
자바는 스레드마다 **작업 메모리(로컬 캐시)**를 가질 수 있습니다.
모든 스레드가 공유하는 공간은 **메인 메모리(힙 등)**입니다.
- 작업 메모리의 값은 메인 메모리에 즉시 반영되지 않을 수 있습니다.
- CPU 캐시와 버퍼링으로 일괄 반영이 일어납니다.
- 성능 최적화 목적의 지연 반영이 가시성 버그를 만듭니다.
코드 순서와 실제 실행 순서의 분리
소스 코드의 실행 순서는 멀티스레드에서 그대로 보장되지 않습니다.
- 실행 순서를 바꾸는 주요 요인들입니다.
- CPU 캐시로 인해 최신 값이 아닌 값을 읽을 수 있습니다.
- 컴파일러 최적화로 명령어가 재배치될 수 있습니다.
- JIT 최적화로 재정렬이 더 공격적으로 적용될 수 있습니다.
이 문제를 통제하는 규칙이 happens-before입니다.
happens-before의 의미
happens-before 관계가 성립하면 앞의 쓰기가 뒤의 읽기에 보입니다.
happens-before 관계가 없으면 최신 값 가시성을 기대할 근거가 없습니다.
- 자주 쓰는 happens-before 기준입니다.
- synchronized에서 락 해제는 이후 같은 락 획득보다 먼저입니다.
- volatile write는 이후 volatile read보다 먼저입니다.
- Thread.start() 이전 작업은 새 스레드 시작 이후보다 먼저입니다.
- **Thread.join()**으로 기다린 스레드의 종료 전 작업은 join 이후보다 먼저입니다.
- 클래스 초기화(정적 초기화) 완료는 해당 클래스를 사용하는 스레드에 안전하게 공개됩니다.
일반 변수의 한계
일반 변수는 “동기화 시점”이 정의되지 않습니다.
즉, 메인 메모리 반영 시점과 다른 스레드 관측 시점이 보장되지 않습니다.
- 위험해지는 조건입니다.
- 여러 스레드가 같은 변수를 읽고 씁니다.
- happens-before 관계가 없습니다.
- 명시적 동기화가 없습니다.
스레드 시작과 종료에서의 동기화 포인트
**Thread.start()**는 대표적인 공개 안전 지점입니다.
- start 직전 상태와 start 직후 관측 사이에 규칙이 생깁니다.
- 부모 스레드가 start 이전에 수행한 작업은 새 스레드에 보이도록 정의됩니다.
- 새 스레드는 동기화된 기준으로 값을 로딩하게 됩니다.
다만 “이미 실행 중인 다른 스레드들까지 함께 동기화”되는 보장은 없습니다.
기존 스레드 간 가시성은 별도의 happens-before가 필요합니다.
**Thread.join()**은 종료 기반 동기화 지점입니다.
- join 이후에는 대상 스레드의 종료 전 작업이 현재 스레드에 보이도록 정의됩니다.
volatile의 역할과 장단점
volatile은 메모리 가시성을 제공하는 키워드입니다.
volatile 변수는 캐시 값이 아니라 메인 메모리 기준으로 읽고 쓰도록 강제합니다.
-
volatile의 효과입니다.
- 읽기 시점마다 최신 값이 보이도록 규칙이 걸립니다.
- 컴파일러와 CPU의 재정렬이 제한됩니다.
- happens-before 관계를 만들 수 있습니다.
-
비용과 주의점입니다.
- 메인 메모리 접근이 늘어 성능 비용이 생길 수 있습니다.
- 가시성만 보장하고 원자성은 보장하지 않습니다.
volatile만으로 안전하지 않은 대표 케이스
복합 연산은 원자 연산이 아닙니다.
따라서 volatile만으로 레이스가 막히지 않습니다.
- 대표적인 위험 연산입니다.
count++count += 1x = x + y같은 읽기-계산-쓰기 형태
이 경우 필요한 선택지는 아래입니다.
- synchronized
- Lock
- Atomic 클래스
volatile이 적합한 전형적인 사용처
“한 스레드만 쓰고 여러 스레드가 읽는 구조”에 적합합니다.
즉, 최신 값의 가시성만 확보하면 되는 신호 변수에 적합합니다.
- 대표 사례입니다.
- 종료 플래그
- 상태 플래그
- 구성 리로드 트리거
예시입니다.
- 종료 플래그 예시입니다.
volatile boolean running = true;- 작업 스레드는
while (running)으로 루프를 실행합니다. - 종료 스레드는
running = false로 종료 신호를 보냅니다.
synchronized, Lock, Atomic 선택 기준
동기화 요구사항을 분해하면 선택이 쉬워집니다.
-
synchronized가 적합한 상황입니다.
- 임계구역 보호가 필요합니다.
- 여러 필드의 일관성을 한 덩어리로 보장해야 합니다.
- 구현 단순성이 중요합니다.
-
Lock이 적합한 상황입니다.
- 타임아웃, 공정성, 조건 변수 등 고급 제어가 필요합니다.
- 락 획득과 해제를 더 세밀하게 다루어야 합니다.
-
Atomic이 적합한 상황입니다.
- 단일 변수의 원자 연산이 핵심입니다.
- 카운터, 플래그의 CAS 기반 갱신이 필요합니다.
클래스 로딩과 정적 초기화의 공개 안전성
정적 초기화는 클래스 초기화 과정에서 한 번만 실행됩니다.
그리고 초기화 완료 결과는 다른 스레드에 안전하게 공개되도록 규칙이 잡혀 있습니다.
- 활용 패턴입니다.
- 초기화 지연 홀더 패턴
- 정적 내부 클래스 기반 싱글턴
정적 초기화 이후의 상태 변경은 별개 문제입니다.
정적 필드 변경은 별도의 동기화가 필요할 수 있습니다.
디버깅이 재현성을 깨는 이유
브레이크포인트는 실행을 멈추고 타이밍을 크게 바꿉니다.
그 결과 버그가 사라지는 착시가 생깁니다.
- 흔한 변화 요인입니다.
- 스케줄링 순서 변화
- JIT 최적화 조건 변화
- 특정 시점의 메모리 장벽 효과 동반 가능
“브레이크포인트가 동기화를 보장한다”로 일반화하면 위험합니다. (대개는 타이밍 교란 영향이 더 큽니다)
System.out.println이 동기화처럼 보이는 현상
System.out은 PrintStream입니다.
PrintStream의 많은 메서드는 내부적으로 동기화를 사용합니다.
그래서 println을 넣으면 문제가 줄어드는 것처럼 보일 수 있습니다.
이 방식은 해결책이 아닙니다.
출력 코드는 타이밍을 바꾸는 부작용이 커서 재발 가능성이 높습니다.
결론
멀티스레드에서 확인할 질문은 하나입니다.
이 읽기와 쓰기 사이에 happens-before 관계가 존재하는가입니다.
-
빠른 선택 가이드입니다.
- 가시성만 필요하면 volatile입니다.
- 복합 연산과 일관성이 필요하면 synchronized 또는 Lock입니다.
- 원자 카운터가 필요하면 Atomic입니다.
- 스레드 경계는 start, join으로 규칙을 확보합니다.
-
피해야 할 착시입니다.
- 디버거와 println은 버그를 숨길 수 있습니다.
- 재현이 안 되면 동기화가 된 것이 아니라 환경이 바뀐 것입니다.
레이스 컨디션이 만들어지는 전형적 상황
- 파일 처리 스레드가 큐에 이벤트를 추가합니다.
- UI 스레드가 큐에서 이벤트를 삭제합니다.
- 둘이 같은 시점에 같은 링크를 건드리면 아래가 발생합니다.
- 조회 중에 노드가 끼어듭니다.
- 삭제 중에 노드가 다시 연결됩니다.
- 결과적으로 리스트가 찢어지거나 루프가 생길 수 있습니다.
임계 영역은 어디로 잡아야 하나
-
임계 영역은 “공유 불변식”이 깨지는 구간입니다.
-
연결 리스트의 불변식은 아래처럼 정리할 수 있습니다.
-
불변식 예시
prev.next == thisnext.prev == thishead.next와tail.prev가 항상 일관됩니다.counter가 실제 노드 개수와 일치합니다.
-
즉 아래가 동시에 바뀌는 구간이 임계 영역입니다.
- 링크 갱신
- head tail 갱신
- 길이 카운터 갱신
예제 코드로 보는 레이스 포인트
노드 추가 appendNode
public boolean appendNode(String name) {
UserData newUser = new UserData(name);
newUser.prev = tail.prev;
newUser.next = tail;
tail.prev.next = newUser; // A Start
tail.prev = newUser;
++counter; // A End
return true;
}
- 여기서 위험한 지점은 아래입니다.
tail.prev.next = newUsertail.prev = newUser++counter
- 이 구간은 “작은 것처럼 보여도” 하나의 트랜잭션입니다.
- 중간에 끼어들면 tail 체인이 깨질 수 있습니다.
counter도 같이 깨집니다
counter++는 원자 연산이 아닙니다.- 그래서 동기화 없이 쓰면 값이 틀어질 수 있습니다.
counter는 아래 중 하나가 필요합니다.- 임계 영역 안에서만 갱신
AtomicInteger사용
노드 삭제 removeAtHead
public UserData removeAtHead() {
if (isEmpty()) return null;
UserData node = head.next; // 임계영역 Start
node.next.prev = head;
head.next = node.next;
--counter; // 임계영역 End
return node;
}
- 여기서도 링크 2개와 카운터 1개가 한 묶음입니다.
- 이 도중에 append가 끼면 아래가 나옵니다.
- 삭제한 노드가 다시 참조됩니다.
- head.next가 잘못된 노드를 가리킵니다.
- 리스트가 끊기거나 순환할 수 있습니다.
join이 있을 때는 왜 멀쩡해 보이나
producer.join()을 하면 생산이 끝난 뒤 소비가 시작됩니다.- 그래서 동시에 같은 링크를 건드릴 일이 줄어듭니다.
- 즉 레이스가 사라진 게 아니라 환경이 안전해진 것입니다.
join 없이 동시에 돌리면 왜 터지나
- 생산과 소비가 같은 자료구조를 동시에 만집니다.
- 임계 영역이 없으면 연결 구조가 쉽게 손상됩니다.
- 그래서
counter가 깨지고 구조 자체도 깨질 수 있습니다.
해결 1 synchronized로 감싸기
- 가장 쉬운 개선은 메서드를 통째로 동기화하는 것입니다.
public synchronized boolean appendNode(String name) { ... }
public synchronized UserData removeAtHead() { ... }
- 장점
- 구현이 단순합니다.
- 구조 손상이 거의 사라집니다.
- 단점
- 필요 없는 코드까지 임계 영역이 됩니다.
- 경쟁이 늘면 병목이 커집니다.
해결 2 ReentrantLock으로 임계 영역만 줄이기
- 핵심은 “링크 갱신 구간만” 락으로 묶는 것입니다.
import java.util.concurrent.locks.ReentrantLock;
class LinkedQueue {
private final ReentrantLock lock = new ReentrantLock();
private int counter = 0;
public boolean appendNode(String name) {
lock.lock();
try {
// 링크 갱신 + counter 갱신만 보호
// tail.prev.next = newUser;
// tail.prev = newUser;
// counter++;
return true;
} finally {
lock.unlock();
}
}
public UserData removeAtHead() {
lock.lock();
try {
// 링크 갱신 + counter 갱신만 보호
// head.next = node.next;
// node.next.prev = head;
// counter--;
return null;
} finally {
lock.unlock();
}
}
}
- 장점
- 임계 영역을 줄이기 쉽습니다.
tryLock()같은 선택지도 생깁니다.
- 단점
- 락 해제를 빼먹으면 장애로 이어집니다.
- 설계 난도가 올라갑니다.
CAS Compare And Swap이란 무엇인가
- CAS는 Compare And Swap 또는 Compare And Set입니다.
- 락 없이 원자적으로 바꾸기 위한 핵심 원리입니다.
- CPU가 제공하는 원자 명령을 이용합니다.
- 대표적으로
cmpxchg계열이 유명합니다. (CPU/아키텍처마다 표현은 다릅니다)
- 대표적으로
- CAS는 아래를 “한 번에” 처리합니다.
- 현재 값이 기대값인지 비교
- 기대값이면 새 값으로 교체
- 성공 실패를 반환
왜 CAS가 Lock free를 가능하게 하나
- 임계 영역을 “잠그는 방식”이 아닙니다.
- 대신 “충돌하면 다시 시도하는 방식”입니다.
- 그래서 커널 블로킹을 줄일 수 있습니다.
Spin lock은 무엇인가
- 스핀 락은 락을 얻을 때까지 반복문으로 재시도합니다.
- 반복문은 CPU 시간을 계속 씁니다.
- 그래서 스핀 락은 아래에서만 이득입니다.
- 이득 조건
- 임계 영역이 매우 짧습니다.
- 경쟁 스레드 수가 적습니다.
- 락이 곧 풀릴 가능성이 큽니다.
CAS 기반 SpinLock 예제
- 보통은 직접 구현하지 않습니다.
- 학습 목적이나 특수한 경우에만 구현합니다.
AtomicBoolean + LockSupport.parkNanos 예제
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.locks.LockSupport;
class SpinLock {
private final AtomicBoolean locked = new AtomicBoolean(false);
public void lock() {
while (!locked.compareAndSet(false, true)) {
// 너무 공격적으로 돌면 CPU를 태움
LockSupport.parkNanos(1_000); // 1us 정도 쉬며 재시도
}
}
public void unlock() {
locked.set(false);
}
}
compareAndSet(false, true)가 CAS입니다.parkNanos는 불필요한 CPU 소모를 줄이는 데 도움이 될 수 있습니다.- 단, 값은 상황별로 튜닝이 필요합니다. (정답은 없습니다)
Lock free 큐가 좋은 예가 되는 이유
- 큐는 선형 자료구조입니다.
- 단일 연결 리스트 기반 큐는 핵심 참조가 단순합니다.
- head 참조
- tail 참조
- node.next 참조
- 이 참조 변경에 CAS를 적용하면 lock free 접근이 가능해집니다.
AtomicReference를 쓰는 이유
- 참조자 변경이 “원자적으로” 이뤄져야 합니다.
- 그래서
AtomicReference<Node>로 head나 tail을 감쌉니다. - 핵심은 “참조자 한 칸 교체”를 CAS로 만드는 것입니다.
lock free가 항상 이기나
- lock free는 경합이 적고 임계 영역이 작을 때 유리할 수 있습니다.
- 경합이 심해지면 재시도가 늘어서 비용이 커질 수 있습니다.
- 그래서 어느 순간에는
ReentrantLock과 큰 차이가 없어질 수 있습니다. (상황 의존입니다)
결론
- 멀티스레드에서 큐가 자주 등장하고 자주 깨집니다.
- 연결 리스트는 포인터 갱신이 여러 단계라 레이스에 취약합니다.
- 해결은 단계적으로 접근하는 게 좋습니다.
- 1단계
synchronized로 correctness를 먼저 확보합니다.
- 2단계
ReentrantLock으로 임계 영역을 줄입니다.
- 3단계
- CAS 기반으로 lock free 구조를 검토합니다.
- 임계 영역 판단이 결국 성능과 안정성을 갈라놓습니다.