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. 왜 가능했나 — 프론트 인메모리는 항상 수정된다
2번을 겪고 나면 원칙 하나가 선명해져요.
프론트엔드에 있는 값은, 그게 변수든 상태든 hidden input이든, 사용자가 언제든 바꿀 수 있다.
우리는 보통 코드를 "위에서 아래로 순서대로, 내가 쓴 대로 흐르는 것"이라고 상상해요. 하지만 그건 우리 통제 하에서만 참이에요. 코드가 사용자의 브라우저에 도착하는 순간, 그 코드도 값도 전부 사용자의 것이 돼요. 실행을 멈추고, 값을 바꾸고, 함수를 건너뛰는 게 전부 가능해요.
그래서 프론트에서 하는 어떤 검증도 보안이 될 수 없어요.
if (amount < 0) return같은 방어 코드? → 그if문 자체를 건너뛸 수 있어요.- 값을 hidden input이나 암호화해서 숨기기? → 숨긴 값을 만드는 코드도 브라우저 안에 있으니 결국 열려요.
- 난독화? → 실행 시점의 메모리 값은 난독화와 상관없이 그대로 보여요.
프론트의 검증은 UX를 위한 것이지 보안을 위한 게 아니에요. "사용자가 실수로 이상한 값을 넣지 않게 돕는" 역할까지가 프론트의 몫이고, "악의를 가진 사용자를 막는" 건 서버만 할 수 있어요. 서버는 사용자가 손댈 수 없는 유일한 곳이니까요.
이걸 한 문장으로 하면 흔히 말하는 "클라이언트를 절대 신뢰하지 마라(never trust the client)" 예요. 우리 회사의 0원 결제는 이 원칙을 어긴 대가였고요. 금액이라는 가장 중요한 값의 근거를 프론트에 둔 것, 그리고 서버가 그걸 검증 없이 받은 것. 두 실수가 겹친 거예요.
4. 다시 설계하기 — 금액의 근거를 서버로
그럼 어떻게 고쳐야 할까요. 방향은 하나예요. 금액을 만들고 확정하는 권한을 프론트에서 서버로 옮기는 것. 프론트는 서버가 정한 금액을 보여주기만 하면 돼요.
구체적으로는 주문서(order) 개념을 서버에 두는 방식이 깔끔해요.
- 주문서를 만드는 시점에, 프론트는 상품 ID와 수량만 서버로 넘겨요. 금액은 안 넘겨요.
- 서버가 그 상품 ID로 가격·할인·배송비를 직접 계산해서 최종 금액을 정하고, 이걸 주문서에 저장해요. (
orderId와 확정 금액) - 프론트는 서버가 응답한 금액을 화면에 표시만 해요. 결제창도 이 금액으로 띄우고요.
- 결제 성공 후 최종 승인 때, 프론트는
orderId만 넘겨요. 금액은 안 넘겨요. - 서버는
orderId로 저장해 둔 금액을 꺼내서, 토스가 알려준 실제 결제 금액과 대조해요. 두 값이 같을 때만 승인해요.
이 설계의 핵심은 프론트가 아예 amount를 만들지도, 넘기지도 않는다는 거예요. 2번에서 했던 "메모리의 amount를 0으로 바꾸는" 공격은, 바꿀 amount가 프론트에 없으니 성립 자체가 안 돼요.
여기서 5번이 특히 중요해요. 흔히 저지르는 실수가 "주문서에 금액을 저장하는 것"까지만 하고, 최종 승인 때 프론트가 넘긴 금액을 다시 믿는 거예요. 그러면 주문서를 서버에 뒀어도 결국 프론트 값을 신뢰하는 셈이라 원점이에요.
최종 승인의 진짜 방어선은 "토스가 승인해 준 결제 금액 == 서버가 주문서에 저장해 둔 금액" 을 서버에서 대조하는 거예요.
토스 페이먼츠 문서에서도 이 대조를 명시적으로 권장해요. 결제 승인 API를 호출하기 전에, 결제창을 띄울 때 쓴 금액을 서버 세션(또는 주문서)에 저장해 두고, 승인 직전에 클라이언트가 보낸 금액과 서버가 저장한 금액이 일치하는지 반드시 확인하라고요. 정리하면 이렇게 흐르는 거죠.
- 주문서 생성 → 서버가 금액 확정·저장
- 결제창 호출 → 저장된 금액으로 결제
- 승인 요청 → 서버가
저장 금액 == 실제 결제 금액확인 후에만 승인
이렇게 두면 프론트에서 무슨 값을 조작하든 서버의 저장값과 안 맞으면 승인이 거부돼요. 신뢰의 근거가 사용자가 손댈 수 없는 서버 안에만 있게 되는 거예요.
마치며
0원 결제라는 자극적인 결과였지만, 원인은 아주 단순한 한 가지였어요. 믿으면 안 되는 걸 믿은 것.
- 프론트에 있는 값은 변수든 상태든 hidden input이든 사용자가 언제든 바꿀 수 있다. 개발자도구 브레이크포인트 하나면 충분하다.
- 그래서 프론트의 검증은 UX용이지 보안용이 아니다. 악의적 사용자를 막는 건 서버만 할 수 있다.
- 금액처럼 중요한 값은 근거를 서버에 두고, 프론트는 표시만 한다.
- 결제 승인의 마지막 방어선은 "서버가 저장한 금액 == 실제 결제 금액" 을 서버에서 대조하는 것이다.
지금은 다 수정됐지만, 저한텐 "클라이언트를 신뢰하지 마라"는 문장이 책 속 격언이 아니라 직접 0원을 찍어본 경험으로 남았어요. 혹시 지금 만들고 있는 서비스에서 중요한 값을 프론트가 계산해 넘기고 있다면, 한 번쯤 브레이크포인트를 걸어보길 권해요. 생각보다 많은 게 열려 있을 거예요.