스프링 컨테이너, 싱글톤 컨테이너

    이 글은 인프런 김영한님의 강의를 복습하며 작성한 글입니다. 

     

    스프링 컨테이너 : Singleton Container

    스프링 컨테이너는 100% 싱글톤 컨테이너는 아니지만, 주로 싱글톤 컨테이너로 사용된다고 한다. 이 때 100% 싱글톤 컨테이너가 아니라는 말은 어떤 Bean Scope를 사용하느냐에 따라 싱글톤 컨테이너가 아닐 수 있다는 것을 의미한다. 예를 들어 ProtoType Scope / Request Scope로 만들어진 Bean은 요청이 올 때마다 생성되기 때문에 싱글톤이 아니다. 

     

    Singleton with Web Application

    웹 어플리케이션은 보통 여러 고객이 동시에 많은 요청을 한다. 롤로 예를 들어보면 한번에 수백 ~ 수천개 이상의 큐가 잡힐 것이다. 고객이 한번에 수백 ~ 수천 번의 요청을 한다는 뜻이다. 이런 웹 어플리케이션의 특성을 객체와 한번 연결지어 생각 해본다.

    요청올 때 마다 생성

    고객이 요청이 올 때마다 객체가 생성되는 상황을 보자. 요청이 올 때 마다 객체를 생성해서 반환해야 하기 때문에 실제 JVM에 존재하는 객체가 많아질 것이고, 메모리 낭비가 아주 심해진다. 또한, 생성하는데 시간도 많이 걸리기 때문에 시간적으로도 효율화가 떨어질 수도 있다. 간단히 이야기해서 요청할 때 마다 객체를 생성하는 것은 비효율적이다. 이런 문제점을 개선하는데 도움이 되는 패턴이 싱글톤(Singleton) 패턴이다.

    싱글톤 패턴은 JVM 내에서 사용되는 인스턴스 객체가 하나를 보장하는 패턴이다. 인스턴스 객체를 하나만 생성하고, 이 객체만으로 필요한 일들을 처리하는 것이다. 즉, 메모리과 생성 효율 관점에서 좋아진다.

     

    기존 DI 컨테이너 싱글톤 여부 확인

    먼저 기존에 사용하던 DI 컨테이너가 정말로 싱글톤 패턴이 아닌지를 코드로 확인해본다.

        void pureContatiner(){
            AppConfig appConfig = new AppConfig();
    
            //1. 조희  : 호출할 때 마다 객체를 생성
            MemberService memberService1 = appConfig.memberService();
            //2. 조희  : 호출할 때 마다 객체를 생성
            MemberService memberService2 = appConfig.memberService();
    
            //참조값이 다른 것을 확인
            System.out.println("memberService1 = " + memberService1);
            System.out.println("memberService2 = " + memberService2);;
    
            //Assertion으로 항상 자동화 되게 만들어야 한다.
            // 둘다 달라야 함.
            assertThat(memberService1).isNotEqualTo(memberService2);
        }

    위 코드를 실행해보면 아래 결과가 나온다. 

    서비스1과 서비스2는 서로 다른 객체인 것이 확인되었다. 기존에 사용하던 DI 컨테이너는 요청이 올 때 마다 객체가 생성되며, 그 객체는 서로 다른 참조를 가지는 것이 확인되었다. 다시 말해서 싱글톤 컨테이너가 아니었다.

     

     

    싱글톤 패턴

    • 싱글톤 패턴은 한 클래스의 인스턴스가 1개만 생성되는 것을 보장하는 디자인 패턴이다.
      • 객체 인스턴스가 2개 이상 생성되지 않도록 막아야 한다.
      • 따라서 생성자를 Pirvate으로 설정해서 외부에서 임의로 생성하지 못하도록 막는다.
    • 싱글톤 객체를 불러오기 위해 Static 메서드를 하나를 생성하고, 이 메서드를 이용해서만 싱글톤 객체를 불러온다.

    싱글톤 객체는 주로 클래스 내에서 Private static으로 설정 후 단 한번만 생성한다. 아래 테스트 코드에서는 싱글톤 패턴으로 객체를 생성한 후, 두 번의 객체 조회를 하고 그 객체가 정말로 같은 것인지 확인을 하는 코드다.

     

    싱글톤 패턴 테스트 코드

        @Test
        @DisplayName("싱글톤 패턴을 적용한 객체 사용")
        void singletonServiceTest(){
            // 1. 객체 하나 조회하기
            SingletonServcie singletonServcie1 = SingletonServcie.getInstance();
            // 2. 객체 하나 조회하기
            SingletonServcie singletonServcie2 = SingletonServcie.getInstance();
    
            // 참조값 같은지 출력값으로 확인하기
            System.out.println("singletonServcie1 = " + singletonServcie1);
            System.out.println("singletonServcie2 = " + singletonServcie1);
    
            // 똑같은 객체인지 확인하기
            Assertions.assertThat(singletonServcie1).isSameAs(singletonServcie2);
        }
        
       
    public class SingletonServcie {
    
        private static final SingletonServcie instance = new SingletonServcie();
    
        public static SingletonServcie getInstance(){
            return instance;
        }
    
        private SingletonServcie() {
        }
    
        public void logic(){
            System.out.println("싱글톤 객체 호출");
        }
    
    }
    • 컴파일 실행 시, JVM의 Static 영역에 객체 인스턴스를 미리 하나 생성해서 올려둔다.
    • 이 객체 인스턴스가 필요하면 오직 getInstance()라는 Static 메서드를 통해서만 호출할 수 있다. 
    • 이 클래스의 네임 스페이스에 속한 객체 인스턴스는 Static 영역에 유일하게 하나만 존재하기 때문에 항상 같은 인스턴스를 반환한다. 

    위 테스트 코드를 실행하면 아래 결과를 볼 수 있다. 메모리에 저장된 주소가 동일하다. 즉, 참조 변수의 이름은 달라도 동일한 참조를 가지고 있다. 싱글톤 패턴이 구현되었다고 볼 수 있다.

     

    싱글톤 패턴의 문제점

    싱글톤 패턴은 객체 재사용으로 인한 장점을 가지지만, 반면 단점도 가진다. 

    1. 구현에 필요한 코드가 많아진다.
    2. DIP를 위반한다.
    3. DIP를 위반하기 때문에 OCP도 위반할 가능성이 높다.
    4. 테스트 하기가 어렵다.
    5. 자식 클래스를 만들기 어렵다. 

    객체 지향 관점에서 봤을 때 가장 큰 문제가 되는 부분은 2 + 3 + 5이다. 이걸 조합해보면 싱글톤 패턴은 유연성이 떨어지는 패턴이라는 것을 확인할 수 있다. 스프링은 싱글톤 패턴을 보완한 '싱글톤 컨테이너'를 제공하면서, 위 싱글톤 패턴의 문제점을 멋지게 해결한 기능을 제공해준다. 

     

    각 단점에 대한 구체적인 예시는 아래에서 살펴본다. 

    구현에 필요한 코드가 많다.

    싱글톤 패턴을 구현하는데 최소 아래의 코드가 들어가야한다. 몇 줄 안된다고 생각할 수도 있지만, 수천개의 싱글톤 객체를 사용해야한다면 이야기는 달라진다. 

    • 각 클래스의 네임 스페이스에 new 키워드로 객체를 넣어야 함.
    • Singleton 객체를 불러오기 위한 static 메서드를 만들어야 함. 
        private static final SingletonServcie instance = new SingletonServcie();
    
        public static SingletonServcie getInstance(){
            return instance;
        }
    
        private SingletonServcie() {
        }

     

    DIP 위반

     

    Singleton 객체는 컴파일 이전 시점부터 이미 개발자가 기존에 지정한 구체가 결정된다. 특정 클래스가 특정 객체를 알고 있다는 것인데 강하게 결합되는 것을 의미한다. 바꿔서 이야기 하면 Depency Injection을 할 수 없다. 

        private static final SingletonServcie instance = new SingletonServcie();
    
        public static SingletonServcie getInstance(){
            return instance;
        }
    
        private SingletonServcie() {
        }

     

    OCP 위반 가능성 존재

    DIP를 위반하기 때문에 OCP를 위반할 가능성도 있다.

        private static final SingletonServcie instance = new SingletonServcie();
    
        public static SingletonServcie getInstance(){
            return instance;
        }
    
        private SingletonServcie() {
        }

    위 코드에서는 OCP를 자연스럽게 위반한다. 왜냐하면 싱글톤 객체를 다른 타입의 구체로 사용할 경우, 설정 정보 이외의 메인 코드들의 변경이 필요하기 때문이다. 

    예를 들어 SingletonService → SingletonService100이라는 객체로 변경되었다고 해보자. 그리고 SingletonService는 Call()이라는 메서드를 가졌지만, SingletonService100은 Call() 메서드가 없다고 한다. 그리고 실제 코드에서는 SingletonService.call()을 했다고 하면, 메인 코드의 변경은 필수적으로 따라온다. 

     

    테스트가 어렵다

    컴파일 이전에 초기값들이 설정되어있다. 따라서 다양항 형태의 테스트를 작성하는 것이 어렵다. 

     

    자식 클래스를 만들기가 어렵다.

    싱글톤 객체는 내부적으로 Private 생성자를 가진다. Private 생성자는 자식 클래스에서 아용할 수 없다. 따라서 확장성 관점에서 좋지 않다. 

     

    싱글톤 컨테이너

    스프링은 싱글톤 패턴의 단점을 보완해서 장점만을 취할 수 있도록 구현된 싱글톤 컨테이너를 제공해준다. 싱글톤 컨테이너는 객체 1개당 1개의 인스턴스만 생성되는 것이 보장된다. 스프링 빈은 대부분 싱글톤 빈으로 생성되고, 스프링 컨테이너에서 관리된다. 대부분이라는 말은 'Bean Scope'를 사용할 경우 싱글톤 빈이 아닌 다른 형태의 빈으로 사용될 수도 있다는 것을 의미한다. 

    1. 스프링 컨테이너는 객체를 싱글톤으로 관리한다.

    @Configuration 클래스 내에서 @Bean 어노테이션을 읽는다. 이 때 메서드 이름에서 가장 앞 글자를 소문자로 바꾼 값(MemberService → memberService)으로 빈 이름을 만든다. 그리고 메서드가 반환하는 객체를 싱글톤 빈으로 만들어서 스프링 컨테이너에 <이름 : 객체> 형식으로 저장한다. 

     

    2. 스프링 컨테이너는 싱글톤 컨테이너 역할을 한다.

    스프링 컨테이너는 대부분 Singleton Scope를 사용하고, 이 경우 객체를 싱글톤으로 관리한다. 

     

    3. 스프링 컨테이너는 싱글톤 패턴의 단점을 개선한다.

    스프링 컨테이너는 Pirvate한 생성자, 초기 설정값을 정하지 않고 싱글톤 객체를 만들어서 싱글톤 컨테이너에서 관리해준다. 따라서 DIP / OCP / 자식 클래스 확장의 단점을 개선하는데 도움이 된다. 

        @Test
        @DisplayName("스프링 컨테이너와 싱글톤")
        void singletonContainerTest(){
    
            // 싱글톤 컨테이너 생성
            ApplicationContext ac = new AnnotationConfigApplicationContext(AppConfig.class);
    
            // 싱글톤 컨테이너로부터 빈 조회
            MemberService memberService1 = ac.getBean("memberService", MemberService.class);
            MemberService memberService2 = ac.getBean("memberService", MemberService.class);
    
            // 같은 참조인지 확인.
            Assertions.assertThat(memberService1).isSameAs(memberService2);
        }
    }

    위의 코드로 스프링 컨테이너를 싱글톤 컨테이너로 활용할 수 있다. 스프링 컨테이너를 활용했을 때와 DI 컨테이너를 사용했을 때를 비교한 그림은 아래처럼 간단하게 나타낼 수 있다. DI 컨테이너는 고객의 요청이 올 때 마다 새로운 객체를 생성해서 반환하는 형태였다. 반면 싱글톤 컨테이너는 기존에 생성된 빈 객체를 요청오면 반환해주는 형태가 된다.

     

     

    싱글톤 방식의 주의점 → Stateless는 필수

    싱글톤은 하나의 객체를 JVM 전체에서 공유한다. 공유하는 자원이라는 이야기인데, 이 때 문제없이 이 객체를 사용하고자 한다면 싱글톤 객체는 반드시 Stateles하게 설계되어야 한다. 예를 들어 Singleton 객체가 상태를 기억하도록 Stateful하게 설계될 경우 클라이언트끼리 의도치 않게 영향을 줄 수 있다. 

    • 특정 클라이언트가 의존적이지 않아야 한다.
    • 특정 클라이언트가 값을 변경할 수 있는 영역이 없어야 한다.
    • 가급적 읽기만 가능해야 한다.
    • 변수가 필요할 경우, 로컬 변수를 선언해서 사용한다

    무상태 설계란 위의 내용을 잘 지켜서 설계하는 것을 의미한다. 

     

    싱글톤 객체, Stateful 문제점 발생 

    Stateful(상태 유지) 형식으로 설계가 되었을 때는 아래와 같은 문제점이 발생할 수 있다.

    • 싱글톤 컨테이너에서 StatefulService 싱글톤 객체를 불러와서, bean1/2에 참조시킴
    • StatefulService는 내부적으로 Stateful한 Price 필드를 가짐. 

    위 상황에서 bean1 / bean2가 각각 order 메서드를 실행한다. 그리고 마지막에 Stateful 변수인 Price에 어떤 값이 들어있는지 확인해봤다. 

     

     @Test
        @DisplayName("싱글톤 공유 했을 때 문제 발생")
        void test1(){
            ApplicationContext ac = new AnnotationConfigApplicationContext(StatefulConfigTest.class);
    
            StatefulServcie bean1 = ac.getBean(StatefulServcie.class);
            StatefulServcie bean2 = ac.getBean(StatefulServcie.class);
    
            bean1.order("userA", 10000);
            bean2.order("userb", 20000);
    
            System.out.println("bean1 = " + bean1 + " price = " + bean1.getPrice());
        }
    
        @Configuration
        static class StatefulConfigTest{
            @Bean
            StatefulServcie statefulServcie(){
                return new StatefulServcie();
            }
    
    
        }
    
    
    
    public class StatefulServcie {
        private int price;
        public void order(String name, int price) {
            System.out.println("name = " + name + " price = " + price);
            this.price = price;
        }
        public int getPrice(){
            return this.price;
        }
    
    }

    실행 결과를 확인해보면 userA의 가격이 10,000이어야 하는데 20,000인 것으로 볼 수 있다. 이는 Bean1 실행 후, Bean2가 Stateful 변수를 변경했기 때문이다. 즉, 이런 문제가 발생할 수 있기 때문에 싱글톤 객체는 반드시 Stateless하게 설계 되어야 한다. 

    댓글

    Designed by JB FACTORY