포스트

스프링 핵심 원리 (6) - 싱글톤 컨테이너

스프링 핵심 원리 (6) - 싱글톤 컨테이너

1. 개요

스프링은 대부분 여러 사용자가 동시에 요청을 보내는 웹 애플리케이션에서 사용된다. 그런데 지금까지 만들어 온 순수 자바 DI 컨테이너(AppConfig)는 빈을 요청할 때마다 new로 객체를 새로 만든다. 트래픽이 많아지면 그만큼 객체가 계속 생성·소멸되므로 메모리 낭비가 심해진다. 이 문제를 어떻게 해결하는지, 그리고 그 해결책이 어떤 새로운 주의사항을 만드는지 정리한다.


2. 요청마다 객체를 새로 만드는 문제

순수한 DI 컨테이너는 조회할 때마다 새 인스턴스를 반환한다.

1
2
3
4
5
6
7
8
9
10
11
@Test
@DisplayName("스프링 없는 순수한 DI 컨테이너")
void pureContainer() {
    AppConfig appConfig = new AppConfig();

    MemberService memberService1 = appConfig.memberService();
    MemberService memberService2 = appConfig.memberService();

    // memberService1 != memberService2
    assertThat(memberService1).isNotSameAs(memberService2);
}

appConfig.memberService()를 호출할 때마다 내부적으로 new MemberServiceImpl(...)이 실행되기 때문에 두 참조값은 다르다. 초당 100건의 요청이 들어오면 초당 100개의 객체가 새로 만들어졌다가 버려지는 셈이다. 해결 방법은 단순하다. 객체를 딱 1개만 만들어서 여러 클라이언트가 공유하도록 하면 된다. 이 아이디어를 구현한 디자인 패턴이 싱글톤 패턴이다.


3. 싱글톤 패턴

싱글톤 패턴은 클래스의 인스턴스가 애플리케이션 전체에서 딱 1개만 만들어지도록 강제하는 패턴이다. 자바에서는 보통 생성자를 private으로 막고, 미리 만들어둔 단일 인스턴스를 정적 메서드로 꺼내 쓰게 한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class SingletonService {

    // 1. static 영역에 객체를 하나만 미리 생성해서 올려둔다.
    private static final SingletonService instance = new SingletonService();

    // 2. 이 static 메서드를 통해서만 인스턴스를 조회할 수 있다.
    public static SingletonService getInstance() {
        return instance;
    }

    // 3. 생성자를 private으로 막아 외부에서 new로 생성하지 못하게 한다.
    private SingletonService() {
    }

    public void logic() {
        System.out.println("싱글톤 객체 로직 호출");
    }
}

new SingletonService()는 컴파일 단계에서부터 막히고, 항상 getInstance()로 얻은 같은 인스턴스만 사용할 수 있다. 이렇게 하면 객체를 하나만 생성해서 재사용할 수 있지만, 대신 다음과 같은 단점들을 함께 떠안게 된다.

  • 싱글톤을 구현하기 위한 코드 자체가 늘어난다.
  • 클라이언트가 구체 클래스(SingletonService)에 직접 의존하게 되어 DIP·OCP를 위반하기 쉽다.
  • private 생성자 때문에 자식 클래스를 만들거나 목(mock) 객체로 대체하기 어려워 테스트하기 힘들다.
  • 내부 상태를 변경하거나 초기화하기 어렵고, 전반적으로 유연성이 떨어진다.

그래서 순수한 싱글톤 패턴은 종종 안티패턴으로도 불린다.


4. 싱글톤 컨테이너

스프링 컨테이너는 이 문제를 아예 다른 방식으로 해결한다. 싱글톤 패턴을 코드로 직접 구현하지 않아도, 컨테이너가 빈을 등록하는 과정 자체에서 객체를 딱 1개만 생성해서 관리해준다. 지금까지 다뤄온 스프링 빈이 바로 이렇게 싱글톤으로 관리되는 객체다. 이런 기능을 하는 컨테이너를 싱글톤 컨테이너, 싱글톤 객체를 생성하고 관리하는 기능을 싱글톤 레지스트리라고 부른다.

1
2
3
4
5
6
7
8
9
10
11
@Test
@DisplayName("스프링 컨테이너와 싱글톤")
void springContainer() {
    ApplicationContext ac = new AnnotationConfigApplicationContext(AppConfig.class);

    MemberService memberService1 = ac.getBean("memberService", MemberService.class);
    MemberService memberService2 = ac.getBean("memberService", MemberService.class);

    // memberService1 == memberService2
    assertThat(memberService1).isSameAs(memberService2);
}

같은 이름으로 두 번 조회했지만 참조값은 동일하다. 덕분에 private 생성자, 정적 팩토리 메서드 같은 장치 없이도 DIP·OCP를 지키면서 테스트하기 쉬운 코드를 유지한 채로 싱글톤의 이점(객체 재사용에 따른 메모리 효율)을 얻을 수 있다.

참고: 스프링의 기본 빈 등록 방식은 싱글톤이지만 그것만 지원하는 것은 아니다. 요청할 때마다 새 객체를 반환하는 스코프도 있는데, 이는 빈 스코프를 다룰 때 별도로 정리한다.


5. 싱글톤 방식의 주의점

싱글톤은 하나의 인스턴스를 여러 클라이언트가 공유하는 방식이기 때문에, 그 객체를 상태를 유지(stateful)하도록 설계하면 반드시 문제가 생긴다. 싱글톤 빈은 항상 무상태(stateless)로 설계해야 한다.

  • 특정 클라이언트에만 의존적인 필드를 두지 않는다.
  • 특정 클라이언트가 값을 변경할 수 있는 필드를 두지 않는다. 가급적 읽기만 가능해야 한다.
  • 공유되면 안 되는 값은 필드 대신 지역 변수, 메서드 파라미터, ThreadLocal 등을 사용한다.

상태를 갖는 필드가 왜 위험한지 예시로 확인해보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
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;
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
public class StatefulServiceTest {

    @Test
    void statefulServiceSingleton() {
        ApplicationContext ac = new AnnotationConfigApplicationContext(TestConfig.class);
        StatefulService statefulService1 = ac.getBean("statefulService", StatefulService.class);
        StatefulService statefulService2 = ac.getBean("statefulService", StatefulService.class);

        // 사용자A가 10000원 주문
        statefulService1.order("userA", 10000);
        // 사용자B가 20000원 주문
        statefulService2.order("userB", 20000);

        // 사용자A의 주문 금액을 조회했는데 20000원이 나온다!
        int price = statefulService1.getPrice();
        System.out.println("price = " + price);
    }

    static class TestConfig {
        @Bean
        public StatefulService statefulService() {
            return new StatefulService();
        }
    }
}

statefulService1statefulService2는 스프링 컨테이너가 관리하는 같은 싱글톤 인스턴스이므로, price 필드도 하나를 공유한다. 사용자 A의 주문 금액을 조회했더니 뒤이어 들어온 사용자 B의 주문 금액이 나오는, 실무에서 실제로 종종 발생하는 장애 패턴이다. 싱글톤 빈의 필드에 상태를 저장하면 이런 식으로 서로 다른 요청의 데이터가 뒤섞일 수 있으므로 항상 무상태로 설계해야 한다.


6. 정리

순수한 DI 컨테이너는 요청마다 객체를 새로 만들어 메모리를 낭비하고, 이를 해결하는 전통적인 싱글톤 패턴은 DIP·OCP 위반과 테스트 어려움 같은 부작용을 안고 있다. 스프링 컨테이너는 별도의 싱글톤 패턴 코드 없이도 빈을 하나만 생성해서 공유하는 싱글톤 레지스트리 역할을 해주므로, 이런 단점 없이 싱글톤의 이점만 취할 수 있다.

다만 하나의 인스턴스를 여러 요청이 공유한다는 사실 자체는 그대로이기 때문에, 스프링 빈에 특정 클라이언트의 상태를 저장하는 필드를 두면 안 된다. 스프링 빈은 항상 무상태(stateless)로 설계해야 한다는 원칙을 지켜야 싱글톤 컨테이너의 이점을 안전하게 누릴 수 있다.

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.