AtraFelis's Develop Diary

[Spring] 싱글톤 컨테이너 (스프링 핵심원리 - 기본편 Section6) 본문

Programming/Spring

[Spring] 싱글톤 컨테이너 (스프링 핵심원리 - 기본편 Section6)

AtraFelis 2025. 1. 13. 22:29

스프링 핵심 원리 - 기본편 강의를 수강하며 작성한 글입니다.
Section 6

바로 이전에 AppConfig를 스프링 컨테이너를 이용할 수 있도록 코드를 수정 해주었다. 하지만 이렇게 수정을 했을 때 어떤 좋은 점이 있는 걸까?

OrderApp을 한 번 살펴보자. 스프링 컨테이너를 사용할 수 있도록 수정했다.

public class OrderApp {  
    public static void main(String[] args) {  
        ApplicationContext context = new AnnotationConfigApplicationContext(AppConfig.class);  

    MemberService memberService = context.getBean("memberService", MemberService.class);  
    OrderService orderService = context.getBean("orderService", OrderService.class);

        ...
    }
}

AppConfig appConfig = new AppConfig()로 사용하던 걸 ApplicationContext ac = new AnnotationConfigApplicationContext(AppConfig.class); 이렇게 선언해서, ac.getBean(memberService) 이렇게 사용해야 한다.

이런 것을 보면 괜히 코드만 길어지고 헷갈리기까지 한다.

물론, 어느정도 스프링을 아는 분들이라면 추후에 @Autowired와 같은 애노테이션으로 쉽게 사용할 수 있게 된다는 것을 알기에 대충 "음, 그렇구나."하고 넘어갔을 수도 있겠다.

하지만, 그것은 편하게 쓰기 위해서 만들어진 기능에 불과하지, 근본적으로 스프링 컨테이너를 써야 하는 이유가 되지는 않는다.

우리는 왜 스프링 컨테이너를 이용해야 하는 걸까?

그 의문에 대한 답은 싱글톤 패턴에 있다.

 

싱글톤이란?

싱글톤 패턴(Singleton Pattern)은 클래스의 인스턴스를 단 하나만 생성하고, 어디서든 이 인스턴스에 접근할 수 있도록 보장하는 디자인 패턴입니다.

ChatGPT에게 싱글톤 패턴에 대해 물어봤을 때 돌아오는 답변이다.

즉, 똑같은 클래스라면, 하나의 객체만을 생성하여 돌려쓰는 방법이다. 이걸 어떻게 구현하지라는 생각은 일단 뒤로 미뤄두고, 일단 싱글톤 패턴을 사용하면 뭐가 좋은지부터 알아보자.

@Test  
@DisplayName("스프링 없는 순수한 DI 컨테이너")  
void pureContainer() {  
    AppConfig appConfig = new AppConfig();  

    // 1. 조회: 호출할 때마다 객체 생성  
    MemberService memberService1 = appConfig.memberService();  
    MemberService memberService2 = appConfig.memberService();  

    // 2. 객체 확인  
    System.out.println("memberService1 = " + memberService1);  
    System.out.println("memberService2 = " + memberService2);  

    // memberRepository1 != memberService2  
    assertThat(memberService1).isNotSameAs(memberService2);  
}
memberService1 = atrafelis.core.member.MemberServiceImpl@135606db
memberService2 = atrafelis.core.member.MemberServiceImpl@518caac3

이렇게 서로 다른 객체가 생성되는 것을 알 수 있다. 이게 무슨 문제인가 싶겠지만, 프로젝트의 사이즈가 커지고 서비스의 사용자가 늘어난다면 어떨까? 클라이언트가 서비스를 요청할 때마다 새로운 객체를 생성한다면 서버의 메모리가 심해지는 현상이 발생한다.

이것을 해결하기 위해 고안된 것이 바로 싱글톤 패턴이다.

기존의 문제점이 요청이 들어올 때마다 새로운 객체가 생성된다는 점에 있으므로, 하나의 객체만을 생성하여 공유하도록 하면 되는 것이다.

최초의 의문으로 돌아가보자.

Q. 스프링 컨테이너를 이용하도록 AppConfig를 수정했을 때 무슨 이점이 있는 걸까?
A. 스프링 컨테이너는 자체적으로 싱글톤 패턴을 구현해준다. 아래의 사진과 같은 일을 마법처럼 자동으로 해준다. 정말 신기하지 않은가?

@Configuration, @Bean과 같은 짧은 애노테이션만 이용해서 싱글톤 패턴을 사용할 수 있게 된 것이다.

하지만 더 좋은 개발자가 되기 위해서는 좀 더 깊숙한 원리까지 파고들 필요가 있다. 애초에 그것을 위해서 이 강의를 수강하고 있는 것이니까.

하지만 스프링 컨테이너가 이런 싱글톤 패턴을 어떻게 구현하는지 알아보기 전에, 여태껏 해왔던 것처럼 스프링을 사용하지 않은 순수한 자바 코드만을 이용해 싱글톤 패턴을 구현해보자.

 

자바로만 싱글톤 패턴 구현하기

public class SingletonService {    
    private static final SingletonService INSTANCE = new SingletonService();  

    public static SingletonService getInstance() {  
        return INSTANCE;  
    }  

    private SingletonService(){}
}

클래스가 단 하나의 인스턴스만 가지도록 하기 위해서는 어떻게 해야할까? 길게 설명할 것 없이 위처럼 선언하면 된다.

코드를 하나하나 뜯어보자.

private static final SingletonService INSTANCE = new SingletonService();

관례적으로 INSTANCE라는 객체명으로 선언한다고 한다.

자기 자신을 priavate static final로 선언하여 가지고 있게 한다. 그리고 다른 클래스에서 자기자신을 호출할 때마다 이 객체를 던져주게끔 하면 되는 것이다.

정말 획기적이지 않은가? 나는 static을 이렇게 활용할 수 있을 거라고는 상상도 못해봤다.

public static SingletonService getInstance() {  
    return INSTANCE;  
}  

객체를 전달할 때는 이렇게 getter를 사용하면 된다. 이제 다른 클래스에서는 getInstance() 메소드를 호출하면, 싱글톤 패턴의 완성이다.

💡 실제로 실행해보기

@Test  
@DisplayName("싱글톤 패턴을 적용한 객체 사용")  
void singletonServiceTest() {
    SingletonService singletonService1 = SingletonService.getInstance();  
    SingletonService singletonService2 = SingletonService.getInstance();  

    System.out.println("memberRepository1 = " + singletonService1);  
    System.out.println("memberRepository2 = " + singletonService1);  

    // memberRepository1 != memberRepository2  
    assertThat(singletonService1).isSameAs(singletonService2);  
}

이 코드를 실행해보면,

memberRepository1 = atrafelis.core.singleton.SingletonService@a307a8c
memberRepository2 = atrafelis.core.singleton.SingletonService@a307a8c

이렇게 같은 객체라는 것을 알 수 있다.

 

private SingletonService(){}

그리고 마지막 private으로 선언한 생성자 부분이다. 이렇게 선언하면, 다른 클래스에서 이 클래스의 객체를 생성하는 것을 막을 수 있다. 즉, new SingletoneService()를 할 수 없게끔 막는다는 의미다.

이것도 나에게는 정말 획기적으로 다가왔다. private 생성자라니. 심지어 이걸 이렇게 활용할 수 있다니.

💡 실제로 실행해보기

싱글톤이고 뭐고 아래와 같이 그냥 객체를 생성해버렸을 경우.

void singletoneConstructorTest() {
    SingletonService singletonService1 = new SingletonService();
}​

praivate 메소드에 접근할 수 없다며 에러가 나타난다. 이렇게 실수로 싱글톤 패턴임에도 객체를 생성했을 경우를 방지할 수 있다.

 

싱글톤 패턴의 단점

물론, 싱글톤 패턴에 장점만 있는 건 아니다.

  • 싱글톤 패턴을 구현하는 데에 많은 코드가 들어간다.
    • 자기 자신을 priavte static final로 선언하고, *getter로 반환하고, *private 생성자로 new 못하게 막고. 이것만 해도 꽤 많은 코드가 필요하다.
  • 의존관계상 클라이언트가 구체 클래스에 의존한다. 즉, DIP를 위반하게 된다.
    • 결국, 실제로 실행해보기에서 보았듯, 클라이언트 코드에서 getInstance()메소드를 호출하여 사용하고 있다. 클라이언트가 구현체에 의존하게 되어버린 것이다.
  • OCP 원칙을 위반할 가능성이 크다
    • DIP를 위반하고 있으므로, 결국 OCP를 위반하게 될 가능성이 매우 높다.
  • 테스트하기 어렵다.
  • 내부 속성을 변경하거나 초기화하기 어렵다.
  • 자식 클래스를 만들기 어렵다
    • private 생성자로 막고 있으므로 자식 클래스를 만들기 어렵다.

이런 상당히 많은 단점들이 존재한다. 결국, 좋자고 사용했던 싱글톤 패턴이 프로그램 전체의 유연성을 떨어뜨리는 결과를 발생시키는 모순이 발생한다.

 

싱글톤 컨테이너

스프링 컨테이너는 기본적으로 싱글톤으로 객체 인스턴스를 관리한다.

이 그림을 다시 보자. 스프링 컨테이너는 스프링 빈을 싱글톤으로 관리하고 있다. 분명히 memberRepository()memberService(), orderService()에서 한 번씩 호출되었는데 컨테이너 상에는 하나만 저장되어 있다.

우리가 직접 클래스 내부에서 private static final*로 인스턴스를 관리하고 있지 않고 *private 생성자로 막아놓지도 않았는데도, 이렇게 싱글톤이 유지가 된다.

즉, 스프링 컨테이너를 사용하면 기존의 싱글톤 패턴의 단점을 해결하면서, 객체를 싱글톤으로 관리할 수 있다. (이것을 가능하게 하는 원리는 조금 더 밑에서 다룬다.)

💡 믿지 못하는 분들을 위한 테스트

스프링 컨테이너에서 객체를 꺼내어 비교하는 테스트이다.

@Test  
@DisplayName("스프링 컨테이너와 싱글톤")  
void springContainer(){  
    ApplicationContext ac = new AnnotationConfigApplicationContext(AppConfig.class);  

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

    System.out.println("memberService1 = " + memberService1);  
    System.out.println("memberService2 = " + memberService2);  

    assertThat(memberService1).isSameAs(memberService2);  
}​

assertJ를 이용한 테스트도 통과되며, 출력으로 비교해보아도 같다는 것을 알 수 있다.

memberService1 = atrafelis.core.member.MemberServiceImpl@6c4f9535
memberService2 = atrafelis.core.member.MemberServiceImpl@6c4f9535

 

싱글톤의 문제점

스프링 컨테이너를 이용해 싱글톤 패턴의 단점을 대부분 해결했지만, 아직 치명적인 문제점이 하나 존재한다.

싱글톤 객체는 무상태(stateless) 로 설계해야 한다. 반대로 말하면, 싱글톤 객체는 상태를 유지하면 안 된다.

상태 stateful? 무상태 stateless? 이게 무슨 말일까.

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;  
    }  
}

객체가 상태를 유지한다는 말은 객체가 무언가 정보를 가지고 있다는 의미다. 이게 무슨 문제가 될 수 있을까?

이것은 비단 싱글톤 패턴 뿐만 아니라, 데이터베이스나, CS를 배울 때에도 간혹 비슷하게 보이는 대표적인 동시성 문제다.

여러 클라이언트가 동시에 주문을 던졌을 때, 싱글톤 객체는 price라는 하나의 상태에 값을 저장하려고 한다. A가 주문을 던지고 결제하기 전, B가 다른 주문을 하면 A의 price가 B의 price로 덮어씌워지는 것이다.

아래는 매우 간단한 테스트를 위한 코드다.

@Test  
void statefulServiceSingleton() {  
    ApplicationContext ac = new AnnotationConfigApplicationContext(TestConfig.class);

    StatefulService statefulService1 = ac.getBean(StatefulService.class);
    StatefulService statefulService2 = ac.getBean(StatefulService.class);

    statefulService1.order("userA", 10000);
    statefulService2.order("userB", 20000);

    assertThat(statefulService1.getPrice()).isEqualTo(statefulService2.getPrice());
}

이 코드에서 A는 10,000원 B는 20,000원짜리 물건을 각자 주문했다. 하지만, A와 B의 결제 금액이 같다고 나타난다.

이것이 바로 싱글톤에서 상태를 유지할 경우 발생하는 심각한 문제이다. 지금은 한눈에 띄지만, 프로그램의 규모가 커지면 돌이킬 수 없는 대참사가 일어날 수 있다.

그렇다면 어떻게 무상태로 설계할 수 있을까?

public class StatefulService {
    public int Order(String name, int price) {  
        System.out.println("name = " + name + ", price = " + price);  
        return price;  
    }
}

이렇게 상태를 저장하지 않과 바로 반환해버리는 것이다. 상태에 대한 것은 전부 클라이언트에 맡기고 애초에 동시성 문제가 나타나지 않게끔 한다.

 

스프링이 싱글톤을 유지하는 방법

AppConfig에서 memberRepository()memberService(), orderService()에서 한 번씩 호출되었는데 컨테이너 상에는 하나만 저장되는 것을 위에서 잠깐 보았다.

이게 어떻게 가능한걸까?

@Test  
void configurationDeep() {  
    ApplicationContext ac = new AnnotationConfigApplicationContext(AppConfig.class);  

    AppConfig bean = ac.getBean(AppConfig.class);  
    AppConfig appConfig = new AppConfig();  

    System.out.println("bean = " + bean.getClass());
    System.out.println("appConfig = " + appConfig.getClass());  

    assertThat(bean.getClass()).isNotSameAs(appConfig.getClass());  
}

먼저 이 코드를 보자.

두 개의 AppConfig를 생성하여 비교해볼 것이다. 하나는 bean이라는 이름의 스프링 컨테이너에서 빼낸 싱글톤으로 관리되는 스프링 빈. 하나는 appConfig라는 이름으로 AppConfig 자체를 생성한 객체이다.

우리는 이 두 객체의 클래스가 같을 것이라고 예상할 수 있다. 객체가 아닌, 이 객체의 원본이 되는 클래스를 말하는 것이다. 유명한 예시를 들자면, 붕어빵이 아니라 붕어빵틀! getClass() 메소드를 이용한 것에 주목해야 한다.

isNotSameAs로 두 클래스를 비교하고 있으므로 원래라면 이 테스트는 당연히 실패해야 했다.

bean = class atrafelis.core.AppConfig$$SpringCGLIB$$0
appConfig = class atrafelis.core.AppConfig

하지만 테스트 코드는 성공하고, 출력된 문장을 확인해보면 두 클래스가 다르다는 것을 확인할 수 있다. 어? 이게 뭐야. 누군가가 중간에 붕아빵을 훔쳐가 바꿔치기한 것이 아닌 이상에야, 두 객체의 원본이 되는 클래스가 달라서는 안 되었다.

이것이 바로 스프링 컨테이너가 싱글톤을 유지하기 위해 우리 몰래 하는 일이다.

이렇게 AppConfig를 상속받는 새로운 클래스를 만든 후, 이 클래스로 생성한 객체를 싱글톤으로 유지하여 사용하는 것이다.


References

스프링 핵심 원리 - 기본편