0원 결제가 가능했던 이유 — 프론트 인메모리를 믿으면 안 되는 이유
예전에 다니던 커머스 회사 이야기입니다.어느 날 서버 개발자가 결제 승인 로직을 살펴보다 배포된 환경에서 프톤트 인메모리 값을 수정할 수 있는지 질문했습니다. 그리고 몇 분 뒤, 개발자도구만으로 0원 결제가 가능하다는 사실이 드러났습니다.
이번 글에서는 그때의 취약했던 결제 설계를 되짚고, 크롬 개발자도구로 결제 금액을 직접 0원으로 바꿔보는 과정을 재현합니다. 그리고 왜 이런 일이 가능한지(프론트 인메모리의 본질)와, 어떻게 다시 설계해야 하는지까지 다룹니다.
- 서버가 금액을 한 번도 확인하지 않았다
- 크롬 개발자도구로 0원 결제 재현하기 (직접 해보기)
- 프론트 인메모리는 항상 수정된다
- 금액의 근거를 서버로 옮기기
1. 서버가 금액을 한 번도 확인하지 않았다
당시 결제 흐름은 대략 이랬습니다.
- 프론트가 상품 가격을 전부 계산합니다. 장바구니 합계, 할인, 배송비까지 계산해서 최종 결제 금액을 만듭니다.
- 그 금액으로 토스 결제모듈을 호출하고, 사용자가 결제를 완료합니다.
- 결제가 성공하면, 프론트가 인메모리 변수에 들고 있던 결제 금액을 서버로 넘깁니다.
- 서버는 그 금액으로 최종 결제 승인을 처리합니다.
문제는 4번이었습니다. 서버가 최종 승인을 할 때 결제 금액을 한 번도 다시 확인하지 않았습니다. 프론트가 넘긴 amount를 그대로 믿고 승인 요청을 보낸 겁니다.
겉으로 보면 자연스러워 보입니다. "프론트가 방금 결제한 금액을 넘긴 건데 뭐가 문제야?" 싶죠. 하지만 여기엔 아주 위험한 가정이 하나 숨어 있었습니다.
"프론트가 서버로 넘기는 값은 프론트가 계산한 그대로일 것이다."
2. 크롬 개발자도구로 0원 결제 재현하기
이 부분은 생각보다 모르는 분이 많아서 잠깐 짚고 가겠습니다. 크롬 개발자도구의 Sources 패널은 실행 중인 자바스크립트를 원하는 지점에서 멈추고(브레이크포인트), 그 순간의 메모리 값을 직접 수정할 수 있습니다.
핵심은 이겁니다. 서버로 값을 보내기 직전에 코드를 멈춰 세우고, 메모리에 있는 amount를 0으로 바꾼 다음, 아무 일 없었다는 듯 다시 실행시키는 것.
직접 해보기
로그인이 필요 없는 아무 웹사이트에서나 테스트할 수 있습니다. 결제 대신 그냥 변수 하나를 바꿔보는 연습입니다.
- 크롬에서 아무 페이지나 열고 F12(맥은
⌥⌘I)로 개발자도구를 엽니다. - Sources 탭으로 갑니다. 왼쪽에 이 사이트가 불러온 자바스크립트 파일들이 트리로 보입니다.
- 멈추고 싶은 코드 줄의 줄 번호를 클릭하면 파란 화살표(브레이크포인트)가 생깁니다. 이제 그 줄에 실행이 도달하면 브라우저가 코드를 멈춥니다.
- 코드가 멈추면 오른쪽 Scope 패널에 그 순간 살아있는 지역 변수들이 다 보입니다.
- 바꾸고 싶은 변수 값을 더블클릭하면 편집 상태가 됩니다.
49000을0으로 고치고 엔터. - 위쪽의 Resume(▶) 버튼을 누르면, 멈췄던 코드가 바뀐 값으로 이어서 실행됩니다.
Sources 패널이 아니어도 됩니다. 코드가 멈춰 있는 동안 Console 탭에서
amount = 0이라고 쳐도 똑같이 그 순간의 메모리 값을 덮어씁니다. 브레이크포인트의 본질은 "실행을 멈춘 사이 개발자가 메모리에 손댈 틈을 준다"는 거니까요.
당시 회사 코드에 대입하면 이렇습니다. confirmPayment(amount) 처럼 서버로 결제 금액을 보내는 줄 바로 위에 브레이크포인트를 겁니다. 결제 버튼을 누르면 그 줄에서 멈추고, amount를 0으로 바꾼 뒤 Resume을 누릅니다.
서버로는 0원이 전송됩니다. 그리고 서버는 그 0원을 그대로 승인했습니다.
여기서 오해하면 안 되는 게 있습니다. 이건 서버를 "해킹"한 게 아닙니다. 토스 결제모듈을 뚫은 것도 아니고요. 그냥 내 브라우저 안에서 내 메모리 값을 내가 바꾼 것뿐입니다. 브라우저는 원래 사용자 편이라, 사용자가 자기 페이지의 자바스크립트를 멈추고 값을 바꾸는 걸 막을 방법이 없습니다.
3. 프론트 인메모리는 항상 수정된다
프론트엔드에 있는 값은, 그게 변수든 상태든 hidden input이든, 사용자가 언제든 바꿀 수 있습니다.
우리는 보통 코드를 "위에서 아래로 순서대로, 내가 쓴 대로 흐르는 것"이라고 상상합니다. 하지만 그건 우리 통제 하에서만 참입니다. 코드가 사용자의 브라우저에 도착하는 순간, 그 코드도 값도 전부 사용자의 것이 됩니다. 실행을 멈추고, 값을 바꾸고, 함수를 건너뛰는 게 전부 가능합니다.
그래서 프론트에서 하는 어떤 검증도 보안이 될 수 없습니다.
if (amount < 0) return같은 방어 코드? → 그if문 자체를 건너뛸 수 있습니다.- 값을 hidden input이나 암호화해서 숨기기? → 숨긴 값을 만드는 코드도 브라우저 안에 있으니 결국 열립니다.
- 난독화? → 실행 시점의 메모리 값은 난독화와 상관없이 그대로 보입니다.
프론트의 검증은 UX를 위한 것이지 보안을 위한 게 아닙니다. "사용자가 실수로 이상한 값을 넣지 않게 돕는" 역할까지가 프론트의 몫이고, "악의를 가진 사용자를 막는" 건 서버만 할 수 있습니다. 서버는 사용자가 손댈 수 없는 유일한 곳이니까요.
4. 금액의 근거를 서버로 옮기기
그럼 어떻게 고쳐야 할까요. 방향은 하나입니다. 금액을 만들고 확정하는 권한을 프론트에서 서버로 옮기는 것. 프론트는 서버가 정한 금액을 보여주기만 하면 됩니다.
구체적으로는 주문서(order) 개념을 서버에 두는 방식이 깔끔합니다.
- 주문서를 만드는 시점에, 프론트는 상품 ID와 수량만 서버로 넘깁니다. 금액은 안 넘깁니다.
- 서버가 그 상품 ID로 가격·할인·배송비를 직접 계산해서 최종 금액을 정하고, 이걸 주문서에 저장합니다. (
orderId와 확정 금액) - 프론트는 서버가 응답한 금액을 화면에 표시만 합니다. 결제창도 이 금액으로 띄우고요.
- 결제 성공 후 최종 승인 때, 프론트는
orderId만 넘깁니다. 금액은 안 넘깁니다. - 서버는
orderId로 저장해 둔 금액을 꺼내서, 토스가 알려준 실제 결제 금액과 대조합니다. 두 값이 같을 때만 승인합니다.
이 설계의 핵심은 프론트가 아예 amount를 만들지도, 넘기지도 않는다는 점입니다. 2번에서 했던 "메모리의 amount를 0으로 바꾸는" 공격은, 바꿀 amount가 프론트에 없으니 성립 자체가 안 됩니다.
여기서 5번이 특히 중요합니다. 흔히 저지르는 실수가 "주문서에 금액을 저장하는 것"까지만 하고, 최종 승인 때 프론트가 넘긴 금액을 다시 믿는 겁니다. 그러면 주문서를 서버에 뒀어도 결국 프론트 값을 신뢰하는 셈이라 원점이죠.
최종 승인의 진짜 방어선은 "토스가 승인해 준 결제 금액 == 서버가 주문서에 저장해 둔 금액" 을 서버에서 대조하는 것입니다.
토스 페이먼츠 문서에서도 이 대조를 명시적으로 권장합니다. 결제 승인 API를 호출하기 전에, 결제창을 띄울 때 쓴 금액을 서버 세션(또는 주문서)에 저장해 두고, 승인 직전에 클라이언트가 보낸 금액과 서버가 저장한 금액이 일치하는지 반드시 확인하라고요. 정리하면 이렇게 흐르는 겁니다.
- 주문서 생성 → 서버가 금액 확정·저장
- 결제창 호출 → 저장된 금액으로 결제
- 승인 요청 → 서버가
저장 금액 == 실제 결제 금액확인 후에만 승인
이렇게 두면 프론트에서 무슨 값을 조작하든 서버의 저장값과 안 맞으면 승인이 거부됩니다. 신뢰의 근거가 사용자가 손댈 수 없는 서버 안에만 있게 되는 거죠.
마치며
0원 결제라는 자극적인 결과였지만, 원인은 아주 단순한 한 가지였습니다. 믿으면 안 되는 걸 믿은 것.
- 프론트에 있는 값은 변수든 상태든 hidden input이든 사용자가 언제든 바꿀 수 있다. 개발자도구 브레이크포인트 하나면 충분하다.
- 그래서 프론트의 검증은 UX용이지 보안용이 아니다. 악의적 사용자를 막는 건 서버만 할 수 있다.
- 금액처럼 중요한 값은 근거를 서버에 두고, 프론트는 표시만 한다.
- 결제 승인의 마지막 방어선은 "서버가 저장한 금액 == 실제 결제 금액"을 서버에서 대조하는 것이다.
지금은 다 수정됐지만, 저한텐 "클라이언트를 신뢰하지 마라"는 문장이 책 속 격언이 아니라 직접 0원을 찍어본 경험으로 남았습니다. 혹시 지금 만들고 있는 서비스에서 중요한 값을 프론트가 계산해 넘기고 있다면, 한 번쯤 브레이크포인트를 걸어보길 권합니다. 생각보다 많은 게 열려 있을 겁니다.