[포도상점] Nice Pay 연동

goodsmell·2025년 9월 22일

PODO-STORE

목록 보기
2/2

포도상점 - 나이스페이 결제 연동 기록

스택 : React + Vite + TypeScript + Tailwind
목표 : AUTHNICE.requestPay() 로 결제창 호출 (승인/확정은 백엔드에서 처리)

0. 전체 플로우 요약

  1. FE: 결제 버튼 클릭
    • AUTHNICE.requestPay() 로 결제창 호출
  2. NICEPAY: 사용자 인증 완료 후 returnUrl(백엔드) 로 POST 전송
  3. BE(returnUrl):
    • authResultCode==='0000' 확인
    • 응답의 tid로 승인(결제 확정) API 호출
    • 성공 시 주문 상태 PAID → 성공 페이지로 리다이렉트
  • 취소/실패 시에는 주문을 그대로 두고(혹은 CANCELED) 재시도가 가능하게 해야 함.

✨ 1. 준비

  1. https://start.nicepay.co.kr/merchant/login/main.do 회원가입
  2. 테스트 상점 개설 (대표님 계정으로 상점 개설 → 나는 멤버정보에 개발자로 추가)
  3. [개발정보] → +발급 → 클라이언트 키 / 시크릿 키 발급

✨ 2. SDK 로드

NICEPAY JS SDK 로드

pay.nicepay.co.kr/v1/js/ JS SDKAUTHNICE.requestPay() method 호출하여 결제창 노출

💡 방식 선택 — 두 가지 방식
공통: SDK URL은 https://pay.nicepay.co.kr/v1/js/

✔️ 방법 1) index.html에 <script src = "..."></script>에 넣기

  • 장점 >
    - 간단하고 어디서든 window.AUTHNICE 바로 사용 가능
    • 초기화 타이밍 이슈 적음
  • 단점 >
    - 모든 페이지에서 항상 sdk를 내려받아서 초기 로딩이 무거워짐
    • 결제 페이지가 아닌 곳에서도 불필요한 js라 로드됨
  • 추천 상황 > 아주 작은 앱이거나 대부분의 화면에서 결제를 자주 띄우는 구조일 때

✔️ 방법 2) 컴포넌트 만들어서 동적 로드

  • 장점 )
    - 결제 화면에서만 sdk로드 -> 초기 번들 가벼움
    • 라우트 단위로 로드/해제 제어 가능 (명확한 의존성 관리)
  • 단점 )
    - 첫 클릭 전에 로드 타이밍을 신경써야 함 (버튼 비활성화/ 프리로드로 해결)
  • 추천 상황 ) 대부분의 서비스에서 이 방법을 사용하는 듯. 결제는 특정화면에서만 쓰로 초기 로딩 가벼움이 중요할 때 사용

난 결제 페이지에서만 필요하고 큰 규모의 플랫폼이므로 2번 선택!`

📝 동적 로드 방법으로 sdk 로드하기

1. env 설정

VITE_NICEPAY_SDK_URL=https://pay.nicepay.co.kr/v1/js/
VITE_NICEPAY_CLIENT_KEY=
VITE_API_BASE_URL=http:

2. 전역 타입 선언

src/types/nicepay.d.ts 파일을 새로 만들고 아래 내용 넣기

// src/types/nicepay.d.ts
export {};
declare global {
  interface Window {
    AUTHNICE?: NicepaySDK;
  }
}
type NicepaySDK = {
  requestPay: (opts: Record<string, any>) => void;
};

3. 로더 유틸(src/lib/loadNicepay.ts)

// 스크립트 로더 유틸
let loadingPromise: Promise<typeof window.AUTHNICE> | null = null;
export function loadNicepay(): Promise<typeof window.AUTHNICE> {
  if (typeof window === "undefined") {
    return Promise.reject(new Error("Window not available"));
  }
  if (window.AUTHNICE) {
    return Promise.resolve(window.AUTHNICE);
  }
  if (loadingPromise) return loadingPromise;
  loadingPromise = new Promise((resolve, reject) => {
    const src = import.meta.env.VITE_NICEPAY_SDK_URL as string;
    if (!src) {
      reject(new Error("VITE_NICEPAY_SDK_URL is not set"));
      return;
    }
    // 이미 붙어있나 체크
    if ([...document.scripts].some((s) => s.src === src)) {
      const check = () => {
        if (window.AUTHNICE) resolve(window.AUTHNICE);
        else setTimeout(check, 50);
      };
      check();
      return;
    }
    const script = document.createElement("script");
    script.src = src;
    script.async = true; // 동적 로드에 적합
    script.onload = () => {
      if (window.AUTHNICE) resolve(window.AUTHNICE);
      else reject(new Error("AUTHNICE not found after load"));
    };
    script.onerror = () => reject(new Error("Failed to load NICEPAY SDK"));
    document.head.appendChild(script);
  });
  return loadingPromise;
}
declare global {
  interface Window {
    AUTHNICE?: {
      requestPay(opts: Record<string, any>): void;
    };
  }
}

4. 훅 (src/hooks/useNicepay.ts)

import { useEffect, useState, useCallback } from "react";
import { loadNicepay } from "@/lib/loadNicepay";
export function useNicepay() {
  const [ready, setReady] = useState(false);
  const [err, setErr] = useState<Error | null>(null);
  useEffect(() => {
    let mounted = true;
    loadNicepay()
      .then(() => mounted && setReady(true))
      .catch((e) => mounted && setErr(e));
    return () => {
      mounted = false;
    };
  }, []);
  const requestPay = useCallback((opts: Record<string, any>) => {
    if (!window.AUTHNICE) throw new Error("NICEPAY not loaded");
    window.AUTHNICE.requestPay(opts);
  }, []);
  return { ready, error: err, requestPay };
}

✨ 3. 결제창 호출 (핵심)

전제 : orderId, amount, goodsName은 이미 서버에서 내려받았다고 가정.
결제수단 UI는 0: 계좌이체(실시간), 1: 카드, 2: 가상계좌.

import { useNicepay } from '@/hooks/useNicepay';

type NicepayOpenParams = {
  orderId: string;      // 서버가 발급한 주문번호
  amount: number;       // 결제금액(서버와 동일해야 함)
  goodsName: string;    // 노출 상품명
  method: 0 | 1 | 2;    // 0:bank, 1:card, 2:vbank
  applicantName?: string; // 가상계좌 시 예금주명으로 사용
};

export function useOpenNicepay() {
  const { ready, requestPay } = useNicepay();

  const openNicepay = ({
    orderId,
    amount,
    goodsName,
    method,
    applicantName,
  }: NicepayOpenParams) => {
    if (!ready) {
      alert('결제 모듈 준비 중입니다. 잠시만 기다려주세요.');
      return;
    }

    // UI → NICEPAY 메서드 매핑
    const nicepayMethod = method === 1 ? 'card' : method === 2 ? 'vbank' : 'bank';

    // 가상계좌 전용 옵션(필수: vbankHolder, 만료옵션 1개 선택)
    const vbankOptions =
      nicepayMethod === 'vbank'
        ? {
            vbankHolder: applicantName || '입금자',
            vbankValidHours: 72, // 또는 vbankExpDate: 'YYYYMMDDHHmmss'
          }
        : {};

    requestPay({
      clientId: import.meta.env.VITE_NICEPAY_CLIENT_KEY, // 프론트 공개키
      method: nicepayMethod, // 'card' | 'bank' | 'vbank'
      orderId,               // 서버 주문번호(고유)
      amount,                // 서버 금액과 반드시 일치
      goodsName,
      // 인증 결과를 서버가 받는 엔드포인트(승인/확정은 서버에서!)
      returnUrl: `${import.meta.env.VITE_API_BASE_URL}/payments/return`,
      // 취소는 "오류" 아님 → 재시도 가능하게 조용히 종료
      fnError: (e: any) => {
        const msg = e?.errorMsg || '';
        if (msg.includes('사용자 취소')) return;
        alert(`결제창 에러: ${msg || '알 수 없는 오류'}`);
      },
      ...vbankOptions,
    });
  };

  return { openNicepay, ready };
}
  • 컴포넌트 사용 예
const { openNicepay, ready } = useOpenNicepay();

// 서버에서 받은 값이라고 가정
const orderId = "123456789";
const amount = 15000;
const goodsName = "포도상점 - 대본";
const method = 1 as const; // 카드

<button
  disabled={!ready}
  onClick={() => openNicepay({ orderId, amount, goodsName, method })}
>
  결제하기
</button>

수단별 옵션 정리

수단method 매핑추가 파라미터
신용카드'card'없음
계좌이체(실시간)'bank'없음
가상계좌'vbank'vbankHolder(필수), vbankValidHours 또는 vbankExpDate 중 1개

⚠️ 가맹점에 해당 수단이 미오픈이면 결제창에서 W004(수단 유효하지 않음) 이 날 수 있음.
프론트 코드는 동일하고, 상점 설정만 열리면 그대로 동작한다


이 글은 “결제창 호출(프론트) 파트만” 다룹니다.
인증 결과 수신/금액 검증/승인(결제 확정)/성공·실패 리다이렉트는 백엔드에서 처리하세요.

0개의 댓글