섹션5. 싱글톤 컨테이너

wnajsldkf·2023년 1월 2일
0

김영한 선생님의 스프링 핵심 원리 - 기본편 강의를 듣고 정리하였습니다.

웹 어플리케이션과 싱글톤

대부분의 스프링 애플리케이션은 웹 애플리케이션이며, 웹이 아닌 애플리케이션 개발도 가능하다. 이런 애플리케이션에서는 여러 고객이 동시에 요청을 하는 상황이 발생한다.

예를들어, 스프링이 없는 순수한 DI 컨테이너에서 객체를 생성하는 테스트를 해보겠다.

	@Test
    @DisplayName("스프링 없는 순수한 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);
    }
    
    /* 결과
    memberService1 = hello.core.member.MemberServiceImpl@609cd4d8
	memberService2 = hello.core.member.MemberServiceImpl@17f7cd29
    */

확인해보면 호출할 때마다 객체를 생성하여, 다른 객체가 생성되는 것을 확인할 수 있다.
스프링이 없는 순수한 DI 컨테이너의 AppConfig는 요청할 때마다, 새롭게 객체를 생성한다.
만약 고객 트래픽이 초당 100이 나오면 초당 100개의 객체가 생성되고 소멸되므로 메모리 낭비가 심하다.

즉, 해당 객체가 딱 1개만 생성되고, 공유하도록 설계하는 싱글톤 패턴을 사용해보자!

싱글톤 패턴

싱글톤 패턴은 디자인 패턴 중 하나이다.
+) 디자인 패턴은 프로그램을 개발할 때, 자주 발생하는 디자인 문제를 정리해서 상황에 따라 간편하게 적용할 수 있게 정리한 것을 말한다.

싱글톤 패턴은 객체 인스턴스가 딱 1개만 생성되는 것을 보장하는 디자인 패턴이다. 애플리케이션이 시작될 때 클래스가 최초 한번만 메모리를 할당하고(static) 메모리에 인스턴스를 만들어 사용한다.

싱글톤 패턴을 구현하는 방법은 다양한다. 강의에서는 객체를 미리 생성해두는 static으로 미리 생성해두는 가장 단순하고 안전한 방법을 선택하였다.

package hello.core.singleton;
public class SingletonService {

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

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

    // 3. 생성자를 private으로 선언해서 외부에서 new 키워드를 사용한 객체 생성을 못하게 막는다. -> 딱 1개의 객체 인스턴스만 존재하므로
    private SingletonService() {
    }

    private void logic() {
        System.out.println("싱글톤 객체 로직 호출");
    }
}
  1. static 영역에 객체를 딱 1개만 생성한다.
  2. public으로 객체를 접근해서 객체 인스턴스가 필요하면 static 메서드를 통해서만 생성한 객체를 조회하도록 한다.
  3. 생성자를 private으로 선언해서 외부에서 new 키워드로 객체 생성을 할 수 없도록 한다. (= 딱 1개의 객체 인스턴스만 존재하도록 한다.)

생성한 싱글톤 객체를 사용해보자.
일단 생성한 싱글톤 서비스에 private 접근자가 붙었으므로 new 키워드로 객체를 생성할 수 없다.

	@Test
    @DisplayName("싱글톤 패턴을 적용한 객체 사용")
    
    void singletonServiceTest() {
        SingletonService singletonService1 = SingletonService.getInstance();
        SingletonService singletonService2 = SingletonService.getInstance();

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

        assertThat(singletonService1).isSameAs(singletonService2);
    }
    
    /* 결과
    singletonService1 = hello.core.singleton.SingletonService@c0c2f8d
	singletonService2 = hello.core.singleton.SingletonService@c0c2f8d
    */

같은 객체를 반환하는 것을 확인할 수 있다. 이처럼 있는 객체를 재활용하기 때문에 성능이 좋아진다는 장점이 있다.
즉, 싱글톤 패턴을 적용하면 고객의 요청이 올 때마다 객체를 생성하는 것이 아니라, 이미 만들어진 객체를 공유하기 때문에 효율적으로 객체를 효율적으로 사용할 수 있다.

장점을 정리하면

  • 고정된 메모리 영역을 갖고 있어, 메모리 낭비를 줄일 수 있다.
  • 싱글톤으로 생성된 클래스 인스턴스는 전역이므로 다른 클래스의 인스턴스들과 데이터 공유가 쉽다.
  • 절대적으로 인스턴스가 한 개만 존재하는 것이 보증된다.
  • 두번째 이용시부터 객체 로딩 시간이 줄어 성능이 좋아진다.

이러한 장점으로 DBCP(Database Connection Pool)과 같은 공통된 객체를 여러개 사용하는 상황에서 많이 사용된다.

하지만! 싱글톤 패턴의 단점도 존재한다.

  • 위에 작성한 SingletonService 코드를 보면, 사전 작업이 많다.
  • 클라이언트가 구체 클래스에 의존하므로 DIP(의존성 역전 원칙)를 위반하며, OCP(개방 폐쇄 원칙) 원칙을 위반할 가능성이 높다. => 객체 지향 설계에 어긋나기 때문에 수정이 어려워지고 유지보수 비용이 높아질 수 있다.
  • 테스트가 어렵다.
  • 내부 속성을 변경하거나 초기화하기 어렵다.
  • private 생성자로 자식 클래스를 만들기 어렵다.
  • 유연성이 떨어진다.
  • 이러한 이유로 안티패턴으로 불리기도 한다.

스프링 컨테이너는 이러한 싱글톤이 가지고 있는 단점은 극복하고, 장점을 극대화한다. 그래서 스프링 컨테이너를 싱글톤 컨테이너라고 하기도 한다.

싱글톤 컨테이너

스프링 컨테이너는 싱글톤 패턴이 가지고 있는 문제점을 해결하면서, 객체 인스턴스를 싱글톤(하나의 객체만 색성)으로 관리한다.

스프링 컨테이너, 싱글톤 컨테이너

  • 스프링 컨테이너는 싱글톤 패턴을 적용하지 않아도, 객체 인스턴스를 싱글톤으로 관리한다.
    • 컨테이너는 객체를 하나만 생성해서 관리한다.
  • 스프링 컨테이너는 싱글톤 컨테이너 역할을 한다. 싱글톤 객체를 생성하고 관리하는 기능을 싱글톤 레지스트리라고 한다.
    • 레지스트리는 현재의 시스템 정보를 담고 있는 데이터베이스 형태의 저장소를 말한다.
  • 스프링 컨테이너의 기능 덕분에 싱글톤 패턴의 단점을 해결하면서 객체를 싱글톤으로 유지할 수 있다.
    • 싱글톤 패턴을 사용하기 위한 복잡한 코드가 필요없다.
    • DIP, OCP, 테스트, 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);

        // memberService1 != memberService2
        assertThat(memberService1).isSameAs(memberService2);
    }
    
    /* 실행 결과: memberService1과 memberService2 모두 같은 객체를 반환하는 것을 확인할 수 있다. 
    memberService1 = hello.core.member.MemberServiceImpl@c2db68f
	memberService2 = hello.core.member.MemberServiceImpl@c2db68f
     */

그림처럼 스프링 컨테이너 덕분에 고객의 요청이 올 때마다 객체를 생성하는 것이 아니라, 이미 만들어진 객체를 공유해서 효율적으로 재사용할 수 있다.

+) 스프링의 기본 빈 등록 방식은 싱글톤 방식이지만, 다른 방식도 지원한다. 요청할 때마다 새로운 객체를 생성해서 반환하는 기능도 있다.

싱글톤 방식의 주의점

객체 인스턴스를 하나만 생성해서 공유하는 싱글톤 방식은 여러 클라이언트가 하나의 객체 인스턴스를 공유하기 때문에 싱글톤 객체는 상태를 유지(stateful)하게 설계하면 안된다.
즉, 무상태(stateless)로 설계해야 한다. 무상태는 현재 상태를 모르기 때문에, 요청을 할 때 매번 명확하게 말해주어야 올바른 결과를 받을 수 있다.
무상태 설계란,

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

스프링 빈 필드에 공유 값이 설정되면 큰 장애가 발생할 수 있다.

예를들어,
사용자 A가 10000원 결제했고, A가 주문하고 금액을 조회하는 사이에 B가 20000원을 결제하였다.

StatefulService.java

public class StatefulService {
    private int price;  // 상태 유지 필드 : 10000원이었던게 20000원이 된 것

    public void order(String name, int price) {
        System.out.println("name = " + name + "price = " + price);
        this.price = price; // 문제 발생
    }

    public int getPrice() {
        return price;
    }
}

StatefulServiceTest.java

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.class);
        StatefulService statefulService2 = ac.getBean(StatefulService.class);

        // ThreadA : A 사용자 10000원 주문
        statefulService1.order("userA", 10000);
        // ThreadB : B 사용자 20000원 주문 -> A가 주문하고 금액을 조회하는 사이에 B가 중간에 들어옴
        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();
        }
    }
}

/* 테스트 결과
name = userAprice = 10000
name = userBprice = 20000
price = 20000
*/

이 코드는 최대한 단순히 설명하기 위해, 실제 스레드는 사용하지 않았다.
Thread A가 사용자A를 호출하고 ThreadB는 사용자B 코드를 호출한다고 했을 때, StatefulServiceprice 필드는 공유되는 필드로 특정 클라이언트가 값을 변경한다. 따라서 사용자 A의 주문금액이 10000원이 아닌, 20000원이 나오는 것이다.
이러한 공유 필드 사용으로 발생하는 오류는 매번! 꼭! 범할 수 있으니, 스프링 빈을 항상 무상태(stateless)로 관리해야 한다고 한다.

만약 이를 무상태로 관리한고 싶다면, 다음과 같이 값을 반환하여 지역변수에 대입해 사용하는 방법도 있다.

StatefulService.java

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

StatefulServieTest.java

APrice = statefulService1.order("userA", 10000);

@Configuration과 싱글톤

@Configuration
public class AppConfig {
    // @Bean memberService -> new MemoryMemberRepository()
    // @Bean orderService -> new MemoryMemberRepository()
    @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 RateDiscountPolicy();
    }
}

AppConfig를 살펴보면 memberService의 빈을 만드는 코드에서 memberRepository()를 호출한다. 이는 new MemoryMemberRepository()를 호출하여 새로운 MemoryMemberRepository 객체를 생성한다.
orderService 빈을 만드는 코드도 동일하게 memberRepository()를 호출한다. 이는 new MemoryMemberRepository를 호출하여 새로운 MemoryMemberRepository 객체를 새엇ㅇ한다.

결과적으로 서로 다른 MemoryMemberRepository가 생성되면서 싱글톤이 마치 깨지는 것처럼 보인다. 과연 그럴까?

ConfigurationSingletonTest.java

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

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

        MemberRepository memberRepository1 = memberService.getMemoryRepository();
        MemberRepository memberRepository2 = memberService.getMemoryRepository();

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

        Assertions.assertThat(memberService.getMemoryRepository()).isSameAs(memberRepository);
        Assertions.assertThat(orderService.getMemberRepository()).isSameAs(memberRepository);
    }
    
    /* 수행 결과
    memberService -> memberRepository = hello.core.member.MemoryMemberRepository@c35172e
	orderService -> memberRepository = hello.core.member.MemoryMemberRepository@c35172e
	memberRepository = hello.core.member.MemoryMemberRepository@c35172e
    */

확인해보면 memberRepository 인스턴스는 모두 같은 인스턴스가 공유되어 사용된다. 왜 두번 호출되지 않았을까?

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

위 예제를 통해 스프링이 싱글톤을 보장해주는 것을 확인했다. 어떻게 가능한 것인가?
위 의문점의 답은 @Configuration에 있다.

ApplicationContext ac = new AnnotationConfigApplicationContext(AppConfig.class);

AnnotationConfigApplicationContext에 의해 파라미터로 넘긴 값은 스프링 빈으로 등록된다. 따라서 AppConfig도 스프링 빈이 된다. 스프링 빈을 조회해서 클래스 정보를 출력해보자.

순수한 클래스라면 class hello.core.AppConfig와 같이 출력되어야 하지만, 다음과 같은 결과를 확인할 수 있다.
bean = class hello.core.AppConfig$$EnhancerBySpringCGLIB$$50587f02
이렇게 클래스 명에 xxxCGLIB가 붙은 것은, 스프링이 CGLIB라는 바이트코드 조작 라이브러리를 사용해서 AppConfig 클래스를 상속받은 임의의 다른 클래스를 만들고, 다른 클래스를 스프링 빈으로 등록한 것이다.
+) CGLib은 Code Generator Library의 약자로, 클래스의 바이트코드를 조작하여 Proxy 객체를 생성해주는 라이브러리이다.


AppConfig 클래스를 상속받아 싱글톤을 위한 코드를 추가한 AppConfig@CGLIB 클래스를 생성한다. 따라서 조회했을 때 순수한 클래스가 아닌 CGLIB가 붙은 것이다. 또한 AppConfig@CGLIB는 AppConfig의 자식 타입이므로, AppConfig 타입으로 조회할 수 있다.

내부 로직은 복잡하겠지만 대략 이런식으로 동작할 것이라 추측할 수 있다.

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

@Bean이 붙은 메서드마다 이미 스프링 빈이 존재하면 존재하는 빈을 반환하고, 스프링 빈이 없으면 새로운 빈을 생성해서 스프링 빈으로 등록하고 반환하는 코드가 동적으로 만들어져, 싱글톤이 보장된다.

@Configuration을 적용하지 않고, @Bean만 적용하는 경우

@Configuration을 주석처리하고 테스트해보았다.

bean = class hello.core.AppConfig

순수한 AppConfig로 스프링 빈에 등록되었고,

call AppConfig.memberService
call AppConfig.memberRepository
call AppConfig.memberRepository
call AppConfig.orderService
call AppConfig.memberRepository

memberRepository가 총 3번 호출되었다. 첫번째는 @Bean에 의한 스프링 컨테이너 등록을 위한 것이고, 두번째, 세번째는 각각 memberRepository()를 호출하면서 발생하였다. 또한 서로 다른 MemoryMemberRepository 인스턴스를 가지고 있다.

Summary

  • @Bean을 사용하면 스프링 빈으로 등록되지만, 싱글톤은 보장하지 않는다.
  • 스프링 설정 정보는 @Configuration을 사용하여 싱글톤을 보장하자.

Reference

profile
https://mywnajsldkf.tistory.com -> 이사 중

0개의 댓글