728x90

GeekNews에 OAuth를 제대로 이해하고 쓰고 있는지 되묻는 팟캐스트가 올라왔다. 주제는 새롭지 않다. 소셜 로그인 붙여본 사람이면 다 아는 얘기다. 그런데 막상 "인가 코드를 왜 따로 받아요?", "PKCE는 SPA만 쓰는 거 아닌가요?" 물어보면 답이 갈린다. 나도 몇 년간 "라이브러리가 알아서 해주니까"로 넘겼던 구간이 있었고, 사내 SSO를 직접 붙이면서 리다이렉트 URI 검증 하나 때문에 반나절을 날린 뒤에야 스펙을 제대로 읽었다.

이 글은 그 삽질을 정리한 것이다. 프로토콜 교과서 요약이 아니라, 실제로 어디서 터지고 왜 터지는지에 초점을 맞춘다.

1. OAuth는 인증이 아니라 인가다 — 구성 요소부터 다시

가장 많이 하는 오해가 "OAuth 로그인"이라는 표현이다. OAuth 2.0은 인가(Authorization) 프레임워크다. "이 앱이 내 구글 드라이브 파일 목록을 읽어도 된다"를 위임하는 프로토콜이지, "이 사람이 누구인지"를 증명하는 프로토콜이 아니다. 사용자 신원 확인은 그 위에 얹은 OpenID Connect(OIDC)의 영역이고, 그때 나오는 게 ID Token(JWT)이다.

이 구분이 실무에서 왜 중요하냐면, 액세스 토큰으로 사용자를 식별하려다 사고가 나기 때문이다. 액세스 토큰은 "이 요청이 어떤 권한 범위를 가지냐"를 담을 뿐, 그 토큰이 어느 클라이언트에게 발급됐는지 검증하지 않고 신원 근거로 쓰면 다른 앱에서 받은 토큰을 그대로 던져서 남의 계정으로 들어가는 시나리오가 열린다. 이게 흔히 말하는 confused deputy 문제다. 신원이 필요하면 OIDC의 ID Token을 쓰고, aud(audience)가 내 클라이언트 ID인지 반드시 확인해야 한다.

구성 요소는 네 개다. 비유하자면 호텔이다.

  • Resource Owner (사용자) — 방 주인. 권한의 원천이다.
  • Client (앱) — 방 청소를 대행하러 온 업체. 주인의 허락이 있어야 들어간다.
  • Authorization Server (인가 서버) — 프런트 데스크. 주인 확인 후 카드키를 발급한다.
  • Resource Server (자원 서버) — 객실 문. 카드키만 확인하고 열어준다. 주인이 누군지는 모른다.

핵심은 자원 서버가 사용자 비밀번호를 절대 모른다는 점이다. 카드키(액세스 토큰)는 유효기간이 있고, 특정 층(scope)만 열리고, 분실해도 재발급으로 무효화된다. 비밀번호를 앱에 직접 주는 방식(그래서 Resource Owner Password Credentials Grant는 OAuth 2.1에서 제거됐다)과 비교하면 차이가 명확하다.

2. 왜 코드와 토큰을 두 번에 나눠 받나 — Front/Back Channel

Authorization Code Grant의 흐름을 압축하면 이렇다.

[1] 브라우저 → 인가 서버   GET /authorize?response_type=code
                          &client_id=my-app&redirect_uri=https://app.example.com/callback
                          &scope=openid profile&state=xyz
                          &code_challenge=E9Me...&code_challenge_method=S256
[2] 사용자 로그인 + 동의
[3] 인가 서버 → 브라우저   302 Location: https://app.example.com/callback?code=AUTH_CODE&state=xyz
[4] 앱 백엔드 → 인가 서버  POST /token  (code + client_secret + code_verifier)
[5] 인가 서버 → 앱 백엔드  { "access_token": "...", "refresh_token": "...", "expires_in": 3600 }

여기서 [1]~[3]이 Front Channel, [4]~[5]가 Back Channel이다. 이 구분이 전부라고 봐도 된다.

Front Channel은 브라우저 리다이렉트를 타고 흐른다. 즉 URL에 실린다. URL은 브라우저 히스토리에 남고, Referer 헤더로 새나가고, 프록시/WAF/CDN 접근 로그에 그대로 찍히고, 사용자가 주소창을 복사해서 슬랙에 붙일 수도 있다. 여기에 액세스 토큰을 실으면 그 토큰은 사실상 여러 군데에 복사본이 남는다.

그래서 Front Channel로는 일회용·단기 인가 코드만 흘린다. 코드는 그 자체로 아무 리소스에도 접근할 수 없다. 실제 토큰은 서버끼리 직접 통신하는 Back Channel(TLS로 보호되는 서버-투-서버 POST)에서만 오간다. 이 채널에서는 client_secret으로 클라이언트 신원까지 확인한다.

비유하자면 인가 코드는 "택배 수령 번호"다. 남이 봐도 번호만으로는 물건을 못 받는다. 신분증(client_secret)까지 있어야 창구에서 물건(토큰)을 준다. 반면 Implicit Grant는 프런트 채널에 물건 자체를 던지는 방식이었고, 그래서 OAuth 2.1에서 제거 대상이 됐다.

인가 코드에는 세 가지 방어가 걸린다. (1) 짧은 수명 — RFC 6749는 최대 10분을 권고하고 실제 구현체들은 보통 1분 내외로 잡는다. (2) 일회성 — 한 번 교환되면 즉시 폐기. (3) 재사용 감지 시 해당 코드로 발급된 토큰까지 함께 무효화. 3번을 구현 안 한 인가 서버가 은근히 있으니 자체 구현할 땐 꼭 챙겨야 한다.

redirect_uri 검증이 무너지면

공격자가 redirect_uri를 자기 서버로 바꿔치기할 수 있으면 위 설계가 통째로 무너진다. 코드가 공격자 서버로 배달되니까. 그래서 인가 서버는 사전 등록된 URI와 완전 일치(exact match)로 비교해야 한다. 부분 일치나 와일드카드를 허용하면 오픈 리다이렉트와 조합돼서 뚫린다.

실무에서 제일 자주 보는 에러가 이거다.

Error 400: redirect_uri_mismatch
The redirect URI in the request, http://localhost:3000/callback,
does not match the ones authorized for the OAuth client.

원인 대부분은 사소하다. 끝의 슬래시 유무(/callback vs /callback/), http/https 차이, 로컬 개발 시 localhost127.0.0.1 혼용, 그리고 ALB/Nginx 뒤에서 X-Forwarded-Proto를 안 넘겨줘서 앱이 스킴을 http로 조립하는 경우다. 마지막 케이스가 특히 악질인데, 로컬에선 되고 스테이징에 올리면 깨진다.

# Nginx 리버스 프록시 뒤 Spring Boot에서 흔한 원인
location / {
    proxy_pass http://app:8080;
    proxy_set_header Host              $host;
    proxy_set_header X-Forwarded-Proto $scheme;   # 이거 빠지면 http로 조립됨
    proxy_set_header X-Forwarded-Host  $host;
    proxy_set_header X-Forwarded-Port  $server_port;
}

Spring Boot라면 여기에 server.forward-headers-strategy=framework(또는 native)를 함께 켜야 실제로 반영된다. 프레임워크별 설정 키는 버전마다 다르니 각 공식 문서 확인 필요.

3. PKCE는 왜 모두의 필수가 되었나

PKCE(Proof Key for Code Exchange, RFC 7636)는 원래 모바일 앱을 위해 나왔다. 네이티브 앱은 client_secret을 안전하게 못 숨긴다(APK 뜯으면 나온다). 그리고 커스텀 스킴 리다이렉트(myapp://callback)는 같은 스킴을 등록한 악성 앱이 가로챌 수 있다. 코드를 탈취당하면 시크릿이 없어도 토큰 교환이 되어버린다.

PKCE의 아이디어는 단순하다. 클라이언트가 매 요청마다 랜덤 문자열 code_verifier를 만들고, 그 SHA-256 해시를 code_challenge로 인가 요청에 실어 보낸다. 토큰 교환 때 원본 verifier를 제시한다. 인가 서버는 해시가 맞는지 확인한다. 코드만 훔친 공격자는 verifier를 모르니 교환에 실패한다.

# PKCE 값 직접 만들어보기 (bash)
code_verifier=$(openssl rand -base64 60 | tr -d '\n=+/' | cut -c1-64)
code_challenge=$(printf '%s' "$code_verifier" \
  | openssl dgst -sha256 -binary \
  | openssl base64 | tr '+/' '-_' | tr -d '=')

echo "verifier : $code_verifier"
echo "challenge: $code_challenge"
verifier : Xk2pQ7mLdR9vNzTfA3sB8cJyH5gW1eU0iO6rP4nMqK
challenge: p3sVYw-Kk9c1nA2fRb7ZQxL0dEmT8uJhGvNyI4oS6XU

주의할 점: base64url이라 +-, /_로 치환하고 패딩 =는 제거해야 한다. 이걸 안 해서 invalid_grant 나는 케이스를 여러 번 봤다.

{
  "error": "invalid_grant",
  "error_description": "PKCE verification failed: code_verifier does not match code_challenge"
}

또 하나 흔한 함정은 verifier 저장 위치다. SPA에서 리다이렉트 왕복 사이에 verifier를 어딘가 보관해야 하는데, 이걸 메모리에만 두면 새로고침이나 새 탭 리다이렉트에서 날아간다. sessionStorage가 현실적인 절충안이고, 백엔드가 있다면 서버 세션에 담는 BFF(Backend for Frontend) 패턴이 더 낫다.

OAuth 2.1은 이 PKCE를 퍼블릭 클라이언트뿐 아니라 컨피덴셜 클라이언트에도 요구하는 방향으로 정리됐다. "서버에 secret 있는데 왜?"라는 반문이 나오는데, PKCE가 막는 건 시크릿 유출이 아니라 인가 코드 주입(authorization code injection) 공격이기 때문이다. 공격자가 자기 세션에서 얻은 코드를 피해자 브라우저에 주입해 피해자 계정에 자기 계정을 연결시키는 식의 시나리오는 시크릿이 있어도 성립한다. PKCE는 "이 코드를 요청한 브라우저 세션"과 "이 코드를 교환하는 세션"이 같은지를 묶어준다.

OAuth 2.1이 정리하는 주요 변경 사항은 대체로 이렇게 알려져 있다. Implicit Grant와 Password Grant 제거, PKCE 필수화, redirect_uri 완전 일치 요구, 베어러 토큰을 쿼리 스트링에 싣는 것 금지. 다만 2.1은 여전히 드래프트 단계를 거치는 중이라 최종 문서의 정확한 문구와 상태는 공식 문서 확인 필요.

4. 실무 트러블슈팅과 아키텍처 선택

리프레시 토큰 로테이션의 함정

퍼블릭 클라이언트에 리프레시 토큰을 줄 때는 로테이션이 권장된다. 리프레시할 때마다 새 리프레시 토큰을 발급하고 기존 것은 폐기한다. 폐기된 토큰이 다시 쓰이면 "탈취됐다"고 보고 해당 토큰 패밀리 전체를 무효화한다.

여기서 실제로 터지는 게 동시 요청 레이스다. 탭 3개가 동시에 만료된 액세스 토큰으로 API를 호출하면 세 개의 리프레시 요청이 동시에 나간다. 하나만 성공하고 나머지 둘은 이미 폐기된 토큰을 쓴 게 되어 패밀리 전체가 무효화되고, 사용자는 아무 이유 없이 로그아웃된다.

{
  "error": "invalid_grant",
  "error_description": "Refresh token has been revoked (token reuse detected)"
}

대응은 두 갈래다. 클라이언트 쪽에서는 리프레시 요청을 단일 프로미스로 묶어 큐잉한다(모든 대기 요청이 하나의 리프레시 결과를 공유). 서버 쪽에서는 짧은 유예 시간(grace period)을 둬서 방금 로테이션된 토큰의 재사용은 몇 초간 허용하는 구현이 많다. 이 유예 지원 여부는 IdP마다 다르니 도입 전 확인해야 한다.

// 리프레시 단일화 — 프런트에서 가장 자주 필요한 패턴
let refreshing = null;

async function getAccessToken() {
  if (isExpired(token)) {
    refreshing ??= fetch('/auth/refresh', { method: 'POST', credentials: 'include' })
      .then(r => {
        if (!r.ok) throw new Error('refresh failed');
        return r.json();
      })
      .finally(() => { refreshing = null; });   // 성공/실패 무관하게 해제
    token = await refreshing;
  }
  return token.access_token;
}

게이트웨이에서 토큰 검증

API 게이트웨이나 서비스 메시에서 JWT를 검증할 때 자주 빠뜨리는 게 있다. iss, aud, exp는 대부분 챙기는데 알고리즘 고정을 안 한다. 검증 라이브러리가 헤더의 alg를 그대로 신뢰하면 alg: none이나 HS256 혼동 공격 여지가 생긴다. 허용 알고리즘을 화이트리스트로 못박아야 한다.

JWKS 캐싱도 함정이다. 인가 서버가 키를 로테이션했는데 게이트웨이가 JWKS를 무기한 캐싱하면 전 서비스가 401을 뱉는다. 반대로 캐시 없이 매 요청 JWKS를 조회하면 인가 서버가 죽는다. 보통 캐시 TTL을 두되, 모르는 kid가 오면 즉시 한 번 재조회하는 방식(rate limit 걸어서)이 정석이다.

# 발급받은 토큰 검사 — 디버깅 첫 단계
curl -s https://idp.example.com/.well-known/openid-configuration | jq '.issuer, .jwks_uri, .token_endpoint'

# 페이로드만 빠르게 까보기 (검증 아님, 디버깅 전용)
echo "$ACCESS_TOKEN" | cut -d. -f2 | base64 -d 2>/dev/null | jq .
"https://idp.example.com"
"https://idp.example.com/.well-known/jwks.json"
"https://idp.example.com/oauth2/token"

{
  "iss": "https://idp.example.com",
  "a
728x90
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