Java

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

jwKim96 2022. 12. 14. 10:49

예제 코드는 여기서 보실 수 있습니다.

0. 들어가며

이번 아이템은 싱글턴 패턴을 안정적으로 구현하기 위한 방법들에 대한 내용입니다.
각각의 방법들을 예제 코드와 함께 알아보겠습니다.

1. 싱글턴이란?

싱글턴(singleton)이란 인스턴스를 오직 하나만 생성할 수 있는 클래스를 말한다.
(Effective java 책 23p)

'오직 하나만 생성할 수 있다' 라는 말은 '인스턴스가 하나만 존재해야 한다' 라고 해석할 수도 있습니다.
즉, 싱글톤 구현을 위해서는 Java 의 런타임 환경인 JVM 에서 단 하나의 인스턴스만 생성되도록 구현해야 한다고 생각할 수 있습니다.

1.1 싱글턴의 특징

1. 메모리 절약

모든 객체는 생성되면 JVM 의 heap 메모리에 저장됩니다.
그러나 싱글턴 객체는 단 한번만 생성되기 때문에 최초에 생성된 단 한개의 객체만 heap 에 저장됩니다.
이후 모든 클라이언트는 해당 객체를 공유하기 때문에 메모리를 효율적으로 사용할 수 있다는 이점이 있습니다.

2. 데이터 공유

싱글톤으로 구현된 객체는 JVM heap 에 저장된 하나의 객체를 모든 클라이언트들이 공유하게 되는데요.
만약 싱글톤 객체에 공유할 정보를 넣어놓는다면 손쉽게 모든 클라이언트들이 공유할 수 있게 됩니다.

3. 멀티스레드 환경을 고려해야 함

다수의 클라이언트에서 하나의 객체, 즉 하나의 자원을 공유하는 상황이다 보니 자칫하면 치명적인 문제가 발생할 수 있습니다.
싱글톤 구현 시 멀티스레드를 고려하지 않으면 여러 클라이언트에서 하나의 자원에 접근하며 레이스 컨디션이 발생할 수 있습니다.
만약 싱글턴의 생성 로직에서 레이스 컨디션이 발생한다면, 여러개의 객체가 생성되는 일도 발생할 수 있습니다.

4. 싱글턴을 사용하는 클라이언트를 테스트하기 어려움

테스트 케이스들을 작성할 때 필연적으로 다양한 입력값, 출력값을 정의해야 합니다.
만약 싱글톤 클래스가 불변객체이고, 생성자를 통해서 값을 주입받는 다면 어떨까요?
그렇게 된다면 이 싱글톤 클래스를 사용하는 코드를 테스트할때 어려움이 생기게 됩니다.

이런 부분을 방지하고자 한다면 싱글톤 클래스가 특정 인터페이스를 상속하게 하는 방법이 있습니다.

interface Something {
    void doSomething();
}

class SingletonSomething implements Something {

    @Override
    public void doSomething() {
        // do something
    }
}

2. 싱글턴을 구현하는 방법

  1. public static final 필드
  2. 정적 팩터리
  3. 열거 타입 활용(enum)
  4. LazyHolder 방식

1,2,3번 방식은 Effective java 에서 제안하는 방식이고, 4번은 추가로 소개하고싶은 방법입니다.
1번부터 차례대로 살펴보겠습니다.

2.1 public static final 필드

/**
 * 1. public static final 필드 방식
 */
public class Singleton1 {
    public static final Singleton1 instance = new Singleton1();
    private Singleton1() { }
}
  • static field 는 Class loader 의 초기화 단계에서 실제로 객체가 생성됨
  • Class loader 의 초기화 단계는 JVM 내부적으로 thread-safe 하게 동작하기 때문에 멀티스레드 환경에서도 안전함
  • 이 싱글톤을 사용하지 않더라도 heap 에 객체가 미리 생성되어있어 불필요하게 메모리를 사용하게 됨

2.2 정적 팩터리 방식

정적 팩터리 방식에서는 여러가지 방법을 사용할 수 있습니다.
이 방식들의 공통적인 특징은 다음과 같습니다.

  • 통상적으로 getInstance 메소드는 싱글톤을 반환하는 기능을 한다고 알려져있음
    • 그래서 클라이언트 측에서 싱글톤을 사용한다고 인지하고 사용할 수 있어서 혼동 가능성이 적음
  • 그리고 상황에 따라 getInstance 메소드만 수정하면 Singleton <--> Non Singleton 으로 변경할 수 있음

1) static final 필드 활용

/**
 * 2. 정적 팩터리 방식
 *     1) static final 필드 활용
 */
public class Singleton2_1 {
    private static final Singleton2_1 instance = new Singleton2_1();
    private Singleton2_1() { }

    public static Singleton2_1 getInstance() {
        return instance;
    }
}

2.1 과 특징이 같습니다.

  • static field 는 Class loader 의 초기화 단계에서 실제로 객체가 생성됨
  • Class loader 의 초기화 단계는 JVM 내부적으로 thread-safe 하게 동작하기 때문에 멀티스레드 환경에서도 안전함
  • 이 싱글톤을 사용하지 않더라도 heap 에 객체가 미리 생성되어있어 불필요하게 메모리를 사용하게 됨

2) Lazy Initialization(지연 초기화)

/**
 * 2. 정적 팩터리 방식
 *     2) Lazy Initialization(지연 초기화)
 */
public class Singleton2_2 {
    private static Singleton2_2 instance;
    private Singleton2_2() { }

    public static Singleton2_2 getInstance() {
        if (instance == null) {
            instance = new Singleton2_2();
        }

        return instance;
    }
}

1)번 방법은 사용하지 않아도 불필요하게 객체가 생성된다는 단점이 있었습니다.
하지만 지연 초기화 방법을 사용하면 그러한 점을 해결할 수 있습니다.

  • 호출할 때 null 인 경우에 객체가 생성되므로 불필요한 메모리 사용을 줄일 수 있음
  • 멀티스레드 환경에서 레이스 컨디션이 발생할 경우, 싱글톤을 만족하지 않을 수 있음

3) Lazy Initialization + Synchronization(지연 초기화 + 동기화)

/**
 * 2. 정적 팩터리 방식
 *     3) Lazy Initialization + Synchronization(지연 초기화 + 동기화)
 */
public class Singleton2_3 {
    private static Singleton2_3 instance;
    private Singleton2_3() { }

    public static synchronized Singleton2_3 getInstance() {
        if (instance == null) {
            instance = new Singleton2_3();
        }

        return instance;
    }
}

멀티스레드 환경에서 레이스 컨디션이 발생하는 경우를 방지하기 위해 동기화를 사용하는 방법입니다.
Java 의 synchronized 키워드를 이용해서 동기화 문제를 해결합니다.

  • thread-safe 한 싱글톤임
  • synchronized 키워드로 인해 성능이 저하될 수 있음

4) LazyHolder 방식

/**
 * 4. LazyHolder 방식
 */
public class Singleton4 {

    private Singleton4() { }

    private static class LazyHolder {
        private static final Singleton4 INSTANCE = new Singleton4();
    }

    public static Singleton4 getInstance() {
        return LazyHolder.INSTANCE;
    }
}

LazyHolder 방식은 지연 초기화 방식의 성능적 문제를 개선하기 위해 제안된 방법입니다.
synchronized 키워드를 사용하면 JVM 내부적으로 락을 획득하는 작업 때문에 성능 저하가 발생합니다.
하지만 이 방법은 Classloader 에 Singleton4 인스턴스 생성 역할을 맡기며 지연 초기화를 구현하는 방법인데요.
클래스 로드와 초기화 과정은 thread-safe 하게 동작하는데, 이는 synchronized 보다는 성능이 좋다고 합니다.
(참고한 글 : https://medium.com/@joongwon/multi-thread-환경에서의-올바른-singleton-578d9511fd42)

  • 지연 초기화로 인해 미사용 객체를 생성하지 않아 메모리를 효율적으로 사용
  • synchronized 키워드를 사용한 방식보다는 성능상 이점이 있음

개인적으로는 synchronized 키워드와 Classloader 의 thread-safe 한 동작에서 왜 성능차이가 있는건지 궁금한데요.
이 부분은 조금 더 알아봐야할 것 같습니다.

3. 조금 더 생각해볼 것들

  • volatile 키워드를 활용한 방식(volatile singleton)
  • 어플리케이션 서버를 수평확장했을 때의 싱글톤 객체