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

Loopy·2022년 5월 13일
0

이펙티브 자바

목록 보기
3/76
post-thumbnail

싱글턴(singleton)이란, 인스턴스를 오직 하나만 생성할 수 있는 클래스를 말한다. 예시로, 함수와 같은 무상태 객체나 설계상 유일해야 하는 시스템 컴포넌트를 들 수 있다.

☁️ 싱글턴과 테스트

클래스를 싱글턴으로 만들면 이를 사용하는 클라이언트는 테스트하기가 어려워질 수 있다.

첫째, 테스트는 일관성을 위해 매번 객체의 값이 초기화 되어야 한다. 하지만, 싱글톤 객체는 오직 하나만 존재하고 공유되기 때문에 테스트가 온전하게 수행되지 못할 수 있다.

둘째, private 생성자를 막아놓으니 테스트에서 싱글턴 객체를 생성하려면 리플렉션을 활용해야 한다.

셋째, Mock과 같은 가짜 객체는 주로 상속을 통해 이루어지기 때문에, 타입을 정의된 인터페이스를 구현해 만든 싱글턴이 아니라면 가짜(mock) 구현으로도 대체할 수 없어진다.

☁️ 싱글턴 생성 방식

싱글턴을 만드는 기본적인 방식은 모두, 생성자는 private 으로 감춰두고 유일한 인스턴스에 접근 가능한 수단으로 public 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 생성자가 없으므로 해당 클래스가 초기화될 때 만들어진 인스턴스가 전체 시스템에서 하나뿐임이 보장되는 것이다.

단, 리플렉션을 통해서 private 생성자에 접근이 가능하다는 점에 주의하자.
권한이 있는 클라이언트가 AccessibleObject.setAccessible() 을 사용해private 생성자를 호출한다면, 두번째 객체가 생성되려 할 때 생성자에서 예외를 던지게 하는 방식으로 막을 수 있다.

Eager 방식의 장점

해당 클래스가 싱글턴임이 API에 명백히 드러난다. public static 필드가 final 이니, 절대 다른 객체를 참조할 수 없기 때문이다. 더불어 멀티 스레드 환경에서도 안전하며, 간결성을 장점으로 들 수 있다.

Eager 방식 단점

  1. static 멤버는 당장 객체를 사용하지 않더라도 메모리에 존재하고 있기 때문에, 리소스가 큰 객체일 경우, 공간 자원의 낭비가 발생하는 문제가 발생한다.

2. 정적 팩터리 방식

public class Elvis{
	private static final Elvis INSTANCE = new Elvis();
    private Elvis() {...}
    
    public static Elvis getInstance() {return INSTANCE;}   // Factory method
    public void leaveTheBuilding(){...}
}

getInstance() 정적 팩터리 메서드를 제공하는 방식이다. 항상 같은 객체의 참조를 반환하므로, 역시 인스턴스를 생성 할 수 없게 된다.

예외 처리

객체 생성 도중 발생하는 예외를 잡으려면, static 구문 안에서 잡자. static 블록은 클래스가 로딩되고 클래스 변수가 준비된 후 자동으로 실행되는 블럭이다.

public class Elvis{
	private static Elvis INSTANCE;  // final 제거
    private Elvis() {...}
    
    static {
        try {
            INSTANCE = new Elvis();
        } catch (Exception e) {
            throw new RuntimeException("싱글톤 객체 생성 오류");
        }
    }
    
    public static Elvis getInstance() {return INSTANCE;}  
}

정정 팩터리 메서드 방식 장점

  1. API를 변경하지 않고도 싱글턴이 아니게 변경이 가능하다. 팩터리 메서드가 호출 스레드별로, 유일한 인스턴스가 아닌 다른 인스턴스를 넘겨주게 할 수 있기 때문이다.

  2. 원한다면 정적 팩터리를 제네릭 싱글턴 팩터리(item 30)로 만들 수 있다.

  3. 정적 팩터리 메서드 참조를 공급자로 사용할 수 있다. 가령 Elvis::getInstanceSupplier<Elvis> 로 사용하는 식이다. (item 43, 44)

☁️ (번외) 직렬화와 싱글턴

만약 위 둘 중의 방식으로 만든 싱글턴 클래스를 직렬화하려면, 다음의 작업을 수행해야 한다.

  1. 모든 인스턴스 필드를 일시적( transient )라 선언한다.
  2. readResolve 메서드를 제공(item 89)한다.

이렇게 하지 않으면, 직렬화 인스턴스를 다시 역직렬화 할때마다 새로운 인스턴스가 생성되는 문제가 발생한다.

public Object readResolve() {  //싱글턴임을 보장해주는 메서드
	return INSTANCE; 	//진짜 Elvis 반환, 가짜 Elvis는 가비지 컬렉터에 맡김
}

3. 지연 로딩 방식의 싱글턴

미사용이 되고 있지만 메모리를 고정적으로 차지하고 있는 문제를 해결하기 위해, 정적 메서드가 호출될 때 그제서야 객체 초기화를 진행하고 메모리에 로드하는 방식이다.

public class Elvis{
	private static Elvis INSTANCE; 
    private Elvis() {...}
   
    // 외부에서 정적 메서드를 호출하면 그제서야 초기화 진행 (lazy)
    public static Elvis getInstance() {
    	if (INSTANCE == null) {
        	INSTANCE = new Elvis();
        }
    	return INSTANCE;
    }  
}

멀티 스레드 동기화 이슈

하지만 이런 방식은 치명적인 단점을 가지고 있다. 만약 스레드 A가 null 임을 판단하는 로직에 접근을 하고, 동시에 스레드 B가 null 임을 판단하는 If 문 로직에 접근을 할 수 있다. 이런 경우, A와 B는 모두 아직 객체가 생성되지 않은 상태로 인식하기 때문에 객체가 2개 생성되어버리는 문제가 발생하는 것이다.

4. 동기화 이슈를 고려한 싱글턴

  1. Synchronized 방식

위에서의 동기화 문제를 해결하려면, synchornized 키워드를 통해 락(Lock)을 걸어주면 된다. 즉, 한번에 하나의 스레드만 null 임을 판단하는 IF 문 로직에 접근할 수 있도록 안전 잠금 장치를 해놓는 방식이다.

public class Elvis{
	private static Elvis INSTANCE; 
    private Elvis() {...}
   
    // 외부에서 정적 메서드를 호출하면 그제서야 초기화 진행 (lazy)
    public static synchronized Elvis getInstance() {
    	if (INSTANCE == null) {
        	INSTANCE = new Elvis();
        }
    	return INSTANCE;
    }  
}

Synchorinzed 방식 단점

여러개의 모듈들이 매번 객체를 가져올 때 synchronized 메서드를 매번 호출하기 때문에, 동기화 작업에 오버헤드가 발생해 성능 하락이 발생한다.

  1. Double-Checked Locking 방식

매번 synchronized 동기화를 실행하는 것이 문제라면, 최초 초기화할때만 적용하고 이미 만들어진 인스턴스를 반환할때는 사용하지 않도록 하는 방법이다.

추가적으로 volatile 키워드를 붙임으로써, 한 스레드의 쓰기 작업(변경)이 다른 스레드에게도 보일 수 있도록 CPU 캐시가 아닌 메인 메모리를 대상으로 I/O 작업이 일어나야 한다.

CPU가 2개 이상 있고, 멀티 스레딩 환경에서는 한 CPU 내부의 캐시에 존재하는 데이터가 언제 메인 메모리로 쓰여질지 모르기 때문에 모든 CPU 가 동일한 캐시 변수 값을 가지고 있으리라 보장할 수 없기 때문이다. 즉 스레드가 변경한 값이 메인 메모리에 저장되지 않아서 다른 쓰레드가 이 값을 볼 수 없는 상황 막아야 한다.

public class Elvis{
	private static volatile Elvis INSTANCE; 
    private Elvis() {...}
   
    // 외부에서 정적 메서드를 호출하면 그제서야 초기화 진행 (lazy)
    public static Elvis getInstance() {
    	if (INSTANCE == null) {  // null일 때만 동기화 체크
        	synchronized(Elvis.class) {  // 클래스 자체에 잠금을 건다.
               if(INSTANCE == null) {  // null일 때만 객체 생성
                   INSTANCE = new Elvis();
               }
            }
        }
    	return INSTANCE;
    }  
}

5. 열거 타입 방식의 싱글턴

enum 자체가 하나만 만들어져서 public static final 로 공개되기 때문에, 멀티 스레드 환경에서도 인스턴스가 두개 이상 생성되는 일은 존재하지 않는다.

public enum Elvis {
	INSTANCE;
    
    public static Elvis getInstance() {
        return INSTANCE;
    }
    
    public void leaveTheBuilding() {....}
}

열거 타입 장점

  1. public 필드 방식과 비슷하지만, 더 간결하고 추가 노력 없이 직렬화할 수 있다.
  2. 복잡한 직렬화 상황이나, 리플렉션 공격에서도 또 다른 인스턴스가 생기는 일을 완벽하게 방지할 수 있어 안전하다.

참고로 직렬화에 대해서는 12장에서 자세하게 다뤄지니 일단 넘어가자.

열거 타입 단점

  1. 만일 싱글톤 클래스를 일반 클래스로 마이그레이션 해야할때, 처음부터 코드를 다시 짜야 되는 단점이 존재한다.
  2. 클래스 상속이 필요할때, enum 외의 클래스 상속은 불가능하다.

가장 좋은 방법은 원소가 한뿐인 열거 타입으로 싱글턴을 만드는 것이다.

참고 자료
https://inpa.tistory.com/entry/GOF-💠-싱글톤Singleton-패턴-꼼꼼하게-알아보자#
https://parkcheolu.tistory.com/16

profile
개인용으로 공부하는 공간입니다. 잘못된 부분은 피드백 부탁드립니다!

0개의 댓글