[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으로 마운트되어야 한다.

플랫폼 스레드의 비용

  1. 시스템 콜 비용: 스레드의 생성, 종료, 전환이 모두 시스템 콜을 거친다. 컨텍스트 스위칭 시 레지스터·스택 등의 상태 저장과 복원이 커널 모드에서 일어나므로 오버헤드가 크다.
  2. 메모리 비용: 커널 스택과 TCB(Thread Control Block) 같은 OS 자원을 할당받는다. JVM 은 플랫폼 스레드 하나당 기본 1MB 의 스택을 잡으므로(-Xss 옵션으로 조정), 수만 개 이상의 스레드를 동시에 유지하기 어렵다.

스레드 컨텍스트 스위칭

같은 프로세스 안의 스레드끼리의 스위칭은 프로세스 단위 스위칭보다 훨씬 가볍다. 같은 메모리 공간을 공유하므로 페이지 테이블을 교체할 필요가 없다.

  1. T1 의 PC(Program Counter), 레지스터, 스택 포인터를 TCB 에 저장.
  2. CPU 가 T2 선택.
  3. 같은 프로세스이므로 페이지 테이블이 그대로다. CPU 의 주소 변환 캐시(TLB, Translation Lookaside Buffer)가 무효화되지 않아 캐시 히트가 유지된다.
  4. 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 규칙은 다음과 같다.

  1. 프로그램 순서 규칙: 한 스레드 안에서 앞의 연산은 뒤의 연산에 happens-before.
  2. 모니터 락 규칙: unlock 은 같은 락의 후속 lock 에 happens-before.
  3. volatile 변수 규칙: volatile 변수의 쓰기는 후속 읽기에 happens-before.
  4. 스레드 시작 규칙: Thread.start() 는 새 스레드의 모든 동작에 happens-before.
  5. 스레드 종료 규칙: 스레드의 모든 동작은 다른 스레드가 본 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스레드 Bcounter (메모리)
t1read → 1010
t2read → 10 (A와 동일)10
t3+1 → 11 (레지스터)10
t4+1 → 11 (레지스터)10
t5write → 1111
t6write → 1111 (덮어씀)

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 블록은 바이트코드 명령어 monitorentermonitorexit 로 변환된다. 객체의 모니터(monitor)를 획득하고 해제하는 동작이다.

monitorenter            ; 객체 모니터 획득 (다른 스레드는 BLOCKED)
  ; 임계 구역 코드
monitorexit             ; 모니터 해제

JVM은 객체 헤더의 Mark Word 에 락 상태를 저장한다.

  1. 편향 락(Biased Lock): 한 스레드만 락을 잡는 패턴에서 거의 비용 0. (JDK 15 deprecated, JDK 18 기본 비활성화)
  2. 경량 락(Lightweight Lock): 짧은 경쟁이 있을 때 CAS(Compare-And-Swap)로 잠금.
  3. 중량 락(Heavyweight Lock): 경쟁이 길어지면 OS 모니터를 활용. 컨텍스트 스위칭 비용 발생.

synchronized의 트레이드오프

  • 한 번에 하나의 스레드만 통과한다. 경쟁이 심하면 대기 스레드가 늘어나 처리량이 떨어진다.
  • 락을 잡은 스레드가 외부 자원(예: 데이터베이스, 외부 API) 호출을 기다리는 동안 다른 스레드도 대기한다.

ReentrantLock

ReentrantLocksynchronized 와 비슷한 의미를 가지지만 더 많은 옵션을 제공한다.

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)비용
volatileOX (단일 읽기·쓰기만)OX매우 낮음
synchronizedOOOO경쟁 시 높음
ReentrantLockOOOOsynchronized 와 유사
Atomic*OO (CAS)OX (논블로킹)낮음
  • 단순 플래그(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() 불필요
구분ThreadLocalScopedValue
변경 가능성mutableimmutable (스코프 내 불변)
생존 범위스레드 전체정해진 스코프(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가 작은 쪽부터 락을 잡기 때문에 두 스레드가 서로 다른 리소스에 대해 락을 요청해도 데드락이 발생하지 않는다.