[ 스프링 공부 ] 싱글톤 컨테이너

2022. 1. 28. 23:51백엔드

 

지난 글에서는 스프링 컨테이너와 스프링 빈에 대해서 배워보았다. 그전까지는 자바만을 사용하여 코드를 작성하다가 본격적으로 스프링 프레임워크를 사용하게 되었는데, 스프링 프레임워크가 어떤 모습으로 생겼는지, 그리고 그것을 구성하고 있는 요소들에는 무엇이 있는지 그 요소들에 대한 본질적인 이해에 도움이 되는 수업이었다.

 

이번글을 통해서는 싱글톤 컨테이너 기술에 대해서 공부한 내용을 정리해보고자 한다.

 

개론을 먼저 적어보도록 하겠다.

 

스프링은 다양한 방면에서 사용되지만 웹 서비스를 위해 개발되었다고 해도 과언이 아니다. 웹 서비스의 특징에는 많은 것이 있지만, 굳이 하나 꼽아보자면 동시에 많은 사용자가 서비스를 사용한다는 것이다.

 

그렇다면 동시에 서비스를 사용하고자 하는 클라이언트들이 특정한 기능을 사용하기 위해서 특정 스프링 빈을 요청한다면 그 스프링 빈은 여러개가 생성되어 활용되도록 하는 것이 일반적인 사고방식이다.

 

하지만, 이렇게 서비스 모델을 디자인할 경우 많은 문제점이 발생할 수 있다. 발생할 수 있는 문제점은 무엇인지. 그리고 스프링은 이것을 어떻게 해결하였는지 알아보도록 하자!!

 


 

우선 스프링 없이 순수한 자바 코드로 DI 컨테이너를 생성하고 스프링 빈을 등록해보자!

package hello.core.singleton;

import hello.core.AppConfig;
import hello.core.member.MemberService;
import org.assertj.core.api.Assertions;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;

public class SingletonTest {

    @Test
    @DisplayName("스프링 없는 순수한 DI 컨테이너")
    void pureContainer(){
        AppConfig appConfig = new AppConfig();
        MemberService memberService1 = appConfig.memberService();
        MemberService memberService2 = appConfig.memberService();

        System.out.println("memberService1 = " + memberService1);
        System.out.println("memberService2 = " + memberService2);

        Assertions.assertThat(memberService1).isNotSameAs(memberService2);
    }
}

 

기존의 AppConfig 클래스 위에 @Configuration 을 주석 처리하면 순수한 DI 컨테이너를 생성할 수 있다.

 

위 코드를 살펴보면 생성한 MemberService를 비교하는데 isNotSameAs() 메소드가 성공한 것을 보면, 두 개의 인스턴스가 같지 않다는 것을 알 수 있다.

 

한마디로 스프링을 사용하지 않고 순수하게 DI 컨테이너를 만들경우 스프링 빈에 같은 요소가 두 개가 등록된다는 것이다.

 

그러면 고객 트래픽이 초당 100이 나오면 초당 100개의 인스턴스가 생성되고 소멸된다. 누구나 알수있다시피 메모리 낭비가 정말 심하다..

 

이에 대한 해결 방안은 명료하다. 객체가 딱 한개만 생성되고 하고 그것을 공유하게 설계하면 된다.

 

우리는 그것은 '싱글톤 패턴'이라고 부른다.

 


 

이번에는 싱글톤으로 불릴 수 있게 클래스를 하나 정의해보자!

package hello.core.singleton;

public class SingletonService {
    
    private static final SingletonService instance = new SingletonService();
    
    public static SingletonService getInstance(){
        return instance;
    }
    
    private SingletonService(){}
    
    public void logic(){
        System.out.println("싱글톤 객체의 로직이 실행된다.");
    }
}

 

우선 인스턴스를 static 영역에서 1개 final type으로 생성한다.

 

그리고 public으로 열어서 인스턴스가 필요하면 아까 생성해둔 인스턴스를 static 메서드를 통해서만 조회하도록 한다.

 

다음으로 생성자를 private으로 선언하여 외부에서 new 키워드를 사용한 객체 생성을 막는다.

 

그렇게 생성된 싱글톤 객체는 아래 logic() 메서드처럼 자체 로직을 실행할 수 있게 하면 비로소 싱글톤 클래스 생성이 완료된것이다.

 

인스턴스를 static 메서드를 통해서만 조회하도록 하고 생성자를 private로 선언할 경우 해당 메서드를 실행하면 항상 같은 인스턴스를 반환하게 된다.

 

 

@Test
@DisplayName("싱글톤 패턴을 적용한 객체 사용")
public void singletoneServiceTest(){
    SingletonService instance1 = SingletonService.getInstance();
    SingletonService instance2 = SingletonService.getInstance();

    System.out.println("instance1 = " + instance1);
    System.out.println("instance2 = " + instance2);

    Assertions.assertThat(instance1).isSameAs(instance2);
}

 

싱글톤 패턴을 적용한 객체를 사용하는 코드는 위와 같다. 메서드 자체를 static으로 선언하였으니, SingletonService를 직접 호출하고 메서드를 실행시켜 인스턴스를 호출한다.

 

인스턴스 호출 과정을 두번 반복하여 각 인스턴스가 같은지 확인하면 된다. 코드를 실행해보면 테스트가 성공하는 것을 볼 수 있다. 정리하자면, 두개의 인스턴스가 같은 인스턴스를 참조하고 있다는 사실을 파악할 수 있다.

 

하지만 이런 싱글톤 패턴에도 문제점이 존재한다.

 

- 내부 속성을 변경하거나 초기화하기가 너무 어렵고, private 생성자로 자식 클래스를 만들기가 어렵다.

요약하자면 너무 고지식하다.

 

- 싱글톤 패턴으로 클래스를 구현하기 위해 코드가 너무 많이 추가된다.

 


 

스프링은 이러한 문제를 모두 해결여 자유롭게 싱글톤 패턴이 적용된 스프링 빈을 제공한다.

 

@Test
@DisplayName("스프링 컨테이너와 싱글톤")
void springContainer(){
    ApplicationContext ac = new AnnotationConfigApplicationContext(AppConfig.class);
    MemberService memberService1 = ac.getBean("memberService", MemberService.class);
    MemberService memberService2 = ac.getBean("memberService", MemberService.class);

    System.out.println("memberService1 = " + memberService1);
    System.out.println("memberService2 = " + memberService2);

    Assertions.assertThat(memberService1).isSameAs(memberService2);
}

 

스프링을 적용하여 코드를 작성하였다. 두 개의 인스턴스가 모두 참조값이 같다는 사실을 알 수 있다.

 

싱글톤을 적용하여 모델을 디자인할 경우 생길 수 있는 문제에 대해서 생각해보자.

 

- 같은 인스턴스를 활용하다가 보니 상태를 유지하게 설계해서는 안된다.

 

변수를 설정할 수 있게 할 경우 중간에 한 클라이언트가 인스턴스를 조회하고 나서 주문을 해놓은 상태에서 상품의 가격이 다르게 출력될 수 있다는 것이다!!

 

package hello.core.singleton;

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

 

위와 같은 상태를 유지하는 싱글톤 패턴의 클래스가 있다고 가정해보자.

 

this.price = price; 이 부분이 가장 문제인데..

 

어떻게 문제가 발생하는지는 다음 코드에서 함께 살펴보자!

package hello.core.singleton;

import org.assertj.core.api.Assertions;
import org.junit.jupiter.api.Test;
import org.springframework.context.ApplicationContext;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import org.springframework.context.annotation.Bean;

import static org.junit.jupiter.api.Assertions.*;

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

        statefulService1.order("userA", 10000);
        statefulService2.order("userB", 20000);

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

userA가 주문을 생성할 때, 10000원의 가격으로 빈을 사용한 후에, userB가 주문을 생성하였다. 가격은 20000원이다.

 

그렇다면 userA는 10000원의 가격을 호출받아야 하지만, userB의 주문이 생성된 후라서 가격이 20000원으로 설정되어 있다. 이에 따라 userA는 20000원의 가격으로 설정된 주문 정보를 돌려 받게 된다.

 

이렇게 코드가 설계되면 클라이언트는 원하는 서비스를 제공받을 수 없게 되고,, 항의가 빗발칠 수 밖에 없다...ㅠㅠㅠ

 

하지만 신기하게도 스프링으로 설계된 싱글톤 패턴은 이와 같은 문제가 해결되어 있다.

 


 

분명 자바 코드 상으로는 분명 싱글톤이 깨지는 것처럼 보인다. 하지만 스프링에서 생성한 스프링 빈은 싱글톤이 깨지지 않게 설계되어 있다.

 

어떻게 스프링은 이것을 해결한 것인지만 살펴보도록 하자.

 

@Configuration 어노테이션이 있는 자바 코드 상으로는 분명 MemberRepository가 세번 호출되어야 하는 것이 맞다. 하지만 스프링은 바이너리코드를 활용하여 이것을 해결하였다.

 

우선 @Configuration 어노테이션이 붙어있는 클래스도 스프링에서는 스프링 빈으로 등록이 된다는 점은 참고바란다.

 

@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$$EnhancerBySpringCGLIB$$910da055

 

분명 우리는 AppConfig라는 클래스를 등록하였으나 기타 등등 내용이 뒤에 적혀있는 것을 확인할 수 있다.

 

순수한 클래스로 설계되었다면 분명

class hello.core.AppConfig

위와 같이 출력되었을 것이다. 클래스명에 CGLIB가 붙어 상당히 복잡해진것처럼 보인다. 그렇다면? 굳이 왜 스프링은 이름을 더 길게하여 클래스를 등록하였을까?

 

정답은 내가 만든 AppConfig를 그대로 사용하는 것이 아니라 AppConfig의 모든 내용을 상속받는 임의의 클래스를 만들었기 때문이다. 그리고 그 클래스를 스프링 빈으로 등록한 것이다!

 

또 의문점이 생긴다. 왜 굳이 내가 만든 AppConfig 클래스를 사용하는 것이 아니라 새로운 클래스를 정의하는 것일까?

 

새로운 클래스를 정의하여 많은 기능이 추가가 되겠지만, 해당 클래스가 스프링 컨테이너에 등록되어 있다면 스프링 컨테이너에서 찾아서 반환하고, 스프링 컨테이너에 없다면 기존 로직을 호출하여 해당 클래스를 생성하고 스프링 컨테이너에 등록한 후에 반환하는 코드가 추가되어 있을 것이다.

 

모든 @Bean 어노테이션이 붙은 메서드마다 위와 같은 로직이 추가될 것이고, 그렇다면 비로소 완전한 싱글톤 디자인이 보장되는 것이다.

 

위 내용은 AppConfig에서 @Configuration 어노테이션을 주석처리하면 스프링 컨테이너에 어떤 스프링 빈이 올라가는지 확인해보면 증명된다.

 

코드를 직접 짜지 않고 결과를 말해주자면, 생성자에서 빈에 등록되어 있는 혹은 등록할 객체를 호출할 경우 매번 생성자가 실행되게 된다. 다르게 말하자면 의존관계가 주입되었을 경우 싱글톤이 무너질 수 밖에 없는 구조가 된다.

 


정리해보자,

 

1. @Bean 어노테이션을 활용하면 스프링 빈으로 등록되지만, 싱글톤은 보장되지 않는다.

 

2. 크게 고민하지말고, 스프링 설정 정보는 항상 @Configuration 어노테이션을 사용하자!