From 1156fb95e5af103f34c6295995a55395dbc812ef Mon Sep 17 00:00:00 2001 From: seoyeon2001 Date: Thu, 26 Feb 2026 16:12:18 +0900 Subject: [PATCH 1/6] =?UTF-8?q?feature:=20arch=20unit=20test=20=EC=A0=81?= =?UTF-8?q?=EC=9A=A9=20with=20claude?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 4 + .../payper/server/GlobalArchitectureTest.java | 479 ++++++++++++++++++ 2 files changed, 483 insertions(+) create mode 100644 src/test/java/com/payper/server/GlobalArchitectureTest.java diff --git a/build.gradle b/build.gradle index 6ba2ce2..3483584 100644 --- a/build.gradle +++ b/build.gradle @@ -50,6 +50,10 @@ dependencies { testImplementation 'org.springframework.boot:spring-boot-starter-security-test' testImplementation 'org.springframework.boot:spring-boot-starter-webmvc-test' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' + + // Archunit +// testImplementation 'com.tngtech.archunit:archunit:1.4.1' + testImplementation 'com.tngtech.archunit:archunit-junit5:1.4.1' } tasks.named('test') { diff --git a/src/test/java/com/payper/server/GlobalArchitectureTest.java b/src/test/java/com/payper/server/GlobalArchitectureTest.java new file mode 100644 index 0000000..9300f7f --- /dev/null +++ b/src/test/java/com/payper/server/GlobalArchitectureTest.java @@ -0,0 +1,479 @@ +package com.payper.server; + +import com.tngtech.archunit.core.domain.JavaClass; +import com.tngtech.archunit.core.importer.ImportOption; +import com.tngtech.archunit.junit.AnalyzeClasses; +import com.tngtech.archunit.junit.ArchTest; +import com.tngtech.archunit.lang.ArchCondition; +import com.tngtech.archunit.lang.ArchRule; +import com.tngtech.archunit.lang.ConditionEvents; +import com.tngtech.archunit.lang.SimpleConditionEvent; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.persistence.Entity; +import jakarta.persistence.MappedSuperclass; +import org.springframework.stereotype.Repository; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.bind.annotation.RestController; + +import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.*; +import static com.tngtech.archunit.library.Architectures.layeredArchitecture; +import static com.tngtech.archunit.library.dependencies.SlicesRuleDefinition.slices; + +/** + * 전체 아키텍처 규칙 검증 테스트 + * + *

현재 알려진 위반 목록 (기존 코드 - 리팩토링 대상)

+ * + * + *

제외 대상

+ * + */ +@AnalyzeClasses( + packages = "com.payper.server", + importOptions = {ImportOption.DoNotIncludeTests.class} +) +class GlobalArchitectureTest { + + private static final String CONTROLLER_PKG = "..controller.."; + private static final String SERVICE_PKG = "..service.."; + private static final String REPOSITORY_PKG = "..repository.."; + private static final String ENTITY_PKG = "..entity.."; + + /** 개발/테스트용 임시 컨트롤러 패키지 - 계층 규칙 적용 제외 */ + private static final String TEST_UTIL_PKG = "com.payper.server.domain.test.."; + + + // ========================================================================== + // 의존성 방향 규칙 + // ========================================================================== + + /** + * 규칙 1 & 4: Controller는 Repository에 의존하면 안 된다. + * + *

Controller는 비즈니스 로직을 Service에 위임해야 하며, 데이터 접근 계층인 + * Repository에 직접 접근해서는 안 된다. + */ + @ArchTest + static ArchRule rule1_4_controllerMustNotDependOnRepository = + noClasses() + .that().resideInAPackage(CONTROLLER_PKG) + .should().dependOnClassesThat().resideInAPackage(REPOSITORY_PKG) + .as("[규칙 1, 4] Controller는 Repository에 직접 의존하면 안 된다"); + + /** + * 규칙 2: Controller는 반드시 Service에 의존해야 한다. + * + *

도메인 로직이 없는 개발용 컨트롤러(domain.test)는 제외한다. + */ + @ArchTest + static ArchRule rule2_controllerMustDependOnService = + classes() + .that().areAnnotatedWith(RestController.class) + .and().resideOutsideOfPackage(TEST_UTIL_PKG) + .should(new ArchCondition("[규칙 2] Controller는 반드시 @Service 클래스에 의존해야 한다") { + @Override + public void check(JavaClass javaClass, ConditionEvents events) { + boolean hasServiceDependency = javaClass.getDirectDependenciesFromSelf() + .stream() + .anyMatch(dep -> dep.getTargetClass().isAnnotatedWith(Service.class)); + + if (!hasServiceDependency) { + events.add(SimpleConditionEvent.violated( + javaClass, + javaClass.getName() + " 는 @Service 클래스에 의존하지 않습니다." + )); + } + } + }); + + /** + * 규칙 3: Service는 반드시 Repository에 의존해야 한다. + * + *

Service 클래스는 데이터 접근을 위해 반드시 하나 이상의 Repository를 직접 주입받아야 한다. + * + *

알려진 위반: AuthService는 UserService에 위임하는 구조라 직접 Repository 의존이 없다. + */ + @ArchTest + static ArchRule rule3_serviceMustDependOnRepository = + classes() + .that().areAnnotatedWith(Service.class) + .should(new ArchCondition("[규칙 3] Service는 반드시 Repository에 의존해야 한다") { + @Override + public void check(JavaClass javaClass, ConditionEvents events) { + boolean hasRepoDependency = javaClass.getDirectDependenciesFromSelf() + .stream() + .anyMatch(dep -> + dep.getTargetClass().isAnnotatedWith(Repository.class) + || dep.getTargetClass().getPackageName().contains(".repository") + ); + + if (!hasRepoDependency) { + events.add(SimpleConditionEvent.violated( + javaClass, + javaClass.getName() + " 는 Repository 클래스에 의존하지 않습니다." + )); + } + } + }); + + /** + * 규칙 5: Service는 Controller를 참조하면 안 된다. + * + *

하위 계층(Service)이 상위 계층(Controller)을 알아서는 안 된다. + */ + @ArchTest + static ArchRule rule5_serviceMustNotDependOnController = + noClasses() + .that().areAnnotatedWith(Service.class) + .should().dependOnClassesThat().areAnnotatedWith(RestController.class) + .as("[규칙 5] Service는 Controller를 참조하면 안 된다"); + + /** + * 규칙 6: Repository는 다른 계층(Service, Controller)을 참조하면 안 된다. + * + *

데이터 접근 계층인 Repository는 비즈니스/프레젠테이션 계층에 의존해서는 안 된다. + */ + @ArchTest + static ArchRule rule6_repositoryMustNotDependOnOtherLayers = + noClasses() + .that().resideInAPackage(REPOSITORY_PKG) + .should().dependOnClassesThat().resideInAnyPackage(SERVICE_PKG, CONTROLLER_PKG) + .as("[규칙 6] Repository는 Service/Controller 계층을 참조하면 안 된다"); + + + // ========================================================================== + // 인터페이스 구현 규칙 + // ========================================================================== + + /** + * 규칙 7: Controller는 Swagger Api 인터페이스를 구현해야 한다. + * + *

API 문서화를 강제하여 모든 Controller가 명세(Api 인터페이스)를 가지도록 한다. + * 개발용 테스트 컨트롤러(domain.test)는 제외한다. + */ + @ArchTest + static ArchRule rule7_controllerMustImplementApiInterface = + classes() + .that().areAnnotatedWith(RestController.class) + .and().resideOutsideOfPackage(TEST_UTIL_PKG) + .should(new ArchCondition("[규칙 7] Controller는 이름이 'Api'로 끝나는 인터페이스를 구현해야 한다") { + @Override + public void check(JavaClass javaClass, ConditionEvents events) { + boolean implementsApi = javaClass.getInterfaces() + .stream() + .anyMatch(iface -> iface.toErasure().getSimpleName().endsWith("Api")); + + if (!implementsApi) { + events.add(SimpleConditionEvent.violated( + javaClass, + javaClass.getName() + " 는 'Api'로 끝나는 인터페이스를 구현하지 않습니다." + )); + } + } + }); + + + // ========================================================================== + // 명명 규칙 + // ========================================================================== + + /** + * 규칙 8: @RestController 클래스의 이름은 "Controller"로 끝나야 한다. + */ + @ArchTest + static ArchRule rule8_controllerClassNameMustEndWithController = + classes() + .that().areAnnotatedWith(RestController.class) + .should().haveSimpleNameEndingWith("Controller") + .as("[규칙 8] @RestController 클래스의 이름은 'Controller'로 끝나야 한다"); + + /** + * 규칙 9: @Service 클래스의 이름은 "Service"로 끝나야 한다. + */ + @ArchTest + static ArchRule rule9_serviceClassNameMustEndWithService = + classes() + .that().areAnnotatedWith(Service.class) + .should().haveSimpleNameEndingWith("Service") + .as("[규칙 9] @Service 클래스의 이름은 'Service'로 끝나야 한다"); + + /** + * 규칙 10: repository 패키지 내 클래스/인터페이스의 이름은 "Repository"로 끝나야 한다. + */ + @ArchTest + static ArchRule rule10_repositoryClassNameMustEndWithRepository = + classes() + .that().resideInAPackage(REPOSITORY_PKG) + .should().haveSimpleNameEndingWith("Repository") + .as("[규칙 10] repository 패키지의 클래스는 이름이 'Repository'로 끝나야 한다"); + + + // ========================================================================== + // 어노테이션 규칙 + // ========================================================================== + + /** + * 규칙 11: controller 패키지의 Controller 클래스는 @RestController 어노테이션이 있어야 한다. + */ + @ArchTest + static ArchRule rule11_controllerMustBeAnnotatedWithRestController = + classes() + .that().resideInAPackage(CONTROLLER_PKG) + .and().haveSimpleNameEndingWith("Controller") + .should().beAnnotatedWith(RestController.class) + .as("[규칙 11] controller 패키지의 Controller 클래스는 @RestController 어노테이션이 있어야 한다"); + + /** + * 규칙 12: service 패키지의 Service 클래스는 @Service 어노테이션이 있어야 한다. + */ + @ArchTest + static ArchRule rule12_serviceMustBeAnnotatedWithService = + classes() + .that().resideInAPackage(SERVICE_PKG) + .and().haveSimpleNameEndingWith("Service") + .should().beAnnotatedWith(Service.class) + .as("[규칙 12] service 패키지의 Service 클래스는 @Service 어노테이션이 있어야 한다"); + + /** + * 규칙 13: repository 패키지의 인터페이스는 @Repository 어노테이션이 있어야 한다. + * + *

Spring Data JPA가 자동으로 빈을 등록하더라도, 명시적 선언을 통해 의도를 드러낸다. + * + *

알려진 위반: UserRepository, RefreshTokenRepository에 @Repository 미선언. + */ + @ArchTest + static ArchRule rule13_repositoryMustBeAnnotatedWithRepository = + classes() + .that().resideInAPackage(REPOSITORY_PKG) + .and().areInterfaces() + .should().beAnnotatedWith(Repository.class) + .as("[규칙 13] repository 패키지의 인터페이스는 @Repository 어노테이션이 있어야 한다"); + + /** + * 규칙 14: "Api"로 끝나는 인터페이스는 @Tag 어노테이션이 있어야 한다. + * + *

Swagger 문서에서 API 그룹을 명확히 하기 위해 @Tag 선언을 강제한다. + */ + @ArchTest + static ArchRule rule14_apiInterfaceMustHaveTagAnnotation = + classes() + .that().haveSimpleNameEndingWith("Api") + .and().areInterfaces() + .should().beAnnotatedWith(Tag.class) + .as("[규칙 14] 'Api'로 끝나는 인터페이스는 @Tag 어노테이션이 있어야 한다"); + + /** + * 규칙 15: entity 패키지의 enum이 아닌 클래스는 @Entity 어노테이션이 있어야 한다. + * + *

@MappedSuperclass가 붙은 공통 기반 클래스(BaseTimeEntity 등)는 제외한다. + */ + @ArchTest + static ArchRule rule15_entityClassMustHaveEntityAnnotation = + classes() + .that().resideInAPackage(ENTITY_PKG) + .and().areNotEnums() + .and().areNotAnnotatedWith(MappedSuperclass.class) + .should().beAnnotatedWith(Entity.class) + .as("[규칙 15] entity 패키지의 클래스(enum · @MappedSuperclass 제외)는 @Entity 어노테이션이 있어야 한다"); + + + // ========================================================================== + // 패키지 배치 규칙 + // ========================================================================== + + /** + * 규칙 16: @RestController 클래스는 controller 패키지에만 있어야 한다. + * + *

개발용 컨트롤러(domain.test)는 제외한다. + * + *

알려진 위반: AuthController가 auth 루트 패키지에 위치. + */ + @ArchTest + static ArchRule rule16_controllerMustResideInControllerPackage = + classes() + .that().areAnnotatedWith(RestController.class) + .and().resideOutsideOfPackage(TEST_UTIL_PKG) + .should().resideInAPackage(CONTROLLER_PKG) + .as("[규칙 16] @RestController 클래스는 controller 패키지에 위치해야 한다"); + + /** + * 규칙 17: @Service 클래스는 service 패키지에만 있어야 한다. + * + *

알려진 위반: AuthService(auth 패키지), UserService(user 패키지)가 service 하위 패키지가 아닌 곳에 위치. + */ + @ArchTest + static ArchRule rule17_serviceMustResideInServicePackage = + classes() + .that().areAnnotatedWith(Service.class) + .should().resideInAPackage(SERVICE_PKG) + .as("[규칙 17] @Service 클래스는 service 패키지에 위치해야 한다"); + + /** + * 규칙 18: @Repository 인터페이스는 repository 패키지에만 있어야 한다. + * + *

알려진 위반: RefreshTokenRepository가 auth.jwt 패키지에 위치. + */ + @ArchTest + static ArchRule rule18_repositoryMustResideInRepositoryPackage = + classes() + .that().areAnnotatedWith(Repository.class) + .should().resideInAPackage(REPOSITORY_PKG) + .as("[규칙 18] @Repository 인터페이스는 repository 패키지에 위치해야 한다"); + + /** + * 규칙 19: "Api"로 끝나는 인터페이스는 controller 패키지에 있어야 한다. + * + *

알려진 위반: AuthApi가 auth 루트 패키지에 위치. + */ + @ArchTest + static ArchRule rule19_apiInterfaceMustResideInControllerPackage = + classes() + .that().haveSimpleNameEndingWith("Api") + .and().areInterfaces() + .should().resideInAPackage(CONTROLLER_PKG) + .as("[규칙 19] 'Api'로 끝나는 인터페이스는 controller 패키지에 위치해야 한다"); + + + // ========================================================================== + // 순환 참조 방지 + // ========================================================================== + + /** + * 규칙 20: 도메인 패키지 간 순환 참조는 없어야 한다. + * + *

최상위 도메인 슬라이스(auth, post, comment, merchant, user 등)끼리 + * 순환 의존이 생기면 안 된다. + */ + @ArchTest + static ArchRule rule20_noCyclicDependencies = + slices() + .matching("com.payper.server.(*)..") + .should().beFreeOfCycles() + .as("[규칙 20] 도메인 패키지 간 순환 참조가 없어야 한다"); + + + // ========================================================================== + // 타입 규칙 + // ========================================================================== + + /** + * 규칙 21: "Repository"로 끝나는 클래스/인터페이스는 반드시 인터페이스여야 한다. + * + *

구현체가 아닌 인터페이스 기반으로 Repository를 정의하여 Spring Data JPA와의 + * 일관성을 유지하고 테스트 확장성을 확보한다. + */ + @ArchTest + static ArchRule rule21_repositoryMustBeInterface = + classes() + .that().haveSimpleNameEndingWith("Repository") + .should().beInterfaces() + .as("[규칙 21] Repository는 인터페이스여야 한다"); + + + // ========================================================================== + // 계층 방향성 규칙 + // ========================================================================== + + /** + * 규칙 22: 계층 간 의존 흐름은 Controller → Service → Repository 순방향이어야 한다. + * + *

역방향 의존(Repository → Service, Service → Controller 등)은 허용하지 않는다. + * 각 계층 내부에서의 의존은 허용한다. + */ + @ArchTest + static ArchRule rule22_layeredArchitectureMustBeRespected = + layeredArchitecture() + .consideringOnlyDependenciesInLayers() + .layer("Controller").definedBy(CONTROLLER_PKG) + .layer("Service").definedBy(SERVICE_PKG) + .layer("Repository").definedBy(REPOSITORY_PKG) + .whereLayer("Controller").mayNotBeAccessedByAnyLayer() + .whereLayer("Service").mayOnlyBeAccessedByLayers("Controller") + .whereLayer("Repository").mayOnlyBeAccessedByLayers("Service") + .as("[규칙 22] 계층 간 의존 흐름은 Controller → Service → Repository 순방향이어야 한다"); + + + // ========================================================================== + // 추가 권장 규칙 + // ========================================================================== + + /** + * [권장 1] Service 클래스에는 클래스 레벨에 @Transactional 어노테이션이 있어야 한다. + * + *

메서드 단위의 누락을 방지하고, 기본 트랜잭션 전략을 명시적으로 선언하게 한다. + * 읽기 전용 메서드는 @Transactional(readOnly = true)로 오버라이드하도록 유도한다. + */ + @ArchTest + static ArchRule recommended1_serviceMustHaveClassLevelTransactional = + classes() + .that().areAnnotatedWith(Service.class) + .should().beAnnotatedWith(Transactional.class) + .as("[권장 1] Service 클래스는 클래스 레벨에 @Transactional이 있어야 한다"); + + /** + * [권장 2] Entity 클래스는 Service 또는 Controller 계층을 의존하면 안 된다. + * + *

도메인 모델의 순수성을 유지하기 위해 Entity가 상위 계층을 알아서는 안 된다. + */ + @ArchTest + static ArchRule recommended2_entityMustNotDependOnServiceOrController = + noClasses() + .that().resideInAPackage(ENTITY_PKG) + .should().dependOnClassesThat().resideInAnyPackage(SERVICE_PKG, CONTROLLER_PKG) + .as("[권장 2] Entity 클래스는 Service/Controller 계층을 의존하면 안 된다"); + + /** + * [권장 3] DTO 클래스(Request/Response)는 entity 패키지에 있으면 안 된다. + * + *

데이터 전송 객체와 도메인 모델을 명확히 분리하여 계층 간 결합을 낮춘다. + */ + @ArchTest + static ArchRule recommended3_dtoMustNotResideInEntityPackage = + noClasses() + .that().haveSimpleNameEndingWith("Request") + .or().haveSimpleNameEndingWith("Response") + .should().resideInAPackage(ENTITY_PKG) + .as("[권장 3] DTO(Request/Response) 클래스는 entity 패키지에 있으면 안 된다"); + + /** + * [권장 4] @Autowired 필드 주입은 사용하면 안 된다. + * + *

생성자 주입(@RequiredArgsConstructor 또는 명시적 생성자)만 허용하여 + * 불변성 확보 및 테스트 용이성을 높인다. + */ + @ArchTest + static ArchRule recommended4_noFieldInjectionWithAutowired = + noFields() + .should().beAnnotatedWith(org.springframework.beans.factory.annotation.Autowired.class) + .as("[권장 4] @Autowired 필드 주입 대신 생성자 주입을 사용해야 한다"); + + /** + * [권장 5] global 패키지는 다른 도메인 패키지에 의존하면 안 된다. + * + *

공통 유틸리티/응답/예외 처리를 담당하는 global 패키지가 + * 특정 도메인에 결합되면 재사용성이 떨어진다. + */ + @ArchTest + static ArchRule recommended5_globalPackageMustNotDependOnDomainPackages = + noClasses() + .that().resideInAPackage("com.payper.server.global..") + .should().dependOnClassesThat().resideInAnyPackage( + "com.payper.server.post..", + "com.payper.server.comment..", + "com.payper.server.merchant..", + "com.payper.server.favorite..", + "com.payper.server.user..", + "com.payper.server.auth.." + ) + .as("[권장 5] global 패키지는 특정 도메인 패키지에 의존하면 안 된다"); +} From e400debe6a2a04e25659bf7b4accc458df378409 Mon Sep 17 00:00:00 2001 From: seoyeon2001 Date: Fri, 27 Feb 2026 01:38:33 +0900 Subject: [PATCH 2/6] =?UTF-8?q?refactor:=20=EA=B3=84=EC=B8=B5=EB=B3=84=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EB=B6=84=EB=A6=AC=20=EB=B0=8F=20?= =?UTF-8?q?=ED=95=B5=EC=8B=AC=20=ED=85=8C=EC=8A=A4=ED=8A=B8=EB=A7=8C=20?= =?UTF-8?q?=EB=82=A8=EA=B9=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ARCHITECTURE.md | 170 ++++++++++++++++++ build.gradle | 2 + .../server/auth/{ => controller}/AuthApi.java | 2 +- .../auth/{ => controller}/AuthController.java | 3 +- .../RefreshTokenRepository.java | 2 +- .../server/auth/jwt/util/JwtParseUtil.java | 2 +- .../auth/jwt/util/JwtRefreshTokenUtil.java | 2 +- .../auth/{ => service}/AuthService.java | 5 +- .../server/auth/util/AuthDummyInit.java | 11 +- .../exception}/AuthException.java | 2 +- .../exception/GlobalExceptionHandler.java | 1 - .../CustomAuthenticationEntryPoint.java | 2 +- .../security/CustomUserDetailsService.java | 2 +- .../security/JwtAuthenticationProvider.java | 2 +- .../user/{ => service}/UserService.java | 4 +- .../server/ControllerArchitectureTest.java | 89 +++++++++ .../payper/server/DtoArchitectureTest.java | 27 +++ .../payper/server/EntityArchitectureTest.java | 41 +++++ .../JwtModulesSpringBootIntegrationTest.java | 4 +- .../server/RepositoryArchitectureTest.java | 37 ++++ .../server/ServiceArchitectureTest.java | 49 +++++ .../server/SwaggerArchitectureTest.java | 44 +++++ .../server/UserAndRefreshTokenJpaTest.java | 2 +- 23 files changed, 478 insertions(+), 27 deletions(-) create mode 100644 ARCHITECTURE.md rename src/main/java/com/payper/server/auth/{ => controller}/AuthApi.java (97%) rename src/main/java/com/payper/server/auth/{ => controller}/AuthController.java (96%) rename src/main/java/com/payper/server/auth/jwt/{ => repository}/RefreshTokenRepository.java (93%) rename src/main/java/com/payper/server/auth/{ => service}/AuthService.java (97%) rename src/main/java/com/payper/server/{auth => global/exception}/AuthException.java (89%) rename src/main/java/com/payper/server/user/{ => service}/UserService.java (94%) create mode 100644 src/test/java/com/payper/server/ControllerArchitectureTest.java create mode 100644 src/test/java/com/payper/server/DtoArchitectureTest.java create mode 100644 src/test/java/com/payper/server/EntityArchitectureTest.java create mode 100644 src/test/java/com/payper/server/RepositoryArchitectureTest.java create mode 100644 src/test/java/com/payper/server/ServiceArchitectureTest.java create mode 100644 src/test/java/com/payper/server/SwaggerArchitectureTest.java diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 0000000..fda6e3e --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,170 @@ +# Payper Server Architecture + +이 문서는 코드베이스에 적용된 아키텍처 규칙을 정리합니다. +규칙은 ArchUnit 테스트로 자동 검증됩니다 (`./gradlew test`). + +--- + +## 패키지 구조 (최종 버전) + +``` +com.payper.server +├── {domain}/ +│ ├── controller/ ← REST 엔드포인트 + Swagger Api 인터페이스 +│ ├── service/ ← 비즈니스 로직 +│ ├── repository/ ← JPA Repository +│ ├── entity/ ← JPA 엔티티 +│ └── dto/ +│ ├── request/ +│ └── response/ +├── global/ ← 공통 (응답 래퍼, 예외, 에러 코드) +└── security/ ← Spring Security 설정 및 필터 +``` + +--- + +## 계층 구조 및 의존 방향 + +``` +Controller → Service → Repository +``` + +- 의존은 **단방향**. 역방향 참조 금지. + +--- + +## 계층별 규칙 + +### Controller + +| 규칙 | 내용 | +|------|------| +| 패키지 위치 | `controller` 패키지에만 위치해야 한다 | +| 어노테이션 | `@RestController`가 선언되어야 한다 | +| Service 의존 | 반드시 하나 이상의 `@Service` 클래스에 의존해야 한다 | +| Repository 격리 | `repository` 패키지에 직접 의존하면 안 된다 | +| Swagger 명세 | 이름이 `Api`로 끝나는 인터페이스를 구현해야 한다 | + +> `domain.test` 패키지의 개발용 컨트롤러는 위 규칙에서 제외된다. + +**명명 예시** +``` +PostController, CommentController +``` + +--- + +### Service + +| 규칙 | 내용 | +|------|------| +| 패키지 위치 | `service` 패키지에만 위치해야 한다 | +| 어노테이션 | `service` 패키지 내 구체 클래스는 `@Service`가 선언되어야 한다 | +| Controller 격리 | `controller` 패키지를 참조하면 안 된다 | + +> interface가 있을 수 있으므로 이름 규칙은 강제하지 않는다. `PostService`, `PostServiceImpl` 등 자유롭게 사용할 수 있다. + +**명명 예시** +``` +PostService, CommentServiceImpl +``` + +--- + +### Repository + +| 규칙 | 내용 | +|------|------| +| 패키지 위치 | `repository` 패키지에만 위치해야 한다 | +| 계층 격리 | `service`, `controller` 패키지를 참조하면 안 된다 | + +> 커스텀이 있을 수 있으므로 이름 규칙은 강제하지 않는다. `PostRepository`, `PostRepositoryCustomImpl`, `PostRepositoryCustom` 등 자유롭게 사용할 수 있다. + +**명명 예시** +``` +PostRepository, PostRepositoryCustomImpl, PostRepositoryCustom +``` + +--- + +### Entity + +| 규칙 | 내용 | +|------|------| +| 어노테이션 | `entity` 패키지의 enum·`BaseTimeEntity` 이외 클래스는 `@Entity`가 선언되어야 한다 | +| 계층 격리 | `service`, `controller` 패키지를 의존하면 안 된다 | + +--- + +### DTO + +| 규칙 | 내용 | +|------|------| +| 패키지 위치 | 이름이 `Request` 또는 `Response`로 끝나는 클래스는 `dto` 패키지에 위치해야 한다 | + +> `global` 패키지의 공통 응답 래퍼(`ApiResponse` 등)는 제외된다. + +--- + +### Swagger Api 인터페이스 + +| 규칙 | 내용 | +|------|------| +| 타입 | 인터페이스여야 한다 | +| 어노테이션 | `@Tag`가 선언되어야 한다 | +| 패키지 위치 | `controller` 패키지에 위치해야 한다 | + +**명명 규칙**: `{Domain}Api` 형태로 이름이 `Api`로 끝나야 한다. + +```java +// 올바른 예 +@Tag(name = "게시글", description = "...") +public interface PostApi { ... } + +// PostController는 PostApi를 구현해야 한다 +@RestController +public class PostController implements PostApi { ... } +``` + +--- + +## 공통 규칙 + +### 의존성 주입 (DI) + +| 규칙 | 내용 | +|------|------| +| 주입 방식 | `@Autowired` 필드 주입 금지. 생성자 주입만 허용 | +| 필드 선언 | `service`, `controller` 계층의 인스턴스 필드는 `final`이어야 한다 | +| Lombok | `service`, `controller` 계층의 구체 클래스는 `@RequiredArgsConstructor`를 사용해야 한다 | + +```java +// 올바른 예 +@Service +@RequiredArgsConstructor +public class PostService { + private final PostRepository postRepository; +} + +// 잘못된 예 +@Autowired +private PostRepository postRepository; +``` + +### global 패키지 독립성 + +`global` 패키지는 도메인 패키지에 의존하면 안 된다. + +--- + +## ArchUnit 테스트 파일 목록 + +| 파일 | 검증 대상 | +|------|----------| +| `GlobalArchitectureTest` | 계층 방향성, global 독립성, DI 방식 | +| `ControllerArchitectureTest` | Controller 계층 전반 | +| `ServiceArchitectureTest` | Service 계층 전반 | +| `RepositoryArchitectureTest` | Repository 계층 전반 | +| `EntityArchitectureTest` | Entity 어노테이션, 계층 격리 | +| `DtoArchitectureTest` | DTO 패키지 위치 | +| `SwaggerArchitectureTest` | Api 인터페이스 타입, 어노테이션, 위치 | diff --git a/build.gradle b/build.gradle index 3483584..3748552 100644 --- a/build.gradle +++ b/build.gradle @@ -31,6 +31,8 @@ dependencies { // lombok compileOnly 'org.projectlombok:lombok' annotationProcessor 'org.projectlombok:lombok' + testCompileOnly 'org.projectlombok:lombok' + testAnnotationProcessor 'org.projectlombok:lombok' // data implementation 'org.springframework.boot:spring-boot-starter-data-jpa' diff --git a/src/main/java/com/payper/server/auth/AuthApi.java b/src/main/java/com/payper/server/auth/controller/AuthApi.java similarity index 97% rename from src/main/java/com/payper/server/auth/AuthApi.java rename to src/main/java/com/payper/server/auth/controller/AuthApi.java index f0ed93f..0a2138a 100644 --- a/src/main/java/com/payper/server/auth/AuthApi.java +++ b/src/main/java/com/payper/server/auth/controller/AuthApi.java @@ -1,4 +1,4 @@ -package com.payper.server.auth; +package com.payper.server.auth.controller; import com.payper.server.auth.dto.request.LoginRequest; import com.payper.server.auth.dto.response.LoginSuccessResponse; diff --git a/src/main/java/com/payper/server/auth/AuthController.java b/src/main/java/com/payper/server/auth/controller/AuthController.java similarity index 96% rename from src/main/java/com/payper/server/auth/AuthController.java rename to src/main/java/com/payper/server/auth/controller/AuthController.java index 1b11c51..6f3afef 100644 --- a/src/main/java/com/payper/server/auth/AuthController.java +++ b/src/main/java/com/payper/server/auth/controller/AuthController.java @@ -1,5 +1,6 @@ -package com.payper.server.auth; +package com.payper.server.auth.controller; +import com.payper.server.auth.service.AuthService; import com.payper.server.auth.dto.request.LoginRequest; import com.payper.server.auth.dto.response.LoginSuccessResponse; import com.payper.server.auth.dto.response.ReissueSuccessResponse; diff --git a/src/main/java/com/payper/server/auth/jwt/RefreshTokenRepository.java b/src/main/java/com/payper/server/auth/jwt/repository/RefreshTokenRepository.java similarity index 93% rename from src/main/java/com/payper/server/auth/jwt/RefreshTokenRepository.java rename to src/main/java/com/payper/server/auth/jwt/repository/RefreshTokenRepository.java index 7a9042a..eb49c49 100644 --- a/src/main/java/com/payper/server/auth/jwt/RefreshTokenRepository.java +++ b/src/main/java/com/payper/server/auth/jwt/repository/RefreshTokenRepository.java @@ -1,4 +1,4 @@ -package com.payper.server.auth.jwt; +package com.payper.server.auth.jwt.repository; import com.payper.server.auth.jwt.entity.RefreshTokenEntity; import org.springframework.data.jpa.repository.JpaRepository; diff --git a/src/main/java/com/payper/server/auth/jwt/util/JwtParseUtil.java b/src/main/java/com/payper/server/auth/jwt/util/JwtParseUtil.java index b097f36..e47607d 100644 --- a/src/main/java/com/payper/server/auth/jwt/util/JwtParseUtil.java +++ b/src/main/java/com/payper/server/auth/jwt/util/JwtParseUtil.java @@ -1,6 +1,6 @@ package com.payper.server.auth.jwt.util; -import com.payper.server.auth.AuthException; +import com.payper.server.global.exception.AuthException; import com.payper.server.auth.jwt.entity.JwtType; import com.payper.server.global.response.ErrorCode; import io.jsonwebtoken.*; diff --git a/src/main/java/com/payper/server/auth/jwt/util/JwtRefreshTokenUtil.java b/src/main/java/com/payper/server/auth/jwt/util/JwtRefreshTokenUtil.java index 3f4952d..ec3439b 100644 --- a/src/main/java/com/payper/server/auth/jwt/util/JwtRefreshTokenUtil.java +++ b/src/main/java/com/payper/server/auth/jwt/util/JwtRefreshTokenUtil.java @@ -1,6 +1,6 @@ package com.payper.server.auth.jwt.util; -import com.payper.server.auth.jwt.RefreshTokenRepository; +import com.payper.server.auth.jwt.repository.RefreshTokenRepository; import com.payper.server.auth.jwt.entity.RefreshTokenEntity; import io.jsonwebtoken.Jwts; import jakarta.annotation.PostConstruct; diff --git a/src/main/java/com/payper/server/auth/AuthService.java b/src/main/java/com/payper/server/auth/service/AuthService.java similarity index 97% rename from src/main/java/com/payper/server/auth/AuthService.java rename to src/main/java/com/payper/server/auth/service/AuthService.java index 268c644..974a92d 100644 --- a/src/main/java/com/payper/server/auth/AuthService.java +++ b/src/main/java/com/payper/server/auth/service/AuthService.java @@ -1,5 +1,6 @@ -package com.payper.server.auth; +package com.payper.server.auth.service; +import com.payper.server.global.exception.AuthException; import com.payper.server.auth.jwt.entity.JwtType; import com.payper.server.auth.jwt.entity.RefreshTokenEntity; import com.payper.server.auth.jwt.util.JwtParseUtil; @@ -9,7 +10,7 @@ import com.payper.server.auth.util.OAuthUserInfo; import com.payper.server.global.exception.ApiException; import com.payper.server.global.response.ErrorCode; -import com.payper.server.user.UserService; +import com.payper.server.user.service.UserService; import com.payper.server.user.entity.AuthType; import com.payper.server.user.entity.User; import com.payper.server.user.entity.UserRole; diff --git a/src/main/java/com/payper/server/auth/util/AuthDummyInit.java b/src/main/java/com/payper/server/auth/util/AuthDummyInit.java index faf625e..a7e9f0d 100644 --- a/src/main/java/com/payper/server/auth/util/AuthDummyInit.java +++ b/src/main/java/com/payper/server/auth/util/AuthDummyInit.java @@ -1,22 +1,13 @@ package com.payper.server.auth.util; -import com.payper.server.auth.AuthService; +import com.payper.server.auth.service.AuthService; import com.payper.server.user.entity.AuthType; import com.payper.server.user.entity.User; -import jakarta.servlet.*; -import jakarta.servlet.http.*; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.boot.ApplicationArguments; import org.springframework.boot.ApplicationRunner; import org.springframework.stereotype.Component; -import org.springframework.transaction.annotation.Transactional; - -import java.io.BufferedReader; -import java.io.IOException; -import java.io.UnsupportedEncodingException; -import java.security.Principal; -import java.util.*; @Component @RequiredArgsConstructor diff --git a/src/main/java/com/payper/server/auth/AuthException.java b/src/main/java/com/payper/server/global/exception/AuthException.java similarity index 89% rename from src/main/java/com/payper/server/auth/AuthException.java rename to src/main/java/com/payper/server/global/exception/AuthException.java index 09d5a34..ce304a8 100644 --- a/src/main/java/com/payper/server/auth/AuthException.java +++ b/src/main/java/com/payper/server/global/exception/AuthException.java @@ -1,4 +1,4 @@ -package com.payper.server.auth; +package com.payper.server.global.exception; import com.payper.server.global.response.ErrorCode; import lombok.Getter; diff --git a/src/main/java/com/payper/server/global/exception/GlobalExceptionHandler.java b/src/main/java/com/payper/server/global/exception/GlobalExceptionHandler.java index b877dd3..2f73b40 100644 --- a/src/main/java/com/payper/server/global/exception/GlobalExceptionHandler.java +++ b/src/main/java/com/payper/server/global/exception/GlobalExceptionHandler.java @@ -1,6 +1,5 @@ package com.payper.server.global.exception; -import com.payper.server.auth.AuthException; import com.payper.server.global.response.ApiResponse; import com.payper.server.global.response.ErrorCode; import com.payper.server.global.response.FieldErrorDto; diff --git a/src/main/java/com/payper/server/security/CustomAuthenticationEntryPoint.java b/src/main/java/com/payper/server/security/CustomAuthenticationEntryPoint.java index 5ab7ec5..9099140 100644 --- a/src/main/java/com/payper/server/security/CustomAuthenticationEntryPoint.java +++ b/src/main/java/com/payper/server/security/CustomAuthenticationEntryPoint.java @@ -1,6 +1,6 @@ package com.payper.server.security; -import com.payper.server.auth.AuthException; +import com.payper.server.global.exception.AuthException; import com.payper.server.global.response.ApiResponse; import com.payper.server.global.response.ErrorCode; import jakarta.servlet.ServletException; diff --git a/src/main/java/com/payper/server/security/CustomUserDetailsService.java b/src/main/java/com/payper/server/security/CustomUserDetailsService.java index 4c03b12..2158d6d 100644 --- a/src/main/java/com/payper/server/security/CustomUserDetailsService.java +++ b/src/main/java/com/payper/server/security/CustomUserDetailsService.java @@ -1,6 +1,6 @@ package com.payper.server.security; -import com.payper.server.auth.AuthException; +import com.payper.server.global.exception.AuthException; import com.payper.server.global.response.ErrorCode; import com.payper.server.user.repository.UserRepository; import com.payper.server.user.entity.User; diff --git a/src/main/java/com/payper/server/security/JwtAuthenticationProvider.java b/src/main/java/com/payper/server/security/JwtAuthenticationProvider.java index 06ad2f4..c7fdc92 100644 --- a/src/main/java/com/payper/server/security/JwtAuthenticationProvider.java +++ b/src/main/java/com/payper/server/security/JwtAuthenticationProvider.java @@ -1,6 +1,6 @@ package com.payper.server.security; -import com.payper.server.auth.AuthException; +import com.payper.server.global.exception.AuthException; import com.payper.server.auth.jwt.entity.JwtType; import com.payper.server.auth.jwt.util.JwtParseUtil; import com.payper.server.global.response.ErrorCode; diff --git a/src/main/java/com/payper/server/user/UserService.java b/src/main/java/com/payper/server/user/service/UserService.java similarity index 94% rename from src/main/java/com/payper/server/user/UserService.java rename to src/main/java/com/payper/server/user/service/UserService.java index 3034c53..f4b438a 100644 --- a/src/main/java/com/payper/server/user/UserService.java +++ b/src/main/java/com/payper/server/user/service/UserService.java @@ -1,6 +1,6 @@ -package com.payper.server.user; +package com.payper.server.user.service; -import com.payper.server.auth.AuthException; +import com.payper.server.global.exception.AuthException; import com.payper.server.auth.util.OAuthUserInfo; import com.payper.server.global.response.ErrorCode; import com.payper.server.user.entity.User; diff --git a/src/test/java/com/payper/server/ControllerArchitectureTest.java b/src/test/java/com/payper/server/ControllerArchitectureTest.java new file mode 100644 index 0000000..3b08112 --- /dev/null +++ b/src/test/java/com/payper/server/ControllerArchitectureTest.java @@ -0,0 +1,89 @@ +package com.payper.server; + +import com.tngtech.archunit.core.domain.JavaClass; +import com.tngtech.archunit.junit.AnalyzeClasses; +import com.tngtech.archunit.junit.ArchTest; +import com.tngtech.archunit.lang.ArchCondition; +import com.tngtech.archunit.lang.ArchRule; +import com.tngtech.archunit.lang.ConditionEvents; +import com.tngtech.archunit.lang.SimpleConditionEvent; +import org.springframework.stereotype.Service; +import org.springframework.web.bind.annotation.RestController; + +import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.*; + +/** + * 컨트롤러 아키텍처 규칙 검증 테스트 + */ +@AnalyzeClasses(packages = "com.payper.server") +class ControllerArchitectureTest { + + private static final String CONTROLLER_PKG = "..controller.."; + private static final String REPOSITORY_PKG = "..repository.."; + + /** 개발/테스트용 임시 컨트롤러 패키지 - 계층 규칙 적용 제외 */ + private static final String TEST_UTIL_PKG = "com.payper.server.domain.test.."; + + /** + * @RestController 어노테이션이 선언되어야 한다. + */ + @ArchTest + static ArchRule controllerMustBeAnnotatedWithRestController = + classes() + .that().haveSimpleNameEndingWith("Controller") + .should().beAnnotatedWith(RestController.class); + + /** + * Repository에 의존하면 안 된다. + */ + @ArchTest + static ArchRule controllerMustNotDependOnRepository = + noClasses() + .that().resideInAPackage(CONTROLLER_PKG) + .should().dependOnClassesThat().resideInAPackage(REPOSITORY_PKG); + + /** + * 반드시 Service에 의존해야 한다. + */ + @ArchTest + static ArchRule controllerMustDependOnService = + classes() + .that().areAnnotatedWith(RestController.class) + .and().resideOutsideOfPackage(TEST_UTIL_PKG) + .should().dependOnClassesThat().areAnnotatedWith(Service.class); + + /** + * Controller는 Swagger Api 인터페이스를 구현해야 한다. + *
+ * API 문서화를 강제하여 모든 Controller가 명세서를 가지도록 한다. + */ + @ArchTest + static ArchRule controllerMustImplementApiInterface = + classes() + .that().areAnnotatedWith(RestController.class) + .and().resideOutsideOfPackage(TEST_UTIL_PKG) + .should(new ArchCondition("이름이 'Api'로 끝나는 인터페이스를 구현해야 한다") { + @Override + public void check(JavaClass javaClass, ConditionEvents events) { + boolean implementsApi = javaClass.getInterfaces() + .stream() + .anyMatch(iface -> iface.toErasure().getSimpleName().endsWith("Api")); + if (!implementsApi) { + events.add(SimpleConditionEvent.violated( + javaClass, + javaClass.getName() + " 는 'Api'로 끝나는 인터페이스를 구현하지 않습니다." + )); + } + } + }); + + /** + * controller 패키지에만 있어야 한다. + */ + @ArchTest + static ArchRule controllerMustResideInControllerPackage = + classes() + .that().haveSimpleNameEndingWith("Controller") + .and().resideOutsideOfPackage(TEST_UTIL_PKG) + .should().resideInAPackage(CONTROLLER_PKG); +} diff --git a/src/test/java/com/payper/server/DtoArchitectureTest.java b/src/test/java/com/payper/server/DtoArchitectureTest.java new file mode 100644 index 0000000..35312b9 --- /dev/null +++ b/src/test/java/com/payper/server/DtoArchitectureTest.java @@ -0,0 +1,27 @@ +package com.payper.server; + +import com.tngtech.archunit.junit.AnalyzeClasses; +import com.tngtech.archunit.junit.ArchTest; +import com.tngtech.archunit.lang.ArchRule; + +import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.classes; + +/** + * DTO 아키텍처 규칙 검증 테스트 + */ +@AnalyzeClasses(packages = "com.payper.server") +class DtoArchitectureTest { + + private static final String DTO_PKG = "..dto.."; + + /** + * dto 패키지에 위치해야 한다. + */ + @ArchTest + static ArchRule dtoMustResideInDtoPackage = + classes() + .that().haveSimpleNameEndingWith("Request") + .or().haveSimpleNameEndingWith("Response") + .and().resideOutsideOfPackage("com.payper.server.global..") // TODO: domain 이동 후 변경 + .should().resideInAPackage(DTO_PKG); +} diff --git a/src/test/java/com/payper/server/EntityArchitectureTest.java b/src/test/java/com/payper/server/EntityArchitectureTest.java new file mode 100644 index 0000000..3023f17 --- /dev/null +++ b/src/test/java/com/payper/server/EntityArchitectureTest.java @@ -0,0 +1,41 @@ +package com.payper.server; + +import com.tngtech.archunit.junit.AnalyzeClasses; +import com.tngtech.archunit.junit.ArchTest; +import com.tngtech.archunit.lang.ArchRule; +import jakarta.persistence.Entity; +import jakarta.persistence.MappedSuperclass; + +import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.*; + +/** + * Entity 아키텍처 규칙 검증 테스트 + */ +@AnalyzeClasses(packages = "com.payper.server") +class EntityArchitectureTest { + + private static final String CONTROLLER_PKG = "..controller.."; + private static final String SERVICE_PKG = "..service.."; + private static final String ENTITY_PKG = "..entity.."; + + /** + * @Entity 어노테이션이 선언되어야 한다. + */ + @ArchTest + static ArchRule entityClassMustHaveEntityAnnotation = + classes() + .that().resideInAPackage(ENTITY_PKG) + .and().areNotEnums() + .and().areNotAnnotatedWith(MappedSuperclass.class) + .and().areTopLevelClasses() + .should().beAnnotatedWith(Entity.class); + + /** + * Service 또는 Controller 계층을 의존하면 안 된다. + */ + @ArchTest + static ArchRule entityMustNotDependOnServiceOrController = + noClasses() + .that().resideInAPackage(ENTITY_PKG) + .should().dependOnClassesThat().resideInAnyPackage(SERVICE_PKG, CONTROLLER_PKG); +} diff --git a/src/test/java/com/payper/server/JwtModulesSpringBootIntegrationTest.java b/src/test/java/com/payper/server/JwtModulesSpringBootIntegrationTest.java index 134780c..e24523c 100644 --- a/src/test/java/com/payper/server/JwtModulesSpringBootIntegrationTest.java +++ b/src/test/java/com/payper/server/JwtModulesSpringBootIntegrationTest.java @@ -1,7 +1,7 @@ package com.payper.server; -import com.payper.server.auth.AuthException; -import com.payper.server.auth.jwt.RefreshTokenRepository; +import com.payper.server.global.exception.AuthException; +import com.payper.server.auth.jwt.repository.RefreshTokenRepository; import com.payper.server.auth.jwt.entity.RefreshTokenEntity; import com.payper.server.auth.jwt.entity.JwtType; import com.payper.server.auth.jwt.util.JwtParseUtil; diff --git a/src/test/java/com/payper/server/RepositoryArchitectureTest.java b/src/test/java/com/payper/server/RepositoryArchitectureTest.java new file mode 100644 index 0000000..effa68c --- /dev/null +++ b/src/test/java/com/payper/server/RepositoryArchitectureTest.java @@ -0,0 +1,37 @@ +package com.payper.server; + +import com.tngtech.archunit.junit.AnalyzeClasses; +import com.tngtech.archunit.junit.ArchTest; +import com.tngtech.archunit.lang.ArchRule; + +import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.*; + +/** + * Repository 아키텍처 규칙 검증 테스트 + */ +@AnalyzeClasses(packages = "com.payper.server") +class RepositoryArchitectureTest { + + private static final String CONTROLLER_PKG = "..controller.."; + private static final String SERVICE_PKG = "..service.."; + private static final String REPOSITORY_PKG = "..repository.."; + + /** + * Service 또는 Controller 계층을 참조하면 안 된다. + */ + @ArchTest + static ArchRule repositoryMustNotDependOnOtherLayers = + noClasses() + .that().resideInAPackage(REPOSITORY_PKG) + .should().dependOnClassesThat().resideInAnyPackage(SERVICE_PKG, CONTROLLER_PKG); + + /** + * repository 패키지에 있어야 한다. + */ + @ArchTest + static ArchRule repositoryMustResideInRepositoryPackage = + classes() + .that().haveSimpleNameContaining("Repository") + .and().doNotHaveSimpleName("RepositoryArchitectureTest") + .should().resideInAPackage(REPOSITORY_PKG); +} diff --git a/src/test/java/com/payper/server/ServiceArchitectureTest.java b/src/test/java/com/payper/server/ServiceArchitectureTest.java new file mode 100644 index 0000000..64127bb --- /dev/null +++ b/src/test/java/com/payper/server/ServiceArchitectureTest.java @@ -0,0 +1,49 @@ +package com.payper.server; + +import com.tngtech.archunit.junit.AnalyzeClasses; +import com.tngtech.archunit.junit.ArchTest; +import com.tngtech.archunit.lang.ArchRule; +import org.springframework.stereotype.Service; + +import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.*; + +/** + * 서비스 아키텍처 규칙 검증 테스트 + */ +@AnalyzeClasses(packages = "com.payper.server") +class ServiceArchitectureTest { + + private static final String CONTROLLER_PKG = "..controller.."; + private static final String SERVICE_PKG = "..service.."; + + /** + * @Service 어노테이션이 선언되어야 한다. + */ + @ArchTest + static ArchRule serviceMustBeAnnotatedWithService = + classes() + .that().resideInAPackage(SERVICE_PKG) + .and().areNotInterfaces() + .and().areTopLevelClasses() + .should().beAnnotatedWith(Service.class); + + /** + * Controller를 참조하면 안 된다. + */ + @ArchTest + static ArchRule serviceMustNotDependOnController = + noClasses() + .that().resideInAPackage(SERVICE_PKG) + .should().dependOnClassesThat().resideInAPackage(CONTROLLER_PKG); + + /** + * service 패키지에 있어야 한다. + */ + @ArchTest + static ArchRule serviceMustResideInServicePackage = + classes() + .that().haveSimpleNameContaining("Service") + .and().resideOutsideOfPackage("..security..") + .and().doNotHaveSimpleName("ServiceArchitectureTest") + .should().resideInAPackage(SERVICE_PKG); +} diff --git a/src/test/java/com/payper/server/SwaggerArchitectureTest.java b/src/test/java/com/payper/server/SwaggerArchitectureTest.java new file mode 100644 index 0000000..f391897 --- /dev/null +++ b/src/test/java/com/payper/server/SwaggerArchitectureTest.java @@ -0,0 +1,44 @@ +package com.payper.server; + +import com.tngtech.archunit.junit.AnalyzeClasses; +import com.tngtech.archunit.junit.ArchTest; +import com.tngtech.archunit.lang.ArchRule; +import io.swagger.v3.oas.annotations.tags.Tag; + +import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.classes; + +/** + * Swagger Api 인터페이스 아키텍처 규칙 검증 테스트 + */ +@AnalyzeClasses(packages = "com.payper.server") +class SwaggerArchitectureTest { + + private static final String CONTROLLER_PKG = "..controller.."; + + /** + * Swagger API 문서는 인터페이스여야 한다. + */ + @ArchTest + static ArchRule apiMustBeInterface = + classes() + .that().haveSimpleNameEndingWith("Api") + .should().beInterfaces(); + + /** + * @Tag 어노테이션이 선언되어야 한다. + */ + @ArchTest + static ArchRule apiInterfaceMustHaveTagAnnotation = + classes() + .that().haveSimpleNameEndingWith("Api") + .should().beAnnotatedWith(Tag.class); + + /** + * controller 패키지에 있어야 한다. + */ + @ArchTest + static ArchRule apiInterfaceMustResideInControllerPackage = + classes() + .that().haveSimpleNameEndingWith("Api") + .should().resideInAPackage(CONTROLLER_PKG); +} diff --git a/src/test/java/com/payper/server/UserAndRefreshTokenJpaTest.java b/src/test/java/com/payper/server/UserAndRefreshTokenJpaTest.java index 35be51e..933ac3b 100644 --- a/src/test/java/com/payper/server/UserAndRefreshTokenJpaTest.java +++ b/src/test/java/com/payper/server/UserAndRefreshTokenJpaTest.java @@ -1,7 +1,7 @@ package com.payper.server; import com.payper.server.auth.jwt.entity.RefreshTokenEntity; -import com.payper.server.auth.jwt.RefreshTokenRepository; +import com.payper.server.auth.jwt.repository.RefreshTokenRepository; import com.payper.server.security.CustomUserDetails; import com.payper.server.user.repository.UserRepository; import com.payper.server.user.entity.AuthType; From 10d284646905ef0949057b6f77de8d419ad3c9fe Mon Sep 17 00:00:00 2001 From: seoyeon2001 Date: Fri, 27 Feb 2026 01:38:50 +0900 Subject: [PATCH 3/6] =?UTF-8?q?refactor:=20=EA=B3=84=EC=B8=B5=EB=B3=84=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EB=B6=84=EB=A6=AC=20=EB=B0=8F=20?= =?UTF-8?q?=ED=95=B5=EC=8B=AC=20=ED=85=8C=EC=8A=A4=ED=8A=B8=EB=A7=8C=20?= =?UTF-8?q?=EB=82=A8=EA=B9=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../payper/server/GlobalArchitectureTest.java | 478 ++---------------- 1 file changed, 44 insertions(+), 434 deletions(-) diff --git a/src/test/java/com/payper/server/GlobalArchitectureTest.java b/src/test/java/com/payper/server/GlobalArchitectureTest.java index 9300f7f..5be3fa3 100644 --- a/src/test/java/com/payper/server/GlobalArchitectureTest.java +++ b/src/test/java/com/payper/server/GlobalArchitectureTest.java @@ -1,479 +1,89 @@ package com.payper.server; -import com.tngtech.archunit.core.domain.JavaClass; import com.tngtech.archunit.core.importer.ImportOption; import com.tngtech.archunit.junit.AnalyzeClasses; import com.tngtech.archunit.junit.ArchTest; -import com.tngtech.archunit.lang.ArchCondition; import com.tngtech.archunit.lang.ArchRule; -import com.tngtech.archunit.lang.ConditionEvents; -import com.tngtech.archunit.lang.SimpleConditionEvent; -import io.swagger.v3.oas.annotations.tags.Tag; -import jakarta.persistence.Entity; -import jakarta.persistence.MappedSuperclass; -import org.springframework.stereotype.Repository; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; -import org.springframework.web.bind.annotation.RestController; +import com.tngtech.archunit.library.Architectures; +import org.springframework.beans.factory.annotation.Autowired; import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.*; import static com.tngtech.archunit.library.Architectures.layeredArchitecture; -import static com.tngtech.archunit.library.dependencies.SlicesRuleDefinition.slices; /** - * 전체 아키텍처 규칙 검증 테스트 + * 공통 아키텍처 규칙 검증 테스트 * - *

현재 알려진 위반 목록 (기존 코드 - 리팩토링 대상)

+ *

특정 계층에 국한되지 않는 전역 규칙을 검증한다. *

- * - *

제외 대상

- * */ @AnalyzeClasses( packages = "com.payper.server", - importOptions = {ImportOption.DoNotIncludeTests.class} + importOptions = {ImportOption.DoNotIncludeTests.class} // TODO: test 패키지의 테스트 클래스들은 제외 ) class GlobalArchitectureTest { private static final String CONTROLLER_PKG = "..controller.."; private static final String SERVICE_PKG = "..service.."; private static final String REPOSITORY_PKG = "..repository.."; - private static final String ENTITY_PKG = "..entity.."; - - /** 개발/테스트용 임시 컨트롤러 패키지 - 계층 규칙 적용 제외 */ - private static final String TEST_UTIL_PKG = "com.payper.server.domain.test.."; - - - // ========================================================================== - // 의존성 방향 규칙 - // ========================================================================== - - /** - * 규칙 1 & 4: Controller는 Repository에 의존하면 안 된다. - * - *

Controller는 비즈니스 로직을 Service에 위임해야 하며, 데이터 접근 계층인 - * Repository에 직접 접근해서는 안 된다. - */ - @ArchTest - static ArchRule rule1_4_controllerMustNotDependOnRepository = - noClasses() - .that().resideInAPackage(CONTROLLER_PKG) - .should().dependOnClassesThat().resideInAPackage(REPOSITORY_PKG) - .as("[규칙 1, 4] Controller는 Repository에 직접 의존하면 안 된다"); - - /** - * 규칙 2: Controller는 반드시 Service에 의존해야 한다. - * - *

도메인 로직이 없는 개발용 컨트롤러(domain.test)는 제외한다. - */ - @ArchTest - static ArchRule rule2_controllerMustDependOnService = - classes() - .that().areAnnotatedWith(RestController.class) - .and().resideOutsideOfPackage(TEST_UTIL_PKG) - .should(new ArchCondition("[규칙 2] Controller는 반드시 @Service 클래스에 의존해야 한다") { - @Override - public void check(JavaClass javaClass, ConditionEvents events) { - boolean hasServiceDependency = javaClass.getDirectDependenciesFromSelf() - .stream() - .anyMatch(dep -> dep.getTargetClass().isAnnotatedWith(Service.class)); - - if (!hasServiceDependency) { - events.add(SimpleConditionEvent.violated( - javaClass, - javaClass.getName() + " 는 @Service 클래스에 의존하지 않습니다." - )); - } - } - }); - - /** - * 규칙 3: Service는 반드시 Repository에 의존해야 한다. - * - *

Service 클래스는 데이터 접근을 위해 반드시 하나 이상의 Repository를 직접 주입받아야 한다. - * - *

알려진 위반: AuthService는 UserService에 위임하는 구조라 직접 Repository 의존이 없다. - */ - @ArchTest - static ArchRule rule3_serviceMustDependOnRepository = - classes() - .that().areAnnotatedWith(Service.class) - .should(new ArchCondition("[규칙 3] Service는 반드시 Repository에 의존해야 한다") { - @Override - public void check(JavaClass javaClass, ConditionEvents events) { - boolean hasRepoDependency = javaClass.getDirectDependenciesFromSelf() - .stream() - .anyMatch(dep -> - dep.getTargetClass().isAnnotatedWith(Repository.class) - || dep.getTargetClass().getPackageName().contains(".repository") - ); - - if (!hasRepoDependency) { - events.add(SimpleConditionEvent.violated( - javaClass, - javaClass.getName() + " 는 Repository 클래스에 의존하지 않습니다." - )); - } - } - }); - - /** - * 규칙 5: Service는 Controller를 참조하면 안 된다. - * - *

하위 계층(Service)이 상위 계층(Controller)을 알아서는 안 된다. - */ - @ArchTest - static ArchRule rule5_serviceMustNotDependOnController = - noClasses() - .that().areAnnotatedWith(Service.class) - .should().dependOnClassesThat().areAnnotatedWith(RestController.class) - .as("[규칙 5] Service는 Controller를 참조하면 안 된다"); - - /** - * 규칙 6: Repository는 다른 계층(Service, Controller)을 참조하면 안 된다. - * - *

데이터 접근 계층인 Repository는 비즈니스/프레젠테이션 계층에 의존해서는 안 된다. - */ - @ArchTest - static ArchRule rule6_repositoryMustNotDependOnOtherLayers = - noClasses() - .that().resideInAPackage(REPOSITORY_PKG) - .should().dependOnClassesThat().resideInAnyPackage(SERVICE_PKG, CONTROLLER_PKG) - .as("[규칙 6] Repository는 Service/Controller 계층을 참조하면 안 된다"); - - - // ========================================================================== - // 인터페이스 구현 규칙 - // ========================================================================== - /** - * 규칙 7: Controller는 Swagger Api 인터페이스를 구현해야 한다. - * - *

API 문서화를 강제하여 모든 Controller가 명세(Api 인터페이스)를 가지도록 한다. - * 개발용 테스트 컨트롤러(domain.test)는 제외한다. - */ - @ArchTest - static ArchRule rule7_controllerMustImplementApiInterface = - classes() - .that().areAnnotatedWith(RestController.class) - .and().resideOutsideOfPackage(TEST_UTIL_PKG) - .should(new ArchCondition("[규칙 7] Controller는 이름이 'Api'로 끝나는 인터페이스를 구현해야 한다") { - @Override - public void check(JavaClass javaClass, ConditionEvents events) { - boolean implementsApi = javaClass.getInterfaces() - .stream() - .anyMatch(iface -> iface.toErasure().getSimpleName().endsWith("Api")); - - if (!implementsApi) { - events.add(SimpleConditionEvent.violated( - javaClass, - javaClass.getName() + " 는 'Api'로 끝나는 인터페이스를 구현하지 않습니다." - )); - } - } - }); - - - // ========================================================================== - // 명명 규칙 - // ========================================================================== - - /** - * 규칙 8: @RestController 클래스의 이름은 "Controller"로 끝나야 한다. - */ - @ArchTest - static ArchRule rule8_controllerClassNameMustEndWithController = - classes() - .that().areAnnotatedWith(RestController.class) - .should().haveSimpleNameEndingWith("Controller") - .as("[규칙 8] @RestController 클래스의 이름은 'Controller'로 끝나야 한다"); - - /** - * 규칙 9: @Service 클래스의 이름은 "Service"로 끝나야 한다. - */ - @ArchTest - static ArchRule rule9_serviceClassNameMustEndWithService = - classes() - .that().areAnnotatedWith(Service.class) - .should().haveSimpleNameEndingWith("Service") - .as("[규칙 9] @Service 클래스의 이름은 'Service'로 끝나야 한다"); - - /** - * 규칙 10: repository 패키지 내 클래스/인터페이스의 이름은 "Repository"로 끝나야 한다. - */ - @ArchTest - static ArchRule rule10_repositoryClassNameMustEndWithRepository = - classes() - .that().resideInAPackage(REPOSITORY_PKG) - .should().haveSimpleNameEndingWith("Repository") - .as("[규칙 10] repository 패키지의 클래스는 이름이 'Repository'로 끝나야 한다"); - - - // ========================================================================== - // 어노테이션 규칙 - // ========================================================================== - - /** - * 규칙 11: controller 패키지의 Controller 클래스는 @RestController 어노테이션이 있어야 한다. - */ - @ArchTest - static ArchRule rule11_controllerMustBeAnnotatedWithRestController = - classes() - .that().resideInAPackage(CONTROLLER_PKG) - .and().haveSimpleNameEndingWith("Controller") - .should().beAnnotatedWith(RestController.class) - .as("[규칙 11] controller 패키지의 Controller 클래스는 @RestController 어노테이션이 있어야 한다"); - - /** - * 규칙 12: service 패키지의 Service 클래스는 @Service 어노테이션이 있어야 한다. - */ - @ArchTest - static ArchRule rule12_serviceMustBeAnnotatedWithService = - classes() - .that().resideInAPackage(SERVICE_PKG) - .and().haveSimpleNameEndingWith("Service") - .should().beAnnotatedWith(Service.class) - .as("[규칙 12] service 패키지의 Service 클래스는 @Service 어노테이션이 있어야 한다"); - - /** - * 규칙 13: repository 패키지의 인터페이스는 @Repository 어노테이션이 있어야 한다. - * - *

Spring Data JPA가 자동으로 빈을 등록하더라도, 명시적 선언을 통해 의도를 드러낸다. - * - *

알려진 위반: UserRepository, RefreshTokenRepository에 @Repository 미선언. - */ - @ArchTest - static ArchRule rule13_repositoryMustBeAnnotatedWithRepository = - classes() - .that().resideInAPackage(REPOSITORY_PKG) - .and().areInterfaces() - .should().beAnnotatedWith(Repository.class) - .as("[규칙 13] repository 패키지의 인터페이스는 @Repository 어노테이션이 있어야 한다"); + private static final String CONTROLLER_LAYER = "Controller"; + private static final String SERVICE_LAYER = "Service"; + private static final String REPOSITORY_LAYER = "Repository"; /** - * 규칙 14: "Api"로 끝나는 인터페이스는 @Tag 어노테이션이 있어야 한다. - * - *

Swagger 문서에서 API 그룹을 명확히 하기 위해 @Tag 선언을 강제한다. + * 계층 간 의존 흐름은 Controller → Service → Repository 순방향이어야 한다. */ @ArchTest - static ArchRule rule14_apiInterfaceMustHaveTagAnnotation = - classes() - .that().haveSimpleNameEndingWith("Api") - .and().areInterfaces() - .should().beAnnotatedWith(Tag.class) - .as("[규칙 14] 'Api'로 끝나는 인터페이스는 @Tag 어노테이션이 있어야 한다"); - - /** - * 규칙 15: entity 패키지의 enum이 아닌 클래스는 @Entity 어노테이션이 있어야 한다. - * - *

@MappedSuperclass가 붙은 공통 기반 클래스(BaseTimeEntity 등)는 제외한다. - */ - @ArchTest - static ArchRule rule15_entityClassMustHaveEntityAnnotation = - classes() - .that().resideInAPackage(ENTITY_PKG) - .and().areNotEnums() - .and().areNotAnnotatedWith(MappedSuperclass.class) - .should().beAnnotatedWith(Entity.class) - .as("[규칙 15] entity 패키지의 클래스(enum · @MappedSuperclass 제외)는 @Entity 어노테이션이 있어야 한다"); - - - // ========================================================================== - // 패키지 배치 규칙 - // ========================================================================== - - /** - * 규칙 16: @RestController 클래스는 controller 패키지에만 있어야 한다. - * - *

개발용 컨트롤러(domain.test)는 제외한다. - * - *

알려진 위반: AuthController가 auth 루트 패키지에 위치. - */ - @ArchTest - static ArchRule rule16_controllerMustResideInControllerPackage = - classes() - .that().areAnnotatedWith(RestController.class) - .and().resideOutsideOfPackage(TEST_UTIL_PKG) - .should().resideInAPackage(CONTROLLER_PKG) - .as("[규칙 16] @RestController 클래스는 controller 패키지에 위치해야 한다"); - - /** - * 규칙 17: @Service 클래스는 service 패키지에만 있어야 한다. - * - *

알려진 위반: AuthService(auth 패키지), UserService(user 패키지)가 service 하위 패키지가 아닌 곳에 위치. - */ - @ArchTest - static ArchRule rule17_serviceMustResideInServicePackage = - classes() - .that().areAnnotatedWith(Service.class) - .should().resideInAPackage(SERVICE_PKG) - .as("[규칙 17] @Service 클래스는 service 패키지에 위치해야 한다"); - - /** - * 규칙 18: @Repository 인터페이스는 repository 패키지에만 있어야 한다. - * - *

알려진 위반: RefreshTokenRepository가 auth.jwt 패키지에 위치. - */ - @ArchTest - static ArchRule rule18_repositoryMustResideInRepositoryPackage = - classes() - .that().areAnnotatedWith(Repository.class) - .should().resideInAPackage(REPOSITORY_PKG) - .as("[규칙 18] @Repository 인터페이스는 repository 패키지에 위치해야 한다"); - - /** - * 규칙 19: "Api"로 끝나는 인터페이스는 controller 패키지에 있어야 한다. - * - *

알려진 위반: AuthApi가 auth 루트 패키지에 위치. - */ - @ArchTest - static ArchRule rule19_apiInterfaceMustResideInControllerPackage = - classes() - .that().haveSimpleNameEndingWith("Api") - .and().areInterfaces() - .should().resideInAPackage(CONTROLLER_PKG) - .as("[규칙 19] 'Api'로 끝나는 인터페이스는 controller 패키지에 위치해야 한다"); - - - // ========================================================================== - // 순환 참조 방지 - // ========================================================================== - - /** - * 규칙 20: 도메인 패키지 간 순환 참조는 없어야 한다. - * - *

최상위 도메인 슬라이스(auth, post, comment, merchant, user 등)끼리 - * 순환 의존이 생기면 안 된다. - */ - @ArchTest - static ArchRule rule20_noCyclicDependencies = - slices() - .matching("com.payper.server.(*)..") - .should().beFreeOfCycles() - .as("[규칙 20] 도메인 패키지 간 순환 참조가 없어야 한다"); - - - // ========================================================================== - // 타입 규칙 - // ========================================================================== - - /** - * 규칙 21: "Repository"로 끝나는 클래스/인터페이스는 반드시 인터페이스여야 한다. - * - *

구현체가 아닌 인터페이스 기반으로 Repository를 정의하여 Spring Data JPA와의 - * 일관성을 유지하고 테스트 확장성을 확보한다. - */ - @ArchTest - static ArchRule rule21_repositoryMustBeInterface = - classes() - .that().haveSimpleNameEndingWith("Repository") - .should().beInterfaces() - .as("[규칙 21] Repository는 인터페이스여야 한다"); - - - // ========================================================================== - // 계층 방향성 규칙 - // ========================================================================== - - /** - * 규칙 22: 계층 간 의존 흐름은 Controller → Service → Repository 순방향이어야 한다. - * - *

역방향 의존(Repository → Service, Service → Controller 등)은 허용하지 않는다. - * 각 계층 내부에서의 의존은 허용한다. - */ - @ArchTest - static ArchRule rule22_layeredArchitectureMustBeRespected = + static Architectures.LayeredArchitecture layeredArchitectureMustBeRespected = layeredArchitecture() .consideringOnlyDependenciesInLayers() - .layer("Controller").definedBy(CONTROLLER_PKG) - .layer("Service").definedBy(SERVICE_PKG) - .layer("Repository").definedBy(REPOSITORY_PKG) - .whereLayer("Controller").mayNotBeAccessedByAnyLayer() - .whereLayer("Service").mayOnlyBeAccessedByLayers("Controller") - .whereLayer("Repository").mayOnlyBeAccessedByLayers("Service") - .as("[규칙 22] 계층 간 의존 흐름은 Controller → Service → Repository 순방향이어야 한다"); - - - // ========================================================================== - // 추가 권장 규칙 - // ========================================================================== - - /** - * [권장 1] Service 클래스에는 클래스 레벨에 @Transactional 어노테이션이 있어야 한다. - * - *

메서드 단위의 누락을 방지하고, 기본 트랜잭션 전략을 명시적으로 선언하게 한다. - * 읽기 전용 메서드는 @Transactional(readOnly = true)로 오버라이드하도록 유도한다. - */ - @ArchTest - static ArchRule recommended1_serviceMustHaveClassLevelTransactional = - classes() - .that().areAnnotatedWith(Service.class) - .should().beAnnotatedWith(Transactional.class) - .as("[권장 1] Service 클래스는 클래스 레벨에 @Transactional이 있어야 한다"); - - /** - * [권장 2] Entity 클래스는 Service 또는 Controller 계층을 의존하면 안 된다. - * - *

도메인 모델의 순수성을 유지하기 위해 Entity가 상위 계층을 알아서는 안 된다. - */ - @ArchTest - static ArchRule recommended2_entityMustNotDependOnServiceOrController = - noClasses() - .that().resideInAPackage(ENTITY_PKG) - .should().dependOnClassesThat().resideInAnyPackage(SERVICE_PKG, CONTROLLER_PKG) - .as("[권장 2] Entity 클래스는 Service/Controller 계층을 의존하면 안 된다"); + .layer(CONTROLLER_LAYER).definedBy(CONTROLLER_PKG) + .layer(SERVICE_LAYER).definedBy(SERVICE_PKG) + .layer(REPOSITORY_LAYER).definedBy(REPOSITORY_PKG) + .whereLayer(CONTROLLER_LAYER).mayNotBeAccessedByAnyLayer() + .whereLayer(SERVICE_LAYER).mayOnlyBeAccessedByLayers(CONTROLLER_LAYER) + .whereLayer(REPOSITORY_LAYER).mayOnlyBeAccessedByLayers(SERVICE_LAYER); /** - * [권장 3] DTO 클래스(Request/Response)는 entity 패키지에 있으면 안 된다. - * - *

데이터 전송 객체와 도메인 모델을 명확히 분리하여 계층 간 결합을 낮춘다. + * global 패키지는 다른 도메인 패키지에 의존하면 안 된다. */ @ArchTest - static ArchRule recommended3_dtoMustNotResideInEntityPackage = + static ArchRule globalPackageMustNotDependOnDomainPackages = noClasses() - .that().haveSimpleNameEndingWith("Request") - .or().haveSimpleNameEndingWith("Response") - .should().resideInAPackage(ENTITY_PKG) - .as("[권장 3] DTO(Request/Response) 클래스는 entity 패키지에 있으면 안 된다"); + .that().resideInAPackage("com.payper.server.global..") + // .should().dependOnClassesThat().resideInAPackage(".domain..") TODO: domain 디렉토리 안으로 이동 후 적용 + .should().dependOnClassesThat().resideInAnyPackage( + "com.payper.server.auth..", + "com.payper.server.user..", + "com.payper.server.post..", + "com.payper.server.comment..", + "com.payper.server.merchant..", + "com.payper.server.favorite.." + ); /** - * [권장 4] @Autowired 필드 주입은 사용하면 안 된다. - * - *

생성자 주입(@RequiredArgsConstructor 또는 명시적 생성자)만 허용하여 - * 불변성 확보 및 테스트 용이성을 높인다. + * @Autowired 필드 주입은 사용하면 안 된다. + *
+ * 생성자 주입 또는 명시적 생성자만 허용한다. */ @ArchTest - static ArchRule recommended4_noFieldInjectionWithAutowired = + static ArchRule noFieldInjectionWithAutowired = noFields() - .should().beAnnotatedWith(org.springframework.beans.factory.annotation.Autowired.class) - .as("[권장 4] @Autowired 필드 주입 대신 생성자 주입을 사용해야 한다"); + .should().beAnnotatedWith(Autowired.class); /** - * [권장 5] global 패키지는 다른 도메인 패키지에 의존하면 안 된다. - * - *

공통 유틸리티/응답/예외 처리를 담당하는 global 패키지가 - * 특정 도메인에 결합되면 재사용성이 떨어진다. + * Service, Controller의 non-static 필드는 final이어야 한다. */ @ArchTest - static ArchRule recommended5_globalPackageMustNotDependOnDomainPackages = - noClasses() - .that().resideInAPackage("com.payper.server.global..") - .should().dependOnClassesThat().resideInAnyPackage( - "com.payper.server.post..", - "com.payper.server.comment..", - "com.payper.server.merchant..", - "com.payper.server.favorite..", - "com.payper.server.user..", - "com.payper.server.auth.." - ) - .as("[권장 5] global 패키지는 특정 도메인 패키지에 의존하면 안 된다"); + static final ArchRule componentsMustHaveOnlyFinalFields = + fields() + .that() + .areDeclaredInClassesThat() + .resideInAnyPackage(SERVICE_PKG, CONTROLLER_PKG) + .and().areNotStatic() + .should().beFinal(); } From ef44212ec61857797c6efeeb36153128b831976e Mon Sep 17 00:00:00 2001 From: seoyeon2001 Date: Fri, 27 Feb 2026 01:51:48 +0900 Subject: [PATCH 4/6] =?UTF-8?q?fix:=20=EC=BD=94=EB=93=9C=20=ED=8F=AC?= =?UTF-8?q?=EB=A7=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/controller/AuthController.java | 2 +- .../server/auth/jwt/util/JwtParseUtil.java | 2 +- .../auth/jwt/util/JwtRefreshTokenUtil.java | 2 +- .../server/auth/service/AuthService.java | 4 +- .../security/JwtAuthenticationProvider.java | 2 +- .../server/user/service/UserService.java | 2 +- .../server/ControllerArchitectureTest.java | 100 ++++++++---------- .../payper/server/DtoArchitectureTest.java | 27 +++-- .../payper/server/EntityArchitectureTest.java | 44 ++++---- .../payper/server/GlobalArchitectureTest.java | 98 +++++++++-------- .../JwtModulesSpringBootIntegrationTest.java | 7 +- .../server/RepositoryArchitectureTest.java | 38 ++++--- .../server/ServiceArchitectureTest.java | 60 +++++------ .../server/SwaggerArchitectureTest.java | 32 ++---- 14 files changed, 195 insertions(+), 225 deletions(-) diff --git a/src/main/java/com/payper/server/auth/controller/AuthController.java b/src/main/java/com/payper/server/auth/controller/AuthController.java index 7c8f471..75b427e 100644 --- a/src/main/java/com/payper/server/auth/controller/AuthController.java +++ b/src/main/java/com/payper/server/auth/controller/AuthController.java @@ -1,9 +1,9 @@ package com.payper.server.auth.controller; -import com.payper.server.auth.service.AuthService; import com.payper.server.auth.dto.request.LoginRequest; import com.payper.server.auth.dto.response.LoginSuccessResponse; import com.payper.server.auth.dto.response.ReissueSuccessResponse; +import com.payper.server.auth.service.AuthService; import com.payper.server.auth.util.OAuthUserInfo; import com.payper.server.global.response.ApiResponse; import com.payper.server.user.entity.AuthType; diff --git a/src/main/java/com/payper/server/auth/jwt/util/JwtParseUtil.java b/src/main/java/com/payper/server/auth/jwt/util/JwtParseUtil.java index 06b2c35..bb37789 100644 --- a/src/main/java/com/payper/server/auth/jwt/util/JwtParseUtil.java +++ b/src/main/java/com/payper/server/auth/jwt/util/JwtParseUtil.java @@ -1,7 +1,7 @@ package com.payper.server.auth.jwt.util; -import com.payper.server.global.exception.AuthException; import com.payper.server.auth.jwt.entity.JwtType; +import com.payper.server.global.exception.AuthException; import com.payper.server.global.response.ErrorCode; import io.jsonwebtoken.*; import io.jsonwebtoken.security.SignatureException; diff --git a/src/main/java/com/payper/server/auth/jwt/util/JwtRefreshTokenUtil.java b/src/main/java/com/payper/server/auth/jwt/util/JwtRefreshTokenUtil.java index f6ec0e6..582df2d 100644 --- a/src/main/java/com/payper/server/auth/jwt/util/JwtRefreshTokenUtil.java +++ b/src/main/java/com/payper/server/auth/jwt/util/JwtRefreshTokenUtil.java @@ -1,7 +1,7 @@ package com.payper.server.auth.jwt.util; -import com.payper.server.auth.jwt.repository.RefreshTokenRepository; import com.payper.server.auth.jwt.entity.RefreshTokenEntity; +import com.payper.server.auth.jwt.repository.RefreshTokenRepository; import io.jsonwebtoken.Jwts; import jakarta.annotation.PostConstruct; import jakarta.servlet.http.Cookie; diff --git a/src/main/java/com/payper/server/auth/service/AuthService.java b/src/main/java/com/payper/server/auth/service/AuthService.java index e197485..69481a7 100644 --- a/src/main/java/com/payper/server/auth/service/AuthService.java +++ b/src/main/java/com/payper/server/auth/service/AuthService.java @@ -1,6 +1,5 @@ package com.payper.server.auth.service; -import com.payper.server.global.exception.AuthException; import com.payper.server.auth.jwt.entity.JwtType; import com.payper.server.auth.jwt.entity.RefreshTokenEntity; import com.payper.server.auth.jwt.util.JwtParseUtil; @@ -9,11 +8,12 @@ import com.payper.server.auth.util.KakaoOAuthUtilImpl; import com.payper.server.auth.util.OAuthUserInfo; import com.payper.server.global.exception.ApiException; +import com.payper.server.global.exception.AuthException; import com.payper.server.global.response.ErrorCode; -import com.payper.server.user.service.UserService; import com.payper.server.user.entity.AuthType; import com.payper.server.user.entity.User; import com.payper.server.user.entity.UserRole; +import com.payper.server.user.service.UserService; import jakarta.servlet.http.HttpServletResponse; import java.util.Date; import java.util.Optional; diff --git a/src/main/java/com/payper/server/security/JwtAuthenticationProvider.java b/src/main/java/com/payper/server/security/JwtAuthenticationProvider.java index 4354143..ddb3c4d 100644 --- a/src/main/java/com/payper/server/security/JwtAuthenticationProvider.java +++ b/src/main/java/com/payper/server/security/JwtAuthenticationProvider.java @@ -1,8 +1,8 @@ package com.payper.server.security; -import com.payper.server.global.exception.AuthException; import com.payper.server.auth.jwt.entity.JwtType; import com.payper.server.auth.jwt.util.JwtParseUtil; +import com.payper.server.global.exception.AuthException; import com.payper.server.global.response.ErrorCode; import jakarta.annotation.Nullable; import lombok.RequiredArgsConstructor; diff --git a/src/main/java/com/payper/server/user/service/UserService.java b/src/main/java/com/payper/server/user/service/UserService.java index 953dc9f..866c839 100644 --- a/src/main/java/com/payper/server/user/service/UserService.java +++ b/src/main/java/com/payper/server/user/service/UserService.java @@ -1,7 +1,7 @@ package com.payper.server.user.service; -import com.payper.server.global.exception.AuthException; import com.payper.server.auth.util.OAuthUserInfo; +import com.payper.server.global.exception.AuthException; import com.payper.server.global.response.ErrorCode; import com.payper.server.user.entity.User; import com.payper.server.user.repository.UserRepository; diff --git a/src/test/java/com/payper/server/ControllerArchitectureTest.java b/src/test/java/com/payper/server/ControllerArchitectureTest.java index 3b08112..4e91aea 100644 --- a/src/test/java/com/payper/server/ControllerArchitectureTest.java +++ b/src/test/java/com/payper/server/ControllerArchitectureTest.java @@ -1,5 +1,7 @@ package com.payper.server; +import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.*; + import com.tngtech.archunit.core.domain.JavaClass; import com.tngtech.archunit.junit.AnalyzeClasses; import com.tngtech.archunit.junit.ArchTest; @@ -10,11 +12,7 @@ import org.springframework.stereotype.Service; import org.springframework.web.bind.annotation.RestController; -import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.*; - -/** - * 컨트롤러 아키텍처 규칙 검증 테스트 - */ +/** 컨트롤러 아키텍처 규칙 검증 테스트 */ @AnalyzeClasses(packages = "com.payper.server") class ControllerArchitectureTest { @@ -24,66 +22,60 @@ class ControllerArchitectureTest { /** 개발/테스트용 임시 컨트롤러 패키지 - 계층 규칙 적용 제외 */ private static final String TEST_UTIL_PKG = "com.payper.server.domain.test.."; - /** - * @RestController 어노테이션이 선언되어야 한다. - */ + /** @RestController 어노테이션이 선언되어야 한다. */ @ArchTest static ArchRule controllerMustBeAnnotatedWithRestController = - classes() - .that().haveSimpleNameEndingWith("Controller") - .should().beAnnotatedWith(RestController.class); + classes().that().haveSimpleNameEndingWith("Controller").should().beAnnotatedWith(RestController.class); - /** - * Repository에 의존하면 안 된다. - */ + /** Repository에 의존하면 안 된다. */ @ArchTest - static ArchRule controllerMustNotDependOnRepository = - noClasses() - .that().resideInAPackage(CONTROLLER_PKG) - .should().dependOnClassesThat().resideInAPackage(REPOSITORY_PKG); + static ArchRule controllerMustNotDependOnRepository = noClasses() + .that() + .resideInAPackage(CONTROLLER_PKG) + .should() + .dependOnClassesThat() + .resideInAPackage(REPOSITORY_PKG); - /** - * 반드시 Service에 의존해야 한다. - */ + /** 반드시 Service에 의존해야 한다. */ @ArchTest - static ArchRule controllerMustDependOnService = - classes() - .that().areAnnotatedWith(RestController.class) - .and().resideOutsideOfPackage(TEST_UTIL_PKG) - .should().dependOnClassesThat().areAnnotatedWith(Service.class); + static ArchRule controllerMustDependOnService = classes() + .that() + .areAnnotatedWith(RestController.class) + .and() + .resideOutsideOfPackage(TEST_UTIL_PKG) + .should() + .dependOnClassesThat() + .areAnnotatedWith(Service.class); /** - * Controller는 Swagger Api 인터페이스를 구현해야 한다. - *
+ * Controller는 Swagger Api 인터페이스를 구현해야 한다.
* API 문서화를 강제하여 모든 Controller가 명세서를 가지도록 한다. */ @ArchTest - static ArchRule controllerMustImplementApiInterface = - classes() - .that().areAnnotatedWith(RestController.class) - .and().resideOutsideOfPackage(TEST_UTIL_PKG) - .should(new ArchCondition("이름이 'Api'로 끝나는 인터페이스를 구현해야 한다") { - @Override - public void check(JavaClass javaClass, ConditionEvents events) { - boolean implementsApi = javaClass.getInterfaces() - .stream() - .anyMatch(iface -> iface.toErasure().getSimpleName().endsWith("Api")); - if (!implementsApi) { - events.add(SimpleConditionEvent.violated( - javaClass, - javaClass.getName() + " 는 'Api'로 끝나는 인터페이스를 구현하지 않습니다." - )); - } - } - }); + static ArchRule controllerMustImplementApiInterface = classes() + .that() + .areAnnotatedWith(RestController.class) + .and() + .resideOutsideOfPackage(TEST_UTIL_PKG) + .should(new ArchCondition("이름이 'Api'로 끝나는 인터페이스를 구현해야 한다") { + @Override + public void check(JavaClass javaClass, ConditionEvents events) { + boolean implementsApi = javaClass.getInterfaces().stream() + .anyMatch(iface -> iface.toErasure().getSimpleName().endsWith("Api")); + if (!implementsApi) { + events.add(SimpleConditionEvent.violated( + javaClass, javaClass.getName() + " 는 'Api'로 끝나는 인터페이스를 구현하지 않습니다.")); + } + } + }); - /** - * controller 패키지에만 있어야 한다. - */ + /** controller 패키지에만 있어야 한다. */ @ArchTest - static ArchRule controllerMustResideInControllerPackage = - classes() - .that().haveSimpleNameEndingWith("Controller") - .and().resideOutsideOfPackage(TEST_UTIL_PKG) - .should().resideInAPackage(CONTROLLER_PKG); + static ArchRule controllerMustResideInControllerPackage = classes() + .that() + .haveSimpleNameEndingWith("Controller") + .and() + .resideOutsideOfPackage(TEST_UTIL_PKG) + .should() + .resideInAPackage(CONTROLLER_PKG); } diff --git a/src/test/java/com/payper/server/DtoArchitectureTest.java b/src/test/java/com/payper/server/DtoArchitectureTest.java index 35312b9..db2d35c 100644 --- a/src/test/java/com/payper/server/DtoArchitectureTest.java +++ b/src/test/java/com/payper/server/DtoArchitectureTest.java @@ -1,27 +1,26 @@ package com.payper.server; +import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.classes; + import com.tngtech.archunit.junit.AnalyzeClasses; import com.tngtech.archunit.junit.ArchTest; import com.tngtech.archunit.lang.ArchRule; -import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.classes; - -/** - * DTO 아키텍처 규칙 검증 테스트 - */ +/** DTO 아키텍처 규칙 검증 테스트 */ @AnalyzeClasses(packages = "com.payper.server") class DtoArchitectureTest { private static final String DTO_PKG = "..dto.."; - /** - * dto 패키지에 위치해야 한다. - */ + /** dto 패키지에 위치해야 한다. */ @ArchTest - static ArchRule dtoMustResideInDtoPackage = - classes() - .that().haveSimpleNameEndingWith("Request") - .or().haveSimpleNameEndingWith("Response") - .and().resideOutsideOfPackage("com.payper.server.global..") // TODO: domain 이동 후 변경 - .should().resideInAPackage(DTO_PKG); + static ArchRule dtoMustResideInDtoPackage = classes() + .that() + .haveSimpleNameEndingWith("Request") + .or() + .haveSimpleNameEndingWith("Response") + .and() + .resideOutsideOfPackage("com.payper.server.global..") // TODO: domain 이동 후 변경 + .should() + .resideInAPackage(DTO_PKG); } diff --git a/src/test/java/com/payper/server/EntityArchitectureTest.java b/src/test/java/com/payper/server/EntityArchitectureTest.java index 3023f17..4f1e461 100644 --- a/src/test/java/com/payper/server/EntityArchitectureTest.java +++ b/src/test/java/com/payper/server/EntityArchitectureTest.java @@ -1,16 +1,14 @@ package com.payper.server; +import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.*; + import com.tngtech.archunit.junit.AnalyzeClasses; import com.tngtech.archunit.junit.ArchTest; import com.tngtech.archunit.lang.ArchRule; import jakarta.persistence.Entity; import jakarta.persistence.MappedSuperclass; -import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.*; - -/** - * Entity 아키텍처 규칙 검증 테스트 - */ +/** Entity 아키텍처 규칙 검증 테스트 */ @AnalyzeClasses(packages = "com.payper.server") class EntityArchitectureTest { @@ -18,24 +16,26 @@ class EntityArchitectureTest { private static final String SERVICE_PKG = "..service.."; private static final String ENTITY_PKG = "..entity.."; - /** - * @Entity 어노테이션이 선언되어야 한다. - */ + /** @Entity 어노테이션이 선언되어야 한다. */ @ArchTest - static ArchRule entityClassMustHaveEntityAnnotation = - classes() - .that().resideInAPackage(ENTITY_PKG) - .and().areNotEnums() - .and().areNotAnnotatedWith(MappedSuperclass.class) - .and().areTopLevelClasses() - .should().beAnnotatedWith(Entity.class); + static ArchRule entityClassMustHaveEntityAnnotation = classes() + .that() + .resideInAPackage(ENTITY_PKG) + .and() + .areNotEnums() + .and() + .areNotAnnotatedWith(MappedSuperclass.class) + .and() + .areTopLevelClasses() + .should() + .beAnnotatedWith(Entity.class); - /** - * Service 또는 Controller 계층을 의존하면 안 된다. - */ + /** Service 또는 Controller 계층을 의존하면 안 된다. */ @ArchTest - static ArchRule entityMustNotDependOnServiceOrController = - noClasses() - .that().resideInAPackage(ENTITY_PKG) - .should().dependOnClassesThat().resideInAnyPackage(SERVICE_PKG, CONTROLLER_PKG); + static ArchRule entityMustNotDependOnServiceOrController = noClasses() + .that() + .resideInAPackage(ENTITY_PKG) + .should() + .dependOnClassesThat() + .resideInAnyPackage(SERVICE_PKG, CONTROLLER_PKG); } diff --git a/src/test/java/com/payper/server/GlobalArchitectureTest.java b/src/test/java/com/payper/server/GlobalArchitectureTest.java index 5be3fa3..d56f43b 100644 --- a/src/test/java/com/payper/server/GlobalArchitectureTest.java +++ b/src/test/java/com/payper/server/GlobalArchitectureTest.java @@ -1,5 +1,8 @@ package com.payper.server; +import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.*; +import static com.tngtech.archunit.library.Architectures.layeredArchitecture; + import com.tngtech.archunit.core.importer.ImportOption; import com.tngtech.archunit.junit.AnalyzeClasses; import com.tngtech.archunit.junit.ArchTest; @@ -7,23 +10,21 @@ import com.tngtech.archunit.library.Architectures; import org.springframework.beans.factory.annotation.Autowired; -import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.*; -import static com.tngtech.archunit.library.Architectures.layeredArchitecture; - /** * 공통 아키텍처 규칙 검증 테스트 * *

특정 계층에 국한되지 않는 전역 규칙을 검증한다. + * *

*/ @AnalyzeClasses( packages = "com.payper.server", - importOptions = {ImportOption.DoNotIncludeTests.class} // TODO: test 패키지의 테스트 클래스들은 제외 -) + importOptions = {ImportOption.DoNotIncludeTests.class} // test 패키지의 테스트 클래스들은 제외 + ) class GlobalArchitectureTest { private static final String CONTROLLER_PKG = "..controller.."; @@ -34,56 +35,53 @@ class GlobalArchitectureTest { private static final String SERVICE_LAYER = "Service"; private static final String REPOSITORY_LAYER = "Repository"; - /** - * 계층 간 의존 흐름은 Controller → Service → Repository 순방향이어야 한다. - */ + /** 계층 간 의존 흐름은 Controller → Service → Repository 순방향이어야 한다. */ @ArchTest - static Architectures.LayeredArchitecture layeredArchitectureMustBeRespected = - layeredArchitecture() - .consideringOnlyDependenciesInLayers() - .layer(CONTROLLER_LAYER).definedBy(CONTROLLER_PKG) - .layer(SERVICE_LAYER).definedBy(SERVICE_PKG) - .layer(REPOSITORY_LAYER).definedBy(REPOSITORY_PKG) - .whereLayer(CONTROLLER_LAYER).mayNotBeAccessedByAnyLayer() - .whereLayer(SERVICE_LAYER).mayOnlyBeAccessedByLayers(CONTROLLER_LAYER) - .whereLayer(REPOSITORY_LAYER).mayOnlyBeAccessedByLayers(SERVICE_LAYER); + static Architectures.LayeredArchitecture layeredArchitectureMustBeRespected = layeredArchitecture() + .consideringOnlyDependenciesInLayers() + .layer(CONTROLLER_LAYER) + .definedBy(CONTROLLER_PKG) + .layer(SERVICE_LAYER) + .definedBy(SERVICE_PKG) + .layer(REPOSITORY_LAYER) + .definedBy(REPOSITORY_PKG) + .whereLayer(CONTROLLER_LAYER) + .mayNotBeAccessedByAnyLayer() + .whereLayer(SERVICE_LAYER) + .mayOnlyBeAccessedByLayers(CONTROLLER_LAYER) + .whereLayer(REPOSITORY_LAYER) + .mayOnlyBeAccessedByLayers(SERVICE_LAYER); - /** - * global 패키지는 다른 도메인 패키지에 의존하면 안 된다. - */ + /** global 패키지는 다른 도메인 패키지에 의존하면 안 된다. */ @ArchTest - static ArchRule globalPackageMustNotDependOnDomainPackages = - noClasses() - .that().resideInAPackage("com.payper.server.global..") - // .should().dependOnClassesThat().resideInAPackage(".domain..") TODO: domain 디렉토리 안으로 이동 후 적용 - .should().dependOnClassesThat().resideInAnyPackage( - "com.payper.server.auth..", - "com.payper.server.user..", - "com.payper.server.post..", - "com.payper.server.comment..", - "com.payper.server.merchant..", - "com.payper.server.favorite.." - ); + static ArchRule globalPackageMustNotDependOnDomainPackages = noClasses() + .that() + .resideInAPackage("com.payper.server.global..") + // .should().dependOnClassesThat().resideInAPackage(".domain..") TODO: domain 디렉토리 안으로 이동 후 적용 + .should() + .dependOnClassesThat() + .resideInAnyPackage( + "com.payper.server.auth..", + "com.payper.server.user..", + "com.payper.server.post..", + "com.payper.server.comment..", + "com.payper.server.merchant..", + "com.payper.server.favorite.."); /** - * @Autowired 필드 주입은 사용하면 안 된다. - *
+ * @Autowired 필드 주입은 사용하면 안 된다.
* 생성자 주입 또는 명시적 생성자만 허용한다. */ @ArchTest - static ArchRule noFieldInjectionWithAutowired = - noFields() - .should().beAnnotatedWith(Autowired.class); + static ArchRule noFieldInjectionWithAutowired = noFields().should().beAnnotatedWith(Autowired.class); - /** - * Service, Controller의 non-static 필드는 final이어야 한다. - */ + /** Service, Controller의 non-static 필드는 final이어야 한다. */ @ArchTest - static final ArchRule componentsMustHaveOnlyFinalFields = - fields() - .that() - .areDeclaredInClassesThat() - .resideInAnyPackage(SERVICE_PKG, CONTROLLER_PKG) - .and().areNotStatic() - .should().beFinal(); + static final ArchRule componentsMustHaveOnlyFinalFields = fields().that() + .areDeclaredInClassesThat() + .resideInAnyPackage(SERVICE_PKG, CONTROLLER_PKG) + .and() + .areNotStatic() + .should() + .beFinal(); } diff --git a/src/test/java/com/payper/server/JwtModulesSpringBootIntegrationTest.java b/src/test/java/com/payper/server/JwtModulesSpringBootIntegrationTest.java index 80c71ae..8f44807 100644 --- a/src/test/java/com/payper/server/JwtModulesSpringBootIntegrationTest.java +++ b/src/test/java/com/payper/server/JwtModulesSpringBootIntegrationTest.java @@ -1,18 +1,15 @@ package com.payper.server; -import com.payper.server.global.exception.AuthException; -import com.payper.server.auth.jwt.repository.RefreshTokenRepository; -import com.payper.server.auth.jwt.entity.RefreshTokenEntity; import static org.assertj.core.api.Assertions.*; -import com.payper.server.auth.AuthException; -import com.payper.server.auth.jwt.RefreshTokenRepository; import com.payper.server.auth.jwt.entity.JwtType; import com.payper.server.auth.jwt.entity.RefreshTokenEntity; +import com.payper.server.auth.jwt.repository.RefreshTokenRepository; import com.payper.server.auth.jwt.util.JwtParseUtil; import com.payper.server.auth.jwt.util.JwtProperties; import com.payper.server.auth.jwt.util.JwtRefreshTokenUtil; import com.payper.server.auth.jwt.util.JwtTokenUtil; +import com.payper.server.global.exception.AuthException; import java.util.Date; import java.util.Optional; import org.junit.jupiter.api.BeforeEach; diff --git a/src/test/java/com/payper/server/RepositoryArchitectureTest.java b/src/test/java/com/payper/server/RepositoryArchitectureTest.java index effa68c..27d348b 100644 --- a/src/test/java/com/payper/server/RepositoryArchitectureTest.java +++ b/src/test/java/com/payper/server/RepositoryArchitectureTest.java @@ -1,14 +1,12 @@ package com.payper.server; +import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.*; + import com.tngtech.archunit.junit.AnalyzeClasses; import com.tngtech.archunit.junit.ArchTest; import com.tngtech.archunit.lang.ArchRule; -import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.*; - -/** - * Repository 아키텍처 규칙 검증 테스트 - */ +/** Repository 아키텍처 규칙 검증 테스트 */ @AnalyzeClasses(packages = "com.payper.server") class RepositoryArchitectureTest { @@ -16,22 +14,22 @@ class RepositoryArchitectureTest { private static final String SERVICE_PKG = "..service.."; private static final String REPOSITORY_PKG = "..repository.."; - /** - * Service 또는 Controller 계층을 참조하면 안 된다. - */ + /** Service 또는 Controller 계층을 참조하면 안 된다. */ @ArchTest - static ArchRule repositoryMustNotDependOnOtherLayers = - noClasses() - .that().resideInAPackage(REPOSITORY_PKG) - .should().dependOnClassesThat().resideInAnyPackage(SERVICE_PKG, CONTROLLER_PKG); + static ArchRule repositoryMustNotDependOnOtherLayers = noClasses() + .that() + .resideInAPackage(REPOSITORY_PKG) + .should() + .dependOnClassesThat() + .resideInAnyPackage(SERVICE_PKG, CONTROLLER_PKG); - /** - * repository 패키지에 있어야 한다. - */ + /** repository 패키지에 있어야 한다. */ @ArchTest - static ArchRule repositoryMustResideInRepositoryPackage = - classes() - .that().haveSimpleNameContaining("Repository") - .and().doNotHaveSimpleName("RepositoryArchitectureTest") - .should().resideInAPackage(REPOSITORY_PKG); + static ArchRule repositoryMustResideInRepositoryPackage = classes() + .that() + .haveSimpleNameContaining("Repository") + .and() + .doNotHaveSimpleName("RepositoryArchitectureTest") + .should() + .resideInAPackage(REPOSITORY_PKG); } diff --git a/src/test/java/com/payper/server/ServiceArchitectureTest.java b/src/test/java/com/payper/server/ServiceArchitectureTest.java index 64127bb..29b0df2 100644 --- a/src/test/java/com/payper/server/ServiceArchitectureTest.java +++ b/src/test/java/com/payper/server/ServiceArchitectureTest.java @@ -1,49 +1,49 @@ package com.payper.server; +import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.*; + import com.tngtech.archunit.junit.AnalyzeClasses; import com.tngtech.archunit.junit.ArchTest; import com.tngtech.archunit.lang.ArchRule; import org.springframework.stereotype.Service; -import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.*; - -/** - * 서비스 아키텍처 규칙 검증 테스트 - */ +/** 서비스 아키텍처 규칙 검증 테스트 */ @AnalyzeClasses(packages = "com.payper.server") class ServiceArchitectureTest { private static final String CONTROLLER_PKG = "..controller.."; private static final String SERVICE_PKG = "..service.."; - /** - * @Service 어노테이션이 선언되어야 한다. - */ + /** @Service 어노테이션이 선언되어야 한다. */ @ArchTest - static ArchRule serviceMustBeAnnotatedWithService = - classes() - .that().resideInAPackage(SERVICE_PKG) - .and().areNotInterfaces() - .and().areTopLevelClasses() - .should().beAnnotatedWith(Service.class); + static ArchRule serviceMustBeAnnotatedWithService = classes() + .that() + .resideInAPackage(SERVICE_PKG) + .and() + .areNotInterfaces() + .and() + .areTopLevelClasses() + .should() + .beAnnotatedWith(Service.class); - /** - * Controller를 참조하면 안 된다. - */ + /** Controller를 참조하면 안 된다. */ @ArchTest - static ArchRule serviceMustNotDependOnController = - noClasses() - .that().resideInAPackage(SERVICE_PKG) - .should().dependOnClassesThat().resideInAPackage(CONTROLLER_PKG); + static ArchRule serviceMustNotDependOnController = noClasses() + .that() + .resideInAPackage(SERVICE_PKG) + .should() + .dependOnClassesThat() + .resideInAPackage(CONTROLLER_PKG); - /** - * service 패키지에 있어야 한다. - */ + /** service 패키지에 있어야 한다. */ @ArchTest - static ArchRule serviceMustResideInServicePackage = - classes() - .that().haveSimpleNameContaining("Service") - .and().resideOutsideOfPackage("..security..") - .and().doNotHaveSimpleName("ServiceArchitectureTest") - .should().resideInAPackage(SERVICE_PKG); + static ArchRule serviceMustResideInServicePackage = classes() + .that() + .haveSimpleNameContaining("Service") + .and() + .resideOutsideOfPackage("..security..") + .and() + .doNotHaveSimpleName("ServiceArchitectureTest") + .should() + .resideInAPackage(SERVICE_PKG); } diff --git a/src/test/java/com/payper/server/SwaggerArchitectureTest.java b/src/test/java/com/payper/server/SwaggerArchitectureTest.java index f391897..86eee3f 100644 --- a/src/test/java/com/payper/server/SwaggerArchitectureTest.java +++ b/src/test/java/com/payper/server/SwaggerArchitectureTest.java @@ -1,44 +1,30 @@ package com.payper.server; +import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.classes; + import com.tngtech.archunit.junit.AnalyzeClasses; import com.tngtech.archunit.junit.ArchTest; import com.tngtech.archunit.lang.ArchRule; import io.swagger.v3.oas.annotations.tags.Tag; -import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.classes; - -/** - * Swagger Api 인터페이스 아키텍처 규칙 검증 테스트 - */ +/** Swagger Api 인터페이스 아키텍처 규칙 검증 테스트 */ @AnalyzeClasses(packages = "com.payper.server") class SwaggerArchitectureTest { private static final String CONTROLLER_PKG = "..controller.."; - /** - * Swagger API 문서는 인터페이스여야 한다. - */ + /** Swagger API 문서는 인터페이스여야 한다. */ @ArchTest static ArchRule apiMustBeInterface = - classes() - .that().haveSimpleNameEndingWith("Api") - .should().beInterfaces(); + classes().that().haveSimpleNameEndingWith("Api").should().beInterfaces(); - /** - * @Tag 어노테이션이 선언되어야 한다. - */ + /** @Tag 어노테이션이 선언되어야 한다. */ @ArchTest static ArchRule apiInterfaceMustHaveTagAnnotation = - classes() - .that().haveSimpleNameEndingWith("Api") - .should().beAnnotatedWith(Tag.class); + classes().that().haveSimpleNameEndingWith("Api").should().beAnnotatedWith(Tag.class); - /** - * controller 패키지에 있어야 한다. - */ + /** controller 패키지에 있어야 한다. */ @ArchTest static ArchRule apiInterfaceMustResideInControllerPackage = - classes() - .that().haveSimpleNameEndingWith("Api") - .should().resideInAPackage(CONTROLLER_PKG); + classes().that().haveSimpleNameEndingWith("Api").should().resideInAPackage(CONTROLLER_PKG); } From 604dbc0b6b4e7000238a2fdf2b6cdf3ee09bc573 Mon Sep 17 00:00:00 2001 From: seoyeon2001 Date: Sun, 1 Mar 2026 01:07:46 +0900 Subject: [PATCH 5/6] =?UTF-8?q?refactor:=20gemini=20=EB=A6=AC=EB=B7=B0=20?= =?UTF-8?q?=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 1 - .../server/ControllerArchitectureTest.java | 25 +++++++------------ .../payper/server/DtoArchitectureTest.java | 6 ++++- .../payper/server/EntityArchitectureTest.java | 6 ++++- .../server/RepositoryArchitectureTest.java | 8 +++--- .../server/ServiceArchitectureTest.java | 8 +++--- .../server/SwaggerArchitectureTest.java | 6 ++++- 7 files changed, 34 insertions(+), 26 deletions(-) diff --git a/build.gradle b/build.gradle index 1644d43..8cbeb6d 100644 --- a/build.gradle +++ b/build.gradle @@ -56,7 +56,6 @@ dependencies { testImplementation 'com.h2database:h2' // Archunit -// testImplementation 'com.tngtech.archunit:archunit:1.4.1' testImplementation 'com.tngtech.archunit:archunit-junit5:1.4.1' } diff --git a/src/test/java/com/payper/server/ControllerArchitectureTest.java b/src/test/java/com/payper/server/ControllerArchitectureTest.java index 4e91aea..3bb4855 100644 --- a/src/test/java/com/payper/server/ControllerArchitectureTest.java +++ b/src/test/java/com/payper/server/ControllerArchitectureTest.java @@ -2,18 +2,21 @@ import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.*; -import com.tngtech.archunit.core.domain.JavaClass; +import com.tngtech.archunit.core.importer.ImportOption; import com.tngtech.archunit.junit.AnalyzeClasses; import com.tngtech.archunit.junit.ArchTest; -import com.tngtech.archunit.lang.ArchCondition; import com.tngtech.archunit.lang.ArchRule; -import com.tngtech.archunit.lang.ConditionEvents; -import com.tngtech.archunit.lang.SimpleConditionEvent; import org.springframework.stereotype.Service; import org.springframework.web.bind.annotation.RestController; +import static com.tngtech.archunit.core.domain.JavaClass.Predicates.simpleNameEndingWith; +import static com.tngtech.archunit.lang.conditions.ArchConditions.implement; + /** 컨트롤러 아키텍처 규칙 검증 테스트 */ -@AnalyzeClasses(packages = "com.payper.server") +@AnalyzeClasses( + packages = "com.payper.server", + importOptions = {ImportOption.DoNotIncludeTests.class} // test 패키지의 테스트 클래스들은 제외 +) class ControllerArchitectureTest { private static final String CONTROLLER_PKG = "..controller.."; @@ -57,17 +60,7 @@ class ControllerArchitectureTest { .areAnnotatedWith(RestController.class) .and() .resideOutsideOfPackage(TEST_UTIL_PKG) - .should(new ArchCondition("이름이 'Api'로 끝나는 인터페이스를 구현해야 한다") { - @Override - public void check(JavaClass javaClass, ConditionEvents events) { - boolean implementsApi = javaClass.getInterfaces().stream() - .anyMatch(iface -> iface.toErasure().getSimpleName().endsWith("Api")); - if (!implementsApi) { - events.add(SimpleConditionEvent.violated( - javaClass, javaClass.getName() + " 는 'Api'로 끝나는 인터페이스를 구현하지 않습니다.")); - } - } - }); + .should(implement(simpleNameEndingWith("Api"))); /** controller 패키지에만 있어야 한다. */ @ArchTest diff --git a/src/test/java/com/payper/server/DtoArchitectureTest.java b/src/test/java/com/payper/server/DtoArchitectureTest.java index db2d35c..5d0c483 100644 --- a/src/test/java/com/payper/server/DtoArchitectureTest.java +++ b/src/test/java/com/payper/server/DtoArchitectureTest.java @@ -2,12 +2,16 @@ import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.classes; +import com.tngtech.archunit.core.importer.ImportOption; import com.tngtech.archunit.junit.AnalyzeClasses; import com.tngtech.archunit.junit.ArchTest; import com.tngtech.archunit.lang.ArchRule; /** DTO 아키텍처 규칙 검증 테스트 */ -@AnalyzeClasses(packages = "com.payper.server") +@AnalyzeClasses( + packages = "com.payper.server", + importOptions = {ImportOption.DoNotIncludeTests.class} // test 패키지의 테스트 클래스들은 제외 +) class DtoArchitectureTest { private static final String DTO_PKG = "..dto.."; diff --git a/src/test/java/com/payper/server/EntityArchitectureTest.java b/src/test/java/com/payper/server/EntityArchitectureTest.java index 4f1e461..32e8937 100644 --- a/src/test/java/com/payper/server/EntityArchitectureTest.java +++ b/src/test/java/com/payper/server/EntityArchitectureTest.java @@ -2,6 +2,7 @@ import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.*; +import com.tngtech.archunit.core.importer.ImportOption; import com.tngtech.archunit.junit.AnalyzeClasses; import com.tngtech.archunit.junit.ArchTest; import com.tngtech.archunit.lang.ArchRule; @@ -9,7 +10,10 @@ import jakarta.persistence.MappedSuperclass; /** Entity 아키텍처 규칙 검증 테스트 */ -@AnalyzeClasses(packages = "com.payper.server") +@AnalyzeClasses( + packages = "com.payper.server", + importOptions = {ImportOption.DoNotIncludeTests.class} // test 패키지의 테스트 클래스들은 제외 +) class EntityArchitectureTest { private static final String CONTROLLER_PKG = "..controller.."; diff --git a/src/test/java/com/payper/server/RepositoryArchitectureTest.java b/src/test/java/com/payper/server/RepositoryArchitectureTest.java index 27d348b..5c0664e 100644 --- a/src/test/java/com/payper/server/RepositoryArchitectureTest.java +++ b/src/test/java/com/payper/server/RepositoryArchitectureTest.java @@ -2,12 +2,16 @@ import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.*; +import com.tngtech.archunit.core.importer.ImportOption; import com.tngtech.archunit.junit.AnalyzeClasses; import com.tngtech.archunit.junit.ArchTest; import com.tngtech.archunit.lang.ArchRule; /** Repository 아키텍처 규칙 검증 테스트 */ -@AnalyzeClasses(packages = "com.payper.server") +@AnalyzeClasses( + packages = "com.payper.server", + importOptions = {ImportOption.DoNotIncludeTests.class} // test 패키지의 테스트 클래스들은 제외 +) class RepositoryArchitectureTest { private static final String CONTROLLER_PKG = "..controller.."; @@ -28,8 +32,6 @@ class RepositoryArchitectureTest { static ArchRule repositoryMustResideInRepositoryPackage = classes() .that() .haveSimpleNameContaining("Repository") - .and() - .doNotHaveSimpleName("RepositoryArchitectureTest") .should() .resideInAPackage(REPOSITORY_PKG); } diff --git a/src/test/java/com/payper/server/ServiceArchitectureTest.java b/src/test/java/com/payper/server/ServiceArchitectureTest.java index 29b0df2..0a6d229 100644 --- a/src/test/java/com/payper/server/ServiceArchitectureTest.java +++ b/src/test/java/com/payper/server/ServiceArchitectureTest.java @@ -2,13 +2,17 @@ import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.*; +import com.tngtech.archunit.core.importer.ImportOption; import com.tngtech.archunit.junit.AnalyzeClasses; import com.tngtech.archunit.junit.ArchTest; import com.tngtech.archunit.lang.ArchRule; import org.springframework.stereotype.Service; /** 서비스 아키텍처 규칙 검증 테스트 */ -@AnalyzeClasses(packages = "com.payper.server") +@AnalyzeClasses( + packages = "com.payper.server", + importOptions = {ImportOption.DoNotIncludeTests.class} // test 패키지의 테스트 클래스들은 제외 +) class ServiceArchitectureTest { private static final String CONTROLLER_PKG = "..controller.."; @@ -42,8 +46,6 @@ class ServiceArchitectureTest { .haveSimpleNameContaining("Service") .and() .resideOutsideOfPackage("..security..") - .and() - .doNotHaveSimpleName("ServiceArchitectureTest") .should() .resideInAPackage(SERVICE_PKG); } diff --git a/src/test/java/com/payper/server/SwaggerArchitectureTest.java b/src/test/java/com/payper/server/SwaggerArchitectureTest.java index 86eee3f..b4668de 100644 --- a/src/test/java/com/payper/server/SwaggerArchitectureTest.java +++ b/src/test/java/com/payper/server/SwaggerArchitectureTest.java @@ -2,13 +2,17 @@ import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.classes; +import com.tngtech.archunit.core.importer.ImportOption; import com.tngtech.archunit.junit.AnalyzeClasses; import com.tngtech.archunit.junit.ArchTest; import com.tngtech.archunit.lang.ArchRule; import io.swagger.v3.oas.annotations.tags.Tag; /** Swagger Api 인터페이스 아키텍처 규칙 검증 테스트 */ -@AnalyzeClasses(packages = "com.payper.server") +@AnalyzeClasses( + packages = "com.payper.server", + importOptions = {ImportOption.DoNotIncludeTests.class} // test 패키지의 테스트 클래스들은 제외 +) class SwaggerArchitectureTest { private static final String CONTROLLER_PKG = "..controller.."; From 3a9e1199c441952c7b4174e17b5e3035ffd6583b Mon Sep 17 00:00:00 2001 From: seoyeon2001 Date: Sun, 1 Mar 2026 01:08:36 +0900 Subject: [PATCH 6/6] =?UTF-8?q?fix:=20=EC=BD=94=EB=93=9C=20=ED=8F=AC?= =?UTF-8?q?=EB=A7=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/payper/server/ControllerArchitectureTest.java | 7 +++---- src/test/java/com/payper/server/DtoArchitectureTest.java | 2 +- .../java/com/payper/server/EntityArchitectureTest.java | 2 +- .../com/payper/server/RepositoryArchitectureTest.java | 9 +++------ .../java/com/payper/server/ServiceArchitectureTest.java | 2 +- .../java/com/payper/server/SwaggerArchitectureTest.java | 2 +- 6 files changed, 10 insertions(+), 14 deletions(-) diff --git a/src/test/java/com/payper/server/ControllerArchitectureTest.java b/src/test/java/com/payper/server/ControllerArchitectureTest.java index 3bb4855..9cf5a77 100644 --- a/src/test/java/com/payper/server/ControllerArchitectureTest.java +++ b/src/test/java/com/payper/server/ControllerArchitectureTest.java @@ -1,5 +1,7 @@ package com.payper.server; +import static com.tngtech.archunit.core.domain.JavaClass.Predicates.simpleNameEndingWith; +import static com.tngtech.archunit.lang.conditions.ArchConditions.implement; import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.*; import com.tngtech.archunit.core.importer.ImportOption; @@ -9,14 +11,11 @@ import org.springframework.stereotype.Service; import org.springframework.web.bind.annotation.RestController; -import static com.tngtech.archunit.core.domain.JavaClass.Predicates.simpleNameEndingWith; -import static com.tngtech.archunit.lang.conditions.ArchConditions.implement; - /** 컨트롤러 아키텍처 규칙 검증 테스트 */ @AnalyzeClasses( packages = "com.payper.server", importOptions = {ImportOption.DoNotIncludeTests.class} // test 패키지의 테스트 클래스들은 제외 -) + ) class ControllerArchitectureTest { private static final String CONTROLLER_PKG = "..controller.."; diff --git a/src/test/java/com/payper/server/DtoArchitectureTest.java b/src/test/java/com/payper/server/DtoArchitectureTest.java index 5d0c483..9f811a1 100644 --- a/src/test/java/com/payper/server/DtoArchitectureTest.java +++ b/src/test/java/com/payper/server/DtoArchitectureTest.java @@ -11,7 +11,7 @@ @AnalyzeClasses( packages = "com.payper.server", importOptions = {ImportOption.DoNotIncludeTests.class} // test 패키지의 테스트 클래스들은 제외 -) + ) class DtoArchitectureTest { private static final String DTO_PKG = "..dto.."; diff --git a/src/test/java/com/payper/server/EntityArchitectureTest.java b/src/test/java/com/payper/server/EntityArchitectureTest.java index 32e8937..decf52f 100644 --- a/src/test/java/com/payper/server/EntityArchitectureTest.java +++ b/src/test/java/com/payper/server/EntityArchitectureTest.java @@ -13,7 +13,7 @@ @AnalyzeClasses( packages = "com.payper.server", importOptions = {ImportOption.DoNotIncludeTests.class} // test 패키지의 테스트 클래스들은 제외 -) + ) class EntityArchitectureTest { private static final String CONTROLLER_PKG = "..controller.."; diff --git a/src/test/java/com/payper/server/RepositoryArchitectureTest.java b/src/test/java/com/payper/server/RepositoryArchitectureTest.java index 5c0664e..aed5c89 100644 --- a/src/test/java/com/payper/server/RepositoryArchitectureTest.java +++ b/src/test/java/com/payper/server/RepositoryArchitectureTest.java @@ -11,7 +11,7 @@ @AnalyzeClasses( packages = "com.payper.server", importOptions = {ImportOption.DoNotIncludeTests.class} // test 패키지의 테스트 클래스들은 제외 -) + ) class RepositoryArchitectureTest { private static final String CONTROLLER_PKG = "..controller.."; @@ -29,9 +29,6 @@ class RepositoryArchitectureTest { /** repository 패키지에 있어야 한다. */ @ArchTest - static ArchRule repositoryMustResideInRepositoryPackage = classes() - .that() - .haveSimpleNameContaining("Repository") - .should() - .resideInAPackage(REPOSITORY_PKG); + static ArchRule repositoryMustResideInRepositoryPackage = + classes().that().haveSimpleNameContaining("Repository").should().resideInAPackage(REPOSITORY_PKG); } diff --git a/src/test/java/com/payper/server/ServiceArchitectureTest.java b/src/test/java/com/payper/server/ServiceArchitectureTest.java index 0a6d229..9459c73 100644 --- a/src/test/java/com/payper/server/ServiceArchitectureTest.java +++ b/src/test/java/com/payper/server/ServiceArchitectureTest.java @@ -12,7 +12,7 @@ @AnalyzeClasses( packages = "com.payper.server", importOptions = {ImportOption.DoNotIncludeTests.class} // test 패키지의 테스트 클래스들은 제외 -) + ) class ServiceArchitectureTest { private static final String CONTROLLER_PKG = "..controller.."; diff --git a/src/test/java/com/payper/server/SwaggerArchitectureTest.java b/src/test/java/com/payper/server/SwaggerArchitectureTest.java index b4668de..0e6ffcb 100644 --- a/src/test/java/com/payper/server/SwaggerArchitectureTest.java +++ b/src/test/java/com/payper/server/SwaggerArchitectureTest.java @@ -12,7 +12,7 @@ @AnalyzeClasses( packages = "com.payper.server", importOptions = {ImportOption.DoNotIncludeTests.class} // test 패키지의 테스트 클래스들은 제외 -) + ) class SwaggerArchitectureTest { private static final String CONTROLLER_PKG = "..controller..";