Skip to main content
비하인드
비하인드SecurityPremium

우리 결제 시스템을 직접 감사해서 찾아낸 것들

치명적인 문제가 두 개 나왔는데, 둘 다 우리가 찾아볼 법한 곳에 있지 않았어요. 지금은 모두 고친 실수들에 대한 공개 보고서예요.

게시일 작성자 AlphaCloud3 분 분량

2026년 8월, 우리는 결제 시스템을 처음부터 다시 검토했어요. 무슨 일이 생겨서가 아니라, 얼마 전에 출시했으니 무엇을 놓쳤는지 알고 싶었기 때문이에요.

놓친 게 꽤 많았어요. 이 글은 그 결과를 하나하나 짚어요. 여기에 적힌 모든 문제는 이미 고쳤고, 대부분은 어차피 공개 변경 로그에도 올라가 있었어요.

치명적인 문제 두 개가 나온 모델링 오류

누군가 우리에게서 Premium을 사면, 결제 대행사에 고객 레코드가 만들어져요. 우리는 그 레코드를 서버 ID 기준으로 저장했어요. 어쨌든 Premium은 서버에 적용되니까요.

그게 바로 틀린 모델이었고, 가장 심각한 두 문제의 뿌리였어요.

고객 레코드는 구매자의 것이에요. 거기에는 구매자의 결제 수단, 청구서 내역, 그리고 누군가 서버 두 개에 결제하는 순간 여러 개의 구독이 담겨요. 이걸 서버 ID로 묶으면 여러 사람이 같은 행을 가리키게 돼요.

문제 1: 남의 고객 데이터를 결제 없이 덮어쓸 수 있었어요

구매는 서버의 모든 멤버에게 열려 있고, 고객 레코드를 찾거나 만드는 함수는 결제 전에 실행돼요. 이 함수는 서버 ID로 행을 찾은 다음, 결제 대행사에 있는 이메일 주소까지 포함해 새로운 구매 희망자의 것으로 조용히 바꿔 버렸어요.

실제로 이건 이런 뜻이었어요: 누군가 한 번이라도 구매한 적이 있는 서버에서 클릭 한 번이면 충분했어요. 그 뒤로는 원래 구매자의 모든 구독에 대한 청구서가 낯선 사람의 이메일 주소로 갔을 거예요. 결제도, 확인도 없이요.

재사용하기 전에 그 레코드가 정말 이 계정의 것인지 확인하도록 고쳤어요. 일치하지 않으면 별도의 레코드를 만들고, 다른 레코드는 건드리지 않아요.

문제 2: 결제 포털에서 남의 구독이 보였어요

같은 모델링 오류가 한 단계 더 나아간 경우예요. 소유권 확인은 서버의 구독에 적용됐지만, 포털 세션은 설계상 여러 서버를 담을 수 있는 고객 레코드에 대해 열렸어요.

그래서 한 서버에서 정상적으로 구매한 사람은 누구나, 같은 레코드에 담긴 다른 서버의 구독을 보고 관리할 수 있었어요.

고객 레코드를 서버 행이 아니라 해당 구매에서 찾도록 고쳤어요.

코드가 우리 약관과 어긋났던 두 가지

이 부분이 가장 마음에 걸렸어요. 기술적인 문제가 아니었으니까요.

블랙리스트가 이미 결제한 기간을 빼앗았어요. 우리 약관에는 구독이 현재 기간이 끝날 때 종료된다고 적혀 있어요. 그런데 서버를 차단할 때 코드는 즉시 해지를 호출했어요: 환불도 없고, 이미 결제한 기간인데도 그 순간부터 서비스도 없었죠. 코드가 우리가 글로 약속한 것과 다른 일을 한 거예요. 이제는 결제한 기간이 끝까지 유지돼요.

철회권 포기를 증명할 수 없었어요. 구매 전에는 서비스가 즉시 시작된다는 점을 확인해야 해요. 그런데 그 확인은 브라우저에만 있었고, 서버로 전송되지도 않았고, 어디에도 저장되지 않았어요. 입증 책임은 우리에게 있는데, 증거가 하나도 없었던 거예요. 이제 동의는 결제 경로의 필수 항목이고, 시간 기록과 버전이 붙은 문구와 함께 저장돼요. 문구 자체도 버전으로 관리해요: 기존 문구는 절대 수정하지 않고 항상 새 버전을 만들어요. 그렇지 않으면 예전 기록을 더 이상 재현할 수 없으니까요.

두 번째 검토가 첫 번째보다 더 나빴어요

처음 21개 문제를 고친 뒤, 몇 주 후에 다시 살펴봤어요. 옳은 판단이었어요. 가장 심각한 문제는 그때서야 드러났거든요.

서버 B의 환불이 서버 A의 유료 평생 라이선스를 지웠어요. 환불과 지불 거절 알림에는 결제만 적혀 있고 서버는 적혀 있지 않아요. 우리는 이걸 고객 레코드를 통해 찾았어요. 그런데 첫 번째 검토에서 레코드가 여러 서버에 걸쳐 한 구매자의 것이라는 걸 제대로 정리하고 나니, 바로 그 찾는 방식이 더 이상 하나로 정해지지 않았어요. 서버 두 개에 결제하고 그중 하나를 환불받은 사람이 다른 서버의 평생 라이선스를 잃을 수 있었어요.

이제는 네 단계의 정확한 절차로 찾고, 하나로 정확히 정할 수 없는 건 추측하지 않고 보고해요.

이긴 지불 거절이 아무것도 되돌리지 못했어요. 지불 거절이 들어오면 코드는 구매 행을 지웠어요. 그 서버가 평생 라이선스를 가졌다는 유일한 증거를요. 나중에 분쟁에서 이겨도 되돌릴 수가 없었어요. 이제는 지우지 않고 표시만 해요.

데이터 보관 정책이 유료 서버를 지웠어요. 봇이 서버를 떠나면 그 서버의 데이터는 30일 뒤에 삭제돼요. 이 정리 작업은 진행 중인 구독이 연결되어 있는지 한 번도 묻지 않았어요.

우리가 얻은 것

버그는 결제 코드에 있지 않았어요. 치명적인 문제 중 금액, 통화, 계산에 관한 건 하나도 없었어요. 전부 그 뒤에 있는 모델에 있었어요: 고객 레코드는 누구의 것인지, 결제는 누구의 것인지, 서버는 누구의 것인지. 금액만 보면서 결제 시스템을 감사하는 사람은 정작 위험한 부분에서 눈을 돌리고 있는 거예요.

감사에는 두 번째 검토가 필요해요. 가장 심각한 문제는 첫 번째 검토에서 한 수정의 직접적인 결과였어요. 우연이 아니에요: 모델을 고치면, 다른 곳에서 여전히 유지되던 가정이 무너져요.

"우리 약관에 그렇게 적혀 있어요"는 확인할 수 있는 문장이에요. 두 문제는 우리가 글로 약속한 것과 코드가 한 일이 어긋난 경우였어요. 읽어 보면 알 수 있는데도 아무도 그러지 않아요. 엔지니어링이 아니라 법처럼 보이니까요.

구매 조건 전체는 이용약관에, 철회권은 철회에 있어요.

더 많은 글