스프링에서의 싱글톤 패턴

gorapaduckoo·2023년 6월 29일
0

스프링 기본편

목록 보기
5/10
post-thumbnail

인프런 김영한님의 스프링 핵심 원리 - 기본편 강의 내용을 바탕으로 작성한 글입니다.


1. 싱글톤 패턴이란?

싱글톤 패턴이란 객체의 인스턴스를 딱 1개만 생성하는 것을 말한다. 생성자를 몇 번 호출하더라도 인스턴스는 딱 1개만 생성되고, 인스턴스 생성 이후에 호출되는 생성자는 이전에 생성해둔 인스턴스를 반환해야 한다.

싱글톤 패턴을 구현하는 방법은 여러가지가 있지만, 주로 아래와 같은 방법이 많이 쓰인다.

class Singleton {
	private static final Singleton singleton = new Singleton();
    
    public static Singleton getInstance(){
    	return singleton;
    }
}
  • singleton 필드는 static 멤버로 설정하여 하나의 인스턴스를 공유하도록 설정한다.
  • 생성자를 private로 설정하여 외부에서 생성자에 접근하는 것을 막는다.
  • getInstance()는 static 메서드로 설정하여, 인스턴스 생성 없이도 호출할 수 있도록 한다.


2. 싱글톤 패턴의 필요성

그런데 왜 갑자기 싱글톤 패턴에 대한 이야기가 나올까? 바로 웹 애플리케이션의 특성 때문이다.
웹 애플리케이션은 여러 고객으로부터 요청을 받기 때문에, 동시에 여러개의 요청이 올 수도 있다.

만약 초당 1만개의 주문 요청이 오면, 1만개의 orderService 인스턴스가 생성된다. 이런 방식은 메모리 낭비가 심하기 때문에 orderService에 싱글톤 패턴을 적용하여 인스턴스는 딱 1개만 생성하고, 클라이언트들이 이 1개의 인스턴스를 공유하여 사용하도록 만드는 것이다.

그렇다면 싱글톤 패턴을 어떻게 적용해야 할까? 스프링이 이미 싱글톤 패턴을 적용해주고 있기 때문에, 우리는 따로 코드를 수정할 필요가 없다.

스프링 컨테이너는 싱글톤 패턴을 따로 적용하지 않아도, 기본적으로 스프링 빈을 싱글톤으로 관리한다. 빈 객체를 미리 생성해둔 뒤, 조회가 들어오면 그에 해당하는 객체를 반환하는 식이다.

테스트 코드를 통해 확인해 보자.

  • 테스트 코드
@Test
void singletonTest() {
	ApplicationContext ac = new AnnotationConfigApplicationContext(AppConfig.class);
    OrderService orderService1 = ac.getBean("orderService", OrderService.class);
    OrderService orderService2 = ac.getBean("orderService", OrderService.class);
    
    System.out.println("orderService1 = " + orderService1);
    System.out.println("orderService2 = " + orderService2);
    
    Assertions.assertThat(orderService1).isSameAs(orderService2);
  • 출력 결과
orderService1 = hello.core.order.OrderServiceImpl@3668d4
orderService2 = hello.core.order.OrderServiceImpl@3668d4

실제로 빈 조회 때마다 동일한 인스턴스를 반환해주는것을 볼 수 있다. 어떻게 이런 일이 가능할까?



3. @Configuration의 역할

@Configuration
public class AppConfig {
	
    @Bean
	public OrderService orderService() {
    	return new OrderServiceImpl(discountPolicy());
    }
    
    @Bean
    public DiscountPolicy discountPolicy() {
    	return new FixDiscountPolicy();
    }
}

지난 글에서 작성했던 설정 파일을 보자. 코드만 보면 처음에 orderService()를 호출할 때 1번, 그리고 discountPolicy 빈을 등록할 때 1번 해서 new FixDiscountPolicy()가 2번 호출되어야 할 것 같다.

@Configuration
public class AppConfig {
	
    @Bean
	public OrderService orderService() {
    	System.out.println("call orderService()");
    	return new OrderServiceImpl(discountPolicy());
    }
    
    @Bean
    public DiscountPolicy discountPolicy() {
    	System.out.println("call discountPolicy()");
    	return new FixDiscountPolicy();
    }
}

하지만 위와 같이 호출 로그를 남겨보면,

call orderService()
call discountPolicy()

와 같이 각각 1번씩만 호출된다. 어떻게 이런 일이 가능할까? 바로 @Configuration 덕분이다.
위의 자바 코드대로면 분명히 3번 호출되어야 한다. 스프링이 자바 코드를 수정한 걸까?

아무리 스프링이라도 자바 코드까지 조작할 수는 없다. 그 대신 스프링은 클래스의 바이트코드를 조작하는 CGLIB라는 라이브러리를 사용한다. 코드를 통해 직접 확인해 보자.

  • 테스트 코드
@Test
void configurationDeep() {
	ApplicationContext ac = new AnnotationConfigApplicationContext(AppConfig.class);
    AppConfig bean = ac.getBean(AppConfig.class);
    
    System.out.println("bean = " + bean.getClass());
}

스프링 컨테이너에 AppConfig를 스프링 빈으로 등록한 뒤, 컨테이너에서 조회하는 코드이다.
위의 코드를 실행하면 콘솔에 bean = class hello.core.AppConfig가 출력될 것이다.

💡new AnnotationConfigApplicationContext()에 매개변수로 넘기는 클래스도 스프링 빈으로 등록되기 때문에, AppConfig.class도 스프링 빈으로 등록된다.

  • 출력 결과
bean = class hello.core.AppConfig$$EnhancerBySpringCGLIB$$ad8aea39

하지만 실제 결과는 조금 다르다. hello.core.AppConfig 뒤에 EnhancerBySpringCGLIB 라는 문자열이 보인다. 실제 AppConfig 클래스가 스프링 빈으로 등록된 것이 아니라, 스프링이 CGLIB 라이브러리를 이용해 AppConfig를 상속받은 자손 클래스를 만들고, 그 자손 클래스를 AppConfig 대신 스프링 빈으로 등록했기 때문이다.

이 AppConfig@CGLIB 클래스는 AppConfig의 바이트 코드를 조작하여, discountPolicy()의 구현부를 (1) 만약 스프링 컨테이너에 discountPolicy라는 빈이 있으면 해당 인스턴스를 반환하고, (2) 없으면 새 인스턴스를 생성하여 스프링 컨테이너에 discountPolicy라는 이름으로 등록한 뒤 인스턴스를 반환한다. 와 비슷한 로직으로 변환한다.

@Configuration 애노테이션이 이 과정들을 진행해주기 때문에, 스프링 설정 파일에 @Configuration을 추가하지 않으면 싱글톤이 보장되지 않는다.

AppConfig에서 @Configuration 애노테이션을 삭제한 뒤, 아래와 같은 테스트 코드를 실행해보자.

  • 테스트 코드
// OrderServiceImpl 클래스에 아래 코드 추가
// public DiscountPolicy getDiscountPolicy() { return discountPolicy; }

	@Test
    void configurationTest2() {
        ApplicationContext ac = new AnnotationConfigApplicationContext(AppConfig.class);

        OrderServiceImpl orderService = ac.getBean("orderService", OrderServiceImpl.class);
        DiscountPolicy discountPolicy1 = ac.getBean("discountPolicy", FixDiscountPolicy.class);
        DiscountPolicy discountPolicy2 = orderService.getDiscountPolicy();

        System.out.println("discountPolicy1 = " + discountPolicy1);
        System.out.println("discountPolicy2 = " + discountPolicy2);
        Assertions.assertThat(discountPolicy1).isSameAs(discountPolicy2);
    }
  • 출력 결과
call orderService()
call discountPolicy()
call discountPolicy()
discountPolicy1 = hello.core.discount.FixDiscountPolicy@34f22f9d
discountPolicy2 = hello.core.discount.FixDiscountPolicy@3d1848cc

discountPolicy는 빈으로 등록되긴 하지만, 스프링 컨테이너에 빈으로 등록된 인스턴스와 orderService에 주입된 인스턴스가 다르다는 것을 확인할 수 있다.



4. 싱글톤 패턴의 주의점

지금까지 싱글톤 패턴의 의미와 스프링에서 싱글톤 패턴을 유지하는 방법에 대해 알아보았다. 이런 싱글톤 패턴을 사용하려면 유의해야 하는 점이 있다.

싱글톤 방식은 여러 클라이언트가 하나의 인스턴스를 공유하여 사용한다. 때문에 싱글톤으로 설계할 객체는 공유 필드가 없는 무상태(stateless)로 설계해야 한다. 조금 더 쉽게 말하면, 특정 클라이언트가 객체의 필드를 변경할 수 있도록 설계하면 안된다는 의미이다. 클라이언트는 가급적 읽기만 가능해야 하며, 만약 클라이언트마다 설정해야 하는 값이 있다면 공유되는 필드 대신 매개변수 등을 이용해야 한다.

간단하게 코드로 살펴보자.

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

위와 같이 상태를 유지하는 서비스 객체를 설계한다.

class StatefulServiceTest {

    @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();
        System.out.println("price = " + price);

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

    static class TestConfig {

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

}
  • 출력 결과
price = 20000

그리고 테스트 코드를 돌려보면, 사용자 A가 사용하는 statefulService1 인스턴스의 price 값이 20000으로 설정된 것을 확인할 수 있다. 사용자 A는 만원을 주문했는데 2만원을 청구받은 상황이다.
이는 사용자 A와 B가 StatefulService의 인스턴스를 공유하여 사용하기 때문이다.

(1) 사용자 A가 statefulServiceprice를 10000으로 설정
(2) 사용자 B가 statefulServiceprice를 20000으로 설정
(3) 사용자 A가 statefulServiceprice를 조회 -> 20000 반환!

  • 수정한 코드
public class StatefulService {
	public int order(String name, int price) {
    	System.out.println("name = " + name + " price = " + price);
        return price;
    }
}

price를 필드가 아닌 메서드의 매개변수로 넘긴 뒤 바로 출력하면, 위와 같은 문제를 방지할 수 있다.

0개의 댓글