해외 구매대행 쇼핑몰에 유니패스 통관부호 실시간 검증 붙이기
FastAPI 프록시 + 바닐라 JS로 카페24 블랙박스 위에 검증 레이어를 얹은 후기
클라이언트로부터 연락이 왔습니다.
"주문할 때 개인통관고유부호가 맞는지 확인해주세요."
요구사항만 보면 단순합니다.
파고들면, 생각보다 훨씬 복잡한 작업이었습니다.
결론부터 말하면, FastAPI 프록시 서버와 바닐라 JS로 해결했고, 재사용 가능한 구조로 만들어뒀습니다.
배경
개인통관고유부호(PCCC)는 해외직구 상품 통관 시 사용하는 13자리 고유번호입니다.
P로 시작하고 뒤에 12자리 숫자가 붙습니다.
관세청 유니패스(UNI-PASS)에서 발급하며, 구매대행 쇼핑몰은 주문 시 이 번호를 수집해 통관 신고에 활용합니다.
2026년 2월 2일부터 관세청 정책이 바뀌었습니다.
기존에는 이름과 전화번호만으로 통관이 됐는데, 우편번호 일치 여부까지 검증 항목에 추가됐습니다.
API 스펙상 우편번호(custPsno) 파라미터는 여전히 옵션으로 표기돼 있지만, 누락되거나 불일치하면 통관 단계에서 지연 또는 차단됩니다.
사실상 필수가 된 셈입니다.
구매대행 특성상 통관 실패는 곧 배송 지연이고, CS 폭탄입니다.
카페24 앱스토어에 공식 PCCC 앱이 있긴 합니다.
주문서에 통관부호 입력 필드를 동적으로 주입해주는 역할입니다.
근데 입력만 받을 뿐, 관세청에 실제로 검증 요청을 보내지는 않습니다.
잘못된 번호를 입력해도, 이름이나 전화번호가 다르게 등록돼 있어도 주문이 그냥 접수됩니다.
문제는 며칠 뒤 통관 단계에서 터집니다.
목표는 명확했습니다. 주문 시점에 관세청 유니패스 API와 실시간으로 통신해서, 검증을 통과하지 못하면 결제 버튼 자체를 막는 것입니다.
기술적 과제 셋
과제 1: 관세청 API를 브라우저에서 직접 호출할 수 없다
유니패스 OpenAPI는 브라우저에서 직접 호출하면 두 가지 문제가 생깁니다.
첫째, API 인증키(crkyCn)가 JS 코드에 그대로 노출됩니다.
브라우저 개발자 도구로 누구나 꺼낼 수 있습니다.
유니패스 API 키는 발급 주체인 제 명의로 1개만 발급되며, 이 키로 여러 클라이언트를 동시에 운영하는 구조라 유출 시 모든 서비스가 한꺼번에 중단됩니다.
둘째, 유니패스 API는 CORS를 허용하지 않습니다.
브라우저에서 https://unipass.customs.go.kr:38010으로 직접 fetch를 보내면 preflight 단계에서 막힙니다.
포트도 38010번 커스텀 포트를 씁니다.
해결책은 FastAPI 프록시 서버였습니다.
[고객 브라우저 — 카페24 주문서]
| fetch POST /api/pccc/verify
v
[unipass.chahyunwoo.dev — Cloudflare Tunnel]
| HTTPS 프록시
v
[Mac mini 홈서버 — Docker FastAPI]
| GET unipass.customs.go.kr:38010 (API 키 주입)
v
[관세청 유니패스 서버]
| XML 응답 (tCnt: 1/0/-1)
v
[FastAPI — XML 파싱 + 에러 메시지 정제 + JSON 변환]
| JSON 응답
v
[고객 브라우저 — UI 업데이트]
브라우저는 제가 운영하는 서버로만 요청을 보내고, 서버가 유니패스 API를 대신 호출합니다.
API 키는 서버 환경변수로만 관리하며 응답이나 로그 어디에도 포함되지 않습니다.
유니패스가 XML로 응답하는 것도 서버 레이어에서 JSON으로 변환해 프론트에 전달합니다.
개발 단계에서는 Mac mini 홈서버에 Docker Compose로 배포하고 Cloudflare Tunnel로 외부에 노출했습니다.
Cloudflare Tunnel을 쓰면 포트 포워딩이나 공인 IP 없이도 도메인을 붙일 수 있고 SSL도 자동으로 처리됩니다.
과제 2: 카페24 주문서는 블랙박스다
카페24 주문서에 외부 스크립트를 넣는 건 생각보다 까다롭습니다.
공식 PCCC 앱이 이미 DOM에 입력 필드를 동적으로 주입하고 있고, 이 주입 타이밍이 불투명합니다.
페이지 로드 시점에 #clearance-info-code를 찾으면 null이 반환됩니다. 앱 스크립트가 나중에 삽입하기 때문입니다.
처음에는 MutationObserver 기반 이벤트 위임을 시도했습니다.
그런데 카페24 자체 스크립트와 이벤트 버블링이 충돌하는 상황이 발생했습니다.
body capture phase change 리스너가 카페24 내부 개인정보 동의 모달 등의 로직과 충돌한 겁니다.
결국 폴링 방식으로 전환했습니다.
폴링은 안티패턴으로 알려져 있습니다. 대부분의 상황에서 맞는 말입니다. 근데 제어할 수 없는 서드파티 스크립트가 DOM을 변경하는 환경에서는 이벤트 기반이 오히려 불안정합니다. 어떤 이벤트가 언제, 어떤 순서로 발생하는지 예측할 수 없기 때문입니다.
500ms 간격으로 필드값 스냅샷을 비교하는 방식입니다.
function startPolling() {
var lastSnapshot = null;
function tick() {
var current = getFieldSnapshot(); // PCCC + 이름 + 전화번호 조합
if (current !== lastSnapshot) {
lastSnapshot = current;
syncButton(); // 값 변경 감지 시 결제 버튼 상태 동기화
}
setTimeout(tick, 500);
}
tick();
}이 폴링이 자동완성(pre-filled) 대응도 겸합니다.
로그인 회원이 저장된 정보로 필드가 자동 채워지는 경우, blur나 change 이벤트가 발생하지 않아 검증이 시작되지 않습니다.
폴링이 이 상황을 감지해 자동으로 검증 버튼 상태를 동기화하는 역할도 합니다.
결제 버튼은 KCP 결제 모듈이 연결된 버튼입니다.
폼이 4개(frm_order_act, payForm, payReqForm, paySSLForm)나 존재하는 복잡한 구조라, form submit을 인터셉트하는 방식은 사용하지 않았습니다.
KCP 내부 로직과 충돌할 위험이 있기 때문입니다.
대신 버튼 자체를 disabled 처리하고, 검증 버튼은 type="button"으로 선언해 form submit 이벤트 전파를 원천 차단했습니다.
또 하나의 함정이 있었습니다.
카페24 공식 PCCC 앱은 pcca-* 클래스 래퍼 안에 PCCC 필드를 주입하는데, 이 래퍼가 자식 DOM 전체에 style="display: none"을 지속적으로 주입하는 사이드이펙트가 있습니다.
CSS 클래스 기반 show/hide 로직이 먹히지 않는 원인이 됩니다.
/* !important 없이는 앱이 덮어씌워버린다 */
.pccc-validator-container { display: none !important; }
.pccc-validator-container.pccc-validator-error { display: block !important; }요소 삽입 직후 element.removeAttribute('style') 호출도 함께 필요합니다.
과제 3: 유니패스 원본 에러 메시지는 사용자가 읽을 수 없다
실제로 받아본 유니패스 응답 메시지 중 하나입니다.
"입력하신 납세의무자명(차현)이 개인통관고유부호의 성명과 일치하지 않습니다. ... UTF-8로 변환하여 실행하십시오."
UTF-8로 변환하여 실행하십시오라는 구절은 개발자에게나 의미가 있습니다.
일반 쇼핑몰 고객이 이 메시지를 보면 뭘 어떻게 해야 할지 알 수 없습니다.
유니패스 v3.9 공식 가이드에 명시된 불일치 메시지는 8종입니다.
서버 레이어에서 사용자 친화 메시지로 변환하는 매핑 테이블을 만들었습니다.
| 유니패스 원본 메시지 | 사용자 표시 메시지 |
|---|---|
| 납세의무자 개인통관고유부호가 존재하지 않습니다 | 존재하지 않는 통관부호입니다. 통관부호를 다시 확인해 주세요. |
| 현재 사용중이 아닌 개인통관고유부호 입니다 | 현재 사용 중이 아닌 통관부호입니다. 관세청에서 사용 상태를 확인해 주세요. |
| 납세의무자명 ... 성명과 일치하지 않 | 입력하신 이름이 통관부호에 등록된 이름과 일치하지 않습니다. |
| 납세의무자 전화번호가 일치하지 않습니다 | 입력하신 전화번호가 통관부호에 등록된 번호와 일치하지 않습니다. |
| 입력하신 우편번호 ... 일치하지 않 | 입력하신 우편번호가 통관부호에 등록된 배송지 우편번호와 일치하지 않습니다. |
| 유효기간이 만료 된 개인통관고유부호 | 유효기간이 만료된 통관부호입니다. 관세청에서 재발급 후 사용해 주세요. |
에러 메시지가 UX입니다. 메시지를 보고 사용자가 다음 행동을 취할 수 있어야 합니다.
백엔드: FastAPI
왜 FastAPI인가
처음 스택을 고를 때 익숙한 NestJS도 후보였습니다.
결국 FastAPI를 택한 건 XML 처리 때문이었습니다.
유니패스 API는 JSON을 지원하지 않고 XML만 반환합니다.
Python xmltodict는 XML을 딕셔너리로 변환하는 게 두 줄이면 끝납니다.
JS 생태계에서 같은 작업을 하면 XML 파서 선택부터 더 많은 코드가 필요합니다.
유니패스 API 호출
실제로 사용하는 클라이언트 코드입니다.
@retry(
retry=retry_if_exception_type((httpx.TimeoutException, httpx.ConnectError)),
stop=stop_after_attempt(2),
wait=wait_fixed(0.5),
reraise=True,
)
async def call_unipass(
api_key: str,
base_url: str,
request: PCCCVerifyRequest,
) -> PCCCVerifyResponse:
params: dict[str, str] = {
"crkyCn": api_key,
"persEcm": request.pccc,
"pltxNm": request.name,
"cralTelno": request.phone,
}
if request.zip_code:
params["custPsno"] = request.zip_code
async with httpx.AsyncClient(timeout=5.0) as client:
response = await client.get(base_url, params=params)
response.raise_for_status()
return parse_unipass_response(response.text)tenacity 재시도 로직은 네트워크 오류와 타임아웃만 2회 재시도하고 500ms 간격을 둡니다.
비즈니스 응답(INVALID/ERROR)은 재시도 대상이 아닙니다.
통관부호가 실제로 존재하지 않는 상황에서 재시도를 돌리는 건 무의미합니다.
XML 파싱의 함정
유니패스 API는 성공이든 실패든 항상 HTTP 200을 반환합니다.
XML 본문의 tCnt 값으로 분기합니다. 1이면 검증 성공(VALID), 0이면 정보 불일치(INVALID), -1이면 시스템장애(ERROR)입니다.
그리고 까다로운 부분이 하나 있습니다.
불일치 에러 정보를 담는 persEcmQryRtnErrInfoVo가 0개에서 n개까지 올 수 있는 복수 구조입니다.
xmltodict는 단일 요소를 dict으로, 복수 요소를 list로 반환합니다.
두 경우를 모두 처리하지 않으면 단일 에러일 때 .get('errMsgCn')이 터집니다.
def _extract_messages(vo: dict) -> list[str]:
raw = vo.get("persEcmQryRtnErrInfoVo")
if raw is None:
return []
# xmltodict: 단일 요소 → dict, 복수 요소 → list
items: list[dict] = raw if isinstance(raw, list) else [raw]
return [to_user_friendly(item["errMsgCn"]) for item in items if item.get("errMsgCn")]
def parse_unipass_response(xml_text: str) -> PCCCVerifyResponse:
try:
parsed = xmltodict.parse(xml_text)
except xml.parsers.expat.ExpatError:
logger.error("unipass_xml_parse_error response_length=%d", len(xml_text))
return PCCCVerifyResponse(status="ERROR", messages=["유니패스 응답 파싱 실패"])
vo: dict = parsed.get("persEcmQryRtnVo", {})
t_cnt = int(vo.get("tCnt", "-1"))
if t_cnt == 1:
return PCCCVerifyResponse(
status="VALID",
valid_from=_parse_date(vo.get("valtPridStrtDt", "")),
valid_until=_parse_date(vo.get("valtPridXpirDt", "")),
)
if t_cnt == 0:
messages = _extract_messages(vo)
if not messages:
fallback = vo.get("ntceInfo", "정보가 일치하지 않습니다.")
messages = [fallback]
return PCCCVerifyResponse(status="INVALID", messages=messages)
notice = vo.get("ntceInfo", "유니패스 시스템 오류가 발생했습니다.")
return PCCCVerifyResponse(status="ERROR", messages=[notice])개인정보 마스킹
로그에 PCCC, 이름, 전화번호 원문을 남기면 개인정보보호법 위반 위험이 있습니다.
개발 중 원문이 로그에 찍히는 상황이 3회 발생했습니다.
마스킹 유틸리티와 테스트 케이스로 제도화했습니다.
def mask_pccc(pccc: str) -> str:
"""P123456789012 → P****56789012"""
if len(pccc) < 5:
return "****"
return f"{pccc[0]}****{pccc[5:]}"
def mask_name(name: str) -> str:
"""홍길동 → 홍** / 1자 이름은 전체 마스킹"""
if not name:
return "***"
if len(name) == 1:
return "*"
return name[0] + "*" * max(len(name) - 1, 2)
def mask_phone(phone: str) -> str:
"""01012345678 → 010****5678"""
if len(phone) < 8:
return "****"
return f"{phone[:3]}****{phone[-4:]}"라우터에서는 이렇게 씁니다.
logger.info(
"pccc_verify pccc=%s name=%s phone=%s",
mask_pccc(body.pccc),
mask_name(body.name),
mask_phone(body.phone),
)Docker 이미지 최적화
멀티 스테이지 빌드로 이미지를 최대한 가볍게 유지했습니다.
FROM python:3.12-slim AS builder
COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv
WORKDIR /app
COPY pyproject.toml uv.lock ./
RUN uv sync --no-dev --no-install-project --frozen
FROM python:3.12-slim AS runtime
WORKDIR /app
COPY --from=builder /app/.venv .venv
COPY . .
ENV PATH="/app/.venv/bin:$PATH"
ENV PYTHONUNBUFFERED=1
EXPOSE 8000
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]패키지 매니저는 pip 대신 uv를 씁니다.
빌드 스테이지에서만 uv를 쓰고, 런타임 이미지에는 .venv만 복사합니다.
프론트엔드: 바닐라 JS
카페24 스킨 환경은 jQuery 버전이 스킨마다 다르고, 설치된 앱이 자체 jQuery를 로드해 버전 충돌이 발생하기도 합니다.
jQuery 의존을 끊고 바닐라 JS로만 작성했습니다.
Samsung Internet과 IE11까지 고려해 ES5 호환 문법을 사용했습니다.
전체 코드를 IIFE 패턴으로 감쌌습니다.
window.PCCCValidator 하나만 노출하고 내부 변수는 전역을 오염시키지 않습니다.
주문서에는 이렇게 붙입니다.
<link rel="stylesheet" href="/js/custom/pccc-validator.css" />
<script>
window.PCCC_VALIDATOR_CONFIG = {
apiEndpoint: 'https://unipass.chahyunwoo.dev/api/pccc/verify',
pcccSelector: '#clearance-info-code',
nameSelector: '[name="rname"]',
phoneSelector: '[name="rphone2_"]',
zipSelector: '[name="rzipcode1"]',
submitSelector: '#btn_payment',
};
</script>
<script src="/js/custom/pccc-validator.js"></script>selector 6개와 apiEndpoint만 실제 쇼핑몰에 맞게 교체하면 됩니다.
카페24 주문서의 DOM selector는 스킨마다 다르므로 개발자 도구로 직접 확인하는 작업은 필요하지만, 로직은 그대로입니다.
UX 의사결정: 검증 버튼 vs blur 자동 트리거
고민이 있었습니다. blur 이벤트로 자동 검증을 트리거할지, 별도 "통관부호 검증" 버튼을 제공할지입니다.
구매대행 특성상 첫 주문 고객이 많고, 통관부호를 처음 입력해보는 사용자가 많습니다.
blur 자동 트리거를 쓰면 다음 필드로 이동하는 순간 검증 요청이 날아가는데, 이때 네트워크 지연이 생기면 사용자가 이미 다른 필드를 채우고 있습니다.
확실히 명시적 버튼이 이 맥락에서는 더 나았습니다.
에러는 각 필드 옆에 인라인으로 표시합니다.
alert는 사용하지 않았습니다.
모바일에서 alert는 흐름을 끊고 어느 필드가 문제인지 파악이 어렵습니다.
var MESSAGE_FIELD_MAP = [
{ keywords: ['전화번호'], selector: '[name="rphone2_"]' },
{ keywords: ['우편번호'], selector: '[name="rzipcode1"]' },
{ keywords: ['성명', '이름'], selector: '[name="rname"]' },
];
function showInlineErrors(messages) {
var unmapped = [];
messages.forEach(function(msg) {
var rule = null;
for (var i = 0; i < MESSAGE_FIELD_MAP.length; i++) {
var r = MESSAGE_FIELD_MAP[i];
for (var j = 0; j < r.keywords.length; j++) {
if (msg.indexOf(r.keywords[j]) !== -1) { rule = r; break; }
}
if (rule) break;
}
if (rule) {
showFieldError(document.querySelector(rule.selector), msg);
} else {
unmapped.push(msg);
}
});
if (unmapped.length) showGlobalError(unmapped.join(' / '));
}접근성도 챙겼습니다.
검증 결과 영역에 aria-live="polite" 속성을 붙여 스크린리더가 결과 변경을 자동으로 읽어주도록 했습니다.
재사용성 설계
이 프로젝트를 처음 시작할 때부터 두 번째 클라이언트를 염두에 뒀습니다.
구매대행 쇼핑몰이라면 같은 요구사항이 반복될 거라는 판단이었습니다.
unipass-proxy는 다른 구매대행 쇼핑몰에 그대로 배포할 수 있습니다.
환경변수 ALLOWED_ORIGINS에 새 도메인을 추가하고 서버를 재시작하면 됩니다.
API 키는 제 명의 하나를 공유하므로 클라이언트마다 새로 발급할 필요가 없습니다.
@property
def allowed_origins(self) -> list[str]:
"""ALLOWED_ORIGINS 환경변수를 쉼표 구분 리스트로 반환."""
raw = _get("ALLOWED_ORIGINS")
return [o.strip() for o in raw.split(",") if o.strip()]첫 번째 클라이언트 작업에 1218시간이 걸렸습니다.7시간으로 줄어듭니다.
아키텍처 설계, 프록시 서버 개발, 클라이언트 스니펫 개발, 문서화 전부 포함입니다.
두 번째 클라이언트부터는 서버 이관 + DOM selector 실측 + 주입 + QA를 합쳐 4
영카트(YCarte), Godo 등 다른 쇼핑몰 플랫폼으로 확장도 가능합니다. 프록시 서버는 플랫폼과 무관하게 동작하고, 클라이언트 스니펫은 selector만 바꾸면 됩니다.
헤맸던 부분들
이벤트 위임이 생각보다 훨씬 위험했습니다.
body 레벨 capture phase 리스너가 카페24 내부 동의 모달과 충돌했습니다.
폴링이 오히려 예측 가능하고 안정적이었습니다.
공식 앱의 display: none 강제 주입은 전혀 예상 못 했습니다.
CSS !important와 removeAttribute('style') 조합으로 해결했지만, 파악하는 데 시간이 걸렸습니다.
유니패스가 복수 에러를 한 번에 반환합니다.
전화번호와 우편번호가 동시에 틀린 경우, messages[]에 두 개가 담겨옵니다.
단일 메시지만 처리하는 코드를 썼다가 한 번 고쳤습니다.
xmltodict의 dict/list 이중성은 반드시 처리해야 합니다.
단일 에러 요소일 때 .get() 대신 ["key"] 형태로 접근하면 런타임에 터집니다.
테스트 케이스에서 단일/복수 케이스를 모두 커버해야 잡을 수 있습니다.
정리
좋은 점
- Cloudflare Tunnel 덕에 홈서버에서 바로 운영 가능. 공인 IP, 포트 포워딩 불필요
- FastAPI + xmltodict 조합으로 XML 처리가 예상보다 훨씬 간결
- 재사용 구조 덕에 두 번째 클라이언트부터 구축 시간이 1/3 수준으로 줄어듦
- 테스트 26개, 전부 통과 (pytest + respx mock)
고려할 점
- 카페24 스킨 환경이 스킨마다, 앱 설치 환경마다 다름. DOM selector 실측 작업은 항상 필요
- 유니패스 API는 SLA가 공식 미공개. 타임아웃 5초는 실전 테스트 기반 경험치
- 카페24 공식 앱 업데이트 시 selector가 바뀔 가능성 있음. 배포 후 모니터링 필요
구매대행 쇼핑몰을 운영 중이라면, 통관부호 실시간 검증은 CS를 사전에 차단하는 가장 효과적인 방법 중 하나입니다.
참고 자료
연관 게시글