fix: 스캔되는 스프링 컴포넌트의 생성자를 하나로 고정한다
새 활동 어댑터가 생성자를 둘 갖고 있었다 — 하나는 운영용, 하나는 테스트가 id 생성기를 넣기 위한 것. 둘 중 어느 것에도 @Autowired 가 없어 컴포넌트 스캔은 고르지 못하고 기본 생성자를 찾다가 실패했다. 컴파일도, 단위 테스트도, 실제 PostgreSQL 위에서 도는 통합 테스트 26개도 전부 통과했다. 그 어느 것도 애플리케이션 컨텍스트를 띄우지 않기 때문이다. 운영에서 파드가 CrashLoopBackOff 로 들어갔고, 그때서야 드러났다. 생성자를 하나로 줄이고 — id 는 어댑터가 만들면 되고 통합 테스트는 그 값을 볼 필요가 없다 — 같은 실수를 다시 못 하게 D20 규칙을 세운다: 스캔되는 컴포넌트는 생성자가 하나이거나, 여럿이면 그중 하나에 @Autowired 가 붙어야 한다. 규칙이 실제로 잡는지 결함을 되돌려 확인했다. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XEHXspz4rv5pB5wiiSsVDu
This commit is contained in:
co-authored by
Claude Opus 5
parent
4c14f1eb8f
commit
ca63d7d3ec
+6
-8
@@ -9,7 +9,6 @@ import java.sql.SQLException;
|
|||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
import java.util.function.Supplier;
|
|
||||||
import org.springframework.jdbc.core.simple.JdbcClient;
|
import org.springframework.jdbc.core.simple.JdbcClient;
|
||||||
import org.springframework.stereotype.Repository;
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
@@ -30,15 +29,14 @@ public class JdbcProjectActivityRepositoryAdapter implements ProjectActivityRepo
|
|||||||
+ " related_resource_type, related_resource_id, occurred_at, version";
|
+ " related_resource_type, related_resource_id, occurred_at, version";
|
||||||
|
|
||||||
private final JdbcClient jdbcClient;
|
private final JdbcClient jdbcClient;
|
||||||
private final Supplier<UUID> idGenerator;
|
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 생성자는 하나뿐이어야 한다. 한때 테스트용 id 생성기를 받는 두 번째 생성자가 있었고, 그러면
|
||||||
|
* 컴포넌트 스캔이 어느 것을 쓸지 정하지 못해 기본 생성자를 찾다가 실패한다 — 컴파일도 테스트도
|
||||||
|
* 통과하고 운영에서 기동만 못 한다. id 는 여기서 만들면 되고, 통합 테스트는 그 값을 볼 필요가 없다.
|
||||||
|
*/
|
||||||
public JdbcProjectActivityRepositoryAdapter(JdbcClient jdbcClient) {
|
public JdbcProjectActivityRepositoryAdapter(JdbcClient jdbcClient) {
|
||||||
this(jdbcClient, UUID::randomUUID);
|
|
||||||
}
|
|
||||||
|
|
||||||
JdbcProjectActivityRepositoryAdapter(JdbcClient jdbcClient, Supplier<UUID> idGenerator) {
|
|
||||||
this.jdbcClient = jdbcClient;
|
this.jdbcClient = jdbcClient;
|
||||||
this.idGenerator = idGenerator;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static ProjectActivityView map(ResultSet rs, int rowNum) throws SQLException {
|
private static ProjectActivityView map(ResultSet rs, int rowNum) throws SQLException {
|
||||||
@@ -85,7 +83,7 @@ public class JdbcProjectActivityRepositoryAdapter implements ProjectActivityRepo
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public ProjectActivityView create(CreateProjectActivityCommand command) {
|
public ProjectActivityView create(CreateProjectActivityCommand command) {
|
||||||
UUID id = idGenerator.get();
|
UUID id = UUID.randomUUID();
|
||||||
jdbcClient
|
jdbcClient
|
||||||
.sql(
|
.sql(
|
||||||
"INSERT INTO project_activity (id, project_id, activity_type, title, summary,"
|
"INSERT INTO project_activity (id, project_id, activity_type, title, summary,"
|
||||||
|
|||||||
+63
@@ -2158,4 +2158,67 @@ class CleanArchitectureTest {
|
|||||||
.filter(value -> value instanceof Number number && number.intValue() == SqlTypes.UUID)
|
.filter(value -> value instanceof Number number && number.intValue() == SqlTypes.UUID)
|
||||||
.isPresent();
|
.isPresent();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Spring 이 생성자를 고를 수 있어야 한다.
|
||||||
|
*
|
||||||
|
* <p>이 규칙은 사고 하나에서 나왔다. 한 어댑터가 생성자를 둘 갖고 있었고 — 하나는 운영용, 하나는 테스트가 id 생성기를 넣기 위한 것 — 둘 중 어느 것에도
|
||||||
|
* {@code @Autowired} 가 없었다. 컴포넌트 스캔은 고르지 못하고 기본 생성자를 찾다가 실패한다. 컴파일도, 단위 테스트도, 실제 PostgreSQL 위에서
|
||||||
|
* 도는 통합 테스트도 전부 통과했다. 그 어느 것도 컨텍스트를 띄우지 않기 때문이다. 운영에서 기동만 못 했다.
|
||||||
|
*
|
||||||
|
* <p>그래서 여기서 막는다: 스캔되는 스프링 컴포넌트는 생성자가 하나이거나, 여럿이면 그중 하나에 {@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<JavaClass> 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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user