890 lines
54 KiB
Markdown
890 lines
54 KiB
Markdown
# GraphQL 인바운드 모듈 상세 코드·아키텍처 리뷰
|
|
|
|
- 기준 일자: 2026-08-14
|
|
- 기준 Git HEAD: `ac874e49e608b35429f82aa098574b52a68f2069`
|
|
- 대상 Gradle leaf: `:adapter:inbound:graphql`
|
|
- 주 대상 경로: `src/adapter/inbound/graphql`
|
|
- 교차 확인 경로: `src/config/architecture/modules.json`, `src/gradle/graphql-platform-conventions.gradle`, `src/.gitignore`
|
|
- 판정: **CHANGES REQUIRED / 현재 컴파일 불가**
|
|
- 검토 방식: 전체 파일·import·production reference inventory, 핵심 실행 경로 정독, 세 개의 독립 병렬 리뷰, Gradle focused/architecture 검증
|
|
- 변경 범위: 이 리뷰 문서만 추가했다. production/test 코드는 수정하지 않았다.
|
|
|
|
리뷰 도중 HEAD가 `c3043e530a604315c4df341b87b5470c7617ea03`에서 위 commit으로 이동했지만,
|
|
GraphQL tree hash는 두 revision 모두 `bd8307364e2312814995e5f3bb1386cee37b1498`이고
|
|
`src/.gitignore`, GraphQL convention, architecture registry에도 delta가 없음을 확인했다.
|
|
|
|
## 1. 결론
|
|
|
|
현재 GraphQL leaf는 373개 production Java 파일과 75개 test Java 파일을 가진 큰 실행 플랫폼 후보지만,
|
|
두 층의 문제가 겹쳐 있다.
|
|
|
|
첫 번째는 즉시 고쳐야 하는 **빌드 차단**이다. `src/.gitignore`의 unanchored `build/` 규칙이 Gradle
|
|
산출물뿐 아니라 Java source package인 `...graphql.build`까지 무시한다. 문서와 production code는
|
|
`GraphQlBuildModel`, `GraphQlStableModule`, `GraphQlAdvancedModule`, `GraphQlModuleBoundaryTest`가 있다고
|
|
주장하지만 실제 checkout에는 없다. 그 결과 focused test는 test 실행 전에 `compileJava`에서 7개
|
|
오류로 실패하고, 단일 leaf 안의 Stable/Advanced 경계를 지킨다는 핵심 안전망도 함께 사라졌다.
|
|
|
|
두 번째는 더 근본적인 **런타임 진실성 문제**다. cost, authorization, DataLoader, cursor,
|
|
idempotency, observation, persisted operation, subscription 등 많은 정책과 값 객체가 구현되어 있지만,
|
|
대부분 Spring GraphQL이 실제 `/graphql` 요청을 처리하는 extension point에 연결되지 않는다. 현재 HTTP
|
|
qualification은 Spring Boot 기본 endpoint와 health controller/error resolver를 검증할 뿐, 이 플랫폼의
|
|
pipeline을 거치지 않는다. 따라서 unit test가 복구되어 green이 되더라도 “정책 객체가 맞다”는 증거와
|
|
“실제 요청에 정책이 강제된다”는 증거를 분리해야 한다.
|
|
|
|
즉시 적용할 원칙은 다음과 같다.
|
|
|
|
1. GQL-001을 단독 PR로 먼저 처리해 compile과 내부 경계 검사를 복구한다.
|
|
2. 복구 전후 모두 현재 artifact를 `runtime-ready GraphQL execution platform`으로 승격하지 않는다.
|
|
3. Spring 기본 `/graphql`을 canonical transport로 정하고, 정책을 공식 extension point에 연결한다.
|
|
4. 자체 MVC/WebFlux adapter를 실제 endpoint로 만들 계획이 없다면 제거한다. 평행 실행 경로를 두지 않는다.
|
|
5. repository 자동 노출은 Advanced라도 제거한다. application use case를 우회하는 예외를 만들지 않는다.
|
|
6. correctness/security red test를 먼저 고정한 뒤 public API와 Gradle leaf를 단계적으로 분리한다.
|
|
7. 실제 random-port request가 정책에 의해 거부되고 resolver/use case가 0회 호출됨을 promotion 증거로 삼는다.
|
|
|
|
## 2. 범위와 증거 경계
|
|
|
|
### 2.1 현재 규모
|
|
|
|
| 항목 | 현재 값 |
|
|
|---|---:|
|
|
| production Java 파일 | 373 |
|
|
| production Java LOC | 20,155 |
|
|
| test Java 파일 | 75 |
|
|
| test Java LOC | 8,424 |
|
|
| test annotation (`@Test`, `@ParameterizedTest`) | 524 |
|
|
| 최상위 production package | 21 |
|
|
| main resource | `graphql/skeleton.graphqls` 1개 |
|
|
| test resource | qualification schema 1개 |
|
|
| 외부 Spring/GraphQL/Reactor import를 가진 production Java | 19 |
|
|
|
|
최상위 package는 `advanced`, `api`, `architecture`, `autoconfigure`, `compat`, `context`, `cost`,
|
|
`dataloader`, `error`, `execution`, `fetch`, `http`, `mutation`, `observation`, `pagination`, `policy`,
|
|
`release`, `scalar`, `schema`, `security`, `testkit`이다.
|
|
|
|
373개 중 354개가 Spring/GraphQL Java/Reactor type을 직접 import하지 않는다는 점은 framework-free policy
|
|
model을 추출할 여지가 크다는 뜻이다. 동시에 거의 모든 최상위 type이 public이어서 현재 한 jar가
|
|
사실상 수백 개의 API를 노출한다.
|
|
|
|
### 2.2 검토 깊이
|
|
|
|
| Path | Status | Evidence | Extracted facts |
|
|
|---|---|---|---|
|
|
| `src/adapter/inbound/graphql/build.gradle` | READ_FULL | 1-54 | servlet runtime 의존, WebFlux compile-only, test lane 등록 |
|
|
| `src/gradle/graphql-platform-conventions.gradle` | READ_FULL | 1-100 | Stable/contract/Advanced/performance lane과 누락된 boundary model 주장 |
|
|
| `src/adapter/inbound/graphql/CLAUDE.md` | READ_FULL | 1-143 | 단일 leaf 내부 28 bounded package, runtime opt-in, 실행 범위·검증 주장 |
|
|
| `src/adapter/inbound/graphql/README.md` | READ_FULL | 1-189 | health endpoint, error mapping, 설정·경계·Advanced 설계 근거 |
|
|
| `src/config/architecture/modules.json` GraphQL record | READ_FULL | GraphQL leaf record | 허용 project edge와 빈 runtime membership |
|
|
| `src/.gitignore` | READ_FULL | 1-16 + `git check-ignore` | `build/`가 Java source package까지 무시하는 직접 원인 |
|
|
| root controller/error resolver/schema | READ_FULL | production + 대응 tests | 현재 실제 Spring GraphQL endpoint 표면 |
|
|
| `autoconfigure`, `http`, `execution`, `architecture` | READ_FULL | production 핵심 경로 + 대응 tests | auto-config 등록, 실행 연결, transport, 경계 검사 |
|
|
| `cost`, `security`, `dataloader`, `pagination`, `mutation` | READ_FULL | 핵심 policy/codec/executor + 대응 tests | 구조 제한, tenant/auth, batch, cursor, idempotency correctness |
|
|
| `schema`, `compat`, `scalar`, `error`, `observation` | READ_FULL | production 핵심 경로 + 대응 tests | schema 조립/호환, scalar, wire error, cardinality |
|
|
| `advanced/**` | READ_PARTIAL | public entry/state transition/production reference scan + 주요 tests | persisted/admin/codegen/subscription/federation/transport seam |
|
|
| `release/**`, `testkit/**` | READ_PARTIAL | public contract/lane/reference scan + suite tests | self-reported evidence와 production artifact 오염 |
|
|
| production 373개/test 75개 전체 | READ_PARTIAL | inventory/import/reference/public-surface scan | 파일·package·사용처·실행 연결의 전수 정적 탐색 |
|
|
|
|
이 문서는 28,579 LOC의 모든 method를 line-by-line 승인한 결과가 아니다. 전체 inventory와 reference scan을
|
|
바탕으로 실행 seam과 고위험 policy를 정독한 구조·correctness 리뷰다. `advanced/**`, release/testkit의
|
|
세부 알고리즘은 명시한 범위 밖에서 `UNVERIFIED`이며, 실제 adopter/runtime·load·fault evidence도 없다.
|
|
|
|
## 3. 유지할 설계
|
|
|
|
리팩터링 과정에서 다음은 보존할 가치가 있다.
|
|
|
|
- registry상 GraphQL leaf의 production project dependency가 Clean Architecture 방향을 벗어나지 않는다.
|
|
- `runtime_memberships`가 비어 있어 현재 app-bootstrap/sample runtime에 조용히 유입되지 않는다.
|
|
- 실제 `HealthGraphqlController`는 얇고 feature/domain/repository 지식이 없다.
|
|
- 실제 Spring exception resolver는 shared error code만 노출하고 raw exception message를 사용하지 않는다.
|
|
- schema compatibility를 SDL 문자열 diff가 아니라 AST로 비교하고 결과를 결정적으로 정렬한다.
|
|
- partial data map에 null을 허용하는 defensive copy를 사용한다. 이를 `Map.copyOf`로 바꾸면 안 된다.
|
|
- cursor HMAC을 `MessageDigest.isEqual`로 비교하고 query/filter에 bind하려는 방향은 맞다.
|
|
- DataLoader 결과에서 `Present`, `Missing`, `Failed`를 구분하려는 결과 algebra는 유용하다.
|
|
- document traversal은 fragment cycle과 방문 node budget을 고려한다.
|
|
- Advanced capability가 기본 비활성이고 experimental production activation을 명시적으로 거부한다.
|
|
- test lane이 빈 performance evidence를 success로 위장하지 않으려는 fail-closed 의도는 좋다.
|
|
- broad static import scan에서 Stable package가 `...graphql.advanced`를 직접 import하는 edge와
|
|
production repository/JPA/Spring Data 직접 사용은 발견되지 않았다.
|
|
|
|
## 4. 우선순위 요약
|
|
|
|
| ID | 우선순위 | 주제 | 완료 조건 |
|
|
|---|---|---|---|
|
|
| GQL-001 | P0 | ignored `build` source package 때문에 compile 및 경계 모델 소실 | 비-ignore package로 모델 복구, compile/test/boundary negative fixture 통과 |
|
|
| GQL-002 | P0 | 플랫폼 정책이 실제 `/graphql` 실행 경로에 미연결 | real interceptor/instrumentation/DataLoader/wiring E2E에서 정책 거부 증명 |
|
|
| GQL-003 | P1 | auto-configuration 등록·binding default·실제 bean 검증 불일치 | 무설정 boot, imports metadata, 실제 override bean validation 통과 |
|
|
| GQL-004 | P1 | servlet artifact가 reactive profile도 표방 | MVC/WebFlux runtime classpath와 context가 별도 leaf에서 독립 통과 |
|
|
| GQL-005 | P1 | request byte 제한 미강제와 valid null variable 거부 | decode 전 body cap, null/omitted/value E2E 통과 |
|
|
| GQL-006 | P1 | `Accept` q-value/q=0 무시 | quality/specificity 기반 negotiation contract 통과 |
|
|
| GQL-007 | P1 | named fragment introspection 우회와 variable nesting 공백 | reachable fragment/variable JSON budget 거부 E2E 통과 |
|
|
| GQL-008 | P1 | resolver 경계 검사가 generic/JAR/subpackage를 놓치고 reactive type을 오판 | actual controller graph와 recursive generic negative fixture 통과 |
|
|
| GQL-009 | P1 | Advanced repository 자동 노출이 canonical hard-stop과 충돌 | repository exposure API 제거, application handler만 허용 |
|
|
| GQL-010 | P1 | cursor framing·rotation·direction·tenant binding 결함 | versioned codec property tests와 active-key/scope rejection 통과 |
|
|
| GQL-011 | P1 | mutation fingerprint collision과 tenant 없는 idempotency scope | typed canonical serialization과 tenant/version scope 테스트 통과 |
|
|
| GQL-012 | P1 | error resolver와 category contract가 두 벌 | 하나의 mapper를 모든 Spring/transport path가 사용 |
|
|
| GQL-013 | P1 | persisted-operation admin 상태·감사·인가가 durable하지 않음 | authenticated principal, CAS state machine, atomic audit contract 통과 |
|
|
| GQL-014 | P1 | codegen이 operation을 검증하지 않고 generator도 code를 생성하지 않음 | executable document validation 또는 정직한 planner 명명 |
|
|
| GQL-015 | P1 | schema comparator가 kind/default/extension/applied directive를 놓침 | breaking matrix와 extension ownership tests 통과 |
|
|
| GQL-016 | P1 | custom DataLoader와 timeout이 실제 loader 실행을 강제하지 않음 | Spring registry 연결과 real query-count/deadline test 통과 |
|
|
| GQL-017 | P1 | MVC concurrency/context와 WebFlux blocking bridge가 안전하지 않음 | bounded admission, context propagation, event-loop nonblocking 증명 |
|
|
| GQL-018 | P1 | pipeline stage 순서가 필요한 정보와 모순 | authenticate→parse/select→authorize→cost→execute executable chain |
|
|
| GQL-019 | P1 | subscription/replay/drain lifecycle의 race와 scope 공백 | atomic state/lease, actor+tenant+subscription binding 경쟁 test 통과 |
|
|
| GQL-020 | P2 | preparsed cache expiry 미사용·global miss serialization | expiry/single-flight/parallel-key test 통과 |
|
|
| GQL-021 | P2 | cancellation hook 하나가 나머지 cleanup을 막음 | all-hooks-once + suppressed exception contract 통과 |
|
|
| GQL-022 | P2 | scalar input/output bounds와 expansion limit 불일치 | BigDecimal/Long 양방향 boundary test 통과 |
|
|
| GQL-023 | P2 | raw operation name metric cardinality와 실제 Observation 미연결 | registered-name/`other` bound와 real MeterRegistry test 통과 |
|
|
| GQL-024 | P2 | testkit/fixed secret/in-memory 구현이 main jar에 포함 | test fixtures/optional leaf 분리 및 jar surface gate 통과 |
|
|
| GQL-025 | P2 | 373개 type의 과도한 public surface와 한 leaf의 낮은 응집도 | api/spi allowlist와 6~8 capability leaf 독립 compile/test |
|
|
| GQL-026 | P2 | GraphQL context/storage SPI ownership이 dependency 방향과 충돌 | inbound-local mapping과 transport-neutral operational port로 분리 |
|
|
| GQL-027 | P3 | 문서·설정 namespace·test count·runtime 지원 주장 drift | generated metadata/runtime adoption test 기반 문서 동기화 |
|
|
|
|
## 5. 상세 발견 사항과 구현 명세
|
|
|
|
### GQL-001 — `build` Java package가 `.gitignore`에 걸려 compile과 경계 검사가 함께 사라졌다
|
|
|
|
**근거**
|
|
|
|
- `src/.gitignore:2`는 root에 고정되지 않은 `build/` 패턴이다.
|
|
- `git check-ignore -v --no-index
|
|
src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/build/GraphQlBuildModel.java`
|
|
는 `src/.gitignore:2:build/`를 반환한다.
|
|
- `GraphQlPlatformAutoConfiguration.java:3,107-108`과
|
|
`advanced/bootstrap/GraphQlAdvancedDependencyRules.java:3,26-28,43`은 존재하지 않는
|
|
`dev...graphql.build.GraphQlBuildModel`을 참조한다.
|
|
- `CLAUDE.md:36-40`, `README.md:108-115`, `graphql-platform-conventions.gradle:11-13`은
|
|
`GraphQlStableModule`, `GraphQlAdvancedModule`, `GraphQlBuildModel`,
|
|
`GraphQlModuleBoundaryTest`가 실제 tree를 검사한다고 기록하지만 네 파일은 main/test tree에 없다.
|
|
- focused `:test`는 `compileJava`에서 해당 package/class 관련 7개 오류로 실패했다.
|
|
|
|
**실패 모드**
|
|
|
|
로컬 작성자가 ignored package 아래 파일을 생성하면 파일이 보이므로 잠시 compile될 수 있지만 commit에
|
|
들어가지 않는다. fresh checkout/CI에서는 소스가 사라져 compile이 깨진다. 더 위험한 변형은 production
|
|
참조를 지웠을 때다. build는 green이 될 수 있지만 Stable→Advanced/core purity/등록 package 검사가 없는
|
|
false green이 된다.
|
|
|
|
**구현 결정**
|
|
|
|
1. source package 이름을 `...graphql.build`가 아니라 `...graphql.moduleboundary`로 바꾼다. `.gitignore`
|
|
예외보다 역할이 분명하고 다른 도구의 `build` 디렉터리 규칙과 충돌하지 않는다.
|
|
2. 세 production model을 복원한다. 단, source-tree scanner가 runtime에 필요하지 않으면
|
|
`GraphQlBuildModel`을 test/build logic으로 이동하고 `GraphQlPlatformAutoConfiguration`의 runtime
|
|
source scan을 제거한다.
|
|
3. `GraphQlModuleBoundaryTest`는 실제 source/import graph를 검사하며 다음 세 negative fixture를 가진다.
|
|
Stable→Advanced import, core package의 Spring/GraphQL/Reactor import, 등록되지 않은 package.
|
|
4. `graphqlStableTest`가 해당 FQCN을 명시적으로 포함하고, test가 0개면 실패하게 유지한다.
|
|
5. repository-level gate에 `src/**/src/{main,test}/java/**/build/**` 같은 ignored source-package를
|
|
탐지하는 검사를 추가한다. Git에 존재하지 않는 파일을 CI가 찾을 수 없으므로, package naming rule과
|
|
required boundary-class existence 검사를 함께 둔다.
|
|
|
|
**필수 테스트/검증**
|
|
|
|
```bash
|
|
cd src
|
|
./gradlew :adapter:inbound:graphql:compileJava --console=plain
|
|
./gradlew :adapter:inbound:graphql:test --console=plain
|
|
./gradlew :adapter:inbound:graphql:graphqlStableTest --console=plain
|
|
./gradlew :adapter:inbound:graphql:test \
|
|
--tests '*GraphQlModuleBoundaryTest' --rerun-tasks --console=plain
|
|
```
|
|
|
|
### GQL-002 — 정책 카탈로그는 크지만 실제 `/graphql` 요청에는 실행되지 않는다
|
|
|
|
**근거**
|
|
|
|
- `GraphQlPlatformAutoConfiguration.java:43-103`은 startup validator, stage 목록, mapping gate,
|
|
observation convention POJO를 bean으로 만들지만 실제 request hook을 등록하지 않는다.
|
|
- `GraphQlExecutionPipeline`은 실행 가능한 Chain of Responsibility가 아니라 enum stage 순서 record다.
|
|
- MVC/WebFlux transport adapter의 `handle()`은 controller/router/filter가 아니며 production 호출처가 없다.
|
|
- production에는 `WebGraphQlInterceptor`, GraphQL Java `Instrumentation`, Spring
|
|
`BatchLoaderRegistry`, 실제 `PreparsedDocumentProvider` 연결이 없다.
|
|
- `GraphQlScalarWiringConfigurer`는 올바른 `RuntimeWiringConfigurer` 구현이지만 production bean이 아니다.
|
|
- HTTP qualification은 Spring Boot 기본 `/graphql`, health controller, root exception resolver와
|
|
test-only security를 검증한다. platform auto-configuration과 custom adapters를 import하지 않는다.
|
|
|
|
**실패 모드**
|
|
|
|
adopter가 최대 depth/complexity, introspection, authorization, timeout, DataLoader policy를 설정하고
|
|
안전하다고 판단해도, Spring 기본 endpoint는 이 객체들을 호출하지 않는다. unit test는 각 policy 함수가
|
|
정상임만 증명하고 endpoint adoption을 증명하지 못한다.
|
|
|
|
**구현 결정: Spring-native 단일 실행 경로**
|
|
|
|
1. Spring 기본 `/graphql`을 canonical HTTP transport로 유지한다.
|
|
2. `GraphQlPlatformWebInterceptor implements WebGraphQlInterceptor`에서 인증 principal을 검증된
|
|
request context로 매핑하고 GraphQL/Reactor context에 넣는다.
|
|
3. `GraphQlPlatformInstrumentation` 또는 `ExecutionGraphQlService` decorator에서 document
|
|
parse/selection, introspection, authorization, cost, deadline/cancellation을 실행한다.
|
|
4. `RuntimeWiringConfigurer`, `BatchLoaderRegistry` 등록/decorator, actual preparsed document provider,
|
|
canonical exception resolver를 auto-configuration이 bean으로 조립한다.
|
|
5. 현재 MVC/WebFlux custom adapters는 제거한다. 자체 transport가 반드시 필요하다면 Spring 기본
|
|
handler를 끄고 실제 route를 소유하게 하며, 두 경로를 동시에 두지 않는다.
|
|
6. 모든 policy stage는 `GraphQlExecutionRequest`와 `GraphQlExecutionContext`를 입력·출력하는 실행 가능한
|
|
handler로 바꾼다. 단순 stage catalog는 문서/검증 view로만 파생한다.
|
|
|
|
**필수 E2E**
|
|
|
|
- random-port servlet `/graphql`에서 depth/cost/alias/introspection/oversize/authz/timeout 거부.
|
|
- 각 거부에서 controller, use case, batch loader 호출 횟수 0.
|
|
- actor/tenant/deadline이 controller와 DataLoader에 동일하게 전달됨.
|
|
- custom scalar를 포함한 schema boot 및 실제 coercion.
|
|
- 같은 document cache hit, request별 DataLoader cache 격리.
|
|
- reactive artifact를 유지한다면 동일 contract를 reactive random-port에서도 실행.
|
|
|
|
Spring GraphQL이 제공하는 공식 연결점은
|
|
[`WebGraphQlInterceptor`](https://docs.spring.io/spring-graphql/reference/1.3/request-execution.html),
|
|
[`RuntimeWiringConfigurer`](https://docs.spring.io/spring-graphql/docs/current/api/org/springframework/graphql/execution/RuntimeWiringConfigurer.html),
|
|
[`BatchLoaderRegistry`](https://docs.spring.io/spring-graphql/docs/current/api/org/springframework/graphql/execution/BatchLoaderRegistry.html)다.
|
|
구현 시 repository lock의 Spring GraphQL 2.0.0/Boot 4.0.0 API signature로 다시 확인한다.
|
|
|
|
### GQL-003 — auto-configuration, binding default, 실제 override 검증이 각각 다른 계약이다
|
|
|
|
**근거**
|
|
|
|
- `GraphQlPlatformAutoConfiguration`은 이름과 달리 `@Configuration`이며 auto-configuration imports
|
|
metadata가 없다. main resource는 schema 한 개뿐이다.
|
|
- `GraphQlPlatformProperties` primitive binding default는 `maximumPageSize=0`,
|
|
`maximumComplexity=0`인데 startup validator는 양수만 허용한다.
|
|
- `productionDefaults()` factory는 Spring binder default가 아니다.
|
|
- custom `backend.graphql.graphiql-enabled/introspection-enabled`와 실제
|
|
`spring.graphql.*` framework flags가 분리되어 있다.
|
|
- startup check는 주입된 override pipeline이 아니라 `GraphQlExecutionPipeline.stable()` 상수를 검증한다.
|
|
|
|
**구현 결정**
|
|
|
|
1. 재사용 starter라면 `@AutoConfiguration(after = GraphQlAutoConfiguration.class)`과
|
|
`META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports`를 추가한다.
|
|
내부 composition 전용이면 이름을 `GraphQlPlatformConfiguration`으로 바꾸고 app-bootstrap에서 명시 import한다.
|
|
2. properties를 nested record/class로 나누고 binder가 실제로 사용하는 default를 선언한다.
|
|
3. framework `GraphQlProperties`를 SSOT로 삼거나 custom flag와의 불일치를 startup failure로 만든다.
|
|
4. startup validator는 실제 주입된 pipeline, scalar manifest, client policies, key ring을 검증한다.
|
|
5. `ApplicationContextRunner`로 enabled/disabled/servlet/reactive/unsafe override matrix를 고정한다.
|
|
|
|
**필수 테스트**
|
|
|
|
- 아무 `backend.graphql.*`도 없는 context가 safe default로 부팅한다.
|
|
- custom/framework GraphiQL·introspection 값이 모순되면 부팅 실패한다.
|
|
- unsafe custom pipeline override가 startup에서 거부된다.
|
|
- auto-configuration imports와 configuration metadata에 모든 property가 존재한다.
|
|
|
|
### GQL-004 — 하나의 artifact가 servlet runtime을 강제하면서 reactive profile도 표방한다
|
|
|
|
**근거**
|
|
|
|
- `build.gradle:19-20`은 `spring-boot-starter-web`을 production implementation으로 둔다.
|
|
- WebFlux는 `compileOnly`라 reactive runtime에는 없다.
|
|
- `GraphQlWebFluxAutoConfiguration`은 application이 이미 reactive일 때만 활성화된다.
|
|
- reactive config는 blocking executor를 `Mono.fromCallable`로 감싸며 scheduler를 바꾸지 않는다.
|
|
- request validator 기본 bean은 MVC config 안에 있어 reactive context에서 기본 생성되지 않는다.
|
|
|
|
**구현 결정**
|
|
|
|
공통 artifact에 두 runtime을 넣지 말고 다음처럼 나눈다.
|
|
|
|
- `graphql-spring-execution`: Spring GraphQL execution/interceptor/wiring. servlet/reactive server 없음.
|
|
- `graphql-transport-mvc`: 위 leaf + `starter-web`.
|
|
- `graphql-transport-webflux`: 위 leaf + `starter-webflux`.
|
|
|
|
reactive profile은 `GraphQlReactiveExecutor` 전용 interface를 필수로 한다. blocking bridge가 필요하면 명시적
|
|
opt-in, bounded scheduler, bulkhead, lifecycle bean과 thread assertion을 함께 둔다.
|
|
|
|
### GQL-005 — request limit은 역직렬화 전에 강제되지 않고 valid null variable은 NPE가 된다
|
|
|
|
**근거**
|
|
|
|
- size policy 메서드는 존재하지만 custom adapters는 `validateEnvelope()`만 호출한다.
|
|
- 이미 materialized된 `GraphQlHttpRequestEnvelope`를 받으므로 JSON allocation 전 body cap을 적용할 수 없다.
|
|
- `GraphQlHttpRequestEnvelope:23-25`는 variables/extensions에 `Map.copyOf`를 사용해 null value를 거부한다.
|
|
- nested map/list는 shallow copy여서 생성 뒤 mutation 가능한 TOCTOU도 남는다.
|
|
|
|
**구현 결정**
|
|
|
|
1. servlet filter/reactive web filter 또는 bounded decoder에서 raw HTTP body byte cap을 먼저 적용한다.
|
|
2. JSON decode 후 query/variables/extensions를 UTF-8 byte 기준으로 검증한다.
|
|
3. variables/extensions는 null-preserving deep immutable JSON value copy를 사용한다.
|
|
4. JSON nesting, object key 수, list length에도 별도 bound를 둔다.
|
|
|
|
**필수 테스트**
|
|
|
|
- ASCII와 다중바이트 UTF-8의 exact limit/limit+1.
|
|
- variables의 omitted/explicit null/non-null 세 의미가 actual coercion까지 보존됨.
|
|
- nested original map/list 변경이 envelope에 반영되지 않음.
|
|
- oversize body는 decoder/controller/use case 0회와 413.
|
|
|
|
### GQL-006 — `Accept` 협상에서 client priority와 명시적 거부를 무시한다
|
|
|
|
**근거**
|
|
|
|
`GraphQlMediaTypes:45-63`은 parameter를 제거하고 server preference를 먼저 순회한다. 따라서
|
|
`application/graphql-response+json;q=0, application/json;q=1`에도 q=0인 첫 media type을 반환한다.
|
|
|
|
**구현 결정**
|
|
|
|
Spring `MediaType.parseMediaTypes`로 parse하고 quality/specificity를 정렬한 뒤 q=0을 제외한다. GraphQL
|
|
over HTTP profile이 생산 가능한 두 type과 client order의 교집합을 선택하고, malformed/empty/wildcard
|
|
정책을 명시한다. GraphQL over HTTP draft도 client가 제시한 우선순위를 존중하도록 요구한다
|
|
([GraphQL over HTTP draft](https://graphql.github.io/graphql-over-http/draft/)).
|
|
|
|
### GQL-007 — named fragment가 custom introspection gate를 우회하고 variable 입력 구조는 측정하지 않는다
|
|
|
|
**근거**
|
|
|
|
- `GraphQlDocumentShapeAnalyzer:96-132`의 introspection walk는 Field/InlineFragment만 처리한다.
|
|
- 같은 class의 일반 shape walk는 `FragmentSpread`와 cycle path를 처리한다.
|
|
- input nesting은 document literal만 보며 variables JSON은 보지 않는다.
|
|
- analyzer는 selected operation이 아니라 document의 모든 operation을 합산한다.
|
|
|
|
**실패 입력**
|
|
|
|
```graphql
|
|
query Q { ...I }
|
|
fragment I on Query { __schema { types { name } } }
|
|
```
|
|
|
|
**구현 결정**
|
|
|
|
operationName으로 선택한 operation과 reachable fragment graph만 하나의 budgeted walker가 순회하도록
|
|
합친다. introspection은 custom walker 하나만 신뢰하지 말고 engine validation/field visibility에서도
|
|
차단한다. variables는 streaming JSON constraint로 별도 제한한다.
|
|
|
|
### GQL-008 — resolver 경계 검사는 실제 adopter graph를 보지 못하고 valid reactive query도 거부한다
|
|
|
|
**근거**
|
|
|
|
- controller inspector와 boundary rules는 raw `Class<?>`만 검사해 `List<Entity>`, `Mono<Entity>`,
|
|
`Optional<Repository>`의 generic 내부 타입을 놓친다.
|
|
- package scan은 `file:` protocol, 직접 자식 `.class`만 지원해 JAR/subpackage를 건너뛴다.
|
|
- tests는 고정 fixture package만 직접 호출하고 production startup caller가 없다.
|
|
- `Publisher`를 subscription 외에서 모두 거부하지만 Spring GraphQL controller는 Query/Mutation에서
|
|
`Mono<T>`와 async return을 지원한다
|
|
([Spring GraphQL annotated controllers](https://docs.spring.io/spring-graphql/reference/controllers.html)).
|
|
|
|
**구현 결정**
|
|
|
|
1. build-time에는 ArchUnit/bytecode scan으로 실제 configured controller packages를 재귀 검사한다.
|
|
2. runtime에는 ApplicationContext의 실제 GraphQL controller bean/method를 startup 검사한다.
|
|
3. Java `Type`을 재귀 순회해 parameterized/array/wildcard/type-variable bound를 본다.
|
|
4. Query/Mutation에는 single-value async(`Mono`, `CompletionStage`)를 허용하고 multi-value publisher만
|
|
Subscription에 제한한다.
|
|
5. suffix-only `Repository/Dao` 휴리스틱은 보조 신호로 낮추고 package/assignability/annotation 증거를 쓴다.
|
|
|
|
### GQL-009 — repository 자동 노출은 Advanced여도 이 저장소의 Clean Architecture를 위반한다
|
|
|
|
**근거**
|
|
|
|
`advanced/compat/GraphQlRepositoryExposureValidator`와 `GraphQlRepositoryAllowlist`는 allowlisted
|
|
repository가 GraphQL field를 직접 back하는 경로를 지원하고 test도 이를 정상으로 고정한다. 현재 실제
|
|
controller가 repository를 직접 호출하는 위반은 없지만, 지원 계약 자체가 root HARD-STOP과 충돌한다.
|
|
|
|
**구현 결정**
|
|
|
|
- `SPRING_DATA_COMPAT`, repository exposure API와 정상 test를 제거한다.
|
|
- 자동 resolver 대상은 application query/use-case handler로 한정한다.
|
|
- 생성 resolver의 constructor/field/method generic graph에 repository, Spring Data interface,
|
|
persistence entity가 있으면 allowlist와 무관하게 실패한다.
|
|
- business transaction과 authorization은 application use case에 남긴다.
|
|
|
|
### GQL-010 — cursor는 서명되지만 정상 payload가 round-trip되지 않고 rotation/scope 검증도 불완전하다
|
|
|
|
**근거**
|
|
|
|
- keyset은 `;`, `=`, `|`, `\`를 escape하지만 decoder는 escape-aware하지 않은 `split`을 먼저 한다.
|
|
- queryProfile/filterFingerprint/keyId는 escape조차 하지 않는다.
|
|
- `GraphQlCursorKeyRing.activeKeyId()`는 production/test에서 사용처가 없고 기본 factory는 항상
|
|
`cursor-key-1`을 payload에 넣는다.
|
|
- connection request decode는 payload direction과 request direction을 비교하지 않는다.
|
|
- cursor는 tenant/actor scope를 bind하지 않는다.
|
|
- `keyIds()`는 mutable backing key set을 반환한다.
|
|
|
|
**구현 결정: versioned Codec Strategy**
|
|
|
|
1. v2 payload를 canonical JSON/CBOR 또는 length-prefixed typed framing으로 만든다.
|
|
2. codec이 active key id를 선택하고 envelope에 기록한다. caller payload가 signing key를 선택하지 않는다.
|
|
3. decode 입력에 expected query/filter/direction/tenant-scope fingerprint를 포함한다.
|
|
4. v1 decode를 migration 기간에만 유지하고 v2만 발급한다.
|
|
5. key ring map/key set을 완전 불변으로 만들고 secret clone은 유지한다.
|
|
6. `forTests()`와 fixed secret은 test fixtures로 이동한다.
|
|
|
|
**필수 property tests**
|
|
|
|
- 모든 string field의 delimiter/backslash/unicode round-trip.
|
|
- active key2로 신규 발급, key1 과거 cursor 검증, unknown/retired key 거부.
|
|
- forward↔backward, tenant A↔B, filter/query 변경 거부.
|
|
- tamper, truncation, oversized token, malformed Base64 거부.
|
|
|
|
### GQL-011 — mutation fingerprint canonical form이 충돌하고 tenant를 scope에 포함하지 않는다
|
|
|
|
**근거**
|
|
|
|
`GraphQlMutationFingerprint:29-33`은 top-level key만 정렬해 `key=value;`를 연결한다. 예를 들어
|
|
`{a:"b;c=d"}`와 `{a:"b", c:"d"}`가 같은 canonical text가 된다. nested map은 재귀 정렬되지 않는다.
|
|
idempotency scope는 actor/coordinate/key만 포함하고 tenant와 contract version은 없다.
|
|
|
|
**구현 결정**
|
|
|
|
- recursive key sorting, JSON type, length framing, null/number normalization을 가진 canonical serializer를
|
|
하나의 port/service로 둔다.
|
|
- tenant fingerprint와 contract version을 scope에 포함한다.
|
|
- actor/tenant의 단순 SHA-256 prefix를 비가역이라고 부르지 않는다. 저엔트로피 identifier에는
|
|
rotation 가능한 HMAC fingerprint를 사용하고 metric label에는 넣지 않는다.
|
|
- `requireSingleUseCase`는 정확히 1을 요구하거나 실제 architecture gate로 교체한다.
|
|
|
|
### GQL-012 — error contract가 두 resolver와 여러 category vocabulary로 분기한다
|
|
|
|
**근거**
|
|
|
|
- root `GraphqlExceptionResolver`만 실제 Spring `DataFetcherExceptionResolverAdapter`와 `@Component`다.
|
|
- `error/GraphQlExceptionResolver`는 richer masking/mapping을 제공하지만 Spring path에 연결되지 않는다.
|
|
- auth/cursor/idempotency/batch/timeout 예외의 code/category/retryable/executionId 계약이 경로마다 다르다.
|
|
- 대소문자만 다른 두 class 이름은 import 실수를 유발한다.
|
|
|
|
**구현 결정: Mapper + Adapter**
|
|
|
|
`GraphQlWireErrorMapper`를 canonical pure mapper로 두고 `GraphQlDataFetcherExceptionResolver`가 Spring
|
|
`GraphQLError`로 adapt한다. request-level HTTP failure와 field failure는 별도 strategy를 쓰되 code,
|
|
category, retryability, masking catalog는 공유한다. unknown failure의 raw message는 어떤 path에서도
|
|
노출하지 않는다.
|
|
|
|
### GQL-013 — persisted operation admin은 존재하지 않는 변경을 성공으로 audit할 수 있다
|
|
|
|
**근거**
|
|
|
|
- in-memory registry의 absent `updateStatus`는 no-op인데 admin service는 `ABSENT→BLOCKED/DEPRECATED` audit을 남긴다.
|
|
- `remove()`는 실제 삭제가 아니라 BLOCKED 전환이다.
|
|
- BLOCKED에서 DEPRECATED로 바꿔 다시 executable하게 만들 수 있는 transition guard가 없다.
|
|
- raw operator 문자열 allowlist를 받고 credential kind 거부 메서드는 service가 호출하지 않는다.
|
|
- registry 변경과 in-memory `ArrayList` audit은 원자적이지 않고 thread-safe하지 않다.
|
|
|
|
**구현 결정: State + authenticated command + durable transaction**
|
|
|
|
1. transport가 만든 `GraphQlAdminPrincipal`만 service에 전달한다.
|
|
2. lifecycle transition table을 두고 BLOCKED는 explicit audited unblock 전까지 terminal로 취급한다.
|
|
3. registry command는 updated record/version을 반환하거나 not-found/conflict를 던진다.
|
|
4. mutation과 audit append를 하나의 durable transactional port로 묶는다.
|
|
5. soft delete가 의도면 `remove`를 `retireAndBlock`으로 이름 바꾼다.
|
|
|
|
### GQL-014 — codegen validator는 operation document를 읽지 않고 generator는 source를 만들지 않는다
|
|
|
|
**근거**
|
|
|
|
`GraphQlClientOperationGenerator.validateOperation`은 nonblank만 확인한 뒤 schema를 자기 자신과 비교한다.
|
|
`operationDocument`는 검증에 쓰지 않는다. invalid syntax나 unknown field operation이 통과한다. 다른
|
|
generator/factory도 실제 handler/source가 아니라 metadata set/report만 반환하는 사례가 많다.
|
|
|
|
**구현 결정**
|
|
|
|
- schema를 executable schema로 만들고 GraphQL Java parser/validator로 selected operation을 검증한다.
|
|
- 실제 source writer/Gradle task가 없다면 class/package를 `codegen-plan` 또는 `compatibility-policy`로
|
|
정직하게 이름 바꾼다.
|
|
- invalid syntax, unknown field/argument/type, operation name ambiguity, valid fragment operation을 테스트한다.
|
|
|
|
### GQL-015 — schema compatibility와 ownership이 breaking change를 놓친다
|
|
|
|
**근거**
|
|
|
|
- 동일 이름의 `type Foo`→`input Foo` 같은 kind change를 먼저 비교하지 않는다.
|
|
- 기존 argument/input field의 default 추가·제거·변경을 비교하지 않는다.
|
|
- `extend type/interface/input/enum/union`의 field/member ownership과 duplicate를 충분히 기록하지 않는다.
|
|
- applied directive 변경이 아니라 directive definition만 비교한다.
|
|
- scalar SDL print 차이를 coercion change라 부르지만 실제 `Coercing` 구현 교체는 보지 못하고 description
|
|
변화는 오탐할 수 있다.
|
|
|
|
**구현 결정**
|
|
|
|
1. registry를 extension까지 normalize하거나 executable schema로 compile한 canonical model을 비교한다.
|
|
2. `TYPE_KIND_CHANGED`, `INPUT_DEFAULT_REMOVED/CHANGED/ADDED`, applied-directive change를 명시한다.
|
|
3. nested list/non-null 변화는 input/output position별 방향성을 재귀 분류한다.
|
|
4. scalar coercion compatibility는 SDL이 아니라 scalar manifest codec/version 계약으로 분리한다.
|
|
|
|
### GQL-016 — custom DataLoader contract는 실제 N+1과 timeout을 보장하지 않는다
|
|
|
|
**근거**
|
|
|
|
- `GraphQlDataLoaderRequestRegistry`는 `Object` map이며 Spring/Java DataLoader registry에 연결되지 않는다.
|
|
- contract suite는 caller가 전달한 observed query count를 검사하고 test는 임의 숫자 1/2를 넘긴다.
|
|
- batch executor는 synchronous chunk 호출 전에만 시간을 보고 long/final chunk를 중단하지 못한다.
|
|
- mapped loader의 null은 `Present(null)`, ordered loader의 null은 `Missing`으로 해석되어 의미가 다르다.
|
|
- result cardinality가 같아도 requested key 대신 다른 key가 들어간 map을 검출하지 못한다.
|
|
|
|
**구현 결정: Spring registry adapter + Decorator**
|
|
|
|
Spring `BatchLoaderRegistry`에 실제 loader를 등록하고 chunk/timeout/auth scope/observation을 loader decorator로
|
|
적용한다. loader는 `CompletionStage`/`Mono`로 deadline/cancellation을 전달한다. null 의미는 하나로
|
|
정하고 requested key set/cardinality를 검증한다.
|
|
|
|
**필수 E2E**
|
|
|
|
- 50개 parent/child query의 fake application port 호출이 1회 또는 bounded chunk 수.
|
|
- request 간 cache 비공유, 같은 request duplicate key dedupe, actor/tenant scope 분리.
|
|
- never-completing loader timeout/cancel, 첫 chunk budget 소진 뒤 다음 chunk 0회.
|
|
- missing/failed/null/wrong-key map 계약.
|
|
|
|
### GQL-017 — MVC는 bounded라고 설명하지만 concurrency/queue가 unbounded이고 context도 전달하지 않는다
|
|
|
|
**근거**
|
|
|
|
- virtual-thread-per-task executor는 task admission을 제한하지 않는다.
|
|
- fixed thread pool은 기본 unbounded `LinkedBlockingQueue`를 사용한다.
|
|
- MVC adapter가 submit한 task를 `GraphQlContextPropagator.wrap`으로 감싸지 않는다.
|
|
- WebFlux blocking fallback은 `subscribeOn`이 없어 subscriber/event-loop thread에서 실행될 수 있다.
|
|
|
|
**구현 결정**
|
|
|
|
- executor 앞에 semaphore/bulkhead 또는 bounded `ThreadPoolExecutor` queue/rejection을 둔다.
|
|
- Spring GraphQL annotated controller executor를 canonical하게 구성해 double scheduling/wait을 피한다.
|
|
- context는 ThreadLocal만 믿지 말고 GraphQLContext/Reactor Context를 SSOT로 삼고 blocking bridge에서만
|
|
snapshot/wrap한다.
|
|
- timeout은 interrupt가 아니라 downstream deadline propagation과 함께 검증한다.
|
|
|
|
### GQL-018 — pipeline stage 순서는 authorization에 필요한 정보를 만들기 전에 authorize한다
|
|
|
|
**근거**
|
|
|
|
pipeline은 `AUTHORIZATION`을 `PARSE_VALIDATE`보다 앞에 두지만 field authorization은 schema coordinate와
|
|
selected operation을 필요로 한다. 현재 pipeline이 실행되지 않아 장애는 잠복해 있지만 그대로 wiring할
|
|
수 없는 순서다.
|
|
|
|
**구현 결정: 실제 Chain of Responsibility**
|
|
|
|
```text
|
|
authenticate transport principal
|
|
→ create request context
|
|
→ persisted lookup / raw document admission
|
|
→ parse + validate + select operation
|
|
→ document/coordinate authorization
|
|
→ structural + complexity budget
|
|
→ execute + field/object authorization + DataLoader
|
|
→ map errors + observe + cleanup
|
|
```
|
|
|
|
각 handler는 입력 상태와 산출 상태를 typed record로 표현하고, 필요한 이전 stage가 없으면 compile-time 또는
|
|
startup validation에서 실패하게 한다.
|
|
|
|
### GQL-019 — subscription/replay/drain policy는 concurrent runtime state machine이 아니다
|
|
|
|
**근거**
|
|
|
|
- replay cursor는 expected subscription과 tenant를 검증하지 않는다.
|
|
- subscription event byte estimate는 실제 serialized bytes가 아니라 `payload.toString()`을 사용한다.
|
|
- drain coordinator는 draining check와 registration increment 사이 race가 있고, state publication 순서에
|
|
따라 startedAt을 null로 볼 수 있다.
|
|
- cancellation/listener collections와 protocol lifecycle의 thread-safety/ownership이 명시되지 않았다.
|
|
- WebSocket/SSE/RSocket “handler factory”는 실제 Spring transport handler가 아니라 policy 객체를 반환한다.
|
|
|
|
**구현 결정**
|
|
|
|
atomic immutable state 또는 lock-protected State pattern으로 `ACCEPTING→DRAINING→CLOSED`를 모델링한다.
|
|
registration은 lease를 받아 close 시 release한다. replay cursor는 actor+tenant+subscription profile에
|
|
bind한다. queue byte bound는 실제 serializer 결과로 계산한다. 실제 handler가 없으면 factory 명명과
|
|
지원 등급을 policy/catalog로 낮춘다.
|
|
|
|
### GQL-020 — preparsed cache의 expiry policy가 사용되지 않고 unrelated miss가 직렬화된다
|
|
|
|
`GraphQlPreparsedCachePolicy`의 expire-after-access 값은 provider에서 사용되지 않는다. cache miss parse가
|
|
synchronized block 안에서 실행되어 서로 다른 document도 직렬화된다. injected Clock/Ticker를 쓰는 bounded
|
|
cache와 per-key single-flight를 적용하고 expiry/access-refresh/same-key-once/different-key-parallel을 테스트한다.
|
|
|
|
### GQL-021 — cancellation hook 하나의 실패가 나머지 cleanup을 막는다
|
|
|
|
request/subscription cancellation listener loop가 exception을 aggregate하지 않는다. 세 hook 중 두 번째가
|
|
throw해도 세 개 모두 정확히 한 번 실행하고 첫 실패에 나머지를 suppressed로 붙이는 공통 cancellation
|
|
primitive로 합친다. 이미 `GraphQlContextCleanup`이 가진 all-cleanups 실행 의미를 재사용한다.
|
|
|
|
### GQL-022 — scalar input/output limit이 대칭이 아니고 작은 입력이 큰 출력을 만들 수 있다
|
|
|
|
- BigDecimal은 precision/scale/exponent/serialized length 제한 없이 parse 후 `toPlainString()`을 사용한다.
|
|
작은 `1E+1000000`이 매우 큰 output allocation을 만들 수 있다.
|
|
- custom Long scalar는 parse에 configured min/max를 적용하지만 serialize/valueToLiteral은 그 범위를 무시한다.
|
|
|
|
lexical length, precision, absolute scale, output length를 먼저 제한하고 Long의 input/output에 같은 range를
|
|
적용한다. coercion error에는 raw input을 포함하지 않는 기존 원칙을 유지한다.
|
|
|
|
### GQL-023 — operation name을 low-cardinality tag라고 가정할 수 없다
|
|
|
|
operation name은 길이/문법만 제한되어 client가 매번 임의 이름을 만들 수 있고 observation convention은 raw
|
|
name을 tag로 사용한다. 실제 Micrometer/Spring Observation interface 연결도 없다. persisted/registered
|
|
operation만 이름 tag로 사용하고 나머지는 `other`로 collapse하거나 production에서 anonymous/unregistered
|
|
operation을 거부한다. 10,000개 임의 name을 actual MeterRegistry에 넣어 series bound를 검증한다.
|
|
|
|
### GQL-024 — production jar가 testkit, fixed secret, in-memory development 구현을 함께 배포한다
|
|
|
|
main source에는 `testkit` 12개 class, `GraphQlConnectionAssembler.forTests()`의 fixed signing secret,
|
|
`GraphQlAuthenticationContextFactory.testContext`, test error context, in-memory persisted registry가 있다.
|
|
`java-test-fixtures` 또는 별도 `graphql-testkit` leaf로 옮기고 production jar에 `.testkit.`, `forTests`,
|
|
`testContext`, fixed secret이 없는 jar content gate를 둔다.
|
|
|
|
### GQL-025 — 373개 public 중심 type과 Stable/Advanced/testkit/release의 한 jar 결합은 변경 비용이 크다
|
|
|
|
package import graph에 명백한 cycle이 없는 방향성은 좋지만 package만으로 외부 API와 classpath isolation을
|
|
보장하지 못한다. 이번 ignored boundary package가 그 취약성을 실제로 보여 줬다. package-private를 default로
|
|
하고 explicit `api`/`spi`만 public으로 허용하는 API surface snapshot을 둔다. Gradle 분리는 28개를 한 번에
|
|
늘리지 않고 §7의 6~8개 capability 단위로 진행한다.
|
|
|
|
### GQL-026 — GraphQL request context와 storage SPI가 inbound에 있어 downstream 구현 방향과 충돌한다
|
|
|
|
문서는 `GraphQlRequestContext`/deadline을 application/JPA/Mongo/HTTP client까지 전달하고 persisted registry를
|
|
외부 durable store가 구현한다고 설명한다. application/outbound가 inbound leaf type을 구현하면 의존 방향이
|
|
뒤집힌다.
|
|
|
|
- GraphQL context는 inbound-local로 유지하고 application command의 actor/tenant/deadline 값으로 명시 매핑한다.
|
|
- object authorization은 application-core의 transport-neutral use case로 두고 GraphQL bridge가 호출한다.
|
|
- persisted operation 저장은 generic operational store/cache port를 neutral contract owner에 두고 GraphQL
|
|
adapter가 key/value mapping만 소유한다. inbound→outbound 직접 edge는 만들지 않는다.
|
|
- composition root는 연결만 하고 business/storage policy를 소유하지 않는다.
|
|
|
|
### GQL-027 — 문서와 실제 설정·테스트·지원 등급이 drift했다
|
|
|
|
- README는 custom `@ConfigurationProperties`가 없다고 하지만 `backend.graphql` properties가 있다.
|
|
- build comment는 `spring.graphql.platform.*`를 언급하지만 실제 prefix는 `backend.graphql`이다.
|
|
- CLAUDE/README는 누락된 boundary classes/tests가 있다고 기록한다.
|
|
- “더 이상 미구현이 아니다”라는 표현은 policy object 존재와 runtime integration을 구분하지 않는다.
|
|
- test count는 compile이 깨진 현재 실행 증거가 아니라 과거/문서 count다.
|
|
|
|
generated configuration metadata, actual bean inventory, random-port adoption test, task JUnit XML에서 문서를
|
|
생성/검증한다. capability마다 `modelled`, `wired`, `integration-verified`, `production-verified`를 분리하고
|
|
현재 수준 이상으로 표현하지 않는다.
|
|
|
|
## 6. 디자인 패턴 적용 제안
|
|
|
|
### 6.1 적용할 패턴
|
|
|
|
| 위치 | 패턴 | 적용 형태 | 해결하는 문제 |
|
|
|---|---|---|---|
|
|
| 실행 pipeline | Chain of Responsibility | typed stage handler + actual Spring execution decorator | stage 목록만 있고 실행되지 않는 문제 |
|
|
| Spring integration | Adapter | pure policy를 interceptor/instrumentation/wiring으로 변환 | framework-free core와 runtime 연결 분리 |
|
|
| transport | Strategy | MVC/WebFlux leaf별 transport strategy | 두 runtime classpath와 blocking policy 혼합 제거 |
|
|
| DataLoader | Decorator | loader에 chunk/deadline/auth/observation을 조합 | 병렬 custom framework와 정책 중복 제거 |
|
|
| error | Mapper + Adapter | pure wire-error mapper + Spring resolver | 두 resolver/category drift 제거 |
|
|
| cursor | Versioned Codec Strategy | v1 read/v2 write codec과 key-ring signer | framing migration과 rotation 분리 |
|
|
| persisted/admin/subscription | State | 허용 transition과 CAS version 명시 | blocked 재활성, drain race, 허위 audit 제거 |
|
|
| application 경계 | Anti-Corruption Mapper | GraphQL context/input → command/context | transport DTO/application leakage 방지 |
|
|
| configuration | Validated Plan/Builder | bind → aggregate validate → immutable runtime plan | resource 생성 뒤 validation과 inert setting 제거 |
|
|
|
|
### 6.2 피할 패턴
|
|
|
|
- `Factory`, `Generator`, `Interceptor`라는 이름만 붙이고 metadata/policy 객체만 반환하지 않는다.
|
|
- 28개 설계상 “모듈”을 근거 없이 28개 Gradle leaf로 기계 분해하지 않는다.
|
|
- controller/router와 Spring 기본 endpoint를 병렬로 유지하지 않는다.
|
|
- custom DataLoader, custom preparsed cache, custom transport를 framework가 제공하는 extension point와 경쟁시키지 않는다.
|
|
- architecture rule을 runtime reflection suffix 검사 하나로만 강제하지 않는다.
|
|
- Advanced라는 이유로 repository/use-case 경계를 완화하지 않는다.
|
|
|
|
## 7. 권장 Gradle·폴더 구조
|
|
|
|
### 7.1 대안 비교
|
|
|
|
| 대안 | 장점 | 단점 | 판정 |
|
|
|---|---|---|---|
|
|
| A. 현재 단일 leaf 유지 + 경계 test 복구 | 가장 빠름, registry 변경 최소 | public/classpath/runtime 결합 유지 | GQL-001 응급 복구용 |
|
|
| B. 6~8 capability leaf로 단계 분리 | 실제 runtime 책임과 dependency를 격리 | registry/settings/lock/CI 갱신 필요 | **권장** |
|
|
| C. 설계의 28 package를 28 leaf로 분리 | 가장 강한 compile boundary | Gradle/lock/CI 비용과 빈 facade 증가 | 현재 과도함 |
|
|
|
|
### 7.2 권장 target
|
|
|
|
```text
|
|
graphql-platform-core
|
|
src/main/java/.../graphql/core/api
|
|
src/main/java/.../graphql/core/policy
|
|
# pure Java, framework/transport/application type 없음
|
|
|
|
graphql-schema
|
|
src/main/java/.../graphql/schema
|
|
src/main/java/.../graphql/scalar
|
|
src/main/java/.../graphql/compat
|
|
# GraphQL Java AST/wiring, no web server
|
|
|
|
graphql-spring-execution
|
|
src/main/java/.../graphql/execution
|
|
src/main/java/.../graphql/security
|
|
src/main/java/.../graphql/error
|
|
src/main/java/.../graphql/dataloader
|
|
src/main/java/.../graphql/autoconfigure
|
|
# application-core bridge + Spring GraphQL extension points
|
|
|
|
graphql-transport-mvc
|
|
src/main/java/.../graphql/http/mvc
|
|
# starter-web only
|
|
|
|
graphql-transport-webflux
|
|
src/main/java/.../graphql/http/webflux
|
|
# starter-webflux only
|
|
|
|
graphql-advanced
|
|
src/main/java/.../graphql/advanced/{persisted,subscription,federation,...}
|
|
# 실제 wired capability만 opt-in; feature가 커지면 사용 단위별 추가 분리
|
|
|
|
graphql-testkit
|
|
src/testFixtures/java 또는 전용 leaf
|
|
|
|
graphql-release-verification
|
|
# Gradle/build logic와 evidence manifest, production runtime에 포함하지 않음
|
|
```
|
|
|
|
허용 방향의 기본안은 다음과 같다.
|
|
|
|
```text
|
|
transport-mvc/webflux → spring-execution → schema → platform-core
|
|
spring-execution → application-core → domain-core
|
|
advanced → spring-execution/schema/platform-core
|
|
testkit → 공개 api/spi만
|
|
release-verification → 각 leaf의 test/evidence artifact만
|
|
```
|
|
|
|
실제 edge와 runtime membership은 반드시 `src/config/architecture/modules.json`에 먼저 등록하고 같은 SSOT의
|
|
Gradle gate로 검증한다. `graphql-persisted-<database>`가 inbound contract에 역의존하는 구조는 만들지 않는다.
|
|
|
|
### 7.3 package visibility
|
|
|
|
- public 허용: 외부 resolver/adopter가 구현·호출해야 하는 `api`, `spi`, configuration properties.
|
|
- package-private/internal: calculator, parser walker, state transition, mapper implementation, factory implementation.
|
|
- test-only: fixture, fake/in-memory, fixed key/principal/context, contract assertion helper.
|
|
- public API snapshot에는 FQCN, constructor/method signature, stability level을 기록한다.
|
|
|
|
## 8. 구현 순서 — 그대로 issue/PR로 분리 가능한 단위
|
|
|
|
### Wave 0 — build와 증거 복구
|
|
|
|
1. **PR GQL-001A**: ignored package red test와 `moduleboundary` package 복구.
|
|
2. **PR GQL-001B**: module boundary negative fixtures와 required FQCN/lane 연결.
|
|
3. focused test, Stable/contract/Advanced lane을 실행한다. 여기서 발견되는 test failure는 다음 wave의
|
|
characterization backlog로 분리한다.
|
|
|
|
### Wave 1 — 실제 endpoint baseline
|
|
|
|
1. 현재 기본 `/graphql`에 health query를 보내는 full configuration test를 만든다.
|
|
2. cost/auth/DataLoader/custom adapter bean이 존재하지만 호출되지 않는 현재 상태를 failing test로 증명한다.
|
|
3. Spring-native endpoint를 canonical로 확정하고 custom transport dead path를 제거한다.
|
|
4. auto-configuration imports, binder defaults, framework property cross-check를 추가한다.
|
|
|
|
### Wave 2 — executable pipeline
|
|
|
|
1. request context interceptor.
|
|
2. parse/select/introspection/cost/auth instrumentation/decorator.
|
|
3. scalar/preparsed/DataLoader/error wiring.
|
|
4. actual observation과 cleanup/cancellation.
|
|
5. servlet random-port qualification을 platform adoption test로 교체한다.
|
|
|
|
### Wave 3 — correctness/security
|
|
|
|
서로 독립인 작은 PR로 다음을 처리한다.
|
|
|
|
- null-preserving request JSON + byte/nesting limit.
|
|
- Accept negotiation.
|
|
- fragment introspection/selected-operation analyzer.
|
|
- cursor v2/rotation/direction/tenant.
|
|
- mutation canonical fingerprint/tenant.
|
|
- schema kind/default/extensions/directives.
|
|
- scalar bounds.
|
|
- persisted admin/subscription state machines.
|
|
|
|
각 PR은 먼저 failing unit/property/integration test를 추가한다.
|
|
|
|
### Wave 4 — architecture와 모듈 분리
|
|
|
|
1. actual controller generic/bytecode gate와 application import gate.
|
|
2. repository exposure capability 제거.
|
|
3. testkit/fixed/in-memory API를 test fixtures로 이동.
|
|
4. `platform-core`, `schema`, `spring-execution` 추출.
|
|
5. MVC/WebFlux leaf 분리와 runtime classpath tests.
|
|
6. Advanced/release verification을 runtime jar에서 분리.
|
|
7. public API snapshot과 package-private 축소.
|
|
|
|
### Wave 5 — Advanced promotion
|
|
|
|
각 capability는 다음 네 증거가 모두 있을 때만 `wired` 이상으로 승격한다.
|
|
|
|
1. 실제 Spring handler/extension point가 존재한다.
|
|
2. real request 또는 protocol-level integration test가 해당 path를 호출한다.
|
|
3. disabled 상태에서 bean/resource/route가 0개다.
|
|
4. restart/concurrency/fault가 필요한 stateful capability는 durable evidence가 있다.
|
|
|
|
codegen/federation/subscription/persisted operation이 이 기준을 못 채우면 policy/catalog로 이름과 문서를
|
|
낮추고 production support claim을 하지 않는다. Spring GraphQL은 federation에 `@EntityMapping`을 포함한
|
|
공식 통합을 제공하므로 별도 facade보다 이를 우선 검토한다
|
|
([Spring GraphQL federation](https://docs.spring.io/spring-graphql/reference/federation.html)).
|
|
|
|
## 9. 테스트 전략과 Definition of Done
|
|
|
|
### 9.1 최소 테스트 피라미드
|
|
|
|
| 계층 | 테스트 | 핵심 assertion |
|
|
|---|---|---|
|
|
| pure policy | unit + property | canonicalization, bounds, transition, deterministic output |
|
|
| Spring composition | `ApplicationContextRunner` | enabled/disabled, bean exact set, unsafe config failure |
|
|
| schema/execution | `ExecutionGraphQlServiceTester` | scalar, parse, validation, error path, DataLoader |
|
|
| transport | random-port MVC/WebFlux | media type, auth, body cap, actual policy rejection |
|
|
| architecture | ArchUnit/bytecode + Gradle edge | generic DTO/entity/repository, package/leaf edge, public API |
|
|
| stateful Advanced | concurrency/restart/store integration | CAS/fencing/audit/replay/durable transition |
|
|
| release | same-SHA evidence manifest | 실행한 lane/version/scenario와 지원 문서 일치 |
|
|
|
|
### 9.2 전체 완료 조건
|
|
|
|
- `:adapter:inbound:graphql:compileJava`, focused `test`, Stable/contract/Advanced lane이 실행되고 green이다.
|
|
- performance lane이 필요한 support claim은 실제 tagged scenario/evidence 없이는 승격되지 않는다.
|
|
- real `/graphql` E2E에서 모든 mandatory policy가 최소 한 번 차단/허용 경로를 가진다.
|
|
- GraphQL DTO/context/framework type이 application/domain에 유출되지 않는다.
|
|
- controller/resolver가 repository, persistence entity, transaction을 직접 소유하지 않는다.
|
|
- MVC/WebFlux runtime dependency가 서로의 server stack을 끌어오지 않는다.
|
|
- production jar에 testkit/fixed secret/in-memory development facade가 없다.
|
|
- public API와 capability support status가 snapshot/manifest로 검증된다.
|
|
- docs의 property name/test count/support status는 generated metadata와 JUnit evidence에서 파생된다.
|
|
|
|
## 10. 이번 리뷰에서 실행한 검증
|
|
|
|
### 성공
|
|
|
|
```bash
|
|
cd src
|
|
./gradlew verifyCleanArchitectureDependencies --console=plain
|
|
```
|
|
|
|
- current HEAD fresh run은 `BUILD SUCCESSFUL in 769ms`, 1 actionable task executed였다.
|
|
- 이 결과는 registry에 선언된 project dependency edge가 맞다는 증거다.
|
|
- 누락된 내부 package boundary, runtime wiring, correctness를 승인하는 증거는 아니다.
|
|
|
|
### 실패
|
|
|
|
```bash
|
|
cd src
|
|
./gradlew :adapter:inbound:graphql:compileJava --console=plain
|
|
./gradlew :adapter:inbound:graphql:test --console=plain
|
|
```
|
|
|
|
- direct `compileJava`의 current HEAD fresh 재현은 `BUILD FAILED in 1s`, 7 errors였다
|
|
(직전 첫 재현도 같은 7 errors, 7s).
|
|
- focused `test`도 같은 `compileJava` 단계에서 실패했다.
|
|
- 누락 package: `dev.caskeleton.adapter.inbound.graphql.build`.
|
|
- 참조 파일: `GraphQlPlatformAutoConfiguration`, `GraphQlAdvancedDependencyRules`.
|
|
- test 75개는 실행 단계에 진입하지 못했다.
|
|
|
|
### 정적 재현
|
|
|
|
```bash
|
|
git check-ignore -v --no-index \
|
|
src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/build/GraphQlBuildModel.java
|
|
```
|
|
|
|
- `src/.gitignore:2:build/`이 반환되어 누락 source package와 ignore rule의 충돌을 확인했다.
|
|
|
|
### 미실행
|
|
|
|
- `graphqlStableTest`, `graphqlContractTest`, `graphqlAdvancedTest`: 동일 compile blocker 때문에 실행 불가.
|
|
- `graphqlPerformanceTest`: compile blocker에 더해 실제 tagged load/fault scenario가 없는 상태.
|
|
- repository 전체 `test`/`check`: review-only 범위이며 focused compile blocker가 먼저 존재한다.
|
|
- production adopter, actual feature schema, JPA/Mongo query-count, real WebSocket/SSE/RSocket, load/soak/fault.
|
|
|
|
## 11. 남은 위험과 판정 범위
|
|
|
|
- GraphQL leaf는 현재 두 composition root runtime에 포함되지 않으므로 발견 사항을 현 서비스의 즉시 runtime
|
|
장애로 확대하지 않는다.
|
|
- 반대로 빈 runtime membership은 adopter 안전성의 증거도 아니다. opt-in 직후 compile/auto-config/runtime
|
|
wiring 문제가 드러난다.
|
|
- build blocker가 해결되면 지금까지 실행되지 못한 524 test annotation에서 추가 failure가 나올 수 있다.
|
|
- Advanced 130개 production class의 모든 concurrent/protocol path를 실환경에서 검증하지 않았다.
|
|
- 공식 Spring GraphQL extension point 선택은 타당하지만 정확한 Boot 4.0.0/Spring GraphQL 2.0.0 API
|
|
signature와 auto-configuration ordering은 구현 시 lock 기준으로 확인해야 한다.
|
|
- 이 리뷰의 `FACT`는 명시한 source/command에 한정되고, target module split은 그 사실에서 도출한
|
|
`INFERENCE/권고`다. registry 변경 전에 별도 설계 문서와 실행 계획을 남겨야 한다.
|
|
|
|
최종 판정은 **CHANGES REQUIRED**다. 구현 순서는 `GQL-001 → GQL-002/003 → GQL-005~018 →
|
|
GQL-024~026 → Advanced promotion`을 권장한다.
|