Files
tech-log-backend/src/adapter/outbound/persistence-jpa/build.gradle
T
DongHyeonkaandClaude Opus 5 48ff648112 feat: Tech Log Studio 백엔드 — 남은 17개 operation 구현 (슬라이스 2~5)
studio-v1.yaml 19개 operation 중 Plan 01이 남긴 17개를 구현한다. 문서 CRUD,
검증·미리보기, 게시, Asset. 이로써 studio-v1은 19/19다.

Plan 01이 Plan 02로 미룬 생성기 union 차단 요인
- 계약 원본은 그대로 두고 prepareStudioCodegenSpec이 생성 직전에 사본을 파생시킨다.
  oneOf+discriminator를 가진 스키마의 하위 타입에 x-implements를 주입하고, union 자체는
  생성을 억제한 뒤 같은 package에 Jackson 다형성 인터페이스를 계약에서 파생해 써 넣는다
- 파생 규칙을 계약의 oneOf/discriminator.mapping에서 읽으므로 union 목록을 손으로
  관리하지 않는다. 계약에 union이 늘면 따라온다
- openApiNullable=false. JsonNullable을 읽는 모듈은 Jackson 2용인데 이 앱의 HTTP
  변환기는 Jackson 3(tools.jackson)다 — 등록될 수 없어 직렬화가 POJO로 새고
  역직렬화가 깨진다. 해당 필드는 계약상 required라 "없음"과 "null"을 구분할 필요도 없다
- 모든 분기가 type:string인 이름 없는 oneOf는 접는다. 안 접으면 필드 0개 껍데기
  클래스가 나와 slug가 {}로 직렬화된다
- oneOf:[X,null]도 접는다. 그대로 두면 같은 모양의 래퍼 타입이 7벌 더 생긴다
- StudioContractUnionJacksonTest가 이 배선을 지킨다. 파생이 깨지면 컴파일이 깨진다

설계 스키마의 구멍 — V8__techlog_studio_working_copy.sql
V7(설계 패키지 database/V1__init.sql)은 유형마다 다른 물리 모델인데 계약은 네 유형을
공통 base + 유형별 확장이라는 하나의 편집 흐름으로 다룬다. 계약이 요구하는데 없던 것:
- document.summary / case_detail.environment,reproduction / reference_detail.rules,examples
- open_question.options,resolution_evidence_target_id,resolution_link_label
- project_decision.title,slug,summary,primary_topic_id
- problem/conclusion/scope_summary/statement가 varchar라 계약의 100000자를 담을 수 없어 text로 넓힘
- project_decision.project_id NOT NULL은 계약이 명시적으로 허용한 초안 저장을
  구조적으로 막고 있었다(게시 필수 여부는 검증이 판단한다) — 풀었다
- studio_relation: 계약의 relations[]는 네 유형 공통이고 항목마다 자체 id와 reason이
  있다. document_relation은 복합 PK라 둘 다 없고 문서끼리만 성립한다.
  (source_kind, source_id) 다형 참조는 studio_validation/studio_preview가 이미 쓰는 방식이다

영속은 JdbcClient
spec §8.3은 쓰기에 JPA @Version을 적었지만 이 네 aggregate는 Studio 저장 경로에서만
쓰이고 UPDATE ... WHERE version = :expectedVersion의 갱신 행 수가 정확히 같은 의미를
준다. 여덟 개 넘는 테이블에 엔티티를 세우는 비용에 상응하는 이득이 없다. 포트 계약이
같으므로 나중에 JPA가 필요하면 어댑터만 바뀐다.

nextAction/dependencyRevision 계산은 SQL 한 벌(StudioDocumentSql)
목록과 상세가 각자 계산하면 "목록에선 게시하라더니 열어보니 검증하라"가 된다.
계약의 nextAction 필터도 SQL이라야 페이지네이션을 깨지 않고 걸 수 있다.

렌더러 (ADR-005)
- commonmark + GFM 확장. 설계 05장 §16대로 라이브러리는 render 패키지 밖으로 안 나간다.
  프론트가 remark 계열로 같은 CommonMark+GFM 기준을 쓰므로 동등성이 유지된다
- ::: directive는 줄 단위 스캔이다. v1 문법에서 중첩이 없고 줄 맨 앞에서만 열린다.
  코드 펜스 안의 :::는 directive로 보지 않는다
- 컨테이너/leaf 판정은 닫는 줄이 실제로 있는지로 한다. 이름 목록으로 정하면 directive를
  더할 때마다 목록을 고쳐야 하고, "닫는 줄 없으면 문서 끝까지"면 닫기를 빠뜨린
  directive 하나가 뒤 내용을 통째로 삼킨다
- 계약이 표현 못 하는 것은 조용히 바꾸지 않고 경고로 남긴다 — 수평선, 머리글 없는 표,
  알 수 없는 directive, 미해결 asset key(경로를 지어내지 않고 버린다)
- RenderModelPort 구현이 inbound web에 있다. 렌더 모델은 계약 DTO이고 그 타입을 소유한
  모듈이 거기다. application에 같은 모양을 한 벌 더 두면 두 정의가 갈라진다

검증 체인
판정 기준은 하나다 — 이 편집본으로 계약이 요구하는 PublicRenderModel을 만들 수 있는가.
각 규칙은 렌더 모델의 required/minLength/minItems에서 나온다. 다른 기준을 쓰면 검증을
통과한 문서가 렌더 단계에서 계약을 위반한다. 첫 오류에서 멈추지 않고 끝까지 모은다.

게시 (spec §7.5 20단계)
- Snapshot의 렌더 모델은 게시 시점에 다시 렌더링하지 않고 사용자가 확인한 미리보기의
  것을 그대로 쓴다. 다시 렌더링하면 승인한 화면과 공개된 화면이 달라질 수 있다
- 단계별 실패가 서로 다른 계약 코드로 나간다. DOCUMENT_VALIDATION_FAILED(지금 검증하면
  실패)와 VALIDATION_STALE(통과했으나 전제가 바뀜)은 다른 사건이고 할 일도 다르다
- 게시 취소는 route도 Snapshot도 지우지 않는다. 지우면 공개된 링크가 끊긴다

Asset
- 확장자와 클라이언트 Content-Type을 신뢰하지 않고 파일 시작 바이트로 판정한다.
  모르는 형식은 저장하지 않고 415로 거절한다
- 바이너리는 기존 object storage 어댑터에 위임한다(spec §9). ObjectStoragePort는
  deprecated지만 이 저장소에서 실제 구현이 붙어 있는 유일한 포트다 — 선택을 브리지
  한 클래스에 가뒀다. 저장 백엔드가 없는 배포는 업로드·삭제만 503이고 나머지는 동작한다
- 공개 이력이 있거나 사용 중인 Asset은 hard delete하지 않는다

검증 — "통과하는데 동작 안 함"을 세 겹으로 막았다
- StudioContractDriftTest에 반대 방향(계약 → published)을 추가했다. 기존 한 방향은
  사라진 operation을 못 잡는다. 양방향 모두 실제로 RED가 되는 것을 확인했다
- postgresqlTechLogStudioPersistenceIntegrationTest 신규 11개. 이 저장소의 check는
  Testcontainers를 돌리지 않아 이 테스트가 없으면 SQL이 한 번도 실행되지 않는다.
  첫 실행에서 실제 결함을 잡았다: fk_publication_latest_event의 지연 검사는 트랜잭션
  끝에 일어나므로 autocommit이면 첫 INSERT에서 위반된다 → 어댑터가 진입 시 활성
  트랜잭션을 확인하고 아니면 원인을 그대로 말하며 실패한다
- 실제 앱 부팅으로 두 건을 더 잡았다. check에 전체 앱 부팅 테스트가 없어 생긴 구멍이다
  1. 생성자 모호성 — 프로덕션/테스트 두 생성자에 표시가 없어 기본 생성자를 찾다 실패
  2. final 클래스 + AOP — @RequiresPermission은 CGLIB 프록시를 쓰는데 final은 subclass
     불가. 템플릿의 NotificationDispatchUseCase가 final이면서 무사한 것은 그 능력이 꺼진
     배포에서 빈으로 등록되지 않아서다. Studio use case는 항상 등록된다

가드레일이 잡은 것
- MUTATING_USE_CASES_DECLARE_REQUIRED_PERMISSION → studio:write 부여, role 매핑은
  프로파일에. application.yml의 role-permissions:{} 기준선은 SampleRemovalSmokeContractTest가 지킨다
- NO_CONTEXT_DEPENDS_ON_STUDIO_FACADE 223건 → 어댑터 패키지를 persistence.techlog.studio.*로
  옮겼다. 규칙을 고치지 않았고, 그 이름이 우회가 아니라 더 정확하다
- verifyEnvKeys → 새 APP_ 키 4건 등록

범위
studio-v1의 19개 전부. public-v1(18) / studio-management-v1(79)은 spec §2.2가 선언한
out of scope다 — 전자는 소비자가 아직 없고 후자는 secondary capability 보존 계약이다.

검증: ./gradlew check BUILD SUCCESSFUL (245 task), 전체 3,721 테스트 실패 0,
techlog PostgreSQL 통합 테스트 3종 통과, 실제 앱 부팅 확인.

AGENTS.md의 commit 정책은 human-only다. 이 커밋은 사용자가 "전부 커밋하고 머지
진행하세요"로 명시적으로 지시해 예외로 수행한다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 23:21:43 +09:00

196 lines
11 KiB
Groovy

// JPA persistence adapter — merged RDBMS base + PostgreSQL vendor module.
// Owns JPA entities, Spring Data repositories, mappers, transaction/audit/lock/outbox port
// implementations, and the vendor-neutral SPI interfaces (OutboxClaimRepository /
// SqlStateErrorMapping). The PostgreSQL driver, flyway-database-postgresql dialect, and vendor
// Flyway migrations live only under the .postgresql subpackage (ArchUnit keeps the base neutral).
sourceSets {
postgresqlIntegrationTest {
java.setSrcDirs(['src/postgresqlIntegrationTest/java'])
resources.setSrcDirs(['src/postgresqlIntegrationTest/resources'])
compileClasspath += sourceSets.main.output
runtimeClasspath += output + compileClasspath
}
}
configurations {
postgresqlIntegrationTestImplementation.extendsFrom testImplementation
postgresqlIntegrationTestCompileOnly.extendsFrom testCompileOnly
postgresqlIntegrationTestRuntimeOnly.extendsFrom testRuntimeOnly
postgresqlIntegrationTestAnnotationProcessor.extendsFrom testAnnotationProcessor
}
ext.jpaPostgreSqlEvidenceImage = 'postgres:16-alpine'
dependencies {
implementation project(':application-core')
implementation project(':shared-contract')
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
// Studio 편집본의 jsonb 컬럼(reference_detail.rules/examples, open_question.options,
// project_decision.consequences, applies_to/excluded_scope)을 읽고 쓰려면 이 모듈에 JSON 매퍼가
// 필요하다. Jackson 2 databind 가 data-jpa 경유로 이미 classpath 에 딸려오지만 그건 선언하지 않은
// 우연한 가용성이고, 이 저장소는 dependency locking 을 쓴다 — 앱의 다른 계층과 같은
// Jackson 3(tools.jackson)을 명시적으로 선언한다.
implementation 'org.springframework.boot:spring-boot-starter-jackson'
// feature-distributed-lock-contract: Spring Integration JDBC LockRegistry backs the
// multi-instance distributedLockProvider. Version managed by Spring Boot BOM.
implementation 'org.springframework.integration:spring-integration-jdbc'
// Vendor (PostgreSQL): Flyway migration API + PostgreSQL driver/dialect. Used only by the
// .postgresql subpackage; the RDBMS base stays vendor-neutral (PERSISTENCE_RDBMS_STAYS_VENDOR_NEUTRAL).
implementation 'org.springframework.boot:spring-boot-starter-flyway'
runtimeOnly 'org.postgresql:postgresql'
runtimeOnly 'org.flywaydb:flyway-database-postgresql'
// Vendor (H2): the local-profile driver. Used only by the .h2 subpackage, which reaches it
// through JDBC/JPA rather than by importing org.h2 types — the same shape as the PostgreSQL
// driver above. Not `developmentOnly`: local is a deployable profile of this artifact, and the
// vendor selector, not the packaging, decides which driver a deployment loads.
runtimeOnly 'com.h2database:h2'
annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor'
postgresqlIntegrationTestImplementation 'org.testcontainers:testcontainers-postgresql'
postgresqlIntegrationTestImplementation 'org.testcontainers:testcontainers-junit-jupiter'
postgresqlIntegrationTestRuntimeOnly 'org.postgresql:postgresql'
}
tasks.withType(JavaCompile).configureEach { options.encoding = 'UTF-8' }
def registerPostgreSqlReadinessTest = { String taskName, String testClass ->
tasks.register(taskName, Test) {
group = 'verification'
description = "Runs the no-skip real PostgreSQL readiness scenario ${testClass}."
testClassesDirs = sourceSets.postgresqlIntegrationTest.output.classesDirs
classpath = sourceSets.postgresqlIntegrationTest.runtimeClasspath
useJUnitPlatform()
filter {
includeTestsMatching testClass
}
failOnNoDiscoveredTests = true
outputs.upToDateWhen { false }
jvmArgs(
'-Duser.timezone=UTC',
"-Djpa.evidence.postgresql.image=${jpaPostgreSqlEvidenceImage}")
}
}
def postgresqlLifecycleIntegrationTest = registerPostgreSqlReadinessTest(
'postgresqlLifecycleIntegrationTest',
'dev.caskeleton.adapter.outbound.persistence.readiness.PostgreSqlLifecycleIntegrationTest')
def postgresqlSecurityBaselineIntegrationTest = registerPostgreSqlReadinessTest(
'postgresqlSecurityBaselineIntegrationTest',
'dev.caskeleton.adapter.outbound.persistence.readiness.PostgreSqlSecurityBaselineIntegrationTest')
def postgresqlMigrationIntegrationTest = registerPostgreSqlReadinessTest(
'postgresqlMigrationIntegrationTest',
'dev.caskeleton.adapter.outbound.persistence.readiness.PostgreSqlMigrationIntegrationTest')
def postgresqlTransactionIntegrationTest = registerPostgreSqlReadinessTest(
'postgresqlTransactionIntegrationTest',
'dev.caskeleton.adapter.outbound.persistence.readiness.PostgreSqlTransactionIntegrationTest')
def postgresqlAggregateIntegrationTest = registerPostgreSqlReadinessTest(
'postgresqlAggregateIntegrationTest',
'dev.caskeleton.adapter.outbound.persistence.readiness.PostgreSqlAggregateIntegrationTest')
def postgresqlQueryIntegrationTest = registerPostgreSqlReadinessTest(
'postgresqlQueryIntegrationTest',
'dev.caskeleton.adapter.outbound.persistence.readiness.PostgreSqlQueryIntegrationTest')
def postgresqlIdempotencyIntegrationTest = registerPostgreSqlReadinessTest(
'postgresqlIdempotencyIntegrationTest',
'dev.caskeleton.adapter.outbound.persistence.readiness.PostgreSqlIdempotencyIntegrationTest')
def postgresqlOutboxStorageIntegrationTest = registerPostgreSqlReadinessTest(
'postgresqlOutboxStorageIntegrationTest',
'dev.caskeleton.adapter.outbound.persistence.readiness.PostgreSqlOutboxStorageIntegrationTest')
def postgresqlOutboxPollingIntegrationTest = registerPostgreSqlReadinessTest(
'postgresqlOutboxPollingIntegrationTest',
'dev.caskeleton.adapter.outbound.persistence.readiness.PostgreSqlOutboxPollingIntegrationTest')
def postgresqlInboxIntegrationTest = registerPostgreSqlReadinessTest(
'postgresqlInboxIntegrationTest',
'dev.caskeleton.adapter.outbound.persistence.readiness.PostgreSqlInboxIntegrationTest')
def postgresqlFileserverMigrationIntegrationTest = registerPostgreSqlReadinessTest(
'postgresqlFileserverMigrationIntegrationTest',
'dev.caskeleton.adapter.outbound.persistence.readiness.PostgreSqlFileserverMigrationIntegrationTest')
def postgresqlFileserverMetadataIntegrationTest = registerPostgreSqlReadinessTest(
'postgresqlFileserverMetadataIntegrationTest',
'dev.caskeleton.adapter.outbound.persistence.readiness.PostgreSqlFileserverMetadataStoreIntegrationTest')
def postgresqlFileserverReclamationIntegrationTest = registerPostgreSqlReadinessTest(
'postgresqlFileserverReclamationIntegrationTest',
'dev.caskeleton.adapter.outbound.persistence.readiness.PostgreSqlFileserverReclamationIntegrationTest')
def postgresqlTechLogSchemaMigrationTest = registerPostgreSqlReadinessTest(
'postgresqlTechLogSchemaMigrationTest',
'dev.caskeleton.adapter.outbound.persistence.techlog.TechLogSchemaMigrationTest')
// Task 9 (listStudioCatalog): JdbcCatalogQueryAdapterTest has no @SpringBootConfiguration to hang a
// @SpringBootTest off in this module (same reason as postgresqlTechLogSchemaMigrationTest above), so it
// needs its own opt-in Testcontainers task rather than reusing an existing one.
def postgresqlTechLogCatalogQueryIntegrationTest = registerPostgreSqlReadinessTest(
'postgresqlTechLogCatalogQueryIntegrationTest',
'dev.caskeleton.adapter.outbound.persistence.techlog.query.JdbcCatalogQueryAdapterTest')
// 슬라이스 2~5: Studio 영속 경로 전체(편집본 4종 왕복, 낙관적 잠금, union 목록, 의존 해석,
// validation/preview artifact, 게시 20단계, 게시 취소, Asset)를 실제 PostgreSQL 위에서 돌린다.
// 표준 check 는 Testcontainers 를 돌리지 않으므로, 이 태스크가 없으면 그 SQL 은 한 번도 실행되지
// 않은 채로 빌드가 통과한다.
def postgresqlTechLogStudioPersistenceIntegrationTest = registerPostgreSqlReadinessTest(
'postgresqlTechLogStudioPersistenceIntegrationTest',
'dev.caskeleton.adapter.outbound.persistence.techlog.studio.StudioPersistenceIntegrationTest')
def verifyJpaSqlConstructionSafety = tasks.register('verifyJpaSqlConstructionSafety') {
group = 'verification'
description = 'Rejects concatenated SQL construction and non-parameterized PostgreSQL timeout configuration.'
File vendorSource = file('src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql')
inputs.dir(vendorSource)
doLast {
List<String> violations = []
vendorSource.eachFileRecurse { File source ->
if (!source.name.endsWith('.java')) {
return
}
String text = source.getText('UTF-8')
def concatenatedSql = text =~ /(?s)(createNativeQuery|queryForObject|update)\s*\([^;]*"\s*\+/
if (concatenatedSql.find()) {
violations << "${source}: concatenated SQL construction"
}
source.readLines().eachWithIndex { String line, int index ->
if (line.contains("set_config('") && !line.contains('?')) {
violations << "${source}:${index + 1}: set_config value is not parameterized"
}
}
}
if (!violations.isEmpty()) {
throw new GradleException(
"verifyJpaSqlConstructionSafety: ${violations.size()} violation(s):\n " +
violations.join('\n '))
}
logger.lifecycle(
'verifyJpaSqlConstructionSafety: OK — no concatenated SQL construction and all set_config values are parameterized.')
}
}
def verifyJpaSecurityFixtures = tasks.register('verifyJpaSecurityFixtures') {
group = 'verification'
description = 'Verifies the no-skip PostgreSQL security fixture covers runtime-role namespace denial.'
File fixture = file(
'src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/readiness/PostgreSqlSecurityBaselineIntegrationTest.java')
inputs.file(fixture)
doLast {
if (!fixture.isFile()) {
throw new GradleException("verifyJpaSecurityFixtures: missing ${fixture}")
}
String text = fixture.getText('UTF-8')
['runtimeRoleCannotCreateInApplicationSchema', 'assertDockerAvailable', '42501'].each {
String required ->
if (!text.contains(required)) {
throw new GradleException(
"verifyJpaSecurityFixtures: ${fixture} is missing '${required}'")
}
}
logger.lifecycle(
'verifyJpaSecurityFixtures: OK — no-skip Docker and runtime-role namespace denial fixtures are present.')
}
}
postgresqlSecurityBaselineIntegrationTest.configure {
dependsOn verifyJpaSqlConstructionSafety
dependsOn verifyJpaSecurityFixtures
dependsOn project(':adapter:inbound:web').tasks.named('jpaPersistenceRedactionContractTest')
}
apply from: rootProject.file('gradle/jpa-evidence.gradle')