도입 계기
작년 말쯤 카카오 테크 캠퍼스에서 결혼 시장에서 소비자를 보호하기 위한 예비 부부와 웨딩 플래너를 매칭해주는 서비스를 개발했습니다. 이때 서비스의 차별성을 두고자, 멤버십 결제 기능을 도입해서 멤버십에 가입한 사람들에게만 실거래가격 정보를 공개해주었습니다. 흔히들 아는 넷플릭스, 왓챠 처럼 멤버십 제도로 1년에 6700원 정도로 가격을 책정해두고 진행했습니다.
결제를 도입하기 위해서 고민을 해본 바로는, 토스 페이먼츠가 테스트 할 수 있으며, 다양한 결제 채널을 이용할 수 있어 토스 페이먼츠의 간편 결제 기능을 도입하기로 했습니다.
도입
토스페이먼츠 로직
먼저 결제 시스템의 무결성 보장에 대한 얘기를 하기 앞서, 토스 페이먼츠의 로직에 대해 설명하겠습니다. 해당 내용이 있어야 이후 내용을 훨씬 잘 이해하실 수 있을 거예요
토스 페이먼츠는 결제를 요청과 인증, 그리고 승인으로 나눠서 진행을 합니다. 먼저 결제 요청과 승인을 살펴보겠습니다. 구매자는 주문서의 상품 정보와 결제 금액을 확인하고 결제하기 버튼을 클릭합니다. 이후 프론트에서 결제 요청 메서드를 호출하여 결제창을 엽니다. 구매자는 카드 정보를 입력하거나 간편결제로 결제 정보를 불러오고, 카드사는 해당 정보가 유효한지 카드 소유자를 인증해줍니다.
이러한 과정이 모두 완료되면, 이제 실제로 결제를 마무리하는 결제 승인 단계로 넘어가게 됩니다. 서버에서 아까 인증 성공했을 때 받은 값들을 파라미터로 넣어서 토스페이먼츠로 결제 승인 API를 호출합니다. API 호출이 완료되면 카드사에서 결제 금액을 실제 고객의 계좌에서 차감하여 결제를 마무리합니다.
초기 접근
처음에는 프론트측에서 결제 요청과 인증, 그리고 승인을 모두 처리한뒤, 서버로 사용자 등급을 올리는 업그레이드를 올리는 API를 따로 호출하도록 구현했습니다.
멤버십 기능이 핵심적인 기능이 아니다 보니, 앞서 말한 토스페이먼츠의 방식과는 전혀 다른 프론트에 100% 의존하는 결제 시스템을 만들어진 것이었습니다. 심지어 저는 사실 결제 로직에 대해서도 잘 알지 못했습니다. 프론트 팀원 분께서 결제 시스템을 먼저 제안해주셨고 서버에게는 필요한 API만 따로 요청을 해주셨기 때문입니다.
물론 해당 방식이 구현하기에 편리하긴 했습니다. 서버 측에 부담도 덜어지고요. 하지만 이렇게 구현했을 때 여러 문제점이 발생한다는 사실을 인식했습니다.
문제점 발견
- 악의적인 사용자가 결제 금액을 조작할 경우, 대처하기 어렵다 우리 컴공이라면 한번쯤? 해보셨을 수도 있을텐데, F12를 눌러서 request body를 임의로 변경할 수 있다는 거 아실 겁니다. 이러한 방식으로 6700원짜리 멤버십을 단돈 1원에 구매할 수 있다는 치명적인 문제가 발생한다는 거죠.
- 사용자가 결제창을 닫아 버린 경우 PG사 (카드 결제사)에는 결제 완료 상태로 남아 데이터가 어긋난다.
- 사용자 업그레이드 호출 API를 임의로 호출할 경우, 결제를 하지 않고 멤버십에 가입할 수 있다. 사용자가 토큰 정보를 입력해서 사용자 업그레이드를 한다면, 0원으로 멤버십에 가입하는 무서운 일이 발생하는 거죠..
초기 방식으로 개발해도 테스트 상태이기 때문에, 문제가 되는 건 아니었지만, 조금 더 안정적이고, 보안이 좋은 서비스를 개발하고 싶다는 마음이 들었습니다.
이에 따라 현업 멘토님께 자문을 구하고, 프론트엔드 측 팀원이랑 함께 기나긴 논의 끝에 다른 방법을 사용하기로 했습니다.
최종 해결
이러한 문제를 해결하기 위해 결제 요청 전후에 관련 정보를 저장 및 검증하는 절차를 추가하고, 검증이 완료되면 서버에서 토스페이먼츠로 결제 승인 API를 호출한 뒤, 성공한 경우에만 사용자를 업그레이드했습니다.
조금 더 자세히 알아보겠습니다.
- 구매자가 결제 요청을 하기 전, 서버로 paymentKey, 가격, orderId 등 결제 요청 데이터를 임시로 저장합니다.
- PG사 서버에서 인증이 완료되면, 해당 인증 내용과 1번에 저장한 내용을 비교합니다.
- 2번의 내용이 유효한 경우, 토스 페이먼츠로 결제 승인 API를 호출합니다.
- 결제 승인 API가 성공하면 유저 등급을 업그레이드하여 멤버십 가입을 완료합니다.
해당 방법을 사용하면 앞서 말한 3가지 문제를 모두 해결 할 수 있었습니다.
다음은 관련 코드입니다.
@Transactional
public void approve(Long userId, PaymentRequest.ApproveDTO requestDTO) {
User user = findUserById(userId);
Payment payment = findPaymentByUserId(user.getId());
// 1. 검증: 프론트 정보와 백엔드 정보 비교
checkBadData(requestDTO, payment);
// 2. 토스 페이먼츠 승인 요청
tossPayApprove(requestDTO);
// 3. 유저 업그레이드
user.upgrade();
// 4. 결제시간 업데이트
payment.updatePayedAt();
// 5. 페이먼트 키 업데이트
payment.updatePaymentKey(requestDTO.paymentKey());
}
private void checkBadData(PaymentRequest.ApproveDTO requestDTO, Payment payment) {
// 받아온 payment와 관련된 데이터(orderId, amount)가 정확한지 확인)
if (isWrongData(payment, requestDTO.orderId(), requestDTO.amount())) {
throw new BadRequestException(BaseException.PAYMENT_WRONG_INFORMATION);
}
}
private Boolean isWrongData(Payment payment, String orderId, Long amount){
return !(payment.getOrderId().equals(orderId)
&& amount.equals(payment.getPayedAmount())
&& amount.equals(SUNSU_MEMBERSHIP_AMOUNT));
}
결론
이를 통해 결제 과정 전반에 걸쳐 데이터 무결성을 보장하였고, 시스템의 안정성을 강화했습니다.
결제 시스템에서 결제 정보 검증을 통해 결제 금액의 무결성 문제를 해결할 수 있다는 점에서 흥미로웠습니다. 이걸 계기로 금융권으로 취업하고 싶다는 생각도 들어서, 현재도 금융권 취업 준비 중에 있습니다. 오늘 아침에도 한국 거래소 필기 시험 보고 왔어요 ㅠㅠ
또한 이를 통해 클라이언트에서 결제 금액을 조직해 승인하는 행위를 방지할 수 있었습니다.
무엇보다도 이번 경험을 통해 문제를 단순히 해결하는 것을 넘어, 고민을 통해 시스템 전반의 설계를 개선하여 신뢰성과 안전성을 확보하는 것이 개발자의 핵심 역량임을 배웠습니다.