본문 바로가기

Spring/스프링 핵심 원리 - 기본

05. 싱글톤 컨테이너

💡 본 게시글은 김영한님의 인프런(Inflearn) 강의 스프링 핵심 원리 - 기본편에 대해 공부하고, 정리한 내용입니다.

 


1. 웹 애플리케이션과 싱글톤

(1) 웹 애플리케이션과 동시다발적 요청 처리

  웹 애플리케이션에서는 동일한 서비스에 대해 여러 사용자가 동시에 요청을 보내는 상황이 흔히 발생합니다. 특히 대규모 기업에서 운영하는 웹 서비스의 경우, 이러한 동시다발적인 요청이 수천, 수만, 혹은 그 이상의 규모로 일어나게 됩니다.

 스프링 프레임워크는 바로 이런 대규모 웹 애플리케이션 환경에서 효율적으로 서비스를 제공하고자 하는 목적으로 탄생했습니다.

 


(2) 순수 DI 컨테이너의 문제점

 스프링 프레임워크를 사용하지 않고, 순수한 DI(Dependency Injection) 컨테이너를 사용하는 경우에는 사용자의 요청이 있을 때마다 새로운 객체가 생성됩니다. 이는 객체 생성에 필요한 자원을 매번 소비하게 되므로, 메모리 사용에 있어서 비효율적인 점이 있습니다.

 예를 들어, 아래의 코드는 `AppConfig` 클래스를 통해 `MemberService` 인스턴스를 생성하는 코드입니다. 이 코드를 실행하면, `memberService()` 메소드를 호출할 때마다 새로운 `MemberService` 객체가 생성됩니다.

package hello.core.singleton; 
import hello.core.AppConfig;import hello.core.member.MemberService;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.*; 

public class SingletonTest {

    @Test
    @DisplayName("스프링 없는 순수한 DI 컨테이너") 
    void pureContainer() {
    
        AppConfig appConfig = new AppConfig(); 
        
        //1. 조회: 호출할 때 마다 객체를 생성
        MemberService memberService1 = appConfig.memberService(); 
        
        //2. 조회: 호출할 때 마다 객체를 생성
        MemberService memberService2 = appConfig.memberService(); 
        
        // 테스트 출력: 참조 값이 다른 것을 확인
        System.out.println("memberService1 = " + memberService1); 
        System.out.println("memberService2 = " + memberService2);
        
        // 테스트 결과: memberService1 != memberService2
        assertThat(memberService1).isNotSameAs(memberService2);
    } 
}

 

※ 코드 결과

  • 스프링 없이 순수한 DI 컨테이너인 AppConfig를 사용하면, 사용자의 요청이 있을 때마다 새로운 객체가 생성되는 것을 알 수 있습니다.  
  • 그렇다면 만약에, 초당 100번의 트래픽이 발생한다면, 이는 초당 100개의 객체가 생성되고, 이후에 소멸되는 것을 의미합니다. 이런 방식은 메모리를 많이 사용하게 되어 비효율적일 수 있습니다.
  • 이를 해결하기 위해, 객체를 단 한 번만 생성하고 이를 공유하도록 설계하는 방법을 고려해볼 수 있습니다. 이런 방식은 메모리 사용을 최적화하는 데 도움이 됩니다.

 

※ 정리하기

  • 웹 애플리케이션 다수의 사용자들이 서비스를 동시에 이용하는 특성을 가지고 있습니다.
  • 이러한 동시성 환경에서 사용자의 요청이 발생할 때마다 새로운 객체를 생성하는 방식은 매우 비효율적일 수 있습니다. 왜냐하면, 이 방식은 시스템 리소스를 과도하게 사용하게 만들고, 서비스의 성능을 저하시킬 수 있기 때문입니다.
  • 이런 문제를 효과적으로 해결하기 위해 싱글톤 디자인 패턴이 주로 사용됩니다. 

(3) 싱글톤 패턴

  • 싱글톤 패턴클래스의 인스턴스가 딱 한 개만 생성되도록 보장하는 디자인 패턴입니다.
  • 이 패턴을 사용하면 동일한 객체를 공유하여 사용할 수 있으므로, 메모리 낭비를 줄일 수 있습니다.
  • 스프링 프레임워크에서는 기본적으로 이 싱글톤 패턴을 사용하여 빈(Bean)을 관리합니다.
    • => 따라서 사용자의 요청이 있을 때마다 빈을 새로 생성하지 않고, 이미 생성된 빈을 공유하여 사용합니다. 이를 통해 메모리 사용을 최적화하고, 동시에 처리해야 하는 요청이 많은 웹 애플리케이션 환경에서도 효율적으로 서비스를 제공할 수 있습니다.

2. 싱글톤 패턴 

(1) 싱글톤 패턴이란? 

  • 싱글톤 패턴이란, 이름에서도 알 수 있듯이 '단 하나만'을 의미하는 패턴입니다. 이 패턴은 특정 클래스의 인스턴스가 하나만 존재하도록 보장하는 디자인 패턴입니다.

(2) 그렇다면 왜 싱글톤 패턴을 사용해야 할까?

  • 왜 인스턴스를 딱 하나만 만들어야 할까요? 이는 전체 프로그램에서 공유해야 하는 데이터가 있거나, 동일한 자원에 동시에 접근하는 것을 제어해야 할 때 유용하기 때문입니다.
  • 예를 들어, 프린터를 여러 사람이 동시에 사용하려 하면 문제가 발생할 수 있습니다. 이런 경우를 방지하기 위해 프린터를 관리하는 클래스의 인스턴스가 하나만 존재하도록 제한할 수 있습니다.

(3) 싱글톤 패턴의 구현 방법

  • 그렇다면 어떻게 하나의 인스턴스만 생성하도록 할까? 보통 클래스를 만들 때, 'public'이라는 키워드를 붙여 생성자를 만듭니다. 이렇게 하면 외부에서도 이 클래스의 인스턴스를 자유롭게 생성할 수 있습니다.
  • 하지만 싱글톤 패턴에서는 'private' 키워드를 붙여 생성자를 정의합니다. 'private'은 '비공개'라는 의미로, 이 키워드를 사용하면 해당 클래스 내부에서만 사용할 수 있도록 제한합니다. 
  • 따라서 외부에서는 'new'라는 키워드를 사용해 이 클래스의 인스턴스를 만들 수 없게 됩니다. 이렇게 하면 인스턴스가 두 개 이상 생성되는 것을 막을 수 있습니다.

(4) 싱글톤 패턴의 이점

  • 싱글톤 패턴을 사용하면 프로그램의 메모리 사용을 효율적으로 관리하고, 데이터를 안전하게 공유할 수 있습니다 이는 프로그램의 성능을 향상시키는 데 큰 도움이 됩니다.
  • 이러한 이유로, 다양한 프로그래밍 언어와 프레임워크에서는 싱글톤 패턴을 적극적으로 활용하고 있습니다. 이를 통해 개발자들은 보다 안정적이고 효율적인 소프트웨어를 만들 수 있게 됩니다.

(5) 싱글톤 패턴의 구현과 이해

  • 싱글톤 패턴을 구현한 예제 코드를 분석하고 이해하도록 하겠습니다. 참고로 이 예제 코드는 일반적으로 볼 수 있는 main 메서드가 아닌, 테스트 위치에 구현되어 있습니다. 
package hello.core.singleton; 

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("싱글톤 객체 로직 호출");
    } 
}

 

 

① static 영역에 객체 instance를 미리 하나 생성

  • 첫 번째로, static 영역에 객체 instance를 미리 하나 생성해서 올려둡니다. 이 부분은 싱글톤 패턴의 핵심적인 원칙인 객체의 한 번만 생성을 보장하는 부분을 명확하게 보여줍니다.
  • 클래스가 JVM에 로딩될 때 'static' 키워드를 이용하여 객체를 최초 한 번만 메모리에 할당하는 것입니다. 이렇게 생성된 SingletonService 클래스의 인스턴스는 static 영역에 위치하게 되므로, 이 클래스의 인스턴스는 단 한 번만 생성되고 이 인스턴스는 프로그램 전체에서 공유하여 사용할 수 있습니다.

② getInstance() 메서드를 통한 인스턴스 조회

  • 두 번째로, 이 객체 인스턴스를 필요로 하는 경우, 오직 getInstance() 메서드를 통해서만 조회할 수 있습니다. 이 메서드를 호출하면 항상 같은 인스턴스를 반환합니다.
  • SingletonService 클래스에서 정의된 getInstance() 메서드는 항상 동일한 인스턴스, 즉 처음에 생성된 단일 객체를 반환합니다. 이 메서드를 통해 외부에서 인스턴스에 접근하여 사용할 수 있습니다.
  • 이 메서드는 public으로 선언되어 있어 외부에서 호출이 가능하며, 이 메서드를 이용해 SingletonService 인스턴스를 얻을 수 있습니다.

③ private 생성자를 통한 인스턴스 생성 제한

  • 마지막으로, 딱 1개의 객체 인스턴스만 존재해야 하므로, 생성자를 private으로 막아서 혹시라도 외부에서 new 키워드로 객체 인스턴스가 생성되는 것을 막습니다.
  • SingletonService 클래스의 생성자는 private으로 선언되어 있습니다. 이 선언은 외부에서 new 키워드를 사용해 새로운 인스턴스를 생성하는 것을 방지하기 위함입니다. 이렇게 private 생성자를 이용하면, SingletonService 클래스 외부에서는 SingletonService의 인스턴스를 생성할 수 없고, 따라서 인스턴스가 여러 개 만들어지는 것을 방지할 수 있습니다.
  • 이로 인해 SingletonService 클래스의 인스턴스는 항상 단 하나만 존재하게 됩니다. 이는 싱글톤 패턴의 핵심 원칙인 '단일 인스턴스'를 보장합니다.

(6) 싱글톤 패턴을 사용하는 테스트 코드

  • 이 테스트 코드를 통해 위에서 생성한 싱글톤 패턴 클래스가 어떻게 동작하는지, 그리고 그 특징이 무엇인지 더 자세히 이해할 수 있습니다.
@Test
@DisplayName("싱글톤 패턴을 적용한 객체 사용") 
public void singletonServiceTest() {
    
    //private으로 생성자를 막아두었습니다. 컴파일 오류가 발생합니다.
    //new SingletonService();

    //1. 조회: 호출할 때 마다 같은 객체를 반환
    SingletonService singletonService1 = SingletonService.getInstance(); 

    //2. 조회: 호출할 때 마다 같은 객체를 반환
    SingletonService singletonService2 = SingletonService.getInstance(); 

    //참조값이 같은 것을 확인
    System.out.println("singletonService1 = " + singletonService1); 
    System.out.println("singletonService2 = " + singletonService2);

    // singletonService1 == singletonService2
    assertThat(singletonService1).isSameAs(singletonService2);

    singletonService1.logic(); 
}

 

 

① private으로 new 키워드를 막아두었습니다.

  • SingletonService 클래스의 생성자가 private으로 설정되어 있기 때문에, new 키워드를 통해 외부에서 직접 객체를 생성할 수 없습니다. 이는 싱글톤 패턴의 핵심 원칙 중 하나인 '단일 인스턴스'를 보장하기 위한 것입니다. 따라서 `new SingletonService();`와 같은 코드는 컴파일 오류를 발생시킵니다.

 

② 호출할 때 마다 같은 객체 인스턴스를 반환합니다.

  • `SingletonService.getInstance();` 메서드를 통해 싱글톤 객체를 호출하면, 항상 동일한 객체 인스턴스를 반환합니다. 이는 싱글톤 패턴의 가장 큰 특징으로, 동일한 객체가 필요한 곳에서는 항상 같은 인스턴스를 공유하여 사용하게 됩니다.

 

③ 참조값이 같음을 확인할 수 있습니다.

  • `singletonService1`과 `singletonService2` 두 변수가 실제로 동일한 객체를 참조하고 있는지 확인하기 위해, 두 변수의 참조값을 출력하고 비교합니다. 이때 출력된 두 참조값이 동일하다면, 두 변수는 같은 객체를 참조하고 있다는 것을 의미합니다. 이를 통해 싱글톤 패턴이 정상적으로 적용되었음을 확인할 수 있습니다.
※ 참고사항
 싱글톤 패턴을 구현하는 방법은 여러가지가 있습니다. 이 예제에서는 객체를 미리 생성해두는 가장 단순하고 안전한 방법을 선택하였습니다. 이외에도 'lazy loading' 방식 등 다양한 싱글톤 패턴 구현 방법이 존재하니, 상황에 따라 적절한 방법을 선택하면 됩니다.

(7) 싱글톤 패턴의 문제점

 싱글톤 패턴을 구현하는 코드 자체가 많이 들어갑니다.

  • 싱글톤 패턴은 그 구현을 위해 추가적인 코드를 필요로 합니다. 예를 들어, 생성자를 private으로 만들어야 하고, 인스턴스를 접근할 수 있는 static 메서드를 제공해야 합니다. 이러한 추가적인 코드는 복잡성을 증가시키며, 코드를 이해하고 유지보수하는 데 어려움을 초래할 수 있습니다.

② 의존관계상 클라이언트가 구체 클래스에 의존합니다. DIP를 위반합니다.

  • DIP(Dependency Inversion Principle)는 상위 모듈이 하위 모듈에 의존하지 않도록, 둘 다 추상화에 의존하게 해야 한다는 원칙입니다. 하지만 싱글톤 패턴에서는 클라이언트가 싱글톤 클래스의 구체적인 구현에 의존하게 됩니다. 이로 인해 코드의 유연성과 재사용성이 저하될 수 있습니다.

③ 클라이언트가 구체 클래스에 의존해서 OCP 원칙을 위반할 가능성이 높습니다.

  • OCP(Open-Closed Principle)는 소프트웨어 구성요소(클래스, 모듈, 함수 등)는 확장에는 열려 있어야 하고, 변경에는 닫혀 있어야 한다는 원칙입니다. 싱글톤 패턴에서는 싱글톤 클래스가 변경될 경우, 이를 사용하는 클라이언트 코드도 함께 변경될 가능성이 높습니다. 이는 OCP 원칙을 위반하는 것으로, 유연성과 재사용성에 문제를 일으킬 수 있습니다.

④ 테스트하기 어렵습니다.

  • 싱글톤 패턴은 전역 상태를 만들기 때문에, 다른 테스트 케이스에 영향을 줄 가능성이 있습니다. 따라서 테스트가 어렵고, 특히 병렬 테스트 환경에서는 더욱 문제가 될 수 있습니다.

⑤ 내부 속성을 변경하거나 초기화하기 어렵습니다.

  • 싱글톤 인스턴스의 상태를 변경하거나 초기화하는 것은 매우 까다롭습니다. 왜냐하면 모든 클라이언트가 공유하는 인스턴스이기 때문에, 한 곳에서의 변경이 모든 곳에 영향을 끼치게 됩니다. 

⑥ private 생성자로 자식 클래스를 만들기 어렵습니다.

  • 싱글톤 패턴에서는 생성자가 private이기 때문에, 이 클래스를 상속받는 자식 클래스를 만드는 것이 불가능합니다. 이로 인해 확장성이 제한되며, OOP의 중요한 특징인 상속을 사용할 수 없게 됩니다.

결론적으로, 싱글톤 패턴은 유연성이 떨어집니다.

  • 위에서 언급한 여러 문제점들로 인해, 싱글톤 패턴은 코드의 유연성을 저하시킵니다. 예를 들어, 싱글톤 패턴을 사용하는 경우 코드의 재사용성이 떨어지고, 확장성이 제한됩니다. 

⑧ 안티패턴으로 불리기도 합니다.

  • 위와 같은 이유로, 싱글톤 패턴은 종종 안티패턴으로 분류됩니다. 즉, 피해야 할 설계 방식으로 간주되기도 합니다. 그러나 이는 상황에 따라 다르며, 싱글톤 패턴이 필요하고 적절하게 사용될 수 있는 경우도 많습니다.

3. 싱글톤 컨테이너 

(1) 싱글톤 컨테이너란?

  • 스프링 컨테이너는 프로그램에서 객체를 다루는 방법에 대한 문제를 해결해줍니다.
  • 객체를 다루는 방법 중 하나는 '싱글톤 패턴'인데, 이는 특정 객체가 프로그램 전체에서 딱 한 번만 만들어지고, 필요한 곳에서는 그 객체를 공유해서 사용하는 방식입니다. 하지만 싱글톤 패턴에는 문제점이 있습니다. 테스트가 어렵고, 코드가 복잡해지는 경향이 있습니다. 
  • 스프링 컨테이너는 이런 문제점을 해결하면서도 싱글톤 방식으로 객체를 관리해줍니다. 지금까지 학습했던 스프링 빈은 바로 이런 싱글톤 방식으로 관리되는 객체입니다.
  • 스프링 컨테이너는 이렇게 스프링 빈을 프로그램 전체에서 공유하도록 도와줌으로써, 메모리를 효율적으로 사용하고, 데이터의 일관성을 유지할 수 있게 돕습니다. 결국, 스프링 컨테이너는 싱글톤 패턴의 단점을 해결하면서도, 객체를 싱글톤 방식으로 효율적으로 관리해주는 역할을 합니다. 이러한 특성은 스프링 프레임워크의 핵심 기능 중 하나입니다.

(2) 싱글톤 컨테이너의 역할 

  • 스프링 컨테이너는 싱글톤 패턴을 코드에 직접 적용하지 않아도, 객체 인스턴스를 싱글톤으로 관리하는 역할을 합니다.
    • 싱글톤이란, 전체 프로그램에서 단 하나의 인스턴스만 생성되어 사용되는 것을 말합니다. 이러한 과정은 컨테이너 생성 과정에서 이루어집니다. 컨테이너가 생성되는 시점에 객체를 하나만 생성하고, 이후에는 그 인스턴스를 재사용합니다. 이렇게 하면 메모리 사용을 효율적으로 관리할 수 있습니다.
  • 스프링 컨테이너는 이렇게 생성된 싱글톤 객체를 관리하는 역할을 합니다. 이 기능을 통해 싱글톤 객체를 관리하는 구조를 '싱글톤 레지스트리'라고 부릅니다.
  • 스프링 컨테이너의 이런 기능 덕분에, 싱글톤 패턴의 모든 단점을 해결하면서도 객체를 싱글톤으로 유지할 수 있습니다.
    • 즉, 싱글톤 패턴을 위한 복잡하고 지저분한 코드를 작성할 필요가 없습니다.
    • 또한, DIP(Dependency Inversion Principle), OCP(Open-Closed Principle) 같은 설계 원칙을 지키면서도 테스트의 용이성을 보장하고, private 생성자의 제약으로 부터 자유롭게 싱글톤을 사용할 수 있습니다. 이는 스프링 컨테이너가 싱글톤 패턴의 장점만을 취하고 단점을 보완하여, 개발의 효율성을 높이는 역할을 한다는 것을 의미합니다.

(3) 싱글톤 컨테이너를 사용하는 테스트 코드 

  • 밑의 코드는 스프링 컨테이너를 테스트하기 위한 자바 코드입니다. 특히, 스프링 컨테이너가 싱글톤 패턴을 어떻게 적용하는지를 검증하는 테스트 코드입니다.
@Test
@DisplayName("스프링 컨테이너와 싱글톤")
void springContainer() {

    //스프링 컨테이너 생성
    ApplicationContext ac = new
    AnnotationConfigApplicationContext(AppConfig.class);
     
     //1. 조회: 호출할 때 마다 같은 객체를 반환
     MemberService memberService1 = ac.getBean("memberService",
    	MemberService.class);
     
     //2. 조회: 호출할 때 마다 같은 객체를 반환
     MemberService memberService2 = ac.getBean("memberService",
   	 	MemberService.class);
     
     //참조값이 같은 것을 확인
     System.out.println("memberService1 = " + memberService1);
     System.out.println("memberService2 = " + memberService2);
     
     //memberService1 == memberService2
     assertThat(memberService1).isSameAs(memberService2);
}

 

스프링 컨테이너 적용 후


3. 싱글톤 방식의 주의점 

(1) 싱글톤 방식 사용 시 주의사항

  • 싱글톤 방식을 사용할 때는 특별히 주의해야 할 몇 가지 사항들이 존재합니다.
  • 싱글톤 패턴을 이용하던가, 스프링 컨테이너를 활용하던가, 어떤 방법을 택하든 간에 객체 인스턴스를 하나만 생성하여 여러 클라이언트와 공유하는 싱글톤 방식에서는 특정 상태를 유지하는 설계를 피해야 합니다.
  • 이는 여러 클라이언트가 동일한 객체 인스턴스를 공유하게 되면서 발생하는 문제점 때문입니다. 

 

① 싱글톤 객체의 무상태 설계

  • 이렇게 싱글톤 객체는 무상태(stateless)로 설계해야 하는데, 이것은 특정 클라이언트에 의존적인 필드가 존재해서는 안 되며, 특정 클라이언트가 값을 변경할 수 있는 필드 역시 존재해서는 안 된다는 의미입니다.
  • 이러한 설계 원칙은 객체의 안정성을 보장하며, 클라이언트 간의 충돌을 최소화하기 위함입니다. 따라서 객체가 읽기만 가능하도록, 즉 변경이 불가능하도록 설계해야 합니다. 

공유되지 않는 변수의 사용

  • 더불어 싱글톤 방식에서는 공유되는 필드 대신에 공유되지 않는 지역변수, 파라미터, ThreadLocal 등 활용해야 합니다. 이러한 설계 원칙은 여러 클라이언트가 동시에 접근해도 서로의 작업에 영향을 끼치지 않기 위한 것입니다. 이런 방식으로 클라이언트 간의 독립성을 보장하고, 동시에 발생할 수 있는 문제점을 예방합니다.

③ 스프링 빈의 무상태 설계

  • 마지막으로, 스프링 빈의 필드에 공유 값을 설정하면 큰 문제가 발생할 수 있습니다. 이는 여러 클라이언트가 동일한 스프링 빈을 공유하게 되므로, 한 클라이언트가 값을 변경하면 다른 클라이언에게도 영향을 미치게 됩니다.
  • 이런 상황을 방지하기해 스프링 빈은 무상태로 설계해야 합니다. 이렇게 함써 각 클라이언트는 독립적으로 동작하며, 다른 클라이언트의 작업에 영향을 미치지 않게 됩니다. 이는 서비스의 안정성을 높이는 중요한 요소입니다.

(2) 싱글톤 객체의 상태를 유지할 경우 발생하는 문제점 예시 

 밑의 코드는 상태를 유지하는 싱글톤 객체 `StatefulService`의 문제점을 보여주는 테스트 코드입니다.

 

`StatefulService` 클래스

  • `StatefulService` 클래스는 `price`라는 상태를 유지하는 필드를 가지고 있습니다. `order` 메서드를 호출할 때마다 `price` 필드의 값이 변경되며, `getPrice` 메서드를 통해 해당 값을 조회할 수 있습니다.
package hello.core.singleton;

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


② 문제점

  • 그런데 여기서 문제가 발생하는데, 그 이유는 `StatefulService`가 스프링 빈으로 등록되어 싱글톤 형태로 관리되기 때문입니다. 싱글톤 빈은 여러 클라이언트에게 공유되므로, 한 클라이언트가 상태를 변경하면 다른 클라이언트에게도 영향을 미칩니다.

 

③ 테스트 코드

  • 예시 테스트 코드에서는 두 개의 클라이언트(사용자 A와 사용자 B)가 `StatefulService`를 이용해 주문을 합니다.
package hello.core.singleton;
import org.assertj.core.api.Assertions;
import org.junit.jupiter.api.Test;
import org.springframework.context.ApplicationContext;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import org.springframework.context.annotation.Bean;

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);
        
         //ThreadA: A사용자 10000원 주문
         statefulService1.order("userA", 10000);
         //ThreadB: B사용자 20000원 주문
         statefulService2.order("userB", 20000);
         
         //ThreadA: 사용자A 주문 금액 조회
         int price = statefulService1.getPrice();
         
         //ThreadA: 사용자A는 10000원을 기대했지만, 기대와 다르게 20000원 출력
         System.out.println("price = " + price);
         Assertions.assertThat(statefulService1.getPrice()).isEqualTo(20000);
     }
     
     static class TestConfig {
         
         @Bean
         public StatefulService statefulService() {
         	return new StatefulService();
         }
     }
}
  • 사용자 A가 먼저 10,000원을 주문하고, 그 다음에 사용자 B가 20,000원을 주문합니다. 이후 사용자 A가 주문 금액을 조회하면, 원하던 10,000원이 아니라 사용자 B가 주문한 20,000원이 반환됩니다. 이는 사용자 B의 `order` 메서드 호출이 `price` 필드 값을 변경했기 때문입니다.

④ 결론

  • 이 예제는 상태를 유지하는 싱글톤 빈의 문제점을 잘 보여줍니다. 이를 해결하려면, 싱글톤 빈은 무상태(stateless)로 설계해야 합니다. => 즉, 클라이언트 간에 상태를 공유하지 않도록 해야 합니다.
  • 이 설명을 최대한 단순하게 하기 위해 실제 쓰레드는 사용하지 않았습니다. 대신 ThreadA가 사용자A 코드를 호출하고 ThreadB가 사용자B 코드를 호출한다고 가정하였습니다.
  • 여기서 `StatefulService`의 `price` 필드는 공유되는 필드로, 특정 클라이언트가 값을 변경하였습니다. 이 때문에 사용자A의 주문금액은 10,000원이 되어야 하는데, 20,000원이라는 결과가 나왔습니다.
  • 실무에서 이런 경우를 종종 보게 되며, 이로 인해 매우 해결하기 어려운 큰 문제들이 발생합니다. 이러한 문제는 몇 년에 한 번씩 꼭 만나게 되는데, 이는 공유 필드 때문입니다.
  • 따라서 진짜 공유 필드는 조심해야 하며, 스프링 빈은 항상 무상태로 설계해야 합니다. 이렇게 함으로써 각 클라이언트 요청이 독립적으로 동작하게 되어, 다른 클라이언트의 요청에 영향을 받지 않게 됩니다.

4. @Configuration과 싱글톤

(1) @Configuration과 싱글톤에 대한 의문점 

  • `@Configuration` 어노테이은 클래스를 스프링 설정 클래스로 지정하며, 클래스 내부의 `@Bean` 어노테이션이 붙은 메서드를 통해 스프링 빈 객체를 생성하고 관리하게 됩니다.
  • 밑의 코드는 `@Configuration` 어노테이션을 사용하여 빈 객체를 생성하고 구성하는 `AppConfig` 클래스를 보여줍니다. 그런데 이상한 점이 있습니다.
@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();
     }
    
    ...
}

① `memberService` 빈 생성

  • `memberService()` 메서드는 `MemberService` 타입의 빈을 생성합니다. 이 메서드 내부에서 `memberRepository()` 메서드를 호출하여 `MemberRepository` 타입의 빈을 주입받습니다. `memberRepository()` 메서드는 `new MemoryMemberRepository()`를 호출하여 `MemoryMemberRepository` 인스턴스를 생성합니다.

② `orderService` 빈 생성

  • 마찬가지로 `orderService()` 메서드는 `OrderService` 타입의 빈을 생성합니다. 이 메서드 내부에서도 `memberRepository()` 메서드를 호출하여 `MemberRepository` 타입의 빈을 주입받습니다. `memberRepository()` 메서드는 `new MemoryMemberRepository()`를 호출하여 `MemoryMemberRepository` 인스턴스를 생성합니다.

※ 의문점

  • 결과적으로 각각 다른 2개의 MemoryMemberRepository가 생성되면서 싱글톤이 깨지는 것 처럼 보입니다.

(2) @Configuration과 싱글톤에 대한 테스트 코드 

  • 스프링 프레임워크에서는 기본적으로 싱글톤 패턴을 적용하여 빈 객체를 관리합니다. 하지만 위의 코드를 보면, `AppConfig`에서 `memberService()`와 `orderService()` 메서드가 각각 `memberRepository()`를 호출하여 `MemoryMemberRepository` 인스턴스를 생성하는 것 같아 보입니다. 이로 인해 싱글톤이 깨지는 것처럼 보입니다.
  • 싱글톤이 정상적으로 동작하는 지 확인하기 위한 테스트 코드를 작성해보겠습니다.
public class MemberServiceImpl implements MemberService {
     private final MemberRepository memberRepository;
     
     //테스트 용도
     public MemberRepository getMemberRepository() {
     	return memberRepository;
     }
}
public class OrderServiceImpl implements OrderService {
     private final MemberRepository memberRepository;
    
    //테스트 용도
     public MemberRepository getMemberRepository() {
     	return memberRepository;
     }
}
  • 싱글톤이 정상적으로 동작하는지 확인하기 위해, `MemberServiceImpl`과 `OrderServiceImpl` 클래스에 `getMemberRepository()` 메서드를 추가합니다.
  • 이 메서드는 `memberRepository` 필드를 반환하므로, 해당 서비스가 사용하는 `MemberRepository` 인스턴스를 직접 확인할 수 있습니다. 이는 잠시 테스트를 위해 추가하는 것이므로, 인터페이스에는 조회 기능을 추가하지 않습니다.
package hello.core.singleton;

import hello.core.AppConfig;
import hello.core.member.MemberRepository;
import hello.core.member.MemberServiceImpl;
import hello.core.order.OrderServiceImpl;
import org.junit.jupiter.api.Test;
import org.springframework.context.ApplicationContext;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import static org.assertj.core.api.Assertions.*;

public class ConfigurationSingletonTest {
     @Test
     void configurationTest() {
         
         ApplicationContext ac = new
        	AnnotationConfigApplicationContext(AppConfig.class);
         
         MemberServiceImpl memberService = ac.getBean("memberService",
        	MemberServiceImpl.class);
         OrderServiceImpl orderService = ac.getBean("orderService",
        	OrderServiceImpl.class);
         MemberRepository memberRepository = ac.getBean("memberRepository",
        	MemberRepository.class);
         
         //모두 같은 인스턴스를 참고하고 있다.
         System.out.println("memberService -> memberRepository = " +
        	memberService.getMemberRepository());
         System.out.println("orderService -> memberRepository = " +
        	orderService.getMemberRepository());
        
         System.out.println("memberRepository = " + memberRepository);
         //모두 같은 인스턴스를 참고하고 있다.

        assertThat(memberService.getMemberRepository()).isSameAs(memberRepository);
        assertThat(orderService.getMemberRepository()).isSameAs(memberRepository);
     }
}

 

  • 위 테스트 코드는 `ApplicationContext`를 통해 `memberService`, `orderService`, `memberRepository` 빈을 각각 조회합니다.
  • 그 후 `memberService`와 `orderService`가 사용하는 `MemberRepository` 인스턴스가 `AppConfig`에서 직접 생성한 `memberRepository` 인스턴스와 동일한지 확인합니다.
  • 테스트 결과를 보면, `memberService`와 `orderService`가 사용하는 `MemberRepository` 인스턴스는 모두 동일한 `MemoryMemberRepository` 인스턴스를 참조하고 있습니다.
  • 이 결과를 통해 스프링 컨테이너가 `@Configuration` 어노테이션을 통해 `@Bean` 어노테이션이 붙은 메서드가 호출될 때 항상 같은 인스턴스를 반환하도록 보장하며, 이를 통해 싱글톤 패턴이 유지되는 것을 확인할 수 있습니다.

(3) AppConfig에 추가로 호출 로그를 작성하기

  • `AppConfig`의 자바 코드를 살펴보면, `new MemoryMemberRepository()`가 각각 두 번 호출되는 것을 확인할 수 있습니다. 이렇게 보면, 두 개의 다른 `MemoryMemberRepository` 인스턴스가 생성되어야 할 것 같습니다.
  • 그러나 실제로는 그렇지 않습니다. 어떻게 이런 일이 발생할 수 있을까요? 이 현상이 발생하는 이유를 알아보기 위해, 실험을 진행해보겠습니다.
package hello.core;

import hello.core.discount.DiscountPolicy;
import hello.core.discount.RateDiscountPolicy;
import hello.core.member.MemberRepository;
import hello.core.member.MemberService;
import hello.core.member.MemberServiceImpl;
import hello.core.member.MemoryMemberRepository;
import hello.core.order.OrderService;
import hello.core.order.OrderServiceImpl;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class AppConfig {
     @Bean
     public MemberService memberService() {
         //1번
         System.out.println("call AppConfig.memberService");
         return new MemberServiceImpl(memberRepository());
     }
     
     @Bean
     public OrderService orderService() {
         //1번
         System.out.println("call AppConfig.orderService");
         return new OrderServiceImpl(
         memberRepository(),
         discountPolicy());
     }
     
     @Bean
     public MemberRepository memberRepository() {
         //2번? 3번?
         System.out.println("call AppConfig.memberRepository");
         return new MemoryMemberRepository();
     }
     
     @Bean
     public DiscountPolicy discountPolicy() {
         return new RateDiscountPolicy();
     }
}

 

  • 위의 코드를 실행하면, `memberService()`와 `orderService()` 메서드에서 각각 `memberRepository()` 메서드를 호출함에도 불구하고, `memberRepository()` 메서드는 한 번만 호출되는 것을 확인할 수 있습니다.
  • 즉, `memberRepository()` 메서드가 여러 번 호출되더라도, 스프링 컨테이너는 한 번 생성한 `MemoryMemberRepository` 인스턴스를 계속 반환하며, 이를 통해 싱글톤 패턴이 유지되는 것을 확인할 수 있습니다.
  • 이렇게 스프링 컨테이너는 `@Bean` 어노테이션을 통해 생성된 빈 객체를 관리하며, 해당 빈 객체가 필요할 때마다 동일한 인스턴스를 반환하도록 보장합니다.
  • 이는 자원을 효율적으로 관리하며, 동시에 애플리케이션 전체에서 상태를 공유하는 싱글톤 패턴을 적용하는 데에 유용합니다.

5. @Configuration과 바이트코드 조작의 마법

1) 스프링 컨테이너와 싱글톤 패턴

  • 스프링 컨테이너는 기본적으로 싱글톤 레지스트리로 작동합니다. 이는 스프링 컨테이너가 관리하는 모든 빈이 싱글톤 패턴을 따르도록 보장해야 함을 의미합니다. 그러나 이를 자바 코드만으로 완벽하게 구현하는 것은 어렵습니다.

2) AppConfig의 메서드 호출 문제

  • 위 예시의 `AppConfig` 클래스에는 `memberRepository()`라는 메서드가 있으며, 이 메서드는 `memberService()`, `orderService()`, 그리고 스프링 컨테이너가 `@Bean` 어노테이션을 통해 스프링 빈을 등록하는 과정에서 총 세 번 호출되어야 합니다.
  • 그러나 실제로는 `memberRepository()` 메서드가 한 번만 호출되는 것을 확인할 수 있습니다.

3) 테스트 코드를 통한 원인 파악

  • 이 동작에 대한 이유를 확인하기 위해 다음과 같은 테스트 코드를 작성해보았습니다.
@Test
void configurationDeep() { 
	ApplicationContext ac = new
		AnnotationConfigApplicationContext(AppConfig.class);

	//AppConfig도 스프링 빈으로 등록된다.
	AppConfig bean = ac.getBean(AppConfig.class); 

	//출력: bean = class hello.core.AppConfig$$EnhancerBySpringCGLIB$$bd479d70
	System.out.println("bean = " + bean.getClass());
}
  • 위 코드를 실행하면, 스프링 컨테이너에 등록된 `AppConfig` 빈의 클래스 정보를 출력합니다. 만약 `AppConfig`가 순수한 클래스라면, `class hello.core.AppConfig`라고 출력되어야 합니다.
  • 그러나 실제 출력 결과는 `class hello.core.AppConfig$$EnhancerBySpringCGLIB$$bd479d70`와 같이 CGLIB가 붙은 복잡한 클래스 명을 출력합니다.

4) 스프링의 바이트코드 조작

  • 이는 스프링이 CGLIB라는 바이트코드 조작 라이브러리를 사용하여 `AppConfig` 클래스를 상속받은 임의의 다른 클래스를 만들고, 그 다른 클래스를 스프링 빈으로 등록한 결과입니다.

  • 이 임의의 다른 클래스는 `@Bean` 어노테이션이 붙은 메서드가 호출될 때마다 이미 스프링 컨테이너에 등록된 빈이 있으면 그 빈을 반환하고, 없으면 새로 생성하여 스프링 컨테이너에 등록하고 반환하는 코드가 동적으로 추가된 클래스입니다. (실제로는 CGLIB의 내부 기술을 사용하는데 매우 복잡합니다.)

5) AppConfig@CGLIB 예상 코드

  •  스프링이 `@Configuration` 어노테이션이 붙은 `AppConfig` 클래스의 바이트코드를 조작하여 생성하는 AppConfig@CGLIB의 메서드는 아래와 같은 동작을 합니다.
@Bean
public MemberRepository memberRepository() {

    if (memoryMemberRepository가 이미 스프링 컨테이너에 등록되어 있으면?) { 
    
        // 스프링 컨테이너에서 등록된 MemoryMemberRepository 빈을 반환합니다.
        return 스프링 컨테이너에서 찾아서 반환;
        
    } else { 
    
        // 스프링 컨테이너에 MemoryMemberRepository 빈이 등록되어 있지 않다면, 
        // 기존의 로직을 호출하여 MemoryMemberRepository 객체를 생성하고,
        // 이를 스프링 컨테이너에 등록합니다.
        기존 로직을 호출해서 MemoryMemberRepository를 생성하고 스프링 컨테이너에 등록 
        
        // 그리고 이 newly created 빈을 반환합니다.
        return 반환
    } 
}

6) 동적으로 만들어진 코드의 작동 원리

  • `@Bean`이 붙은 각 메서드마다 스프링 컨테이너는 먼저 해당 빈이 이미 등록되어 있는지를 확인합니다. 만약 빈이 존재하면, 이를 반환하고, 그렇지 않다면 메서드를 호출하여 새로운 객체를 생성하고 이를 스프링 컨테이너에 등록한 뒤 반환합니다. 이런 코드가 각 `@Bean` 메서드마다 동적으로 추가되어 있습니다.
  • 이렇게 동적으로 생성된 코드 덕분에, `@Configuration` 클래스에서 `@Bean` 어노테이션이 붙은 메서드를 호출해도 항상 동일한 싱글톤 빈 객체가 반환되는 것을 보장할 수 있습니다. 즉, 스프링 컨테이너는 이런 방식으로 싱글톤 패턴을 보장합니다.

7) AppConfig@CGLIB와 AppConfig의 관계

  • 마지막으로, AppConfig@CGLIB는 AppConfig 클래스를 상속받은 자식 클래스이므로, AppConfig 타입으로 조회할 수 있습니다. 즉, `ApplicationContext.getBean(AppConfig.class)`와 같은 코드로 AppConfig@CGLIB 객체를 가져올 수 있습니다.

8) @Configuration 없이 @Bean만 적용할 경우 

  • 스프링 프레임워크에서 `@Configuration` 어노테이션은 바이트코드를 조작하는 CGLIB 기술을 사용해 싱글톤 패턴을 보장합니다.
  • 그렇다면, 만약 `@Bean` 어노테이션만 사용하고 `@Configuration` 어노테이션을 사용하지 않으면 어떻게 동작할까?
//@Configuration 삭제 
public class AppConfig {

}
  • 위와 같이 `@Configuration` 어노테이션을 제거하고 실행해보면, 아래와 같은 출력 결과를 볼 수 있습니다.
bean = class hello.core.AppConfig
  • 이 출력 결과를 통해 `AppConfig`가 CGLIB 기술 없이 순수한 `AppConfig` 클래스로 스프링 빈에 등록되었음을 확인할 수 있습니다.
  • 또한, `@Bean`이 붙은 메서드들이 호출되는 과정을 살펴보면 다음과 같습니다.
call AppConfig.memberService
call AppConfig.memberRepository
call AppConfig.orderService
call AppConfig.memberRepository
call AppConfig.memberRepository
  • 이 출력 결과를 보면, `MemberRepository`가 총 3번 호출된 것을 알 수 있습니다. 1번은 `@Bean` 어노테이션에 의해 스프링 컨테이너에 등록되기 위한 호출이고, 나머지 2번은 `memberRepository()` 메서드를 호출하는 과정에서 발생한 것입니다.
  • 이를 통해, `@Configuration` 어노테이션이 없는 경우 `@Bean`이 붙은 메서드가 호출될 때마다 새로운 객체가 생성되며, 이는 싱글톤 패턴이 깨지는 것을 의미합니다.

9) 인스턴스 비교 테스트 결과 

memberService -> memberRepository =
hello.core.member.MemoryMemberRepository@6239aba6 
orderService -> memberRepository  =
hello.core.member.MemoryMemberRepository@3e6104fc
memberRepository = hello.core.member.MemoryMemberRepository@12359a82
  • 위의 결과를 보면, `memberService`, `orderService`, `memberRepository` 각각이 서로 다른 `MemoryMemberRepository` 인스턴스를 가지고 있음을 알 수 있습니다.
  • 이는 `@Configuration` 어노테이션 없이 `@Bean` 어노테이션만 사용했을 때, 싱글톤 패턴이 깨진다는 것을 확인하는 실험 결과입니다.
  • 이 실험을 통해, `@Configuration` 어노테이션의 중요성을 확인할 수 있습니다. `@Configuration` 어노테이션을 사용하면 스프링 컨테이너가 `@Bean` 어노테이션이 붙은 메서드를 호출할 때마다 동일한 객체를 반환하도록 보장하며, 이는 싱글톤 패턴을 유지하는 데 필수적인 역할을 합니다.
  • 따라서 싱글톤 패턴을 유지하려면 `@Configuration` 어노테이션을 꼭 사용해야 합니다.

10) 정리 및 요약 

  • `@Bean` 어노테이션만을 사용하여 스프링 빈을 등록할 경우, 해당 빈은 스프링 컨테이너에 등록됩니다. 그러나 이렇게 `@Bean` 어노테이션만을 사용한 경우, 스프링은 싱글톤 패턴을 보장하지 않습니다.
  • 예를 들어, `memberRepository()`와 같이 다른 빈에 의존성이 주입되어야 하는 메서드를 직접 호출할 경우, `@Bean` 어노테이션만 사용하면 메서드가 호출될 때마다 새로운 객체가 생성됩니다. 이는 싱글톤 패턴이 깨진 것을 의미합니다.
  • 따라서, 스프링에서 싱글톤 패턴을 보장하려면, `@Configuration` 어노테이션을 반드시 사용해야 합니다. `@Configuration` 어노테이션은 스프링 컨테이너가 `@Bean` 어노테이션이 붙은 메서드를 호출할 때마다 동일한 객체를 반환하도록 보장합니다.
  • 결국, 스프링 설정 정보를 작성할 때에는 항상 `@Configuration`을 사용해야 하며, 이는 싱글톤 패턴을 보장하는 데 필수적인 요소입니다. 이러한 점을 이해하고 있으면, 스프링 빈의 생명주기와 싱글톤 패턴에 대한 깊은 이해를 갖게 됩니다.