현재 회사는 PG를 주요 비즈니스로 운영하고 있다. 처음 입사 당시에는 BO 파트에서 일했으나, 결제 담당 개발자의 퇴사로 인해 해당 업무를 맡게 되었다. 기존 PG 서비스는 약 10년 전에 개발되어 현재까지 운영되고 있지만, 문제가 많아 4년 전부터 리뉴얼 계획이 수립된 것으로 보인다. 그러나 진행이 원활하지 않았고, 내가 해당 파트를 담당한 이후부터 본격적으로 리뉴얼을 추진하고 있다.
PG(이하 결제창) 프로세스에서는 여러 단계에서 페이지 전환이 자주 발생한다. 여기서 페이지 전환이란, React처럼 특정 컴포넌트만 변경되는 방식이 아니라 전체 페이지가 새로 로드되는 방식을 의미한다. 이로 인해 데이터를 메모리에 저장하더라도 페이지가 변경되면 사라지게 된다. 따라서 결제 데이터를 임시로 어디에 저장할지에 대한 고민이 필요했다.
1. 브라우저 로컬 스토리지 이용
결제 요청 데이터의 크기는 비교적 작은 편이었다(3MB 이하). 데이터가 작고 로컬 스토리지를 사용하면 데이터가 유지되므로, 페이지가 전환되더라도 결제 요청 데이터를 무리 없이 불러올 수 있었다. 그러나 결과적으로 이 방식을 채택하지 않았는데, 그 이유는 다음과 같다.
1-1. 만약 결제자가 컴퓨터 관련 종사자라면?
개발자나 기술적으로 숙련된 사용자는 브라우저 개발자 도구를 통해 로컬 스토리지에 접근할 수 있으며, 이를 이용해 결제 금액을 임의로 조작할 위험이 있다.
1-2. 플랫폼
PC 및 모바일 브라우저에서는 로컬 스토리지를 별도의 권한 요청 없이 사용할 수 있다. 하지만 결제창을 개발할 때는 이 두 플랫폼뿐만 아니라 웹뷰 환경도 고려해야 한다. 안드로이드 웹뷰에서는 로컬 스토리지를 사용하려면 관련 옵션을 활성화해야 한다.
또한, 최근 개인정보 보호 정책이 강화되면서 기본 브라우저가 시크릿 모드 또는 프라이빗 모드로 설정된 사용자도 많아졌다. 이 경우 로컬 스토리지가 정상적으로 동작하지 않을 수 있다.
마지막으로, 결제창 리뉴얼 과정에서 개인적인 욕심으로 연동을 최대한 쉽고 간단하게 만들고 싶었다. 개발자가 최소한의 작업만으로 결제창을 연동할 수 있도록 하는 것도 중요한 목표였다.
2. 쿠키
로컬 스토리지를 고려했던 것처럼, 데이터 크기가 비교적 작다는 점을 이유로 쿠키 방식도 떠올렸다. 물론 단순한 쿠키 외에도 커스텀 HTTP 헤더 값을 이용하는 방법도 고려했지만, 이는 불가능했다.
그 이유는, 페이지 전체를 전환하려면 결국 form.submit() 방식을 사용해야 하는데, 이 방식에서는 커스텀 HTTP 헤더를 설정할 수 없기 때문이다.
반면, 서버에서 쿠키에 결제 데이터를 담으면, 브라우저가 자동으로 해당 쿠키 값을 포함해 요청을 보내므로 쿠키 방식이 적절해 보였다. 그러나 시크릿 모드 또는 프라이빗 모드에서는 쿠키 저장이 차단될 가능성이 있어, 최종적으로 채택하지 않았다.
위 상황을 정리하면, 첫째, 결제 데이터는 절대로 변조되어서는 안 되며, 변조가 발생할 경우 서버에서 이를 감지하고 결제를 차단해야 한다.
둘째, 시크릿 모드 또는 프라이빗 모드에서도 결제가 정상적으로 진행되어야 한다.
먼저, 데이터 변조를 어떻게 막을 것인지 해결하는 것이 우선이었다. 여러 가지 방법이 떠올랐지만, 실생활에서 쉽게 적용할 수 있는 방식을 찾던 중 HTTPS의 탄생 배경에 관심을 두게 되었다.
HTTP를 사용해 데이터를 주고받으면, 중간 공격자(Man-in-the-Middle, MITM)가 패킷을 가로채 평문 데이터를 변조할 위험이 있다. 이를 방지하기 위해 HTTPS는 데이터를 암호화하여 전송하며, 공격자가 중간에서 데이터를 가로채더라도 내용을 확인할 수 없도록 한다.
또한, HTTPS 통신을 시작하기 전에 SSL Handshake 과정이 이루어지는데, 이 과정에서 어떤 알고리즘을 사용할지 협의한 후, 비대칭키 암호화와 대칭키 암호화를 조합하여 데이터를 보호한다. 과정은 다음과 같다.
- 브라우저는 사용 가능한 암호화 알고리즘과 랜덤 문자열을 서버로 전송한다.
- 서버는 지원하는 알고리즘을 선택한 뒤, 랜덤 문자열을 생성하고, 서버의 공개키가 포함된 SSL 인증서를 응답으로 보낸다.
- 브라우저는 CA의 공개키를 이용해 인증서를 검증한 후, 주고받은 랜덤 문자열을 기반으로 키를 생성한다.
- 생성한 키를 서버의 공개키로 암호화하여 서버에 전송한다.
- 서버는 개인키로 이를 복호화한 후, 이후 단계부터는 대칭키를 사용해 데이터를 주고받는다.
SSL Handshake 과정을 참고하여 일부를 간소화한 Custom SSL Handshake를 설계하였다.
- 클라이언트는 랜덤 문자열(10자)을 생성한 후 서버로 전송한다.
- 서버는 랜덤 문자열(10자)과 공개키를 클라이언트에 반환한다.
- 클라이언트는 임시 키를 생성하고, 결제 요청 데이터를 AES(대칭키) 알고리즘으로 암호화한다.
- 암호화된 데이터를 RSA(비대칭키) 알고리즘을 사용하여 서버의 공개키로 한 번 더 암호화한 후 서버로 전송한다.
- 서버는 개인키를 이용해 복호화하여 임시 키를 획득한 뒤, 해당 키를 사용해 AES로 암호화된 데이터를 복호화한다.
하지만 위 방법은 다음과 같은 이유로 인해 사용할 수 없었다.
- 브라우저마다 지원하는 RSA 알고리즘 방식이 달랐다.
- 개발 과정에서 발견한 문제인데, 서버는 RSA 알고리즘을 지원하지만 Web Crypto API에서 RSA-OAEP를 기본 제공하고, 일반 RSA 사용은 브라우저에서 직접 허용하지 않는다.
- 키 크기, 패딩 방식, 알고리즘 지원 여부에 따라 복잡한 구현이 필요했다.
- 브라우저마다 지원하는 방식이 다르다 보니, 이를 고려하여 코드를 구현해야 했는데 너무 복잡하고 시간이 많이 걸리는 작업이었다.
- 유지보수 및 버그 관리가 어려울 것으로 예상되었다.
- 브라우저별 호환성을 맞추는 과정에서 버그 발생 가능성이 높았고, 이를 해결하는 과정에서 가맹점으로부터 많은 문의가 들어올 가능성이 컸다.
그래서 다른 PG사들은 데이터 변조를 어떻게 방지하는지 공식 문서를 참고해 보았다. 토스페이먼츠, 이니시스, 나이스페이먼츠의 결제 연동 문서를 분석을 해보았다.
토스페이먼츠는 내부적인 구현이 공개되지 않아 정확한 방식은 알 수 없었지만, 결제창 호출 시 결제 데이터를 별도로 저장한 후, 해당 데이터에 접근할 수 있는 키(paymentKey)를 통해 결제를 이어가는 방식으로 보였다.
이니시스와 나이스페이먼츠는 EncData라는 값을 사용하고 있었다. 이 값은 상품 주문번호와 결제 금액 등의 정보를 해시 함수로 변환한 값으로 추정된다. 결제 과정에서 페이지가 전환될 때마다 데이터베이스에서 결제 요청 데이터를 가져와 다시 해시 함수를 적용하고, 이를 비교(equals 연산)하여 데이터 변조를 방지하는 방식으로 보였다.
그래서 이니시스와 나이스페이먼츠의 EncData 방식을 그대로 따르는 대신, 해시 함수가 아닌 대칭키 암호화 방식을 사용하여 결제 금액과 상품 주문번호 등을 암호화하고, 이를 토큰처럼 활용하기로 결정했다.
물론, 해시 함수를 이용해 토큰을 생성한 후 데이터베이스에서 조회하여 해시 값을 검증하는 방식도 고려했지만, 이 방식은 비즈니스 로직에 과도한 중복 검증이 발생할 가능성이 높았다(AOP로 일부 해결할 수는 있지만 여전히 부담이 있었다).
대신, Interceptor 단에서 요청의 body에서 토큰 값을 가져와 복호화하고, 복호화에 실패하면 결제를 차단하는 방식이 더 적절하다고 판단했다. 이렇게 하면 검증 로직을 담당하는 클래스가 명확해지고, 코드도 훨씬 깔끔해졌다.
토큰을 생성하는 방법을 고민하던 중, 가장 널리 알려진 JWT를 먼저 떠올렸다. 하지만 JWT는 header, payload, signature 세 부분으로 구성되어 있어 예상보다 데이터 크기가 커졌고, 각 부분이 '.'으로 구분되다 보니 인증이 완료된 후 가맹점의 인증 완료 페이지로 이동할 때, 토큰 값을 URL 파라미터로 전달해야 했다. 그러나 토큰에 '.' 문자가 포함되어 있어 URL-safe하지 않은 문제가 발생했다. 압축에 대해 생각할 수 있지만 압축은 만능 도구가 아니다. 암호화된 데이터는 랜덤성을 띠므로 일반적인 압축 알고리즘이 효과적으로 작동하지 않는다. 오히려 압축 후 크기가 증가할 가능성이 높다.
이에 따라, 기존에 Custom SSL Handshake 구현 시 사용했던 AES 알고리즘을 활용하여 토큰을 생성하기로 결정했다. 생성된 바이트 데이터를 Base64로 인코딩하여 전송할 계획이었으나, Base64 인코딩 결과에 '+'와 '/' 같은 URL-safe하지 않은 특수 문자가 포함될 수 있었다. 이를 해결하기 위해 Base64 인코딩 시 URL-safe한 변환을 적용하는 별도의 함수(Base64.getUrlEncoder())를 사용하여 문제를 해결했다.
결제창을 호출하면 결제 요청 데이터에서 금액, 상품 주문 번호 등을 가져와 토큰을 생성한 후, 해당 값을 렌더링할 페이지에 적재해야 한다. 기존 결제창뿐만 아니라, 다른 PG사들의 방식을 살펴보면서 공통적으로 input 태그에 hidden 값을 설정한 후 데이터를 적재하는 방식을 사용한다는 점을 확인했다. 그러나 이 방식은 개발 지식이 조금만 있어도 쉽게 데이터를 변경할 수 있는 보안상의 취약점이 존재했다.
리뉴얼한 결제창에서는 템플릿 엔진으로 타임리프(Thymeleaf)를 사용하고 있다. 타임리프 문법을 활용하면 script 태그 내에 모델 값을 저장할 수 있지만, 이는 변조될 위험이 있다. 이를 방지하기 위해 JavaScript의 Object.defineProperties() 함수를 사용했다. 객체 수정만 막는 Object.freeze()도 사용가능하지만, Object.freeze()는 객체 속성 변경만 막을 뿐, delete window.data를 통한 삭제는 방지하지 못한다. 이를 해결하기 위해 Object.defineProperties()를 사용하여 writable: false, configurable: false 옵션을 적용함으로써 데이터의 수정과 삭제를 모두 차단했다.
또한, 페이지 전환 시 form.submit()을 사용하는데, 다른 PG사들의 경우 해당 form 내 데이터를 그대로 남겨두는 경우가 많았다. 이로 인해, 개발자 도구를 통해 어떤 데이터가 인증에 필요한지 쉽게 확인할 수 있는 문제가 있었다. 이를 해결하기 위해, 나는 submit()을 호출하자마자 form.remove()를 실행하여 데이터가 노출되지 않도록 처리했다.
정리하면, 초기 리뉴얼 작업에서 나는 데이터 변조를 방지하기 위해 AES 알고리즘을 사용하여 토큰을 생성했다. 또한, 데이터를 복호화하는 로직을 담당하는 클래스를 별도로 만들어 코드의 가독성과 구조를 개선했다.
기존에는 input 태그에 데이터를 적재하는 방식이었으나, 보안 강화를 위해 script 태그를 활용하여 모델 값을 저장하고 Object.freeze()를 사용해 수정이 불가능하도록 처리했다. 추가적으로, 데이터 노출을 방지하기 위해 form.submit() 직후 form.remove()를 실행하는 로직을 추가했다.
프로세스 흐름
- 필수 데이터(상품 주문번호, 금액, 가맹점 정보 등)를 기반으로 결제창 호출
- 서버에서 해당 데이터를 바탕으로 토큰 생성
- 랜더링할 페이지에서 모델을 적재하고 Object.defineProperties()를 적용하여 수정/삭제 불가능하게 설정
- 사용자가 카드를 선택하면, 결제를 식별할 수 있는 고유 번호를 생성하고 결제 데이터를 데이터베이스에 저장
- 인증이 완료되면, 식별 아이디와 토큰 값을 가맹점의 인증 완료 페이지 URL로 전달
'회사' 카테고리의 다른 글
| 결제창 코드 리팩토링 후기 (0) | 2025.07.26 |
|---|---|
| 실제 PG 결제창에서 발생한 중복 결제 문제 해결기 (0) | 2025.02.17 |