게이트웨이 뒤에서 도는 내부 API 프로젝트를 시작할 때 복사하는 템플릿이다. 레이어드 구조, 다중 데이터소스, 테스트 3분류, 감사 필드 자동 주입, 클라우드별 비밀값 출처 프로필이 이미 배선돼 있다.
이 문서는 이 레포를 처음 복사한 사람을 대상으로 한다. 위에서부터 읽고 「시작하기」를 따라 하면 새 프로젝트가 뜬다.
이 스켈레톤에는 인증 프레임워크가 없다.
spring-boot-starter-security를 쓰지 않는다. 게이트웨이를 통해서만 도달 가능하다는 배포 전제 위에 서 있으며, 그 전제를 코드가 강제하지 않는다. 외부에 직접 노출할 API 라면spring-boot-public-api-skeleton을 쓴다.
같은 규약을 공유하는 스켈레톤이 셋이다. 어느 것을 복사할지 먼저 고른다.
| 스켈레톤 | 쓰는 곳 | 인증 |
|---|---|---|
spring-boot-public-api-skeleton |
외부에 노출되는 API | Spring Security + JWT |
spring-boot-internal-api-skeleton (이 레포) |
게이트웨이 뒤의 내부 API | 없음 — 망 경계에 의존 |
spring-batch-skeleton |
배치·스케줄 작업 | 웹훅 API Key |
세 레포의 「코딩 규약」 절은 글자 단위로 동일하다. 규약을 고치려면 셋을 함께 고친다.
| 항목 | 버전 |
|---|---|
| Spring Boot | 4.1.1 |
| Kotlin | 2.3.21 |
| Java | 21 |
| Gradle | 9.3.0 |
| ktlint | 1.7.1 |
| DB | MySQL 8 |
여기 적힌 숫자는 빌드 파일이 정본이다. 어긋나면 build.gradle.kts · gradle-wrapper.properties 를 따른다. Jackson 등 Spring Boot BOM 이 관리하는 라이브러리는 여기 적지 않는다 — 확인하려면 ./gradlew dependencies 를 쓴다.
sdk list java && sdk use java 21.0.2-tem # JDK 21
./gradlew --version # Gradle 은 wrapper 를 쓴다 — 별도 설치 불필요
docker info # 로컬 실행(docker compose)과 통합 테스트(Testcontainers)에 쓴다복사 직후 반드시 고쳐야 하는 곳이다. 하나라도 빠뜨리면 스켈레톤 이름이 그대로 남는다.
| 고칠 것 | 좌표 | 현재 값 |
|---|---|---|
| 루트 프로젝트 이름 | settings.gradle.kts:1 |
internal-api-skeleton |
| group | build.gradle.kts group |
com.rex |
| 애플리케이션 이름 | src/main/resources/application.yml spring.application.name |
internal-api-skeleton |
| base package | src/main/kotlin/com/rex/api/internal/skeleton/ |
— |
| Kotest 설정 FQN 3곳 | build.gradle.kts kotest.framework.config.fqn |
아래 |
Kotest FQN 을 잊지 않는다. base package 를 바꾸면 아래 세 줄이 가리키던 클래스가 사라진다. 빌드 스크립트 안의 문자열이라 IDE 의 패키지 이름 변경이 따라가지 않는다.
systemProperty("kotest.framework.config.fqn", "com.rex.api.internal.skeleton.config.KotestUnitProjectConfig") systemProperty("kotest.framework.config.fqn", "com.rex.api.internal.skeleton.config.KotestIntegrationProjectConfig") systemProperty("kotest.framework.config.fqn", "com.rex.api.internal.skeleton.config.KotestE2eProjectConfig")
base package 를 바꿀 때는 문자열 안의 FQN 도 함께 고친다 — META-INF/spring.factories, RedisCacheSerializerFactory 의 allowIfSubType, MapStruct expression = "java(…)", 규약 테스트의 BASE 상수.
업무 스키마 2개(main @Primary · sub)를 각각 read/write 로 나눠 갖는다. 스키마 이름은 skeleton_main · skeleton_sub 이다 — application.yml 의 URL 과 docker/mysql/init/01-schemas-users.sql 을 함께 바꾼다. 하나만 쓰면 sub 를 지운다(「다중 데이터소스」 참고).
애플리케이션은 비밀값을 논리 키(${db-writer-password} 같은 kebab-case 이름)로만 참조한다. 어디서 채울지는 프로필이 고른다 — 「설정과 비밀값」 참고. 쓰지 않는 클라우드의 의존성 줄과 프로필 yml 은 지운다.
| 지울 것 | 함께 지워야 하는 테스트 |
|---|---|
Product* (main 스키마 예제), Note* (sub 스키마 예제) |
같은 이름의 단위·통합·e2e 테스트, context/product/ProductBuilder.kt |
persistence/jpa/entity/main/SoftDeletableExampleEntity.kt |
AuditingIntegrationTest · AuditorChainIntegrationTest · AuditorHeaderDistrustIntegrationTest · support/AuditProbeConfiguration.kt |
service/example/, client/example/ (비동기 프록시·WebClient 예제) |
service/example/, client/example/ 아래 테스트 |
샘플만 지우면 빌드가 깨진다. SoftDeletableExampleEntity 는 감사 계약을 검증하는 통합 테스트 셋이 저장 대상으로 쓴다. 지울 거라면 그 테스트들의 저장 대상을 프로젝트의 실제 감사 엔티티로 바꿔라 — 통째로 지우면 감사 계약을 아무도 검증하지 않게 된다. 예제를 지우고 나면 db/manual/create-*.sql 의 테이블도 정리한다.
docker compose up -d # MySQL·Redis — 스키마·계정·업무 테이블까지 만든다
./gradlew ktlintFormat && ./gradlew build
./gradlew bootRun # SPRING_PROFILES_ACTIVE 가 없으면 local 로 뜬다
curl localhost:8888/actuator/healthdocker/mysql/init 과 db/manual/*.sql 은 볼륨을 처음 만들 때만 실행된다. 바꾼 뒤에는 docker compose down -v 로 다시 만든다.
계층을 건너뛰지 않는다. Controller 가 EntityService 를 직접 부르지 않는다.
Controller HTTP 입출력. Model 만 쓴다. @Valid 로 입력 검증
│ (DTO·Entity 를 직접 쓰지 않는다)
▼
MappingService DTO ↔ Model 변환(MapStruct). 여러 BizService 를 조합해 응답을 만든다
│ 위치: service/mapping/
▼
BizService 비즈니스 로직. 트랜잭션 경계. DTO 를 쓴다
│ 위치: service/biz/
▼
EntityService 단일 Entity CRUD. Entity ↔ DTO 변환. 캐싱
│ 위치: persistence/jpa/service/, persistence/jdbc/service/
▼
Repository 순수 데이터 접근만
위치: persistence/jpa/repository/, persistence/jdbc/repository/
Controller 는 MappingService 만 주입받는다. BizService 를 직접 주입하지 않는다.
BaseAuditedEntity 를 상속한 엔티티는 created_by · updated_by 가 자동으로 채워진다. 그 값이 어디서 오는지가 보안 계약이다.
| 순위 | 출처 | 조건 |
|---|---|---|
| 1 | X-User-Id 헤더 |
app.audit.trust-user-header 가 켜진 경우에만 (기본 false) |
| 2 | Constant.SYSTEM_USER_ID |
그 외 전부 |
HeaderAuditorContextFilter 라는 이름이 계약을 말한다 — 인증 결과가 아니라 헤더에서 온다. 이 레포에는 인증 프레임워크가 없어 인증 주체를 도출할 수 없다. MDC 는 로깅 전용이고 감사에 관여하지 않는다.
플래그를 켜는 것은 이 코드가 강제하지 않는 배포 계약을 단언하는 행위다 — 게이트웨이를 통해서만 도달 가능하고, 게이트웨이가 자기 인증 결과로
X-User-Id를 덮어써 호출자가 위조할 수 없어야 한다. 그 경계 없이 켜지 않는다.
헤더가 비었거나 숫자가 아니거나 Int 범위를 벗어나면 SYSTEM_USER_ID 로 떨어진다.
이 절이 세 스켈레톤 공통 규약의 정본이다. CLAUDE.md 를 포함한 다른 문서는 여기를 가리킨다. 세 레포에서 이 절은 글자 단위로 동일해야 한다.
| 항목 | 규칙 | 올바른 예 | 잘못된 예 |
|---|---|---|---|
| Enum 클래스 | 접미사 없이 명명 | ProductStatus, OrderType |
ProductStatusEnum |
| Configuration 클래스 | Configuration 접미사 |
SecurityConfiguration |
SecurityConfig |
| DataSource 파라미터 | 명시적 이름 | mainDataSource, subDataSource |
dataSource |
| enum 패키지 | enums/ |
...enums.SampleStatus |
enum/ — Java 예약어라 MapStruct 빌드가 깨진다 |
| 역할 코드·Enum 값·상수 | 풀네임 | ADMINISTRATOR, TEAM_LEADER |
ADMIN, TL |
클래스 접미사는 그 클래스가 어느 계층에 속하는지를 이름만으로 알 수 있게 한다.
| 접미사 | 계층 | 예 |
|---|---|---|
~Model |
Controller 입출력 | SampleModel |
~Dto |
Service 간 전송 | SampleDto |
~JpaEntity · ~JdbcEntity |
DB 매핑 | SampleJpaEntity |
~JpaRepository · ~JdbcRepository |
데이터 접근 | SampleJpaRepository |
~JpaEntityService · ~JdbcEntityService |
단일 Entity CRUD | SampleJpaEntityService |
~BizService |
비즈니스 로직 | SampleBizService |
~MappingService |
Controller 입출력 변환 · BizService 호출 (응답은 ~Model) |
SampleMappingService |
~Client |
외부 API 어댑터 (client/ 패키지, @Component) |
SampleApiClient |
~Mapper |
MapStruct 변환 | SampleMapper |
~Configuration |
설정 | RedisConfiguration |
응답은 ~Model, 요청 본문은 ~Dto 를 허용한다. Controller 는 MappingService 만 주입받고 Model 을 응답한다(허용 목록: MappingService 와 util/·config/ 의 요청 경계 보조 컴포넌트 — ~Resolver·~Authorizer·~Properties — 만; 그 외 BizService·EntityService·Repository·Client·임의 서비스는 전부 위반). MappingService 는 BizService · Mapper · Properties 만 주입받는다. ~EntityService 는 Entity 를 위로 올리지 않는다 — DTO 를 돌려주거나 의도 메서드(revoke… · markUsed…)로 쓴다. 검출: ServiceNamingConventionTest · LayeringConventionTest.
1 Class per 1 File. 최상위 class·interface·object(enum·data·annotation 포함)는 파일에 하나만 둔다. 예외는 Kotlin extension(최상위 함수·프로퍼티만 있는 파일)과 sealed 계층(sealed 타입과 그 직접 하위 타입) 뿐이며 typealias·fun main 은 세지 않는다. 관련 파일은 하위 패키지로 묶는다. 검출: OneClassPerFileConventionTest(파일 이름 = 클래스 이름은 ktlint standard:filename).
Model / DTO / Entity 를 섞지 않는다. Entity 는 상위 계층에 노출하지 않는다. 변환은 MapStruct 가 한다 — 손으로 매핑 코드를 쓰지 않는다. 선언 형태(Model·DTO·JdbcEntity 는 data class + val, JPA Entity 는 class + var)·nullable·MapStruct 정책의 정본은 .claude/rules/conventions.md 「타입 선언」이다.
트랜잭션은 클래스 레벨에 @Transactional(readOnly = true) 를 기본으로 두고, 변경 메서드에만 @Transactional 을 붙인다. 메서드 레벨이 클래스 레벨을 덮는다.
모든 @Transactional 은 transactionManager 를 명시한다(primary 포함) — 데이터소스가 여럿이라 묵시는 primary 로 간다. 정본·근거·검출은 .claude/rules/conventions.md 「데이터베이스」 3.
@Async 도 같은 프록시 규칙이다 — 같은 클래스 안 호출은 동기로 돈다. 비동기는 별도 빈으로 나눈다(.claude/rules/conventions.md 「데이터베이스」 3).
문자열 값을 갖는 Enum 은 StringEnum 인터페이스를 구현한다. JPA 영속화는 AbstractStringEnumConverter, 역변환은 StringEnumUtil.fromValue() 를 쓴다. name() 을 DB 값으로 쓰지 않는다 — 이름을 바꾸면 데이터가 깨진다.
커스텀 예외는 ExceptionCode 를 구현하고 GlobalExceptionHandler 가 중앙에서 처리한다. 예외를 삼키지 않는다.
비밀값은 논리 키로만 참조한다. yml 에는 ${db-writer-password} 같은 kebab-case 논리 키만 적고 평문을 어디에도 적지 않는다. 값은 환경변수 또는 비밀값 출처 프로필(aws·azure·gcp)이 채운다 — 정본은 .claude/rules/conventions.md 「설정값·비밀값 규약」.
ktlint 는 강제다. 커밋 전에 ./gradlew ktlintFormat 을 돌린다.
소스셋이 셋이고 성격이 다르다. 새 테스트를 어디에 둘지는 「무엇에 의존하는가」로 정한다.
| 소스셋 | Gradle task | 의존 | 도구 |
|---|---|---|---|
src/test/unit |
test |
없음 — Spring Context 를 띄우지 않는다 | Kotest · MockK · FixtureMonkey |
src/test/integration |
integrationTest |
Docker — Testcontainers MySQL·Redis·LocalStack | @SpringBootTest |
src/test/e2e |
e2eTest |
docker compose 로 띄운 MySQL·Redis | TestRestTemplate |
./gradlew test # 유닛만 — 가장 빠르다
./gradlew integrationTest # Docker 필요
./gradlew check # 유닛 + 통합 (+ ktlint)
docker compose up -d && ./gradlew e2eTest # e2e 는 check 에 묶지 않는다
./gradlew test --tests "*ProductBizServiceUnitTest" # 특정 클래스만커밋 전 전체 확인은 check 다. ./gradlew test 만 돌리고 넘어가면 통합 테스트를 보지 않은 것이다. e2e 는 로컬 인프라가 필요해 check 에서 뺐다 — 필요할 때 따로 돌린다.
| 클래스 | 위치 | 용도 |
|---|---|---|
FixtureMonkeyFactory |
src/test/unit/.../context/ |
테스트 픽스처 생성 |
ProductBuilder |
src/test/unit/.../context/product/ |
도메인 빌더 예시 |
IntegrationTestConfiguration |
src/test/integration/.../config/ |
Redis·WebClient 를 Mock 으로 대체 |
TestcontainersConfiguration |
src/test/integration/.../config/ |
Testcontainers MySQL(main·sub)·Redis 기동 |
환경(dev·staging·prod)별 프로필은 없다. 환경 간 차이(호스트, 풀 크기, 로그 레벨)는 배포할 때 환경변수로 준다. 프로필은 비밀값 출처만 고른다.
| 프로필 | 비밀값 출처 | 필요한 것 |
|---|---|---|
| (없음) | 환경변수 (컨테이너, k8s Secret) | 논리 키를 환경변수로 (.env.example) |
local |
application-local.yml 의 docker compose 값 |
docker compose up -d |
aws |
AWS Secrets Manager — JSON 비밀 하나의 키 | AWS_SECRET_ID, AWS 기본 자격증명 체인 |
azure |
Azure Key Vault — 논리 키와 같은 이름의 비밀 | AZURE_KEYVAULT_ENDPOINT, DefaultAzureCredential |
gcp |
GCP Secret Manager — 논리 키와 같은 이름의 비밀 | GCP_PROJECT_ID, Application Default Credentials |
loki (조합) |
— | Loki 로그 전송. LOKI_URL (예: SPRING_PROFILES_ACTIVE=aws,loki) |
빠진 논리 키가 있으면 UnresolvedPlaceholderGuard 가 기동을 막고 빠진 키를 나열한다 — 해석되지 않은 ${…} 가 문자열 그대로 설정값이 되는 일을 막는다.
| 논리 키 | 환경변수 | 비밀 | 설명 |
|---|---|---|---|
db-host |
DB_HOST |
MySQL 쓰기 호스트 | |
db-port |
DB_PORT |
기본 3306 | |
db-read-host |
DB_READ_HOST |
읽기 복제본 호스트 (기본 db-host) |
|
db-reader-username · db-reader-password |
DB_READER_USERNAME · DB_READER_PASSWORD |
✔ | 읽기 계정 |
db-writer-username · db-writer-password |
DB_WRITER_USERNAME · DB_WRITER_PASSWORD |
✔ | 쓰기 계정 |
redis-host · redis-port |
REDIS_HOST · REDIS_PORT |
||
redis-password |
REDIS_PASSWORD |
✔ | 기본 빈 값 |
redis-ssl-enabled |
REDIS_SSL_ENABLED |
기본 false |
|
redis-managed-cluster |
REDIS_MANAGED_CLUSTER |
관리형 Redis 클러스터(단일 엔드포인트)면 true — RedisConfiguration |
|
core-api-url |
CORE_API_URL |
CoreApi 예제 클라이언트의 대상 |
Spring 은 환경변수 DB_WRITER_PASSWORD 를 논리 키 db-writer-password 로 자동 대응시킨다. 비밀 저장소에는 논리 키 이름(kebab-case) 그대로 비밀을 만든다 — Key Vault 이름 제약(영숫자·하이픈)을 만족하는 형식이다. 키를 추가하면 .env.example 과 application-gcp.yml 에도 추가한다(SecretKeyCoverageTest 가 누락을 잡는다).
# AWS — JSON 비밀 하나에 키를 모은다
aws secretsmanager create-secret --name internal-api-skeleton/prod \
--secret-string '{"db-writer-username":"app","db-writer-password":"…","db-reader-username":"ro","db-reader-password":"…"}'
SPRING_PROFILES_ACTIVE=aws AWS_SECRET_ID=internal-api-skeleton/prod DB_HOST=… java -jar app.war
# Azure — 비밀 이름 = 논리 키
az keyvault secret set --vault-name <vault> --name db-writer-password --value '…'
SPRING_PROFILES_ACTIVE=azure AZURE_KEYVAULT_ENDPOINT=https://<vault>.vault.azure.net/ DB_HOST=… java -jar app.war
# GCP — 비밀 이름 = 논리 키 (application-gcp.yml 이 키마다 ${sm@…} 로 대응시킨다)
printf '…' | gcloud secrets create db-writer-password --data-file=-
SPRING_PROFILES_ACTIVE=gcp GCP_PROJECT_ID=<project> DB_HOST=… java -jar app.war세 클라우드 starter 는 모두 runtimeOnly 로 들어 있고 기본 application.yml 에서 꺼져 있다. 하나만 쓴다면 나머지를 지운다.
| 클라우드 | build.gradle.kts |
리소스 |
|---|---|---|
| AWS | spring-cloud-aws-dependencies, spring-cloud-aws-starter-secrets-manager, testcontainers-localstack, awssdk:secretsmanager |
application-aws.yml, AwsSecretsManagerProfileIntegrationTest |
| Azure | spring-cloud-azure-dependencies, spring-cloud-azure-starter-keyvault, lowkey-vault-testcontainers |
application-azure.yml, AzureKeyVaultProfileIntegrationTest |
| GCP | spring-cloud-gcp-dependencies, spring-cloud-gcp-starter-secretmanager |
application-gcp.yml, SecretKeyCoverageTest 의 GCP 검사 |
기본 application.yml 의 spring.cloud.* 비활성 블록에서도 해당 줄을 지운다.
| 항목 | 값 |
|---|---|
| 로깅 | logback-spring.xml 하나 — local 은 컬러 콘솔, 그 외는 콘솔 + 파일(./logs), loki 를 조합하면 Loki |
| 의존성 | build.gradle.kts 에 직접 정의 — version catalog 를 쓰지 않는다 |
./gradlew ktlintFormat # 커밋 전 필수
./gradlew ktlintCheck # 검사만
./gradlew build # 빌드 + 유닛 테스트
./gradlew build -x test # 테스트 없이
./gradlew clean build
docker compose up -d
./gradlew bootRun # local 프로필업무 스키마 2개를 각각 read/write 로 나눠 갖는다. 쓰기는 db-host, 읽기는 db-read-host(없으면 db-host)로 가고, LazyConnectionDataSourceProxy 가 readOnly 트랜잭션을 읽기 풀로 보낸다.
| 스키마 | 설정 클래스 | 트랜잭션 매니저 | 예제 |
|---|---|---|---|
skeleton_main (@Primary) |
config/persistence/Main{DataSource,Jpa,Jdbc}Configuration.kt |
mainTransactionManager |
Product, Test, SoftDeletableExampleEntity |
skeleton_sub |
config/persistence/Sub{DataSource,Jpa,Jdbc}Configuration.kt |
subTransactionManager |
Note |
DataSource 빈은 반드시 명시적 이름을 갖는다(mainDataSource·subDataSource). 이름 없는 dataSource 를 만들면 어느 스키마인지 알 수 없고 주입이 모호해진다.
primary 가 아닌 스키마의 JDBC 접근은 한정자를 붙인다. NamedParameterJdbcOperations 를 한정자 없이 주입하면 @Primary(main) 가 들어온다 — NoteJdbcRepository 의 @Qualifier("subNamedParameterJdbcOperations") 가 예다. Spring Data JDBC 리포지토리는 SubJdbcConfiguration 의 jdbcOperationsRef·dataAccessStrategyRef 가 같은 일을 한다.
업무 테이블은 Flyway 로 관리하지 않는다. 정본은 src/main/resources/db/manual/create-{main,sub}.sql 이고, docker compose 초기화와 Testcontainers 가 같은 파일을 쓴다. 운영에서는 사람이 실행한다(재실행해도 기존 행을 지우지 않는다 — ManualBusinessSchemaIntegrationTest). 문자셋·콜레이션은 utf8mb4 / utf8mb4_0900_ai_ci 다.
| 계정 | 권한 | 쓰는 곳 |
|---|---|---|
skeleton_reader |
SELECT |
read 풀 |
skeleton_writer |
SELECT, INSERT, UPDATE, DELETE |
write 풀 |
client/core/CoreApi 는 @HttpExchange 선언형 클라이언트 예제다 — GET {core-api-url}/products/{id} 를 호출하고 CoreApiBizService → CoreApiMappingService 를 거쳐 /products/{id}/core 로 노출된다. 요청에는 TraceIdPropagationFilter 가 X-Trace-Id 를 실어 보낸다.
webClientUtil.postAsync(
baseUrl = "https://api.external.com",
uri = "/notifications",
headers = HttpHeaders(),
requestBody = request,
responseType = NotificationResponse::class.java,
maxRetryCount = 3,
waitRetryTime = 2,
).doOnSuccess { … }.doOnError { … }.subscribe()
webClientUtil.get(
baseUrl = "https://api.external.com",
uri = "/users/$userId",
responseType = UserInfo::class.java,
timeout = 10,
)재시도 분기가 두 예외로 갈린다 — RetryableException(5xx) 은 자동 재시도, NonRetryableException 은 즉시 실패.
@Cacheable 과 같지만 결과가 null 이면 캐싱하지 않는다. null 을 캐싱하면 뒤늦게 생긴 데이터를 TTL 동안 못 본다.
| 클래스 | 하는 일 |
|---|---|
DateExtensions |
today.inPeriod(start, end) — 시작·종료일 포함 |
LoggerExtension |
private val log = logger() |
MIT — LICENSE. 이 템플릿으로 만든 프로젝트는 LICENSE 를 지우거나 그 프로젝트의 라이선스로 바꾼다.
에이전트 작업의 진입점은 AGENTS.md다. 프로젝트 규칙은 CLAUDE.md와 README 「코딩 규약」 원본을 공유한다. .claude/rules/**/*.md가 있으면 AGENTS.md의 탐색 절차에 따라 직접 읽으며, paths가 없는 규칙은 항상 적용하고 있는 규칙은 작업 경로에 맞춰 적용한다. 규칙 본문을 별도 사본으로 복제하지 않는다.