티스토리 뷰

1) 문제 상황

  • 중복 제출: “결제하기” 버튼을 빠르게 연타하면 여러 결제 요청이 동시에 전송
  • 세션/쿠키 잔존: 체크아웃 완료 후 혹은 이탈 시에도 세션이 남아 혼란
  • 이벤트 난립: 폼 곳곳에 흩어진 리스너로 유지보수가 어려움
  • 피드백 지연: 제출 상태/에러가 즉각적으로 반영되지 않아 UX 저하

2) 개선 목표

  • 중복 제출 방지: 간단한 락(lock) + 버튼 비활성화 + 로딩 copy
  • 아이덴포턴시(idempotency): 서버가 중복 요청을 단 1건으로 처리하도록 키 부여
  • 세션 클리어: 체크아웃 완료/화면 이탈 시 세션·쿠키 정리
  • 이벤트 통합: 한 클래스에서 폼 제출/이탈 감지/세션 관리를 일원화

3) 개선 전 (문제 재현용 코드)

  • 버튼을 연타하면 **동시에 여러 “주문”**이 만들어질 수 있다고 가정
  • 이탈/새로고침 시 세션 정리가 없음
<form id="checkout-before">
  <label>이름</label>
  <input name="name" required />
  <label>이메일</label>
  <input type="email" name="email" required />
  <button type="submit">결제하기</button>
</form>
<script>
const form = document.getElementById('checkout-before');
let count = 0;
form.addEventListener('submit', (e) => {
  e.preventDefault();
  const id = ++count;
  localStorage.setItem('session_before', JSON.stringify({ id, at: Date.now() }));
  // 서버 지연 시뮬레이션
  setTimeout(() => {
    console.log(`[${id}] 주문 생성됨 (중복 가능)`); // 중복 위험
  }, 1200);
});
</script>

 

문제 요약

  • 더블클릭/연타 시 여러 요청 전송
  • 체크아웃 완료 후에도 세션 잔존
  • 이벤트/로직 분산

4) 개선 1 — 락(lock) + 버튼 비활성화/로딩

  • 제출 직후 2초간 락을 걸어 추가 제출을 무시
  • 버튼을 비활성화하고 라벨을 “결제 처리중…”으로 변경해 피드백 제공
lock(ms=2000){
  this.isLocked = true;
  this.payBtn.disabled = true;
  this.payBtn.textContent = '결제 처리중...';
  setTimeout(()=>{
    this.isLocked = false;
    this.payBtn.disabled = false;
    this.payBtn.textContent = '결제하기';
  }, ms);
}

5) 개선 2 — 아이덴포턴시 키

  • 클라이언트가 idempotency_key를 생성/보관 → 서버는 같은 키의 중복 요청을 1건만 인정
  • 실제 WooCommerce/PG 연동 시엔 서버에서 키 저장/조회가 필수
getIdempotencyKey(){
  let key = localStorage.getItem('idem_key');
  if (!key) {
    key = 'idem_' + Math.random().toString(36).slice(2);
    localStorage.setItem('idem_key', key);
  }
  return key;
}

6) 개선 3 — 세션 정리(완료/이탈)

  • 결제 성공/사용자 이탈(visibilitychange/pagehide/beforeunload) 시 세션/쿠키 삭제
  • 실제 서비스에서는 업무 규칙에 맞춰 조건 적용 권장
clearSession(){
  localStorage.removeItem('session_after');
  localStorage.removeItem('idem_key');
  document.cookie = "session=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;";
}
['visibilitychange', 'pagehide', 'beforeunload'].forEach(evt => {
  window.addEventListener(evt, () => {
    if (document.visibilityState === 'hidden' || evt === 'beforeunload' || evt === 'pagehide') {
      this.clearSession();
    }
  });
});

7) 최종 통합 

  • 폼 제출, 락, 아이덴포턴시, 세션 저장/복구, 이탈 감지까지 한 클래스에서 관리
<form id="checkout-after">
  <input name="name" required />
  <input type="email" name="email" required />
  <button id="pay-btn" type="submit">결제하기</button>
  <button id="reset-session" type="button">세션 초기화</button>
</form>
<script>
class CheckoutManager {
  constructor(form, logEl, payBtn, resetBtn){
    this.form=form; this.log=logEl; this.payBtn=payBtn; this.resetBtn=resetBtn;
    this.isLocked=false; this.bind(); this.restoreSession();
  }
  getIdempotencyKey(){
    let key = localStorage.getItem('idem_key');
    if (!key){ key='idem_'+Math.random().toString(36).slice(2); localStorage.setItem('idem_key', key); }
    return key;
  }
  lock(ms=2000){ this.isLocked=true; this.payBtn.disabled=true; this.payBtn.textContent='결제 처리중...';
    setTimeout(()=>{ this.isLocked=false; this.payBtn.disabled=false; this.payBtn.textContent='결제하기'; }, ms); }
  saveSession(payload){ localStorage.setItem('session_after', JSON.stringify(payload)); }
  clearSession(){ localStorage.removeItem('session_after'); localStorage.removeItem('idem_key'); document.cookie="session=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;"; }
  restoreSession(){ const s=localStorage.getItem('session_after'); if(s){ this.println(`세션 복구됨: ${s.slice(0,60)}...`); } }
  println(t,c=''){ const d=document.createElement('div'); if(c) d.className=c; d.textContent=t; this.log.appendChild(d); this.log.scrollTop=this.log.scrollHeight; }
  bind(){
    this.form.addEventListener('submit',(e)=>{
      e.preventDefault();
      if(this.isLocked){ this.println('잠금 상태: 중복 제출 무시','warn'); return; }
      const payload=Object.fromEntries(new FormData(this.form).entries());
      payload.ts=Date.now(); payload.idempotency_key=this.getIdempotencyKey();
      this.lock(2000); this.saveSession(payload);
      this.println(`요청 전송: idem=${payload.idempotency_key}`);
      setTimeout(()=>{ this.println('서버 응답: 주문 1건 처리됨 (아이덴포턴시)','success'); this.clearSession(); },1200);
    });
    this.resetBtn.addEventListener('click',()=>{ this.clearSession(); this.println('세션 수동 초기화','warn'); });
    ['visibilitychange','pagehide','beforeunload'].forEach(evt=>{
      window.addEventListener(evt,()=>{ if(document.visibilityState==='hidden'||evt==='beforeunload'||evt==='pagehide'){ this.clearSession(); this.println('이탈 감지: 세션 자동 정리','warn'); }});
    });
  }
}
</script>

WooCommerce 체크아웃: 상태/이벤트 관리 개선 데모

개선 전

단순 체크아웃 (중복 제출/세션정리 없음)

버튼 연타 시 중복 결제 요청이 전송되고, 화면 이탈 후에도 세션이 남을 수 있습니다.

문제: 더블클릭/연타 시 여러 결제 요청이 동시에 진행될 수 있으며, 이탈/새로고침 후에도 세션 정보가 남습니다.
개선 후

체크아웃 상태·이벤트 관리 (Lock + Idempotency + 이탈정리)

폼 제출 시 2초 락과 아이덴포턴시 키로 중복 방지, 완료/이탈 시 세션 정리.

팁: 결제 버튼을 빠르게 여러 번 클릭해 보세요. 첫 1회만 처리되고, 나머지는 락/아이덴포턴시로 무시됩니다.

원래 프로젝트의 구조를 해치지 않는 선에서 포함된 JS파일의 구조를 최대한 개선해 보았다. 이 프로젝트는 '한 번에 한 주문만 결제'해 장바구니가 필요없는 구조였다. 프로젝트에 맞게 수정해 사용하자.

공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
TAG
more
«   2025/11   »
1
2 3 4 5 6 7 8
9 10 11 12 13 14 15
16 17 18 19 20 21 22
23 24 25 26 27 28 29
30
글 보관함