[Spring] 싱글톤 컨테이너

kdkdhoho·2022년 2월 22일
0

Spring

목록 보기
9/26

이 글은 인프런 - 스프링 핵심 원리 기본편을 보고 공부한 것을 정리한 글입니다.

웹 어플리케이션과 싱글톤

스프링은 태생이 기업용 온라인 서비스 기술을 지원하기 위해 탄생했다.
요즘 스프링은 대부분 웹 어플리케이션에 사용된다.

여기서 웹 어플리케이션은 보통 여러 고객이 동시에 요청을 한다.
만약, 세 명의 클라이언트가 각각 memberService를 요청한다고 가정해보자.

그럼 memberService 객체는 총 3개가 생성되어 반환될 것이다.

아래 코드는 스프링 없는 순수 DI 컨테이너 테스트 코드이다.

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

	MemberService memberService1 = appConfig.memberService();
	MemberService memberService2 = appConfig.memberService();

	assertThat(memberService1).isNotSameAs(memberService2);
}

.isEqualTo().equals() 를 사용
isSameAs()는 참조값이 같은지 비교

만약 이런 식으로 클라이언트에 객체를 반환한다면, 100만명이 요청하면 객체를 100만개 만들어야 한다.
사실 memberService는 memberRepository를 의존하기에 총 200만개가 생성된다.

이는 메모리 비효율적인 방법이다.

따라서 해당 객체를 1개만 생성하고, 공유하도록 설계하면 된다.
이것이 싱글톤 패턴이다.

싱글톤 패턴

여기서 싱글톤 패턴은 디자인 패턴 중 하나이다.

소프트웨어 디자인 패턴에서 싱글턴 패턴(Singleton pattern)을 따르는 클래스는, 생성자가 여러 차례 호출되더라도 실제로 생성되는 객체는 하나이고 최초 생성 이후에 호출된 생성자는 최초의 생성자가 생성한 객체를 리턴한다. 이와 같은 디자인 유형을 싱글턴 패턴이라고 한다.
위키백과 - 싱글톤 패턴

public class SingletonService {

    // 1. static 영역에 객체를 딱 하나만 생성
    private static final SingletonService instance = new SingletonService();

    // 2. 외부에서 new 키워드를 통해 새로운 객체 생성을 막음
    private SingletonService(){}

    // 외부에서 필요하면 getInstance()를 통해서만 instance를 가져올 수 있음
    public static SingletonService getInstance() {
        return instance;
    }

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

방법은 위와 같이 static으로 객체를 딱 하나만 올려놓는다. 그리고 외부에서 또 다른 객체 생성을 막기 위해 생성자를 private으로 막아두고, 해당 인스턴스를 가져오기 위해 getInstance() 메서드를 사용한다.

이렇게 객체 하나를 이용하는 것이 매번 객체를 만드는 것보다 효율이 굉장히 높다.

그리고 기존 AppConfig.java에서도 같은 방식으로 적용 후, .getInstance()로 객체를 가져오면 적용이 가능하다.

하지만 이 싱글톤 패턴을 적용하면 여러 문제점이 생긴다.

  1. 싱글톤 패턴을 구현하는 코드 자체가 많이 들어간다.
  2. 구체 클래스에서 .getInstance() 를 사용하기 때문에 의존관계 상 클라이언트가 구체 클래스에 의존한다.
    즉 DIP를 위반한다. 이는 OCP를 위반할 가능성도 높아진다.
  3. 테스트하기 어렵다.
  4. 내부 속성을 변경하거나 초기화 하기 어렵다.
  5. private 생성자로 자식 클래스를 만들기 어렵다.

즉, 유연성이 떨어진다. 따라서 '안티패턴'이라고도 불린다.

싱글톤 컨테이너

스프링 컨테이너는 싱글톤 패턴의 문제점을 모두 해결하면서, 객체 인스턴스를 싱글톤으로 관리한다.
사실 스프링 빈싱글톤으로 관리되는 빈이다.

void springContainer() {
    ApplicationContext ac = new AnnotationConfigApplicationContext(AppConfig.class);

    MemberService memberService1 = ac.getBean(MemberService.class);
    MemberService memberService2 = ac.getBean(MemberService.class);

    assertThat(memberService1).isSameAs(memberService2);
}

때문에 이와 같이 기존에 했던대로, 스프링 컨테이너에 @Bean으로 등록한 객체들을 스프링 컨테이너에서 가져와 사용하면 된다.
비로소 기존 싱글톤 패턴의 문제점인 싱글톤 패턴 구현 코드 비용 소모, DIP/OCP 위반, 유연성 떨어짐 등을 간단히 해결할 수 있게 되고
이미 만들어진 객체를 공유하여 효율적으로 재사용할 수 있게 된다.

참고로 스프링의 기본 빈 등록 방식은 싱글톤이지만, 요청할 때마다 새로운 객체를 생성해서 반환하는 기능들도 제공한다.
하지만 주로 싱글톤 방식을 사용 !

싱글톤 방식의 주의점 (중요)

객체 인스턴스를 하나만 생성해서 공유하는 싱글톤 방식은 여러 클라이언트가 하나의 객체 인스턴스를 공유하기 때문에,
싱글톤 객체는 상태를 유지(stateful)하게 설계하면 안된다. 무상태(stateless)로 설계해야 한다!

그러기 위해선

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

    스레드 로컬 저장소는 정적 또는 전역 메모리를 스레드에 로컬로 사용하는 컴퓨터 프로그래밍 방법입니다.
    위키백과 - Thread-local storage

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 this.price;
    }
}
@Test
void statefulServiceSingleton() {
    ApplicationContext ac = new AnnotationConfigApplicationContext(TestConfig.class);

    StatefulService statefulService1 = ac.getBean(StatefulService.class);
    StatefulService statefulService2 = ac.getBean(StatefulService.class);

    // ThreadA: 사용자A 주문
    statefulService1.order("userA", 10000);
    // ThreadB: 사용자B 주문
    statefulService2.order("userB", 20000);

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

    System.out.println("price = " + price); // 원래 10000이 나와야 하는데 20000 출력됨

    Assertions.assertThat(price).isEqualTo(20000);
}

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

이와 같이 동시성 문제가 발생한다. 이를 해결하기 위해서는

public class StatefulService {

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

지역변수 int를 없애고 바로 price를 리턴해준다.

class StatefulServiceTest {

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

        StatefulService statefulService1 = ac.getBean(StatefulService.class);
        StatefulService statefulService2 = ac.getBean(StatefulService.class);

        // ThreadA: 사용자A 주문
        int userAPrice = statefulService1.order("userA", 10000);
        // ThreadB: 사용자B 주문
        int userBPrice = statefulService2.order("userB", 20000);

        System.out.println("userAPrice = " + userAPrice);

        Assertions.assertThat(userBPrice).isEqualTo(20000);
    }

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

}

그럼 테스트 결과는 정상적으로 10000원이 출력될 것이고 테스트도 통과된다.

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

빈을 생성할 때 코드를 한번 보자.
물론 순서는 보장할 수 없지만, memberService()가 실행되면 memberRepository()가 우선 실행된다.
그 다음 memberRepository()를 실행하고,
orderSerivce()를 실행하면 memberRepository()discountPolicy()가 우선 실행된다.

그럼 결국 memberRepository()는 세 번 호출되어 싱글톤이 유지가 되는지 의문이 생길 수 있는데,
실제로 테스트를 해보면 알 수 있다.

public class ConfigurationSingletonTest {

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

        MemberServiceImpl memberService = ac.getBean(MemberServiceImpl.class);
        OrderServiceImpl orderService = ac.getBean(OrderServiceImpl.class);
        MemberRepository memberRepository = ac.getBean(MemberRepository.class);

        MemberRepository memberRepositoryOfMemberService = memberService.getMemberRepository();
        MemberRepository memberRepositoryOfOrderService = orderService.getMemberRepository();

        System.out.println("memberRepositoryOfMemberService = " + memberRepositoryOfMemberService);
        System.out.println("memberRepositoryOfOrderService = " + memberRepositoryOfOrderService);
        System.out.println("memberRepository = " + memberRepository);

        assertThat(memberRepositoryOfMemberService).isSameAs(memberRepositoryOfOrderService);
        assertThat(memberRepositoryOfOrderService).isSameAs(memberRepository);
    }

}

그리고 AppConfig.java에서도

@Configuration
public class AppConfig {

    @Bean
    public MemberService memberService() {
        System.out.println("call AppConfig.memberService");
        return new MemberServiceImpl(memberRepository()); // 생성자 주입
    }

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

    @Bean
    public OrderService orderService() {
        System.out.println("call AppConfig.orderService");
        return new OrderServiceImpl(memberRepository(), discountPolicy());
    }
}

이렇게 호출될 때마다 출력시키면, call AppConfig.memberRepository은 한 번밖에 출력이 되지 않는다.

@Configuration과 바이트코드 조작의 마법

이 비밀은 AppConfig.java 안에 있다.

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

	AppConfig appConfig = ac.getBean(AppConfig.class);
	System.out.println("appConfig = " + appConfig.getClass());
}

직접 AppConig 클래스를 출력하면 class hello.core.AppConfig가 출력되어야 한다.
하지만 실제로는 appConfig = class hello.core.AppConfig$$EnhancerBySpringCGLIB$$4ab6406e가 출력이 된다.

여기서 $$EnhancerBySpringCGLIB$$4ab6406e를 자세히 보면, CGLIB이라는 것이 있다.
CGLIB이라는 바이트코드 조작 라이브러리를 사용해서 AppConfig 클래스를 상속받은 임의의 다른 클래스를 만들고, 그 다른 클래스를 스프링 빈으로 등록한 것이다.

이 다른 클래스가 싱글톤을 보장하도록 해주는 것이다.

동작원리는 간단히, @Bean이 붙은 메서드마다 이미 스프링 빈이 존재하면 존재하는 빈을 반환하고, 없으면 생성해서 스프링 빈으로 등록하고 반환하는 코드가 동적으로 만들어진다.

@Bean
public MemberRepository memberRepository() {
	if (memoryMemberRepository가 이미 스프링 컨테이너에 등록되어 있으면?) {
		return 스프링 컨테이너에서 찾아서 반환;
	} else {
		기존 로직을 호출해서 MemoryMemberRepository를 생성하고 스프링 컨테이너에 등록
		return 반환
	}
}

@Configuration을 적용하지 않고, @Bean만 적용하면 어떻게 될까?

@Configuration을 붙이면 CGLIB 라이브러리를 통해 싱글톤을 보장하지만, 만약 이를 제거한다면 어떻게 될까?

System.out.println("appConfig = " + appConfig.getClass());을 통해 출력되는 AppConfig의 클래스는
appConfig = class hello.core.AppConfig으로 나온다.
그리고 configurationTest()를 실행시키면 결과는 실패로 나온다.

그 안에 내용을 보면 memberRepository()가 총 세 번 호출되는 것을 알 수 있다.

그리고 또 하나의 문제가 있다.
MemberService안에 있는 memberRepository는 스프링 빈에 등록된 인스턴스가 아니라, 객체를 새로 생성한 것이 들어가게 된다.

결론, @Bean만 달면 스프링 빈에 등록은 된다. 하지만 @Configuration을 안달면 싱글톤이 보장되지 않는다.

때문에 스프링 설정 정보에는 항상 @Configuration 을 달아주자 !

profile
newBlog == https://kdkdhoho.github.io

0개의 댓글