섹션 5. 싱글톤 컨테이너

본 시리즈는 우아한형제들 개발 팀장이신 김영한님의 스프링 핵심 원리 - 기본편 강의를 들으며 개인적으로 정리한 내용을 담고 있습니다. 제가 들은 강의는 인프런에 등록되어 있습니다. 모든 다이어그램을 포함한 사진의 출처는 위 강의의 강의록임을 밝힙니다. 개인적으로 정리한 내용이기 때문에 글 내용에 오류가 있을 수 있으며 이에 대한 피드백은 댓글로 부탁드립니다.

이번 섹션에서 다룰 내용

  • 웹 app에서 싱글톤이 중요한 이유
  • 싱글톤 패턴이 무엇이고 어떻게 사용하는지
  • @Configuration에 대해서



웹 애플리케이션과 싱글톤

  • 웹 app은 보통 여러 고객이 동시에 요청을 하는 특성을 가진다.
  • 그렇다보니 우리가 이전에 작성한 AppConfig같은 겨우 한 번에 여러 요청이 갈 수 있는데, 그 때마다 빈을 새로 생성하게 된다.

테스트를 통해 검증해보자.

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);
        System.out.println("memberService2 = " + memberService2);
        //memberServicec1 != memberService2
        assertThat(memberService1).isNotSameAs(memberService2);
    }
}
  • memberService의 참조값이 다르게 나오는 것을 확인할 수 있다.
  • 이는 AppConfig에 요청이 올 때마다 객체를 새로 생성하기 때문이다.
    • 메모리 낭비가 매우 심하다!
  • 그래서 싱글톤 패턴이 필요하다.

싱글톤 패턴

  • 싱글톤 패턴을 적용하여 문제를 해결해보자!
  • 싱글톤 패턴은 어떤 클래스의 인스턴스가 딱 한 개만 생성되도록 하는 디자인 패턴이다.
    • 즉, 객체를 두 개 이상 생성하지 못하게 막으면 된다.
    • 어떻게??? 코드 예시를 보자.
public class SingletonService {
    private static final SingletonService instance = new SingletonService();
    public static SingletonService getInstance() {
        return instance;
    }
    private SingletonService() {}	// private 생성자
    public void logic() {
        System.out.println("싱글톤 객체 로직 호출");
    }
}
  1. static 영역에 객체 instance를 미리 하나 생성해둔다.
    • static으로 선언되면 프로그램이 실행되자마자 바로 객체가 생성된다.
  2. 객체 인스턴스가 필요하면 getInstance() 메소드로만 호출할 수 있다.
  3. 딱 하나의 인스턴스만 존재하도록 기본 생성자를 private으로 설정해서 외부에서 호출할 수 없도록 한다.

테스트도 해보자.

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

        System.out.println("singletonService1 = " + singletonService1);
        System.out.println("singletonService2 = " + singletonService2);

        assertThat(singletonService1).isSameAs(singletonService2);
        //same: == (레퍼런스 비교)
        //equal: equals (값 비교)
    }
}
  • 잘 된다!
  • 객체를 새로 생성하지 않고 있는 객체를 그대로 재활용하니까 성능이 상당히 좋아진다.
  • 참고로 assertThat 뒤에 연속으로 붙는 함수로 isSameAs()가 들어갔는데, 이 때는 레퍼런스를 비교하는 것이다.

그렇다면 매번 이렇게 모든 객체를 싱글톤 패턴을 이용해 만들어야할까? NO!

스프링에게 부탁하면 스프링이 알아서 해준다! (기가 막히죠)

그런데 싱글톤 패턴도 문제가 있다.

싱글톤 패턴의 문제점

  • 싱글톤 패턴을 적용하느라 메인 로직과 무관한 각종 코드가 들어가야 한다.
  • 의존관계 상 클라이언트가 구현 클래스에 의존해야한다. 그래서 DIP를 위반하게 된다.
    • 정확히는 AppConfig같은 클래스를 이용하여 이런 문제를 회피할 수 있지만 싱글톤 패턴의 의도 자체가 getInstance()를 호출할 때 구현 클래스를 의존하게 하는 것이다. (?)
  • 구현 클래스에 의존하다보니 OCP를 위반하게 될 가능성이 높다.
  • 테스트하기 어렵다.
  • 유연성이 떨어진다.

근데 신기하게도 스프링에서는 이런 단점을 모두 제거하고 객체를 싱글톤으로 관리해준다고 한다. 그게 어떻게 가능하지?




싱글톤 컨테이너

스프링 컨테이너는 싱글톤 패턴의 문제점을 해결하면서 각 객체 인스턴스를 싱글톤으로 관리한다.

지금까지 배운 스프링 빈이 바로 싱글톤으로 관리되는 빈이다.

  • 스프링 컨테이너는 알아서 객체 인스턴스를 싱글톤으로 관리한다.
    • 즉, 컨테이너 전역에 객체 당 인스턴스를 딱 하나만 갖고 있다.
  • 스프링 컨테이너는 싱글톤 컨테이너 역할을 한다. 이렇게 싱글톤 객체를 생성하고 관리하는 기능을 싱글톤 레지스트리라고 한다.
  • 스프링 컨테이너의 이런 기능 덕분에 싱글톤 패턴의 모든 단점을 해결하면서 객체를 싱글톤으로 유지할 수 있다.
    • 싱글톤 패턴만을 위한 추가적인 코드가 필요치 않다.
    • 유연성을 유지할 수 있다.

스프링 컨테이너를 이용해서 싱글톤 테스트를 해보자.

public class SingletonTest {
    @Test
    @DisplayName("스프링 컨테이너와 싱글톤")
    void springContainer() {
        AnnotationConfigApplicationContext 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);

        //memberServicec1 == memberService2
        assertThat(memberService1).isSameAs(memberService2);
    }
}

  • 스프링이 싱글톤으로 객체를 생성해주기 때문에 두 객체의 참조값이 같은 것을 확인할 수 있다.
  • 스프링 컨테이너를 사용해야 할 큰 이유 중 하나이다! (직접 AppConfig를 만드는 것보다 스프링을 쓰는 게 나은 이유)

참고: 스프링의 기본 빈 등록 방식은 싱글톤이지만, 싱글톤 방식만 지원하는 것은 아니다. 요청할 때마다 새로운 객체를 생성해서 반환하는 기능도 제공한다. 그러나 대개는 싱글톤으로 생성된 인스턴스를 사용하게 된다. 자세한 내용은 뒤에 빈 스코프에서 설명한다.

싱글톤 방식의 주의점

  • 싱글톤 방식은 여러 클라이언트가 객체 인스턴스를 공유하여 사용하기 때문에 상태를 유지(stateful)하게 설계하면 안된다.
  • 무상태(stateless)로 설계해야 한다!
    • 특정 클라이언트에 의존적인 필드가 있으면 안된다.
    • 특정 클라이언트가 값을 변경할 수 있는 필드가 있으면 안된다!
    • 가급적 읽기만 가능해야 한다!
    • 필드 대신에 자바에서 공유되지 않는 지역변수, 파라미터, ThreadLocal 등을 사용해야 한다.
  • 스프링 빈의 필드에 공유값을 설정하면 큰 장애가 발생할 수 있다!
  • 실무에서도 이것 때문에 굉장히 고생한다고 함... 중요한 내용인듯

어떤 문제가 발생할 수 있는지 코드로 알아보자

StatefulService: 상태를 갖는 객체를 가정한 클래스

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

StatefulServiceTest: 테스트 코드

class StatefulServiceTest {
    ApplicationContext ac = new AnnotationConfigApplicationContext(TestConfig.class);
    @Test
    void statefulServiceSingleton() {
        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);

        //ThreadA: 사용자A 주문 금액 조회
        int price = statefulService1.getPrice();
        System.out.println("price = " + price); //20000원이 나옴. 엥?
        assertThat(statefulService1.getPrice()).isEqualTo(20000);
    }
    static class TestConfig {
        @Bean
        public StatefulService statefulService() {
            return new StatefulService();
        }
    }
}
  • order객체가 싱글톤으로 생성되었고, 사용자 A가 주문 금액을 조회하기 직전에 사용자 B가 주문 금액(price 필드)을 수정했기 때문에 의도하지 않은 오류가 발생했다.
    • 실무에서도 종종 발생하는 일이고, 아주 치명적인 오류가 된다. (특히 돈이랑 관련되면.. ㄷㄷㄷ)
  • 이 문제는 결국 클라이언트가 필드의 값을 변경했기 때문에 발생한 에러이다.
  • 그래서 스프링 빈은 항상 무상태(stateless)로 설계해야한다.
    • 요컨대 위 코드에서는 order함수가 그냥 매개변수로 넘겨받은 price를 반환하게 하고 price 필드는 지워버리면 된다.

(이런 문제가) 몇 년에 한 번씩 꼭 나오더라고요. 공유필드는 진짜 조심해야되고요, 스프링 빈은 항상 무상태로 설계해야 한다는 걸 꼭 기억하셔야됩니다.




@Configuration과 싱글톤

@Configuration은 싱글톤을 위해 존재하는 것이다.

@Configuration의 역할에 대해 알아보기 위해서, AppConfig를 다시 한 번 보자.

@Configuration
public class AppConfig {
    @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 RateDiscountPolicy();
    }
}
  • 이 코드만 보면, memberService()가 호출될 때, orderService()가 호출될 때 모두 memberRepository()가 호출되는데, 그러면 new MemoryMemberRepository()가 두 번 호출되어서 싱글톤이 깨지는 게 아닌가? 하는 의문을 가질 수 있다.

과연 진짜 그럴까? 테스트를 해보자.

먼저 OrderServiceImplMemberServiceImpl에 임시로 테스트용 메소드를 만든다. (getMemberRepository()) 그리고나서 테스트 코드를 작성한다.

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);
        System.out.println("orderService -> memberRepository2 = " + memberRepository2);
        System.out.println("memberRepository = " + memberRepository);

        assertThat(memberRepository).isSameAs(memberRepository1).isSameAs(memberRepository2);
    }
}
  • 모든 memberRepository가 같은 주소를 갖고 있음을 확인할 수 있다.

  • 상식적으로 생성자가 여러 번 불렸는데 이게 어떻게 가능한 일일까? 혹시 생성자가 두 번 이상 호출되지 않는 건 아닐까?

    • AppConfig의 각 메소드에 호출될 때마다 콘솔 출력을 남기도록 추가하고 테스트해보자.
    • 총 세 번의 콘솔 출력이 발생한다.
call AppConfig.memberService
call AppConfig.memberRepository
call AppConfig.orderService

memberRepository가 세 번 호출되어야 할텐데 왜 한 번만 호출되었을까?

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

스프링 컨테이너는 싱글톤 레지스트리이므로 스프링 빈이 싱글톤이 되도록 보장해줘야 한다. 위 사례는 스프링 컨테이너가 memberRepository 객체를 싱글톤으로 잘 유지한 사례이다. 그런데 어떻게 이게 가능했을까? 자바 코드 상으로는 new MemoryMemberRepository()가 세 번 호출되어야 하는 상황인데..

비밀은 바로 @Configuration에 있다. AppConfig 스프링 빈을 조회해서 클래스 정보를 출력해보자.

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

    System.out.println("bean = " + bean.getClass());
}

결과는 다음과 같이 나온다.

bean = class hello.core.AppConfig$$EnhancerBySpringCGLIB$$25fba07b

hello.core.AppConfig 까진 알겠는데 그 뒤에 요오상한 $$EnhancerBySpringCGLIB$$는 뭘까?

이는 내가 만든 클래스가 아니라 스프링이 CGLIB라는 바이트코드 조작 라이브러리를 사용해서 AppConfig 클래스를 상속받은 임의의 다른 클래스를 만들고, 그 다른 클래스를 스프링 빈으로 등록했다는 것을 보여준다. 엥?

스프링이 새로 만든 이 요오상한 클래스가 바로 싱글톤이 보장되도록하는 역할을 한다. 그니까 실제로 스프링 컨테이너에 스프링 빈으로 등록되는 건 내가 만든 클래스가 아니고 스프링이 새로 만든 클래스다.

AppConfig@CGLIB 예상 코드

@Bean
public MemberRepository memberRepository() {
    if (memoryMemberRepository가 이미 스프링 컨테이너에 있으면) {
        return 스프링 컨테이너에서 찾아서 반환;
    } else {	//스프링 컨테이너에 없으면
        기존 로직을 호출해서 MemoryMemberRepository를 생성하고 스프링 컨테이너에 등록
        return 반환
    }
}
  • @Bean이 붙은 모든 각 메소드에 대해 이런 코드가 자동으로 생성된다. 이 덕분에 싱글톤이 보장된다.
  • 참고로 AppConfig@CGLIBAppConfig의 자식 클래스기 때문에 AppConfig 타입으로도 조회가 가능한 것이다.

@Configuration을 적용하지 않고 @Bean만 적용하면 어떻게 될까?

@Configuration을 붙이면 바이트코드를 조작하는 CGLIB 기술을 사용해서 싱글톤을 보장하지만, 만약 @Configuration을 제거하고 @Bean만 남기면 어떻게 될까?

//@Configuration
public class AppConfig {
    ...
}

@Configuration을 제거하고 동일한 테스트를 해보면 다음과 같은 결과가 다온다.

bean = class hello.core.AppConfig
  • 테스트 결과를 보면 우리가 작성한 AppConfig가 그대로 스프링 빈을 등록되었음을 알 수 있다.
  • 또한 memberServiceorderService가 가진 memberRepository의 레퍼런스를 출력해보면, 서로 다른 인스턴스임을 알 수 있다.
  • 그리고 문제가 하나 더 있는데, memberServiceorderService에 생성자로 주입된 memberRepository스프링 빈이 아니다. 즉, 스프링 컨테이너에서 관리하지 않는다.

정리

  • @Bean만 사용해도 스프링 빈으로 등록되지만, 싱글톤을 보장하지 않는다.
    • memberRepository()처럼 의존관계 주입이 필요해서 메소드를 직접 호출할 때 싱글톤을 보장하지 않는다.
    • 이는 일반적인 자바 코드로 인식해서 고대로 생성자를 호출하기 때문이다. (혹은, 사용자가 만든 클래스를 그대로 스프링 컨테이너에 등록하기 때문이다.)
  • 애초에 고민할 거리가 아니다. 스프링 설정 정보는 항상 @Configuration을 사용하자.

이번 섹션에서 배운 @Configuration의 역할은

  • 사용자가 만든 클래스를 스프링 빈으로 등록하는 척하면서 사실은 바이트코드 단위로 어떠한 수정을 가해서 스프링 컨테이너가 만든 새로운 클래스(사용자의 클래스를 상속받는 클래스)를 등록하여 설정 정보에 등록된 각 스프링 빈이 싱글톤을 유지하도록 해주는 역할을 한다.
    • 특히 어떤 스프링 빈을 등록하는 과정에서 다른 스프링 빈을 직접 호출하는 경우, 이미 등록된 빈에 대해 추가적인 인스턴스 생성을 막아준다.



사용한 단축키

  • 클래스 이름에 대고 Alt + Enter: 여러 메뉴가 있는데, 테스트를 만들 수 있다.

좋은 웹페이지 즐겨찾기