JAVA

[JAVA] 싱글톤 구현 방식

경딩 2024. 11. 21. 23:07

싱글톤의 여러가지 구현 방식과 주의점을 알아보자.

 

싱글톤 패턴이란?

인스턴스를 오직 한 개만 제공하는 클래스입니다.

 

시스템 런타임, 환경 세팅에 대한 정보 등, 인스턴스가 여러 개 일 때 문제가 생길 수 있는 경우가 있다. 

인스턴스를 오직 한개만 만들어 제공하는 클래스가 필요하다.

 

싱글톤 구현 방식 1. private 생성자 및 static 메서드 사용

public class Settings {
    private static Settings instance;


    // 클래스 밖에서 인스턴스 생성 방지
    private Settings(){}
    public static Settings getInstance(){
        if(instance == null) {
            return instance = new Settings();
        }
        return instance;
    }
    
}

 

해당 방식은 멀티 쓰레드 환경에서 안전하지 않다.

동시에 두개의 스레드가 접근해 new를 동시에 실행한다면 두 스레드가 가진 인스턴스는 달라질 것이다.

 

싱글톤 구현 방식 2. private 생성자 및 static 메서드 사용 + synchronized 

    private static Settings instance;


    // 클래스 밖에서 인스턴스 생성 방지
    private Settings(){}
    public static synchronized Settings getInstance(){
        if(instance == null) {
            return instance = new Settings();
        }
        return instance;
    }

 

synchronized 키워드를 사용하면 멀티쓰레드 환경에서 안전하다.

하지만 쓰레드가 기다리게 되는 병목현상이 나타날 우려도 있다.

자세힌 알고 싶으면 synchronized에

 

 

싱글톤 구현 방식 3. 이른 초기화(eager initalization) 사용하기

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


    // 클래스 밖에서 인스턴스 생성 방지
    private Settings(){}
    public static Settings getInstance(){
        return INSTANCE;
    }
   
 }

 

이른 초기화는 클래스가 로딩되는 시점에 미리 만들어 놓은 객체를 리턴해주면 되기 때문에 멀티 스레드 환경에서 안전한다.

대부분의 상황에서는 일반적인 초기화가 지연 초기화보다 낫다. [effective java 참고]

만약 해당 인스턴스를 만드는 과정이 굉장히 길고 오래 걸리고 메모리를 많이 사용함에도 불구하고 해당 객체가 잘 쓰이지 않는 상황일 때 지연초기화를 쓰는 것이 좋다.

 

싱글톤 구현 방식 4. double checked locking 으로 효율적인 동기화 블록 만들기

public class Settings {
    private static volatile Settings instance;


    // 클래스 밖에서 인스턴스 생성 방지
    private Settings(){}
    public static Settings getInstance(){
        if (instance == null) {
            synchronized (Settings.class) {
                if (instance == null) {
                    instance = new Settings();
                }
            }
        }
        return instance;
    }
  }

 

객체 생성을 나중에 하고 싶지만 Synchronized block 이 비용이 신경쓰인다면 다음과 같이 구현하면 된다.

인스턴스가 이미 있는 경우 Synchronized 를 skip 하게 되어 Synchronized block을

동시에 멀티스레드가 들어오는 경우에만 실행된다.

모든 스레드가 Synchronized에

그리고 인스턴스를 필요로 하는 시점에서만 만들 수 있다.

 

싱글톤 구현 방식 5. static inner 클래스 사용하기

public class Settings {

    // 클래스 밖에서 인스턴스 생성 방지
    private Settings(){}

    
    static class SettingsHolder {
        public static final Settings INSTANCE = new Settings();
    }


    public static Settings getInstance(){
        return SettingsHolder.INSTANCE;
    }
}

 

해당 코드는 멀티스레드 환경에서 안전하고 getInstance 호출 시 SettingsHolder 가 로딩이 되며 인스턴스를 생성하기 때문에  lazy loading 이 된다는 장점이 있다.

 

더보기

static inner 클래스 사용하기 

이 방식으로 싱글턴 객체를 생성할 땐 어떻게 하나의 객체만 생성된다는걸 보장할 수 있을까?

 

클래스 참조시 클래스 로딩이 발생하며 클래스 초기화 과정이 이루어집니다.
(jvm 스펙에 따르면 ) 클래스는 로딩 및 초기화 과정이 딱 한번만 수행됩니다. 따라서 스레드 세이프하여 하나의 객체만 생성된다는 것을 보장할 수 있습니다.
또한 final 키워드가 있어 참조를 변경할 수 없기에 불변입니다.


싱글톤 패턴 구현을 깨트리는 방법

1. 리플렉션으로 싱글톤 깨뜨리기

 

package singleton;

import java.lang.reflect.Constructor;
import java.lang.reflect.InvocationTargetException;

public class App {
    public static void main(String[] args) throws NoSuchMethodException, InvocationTargetException, InstantiationException, IllegalAccessException {
        Settings settings1 = Settings.getInstance();


        Constructor<Settings> constructor = Settings.class.getDeclaredConstructor();
        constructor.setAccessible(true);
        Settings settings = constructor.newInstance();

        System.out.println(settings1 == settings);
    }
}

리플렉션을 통해 new을 활용한 것과 같은 인스턴스를 생성해 새로운 인스턴스를 만드는 것을 확인할 수 있다.

리플렉션으로 꺼내온 객체는 싱글톤이 깨지는 것을 확인할 수 있다.

 

 

2. 직렬화 , 역직렬화 활용

자바에는 오브젝트를 파일 형태로 디스크에 저장해 놨다가 다시 읽어드리는 직렬화, 역직렬화를 지원한다.

public class Settings implements Serializable { // 직렬화를 위해 Serializable 구현

 

package singleton;

import java.io.*;

public class App {
    public static void main(String[] args) throws Exception {
        Settings settings1 = Settings.getInstance();
        Settings settings2 = null;

        // 직렬화 작업
        try (ObjectOutput out = new ObjectOutputStream(new FileOutputStream("setting.obj"))) {
            out.writeObject(settings1);
        }

        // 역직렬과 작업
        try (ObjectInput in = new ObjectInputStream(new FileInputStream("setting.obj"))) {
            settings2 = (Settings)in.readObject();
        }

        System.out.println(settings1);
        System.out.println(settings2);

        System.out.println(settings1 == settings2);
    }
}

 

직렬화 역직렬화를 통해서도 싱글톤을 깨뜨릴 수 있다.

 

역직렬화시 싱글톤을 보장하기 위해 역직렬화 시 쓰이는 readResolve 메서드를 다음과 같이 정의해 주면 싱글톤을 보장할 수 있다.

 

    protected Object readResolve() {
        return getInstance();
    }
package generic.sigleton;

import java.io.Serializable;

public class Settings implements Serializable {

    private static final Settings INSTANCE = new Settings();


    private static class SettingsHoler {
        private static final Settings INSTANCE = new Settings();
    }
    public static Settings getInstance(){
        return SettingsHoler.INSTANCE;
    }

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

}

 

안전하고 단순하게 싱글톤 구현하기

 

리플렉션을 통해 싱글톤을 깨지는 것을 방지하는 방법은 없을까?

 

 

enum 타입 방식의 싱글턴 - 바람직한 방법

package singleton;

public enum Settings {

    INSTACE;
}

enum 클래스는 내부 코드적으로 리플렉션 생성을 막아놓았다.

단점은 클래스 로딩 순간 해당 인스턴스가 미리 만들어진다는 것이다. 객체 생성 비용이 비싸지 않는다면 가장 안전한 방법이다. 

enum 은 기본적으로 Serializable 를 상속받고 있고 별다른 조치를 취하지 않아도 안전하게 동일한 인스턴스로 역직렬화가 된다.

 

 

 

  • 자바에서 enum 을 사용하지 않고 싱글톤을 구현하는 방법은?
    • private 생성자를 만들고 static 메서드를 사용하여 싱글톤을 구현한다.
  • private 생성자와 static 메서드를 사용하는 방법의 단점은?
    • 지연 로딩 시 멀티 스레드에 안전하기 위해 추가 작업이 필요하다.
    • 리플렉션으로 싱글톤을 깨뜨릴 수 있다.
  • enum을 사용해 싱글톤 패턴을 구현하는 방법의 장점과 단점은?
    • 리플렉션, 역직렬화로  싱글톤을 파괴하는 객체 생성을 방지할 수 있다.
    • 객체가 미리 생성된다.
  • static inner 클래스를 사용해 싱글톤 패턴을 구현하라.

싱글톤 패턴 문제점

  • 싱글톤 패턴을 구현하는 코드 자체가 많이 들어간다.
  • 외존관계상 클라이언트가 구체 클래스에 의존한다 -> DIP 를 위반한다.
    • 구체클래스.getInstance() 와 같이 꺼내야함
  • 클라이언트가 구체클래스의 의존해서 OCP 원칙을 위반할 가능성이 높다.
  • 테스트 하기 어렵다. (인스턴스를 미리 박아서 설정이 이미 코드에서 끝남.)
  • private 생성자로 자식 클래스를 만들기  어렵다.
  • 내부 속성을 변경하거나 초기화하기 어렵다.
  • 결론적으로 유연성이 떨어진다. (구체클래스.getInstances 를 해줘야하기 때문에 유연성이 떨어짐, DI 불가)

그러나스프링에서는 싱글톤의 단점을 다 제거한 싱글톤 컨테이너를 사용할 수 있다.


싱글톤 방식의 주의점

  • 싱글톤 패턴이든, 스프링 같은 싱글톤 컨테이너를 사용하든, 객체 인스턴스를 하나만 생성해서 공유하는 싱글톤 방식은 여러 클라이언트가 하나의 같은 객체 인스턴스를 공유하기 때문에 싱글톤 객체는 상태를 유지(stateful) 하게 설계하면 안된다.
  • 무상태(stateless) 로 설계해야한다 
    • 특정 클라이언트에 의존적인 필드가 있으면 안된다.
    • 특정 클라이언트가 값을 변경할 수 있는 필드가 있으면 안된다.
    • 가급적 읽기만 가능해야 한다.
    • 필드 대신에 자바에서 공유되지 않은, 지역변수, 파라미터, ThreadLocal 등을 사용해야한다.
  • 스프링 빈의 필드에 공유 값을 설정하면 큰 장애로 이어질 수 있다.
public class StatefulService {
    private int price;// 상태를 유지하는 필드

    public void order(String name, int price) {
        System.out.println("name = " + name + ", price = " + price);
        this.price = price; // 여기가 문제
    }

    public int getPrice() {
        return price;
    }
}

 

 

import org.assertj.core.api.Assertions;
import org.junit.jupiter.api.Test;
import org.springframework.context.ApplicationContext;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import org.springframework.context.annotation.Bean;

import static org.junit.jupiter.api.Assertions.*;

class StatefulServiceTest {
    @Test
    void statefulServiceSingleton() {
        ApplicationContext ac = new AnnotationConfigApplicationContext(StatefulService.class);
        StatefulService statefulService1 = ac.getBean(StatefulService.class);
        StatefulService statefulService2 = ac.getBean(StatefulService.class);

        // ThreadA : A 사용자가 10000 원 주문
        statefulService1.order("userA", 10000);

        // ThreadB : B 사용자가 10000 원 주문
        statefulService1.order("userB", 20000);

        //  ThreadA : 사용자A 주문 금액 조회
        int price = statefulService1.getPrice();
        System.out.println("price = " + price);

        Assertions.assertThat(statefulService1.getPrice()).isEqualTo(20000);

    }

    static class TestConfig{
        @Bean
        public StatefulService statefulService() {
            return new StatefulService();
        }
    }

}

 

A 사용자가 만원을 주문하였지만 가격조회 결과 2만원으로 바뀐것을 볼 수 있다.

A 와 B 는 같은 객체를 사용하므로 A 사용자가 만원으로 셋팅하였어도 B 사용자가 2만원을 셋팅하였기 때문에

결과적으로 A,B 는 2만원의 결과를 갖는다.

 현재 StatefulSercice 의 price 는 공유되는 필드이다. 특정 클라이언트가  값을 변경하였기 때문에 발생한  문제다.

공유 필드를 조심해야한다. 스프링은 항상 무상태로 (stateless) 로 설계하자.

 

public class StatefulService {


    public int order(String name, int price) {
        System.out.println("name = " + name + ", price = " + price);
        return price;
    }

}

 

공유 변수를 없애고 지역변수를 사용했다.

즉 공유 필드를 없애버렸다.

    @Test
    void statefulServiceSingleton() {
        ApplicationContext ac = new AnnotationConfigApplicationContext(StatefulService.class);
        StatefulService statefulService1 = ac.getBean(StatefulService.class);
        StatefulService statefulService2 = ac.getBean(StatefulService.class);

        // ThreadA : A 사용자가 10000 원 주문
        int userAPrice = statefulService1.order("userA", 10000);

        // ThreadB : B 사용자가 10000 원 주문
        int userBPrice = statefulService1.order("userB", 20000);

        //  ThreadA : 사용자A 주문 금액 조회
        System.out.println("price = " + userAPrice);

    }

 

 

실행결과 의도한 A 가 주문한 만원이 잘 나온것을 확인 할 수 있다.

스레도별로 독립적인 지역변수를 가지기 때문에(공유 X ) B 의 실행결과가 A 의 변수의 영향을 주지 않는다.

즉 상태를 가지지 않게 설계하였다.

 

 

 

참고 자료

GoF 의 디자인 패턴, 이팩티브 자바