싱글턴(singleton)이란 오직 하나만 생성할 수 있는 클래스를 말함
싱글턴의 전형적인 예는 함수와 같은 무상태(stateless) 객체나 설계상 유일해야 하는 시스템 컴포넌트를 들 수 있음
무상태 객체와 설계상 유일해야 하는 시스템 컴포넌트?
간단하게 클래스 내부에 인스턴스 변수가 없는 객체를 의미함
그래서 이 객체는 특정 클라이언트에 의존적이거나 값을 변경 할 수 있는 필드가 있거나 하면 안됨, 가급적 읽기만 해야하는 상태를 말함
예를 들면 아래와 같이 보일 수 있음, 그래서 함수와 같은 무상태 객체라고 한 것
class Stateless {
void test() {
System.out.println("Test!");
}
}
class Stateless {
//No static modifier because we're talking about the object itself
final String TEST = "Test!";
void test() {
System.out.println(TEST);
}
}
이렇게 쓴다면 장점은 스레드의 안전함 예를 들어 주문 금액을 처리하는 로직 같은 경우가 예를 들 수 있음 A에서 10000원으로 처리했는데 B에서 20000원을 했다면 이 금액 변수가 계속 변함, 그렇기 때문에 이 상황에서 싱글턴에 무상태 객체를 사용하여서 이런 이슈를 처리할 수 있음
책 예시에서 private 생성자로 public static final 필드인 Elvis.INSTANCE를 초기화 할 때 딱 한 번 호출하는데 이 의미 자체가 인스턴스가 전체 시스템에서 하나뿐임이 유일하게 보장되는 유일한 시스템 컴포넌트라고도 볼 수 있음
여기서 클래스를 싱글턴으로 만들면 이를 사용하는 클라이언트를 테스트하기가 어려워질 수 있음
타입을 인터페이스로 정의한 다음 그 인터페이스를 구현해서 만든 싱글턴이 아니라면 싱글턴 인스턴스를 가짜(mock)구현으로 대체할 수 없기 때문임
싱글턴을 만드는 방식은 보통 둘 중 하나임, 두 방식 모두 생성자는 private으로 감춰두고, 유일한 인스턴스에 접근할 수 있는 수단으로 public static 멤버를 하나 마련해둠
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 클래스가 초기화될 때 만들어진 인스턴스가 전체 시스템에서 하나뿐임이 보장됨
예외는 권한이 있는 클라이언트는 리플렉션 API를 사용해 private 생성자를 호출할 수 있음, 이러한 공격을 방어하려면 생성자를 수정하여 두번째 객체가 생성되려 할 때 예외를 던져야 함
이 같은 방식은 해당 클래스가 싱글턴임이 API에 명백히 드러나고 절대로 다른 객체를 참조할 수 없음
리플렉션 API?
리플렉션 API는 구체적인 클래스 타입을 알지 못해도 그 클래스의 정보(메서드, 타입, 변수 등등)에 접근할 수 있게 해주는 자바 API임
예를 들면 아래와 같음
public class Car {
private final String name;
private int position;
public Car(String name, int position) {
this.name = name;
this.position = position;
}
public void move() {
this.position++;
}
public int getPosition() {
return position;
}
}
여기서 리플렉션 API가 아니라면 저 Car 타입을 설정하지 않고 Car에 있는 메서드를 사용할 경우 에러가 남, 타입만 알 뿐, Car 클래스라는 구체적인 타입을 모르기 때문에
아래와 같이 리플렉션 API를 활용하여 Car 클래스의 move 메서드를 호출할 수 있음
public static void main(String[] args) throws Exception {
Object obj = new Car("foo", 0);
Class carClass = Car.class;
Method move = carClass.getMethod("move");
// move 메서드 실행, invoke(메서드를 실행시킬 객체, 해당 메서드에 넘길 인자)
move.invoke(obj, null);
Method getPosition = carClass.getMethod("getPosition");
int position = (int)getPosition.invoke(obj, null);
System.out.println(position);
// 출력 결과: 1
}
리플렉션 API를 통해서 위와 같이 move 메서드에 접근을 하고 처리할 수 있음
public class Elvis {
private static final Elvis INSTANCE = new Elvis();
private Elvis() { ... }
public static Elvis getInstance() { return INSTANCE; }
public void leaveTheBuilding() { ... }
}
위와 같이 처리한다면 Elvis.getInstance()
는 항상 같은 객체의 참조를 반환하므로 제 2의 Elvis 인스턴스란 결코 만들어지지 않음
API를 바꾸지 않아도 싱글턴이 아니게 변경할 수 있음, 팩터리 메서드가 호출하는 스레드별로 다른 인스턴스를 넘겨주게 할 수 있음
원한다면 정적 팩터리를 제너릭 싱글턴 팩터리로 만들 수 있다는 점임
그리고 정적 팩터리의 메서드 참조를 공급자로 사용할 수 있음
API를 바꾸지 않아도 싱글턴이 아니게 변경?, 제너릭 싱글턴 팩터리? 공급자 사용?
이 방식은 정적 팩터리 방식이 기저에 깔려있음
정적 팩터리 방식의 싱글턴 인스턴스를 얻는 방법은 아래와 같음
public class YongCoding{
// 인스턴스를 얻기 위해서는 정적 팩토리 메소드를 이용해야한다.
private static final YongCoding INSTANCE = new YongCoding();
// private 생성자
private YongCoding(){
// 생략!
}
// 정적 팩토리 메소드 방식으로 싱글턴 객체 얻기
public static YongCoding getInstance(){
return INSTANCE;
}
}
여기서 싱글턴이 아니게 변경을 하는 것이 새로운 객체를 생성하여 반환한다고 하면 아래와 같이 변경할 수 있음
public class YongCoding{
// 인스턴스를 얻기 위해서는 정적 팩토리 메소드를 이용해야한다.
private static final YongCoding INSTANCE = new YongCoding();
// private 생성자
private YongCoding(){
// 생략!
}
// 정적 팩토리 메소드 방식으로 싱글턴 객체 얻기
public static YongCoding getInstance(){
return new YongCoding(); // 변경 부분
}
}
여기서 그럼 API를 바꾸지 않고 싱글턴이 아니게 변경했다는 것은? 바로 이 클래스를 사용하는 다른 클래스들을 본다면 알 수 있음
// 시그니쳐의 변경이 없다.
// 클라이언트는 이 코드가 싱글턴인지 새로운 객체를 생성해서 반환하는지 상관없이 그대로 사용해도 아무 문제가 없다.
YongCoding.getInstance();
getInstance()
사용에 있어서 아무런 문제가 없음, 이것이 바로 위에서 말한대로 API를 바꾸지 않아도 이렇게 변경할 수 있는 방식을 의미함
제너릭으로 타입 설정 가능한 인스턴스를 만들어두고, 반환 시에 제너릭으로 받은 타입을 이용해 타입을 결정하는 것을 말함
요청한 타입 매개변수에 맞게 매번 그 객체의 타입을 바꿔주는 정적 팩터리를 만드는 것
아래처럼 제너릭으로 만들어두면 여러 타입으로 내부 객체를 받아도 에러가 나지 않음, 큰 유연성을 제공해줌
public class GenericFactoryMethod {
public static final Set EMPTY_SET = new HashSet();
public static final <T> Set<T> emptySet() {
return (Set<T>) EMPTY_SET;
}
}
@Test public void genericTest() {
Set<String> set = GenericFactoryMethod.emptySet();
Set<Integer> set2 = GenericFactoryMethod.emptySet();
Set<Elvis> set3 = GenericFactoryMethod.emptySet();
set.add("ab");
set2.add(123);
set3.add(Elvis.INSTANCE);
String s = set.toString();
System.out.println("s = " + s);
}
본문에서 정적 팩터리 메서드 참조를 공급자로 사용할 수 있게 했는데 이 공급자에 대해서 더 알아본다면
공급자(Supplier)는 자바에서 함수형 인터페이스로 추상 메서드는 모두 매개변수를 받는데 이 Supplier의 경우 매개변수를 받지 않고 단순히 무엇인가를 반환하는 추상메서드가 존재함
Supplier가 제너릭 타입이므로 어떠한 것이든 받아서 리턴할 수 있음
이를 예를 본다면 아래와 같음
public class Elvis {
private static final Elvis INSTANCE = new Elvis();
private Elvis() { }
public static Elvis getInstance() { return INSTANCE; }
}
즉 여기서 getInstance()
를 통해 항상 같은 인스턴스를 리턴하는데 싱글턴으로 이 메서드 참조를 함수형으로써 쓴다면 Elvis::getInstance
로 쓸 수 있는데 이 상황에서 공급자로 Supplier<Elvis>
로 쓸 수 있음을 의미함
// 싱글턴임을 보장해주는 readResolve 메서드
private Object readResolve() {
// '진짜' Elvis를 반환하고, 가짜 Elvis는 가비지 컬렉터에 맡김
return INSTANCE;
}
싱글턴 클래스 직렬화?
말 그대로 직렬화 인터페이스를 구현한다는 것을 의미함
하지만 여기서 역직렬화시 같은 인스턴스각 또 생기기 때문에 문제가 생긴다고 하였는데 이를 막기 위해서 transient
선언을 해서 readResolve
메서드를 제공하는 것
transient
를 통해서 Serialize하는 과정에서 제외를 하고 readResolve
를 통해서 기존 인스턴스를 반환하는 것임
class Class implements Serializable {
private static final transient Class INSTANCE = new Class();
private Class() { ... }
private Object readResolve() { return INSTANCE; }
}
public enum Elvis {
INSTANCE;
public void leaveTheBuilding() { ... }
}
간결하고 직렬화도 쉽고 아주 복잡한 직렬화 상황, 리플렉션 공격에서도 제2의 인스턴스가 생기는 일을 막아줌
대부분 상황에서는 원소가 하나뿐인 열거 타입이 싱글턴을 만드는 가장 좋은 방법임
단 만들려는 싱글턴이 Enum외의 클래스를 상속해야 한다면 이 방법은 사용할 수 없음(열거 타입이 다른 인터페이스를 구현하도록 선언할 수는 있음)
리플렉션 공격?
리플렉션에 의하면 non-enum 싱글턴 두번째 인스턴스를 만들 수 있고 역직렬화할 수 있다고 함
이를 통해서 클라이언트는 싱글턴의 의미가 흐릿해질 수 있음, 이를 활용한 공격은 공격자가 개발자가 의도치 않은 flow를 만들어서 이를 통해서 액세스나 정보를 탈취하는 공격을 말함
여기서 이 방식을 private 생성자 & 열거 타입을 통해서 위에서 말한 문제에 대해서 방지하기 때문에 이런 공격을 막을 수 있다는 것임