스프링 컨테이너가 싱글톤을 보장하는 원리

woply·2022년 1월 19일
1

TIL(Today I Learn)

목록 보기
17/31

📖 ✏️

  1. TIL 시리즈에 작성된 글은 '매일 매일 학습한 지식 조각을 메모해 놓은 포스팅'입니다. 공유가 아닌 개인적인 학습 내용 기록을 목적으로 작성되었음을 알려드립니다.
  2. 그 외 시리즈에 작성된 공유 목적의 포스팅은 시간이 날 때마다 별도로 작성하고 있습니다. 주로, TIL 시리즈에 작성된 내용에서 특정 주제를 선정하고, 더 깊이 공부한 후 정리하여 작성합니다.

스프링 컨테이너가 싱글톤을 보장하는 원리

1. 스프링 컨테이너는 싱글톤을 보장한다.

스프링 컨테이너는 싱글톤을 보장한다. 아래와 같이 @Configuration 애노테이션이 지정되어 있는 설정 클래스는 의존 관계 주입을 위해 객체가 여러번 생성되어도 싱글톤이 보장된다.

@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());
    }

    // ...이하 생략
    

위 코드를 살펴보면 memberService Bean을 등록하는 과정에서 memberRepository()를 호출하여 MemoryMemberRepository 객체를 생성한다. 이어서 memberRepository Bean을 등록하기 위해 MemoryMemberRepository 객체가 또 한 번 생성된다. 마지막으로 orderService Bean을 등록하는 과정에서 memberRepository()가 호출되고 MemoryMemberRepository 객체가 또 다시 생성된다.

각각 서로 다른 3개의 MemoryMemberRepository가 생성되는 코드라는 것은 자명하다. 이로써, 하나의 객체를 공유하여 사용하는 싱글톤 패턴이 성립하지 않는 듯 보인다. 스프링 컨테이너는 정말 싱글톤 패턴을 유지하고 있을까? 만약 싱글톤을 보장한다면 어떤 원리로 이 문제를 해결하고 있을까?

먼저, 스프링 컨테이너가 싱글톤을 보장하고 있는지 테스트를 진행해 보자. 서로 다른 3개의 MemoryMemberRepository 객체가 동일한 객체가 맞는지 확인하는 테스트다.

1-1. 스프링 컨테이너의 싱글톤 보장 테스트

객체 간 참조값을 비교해보기 위해 사전 준비가 필요하다. MemberServiceImpl이 생성하는 MemberRepository의 구현체를 확인할 수 있도록 getMemberRepository()를 만들었다.

public class MemberServiceImpl implements MemberService{

    private final MemberRepository memberRepository;

    public MemberServiceImpl(MemberRepository memberRepository) {
        this.memberRepository = memberRepository;
    }

    @Override
    public void join(Member member) {
        memberRepository.save(member);

    }

    @Override
    public Member findMember(Long memberId) {
        return memberRepository.findById(memberId);
    }

    // AppConfig에서 2번 생성된 MemberRepository 객체의 동일 여부 확인을 위해 임시로 만든 메서드
    public MemberRepository getMemberRepository() {
        return memberRepository;
    }
}

마찬가지로 OrderServiceImpl 객체가 생성하는 MemberRepository 구현체도 조회할 수 있도록 getMemberRepository()를 만들었다.

public class OrderServiceImpl implements OrderService {

    private final MemberRepository memberRepository;
    private final DiscountPolicy discountPolicy;

    public OrderServiceImpl(MemberRepository memberRepository, DiscountPolicy discountPolicy) {
        this.memberRepository = memberRepository;
        this.discountPolicy = discountPolicy;
    }

    @Override
    public Order createOrder(Long memberId, String itemName, int itemPrice) {
        Member member = memberRepository.findById(memberId);

        int discountPrice = discountPolicy.discount(member, itemPrice);
        // 설계가 잘된 이유는 discountPolisy가 알아서 해주기 때문이다.
        // 단일 책임 원칙을 잘 지킨 경우라고 할 수 있다.

        return new Order(memberId, itemName, itemPrice, discountPrice);
    }

    // AppConfig에서 2번 생성된 MemberRepository 객체의 동일 여부 확인을 위해 임시로 만든 메서드
    public MemberRepository getMemberRepository() {
        return memberRepository;
    }
}

다음으로, 스프링 컨테이너를 생성하고 getMemberRepository()를 이용해 두 객체를 조회할 수 있도록 클래스를 만들었다. 클래스 이름은 ConfigurationSingletonTest다.

getBean()를 이용해 MemberServiceImpl 구현체와 OrderServiceImpl 구현체를 꺼내 온다. 테스트를 위해 각 구현체에 임시로 만들어 놓은 getMemberRepository()를 이용해 객체를 각각 조회한다. 추가로 AppConfig.classmemberRepository()를 호출하여 실제 반환 객체인 MemoryMemberRepository()도 꺼내 온다.

세 가지 방법으로 각기 다른 곳에서 생성된 MemberRepository 구현체의 참조값을 비교하는 테스트를 진행한다.

public class ConfigurationSingletonTest {

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

        // MemberRepository를 생성하는 두 Service 구현체를 꺼내온다.
        MemberServiceImpl memberService = ac.getBean("memberService", MemberServiceImpl.class);
        OrderServiceImpl orderService = ac.getBean("orderService", OrderServiceImpl.class);
        // AppConfig에서 생성되는 MemberRepository 구현체도 꺼내온다.
        MemberRepository memberRepository = ac.getBean("memberRepository", MemberRepository.class);

        // 각각의 구현체가 필드 변수에 가지고 있는 memberRepository 객체를 조회한다.
        MemberRepository memberRepository1 = memberService.getMemberRepository();
        MemberRepository memberRepository2 = orderService.getMemberRepository();

        // 세 가지 방식으로 생성된 MemberRepository 구현체의 참조값을 비교한다.
        System.out.println("memberServiceImpl -> memberRepository1 = " + memberRepository1);
        System.out.println("orderServiceImpl -> memberRepository2 = " + memberRepository2);
        System.out.println("memberRepository = " + memberRepository);

        // 세 객체의 참조값이 동일한지 검증한다.
        assertThat(memberService.getMemberRepository()).isSameAs(memberRepository);
        assertThat(orderService.getMemberRepository()).isSameAs(memberRepository);
    }
}

테스트를 실행해보면 모두 통과한다. 아래와 같이 로그를 통해 세 객체의 참조값이 일치하다는 사실도 확인할 수 있다.

MemberRepository 구현체는 MemberServiceImplOrderServiceImpl에서 각각 생성되는 듯 보이지만, 스프링 컨테이너는 여전히 싱글톤을 보장하고 있다. 어떤 원리로 싱글톤이 보장되는 것일까?

2. 컨테이너는 객체를 중복 생성하지 않는다.

AppConfig의 자바 코드를 보면 아래와 같이 new MemoryMemberRepository()가 분명 호출되어야 한다.

  1. Bean에 등록하기 위해 @Bean이 붙어있는 memberRepository() 호출
  2. memberService() 로직에서 memberRepository() 호출
  3. orderService() 로직에서 memberRepository() 호출

실제로 그러할까? 메서드 실행 단위마다 출력 로그를 남겨 확인해 보았다.

@Configuration
public class AppConfig {

    @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 FixDiscountPoliocy();
        return new RateDiscountPolicy(); // 기획자가 배우를 변경하듯, AppConfig에서 할인 정책을 변경한다.
    }
}

출력 결과를 보면, call AppConfig.memberRepository는 단 한 번만 호출되었다.

스프링 컨테이너는 여러번 객체를 생성하는 코드를 그대로 따르지 않는다. 언제나 하나의 객체를 사용하는 싱글톤을 보장한다. 그 원리는 @Configuration에 있다.

3. @Configuration이 싱글톤을 보장하는 원리

스프링 컨테이너가 싱글톤을 보장할 수 있는 이유는 @Configuration를 이용한 CGLIB 바이트코드 조작 라이브러리를 사용하기 때문이다.

직접 확인해 보자. 아래와 같이 스프링 컨테이너 객체를 생성하고, getBean()을 이용하여 Bean에 등록된 자바 설정 파일 객체를 조회했다.

@Test
void configuration_깊이_알아보기() {
    ApplicationContext ac = new AnnotationConfigApplicationContext(AppConfig.class);
    AppConfig bean = ac.getBean(AppConfig.class);

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

아래와 같은 출력 결과를 확인할 수 있다.

getClass()로 조회하면 클래스명인 class hello.core.AppConfig까지만 나오는 것이 정상이다. 뒤에 이어서 붙어있는 EnhancerBySpringCGLIB는 무엇일까?

스프링은 CGLIB라는 바이트코드 조작 라이브러리를 사용하여, AppConfig 클래스를 상속하는 임의의 클래스를 생성한다. 그렇게 생성된 클래스가 스프링 Bean으로 등록된다. 스프링은 컨테이너의 설정 파일로 파라미터의 추가했던 AppConfig를 사용하지 않는다.

AppConfig를 상속받은 새로운 인스턴스(CGLIB)를 사용한다. 그리고 새로운 인스턴스가 싱글톤을 보장한다. AppConfig@CGLIB의 내부에서는 Bean에 등록된 메서드를 오버라이드 한다. 오버라이드 된 메서드는 중복 객체를 생성하지 않고, 기존 객체를 공유한다. 싱글톤을 보장하도록 메서드 기능을 오버라이드하는 것이다.

아래는 오버라이드된 memberRepository()의 예상 로직이다. 해당 객체의 생성 여부에 따라 새로운 객체를 생성하거나, 기존에 생성되어 컨테이너에 보관중인 객체가 있다면 찾아서 반환하는 방식으로 싱글톤을 보장한다.

3-1. AppConfig@CGLIB 클래스의 예상 코드

@Bean
public MemberRepository memberRepository() {
    if (memoryMemberRepository가 이미 스프링 컨테이너에 등록되어 있으면?) { 
        return 스프링 컨테이너에서 찾아서 반환;
    } else { //스프링 컨테이너에 없으면 기존 로직을 호출해서 MemoryMemberRepository를 생성하고 스프링 컨테이너에 등록 
        return 반환
    } 
}

참고
AppConfig@CGLIB는 AppConfig의 자식 타입이므로, AppConfig 타입으로 조회가 가능하다.

3-2. @Configuration가 없으면 작동하지 않는다.

@Configuration을 적용하지 않으면, 스프링은 싱글톤을 보장하지 않는다. 바이트코드를 조작하는 CGLIB 기술을 사용하는 key가 @Configuration에 있기 때문이다.

@Configuration를 제거하면 AppConfigCGLIB 기술 없이 순수한 AppConfig 클래스로 스프링 Bean에 등록된다. 앞서 진행했던 객체 생성 테스트를 동일하게 실행해보면, 아래와 같이 MemberRepository가 총 3번 생성된다.

call AppConfig.memberService
call AppConfig.memberRepository
call AppConfig.orderService
call AppConfig.memberRepository
call AppConfig.memberRepository

당연히 각각의 인스턴스 참조값도 모두 다르다. 엄연히 다른 객체이기 때문이다.


4. 요약

  • 스프링 컨테이너가 싱글톤을 보장할 수 있는 원리는 CGLIB 기술에 있다.
  • @Configuration이 적용된 스프링 컨테이너에서 CGLIB 기술이 적용된다.
  • @Bean만 사용하면 스프링 Bean 등록은 가능하다. 하지만 싱글톤은 보장되지 않는다.
  • 싱글톤을 보장받기 위해서는 스프링 설정 정보 클래스에 항상 @Configuration을 사용해야 한다.
profile
7년간 마케터로 일했고, 현재는 헤렌에서 백엔드 개발자로 일하고 있습니다. 고객 가치를 설계하는 개발자를 지향하며, 개발, 독서, 글쓰기를 좋아합니다. 업이 심오한 놀이이길 바라는 덕업일치 주의자입니다.

0개의 댓글