logo
작성일

Stairno 4a. 운영 결제 리허설

Authors
  • avatar
    Name
    seren-wib
    Twitter
Contents

로드맵 4단계 후반부. 10월 22일에 4단계를 닫았다(12절). 4편에서 만든 결제가 운영에서 진짜로 되는지 하나씩 돌려 봤다. 전체 그림은 Stairno 0. 전체 로드맵에 있다.

한 줄 요약

10월 20일 새벽, 운영 서버에 테스트 키로 결제를 실제로 일으켜 보는 리허설을 했다. 시작하자마자 공개 첫날부터 다른 사이트의 로그인이 막혀 있었다는 걸 알았고, 급히 고쳐 올린 뒤 시나리오 10개 중 8개를 통과시켰다. 그 과정에서 실수 몇 개와 설계의 빈틈 하나가 나왔다. 숨기지 않고 적는다.

1. 왜 리허설을 따로 하나

테스트는 가짜 환경에서 돈다. 가짜 결제사, 가짜 DB, 가짜 서버. 그런데 결제에는 운영에서만 확인되는 것들이 있다.

  • 결제사가 결제를 마친 사용자를 진짜 사이트 주소로 돌려보내는가
  • 결제사가 보내는 웹훅이 진짜 서버에 도착하는가
  • 메일이 진짜 메일함에 오는가

그래서 4단계는 운영에서 시나리오를 하나씩 돌려 보고, 인수 체크리스트를 채워야 닫힌다.

2. 리허설 전용 사이트를 만든 이유

문제가 하나 있었다. 주문을 만들려면 API에 붙은 사이트가 있어야 한다.

  • 주문 요청에는 사이트 토큰과 구매자 토큰이 둘 다 필요하다
  • 결제가 끝나고 돌아올 주소는 그 사이트의 https 주소여야 한다
  • 토스 결제창은 사이트 페이지에서 열린다

그런데 첫 사이트 Labs는 5단계, 4단계가 끝난 뒤에 만든다. 닭이 먼저냐 달걀이 먼저냐다.

3단계 때는 비슷한 상황에서 "사이트가 생기면 그때 확인"으로 미뤘다. 이번엔 그러지 않기로 했다. Labs에 결제를 붙이다가 문제가 생기면, 그게 결제 API의 버그인지 Labs의 버그인지 가려낼 수 없기 때문이다. 그래서 리허설만 하는 작은 사이트를 따로 만들었다.

리허설 도구

레포의 experiments/payment-rehearsal. 디자인 없는 한 페이지짜리 Worker다.

  • 진짜 Stairno 사이트와 똑같은 방식으로 붙는다. OIDC로 로그인하고(PKCE 포함), 사이트 토큰을 받는다
  • 버튼: 로그인, 1회 상품 주문(원화는 토스, 달러는 PayPal), 결제 확인, 금액을 반으로 바꾼 결제 확인, 구독 시작, 주문·권한 조회
  • 카드 번호 입력칸은 없다. 카드는 토스 창이 받는다
  • 사이트 비밀번호(client_secret)는 이 Worker의 시크릿으로만 둔다

가장 중요한 원칙: API 쪽에 리허설용 예외나 뒷문은 하나도 없다. 리허설이 진짜 사이트와 같은 길을 타야 의미가 있다. 이 도구는 레포에 남겨 두고, 결제 경로를 바꾸는 출시가 있을 때마다 다시 돌린다.

3. 준비

순서한 일
1리허설 도구를 Cloudflare에 올린다
2계정 센터에 리허설 도구를 OIDC 사이트로 등록하고, 받은 이름표와 비밀번호를 도구의 시크릿에 넣는다
3그 사이트 소속 상품 둘을 등록한다. 1회 상품(1,000원, 1달러), 월 구독 상품(1,000원)
4토스와 PayPal 대시보드에서 웹훅 받을 주소가 우리 API로 맞게 되어 있는지 본다
5리허설용 Stairno 계정 하나. 언어는 한국어로 시작
6운영 DB를 읽기만 하는 확인 스크립트

역할도 나눴다. 카드 입력, 결제사·운영자 비밀번호 입력, 대시보드 설정 변경은 내가 한다. 나머지 버튼 누르기와 결과 확인(응답, DB 기록, 메일)은 AI 에이전트가 한다.

2번에서 실수가 하나 있었다. 처음 등록했을 때 발급된 사이트 비밀번호가 작업 대화 기록에 그대로 찍혔다. 대화 기록도 남는 곳이니, 그 등록을 지우고 새로 등록했다. 지운 쪽 비밀번호로 로그인을 시도하면 거절(invalid_client)되는 것까지 확인했다.

6번은 생각보다 까다로웠다. Neon 콘솔에서 만든 DB 계정은 자동으로 관리자 그룹에 들어가서, "읽기 권한만 준다"는 설정이 먹지 않았다. 그래서 계정 권한 대신, 스크립트가 여는 트랜잭션 자체를 읽기 전용으로 열어서 쓰기를 원천 차단했다. 스크립트는 리허설 데이터만 읽고, 이메일·토큰·카드 열쇠는 출력하지 않는다.

4. 첫 버튼에서 404

리허설 도구에서 "로그인"을 눌렀다. 계정 센터의 로그인 페이지 /sign-in으로 가야 하는데, **404(그런 주소 없음)**가 떴다.

이 페이지는 다른 사이트가 "Stairno로 로그인"을 누르면 도착하는 곳이다. 그러니까 이 말은, 10월 18일 공개 첫날부터 어떤 사이트도 Stairno로 로그인할 수 없었다는 뜻이다. 붙은 사이트가 하나도 없어서 아무도 몰랐다. 3단계에서 "붙일 사이트가 없으니 5단계에서"로 미뤘던 바로 그 확인이다.

원인

계정 센터는 주소 앞에 언어를 붙인다(/ko/..., /en/..., i18n). Astro의 언어 설정은 "모든 페이지에 언어를 붙인다"로 되어 있었고, 이 설정에서는 언어가 안 붙은 페이지를 다 그린 뒤에 404로 바꿔 버린다. 그런데 OIDC 로그인 페이지는 언어 없는 /sign-in이다. 다른 사이트가 사용자 언어를 모르고 보내는 주소라서다.

왜 테스트는 통과했나

화면 테스트는 페이지 하나를 따로 떼어 내서 그려 본다. 그 과정에는 언어 설정에 따른 주소 처리가 끼지 않는다. 그래서 "/sign-in 페이지가 로그인 폼을 그린다"는 테스트는 통과했지만, "운영 빌드에서 /sign-in 주소가 살아 있다"는 아무도 확인하지 않았다. 테스트 종류 표에서 말한 "어떤 테스트도 운영 주소까지는 보장하지 않는다"가 그대로 일어났다.

고친 방법

Astro에서 언어 접두사 밖에서도 답할 수 있는 건 확장 기능(integration)이 따로 끼워 넣은 주소뿐이다. 그래서 /sign-in을 그 방식으로 넣고, 들어온 요청을 따라온 정보 그대로 사용자 언어의 로그인 페이지(/ko/sign-in 등)로 보내게(302) 했다. 언어 없이 답해야 하는 주소는 목록으로 따로 묶어 테스트하고, 실제 빌드를 띄워서 /sign-in이 302로 답하는지, 다른 주소는 여전히 404인지도 직접 확인했다.

5. 내가 지운 주소를 가리키던 메일

리허설 순서에는 "구독 갱신이 실패하면 오는 메일의 버튼을 눌러 본다"가 있다. 준비하면서 그 메일을 다시 봤더니, 버튼이 /billing을 가리키고 있었다.

그 주소는 4편의 v0.4.1에서 계정 센터를 한 페이지로 바꾸면서 내가 지운 주소다. 화면 구조를 바꿀 때 메일 안의 링크는 같이 보지 않았다. 사용자가 그 메일을 받았다면 버튼을 눌러서 404를 봤을 것이다. 새 구조의 결제 덩어리(/ko/#payment-methods)로 바꿨다.

6. 급한 수정: v0.4.2, 그리고 느려진 GitHub Actions

두 수정을 hotfix v0.4.2로 묶었다. 운영 중인 v0.4.1에 이 둘만 얹은 버전이다. develop에 쌓여 있던 다른 변경은 섞지 않는다.

main으로 가는 변경은 CI를 반드시 통과해야 한다. 평소 몇 분이면 끝나는 CI가 이날은 40분 가까이 걸렸다. GitHub Actions 쪽이 느려진 날이었다. 기다리는 것 말고 할 수 있는 게 없었고, 변경을 올리고 머지할 때까지 한 시간이 걸렸다.

새벽 1시 반쯤 v0.4.2가 운영에 올라갔고, /sign-in이 제대로 로그인 페이지로 보내는 걸 확인한 뒤 리허설을 다시 시작했다.

7. 아무도 갖고 있지 않던 시크릿

같은 밤에 하나가 더 나왔다. 운영 서버의 BETTER_AUTH_SECRET 원래 값이 어디에도 없었다. 비밀번호 관리자에도, 내 컴퓨터에도. Cloudflare는 넣은 시크릿을 다시 보여 주지 않는다.

이 값은 꽤 중요하다. 로그인 토큰에 서명하는 비밀 열쇠가 DB에 이 값으로 암호화돼 있다. 갖고 있는 어떤 값으로도 그 열쇠가 풀리지 않았다.

공개 전이고 사용자가 나와 테스트 계정뿐이라, 그냥 새 값으로 바꿨다.

  1. 새 값을 먼저 비밀번호 관리자에 저장
  2. Cloudflare에 새 값을 넣는다(wrangler secret put)
  3. 옛 값으로 암호화된 서명 열쇠를 DB에서 지운다
  4. 공개 열쇠 주소를 한 번 불러 새 서명 열쇠를 만들게 하고, 새 값으로 풀리는지 확인

이 값을 바꾸면 모든 로그인이 끊기고, 나가 있던 다운로드 링크도 전부 무효가 된다. 사용자가 있었다면 공지부터 해야 하는 일이다. 교훈은 단순하다. 어디에도 적어 두지 않은 시크릿은 잃어버린 시크릿이다. 넣기 전에 적는다. 이 절차는 운영 문서에 그대로 옮겨 적었다.

8. 결과: 첫날 10개 중 8개

순서시나리오확인한 것결과
1토스 1회 결제주문 fulfilled, 권한 생성, 영수증 메일통과
2금액 조작금액을 반으로 바꾸면 거절(422), 주문 failed, 토스에 승인 요청 안 감통과
3취소와 환불주문 refunded, 권한 회수, 환불 메일통과
4카드 등록·삭제끝 4자리 표시, 삭제하면 결제사의 카드 열쇠도 삭제통과
5구독 시작기본 카드로 바로 결제, 구독 active, 권한 기한 = 구독 기간 끝통과
6갱신 실패 세 번 → 구독 만료실패마다 메일, 네 번째 시도에서 expired통과(10월 21일)
7PayPal 결제와 환불fulfilled → refunded, 웹훅 서명 확인 통과통과
8웹훅 다시 보내기같은 알림을 또 보내도 기록이 늘지 않고 주문 그대로(멱등)통과
9결제 스위치 끄기로그인은 되고 주문만 503통과
10메일 두 언어계정 언어를 영어로 바꿔 1번과 3번을 다시통과(10월 21일)

카드 입력은 토스 테스트 카드로, PayPal은 샌드박스 구매자 계정으로 했다. 실제 돈은 한 푼도 안 나갔다.

3번에서 나온 설계의 빈틈

리허설 절차서에는 "계정 센터에서 환불 요청"이라고 적혀 있었다. 그런데 계정 센터에 환불 버튼이 없었다. 확인해 보니 요구사항 명세서는 환불 요청을 사이트가 넣도록 정해 두었고, 계정 센터는 요청 상태만 보여 주게 되어 있었다. 코드는 명세서대로였고, 절차서가 틀렸던 것이다.

리허설은 도구에 "환불 요청" 버튼을 더해서 통과시켰다. 그런데 사용자 입장에서 다시 생각해 보면, 어느 사이트에서 샀든 환불은 주문 내역이 있는 계정 센터에서 요청하는 게 자연스럽다. 사이트마다 환불 화면을 만드는 것도 Stairno 0에서 피하려던 "같은 일 여러 번"이다.

그래서 그 자리에서 결정을 바꾸고 계획서에 적었다.

  • 환불 요청은 계정 센터의 주문 상세에서 받는다. 사유를 적어 보내면 요청이 되고, 바로 환불되지는 않는다
  • 운영자가 확인해서 승인하거나 거절하고, 결과는 메일로 간다
  • 사이트에서 요청을 넣는 길은 입구로 남겨 둔다
  • 환불 정책 본문은 파는 사이트에 두고, 계정 센터는 그 링크를 건다
  • 명세서를 먼저 고친 뒤 구현한다. 리허설이 끝난 뒤 다음 출시에

6번이 남은 이유

토스 테스트 환경은 카드와 상관없이 결제를 다 승인한다. 그러니 "갱신이 실패하는 상황"을 일부러 만들어야 한다. 공식 방법은 요청에 테스트용 표시를 붙이는 것인데, 그러려면 API를 고쳐야 하고 "리허설용 예외 없음" 원칙에 어긋난다.

그래서 이렇게 하기로 했다. 운영 DB의 리허설 데이터만 살짝 바꿔서, 저장된 카드 열쇠와 고객 번호가 맞지 않게 만든다. 그러면 토스가 진짜로 "고객 정보가 맞지 않음"이라며 거절한다. 그리고 하루 한 번 도는 갱신 Cron을 잠깐 매분으로 바꿔서 첫 실패와 재시도 세 번을 연달아 일으킨다. 끝나면 데이터와 Cron을 원래대로 돌린다. 대시보드 설정이 바뀌는 데 최대 15분이 걸리고 단계가 많아서, 날이 밝은 뒤로 넘겼다.

9. 다음

  1. 리허설 6번과 10번. 10월 21일에 끝내고 인수 체크리스트에 적었다(11절)
  2. 정리. 읽기 전용 확인 스크립트와 임시 DB 계정을 지운다. 공유용 Google Docs를 마크다운 원본에 맞춘다. 10월 22일에 했다(12절)
  3. 4단계를 닫는다. develop에 쌓인 것을 다음 출시로 낸다. 10월 22일 v0.4.3
  4. 계정 센터 환불 요청. 명세서부터 고치고 만든다
  5. 로그인 유지. 로그인 화면의 "로그인 유지" 선택. 이것도 명세서부터
  6. 리허설 중에 찾고 아직 못 고친 것 둘. 허브 계정 메뉴의 "로그인" 링크가 없는 주소를 가리킨다. OIDC로 로그인한 뒤에도 계정 센터의 "연결된 사이트"가 비어 있다
  7. 5단계 stairnolabs.com. 진짜 사이트가 처음으로 Stairno 계정에 붙는다. 3단계에서 미뤄 둔 운영 실험 13단계와 "계정 삭제 뒤 토큰 갱신 실패" 확인도 여기서 한다

실제 결제는 사업자등록과 결제사 계약 뒤다. 그 전에 Workers 유료 플랜 전환과 약관의 사업자 정보 표기를 다시 본다.

10. 돌아보며

  • 문서를 먼저 쓴 건 남는 장사였다. 사흘 동안 결정이 수십 개 나왔는데, 전부 날짜와 함께 계획서와 설계 명세서에 남아 있다. 리허설 중에 환불 위치를 바꿀 때도 "어디를 고쳐야 하는지"가 문서에 이미 있었다
  • 테스트가 통과한다고 주소가 살아 있는 건 아니다. /sign-in 404는 화면 테스트로는 잡을 수 없다. 진짜 사이트를 운영에 붙여 봐야 나온다. "붙일 사이트가 없으니 나중에"가 이틀 가까이 사이트 로그인을 막고 있었다. 리허설 도구를 따로 만든 이유가 첫 버튼에서 증명됐다
  • 시크릿은 넣기 전에 적는다. 넣고 나서 적으려고 하면 이미 늦다
  • 빠른 건 좋은데, 빨라서 놓친 게 있다. 화면 구조를 몇 시간 만에 바꾸면서 메일 속 링크를 같이 보지 않았다. 바뀐 주소를 가리키는 곳을 찾는 테스트가 있었으면 급한 수정까지 가지 않았을 것이다

11. 덧붙임: 나머지 둘 (10월 21일)

다음 날 밤 6번과 10번을 마저 돌렸다. 둘 다 통과해서 10개 전부 통과다.

10번: 메일 두 언어

계정 언어를 영어로 바꾸고 1번과 3번을 다시 했다. 이번에는 카드 대신 간편결제로 결제했다. 결제 확인이 통과하고 주문이 fulfilled가 됐고, 영어 영수증이 왔다. 도구에서 환불을 요청하고 운영자 명령으로 승인하니 주문이 refunded, 권한이 회수되고 영어 환불 메일이 왔다.

메일 언어는 보내는 그 순간의 계정 언어를 따른다. 그래서 첫날의 한국어 결과로는 영어 쪽을 증명할 수 없었다. 같은 일을 두 번 한 이유다.

6번: 갱신 실패 세 번

계정을 영어로 둔 채 했다. 영어 갱신 실패 메일까지 한 번에 보려고.

시도구독 상태실패 횟수메일
첫 갱신past_due1영어
재시도 1past_due2영어
재시도 2past_due3영어
재시도 3expired4영어

네 번 모두 토스가 "고객 정보가 맞지 않음"(NOT_MATCHES_CUSTOMER_KEY)으로 거절했고, 우리 쪽은 이걸 장애가 아니라 결제 거절로 받아 실패 주문과 메일로 처리했다. 메일 버튼은 5절에서 고친 그 주소, 계정 센터 결제 섹션으로 간다. 고쳐 놓고 운영에서 눌러 본 건 이번이 처음이었다.

재시도는 원래 마지막 시도 사흘 뒤다. 사흘을 기다릴 수 없으니 재시도마다 DB의 "마지막 시도 날짜"를 사흘 전으로 돌려서 다음 분에 돌게 했다. 끝나고 카드 데이터를 되돌리고 매분 Cron을 지웠다.

30분을 날린 저장 버튼

대시보드에서 매분 Cron을 더하고 15분을 기다렸는데 아무 일도 없었다. 기다리는 걸 그만두고 로그를 열었다.

  • Cron이 돌면 우리 API는 갱신할 게 없어도 renewal run 한 줄을 남긴다. 매분 Cron을 더한 뒤로 그 줄이 하나도 없었다. 우리 코드까지 오지도 못한 것이다
  • 그날 아침 9시(자정 UTC)에는 그 줄이 있었다. 원래 Cron은 멀쩡하다는 뜻이니 운영 장애는 아니었다
  • 원인은 저장을 안 한 것이었다. 대시보드의 추가 창에서 "Add"를 누르면 목록에 임시로 올라갈 뿐이고, 칸 테두리가 점선이면 아직 저장 전이다. "Save"까지 눌러야 반영된다. 창을 나갔다 들어오니 줄이 사라져 있었다
  • Save를 누르고 약 8분 뒤 첫 실행이 돌았다

로컬의 Cloudflare 로그인 토큰에는 Workers 로그를 볼 권한이 없어서 터미널로 실시간 로그를 볼 수도 없었다. 대시보드의 로그 화면으로 확인했다. 절차서에는 "Add 뒤 Save, 새로고침해서 남았는지 본다"를 적어 넣었다.

권한이 안 끝난 것처럼 보인 이유

구독이 expired가 된 뒤에도 구독 권한은 원래 만료 시각(한 달 뒤)으로 남아 있었다. 버그처럼 보이지만 리허설이 만든 어긋남이다. 구독형 권한은 만료 시각으로 끝나고, 운영에서는 그 시각이 구독 기간 끝과 같다. 리허설은 갱신 대상으로 만들려고 구독의 기간 끝만 당겼고 권한의 만료 시각은 건드리지 않았다. 실제로 갱신이 실패할 때쯤이면 권한 만료 시각도 이미 지나 있다.

돌아보며, 하나 더

  • 기다리기 전에 "돌고 있다는 증거"부터 본다. 15분이라는 숫자를 믿고 기다리는 동안 아무것도 확인하지 않았다. 실행마다 한 줄 남기는 로그가 있었으니, 처음 2분 안에 로그를 열었으면 바로 알았을 일이다

12. 덧붙임: 4단계를 닫으며 (10월 22일)

리허설이 다 통과했다고 단계가 닫히는 건 아니다. 9절의 2번과 3번, 뒷정리와 출시가 남아 있었다.

뒷정리

리허설 동안 운영 DB를 읽으려고 만든 것들을 지웠다. 읽기 전용 확인 스크립트, 그 스크립트가 쓰던 접속 정보 파일, 스크립트를 묻지 않고 돌리게 해 둔 허용 규칙. 읽기만 하는 계정이어도 운영 DB 주소가 든 파일이 노트북에 남아 있을 이유는 없다. 임시 DB 계정은 Neon 콘솔에서 지운다.

리허설 도구 자체는 남겨 둔다. 결제 경로를 바꾸는 출시가 있을 때마다 다시 돌린다.

공유 문서

명세서는 레포의 마크다운이 원본이고, 남에게 보여 주는 Google Docs는 단계가 끝날 때 한 번 맞춘다. 매 PR마다 Docs까지 고치면 일이 두 배라서 정한 규칙이다. 링크를 지키려고 새로 올리지 않고 같은 문서 안에서 바뀐 곳만 고쳤다.

문서어긋난 정도
요구사항 명세서이미 맞아 있었다
설계 명세서세 줄
계획서표 몇 줄과 결정 셋
테스트와 배포 문서리허설 절차 한 절이 통째로, 인수 체크리스트 결과 전부

규칙의 대가도 보인다. 그사이 Docs를 열어 본 사람이 있었다면 리허설 절차가 없는 문서를 봤을 것이다. 단계가 사흘이라 괜찮았지, 길어지면 중간에 한 번 더 맞춰야 한다.

미뤄 둔 운영 절차

테스트와 배포 문서에는 "환불과 웹훅 재처리 절차는 2차 전개 때 더한다"는 줄이 4단계 내내 그대로 있었다. 리허설에서 둘 다 해 봤으니 이제 쓸 수 있었다.

  • 환불. 열린 환불 요청을 DB에서 보고, 사이트의 환불 정책으로 판단한 뒤 운영자 명령으로 승인하거나 거절한다. 쓰다가 하나를 코드로 확인했다. 결제사 대시보드에서 손으로 환불하면 결제사의 웹훅이 주문은 환불로 맞추지만, 열린 환불 요청은 닫지 않는다. 요청을 닫는 건 운영자 명령뿐이다. 그래서 절차서에 "대시보드에서 손으로 환불하지 않는다"를 적었다
  • 웹훅 재처리. 처리하다 실패한 웹훅은 DB에 실패로 남고 결제사에 오류를 답한다. 그러면 결제사가 알아서 다시 보낸다(토스는 3일 19시간 동안 최대 일곱 번). 원인을 고쳐 배포한 뒤 결제사 대시보드에서 다시 보내면 끝이다. 리허설 8번에서 재전송이 중복 없이 처리되는 걸 이미 봤다

v0.4.3

develop에 쌓인 것을 v0.4.3으로 냈다. 들어간 건 작다.

  • 주문 목록에서 버튼처럼 생긴 링크의 밑줄을 없애고 글자를 가운데로
  • 리허설 도구의 환불 요청 버튼
  • 가끔 실패하던 테스트 하나. 빌링키가 위조되면 거절하는지 보는 테스트가 암호문의 끝에서 두 번째 글자를 A로 바꾸는데(이미 A면 B로), "이미 A인가"를 옆 글자를 보고 판단했다. 바꿀 자리가 원래 A이면 A를 A로 바꾸는 셈이라 아무것도 안 바뀌고, 복호화가 성공해서 테스트가 실패한다. 64번에 한 번쯤이다. 바꿀 그 자리를 보게 고쳤다

열려 있던 이슈 둘

v0.4.2의 PR 본문에 "Closes #209, closes #211"을 써 뒀는데, 이틀 뒤에 보니 두 이슈가 그대로 열려 있었다. GitHub은 PR이 기본 브랜치에 머지될 때만 이 키워드로 이슈를 닫는다. 이 레포의 기본 브랜치는 develop이고, 출시 PR은 main으로 간다. 그래서 출시 PR에 쓴 닫기 키워드는 아무 일도 하지 않는다. 운영에서 고쳐진 걸 다시 확인하고 손으로 닫았다.

남은 것

  • 계정 센터의 환불 요청, 로그인 유지. 둘 다 명세서부터
  • 리허설 중에 찾은 버그 셋. 허브 계정 메뉴의 "로그인" 링크가 없는 주소를 가리킨다. OIDC로 로그인한 뒤에도 계정 센터의 "연결된 사이트"가 비어 있다. 환불된 주문은 주문 목록에 환불 표시가 두 번 뜬다(주문 상태와 환불 요청 상태가 같이 보인다)
  • 그다음이 5단계 stairnolabs.com이다