본문 바로가기
JAVA/Effective Java

Item3. 생성자나 열거 타입으로 싱글턴임을 보증하라

by 민휘 2023. 5. 14.

다루는 내용

  • 방법1 : private 생성자 + public static final 필드
  • 방법2 : 퍼블릭 스태틱 메소드
  • 방법3 : enum
  • 리플렉션 공격
  • 역직렬화 시 깨지는 싱글톤
  • 제네릭 싱글톤 팩토리 메소드
  • 메소드 참조와 Supplier
  • 코드로 만드는 싱글톤의 한계
  • 싱글톤 레지스트리
  • 싱글톤의 활용
  • 싱글톤 만들 때 꼭 무상태로 만들자!!!!

 

 

방법1. private 생성자 + public static final 필드

 

public class Elvis {
    /**
     * 싱글톤 오브젝트
     */
    public static final Elvis INSTANCE = new Elvis();
    private Elvis() {}

    public void sing() {
        System.out.println("Elvis is singing!");
    }

    public static void main(String[] args) {
        Elvis elvis = Elvis.INSTANCE;
        elvis.sing();
    }

}

 

 

 

장점1. 간결하고 명시적인 API

 

public 필드를 제공하는 방법은 간결하고 싱글톤임을 API에 드러낼 수 있다. 자바독을 만들었을 때 field 탭에 명시적으로 보이기 때문에 인식이 쉽다.

 

단점1. 테스트 어려움

 

하지만 싱글톤 패턴을 사용하는 방법은 공통적으로 클라이언트 테스트가 어렵다. 싱글톤은 전역 객체로 사용되므로 다른 객체와의 결합도가 강해질 수 있다. 클라이언트의 단위 테스트는 순서에 상관없이 독립적으로 실행 결과를 지원해야하는데, 싱글톤은 다양한 객체와의 상호작용으로 상태값을 가지므로 단위 테스트의 목적인 특정 기능만을 테스트하는 의도에 부합하지 않는다.

 

이러한 단점은 싱글톤에 인터페이스를 두어 테스트용 객체로 대체하는 방법을 사용할 수 있다.

public interface IElvis {
    void leaveTheBuilding();
    void sing();
}

public class Concert {
    private boolean lightsOn;
    private boolean mainStateOpen;
    private IElvis elvis;

    public Concert(IElvis elvis) {
        this.elvis = elvis;
    }

    public void perform() {
        mainStateOpen = true;
        lightsOn = true;
        elvis.sing();
    }

    public boolean isLightsOn() {
        return lightsOn;
    }

}

public class ConcertTest {
    @Test
    void perform() {
        Concert concert = new Concert(new IElvis() {
            @Override
            public void leaveTheBuilding() {
                System.out.println("bye");
            }

            @Override
            public void sing() {
                System.out.println("rehearse...");
            }
        });
        concert.perform();

       assertTrue(concert.isLightsOn());
    }
}

 

 

단점2. 리플렉션 공격

 

리플렉션 api를 사용하면 private 생성자를 호출할 수 있다.

public class ElvisReflection {
    public static void main(String[] args) {
        try {
            Constructor<Elvis> defaultConstructor = Elvis.class.getDeclaredConstructor(); // private 포함 생성자 가져옴
            defaultConstructor.setAccessible(true); // private 접근 가능
            Elvis e1 = defaultConstructor.newInstance();
            Elvis e2 = defaultConstructor.newInstance();
            System.out.println(e1 == e2);
        } catch (InvocationTargetException | NoSuchMethodException | InstantiationException | IllegalAccessException e) {
            e.printStackTrace();
        }
    }
}

 

클래스에 flag 변수를 두어서 private 생성자가 두번 호출된 경우 예외를 던져서 방어할 수 있다. 하지만 코드가 추가되어 복잡해진다.

public class Elvis {
    /**
     * 싱글톤 오브젝트
     */
    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;
    }
		// ..

}

 

 

단점3. 역직렬화할 때 새로운 인스턴스가 생성될 수 있다.

객체를 바이트스프림으로 상호 변환하는 기술이다. 바이트스트림으로 변환한 객체를 파일로 저장하거나 네트워크를 통해 다른 시스템으로 전송할 수 있다. 오브젝트를 바이트스트림으로 만드는 작업이 직렬화이고, 바이트스트림을 오브젝트로 복원하는 작업이 역직렬화이다.

 

객체를 메시지로 전송하는 작업을 요즘엔 json을 사용한다. 만약 jvm 시스템에 메시지를 보낸다면 직렬화도 유용한데, 다른 시스템으로 보낸다면 json을 사용하는 것이 나을 것이다.

 

구현 상으로 다음 사항을 지켜야한다.

  • Serializable 인터페이스 구현
  • transient : 직렬화 하지 않을 필드를 선언
  • static은 직렬화되지 않는다. 당연함 클래스 필드임
  • serialVersionUID : 이 아이디가 동일해야 직렬화나 역직렬화가 가능하다. 직렬화로 오브젝트 파일이 만들어진 상태에서 구현을 변경하면, 역직렬화에 실패한다. 런타임에 만들어진 코드의 UID가 변경되기 때문이다. 구현을 바꾸더라도 역직렬화 가능하도록 만들려면 명시적으로 선언해야한다.
// 전송할 객체
public class Book implements Serializable {

    private static final long serialVersionUID = 1L;
    private String isbn;
    private String title;
    private LocalDate published;
    private String name;
    private transient int numberOfSold;

    public Book(String isbn, String title, String author, LocalDate published) {
        this.isbn = isbn;
        this.title = title;
        this.published = published;
    }

    @Override
    public String toString() {
        return "Book{" +
                "isbn='" + isbn + '\\'' +
                ", title='" + title + '\\'' +
                ", published=" + published +
                ", numberOfSold=" + numberOfSold +
                '}';
    }

    // getter setter
}

// 직렬화와 역직렬화
public class SerializationExample {

    private void serialize(Book book) {
        try (ObjectOutput out = new ObjectOutputStream(new FileOutputStream("book.obj"))) {
            out.writeObject(book);
        } catch (IOException e) {
            throw new RuntimeException(e);
        }
    }

    private Book deserialize() {
        try (ObjectInput in = new ObjectInputStream(new FileInputStream("book.obj"))) {
            return (Book) in.readObject();
        } catch (IOException | ClassNotFoundException e) {
            throw new RuntimeException(e);
        }
    }

    public static void main(String[] args) {
        Book book = new Book("12345", "이팩티브 자바 완벽 공략", "백기선",
                LocalDate.of(2022, 3, 21));
        book.setNumberOfSold(200); // static

        SerializationExample example = new SerializationExample();
        example.serialize(book);
        Book deserializedBook = example.deserialize();

        System.out.println(book);
        System.out.println(deserializedBook);
    }
}

 

 

readResolve와 transient

역직렬화할 때 readResolve : https://madplay.github.io/post/what-is-readresolve-method-and-writereplace-method

 

readResolve() 메서드는 java.io.Serializable 인터페이스를 구현하는 클래스에서 사용됩니다. 이 메서드는 객체 역직렬화 과정에서 호출되어, 역직렬화된 객체를 반환하여 새로운 객체 생성을 막는 역할을 합니다. 따라서 readResolve() 메서드를 사용하기 위해서는 해당 클래스가 Serializable 인터페이스를 구현해야 하며, 이 메서드를 직접 정의하여 사용할 수 있다.

 

readResolve 메서드에서는 원하는 객체를 반환합니다. 이 객체는 역직렬화 프레임워크에 의해 객체 그래프에 삽입되며, 이후에는 이 객체가 사용됩니다.

 

 

Elvis에 역직렬화 방어 적용하기

 

private 생성자를 사용하는 Elivs 싱글톤 클래스가 있다.

public class Elvis implements Singer {

    private static final Elvis INSTANCE = new Elvis();
    private static boolean created;
    public static Elvis getInstance() { return INSTANCE; }

    private Elvis() { }

    @Override
    public void sing() {
        System.out.println("Elvis is singing!");
    }

    public static void main(String[] args) {
        Elvis elvis = Elvis.getInstance();
        elvis.sing();
    }

}

 

Elvis는 역직렬화할 때 새로운 인스턴스가 생성되어 싱글톤이 깨진다.

public class EnumElvisSerialization {

    public static void main(String[] args) {
        try (ObjectOutput out = new ObjectOutputStream(new FileOutputStream("elvis.obj"))) {
            out.writeObject(Elvis.getInstance());
        } catch (IOException e) {
            e.printStackTrace();
        }

        try (ObjectInput in = new ObjectInputStream(new FileInputStream("elvis.obj"))) {
            Elvis elvis = (Elvis) in.readObject();
            System.out.println(elvis == Elvis.getInstance()); // false - 싱글톤 깨짐!
        } catch (IOException | ClassNotFoundException e) {
            e.printStackTrace();
        }

    }
}

 

Elvis에 다음과 같이 Serializable을 구현하고 readResolve를 재정의하여 싱글톤 객체를 반환하도록 한다.

그러면 역직렬화될 때 새로운 인스턴스가 만들어지는 대신 readResolve가 반환하는 객체를 사용한다.

참고로 static 필드는 직렬화할 때 무시되므로 transient 키워드를 붙이지 않아도 된다.

public class Elvis implements Singer, Serializable {

    private static final Elvis INSTANCE = new Elvis();
    private static boolean created;
   public static Elvis getInstance() { return INSTANCE; }

    private Object readResolve() {
        return getInstance();
    }

    private Elvis() { }
}

 

다시 직렬화 테스트를 해보면 true가 나온다.

 

 

 

방법2. private 생성자 + 정적 팩터리 메서드

public class Elvis {

    private static final Elvis INSTANCE = new Elvis();
    private static boolean created;
    public static Elvis getInstance() { return INSTANCE; }

    private Elvis() {
        if (created) {
            throw new UnsupportedOperationException("can't be created by constructor.");
        }
        created = true;
    }

    public void sing() {
        System.out.println("Elvis is singing!");
    }

    public static void main(String[] args) {
        Elvis elvis = Elvis.getInstance();
        elvis.sing();
    }

}

 

방법1과 동일하게 테스트 어려움, 리플렉션 공격, 역직렬화로 싱글톤이 깨진다는 단점이 있다.

 

 

장점1. API 변경 없이 싱글턴이 아니게 변경할 수 있다

 

getInstance에서 새로운 인스턴스를 생성해 반환하는 방식으로 변경 가능하다. 이때 클라이언트 코드는 변경되지 않는다.

public class Elvis {
    public static Elvis getInstance() { return new Elvis();

    public static void main(String[] args) {
        Elvis elvis = Elvis.getInstance();
        elvis.sing();
    }

}

 

 

장점2. 정적 팩터리를 제네릭 싱글턴 팩토리로 만들 수 있다

 

싱글턴 패턴과 제네릭을 결합하여, 타입 재사용 가능한 객체 생성 코드를 제공한다. 인스턴스는 동일한데, 원하는 타입으로 형변환이 가능하다. @SuppressWarnings는 해당 코드에 대해 타입 체크를 수행하지 않는다는 내용을 경고한다.

public class MetaElvis<T> {

    private static final MetaElvis<Object> INSTANCE = new MetaElvis<>();
    private MetaElvis() {}

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

    public static void main(String[] args) {
        MetaElvis<String> e1 = MetaElvis.getInstance();
        MetaElvis<Integer> e2 = MetaElvis.getInstance();
				System.out.println(e1.equals(e2)); // hashcode 같으므로 true
        System.out.println(e1 == e2); // 타입 달라서 == 비교 불가능
    }

}

 

 

 

장점3. 정적 팩터리 메소드 참조를 Supplier로 사용할 수 있다

 

정적 팩터리 메소드는 인자를 받지 않고 객체를 반환하므로 Supplier로 사용할 수 있다. 클라이언트 코드에서 Supplier 타입으로 정적 팩토리 메소드를 넘길 때 메소드 참조를 사용할 수 있으므로 훨씬 간결한 표현이 가능하다.

public class ConcertBySinger {
    public void start(Supplier<Singer> singerSupplier) {
        Singer singer = singerSupplier.get();
        singer.sing();
    }

    public static void main(String[] args) {
        ConcertBySinger concert = new ConcertBySinger();
        concert.start(Elvis::getInstance);
    }
}

 

Supplier의 인터페이스 시그니처

Supplier (Java Platform SE 8 )

 

Supplier (Java Platform SE 8 )

Represents a supplier of results. There is no requirement that a new or distinct result be returned each time the supplier is invoked. This is a functional interface whose functional method is get().

docs.oracle.com

 

T get()
/**
Gets a result.
Returns:
a result
*/

 

함수형 인터페이스 심화학습

 

 

 

 

방법3 : enum

가장 간결하다. 공격에도 안전하다. 대부분의 상황에서 원소가 하나뿐인 열거 타입이 싱글턴을 만드는 가장 좋은 방법이다.

public enum EnumElvis {
    INSTANCE;

    public void leaveTheBuilding() {
        System.out.println("going out");
    }

    public static void main(String[] args) {
        EnumElvis elvis = EnumElvis.INSTANCE;
        elvis.leaveTheBuilding();
    }
}

 

 

리플렉션 공격에 안전하다

 

enum은 java.lang.Enum 클래스를 상속받는 클래스이다. enum은 내부적으로 리플렉션으로 생성자를 얻는 것을 막아두었다. 그래서 리플렉션 api를 사용해 생성자를 얻으려고 하면 NoSuchMethodException 예외가 발생한다.

 

public class EnumElvisReflection {
    public static void main(String[] args) {
        try {
						// NoSuchMethodException!
            Constructor<EnumElvis> declaredConstructor = EnumElvis.class.getDeclaredConstructor();
            System.out.println(declaredConstructor);
        } catch (NoSuchMethodException e) {
            e.printStackTrace();
        }
    }
}

 

 

역직렬화에 안전하다

 

GPT 왈 :

 

Java에서 Enum 클래스는 직렬화(serialization)과 역직렬화(deserialization)에 안전한 클래스입니다. 이는 Enum 클래스가 직렬화할 때, JVM이 자동으로 관리하는 serialVersionUID를 사용하기 때문입니다.

 

Java에서 Serializable 인터페이스를 구현한 클래스는 직렬화할 때 serialVersionUID를 명시해주어야 합니다. 그렇지 않으면, 클래스의 구현이 변경될 때, serialVersionUID가 자동으로 변경되어 역직렬화할 때 버전이 맞지 않아 예외가 발생할 수 있습니다.

 

하지만, Enum 클래스는 serialVersionUID를 명시하지 않아도 안전합니다. 이는 Enum 클래스가 컴파일러에 의해 자동으로 serialVersionUID를 생성하고, JVM이 이를 자동으로 관리하기 때문입니다. 따라서, Enum 클래스는 직렬화와 역직렬화가 안전합니다.

 

public class EnumElvisSerialization {
    
    public static void main(String[] args) {
        try (ObjectOutput out = new ObjectOutputStream(new FileOutputStream("elvis.obj"))) {
            out.writeObject(EnumElvis.INSTANCE);
        } catch (IOException e) {
            e.printStackTrace();
        }

        try (ObjectInput in = new ObjectInputStream(new FileInputStream("elvis.obj"))) {
            EnumElvis elvis = (EnumElvis) in.readObject();
            System.out.println(elvis == EnumElvis.INSTANCE); // true
        } catch (IOException | ClassNotFoundException e) {
            e.printStackTrace();
        }

    }
}

 

 

 

 

테스트 오브젝트로 대체 가능하다

enum 역시 자바 클래스이므로 인터페이스를 구현하도록 할 수 있다. 테스트할 때 싱글톤 타입이 불편한 경우, 동일한 인터페이스를 구현하는 테스트용 객체를 구현하여 사용할 수 있다.

public enum EnumElvis implements IElvis {
    INSTANCE;

    @Override
    public void sing() {
        System.out.println("EnumElvis is singing");
    }

}

 

 

 

코드로 만든 싱글톤의 한계

 

  1. private 생성자로 인한 한계
    • 상속 불가능
    • 리플렉션 공격
    • 역직렬화시 싱글톤이 깨짐
    • 생성 방법이 제한적이므로 테스트에서 목 대체가 어려움 → 이 문제는 인터페이스로 해결 가능
    따라서 싱글톤을 코드로 구현해야하만 한다면, private 생성자를 사용하는 방법보다는 안전하고 간결한 enum을 사용하자.
  2. 싱글톤의 사용은 전역 상태를 만든다. 싱글톤은 테스트가 어렵다.그런데 싱글톤 객체는 어플리케이션 전역에서 사용되는 상태를 가지기 때문에 테스트할 때 문제가 될 수 있다. 하나의 테스트에서 싱글톤 객체의 상태를 변경하면 그 상태는 다른 테스트에도 영향을 미친다. 이로 인해 테스트가 서로 영향을 주고 받을 수 있으며, 이는 테스트 결과를 신뢰할 수 없게 만들어 버그를 찾기 어렵게 만든다. (데이터베이스의 데이터 무결성과 비슷한 컨셉이다)
  3. 싱글톤은 테스트가 어렵다. 각 단위 테스트는 독립적으로 실행되어야하며, 테스트의 실행 결과가 다른 테스트의 실행으로 인해 달라지면 안된다. (JUnit은 테스트 순서를 보장하지 않으므로 독립적인 테스트를 꼭꼭 지켜야한다) 그런데 싱글톤 객체는 어플리케이션 전역에서 사용되는 상태를 가지기 때문에 테스트할 때 문제가 될 수 있다. 하나의 테스트에서 싱글톤 객체의 상태를 변경하면 그 상태는 다른 테스트에도 영향을 미친다. 이로 인해 테스트가 서로 영향을 주고 받을 수 있으며, 이는 테스트 결과를 신뢰할 수 없게 만들어 버그를 찾기 어렵게 만든다. (데이터베이스의 데이터 무결성과 비슷한 컨셉이다)
  4. 서버환경에서는 싱글톤이 하나만 만들어지는 것을 보장하지 못한다. 서버에서 클래스 로더를 어떻게 구성하느냐에 따라 싱글톤 클래스임에도 하나 이상의 오브젝트가 만들어질 수 있다. 클래스 로더는 클래스패스를 기반으로 동적으로 클래스를 로드한다. 서버 환경에서는 서블릿 컨테이너에서 각 웹 애플리케이션마다 클래스 로더를 생성하므로, 클래스 로더마다 다른 인스턴스를 가지도록 구성할 경우 싱글톤 클래스라 할지라도 서로 다른 인스턴스가 만들어질 수 있다. 이 문제를 해결하려면 클래스 로더를 공유하는 방식으로 구성한다.또한, 여러개의 JVM에 분산되어 설치되는 경우에도 JVM마다 오브젝트가 생기므로 싱글톤이 깨진다.

 

 

 

싱글톤 레지스트리

 

싱글톤 레지스트리는 싱글톤 형태의 오브젝트를 만들고 관리하는 기능을 제공한다. 스프링 컨테이너는 싱글톤을 생성하고, 관리하고, 공급하는 싱글톤 관리 컨테이너이다. 싱글톤 레지스트리를 사용하면 싱글톤 코드를 구현하지 않은 평범한 자바 클래스를 싱글톤을 활용할 수 있다.

 

싱글톤을 구현하기 위해 private 생성자를 두거나 리플렉션, 직렬화에 안전함을 지키기 위해 지저분한 구현을 추가할 필요가 없다. 그리고 enum이 아닌 직관적인 자바 클래스를 사용할 수 있다. 그래서 스프링이 지지하는 객체지향적인 설계 방식과 원칙, 디자인 패턴을 적용하는데 아무런 제약이 없다. public 생성자를 가질 수 있으므로 테스트 환경에서 제약없이 테스트용 객체를 만들 수 있다.

 

서버환경에서 싱글톤으로 관리되면 좋은 서비스 오브젝트를 스프링 컨테이너 빈에 등록하는 것만으로 싱글톤 관리가 가능하다. 싱글톤으로 관리돼야하는 환경이 아니라면 간단히 오브젝트를 생성해 사용할 수 있다.

 

싱글톤의 활용

 

싱글톤은 전체 시스템에서 딱 하나의 인스턴스만 존재하도록 보장하는 패턴이다. 싱글톤은 비즈니스 요구사항의 특성 상 반드시 싱글톤을 지켜야하는 경우도 있고, 기술적인 면에서 장점이 있어 싱글톤으로 관리하는 것을 지향하는 경우도 있다.

 

싱글톤으로 활용하는 도메인 모델

 

서버 환경에서 싱글톤으로 활용하는 서비스 오브젝트

  • 데이터베이스 연결 : 데이터베이스 연결을 위한 오브젝트는 애플리케이션에서 여러 곳에서 사용됩니다. 이런 경우에 싱글톤으로 관리하여 애플리케이션 전역에서 공유하여 사용합니다. 이를 통해 매번 연결 객체를 생성하는 오버헤드를 줄이고, 더욱 효율적인 리소스 관리가 가능합니다.
  • 캐시 : 반복적으로 사용되는 데이터를 저장하는 메모리 공간입니다. 캐시 객체는 애플리케이션 전역에서 공유하여 사용됩니다. 이를 통해 매번 새로운 캐시를 생성하는 오버헤드를 줄이고, 더욱 효율적인 캐시 관리가 가능합니다.
  • 메일 발송 서비스 : 메일 전송을 위한 오브젝트입니다. 애플리케이션에서 메일 발송 기능을 사용할 때마다 매번 새로운 메일 발송 오브젝트를 생성하는 것은 비효율적입니다. 따라서 메일 발송 서비스를 싱글톤으로 관리하여 애플리케이션 전역에서 공유하여 사용합니다. 이를 통해 매번 새로운 메일 발송 오브젝트를 생성하는 오버헤드를 줄이고, 더욱 효율적인 리소스 관리가 가능합니다.

 

싱글톤과 오브젝트의 상태

 

디자인 책을 쓴 GoF 멤버조차도 싱글톤 패턴은 매우 조심해서 사용해야 하거나 피해야 할 패턴이라고 말한다.

 

싱글톤은 멀티 스레드 환경이라면 여러 스레드가 동시 접근해서 사용할 수 있기 때문에, 상태 관리에 주의해야 한다. 서비스 형태의 오브젝트로 사용되는 경우에는 싱글톤 객체를 무상태 방식으로 만들어야 한다. 다중 사용자의 요청을 한꺼번에 처리하는 스레드들이 동시에 싱글톤 오브젝틍의 인스턴스 변수를 수정하도록 하면, 여러 스레드가 값을 덮어쓰고 자신이 저장하지 않은 값을 읽어오므로 무결성이 깨진다.

따라서 싱글톤은 인스턴스 필드의 값을 변경하고 유지하는 상태유지 방식을 사용하면 안된다. 대신 이 객체에서 오퍼레이션을 하기 위해 필요로 하는 상태는 파라미터 주입, 로컬 변수, 리턴 값 등 유지되지 않는 방법을 사용하면 된다. 다만 읽기 전용이거나 싱글톤으로 관리되는 것이 보장되는 상태라면 필드로 추가할 수 있다.

 

public class UserDao {
    ConnectionMaker connectionMaker; // 싱글톤 읽기전용 변수

    public UserDao(ConnectionMaker connectionMaker) { // 생성자 di
        this.connectionMaker = connectionMaker;
    }

		// 쓰기작업이 가능한 User는 매개변수 di
    public void add(User user) throws ClassNotFoundException, SQLException {
        Connection c = connectionMaker.makeConnection();
				// ...
		}
		
		// 쓰기작업이 가능한 String는 매개변수 di
		public User get(String id) throws ClassNotFoundException, SQLException {
	      Connection c = connectionMaker.makeConnection();
				// ...
		}
}