본문으로 건너뛰기

Toss Payments SDK 초기화와 금액 갱신 책임 분리

SDK 인스턴스의 생명주기와 결제 금액 갱신 시점을 분리한 과정을 정리합니다.


0. 개요​

문제 상황
  • 결제 페이지 상태 변경 시 Toss Payments Widget이 반복 초기화
  • 쿠폰/포인트 할인 적용 시 화면이 깜빡이며 재렌더링
  • 간헐적으로 "결제 수단을 선택해주세요" 에러 발생
해결 방향
  • SDK 생명주기 정리: 금액 상태 변경과 SDK 초기화를 분리
  • 금액 업데이트 시점 변경: 결제 버튼 클릭 → 서버 응답 후 1회만
  • useRef로 인스턴스 관리: 리렌더링 없이 SDK 객체 유지

1. 문제 진단​

AS-IS: 문제가 되었던 코드 구조​

// pages/PaymentPage.jsx (개선 전)
const PaymentPage = () => {
const [totalPrice, setTotalPrice] = useState(0);
const [salePrice, setSalePrice] = useState(0);
const [usedPoint, setUsedPoint] = useState(0);

const paymentRequest = () => {
const paymentWidget = paymentWidgetRef.current;
requestPayment(cartId, couponId, paymentWidget, usedPoint);
};

// 문제 1: SDK 관련 값을 useEffect 의존성에 포함
useEffect(() => {
(async () => {
const paymentWidget = await loadPaymentWidget(clientKey, customerKey);
// SDK 초기화 + 위젯 렌더링
})();
}, [totalPrice, salePrice, usedPoint]); // ❌ 금액 변경 시마다 재초기화

// 문제 2: 금액 업데이트 useEffect가 별도로 존재
useEffect(() => {
const paymentMethodsWidget = paymentMethodsWidgetRef.current;
if (paymentMethodsWidget == null) return;

paymentMethodsWidget.updateAmount(
Math.max(totalPrice - salePrice - usedPoint, 0)
);
}, [totalPrice, salePrice, usedPoint]); // ❌ 금액 변경 시마다 실행

return 결제하기;
};
// hooks/useRequestPayment (개선 전)
const useRequestPayment = () => {
const requestPayment = async (cartId, couponId, paymentWidget, point) => {
try {
const response = await api.post("/api/payments/prepare", {
cartId,
couponId,
point,
});

// Toss 결제창 호출
paymentWidget?.requestPayment(response.data);
} catch (error) {
console.error("결제 실패:", error);
}
};

return requestPayment;
};

문제점

  1. 금액 관련 state가 useEffect 의존성 배열에 포함
    • 쿠폰 적용 → salePrice 변경 → useEffect 실행 → SDK 재초기화 → 화면 깜빡임
  2. 초기화와 금액 갱신의 실행 순서가 불명확
    • SDK 인스턴스가 준비되기 전에 금액 갱신 Effect가 실행될 가능성
  3. 결제 버튼 클릭 전에 금액 업데이트
    • 서버 검증 전에 클라이언트에서 금액 계산 → 불일치 가능성

2. 해결 과정​

Step 1: useEffect 의존성 배열 최적화​

// pages/PaymentPage.jsx (개선 후)
const PaymentPage = () => {
const paymentWidgetRef = useRef(null);
const paymentMethodsWidgetRef = useRef(null);

const paymentRequest = () => {
const paymentWidget = paymentWidgetRef.current;
const paymentMethodsWidget = paymentMethodsWidgetRef.current;

requestPayment(
cartId,
couponId,
paymentWidget,
paymentMethodsWidget,
usedPoint
);
};

// SDK 초기화를 금액 상태 변경과 분리
useEffect(() => {
(async () => {
try {
const paymentWidget = await loadPaymentWidget(clientKey, customerKey);
paymentWidgetRef.current = paymentWidget;

const paymentMethodsWidget = paymentWidget.renderPaymentMethods(
"#payment-widget",
{ value: totalPrice }
);
paymentMethodsWidgetRef.current = paymentMethodsWidget;
} catch (error) {
console.error("SDK 초기화 실패:", error);
}
})();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []); // 개발 환경의 Strict Mode에서는 검증 목적으로 재실행될 수 있음

return 결제하기;
};

핵심 개선

  • ✅ SDK 초기화를 금액 관련 상태 변경과 분리
  • ✅ SDK 인스턴스를 useRef에 저장 → 리렌더링 없이 값 유지
  • ✅ 금액 업데이트 useEffect 제거 → 결제 버튼 클릭 시점으로 이동

Step 2: 금액 업데이트 시점 변경​

// hooks/useRequestPayment (개선 후)
const useRequestPayment = () => {
const requestPayment = async (
cartId,
couponId,
paymentWidget,
paymentMethodsWidget,
point
) => {
try {
// 1. 서버에 결제 정보 전송
const response = await api.post("/api/payments/prepare", {
cartId,
couponId,
point,
});

// 2. 서버가 계산한 최종 금액으로 위젯 업데이트
paymentMethodsWidget.updateAmount(Math.max(response.data.amount, 0));

// 3. Toss 결제창 호출
paymentWidget?.requestPayment(response.data);
} catch (error) {
console.error("결제 실패:", error);
}
};

return requestPayment;
};

핵심 개선

  • ✅ 서버 응답 후 금액 업데이트 → 서버가 계산한 금액을 결제 위젯에 반영
  • ✅ 결제 버튼 클릭 시 updateAmount 호출 → 초기화와 금액 갱신의 실행 순서 명확화
  • ✅ paymentMethodsWidget을 함께 전달 → SDK 호출 안정화

3. 개선 효과​

성능 비교​

항목BeforeAfter개선율
항목BeforeAfter
-------------------------------------------------------------------------------------
SDK 초기화금액 관련 상태 변경마다 재실행컴포넌트 생명주기에 맞춰 실행
금액 갱신별도 Effect에서 실행결제 요청 시 서버 응답 후 실행
화면 깜빡임반복 초기화 과정에서 발생반복 초기화 제거 후 완화
"결제 수단 선택" 에러간헐적 발생실행 순서 정리 후 재현되지 않음

시나리오별 실행 경로​

작업 흐름BeforeAfter
페이지 진입2회 (초기화 + 금액 업데이트)1회 (초기화만)
쿠폰 적용+2회 (재초기화 + 금액 업데이트)0회
포인트 적용+2회0회
쿠폰 변경+2회0회
정리상태 변경마다 관련 Effect 실행초기화와 금액 갱신 경로 분리

4. 핵심 포인트​

1️⃣ useEffect 의존성 관리​

// ❌ 잘못된 예시
useEffect(() => {
// SDK 초기화
}, [totalPrice, salePrice, usedPoint]);

// ✅ 올바른 예시
useEffect(() => {
// SDK 초기화를 금액 상태 변경과 분리
}, []);

원칙

  • 외부 라이브러리 초기화: 금액 상태 변경과 독립된 Effect에서 수행
  • 상태 업데이트: 이벤트 핸들러 또는 API 응답 후 처리
  • 의존성 배열: 정말 필요한 값만 포함

2️⃣ useRef의 올바른 활용​

// ❌ 잘못된 예시 (state 사용)
const [paymentWidget, setPaymentWidget] = useState(null);
// → 리렌더링 발생

// ✅ 올바른 예시 (useRef 사용)
const paymentWidgetRef = useRef(null);
paymentWidgetRef.current = paymentWidget;
// → 리렌더링 없이 값 유지

useRef 사용 시기

  • ✅ 리렌더링을 유발하지 않아야 하는 값
  • ✅ 외부 라이브러리 인스턴스
  • ✅ DOM 엘리먼트 참조

3️⃣ 비동기 작업 순서 관리​

[결제 버튼 클릭]
↓
[1. 서버에 결제 정보 전송]
↓
[2. 서버가 최종 금액 계산 및 검증]
↓
[3. updateAmount(서버 금액)]
↓
[4. requestPayment(결제창 호출)]

원칙

  • ✅ 서버가 최종 금액을 계산하고 클라이언트는 그 값을 위젯에 반영
  • ✅ 비동기 작업 순서를 명시해 초기화 전 갱신 가능성을 줄임
  • ✅ 단일 진입점(결제 버튼)에서 모든 작업 제어

5. 추가 개선사항​

에러 처리 강화​

const useRequestPayment = () => {
const requestPayment = async (...) => {
try {
const response = await api.post('/api/payments/prepare', {
cartId,
couponId,
point
});

// 금액 음수 체크
if (response.data.amount < 0) {
throw new Error('결제 금액이 0원 미만입니다.');
}

paymentMethodsWidget.updateAmount(response.data.amount);
paymentWidget?.requestPayment(response.data);

} catch (error) {
// 사용자 친화적인 에러 메시지
if (error.response?.status === 400) {
alert('쿠폰 또는 포인트 사용이 유효하지 않습니다.');
} else {
alert('결제 처리 중 오류가 발생했습니다.');
}

console.error('결제 실패:', error);
}
};

return requestPayment;
};

6. 결론​

성과
  • Toss SDK 초기화와 결제 로직을 마운트 1회 + 결제 버튼 클릭 시 금액 업데이트 구조로 변경
  • useEffect 의존성 최적화 + useRef의 올바른 활용으로 불필요한 리렌더링 제거
  • 결과적으로 반복 초기화로 인한 로딩 지연과 화면 깜빡임을 줄이고 실행 순서를 명확히 함
배운 점
  • useEffect 의존성 관리의 중요성
    • 외부 라이브러리는 React의 렌더링 사이클과 분리해서 관리
  • 비동기 작업 순서 관리 경험
    • 공유하는 SDK 인스턴스의 준비 여부에 따라 실행 결과가 달라질 수 있음을 확인
    • 결제 버튼 클릭을 금액 갱신의 단일 진입점으로 구성
  • useRef의 활용
    • 리렌더링을 유발하지 않으면서 값을 유지해야 할 때 useRef 사용

7. 참고 자료​