도림.로그

Tags

Series

About

[Spring 공부] Spring 핵심 원리 - 4

2022. 06. 11.

Spring Study

▶ Expand post list

    Spring에 대해 처음 공부하는 사람이 정리를 목적으로 작성한 글입니다. 오개념 등 잘못된 부분이 있을 경우 댓글로 가감없이 지적해주세요! 확인하는 대로 정확한 정보를 기반해 빠르게 수정할 수 있도록 하겠습니다.

    개요

    이번 Spring Boot 스터디는 배달의 민족의 김영한님께서 인프런에 올려주시는 자바 스프링 완전 정복 시리즈를 기반으로 진행한다. 그 시리즈의 가장 첫 강의인 스프링 입문 - 코드로 배우는 스프링 부트, 웹 MVC, DB 접근 기술을 끝내고, 다음 강의인 스프링 핵심 원리 - 기본편을 정리해보려 한다.

    이번 포스트에서는 싱글톤 컨테이너에 대한 것을 간단히 정리해보도록 하겠다.

    싱글톤을 사용하는 이유

    싱글톤을 사용하는 이유를 떠올려 보기 위해 역으로 싱글톤을 사용하지 않을 때 발생하는 문제점에 대해서 알아보도록 하자. 객체지향 프로그래밍에 대해서, 혹은 자바에 대해서 대학교 강의에서 처음 접한 많은 사람들은 싱글톤 패턴을 안티패턴으로 알고 있을 것이다.

    실제로 그렇다. 싱글톤 패턴엔 수많은 단점이 있다. 예를 들어,

    • 자바에서는 싱글톤 패턴을 구현하기 위해 부가적으로 코드를 내부에 더 작성해주어야 한다.
    • 생성자가 private 하기 때문에 자식 클래스를 만들기 어렵다. 즉, 상속을 이용한 추상화에 불리하다.
    • 사실상 클래스 내부의 상태가 static한 상태가 되기 때문에 캡슐화의 관점에서 객체 지향의 철학과 거리가 있다.
    • 싱글톤 객체 내부에서 인스턴스를 꺼내기 위해서 getInstance() 를 호출해야 하는데, 이 것의 이름이 getInstance() 가 될지 아니면 get() 이 될지, 그리고 파라미터로는 뭘 전달할지 아무도 예측할 수 없다. 즉 사용하는 쪽에서 구현체에 의존하게 되어 DIP를 위반하게 된다.

    등의 단점이 있다.

    그럼에도 불구하고 싱글톤을 사용할 수 밖에 없는 이유는 웹 어플리케이션으로 동시에 많은 요청이 들어오기 때문이다. 더 엄밀하게 풀어보자면, 동시에 들어오는 많은 요청에 대해서 각 요청마다 new 키워드를 통해 객체를 생성할 경우 메모리 비용과 GC 비용이 치솟게 되기 때문이다. 예를 들어, 요청 1000개면 객체 1000개, 요청 10000개면 객체 10000개를 생성해야 한다.

    싱글톤 패턴을 사용하면 이러한 객체 생성 및 메모리 관리의 문제에서 비교적 자유로워질 수 있다.

    근데 단점이 찜찜한데?

    확실히 객체지향적의 관점에서 싱글톤 패턴의 단점은 썩 유쾌하게 느껴지지 않는다. 스프링은 개발자들에게 불쾌하게 다가올 수 있는 이 단점들을 프레임워크 단에서 해결해준다.

    스프링 싱글톤 컨테이너

    스프링 컨테이너는 싱글톤 패턴의 문제를 해결하면서 객체 인스턴스는 싱글톤으로 관리해준다. 🤔 어떻게?

    • 스프링 컨테이너는 싱글톤을 코드를 통해서 적용하지 않더라도 알아서 객체 인스턴스를 싱글톤으로 관리한다.
      • 애초에 컨테이너 생성 과정에서 컨테이너는 객체를 하나만 생성해서 관리한다.
    • 스프링 컨테이너는 이렇게 생성된 싱글톤 객체들을 관리하는 싱글톤 컨테이너 역할을 한다. 이렇게 싱글톤 객체를 생성하고 관리하는 것을 싱글톤 레지스트리라고 하기도 한다.
    • 스프링 컨테이너의 기능에 힘입어, 개발자의 입장에서는 싱글톤 패턴의 단점을 해결하면서 그대로 싱글톤 패턴의 장점을 누릴 수 있다.
      • 싱글톤 패턴을 구현하기 위한 추가적인 코드를 작성하지 않아도 된다.
      • DIP, OCP, 테스트, private 생성자로부터 자유롭게 싱글톤 패턴을 사용할 수 있게 된다.

    물론 싱글톤만 해주는건 아님

    스프링은 물론 싱글톤 이외에도 요청마다 새로운 객체를 생성해서 사용하는 방법도 지원한다. 이는 빈 스코프에 대해서 학습할 때 다시 다뤄보도록 하겠다.

    싱글톤 사용 시 주의해야 할 점

    스프링을 사용하는 것과는 무관하게, 싱글톤을 사용하는 것 자체에 있어서 주의해야 할 점은 상태에 대한 관리이다. 객체 인스턴스 하나를 공유해서 사용하는 싱글톤의 특성 상, 싱글톤 객체는 절대 Stateful 하게 설계해서는 안된다. Instance Field, Static Field 모두 없어야 한다는 의미이다. (사실상 Instance Field는 싱글톤에서는 JVM 런타임에 올라갈 때 바로 로딩되지 않을 뿐이지, 한번 로딩된 이후에는 Static Field와 차이를 가지지 않는다.)

    상태가 유지되는 코드의 문제 상황

    Stateful 하게 설계하면 특히 여러개의 쓰레드가 같은 인스턴스에 접근했을 때, 그리고 해당 State를 변경하는 코드에 함께 접근할 때 심각한 문제를 야기할 수 있다. 다음 예제와 함께 생각해보자.

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

    위와 같은 싱글톤 서비스가 있다고 생각하자. 이제 이 서비스를 사용하는 다음의 코드 시퀀스가 있다고 가정하자.

    public int sampleSequence(String nameFromRequest, int priceFromDB) {
        this.statefulService.order(nameFromRequest, priceFromDB);
        return this.statefulService.getPrice(); // 진짜 사고 발생
    }

    시퀀스를 보면 order 즉 주문을 하고, 주문의 가격을 불러오고 있다. 주문 후 주문한 사람에게 영수증을 발행해주는 것과 마찬가지의 상황으로 생각할 수도 있다.

    이 코드 시퀀스를 2개의 쓰레드 T1과 T2가 접근한다고 가정하자. 그리고 어째저째 여러 스케줄링과 요인에 따라서 실행 순서가 다음과 같이 됐다고 생각해보자.

    T1.order('엄청난 부자', 100000000);
    T2.order('조금 부자', 100);
    T1.getPrice();
    T2.getPrice();

    그러면 어떻게 될까? 엄청난 부자에게는 1억원의 영수증이 발급되어야 한다. 하지만 조금 부자 쓰레드가 중간에 들어와서 StatefulService 의 price를 100원으로 변경해버렸다. 따라서 엄청난 부자와 조금 부자 둘 다 100원의 영수증을 발급받게 된다. 유저 2명의 상황만 해도 이렇게 어지러운데, 만약 초당 트래픽이 1만인 실제 서비스에서 이런 일이 일어났다고 생각하면 그저 어지럽기만 하다. 그냥 회사 문 닫는거다.

    그럼 어떻게?

    무조건 Stateless 하게 설계해야 한다. global 하게 접근할 수 있는 state를 절대 만들지 말자.

    여러 쓰레드가 하나의 자원에 접근하는 상황을 막지 못하면 심각한 문제에 도달할 수 있다.

    개인적으로는 ThreadLocal 등의 다른 방법으로 문제를 해결하기보다 애초에 구조를 잘 잡아서 문제를 발생시키지 않는 것이 최선인 것 같다.

    사실 이것은 비단 싱글톤의 문제 뿐 아니라, http 그리고 REST 환경에서도 마찬가지이다. 이 환경에서의 서비스는 Scale out 상황 등을 고려했을 때에도 Stateless 하게 설계하는 것이 정석으로 알려져 있다.

    @Configuration과 싱글톤, 그리고 CGLib

    개인적으로 이 부분에 있어서 아주 궁금함이 많았다. 강의 상에서 실습하며 작성한 코드에 의하면 도저히 일반적인 실행 순서로는 이해할 수 있는 부분이 아니었기 때문이다. 그리고 그 해결법에 대해서도 굉장히 묘한 해결책이라는 생각을 했다.

    뭔가 이상한듯한 AppConfig

    강의에서 나오는 예제 코드를 복붙하는 행위를 최대한 배제하려고 신경쓰고 있지만, AppConfig의 경우엔 굉장히 일반적이기 때문에 그대로 사용하도록 하겠다.

    @Configuration
    public class AppConfig {
        @Bean
        public MemberService memberService() {
            return new MemberServiceImpl(memberRepository());
        }
    
        @Bean
        public OrderService orderService() {
            return new OrderServiceImpl(
                    memberRepository(),
                    discountPolicy());
        }
    
        @Bean
        public MemberRepository memberRepository() {
            return new MemoryMemberRepository();
        }
        // ...rest
    }

    이 코드를 보면 memberRepository() 가 외부 Bean에서 주입을 위해 2번 호출되는 것을 볼 수 있다.

    • memberService() 에서
    • orderService() 에서

    각각 호출되어 2번 호출된다.

    이에 더하여, 애초에 MemberRepository 에 대한 설정을 하기 위해 스프링 컨테이너가 구동되면서 @Bean 어노테이션을 인식하여 처음에 1번 호출된다.

    하지만 우리는 위에서 언급했듯, 스프링 컨테이너를 믿기 때문에 클래스를 작성할 때 별도로 싱글톤 패턴을 구현하기 위한 코드를 작성하지 않았다. 그렇다면 memberRepository() 는 실행 시점에 분명 3번 실행된다.

    일반적인 자바 런타임에 의하면 따라서 각각의 memberRepository() 는 각각의 MemoryMemberRepository 인스턴스를 반환해야 하고, 이에 따라 싱글톤 패턴을 지키지 못하게 된다. 하지만 스프링 컨테이너는 이 인스턴스들이 모두 같은 싱글톤 인스턴스임을 보장한다. 이것이 어떻게 가능한걸까?

    CGLIB과 바이트 코드 조작

    위의 코드를 설명하면서 짚지 않고 넘어간 하나의 어노테이션이 있다. 바로 @Configuration 이다.

    까짓게 뭔데? 그냥 설정 클래스라는거 아니야? 라고 생각했었다. 그런데, 생각해보면 프레임워크 그리고 DI, IoC 라는 관점에서 설정은 그 자체로 굉장히 중요한 의미를 가지고 있다. 이 설정을 기반으로 프로젝트가 프레임워크 위에서 구동이 되기 때문이다. 그러면 이 @Configuration싱글톤 컨테이너라는 문맥에서는 어떤 역할을 하고 있을까?

    @Configuration 이 붙은 클래스도 스프링 컨테이너에는 하나의 스프링 빈(Spring Bean)으로 등록된다. 그런데, 이 클래스는 스프링 컨테이너에 곧이 곧대로 자신의 인스턴스가 등록되는 것이 아니라, CGLIB을 통해서 바이트 코드가 조작된 클래스(**.*.AppConfig$$EnhancerBySpringCGLIB$$somehash)가 인스턴스로 등록된다. (참고로 바이트 코드가 조작된 클래스는 원래 AppConfig 클래스를 상속하였기 때문에 AppConfig 를 이용해서 스프링 컨테이너로부터 조회해올 수 있다.)

    이렇게 바이트 코드가 조작된 AppConfig 는 내부에 설정되어 있는 스프링 빈을 가져올 때, 스프링 컨테이너에 이미 등록된 경우가 있었는지를 한 번 더 확인하는 절차를 가지게 된다. 없으면 생성하고, 있으면 있는 것을 그대로 가져다 쓰는, 그야말로 바이트 코드 조작을 통해서 싱글톤 패턴을 강제로 구현하는 것이다.

    실제로, @Configuration 어노테이션이 없는 상태로 위의 코드를 실행하면, 일반적인 자바 런타임에서의 실행 메커니즘대로 3개의 MemoryMemberRepository 인스턴스가 스프링 컨테이너에 등록된다.

    어떻게 보면 기행으로 보이는데…

    처음에 이 방식을 보고서 굉장한 기행이라는 생각을 했다. 그렇지만, 자바에서 싱글톤 패턴으로 클래스를 선언할 경우 생기는 단점에 대해서 생각하고는 해당 방식에 대해서 납득하게 되었다.

    윗 부분에서 기술한 싱글톤 패턴을 자바 코드로 구현할 경우 생기는 단점들은 클래스에 여러가지 디자인 패턴을 적용하기 어렵게 만들고, 단위 테스트도 수행하기 어렵게 만든다. 또한 강제로 구현체에 의존하게 되기 때문에 DIP, OCP도 위배하게 된다.

    유지보수하기 좋은 소프트웨어를 개발할 수 있게 하기 위해서는 이러한 문제들을 반드시 해결해야 했을 것이다. 이에 대한 해결책으로 스프링 개발자들은 Proxy 객체를 만드는 것을 선택했다. 하지만 Java Dynamic Proxy는 반드시 인터페이스를 구현하는 클래스에 대해서만 Reflection을 이용해서 Proxy 객체를 생성해야 하기 때문에 이러한 상황에는 적합하지 않다.

    따라서, 클래스만으로도 Proxy 객체를 생성할 수 있는 CGLIB을 선택한 것이라고 납득하게 되었다.

    아직 AOP나 Java Reflection, Proxy 패턴에 대한 이해가 약하기 때문에 정확하지는 않을 수 있습니다. 댓글로 잘못된 부분이 있으면 꼭 지적해주세요! 앞으로 꾸준히 공부해서, 완벽히 이해를 한 다음 고치도록 하겠습니다.

    결론은

    크게 고민할 필요가 없다.

    일단 이 정도 이해도로 개발을 해야 하는 상황이라면 그냥 @Configuration 어노테이션을 사용하도록 하자.

    다음은

    이상, 스프링의 싱글톤 컨테이너에 대한 내용들에서 중요하다고 느낀 부분들을 간략히 정리해 보았다. 이 부분에 대해서 정확히 이해하기 위해서는 더 공부해야 하는 내용이 많다고 느낀다. AOP나 Proxy 패턴에 대해서 공부하는 것은 물론이고, 실제로 이러한 형태로 코드를 작성했을 때 여러가지 디자인 패턴을 적용하거나 아니면 테스트를 작성하는 데에 이득이 있는지를 느껴보는 것이 중요하다는 생각이 든다.

    다음 포스트에서는 컴포넌트 스캔에 대한 내용을 다뤄보도록 하겠다.

    끝.

    #java#spring#inflearn#Singleton#container#spring bean

    © 2024, Built with Gatsby