전체 예제
인증, 작업 제출, 상태 확인, 결과 조회까지 한 파일로 도는 예제입니다. 그대로 실행해 볼 수 있습니다.
이 예제는 Python(requests) 또는 Node.js 18 이상과 허용된 API 키가 필요합니다. 데모(real_test)와 정식(production)은 실제 기관을 호출하므로 실행 전 조회 기간과 권한을 확인하세요. 요청 기록을 실행 폴더의 xdata-request.json에 저장하며, 같은 파일로 재실행하면 기존 Job을 이어서 조회하거나 같은 키와 본문으로 접수를 복구합니다. 동시에 두 프로세스를 실행하지 마세요. 기록이 꼬였다고 파일을 지우고 다시 실행하면 안 됩니다 — 원래 요청부터 확인하세요.
이 예제가 부르는 hometax.etax.invoice.search_list 는 등록된 자격증명이 필요합니다 — sandbox 를 포함한 모든 환경에서 그렇습니다(기관마다 다릅니다). 이미 등록했다면 자격증명 등록에서 account_link_id 를 확인해 아래 준비 단계에서 XDATA_ACCOUNT_LINK_ID 로 설정하세요. 아직 등록하지 않았다면 콘솔에서 먼저 등록하세요 — 등록한 자격증명이 그 기관에 하나뿐이면 생략해도 자동으로 선택됩니다.
Windows PowerShell 실행 준비
아래 코드를 편집기에 저장하고 그 폴더에서 PowerShell을 여세요(파일 탐색기 주소 표시줄에 powershell 입력). Node.js와 Python 예제는 같은 폴더에서 하나만 실행합니다. Secret은 숨김 입력으로 받아 현재 프로세스에만 설정하고 실행 후 제거합니다.
$env:XDATA_ENV = Read-Host '환경: sandbox / real_test / production'
$env:XDATA_CLIENT_ID = Read-Host 'Client ID'
$env:XDATA_ACCOUNT_LINK_ID = Read-Host 'account_link_id (선택 - 등록한 자격증명이 하나뿐이면 비워 두세요)'
$secretInput = Read-Host 'Client Secret' -AsSecureString
try {
$env:XDATA_CLIENT_SECRET = [System.Net.NetworkCredential]::new('', $secretInput).Password
python ./xdata_quickstart.py
# Node.js 를 선택했다면 위 python 명령 대신 node ./xdata-quickstart.mjs
} finally {
Remove-Item Env:XDATA_CLIENT_SECRET
$secretInput.Dispose()
}제출부터 결과까지
Node.js: 추가 패키지 설치 없음 (18 이상)
Python: python -m pip install requests// 아래 PowerShell 준비 명령 후: node xdata-quickstart.mjs
import { readFileSync, writeFileSync, existsSync } from 'node:fs';
import { randomUUID } from 'node:crypto';
const BASE = 'https://api.xdata.kr';
const ENV_SCOPE = process.env.XDATA_ENV;
if (!['sandbox', 'real_test', 'production'].includes(ENV_SCOPE)) throw new Error('XDATA_ENV 확인');
if (!process.env.XDATA_CLIENT_ID || !process.env.XDATA_CLIENT_SECRET) {
throw new Error('XDATA_CLIENT_ID와 XDATA_CLIENT_SECRET 을 설정하세요.');
}
const requestFile = './xdata-request.json';
const body = {
provider: 'hometax', action: 'hometax.etax.invoice.search_list',
// 등록된 자격증명이 하나뿐이면 생략해도 자동으로 선택됩니다. hometax 는 sandbox 에서도
// 자동 선택 대상 자격증명이 없으면 실패합니다 - 준비 단계에서 비워 뒀다면 먼저
// [자격증명 등록](/common-guide/account-link) 부터 하세요.
...(process.env.XDATA_ACCOUNT_LINK_ID ? { account_link_id: process.env.XDATA_ACCOUNT_LINK_ID } : {}),
params: { from_date: '2026-01-01', to_date: '2026-01-31' },
};
const identity = JSON.stringify({ base: BASE, environment: ENV_SCOPE, client: process.env.XDATA_CLIENT_ID, body });
// 같은 파일로 재실행하면 새 작업이 아니라 기존 접수를 이어서 확인합니다.
if (!existsSync(requestFile)) writeFileSync(requestFile, JSON.stringify({ identity, key: randomUUID() }), { flag: 'wx', mode: 0o600 });
const request = JSON.parse(readFileSync(requestFile, 'utf8'));
if (request.identity !== identity || typeof request.key !== 'string' || !request.key) throw new Error('기존 요청 기록과 다릅니다. 파일을 지우지 말고 원래 환경, 키, 본문을 확인하세요.');
const token = Buffer.from(
`${process.env.XDATA_CLIENT_ID}:${process.env.XDATA_CLIENT_SECRET}`,
).toString('base64');
const headers = {
Authorization: `Basic ${token}`,
'X-Env-Scope': ENV_SCOPE,
'Content-Type': 'application/json',
};
async function readError(response) {
// 오류 본문은 최상위 error 입니다. 없는 주소의 404 처럼 error 가 없는 응답은 detail 문장을 message 로 씁니다.
const payload = await response.json().catch(() => null);
return payload?.error ?? { message: typeof payload?.detail === 'string' ? payload.detail : undefined };
}
async function submitJob(body) {
const response = await fetch(`${BASE}/v1/jobs`, {
method: 'POST',
// 재시도할 때는 이 키를 그대로 다시 씁니다. 새 키를 쓰면 작업이 한 번 더 실행됩니다.
headers: { ...headers, 'Idempotency-Key': request.key },
signal: AbortSignal.timeout(30000),
body: JSON.stringify(body),
});
if (!response.ok) {
const error = await readError(response);
throw new Error(`제출 실패 ${response.status} ${error.code ?? '알 수 없음'}: ${error.message ?? ''}`);
}
return response.json();
}
async function waitForResult(jobId) {
for (let attempt = 0; attempt < 20; attempt++) {
const response = await fetch(`${BASE}/v1/jobs/${jobId}`, { headers, signal: AbortSignal.timeout(30000) });
if (!response.ok) throw new Error(`상태 조회 HTTP ${response.status}. 같은 기록으로 재실행하세요.`);
const job = await response.json();
console.log(`${job.progress}% ${job.progress_message ?? job.status}`);
if (job.status === 'SUCCEEDED') {
const resultResponse = await fetch(`${BASE}/v1/jobs/${jobId}/result`, { headers, signal: AbortSignal.timeout(30000) });
if (!resultResponse.ok) throw new Error(`결과 조회 HTTP ${resultResponse.status}`);
return resultResponse.json();
}
if (job.status !== 'QUEUED' && job.status !== 'RUNNING') {
throw new Error(`${job.status}: ${job.error?.code ?? ''}`);
}
await new Promise((resolve) => setTimeout(resolve, (job.retry_after_seconds ?? 2) * 1000));
}
throw new Error('조회 횟수 한도에 도달했습니다. 같은 기록으로 재실행하여 상태를 확인하세요.');
}
if (!request.job_id) {
const submitted = await submitJob(body);
if (typeof submitted.job_id !== 'string' || !submitted.job_id) throw new Error('Job 응답 형식 오류');
request.job_id = submitted.job_id;
writeFileSync(requestFile, JSON.stringify(request), { mode: 0o600 });
}
const jobId = request.job_id;
const result = await waitForResult(jobId);
console.log('result_received:', typeof result === 'object' && result !== null);
// 결과 원문은 로그에 출력하지 않습니다. 실제 값은 응답을 그대로 저장해 사용하세요.# 아래 PowerShell 준비 명령 후: python xdata_quickstart.py
# pip install requests
import base64, json, os, time, uuid, requests
from pathlib import Path
BASE = 'https://api.xdata.kr'
ENV_SCOPE = os.environ['XDATA_ENV']
if ENV_SCOPE not in ('sandbox', 'real_test', 'production'):
raise ValueError('XDATA_ENV 확인')
body = {
'provider': 'hometax', 'action': 'hometax.etax.invoice.search_list',
'params': {'from_date': '2026-01-01', 'to_date': '2026-01-31'},
}
# 등록된 자격증명이 하나뿐이면 생략해도 자동으로 선택됩니다. hometax 는 sandbox 에서도
# 자동 선택 대상 자격증명이 없으면 실패합니다 - 준비 단계에서 비워 뒀다면 먼저
# [자격증명 등록](/common-guide/account-link) 부터 하세요.
if os.environ.get('XDATA_ACCOUNT_LINK_ID'):
body['account_link_id'] = os.environ['XDATA_ACCOUNT_LINK_ID']
identity = dict(base=BASE, environment=ENV_SCOPE, client=os.environ['XDATA_CLIENT_ID'], body=body)
request_file = Path('./xdata-request.json')
# 같은 파일로 재실행하면 새 작업이 아니라 기존 접수를 이어서 확인합니다.
if not request_file.exists():
with request_file.open('x', encoding='utf-8') as stream:
json.dump(dict(identity=identity, key=str(uuid.uuid4())), stream)
stream.flush()
os.fsync(stream.fileno())
record = json.loads(request_file.read_text(encoding='utf-8'))
if record.get('identity') != identity or not isinstance(record.get('key'), str) or not record['key']:
raise ValueError('기존 요청 기록과 다릅니다. 파일을 지우지 말고 원래 환경, 키, 본문을 확인하세요.')
token = base64.b64encode(
f"{os.environ['XDATA_CLIENT_ID']}:{os.environ['XDATA_CLIENT_SECRET']}".encode()
).decode()
HEADERS = {
'Authorization': f'Basic {token}',
'X-Env-Scope': ENV_SCOPE,
'Content-Type': 'application/json',
}
def read_error(response) -> dict:
# 오류 본문은 최상위 error 입니다. 없는 주소의 404 처럼 error 가 없는 응답은 detail 문장을 message 로 씁니다.
try:
payload = response.json()
except ValueError:
return {}
if not isinstance(payload, dict):
return {}
detail = payload.get('detail')
return payload.get('error') or ({'message': detail} if isinstance(detail, str) else {})
def submit_job(body: dict) -> dict:
response = requests.post(
f'{BASE}/v1/jobs',
# 재시도할 때는 이 키를 그대로 다시 씁니다. 새 키를 쓰면 작업이 한 번 더 실행됩니다.
headers={**HEADERS, 'Idempotency-Key': record['key']},
timeout=30,
json=body,
)
if not response.ok:
error = read_error(response)
raise RuntimeError(f"제출 실패 {response.status_code} {error.get('code', '알 수 없음')}: {error.get('message', '')}")
return response.json()
def wait_for_result(job_id: str) -> dict:
for attempt in range(20):
response = requests.get(f'{BASE}/v1/jobs/{job_id}', headers=HEADERS, timeout=30)
response.raise_for_status()
job = response.json()
print(f"{job['progress']}% {job.get('progress_message') or job['status']}")
if job['status'] == 'SUCCEEDED':
response = requests.get(f'{BASE}/v1/jobs/{job_id}/result', headers=HEADERS, timeout=30)
response.raise_for_status()
return response.json()
if job['status'] not in ('QUEUED', 'RUNNING'):
raise RuntimeError(f"{job['status']}: {(job.get('error') or {}).get('code')}")
time.sleep(job.get('retry_after_seconds') or 2)
raise TimeoutError('조회 횟수 한도에 도달했습니다. 같은 기록으로 재실행하세요.')
if not record.get('job_id'):
submitted = submit_job(body)
if not isinstance(submitted.get('job_id'), str) or not submitted['job_id']:
raise ValueError('Job 응답 형식 오류')
record['job_id'] = submitted['job_id']
with request_file.open('w', encoding='utf-8') as stream:
json.dump(record, stream)
stream.flush()
os.fsync(stream.fileno())
result = wait_for_result(record['job_id'])
print('result_received:', isinstance(result, dict))
# 결과 원문은 로그에 출력하지 않습니다. 실제 값은 응답을 그대로 저장해 사용하세요.예제가 쓰는 규칙
| 규칙 | 어디서 정하나 |
|---|---|
| Authorization 은 Basic 한 가지 | 인증 |
| account_link_id 는 선택 — 없으면 자동 선택(등록한 자격증명이 하나일 때만) | 자격증명 등록 |
| X-Env-Scope 로 환경 선택 | 환경 |
| 재시도는 같은 Idempotency-Key | 재시도 |
| retry_after_seconds 만큼 기다렸다 다시 확인 | 작업 처리 흐름 |
기관·작업마다 필요한 params 는 다릅니다. 예제의 provider 와 action 을 바꾸고, 그 작업의 파라미터는 상품별 API 레퍼런스에서 확인합니다.
이 실행 예제는 오류가 나면 중단합니다. 상태나 결과 조회 실패나 연결 오류는 같은 요청 기록으로 다시 실행해 복구하세요 — 자동으로 새 요청을 만들지 않습니다. 실제 서비스에서는 HTTP 상태와 XBOSS 오류 응답의 error.code 를 함께 보고 재시도할지 판단하세요 — 오류 코드.