Spring 핵심 원리 - 싱글톤 컨테이너

김태훈·2023년 2월 20일
0

Spring 핵심 원리

목록 보기
12/15

1. 웹 어플리케이션과 싱글톤

1. 스프링 없는 싱글 톤

보통의 웹 어플리케이션은 고객이 동시에 요청을 하게 된다. 만약 AppConfig (의존성 주입했던 DI컨테이너) 에서 많은 고객이 회원가입을 해서 memberService를 요청하게 될 때, 스프링없이 순수 JAVA코드를 짠다면 수많은 객체가 생성될 것이다. 이를 테스트 해보자.

public class SingletonTest {
    @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
        Assertions.assertThat(memberService1).isNotSameAs(memberService2);
    }
}

이렇게되면, 테스트에 통과한다. 즉, 요청이 올 때마다 새로운 객체가 생성된다.
하지만 이는 큰 서비스에서는 문제가 된다. 따라서 필요로 하는 객체가 딱 1개만 생성되고, 공유되도록 설계하면 해결될 것이다.

2. 싱글톤 패턴

1. 싱글톤 패턴의 정의

싱글톤 패턴이란 클래스의 인스턴스가 단 1개만 생성되는 것을 보장하는 디자인 패턴이다.
따라서 객체 인스턴스를 2개이상 생성하지 못하도록 막아야 한다.
이를 위해서 private생성자를 사용하여 외부에서 "new" 로 해당 클래스의 인스턴스를 생성하지 못하게 막아야 한다.

public class SingletonService {
    //1. private인데 static이므로 단 한개의 클래스만 static 영역에 올라간다.
    private static final SingletonService instance = new SingletonService();
    //2. public으로 열어서 객체 인스턴스가 필요하면 이 static 메서드를 통해서만 조회하도록 허용한다.
    public static SingletonService getInstance() {
        return instance;
    };
    //3. 생성자를 private으로 선언해서 외부에서 new 키워드를 사용한 객체 생성을 못하게 막는다.
    // 다른 곳에서 new로 SingletonService를 불러오려고 해도 안될 것임!!
    private SingletonService() {
    }
    public void logic() {
        System.out.println("싱글톤 객체 로직 호출");
    }
}

이렇게 하게 되면, 객체 인스턴스가 필요할때 오로지, getInstance()라는 메소드로 불러와야만 한다.
또한 생성자 메소드도 private으로 막는다.

이를 SingletonTest에서 확인해보자

2. 방금 만든 SingletonTest에서 싱글톤 객체를 적용했는지 확인

다음 코드를 추가하자.

@Test
@DisplayName("싱글톤 패턴을 적용한 객체 사용")
public void singletonServiceTest() {
  //private으로 생성자를 막아두었다. 컴파일 오류가 발생한다.
  //new SingletonService();
  //1. 조회: 호출할 때 마다 같은 객체를 반환
  SingletonService singletonService1 = SingletonService.getInstance();
  //2. 조회: 호출할 때 마다 같은 객체를 반환
  SingletonService singletonService2 = SingletonService.getInstance();
  //참조값이 같은 것을 확인
  System.out.println("singletonService1 = " + singletonService1);
  System.out.println("singletonService2 = " + singletonService2);
  // singletonService1 == singletonService2
  assertThat(singletonService1).isSameAs(singletonService2);
  singletonService1.logic();
}


같은 인스턴스로 적용시킴을 확인했다.

assertThat에서 isSameAs와 isEqualTo와의 차이

  • isSameAS
    인스턴스 비교
  • isEqualTo
    equlas 메소드와 동일 (대상의 내용을 비교)
  • ==
    주소값을 비교 (내용은 같아도 주소는 다를 수 있기 때문에.. ex) String비교)

3. 그렇다면 AppConfig에서 모든 클래스들을 다 Singleton으로 바꿔?

그렇지 않아도 된다.
자동으로 스프링 컨테이너가 이 일을 해주기 때문이다.

4. Singleton 패턴이 가지고 있는 문제점

  • 코드가 많아짐 (private 생성자 추가... 등등)
  • DIP 위반 -> getInstance 메소드 호출해야함
  • 클라이언트가 구체 클래스에 의존 -> OCP위반
  • 테스트하기 어려움 (Singleton을 지정해서 가져와야하기 때문)
  • 내부 속성 변경하기 어려움
  • private생성자로, 자식 클래스 만들기 어려움

이를 해결하는 것이 Singleton Container이다 !!

3. Singleton Container

스프링 컨테이너는 싱글톤 패턴의 문제를 해결하면서, 객체 인스턴스들을 싱글톤으로 관리한다.
따라서 스프링 빈은 싱글톤이다.
다시 말해서, 스프링 컨테이너는 싱글톤 컨테이너 역할을 한다.
코드를 보자

SingletonTest클래스에 해당 코드를 삽입할 건데, 우리는 컨테이너를 사용할 것이므로 AppConfig 클래스의 인스턴스를 생성할 것이 아니라, 스프링 컨테이너에서 AppConfig클래스를 관리하게 ApplicationContext에서 AppConfig인스턴스를 주입한다.

    @DisplayName("스프링 없는 순수한 DI 컨테이너")
    void springContainer() {
//        AppConfig appConfig = new AppConfig();
        ApplicationContext ac = new AnnotationConfigApplicationContext(AppConfig.class);
        //1. 조회: 호출할 때 마다 객체를 생성

        MemberService memberService1 = ac.getBean("memberService",MemberService.class);
        //2. 조회: 호출할 때 마다 객체를 생성
        MemberService memberService2 = ac.getBean("memberService",MemberService.class);
        //참조값이 다른 것을 확인
        System.out.println("memberService1 = " + memberService1);
        System.out.println("memberService2 = " + memberService2);
        //memberService1 != memberService2
        Assertions.assertThat(memberService1).isSameAs(memberService2);
    }

4. 싱글톤 방식의 주의점

싱글톤 방식은 여러 클라이언트가 하나의 같은 객체 인스턴스를 공유하므로, 싱글톤 객체는 stateful하게 설계하는 것이 아니라 stateless하게 설계해야 한다.

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

1. StatefulService의 코드 예시

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

2. StatefulService의 테스트 코드 예시

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);
        //ThreadA: A사용자 10000원 주문
        statefulService1.order("userA", 10000);
        //ThreadB: B사용자 20000원 주문
        statefulService2.order("userB", 20000);
        //ThreadA: 사용자A 주문 금액 조회
        int price = statefulService1.getPrice();
        //ThreadA: 사용자A는 10000원을 기대했지만, 기대와 다르게 20000원 출력
        System.out.println("price = " + price); //
        //---------------------- 얼마가 나올까 ? -----------------------
        Assertions.assertThat(statefulService1.getPrice()).isEqualTo(20000);
    }

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

    }
};

그러면, 저 price를 print한 값은 얼마가 나올까?
답은 20000원이다. statefulService1과 statefulService2가 서로 공유되어, 10000원에 20000원이 덮어 씌워졌기 때문이다.

3. Stateless 하게 코드 설계해서 해결하기

1. StatefulService 코드를 다음과 같이 변경

public class StatefulService {
//    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;
//    }
}

2. 테스트 코드 변경후 확인

package Goat.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);
        //ThreadA: A사용자 10000원 주문
        int userAPrice = statefulService1.order("userA", 10000);
        //ThreadB: B사용자 20000원 주문
        int userBPrice = statefulService2.order("userB", 20000);
        //ThreadA: 사용자A 주문 금액 조회
        System.out.println("price = " + userAPrice); //
        //---------------------- 얼마가 나올까 ? -----------------------
    }

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

    }
};

5. @Configuration과 싱글톤

@Configuration
public class AppConfig {

    @Bean
    public MemberService memberService(){
        return new MemberServiceImpl(memberRepository());
    }

    @Bean
    private static MemberRepository memberRepository() { //굳이 public으로
        return new MemoryMemberRepository();
    }

    @Bean
    public OrderService orderService(){
        return new OrderServiceImpl(memberRepository(), discountPolicy());
    }
    @Bean
    private static DiscountPolicy discountPolicy() {
//        return new FixDiscountPolicy();
        return new RateDiscountPolicy();
    }

}

AppConfig 설정을 보자.
@Bean으로 memberService 등록 -> new MemoryMemberRepository 주입 ->
@Bean으로 orderSerivce 등록 -> memberRepository(MemoryMemberRepository), discountPolicy 호출

이렇게 되면 두가지의 MemoryMemberRepository가 호출되어 싱글톤이 유지가 안되지 않을까?
이를 테스트해보자.

1. 싱글톤 유지 테스트

  • memberService에서 Repository 꺼내는 코드
  • orderService에서 Repository 꺼내는 코드

이렇게 두가지 코드를 작성해보자.

  • memberService
package Goat.core.member;

public class MemberServiceImpl implements MemberService{

    private final MemberRepository memberRepository;

    public MemberServiceImpl(MemberRepository memberRepository) {
        this.memberRepository = memberRepository;
    }

    @Override
    public void join(Member member) {
        memberRepository.save(member);
    }

    @Override
    public Member findMember(Long memberId) {
        return memberRepository.findById(memberId);
    }

    //싱글톤 테스트 코드
    public MemberRepository getMemberRepository() {
        return memberRepository;
    }
}
  • orderService
public class OrderServiceImpl implements OrderService{

    private final MemberRepository memberRepository;
    private final DiscountPolicy discountPolicy;

    public OrderServiceImpl(MemberRepository memberRepository, DiscountPolicy discountPolicy) {
        this.memberRepository = memberRepository;
        this.discountPolicy = discountPolicy;
    }

    @Override
    public Order createOrder(Long memberId, String itemName, int itemPrice) {
        Member resultMember = memberRepository.findById(memberId);
        int discountPrice = discountPolicy.discount(resultMember,itemPrice);

        return new Order(memberId, itemName, itemPrice, discountPrice);
    }

    //싱글톤 테스트 코드
    public MemberRepository getMemberRepository() {
        return memberRepository;
    }
}
  • 테스트 코드 작성
public class ConfigurationSingletonTest {

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

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

        //모두 같은 인스턴스를 참고하고 있다.
        System.out.println("memberService -> memberRepository = " + memberService.getMemberRepository());
        System.out.println("orderService -> memberRepository = " + orderService.getMemberRepository());

    }
}

결과는 다른 인스턴스였다. 잘못된 결과가 나왔는데 이유가 무엇일까?

  • 잘못된 결과가 나온 이유
    AppConfig에서 MemberRepository 메소드가 static이기 때문이다.
@Bean
    private static MemberRepository memberRepository() { //굳이 public으로
        return new MemoryMemberRepository();
    }

다음은 공식 문서 내용이다.

By marking this method as static, it can be invoked without causing instantiation of its declaring @Configuration class, thus avoiding the above-mentioned lifecycle conflicts. Note however that static @Bean methods will not be enhanced for scoping and AOP semantics as mentioned above.

다시 말해서, @Configuration과 @Bean의 조합으로 싱글톤을 보장하는 경우는 static이 아닌 메소드일 때만 가능하다고 한다.
따라서 static을 빼고 public으로 바꾼 후 돌려봤더니,

  • memberRepository도 테스트 해본 결과
public class ConfigurationSingletonTest {

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

        //모두 같은 인스턴스를 참고하고 있다.
        System.out.println("memberService -> memberRepository = " + memberService.getMemberRepository());
        System.out.println("orderService -> memberRepository = " + orderService.getMemberRepository());
        System.out.println("memberRepository = " + memberRepository);

    }
}

2. @Bean에서의 호출 횟수?

같은 인스턴스가 참조가 되었다는 것은 스프링 컨테이너에서 호출을 각각 한번씩만 하기 때문일까?
이를 확인해보자

@Bean에서 print를 통해 호출을 확인해보자.

@Configuration
public class AppConfig {

    @Bean
    public MemberService memberService(){
        System.out.println("call AppConfig.memberService");
        return new MemberServiceImpl(memberRepository());
    }

    @Bean
    public MemberRepository memberRepository() { //굳이 public으로 -> 이유가있었구나.. 스프링컨테이너 싱글톤 보장 위함
        System.out.println("call AppConfig.memberRepository");
        return new MemoryMemberRepository();
    }

    @Bean
    public OrderService orderService(){
        System.out.println("call AppConfig.orderService");
        return new OrderServiceImpl(memberRepository(), discountPolicy());
    }
    @Bean
    private static DiscountPolicy discountPolicy() {
//        return new FixDiscountPolicy();
        return new RateDiscountPolicy();
    }

}

우리의 예상으로는 (순서는 달라질수 있음)
1. memberService 호출
2. memberRepository 호출
3. memberRepository 호출
4. orderService 호출
5. memberRepository 호출
이렇게 될것 같다.

하지만 결과는

단 세번만 호출이 된다.
이 이유를 알아보자.

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

스프링은 클래스의 바이트코드를 조작하는 라이브러리를 사용한다. 이 때문에 결국 저렇게 단 세번만의 호출을 할 수 있게 만든다.
이를 테스트 해보자.

1. ConfigurationSingletonTest에 코드 추가

	@Test
    void configurationDeep() {
        ApplicationContext ac = new AnnotationConfigApplicationContext(AppConfig.class);
        //AnnotationConfigApplication으로 넘기면 AppConfig도 스프링 빈으로 등록된다.
        AppConfig bean = ac.getBean(AppConfig.class);
        System.out.println("bean = " + bean.getClass());
    }


이렇게 뒤에 SpringCGLIB이 같이 출력이 되는데 순수한 클래스라면
"class Goat.core.AppConfig"
만 출력이 되어야 한다.
즉, @Configuration이 붙으면 스프링이 자동으로 CGLIB이라는 바이트코드 조작 라이브러리를 사용하여 AppConfig 클래스를 상속받은 임의의 다른 클래스를 만들고, 그 다른 클래스를 스프링 빈으로 등록한 것이다.

Configuration 어노테이션을 생략한다면, 싱글톤으로 등록이 안되고, 각 Bean마다 순수 자바 코드처럼 스프링 컨테이너에 등록될 때마다 해당 인스턴스를 계속해서 생성하게 된다.
게다가, memberServic를 호출했을 때, 주입된 memberRepository는 스프링 빈이 아니다. (내가 직접 new해서 만든 인스턴스임)

즉, Bean만 사용하면 스프링 빈으로 등록이 되지만, 싱글톤을 보장하지 않는다.

profile
기록하고, 공유합시다

0개의 댓글