📌 싱글턴(Singleton)
코딩을 맨처음 배울 때, '싱글턴 패턴이라는 것이 있다' 정도를 듣기는 했었지만 당시만 해도 이렇게까지 중요한 내용일 거라고는 생각도 못했었다.
싱글턴이란 인스턴스를 오직 하나만 생성할 수 있는 클래스이다.
(ex. 무상태(stateless) 객체, 설계상 유일해야 하는 시스템 컴포넌트)
✒️ 싱글턴의 한계점
1. private 생성자를 가지고 있어 상속이 불가능하다
2. 테스트가 어렵다 : 인스턴스를 구현한 Singleton 객체가 아니라면 mock 객체(실제 객체를 다양한 조건으로 인해 제대로 구현하기 어려울 경우 사용하는 가짜 객체. 테스트 작성을 위한 환경 구축이 어렵거나, 테스트가 특정 케이스에 의존적인 경우에 사용한다.)를 만들 수 없어, 클라이언트가 테스트하기가 힘들어진다.
3. 서버 환경에서는 싱글턴이 하나만 만들어짐을 보장할 수 없다 : 서버의 클래스 로더 구성 방식에 따라, 혹은 여러 개의 JVM에 분산되어 설치되는 경우 싱글턴임에 불구하고 하나 이상의 오브젝트 생성이 가능할 수 있다.
4. 싱글톤의 사용은 전역 상태를 만들 수 있어 바람직하지 못하다 : 객체 지향 프로그래밍에서는 1번과 4번 단점 모두 권장되지 않는 모델이다. 4번의 경우 static 필드와 메서드로만 구성된 클래스를 사용하는 것을 권장한다.
📌 싱글턴을 만드는 방식
1️⃣ public static 멤버가 final 필드인 방식
public class Elvis {
public static final Elvis INSTANCE = new Elvis();
private Elvis() {}
public void leaveTheBuilding() {}
}
private 생성자는 public static final 필드인 Elvis.INSTANCE를 초기화할 때 딱 한 번만 호출된다.
public 혹은 protected 생성자가 없기 때문에 외부에서 클래스 생성이 불가능하므로, Elvis 클래스가 초기화될 때 만들어진 인스턴스는 하나 뿐임을 보장한다.
pros1. 해당 클래스가 싱글턴임이 API에 명백히 드러난다. (final이므로 다른 객체 참조 불가하므로)
pros2. 간결하다.
2️⃣ 정적 팩터리 메서드를 public static 멤버로 제공하는 방식
public class Elvis {
private static final Elvis INSTANCE = new Elvis();
private Elvis() {}
public static Elvis getInstance() { return INSTANCE; }
public void leaveTheBuilding() {}
}
Elvis.getInstance()는 언제나 같은 객체의 참조를 반환하므로 인스턴스가 하나임을 보장한다.
pros1. API를 바꾸지 않고도 싱글턴이 아니게 변경 가능한 확장성이 있다. 유일한 인스턴스를 반환하던 팩터리 메서드가 호출하는 스레드 별로 다른 인스턴스를 넘겨주도록 리턴하는 등 기능 변환이 가능하다.
pros2. 정적 팩터리를 제네릭 싱글턴 팩터리(Item 30)로 만들 수도 있다.
pros3. 정적 팩터리 메서드 참조를 공급자(supplier)로 사용 가능하다.
✒️ 공급자(Supplier)
가령 Elvis::getInstance를 Supplier<Elivs>처럼 함수형 인터페이스의 구현체로 사용하는 방법(Item 43, 44)이다.
정적 팩터리 메서드를 Supplier로 사용하게 되면, 객체 생성 로직을 Supplier 구현체에 캡슐화할 수 있다.
그리고 필요한 시점에 get함수만으로 객체를 생성할 수 있게 된다.
# Supplier: get 메서드만으로 아무 type이나 반환이 가능한 인터페이스.
public class Foo {
public static void main(String[] args) {
// Supplier로 getInstance 메서드 참조
Supplier<Elvis> supplier = Elvis::getInstance;
// get 메서드를 호출하여 Elvis 객체 생성
Elvis elvis = supplier.get();
// Elvis 인스턴스의 메서드 호출
elvis.leaveTheBuilding();
}
}
📌 리플렉션(Reflection) 방어
비록 public static final 방식과 static factory 방식 모두 클라이언트의 인스턴스 생성을 막아주지만,
권한이 있는 클라이언트는 리플렉션 API(Item 65)인 AccessibleObject.setAccessible을 사용해 private 생성자를 호출할 수 있다.
Constructor<Elvis> constructor = (Constructor<Elivs>)elvis2.getClass().getDeclaredConstructor();
constructor.setAccessible(true);
Elvis elvis3 = constructor.newInstance();
assertNotSame(elvis2, elvis3); // Fail
이러한 경우 대부분 악의적인 공격이라 간주하여, 두 번째 객체가 생성되려 할 때 예외처리를 해주어야 한다.
public class Elvis {
public static final Elvis INSTANCE = new Elvis();
private Elvis() {
if (INSTANCE != null) { throw new RuntimeException("어림도 없습니다 ^^"); }
}
}
📌 두 방식의 문제점
직렬화/역직렬화 과정에서 새로운 인스턴스를 만들어서 반환한다.
왜냐하면, 역직렬화는 기본 생성자를 호출하지 않고 readResolve() 메서드로 값을 복사해서 새로운 인스턴스를 반환하기 떄문이다.
이를 방지하기 위해서는 readResolve에서 싱글턴 인스턴스를 반환하는 코드를 추가하고, 모든 필드에 transient(직렬화 제외) 키워드를 추가한다.
// 싱글턴임을 보장해주는 readResolve 메서드
private Object readResolve() {
return INSTANCE;
}
진짜 Elvis를 반환하고, 가짜 Elvis는 가비지 컬렉터에 맡긴다.
📌 원소가 하나인 Enum 방식
조금 부자연스러워 보이나 대부분의 상황에서 원소가 하나뿐인 열거 타입이 싱글턴을 만드는 가장 좋은 방법이다.
enum Elvis {
INSTANCE;
public void leaveTheBuilding() {}
}
public class Foo {
public static void main(String[] args) {
Elvis.INSTANCE.leaveTheBuilding();
}
}
Elvis 타입의 인스턴스는 INSTANCE 하나 뿐이며, 더이상 생성이 불가능하다.
pros1. 앞의 두 방식보다 훨씬 간결하다.
pros2. 아주 복잡한 직렬화 상황에도 추가 코드 없이 직렬화가 가능하다.
pros3. 리플렉션 공격에서도 제 2의 인스턴스가 생성되는 것을 완벽히 막아준다.
하지만 만들려는 싱글턴 타입이 Enum 외의 클래스를 상속하는 경우엔 이 방법은 사용할 수 없다.