Pre-shipment는 통관 문서를 먼저 올립니다
FedEx SOAP API 종료에 대응해 운송장 생성 기능을 REST로 바꾸면서 ETD(Electronic Trade Documents)도 도입했습니다. ETD는 통관 문서를 전자 제출하는 기능인데, 구현 순서를 잘못 이해하면 운송장 요청과 문서 업로드의 책임이 뒤집힙니다.
이번에 적용한 것은 Pre-shipment 방식이었습니다. 자체 생성한 Commercial Invoice PDF를 먼저 업로드하고, 응답으로 받은 documentId를 운송장 생성 요청에 넣습니다.
Commercial Invoice PDF 생성
│
▼
Trade Documents Upload API
│ documentId
▼
Create Shipment 요청에 문서 참조 추가
│
▼
운송장 번호·라벨 응답 처리Post-shipment 방식처럼 운송장 번호를 먼저 받은 뒤 문서를 올리는 흐름과는 호출 순서와 요청 필드가 다릅니다. 외부 안내와 실제 오류를 검토할 때도 어느 방식에 대한 설명인지 먼저 구분했습니다.
SDK 없이 요청·응답 모델을 만들었습니다
REST SDK를 도입하지 않고 기존 Spring 통신 방식에 맞춰 운송 요청·응답 모델과 문서 업로드 모델을 만들었습니다. 문서 업로드 요청에는 다음 값이 들어갔습니다.
- 워크플로: Pre-shipment ETD
- 문서 종류: Commercial Invoice
- 파일 정보: PDF 이름과 Content-Type
- 출발·도착 국가 코드
업로드는 JSON 메타데이터와 PDF 파일을 하나의 multipart/form-data 요청으로 보냈습니다. 성공 응답의 docId를 통신 계층에서 꺼내 업무 계층에 돌려줬습니다. 내부 이름을 줄인 모델은 다음과 같습니다.
@Builder
public class DocumentUploadRequest {
private final String workflow = "ETD_PRE_SHIPMENT";
private final String fileName;
private final String contentType = "application/pdf";
private final DocumentMeta meta;
}
@Getter
public class DocumentUploadResponse {
private Output output;
public static class Output {
private DocumentMeta meta;
}
public static class DocumentMeta {
private String documentId;
private String documentType;
}
}클래스와 일부 고정값은 공개용으로 일반화했지만, 메타데이터 JSON + PDF → 문서 ID라는 실제 요청·응답 책임은 그대로입니다. 계정번호, 인증정보, 운영 URL은 포함하지 않았습니다.
업무 계층에서 호출 순서를 고정했습니다
문서 API와 운송장 API를 각각 호출할 수 있게 만드는 것만으로는 부족했습니다. documentId가 생기기 전에 운송장 요청을 만들지 않도록 상위 서비스에서 순서를 고정했습니다.
public ShipmentResult generateShipment(Shipment shipment) {
boolean etdEnabled = settings.isEtdEnabled();
if (etdEnabled) {
File invoice = invoicePdf.create(shipment);
String documentId = documentClient.uploadCommercialInvoice(
shipment.accountNumber(), invoice, toUploadRequest(shipment, invoice));
shipment.attachDocument(documentId);
}
ShipmentRequest request = shipmentRequestFactory.create(shipment);
ShipmentResult result = shipmentClient.createShipment(
request, shipment.accountNumber());
if (!etdEnabled) {
invoicePdf.createWithTrackingNumber(shipment, result.trackingNumber());
}
return result;
}이 코드는 실제 서비스의 동작 순서를 보존해 축약했습니다. ETD를 쓰면 상업송장을 먼저 만들고 업로드해 문서 ID를 저장합니다. 그 다음 요청 팩토리가 문서 참조를 포함한 운송장 요청을 만듭니다. ETD를 쓰지 않는 법인은 기존처럼 운송장 번호를 받은 뒤 출력용 Invoice를 만듭니다.
documentId는 운송장 요청의 문서 참조가 됩니다
문서 업로드가 성공하면 반환받은 ID를 Create Shipment 요청의 전자문서 상세에 넣습니다. 그래야 두 호출이 하나의 배송으로 연결됩니다.
if (etdEnabled && hasText(shipment.documentId())) {
specialServices.add(ELECTRONIC_TRADE_DOCUMENTS);
etdDetail = EtdDetail.builder()
.requestedDocumentTypes(List.of("COMMERCIAL_INVOICE"))
.attachedDocuments(List.of(
AttachedDocument.builder()
.documentType("COMMERCIAL_INVOICE")
.documentId(shipment.documentId())
.build()))
.build();
}여기서 documentId를 일반 배송 필드와 섞지 않고 ETD 상세 모델에 뒀습니다. ID가 없는 경우에는 전자문서 서비스를 요청하지 않습니다. 운송장 응답에서는 운송장 번호와 라벨 PDF를 처리하고, Commercial Invoice를 종이로 몇 부 출력해야 하는지도 ETD 사용·업로드 여부와 응답 문서 정보를 기준으로 판단했습니다.
ETD가 비활성화됐거나 문서가 전자 제출되지 않은 경우에는 수동 Commercial Invoice 출력이 필요하다는 결과를 남겼습니다. 외부 문서 응답이 비어 있는 경우까지 무조건 전자 제출 완료로 보지 않았습니다.
OAuth 토큰을 계정군별로 캐시했습니다
운송장과 문서 업로드 API는 모두 OAuth Access Token이 필요합니다. 호출마다 토큰을 새로 발급하면 불필요한 인증 요청이 늘고, 동시에 여러 운송장을 만들 때 같은 자격정보로 토큰 요청이 몰릴 수 있습니다.
실제 구현은 clientId별 자격정보, 토큰과 Lock을 각각 ConcurrentHashMap으로 관리했습니다. 만료된 경우에만 Lock 안에서 캐시를 다시 확인하고 새 토큰을 요청했습니다.
TokenInfo token = tokenCache.get(clientId);
if (token == null || token.isExpired(now())) {
Object lock = locks.computeIfAbsent(clientId, key -> new Object());
synchronized (lock) {
token = tokenCache.get(clientId);
if (token == null || token.isExpired(now())) {
token = requestNewToken(credentials.get(clientId));
tokenCache.put(clientId, token);
}
}
}
return token.accessToken();Lock에 들어간 뒤 캐시를 다시 읽는 이유는 먼저 들어간 요청이 이미 토큰을 갱신했을 수 있기 때문입니다. 만료 직전의 토큰을 다른 요청이 집어 가지 않도록 실제 구현에서는 응답 만료 시간보다 앞선 시점을 캐시 만료로 계산했습니다.
법인별 인증과 ETD 정책을 분리했습니다
하나의 애플리케이션이 여러 배송 계정을 사용했기 때문에 운영 환경용 키 하나로는 부족했습니다. 배송 계정번호를 인증 clientId로 해석하는 Resolver를 두고, 토큰 관리자는 그 clientId에 맞는 Key·Secret을 읽도록 나눴습니다.
설정을 공개용 이름으로 바꾸면 다음과 같습니다.
fedex.clients=headquarters,branch
fedex.client.headquarters.key=${FEDEX_HQ_KEY}
fedex.client.headquarters.secret=${FEDEX_HQ_SECRET}
fedex.client.branch.key=${FEDEX_BRANCH_KEY}
fedex.client.branch.secret=${FEDEX_BRANCH_SECRET}
fedex.account-client.<masked-account>=branch
fedex.etd.enabled=true실제 계정번호와 환경별 값은 제외했습니다. 테스트·운영 환경도 분리했고, 법인별 운영 정책은 다음처럼 달랐습니다.
| 적용 범위 | REST 운송장 | ETD | 비고 |
|---|---|---|---|
| 본사 | 적용 | 적용 | Pre-shipment 문서 업로드 |
| 칠레지사 | 적용 | 적용 | 전용 운영 인증 연결 후 반영 |
| 유럽법인 | 적용 | 미적용 | 현업 운영 방침에 따라 REST만 사용 |
검증 범위와 이전 글과의 차이
본사·칠레·유럽의 계정, API Key, 환경 설정과 운송장 생성 결과를 교차 확인했습니다. Commercial Invoice 업로드부터 documentId, 운송장 생성까지 이어지는 흐름과 전자 제출 실패 시 수동 출력 절차도 확인했습니다. 이후 유럽의 독립 실행 환경에서 빠진 인증 설정을 별도로 보완했습니다.
관련 글인 FedEx REST 배포를 백업 WAR로 복구하고 재전환한 과정은 운영 계정 연결 누락과 단계적 복구에 초점을 맞춥니다. 이 글은 같은 프로젝트에서 문서 업로드 → 문서 ID → 운송장 생성을 코드로 어떻게 연결하고 인증·ETD 설정을 어디에 나눴는지 설명합니다.