외부 시스템 연동에서 장애가 생기는 자리 — 인터페이스 설계 원칙

운영 중인 서비스에서 장애 보고를 받고 로그를 열어보면, 원인이 우리가 작성한 비즈니스 로직인 경우는 생각보다 많지 않습니다. 대부분은 다른 시스템과 맞닿은 지점입니다.

결제 대행사 응답이 평소보다 느려졌거나, 채널사 API가 예고 없이 필드를 추가했거나, 상대 시스템이 점검 중인데 그 사실을 우리가 모르고 있었거나 하는 경우입니다. 우리 코드는 그대로인데 장애는 발생합니다.

SI 프로젝트는 이런 경계가 유난히 많습니다. 신규 시스템 하나를 만들어도 기존 사내 시스템, 외부 솔루션, 공공 API와 붙여야 하는 경우가 대부분입니다. 저희가 진행한 숙박 운영 시스템만 해도 예약 채널, 결제, 객실 제어, 회계 시스템이 각각 다른 방식으로 연결되어 있었습니다.

이 글은 그런 경계를 설계할 때 반복적으로 확인하게 되는 원칙들을 정리한 것입니다. 특정 기술 스택에 묶이지 않는 내용 위주로 다룹니다.


1. 코드를 쓰기 전에 규약을 고정합니다

연동 방식부터 결정합니다

"API로 붙인다"는 말은 아직 아무것도 정하지 않은 상태입니다. 실무에서 마주치는 연동 방식은 여러 가지이고, 각각 설계와 운영 부담이 다릅니다. 특히 오래된 사내 시스템이 상대라면 REST API를 선택할 수 없는 경우가 많습니다.

방식 주로 쓰이는 상황 유의할 점
REST / HTTP API 신규 시스템, 외부 서비스 가장 무난하나 실시간 장애가 전파됨
SOAP 금융·공공 레거시 스키마가 엄격하고 라이브러리 선택지가 적음
파일 전송(FTP 등) 정산·대량 마스터 데이터 전송 완료 판정과 중복 수신 처리가 필요
데이터베이스 직접 접근 사내 레거시 상대 스키마 변경에 그대로 노출됨. 가급적 피함
메시지 큐 이벤트 전달, 비동기 처리 순서·중복·유실 정책을 명시해야 함

이 중 파일 전송은 오래된 방식이지만 정산 업무에서는 여전히 널리 쓰입니다. 여기서 반복해서 문제가 되는 지점은 "파일이 완전히 올라왔는지 어떻게 아는가"입니다. 쓰는 도중인 파일을 읽어버리면 데이터가 잘립니다. 완료 표식 파일을 함께 올리거나, 임시 이름으로 올린 뒤 이름을 바꾸는 방식으로 해결합니다.

데이터베이스 직접 접근은 가장 빠르게 붙일 수 있지만 권하지 않습니다. 상대 시스템이 컬럼 하나를 바꾸면 우리 서비스가 멈추고, 그 변경을 사전에 통보받을 경로도 없습니다. 불가피하게 선택해야 한다면 최소한 읽기 전용 계정과 전용 뷰를 요청하는 편이 낫습니다.

문서 없이 시작하지 않습니다

연동 작업에서 가장 흔한 실패 패턴은 문서 없이 시작하는 것입니다. 상대 시스템 담당자와 메신저로 필드를 주고받으며 개발하면 초반에는 빨라 보이지만, 운영에 들어가면 "그때 어떻게 하기로 했더라"가 반복됩니다.

인터페이스 정의서에 들어가야 하는 것

형식은 문서든 스키마 파일이든 상관없습니다. 다만 다음 항목은 빠지면 나중에 문제가 됩니다.

구분 확정해야 할 내용
엔드포인트 운영·검증 환경 주소, 프로토콜, 포트
인증 인증 방식, 키 발급·갱신 절차, 만료 주기
요청/응답 필드명, 자료형, 길이, 필수 여부, 기본값
코드값 상태·유형 코드의 전체 목록과 의미
오류 오류 코드 체계, 재시도 가능 여부 구분, 오류가 HTTP 상태 코드로 오는지 본문 코드로 오는지
제한 호출 한도(rate limit), 동시 호출 수, 최대 페이로드 크기
응답 시간 평소 응답 시간과 최대 응답 시간, 상대가 권장하는 타임아웃
재시도·멱등 중복 요청 방지 수단(멱등 키 필드와 형식, 보관 기간), 같은 키로 다시 보냈을 때의 응답
결과 조회 응답을 받지 못한 건의 처리 결과를 확인하는 조회 수단과 조회 키
콜백·웹훅 서명 방식과 비밀키 교체 절차, 재전송 횟수와 간격, 순서 보장 여부, 수신 성공으로 보는 응답
대사 대사용 자료의 제공 방식과 시각, 집계 기준 시각(마감 시각, 시간대), 취소 건 표기
검증 환경 테스트 계정과 테스트 데이터, 운영과 다르게 동작하는 부분
운영 정기 점검 시간, 장애 공지 경로, 담당자 연락처

마지막 운영 항목이 자주 누락됩니다. 상대 시스템이 매주 화요일 새벽에 점검한다는 사실을 운영 한 달 뒤에 알게 되는 상황은 피할 수 있습니다.

결과 조회 항목은 개발 후반에야 필요성이 드러나는 경우가 많습니다. 요청을 보냈는데 응답을 받지 못했을 때 그 건이 처리됐는지 확인할 방법이 없으면, 뒤에서 다룰 타임아웃과 재시도 설계가 성립하지 않습니다. 조회 수단이 없는 상대라면 착수 단계에서 요청해두는 편이 낫습니다.

오류 항목에서는 오류가 어디에 실려 오는지를 확인합니다. 오래된 시스템 중에는 실패해도 HTTP 200으로 응답하고 본문의 결과 코드로 성공 여부를 알리는 곳이 있습니다. 이 경우 상태 코드만 보고 성공으로 처리하면 실패한 건이 정상 건으로 저장됩니다.

인증 항목에서는 키를 사람 계정에 묶지 않습니다. 담당자 계정으로 발급한 키를 연동에 쓰면 그 담당자가 퇴사해 계정을 정리하는 날 연동이 함께 끊깁니다. 저희가 구축한 B-Saver에서는 데몬과 백엔드 서비스가 API를 호출할 때 사람 계정이 아니라 별도로 발급한 API 토큰을 쓰도록 분리했습니다.

값의 형식을 말로 적지 말고 예시로 적습니다

정의서에서 모호함이 발생하는 지점은 대체로 정해져 있습니다.

  • 날짜·시각 — 2026-09-21T14:30:00+09:00인지 20260921143000인지, 시간대는 KST인지 UTC인지. 상대가 UTC로 주는데 우리가 KST로 해석하면 9시간이 어긋납니다
  • 금액 — 원 단위 정수인지, 소수점이 있는지, 통화 코드가 함께 오는지. 해외 결제가 섞이면 반드시 통화 코드가 필요합니다
  • 문자셋과 길이 — UTF-8 기준 바이트 길이인지 문자 길이인지. 한글이 들어가면 결과가 달라집니다
  • 널과 빈 문자열 — 값이 없을 때 필드를 생략하는지, null을 보내는지, ""를 보내는지. 세 가지가 섞여 오는 경우가 실제로 있습니다
  • 불리언 — true인지 "Y"인지 1인지
  • 식별자 — 길이 제한과 문자 범위. 상대가 20자까지만 받는데 우리가 36자 UUID를 보내면 잘립니다

각 항목마다 실제 값 예시를 적어두면 해석 차이가 거의 사라집니다.

버전 정책을 먼저 합의합니다

연동 규약은 반드시 바뀝니다. 바뀔 때 어떻게 할지를 미리 정해두는 것이 핵심입니다.

  • 필드 추가는 호환을 깨지 않도록 합니다. 받는 쪽은 모르는 필드를 무시하도록 구현합니다
  • 필드 삭제·의미 변경은 새 버전으로 분리합니다
  • 변경 사전 통보 기간을 정합니다
  • 구 버전 지원 종료 시점을 명시합니다

받는 쪽 구현에서 "정의서에 없는 필드가 오면 오류를 낸다"는 방식은 피하는 편이 좋습니다. 상대가 필드 하나를 추가한 것만으로 우리 서비스가 멈추게 됩니다.


2. 실패를 기본값으로 두고 설계합니다

외부 호출은 실패합니다. 이것을 예외 상황이 아니라 정상적인 동작 경로 중 하나로 보고 설계해야 합니다.

타임아웃은 세 군데에 겁니다

타임아웃을 걸지 않은 외부 호출은 장애를 전파시킵니다. 상대 시스템이 느려지면 우리 서버의 스레드나 커넥션이 그대로 묶이고, 결국 무관한 기능까지 함께 멈춥니다.

  • 연결 타임아웃 — TCP 연결 수립까지 기다리는 시간. 짧게 잡아도 무방합니다
  • 읽기 타임아웃 — 응답 데이터를 기다리는 시간. 상대 시스템의 평소 응답 시간을 측정해서 정합니다
  • 전체 타임아웃 — 재시도를 포함한 총 소요 시간의 상한

여기서 중요한 것은 상위 호출자의 여유 시간보다 작게 잡는 것입니다. 사용자 요청이 3초 안에 끝나야 하는데 내부에서 5초짜리 외부 호출을 걸면, 타임아웃이 의미가 없어집니다.

연결과 읽기를 나누는 이유는 두 실패의 의미가 다르기 때문입니다. 연결 타임아웃은 요청이 상대에 도달하지 못한 것이므로 다시 보내도 안전합니다. 읽기 타임아웃은 요청이 이미 전달된 뒤에 응답만 받지 못한 것이어서, 상대가 그 요청을 처리했는지 알 수 없습니다.

읽기 타임아웃 값은 감으로 정하지 않고 상대의 응답 시간 분포에서 고릅니다. AWS는 정상 요청이 타임아웃으로 잘못 끊기는 비율을 먼저 정하고(예: 0.1%), 그에 해당하는 응답 시간 백분위(p99.9)를 타임아웃으로 삼는 방식을 소개합니다.(AWS Builders' Library, Timeouts, retries, and backoff with jitter) 연동 초기에는 측정값이 없으므로 검증 환경에서 응답 시간을 기록해 초기값을 잡고, 운영 데이터가 쌓이면 다시 조정합니다.

라이브러리 기본값을 그대로 두지 않습니다. HTTP 클라이언트의 기본 타임아웃은 연동 용도로는 지나치게 긴 경우가 많습니다. Node.js 내장 fetch가 사용하는 undici를 예로 들면 현재 문서 기준으로 연결 타임아웃 기본값은 10초, 응답 헤더와 본문을 기다리는 시간의 기본값은 각각 300초입니다.(undici Client 문서) 설정 없이 쓰면 상대가 응답하지 않을 때 요청 하나가 5분 동안 연결을 붙잡습니다.

아래는 세 가지 타임아웃을 나눠 설정하는 예입니다. Node.js 20 이상에 undici 패키지를 설치한 환경을 가정했고, 수치는 설명용 예시입니다.

import { Agent } from 'undici';

export const partnerAgent = new Agent({
  connect: { timeout: 2_000 }, // 연결 수립까지
  headersTimeout: 5_000,       // 응답 헤더가 도착할 때까지
  bodyTimeout: 5_000,          // 본문 조각 사이의 간격
  connections: 20,             // 상대 한 곳에 여는 동시 연결 상한
});

export const TOTAL_DEADLINE_MS = 12_000; // 재시도를 포함한 전체 상한

타임아웃 종류에 따라 오류 코드가 다르게 나옵니다. undici는 연결 실패를 UND_ERR_CONNECT_TIMEOUT, 헤더 대기 초과를 UND_ERR_HEADERS_TIMEOUT, 본문 대기 초과를 UND_ERR_BODY_TIMEOUT으로 구분합니다.(undici Errors 문서) 이 구분을 연동 로그에 그대로 남겨두면 "상대에 닿지 못한 것인지, 닿았는데 답이 없었던 것인지"를 나중에 가릴 수 있습니다.

타임아웃은 실패가 아니라 결과 미확인입니다

타임아웃이 난 건을 곧바로 실패로 기록하는 것이 연동에서 가장 흔한 오류입니다. 응답을 받지 못했을 뿐 상대는 처리를 끝냈을 수 있습니다. 결제라면 승인은 올라갔는데 우리 쪽에는 실패로 남고, 사용자가 다시 시도하면 이중 결제가 됩니다.

그래서 상태를 성공과 실패 둘로만 두지 않고 확인 중 상태를 둡니다.

상황 우리 쪽 기록 다음 조치
연결 자체가 실패 미전송 그대로 재시도
요청 전송 후 응답 없음 확인 중 결과 조회, 또는 같은 멱등 키로 재전송
실패 응답 수신 실패 오류 유형에 따라 재시도 여부 판단
성공 응답 수신 성공 상대 거래번호 저장

확인 중인 건은 상대의 결과 조회 수단으로 확정합니다. 조회도 실패하면 주기적으로 다시 확인하는 작업에 넘기고, 일정 시간이 지나도 확정되지 않으면 운영자에게 알립니다. 결제 단말처럼 응답이 늦거나 중간에 끊기는 일이 잦은 상대라면 이 절차가 설계의 중심이 됩니다. B-Saver의 결제 단말(VAN) 연동에서도 폴링 주기와 타임아웃, 재시도 조건을 별도 문서로 정리해 관리하고 있습니다. 키오스크 결제에서 같은 문제가 어떻게 나타나는지는 키오스크 프로젝트에서 개발보다 오래 걸리는 것들 — 무인화 시스템 구축 체크리스트에서 다뤘습니다.

재시도는 조건을 나눠서 합니다

모든 실패를 재시도하면 안 됩니다. 상대 시스템이 이미 힘든 상황에서 재시도가 몰리면 장애가 깊어집니다.

실패 유형 재시도 이유
연결 실패 한다 요청이 전달되지 않았으므로 안전함
응답 타임아웃 멱등할 때만 한다 상대가 이미 처리했을 수 있음
502, 503, 504 한다 상대 시스템이나 중간 게이트웨이의 일시 문제
500 멱등할 때만 한다 처리 도중에 실패했을 수 있음
429 호출 한도 초과 한다 (대기 후) 응답의 Retry-After 안내를 따름
408 한다 서버가 요청을 다 받지 못한 경우임
그 밖의 4xx 요청 오류 하지 않는다 같은 요청은 계속 실패함
인증 실패(401, 403) 하지 않는다 키 갱신이 먼저
업무 규칙 위반 하지 않는다 재시도로 해결되지 않음

상태 코드의 의미는 HTTP 명세를 기준으로 삼습니다. 503은 일시적인 과부하나 점검으로 요청을 처리할 수 없다는 뜻이고, 서버가 Retry-After로 대기 시간을 알려줄 수 있습니다.(RFC 9110, 15.6.4) 408은 서버가 기다린 시간 안에 요청 메시지를 다 받지 못했다는 뜻이며 클라이언트가 요청을 반복할 수 있습니다.(RFC 9110, 15.5.9) 429는 RFC 9110이 아니라 별도 문서에 정의되어 있고, 마찬가지로 Retry-After를 포함할 수 있습니다.(RFC 6585, Section 4)

다만 상대 시스템이 명세대로 상태 코드를 쓴다는 보장은 없습니다. 입력값 오류에 500을 돌려주는 시스템도 있으므로, 위 표는 기본값으로 두고 상대의 오류 코드 목록을 받아 재시도 대상을 다시 확정합니다.

Retry-After는 두 가지 형식으로 옵니다

Retry-After 값은 초 단위 정수(Retry-After: 120)이거나 HTTP 날짜(Retry-After: Fri, 31 Dec 1999 23:59:59 GMT)입니다.(RFC 9110, 10.2.3) 초 단위만 처리하도록 구현하면 날짜 형식이 왔을 때 대기 시간을 계산하지 못하고, 구현에 따라서는 대기 없이 곧바로 재시도하게 됩니다. 두 형식을 모두 해석하고, 안내받은 시간이 우리 전체 타임아웃보다 길면 기다리지 않고 실패로 넘깁니다.

백오프에 지터를 넣는 방법

재시도 간격은 지수 백오프에 무작위 지연(jitter)을 더하는 방식이 기본입니다. 고정 간격으로 재시도하면 실패한 요청들이 같은 시점에 다시 몰립니다. 간격을 두 배씩 늘리기만 해도 같은 순간에 실패한 요청들은 여전히 같은 순간에 다시 보내지므로, 대기 시간 자체를 흩어야 합니다.

AWS Architecture Blog의 글이 이 문제를 모의실험으로 비교했습니다. 대기 시간을 0부터 지수 상한 사이에서 무작위로 고르는 방식(full jitter)이 전체 호출 수가 적었고, 상한의 절반을 고정으로 두고 나머지만 흩는 방식(equal jitter)은 지터를 넣은 방식들 가운데 결과가 가장 나빴습니다.(AWS Architecture Blog, Exponential Backoff And Jitter)

아래는 앞의 타임아웃 설정에 재시도를 더한 예입니다. 같은 환경을 가정했고 수치는 예시입니다.

import { setTimeout as sleep } from 'node:timers/promises';
import { fetch, type Response } from 'undici';
import { partnerAgent, TOTAL_DEADLINE_MS } from './partner-agent';

const RETRYABLE_STATUS = new Set([408, 429, 500, 502, 503, 504]);
const BASE_DELAY_MS = 200;
const MAX_DELAY_MS = 5_000;
const MAX_ATTEMPTS = 4;

function backoffDelay(attempt: number): number {
  const ceiling = Math.min(MAX_DELAY_MS, BASE_DELAY_MS * 2 ** attempt);
  return Math.random() * ceiling; // full jitter
}

function retryAfterMs(header: string | null): number | null {
  if (header === null) return null;
  if (/^\d+$/.test(header)) return Number(header) * 1000;
  const retryAt = Date.parse(header);
  return Number.isNaN(retryAt) ? null : Math.max(0, retryAt - Date.now());
}

export async function postWithRetry(
  url: string,
  body: string,
  idempotencyKey: string,
): Promise<Response> {
  const deadline = AbortSignal.timeout(TOTAL_DEADLINE_MS);

  for (let attempt = 0; ; attempt += 1) {
    const isLastAttempt = attempt === MAX_ATTEMPTS - 1;
    let waitMs = backoffDelay(attempt);
    try {
      const response = await fetch(url, {
        method: 'POST',
        headers: { 'content-type': 'application/json', 'idempotency-key': idempotencyKey },
        body,
        dispatcher: partnerAgent,
        signal: deadline,
      });
      if (!RETRYABLE_STATUS.has(response.status) || isLastAttempt) return response;
      waitMs = retryAfterMs(response.headers.get('retry-after')) ?? waitMs;
      await response.body?.cancel(); // 읽지 않은 본문을 버려 연결을 반환
    } catch (error) {
      if (deadline.aborted || isLastAttempt) throw error;
    }
    await sleep(waitMs, undefined, { signal: deadline });
  }
}

이 예제에서 확인할 점은 세 가지입니다. 멱등 키는 함수 밖에서 만들어 넘기므로 재시도 내내 같은 값이 유지됩니다. 전체 타임아웃 신호 하나를 호출과 대기에 함께 걸어, 재시도가 남아 있어도 상한을 넘으면 중단됩니다. 500과 응답 타임아웃을 재시도하는 것은 상대가 멱등 키를 지원한다는 전제에서만 성립하므로, 지원하지 않는 상대라면 재시도 대상에서 빼야 합니다.

재시도는 한 계층에서만 합니다

재시도 횟수는 무한이 아니어야 합니다. 상한에 도달하면 실패로 확정하고 다음 절차로 넘겨야 합니다.

횟수만큼 중요한 것이 어디에서 재시도하는가입니다. 화면, API 서버, 연동 모듈이 각자 재시도하면 횟수가 곱해집니다. 앞에서 인용한 AWS Builders' Library 글은 다섯 계층이 각각 세 번씩 재시도하면 맨 아래 데이터베이스가 받는 부하가 243배가 된다는 예를 들고, 재시도를 한 지점에서만 하도록 권합니다. 연동에서는 외부 호출을 직접 수행하는 모듈 한 곳에서만 재시도하고, 그 위 계층은 결과를 그대로 전달하는 구성이 안전합니다. HTTP 클라이언트 라이브러리나 SDK가 기본으로 재시도하는지도 확인합니다. 모르는 사이에 이중으로 재시도하고 있는 경우가 있습니다.

멱등성이 없으면 재시도를 할 수 없습니다

재시도의 전제 조건입니다. 같은 요청을 여러 번 보내도 결과가 한 번 보낸 것과 같아야 합니다.

HTTP 명세에서는 GET, HEAD, PUT, DELETE, OPTIONS, TRACE를 멱등 메서드로 정의하고 있으며, POST는 멱등하지 않습니다.(RFC 9110, Section 9.2.2) 같은 절은 요청의 의미가 실제로 멱등하다는 것을 알 수 있거나 원래 요청이 적용되지 않았음을 확인할 수단이 있는 경우가 아니면, 클라이언트가 멱등하지 않은 메서드의 요청을 자동으로 재시도해서는 안 된다고 권고합니다. 문제는 결제 요청, 주문 생성처럼 실무에서 중요한 호출이 대부분 POST라는 점입니다.

메서드가 멱등으로 분류된다고 해서 상대의 구현이 멱등한 것도 아닙니다. PUT으로 받으면서 내부에서는 호출할 때마다 수량을 더하는 식으로 구현된 API도 있으므로, 재시도 대상에 넣기 전에 상대에게 확인합니다.

해결 방법은 요청 단위 고유 키를 쓰는 것입니다.

  1. 호출하는 쪽에서 요청마다 고유한 키를 생성합니다
  2. 요청 헤더나 본문에 그 키를 담아 보냅니다
  3. 받는 쪽은 키를 저장해두고, 같은 키로 다시 들어오면 새로 처리하지 않고 이전 결과를 반환합니다

키는 재시도할 때 동일하게 유지되어야 합니다. 재시도마다 새 키를 만들면 멱등성이 사라집니다. 흔한 실수는 HTTP 호출 함수 안에서 키를 생성하는 것입니다. 그러면 함수가 다시 호출될 때마다 새 키가 만들어집니다. 키는 업무 요청이 만들어지는 시점에 한 번 생성해 업무 데이터와 함께 저장하고, 전송할 때는 저장된 값을 읽어 씁니다. 프로세스가 재시작된 뒤의 재전송에서도 같은 키가 나가야 하기 때문입니다.

Idempotency-Key 헤더는 아직 표준이 아닙니다

키를 싣는 헤더 이름으로 Idempotency-Key가 널리 쓰이지만, 이 헤더는 2026년 10월 현재 RFC로 확정되지 않았습니다. IETF HTTPAPI 워킹그룹의 인터넷 초안으로 진행되었고, 마지막 판(07, 2025년 10월)은 2026년 4월에 만료된 상태입니다.(IETF Datatracker, draft-ietf-httpapi-idempotency-key-header) 따라서 "표준이니 상대도 지원할 것"이라고 가정할 수 없고, 헤더 이름과 동작을 상대의 문서에서 확인해야 합니다. 초안의 구현 현황 절에도 같은 이름의 헤더를 쓰는 서비스와 다른 이름을 쓰는 서비스가 함께 나열되어 있습니다.

초안은 표준이 아니더라도 양쪽이 합의할 항목을 정리하는 기준으로는 쓸 만합니다. 초안이 정한 내용은 다음과 같습니다.(draft-ietf-httpapi-idempotency-key-header-07)

항목 초안의 내용
키의 값 UUID 또는 그에 준하는 무작위 식별자를 권장. 다른 본문의 요청에 재사용하면 안 됨
보관 기간 서버가 만료 정책을 정하고 문서로 공개
요청 지문 키와 함께 본문의 체크섬 등으로 같은 요청인지 판별할 수 있음
완료된 요청의 재시도 이전에 완료된 처리의 결과를 그대로 응답(성공이든 오류든)
처리 중인 요청의 재시도 409 Conflict
같은 키, 다른 본문 422 Unprocessable Content
필수인데 키가 없음 400 Bad Request

초안의 문법으로는 값을 큰따옴표로 감싼 문자열로 보내지만(Idempotency-Key: "8e03978e-40d5-43e8-bc93-6894a57f9324"), 실제 표기는 상대가 문서에 적은 형식을 따릅니다.

받는 쪽의 멱등 처리는 유일 제약으로 구현합니다

우리가 요청을 받는 쪽일 때 가장 흔한 구현 오류는 "키가 있는지 조회하고, 없으면 처리한다"를 두 단계로 나누는 것입니다. 같은 키의 요청 두 건이 거의 동시에 들어오면 둘 다 "없음"을 확인하고 둘 다 처리합니다. 재시도는 원래 요청이 아직 끝나지 않은 시점에 들어오는 경우가 많아, 이 경합은 드물지 않습니다.

조회 대신 유일 제약이 걸린 삽입으로 선점해야 합니다. 아래는 PostgreSQL 9.5 이상을 가정한 예입니다. ON CONFLICT는 9.5에서 추가되었습니다.(PostgreSQL 9.5 릴리스 노트)

CREATE TABLE idempotency_keys (
  idempotency_key text        PRIMARY KEY,
  request_hash    text        NOT NULL,
  status          text        NOT NULL DEFAULT 'PROCESSING',
  response_code   int,
  response_body   jsonb,
  created_at      timestamptz NOT NULL DEFAULT now()
);

-- 요청마다 가장 먼저 실행합니다. 한 행이 반환되면 처음 보는 키입니다.
INSERT INTO idempotency_keys (idempotency_key, request_hash)
VALUES ($1, $2)
ON CONFLICT (idempotency_key) DO NOTHING
RETURNING idempotency_key;

반환된 행이 없으면 이미 있는 키이므로 기존 행을 읽어 분기합니다.

기존 행의 상태 응답
request_hash가 다름 422. 같은 키로 다른 내용을 보낸 것이므로 처리하지 않음
PROCESSING 409. 먼저 들어온 요청이 처리 중
DONE 저장해둔 response_code와 response_body를 그대로 반환

운영에서 주의할 점입니다.

  • 처리 도중 죽은 요청의 키를 남겨두지 않습니다. 키 삽입과 업무 처리를 한 트랜잭션에 넣으면, 처리가 실패해 롤백될 때 키도 함께 사라져 재시도가 정상적으로 다시 처리됩니다. 이때 같은 키의 동시 요청은 앞선 트랜잭션이 끝날 때까지 삽입에서 기다립니다. 처리 중에 외부 호출이 있어 트랜잭션을 나눠야 한다면 PROCESSING 행에 만료 시각을 두고, 만료된 행은 다시 선점할 수 있게 합니다. 둘 다 없으면 PROCESSING 상태의 키만 남아 이후 재시도가 계속 409를 받습니다
  • 보관 기간은 상대의 최대 재시도 기간보다 길게 잡습니다. 하루 동안 재전송하는 상대인데 키를 한 시간만 보관하면, 그 뒤에 온 재전송은 새 요청으로 처리됩니다
  • 키의 범위를 정합니다. 호출자가 여럿이면 호출자 식별자와 키를 함께 유일 조건으로 둡니다. 서로 다른 호출자가 같은 값을 보내는 경우를 막기 위해서입니다
  • 본문 비교를 생략하지 않습니다. 키만 보고 이전 결과를 돌려주면, 호출자의 버그로 같은 키에 다른 금액이 실려 왔을 때 잘못된 요청이 성공으로 응답됩니다

웹훅은 서명과 중복을 함께 확인합니다

외부에서 들어오는 콜백이나 웹훅에도 같은 원칙이 적용됩니다. 중복 전달될 수 있으므로, 처리 전에 이미 처리한 건인지 확인하는 절차가 필요합니다.

웹훅에는 한 가지가 더 붙습니다. 수신 주소는 외부에 열려 있으므로, 들어온 요청이 실제로 상대가 보낸 것인지 확인해야 합니다. 확인 없이 처리하면 주소를 아는 누구나 "결제 완료" 통지를 만들어 보낼 수 있습니다. 일반적인 방식은 공유 비밀키로 본문의 HMAC-SHA256 값을 계산해 헤더에 싣는 것입니다. GitHub는 X-Hub-Signature-256 헤더에 sha256=로 시작하는 값을 싣고(GitHub Docs, Validating webhook deliveries), Stripe는 시각과 본문을 이어 붙인 문자열에 서명해 Stripe-Signature 헤더에 시각과 함께 싣습니다.(Stripe Docs, Webhooks) 서명 대상과 헤더 형식은 상대마다 다르므로 정의서에서 확정합니다.

아래는 시각과 본문을 .으로 이어 서명하는 방식을 가정한 검증 예입니다. Node.js 18 이상 기준입니다.

import { createHmac, timingSafeEqual } from 'node:crypto';

const TOLERANCE_SECONDS = 300;

interface WebhookSignatureInput {
  rawBody: Buffer;
  timestamp: string;
  signatureHex: string;
  secret: string;
}

export function isValidWebhookSignature(input: WebhookSignatureInput): boolean {
  const ageSeconds = Math.abs(Date.now() / 1000 - Number(input.timestamp));
  if (!Number.isFinite(ageSeconds) || ageSeconds > TOLERANCE_SECONDS) return false;

  const expected = createHmac('sha256', input.secret)
    .update(`${input.timestamp}.`)
    .update(input.rawBody)
    .digest();
  const received = Buffer.from(input.signatureHex, 'hex');

  return received.length === expected.length && timingSafeEqual(received, expected);
}

검증이 실패하는 원인은 대체로 정해져 있습니다.

증상 원인 해결
서명이 항상 불일치 프레임워크가 JSON으로 파싱한 본문을 다시 직렬화해 서명 계산에 사용 파싱 전의 원본 바이트를 그대로 사용
한글이 포함된 건만 불일치 본문을 UTF-8이 아닌 인코딩으로 해석 본문을 UTF-8로 다룸
비교 함수에서 예외 발생 길이가 다른 값을 timingSafeEqual에 전달 길이를 먼저 비교
간헐적으로 만료 판정 서버 시계가 어긋남 NTP로 시각을 맞추고 허용 오차를 둠
비밀키 교체 직후 전부 실패 새 키와 이전 키가 함께 쓰이는 기간을 고려하지 않음 교체 기간에는 두 키로 모두 검증

서명 비교에 일반 문자열 비교를 쓰지 않는 이유는 비교에 걸리는 시간으로 값이 추측될 수 있기 때문입니다. Node.js의 crypto.timingSafeEqual은 일정 시간 알고리즘으로 비교하며, 두 값의 바이트 길이가 다르면 오류를 던집니다.(Node.js crypto 문서) 서명에 시각을 포함하는 이유는 가로챈 요청을 나중에 다시 보내는 재전송 공격을 막기 위해서입니다. Stripe의 공식 라이브러리는 허용 오차의 기본값을 5분으로 둡니다.

서명이 맞더라도 처리 순서가 남습니다.

  • 빨리 응답하고 처리는 뒤로 미룹니다. 수신 즉시 원문을 저장하고 2xx를 돌려준 뒤 별도 작업으로 처리합니다. 응답이 늦으면 상대는 실패로 보고 다시 보냅니다
  • 이벤트 식별자로 중복을 거릅니다. 앞의 멱등 키 테이블과 같은 방식으로 이벤트 ID에 유일 제약을 겁니다
  • 순서를 가정하지 않습니다. Stripe는 이벤트가 생성된 순서대로 전달된다고 보장하지 않으며, 같은 이벤트가 두 번 이상 전달될 수 있다고 문서에 밝혀두고 있습니다. 통지를 받으면 본문의 값을 그대로 믿기보다 상대 API로 현재 상태를 다시 조회하는 편이 안전합니다
  • 웹훅만 믿지 않습니다. 우리 쪽 장애로 받지 못한 통지는 상대의 재전송 기간이 지나면 사라집니다. 누락은 뒤에서 다룰 대사로 잡아야 합니다

상대가 죽었을 때 우리까지 죽지 않게 합니다

외부 시스템이 장기 장애 상태일 때 계속 호출을 시도하면 두 가지 문제가 생깁니다. 우리 자원이 소모되고, 상대의 복구가 늦어집니다.

서킷 브레이커 패턴이 이 문제를 다룹니다. 실패율이 기준을 넘으면 일정 시간 호출 자체를 차단하고, 이후 소량의 시험 호출로 회복 여부를 확인하는 방식입니다.

상태는 세 가지입니다. 평소에는 호출을 그대로 통과시키면서 실패를 셉니다(닫힘). 실패가 기준에 도달하면 이후 호출은 상대에 보내지 않고 즉시 오류로 돌려줍니다(열림). 정해둔 시간이 지나면 시험 호출을 보내, 성공하면 닫힘으로 돌아가고 실패하면 다시 대기합니다(반열림).(Martin Fowler, Circuit Breaker)

라이브러리마다 이름은 다르지만 정해야 하는 값은 비슷합니다.

설정 정하는 기준 잘못 잡았을 때
차단 기준(실패율 또는 연속 실패 수) 평소 실패율보다 충분히 높게 낮으면 일시 오류에도 차단되고, 높으면 차단이 늦음
판정에 필요한 최소 호출 수 호출이 적은 시간대에도 의미 있는 수 없으면 새벽에 2건 중 1건 실패로 차단됨
열림 유지 시간 상대의 통상 복구 시간 짧으면 회복 중인 상대를 계속 두드림
반열림 시험 호출 수 소량 많으면 차단을 푼 순간 부하가 다시 몰림
실패로 세는 범위 연결 실패, 타임아웃, 5xx 4xx나 업무 오류까지 세면 입력 오류가 많은 날 차단됨

마지막 줄이 자주 틀립니다. 잔액 부족이나 필수값 누락 같은 응답은 상대가 정상적으로 동작하고 있다는 뜻이므로 실패로 세지 않습니다.

차단 단위도 정해야 합니다. 상대 시스템 전체를 하나로 묶으면 느린 조회 API 하나 때문에 정상인 결제 API까지 차단됩니다. 상대의 기능 단위로 나누는 편이 낫습니다. 서버가 여러 대라면 차단 상태는 보통 서버마다 따로 판정된다는 점도 알아둡니다. 한 대는 열려 있고 다른 한 대는 닫혀 있는 시간이 생기므로, 사용자에게는 같은 기능이 됐다 안 됐다 하는 것처럼 보일 수 있습니다.

차단 상태에서 무엇을 반환할지가 설계의 핵심입니다.

  • 캐시된 직전 값을 쓸 수 있는가
  • 기본값으로 진행할 수 있는가
  • 해당 기능만 비활성화하고 나머지는 동작시킬 수 있는가
  • 사용자에게 어떤 안내를 보여줄 것인가

"조회 실패 시 빈 목록 반환"은 위험할 수 있습니다. 사용자에게는 데이터가 없는 것처럼 보이기 때문입니다. 실패는 실패로 표시하는 편이 안전합니다.

보내는 양을 우리가 조절합니다

외부 시스템은 대부분 호출 한도를 두고 있습니다. 한도를 넘기면 차단되거나, 명시적 한도가 없더라도 상대 시스템이 느려집니다. 우리 쪽에서 보내는 속도를 제어하는 장치가 필요합니다.

  • 호출량 제한 — 초당 또는 분당 호출 수를 우리 쪽에서 먼저 제한합니다. 상대가 막기 전에 우리가 조절하는 편이 낫습니다
  • 동시 호출 수 제한 — 커넥션 풀 크기로 상한을 둡니다. 무제한으로 열리면 상대와 우리 양쪽에 부담이 됩니다
  • 대량 작업 분산 — 배치로 수만 건을 보내야 한다면 한꺼번에 던지지 않고 나눠서 보냅니다
  • 우선순위 구분 — 사용자가 기다리고 있는 실시간 호출과 배경에서 도는 동기화 작업이 같은 자원을 두고 경쟁하지 않도록 분리합니다

마지막 항목이 실제로 자주 문제가 됩니다. 야간 배치가 도는 동안 사용자 요청이 느려지는 현상은 대체로 이 구분이 없어서 생깁니다.

끝내 실패한 건을 버리지 않습니다

재시도를 다 소진한 요청이 로그에만 남고 사라지면, 나중에 복구할 방법이 없습니다. 실패한 메시지를 별도 저장소에 보관하는 구조가 필요합니다.

  • 실패 원인과 시각, 재시도 횟수를 함께 기록합니다
  • 원본 요청 전문을 보관해 재처리가 가능하게 합니다
  • 운영자가 확인하고 재처리할 수 있는 화면을 제공합니다
  • 일정 건수 이상 쌓이면 알림이 발생하도록 합니다

이 구조가 없으면 장애 복구가 데이터베이스를 직접 수정하는 작업이 됩니다.


3. 데이터가 어긋나는 지점을 관리합니다

연동에서 가장 다루기 어려운 문제는 장애가 아니라 불일치입니다. 양쪽 시스템 모두 오류 없이 동작했는데 데이터가 다른 상황입니다.

동기와 비동기를 구분해서 씁니다

모든 연동을 실시간 호출로 처리할 필요는 없습니다.

방식 적합한 경우 주의할 점
동기 호출 결과를 즉시 알아야 하는 경우(결제 승인, 재고 확인) 상대 장애가 곧바로 전파됨
비동기 메시지 결과가 조금 늦어도 되는 경우(알림 발송, 통계 반영) 순서 보장과 중복 처리 설계 필요
배치 동기화 대량 데이터 정기 반영(마스터 데이터, 정산) 주기 사이의 시차를 업무에서 수용해야 함

화면에서 "처리 중입니다"를 보여줄 수 있는 업무라면 비동기로 돌리는 편이 안정적입니다. 사용자를 기다리게 하지 않으면서 재시도 여유도 확보됩니다. 저희가 개발한 선박 운항 시스템 운영 플랫폼도 같은 이유로 실행 요청을 큐에 넣고 별도 프로세스가 수행하도록 나눴습니다. 원격 작업은 오래 걸리고 중간에 실패할 수 있어, 요청을 받는 부분과 수행하는 부분이 붙어 있으면 화면이 그 시간 동안 묶이고 실패한 작업의 상태를 따로 추적하기도 어렵습니다.

두 곳에 나눠 쓰는 순간 어긋납니다

우리 데이터베이스에 저장하고 외부 시스템에도 전송하는 작업은 하나의 트랜잭션으로 묶이지 않습니다. 둘 중 하나만 성공하는 상황이 반드시 발생합니다.

① DB 저장 성공 → 외부 전송 실패 → 우리만 알고 상대는 모름
② 외부 전송 성공 → DB 저장 실패 → 상대만 알고 우리는 모름

②가 더 위험합니다. 우리 시스템에 기록이 없으니 추적할 단서도 없습니다.

실무에서는 저장과 전송을 한 트랜잭션 안에서 처리하되, 전송은 예약만 하는 방식을 씁니다. 업무 데이터와 전송 대기 기록을 같은 트랜잭션으로 저장하고, 별도 프로세스가 대기 기록을 읽어 실제 전송을 수행합니다. 전송이 실패하면 대기 기록이 남아 있으므로 재시도할 수 있습니다. 이 구조를 트랜잭션 아웃박스 패턴이라고 부릅니다.(microservices.io, Transactional outbox)

이 방식에서도 중복 전송은 발생할 수 있습니다. 전송은 성공했는데 완료 표시를 남기기 전에 프로세스가 죽는 경우입니다. 앞서 다룬 멱등성이 여기서 필요합니다.

아웃박스 테이블 구성 예

아래는 PostgreSQL 9.5 이상을 가정한 최소 구성입니다. 간격과 건수는 예시입니다.

CREATE TABLE outbox (
  id              bigserial   PRIMARY KEY,
  idempotency_key uuid        NOT NULL UNIQUE,
  destination     text        NOT NULL,
  payload         jsonb       NOT NULL,
  status          text        NOT NULL DEFAULT 'PENDING', -- PENDING, SENT, DEAD
  attempts        int         NOT NULL DEFAULT 0,
  next_attempt_at timestamptz NOT NULL DEFAULT now(),
  last_error      text,
  created_at      timestamptz NOT NULL DEFAULT now(),
  sent_at         timestamptz
);

CREATE INDEX outbox_due_idx ON outbox (next_attempt_at) WHERE status = 'PENDING';

업무 데이터를 저장하는 트랜잭션 안에서 outbox에도 한 행을 넣습니다. 멱등 키는 이 시점에 만들어 함께 저장합니다. 전송 프로세스는 다음 순서로 동작합니다.

-- 1) 보낼 건을 선점합니다. 다음 시도 시각을 미뤄두므로 다른 작업자가 같은 건을 집지 않습니다.
UPDATE outbox
SET attempts = attempts + 1,
    next_attempt_at = now() + interval '5 minutes'
WHERE id IN (
  SELECT id FROM outbox
  WHERE status = 'PENDING' AND next_attempt_at <= now()
  ORDER BY id
  LIMIT 50
  FOR UPDATE SKIP LOCKED
)
RETURNING id, idempotency_key, destination, payload, attempts;

-- 2) 트랜잭션 밖에서 외부로 전송합니다.

-- 3-a) 성공하면 완료로 표시합니다.
UPDATE outbox SET status = 'SENT', sent_at = now() WHERE id = $1;

-- 3-b) 재시도 가능한 실패면 다음 시도 시각을 백오프로 다시 잡습니다.
UPDATE outbox SET next_attempt_at = $2, last_error = $3 WHERE id = $1;

-- 3-c) 재시도 불가능한 실패이거나 횟수 상한에 도달하면 격리합니다.
UPDATE outbox SET status = 'DEAD', last_error = $2 WHERE id = $1;

SKIP LOCKED는 다른 트랜잭션이 잠근 행을 기다리지 않고 건너뛰는 옵션으로, PostgreSQL 문서는 여러 소비자가 큐처럼 쓰는 테이블에서 잠금 경합을 피하는 용도로 쓸 수 있다고 설명합니다.(PostgreSQL 문서, SELECT) 전송 프로세스를 여러 개 띄워도 같은 건을 동시에 집지 않게 하는 장치입니다.

구성할 때 자주 틀리는 부분입니다.

  • 외부 호출을 트랜잭션 안에서 하지 않습니다. 행을 잠근 채로 전송하면 상대가 느려질 때 데이터베이스 연결과 잠금이 함께 묶입니다. 위 예처럼 선점만 짧은 트랜잭션으로 끝내고 전송은 밖에서 합니다
  • 선점 유지 시간은 전체 타임아웃보다 길어야 합니다. 예에서는 5분입니다. 전송이 끝나기 전에 이 시간이 지나면 다른 작업자가 같은 건을 다시 집어 중복 전송합니다
  • 업무 코드에서 직접 전송하는 경로를 남기지 않습니다. "급한 건이니 바로 보내고 아웃박스에도 넣는다"는 식의 예외가 생기면 두 경로의 결과가 어긋납니다
  • 완료된 행을 정리합니다. SENT 행을 계속 쌓아두면 테이블이 커집니다. 보관 기간을 정해 주기적으로 옮기거나 지웁니다. 위 예의 부분 인덱스는 대기 중인 행만 담으므로 조회 성능은 유지됩니다
  • 순서가 필요한 업무라면 별도 장치가 필요합니다. 앞선 건이 재시도 대기 중일 때 뒤의 건이 먼저 나갈 수 있습니다. 아래 순서 항목에서 다룹니다

DEAD 상태가 앞에서 말한 "끝내 실패한 건"의 보관 장소가 됩니다. 원본 요청과 실패 원인, 시도 횟수가 한 행에 남아 있으므로 운영 화면에서 조회하고 PENDING으로 되돌려 재처리할 수 있습니다.

외부를 먼저 호출해야 한다면 기록부터 남깁니다

결제 승인처럼 결과를 즉시 받아야 해서 아웃박스로 미룰 수 없는 호출도 있습니다. 이때는 호출 전에 "요청 예정" 상태의 기록을 먼저 커밋합니다. 그 뒤에 외부를 호출하고, 결과에 따라 기록을 갱신합니다. 호출 뒤에 저장이 실패하거나 프로세스가 죽더라도 "요청 예정" 기록이 남아 있으므로, 이 상태로 오래 머문 건을 찾아 상대에게 결과를 조회하면 ②의 상황을 복구할 수 있습니다.

정기적으로 맞춰봅니다

설계를 아무리 해도 장기간 운영하면 데이터는 어긋납니다. 이를 전제하고 대사(reconciliation) 절차를 만들어둡니다.

  • 하루 단위로 양쪽 건수와 금액 합계를 비교합니다
  • 불일치가 발견되면 건별로 대조합니다
  • 자동 보정이 가능한 유형과 사람이 확인해야 하는 유형을 구분합니다
  • 대사 결과를 기록으로 남깁니다

결제나 정산이 얽힌 시스템이라면 대사는 선택이 아닙니다. 오히려 대사가 없다는 사실이 나중에 더 큰 비용이 됩니다. 몇 달 치 데이터를 소급해서 맞추는 작업은 훨씬 어렵습니다.

건별 대조는 상대가 준 자료를 테이블에 적재한 뒤 양쪽을 완전 외부 조인으로 묶으면 됩니다. 아래는 우리 결제 테이블(payments)과 상대의 정산 자료를 적재한 테이블(partner_settlement)을 상대 거래번호로 대조하는 예입니다. 테이블과 컬럼 이름은 설명용이고, 상대의 상태값은 적재할 때 우리 코드 체계로 변환해두었다고 가정합니다. :from, :to, :settle_date는 실행 시 넘기는 값입니다.

SELECT COALESCE(o.partner_tx_id, p.tx_id) AS tx_id,
       CASE
         WHEN p.tx_id IS NULL         THEN 'ONLY_OURS'
         WHEN o.partner_tx_id IS NULL THEN 'ONLY_PARTNER'
         WHEN o.amount <> p.amount    THEN 'AMOUNT_MISMATCH'
         ELSE 'STATUS_MISMATCH'
       END AS diff_type,
       o.amount AS our_amount,
       p.amount AS partner_amount
FROM (
  SELECT partner_tx_id, amount, status
  FROM payments
  WHERE approved_at >= :from AND approved_at < :to
) o
FULL OUTER JOIN (
  SELECT tx_id, amount, status
  FROM partner_settlement
  WHERE settle_date = :settle_date
) p ON p.tx_id = o.partner_tx_id
WHERE p.tx_id IS NULL
   OR o.partner_tx_id IS NULL
   OR o.amount <> p.amount
   OR o.status <> p.status;

결과의 유형마다 원인과 조치가 다릅니다.

유형 흔한 원인 조치
우리에만 있음 전송 실패를 성공으로 기록, 상대의 집계 마감 이후 건 상대에 결과 조회 후 재전송 또는 취소
상대에만 있음 응답을 받지 못해 우리가 실패로 기록, 웹훅 누락 상대 자료 기준으로 우리 기록 보정. 사용자 안내 필요 여부 확인
금액 불일치 부분 취소 미반영, 수수료·부가세 포함 여부 차이, 통화·단위 차이 사람이 확인. 자동 보정하지 않음
상태 불일치 취소 통지 누락, 상태 코드 매핑 오류 상대에 현재 상태 조회 후 보정

대사 결과에 불일치가 대량으로 나온다면 실제 불일치보다 비교 기준이 어긋난 경우가 많습니다. 우리는 승인 시각 기준 자정으로 끊었는데 상대는 매입 시각 기준이거나 23시 30분에 마감하는 식입니다. 한쪽이 UTC이고 다른 쪽이 KST여도 같은 현상이 나타납니다. 그래서 집계 기준 시각을 정의서에 적어두어야 하고, 경계 시간대의 건은 다음 날 대사에서 한 번 더 맞춰보도록 만듭니다. 아직 확인 중인 건은 비교 대상에서 빼지 않고 별도 유형으로 표시해, 며칠째 확정되지 않는 건이 드러나게 합니다.

자동 보정의 범위는 좁게 시작합니다. 상대 자료가 원장인 항목(승인 여부, 상대 거래번호)은 상대 기준으로 맞추고, 금액이 다른 건은 사람이 확인한 뒤 처리하는 구분이 무난합니다. 보정한 건은 누가 언제 어떤 근거로 바꿨는지 기록에 남깁니다. 시스템을 교체하는 기간에 구 시스템과 신 시스템을 맞춰보는 절차는 레거시 데이터 이관 — 옮기기 전에 확인해야 하는 것들에서 따로 다뤘습니다.

순서가 중요한 업무인지 먼저 판단합니다

비동기 메시지를 쓰면 전달 순서가 보장되지 않는 경우가 있습니다. 주문이 생성되기 전에 주문 취소 메시지가 먼저 도착하는 상황이 실제로 발생합니다.

먼저 확인할 것은 이 업무가 정말 순서에 의존하는가입니다. 상태를 덮어쓰는 방식이라면 순서가 덜 중요하지만, 증감 연산이라면 순서가 결과를 바꿉니다.

순서가 필요하다면 선택지는 세 가지입니다.

  • 키 단위 순서 보장 — 같은 주문번호의 메시지는 같은 처리 단위로 보내 순서를 유지합니다
  • 버전 번호 활용 — 메시지에 일련번호를 담고, 받는 쪽에서 이전 번호보다 낮은 메시지를 무시합니다
  • 도착 후 재정렬 — 일정 시간 모아두었다가 순서대로 처리합니다. 지연이 생기므로 실시간성이 필요한 업무에는 맞지 않습니다

두 번째 방식이 구현 부담이 가장 적습니다. 다만 어떤 값을 버전으로 쓸지를 상대와 합의해야 합니다. 수정 시각을 쓰는 경우 두 시스템의 시계가 어긋나면 그대로 문제가 됩니다.

상대 시스템을 신뢰하지 않고 검증합니다

들어오는 데이터는 정의서대로 온다고 가정하지 않는 편이 좋습니다.

  • 필수 필드 존재 여부
  • 자료형과 범위
  • 코드값이 정의된 목록에 있는지
  • 참조하는 식별자가 우리 쪽에 실제로 있는지
  • 금액·수량의 부호와 상한

검증에 실패한 데이터는 처리하지 않고 격리합니다. 일단 저장해두고 나중에 고치는 방식은 잘못된 데이터를 시스템 전체로 퍼뜨립니다.


4. 문제가 생겼을 때 볼 수 있어야 합니다

연동 장애 대응에서 시간이 가장 많이 드는 단계는 복구가 아니라 원인 파악입니다. 우리 쪽 문제인지 상대 쪽 문제인지 판단하는 데만 한나절이 걸리는 경우가 있습니다.

요청을 끝까지 추적할 수 있게 합니다

하나의 사용자 요청이 여러 시스템을 거치면, 각 시스템의 로그가 따로 놀게 됩니다. 이를 묶는 식별자가 필요합니다.

  • 요청 진입 시점에 추적 ID를 생성합니다
  • 모든 로그에 이 ID를 포함시킵니다
  • 외부 호출 시 헤더에 실어 보냅니다
  • 상대 시스템도 같은 규약을 쓰면 양쪽 로그를 함께 볼 수 있습니다

양쪽이 규약을 새로 정해야 한다면 W3C Trace Context를 기준으로 삼을 수 있습니다. traceparent 헤더 하나에 버전, 추적 ID(16진수 32자), 호출자 구간 ID(16진수 16자), 플래그를 -로 이어 싣는 형식이며(traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01), W3C 권고안으로 확정되어 있습니다.(W3C Trace Context) 표준 형식을 쓰면 추적 도구를 도입할 때 헤더를 다시 정의하지 않아도 됩니다.

상대가 우리 추적 ID를 받아주지 않는다면, 상대가 발급한 거래번호를 우리가 저장해두는 것으로 대신할 수 있습니다. 문의할 때 이 번호가 있는 것과 없는 것은 차이가 큽니다.

연동 구간의 로그는 따로 관리합니다

일반 애플리케이션 로그와 섞어두면 필요할 때 찾기 어렵습니다. 외부 호출은 별도로 기록하는 편이 낫습니다.

기록할 항목입니다.

  • 호출 대상과 목적
  • 요청·응답 시각과 소요 시간
  • 응답 코드와 오류 메시지
  • 재시도 횟수
  • 추적 ID와 상대 거래번호
  • 멱등 키
  • 실패 구분(연결 실패, 응답 타임아웃, 오류 응답)

요청·응답 전문을 남길지는 판단이 필요합니다. 디버깅에는 유용하지만 개인정보나 카드 정보가 포함될 수 있습니다. 남긴다면 마스킹 규칙을 먼저 정의해야 합니다. 주민등록번호, 카드번호, 계좌번호, 연락처가 그대로 로그에 남는 상황은 피해야 합니다. 본문만 가리고 헤더를 놓치는 경우가 많습니다. 인증 토큰, API 키, 웹훅 서명이 실린 헤더도 마스킹 대상에 넣습니다.

알림 기준을 미리 정합니다

로그를 남겨도 사람이 보지 않으면 의미가 없습니다. 어떤 상황에서 누구에게 알릴지 정해둡니다.

신호 기준 예시 조치
오류율 상승 5분간 실패율이 평소 대비 급증 담당자 알림
응답 지연 평균 응답 시간이 임계치 초과 담당자 알림
서킷 차단 차단 상태 진입 즉시 알림
실패 큐 적체 미처리 건수가 기준 초과 담당자 알림
전송 지연 가장 오래된 대기 건의 대기 시간이 기준 초과 담당자 알림
결과 미확정 확인 중 상태로 일정 시간 이상 머문 건 발생 담당자 알림
대사 불일치 일일 대사에서 차이 발생 업무 담당자 통보

임계치는 처음부터 정확할 수 없습니다. 운영하면서 조정한다는 전제로 시작하되, 조정 주체와 주기를 정해두는 것이 중요합니다. 알림이 너무 자주 오면 사람들이 무시하게 되고, 그때부터 알림은 없는 것과 같아집니다. 운영자 알림의 기준을 잡는 방법은 알림이 무시되기 시작하는 지점 — 발송 시스템 설계에서 자세히 다뤘습니다.

상대 시스템 장애일 때의 절차를 미리 만들어둡니다

연동 장애의 상당수는 우리가 고칠 수 없는 문제입니다. 상대 시스템이 복구될 때까지 기다리는 것 외에 할 수 있는 일이 없는 경우가 있습니다. 그렇다고 아무 준비가 없으면 그 시간 동안 혼란이 커집니다.

미리 정리해두면 좋은 항목입니다.

  • 판단 기준 — 우리 문제인지 상대 문제인지 구분하는 방법. 상대가 제공하는 상태 페이지나 헬스체크 엔드포인트가 있다면 확인 경로로 등록해둡니다
  • 연락 경로 — 상대 담당자 연락처와 접수 방법. 야간·휴일 경로가 따로 있는지 확인합니다
  • 내부 공유 — 고객 응대 부서가 상황을 알 수 있도록 전달하는 경로
  • 기능 축소 운영 — 해당 연동이 막혔을 때 나머지 기능만으로 운영할 수 있는지, 그러려면 무엇을 꺼야 하는지
  • 복구 후 처리 — 장애 기간에 밀린 건을 어떤 순서로 재처리할지, 사용자에게 소급 안내가 필요한지

특히 복구 후 처리가 자주 빠집니다. 상대 시스템이 살아났다고 해서 끝이 아닙니다. 밀려 있던 요청을 한꺼번에 보내면 이제 막 회복한 상대를 다시 쓰러뜨릴 수 있습니다. 재처리 속도를 조절하는 장치가 필요합니다.

사용자에게 무엇을 보여줄지 정의합니다

기술적인 복구와 별개로, 장애 중에 사용자가 보는 화면도 설계 대상입니다.

  • 재시도하면 될 상황인지, 기다려야 하는 상황인지 구분해서 안내합니다
  • "알 수 없는 오류"는 피합니다. 문의 시 참조할 수 있는 코드나 번호를 함께 보여줍니다
  • 결제처럼 돈이 걸린 작업은 현재 상태를 명확히 알려야 합니다. "결제가 되었는지 알 수 없습니다"는 가장 나쁜 안내입니다
  • 중복 시도를 유도하지 않도록 버튼 상태를 관리합니다

5. 착수 전 점검 목록

규약

  • 인터페이스 정의서에 엔드포인트·인증·필드·코드값·오류·제한이 모두 기재되어 있는가
  • 날짜·금액·널 처리 방식이 실제 값 예시로 명시되어 있는가
  • 오류가 HTTP 상태 코드로 오는지 본문 코드로 오는지, 오류 코드별 재시도 가능 여부가 적혀 있는가
  • 평소 응답 시간과 최대 응답 시간을 받았거나 검증 환경에서 측정했는가
  • 중복 요청 방지 수단(멱등 키의 필드, 형식, 보관 기간)이 정의서에 있는가
  • 응답을 받지 못한 건의 결과를 확인할 조회 수단이 있는가
  • 웹훅의 서명 방식, 재전송 정책, 순서 보장 여부, 비밀키 교체 절차가 적혀 있는가
  • 대사 자료의 제공 방식과 집계 기준 시각이 적혀 있는가
  • 연동용 인증 키가 특정 담당자의 계정에 묶여 있지 않은가
  • 검증 환경이 운영과 다르게 동작하는 부분을 확인했는가
  • 상대 시스템의 정기 점검 시간과 장애 공지 경로를 알고 있는가
  • 규약 변경 시 통보 기간과 버전 정책이 합의되어 있는가
  • 모르는 필드가 들어와도 동작하도록 구현했는가

실패 처리

  • 연결·읽기·전체 타임아웃이 모두 설정되어 있는가 (라이브러리 기본값을 그대로 쓰고 있지 않은가)
  • 타임아웃 값이 상위 호출자의 여유 시간보다 작은가
  • 응답 타임아웃 건을 실패가 아닌 확인 중 상태로 기록하고, 확정하는 절차가 있는가
  • 재시도 대상과 비대상이 오류 유형별로 구분되어 있는가
  • 재시도가 한 계층에서만 일어나는가 (라이브러리의 기본 재시도 포함)
  • 백오프와 지터가 적용되어 있고 Retry-After의 두 형식을 모두 해석하는가
  • 멱등성 키가 정의되어 있고 재시도 시 동일하게 유지되는가
  • 받는 쪽 멱등 처리가 조회 후 처리가 아니라 유일 제약으로 구현되어 있는가
  • 웹훅 서명을 원본 본문으로 검증하고 시각 허용 오차를 확인하는가
  • 들어오는 콜백의 중복 처리를 막고 있는가
  • 서킷 브레이커가 업무 오류를 실패로 세지 않으며, 차단 단위가 기능별로 나뉘어 있는가
  • 서킷 차단 시 반환할 값과 사용자 안내가 정의되어 있는가
  • 최종 실패 건을 보관하고 재처리할 수단이 있는가

정합성

  • 동기·비동기·배치 중 업무 특성에 맞는 방식을 선택했는가
  • 우리 저장과 외부 전송이 어긋날 때의 복구 경로가 있는가
  • 외부 호출이 데이터베이스 트랜잭션 밖에서 이루어지는가
  • 전송 대기 건의 선점 유지 시간이 전체 타임아웃보다 긴가
  • 대사 절차와 주기가 정해져 있는가
  • 대사의 집계 기준 시각과 시간대가 양쪽에서 같은가
  • 자동 보정 대상과 사람이 확인할 대상이 구분되어 있는가
  • 들어오는 데이터를 검증하고 실패 건을 격리하는가

관측

  • 추적 ID가 모든 로그와 외부 호출에 포함되는가
  • 상대 거래번호를 저장하고 있는가
  • 로그 마스킹 규칙이 정의되어 있고 인증 헤더도 대상에 포함되는가
  • 연동 로그에서 연결 실패와 응답 타임아웃이 구분되는가
  • 알림 기준과 수신자가 정해져 있는가
  • 장애 중 사용자에게 보여줄 화면이 설계되어 있는가

마치며

연동 설계에서 반복되는 원칙은 결국 하나로 모입니다. 상대 시스템이 언제든 느려지거나 멈추거나 예상과 다른 값을 보낼 수 있다고 가정하는 것입니다.

이 가정을 받아들이면 설계가 달라집니다. 타임아웃을 걸고, 재시도 조건을 나누고, 멱등성 키를 넣고, 실패 건을 보관하고, 정기적으로 대사합니다. 각각은 큰 작업이 아니지만, 없을 때의 비용은 운영 기간 내내 누적됩니다.

저희는 예약·결제·객실 제어처럼 성격이 다른 시스템을 하나로 묶는 프로젝트를 여러 차례 수행했습니다. 기존 시스템과의 연동이 포함된 구축을 준비하고 계시다면 문의하기로 편하게 연락해주세요.


함께 보면 좋은 글

참고 자료