싱글톤 (Singleton)
왜 싱글톤이 필요한가
웹 애플리케이션은 수많은 사용자가 동시에 요청을 보낸다. 만약 요청마다 새 객체를 생성하면 어떻게 될까?
// 스프링 없이 순수 자바로 구현한다면
public class AppConfig {
public OrderService orderService() {
return new OrderServiceImpl(memberRepository(), discountPolicy()); // 매번 새 객체 생성
}
public MemberRepository memberRepository() {
return new MemoryMemberRepository(); // 매번 새 객체 생성
}
}
클라이언트 A 요청 → new OrderServiceImpl 생성, new MemoryMemberRepository 생성 ...
클라이언트 B 요청 → new OrderServiceImpl 생성, new MemoryMemberRepository 생성 ...
클라이언트 C 요청 → new OrderServiceImpl 생성, new MemoryMemberRepository 생성 ...
초당 10,000개 요청 → 초당 수만 개의 객체 생성 → GC 과부하 → 성능 저하
OrderServiceImpl은 상태가 없는 객체다. 굳이 요청마다 새로 만들 이유가 없다.
하나만 만들어서 공유하면 메모리 낭비 없이 효율적이다.
이것이 싱글톤 패턴이다: 클래스의 인스턴스가 딱 1개만 생성되도록 보장하는 패턴.
순수 자바 싱글톤 패턴
자바로 직접 구현하면 이렇다.
public class SingletonService {
// 1. 클래스 로딩 시점에 인스턴스를 딱 1개 생성하고 static으로 보관
private static final SingletonService instance = new SingletonService();
// 2. 생성자를 private으로 막아 외부에서 new 불가
private SingletonService() {}
// 3. 유일한 인스턴스를 반환하는 정적 메서드
public static SingletonService getInstance() {
return instance;
}
public void logic() {
System.out.println("싱글톤 객체 로직 호출");
}
}
// 테스트
SingletonService s1 = SingletonService.getInstance();
SingletonService s2 = SingletonService.getInstance();
assertThat(s1).isSameAs(s2); // true — 같은 인스턴스!
// s1 == s2 (메모리 주소가 동일)
// new SingletonService(); // 컴파일 에러 — private 생성자라서 외부에서 호출 불가
순수 싱글톤 패턴의 문제점
직접 구현하는 싱글톤은 여러 문제가 있다.
| 문제 | 설명 |
|---|---|
| 부가 코드 필요 | private 생성자, static 필드, getInstance() 등 매번 같은 코드 작성 |
| DIP 위반 | 클라이언트가 getInstance()를 직접 호출 → 구체 클래스에 의존 |
| OCP 위반 가능 | 구현 변경 시 클라이언트 코드도 변경해야 할 수 있음 |
| 테스트 어려움 | Mock 객체로 교체하기 어려움, 테스트 간 상태 공유 |
| 유연성 부족 | 상속, 다형성 적용이 어려움 |
// DIP 위반 예
public class ClientA {
// SingletonService의 구체 클래스에 직접 의존 — 인터페이스 기반으로 바꾸기 어려움
private SingletonService service = SingletonService.getInstance();
}
스프링의 싱글톤 컨테이너
스프링은 위 문제를 모두 해결하면서 싱글톤을 보장한다.
ApplicationContext ac = new AnnotationConfigApplicationContext(AppConfig.class);
MemberService s1 = ac.getBean("memberService", MemberService.class);
MemberService s2 = ac.getBean("memberService", MemberService.class);
assertThat(s1).isSameAs(s2); // true — 스프링이 같은 인스턴스를 반환
// s1 == s2
스프링은 private 생성자 같은 부가 코드 없이도 싱글톤을 보장한다.
DI로 주입받기 때문에 클라이언트는 인터페이스에만 의존하면 된다.
| 스프링 싱글톤의 장점 | 설명 |
|---|---|
| 자동 관리 | 싱글톤 구현 코드 불필요 |
| DIP 준수 | 인터페이스 기반으로 주입, 구체 클래스 몰라도 됨 |
| 테스트 용이 | Mock 객체 주입 가능 (@MockBean 등) |
| 유연성 | 상속, 다형성 정상 지원 |
싱글톤 주의사항: 무상태 설계 (Stateless)
싱글톤 빈은 상태를 가지면 안 된다 (Stateless). 이것이 싱글톤에서 가장 중요하고 가장 많이 실수하는 부분이다.
하나의 싱글톤 객체를 여러 스레드가 동시에 공유한다. 인스턴스 변수(필드)에 값을 저장하면, 한 스레드가 쓴 값을 다른 스레드가 읽어버리는 심각한 버그가 발생한다.
문제 예시
아래 코드는 실제 서비스에서 결제 금액 오류를 유발하는 심각한 버그 패턴이다.
// 위험한 코드!
@Component
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 service = ...; // 싱글톤 — 모든 스레드가 같은 객체 사용
// [스레드 A] 김철수가 10,000원 주문
service.order("김철수", 10000); // this.price = 10000
// [스레드 B] 스레드 A가 getPrice() 호출하기 직전에 끼어들어서 주문
service.order("김영희", 20000); // this.price = 20000 (덮어씀!)
// [스레드 A] 가격 조회
int price = service.getPrice();
// 기대: 10000 실제: 20000 ← 버그!
실제 서비스에서 이런 버그가 발생하면 다른 사람의 결제 금액이 내 영수증에 찍히는 상황이 된다. 은행, 결제 시스템에서 치명적인 결함이다.
올바른 무상태 설계
// 안전한 코드 — 인스턴스 변수를 사용하지 않음
@Component
public class StatelessService {
public int order(String name, int price) {
System.out.println("주문: name=" + name + " price=" + price);
return price; // 지역 변수로 처리 — 스레드마다 독립적인 스택에 저장
}
}
지역 변수, 파라미터, 반환값은 스레드마다 독립적인 스택(Stack) 메모리에 저장된다. 스레드끼리 공유하지 않으므로 안전하다.
인스턴스 변수(필드)는 힙(Heap) 메모리에 저장된다. 모든 스레드가 공유한다.
스레드 A 스택: price = 10000 (지역 변수) → 스레드 A만 사용
스레드 B 스택: price = 20000 (지역 변수) → 스레드 B만 사용
힙 (싱글톤 객체): StatelessService@x01 → 모든 스레드 공유, 상태 없으므로 안전
무상태 설계 체크리스트
| 코드 패턴 | 안전 여부 | 이유 |
|---|---|---|
| 인스턴스 변수에 값을 쓴다 | 위험 | 힙 메모리 공유 → 스레드 간 덮어쓰기 |
| 인스턴스 변수를 읽기만 한다 (설정값 등) | 안전 | 변경이 없으므로 공유해도 무방 |
| 지역 변수, 파라미터, 반환값을 사용한다 | 안전 | 스레드마다 독립 스택에 저장 |
ThreadLocal을 사용한다 |
안전 | 스레드마다 독립된 저장 공간 |
스레드별 독립 상태가 필요할 때 — ThreadLocal
싱글톤 빈인데, 스레드마다 다른 값을 저장해야 하는 경우가 있다. 예: 현재 로그인한 사용자 정보를 요청 처리 중 여러 계층에서 참조해야 할 때.
이때 ThreadLocal을 사용한다.
// ThreadLocal: 스레드마다 독립된 저장 공간 제공
public class UserContext {
// static이지만 내부적으로 스레드마다 별도 공간을 가짐
private static final ThreadLocal<String> currentUser = new ThreadLocal<>();
public static void set(String user) {
currentUser.set(user);
}
public static String get() {
return currentUser.get(); // 현재 스레드에 저장된 값만 반환
}
public static void clear() {
currentUser.remove(); // 사용 후 반드시 제거!
}
}
// 스레드 A: 사용자 "김철수"
UserContext.set("김철수");
// 스레드 B: 사용자 "김영희"
UserContext.set("김영희");
// 스레드 A에서 조회
UserContext.get(); // "김철수" — 스레드 A의 데이터만 나옴
// 스레드 B에서 조회
UserContext.get(); // "김영희" — 스레드 B의 데이터만 나옴
ThreadLocal 주의: 요청 처리가 끝난 후 반드시 remove()를 호출해야 한다.
스레드 풀 환경에서는 스레드가 재사용되므로, 제거하지 않으면 이전 요청의 사용자 정보가 다음 요청에 그대로 남아있게 된다.
@Configuration과 CGLIB — 스프링이 싱글톤을 보장하는 방법
문제: 자바 코드 그대로라면 싱글톤이 깨진다
@Configuration
public class AppConfig {
@Bean
public MemberService memberService() {
return new MemberServiceImpl(memberRepository()); // memberRepository() 호출 1
}
@Bean
public OrderService orderService() {
return new OrderServiceImpl(memberRepository(), discountPolicy()); // memberRepository() 호출 2
}
@Bean
public MemberRepository memberRepository() {
return new MemoryMemberRepository(); // new!
}
}
자바 코드대로 실행된다면 memberRepository()가 2번 호출되어 MemoryMemberRepository가 2개 생길 것 같다.
그러면 MemberServiceImpl이 사용하는 레포지토리와 OrderServiceImpl이 사용하는 레포지토리가 서로 다른 인스턴스가 된다.
하지만 실제로 실행해보면 1개만 생성된다. 어떻게 된 것일까?
CGLIB 바이트코드 조작
스프링은 @Configuration이 붙은 클래스를 그대로 쓰지 않는다.
CGLIB 라이브러리를 이용해 AppConfig를 상속한 프록시(Proxy) 클래스를 만들고, 그것을 빈으로 등록한다.
// 확인 방법
ApplicationContext ac = new AnnotationConfigApplicationContext(AppConfig.class);
AppConfig bean = ac.getBean(AppConfig.class);
System.out.println(bean.getClass());
// 출력: class hello.core.AppConfig$$EnhancerBySpringCGLIB$$12ab34cd
// → 원본 AppConfig가 아니라 CGLIB이 만든 프록시 클래스!
CGLIB이 만든 프록시는 @Bean 메서드를 오버라이딩해서 다음과 같은 로직을 끼워 넣는다:
// CGLIB이 생성하는 코드 (개념적 설명)
@Bean
public MemberRepository memberRepository() {
if (스프링 컨테이너에 "memberRepository" 빈이 이미 존재하는가?) {
return 기존 빈을 반환; // 싱글톤 보장
} else {
MemberRepository repo = new MemoryMemberRepository();
스프링 컨테이너에 등록;
return repo;
}
}
처음 호출: memberRepository 빈 없음 → new MemoryMemberRepository() → 컨테이너에 등록
두 번째 호출: memberRepository 빈 이미 있음 → 기존 빈 반환 → new 안 함
결과: MemoryMemberRepository는 딱 1개만 생성됨 → 싱글톤 보장!
@Configuration 없이 @Bean만 쓰면?
// @Configuration 없이 사용
public class AppConfig { // @Configuration 제거!
@Bean
public MemberService memberService() {
return new MemberServiceImpl(memberRepository()); // 순수 자바 메서드 호출
}
@Bean
public OrderService orderService() {
return new OrderServiceImpl(memberRepository(), discountPolicy()); // 또 순수 자바 메서드 호출
}
@Bean
public MemberRepository memberRepository() {
return new MemoryMemberRepository(); // 호출될 때마다 new!
}
}
@Configuration이 없으면 CGLIB 프록시가 만들어지지 않는다.
memberRepository()는 단순 자바 메서드 호출이 되어 호출할 때마다 new MemoryMemberRepository()를 생성한다.
싱글톤이 깨진다.
항상 @Configuration과 @Bean을 함께 사용할 것.
내부 동작 원리
싱글톤 레지스트리 내부 구조
스프링이 싱글톤을 저장하고 관리하는 클래스는 DefaultSingletonBeanRegistry다.
// DefaultSingletonBeanRegistry 내부 (의사코드)
public class DefaultSingletonBeanRegistry {
// 완성된 싱글톤 빈 저장소 (1단계 캐시)
private final Map<String, Object> singletonObjects = new ConcurrentHashMap<>(256);
// 아직 생성 중인 빈 (2단계 캐시) — 순환참조 해결용
private final Map<String, Object> earlySingletonObjects = new ConcurrentHashMap<>(16);
// 빈 팩토리 (3단계 캐시) — CGLIB 프록시 생성 준비용
private final Map<String, ObjectFactory<?>> singletonFactories = new HashMap<>(16);
}
getBean() 호출 시 이 3개 캐시를 순서대로 확인한다:
getBean("memberService") 호출
1. singletonObjects에서 "memberService" 검색 → 있으면 즉시 반환 (O(1), 가장 빠름)
2. earlySingletonObjects에서 검색 → 있으면 반환 (생성 중인 빈)
3. singletonFactories에서 팩토리 꺼내서 빈 생성 → earlySingletonObjects에 저장
4. 셋 다 없으면 새로 생성 시작
3단계 캐시 — 순환참조를 어떻게 해결하는가
생성자 주입의 순환참조는 해결이 불가능하지만, Setter/필드 주입의 순환참조는 3단계 캐시로 해결한다. (단, 이 방식은 권장하지 않는다. 순환참조 자체가 설계 문제다.)
A → B, B → A 순환참조 상황 (필드 주입)
① A 생성 시작
- singletonFactories에 "A 만들기" 팩토리 등록
- A의 인스턴스 생성 (아직 B 미주입)
② A를 생성하다가 B가 필요함 → B 생성 시작
- singletonFactories에 "B 만들기" 팩토리 등록
- B의 인스턴스 생성 (아직 A 미주입)
③ B를 생성하다가 A가 필요함
- singletonObjects 확인 → 없음 (아직 완성 안 됨)
- earlySingletonObjects 확인 → 없음
- singletonFactories 확인 → "A 만들기" 팩토리 발견!
- 팩토리 실행 → 완성되지 않은 A 인스턴스 반환
- earlySingletonObjects에 A 저장
④ B에 A 주입 완료 → B 완성 → singletonObjects에 B 저장
⑤ A에 B 주입 완료 → A 완성 → singletonObjects에 A 저장
→ earlySingletonObjects에서 A 제거 (완성본으로 교체)
생성자 주입에서는 3단계 캐시가 작동하지 않는다.
생성자 주입은 인스턴스 자체를 만들기 전에 의존 빈을 먼저 주입해야 하므로,
A를 만들려면 B가 필요하고, B를 만들려면 A가 필요한 상태가 되어 즉시 BeanCreationException이 발생한다.
이것이 생성자 주입을 권장하는 또 다른 이유다 — 순환참조를 숨기지 않고 즉시 드러낸다.