[아이템 3] private 생성자나 열거 타입으로 싱글턴임을 보증하라

gang_shik·2022년 2월 18일
0

Effective Java 2장

목록 보기
3/9

싱글턴

  • 싱글턴(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구체적인 클래스 타입을 알지 못해도클래스의 정보(메서드, 타입, 변수 등등)에 접근할 수 있게 해주는 자바 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 static 멤버로 제공
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>로 쓸 수 있음을 의미함


  • 여기서 둘 중 하나의 방식으로 싱글턴 클래스직렬화하려면 단순히 Serializable구현한다고 선언하는 것만으로는 부족함, 모든 인스턴스 필드일시적이라고 선언하고 readResolve 메서드제공해야함, 이렇게 하지 않으면 역직렬화새로운 인스턴스가 만들어짐
// 싱글턴임을 보장해주는 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 생성자 & 열거 타입을 통해서 위에서 말한 문제에 대해서 방지하기 때문에 이런 공격을 막을 수 있다는 것임

profile
측정할 수 없으면 관리할 수 없고, 관리할 수 없으면 개선시킬 수도 없다

0개의 댓글