티스토리 뷰
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>
원래 프로젝트의 구조를 해치지 않는 선에서 포함된 JS파일의 구조를 최대한 개선해 보았다. 이 프로젝트는 '한 번에 한 주문만 결제'해 장바구니가 필요없는 구조였다. 프로젝트에 맞게 수정해 사용하자.
'Programming' 카테고리의 다른 글
| [Claude Code] Claude Code 사용 후기와 모드 비교 (2) | 2025.10.20 |
|---|---|
| [사이드 프로젝트] 구글스프레드시트 메모 정리 프로그램 (0) | 2025.10.06 |
| [Next.js] 라우트 핸들러(Router handlers)를 사용해 api 통신하기 (2) | 2025.03.16 |
