대다수의 스프링 애플리케이션은 웹 애플리케이션으로 되어 있습니다. 웹 애플리케이션은 보통 다수의 사용자가 동시에 요청을 합니다. 사용자 A가 웹 사이트에 요청을 보낼 때 사용자 B도 웹 사이트에 요청을 보낼 수 있죠.
그런데 이전에 설계한 AppConfig에 따르면 요청이 들어오면 새로운 객체 인스턴스, 즉 스프링 빈이 만들어져야 합니다. 그렇다면 사용자가 요청할 때마다 새롭게 객체 인스턴스를 생성하면 메모리 차지가 심하지 않을까요? 이를 해결하려면 객체 인스턴스가 하나만 생성되도록 하고 요청 시 같은 객체 인스턴스를 사용하도록 해야 합니다.
싱글톤 패턴
싱글톤 패턴은 객체 인스턴스가 하나만 생성되는 것을 보장하는 디자인 패턴입니다. 일단 객체 인스턴스가 외부에서 마구잡이로 생성되는 것을 막기 위해 생성자에 private 접근 지시자를 적용합니다. 그리고 객체 인스턴스를 얻는 전역 메서드를 추가합니다. 다음처럼 말이죠.
public class SingletonService {
// 데이터 영역에 객체를 딱 하나만 생성
private static final SingletonService instance;
// 객체 인스턴스가 필요하면 이 static 메서드를 통해 조회
public static SingletonService getInstance() {
if (this.instance == null) {
this.instance = new SingletonService();
}
return this.instance;
}
// 생성자를 private로 선언해 외부에서 객체 인스턴스를 생성하지 못하게 함
private SingletonService() {}
}
정말 객체 인스턴스가 유일한지 확인해 보겠습니다.
@Test
@DisplayName("싱글톤 패턴을 적용한 객체 사용")
public void singletonServiceTest() {
// 외부 클래스에서 생성자를 호출하면 컴파일 에러가 발생한다
// SingletonService singletonService = new SingletonService();
// 호출할 때마다 같은 객체를 반환
SingletonService singletonServiceA = SingletonService.getInstance();
SingletonService singletonServiceB = SingletonService.getInstance();
// 레퍼런스가 같음을 확인
Assertions.assertThat(singletonServiceA).isSameAs(singletonServiceB);
}
싱글톤 패턴을 사용하는 이유
메모리 낭비와 시간 지연 해소
싱글톤 패턴을 적용하면 하나의 객체 인스턴스만 생성돼 해당 객체는 고정된 메모리 영역을 사용하므로 메모리를 낭비하지 않습니다. 또 이미 생성된 객체 인스턴스를 활용하기에 약간의 시간 지연을 줄일 수 있습니다.
용이한 데이터 공유
객체 인스턴스가 전역으로 사용되므로 다른 객체와 데이터 공유가 쉽습니다.
싱글톤 패턴의 문제점
SOLID 위반
SOLID는 로버트 마틴이 명명한 객체 지향 프로그래밍 및 설계의 다섯 가지 기본 원칙을 말하며 마이클 페더스가 두문자어 기억술로 이를 소개했습니다.
싱글톤 패턴은 비즈니스 로직과 하나의 인스턴스만을 생성하는 로직, 이렇게 두 가지의 책임을 가지고 있어 SOLID에서 S인 "하나의 클래스는 하나의 책임만 가져야 한다"의 단일 책임 원칙(SRP, Single Responsibility Principle)을 위반합니다.
또 클래스에서 객체 인스턴스(구현체)를 생성하다 보니 클래스가 추상화(인터페이스)와 구현체(객체 인스턴스)에 모두 의존해 SOLID에서 D인 "추상화에 의존해야지, 구체화에 의존하면 안 된다"의 의존 관계 역전 원칙(DIP, Dependency Inversion Principle)을 위반합니다.
DIP를 위반하며 구현체가 바뀌면 클래스를 수정해야 하므로 자연스럽게 SOLID에서 O인 "소프트웨어 요소는 확장에는 열려 있으나 변경에는 닫혀 있어야 한다"의 개방-폐쇄 원칙(OCP, Open-Closed Principle)을 위반하게 됩니다.
상속의 어려움
생성자에 private 접근 지시자가 적용돼 다른 클래스에서 접근할 수 없게 됩니다. 그러므로 서브 클래스가 슈퍼 클래스의 생성자를 호출하지 못하는 문제가 생기게 됩니다.
이를 해결하려면 생성자에 protected 접근 지시자를 적용할 수 밖에 없습니다. 많은 애플리케이션에서는 싱글톤 패턴을 적용하는 클래스의 생성자 접근 지시자로 protected를 선택합니다.
다중 스레드 환경 고려
멀티 스레드 환경에서 여러 스레드에서 생성 메서드에 동시에 접근한다면 한 스레드에서 해당 객체 인스턴스가 생성되었음에도 다른 스레드에서 이를 알아차리지 못해 여러 개의 객체 인스턴스가 생성될 수 있습니다.
이를 해결하려면 synchronized 키워드로 생성 메서드를 최초 호출한 스레드가 해당 메서드의 호출을 종료할 때까지 다른 스레드가 접근하지 못하도록 해야 합니다.
그런데 한 가지 문제점이 있습니다. 이미 객체 인스턴스가 생성된 이후에는 객체 인스턴스가 하나이므로 이후 최초 호출 스레드가 다른 스레드의 접근을 막게 되면 불필요한 시간 지연을 겪게 됩니다. 자세한 내용은 향후 서술될 글을 참조하거나 다른 글들을 참조해 주세요.
이렇듯 다중 스레드 환경을 고려하게 되면 소스 코드의 양이 증가하게 됩니다.
테스트의 어려움
객체 인스턴스가 전역적으로 공유되다 보니 각 테스트 케이스마다 객체 인스턴스의 필드 값이 변경되므로 다음 테스트 케이스에 영향을 미칠 수 있습니다.
스프링 컨테이너와 싱글톤
@Configuration
@Configuration
public class AppConfig {
@Bean
public MemberService memberService() {
return new MemberServiceImpl(memberRepository());
}
@Bean
public OrderService orderService() {
return new OrderServiceImpl(
memberRepository(),
discountPolicy()
);
}
@Bean
public MemberRepository memberRepository() {
return new MemoryMemberRepository();
}
// ...
}
위 AppConfig 클래스를 살펴봅시다. 이 코드를 따라가면 memberService 빈을 만들 때 memberRepository를 호출하고, orderService 빈을 만들 때 memberRepository를 호출하고 memberRepository 빈을 만들 때 객체 인스턴스를 생성하므로 총 3개의 객체 인스턴스가 생성되며 싱글톤이 아닌 것처럼 보입니다.
@Test
void configurationTest() {
ApplicationContext ac = new AnnotationConfigApplicationContext(AppConfig.class);
final MemberServiceImpl memberService = ac.getBean("memberService", MemberServiceImpl.class);
final OrderServiceImpl orderService = ac.getBean("orderService", OrderServiceImpl.class);
final MemberRepository memberRepository = ac.getBean("memberRepository", MemberRepository.class);
final MemberRepository memberRepositoryA = memberService.getMemberRepository();
final MemberRepository memberRepositoryB = orderService.getMemberRepository();
assertThat(memberRepositoryA).isSameAs(memberRepositoryB);
assertThat(memberRepository).isSameAs(memberRepositoryA);
assertThat(memberRepository).isSameAs(memberRepositoryB);
}
그러나 실제 확인해 보면 memberRepository는 모두 같은 인스턴스로 공유됨을 확인할 수 있습니다.
스프링 컨테이너는 싱글톤 패턴을 개발자가 구현하지 않아도 알아서 객체 인스턴스(스프링 빈)를 싱글톤으로 관리합니다. 스프링은 위에서 서술한 다양한 싱글톤 패턴의 문제점을 보완한 싱글톤 레지스트리를 적용합니다.
싱글톤 레지스트리
싱글톤 레지스트리는 클래스의 생성자에 private 접근 지시자를 사용하지 않고 일반적인 클래스를 싱글톤으로 활용할 수 있게 합니다.
이는 바이트 코드를 조작하여 객체 인스턴스(스프링 빈)가 스프링 컨테이너에 등록돼 있는지 확인하고 있으면 스프링 컨테이너에서 찾아 객체 인스턴스를 반환하고 없으면 이를 생성해 반환하는 위에서 서술했던 비슷한 방식으로 작동하게 합니다.
@Test
void configurationDeep() {
ApplicationContext ac = new AnnotationConfigApplicationContext(AppConfig.class);
AppConfig bean = ac.getBean(AppConfig.class);
System.out.println("bean = " + bean.getClass());
// [Output] bean = class com.patulus.helloSpring.AppConfig$$SpringCGLIB$$0
// bean = class com.patulus.helloSpring.AppConfig이 아닌 이상한 클래스가 나온다!
}
스프링은 설정 정보 클래스에 @Configuration 애너테이션이 적용되어 있어야 바이트 코드를 조작하여 싱글톤을 보장하게 됩니다. 만약 @Configuration 없이 @Bean 애너테이션만 있다면 이전에 예상한 것처럼 여러 개의 memberRepository가 생성됨을 확인할 수 있습니다.