결제 기능 공통 컴포넌트 구현기

결제 기능 공통 컴포넌트 구현기

- 결제 SDK를 재사용 가능한 React 공통 컴포넌트로 구현한 경험 -

1. SDK 공통 컴포넌트로 구현하게 된 경위

실제 결제 화면에서 처리해야 하는 비즈니스 로직은 단순히 SDK를 호출하는 것 이상의 광범위한 작업이었습니다.

  • 회원 및 비회원 고객 키 설정

  • 결제 금액 변경 사항의 실시간 반영

  • 백엔드 결제 데이터 생성 및 연동

  • 인라인 및 팝업 결제 방식의 동시 지원

  • 결제 성공 및 실패 시 리다이렉트 처리

  • 결제 오류 발생 시 백엔드 상태 업데이트

  • 중복 결제 요청 방지 로직 적용

  • React Native WebView 환경 대응

이러한 로직을 각 화면마다 개별적으로 구현하면 중복 코드가 늘어날 뿐만 아니라, 결제 처리 규칙의 일관성을 유지하기가 매우 어렵습니다. 예를 들어, 특정 화면에서는 SDK 오류를 백엔드에 기록하지만 다른 화면에서는 단순히 콘솔에만 출력하는 등의 파편화 문제가 발생할 수 있습니다.

또한 서비스별로 요구되는 결제 UI가 다르다는 점도 고려해야 했습니다. 결제 약관이나 수단 구성이 다양하고 결제 버튼의 위치와 디자인도 제각각이었기에, 이를 공통 컴포넌트 내부에 고정된 형태로 포함시키기는 현실적으로 불가능했습니다.

따라서 공통 컴포넌트가 SDK 연동과 결제 상태 관리를 전담하되, 실제 결제를 실행하는 시점은 각 화면에서 유연하게 결정할 수 있는 구조를 설계하게 되었습니다.

2. 기대 효과

공통 컴포넌트를 구현하며 목표로 했던 효과는 다음과 같습니다.

첫째, 화면과 SDK 사이의 결합도를 최소화했습니다. 개별 화면은 SDK의 복잡한 초기화 과정을 알 필요 없이 주문 번호, 주문명, 금액, 리다이렉트 URL 등 핵심 데이터만 전달하도록 설계했습니다.

둘째, 결제 처리 규칙의 일관성을 확보했습니다. 결제 데이터 생성부터 최종 승인, 실패 상태 저장에 이르는 전 과정을 공통 영역에서 처리하여 화면별 동작 차이로 인한 오류를 방지하고자 했습니다.

셋째, 인라인과 팝업 방식을 단일 인터페이스로 통합했습니다. 사용자는 결제 방식만 옵션으로 선택하고, 실행 시에는 동일하게 executePayment() 메서드를 호출하는 일관된 개발 경험을 누릴 수 있습니다.

넷째, 유지보수 효율성을 높였습니다. SDK 옵션이 변경되거나 오류 처리 정책이 수정될 때, 모든 화면을 일일이 고치는 대신 공통 컴포넌트 한 곳만 업데이트하여 신속하게 대응할 수 있습니다.

3. 구현 방식

3.1 외부에 결제 실행 인터페이스 제공

결제 버튼의 UI 구성은 개별 서비스 화면의 자율에 맡기되, 공통 컴포넌트는 오직 결제 실행 로직만을 인터페이스로 노출해야 했습니다. 이를 위해 React의 `forwardRef`와 `useImperativeHandle`을 활용했습니다.

export interface RequestPaymentContainerRef {
  executePayment: () => Promise<void>;
}

useImperativeHandle(ref, () => ({
  executePayment: async () => {
    if (!innerPaymentRef.current) {
      throw new Error('Payment component is not ready.');
    }
    await innerPaymentRef.current.executePayment();
  },
}));

컴포넌트를 사용하는 상위 화면에서는 노출된 `ref`를 통해 간편하게 결제를 트리거할 수 있습니다.

const paymentRef = useRef<RequestPaymentContainerRef>(null)

const handlePayment = async () => {
  await paymentRef.current?.executePayment();
};

특히 executePayment는 호출 측에서 실행 실패를 명확히 인지할 수 있도록 Promise<void> 형태로 제공했습니다. 만약 결제 컴포넌트가 준비되지 않은 상태에서 호출되면 예외를 발생시켜, 상위 화면에서 적절한 안내 처리를 유도했습니다.

3.2 인라인과 팝업 결제 분리

인라인 결제는 페이지 내에 수단과 약관을 직접 렌더링하지만, 팝업 결제는 executePayment() 호출 시점에 결제창을 띄우고 데이터를 생성합니다.

이처럼 상이한 메커니즘을 하나의 컴포넌트에서 관리하면 복잡도가 매우 높아집니다. 따라서 `RequestPayment`와 `RequestPaymentPopup`으로 역할을 나누고, 상위 컨테이너에서 이를 선택적으로 사용하도록 구성했습니다.

내부 구현은 다르더라도 외부로 노출되는 인터페이스는 동일하게 유지하여 다형성을 확보했습니다.

3.3 성공·실패 리다이렉트 구성

SDK 결제창에서 결제가 성공했다고 해서 모든 비즈니스 로직이 완료된 것은 아닙니다. 리다이렉트된 URL에서 전달받은 `paymentKey` 등의 정보를 백엔드 승인 API에 전달하여 최종 확정하는 단계가 반드시 필요합니다.

전체적인 프로세스는 다음과 같이 진행됩니다.

  1. 백엔드에 사전 결제 정보를 등록하고 고유 결제 ID를 발급받습니다.

  2. 발급된 ID를 `orderId`로 지정하여 SDK 결제를 요청합니다.

  3. PG사 결제창 처리 후 지정된 성공/실패 URL로 이동합니다.

  4. 성공 시, 백엔드 최종 승인 API를 호출합니다.

  5. 실패 시, 백엔드의 결제 상태를 실패로 업데이트합니다.

  6. 모든 후처리가 완료된 후 최종 목적지 화면으로 이동합니다.

이를 위해 `success-redirect`와 `fail-redirect`라는 중간 브리지 경로를 설계했습니다. 이 단계에서 비즈니스 로직을 완결함으로써 'PG 결제 성공'과 '내부 데이터 처리 완료'를 엄격히 구분하고 데이터 정합성을 높였습니다.

4. 구현 시 고려해야 하는 점

4.1 초기화와 결제 가능 상태

SDK 인스턴스가 생성되었다고 해서 곧바로 결제가 가능한 것은 아닙니다. 인라인 방식의 경우, 결제 수단 및 약관 UI의 렌더링이 완전히 마무리되어야 합니다.

따라서 SDK 객체인 widgets와 실제 렌더링 완료 상태인 ready를 엄격히 분리하여 관리했습니다. 결제 사고를 막기 위해 렌더링이 완료되기 전까지는 버튼을 비활성화하는 등의 방어적 처리를 적용했습니다.

또한 props로 전달된 금액이 변경될 때 SDK 내부 상태도 동기화되어야 하므로, widgets.setAmount()를 적절히 재호출하는 로직을 포함했습니다.

4.2 중복 승인 요청 방지

성공 리다이렉트 화면이 마운트될 때 승인 API를 호출하는데, 이때 새로고침이나 뒤로 가기 등으로 인해 동일한 요청이 중복 발생할 수 있습니다.

프런트엔드 상태 관리만으로는 이를 완벽히 차단하기 어렵습니다. 따라서 네트워크 재시도나 다중 탭 환경까지 고려하여 백엔드 및 PG사 API 레벨에서 멱등성(Idempotency)을 보장하도록 설계하는 것이 필수적입니다.

4.3 URL과 라우팅 환경

공통 컴포넌트는 다양한 라우팅 환경에서 활용되므로 `basePath`의 정규화 처리가 중요합니다. 쿼리 파라미터를 연결할 때 구분자(`?` 또는 `&`) 선택과 오류 메시지 인코딩 등 세밀한 처리가 뒷받침되어야 결제 흐름이 중단되지 않습니다.

언뜻 단순해 보이는 작업이지만, 임베디드 환경이나 복합 경로 구조에서는 이러한 기본 처리가 결제 성공 여부를 결정짓는 핵심 요소가 됩니다.

4.4 팝업과 WebView 환경

팝업 방식은 실행 시점에 이벤트 리스너를 등록하므로, 버튼 중복 클릭으로 인한 리스너 중복 등록을 방지해야 합니다. 실행 중 상태 관리와 언마운트 시의 정리(Cleanup) 로직을 철저히 구현했습니다.

특히 React Native WebView 환경을 위해 `appScheme` 주입 기능을 지원했습니다. 하드코딩을 피하고 애플리케이션 설정에서 동적으로 환경을 구성할 수 있게 하여 확장성을 고려했습니다.

5. 결론

결제 SDK로 단순한 결제창을 구현하는 것은 어렵지 않았습니다. 하지만 진정한 도전은 초기화부터 백엔드 상태 관리까지 이어지는 복잡한 워크플로우를 전사적으로 일관되게 적용하는 것이었습니다.

이번 프로젝트를 통해 결제 기능을 역할별로 세분화하고, `forwardRef`를 통해 캡슐화된 인터페이스를 제공함으로써 사용성을 극대화했습니다.

공통 컴포넌트 도입 이후 새로운 서비스에 결제 기능을 추가할 때 발생하는 비용과 휴먼 에러가 눈에 띄게 줄었으며, 시스템 전반의 유지보수성도 강화되었습니다.

물론 기술적 완성도는 프런트엔드에만 머물지 않습니다. 백엔드의 멱등성 처리와 상태 보정 로직이 함께 어우러질 때 비로소 신뢰할 수 있는 결제 시스템이 완성된다고 생각합니다.

재사용 가능한 컴포넌트란 코드 중복 제거를 넘어, 외부 라이브러리의 복잡성을 격리하고 서비스 전체에 일관된 비즈니스 가치를 제공하는 것이라는 점을 확인한 소중한 경험이었습니다.

chnsik

Site footer