2789 단어
14 분
[ Effective Java ] 싱글턴 패턴을 잘 사용하기

들어가면서#

이번 포스트에서는 싱글턴 패턴을 사용하였을 때 볼 수 있는 문제들과, 이걸 해결 할 수 있는 방법에 대해 알아보자. 싱글턴을 구현하는 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 finalINSTANCEå를 생성하여 객체를 하나만 사용하게 제한하고 있다.

위 코드와 같이 작성하면 장단점이 있다.

장점: 간결하고 싱글턴임을 API에 들어낼 수 있다.#

javadoc 위 사진과 같이 싱글톤 오브젝트라는 것을 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 접근지시자에 한해서만 생성자를 가지고 올 수 있다.

위 코드를 실행하게 되면 reflection1

다음과 같이 다른 인스턴스 값이 나온 것을 알 수 있다. 다시 말해, 싱글톤이 깨진다.

그러면 어떻게 하는게 좋을까? 이펙티브 자바에서는 필드와 생성자를 생성할 때 예외처리를 하는 것을 권장하고 있다.

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;  
    }  
  
}

이 상태에서 코드를 실행하면, reflection2 위 결과처럼 예외처리가 나오게 된다.

단점: 역직렬화 할 때 새로운 인스턴스가 생길 수 있다.#

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 생성자 + 정적 팩터리 메서드 사용#

이 방법은 기존에 INSTANCEpublic에서 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();와 같이 호출하면, 컴파일러는 TString이어야 함을 자동으로 추론한다. 만약 메서드 레벨의 제네릭 선언이 없다면, 메서드는 클래스 레벨의 제네릭 타입만 사용할 수 있어 제네릭 싱글톤 패턴을 구현할 수 없다. 또한, 다양한 타입으로 형변환하여 사용하는 싱글톤 팩토리의 유연성이 사라진다. 따라서 메서드 레벨의 제네릭 선언을 활용하면, 하나의 싱글톤 인스턴스를 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();  
        }  
    }  
}
  • 코드 실행 결과(생성자를 가지고 오지 못하는 것을 볼 수 있다.)

reflection3 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 값이 같은 것을 볼 수 있다.)

serialization 위 코드와 결과와 같이 역직렬화를 하더라도 동일한 인스턴스를 얻을 수 있다.

출처#

  • 이펙티브 자바 아이템 3: 생성자나 열거 타입으로 싱글턴임을 보장하라.
  • 백기선의 이펙티브 자바(인프런)
[ Effective Java ] 싱글턴 패턴을 잘 사용하기
https://blog-full-of-desire-v3.vercel.app/posts/java/effective-java/item3-singleton/singleton/
저자
SpeculatingWook
게시일
2025-03-04
라이선스
CC BY-NC-SA 4.0