-> 블로그 이전

[Spring - 기본] 싱글톤 컨테이너

2022. 5. 14. 17:36Language`/Spring

웹 애플리케이션 & 싱글톤

웹 애플리케이션 거의 대부분 여러 user가 동시에 접속한다

여러명의 고객이 서비스에 join을 하기 위해 서로 memberService를 호출하게 된다면??

 

만약 고객 트래픽이 초당 100이 나온다면 초당 100개의 memberService 객체가 생성되고 소멸된다

>> 이것은 굉장한 메모리 낭비이다

 

이러한 문제점을 해결하는 간단한 방법은 "해당 객체를 1개만 생성하고 여러 user가 공유하도록 설계"하면 된다. 이러한 방식을 "싱글톤 패턴"이라고 한다

 


싱글톤 패턴

싱글톤 패턴은 클래스의 인스턴스가 딱 1개만 생성되는 것을 보장하는 디자인 패턴이다

따라서 객체의 인스턴스가 2개 이상 생성되지 못하도록 막아줘야 한다

  • 생성자를 private로 선언해서 외부에서 임의로 new를 통해서 인스턴스를 생성하지 못하도록 막아주기
public class SingletonService {
    // 객체를 미리 생성해두는 가장 단순하고 안전한 방법
    private static final SingletonService instance = new SingletonService();

    private SingletonService(){

    }

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

이렇게 SingletonService 객체에 대한 인스턴스(instance)를 미리 생성해두어서 외부에서는 "getInstance()"를 통해서만 미리 생성된 인스턴스를 공유하면서 사용할 수 있다

 

싱글톤 패턴을 적용하면 요청에 대해서 매번 객체를 생성하는 것이 아니라 이미 만들어진 객체를 공유함에 따라서 효율적으로 사용할 수 있다.

하지만 이러한 싱글톤 패턴도 수많은 문제점들을 보유하고 있다

1. 싱글톤 패턴을 구현하는 코드 자체를 생성해야 한다
2. 의존관계상 클라이언트가 싱글톤 구현 클래스에 의존한다 (DIP 위반 / OCP 위반)
3. 테스트하기 힘들다
4. 내부 속성을 변경하거나 초기화하기 힘들다
5. private 생성자이므로 자식 클래스를 만들기 힘들다
6. 유연성이 떨어진다
>> 싱글톤 패턴은 "안티패턴"이라고 불리기도 한다

 

그리고 싱글톤 방식을 사용할 때 가장 주의해야 하는점이 "Stateless로 설계해라"이다

만약 싱글톤 객체를 "Stateful"하게 설계한다면 다음과 같은 문제점이 발생할 수 있다

public class StatefulService {
    private int price; // Stateful Field

    public void order(String name, int price){
        System.out.println("name : " + name + " / price : " + price);
        this.price = price;
    }

    public int getPrice(){
        return price;
    }
}
class StatefulServiceTest {

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

    @org.junit.jupiter.api.Test
    @DisplayName("Stateful Test")
    void statefulServiceSingleton(){
        ApplicationContext ac =
                new AnnotationConfigApplicationContext(Test.class);

        StatefulService s1 = ac.getBean(StatefulService.class);
        StatefulService s2 = ac.getBean(StatefulService.class);

        s1.order("userA", 100000);
        s2.order("userB", 3000000);

        int price1 = s1.getPrice();
        int price2 = s2.getPrice();

        System.out.println("userA : " + price1);
        System.out.println("userB : " + price2);

        Assertions.assertThat(price1).isEqualTo(100000);
    }

}

order()부분에서 "this.price = price"를 통해서 Stateful하게 설계를 하였다

 

그리고 밑에 Test에서 userA는 10만원짜리 물건을 주문했고, userB는 300만원짜리 물건을 주문했다

그리고 각 user별로 구매한 가격을 출력했더니 다음과 같은 결과가 도출되었다

실제로 userA는 10만원짜리를 샀는데 Stateful하게 설계된 price때문에 나중에 order한 userB의 가격으로 update되었다

 

이러한 문제가 실무 전산 시스템에서 발생한다면 시스템에 굉장히 큰 악영향을 미칠 것이다

 

따라서 싱글톤 객체는 Stateless로 설계해야 한다

특정 클라이언트에 의존적인 필드가 있으면 안된다
특정 클라이언트가 값을 변경할 수 있는 필드가 있으면 안된다
되도록이면 읽기만 가능해야 한다
필드 대신에 자바에서 공유되지 않는 {지역변수, 파라미터, ThreadLocal, ...}등을 사용해야 한다

@Configuraion & 싱글톤

@Configuration
public class AppConfig {
    @Bean
    MemberRepository memberRepository(){
        System.out.println("memberRepository 스프링 빈 등록!!");
        return new MemoryMemberRepository();
    }

    @Bean
    MemberService memberService(){
        System.out.println("memberService 스프링 빈 등록!!");
        return new MemberServiceImpl(memberRepository());
    }

    @Bean
    DiscountPolicy discountPolicy(){
        System.out.println("discountPolicy 스프링 빈 등록!!");
        return new FixedDiscountPolicy();
    }

    @Bean
    OrderService orderService(){
        System.out.println("orderService 스프링 빈 등록!!");
        return new OrderServiceImpl(memberRepository(), discountPolicy());
    }
}

이 코드를 보게되면 다음과 같은 호출 횟수를 예상할 수 있다

MemberRepository : 3번
DiscountPolicy : 2번
MemberService : 1번
OrderService : 1번

그런데 실제 실행 결과는 다음과 같다

왜 이러한 결과가 나왔는지 직접 등록된 빈을 조회해보자

@Test
@DisplayName("등록된 빈 조회")
void test(){
    AnnotationConfigApplicationContext ac = new AnnotationConfigApplicationContext(AppConfig.class);
    AppConfig appConfig = ac.getBean(AppConfig.class);
    MemberRepository memberRepository = ac.getBean(MemberRepository.class);
    MemberService memberService = ac.getBean(MemberService.class);
    OrderService orderService = ac.getBean(OrderService.class);
    DiscountPolicy discountPolicy = ac.getBean(DiscountPolicy.class);

    System.out.println("appConfig = " + appConfig.getClass());
    System.out.println("memberRepository = " + memberRepository.getClass());
    System.out.println("memberService = " + memberService.getClass());
    System.out.println("orderService = " + orderService.getClass());
    System.out.println("discountPolicy = " + discountPolicy.getClass());
}

Spring Container는 싱글톤 레지스트리로써 스프링 빈이 싱글톤이 되도록 보장해줘야 한다.

하지만 스프링이 자바 코드까지 바꿔가면서 어떻게 하기는 사실상 불가능하다

따라서 자바 코드만 봤을 때는 위와 같은 예상한 호출횟수대로 호출되는 것이 당연하게 보인다

>> Spring은 싱글톤 보장을 위해서 "클래스의 바이트코드를 조작하는 라이브러리(CGLIB)"를 사용한다

위의 결과를 보게되면 스프링 설정 클래스의 getClass()를 조회한 결과로 "EnhancerBySpringCGLIB"라는 것이 붙게 되었다

여기서 CGLIB라는 바이트코드 조작 라이브러리를 사용해서 AppConfig 클래스를 상속받은 Proxy를 만들고, Proxy를 스프링 빈으로 등록한다

 

여기서 @Configuration이라는 기능 덕분에 하위의 @Bean들은 전부 "Singleton"으로 관리가 된다

  • CGLIB는 먼저 스프링 컨테이너를 뒤져서 해당 빈이 등록되어있는지 확인
    • 등록되어 있으면 등록된 빈 return
    • 등록되지 않았으면 해당 빈 등록하고 return
@Test
@DisplayName("동일한 스프링 빈 2번 조회")
void test(){
    AnnotationConfigApplicationContext ac = new AnnotationConfigApplicationContext(AppConfig.class);
    MemberRepository memberRepository1 = ac.getBean(MemberRepository.class);
    MemberRepository memberRepository2 = ac.getBean(MemberRepository.class);

    System.out.println("memberRepository1 = " + memberRepository1);
    System.out.println("memberRepository2 = " + memberRepository2);
    System.out.println("isSame? = " + (memberRepository1 == memberRepository2));
}

결과를 보면 MemberRepository 타입의 스프링 빈을 2번 조회한 경우 동일한 빈이 조회됨을 알 수 있고 따라서 "싱글톤"으로 관리된다는 사실도 알 수 있다

>> 여기서 만약에 @Configuration이 없다면 어떤 현상이 발생할까?

@Configuration이 없어도 스프링 빈은 정상적으로 등록되지만 "싱글톤으로 관리되지 않는다"

  • 각 빈을 호출할 때마다 필요한 빈 객체들이 추가적으로 호출됨을 확인할 수 있다