백엔드
웹 API
중복 제출 방지: 버튼 잠금, PRG, 유일 제약과 멱등 키

중복 제출 방지: 버튼 잠금, PRG, 유일 제약과 멱등 키

들어가며

결제 버튼을 눌렀는데 화면이 한참 그대로라 한 번 더 눌렀습니다. 잠시 뒤 주문 내역에는 같은 주문이 두 건 들어와 있습니다. 사용자는 한 번 주문할 생각이었지만, 서버에는 똑같은 요청이 두 번 도착한 것입니다.

이번 글에서는 이런 중복 제출이 어디서 생기는지, 화면과 서버에서 각각 무엇으로 막는지 정리합니다. 버튼 잠금과 PRG 패턴, 유일 제약, 멱등 키를 차례로 다루고, 각 방어가 막는 범위를 표로 비교합니다.


1. 중복 제출은 어디서 생기나

중복 제출은 한 번의 의도가 여러 개의 요청으로 서버에 도착하는 현상입니다. 원인은 생각보다 여러 갈래입니다.

  • 더블클릭: 응답이 늦으면 사용자는 버튼을 다시 누릅니다. 폼에서 Enter 를 두 번 치는 경우도 같습니다.
  • 새로고침과 뒤로 가기: POST 결과 화면에서 새로고침하면 브라우저는 같은 폼을 다시 보낼지 묻고, 확인을 누르면 POST 가 한 번 더 갑니다.
  • 재시도: 응답이 시간 초과로 끊기면 앱의 재시도 로직이나 사용자가 요청을 다시 보냅니다. 첫 요청이 서버에서는 이미 성공했을 수도 있습니다.
  • 직접 호출: API 를 화면 없이 직접 부르면 화면 쪽 방어는 아무 소용이 없습니다.

서버 입장에서 두 요청은 내용이 같을 뿐 서로 다른 요청입니다. 서버가 따로 알려 주는 정보 없이는 같은 의도인지 구분할 수 없습니다.

2. 화면에서 줄이기: 버튼 잠금과 PRG

버튼 잠금

가장 먼저 떠오르는 방법은 요청을 보내는 동안 버튼을 비활성화하는 것입니다. 클릭 이벤트가 아니라 폼 제출 이벤트에 걸어야 Enter 키로 보내는 경우까지 잡힙니다.

form.addEventListener("submit", (e) => {
  const button = form.querySelector("button[type=submit]");
  if (button.disabled) {
    e.preventDefault();
    return;
  }
  button.disabled = true;
});

버튼 잠금은 사용자 실수를 줄이는 사용성 장치입니다. 새로고침, 재시도, 직접 호출은 막지 못하므로 정합성을 보장하지는 않습니다.

PRG 패턴

PRG(Post/Redirect/Get)는 POST 를 처리한 뒤 결과 화면을 바로 그리지 않고 리다이렉트를 돌려주는 방식입니다. 서버가 303 See Other 와 함께 결과 페이지 주소를 주면, 브라우저는 그 주소를 GET 으로 다시 요청합니다.

PRG 는 계산을 마친 손님을 영수증 창구로 보내는 것과 같습니다. 창구에서 다시 물어도 계산은 한 번입니다.

POST 후 바로 화면주문 2건
🖱️브라우저client🗄️서버server① POST /orders② 200 완료 화면새로고침③ POST /orders④ 200 · 주문 2

새로고침이 마지막 POST 를 다시 보냅니다.

PRG주문 1건
🖱️브라우저client🗄️서버server① POST /orders② 303 See Other③ GET /orders/42④ 200 완료 화면새로고침⑤ GET /orders/42

새로고침은 조회만 반복합니다.

PRG 는 새로고침으로 인한 재전송만 막습니다. 더블클릭과 재시도는 여전히 서버까지 갑니다.

이렇게 하면 결과 화면의 마지막 요청은 GET 이 됩니다. 사용자가 새로고침해도 조회만 반복되고 주문은 다시 만들어지지 않습니다. 다만 PRG 는 새로고침 하나만 해결합니다. 더블클릭이나 재시도는 여전히 서버까지 갑니다.

3. 서버에서 막기: 유일 제약

화면 쪽 방어는 결국 우회될 수 있습니다. 실제로 막는 일은 서버가 해야 합니다.

확인 후 저장의 함정

흔히 쓰는 방법은 먼저 SELECT 로 같은 기록이 있는지 보고, 없으면 INSERT 하는 것입니다. 요청이 하나씩 오면 잘 동작하지만, 거의 동시에 두 요청이 들어오면 문제가 생깁니다.

두 사람이 동시에 빈자리를 보고 동시에 앉으면 한 자리에 둘이 앉게 됩니다.

요청 ASELECT 없음INSERT요청 BSELECT 없음INSERT2행 생김둘 다 없음 확인0123456ms
확인과 저장 사이의 틈에 다른 요청이 끼어듭니다. 시간 간격은 어림이며, 실제로는 수 밀리초 안에서도 일어납니다.

두 요청이 모두 INSERT 전에 SELECT 를 실행하면 둘 다 "없음"을 봅니다. 그리고 둘 다 저장합니다. 확인과 저장 사이에 틈이 있기 때문입니다.

유일 제약으로 한 번에

해결책은 확인과 저장을 한 동작으로 묶는 것입니다. 데이터베이스의 유일 제약(UNIQUE) 은 같은 값의 행이 둘 생기지 못하게 막고, 동시에 들어온 요청 중 하나만 통과시킵니다.

-- 확인과 선점을 한 문장으로 (PostgreSQL)
INSERT INTO idem (key) VALUES ('K1')
ON CONFLICT (key) DO NOTHING
RETURNING key;
-- 1행이 돌아오면 처음 온 요청, 0행이면 이미 접수된 요청

MySQL 이라면 INSERT IGNORE 를 쓰거나 중복 키 오류를 잡아 같은 판단을 합니다. 업무 규칙 자체가 "한 번만"이라면 업무 키에 바로 유일 제약을 거는 방법도 있습니다. 예를 들어 쿠폰은 사용자당 한 장이라면 (user_id, coupon_id) 에 유일 제약을 겁니다.

4. 멱등 키: 같은 의도를 알아보는 표식

업무 키가 없는 요청, 예를 들어 같은 상품을 두 번 주문하는 것이 정상인 경우에는 다른 표식이 필요합니다. 이때 쓰는 것이 멱등 키(idempotency key) 입니다.

클라이언트는 사용자의 의도 하나마다 UUID 같은 고유 값을 만들어 헤더에 담아 보냅니다. 재시도할 때는 같은 키를 그대로 씁니다. 헤더 이름은 IETF 에서 표준 초안으로 논의 중인 Idempotency-Key 를 많이 씁니다.

POST /orders HTTP/1.1
Idempotency-Key: 8e4c2a1f-5b3d-4c7e-9a10-2f6b8d3e1c55
Content-Type: application/json

서버는 위의 유일 제약 방식으로 키를 선점하고, 같은 키가 다시 오면 상태에 따라 응답합니다.

  • 첫 요청이 끝났으면 저장해 둔 응답을 그대로 돌려줍니다.
  • 아직 처리 중이면 409 Conflict 로 잠시 뒤 다시 확인하게 합니다.
  • 키는 같은데 본문이 다르면 422 같은 오류로 거절합니다.

멱등 기록은 재시도가 올 수 있는 기간보다 오래 둬야 합니다. 보관 기간은 접수 시점이 아니라 처리가 끝난 시점부터 세는 편이 안전합니다. 처리가 오래 걸려도 기록이 먼저 사라지지 않습니다.

5. 어떤 방어가 무엇을 막나

현관 안내문은 실수를 줄이고, 실제로 막는 것은 잠긴 문입니다.

방어 수단더블클릭새로고침재시도직접 호출
버튼 잠금client
PRGPost/Redirect/Get
멱등 키Idempotency-Key키를 바꿔 보내면 새 요청
업무 키 유일UNIQUE
막음 못 막음 조건부
화면 쪽 방어는 원인 하나씩만 줄입니다. 업무 키 유일 제약은 업무 규칙이 '한 번만'일 때만 쓸 수 있고, 그 밖에는 멱등 키가 필요합니다.

화면 쪽 방어는 각각 원인 하나씩만 줄입니다. 원인 전부를 막는 것은 서버 쪽 유일 제약뿐이고, 화면 쪽 방어는 그 앞에서 불필요한 요청을 덜어 주는 역할입니다.

헷갈리기 쉬운 점

  • SELECT 로 먼저 확인하면 안전하다는 오해. 동시 요청은 둘 다 "없음"을 볼 수 있습니다. 확인과 저장을 유일 제약 한 번으로 묶어야 합니다.
  • 버튼을 잠갔으니 끝이라는 오해. 새로고침, 시간 초과 뒤 재시도, 직접 호출은 버튼을 거치지 않습니다.
  • 재시도마다 멱등 키를 새로 만드는 실수. 키가 바뀌면 서버는 새 의도로 봅니다. 키는 사용자의 행동 하나에 묶고, 재시도에는 같은 키를 씁니다.

면접에서 자주 받는 질문

버튼을 두 번 눌러 주문이 두 건 생겼습니다. 버튼만 잠그면 될까요?

버튼 잠금은 실수를 줄이지만 새로고침, 재시도, 직접 호출은 막지 못합니다. 화면에서는 잠금과 PRG 로 요청을 줄이고, 서버에서는 멱등 키와 유일 제약으로 한 번만 반영되게 합니다. 대가는 키 저장소와 보관 기간을 관리해야 한다는 점입니다.

같은 키로 온 두 번째 요청에는 무엇을 돌려주나요?

첫 요청이 끝났으면 저장한 응답을 그대로 돌려줍니다. 아직 처리 중이면 409 로 나중에 다시 확인하게 하고, 같은 키에 본문이 다르면 422 같은 오류로 거절합니다.

멱등 기록은 얼마나 보관해야 하나요?

재시도가 올 수 있는 기간과 작업이 살아 있는 기간보다 길어야 합니다. 접수 시점이 아니라 끝난 시점부터 세면 처리 대기가 길어져도 기록이 먼저 사라지지 않습니다.

정리

  • 중복 제출은 더블클릭, 새로고침, 재시도, 직접 호출에서 생기고 서버는 스스로 구분하지 못합니다.
  • 버튼 잠금과 PRG 는 화면에서 요청을 줄이는 장치이고 보장은 아닙니다.
  • 서버는 유일 제약으로 확인과 저장을 한 번에 하고, 업무 키가 없으면 멱등 키를 씁니다.

결국 화면은 줄이고, 서버가 막습니다. 둘을 함께 둘 때 사용자는 편하고 데이터는 한 번만 남습니다.