Files
document-haness/docs/clean-architecture-backend-template/tech-log-studio/graphql-surface/case/case-a16-f004-graphqloperationnamepolicy.md
T
DongHyeonkaandClaude Fable 5.1 b25357c48a docs(clean-architecture-backend-template): fold analysis into final and re-select one topic
- analysis/·source-index·state.json 을 final/document.md 제2부·제3부로 접었다. SSOT 는 하나다
- 파일럿 — commit-ambiguity-as-a-result 를 새 기준으로 재선별. 후보 14 → 글감 5
  (PROMOTE 5 · MERGE_INTO 3 · KEEP_IN_SSOT 4 · 보류 2). 기록 5건을 다시 썼고 그림 1개를
  techviz 로 만들었다
- 재선별이 잡은 것: 제1부 §6.2·§11.1 이 자기 §13.2 와 어긋나 있었다(레인을 안 돌렸다 vs
  돌렸다) — 정정. 이미 답이 나와 있던 Question 을 HEAD 재실행 질문으로 다시 세웠다.
  Concept 이 인용한 코드가 SSOT 에 없어 뺐다
- candidateScope·sourceRepository 기록. 나머지 43개 주제는 재선별 대기(PENDING 905)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 12:39:20 +09:00

168 lines
17 KiB
Markdown

---
kind: CASE
slug: a16-f004-graphqloperationnamepolicy
title: 연산 이름 정책의 두 구현 중 하나만 배선되고, 미배선 쪽만 GraphQlOperationNamePolicy를 쓴다
topic: graphql-surface
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: case:a16-f004-graphqloperationnamepolicy
evidenceCapturedOn: 2026-09-03
assets:
- key: a16-f004-graphqloperationnamepolicy
file: ../../../final/evidence/rendered/a16-f004-graphqloperationnamepolicy.svg
- key: a16-f004-graphqloperationnamepolicy-run
file: ../../../final/evidence/rendered/a16-f004-graphqloperationnamepolicy-run.svg
evidence:
- ../../../final/evidence/raw/a16-f004-graphqloperationnamepolicy.txt
- ../../../final/evidence/raw/a16-f004-graphqloperationnamepolicy-run.txt
source:
- 원본 분석 절은 final/document.md#a16#L403 이다. 세 구현의 참조 계수는 정적 검색으로, 두 구현의 판정 차이는 프로브 실행으로 확인했다.
---
# 연산 이름 정책의 두 구현 중 하나만 배선되고, 미배선 쪽만 GraphQlOperationNamePolicy를 쓴다
이름 없는 연산을 운영에서 거부하는 규칙이 `GraphQlOperationNamePolicy``GraphQlOperationSelectionHandler``GraphQlRequestEnvelopeValidator` 셋에 나뉘어 있고, 자동설정이 조립하는 것은 `GraphQlOperationSelectionHandler` 하나다. 조립되는 `GraphQlClientPolicy``defaults(...)` 가 만드는 것 하나뿐이고 그것이 `namedOperationRequired` 에 상수 `false` 를 넣으므로, `GraphQlOperationSelectionHandler` 의 이름 요구 분기는 출하 상태에서 실행되지 않는다.
## 관계
- **프로파일별 정책 매니페스트가 미배선이라, 자격에서 해석된 프로파일이 아무 예산도 선택하지 않는다**
둘 다 프로파일마다 다른 값을 고르도록 타입을 만들어 두고, 조립은 기본값 하나만 넣는다.
- **플랫폼이 강제한다고 적었지만 그 규칙을 실제로 거는 것은 라이브러리다**
둘 다 자바독이 적은 강제 주체와 그 검사를 실제로 도는 코드가 어긋난다.
- **중복 장치를 찾으면 어느 쪽이 조립됐는지 먼저 확인한다**
이름을 요구하는 코드가 셋인데, 어느 것이 조립되는지 보기 전에는 규칙이 걸린다고 읽게 된다.
## 문제
익명 연산을 거부하는 규칙이 자바독에 적혀 있다.
그 규칙을 거는 코드가 몇 개이고 그중 무엇이 조립되는지, 조립된 것이 실제로 그 검사를 도는지 확인했다.
## 결론
거는 코드는 셋이고, 조립되는 것은 하나다.
GraphQlOperationNamePolicy 는 미배선 인터셉터의 필드와 생성자에서만 참조되고, 그 인터셉터를 만드는 코드는 GraphQlOperationNamePolicyTest:94 한 줄이다. GraphQlRequestEnvelopeValidator 도 같은 불리언을 읽지만 생성 지점 아홉이 전부 test 와 testFixtures 다. 남는 GraphQlOperationSelectionHandler 는 GraphQlExecutionChain 과 GraphQlPlatformInstrumentation 을 거쳐 요청마다 돈다.
그런데 그 핸들러가 읽는 namedOperationRequired 가 조립되는 정책에서 거짓이다.
GraphQlClientPolicy 를 만드는 프로덕션 코드가 defaults(...) 하나이고, 열여섯 번째 성분에 상수 false 가 들어간다. 프로브로 돌려 보니 익명 단일 연산이 통과하고 operationId 가 anonymous 로 찍힌다. 같은 핸들러에 그 성분만 참으로 바꾸면 같은 문서가 거부된다.
배선된 경로가 언제나 거는 것은 따로 있다. 문서에 연산이 하나도 없을 때, 요청한 이름과 맞는 연산이 문서에 없을 때, 그리고 연산이 여럿인데 어느 것을 돌릴지 지정하지 않았을 때다. 이름 요구와 달리 이 셋은 클라이언트 프로파일을 보지 않는다.
두 구현은 같은 입력에 반대로 답하기도 한다. 두 글자짜리 이름을 미배선 정책에 넣으면 IllegalArgumentException 이 나오고, 배선된 핸들러는 통과시킨 뒤 식별자를 anonymous 로 정규화한다. ADMIN 프로파일을 요구에서 빼는 예외도 미배선 쪽에만 있다.
판정은 원문과 같은 P3 이고, 노출이 없다는 것이 근거다. 근거는 셋으로 갈린다. 미배선 정책에는 실행 경로가 없다. 어댑터 자체가 backend.graphql.enabled 로 꺼져 있는 것이 출하 기본값이다. 그리고 배선된 핸들러가 심는 operationId 를 읽는 프로덕션 코드가 이 모듈에 없어서, anonymous 로 뭉개진 식별자가 지금 아무 데로도 흘러가지 않는다.
## 검증 환경
OpenJDK : 21.0.12
확인 방식 : 세 구현의 바깥 참조 전수 계수, 조립되는 정책 빈과 defaults 의 성분 확인, 배선 사슬과 어댑터 프로퍼티 조건 확인, GraphQlOperationSelectionHandler.handle 과 GraphQlOperationNamePolicy.verify 를 문서 넷과 프로파일 둘로 직접 실행
소스 수정 : x
## 재현 조건
1. GraphQlOperationNamePolicy 의 자바독에서 이름을 요구하는 이유를 읽는다.
2. GraphQlOperationNamePolicy 를 언급하는 파일을 소스 세트별로 모아 센다.
3. 그것을 쓰는 인터셉터를 생성하는 자리가 어느 소스 세트에 있는지 확인한다.
4. namedOperationRequired 를 읽는 프로덕션 코드를 전부 찾고, 그 클래스들을 만드는 자리도 함께 센다.
5. GraphQlClientPolicy 를 만드는 프로덕션 코드와 그 빈에 붙은 조건 애너테이션을 확인한다.
6. defaults 가 마지막 세 성분에 넣는 값과 그 성분의 이름을 대조한다.
7. 배선된 핸들러가 실행 사슬과 Instrumentation 을 거쳐 요청 경로에 오르는 자리와, 어댑터 자체의 프로퍼티 조건을 읽는다.
8. 조립되는 값 그대로 GraphQlOperationSelectionHandler.handle 에 익명 단일 연산, 이름이 Ab 인 연산, 이름이 HealthQuery 인 연산, 연산 둘짜리 문서를 넣는다.
9. 같은 핸들러에 namedOperationRequired 만 참으로 바꿔 익명 단일 연산을 다시 넣는다.
10. GraphQlOperationNamePolicy.production().verify 에 PUBLIC 의 이름 없는 선택과 ADMIN 의 이름 없는 선택, 그리고 PUBLIC 의 Ab 와 HealthQuery 를 넣는다.
11. GraphQlOperationName.parse 의 본문에서 빈 Optional 이 나오는 조건을 읽는다.
12. GraphQlRequestContext 의 operationId 를 읽는 프로덕션 코드를 센다.
## 본문
<!-- body:start -->
`GraphQlOperationNamePolicy` 의 자바독에는 운영에서 연산에 이름을 요구하는 이유가 적혀 있다. 규격은 익명 연산 하나를 허용하고 로컬에서는 그게 편하지만, 운영에서는 추적과 비용 예외와 persisted operation 레지스트리와 사용량 분석이 공통으로 키에 쓰는 연산 이름이 없어진다. 이름이 없으면 대신 쓸 수 있는 키는 원본 문서 문자열뿐인데, 그 문자열은 길이 상한이 없고 변수까지 들어 있다.
## 이름 요구를 거는 코드 셋과 각각의 바깥 참조
:::evidence key="a16-f004-graphqloperationnamepolicy" alt="저장소 루트에서 돌린 정적 검색 출력 89줄. 같은 불리언을 읽는 세 클래스의 줄 수가 먼저 나오고, GraphQlOperationNamePolicy 를 자기 파일 밖에서 쓰는 자리가 main 두 줄과 test 여덟 줄로 갈려 보인다. GraphQlOperationNameInterceptor 의 바깥 참조는 시험 한 줄뿐이고, GraphQlRequestEnvelopeValidator 를 실제로 만드는 아홉 자리는 전부 test 와 testFixtures 다. 이어서 namedOperationRequired 를 읽는 프로덕션 세 줄, GraphQlClientPolicy 인스턴스를 만드는 프로덕션 코드 전부와 그 빈을 내놓는 메서드의 조건 애너테이션, defaults 가 마지막 세 성분에 넣는 값 셋과 그 성분의 이름 셋이 나란히 나온다. 그다음 배선된 핸들러가 실행 사슬과 Instrumentation 을 거쳐 요청 경로에 오르는 자리들과 어댑터 자체의 프로퍼티 조건, GraphQlRequestContext 의 operationId 를 읽는 프로덕션 코드가 0 건이라는 확인, 그리고 GraphQlOperationNamePolicy 54행부터 61행까지와 GraphQlOperationName.parse 의 본문이 보인다." caption="세 구현의 바깥 참조 · namedOperationRequired 소비자 셋 · defaults 의 마지막 세 값 · 배선 사슬과 어댑터 조건 · operationId 소비자 0 — 89줄 · exit 0" zoom="true"
:::
`GraphQlOperationNamePolicy`(85줄)를 자기 파일 밖에서 쓰는 프로덕션 코드는 `GraphQlOperationNameInterceptor` 의 필드 선언(\:17)과 생성자(\:24) 두 줄이다. 그 인터셉터를 만드는 자리는 저장소 전체에서 `GraphQlOperationNamePolicyTest:94` 하나이고, 자동설정에는 없다.
`GraphQlRequestEnvelopeValidator`(152줄)의 `validateEnvelope` 도 같은 불리언을 읽어 이름 없는 요청을 거부한다(\:134). 이 클래스를 만드는 자리는 아홉인데 여덟은 자기 시험이고 나머지 하나가 `GraphQlContractFixture:84` 다. 프로덕션 조립에는 없다.
남는 것이 `GraphQlOperationSelectionHandler`(125줄)다. `GraphQlPlatformAutoConfiguration:404``new GraphQlOperationSelectionHandler(clientPolicy)` 로 만들어 `:403``GraphQlExecutionChain.stable` 에 넣고, `:420` 이 그 사슬을 `GraphQlPlatformInstrumentation` 으로 감싼다. Spring for GraphQL 이 `Instrumentation` 빈을 집어가므로 `GraphQlPlatformInstrumentation:81``chain.run` 이 실제 요청마다 돈다. 이 핸들러의 생성자가 받는 것은 `GraphQlOperationNamePolicy` 가 아니라 `GraphQlClientPolicy` 다.
어댑터 자체는 기본으로 꺼져 있다. `GraphQlRootAutoConfiguration:21``@ConditionalOnProperty(prefix = "backend.graphql", name = "enabled", havingValue = "true")` 를 달고 있어서, 아래의 통과와 거부는 그 프로퍼티를 켠 배포에서만 일어난다.
## 조립되는 GraphQlClientPolicy 의 namedOperationRequired 는 false 다
`GraphQlClientPolicy` 인스턴스를 만드는 프로덕션 코드는 `GraphQlClientPolicy.defaults` 와 그 안의 생성자 호출뿐이고, 그 `defaults` 를 부르는 자리는 `GraphQlPlatformAutoConfiguration:303` 하나다. 그 메서드에 `@ConditionalOnMissingBean` 이 붙어 있어 채택자가 자기 빈을 내놓지 않으면 이것이 쓰인다.
```java
// GraphQlClientPolicy.java:85-104 (마지막 세 성분)
public static GraphQlClientPolicy defaults(
int maximumPageSize, long maximumComplexity, boolean introspectionAllowed) {
return new GraphQlClientPolicy(
,
introspectionAllowed, // :101 → :49 introspectionAllowed
false, // :102 → :50 persistedOperationOnly
false); // :103 → :51 namedOperationRequired
}
```
레코드 성분 순서에서 `namedOperationRequired` 는 열여섯 번째이자 마지막(\:51)이고, `defaults` 가 그 자리에 넣는 값은 상수 `false` 다. 이 값을 프로퍼티로 바꿀 수 있는 경로는 없다.
## 같은 문서를 배선된 핸들러는 통과시키고 미배선 정책은 거부한다
:::evidence key="a16-f004-graphqloperationnamepolicy-run" alt="JVM 프로브 출력 19줄. 맨 앞에 OpenJDK 판이 찍히고, 조립되는 defaults 가 namedOperationRequired 에 false 를 넣는다는 것이 먼저 나온다. 이어서 배선된 GraphQlOperationSelectionHandler 에 그 값을 그대로 넣었을 때 익명 단일 연산과 이름이 Ab 인 연산이 모두 통과하고 둘 다 operationId 가 anonymous 로 찍히며, 이름이 HealthQuery 인 연산은 통과하면서 operationId 가 healthquery 로 정규화되고, 연산 둘에 operationName 이 없는 문서만 거부된다. 같은 핸들러에 namedOperationRequired 만 true 로 바꾸면 익명 단일 연산이 거부된다. 끝으로 미배선 GraphQlOperationNamePolicy.production().verify 가 PUBLIC 의 이름 없는 연산은 GraphQlAnonymousOperationException 으로 거부하고, ADMIN 의 이름 없는 연산은 통과시키며, 이름이 Ab 인 연산에서는 IllegalArgumentException 을 던지고, 이름이 HealthQuery 인 연산은 통과시키는 것이 보인다." caption="조립되는 값과 두 구현의 판정 · 같은 이름에 통과와 예외가 갈린다 — 19줄 · exit 0" zoom="true"
:::
조립되는 값 그대로 배선된 핸들러에 문서를 넣으면 익명 단일 연산이 통과한다. `:74``policy.namedOperationRequired() && selected.getName() == null` 에서 앞쪽이 거짓이라 뒤를 보지 않는다. 같은 핸들러에 그 성분만 `true` 로 바꾸면 같은 문서가 `GraphQlAnonymousOperationException` 으로 거부된다.
거부가 남아 있는 것은 셋이다. 연산이 하나도 없는 문서(\:53), `operationName` 이 가리키는 연산이 문서에 없는 경우(\:65), 그리고 연산이 둘 이상인데 `operationName` 이 없는 경우(\:68)다. 마지막 것을 거절이 아니라 기본 선택으로 두면 클라이언트가 문서 순서를 바꿔 실행 대상을 바꿀 수 있고 그것이 인가 우회가 된다고 이 핸들러의 클래스 자바독이 적는다. 이 셋은 클라이언트 프로파일과 무관하게 항상 건다.
두 구현이 반대로 답하는 입력도 있다. 연산 이름이 `Ab` 인 문서에서 배선된 핸들러는 통과시키고 `operationId``anonymous` 로 정규화한다. `MINIMUM_OPERATION_ID_LENGTH` 가 3 이라 두 글자는 식별자가 되지 못한다. 정규화 메서드의 자바독에는 이름 규칙 때문에 유효한 요청을 거절하지는 않는다고 적혀 있다. 미배선 정책에 같은 이름을 넣으면 `IllegalArgumentException : invalid GraphQL operation name` 이 나온다.
프로파일 예외도 한쪽에만 있다. `GraphQlOperationNamePolicy:49``production && !ADMIN.equals(client.value())` 로 ADMIN 을 요구에서 뺀다. 배선된 경로에는 프로파일별 분기가 없고 불리언 하나를 읽는다.
## GraphQlOperationNamePolicy\:57 은 실행되지 않는다
이름이 규칙을 어겼을 때 `GraphQlAnonymousOperationException` 을 던지려고 쓴 검사인데, 그 예외는 나오지 않는다.
```java
// GraphQlOperationNamePolicy.java:54-61
if (namedRequired && !selection.named()) {
throw new GraphQlAnonymousOperationException("named operation required");
}
if (selection.named() && GraphQlOperationName.parse(selection.operationName()).isEmpty()) {
// Validates the bounded naming pattern; an unbounded name would defeat the reason for
// requiring one at all.
throw new GraphQlAnonymousOperationException("named operation required");
}
```
`selection.named()` 가 참이면 `operationName` 은 널도 공백도 아니다. 그 값을 받은 `GraphQlOperationName.parse` 는 빈 `Optional` 을 돌려주는 분기(널이거나 공백)에 들어가지 않고 `new GraphQlOperationName(candidate)` 로 간다. 그 생성자는 `[A-Za-z][_0-9A-Za-z]{2,127}` 에 맞지 않으면 `IllegalArgumentException` 을 던진다. 그래서 `isEmpty()` 는 이 자리에서 참이 될 수 없고, 60행의 `throw` 는 실행되지 않는다.
`GraphQlRequestEnvelopeValidator:134` 에도 `parse(...).isEmpty()` 를 보는 같은 검사가 있지만 결과가 다르다. `envelope.operationName()` 은 클라이언트가 보내지 않으면 널이므로 `parse` 가 빈 `Optional` 을 돌려주고, 의도한 `GraphQlRequestFormatException` 이 나온다.
## 원문과 갈리는 자리
원문 §12.2 는 익명 연산 거부를 배선된 `GraphQlOperationSelectionHandler` 가 수행하므로 강제 자체는 존재한다고 적고, 기록할 것은 정책 객체의 이원화라고 했다. 위 실행은 그중 강제가 존재한다는 부분을 뒤집는다. 조립되는 `GraphQlClientPolicy``namedOperationRequired``false` 를 넣으므로, 이름을 요구하는 강제는 출하 상태에서 어느 코드도 걸지 않는다. 배선된 핸들러가 항상 거는 것은 연산이 없는 문서, 지정한 이름의 연산이 없는 문서, 연산이 여럿인데 `operationName` 이 없는 문서 세 가지이지 이름 요구가 아니다.
원문 §11.2 가 미배선 인터셉터를 누락이 아니라 중복이라고 판정한 것도 함께 흔들린다. `GraphQlOperationNamePolicy.production()` 은 ADMIN 이 아닌 프로파일에 이름을 강제하는데 배선된 핸들러는 지금 그것을 강제하지 않으므로, 두 구현은 같은 일을 하지 않는다.
원문의 인용에도 어긋난 곳이 둘 있다. 배선된 검사를 `:75` 로 적었는데 실제는 `:74` 다. 그리고 `GraphQlOperationNamePolicy` 의 참조자를 미배선 인터셉터와 자기 자신뿐이라고 적었는데 자기 시험 여덟 줄이 빠졌다.
이원화 판정과 P3 등급은 원문과 같다.
## 확인하지 못한 것
프로브는 실행 사슬의 한 단계만 직접 불렀다. 요청을 엔드포인트로 쏘아 응답을 받아 본 것은 아니다.
채택자가 `GraphQlClientPolicy` 빈을 직접 등록하면 `@ConditionalOnMissingBean` 이라 `namedOperationRequired` 가 참이 될 수 있다. 그런 배포를 띄워 보지는 않았다.
브라우저나 HTTP 클라이언트로 실제 GraphQL 요청을 보내지 않았다. 프로브는 `GraphQlOperationSelectionHandler.handle` 을 직접 불렀고, 그 앞뒤에 있는 전송 계층과 인증 계층은 거치지 않았다.
<!-- body:end -->