싱글턴 패턴 구현 방식 비교와 최적화

싱글턴 패턴은 객체 지향 설계에서 특정 클래스의 인스턴스가 프로그램 전체에서 단 하나만 존재하도록 보장하는 생성 패턴이다. 이 패턴은 전역 접근점을 제공하여 어디서든 동일한 객체를 재사용할 수 있게 한다.

기본 설계 원칙

싱글턴을 구현하려면 세 가지 핵심 원칙을 지켜야 한다.

  • 생성자를 private으로 선언해 외부에서 직접 인스턴스를 생성하지 못하게 한다
  • 클래스 내부에 정적 참조 변수를 두어 유일한 인스턴스를 보관한다
  • 외부에서 인스턴스를 획득할 수 있는 공개 메서드를 제공한다

즉시 초기화 방식

클래스 로딩 시점에 바로 인스턴스를 만드는 방식이다.

public class AppConfig {
    
    private AppConfig() {}
    
    private static final AppConfig uniqueRef = new AppConfig();
    
    public static AppConfig fetchInstance() {
        return uniqueRef;
    }
}

이 방식은 구현이 단순하고 스레드 안전하지만, 실제 사용 여부와 상관없이 메모리를 점유하는 단점이 있다.

지연 초기화와 동기화

실제 필요할 때 인스턴스를 생성하는 방식을 살펴본다.

public class AppConfig {
    
    private AppConfig() {}
    
    private static AppConfig uniqueRef;
    
    public static AppConfig fetchInstance() {
        if (uniqueRef == null) {
            uniqueRef = new AppConfig();
        }
        return uniqueRef;
    }
}

위 코드는 멀티스레드 환경에서 여러 개의 인스턴스가 생성될 수 있는 심각한 문제를 내포하고 있다. 이를 해결하기 위해 메서드 수준의 동기화를 적용할 수 있다.

public class AppConfig {
    
    private AppConfig() {}
    
    private static AppConfig uniqueRef;
    
    public static synchronized AppConfig fetchInstance() {
        if (uniqueRef == null) {
            uniqueRef = new AppConfig();
        }
        return uniqueRef;
    }
}

동기화된 메서드는 안전성은 보장하지만, 인스턴스가 이미 생성된 이후에도 불필요한 락 경합이 발생해 성능이 저하된다.

이중 검증 잠금

동기화 블록의 범위를 최소화하면서도 안전성과 성능을 모두 챙기는 기법이다.

public class AppConfig {
    
    private AppConfig() {}
    
    private static volatile AppConfig uniqueRef;
    
    public static AppConfig fetchInstance() {
        if (uniqueRef == null) {
            synchronized (AppConfig.class) {
                if (uniqueRef == null) {
                    uniqueRef = new AppConfig();
                }
            }
        }
        return uniqueRef;
    }
}

volatile 키워드는 JDK 1.5부터 도입되었으며, 인스턴스 생성 과정의 명령어 재배치로 인한 문제를 방지한다. 이중 검증 구조는 첫 번째 조건에서 필터링하여 불필요한 동기화 진입을 줄인다.

정적 내부 클래스 활용

JVM의 클래스 로딩 메커니즘을 활용한 우아한 해결책이다.

public class AppConfig {
    
    private AppConfig() {}
    
    private static class InstanceHolder {
        private static final AppConfig INSTANCE = new AppConfig();
    }
    
    public static AppConfig fetchInstance() {
        return InstanceHolder.INSTANCE;
    }
}

정적 내부 클래스 InstanceHolder는 실제 참조될 때까지 로딩되지 않는다. 이 특성 덕분에 지연 초기화가 자연스럽게 이루어지며, 클래스 로딩 과정이 스레드 안전을 보장하므로 별도의 동기화 코드가 필요 없다.

검증 예시

내부 클래스의 지연 로딩 여부를 확인하기 위한 테스트 코드다.

public class AppConfig {
    
    public AppConfig() {}
    
    private static class InstanceHolder {
        static {
            System.out.println("내부 클래스 로딩 완료");
        }
        private static final AppConfig INSTANCE = new AppConfig();
    }
    
    public static AppConfig fetchInstance() {
        return InstanceHolder.INSTANCE;
    }
}

단순히 new AppConfig()를 호출하거나 AppConfig.class에 접근할 경우 내부 클래스는 초기화되지 않는다. 반면 fetchInstance()가 호출되는 순간 비로소 내부 클래스가 로딩되며 단일 인스턴스가 생성된다.

public class Verification {
    public static void main(String[] args) {
        System.out.println(AppConfig.fetchInstance());
        System.out.println(AppConfig.fetchInstance());
        
        new Thread(() -> {
            System.out.println(AppConfig.fetchInstance());
        }).start();
    }
}

실행 결과는 동일한 해시코드를 출력하며, 내부 클래스 로딩 메시지는 단 한 번만 찍힌다.

구현 방식 선택 가이드

방식장점단점장 상황
즉시 초기화단순, 스레드 안전불필요한 자원 점유항상 사용되는 리소스
메서드 동기화구현 간단성능 저하저빈도 접근
이중 검증지연 초기화, 고성능복잡함, volatile 필수고빈도 멀티스레드
정적 내부 클래스우아함, 자동 동기화클래스가 많아짐대부분의 상황

정적 내 클래스 방식은 지연 초기화와 스레드 안전을 모두 만족하면서도 코드가 깔끔하므로 실무에서 가장 선호되는 방법이다. 다만 모든 싱글턴 구현은 리플렉션을 통한 생성자 호출이나 직렬화/역직렬화 과정에서의 규칙 위반에 취약하므로, 이러한 상황을 방어해야 할 때는 enum 기반 구현을 고려해야 한다.

태그: java Singleton Pattern Design Pattern Multithreading Volatile

7월 16일 16:51에 게시됨