자바 백엔드 입문 20편. Spring Bean의 6가지 Scope 중 핵심인 Singleton과 Prototype의 차이를 호텔 룸 비유로 풀고, 멀티스레드 환경에서 Singleton 함정과 해결법까지 풀어쓴 학습 노트.
이 글은 자바 백엔드 입문 시리즈 59편 중 20편이에요. 16편에서 "Bean은 기본적으로 한 번만 만들어 재사용" 이라고 짧게 다뤘는데, 이번 20편이 그 "몇 번 만들 것인가" 의 답을 정리하는 글이에요. Bean Scope 라고 부르고, 6가지가 있는데 입문자는 두 개(Singleton·Prototype)만 잡으면 90% 끝납니다.
Bean Scope가 헷갈리는 이유
처음 "Scope" 라는 단어를 들으면 "범위?" 가 떠오르는데, 정확히 어떤 범위인지가 처음엔 잡히지 않아요.
이 글에서는 호텔 룸 비유로 풀어요. Singleton = "한 호텔에 단 한 개뿐인 프레지덴셜 스위트", Prototype = "체크인할 때마다 새 객실 할당". 끝까지 따라오시면 6가지 Scope가 한 그림에 들어와요.
Bean Scope = 같은 클래스로 객체를 몇 개 만드는가
Bean Scope 는 "Spring 컨테이너가 이 클래스의 Bean을 몇 개 만들고, 언제까지 보관할지" 의 정책이에요. 같은 @Component 가 박혀 있어도 Scope에 따라 동작이 완전히 달라져요.
@Scope 어노테이션으로 지정해요. 안 박으면 기본값 = Singleton.
@Component
@Scope("singleton") // 기본값 — 생략 가능
public class OrderService { ... }
@Component
@Scope("prototype") // 매번 새 객체
public class OrderRequest { ... }
6가지 Scope 한눈에
Spring 공식 문서에 따르면 6개 Scope를 지원해요.
| Scope | 의미 | 사용 빈도 |
|---|---|---|
| singleton | 컨테이너에 1개. 모든 요청에서 같은 객체 재사용 | 99% (기본값) |
| prototype | 호출할 때마다 새 객체 | 1% (가끔) |
| request | HTTP 요청 1개당 1개. 요청 끝나면 폐기 | 가끔 (웹 전용) |
| session | HTTP 세션 1개당 1개. 사용자 1명당 1개 | 드물게 (웹 전용) |
| application | ServletContext 1개당 1개 | 매우 드묾 (웹 전용) |
| websocket | WebSocket 세션 1개당 1개 | 매우 드묾 (웹 전용) |
뒤 4개(request·session·application·websocket)는 웹 환경 전용. 일반 백엔드 입문에서는 거의 안 봐요. 입문자가 잡아야 할 건 singleton과 prototype 두 개.
Singleton — 호텔의 단 하나뿐인 스위트
Singleton Scope = "이 클래스의 Bean은 컨테이너 안에 단 한 개". 누가 몇 번을 요청해도 같은 객체가 반환돼요.
@Component
public class OrderService { ... } // @Scope 안 박으면 자동 Singleton
ApplicationContext ctx = ...;
OrderService a = ctx.getBean(OrderService.class);
OrderService b = ctx.getBean(OrderService.class);
// a == b → true (완전히 같은 객체)
비유로 풀어볼게요. 호텔에 프레지덴셜 스위트가 단 한 개 있어요. 누가 예약해도 같은 방. 다음 손님도 같은 방. 다 만 한 방이라 "호텔 어디서 어떻게 요청해도 그 방 한 개" 가 돌아옵니다.
왜 Singleton이 기본일까? 성능 때문이에요. 한 컨트롤러나 서비스 객체를 매번 새로 만들면 GC 부담 폭증. 99% 케이스에서 "객체 안에 상태(state)가 없다면" 같은 객체를 재사용해도 문제없어요. 비즈니스 로직 클래스는 보통 stateless라 Singleton이 최적이에요.
Singleton의 함정 — 멀티스레드와 인스턴스 변수
Singleton이 성능엔 좋은데, 인스턴스 변수가 있으면 위험해져요.
@Component
public class OrderCounter {
private int count = 0; // ⚠ 인스턴스 변수 — Singleton에선 공유됨
public int next() {
count++;
return count;
}
}
이 OrderCounter 가 Singleton이라 모든 사용자 요청에서 같은 객체. 한 사용자가 next() 호출하면 count = 1, 동시에 다른 사용자가 호출하면 count = 2. 멀티스레드 환경에서 race condition 이 발생해요. 두 스레드가 동시에 count++ 하면 한 증가가 사라질 수 있어요.
해결법 3가지:
1. 인스턴스 변수 안 쓰기 — Spring 컨트롤러·서비스는 stateless로 짜는 게 표준
2. synchronized 또는 AtomicInteger 같은 thread-safe 도구 사용
3. 꼭 상태가 필요하면 Prototype Scope 로 바꾸기
99% 케이스에서 답은 1번. "왜 Spring 컨트롤러 안의 인스턴스 변수가 위험한가" 가 면접 단골 — Singleton이라 모든 스레드에서 공유되어 race condition 발생, 라는 답이 정답.
Prototype — 체크인할 때마다 새 방
Prototype Scope = "이 클래스를 요청할 때마다 새 객체". getBean() 또는 @Autowired 시점에 매번 new 가 일어나요.
@Component
@Scope("prototype")
public class OrderRequest {
private final LocalDateTime createdAt = LocalDateTime.now();
}
OrderRequest a = ctx.getBean(OrderRequest.class);
OrderRequest b = ctx.getBean(OrderRequest.class);
// a != b → true (서로 다른 객체)
// a.createdAt != b.createdAt (생성 시각도 다름)
비유 — 호텔이 매번 새 방을 할당. 손님이 체크인할 때마다 다른 객실 번호를 받는 그림. "같은 카드(Bean 이름)로 요청해도 매번 다른 방".
언제 Prototype을 쓰나? 객체가 상태를 가져야 하고 그 상태가 호출별로 달라야 할 때. 예: 일회용 작업 컨텍스트, 사용자별 임시 데이터 holder. 실무에서는 정말 드물게 만나요.
Singleton Bean은 컨테이너 종료 시 @PreDestroy 자동 호출. Prototype Bean은 컨테이너가 더 이상 관리 안 해서 — 소멸 콜백이 자동으로 안 불려요. 자원 정리가 필요하면 사용자가 직접 처리해야 합니다.
Singleton이 Prototype을 주입받을 때 — 함정 + 해결
여기서 자주 막히는 함정이에요. Singleton Bean이 Prototype Bean을 주입받으면 — Singleton이 처음 만들어질 때 Prototype도 한 번만 주입돼서 "매번 새 객체" 가 안 일어나요.
@Component
public class OrderService {
@Autowired
private OrderRequest request; // Prototype이지만 한 번만 주입됨
}
OrderService 가 Singleton이라 처음 만들어질 때 OrderRequest 한 개를 받아 보관. 그 후 모든 호출에서 같은 OrderRequest 가 쓰여요. Prototype 의미가 사라지는 거예요.
해결법 3가지:
1. ObjectProvider<T> — request.getObject() 호출 시마다 새 객체
2. @Lookup 어노테이션 — 메서드 호출 시마다 새 객체 반환
3. Provider<T> (JSR-330 표준)
@Component
public class OrderService {
@Autowired
private ObjectProvider<OrderRequest> requestProvider;
public void process() {
OrderRequest req = requestProvider.getObject(); // 매번 새 객체
}
}
실무에서는 ObjectProvider 패턴이 가장 깔끔.
Request·Session — 웹 환경 전용 Scope
웹 백엔드 짤 때 가끔 만나는 두 Scope.
@Component
@Scope(value = "request", proxyMode = ScopedProxyMode.TARGET_CLASS)
public class RequestContext { ... } // HTTP 요청 1개당 1개
@Component
@Scope(value = "session", proxyMode = ScopedProxyMode.TARGET_CLASS)
public class UserSession { ... } // 사용자 세션 1개당 1개
- Request Scope — HTTP 요청 처리 동안만 유효한 객체. 요청 끝나면 폐기
- Session Scope — 한 사용자가 로그인부터 로그아웃까지 사용하는 객체
proxyMode = ScopedProxyMode.TARGET_CLASS 가 필수. Singleton 컨트롤러가 Request Bean을 주입받을 때 "매 요청마다 새로운 RequestContext" 가 되려면 프록시가 필요하거든요.
실무는 보통 Request·Session 데이터를 "HttpServletRequest·HttpSession 매개변수" 로 처리해요. Scope를 직접 박는 일은 드물어요.
한 줄 정리 — Bean Scope = Bean을 몇 개 만들고 언제까지 보관하나의 정책. Singleton(기본) 이 99%. Prototype은 매번 새 객체. Request·Session은 웹 전용.
시험 직전 한 번 더 — Bean Scope 입문자가 매번 헷갈리는 것
- Bean Scope = Bean의 인스턴스 수와 생명 주기 정책
- 6가지 = singleton / prototype / request / session / application / websocket
- 기본값 = singleton —
@Scope안 박으면 자동 - Singleton = 컨테이너에 1개. 모든 요청에서 같은 객체
- Prototype = 요청할 때마다 새 객체
- Request·Session·Application·WebSocket = 웹 환경 전용 (Spring Web MVC 필요)
- Singleton 기본 = 성능 + 메모리 효율 (객체 새로 만들 비용 절감)
- Spring 컨트롤러·서비스 = stateless로 짜는 게 표준
- Singleton + 인스턴스 변수 = race condition 위험 (면접 단골)
- 해결법 3가지 = ① 인스턴스 변수 X (1순위) ② thread-safe 도구 ③ Prototype
- Prototype 함정 =
@PreDestroy자동 호출 X — 자원 정리 직접 - Singleton이 Prototype 주입 받을 때 = 한 번만 주입돼서 Prototype 의미 X
- 해결법 =
ObjectProvider<T>/@Lookup/Provider<T>(JSR-330) ObjectProvider.getObject()호출 시마다 새 객체- Request Scope = HTTP 요청 1개당 1개, 끝나면 폐기
- Session Scope = 사용자 세션 1개당 1개 (로그인~로그아웃)
- Application Scope = ServletContext 1개당 1개 (앱 전체 생명주기)
- WebSocket Scope = WebSocket 세션 1개당 1개
- Request·Session Bean 주입 시
proxyMode = ScopedProxyMode.TARGET_CLASS필수 - 실무 = Singleton 99%, Prototype 1%, 나머지 4개 매우 드묾
시리즈 다른 편 (앞뒤 글 모음)
이전 글:
- 15편 — 의존성 주입이 왜 필요한가
- 16편 — Bean이란 일반 객체와의 차이
- 17편 — ApplicationContext 컨테이너 본체
- 18편 — @Component @Autowired 한 번에
- 19편 — @Configuration @Bean Java Config
다음 글: