나의 브을로오그으

#5-1. [스프링 핵심 원리-기본편] - 싱글턴 패턴 본문

Spring

#5-1. [스프링 핵심 원리-기본편] - 싱글턴 패턴

__jhp_+ 2022. 7. 21. 07:26

웹 애플리케이션과 싱글톤

- 스프링은 태생이 기업용 온라인 서비스 기술 지원을 위해 탄생했다.

- 대부분의 스프링 앱은 웹 앱이다. 물론 웹이 아닌 애플리케이션 개발도 얼마든지 가능하다.

- 웹 앱은 보통 여러 고객이 동시 요청을 한다. 

[BeanDefinitionTest]

package hello.core.beandefinition;

import hello.core.order.AppConfig;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.config.BeanDefinition;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import org.springframework.context.support.GenericXmlApplicationContext;

public class BeanDefinitionTest {
    AnnotationConfigApplicationContext ac = new AnnotationConfigApplicationContext(AppConfig.class);
//    GenericXmlApplicationContext ac = new GenericXmlApplicationContext("appConfig.xml");

    @Test
    @DisplayName("빈 설정 메타정보 확인")
    void findApplicationBean() {
        String[] beanDefinitionNames = ac.getBeanDefinitionNames();
        for (String beanDefinitionName : beanDefinitionNames) {
            BeanDefinition beanDefinition = ac.getBeanDefinition(beanDefinitionName);

            if (beanDefinition.getRole() == BeanDefinition.ROLE_APPLICATION) {
                System.out.println("beanDefinitionName = " + beanDefinitionName +
                        " beanDefinition = " + beanDefinition);
            }
        }
    }
}

- 우리가 만들었던 스프링 없는 순수한 DI컨테이너인 AppConfig는 요청 시 객체를 새로 생성한다.

- 고객 트래픽이 초당 100이 나오면 초당 100개 객체가 생성, 소멸된다 -> 메모리 낭비 심함

- 해결방안은 해당 객체가 딱 1개만 생성되고, 공유하도록 설계하면 된다. -> 싱글턴 패턴

 

 

싱글턴 패턴

- 클래스의 인스턴스가 딱 1개만 생성되는 것을 보장하는 디자인 패턴

- 객체 인스턴스가 2개 생성되지 않도록 막기만 하면 됨.

package hello.core.singleton;

public class SingletonService {
    private static final SingletonService instance = new SingletonService();

    public static SingletonService getInstance() { return instance; }

    private SingletonService() { }

    public void logic() {
        System.out.println("싱글톤 객체 로직 호출");
    }
}

1. static 영역에 객체 instance를 미리 하나 생성한다.

2. 이 객체 인스턴스가 필요하면 오직 getInstance() 메소드를 통해서만 조회 가능하다. 이 메서드를 호출하면 항상 같은 인스턴스를 반환한다.

3. 딱 1개의 객체 인스턴스만 존재해야 하므로, 생성자를 private으로 막아서 혹시라도 외부에서 new 키워드로 객체 인스턴스가 생성되는 것을 막는다.

 

[SingletonTest]

package hello.core.singleton;

import hello.core.member.MemberService;
import hello.core.order.AppConfig;
import org.assertj.core.api.Assertions;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;

public class SingletonTest {

    @Test
    @DisplayName("스프링 없는 순수 DI 컨테이너")
    void pureContainer() {
        AppConfig appConfig = new AppConfig();
        // 1. 조회 : 호출할 때 마다 객체를 생성
        MemberService memberService_1 = appConfig.memberService();

        // 2. 조회 : 호출할 때 마다 객체를 생성
        MemberService memberService_2 = appConfig.memberService();

        // memberService1 != memberService2
        Assertions.assertThat(memberService_1).isNotSameAs(memberService_2);
    }

    @Test
    @DisplayName("싱글턴 패턴을 적용한 객체 사용")
    void singletonServiceTest() {
        SingletonService singletonService_1 = SingletonService.getInstance();
        SingletonService singletonService_2 = SingletonService.getInstance();

        Assertions.assertThat(singletonService_1).isSameAs(singletonService_2);
        // same == 실제 자바 객체의 할당된 값이 같은지 비교
        // equal == equals 메소드를 오버라이드 가능, 그것을 호출
    }
}

- private으로 new 키워드를 막아둔다.

- 호출할 때 마다 같은 객체 인스턴스를 반환하는 것을 확인 가능하다.

 

※참고 : 싱글턴 패턴 구현 방법은 다양하다. 그중에서 가장 단순하고 안전한 방법으로 구현하였다.

 

싱글턴 패턴을 작용하면 고객 요청이 올 때 마다 객체를 생성하는 것이 아니라, 이미 만들어진 객체를 공유해서 효울적으로 사용 가능하다. 하지만 싱글턴 패턴은 다음의 문제점들이 있다.

 

싱글턴 패턴 문제점

- 싱글턴 패턴을 구현하는 코드 자체가 많아진다.

- 의존관계상 클라이언트가 구체 클래스에 의존한다. -> DIP 위반

- 클라이언트가 구현 클래스에 의존해서 OCP 원칙을 위반할 가능성이 높다.

- 테스트하기 어렵다.

- 내부 속성을 변경하거나 초기화 하기 어렵다.

- private 생성자로 자식 클래스를 만들기 어렵다.

- 결론적으로 유연성이 떨어진다.

- 안티패턴으로 불리기도 한다.

 

그렇다면, Service, Repository 등 모두 공통으로 쓰는 객체이므로 싱글톤으로 생성해서 사용하면 된다.

그러나!!! 스프링에서는 이렇게 할 필요가 없다. 스프링 컨테이너가 기본적으로 객체를 싱글턴으로 생성해서 관리해준다.

 

 

싱글턴 컨테이너

- 스프링 컨테이너는 싱글턴 패턴의 문제점을 해결하면서, 객체 인스턴스를 싱글턴(1개만 생성)으로 관리한다. 지금까지 우리가 학습한 스프링 빈이 바로 싱글톤으로 관리하는 빈이다.

 

- 싱글턴 컨테이너는 싱글턴 패턴을 적용하지 않아도, 객체 인스턴스를 싱글턴으로 관리한다.

- 이전에 설명한 컨테이너 생성 과정을 자세히 보자. 컴테이너는 객체를 하나만 생성해서 관리한다.

 

 

싱글턴 방식의 주의점

- 싱글턴 패턴이든, 싱글턴 컨테이너를 사용하든, 싱글턴 객체는 여러 클라이언트에서 공유해서 사용하기 때문에 객체의 상태를 유지(stateful)하게 설계하면 안된다.

- 무상태(stateless)로 설계해야 한다.

  * 특정 클라이언트에 의존적인 필드가 있으면 안된다.

  * 특정 클라이언트가 값을 변경할 수 있는 필드가 있으면 안된다!

  * 가급적 읽기만 가능해야 한다.

  * 필드 대신에 자바에서 공유되지 않는, 지역변수, 파라미터, ThreadLocal 등을 사용해야 한다.

 

[StatefulServiceTest]

package hello.core.singleton;

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

class StatefulServiceTest {

    @Test
    @DisplayName("스테이트가 변경되는 필드를 가진 싱글톤 객체 테스트")
    void statefulServiceSingleton() {
        AnnotationConfigApplicationContext ac = new AnnotationConfigApplicationContext(TestConfig.class);
        StatefulService statefulService_1 = ac.getBean(StatefulService.class);
        StatefulService statefulService_2 = ac.getBean(StatefulService.class);

        /*
        시나리오 : 사용자 A가 10000원에 주문을 하고 가격을 확인하는데
        그 사이에 사용자 B가 20000원에 주문을 해버렸다. 이러면 price는 20000원이 되었으므로
        잘못된 싱글턴 객체 설계이다.
         */
        // ThreadA : A 사용자가 10000원 주문
        statefulService_1.order("userA", 10000);

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

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

        Assertions.assertThat(statefulService_1.getPrice()).isEqualTo(20000);
    }

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

}

- 실제로 테스트해보려면 쓰레드를 이용해서 해보는게 가장 정확하다.

- ThreadA가 사용자A 코드를 호출하고, ThreadB가 사용자B 코드를 호출한다고 가정하자.

- StatefulService의 price필드는 공유되는 필드인데, 특정 클라이언트가 값을 변경한다.

- 사용자A의 주문금액은 10000원이 되어야 하는데, 20000원이라는 결과가 나왔다.

- 진짜 공유되는 필드는 조심히 사용해야 한다.

 

[해결책]

[StatefulService]

package hello.core.singleton;

public class StatefulService {

//    private int price; // 상태를 유지하는 필드

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

[StatefulServiceTest]

package hello.core.singleton;

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

class StatefulServiceTest {

    @Test
    @DisplayName("스테이트가 변경되는 필드를 가진 싱글톤 객체 테스트")
    void statefulServiceSingleton() {
        AnnotationConfigApplicationContext ac = new AnnotationConfigApplicationContext(TestConfig.class);
        StatefulService statefulService_1 = ac.getBean(StatefulService.class);
        StatefulService statefulService_2 = ac.getBean(StatefulService.class);

        /*
        시나리오 : 사용자 A가 10000원에 주문을 하고 가격을 확인하는데
        그 사이에 사용자 B가 20000원에 주문을 해버렸다. 이러면 price는 20000원이 되었으므로
        잘못된 싱글턴 객체 설계이다.
         */
        // ThreadA : A 사용자가 10000원 주문
        int userAPrice = statefulService_1.order("userA", 10000);

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

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

        Assertions.assertThat(userAPrice).isEqualTo(10000);
    }

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

}

다시한번 스프링 빈(싱글턴 객체)은 무상태를 유지하자!