웹훅에는 결제 검증이 있었는데, 브라우저 콜백에는 없었습니다
학교 서류 접수 서비스에서 결제가 끝나면 그 자리에서 신청 접수가 확정됩니다. 돈을 받았으니 서류를 받아준다는, 서비스에서 가장 중요한 한 칸입니다. 이 칸이 실제 결제 없이 넘어갈 수 있는 상태였다는 것을 결제 후속 작업을 정리하다 발견했습니다.
코드에는 결제 검증이 분명히 있었습니다. 다만 결제 완료가 서버에 도달하는 길이 둘이었고, 검증은 그중 한쪽에만 있었습니다.
결제가 끝났다는 신호가 두 길로 들어옵니다
결제창을 띄우고 나면 '이 결제가 끝났다'가 서버에 도달하는 경로가 둘입니다. 하나는 결제대행사가 우리 서버로 직접 쏘는 웹훅(결제 결과를 서버 대 서버로 통지하는 호출)입니다. 다른 하나는 사용자의 브라우저가 결제창을 닫고 돌아오면서 부르는 완료 처리 API 입니다.
경로를 둘로 둔 것 자체는 정상 설계입니다. 웹훅은 늦거나 실패할 수 있어서 그것만 믿으면 화면이 멈춘 채로 있게 됩니다. 사용자는 결제 직후 결과를 바로 봐야 하고, 그래서 브라우저가 돌아오는 길이 따로 필요합니다.
문제는 둘이 같은 일을 한다는 데 있었습니다. 두 경로 모두 최종적으로 접수를 확정합니다. 그런데 결제대행사에 다시 물어보는 코드는 웹훅 쪽에만 있었습니다.
웹훅 — 대행사 단건조회로 결제 상태·주문번호·금액 셋을 대조한 뒤 접수를 확정합니다
브라우저 완료 처리 — 클라이언트가 보낸 결제 식별자를 그대로 믿고 접수를 확정했습니다

한쪽에만 있는 검증은 있는 검증처럼 보입니다
이 구멍이 오래 살아 있었던 이유는 단순합니다. 코드를 읽는 사람이 '결제 검증이 있나'를 찾으면 웹훅 쪽에서 한 번 걸리고, 거기서 탐색이 끝납니다. 대조 코드가 눈에 보였으니 검증은 있는 것이 됩니다.
처음엔 '웹훅이 막고 있으니 괜찮은 것 아닌가'를 따져 봤습니다. 성립하지 않았습니다. 두 경로는 서로를 기다리지 않습니다. 브라우저 쪽이 먼저 도착하면 그 자리에서 접수가 확정되고, 뒤늦게 온 웹훅은 이미 확정된 건을 봅니다. 나중에 오는 검증은 앞서 열린 문을 닫지 못합니다.
그래서 물어야 하는 질문이 바뀝니다. '그 검증이 있나'가 아니라 '그 검증이 모든 진입점에 있나'입니다. 같은 상태를 바꾸는 입구를 먼저 세고, 그다음에 입구마다 검증을 셉니다. 순서를 바꾸면 한 번 걸린 곳에서 탐색이 멈춥니다.
그 사람 것인가와 실제로 일어났나는 다른 질문입니다
이 구멍은 인가 검사가 하나도 빠지지 않은 상태에서 열려 있었습니다. 로그인한 사용자였고, 접수 건도 자기 것이었습니다. 권한 검사는 전부 통과했고 결제만 없었습니다.
클라이언트가 들고 온 식별자에는 질문이 둘 있습니다. 하나는 '그 사람 것인가'이고 다른 하나는 '실제로 일어났나'입니다. 앞의 질문은 우리 DB 가 답할 수 있습니다. 뒤의 질문은 원천만 답합니다. 인가를 통과했다고 사실이 확인된 것이 아닙니다.
브라우저를 거쳐 돌아온 값은 사용자 손이 닿은 값입니다. 성공 여부·금액·귀속을 그 값으로 판단하면 안 됩니다. 돈이 걸린 경로는 앞단이 무엇을 보냈든 원천에 다시 묻습니다.
고칠 때 대조 항목을 셋으로 둔 이유도 같은 자리에 있습니다. 상태만 보면 다른 건의 성공한 결제가 통과합니다. 주문번호만 보면 금액이 다른 결제가 통과합니다. 그래서 상태·주문번호·금액 셋을 모두 봅니다. 셋 중 하나라도 어긋나면 접수되지 않습니다.

대조를 하나 더 쓸까, 헬퍼로 합칠까
고치는 방향은 둘이었습니다. 브라우저 쪽에도 대조 코드를 하나 더 쓰는 방법과, 대조를 공용 헬퍼로 빼고 두 경로가 같은 것을 부르게 하는 방법입니다.
방향 | 하는 일 | 걸리는 것 |
|---|---|---|
대조 코드를 하나 더 쓴다 | 브라우저 완료 처리에 같은 대조를 복제한다 | 같은 판정이 두 곳에 남는다. 다음에 한쪽만 고쳐질 수 있다 |
공용 헬퍼로 합친다 | 두 경로가 불일치 사유를 돌려주는 헬퍼 하나를 부른다 | 경로별 차이를 뭉개지 않게 합칠 범위를 정해야 한다 |
합치는 쪽을 골랐습니다. 중복을 줄이려는 선택이 아니었습니다. 둘로 두면 다음에 한쪽만 고쳐져 또 갈라지는데, 이번 사고가 정확히 그 형태였기 때문입니다. 합칠지 말지는 '지금 똑같나'가 아니라 '앞으로 같이 바뀌어야 하나'로 정합니다.
지금은 두 경로가 같은 헬퍼 하나를 부릅니다. 불일치면 그 헬퍼가 사유를 돌려줍니다. 다음에 진입점이 셋이 되어도 같은 것을 부르면 됩니다.
옛 코드에 먼저 대 보고 넣은 테스트
회귀 테스트 5개를 새로 넣었습니다. 넣기 전에 옛 코드에 대고 먼저 돌려서 5개가 실제로 실패하는 것을 확인했습니다. 고친 뒤에는 전부 통과했고, 해당 모듈 테스트 32개도 함께 통과했습니다.
이 순서를 지킨 이유가 있습니다. 옛 코드에서도 통과하는 테스트는 아무것도 증명하지 않습니다. 관문이나 검증에 붙은 테스트가 그렇게 되면, 한 번도 막은 적 없이 초록불만 켜고 있는 상태가 됩니다. 테스트가 늘었다는 사실과 검증이 실제로 동작한다는 사실은 다릅니다.
배포 후에는 결제 관련 설계 문서도 같이 갱신했습니다. '왜 두 경로가 같은 헬퍼를 부르는가'가 코드에만 있으면 다음 사람이 한쪽만 고칩니다. 개발·리뷰·배포는 같은 날 마쳤고, 최종 리뷰에서 Critical 은 0건이었습니다.

합친 것은 대조 한 조각이지 경로 전체가 아닙니다
합치기에도 선이 있습니다. 웹훅은 비로그인 진입점이고 브라우저 완료 처리는 로그인 진입점입니다. 입력을 얼마나 믿을 수 있는지도, 에러를 어디까지 보여줘도 되는지도 다릅니다. 경로 전체를 한 함수로 합쳤다면 그 차이가 뭉개졌을 겁니다. 합친 것은 대조 한 조각입니다.
이 선택으로 새로 지는 비용도 있습니다. 대행사 단건조회에 의존하는 만큼 그쪽 지연과 장애를 그대로 받습니다. 완료 처리가 느려지고, 대행사가 응답하지 않으면 접수가 안 됩니다. 그 값이 사실의 원천이라 감수하는 쪽이 맞다고 봤지만, 타임아웃과 실패 시 처리는 따로 정해야 합니다. 보류로 두고 뒤에 온 웹훅이 확정하게 하는 형태가 후보입니다.
셋을 대조해도 만능은 아닙니다. 금액과 주문번호까지 맞는 실제 결제를 가져오는 경우는 이 대조로 걸러지지 않습니다. 그 앞에는 주문번호를 예측 불가능하게 만드는 것과, 이미 소비된 결제를 다시 쓰지 못하게 막는 것이 따로 있어야 합니다.
경로를 애초에 둘로 두지 않는 선택도 있습니다. 웹훅만 정본으로 두고 화면은 결과를 폴링하는 방식입니다. 이 구멍은 아예 생기지 않는 대신 사용자 대기 시간과 구현 비용을 냅니다. 규모가 작으면 이쪽이 단순합니다.
같은 것을 우리 코드에서 확인하려면
비슷한 구조는 흔합니다. 결제 결과를 받는 창구가 둘 이상인 서비스라면 아래를 한 번 세어 보면 됩니다.
같은 상태를 바꾸는 진입점이 몇 개인지 먼저 센다 — 검증부터 찾으면 한 곳에서 탐색이 멈춥니다
진입점마다 원천 재확인이 있는지 각각 확인한다 — 한 곳에 있는 것으로 갈음하지 않습니다
대조 항목이 상태 하나뿐인지 본다 — 상태·주문번호·금액을 함께 봐야 합니다
같은 판정이 두 곳에 복제돼 있으면 합칠 후보로 둔다 — 기준은 앞으로 같이 바뀌어야 하는가입니다
검증에 붙인 테스트를 옛 코드에 대고 돌려 본다 — 거기서도 통과하면 그 테스트는 아무것도 막지 않습니다
마무리
이 건은 새로운 취약점이 아니라 이미 알고 있던 규칙이 한쪽 경로에만 적용된 상태였습니다. 검증은 있었고, 다만 전부에 있지 않았습니다. 그래서 코드를 읽는 사람 눈에는 계속 안전해 보였습니다.
발견부터 배포까지 하루였고, 실제로 오래 걸린 부분은 고치는 일이 아니라 '이게 정말 통과하나'를 문장으로 닫아 보는 일이었습니다. 돈이 걸린 경로에서는 그 확인을 진입점 수만큼 반복하는 편이 안전합니다.