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

Q_Hyun·2023년 10월 31일
0

Effective Java 스터디

목록 보기
1/3
post-thumbnail

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

Singleton 이란?

Singleton이란 객체에 대한 인스턴스를 하나만 있도록 하고, 해당 인스턴스에 대해 전역적인 접근을 가능하게 한다. - Refactoring.Guru

위의 말과 같이 싱글톤 패턴이란 오직 하나의 인스턴스만이 있도록 만들어주는 기법이다. 오직 하나의 인스턴스만이 존재하는 경우에 대해서는 이펙티브 자바에서는 다음과 같이 설명한다.

싱글톤의 대상은 무상태(stateless)객체 혹은 설계상 유일해야 하는 시스템 컴포넌트가 된다. - 이팩티브 자바 3/E 23page

무상태 객체는 내부에 변수가 없는 객체를 의미한다.

일반적으로 우리가 Spring에서 bean으로 등록하는 객체들은 의존성 주입을 받은 bean들을 제외한 변수가 존재하지 않기 때문에, 무상태 객체라고 볼 수 있다.

무상태 객체의 예시

@Service
@RequiredArgsConstructor
public class UserService {

    private final UserRepository userRepository;
    private final CertificationService certificationService;
	...
}

설계상 유일해야 하는 시스템 컴포넌트에 대한 예시로는 주로 설정 정보들을 예시로 든다.

public class Settings {
    
    private static Settings instance;
    private Settings() { }
    public static Settings getInstance() {
        if (instance == null) {
            instance = new Settings();
        }
        return instance;
    }
}

설정 정보 외에도 Connection Pool 같은 경우가 상태가 있지만 유일해야 하는 객체이다.

Singleton을 만드는 방법

싱글톤을 만드는 대표적인 방법에 대해서 알아보자. 아래 소개하는 방법은 이펙티브 자바에서 소개된 방법이다.

public static 변수 이용하기

public static 변수를 이용해서 싱글톤을 생성한 경우이다.

public class Calculator {
    public static Calculator INSTANCE = new Calculator();
    public int add(int a, int b) {
        return a + b;
    }
    
    public static void main(String[] args) {
        Calculator cal = Calculator.INSTANCE;
        System.out.println(cal.add(1,2));
    }
}

정적 팩토리 메서드 이용하기

public static 변수를 private으로 변경하고, 정적 팩토리 메서드로 변경을 한 케이스이다.

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

    public static Calculator getInstance(){return INSTANCE;}

    public int add(int a, int b) {
        return a + b;
    }
}

Singleton을 보장하는 방법

위에서 Singleton을 생성하는 방법에 대해서 알아보았다.
하지만 위의 방법은 싱글톤을 보장 할 수 없다.

그 이유는 생성자가 공개되어있기 때문이다.

public class Main {

    public static void main(String[] args) {
        Calculator cal = Calculator.getInstance();
        Calculator cal2 = new Calculator();

        System.out.println(cal == Calculator.getInstance()); // true
        System.out.println(cal == cal2); // false
    }
}

public static 변수를 이용해도 결과는 동일하다.

1. private 생성자 이용하기

싱글톤이 깨진 이유가 생성자가 공개되어있기 때문이니, 생성자를 공개하지 않는 것으로 싱글톤을 일단은 보장할 수 있다.

public class Calculator {
    private static Calculator INSTANCE = new Calculator();

    public static Calculator getInstance(){return INSTANCE;}

    private Calculator(){}

    public int add(int a, int b) {
        return a + b;
    }
}

public class Main {

    public static void main(String[] args) {
        Calculator cal = Calculator.getInstance();
        Calculator cal2 = new Calculator(); // 컴파일 에러 발생
    }
}

하지만 private 생성자를 통해 구현한 싱글톤은 reflection을 통해 파괴할 수 있다.

public class Main {

    public static void main(String[] args)
            throws Exception {
        Calculator cal = Calculator.getInstance();

        // reflection으로 생성자 부르기
        Constructor<Calculator> constructor = Calculator.class.getDeclaredConstructor();
        constructor.setAccessible(true);
        Calculator cal2 = constructor.newInstance();
        
        System.out.println(cal == cal2); // false
    }
}

2. readResolve 메서드 추가하기

싱글톤이 깨지는 상황은 직렬화/역직렬화 상황에서도 발생을 한다.

    public static void main(String[] args)
            throws Exception {
        Calculator cal = Calculator.getInstance();

        // 외부 파일명
        String fileName = "Calculator.obj";

        // 파일 스트림 객체 생성 (try with resource)
        try (
                FileOutputStream fos = new FileOutputStream(fileName);
                ObjectOutputStream out = new ObjectOutputStream(fos)
        ) {
            // 직렬화 가능 객체를 바이트 스트림으로 변환하고 파일에 저장
            out.writeObject(cal);

        } catch (IOException e) {
            e.printStackTrace();
        }

        // 파일 스트림 객체 생성 (try with resource)
        Calculator deserializedCalculator = null;
        try(
                FileInputStream fis = new FileInputStream(fileName);
                ObjectInputStream in = new ObjectInputStream(fis)
        ) {
            // 바이트 스트림을 다시 자바 객체로 변환 (이때 캐스팅이 필요)
            deserializedCalculator = (Calculator) in.readObject();

        } catch (IOException | ClassNotFoundException e) {
            e.printStackTrace();
        }

        System.out.println(cal == deserializedCalculator); // false
    }

위의 상황과 같이 싱글톤 객체를 직렬화하여 만든 파일을 역직렬화를 하면 원본 객체와 역직렬화를 한 객체가 서로 다른 객체라고 인식을 한다.

이때 readResolve()메서드를 추가해보자.

readResolve() 메서드란 직렬화/역직렬화가 가능한 객체가 스트림에서 읽히고 반환이 될 때, 반환되는 객체를 대체/수정할 수 있는 메서드라고 한다.

자세한 내용은 다음 Docs를 참고하자

책에 나온 것 처럼 아래 메서드를 추가하면 놀랍게도, 역직렬화 객체와 싱글톤 객체에 대한 참조값 비교가 true가 나온다.

    private Object readResolve(){
        return INSTANCE;
    }

3. enum 클래스를 이용하기

책에서 소개된 싱글톤을 보장할 수 있는 마지막 방법은 enum을 이용하는 방법이다. 위에서 본 Calculator를 enum으로 만들어보고, reflection과 직렬화로 깰 수 있는지 확인해보자.

먼저 reflection 사용이다.


    public static void main(String[] args) throws Exception{
        CalculatorEnum cal = CalculatorEnum.INSTANCE;
        Constructor<?>[] constructors =
                cal.getClass().getDeclaredConstructors();

        for (Constructor<?> constructor : constructors) {
            constructor.setAccessible(true);
            constructor.newInstance();
        }
    }

Exception in thread "main" java.lang.IllegalArgumentException: Cannot reflectively create enum objects가 터진다. enum은 reflection으로 만들 수 없다고 한다.

그 다음은 직렬화/역직렬화이다.

    public static void main(String[] args) throws Exception{
        CalculatorEnum cal = CalculatorEnum.INSTANCE;

        // 외부 파일명
        String fileName = "Calculator.obj";

        // 파일 스트림 객체 생성 (try with resource)
        try (
                FileOutputStream fos = new FileOutputStream(fileName);
                ObjectOutputStream out = new ObjectOutputStream(fos)
        ) {
            // 직렬화 가능 객체를 바이트 스트림으로 변환하고 파일에 저장
            out.writeObject(cal);

        } catch (IOException e) {
            e.printStackTrace();
        }

        // 파일 스트림 객체 생성 (try with resource)
        CalculatorEnum deserializedCalculator = null;
        try(
                FileInputStream fis = new FileInputStream(fileName);
                ObjectInputStream in = new ObjectInputStream(fis)
        ) {
            // 바이트 스트림을 다시 자바 객체로 변환 (이때 캐스팅이 필요)
            deserializedCalculator = (CalculatorEnum) in.readObject();

        } catch (IOException | ClassNotFoundException e) {
            e.printStackTrace();
        }_

        System.out.println(cal == deserializedCalculator); // true
    }

readResolve() 메서드를 추가하지 않아도, true가 나온다.

enum 클래스를 이용하면 reflection과 직렬화/역직렬화에도 안전하게 싱글톤을 이용할 수 있다. 책에서도 이 방법을 가장 추천하지만 보통은 enum 클래스를 이용하는 것에 어색함이 있기 때문에, 잘 사용을 안하는 듯 싶다. 추가로 enum외에는 상속이 불가능하다는 특징도 있다.

ref

Refactoring.Guru - 싱글톤 패턴
이팩티브 자바 3/E 23page
Object Input Classes docs
자바 직렬화(Serializable) - 완벽 마스터하기 (인파)

0개의 댓글