spring

복잡한 Join 탈출 - 결제 내역 조회 With Mongo DB

e4g3r 2024. 12. 16. 22:21

진행중인 프로젝트에선 사용자들이 Question이라는 상품을 구매할 수 있습니다. Question은 고등학교 과정의 문제를 의미합니다.

(수학, 물리, 생명, 지구과학 등)
따라서 사용자는 자신의 구매내역을 조회하는 API도 필요하게 되었습니다.

Question 결제와 관련된 테이블

 

Question 결제와 관련된 테이블은 QuestionPayment, QuestionOrder, Coupon, UserCoupon입니다.

이 4개의 테이블을 join하여 결제 정보(결제 금액, 사용한 쿠폰, 각 상품의 가격)를 조회하게 됩니다.

하지만 구매내역에서는 구매한 상품의 정보도 필요하기 때문에 QuestionOrder에 있는 questionId를 이용하여 상품 정보를 조회합니다.

Question과 관련된 테이블

 

Question은 QuestionCategory, Creator라는 테이블과 관계가 있습니다. QuestionCategory는 문제의 분야를 의미합니다.

(수1- 지수와 로그, 수2- 함수의 극한, 미적분 - 도함수의 활용, 화학 - 용액의 농도)

Creator는 문제를 만든 제작자이고 Creator의 이름과 이메일은 User 테이블로부터 참조하게 됩니다.

 

Question 상세 데이터를 조회하기 위해선 Question + 3 Join (QuestionCategory, Creator, User)이 필요합니다.

 

그 결과 결제내역을 조회하기 위해 querydsl을 이용해서 8단 join을 해야했습니다.

이번 리팩토링 대상은 결제 내역 조회로 정하였고 Mongo DB를 이용해서 개선하는 방식을 선정하였습니다.

결제 내역 조회용 모델 

public class QuestionPaymentHistory {
    private Long paymentId;
    private String orderId;
    private Long userId;
    private List<OrderQuestion> orders;
    private QuestionPaymentCoupon coupon;
    private int amount;
    private Boolean isUsedCoupon;
    private QuestionPaymentStatus status;
    private LocalDateTime createdAt;

    @Getter
    @AllArgsConstructor
    public static class QuestionPaymentCoupon {
        private String title;
        private CouponType couponType;
        private int value;
    }

    @Getter
    @AllArgsConstructor
    public static class OrderQuestion {
        private Long questionId;
        private int amount;
        private String title;
        private String thumbnail;
        private String creatorName;
        private Subject subject;
        private String mainCategory;
        private String subCategory;
    }
}

 

결제 내역 조회 API 요청 시 응답하게 되는 객체는 위 QuestionPaymentHistory입니다.

위 객체를 생성하기 위해서는 querydsl을 통해 8단 join을 하여 조회 후 객체를 생성합니다.

저는 이러한 복잡한 join을 개선하고자 했습니다. 추가로 만약 8단 join에 사용되는 Question, QuestionCategory, QuestionPaymentOrder, UserCoupon... 등 테이블의 데이터가  많이 누적된다면 느려질 수도 있겠다라는 생각이 들었습니다.

따라서 위 객체를 결제 처리 직후 가공하여 저장하고 이후에는 해당 데이터를 조회하는 방식이 좋을 것 같다고 생각했습니다.

QuestionPaymentHistory 객체 형식으로 저장하기 위해서는 비교적 저장 형식이 자유로운 비관계형 데이터베이스 NoSQL이

적합하다고 판단되었습니다.

 

위 방식은 Command 모델과 Read 모델을 분리하는 CQRS 방식에서 영감을 얻게 되었습니다.

결제 요청 시 결제 관련 데이터는 RDB에 저장하여 데이터의 정합성을 보장하고 결제와 관련된 상세 정보가 포함되어 있는

QuestionPaymentHistory를 조회용 모델로 Mongo DB에 저장합니다.

고려 할 상황

CQRS 방식처럼 조회 모델을 따로 분리할 경우 고려할 상황이 있는데요. 만약 특정 데이터가 업데이트 된다면

조회용 모델의 데이터도 업데이트 되어야 하는 상황이 발생할 수 있습니다.

 

예를 들어 QuestionPayment의 경우 내부 데이터로 구매한 문제의 이름, 썸네일 이미지, 제작자 이름을 보유하고 있습니다.

만약 제작자의 이름이(creatorName) 변경된다면 MongoDB에 있는 QuestionPaymentHistory 데이터들도 반영되어야 할 것입니다.

 

하지만 결제 내역이란 데이터는 다른 상황이라고 생각합니다.

결제 내역에서의 상품 정보는 사용자가 구매했던 시점의 정보가 유지되어야 하기 때문입니다.

사용자는 자신이 구매했던 상품의 정보를 조회하고 싶었을 것이고 만약 상품의 정보가 바뀌었다면 헷갈릴 수 있기 때문입니다.

 

물론 도메인 특성 상 상품의 정보가바뀔일은 많지 않을 것입니다. (바뀔 거면 새로운 상품을 추가하는 게..)

 

결제 정보 화면
구매 했던 상품의 현재 상품 정보

 

다른 플랫폼에서는 상품 정보를 구매 당시의 데이터인지, 최신 상품 정보인지 확인해보았고 네이버페이의 경우는 구매 당시의 정보임을

확인했습니다. 구매 당시 상품의 이름과 현재 상품의 이름은 다르고 상품의 썸네일 이미지도 다른 것을 볼 수 있습니다.

 

추가로 이와 같이 처리하면 삭제 된 상품의 정보도 결제 내역에선 별다른 처리 없이 조회할 수 있다는 장점이 있습니다.

 

상품 결제 시 조회용 모델 데이터 생성하기

 

payment 함수는 주문을 처리하는 함수입니다. 주문 처리 후 CompletedQuestionPaymentEvent를 발행하는데요.

 

도메인 특성 상 결제 이후 후처리 해야 할 로직들을 많기 때문에 이벤트를 발행하여 처리하고 있습니다.

그 중 조회용 결제 모델 저장 또한 이벤트로 처리하기로 하였습니다.

 

위 코드는 CompletedQuestionPaymentEvent 이벤트가 발행되었을 때 각종 정보들을 가공하여 QuestionPaymentHistory를

생성하고 저장하는 로직입니다.

 

@Document(collection = "question_payment_history")
public class QuestionPaymentHistoryDocument {
    @Id
    private Long paymentId;
    private String orderId;
    private Long userId;
    private List<OrderQuestion> orders;
    private QuestionPaymentCoupon coupon;
    private int amount;
    private Boolean isUsedCoupon;
    private QuestionPaymentStatus status;
    private LocalDateTime createdAt;
}

 

MongoDB에 저장되는 Document 클래스는 위와 같이 정의하였습니다.

 

 단순해진 코드

결제 내역 조회 코드가 매우 단순해졌습니다. 기존에는 여러 table을 querydsl을 통해 join 처리를 해야했지만

mongo db에는 QuestionPaymentHistory 형태로 데이터가 저장되어 있기 때문에 단순히 페이징 처리를 해주면 됩니다.

조회 성능 테스트

기존 방식은 join을 거는 테이블이 많기 때문에 데이터가 많아질 경우 성능 저하가 발생함을 우려했었는데요. 실제로 더미 데이터를 추가

했을 때 성능에 문제가 생길지 확인해보았습니다.

 

테스트 상황

 - 결제 데이터 30만건

 - 한 건의 결제마다 10개의 상품 구매 -> 300만건의 주문 데이터

 - userId가 1인 결제 데이터는 277개

 

성능 테스트 환경

 - userId가 1인 주문 내역을 페이지네이션 형식으로 요청

 

 

성능, 부하 테스트는 k6로 진행하였고 가상의 사용자 100명이 3분동안 요청을 보내는 상황으로 가정하였습니다.

 

기존 join 방식 테스트 결과입니다.

서버가 요청을 처리하는 평균 시간 28.6/ms, tps 96.8/s

 

mongo DB를 이용한 방식 테스트 결과입니다.

서버가 요청을 처리하는 평균 시간 34.6/ms, tps 96.1/s

 

걱정 했던 것과는 달리 오히려 mongo DB의 방식이 평균적으로 처리 시간이 높은 것으로 나왔습니다. 
물론 두 방식 모두 40ms조차 안되는 처리 시간이지만 join 방식의 속도가 더 빠른 것은 의외였습니다.
join 방식의 성능 향상을 위해 join에 사용되는 필드들을 index 처리를 하였던 것이 큰 효과를 본 것 같습니다.

 

비록 join 방식이 미세하게 더 빠른 처리 속도지만 조회 방식의 코드가 단순해진 점, 결제 시점의 상품 정보를 그대로 저장해서

보관해야한다는 점을 고려하여 mongo DB 방식을 사용하기로 선택했습니다.

추가로 많은 레퍼런스에서는 단순 조회일 경우 mongo DB의 성능이 더 높게 측정되었었는데 혹시 저의 테스트 환경 및 세팅이 
잘못 된 것인지 확인해봐야겠습니다.

보완 할 점

 

결제 완료 이후 이벤트 발행이 되면 결제 정보를 저장하는 방식입니다. 결제 처리 요청에 영향을 받지 않도록 하기 위해 결제 저장 정보는

비동기 처리로 진행되도록 하였습니다. 따라서 결제 처리가 성공되더라도 결제 정보 저장은 실패할 수 있습니다.
따라서 결제 정보 저장 로직이 실패된다면 사용자는 자신의 결제 내역을 볼 수 없기 때문에 결제가 완료된 것인지 실패된 것인지
알 수 없기 때문에 후처리 과정의 성공을 보장하도록 처리해야겠습니다.