public-v1.yaml의 18개 operation 전부를 구현한다. 사이트·홈·프로필, 탐색 2종, 주제
2종, 문서 상세 3종, 프로젝트 5종, 릴리스 2종, 검색. studio-v1(19/19)에 이어
public-v1도 18/18이다.
생성기가 계약 필드를 조용히 빠뜨리고 있었다 — 근본 원인은 파생 단계의 YAML alias
swagger-parser가 이 문서의 스키마 15개를 "is not of type `object`"로 거절했다.
거절당한 스키마들은 전부 type: object를 명시하고 있어서 계약 결함처럼 보이지 않았고,
validateSpec을 끄면 생성은 성공했다. 그런데 그렇게 만든 모델에서 LatestEntry.publishedAt,
ProjectListItem.updatedAt, SearchResultItem.matchedFields, ReleaseListItem.changeTypes가
사라져 있었다. 컴파일은 통과한다 — 아직 아무도 그 필드를 안 쓰니까.
원인은 prepare 단계였다. 변환들이 같은 Map 인스턴스를 여러 property에 재사용했고
snakeyaml이 그 지점을 anchor/alias(&id001 / *id001)로 덤프했다. swagger-parser는
alias 노드를 해석하지 못해 그 스키마 전체를 거절하고, generator는 검증을 끄면 문서를
받아들이되 alias였던 property를 말없이 버린다. 파생 스펙에 alias가 34곳 있었다.
- 덤프 직전 deep copy로 노드 identity를 끊어 alias를 원천 차단하고, 남으면 빌드가
실패하도록 fail-closed 게이트를 뒀다. validateSpec은 다시 켰다
- verifyPublicGeneratedModels를 schema 이름 대조에서 property 대조로 강화했다.
이번 누락을 이 게이트가 통과시켰기 때문이다. 지금은 schema 62개 · property 250개를 센다
계약이 선언했는데 서버가 무시하던 필터를 채웠다
지정해도 오류가 아니라 "결과 0건"으로 보여서 소비자가 자기 요청이 틀렸다는 걸 알 수 없었다.
- exploreQuestions: tag 필터 없음, sort 3값이 SQL에 반영되지 않음
- listPublicProjectDecisions: status 필터 없음
- listPublicProjectRecords: type/relation 필터 없음, QUESTION이 대상에서 빠져 있었음
- 필터는 목록과 총계 두 쿼리에 같이 걸린다. 갈라지면 마지막 페이지가 비어 보인다
- enum 파라미터는 요청 경계에서 검사해 PUBLIC_REQUEST_INVALID로 거절한다
응답 봉투와 오류 경계
- 컨트롤러는 봉투를 반환하지 않는다. EnvelopeBodyAdvice가 감싼다(ADR-006)
- PublicExceptionHandler를 publicapi 스코프로 두고, StudioExceptionHandler의 스코프를
...web.techlog → ...web.techlog.studio로 좁혔다. 좁히지 않으면 공개 조회의 파라미터
오류가 Studio 계약 코드(REQUEST_VALIDATION_FAILED, 422)로 나가는데, 그 코드는
public-v1의 ApiError.code enum에 없어 프론트엔드의 응답 파싱 자체가 깨진다
- FieldError 모양이 studio({path,message})와 public({field,code,message})이 다르다
실행이 잡아낸 결함
컴파일과 단위 테스트로는 드러나지 않았고 실제 PostgreSQL과 실제 기동이 잡았다.
- profile()의 selectedEvidence가 List.of() 하드코딩이었다. 계약 필드가 항상 비어 있었다
- latestEntries/latestRecords가 projection의 모든 resource_type을 흘렸다. 계약의
LatestEntry.entryType은 4값뿐이라 QUESTION이 섞이면 매퍼가 500을 낸다
- home_focus_config.default_focus_type은 마이그레이션 직후 NULL인데 계약은 이 필드를
required + enum 3값으로 선언한다. 배포 직후 첫 요청부터 /home이 깨졌다.
HomeFocusView.resolve가 반드시 유효한 값 하나를 정하도록 고쳤다
V9__techlog_public_surface.sql
설계 패키지 database/V1__init.sql이 정의한 공개 표면 6종(release, site_config,
profile_page, home_focus_config, project_topic, topic_featured_document)과 단일 행
시딩. 릴리스는 Publication 파이프라인을 거치지 않고 자체 workflow_status로 공개된다.
게이트
- PublicContractDriftTest: springdoc이 게시하는 표면과 계약을 양방향 대조한다.
계약의 servers(/api/v1/public)를 경로에 더해 비교하며, operation 수 18을 함께 고정해
"비교 대상이 0건이라 통과"를 실패로 만든다. 봉투 래핑도 확인한다
- PublicErrorRegistryTest: PublicError ↔ error-codes.yaml ↔ 계약 enum 3자 대조.
INTERNAL_ERROR는 스켈레톤 소유라 재선언하지 않으므로 "계약 = public 소유 ∪ 그 하나"로
고정한다. vendored 계약의 MANIFEST 해시도 확인한다
- postgresqlTechLogPublicPersistenceIntegrationTest: 어댑터 7종과 V9를 실제
PostgreSQL에서 돌린다. 표준 check는 Testcontainers를 돌리지 않으므로 이 태스크가
없으면 이 SQL은 한 번도 실행되지 않은 채 빌드가 통과한다. 게시 취소·비공개 자료를
함께 심어 어느 경로로도 새지 않는지 확인한다
검증
./gradlew check BUILD SUCCESSFUL (248 task). 공개 조회 통합 테스트 24/24.
실제 PostgreSQL로 앱을 띄워 18개 operation 전부 실호출 — 5xx 0건, 파라미터 검증 5종
전부 계약 코드. 한때 사라졌던 publishedAt/matchedFields/changeTypes가 실응답에 있다.
알려진 선재 실패: ActuatorSecurityHttpTest가 /actuator/health 503으로 실패한다.
기저 커밋 743fee3에서도 동일하게 재현되며, 원인은 redis가 호스트 포트에 노출되지 않아
헬스가 DOWN인 환경 문제다. 이 커밋과 무관하다.
AGENTS.md의 commit 정책은 human-only다. 이 커밋은 사용자가 "지금 변경했던 내용을
전부 반영하고 develop과 main에 반영하도록" 지시해 예외로 수행한다.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Tech Log Backend
Initialized from clean-architecture-backend-template revision
0a6dd0e419620683de48f69b5c6d22d9964b6f44 as a tracked snapshot. Template
updates are applied explicitly and recorded in template.lock.json.
ca-skeleton은 Java 21, Spring Boot 4.0.0, Gradle 멀티모듈 기반의 Clean Architecture 백엔드 템플릿입니다. fork해서 도메인·패키지·엔티티·유스케이스를 추가하면 새 서비스를 시작할 수 있고, 모듈 경계와 의존 방향은 그대로 유지합니다. 기본 패키지는 dev.caskeleton이며, 구체적인 예시 도메인은 제거되어 있습니다.
이 문서는 전체 구조와 첫 실행만 다룹니다. 모듈별 상세 규칙과 설계 근거는 각 모듈의 README와 CLAUDE.md가, 빌드·환경 변수 상세는 src/README.md가 소유합니다.
아키텍처 한눈에
의존은 항상 바깥에서 안으로 흐릅니다. adapter가 core의 port에 의존하고, core는 adapter를 알지 못합니다. 이 방향이 유지되는 한 도메인 규칙과 기술 선택(웹 프레임워크, DB, 메시징)을 서로 독립적으로 바꿀 수 있습니다.
app-bootstrap -> adapter:inbound:* -> application-core -> domain-core
app-bootstrap -> adapter:outbound:* -> application-core -> domain-core
모든 런타임 모듈 -> shared-contract
family 수준의 책임은 다음과 같습니다.
| 모듈 family | 책임 |
|---|---|
domain-core |
순수 도메인 모델·불변식·이벤트·port. 프레임워크·transport·DB·IO 타입 금지 |
application-core |
command·유스케이스·application 정책·트랜잭션 port. inbound DTO·persistence entity 금지 |
adapter:inbound:* |
HTTP·gRPC·GraphQL·WebSocket transport 경계, DTO·validation·인증·에러 매핑 |
adapter:outbound:persistence-* |
JPA/PostgreSQL·MongoDB 영속 구현과 매핑·migration |
adapter:outbound:* |
support(공유 베이스)·messaging·cache·notification·object storage·file·HTTP client·identifier 능력을 port 뒤에서 구현. 외부 연동 어댑터(messaging·cache·notification·HTTP client)는 기본 비활성 |
shared-contract |
skeleton 전역 운영 계약. business/domain 개념 저장 금지 |
app-bootstrap |
Spring Boot entrypoint와 composition root |
정확한 18개 leaf 목록과 각 leaf의 Gradle path·소스 경로·허용 production 의존 edge는
src/config/architecture/modules.json이 SSOT입니다. focused
test는 해당 Gradle path에서 ./gradlew <gradle-path>:test --console=plain 형태로 파생하며, root
문서나 기억에서 개별 leaf edge를 추론하지 않습니다.
퀵스타트
전제조건은 Temurin 21(루트 .tool-versions에 고정)과 Docker Engine 또는 Docker Desktop입니다. Gradle은 저장소 wrapper를 씁니다. 첫 실행 진입점은 하나입니다.
cd src
./gradlew bootstrap
bootstrap은 compile 검사, PostgreSQL Compose 기동, 애플리케이션 이미지 build·기동(startup Flyway 포함), GET /api/healthcheck HTTP smoke를 순서대로 실행합니다. 각 단계가 별도 Gradle task라 실패 단계가 task 이름으로 드러납니다. 기동을 확인하려면 health endpoint를 호출합니다.
curl -fsS http://localhost:8080/api/healthcheck
로컬 스택을 내릴 때는 저장소 루트에서 실행합니다.
docker compose -f docker-compose.yml -f docker-compose.local.yml down
src/.env는 커밋된 안전 기본값이라 별도 .env.example을 만들지 않습니다. 전체 환경 변수 목록과 조정 시점은 src/README.md와 docs/registries/env-keys.yaml에 있습니다.
프로파일별 데이터스토어
bootstrap은 컨테이너 경로(PostgreSQL)를 검증하는 첫 실행 진입점입니다. 일상 개발은 Docker 없이 돌리는 local 프로파일이며, 이때 데이터스토어는 H2 in-memory입니다.
cd src
./gradlew :app-bootstrap:bootRun
| 프로파일 | 데이터스토어 | 스키마 소유자 |
|---|---|---|
local (bootRun 기본) |
H2 in-memory | Hibernate create-drop |
dev |
PostgreSQL | Flyway |
prod |
PostgreSQL | Flyway |
local은 wiring과 애플리케이션 동작을 검증하고, migration과 vendor 동작은 검증하지 않습니다. 프로파일별 설정은 src/app-bootstrap/src/main/resources/의 application-{local,dev,prod}.yml이, 상세 설명은 src/README.md가 소유합니다.
새 프로젝트로 시작하기
이 저장소를 새 서비스의 출발점으로 쓸 때 핵심 단계는 다음과 같습니다. 전체 체크리스트는 AGENTS.md의 "템플릿 재사용 체크리스트"에 있습니다.
-
src/settings.gradle의
rootProject.name을 새 서비스 이름으로 바꿉니다. -
패키지 루트
dev.caskeleton을 조직·서비스 패키지로 바꿉니다. 소스뿐 아니라 빌드·설정 파일의 참조도 함께 바꿔야mainClass·group이 어긋나bootstrap이 깨지지 않습니다.cd src find . -type f \( -name '*.java' -o -name '*.gradle' -o -name '*.yml' \) -print0 | xargs -0 sed -i 's/dev.caskeleton/com.yourorg.yourservice/g'애플리케이션 이름 등 나머지 rename 단계는 위 체크리스트를 따릅니다.
-
CaSkeletonApplication을 새 애플리케이션 이름으로 바꾸고, 목표 도메인의 엔티티·repository port·유스케이스·adapter를 production 모듈에 추가합니다. -
모듈 이름과 경계는 그대로 유지합니다.
검증은 전체 테스트와 sample-off 재유입 방지 계약을 모두 통과시킵니다.
cd src
./gradlew test
./gradlew :app-bootstrap:sampleOffTest
sampleOffTest는 삭제된 샘플 타입이 production bootstrap classpath에 다시 들어오지 않는지 검증합니다.
아키텍처 규칙과 검증
애플리케이션이 동작하더라도 아래를 어기면 병합하지 않습니다. 8개 HARD-STOP 조건의 정본 로컬 정책 권위는 AGENTS.md이며, CLAUDE.md는 동기화된 요약입니다.
domain-core는 Spring·JPA·Servlet·HTTP·DB·cloud SDK 타입을 import하지 않습니다.- controller는 repository를 직접 호출하거나 persistence entity를 반환하지 않습니다.
- inbound DTO는
application-core나domain-core로 들어가지 않습니다. - 비즈니스 정책은 mapper·filter·config·settings·controller에 두지 않습니다.
- 새 외부 시스템 연동은 domain/application port와 adapter 모듈로 표현합니다.
이 규칙은 두 축으로 자동 강제합니다. ArchUnit CleanArchitectureTest가 컴파일된 소스 의존성을,
verifyCleanArchitectureDependencies 게이트가 JSON registry의 허용 Gradle project edge를
검사합니다.
cd src
./gradlew verifyCleanArchitectureDependencies
두 검증 축은 ci-quality-gates.yml의 release gate에 연결되어, 규칙 위반이 병합·릴리스를 막습니다.
더 알아보기
- 빌드·검증 게이트·환경 변수 상세: src/README.md
- 모듈 레지스트리(18개 leaf SSOT): src/config/architecture/modules.json
- 에이전트·기여자 작업 규칙: AGENTS.md · CLAUDE.md
- 빌드·릴리스 공급망 파이프라인은 현재 Mode B 복구 범위에 포함되지 않았다. 현재 저장소가 제공하는 canonical workflow는 품질·의존성 취약점·링크 검사이며, release/publish 자동화는 별도 설계와 권한 검토 후 추가한다.
- 모듈별 설계 결정: domain-core · application-core · adapter:inbound:web · adapter:outbound:persistence-jpa · shared-contract · app-bootstrap