본문 바로가기
개발/Design Pattern

[Pattern] 싱글톤 패턴 정리

by baau 2023. 6. 25.

  • 단 하나의 유일한 객체를 만들기 위한 디자인 패턴
  • 클래스의 인스턴스를 오직 하나만 만들어서 글로벌하게 접근할 수 있도록 한다.
    • 메모리 절약을 위해 인스턴스가 필요할 때 똑같은 인스턴스를 만들지 않고 기존의 인스턴스를 가져와 활용하는 기법
  • 대부분 리소스를 많이 차지하는 역할을 하는 무거운 클래스를 대상으로 한다.
    • ex) 데이터베이스 연결 모듈, 디스크 연결, 네트워크 통신, DBCP 커넥션풀, 스레드풀, 캐시, 로그 기록 객체

 

자바로 싱글톤 패턴 구현

생성자를 private으로 설정해서 외부에서 인스턴스로 만들 수 없게 만드는 것이 중요하다.

 

1. Lazy initialization

public class Singleton {

	private static Singleton instance;

	private Singleton();	

	public static Singleton getInstance() {
		if (instance == null) {
			intance = new Singleton();
		}
		return instance; 
	}
}
  • 메서드를 호출했을 때 인스턴스 변수의 초기화 여부에 따라 초기화하거나 이미 존재하는 것을 반환한다.
  • 사용하는 시점에 생성할 수 있는 장점이 있다.
  • 멀티 쓰레드 환경에서 안전하지 않다. (thread-safe X)

 

2. Eager initialization

public class Singleton {

	private static final Singleton instance = new Singleton();

	private Singleton();	

	public static Singleton getInstance() {
		return instance;
	}
}
  • 가장 직관적인 방법이다.
  • static final로 선언하여, Singleton 클래스의 유일한 인스턴스를 저장하고, immutable 하다.
  • static은 당장 사용하지 않는 객체더라도, 클래스 로딩 시점에 정적 필드 instance가 초기화된다.
    • 만일 리소스가 큰 객체일 경우 공간 자원 낭비가 발생한다.
  • 생성자 생성의 비용이 크지 않더라면, 해당 방법을 사용하더라도 큰 무리는 없다.

 

3. Thread safe initialization

public class Singleton {

	private static Singleton instance;

	private Singleton();	
	
	public static synchronized Singleton getInstance() {
		if (instance == null) {
			intance = new Singleton();
		}
		return instance;
	}
}
  • synchronized 키워드를 통해 하나의 스레드만 해당 메서드에 접근할 수 있도록 한다.
  • 따라서 멀티 쓰레드 환경에서도 안전하다. (thread-safe O)
  • 여러 개의 스레드가 인스턴스를 가져올 때마다 synchronized 메서드를 매번 호출하여 동기화 작업이 일어나 성능이 떨어진다.
    • 인스턴스가 초기화되지 않은 상태에서는 동기화 작업을 통해 인스턴스가 단 하나만 만들어지는 것을 보장할 수 있지만,
    • 이미 기존의 인스턴스가 만들어진 상황에서도 동기화 작업이 일어난다.

 

4. Double-Checked Locking

public class Singleton {

	private static volatile Singleton instance;

	private Singleton();	

	public static Singleton getInstance() {
		if (instance == null) {
			synchronized (Singleton.class) {
				if (instance == null) {
					intance = new Singleton();
				}
			}
		}
    		
		return instance;
	}
}
  • 이미 인스턴스가 기존에 존재하는 경우 synchronized의 동기화 과정이 일어나지 않는다.
  • 인스턴스가 없을 경우, 최초 초기화할 때만 동기화 과정이 일어난다.
  • volatile 키워드를 통해 변수의 일관성과 가시성을 보장한다.
    • 변수를 읽고 쓰는 작업이 캐시 메모리가 아닌 메인 메모리에서 이루어지도록 한다. 따라서 변경된 값을 다른 스레드가 즉시 알 수 있어 가시성(visibility) 문제를 해결한다.
    • volatile 은 JVM 1.5 이상이어야 한다.
    • volatile 키워드는 모든 스레드 간의 상호작용을 동기화하는 완전한 동기화 메커니즘이 아니다.
    • volatile은 변수의 가시성과 일관성을 보장하지만, 스레드 간의 상호작용이 여러 단계로 이루어지거나 원자적이지 않은 연산을 수행하는 경우에는 동기화 메커니즘(synchronized 블록, Lock)을 추가해야 한다.
    • 따라서 volatile을 사용하는 것을 지양하는 편이다.

 

5. LazyHolder 👍🏻

public class Singleton {

	private Singleton();	

	private static class SingletonHolder {
		private static final Singleton INSTANCE = new Singleton();
	}

	public static Singleton getInstance() {
		return SingletonHolder.INSTANCE;
	}
}
  • 정적 내부 클래스 SingletonHolder는 Singleton의 인스턴스를 지연 초기화하는 역할을 한다.
  • getInstance() 메서드가 호출되면 SingletonHolder.INSTANCE를 통해 Singleton 인스턴스가 초기화되고 반환됩니다.
  • 이를 통해 지연 초기화와 동시에 스레드 안전성이 보장됩니다.
  • 다만 클라이언트가 임의로 Reflection API, 직렬화/역직렬화를 통해 싱글톤을 파괴할 수 있다는 단점을 가지고 있다.
  • 안정성보다는 성능이 중요시되는 환경에서 사용하기 적합하다.

 

6. Enum 👍🏻

public enum Settings {
	INSTANCE;
}

Settings settings = Settings.INSTANCE;
  • enum은 멤버를 만들 때, private로 만들고 한 번만 초기화한다.
  • 따라서 유일한 인스턴스를 만들고, 동시성을 보장한다.
  • 클라이언트에서 Reflection을 통한 공격에도 안전하다.
  • 하지만 클래스 상속이 필요할 때, enum 외의 클래스 상속은 불가능하다.
  • 직렬화와 안정성이 중요시하는 환경에서 사용하기 적합하다.

 

싱글톤 구현 방법을 깨트리는 방법

1. 리플렉션

Singleton settings = new Singleton();

Constructor<Singleton> constructor = Singleton.class.getDeclaredConstructor();
constructor.setAccessible(true);
Singleton sigleton = constructor.newInstance();

settings == settings1 // false
  • 리플렉션을 사용하여, Singleton 클래스의 private 접근자를 무시하고 생성자에 접근할 수 있다.
  • newInstance()를 통해 새로운 객체를 동적으로 생성할 수 있다.
  • Singleton 패턴의 목적을 깰 수 있다.

 

2. 직렬화 & 역직렬화

public calss Singleton implements Serializable {
	...
}

Singleton settings = new Singleton();
  • Singleton 클래스를 직렬화하고 역직렬화하는 경우, 역직렬화된 객체는 새로운 인스턴스가 된다.
  • 직렬화/역직렬화 과정에서 생성자는 호출되지 않기 때문에, Singleton 클래스의 인스턴스 생성 방지 메커니즘이 우회될 수 있다.
  • Singleton 패턴의 목적을 깰 수 있다.
  • Singleton 클래스를 직렬화할 때 Singleton 패턴을 유지하기 위해서는 readResolve() 메서드를 정의해야 합니다.
    • readResolve() 메서드는 역직렬화된 객체를 반환하는 역할을 합니다. readResolve() 메서드를 구현하여 항상 동일한 Singleton 인스턴스를 반환하도록 지정할 수 있습니다.
public class Singleton implements Serializable {
	...

	private static final Singleton instance = new Singleton();

	private Singleton() {}

	public static Singleton getInstance() {
		return instance;
	}

	protected Object readResolve() {
		return instance;
	}
}

 

싱글톤의 문제점

  • 모듈 간 의존성이 높아진다.
    • 싱글톤은 대부분 인터페이스가 아닌 클래스의 객체를 미리 생성하고 정적 메서드를 사용하여 다른 클래스에서 사용하기 때문에 클래스 간의 강한 의존성과 높은 결합이 생기게 된다.
    • 싱글톤 인스턴스가 변경되면 이를 참조하고 있는 모든 모듈들도 수정이 필요할 수 있다.
  • SOLID 위배 사례가 많다.
    • 싱글톤 인스턴스 자체가 하나만 생성하기 때문에 여러 가지 책임을 지닐 수 있다. (SRP 위반)
    • 싱글톤 클래스를 많은 클래스가 참조한다면, 클래스들 간의 결합도가 높아질 수 있다. (OCP 위반)
    • 의존 관계상 클라이언트가 인터페이스와 같은 추상화가 아닌, 구체 클래스에 의존하게 된다. (DIP도 위반)
  • 단위 테스트의 어려움
    • 싱글톤 클래스를 사용하는 모듈을 테스트하기 어렵다.
    • 단위 테스트는 테스트가 서로 독립적이어야 하고 테스트를 어떤 순서로든 실행할 수 있어야 하는데, 싱글톤 인스턴스는 자원을 공유하고 있기 때문에, 테스트가 결함 없이 수행되려면 매번 인스턴스의 상태를 초기화시켜주어야 한다.
    • 싱글톤은 생성 방식이 제한적이기 때문에 Mock 객체로 대체하기 어려우며, 동적으로 객체를 주입하기도 힘들다.

 

스프링 컨테이너에서 객체 관리

  • 스프링 컨테이너는 싱글톤 패턴을 적용하지 않아도, 객체 인스턴스를 Ioc 방식의 컨테이너에서 싱글톤으로 관리한다.
  • private 생성자를 사용할 필요가 없어, 상속을 통해 싱글톤 객체를 확장하거나 사용할 수 있는 가능성을 열어둔다.
  • 테스트 환경에서 동일한 인스턴스를 공유하여 일관된 테스트를 수행할 수 있고, 필요한 경우 Mock 객체로 대체하여 테스트할 수 있다.
  • 스프링 컨테이너에 객체가 빈 등록되면, 컨테이너가 해당 빈을 싱글톤으로 생성하고 관리하기 때문에 1개의 객체 생성을 보장받을 수 있다.
  • 스프링 컨테이너를 사용하여 객체를 싱글톤으로 관리하면 객체지향적인 개발을 할 수 있다. DI을 통해 객체 간의 관계를 구성하고, 인터페이스를 통해 프로그래밍할 수 있습니다.