[Spring] 기본편 #5 - 싱글톤 컨테이너

strongmhk·2023년 5월 16일
0

Spring

목록 보기
5/25
post-thumbnail


📌웹 애플리케이션과 싱글톤

  • 스프링은 태생이 기업용 온라인 서비스 기술을 지원하기 위해 탄생했다.
  • 대부분의 스프링 애플리케이션은 웹 애플리케이션 이지만, 웹이 아닌 애플리케이션도 얼마든지 개발할 수 있다.
  • 웹 애플리케이션은 보통 여러 고객(클라이언트)이 동시에 요청을 한다.



스프링 없는 순수한 DI컨테이너 테스트

package Hello.core.singleton;

import Hello.core.AppConfig;
import Hello.core.member.MemberService;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.context.ApplicationContext;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;

import static org.assertj.core.api.Assertions.*;

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
        assertThat(memberService1).isNotSameAs(memberService2);
    }
}


  • 위의 스프링 없는 순수한 DI컨테이너인 AppConfig는 요청을 할 때 마다 객체를 새로 생성한다.
  • 고객 트래픽이 초당 100이 나오면 초당 100개 객체가 생성되고 소멸된다..(메모리 낭비가 심함)
  • 이를 해결하기 위해서는 객체가 딱 1개만 생성되고, 공유하도록 설계하면 된다(싱글톤 패턴)






📌싱글톤 패턴

  • 클래스의 인스턴스가 딱 1개만 생성되는 것을 보장하는 디자인 패턴
  • 객체 인스턴스를 2개 이상 생성하지 못하도록 막음(private 생성자를 사용해 외부에서 new 키워드를 사용하지 못하도록 함)


구현(스프링 X, 단순 자바 코드)

test.java.Hello.core.singleton

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를 이용한 인스턴스 생성을 방지
    private SingletonService(){

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

}
  • static 영역에 instance라는 객체를 미리 하나 생성해서 올려둔다
  • 이 객체 인스턴스가 필요하면 오직 getInstance() 메소드를 통해서만 조회할 수 있고, 이 메소드를 호출하면 항상 같은 인스턴스를 반환한다
  • 이 객체의 생성자를 private으로 지정해 외부에서 new를 통해 이 객체의 인스턴스가 생성되는 것을 방지한다 -> 1개의 객체 인스턴스만 존재




테스트 코드

package Hello.core.singleton;

import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;

import static org.assertj.core.api.Assertions.assertThat;

public class SingletonServiceTest {
    @Test
    @DisplayName("싱글톤 패턴을 적용한 객체 사용")
    void singlethonServiceTest(){
        //1. 조회 : 메소드 호출시 같은 객체 반환
        SingletonService singletonService1 = SingletonService.getInstance();
        //2. 조회 : 메소드 호출시 같은 객체 반환
        SingletonService singletonService2 = SingletonService.getInstance();

        // 참조값이 같은 것을 확인
        System.out.println("singletonService1 = " + singletonService1);
        System.out.println("singletonService2 = " + singletonService2);

        assertThat(singletonService1).isSameAs(singletonService2);
        //same 주소값 비교(==)
        //equal 대상의 내용(content) 비교

        singletonService1.logic();

    }
}

  • getInstance() 메소드를 호출할 때 마다 같은 객체 인스턴스를 반환함
  • 고객의 요청이 올 때 마다 객체를 생성 X, 이미 만들어진 객체를 공유 O(효율적)

참고

싱글톤 패턴을 구현하는 방법은 여러가지가 있음(호출 타이밍에 객체가 생성이 안돼있으면 그때서야 생성해 사용, 지연하는 방법)
그러나 이러한 부분들은 스프링이 다 관리해주기 때문에 궁금하면 찾아볼 것!



싱글톤 패턴의 문제점

  • 싱글톤 패턴을 구현하는 코드 자체가 많이 들어감
  • 의존관계상 클라이언트가 구체 클래스에 의존 -> DIP 위반, OCP 위반할 가능성 높음
  • 테스트하기 어려움
  • 내부 속성 변경과 초기화가 어려움
  • private 생성자로 자식 클래스를 만들기 어려움
  • 결론적으로 유연성이 떨어짐 -> 안티패턴






📌싱글톤 컨테이너



싱글톤 컨테이너 적용 후

  • 스프링 컨테이너는 싱글톤 패턴을 적용하지 않아도, 객체 인스턴스를 싱글톤으로 관리한다
  • 또한 앞에서 직접 코드로 구현했던, 싱글톤 패턴의 단점을 해결해준다(유연성 있게~_~)


테스트 코드

    @Test
    @DisplayName("스프링 컨테이너와 싱글톤")
    void springContainer(){
//        AppConfig appConfig = new AppConfig();
        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);
    }







📌싱글톤 방식의 주의점

  • 싱글톤 방식을 사용하면 여러 클라이언트가 하나의 같은 객체 인스턴스를 공유하기 때문에, 싱글톤 객체는 상태를 유지(stateful)하게 설계하면 안된다.
  • 무상태(stateless)로 설계해야 한다!
    • 특정 클라이언트에 의존적인 필드가 있으면 안된다
    • 특정 클라이언트가 값을 변경할 수 있는 필드가 있으면 안된다
    • 가급적 읽기만 가능해야 한다
    • 필드 대신에 공유되지 않는 지역변수, 파라미터 등을 사용해야한다


상태 유지(stateful)의 문제점 예시


package Hello.core.singleton;

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

}


테스트 코드

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;



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원 주문
        int userAPrice = statefulService1.order("userA", 10000);
        // ThreadB : B사용자 20000원 주문
        int userBPrice = statefulService2.order("userB", 20000);

        // ThreadA : 사용자A 주문 금액 조회
//        int price = statefulService1.getPrice();
        System.out.println("price = " + userAPrice); // A 사용자가 10000원 주문하고 A사용자의 주문 내역을 호출하려했는데
        // 호출하는 사이에 B 사용자가 20000원 주문하면 statefulService1과 statefulService2는 같은 인스턴스이기 때문에
        // price값이 20000원으로 돼버림..


//        Assertions.assertThat(statefulService1.getPrice()).isEqualTo(20000);

    }

    static class TestConfig{

        @Bean
        public StatefulService statefulService(){
            return new StatefulService();
        }
    }
}
  • 사용자 A가 10000원어치를 주문 후 사용자 B가 20000원 어치를 주문
  • 이때 사용자 A의 주문금액을 조회하면 20000원으로 뜸(price는 공유 필드이기 때문)
  • 즉, price와 같은 공유 필드를 사용하는 것을 지양해야함!






📌@Configuration과 싱글톤



AppConfig 살펴보기


@Configuration

public class AppConfig {

	@Bean

	public MemberService memberService() {

		return new MemberServiceImpl(memberRepository());

	}

	@Bean

	public OrderService orderService() {

		return new OrderServiceImpl(memberRepository(),discountPolicy());

	}	

	@Bean

	public MemberRepository memberRepository() {

		return new MemoryMemberRepository();

	}

...

}
  • AppConfig에서 @bean이 달린 메소드들을 호출하면 스프링 컨테이너에 빈을 등록한다
  • 그 후 memberService 빈과 orderService 빈이 memberRepository()를 호출한다
  • new MemoryMemberRepository()가 2번 호출된다
  • 그러면 다른 2개의 MemoryMemberRepository가 생성되면서 싱글톤이 깨지는 것 처럼보이는데, 테스트를 해보자




검증 용도의 코드 추가


public MemberRepository getMemberRepository() {

	return memberRepository;

}
  • MemberServiceImpl과 OrderServiceImpl에 위의 코드를 추가해준다


테스트 코드

package Hello.core.singleton;

import Hello.core.AppConfig;
import Hello.core.member.MemberRepository;
import Hello.core.member.MemberServiceImpl;
import Hello.core.order.OrderServiceImpl;
import org.junit.jupiter.api.Test;
import org.springframework.context.ApplicationContext;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;


import static org.assertj.core.api.Assertions.*;

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


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

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

        assertThat(memberService.getMemberRepository()).isSameAs(memberRepository);
        assertThat(orderService.getMemberRepository()).isSameAs(memberRepository);

    }
}

  • 확인해보니 memberRepository 인스턴스는 모두 같은 인스턴스가 공유되어 사용된다(참조값이 같다)
  • 그럼 AppConfig에 호출 로그를 남기는 방식으로 다시 한 번 검증해보자




AppConfig 변경

package Hello.core;

import Hello.core.discount.DiscountPolicy;
import Hello.core.discount.FixDiscountPolicy;
import Hello.core.discount.RateDiscountPolicy;
import Hello.core.member.MemberService;
import Hello.core.member.MemberServiceImpl;
import Hello.core.member.MemberRepository;
import Hello.core.member.MemoryMemberRepository;
import Hello.core.order.OrderService;
import Hello.core.order.OrderServiceImpl;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;


@Configuration
// 애플리케이션의 설정정보
public class AppConfig {

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


    // 예상
    // call AppConfig.memberService
    // call AppConfig.memberRepository
    // call AppConfig.orderService
    // call AppConfig.memberRepository
    // call AppConfig.memberRepository

    // 실제
    // call AppConfig.memberService
    // call AppConfig.memberRepository
    // call AppConfig.orderService

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

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

    @Bean
    public MemberRepository memberRepository() {
        System.out.println("call AppConfig.memberRepository");
        return new MemoryMemberRepository(); // 구현 : 메모리 회원 저장소
    }

    @Bean
    public DiscountPolicy discountPolicy(){
        return new RateDiscountPolicy(); //  구현 : 정액 할인 정책
    }

}

  • memberRepository()가 3번 호출될 것 처럼 보이지만, @Configuration이 관리해주기 때문에 1번만 호출된다






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


AppConfig 클래스 정보 출력

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

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

업로드중..

  • AppConfig도 파라미터로 넘겨줬기 때문에 스프링 빈이다
  • AppConfig 스프링 빈을 조회해서 클래스 정보를 출력해보자
  • 순수한 클래스 class hello.core.AppConfig가 출력되지 않고 CGLIB뭐시기가 출력된다






AppConfig의 상속

업로드중..

  • 스프링 빈에 등록된 AppConfig는 내가 만든 클래스가 아니고, 스프링이 CGLIB라는 바이트코드 조작 라이브러리를 사용해서 AppConfig 클래스를 상속받은 임의의 다른 클래스를 만들고, 그 다른 클래스를 스프링 빈으로 등록한 것이다






AppConfig@CGLIB 예상 코드


@Bean

public MemberRepository memberRepository() {

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

}
  • @Bean이 붙은 메소드마다 이미 스프링 빈이 존재하면 존재하는 빈을 반환, 스프링 빈이 없으면 생성해서 스프링 빈으로 등록 후 반환하는 코드가 동적으로 만들어진다


참고

AppConfig@CGLIBAppConfig의 자식 타입이므로, AppConfig 타입으로 조회할 수 있다(부모 타입을 조회하면 자식도 함께 조회된다)







📌정리

  • @Bean만 사용해도 스프링 빈으로 등록되지만, 싱글톤을 보장하지 않는다
    - memberRepository()처럼 의존관계 주입이 필요해서 메소드를 직접 호출할 때 싱글톤을 보장하지 않는다
  • 고민할 것 없이 스프링 설정 정보는 항상 @Configuration을 사용하자
profile
저 커서 개발자가 될래요!

0개의 댓글