--- title: error / multi-module Spring Boot JPA repository scan miss (2026-06-10) source_type: error-note status: raw related_branches: [feature-rate-limit-idempotency-contract, feature-migration-startup-contract, feature-developer-experience-contract] related_projects: [ca-skeleton] tags: [error, ca-skeleton, spring, spring-boot, jpa, spring-data, startup, dotenv, clean-architecture] created: 2026-06-10 status_label: 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`): ```text APPLICATION FAILED TO START Failed to bind properties under 'spring.profiles.active' to java.util.Set: 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 연결 거부 ```text 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 리포지토리 빈 부재 ```text 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 클래스를 다시 실행하자: ```text 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 1. **Layer 1**: IDE 에서 main 클래스를 작업 디렉터리 = 워크스페이스 루트로 Run. `me.paulschwarz:spring-dotenv` 는 "현재 작업 디렉터리의 `.env`"만 읽는데 `.env` 는 `src/.env` 에 있어 못 찾음 → `application.yml` 의 `spring.profiles.active: ${SPRING_PROFILES_ACTIVE}`(인라인 기본값 없음) 미치환 → 리터럴 문자열이 프로파일명이 되어 바인딩 즉사. 2. **Layer 2**: `cd src && ./gradlew bootRun` (작업 디렉터리 src/ 라 `.env` 로드됨 → profile=local 해석) 하되 localhost:5432 에 Postgres 없음 → `MigrationStartupRunner` 의 Flyway 가 연결 실패로 fail-fast(설계대로). 3. **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` 는 스캔 대상 밖 → 리포지토리 프록시 빈 미생성 → 이를 주입받는 `IdempotencyReaper` wiring 실패. - `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 미누출, CA `api` vs `implementation` 정책)이라 app-bootstrap 컴파일 클래스패스에 `@EnableJpaRepositories` 가 없다. JPA 설정은 **JPA 를 소유한 모듈**에 둬야 경계와 클래스패스가 동시에 맞는다. sample-portfolio 가 자기 JpaConfig 를 persistence 패키지에 둔 이유와 동일. - **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*.yml` 3개는 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 `implementation` vs `api` 경계가 설정 클래스의 거주 모듈을 강제한다.** 프레임워크 설정 어노테이션은 그 의존을 `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` 처럼 모듈 의미를 담은 이름으로.