mojo's Blog
싱글톤 컨테이너 본문
시작하기에 앞서 ...
org.springframework.beans.factory.BeanDefinitionStoreException: Unexpected exception parsing XML document from class path resource [appConfig.xml]; nested exception is java.lang.IllegalArgumentException: protocol = http host = null
와 같은 에러를 발견하게 될 경우
<beans xmlns="http://www.springframework.org/schema/beans"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.springframework.org/schema/beans http://
www.springframework.org/schema/beans/spring-beans.xsd">
와 같이 http:// 다음에 라인개행이 있으면 안된다.
아래와 같이 라인개행이 없어야 한다.
<beans xmlns="http://www.springframework.org/schema/beans"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd">
<bean id="memberService" class="hello.core.member.MemberServiceImpl">
<constructor-arg name="memberRepository" ref="memberRepository"/>
</bean>
<bean id="memberRepository" class="hello.core.member.MemoryMemberRepository"/>
<bean id="orderService" class="hello.core.order.OrderServiceImpl">
<constructor-arg name="memberRepository" ref="memberRepository"/>
<constructor-arg name="discountPolicy" ref="discountPolicy"/>
</bean>
<bean id="discountPolicy" class="hello.core.discount.RateDiscountPolicy"/>
</beans>
웹 애플리케이션과 싱글톤
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;
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);
}
}
위 코드에 대한 실행 결과는 다음과 같다.
두 객체가 JVM 에서 서로 다르게 생성되는 것을 알 수 있다.
좀 더 확실한 검증을 위해 다음 코드를 아래에 추가하여 실행해보면 다음과 같다.
// memberService1 != memberService2
Assertions.assertThat(memberService1).isNotSameAs(memberService2);
즉, 고객이 요청할 때 마다 새로운 객체를 생성한다는 것을 알 수 있다.
- 스프링 없는 순수한 DI 컨테이너인 AppConfig는 요청할 때 마다 객체를 새로 생성한다.
- 고객 트래픽이 초당 100이 나오면 초당 100개 객체가 생성되고 소멸된다. => 메모리 낭비가 심함
- 해결방안은 해당 객체가 딱 1개만 생성되고, 공유하도록 설계하면 된다. => 싱글톤 패턴
싱글톤 패턴
싱글톤 패턴이란?
클래스의 인스턴스 즉, 객체가 딱 1개만 생성되는 것을 보장하는 디자인 패턴이다.
그래서 객체 인스턴스를 2개 이상 생성하지 못하도록 막아야 한다.
이때, private 생성자를 사용해서 외부에서 임의로 new 키워드를 사용하지 못하도록 막아야 한다.
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 영역에 객체를 딱 1개만 생성하는 부분을 보면, private static final 으로 instance 객체를 생성하게 된다면 SingletonService 클래스를 대표하는 객체가 형성된다.
- 그리고 public static 으로 getInstance() 메서드를 만듬으로써 해당 메서드를 통해 객체 조회가 가능하다.
- private 으로 생성자를 선언하게 된다면, 외부에서 new 를 못하며 오직 현재 클래스인 SingletonService 내에서만 객체 생성이 가능하게 된다. (중요!)
아래 코드와 같이 SingletonService 의 객체를 만들어낸다고 할 때, 정상적으로 수행되는지에 대한 확인이 필요하다.
@Test
@DisplayName("싱글톤 패턴을 적용한 객체 사용")
void singletonServiceTest() {
new SingletonService();
}
오류가 나는것으로 보아 의도한대로 객체가 생성되지 않음을 확인할 수 있다.
이번엔 직접 SingletonService 객체를 가져오기 위해 SingletonService 의 getInstance() 메서드를 사용해보도록 한다.
@Test
@DisplayName("싱글톤 패턴을 적용한 객체 사용")
void singletonServiceTest() {
SingletonService singletonService1 = SingletonService.getInstance();
SingletonService singletonService2 = SingletonService.getInstance();
// 참조값이 같은 것을 확인할 수 있다.
System.out.println("singletonService1 = " + singletonService1);
System.out.println("singletonService2 = " + singletonService2);
}
이전에는 객체를 생성할 때 마다 JVM 에서 서로 다른 객체가 생성이 되었지만, Singleton 패턴을 적용함으로써 동일한 객체를 가져오게 된 것을 알 수 있다.
아래와 같은 코드를 추가함으로써 정말로 두 참조값이 같은지를 확인해보고 싶다.
Assertions.assertThat(singletonService1).isSameAs(singletonService2);
서로 같음을 확인하였다. (same(==)과 equal의 차이에 대해 상기해보기)
싱글톤 패턴을 적용하면 고객의 요청이 들어올 때 마다 객체를 생성하는 것이 아니라, 이미 만들어진 객체를 공유해서 효율적으로 사용할 수 있다.
하지만 싱글톤 패턴은 다음과 같은 수 많은 문제점들을 가지고 있다.
※ 싱글톤 패턴 문제점
- 싱글톤 패턴을 구현하는 코드 자체가 많이 들어간다.
- 의존관계상 클라이언트가 구체 클래스에 의존한다. => DIP 위반
- 클라이언트가 구체 클래스에 의존해서 OCP 원칙을 위반할 가능성이 높다.
- 테스트하기 어렵다.
- 내부 속성을 변경하거나 초기화 하기 어렵다.
- private 생성자로 자식 클래스를 만들기 어렵다.
- 결론적으로 유연성이 떨어진다.
- 안티패턴으로 불리기도 한다.
싱글톤 컨테이너
스프링 컨테이너는 싱글톤 패턴의 문제점을 해결하면서 객체 인스턴스를 싱글톤으로 관리한다.
지금까지 학습한 스프링 빈이 결국 싱글톤으로 관리되는 빈이다!
※ 싱글톤 컨테이너
스프링 컨테이너는 싱글톤 패턴을 적용하지 않아도, 객체 인스턴스를 싱글톤으로 관리한다.
- 이전에 컨테이너 생성 과정을 생각해보면, 컨테이너는 객체를 하나만 생성해서 관리한다.
스프링 컨테이너는 싱글톤 컨테이너 역할을 하며 싱글톤 객체를 생성하고 관리하는 기능을 싱글톤 레지스트리라 한다.
스프링 컨테이너의 이런 기능 덕분에 싱글톤 패턴의 모든 단점을 해결하면서 객체를 싱글톤으로 유지할 수 있다.
- 싱글톤 패턴을 위한 지저분한 코드가 들어가지 않아도 된다.
- DIP, OCP, 테스트, private 생성자로부터 자유롭게 싱글톤을 사용할 수 있다.
다음과 같이 스프링 컨테이너를 통해 객체를 생성하여 두 참조값이 동일한지 다른지를 확인해보도록 한다.
@Test
@DisplayName("스프링 컨테이너와 싱글톤")
void springContainer() {
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).isNotSameAs(memberService2);
}
이전에 AppConfig 객체를 통해 MemberService 객체를 만들어냈을 때 두 참조값이 다른것을 위에서 확인했었다.
그런데 신기하게도, 스프링 컨테이너로 MemberService 객체를 만들어냈을 때 두 참조값이 같다.
즉, 싱글톤 패턴이 적용되어 두 객체가 동일하다는 것을 알 수 있다.
스프링 컨테이너 덕분에 고객의 요청이 올 때 마다 객체를 생성하는 것이 아닌, 이미 만들어진 객체를 공유하여 효율적으로 재사용할 수 있다.
스프링의 기본 빈 등록 방식은 싱글톤이지만, 싱글톤 방식만 지원하는 것은 아니다.
요청할 때 마다 새로운 객체를 생성해서 반환하는 기능도 제공하는데 이는 나중에 빈 스코프에서 공부해보도록 하자.
싱글톤 방식의 주의점
싱글톤 패턴이든, 스프링 같은 싱글톤 컨테이너를 사용하든, 객체 인스턴스를 하나만 생성해서 공유하는 싱글톤 방식은 여러 클라이언트가 하나의 같은 객체 인스턴스를 공유하기 때문에 싱글톤 객체는 상태를 유지(stateful)하게 설계하면 안된다.
즉, 무상태(stateless)로 설계해야 한다.
- 특정 클라이언트에 의존적 필드가 있으면 안된다.
- 특정 클라이언트가 값을 변경할 수 있는 필드가 있으면 안된다.
- 가급적 읽기만 가능해야 한다.
- 필드 대신에 자바에서 공유되지 않는 지역변수, 파라미터, ThreadLocal 등을 사용해야 한다.
스프링 빈의 필드에 공유 값을 설정하면 정말 큰 장애가 발생할 수 있다.
[꿀팁] : ctrl + shift + t 단축키를 통해 테스트 코드를 편하게 만들 수 있다.
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;
}
}
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원 주문
statefulService1.order("userA", 10000);
// ThreadB : B 사용자 20000원 주문
statefulService2.order("userB", 20000);
// ThreadA : 사용자A 주문 금액 조회
int price = statefulService1.getPrice();
System.out.println("price = " + price);
Assertions.assertThat(statefulService1.getPrice()).isEqualTo(20000);
}
static class TestConfig {
@Bean
public StatefulService statefulService(){
return new StatefulService();
}
}
}
의도한 값은 price = 10000 이였지만, 스프링 컨테이너는 동일한 객체 즉, 싱글톤 패턴이 적용되었기 때문에 동일한 객체에 정보를 두번 변경한 셈이다.
즉, 마지막으로 변경된 "userB", 20000 이 적용되었기 때문에 price = 20000 이 나타난 것을 확인할 수 있다.
실무에서 이런 경우가 종종 나타난다고 하는데, 이로인해 정말 해결하기 어려운 큰 문제들이 터진다고 한다.
결국, 공유필드는 조심해야 하며 스프링 빈은 항상 무상태(stateless)로 설계해야 한다!
무상태(stateless)로 설계한다는 것은 어떻게 해야하는가?
아래 코드를 보면 알 수 있다.
public class StatefulService {
public int order(String name, int price){
System.out.println("name = " + name + "price = " + price);
return price;
}
}
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 주문 금액 조회
System.out.println("price = " + userAPrice);
}
static class TestConfig {
@Bean
public StatefulService statefulService(){
return new StatefulService();
}
}
}
위와 같이 "userA", 10000 에 대한 order 메서드를 적용함과 동시에 price 값을 반환하는 설계를 stateless 으로 설계한다고 볼 수 있다.
Configuration과 싱글톤
public class MemberServiceImpl implements MemberService {
private final MemberRepository memberRepository;
public MemberServiceImpl(MemberRepository memberRepository){
this.memberRepository = memberRepository;
}
...
// 테스트 용도
public MemberRepository getMemberRepository(){
return memberRepository;
}
}
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;
}
...
// 테스트 용도
public MemberRepository getMemberRepository(){
return memberRepository;
}
}
@Configuration
public class AppConfig {
// @Bean memberService -> new MemoryMemberRepository()
// @Bean orderService -> new MemoryMemberRepository()
@Bean
public MemberService memberService(){
return new MemberServiceImpl(memberRepository());
}
@Bean
public MemberRepository memberRepository(){
return new MemoryMemberRepository();
}
@Bean
public OrderService orderService(){
return new OrderServiceImpl(memberRepository(), discountPolicy());
}
@Bean
public DiscountPolicy discountPolicy(){
return new RateDiscountPolicy();
}
}
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);
}
}
신기하게도 MemberServiceImpl, OrderServiceImpl, MemberRepository 세 객체를 형성한 후,
MemberServiceImpl, OrderServiceImpl 클래스 내에 getMemberRepository() 메서드를 만들어 MemberRepository 객체를 반환하도록 하여 세 객체를 비교해보면 전부 참조값이 같은것을 알 수 있다.
즉, 싱글톤 패턴이 완벽하게 적용된 것을 짐작할 수 있다. (객체가 단 한번만 생성)
의문점 : AppConfig의 자바 코드를 보면 분명히 각각 2번 new MemoryMemberRepository 호출에서 다른 인스턴스가 생성되어야 하는데 왜 그런가?
이러한 의문점을 알아보기 위해 2번 호출이 되는지 안되는지를 AppConfig 코드에 print 를 추가하여 확인해보도록 한다.
@Configuration
public class AppConfig {
// @Bean memberService -> new MemoryMemberRepository()
// @Bean orderService -> new MemoryMemberRepository()
@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(){
System.out.println("call AppConfig.discountPolicy");
return new RateDiscountPolicy();
}
}
놀랍게도 싱글톤 패턴을 적용하여 Bean 에 등록된 메서드가 단 한번만 호출되는 것을 볼 수 있다.
@Configuration과 바이트코드 조작의 마법
스프링 컨테이너는 싱글톤 레지스트리다.
따라서 스프링 빈이 싱글톤이 되도록 보장해줘야 한다.
그런데 스프링이 자바 코드까지 어떻게 하기는 어렵다.
위에서 본 것 처럼 테스트 코드로 작성한 자바 코드를 보면 memberService 객체가 총 3번 만들어져야 하는 것이 맞다.
그래서 스프링은 클래스의 바이트코드를 조작하는 라이브러리를 사용한다.
모든 비밀은 @Configuration 을 적용한 AppConfig 에 있다.
다음 코드를 보도록 한다.
@Test
void configurationDeep(){
ApplicationContext ac = new AnnotationConfigApplicationContext(AppConfig.class);
AppConfig bean = ac.getBean(AppConfig.class);
System.out.println("bean = " + bean.getClass());
}
순수한 클래스라면 다음과 같이 출력이 되어야만 한다.
bean = class hello.core.AppConfig
그런데 예상과 다르게도, 클래스 명에 xxxCGLIB 가 부으면서 상당히 복잡해진 것을 볼 수 있다.
이것은 내가 만든 클래스가 아닌 스프링이 CGLIB 라는 바이트코드 조작 라이브러리를 사용해서 AppConfig 클래스를 상속받은 임의의 다른 클래스를 만들고, 그 다른 클래스를 스프링 빈으로 등록한 것이다.
아래 그림과 같은 형태라고 보면 좋다.
그 임의의 다른 클래스가 바로 싱글톤이 보장되도록 해준다.
다음과 같이 바이트 코드를 조작해서 작성되어 있을 것이다. (CGLIB 내부 기술을 사용하는데 실제로 매우 복잡)
@Bean
public MemberRepository memberRepository() {
if (memoryMemberRepository가 이미 스프링 컨테이너에 등록된 경우) {
return (스프링 컨테이너에서 찾아서 반환);
}
else {
기존 로직을 호출해서 MemoryMemberRepository를 생성하고 스프링 컨테이너에 등록
return (반환)
}
}
- @Bean이 붙은 메서드마다 이미 스프링 빈이 존재하면 존재하는 빈을 반환하고, 스프링 빈이 없으면 생성해서 스프링 빈으로 등록하고 반환하는 코드가 동적으로 만들어진다.
- 덕분에 싱글톤이 보장되는 것이다!
@Configuration 을 적용하지 않고, @Bean 만 적용하면 어떻게 될까 ?
직접 AppConfig 클래스에서 @Configuration 을 주석처리 한 후에 코드를 실행해보도록 한다.
이번엔 정말 의도한대로 순수한 클래스 AppConfig 가 나타난 것을 확인할 수 있다.
그렇다면 결국 싱글톤 패턴이 적용되지 않은것을 알 수 있으며, 위 실행 결과를 자세히 보면 memberRepository 메서드가 여러번 호출이 된 것으로 보아 객체가 여러개 JVM 에서 생성되는 것을 알 수 있다.
※ 정리
- @Bean 만 사용해도 스프링 빈으로 등록이 가능하다. 하지만 싱글톤이 보장되지 않는다.
- memberRepository() 처럼 의존관계 주입이 필요해서 메서드를 직접 호출할 때 싱글톤을 보장하지 않는다.
- 크게 고민할 것 없이, 스프링 설정 정보는 항상 @Configuration 을 사용하도록 하자!
'Spring' 카테고리의 다른 글
의존관계 자동 주입 (0) | 2022.03.07 |
---|---|
컴포넌트 스캔 (0) | 2022.03.03 |
스프링 컨테이너와 스프링 빈 (0) | 2022.01.25 |
AppConfig 리팩터링 및 IoC, DI, 그리고 컨테이너 (0) | 2022.01.24 |
DI(Dependency Injection) (0) | 2022.01.23 |