84 lines
8.0 KiB
Markdown
84 lines
8.0 KiB
Markdown
---
|
|
title: error / method-security-class-pointcut-final-usecase-bean-2026-06-12
|
|
source_type: error-note
|
|
status: raw
|
|
related_branches: [feature-domain-event-outbox-contract]
|
|
related_projects: [ca-skeleton]
|
|
tags: [error, ca-skeleton, spring, spring-security, method-security, cglib, aop, outbox, scheduler]
|
|
created: 2026-06-12
|
|
status_label: resolved
|
|
---
|
|
|
|
# error: method-security-class-pointcut-final-usecase-bean-2026-06-12
|
|
|
|
> Layer: `raw/errors/` — 작업 중 마주친 단일 실패·트러블슈팅 기록. 원본은 raw에 영구 보관한다.
|
|
|
|
## Parent / 부모
|
|
|
|
- [[raw/branch-notes/feature-domain-event-outbox-contract]] — Task E `OutboxConfig` 의 `PublishPendingOutboxEventsUseCase` 수동 `@Bean` 등록이 adapter-web 의 method security 와 충돌해 bootRun 기동 실패.
|
|
|
|
## 증상 / Symptom
|
|
|
|
- 에러 메시지 1 (기동 실패, 원문 그대로):
|
|
```text
|
|
Error creating bean with name 'publishPendingOutboxEventsUseCase' defined in class path resource
|
|
[dev/caskeleton/bootstrap/outbox/OutboxConfig.class]: Could not generate CGLIB subclass of class
|
|
dev.caskeleton.application.outbox.PublishPendingOutboxEventsUseCase
|
|
...
|
|
Caused by: java.lang.IllegalArgumentException: Cannot subclass final class
|
|
dev.caskeleton.application.outbox.PublishPendingOutboxEventsUseCase
|
|
```
|
|
- 에러 메시지 2 (`final` 제거 후 매 틱 5초마다, 원문 그대로):
|
|
```text
|
|
outbox relay scheduler: unexpected error in relay cycle — relay will retry on the next tick
|
|
org.springframework.security.authentication.AuthenticationCredentialsNotFoundException:
|
|
An Authentication object was not found in the SecurityContext
|
|
at ...AuthorizationManagerBeforeMethodInterceptor.getAuthentication(...)
|
|
```
|
|
- 발생 컨텍스트: `./gradlew bootRun` 풀 컨텍스트 기동. Testcontainers 계약 테스트는 use case 를 `new` 로 직접 조립(minimal context, method security 부재)하므로 미검출 — 풀 컨텍스트에서만 재현.
|
|
- 발생 환경: local, Spring Boot 3.5.15, Spring Security `@EnableMethodSecurity(prePostEnabled = false)` + 커스텀 Advisor.
|
|
- 재현 가능 여부: `always`.
|
|
|
|
## 재현 절차 / Reproduction
|
|
|
|
1. adapter-web `MethodSecurityConfig` 가 `AnnotationMatchingPointcut.forClassAnnotation(RequiresPermission.class)` 를 포함한 union pointcut 의 `AuthorizationManagerBeforeMethodInterceptor` Advisor 를 등록한 상태.
|
|
2. `@RequiresPermission` 이 클래스 레벨에 붙은 `final` 클래스를 `@Bean` 으로 등록 (`OutboxConfig.publishPendingOutboxEventsUseCase`).
|
|
3. 기동 → auto-proxy 가 Advisor 매칭 빈을 CGLIB 서브클래싱 시도 → `Cannot subclass final class` 로 컨텍스트 refresh 실패. (Spring Boot 기본 `spring.aop.proxy-target-class=true` — 인터페이스가 있어도 CGLIB.)
|
|
4. `final` 만 제거하면 기동은 성공하지만, `@Scheduled` 스케줄러 스레드에는 `Authentication` 이 없으므로 use case 호출 시마다 `AuthenticationCredentialsNotFoundException` — relay 가 한 건도 처리 못 함.
|
|
|
|
## 조사 단계 / Investigation log
|
|
|
|
- 2026-06-12 — bootRun 로그 첫 실패는 Flyway `Connection to localhost:5432 refused` — `ca-pg` PostgreSQL 컨테이너가 18시간 전 Exited (restart policy `no`, 재부팅 후 자동 시작 안 됨). `docker start ca-pg` 로 해소 (환경 문제, 코드 무관).
|
|
- 2026-06-12 — 두 번째 실패가 CGLIB `Cannot subclass final class`. 동작하는 4개 sample use case (`CreateWorkLogUseCase` 등) 와 대조 → 전부 `@RequiresPermission` + **non-final** `public class`. outbox use case 만 `public final class`.
|
|
- 2026-06-12 — `final` 제거로 기동 성공했으나 relay 틱마다 `AuthenticationCredentialsNotFoundException`. `AuthorizationManagerBeforeMethodInterceptor.getAuthentication` 은 SecurityContext 가 비어 있으면 커스텀 `AuthorizationManager.check` 도달 전에 throw — fail-closed 라 매니저 측 우회 불가.
|
|
- 2026-06-12 — use case Javadoc 의 설계 의도 확인: "enforcement in the scheduler context is by convention (the scheduler is app-bootstrap-internal)" — 즉 annotation 은 ArchUnit D4 충족용 선언이고 스케줄러 경로 런타임 집행은 의도가 아님. 계약 테스트(`OutboxContainerTestSupport.relayUseCase`)도 bean 이 아닌 `new` 조립.
|
|
|
|
## 근본 원인 / Root cause
|
|
|
|
- 직접 원인: `final` 클래스가 CGLIB auto-proxy 대상이 됨 (#1) / 인증 없는 스케줄러 스레드에서 method security 가 fail-closed 거부 (#2).
|
|
- 근본 원인: **클래스 레벨 `@RequiresPermission` pointcut 이 있는 컨텍스트에서, 그 annotation 이 붙은 클래스를 Spring bean 으로 등록하는 행위 자체**가 두 증상의 공통 원인. bean 등록 = advisor 매칭 = 프록시 + 런타임 집행. 스케줄러 전용 시스템 use case 는 둘 다 비의도.
|
|
- 트리거 조건: `@RequiresPermission` 클래스-레벨 annotation + 해당 클래스의 bean 등록 + (a) `final` 또는 (b) 비인증 스레드(scheduler/batch)에서의 호출.
|
|
|
|
## Sources / 근거
|
|
|
|
- 로컬 검증: bootRun 로그 3회 (`/tmp/bootrun{2,3,4}.log`) — 수정 전 기동 실패/틱 ERROR, 수정 후 `Started CaSkeletonApplication in 3.394 seconds` + 3틱 이상 ERROR 0건 + `/api/healthcheck` HTTP 200 (`locally-verified`).
|
|
- 수정 후 회귀: `./gradlew :application-core:test` (outbox 3개 클래스 41건 포함 green), `:app-bootstrap:test` 224/224 PASS (ArchUnit 48 rules + Testcontainers 계약 5종 실행), `verifyCleanArchitectureDependencies` PASS.
|
|
- Spring 공식 문서 인용은 미보강 (`needs-confirmation` — proxy-target-class 기본값 및 method security 의 fail-closed 동작에 대한 reference 절 인용 권고).
|
|
|
|
## 해결 / Resolution
|
|
|
|
- 적용한 조치: `PublishPendingOutboxEventsUseCase` 를 **context bean 에서 제외** — `OutboxConfig` 의 단독 `@Bean` 제거, `outboxRelayScheduler` `@Bean` 메서드 내부에서 수동 조립(계약 테스트와 동일 방식). `OutboxRelayScheduler` 는 `@Component` 스캔 제거 후 `OutboxConfig` `@Bean` 등록으로 이전 (`@ConditionalOnProperty` 게이트는 `@Bean` 메서드로 이동, 동일 property). use case 는 canonical 형태인 `public final class` 복원. 두 클래스 Javadoc 에 "bean 으로 등록하면 안 되는 이유" 제약 명시.
|
|
- 검증 방법: bootRun 기동 + healthcheck 200 + relay 3틱 ERROR 0건; 위 Gradle 회귀 전부 green.
|
|
- 잔여 위험: `outbox:relay` 권한은 런타임 미집행(선언적 convention). 실제 집행이 필요해지면 스케줄러에 시스템 principal(SecurityContext) 을 세우고 role registry 에 권한을 매핑하는 별도 설계 결정 필요 — 보안 설계 확장이므로 리뷰 체인 몫.
|
|
|
|
## 회고 / Lessons
|
|
|
|
- 빨리 감지하는 신호: "Could not generate CGLIB subclass … final class" 가 `@Bean` 등록 빈에서 나오면, 어떤 Advisor 가 그 빈을 매칭하는지부터 추적 (`@RequiresPermission`/`@Transactional`/`@Observed` 류 클래스-레벨 pointcut). `final` 제거는 증상 치료 — 프록시가 "왜" 생기는지가 근본 질문.
|
|
- 예방 체크리스트: 클래스-레벨 annotation pointcut 이 있는 프로젝트에서 그 annotation 이 붙은 타입을 bean 으로 등록할 때는 (1) final 여부, (2) 호출 스레드의 SecurityContext 유무를 함께 점검. 스케줄러/배치 전용 use case 는 bean 등록 대신 수동 조립을 기본으로.
|
|
- 검출 공백: minimal-context 계약 테스트는 풀 컨텍스트 배선 결함을 못 잡는다 — 풀 컨텍스트 smoke 테스트(`@SpringBootTest` + Testcontainers context-load)가 없으면 이 부류는 bootRun 에서만 터진다 (개선 후보).
|
|
- wiki 일반화 후보: "클래스-레벨 AOP pointcut 환경에서 bean 등록은 곧 '프록시 + 런타임 집행' 옵트인이다 — 선언만 원하면 bean 으로 만들지 마라" (wiki/concepts 추출 후보).
|
|
|
|
## Related / 관련
|
|
|
|
- 관련 에러: [[raw/errors/spring-configuration-bean-factory-method-not-processed-2026-06-11]] — 같은 "Spring 등록 방식이 처리 여부를 결정한다" 계열, [[raw/errors/testcontainers-two-context-shared-datasource-close-2026-06-11]] — 같은 branch 의 계약 테스트 배선 문제.
|