📖 프로젝트 개요
ERD
Cook-Shoong/0.2.8
GIT
https://github.com/nhnacademy-be3-CookShoong/cookshoong-backend
Image
⌨️ 사용 기술 및 라이브러리
- Spring Boot
- JPA/Hibernate
- Spring Data JPA
- QueryDSL
- Spring Event
- MySQL
- RabbitMQ
- Redis
- Redisson
🤔 고민과 구현
쿠폰 타입 분할을 위한 erd 설계와 코드 설정
- 각종 사이트의 할인 쿠폰을 조사해봤더니 일반 금액처럼 쓸 수 있는 쿠폰과 결제 금액의 일정 퍼센트를 할인하는 쿠폰으로 나뉘었습니다. 두 쿠폰 타입은 데이터상으로 명확히 구분되어야 하지만, 쿠폰을 사용하는 상황에서는 구분 없이 적용되어야 했습니다. 이를 반영하기 위해 ERD 구현 시 쿠폰 타입에 따른 super-type과 sub-type을 작성했습니다.
- Spring에서도 이러한 구조를 구현하기 위해
@Inheritance
어노테이션을 사용하였습니다. 사용자에게 해당 값을 전달하기 위해 하위 Entity에서 @DiscriminatorValue
를 지정하고 getClass를 이용하여 알맞은 DTO로 변화시키는 로직을 구현했습니다.
쿠폰의 사용처 또한 단일 매장 쿠폰, 가맹점 매장 쿠폰, 어느 곳에서나 사용 가능한 쿠폰으로 나눌 수 있었고, 이 역시 대동소이한 방법으로 구현했습니다.
Hibernate Bulk Insert로 쿠폰 대량 생성 시간 단축
- ERD 구조 상 쿠폰은 한 장당 하나의 튜플을 가지는 형태입니다. 매장 사장님들은 장기 이벤트 시 대량의 쿠폰을 생성해 두고 사용할 것이란 판단이 들었고, 따라서 쿠폰의 최대 생성 수량을 제한해야 했습니다.
최대 수량을 1,000장으로 정한 후 Spring Data JPA로 한 번에 1,000장의 쿠폰을 생성했을 때, saveAll()로 하나의 트랜잭션에서 수행했음에도 불구하고 약 10초의 시간이 걸렸습니다.
- 이를 해결하기 위해 Hibernate Bulk Insert를 설정하였습니다. 쿠폰의 @id가 auto-increment가 아닌 UUID 형태였기에 적용시킬 수 있었습니다.
설정 후 1,000장의 쿠폰을 한 번에 생성하니 1초 미만으로 수행이 가능함을 확인했습니다.
쿠폰 숨김과 삭제 구분
- 만일 준비한 쿠폰이 모두 소진되었을 경우, 사장님은 해당 쿠폰 정책을 삭제하고 싶을 수 있습니다. 그러나 사용자들이 쿠폰을 획득하는 시간과 소비하는 시간은 다릅니다. 사장님이 쿠폰을 삭제한 이후에 사용자가 쿠폰을 사용하는 경우를 생각해야 했습니다.
혹은 사장님이 오발행한 쿠폰(ex: 할인률을 너무 높게 책정)을 삭제하여 사용자의 사용을 막아야 할 경우도 생각해볼 수 있었습니다. 사장님이 단순하게 쿠폰 정책을 보고 싶지 않은 것인지, 혹은 실수에 대한 대처를 하는 것인지 구분해야 했습니다.
- 따라서 단순히 쿠폰 정책을 보이지 않게 하고 싶은 경우에는 ‘숨김’으로, 잘못된 쿠폰 발행에 대한 방어로는 ‘삭제’로 나누어 처리할 수 있도록 하였습니다.
다만 삭제는 서비스 입장에서 고객들에게 민감하게 다가갈 수 있기에 관리자만 가능하도록 하였습니다.
주문 취소 시 쿠폰 환불
- 사용자가 메뉴 교체 또는 수량 오기입 등으로 인해 주문을 곧바로 취소하는 경우는 상당히 많습니다. 매장에서 재료 소진이나 배달 불가 등의 이유로도 취소되는 경우가 있습니다. 만약 취소된 사항에 대해 쿠폰이 환급되지 않는다면 불만이 생길 것입니다.
- 해결 방안으로 주문이 취소될 경우 쿠폰에 대한 취소 로그를 남겨 재사용 할 수 있도록 하였습니다. 쿠폰 사용 시 최근 로그가 존재하지 않거나 ‘이미 사용됨'이 아니라면 쿠폰이 적용되도록 로직을 작성했습니다.
쿠폰 중복 사용 방지
- 다수의 클라이언트에 하나의 계정으로 로그인하고, 각각의 주문에서 동일한 쿠폰을 사용하여 주문하는 상황을 고려해볼 수 있었습니다. 만약 이러한 경우에 모든 주문이 수락될 경우 큰 손실을 가져올 수 있었습니다.
- 따라서 주문의 UUID를 대상으로 Redisson 분산락을 활용하여 위와 같은 악의적인 쿠폰 중복 사용을 방지하였습니다.
쿠폰 중복 발급 방지
- 중복 사용과 마찬가지로, 동일한 정책의 쿠폰을 계속 발급 요청할 시 서버의 처리량이 마구 늘어날 가능성이 있었습니다.
- 이를 방지하기 위해 RabbitMQ를 사용하여 발급 요청과 실제 발급을 분리시킨 후, 양쪽 모두에 검증 코드를 두어 중복 발급을 막았습니다.
주문과 결제 시점 구분
- 주문을 요청하는 시점과 주문이 실제 수행되어 결제되는 시점은 다릅니다. 따라서 메뉴명이나 가격이 주문 시점과 결제 시점에서 차이가 날 수 있습니다. 또한 기존에 주문했던 메뉴가 이름이 변경되거나 사라질 경우, 주문 기록이 같이 변동될 가능성이 있었습니다.
- 이에 주문 시 해당 순간의 메뉴명과 가격을 기록하고, 결제 시점에서 메뉴의 금액이 증가한 경우에는 예외를 발생시켜 결제가 진행되지 않도록 했습니다. 만약 가격이 오히려 하락한 경우에는 하락한 가격을 기준으로 결제가 실행될 수 있도록 하였습니다.
포인트 발급 트랜잭션 분리
- 회원가입, 주문, 리뷰 작성 시 포인트가 지급됩니다. 그러나 포인트 발급의 문제가 생겼을 때 같은 트랜잭션을 사용할 경우, 앞선 핵심 로직들까지 영향을 받아 작업 수행이 취소될 수 있습니다.
- 이를 위해 비동기 스프링 이벤트를 사용, 해당 로직들이 수행되는 범위를 따로 분리하였습니다.
동시성 처리
- 한 번에 여러 사용자가 쿠폰을 발급을 요청할 경우 동시성 문제가 발생할 수 있었습니다. 이는 각각의 쿠폰 소유자 정보에 accountId를 업데이트하는 방식이었기에, 락으로 블로킹을 걸어서 해결할 수 없었습니다. Serializable은 해당 테이블에 넥스트 키 락을 걸게 되므로 다른 트랜잭션들의 처리까지 영향을 미칠 수 있었습니다.
- 따라서 Redis의 싱글 스레드를 활용, 발급 요청이 빈번한 쿠폰의 UUID들을 캐싱하였습니다. 쿠폰 발급이 요청되면 해당 UUID를 넘겨주며 동시성 문제를 우회했습니다.
MySQL과 Redis의 데이터 불일치를 해소하기 위해 Redisson의 분산락을 활용하여 정합성을 맞출 수 있도록 했습니다.
💡 알게 된 점
- Super-Type과 Sub-Type을 JPA entity로 구현하고 사용하는 법을 알게 되었습니다.
- 동적 쿼리를 직접 작성해보며 QueryDSL을 더 학습할 수 있었습니다.
- RabbitMQ의 익스체인지, 바인딩, 프로듀서, 컨슈머, DLX, DLQ 등에 대해 알게 되었습니다.
- 동시성 처리를 위해 여러 Lock에 대해 학습하고 사용해 볼 수 있었습니다.
😅 아쉬웠던 점
- 비동기 스프링 이벤트 사용 시 쓰레드 풀을 설정하였다면 더 효율적이었을 것 같습니다.
- 동시성을 처리하기 위해 RabbitMQ, Redisson을 통한 분산락, Redis를 통한 쿠폰 발급 전략 및 Bulk Insert 등을 구현하였습니다. SKIP LOCKED 쿼리를 사용했다면 훨씬 더 가볍게 구현할 수 있었다는 점이 몹시 아쉽습니다. 이후 프로젝트에서는 넓은 시야를 통해 최적의 해결책을 찾고자 노력할 것입니다.
- RabbitMQ 공식 문서를 통해 코드를 작성하였으나, 세부적인 설정에 대해 확인하지 못 한 점이 아쉽습니다.
- 급격한 스케쥴 변동으로 당초 생각했던 깊은 공부와 이해도를 바탕으로 한 코드 작성이 이루어지지 못한 것 같아 아쉽습니다.
👍 좋았던 점
- 쿠폰 처리에 대해 정말 깊게 고민해볼 수 있는 시간이었습니다.
- 멀티 스레딩 테스트를 작성해보며 ‘효율적인 테스트는 무엇일까’를 생각해 볼 수 있는 시간이었습니다.