[주문 결제 #3] pg사의 에러는 어떻게 핸들링할까? (NestJS)

DatQueue·2024년 2월 11일
0
post-thumbnail

🧃 결제 승인시 발생하는 에러

이전 포스팅의 마지막에서 언급하였다시피 "토스 페이먼츠" pg사 측에서 던져준 에러를 어떻게 핸들링하는 것이 좋을지 고민해 볼 필요가 있다.

"결제 승인"이란 작업시에 발생할 수 있는 에러엔 어떤 것들이 있을까?

결제 승인 에러 코드 - 개발자 센터

위의 링크를 통해 결제 승인 시 발생할 수 있는 모든 에러를 확인할 수 있다. 아래와 같은 에러 코드들이 발생할 수 있는 에러로써 제시된다.

충분히 자주 발생할 수 있는 에러도 있고, 동시에 거의 발생할 일이 없을거 같은 에러 또한 보인다.


> 모든 에러를 다 처리해 주어야 할까?

위 질문의 대답으로 나는 "그렇다"를 택하였다. 결제와 같이 금전 거래가 발생하는 것은 굉장히 예민한 부분이라 생각한다. 서드파티와 같은 외부 라이브러리를 사용하면서 발생하는 에러는 왠만하면 클라이언트측에 공개하지 않는 것이 1차적 접근이지만 결제의 경우는 엄연히 별개이다.

아래의 에러들을 잠깐 살펴보자.

결제 승인 과정에서 충분히 발생할 수 있는 에러이다. 이와 같은 에러가 발생하였을 경우 서버는 클라이언트에게 정확한 에러 상황 메시지를 전달하여 유저가 불편함을 겪지 않게끔 해줄 필요가 있다.

반면 아래와 같은 에러는 어떠할까?

"잘못된 시크릿 키" 연동. 이러한 에러는 애플리케이션 서버 측의 엄연한 실수이다. 해당 에러뿐 아니라 몇 가지 더 존재하게 되는데, 이는 굳이 유저에게 알려줄 필요도 없고 보안상의 이슈가 될 수 있는 에러이다.

하지만, "개발 단계"에서는 충분히 핸들링 해줄 가치가 있고 물론 운영단계에서도 어떤 일이 일어날지 모르니 pg사로부터 받아올 필요가 있다.

이렇게 다양한 케이스의 에러가 존재하고 본인은 어떻게 이를 처리해 주었는가에 대해 경험을 공유해보고자 한다.



🧃 Custom Exception Filter 설계


일전에 프로젝트 빌딩 단계를 다루는 글 중 "에러를 어떻게 소통하는 가"에 대해 다뤄본적이 있다.

해당 글 내부에서 pg사와 같은 서드파티 에러를 Nestjs에서 어떤식으로 핸들링할지 소개했었고 그 방법으로 "에러 필터"를 적용하였다.

에러를 소통해보자 - velog 포스팅

> TossPaymentsConfirmFilter

결제 승인 시 발생 할 수 있는 외부 에러를 핸들링 해주는 커스텀 필터

// payments-confirm.filter.ts

import { ArgumentsHost, Catch, ExceptionFilter, HttpStatus } from "@nestjs/common";
import { AxiosError } from "axios";
import { Request, Response } from "express";
import { UnCatchedExceptionErrCodeEnum } from "../../enums/uncatched-exception.enum";
import { confirmErrorCodeMessageObject } from "./messages/tossPayments-errcode-message.object";

@Catch(AxiosError)
export class TossPaymentsConfirmFilter implements ExceptionFilter {

  catch(exception: any, host: ArgumentsHost) {
    const ctx = host.switchToHttp();
    const response = ctx.getResponse<Response>();
    const request = ctx.getRequest<Request>();
    const curr_timestamp: string = new Date().toLocaleString("ko-KR", { timeZone: "Asia/Seoul"});

    const ex = handlingException(exception);

    let responseStatus = HttpStatus.INTERNAL_SERVER_ERROR;

    switch (exception.response.status) {
      case 400:
        responseStatus = HttpStatus.BAD_REQUEST;
        break;
      case 401:
        responseStatus = HttpStatus.UNAUTHORIZED;
        break;
      case 403:
        responseStatus = HttpStatus.FORBIDDEN;
        break;
      case 404:
        responseStatus = HttpStatus.NOT_FOUND;
        break;
      default:
        responseStatus = HttpStatus.INTERNAL_SERVER_ERROR;
    }
    
    response.status(responseStatus).json({
      errorCode: ex.code,
      status: responseStatus,
      msg: ex.message,
      timestamp: curr_timestamp,
      path: request.url,
    });
  }
}

interface ExceptionStatus {
  code: string;
  message: string;
}

const handlingException = (err: AxiosError): ExceptionStatus => {
  const code = err.response.data['code'];

  if (!!code) {
    return Object.keys(confirmErrorCodeMessageObject).includes(code) 
      ? confirmErrorCodeMessageObject[code] 
      : { code: UnCatchedExceptionErrCodeEnum.UNCATCHED, message: "알 수 없는 오류로 인한 결제 승인 거부 :) 고객센터 문의 바람" }
  }

  return { code: UnCatchedExceptionErrCodeEnum.UNCATCHED, message: "알 수 없는 오류로 인한 결제 승인 거부 :) 고객센터 문의 바람" };
}

발생할 수 있는 에러 상태는 400, 401, 403, 404, 500 으로 다양하다.

각 상태에 대한 모든 에러를 받아주도록 하며, 에러에 대한 정의는 별도의 객체로 나타내 필터 클래스를 깔끔히 해준다.

조금 헷갈릴 수 있지만, handlingException() 내부에서 선언한 아래의 코드와

const code = err.response.data['code'];

공통 응답 객체를 이루는데 사용될 code다르다.

{ code: UnCatchedExceptionErrCodeEnum.UNCATCHED, message: "알 수 없는 오류로 인한 결제 승인 거부 :) 고객센터 문의 바람" }

첫 번째, err.response.data를 통해 접근한 code는 토스 페이먼츠의 에러 응답 코드에 접근한 경우이고 두 번째 code는 애플리케이션 서버에서 지정한 errorCode이다. 이는 별도의 enum을 통해 관리되고 아래에서 소개하도록 하겠다.


> confirmErrorCodeMessageObject && PaymentConfirmExceptionErrCodeEnum

더 좋은 방법이 있는지는 모르겠지만 토스 페이먼츠 문서에서 제시하는 결제 승인 관련 "모든" 에러를 핸들링 하기 위해 위와 같은 수작업을 해줄 수 밖에 없었다.

항상 깨끗하고 깔끔한 코드만 존재하는 것이 아니라 위와 같은 작업또한 서비스를 구성하고 운영하는데 있어 그만큼 중요한 코드라 생각이 들었으므로 한땀 한땀 작성해주었다.

클라이언트 측 또한 발생할 수 있는 에러를 개발자 문서를 통해 확인할 수 있지만 이렇게 자체 서버의 "공통 응답 에러"와 연결짓기 위해선 서버 측에서 관리하는 과정이 필요했던 것이다.

여기서 주의해야 할 점은 아무리 토스 페이먼츠에서 400번대로 넘겨져온 에러라 해도 보안상 클라이언트에게 넘겨줄 필요가 없는 에러의 경우는 "알 수 없는 에러 _UnCatched Error"로 교체해주도록 하는 것이다.


> 라우트 핸들러에 적용

앞서 생성한 커스텀 예외 필터를 라우트 핸들러 함수(API) 레벨에 주입해 줌으로써 pg사로 부터 넘어온 에러를 비즈니스 로직까지 끌고 오지 않고, 유연하게 처리해 줄 수 있게 되었다.

추후, "결제 취소"와 관련된 에러도 동일하게 처리할 수 있다.


결제는 민감하고 어떤 일이 발생할지 예측할 수 없다. 에러가 "완전히" 일어나지 않게 모든 상황을 컨트롤 하는 것은 사실상 불가능에 가깝고 에러가 발생하더라도 예외 처리를 통해 문제 상황을 인지하고 적절한 제스처를 취해주는 것이 중요하다.

클라이언트 측은 애플리케이션과 공유하는 자체 에러 코드를 통해 "유저에게 보여줄 에러", "그렇지 않을 에러"를 판별해(물론 이는 단순 클라이언트 개발자만이 다룰 문제는 아닐 것이다) 결제 승인시 발생하는 에러에 대해 유연한 처리를 수행할 수 있다.


다음 포스팅 예고

다음 포스팅부턴 본격적으로 "주문 프로세스"에 들어갈 것이다. 주문 플로우는 어떻게 진행되고 부가적으로 처리해주어야 할 작업엔 어떤 것들이 있고 등의 경험을 함께 공유해보고자 한다.

profile
You better cool it off before you burn it out / 티스토리(Kotlin, Android): https://nemoo-dev.tistory.com

0개의 댓글