Files
document-haness/docs/clean-architecture-backend-template/tech-log-studio/websocket-and-realtime/case/case-a17-f002-stomp.md
T
DongHyeonkaandClaude Opus 5 b2963105a8 docs(keycloak-session-store): import the session-storage lab as a new project
The keycloak project ended with four open questions that design could not
settle. A two-VM lab was built to answer them by measurement, and this is
that material: 26 experiments, 125 raw command outputs, 22 browser captures.

Follows the import procedure in README.md.

  source/     the originating repository verbatim — 78 documents, 28 SVGs,
              8 manifests, plus .source-revision recording the commit
  final/      the SSOT
    document.md   729 lines written from the 29 experiment documents, not
                  concatenated: what was predicted, what was measured, and
                  where the measurement itself was wrong
    evidence/raw    125 outputs, flattened to <experiment>__<file> because
                    the originals collided (01-baseline.txt appeared three
                    times) and the audit only globs the top level
    evidence/meta   one per raw file; command and exitCode are null and the
                    README says why rather than inventing them
    evidence/browser  22 captures
    assets/       three diagrams through techviz
    .techviz/     their VizSpecs

A separate project rather than an addition to keycloak: the B-layer answers
that project's four questions, but the A, C and D layers are about cluster
failure, SSO and operations, and one document.md should hold one subject.
The four question records there can point here through 관계.

Recorded rather than papered over: only three of the 28 diagrams were
remade. The repository forbids hand-drawn SVG and forbids titles inside the
canvas; all 28 originals carry both, so converting them is redrawing, not
reformatting. They stay in source/ and the gap is written into the document.

verify-pipeline.py passes. audit-records.py reports no issues.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 22:51:59 +09:00

15 KiB

kind, slug, title, topic, project, status, sourceRevision, rootTreeNode, evidenceCapturedOn, body, assets, evidence, source
kind slug title topic project status sourceRevision rootTreeNode evidenceCapturedOn body assets evidence source
CASE a17-f002-stomp 연결 티켓·origin 정책·메시지 권한·연결 예산이 요청 경로 밖이고, 그중 일부는 STOMP 어댑터가 다른 방식으로 대체한다 websocket-and-realtime clean-architecture-backend-template 게시 전 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916 case:a17-f002-stomp 2026-09-04 case-a17-f002-stomp.body.md
key file
a17-f002-stomp ../../../final/evidence/rendered/a17-f002-stomp.svg
../../../final/evidence/raw/a17-f002-stomp.txt
원본 분석 절은 analysis/17-adapter-inbound-websocket.md#L273 이다. 등록 지점과 세 번째 인터셉터, 출하 그래프 부재, 정책 열 파일의 계수와 생성 지점, 그리고 22 의 출처는 위 정적 검색에서 확인할 수 있다.

연결 티켓·origin 정책·메시지 권한·연결 예산이 요청 경로 밖이고, 그중 일부는 STOMP 어댑터가 다른 방식으로 대체한다

WebSocketConfigAuthenticatedHandshakeInterceptorWebSocketInboundAuthorizationInterceptor 를 등록한다. security·authz·idempotency·budget 네 패키지의 정책 타입 열 개는 stomp 패키지가 한 번도 참조하지 않는다. 다만 이 모듈 자체가 두 출하 런타임 그래프에 모두 없어서, 어느 쪽도 지금 도는 코드는 아니다.

관계

  • 프레임워크 자유 신원 모델과 교차 테넌트 가드가 프로덕션에서 한 번도 참조되지 않는다 둘 다 보안 정책 타입 묶음의 프로덕션 참조가 0 이고, 실제로 도는 검사는 프레임워크 설정 쪽에 따로 있다.
  • 능력 프로퍼티 이름을 만드는 코드와 실제 게이트가 다른 접두사를 쓴다 같은 리프에서, 선언된 이름이나 타입을 읽는 코드가 실제 등록 경로에 없다. 저쪽은 능력 이름을 읽는 조건이 없고, 여기는 정책 열 타입을 참조하는 코드가 stomp 에 없다.
  • 중복 장치를 찾으면 어느 쪽이 조립됐는지 먼저 확인한다 인바운드 권한을 정하는 구현이 둘인데, WebSocketConfig 가 등록하는 것은 WebSocketInboundAuthorizationInterceptor 뿐이고 MessageAuthorizationPolicyvalidate 에 실려 가지도 않는다.

문제

인바운드 보안을 정하는 코드가 이 리프에 두 벌 있다.

어느 쪽이 채널에 등록되는지, 등록되지 않는 쪽이 어디까지 소비되는지, 그리고 그 대비가 어느 배포에서 관측되는지 확인했다.

결론

어댑터가 등록하는 것은 인터셉터 둘이다.

핸드셰이크에 AuthenticatedHandshakeInterceptor(34줄)가 WebSocketConfig:53 에서, 클라이언트 인바운드 채널에 WebSocketInboundAuthorizationInterceptor(53줄)가 :59 에서 붙는다. 그 설정 클래스는 ca-skeleton.websocket.enabled 조건 아래에 있다.

같은 채널에 붙는 인터셉터가 하나 더 있다. advanced/stomp/StompSecurityInterceptor(96줄)를 StompConfiguration:40 이 등록하고, 게이트는 app.websocket-platform.advanced.stomp.enabled 다.

정책 계층은 네 패키지에 열 파일이다. security 넷은 WebSocketOriginPolicy(116줄), WebSocketConnectionTicket(77줄), WebSocketAuthenticationProfile(57줄), WebSocketTicketStore(32줄), authz 하나는 MessageAuthorizationPolicy(93줄), budget 하나는 WebSocketConnectionBudget(103줄), idempotency 넷은 CommandReconciliation(84줄)·CommittedResultLedger(61줄)·WebSocketCommandKey(40줄)·WebSocketCommandOutcome(33줄)이다.

그 열 개 중 stomp 쪽이 이름이라도 언급하는 것은 하나도 없다. 검색 범위를 리프 전체로 넓히면 WebSocketOriginPolicy 와 WebSocketConnectionBudget 과 MessageAuthorizationPolicy 만 각각 한 자리에서 만들어진다.

MessageAuthorizationPolicy 는 한 단 더 간다. 그것을 받는 main 코드는 WebSocketPlatformStartupValidator.validate 의 파라미터 하나인데, 그 검증기의 생성자는 불리언 하나를 받고 생성 지점은 자기 시험 두 줄뿐이다. 이 정책을 validate 에 실어 보내는 코드가 없다.

노출로 볼 근거는 없다. 다만 근거는 문서 정합이 아니라 배선이다. app-bootstrap/build.gradle:196~:200 과 리프 CLAUDE.md:21~:23 이 이 모듈은 두 출하 런타임 그래프에 모두 없다고 적는다. 넓은 쪽만 꺼져 있는 것이 아니라 좁은 쪽도 지금은 돌지 않는다.

기록하는 것은 한 리프에 인바운드 보안 코드가 두 벌 있고, stomp 가 정책 열 타입을 한 번도 참조하지 않으며, 그중 MessageAuthorizationPolicy 는 넘겨지는 자리조차 없다는 것이다.

판정은 P2 이고 원문과 같다. 다만 원문 §13.1 이 정책 계층을 22 파일로 적은 것과 달리 실제로 센 것은 10 파일이고, 22 는 sub-scope 04 여덟 패키지의 main 합계다.

검증 환경

확인 방식 : 어댑터와 advanced 쪽 인터셉터의 등록 지점과 게이트 확인, 출하 런타임 그래프 포함 여부 확인, 정책 네 패키지의 파일·선언 형태·줄 수 전수, 열 타입의 stomp 참조와 main 생성 지점 계수, MessageAuthorizationPolicy 의 공개 멤버와 소비 사슬 추적, 원문이 적은 22 의 출처 대조, 리프 CLAUDE.md 의 범위와 인바운드 정책 절 확인 소스 수정 : x

재현 조건

  1. WebSocketConfig 에서 인터셉터를 만들고 등록하는 줄과 조건 애너테이션을 읽는다.
  2. advanced/stomp 에서 같은 채널에 등록되는 인터셉터와 그 게이트를 찾는다.
  3. app-bootstrap 빌드 파일과 리프 CLAUDE.md 에서 이 모듈의 출하 여부를 읽는다.
  4. security·authz·idempotency·budget 네 패키지의 파일을 이름·선언 형태·줄 수까지 전부 센다.
  5. 4에서 나열한 이름들을 stomp 쪽에서 되짚고, 같은 이름의 main 생성 지점도 함께 센다.
  6. MessageAuthorizationPolicy 의 공개 멤버를 나열하고, 그것을 받는 main 코드와 그 코드의 생성자 시그니처와 생성 지점을 차례로 확인한다.
  7. 원문이 22 로 적은 수가 어느 헤더에서 왔는지 찾고, 그 헤더가 나열한 패키지를 전부 세어 대조한다.
  8. 리프 CLAUDE.md 의 범위 절과 인바운드 정책 절을 끝까지 읽는다.

본문

이 리프에는 인바운드 보안을 정하는 코드가 두 벌 있다. 어댑터가 등록하는 인터셉터들과, security·authz·idempotency·budget 네 패키지의 정책 타입들이다.

등록되는 인터셉터와 그 게이트

:::evidence key="a17-f002-stomp" alt="저장소 루트에서 돌린 정적 검색 출력 98줄. ca-skeleton.websocket 어댑터가 등록하는 인터셉터 둘의 줄 수와 WebSocketConfig 의 조건 애너테이션·필드 생성·addInterceptors·configureClientInboundChannel 줄이 먼저 나온다. 이어서 같은 채널에 붙는 세 번째 인터셉터 StompSecurityInterceptor 와 그것을 등록하는 StompConfiguration, 그리고 그쪽의 별도 프로퍼티 게이트가 나온다. 그다음 이 모듈이 두 출하 런타임 그래프에 모두 없다는 app-bootstrap 빌드 파일의 주석과 리프 CLAUDE.md 의 같은 서술이 이어진다. 정책 계층 열 파일이 패키지·이름·선언 형태·줄 수로 나열되고 합계가 10 으로 찍힌다. 그다음 열 타입 각각에 대해 stomp 참조 수와 main 생성 수와 같은 파일의 @Bean 유무가 표로 나오는데 stomp 참조는 전부 0 이고 main 생성은 셋만 1 이다. 이어서 원문이 22 로 적은 수가 sub-scope 04 여덟 패키지의 main 합계라는 것이 헤더와 계수로 확인되고, MessageAuthorizationPolicy 의 공개 메서드 여섯과 그것을 받는 자리, 그 받는 쪽의 생성자와 생성 지점이 나온다. 끝으로 리프 CLAUDE.md 의 범위 네 줄과 인바운드 정책 절 다섯 규칙이 보인다." caption="어댑터 인터셉터 둘과 세 번째 인터셉터 · 두 출하 그래프에 모두 없음 · 정책 열 파일과 stomp 참조 0 · 22 의 출처 · 검증기의 두 시그니처 · CLAUDE.md 범위 — 98줄 · exit 0" zoom="true" :::

WebSocketConfigAuthenticatedHandshakeInterceptor(34줄)를 :36~:37 에서 만들어 :53.addInterceptors 로 핸드셰이크에 붙이고, WebSocketInboundAuthorizationInterceptor(53줄)를 :44 에서 만들어 :59configureClientInboundChannel 에서 클라이언트 인바운드 채널에 등록한다. 이 설정 클래스는 :32@ConditionalOnProperty(prefix = "ca-skeleton.websocket", name = "enabled", havingValue = "true") 아래에 있다.

인터셉터가 둘만은 아니다. advanced/stomp/StompSecurityInterceptor(96줄)가 StompConfiguration:40 에서 같은 클라이언트 인바운드 채널에 등록된다. 게이트는 다른 프로퍼티 접두사다 — :24~:27app.websocket-platform.advanced.stomp.enabled. 아래에서 어댑터 쪽 둘을 셀 때 이 세 번째는 포함하지 않는다.

어느 쪽도 출하 배포에서 돌지 않는다

app-bootstrap/build.gradle:196~:200 이 이 리프를 conditionalTransportTestImplementation 으로만 물면서 주석에 적는다 — 이 프로젝트들은 main 의 api/implementation/compileOnly/runtimeOnly 에 없고 따라서 두 출하 런타임 그래프에서도 빠진다. 리프의 CLAUDE.md:21~:23 이 같은 것을 다시 적는다. 등록되고 시험된다고 활성화되는 것이 아니며, 앞으로의 컴포지션이 의존을 의도적으로 추가하고 프로퍼티를 켜야 한다는 것이다.

따라서 아래의 대비는 이 모듈을 컴포지션에 넣고 프로퍼티를 켠 배포를 가정한 것이다.

정책 계층 열 파일

security 넷은 WebSocketOriginPolicy(final class, 116줄), WebSocketConnectionTicket(record, 77줄), WebSocketAuthenticationProfile(enum, 57줄), WebSocketTicketStore(interface, 32줄)다. authz 하나가 MessageAuthorizationPolicy(final class, 93줄), budget 하나가 WebSocketConnectionBudget(record, 103줄)이다. idempotency 넷 중 인터페이스가 둘(CommandReconciliation 84줄, CommittedResultLedger 61줄), 나머지가 WebSocketCommandKey(record, 40줄)와 WebSocketCommandOutcome(enum, 33줄)이다.

열 타입의 이름을 stomp 패키지 안에서 하나씩 찾으면 전부 0 건이다. 어댑터가 등록하는 두 인터셉터는 이 타입들을 알지 못한다.

다만 리프 전체로 넓히면 셋은 main 에서 생성된다. WebSocketOriginPolicy, WebSocketConnectionBudget, MessageAuthorizationPolicy 가 각각 한 자리씩이다. 그 자리들이 요청 경로에 오르는지까지는 이 검색이 답하지 않는다.

MessageAuthorizationPolicy 를 넘기는 코드가 없다

MessageAuthorizationPolicy 의 공개 멤버는 여섯이다. of(:39), permits(:57), declares(:70), undeclaredAmong(:81), requirements(:90), 그리고 클래스 자신이다.

이것을 자기 파일 밖에서 받는 main 코드는 WebSocketPlatformStartupValidator 하나다. 다만 생성자가 아니라 validate(...) 의 파라미터(:56)이고, 본문 :106undeclaredAmong 을 부른다.

그 검증기의 생성자는 :38WebSocketPlatformStartupValidator(boolean productionProfile) 다. 그것을 생성하는 두 줄은 WebSocketPlatformStartupValidatorTest:40·:42 이고 넘기는 인자는 불리언이다. 즉 이 정책 객체를 validate 에 실어 보내는 코드는 main 에도 test 에도 나오지 않았다.

등록된 인터셉터가 강제하는 것과 문서가 적은 것

리프 CLAUDE.md:15~:18 이 이 모듈의 범위를 네 줄로 적고, 그중 :16"HTTP-handshake principal enforcement and client-inbound STOMP destination authorization" 이다.

:52~:60 의 인바운드 정책 절은 규칙 다섯이다. HTTP 업그레이드에 비어 있지 않은 Principal 이 이미 있어야 하고 어댑터가 자격을 인증하지는 않는다, SUBSCRIBE 는 설정된 브로드캐스트 목적지에만 허용한다, 인증된 SEND/app/** 아래만 허용한다, /topic/** 로 가는 클라이언트 SEND 는 거부한다, 그리고 클라이언트가 보는 모든 처리 실패는 고정된 WEBSOCKET_REQUEST_REJECTED ERROR 코드가 된다.

앞의 넷이 두 인터셉터의 일이다. 다섯째는 SafeStompSubProtocolErrorHandler 가 맡는다.

원문과 갈리는 자리

원문 §13.1 은 플랫폼 정책 계층을 22 파일로 적었다. 22 는 같은 문서 :235 의 sub-scope 04 헤더가 적은 수다 — security·authz·idempotency·budget·error·observability·admin·release 여덟 패키지의 main 합계이고, 세어 보면 4+1+4+1+5+2+2+3 = 22 다. §12.1 이 정책 계층으로 한정해 열거한 것은 앞의 네 패키지 10 파일이다. §13.1 이 sub-scope 계수를 정책 계층 계수 자리에 옮겨 썼다.

원문 §12.2 는 MessageAuthorizationPolicy 의 등록을 "없음" 으로 적었다. 등록되지 않는다는 결론은 같지만, 그것을 validate 파라미터로 받는 main 코드가 하나 있고 그 검증기 자신이 다시 생성되지 않는다는 두 단은 그 표에 없다.

원문 §18.1 은 StompConfiguration@Bean 0 이고 리프 타입 import 가 없어 프레임워크 설정만 조정하는 부류로 봤다. 같은 패키지라 import 문이 없을 뿐, 생성자로 StompSecurityInterceptor 를 받아 인바운드 채널에 등록한다.

그리고 원문 §13.1 의 "실제 배포에서 적용되는 보안은 stomp 패키지의 두 인터셉터" 는 이 리비전에서 성립하지 않는다. 그 두 인터셉터도 출하 런타임 그래프에 없다.

확인하지 못한 것

WebSocket 클라이언트를 붙여 구독과 전송을 시도해 보지 않았다. 확인한 것은 어느 코드가 등록되고 어느 이름이 어디서 참조·생성되는가까지다.

리프 밖 main 에서 세 타입을 생성하는 자리가 요청 경로에 오르는지는 추적하지 않았다. 나머지 일곱 타입의 리프 밖 참조도 세지 않았다.

제목이 말하는 "다른 방식으로 대체한다" 중 이 기록이 확인한 것은 목적지 허용 목록 하나다. 연결 티켓과 출처 정책과 연결 예산을 어댑터가 대신하는지는 보지 않았다.

WebSocket 클라이언트를 붙여 구독과 전송을 시도해 보지 않았다. 확인한 것은 어느 코드가 등록되고 어느 이름이 어디서 참조·생성되는가까지다.

리프 밖 main 에서 세 타입을 생성하는 자리가 요청 경로에 오르는지는 추적하지 않았다. 나머지 일곱 타입의 리프 밖 참조도 세지 않았다.

넓은 모델을 배선했을 때 좁은 모델과 어떤 충돌이 생기는지도 보지 않았다.