Fundamentals
- 1.1 동시성의 역사
- 1.2 스레드 안전성
- 1.3 가시성과 불변
- 1.4 스레드 안전 클래스 설계
- 1.5 동기화 컬렉션과 빌딩 블록
동시성 프로그래밍의 핵심은 결국 하나야. 공유 가변 상태를 어떻게 안전하게 다루느냐. 이게 1부 전체를 관통하는 질문이지. 스레드가 왜 필요한지부터 시작해서, 안전성을 확보하는 기본 도구들, 객체를 안전하게 공유하는 방법, 클래스를 설계하는 원칙, 그리고 자바가 제공하는 동시성 빌딩 블록까지 한 번에 정리해볼게.
예전 운영체제는 한 번에 프로그램 하나만 돌렸거든. 자원 활용이 형편없었지. 하나가 I/O를 기다리면 CPU가 그냥 놀았어. 그래서 프로세스라는 개념이 나왔고, 프로세스끼리는 메모리가 격리돼 있어서 통신 비용이 크니까 스레드가 등장한 거야. 같은 프로세스 안에서 메모리를 공유하면서 동시에 여러 작업을 수행할 수 있게 된 건데, 이 공유 메모리 때문에 온갖 문제가 생겨. 스레드를 쓰면 멀티프로세서 활용, 비동기 이벤트의 단순한 모델링, 반응성 향상 이 세 가지가 좋아지지. 서버가 여러 클라이언트를 동시에 처리할 때 각 연결을 별도 스레드에서 처리하면 코드가 순차적으로 읽히잖아. 논블로킹 I/O로 직접 짜면 복잡한 상태 머신이 되는데 말이야.
대신 위험도 크지. 크게 세 카테고리야. **안전성(Safety)**은 "나쁜 일이 일어나지 않는다"는 보장이고, **활동성(Liveness)**은 "좋은 일이 결국 일어난다"는 보장이야. 여러 스레드가 같은 변수를 동시에 읽고 쓰면 **경쟁 조건(race condition)**이 발생하고, A가 B의 락을 기다리고 B가 A의 락을 기다리면 데드락에 걸리지. 성능 문제도 있어. 컨텍스트 스위칭 비용이 있고 동기화를 위해 락을 잡으면 다른 스레드가 기다려야 하니까 병렬성이 줄어들거든. 이 세 가지 위험은 서로 트레이드오프 관계야. 안전성을 높이려고 동기화를 많이 하면 성능이 떨어지고 데드락 위험이 커지고, 성능을 높이려고 동기화를 줄이면 안전성이 깨져. 이 균형을 잘 잡는 게 동시성 프로그래밍의 핵심이지.
스레드 안전성의 정의부터 확실히 잡아야 해. 저자의 정의는 이거야 — "여러 스레드가 클래스에 접근할 때, 실행 환경이 어떤 스케줄링을 하든, 호출하는 쪽에서 추가 동기화 없이도 정확하게 동작하면 그 클래스는 스레드 안전하다". 핵심은 "호출하는 쪽에서 추가 동기화 없이"라는 부분이야. 사용하는 쪽에서 synchronized를 감싸야만 안전한 클래스는 스레드 안전하지 않은 거거든. 상태가 없는 객체는 항상 스레드 안전하지. 공유할 데이터가 없으니까.
문제는 상태가 있을 때 시작돼. 두 가지 전형적인 경쟁 조건 패턴이 있어. check-then-act는 먼저 조건을 확인하고 그 결과에 따라 행동하는 건데, 확인과 행동 사이에 다른 스레드가 끼어들 수 있지. 전형적인 게 **지연 초기화(lazy initialization)**야. read-modify-write는 값을 읽고, 수정하고, 다시 쓰는 건데 count++가 대표적이지. 바이트코드 레벨에서 읽기-수정-쓰기 세 단계라서 원자적이지 않아.
// check-then-act 경쟁 조건
if (instance == null) { // check
instance = new Expensive(); // act — 두 스레드가 동시에 여기 도달할 수 있다
}
이걸 해결하려면 원자적(atomic) 실행이 필요해. java.util.concurrent.atomic 패키지의 AtomicLong 같은 클래스가 이걸 보장해주지. 근데 상태 변수가 여러 개이고 서로 관련이 있으면 원자 변수만으로는 부족하고, 이때 락이 필요해.
자바의 기본 동기화 메커니즘은 synchronized 블록이야. 내장 락(intrinsic lock) 또는 모니터 락이라고 부르지. 자바의 내장 락은 **재진입(reentrant)**이 가능해서 같은 스레드가 이미 잡고 있는 락을 다시 요청하면 성공하거든. 이게 없으면 부모 클래스의 synchronized 메서드를 오버라이드한 자식에서 super.method() 호출할 때 데드락이 걸려.
public class LoggingWidget extends Widget {
public synchronized void doSomething() {
System.out.println("calling doSomething");
super.doSomething(); // 재진입 덕분에 데드락 안 걸림
}
}
락 쓸 때 중요한 규칙이 있어. 변경 가능한 공유 상태에 접근하는 모든 곳에서 같은 락을 사용해야 해. 읽기만 해도! 한 곳이라도 락 없이 접근하면 안전하지 않지. 그리고 관련된 상태 변수들은 하나의 원자적 연산으로 갱신해야 해. 동기화를 너무 넓게 잡으면 성능이 떨어지니까 synchronized 블록을 가능한 짧게 유지하는 게 중요하고. 오래 걸리는 연산을 synchronized 블록 안에 넣으면 안 돼. 먼저 올바르게 만들고, 그다음에 필요하면 빠르게 만들어라 — 이게 저자의 경고야.
동기화는 원자성뿐만 아니라 **가시성(visibility)**도 보장하거든. 한 스레드가 변수를 수정했을 때 다른 스레드가 그 수정된 값을 볼 수 있느냐의 문제야. 가시성이 보장되지 않으면 한 스레드가 ready = true를 설정하고 number = 42를 써도, 다른 스레드는 ready = true를 보면서 number = 0을 읽을 수 있어. 심지어 ready가 영원히 false로 보일 수도 있지. 재배치(reordering) 때문이야. 컴파일러, 프로세서, 메모리 시스템이 성능 최적화를 위해 명령어 순서를 바꿀 수 있거든.
volatile 변수는 가시성을 보장해. volatile로 선언된 변수를 읽으면 항상 가장 최근에 쓴 값을 보지. 하지만 원자성은 보장하지 않아. volatile count++는 여전히 안전하지 않거든. volatile은 변수에 쓰는 값이 현재 값에 의존하지 않을 때, 단순한 상태 플래그로 쓸 때 적합해.
객체를 **발행(publish)**한다는 건 현재 스코프 밖에서 접근할 수 있게 만드는 거고, **탈출(escape)**은 발행하면 안 되는 객체가 발행되는 거야. 가장 위험한 게 생성자에서 this 참조가 빠져나가는 경우지. 생성자가 아직 끝나지 않았는데 this가 다른 스레드에 노출되면 초기화가 덜 된 객체를 보게 돼. 생성자에서 this를 빠져나가게 하지 마라 — 이게 핵심 규칙이야.
공유하지 않으면 동기화가 필요 없잖아. **스레드 한정(thread confinement)**은 데이터를 특정 스레드만 접근할 수 있게 제한하는 기법이야. 스택 한정은 지역 변수가 해당 스레드 스택에만 존재하니까 자동으로 한정되는 거고, ThreadLocal은 각 스레드마다 독립적인 값을 가지게 해주는 유틸리티야. JDBC 커넥션을 ThreadLocal에 저장하면 각 스레드가 자기만의 커넥션을 쓰게 되지.
private static ThreadLocal<Connection> connectionHolder =
ThreadLocal.withInitial(() -> DriverManager.getConnection(DB_URL));
**불변 객체(immutable object)**는 항상 스레드 안전해. 상태를 바꿀 수 없으니까 경쟁 조건이 발생할 수 없거든. 객체가 불변이려면 생성 후 상태를 바꿀 수 없고, 모든 필드가 final이고, 생성자에서 this가 탈출하지 않아야 해. 여러 관련 변수를 원자적으로 갱신해야 할 때 불변 객체로 묶으면 락 없이도 안전하게 처리할 수 있어. volatile 참조와 불변 객체를 조합하는 패턴이지.
객체를 다른 스레드에 안전하게 발행하려면 정적 초기화자, volatile 필드, final 필드, 락으로 보호되는 필드 중 하나를 써야 해. 효과적으로 불변인 객체는 안전하게 발행하기만 하면 동기화 없이 사용할 수 있고, 가변 객체는 안전한 발행에 더해 모든 접근에서 동기화도 해야 하지.
스레드 안전한 클래스를 설계할 때는 세 가지를 먼저 파악해야 해. 객체의 상태를 구성하는 변수들이 뭔지, 상태 변수에 대한 **불변 조건(invariant)**이 뭔지, 그리고 동시 접근을 어떻게 관리할 건지. **동기화 정책(synchronization policy)**이란 불변 조건을 유지하면서 동시 접근을 관리하는 방법을 정의한 거야.
**인스턴스 한정(instance confinement)**은 객체를 다른 객체 안에 캡슐화해서 그 객체의 락으로 모든 접근 경로를 보호하는 기법이야.
public class PersonSet {
private final Set<Person> mySet = new HashSet<>();
public synchronized void addPerson(Person p) { mySet.add(p); }
public synchronized boolean containsPerson(Person p) { return mySet.contains(p); }
}
HashSet은 스레드 안전하지 않지만 PersonSet 안에 한정돼 있고 모든 접근이 내장 락으로 보호되니까 전체적으로 스레드 안전한 거지. **스레드 안전성 위임(delegation)**도 있어. 클래스의 모든 상태를 스레드 안전한 객체에 위임하는 건데, 주의할 점이 있어. 독립적인 상태 변수들만 위임할 수 있어. 두 상태 변수 사이에 불변 조건이 있으면 각각을 AtomicInteger로 만들어도 복합 연산이 원자적이지 않아서 불변 조건이 깨질 수 있거든.
// 위임이 실패하는 경우 — lower <= upper 불변 조건을 원자적으로 보장할 수 없다
public class NumberRange {
private final AtomicInteger lower = new AtomicInteger(0);
private final AtomicInteger upper = new AtomicInteger(0);
public void setLower(int i) {
if (i > upper.get()) throw new IllegalArgumentException();
lower.set(i); // check-then-act: 경쟁 조건!
}
}
기존 클래스에 새 원자적 연산을 추가하고 싶을 때 가장 안전한 방법은 **조합(composition)**이야. 기존 객체를 감싸는 래퍼를 만들고 자체 락을 사용하는 거지. 상속은 위험하고 클라이언트 사이드 락킹은 원본 객체의 락을 알아야 해서 깨지기 쉬워. 그리고 동기화 정책을 반드시 문서화해야 해. @GuardedBy("this"), @ThreadSafe, @NotThreadSafe 같은 어노테이션으로 어떤 락이 어떤 상태를 보호하는지 명시하는 게 좋지.
Collections.synchronizedXxx 래퍼로 만드는 동기화 컬렉션은 각 메서드가 스레드 안전하지만 복합 연산은 여전히 위험해. 이터레이터도 fail-fast라서 순회 중에 컬렉션이 변경되면 ConcurrentModificationException을 던지지.
java.util.concurrent의 동시성 컬렉션은 훨씬 낫지. ConcurrentHashMap은 **락 스트라이핑(lock striping)**을 사용해서 전체 맵에 하나의 락을 거는 대신 여러 세그먼트로 나누고 각 세그먼트마다 별도의 락을 써. 읽기 연산은 대부분 락 없이 수행되고, putIfAbsent, replace, computeIfAbsent 같은 복합 연산을 원자적 메서드로 제공해. CopyOnWriteArrayList는 변경할 때마다 배열 전체를 복사하는 건데, 읽기가 쓰기보다 훨씬 많은 경우에 적합하지. 이벤트 리스너 목록이 전형적인 사용 사례야.
BlockingQueue는 생산자-소비자 패턴을 구현하는 핵심 도구야. 큐가 비어 있으면 take()가 블로킹하고, 큐가 가득 차면 put()이 블로킹하지. 생산자와 소비자가 서로를 알 필요 없이 큐만 공유하면 돼.
BlockingQueue<Task> queue = new LinkedBlockingQueue<>(100);
queue.put(new Task()); // 큐가 가득 차면 공간이 생길 때까지 대기
Task task = queue.take(); // 큐가 비면 데이터가 올 때까지 대기
동기화 도구도 중요해. CountDownLatch는 카운트가 0이 될 때까지 스레드를 대기시키고, Semaphore는 동시 접근 수를 제한하고, CyclicBarrier는 N개의 스레드가 모두 배리어에 도달할 때까지 기다리게 하지. CountDownLatch와 달리 CyclicBarrier는 재사용할 수 있어서 반복적인 병렬 연산에 적합하고.
5장 마지막에 나오는 스레드 안전한 캐시 예제가 정말 좋은데, ConcurrentHashMap<K, Future<V>> + putIfAbsent로 "계산 중"임을 표현하는 패턴이야. 다른 스레드는 같은 키에 대해 진행 중인 Future를 발견하면 결과를 기다리기만 하면 돼.
public V compute(K key) throws InterruptedException {
Future<V> f = cache.get(key);
if (f == null) {
FutureTask<V> ft = new FutureTask<>(() -> computeExpensive(key));
f = cache.putIfAbsent(key, ft); // 원자적 삽입
if (f == null) { f = ft; ft.run(); }
}
return f.get(); // 결과 대기
}
ConcurrentHashMap + FutureTask + putIfAbsent 조합은 실전에서 정말 유용하지. 직접 저수준 동기화를 구현할 일을 크게 줄여주는 빌딩 블록들을 잘 활용하는 게 핵심이야.
정리
1장 읽고 기억할 거 세 가지:
- 스레드 안전성은 "추가 동기화 없이도 정확하게 동작"하는 거야. check-then-act와 read-modify-write가 두 대표적인 경쟁 조건 패턴이고, 원자성과 가시성 둘 다 잡아야 안전한 거지
- 불변 객체는 항상 스레드 안전하고, 스레드 한정은 공유 자체를 피하는 거야. volatile은 가시성만 보장하지 원자성은 보장 안 하니까 용도를 확실히 구분해야 해
- ConcurrentHashMap, BlockingQueue, CountDownLatch 같은 java.util.concurrent 빌딩 블록을 적극 활용해야 해. 직접 synchronized로 짜는 것보다 훨씬 안전하고 성능도 좋거든