
싱글톤(Singleton)은 객체지향 프로그래밍에서 사용되는 디자인 패턴 중 하나로, 특정 클래스의 인스턴스가 프로그램 내에서 단 하나만 존재하도록 보장하는 패턴이다.
이전에 우리는 스프링이 제공하는 컨테이너 기능에 대해 학습했다. AppConfig 클래스에 @Configuration을 붙이고, 사용할 객체를 @Bean으로 등록하여 스프링 컨테이너가 이를 관리하도록 했다.
스프링 부트 입문에서 이미 @Bean으로 관리되는 객체들은 컨테이너에 하나만 존재하며 공유해서 사용된다는 개념을 배웠다. 이번 챕터에서는 "하나만 존재해야 하는" 이 원칙의 필요성을 실습과 사례를 통해 직접 느껴보도록 하자. 이는 스프링의 싱글톤(Singleton) 원칙과 긴밀하게 연결되어 있다.

위의 그림은 클라이언트 요청마다 DI 컨테이너가 해당 클래스의 객체를 각각 생성해서 반환하는 경우를 나타낸다.
위 그림에서 3개의 동시 요청을 보여주고 있다.
3개의 동시 요청일 때 요청에 필요한 3개의 객체를 생성해서 사용하는 것에 문제가 없어 보인다.
그러나 요청이 50만 개로 증가한다면 상황은 달라진다.
요청마다 새로운 객체를 생성해야 하므로 50만 개의 객체가 생성되며, 요청이 종료되면 사용된 객체를 자원 해제해야 한다. 이러한 작업은 메모리와 CPU를 포함한 시스템 리소스를 과도하게 소모하게 된다.
즉, 단순히 요청 수가 많다는 이유로 매번 새로운 객체를 생성하고 해제하는 것은 성능과 비용 면에서 큰 문제가 된다. 이를 해결하기 위해 스프링은 싱글톤(Singleton) 컨테이너를 통해 객체를 하나만 생성하고 공유해서 사용하도록 한다.
싱글톤 패턴을 적용하면, 고객의 요청이 올 때마다 객체를 새로 생성하지 않고 이미 만들어진 객체를 공유하여 효율적으로 사용할 수 있다.
싱글톤 패턴은 하나의 객체를 생성해놓고 이를 여러 요청에서 사용하여 객체 하나의 용량으로만 모든 요청을 커버하는 장점을 위에서 설명했다.
이에 따른 단점도 존재하는데,
싱글톤 패턴은 다음과 같은 몇 가지 문제점을 가지고 있다:
1. 구현을 위한 추가 코드가 필요하다.
3. private 생성자로 인해 자식 클래스를 만들기 어렵다.
public class SingletonService {
// static 필드로 인스턴스를 하나만 생성
private static final SingletonService instance = new SingletonService();
// 생성자를 private으로 만들어 외부에서 new 못하게 막음
private SingletonService() {}
// 외부에서 인스턴스를 얻는 유일한 방법
public static SingletonService getInstance() {
return instance;
}
public void logic() {
System.out.println("싱글톤 객체의 로직 호출");
}
}
위 코드는 싱글톤 스타일의 클래스를 직접 작성했을 때의 코드이다. 우리가 사용하는 DI를 활용할 싱글톤 객체마다 위 코드의 형식을 작성해야한다면 매우 귀찮은 작업이 될 것이다.(보일러 플레이트 코드 문제)
@Service // 또는 @Component
public class UserService {
// 이 클래스는 싱글톤처럼 관리됨
}
스프링은 설정 파일(XML 또는 Java Config) 또는 애노테이션만으로 싱글톤 스타일을 적용해주어 이 문제를 극복한다.
public class SingletonService {
private static final SingletonService instance = new SingletonService();
private SingletonService() {
System.out.println("생성자 호출");
}
public static SingletonService getInstance() {
return instance;
}
public void logic() {
System.out.println("싱글톤 로직 실행");
}
}
public class CustomSingletonService extends SingletonService {
// ❌ 컴파일 에러: SingletonService() has private access
}
생성자가 private이므로 상속 자체가 컴파일 타임에서 막힌다.(자식 클래스의 생성자는 반드시 부모 클래스의 생성자를 먼저 호출해야하기 때문이다.)
스프링의 해결은 단순하다. 그냥 public으로 생성자를 열어버린다. 어차피 Bean 객체의 생성은 개발자가 수행하는 것이 아니라 컨테이너가 생성하고 관리하기 때문이다.
굉장히 싱거운 해결법이지만 이것이 스프링 철학이다.
아니 무슨 소리냐 private을 통해 원천 차단으로 인한 장점을 버리는 것이 스프링 철학이라고? 실수할 놈들에 대한 차단이 필요하지 않나?
MyService myService = new MyService();
이렇게 실수할 수도 있다.
하지만,
이렇게 작성하는 개발자는 스프링 DI 구조를 무시하는 것이고 (테스트면 괜찮다. 오히려 public으로 열었기에 테스트 코드에서 직접 생성하는 코드 작성이 가능하다.)
명백히 잘못된 사용이기에 스프링은 가장 먼저 알아야할 자신들의 철학을 무시한 개발자들을 존중하지 않는 것을 선택했다. 스프링은 단순히 컨벤션을 제공하고 개발자가 이를 이해하고 따르길 기대하는 것이다.
스프링은 이러한 단점들을 최대한 상쇄하면서도 메모리 비용 감소와 같은 싱글톤 패턴의 장점만을 취하도록 설계되었다. 스프링 컨테이너는 싱글톤 패턴의 장점을 살린 유연한 싱글톤 방식으로 객체를 관리하여 효율성과 확장성을 동시에 제공한다.
정말 같은 싱글톤 빈 객체를 제공하는지 마지막으로 테스트 코드를 작성해보자.
@Test
@DisplayName("스프링 컨테이너와 싱글톤")
void springContainer() {
AnnotationConfigApplicationContext 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);
}
위와 같은 테스트에서 ac.getBean을 통해 Config에서 관리되는 Bean인 memberService 객체를 두 번 호출했고, 모두 동일한 것을 테스트를 통해 확인 가능하다.
isSameAs : isEqualto와 달리 동일성을 체크한다.(isEqual vs == )을 생각하면 될 듯 하다. 즉 위의 코드의 memberService1와 memberService2는 메모리 주소까지 같다.스프링의 싱글톤 방식에서는 하나의 객체를 여러 곳에서 공유하기 때문에, 공유 객체가 수정되면 안 된다는 점에 주의해야 한다.
아래의 예시 코드를 참고하면, 객체가 공유되는 상황에서 필드에 대해 내부 수정이 발생할 수 있다는 것을 알 수 있다. 이는 싱글톤 방식에서 절대 발생하면 안 되는 상황이다. 공유 객체의 내부 상태가 변경되면, 다른 곳에서 예상하지 못한 오류나 데이터 불일치가 발생할 수 있다.
이러한 문제를 방지하기 위해, 공유 객체는 반드시 무상태(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;
}
}
//////// /////// ////////
class StatefulServiceTest {
@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);
int price = statefulService1.getPrice();
System.out.println("price = " + price);
Assertions.assertThat(statefulService1.getPrice()).isEqualTo(20000);
}
static class TestConfig {
@Bean
public StatefulService statefulService() {
return new StatefulService();
}
}
}
StatefulService의 경우, 내부적으로 임시로 사용할 메서드 TestConfig에서 @Bean을 통해 Spring Bean(싱글톤)으로 등록되었다. 그러나 StatefulService의 내부를 보면, order 메서드가 호출되면서 price 필드가 수정될 수 있다.
단위 테스트의 흐름을 따라가면 다음과 같은 문제가 발생한다:
1. statefulService1과 statefulService2는 같은 참조값을 가진다.
2. statefulService1.order("userA", 10000);: @2438dcd 참조를 가진 statefulService 객체의 price를 10000으로 변경한다.
3. statefulService2.order("userB", 20000);: 같은 @2438dcd 참조를 가진 statefulService 객체의 price를 20000으로 변경한다.
결국, 같은 객체를 공유하기 때문에 statefulService 인스턴스의 상태는 마지막 실행인 statefulService2.order("userB", 20000);에 따라가게 된다. 이처럼 공유 필드가 수정 가능한 상태로 설계된 경우, 의도하지 않은 상태 변경이 발생할 수 있어 매우 주의가 필요하다.
만약 실무에서 이러한 문제가 발생한다면, 다음과 같은 치명적인 결과를 초래할 수 있다:
Spring Bean은 항상 무상태(stateless)로 설계하자.
공유 필드를 제거하고, 요청마다 독립적인 데이터를 처리할 수 있도록 설계함으로써 이러한 문제를 예방해야 한다.
@Configuration
public class AppConfig {
@Bean
public MemberService memberService() {
return new MemberServiceImpl(memberRepository());
}
@Bean
public MemberRepository memberRepository() {
return new MemoryMemberRepository();
}
@Bean
public OrderService orderService() {
return new OrderServiceImpl(memberRepository(), discountPolicy());
}
@Bean
public DiscountPolicy discountPolicy() {
// return new FixDiscountPolicy();
return new RateDiscountPolicy();
}
}
AppConfig에서 이상한 지점을 발견할 수 있다. 우리는 memberService()가 싱글톤 패턴에 따라 스프링 컨테이너에 객체 하나만 존재한다는 점은 이해했다. 그러나 MemberServiceImpl(memberRepository())에서 memberRepository()를 호출하고, orderService()에서도 memberRepository()를 호출한다.
memberRepository() 코드를 보면 new 키워드를 사용해 새로운 객체를 생성하고 반환한다. 따라서, memberService()와 orderService()는 각각 서로 다른 memberRepository 객체를 가져야 하지 않을까라는 의문이 생길 수 있다.
싱글톤이 보장되기 위해서는 위의 문제에서 두 메서드(memberService()와 orderService())가 동일한 memberRepository 객체를 사용해야 한다. 그러나 스프링이 아무리 강력한 프레임워크라도 자바 코드의 논리, 즉 memberRepository() 메서드가 호출될 때마다 새로운 객체를 생성하는 동작을 벗어날 수는 없다.
@Test
void configurationTest() {
AnnotationConfigApplicationContext 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);
MemberRepository memberRepository1 = memberService.getMemberRepository();
MemberRepository memberRepository2 = orderService.getMemberRepository();
System.out.println("memberRepository = " + memberRepository);
System.out.println("memberService -> memberRepository = " + memberRepository1);
System.out.println("orderService -> memberRepository = " + memberRepository2);
assertThat(orderService.getMemberRepository()).isSameAs(memberRepository);
assertThat(memberService.getMemberRepository()).isSameAs(orderService.getMemberRepository());
}
AppConfig 빈 메서드들에 System.out.println("call AppConfig.memberService");와 같이 print찍어본 결과
1번씩만 생성되는 것을 볼 수 있다.
@Configuration이 빈의 싱글톤을 유지시켜주고있다고 보면 될 것이다.
스프링은 CGLIB라는 바이트코드 조작 라이브러리를 사용하여, AppConfig 클래스를 상속받은 임의의 다른 클래스를 생성하고, 이 클래스를 스프링 빈으로 등록한다.
@Bean이 붙은 메서드마다:
이 과정은 CGLIB를 통해 동적으로 생성된 코드로 구현되며, 이를 통해 싱글톤을 완벽히 보장할 수 있다.
만약 @Configuration을 제거하고 테스트 코드를 실행하면, 스프링은 위와 같은 CGLIB 처리를 하지 않는다.
이 경우 @Bean 메서드가 호출될 때마다 새로운 객체를 생성하게 되며, 두 메서드가 서로 다른 memberRepository를 참조하는 문제가 발생한다.
결과적으로, 싱글톤 패턴이 깨지며 아래와 같은 현상이 발생한다:

결론: @Configuration은 스프링 컨테이너에서 싱글톤 패턴을 유지하기 위한 핵심적인 역할을 하며, 이를 통해 여러 메서드에서 동일한 객체를 참조하도록 보장한다.
싱글톤이 왜 필요한지 : 객체의 무한 생성 방지 가능 + @
따라오는 여러 단점들 : Spring 컨테이너의 싱글톤 패턴은 이를 프레임워크 차원에서 최대한 막아준다.
어떻게 막는데 : 예 -> @Configuration은 cgblib 바이트 코드 조작을 통해 싱글톤에 해당하는 메서드들이 호출하는 메서드또한 빈 등록일 경우 이러한 흐름까지 싱글톤을 보장