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

NAKTA·2023년 11월 16일
0

effective java 3e

목록 보기
3/5
post-thumbnail

🌱 들어가면서

이번 주제는 디자인 패턴 종류 중 하나인 싱글톤 (Singlton) 패턴에 대한 것이다.
익숙하게 봐왔던 패턴이지만 자세하게 공부해 본 적은 없는 것 같다. 이번 글에서는 싱글톤 패턴의 구현 방식, 더불어 주의할 점은 무엇인지, 그리고 열거타입 (Enum)과 어떤 관계가 있는지 알아보고자 한다.



🙃 싱글톤 (Singleton) 패턴이란?

이미지 출처

싱글톤 패턴을 잘 나타내는 이미지라 생각하여 하나 가져와봤다.

싱글톤 패턴(Singleton Pattern)디자인 패턴 중 하나로,
어떤 클래스가 최대 하나의 인스턴스만을 갖도록 보장하고,
이에 대한 전역적인 접근점을 제공하는 패턴이다.

간단하게 말해서, 여러 명이서 하나를 공유해서 쓴다는 말이다.


✅ 싱글톤 (Singleton) 패턴을 사용하는 이유

싱글톤 패턴의 특징은 알았지만 해당 패턴을 왜 사용하는지 의문점이 생길 것이다.
주요 사용 이유는 다음과 같다.

1. 메모리 낭비 방지

한개의 인스턴스만을 고정 메모리 영역에 생성하고 추후 해당 객체를 접근할 때 추가적으로 객체를 생성하지 않아 메모리 낭비를 방지할 수 있다.

2. 객체 접근 속도

이미 생성되어 있는 인스턴스를 호출하기에 속도 측면에서도 빠른 이점이 있다.

3. 데이터 공유가 쉬움

전역적인 접근점을 제공하기 때문에 다른 클래스 간 데이터 공유가 쉽다.


⛔️ 싱글톤 (Singleton) 패턴의 문제점

모든 디자인 패턴이 그렇듯이, 싱글톤 패턴도 문제점을 가지고 있다.

1. 의존성이 높아진다.

싱글톤 패턴을 사용하는 경우 클래스의 객체를 미리 생성한 뒤에 필요한 경우 정적 메서드를 제공하는데 다른 클래스에서 해당 싱글톤 객체를 이용하기 위해서는 메서드의 존재를 알 필요가 있기 때문에 클래스 사이에 의존성 이 높아지게 된다는 문제점이 있다.

2. 상속이 어렵다.

ITEM 01 에서 나와있듯, 싱글톤 패턴은 정적 팩토리 메서드를 활용해야 하기 때문에, 생성자를 private 로 만드는 경우가 많기에 상속을 통해 하위 타입 클래스를 만들 수 없다는 특징이 있다. 이렇게 되면 객체 지향의 다형성 을 활용하기 힘들어진다.

3. 테스트하기 힘들다.

싱글톤 객체는 모든 클래스에게 자원을 공유하고 있다는 특징이 있다.
이러한 특징은 서로 독립적이고 어떤 순서로든 실행될 수 있어야 하는 단위 테스트 를 하는데 문제가 될 수 밖에 없다. 싱글톤 패턴은 미리 생성된 인스턴스를 기반으로 구현하므로 각 테스트마다 독립적인 인스턴스를 만들기가 어렵다.

추가로 싱글톤은 인터페이스를 통해 추상화하지 않고 해당 클래스를 직접 참조하므로 Mock 을 만드는데 어려움이 있어 Mock 테스트 를 하는데 어려움이 있다.


이렇듯, 여러가지 장점과 단점이 존재하기 때문에 싱글톤 패턴을 사용하는데는 각별한 주의를 요한다.



🙃 싱글톤 패턴을 만드는 방법

이제부터 싱글턴 만드는 여러가지 방법에 대해서 알아보자.

1. public static final 필드 방식

아래 예시 코드부터 보자.

public class Singleton {
    public static final Singleton INSTANCE = new Singleton();

    private Singleton(){}
}

다음 코드는 생성자를 private 접근 제한을 둬 추가 객체 생성을 막고,
Singleton.INSTANCE 를 통해 직접 필드에 접근해 객체를 얻어오는 방식이다.

코드가 간단한 만큼 간단한 방식으로
다음과 같이 작성하면 전체 시스템에서 해당 객체가 하나뿐이라는 것을 보장받을 수 있다.


하지만, 이 코드는 문제가 있다.

import java.lang.reflect.Constructor;

public class ReflectionTest {
    static Singleton singletonFromField;

    public static void main(String[] args) {
        try {
            singletonFromField = Singleton.INSTANCE;

            Constructor ctor = Class.forName("Singleton").getDeclaredConstructor();
            ctor.setAccessible(true);

            Singleton singletonFromConstructor = (Singleton) ctor.newInstance();

            System.out.println(singletonFromField);
            System.out.println(singletonFromConstructor);
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

다음과 같이 java Reflection API 를 호출해서 private 접근 제한자로 선언된 생성자에 접근하여 객체를 만들면 추가적으로 객체를 만드는게 가능하기 때문에 싱글톤을 보장하지 못한다는 특징이 있다.

이를 막기 위해서는, 두 번째 인스턴스를 생성할 때 예외처리 를 해주어야 한다.

💡 java Reflection API 란?
구체적인 클래스 타입을 알지 못해도 그 클래스의 정보(메서드, 타입, 변수 등등)에 접근할 수 있게 해주는 자바 API다.


추가적으로, 해당 코드는 역직렬화 (deserialization) 에도 안전하지 못하다.

import java.io.*;

public class DeserializationTest {
    public static void main(String[] args) throws IOException, ClassNotFoundException {
        Singleton singleton1 = Singleton.getInstance();

        String fileName = "singleton.obj";

        // 직렬화
        ObjectOutputStream out = new ObjectOutputStream(new BufferedOutputStream(new FileOutputStream(fileName)));
        out.writeObject(singleton1);
        out.close();

        // 역직렬화
        ObjectInputStream in = new ObjectInputStream(new BufferedInputStream(new FileInputStream(fileName)));
        Singleton singleton2 = (Singleton) in.readObject();
        in.close();

        System.out.println("singleton1 == singleton2 : " + (singleton1 == singleton2));
        System.out.println(singleton1);
        System.out.println(singleton2);
    }
}

해당 코드의 결과는 다음과 같다.

singleton1 == singleton2 : false
Singleton@26a1ab54
Singleton@5a2e4553

이러한 이유가 발생하는 이유는 역직렬화 자체가 보이지 않는 생성자의 역할을 수행하기 때문이다. 객체 를 또 만들어, 직렬화에 사용된 객체 와 다른 객체 를 만들기 때문에 더 이상 싱글톤을 보장할 수 없게 된다.


따라서, 이러한 문제를 해결하기 위해서는 readResolve() 메서드를 정의하면 된다.

import java.io.Serializable;

public class Singleton implements Serializable {
    public static final Singleton INSTANCE = new Singleton();

    private Singleton() {
    }

    Object readResolve() {
        return INSTANCE;
    }
}

해당 메서드를 정의하면 역직렬화 과정에서 새로 만들어진 객체 대신 readResolve() 메서드로 반환되는 객체 를 사용할 수 있도록 조정해주기 때문이다.


📑 정리

  • 코드가 간결하다.
  • java.Relection API에 취약하므로 추가적인 예외처리가 필요하다.
  • 역직렬화 과정에서 싱글톤이 깨져버리므로 readResolve() 메서드 구현이 필요하다.



2. 정적 팩토리 메서드 방식

해당 방식은 2가지 방법이 존재한다.

방법 1

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

    private Singleton() {
    }

    public static Singleton getInstance() {
        return INSTANCE;
    }
}

방법 2

public class Singleton {
    private static Singleton INSTANCE;

    private Singleton() {
    }

    public static Singleton getInstance() {
        if (INSTANCE == null) {
            INSTANCE = new Singleton();
        }

        return INSTANCE;
    }
}

얼핏 보기에는, 두 코드 모두 같은 로직으로 보이지만 차이점이 확실히 존재한다.


방법1의 경우에는,
클래스가 로드될 때 즉시 INSTANCE 필드에 인스턴스를 생성해 두는 방법이다.
사실상 위의 public static final 필드 방식 과 다르지 않다.
무엇보다, 이 방법은 쓰레드 안전 (thread-safe) 하다는 장점이 있다.

방법 2의 경우에는,
처음에 INSTANCE 필드는 null 로 초기화 되어 있는 상태이며,
사용자가 인스턴스가 필요한 시점에 호출하면 인스턴스를 생성하는 방법이다.
이 방법은 초기화가 지연됨으로써 메모리를 덜 사용할 수 있다는 이점이 있다.

추가적으로, 객체 생성을 정적 팩토리 메서드 가 담당하기 때문에 해당 메서드의 내용만 바꾸면 언제든지 싱글톤이 아니게 만들 수 있다는 특징이 있다.

하지만, 멀티스레드 환경에서 여러 스레드가 동시에 getInstance() 메서드를 호출하면 인스턴스가 여러 번 생성될 수 있으므로 동기화 작업을 따로 해줘야한다.


📑 정리

  • 정적 팩토리 메서드 방법의 경우 언제든지 싱글턴이 아니게 변경할 수 있다.
  • java.Relection API에 취약하므로 추가적인 예외처리를 해줘야 한다.
  • 역직렬화 과정에서 싱글톤이 깨져버리므로 readResolve() 메서드 구현이 필요하다.



3. 열거 타입 방식

열거 타입 (Enum) 이란?

한정된 값만을 가지는 데이터 타입을 의미한다.


이 개념을 이해하기 위해서 다음 예시를 보자.

public enum Week {
    MONDAY,
    TUESDAY,
    WEDNESDAY,
    THURSDAY,
    FRIDAY,
    SATURDAY,
    SUNDAY
}

위의 열거 타입은 한정된 7개의 값 (MONDAY~SUNDAY)을 가진다.
이러한 한정된 값들을 열거 상수 라고 부른다.


기본적인 개념은 알았으니 메모리 관점 에서 열거 타입을 보자.

우선 알아둬야 할 것은, 열거타입은 참조 타입 이다.

Week day = null;
day = Week.SUNDAY;

다음과 같이 열거 타입 변수를 null 로 선언하고 열거 상수 를 저장할 수 있다.
참조 타입에 다음과 같이 값을 할당할 수 이유는 열거 상수 는 곧 객체 라는 말이 된다.


다음 그림을 한번 보자.
이미지 출처

JAVA에서 열거 상수 는 각각 내부적으로 public static final 필드이면서 객체로 제공되도록 한다. 각각의 열거 상수static 이기 때문에, 클래스가 로드되는 시점에 Method 영역 에 올라가게 된다.

Heap 영역 에는 MONDAY~SUNDAY 까지 각각 고유의 객체가 만들어지고 Method 영역열거 상수 들은 해당 객체들을 참조하게 된다.


그리고 다음과 같이 변수를 선언했을 때,

Week today = Week.MONDAY;

이미지 출처

today 변수는 Stack 영역 에 선언되고 해당 변수는 Method 영역 에 있는 MONDAY 객체의 주소 값을 복사하므로 결과적으로 Heap 영역 에 생성된 객체를 바라본다는 말이 된다.



싱글톤 (Singleton) 활용

열거 타입으로 싱글톤을 선언하는건 매우 간단하다.

public enum Singleton {
	INSTANCE;
}

코드가 너무나도 간결하다.


우리는 앞서 열거 타입에 정의된 열거 상수 들은 곧 객체 라는 것을 인지하고 있다.
따라서, 다음과 같이 선언되면 객체 를 하나만을 보장할 수 있는 말이 된다.

그리고, 생성자가 없으므로 java Reflection API역직렬화 (deserialization) 에도 안전하다.


📑 정리

  • 코드가 매우 간결하다.
  • java Reflection API에 안전하므로 따로 예외처리를 안해줘도 된다.
  • 역직렬화 과정에서도 안전하므로 따로 readResolve() 메서드를 구현하지 않아도 된다.



🔎 참조

profile
느려도 확실히

0개의 댓글