웹 애플리케이션과 싱글톤
대부분의 스프링 애플리케이션은 웹 애플리케이션이다. (물론 아닌것도 개발할 수 있다)
그리고 웹 애플리케이션은 보통 여러 고객이 동시에 요청을 한다.
스프링이 없는 순수한 DI컨테이너에서 동시 요청이 발생했을 때 어떤 일이 발생할까?
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);
// memberService != memberService2
Assertions.assertThat(memberService1).isNotSameAs(memberService2);
}

순수 DI 컨테이너인 AppConfig는 요청을 할 때마다 새로운 객체를 생성한다.
tps가 100개가 나오면 100개가 생성되고 소멸된다 (GC가 계속 작동하게된다.)
해결 방안은 객체가 딱 1개만 생성되고, 공유하도록 설계하면 된다 -> 싱글톤 패턴
싱글톤 패턴
package hello.core.singleton;
public class SingletonService {
private static final SingletonService instance = new SingletonService();
public static SingletonService getInstance() {
return instance;
}
private SingletonService() {
}
public void logic() {
System.out.println("싱글톤 객체 호출");
}
}
- static 영역에 객체 instance를 미리 하나 생성해서 올려둔다.
- 이 객체 인스턴스가 필요하면 오직 getInstance() 메서드를 통해 조회할 수 있다. 조회 시 항상 같은 인스턴스를 반환하게 된다.
- 한개의 객체 인스턴스만 존재해야하므로, 외부에서 생성하지 못하게 private 생성자를 사용해 인스턴스가 생성되는것을 막는다.
@Test
@DisplayName("싱글톤 패턴을 적용한 객체 사용")
void singletonServiceTest() {
SingletonService singletonService1 = SingletonService.getInstance();
SingletonService singletonService2 = SingletonService.getInstance();
System.out.println("singletonService1 = " + singletonService1);
System.out.println("singletonService2 = " + singletonService2);
assertThat(singletonService1).isSameAs(singletonService2);
// same : ==
// equal : equals
}

호출 할 때마다 같은 인스턴스를 반환하는것을 확인할 수 있다.
이렇게 싱글톤 패턴을 적용하면, 요청이 올 때마다 객체를 생성하지 않고 이미 만들어진 객체를 공유해서 효율적으로 사용할 수 있다.
그러나 싱글톤 패턴에도 단점이 존재한다.
- 싱글톤 패턴을 구현하는 코드 자체가 많아진다.
- 클라이언트가 구체 클래스에 의존하게 된다. (getInstance로 직접 조회해야하기 때문에 구체클래스에 의존, DIP 위반)
- 구체 클래스에 의존하므로 OCP를 위반할 수 있다.
- 테스트 하기가 어렵다.
- private 생성자를 사용하므로 자식 클래스 생성이 어려워진다.
- 따라서 유연성이 떨어진다.
싱글톤 컨테이너
스프링 컨테이너는 싱글톤 패턴의 문제점을 해결하면서, 객체 인스턴스를 싱글톤으로 관리한다.
지금까지 학습한 스프링 빈이 바로 싱글톤으로 관리되는 빈이다.
이전에 컨테이너 생성과정을 다시 확인해보면, @Bean으로 컨테이너에 하나의 객체만 생성해서 관리하는것을 알 수 있다.
이렇게 싱글톤을 관리하는 기능을 싱글톤 레지스트리라고 한다.
이러한 싱글톤 컨테이너의 기능덕에 싱글톤 패턴의 단점을 해결하면서 객체를 싱글톤으로 유지할 수 있게 되는 것이다.
- 싱글톤 패턴을 구현하기 위해 불필요한 코드를 추가하지 않아도 됨
- DIP, OCP, 테스트, private 생성자로부터 자유로워짐
@Test
@DisplayName("스프링 컨테이너와 싱글톤")
void springContainer() {
ApplicationContext ac = new AnnotationConfigApplicationContext(AppConfig.class);
//1. 조회: 호출할 때마다 객체를 생성
MemberService memberService1 = ac.getBean("memberService", MemberService.class);
MemberService memberService2 = ac.getBean("memberService", MemberService.class);
//참조값이 같은것을 확인
System.out.println("memberService1 = " + memberService1);
System.out.println("memberService2 = " + memberService2);
//MemberService1 != memberService2
assertThat(memberService1).isSameAs(memberService2);
}

싱글톤 컨테이너 적용 후엔 클라이언트 A, B, C가 memberService를 요청해도 동일한 인스턴스를 반환할 수 있게 된다.
기본적으로 스프링은 싱글톤 방식으로 동작한다고 이해하면 된다.
싱글톤 방식의 주의점
싱글톤 패턴이든, 스프링같은 싱글톤 컨테이너를 사용하던 객체 인스턴스를 하나만 생성해서 공유하는 싱글톤 방식은 싱글톤 객체를 유지 (stateful)하게 설계하면 안된다. stateless(무상태)로 설계해야 한다.
이게 무슨말이냐
- 특정 클라이언트에 의존적인 필드가 있으면 안된다.
- 특정 클라이언트가 값을 변경할 수 있는 필드가 있으면 안된다.
- 가급적 읽기만 가능해야 한다.
- 필드 대신에 자바에서 공유되지 않는 지역변수, 파라미터, 쓰레드로컬 등을 사용해야한다.
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;
}
}
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;
import static org.junit.jupiter.api.Assertions.*;
class StatefulServiceTest {
@Test
void statefulServiceSingleton() {
ApplicationContext ac = new AnnotationConfigApplicationContext(TestConfig.class);
StatefulService statefulService1 = ac.getBean(StatefulService.class);
StatefulService statefulService2 = ac.getBean(StatefulService.class);
// ThreadA: A 사용자가 1만원 주문
statefulService1.order("userA", 10000);
// ThreadB: B 사용자가 2만원 주문
statefulService2.order("userB", 20000);
// ThreadA: 사용자 A 주문금액 조회
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();
}
}
}
여기서, 우리는 statefulService1.order에 userA가 10000원 주문을 넣었고, 2엔 20000원 주문을 넣었다.
그리고 주문 금액을 조회하기 위해 statefulService1.getPrice()를 호출했을 때, service1의 값은 1만원이 나올거라고 생각한다.
그러나 조회되는 값은 20000원이 된다. 그 이유는 StatefulService 클래스에 상태를 유지하는 필드 private int price;가 있기 때문이다.
StatefulService 객체의 price필드는 공유되고 있는 상태이기 때문에, price는 나중에 입력한 값으로 상태가 변경된다.
공유 필드는 항상 조심해야하고, 스프링 빈은 항상 무상태(stateless)로 설계해야한다는 점을 잊지 말자.
무상태 설계 예시
package hello.core.singleton;
public class StatefulService {
public void order(String name, int price) {
System.out.println("name = " + name + " price = " + price);
this.price = price;
}
}
@Test
void statefulServiceSingleton() {
ApplicationContext ac = new AnnotationConfigApplicationContext(TestConfig.class);
StatefulService statefulService1 = ac.getBean(StatefulService.class);
StatefulService statefulService2 = ac.getBean(StatefulService.class);
// ThreadA: A 사용자가 1만원 주문
int userAPrice = statefulService1.order("userA", 10000);
// ThreadB: B 사용자가 2만원 주문
int userBPrice = statefulService2.order("userB", 20000);
// ThreadA: 사용자 A 주문금액 조회
System.out.println("price = " + userAPrice);
Assertions.assertThat(statefulService1.getPrice()).isEqualTo(20000);
}
위와같이 공유 필드 코드를 삭제 후, 지역변수를 사용했다. 지역변수는 공유되지 않기 때문에 정상적으로 해결할 수 있게 된다.
즉 지역변수를 단순히 출력하는 것이지 statefulService의 상태를 공유하는것이 아니라는 말이다.
(매우 기초적인 예시이다)
@Configuration과 싱글톤
AppConfig 코드를 한번 확인해보자.
@Configuration
public class AppConfig {
@Bean
public MemberService memberService() {
System.out.println("call AppConfig.memberService");
return new MemberServiceImpl(memberRepository());
}
@Bean
public MemoryMemberRepository memberRepository() {
System.out.println("call AppConfig.memberRepository");
return new MemoryMemberRepository();
}
@Bean
public OrderService orderService() {
System.out.println("call AppConfig.orderService");
return new OrderServiceImpl(memberRepository(), discountPolicy());
}
@Bean
public DiscountPolicy discountPolicy() {
return new RateDiscountPolicy();
}
}
memberService 빈을 만드는 코드를 보면 memberRepository()를 호출한다.
-> 호출 시 new MemoryMemberRepository() 호출
orderService 빈을 만드는 코드도 동일하게 memberRepository()를 호출한다.
-> 호출 시 new MemoryMemgberRepository() 호출
이렇게 되면, 각 두번의 MemoryMemberRepository()가 호출되므로, 싱글톤이 깨지게 되는게 아닌가라는 생각이 든다.
@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);
MemberRepository memberRepository1 = memberService.getMemberRepository();
MemberRepository memberRepository2 = orderService.getMemberRepository();
System.out.println("memberService -> memberRepository = " + memberRepository1);
System.out.println("orderService -> memberRepository = " + memberRepository2);
System.out.println("memoryMemberRepository = " + memberRepository);
Assertions.assertThat(memberService.getMemberRepository()).isSameAs(memberRepository);
Assertions.assertThat(orderService.getMemberRepository()).isSameAs(memberRepository);
}

memberRepository는 한번만 호출되고, 참조값이 같은걸 확인할 수 있다.
어떻게 이렇게 작동하는걸까?
@Configuration과 바이트코드 조작
스프링 컨테이너는 싱글톤 레지스트리다. 따라서 스프링 빈이 싱글톤이 되도록 보장해주어야 한다.
코드상으로 보았을 땐 분명 memberRepository가 2번 호출되는게 맞다.
그래서 스프링은 클래스의 바이트코드를 조작하는 라이브러리를 사용한다.
@Configuration을 적용한 AppConfig를 살펴보면 답이 나온다.
@Test
void configurationDeep() {
ApplicationContext ac = new AnnotationConfigApplicationContext(AppConfig.class);
AppConfig bean = ac.getBean(AppConfig.class);
System.out.println("bean = " + bean.getClass());
}
위 코드를 확인해보면, AnnotationConfigApplicationContext에 파라미
터로 넘긴 값은 스프링 빈으로 등록된다.
그렇기 때문에 AppConfig도 스프링 빈이 되는것이다.
그럼 AppConfig 스프링 빈을 조회해서 클래스 정보를 출력해보면

AppConfig다음에 이상한 주소가 출력이 된다.
원래라면 AppConfig 뒤에 다 떼고 순수 클래스가 출력되어야 하는데 다른것이 출력된 것이다.
이것은 내가 만든 클래스 AppConfig 자체가 아니라, CGLIB이라는 바이트코드 조작 라이브러리를 사용해서 AppConfig를 상속받은
임의의 다른 클래스를 만들고, 그 다른 클래스를 스프링 빈으로 등록한 것이다.

그 임의의 다른 클래스가 싱글톤이 보장되도록 해준다. CGLIB의 내부 예상 코드는 다음과 같을 것이다.
@Bean
public MemberRepository memberRepository() {
if (memoryMemberRepository가 이미 스프링 컨테이너에 등록되어 있으면?) {
return 스프링 컨테이너에서 찾아서 반환;
} else { //스프링 컨테이너에 없으면
기존 로직을 호출해서 MemoryMemberRepository를 생성하고 스프링 컨테이너에 등록
return 반환
}
}
위와 같은 기능을 오버라이딩해서 싱글톤이 보장되게끔 작동하게 된다고 예상할 수 있다.
* AppConfig@CGLIB은 AppConfig의 자식 타입이므로 AppConfig 타입으로 조회할 수 있다.
@Configuration을 적용하지 않고 @Bean만 적용한다면 어떻게 되는걸까?
위에서 설명했듯이 @Configuration을 붙이면 CGLIB 기술을 사용해 싱글톤을 보장되게끔 한다.
만약 @Bean 만 적용하게 되었을 땐 싱글톤이 보장되지 않는다. 따라서
위의 configurationDeep() 테스트로 빈을 조회할 시에 순수한 클래스 bean = class hello.core.AppConfig가 출력된다.
그리고 위의 memberRepository가 2번 출력되고, 그 참조값이 전부 다르게 나온다.
즉, @Configuration이 없으면 @Bean 메서드가 호출될 때마다 새로운 객체를 생성한다. (new MemoryMemberRepository)
따라서 스프링 컨테이너가 완전한 싱글톤으로 관리하지 못한다는 말이다.
@Bean을 사용할 땐 항상 @Configuration을 사용해 싱글톤을 보장받게 코드를 작성하는것이 권장된다.
요약
1. 순수한 DI 컨테이너의 문제점
• 요청마다 새로운 객체를 생성 → 메모리 낭비 & GC 부담
2. 싱글톤 패턴
• 하나의 객체만 생성하여 공유 (static instance 활용)
• 단점: DIP 위반, 테스트 어려움, 코드 복잡성 증가
3. 스프링 컨테이너의 싱글톤 관리
• 스프링이 자동으로 싱글톤을 보장 → 개발자가 직접 싱글톤 패턴을 구현할 필요 없음
• @Bean을 사용하면 컨테이너가 객체를 한 번만 생성하고 재사용
4. 싱글톤의 주의점 (Stateless 설계 필수!)
• 공유 필드(stateful) 사용 금지 → 값이 변경되면 동시 요청에서 문제 발생
• 지역 변수, 파라미터 활용하여 상태를 유지하지 않는 방식 사용
5. @Configuration과 CGLIB
• 스프링이 @Configuration을 사용하면 바이트코드 조작(CGLIB)으로 싱글톤을 보장
• 같은 @Bean을 여러 번 호출해도 동일한 객체를 반환
*핵심 : 스프링 컨테이너 덕분에 싱글톤을 직접 구현할 필요가 없으며, 무상태(stateless) 원칙을 지키면 안전하게 사용할 수 있다.
'Spring' 카테고리의 다른 글
의존관계 자동 주입 (0) | 2025.03.27 |
---|---|
컴포넌트 스캔 (0) | 2025.03.26 |
스프링 컨테이너와 스프링 빈 (1) | 2025.02.24 |
스프링 핵심 원리 (2) (0) | 2025.02.20 |
스프링 핵심 원리(1) (0) | 2025.02.19 |