싱글톤 컨테이너
웹 애플리케이션과 싱글톤
스프링은 기업용 온라인 서비스를 지원하기 위해 탄생했으며, 대다수의 스프링 애플리케이션은 웹 기반이다. 웹 애플리케이션의 특성상, 여러 사용자가 동시에 서비스에 접근하게 된다.
스프링 없는 순수한 DI컨테이너 테스트
package hello.core.Singleton;
import hello.core.AppConfig;
import hello.core.member.MemberService;
import org.assertj.core.api.Assertions;
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);
//memberService1 != memberService2
Assertions.assertThat(memberService1).isNotSameAs(memberService2);
}
}
memberService1 = hello.core.member.MemberServiceImpl@2357d90a
memberService2 = hello.core.member.MemberServiceImpl@6328d34a
초기에 개발했던 순수한 DI컨테이너인 AppConfig 는 사용자의 요청이 있을때마다 새로운 객체를 생성하는 구조로 고객의 트래픽이 초당 100건이 발생한다면, 초당 100개의 객체가 생성되었다가 소멸하는 상황이 반복된다. 이는 메모리의 낭비와 성능 저하로 이루어질 수 있다.
이러한 문제점을 해결방안으로 객체를 한번만 생성하고, 이를 모든 사용자에게 공유하는 방식 "싱글톤 패턴" 도입하는 것이다.
싱글톤 패턴
싱글톤 패턴은 소프트웨어 설계 디자인패턴 중 하나로, 특정 클래스의 인스턴스가 프로그램 내에서 단 하나만 존재하도록 보장하는 패턴이다.
싱글톤 패턴 핵심원리
- 미리 생성된 객체의 활용 : static 영역에 객체를 미리 하나만 생성해둔다.
- 유일한 접근 방법 : 이 객체는 오직 getInstance() 메서드를 통해서만 접근이 가능하다.
- 객체 생성 제한 : 생성자에 private 접근 제한자를 사용하여 외부에서의 객체 생성을 막는다.
package hello.core.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("싱글톤 객체 로직 호출");
}
}
package hello.core.Singleton;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
public class 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);
}
}
싱글톤 패턴의 문제점
싱글톤 패턴은 객체의 재사용성을 보장하는 장점이 있지만, 여러 문제점도 동반된다.
- 코드 복잡성 증가 : 싱글톤 패턴을 구현하기 위한 추가 코드가 필요하다.
- DIP 위반 : 클라이언트가 구체 클래스에 의존하게 되므로, 의존성 역전 원칙(DIP)를 위반한다.
- OCP 위반 : 클라이언트가 구체 클래스에 의존하므로 개방-폐쇄(OCP)위반할 가능성이 높다.
- 테스트 어려움 : 싱글톤 객체는 테스트하기 어렵다.
- 유연성 부족 : 내부 속성 변경이나 초기화, 상속등이 어렵다.
싱글톤 컨테이너
스프링 컨테이너는 싱글톤 패턴의 문제점들을 해결하면서, 객체 인스턴스를 싱글톤으로 관리한다.
스프링 컨테이너는 기본적으로 빈(Bean)을 싱글톤으로 관리한다. 따라서 별도의 싱글톤 패턴 코드를 작성할 필요 없이, 스프링을 통해 자동으로 객체의 싱글톤 관리가 이루어진다.
스프링 컨테이너의 이러한 기능 덕분에 싱글톤 패턴의 모든 단점들이 해결하면서 객체를 싱글톤으로 유지할 수 있다.
- 코드 단순화 : 싱글톤 패턴 관련 코드를 별도로 작성하지 않아도 된다.
- 설계 원칙 적용 : DIP, OCP와 같은 설계 원을 쉽게 지킬 수 있다. 또한, 테스트하기 용이하고 private 생성자의 제약에서 자유롭다.
스프링 컨테이너 사용 테스트 코드
@Test
@DisplayName("싱글톤 컨테이너와 싱글톤")
void SingleContainer() {
//1. 조회 : 호출할 때 마다 객체를 생성
ApplicationContext ac = new AnnotationConfigApplicationContext(AppConfig.class);
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);
}
싱글톤 컨테이너 적용 후
스프링 컨테이너의 싱글톤 관리 덕분에 요청이 올 때 마다 새로운 객체를 생성하는 것이 아니라, 이미 만들어진 객체를 효율적으로 재사용할 수 있다.
스프링은 기본적으로 빈을 싱글톤으로 관리하지만, 이외에도 요청할 때 마다 새로운 객체를 생성해서 다양한 빈 스코프를 제공하여 개발자의 요구에 맞게 유연하게 객체의 생명주기를 관리할 수 있게 도와준다.
싱글톤 방식의 주의점
싱글톤 패턴이든, 스프링 같은 싱글톤 컨테이너를 사용하든, 객체 인스턴스를 하나만 생성해서 공유하는 싱글톤 방식은 여러 클라이언트가 하나의 같은 객체 인스턴스를 공유하기 때문에, 싱글톤 객체는 상태를 유지(stateful)하게 설계하면 안된다.
무상태(stateless)로 설계해야한다.
- 특정 클라이언트에 의존적인 필드가 있으면 안된다.
- 특정 클라이언트가 값을 변경할 수 있는 필드가 있으면 안된다.
- 필드 대신, 자바에서 공유되지 않는, 지역변수, 파라미터, ThreadLocal 등을 사용해야 한다.
스프링 빈의 필드에 공유 값을 설정하면 정말 큰 장애 이슈가 발생한다.
상태를 유지할 경우 발생하는 문제점
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;
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);
//ThreadA : A사용자 10000원 주문
statefulService1.order("userA", 10000);
//ThreadB : B사용자 20000원 주문
statefulService2.order("userB", 20000);
int price = statefulService1.getPrice();
System.out.println("price = " + price);
Assertions.assertThat(statefulService1).isSameAs(statefulService2);
}
static class TestConfig {
@Bean
public StatefulService statefulService(){
return new StatefulService();
}
}
}
name = userA price = 10000
name = userB price = 20000
price = 20000
order 메서드는 주문을 받을 때마다 price 필드에 주문 금액을 저장한다. 그러나, 설계된 서비스는 여러 클라이언트에 의해 동시에 접근될 경우, 마지막에 저장된 주문 금액으로 모든 클라이언트의 주문 금액이 덮어쓰여지게 된다.
싱글톤 방식의 객체는 여러 클라이언트에 의해 공유되기 때문에, 상태 정보를 저장하는 설계는 큰 문제를 초래할 수 있다.
스프링 빈은 항상 무상태(stateless)방식으로 설계 해야한다.
무상태(stateless) 방식 코드변경
package hello.core.Singleton;
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;
// }
}
package hello.core.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);
//ThreadA : A사용자 10000원 주문
int userAPrice = statefulService1.order("userA", 10000);
//ThreadB : B사용자 20000원 주문
int userBPrice = statefulService2.order("userB", 20000);
// int price = statefulService1.getPrice();
System.out.println("price = " + userAPrice);
// Assertions.assertThat(statefulService1).isSameAs(statefulService2);
}
static class TestConfig {
@Bean
public StatefulService statefulService(){
return new StatefulService();
}
}
}
name = userA price = 10000
name = userB price = 20000
price = 10000
@Configuration, 싱글톤
@Configuration 설정 클래스에서 동일한 빈 메서드를 여러 번 호출하면 어떻게 될까?
@Configuration 빈의 생성
package hello.core;
import hello.core.discount.DiscountPolicy;
import hello.core.discount.FixDiscountPolicy;
import hello.core.member.MemberService;
import hello.core.member.MemberServiceImpl;
import hello.core.member.MemoryMemberRepository;
import hello.core.order.OrderService;
import hello.core.order.OrderServiceImpl;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class AppConfig {
//@Bean memberService -> new MemoryMemberRepository
//@Bean orderService -> new MemoryMemberRepository
@Bean
public MemberService memberService() {
return new MemberServiceImpl(memberRepository());
}
@Bean
public MemoryMemberRepository memberRepository() {
return new MemoryMemberRepository();
}
@Bean
public OrderService orderService(){
return new OrderServiceImpl(memberRepository(), discountPolicy());
}
@Bean
public DiscountPolicy discountPolicy(){
return new FixDiscountPolicy();
}
}
memberRepository 메서드를 호출할 때마다 새로운 MemoryMemberRepository 인스턴스가 생성될 것으로 보이지만, 싱글톤은 깨지지 않는다.
검증 테스트코드
package hello.core.Singleton;
import hello.core.AppConfig;
import hello.core.OrderApp;
import hello.core.member.MemberRepository;
import hello.core.member.MemberService;
import hello.core.member.MemberServiceImpl;
import hello.core.order.OrderService;
import hello.core.order.OrderServiceImpl;
import org.assertj.core.api.Assertions;
import org.junit.jupiter.api.Test;
import org.springframework.context.ApplicationContext;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
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("orderService -> memberRepository2 = " + memberRepository2);
System.out.println("memberRepository = " + memberRepository);
assertThat(memberService.getMemberRepository()).isSameAs(memberRepository);
assertThat(orderService.getMemberRepository()).isSameAs(memberRepository);
}
}
memberService -> memberRepository1 = hello.core.member.MemoryMemberRepository@21ba0741
orderService -> memberRepository2 = hello.core.member.MemoryMemberRepository@21ba0741
memberRepository = hello.core.member.MemoryMemberRepository@21ba0741
테스트 결과, 모든 "memberRepository" 참조는 동일한 인스턴스를 가리킨다.
스프링 컨테이너는 "@Configuration" 클래스를 특별하게 처리하여, 메서드가 호출될 때마다 새로운 객체를 생성하는 것이 아니라, 처음 생성된 객체를 반환하도록 한다.
@Configuration과 바이트 코드
스프링 컨테이너는 싱글톤 레지스트리다. 따라서 스프링 빈이 싱글톤이 되도록 보장해주어야 한다. 그런데 스프링이 자바 코드까지 컨트롤하기는 어렵다.
위 예제 자바 코드를 보면 인스턴스 생성이 3번 호출되어야 하지만, 스프링은 클래스의 바이트 코드를 조작하는 라이브러리를 사용한다.
@Test
void configurationDeep(){
ApplicationContext ac = new AnnotationConfigApplicationContext(AppConfig.class);
AppConfig bean = ac.getBean(AppConfig.class);
System.out.println("bean.getClass() = " + bean.getClass());
}
bean.getClass() = class hello.core.AppConfig$$SpringCGLIB$$0
AnnotationConfigApplicationContext 에 파라미터로 넘긴 값은 스프링 빈으로 등록된다. 그래서 "AppConfig도 스프링 빈이 된다.
순수한 클래스라면 다음과 같이 출력되어야 하지만
class hello.core.AppConfig
클래스 명에 CGLIB가 붙으면서 상당히 복잡해진 코드가 출력된다. 이것은 스프링이 CGLIB라는 바이트 코드 조작 라이브러리를 사용해서 AppConfig 클래스를 상속받은 임의의 다른 클래스를 만들고, 그 다른 클래스를 스프링 빈으로 등록한 것이다.
스프링은 "@Configuration" 어노테이션이 붙은 설정 클래스의 빈 생성 메서드를 여러번 호출해도 항상 동일한 인스턴스를 반환하게 하기위해서 이러한 기술을 사용한다. 이렇게 함으로써 스프링은 싱글톤 빈의 원칙을 유지하면서도, 설정 클래스 내에서의 빈 메서드 호출을 최적화할 수 있다.
AppConfig@CGLIB 예상 코드
@Bean
public MemberRepository memberRepository() {
if (memoryMemberRepository가 이미 스프링 컨테이너에 등록되어 있으면?) {
return 스프링 컨테이너에서 찾아서 반환;
} else { //스프링 컨테이너에 없으면
기존 로직을 호출해서 MemoryMemberRepository를 생성하고 스프링 컨테이너에 등록
return 반환
}
}
출처 : 인프런 - 🔗 스프링 핵심원리 - 기본편by 우아한형제 김영한님