Transaction Event Registry ArchUnit — 자동 검증 규칙
TransactionEventRegistry의 아키텍처 규칙을 ArchUnit으로 자동 검증합니다.
빌드 시 규칙 위반이 감지되면 빌드 실패로 강제합니다.
1) 검증 규칙 요약
| 카테고리 |
규칙 |
심각도 |
| 기본 구조 |
@Component 어노테이션 필수 |
필수 |
| 기본 구조 |
*EventRegistry 접미사 필수 |
필수 |
| 기본 구조 |
common.config 패키지 위치 |
필수 |
| 기본 구조 |
final 클래스 금지 |
필수 |
| 의존성 |
ApplicationEventPublisher 의존 필수 |
필수 |
| 금지 |
ThreadLocal 사용 금지 |
필수 |
| 금지 |
Lombok 금지 |
필수 |
| 금지 |
@Transactional 금지 |
필수 |
2) ArchUnit 테스트 코드
파일 위치
application/src/test/java/
└─ com/ryuqq/application/architecture/event/
└─ TransactionEventRegistryArchTest.java
전체 코드
package com.ryuqq.application.architecture.event;
import com.tngtech.archunit.core.domain.JavaClasses;
import com.tngtech.archunit.core.importer.ClassFileImporter;
import com.tngtech.archunit.lang.ArchRule;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Component;
import static com.tngtech.archunit.core.domain.JavaModifier.FINAL;
import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.classes;
import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.fields;
import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses;
import static org.junit.jupiter.api.Assumptions.assumeTrue;
/**
* TransactionEventRegistry ArchUnit 검증 테스트 (Zero-Tolerance)
*
* <p>핵심 철학: Registry는 커밋 후 Event 발행을 보장, ThreadLocal 금지</p>
*/
@DisplayName("TransactionEventRegistry ArchUnit Tests (Zero-Tolerance)")
@Tag("architecture")
@Tag("event")
class TransactionEventRegistryArchTest {
private static JavaClasses classes;
private static boolean hasEventRegistryClasses;
@BeforeAll
static void setUp() {
classes = new ClassFileImporter()
.importPackages("com.ryuqq.application");
hasEventRegistryClasses = classes.stream()
.anyMatch(javaClass -> javaClass.getSimpleName().endsWith("EventRegistry"));
}
// ==================== 기본 구조 규칙 ====================
@Nested
@DisplayName("기본 구조 규칙")
class BasicStructureRules {
@Test
@DisplayName("[필수] EventRegistry는 @Component 어노테이션을 가져야 한다")
void eventRegistry_MustHaveComponentAnnotation() {
assumeTrue(hasEventRegistryClasses, "EventRegistry 클래스가 없어 테스트를 스킵합니다");
ArchRule rule = classes()
.that().haveSimpleNameEndingWith("EventRegistry")
.should().beAnnotatedWith(Component.class)
.because("EventRegistry는 Spring Bean으로 등록되어야 합니다");
rule.check(classes);
}
@Test
@DisplayName("[필수] config 패키지의 Registry 클래스는 'EventRegistry' 접미사를 가져야 한다")
void config_MustHaveCorrectSuffix() {
assumeTrue(hasEventRegistryClasses, "EventRegistry 클래스가 없어 테스트를 스킵합니다");
ArchRule rule = classes()
.that().resideInAPackage("..application..config..")
.and().haveSimpleNameContaining("Event")
.and().haveSimpleNameContaining("Registry")
.and().areNotInterfaces()
.should().haveSimpleNameEndingWith("EventRegistry")
.because("Registry는 'EventRegistry' 접미사를 사용해야 합니다");
rule.check(classes);
}
@Test
@DisplayName("[필수] EventRegistry는 ..application..config.. 패키지에 위치해야 한다")
void eventRegistry_MustBeInCorrectPackage() {
assumeTrue(hasEventRegistryClasses, "EventRegistry 클래스가 없어 테스트를 스킵합니다");
ArchRule rule = classes()
.that().haveSimpleNameEndingWith("EventRegistry")
.should().resideInAPackage("..application..config..")
.because("EventRegistry는 application.common.config 패키지에 위치해야 합니다");
rule.check(classes);
}
@Test
@DisplayName("[필수] EventRegistry는 final 클래스가 아니어야 한다")
void eventRegistry_MustNotBeFinal() {
assumeTrue(hasEventRegistryClasses, "EventRegistry 클래스가 없어 테스트를 스킵합니다");
ArchRule rule = classes()
.that().haveSimpleNameEndingWith("EventRegistry")
.should().notHaveModifier(FINAL)
.because("Spring 프록시 생성을 위해 EventRegistry가 final이 아니어야 합니다");
rule.check(classes);
}
}
// ==================== 의존성 규칙 ====================
@Nested
@DisplayName("의존성 규칙")
class DependencyRules {
@Test
@DisplayName("[필수] EventRegistry는 ApplicationEventPublisher를 의존해야 한다")
void eventRegistry_MustDependOnApplicationEventPublisher() {
assumeTrue(hasEventRegistryClasses, "EventRegistry 클래스가 없어 테스트를 스킵합니다");
ArchRule rule = classes()
.that().haveSimpleNameEndingWith("EventRegistry")
.should().dependOnClassesThat().areAssignableTo(ApplicationEventPublisher.class)
.because("EventRegistry는 ApplicationEventPublisher로 Event를 발행해야 합니다");
rule.check(classes);
}
@Test
@DisplayName("[필수] EventRegistry는 Application/Domain Layer만 의존해야 한다")
void eventRegistry_MustOnlyDependOnApplicationAndDomainLayers() {
assumeTrue(hasEventRegistryClasses, "EventRegistry 클래스가 없어 테스트를 스킵합니다");
ArchRule rule = classes()
.that().haveSimpleNameEndingWith("EventRegistry")
.should().onlyAccessClassesThat()
.resideInAnyPackage(
"com.ryuqq.application..",
"com.ryuqq.domain..",
"org.springframework..",
"java..",
"jakarta.."
)
.because("EventRegistry는 Application Layer와 Domain Layer만 의존해야 합니다");
rule.check(classes);
}
}
// ==================== 금지 규칙 (Zero-Tolerance) ====================
@Nested
@DisplayName("금지 규칙 (Zero-Tolerance)")
class ProhibitionRules {
@Test
@DisplayName("[금지] EventRegistry는 ThreadLocal을 사용하지 않아야 한다")
void eventRegistry_MustNotUseThreadLocal() {
assumeTrue(hasEventRegistryClasses, "EventRegistry 클래스가 없어 테스트를 스킵합니다");
ArchRule rule = noClasses()
.that().haveSimpleNameEndingWith("EventRegistry")
.should().dependOnClassesThat().areAssignableTo(ThreadLocal.class)
.because("ThreadLocal은 Virtual Thread에서 문제가 발생합니다. " +
"TransactionSynchronizationManager를 사용하세요.");
rule.check(classes);
}
@Test
@DisplayName("[금지] EventRegistry는 @Transactional을 가지지 않아야 한다")
void eventRegistry_MustNotHaveTransactionalAnnotation() {
assumeTrue(hasEventRegistryClasses, "EventRegistry 클래스가 없어 테스트를 스킵합니다");
ArchRule rule = noClasses()
.that().haveSimpleNameEndingWith("EventRegistry")
.should().beAnnotatedWith("org.springframework.transaction.annotation.Transactional")
.because("EventRegistry는 트랜잭션을 열지 않습니다. " +
"호출자(Facade)가 트랜잭션을 관리합니다.");
rule.check(classes);
}
@Test
@DisplayName("[금지] EventRegistry는 Lombok 어노테이션을 가지지 않아야 한다")
void eventRegistry_MustNotUseLombok() {
assumeTrue(hasEventRegistryClasses, "EventRegistry 클래스가 없어 테스트를 스킵합니다");
ArchRule rule = noClasses()
.that().haveSimpleNameEndingWith("EventRegistry")
.should().beAnnotatedWith("lombok.Data")
.orShould().beAnnotatedWith("lombok.Builder")
.orShould().beAnnotatedWith("lombok.Getter")
.orShould().beAnnotatedWith("lombok.Setter")
.orShould().beAnnotatedWith("lombok.AllArgsConstructor")
.orShould().beAnnotatedWith("lombok.NoArgsConstructor")
.orShould().beAnnotatedWith("lombok.RequiredArgsConstructor")
.because("EventRegistry는 Plain Java를 사용해야 합니다 (Lombok 금지)");
rule.check(classes);
}
@Test
@DisplayName("[금지] EventRegistry는 @Service 어노테이션을 가지지 않아야 한다")
void eventRegistry_MustNotHaveServiceAnnotation() {
assumeTrue(hasEventRegistryClasses, "EventRegistry 클래스가 없어 테스트를 스킵합니다");
ArchRule rule = noClasses()
.that().haveSimpleNameEndingWith("EventRegistry")
.should().beAnnotatedWith("org.springframework.stereotype.Service")
.because("EventRegistry는 @Service가 아닌 @Component를 사용해야 합니다");
rule.check(classes);
}
}
// ==================== 필드 규칙 ====================
@Nested
@DisplayName("필드 규칙")
class FieldRules {
@Test
@DisplayName("[권장] EventRegistry 필드는 final이어야 한다")
void eventRegistry_FieldsShouldBeFinal() {
assumeTrue(hasEventRegistryClasses, "EventRegistry 클래스가 없어 테스트를 스킵합니다");
ArchRule rule = fields()
.that().areDeclaredInClassesThat().haveSimpleNameEndingWith("EventRegistry")
.and().areNotStatic()
.should().beFinal()
.because("EventRegistry는 불변성을 위해 생성자 주입을 사용해야 합니다 (final 필드)");
rule.check(classes);
}
}
}
3) 규칙 상세 설명
기본 구조 규칙
| 규칙 |
설명 |
위반 시 |
@Component 필수 |
Spring Bean 등록 |
빌드 실패 |
*EventRegistry 접미사 |
일관된 네이밍 |
빌드 실패 |
config 패키지 |
올바른 위치 |
빌드 실패 |
final 금지 |
프록시 생성 |
빌드 실패 |
의존성 규칙
| 규칙 |
설명 |
위반 시 |
ApplicationEventPublisher 의존 |
Event 발행 |
빌드 실패 |
| Layer 제한 |
아키텍처 준수 |
빌드 실패 |
금지 규칙
| 규칙 |
이유 |
위반 시 |
ThreadLocal 금지 |
Virtual Thread 안전 |
빌드 실패 |
@Transactional 금지 |
Facade 책임 |
빌드 실패 |
Lombok 금지 |
Plain Java |
빌드 실패 |
@Service 금지 |
@Component 사용 |
빌드 실패 |
필드 규칙
| 규칙 |
설명 |
위반 시 |
final 필드 |
불변성 |
경고 |
4) 관련 문서
작성자: Development Team
최종 수정일: 2025-12-04
버전: 1.0.0