235 lines
5.8 KiB
Markdown
235 lines
5.8 KiB
Markdown
# 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별 정책이 뒤섞이기 쉽다
|