Backend/Java

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

김세진 2024. 1. 24. 22:27
반응형

 

 

 

 

 

개요

 

싱글턴(singleton)이란 디자인 패턴 중 하나로, 인스턴스를 오직 하나만 가질 수 있도록 보장하는 클래스를 말한다. 싱글턴의 예로는 무상태(stateless) 객체나 설계상 유일해야 하는 시스템 컴포넌트를 들 수 있다. @Service, @Controller 등으로 생성되는 스프링 빈 또한 싱글턴의 일종이다. 싱글턴을 생성하는 방법은 여러 가지가 있는데, 아래 3가지 방식에 대해 소개한다.

 

  1. public static final 필드 방식(Eager Initialization)
  2. 정적 팩터리 방식
  3. 열거 타입 방식(Enum Initialization)

1번과 2번 방식이 싱글턴을 만들 때 일반적으로 사용되지만, 이펙티브 자바의 저작자는 열거 타입 방식이 대부분의 상황에서 가장 좋은 방법이라 말한다. 1번, 2번 방식의 경우 리플렉션(아이템 65)과 직렬화 관련 이슈 때문인데, 순차적으로 살펴보도록 하자.

 

 


public static final 필드 방식

 

public class PublicStaticFinal {
	public static final PublicStaticFinal INSTANCE = new PublicStaticFinal();
	private PublicStaticFinal() {
	}
}

 

public static final로 클래스를 초기화할 때 private 생성자가 호출되면서 인스턴스를 생성한다. public이나 protected 생성자가 없으므로 인스턴스가 전체 시스템에서 하나뿐임이 보장된다.

 

한편, 클래스가 로드될 때 자동으로 초기화되므로 Eager Initialization 이라고도 불린다.

 

장점

1. 해당 클래스가 싱글턴임이 API에 드러난다.

2. 코드가 간결하다.

3. 클래스 로더에 의해 클래스가 로딩될 때 싱글톤 객체가 생성되므로 thread-safe 하다는 장점이 존재한다.

 


정적 팩터리 방식

 

public class StaticFactory {
	private static final StaticFactory INSTANCE = new StaticFactory();
	private StaticFactory() {
	}
    
	public static StaticFactory getInstance() {
		return StaticFactory.INSTANCE;
	}
}

 

1번 방법과 동일하게 private 생성자를 사용하지만 public static final 이 아닌 private static final로 선언하여 인스턴스 필드에 직접 접근을 막고 getInstance() 정적 메서드를 통해 인스턴스를 반환받도록 한다. 위 코드는 1번과 동일하게 클래스 로딩 시 인스턴스가 생성되는 방식이며, 생성자 호출을 getInstance() 부분에 넣어 지연 초기화를 할 수도 있다.

 

public class StaticFactory { // 지연 초기화 방식
	private static StaticFactory INSTANCE;

	private StaticFactory() {
    }

	public static StaticFactory getInstance() {
		if (INSTANCE == null) {
			INSTANCE = new StaticFactory();
		}
		return StaticFactory.INSTANCE;
	}
}

 

지연 초기화 방식의 경우 multi-thread 환경에서 thread-safe 하지는 않지만 해당 싱글턴 클래스가 필요한 경우에만 적절하게 호출할 수 있으므로 메모리를 절약할 수 있다는 장점이 있다.

 

정적 팩터리 방식은 크게 세 가지 장점이 존재한다.

장점 1. API를 변경하지 않고 싱글턴이 아니게 변경할 수 있다.

public class StaticFactory {
	private static final StaticFactory INSTANCE = new StaticFactory();
	private StaticFactory() {
	}
    
	public static StaticFactory getInstance() {
		return new StaticFactory();
	}
}

 

위 코드와 같이 정적 팩터리를 수정하여 기존 인스턴스를 반환할지, 아니면 새 인스턴스를 반환할지 결정할 수 있다.

 

장점 2. 정적 팩터리를 제네릭 싱글턴 팩터리로 만들 수 있다.(아이템 30)

public class GenericStaticFactory<T> {
	private static final Set<Object> INSTANCE_SET = new HashSet<>();

	private GenericStaticFactory() {
	}

	@SuppressWarnings("unchecked")
	public static <T> Set<T> getInstance() {
		return (Set<T>) INSTANCE_SET;
	}

	public static void main(String[] args) {
		Set<String> instance1 = GenericStaticFactory.getInstance();
		Set<Boolean> instance2 = GenericStaticFactory.getInstance();
		Set<Integer> instance3 = GenericStaticFactory.getInstance();

		// 다양한 타입으로 원소를 받을 수 있음
		instance1.add("ab");
		instance2.add(false);
		instance3.add(123);

		System.out.println(instance1);	// [ab, false, 123]
		System.out.println(instance1.equals(instance2));	// true
	}
}

 

위처럼 정적 팩터리를 제네릭 싱글턴 팩터리로 만들어 다양한 타입을 하나의 인스턴스로 받을 수 있다.

 

장점 3. 정적 팩터리의 메서드 참조를 공급자(supplier)로 사용할 수 있다.(아이템43, 44)

자바 8부터 도입된 메서드 참조를 간단히 설명하면 이중 콜론(::)을 이용해 "클래스명::정적메서드명" 꼴로 람다식을 축약한 표현 방식이다.

// 문자열의 길이를 구하는 예제
List<String> strings = Arrays.asList("Hello", "World");

// 람다식
List<Integer> lengths = strings.stream()
    .map(s -> s.length())
    .collect(Collectors.toList());

// 메서드 참조
List<Integer> lengths2 = strings.stream()
    .map(String::length)
    .collect(Collectors.toList());

 

마찬가지로 자바 8부터 도입된 Supplier는 함수형 프로그래밍을 위해 추가된 함수형 인터페이스(추상 메서드가 오직 하나인 인터페이스) 중 하나이며, get() 이라는 추상 메서드가 하나 존재한다. get 메서드는 매개변수를 받지 않고 값을 반환한다.

@FunctionalInterface
public interface Supplier<T> {
    T get();
}

 

 

 

만약 위같은 장점이 필요하지 않다면 public 필드 방식(1번)이 더 좋다.

 

하지만 위 두 가지 방법에는 앞서 언급했던 것과 같이 공통된 단점이 존재한다.

 


리플렉션 API 공격 및 역직렬화 문제

 

리플렉션 API 공격


public class StaticFactory {
	private static final StaticFactory INSTANCE = new StaticFactory();

	private StaticFactory() {
		System.out.println("StaticFactory Initialized");
	}

	public static StaticFactory getInstance() {
		return StaticFactory.INSTANCE;
	}
}

class ReflectionAttack {
	public static void main(String[] args) throws Exception {
		Constructor<?> constructor = StaticFactory.class.getDeclaredConstructors()[0];
		constructor.setAccessible(true);
		StaticFactory staticFactory = (StaticFactory) constructor.newInstance();
	}
}

 

위 두 가지 방법은 권한이 있는 개발자가 리플렉션 API로 private 생성자를 호출할 여지가 있다. 

 

ReflectionAttack 클래스에는 자바의 리플렉션 API를 사용하여 setAccessible을 통해 private 생성자에 접근할 권한을 얻고 이를 통해 새 인스턴스를 얻는 코드가 작성되어 있다. 이 코드를 실행하면 아래와 같은 결과를 얻을 수 있다.

 

 

클래스 로드 시 초기화되면서 인스턴스가 하나 생성되고, 이후 리플렉션 API에 의해 인스턴스가 하나 더 생성되었음을 알 수 있다. 이러한 리플렉션 공격을 방지하기 위해 인스턴스가 존재한다면 AssertionError를 throw 하도록 조치할 수 있다.

 

public class StaticFactory {
	private static final StaticFactory INSTANCE = new StaticFactory();

	private StaticFactory() {
		// 추가 인스턴스화 방지
		if (INSTANCE != null) {
			throw new AssertionError();
		}
		System.out.println("StaticFactory Initialized");
	}

	public static StaticFactory getInstance() {
		return StaticFactory.INSTANCE;
	}
}

 

 

위처럼 리플렉션 API를 통해 private 생성자를 호출하자 예외가 발생하는 것을 알 수 있다. 뿐만 아니라, 이 조치는 클래스 내부에서 실수로 생성자를 호출하는 것도 방지할 수 있다.

 

 

역직렬화 문제


1, 2번 방법으로 싱글턴을 구성할 경우, 직렬화를 하기 위해 Serializable 클래스를 구현하는 것만으로는 역직렬화할 때마다 새 인스턴스가 만들어지게 된다. 이를 해결하기 위해 readResolve 메서드를 구현하여 기존 인스턴스를 반환하도록 하면 된다.

 

// @Override 를 사용하지 않음
private Object readResolve() {
    return INSTANCE;
}

 

readResolve 메서드를 정의하게 되면 역직렬화 시 readObject를 통해 만들어진 인스턴스 대신 readResolve 에서 반환되는 인스턴스를 사용하도록 강제할 수 있다. readResolve를 통해 기존 인스턴스를 반환하므로, 역직렬화시 구성된 인스턴스는 가비지 컬렉터에 의해 처리된다. 또한 그렇게 생성되는 임시 인스턴스에는 어떠한 필드도 필요하지 않다. 따라서 직렬화/역직렬화가 필요한 싱글턴 클래스의 모든 멤버 필드에는 transient 를 선언하여 직렬화에서 제외되도록 해야 한다.

 


열거 타입 방식

 

public enum EnumInitialization {
	INSTANCE;

	// 멤버 필드와 메서드도 여느 클래스와 같이 사용 가능하다.
	private String value;

	public String getValue() {
		return value;
	}
	public void setValue(String value) {
		this.value = value;
	}
}

 

 

Enum 클래스는 자바 차원에서 해당 인스턴스가 하나임을 보장하기 때문에 추가 인스턴스가 생길 여지를 완전히 제거하고 앞서 살펴본 방식의 단점(리플렉션 및 직렬화 문제)을 별다른 노력 없이 제거하는 방법이다.

 

단, Enum 외의 클래스를 상속해야 하는 경우에는 해당 방법을 사용할 수 없다. (열거 타입이 다른 인터페이스를 구현하도록 선언할 수는 있다)

 

 

반응형