Carefree Hub

Command Palette

Search for a command to run...

Spring Singleton - 싱글톤 디자인 패턴과 주의점

Spring Singleton - 싱글톤 디자인 패턴과 주의점

웹 애플리케이션에서 싱글톤 패턴의 필요성과 스프링 컨테이너의 싱글톤 관리 방식

Carefreelife98
5분 소요

웹 애플리케이션과 싱글톤

Web Application 특성 상 대규모 트래픽을 다루게 된다. 이때 매 요청, 응답 과정 마다 새로운 객체를 만들어 반환하게 되면 JVM 메모리에 새로운 객체가 끊임없이 load 된다.

다중 호출 후 생성된 객체 간 참조값 비교 테스트

java
1public class SingletonTest {
2
3 @Test
4 @DisplayName("스프링 없는 순수한 DI Container")
5 void pureContainer() {
6 AppConfig appConfig = new AppConfig();
7
8 // 호출할 때마다 새로운 객체를 생성
9 MemberService memberService1 = appConfig.memberService();
10 MemberService memberService2 = appConfig.memberService();
11
12 // 참조 값이 다른 것을 확인
13 System.out.println("memberService1 = " + memberService1);
14 System.out.println("memberService2 = " + memberService2);
15 }
16}
bash
1memberService1 = hello.core.member.MemberServiceImpl@71a794e5
2memberService2 = hello.core.member.MemberServiceImpl@76329302
  • 같은 함수를 두번 호출했을때, 반환되는 객체는 서로 다른 객체 (참조 값이 다름)
  • TPS가 50000이라면, 초당 50000개의 객체가 JVM 메모리에 load됨
  • 매우 비효율적 -> 메모리 낭비가 심함
  • 해결 방안: 공유할 수 있는 객체를 단 하나만 생성 (= 싱글톤 패턴)

싱글톤 패턴 사용하기

싱글톤 패턴

  • 클래스의 인스턴스가 단 1개만 생성되는 것을 보장하는 디자인 패턴
  • private 생성자를 사용하여 외부에서 new 키워드를 사용하지 못하도록 막아야 한다
java
1public class SingletonService {
2
3 private static final SingletonService instance = new SingletonService();
4
5 public static SingletonService getInstance() {
6 return instance;
7 }
8
9 // private 생성자로 외부의 new 키워드 차단
10 private SingletonService() {}
11
12 public void logic() {
13 System.out.println("싱글톤 객체 로직 호출");
14 }
15}
  1. static 영역에 객체 instance를 미리 하나 생성
  2. 이 객체 인스턴스가 필요하면 오직 getInstance() 메서드를 통해서만 조회
  3. 단 하나의 객체 인스턴스만 존재해야 하므로, 생성자를 private으로 막음

싱글톤 테스트

java
1@Test
2@DisplayName("싱글톤 패턴을 적용한 객체 사용")
3void singletoneServiceTest() {
4 SingletonService singletonService1 = SingletonService.getInstance();
5 SingletonService singletonService2 = SingletonService.getInstance();
6
7 System.out.println("singletonService1 = " + singletonService1);
8 System.out.println("singletonService2 = " + singletonService2);
9
10 assertThat(singletonService1).isSameAs(singletonService2);
11}
bash
1singletonService1 = hello.core.singleton.SingletonService@3b07a0d6
2singletonService2 = hello.core.singleton.SingletonService@3b07a0d6

각 호출의 결과로 반환된 객체의 참조 값이 서로 같다.

싱글톤 패턴의 한계

  • 싱글톤 패턴을 구현하는 코드 자체가 많이 들어간다
  • 의존 관계 상 Client가 구체 Class에 의존한다 (DIP 위반)
  • Client가 구체 Class에 의존함에 따라 OCP 원칙 위반 가능성이 높다
  • 테스트하기 어려움
  • 내부 속성의 변경 및 초기화가 어려움
  • private 생성자로 자식 Class 생성이 어려움

결론은 싱글톤 패턴 적용 시 유연성이 떨어진다. 하지만 스프링 프레임워크는 앞서 제시한 모든 단점을 커버하는 싱글톤 컨테이너(스프링 컨테이너)를 제공한다.

싱글톤 컨테이너 (스프링 컨테이너)

스프링 컨테이너의 싱글톤 관리

스프링 컨테이너는 싱글톤 패턴의 문제점을 해결하는 동시에, 객체 인스턴스를 싱글톤으로 관리한다.

  • 스프링 컨테이너는 싱글톤 패턴을 따로 적용하지 않아도, 객체 인스턴스를 싱글톤으로 관리
    • 스프링 컨테이너 생성 시, 모든 스프링 빈 객체를 미리 생성하여 key-value 형태로 등록
    • Singleton Registry: 싱글톤 객체를 생성하고 관리하는 기능
  • 앞서 언급한 싱글톤 패턴의 모든 문제점 해결
    • 싱글톤 패턴 구현을 위한 지저분한 코드 X
    • DIP, OCP, 테스트, private 생성자로부터 자유로움

스프링 컨테이너 싱글톤 패턴 테스트

java
1@Test
2@DisplayName("스프링 컨테이너를 사용한 싱글톤 패턴 구현")
3void springContainerSingleton() {
4
5 ApplicationContext ac = new AnnotationConfigApplicationContext(AppConfig.class);
6
7 MemberService memberService1 = ac.getBean("memberService", MemberService.class);
8 MemberService memberService2 = ac.getBean("memberService", MemberService.class);
9
10 System.out.println("memberService1 = " + memberService1);
11 System.out.println("memberService2 = " + memberService2);
12
13 assertThat(memberService1).isSameAs(memberService2);
14}
bash
1memberService1 = hello.core.member.MemberServiceImpl@20435c40
2memberService2 = hello.core.member.MemberServiceImpl@20435c40
  • 두 객체가 동일한 참조 값을 가짐
  • 스프링 컨테이너를 사용하여, 고객의 요청이 발생할 때마다 새로운 객체를 생성하는 것이 아닌, 기존 객체를 공유하여 효율적으로 재사용

싱글톤 방식의 주의점

싱글톤 패턴과 같이 하나의 객체를 공유하는 방식을 사용하게 되면 다중 Client가 하나의 같은 객체 인스턴스를 공유.

따라서, 싱글톤으로 생성된 객체는 무상태(Stateless)하도록 설계해야 한다.

  • 특정 Client에 의존적인 필드가 존재하면 안된다
  • 특정 Client가 값을 변경할 수 있는 필드가 있으면 안된다
  • 가급적 Read-Only로서 존재해야 한다
  • 객체 내 field 대신, 공유되지 않는 지역변수, Parameter, ThreadLocal 등을 사용해야 한다
  • 스프링 빈의 필드에 공유 값을 설정하면 큰 장애 발생 가능성이 높다

싱글톤 객체를 Stateful 하게 설계 시 장애 발생 테스트

java
1// StatefulService.class
2public class StatefulService {
3
4 private int price; // 상태 유지 필드
5
6 public void order(String name, int price) {
7 System.out.println("name = " + name + " price = " + price);
8 this.price = price; // 장애 발생 지점
9 }
10
11 public int getPrice() {
12 return price;
13 }
14}
java
1@Test
2void statefulServiceSingleton() {
3 AnnotationConfigApplicationContext ac = new AnnotationConfigApplicationContext(TestConfig.class);
4 StatefulService statefulService1 = ac.getBean(StatefulService.class);
5 StatefulService statefulService2 = ac.getBean(StatefulService.class);
6
7 // ThreadA: User A 10000원 주문
8 statefulService1.order("userA", 10000);
9
10 // ThreadB: User B 20000원 주문
11 statefulService2.order("userB", 20000);
12
13 // [장애 발생] ThreadA: User A 주문 금액 조회 시, 20000 출력
14 int price = statefulService1.getPrice();
15 System.out.println("price = " + price);
16
17 Assertions.assertThat(statefulService1.getPrice()).isEqualTo(20000);
18}
  • User A가 10000원을 주문 후, User B가 20000원을 주문
  • User A가 자신의 주문 금액을 조회하면 20000원이 반환됨 (장애!)

해결 방법 - Stateless 하게 설계하자

java
1// StatelessService.class
2public class StatelessService {
3
4 public int order(String name, int price) {
5 System.out.println("name = " + name + " price = " + price);
6 return price; // 로직 수행 후 상태를 가지고 있지 않고 반환
7 }
8}
  • 외부 입력으로부터 내부 서비스 로직 실행 후 결과값을 반환하도록 변경
  • 각각의 요청마다 비즈니스 로직 실행 후 결과 값을 반환하여 사용
  • 싱글톤으로 생성된 빈은 하나의 모듈(Module)이 되는 셈
bash
1name = userA price = 10000
2name = userB price = 20000
3User A's price = 10000
4User B's price = 20000

참고: Inflearn - 김영한님 강의(스프링 핵심 원리 기본편)