5.4 KiB
5.4 KiB
Integration Serialization / Deserialization 예시
좋은 예시
예시 1. 외부 response DTO만 관대하게 읽는다
@JsonIgnoreProperties(ignoreUnknown = true)
public record KeycloakUserResponse(
@JsonProperty("id") String id,
@JsonProperty("email") String email,
@JsonProperty("enabled") boolean enabled
) {
}
좋은 이유:
- provider가 필드를 추가해도 파싱이 덜 깨진다
- 외부 필드명 mismatch를 DTO 경계에서 해결한다
- 내부 모델로 바로 새지 않는다.
예시 2. 성공 응답과 오류 응답 DTO를 분리한다
@JsonIgnoreProperties(ignoreUnknown = true)
public record StripeChargeResponse(
@JsonProperty("id") String id,
@JsonProperty("status") String status
) {
}
@JsonIgnoreProperties(ignoreUnknown = true)
public record StripeErrorResponse(
@JsonProperty("error") StripeErrorBody error
) {
}
@JsonIgnoreProperties(ignoreUnknown = true)
public record StripeErrorBody(
@JsonProperty("type") String type,
@JsonProperty("code") String code,
@JsonProperty("message") String message
) {
}
좋은 이유:
- success/error shape를 억지로 하나의 DTO에 우겨 넣지 않는다
- adapter가 provider failure semantics를 더 명확하게 번역할 수 있다
예시 3. form-urlencoded 계약은 JSON으로 억지 변환하지 않는다
@Service
@RequiredArgsConstructor
class KeycloakTokenClient {
private final RestClient restClient;
TokenResult issue(KeycloakTokenCommand command) {
MultiValueMap<String, String> form = new LinkedMultiValueMap<>();
form.add("grant_type", "password");
form.add("client_id", command.clientId());
form.add("username", command.username());
form.add("password", command.password());
KeycloakTokenResponse response = restClient.post()
.uri("/protocol/openid-connect/token")
.contentType(MediaType.APPLICATION_FORM_URLENCODED)
.body(form)
.retrieve()
.body(KeycloakTokenResponse.class);
return new TokenResult(response.accessToken(), response.expiresIn());
}
}
좋은 이유:
- provider media type을 정확히 따른다
- form 계약을 JSON DTO로 왜곡하지 않는다
- Spring converter 지원과도 맞는다.
예시 4. provider-specific weird format은 adapter mapper에서 흡수한다
public record ExternalPaymentResult(
String paymentId,
PaymentState state
) {
}
public class StripePaymentMapper {
ExternalPaymentResult toResult(StripeChargeResponse response) {
return new ExternalPaymentResult(
response.id(),
switch (response.status()) {
case "succeeded" -> PaymentState.SUCCEEDED;
case "processing" -> PaymentState.PROCESSING;
default -> PaymentState.UNKNOWN;
}
);
}
}
좋은 이유:
- provider string enum이 domain enum으로 직접 새지 않는다
- 새 값이 추가돼도 UNKNOWN으로 흡수할 수 있다
예시 5. 공통 builder를 주입받아 client를 만든다
@Service
class ExternalUserClient {
private final RestClient restClient;
ExternalUserClient(RestClient.Builder builder, ExternalUserProperties properties) {
this.restClient = builder
.baseUrl(properties.baseUrl())
.build();
}
}
좋은 이유:
- 공통 HttpMessageConverters와 request factory를 따른다
- 로컬 ObjectMapper/client 생성을 줄인다.
나쁜 예시
예시 1. 내부 entity를 외부 request body로 직접 보낸다
@Entity
public class User {
@Id
private Long id;
private String email;
private String password;
private String role;
}
restClient.post()
.uri("/users")
.body(user)
.retrieve();
나쁜 이유:
- 내부 모델이 외부 계약으로 새어 나간다
- provider에 보내면 안 되는 필드까지 함께 나갈 수 있다
- serialization concern이 domain/entity를 오염시킨다
예시 2. adapter 메서드 안에서 new ObjectMapper()를 만든다
public ExternalUserResult getUser(String id) throws Exception {
String body = httpClient.get(...);
ObjectMapper objectMapper = new ObjectMapper();
return objectMapper.readValue(body, ExternalUserResult.class);
}
나쁜 이유:
- 공통 mapper/configuration을 우회한다
- client별 일관성이 깨진다
- message converter 경계를 스스로 무너뜨린다.
예시 3. external response를 raw Map으로 받아 business 로직에 넘긴다
Map<String, Object> response = restClient.get()
.uri("/users/{id}", id)
.retrieve()
.body(Map.class);
return userService.handle(response);
나쁜 이유:
- payload reading 경계가 application으로 번진다
- contract drift가 여러 계층에 퍼진다
- tolerant reader가 아니라 “아무도 책임지지 않는 reader”가 된다
예시 4. 외부 오류 본문을 그대로 예외 메시지로 올린다
catch (HttpClientErrorException ex) {
throw new RuntimeException(ex.getResponseBodyAsString());
}
나쁜 이유:
- provider raw payload가 내부 예외/로그로 새어 나간다
- 민감정보나 과도한 본문이 포함될 수 있다
- success/error parsing 규칙이 사라진다