기록
NicePay 모바일웹 결제 트러블 슈팅
들어가며
안녕하세요, 약 3개월 만에 글을 쓰게 되었습니다. 그동안 많은 일이 있었습니다. 부트캠프를 수료하고 인턴으로 입사하여 2개월째 다니고 있습니다.
입사 일주일 후 팀에서 두 명이 동시에 퇴사하면서 예상치 못한 상황을 맞이했습니다. 처음 보는 프로젝트의 유지보수를 맡게 되었고, 5명이 진행할 예정이었던 신규 프로젝트를 3명이서 개발해야 하는 상황이 되었습니다. 그렇다고 다른 팀에서 인원을 빼올 순 없으니까요.. 중간에 결국 4명에서 하게 됐습니다 ㅠ
더 어려웠던 점은 기존에 익숙했던 Java/Spring 스택이 아닌 JS,TS 기반의 React/Node.js 프로젝트였다는 것입니다. Node.js는 처음이라 퇴근 후에도 학습을 병행하며 힘들게 프로젝트를 진행했습니다.
다행히 큰 이슈 없이 진행되는 듯했으나, 유지보수하던 프로젝트가 정식 오픈 일주일 전 결제 관련 문제가 발생했습니다.
문제 상황 (Problem)
"PC웹에서는 결제가 정상 작동하는데, 모바일웹에서는 결제가 되지 않습니다."
오픈을 앞둔 상황에서 결제 기능에 장애가 발생했습니다. 이 프로젝트는 제가 처음부터 구현한 것이 아니라 이미 구현된 상태에서 변경사항을 반영하는 형태로 작업했기 때문에, 전체 구조에 대한 이해가 부족한 상태였습니다.
에러 현상
결제 흐름을 테스트한 결과, PC웹과 모바일웹의 로직이 같은데도 결과가 달랐습니다.
- PC웹: pay1 → 결제창 → pay2
- 모바일웹: pay1 → 결제창 → 405 Error
405 에러는 허용되지 않은 HTTP 메서드를 사용했을 때 발생하는 에러이기에 POST 요청에서 405 에러가 발생하여 GET 메서드로 변경해봤지만 동일한 에러가 발생했습니다.
디버깅 과정
같은 결제 로직을 사용하는데 모바일웹에서만 에러가 발생하는 이유를 파악하기 위해, isMobile 분기를 추가하여 PC웹과 모바일웹을 분리한 후 로그를 통해 디버깅을 진행했습니다.
pay1에서 결제까지 정상적으로 진행이되지만, 결제창에서 pay2로 넘어가는 과정에서 계속 405 에러가 발생했습니다.
원인 분석
PC웹과 모바일웹의 결제 흐름을 자세히 비교하면서 근본적인 차이점을 발견했습니다.
PC웹 결제 방식
PC에서는 NicePay가 제공하는 goPay() 를 사용합니다. 이 함수는 결제 정보가 담긴 form을 받아 Iframe으로 결제창을 띄웁니다.
결제 완료 후 처리는 미리 등록해둔 전역 콜백 함수(window.nicepaySubmit, window.nicepayClose)를 통해 이루어집니다.
PC웹 흐름
pay1 → Iframe 결제창 → 클라이언트 검증(콜백) → pay2
모바일웹 결제 방식
모바일웹에서는 goPay() 함수를 사용하지 않습니다. 대신 전체 페이지 리다이렉트 방식을 사용합니다.
(사실 기존 코드에서 PC웹 결제 부분에서는 사용하지도 않는 ReturnURL을 사용하고 있어서 더 헷갈렸습니다..)
form의 action 속성을 NicePay 모바일 결제 URL로 설정한 후 .submit() 메서드를 호출하여 결제 페이지로 이동시킵니다.
모바일웹 흐름
pay1 → 결제 페이지 → 외부 앱(카드사/결제앱) → ReturnURL → pay2
왜 모바일은 다른 방식을 사용할까
모바일에서는 결제 시 ispmobile://, kftc-bankpay://, intent:// 같은 URL Scheme을 통해 외부 결제 앱으로 이동합니다.
팝업이나 Iframe 방식으로는 이러한 앱 전환 흐름을 안정적으로 처리하기 어렵습니다. 모바일 브라우저에서 팝업은 사용자 경험을 해치며, 보안상 차단되는 경우도 많습니다.
따라서 전체 페이지 리다이렉트 방식이 모바일 결제의 표준으로 자리 잡았습니다.
인증(Authentication)과 승인(Approval)의 분리
결제 프로세스는 크게 두 단계로 구성됩니다.
- 인증(Authentication): 사용자가 카드 정보를 입력하고 본인 확인
- 승인(Approval): 인증 정보를 바탕으로 실제 금전 거래 확정
PC웹의 nicepaySubmit() 콜백은 승인까지 완료된 결과를 클라이언트에 직접 전달합니다.
모바일웹에서는 사용자가 외부 앱에서 인증을 완료하면, NicePay가 ReturnURL로 지정된 백엔드 서버로 AuthToken, Tid 같은 인증 결과만 POST 요청으로 전달합니다.
백엔드 서버는 이 데이터를 받아 서버 대 서버 통신으로 NicePay의 승인 API를 호출하여 최종 결제를 완료합니다.
이 2단계 절차는 사용자가 브라우저를 닫거나 네트워크가 끊겨도 결제 데이터가 유실되거나 위변조되는 것을 방지하는 중요한 보안 장치입니다.
문제의 핵심
결론은 모바일웹에서 POST 요청을 받을 서버 엔드포인트가 존재하지 않았습니다.
NicePay가 ReturnURL로 인증 결과를 전달하려 했지만, 해당 엔드포인트가 없어 405 에러가 발생한 것입니다.
해결 방법 (Solution)
/mobile/result 엔드포인트를 추가하여 모바일 결제 흐름을 수정하였습니다.
// ex) payments.js
router.post('/mobile/result', async function (req, res) {
try {
// 1. NicePay에서 전달받은 인증 데이터 추출
// 2. NicePay 승인 API 호출 (서버 대 서버)
// 3. 결제 정보 DB 저장
// 4. 프론트엔드 결과 페이지로 리다이렉트
return res.redirect(`${domain}/pay2?status=success`);
} catch (error) {
return res.redirect(`${domain}/pay2?status=fail`);
}
});
변경된 흐름
pay1 → 결제 페이지 → /mobile/result (서버 검증) → pay2
정리 (Summary)
이번 이슈는 PC웹과 모바일웹의 결제 아키텍처 차이를 이해하지 못해 발생한 문제였습니다.
| 구분 | PC웹 | 모바일웹 |
|---|---|---|
| 결제창 | Iframe/팝업 | 전체 페이지 리다이렉트 |
| 결과 처리 | 클라이언트 콜백 함수 | 서버 엔드포인트(ReturnURL) |
| 검증 방식 | 클라이언트 사이드 | 서버 사이드 |
| 보안 | 클라이언트 의존 | 서버 간 통신으로 강화 |
배운 점
- PC와 모바일의 결제 방식이 근본적으로 다르다는 것
ReturnURL의 역할과 서버 사이드 검증의 중요성- 모바일 결제에서 URL Scheme을 통한 앱 전환 메커니즘
- 인증(Authentication)과 승인(Approval)의 분리가 왜 필요한지
결제 로직을 처음 다뤄서 모든것이 낯설고 어려웠는데, 웹과 모바일웹의 차이점을 분석하고 찾아보면서 결제관련 아키텍쳐를 배울 수 있어서 좋은 경험이었던 것 같습니다.
지금보면 간단해보이지만 무려 이틀동안 이걸 붙잡고 있었다는 무서운 이야기가..