JAVA

Java 싱글톤 패턴 정리 | 구현 방법 & 최적의 선택법

dev-seonho 2025. 2. 26. 17:47

💡 이 글에서는 Java의 싱글톤 패턴(Singleton Pattern)의 개념과 다양한 구현 방법을 상세히 설명합니다.

💡 싱글톤 패턴을 활용하면 객체를 하나만 생성하여 메모리를 절약하고, 여러 스레드에서도 안전하게 공유할 수 있습니다.

 

 

📌 목차

1️⃣ 싱글톤 패턴이란?

2️⃣ 싱글톤 패턴을 사용하는 이유

3️⃣ 싱글톤 패턴 구현 방법

4️⃣ 싱글톤 패턴 비교 & 최적의 선택

 

1. 싱글톤 패턴이란?

  • 싱글톤 패턴은 클래스의 인스턴스를 단 하나만 생성하고 모든 곳에서 같은 인스턴스를 공유하도록 보장하는 디자인패턴이다.

싱글톤 패턴의 특징

  • 객체를 하나만 생성해야하기 때문에 생성자를 private으로 선언한다. (new 사용 불가)
  • 어디서든 같은 인스턴스를 사용할 수 있다.
  • 메모리 절약 & 성능 최적화 가능하다.

*최적화 : 최적화란 소프트웨어, 하드웨어, 알고리즘 등을 더 빠르고 효율적이며 자원을 덜 사용하도록 해선하는 과정이다. 즉, 실행 속도를 높이거나 메모리 사용량을 줄이거나 네트워크 비용을 절감하는 등의 작업이 포함된다.

2. 싱글톤 패턴을 사용하는 이유

  • 싱글톤 패턴을 사용하면 메모리 낭비를 줄이고 여러개의 객체가 생성되는 걸 방지할 수 있다.
  • DB 연결 객체, 로그 관리 객체, 설정 파일 관리 객체 등 여러 개의 객체가 필요하지 않을 경우 싱글톤으로 관리하면 좋다.
    1. DB 연결(Connection Pool) : 같은 DB를 공유해 성능 최적화한다.
    2. 설정(Configuraion) 관리 : 하나의 설정 인스턴스를 모든 곳에서 공유한다.
    3. 로그(Log) 관리 : 여러 개의 로그 객체를 만들 필요 없이, 하나의 인스턴스에서 관리한다.
    4. 캐시(Cache) 관리 : 한 번 생성한 데이터를 메모리에 유지해 성능을 향상한다.

3. 싱글톤 패턴 구현 방법

1) Eager Initialization(미리 초기화 - 객체를 필요로 하기 전에 미리 생성하는 방식)

  • 싱글톤 객체를 static final로 선언해 클래스가 로드 될 때 미리 초기화하는 방식이다.
  • 싱글톤 객체를 호출하기 전에 인스턴스를 만들어서 대기하는 방식이다.
public class Singleton {
    private static final Singleton INSTANCE = new Singleton(); // ✅ 클래스 로딩 시 객체 생성

    private Singleton() {} // private 으로 생성자를 선언해 new로 객체를 선언하지 못하게 한다.

    public static Singleton getInstance() {
        return INSTANCE; // ✅ 항상 같은 객체 반환
    }
}

 

장점

  1. 구현이 매우 간단하고 직관적이다.
  2. 멀티스레드 환경에서 안정적이다. -> 클래스는 원자적으로 처리하기 때문에 싱글톤 객체가 안전하게 초기화된다.
  3. 객체 생성 비용이 크지 않고 객체를 항상 사용하는 것이 확실하면 효율적인 방법이다.

*원자적: 어떤 작업이 중간에 끊기거나 다른 작업이 끼어들 수 없이 한번에 실행되는 것을 의미한다.

 

단점

  1. 싱글톤 객체가 사용되지 않는다면 메모리 낭비가 발생한다.

 

2) Static Block Initialization (정적 블록 초기화)

  • static block 을 이용해 인스턴스를 초기화 하는 방식이다.
  • Eager Initialization 방식과 거의 동일하지만 static 블록을 사용해 예외 처리를 할 수 있다는 차이점이 있다.
public class Singleton {
    private static final Singleton INSTANCE;

    static {
        try {
            INSTANCE = new Singleton(); // ✅ 클래스 로딩 시 객체 생성
        } catch (Exception e) {
            throw new RuntimeException("Error creating singleton instance");
        }
    }

    private Singleton() {} // private 생성자

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

 

장점

  • 멀티스레드 환경에서 안전하다.
  • 예외 처리가 가능하다. (객체 생성 중 오류가 발생하면 예외를 던질 수 있다.)
  • 객체 생성 비용이 크지 않고 객체를 항상 사용하는 것이 확실하고 객체 생성 중 예외가 발생할 가능성이 있을 때 사용하면 효율적인 방식이다. 

단점

  • 미리 객체를 생성해 사용되지 않으면 메모리가 낭비된다.

 

3) Lazy Initialization(기본적인 싱글톤)

  • 싱글톤 객체를 리턴하는 메서드를 호출했을 때 싱글톤 인스턴스 변수 null 유무의 따라 초기화하거나 반환하는 방법이다.
public class Singleton {
    private static Singleton instance;

    private Singleton() {}

    public static Singleton getInstance() {
        if (instance == null) {  // 🚨 멀티스레드 환경에서 안전하지 않음
            instance = new Singleton();
        }
        return instance;
    }
}

 

단점

  1. Thread-unsafe한 방식이기 때문에 여러 스레드가 동시에 접근하면 싱글톤 객체가 두개 이상 생성될 수 있다.

 

4) synchronized 키워드를 사용한 싱글톤

  • Lazy Initialization 방식의 스레드 안전하지 않은 문제를 해결하기 위해 synchronized를 추가한 방식이다. 
  • synchronized 를 통해 메서드에 스레드가 하나씩 접근시키는 방식이다.
public class Singleton {
    private static Singleton instance;

    private Singleton() {}

    public static synchronized Singleton getInstance() { // 🚨 성능 저하 발생 가능
        if (instance == null) {
            instance = new Singleton();
        }
        return instance;
    }
}

 

단점

  1. synchronized 를 사용하면 메서드를 호출할 때마다 매번 동기화가 발생해 성능이 저하된다. (오버헤드 발생)
  2. 이미 객체가 생성된 이후에는 synchronized 가 필요 없는데 불필요한 동기화가 일어난다.

* 오버헤드: 어떤 작업을 수행하기 위해 추가적으로 발생하는 비용(시간, 메모리, 연산량 등)을 의미한다. 즉, 실제 작업을 수행하는 것 외에도 부가적으로 필요한 리소스를 말한다.

*오버헤드 발생 이유 : 모든 스레드가 동시에 접근하지못하고 순차적으로 실행됨으로써 CPU가 Lock을 관리하는데 추가적인 비용(연산과 시간)이 발생하여 성능이 저하된다.

 

5) Double-Checked Locking (DCL)

  • synchronized의 성능저하 문제를 해결하려고 나타났다. 
  • double-checked Locking 은 두번의 null 체크를 통해 이미 생성되어 있는 경우에는 동기화 블록에 진입하지 않고 빠르게 반환할 수 있게한다.
  • 최초 생성시에는 동기화가 필요하지만 그 이후에는 동기화 없이 인스턴스를 반환하는 방식이다.
  • 객체를 생성된 이후에는 동기화가 필요하지 않으므로 오버헤드가 줄어든다.
  • volatile을 사용 객체 생성시 발생할 수 있는 명령어 재정렬을 방지한다.
    • volataile은 CPU가 연산 순서를 바꾸지 못하게 막아준다.
    • instance가 생성될 때 다른 스레드가 최신값을 즉시 볼 수 있도록 하고

*volatile : 멀티스레드 환경에서 변수의 값이 항상 최신 상태로 유지지 되도록 보장하는 키워드다. CPU 캐시가 아닌 메인 메모리에서 변수를 읽고 쓰고록 강제하는 역할을 한다. 즉, volatile을 사용하면 한 스레드가 변경한 값을 다른 스레드가 즉시 볼 수 있도록 보장하고 CPU가 연산 순서를 바꾸지 못하게 막는다.

*DCL에서 volatile이 필요한 이유 :

instance가 생성될 때 다른 스레드가 최신 값을 즉시 볼 수 있도록해서 가시성이 보장되고 

*가시성 : 한 스레드가 변경한 변수값을 다른 스레드가 즉시 볼 수 있는지를 말한다. 가시성이 보장된다는 것은 값을 인식할 수 있는 것이고 보장되지 않는다는 것은 값을 인식하지 못한다는 것이다.

*명령어 재정렬 : JVM과 CPU는 성능 최적화를 위해 연산 순서를 바꿀 수 있다. 

(1) 정상적인 객체 생성 순서 (문제 없음)

1️⃣ 메모리 공간을 할당한다. (allocate memory)
2️⃣ 생성자를 실행하여 객체를 초기화한다. (initialize)
3️⃣ instance 변수에 객체의 주소를 저장한다. (assign reference)

 

(2) 명령어 재정렬로 인해 발생할 수 있는 잘못된 실행 순서

1️⃣ 메모리 공간을 할당한다. (allocate memory)
2️⃣ instance 변수에 객체의 주소를 저장한다. (assign reference) 🚨
3️⃣ 생성자를 실행하여 객체를 초기화한다. (initialize) 🚨

이 경우 인스턴스 변수가 다른 스레드에서 읽을 때 객체 내 데이터가 초기화 되지 않은 상태인 불완전한 상태일 수 있다. 그래서 volatile을 사용해 명령어 재정렬을 방지한다.

다른 싱글톤 패턴은 클래스 로딩 시점에 객체 생성이 되기 때문에 명령어 재정렬 문제가 발생하지 않는다. 

 

public class Singleton {
    // volatile 키워드가 중요함: 객체 생성 시 발생할 수 있는 명령어 재정렬 문제를 방지함.
    private static volatile Singleton instance;

    private Singleton() {
    }

    public static Singleton getInstance() {
        // 1. 첫 번째 체크: 동기화 없이 빠르게 null 여부 확인
        if (instance == null) {
            synchronized (Singleton.class) {
                // 2. 두 번째 체크: 다른 스레드가 동기화 블록 안에서 생성했는지 다시 확인
                if (instance == null) {
                    instance = new Singleton();
                }
            }
        }
        return instance;
    }
}

 

장점

  1. 멀티스레드 환경에서 안전하다.
  2. 객체가 필요할 때만 생성(Lazy Initialization(지연 초기화))되므로 메모리 절약이 가능하다.
  3. synchronized를 최소화해 성능 저하 문제를 해결했다.

단점

  1. volatile이 자바4 버전에서는 지원이 불완전하다.
  2. 코드가 다른 것들보다 복잡하다.

 

6) Bill Pugh Solution (빌 퓨 솔루션LazyHolder) – 정적 내부 클래스 방식

    • Bill Pugh Solution 방식은 싱글톤을 구현하는 가장 효율적인 방식 중 하나이다.
    • 정적 내부 클래스(static nested class) 를 사용해 JVM의 클래스 로딩 원자성을 활용한다.
    • 이 방식을 통해 Lazy Initialization(지연 초기화)가 가능하고 멀티 스레드 환경에서 안전하다.

*원자성 : 클래스 로딩 시 

public class Singleton {
    private Singleton() {} // ✅ private 생성자

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

    public static Singleton getInstance() {
        return SingletonHolder.INSTANCE; // ✅ getInstance()가 처음 호출될 때 객체가 생성됨
    }
}

 

장점

  1. 정적 내부 클래스는 호출시 로드 되기 때문에 Lazy Initialization(지연 초기화)이 된다. (사용하지 않는다면 메모리 낭비를 하지 않는다.)
  2. 클래스를 로드할 때 원자성이 보장되 여러 스레드가 접근해도 한번만 실행되어 멀티스레드 환경에서도 안전하다.
  3. (그리고 static final에 싱글톤 객체를 담아 초기화 후 값을 변경하지 못하기 때문에 불변성이 보장되어 더욱 안정적이다.)
  4. syncrozied 불록이 없어 동기화없이 빠른 실행이 가능하다.
  5. 구현이 간결하고 직관적이다.
  6. 웹 애플리케이션 설정 관리할 때 사용한다.

*원자성 : 작업이 중간에 끊기지 않고, 하나의 단위로 실행되는 것

 

단점

  1. 리플렉션을 사용하면 private 생성자를 강제로 호출해 새로운 객체를 생성해서 강제로 싱글톤을 깨뜨릴 수 있지만 생성자에 두번째 객체 생성을 방지하는 코드를 작성하면 된다.
  2. Serializable를 구현하면 역직력화 시 새로운 객체가 생성될 수 있지만 readResolve() 메서드를 추가해 기존의 싱글톤 인스턴스를 반환하도록 설정하면 된다.

7) Enum Singleton 

  • enum을 이용한 싱글톤은 가장 안전하고 간결한 싱글톤 구현 방식이다.
  • 자바의 enum은 기본적으로 싱글톤 패턴이 보장되도록 설계되어 있어 추가적인 코드 없이 싱글톤 생성이 가능하다.
  • 리플렉션 공격을 방어할 수 있고 직렬화/역직렬화 시에도 싱글톤이 깨지지 않는다.

예제1

public enum Singleton {
    INSTANCE; // ✅ 싱글톤 인스턴스

    public void someMethod() {
        System.out.println("Hello, Singleton!");
    }
}
public class Main {
    public static void main(String[] args) {
        Singleton s1 = Singleton.INSTANCE;
        Singleton s2 = Singleton.INSTANCE;

        System.out.println(s1 == s2); // ✅ true (항상 같은 객체)
    }
}

 

예제2

public enum AppConfig {
    INSTANCE; // ✅ Enum 기반 싱글톤 객체

    private String dbUrl;
    private String dbUser;
    private String dbPassword;

    // 초기 설정값 로드
    AppConfig() {
        // 예제: 환경 변수를 이용한 설정값 로드
        this.dbUrl = System.getenv().getOrDefault("DB_URL", "jdbc:mysql://localhost:3306/mydb");
        this.dbUser = System.getenv().getOrDefault("DB_USER", "root");
        this.dbPassword = System.getenv().getOrDefault("DB_PASSWORD", "password");
    }

    // Getter 메서드
    public String getDbUrl() {
        return dbUrl;
    }

    public String getDbUser() {
        return dbUser;
    }

    public String getDbPassword() {
        return dbPassword;
    }
}
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/config")
public class ConfigController {

    @GetMapping
    public String getConfig() {
        // Enum Singleton을 사용하여 설정 정보 가져오기
        AppConfig config = AppConfig.INSTANCE;

        return "DB URL: " + config.getDbUrl() + "<br>" +
               "DB User: " + config.getDbUser() + "<br>" +
               "DB Password: " + config.getDbPassword();
    }
}

 

장점

  1. Enum 타입은 JVM이 클래스 로딩 시 한번만 로드하기 때문에 싱글톤 객체가 추가로 생성될 가능성이 없어 동기화 없이도 멀티스레드 환경에서 안전하게 사용할 수 있다.
  2. Enum 은 내부적으로 추가적인 객체 생성을 허용하지 않는다.
  3. Enum은  내부적으로 readResolve()를 자동으로 처리하기 때문에 직렬화/역직렬화 시에도 싱글톤이 유지된다.
  4. 리플렉션 공격 방어가 가능하다. enum 생성자는 JVM 내부에서 관리되기 때문에 리플렉션을 사용해도 새로운 객체를 생성할 수 없다.
  5. 가장 간결하게 싱글톤을 구현한다. 추가적인 코드가 필요없다.
  6. 보안이 중요한 환경에서 사용될 수 있다.

단점

  1. LazyInitialization(지연 초기화) 불가능하다. Enum은 클래스가 로드 될 때 객체가 생성되기 때문에 LazyInitialization(지연 초기화)가 필요하다면 적합하지 않다. (즉, 프로그램 실행 후 싱글톤 객체가 필요 없더라도 미리 생성된다.)
    • LazyInitialization(지연 초기화)가 필요하면 Bill Pugh Solution 이 적합하다.

 

4. 요약 & 정리

  • 가장 안전하고 쉬운 방식 -> Enum singeton
  • Lazy Initialization & 선능 최적화 -> Bill Pugh Solution