본문 바로가기
김영한 스프링/김영한 스프링 기본편

싱글톤 컨테이너

by hoshi03 2023. 9. 18.

• 웹 어플리케이션과 싱글톤

 

싱글톤으로 객체의 인스턴스를 단 하나만 만들고 공유하는 방식이 아닌 호출할때마다 새 객체를 만드는 방식을 사용하면 많이 비효율적이다. 일단 비효율적인 DI 컨테이너를 만들고 개선해보자

public class SingletonTest {
    @Test
    @DisplayName("스프링 없는 순수 DI 컨테이너")
    void pureContainer(){
        AppConfig appConfig = new AppConfig();
        //1. 조회
        MemberService memberService1 = appConfig.memberService();
        MemberService memberService2 = appConfig.memberService();
        System.out.println("memberService1 = " + memberService1);
        System.out.println("memberService2 = " + memberService2);
        //당연히 두개는 다르다
        Assertions.assertThat(memberService1).isNotSameAs(memberService2);
    }
}

• 싱글톤 패턴

클래스의 인스턴스가 딱 1개만 생성되는 것을 보장하는 디자인 패턴
그래서 객체 인스턴스를 2개 이상 생성하지 못하도록 막아야 한다.
private 생성자를 사용해서 외부에서 임의로 new 키워드를 사용하지 못하도록 막아야 한다

 

단 하나의 private static final 인스턴스를 맨 처음에 만들어두고 private 생성자로 다른데서는 사용 못하게 막는다

다른 곳에서는 getInstance를 사용해서 인스턴스를 조회하게 만들어준다

public class SingletonService {
    //인스턴스 딱 하나만 생성
    private static final SingletonService instance = new SingletonService();

    //객체 인스턴스를 getInstance를 통해서 조회
    public static SingletonService getInstance(){
        return instance;
    }
    
    //private 생성자로 다른데서는 싱글톤을 생성 못하게 막음
    private SingletonService(){}

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

이렇게 싱글톤으로 만들면 여러번 호출해도 같은 객체가 호출된다

    @Test
    @DisplayName("싱글톤 사용한 객체 테스트")
    void singletonTest(){
        SingletonService singletonService1 = SingletonService.getInstance();
        SingletonService singletonService2 = SingletonService.getInstance();
        Assertions.assertThat(singletonService1).isSameAs(singletonService2);
    }

 

기존에 AppConfig는 싱글톤 패턴이 적용되지 않아서 호출할때마다 새 객체를 생성해주는 상태인데 싱글톤으로 전환하려니까 아찔하다.. 하지만 싱글톤 컨테이너를 사용하면 바로 싱글톤으로 전환할 수 있다!

 

싱글톤 패턴의 문제점

싱글톤 패턴이 정말 좋아보이지만 문제점도 많다..

싱글톤 패턴을 구현하는 코드 자체가 많이 들어간다. <- 싱글톤에 있는 logic 함수 하나 만들기 위해서 많은 코드를 쳐야됨
의존관계상 클라이언트가 구체 클래스에 의존한다. DIP를 위반한다.
클라이언트가 구체 클래스에 의존해서 OCP 원칙을 위반할 가능성이 높다.
테스트하기 어렵다.
내부 속성을 변경하거나 초기화 하기 어렵다.
private 생성자로 자식 클래스를 만들기 어렵다.
결론적으로 유연성이 떨어진다.
안티패턴으로 불리기도 한다.

 

• 싱글톤 컨테이너

스프링 컨테이너는 싱글톤 패턴을 사용하지 않아도 객체 인스턴스를 싱글톤으로 관리한다

스프링 컨테이너는 싱글톤 컨테이너 역할을 한다. 

이렇게 싱글톤 객체를 생성하고 관리하는 기능을 싱글톤 레지스트리라 한다.
스프링 컨테이너의 이런 기능 덕분에 싱글턴 패턴의 모든 단점을 해결하면서 객체를 싱글톤으로 유지할 수 있다.
싱글톤 패턴을 위한 지저분한 코드가 들어가지 않아도 된다.
DIP, OCP, 테스트, private 생성자로 부터 자유롭게 싱글톤을 사용할 수 있다.

 

    void springContainer(){
        AnnotationConfigApplicationContext ac = new AnnotationConfigApplicationContext(AppConfig.class);
        AppConfig appConfig = new AppConfig();
        //1. 조회
        MemberService memberService1 = ac.getBean("memberService",MemberService.class);
        MemberService memberService2 = ac.getBean("memberService",MemberService.class);
        Assertions.assertThat(memberService1).isSameAs(memberService2);
    }

AppConfig를 싱글톤 패턴으로 만들지 않았어도 스프링 컨테이너에 등록하면(정적 메소드는 안됨!) 싱글톤으로 사용한다

기본 bean 등록 방식은 싱글톤이지만 요청할때 마다 새 객체를 만드는 방법으로 수정할 수도 있다

 

 

  싱글톤 방식의 주의점

싱글톤 방식은 여러 클라이언트가 한 객체를 공유하기 때문에 싱글톤 객체는 상태를 유지(statefull)하게 설계하면 안된다!

무상태(stateless)로 설계해야 한다

특정 클라이언트에 의존적이거나 변경 가능하면 안되고 가급적 읽기만 가능하게 해야한다.

지역변수나 파라미터, threadlocal등을 사용해야 한다

*테스트 설정할때 ctrl + shift + t 를 이용하면 바로 테스트를 만들 수 있다

 

예시를 위해 만든 주문하는 기능을 가진 클래스 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;
    }
}

 

StatefulServiceTest를 만들어서 stateful한 상태의 문제점 테스트

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

StatefulServiceTest를 만들어서 테스트를 해보면 A사용자의 주문 금액은 10000원이지만 B사용자가 주문 하면 그 주문이 덮어씌워져버린다

 

 

이 문제점을 해결하기 위해서 기존에 StatefulService를 고쳐야한다

공유하는 price를 없애고 지역변수로 사용하는걸로 코드를 수정한다

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

order 메서드 내부의 지역변수로 처리되게 변경했다

공유필드는 항상 주의하면서 stateless가 되게 설계하자!

 

• @Configuration과 싱글톤

우리가 사용하는 Appconfig 코드에서는 memberService 빈을 만드는 코드를 보면 `memberRepository()` 를 호출한다.
이 메서드를 호출하면 `new MemoryMemberRepository()` 를 호출한다.
orderService 빈을 만드는 코드도 동일하게 `memberRepository()` 를 호출한다.
이 메서드를 호출하면 `new MemoryMemberRepository()` 를 호출한다.
결과적으로 각각 다른 2개의 `MemoryMemberRepository` 가 생성되면서 싱글톤이 깨지는 것 처럼 보이는데 

스프링 컨테이너에서 이 문제를 어떻게 해결하나?

 

MemberServiceImpl과 OrderServiceImpl에 테스트 코드를 추가해서 테스트해보기

public MemberRepository getMemberRepository(){
        return memberRepository;
    }

각자 가져오는 memberRepositoty가 뭘지 테스트해보자

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

        //모두 같은 인스턴스를 참고하고 있다.
        assertThat(memberService.getMemberRepository()).isSameAs(memberRepository);
        assertThat(orderService.getMemberRepository()).isSameAs(memberRepository);
    }
}

여러번 new로 호출해서 memberRepository를 3개 가져올 줄 알앗는데 모두 같은 걸 가져왔다!

 

Appconfig에 memberRepository를 생성할대마다 call을 하게 코드를 변경하고 찍으면 

    //저장소 선택하는 함수
    @Bean
    public MemberRepository memberRepository() {
        System.out.println("call AppConfig.memberRepository");
        return new MemoryMemberRepository();
    }

 

1. 스프링 컨테이너가 스프링 빈에 등록하기 위해 @Bean이 붙어있는 `memberRepository()` 호출
2. memberService() 로직에서 `memberRepository()` 호출
3. orderService() 로직에서 `memberRepository()` 호출

 

이렇게 3번 호출될것 같지만 단 한번만 호출되면서 싱글톤을 보장해준다.. 

 

•@Configuration과 바이트 코드

이렇게 된 이유는 @Configuration 어노테이션이 붙은 Appconfig 클래스도 클래스 명에 xxxCGLIB가 붙으면서AppConfig 클래스를 상속받은 임의의 다른 클래스를 만들고, 그 다른 클래스를 스프링 빈으로 등록해서 싱글톤을 보장해주기 때문이다 (싱글톤 패턴으로 이미 있는지 체크해서 없으면 생성해서 반환을, 있으면 그걸 반환을 해준다고 한다)

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

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

 

AppConfig에서 @Configuration 어노테이션을 빼버리면 바이트 코드가 붙지 않은 깔끔한 AppConfig가 나오고

싱글톤이 깨져서 memberRepository가 여러번 호출되버린다 

 

결론

@Bean만 사용해도 스프링 빈으로 등록되지만, 싱글톤을 보장하지 않는다.
`memberRepository()` 처럼 의존관계 주입이 필요해서 메서드를 직접 호출할 때 싱글톤을 보장하지 않는다.

스프링 설정 정보는 항상 `@Configuration` 을 사용하자.

'김영한 스프링 > 김영한 스프링 기본편' 카테고리의 다른 글

의존관계 자동 주입  (1) 2023.10.03
컴포넌트 스캔  (0) 2023.09.20
스프링 빈과 스프링 컨테이너  (0) 2023.09.13
자바로 만들기  (0) 2023.08.31