5. 싱글톤 컨테이너

jinhxxxxkim·2023년 1월 30일
0

Spring핵심

목록 보기
5/9
post-thumbnail

5. 싱글톤 컨테이너

1. 싱글톤 컨테이너 - 웹 애플리케이션과 싱글톤

  • 대부분의 스프링 애플리케이션은 웹 애플리케이션이다
  • 웹 애플리케이션은 보통 여러 고객이 동시에 요청을 한다
  • 스프링 없는 순수한 DI 컨테이너인 AppConfig는 요청을 할 때 마다 객체를 새로 생성한다.
  • 고객 트래픽이 초당 100이 나오면 초당 100개 객체가 생성되고 소멸된다
    \to 메모리 낭비가 심하다
  • 해결방안은 해당 객체가 딱 1개만 생성되고, 공유하도록 설계하면 된다.
    \to 싱글톤 패턴

스프링 없는 순수한 DI 컨테이너 Test

//0. 순수한 DI 컨테이너 생성
AppConfig appConfig = new AppConfig();

//1. 조회: 호출할 때마다 객체 생성
MemberService memberService1 = appConfig.memberService();

//2. 조회: 호출할 때마다 객체 생성
MemberService memberService2 = appConfig.memberService();

// 참조값이 다름을 확인
Assertions.assertThat(memberService1).isNotSameAs(memberService2);
  • 고객 트래픽이 2회 발생한 경우다.
  • 트래픽 발생마다 memberService메소드를 호출하여 memberService객체를 생성한다.
  • memberService메소드를 호출할 경우, 내부적으로 memberRepository객체 또한 생성되어 트래픽 1회 발생마다 2개의 객체가 생성된다.
  • 따라서 싱글톤 패턴을 사용하여 메모리 낭비를 줄여야한다.

2. 싱글톤 컨테이너 - 싱글톤 패턴

  • 클래스의 인스턴스가 딱 1개만 생성되는 것을 보장하는 디자인 패턴이다
  • 즉 하나의 JVM안에서는 객체 인스턴스가 1개만 생성된다
  • 따라서 객체 인스턴스를 2개 이상 생성하지 못하도록 막아야 한다
    \to private 생성자를 사용해서 외부에서 임의로 new 키워드를 사용하지 못하도록 막아야 한다.

싱글톤 패턴 적용

public class SingletonService {

    private static final SingletonService instance = new SingletonService();

    public static SingletonService getInstance(){
        return instance;
    }

    private SingletonService(){}

    public void logic(){
        // 싱글톤 객체 로직 호출
        ...
    }
}
  • static 영역에 객체 instance를 미리 하나 생성한다
  • 해당 객체 인스턴스가 필요하다면, getInstance()메소드를 통해서만 조회할 수 있으며, 이 메소드를 호출하면 항상 동일한 인스턴스를 반환한다.
  • 단 하나의 인스턴스만 존재해야하므로, 생성자를 private으로 선언하여 외부에서 객체 인스턴스가 생성되는 것을 방지한다.

싱글톤 패턴 적용 Test

void singletonServiceTest(){
	SingletonService singletonService1 = SingletonService.getInstance();
    SingletonService singletonService2 = SingletonService.getInstance();

	assertThat(singletonService1).isSameAs(singletonService2);
}
  • 2개의 인스턴스를 getInstance()메소드를 통해 조회한 후, 동일한 인스턴스인지 확인한다.
  • isSameAs(): ==연산과 동일하며, 인스턴스의 참조값을 비교한다.
  • isEqualTo(): equal과 동이라면, 인스턴스의 값 자체를 비교한다.

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

싱글톤 패턴 단점

  • 싱글톤 패턴을 구현하는 코드 자체가 많이 들어간다.
    • 싱글톤 패턴의 로직만 필요한데, static 인스턴스를 생성하는 등의 오버헤드가 크다.
  • 의존관계상 클라이언트가 구체 클래스에 의존한다. \to DIP를 위반한다.
    • getInstance()메소드를 통해 참조해야 하므로, 구체클래스에 의존하게 된다.
  • 클라이언트가 구체 클래스에 의존해서 OCP 원칙을 위반할 가능성이 높다.
  • 테스트하기 어렵다.
  • 내부 속성을 변경하거나 초기화 하기 어렵다.
  • private 생성자로 자식 클래스를 만들기 어렵다.
  • 결론적으로 유연성이 떨어진다.
    • DI를 적용하기 어렵다.
  • 안티패턴으로 불리기도 한다.

3. 싱글톤 컨테이너 - 싱글톤 컨테이너

  • 스프링 컨테이너는 위의 싱글톤 패턴의 문제점을 해결하면서, 객체 인스턴스를 싱글톤으로 관리한다.
  • 지금까지의 스프링 빈이 싱글톤으로 관리되는 빈이다.

싱글톤 컨테이너

  • 스프링 컨테이너는 싱글톤 패턴을 적용하지 않아도, 객체 인스턴스를 싱글톤으로 관리한다.
  • 스프링 컨테이너 덕분에 고객의 요청이 올 때 마다 객체를 생성하는 것이 아니라, 이미 만들어진 객체를 공유해서 효율적으로 재사용할 수 있다
  • 이전의 컨테이너의 생성과정에서 컨테이너는 객체를 하나만 생성해서 관리한다.
  • 스프링 컨테이너는 싱글톤 컨테이너의 역할을 하며, 이러한 싱글톤 객체를 생성하고 관리하는 기능을 싱글톤 레지스트리라고 한다.
  • 싱글톤 패턴을 위한 지저분한 코드가 들어가지 않아도 된다.
  • DIP, OCP, 테스트, private 생성자로 부터 자유롭게 싱글톤을 사용할 수 있다.

싱글톤 컨테이너 Test

void SpringContainer(){
	ApplicationContext ac = new AnnotationConfigApplicationContext(AppConfig.class);
	MemberService memberService1 = ac.getBean("memberService", MemberService.class);
    MemberService memberService2 = ac.getBean("memberService", MemberService.class);
    
    assertThat(memberService1).isSameAs(memberService2);
}
  • AnnotationConfigApplicationContext 클래스를 통해 스프링 컨테이너를 생성하며, AppConfig를 설정 정보로 전달하여, 빈을 등록한다.
  • 해당 빈들은 싱글톤으로 관리하기 때문에 Test를 통과한다.

4. 싱글톤 컨테이너 - 싱글톤 방식의 주의점

  • 싱글톤 방식은 여러 클라이언트가 하나의 같은 객체 인스턴스를 공유하기 때문에 싱글톤 객체는 상태를 유지(stateful)하게 설계하면 안된다.
    • 하나의 객체가 갖는 상태를 여러 클라이언트가 공유하는 결과가 생긴다.
  • 무상태(stateless)로 설계해야 한다.
    • 특정 클라이언트에 의존적인 필드가 있으면 안된다.
    • 특정 클라이언트가 값을 변경할 수 있는 필드가 있으면 안된다.
    • 가급적 읽기만 가능해야 한다.
    • 필드 대신에 자바에서 공유되지 않는, 지역변수, 파라미터, ThreadLocal 등을 사용해야 한다.

Stateful의 문제

public class StatefulService {

    private int price; // stateful한 필드

    public void order(String name, int price) {
        System.out.println("name = " + name + " price = " + price);
        this.price = price; // 문제!
    }
    
    public int getPrice() { return price;}
}
  • 사용자가 주문order()메소드를 호출할 수 있다.
  • 주문을 통해 price라는 필드의 값을 변경하게 된다.
  • getPrice()메소드를 통해 자신의 주문 가격을 조회할 수 있다.

StatefulService Test

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

        // TreadA: A사용자가 10000원 주문
        statefulService1.order("userA", 10000);

        // TreadB: B사용자가 20000원 주문
        statefulService2.order("userB", 20000);

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

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

    static class TestConfig{
        @Bean
        public StatefulService statefulService(){
            return new StatefulService();
        }
    }
  • Test를 위한 TestConfig라는 static 클래스를 선언한다.
  • 스프링 컨테이너를 AnnotationConfigApplicationContext를 사용하여 선언하며, TestConfig클래스를 설정정보로 전달한다.
  • 그러면, 컨테이너 내부에는 statefulService라는 이름의 스프링 빈 1개가 싱글톤으로 관리되게 된다.
  • ThreadA가 사용자A 코드를 호출하고, ThreadB가 사용자B 코드를 호출한다 가정하자
  • StatefulServiceprice 필드는 공유되는 필드인데, 특정 클라이언트가 값을 변경한다.
  • A 사용자가 10,000원을 주문한 후, 조회하는 사이에 B 사용자가 20,000원을 주문할 경우, 공유되는 필드인 price의 값이 20,000원으로 변경되게 된다.

Stateless 설계

StatefulService class

public class StatefulService {
    public int order(String name, int price) {
        System.out.println("name = " + name + " price = " + price);
		return price;
    }
}

StatefulServiceTest

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

    // TreadA: A사용자가 10000원 주문
    int userAPrice = statefulService1.order("userA", 10000);

    // TreadB: B사용자가 20000원 주문
    int userBPrice = statefulService2.order("userB", 20000);
 	
    Assertions.assertThat(userAPrice).isEqualTo(10000);
}
  • 기존의 공유되는 필드인 price를 삭제하였다.
  • 공유되지 않는 지역변수( userAPrice, userBPrice )를 사용한다.

5. 싱글톤 컨테이너 - @Configuration과 싱글톤

AppConfig class

public class AppConfig {
    @Bean
    public MemberService memberService(){
        return new MemberServiceImpl(memberRepository());
    }
    @Bean
    public MemberRepository memberRepository() {
        return new MemoryMemberRepository();
    }
    @Bean
    public OrderService orderService(){
        return new OrderServiceImpl(memberRepository(), discountPolicy()); 
    }
    ...
}
  • 스프링 컨테이너에 스프링 빈을 등록 할 때, 메소드를 호출하게 되는데, 이 때 MemoryMemberRepository()가 2번 호출 되며 싱글톤이 꺠지는 것 처럼 보인다.

검증 용도의 코드 추가

MemberServiceImpl class

public class MemberServiceImpl implements MemberService{
	private final MemberRepository memberRepository;
    ...
    // test 용도
    public MemberRepository getMemberRepository() {
        return memberRepository;
    }
}

OrderServiceImpl class

public class OrderServiceImpl implements OrderService{
	private final MemberRepository memberRepository;
	...
    // test 용도
    public MemberRepository getMemberRepository() {
        return memberRepository;
    }
}
  • 각각의 클래스에 memberRepository를 조회하기 위한 메소드를 선언한다.

AppConfig Test

  • MemberService, orderService 메소드 모두 memberRepository메소드를 호출하는데 과연 모두 동일한 인스턴스를 참조하는 지 확인해보자.
@Test
void configurationTest(){
	ApplicationContext ac = new AnnotationConfigApplicationContext(AppConfig.class);

    MemberServiceImpl memberService1 = ac.getBean("memberService", MemberServiceImpl.class);
    OrderServiceImpl orderService1 = ac.getBean("orderService", OrderServiceImpl.class);
    MemberRepository memberRepository = ac.getBean("memberRepository", MemberRepository.class);

    MemberRepository memberRepository1 = memberService1.getMemberRepository();
    MemberRepository memberRepository2 = orderService1.getMemberRepository();

    System.out.println("Real => memberRepository = " + memberRepository);
    System.out.println("MemberService => memberRepository1 = " + memberRepository1);
    System.out.println("OrderService => memberRepository2 = " + memberRepository2);
	
    assertThat(memberService1.getMemberRepository()).isSameAs(memberRepository);
	assertThat(orderService1.getMemberRepository()).isSameAs(memberRepository);
}

출력

Real => memberRepository = hello.core.member.MemoryMemberRepository@37052337
MemberService => memberRepository1 = hello.core.member.MemoryMemberRepository@37052337
OrderService => memberRepository2 = hello.core.member.MemoryMemberRepository@37052337
  • 실제 스프링 컨테이너 내부에 생성된 memberRepository 인스턴스와 MemberService, orderService 메소드가 파라미터로 전달하는 memberRepository 인스턴스가 정확히 일치하는 것을 확인할 수 있다.
  • 즉, memberRepository 인스턴스는 모두 같은 인스턴스가 공유되어 사용된다.
// 예상
call AppConfig.memberService
call AppConfig.memberRepository
call AppConfig.memberRepository
call AppConfig.orderService
call AppConfig.memberRepository

// 실제
call AppConfig.memberService
call AppConfig.memberRepository
call AppConfig.orderService
  • AppConfig에 호출 로그 남겨 확인해보면 모든 빈은 1번만 호출된다

5. 싱글톤 컨테이너 - @Configuration과 바이트코드 조작의 마법

  • 스프링 컨테이너는 싱글톤 레지스트리이므로 스프링 빈이 싱글톤이 되도록 보장해주어야 한다.
  • 자바코드를 통해 빈을 등록할 경우 위의 경우 3번의 memberRepository 등록이 이루어져야할것으로 보여진다.
  • 하지만 스프링은 클래스의 바이트코드를 조작하는 라이브러리를 사용해 1번만 등록하게 한다.
ApplicationContext ac = new AnnotationConfigApplicationContext(AppConfig.class);
AppConfig bean = ac.getBean(AppConfig.class);

System.out.println("bean = " + bean.getClass());
  • AppConfig 또한 스프링 빈으로 등록이 된다.
  • AppConfig 스프링 빈을 조회하여 클래스 정보를 조회해본다.

출력

bean = class hello.core.AppConfig$$EnhancerBySpringCGLIB$$68989ba6
  • 순수한 클래스라면, class hello.core.AppConfig라고 출략되어야한다.
  • 이는 스프링이 CGLIB라는 바이트코드 조작 라이브러리를 사용해서 AppConfig 클래스를 상속받은 임의의 다른 클래스를 만들고, 그 다른 클래스를 스프링 빈으로 등록한 것이다.
  • 해당 임의의 다른 클래스가 싱글톤이 되도록 보장해준다.

AppConfig@CGLIB 예상 코드

@Bean
public MemberRepository memberRepository() {
 	if (memoryMemberRepository가 이미 스프링 컨테이너에 등록되어 있으면?) {
		return 스프링 컨테이너에서 찾아서 반환;
 	} else { //스프링 컨테이너에 없으면
 		기존 로직을 호출해서 MemoryMemberRepository를 생성하고 스프링 컨테이너에 등록
 		return 반환
 	}
}
  • 이미 스프링 빈이 존재할 경우, 존재하는 빈을 반환하고, 아니라면 생성하여 스프링 빈으로 등록하여 반환하는 코드가 동적으로 만들어진다.
  • 따라서 싱글톤이 보장될 수 있다.
  • 참고:
    • AppConfig@CGLIB는 AppConfig의 자식 타입이므로, AppConfig 타입으로 조회 할 수 있다.

@Configuration 을 적용하지 않을 경우

  • @Configuration 을 붙이면 바이트코드를 조작하는 CGLIB 기술을 사용해서 싱글톤을 보장한다

출력 1.

bean = class hello.core.AppConfig
  • AppConfig가 CGLIB 기술 없이 순수한 AppConfig로 스프링 빈에 등록된 것을 확인할 수 있다.

출력 2.

call AppConfig.memberService
call AppConfig.memberRepository
call AppConfig.orderService
call AppConfig.memberRepository
call AppConfig.memberRepository
  • MemberRepository가 총 3번 호출되었다.

출력 3.

Real => memberRepository = hello.core.member.MemoryMemberRepository@1c481ff2
MemberService => memberRepository1 = hello.core.member.MemoryMemberRepository@72437d8d
OrderService => memberRepository2 = hello.core.member.MemoryMemberRepository@1b955cac
  • 각각 다 다른 MemoryMemberRepository 인스턴스를 가지고 있다.
  • 스프링 빈으로 등록은 되지만, 싱글톤을 보장하지 않는다.
    • memberRepository() 처럼 의존관계 주입이 필요해서 메서드를 직접 호출할 때 싱글톤을 보장하지 않는다.

출처: 인프런 스프링 핵심 원리 - 기본편 (김영한)
인프런 스프링 핵심 원리

0개의 댓글