김코딩

싱글톤 패턴(Singleton Pattern) 본문

디자인 패턴

싱글톤 패턴(Singleton Pattern)

김코딩딩 2026. 1. 13. 15:29

디자인 패턴 시리즈의 첫 번째 주제는 싱글톤 패턴(Singleton Patter)이다.

 

싱글톤은 23가지 GoF 디자인 패턴 중 가장 간단하면서도, 가장 많이 사용되고, 동시에 가장 많이 오해받는 패턴이다.

 

Spring을 사용한다면 이미 매일 싱글톤 패턴을 사용하고 있을 것이다. 하지만 정작 "왜 싱글톤인가?"를 정확히 아는 개발자는 많지 않다.

 

이번 글에서는 싱글톤 패턴이 무엇인지, 왜 필요한지, 어떻게 구현하는지, 그리고 실무에서 어떻게 사용되는지 알아보자.


싱글톤 패턴의 정의

싱글톤 패턴(Singleton Pattern)클래스의 인스턴스가 오직 하나만 생성되도록 보장하고, 어디서든 그 인스턴스에 접근할 수 있도록 하는 생성 패턴이다.

 

핵심 특징:

  1. 클래스의 인스턴스가 단 하나만 존재한다
  2. 인스턴스에 대한 전역 접근점을 제공한다
  3. 생성자를 private으로 만들어 외부에서 직접 생성을 막는다

어떤 상황에서 싱글톤 패턴을 사용할까?

애플리케이션을 개발하다 보면, 전체 시스템에서 단 하나의 인스턴스만 필요한 객체들이 있다.

 

예를 들어:

  • 데이터베이스 연결 풀(Connection Pool): 여러 개 만들면 리소스 낭비
  • 설정 관리자(Configuration Manager): 앱 전체에서 같은 설정을 공유해야 함
  • 로거(Logger): 모든 곳에서 동일한 로깅 시스템을 사용해야 함
  • 캐시(Cache): 데이터를 공유하려면 하나의 캐시 인스턴스가 필요

만약 이런 객체들을 여러 번 생성하면 어떻게 될까?

DatabaseConnection conn1 = new DatabaseConnection();
DatabaseConnection conn2 = new DatabaseConnection();
DatabaseConnection conn3 = new DatabaseConnection();

 

메모리 낭비는 물론이고, 각 인스턴스가 서로 다른 상태를 가지게 되어 예상치 못한 버그가 발생할 수 있다. 특히 설정 객체가 여러 개 생성되면, 어떤 설정이 진짜인지 알 수 없는 상황이 벌어진다.

"이 클래스의 인스턴스는 절대 하나만 존재해야 해!"

 

이런 요구사항을 해결하는 것이 바로 싱글톤 패턴이다.


순수 Java로 구현하기

싱글톤 패턴은 여러 방법으로 구현할 수 있다. 각각의 장단점을 살펴보자.

1. Eager Initialization (이른 초기화)

가장 간단한 방법이다. 클래스 로딩 시점에 인스턴스를 미리 생성한다.

public class DatabaseConnection {
    // 클래스 로딩 시점에 인스턴스 생성
    private static final DatabaseConnection instance = new DatabaseConnection();
    
    // private 생성자로 외부 생성 차단
    private DatabaseConnection() {
        System.out.println("DatabaseConnection 생성됨");
    }
    
    // 전역 접근점 제공
    public static DatabaseConnection getInstance() {
        return instance;
    }
    
    public void connect() {
        System.out.println("DB 연결 성공!");
    }
}

// 사용 예시
public class Main {
    public static void main(String[] args) {
        DatabaseConnection conn1 = DatabaseConnection.getInstance();
        DatabaseConnection conn2 = DatabaseConnection.getInstance();
        
        System.out.println(conn1 == conn2);  // true - 같은 인스턴스!
        conn1.connect();
    }
}

 

장점:

  • 구현이 간단하다
  • Thread-safe하다 (클래스 로딩은 JVM이 보장)

단점:

  • 사용하지 않아도 무조건 생성된다 (메모리 낭비 가능)
  • 생성 시점에 예외 처리가 어렵다

2. Lazy Initialization (늦은 초기화)

실제로 사용될 때 인스턴스를 생성한다.

public class ConfigManager {
    private static ConfigManager instance;
    
    private ConfigManager() {
        System.out.println("ConfigManager 생성됨");
    }
    
    // 사용 시점에 생성
    public static ConfigManager getInstance() {
        if (instance == null) {
            instance = new ConfigManager();
        }
        return instance;
    }
}

 

장점:

  • 필요할 때만 생성된다 (메모리 효율적)

단점:

  • Thread-safe하지 않다! 멀티스레드 환경에서 여러 인스턴스가 생성될 수 있음

3. Thread-Safe Lazy Initialization (동기화)

synchronized 키워드로 스레드 안전성을 보장한다.

public class Logger {
    private static Logger instance;
    
    private Logger() {}
    
    // synchronized로 동기화
    public static synchronized Logger getInstance() {
        if (instance == null) {
            instance = new Logger();
        }
        return instance;
    }
}

 

장점:

  • Thread-safe하다
  • 필요할 때만 생성된다

단점:

  • 성능 저하 (매번 동기화 비용 발생)

4. Double-Checked Locking

성능과 안전성을 모두 잡은 방법이다.

public class CacheManager {
    private static volatile CacheManager instance;
    
    private CacheManager() {}
    
    public static CacheManager getInstance() {
        if (instance == null) {  // 첫 번째 체크 (동기화 X)
            synchronized (CacheManager.class) {
                if (instance == null) {  // 두 번째 체크 (동기화 O)
                    instance = new CacheManager();
                }
            }
        }
        return instance;
    }
}

 

장점:

  • Thread-safe하다
  • 성능이 좋다 (이미 생성된 경우 동기화 안 함)

단점:

  • 코드가 복잡하다

5. Bill Pugh Solution

Inner Static Helper Class를 사용하는 방법으로, 가장 우아하고 효율적이다.

public class ApiClient {
    private ApiClient() {}
    
    // Inner Static Helper Class
    private static class SingletonHelper {
        private static final ApiClient INSTANCE = new ApiClient();
    }
    
    public static ApiClient getInstance() {
        return SingletonHelper.INSTANCE;
    }
}

 

장점:

  • Thread-safe하다
  • Lazy Loading이 가능하다 (Helper 클래스가 로딩될 때 생성)
  • 동기화 비용이 없다
  • 코드가 깔끔하다

가장 권장되는 방식이다.

6. Enum Singleton (최고의 방법)

Joshua Bloch가 "Effective Java"에서 제안한 방법이다.

public enum SystemConfig {
    INSTANCE;
    
    public void loadConfig() {
        System.out.println("설정 로드");
    }
}

// 사용
SystemConfig.INSTANCE.loadConfig();

 

장점:

  • 가장 간결하다
  • Thread-safe하다
  • Serialization 문제 해결
  • Reflection 공격 방어

단점:

  • 상속이 불가능하다
  • Enum을 싱글톤으로 쓴다는 게 직관적이지 않을 수 있다

싱글톤 패턴의 장점

  1. 메모리 절약: 하나의 인스턴스만 생성하므로 메모리 효율적
  2. 전역 접근: 어디서든 동일한 인스턴스에 접근 가능
  3. 데이터 공유: 여러 곳에서 같은 데이터를 공유해야 할 때 유용
  4. 초기화 제어: 인스턴스 생성 시점을 제어할 수 있음

싱글톤 패턴의 단점

  1. 테스트하기 어렵다: Mock 객체로 대체하기 어려움
  2. 전역 상태: 전역 변수처럼 동작하여 결합도가 높아짐
  3. 단일 책임 원칙 위반: 인스턴스 생성과 비즈니스 로직이 섞임
  4. 멀티스레드 문제: 구현 방법에 따라 동시성 이슈 발생 가능

"싱글톤은 안티패턴이다"라는 의견도 있다. 남용하면 코드가 강하게 결합되고 테스트가 어려워진다.


Spring에서의 싱글톤

Spring을 사용한다면, 이미 싱글톤 패턴을 매일 사용하고 있다.

@Service
public class UserService {
    // Spring Container가 싱글톤으로 관리
}

@Repository
public class UserRepository {
    // 이것도 싱글톤
}

 

Spring의 싱글톤 방식:

  • Spring Container가 빈(Bean)을 기본적으로 싱글톤 스코프로 관리
  • 개발자가 직접 싱글톤 코드를 작성할 필요 없음
  • Thread-safe하게 관리됨
  • 필요하면 @Scope("prototype")으로 변경 가능

중요한 차이:

  • 순수 싱글톤: JVM당 하나의 인스턴스
  • Spring 싱글톤: Spring Container당 하나의 인스턴스

Spring을 사용한다면 직접 싱글톤 코드를 구현할 일은 거의 없다. Spring Container에게 관리를 맡기는 것이 더 안전하고 편리하다.


실무에서 언제 사용할까?

사용하면 좋은 경우:

  • 설정 관리 객체
  • 로깅 시스템
  • 데이터베이스 연결 풀
  • 캐시 매니저
  • 스레드 풀

사용하지 말아야 하는 경우:

  • 상태를 가지는 객체 (Stateful)
  • 여러 인스턴스가 필요한 경우
  • 테스트가 중요한 경우

핵심 원칙:

"Spring을 사용한다면 직접 싱글톤을 구현하지 말고, Spring Container에게 맡겨라"


결론

싱글톤 패턴가장 간단하면서도 실무에서 가장 많이 사용되는 패턴이다.

 

기억해야 할 포인트:

  • 인스턴스가 하나만 필요할 때 사용한다
  • 여러 구현 방법이 있지만, Bill Pugh 방식 또는 Enum 방식을 권장한다
  • Spring 환경에서는 직접 구현하지 말고 Container에게 관리를 맡긴다
  • 남용하지 않는다 - 꼭 필요한 곳에만 사용한다

다음 글에서는 객체 생성을 더 유연하게 만드는 팩토리 메서드 패턴을 알아볼 것이다.

 

다음 글 예고: 팩토리 메서드 패턴 - 객체 생성의 책임을 분리하라