본문 바로가기
BackEnd/Node

신규 결제 시스템 도입기 (중복결제 방지하기)

by sorryisme 2025. 1. 15.

현업에서 새로운 결제 시스템에 대한 추가적인 도입을 진행했다. 기존 카드와 가상계좌 결제뿐 아니라 추가적으로 간편결제가 도입되어 결제를 수행한다. 하지만 결제 수행 간에 여러가지 고려해야할 사항들이 있다. 가장 크리티컬한 문제는 이중 결제이다.

고객의 신뢰도를 잃을 뿐 아니라 다양한 후속 조치를 처리해야하는 문제까지 발생한다. 특히 이번에 도입하는 간편 결제는 환불 기능도 존재하지 않아서 중복 결제의 리스크가 존재하기에 이 문제를 반드시 해결해야한다.

 

해결 방안

  • 중복결제는 막는 방법은 다양하다
    • 프론트에서 연속된 요청을 막는다
    • 유니크한 키 값으로 결제여부를 체크한다
    • 뮤텍스 관련 라이브러리를 사용
    • 락을 사용한다

일단 프론트에서 연속된 요청을 막는 것은 기본으로 수행하였다. 허나 항상 중복결제 클릭을 방지한다고 모든 리스크가 사라지는 건 아니다. 예상치 못한 동작, 여러 결제 버튼에 대한 중복 클릭 방지 누락 등의 이슈로 언제든지 문제가 발생될 수 있다

그렇기에 백엔드에서 처리해야할 사항에 대해서는 위 사항들에 대해서 고려해본 결과 `락을 사용하는 기법`으로 진행하고자 했다.

 

1. 유니크한 키 값으로 결제여부를 체크한다

링크 : 인증 및 기타 헤더 설정 ❘ 토스페이먼츠 개발자센터 ​

 

토스 페이먼츠와 같이 멱등성을 보장하는 방식이라고 보면 될 것 같다. 특히 결제 방식과 같이 예민한 방식에서 위와 같은 멱등성 키를 기반으로 조회하는 방법이 있다.

 

허나 현재 상황에서 클라이언트와 서버 쪽에 대한 개발을 추가로 진행하는 것은 과하다고 생각했다. 현재 개발 인력 상황 상 클라이언트의 로직 수정과 더불어 일정을 수행하기 위해서 힘들다고 판단하였다.

 

또한 대규모 트래픽이 아닌 현재 운영하고 있는 B2B 서비스에서 결제 수행에서는 비관적 락으로 충분하다고 판단했다 물론 트래픽이 추후에 증가될 가능성에 대해서 고려해야겠지만 현재 상황상 가장 적합하다고 판단하였고 멱등성 케이스의 경우 네트워크 유실에 대한 케이스까지 처리된다는 점에서 개인적으로 구현하고자 한다

 

2. 뮤텍스 라이브러리

어플리케이션에서 락을 통한 제어를 위해 사용하는 방법이다. 물론 nestjs로 구성되어 있으며 멀티스레딩 방식으로 구현하지 않았기에 충분히 고려할만 한 상황이지만 문제는 스케일 아웃된 현재 아키텍쳐상 로드 밸런서가 여러 개로 분산되어 request를 처리되었을 때 정상적인 처리가 불가능하다고 판단했다

 

 

위와 같이 스케일 아웃된 상황에서 로드 밸런서가 각각의 서버로 2개의 request를 분산처리하게 되면 사실상 정상적인 처리가 되지 않을 수 있다

 

 

3. 비관적 락 처리

현재 상황에서 가장 빠르고 요건에 맞게 수행할 수 있는 부분은 비관적 락으로 처리하는 방식으로 판단했다

 

적용 전 로직 (예시로 구현했다)
  async processPayment(paymentId: number, paymentDetails: string): Promise<Payment> {
    const queryRunner = this.connection.createQueryRunner();

    // 트랜잭션 시작
    await queryRunner.connect();
    await queryRunner.startTransaction();

    try {
    
      // 동시성 문제를 해결하지 못함
      **const payment = await queryRunner.manager.findOne(Payment, paymentId);**

      if (!payment) {
        throw new ConflictException('결제 정보를 찾을 수 없습니다.');
      }

      if (payment.isPaid) {
        throw new ConflictException('이미 결제가 완료된 상태입니다.');
      }

      // 2. 결제 수행 (여기서는 단순히 상태 변경으로 가정)
      payment.isPaid = true;
      payment.paymentDetails = paymentDetails;

      // 3. 최종 결제 내용 저장
      await queryRunner.manager.save(payment);

      // 트랜잭션 커밋
      await queryRunner.commitTransaction();

      return payment;
    } catch (error) {
      // 트랜잭션 롤백
      await queryRunner.rollbackTransaction();
      throw error;
    } finally {
      // 트랜잭션 종료
      await queryRunner.release();
    }
  }

위 로직 수행 시 결제 여부 확인 단계에서 락을 적용하지 않았을 경우 연속된 요청에 대해서 중복된 결제가 수행될 수 있다. 실제로 결제를 수행하고 최종 결제 내역을 저장하는 단계에서 다른 서버에서 결제 여부 확인하는 로직이 수행된다면 중복 결제가 수행될 수도 있다

 

[그림] 비관적 락에 대한 시퀀스 다이어그램

 

 

적용 후 로직
  async processPayment(paymentId: number, paymentDetails: string): Promise<Payment> {
    const queryRunner = this.connection.createQueryRunner();

    // 트랜잭션 시작
    await queryRunner.connect();
    await queryRunner.startTransaction();

    try {
      // 1. 결제 여부 비관적 락 조회
      const payment = await queryRunner.manager.findOne(Payment, paymentId, {
        lock: { mode: 'pessimistic_write' },
      });

      if (!payment) {
        throw new ConflictException('결제 정보를 찾을 수 없습니다.');
      }

      if (payment.isPaid) {
        throw new ConflictException('이미 결제가 완료된 상태입니다.');
      }

      // 2. 결제 수행 (여기서는 단순히 상태 변경으로 가정)
      payment.isPaid = true;
      payment.paymentDetails = paymentDetails;

      // 3. 최종 결제 내용 저장
      await queryRunner.manager.save(payment);

      // 트랜잭션 커밋
      await queryRunner.commitTransaction();

      return payment;
    } catch (error) {
      // 트랜잭션 롤백
      await queryRunner.rollbackTransaction();
      throw error;
    } finally {
      // 트랜잭션 종료
      await queryRunner.release();
    }
  }

수행 결과 다음과 같은 내용을 확인할 수 있다

 

위 내용에다음과 같은 문제가 있다

 

위의 코드로 중복 결제에 대한 기본적인 방어를 할 수 있으나 다음과 같은 문제가 발생될 수 있다.

  1. 결제 수행이 외부 API에 연동되어있다면 최종 결과에 대한 처리
  2. 외부 API 결제는 수행되었으나 네트워크 유실로 인한 결과가 정상적으로 돌아오지 않았을 경우
  3. 실제 결제는 되었는데 네트워크 에러 발생으로 인한 롤백발생
  4. 실제 결제 내역과 DB 결제 내역이 다른 문제가 발생

위 케이스가 얼마나 발생될지 모르지만 제일 좋은 건 내부적으로도 Idempotency 방식을 수용하고 추가적으로 PG사 또한 Idempotency 결제를 제공하는 업체를 선정하는 것이라고 생각된다.

 

 

2편 : https://sorryisme.tistory.com/99

'BackEnd > Node' 카테고리의 다른 글

신규 결제 시스템 도입기2 (동시성을 고려하기)  (0) 2025.01.17