본문 바로가기

Spring

싱글톤 컨테이너

이전에 우리는 스프링 컨테이너의 존재와 필요성에 대해서 느꼈다.

이러한 스프링 컨테이너는 싱글톤 패턴의 형태로 되어 있는데, 싱글톤은 무엇인지 그리고, 싱글톤 컨테이너는 어떻게 구성되어 있는지 알아보자.

 

 

 

💻 싱글톤 패턴

먼저, 싱글톤 패턴이 무엇인지부터 알아보자.

예전 학부 수업중 디자인 패턴을 몇개 알아본 적이 있는데, 그 중에 싱글톤 패턴에 대해서도 다뤘었다. 그 당시는 어려워서 자세한 구현 방법은 제대로 알지 못하였고, 객체를 하나만 생성한다는 것만 알았다.

 

싱글톤 패턴이란 클래스의 인스턴스가 딱 1개만 생성 되는 것을 보장하는 디자인 패턴이다. 이 싱글톤 디자인 패턴을 따르는 것들은 객체 인스턴스가 2개 이상 생성되지 못한다. 이렇게 구성하는 방식은 꽤나 간단하다.

 

 

public class SingletonService {

	private static final SingletonService instance = new SingletonService();

	public static SingletonService getInstance() {
         return instance;
	}
    
    private SingletonService() {
    }
}

 

해당 예시는 SingletonService라는 클래스를 싱글톤 패턴에 의해 만든 것이다.

스태틱 영역에 객체를 딱 1개만 생성해두었다. 

 

만약, 다른 코드에서 해당 클래스의 참조를 원한다면, 미리 만들어 둔 instance 변수를 return해주는 getInstance() 메소드를 이용해서만 조회를 할 수 있다.

 

생성자는 private로 지정하여서 외부에서 임의로 new 키워드를 이용하여 생성하지 못하도록 설정해두었다.

싱글톤을 구현하는 방법에는 여러가지가 있지만, 여기서는 가장 단순하게 미리 인스턴스를 생성해두고 그것을 리턴해주는 방법을 사용했다.

 

이렇게 싱글톤 패턴을 적용하면, 해당 클래스를 참조할때마다 새로운 객체를 생성하는 것이 아니라, 이미 만들어진 객체를 공유해서 효율적으로 사용할 수 있다. 이렇게 되면 메모리적으로 상당히 좋은 효과를 이끌어 낸다.

 

 

 

 

 

 

💻 싱글톤 패턴의 문제점

 

이런 싱글톤 패턴에도 문제점이 당연히 존재한다.

  • 코드 자체가 많이 들어간다.
  • 의존관계상 구체 클래스에 의존한다. -> DIP 위반
  • 클라이언트가 구체 클래스에 의존해서 OCP원칙 위반 가능성이 높다.
  • 테스트하기 어렵다.
  • 내부 속성을 변경하거나 초기화 하기 어렵다.
  • 자식 클래스를 만들기 어렵다. 

이렇게 문제점이 많아서 안티패턴으로 불리기도 하며, 너무 많은 싱글톤 패턴은 악영향을 초래한다.

 

 

 

 

 

 

 

💻 싱글톤 컨테이너

 

하지만 다행히도 우리가 사용하는 스프링 컨테이너는 싱글톤 패턴의 문제점을 해결하면서, 객체 인스턴스를 싱글톤으로 관리해준다.

@Test
    @DisplayName("스프링 컨테이너와 싱글톤")
    void singleContainer(){

        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
        Assertions.assertThat(memberService1).isSameAs(memberService2);


    }

 

위 코드는 하나의 싱글톤 컨테이너를 만들고,

그 코드에 똑같은 memberService라는 빈을 참조하는 객체들을 2개 만들었다.

 

 

 

 

 

해당 테스트 코드를 돌리면 memberService1,2가 똑같은 것으로 나온다.

즉, 스프링 컨테이너는 자동으로 객체 인스턴스들을 싱글톤으로 유지시켜 주고 있다.

이렇게 싱글톤으로 유지를 시켜주고 있기 때문에, 해당 클래스에 대해 요청이 들어올 때마다 객체를 생성하는 것이 아닌, 이미 만들어진 객체를 공유하여 재사용할 수 있다.

 

isSameAs메소드에 대해 설명하자면, isEqualTo는 객체의 값이 같은지를 비교하지만, isSameAs는 객체가 같은 객체인지 즉, 메모리에서 같은 주소에 위치해있는지를 비교한다.

 

 

 

 

💻 싱글톤 사용시 주의점

 

싱글톤은 객체를 생성하는 것이 아닌, 이미 만들어진 객체를 공유해서 재사용하는 것이다. 따라서, 이 객체가 클라이언트에 의해서 변경되는 것은 최대한 막아야한다. 특정 클라이언트에 의존적인 필드가 있으면 안되며, 또한 특정 클라이언트가 값을 변경할 수 있으면 안된다.

즉, 싱글톤 객체는 상태를 stateful하게 설계하면 안되고 stateless(무상태)하게 설계되어져야만 한다!

 

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

 

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;

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

 

StatefulService는 싱글톤 패턴으로 제작되어졌다.

허나, order메소드를 보면

this.price = price; 라는 코드가 존재한다. 이것은 특정 클라이언트가 싱글톤 객체의 값을 변경할 수 있는 부분이다.

 

따라서, 밑에 코드의 Test를 보면, 쓰레드 A와 B가 둘다 order에 접근한다. A가 자신의 주문 금액을 조회하면, 원하는 값인 10000원이 아닌 20000원이 출력되게 된다. 매우 잘못된 것이다.

 

 

 

 

 

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

 

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

//        Assertions.assertThat(statefulService1.getPrice()).isSameAs(10000);
    }

    static class TestConfig{

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

 

따라서, 해당 코드처럼 stateless하게 설계를 진행해야 한다.

위처럼 price를 반환해주도록 싱글톤 객체의 메소드를 변경해주면 더 이상 stateful하지 않다.

 

 

 

 

 

 

💻 @configuration

 

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

    @Bean
    public DiscountPolicy discountPolicy(){
        return new FixDiscountPolicy();
    }



}

 

예전에 작성한 AppConfig라는 클래스이다.

그런데 memberService, orderRepository를 보면, 둘다 memberRepository()를 호출하고 있다. 그리고 memberRepository()는 새로운 MemoryMemberRepository()를 호출 하고있다.

 

그렇다면, 각각 호출이 될때마다 새로운 객체를 사용하고 있진 않을까?

그래서 memberService,orderService, memberRepository()를 순서대로 호출해보겠다.

그리고, 해당 코드들마다 호출을 기록하는 코드를 추가하여 출력하도록 해두었다.

 

만약, 새로운 객체를 사용하고 있으면,

memberService가 호출되고 그로 인해 memberRepository()를 호출 해서 새로운 MemoryMemberRepository를 생성할 것이다.

그리고, orderService가 호출되고 그로 인해 memberRepository()를 호출 해서 새로운 MemoryMemberRepository를 생성할 것이다.

마지막으로 memberRepository()를 호출해서 MemoryMemberRepository는 3개가 생성될 것으로 예상된다.

그리고 memberService와 orderService는 각각 다른 MemoryMemberRepository를 갖고 있어야 한다.

 

 

 

 

 

 

 

 

하지만, 그렇지 않다.

각각 메소드들은 호출이 한번씩만 되었고 memberService와 orderService의 MemoryMemberRepository는 동일한 주소를 가리키고 있다.

이는, 해당 스프링이 싱글톤을 유지시켜주고 있다는 말이다.

위처럼 결과가 나오는 것은 @Configuration이라는 어노테이션을 쓰고 있기 때문이다.

@Configuration을 쓰면, 스프링은 해당 클래스에 존재하는 빈들을 자동적으로 싱글톤 객체로 관리를 해준다.

 

그리고 해당 객체들은 그 자체로 저장이 되는 것이 아니라, 해당 클래스를 상속하는 임의의 클래스를 만들고, 그 클래스를 스프링 빈으로 저장한다. 해당 클래스는 이름에 xxxCGLIB라는 것이 붙어 저장되어 진다.

 

아마, 해당 빈이 모든 메소드마다 해당 객체가 이미 스프링 컨테이너에 등록되어 있으면 해당 객체를 return 해주고, 없으면 새로 생성하는 코드가 추가 되어진 것일 것이다.

 

이처럼 @Configuration은 무조건적인 것이고, 스프링 설정 정보에는 항상 해당 어노테이션을 사용하자!