11 KiB
11 KiB
title, source_type, status, related_branches, related_projects, tags, created, status_label
| title | source_type | status | related_branches | related_projects | tags | created | status_label | |||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| error / multi-module Spring Boot JPA repository scan miss (2026-06-10) | error-note | raw |
|
|
|
2026-06-10 | resolved |
error: multi-module Spring Boot JPA repository scan miss (2026-06-10)
Layer:
raw/errors/— 작업 중 마주친 단일 실패·트러블슈팅 기록. 원본은 raw에 영구 보관한다.
Parent / 부모
- raw/branch-notes/feature-rate-limit-idempotency-contract — 이 feature 가 들인 첫 프로덕션 JPA 리포지토리(
IdempotencyRecordJpaRepository)의 스캔 등록 누락이 근본 원인. - raw/branch-notes/feature-migration-startup-contract — fail-fast migration runner 가 2차 레이어(DB 미가동)를 명확한 startup 에러로 노출.
- raw/branch-notes/feature-developer-experience-contract — IDE 직접 실행 시
src/.env미로딩(1차 레이어)은 dev-experience 영역. - raw/project-notes/ca-skeleton-operational-contract — ca-tmpl 런타임 기동 계약.
증상 / Symptom
"서버 실행이 안 된다"는 단일 호소 뒤에 3겹의 서로 다른 실패가 있었다. IDE 직접 실행과 ./gradlew bootRun 이 서로 다른 에러를 뱉어 혼란을 키웠다.
Layer 1 — IDE 직접 실행: 프로파일 바인딩 실패 (env 미로딩)
사용자가 VS Code 에서 main 클래스를 직접 Run (java @argfile dev.caskeleton.bootstrap.CaSkeletonApplication):
APPLICATION FAILED TO START
Failed to bind properties under 'spring.profiles.active' to java.util.Set<java.lang.String>:
Property: spring.profiles.active
Value: "${SPRING_PROFILES_ACTIVE}"
Reason: Profile '${SPRING_PROFILES_ACTIVE}' must contain a letter, digit or allowed char ('-', '_', '.', '+', '@')
Layer 2 — ./gradlew bootRun + DB 미가동: Flyway 연결 거부
startup failure in phase startup.phase=migration: Flyway forward-only migration failed during startup
org.flywaydb.core.internal.exception.FlywaySqlException: Unable to obtain connection from database:
Connection to localhost:5432 refused.
SQL State : 08001
at dev.caskeleton.bootstrap.runtime.startup.MigrationStartupRunner.migrate(MigrationStartupRunner.java:47)
Layer 3 (진짜 버그) — DB 가동 후: JPA 리포지토리 빈 부재
APPLICATION FAILED TO START
Parameter 0 of constructor in dev.caskeleton.adapter.persistence.idempotency.IdempotencyReaper
required a bean of type 'dev.caskeleton.adapter.persistence.idempotency.IdempotencyRecordJpaRepository'
that could not be found.
Layer 4 (Layer 3 수정의 부작용) — IDE 재실행: 빈 이름 충돌
Layer 3 을 JpaConfig 추가로 고친 뒤 사용자가 IDE 에서 main 클래스를 다시 실행하자:
APPLICATION FAILED TO START
ConflictingBeanDefinitionException: Annotation-specified bean name 'jpaConfig' for bean class
[dev.caskeleton.sample.portfolio.adapter.persistence.config.JpaConfig] conflicts with existing,
non-compatible bean definition of same name and class [dev.caskeleton.adapter.persistence.config.JpaConfig]
- IDE 가 생성한 argfile 클래스패스에
sample-portfolio/build/classes/java/main이 포함됨 → IDE main-클래스 실행이 test 스코프를 끌어옴.bootRun은 sample 을testImplementation으로 제외하므로 이 충돌이 안 보였다(검증 맹점). - production ↔ sample 동일 simple 클래스명 =
{JpaConfig, package-info}.package-info는 빈이 아니므로 충돌 빈은JpaConfig하나 (둘 다@Configuration→ 디폴트 빈 이름jpaConfig). - 재현 가능 여부:
always(환경 조건만 갖추면 결정적).
재현 절차 / Reproduction
- Layer 1: IDE 에서 main 클래스를 작업 디렉터리 = 워크스페이스 루트로 Run.
me.paulschwarz:spring-dotenv는 "현재 작업 디렉터리의.env"만 읽는데.env는src/.env에 있어 못 찾음 →application.yml의spring.profiles.active: ${SPRING_PROFILES_ACTIVE}(인라인 기본값 없음) 미치환 → 리터럴 문자열이 프로파일명이 되어 바인딩 즉사. - Layer 2:
cd src && ./gradlew bootRun(작업 디렉터리 src/ 라.env로드됨 → profile=local 해석) 하되 localhost:5432 에 Postgres 없음 →MigrationStartupRunner의 Flyway 가 연결 실패로 fail-fast(설계대로). - Layer 3: Postgres 기동 후
bootRun→ Flyway V1 적용 성공 → 그러나IdempotencyReaper생성자가IdempotencyRecordJpaRepository를 요구하는데 그 Spring Data 리포지토리 빈이 컨텍스트에 없어UnsatisfiedDependencyException.
원인 / Root cause
@SpringBootApplication은dev.caskeleton.bootstrap에 있다. Spring Boot 의 JPA 엔티티/리포지토리 자동 스캔 기준 패키지는@AutoConfigurationPackage(=@SpringBootApplication이 위치한 패키지)이며dev.caskeleton.bootstrap하위만 스캔한다.@SpringBootApplication(scanBasePackages = "dev.caskeleton")는 컴포넌트 스캔만 넓힌다. JPA 엔티티/리포지토리 스캔에는 영향이 없다 — 흔한 오해.- 따라서
dev.caskeleton.adapter.persistence.idempotency.IdempotencyRecordJpaRepository는 스캔 대상 밖 → 리포지토리 프록시 빈 미생성 → 이를 주입받는IdempotencyReaperwiring 실패. spring-boot-starter-data-jpa는 존재(adapter-persistence)하므로 JPA 자동설정 자체는 켜져 있었다. 스캔 패키지만 어긋난 것.- idempotency feature 가 adapter-persistence 에 첫 프로덕션 JPA 리포지토리/엔티티를 들였지만, composition root 에 대응하는
@EntityScan/@EnableJpaRepositories등록을 빠뜨렸다.sample-portfolio는 자기 패키지용JpaConfig를 이미 갖고 있었는데(adapter.persistence.config.JpaConfig), 그 선례가 프로덕션 모듈로 복제되지 않았다.
해결 / Resolution
- Layer 3 (프로덕션 코드):
src/adapter-persistence/.../config/PersistenceJpaConfig.java신설 —@Configuration @EntityScan(basePackages="dev.caskeleton.adapter.persistence") @EnableJpaRepositories(basePackages="dev.caskeleton.adapter.persistence").scanBasePackages="dev.caskeleton"컴포넌트 스캔이 이@Configuration을 픽업한다. 스캔 기준을 모듈 루트로 잡아 향후 추가 엔티티/리포지토리까지 커버.- 왜 app-bootstrap 이 아니라 adapter-persistence 인가: 처음엔 app-bootstrap 에 뒀더니
package org.springframework.data.jpa.repository.config does not exist컴파일 에러.spring-boot-starter-data-jpa가 adapter-persistence 의implementation의존(= API 미누출, CAapivsimplementation정책)이라 app-bootstrap 컴파일 클래스패스에@EnableJpaRepositories가 없다. JPA 설정은 JPA 를 소유한 모듈에 둬야 경계와 클래스패스가 동시에 맞는다. sample-portfolio 가 자기 JpaConfig 를 persistence 패키지에 둔 이유와 동일.
- 왜 app-bootstrap 이 아니라 adapter-persistence 인가: 처음엔 app-bootstrap 에 뒀더니
- Layer 4 (클래스명): 처음엔 프로덕션 클래스명을
JpaConfig로 지었더니 IDE 실행에서 sample 의 동명JpaConfig와 빈 이름 충돌.PersistenceJpaConfig로 rename 하여 디폴트 빈 이름을persistenceJpaConfig로 분리. production↔sample 충돌 빈이JpaConfig하나뿐이라 rename 으로 완결(whack-a-mole 아님). IDE 가 쓴 실제 argfile(sample 포함) 그대로 재현 →Started CaSkeletonApplication. bootRun(sample 없음)도 green.- 대안(미채택):
@SpringBootApplication에excludeFilters로dev.caskeleton.sample.portfolio..*를 production 스캔에서 제외(IDE 실행도 production 처럼 sample 미로딩). 더 architecture-honest 하지만@ComponentScan이중 스캔 의미가 까다롭고 blast radius 가 커서, 결정적이고 저위험인 rename 을 택함. production-fidelity 가 필요하면bootRun/Spring Boot Dashboard 사용 권고.
- 대안(미채택):
- Layer 1 (IDE dev-experience):
.vscode/launch.json신설 —"cwd": "${workspaceFolder}/src"+"envFile": "${workspaceFolder}/src/.env"로 IDE 직접 실행도bootRun과 동일하게src/.env를 로드. - Layer 2 (환경):
.env값과 일치하는 Postgres 를docker run으로 기동(레포의docker-compose*.yml3개는 0바이트 플레이스홀더라 turnkey 아님):docker run --name ca-pg -p 5432:5432 -e POSTGRES_DB=ca_skeleton -e POSTGRES_USER=ca_skeleton -e POSTGRES_PASSWORD=ca_skeleton -d postgres:16. - 검증: 세 레이어 처리 후
bootRun→Started CaSkeletonApplication in 3.463 seconds.verifyCleanArchitectureDependencies/:app-bootstrap:test --tests '*CleanArchitectureTest'/:adapter-persistence:test모두 PASS.
교훈 / Lesson
./gradlew check그린 ≠ 부팅 가능. idempotency 브랜치 노트는 "check 전체 PASS"를 기록했지만 프로덕션 컨텍스트를 실제로 띄우는 full-context boot test 가 없어 이 wiring 누락이 통과됐다. 멀티모듈 Spring Boot 에서 프로덕션 데이터소스로 컨텍스트를 로드하는 smoke test(Testcontainers Postgres 등)가 있었다면 즉시 잡혔다 — 후속 권고.scanBasePackages는 JPA 스캔을 넓히지 않는다. 멀티모듈에서 어댑터 패키지가@SpringBootApplication패키지 밖이면@EntityScan/@EnableJpaRepositories를 명시해야 한다. 모듈이 첫 JPA 리포지토리를 가질 때가 이 설정을 추가할 시점.- CA
implementationvsapi경계가 설정 클래스의 거주 모듈을 강제한다. 프레임워크 설정 어노테이션은 그 의존을implementation으로 가진 모듈 안에서만 컴파일된다 → "JPA 설정은 JPA 소유 모듈에" 가 자연 귀결. - 하나의 "안 돼요"가 여러 레이어일 수 있다. IDE 실행과
bootRun의 에러가 달랐던 건 env 로딩 경로 차이 때문. 사용자 환경의 실제 에러 텍스트를 먼저 확보하지 않고 내 재현만 믿었다면 1차(env) 레이어를 놓쳤을 것. - IDE "Run main class" 는 test 스코프를 끌어온다 →
bootRun과 클래스패스가 다르다.testImplementation project(':sample-portfolio')인데도 IDE argfile 에 sample main 산출물이 들어왔다. 그래서bootRun검증만 믿으면 IDE-only 충돌을 놓친다. IDE 경로를 검증하려면 IDE 가 만든 실제 argfile 로 재현하는 게 가장 충실하다. - 같은 component-scan 루트(
dev.caskeleton) 아래 모듈 간 동일 simple 클래스명을 피하라. 두@Configuration이 같은 simple 명이면 디폴트 빈 이름이 충돌(ConflictingBeanDefinitionException)한다. fixture(sample)와 production 이 둘 다JpaConfig였던 게 화근 — production 은PersistenceJpaConfig처럼 모듈 의미를 담은 이름으로.