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 031666b..8cbeb6d 100644 --- a/build.gradle +++ b/build.gradle @@ -32,6 +32,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' @@ -52,6 +54,9 @@ dependencies { testImplementation 'org.springframework.boot:spring-boot-starter-webmvc-test' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' testImplementation 'com.h2database:h2' + + // Archunit + testImplementation 'com.tngtech.archunit:archunit-junit5:1.4.1' } spotless { 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 17d8f23..b787ff9 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 95% rename from src/main/java/com/payper/server/auth/AuthController.java rename to src/main/java/com/payper/server/auth/controller/AuthController.java index e3c27c4..75b427e 100644 --- a/src/main/java/com/payper/server/auth/AuthController.java +++ b/src/main/java/com/payper/server/auth/controller/AuthController.java @@ -1,8 +1,9 @@ -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; 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/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 19116bb..782e072 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 java.util.Optional; 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 5972e00..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.auth.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 47295bc..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.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/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 08bfe38..69481a7 100644 --- a/src/main/java/com/payper/server/auth/AuthService.java +++ b/src/main/java/com/payper/server/auth/service/AuthService.java @@ -1,4 +1,4 @@ -package com.payper.server.auth; +package com.payper.server.auth.service; import com.payper.server.auth.jwt.entity.JwtType; import com.payper.server.auth.jwt.entity.RefreshTokenEntity; @@ -8,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.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/auth/util/AuthDummyInit.java b/src/main/java/com/payper/server/auth/util/AuthDummyInit.java index a7eac85..a18fdd4 100644 --- a/src/main/java/com/payper/server/auth/util/AuthDummyInit.java +++ b/src/main/java/com/payper/server/auth/util/AuthDummyInit.java @@ -1,6 +1,6 @@ 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.*; 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 6f23273..54511e1 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 7788b15..2496a6b 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 9595a0f..8dc624f 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.entity.User; import com.payper.server.user.repository.UserRepository; diff --git a/src/main/java/com/payper/server/security/JwtAuthenticationProvider.java b/src/main/java/com/payper/server/security/JwtAuthenticationProvider.java index 493e2c7..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.auth.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/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 251dc7a..866c839 100644 --- a/src/main/java/com/payper/server/user/UserService.java +++ b/src/main/java/com/payper/server/user/service/UserService.java @@ -1,7 +1,7 @@ -package com.payper.server.user; +package com.payper.server.user.service; -import com.payper.server.auth.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 new file mode 100644 index 0000000..9cf5a77 --- /dev/null +++ b/src/test/java/com/payper/server/ControllerArchitectureTest.java @@ -0,0 +1,73 @@ +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; +import com.tngtech.archunit.junit.AnalyzeClasses; +import com.tngtech.archunit.junit.ArchTest; +import com.tngtech.archunit.lang.ArchRule; +import org.springframework.stereotype.Service; +import org.springframework.web.bind.annotation.RestController; + +/** 컨트롤러 아키텍처 규칙 검증 테스트 */ +@AnalyzeClasses( + packages = "com.payper.server", + importOptions = {ImportOption.DoNotIncludeTests.class} // test 패키지의 테스트 클래스들은 제외 + ) +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(implement(simpleNameEndingWith("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..9f811a1 --- /dev/null +++ b/src/test/java/com/payper/server/DtoArchitectureTest.java @@ -0,0 +1,30 @@ +package com.payper.server; + +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", + importOptions = {ImportOption.DoNotIncludeTests.class} // test 패키지의 테스트 클래스들은 제외 + ) +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..decf52f --- /dev/null +++ b/src/test/java/com/payper/server/EntityArchitectureTest.java @@ -0,0 +1,45 @@ +package com.payper.server; + +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 jakarta.persistence.Entity; +import jakarta.persistence.MappedSuperclass; + +/** Entity 아키텍처 규칙 검증 테스트 */ +@AnalyzeClasses( + packages = "com.payper.server", + importOptions = {ImportOption.DoNotIncludeTests.class} // test 패키지의 테스트 클래스들은 제외 + ) +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/GlobalArchitectureTest.java b/src/test/java/com/payper/server/GlobalArchitectureTest.java new file mode 100644 index 0000000..d56f43b --- /dev/null +++ b/src/test/java/com/payper/server/GlobalArchitectureTest.java @@ -0,0 +1,87 @@ +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; +import com.tngtech.archunit.lang.ArchRule; +import com.tngtech.archunit.library.Architectures; +import org.springframework.beans.factory.annotation.Autowired; + +/** + * 공통 아키텍처 규칙 검증 테스트 + * + *

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

+ */ +@AnalyzeClasses( + packages = "com.payper.server", + importOptions = {ImportOption.DoNotIncludeTests.class} // 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 CONTROLLER_LAYER = "Controller"; + private static final String SERVICE_LAYER = "Service"; + private static final String REPOSITORY_LAYER = "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); + + /** 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.."); + + /** + * @Autowired 필드 주입은 사용하면 안 된다.
+ * 생성자 주입 또는 명시적 생성자만 허용한다. + */ + @ArchTest + static ArchRule noFieldInjectionWithAutowired = noFields().should().beAnnotatedWith(Autowired.class); + + /** Service, Controller의 non-static 필드는 final이어야 한다. */ + @ArchTest + 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 04f79d9..8f44807 100644 --- a/src/test/java/com/payper/server/JwtModulesSpringBootIntegrationTest.java +++ b/src/test/java/com/payper/server/JwtModulesSpringBootIntegrationTest.java @@ -2,14 +2,14 @@ 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 new file mode 100644 index 0000000..aed5c89 --- /dev/null +++ b/src/test/java/com/payper/server/RepositoryArchitectureTest.java @@ -0,0 +1,34 @@ +package com.payper.server; + +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", + importOptions = {ImportOption.DoNotIncludeTests.class} // test 패키지의 테스트 클래스들은 제외 + ) +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").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..9459c73 --- /dev/null +++ b/src/test/java/com/payper/server/ServiceArchitectureTest.java @@ -0,0 +1,51 @@ +package com.payper.server; + +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", + importOptions = {ImportOption.DoNotIncludeTests.class} // test 패키지의 테스트 클래스들은 제외 + ) +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..") + .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..0e6ffcb --- /dev/null +++ b/src/test/java/com/payper/server/SwaggerArchitectureTest.java @@ -0,0 +1,34 @@ +package com.payper.server; + +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", + importOptions = {ImportOption.DoNotIncludeTests.class} // test 패키지의 테스트 클래스들은 제외 + ) +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 90ee844..c4b996e 100644 --- a/src/test/java/com/payper/server/UserAndRefreshTokenJpaTest.java +++ b/src/test/java/com/payper/server/UserAndRefreshTokenJpaTest.java @@ -2,8 +2,8 @@ import static org.assertj.core.api.Assertions.assertThat; -import com.payper.server.auth.jwt.RefreshTokenRepository; import com.payper.server.auth.jwt.entity.RefreshTokenEntity; +import com.payper.server.auth.jwt.repository.RefreshTokenRepository; import com.payper.server.security.CustomUserDetails; import com.payper.server.user.entity.AuthType; import com.payper.server.user.entity.User;