83. 지연 초기화는 신중히 사용하라

지연 초기화는 필드의 초기화 시점을 그 값이 처음 필요할 때까지 늦추는 기법이다.

즉, 객체가 생성될 때 바로 초기화하지 않고, 해당 값이 필요한 시점에 초기화한다.

이는 정적 필드와 인스턴스 필드 모두에 사용할 수 있다.

// 일반적인 초기화
private final Member member = createMember();

// 지연 초기화
private Member member;

private synchronized Member getMember() {
    if (member == null) {
        member = createMember();
    }
    return member;
}

지연 초기화는 양날의 검이다

지연 초기화를 사용하면 장점과 단점이 함께 있다.

장점:

  • 객체 생성 시의 초기화 비용을 줄일 수 있다

  • 사용하지 않는 필드는 초기화되지 않는다

단점:

  • 필드에 처음 접근할 때 초기화 비용이 발생한다

  • 코드가 복잡해진다

  • 멀티스레드 환경에서는 추가적인 동기화가 필요하다

일반 초기화와 지연 초기화 비교

비교 항목
일반 초기화
지연 초기화

초기화 시점

객체 생성 시

필드 첫 사용 시

초기 로딩 속도

느림 (모든 필드 초기화)

빠름 (필요한 필드만 초기화)

첫 사용 시 속도

빠름 (이미 초기화됨)

느림 (초기화 필요)

메모리 사용

더 많음 (사용하지 않는 필드도 초기화)

더 적음 (필요한 필드만 초기화)

코드 복잡성

간단함

복잡함 (동기화 필요)

스레드 안전성

기본적으로 안전

추가 작업 필요 (volatile, synchronized)

지연 초기화가 필요한 경우

  1. 클래스 중 일부 인스턴스만 해당 필드를 사용할 때

    • 모든 인스턴스가 사용한다면 일반 초기화가 낫다

  2. 필드 초기화 비용이 크고 사용 빈도가 낮을 때

    • 초기화 비용이 작다면 일반 초기화가 낫다

  3. 초기화 순환 문제를 해결해야 할 때

    • 두 객체가 서로를 참조하는 경우

지연 초기화 기법들

1. 일반적인 초기화 (권장)

// 인스턴스 필드 초기화의 일반적인 방법
private final FieldType field = computeFieldValue();

대부분의 상황에서는 이 방식이 가장 간단하고 안전하다.

2. synchronized 접근자 방식

// 인스턴스 필드의 지연 초기화 - synchronized 접근자 방식
private FieldType field;

private synchronized FieldType getField() {
    if (field == null)
        field = computeFieldValue();
    return field;
}

이 방식은 초기화 순환성 문제가 있을 경우 사용하자. 다만 동기화로 인한 성능 저하가 있다.

3. 홀더 클래스 관용구 (정적 필드용)

// 정적 필드용 지연 초기화 홀더 클래스 관용구
private static class FieldHolder {
    static final FieldType field = computeFieldValue();
}

private static FieldType getField() {
    return FieldHolder.field;
}

정적 필드를 지연 초기화할 때 가장 좋은 방법이다.

클래스는 처음 사용될 때만 초기화되는 자바의 특성을 활용한다.

4. 이중검사 관용구 (인스턴스 필드용)

// 인스턴스 필드 지연 초기화용 이중검사 관용구
private volatile FieldType field;

private FieldType getField() {
    FieldType result = field;
    if (result == null) {  // 첫 번째 검사 (락 없이)
        synchronized(this) {
            if (field == null)  // 두 번째 검사 (락 사용)
                field = computeFieldValue();
            return field;
        }
    }
    return result;
}

volatile 키워드는 필드의 변경이 모든 스레드에 즉시 보이도록 보장한다.

지역 변수 result는 이미 초기화된 경우 필드를 한 번만 읽도록 최적화한다.

인스턴스 필드를 지연 초기화하면서도 동기화 비용을 최소화하고 싶을 때 사용한다.

volatile 키워드 간략 설명

지연 초기화 패턴(특히 이중검사와 단일검사 관용구)에서 volatile 키워드는 중요한 역할을 한다

  • 메모리 가시성 보장

    • 한 스레드가 volatile 변수를 수정하면 다른 모든 스레드가 즉시 그 변경을 볼 수 있다.

    • 이것은 여러 스레드가 동시에 변수에 접근할 때 매우 중요하다

  • 객체 초기화 안전성

    • volatile 없이는 객체가 완전히 초기화되기 전에 다른 스레드가 참조를 볼 수 있다.

    • 이로 인해 반쯤 초기화된 객체가 사용될 위험이 있다

  • 사용 시점

    • 멀티스레드 환경에서 지연 초기화를 사용할 때는 필드를 volatile로 선언해야 한다

    • 단, 홀더 클래스 관용구는 예외로, JVM의 클래스 로딩 메커니즘이 동기화를 보장하므로 volatile이 필요하지 않다.

5. 단일검사 관용구

// 단일검사 관용구 - 초기화가 중복해서 일어날 수 있다!
private volatile FieldType field;

private FieldType getField() {
    FieldType result = field;
    if (result == null)
        field = result = computeFieldValue();
    return result;
}

초기화가 여러 번 일어나도 상관없는 경우(예: 계산 비용은 크지만 항상 같은 결과를 반환하는 경우)에 사용한다.

각 기법의 사용 상황 비교

초기화 기법
사용 상황
장점
단점

일반 초기화

대부분의 경우

간단하고 안전함

불필요한 초기화 가능성

synchronized 접근자

순환 참조 문제 시

안전함

성능 저하

홀더 클래스

정적 필드

성능 좋음

정적 필드만 가능

이중검사

인스턴스 필드

성능과 안전성 균형

구현 복잡

단일검사

중복 초기화 허용 시

구현 간단

중복 초기화 발생

멀티스레드 환경에서의 주의점

멀티스레드 환경에서 지연 초기화를 사용할 때는 반드시 적절한 동기화를 해야 한다.

그렇지 않으면 다음과 같은 문제가 발생할 수 있다

  1. 가시성 문제: 한 스레드의 변경이 다른 스레드에 보이지 않을 수 있다

  2. 경쟁 상태: 여러 스레드가 동시에 초기화를 시도할 수 있다

  3. 부분적 초기화: 객체가 완전히 초기화되기 전에 다른 스레드가 접근할 수 있다

이러한 문제를 방지하기 위해 volatile 키워드와 적절한 동기화(synchronized)를 사용해야 한다.

정리

  • 대부분의 필드는 지연시키지 말고 곧바로 초기화하자.

  • 성능 때문에 혹은 초기화 순환을 막기 위해 지연 초기화가 필요하다면

    • 인스턴스 필드에는 이중검사 관용구

    • 정적 필드에는 홀더 클래스 관용구

    • 중복 초기화가 허용된다면 단일검사 관용구


🧩 어려웠던 점

지연 초기화에 대해 공부하면서 가장 어려웠던 점은 멀티스레드 환경에서의 안전성을 이해하는 것이었다.

성능 측면에서 지연 초기화가 실제로 도움이 되는 상황을 판단하는 것도 어려웠다.

실제로는 측정을 통해서만 확인할 수 있다는 점이 현실적인 어려움으로 다가왔다.

💡 느낀 점

코드의 성능과 안전성, 가독성 사이의 균형을 맞추는 것이 중요하다는 점을 다시 한번 깨달았다.

특히 멀티스레드 환경에서는 성능 향상을 위한 기법들이 오히려 더 큰 문제를 일으킬 수 있기 때문에 신중하게 접근해야 한다.

Last updated