김영한 강사님의 스프링 기본편 강의와 따로 찾아본 내용을 정리한 글입니다.


스프링 컨테이너는 싱글톤 컨테이너라고도 부릅니다.

여기서 말하는 싱글톤이란 컨테이너 내의 Bean을 모두 싱글톤, 즉 하나의 객체로 관리함을 의미합니다.

이번 시간에는 왜 싱글톤으로 관리되어야 하는지와 그 구현 방법에 대해 알아보도록 하겠습니다.

 

왜 싱글톤으로 관리되어야할까?

싱글톤이 아닌 상황을 가정해보겠습니다. 하나의 서버에 같은 서비스를 100명의 고객이 동시에 호출한다면 서비스(여기에서는 Bean)도 100개가 생성되어야 합니다. 이러한 상황은 각 유저마다 서비스 객체가 생성되어 메모리 사용에 비효율적이므로 권장되지 않습니다. 따라서 대부분의 서비스는 싱글톤 컨테이너로 관리합니다.

 

스프링이 적용되지 않은 서비스를 테스트 코드를 통해 확인해보겠습니다. 이 경우에는 스프링 컨테이너에서 service 를 꺼내오는 것이 아닌 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();
    }

    @Bean
    public DiscountPolicy discountPolicy() {
        return new FixDiscountPolicy();
    }
}
@Test
    @DisplayName("스프링 없는 순수 DI 테스트")
    void pureContainer() {
        AppConfig appConfig = new AppConfig();

        MemberService memberService1 = appConfig.memberService();
        MemberService memberService2 = appConfig.memberService();

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

        Assertions.assertThat(memberService1).isNotSameAs(memberService2);
    }

테스트 결과

memberService 의 참조값이 다름을 알 수 있습니다. 호출될 때마다 return new MemberService() 가 실행됐기 때문에 당연한 일이죠. 그렇다면 싱글톤으로 관리하려면 어떻게 해야할까요? 먼저 퓨어한 자바 코드를 통해 구현해보도록 하겠습니다.

 

싱글톤 구현 - 순수 자바

public class SingletonService {
    
    // 2
    private static final SingletonService instance = new SingletonService();

	// 3
    public static SingletonService getInstance() {
        return instance;
    }
	
    // 1
    private SingletonService() {}

    public void logic() {
        System.out.println("싱글톤 객체 로직 호출");
    }
}
  1. 생성자는 private 으로 닫아 외부에서 new 를 통해 객체가 생성되는 것을 막는다.
  2. private 생성자를 통해 클래스 내부에서 private static 를 통해 instance 를 딱 하나 생성한다.
  3. 외부에서 인스턴스를 조회할 수 있도록 getInstance() 메소드 작성

private static final 의 의미 

먼저 final 은 한번 초기화되면 값이 변경 불가능하다. static 은 모든 클래스가 단 하나의 값을 공유함을 의미합니다. 따라서 heap 메모리가 아닌 static 메모리 영역에 할당되어 모든 클래스가 하나의 값을 공유합니다. 

 

위의 코드에서는 아무리 SingletonService 클래스가 여러개 존재해도 new SingletonService() 그 자체로 생성되어 할당된  변수 instance 는 단 하나라는 뜻입니다. 테스트 코드를 통해 확인하면 다음과 같습니다.

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

        System.out.println("instance1 = " + instance1);
        System.out.println("instance2 = " + instance2);

        Assertions.assertThat(instance1).isSameAs(instance2);
    }

지금까지 싱글톤 컨테이너에 대해 알아보았습니다. 코드 구현이 복잡한 문제를 간단하게 해결해주기 위하여 스프링이 이제서야 등장합니다.

 

싱글톤 구현 - 스프링

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

        Assertions.assertThat(memberService1).isSameAs(memberService2);
    }

참조값이 같다!

이전과는 달리 ApplicationContext (스프링 컨테이너) 에서 getBean() 메소드를 통해 꺼냈습니다. 테스트 코드를 통해 확인해보니 참조값이 같습니다. 즉, 스프링 컨테이너 속에서는 MemberService 객체가 단 하나 생성됩니다. 이것이 어떻게 가능한지 더 자세히 살펴보도록 하겠습니다.

@Configuration 의 비밀

스프링 컨테이너의 빈은 모두 싱글톤이 되도록 보장되어야 합니다. 이것이 어떻게 가능한지 살펴보도록 하겠습니다.

 

ApplicationContext ac = new AnnotationConfigApplicationContext(AppConfig.class);

이 코드에서 AnnotaionConfigApplicationContext의 파라미터로 넘겨진 값은 스프링 빈으로 등록됩니다. 이때 AppConfig가 Bean 으로 먼저 등록된 후 다른 Bean 이 Singleton 으로 등록되는 것이다. 하지만 여기서 유의점은 등록된 AppConfig 빈의 이름이다.

등록된 bean 모두 출력했을 때

❗hello.core.AppConfig 가 아니라 hello.core.AppConfig$$EnhancerBySpringCGLIB 왜 그럴까?

실제로 등록되는 것이 AppConfig 가 아니라 AppConfig$$EnhancerBySpringCGLIB 이기 때문이다. 이것은 AppConfig 를 상속받아 만들어지고 실제로 Bean 등록이 되는 것이 CGLIB이다!

 

이는 @Configuration 때문에 생긴 일인데 이 특수한 CGLIB AppConfig 는 내부적으로 등록된 빈은 또 다시 등록하지 않도록 설계되어있다. 

 

다르게 말하면 @Configuration 이 없다면 빈이 호출될 때마다 여러번 생성되는 위험성이 있다!

 

이제는 어떠한 클라이언트도 모두 같은 MemberService 객체를 참조할 수 있습니다.

❗싱글톤 컨테이너에서 조심할 점

클라이언트 A, B, C 가 동시에 MemberService 를 이용합니다. 하나의 서버라면 3개의 쓰레드가 생성되어 동시에 같은 메모리를 참조하게 됩니다. 이때 만약에 공유되는 변수가 있고 그것이 stateful 하다면?

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

쓰레드 A가 order() 메소드를 통해 price 를 1000으로 바꿨고 쓰레드 B가 2000으로 바꿨다. 이때 쓰레드 A가 getPrice() 메소드로 본인의 price를 확인한다면? 원래는 1000이 나와야하지만 B의 가격인 2000이 나와버린다. 

 

이처럼 싱글톤 서비스는 절대로 stateful 한 변수를 만들면 안된다.