<Spring> 스프링 핵심원리 이해 5 - 싱글톤 컨테이너
웹 어플리케이션과 싱글톤의 관계
- 통상적으로 서비스를 운영하다보면 위 그림과 같이 동일한 요청이 서로 다른 클라이언트로부터 동시에 들어올 수 있다.
- 요청이 들어오면 객체를 만들어서 메모리를 사용하게 되는데, 만약 동일한 요청들을전부 상이한 메모리 공간에 할당시켜 각각 응답해주게되면 메모리 공간이 남아나질 않을 것이다.
- 아래 코드는 싱글톤 패턴을 적용하지 않은 DI컨테이너인데, 동일한 요청에 대해서 각각 서로 다른 객체들을 만들어서 응답해주고 있다.
- 그래서 이에 대한 해결방안으로 웹 어플리케이션을 구현할 때, 동일한 요청들에 대해서는 싱글톤 패턴을 적용시킨다.
- test의 singleton패키지 생성후 SingletonTest 클래스를 생성한다.
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);
//memberService1 != memberService2
assertThat(memberService1).isNotSameAs(memberService2);
}
}
싱글톤 패턴
- 클래스의 인스턴스가 딱 1개만 생성되는 것을 보장하는 디자인 패턴이다.
- 해당되는 인스턴스에 대해서 static을 통해 최초 1번만 메모리를 할당시키며 이후 해당 인스턴스에 대한 호출이 생길 때 마다 최초로 생긴 인스턴스를 사용한다.
- 그래서 private를 사용해 객체 인스턴스를 2개 이상 생성하지 못하도록 막는다.
- 싱글톤 패턴 적용 예제를 위해 SingletonService 클래스를 생성한다.
package hello.core.singleton;
public class SingletonService {
//static 객체라서 클래스레벨로 딱 한개만 생성된다.
//Lazy-init 방식은 아니다 => static 으로 바로 생성해버림
private static final SingletonService instance = new SingletonService();
public static SingletonService getInstance() {
return instance;
}
//Using Private Constructor to prevent making new instance
private SingletonService() {}
public void logic() {
System.out.println("singleton logic");
}
}
- static 영역에 객체 instance를 미리 하나 생성해서 올려둔다.
- 해당 객체 인스턴스가 필요하면 오직 getInstance()메서드를 통해서만 접근 할 수 있다.해당 메서드를 호출하면 항상 같은 인스턴스를 반환한다.
- 생성자를 private로 막아서 instance가 여러개 생성되는것을 막는다.
- 위와 같은 과정으로 싱글톤 패턴을 적용 시킬 수 있다.
- SingletonTest 클래스 내에 singletonServiceTest() 메서드를 생성해서 해당 싱글톤 패턴을 사용하는 테스트 코드를 작성해보자.
@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();
}
- 해당 테스트 코드를 실행해보면 아래와 같이 호출할때 마다 동일한 인스턴스가 반환되는 것을 볼 수 있다.
싱글톤 패턴의 단점
- 싱글톤 패턴을 사용하는 다른 객체들간의 결합도(의존성)이 높아지기 때문에 객체 지향 설계 원칙에 위배된다.즉, 클라이언트가 구체 클래스에 의존한다.
- 내부 설계를 변경하거나 초기화하기가 어렵다.
- private 생성자를 사용하기 때문에 자식 클래스를 만들기 어렵다.
- 결론적으로 유연하지 않다라는 큰 단점이 존재한다.
싱글톤 컨테이너(스프링 컨테이너)
- 스프링 컨테이너는 싱글톤 패턴을 적용하지 않아도, 객체 인스턴스를 싱글톤으로 관리한다.
- 그래서 스프링 컨테이너는 싱글톤 패턴의 문제점을 해결하면서, 객체 인스턴스를 싱글톤(1개만 생성)으로 관리한다.
- 지금까지 써 왔던 스프링 빈이 바로 싱글톤 패턴으로 관리되는 빈이다.
- 스프링 컨테이너를 사용해서 테스트 코드를 작성해보자.
@Test
@DisplayName("스프링 컨테이너와 싱글톤")
void springContainer() {
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
assertThat(memberService1).isSameAs(memberService2);
}
- 위의 테스트 코드도 다른 클라이언트의 요청에 동일한 static 객체를 반환한다.
싱글톤 방식의 주의점
- 싱글톤 패턴 또는 싱글톤 컨테이너든 객체 인스턴스를 하나만 생성해서 사용하는 방식의 경우 여러 클라이언트가 하나의 객체를 공유하기 때문에 해당 객체를 상태를 유지시키는 구조로 설계해서는 안된다.
- 위에서 언급한 싱글톤 패턴의 단점과도 일맥상통하는 부분이며, 조금 더 풀어서 설명하면 특정 클라이언트가 특정값을 변경할 수 있게 하며 안된다는 뜻이다.
- 해당 문제점을 가지는 예시코드를 작성해보자.
- StatefulService 클래스를 먼저 생성한다.
package hello.core.singleton;
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;
}
}
- 이후 해당 클래스의 테스트 클래스 작성한다.
package hello.core.singleton;
class StatefulServiceTest {
@Test
void statefulServiceSingleton() {
ApplicationContext ac = new AnnotationConfigApplicationContext(TestConfig.class);
StatefulService statefulService1 = ac.getBean(StatefulService.class);
StatefulService statefulService2 = ac.getBean(StatefulService.class);
/*
* A가 주문을하고 주문금액 조회하는 사이에
* B가 주문을 해버린 상황
* */
//Thread A : A 사용자가 10000원 주문
statefulService1.order("userA",10000);
//Thread B : B 사용자가 10000원 주문
statefulService2.order("userB",20000);
//Thread A : A 사용자가 주문금액조회
int price = statefulService1.getPrice();
System.out.println("A의 price = " + price);
Assertions.assertThat(statefulService1.getPrice()).isEqualTo(statefulService2.getPrice());
}
static class TestConfig {
@Bean
public StatefulService statefulService() {
return new StatefulService();
}
}
}
- 위 테스트의 결과는 아래처럼 A사용자의 주문금액(10000원)이 B사용자의 주문금액으로 할당되어지는 오류를 보여준다.
- 즉 이러한 구조가 클라이언트가 특정값을 변경할수 있게 하는구조, stateful한 구조라 할 수 있다.
- 그렇기에 공유필드는 항상 조심해서 로직을 처리해야 하며, 스프링 빈은 항상 무상태로 설계해야한다
@Configuration과 싱글톤
@Configuration
의 역할을 알아보기 위해, AppConfig 코드를 보자
@Configuration
public class AppConfig {
@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 MemoryMemberRepository memberRepository() {
System.out.println("call AppConfig.memberRepository");
return new MemoryMemberRepository();
}
@Bean
public DiscountPolicy discountPolicy() {
return new RateDiscountPolicy();
}
}
- memberService 빈을 만드는 메서드를 호출하면 memberRepository()메서드를 호출한다.
- 위와 마찬가지로 orderService 빈을 만드는 메서드를 호출하면 동일하게 memberRepository()를 호출한다.
- memberRepository()는 new MemoryMemberRepository()를 호출한다.
- 결과적으로 서로 다른 new MemoryMemberRepository()가 생성되면서 싱글톤이 깨지는 것처럼 보인다.
- 하지만 당연히 스프링 컨테이너는 해당 인스턴스를 싱글톤 방식으로 처리한다.
- 이러한 과정이 어떻게 일어나는지 이제부터 테스트하면서 검증해보자.
- 먼저 MemberServiceImpl,OrderServiceImpl에 테스트 용도의 메서드를 추가하자.
//테스트 용도
public MemberRepository getMemberRepository() {
return memberRepository;
}
- 이후 ConfigurationSingletonTest 생성후 configurationTest()메서드를 생성한다.
public class ConfigurationSingletonTest {
//모든 인스턴스가 5번이 아니라 3번이 호출된다.
@Test
void configurationTest() {
AnnotationConfigApplicationContext 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 MemoryMemberRepository()를 호출한 각각의 memberService,orderService뿐만 아니라 확인용 memberRepository 또한 같은 인스턴스를 사용하고 있다.
- 즉, 우려한 바와 달리 스프링 컨테이너에서 자동으로 싱글톤 패턴을 유지하면서 객체를 활용시키고 있다.
스프링 컨테이너의 빈 호출 횟수 알아보기
- 예상되는 호출 횟수는 memberService(1회),orderService(1회), memberRepository(3회)로 총 5회이다.
- 호출 횟수 확인을 위해 아래 코드를 AppConfig 클래스의 메서드에 추가한다.
System.out.println("call AppConfig.해당 인스턴스");
- 아래와 같이 모든 빈들이 한번만 호출된다는 결과를 볼 수 있다. 중복되어 호출될줄 알았던 memberRepository는 한번의 호출만 진행되며, 해당 인스턴스를 공유하며 로직을 처리하고 있다.
@Configuration과 바이트 코드
- 위에서 진행한 코드를 순수 자바 코드로만 분석하면, 분명히 5번 호출 되는게 맞다, 하지만 중복되는 호출을 줄이기 위해 AppConfig의 @Configuration이 바이트 코드를 조작해서 이 문제를 해결한다.
- 스프링은 바이트 코드 조작을 위한 CGLIB이라는 라이브러리를 사용하며,이는 AppConfig를 상속받은 임의의 다른 클래스를 만들고 해당 클래스를 스프링 빈으로 등록한다.
- 위 과정을 보기 위해 아래의 테스트 코드를 ConfigurationSingletonTest에 추가하자.
@Test
void configurationDeep() {
AnnotationConfigApplicationContext ac = new AnnotationConfigApplicationContext(AppConfig.class);
AppConfig bean = ac.getBean(AppConfig.class);
System.out.println("bean = " + bean.getClass());
}
- 위의 테스트 코드는 아래와 같은 결과를 출력하는데, AppConfig가 순수한
class hello.core.AppConfig
로만 이루어지지 않는다는것 볼 수 있다. - 언급한 CGLIB에 의해 새롭게 정의된 클래스를 스프링 빈의 AppConfig로 올려준다.
- 일련의 과정을 진행해주는 어노테이션이 @Configuration 이다.
소스코드 : https://github.com/brido4125/core-spring
해당 글은 김영한님의 <스프링 핵심> 강좌를 기반으로 작성되었습니다.