[JAVA] 스레드 안전한 공유 객체를 만드는 세 가지 축: 불변, 공개, confinement

2026. 4. 6. 23:29·Language/Java

1. 이 글에서 정리할 것

이 글은 공유 객체를 어떻게 설계·공개해야 안전한가를 JMM(Java Memory Model)과 JIT 관점까지 엮어서 정리한 것이다.

 

핵심 축은 세 가지다.

  • 공유 가변 상태를 가능한 줄이고, 불변/결과적 불변으로 최대한 끌어올릴 것
  • 객체를 어떻게 공개하느냐(safe publication) 를 설계에 포함할 것
  • 아예 공유하지 않을 수 있을 때는 thread confinement로 escape 자체를 막을 것

2. 공유 상태의 세 단계: 불변 / 결과적으로 불변 / 가변

공유 상태는 다음 세 수준으로 나눠서 보는 게 이해가 쉽다.

2.1 완전 불변(Immutable)

조건은 세 가지 정도로 정리할 수 있다.

  • 생성 이후 상태가 절대 변하지 않는다 (setter 없음, 내부 가변 구조도 변경되지 않음).
  • 모든 필드가 final이고, 생성자에서 한 번만 설정된다.
  • 생성 도중 this가 외부로 escape 하지 않는다 (생성자 안에서 리스너 등록, 다른 스레드에 넘기기 등 없음).

예: String, primitive wrapper, 잘 설계된 값 객체(돈, 좌표, 기간 등).

완전 불변의 장점은 명확하다.

  • 어떤 스레드에서 어떻게 공유하든, 추가 동기화 없이 안전하다.
  • defensive copy를 덜 만들어도 된다 (외부에 넘겨도 값이 바뀌지 않으니까).

단, “내부에 가변 객체를 들고 있지만 바깥에서 안 바꾸겠지” 같은 건 이미 깨진 설계라서, 진짜 불변인지 항상 확인해야 한다.

2.2 결과적으로 불변(Effectively immutable)

코드는 겉으로 가변처럼 보여도, “생성 이후 논리적으로는 절대 변경하지 않는다”는 제약을 지키는 객체다.

  • DTO인데 setter가 없고 생성자만 있는 클래스
  • 애플리케이션 시작 시점에 한 번 만들어서, 이후 구성 변경이 거의 없는 설정 스냅샷 등

이런 객체는 안전하게 공개(safe publication)만 되면, 이후에는 동기화 없이 읽어도 된다.

예를 들면:

class AppConfig {
    private final String endpoint;
    private final int timeout;

    // 생성자에서만 설정, 이후 변경 없음
    AppConfig(String endpoint, int timeout) {
        this.endpoint = endpoint;
        this.timeout = timeout;
    }
}

class Holder {
    private volatile AppConfig config; // 안전한 공개를 위한 volatile

    public void init() {
        config = new AppConfig("https://api.example.com", 1000);
    }

    public AppConfig getConfig() {
        return config; // 이후 여러 스레드에서 동기화 없이 읽어도 안전
    }
}

 

config에 대한 volatile write → volatile read가 happens-before를 만들어서, 객체의 모든 필드 쓰기가 다른 스레드에서 보장되도록 해 준다.

2.3 가변(Mutable)

생성 이후에도 상태가 계속 바뀌는 일반적인 객체다.

이런 객체는 두 타이밍에 모두 신경 써야 한다.

  • 처음에 다른 스레드에서 보이도록 만들 때: 안전한 공개 필요
  • 그 이후 읽고 쓸 때: 동기화(락, volatile, 원자 클래스 등) 필요

정리하면 다음과 같이 볼 수 있다.

상태 타입 공개 시 요구사항 이후 접근 동기화
완전 불변 아무 방법으로나 공개해도 OK 불필요
결과적으로 불변 안전한 공개 필요 불필요
가변 안전한 공개 + 이후 접근 동기화 필요

 

3. 안전한 공개(Safe publication)

핵심 문장은 하나다.

객체를 공개할 때, 참조와 그 객체의 상태가 동시에 보이도록 보장하라.

 

단순히 “공개 필드나 컬렉션에 객체 참조를 대입하는 것”만으로는 안전한 공개가 아니다.

  • 다른 스레드가 부분 초기화 상태를 볼 수 있다.
  • CPU/JIT reordering 때문에, 생성자 안에서의 필드 쓰기보다 참조 저장이 먼저 관측될 수 있다.

3.1 대표적인 안전 공개 패턴

다음 패턴들은 JMM 상에서 안전한 공개를 보장한다.

  1. 정적 초기화(static initialization)
public class GlobalConfig {
    public static final AppConfig INSTANCE = new AppConfig(...);
}
  • 클래스 초기화는 JVM이 내부 락으로 보호하며, 초기화 완료 전에는 어떤 스레드도 해당 정적 필드를 볼 수 없다.
  • 한 번 초기화가 끝나고 나면, 모든 스레드에서 완전히 초기화된 INSTANCE만 보게 된다.
  1. volatile 필드에 대입하기
class ConfigHolder {
    private volatile AppConfig config;

    public void init() {
        config = new AppConfig(...); // volatile write
    }

    public AppConfig getConfig() {
        return config;               // volatile read
    }
}
  • volatile write → volatile read 사이에 happens-before 관계가 생기고, write 이전의 모든 쓰기가 read 이후의 스레드에서 보이게 된다.
  1. 락으로 보호되는 필드에 넣기
class SharedHolder {
    private AppConfig config;
    private final Object lock = new Object();

    public void publish(AppConfig cfg) {
        synchronized (lock) {
            config = cfg;
        }
    }

    public AppConfig get() {
        synchronized (lock) {
            return config;
        }
    }
}

 

  • 같은 락으로 쓰기와 읽기를 감싸면, unlock → lock 사이에 happens-before가 생겨, 참조와 그 객체 상태를 동시에 보게 된다.
  1. 동시성 컬렉션 / 핸드오프 API 사용
  • BlockingQueue.put()/take()
  • ConcurrentMap.put()/get()
  • Future.get() 등

이런 API들은 내부적으로 안전한 공개를 만족하도록 설계되어 있다.

요약하면:

  • 결과적으로 불변: 안전한 공개만 되면 이후에는 동기화 없이 사용.
  • 가변: 안전하게 공개 + 이후 읽기/쓰기 모두 동기화.

4. thread confinement: “애초에 공유하지 말자”

동기화 비용과 복잡도를 줄이는 가장 쉬운 방법은, 애초에 공유하지 않는 것이다. 이걸 흔히 thread confinement라고 부른다.

4.1 개념

  • 어떤 객체가 오직 하나의 스레드에서만 접근된다면, 그 객체는 그 스레드에 confined 되었다고 말한다.
  • confined 객체는 동기화 없이 마음껏 읽고 써도 된다.
  • 언어 차원에서 키워드는 없고, 우리가 코드로 escape 경로를 막아야 한다.

4.2 Stack confinement / 로컬 변수

로컬 변수/파라미터로만 존재하고, 필드/콜백/컬렉션 등을 통해 다른 스레드로 넘기지 않으면, 그 참조는 stack-confined라고 볼 수 있다.

void process(List<Item> items) {
    int localCount = 0; // 이 스레드에서만 사용
    for (Item item : items) {
        if (item.isValid()) localCount++;
    }
    log(localCount);
}

 

localCount는 현재 스레드의 스택 프레임에만 존재하고, 다른 스레드에 공유되지 않는다. 이런 값은 JMM 관점에서 동기화 걱정에서 완전히 벗어난다.

 

주의할 점:

void f(Executor executor) {
    List<String> list = new ArrayList<>(); // list 참조는 로컬
    executor.submit(() -> list.add("x"));  // 실제 리스트 객체는 다른 스레드로 escape
}

 

  • list 변수 자체는 스택에 있지만, ArrayList 인스턴스는 람다(클로저)를 통해 다른 스레드로 넘어간다.
  • 이 경우 해당 리스트는 더 이상 confined가 아니다.

4.3 ThreadLocal confinement

ThreadLocal은 JDK가 제공하는 thread confinement 도구다.

private static final ThreadLocal<DateFormat> fmt =
    ThreadLocal.withInitial(() -> new SimpleDateFormat("yyyy-MM-dd"));

String format(Date date) {
    return fmt.get().format(date);
}

 

  • 각 스레드는 fmt.get()를 호출할 때 자기만의 인스턴스를 받는다.
  • 같은 ThreadLocal 변수를 여러 스레드가 공유하더라도, 실제로는 “스레드별로 다른 인스턴스를 들고 있다”고 보면 된다.
  • 따라서 내부 상태가 가변이어도, 해당 스레드 안에서만 쓰면 동기화 없이 안전하다.

5. 클래스 초기화와 static 필드의 안전 공개

불변/결과적으로 불변 객체를 공개할 때 자주 쓰는 패턴이 public static final이다. 이게 왜 안전한지 이해하려면, JVM의 클래스 초기화 규칙을 한 줄로 기억해 두면 좋다.

5.1 클래스 초기화 락 개념

JVM은 대략 다음을 보장한다.

  • 각 클래스/인터페이스 C마다 고유한 초기화 락 L_C가 있다.
  • C를 처음 사용할 때, 한 스레드가 L_C를 잡고 (static 초기화)을 실행한다.
  • 초기화가 끝나기 전에는, 다른 스레드는 C의 정적 필드에 접근하기 위해 블록된다.
  • 초기화가 완료된 이후에야 다른 스레드가 C의 정적 필드를 볼 수 있다.

즉:

  public class ConfigHolder {
    public static final AppConfig GLOBAL = new AppConfig(...);
}

 

  • JVM이 처음으로 ConfigHolder를 로딩/사용하려 할 때, 클래스 초기화 락 아래에서 GLOBAL을 한 번만 초기화한다.
  • 그 이후에 다른 스레드가 ConfigHolder.GLOBAL을 읽을 때는, 이미 완전히 초기화된 객체만 보게 된다.

따라서 static 초기화는 락 + happens-before가 합쳐진 공짜 safe publication이라고 볼 수 있다.

 

6. escape analysis와 “return하면 밖으로 빼니까 안전하지 않다?” 

마지막으로, thread confinement를 JIT 최적화(escape analysis)와 연결해서 보자.

 

6.1 Escape analysis 개요

JIT 컴파일러는 “이 객체 참조가 어디까지 나가는가?”를 분석한다.

  • 전혀 메서드 밖으로 안 나가면: NoEscape
  • 같은 스레드 안의 다른 메서드까지만: ArgEscape / MethodEscape
  • 다른 스레드에도 갈 수 있으면: GlobalEscape (실제 명칭은 구현마다 다름)
  • NoEscape / 일부 MethodEscape인 객체는 상황에 따라:
  • 힙 대신 스택/레지스터에 둘 수 있다 (stack allocation, scalar replacement).
  • 해당 객체에 대한 락을 제거할 수 있다 (synchronized elision).

즉, 우리가 코드 레벨에서 escape 하지 않도록 설계(thread confinement)를 해 주면, JIT 입장에서는 더 공격적인 최적화를 적용하기 쉬워진다.

 

6.2 return acc;는 안전하지 않은가?

예를 들어:

  Point sumDistances(List<Point> pts) {
    Point acc = new Point(0, 0);
    for (Point p : pts) {
        // acc 갱신
    }
    return acc;
}

 

여기서 질문은 두 가지다.

 

  1. 공유/스레드 안전 관점
    • “리턴하면 밖으로 빼니까 thread confinement가 깨지나?”
  2. 최적화 관점
    • “리턴하면 스택/레지스터 할당은 못 하나?”

 

각각 따로 보자.

 

(1) 스레드 안전 관점

  • “메서드 밖으로 나간다”와 “다른 스레드에 공유된다”는 별개의 문제다.
  • 호출한 같은 스레드 안에서만 리턴값을 쓰고 버리면, 여전히 그 스레드에 confined라고 볼 수 있다.
  • 리턴값을 다른 스레드에 넘기거나, 공유 컬렉션에 넣을 때 비로소 thread confinement가 깨진다.

즉:

  • 리턴 자체가 문제는 아니고, 리턴 이후 그 값을 어디로 보내느냐가 문제다.

 

(2) 스택/레지스터 최적화 관점

  • 메서드가 끝난 뒤에도 객체를 써야 한다면(리턴값), 그 객체는 논리적으로 메서드 수명을 넘어 존재해야 한다.
  • 이 경우 “순수한” stack allocation만으로는 부족하고, JIT의 scalar replacement, escape analysis 결과에 따른 더 고급 최적화가 필요하다.
  • 그래서 직관적으로 “메서드 안에서만 쓰이고 끝나는 객체(NoEscape)가 힙 할당 제거의 가장 좋은 후보”라는 말이 나온다.

정리하면:

  • “리턴하면 무조건 thread confinement가 깨진다”는 아니다.
  • 리턴값을 어떤 스레드에서 어떻게 쓰느냐에 따라 confinement 유지 여부가 갈린다.
  • “리턴하면 스택/레지스터 최적화는 더 어려워진다”는 직관은 대체로 맞다.
    다만 실제 HotSpot은 inlining + scalar replacement로 꽤 많은 경우를 최적화할 수 있다.

'Language > Java' 카테고리의 다른 글

[JAVA] 자바 synchronized 를 바이트코드로 까보며 이해하기  (4) 2026.04.02
[JAVA] 자바 동시성 프로그래밍: 기초부터 메모리 모델, 설계 전략까지 한 번에 정리하기  (0) 2026.04.01
[JAVA] JVM Class Loading, Metaspace, 그리고 메서드 바이트코드까지 한 번에 이해하기  (0) 2026.03.31
[EFFECTIVE JAVA 3/E] #2 생성자에 매개변수가 많다면 빌더를 고려하라  (0) 2025.09.16
[EFFECTIVE JAVA 3/E] #1 생성자 대신 정적 팩터리 메서드를 고려하라  (0) 2025.09.15
'Language/Java' 카테고리의 다른 글
  • [JAVA] 자바 synchronized 를 바이트코드로 까보며 이해하기
  • [JAVA] 자바 동시성 프로그래밍: 기초부터 메모리 모델, 설계 전략까지 한 번에 정리하기
  • [JAVA] JVM Class Loading, Metaspace, 그리고 메서드 바이트코드까지 한 번에 이해하기
  • [EFFECTIVE JAVA 3/E] #2 생성자에 매개변수가 많다면 빌더를 고려하라
Log Cat
Log Cat
잊어버리지 않기 위한 메모장
  • Log Cat
    개발 메모장
    Log Cat
  • 전체
    오늘
    어제
    • 전체보기 (45) N
      • Book Review (6)
      • Language (15) N
        • Java (10) N
        • Go (1)
        • JavaScript (1)
        • TypeScript (3)
      • Computer Science (3)
        • Network (1)
        • Database (1)
        • Design Pattern (0)
      • Spring Framework (10)
        • Spring & Spring Boot (4)
        • Spring Batch (4)
        • Servlet & JSP (2)
      • Python Framework (3)
        • FastAPI (3)
      • Infra (3)
        • Dcoker (1)
        • Kafka (2)
      • ORM (1)
        • JPA (1)
      • Fun (1) N
      • Error (2)
      • Retrospective (0)
      • Certificate (1)
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
  • 링크

    • Github
  • 인기 글

  • 태그

    leetcode
    BOJ
    프로그래머스 문제
    escape-analysis
    jmm
    spring boot
    백준
    spring
    fastapi
    프로그래머스문제
    네트워크 원리
    개발서적
    우아한테크코스
    effective java 3/e
    코테
    리트코드
    programmers
    성공과 실패를 결정하는 1%의 네트워크 원리
    네트워크
    개발서적리뷰
    프로그래머스
    이팩티브 자바
    Typescript
    코딩테스트
    자바 풀이
    자바
    spring kafka
    Java
    jvm
    Python
  • 최근 글

  • hELLO· Designed By정상우.v4.10.6
Log Cat
[JAVA] 스레드 안전한 공유 객체를 만드는 세 가지 축: 불변, 공개, confinement
상단으로

티스토리툴바