init: 클린 기반 auth 서버 설계

This commit is contained in:
DongHyeonka
2026-07-24 14:30:18 +09:00
parent 471db0203d
commit 8a1ac1e769
3642 changed files with 275893 additions and 1 deletions
+218
View File
@@ -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 기준과 충돌한다
- 정상 흐름과 에러 흐름이 한 메서드에 뒤섞인다
+181
View File
@@ -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 정책이 중앙화되지 않는다
- 외부 메시지와 내부 예외 메시지가 섞인다
- 다른 파일에서도 같은 문자열이 반복되기 쉽다
+290
View File
@@ -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 재사용 오용을 막지 못한다
- 중복 생성/중복 실행 위험이 남는다
- “같은 요청의 재시도”와 “다른 요청”을 구분하지 못한다
+285
View File
@@ -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로는 명시적 필터 파라미터가 더 낫다
+202
View File
@@ -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에 직접 결합된다
+233
View File
@@ -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인 응답도 이중 래핑한다
- 파일/리소스/스트리밍 응답을 망가뜨릴 수 있다
- 규약 적용이 아니라 무차별 변환이 된다
+234
View File
@@ -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별 정책이 뒤섞이기 쉽다