Inflearn/스프링 핵심 원리_기본편

싱글톤 컨테이너 - 2 (싱글톤 주의점, @Configuration, 바이트 코드 조작)

soohykeee 2023. 2. 24. 21:46
728x90

 

싱글톤 컨테이너 - 1 (싱글톤 패턴 문제점 + 사용이유, 싱글톤 컨테이너)

 

싱글톤 컨테이너 - 1 (싱글톤 패턴 문제점 + 사용이유, 싱글톤 컨테이너)

스프링 컨테이너와 스프링 빈 (스프링 컨테이너 생성+과정, 스프링 빈 조회, BeanDefinition) 스프링 컨테이너와 스프링 빈 (스프링 컨테이너 생성+과정, 스프링 빈 조회, BeanDefinition) 스프링 핵심 원

soohykeee.tistory.com

 


 

싱글톤 방식의 주의점

앞서 싱글톤에 대해 알아보고, 테스트 코드를 통해 싱글톤을 사용도 해보았다. 싱글톤 패턴이나, 스프링 같은 싱글톤 컨테이너 사용처럼 객체 인스턴스를 하나만 생성하여 공유하는 싱글톤 방식은 주의할 점이 존재한다. 여러 클라이언트가 하나의 같은 객체 인스턴스를 공유하기에 싱글톤 객체는 상태를 유지(stateful)하게 설계하면 안되고, 무상태(stateless)로 설계해야 한다.

무상태는 간단히 설명하면 다음과 같은 특징을 갖고있다.

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

 

만약 유지(stateful)하게 설계했을 경우 발생하는 문제점을 테스트를 통해 알아보겠다. 앞서처럼 간단한 테스트 진행을 위해 test 디렉토리 하위에 StatefulService, StatefulServiceTest 클래스를 생성해준다.

 

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;
    }
}
public class StatefulServiceTest {

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

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

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

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

        //ThreadA : 사용자A 주문 금액 조회
        int price = statefulService1.getPrice();
        //기대와 다르게 10000원이 아닌 20000원 출력
        System.out.println("price = " + price);

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

단순한 테스트 코드 진행을 위해 실제 Thread는 사용하지 않는 테스트 코드를 작성했다. 위의 코드를 보면, 최종 price가격이 10000원으로 출력이 될 것 같지만, 예상과는 다르게 20000원이 출력이 된다. StatefulService의 price 필드는 공유되는 필드인데, 특정 클라이언트가 값을 변경했기에 값이 달라져 출력이 되는 것이다. 이처럼 공유필드는 아주 조심해서 설계해야 한다. 스프링 빈은 항상 무상태(stateless)로 설계해줘야 한다.

 


 

@Configuration과 싱글톤

앞서 만들었던 AppConfig 클래스를 보자

@Configuration
public class AppConfig {

    @Bean
    public MemberRepository memberRepository() {
        return new MemoryMemberRepository();
    }

    @Bean
    public DiscountPolicy discountPolicy() {
        return new RateDiscountPolicy();
    }

    @Bean
    public MemberService memberService() {
        return new MemberServiceImpl(memberRepository());
    }

    @Bean
    public OrderService orderService() {
        return new OrderServiceImpl(memberRepository(), discountPolicy());
    }

}

위의 코드에서 memberService와 orderService 빈을 만드는 코드를 보면 동일하게 memberRepository()를 호출한다. 또한 해당 메서드를 호출하면 new MemoryMemberRepository()를 호출한다. 다시 생각해보면 다른 MemoryMemberRepository가 생성되면서 싱글톤이 깨지는 것 처럼 보인다. 정말로 싱글톤이 깨져 하나의 인스턴스를 공유하는 것이 아니고 따로따로 생성이 되는지 테스트 코드를 통해 확인해보겠다.

테스트를 위해 MemberServiceImpl과 OrderServiceImpl 클래스에 MemberRepository를 조회할 수 있는 기능을 추가한다. 기능 검증을 위해 잠깐 사용하는 것이니 인터페이스에 조회기능까지 추가하지는 않는다.

public class MemberServiceImpl implements MemberService {

    private final MemberRepository memberRepository;

    // 테스트 용도 추가
    public MemberRepository getMemberRepository() {
        return memberRepository;
    }

    // 생략 ...
    
}
public class OrderServiceImpl implements OrderService {

    private final MemberRepository memberRepository;
    private final DiscountPolicy discountPolicy;

    // 테스트 용도 추가
    public MemberRepository getMemberRepository() {
        return memberRepository;
    }

    // 생략 ...
    
}

 

public class ConfigurationSingletonTest {
    
    @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);
        
        // 모두 같은 인스턴스를 참고
        System.out.println("memberService -> memberRepository = " + memberService.getMemberRepository());
        System.out.println("orderService -> memberRepository = " + orderService.getMemberRepository());
        System.out.println("memberRepository = " + memberRepository);

        Assertions.assertThat(memberService.getMemberRepository()).isSameAs(memberRepository);
        Assertions.assertThat(orderService.getMemberRepository()).isSameAs(memberRepository);

    }
}

* 원래 memberServcieImpl, orderServiceImpl 처럼 구체 클래스를 꺼내오는 것은 추천하지 않는 방식이지만, 현재 구체 클래스에 getMemberRepository() 메서드를 만들었기에 이를 사용하기 위해서 테스트 코드로 구체 클래스를 꺼내오는 것이다.

위처럼 테스트 코드를 작성하고 실행해보면, memberService, orderService 모두 동일한 memberRepository 인스턴스를 공유하여 사용하는 것을 확인할 수 있다. 위의 AppConfig 클래스를 보면 각각 new MemoryMemberRepository를 호출해서 다른 인스턴스가 생성되는것 처럼 보이지만 그렇지 않다는 것이다. 이를 더 자세히 알아보기 위해 다음과 같이 호출 로그를 작성해줄 것이다.

 

@Configuration
public class AppConfig {

    @Bean
    public MemberRepository memberRepository() {
        System.out.println("call AppConfig.memberRepository");
        return new MemoryMemberRepository();
    }

    @Bean
    public DiscountPolicy discountPolicy() {
        return new RateDiscountPolicy();
    }

    @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을 호출해서 스프링 빈을 생성한다. 그래서 memberRepository() 는 다음과 같이 총 3번이 호출되어야 하는 것 아닐까?

  1. 스프링 컨테이너가 스프링 빈에 등록하기 위해 @Bean이 붙어있는 memberRepository() 호출
  2. memberService() 로직에서 memberRepository() 호출
  3. orderService() 로직에서 memberRepository() 호출

다시 말해, 빈이 등록될 때 다음과 같은 순서는 상관없지만, 다음과 같은 수만큼 출력이 될거라고 예상했다.

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

하지만 위의 예상과는 다르게 membeRepository는 1번만 호출이 된다.

 


 

@Configuration과 바이트 코드 조작

스프링 컨테이너는 싱글톤 레지스트리이다. 그렇기에 스프링 빈이 싱글톤이 되도록 보장해줘야 한다. 하지만 스프링이 자바 코드까지 조작하기는 어렵다. 앞서 위에 작성한 AppConfig 클래스의 자바 코드를 보면 위에서 설명했듯이 3번 호출이 되어야 하는 것이 맞다. 그래서 스프링은 클래스의 바이트코드를 조작하는 라이브러리를 사용한다. 위에서 처럼 예상과 다르게 출력이 일어난 것은 @Configuration 어노테이션을 적용한 AppConfig에 있다.

ConfigurationSingletonTest 클래스에 다음과 같은 테스트 코드를 작성한다. 

@Test
void configurationDeep() {
    ApplicationContext ac = new AnnotationConfigApplicationContext(AppConfig.class);
    
    // AppConfig도 스프링 빈으로 등록할 수 있다.
    AppConfig bean = ac.getBean(AppConfig.class);

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


AppConfig를 스프링 빈으로 등록한 후 해당 빈의 클래스 정보를 출력해보면 위와 같이 출력이 된다. 만약 순수한 클래스였다면 class com.example.core.AppConfig 로 출력이 되었어야 한다. 하지만 예상과 다르게 클래스명에 ~~CGLIB이 붙으면서 복잡해진 것을 확인할 수 있다. 이것은 우리가 작성한 클래스가 아니라 스프링이 CGLIB이라는 바이트코드 조작 라이브러리를 사용해서 AppConfig 클래스를 상속받은 임의의 다른 클래스를 만들고, 그 다른 클래스를 스프링 빈으로 등록한 것이다. 이러한 임의의 클래스가 바로 싱글톤이 보장되도록 해주는 것이다. 

 

만약 AppConfig 클래스에 @Configuration 어노테이션을 사용하지 않고, @Bean만 적용한다면,  앞서 설명했던 부분들이 적용되지 않는다. 우선 ~~CGLIB이 붙지않은 bean = class com.example.core.AppConfig 로 클래스명이 출력이된다. 또한 memberRepository 가 아래처럼 3번 불려지게 된다. 

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

또한 인스턴스가 같은지 테스트하는 코드 또한 실패하게 된다. 즉, 싱글톤 패턴이 깨지게 되는 것이다.

 

정리

  • @Bean만 사용해도 스프링 빈으로 등록되지만, 싱글톤을 보장하지 않는다.
  • memberRepository() 처럼 의존관계 주입이 필요해서 메서드를 직접 호출할 때 싱글톤을 보장하지 않는다.
    스프링 설정 정보는 항상 @Configuration 을 사용하자.

 

 


 

728x90
댓글수1