스프링/기본편

싱글톤 컨테이너

수풀속의고라니 2022. 5. 22. 12:04
728x90

웹 애플리케이션과 싱글톤


 

지금 고객에게서 요청이 올 때마다 같은 요청에 대해서도 하나하나 객체를 따로 만들어서 응답을 해주고 있다. 이점이 문제점이라고 볼 수 있음

 

그리고 이 방식이 이전까지 우리가 만들었던 DI 컨테이너

 

스프링이 없는 순수 DI 컨테이너

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

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

        //참조값이 다른 것을 확인
        System.out.println("memberService1 = " + memberService1);
        //memberService1 = hello.core.member.MemberServiceImpl@66d18979
        System.out.println("memberService2 = " + memberService2);
        //memberService2 = hello.core.member.MemberServiceImpl@bccb269

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

 

 

싱글톤 패턴


 

//test 아래에 만들었음
public class SingletonService {

    //자기 자신을 내부에 private으로 하나 가지고 있음
    //static으로 생성해서 클래스 레벨에 올라가기 때문에 하나만 생성됨
    //자기 자신을 생성해서 instance안에 넣어놓음
    //1. static 영역에 객체를 딱 1개만 생성
    private static final SingletonService instance = new SingletonService();

    //new로 한번만 생성해서 instance에 담아뒀고, 이제 SingletonService를 생성할 수 있는 곳은 없음
    //instance의 참조를 꺼낼 수 있는 방법은 이제 instance를 사용하는 방법 밖에 없음음

    //조회할 때는 얘를 사용
    //2. public으로 열어서 객체 인스터스가 필요하면 이 static 메서드를 통해서만 조회하도록 허용한다.
    public static SingletonService getInstance(){
        return instance;
    }

    //private으로 생성자
    //3. 생성자를 private으로 선언해서 외부에서 new 키워드를 사용한 객체 생성을 못하게 막는다.
    private SingletonService(){

    }

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

 

 

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

    //같은 객체 인스턴스가 반환되는 것을 확인
    System.out.println("singletonService1 = " + singletonService1);
    //singletonService1 = hello.core.singleton.SingletonService@63475ace
    System.out.println("singletonService2 = " + singletonService2);
    //singletonService2 = hello.core.singleton.SingletonService@63475ace

    assertThat(singletonService1).isSameAs(singletonService2);
}

 

위에서 만들었던 SingletonTest 클래스에 위의 로직을 추가하고 실행하면 싱글톤 패턴이 적용된 것을 확인할 수 있다.

 

 

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

 

싱글톤 패턴의 문제점

 

 

싱글톤 컨테이너


 위의 문제점들은 스프링을 사용함으로서 해결할 수 있따. 스프링 컨테이너는 싱글톤 패턴의 문제점을 해결하면서, 객체 인스턴스를 싱글톤(1개만 생성)으로 관리한다.

 지금까지 공부한 스프링 빈이 싱글톤으로 관리되는 빈이다.

 

이전에 

 

 

이런 식으로 스프링 컨테이너에 객체를 미리 저장해 준다는 것을 공부했었는데 호츨될 때 그냥 이 객체를 가져다가 쓰면 되는 것이다.

 

싱글톤 컨테이너

 

스프링 컨테이너를 사용하는 테스트 코드

@Test
@DisplayName("스프링 컨테이너와 싱글톤")
void springContainer(){
    ApplicationContext ac = new AnnotationConfigApplicationContext(AppConfig.class);

    MemberService memberService1 = ac.getBean("memberService", MemberService.class);
    MemberService memberService2 = ac.getBean("memberService", MemberService.class);

    System.out.println("memberService1 = " + memberService1);
    System.out.println("memberService2 = " + memberService2);

    assertThat(memberService1).isSameAs(memberService2);
    //테스트는 성공
    //스프링이 처음에 컨테이너에서 등록한 빈을 반환해 주는 것으로 싱글톤 패턴 맞춰짐
    //실제로 만든 싱글톤 코드는 하나도 없음
}

 

 

 

싱글톤 방식의 주의점


 

상태를 유지할경우 발생하는 문제점 예시

주문자와 가격을 받고 리턴하는 클래스

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;
    }
}

 

테스트 클래스

class StatefulServiceTest {

    @Test
    void statefulServiceSinglton(){

        ApplicationContext ac = new AnnotationConfigApplicationContext(TestConfig.class);
        StatefulService statefulService1 = ac.getBean(StatefulService.class);
        StatefulService statefulService2 = ac.getBean(StatefulService.class);

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

        /*A가 주문을 하고 금액을 조회하려는 사이에 B가 주문을 해버린 상황*/

        //ThreadA : 사용자A 주문 금액 조회
        int price = statefulService1.getPrice();
        System.out.println("price = " + price);
        //10000원이 아니라 20000원 나오게 됨
        //값을 넣는 StatefulService는 같은 객체

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

    static class TestConfig{

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

 

 

특정 클라이언트가 값을 변경할 수 있는 필드가 있으면 안된다고 했었는데, 여기서는 price필드의 값이 변경 가능하게 되어있었음

 

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;
    }
}

 

class StatefulServiceTest {

    @Test
    void statefulServiceSinglton(){

        ApplicationContext ac = new AnnotationConfigApplicationContext(TestConfig.class);
        StatefulService statefulService1 = ac.getBean(StatefulService.class);
        StatefulService statefulService2 = ac.getBean(StatefulService.class);

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

        /*A가 주문을 하고 금액을 조회하려는 사이에 B가 주문을 해버린 상황*/

        //ThreadA : 사용자A 주문 금액 조회
//        int price = statefulService1.getPrice();
        System.out.println("price = " + userAPrice);
        //이제 userB에 얼마를 넣던 userA의 주문 금액이 나오게 됨 - 지역변수는 공유되는 것이 아니기 때문

//        assertThat(statefulService1.getPrice()).isEqualTo(20000);
    }

    static class TestConfig{

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

 

@Configuration과 싱글톤


이상한 점이 하나 있음. 아래의 AppConfig를 보면

 

@Configuration //설정정보 클래스에 달아주는 어노테이션
public class AppConfig {

    //@Bean memberService -> new MemoryMemberRepository()
    //@Bean orderService -> new MemoryMemberRepository()
    
    @Bean
    public MemberService memberService(){
        return new MemberServiceImpl(memberRepository());
    }

    @Bean
    public MemoryMemberRepository memberRepository() {
        return new MemoryMemberRepository();
    }

    @Bean
    public OrderService orderService(){
        return new OrderServiceImpl(memberRepository(), discountPolicy());
    }

    @Bean
    public DiscountPolicy discountPolicy(){
//        return new FixDiscountPolicy();
        return new RateDiscountPolicy();
    }
}

 

 

검증 용도의 코드 추가

//테스트 용도
public MemberRepository getMemberRepository(){
    return memberRepository;
}

 

OrderServiceImpl과 MemberServiceImpl 클래스에 위의 코드를 추가

 

public class ConfigurationSingletonTest {

    @Test
    void configurationTest(){
        ApplicationContext ac = new AnnotationConfigApplicationContext(AppConfig.class);

        MemberServiceImpl memberService = ac.getBean("memberService", MemberServiceImpl.class);
        OrderServiceImpl orderService = ac.getBean("orderService", OrderServiceImpl.class);
        MemberRepository memberRepository = ac.getBean("memberRepository", MemberRepository.class);

        MemberRepository memberRepository1 = memberService.getMemberRepository();
        MemberRepository memberRepository2 = orderService.getMemberRepository();
        System.out.println("memberService -> memberRepository1 = " + memberRepository1);
        //memberService -> memberRepository1 = hello.core.member.MemoryMemberRepository@25e2ab5a
        System.out.println("orderService -> memberRepository2 = " + memberRepository2);
        //memberService -> memberRepository1 = hello.core.member.MemoryMemberRepository@25e2ab5a
        System.out.println("memberRepository = " + memberRepository);
        //memberService -> memberRepository1 = hello.core.member.MemoryMemberRepository@25e2ab5a

        //new가 여러번 실행되었다고 생각했지만 출력 결과를 보면 같은 인스턴스를 사용하고 있는 것을 확인할 수 있음

        Assertions.assertThat(memberService.getMemberRepository()).isSameAs(memberRepository);
        Assertions.assertThat(orderService.getMemberRepository()).isSameAs(memberRepository);
    }
}

 

그리고 위와 같은 검증 로직을 만들어서 실행해보면 결과는 생각과는 다르게 싱글톤이 지켜지고 있음을 확인 가능

 

 

AppConfig에 호출 로그 남김

@Configuration //설정정보 클래스에 달아주는 어노테이션
public class AppConfig {

    //@Bean memberService -> new MemoryMemberRepository()
    //@Bean orderService -> new MemoryMemberRepository()

    /*아래와 같은 횟수로 호출되어야 한다고 생각되어 진다.*/
    //call AppConfig.memberService
    //call AppConfig.memberRepository
    //call AppConfig.memberRepository
    //call AppConfig.orderService
    //call AppConfig.memberRepository
    
    /*하지만 실제로 실행된 결과를 보면*/
    //call AppConfig.memberService
    //call AppConfig.memberRepository
    //call AppConfig.orderService
    //이렇게 memberRepository가 한번만 실행이 된 것을 확인할 수 있다.

    @Bean
    public MemberService memberService(){
        System.out.println("call AppConfig.memberService");
        return new MemberServiceImpl(memberRepository());
    }

    @Bean
    public MemoryMemberRepository memberRepository() {
        System.out.println("call AppConfig.memberRepository");
        return new MemoryMemberRepository();
    }

    @Bean
    public OrderService orderService(){
        System.out.println("call AppConfig.orderService");
        return new OrderServiceImpl(memberRepository(), discountPolicy());
    }

    @Bean
    public DiscountPolicy discountPolicy(){
//        return new FixDiscountPolicy();
        return new RateDiscountPolicy();
    }
}

 

 

@Configuration과 바이트코드 조작의 마법


 

@Test
void configurationDeep(){
    ApplicationContext ac = new AnnotationConfigApplicationContext(AppConfig.class);
    AppConfig bean = ac.getBean(AppConfig.class);

    System.out.println("bean = " + bean.getClass());
    //bean = class hello.core.AppConfig$$EnhancerBySpringCGLIB$$48c26325
    //class hello.core.AppConfig 까지만 출력되는 것이 정상인데 뒤에 더 붙어있음
}

 

@Configuration 검증을 했던 테스트 파일에 위의 코드를 넣고 실행하면 저러한 결과가 나오게 된다.

 

 

 

CGLIB로 조작된 AppConfig를 빈으로 등록하게 되고, 등록된 빈은 이름만 AppConfig이고, 다른 클래스

 

 

@Bean이 붙은 메서드마다 이미 스프링 빈이 존재하면 존재하는 빈을 반환하고, 스프링 빈이 없으면  생성해서 스프링 빈으로 등록하고 반환하는 코드가 동적으로 만들어진다. 덕분에 싱글톤이 보장되는 것이다.

 

즉, 최초 실행해서 컨테이너에 스프링 빈이 없으면 내가 생성한 것을 등록해주고, 이미 등록이 되어 있다면 컨테이너에 있는 것을 꺼내서 반환하는 것

 

 

빈으로는 모두 등록이 되지만 아래의 결과를 보면 싱글톤 원리가 깨지는 것을 확인할 수 있다.

 

 

순수한 AppConfig 클래스가 호출되고, 모두 다 new로 새로 생성하는 것을 볼 수 있음

 

 

크게 고민할 것이 없다. 스프링 설정 정보는 항상 @Configuration 을 사용하자.

728x90