Spring 스터디2 - 6
웹 애플리케이션과 싱글톤 패턴
스프링은 웹 애플리케이션 외에도 다양한 종류의 애플리케이션을 개발할 수 있으나, 대부분은 웹 애플리케이션이다. 웹 애플리케이션은 보통 여러 고객이 동시에 요청을 한다. 만약 고객1~3이 동시에 요청을 하게 되면 3개의 인스턴스를 생성하여 응답할 것이라고 생각할 것이다. 해당 예를 스프링 없는 DI 컨테이너로 테스트할 수 있도록 테스트 코드를 작성한다.
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);
}
ApplicationContext 없이 그냥 AppConfig로 작성하여, 1번과 2번 요청에 서로 다른 객체를 생성하여 응답한다. 이게 만약 실제 서비스였을 경우 100명이 요청하면 100개의 객체가 생성되며 메모리 낭비가 심각해지는데, 이 문제의 해결방안은 객체가 단 하나만 생성되고 공유하도록 설계한 싱글톤 패턴을 사용하면 된다.
싱글톤 패턴
싱글톤 패턴(Singleton Pattern)은 객체를 단 하나만 생성하고 공유하도록 설계한 디자인 패턴이다.
SingletonService
package spring.basic.singleton;
public class SingletonService {
private static final SingletonService instance = new SingletonService();
public static SingletonService getInstance() {
return instance;
}
private SingletonService() {
}
public void logic() {
System.out.println("싱글톤 객체 로직 호출");
}
}
싱글톤 패턴의 구현 방식은 클래스 내부에 유일한 인스턴스를 static으로 미리 생성해놓고, 생성자를 private로 설정해 외부에서 new로 인스턴스를 만들지 못하게 한다. 그리고 예시와 같이 getInstance()와 같은 메소드로만 인스턴스를 반환하도록 한다.
SingletonTest
@Test
@DisplayName("싱글톤 패턴을 적용한 객체 사용")
void singletonServiceTest() {
SingletonService singletonService1 = SingletonService.getInstance();
SingletonService singletonService2 = SingletonService.getInstance();
System.out.println("singletonService1 = " + singletonService1);
System.out.println("singletonService2 = " + singletonService2);
assertThat(singletonService1).isSameAs(singletonService2);
}
이번 테스트에서는 같은 인스턴스를 반환한다. 이와 같이 싱글톤 패턴을 적용하면 요청 마다 객체를 생성하는 것이 아니라, 이미 만들어진 객체를 공유해서 메모리를 효율적으로 사용할 수 있다는 장점이 있다. 하지만 단점도 존재하는데 싱글톤 패턴을 위한 코드가 많아지며(위 SingletonService 예시처럼), 클라이언트가 구현 클래스에 의존해 DIP와 OCP를 위반할 수 있고, 테스트가 어렵고 유연성이 떨어지며, 내부 상태를 변경하기 어렵다는 점이 있다.
(장점보다 단점이 많아보인다..)
싱글톤 컨테이너
스프링 프레임워크는 싱글톤 컨테이너라는 개념을 통해 싱글톤 패턴을 직접 구현하지 않아도 객체를 싱글톤으로 관리한다. 스프링 컨테이너(ApplicationContext)는 빈 객체를 기본적으로 싱글톤 스코프(다른 스코프도 존재)로 생성해 관리한다. @Configuration 어노테이션이 붙은 설정 클래스의 @Bean 메소드로 등록된 객체를 컨테이너가 단 하나만 생성해서 공유한다는 의미이며, 이 기능을 싱글톤 레지스트리라고 한다.
SingletonTest
@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);
assertThat(memberService1).isSameAs(memberService2);
}
싱글톤 패턴 테스트 코드와 다르게 싱글톤 컨테이너는 싱글톤 패턴을 위한 복잡한 코드가 필요 없다. 또한, 싱글톤 패턴의 단점을 대부분 극복한다. DIP와 OCP를 준수할 수 있고, 테스트가 용이하고, private 생성자에 얽매이지 않고, 유연성이 높아 다양한 스코프를 지원한다.
싱글톤의 주의점
위에서 살펴본 싱글톤 패턴이나 싱글톤 컨테이너나 여러 클라이언트가 하나의 인스턴스를 공유하기 때문에 싱글톤 객체는 상태를 Stateful하게 설계하면 안 되고, Stateless하게 설계해야 한다. 이는 특정 클라이언트에 의존적인 필드가 없어야하며, 내부 필드 대신 지역변수, 파라미터, ThreadLocal 등을 활용해야 한다.
StatefulService
package spring.basic.singleton;
public class StatefulService {
private int price; //Stateful
public void order(String name, int price) {
System.out.println("name = " + name + " price = " + price);
this.price = price; //문제 발생 지점
}
public int getPrice() {
return price;
}
}
StatefulServiceTest
package spring.basic.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;
import static org.junit.jupiter.api.Assertions.*;
class StatefulServiceTest {
@Test
void statefulServiceSingleton() {
ApplicationContext ac = new AnnotationConfigApplicationContext(TestConfig.class);
StatefulService statefulService1 = ac.getBean(StatefulService.class);
StatefulService statefulService2 = ac.getBean(StatefulService.class);
//userA 10000원 주문
statefulService1.order("userA", 10000);
//userB 20000원 주문
statefulService2.order("userB", 20000);
//userA 주믄 금액 조회
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();
}
}
}
Stateful하게 설계했을 때 발생하는 문제점을 확인할 수 있는 테스트이다. A가 10000원짜리 주문을 하고 주문금액을 조회하는 사이에, B가 20000원짜리 주문을 하면 어떤 결과가 나올까? 애플리케이션에 문제가 없다면 10000원이 나와야하지만, 싱글톤을 Stateful하게 설계하는 바람에 20000원이 나오게 된다. 실무에서도 종종 발생하는 문제라고 하는데 어마어마한 피해가 나올 수 있으니 정말 조심해야한다. 항상 Stateless로 설계할 수 있도록하자.
@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 MemberRepository memberRepository() {
System.out.println("call AppConfig.memberRepository");
return new MemoryMemberRepository();
}
기존에 작성한 AppConfig만 보면 memberService 빈을 생성할 때 memberRepository() 메소드를 호출하고, new MemoryMemberRepository()를 호출한다. 또 orderService 빈을 생성할 때도 memberRepository() 메소드를 호출하고, new MemoryMemberRepository()를 호출한다. 이렇게 보면 2개의 MemoryMemberRepository가 생성되면서 싱글톤이 깨지는거 같다.
ConfigurationSingletonTest
package spring.basic.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 spring.basic.AppConfig;
import spring.basic.member.MemberRepository;
import spring.basic.member.MemberServiceImpl;
import spring.basic.order.OrderServiceImpl;
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 -> memberRepository1 = " + memberRepository1);
System.out.println("memberService -> memberRepository2 = " + memberRepository2);
System.out.println("memberRepository = " + memberRepository);
assertThat(memberService.getMemberRepository()).isSameAs(memberRepository);
assertThat(orderService.getMemberRepository()).isSameAs(memberRepository);
}
@Test
void configurationDeep() {
ApplicationContext ac = new AnnotationConfigApplicationContext(AppConfig.class);
AppConfig bean = ac.getBean(AppConfig.class);
System.out.println("bean = " + bean.getClass());
}
}
MemberServiceImpl과 OrderServieImpl에 테스트 코드를 추가하고, ConfigurationSingletonTest 테스트를 작성한다. 위에서 예상한 바와 다르게 memberRepository 인스턴스는 모두 같은 인스턴스가 공유되어 사용되고 있음을 확인할 수 있다.
이는 @Configuration 어노테이션을 달고 있는 AppConfig 클래스의 영향이다. 스프링 컨테이너를 생성하기 위한 AnnotationConfigurationApplicationContext에 파라미터로 넘긴 값은 빈으로 등록되기 때문에 AppConfig도 빈이다. 빈의 클래스 정보를 출력하게 되면 spring.basic.AppConfig
가 아니라 AppConfig$$SpringCGLIB$$0
같이 CGLIB을 포함하여 나온다.
AppConfig 빈의 클래스 정보에 CGLIB이 포함되는 이유는 스프링이 @Configuration 어노테이션을 달고 있는 AppConfig 클래스를 스프링이 직접 인스턴스를 생성하지 않고, CGLIB을 사용해 해당 클래스를 상속한 프록시 클래스를 만들어 빈으로 등록하기 때문이다.
그리고 위에서 memberRepository가 여러번 호출되지 않는 이유는 이 프록시 클래스가 내부적으로 AppConfig의 메소드(@Bean이 붙은 메소드) 호출을 가로채어, 싱글톤 빈을 보장하고, 컨테이너에서 이미 생성된 빈이 있으면 그것을 반환하도록 동작하기 때문이다.