private 생성자나 열거 타입으로 싱글톤임을 보장하라
싱글톤은 인스턴스를 오직 하나만 생성할 수 있도록 보장해주는 디자인 패턴을 의미한다. 대표적인 예로는 stateless한 객체나 함수 상에 유일하게 설계하는 경우이다. 즉, 반대로 싱글톤 객체는 stateful하게 설계하면 안된다.
싱글톤 관련 포스팅 : https://sasca37.tistory.com/20
클래스를 싱글톤으로 설계하면 이를 사용하는 클라이언트를 테스트하기가 어려워 질 수 있다. 타입을 인터페이스로 정의한 다음 그 인터페이스를 구현해서 만든 싱글톤이 아니라면, 싱글톤 인스턴스를 mock
객체로 대체할 수 없기 때문이다.
싱글톤 방식은 public static final
방식과 정적 팩터리 방식, Enum 방식이 존재한다. 앞의 두 방식 모두 생성자는 private
으로 감춰두고, 유일한 인스턴스에 접근할 수 있는 수단으로 public static
멤버를 생성한다.
public static final 방식
package effective.item3;
import java.lang.reflect.Constructor;
import java.lang.reflect.InvocationTargetException;
public class SingletonService {
public static final SingletonService INSTANCE = new SingletonService();
private SingletonService(){}
public void logic () {
System.out.println("싱글톤 객체 호출");
}
}
- 장점
private
생성자를 통해 외부 호출을 막고,public static final
필드를 통해 딱 한번만 초기화하여 인스턴스를 공유한다. 즉, 클래스 초기화 시점에 생성된 인스턴스가 전체 시스템에서 하나뿐임을 보장한다.- 해당 클래스가 싱글톤임이 API에 명백하게 드러난다.
정적 팩터리 방식
public class SingletonService {
private static final SingletonService instance = new SingletonService();
public static SingletonService getInstance() {
return instance;
}
private SingletonService(){}
public void logic () {
System.out.println("싱글톤 객체 호출");
}
}
- 장점
- API를 바꾸지 않고도 싱글톤이 아니게 변경할 수 있다. 즉, 스레드 별로 다른 인스턴스를 넘겨주게 할 수 있다.
- 정적 팩터리를 제네릭으로 만들 수 있다.
- 정적 팩터리의 메서드 참조를
supplier
로 사용할 수 있다.
- 해당 장점을 사용하지 않는다면
public static final
방식이 바람직하다.
class Test {
public static void main(String[] args) throws NoSuchMethodException, InvocationTargetException, InstantiationException, IllegalAccessException {
Supplier<SingletonService> sss = SingletonService::getInstance;
SingletonService ss = sss.get();
ss.logic();
}
}
- 다음과 같이
supplier
를 통한 인스턴스 호출 방식의 예이다.Consumer
,Function
,Operator
,Predicate
등 다양한 공급자가 존재한다.
리플렉션 문제
private
한 생성자로 싱글톤을 보장해줄 수 있다. 하지만 예외적으로 리플렉션 API
에서 private한 생성자에 접근할 수 있다.
리플렉션이란 구체적인 클래스 타입을 알지 못해도 컴파일된 바이트 코드를 통해 해당 클래스의 메서드, 타입, 변수까지 접근 가능하게 해주는 자바 API이다. 바이트 코드로 접근하기 때문에 static, private으로 선언된 부분도 접근할 수 있게 된다.
class Test {
public static void main(String[] args) throws NoSuchMethodException, InvocationTargetException, InstantiationException, IllegalAccessException {
SingletonService ss = SingletonService.INSTANCE;
Constructor<SingletonService> cs = (Constructor<SingletonService>) ss.getClass().getDeclaredConstructor();
cs.setAccessible(true);
SingletonService ss2 = cs.newInstance();
System.out.println(ss); // effective.item3.SingletonService@7dc36524
System.out.println(ss2); // effective.item3.SingletonService@35bbe5e8
}
}
- 예외적으로 권한이 있는 클라이언트는
리플렉션 API
인AccessibleObject.setAccessible
을 사용하여 private 생성자를 호출할 수 있다. 해당 예시를 보면 다른 인스턴스가 생성된 것을 볼 수 있다.
private SingletonService(){
if (INSTANCE != null) {
throw new AssertionError();
}
}
- 이러한 공격을 방어하려면 private 생성자 내부에서 두 번째 객체가 생성되려 할 때 예외를 던지게 하면 된다.
- 관련 API 문서 : https://docs.oracle.com/javase/8/docs/api/java/lang/reflect/AccessibleObject.html
둘 중 하나의 방식으로 만든 싱글톤 클래스를 직렬화
한다고 하면, Serializable
선언하는 것만으로는 부족하다.
직렬화
란 자바 시스템 내부의 객체 또는 데이터를 외부 자바 시스템에서도 사용할 수 있도록 바이트 코드로 변환하는 기술을 의미하며 그 반대를 역직렬화라고 한다.
모든 인스턴스 필드를 일시적(transient)이라 하고, readResolve 메서드를 제공해야 한다. 그렇지 않으면 역직렬화에서 새로운 인스턴스가 만들어진다.
private Object readResolve() {
// 실제 SingletonService를 반환하고, 가짜 SingletonService는 gc에 맡긴다.
return instance;
}
- 싱글톤임을 보장해주는
readResolve
메서드
Enum 방식
Enum은 상수들만 모아놓은 클래스로 생성자, 메서드를 가질 수 있다. 기본적으로 private
한 생성자를 가진다.
고정된 상수들의 집합으로 컴파일 시점에 모든 값을 알고 있어야하기 때문에 다른 패키지나 클래스에서 동적으로 값을 정해줄 수 없으며 상속이 불가능하다. (리플렉션도 방지)
public enum SingletonService {
INSTANCE;
public void logic () {
System.out.println("싱글톤 객체 호출");
}
}
public
필드 방식과 비슷하지만, 더 간결하고 추가 작업 없이 직렬화할 수 있다. 대부분의 상황에선 원소가 하나뿐인 열거 타입의 싱글톤을 만드는 것이 가장 좋은 방법이다.- 만들려는 싱글톤이 Enum 외의 클래스를 상속해야 한다면 해당 방법은 사용할 수 없다. 열거 타입이 다른 인터페이스를 구현하도록 선언할 수는 있다.
REFERENCES