11 min read

<Spring> 스프링 핵심원리 이해 5 - 싱글톤 컨테이너

웹 어플리케이션과 싱글톤의 관계

여러 고객이 동시에 동일한 서비스를 요청하는 경우
여러 고객이 동시에 동일한 서비스를 요청하는 경우
  • 통상적으로 서비스를 운영하다보면 위 그림과 같이 동일한 요청이 서로 다른 클라이언트로부터 동시에 들어올 수 있다.
  • 요청이 들어오면 객체를 만들어서 메모리를 사용하게 되는데, 만약 동일한 요청들을전부 상이한 메모리 공간에 할당시켜 각각 응답해주게되면 메모리 공간이 남아나질 않을 것이다.
  • 아래 코드는 싱글톤 패턴을 적용하지 않은 DI컨테이너인데, 동일한 요청에 대해서 각각 서로 다른 객체들을 만들어서 응답해주고 있다.
  • 그래서 이에 대한 해결방안으로 웹 어플리케이션을 구현할 때, 동일한 요청들에 대해서는 싱글톤 패턴을 적용시킨다.
  • test의 singleton패키지 생성후 SingletonTest 클래스를 생성한다.
public class SingletonTest {
//객체를 요청할 때 마다 객체를 새로 생성하는 문제점을 가진다
    @Test
    @DisplayName("스프링이 없는 순수한 DI 컨테이너")
    void pureContainer() {
        AppConfig appConfig = new AppConfig();
        //1. 조회 : 호출 할 때마다 객체를 생성(문제점)
        MemberService memberService1 = appConfig.memberService();

        MemberService memberService2 = appConfig.memberService();

        //참조값이 다른것을 확인
        System.out.println("memberService1 = " + memberService1);
        System.out.println("memberService2 = " + memberService2);
        //memberService1 != memberService2
        assertThat(memberService1).isNotSameAs(memberService2);
    }
}
동일한 객체지만 서로 다른 메모리 공간에 할당
동일한 객체지만 서로 다른 메모리 공간에 할당

싱글톤 패턴

  • 클래스의 인스턴스가 딱 1개만 생성되는 것을 보장하는 디자인 패턴이다.
  • 해당되는 인스턴스에 대해서 static을 통해 최초 1번만 메모리를 할당시키며 이후 해당 인스턴스에 대한 호출이 생길 때 마다 최초로 생긴 인스턴스를 사용한다.
  • 그래서 private를 사용해 객체 인스턴스를 2개 이상 생성하지 못하도록 막는다.
  • 싱글톤 패턴 적용 예제를 위해 SingletonService 클래스를 생성한다.
package hello.core.singleton;

public class SingletonService {
    //static 객체라서 클래스레벨로 딱 한개만 생성된다.
    //Lazy-init 방식은 아니다 => static 으로 바로 생성해버림
    private static final SingletonService instance = new SingletonService();

    public static SingletonService getInstance() {
        return instance;
    }

    //Using Private Constructor to prevent making new instance
    private SingletonService() {}

    public void logic() {
        System.out.println("singleton logic");
    }

}
  • static 영역에 객체 instance를 미리 하나 생성해서 올려둔다.
  • 해당 객체 인스턴스가 필요하면 오직 getInstance()메서드를 통해서만 접근 할 수 있다.해당 메서드를 호출하면 항상 같은 인스턴스를 반환한다.
  • 생성자를 private로 막아서 instance가 여러개 생성되는것을 막는다.
  • 위와 같은 과정으로 싱글톤 패턴을 적용 시킬 수 있다.
  • SingletonTest 클래스 내에 singletonServiceTest() 메서드를 생성해서 해당 싱글톤 패턴을 사용하는 테스트 코드를 작성해보자.
@Test
@DisplayName("싱글톤 패턴을 적용한 객체 사용") public void singletonServiceTest() {
//private으로 생성자를 막아두었다. 컴파일 오류가 발생한다. //new SingletonService();
//1. 조회: 호출할 때 마다 같은 객체를 반환
SingletonService singletonService1 = SingletonService.getInstance();
//2. 조회: 호출할 때 마다 같은 객체를 반환
SingletonService singletonService2 = SingletonService.getInstance();
//참조값이 같은 것을 확인
System.out.println("singletonService1 = " + singletonService1); System.out.println("singletonService2 = " + singletonService2);
      // singletonService1 == singletonService2
      assertThat(singletonService1).isSameAs(singletonService2);
	singletonService1.logic();
}
  • 해당 테스트 코드를 실행해보면 아래와 같이 호출할때 마다 동일한 인스턴스가 반환되는 것을 볼 수 있다.

싱글톤 패턴의 단점

  • 싱글톤 패턴을 사용하는 다른 객체들간의 결합도(의존성)이 높아지기 때문에 객체 지향 설계 원칙에 위배된다.즉, 클라이언트가 구체 클래스에 의존한다.
  • 내부 설계를 변경하거나 초기화하기가 어렵다.
  • private 생성자를 사용하기 때문에 자식 클래스를 만들기 어렵다.
  • 결론적으로 유연하지 않다라는 큰 단점이 존재한다.

싱글톤 컨테이너(스프링 컨테이너)

  • 스프링 컨테이너는 싱글톤 패턴을 적용하지 않아도, 객체 인스턴스를 싱글톤으로 관리한다.
  • 그래서 스프링 컨테이너는 싱글톤 패턴의 문제점을 해결하면서, 객체 인스턴스를 싱글톤(1개만 생성)으로 관리한다.
  • 지금까지 써 왔던 스프링 빈이 바로 싱글톤 패턴으로 관리되는 빈이다.
  • 스프링 컨테이너를 사용해서 테스트 코드를 작성해보자.
@Test
@DisplayName("스프링 컨테이너와 싱글톤")
  void springContainer() {
      ApplicationContext ac = new
  AnnotationConfigApplicationContext(AppConfig.class);
	//1. 조회: 호출할 때 마다 같은 객체를 반환
      MemberService memberService1 = ac.getBean("memberService",
  MemberService.class);
	//2. 조회: 호출할 때 마다 같은 객체를 반환
      MemberService memberService2 = ac.getBean("memberService",
  MemberService.class);
	//참조값이 같은 것을 확인
System.out.println("memberService1 = " + memberService1); System.out.println("memberService2 = " + memberService2);
      //memberService1 == memberService2
      assertThat(memberService1).isSameAs(memberService2);
  }

  • 위의 테스트 코드도 다른 클라이언트의 요청에 동일한 static 객체를 반환한다.

싱글톤 방식의 주의점

  • 싱글톤 패턴 또는 싱글톤 컨테이너든 객체 인스턴스를 하나만 생성해서 사용하는 방식의 경우 여러 클라이언트가 하나의 객체를 공유하기 때문에 해당 객체를 상태를 유지시키는 구조로 설계해서는 안된다.
  • 위에서 언급한 싱글톤 패턴의 단점과도 일맥상통하는 부분이며, 조금 더 풀어서 설명하면 특정 클라이언트가 특정값을 변경할 수 있게 하며 안된다는 뜻이다.
  • 해당 문제점을 가지는 예시코드를 작성해보자.
  • StatefulService 클래스를 먼저 생성한다.
   package hello.core.singleton;

  	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;
} 
}
  • 이후 해당 클래스의 테스트 클래스 작성한다.
package hello.core.singleton;

class StatefulServiceTest {
    @Test
    void statefulServiceSingleton() {
        ApplicationContext ac = new AnnotationConfigApplicationContext(TestConfig.class);
        StatefulService statefulService1 = ac.getBean(StatefulService.class);
        StatefulService statefulService2 = ac.getBean(StatefulService.class);

        /*
        * A가 주문을하고 주문금액 조회하는 사이에
        * B가 주문을 해버린 상황
        * */

        //Thread A : A 사용자가 10000원 주문
        statefulService1.order("userA",10000);
        //Thread B : B 사용자가 10000원 주문
        statefulService2.order("userB",20000);

        //Thread A : A 사용자가 주문금액조회
        int price = statefulService1.getPrice();
        System.out.println("A의 price = " + price);

        Assertions.assertThat(statefulService1.getPrice()).isEqualTo(statefulService2.getPrice());
    }

    static class TestConfig {
        @Bean
        public StatefulService statefulService() {
            return new StatefulService();
        }
    }
}

  • 위 테스트의 결과는 아래처럼 A사용자의 주문금액(10000원)이 B사용자의 주문금액으로 할당되어지는 오류를 보여준다.
  • 즉 이러한 구조가 클라이언트가 특정값을 변경할수 있게 하는구조, stateful한 구조라 할 수 있다.
  • 그렇기에 공유필드는 항상 조심해서 로직을 처리해야 하며, 스프링 빈은 항상 무상태로 설계해야한다

@Configuration과 싱글톤

  • @Configuration의 역할을 알아보기 위해, AppConfig 코드를 보자
@Configuration
public class AppConfig {
    @Bean
    public MemberService memberService() {
        System.out.println("call AppConfig.memberService");
        return new MemberServiceImpl(memberRepository());
    }
    @Bean
    public OrderService orderService() {
        System.out.println("call AppConfig.orderService");
        return new OrderServiceImpl(memberRepository(), discountPolicy());
    }
    @Bean
    public MemoryMemberRepository memberRepository() {
        System.out.println("call AppConfig.memberRepository");
        return new MemoryMemberRepository();
    }
    @Bean
    public DiscountPolicy discountPolicy() {
        return new RateDiscountPolicy();
    }
}
  • memberService 빈을 만드는 메서드를 호출하면 memberRepository()메서드를 호출한다.
  • 위와 마찬가지로 orderService 빈을 만드는 메서드를 호출하면 동일하게 memberRepository()를 호출한다.
  • memberRepository()는 new MemoryMemberRepository()를 호출한다.
  • 결과적으로 서로 다른 new MemoryMemberRepository()가 생성되면서 싱글톤이 깨지는 것처럼 보인다.
  • 하지만 당연히 스프링 컨테이너는 해당 인스턴스를 싱글톤 방식으로 처리한다.
  • 이러한 과정이 어떻게 일어나는지 이제부터 테스트하면서 검증해보자.
  • 먼저 MemberServiceImpl,OrderServiceImpl에 테스트 용도의 메서드를 추가하자.
//테스트 용도
public MemberRepository getMemberRepository() {
          return memberRepository;
      }
  • 이후 ConfigurationSingletonTest 생성후 configurationTest()메서드를 생성한다.
public class ConfigurationSingletonTest {

    //모든 인스턴스가 5번이 아니라 3번이 호출된다.
    @Test
    void configurationTest() {
        AnnotationConfigApplicationContext 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);

        //모두 같은 인스턴스를 참고하고 있다.
        System.out.println("memberService -> memberRepository = " + memberService.getMemberRepository());
        System.out.println("orderService -> memberRepository  = " + orderService.getMemberRepository());
        System.out.println("memberRepository = " + memberRepository); //모두 같은 인스턴스를 참고하고 있다.

        assertThat(memberService.getMemberRepository()).isSameAs(memberRepository);
        assertThat(orderService.getMemberRepository()).isSameAs(memberRepository);
    }
}
  • 아래의 사진처럼 new MemoryMemberRepository()를 호출한 각각의 memberService,orderService뿐만 아니라 확인용 memberRepository 또한 같은 인스턴스를 사용하고 있다.
  • 즉, 우려한 바와 달리 스프링 컨테이너에서 자동으로 싱글톤 패턴을 유지하면서 객체를 활용시키고 있다.
configurationTest() 메서드 결과
configurationTest() 메서드 결과

스프링 컨테이너의 빈 호출 횟수 알아보기

  • 예상되는 호출 횟수는 memberService(1회),orderService(1회), memberRepository(3회)로 총 5회이다.
  • 호출 횟수 확인을 위해 아래 코드를 AppConfig 클래스의 메서드에 추가한다.
System.out.println("call AppConfig.해당 인스턴스");
  • 아래와 같이 모든 빈들이 한번만 호출된다는 결과를 볼 수 있다. 중복되어 호출될줄 알았던 memberRepository는 한번의 호출만 진행되며, 해당 인스턴스를 공유하며 로직을 처리하고 있다.
스프링 빈 호출 횟수
스프링 빈 호출 횟수

@Configuration과 바이트 코드

  • 위에서 진행한 코드를 순수 자바 코드로만 분석하면, 분명히 5번 호출 되는게 맞다, 하지만 중복되는 호출을 줄이기 위해 AppConfig의 @Configuration이 바이트 코드를 조작해서 이 문제를 해결한다.
  • 스프링은 바이트 코드 조작을 위한 CGLIB이라는 라이브러리를 사용하며,이는 AppConfig를 상속받은 임의의 다른 클래스를 만들고 해당 클래스를 스프링 빈으로 등록한다.
  • 위 과정을 보기 위해 아래의 테스트 코드를 ConfigurationSingletonTest에 추가하자.
    @Test
    void configurationDeep() {
        AnnotationConfigApplicationContext ac = new AnnotationConfigApplicationContext(AppConfig.class);
        AppConfig bean = ac.getBean(AppConfig.class);

        System.out.println("bean = " + bean.getClass());
    }
  • 위의 테스트 코드는 아래와 같은 결과를 출력하는데, AppConfig가 순수한 class hello.core.AppConfig로만 이루어지지 않는다는것 볼 수 있다.
  • 언급한 CGLIB에 의해 새롭게 정의된 클래스를 스프링 빈의 AppConfig로 올려준다.
  • 일련의 과정을 진행해주는 어노테이션이 @Configuration 이다.
configurationDeep() 메서드 결과
configurationDeep() 메서드 결과

소스코드 : https://github.com/brido4125/core-spring

해당 글은 김영한님의 <스프링 핵심> 강좌를 기반으로 작성되었습니다.