스트라이프 API 새로운 버전으로 옮기기
Problem
라이너는 구독으로 먹고 살고 있습니다. 따라서 더 편하고 쉬운 결제가 매출 성장에 큰 도움을 줍니다. 라이너에서 가장 많이 쓰이고 있는 결제 모듈은 스트라이프입니다. 하지만 스트라이프 결제 모듈의 버전이 낮아 새롭게 제공되는 좋은 기능들을 사용하지 못하고 있었습니다. 특히 그 중에서도 애플 페이, 구글 페이, 알리 페이에 대한 지원이 안되고 있었습니다. 라이너 사용자 중에 애플 페이가 가능한 미국 사용자가 많고 적지 않은 수의 중국어권 유저들이 있었기 때문에 결제 모듈의 업데이트가 반드시 필요했습니다. 이외에도 더 고급스러운 fraud detection, 3D secure 결제, 더 깔끔한 결제 UI/UX 등을 사용할 수 있었습니다.
결제 모듈을 업데이트해야 하는 이유는 충분했지만 레거시 시스템을 개선해야 한다는 부담이 있었습니다. 이는 언젠가 찾아오는 빚쟁이의 독촉과 같습니다. 프로그램이 마주하는 현실 세계의 상황은 항상 변하기 때문에, 기존 시스템 또한 변화가 필요한 시기가 찾아오기 마련입니다. 물론 레거시 코드를 건드리는 것은 매우 고통스러운 일입니다. 이번에 진행했던 스트라이프 결제 모듈 업데이트 작업은 고통스러웠습니다. 하지만 막상 하고나니 서비스에 대한 이해가 대폭 올라가서 만족스러웠습니다. 영광의 상처랄까요.
Solution
스트라이프 결제 API의 버전이 올라가며 기존의 flow가 거꾸로 바뀌었습니다. 새로운 API 스펙에 맞추어 input / output만 적절히 변경하면 될 줄 알았던 저는 크게 당황했습니다. 자세한건 stripe migration guide에도 나와있습니다.
>> 스트라이프 가이드
You may have used the legacy version of Checkout to create a token or source on the client, and passed it to your server to create a customer and subscription. The new version of Checkout’s server integration, however, reverses this flow.
스트라이프가 업데이트 되면서 성공 / 실패 여부까지 스트라이프에서 처리하게 되었습니다. 라이너는 결제 성공 여부가 아닌 프로세스 자체에 대한 실행 성공 여부만을 처리할 수 있게 되었습니다. 따라서 유저 결제 성공 여부는 webhook만을 사용하여 확정해야 했습니다. 또한 customer와 subscription을 미리 생성하고 결제 프로세스를 태웠던 기존 flow에서 session이라는 것을 통해 스트라이프가 customer와 subscription 모두 자동으로 생성하도록 변경되었습니다. 말 그대로 이제 스트라이프 클라이언트는 세션 정보만 가져와서 결제 창만 띄우면 되는 것이었습니다.
기존 프로세스의 시작은 아래와 같았습니다. 스트라이프가 결제만 진행하고 이에 대한 성공 / 실패 여부는 라이너에 맡기는 구조였습니다. 결제 성공시 라이너는 프리미엄으로 업그레이드가 가능한 임시 프리미엄 플랜을 유저에게 부여합니다. 이후 결제 성공 영수증(invoice.paid
)이 스트라이프 webhook을 통해 호출되면 임시 프리미엄 플랜을 프리미엄 플랜으로 확정하는 구조입니다.
새로운 stripe 결제 모듈에서는 session만 잘 관리하면 유저가 경험하는 결제 프로세스는 모두 마무리 됩니다. 이를 다음과 같이 표현하여 구현하였습니다.
한 가지 수정한 것이 있다면 바로 스트라이프 customer의 생성 부분입니다. session 결제 성공 시점에 스트라이프가 생성한 customer와 liner 사용자가 매핑된다면, 미결제 session들이 동시에 결제되는 특수한 경우에 중복 매핑이 발생할 수 있습니다. 이는 스트라이프의 customer는 ID를 제외하고 별도의 고유 식별자 없이 계속해서 생성되기 때문입니다. 따라서 session을 만들 때에 미리 customer와 liner 사용자의 매핑을 만들어두고 이후 결제에서는 미리 생성된 customer 정보만을 사용하도록 했습니다.
session이 생성되면 생성된 session의 ID를 가지고 스트라이프의 결제 프로세스를 태웁니다. 이후부터는 모두 스트라이프가 알아서 진행합니다. 심지어 성공 / 실패에 대한 페이지 리다이렉트도 입력 받은 url을 바탕으로 알아서 해줍니다. 깔끔합니다. 하지만 위의 새로운 결제 프로세스에서는 정말 유저가 결제
만 상황이기 때문에 다음과 같은 일들이 남아있습니다.
1. 영수증(invoice) 정보 저장
2. 구독 정보(subscription) 정보 저장
3. 결제 성공 / 실패에 따른 사용자의 멤버십 변경
레거시 시스템에서는 결제 성공 시에 subscription을 저장하고 사용자에게 임시 프리미엄 플랜을 발급했습니다. 이후 영수증 지불 event에 영수증 정보가 오면 해당 status에 맞추어 사용자의 플랜을 변경했습니다.
새로운 시스템에서는 결제 이후의 일에 대해서는 모든 것이 webhook 기반으로 이루어져야 했습니다. 따라서 다음과 같이 해결하였습니다.
session이 성공하는 경우, invoice.paid
라는 이벤트도 오지만 그와 함께 checkout.session.completed
라는 이벤트가 옵니다. 이는 session 결제가 성공적으로 이루어지는 경우에 발생하는 이벤트입니다. 이 요청이 webhook으로 들어오며 이때 session에 포함된 구독 정보가 함께 오게 됩니다. 이러한 정보를 바탕으로 subscription 정보를 저장합니다. 또한 사용자의 멤버십을 프리미엄으로 업그레이드 합니다.
동시에 들어오는 invoice.paid
의 경우 invoice 정보를 DB에 저장하고 멤버십을 프리미엄으로 업그레이드합니다. 즉 최초 결제의 경우 2번의 업그레이드 호출이 이루어집니다.
구독이 갱신되는 경우는 invoice.paid
라는 이벤트만을 사용하여 멤버십 상태를 업데이트 합니다. 반대로 invoice.payment_failed
의 경우 멤버십을 다시 basic으로 설정합니다. 라이너에는 구독 만료를 총괄적으로 관리하는 시스템이 별도로 존재하기 때문에 더블 체크가 됩니다.
정리하면 최초 결제의 경우 checkout.session.completed
를 통해서, 이후 구독 갱신의 경우에는 invoice.paid
를 통해서 멤버십을 관리하게 됩니다. 스트라이프 문서가 워낙 깔끔하게 잘 정리되어있기 때문에 결제 API를 붙이는데에는 큰 어려움이 없었습니다. 다만 관련 레거시 코드를 읽고 새로운 기능에 잘 녹여내는 것이 중요한 포인트였습니다.
One more thing?
마지막으로 개발하면서 공유 드릴만한 포인트가 있어 이 부분을 말씀드리려 합니다. 스트라이프 webhook이 클라이언트를 호출하는 경우 stripe-signature
라는 것과 함께 암호화된 body를 보내게 됩니다. 이때 body를 raw하게 읽지 않으면 스트라이프 문서에 적힌 아래의 코드가 작동하지 않습니다.
app.post('/webhook', bodyParser.raw({type: 'application/json'}), (request, response) => {
const sig = request.headers['stripe-signature'];
let event;
try {
event = stripe.webhooks.constructEvent(request.body, sig, endpointSecret);
}
catch (err) {
response.status(400).send(`Webhook Error: ${err.message}`);
}
라이너 백엔드는 express를 사용하고 있었고, 자동으로 json 파싱을 해주는 middeware를 사용하고 있었습니다. 이 부분 때문에 raw body를 읽어오지 못하여 계속해서 시그니처 failure가 났습니다. 여러 방법을 찾던 도중 아래의 블로그에서 제시한 방법을 찾았습니다.
app.use(bodyParser.json({
verify: (req, res, buf) => {
req.rawBody = buf
}
}));
기존에 사용하던 bodyParser.json()에 obj만 조금 변경해주니 문제없이 동작했습니다. raw_body를 읽는 별도의 parser 없이도 작동한다는 점에서 마음이 편안해지는 해결책이었습니다.
const { 'stripe-signature': stripeSig } = req.headers;
let event;
try {
event = stripe.webhooks.constructEvent(req.rawBody, stripeSig, checkoutConf.signingSecret);
} catch (err) {
return res.status(400).json('invalid signature');
}