chore: snapshot working tree before harness removal

미추적 파일과 미커밋 수정을 전부 담아 pre-harness-removal 태그의 복구
범위를 확보한다. .agents/skills/writing-natural-korean 9개와
korean-technical-blog-skills-bundle-v1 61개가 여기 포함된다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
DongHyeonka
2026-08-07 14:19:24 +09:00
co-authored by Claude Opus 5
parent b101b6e717
commit 1099834617
85 changed files with 8547 additions and 114 deletions
@@ -0,0 +1,28 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="1400" height="330" viewBox="0 0 1400 330" role="img" aria-labelledby="title desc">
<title id="title">Runtime call</title>
<desc id="desc">FeedController calls GetFeedUseCase, which dispatches to SpringTransactionPort at runtime.</desc>
<metadata>{&quot;source&quot;:&quot;runtime-call-source-dependency.svg&quot;,&quot;panel&quot;:&quot;upper&quot;,&quot;canvas_policy&quot;:&quot;diagram-only&quot;}</metadata>
<defs>
<marker id="arrow-ink" markerWidth="10" markerHeight="8" refX="9" refY="4" orient="auto" markerUnits="strokeWidth"><path d="M 0 0 L 10 4 L 0 8 z" fill="#25282D"/></marker>
</defs>
<rect width="1400" height="330" fill="#FFFFFF"/>
<rect x="35" y="45" width="1330" height="245" fill="#FBFCFE" stroke="#C5CBD3" stroke-width="1.3" rx="10"/>
<rect x="44" y="34" width="233.9" height="20" fill="#FFFFFF" rx="2"/>
<text x="51" y="50" font-family="'Noto Sans CJK KR', 'Apple SD Gothic Neo', sans-serif" font-size="11" font-weight="650" fill="#667085" text-anchor="start">실행 시점 관계 · 실선 = 호출·디스패치</text>
<rect x="100" y="125" width="245" height="90" fill="#FFFFFF" stroke="#59616B" stroke-width="1.5" rx="8"/>
<text x="222.5" y="164" font-family="'Noto Sans CJK KR', 'Apple SD Gothic Neo', sans-serif" font-size="16" font-weight="700" fill="#25282D" text-anchor="middle">FeedController</text>
<text x="222.5" y="189" font-family="'Noto Sans CJK KR', 'Apple SD Gothic Neo', sans-serif" font-size="12" font-weight="450" fill="#667085" text-anchor="middle">driving adapter</text>
<rect x="555" y="105" width="285" height="130" fill="#EAF3FF" stroke="#1677FF" stroke-width="1.5" rx="8"/>
<text x="697.5" y="164" font-family="'Noto Sans CJK KR', 'Apple SD Gothic Neo', sans-serif" font-size="16" font-weight="700" fill="#25282D" text-anchor="middle">GetFeedUseCase</text>
<text x="697.5" y="189" font-family="'Noto Sans CJK KR', 'Apple SD Gothic Neo', sans-serif" font-size="12" font-weight="450" fill="#667085" text-anchor="middle">concrete service</text>
<rect x="1060" y="125" width="250" height="90" fill="#FFFFFF" stroke="#59616B" stroke-width="1.5" rx="8"/>
<text x="1185" y="164" font-family="'Noto Sans CJK KR', 'Apple SD Gothic Neo', sans-serif" font-size="16" font-weight="700" fill="#25282D" text-anchor="middle">SpringTransactionPort</text>
<text x="1185" y="189" font-family="'Noto Sans CJK KR', 'Apple SD Gothic Neo', sans-serif" font-size="12" font-weight="450" fill="#667085" text-anchor="middle">runtime implementation</text>
<path d="M 345 170 L 555 170" fill="none" stroke="#25282D" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" marker-end="url(#arrow-ink)"/>
<rect x="405.4" y="145" width="89.2" height="20" fill="#FFFFFF" rx="2"/>
<text x="450" y="161" font-family="'Noto Sans CJK KR', 'Apple SD Gothic Neo', sans-serif" font-size="11" font-weight="650" fill="#25282D" text-anchor="middle">Runtime call</text>
<path d="M 840 170 L 1060 170" fill="none" stroke="#25282D" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" marker-end="url(#arrow-ink)"/>
<rect x="892.8" y="145" width="114.3" height="20" fill="#FFFFFF" rx="2"/>
<text x="950" y="161" font-family="'Noto Sans CJK KR', 'Apple SD Gothic Neo', sans-serif" font-size="11" font-weight="650" fill="#25282D" text-anchor="middle">Runtime dispatch</text>
</svg>

After

Width:  |  Height:  |  Size: 3.3 KiB

@@ -0,0 +1,36 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="1400" height="340" viewBox="0 0 1400 340" role="img" aria-labelledby="title desc">
<title id="title">Source dependency and contract ownership</title>
<desc id="desc">GetFeedUseCase depends on application-core contracts, while SpringTransactionPort implements TransactionPort.</desc>
<metadata>{&quot;source&quot;:&quot;runtime-call-source-dependency.svg&quot;,&quot;panel&quot;:&quot;lower&quot;,&quot;canvas_policy&quot;:&quot;diagram-only&quot;}</metadata>
<defs>
<marker id="arrow-muted" markerWidth="10" markerHeight="8" refX="9" refY="4" orient="auto" markerUnits="strokeWidth"><path d="M 0 0 L 10 4 L 0 8 z" fill="#667085"/></marker>
<marker id="arrow-purple" markerWidth="10" markerHeight="8" refX="9" refY="4" orient="auto" markerUnits="strokeWidth"><path d="M 0 0 L 10 4 L 0 8 z" fill="#7556D8"/></marker>
</defs>
<rect width="1400" height="340" fill="#FFFFFF"/>
<rect x="35" y="45" width="1330" height="255" fill="#FBFCFE" stroke="#C5CBD3" stroke-width="1.3" rx="10"/>
<rect x="44" y="34" width="284.2" height="20" fill="#FFFFFF" rx="2"/>
<text x="51" y="50" font-family="'Noto Sans CJK KR', 'Apple SD Gothic Neo', sans-serif" font-size="11" font-weight="650" fill="#667085" text-anchor="start">계약 소유·소스 의존 · 점선 = 타입·계약을 향함</text>
<rect x="95" y="135" width="260" height="85" fill="#F1EDFF" stroke="#7556D8" stroke-width="1.5" rx="8"/>
<text x="225" y="156" font-family="'Noto Sans CJK KR', 'Apple SD Gothic Neo', sans-serif" font-size="11" font-weight="600" fill="#667085" text-anchor="middle" letter-spacing="0.8">&lt;&lt;interface&gt;&gt;</text>
<text x="225" y="177.5" font-family="'Noto Sans CJK KR', 'Apple SD Gothic Neo', sans-serif" font-size="16" font-weight="700" fill="#25282D" text-anchor="middle">QueryUseCase&lt;Q,R&gt;</text>
<text x="225" y="202.5" font-family="'Noto Sans CJK KR', 'Apple SD Gothic Neo', sans-serif" font-size="12" font-weight="450" fill="#667085" text-anchor="middle">application-core contract</text>
<rect x="565" y="115" width="270" height="125" fill="#EAF3FF" stroke="#1677FF" stroke-width="1.5" rx="8"/>
<text x="700" y="171.5" font-family="'Noto Sans CJK KR', 'Apple SD Gothic Neo', sans-serif" font-size="16" font-weight="700" fill="#25282D" text-anchor="middle">GetFeedUseCase</text>
<text x="700" y="196.5" font-family="'Noto Sans CJK KR', 'Apple SD Gothic Neo', sans-serif" font-size="12" font-weight="450" fill="#667085" text-anchor="middle">implements · calls</text>
<rect x="1060" y="135" width="240" height="85" fill="#F1EDFF" stroke="#7556D8" stroke-width="1.5" rx="8"/>
<text x="1180" y="156" font-family="'Noto Sans CJK KR', 'Apple SD Gothic Neo', sans-serif" font-size="11" font-weight="600" fill="#667085" text-anchor="middle" letter-spacing="0.8">&lt;&lt;interface&gt;&gt;</text>
<text x="1180" y="177.5" font-family="'Noto Sans CJK KR', 'Apple SD Gothic Neo', sans-serif" font-size="16" font-weight="700" fill="#25282D" text-anchor="middle">TransactionPort</text>
<text x="1180" y="202.5" font-family="'Noto Sans CJK KR', 'Apple SD Gothic Neo', sans-serif" font-size="12" font-weight="450" fill="#667085" text-anchor="middle">application-core contract</text>
<path d="M 565 160 L 355 160" fill="none" stroke="#7556D8" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" stroke-dasharray="7 5" marker-end="url(#arrow-purple)"/>
<rect x="421.6" y="135" width="76.7" height="20" fill="#FFFFFF" rx="2"/>
<text x="460" y="151" font-family="'Noto Sans CJK KR', 'Apple SD Gothic Neo', sans-serif" font-size="11" font-weight="650" fill="#7556D8" text-anchor="middle">Implements</text>
<path d="M 835 160 L 1060 160" fill="none" stroke="#7556D8" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" stroke-dasharray="7 5" marker-end="url(#arrow-purple)"/>
<rect x="902.9" y="135" width="89.2" height="20" fill="#FFFFFF" rx="2"/>
<text x="947.5" y="151" font-family="'Noto Sans CJK KR', 'Apple SD Gothic Neo', sans-serif" font-size="11" font-weight="650" fill="#7556D8" text-anchor="middle">Runtime call</text>
<rect x="1080" y="65" width="200" height="50" fill="#FFFFFF" stroke="#59616B" stroke-width="1.5" rx="8"/>
<text x="1180" y="95" font-family="'Noto Sans CJK KR', 'Apple SD Gothic Neo', sans-serif" font-size="12" font-weight="700" fill="#25282D" text-anchor="middle">SpringTransactionPort</text>
<path d="M 1180 115 L 1180 135" fill="none" stroke="#667085" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" stroke-dasharray="7 5" marker-end="url(#arrow-muted)"/>
<rect x="1221.7" y="117" width="76.7" height="20" fill="#FFFFFF" rx="2"/>
<text x="1260" y="133" font-family="'Noto Sans CJK KR', 'Apple SD Gothic Neo', sans-serif" font-size="11" font-weight="650" fill="#667085" text-anchor="middle">Implements</text>
</svg>

After

Width:  |  Height:  |  Size: 4.7 KiB

@@ -236,12 +236,12 @@ Port는 아웃바운드 어댑터가 코어에 제공해야 할 기능을 정합
코드가 바뀌는 이유를 기준으로 `ca-tmpl`의 모듈에 대응시켰습니다. 같은 이유로 바뀌는 코드는 한 경계에
두고 다른 이유로 바뀌는 코드는 의존 방향을 나눴습니다.
| 링 | `ca-tmpl` 모듈 | 대표 책임 | 이곳에 두는 이유 |
| -------------------------------------------------- | --------------------------------------------- | ------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------- |
| 링 | `ca-tmpl` 모듈 | 대표 책임 | 이곳에 두는 이유 |
| -------------------------------------------------- | --------------------------------------------- | ------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------- |
| 엔터프라이즈 업무 규칙(Enterprise Business Rules) | `domain-core` | Aggregate·Value Object — 예:`FeedItem` | 핵심 불변식은 HTTP·DB·Spring 교체와 무관하게 유지돼야 합니다. 그래서 JPA와 Spring 타입을 클래스패스에서 제외합니다. |
| 애플리케이션 업무 규칙(Application Business Rules) | `application-core` | Use Case·Input/Output Port — 예:`GetFeedUseCase`, `FeedQueryPort` | 유스케이스는 업무 흐름과 필요한 외부 능력을 정의하되, 그 능력을 어떤 기술로 구현하는지는 몰라야 합니다. |
| 인터페이스 어댑터(Interface Adapters) | `adapter:inbound:*`, `adapter:outbound:*` | Controller·영속성 어댑터 — 예:`FeedController`, `FeedQueryAdapter` | HTTP·JPA 같은 외부 모델을 코어 계약으로 변환하는 책임을 모아 기술 변경의 직접 파급을 경계 밖에 가둡니다. |
| 프레임워크와 드라이버(Frameworks & Drivers) | 어댑터의 구체 기술 의존,`app-bootstrap` | Spring MVC·Spring Data JPA·PostgreSQL·Boot/Flyway·관측·보안 배선 | 구체 프레임워크 선택과 실행 시점 조립은 배포 환경에 따라 바뀌므로 가장 바깥에서 결정합니다. |
| 애플리케이션 업무 규칙(Application Business Rules) | `application-core` | Use Case·Input/Output Port — 예:`GetFeedUseCase`, `FeedQueryPort` | 유스케이스는 업무 흐름과 필요한 외부 능력을 정의하되, 그 능력을 어떤 기술로 구현하는지는 몰라야 합니다. |
| 인터페이스 어댑터(Interface Adapters) | `adapter:inbound:*`, `adapter:outbound:*` | Controller·영속성 어댑터 — 예:`FeedController`, `FeedQueryAdapter` | HTTP·JPA 같은 외부 모델을 코어 계약으로 변환하는 책임을 모아 기술 변경의 직접 파급을 경계 밖에 가둡니다. |
| 프레임워크와 드라이버(Frameworks & Drivers) | 어댑터의 구체 기술 의존,`app-bootstrap` | Spring MVC·Spring Data JPA·PostgreSQL·Boot/Flyway·관측·보안 배선 | 구체 프레임워크 선택과 실행 시점 조립은 배포 환경에 따라 바뀌므로 가장 바깥에서 결정합니다. |
`domain-core`를 별도 모듈로 둔 이유는 도메인 규칙을 프레임워크 변경에서 보호하기 위해서입니다. 이 모듈에는
Spring Web, JPA, Spring TX가 없으므로 도메인 코드가 해당 타입을 참조하면 컴파일 단계에서 실패합니다.
@@ -391,7 +391,9 @@ Inbound와 outbound는 테스트 전략도 다릅니다.
- Outbound adapter : 데이터 매핑, 외부 시스템 연동, timeout-retry, 기술 예외 변환
<!-- techviz:begin id=inbound-transport-boundary context-sha256=04fbab095d33d301746c34f7cca305730919bad3c341bcf63b8ad3ee3b396d31 -->
<!-- techviz:generate id=inbound-transport-boundary -->
![네 inbound adapter의 HTTP DTO, protobuf message, GraphQL request, WebSocket message가 Command 또는 Query로 수렴해 application use case를 호출하는 흐름도.](../assets/inbound-transport-boundary/inbound-transport-boundary.svg)
왼쪽에서 오른쪽으로 읽습니다. web은 HTTP DTO, grpc는 protobuf message, graphql은 GraphQL request, websocket은 WebSocket message를 각 어댑터 경계에서 처리합니다. 네 어댑터는 전송 기술 타입을 application-core로 넘기지 않고 Command 또는 Query로 변환합니다. 변환된 입력만 Application use case를 호출합니다.
@@ -401,7 +403,9 @@ app-bootstrap은 프로젝트가 실제 사용할 inbound와 outbound adapter를
이 프로젝트의 모듈 간 의존은 inbound와 outbound 모두 바깥에서 안쪽으로 향합니다. verifyCleanArchitectureDependencies는 모듈 간 프로젝트의 의존성을 검사하고, ArchUnit의 DOMAIN_IS_TRUE는 모듈 내부 코드가 금지된 프레임워크 타입을 참조하는지 검사합니다.
<!-- techviz:begin id=bootstrap-dependency-guards context-sha256=04fbab095d33d301746c34f7cca305730919bad3c341bcf63b8ad3ee3b396d31 -->
<!-- techviz:generate id=bootstrap-dependency-guards -->
![가운데 application-core와 양쪽 port·adapter, 아래 app-bootstrap, Gradle 모듈 의존 게이트와 ArchUnit 내부 순수성 게이트의 연결을 함께 보여 주는 ports-and-adapters 구조도.](../assets/bootstrap-dependency-guards/bootstrap-dependency-guards.svg)
가운데 Application Core를 기준으로 왼쪽에는 Inbound adapters와 Input port, 오른쪽에는 Output port와 Outbound adapters가 있습니다. 어댑터의 모듈 의존은 포트와 코어 쪽을 향합니다. 아래의 app-bootstrap은 실제 사용할 양쪽 어댑터를 선택하고 application port에 연결합니다. 별도의 두 검증 게이트 중 verifyCleanArchitectureDependencies는 모듈 간 프로젝트 의존을 검사하고 ArchUnit 규칙은 모듈 내부 코드의 금지된 프레임워크 타입 참조를 검사합니다.
@@ -433,15 +437,16 @@ app-bootstrap은 프로젝트가 실제 사용할 inbound와 outbound adapter를
`Command` 마커는 `sample-portfolio``CreateWorkLogCommand`에 실제로 적용돼 있습니다.
재매핑은 두 번 일어납니다.
1. persistence에서 application으로 넘어갈 때입니다.
`FeedQueryAdapter.loadFeed()`는 JPA 조회 결과를 `FeedSummary`로 직접 조립합니다.
`FeedItemJpaEntity → FeedItem → FeedSummary`처럼 애그리게이트를 재구성하지 않고 조회 결과에서 곧장 애플리케이션 프로젝션으로 건너갑니다.
`FeedQueryAdapter.loadFeed()`는 JPA 조회 결과를 `FeedSummary`로 직접 조립합니다.
`FeedItemJpaEntity → FeedItem → FeedSummary`처럼 애그리게이트를 재구성하지 않고 조회 결과에서 곧장 애플리케이션 프로젝션으로 건너갑니다.
같은 DB 안에서 읽기 경로만 논리적으로 나누는 이 우회가 뒤에서 다룰 CQRS-lite 결정의 구체적인 모습입니다.
CQRS는 명령(Command)과 조회(Query)의 코드·모델을 나누는 패턴이고 lite는 저장소 분리 없이 코드 경로와 모델만 나눈 수준을 뜻합니다.
2. application에서 web으로 나갈 때입니다.
`FeedWebMapper.toResponse()``FeedSummary``FeedResponse`로 다시 조립합니다.
`FeedWebMapper.toResponse()``FeedSummary``FeedResponse`로 다시 조립합니다.
두 매핑을 모두 어댑터가 소유하므로 `GetFeedUseCase``FeedQueryPort`는 웹 응답이나 JPA 엔티티의
모양을 모릅니다. `GetFeedUseCase``FeedResponse`를 직접 만들었다면 HTTP 응답 변경이 코어 변경으로 번졌을 것입니다. 반대로 도메인 재구성이 필요한 경로에서는 어댑터의 `FeedItemPersistenceMapper`가 코어 모델 변경을 따라 바뀌는 것이 의도한 결합입니다.
@@ -474,11 +479,11 @@ arawn은 외형 복제보다 높은 응집과 느슨한 결합을 강조했습
`shared-contract`를 알고 SLF4J도 사용합니다. “세 모듈이 모두 완전히 순수하다”고 약속하는 대신,
각 모듈이 알아도 되는 타입을 클래스패스와 ArchUnit 규칙으로 제한했습니다.
| 모듈 | 맡은 결정 | 허용한 지식 | 대표 실행 경로 | 경계를 고정하는 규칙 |
| -------------------- | ----------------------------------------- | -------------------------------------------- | ---------------------------------------------------------------------- | --------------------------------------------------------------- |
| `domain-core` | 애그리게이트·값·이벤트·식별자와 불변식 | main compile은 JDK와 자체 타입뿐 | `FeedItem`이 자체 스테레오타입·JDK 타입만 사용 | `DOMAIN_IS_PURE`, `DOMAIN_HAS_NO_LOGGER` |
| `application-core` | 유스케이스 순서와 바깥 능력의 포트 | domain-core, shared-contract | `GetFeedUseCase``tx.inRead(() -> feedQuery.loadFeed(...))` 호출 | `APPLICATION_DOES_NOT_DEPEND_ON_ADAPTERS_OR_TRANSPORT` 외 |
| `shared-contract` | 응답·오류·추적·메트릭 같은 운영 계약 | main 외부 의존 0, 허용한 운영 패키지 prefix | `Envelope(success, data, error, meta)``ApiErrorCode` | `SHARED_CONTRACT_CONTAINS_ONLY_OPERATIONAL_CONTRACT_PACKAGES` |
| 모듈 | 맡은 결정 | 허용한 지식 | 대표 실행 경로 | 경계를 고정하는 규칙 |
| -------------------- | ----------------------------------------- | ------------------------------------------- | ---------------------------------------------------------------------- | --------------------------------------------------------------- |
| `domain-core` | 애그리게이트·값·이벤트·식별자와 불변식 | main compile은 JDK와 자체 타입뿐 | `FeedItem`이 자체 스테레오타입·JDK 타입만 사용 | `DOMAIN_IS_PURE`, `DOMAIN_HAS_NO_LOGGER` |
| `application-core` | 유스케이스 순서와 바깥 능력의 포트 | domain-core, shared-contract | `GetFeedUseCase``tx.inRead(() -> feedQuery.loadFeed(...))` 호출 | `APPLICATION_DOES_NOT_DEPEND_ON_ADAPTERS_OR_TRANSPORT` 외 |
| `shared-contract` | 응답·오류·추적·메트릭 같은 운영 계약 | main 외부 의존 0, 허용한 운영 패키지 prefix | `Envelope(success, data, error, meta)``ApiErrorCode` | `SHARED_CONTRACT_CONTAINS_ONLY_OPERATIONAL_CONTRACT_PACKAGES` |
`domain-core`의 빈 의존 블록은 Spring·JPA·Servlet·Hibernate 같은 외부 프레임워크 타입이 들어올 직접 의존 통로를 없앱니다. JDK 자체의 파일·네트워크·SQL API까지 자동으로 금지한다는 뜻은 아닙니다.
`DOMAIN_IS_PURE`는 금지 패키지 의존을 막고 `DOMAIN_HAS_NO_LOGGER`는 로깅 프레임워크까지 차단합니다.
@@ -501,8 +506,8 @@ REST·gRPC·GraphQL·WebSocket 네 인바운드 모듈은 같은 깊이로 구
두었습니다. 구현 범위는 달라도 전송 기술을 코어 밖에 두고 아웃바운드 구현을 직접 고르지
않는 규칙은 같게 적용했습니다.
| 모듈 | 현재 제공하는 기능 | 결정적인 차이 |
| ----------------------------- | -------------------------------------- | ------------------------------------------------------------------------------------------- |
| 모듈 | 현재 제공하는 기능 | 결정적인 차이 |
| ----------------------------- | -------------------------------------- | --------------------------------------------------------------------------------------------- |
| `adapter:inbound:web` | `/feed` REST·health·공유 웹 인프라 | `FeedController``GetFeedUseCase`를 호출하고 `FeedWebMapper`로 응답 DTO를 만듭니다 |
| `adapter:inbound:grpc` | health·reflection | 피처 proto 없이 Netty 서버를 직접 수명주기 관리합니다 |
| `adapter:inbound:graphql` | 최소 헬스 스키마 | 피처 스키마·리졸버 추가는 소비 프로젝트의 확장 작업입니다 |
@@ -525,12 +530,12 @@ REST·gRPC·GraphQL·WebSocket 네 인바운드 모듈은 같은 깊이로 구
달라질 수 있어 “바로 실행할 기준 구현”과 “소비 프로젝트가 채울 확장점”을 구분했습니다. 구현 깊이는
달라도 인바운드나 다른 아웃바운드 구현을 직접 선택하지 않는 규칙은 같게 적용했습니다.
| 묶음 | 모듈 | 구현된 능력 | 도입 시 확인할 예외 |
| --------------- | ------------------------------------------------- | ------------------------------------------------------- | ------------------------------------------------------------------------------------- |
| 영속성 | `persistence-jpa`, `persistence-mongo` | RDBMS 구현과 opt-in NoSQL 배선 | 멱등성·outbox·분산 락은 JPA에 구현돼 있습니다. Mongo는 드라이버·리포지토리 스캔 배선만 있고 `document``repository`는 템플릿을 가져다 쓰는 프로젝트에서 추가합니다. |
| SDK 없는 확장점 | `cache-redis`, `messaging`, `notification` | 실제 Redis·Kafka·Slack 클라이언트를 연결할 인터페이스 | 모듈이 존재해도 벤더 연결이 완성된 것은 아닙니다 |
| 외부 시스템 | `objectstorage`, `httpclient`, `fileserver` | AWS SDK S3/MinIO, 회복성 HTTP, 순수 JDK 파일 출력 | 클라이언트 유무와 빈 활성화 조건이 서로 다릅니다 |
| 기반 구현 | `identifier`, `support` | UUIDv7 식별자와 공통 상관관계 로깅 | `support`만 형제들이 공유할 수 있습니다 |
| 묶음 | 모듈 | 구현된 능력 | 도입 시 확인할 예외 |
| --------------- | ------------------------------------------------- | ------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 영속성 | `persistence-jpa`, `persistence-mongo` | RDBMS 구현과 opt-in NoSQL 배선 | 멱등성·outbox·분산 락은 JPA에 구현돼 있습니다. Mongo는 드라이버·리포지토리 스캔 배선만 있고`document``repository`는 템플릿을 가져다 쓰는 프로젝트에서 추가합니다. |
| SDK 없는 확장점 | `cache-redis`, `messaging`, `notification` | 실제 Redis·Kafka·Slack 클라이언트를 연결할 인터페이스 | 모듈이 존재해도 벤더 연결이 완성된 것은 아닙니다 |
| 외부 시스템 | `objectstorage`, `httpclient`, `fileserver` | AWS SDK S3/MinIO, 회복성 HTTP, 순수 JDK 파일 출력 | 클라이언트 유무와 빈 활성화 조건이 서로 다릅니다 |
| 기반 구현 | `identifier`, `support` | UUIDv7 식별자와 공통 상관관계 로깅 | `support`만 형제들이 공유할 수 있습니다 |
영속성의 기준 구현은 `persistence-jpa`입니다. Feed 엔티티·리포지토리·매퍼와, 나중에 확인할
트랜잭션·락·멱등성·outbox 구현이 이 모듈에 놓입니다. PostgreSQL 드라이버는 `runtimeOnly`
@@ -581,8 +586,11 @@ Mongo 자동설정 세 개를 꺼 클래스패스를 무력화하고, 모듈이
아래 그림은 `app-bootstrap`의 main 프로젝트 의존에 포함된 어댑터 열한 개와 현재 main 의존 목록에
없는 참조 어댑터 세 개를 비교합니다. 실행 시 활성 빈 전체를 측정한 그림은 아닙니다.
<!-- techviz:begin id=production-vs-optin context-sha256=81fb5cb8cd16eaae6916a0d0f2b3cddfabc39e58a87559466b52f922ca95a95b -->
<!-- techviz:generate id=production-vs-optin -->
![왼쪽의 app-bootstrap main 의존 포함 어댑터 11개와 오른쪽의 main 의존 목록 밖 grpc·graphql·websocket 세 개를 비교한 그림. 클래스패스 구성 비교이며 활성 빈 수를 뜻하지 않는다.](../assets/production-vs-optin.svg)
<details>
@@ -593,6 +601,7 @@ Mongo 자동설정 세 개를 꺼 클래스패스를 무력화하고, 모듈이
</details>
[Editable source](../assets/production-vs-optin.drawio) · [Grounded VizSpec](.techviz/production-vs-optin/spec.json)
<!-- techviz:end id=production-vs-optin -->
```java
@@ -606,6 +615,7 @@ public CacheBackend redisCacheBackend(RedisClient redisClient) {
return new RedisCacheStore(redisClient);
}
```
`@ConditionalOnProperty`가 외부 백엔드 빈 활성화를 한 번 더 결정합니다.
`app-bootstrap`이 의존하는 모듈도 모두 실행되지는 않습니다. Redis나 Kafka 같은 외부 백엔드는 런타임
@@ -861,12 +871,12 @@ Bean Validation도 범위 거부도 없습니다. 두 파라미터는
계층 규율을 고정하는 규칙과 대표 테스트는 다음과 같습니다.
| 검사 | 고정하는 경계 |
| ----------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `VALIDATION_CONSTRAINTS_STAY_AT_WEB_BOUNDARY` | 도메인·애플리케이션 패키지의`jakarta.validation..` 의존 거부 |
| 검사 | 고정하는 경계 |
| ----------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `VALIDATION_CONSTRAINTS_STAY_AT_WEB_BOUNDARY` | 도메인·애플리케이션 패키지의`jakarta.validation..` 의존 거부 |
| `VALID_CASCADE_DEPTH_AT_MOST_THREE` | `@Valid` 캐스케이드의 직접 raw 필드 사슬을 3단계로 제한하는 근사 가드 — 컨테이너 제네릭 원소와 수렴 그래프의 최장 경로는 정확히 추적하지 못할 수 있습니다 |
| `PosterControllerWireTest` | 빈 제목은 유스케이스 전에 400`VALIDATION_FAILED`, 이미지 없는 발행은 400 `POSTER_IMAGE_REQUIRED` |
| `PosterTest` | null/blank 제목이 NPE가 아닌`PosterInvariantException(TITLE_BLANK)`로 실패 |
| `PosterControllerWireTest` | 빈 제목은 유스케이스 전에 400`VALIDATION_FAILED`, 이미지 없는 발행은 400 `POSTER_IMAGE_REQUIRED` |
| `PosterTest` | null/blank 제목이 NPE가 아닌`PosterInvariantException(TITLE_BLANK)`로 실패 |
### 예외·오류 응답 — 두 단계 처리 사슬, 하나의 Envelope
@@ -1543,8 +1553,8 @@ Tudum 사례는 CQRS를 버린 사례가 아닙니다. Kafka에서 Raw Hollow로
템플릿에서는 경계를 반복 검사할 수 있지만, 1회성 서비스에서는 같은 장치가 유지비만 늘릴 수 있었습니다.
그래서 아래 표에는 선택의 장점만 적지 않고 반대편이 더 나은 조건도 함께 남겼습니다.
| 결정 | 선택 | 선택으로 얻는 것 | 반대편이 나은 조건 |
| ---------------- | ----------------------------------------------------- | ------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------- |
| 결정 | 선택 | 선택으로 얻는 것 | 반대편이 나은 조건 |
| ---------------- | ----------------------------------------------------- | --------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------- |
| ① 패키지 배치 | 계층 소유 코어·어댑터와 수직 샘플을 함께 쓰는 hybrid | 프로덕션 경계는 계층별로 통제하고, 샘플은 한 기능의 종단 구성을 보여 줍니다. | 한 가지 축만으로 충분한 작은 서비스 |
| ② 트랜잭션 경계 | `@Transactional` 대신 코어 소유 포트 | Spring TX를 애플리케이션 클래스패스에서 빼고 트랜잭션 의도를 테스트 가능한 계약으로 만듭니다. | 단일 DB를 쓰며 간접 호출 비용이 더 큰 작은 팀 |
| ③ 모듈화 | 멀티모듈 + ArchUnit | 금지된 타입은 컴파일에서, 허용 범위 안의 패키지 위반은 테스트에서 잡습니다. | 수명이 짧아 모듈·정책 유지비를 회수하기 어려운 서비스 |
+125 -86
View File
@@ -42,7 +42,9 @@
2. **애플리케이션 요청 구간:** 브라우저 입력, 중간 계층의 credential 변환, 보호 자원의 검증, 최종 응답
<!-- techviz:begin id=login-api-phase-split context-sha256=df4d1a604c74e756672b5b40510abfedb8c67b39af280a5f51985ea9972f5371 -->
<!-- techviz:generate id=login-api-phase-split -->
![로그인 구간과 애플리케이션 요청 구간을 나누어 AP2 mediator·브라우저, AP3 BFF, AP4 oauth2-proxy·Nginx의 책임 배치를 비교한 다이어그램.](assets/login-api-phase-split/login-api-phase-split.svg)
<details>
@@ -53,23 +55,24 @@
</details>
[Editable source](assets/login-api-phase-split/login-api-phase-split.drawio) · [Grounded VizSpec](.techviz/login-api-phase-split/spec.json)
<!-- techviz:end id=login-api-phase-split -->
### 같은 사용자를 나타내도 데이터의 의미는 다르다
네 예제의 응답에는 모두 `regular-user`가 있었습니다. 처음에는 같은 사용자 이름이니 같은 인증 정보라고 묶어도 될 것처럼 보였습니다. 그런데 값이 들어오는 곳을 확인해 보니 어떤 때는 JWT 안의 claim이었고 어떤 때는 Nginx가 만든 header였습니다. Claim은 token 안에 들어 있는 사용자 정보 항목입니다. 둘을 모두 인증 정보라고 쓰면 JWT를 검증한 것인지, Nginx가 만든 header를 확인한 것인지 구분할 수 없었습니다.
| 데이터 | 만든 주체 | 주된 소비자 | 의미 |
|---|---|---|---|
| authorization code | Keycloak | OAuth client | 짧게 사용되는 code 교환 입력 |
| PKCE verifier | AP1 SPA, AP3 BFF, AP4 oauth2-proxy | Keycloak token endpoint | authorization request를 시작한 client와 code 교환 주체를 연결 |
| access token | Keycloak | Resource Server | API 요청을 인증·인가하는 Bearer credential |
| refresh token | Keycloak | AP1 SPA, AP2 mediator, AP3 BFF 등 해당 소유자 | 새 access token을 얻는 장기 credential |
| server session 식별 cookie | mediator 또는 BFF | 같은 server-side login state의 소유자 | 브라우저 요청을 HttpSession 인증 상태에 연결 |
| proxy session cookie | oauth2-proxy | oauth2-proxy의 auth endpoint | AP4의 minimal client-side session 상태를 다음 auth subrequest에 제시 |
| CSRF token | AP3 BFF | AP3 BFF | cookie가 자동 첨부되는 상태 변경 요청의 의도 확인 |
| identity header | oauth2-proxy 결과를 받은 Nginx | AP4 upstream | edge가 확인한 사용자 identity의 투영 |
| internal auth token | AP4 배포 설정 | AP4 upstream | 허용된 edge를 거쳤다는 추가 신뢰 신호 |
| 데이터 | 만든 주체 | 주된 소비자 | 의미 |
| -------------------------- | ---------------------------------- | --------------------------------------------- | -------------------------------------------------------------------- |
| authorization code | Keycloak | OAuth client | 짧게 사용되는 code 교환 입력 |
| PKCE verifier | AP1 SPA, AP3 BFF, AP4 oauth2-proxy | Keycloak token endpoint | authorization request를 시작한 client와 code 교환 주체를 연결 |
| access token | Keycloak | Resource Server | API 요청을 인증·인가하는 Bearer credential |
| refresh token | Keycloak | AP1 SPA, AP2 mediator, AP3 BFF 등 해당 소유자 | 새 access token을 얻는 장기 credential |
| server session 식별 cookie | mediator 또는 BFF | 같은 server-side login state의 소유자 | 브라우저 요청을 HttpSession 인증 상태에 연결 |
| proxy session cookie | oauth2-proxy | oauth2-proxy의 auth endpoint | AP4의 minimal client-side session 상태를 다음 auth subrequest에 제시 |
| CSRF token | AP3 BFF | AP3 BFF | cookie가 자동 첨부되는 상태 변경 요청의 의도 확인 |
| identity header | oauth2-proxy 결과를 받은 Nginx | AP4 upstream | edge가 확인한 사용자 identity의 투영 |
| internal auth token | AP4 배포 설정 | AP4 upstream | 허용된 edge를 거쳤다는 추가 신뢰 신호 |
예를 들어 access token의 `preferred_username`과 AP4의 `X-Auth-Request-User`에는 모두 `regular-user`가 들어갈 수 있었습니다. 화면에서 보이는 이름은 같지만 실제 검증 방식은 다릅니다. Resource Server는 JWT의 서명과 issuer, audience를 확인했습니다. AP4 upstream은 요청이 신뢰할 수 있는 edge를 거쳤는지, 내부 인증값도 맞는지 확인했습니다.
@@ -80,7 +83,9 @@ AP3와 AP4를 처음 보았을 때는 JavaScript가 OAuth token을 받지 않으
그래서 이 글에서 “브라우저에 없다”는 표현은 애플리케이션이 사용하는 OAuth token에만 쓰기로 했습니다. IdP의 SSO 상태까지 없다는 뜻은 아닙니다. 반대로 AP1이 Web Storage에 token을 쓰지 않는다고 JavaScript에서 token이 사라지는 것도 아니었습니다. Access·refresh·ID token은 실행 중 memory에 있었습니다. 악성 script는 같은 화면에서 fetch를 가로채거나 사용자를 대신해 API를 부를 수 있었습니다. Memory-only로 줄어드는 것은 새로고침 뒤에도 남는 복사본이지 실행 중 XSS의 권한은 아니었습니다.
<!-- techviz:begin id=credential-custody-map context-sha256=df4d1a604c74e756672b5b40510abfedb8c67b39af280a5f51985ea9972f5371 -->
<!-- techviz:generate id=credential-custody-map -->
![AP1부터 AP4까지 OAuth credential 소유자, 브라우저 credential, 보관 모델과 현재 입증된 운영 범위를 같은 네 축으로 정렬한 비교 다이어그램.](assets/credential-custody-map/credential-custody-map.svg)
<details>
@@ -91,6 +96,7 @@ AP3와 AP4를 처음 보았을 때는 JavaScript가 OAuth token을 받지 않으
</details>
[Editable source](assets/credential-custody-map/credential-custody-map.drawio) · [Grounded VizSpec](.techviz/credential-custody-map/spec.json)
<!-- techviz:end id=credential-custody-map -->
### 현재 구현은 운영 참조 아키텍처가 아니라 관찰 가능한 학습 환경이다
@@ -115,32 +121,34 @@ AP3와 AP4를 처음 보았을 때는 JavaScript가 OAuth token을 받지 않으
처음에는 네 패턴을 설명하는 용어부터 비교했습니다. 그런데 용어만 나란히 놓으니 실제로 누가 code를 바꾸고 API를 부르는지 잘 보이지 않았습니다. 그래서 로그인과 API 요청을 맡는 구성요소를 같은 표에 놓았습니다.
| 비교 축 | AP1 · SPA direct | AP2 · token mediator | AP3 · BFF | AP4 · edge forward-auth |
|---|---|---|---|---|
| OAuth client | 브라우저의 public SPA | Spring mediator | Spring BFF | oauth2-proxy |
| client 종류 | public | confidential | confidential | confidential |
| code 교환 주체 | 브라우저 | mediator | BFF | oauth2-proxy |
| PKCE | S256 | 현재 client 등록·흐름에서 명시적 AP1/AP3/AP4 가드레일과 동일하게 주장하지 않음 | S256 | S256 |
| refresh token 소유자 | 브라우저 JavaScript memory | mediator의 authorized client | BFF의 authorized client | 지속 보관 근거 없음: oauth2-proxy가 code/token 교환은 하지만 minimal cookie에는 access·refresh·ID token을 저장하지 않고 refresh lifecycle도 검증되지 않음 |
| access token이 JavaScript 응답에 포함되는가 | 포함 | 포함 | 미포함 | 미포함 |
| API를 호출하는 주체 | 브라우저 | 브라우저 | BFF | Nginx가 upstream 요청을 연결 |
| 보호 자원이 받는 credential | Bearer JWT | Bearer JWT | BFF가 붙인 Bearer JWT | user·email header + internal token |
| 애플리케이션 측 로그인 상태 | server session 없음 | `AP2_SESSION` + authorized client | `AP3_SESSION` + authorized client | minimal client-side `AP4_SESSION`을 사용하는 proxy 경계 |
| 새로 필요한 핵심 방어 | browser token 수명주기·XSS 피해 축소 | access 응답 제한·CORS·server state 운영 | CSRF·session scale-out·token-at-rest | network isolation·header overwrite·service identity |
| 비교 축 | AP1 · SPA direct | AP2 · token mediator | AP3 · BFF | AP4 · edge forward-auth |
| ------------------------------------------- | ------------------------------------- | ------------------------------------------------------------------------------- | -------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- |
| OAuth client | 브라우저의 public SPA | Spring mediator | Spring BFF | oauth2-proxy |
| client 종류 | public | confidential | confidential | confidential |
| code 교환 주체 | 브라우저 | mediator | BFF | oauth2-proxy |
| PKCE | S256 | 현재 client 등록·흐름에서 명시적 AP1/AP3/AP4 가드레일과 동일하게 주장하지 않음 | S256 | S256 |
| refresh token 소유자 | 브라우저 JavaScript memory | mediator의 authorized client | BFF의 authorized client | 지속 보관 근거 없음: oauth2-proxy가 code/token 교환은 하지만 minimal cookie에는 access·refresh·ID token을 저장하지 않고 refresh lifecycle도 검증되지 않음 |
| access token이 JavaScript 응답에 포함되는가 | 포함 | 포함 | 미포함 | 미포함 |
| API를 호출하는 주체 | 브라우저 | 브라우저 | BFF | Nginx가 upstream 요청을 연결 |
| 보호 자원이 받는 credential | Bearer JWT | Bearer JWT | BFF가 붙인 Bearer JWT | user·email header + internal token |
| 애플리케이션 측 로그인 상태 | server session 없음 | `AP2_SESSION` + authorized client | `AP3_SESSION` + authorized client | minimal client-side`AP4_SESSION`을 사용하는 proxy 경계 |
| 새로 필요한 핵심 방어 | browser token 수명주기·XSS 피해 축소 | access 응답 제한·CORS·server state 운영 | CSRF·session scale-out·token-at-rest | network isolation·header overwrite·service identity |
AP2의 PKCE 칸은 다른 패턴과 똑같이 채우지 않았습니다. “Authorization Code를 쓴다”와 “현재 구현이 PKCE S256까지 같은 방식으로 고정했다”는 서로 다른 주장이었기 때문입니다. 저는 코드와 설정에서 확인한 범위보다 넓혀 네 패턴을 억지로 대칭적으로 만들지 않았습니다.
그다음에는 로그인 뒤 요청 한 번에서 실제로 움직이는 데이터를 적었습니다.
| 패턴 | 브라우저가 보내는 입력 | 중간 계층이 조회·생성하는 데이터 | 보호 자원의 실제 입력 | 브라우저가 받는 출력 |
|---|---|---|---|---|
| AP1 | `Authorization: Bearer <access_token>` | 없음 | 동일 Bearer JWT | `/api/me` JSON |
| AP2 | 먼저 `AP2_SESSION`, 다음에 Bearer access token | mediator가 authorized client에서 access token을 읽어 JSON으로 반환 | 브라우저가 다시 만든 Bearer JWT | token JSON, 이어서 `/api/me` JSON |
| AP3 | `AP3_SESSION`; POST에는 `X-XSRF-TOKEN` 추가 | BFF가 authorized client에서 access token을 읽고 downstream Bearer header 생성 | BFF가 보낸 Bearer JWT | BFF가 중계한 JSON |
| AP4 | `AP4_SESSION` | Nginx auth subrequest, oauth2-proxy의 user·email 결과, 배포 secret | 정제된 identity header + internal token | `/edge/me`가 만든 identity JSON |
| 패턴 | 브라우저가 보내는 입력 | 중간 계층이 조회·생성하는 데이터 | 보호 자원의 실제 입력 | 브라우저가 받는 출력 |
| ---- | ----------------------------------------------- | ----------------------------------------------------------------------------- | --------------------------------------- | ---------------------------------- |
| AP1 | `Authorization: Bearer <access_token>` | 없음 | 동일 Bearer JWT | `/api/me` JSON |
| AP2 | 먼저`AP2_SESSION`, 다음에 Bearer access token | mediator가 authorized client에서 access token을 읽어 JSON으로 반환 | 브라우저가 다시 만든 Bearer JWT | token JSON, 이어서`/api/me` JSON |
| AP3 | `AP3_SESSION`; POST에는 `X-XSRF-TOKEN` 추가 | BFF가 authorized client에서 access token을 읽고 downstream Bearer header 생성 | BFF가 보낸 Bearer JWT | BFF가 중계한 JSON |
| AP4 | `AP4_SESSION` | Nginx auth subrequest, oauth2-proxy의 user·email 결과, 배포 secret | 정제된 identity header + internal token | `/edge/me`가 만든 identity JSON |
<!-- techviz:begin id=four-pattern-request-boundaries context-sha256=df4d1a604c74e756672b5b40510abfedb8c67b39af280a5f51985ea9972f5371 -->
<!-- techviz:generate id=four-pattern-request-boundaries -->
![AP1, AP2, AP3, AP4의 브라우저 입력, 중간 변환, 보호 자원 credential과 브라우저 출력을 같은 네 축으로 비교한 다이어그램.](assets/four-pattern-request-boundaries/four-pattern-request-boundaries.svg)
<details>
@@ -151,6 +159,7 @@ AP2의 PKCE 칸은 다른 패턴과 똑같이 채우지 않았습니다. “Auth
</details>
[Editable source](assets/four-pattern-request-boundaries/four-pattern-request-boundaries.drawio) · [Grounded VizSpec](.techviz/four-pattern-request-boundaries/spec.json)
<!-- techviz:end id=four-pattern-request-boundaries -->
### AP1에서 막히는 지점: protocol 투명성과 browser credential
@@ -194,7 +203,9 @@ Refresh token만 mediator로 옮기는 AP2나 모든 token을 BFF에 맡기는 A
대신 token을 Local Storage나 Session Storage에 복사하지 않았습니다. Access token은 300초만 유효하게 두고 refresh token rotation과 reuse 0을 사용했습니다. Resource Server는 issuer나 audience가 다르면 401을 반환하게 했습니다. 그래도 실행 중인 XSS는 같은 origin의 사용자 권한을 쓸 수 있었고, 이미 발급된 access JWT는 만료될 때까지 유효했습니다.
<!-- techviz:begin id=ap1-direct-architecture context-sha256=df4d1a604c74e756672b5b40510abfedb8c67b39af280a5f51985ea9972f5371 -->
<!-- techviz:generate id=ap1-direct-architecture -->
![SPA, Keycloak, 브라우저 JavaScript memory, Resource Server가 왼쪽에서 오른쪽으로 연결된 AP1 직접 인증 아키텍처.](assets/ap1-direct-architecture/ap1-direct-architecture.svg)
<details>
@@ -205,6 +216,7 @@ Refresh token만 mediator로 옮기는 AP2나 모든 token을 BFF에 맡기는 A
</details>
[Editable source](assets/ap1-direct-architecture/ap1-direct-architecture.drawio) · [Grounded VizSpec](.techviz/ap1-direct-architecture/spec.json)
<!-- techviz:end id=ap1-direct-architecture -->
### AP2: refresh credential은 서버에, 직접 API 호출은 브라우저에 둔다
@@ -218,7 +230,9 @@ Mediator는 Spring `oauth2Login`으로 code를 교환한 뒤 access·refresh tok
AP2에서는 노출 범위를 줄이려고 CORS origin과 method를 좁히고 refresh token을 응답에서 뺐습니다. Session cookie에는 HttpOnly와 SameSite를 설정했고 Resource Server는 audience를 검증하게 했습니다. 다만 access token 전달 횟수 제한, durable store, logout, 만료 뒤 실제 refresh 동작은 아직 검증하지 못했습니다.
<!-- techviz:begin id=ap2-mediator-architecture context-sha256=df4d1a604c74e756672b5b40510abfedb8c67b39af280a5f51985ea9972f5371 -->
<!-- techviz:generate id=ap2-mediator-architecture -->
![브라우저가 Spring mediator에서 access token만 받아 Resource Server를 직접 호출하고 refresh token은 authorized-client store에 남기는 AP2 split-custody 아키텍처.](assets/ap2-mediator-architecture/ap2-mediator-architecture.svg)
<details>
@@ -229,6 +243,7 @@ AP2에서는 노출 범위를 줄이려고 CORS origin과 method를 좁히고 re
</details>
[Editable source](assets/ap2-mediator-architecture/ap2-mediator-architecture.drawio) · [Grounded VizSpec](.techviz/ap2-mediator-architecture/spec.json)
<!-- techviz:end id=ap2-mediator-architecture -->
### AP3: browser token 비노출과 application-owned session을 맞바꾼다
@@ -242,7 +257,9 @@ AP2에서는 refresh token을 server로 옮겼지만 access token은 여전히
이 비교를 통해 stateless Resource Server와 OAuth 흐름을 직접 보는 일이 더 중요하면 AP1이 맞다고 판단했습니다. 브라우저의 직접 API 호출을 남겨야 한다면 AP2를 선택할 수 있었습니다.
<!-- techviz:begin id=ap3-bff-architecture context-sha256=df4d1a604c74e756672b5b40510abfedb8c67b39af280a5f51985ea9972f5371 -->
<!-- techviz:generate id=ap3-bff-architecture -->
![Browser session zone과 server-side BFF zone 사이에서 AP3_SESSION이 downstream Bearer 요청으로 바뀌는 BFF 아키텍처.](assets/ap3-bff-architecture/ap3-bff-architecture.svg)
<details>
@@ -253,6 +270,7 @@ AP2에서는 refresh token을 server로 옮겼지만 access token은 여전히
</details>
[Editable source](assets/ap3-bff-architecture/ap3-bff-architecture.drawio) · [Grounded VizSpec](.techviz/ap3-bff-architecture/spec.json)
<!-- techviz:end id=ap3-bff-architecture -->
### AP4: OAuth를 모르는 upstream 앞에서 신뢰 경로를 만든다
@@ -266,7 +284,9 @@ AP2에서는 refresh token을 server로 옮겼지만 access token은 여전히
대신 proxy session과 identity header를 믿을 조건이 핵심 인프라가 되었습니다. 현재 예제에서는 App과 oauth2-proxy의 host port를 닫고, internal auth location을 정확히 일치시켰습니다. 브라우저가 보낸 동명 header는 Nginx 값으로 덮어썼고, 단일 trusted proxy IP와 upstream internal-token 검증도 함께 두었습니다. 운영에서는 shared secret을 secret manager에서 주입하고 교체하거나 mTLS·workload identity로 더 강하게 묶어야 합니다. 현재는 user와 email만 전달하므로 role이나 다른 claim이 필요하면 allowlist와 직렬화 규칙, 크기 제한, upstream 검증 계약을 새로 정해야 합니다.
<!-- techviz:begin id=ap4-edge-trust-architecture context-sha256=df4d1a604c74e756672b5b40510abfedb8c67b39af280a5f51985ea9972f5371 -->
<!-- techviz:generate id=ap4-edge-trust-architecture -->
![외부 브라우저 zone과 Nginx, oauth2-proxy, Spring upstream이 있는 AP4 deployment path를 나눈 edge trust 아키텍처.](assets/ap4-edge-trust-architecture/ap4-edge-trust-architecture.svg)
<details>
@@ -277,6 +297,7 @@ AP2에서는 refresh token을 server로 옮겼지만 access token은 여전히
</details>
[Editable source](assets/ap4-edge-trust-architecture/ap4-edge-trust-architecture.drawio) · [Grounded VizSpec](.techviz/ap4-edge-trust-architecture/spec.json)
<!-- techviz:end id=ap4-edge-trust-architecture -->
## 선택이 코드와 흐름에 반영되는 방식
@@ -391,12 +412,12 @@ Serialized user는 `InMemoryWebStorage`에 있고 module 변수 `currentUser`도
Callback 처리가 끝나면 브라우저에는 다음 값이 남습니다.
| 위치 | 남는 데이터 | reload 뒤 |
|---|---|---|
| JavaScript memory | `User`, access·refresh·ID token, expiry, profile | 사라짐 |
| Session Storage | redirect transaction용 state와 verifier | callback 완료 뒤 제거되는 것이 계약 |
| Local Storage | 애플리케이션이 쓰지 않음 | 해당 없음 |
| Keycloak origin cookie | IdP SSO 상태가 존재할 수 있음 | AP1 app memory와 별개 |
| 위치 | 남는 데이터 | reload 뒤 |
| ---------------------- | ---------------------------------------------------- | ----------------------------------- |
| JavaScript memory | `User`, access·refresh·ID token, expiry, profile | 사라짐 |
| Session Storage | redirect transaction용 state와 verifier | callback 완료 뒤 제거되는 것이 계약 |
| Local Storage | 애플리케이션이 쓰지 않음 | 해당 없음 |
| Keycloak origin cookie | IdP SSO 상태가 존재할 수 있음 | AP1 app memory와 별개 |
Memory user가 사라진다고 Keycloak SSO까지 로그아웃되는 것은 아닙니다. Reload 뒤 애플리케이션 token 상태를 포기했다는 말과 IdP session을 제거했다는 말은 구분해야 합니다.
@@ -477,14 +498,14 @@ SPA는 이 JSON을 다시 화면용 object로 조립합니다.
**4단계 — 실패와 수명주기를 같은 흐름에서 읽는다**
| 입력 또는 사건 | 최초 거부 지점 | 관측 가능한 결과 | 보장하지 않는 세부 |
|---|---|---|---|
| Bearer 없음 | Spring Security | `/api/me` 401 | exact error body |
| 잘못된 audience | custom audience validator | 401 | UI용 JSON error 모양 |
| 잘못된 issuer | issuer validator | 401 | UI용 JSON error 모양 |
| regular user가 `/api/admin` 호출 | authority decision | 403 | 공통 error envelope |
| callback query의 `error` | oidc-client-ts callback, app catch | unauthenticated UI와 error message | exact provider error schema |
| app memory user 없음 또는 expired | `callProtectedApi()` local guard | network call 없이 login-required JSON | 자동 재로그인 |
| 입력 또는 사건 | 최초 거부 지점 | 관측 가능한 결과 | 보장하지 않는 세부 |
| --------------------------------- | ---------------------------------- | ------------------------------------- | --------------------------- |
| Bearer 없음 | Spring Security | `/api/me` 401 | exact error body |
| 잘못된 audience | custom audience validator | 401 | UI용 JSON error 모양 |
| 잘못된 issuer | issuer validator | 401 | UI용 JSON error 모양 |
| regular user가`/api/admin` 호출 | authority decision | 403 | 공통 error envelope |
| callback query의`error` | oidc-client-ts callback, app catch | unauthenticated UI와 error message | exact provider error schema |
| app memory user 없음 또는 expired | `callProtectedApi()` local guard | network call 없이 login-required JSON | 자동 재로그인 |
SPA는 non-2xx 응답에서도 `response.ok`을 확인하기 전에 `response.json()`을 시도합니다. Spring의 401 body가 비어 있거나 JSON이 아니면 의도한 “보호 API가 401을 반환했다”는 message보다 JSON parse error가 먼저 보일 수 있습니다. Negative E2E는 UI button 경로가 아니라 별도 Node fetch로 status만 확인하므로 이 failure UX는 현재 고정되어 있지 않습니다.
@@ -493,7 +514,9 @@ Refresh와 logout도 서로 다른 효과를 가집니다. Realm은 access token
`automaticSilentRenew=true`도 구성되어 있지만, browser가 실제 expiry를 기다려 silent renewal을 완료하고 새 `User`를 memory에 저장하는 경로는 acceptance test가 아닙니다. Manual refresh helper로 검증하는 것과 app runtime의 automatic renewal을 같은 결과로 간주하지 않습니다.
<!-- techviz:begin id=ap1-browser-bearer-flow context-sha256=df4d1a604c74e756672b5b40510abfedb8c67b39af280a5f51985ea9972f5371 -->
<!-- techviz:generate id=ap1-browser-bearer-flow -->
![브라우저 SPA, Keycloak, Resource Server 사이에서 authorization request, callback, token 교환, Bearer API 호출과 JSON 응답이 이어지는 순서도.](assets/ap1-browser-bearer-flow/ap1-browser-bearer-flow.svg)
<details>
@@ -504,6 +527,7 @@ Refresh와 logout도 서로 다른 효과를 가집니다. Realm은 access token
</details>
[Editable source](assets/ap1-browser-bearer-flow/ap1-browser-bearer-flow.drawio) · [Grounded VizSpec](.techviz/ap1-browser-bearer-flow/spec.json)
<!-- techviz:end id=ap1-browser-bearer-flow -->
### AP2 완주: server의 authorized client가 browser Bearer가 되기까지
@@ -744,20 +768,22 @@ authorization code
**6단계 — AP2의 실패와 공백을 endpoint별로 구분한다**
| 상황 | 현재 경계의 결과 | 확인된 것 | 아직 고정되지 않은 것 |
|---|---|---|---|
| 미인증 `/token/boundary` 또는 `/token/access` | controller 이전 login entry point | UI는 redirect와 401 양쪽을 처리 | exact redirect/401 contract |
| 인증됨, boundary 조회에 authorized client 없음 | boolean false를 담은 200 | controller branch | token 복구 UX |
| 인증됨, access endpoint에 client/token 없음 | 401 | controller status와 reason | exact JSON error body |
| anonymous `/api/me` | 401 | backend test contract | error envelope |
| foreign audience | `invalid_token` validation result | validator test contract | AP2 browser E2E의 401 |
| access token expiry | manager가 refresh 가능한 provider를 가짐 | configuration | real refresh success·failure |
| mediator restart 또는 replica 이동 | process-local state에 영향 | 구현상 저장소 경계 | recovery/failover contract |
| 상황 | 현재 경계의 결과 | 확인된 것 | 아직 고정되지 않은 것 |
| ------------------------------------------------ | ---------------------------------------- | ------------------------------- | ----------------------------- |
| 미인증`/token/boundary` 또는 `/token/access` | controller 이전 login entry point | UI는 redirect와 401 양쪽을 처리 | exact redirect/401 contract |
| 인증됨, boundary 조회에 authorized client 없음 | boolean false를 담은 200 | controller branch | token 복구 UX |
| 인증됨, access endpoint에 client/token 없음 | 401 | controller status와 reason | exact JSON error body |
| anonymous`/api/me` | 401 | backend test contract | error envelope |
| foreign audience | `invalid_token` validation result | validator test contract | AP2 browser E2E의 401 |
| access token expiry | manager가 refresh 가능한 provider를 가짐 | configuration | real refresh success·failure |
| mediator restart 또는 replica 이동 | process-local state에 영향 | 구현상 저장소 경계 | recovery/failover contract |
AP2는 refresh credential을 browser 밖으로 옮깁니다. 하지만 logout 시 session과 authorized client를 함께 삭제하는 code, token-at-rest encryption, shared durable store, handoff replay rejection은 구현되어 있지 않습니다. 따라서 AP2가 refresh token을 브라우저에 보내지 않는다는 점까지만 확인했습니다. 이를 운영 환경에 바로 쓸 수 있다고 말할 수는 없습니다.
<!-- techviz:begin id=ap2-mediator-handoff-flow context-sha256=df4d1a604c74e756672b5b40510abfedb8c67b39af280a5f51985ea9972f5371 -->
<!-- techviz:generate id=ap2-mediator-handoff-flow -->
![브라우저, Spring mediator, authorized-client store, Resource Server 사이에서 AP2_SESSION 요청, access-only 응답, 브라우저 Bearer 호출과 JSON 응답이 이어지는 순서도.](assets/ap2-mediator-handoff-flow/ap2-mediator-handoff-flow.svg)
<details>
@@ -768,6 +794,7 @@ AP2는 refresh credential을 browser 밖으로 옮깁니다. 하지만 logout
</details>
[Editable source](assets/ap2-mediator-handoff-flow/ap2-mediator-handoff-flow.drawio) · [Grounded VizSpec](.techviz/ap2-mediator-handoff-flow/spec.json)
<!-- techviz:end id=ap2-mediator-handoff-flow -->
### AP3 완주: session cookie가 BFF의 downstream Bearer가 되기까지
@@ -984,7 +1011,9 @@ POST X-XSRF-TOKEN = same raw token
이 구분을 놓치면 “CSRF JSON에서 받은 token을 그대로 header에 복사한다”는 잘못된 구현 설명이 됩니다. 실제 SPA의 data source는 cookie입니다.
<!-- techviz:begin id=ap3-csrf-boundary context-sha256=df4d1a604c74e756672b5b40510abfedb8c67b39af280a5f51985ea9972f5371 -->
<!-- techviz:generate id=ap3-csrf-boundary -->
![BFF CSRF endpoint가 raw XSRF cookie와 masked JSON token으로 분기하고, SPA가 raw cookie만 실제 POST header 값으로 사용해 Spring CSRF filter에 제출하는 데이터 흐름.](assets/ap3-csrf-boundary/ap3-csrf-boundary.svg)
<details>
@@ -995,6 +1024,7 @@ POST X-XSRF-TOKEN = same raw token
</details>
[Editable source](assets/ap3-csrf-boundary/ap3-csrf-boundary.drawio) · [Grounded VizSpec](.techviz/ap3-csrf-boundary/spec.json)
<!-- techviz:end id=ap3-csrf-boundary -->
**5단계 — form input이 process-global preference가 되기까지**
@@ -1034,19 +1064,21 @@ Controller보다 먼저 Spring CSRF filter가 repository의 expected token과 su
**6단계 — CSRF와 SameSite 실패를 별도 방어선으로 읽는다**
| 입력 | Cookie 동작 | CSRF 동작 | 결과 |
|---|---|---|---|
| same-origin, AP3 session, CSRF header 없음 | session cookie 첨부 | filter가 token 부재 거부 | 403 |
| same-origin, matching raw cookie/header | session cookie 첨부 | token 일치 | controller 200 |
| 다른 port지만 same-site인 요청, header 없음 | session cookie가 실릴 수 있음 | token 부재 거부 | 403 |
| `127.0.0.1`에서 `localhost`로 cross-site POST | SameSite=Lax로 `AP3_SESSION`이 request header에서 제외되는지 확인 | 이 test는 이후 server 처리를 고정하지 않음 | 최종 status/body가 아니라 cookie omission이 acceptance point |
| 입력 | Cookie 동작 | CSRF 동작 | 결과 |
| ------------------------------------------------- | ------------------------------------------------------------------ | ------------------------------------------ | ------------------------------------------------------------ |
| same-origin, AP3 session, CSRF header 없음 | session cookie 첨부 | filter가 token 부재 거부 | 403 |
| same-origin, matching raw cookie/header | session cookie 첨부 | token 일치 | controller 200 |
| 다른 port지만 same-site인 요청, header 없음 | session cookie가 실릴 수 있음 | token 부재 거부 | 403 |
| `127.0.0.1`에서 `localhost`로 cross-site POST | SameSite=Lax로`AP3_SESSION`이 request header에서 제외되는지 확인 | 이 test는 이후 server 처리를 고정하지 않음 | 최종 status/body가 아니라 cookie omission이 acceptance point |
SameSite는 cookie의 cross-site 전송을 제한하는 browser 정책이고 CSRF token은 state-changing request의 의도를 server가 검증하는 application protocol입니다. Port가 달라도 site 계산상 같은 경우가 있으므로 둘은 서로 대체할 수 없습니다.
JavaScript가 OAuth token을 받지 않는다고 XSS가 무해해지는 것도 아닙니다. Same-origin 악성 script는 피해자 session으로 BFF endpoint를 호출하고 readable XSRF cookie도 읽을 수 있습니다. AP3가 줄이는 것은 access·refresh token 원문이 browser script에서 유출되어 다른 client나 직접 API 호출에 재사용되는 반경입니다. CSP, output encoding, dependency integrity와 application authorization은 여전히 별도 방어선입니다.
<!-- techviz:begin id=ap3-bff-session-flow context-sha256=df4d1a604c74e756672b5b40510abfedb8c67b39af280a5f51985ea9972f5371 -->
<!-- techviz:generate id=ap3-bff-session-flow -->
![브라우저, BFF, authorized-client store, Resource Server 사이에서 AP3_SESSION 요청, server-held token 조회, downstream Bearer 호출과 중계 JSON이 이어지는 순서도.](assets/ap3-bff-session-flow/ap3-bff-session-flow.svg)
<details>
@@ -1057,6 +1089,7 @@ JavaScript가 OAuth token을 받지 않는다고 XSS가 무해해지는 것도
</details>
[Editable source](assets/ap3-bff-session-flow/ap3-bff-session-flow.drawio) · [Grounded VizSpec](.techviz/ap3-bff-session-flow/spec.json)
<!-- techviz:end id=ap3-bff-session-flow -->
### AP4 완주: proxy session이 trusted identity JSON이 되기까지
@@ -1081,14 +1114,14 @@ auth_request /oauth2/auth;
`location = /oauth2/auth``internal`입니다. Nginx가 만드는 subrequest만 들어갈 수 있고 browser가 같은 URL을 직접 호출하면 정상 auth endpoint로 사용할 수 없습니다. Subrequest는 body를 보내지 않고 `Content-Length`를 비웁니다. 대신 원래 요청의 문맥을 header로 바꿉니다.
| Nginx가 만드는 auth input | 값의 출처 |
|---|---|
| `X-Original-URL` | scheme, host와 original request URI |
| `X-Real-IP` | client address |
| `X-Forwarded-For` | proxy chain |
| `X-Forwarded-Host` | original host |
| `X-Forwarded-Proto` | original scheme |
| `X-Forwarded-Uri` | original request URI |
| Nginx가 만드는 auth input | 값의 출처 |
| --------------------------- | --------------------------------------- |
| `X-Original-URL` | scheme, host와 original request URI |
| `X-Real-IP` | client address |
| `X-Forwarded-For` | proxy chain |
| `X-Forwarded-Host` | original host |
| `X-Forwarded-Proto` | original scheme |
| `X-Forwarded-Uri` | original request URI |
| `Cookie: AP4_SESSION=...` | browser에 cookie가 있을 때 원래 request |
미인증 상태에서 oauth2-proxy의 auth endpoint가 401을 반환하면 general `/` location은 `@oauth2_signin`으로 이동해 다음 redirect를 만듭니다.
@@ -1235,14 +1268,14 @@ AP1·AP2·AP3의 Resource Server는 JWT의 issuer와 audience를 직접 확인
**5단계 — AP4의 401, 302와 404는 경로별로 다르다**
| 외부 입력 | 인증 상태 | 최초 결정 지점 | 결과 |
|---|---|---|---|
| `GET /` | 미인증 | general location의 auth 401 error page | `/oauth2/start`로 302 |
| `GET /api/edge` | 미인증 | exact API location의 auth 401 error page | redirect 없는 401 `{"error":"authentication required"}` |
| `GET /oauth2/auth` | 무관 | `internal` exact location | 외부에서는 404 |
| `GET /` + spoofed identity headers | 정상 AP4 session | Nginx header overwrite | 실제 authenticated user로 200 |
| internal `/edge/me` + user header만 | edge token 없음 | controller | 401 trusted-edge error |
| internal `/edge/me` + wrong token | token mismatch | controller | 401 trusted-edge error |
| 외부 입력 | 인증 상태 | 최초 결정 지점 | 결과 |
| ------------------------------------ | ---------------- | ---------------------------------------- | -------------------------------------------------------- |
| `GET /` | 미인증 | general location의 auth 401 error page | `/oauth2/start`로 302 |
| `GET /api/edge` | 미인증 | exact API location의 auth 401 error page | redirect 없는 401`{"error":"authentication required"}` |
| `GET /oauth2/auth` | 무관 | `internal` exact location | 외부에서는 404 |
| `GET /` + spoofed identity headers | 정상 AP4 session | Nginx header overwrite | 실제 authenticated user로 200 |
| internal`/edge/me` + user header만 | edge token 없음 | controller | 401 trusted-edge error |
| internal`/edge/me` + wrong token | token mismatch | controller | 401 trusted-edge error |
Redirect 없는 JSON 401은 정확히 `/api/edge` 예시 path에만 구성되어 있습니다. “AP4의 모든 API path가 JSON 401을 반환한다”고 일반화하면 안 됩니다. 다른 path는 현재 general location의 login redirect 규칙을 따릅니다.
@@ -1262,7 +1295,9 @@ App과 oauth2-proxy의 host port가 닫혀 있다는 사실도 중요합니다.
AP4가 authentication gate를 중앙화했다고 application authorization까지 자동으로 완성되는 것은 아닙니다. 현재 `/edge/me`도 role decision을 하지 않습니다.
<!-- techviz:begin id=ap4-edge-forward-auth-flow context-sha256=df4d1a604c74e756672b5b40510abfedb8c67b39af280a5f51985ea9972f5371 -->
<!-- techviz:generate id=ap4-edge-forward-auth-flow -->
![브라우저, Nginx, oauth2-proxy, Spring upstream 사이에서 AP4_SESSION 검증, identity header 덮어쓰기, internal token 검증과 JSON 응답이 이어지는 순서도.](assets/ap4-edge-forward-auth-flow/ap4-edge-forward-auth-flow.svg)
<details>
@@ -1273,6 +1308,7 @@ AP4가 authentication gate를 중앙화했다고 application authorization까지
</details>
[Editable source](assets/ap4-edge-forward-auth-flow/ap4-edge-forward-auth-flow.drawio) · [Grounded VizSpec](.techviz/ap4-edge-forward-auth-flow/spec.json)
<!-- techviz:end id=ap4-edge-forward-auth-flow -->
### Google login이 들어와도 네 애플리케이션 경계는 바뀌지 않는다
@@ -1304,12 +1340,12 @@ AP1 Resource Server가 신뢰하는 issuer도 Keycloak이고, AP2·AP3가 교환
아래 표는 최신 실행 성적표가 아니라 커밋된 자동 테스트가 확인하도록 정의한 acceptance contract입니다. Pattern별 verify flow는 stack을 다시 만들기 전에 Docker volume을 삭제하므로, 보존해야 할 local realm과 database가 있는 환경에서 그대로 실행해서는 안 됩니다.
| 패턴 | 테스트가 만드는 핵심 입력 | 기대 output | 지키려는 경계 |
|---|---|---|---|
| AP1 | S256 authorization request, 실제 login, Bearer `/api/me`, 동일 정상 JWT를 expected issuer·audience가 다른 diagnostic server에 제출 | 정상 200, diagnostic server 401, runtime fetch hook에서 access token 관측, persistent Web Storage에 access token 없음 | Browser가 token owner라는 사실과 Resource Server validation |
| AP2 | Login session으로 boundary/access GET, 반환 token으로 direct API GET | server access·refresh booleans true, refresh field 없음, access JSON 세 field, `no-store`, API 200 | Refresh custody는 server, access credential은 browser |
| AP3 | Session-only `/bff/api/me`, CSRF 없는 POST, matching header POST, cross-site POST | browser token count 0, downstream JSON 200, 403/200 분리, SameSite cookie omission | BFF token custody와 cookie-authenticated state-change protection |
| AP4 | Cookie 없는 `/``/api/edge`, 정상 session, spoofed headers, external auth endpoint, direct app/proxy ports | root 302, exact API 401, 실제 user 200, auth endpoint 404, internal ports inaccessible | Edge만 trusted identity input을 만들 수 있는 path |
| 패턴 | 테스트가 만드는 핵심 입력 | 기대 output | 지키려는 경계 |
| ---- | ------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------- |
| AP1 | S256 authorization request, 실제 login, Bearer`/api/me`, 동일 정상 JWT를 expected issuer·audience가 다른 diagnostic server에 제출 | 정상 200, diagnostic server 401, runtime fetch hook에서 access token 관측, persistent Web Storage에 access token 없음 | Browser가 token owner라는 사실과 Resource Server validation |
| AP2 | Login session으로 boundary/access GET, 반환 token으로 direct API GET | server access·refresh booleans true, refresh field 없음, access JSON 세 field,`no-store`, API 200 | Refresh custody는 server, access credential은 browser |
| AP3 | Session-only`/bff/api/me`, CSRF 없는 POST, matching header POST, cross-site POST | browser token count 0, downstream JSON 200, 403/200 분리, SameSite cookie omission | BFF token custody와 cookie-authenticated state-change protection |
| AP4 | Cookie 없는`/``/api/edge`, 정상 session, spoofed headers, external auth endpoint, direct app/proxy ports | root 302, exact API 401, 실제 user 200, auth endpoint 404, internal ports inaccessible | Edge만 trusted identity input을 만들 수 있는 path |
### AP1 검증을 단계별로 읽는 법
@@ -1409,12 +1445,12 @@ Spoofing test는 authenticated browser가 `X-Auth-Request-User: spoofed-admin`,
네 패턴을 모두 실행하고 나니 AP1에서 AP4로 갈수록 브라우저에 OAuth token이 덜 보이는 것은 맞았습니다. 처음에는 번호가 높을수록 더 나은 패턴처럼 보였습니다. 그런데 token을 브라우저에서 치울 때마다 그 일을 다른 곳이 맡았습니다. AP3에는 server session과 CSRF가 생겼고, AP4에는 proxy session과 identity header를 믿을 조건이 생겼습니다. 그래서 번호 순서 대신 브라우저와 BFF, edge 중 누가 token과 session을 관리하는지로 비교했습니다.
| 패턴 | 얻는 것 | 잃거나 추가하는 것 | 잘 맞는 조건 | 피해야 할 조건 |
|---|---|---|---|---|
| AP1 | protocol 가시성, stateless Resource Server, direct API | browser token lifecycle, XSS 시 token·권한 악용, reload state 포기 | public SPA가 API를 직접 불러야 하고 token-in-browser를 수용 | browser token 자체가 정책상 금지 |
| AP2 | client secret·refresh token server custody, 기존 Bearer API 유지 | access token 노출과 server state를 동시에 운영 | direct browser-to-API가 실제 요구이며 refresh credential만 분리 | one-time handoff나 tokenless browser가 요구 |
| AP3 | OAuth token 비노출, application-owned fan-out과 session | CSRF, shared session/token store, BFF latency와 장애 지점 | backend가 API composition과 사용자 session을 소유 | stateless direct API와 독립 client가 핵심 |
| AP4 | OAuth 비인지 upstream 앞의 공통 login gate | proxy session, network·header trust, claim projection 계약 | 기존 upstream 변경이 어렵고 edge policy를 강제 가능 | backend direct path나 header overwrite를 닫을 수 없음 |
| 패턴 | 얻는 것 | 잃거나 추가하는 것 | 잘 맞는 조건 | 피해야 할 조건 |
| ---- | ----------------------------------------------------------------- | ------------------------------------------------------------------- | --------------------------------------------------------------- | ----------------------------------------------------- |
| AP1 | protocol 가시성, stateless Resource Server, direct API | browser token lifecycle, XSS 시 token·권한 악용, reload state 포기 | public SPA가 API를 직접 불러야 하고 token-in-browser를 수용 | browser token 자체가 정책상 금지 |
| AP2 | client secret·refresh token server custody, 기존 Bearer API 유지 | access token 노출과 server state를 동시에 운영 | direct browser-to-API가 실제 요구이며 refresh credential만 분리 | one-time handoff나 tokenless browser가 요구 |
| AP3 | OAuth token 비노출, application-owned fan-out과 session | CSRF, shared session/token store, BFF latency와 장애 지점 | backend가 API composition과 사용자 session을 소유 | stateless direct API와 독립 client가 핵심 |
| AP4 | OAuth 비인지 upstream 앞의 공통 login gate | proxy session, network·header trust, claim projection 계약 | 기존 upstream 변경이 어렵고 edge policy를 강제 가능 | backend direct path나 header overwrite를 닫을 수 없음 |
### AP1을 적용하거나 떠날 기준
@@ -1461,7 +1497,9 @@ AP3에서 AP4로 옮기는 일은 한 단계 업그레이드가 아니었습니
반대로 AP4의 upstream이 더 많은 claim과 애플리케이션 흐름을 요구하기 시작하면 BFF로 돌아갈 수 있었습니다. Header 종류를 계속 늘리는 것보다 API 조합 책임을 애플리케이션에 돌려주는 편이 명확할 수 있었습니다. 저는 어느 쪽으로 옮길지를 번호로 판단하지 않았습니다. 새로 일을 맡는 곳이 state와 검증을 감당할 수 있는지를 보았습니다.
<!-- techviz:begin id=credential-contract-migration context-sha256=df4d1a604c74e756672b5b40510abfedb8c67b39af280a5f51985ea9972f5371 -->
<!-- techviz:generate id=credential-contract-migration -->
![AP1에서 AP2, AP2에서 AP3, AP3에서 AP4, AP4에서 AP3로 이동할 때 호출 계약, 소유권, 브라우저 계약, 운영 책임과 전환 성격을 같은 다섯 축으로 비교한 네 항목.](assets/credential-contract-migration/credential-contract-migration.svg)
<details>
@@ -1472,6 +1510,7 @@ AP3에서 AP4로 옮기는 일은 한 단계 업그레이드가 아니었습니
</details>
[Editable source](assets/credential-contract-migration/credential-contract-migration.drawio) · [Grounded VizSpec](.techviz/credential-contract-migration/spec.json)
<!-- techviz:end id=credential-contract-migration -->
## 결국 지키려던 것은 무엇이었나