회원 포인트 잔액을 세어 보니 음수인 회원이 40여 명이었다. 합계 약 -2,200만 원. 가진 것보다 많이 쓴 것이다. 사용액이 잔액을 넘는지 보는 검사가 자바스크립트 한 군데에만 있었다. 컨트롤러·서비스·매퍼 어디에도 하한 검사가 없었다. 에러가 안 나기 때문에 아무도 몰랐다. 음수 잔액도 숫자라서 화면에 그대로 찍히고, 시스템은 정상 동작으로 본다.
검증이 화면에만 있었다
코드를 훑었다. 사용액이 잔액을 넘는지 보는 곳은 딱 한 군데였다.
// res004Common.js
if (Number(usePoint) > Number(balance)) {
alert(MESSAGE.MSG0026); // "보유 포인트를 초과했습니다"
return false;
}
저장 쿼리는 이렇게 생겼다.
-- 직전 잔액에 이번 증감을 그대로 더한다
INSERT INTO USER_PAYMENT (..., BALANCE)
VALUES (..., #{beforeBalance} + #{payAmount} * #{payType})
들어온 값을 그대로 더한다. 음수가 되든 말든 검사가 없다.
어떻게 음수가 되나
프론트 검증만 있으면 뚫리는 경로가 여러 개다.
1. 요청을 직접 보낸다. 개발자 도구에서 값을 바꿔 POST 하면 끝이다. 백오피스라 외부 공격보다는 가능성이 낮지만, 0은 아니다.
2. 다른 경로로 들어온다. 이게 실제 원인에 가깝다. 포인트가 바뀌는 지점이 여러 곳인데, 검증은 한 화면에만 있었다.
- 수납 화면에서 사용
- 예약 확인완료 처리 중 자동 차감
- 관리자가 수동으로 조정
확인완료 경로에는 화면 검증 자체가 없다. 백그라운드 처리라 알림창을 띄울 데가 없다.
3. 소급 입력. 이게 제일 까다롭다. 잔액이 날짜 기준 누적이라, 과거 날짜로 사용 이력을 넣으면 그 시점 이후의 모든 잔액이 밀린다. 지금 잔액만 보고 막으면 이건 못 막는다.
왜 아무도 몰랐나
에러가 안 나기 때문이다. 음수 잔액도 숫자다. 화면에 그대로 표시된다. -50,000원이 찍혀도 시스템은 정상 동작이라고 본다.
그리고 개별 건은 작다. 몇 만 원씩이라 점포에서도 "그런가 보다" 하고 넘어간다. 전체를 세어 봐야 40여 명 / 2,200만 원대가 보인다.
정기적으로 무결성을 확인하는 쿼리가 없으면 이런 건 영원히 안 드러난다. 비교할 기준값이 없으면 틀린 숫자를 검증할 방법이 없다는 것과 같은 구조다 — 그쪽은 대상자 수가, 이쪽은 잔액이 조용히 틀려 있었다.
어디에 넣어야 하나
포인트가 바뀌는 지점을 전부 찾았다. 세 군데였다.
insertUserPayment 수납 화면에서 사용
updateUserPayment 수정
확인완료 처리 경로 자동 차감
세 곳 모두에 넣어야 한다. 한 곳만 막으면 나머지로 샌다.
넣을 자리는 이미 있었다. 수납 경로는 직전 잔액을 이미 읽고 있었다.
Map<String, Object> before = paymentDAO.getLastBalance(param);
// ← 여기서 검사하면 된다. 값이 이미 손에 있다
읽어 놓고 안 쓰고 있었던 것이다. 검사 한 줄이면 됐다.
소급 입력까지 막으려면
지금 잔액이 아니라 그 시점의 잔액이 필요하다.
SELECT BALANCE FROM USER_PAYMENT
WHERE USER_ID = #{userId} AND PAY_DATE <= #{payDate}
ORDER BY PAY_DATE DESC, SEQ DESC LIMIT 1
그리고 그 뒤의 이력도 전부 다시 계산해야 한다. 단순한 하한 검사보다 훨씬 무겁다.
그래서 단계를 나눴다. 먼저 현재 시점 검증을 넣어 새로 생기는 것을 막고, 소급 건은 별도로 다룬다. 완벽한 해법을 기다리다 아무것도 안 넣는 것보다 낫다.
기존 음수는 어떻게 하나
코드를 고쳐도 이미 쌓인 40여 명은 그대로다. 검증은 앞으로 생길 것만 막는다.
정리는 별개 작업이다. 그리고 이건 개발이 혼자 정할 문제가 아니다.
- 0으로 올릴 것인가 (회사가 손실 부담)
- 회원에게 청구할 것인가
- 점포별로 다르게 처리할 것인가
데이터 보정은 정책 결정이 먼저다. 기술적으로는 UPDATE 한 줄이지만 그 한 줄이 돈에 대한 결정이다.
일반화하면
프론트 검증은 UX지 보안이 아니다. 사용자가 실수하기 전에 알려 주는 용도다.
판단 기준 하나 — 그 검증이 없으면 데이터가 깨지는가?
- 깨진다 → 서버에도 반드시 있어야 한다
- 안 깨진다 (형식 안내, 오타 방지) → 프론트만으로 충분하다
그리고 한 값이 여러 경로로 바뀌면 검증은 그 값 옆에 둔다. 화면마다 넣으면 반드시 하나를 빠뜨린다. 우리가 그랬다.
가장 좋은 건 DB 제약이다. 잔액에 CHECK (BALANCE >= 0)을 걸면 어느 경로로 들어와도 막힌다. 기존 음수 데이터 때문에 지금은 못 걸지만, 정리한 뒤에는 이게 답이다.
에러가 아니라 "아무 일도 안 일어남"으로 나타나는 문제가 이 시리즈에 계속 나오는데, 이번 건은 한 발 더 나갔다. 아무 일도 안 일어나는 게 아니라 잘못된 일이 정상처럼 일어났다.
정리
- 프론트 검증만 있는 상태로 오래 돌면 조용히 데이터가 깨진다. 음수 잔액 40여 명 / 약 -2,200만 원.
- 값이 바뀌는 경로가 여러 개면 전부에 검증이 필요하다. 화면이 없는 경로가 특히 위험하다.
- 검증에 필요한 값을 이미 읽고 있는 경우가 많다. 쓰지 않고 있을 뿐이다.
- 누적 잔액은 소급 입력이 가능해서 "현재 잔액" 검사만으로는 부족하다. 단계를 나눠 넣는다.
- 기존 데이터 보정은 정책 결정이다. 코드 수정과 분리한다.
- 최종 방어선은 DB 제약이다. 어느 경로로도 못 뚫는다.
- 정기적으로 무결성을 세는 쿼리가 없으면 이런 건 영원히 안 드러난다.
'웹개발' 카테고리의 다른 글
| HTTPS 리다이렉트 무한 루프 — 앱이 원래 프로토콜을 모르기 때문이다 (0) | 2026.09.28 |
|---|---|
| 차단했는데 12시간을 더 쓴다 — 세션 인증에는 무효화가 없다 (0) | 2026.09.21 |
| 메서드 이름이 트랜잭션을 결정한다 — Spring AOP 일괄 트랜잭션의 함정 (0) | 2026.09.17 |
| npm ci와 npm install 차이 — 속도가 아니라 락 파일을 대하는 태도다 (0) | 2026.09.14 |
| Spring readOnly 트랜잭션을 리더로 보내면 생기는 read-after-write 문제 (0) | 2026.09.13 |
댓글