Security ArchUnit — 보안 컴포넌트 아키텍처 검증
목적: Security 관련 클래스의 아키텍처 규칙을 ArchUnit으로 자동 검증
Zero-Tolerance: 보안 아키텍처 위반 시 빌드 실패
1️⃣ 검증 대상 컴포넌트
| 컴포넌트 |
패키지 위치 |
검증 항목 |
| ApiPaths |
auth/paths/ |
final 클래스, private 생성자, static final 필드 |
| SecurityPaths |
auth/paths/ |
final 클래스, private 생성자, static final 배열 |
| Filter |
auth/filter/ |
OncePerRequestFilter 상속, 네이밍 |
| Handler |
auth/handler/ |
Spring Security 인터페이스 구현 |
| Component |
auth/component/ |
@Component 어노테이션, SRP |
| Config |
auth/config/ |
@Configuration 어노테이션 |
2️⃣ ArchUnit 테스트 코드
SecurityArchTest.java
package com.company.adapter.in.rest.architecture.security;
import com.tngtech.archunit.core.domain.JavaClass;
import com.tngtech.archunit.core.domain.JavaClasses;
import com.tngtech.archunit.core.domain.JavaField;
import com.tngtech.archunit.core.domain.JavaModifier;
import com.tngtech.archunit.core.importer.ClassFileImporter;
import com.tngtech.archunit.core.importer.ImportOption;
import com.tngtech.archunit.lang.ArchCondition;
import com.tngtech.archunit.lang.ConditionEvents;
import com.tngtech.archunit.lang.SimpleConditionEvent;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.security.web.access.AccessDeniedHandler;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.classes;
import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.fields;
/**
* Security 컴포넌트 ArchUnit 테스트
*
* <p>보안 관련 클래스의 아키텍처 규칙을 검증합니다.
*
* @author development-team
* @since 1.0.0
*/
@DisplayName("Security ArchUnit 테스트")
class SecurityArchTest {
private static JavaClasses importedClasses;
@BeforeAll
static void setUp() {
importedClasses = new ClassFileImporter()
.withImportOption(ImportOption.Predefined.DO_NOT_INCLUDE_TESTS)
.importPackages("com.company.adapter.in.rest.auth");
}
// ========================================
// API Paths 규칙
// ========================================
@Nested
@DisplayName("ApiPaths 규칙")
class ApiPathsRules {
@Test
@DisplayName("ApiPaths 클래스는 final이어야 한다")
void apiPaths_should_be_final() {
classes()
.that().haveSimpleName("ApiPaths")
.should().haveModifier(JavaModifier.FINAL)
.as("ApiPaths 클래스는 상속을 방지하기 위해 final이어야 합니다")
.check(importedClasses);
}
@Test
@DisplayName("ApiPaths 클래스는 private 생성자만 가져야 한다")
void apiPaths_should_have_private_constructor() {
classes()
.that().haveSimpleName("ApiPaths")
.should(haveOnlyPrivateConstructors())
.as("ApiPaths 클래스는 인스턴스화를 방지하기 위해 private 생성자만 가져야 합니다")
.check(importedClasses);
}
@Test
@DisplayName("ApiPaths의 모든 필드는 public static final이어야 한다")
void apiPaths_fields_should_be_public_static_final() {
fields()
.that().areDeclaredInClassesThat().haveSimpleName("ApiPaths")
.and().areNotDeclaredInClassesThat().areInnerClasses()
.should().bePublic()
.andShould().beStatic()
.andShould().beFinal()
.as("ApiPaths의 모든 필드는 public static final이어야 합니다")
.check(importedClasses);
}
@Test
@DisplayName("ApiPaths의 경로 필드는 String 타입이어야 한다")
void apiPaths_path_fields_should_be_string() {
fields()
.that().areDeclaredInClassesThat().haveSimpleName("ApiPaths")
.and().arePublic()
.should().haveRawType(String.class)
.as("ApiPaths의 경로 필드는 String 타입이어야 합니다")
.check(importedClasses);
}
@Test
@DisplayName("ApiPaths의 Nested 클래스도 final이어야 한다")
void apiPaths_nested_classes_should_be_final() {
classes()
.that().areInnerClasses()
.and().resideInAnyPackage("..auth.paths..")
.should().haveModifier(JavaModifier.FINAL)
.as("ApiPaths의 Nested 클래스도 final이어야 합니다")
.check(importedClasses);
}
}
// ========================================
// SecurityPaths 규칙
// ========================================
@Nested
@DisplayName("SecurityPaths 규칙")
class SecurityPathsRules {
@Test
@DisplayName("SecurityPaths 클래스는 final이어야 한다")
void securityPaths_should_be_final() {
classes()
.that().haveSimpleName("SecurityPaths")
.should().haveModifier(JavaModifier.FINAL)
.as("SecurityPaths 클래스는 final이어야 합니다")
.check(importedClasses);
}
@Test
@DisplayName("SecurityPaths 클래스는 private 생성자만 가져야 한다")
void securityPaths_should_have_private_constructor() {
classes()
.that().haveSimpleName("SecurityPaths")
.should(haveOnlyPrivateConstructors())
.as("SecurityPaths 클래스는 private 생성자만 가져야 합니다")
.check(importedClasses);
}
@Test
@DisplayName("SecurityPaths의 배열 필드는 public static final이어야 한다")
void securityPaths_arrays_should_be_public_static_final() {
fields()
.that().areDeclaredInClassesThat().haveSimpleName("SecurityPaths")
.should().bePublic()
.andShould().beStatic()
.andShould().beFinal()
.as("SecurityPaths의 배열 필드는 public static final이어야 합니다")
.check(importedClasses);
}
}
// ========================================
// Filter 규칙
// ========================================
@Nested
@DisplayName("Filter 규칙")
class FilterRules {
@Test
@DisplayName("Filter 클래스는 OncePerRequestFilter를 상속해야 한다")
void filters_should_extend_OncePerRequestFilter() {
classes()
.that().resideInAPackage("..auth.filter..")
.and().haveSimpleNameEndingWith("Filter")
.should().beAssignableTo(OncePerRequestFilter.class)
.as("Filter 클래스는 OncePerRequestFilter를 상속해야 합니다 (요청당 1회 실행 보장)")
.check(importedClasses);
}
@Test
@DisplayName("Filter 클래스는 'Filter'로 끝나야 한다")
void filters_should_have_filter_suffix() {
classes()
.that().resideInAPackage("..auth.filter..")
.and().areAssignableTo(OncePerRequestFilter.class)
.should().haveSimpleNameEndingWith("Filter")
.as("Filter 클래스는 'Filter'로 끝나야 합니다")
.check(importedClasses);
}
@Test
@DisplayName("JwtAuthenticationFilter는 doFilterInternal을 오버라이드해야 한다")
void jwtFilter_should_override_doFilterInternal() {
classes()
.that().haveSimpleName("JwtAuthenticationFilter")
.should(overrideDoFilterInternal())
.as("JwtAuthenticationFilter는 doFilterInternal을 오버라이드해야 합니다")
.check(importedClasses);
}
}
// ========================================
// Handler 규칙
// ========================================
@Nested
@DisplayName("Handler 규칙")
class HandlerRules {
@Test
@DisplayName("AuthenticationErrorHandler는 AuthenticationEntryPoint를 구현해야 한다")
void authErrorHandler_should_implement_AuthenticationEntryPoint() {
classes()
.that().haveSimpleName("AuthenticationErrorHandler")
.should().implement(AuthenticationEntryPoint.class)
.as("AuthenticationErrorHandler는 AuthenticationEntryPoint를 구현해야 합니다 (401 처리)")
.check(importedClasses);
}
@Test
@DisplayName("AuthenticationErrorHandler는 AccessDeniedHandler를 구현해야 한다")
void authErrorHandler_should_implement_AccessDeniedHandler() {
classes()
.that().haveSimpleName("AuthenticationErrorHandler")
.should().implement(AccessDeniedHandler.class)
.as("AuthenticationErrorHandler는 AccessDeniedHandler를 구현해야 합니다 (403 처리)")
.check(importedClasses);
}
@Test
@DisplayName("Handler 클래스는 @Component 어노테이션이 있어야 한다")
void handlers_should_have_component_annotation() {
classes()
.that().resideInAPackage("..auth.handler..")
.and().haveSimpleNameEndingWith("Handler")
.should().beAnnotatedWith(Component.class)
.as("Handler 클래스는 @Component 어노테이션이 있어야 합니다")
.check(importedClasses);
}
}
// ========================================
// Component 규칙
// ========================================
@Nested
@DisplayName("Component 규칙")
class ComponentRules {
@Test
@DisplayName("auth/component 패키지의 클래스는 @Component 어노테이션이 있어야 한다")
void components_should_have_component_annotation() {
classes()
.that().resideInAPackage("..auth.component..")
.should().beAnnotatedWith(Component.class)
.as("auth/component 패키지의 클래스는 @Component 어노테이션이 있어야 합니다")
.check(importedClasses);
}
@Test
@DisplayName("TokenCookieWriter는 @Component 어노테이션이 있어야 한다")
void tokenCookieWriter_should_be_component() {
classes()
.that().haveSimpleName("TokenCookieWriter")
.should().beAnnotatedWith(Component.class)
.as("TokenCookieWriter는 @Component 어노테이션이 있어야 합니다")
.check(importedClasses);
}
@Test
@DisplayName("MdcContextHolder는 @Component 어노테이션이 있어야 한다")
void mdcContextHolder_should_be_component() {
classes()
.that().haveSimpleName("MdcContextHolder")
.should().beAnnotatedWith(Component.class)
.as("MdcContextHolder는 @Component 어노테이션이 있어야 합니다")
.check(importedClasses);
}
}
// ========================================
// Config 규칙
// ========================================
@Nested
@DisplayName("Config 규칙")
class ConfigRules {
@Test
@DisplayName("SecurityConfig는 @Configuration 어노테이션이 있어야 한다")
void securityConfig_should_have_configuration_annotation() {
classes()
.that().haveSimpleName("SecurityConfig")
.should().beAnnotatedWith(org.springframework.context.annotation.Configuration.class)
.as("SecurityConfig는 @Configuration 어노테이션이 있어야 합니다")
.check(importedClasses);
}
@Test
@DisplayName("SecurityConfig는 @EnableWebSecurity 어노테이션이 있어야 한다")
void securityConfig_should_have_enableWebSecurity_annotation() {
classes()
.that().haveSimpleName("SecurityConfig")
.should().beAnnotatedWith(
org.springframework.security.config.annotation.web.configuration.EnableWebSecurity.class)
.as("SecurityConfig는 @EnableWebSecurity 어노테이션이 있어야 합니다")
.check(importedClasses);
}
@Test
@DisplayName("SecurityConfig는 @EnableMethodSecurity 어노테이션이 있어야 한다")
void securityConfig_should_have_enableMethodSecurity_annotation() {
classes()
.that().haveSimpleName("SecurityConfig")
.should().beAnnotatedWith(
org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity.class)
.as("SecurityConfig는 @EnableMethodSecurity 어노테이션이 있어야 합니다 (Method Security 활성화)")
.check(importedClasses);
}
}
// ========================================
// 의존성 규칙
// ========================================
@Nested
@DisplayName("의존성 규칙")
class DependencyRules {
@Test
@DisplayName("auth/paths 패키지는 다른 auth 패키지에 의존하지 않아야 한다")
void paths_should_not_depend_on_other_auth_packages() {
classes()
.that().resideInAPackage("..auth.paths..")
.should().onlyDependOnClassesThat()
.resideInAnyPackage(
"java..",
"..auth.paths.."
)
.as("auth/paths 패키지는 순수 상수 클래스여야 합니다")
.check(importedClasses);
}
@Test
@DisplayName("Filter는 Application Port에 의존할 수 있다")
void filters_may_depend_on_application_ports() {
// Filter → Application Port 의존 허용 (TokenProviderPort 등)
classes()
.that().resideInAPackage("..auth.filter..")
.should().onlyDependOnClassesThat()
.resideInAnyPackage(
"java..",
"jakarta..",
"org.springframework..",
"org.slf4j..",
"..auth..",
"..application..port.."
)
.as("Filter는 Application Port에 의존할 수 있습니다")
.check(importedClasses);
}
}
// ========================================
// Custom Conditions
// ========================================
/**
* private 생성자만 있는지 검증
*/
private static ArchCondition<JavaClass> haveOnlyPrivateConstructors() {
return new ArchCondition<>("have only private constructors") {
@Override
public void check(JavaClass javaClass, ConditionEvents events) {
javaClass.getConstructors().forEach(constructor -> {
if (!constructor.getModifiers().contains(JavaModifier.PRIVATE)) {
events.add(SimpleConditionEvent.violated(
constructor,
String.format("%s has non-private constructor", javaClass.getName())
));
}
});
}
};
}
/**
* doFilterInternal 메서드 오버라이드 검증
*/
private static ArchCondition<JavaClass> overrideDoFilterInternal() {
return new ArchCondition<>("override doFilterInternal method") {
@Override
public void check(JavaClass javaClass, ConditionEvents events) {
boolean hasMethod = javaClass.getMethods().stream()
.anyMatch(method -> method.getName().equals("doFilterInternal"));
if (!hasMethod) {
events.add(SimpleConditionEvent.violated(
javaClass,
String.format("%s does not override doFilterInternal", javaClass.getName())
));
}
}
};
}
}
3️⃣ 검증 규칙 요약
ApiPaths 규칙 (6개)
| 규칙 |
설명 |
이유 |
| final 클래스 |
상속 방지 |
유틸리티 클래스 패턴 |
| private 생성자 |
인스턴스화 방지 |
상수 클래스 |
| public static final 필드 |
상수 정의 |
컴파일 타임 상수 |
| String 타입 필드 |
경로 타입 |
타입 안전성 |
| Nested 클래스 final |
BC별 그룹화 |
구조적 일관성 |
| 외부 의존 금지 |
순수 상수 |
의존성 분리 |
Filter 규칙 (3개)
| 규칙 |
설명 |
이유 |
| OncePerRequestFilter 상속 |
요청당 1회 실행 |
중복 실행 방지 |
| Filter 접미사 |
네이밍 규칙 |
가독성 |
| doFilterInternal 오버라이드 |
필터 로직 |
구현 보장 |
Handler 규칙 (3개)
| 규칙 |
설명 |
이유 |
| AuthenticationEntryPoint 구현 |
401 처리 |
인증 실패 |
| AccessDeniedHandler 구현 |
403 처리 |
인가 실패 |
| @Component 어노테이션 |
Spring Bean |
DI |
Config 규칙 (3개)
| 규칙 |
설명 |
이유 |
| @Configuration |
설정 클래스 |
Spring Config |
| @EnableWebSecurity |
Security 활성화 |
필수 |
| @EnableMethodSecurity |
Method Security |
@PreAuthorize |
4️⃣ 실행 방법
Gradle 실행
# Security ArchUnit 테스트만 실행
./gradlew test --tests "*SecurityArchTest*"
# 전체 ArchUnit 테스트 실행
./gradlew test --tests "*ArchTest*"
빌드 시 자동 실행
// build.gradle
test {
useJUnitPlatform()
// ArchUnit 테스트 포함
}
5️⃣ 체크리스트
📚 관련 가이드
작성자: Development Team
최종 수정일: 2025-11-13
버전: 1.0.0