Duplicate relations. Connecting the same target twice saved without a word: the contract carries no uniqueItems on relations (only maxItems 20) and the validator checked order uniqueness but not target. The document then renders the same row twice publicly, and removing one leaves the other behind — "삭제했는데 그대로". Rejected now, alongside the existing order check. two distinct targets 201 same target twice 422 REQUEST_VALIDATION_FAILED The prod DDL guard ran too late. JpaSchemaSafetyValidator was a SmartInitializingSingleton, which fires after every singleton exists — including entityManagerFactory, which Hibernate builds by applying ddl-auto. Booting prod with ddl-auto=update logged "Initialized JPA EntityManagerFactory" first and the PROFILE_MISMATCH second, with the tables Hibernate created in between still in the schema. The guard stopped traffic but not schema mutation, so a misconfigured deploy had already changed the production database by the time it refused to start. It is a BeanFactoryPostProcessor now, before any bean is instantiated. fs_* tables dropped, prod booted with ddl-auto=update exit 71, no EntityManagerFactory line, 0 tables created Object storage inside a database transaction. UploadStudioAssetUseCase called binaries.store from inside inWrite, holding a connection and its locks for the length of a network round-trip — a slow storage backend becomes connection-pool exhaustion. It bought nothing: storage does not join the transaction, so a failed commit leaves the bytes written either way. Storage now happens first and the database write is a short transaction; a failed write deletes the object it just uploaded, and a failed delete is attached with addSuppressed rather than replacing the error the caller needs to see. Full build passes apart from one fileserver flake (LocalPersistentControlPlaneTest.heldOperationReentrancyIsScopedToThe AttestedRoot) that passes in isolation and touches none of these files. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
src — 빌드 스크립트 / 환경 변수 참조
src/ 는 Gradle 멀티모듈 루트입니다. 모듈 경계·의존 방향 규칙은 루트 CLAUDE.md
와 AGENTS.md, 모듈별 규칙은 각 모듈의 CLAUDE.md 가 SSOT 입니다.
이 문서는 build.gradle 과 .env 의 코드 주석에서 덜어낸 설정 항목 설명과 결정 근거를 모아둔 참조용 기록입니다. 두 파일에는 짧은 기능 주석과 "자세한 내용은 README 참조" 포인터만 남기고, "왜 이렇게 했나"는 여기서 풀어 설명합니다.
build.gradle — 빌드 / 검증 게이트
모든 모듈의 check 태스크는 아래 verify 게이트에 의존합니다. 빌드를 통과하려면 검사가
모두 green 이어야 합니다.
| 게이트 | 하는 일 |
|---|---|
verifyCleanArchitectureDependencies |
모듈 간 의존 방향이 허용된 범위 안에 있는지 검사 |
verifyRuntimeModuleMembership |
registry의 production composition root membership과 실제 main project dependency가 정확히 일치하는지 검사 |
verifyEnvKeys |
env-keys.yaml ↔ application.yml ↔ src/.env 가 어긋나지 않는지 검사 |
verifyOneTypePerFile |
파일당 public 최상위 타입 1개, 파일명 == 타입명인지 검사 |
verifyTrivyignore |
.trivyignore.yaml 의 Trivy suppression 이 사유·만료일을 갖추고 만료/기한초과가 아닌지 검사 |
verifyReadmeCommands |
root README의 실행 가능한 Gradle/Compose/Make 명령이 실제 task/file/target과 일치하는지 검사 |
Local bootstrap
./gradlew bootstrap은 bootstrapCompile → bootstrapDependencies →
bootstrapMigrateAndStart → bootstrapSmoke를 순서대로 실행합니다.
DB와 app lifecycle은 저장소 루트의 base/local Compose 조합이 소유하며, app startup Flyway가
끝나 public health endpoint가 준비되어야 다음 단계로 넘어갑니다. src/.env는 env 설정의
SSOT이고 bootstrap이 별도 env template을 만들지 않습니다.
README command drift는 다음 명령으로 독립 실행할 수 있습니다.
./gradlew verifyReadmeCommands
Traceable version + dependency locking
- 모든 project version은
<MAJOR>.<MINOR>.<PATCH>+<12자리 git sha>입니다. base version은-PreleaseVersion/RELEASE_VERSION, revision은-PgitRevision/GIT_SHA/GITHUB_SHA순으로 주입하고, 로컬에서는 현재 Git commit을 읽습니다. - 모든 JAR manifest에
Implementation-Version과Build-Revision을 기록합니다. Git metadata도 revision property도 없는 상태는 traceable artifact를 만들 수 없으므로 configuration 단계에서 실패합니다. - 모든 subproject가
lockAllConfigurations()+LockMode.STRICT를 사용하고, lock state는 Gradle 기본<module>/gradle.lockfile에 둡니다. 이 경로는 Renovate Gradle manager 기본 인식 경로와 같습니다. - lock 갱신은
./gradlew resolveAndLockAll --write-locks한 가지 명령으로 수행합니다. 이 task는--write-locks가 없으면 실패하며, 일반 build가 lock state를 조용히 다시 쓰지 못하게 합니다.
Reproducible archives (D10)
모든 AbstractArchiveTask는 file timestamp 보존을 끄고, file order를 재현 가능하게 정렬하며,
directory/file mode를 각각 0755/0644로 고정합니다. Java toolchain은 21이고 로컬·CI patch
version은 root .tool-versions의 Temurin 값으로 맞춥니다.
두 번의 clean, no-cache bootJar SHA-256 비교는 다음 명령으로 실행합니다.
cd ..
bash .github/scripts/verify-reproducible-build.sh
이 검사는 동일 toolchain·동일 source revision 안에서 archive 재현성을 검증합니다. 서로 다른 JDK vendor/build나 container base image까지 byte-for-byte 같음을 주장하지 않습니다.
-parameters 컴파일러 플래그
- 결정. 모든 subproject 의 Java 컴파일에
-parameters플래그를 직접 설정합니다. - 근거. Spring MVC 는
@PathVariable/@RequestParam의 이름을 reflection 의 parameter metadata 에서 읽습니다. 이 플래그가 없으면 파라미터 이름이arg0,arg1로 컴파일되어 바인딩이 깨집니다. Spring Boot Gradle 플러그인은 이 플래그를 자동으로 켜 주지만, 이 프로젝트는 플러그인을apply false로 두기 때문에 자동 적용이 일어나지 않습니다. 그래서 각 subproject 의JavaCompile에 직접 설정합니다.
verifyCleanArchitectureDependencies
- 하는 일. config/architecture/modules.json의
allowed_dependencies를 읽고, 실제 Gradle 프로젝트 의존(api/implementation/compileOnly/runtimeOnly)이 그 범위를 벗어나면 빌드를 실패시킵니다. - JSON registry가 의존 방향의 SSOT 입니다. 새 모듈이나 새 production 의존 edge를 추가하면
registry와 ArchUnit 규칙(
CleanArchitectureTest)을 함께 갱신해야 합니다. settings와 gate는 같은 registry를 읽고, 등록되지 않은 leaf나 허용되지 않은 edge를 fail-closed로 거부합니다.
verifyRuntimeModuleMembership
- 하는 일. 같은 registry의
runtime_compositions와 각 leaf의runtime_memberships를 읽어app-bootstrap의 실제api/implementation/compileOnly/runtimeOnlyproject dependency와 정확히 대조합니다. - opt-in의 의미. membership이 빈 GraphQL/gRPC/WebSocket/Mongo leaf는 독립 빌드 대상이지만
production runtime에는 없습니다. app-bootstrap의
conditionalTransportTesttest-only classpath는 실제 채택 전에 세 inbound transport를 함께 qualification하기 위한 evidence composition입니다. - 변경 규칙. production edge를 추가하거나 제거할 때
allowed_dependencies,runtime_memberships, 실제 Gradle dependency를 같은 변경에서 갱신하지 않으면check가 실패합니다.
세 opt-in inbound transport의 test-only composition, 실제 wire 경계, positive-count/zero-skip 증거는 다음 release-blocking aggregate로 실행합니다.
./gradlew conditionalTransportQualification
verifyOneTypePerFile (code-conventions I6)
- 하는 일.
src/main/java의 모든.java파일이 public 최상위 타입을 1개만 갖고, 그 타입 이름이 파일 이름과 같은지 검사합니다 (Google Java Style Guide §3.4.1).package-info.java,module-info.java는 예외입니다. - 근거. 이 "파일 모양(file-shape)" 규칙은 ArchUnit 으로는 잡을 수 없습니다. ArchUnit 은 컴파일된
bytecode 를 읽기 때문에 "한 파일에 몇 개의 타입이 있었는지", "파일 이름이 무엇이었는지" 같은 소스
파일 레벨 정보를 볼 수 없습니다. 그래서 다른
verify*게이트와 똑같이 기계적으로 강제하려고 소스 파일을 직접 스캔하는 별도 태스크로 만들어check에 연결했습니다.
verifyEnvKeys
- 하는 일.
docs/registries/env-keys.yaml,application.yml,src/.env세 곳을 lock-step(서로 어긋나지 않게) 으로 유지합니다.env-keys.yaml이APP_키의 SSOT 이고, drift 가 생기면 빌드를 실패시킵니다. - 막으려는 것 3가지. (1) 필수 env 가 조용히 누락되는 것, (2) 더 이상 쓰지 않는 stale env 키가
.env에 남는 것, (3) 실제로 쓰는APP_키가 registry 에 등록되지 않고 빠져나가는 것. - 검사 항목.
- A.
application.yml의 placeholder 중 inline default(${VAR:default})가 없는 필수 placeholder(${VAR})는 반드시.env에 존재해야 합니다. - B.
.env의 모든 키는application.yml의 어떤${...}placeholder 가 참조해야 합니다. (아무도 안 쓰는 키는 orphan 으로 간주해 실패) - C.
.env의 모든APP_키는 registry 에- name: <KEY>행이 있어야 합니다.
- A.
SPRING_*키는 왜 registry 추적 대상이 아닌가.SPRING_*는 Spring Boot 가 정의한 native 키라 프로젝트가 소유한 계약이 아닙니다. 그래서 C 검사는 일부러APP_prefix 로만 범위를 좁혔습니다.- 비고.
src/.env가 프로젝트의 커밋된 env 파일이며, 별도의.env.example템플릿은 두지 않습니다.
verifyPublicPathSnapshot
- 하는 일. deny-by-default public path 표면이 승인 없이 바뀌면 빌드를 실패시킵니다.
- 배경. 이 앱은 deny-by-default 입니다. 즉
SECURITY_PUBLIC_PATHS가 먹이는 명시적permitAll()경로를 제외한 모든 요청은 인증을 요구합니다(src/.env→SecuritySettings.publicPaths()→SecurityConfig). 이 public 표면이 바뀌는 순간이 곧 보호되던 엔드포인트가 조용히 공개로 노출되는 지점입니다. 그래서 그 표면을 snapshot 으로 떠 두고, 미승인 변경에 빌드를 실패시킵니다. - 승인 방법.
verifyPublicPathSnapshot은 항상 읽기 전용입니다. reviewer 가 변경을 승인한 뒤./gradlew updatePublicPathSnapshot -PapprovePublicPathChange로 snapshot 을 명시적으로 다시 생성합니다. 공개 경로 변경은 보안 리뷰 대상으로 보고 재생성된 snapshot 을 함께 커밋합니다. - 결정 — 무엇을 snapshot 했나 (프로젝트 선택). 초기안은 기동 시
SecurityFilterChain.getFilters()를 introspection 하는 방식이었습니다. 하지만 그 reflection 은 Spring 버전마다 깨지기 쉽습니다(permitAllmatcher 가RequestMatcherDelegatingAuthorizationManager의 private 필드에 숨어 있음). 그래서permitAll()을 실제로 먹이는 결정적 SSOT 인SECURITY_PUBLIC_PATHS자체를 snapshot 합니다. 탐지 목표(공개 경로 변경은 무조건 게이트를 실패시킨다)는 같고, 메커니즘은 더 견고합니다. - snapshot 위치.
docs/security/public-paths-snapshot.txt. 이 파일은 커밋된 필수 보안 baseline 입니다. CI 는 Gradle 실행 전에 파일이 비어 있지 않고 Git에 추적되는지 검사하므로 fresh checkout 에서 누락되거나 untracked 상태면 즉시 실패합니다. 승인된 변경만 update task로 재생성한 뒤 보안 리뷰와 함께 커밋합니다.
verifyTrivyignore
- 하는 일. repo 루트
.trivyignore.yaml의 모든 Trivy suppression 항목이 (1)id, (2) 비어있지 않은statement(사유), (3) 미래이면서 90일 이내인expired_at(만료일) 을 갖추었는지 검사하고, 하나라도 빠지거나 이미 만료됐거나 90일을 초과하면./gradlew check를 실패시킵니다. - 막으려는 것. 2026-05-25 ca-tmpl audit 에서 발견된 "만료일·사유 없는 suppression 을 추가해
취약점을 영구히 조용히 우회"하는 구멍입니다. Trivy 는
expired_at이 없으면 영구 유효로 취급하므로(공식 문서), 만료일 누락 자체를 차단해야 합니다. - 두 겹의 보완 통제. 이 게이트는 필드 검증(CI),
.github/CODEOWNERS는 merge 승인(GitHub 네이티브)을 담당합니다. CODEOWNERS 는 "누가 파일을 바꿀 수 있는가"만, 이 게이트는 "필드가 갖춰졌는가" 만 잡으므로 둘은 대체재가 아니라 보완재입니다. - 결정 — 90일 상한 (프로젝트 선택). Trivy 문서는
expired_at필드의 존재만 보장하고 기간 상한은 권고하지 않습니다. 짧으면 재검토 부담이 늘고, 길면 사실상 영구 ignore 가 되는 trade-off 에서 90일을 기본값으로 두었습니다. fork 는src/build.gradle의maxWindowDays로 조정합니다. - 위치. suppression 파일은
docs/가 아니라 repo 루트(.trivyignore.yaml)에 둡니다 — Trivy 가 스캔 루트에서 자동으로 읽는 커밋 대상 파일이기 때문입니다. 정책 전문(severity·KEV·license·SLA)은.github/dependency-vulnerability-policy.md, CI 배선은.github/workflows/dependency-vulnerability.yml에 있습니다.
verifyQuarantineSunset + 플래키 격리
- 하는 일. 플래키(간헐 실패) 테스트는 JUnit 기본
@Tag("quarantine")를 붙여 격리합니다. 메인test태스크는excludeTags 'quarantine'로 이들을 릴리스 게이트에서 제외하므로 플래키 테스트가 merge 를 막지 않습니다. 격리된 테스트는 별도./gradlew quarantineTest(비차단,ignoreFailures)로만 돕니다. - 막으려는 것. 격리가 영구 주차장 이 되는 것.
verifyQuarantineSunset(루트 태스크,check에 연결)이 매 빌드마다 (1) 레지스트리 스키마(test/quarantined_since/reason/tracking_issue), (2) 14일 sunset(quarantined_since가 14일을 넘으면 빌드 실패), (3) drift(소스에@Tag("quarantine")가 달렸는데 레지스트리에 없으면 실패)를 검사합니다. - 결정 — 14일 sunset (프로젝트 선택). Spotify/Google/MS 사례는 격리 버킷의 정당성만
보이고(Fowler 는 반대), 14일이라는 정량값·자동 강제는 ca-tmpl 절충안입니다(
company-case-study강도 — 공식 best practice 아님). fork 는src/build.gradle의sunsetDays로 조정합니다. - 위치. 레지스트리는
docs/(gitignore) 가 아니라 repo 루트flaky-quarantine.yaml에 둡니다 — CI 가 읽어야 하는 커밋 대상 파일이기 때문입니다(.trivyignore.yaml과 같은 이유). 스켈레톤은 빈 버킷(quarantined: [])으로 출고됩니다.
CI 게이트 배선
- 소유 범위. 이 계약은 게이트 배선(어떤 게이트가 CI 에서 돌고 실패 시 어떻게 릴리스를 막는가)을
소유합니다. 개별 scanner/tool/severity 정책 은 owner 브랜치가 소유하며, 그 20행 매핑의 in-repo
SSOT 가
.github/ci-gate-matrix.yml입니다..github/scripts/verify-gate-matrix.sh(gate-matrix-lint잡)가 표 ↔ 실제 task/test/job 정합을 매 PR 마다 cross-check 합니다. - 워크플로.
.github/workflows/ci-quality-gates.yml의release-gate잡이 모든 release-blocking 게이트의 fan-in(단일 required status check)입니다. 플래키quarantine잡은 의도적으로needs에서 제외(비차단)됩니다. 위임 게이트(Trivy SCA/이미지 스캔)는dependency-vulnerability.yml가 소유하며, GitHub Actions 는 워크플로 간needs를 못 쓰므로 branch protection 의 required check 합집합으로 묶습니다.
.env — 환경 변수 레퍼런스
spring-dotenv 가 src/.env 를 읽어 외부화 설정을 주입합니다(bootRun 의 working dir 가 src/ 라
이 파일이 잡힙니다). 아래는 섹션별 키 설명입니다. 따로 표기가 없으면 restart-only(값 변경 시 재기동
필요)로 간주하세요.
App identity
APP_NAME—spring.application.name과 JSON 로그의app필드. 자유 문자열.SPRING_PROFILES_ACTIVE— 활성 Spring profile. 보통local|dev|stage|prod. JSON 로그의profile필드도 이 값을 씁니다.
Runtime safety (기동 시 StartupSafetyValidator 가 fail-fast 검사, D8)
APP_ERROR_DETAIL_EXPOSURE_ENABLED— 응답에 내부 에러 상세를 노출할지.true|false.prod프로필에서는 반드시false여야 하며, 아니면 기동이 실패합니다.APP_LOG_BODY_CAPTURE_ENABLED— 요청/응답 body 를 로그에 캡처할지.true|false.prod에서는 반드시false, 아니면 기동 실패.APP_MULTI_INSTANCE_ENABLED—true면 인스턴스 협조용 빈 5종(lock / cache-stampede / leader / rate-limit / migration)이 모두 있어야 하며, 하나라도 없으면 기동이 실패합니다.APP_RATE_LIMIT_ENABLED— provider-neutral edge rate-limit interceptor 활성화 (429 +Retry-After+X-RateLimit-*응답). 기본값은false이며,true로 바꿀 때는APP_RATE_LIMIT_PROVIDER=redis와 canonical coordination role을 함께 구성해야 합니다.APP_RATE_LIMIT_CLIENT_IP_MODE— 클라이언트 IP 판별 방식.remote-addr-only|forwarded-headers-trusted. 신뢰된 ingress/LB 가X-Forwarded-For를 앱 도달 전에 덮어쓸 때만forwarded-headers-trusted를 쓰세요. 아니면 IP 위조에 노출됩니다.APP_IDEMPOTENCY_TTL— idempotency 레코드 기본 TTL. duration(예:24h,72h). 오래 도는 use case 는 최대 72h 까지 override 가능. (D6)
Async executor
@Async ThreadPoolTaskExecutor 풀 크기 설정입니다.
APP_ASYNC_EXECUTOR_CORE_SIZE— 항상 살아있는 워커 수. 1 이상 정수.APP_ASYNC_EXECUTOR_MAX_SIZE— 워커 수 상한. core-size 이상.APP_ASYNC_EXECUTOR_QUEUE_CAPACITY— 백로그 큐 용량. bounded(유한) 필수, unbounded 금지(D7). 1 이상 정수.
Optional integration adapters
선택형 Kafka / Redis / Slack / Google Email 어댑터 템플릿입니다. 기본은 전부 비활성(비활성 = 선택
모듈의 기본값). Layer 1 의 @ConditionalOnProperty 가 enabled 일 때만 실제 어댑터를 등록하고, 아니면
fail-fast sentinel 이 포트를 충족합니다(Layer 3).
APP_CACHE_REDIS_ENABLED— Redis 캐시 어댑터 on/off.true|false.APP_CACHE_CANONICAL_DEFAULT_PROVIDER— canonical default semantic region 선택.disabled(기본) |redis.redis는 canonical Redis CACHE role binding을 함께 요구합니다.APP_CACHE_REDIS_CLIENT_MODE—managed는 내장 Lettuce runtime,external은 프로젝트가 제공한RedisClientbean을 사용합니다.APP_MESSAGING_BROKER— 활성 메시지 브로커 id(예:kafka). 빈 값 = 메시징 비활성(사용 시 fail-fast).APP_MESSAGING_KAFKA_BROKERS—host:portCSV.APP_MESSAGING_BROKER=kafka일 때만 필수, 아니면 빈 값.APP_NOTIFICATION_SLACK_PROVIDER— 활성 Slack provider id(예:webhook). 빈 값 = Slack 비활성.APP_NOTIFICATION_EMAIL_PROVIDER— 활성 email provider id(예:google-email). 빈 값 = email 비활성.
Outbound HTTP client
현재 canonical activation은 다음 두 설정 트리만 사용합니다.
ca-skeleton:
capabilities:
http-client:
expected-state: DISABLED
bindings: {}
providers:
http-client: {}
- 기본
DISABLED는 binding/provider definition이 모두 비어 있어야 하며DISABLED_VERIFIED만 게시하고 client, executor, pool, retry/CB registry를 만들지 않습니다. ACTIVE는 exact destination/provider/operation-catalog binding을 요구합니다. 현재 유일한 buffered-classic readiness card가NOT_IMPLEMENTED이므로 provider resource 생성 전에 fail-closed합니다. 아직 운영 HTTP provider를 활성화할 수 있다는 뜻이 아닙니다.- 기존
APP_OUTBOUND_HTTP_*와app.outbound.http.*는 canonical 설정이 아닙니다. 루트src/.env,app-bootstrap의 application YAML, env-key registry에서 제거됐으며 canonical composition에 입력하면 상태와 무관하게 기동을 거부합니다. - legacy JDK facade가 필요한 fork만 canonical composition 밖에서
OutboundHttpSettings.bindLegacy(Binder)와 legacy configuration을 명시적으로 import합니다. timeout/retry/CB/response-size 설정은 그 migration API 내부 계약일 뿐 canonical provider readiness를 증명하지 않습니다.
Logging
Root / app 레벨 — 허용값은 모두 TRACE | DEBUG | INFO | WARN | ERROR | OFF.
APP_LOG_LEVEL_ROOT— root 로거 레벨.APP_LOG_LEVEL_APP— 앱 패키지 레벨.
패키지별 레벨(root 를 덮어씀) — 동일 허용값.
APP_LOG_LEVEL_SPRING/APP_LOG_LEVEL_WEB— 각 패키지 레벨.APP_LOG_LEVEL_SQL—DEBUG로 두면 JPA/jdbc 연결 후 SQL 문이 출력됩니다.
파일 출력 + rolling
APP_LOG_FILE_ENABLED—true면 rolling JSON 파일 appender 를 붙입니다.APP_LOG_FILE_PATH—bootRunworking dir(src/) 기준 상대 경로 또는 절대 경로.APP_LOG_FILE_MAX_SIZE— 파일 1개 최대 크기(단위KB|MB|GB).APP_LOG_FILE_MAX_HISTORY— 보관할 rolled archive 개수. 1 이상 정수.APP_LOG_FILE_TOTAL_SIZE_CAP— 전체 rolled 파일 용량 상한(단위KB|MB|GB,0= 비활성).
Async appender
APP_LOG_ASYNC_ENABLED—true면 appender 를AsyncAppender로 감싸 non-blocking I/O.APP_LOG_ASYNC_QUEUE_SIZE— back-pressure 전 in-memory 큐 깊이. 1 이상 정수.APP_LOG_ASYNC_DISCARDING_THRESHOLD— 남은 큐 용량이 이 값 미만이면TRACE/DEBUG/INFO이벤트를 버립니다(WARN/ERROR는 항상 유지).0= 절대 버리지 않음. 0 이상 정수.
JSON 인코더 세부
APP_LOG_JSON_TIMEZONE— IANA timezone(예:UTC,Asia/Seoul) 또는default(JVM 기본).APP_LOG_JSON_TIMESTAMP_PATTERN— 타임스탬프 패턴. 보통 ISO 8601 (yyyy-MM-dd'T'HH:mm:ss.SSSXXX).APP_LOG_JSON_INCLUDE_CALLER_DATA—true면 file/method/line 을 추가. 성능 비용이 큽니다.APP_LOG_JSON_LOGGER_NAME_LENGTH—0= 로거 이름 전체, 양수 = 패키지 축약(예:36→dev.caskeleton.bootstrap.Foo가d.c.bootstrap.Foo로).
Sampling (SamplingTurboFilter)
APP_LOG_SAMPLING_RATE—INFO이하 로그를 남길 확률. [0.0, 1.0] float. prod 는0.1(10% 샘플링)이 권장,WARN/ERROR는 항상 유지.1.0= 샘플링 없음(dev/local/staging 기본).
Distributed tracing
OTEL_EXPORTER_OTLP_ENDPOINT— OTLP exporter endpoint. 빈 값 = exporter off(스켈레톤에서 OTel SEAM 미활성). 값이 있으면 유효한 URL 이어야 하며 기동 시TracingProperties가 검증합니다.APP_TRACING_ENABLED—false면 tracing seam 은 꺼지지만,meta.traceId는RequestLoggingFilter가 W3Ctraceparent로 여전히 생성합니다(D4 disabled-fallback 보장).APP_TRACING_SAMPLE_RATE— per-profile 기본값을 덮어쓰는 샘플 비율. [0.0, 1.0] float.- per-profile 기본값(D6): prod =
0.01, staging =0.10, dev/local =1.0. - 결정 — 빈 값으로 두는 이유(D-1 ISSUE-1 fix). 빈 값이어야 per-profile resolver 의 기본값이
실제 tracer sampler 까지 도달합니다(
TracingSampleRateResolver가 SSOT). 값을 박으면 프로필별 기본값이 무시되므로, 특정 비율을 강제하고 싶을 때만 채웁니다.
- per-profile 기본값(D6): prod =
Privacy: user_principal pseudonymization
APP_PRIVACY_PSEUDONYMIZATION_SALT— secret 등급 HMAC-SHA-256 salt.__LOCAL_DEV_prefix 는 로컬 전용 sentinel 값이며, prod 에서는 secret-manager 가 주입하는 실제 값을 써야 합니다.
Spring Boot bootstrap
SPRING_BANNER_MODE—off|console|log.SPRING_MAIN_LAZY_INITIALIZATION—true면 빈 생성을 첫 사용 시점까지 지연.SPRING_MAIN_LOG_STARTUP_INFO—true면Starting/Started로그 출력.SPRING_THREADS_VIRTUAL_ENABLED—true면 Tomcat 요청 처리에 Java 21 virtual threads 사용.
Jackson — deserialization policy
모든 request DTO 는 Jackson 경계를 지납니다. 아래 4개 스위치는 잘못된 입력을 조용히 강제 변환하지
않고 즉시 실패하게 만듭니다. 개별 DTO 에 클래스 레벨 @JsonIgnoreProperties(ignoreUnknown = true)
로 이 정책을 완화하는 것은 금지이며 ArchUnit 규칙으로 막혀 있습니다.
..._FAIL_ON_UNKNOWN_PROPERTIES—true: 타입에 선언되지 않은 JSON 키를 거부(Jackson 2.13+ 기본)...._FAIL_ON_NULL_FOR_PRIMITIVES—true: primitive 필드에 JSONnull이 와도0/false로 강제 변환하지 않고 "필수 필드 누락" 에러로 노출. 또는 wrapper 타입(Integer/Long/Boolean) 과Optional<T>를 쓰세요...._FAIL_ON_IGNORED_PROPERTIES—true: JSON 에@JsonIgnore처리된 필드가 들어오면 throw (조용히 버리는 대신 계약 drift 를 노출)...._READ_UNKNOWN_ENUM_VALUES_AS_NULL—false(Jackson 기본 유지): 모르는 enum 값이 조용히null이 되지 않고 throw 되어VALIDATION_FAILED로 드러나게 합니다.
Jackson — serialization policy
응답 생성 쪽 정책입니다. 현재 Jackson/Spring Boot 기본값과 같지만 명시적으로 못박아, 미래에 Spring
Boot 기본값이 바뀌어도 wire 계약이 조용히 깨지지 않게 합니다(spring.mvc.problemdetails.enabled=false
와 같은 근거). JacksonSerializationPolicyTest 가 강제하며, new BigDecimal(double) 생성자는
no_bigdecimal_double_constructor ArchUnit 규칙으로 금지됩니다.
..._WRITE_DATES_AS_TIMESTAMPS—false(D2 / RFC 3339):java.time값을 ISO-8601 문자열로 직렬화(OffsetDateTime→"...Z",LocalDate→"YYYY-MM-DD").true면 epoch 숫자나[y,m,d,...]배열로 나가 datetime 계약이 깨집니다.- BigDecimal plain output — Jackson 3에는 별도
WRITE_BIGDECIMAL_AS_PLAIN설정 키가 없습니다.JacksonSerializationPolicyTest가"12300000000.00"plain 출력을 직접 검증하고,no_bigdecimal_double_constructorArchUnit 규칙이 부정확한new BigDecimal(double)생성을 금지합니다. 엔드포인트별 string vs number 선택은 그대로 명시적으로 둡니다(공개/금융 API 는 string, 내부 API 는 number+plain).
Server / Tomcat
APP_SERVER_PORT— 1~65535 정수.APP_SERVER_SHUTDOWN—graceful|immediate.APP_SERVER_SHUTDOWN_TIMEOUT— duration(예:30s|1m|500ms).APP_SERVER_TOMCAT_MAX_THREADS— 동시 요청 워커 상한. 1 이상 정수.APP_SERVER_TOMCAT_MIN_SPARE_THREADS— idle 워커 풀 하한. 0 이상 정수.APP_SERVER_TOMCAT_ACCEPT_COUNT— 들어오는 TCP 연결의 OS backlog 큐 깊이. 0 이상 정수.APP_SERVER_TOMCAT_MAX_CONNECTIONS— 동시에 열 수 있는 연결 수 상한. 1 이상 정수.APP_SERVER_TOMCAT_CONNECTION_TIMEOUT— duration(예:20s|1m).APP_SERVER_COMPRESSION_ENABLED—true|false.APP_SERVER_COMPRESSION_MIN_RESPONSE_SIZE— 이 크기 미만 payload 는 압축하지 않음(bytes 또는 단위, 예:1024|1KB|2KB).APP_SERVER_FORWARD_HEADERS_STRATEGY—none|native|framework. LB/proxy 뒤에서X-Forwarded-*를 신뢰할지.APP_SERVER_ERROR_INCLUDE_STACKTRACE—always|never|on_param.APP_SERVER_ERROR_INCLUDE_MESSAGE—always|never|on_param.
Presentation
PRESENTATION_API_BASE_PATH— 모든 controller 앞에 붙는 leading-slash 경로(예:/api|/v1|"").
Auth (OIDC resource server)
APP_SECURITY_JWT_ISSUER— OIDC issuer URI(Keycloak realm, Auth0 tenant 등). 필수 — 없으면 기동 실패. 예:https://keycloak.example.com/realms/ca-skeleton.APP_SECURITY_JWT_AUDIENCE— 기대하는audclaim. 빈 값으로 두면 audience 검증을 건너뜁니다.SECURITY_PUBLIC_PATHS— 인증을 우회하는 경로 CSV.api-base-path뒤의 full path 를 씁니다(예:/api/healthcheck). 이 값의 변경은verifyPublicPathSnapshot게이트가 감시합니다(위 build.gradle 설명 참조).
CORS
APP_SECURITY_CORS_ENABLED—true|false.APP_SECURITY_CORS_ORIGINS— 허용 origin CSV(예:http://localhost:3000,https://app.example.com).APP_SECURITY_CORS_ALLOWED_METHODS— 허용 메서드 CSV. 빈 값이면 기본값 사용(GET, POST, PATCH, PUT, DELETE, OPTIONS).APP_SECURITY_CORS_ALLOWED_HEADERS— 허용 헤더 CSV.*= 모든 헤더 허용.APP_SECURITY_CORS_ALLOW_CREDENTIALS—true|false.APP_SECURITY_CORS_MAX_AGE— preflight 캐시 TTL(초).
프로파일과 데이터베이스
프로파일마다 데이터스토어가 다르고, 그 차이는 app-bootstrap/src/main/resources/application-*.yml
가 소유합니다.
| 프로파일 | 데이터스토어 | 스키마 소유자 | 외부 인프라 |
|---|---|---|---|
local (기본) |
H2 in-memory | Hibernate ddl-auto: create-drop |
없음 |
dev |
PostgreSQL | Flyway db/migration/postgresql |
필요 |
prod |
PostgreSQL | Flyway db/migration/postgresql |
필요 |
local 은 ./gradlew :app-bootstrap:bootRun 의 기본값(src/.env 의 SPRING_PROFILES_ACTIVE=local)
이라 Docker 없이 바로 뜹니다. 아래 APP_DATASOURCE_* 값은 local 에서는 쓰이지 않고
application-local.yml 이 덮어씁니다.
local 이 검증하는 것은 wiring·요청/응답·애플리케이션 로직이고, 검증하지 않는 것은 migration 과
vendor 동작입니다. H2 에는 migration tree 가 없어 migration 에만 존재하는 테이블(capability schema
registry, polling-delivery·inbox stream, Spring Integration lock)이 만들어지지 않습니다. 해당
capability 는 local 기본값에서 꺼져 있고, 켜면 테이블 없음으로 실패합니다.
dev 를 호스트에서 띄우려면(SPRING_PROFILES_ACTIVE=dev) PostgreSQL 이 필요합니다.
docker-compose.local.yml 의 db 서비스가 루프백(127.0.0.1:5433)에만 게시하며, 이 주소가
아래 APP_DATASOURCE_URL 의 커밋된 기본값입니다. 저장소 루트에서 실행합니다.
docker compose -f docker-compose.yml -f docker-compose.local.yml up -d --wait db
컨테이너로 띄우는 ./gradlew bootstrap 경로는 이 호스트 포트를 쓰지 않습니다. compose 가 app
컨테이너의 APP_DATASOURCE_URL 을 내부 네트워크 주소 jdbc:postgresql://db:5432/... 로 덮어씁니다.
ca-skeleton.persistence.vendor(postgresql | h2)가 어느 vendor 구성을 조립할지 고르는 단일
스위치입니다. 값이 둘 중 하나가 아니면 기동이 실패하고, prod 에서 h2 이거나 datasource URL 이
jdbc:h2: 이면 PersistenceVendorProdSafetyValidator 가 기동을 거부합니다(env 로 덮어써도 동일).
Database (Postgres)
APP_DATASOURCE_URL— JDBC URL(예:jdbc:postgresql://host:5432/dbname).dev·prod에서 쓰이며, 커밋된 기본값jdbc:postgresql://localhost:5433/ca_skeleton은 위 composedb서비스의 호스트 주소입니다.APP_DATASOURCE_USERNAME/APP_DATASOURCE_PASSWORD— DB 접속 계정.APP_DATASOURCE_DRIVER— Hibernate dialect 에 맞는 드라이버(예:org.postgresql.Driver).APP_DATASOURCE_DDL_AUTO—none|validate|update|create|create-drop. prod 는validate또는none(JpaSchemaSafetyValidator가 기동 시 강제).local은 이 값을 쓰지 않습니다 —application-local.yml이create-drop으로 고정합니다.APP_DATASOURCE_SHOW_SQL—true면 SQL 을 로그로 echo.APP_DATASOURCE_FORMAT_SQL— SQL pretty-print(SHOW_SQL=true일 때만 의미 있음).APP_DATASOURCE_OPEN_IN_VIEW— Hibernate OSIV. prod 에서는 피하세요.
HikariCP 커넥션 풀
APP_DATASOURCE_POOL_MAX_SIZE— DB 동시 연결 최대 수. 1 이상 정수.APP_DATASOURCE_POOL_MIN_IDLE— warm 하게 유지하는 최소 idle 연결 수. 0 이상 정수.APP_DATASOURCE_CONNECTION_TIMEOUT—acquire()가 실패하기 전 대기 시간(ms).APP_DATASOURCE_POOL_IDLE_TIMEOUT— idle 연결 회수 임계 시간(ms).APP_DATASOURCE_POOL_MAX_LIFETIME— 연결의 최대 수명(ms). broker timeout 전에 rotate 하도록 설정합니다.
Management / Actuator
- 결정 — management 포트 분리. actuator 엔드포인트를 앱 API 와 같은 소켓에 노출하지 않으려고 별도 management 포트를 둡니다.
MANAGEMENT_SERVER_PORT— 1~65535 정수.APP_SERVER_PORT(8080)와 달라야 합니다.