들어가면서
이번 포스트에서는 싱글턴 패턴을 사용하였을 때 볼 수 있는 문제들과, 이걸 해결 할 수 있는 방법에 대해 알아보자. 싱글턴을 구현하는 3가지 방법을 보고 각각의 장단점, 단점을 해결할 수 있는 방법에 대해 알아볼 것이다.
private 생성자 + public static final 필드 사용
싱글턴을 사용한 예시 코드를 보자.
public class Concert {
private boolean lightsOn;
private boolean mainStateOpen;
private Elvis elvis;
public Concert(IElvis elvis) {
this.elvis = elvis;
}
public void perform() {
mainStateOpen = true;
lightsOn = true;
elvis.sing();
}
public boolean isLightsOn() {
return lightsOn;
}
public boolean isMainStateOpen() {
return mainStateOpen;
}
}
public class Elvis {
/**
* 싱글톤 오브젝트
*/
public static final Elvis INSTANCE = new Elvis();
private Elvis() {}
public void leaveTheBuilding() {
System.out.println("Whoa baby, I'm outta here!");
}
public void sing() {
System.out.println("I'll have a blue~ Christmas without you~");
}
// 이 메서드는 보통 클래스 바깥(다른 클래스)에 작성해야 한다!
public static void main(String[] args) {
Elvis elvis = Elvis.INSTANCE;
elvis.leaveTheBuilding();
}
}
다음 코드를 보면 Elvis
생성자를 private
으로 제한하고, 객체 생성을 제한하고 있다. 또한 static final
로 INSTANCE
å를 생성하여 객체를 하나만 사용하게 제한하고 있다.
위 코드와 같이 작성하면 장단점이 있다.
장점: 간결하고 싱글턴임을 API에 들어낼 수 있다.
위 사진과 같이 싱글톤 오브젝트라는 것을 API 문서에 잘 보여준다.
단점: 싱글톤을 사용하는 클라이언트 테스트하기 어려워진다.
다음과 같은 테스트코드를 예시로 보자.
class ConcertTest {
@Test
void perform() {
Concert concert = new Concert(Elvis.INSTANCE);
concert.perform();
assertTrue(concert.isLightsOn());
assertTrue(concert.isMainStateOpen());
}
}
다음 코드를 보면, Concert
를 테스트하는 클래스는 Elvis
객체를 직접 들고 와서 테스트를 진행한다. 그런데 만약 Elvis
의 객체 자체가 테스트하기에 오래 걸리는 인스턴스라면 Concert
를 테스트하는데 너무 비효율적이다. 다시 말해, Mock
객체를 사용할 수가 없다.
대안은 인터페이스를 활용하는 방법이 있다.
public interface IElvis {
void leaveTheBuilding();
void sing();
}
다음과 같이 인터페이스를 선언해준다.
public class Concert {
private boolean lightsOn;
private boolean mainStateOpen;
private IElvis elvis;
...
}
public class Elvis implements IElvis{
/**
* 싱글톤 오브젝트
*/
public static final Elvis INSTANCE = new Elvis();
private Elvis() {}
@Override
public void leaveTheBuilding() {
System.out.println("Whoa baby, I'm outta here!");
}
@Override
public void sing() {
System.out.println("I'll have a blue~ Christmas without you~");
}
...
}
다음과 같이 Elvis
를 인터페이스를 구현하는 구현체로 지정을 하게 되면 Mock 객체를 따로 만들어 테스트가 가능하다.
class ConcertTest {
@Test
void perform() {
Concert concert = new Concert(new MockElvis());
concert.perform();
assertTrue(concert.isLightsOn());
assertTrue(concert.isMainStateOpen());
}
}
단점: 리플렉션으로 private 생성자를 호출할 수 있다.
reflection을 사용하게 되면 싱글턴이 깨질 수 있다. 다음 코드를 보자.
public class ElvisReflection {
public static void main(String[] args) {
try {
Constructor<Elvis> defaultConstructor = Elvis.class.getDeclaredConstructor();
defaultConstructor.setAccessible(true);// 설정 안해주면 private 생성자의 값을 가지고 올 수 없다.
Elvis elvis1 = defaultConstructor.newInstance();
Elvis elvis2 = defaultConstructor.newInstance();
System.out.println(elvis1);
System.out.println(elvis2);
System.out.println(elvis1 == elvis2);
} catch (InvocationTargetException | NoSuchMethodException | InstantiationException | IllegalAccessException e) {
e.printStackTrace();
}
}
}
NOTE
getDeclaredConstructor()
&getConstructor()
getDeclaredConstructor()
는 접근지시자(public, private)과 상관없이 모든 생성자를 가지고 온다. 하지만getConstructor()
는 public 접근지시자에 한해서만 생성자를 가지고 올 수 있다.
위 코드를 실행하게 되면
다음과 같이 다른 인스턴스 값이 나온 것을 알 수 있다. 다시 말해, 싱글톤이 깨진다.
그러면 어떻게 하는게 좋을까? 이펙티브 자바에서는 필드와 생성자를 생성할 때 예외처리를 하는 것을 권장하고 있다.
public class Elvis implements IElvis {
/**
* 싱글톤 오브젝트
*/
public static final Elvis INSTANCE = new Elvis();
private static boolean created; // 생성되었는지 확인하는 필드
private Elvis() { // 이미 생성되었다면 예외처리
if (created) {
throw new UnsupportedOperationException("can't be created by constructor.");
}
created = true;
}
public void leaveTheBuilding() {
System.out.println("Whoa baby, I'm outta here!");
}
public void sing() {
System.out.println("I'll have a blue~ Christmas without you~");
}
// 이 메서드는 보통 클래스 바깥(다른 클래스)에 작성해야 한다!
public static void main(String[] args) {
Elvis elvis = Elvis.INSTANCE;
elvis.leaveTheBuilding();
}
private Object readResolve() {
return INSTANCE;
}
}
이 상태에서 코드를 실행하면, 위 결과처럼 예외처리가 나오게 된다.
단점: 역직렬화 할 때 새로운 인스턴스가 생길 수 있다.
public class ElvisSerialization {
public static void main(String[] args) {
try (ObjectOutput out = new ObjectOutputStream(new FileOutputStream("elvis.obj"))) {
out.writeObject(Elvis.INSTANCE);
} catch (IOException e) {
e.printStackTrace();
}
try (ObjectInput in = new ObjectInputStream(new FileInputStream("elvis.obj"))) {
Elvis elvis3 = (Elvis) in.readObject();
System.out.println(elvis3 == Elvis.INSTANCE);
} catch (IOException | ClassNotFoundException e) {
e.printStackTrace();
}
}
}
위 코드는 직렬화 후 역질렬화하여 기존의 인스턴스와 같은 객체인지 비교하는 코드이다. 코드를 실행해보면 false
가 나온다.
이걸 해결하기 위해서는 Overriding과 유사한 작업을 해주어야 한다.
public class Elvis implements IElvis, Serializable {
/**
* 싱글톤 오브젝트
*/
public static final Elvis INSTANCE = new Elvis();
private static boolean created;
...
private Object readResolve() {
return INSTANCE;
}
}
다음과 같은 함수를 선언하게 되면 overriding과 유사하게 작동을 한다. 이번에 실행을 해보면 true
가 나온다.
하지만 이제 장점이었던 간결함은 사라진다…
NOTE실제 서버 개발할 때에는 스프링에서 빈으로 등록하여 사용하자. 굳이 싱글턴을 만들 필요가 없다.
private 생성자 + 정적 팩터리 메서드 사용
이 방법은 기존에 INSTANCE
가 public
에서 private
으로 바꾸고, getInstance()
를 사용하여 객체를 가지고 온다. 코드로 보자.(단점들은 전에 봤던 단점들과 같다.)
public class Elvis implements Singer {
private static final Elvis INSTANCE = new Elvis();
private Elvis() { }
public static Elvis getInstance() { return INSTANCE; }
public void leaveTheBuilding() {
System.out.println("Whoa baby, I'm outta here!");
}
// 이 메서드는 보통 클래스 바깥(다른 클래스)에 작성해야 한다!
public static void main(String[] args) {
Elvis elvis = Elvis.getInstance();
elvis.leaveTheBuilding();
System.out.println(Elvis.getInstance());
System.out.println(Elvis.getInstance());
}
@Override
public void sing() {
System.out.println("my way~~~");
}
}
장점 1. API를 바꾸지 않고도 싱글턴이 아니게 변경할 수 있다.
public class Elvis implements Singer {
private static final Elvis INSTANCE = new Elvis();
private Elvis() { }
public static Elvis getInstance() { return new Elvis(); }
...
public static void main(String[] args) {
Elvis elvis = Elvis.getInstance();
elvis.leaveTheBuilding();
System.out.println(Elvis.getInstance());
System.out.println(Elvis.getInstance());
}
}
위 코드처럼 INSTANCE가 아니라, 새로운 Elvis 객체를 반환하게 하면 싱글톤을 풀 수 있다. 클라이언트쪽 코드도 따로 변경할 필요가 없다.
장점 2. 정적 팩터리를 제네릭 싱글턴 팩토리로 만들 수 있다.
제네릭 싱글톤 팩토리
public class MetaElvis<T> {
private static final MetaElvis<Object> INSTANCE = new MetaElvis<>();
private MetaElvis() { }
@SuppressWarnings("unchecked")
public static <T> MetaElvis<T> getInstance() { return (MetaElvis<E>) INSTANCE; }
public void say(T t) {
System.out.println(t);
}
public void leaveTheBuilding() {
System.out.println("Whoa baby, I'm outta here!");
}
public static void main(String[] args) {
MetaElvis<String> elvis1 = MetaElvis.getInstance();
MetaElvis<Integer> elvis2 = MetaElvis.getInstance();
System.out.println(elvis1);
System.out.println(elvis2);
elvis1.say("hello");
elvis2.say(100);
}
}
위처럼 제네릭 싱글턴 타입을 사용하면 인스턴스는 동일하지만, 원하는 타입으로 형변환을 해줄 수 있다. 이걸 사용하면, 인스턴스 자체는 같은 인스턴스이다.(위 예시로 작성한 다른 두 타입의 객체는 타입이 다르기 때문에 ==
비교가 불가능하다. equals()
비교를 해주어야 한다.)
위 제네릭 싱글턴 팩토리는 단순하게 우리가 원하는 타입으로 변환을 해준다.
NOTE
<T> MetaElvis<T> getInstance()
에서 앞<T>
는 왜 들어가 있지?메서드 선언
public static <T> MetaElvis<T> getInstance()
에서 맨 앞에 있는<T>
는 제네릭 메서드를 정의하기 위한 타입 매개변수 선언이다.메서드 레벨에서 제네릭을 선언하면 클래스에서 선언된 제네릭 타입과는 별개로, 특정 메서드에서 사용할 제네릭 타입을 지정할 수 있다. 이를 위해 반환 타입 앞에
<T>
를 명시하여 해당 메서드가 제네릭 메서드임을 컴파일러에 알린다. 이를 통해 호출자가 지정한 타입T
를 반환 타입MetaElvis<T>
에 적용할 수 있어 타입 안전성이 보장된다. 또한, 컴파일러는 메서드 호출 시 문맥을 통해 타입T
를 추론할 수 있다.(여기서 말하는MetaElvis<T>
는 클래스를 말하는 것이 아니다.)예를 들어
MetaElvis<String> elvis1 = MetaElvis.getInstance();
와 같이 호출하면, 컴파일러는T
가String
이어야 함을 자동으로 추론한다. 만약 메서드 레벨의 제네릭 선언이 없다면, 메서드는 클래스 레벨의 제네릭 타입만 사용할 수 있어 제네릭 싱글톤 패턴을 구현할 수 없다. 또한, 다양한 타입으로 형변환하여 사용하는 싱글톤 팩토리의 유연성이 사라진다. 따라서 메서드 레벨의 제네릭 선언을 활용하면, 하나의 싱글톤 인스턴스를MetaElvis<String>
,MetaElvis<Integer>
등 다양한 타입으로 안전하게 사용할 수 있다.
장점 3. 정적 팩터리의 메서드 참조를 공급자(Supplier)로 사용할 수 있다.
public class Concert {
public void start(Supplier<Singer> singerSupplier) {
Singer singer = singerSupplier.get();
singer.sing();
}
public static void main(String[] args) {
Concert concert = new Concert();
concert.start(Elvis::getInstance);
}
}
다음과 같이 메서드 참조를 Supplier로 사용할 수 있다.
열거 타입 사용
어떻게 구현하는지 코드로 먼저 보자.
public enum Elvis {
INSTANCE;
public void leaveTheBuilding() {
System.out.println("기다려 자기야, 지금 나갈께!");
}
// 이 메서드는 보통 클래스 바깥(다른 클래스)에 작성해야 한다!
public static void main(String[] args) {
Elvis elvis = Elvis.INSTANCE;
elvis.leaveTheBuilding();
}
}
위처럼 작성하게 되면 가장 간결한 방법이며 직렬화와 리플렉션에도 안전하다. 그래서 대부분의 상황에서는 원소가 하나뿐인 열거 타입이 싱글턴을 만드는 가장 좋은 방법이다.
Reflection 문제도 없다.
public class EnumElvisReflection {
public static void main(String[] args) {
try {
Constructor<Elvis> declaredConstructor = Elvis.class.getDeclaredConstructor();
System.out.println(declaredConstructor);
} catch (NoSuchMethodException e) {
e.printStackTrace();
}
}
}
- 코드 실행 결과(생성자를 가지고 오지 못하는 것을 볼 수 있다.)
enum은 reflection 자체가 생성자를 가지고 올 수 없다. 그래서 enum을 사용하지 않고 구현했던 방식에서
readResolve()
함수를 구현할 필요가 없다.
직렬화, 역직렬화 문제도 없다.
public class EnumElvisSerialization {
public static void main(String[] args) {
try (ObjectOutput out = new ObjectOutputStream(new FileOutputStream("elvis.obj"))) {
out.writeObject(Elvis.INSTANCE);
} catch (IOException e) {
e.printStackTrace();
}
try (ObjectInput in = new ObjectInputStream(new FileInputStream("elvis.obj"))) {
Elvis elvis = (Elvis) in.readObject();
System.out.println(elvis);
System.out.println(Elvis.INSTANCE);
System.out.println(elvis == Elvis.INSTANCE);
} catch (IOException | ClassNotFoundException e) {
e.printStackTrace();
}
}
}
- 코드 실행 결과(객체의 hash 값이 같은 것을 볼 수 있다.)
위 코드와 결과와 같이 역직렬화를 하더라도 동일한 인스턴스를 얻을 수 있다.
출처
- 이펙티브 자바 아이템 3: 생성자나 열거 타입으로 싱글턴임을 보장하라.
- 백기선의 이펙티브 자바(인프런)