init: 클린 기반 auth 서버 설계
This commit is contained in:
@@ -0,0 +1,218 @@
|
||||
# API Controller 예시
|
||||
|
||||
## 좋은 예시
|
||||
|
||||
### 예시 1. controller는 request DTO를 받아 use case를 호출하고 표준 응답을 반환한다
|
||||
|
||||
```java
|
||||
@RestController
|
||||
@RequiredArgsConstructor
|
||||
@RequestMapping("/api/sessions")
|
||||
public class SessionCommandController {
|
||||
|
||||
private final CreateSessionUseCase createSessionUseCase;
|
||||
|
||||
@PostMapping
|
||||
public ApiResult<CreateSessionResponse> create(
|
||||
@Valid @RequestBody CreateSessionRequest request
|
||||
) {
|
||||
CreateSessionResult result = createSessionUseCase.create(
|
||||
request.email(),
|
||||
request.password(),
|
||||
request.loginType()
|
||||
);
|
||||
|
||||
return ApiResult.success(CreateSessionResponse.from(result));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- @RestController가 API 용도와 맞다
|
||||
- JSON body를 전용 request DTO로 받는다
|
||||
- controller가 use case 호출과 응답 반환에 집중한다
|
||||
|
||||
### 예시 2. ResponseEntity는 HTTP 제어가 필요할 때만 사용한다
|
||||
|
||||
```java
|
||||
@RestController
|
||||
@RequiredArgsConstructor
|
||||
@RequestMapping("/api/users")
|
||||
public class UserCommandController {
|
||||
|
||||
private final RegisterUserUseCase registerUserUseCase;
|
||||
|
||||
@PostMapping
|
||||
public ResponseEntity<ApiResult<UserCreatedResponse>> register(
|
||||
@Valid @RequestBody RegisterUserRequest request
|
||||
) {
|
||||
UserCreatedResult result = registerUserUseCase.register(request.email(), request.password());
|
||||
UserCreatedResponse response = UserCreatedResponse.from(result);
|
||||
|
||||
URI location = URI.create("/api/users/" + response.userId());
|
||||
|
||||
return ResponseEntity.created(location)
|
||||
.body(ApiResult.success(response));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 201 Created와 Location 헤더가 필요한 경우에만 ResponseEntity를 사용한다
|
||||
- 모든 endpoint를 습관적으로 ResponseEntity로 감싸지 않는다
|
||||
|
||||
### 예시 3. 입력 출처를 시그니처에 명시한다
|
||||
|
||||
```java
|
||||
@RestController
|
||||
@RequiredArgsConstructor
|
||||
@RequestMapping("/api/users")
|
||||
public class UserQueryController {
|
||||
|
||||
private final UserQueryUseCase userQueryUseCase;
|
||||
|
||||
@GetMapping("/{userId}")
|
||||
public ApiResult<UserResponse> getUser(
|
||||
@PathVariable String userId,
|
||||
@RequestParam(defaultValue = "false") boolean includeInactive
|
||||
) {
|
||||
UserResult result = userQueryUseCase.getUser(userId, includeInactive);
|
||||
return ApiResult.success(UserResponse.from(result));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- path와 query 입력 출처가 시그니처에서 구분된다
|
||||
- HttpServletRequest 전체를 들고 오지 않아도 되는 입력은 annotation으로 처리한다
|
||||
- request id 같은 관측용 헤더는 use case 입력으로 섞지 않는다
|
||||
|
||||
### 예시 4. controller는 예외를 직접 잡지 않는다
|
||||
|
||||
```java
|
||||
@RestController
|
||||
@RequiredArgsConstructor
|
||||
@RequestMapping("/api/password")
|
||||
public class PasswordController {
|
||||
|
||||
private final ChangePasswordUseCase changePasswordUseCase;
|
||||
|
||||
@PostMapping("/change")
|
||||
public ApiResult<Void> changePassword(
|
||||
@Valid @RequestBody ChangePasswordRequest request
|
||||
) {
|
||||
changePasswordUseCase.change(
|
||||
request.userId(),
|
||||
request.currentPassword(),
|
||||
request.newPassword()
|
||||
);
|
||||
|
||||
return ApiResult.success(null);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 예외는 @RestControllerAdvice에서 통합 처리할 수 있다
|
||||
- controller가 공통 에러 응답 정책을 직접 품지 않는다
|
||||
|
||||
## 나쁜 예시
|
||||
|
||||
### 예시 1. controller가 repository를 직접 호출한다
|
||||
|
||||
```java
|
||||
@RestController
|
||||
@RequiredArgsConstructor
|
||||
@RequestMapping("/api/users")
|
||||
public class BadUserController {
|
||||
|
||||
private final UserRepository userRepository;
|
||||
|
||||
@GetMapping("/{userId}")
|
||||
public ApiResult<User> getUser(@PathVariable Long userId) {
|
||||
User user = userRepository.findById(userId).orElseThrow();
|
||||
return ApiResult.success(user);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- controller가 persistence access를 직접 수행한다
|
||||
- domain/entity가 외부 응답 모델로 직접 노출된다
|
||||
- application boundary가 사라진다
|
||||
|
||||
### 예시 2. entity를 request body로 직접 받는다
|
||||
|
||||
```java
|
||||
@RestController
|
||||
@RequestMapping("/api/users")
|
||||
public class BadUserCommandController {
|
||||
|
||||
@PostMapping
|
||||
public ApiResult<Void> create(@Valid @RequestBody User user) {
|
||||
return ApiResult.success(null);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- request model과 domain/persistence model이 섞인다
|
||||
- 웹 입력 변경이 domain/entity 구조에 직접 번진다
|
||||
|
||||
### 예시 3. 모든 응답을 습관적으로 ResponseEntity로 감싼다
|
||||
|
||||
```java
|
||||
@RestController
|
||||
@RequestMapping("/api/health")
|
||||
public class BadHealthController {
|
||||
|
||||
@GetMapping
|
||||
public ResponseEntity<ApiResult<String>> health() {
|
||||
return ResponseEntity.ok(ApiResult.success("ok"));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 추가로 제어할 status/header가 없다
|
||||
- 불필요한 ceremony만 늘어난다
|
||||
|
||||
### 예시 4. controller 안에서 공통 예외를 직접 처리한다
|
||||
|
||||
```java
|
||||
@RestController
|
||||
@RequiredArgsConstructor
|
||||
@RequestMapping("/api/sessions")
|
||||
public class BadSessionController {
|
||||
|
||||
private final CreateSessionUseCase createSessionUseCase;
|
||||
|
||||
@PostMapping
|
||||
public ResponseEntity<ApiResult<?>> create(@RequestBody CreateSessionRequest request) {
|
||||
try {
|
||||
return ResponseEntity.ok(ApiResult.success(
|
||||
createSessionUseCase.create(request.email(), request.password(), request.loginType())
|
||||
));
|
||||
} catch (InvalidCredentialException ex) {
|
||||
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
|
||||
.body(ApiResult.fail(ErrorCode.INVALID_CREDENTIAL));
|
||||
} catch (Exception ex) {
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body(ApiResult.fail(ErrorCode.INTERNAL_SERVER_ERROR));
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- controller마다 예외 정책이 중복된다
|
||||
- 전역 advice 기준과 충돌한다
|
||||
- 정상 흐름과 에러 흐름이 한 메서드에 뒤섞인다
|
||||
@@ -0,0 +1,181 @@
|
||||
# API Versioning 예시
|
||||
|
||||
## 좋은 예시
|
||||
|
||||
### 예시 1. path major versioning으로 계약을 명시한다
|
||||
|
||||
```java
|
||||
@RestController
|
||||
@RequiredArgsConstructor
|
||||
@RequestMapping("/api/v1/users")
|
||||
public class UserQueryV1Controller {
|
||||
|
||||
private final UserQueryUseCase userQueryUseCase;
|
||||
|
||||
@GetMapping("/{userId}")
|
||||
public ApiResult<UserV1Response> getUser(@PathVariable String userId) {
|
||||
UserResult result = userQueryUseCase.getUser(userId);
|
||||
return ApiResult.success(new UserV1Response(
|
||||
result.userId(),
|
||||
result.email()
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@RestController
|
||||
@RequiredArgsConstructor
|
||||
@RequestMapping("/api/v2/users")
|
||||
public class UserQueryV2Controller {
|
||||
|
||||
private final UserQueryUseCase userQueryUseCase;
|
||||
|
||||
@GetMapping("/{userId}")
|
||||
public ApiResult<UserV2Response> getUser(@PathVariable String userId) {
|
||||
UserResult result = userQueryUseCase.getUser(userId);
|
||||
return ApiResult.success(new UserV2Response(
|
||||
result.userId(),
|
||||
result.email(),
|
||||
result.displayName()
|
||||
));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- URL만 보고 major version이 드러난다
|
||||
- 버전별 계약 차이가 controller와 DTO에서 명확하다
|
||||
- 내부 use case는 공유하면서 외부 계약은 분리할 수 있다
|
||||
|
||||
### 예시 2. Spring 7+ native version mapping을 제한적으로 활용한다
|
||||
|
||||
```java
|
||||
@RestController
|
||||
@RequestMapping("/accounts/{id}")
|
||||
public class AccountController {
|
||||
|
||||
@GetMapping
|
||||
public ApiResult<AccountLegacyResponse> getDefault(@PathVariable String id) {
|
||||
return ApiResult.success(...);
|
||||
}
|
||||
|
||||
@GetMapping(version = "1.1")
|
||||
public ApiResult<AccountV11Response> getV11(@PathVariable String id) {
|
||||
return ApiResult.success(...);
|
||||
}
|
||||
|
||||
@GetMapping(version = "1.2+")
|
||||
public ApiResult<AccountV12Response> getV12Plus(@PathVariable String id) {
|
||||
return ApiResult.success(...);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- Spring이 공식 지원하는 version mapping 규칙을 따른다
|
||||
- fixed version과 baseline version의 의미가 분명하다
|
||||
- 단, 이 방식은 Spring 7+에 맞는 선택지다.
|
||||
|
||||
### 예시 3. deprecated version에 sunset 공지를 준비한다
|
||||
|
||||
```java
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/sessions")
|
||||
public class SessionV1Controller {
|
||||
// 구버전 유지
|
||||
}
|
||||
```
|
||||
|
||||
운영 정책 예:
|
||||
|
||||
- 문서에 v1 deprecation 공지
|
||||
- 릴리스 노트에 종료 일정 공지
|
||||
- 응답 헤더에 deprecation/sunset/link 추가
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 버전 종료가 갑작스럽지 않다
|
||||
- 클라이언트가 마이그레이션할 시간을 가진다
|
||||
- Spring도 deprecation 관련 응답 헤더 전송을 지원한다.
|
||||
|
||||
## 나쁜 예시
|
||||
|
||||
### 예시 1. 같은 API 군에서 path와 header versioning을 섞는다
|
||||
|
||||
```java
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/users")
|
||||
public class MixedVersionController {
|
||||
|
||||
@GetMapping
|
||||
public ApiResult<List<UserResponse>> getUsers() {
|
||||
return ApiResult.success(...);
|
||||
}
|
||||
|
||||
@GetMapping(headers = "API-Version=2")
|
||||
public ApiResult<List<UserResponse>> getUsersV2() {
|
||||
return ApiResult.success(...);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 버전 협상 위치가 두 군데다
|
||||
- client, gateway, 문서, 테스트가 모두 복잡해진다
|
||||
- 한 API product 안의 일관성을 깨뜨린다
|
||||
|
||||
### 예시 2. breaking change인데 version을 올리지 않는다
|
||||
|
||||
```java
|
||||
public record UserResponse(
|
||||
String userId,
|
||||
String email,
|
||||
String displayName,
|
||||
String role
|
||||
) {
|
||||
}
|
||||
```
|
||||
|
||||
기존에 email만 응답하던 endpoint가 같은 /api/v1/users/{id} 에서
|
||||
|
||||
- 기존 필드 삭제
|
||||
- 필수 필드 의미 변경
|
||||
- 구조 변경
|
||||
|
||||
을 해 버리는 경우
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 기존 client를 조용히 깨뜨린다
|
||||
- versioning 목적 자체를 무력화한다
|
||||
|
||||
### 예시 3. minor/patch를 path에 과하게 노출한다
|
||||
|
||||
```java
|
||||
@RequestMapping("/api/v1.0.3/users")
|
||||
public class UserController {
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 공개 URL이 불필요하게 복잡해진다
|
||||
- minor/patch 수준 변화까지 client 계약에 노출된다
|
||||
- 프로젝트의 major-only path 전략과 맞지 않는다
|
||||
|
||||
### 예시 4. 버전 누락 시 최신 버전으로 암묵 fallback한다
|
||||
|
||||
```java
|
||||
@GetMapping("/api/users/{userId}")
|
||||
public ApiResult<UserResponse> getUser(@PathVariable String userId) {
|
||||
// 내부적으로 최신 버전 계약으로 응답
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- client가 어떤 계약을 호출하는지 불명확하다
|
||||
- 시간이 지나며 응답 의미가 조용히 바뀔 수 있다
|
||||
- 명시적 계약 원칙과 맞지 않는다
|
||||
@@ -0,0 +1,214 @@
|
||||
# Authentication Object Access 예시
|
||||
|
||||
## 좋은 예시
|
||||
|
||||
### 예시 1. 프로젝트 전용 @CurrentUser를 정의한다
|
||||
|
||||
```java
|
||||
@Target(ElementType.PARAMETER)
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
@Documented
|
||||
@AuthenticationPrincipal
|
||||
public @interface CurrentUser {
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- controller가 Spring Security 애노테이션에 직접 결합되지 않는다
|
||||
- 현재 사용자 접근 규칙이 한 파일에 모인다
|
||||
- Spring 공식 문서도 같은 메타 애노테이션 방식을 예시로 보여 준다.
|
||||
|
||||
### 예시 2. controller는 전용 현재 사용자 타입만 받는다
|
||||
|
||||
```java
|
||||
public record AuthenticatedUser(
|
||||
String userId,
|
||||
Set<String> authorities
|
||||
) {
|
||||
}
|
||||
|
||||
@RestController
|
||||
@RequiredArgsConstructor
|
||||
@RequestMapping("/api/v1/sessions")
|
||||
public class SessionCommandController {
|
||||
|
||||
private final CreateSessionUseCase createSessionUseCase;
|
||||
|
||||
@PostMapping
|
||||
public ApiResult<CreateSessionResponse> create(
|
||||
@CurrentUser AuthenticatedUser currentUser,
|
||||
@Valid @RequestBody CreateSessionRequest request
|
||||
) {
|
||||
CreateSessionResult result = createSessionUseCase.create(
|
||||
new CreateSessionCommand(
|
||||
currentUser.userId(),
|
||||
request.email(),
|
||||
request.password()
|
||||
)
|
||||
);
|
||||
|
||||
return ApiResult.success(new CreateSessionResponse(
|
||||
result.sessionId(),
|
||||
result.accessToken()
|
||||
));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- controller가 현재 사용자 접근을 명시적으로 드러낸다
|
||||
- application에는 필요한 값만 전달한다
|
||||
- SecurityContextHolder 직접 접근이 없다
|
||||
|
||||
### 예시 3. ID만 필요하면 claim/field만 바로 주입한다
|
||||
|
||||
```java
|
||||
@Target(ElementType.PARAMETER)
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
@Documented
|
||||
@AuthenticationPrincipal(expression = "userId")
|
||||
public @interface CurrentUserId {
|
||||
}
|
||||
|
||||
@RestController
|
||||
@RequiredArgsConstructor
|
||||
@RequestMapping("/api/v1/password")
|
||||
public class PasswordController {
|
||||
|
||||
private final ChangePasswordUseCase changePasswordUseCase;
|
||||
|
||||
@PostMapping("/change")
|
||||
public ApiResult<Void> changePassword(
|
||||
@CurrentUserId String userId,
|
||||
@Valid @RequestBody ChangePasswordRequest request
|
||||
) {
|
||||
changePasswordUseCase.change(
|
||||
new ChangePasswordCommand(
|
||||
userId,
|
||||
request.currentPassword(),
|
||||
request.newPassword()
|
||||
)
|
||||
);
|
||||
|
||||
return ApiResult.success(null);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 필요한 최소 actor 정보만 유스케이스로 간다
|
||||
- current user 타입 전체를 넘기지 않아도 된다
|
||||
- 공식 문서의 expression 기반 메타 애노테이션 패턴과 맞는다.
|
||||
|
||||
### 예시 4. Principal은 단순 확인 endpoint에 제한적으로 쓴다
|
||||
|
||||
```java
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/me")
|
||||
public class MeController {
|
||||
|
||||
@GetMapping
|
||||
public ApiResult<Map<String, String>> me(Principal principal) {
|
||||
return ApiResult.success(Map.of("name", principal.getName()));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 단순 identity 확인 수준에는 충분하다
|
||||
- 복잡한 Security 타입을 노출하지 않는다
|
||||
- Spring MVC가 공식 지원하는 기본 method argument다.
|
||||
|
||||
## 나쁜 예시
|
||||
|
||||
### 예시 1. controller가 SecurityContextHolder를 직접 읽는다
|
||||
|
||||
```java
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/me")
|
||||
public class BadMeController {
|
||||
|
||||
@GetMapping
|
||||
public ApiResult<String> me() {
|
||||
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
|
||||
CustomUserPrincipal principal = (CustomUserPrincipal) authentication.getPrincipal();
|
||||
return ApiResult.success(principal.getUserId());
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- controller가 보안 저장소 접근과 캐스팅 책임까지 가진다
|
||||
- 시그니처에서 현재 사용자 의존이 드러나지 않는다
|
||||
- Spring 공식 문서도 이 패턴보다 @AuthenticationPrincipal 쪽을 권장 예시로 보여 준다.
|
||||
|
||||
### 예시 2. application이 Spring Security 타입을 직접 받는다
|
||||
|
||||
```java
|
||||
@Service
|
||||
public class BadChangePasswordService {
|
||||
|
||||
public void change(Authentication authentication, String currentPassword, String newPassword) {
|
||||
String userId = ((CustomUserPrincipal) authentication.getPrincipal()).getUserId();
|
||||
// ...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- application이 Spring Security에 결합된다
|
||||
- 유스케이스 입력이 보안 프레임워크 타입에 종속된다
|
||||
- 테스트와 재사용성이 나빠진다
|
||||
|
||||
### 예시 3. controller가 role check로 인가를 직접 처리한다
|
||||
|
||||
```java
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/admin")
|
||||
public class BadAdminController {
|
||||
|
||||
@PostMapping("/users/{userId}/lock")
|
||||
public ApiResult<Void> lock(
|
||||
@CurrentUser AuthenticatedUser currentUser,
|
||||
@PathVariable String userId
|
||||
) {
|
||||
if (!currentUser.authorities().contains("ROLE_ADMIN")) {
|
||||
throw new AccessDeniedException("forbidden");
|
||||
}
|
||||
|
||||
// ...
|
||||
return ApiResult.success(null);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 인가 규칙이 controller imperative code로 새어 나갔다
|
||||
- security rule/method security와 역할이 충돌한다
|
||||
- defense in depth 구조가 흐려진다.
|
||||
|
||||
### 예시 4. JWT claim을 여러 계층에서 직접 파싱한다
|
||||
|
||||
```java
|
||||
@Service
|
||||
public class BadUserService {
|
||||
|
||||
public void doSomething(JwtAuthenticationToken authentication) {
|
||||
String userId = authentication.getToken().getClaimAsString("sub");
|
||||
// ...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- application이 특정 인증 메커니즘(JWT claim 구조)에 결합된다
|
||||
- principal 해석 책임이 security adapter에 모이지 않는다
|
||||
- 토큰 구조 변경이 여러 계층으로 번진다
|
||||
@@ -0,0 +1,185 @@
|
||||
# Error Code / HTTP Status Separation 예시
|
||||
|
||||
## 좋은 예시
|
||||
|
||||
### 예시 1. ErrorCode가 status와 외부 메시지를 함께 관리한다
|
||||
|
||||
```java
|
||||
public enum ErrorCode {
|
||||
REQUEST_VALIDATION_FAILED(HttpStatus.BAD_REQUEST, "REQUEST_VALIDATION_FAILED", "Request validation failed"),
|
||||
MALFORMED_JSON_REQUEST(HttpStatus.BAD_REQUEST, "MALFORMED_JSON_REQUEST", "Malformed request body"),
|
||||
DUPLICATE_EMAIL(HttpStatus.CONFLICT, "DUPLICATE_EMAIL", "Email already exists"),
|
||||
INVALID_ACCESS_TOKEN(HttpStatus.UNAUTHORIZED, "INVALID_ACCESS_TOKEN", "Invalid access token"),
|
||||
ACCESS_DENIED(HttpStatus.FORBIDDEN, "ACCESS_DENIED", "Access denied"),
|
||||
USER_NOT_FOUND(HttpStatus.NOT_FOUND, "USER_NOT_FOUND", "User not found"),
|
||||
UPSTREAM_AUTH_SERVER_UNAVAILABLE(HttpStatus.SERVICE_UNAVAILABLE, "UPSTREAM_AUTH_SERVER_UNAVAILABLE", "Authentication server is temporarily unavailable"),
|
||||
INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "INTERNAL_SERVER_ERROR", "Unexpected server error");
|
||||
|
||||
private final HttpStatus httpStatus;
|
||||
private final String code;
|
||||
private final String message;
|
||||
|
||||
ErrorCode(HttpStatus httpStatus, String code, String message) {
|
||||
this.httpStatus = httpStatus;
|
||||
this.code = code;
|
||||
this.message = message;
|
||||
}
|
||||
|
||||
public HttpStatus httpStatus() {
|
||||
return httpStatus;
|
||||
}
|
||||
|
||||
public String code() {
|
||||
return code;
|
||||
}
|
||||
|
||||
public String message() {
|
||||
return message;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- HTTP status와 application code가 함께 정책화된다
|
||||
- 문자열 하드코딩이 흩어지지 않는다
|
||||
- 같은 code가 어디서든 같은 기본 status를 갖는다
|
||||
|
||||
### 예시 2. advice는 예외를 ErrorCode로 매핑하고, status와 body를 함께 만든다
|
||||
|
||||
```java
|
||||
@RestControllerAdvice
|
||||
public class ApiExceptionHandler {
|
||||
|
||||
@ExceptionHandler(DuplicateEmailException.class)
|
||||
public ResponseEntity<ApiResult<Void>> handleDuplicateEmail() {
|
||||
ErrorCode errorCode = ErrorCode.DUPLICATE_EMAIL;
|
||||
|
||||
return ResponseEntity.status(errorCode.httpStatus())
|
||||
.body(ApiResult.fail(errorCode));
|
||||
}
|
||||
|
||||
@ExceptionHandler(InvalidAccessTokenException.class)
|
||||
public ResponseEntity<ApiResult<Void>> handleInvalidAccessToken() {
|
||||
ErrorCode errorCode = ErrorCode.INVALID_ACCESS_TOKEN;
|
||||
|
||||
return ResponseEntity.status(errorCode.httpStatus())
|
||||
.body(ApiResult.fail(errorCode));
|
||||
}
|
||||
|
||||
@ExceptionHandler(UserNotFoundException.class)
|
||||
public ResponseEntity<ApiResult<Void>> handleUserNotFound() {
|
||||
ErrorCode errorCode = ErrorCode.USER_NOT_FOUND;
|
||||
|
||||
return ResponseEntity.status(errorCode.httpStatus())
|
||||
.body(ApiResult.fail(errorCode));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- status와 body code가 같은 정책 타입에서 나온다
|
||||
- controller가 실패 응답을 직접 만들지 않는다
|
||||
- ErrorCode와 HTTP status 역할이 모두 드러난다
|
||||
|
||||
### 예시 3. 같은 400 계열 아래 여러 세부 ErrorCode를 둔다
|
||||
|
||||
```java
|
||||
@RestControllerAdvice
|
||||
public class RequestExceptionHandler {
|
||||
|
||||
@ExceptionHandler(MethodArgumentNotValidException.class)
|
||||
public ResponseEntity<ApiResult<Map<String, String>>> handleValidation(
|
||||
MethodArgumentNotValidException ex
|
||||
) {
|
||||
Map<String, String> errors = ex.getBindingResult()
|
||||
.getFieldErrors()
|
||||
.stream()
|
||||
.collect(Collectors.toUnmodifiableMap(
|
||||
FieldError::getField,
|
||||
DefaultMessageSourceResolvable::getDefaultMessage,
|
||||
(first, second) -> first
|
||||
));
|
||||
|
||||
ErrorCode errorCode = ErrorCode.REQUEST_VALIDATION_FAILED;
|
||||
|
||||
return ResponseEntity.status(errorCode.httpStatus())
|
||||
.body(ApiResult.fail(errorCode, errors));
|
||||
}
|
||||
|
||||
@ExceptionHandler(HttpMessageNotReadableException.class)
|
||||
public ResponseEntity<ApiResult<Void>> handleMalformedJson() {
|
||||
ErrorCode errorCode = ErrorCode.MALFORMED_JSON_REQUEST;
|
||||
|
||||
return ResponseEntity.status(errorCode.httpStatus())
|
||||
.body(ApiResult.fail(errorCode));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 둘 다 400이지만 세부 원인은 ErrorCode로 구분된다
|
||||
- status는 넓은 범주, code는 세부 식별자라는 역할 분리가 분명하다
|
||||
|
||||
## 나쁜 예시
|
||||
|
||||
### 예시 1. 모든 실패를 200으로 응답한다
|
||||
|
||||
```java
|
||||
@ExceptionHandler(DuplicateEmailException.class)
|
||||
public ResponseEntity<ApiResult<Void>> handleDuplicateEmail() {
|
||||
return ResponseEntity.ok(ApiResult.fail(ErrorCode.DUPLICATE_EMAIL));
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- body는 실패인데 HTTP status는 성공이다
|
||||
- HTTP semantics와 application semantics가 충돌한다
|
||||
|
||||
### 예시 2. custom 6xx status를 사용한다
|
||||
|
||||
```java
|
||||
@ExceptionHandler(UpstreamAuthServerUnavailableException.class)
|
||||
public ResponseEntity<ApiResult<Void>> handleUpstreamFailure() {
|
||||
return ResponseEntity.status(601)
|
||||
.body(ApiResult.fail(ErrorCode.UPSTREAM_AUTH_SERVER_UNAVAILABLE));
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 601은 유효한 HTTP status가 아니다
|
||||
- 세부 원인 구분은 ErrorCode로 해야 한다
|
||||
|
||||
### 예시 3. @ResponseStatus(reason=...)를 REST API 기본 실패 전략으로 사용한다
|
||||
|
||||
```java
|
||||
@ResponseStatus(code = HttpStatus.CONFLICT, reason = "Email already exists")
|
||||
public class DuplicateEmailException extends RuntimeException {
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- HTTP status와 REST body 정책을 예외 클래스에 고정해 버린다
|
||||
- reason 기반 sendError는 REST API 응답 규약과 잘 맞지 않는다
|
||||
- body envelope 통일과 충돌하기 쉽다
|
||||
|
||||
### 예시 4. advice에서 문자열 코드와 예외 메시지를 직접 하드코딩한다
|
||||
|
||||
```java
|
||||
@ExceptionHandler(DuplicateEmailException.class)
|
||||
public ResponseEntity<ApiResult<Void>> handleDuplicateEmail(DuplicateEmailException ex) {
|
||||
return ResponseEntity.status(HttpStatus.CONFLICT)
|
||||
.body(ApiResult.fail("DUPLICATE_EMAIL", ex.getMessage()));
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- status/code/message 정책이 중앙화되지 않는다
|
||||
- 외부 메시지와 내부 예외 메시지가 섞인다
|
||||
- 다른 파일에서도 같은 문자열이 반복되기 쉽다
|
||||
@@ -0,0 +1,290 @@
|
||||
# Idempotency 예시
|
||||
|
||||
## 좋은 예시
|
||||
|
||||
### 예시 1. 멱등 키가 필요한 POST endpoint는 헤더를 명시적으로 받는다
|
||||
|
||||
```java
|
||||
@RestController
|
||||
@RequiredArgsConstructor
|
||||
@RequestMapping("/api/v1/users")
|
||||
public class UserCommandController {
|
||||
|
||||
private final RegisterUserUseCase registerUserUseCase;
|
||||
|
||||
@PostMapping
|
||||
public ResponseEntity<ApiResult<CreateUserResponse>> register(
|
||||
@RequestHeader("Idempotency-Key") String idempotencyKey,
|
||||
@Valid @RequestBody CreateUserRequest request,
|
||||
AuthenticatedUser authenticatedUser
|
||||
) {
|
||||
CreateUserCommand command = new CreateUserCommand(
|
||||
authenticatedUser.userId(),
|
||||
idempotencyKey,
|
||||
request.email(),
|
||||
request.password(),
|
||||
request.displayName()
|
||||
);
|
||||
|
||||
CreateUserResult result = registerUserUseCase.register(command);
|
||||
|
||||
return ResponseEntity.created(URI.create("/api/v1/users/" + result.userId()))
|
||||
.body(ApiResult.success(new CreateUserResponse(
|
||||
result.userId(),
|
||||
result.email(),
|
||||
result.displayName()
|
||||
)));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- controller는 헤더를 읽고 command로 전달만 한다
|
||||
- 멱등성 구현 책임이 controller에 머무르지 않는다
|
||||
- POST 생성 endpoint에서 멱등 키 요구가 명확하다
|
||||
|
||||
### 예시 2. application/service에서 키 + fingerprint로 중복을 판정한다
|
||||
|
||||
```java
|
||||
public record IdempotencyScope(
|
||||
String actorId,
|
||||
String operation
|
||||
) {
|
||||
}
|
||||
|
||||
public record StoredRegistrationResult(
|
||||
String userId,
|
||||
String email,
|
||||
String displayName
|
||||
) {
|
||||
}
|
||||
|
||||
public interface IdempotencyStore {
|
||||
Optional<StoredRegistrationResult> findCompleted(
|
||||
IdempotencyScope scope,
|
||||
String key,
|
||||
String fingerprint
|
||||
);
|
||||
|
||||
IdempotencyStartResult tryStart(
|
||||
IdempotencyScope scope,
|
||||
String key,
|
||||
String fingerprint
|
||||
);
|
||||
|
||||
void complete(
|
||||
IdempotencyScope scope,
|
||||
String key,
|
||||
String fingerprint,
|
||||
StoredRegistrationResult result
|
||||
);
|
||||
}
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class RegisterUserUseCase {
|
||||
|
||||
private final IdempotencyStore idempotencyStore;
|
||||
private final UserRegistrationService userRegistrationService;
|
||||
|
||||
public CreateUserResult register(CreateUserCommand command) {
|
||||
IdempotencyScope scope = new IdempotencyScope(command.actorId(), "register-user");
|
||||
String fingerprint = fingerprint(command);
|
||||
|
||||
idempotencyStore.findCompleted(scope, command.idempotencyKey(), fingerprint)
|
||||
.ifPresent(storedResponse -> {
|
||||
throw new IdempotentReplayException(storedResponse);
|
||||
});
|
||||
|
||||
IdempotencyStartResult startResult = idempotencyStore.tryStart(
|
||||
scope,
|
||||
command.idempotencyKey(),
|
||||
fingerprint
|
||||
);
|
||||
|
||||
if (startResult == IdempotencyStartResult.IN_PROGRESS) {
|
||||
throw new IdempotencyRequestInProgressException();
|
||||
}
|
||||
|
||||
if (startResult == IdempotencyStartResult.KEY_REUSED_WITH_DIFFERENT_REQUEST) {
|
||||
throw new IdempotencyKeyMismatchException();
|
||||
}
|
||||
|
||||
CreateUserResult result = userRegistrationService.register(command);
|
||||
|
||||
StoredRegistrationResult storedResult = new StoredRegistrationResult(
|
||||
result.userId(),
|
||||
result.email(),
|
||||
result.displayName()
|
||||
);
|
||||
|
||||
idempotencyStore.complete(scope, command.idempotencyKey(), fingerprint, storedResult);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private String fingerprint(CreateUserCommand command) {
|
||||
return DigestUtils.sha256Hex(
|
||||
command.actorId() + "|" +
|
||||
command.email() + "|" +
|
||||
command.displayName()
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 멱등성 판정이 application 경계에 있다
|
||||
- key뿐 아니라 fingerprint도 비교한다
|
||||
- 완료 결과 재생, 진행 중 충돌, key 재사용 충돌을 분리한다
|
||||
- application은 HTTP status, ApiResult, JSON 직렬화 세부를 알지 않는다
|
||||
|
||||
### 예시 3. 멱등성 오류도 공통 에러 응답 규약으로 처리한다
|
||||
|
||||
```java
|
||||
@RestControllerAdvice
|
||||
public class IdempotencyExceptionHandler {
|
||||
|
||||
@ExceptionHandler(IdempotencyKeyMissingException.class)
|
||||
public ResponseEntity<ApiResult<Void>> handleMissingKey() {
|
||||
ErrorCode errorCode = ErrorCode.IDEMPOTENCY_KEY_REQUIRED;
|
||||
|
||||
return ResponseEntity.status(errorCode.httpStatus())
|
||||
.body(ApiResult.fail(errorCode));
|
||||
}
|
||||
|
||||
@ExceptionHandler(IdempotencyRequestInProgressException.class)
|
||||
public ResponseEntity<ApiResult<Void>> handleInProgress() {
|
||||
ErrorCode errorCode = ErrorCode.IDEMPOTENCY_REQUEST_IN_PROGRESS;
|
||||
|
||||
return ResponseEntity.status(errorCode.httpStatus())
|
||||
.body(ApiResult.fail(errorCode));
|
||||
}
|
||||
|
||||
@ExceptionHandler(IdempotencyKeyMismatchException.class)
|
||||
public ResponseEntity<ApiResult<Void>> handleMismatch() {
|
||||
ErrorCode errorCode = ErrorCode.IDEMPOTENCY_KEY_REUSED_WITH_DIFFERENT_REQUEST;
|
||||
|
||||
return ResponseEntity.status(errorCode.httpStatus())
|
||||
.body(ApiResult.fail(errorCode));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 400/409/422 같은 HTTP status와 application error code를 함께 유지한다
|
||||
- 멱등성 오류도 전체 API 에러 규약에 맞춰진다
|
||||
|
||||
### 예시 4. key는 opaque UUID를 사용한다
|
||||
|
||||
요청 예:
|
||||
|
||||
```http
|
||||
POST /api/v1/users
|
||||
Idempotency-Key: 8e03978e-40d5-43e8-bc93-6894a57f9324
|
||||
Content-Type: application/json
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 민감정보가 없다
|
||||
- 재시도 시 같은 key를 다시 보낼 수 있다
|
||||
- 운영/추적에도 적당한 opaque identifier다
|
||||
|
||||
## 나쁜 예시
|
||||
|
||||
### 예시 1. GET에 멱등 키를 요구한다
|
||||
|
||||
```java
|
||||
@GetMapping("/{userId}")
|
||||
public ApiResult<UserResponse> getUser(
|
||||
@RequestHeader("Idempotency-Key") String idempotencyKey,
|
||||
@PathVariable String userId
|
||||
) {
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- GET은 이미 safe/idempotent다
|
||||
- 불필요한 계약 복잡도만 늘어난다
|
||||
|
||||
### 예시 2. 서버가 멱등 키를 생성한다
|
||||
|
||||
```java
|
||||
@PostMapping
|
||||
public ApiResult<CreateUserResponse> register(@RequestBody CreateUserRequest request) {
|
||||
String idempotencyKey = UUID.randomUUID().toString();
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- client가 타임아웃 후 같은 요청을 재시도할 때 같은 key를 다시 보낼 수 없다
|
||||
- 재시도 안전성이라는 목적을 달성하지 못한다
|
||||
|
||||
### 예시 3. 이메일을 멱등 키로 사용한다
|
||||
|
||||
```http
|
||||
Idempotency-Key: donghyun@example.com
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 개인정보가 key에 노출된다
|
||||
- 요청 의도 식별자와 사용자 식별자가 뒤섞인다
|
||||
- 같은 사용자의 다른 요청을 구분하기 어렵다
|
||||
|
||||
### 예시 4. controller의 로컬 메모리로만 중복을 막는다
|
||||
|
||||
```java
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/users")
|
||||
public class BadUserController {
|
||||
|
||||
private final Set<String> processedKeys = ConcurrentHashMap.newKeySet();
|
||||
|
||||
@PostMapping
|
||||
public ApiResult<Void> register(
|
||||
@RequestHeader("Idempotency-Key") String idempotencyKey,
|
||||
@RequestBody CreateUserRequest request
|
||||
) {
|
||||
if (!processedKeys.add(idempotencyKey)) {
|
||||
return ApiResult.success(null);
|
||||
}
|
||||
|
||||
// 실제 생성 처리
|
||||
return ApiResult.success(null);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 다중 인스턴스 환경에서 깨진다
|
||||
- fingerprint 비교가 없다
|
||||
- 애플리케이션 재기동 시 기록이 사라진다
|
||||
- controller가 멱등성 구현 책임까지 떠안는다
|
||||
|
||||
### 예시 5. 같은 key를 다른 payload에 재사용해도 새 요청으로 처리한다
|
||||
|
||||
```java
|
||||
public void handle(String key, CreateUserRequest request) {
|
||||
if (store.contains(key)) {
|
||||
process(request); // 그냥 다시 처리
|
||||
return;
|
||||
}
|
||||
process(request);
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- key 재사용 오용을 막지 못한다
|
||||
- 중복 생성/중복 실행 위험이 남는다
|
||||
- “같은 요청의 재시도”와 “다른 요청”을 구분하지 못한다
|
||||
@@ -0,0 +1,285 @@
|
||||
# Pagination / Sort / Filter 예시
|
||||
|
||||
## 좋은 예시
|
||||
|
||||
### 예시 1. 일반 목록 조회는 명시적 query DTO + page 응답으로 표현한다
|
||||
|
||||
```java
|
||||
public record UserListRequest(
|
||||
@Min(1) int page,
|
||||
@Min(1) @Max(100) int size,
|
||||
String keyword,
|
||||
UserStatus status,
|
||||
String sortBy,
|
||||
SortDirection direction
|
||||
) {
|
||||
}
|
||||
|
||||
public record PageResponse<T>(
|
||||
List<T> items,
|
||||
int page,
|
||||
int size,
|
||||
boolean hasNext,
|
||||
Long totalCount
|
||||
) {
|
||||
}
|
||||
|
||||
@RestController
|
||||
@RequiredArgsConstructor
|
||||
@RequestMapping("/api/v1/users")
|
||||
public class UserQueryController {
|
||||
|
||||
private final SearchUsersUseCase searchUsersUseCase;
|
||||
|
||||
@GetMapping
|
||||
public ApiResult<PageResponse<UserResponse>> search(
|
||||
@Valid @ModelAttribute UserListRequest request
|
||||
) {
|
||||
SearchUsersQuery query = SearchUsersQuery.of(
|
||||
request.page(),
|
||||
request.size(),
|
||||
request.keyword(),
|
||||
request.status(),
|
||||
request.sortBy(),
|
||||
request.direction()
|
||||
);
|
||||
|
||||
UserPageResult result = searchUsersUseCase.search(query);
|
||||
|
||||
return ApiResult.success(new PageResponse<>(
|
||||
result.items().stream()
|
||||
.map(UserResponse::from)
|
||||
.toList(),
|
||||
result.page(),
|
||||
result.size(),
|
||||
result.hasNext(),
|
||||
result.totalCount()
|
||||
));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 외부 계약이 명시적이다
|
||||
- query input과 내부 paging 모델이 분리된다
|
||||
- 응답도 raw Page가 아니라 API 전용 DTO다
|
||||
|
||||
### 예시 2. sort field는 allowlist로 받고, persistence adapter에서 내부 정렬로 변환한다
|
||||
|
||||
```java
|
||||
public enum UserSortField {
|
||||
CREATED_AT("createdAt"),
|
||||
DISPLAY_NAME("displayName");
|
||||
|
||||
private final String externalName;
|
||||
|
||||
UserSortField(String externalName) {
|
||||
this.externalName = externalName;
|
||||
}
|
||||
|
||||
public static UserSortField from(String value) {
|
||||
return Arrays.stream(values())
|
||||
.filter(field -> field.externalName.equals(value))
|
||||
.findFirst()
|
||||
.orElseThrow(() -> new InvalidSortFieldException(value));
|
||||
}
|
||||
}
|
||||
|
||||
@Service
|
||||
public class JpaUserSortMapper {
|
||||
|
||||
public Sort toSort(UserSortField sortField, SortDirection direction) {
|
||||
return switch (sortField) {
|
||||
case CREATED_AT -> Sort.by(direction.toSpring(), "createdAt", "id");
|
||||
case DISPLAY_NAME -> Sort.by(direction.toSpring(), "displayName", "id");
|
||||
};
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 외부 정렬 키와 내부 컬럼/프로퍼티가 분리된다
|
||||
- unsupported sort field를 명시적으로 거절할 수 있다
|
||||
- tie-breaker가 포함되어 정렬이 안정적이다
|
||||
- Spring Data `Sort` 변환은 persistence adapter 경계에 머문다
|
||||
|
||||
### 예시 3. count가 필요 없으면 Slice 스타일 응답으로 줄인다
|
||||
|
||||
```java
|
||||
public record SliceResponse<T>(
|
||||
List<T> items,
|
||||
int page,
|
||||
int size,
|
||||
boolean hasNext
|
||||
) {
|
||||
}
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class SearchAuditLogUseCase {
|
||||
|
||||
private final AuditLogRepository auditLogRepository;
|
||||
|
||||
public AuditLogSliceResult search(SearchAuditLogQuery query) {
|
||||
return auditLogRepository.findSliceByCondition(query);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- application은 count가 필요 없는 slice 결과를 typed result로 반환한다
|
||||
- Spring Data `Slice` / `PageRequest`와 response DTO 변환은 바깥 adapter 책임으로 남긴다
|
||||
|
||||
### 예시 4. 대용량 피드는 cursor pagination을 쓴다
|
||||
|
||||
```java
|
||||
public record CursorPageResponse<T>(
|
||||
List<T> items,
|
||||
String nextCursor,
|
||||
boolean hasNext
|
||||
) {
|
||||
}
|
||||
|
||||
public record TimelineRequest(
|
||||
String cursor,
|
||||
@Min(1) @Max(100) int size
|
||||
) {
|
||||
}
|
||||
|
||||
@RestController
|
||||
@RequiredArgsConstructor
|
||||
@RequestMapping("/api/v1/timeline")
|
||||
public class TimelineController {
|
||||
|
||||
private final ReadTimelineUseCase readTimelineUseCase;
|
||||
|
||||
@GetMapping
|
||||
public ApiResult<CursorPageResponse<TimelineItemResponse>> read(
|
||||
@Valid @ModelAttribute TimelineRequest request
|
||||
) {
|
||||
TimelineWindowResult result = readTimelineUseCase.read(request.cursor(), request.size());
|
||||
|
||||
return ApiResult.success(new CursorPageResponse<>(
|
||||
result.items(),
|
||||
result.nextCursor(),
|
||||
result.hasNext()
|
||||
));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 대용량/변동이 큰 목록에 더 적합하다
|
||||
- 외부에는 opaque cursor만 노출한다
|
||||
- page number 깊이에 따라 성능이 급격히 나빠지는 구조를 피할 수 있다
|
||||
|
||||
### 예시 5. 다중 값 필터는 반복 query parameter로 받는다
|
||||
|
||||
```java
|
||||
public record UserSearchRequest(
|
||||
List<UserStatus> status,
|
||||
String keyword,
|
||||
@Min(1) int page,
|
||||
@Min(1) @Max(100) int size
|
||||
) {
|
||||
}
|
||||
```
|
||||
|
||||
요청 예:
|
||||
|
||||
```http
|
||||
GET /api/v1/users?status=ACTIVE&status=PENDING&keyword=kim&page=1&size=20
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 다중 값 필터가 명확하다
|
||||
- query parameter 규약이 읽기 쉽다
|
||||
- Spring 바인딩과도 자연스럽게 맞는다
|
||||
|
||||
## 나쁜 예시
|
||||
|
||||
### 예시 1. 공개 API controller에 raw Pageable을 그대로 노출한다
|
||||
|
||||
```java
|
||||
@RestController
|
||||
@RequiredArgsConstructor
|
||||
@RequestMapping("/api/v1/users")
|
||||
public class BadUserController {
|
||||
|
||||
private final UserRepository userRepository;
|
||||
|
||||
@GetMapping
|
||||
public Page<User> getUsers(Pageable pageable) {
|
||||
return userRepository.findAll(pageable);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 외부 계약이 Spring Data 내부 타입에 종속된다
|
||||
- entity와 raw Page가 그대로 노출된다
|
||||
- request/response 계약을 프로젝트가 통제하기 어렵다
|
||||
|
||||
### 예시 2. 지원하지 않는 sort field를 조용히 무시한다
|
||||
|
||||
```java
|
||||
public Sort toSort(String sortBy, SortDirection direction) {
|
||||
if ("createdAt".equals(sortBy)) {
|
||||
return Sort.by(direction.toSpring(), "createdAt");
|
||||
}
|
||||
return Sort.unsorted();
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 잘못된 요청을 성공처럼 처리한다
|
||||
- client는 정렬이 적용된 줄 오해할 수 있다
|
||||
- unsupported sort는 명시적으로 거절해야 한다
|
||||
|
||||
### 예시 3. 깊은 페이지까지 offset만 강제한다
|
||||
|
||||
```java
|
||||
@GetMapping("/api/v1/events")
|
||||
public ApiResult<PageResponse<EventResponse>> getEvents(
|
||||
@RequestParam int page,
|
||||
@RequestParam int size
|
||||
) {
|
||||
// 수백만 건 로그를 무조건 offset paging으로 조회
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 큰 offset paging은 성능이 급격히 나빠질 수 있다
|
||||
- 이벤트/로그/피드 계열에는 cursor 전략 검토가 필요하다
|
||||
|
||||
### 예시 4. 페이지마다 sort/filter가 달라질 수 있게 한다
|
||||
|
||||
```http
|
||||
GET /api/v1/users?page=1&size=20&sortBy=createdAt&direction=desc
|
||||
GET /api/v1/users?page=2&size=20&sortBy=displayName&direction=asc
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 같은 목록의 다음 페이지라는 의미가 깨진다
|
||||
- 중복/누락/순서 흔들림이 생길 수 있다
|
||||
|
||||
### 예시 5. 범용 filter DSL을 기본 공개 API에 도입한다
|
||||
|
||||
```http
|
||||
GET /api/v1/users?filter=(status eq ACTIVE and (createdAt gt 2026-01-01)) or (role in [ADMIN,OWNER])
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 단순 목록 API치고 계약이 과도하게 복잡하다
|
||||
- 문서화, 검증, 운영 비용이 커진다
|
||||
- 기본 공개 API로는 명시적 필터 파라미터가 더 낫다
|
||||
@@ -0,0 +1,202 @@
|
||||
# Request / Response DTO 예시
|
||||
|
||||
## 좋은 예시
|
||||
|
||||
### 예시 1. request와 response를 명확히 분리한다
|
||||
|
||||
```java
|
||||
public record CreateUserRequest(
|
||||
@NotBlank String email,
|
||||
@NotBlank String password,
|
||||
@NotBlank String displayName
|
||||
) {
|
||||
}
|
||||
|
||||
public record CreateUserResponse(
|
||||
String userId,
|
||||
String email,
|
||||
String displayName
|
||||
) {
|
||||
}
|
||||
|
||||
@RestController
|
||||
@RequiredArgsConstructor
|
||||
@RequestMapping("/api/users")
|
||||
public class UserCommandController {
|
||||
|
||||
private final RegisterUserUseCase registerUserUseCase;
|
||||
|
||||
@PostMapping
|
||||
public ApiResult<CreateUserResponse> register(@Valid @RequestBody CreateUserRequest request) {
|
||||
RegisteredUser registeredUser = registerUserUseCase.register(
|
||||
request.email(),
|
||||
request.password(),
|
||||
request.displayName()
|
||||
);
|
||||
|
||||
return ApiResult.success(new CreateUserResponse(
|
||||
registeredUser.userId(),
|
||||
registeredUser.email(),
|
||||
registeredUser.displayName()
|
||||
));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- request와 response 역할이 분리된다
|
||||
- request DTO가 그대로 내부 모델처럼 전파되지 않는다
|
||||
- 응답이 entity 구조가 아니라 API 계약 중심으로 표현된다
|
||||
|
||||
### 예시 2. query/form 입력은 전용 @ModelAttribute DTO로 받는다
|
||||
|
||||
```java
|
||||
public record UserSearchRequest(
|
||||
@NotBlank String keyword,
|
||||
@Min(1) int page,
|
||||
@Min(1) @Max(100) int size
|
||||
) {
|
||||
}
|
||||
|
||||
@RestController
|
||||
@RequiredArgsConstructor
|
||||
@RequestMapping("/api/users")
|
||||
public class UserQueryController {
|
||||
|
||||
private final SearchUsersUseCase searchUsersUseCase;
|
||||
|
||||
@GetMapping
|
||||
public ApiResult<UserSearchResponse> search(@Valid @ModelAttribute UserSearchRequest request) {
|
||||
UserSearchResult result = searchUsersUseCase.search(
|
||||
request.keyword(),
|
||||
request.page(),
|
||||
request.size()
|
||||
);
|
||||
|
||||
return ApiResult.success(UserSearchResponse.from(result));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- query 입력도 전용 web model로 분리된다
|
||||
- @ModelAttribute 대상이 domain/entity가 아니다
|
||||
- 검색 조건과 응답 모델이 분리된다
|
||||
|
||||
### 예시 3. ResponseEntity는 HTTP 제어가 필요할 때만 사용한다
|
||||
|
||||
```java
|
||||
@RestController
|
||||
@RequiredArgsConstructor
|
||||
@RequestMapping("/api/users")
|
||||
public class UserCommandController {
|
||||
|
||||
private final RegisterUserUseCase registerUserUseCase;
|
||||
|
||||
@PostMapping
|
||||
public ResponseEntity<ApiResult<CreateUserResponse>> register(
|
||||
@Valid @RequestBody CreateUserRequest request
|
||||
) {
|
||||
RegisteredUser registeredUser = registerUserUseCase.register(
|
||||
request.email(),
|
||||
request.password(),
|
||||
request.displayName()
|
||||
);
|
||||
|
||||
CreateUserResponse response = new CreateUserResponse(
|
||||
registeredUser.userId(),
|
||||
registeredUser.email(),
|
||||
registeredUser.displayName()
|
||||
);
|
||||
|
||||
return ResponseEntity.created(URI.create("/api/users/" + response.userId()))
|
||||
.body(ApiResult.success(response));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 201 Created와 Location 제어가 필요해 ResponseEntity 사용 이유가 분명하다
|
||||
|
||||
## 나쁜 예시
|
||||
|
||||
### 예시 1. entity를 request body로 직접 받는다
|
||||
|
||||
```java
|
||||
@Entity
|
||||
public class User {
|
||||
@Id
|
||||
private Long id;
|
||||
private String email;
|
||||
private String role;
|
||||
}
|
||||
|
||||
@PostMapping("/api/users")
|
||||
public ApiResult<Void> create(@Valid @RequestBody User user) {
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- web input model과 persistence/domain model이 섞인다
|
||||
- 바인딩 범위가 불필요하게 넓다
|
||||
- API 변경이 entity 구조에 직접 번진다
|
||||
|
||||
### 예시 2. request와 response를 하나의 DTO로 재사용한다
|
||||
|
||||
```java
|
||||
public record UserDto(
|
||||
String userId,
|
||||
String email,
|
||||
String password,
|
||||
String displayName,
|
||||
String role
|
||||
) {
|
||||
}
|
||||
|
||||
@PostMapping("/api/users")
|
||||
public ApiResult<UserDto> create(@RequestBody UserDto request) {
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 요청과 응답의 책임이 섞인다
|
||||
- 응답에 불필요하거나 민감한 필드가 섞이기 쉽다
|
||||
- write model과 read model이 분리되지 않는다
|
||||
|
||||
### 예시 3. DTO를 내부 모델처럼 그대로 넘긴다
|
||||
|
||||
```java
|
||||
@PostMapping("/api/users")
|
||||
public ApiResult<Void> create(@Valid @RequestBody CreateUserRequest request) {
|
||||
registerUserUseCase.register(request);
|
||||
return ApiResult.success(null);
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- request DTO가 presentation 경계를 넘어 application 시그니처로 새어 나간다
|
||||
- 내부 use case가 transport model에 결합된다
|
||||
|
||||
### 예시 4. 응답으로 entity를 직접 반환한다
|
||||
|
||||
```java
|
||||
@GetMapping("/api/users/{userId}")
|
||||
public ApiResult<User> get(@PathVariable Long userId) {
|
||||
User user = userRepository.findById(userId).orElseThrow();
|
||||
return ApiResult.success(user);
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- persistence/domain 구조가 외부 계약이 된다
|
||||
- 내부 필드가 의도치 않게 노출되기 쉽다
|
||||
- controller가 repository와 entity에 직접 결합된다
|
||||
@@ -0,0 +1,233 @@
|
||||
# Response Format 예시
|
||||
|
||||
## 좋은 예시
|
||||
|
||||
### 예시 1. 일반 성공 응답은 ApiResult<T>로 반환한다
|
||||
|
||||
```java
|
||||
public record UserResponse(
|
||||
String userId,
|
||||
String email,
|
||||
String displayName
|
||||
) {
|
||||
}
|
||||
|
||||
@RestController
|
||||
@RequiredArgsConstructor
|
||||
@RequestMapping("/api/users")
|
||||
public class UserQueryController {
|
||||
|
||||
private final UserQueryUseCase userQueryUseCase;
|
||||
|
||||
@GetMapping("/{userId}")
|
||||
public ApiResult<UserResponse> getUser(@PathVariable String userId) {
|
||||
UserResult result = userQueryUseCase.getUser(userId);
|
||||
|
||||
return ApiResult.success(new UserResponse(
|
||||
result.userId(),
|
||||
result.email(),
|
||||
result.displayName()
|
||||
));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 성공 응답 형식이 명확하다
|
||||
- business payload와 공통 envelope가 분리된다
|
||||
- controller가 임시 JSON을 조립하지 않는다
|
||||
|
||||
### 예시 2. HTTP 제어가 필요할 때만 ResponseEntity<ApiResult<T>>를 사용한다
|
||||
|
||||
```java
|
||||
@RestController
|
||||
@RequiredArgsConstructor
|
||||
@RequestMapping("/api/users")
|
||||
public class UserCommandController {
|
||||
|
||||
private final RegisterUserUseCase registerUserUseCase;
|
||||
|
||||
@PostMapping
|
||||
public ResponseEntity<ApiResult<CreateUserResponse>> register(
|
||||
@Valid @RequestBody CreateUserRequest request
|
||||
) {
|
||||
RegisteredUser result = registerUserUseCase.register(
|
||||
request.email(),
|
||||
request.password(),
|
||||
request.displayName()
|
||||
);
|
||||
|
||||
CreateUserResponse response = new CreateUserResponse(
|
||||
result.userId(),
|
||||
result.email(),
|
||||
result.displayName()
|
||||
);
|
||||
|
||||
return ResponseEntity.created(URI.create("/api/users/" + response.userId()))
|
||||
.body(ApiResult.success(response));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- ResponseEntity 사용 이유가 201 Created + Location으로 분명하다
|
||||
- body 형식은 여전히 프로젝트 표준을 따른다
|
||||
|
||||
### 예시 3. 실패 응답은 advice에서 ApiResult로 통일한다
|
||||
|
||||
```java
|
||||
@RestControllerAdvice
|
||||
public class ApiExceptionHandler {
|
||||
|
||||
@ExceptionHandler(MethodArgumentNotValidException.class)
|
||||
public ResponseEntity<ApiResult<Map<String, String>>> handleValidation(
|
||||
MethodArgumentNotValidException ex
|
||||
) {
|
||||
Map<String, String> errors = ex.getBindingResult()
|
||||
.getFieldErrors()
|
||||
.stream()
|
||||
.collect(Collectors.toUnmodifiableMap(
|
||||
FieldError::getField,
|
||||
DefaultMessageSourceResolvable::getDefaultMessage,
|
||||
(first, second) -> first
|
||||
));
|
||||
|
||||
return ResponseEntity.badRequest()
|
||||
.body(ApiResult.fail(ErrorCode.REQUEST_VALIDATION_FAILED, errors));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 예외 응답 형식이 중앙에서 통일된다
|
||||
- controller가 실패 body를 직접 조립하지 않는다
|
||||
- 상세 오류 정보가 규칙적으로 담긴다
|
||||
|
||||
### 예시 4. 전역 응답 래핑은 이중 래핑을 피한다
|
||||
|
||||
```java
|
||||
@RestControllerAdvice
|
||||
public class ApiResultResponseBodyAdvice implements ResponseBodyAdvice<Object> {
|
||||
|
||||
@Override
|
||||
public boolean supports(MethodParameter returnType, Class<?> converterType) {
|
||||
Class<?> parameterType = returnType.getParameterType();
|
||||
|
||||
return !ApiResult.class.isAssignableFrom(parameterType)
|
||||
&& !ResponseEntity.class.isAssignableFrom(parameterType)
|
||||
&& !Resource.class.isAssignableFrom(parameterType);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Object beforeBodyWrite(
|
||||
Object body,
|
||||
MethodParameter returnType,
|
||||
MediaType selectedContentType,
|
||||
Class<? extends HttpMessageConverter<?>> selectedConverterType,
|
||||
ServerHttpRequest request,
|
||||
ServerHttpResponse response
|
||||
) {
|
||||
if (body == null) {
|
||||
return ApiResult.success(null);
|
||||
}
|
||||
|
||||
if (body instanceof ApiResult<?>) {
|
||||
return body;
|
||||
}
|
||||
|
||||
return ApiResult.success(body);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 공통 envelope 적용 지점이 분명하다
|
||||
- 이미 래핑된 응답을 다시 감싸지 않는다
|
||||
- file/resource 응답을 무심코 건드리지 않는다
|
||||
|
||||
## 나쁜 예시
|
||||
|
||||
### 예시 1. controller마다 임시 응답 구조를 만든다
|
||||
|
||||
```java
|
||||
@GetMapping("/api/users/{userId}")
|
||||
public Map<String, Object> getUser(@PathVariable String userId) {
|
||||
UserResult result = userQueryUseCase.getUser(userId);
|
||||
|
||||
Map<String, Object> response = new LinkedHashMap<>();
|
||||
response.put("ok", true);
|
||||
response.put("payload", result);
|
||||
return response;
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 프로젝트 공통 응답 형식을 깨뜨린다
|
||||
- 다른 endpoint와 구조가 달라진다
|
||||
- 임시 필드명이 계약이 되어 버린다
|
||||
|
||||
### 예시 2. ResponseEntity를 의미 없이 남발한다
|
||||
|
||||
```java
|
||||
@GetMapping("/api/health")
|
||||
public ResponseEntity<ApiResult<String>> health() {
|
||||
return ResponseEntity.ok(ApiResult.success("ok"));
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 별도 header/status 제어가 없다
|
||||
- 불필요한 ceremony만 늘어난다
|
||||
|
||||
### 예시 3. 실패 응답에 내부 예외 메시지를 그대로 노출한다
|
||||
|
||||
```java
|
||||
@ExceptionHandler(Exception.class)
|
||||
public ResponseEntity<ApiResult<Void>> handle(Exception ex) {
|
||||
return ResponseEntity.internalServerError()
|
||||
.body(ApiResult.fail("INTERNAL_SERVER_ERROR", ex.getMessage()));
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 내부 메시지가 외부 계약이 된다
|
||||
- 민감한 구현 세부사항이 노출될 수 있다
|
||||
- 외부 응답 메시지 정책이 없다
|
||||
|
||||
### 예시 4. ResponseBodyAdvice에서 무조건 감싼다
|
||||
|
||||
```java
|
||||
@RestControllerAdvice
|
||||
public class BadApiResultResponseBodyAdvice implements ResponseBodyAdvice<Object> {
|
||||
|
||||
@Override
|
||||
public boolean supports(MethodParameter returnType, Class<?> converterType) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Object beforeBodyWrite(
|
||||
Object body,
|
||||
MethodParameter returnType,
|
||||
MediaType selectedContentType,
|
||||
Class<? extends HttpMessageConverter<?>> selectedConverterType,
|
||||
ServerHttpRequest request,
|
||||
ServerHttpResponse response
|
||||
) {
|
||||
return ApiResult.success(body);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 이미 ApiResult인 응답도 이중 래핑한다
|
||||
- 파일/리소스/스트리밍 응답을 망가뜨릴 수 있다
|
||||
- 규약 적용이 아니라 무차별 변환이 된다
|
||||
@@ -0,0 +1,234 @@
|
||||
# Serialization / Jackson 예시
|
||||
|
||||
## 좋은 예시
|
||||
|
||||
### 예시 1. request/response DTO를 분리하고 timestamp는 offset 기반으로 노출한다
|
||||
|
||||
```java
|
||||
public record CreateSessionRequest(
|
||||
@NotBlank String email,
|
||||
@NotBlank String password
|
||||
) {
|
||||
}
|
||||
|
||||
public record CreateSessionResponse(
|
||||
String sessionId,
|
||||
String accessToken,
|
||||
OffsetDateTime issuedAt,
|
||||
OffsetDateTime expiresAt
|
||||
) {
|
||||
}
|
||||
|
||||
@RestController
|
||||
@RequiredArgsConstructor
|
||||
@RequestMapping("/api/v1/sessions")
|
||||
public class SessionCommandController {
|
||||
|
||||
private final CreateSessionUseCase createSessionUseCase;
|
||||
|
||||
@PostMapping
|
||||
public ApiResult<CreateSessionResponse> create(
|
||||
@Valid @RequestBody CreateSessionRequest request
|
||||
) {
|
||||
SessionResult result = createSessionUseCase.create(request.email(), request.password());
|
||||
|
||||
return ApiResult.success(new CreateSessionResponse(
|
||||
result.sessionId(),
|
||||
result.accessToken(),
|
||||
result.issuedAt(),
|
||||
result.expiresAt()
|
||||
));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- request/response 계약이 분리된다
|
||||
- password는 응답 DTO에 존재하지 않는다
|
||||
- timestamp가 OffsetDateTime으로 명확하다
|
||||
|
||||
### 예시 2. 외부 공급자 webhook DTO만 lenient하게 받는다
|
||||
|
||||
```java
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public record ExternalAuthWebhookRequest(
|
||||
String eventId,
|
||||
String eventType,
|
||||
String subjectId
|
||||
) {
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 외부 공급자가 필드를 추가해도 파싱이 덜 깨진다
|
||||
- lenient 정책이 third-party integration DTO로 국소화된다
|
||||
- first-party API request DTO와 기준이 분리된다
|
||||
|
||||
### 예시 3. 외부 계약 이름 mismatch만 @JsonProperty로 보정한다
|
||||
|
||||
```java
|
||||
public record ExternalUserResponse(
|
||||
@JsonProperty("user_id") String userId,
|
||||
@JsonProperty("display_name") String displayName
|
||||
) {
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 내부 표준 naming을 전체 프로젝트에 퍼뜨리지 않는다
|
||||
- mismatch를 DTO 경계에서 해결한다
|
||||
|
||||
### 예시 4. write-only 필드는 예외적으로만 사용한다
|
||||
|
||||
```java
|
||||
public record ResetPasswordCommandRequest(
|
||||
@NotBlank String userId,
|
||||
@NotBlank @JsonProperty(access = JsonProperty.Access.WRITE_ONLY) String newPassword
|
||||
) {
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 입력만 받고 다시 내보내면 안 되는 필드를 제한적으로 막는다
|
||||
- 그래도 request DTO 안에 국소화돼 있다
|
||||
|
||||
### 예시 5. 공통 직렬화 예외는 전역 컴포넌트로 등록한다
|
||||
|
||||
```java
|
||||
@Configuration
|
||||
public class JacksonConfig {
|
||||
|
||||
@Bean
|
||||
Module userIdModule() {
|
||||
SimpleModule module = new SimpleModule();
|
||||
module.addSerializer(UserId.class, new JsonSerializer<>() {
|
||||
@Override
|
||||
public void serialize(UserId value, JsonGenerator gen, SerializerProvider serializers) throws IOException {
|
||||
gen.writeString(value.value());
|
||||
}
|
||||
});
|
||||
return module;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 반복되는 값 객체 직렬화를 전역 정책으로 올린다
|
||||
- controller나 DTO마다 같은 로직을 복붙하지 않는다
|
||||
|
||||
## 나쁜 예시
|
||||
|
||||
### 예시 1. entity를 그대로 응답으로 내보낸다
|
||||
|
||||
```java
|
||||
@Entity
|
||||
public class User {
|
||||
@Id
|
||||
private Long id;
|
||||
private String email;
|
||||
private String password;
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
private Organization organization;
|
||||
}
|
||||
|
||||
@GetMapping("/api/v1/users/{id}")
|
||||
public ApiResult<User> getUser(@PathVariable Long id) {
|
||||
User user = userRepository.findById(id).orElseThrow();
|
||||
return ApiResult.success(user);
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- persistence 모델이 외부 계약이 된다
|
||||
- 민감 필드와 lazy relation 노출 위험이 있다
|
||||
- API shape가 entity 구조에 끌려간다
|
||||
|
||||
### 예시 2. controller에서 로컬 ObjectMapper를 만든다
|
||||
|
||||
```java
|
||||
@GetMapping("/api/v1/users/{id}")
|
||||
public String getUser(@PathVariable Long id) throws JsonProcessingException {
|
||||
ObjectMapper mapper = new ObjectMapper();
|
||||
return mapper.writeValueAsString(userService.getUser(id));
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 전역 Jackson 규칙을 우회한다
|
||||
- converter, module, naming, time 설정이 끊어진다
|
||||
- controller 책임이 과도해진다
|
||||
|
||||
### 예시 3. public API shape를 @JsonView로 관리한다
|
||||
|
||||
```java
|
||||
public class UserViewModel {
|
||||
|
||||
@JsonView(Summary.class)
|
||||
private String userId;
|
||||
|
||||
@JsonView(Summary.class)
|
||||
private String displayName;
|
||||
|
||||
@JsonView(Detail.class)
|
||||
private String email;
|
||||
|
||||
interface Summary {}
|
||||
interface Detail extends Summary {}
|
||||
}
|
||||
|
||||
@GetMapping("/api/v1/users/{id}")
|
||||
@JsonView(UserViewModel.Summary.class)
|
||||
public UserViewModel getUser(@PathVariable String id) {
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- summary/detail 계약이 DTO 분리 대신 view 규칙에 숨어든다
|
||||
- public API contract evolution이 읽기 어려워진다
|
||||
- versioning/응답 shape 관리 수단으로는 과도하게 간접적이다
|
||||
|
||||
### 예시 4. first-party request DTO에서 unknown field를 무비판적으로 무시한다
|
||||
|
||||
```java
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public record CreateUserRequest(
|
||||
String email,
|
||||
String password,
|
||||
String displayName
|
||||
) {
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 클라이언트 오타나 잘못된 필드 전송을 조용히 숨길 수 있다
|
||||
- 우리가 소유한 API 계약이 흐려진다
|
||||
- strict 정책을 택한 API군과 충돌한다
|
||||
|
||||
### 예시 5. null omission을 보기 좋다는 이유만으로 남발한다
|
||||
|
||||
```java
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
public record UserResponse(
|
||||
String userId,
|
||||
String displayName,
|
||||
String email,
|
||||
String phoneNumber
|
||||
) {
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 필드 omission이 계약 의미를 바꾼다
|
||||
- 클라이언트가 null과 absent를 구분해야 하는 경우 혼란이 생긴다
|
||||
- 전역/DTO별 정책이 뒤섞이기 쉽다
|
||||
Reference in New Issue
Block a user