Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
170 changes: 170 additions & 0 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
@@ -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 인터페이스 타입, 어노테이션, 위치 |
5 changes: 5 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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;
Expand Down
Original file line number Diff line number Diff line change
@@ -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.*;
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand Down
73 changes: 73 additions & 0 deletions src/test/java/com/payper/server/ControllerArchitectureTest.java
Original file line number Diff line number Diff line change
@@ -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 인터페이스를 구현해야 한다. <br>
* 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);
}
Loading