CDI 심화 — 인터셉터, 이벤트, Qualifier, 그리고 ArC의 스코프 관리
@Singleton과@ApplicationScoped둘 다 인스턴스가 하나인 건 똑같아 보이는데, 왜 두 개가 따로 존재할까? 이 질문에 제대로 답할 수 있다면 CDI 스코프를 이해하고 있는 겁니다.
CDI 스코프 정리
CDI(Contexts and Dependency Injection)에서 스코프는 빈의 생명주기를 결정합니다. Quarkus의 ArC가 지원하는 주요 스코프를 정리합니다.
| 스코프 | 어노테이션 | 생명주기 | 프록시 |
|---|---|---|---|
| Application | @ApplicationScoped | 앱 전체에서 하나 | O |
| Singleton | @Singleton | 앱 전체에서 하나 | X |
| Request | @RequestScoped | HTTP 요청 하나 | O |
| Dependent | @Dependent | 주입 지점마다 새로 생성 | X |
@Singleton vs @ApplicationScoped
둘 다 애플리케이션 전체에서 인스턴스가 하나라는 점은 같습니다. 차이는 프록시 생성 여부 입니다.
@ApplicationScoped
public class UserService {
// ArC가 프록시 객체를 만들어서 주입
// → 지연 초기화 가능
// → 다른 스코프의 빈을 주입받을 수 있음
}
@Singleton
public class ConfigHolder {
// 프록시 없이 실제 인스턴스를 직접 주입
// → 즉시 초기화
// → 약간 더 빠름 (프록시 오버헤드 없음)
}
실질적으로 어떤 차이가 나는지 보면 이렇습니다.
@ApplicationScoped: 프록시가 생기므로@RequestScoped빈을 필드로 주입받아도 문제 없음. 프록시가 실제 요청마다 올바른 인스턴스를 찾아줌@Singleton: 프록시가 없으므로@RequestScoped빈을 직접 주입받으면 ** 항상 같은 인스턴스 **를 참조하게 됨 (버그의 원인)
@Singleton
public class BadExample {
@Inject
RequestScopedBean bean; // 위험! 프록시가 없어서 요청마다 갱신되지 않음
}
@ApplicationScoped
public class GoodExample {
@Inject
RequestScopedBean bean; // 안전. 프록시가 올바른 인스턴스를 매번 찾아줌
}
공부하다 보니, 대부분의 경우
@ApplicationScoped를 쓰는 게 안전하다는 결론에 도달했습니다.@Singleton은 프록시 오버헤드가 문제가 될 정도로 성능에 민감한 경우에만 사용하면 됩니다.
@Qualifier — 같은 타입의 여러 구현체 구분
인터페이스 하나에 구현체가 여러 개라면, CDI는 어떤 걸 주입해야 할지 모릅니다. @Qualifier로 구분해줘야 합니다.
커스텀 Qualifier 정의
@Qualifier
@Retention(RUNTIME)
@Target({FIELD, TYPE, METHOD, PARAMETER})
public @interface Premium {}
@Qualifier
@Retention(RUNTIME)
@Target({FIELD, TYPE, METHOD, PARAMETER})
public @interface Standard {}
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) {
// 이메일 발송
}
}
@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를 쓸 수도 있습니다.
@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 — 환경별 빈 교체
테스트나 특정 환경에서 구현체를 교체하고 싶을 때 사용합니다.
@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에서는 프로파일과 조합해서 더 유연하게 사용할 수 있습니다.
@Alternative
@Priority(1)
@ApplicationScoped
@IfBuildProfile("test") // test 프로파일에서만 활성화
public class MockPaymentGateway implements PaymentGateway { }
Spring에서는
@Profile("test")+@Primary조합으로 비슷한 걸 합니다. CDI의@Alternative+@Priority는 개념은 같지만, 우선순위를 숫자로 명시적으로 지정한다는 차이가 있습니다.
인터셉터 — 횡단 관심사 처리
인터셉터는 AOP(Aspect-Oriented Programming)의 CDI 버전입니다. 로깅, 트랜잭션, 성능 측정 같은 공통 로직을 메서드 호출 전후에 끼워 넣을 수 있습니다.
인터셉터 바인딩 정의
@InterceptorBinding
@Retention(RUNTIME)
@Target({TYPE, METHOD})
public @interface Logged {}
인터셉터 구현
@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;
}
}
}
인터셉터 적용
@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 구현입니다. 발행자와 구독자가 서로를 직접 참조하지 않아도 됩니다.
이벤트 정의와 발행
// 이벤트 객체
public record OrderCreatedEvent(
String orderId,
String userId,
int totalAmount,
Instant createdAt
) {}
@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;
}
}
이벤트 구독
@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를 사용합니다.
orderCreatedEvent.fireAsync(new OrderCreatedEvent(...))
.thenAccept(e -> Log.info("비동기 이벤트 처리 완료"));
이 패턴을 처음 보면 Spring의
ApplicationEventPublisher와@EventListener가 떠오릅니다. 실제로 거의 동일한 개념입니다. CDI 표준이니 Quarkus가 아닌 다른 CDI 구현체에서도 똑같이 동작한다는 차이가 있죠.
ArC에서 지원하지 않는 CDI 기능
ArC는 CDI Lite 기반이므로, 전체 CDI 스펙 중 일부 기능은 지원하지 않습니다.
| 미지원 기능 | 설명 |
|---|---|
@ConversationScoped | JSF 대화 스코프. 웹 프레임워크에 종속적이라 제외 |
| Portable Extensions | 전통 CDI 확장 메커니즘. Quarkus는 Build Time Extensions 사용 |
@Decorator | CDI 데코레이터 패턴. ArC에서는 실험적 지원 |
| Passivation | 빈의 직렬화/역직렬화. 서버리스 환경에서는 불필요 |
BeanManager의 일부 메서드 | 동적 빈 룩업의 일부 API가 제한됨 |
// 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에서 넘어온 개발자를 위해 주요 매핑을 정리합니다.
| Spring | CDI (Quarkus) | 비고 |
|---|---|---|
@Component / @Service | @ApplicationScoped | CDI는 스코프 어노테이션이 빈 등록을 겸함 |
@Autowired | @Inject | 거의 동일 |
@Qualifier("name") | @Named("name") 또는 커스텀 @Qualifier | CDI가 더 타입 안전 |
@Primary | @Alternative + @Priority | CDI는 우선순위를 숫자로 지정 |
@Profile("test") | @IfBuildProfile("test") | Quarkus 전용 |
@Aspect + 포인트컷 | @Interceptor + 바인딩 | CDI가 더 명시적 |
ApplicationEventPublisher | Event<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 경험이 있다면 위의 매핑 표만 보면 바로 적응할 수 있습니다.