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:
DongHyeonka
2026-08-23 20:14:47 +09:00
co-authored by Claude Opus 5
parent 4c14f1eb8f
commit ca63d7d3ec
2 changed files with 69 additions and 8 deletions
@@ -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<UUID> idGenerator;
/*
* 생성자는 하나뿐이어야 한다. 한때 테스트용 id 생성기를 받는 두 번째 생성자가 있었고, 그러면
* 컴포넌트 스캔이 어느 것을 쓸지 정하지 못해 기본 생성자를 찾다가 실패한다 — 컴파일도 테스트도
* 통과하고 운영에서 기동만 못 한다. id 는 여기서 만들면 되고, 통합 테스트는 그 값을 볼 필요가 없다.
*/
public JdbcProjectActivityRepositoryAdapter(JdbcClient jdbcClient) {
this(jdbcClient, UUID::randomUUID);
}
JdbcProjectActivityRepositoryAdapter(JdbcClient jdbcClient, Supplier<UUID> 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,"
@@ -2158,4 +2158,67 @@ class CleanArchitectureTest {
.filter(value -> value instanceof Number number && number.intValue() == SqlTypes.UUID)
.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"));
}
}
};
}
}