Spring Bean Scope 커스텀 설계

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 지연 주입도 함께 참고하세요.

위로 스크롤
WordPress Appliance - Powered by TurnKey Linux