[Java] 스레드와 동시성: 메모리 모델, volatile, synchronized, ThreadLocal
개요
자바 백엔드 서버는 멀티스레드로 동작한다. 톰캣 워커, 비동기 작업, 스케줄러, 캐시 새로 고침까지 동시에 여러 스레드가 같은 객체를 읽고 쓴다. 이 작업들은 자바 메모리 모델(JMM, Java Memory Model)이 정한 규칙에서 동작한다. 하지만 한 객체를 두고, 여러 스레드가 동시에 접근하는 것은 문제를 야기할 가능성이 있다.
이를 동시성 문제라 하며, 동시성 코드의 문제는 대체로 세 가지 원인으로 압축된다. 가시성(visibility), 원자성(atomicity), 순서(ordering) 이다. volatile, synchronized, Atomic 같은 도구는 이 세 가지를 어느 수준까지 보장하느냐가 다르다.
또 다른 것은 스레드의 스케줄링이다. 플랫폼 스레드, 가상 스레드, 컨텍스트 스위칭, 스레드 상태, ThreadLocal 같은 개념은 모두 스레드가 어떻게 자원을 잡고 어떻게 데이터를 공유하느냐에 대한 문제를 다룬다.
스레드: 가벼운 스케줄링 단위
스레드는 프로세스보다 가벼운 스케줄링 단위다. 같은 프로세스에 속한 스레드들은 메모리(힙, 코드 영역, 파일 디스크립터 등)를 공유하며, CPU 자원을 할당받아 작업을 수행한다.
- 커널 스레드: OS 커널이 직접 관리하는 스레드. 실제 CPU 에 매핑되는 단위.
- 플랫폼 스레드(자바 스레드): JVM 위의 스레드. 커널 스레드와 1:1 로 매핑.
- 가상 스레드(Java 21+): JVM 레벨에서 다중화되는 경량 스레드. 플랫폼 스레드 위에 마운트되어 동작.
결국 작업이 실행되려면 커널 스레드와 매핑되어야 한다. 플랫폼 스레드와 커널 스레드가 1:1 이라는 점, 가상 스레드는 다수가 소수의 플랫폼 스레드 위에 M:N으로 마운트되어야 한다.
플랫폼 스레드의 비용
- 시스템 콜 비용: 스레드의 생성, 종료, 전환이 모두 시스템 콜을 거친다. 컨텍스트 스위칭 시 레지스터·스택 등의 상태 저장과 복원이 커널 모드에서 일어나므로 오버헤드가 크다.
- 메모리 비용: 커널 스택과 TCB(Thread Control Block) 같은 OS 자원을 할당받는다. JVM 은 플랫폼 스레드 하나당 기본 1MB 의 스택을 잡으므로(
-Xss옵션으로 조정), 수만 개 이상의 스레드를 동시에 유지하기 어렵다.
스레드 컨텍스트 스위칭
같은 프로세스 안의 스레드끼리의 스위칭은 프로세스 단위 스위칭보다 훨씬 가볍다. 같은 메모리 공간을 공유하므로 페이지 테이블을 교체할 필요가 없다.
- T1 의 PC(Program Counter), 레지스터, 스택 포인터를 TCB 에 저장.
- CPU 가 T2 선택.
- 같은 프로세스이므로 페이지 테이블이 그대로다. CPU 의 주소 변환 캐시(TLB, Translation Lookaside Buffer)가 무효화되지 않아 캐시 히트가 유지된다.
- TCB2 의 정보를 로드하여 T2 실행.
저장과 복원할 상태가 적고, 메모리 매핑이 유지된다는 점에서 프로세스보다 스레드 컨텍스트 스위칭이 가볍다
자바 메모리 모델 (JMM)
멀티스레드가 같은 객체에 접근할 때, 각 스레드가 같은 객체를 읽었음에도 값이 일치하지 않을 수 있다.
메인 메모리와 작업 메모리
하드웨어 수준에서 CPU는 RAM보다 훨씬 빠르다. 이 속도 차이를 메우기 위해 CPU 마다 캐시(L1, L2, L3)가 있다. 스레드가 변수를 읽고 쓸 때, 실제로는 메인 메모리가 아니라 CPU 캐시에 먼저 들어간다.
| JMM 개념 | 하드웨어 대응 |
|---|---|
| 자바 스레드 | CPU 코어 |
| 메인 메모리 | RAM |
| 작업 메모리 | CPU 캐시 |
스레드 1이 X를 1로 바꾸어도, 그 변경은 스레드 1의 작업 메모리(CPU 캐시)에만 반영된다. 메인 메모리에 동기화되기 전까지 스레드 2는 여전히 X = 0으로 본다. 이 현상이 가시성 문제다.
메모리 간 상호작용
JMM 은 작업 메모리와 메인 메모리 사이의 상호작용을 다음 8가지 연산으로 정의한다.
- 잠금(lock): 메인 메모리 변수를 특정 스레드만 사용할 수 있도록 잠금.
- 잠금 해제(unlock): 잠긴 변수를 해제.
- 읽기(read): 메인 메모리의 값을 스레드의 작업 메모리로 전송.
- 적재(load): 작업 메모리에 읽기한 값을 복사.
- 사용(use): 변수값을 실행 엔진에 전달.
- 할당(assign): 실행 엔진의 결과를 작업 메모리 변수에 저장.
- 저장(store): 작업 메모리의 값을 메인 메모리로 전송.
- 쓰기(write): 메인 메모리에 값을 업데이트.
매번 작업 메모리 변경 시마다 메인 메모리에 동기화하면 성능이 떨어지므로, JMM 은 필요한 순간에만 동기화한다.
Happens-Before 관계
A가 B에 happens-before한다는 것은 A의 결과가 B에서 볼 수 있다는 뜻이다.
sequenceDiagram autonumber participant T1 as Thread 1 participant MM as Main Memory participant T2 as Thread 2 T1->>T1: x = 10 T1->>T1: ready = true (volatile write) T1->>MM: store x, ready Note over MM: x=10, ready=true T2->>MM: load ready (volatile read) MM-->>T2: ready=true Note over T2: happens-before 보장으로 x=10 도 보임 T2->>T2: x 사용 = 10
대표적인 happens-before 규칙은 다음과 같다.
- 프로그램 순서 규칙: 한 스레드 안에서 앞의 연산은 뒤의 연산에 happens-before.
- 모니터 락 규칙:
unlock은 같은 락의 후속lock에 happens-before. - volatile 변수 규칙: volatile 변수의 쓰기는 후속 읽기에 happens-before.
- 스레드 시작 규칙:
Thread.start()는 새 스레드의 모든 동작에 happens-before. - 스레드 종료 규칙: 스레드의 모든 동작은 다른 스레드가 본
Thread.join()의 반환에 happens-before.
이 규칙들이 없으면 컴파일러와 CPU가 명령어를 재정렬해서 다른 결과가 나올 수 있다.
volatile: 가시성과 순서 보장
volatile 키워드는 변수의 읽기와 쓰기를 메인 메모리에 직접 수행하도록 한다. 작업 메모리(CPU 캐시) 단계를 건너뛴다.
가시성 보장
volatile 로 선언된 변수는 한 스레드가 쓴 값을 다른 스레드가 즉시 볼 수 있다.
public class FlagShutdown {
private volatile boolean running = true;
public void start() {
new Thread(() -> {
while (running) { // 메인 메모리에서 직접 읽음
doWork();
}
log.info("worker stopped");
}).start();
}
public void shutdown() {
running = false; // 메인 메모리에 즉시 반영
}
}volatile 이 없으면 워커 스레드의 작업 메모리가 running=true 를 캐시한 채로 영원히 루프를 돌 수도 있다. JIT(Just-In-Time) 컴파일러가 이 변수는 변하지 않는다 고 가정해 무한 루프로 최적화하기도 한다.
순서 보장 (메모리 배리어)
volatile 은 메모리 배리어 역할도 한다. 컴파일러와 CPU가 명령어를 재정렬할 때 volatile 변수의 읽기와 쓰기를 가로질러 옮길 수 없다.
private int x = 0;
private volatile boolean ready = false;
// Producer
x = 42; // (1)
ready = true; // (2) volatile write 로 (1)이 (2) 위로 못 올라옴
// Consumer
if (ready) { // (3) volatile read 로 이후의 (4)가 (3) 위로 못 내려옴
System.out.println(x); // (4) 항상 42 출력 보장
}volatile 가 없으면 컴파일러가 (1) 과 (2) 의 순서를 바꿔 Consumer가 ready=true 인데 x=0 으로 보는 상황이 가능하다.
원자성은 보장하지 않는다
volatile 는 원자성을 보장하지 않는다.
private volatile int counter = 0;
public void increment() {
counter++; // 원자적이지 않음. 읽기, +1, 쓰기 3 단계
}counter++ 는 바이트코드로 풀면 load, iconst_1, iadd, store 의 여러 단계로 이뤄진다. 중간에 다른 스레드가 끼어들면 변경이 유실된다. 여러 스레드가 동시에 호출하면 카운트가 누락된다.
왜 누락되는가
counter++ 한 줄은 CPU 입장에서 세 단계로 쪼개진다.
| 단계 | 의미 |
|---|---|
| read (load) | 메모리에서 counter 값을 읽어 온다 |
| add (+1) | 읽어온 값에 1을 더한다 (레지스터 안에서) |
| write (store) | 더한 결과를 counter 에 다시 쓴다 |
문제는 이 세 단계가 하나로 묶이지 않고, read 와 write 사이에 다른 스레드가 끼어들어 같은 값을 읽어 버릴 수 있다.
counter = 10 인 상태에서 스레드 A, B 가 동시에 counter++ 를 한다고 하자. 기대값은 12다. 두 스레드 모두 +1 했기 때문이다.
| 시간 | 스레드 A | 스레드 B | counter (메모리) |
|---|---|---|---|
| t1 | read → 10 | 10 | |
| t2 | read → 10 (A와 동일) | 10 | |
| t3 | +1 → 11 (레지스터) | 10 | |
| t4 | +1 → 11 (레지스터) | 10 | |
| t5 | write → 11 | 11 | |
| t6 | write → 11 | 11 (덮어씀) |
volatile 은 읽기, 쓰기를 메인 메모리에 직접 수행해 항상 최신 값을 보게 하는 가시성만 보장할 뿐 read, add, write를 하나로 묶는 원자성은 보장하지 않는다. 그래서 volatile 이어도 결과는 똑같이 11 이다.
해결책은 read, add, write를 하나로 묶는 것이다. AtomicInteger.incrementAndGet() 은 CAS 로, synchronized 는 락으로 끼어듦 자체를 차단한다.
원자적 증가가 필요하면 AtomicInteger 를 쓴다.
private final AtomicInteger counter = new AtomicInteger(0);
public void increment() {
counter.incrementAndGet(); // CAS 기반 원자 연산
}synchronized: 원자성, 가시성, 순서 모두 보장
synchronized 는 가시성과 순서뿐 아니라 원자성까지 보장한다. 블록 또는 메서드를 잠구어 한 번에 하나의 스레드만 실행하도록 한다.
public class Counter {
private int count = 0;
public synchronized void increment() {
count++;
}
public synchronized int get() {
return count;
}
}바이트코드 수준에서 본 synchronized
synchronized 블록은 바이트코드 명령어 monitorenter 와 monitorexit 로 변환된다. 객체의 모니터(monitor)를 획득하고 해제하는 동작이다.
monitorenter ; 객체 모니터 획득 (다른 스레드는 BLOCKED)
; 임계 구역 코드
monitorexit ; 모니터 해제JVM은 객체 헤더의 Mark Word 에 락 상태를 저장한다.
- 편향 락(Biased Lock): 한 스레드만 락을 잡는 패턴에서 거의 비용 0. (JDK 15 deprecated, JDK 18 기본 비활성화)
- 경량 락(Lightweight Lock): 짧은 경쟁이 있을 때 CAS(Compare-And-Swap)로 잠금.
- 중량 락(Heavyweight Lock): 경쟁이 길어지면 OS 모니터를 활용. 컨텍스트 스위칭 비용 발생.
synchronized의 트레이드오프
- 한 번에 하나의 스레드만 통과한다. 경쟁이 심하면 대기 스레드가 늘어나 처리량이 떨어진다.
- 락을 잡은 스레드가 외부 자원(예: 데이터베이스, 외부 API) 호출을 기다리는 동안 다른 스레드도 대기한다.
ReentrantLock
ReentrantLock 은 synchronized 와 비슷한 의미를 가지지만 더 많은 옵션을 제공한다.
private final ReentrantLock lock = new ReentrantLock();
public void transfer(int amount) {
if (!lock.tryLock(500, TimeUnit.MILLISECONDS)) {
throw new BusyException(); // 타임아웃
}
try {
// 임계 구역
} finally {
lock.unlock();
}
}tryLock(timeout): 일정 시간만 기다리고 실패하면 포기.lockInterruptibly(): 대기 중에도 인터럽트로 깰 수 있음.- 공정성 옵션(
new ReentrantLock(true)): 대기 순서대로 락 부여.
volatile vs synchronized vs Atomic 비교
| 도구 | 가시성 | 원자성 | 순서(reordering) | 락 | 비용 |
|---|---|---|---|---|---|
| volatile | O | X (단일 읽기·쓰기만) | O | X | 매우 낮음 |
| synchronized | O | O | O | O | 경쟁 시 높음 |
| ReentrantLock | O | O | O | O | synchronized 와 유사 |
| Atomic* | O | O (CAS) | O | X (논블로킹) | 낮음 |
- 단순 플래그(true/false), 참조 한 번 쓰고 여러 번 읽기:
volatile - 단일 변수의 원자적 증감, CAS 기반 알고리즘:
AtomicInteger,AtomicReference - 여러 변수를 묶어 원자적으로 갱신해야 함:
synchronized,ReentrantLock - 락이 필요하지만 더 정밀한 제어가 필요:
ReentrantLock(타임아웃, 인터럽트, 공정성)
ThreadLocal: 스레드 단위 저장소
ThreadLocal 은 각 스레드가 독립적으로 값을 가질 수 있도록 하는 저장소다. 같은 ThreadLocal 객체에 접근하더라도, 스레드마다 서로 다른 값을 읽고 쓴다.
private static final ThreadLocal<String> USER_CONTEXT = new ThreadLocal<>();
public void handleRequest(String userId) {
try {
USER_CONTEXT.set(userId);
process();
} finally {
USER_CONTEXT.remove(); // 반드시 정리
}
}
private void process() {
String userId = USER_CONTEXT.get();
log.info("처리 중인 사용자: {}", userId);
}내부 구조
ThreadLocal 의 핵심은 각 Thread 객체가 내부적으로 ThreadLocalMap 을 가지고 있다는 점이다.
// Thread 클래스 내부
ThreadLocal.ThreadLocalMap threadLocals = null;ThreadLocal.get() 은 현재 스레드를 찾고, 그 스레드의 ThreadLocalMap 에서 자기 자신(this)을 키로 값을 꺼낸다.
사용처
- Spring SecurityContextHolder: 현재 인증된 사용자 정보를
ThreadLocal<SecurityContext>로 관리. - Spring TransactionSynchronizationManager: 현재 스레드의 DB 커넥션, 트랜잭션 상태를 ThreadLocal 로 묶음.
@Transactional의 기반. - MDC(Mapped Diagnostic Context): Logback, Log4j2 가 traceId 같은 컨텍스트를 ThreadLocal 에 담아 로그 패턴에 자동 삽입.
// MDC + traceId 예시
public class TraceIdFilter implements Filter {
@Override
public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)
throws IOException, ServletException {
try {
MDC.put("traceId", UUID.randomUUID().toString().substring(0, 8));
chain.doFilter(req, res);
} finally {
MDC.clear();
}
}
}14:23:01 [http-nio-8080-exec-1] [traceId=a3f1b2c4] INFO UserService - 사용자 조회 시작
14:23:01 [http-nio-8080-exec-1] [traceId=a3f1b2c4] DEBUG UserRepository - SELECT * FROM users WHERE id = ?ThreadLocal 의 실수 케이스
1. 스레드 풀에서의 데이터 오염
스레드 풀에서 스레드는 재사용되므로, 이전 요청의 ThreadLocal 값이 다음 요청에 남아있을 수 있다. 사용 후 반드시 remove() 해야 한다.
// 잘못된 코드. remove() 누락
public void handle(String userId) {
USER_CONTEXT.set(userId);
process();
// remove() 빠짐 → 다음 요청에서 userId 노출 가능
}이 문제는 보안 사고로 직결된다. 사용자 A 의 인증 정보가 사용자 B 의 요청에서 노출될 수 있다.
2. 메모리 누수
ThreadLocalMap 의 Entry 는 키를 WeakReference 로 감싼다. ThreadLocal 객체에 대한 외부 참조가 사라지면 GC(Garbage Collector)가 키를 수거하지만, 값(value)은 강한 참조로 남는다. 키만 사라진 stale entry 가 누적되면 누수가 된다.
스레드 풀에서는 스레드가 오래 살아있어서 누수가 누적되기 쉽다. 정상 패턴은 항상 try-finally 로 remove() 를 호출하는 것이다.
3. 비동기 환경에서의 컨텍스트 유실
WebFlux, Coroutine, 가상 스레드 환경에서 작업이 다른 스레드로 옮겨가면 ThreadLocal 값이 사라진다.
// WebFlux 환경
@GetMapping("/user")
public Mono<String> getUser() {
USER_CONTEXT.set("userA"); // nio-thread-1
return Mono.fromCallable(() -> {
// boundedElastic-1 로 전환되어 다른 스레드
return USER_CONTEXT.get(); // null
}).subscribeOn(Schedulers.boundedElastic());
}Reactor 는 Context 를 자체 제공하고, Kotlin Coroutine 은 ThreadLocal.asContextElement() 로 코루틴 컨텍스트에 바인딩한다. 가상 스레드 환경에서는 Java 21 의 ScopedValue 가 권장된다.
ScopedValue: ThreadLocal 의 후속
Java 21 (Preview) 에서 도입된 ScopedValue 는 ThreadLocal 의 한계를 보완한다.
private static final ScopedValue<String> CURRENT_USER = ScopedValue.newInstance();
ScopedValue.runWhere(CURRENT_USER, "userA", () -> {
System.out.println(CURRENT_USER.get()); // userA
process(); // 하위 호출에서도 접근 가능
});
// 스코프를 벗어나면 자동 해제되어 remove() 불필요| 구분 | ThreadLocal | ScopedValue |
|---|---|---|
| 변경 가능성 | mutable | immutable (스코프 내 불변) |
| 생존 범위 | 스레드 전체 | 정해진 스코프(runWhere) 내부 |
| 자식 스레드 전파 | InheritableThreadLocal 필요 | 자동 전파 (StructuredTaskScope) |
| 해제 | remove() 명시 호출 | 스코프 종료 시 자동 |
| 메모리 | 스레드마다 Map 유지 | 스코프 기반, 경량 |
대량의 가상 스레드 환경에서는 ScopedValue 가 더 적합하다.
코드 예시
1. happens-before 가 깨졌을 때 (DCL 안티패턴)
// 잘못된 더블 체크 락 (DCL). Java 1.4 이전엔 동작 안 함
public class Singleton {
private static Singleton instance;
public static Singleton getInstance() {
if (instance == null) { // (1)
synchronized (Singleton.class) {
if (instance == null) {
instance = new Singleton(); // (2) 초기화가 끝나기 전 참조가 보일 수 있음
}
}
}
return instance;
}
}new Singleton() 은 (a) 메모리 할당, (b) 생성자 실행, (c) 참조 대입 의 세 단계로 풀린다. 컴파일러가 (b) 와 (c) 의 순서를 바꿔 다른 스레드가 참조는 있지만 생성자는 끝나지 않은 객체를 볼 수 있다.
해결책은 volatile 추가다.
private static volatile Singleton instance;volatile 이 메모리 배리어 역할을 해서 (b) 가 (c) 뒤로 가지 못한다.
2. ThreadPool 과 ThreadLocal (안전 패턴)
@Service
@RequiredArgsConstructor
public class AuditService {
private static final ThreadLocal<String> ACTOR = new ThreadLocal<>();
public void withActor(String actor, Runnable action) {
String previous = ACTOR.get();
try {
ACTOR.set(actor);
action.run();
} finally {
if (previous == null) {
ACTOR.remove(); // 원래 비어 있었으면 제거
} else {
ACTOR.set(previous); // 중첩 호출 복원
}
}
}
public String currentActor() {
return ACTOR.get();
}
}핵심은 두 가지다. 무조건 try-finally, 그리고 중첩 호출을 고려한 복원이다.
3. AtomicReference 로 락 없는 상태 갱신
public class LeaderBoard {
private final AtomicReference<List<Score>> top10 =
new AtomicReference<>(List.of());
public void publish(List<Score> newScores) {
top10.set(newScores); // 원자적 교체
}
public List<Score> get() {
return top10.get(); // 읽기는 항상 일관된 스냅샷
}
public void updateScore(Score s) {
// CAS 루프로 락 없이 안전한 갱신
top10.updateAndGet(current -> {
List<Score> updated = new ArrayList<>(current);
updated.add(s);
updated.sort(Comparator.reverseOrder());
return updated.stream().limit(10).toList();
});
}
}AtomicReference.updateAndGet 은 CAS 가 실패하면 재시도한다. 경쟁이 적으면 락보다 빠르고, 경쟁이 심하면 재시도가 누적되어 오히려 느릴 수 있다. 경쟁 패턴을 파악한 뒤 선택한다.
4. ReentrantLock 으로 데드락 방지
public class Account {
private final ReentrantLock lock = new ReentrantLock();
private long balance;
private final long id;
public Account(long id, long balance) {
this.id = id;
this.balance = balance;
}
public static void transfer(Account from, Account to, long amount)
throws InterruptedException {
// ID 순으로 락을 잡아 데드락 방지
Account first = from.id < to.id ? from : to;
Account second = from.id < to.id ? to : from;
if (!first.lock.tryLock(500, TimeUnit.MILLISECONDS)) {
throw new BusyException();
}
try {
if (!second.lock.tryLock(500, TimeUnit.MILLISECONDS)) {
throw new BusyException();
}
try {
from.balance -= amount;
to.balance += amount;
} finally {
second.lock.unlock();
}
} finally {
first.lock.unlock();
}
}
}ID가 작은 쪽부터 락을 잡기 때문에 두 스레드가 서로 다른 리소스에 대해 락을 요청해도 데드락이 발생하지 않는다.