본문으로 건너뛰기

Toss Payments SDK useEffect 중복 실행 문제 해결

useEffect 의존성 최적화 + useRef로 결제 페이지 로딩 70% 단축


0. 개요

문제 상황
  • 결제 페이지 진입 시 Toss Payments Widget 로딩에 3~5초 소요
  • 쿠폰/포인트 할인 적용 시 화면이 깜빡이며 재렌더링
  • 간헐적으로 "결제 수단을 선택해주세요" 에러 발생
해결 방향
  • useEffect 의존성 배열 최적화: SDK 초기화는 마운트 시 1회만
  • 금액 업데이트 시점 변경: 결제 버튼 클릭 → 서버 응답 후 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. 두 개의 useEffect가 동시에 실행
    • SDK 초기화 useEffect + 금액 업데이트 useEffect → Race Condition 발생 가능
  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 초기화 (마운트 시 1회만)
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
}, []); // ✅ 의존성 배열 비움 → 마운트 시 1회만 실행

return 결제하기;
};

핵심 개선

  • ✅ useEffect 의존성 배열을 비움 ([]) → 마운트 시 1회만 실행
  • ✅ 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;
};

핵심 개선

  • 서버 응답 후 금액 업데이트 → 클라이언트-서버 금액 일치 보장
  • 결제 버튼 클릭 시 1회만 updateAmount 호출 → Race Condition 제거
  • paymentMethodsWidget을 함께 전달 → SDK 호출 안정화

3. 개선 효과

성능 비교

항목BeforeAfter개선율
결제 페이지 로딩3~5초1초약 70% ↓
useEffect 실행 횟수8~12회1회약 90% ↓
화면 깜빡임✅ 발생❌ 제거100% 개선
"결제 수단 선택" 에러간헐적 발생❌ 해결100% 개선

시나리오별 useEffect 실행 횟수

작업 흐름BeforeAfter
페이지 진입2회 (초기화 + 금액 업데이트)1회 (초기화만)
쿠폰 적용+2회 (재초기화 + 금액 업데이트)0회
포인트 적용+2회0회
쿠폰 변경+2회0회
총계8~12회1회

4. 핵심 포인트

1️⃣ useEffect 의존성 관리

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

// ✅ 올바른 예시
useEffect(() => {
// SDK 초기화 (마운트 시 1회만)
}, []);

원칙

  • 외부 라이브러리 초기화: 마운트 시 1회만 (useEffect([]))
  • 상태 업데이트: 이벤트 핸들러 또는 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(결제창 호출)]

원칙

  • 서버가 최종 금액을 계산 → 클라이언트-서버 금액 일치
  • 비동기 작업은 순차적으로 처리 → Race Condition 방지
  • 단일 진입점(결제 버튼)에서 모든 작업 제어

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의 올바른 활용으로 불필요한 리렌더링 제거
  • 결과적으로 결제 페이지 로딩 시간 단축, 화면 깜빡임 제거, Race Condition 발생 가능성 제거
배운 점
  • useEffect 의존성 관리의 중요성
    • 외부 라이브러리는 React의 렌더링 사이클과 분리해서 관리
  • 비동기 작업과 Race Condition 처리 경험
    • 두 개의 useEffect가 동시에 실행되면 경합 조건 발생 가능
    • 단일 진입점(결제 버튼 클릭)에서 모든 작업을 순차적으로 처리
  • useRef의 활용
    • 리렌더링을 유발하지 않으면서 값을 유지해야 할 때 useRef 사용

7. 참고 자료