외부 API 연동에서 Adapter 패턴으로 포맷 차이 흡수하기
Canonical Model과 스키마 레지스트리를 중심에 둔 Adapter 계층으로 외부 API의 포맷·버전 차이를 흡수하는 구조를 정리한다.
2026-08-13 · 최초 발행 2025-10-14
정의와 적용 범위
Adapter 패턴은 서로 다른 인터페이스(포맷·프로토콜)를 내부 표준 모델에 맞춰 변환하는 중간 계층이다. 외부의 변경이 내부 도메인까지 퍼지지 않도록 막는 안티커럽션 레이어(ACL)로 동작한다는 점이 핵심이다.
포맷 변환은 구조적 변환(JSON↔XML↔CSV↔Proto/Avro), 스칼라 변환(타임존·스케일·인코딩), 의미 변환(코드셋 매핑·값 사전화), 버전 변환(필드 추가·폐기·대체)까지 여러 층을 포함한다. 이를 다루려면 표준 데이터 모델(Canonical Model)을 세우고, 스키마 검증(JSON Schema/Avro/Protobuf)과 버전 관리(semver·contract test), 멱등성·일관성 확보가 뒤따라야 한다.
Canonical Model과 스키마 레지스트리
내부 표준 데이터 모델을 단일 진실 원천(SSOT)으로 두고, 외부별 어댑터는 이 표준 모델 기준으로만 변환한다. 모델 변경은 스키마 레지스트리(예: GitOps + JSON Schema/Avro)로 버전 관리한다. 내부 모델은 하위 호환을 우선하고, 외부 공급자별 변환 규칙은 독립된 버전으로 추적한다.
검증과 변환 파이프라인
입력 → 스키마 검증 → 필드 정규화 → 의미 매핑 → 출력 순서로 파이프라인을 구성한다. 실패하면 즉시 중단하고 에러를 상세히 로깅한다. 유효성 검증은 타입·필수·범위·패턴, 타임존·스케일 보정까지 다룬다. 변환 규칙은 선언적 매핑(룰 엔진·매퍼)으로 만들어 재사용성을 확보한다.
오류 처리와 가시성
오류는 클라이언트 오류(400대, 매핑/검증), 서버 오류(500대), 네트워크/시간초과로 나눠 각각 재시도·대기열(DLQ)·보정 큐를 분리한다. Correlation-ID와 Idempotency-Key를 전파하고, 구조화 로그와 메트릭(성공률, 변환 시간, 재시도율)을 대시보드로 만든다.
복원력과 성능
서킷 브레이커, 지수 백오프 재시도, 레이트 리미팅, 캐싱(Etag/조건부 요청), 벌크헤드로 회복 탄력성을 확보한다. 큰 페이로드는 스트리밍 파서로, 변경분은 부분 업데이트(diff/patch)로 처리하고, 서버가 허용하면 배치로 묶어 호출 수와 대역폭을 줄인다.
버전 관리와 계약 테스트
계약 기반 테스트(Consumer-Driven Contract, Pact 등)로 스키마 호환성을 미리 검증한다. SemVer의 Major 변경은 강제로 옵트인시킨다. 점진적 롤아웃은 Dual-run이나 Shadow traffic으로 변환 품질을 확인한 뒤 점유율을 넓히는 방식이 안전하다.
이런 구조 전체(Canonical Model, 검증 파이프라인, 계약 테스트, 복원력 패턴)를 갖추면 체감되는 변화 폭은, 스키마 검증과 멱등성 적용으로 연동 실패율이 3060% 줄고 재처리 건수가 40% 이상 줄어든다는 것, 신규 공급자를 추가할 때 평균 연동 기간이 30% 단축되고 변환 규칙 재사용률이 올라가면서 코드 중복이 50% 이상 줄어든다는 것, 서킷 브레이커와 레이트 리미팅으로 외부 장애가 내부로 번지는 걸 막아 MTTR이 2040% 단축된다는 것이다.
활용 사례
결제 게이트웨이 다중 연동
표준 결제 모델을 정의하고, PG별 어댑터에서 통화·금액 스케일·상태코드를 매핑한 뒤 응답을 표준 정산 이벤트로 바꾼다. 승인·취소는 멱등키를 적용하고, 재시도 한계를 넘으면 DLQ로 보내 운영자가 보정하는 워크플로우로 연결한다.
글로벌 물류 API 연동
타임존은 UTC로 정규화하고, 주소 포맷을 표준화하며, 운송장 상태 코드를 사전화한다. 운송사별로 레이트 리미팅을 차등 적용하고, SLA 모니터링 지표는 하나로 통일한다.
SaaS CRM 양방향 동기화
웹훅 수신 어댑터는 서명 검증 → 스키마 검증 → 필드 매핑 → 이벤트 발행 순서로 처리한다. 역방향 푸시에서는 필드 삭제·숨김 정책을 적용한다. 충돌 해결 정책(최신 타임스탬프 우선 또는 병합 규칙)도 어댑터 레벨에서 일관되게 처리한다.
변환 파이프라인 흐름도
포맷별 특성 비교
| 포맷 | 성능(대역폭/파싱) | 일관성(스키마 엄격성) | 안정성(진화/호환) | 운영 편의 |
|---|---|---|---|---|
| JSON | 중간 | 중간(스키마 외부 도구) | 높음(관용성) | 높음(디버깅 용이) |
| XML | 낮음 | 높음(XSD) | 중간(부담 큰 스키마) | 중간 |
| CSV | 높음(작은 페이로드) | 낮음(약한 스키마) | 낮음(필드 순서 민감) | 중간(사전 합의 필수) |
| Protobuf/Avro | 매우 높음 | 높음(내장 스키마) | 매우 높음(필드 태그 기반) | 낮음(도구 필요) |
Spring Boot 3(WebClient) 기반 구현
- 환경/전제조건: Java 17, Spring Boot 3.3.x
- 의존성: spring-boot-starter-webflux, resilience4j-spring-boot3, com.networknt:json-schema-validator, jackson
- 목적: 내부 주문 표준 모델 → 공급자 요청 포맷 변환 → 응답 검증·매핑 → 멱등성·재시도·서킷 브레이커 적용
pom.xml 의존성 요약
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webflux</artifactId>
</dependency>
<dependency>
<groupId>io.github.resilience4j</groupId>
<artifactId>resilience4j-spring-boot3</artifactId>
</dependency>
<dependency>
<groupId>com.networknt</groupId>
<artifactId>json-schema-validator</artifactId>
<version>1.0.91</version>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
</dependency>
</dependencies>
간단 DTO 및 어댑터 서비스
// Java 17+, Spring Boot 3.3
package com.example.adapter;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.github.resilience4j.circuitbreaker.annotation.CircuitBreaker;
import io.github.resilience4j.retry.annotation.Retry;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Service;
import org.springframework.web.reactive.function.client.WebClient;
import reactor.util.retry.RetryBackoffSpec;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.time.Duration;
import java.time.OffsetDateTime;
import java.time.ZoneOffset;
import java.util.HexFormat;
@JsonInclude(JsonInclude.Include.NON_NULL)
record OrderCanonical(String orderId, String currency, long amountMinor, OffsetDateTime createdAtUtc) {}
record ProviderReq(String id, String curr, String amount, String timestamp) {}
record ProviderRes(String status, String code, String message) {}
@Service
public class ProviderAdapter {
private final WebClient webClient;
private final ObjectMapper om;
private final JsonSchemaValidator validator; // 래퍼, 아래 참고
public ProviderAdapter(WebClient.Builder builder, ObjectMapper om, JsonSchemaValidator validator) {
this.webClient = builder.baseUrl("https://api.provider.example").build();
this.om = om;
this.validator = validator;
}
@CircuitBreaker(name = "provider")
@Retry(name = "provider")
public ProviderRes send(OrderCanonical order) {
// 1) 정규화
var normalized = normalize(order);
// 2) 변환
var req = mapToProvider(normalized);
// 3) 멱등 키
var idempotencyKey = sha256Hex(req.id() + ":" + req.amount());
// 4) 호출
var res = webClient.post()
.uri("/v1/orders")
.contentType(MediaType.APPLICATION_JSON)
.header("Idempotency-Key", idempotencyKey)
.bodyValue(req)
.retrieve()
.onStatus(s -> s.is4xxClientError(), r -> r.createException().flatMap(e -> {
// 매핑/검증/규칙 위반 계열로 분류하여 즉시 실패
return reactor.core.publisher.Mono.error(new RuntimeException("Client error from provider", e));
}))
.bodyToMono(String.class)
.retryWhen(retrySpec())
.map(this::validateAndMap)
.block(Duration.ofSeconds(10));
return res;
}
private OrderCanonical normalize(OrderCanonical o) {
// UTC 정규화, 금액 소수 스케일을 minor unit으로 고정
var ts = o.createdAtUtc() != null ? o.createdAtUtc().withOffsetSameInstant(ZoneOffset.UTC) : OffsetDateTime.now(ZoneOffset.UTC);
return new OrderCanonical(o.orderId(), o.currency().toUpperCase(), o.amountMinor(), ts);
}
private ProviderReq mapToProvider(OrderCanonical o) {
// 예: amountMinor(소수 없는 minor 단위)를 문자열로, 타임스탬프 ISO8601
return new ProviderReq(o.orderId(), o.currency(), String.valueOf(o.amountMinor()), o.createdAtUtc().toString());
}
private ProviderRes validateAndMap(String body) {
try {
JsonNode node = om.readTree(body);
validator.validate(node); // 외부 응답 스키마 검증
var status = node.path("status").asText();
var code = node.path("code").asText();
var message = node.path("message").asText(null);
return new ProviderRes(status, code, message);
} catch (Exception e) {
throw new RuntimeException("Provider response invalid", e);
}
}
private RetryBackoffSpec retrySpec() {
return reactor.util.retry.Retry.backoff(3, Duration.ofMillis(300))
.filter(ex -> !(ex instanceof IllegalArgumentException)) // 클라이언트 오류는 재시도 제외
.maxBackoff(Duration.ofSeconds(5));
}
private static String sha256Hex(String s) {
try {
MessageDigest md = MessageDigest.getInstance("SHA-256");
return HexFormat.of().formatHex(md.digest(s.getBytes(StandardCharsets.UTF_8)));
} catch (Exception e) {
throw new RuntimeException(e);
}
}
}
간단한 JSON 스키마 검증 래퍼
package com.example.adapter;
import com.fasterxml.jackson.databind.JsonNode;
import com.networknt.schema.JsonSchema;
import com.networknt.schema.JsonSchemaFactory;
import com.networknt.schema.SpecVersion;
import com.networknt.schema.ValidationMessage;
import org.springframework.stereotype.Component;
import java.util.Set;
@Component
public class JsonSchemaValidator {
private final JsonSchema schema;
public JsonSchemaValidator() {
// 예시: 실제로는 외부 파일/레지스트리에서 로드
String schemaStr = """
{ "$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"properties": {
"status": { "type": "string" },
"code": { "type": "string" },
"message": { "type": "string" }
},
"required": ["status","code"],
"additionalProperties": true
}
""";
JsonSchemaFactory factory = JsonSchemaFactory.getInstance(SpecVersion.VersionFlag.V202012);
this.schema = factory.getSchema(schemaStr);
}
public void validate(JsonNode node) {
Set<ValidationMessage> errors = schema.validate(node);
if (!errors.isEmpty()) {
throw new IllegalArgumentException("Schema validation failed: " + errors);
}
}
}
핵심 운영 팁
- 포인트 컷 분리: 검증, 변환, 호출, 매핑을 함수 레벨로 분리해 단위 테스트 용이성을 확보한다.
- 계약 테스트: Pact 등으로 어댑터-공급자 계약을 CI에서 지속 검증한다.
- 보안: 서명 검증(HMAC/JWS), PII 마스킹, 응답 로그 샘플링, 비정상 패턴 차단(WAF/Schema hardening)을 적용한다.