Spring Bean Scope란?
Spring의 Bean Scope는 빈 인스턴스의 생명주기와 공유 범위를 결정합니다. 대부분 개발자가 singleton만 사용하지만, request, session, prototype, 그리고 커스텀 스코프를 올바르게 활용하면 멀티테넌시, 배치 처리, A/B 테스트 같은 복잡한 요구사항을 깔끔하게 해결할 수 있습니다.
이 글에서는 각 스코프의 내부 동작 원리, Scope Proxy의 CGLIB 프록시 메커니즘, 커스텀 스코프 구현, 그리고 프로덕션 활용 패턴까지 심층적으로 다룹니다.
기본 5가지 스코프
| 스코프 | 인스턴스 생성 | 소멸 | 사용처 |
|---|---|---|---|
singleton |
컨테이너당 1개 | 컨테이너 종료 시 | 기본값, 대부분의 서비스 |
prototype |
요청할 때마다 새로 생성 | GC 대상 (Spring 미관리) | 상태 있는 빈, 빌더 |
request |
HTTP 요청당 1개 | 요청 완료 시 | 요청 컨텍스트, 감사 로그 |
session |
HTTP 세션당 1개 | 세션 만료 시 | 장바구니, 사용자 설정 |
application |
ServletContext당 1개 | 앱 종료 시 | 글로벌 공유 상태 |
Prototype 스코프의 함정
prototype 빈을 singleton 빈에 주입하면 기대와 다르게 동작합니다:
@Component
@Scope("prototype")
public class RequestContext {
private final String id = UUID.randomUUID().toString();
public String getId() { return id; }
}
@Service // singleton
public class OrderService {
private final RequestContext context; // ⚠️ 한 번만 주입됨!
public OrderService(RequestContext context) {
this.context = context;
}
public void process() {
// 항상 같은 id! prototype이 아니라 singleton처럼 동작
log.info("Context ID: {}", context.getId());
}
}
singleton 빈은 한 번만 생성되므로, 주입된 prototype 빈도 한 번만 생성됩니다. 해결 방법 3가지:
// 해결 1: ObjectProvider (권장)
@Service
public class OrderService {
private final ObjectProvider<RequestContext> contextProvider;
public OrderService(ObjectProvider<RequestContext> contextProvider) {
this.contextProvider = contextProvider;
}
public void process() {
RequestContext ctx = contextProvider.getObject(); // 매번 새 인스턴스
log.info("Context ID: {}", ctx.getId());
}
}
// 해결 2: @Lookup 메서드 주입
@Service
public abstract class OrderService {
@Lookup
protected abstract RequestContext createContext();
public void process() {
RequestContext ctx = createContext(); // 매번 새 인스턴스
log.info("Context ID: {}", ctx.getId());
}
}
// 해결 3: Scope Proxy (request/session 스코프에 적합)
@Component
@Scope(value = "prototype", proxyMode = ScopedProxyMode.TARGET_CLASS)
public class RequestContext {
// CGLIB 프록시가 주입되어, 접근할 때마다 새 인스턴스 반환
}
Request Scope 실전 활용
request 스코프는 HTTP 요청마다 새 인스턴스를 만들어 요청 컨텍스트를 전파하는 데 최적입니다:
// 요청 컨텍스트 홀더
@Component
@Scope(value = WebApplicationContext.SCOPE_REQUEST,
proxyMode = ScopedProxyMode.TARGET_CLASS)
@Getter @Setter
public class RequestContextHolder {
private String requestId;
private String userId;
private String tenantId;
private Instant startTime;
private final Map<String, Object> attributes = new HashMap<>();
}
// 필터에서 컨텍스트 초기화
@Component
@Order(Ordered.HIGHEST_PRECEDENCE)
public class RequestContextFilter extends OncePerRequestFilter {
private final RequestContextHolder contextHolder;
@Override
protected void doFilterInternal(HttpServletRequest req,
HttpServletResponse res, FilterChain chain) throws Exception {
contextHolder.setRequestId(
req.getHeader("X-Request-ID") != null
? req.getHeader("X-Request-ID")
: UUID.randomUUID().toString()
);
contextHolder.setUserId(extractUserId(req));
contextHolder.setTenantId(req.getHeader("X-Tenant-ID"));
contextHolder.setStartTime(Instant.now());
// MDC에도 설정 (로깅용)
MDC.put("requestId", contextHolder.getRequestId());
MDC.put("tenantId", contextHolder.getTenantId());
try {
chain.doFilter(req, res);
} finally {
MDC.clear();
}
}
}
// 어디서든 주입받아 사용
@Service
@RequiredArgsConstructor
public class AuditService {
private final RequestContextHolder context; // Scope Proxy 주입
public void logAction(String action) {
var log = AuditLog.builder()
.requestId(context.getRequestId())
.userId(context.getUserId())
.tenantId(context.getTenantId())
.action(action)
.duration(Duration.between(context.getStartTime(), Instant.now()))
.build();
auditRepo.save(log);
}
}
Scope Proxy 내부 동작
proxyMode = ScopedProxyMode.TARGET_CLASS를 설정하면 Spring은 CGLIB 프록시를 생성합니다:
// 실제 주입되는 것은 프록시 객체
// RequestContextHolder$$SpringCGLIB$$0
// 프록시의 동작:
// 1. 메서드 호출 시 현재 스코프(예: HTTP 요청)에서 실제 빈을 찾음
// 2. 실제 빈에 메서드를 위임
// 3. 스코프 밖에서 호출하면 예외 발생
// ScopedProxyMode 옵션:
// - NO: 프록시 없음 (기본값)
// - INTERFACES: JDK 동적 프록시 (인터페이스 필요)
// - TARGET_CLASS: CGLIB 프록시 (클래스 직접 프록시)
// 주의: 비동기 스레드에서 request scope 접근 시 예외!
@Async
public void asyncMethod() {
context.getRequestId(); // ❌ IllegalStateException!
// "No thread-bound request found"
}
커스텀 스코프 구현
Spring이 제공하지 않는 스코프가 필요할 때 직접 구현할 수 있습니다. 배치 작업 스코프 예시:
// 1. Scope 인터페이스 구현
public class BatchScope implements Scope {
private static final ThreadLocal<Map<String, Object>> batchStore =
ThreadLocal.withInitial(HashMap::new);
private static final ThreadLocal<Map<String, Runnable>> destructionCallbacks =
ThreadLocal.withInitial(HashMap::new);
@Override
public Object get(String name, ObjectFactory<?> objectFactory) {
Map<String, Object> scope = batchStore.get();
return scope.computeIfAbsent(name, k -> objectFactory.getObject());
}
@Override
public Object remove(String name) {
destructionCallbacks.get().remove(name);
return batchStore.get().remove(name);
}
@Override
public void registerDestructionCallback(String name, Runnable callback) {
destructionCallbacks.get().put(name, callback);
}
@Override
public Object resolveContextualObject(String key) {
return null;
}
@Override
public String getConversationId() {
return Thread.currentThread().getName();
}
// 배치 시작/종료 시 호출
public static void startBatch() {
batchStore.get().clear();
}
public static void endBatch() {
destructionCallbacks.get().values().forEach(Runnable::run);
destructionCallbacks.get().clear();
batchStore.get().clear();
}
}
// 2. 스코프 등록
@Configuration
public class BatchScopeConfig implements BeanFactoryPostProcessor {
@Override
public void postProcessBeanFactory(ConfigurableBeanFactory factory) {
factory.registerScope("batch", new BatchScope());
}
}
// 3. 사용
@Component
@Scope("batch")
public class BatchContext {
private final String batchId = UUID.randomUUID().toString();
private int processedCount = 0;
public void incrementProcessed() { processedCount++; }
public int getProcessedCount() { return processedCount; }
}
// 배치 실행
@Service
public class BatchProcessor {
private final ObjectProvider<BatchContext> contextProvider;
public void executeBatch(List<Item> items) {
BatchScope.startBatch();
try {
BatchContext ctx = contextProvider.getObject();
for (Item item : items) {
processItem(item);
ctx.incrementProcessed();
}
log.info("Processed: {}", ctx.getProcessedCount());
} finally {
BatchScope.endBatch();
}
}
}
테넌트 스코프: 멀티테넌시
멀티테넌트 애플리케이션에서 테넌트별 빈 인스턴스를 관리하는 커스텀 스코프:
public class TenantScope implements Scope {
private final ConcurrentMap<String, Map<String, Object>> tenantBeans =
new ConcurrentHashMap<>();
@Override
public Object get(String name, ObjectFactory<?> objectFactory) {
String tenantId = TenantContext.getCurrentTenant();
if (tenantId == null) {
throw new IllegalStateException("No tenant context");
}
Map<String, Object> scope = tenantBeans
.computeIfAbsent(tenantId, k -> new ConcurrentHashMap<>());
return scope.computeIfAbsent(name, k -> objectFactory.getObject());
}
// 테넌트 제거 시 모든 빈 정리
public void removeTenant(String tenantId) {
tenantBeans.remove(tenantId);
}
@Override
public String getConversationId() {
return TenantContext.getCurrentTenant();
}
// ... remove, registerDestructionCallback 생략
}
// 테넌트별 캐시 매니저
@Component
@Scope(value = "tenant", proxyMode = ScopedProxyMode.TARGET_CLASS)
public class TenantCacheManager {
private final Map<String, Object> cache = new ConcurrentHashMap<>();
// 테넌트마다 독립된 캐시 인스턴스
}
스코프 선택 가이드
| 요구사항 | 스코프 | 주의사항 |
|---|---|---|
| 상태 없는 서비스 | singleton (기본) | 필드에 상태 저장 금지 |
| 매번 새 인스턴스 필요 | prototype + ObjectProvider | Spring이 소멸 관리 안 함 |
| 요청별 컨텍스트 전파 | request + Scope Proxy | 비동기 스레드에서 접근 불가 |
| 사용자별 상태 관리 | session | Serializable 구현, 클러스터 동기화 |
| 비즈니스 단위별 격리 | 커스텀 스코프 | 라이프사이클 직접 관리 필요 |
마무리
Spring Bean Scope는 singleton만으로 해결할 수 없는 상태 격리와 라이프사이클 관리 문제를 해결합니다. prototype의 주입 함정을 이해하고, request scope로 요청 컨텍스트를 전파하며, 커스텀 스코프로 배치·테넌트별 빈을 관리하면 더 유연한 아키텍처를 설계할 수 있습니다.
관련 글로 Spring Bean Lifecycle 심화와 Spring ObjectProvider 지연 주입도 함께 참고하세요.