728x90

어젯밤 HSA 사이트에서 로그인하다가 갑자기 "패스키를 설정하시겠어요?" 팝업이 떴다. 무심코 눌렀다가 회사 노트북의 비밀번호 관리자에 패스키가 등록됐고, 오늘 아침 개인 컴퓨터에서 그 계정에 접근할 수 없게 됐다. 이건 원문 Hacker News 댓글에 나온 실제 사례인데, 나도 비슷한 경험이 있다. 5년째 인프라/DevOps를 하면서 SSH 키, TPM, YubiKey를 매일 다루는 사람인데도 Passkey는 "이게 대체 어디 저장되는 거지?"라는 질문에 즉답을 못 한다.

이번 글은 X 프로덕트 헤드 Nikita Bier의 "Passkey는 소비자 심리를 이해 못 하는 엔지니어들이 만들었다"는 비판을 출발점으로 삼는다. 하지만 단순히 "UX가 나쁘다"고 끝내지 않고, 백엔드/인프라 엔지니어가 실제로 로그인 서비스에 Passkey를 붙일 때 무엇을 알아야 하는지, 특히 디바이스 분실·복구·계정 공유 같은 지뢰밭을 어떻게 설계해야 하는지를 다룬다.

1. 도입: 왜 지금 Passkey가 다시 도마에 올랐나

Passkey는 새로운 기술이 아니다. FIDO2/WebAuthn 표준 자체는 몇 년 전부터 있었고, Apple·Google·Microsoft가 밀면서 Amazon, GitHub 같은 대형 서비스가 이미 지원한다. 문제는 표준은 우아한데 사용자 경험이 산산조각 나 있다는 점이다.

원문에서 가장 뼈아픈 지적은 이거다. Hacker News는 컴퓨터 활용 상위 1%에 가까운 사람들이 모인 곳인데, 그곳의 26년차 엔지니어조차 이렇게 말한다:

"공개키·개인키 원리도 이해하지만, 여러 기기와 브라우저에서 패스키를 어떻게 써야 로그인에 지장이 없는지는 모르겠다. iPad, iPhone, Windows, MacBook에서 Brave·Firefox·Safari를 쓰는데, 휴대폰 Safari에서 우연히 패스키를 만들면 다른 기기에서도 되는지, 동기화되는지, 사이트마다 몇 개까지 되는지 불분명하다."

여기서 핵심을 짚자. Passkey의 "개념"이 어려운 게 아니라, "구현이 사이트마다 제각각"이라 멘탈 모델을 세울 수 없다는 것이다. 이건 UX 문제인 동시에, 우리 같은 서비스 구현자가 만들어내는 문제다. 즉 우리가 어떻게 붙이느냐에 따라 사용자 경험이 결정된다는 뜻이다.

2. 핵심: Passkey 동작 원리를 SSH 키로 이해하기

원문 댓글 중 가장 정확한 설명은 이거다: "웹용으로 자동화된 SSH authorized_keys라고 생각하면 된다." 인프라 엔지니어라면 이 비유 하나로 90%가 이해된다.

등록(Registration): 키 쌍을 만든다

SSH를 처음 세팅할 때 우리가 하는 일을 떠올려보자.

$ ssh-keygen -t ed25519 -C "my-laptop"
Generating public/private ed25519 key pair.
Your identification has been saved in ~/.ssh/id_ed25519
Your public key has been saved in ~/.ssh/id_ed25519.pub

# 서버에 공개키만 등록
$ ssh-copy-id user@server

Passkey도 똑같다. 사이트에 가입할 때 브라우저(정확히는 인증기, Authenticator)가 비대칭 키 쌍을 만들고, 공개키만 서버로 보낸다. 개인키는 절대 서버로 가지 않는다. 차이점은 개인키가 파일(~/.ssh/)이 아니라 다음 중 한 곳에 저장된다는 것이다.

  • 하드웨어 보안 모듈: YubiKey, 스마트폰의 Secure Enclave, PC의 TPM
  • 클라우드 동기화 저장소: iCloud Keychain, Google Password Manager, 1Password, Bitwarden

바로 이 "어디에 저장되느냐"가 사용자를 혼란에 빠뜨리는 지점이다. SSH는 ~/.ssh/에 있다는 게 명확한데, Passkey는 "휴대폰인가? 브라우저인가? 지문인가?" 알 수가 없다. 여기서 중요한 오해 하나를 풀자. 지문/얼굴은 개인키가 아니다. 생체 인증은 그저 기기에 저장된 개인키를 꺼내 쓰기 위한 잠금 해제 수단일 뿐이다. Touch ID는 "너 정말 이 폰 주인 맞아?"를 확인하는 것이고, 실제 인증은 그 뒤 개인키가 한다.

인증(Authentication): 챌린지-응답

로그인할 때는 SSH의 챌린지-응답과 동일하다. 서버가 랜덤한 챌린지를 보내면, 개인키로 서명해서 돌려준다. 서버는 저장해둔 공개키로 서명을 검증한다.

Passkey의 진짜 강점은 여기서 나온다: 피싱 저항성. WebAuthn은 서명할 때 도메인(origin)을 함께 묶어 서명한다. 즉 google.com용 Passkey는 google-login.evil.com에서는 절대 동작하지 않는다. TOTP 코드는 가짜 페이지가 중계(relay)할 수 있지만, Passkey 챌린지-응답은 중계가 원천 불가능하다. 이게 SMS OTP나 TOTP보다 확실히 나은 점이다.

서버는 무엇을 저장하나

백엔드 관점에서 Passkey 사용자 레코드는 대략 이렇게 생겼다. 서버가 저장하는 건 "비밀"이 아니라 "공개키"라는 게 핵심이다.

{
  "user_id": "u_8f3a2b",
  "credentials": [
    {
      "credential_id": "AQIDBAUGBwgJCgsMDQ4PEA...",
      "public_key": "pQECAyYgASFYI...",
      "sign_count": 42,
      "transports": ["internal", "hybrid"],
      "aaguid": "adce0002-35bc-c60a-648b-0b25f1f05503",
      "created_at": "2024-11-20T09:12:00Z",
      "device_name": "iPhone 15 (iCloud)"
    }
  ]
}

여기서 실무적으로 중요한 두 가지:

  • credentials배열이라는 점. 한 사용자가 여러 Passkey를 등록할 수 있고, 반드시 그렇게 설계해야 한다(뒤에서 설명).
  • sign_count는 복제 탐지용 카운터다. 하지만 iCloud처럼 클라우드 동기화되는 Passkey는 카운터가 0으로 고정되거나 증가하지 않는 경우가 많다. sign_count가 0이라고 무조건 거부하면 안 된다. 이건 흔한 함정이라 뒤에서 다시 언급한다.

3. 실무 관점: 도입 시 고려사항과 지뢰밭

3-1. 절대 "Passkey 온리"로 강제하지 마라

원문에서 가장 많이 나온 불만이 디바이스 분실 = 계정 접근 상실이다. 이건 UX 문제가 아니라 아키텍처 설계 문제다. 비밀번호는 종이에 적어 금고에 넣을 수 있고, TOTP는 여러 기기에 설치할 수 있다. 그런데 Passkey를 유일한 인증 수단으로 두고 그게 하드웨어에만 있으면, 기기 분실 시 복구가 악몽이 된다.

xguru의 댓글에 나온 BeeBS의 접근이 현실적인 정답에 가깝다:

"먼저 이메일 매직링크로 가입한 뒤 Passkey를 등록하도록 했다. 이 조합이면 서버에 비밀번호를 전혀 저장하지 않아도 되고, 기기를 잃어도 이메일로 다시 로그인해 새 Passkey를 등록할 수 있다."

실무 설계 원칙을 정리하면:

  • Passkey는 "빠른 로그인 수단"으로 붙이고, 복구 경로는 항상 별도로 확보한다. 이메일 매직링크, 백업 코드, 또는 두 번째 Passkey.
  • 등록 UX에서 "두 개 이상 등록"을 강하게 유도한다. "이 기기 + 다른 기기(또는 YubiKey)". 원문의 78세 부모님 사례처럼 "예비 열쇠도 두자"는 비유가 실제로 통했다.

3-2. 흔한 함정: 실제로 마주칠 에러들

(a) origin 불일치 에러 — 개발 환경에서 가장 먼저 만난다. 로컬에서 localhost로 테스트하다가 스테이징 도메인으로 넘어갈 때, 혹은 RP ID(Relying Party ID) 설정을 잘못하면 브라우저 콘솔에 이게 뜬다:

SecurityError: The relying party ID is not a registrable domain
suffix of, nor equal to the current domain.

이건 rpId를 현재 접속한 도메인과 맞지 않게 설정했을 때 발생한다. 예를 들어 app.example.com에서 접속했는데 rpIdlogin.other.com으로 줬거나, localhost가 아닌 IP(127.0.0.1)로 접속한 경우다. WebAuthn은 localhost는 예외적으로 허용하지만 IP 주소는 허용하지 않는다는 걸 기억하자.

(b) sign_count 검증 실패 — 위에서 언급한 문제. iCloud 동기화 Passkey로 로그인했는데 서버가 이렇게 거부하는 경우:

AuthenticationError: signature counter did not increase
(stored: 42, received: 0) — possible cloned authenticator

이론적으로는 복제 탐지 로직이 맞지만, 클라우드 동기화 Passkey는 카운터가 0으로 오는 게 정상 동작이다. sign_count가 0이면 카운터 검증을 스킵하도록 예외 처리해야 한다. 이걸 모르고 엄격하게 막으면 Apple 사용자 전체가 로그인 불가에 빠진다. (관련 동작은 사용하는 라이브러리 문서 확인 필요 — SimpleWebAuthn, WebAuthn4J 등마다 기본값이 다르다.)

(c) 사용자 검증(User Verification) 정책 충돌 — 등록 시 userVerification: "required"로 했는데 하드웨어 키가 PIN을 지원하지 않으면:

NotAllowedError: The operation either timed out or was not allowed.

이 에러는 원인 범위가 넓어서 악명 높다. 사용자가 그냥 취소했을 수도 있고, UV 정책이 안 맞을 수도 있고, 타임아웃일 수도 있다. 로그만 보고는 원인을 특정하기 어려우니, 클라이언트에서 등록/인증 옵션을 상세 로깅해두는 게 디버깅에 필수다.

3-3. 계정 공유는 근본적으로 깨진다

원문에서 반복적으로 나오는 현실 문제. "Netflix 비밀번호 보내줄래?"가 안 된다. 비밀번호는 문자로 보낼 수 있지만 Passkey는 특정 기기/클라우드에 묶여 있어 남에게 넘길 수 없다. 배우자와 Amazon Prime을 공유하는 흔한 시나리오조차 막힌다.

실무적으로는 이렇게 접근해야 한다:

  • 가족/팀 공유가 핵심 유스케이스인 서비스라면, 계정 공유를 Passkey로 풀지 말고 "초대/멤버 권한" 같은 제대로 된 멀티유저 모델로 설계하라. Passkey는 개인 인증 수단이지 공유 수단이 아니다.
  • 공유가 불가피하면 비밀번호 로그인 경로를 함께 남겨둔다.

3-4. 트레이드오프 요약

항목Passkey비밀번호 + 관리자
피싱 저항성강함 (origin 바인딩)약함 (사람이 붙여넣기 가능)
서버 유출 시공개키만 유출 → 안전해시라도 크래킹 위험
기기 분실 복구어려움 (설계 필수)비교적 쉬움
계정 공유사실상 불가가능 (보안상 비권장이지만)
구현 일관성사이트마다 제각각성숙, 예측 가능
벤더 종속클라우드 동기화 시 종속 우려낮음

원문의 균형 잡힌 결론 하나를 인용하자면: "비밀번호는 근본적으로 망가진 보안 모델이고, 사용자들도 제대로 쓴 적이 거의 없다. 새 방식이 어렵다는 이유로 쉬운 문제에만 머무르는 건 바람직하지 않다." 즉 Passkey를 무작정 배격할 이유는 없지만, 복구·공유·일관성 문제를 우리가 설계로 메꿔야 한다는 뜻이다.

4. 정리: 한 줄 요약과 도입 체크리스트

한 줄 요약: Passkey는 SSH 키를 웹에 자동화한 것이고, 개념은 우아하지만 "복구·공유·일관성"을 서비스 구현자가 책임지고 설계하지 않으면 사용자에게는 "마법의 요정 가루"로 남는다.

누가 언제 써야 하나:

  • 써라: 피싱이 실제 위협인 서비스(금융, 관리자 콘솔), Apple/Google 생태계에 익숙한 일반 소비자 대상 서비스. 특히 "모든 사이트에서 같은 비밀번호를 직접 입력하던" 사용자를 대체할 때 보안 이득이 크다.
  • 미뤄라 / 병행하라: 계정 공유가 핵심인 서비스, 오프라인 백업/자체 동기화를 중시하는 기술직 사용자 비중이 높은 경우, 다양한 OS/브라우저 조합을 지원해야 하는 경우.

도입 체크리스트:

  1. Passkey를 유일 인증 수단으로 강제하지 않는다. 복구 경로(이메일 매직링크/백업 코드) 필수.
  2. 한 사용자당 여러 credential 등록을 지원하고, 등록 시 두 번째 키를 유도한다.
  3. sign_count == 0 케이스(클라우드 동기화 Passkey)를 예외 처리한다.
  4. rpId와 origin 설정을 환경별(local/staging/prod)로 명확히 분리한다.
  5. 계정 공유는 Passkey가 아닌 멀티유저 권한 모델로 푼다.
  6. 사용자에게 "이 폰이 열쇠이고, 예비 열쇠도 만들어두자"는 물리적 열쇠 비유로 안내한다. 원문 사례상 이게 가장 잘 통했다.
  7. 등록/인증 옵션을 상세 로깅해 NotAllowedError 같은 모호한 에러를 디버깅할 수 있게 한다.

참고 자료

※ 본문의 에러 메시지와 코드는 실무에서 마주치는 형태를 재구성한 예시다. 라이브러리·OS·브라우저 버전에 따라 문구와 기본 동작이 다를 수 있으니, 실제 도입 시 사용하는 스택의 공식 문서를 확인하기 바란다.

728x90
728x90

일반 CRUD 백엔드만 굴리다가 결제·정산 시스템에 처음 투입되면 멘붕이 온다. "그냥 잔액 칼럼 하나 두고 더하고 빼면 되는 거 아니야?"라고 생각하다가, 환불이 중복으로 두 번 나가고, 정산 금액이 1원씩 안 맞고, 외부 PG 웹훅이 두 번 와서 같은 거래가 두 번 찍히는 걸 보게 된다. 이게 다 같은 뿌리에서 나온 문제다.

최근 GeekNews에 올라온 핀테크 엔지니어링 핸드북은 이 바닥에서 반복적으로 터지는 함정들을 세 가지 원칙으로 깔끔하게 정리했다. 이 글에서는 그 원칙을 실무 코드와 실제로 만날 법한 에러까지 붙여서 풀어본다.

1. 왜 금융 시스템은 일반 백엔드와 다르게 설계해야 하는가

원문이 제시하는 세 가지 원칙이 전부다. 외워두면 설계 회의에서 싸울 때 근거가 된다.

  • No invented data: 돈은 없던 데서 생기지 않는다. 중복 처리와 임의 잔액 변경을 막아야 한다.
  • No lost data: 돈에 일어난 모든 일은 추적되고 영속화돼야 한다.
  • No trust: 외부 제공자도, 내부 컴포넌트도, 현실 세계도 믿지 말고 검증한다.

일반 CRUD에서는 "최신 상태"가 진실이다. 행 하나 업데이트하면 끝. 그런데 금융 시스템은 "어떻게 그 상태가 됐는지"가 더 중요하다. 규제 감사가 들어오면 "지금 잔액 100만 원입니다"로는 부족하고, "이 100만 원이 어떤 거래들의 합으로 만들어졌는지" 몇 년 전 것까지 재구성할 수 있어야 한다. 그래서 추적성·불변성·검증 가능성이 성능이나 편의성보다 앞선다.

핵심 차이를 한 줄로 요약하면: 일반 백엔드는 상태를 덮어쓰지만, 금융 백엔드는 상태를 누적한다.

2. 금액을 float로 저장하면 안 되는 이유

가장 흔하고 가장 치명적인 실수다. 신입한테 "왜 float 쓰면 안 돼요?"라고 물으면 대부분 "정밀도 문제요"라고 답하는데, 실제로 무슨 일이 벌어지는지 보여주는 게 빠르다.

# Python에서 IEEE-754 double 동작 확인
$ python3 -c "print(0.1 + 0.2)"
0.30000000000000004

$ python3 -c "print(0.1 + 0.2 == 0.3)"
False

이게 왜 무섭냐면, 단건 거래에서는 안 보이다가 수십만 건을 합산하는 정산 배치에서 갑자기 터진다. 외부 정산 데이터랑 1원, 2원씩 안 맞기 시작하는데 원인 찾는 데 며칠 날린다. 원문 표현대로 "예측하기 어려운 정밀도 손실"이라 디버깅이 지옥이다.

해결책은 책임에 따라 조합한다

  • 최소 단위 정수 저장: €12.34를 1234(센트)로 저장. 중앙은행 시스템과 같은 고정 정밀도 방식이다. 단, ISO 4217 자릿수를 따라야 하고 "항상 소수점 2자리"라고 가정하면 안 된다. JPY는 0자리, 일부 통화는 3자리다.
  • BigDecimal 같은 임의 정밀도 타입: 반올림 위치를 명시적으로 제어할 수 있어 FX·이자처럼 연산이 줄줄이 이어지는 중간 계산에 적합하다.
  • 유리수(Rational): 정밀도 손실이 절대 허용 안 될 때 가장 강력하지만 느리고 변환이 까다롭다.

저장 방식과 계산 방식은 별개 결정이다. 정수로 저장하고 BigDecimal로 중간 계산하는 조합이 흔하다.

// Java: BigDecimal로 안전하게 계산 (반올림 명시)
import java.math.BigDecimal;
import java.math.RoundingMode;

BigDecimal a = new BigDecimal("0.1");
BigDecimal b = new BigDecimal("0.2");
System.out.println(a.add(b));  // 0.3 (정확)

// 수수료 3.5% 계산 후 소수점 2자리 반올림
BigDecimal amount = new BigDecimal("12340");  // 최소단위(센트)
BigDecimal fee = amount.multiply(new BigDecimal("0.035"))
                       .setScale(0, RoundingMode.HALF_EVEN);
System.out.println(fee);  // 432

주의: Java에서 new BigDecimal(0.1)처럼 double을 직접 넣으면 float 문제가 그대로 따라온다. 반드시 new BigDecimal("0.1")처럼 문자열로 넣어야 한다.

직렬화 경계에서 다시 터지는 함정

내부에서 BigDecimal 잘 써놓고 안심하다가, JSON으로 내보내는 순간 무너진다. 일반 JSON 숫자는 대부분의 파서에서 IEEE-754 double로 파싱되기 때문이다. 그래서 돈은 JSON에서 12.34 같은 문자열이나 최소 단위 정수로 보내야 한다.

// 나쁜 예: 숫자로 직렬화 → 수신측에서 double로 파싱되어 정밀도 손실
{ "amount": 12.34, "currency": "EUR" }

// 좋은 예: 문자열 또는 최소단위 정수
{ "amount": "12.34", "currency": "EUR" }
{ "amount_minor": 1234, "currency": "EUR" }

그리고 돈은 숫자만으로 표현하면 안 된다. 항상 통화와 함께 Money 타입으로 묶어라. 서로 다른 통화 덧셈은 금지하고, 변환은 통제된 환율로만 명시적으로 한다. 이걸 안 하면 USD 금액에 KRW 금액을 더하는 버그가 코드 리뷰를 통과해버린다.

3. 멱등성 설계: 결제 재시도와 중복 처리를 막는 패턴

분산 시스템에서는 exactly-once delivery를 보장할 수 없다. 그래서 재시도가 필요하고, 재시도는 중복 전달을 만든다. 이 모순을 푸는 게 멱등성(Idempotency)이다. 같은 메시지가 두 번 와도 효과는 한 번만 나게 하는 성질.

실무에서 언제 만나냐면:

  • 클라이언트가 결제 요청 보냈는데 응답 타임아웃 → 사용자가 결제 버튼 다시 누름 → 두 번 결제
  • PG 웹훅이 redelivery policy 때문에 같은 이벤트를 2~3번 보냄
  • Kafka consumer가 처리 후 offset commit 전에 crash → 재시작 시 같은 메시지 재처리

명시적 idempotency key가 정답

원문은 payload 기반 deduplication보다 명시적 idempotency key가 보통 더 단순하고 안전하다고 본다. 핵심은 key를 특정 operation과 client 범위로 제한하는 것.

-- idempotency key를 DB unique 제약으로 강제
CREATE TABLE idempotency_keys (
    key         VARCHAR(255) NOT NULL,
    client_id   VARCHAR(64)  NOT NULL,
    operation   VARCHAR(64)  NOT NULL,
    response    JSONB,
    created_at  TIMESTAMPTZ  NOT NULL DEFAULT now(),
    PRIMARY KEY (client_id, operation, key)
);

-- 결제 처리 트랜잭션 안에서 먼저 키를 선점
INSERT INTO idempotency_keys (key, client_id, operation)
VALUES ('pay-20240601-abc123', 'merchant-42', 'charge');

두 번째 요청이 같은 키로 들어오면 unique 제약에 걸린다. 흔히 마주치는 에러가 이거다:

ERROR:  duplicate key value violates unique constraint "idempotency_keys_pkey"
DETAIL:  Key (client_id, operation, key)=(merchant-42, charge, pay-20240601-abc123) already exists.

이 에러를 예외로 흘려보내면 안 되고, "아 이미 처리된 요청이구나" 하고 저장해둔 원래 응답(response 컬럼)을 그대로 돌려줘야 한다. 이걸 처리 안 하고 500 에러로 던지면 클라이언트가 또 재시도하는 무한 루프에 빠진다.

흔한 함정들

  • 오류를 재생할지 재처리할지: 영구 오류(잔액 부족 등)는 그대로 재생하는 게 보통 단순하다. 일시 오류만 재처리.
  • 24시간 idempotency window: 구현은 단순해지지만 correctness 비용이 크다. window 밖에서 들어온 재시도는 중복으로 안 잡힌다.
  • 동시성: 대규모에서는 같은 키가 동시에 두 번 들어올 때 atomic barrier가 필요하다. unique 제약이 그 역할을 해준다.
  • out-of-order retry: 재시도가 원본보다 먼저 도착할 수도 있다. 테스트에 반드시 포함해라.

4. 이중 장부(Double-Entry Accounting)와 불변 원장

잔액 칼럼 하나 두는 설계가 왜 망하는지 여기서 명확해진다. 잔액은 저장하지 않고, 돈의 이동에서 파생해야 한다.

복식부기는 모든 거래를 (credit account, debit account, amount) 형태의 entry로 저장한다. 모든 entry가 한 계정에서 다른 계정으로 같은 금액을 옮기므로 장부는 항상 균형을 이룬다. 돈에는 항상 출처와 목적지가 있다. 외부 PG조차 전용 계정을 가져야 시스템 안팎으로 흐르는 돈을 추적할 수 있다.

-- 불변 원장: append-only, UPDATE/DELETE 금지
CREATE TABLE ledger_entries (
    id              BIGSERIAL PRIMARY KEY,
    debit_account   VARCHAR(64)  NOT NULL,
    credit_account  VARCHAR(64)  NOT NULL,
    amount_minor    BIGINT       NOT NULL,  -- 최소단위 정수
    currency        CHAR(3)      NOT NULL,
    value_time      TIMESTAMPTZ  NOT NULL,  -- 거래가 실제 발생한 시점
    booking_time    TIMESTAMPTZ  NOT NULL DEFAULT now(),  -- 기록된 시점
    settlement_time TIMESTAMPTZ,            -- 돈이 실제 이전된 시점 (T+X)
    reverses_id     BIGINT REFERENCES ledger_entries(id)  -- 정정 시 원본 연결
);

-- 잔액은 SELECT로 파생
SELECT
    SUM(CASE WHEN credit_account = 'user-42' THEN amount_minor ELSE 0 END)
  - SUM(CASE WHEN debit_account  = 'user-42' THEN amount_minor ELSE 0 END)
    AS balance_minor
FROM ledger_entries
WHERE currency = 'KRW';

시간을 created_at 하나로 합치지 마라

이게 진짜 자주 하는 실수다. 거래에는 보통 2~3개의 타임스탬프가 붙는다.

  • Value time: 거래가 실제 발생한 시점
  • Booking time: 시스템에 기록된 시점
  • Settlement time: 돈이 실제 이전된 시점 (T+2면 value date로부터 2일 뒤)

카드 결제를 예로 들면 T1에 결제 발생, T2에 시스템 기록, T3에 PG가 실제 입금. 이걸 created_at 하나로 뭉개면 나중에 재구성 불가능한 정보를 잃는다. 비즈니스 보고서는 value time이나 settlement time을 보는데, created_at밖에 없으면 월별 정산이 틀어진다.

정정은 덮어쓰지 말고 상쇄 entry로

posted entry는 관례상 불변이다. 잘못 찍혔으면 원본을 수정하는 게 아니라 compensating entry를 추가하고 원본과 양방향 연결한다.

  • Reversal: 원본을 경제적으로 없었던 것처럼 완전히 상쇄. 단, 원본과 reversal 둘 다 이력에 남는다.
  • Correction/adjustment: 실제 값과 올바른 값의 차이를 booking하거나, 되돌린 뒤 다시 posting.

정정은 원본과 다른 보고 기간에 들어갈 수 있다. 이미 닫힌(외부에 보고된) 보고 기간에 backdate하는 건 보통 금지다. 그래서 연결 정보가 있어야 "실제 활동"과 "cleanup"을 보고서에서 구분할 수 있다.

5. 분산 환경에서의 정합성: 2PC vs Saga vs Outbox

"상태 변경은 DB에 commit됐는데 Kafka publish가 실패했다" 또는 "publish는 성공했는데 응답을 못 받아서 DB를 rollback했다" — 이 문제를 어떻게 풀 것인가.

방식동작실무 평가
2PC / 분산 트랜잭션 여러 시스템을 하나의 원자적 트랜잭션으로 묶음 교과서 정답이지만 복잡성·표준화 어려움 때문에 실무에선 드물게 쓰임
Outbox 패턴 상태 변경과 "publish 의도"를 같은 트랜잭션으로 전용 테이블에 기록, 별도 relay가 나중에 발행 가장 실용적. DB 트랜잭션 하나로 원자성 확보
CDC DB의 WAL/replication log를 읽어 commit된 변경을 event로 변환 (Debezium, AWS DMS) raw row 형태라 내부 schema 누출 막으려면 postprocessing 필요
Saga 외부 효과가 영구 실패하면 compensating action을 posting해서 보상 외부 호출은 rollback 불가능하므로 roll-forward 또는 보상 거래로 처리

Outbox 패턴 핵심

-- 비즈니스 상태 변경과 outbox 기록을 같은 트랜잭션에
BEGIN;
  INSERT INTO ledger_entries (debit_account, credit_account, amount_minor, currency, value_time)
  VALUES ('user-42', 'merchant-7', 50000, 'KRW', now());

  INSERT INTO outbox (event_id, topic, payload)
  VALUES (gen_random_uuid(), 'payment.completed', '{"order_id":"o-123"}');
COMMIT;
-- relay 프로세스가 outbox를 polling하여 Kafka로 발행 후 마킹

어떤 메커니즘을 고르든 delivery는 at-least-once다. relay가 publish 후 기록 전에 죽으면 재시작 때 또 보낸다. 그래서 consumer는 stable event id로 deduplicate하고 멱등적으로 동작해야 한다. 3번 섹션의 멱등성이 여기서 다시 필요해진다. 결국 다 연결돼 있다.

재시작 가능한 흐름과 funds reservation

돈 흐름은 여러 단계를 거치고 단계 사이 어디서든 죽을 수 있다고 가정해야 한다. 진행 상태를 메모리가 아니라 영속 저장소에 두고, 명시적 state machine으로 모델링하고, 각 단계 완료를 다음 단계 시작 전에 commit한다. Temporal, Camunda, AWS Step Functions 같은 durable-execution engine을 쓰거나 직접 만든다.

외부 호출 전에 race condition을 막으려면 funds reservation(hold-and-release)을 쓴다. 외부 상호작용 전에 자금을 예약하고, 성공하면 settle, 실패하면 release. 이때 available = total - reserved로 가용 잔액을 구분한다. 중요한 건 잔액 확인과 reservation 기록이 linearizable해야 한다는 것. stale read에서는 두 거래가 모두 잔액 확인을 통과해 같은 자금을 두 번 쓰게 된다.

6. 실무 체크리스트: 시나리오별 설계 포인트

웹훅 처리 (PG·custodian 연동)

  • 순서 가정 금지 — out-of-order로 오거나 stale data를 담는다. 받은 웹훅으로 상태를 덮어쓰지 마라.
  • 웹훅 본문은 trigger로만 쓰고, authoritative state는 API를 다시 조회해서 확인. (단 API도 eventually consistent라 retry 필요)
  • 빠르게 2xx로 ack하고 비동기 처리. raw payload는 그대로 durable store에 저장.
  • 서명 검증은 재직렬화한 payload가 아니라 받은 raw bytes 위에서 해야 한다. 이거 틀리면 HMAC 검증이 간헐적으로 실패한다.
  • 웹훅은 "진실"이 아니라 "뭔가 일어났다는 hint"로 취급.

환불·정정

  • 원본 entry 수정 금지. compensating entry로 reversal 또는 correction.
  • 이미 닫힌 보고 기간에 backdate 가능한지 보고 일정 확인.
  • reversal이 자금 빠져나간 뒤 들어오면 의도치 않은 overdraft가 생긴다. 음수 잔액을 0으로 clamp하면 돈을 만들어내는 꼴이니 절대 금지.

정산·대사(Reconciliation)

  • 두 시스템 이상의 상태가 어긋나는 data drift는 필연. ledger·PG·bank 셋 이상일 수 있다.
  • settlement가 T+3이면 record는 3일간 unreconciled 상태가 정상 — 이걸 process에 반영 안 하면 불필요한 alert가 쏟아진다.
  • discrepancy를 단순 overwrite로 맞추지 마라. correction record로 원인을 이해하고 고쳐야 한다.
  • external provider id를 내
728x90

+ Recent posts