JAVA/Spring

[Spring] 스프링과 싱글톤

JongHyun99 2021. 7. 13. 18:32
728x90

웹서비스는 대량의 트래픽을 고려하여 설계되어야 한다.

스프링은 기업의 온라인 서비스 기술을 지원하기 위해 탄생했기에 대량의 트래픽에 대한 문제점을 해결책을 갖고있다.

스프링이 없는 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);
    }

만약 Spring을 사용하지 않고 Java 코드로만 service를 호출하게 되면

매번 appConfig에서 새로운 객체를 호출하게 된다.

그 증거로 출력값을 확인해보면 아래와 같다.

memberService1 = hello.core.member.MemberServiceImpl@cb51256
memberService2 = hello.core.member.MemberServiceImpl@59906517

실제 웹사이트 운영시 초당 수백에서 수만건 까지 요청이 일어날 수 있으므로 이러한 설계일 경우 매 순간 객체가 수천건이 생성되었다 소멸되므로 비효율적인 방법이다.

이를 해결하기 위해 스프링은 싱글톤 디자인패턴을 사용한다.

싱글톤 패턴이란?

  • 클래스의 인스턴스가 딱 1개만 생성되도록 보장하는 디자인 패턴이다.
  • 그래서 private 접근자로 외부에서 객체를 생성할 수 없도록 막아야 한다.
public class SingletonService {

    //1. static 영역에 객체를 딱 1개만 생성해둔다.
    private final static SingletonService instance = new SingletonService();

    //2. public으로 열어서 이 객체 인스턴스가 필요하면 이 static 메서드를 통해서만 조회하도록 허용한다.
    public static SingletonService getInstance() {
        return instance;
    }

    //3. 생성자를 private로 선언해서 외부에서의 접근을 막는다.
    private SingletonService() {
    }

    public void logic() {
        System.out.println("싱글톤 개체 로직 호출");
    }
}

위와 같이 생성자로만 접근 가능한 싱글톤 객체를 만든다.

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

        System.out.println("singletonService1 = " + singletonService1);
        System.out.println("singletonService2 = " + singletonService2);

        assertThat(singletonService1).isSameAs(singletonService2);
    }

위 코드에서 static final로 생성된 객체를 호출하게 되므로 singletonService 객체는 서로 동일한 객체를 참조하게 된다.

singletonService1 = hello.core.singleton.SingletonService@6043cd28
singletonService2 = hello.core.singleton.SingletonService@6043cd28

출력된 모습

싱글톤 패턴의 문제점

  1. 추가적인 코드 작성이 필요하다.
  2. 클라이언트가 구체코드에 의존하므로 DIP를 위반한다.
  3. 테스트가 어렵다.
  4. 유연성이 떨어진다.
  5. private 생성자로 자식 클래스를 만들기 어렵다.

스프링에서는?

스프링 컨테이너는 객체 인스턴스를 스프링 빈으로서 등록하여 객체 호출할 때, 컨테이너에 등록되어 있는 빈을 호출하여 싱글톤 처럼 사용된다. 이러한 기능을 싱글톤 레지스트리라고 한다.

스프링 컨테이너의 이런 기능덕분에 기존 싱글톤 패턴의 모든 단점을 해결하면서 객체를 싱글톤으로 유지할 수 있다.

→ 요청이 들어올 떄 마다 객체를 재생성 하는 것이 아닌, 이미 만들어진 객체를 공유해서 효율적으로 사용할 수 있음

(스프링 빈의 기본 스코프 등록 방식은 싱글톤이지만 매번 새로운 객체가 생성하도록 하는 기능도 제공한다.)

 

싱글톤 패턴의 주의점!

싱글톤 패턴 혹은 싱글톤(스프링) 컨테이너를 사용하든, 객체 인스턴스를 하나만 생성해서 공유하는 방식은 여러 클라이언트가 하나의 객체를 공유하기 때문에 객체 상태를 유지(stateful)하게 설게하면 안된다.

무상태로 설계해야함

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

싱글톤 빈 필드에 공유 값이 있으면 큰 장애가 발생할 수 있음

아래 간단한 예시 코드 작성

예시) StatefulService.java

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

위와 같이 이름과 주문금액을 입력할 수 있는 서비스가 있다.

StatefulServiceTest.java

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

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

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

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

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

위 서비스에서 userA가 10,000원을 주문하고, userB가 20,000원을 주문했을 때 주문결과를 조회하면 20,000원이 출력되기 때문에 userA 입장에서는 잘못된 결과를 받을 수 있다.

이런 경우를 막기 위해 공유필드를 조심해야함, 스프링 빈은 항상 무상태(stateless)로 설계하자.

그러면 어떻게 해야할까

//    private int price; //상태를 유지하는 필드
    public int order(String name, int price) {
        System.out.println("name = " + name + " price = " + price);
//        this.price = price; //여기가 문제!
        return price;
    }

//    public int getPrice() {
//        return price;
//    }
}

공유 필드를 없애고 메소드에 return 해주었다.

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

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

        //ThreadB : B사용자 20,000원 주문
        int userBPrice = statefulService1.order("userB", 20000);

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

    }

변수를 생성해서 return값을 받는 방향으로 문제를 해결할 수 있다.

실무에서 이런 장애가 발생하면 고치기도 어렵고 심각한 피해를 일으키기 때문에 유의하여야 한다.

위 예시는 간단하기 때문에 장애를 금방 찾을 수 있지만 실제 서비스 환경에서는 멀티 쓰레드 환경, 상속관계 등의 복잡한 로직으로 이루어진 환경이 많기 때문에 더욱 고치기 어렵게 된다.

@Configuration의 기능

@Configuration
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());
    }

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

위 스프링 Config 파일은 Bean으로 등록하는 과정에서 각 객체를 new로 호출하고 있다.

//@Bean memberService -> new MemoryMemberRepository()
//@Bean orderService -> new MemoryMemberRepository()

그러면 이 과정에서 new로 객체가 2개가 생성되므로 싱글톤 패턴이 깨지게 될것이다.

스프링 컨테이너는 이 문제를 @Configuration을 이용하여 해결한다.

아래 예제를 만들어 확인해보자.

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

    MemberServiceImpl memberService = ac.getBean("memberService", MemberServiceImpl.class);
    OrderServiceImpl orderService = ac.getBean("orderService", OrderServiceImpl.class);

    MemberRepository memberRepository1 = memberService.getMemberRepository();
    MemberRepository memberRepository2 = orderService.getMemberRepository();

    System.out.println("memberService -> memberRepository1 = " + memberRepository1);
    System.out.println("orderService -> memberRepository2 = " + memberRepository2);
}

memberServiceImpl와 orderServiceImpl에서 각각 객체값을 구하는 메소드를 만들어서 출력해보면 같은 객체로 출력된다.

출력값

memberService -> memberRepository1 = hello.core.member.MemoryMemberRepository@71a8adcf
orderService -> memberRepository2 = hello.core.member.MemoryMemberRepository@71a8adcf

분명 orderService와 meberService는 AppConfig에서 서로 new로 memberRepository를 생성했지만 같은 객체로 출력된다.

어떻게 싱글톤이 보장될까?

스프링 컨테이너는 싱글톤 레지스트리이기에, 스프링 빈이 싱클톤이 되도록 보장해주어야 한다.

그래서 위와 같이 같은 객체를 불러와야하는 상황에서 스프링은 바이트 코드를 조작하여 싱글톤을 보장한다.

@Configuration 어노테이션이 붙어있는 클래스는 스프링이 CGLIB라는 바이트코드 조작 라이브러리를 사용해서 해당 클래스를 상속받은 임의의 클래스를 만들고, 그 임의의 클래스를 스프링 빈으로 등록한 것이다.

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

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

실제로 위 코드로 @Configuration이 붙어있는 AppConfig코드의 Bean Class Data를 출력해보면 아래와 같이 출력된다.

bean = class hello.core.AppConfig$$EnhancerBySpringCGLIB$$f30ab3f

AppConfig에서 끝나지 않고 뒤에 CGLIB 등 부가적인 문자가 붙어있다.

한마디로 @Configuration이 붙어있는 클래스는 원본 클래스가 아닌 CGLIB으로 조작한 상속 클래스가 빈으로 등록된다는 것이다.

CGLIB으로 등록된 클래스는 조건문으로 스프링 컨테이너에 등록되어 있는 객체인 경우에 새로 생성하지 않고 등록된 빈을 반환하는 로직이 생성되므로 싱글톤이 보장된다.

만약 @Configuration을 적용하지 않고 @Bean만 사용하게 된다면?

@Configuration이 없어도 객체들이 스프링 컨테이너에 등록되지만

CGLIB으로 조작된 클래스가 등록되지 않으므로 같은 객체를 여러차례 호출하게 될 경우 싱글톤이 깨지게 된다.

출력값

memberService -> memberRepository1 = hello.core.member.MemoryMemberRepository@36b4fe2a
orderService -> memberRepository2 = hello.core.member.MemoryMemberRepository@574b560f
memberRepository = hello.core.member.MemoryMemberRepository@ba54932

실제로 Configuration을 제거하고 위 예제를 동작시키면 위 출력과 같이 다른 객체로 생성되는 것을 볼 수 있다. (이렇게 새로 생성된 객체는 스프링 컨테이너 손에서 벗어나게 됨)

이후에는 Autowired 어노테이션을 이용해 더 간단하게 빈을 관리할 수 있게된다.

정리

  • @Bean만 사용해도 스프링 빈으로 등록되지만 싱글톤이 보장되지 않음
    (memberRepository() 처럼 의존관계 주입이 필요한 메서드를 직접 호출될 때)
  • 고민하지 말고 스프링 설정에는 @Configuration을 사용하면 된다.