싱글톤 컨테이너

권영태·2024년 1월 5일
0

스프링

목록 보기
16/18

출처 : 스프링 핵심 원리 - 기본편


스프링 애플리케이션은 대부분 웹 애플리케이션이다. 웹 애플리케이션은 보통 여러 고객이 동시에 요청한다.

순수 DI 컨테이너

만약, 스프링 없는 순수 DI 컨테이너를 통해 여러 고객이 조회 요청한다면 어떻게 될까?

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

	//memberService1 != memberService2
	assertThat(memberService1).isNotSameAs(memberService2);
}

Test결과를 확인하니 두 객체 참조값이 달랐고, AppConfig 요청을 할 때마다 객체를 새로 생성한다는 사실을 확인했다. 트래픽이 초당 100 아니 1,000번이 발생한다면 1,000개의 객체가 생성되고 소멸된다. 즉, 메모리 낭비가 심하다.
위 문제를 해결할 수 있는 방안은 객체를 딱 1개만 생성하고, 이를 공유하도록 설계하면 된다.
그래서 만들어진 디자인 패턴이 바로 싱글톤 패턴이다.

싱글톤 패턴

  • 클래스의 인스턴스가 딱 1개만 생성되는 것을 보장하는 디자인 패턴.
  • 어떠한 경우라도, 1개만 생성되어야 되기 때문에 private 생성자를 사용하여 외부에서 임의로 new 키워드를 사용하지 못하도록 막아야 한다!

싱글톤 패턴을 구현하는 방법은 여러가지가 있다. 본 글에서는 생략한다

  • 싱글톤 패턴은 이미 만들어진 객체를 공유해서 효율적으로 사용할 수 있지만, 여러 문제점들을 가지고 있다.
    • 싱글톤 패턴 구현 코드가 많다.
      • private static 변수 선언, getInstance(), 생성자 등
    • 의존관계상 클라이언트가 구체 클래스에 의존한다. -> DIP/OCP 위반
      • 구체 클래스.getInstance() 방식으로 가져와야 한다.
    • private 생성자로 자식 클래스를 만들지 못하며, 내부 속성을 변경하거나 테스트하기 어렵다.
    • 그래서 안티 패턴으로 불리기도 한다.

싱글톤 컨테이너

스프링 컨테이너는 싱글톤 패턴의 문제점을 해결하면서, 객체 인스턴스를 싱글톤으로 관리한다.
전 글에서 학습한 스프링 빈이 바로 싱글톤으로 관리되는 빈이다.

  • 스프링 컨테이너는 객체 하나만 생성해서 관리하기 때문에 싱글톤 컨테이너 역할을 한다.
    이렇게 싱글톤 객체를 생성하고 관리하는 기능을 싱글톤 레지스트리라 한다.
  • 결론적으로 이런 기능 덕분에 싱글톤 패턴의 모든 단점을 해결하면서 객체를 싱글톤으로 유지할 수 있다.
    • 싱글톤 패턴을 위한 지저분한 코드❌
    • DIP/OCP를 지키면서 자유롭게 싱글톤 사용할 수 있다.
@Test
@DisplayName("스프링 컨테이너와 싱글톤")
void springContainer() {
//	AppConfig appConfig = new AppConfig();
    ApplicationContext 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);

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

스프링 컨테이너 ApplicationContext를 사용해 AppCofig.class를 빈으로 등록시키고, 두 서비스를 호출하여 참조값을 비교해보니 같은 인스턴스임을 확인할 수 있다.

즉, 고객의 요청이 올 때 마다 객체를 생성하는 것이 아니라, 이미 만들어진 객체를 공유해서 효율적으로 재사용할 수 있다.


싱글톤 방식의 주의점

  • 하나의 객체 인스턴스를 공유하는 싱글톤 방식은 여러 클라이언트가 하나의 같은 객체를 공유하기 때문에 싱글톤 객체는 상태를 유지(stateful)하게 설계하면 안된다.

문제를 확인해보기 위해 order() 호출 시 필드 price를 공유하는 다음과 같은 코드가 존재한다.

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

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

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

order() 호출 시 price 필드를 공유하기 때문에 B사용자의 주문 금액이 저장되어 사용자A 주문 금액 조회 시 20,000이 조회되는 문제가 발생하기 때문이다.

문제를 해결하기 위해선 원초적으로 상태를 유지하지 않는 무상태(stateless)로 설계해야 한다.

  • 특정 클라이언트에 의존적인 필드가 있으면 안된다.
  • 특정 클라이언트가 값을 변경할 수 있는 필드가 있으면 안된다. 즉, 읽기만 가능해야 한다.
  • 필드 대신에 공유되지 않는 지역변수, 파라미터, ThreadLocal 등을 사용해야 한다.

Configuration과 싱글톤

@Configuration
public class AppConfig {

    @Bean
    public MemberService memberService() {
        System.out.println("call AppConfig.memberService");
        return new MemberServiceImpl(memberRepository());
    }

    @Bean
    public MemberRepository 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 FixDiscountPolicy();
        return new RateDiscountPolicy();
    }
}

위 코드를 보고 있으면 뭔가 이상하다. 스프링 컨테이너가 실행될 때 각 빈들을 호출하면 각 빈들이 몇번 호출 되는지 세어보자.

  • memberService(): 1번
  • memerRepository(): 3번
  • orderService(): 1번
  • discountPolicy(): 1번

어.. 이상하다. 그렇다면 memerRepository()는 2개 저장되어 싱글톤 패턴이 깨지는게 아닐까?
AppCofig.class의 각 빈을 호출할 때 call AppCofig.~~를 출력시키고, memberService()orderService()MemberRepository가 다른지 비교해보자.
싱글톤 패턴이 깨진다면 memberService()orderService() 호출 시 생성되는 MemberRepository가 다르게 확인될 것이다.

@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 -> memberRepository = " + memberRepository1);
    System.out.println("orderService -> memberRepository = " + memberRepository2);
    System.out.println("memberRepository = " + memberRepository);
	Assertions.assertThat(memberService.getMemberRepository()).isSameAs(memberRepository);
 	Assertions.assertThat(orderService.getMemberRepository()).isSameAs(memberRepository);
}

테스트 결과는 memeberService()orderService() 그리고 memberRepository()까지 같은 MemberRepository가 생성되었음을 확인할 수 있었고, 각 메서드들은 한번 씩 출력됨을 출력문을 통해 확인할 수 있다.

위 테스트 결과가 나온 이유는 바로 스프링이 클래스의 바이트코드를 조작하는 라이브러리를 사용하기 때문이다.
@Configuration을 사용하면 내가 만든 클래스를 스프링 빈으로 등록하는게 아니라, CGLIB라는 바이트코드 조작 라이브러리를 사용해서 AppConfig 클래스를 상속받은 임의의 다른 클래스를 만들어 이를 스프링 빈으로 등록한다.

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

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

AppConfig의 className을 예상해보면 hello.core.AppCofig가 나와야되지만, 실제로는 뒤에 ~~CGLIB가 붙어있는 className을 볼 수 있다.

즉 스프링 컨테이너에는 실제로 다음 그림과 같이 CGLIB를 통해 만들어진 인스턴스가 들어가 있다.

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

이 코드는 김영한 강사님께서 예시로 든 AppConfig@CGLIB 예상 코드다.
스프링 컨테이너는 AppCofig.class를 복제한 AppConfig@CGLIB.class를 만들고 위 코드와 같은 로직을 통해 이미 스프링 빈이 존재하면 존재하는 빈을 반환하고, 스프링 빈이 없다면 생성해서 등록하고 반환하는 코드가 만들어진다.
위 같은 방법을 사용하여 싱글톤을 보장시킨다.

  • @Bean만 사용해도 스프링 빈으로 등록되지만, 싱글톤을 보장하지 않는다.
  • 크게 고민할 것 없이 스프링 설정 정보는 항상 @Configuration을 사용하자!

정리

  • 스프링 컨테이너는 객체 인스턴스를 하나만 생성해서 관리하기 때문에 싱글톤 컨테이너 역할을 한다. -> 싱글톤 패턴 보장
  • 싱글톤 방식을 지키기 위해선 유지(stateful)이 아닌 무상태(stateless)로 설계하자!
  • 스프링 컨테이너에 등록되는 객체는 순수 객체가 아닌 스프링이 조작한 객체가 등록된다.




생각 정리

지난 여름 미니 프로젝트를 진행하다, 어디에서 '싱글톤이 좋다', '싱글톤 개발을 해야한다.'라는 말들을 듣고 '스프링에서 싱글톤은 어떻게 적용 시키는거지?' 하며 구글링을 열심히 찾아봤던 기억이 있다.
개념만 잠깐 공부하다 어려워보여서 창을 닫긴했지만..
이번 싱글톤 컨테이너를 공부하면서, 스프링 컨테이너와 빈 그리고 싱글톤 컨테이너까지 연결고리가 딱 딱 맞춰지는 느낌이 들어서 너무 좋고, 개념을 터득하니 나중에 프로젝트를 진행할 때 좀 더 좋은 코드들을 작성할 수 있다는 자신감이 든다.

profile
GitHub : https://github.com/dudxo

0개의 댓글