본문 바로가기
Back-end/Spring

10_스프링 컨테이너는 싱글톤을 준수한다.

by 카랑현석 2025. 4. 27.

Summary

  • 스프링 컨테이너를 사용하면 객체 인스턴스를 싱글톤으로 관리하고 싱글톤에서 생기는 단점도 모두 해결해준다.
    • 스프링 컨테이너를 사용하면 기본적으로 싱글톤 방식을 지원한다. (물론 필요하다면 요청할 때마다 다른 참조를 하는 방식도 지원을 한다.)
    • 그래서 스프링 컨테이너를 사용하면 된다.
    • 그래서 '스프링 컨테이너'를 '싱글톤 컨테이너' 라고 부르기도 한다.

 

  • @Configuration 어노테이션 덕분에 스프링 컨테이너에서 싱글톤을 유지할 수 있다.

 

싱글톤?

  • GOF 디자인 패턴의 싱글톤 패턴
  • 클래스의 인스턴스가 1개임을 보장한다. (이미 만들어진 객체를 공유한다.)
  • 스프링 컨테이너는 기본적으로 싱글톤 방식을 지원한다.

싱글톤이 필요한 이유

  • 요청을 할 때마다 new를 통해 객체를 만든다면, 몇 천만명의 사용자가 요청한다면? -> 메모리 낭비

 

순수 자바로 싱글톤 만들기

  • 인스턴스를 하나만 사용하므로 메모리 낭비가 되지 않는다.

 

  • 싱글톤 구현 코드가 길다.
  • 클라이언트가 구현체에 의존한다. -> DIP 위반
  • 클라이언트가 구현체에 의존하기 때문에 OCP 위반 가능성 높음
  • 생성자를 private로 사용하기 때문에 자식 클래스를 만들기 어려움
  • 이러한 단점은 스프링 컨테이너가 해결해준다.
// JAVA로 싱글톤을 구현한 코드
public class SingletonService {

    // 1. static 영역에 객체를 딱 1개만 생성해둔다.
    private static final SingletonService instance = new SingletonService();

    // 2. public 으로 열어서 객체 인스턴스가 필요하면 이 static 메서드를 통해서만 조회하도록 허용한다.
    public static SingletonService getInstance() {
        return instance;
    }

    // 3. 생성자를 private으로 선언해서 외부에서 new 키워드를 사용한 객체 생성을 못하게 막는다.
    private SingletonService() {
    }

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

 

스프링 컨테이너가 싱글톤을 적용해주는지 확인

  • memberService1 와 memberService2의 참조값은 둘 다 같다.
    @Test
    @DisplayName("스프링 컨테이너는 싱글톤이 자동으로 적용된다. 그리고 순수 싱글톤의 단점도 해결한다.")
    public void springContainer() {
        ApplicationContext ac = new AnnotationConfigApplicationContext(AppConfig.class);

        // 1. 조회 : 호출할 때 마다 객체를 생성하는지
        MemberService memberService1 = ac.getBean("memberService", MemberService.class);

        // 2. 조회 : 호출할 때 마다 객체를 생성하는지
        MemberService memberService2 = ac.getBean("memberService", MemberService.class);

        // 3. 주소(참조)가 다른 것을 확인
        System.out.println("memberService1 = " + memberService1);
        System.out.println("memberService2 = " + memberService2);

        // memberService1 객체와 memberService2 객체가 같은 것을 증명
        Assertions.assertThat(memberService1).isSameAs(memberService2);
    }

 

🌟 싱글톤 방식의 주의점 - 공유 필드를 조심하자!

  • 객체의 인스턴스를 하나만 공유해서 사용한다. = 여러 클라이언트가 하나의 객체를 공유해서 사용한다.
  • 그래서 싱글톤 객체는 상태를 유지(stateful) 하게 설계하면 안된다. 무상태(stateless) 로 설계해야 한다.
    • stateful : 특정 클라이언트가 공유 변수의 값을 바꿀 수 있는 경우

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;
    }
}
    @Test
    @DisplayName("stateful 테스트 - 객체를 공유하기 때문에 price 변수가 공유되어 문제 발생")
    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);
        
        // ThreadA : 사용자A 주문 금액 조회
        int price = statefulService1.getPrice();
        System.out.println("price = " + price);

        // 사용자A가 조회했을 때 10000원을 의도했는데 20000원이 나온다.
        Assertions.assertThat(price).isEqualTo(10000);
    }

 

  • stateless : 특정 클라이언트가 공유 변수의 값을 바꾸는 것이 아니라 get 메서드를 통해 조회하도록 한다.

public class StatelessService {

    // 전역(공유) 부분에 변수 두는 것은 항상 조심한다.

    public int order(String name, int price) {
        System.out.println("name = " + name + " price = " + price);
        return price;
    }
}
    @Test
    @DisplayName("stateless 테스트")
    public void statelessServiceSingleton() {
        ApplicationContext ac = new AnnotationConfigApplicationContext(TestConfig.class);

        StatelessService statelessService1 = ac.getBean(StatelessService.class);
        StatelessService statelessService2 = ac.getBean(StatelessService.class);

        int userAPrice = statelessService1.order("userA", 10000);
        int userBPrice = statelessService2.order("userB", 20000);

        Assertions.assertThat(userAPrice).isEqualTo(10000);
        Assertions.assertThat(userAPrice).isNotEqualTo(userBPrice);
    }

    static class TestConfig {

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

        @Bean
        public StatelessService statelessService() {
            return new StatelessService();
        }
    }

 

@Configuration 어노테이션 의미

  • @Configuration 어노테이션 덕분에 스프링 컨테이너에서 싱글톤을 유지할 수 있다.
    • 참고 : GCLIB 이라는 아이 덕분에 싱글톤을 유지할 수 있다.

 

생각을 해보자. 아래 코드에서

memberRepository()를 호출하면 new MemoryMemberRepository() 가 할당된다.

memberService()를 호출하면 memberRepository()를 호출하고 new MemoryMemberRepository ()가 할당된다.

orderService()를 호출하면 memberRepository()를 호출하고 new MemoryMemberRepository ()가 할당된다.

 

셋 다 new 을 통해 새롭게 할당하니까, 참조 값이 다를 것인데 어떻게 싱글톤이 된다는 이야기인가?

결론적으로 @Configuration 덕분에 싱글톤이 유지된다.

@Configuration
public class AppConfig {

    @Bean
    public MemberRepository memberRepository() {
        System.out.println("call AppConfig.memberRepository");
        return new MemoryMemberRepository();
    }

    @Bean
    public MemberService memberService() {
        System.out.println("call AppConfig.memberService");
        return new MemberServiceImpl(memberRepository());
    }

    @Bean
    public DiscountPolicy discountPolicy() {
//        return new FixDiscountPolicy();
        return new RateDiscountPolicy();
    }

    @Bean
    public OrderService orderService() {
        System.out.println("call AppConfig.orderService");
        return new OrderServiceImpl(memberRepository(), discountPolicy());
    }
}

 

 

@Configuration 내부에서는 만약 스프링 컨테이너에 이미 등록되어 있는 객체라면 스프링 컨테이너에서 찾아서 반환하고,

스프링 컨테이너에 없던 새로운 객체라면 기존 로직을 호출해서 스프링 컨테이너를 등록한다.

 

@Configuration 어노테이션을 빼고 실행하면 아래 결과처럼 싱글톤이 깨져버린다.

memberRepository 가 여러 개 할당되어 버린다.

 

 

참고 자료

이해를 위해 인프런_김영한님의 '스프링 핵심 원리 - 기본편'의 '섹션 6. 싱글톤 컨테이너'을 참고하여 쉬운 예시로 작성하였습니다.