[스프링] 싱글톤 방식의 주의점 - 스프링 빈은 항상 무상태(stateless)로 설계하자

2021. 12. 8. 16:58Spring & Spring Boot

스프링 싱글톤 방식으로 설계시 주의해야할 사항이 있다. 스프링 빈을 항상 무상태(Stateless)로 설계하는 것이다.

간단히 말하자면 스프링 빈으로 등록되는 클래스는 공유가 될 수 있는 전역 변수를 사용하지 말아야 한다는 것이다.

싱글톤으로 빈 객체를 관리하는 스프링 컨테이너의 특성상 빈에 멤버변수를 두게 되는 경우 해당 멤버변수에 들어간 값이 다른 곳에서 상태를 유지(Stateful)한 채 재사용된다는 문제점이 있다. 이렇게 될 경우 서로 다른 요청에 따라 스레드 별로 각자의 스택 메모리 영역을 차지한다 하더라도 스프링 컨테이너가 동작함에 따라 이미 힙 영역에 빈 객체가 로딩되어 공유되기 때문에 빈 객첼을 Stateful한 상태로 설계할 경우 서로 다른 요청임에도 불구하고 값이 꼬여버리는 경우가 발생할 수 있다. 그렇기 때문에 빈 객체는 메소드만 공유하는 역할을 해야하며 빈 객체의 상태값을 유지하게 만드는 필드 값이 없는 무상태(Stateless)로 설계해야한다.

 

추가로 스프링 컨테이너의 경우 컨테이너가 동작되는 시점에 빈으로 설정해둔 객체를 메모리에 Loading하는 Pre-Loading 방식을 default로 취하기 때문에 메모리에서 하나의 빈을 공유해서 여러 요청을 처리한다.

 

그럼 코드를 보면서 왜 빈을 Stateless하게 설정해야 되는지 알아보자.

@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(10000); // 에러발생
}

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

TestConfig 클래스를 컨테이너에 직접 등록을 해줬다. 그리고 해당 클래스에서 등록한 빈을 getBean해줄 경우 객체는 컨테이너에 한개만 등록되어 재사용된다. 위 코드를 보면 price의 값은 10000원이 저장된 것을 예상했지만 에러가 발생한다. 

 

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

컨테이너에 등록된 빈을 확인 해보니 전역 변수(상태를 유지하는 필드)가 선언되어 있다. 스프링 싱글톤 방식의 경우 컨테이너에 등록된 객체는 한개만 등록되어 재사용 되므로 상태를 유지하는 필드가 있을 경우 값을 변경할 때마다 같은 객체의 상태값이 변화된다.

 

위와 같이 설계를 할 경우 만약 A사용자의 주문 금액이 10000원이 되어야 하는데 같은 주문 객체를 공유함으로 인해 다른 사용자인 B의 주문 금액인 20000원으로 주문 금액이 변경되어 공유된다. 이렇게 되면 A사용자의 의도와는 달리 20000원이 주문되는 아주 큰 문제가 발생하게 된다!

 

 

문제점 해결방법

public class StatefulService {

    //private int price; // 상태를 유지하는 필드 -- 제거

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

주문이 공유되는 문제를 해결하기 위해서 일단 가장 큰 문제였던 스프링 빈의 상태를 유지하는 필드를 제거해서 무상태로 수정한다. 빈에 상태값을 저장해서 유지하는 것이 아닌 값이 들어오면 바로 return하는 방식으로 수정해주었다.

 

    @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원 주문
        int userA = statefulService1.order("userA", 10000);
        //ThreadB: B사용자 20000원 주문
        int userB = statefulService2.order("userB", 20000);

        //ThreadA: 사용자A 주문 금액 조회
        System.out.println("price = " + userA);

        Assertions.assertThat(userA).isEqualTo(10000);
    }

    static class TestConfig {

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

이렇게 수정을 해주게 된다면 주문이라는 객체는 동일하게 사용하되 무상태로 값을 유지하지 않고 바로 반환해주어서 각 입력금액에 맞게 주문금액이 정상적으로 셋팅되었다.

 

참고자료

스프링 핵심 원리 인프런 강의 - 김영한