포트원 API + NestJS(nodeJS) 서버 통합하기

# nodeJS# nestJS# 포트원
포트원 API + NestJS(nodeJS) 서버 통합하기

포트원이란?

출처 : 포트원 공식 https://blog.portone.io/faq_portonepg/

포트원은 여러 PG사(KG이니시스, 토스페이먼츠, 카카오페이 등)를 단일 API로 추상화한 결제 연동 플랫폼이다.

예를 들어서, 카카오페이와 KG이니시스 PG를 연동한다면, 2곳의 개발자센터에서 자료를 뒤져 연동할 필요 없이 포트원 API(SDK)만 딸깍하면 되는 것이다.

포트원 API

포트원 API는 현재 V1(Classic)과 V2 두 버전이 공존한다. 인증 방식과 DTO / endpoint의 차이가 있다. v2이 커버치지 못하는 부분도 존재해서 필요에 따라 적당히 혼합해서 사용해야 한다.

특히나 ai 개발 도구를 사용한다면 옛날 api인 v1을 들고오는 경우가 잦으니, https://developers.portone.io/api/rest-v2/overview?v=v2 을 확인하자.

구분인증 방식
V1 ClassicAPI Secret으로 액세스 토큰 발급 후 사용 (api.iamport.kr)
V2Authorization: PortOne {apiSecret} 직접 사용이 기본값. 토큰 방식(Bearer ACCESS_TOKEN)은 선택지

포트원 SDK

포트원은 @portone/server-sdk (Node.js 기준) 를 공식 제공한다. 프로젝트마다 다르겠지만, sdk를 잘 사용한다면 포트원용 api 클라이언트가 필요하지 않을수 있다.
물론 포트원 자체 api도 잘되어 있지만, 결제 도메인 특성상 직접 api를 구현하려면 난이도가 높아진다

https://developers.portone.io/sdk/ko/v2-server-sdk/readme 상단에 서버/ 모바일/ 브라우저 sdk 문서가 정리되어 있다

설명

SDK는 포트원 API를 직접 호출하는 대신, 타입이 보장된 메서드로 추상화해준다. 결제 조회, 취소, 웹훅 검증 등을 SDK로 처리할 수 있다.

다만 커스텀 에러 핸들링, 도메인 예외 변환, 세밀한 토큰 관리가 필요한 경우 직접 API 호출이 더 유연할수 있다!


결제 플로우

출처 : 포트원 공식 https://portone.io/

다음과 같은 FLOW로 결제 흐름이 진행되게 된다.

클라이언트(React) 에서는 구매 페이지 접근 / 결제창 호출 / 결제 완료 화면 접근 이 가능해야 하고

백엔드(nestJS서버) 에서는 포트원에서 주는 웹훅을 받을수 있어야 하며, 프론트 결제창 호출이 가능한 로직이 있어야 한다.

이 글에서는 서버 단의 구현만 말해보도록 하겠다


nestJS 서버 아키텍쳐 설계하기

결제 연동을 진행한 서버에서는 레이어드 아키텍쳐를 사용했다

설명

이 아키텍쳐를 설명하자면, Controller부터 PortoneApiService 위까지는 포트원의 존재를 모른다.
포트원 응답 구조, 상태값 문자열, 에러 타입이 상위 레이어로 전파되지 않게 설계했다.

포트원 클라이언트 만들기

포트원과 직접 통신하는 코드는 src/global/external/portone/ 하위에만 존재하도록 구현했다.

설명

클라이언트에서 토큰 자동 관리하기

PortOneClient는 Axios 인터셉터로 토큰 발급·갱신을 내부에서 처리하도록 구현했다.

결제 처리하기

결제 처리는 크게 세 단계로 나뉜다.

설명
  1. 결제 준비 및 포트원 결제 키 발급
  2. 클라이언트에서 해당 키로 사용자에게 결제 요청
  3. 서버에서 결제 결과 확인 후 결제 완료 처리

결제 준비하기

클라이언트가 결제 창을 띄우기 전, 서버에서 다양한 비즈니스 로직을 적용하기 위해 paymentSession이라는 개념을 적용했다.

이번 프로젝트에서는 paymentSession 을 생성한 후 해당 결제 세션에 할인 / 쿠폰 등을 적용하는 식으로 설계했다.

설명

이 단계는 필수는 아니지만, 결제 전에 적용할 비즈니스 로직이 있다면 필요한 과정인것 같다.
우리 프로젝트에서는 포인트 사용과 쿠폰 사용 등등의 로직이 있기 때문에 이러한 개념을 적용했다.

결제 키(portonePaymentId) 클라이언트에게 내리기

클라이언트가 결제창을 열기 직전, 서버에 결제 키 발급을 요청한다.
portonePaymentId는 서버가 생성해서 클라이언트에 내려주는것이 안전하다

클라이언트는 이 PaymentId를 가지고 결제를 처리하게 된다.

설명

클라이언트는 이 portonePaymentId를 포트원 결제창에 그대로 넘긴다.

서버가 키를 직접 만들기 때문에, 나중에 웹훅이 들어왔을 때 어느 결제 건인지 바로 찾을 수 있다.

결제 완료 처리하기 (앱)

결제가 끝나면 클라이언트가 서버에 완료를 알린다.

설명

이때 클라이언트(앱)에서 주는 api요청은 결제 금액 등이 위변조 되어있을수 있기 때문에, 해당 결제 ID로 들어온 결제가 있는지 포트원 api로 다시 확인해야 한다. (위 사진 3번 참고)

결제 완료 처리하기 (웹훅)

결제 완료 이후, 클라이언트가 결제 후 앱을 꺼버릴 수 있다.
그래서 포트원이 서버로 직접 보내는 웹훅도 동일하게 처리한다.

설명

두 API 모두 같은 portonePaymentId 값으로 들어오기 때문에 먼저 들어온 API가 처리하고, 나중에 들어온 쪽은 무시하도록 설계하면 된다.

결제 검증하기

결제 처리 요청을 받았다면 다음과 같은 순서로 결제가 정상적으로 처리됐는지 확인하면 된다

이렇게 검증을 마친 후, 결제 완료 이후의 비즈니스 로직을 작성하면 된다.
결제 검증은 이런 FLOW대로 하도록 포트원 공식 문서에서 제시하고 있다.

예외처리

외부 예외와 도메인 예외를 분리하도록 코드를 작성하면 된다

설명

서비스 코드에서 외부 예외를 잡아서 도메인 예외로 재포장한다.

결제 쪽이다보니 Exception 상황이 많다. 이 부분은 실제 프로덕션 운영 전에 ai 툴로 엣지 케이스가 있는지 각 프로젝트에 맞게 한번 더 검증해야 한다..

웹훅 서명 검증하기

포트원에서 보내주는 웹훅 api를 개발할때, 정상적인 요청임에도 불구하고 서명 검증이 실패하는 경우가 발생했다.

포트원 웹훅 서명은 JSON 파싱 전 원본 바이트를 HMAC으로 검증한다.

NestJS의 body-parser가 요청을 자동 파싱하기 때문에, 파싱된 객체를 JSON.stringify하면 키 순서나 공백 차이로 서명 검증이 실패한다.

커스텀 @RawBody() 데코레이터로 원본 Buffer를 따로 꺼내야 한다.

서명 검증은 @portone/server-sdk의 PortOne.Webhook.verify()를 사용하자.
직접 HMAC을 구현하면 타임스탬프 검증, 리플레이 어택 방지 등 구현할껏이 많아 귀찮아 지기 때문이다

이 글에서는 상세한 코드 등을 적지는 않았지만, 이 플로우대로 ai에이전트와 함께 작업한다면 쉽게 연동할 수 있을 것이다