[Java] 싱글톤 객체를 만드는 다양한 방법(feat. 멀티 스레드 환경)

simhani1·2025년 5월 12일

Java

목록 보기
3/3
post-thumbnail

개요

스터디 진행 중 싱글톤과 관련해서 재미있는 이야기가 나왔고 흥미로웠습니다. 기본적으로 싱글톤 객체를 생성하는 방법 외에 여러 가지 제약 조건이 붙었을 때 코드를 개선하는 과정을 정리하고자 합니다.

1. Eager Initialization (즉시 초기화 방식)

public class Foo {

    private static final Foo INSTANCE = new Foo();

    private Foo() {}

    public static Foo getInstance() {
        return INSTANCE;
    }
}

JVM은 클래스 로딩 시점에 Metaspace 영역에 클래스의 정보, staic 변수, 메서드 등을 저장합니다. 그리고 static 변수와 함수는 JVM이 클래스 당 하나만 생성하므로 유일성이 보장됩니다. 따라서 즉시 초기화 방식을 사용하면 싱글톤이 보장될 수 있습니다. 이 방법 외에도 지연 초기화와 synchronized 키워드를 사용하여 싱글톤을 만들 수도 있습니다.

2. Lazy Initialization + synchronized (지연 초기화 + 동기화)

public class Foo {
    private static Foo instance;

    private Foo() {}

    public static synchronized Foo getInstance() {
        if (instance == null) {
            instance = new Foo();
        }
        return instance;
    }
}

synchronized 키워드를 사용하는 이유는 함수 내부가 원자적 연산이 아니기 때문입니다. 멀티 스레드 상황에서 동시성 문제가 발생할 수 있습니다. 만약 두 개의 스레드가 동시에 아래 코드를 동시에 실행한다면 어떤 상황이 발생할 수 있을까요?

if (instance == null) {
    instance = new Foo();
}
return instance;

이처럼 Heap 메모리에 두 개의 인스턴스가 생성되면서 싱글톤이 깨질 수 있습니다. 극단적으로 스레드 100개가 동시에 getInstance() 함수에 진입하면 더 많은 메모리 공간을 낭비하게 되겠죠. 따라서 메서드 레벨에 synchronized로 동기화 블록을 설정해야 합니다.

중간 정리(두 방식의 단점)

일반적으로 사용하는 싱글톤 패턴의 예제 두 가지를 살펴봤습니다. 그런데 두 방법 모두 단점이 있습니다.

방법1, 방법2 모두 프로그램을 실행했을 때 인스턴스가 반드시 생성됩니다. 싱글톤 객체가 100개가 있을 때 실제 사용되는 객체가 1개라면 불필요하게 Heap 메모리를 차지하게 됩니다. 또한 방법2는 멀티 스레드 환경에서 임계 영역 진입을 위해 Lock 획득이 필요하고 바쁜 대기(busy waiting)가 발생하여 리소스 낭비로 이어질 수 있습니다.

정리하면 크게 두 가지 문제가 있습니다.

  1. 불필요한 객체 생성 가능성(방법1, 방법2 해당)
  2. 멀티 스레드 환경에서 성능 저하 문제(방법2 해당)

3. Double-Checked Locking(이중 검사 락)

public class Foo {

    private static volatile Foo instance;

    private Foo() {}

    public static Foo getInstance() {
        if (instance == null) { // 1차 검사 (락 없이)
            synchronized (Foo.class) {  // 2차 검사 (락 사용)
                if (instance == null) {
                    instance = new Foo();
                }
            }
        }
        return instance;
    }
}

이 방법은 앞서 살펴본 두 가지 방식의 단점을 보완한 방식입니다.

  1. 첫 번째 if문에서 인스턴스의 존재 여부를 검사합니다. 이미 인스턴스가 생성되어 있다면 synchronized 블록에 진입하지 않고 바로 인스턴스를 반환합니다.
  2. 인스턴스가 없는 경우에만 synchronized 블록에 진입하여 한 번 더 인스턴스의 존재 여부를 체크합니다. 이렇게 이중으로 검사하는 방식을 통해 두 가지 이점을 얻을 수 있습니다.
    • 실제로 객체를 생성하는 시점에만 인스턴스를 생성 가능
    • 1차 검사로 불필요한 락 획득을 방지 가능

주의할 점은 instance 변수에 volatile 키워드를 사용해야 한다는 것입니다. volatile을 사용하지 않으면 JVM의 최적화로 인한 메모리 재정렬(memory reordering)가 발생할 수 있어 다른 스레드에서 완전히 초기화되지 않은 인스턴스를 참조할 수 있기 때문입니다.

  • 메모리 재정렬 상황

4. Lazy Holder

public class Foo {

    private Foo() {}

    // 정적 내부 클래스
    private static class FooHolder {
        private static final Foo INSTANCE = new Foo();
    }

    // 외부에서 호출하는 메서드
    public static Foo getInstance() {
        return FooHolder.INSTANCE;
    }
}

방법3은 thread-safe 하지만 코드가 복잡합니다. 이 방법은 정적 내부 클래스는 호출 시점에 JVM에 로드되고 초기화되는 특징을 사용합니다. 따라서 코드 가독성 향상과 synchronized 블록도 제거하면서도 thread-safe 하게 싱글톤 객체를 만들 수 있습니다.

참고
명령어 재정렬
메모리 가시성 문제

0개의 댓글