쿠폰을 두 번 쓰면 어떻게 막죠? — 1회용 디지털 쿠폰 만들기
매장에서 쓰던 종이 쿠폰을 디지털로 바꾸면서 QR 쿠폰 발행 프로그램을 직접 만들었어요. 제일 신경 쓴 건 '한 번 쓴 쿠폰을 두 번 못 쓰게' 막는 거였어요. 손님이 화면을 캡처해두거나, 두 직원이 동시에 스캔해도 딱 한 번만 인정되게 하는 방법을 적어둡니다.
title: "쿠폰을 두 번 쓰면 어떻게 막죠? — 1회용 디지털 쿠폰 만들기" date: "2026-06-24" description: "매장에서 쓰던 종이 쿠폰을 디지털로 바꾸면서 QR 쿠폰 발행 프로그램을 직접 만들었어요. 제일 신경 쓴 건 '한 번 쓴 쿠폰을 두 번 못 쓰게' 막는 거였어요. 손님이 화면을 캡처해두거나, 두 직원이 동시에 스캔해도 딱 한 번만 인정되게 하는 방법을 적어둡니다." tags: ["dev", "nextjs", "database", "자영업"] category: "dev"
제가 일하는 매장에서는 손님께 쿠폰을 꽤 자주 드려요. 재방문 감사 쿠폰, 이벤트 쿠폰 같은 것들요. 지금까진 종이로 찍어서 썼는데, 인쇄비도 들고 몇 장이 실제로 쓰였는지 집계도 안 돼서, 결국 디지털 쿠폰을 발행하는 작은 웹앱을 직접 만들었어요.
만들기로 마음먹고 나서 제일 먼저 떠오른 걱정은 이거였어요.
"디지털 쿠폰은 화면을 캡처하면 그만인데, 같은 쿠폰을 두 번 쓰면 어떻게 막지?"
종이 쿠폰은 직원이 받아서 회수하면 그만이지만, QR 쿠폰은 손님 폰에 그대로 남아 있잖아요. 캡처해두고 다음에 또 내밀 수도 있고요. 그래서 이 프로그램에서 제일 공들인 부분이 '1회용 보장' 이었어요. 오늘은 그 이야기를 적어둘게요.
쿠폰 한 장 = 추측 불가능한 토큰 하나
먼저 쿠폰마다 고유한 주소가 있어야 해요. 그래야 QR로 만들어서 손님께 보낼 수 있으니까요. 이 토큰이 짧거나 순서대로면 남이 슬쩍 다음 번호를 찍어볼 수 있으니, 그냥 랜덤한 긴 값으로 만들었어요.
import { randomBytes } from "crypto";
// 추측 불가능한 쿠폰 토큰 (URL-safe, 22자 내외)
export function newToken() {
return randomBytes(16).toString("base64url");
}
// QR/링크에 담길 고객용 쿠폰 주소
export function couponUrl(token: string) {
return `${process.env.NEXT_PUBLIC_BASE_URL}/c/${token}`;
}
이 주소를 QR 코드로 바꿔서 손님 화면에 띄워주는 거예요. 손님은 앱 같은 거 깔 필요 없이, 이 화면만 직원에게 보여주면 돼요.
핵심 — "두 번째 사용"을 막는 한 줄
이제 본론이에요. 직원이 QR을 스캔하면 그 쿠폰을 '사용 완료'로 바꿔야 하는데, 순진하게 짜면 이런 식이 돼요.
// ⚠️ 위험한 방식 — 이렇게 하면 안 돼요
const coupon = await prisma.coupon.findUnique({ where: { id: token } });
if (coupon.status === "issued") {
await prisma.coupon.update({
where: { id: token },
data: { status: "redeemed" },
});
}
평소엔 잘 돌아가요. 그런데 문제는 동시에 두 번 들어올 때예요.
손님이 일행 두 명에게 같은 캡처를 나눠주고 서로 다른 직원에게 동시에 내밀었다고 해볼게요. 두 요청이 거의 같은 순간에 들어오면, 둘 다 "status가 issued네? 통과!"를 읽어버려요. 그 다음 둘 다 redeemed로 바꾸고요. 결과적으로 한 장짜리 쿠폰이 두 번 인정돼요. 읽기와 쓰기 사이에 틈이 있어서 생기는 일이에요.
그래서 읽고-판단하고-쓰기를 따로 하지 않고, "아직 issued인 경우에만 redeemed로 바꿔라"를 한 번의 명령으로 처리했어요.
// status가 'issued'인 경우에만 갱신 — 조건과 변경을 한 번에 (동시 스캔 방어)
const result = await prisma.coupon.updateMany({
where: { id: token, status: "issued" },
data: {
status: "redeemed",
redeemedAt: new Date(),
storeId,
},
});
if (result.count === 0) {
// 바뀐 행이 0개 = 이미 누군가 먼저 썼다는 뜻
return { ok: false, message: "이미 사용 처리된 쿠폰입니다." };
}
포인트는 where에 status: "issued" 조건을 같이 넣은 거예요. 데이터베이스가
"조건에 맞는 행을 찾아서 바꾸는 일"을 하나의 원자적 동작으로 처리해주기 때문에,
동시에 두 요청이 와도 딱 하나만 성공해요. 먼저 도착한 쪽이 status를 redeemed로
바꾸는 순간, 두 번째 요청의 where 조건(status: "issued")은 더 이상 맞지 않게
되거든요. 그래서 두 번째는 바뀐 행이 0개가 되고, 우리는 그걸로 "이미 쓴 쿠폰"임을
알 수 있어요.
캡처해서 나중에 또 내미는 경우도 마찬가지예요. 그땐 이미 redeemed라서 조건에 안 맞고, 조용히 거절돼요.
덤으로 챙긴 것들
이렇게 토큰 + DB 한 장으로 관리하니까, 처음엔 생각 못 했던 것들이 따라왔어요.
- 0원 발송 — 손님 번호를 붙여넣으면 각자 전용 토큰 쿠폰이 만들어지고, 그걸 제 폰 문자앱으로 보내요. 따로 유료 발송 서비스를 안 끼니 비용이 안 들어요.
- 실시간 집계 — 몇 장 발행했고 몇 장 쓰였는지가 그냥 숫자로 보여요. 종이 땐 절대 못 하던 거예요.
- 호점별·테마별 기록 — 어느 지점에서 어떤 혜택으로 썼는지가 남아서, 나중에 "이 이벤트가 먹혔나"를 판단할 수 있어요.

남는 생각
종이 쿠폰을 디지털로 바꾸는 일이 거창해 보였는데, 막상 핵심은 의외로 단순했어요. "읽고 나서 바꾸지 말고, 조건을 걸어서 한 번에 바꿔라." 동시성 문제는 이 한 끗에서 갈리더라고요.
이번엔 평소와 달리 '직원도 손님도 편하게'를 1순위로 두고 만들었는데, 그러다 보니 오히려 안 보이는 곳(이런 1회용 보장 같은)을 더 탄탄하게 만들게 됐어요. 손님이 QR만 쓱 보여주는 그 편한 경험 뒤에는, 두 번 못 쓰게 막는 이런 장치가 깔려 있는 거예요.