Files
llm-wiki/raw/errors/jpa-repository-scan-miss-multimodule-2026-06-10.md
T

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
feature-rate-limit-idempotency-contract
feature-migration-startup-contract
feature-developer-experience-contract
ca-skeleton
error
ca-skeleton
spring
spring-boot
jpa
spring-data
startup
dotenv
clean-architecture
2026-06-10 resolved

error: multi-module Spring Boot JPA repository scan miss (2026-06-10)

Layer: raw/errors/ — 작업 중 마주친 단일 실패·트러블슈팅 기록. 원본은 raw에 영구 보관한다.

Parent / 부모

증상 / 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

  1. Layer 1: IDE 에서 main 클래스를 작업 디렉터리 = 워크스페이스 루트로 Run. me.paulschwarz:spring-dotenv 는 "현재 작업 디렉터리의 .env"만 읽는데 .envsrc/.env 에 있어 못 찾음 → application.ymlspring.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

  • @SpringBootApplicationdev.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.
    • 대안(미채택): @SpringBootApplicationexcludeFiltersdev.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.
  • 검증: 세 레이어 처리 후 bootRunStarted 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 처럼 모듈 의미를 담은 이름으로.