From ca63d7d3ecb6ab79279bab641f578c7b7df42430 Mon Sep 17 00:00:00 2001 From: DongHyeonka Date: Sun, 23 Aug 2026 20:14:47 +0900 Subject: [PATCH] =?UTF-8?q?fix:=20=EC=8A=A4=EC=BA=94=EB=90=98=EB=8A=94=20?= =?UTF-8?q?=EC=8A=A4=ED=94=84=EB=A7=81=20=EC=BB=B4=ED=8F=AC=EB=84=8C?= =?UTF-8?q?=ED=8A=B8=EC=9D=98=20=EC=83=9D=EC=84=B1=EC=9E=90=EB=A5=BC=20?= =?UTF-8?q?=ED=95=98=EB=82=98=EB=A1=9C=20=EA=B3=A0=EC=A0=95=ED=95=9C?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 새 활동 어댑터가 생성자를 둘 갖고 있었다 — 하나는 운영용, 하나는 테스트가 id 생성기를 넣기 위한 것. 둘 중 어느 것에도 @Autowired 가 없어 컴포넌트 스캔은 고르지 못하고 기본 생성자를 찾다가 실패했다. 컴파일도, 단위 테스트도, 실제 PostgreSQL 위에서 도는 통합 테스트 26개도 전부 통과했다. 그 어느 것도 애플리케이션 컨텍스트를 띄우지 않기 때문이다. 운영에서 파드가 CrashLoopBackOff 로 들어갔고, 그때서야 드러났다. 생성자를 하나로 줄이고 — id 는 어댑터가 만들면 되고 통합 테스트는 그 값을 볼 필요가 없다 — 같은 실수를 다시 못 하게 D20 규칙을 세운다: 스캔되는 컴포넌트는 생성자가 하나이거나, 여럿이면 그중 하나에 @Autowired 가 붙어야 한다. 규칙이 실제로 잡는지 결함을 되돌려 확인했다. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01XEHXspz4rv5pB5wiiSsVDu --- .../JdbcProjectActivityRepositoryAdapter.java | 14 ++--- .../architecture/CleanArchitectureTest.java | 63 +++++++++++++++++++ 2 files changed, 69 insertions(+), 8 deletions(-) diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/management/JdbcProjectActivityRepositoryAdapter.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/management/JdbcProjectActivityRepositoryAdapter.java index 7ff1005..758b0b4 100644 --- a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/management/JdbcProjectActivityRepositoryAdapter.java +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/management/JdbcProjectActivityRepositoryAdapter.java @@ -9,7 +9,6 @@ import java.sql.SQLException; import java.util.List; import java.util.Optional; import java.util.UUID; -import java.util.function.Supplier; import org.springframework.jdbc.core.simple.JdbcClient; import org.springframework.stereotype.Repository; @@ -30,15 +29,14 @@ public class JdbcProjectActivityRepositoryAdapter implements ProjectActivityRepo + " related_resource_type, related_resource_id, occurred_at, version"; private final JdbcClient jdbcClient; - private final Supplier idGenerator; + /* + * 생성자는 하나뿐이어야 한다. 한때 테스트용 id 생성기를 받는 두 번째 생성자가 있었고, 그러면 + * 컴포넌트 스캔이 어느 것을 쓸지 정하지 못해 기본 생성자를 찾다가 실패한다 — 컴파일도 테스트도 + * 통과하고 운영에서 기동만 못 한다. id 는 여기서 만들면 되고, 통합 테스트는 그 값을 볼 필요가 없다. + */ public JdbcProjectActivityRepositoryAdapter(JdbcClient jdbcClient) { - this(jdbcClient, UUID::randomUUID); - } - - JdbcProjectActivityRepositoryAdapter(JdbcClient jdbcClient, Supplier idGenerator) { this.jdbcClient = jdbcClient; - this.idGenerator = idGenerator; } private static ProjectActivityView map(ResultSet rs, int rowNum) throws SQLException { @@ -85,7 +83,7 @@ public class JdbcProjectActivityRepositoryAdapter implements ProjectActivityRepo @Override public ProjectActivityView create(CreateProjectActivityCommand command) { - UUID id = idGenerator.get(); + UUID id = UUID.randomUUID(); jdbcClient .sql( "INSERT INTO project_activity (id, project_id, activity_type, title, summary," diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/CleanArchitectureTest.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/CleanArchitectureTest.java index 74be600..e89c68c 100644 --- a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/CleanArchitectureTest.java +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/CleanArchitectureTest.java @@ -2158,4 +2158,67 @@ class CleanArchitectureTest { .filter(value -> value instanceof Number number && number.intValue() == SqlTypes.UUID) .isPresent(); } + + /** + * Spring 이 생성자를 고를 수 있어야 한다. + * + *

이 규칙은 사고 하나에서 나왔다. 한 어댑터가 생성자를 둘 갖고 있었고 — 하나는 운영용, 하나는 테스트가 id 생성기를 넣기 위한 것 — 둘 중 어느 것에도 + * {@code @Autowired} 가 없었다. 컴포넌트 스캔은 고르지 못하고 기본 생성자를 찾다가 실패한다. 컴파일도, 단위 테스트도, 실제 PostgreSQL 위에서 + * 도는 통합 테스트도 전부 통과했다. 그 어느 것도 컨텍스트를 띄우지 않기 때문이다. 운영에서 기동만 못 했다. + * + *

그래서 여기서 막는다: 스캔되는 스프링 컴포넌트는 생성자가 하나이거나, 여럿이면 그중 하나에 {@code @Autowired} 가 붙어 있어야 한다. + */ + @ArchTest + static final ArchRule SPRING_COMPONENTS_HAVE_AN_UNAMBIGUOUS_CONSTRUCTOR = + classes() + .that() + .areAnnotatedWith("org.springframework.stereotype.Repository") + .or() + .areAnnotatedWith("org.springframework.stereotype.Service") + .or() + .areAnnotatedWith("org.springframework.stereotype.Component") + .or() + .areAnnotatedWith("org.springframework.web.bind.annotation.RestController") + .should(haveAConstructorSpringCanChoose()) + .as( + "D20: a scanned Spring component must have exactly one constructor, or mark one" + + " @Autowired — otherwise component scan falls back to a no-arg constructor that" + + " does not exist and the application fails to start"); + + private static ArchCondition haveAConstructorSpringCanChoose() { + return new ArchCondition<>("have a constructor Spring can choose") { + @Override + public void check(JavaClass item, ConditionEvents events) { + var constructors = + item.getConstructors().stream() + .filter(constructor -> !constructor.getModifiers().contains(JavaModifier.SYNTHETIC)) + .toList(); + if (constructors.size() <= 1) { + return; + } + boolean autowired = + constructors.stream() + .anyMatch( + constructor -> + constructor.getAnnotations().stream() + .anyMatch( + annotation -> + annotation + .getRawType() + .getName() + .equals( + "org.springframework.beans.factory.annotation.Autowired"))); + if (!autowired) { + events.add( + SimpleConditionEvent.violated( + item, + item.getName() + + " declares " + + constructors.size() + + " constructors and marks none @Autowired;" + + " component scan cannot choose one")); + } + } + }; + } }