logo
작성일

Stairno 4. 결제 만들기 (테스트 모드)

Authors
  • avatar
    Name
    seren-wib
    Twitter
Contents

로드맵 4단계 전반부. 결제를 테스트 모드로 만들어 첫 출시(v0.4.0)까지 간 이야기다. 운영 리허설은 4a편에 따로 있다. 전체 그림은 Stairno 0. 전체 로드맵에 있다.

한 줄 요약

10월 18일 오후부터 밤까지, 공용 API에 결제를 붙였다. 사이트가 주문을 만들면 API가 결제사와 이야기하고, 결제가 끝나면 "이 사람은 이 제품을 써도 된다"는 권한을 기록한다. 카드는 계정 센터에서 한 번 등록하고, 구독은 매일 자동으로 갱신된다. 전부 테스트 모드라 실제 돈은 오가지 않는다.

1. 무엇을 만드나

결제사통화만든 것
토스페이먼츠원화(KRW)1회 결제, 카드 등록(빌링키), 구독 자동결제, 취소, 웹훅
PayPal달러(USD)1회 결제, 환불, 웹훅

계정 센터 화면에는 카드 등록·삭제·기본 카드 바꾸기, 주문 내역, 구독 해지와 재개, 구매한 데스크톱 프로그램 다시 받기가 들어간다. 결제 영수증, 갱신 실패, 환불 결과 메일도 보낸다.

실제 결제로 바꿀 때는 테스트 열쇠를 진짜 열쇠로 바꾸고 사업자 정보를 적는 것만으로 끝나게 설계한다. 그 전환은 사업자등록 뒤인 9단계다.

2. 먼저 정한 규칙들

결제는 실수가 곧 돈 사고다. 그래서 코드보다 규칙을 먼저 정했다. 전부 레포의 작업 규칙 문서에 적혀 있고, AI 에이전트도 이 규칙을 따른다.

  1. 결제사는 어댑터 뒤에만 둔다. 주문 처리 코드는 "결제 승인", "취소" 같은 공통 기능만 알고, 토스인지 PayPal인지 모른다. 테스트는 진짜 결제사 대신 가짜 어댑터로 돈다. "다음 결제는 거절해", "결제사가 응답 없는 척해" 같은 상황을 마음대로 만든다
  2. 금액은 정수 + 통화. 1,000원은 1000 KRW, 1달러는 100 USD(센트). 소수로 계산하지 않고, 원과 달러를 섞어 더하지 않는다
  3. 카드 번호를 받는 코드는 없다. 결제사 화면이 받는다. 우리는 빌링키(그것도 암호화해서)와 카드사, 끝 4자리만 갖는다
  4. 서버가 금액을 다시 확인한다. 브라우저가 보낸 금액이 주문 금액과 다르면 거절하고 결제사에 승인 요청을 보내지 않는다
  5. 웹훅은 멱등하게. 같은 알림이 두 번 와도 한 번만 처리한다
  6. 주문은 상태 기계를 따른다. 허용된 이동만 일어난다
  7. 결제만 끄는 스위치. 장애 때 로그인은 살리고 결제만 멈춘다
  8. 로그에 개인정보를 남기지 않는다. 이메일, 토큰, 카드 정보 대신 주문 번호와 결제사 번호만

3. 만든 순서

PR 하나에 기능 하나. 아래 순서대로 하나씩 테스트 먼저 쓰고 넣었다.

순서만든 것쉽게 말하면
1결제 DB 설계상품, 가격, 주문, 결제, 카드, 구독, 권한, 환불 요청, 웹훅 기록 표
2어댑터, 가짜 어댑터, 상태 기계결제사와 대화하는 틀과, 주문이 움직이는 규칙
3상품 등록 명령, 결제 스위치운영자가 명령어로 상품을 등록한다. 관리자 화면은 안 만든다
4사이트 신분, 주문 API사이트가 "나는 등록된 Stairno 사이트다"를 밝히고 주문을 만든다
5토스 결제 승인결제창에서 돌아온 결제를 확인하고 확정한다
6토스 웹훅토스가 보내는 결제 상태 알림을 받는다
7PayPal 결제달러 결제. 승인 화면에 다녀와서 확정(캡처)한다
8카드 등록과 삭제토스 카드 등록창 → 빌링키 발급 → 암호화해서 저장
9계정 삭제 때 빌링키도 삭제계정을 지우면 결제사에 저장된 카드 열쇠도 지운다
10결제 수단 화면계정 센터에서 카드 등록, 기본 카드, 삭제
11빌링키 결제저장된 카드로 결제
12구독 시작구독 상품을 사면 기본 카드로 바로 결제하고 구독을 연다
13기본 카드를 바꾸면 구독도 따라감처음엔 빠져 있었다. 기본 카드를 바꿔도 구독은 옛 카드에 묶여 있어서 따로 고쳤다
14매일 자동 갱신하루 한 번 Cron이 기간이 끝난 구독을 결제한다
15구독 해지와 재개계정 센터에서만. 사이트가 대신 해지할 수는 없다
16권한 조회사이트가 "이 사람 이 제품 써도 돼?"를 묻는다
17다운로드 링크산 사람에게 10분짜리 다운로드 주소를 준다
18환불 요청과 운영자 환불 명령사이트가 환불을 요청하면 운영자가 확인하고 명령어로 환불한다
19결제 메일영수증, 갱신 실패, 환불 결과. 주문마다 종류별로 한 번만
20주문 내역, 다시 받기 화면계정 센터에서 내 주문과 받을 수 있는 프로그램

4. 결제 한 번이 실제로 지나가는 길

사용자가 Labs에서 1,000원짜리 프로그램을 산다고 하자. (Labs는 아직 없으니, 지금은 4a편의 리허설 사이트가 그 자리를 대신한다.)

  1. 사이트가 주문을 만든다. 사이트는 자기 신분(사이트 토큰)과 구매자 신분(사용자 토큰)을 같이 보낸다. API는 상품 가격을 DB에서 읽어 주문을 pending으로 만든다. 가격은 사이트가 정하지 않는다. 30분 안에 결제 안 하면 만료된다
  2. 토스 결제창이 열린다. 사용자가 카드를 넣고 인증한다. 아직 돈은 안 나갔다
  3. 사용자가 사이트로 돌아온다. 토스가 결제 번호와 금액을 붙여 사이트로 돌려보낸다
  4. 사이트가 API에 "확정해 줘"를 보낸다. API는 순서대로 확인한다
    • 이 사이트의 주문이 맞나
    • 이미 처리한 결제 번호인가(그렇다면 처음 결과를 그대로 돌려준다)
    • 주문이 아직 pending인가
    • 금액이 주문과 같은가. 다르면 주문을 failed로 두고 거절한다. 토스에는 승인 요청을 보내지 않는다
  5. API가 토스에 승인을 요청한다. 여기서 돈이 나간다. 토스가 승인한 금액을 한 번 더 비교한다
  6. 결제 완료, 주문 fulfilled, 권한 생성. 이 셋을 한 번에 기록한다
  7. 영수증 메일이 사용자 언어로 간다
  8. 나중에 토스의 웹훅이 온다. 이미 처리한 결제라 아무것도 바꾸지 않는다. 만약 사용자가 3단계에서 창을 닫아 버렸다면, 이 웹훅이 대신 6단계를 해 준다

5. 만들면서 부딪힌 것

대화형 트랜잭션이 없다

보통은 "결제 기록 쓰기 + 주문 상태 바꾸기 + 권한 만들기"를 트랜잭션 하나로 묶는다. 그런데 Workers에서 Neon에 붙는 방식(HTTP 연결)은 요청 하나하나가 따로라서, 여러 요청에 걸친 트랜잭션을 열어 둘 수 없다.

그래서 상태를 바꾸는 일은 SQL 한 문장으로 쓰기로 했다. 그리고 그 문장에 "주문이 이 상태들일 때만 바꿔라"는 조건을 붙인다. 허용 상태 목록은 상태 기계 표에서 자동으로 만든다. 그러면 두 요청이 동시에 같은 주문을 바꾸려 해도, 먼저 바꾼 쪽만 성공하고 나머지는 조건에 안 맞아 아무 일도 안 일어난다.

웹훅은 저장보다 확인이 먼저

웹훅을 받으면 "받았음"을 먼저 저장하고 나중에 확인하는 게 자연스러워 보인다. 그런데 그러면 위조된 알림이 진짜 알림의 이벤트 번호를 먼저 차지할 수 있다. 진짜가 나중에 오면 "이미 받은 번호"로 버려진다. 그래서 순서를 확인 → 저장 → 처리로 했다. 토스의 결제 알림에는 서명이 없어서, 알림을 받으면 토스에 그 결제를 직접 다시 물어보고 토스가 답한 상태만 믿는다.

다운로드 링크를 직접 서명한다

다운로드 파일은 Cloudflare R2에 둔다. R2에서 "10분짜리 다운로드 주소"를 만들려면 별도 열쇠가 하나 더 필요한데, 비밀 값이 늘어나는 게 싫었다. 그래서 다운로드 주소를 API 자신의 주소로 만들고, "이 파일, 이 시각까지"를 API가 직접 서명한다. 사용자가 그 주소로 오면 API가 서명과 시간을 확인하고 파일을 흘려보낸다.

결제 거절을 장애와 구분하기

"카드 한도 초과"는 결제 거절이고, "토스 서버가 응답 없음"은 장애다. 거절이면 주문을 failed로 닫고 사용자에게 알리면 되지만, 장애면 주문을 그대로 두고 다시 시도할 수 있게 해야 한다. 토스 문서에서 오류 코드를 하나씩 확인해서, 어댑터가 코드로 둘을 나누게 했다.

6. 중간에 바꾼 것: Git Flow

3단계까지는 PR이 CI를 통과하면 바로 main에 넣고, main은 바로 운영에 올라갔다. 결제를 붙이면서 이게 불안해졌다. 결제 기능이 반쯤 들어간 상태로 운영에 올라가는 건, 로그인 화면 글자 하나 바뀌는 것과 무게가 다르다.

그래서 10월 18일 저녁, 결제 PR을 20개 넘게 넣은 시점에 Git Flow로 바꿨다.

  • 평소 작업은 develop에 모은다. develop에 들어간다고 운영에 올라가지 않는다
  • 로드맵 단계 하나가 끝나면 release 브랜치를 만들어 main으로 보낸다. 그때 한 번 배포한다
  • 운영에서 급한 버그가 나오면 hotfix로 운영 버전만 고친다
  • 버전 번호의 가운데 숫자는 로드맵 단계 번호다. 4단계 첫 출시가 v0.4.0

확인도 층을 나눴다. 커밋마다 내 컴퓨터에서 바꾼 테스트만 → develop에 올릴 때 CI 전체 → main으로 갈 때 CI 필수 → 배포 직전 테스트 한 번 더 → 운영에서 리허설. 마지막 층이 4a편이다. 결제사가 우리 서버로 돌아오는 주소와 웹훅은 운영에서만 진짜로 확인할 수 있어서다.

7. v0.4.0, 그리고 v0.4.1

밤 10시쯤 결제 메일, 주문 내역, 다시 받기 화면까지 넣고 첫 출시 v0.4.0을 운영에 올렸다.

그리고 계정 센터 화면을 다시 봤다. 페이지가 프로필, 기기, 연결된 사이트, 결제 수단, 구독, 주문, 다운로드, 삭제로 흩어져 있어서 뭐가 어디 있는지 찾기 어려웠다. 구글, 마이크로소프트, 애플, GitHub, 카카오의 계정 화면을 비교해 보고 이렇게 바꿨다.

  • 한 페이지에 네 덩어리: 계정 관리 → 결제 → 내 제품 → 보안. 왼쪽 목차를 누르면 그 자리로 스크롤하고, 지금 보고 있는 덩어리가 목차에 표시된다
  • 계정 삭제는 맨 아래, 보안 덩어리의 끝. 실수로 누르기 어려운 자리다
  • 비밀번호 변경이 빠져 있었다. 넣었다. 바꾸면 다른 기기의 로그인을 전부 끊고, "비밀번호가 바뀌었습니다" 메일을 보낸다. 내가 바꾼 게 아니면 바로 알 수 있게
  • 국가·지역과 시간대. 주문 날짜를 그 사람 시간대로 보여 준다
  • 프로필 사진

생일, 성별, 전화번호, 주소는 안 받기로 했다. 쓸 기능이 없는 개인정보는 받지 않는 게 개인정보보호법의 원칙(최소 수집)이고, 털릴 것도 줄어든다.

자정 직후 v0.4.1로 올렸다. 여기까지가 4단계 구현이다. 남은 건 이게 운영에서 진짜로 되는지 확인하는 일이다.

다음 글: Stairno 4a. 운영 결제 리허설