@Singleton@ApplicationScoped 둘 다 인스턴스가 하나인 건 똑같아 보이는데, 왜 두 개가 따로 존재할까? 이 질문에 제대로 답할 수 있다면 CDI 스코프를 이해하고 있는 겁니다.

CDI 스코프 정리

CDI(Contexts and Dependency Injection)에서 스코프는 빈의 생명주기를 결정합니다. Quarkus의 ArC가 지원하는 주요 스코프를 정리합니다.

스코프어노테이션생명주기프록시
Application@ApplicationScoped앱 전체에서 하나O
Singleton@Singleton앱 전체에서 하나X
Request@RequestScopedHTTP 요청 하나O
Dependent@Dependent주입 지점마다 새로 생성X

@Singleton vs @ApplicationScoped

둘 다 애플리케이션 전체에서 인스턴스가 하나라는 점은 같습니다. 차이는 프록시 생성 여부 입니다.

JAVA
@ApplicationScoped
public class UserService {
    // ArC가 프록시 객체를 만들어서 주입
    // → 지연 초기화 가능
    // → 다른 스코프의 빈을 주입받을 수 있음
}

@Singleton
public class ConfigHolder {
    // 프록시 없이 실제 인스턴스를 직접 주입
    // → 즉시 초기화
    // → 약간 더 빠름 (프록시 오버헤드 없음)
}

실질적으로 어떤 차이가 나는지 보면 이렇습니다.

  • @ApplicationScoped: 프록시가 생기므로 @RequestScoped 빈을 필드로 주입받아도 문제 없음. 프록시가 실제 요청마다 올바른 인스턴스를 찾아줌
  • @Singleton: 프록시가 없으므로 @RequestScoped 빈을 직접 주입받으면 ** 항상 같은 인스턴스 **를 참조하게 됨 (버그의 원인)
JAVA
@Singleton
public class BadExample {
    @Inject
    RequestScopedBean bean; // 위험! 프록시가 없어서 요청마다 갱신되지 않음
}

@ApplicationScoped
public class GoodExample {
    @Inject
    RequestScopedBean bean; // 안전. 프록시가 올바른 인스턴스를 매번 찾아줌
}

공부하다 보니, 대부분의 경우 @ApplicationScoped를 쓰는 게 안전하다는 결론에 도달했습니다. @Singleton은 프록시 오버헤드가 문제가 될 정도로 성능에 민감한 경우에만 사용하면 됩니다.


@Qualifier — 같은 타입의 여러 구현체 구분

인터페이스 하나에 구현체가 여러 개라면, CDI는 어떤 걸 주입해야 할지 모릅니다. @Qualifier로 구분해줘야 합니다.

커스텀 Qualifier 정의

JAVA
@Qualifier
@Retention(RUNTIME)
@Target({FIELD, TYPE, METHOD, PARAMETER})
public @interface Premium {}

@Qualifier
@Retention(RUNTIME)
@Target({FIELD, TYPE, METHOD, PARAMETER})
public @interface Standard {}
JAVA
public interface NotificationSender {
    void send(String to, String message);
}

@Premium
@ApplicationScoped
public class SmsNotificationSender implements NotificationSender {
    @Override
    public void send(String to, String message) {
        // SMS 발송
    }
}

@Standard
@ApplicationScoped
public class EmailNotificationSender implements NotificationSender {
    @Override
    public void send(String to, String message) {
        // 이메일 발송
    }
}
JAVA
@ApplicationScoped
public class NotificationService {

    @Inject
    @Premium
    NotificationSender premiumSender;  // SMS

    @Inject
    @Standard
    NotificationSender standardSender; // 이메일

    public void notify(User user, String message) {
        if (user.isPremium()) {
            premiumSender.send(user.getPhone(), message);
        } else {
            standardSender.send(user.getEmail(), message);
        }
    }
}

@Named — 간단한 문자열 기반 구분

커스텀 Qualifier를 매번 만들기 번거로우면 @Named를 쓸 수도 있습니다.

JAVA
@Named("sms")
@ApplicationScoped
public class SmsNotificationSender implements NotificationSender { }

@Named("email")
@ApplicationScoped
public class EmailNotificationSender implements NotificationSender { }

// 주입
@Inject
@Named("sms")
NotificationSender smsSender;

@Named는 편하지만 문자열 기반이라 타이포 위험이 있습니다. 프로젝트 규모가 커지면 커스텀 @Qualifier를 쓰는 게 안전합니다. Spring의 @Qualifier("name")과 비슷한 맥락입니다.


@Alternative + @Priority — 환경별 빈 교체

테스트나 특정 환경에서 구현체를 교체하고 싶을 때 사용합니다.

JAVA
@ApplicationScoped
public class RealPaymentGateway implements PaymentGateway {
    @Override
    public PaymentResult charge(int amount) {
        // 실제 PG사 API 호출
        return callExternalApi(amount);
    }
}

@Alternative
@Priority(1)
@ApplicationScoped
public class MockPaymentGateway implements PaymentGateway {
    @Override
    public PaymentResult charge(int amount) {
        // 테스트용 목 구현
        return PaymentResult.success(amount);
    }
}

@Priority 값이 높은 @Alternative가 원래 빈을 대체합니다. Quarkus에서는 프로파일과 조합해서 더 유연하게 사용할 수 있습니다.

JAVA
@Alternative
@Priority(1)
@ApplicationScoped
@IfBuildProfile("test")  // test 프로파일에서만 활성화
public class MockPaymentGateway implements PaymentGateway { }

Spring에서는 @Profile("test") + @Primary 조합으로 비슷한 걸 합니다. CDI의 @Alternative + @Priority는 개념은 같지만, 우선순위를 숫자로 명시적으로 지정한다는 차이가 있습니다.


인터셉터 — 횡단 관심사 처리

인터셉터는 AOP(Aspect-Oriented Programming)의 CDI 버전입니다. 로깅, 트랜잭션, 성능 측정 같은 공통 로직을 메서드 호출 전후에 끼워 넣을 수 있습니다.

인터셉터 바인딩 정의

JAVA
@InterceptorBinding
@Retention(RUNTIME)
@Target({TYPE, METHOD})
public @interface Logged {}

인터셉터 구현

JAVA
@Logged
@Interceptor
@Priority(Interceptor.Priority.APPLICATION)
public class LoggingInterceptor {

    @AroundInvoke
    Object logMethodCall(InvocationContext context) throws Exception {
        String method = context.getMethod().getName();
        String className = context.getTarget().getClass().getSimpleName();

        Log.infof("[%s.%s] 호출 시작 - 파라미터: %s",
            className, method, Arrays.toString(context.getParameters()));

        long start = System.currentTimeMillis();
        try {
            Object result = context.proceed();
            long elapsed = System.currentTimeMillis() - start;

            Log.infof("[%s.%s] 호출 완료 - 소요시간: %dms",
                className, method, elapsed);
            return result;
        } catch (Exception e) {
            long elapsed = System.currentTimeMillis() - start;
            Log.errorf("[%s.%s] 예외 발생 (%dms): %s",
                className, method, elapsed, e.getMessage());
            throw e;
        }
    }
}

인터셉터 적용

JAVA
@ApplicationScoped
public class OrderService {

    @Logged  // 이 메서드에만 적용
    public Order createOrder(OrderRequest request) {
        // 주문 생성 로직
        return order;
    }
}

// 또는 클래스 레벨에 붙이면 모든 메서드에 적용
@Logged
@ApplicationScoped
public class PaymentService {
    // 모든 public 메서드에 로깅 적용
}

Spring AOP와의 차이를 정리하면 이렇습니다. Spring은 @Aspect + 포인트컷 표현식으로 유연하게 대상을 지정할 수 있지만, CDI 인터셉터는 어노테이션 바인딩 방식이라 더 명시적입니다. "어떤 메서드에 인터셉터가 적용되는지"가 코드에서 바로 보인다는 장점이 있습니다.


CDI 이벤트 — 느슨한 결합

CDI 이벤트는 옵저버 패턴의 CDI 구현입니다. 발행자와 구독자가 서로를 직접 참조하지 않아도 됩니다.

이벤트 정의와 발행

JAVA
// 이벤트 객체
public record OrderCreatedEvent(
    String orderId,
    String userId,
    int totalAmount,
    Instant createdAt
) {}
JAVA
@ApplicationScoped
public class OrderService {

    @Inject
    Event<OrderCreatedEvent> orderCreatedEvent;

    @Transactional
    public Order createOrder(OrderRequest request) {
        Order order = Order.from(request);
        order.persist();

        // 이벤트 발행 — 누가 구독하는지 OrderService는 모름
        orderCreatedEvent.fire(new OrderCreatedEvent(
            order.id, request.userId(),
            request.totalAmount(), Instant.now()
        ));

        return order;
    }
}

이벤트 구독

JAVA
@ApplicationScoped
public class NotificationObserver {

    @Inject
    NotificationSender sender;

    void onOrderCreated(@Observes OrderCreatedEvent event) {
        sender.send(event.userId(),
            "주문이 완료되었습니다. 주문번호: " + event.orderId());
    }
}

@ApplicationScoped
public class PointObserver {

    void onOrderCreated(@Observes OrderCreatedEvent event) {
        int points = event.totalAmount() / 100;
        // 포인트 적립 로직
        Log.infof("사용자 %s에게 %d 포인트 적립", event.userId(), points);
    }
}

// 비동기 이벤트도 가능
@ApplicationScoped
public class AnalyticsObserver {

    void onOrderCreated(@ObservesAsync OrderCreatedEvent event) {
        // 별도 스레드에서 실행
        analyticsClient.trackOrder(event);
    }
}

비동기 이벤트를 발행할 때는 fireAsync를 사용합니다.

JAVA
orderCreatedEvent.fireAsync(new OrderCreatedEvent(...))
    .thenAccept(e -> Log.info("비동기 이벤트 처리 완료"));

이 패턴을 처음 보면 Spring의 ApplicationEventPublisher@EventListener가 떠오릅니다. 실제로 거의 동일한 개념입니다. CDI 표준이니 Quarkus가 아닌 다른 CDI 구현체에서도 똑같이 동작한다는 차이가 있죠.


ArC에서 지원하지 않는 CDI 기능

ArC는 CDI Lite 기반이므로, 전체 CDI 스펙 중 일부 기능은 지원하지 않습니다.

미지원 기능설명
@ConversationScopedJSF 대화 스코프. 웹 프레임워크에 종속적이라 제외
Portable Extensions전통 CDI 확장 메커니즘. Quarkus는 Build Time Extensions 사용
@DecoratorCDI 데코레이터 패턴. ArC에서는 실험적 지원
Passivation빈의 직렬화/역직렬화. 서버리스 환경에서는 불필요
BeanManager의 일부 메서드동적 빈 룩업의 일부 API가 제한됨
JAVA
// ArC에서 동적으로 빈을 가져오려면 이렇게
@ApplicationScoped
public class DynamicLookup {

    @Inject
    Instance<NotificationSender> senders;

    // 모든 구현체 순회
    public void notifyAll(String message) {
        for (NotificationSender sender : senders) {
            sender.send("admin", message);
        }
    }

    // 특정 Qualifier로 필터링
    public void notifyPremium(String message) {
        senders.select(new Premium.Literal())
               .get()
               .send("admin", message);
    }
}

Spring DI와의 비교 요약

Spring에서 넘어온 개발자를 위해 주요 매핑을 정리합니다.

SpringCDI (Quarkus)비고
@Component / @Service@ApplicationScopedCDI는 스코프 어노테이션이 빈 등록을 겸함
@Autowired@Inject거의 동일
@Qualifier("name")@Named("name") 또는 커스텀 @QualifierCDI가 더 타입 안전
@Primary@Alternative + @PriorityCDI는 우선순위를 숫자로 지정
@Profile("test")@IfBuildProfile("test")Quarkus 전용
@Aspect + 포인트컷@Interceptor + 바인딩CDI가 더 명시적
ApplicationEventPublisherEvent<T>거의 동일
@EventListener@Observes거의 동일

정리

CDI 심화 기능의 핵심을 요약합니다.

  • @ApplicationScoped는 프록시가 생기고, @Singleton은 프록시가 없다 — 대부분의 경우 @ApplicationScoped가 안전
  • @Qualifier 로 같은 타입의 여러 구현체를 구분하고, @Alternative 로 환경별 교체가 가능
  • 인터셉터 는 AOP의 CDI 버전으로, 어노테이션 바인딩이라 적용 대상이 코드에서 명시적으로 보임
  • CDI 이벤트 는 Event<T> + @Observes로 느슨한 결합을 구현
  • ArC 는 CDI Lite 기반이라 @ConversationScoped, Portable Extensions 등 일부 기능은 미지원

결국 CDI의 핵심 개념은 Spring DI와 크게 다르지 않습니다. 어노테이션 이름만 다를 뿐 "의존성 주입 + AOP + 이벤트"라는 세 축은 동일합니다. Spring 경험이 있다면 위의 매핑 표만 보면 바로 적응할 수 있습니다.

댓글 로딩 중...