NHN아카데미 인증 과정 프로젝트 - 쿡슝 회고록

eora21·2023년 9월 10일
0
post-thumbnail

NHN아카데미에서 구현한 음식 주문 Web으로 쿠폰, 포인트, 주문을 구현하였습니다.
그 중 쿠폰 구현에 대해 고민한 부분 및 잘못된 방향으로 구현한 부분들을 작성하였습니다.

쿠폰

쿠폰의 종류와 사용처에 대해 고민한 시간이 의외로 길었습니다.
음식 주문과 무관하게, 아는 쿠폰의 종류를 한 번 나열해 보겠습니다.

회원가입 쿠폰, 생일 쿠폰, 등급 쿠폰, 브랜드 쿠폰, 카테고리 쿠폰..

세상엔 참으로 많은 쿠폰들이 있습니다.
그러나 해당 쿠폰들을 하나씩 다른 종류로 구분한다면, 관리가 참으로 힘들어질 것이라 생각합니다.

따라서 해당 쿠폰들을 모아두고, 우리가 적절히 사용할 수 있도록 추상화(Abstraction)를 진행했습니다.

쿠폰의 종류

쿠폰의 궁극적인 목적은 어디까지나 할인일 것입니다.
해당 목적 기준, 어떠한 방식으로 할인해주는 지 생각해 보았습니다.

일정 금액 할인

먼저, 일정한 금액으로 할인해주는 쿠폰이 있습니다.
이번에 진행한 음식 주문 Application뿐만 아니라, 수많은 곳에서 사용되고 있죠.
쿠폰 하면 떠오르는 대표적인 이미지일 것입니다.

퍼센트 할인

반면 일정 비율로 할인해주는 쿠폰도 있습니다.
음식 주문 쪽에서는 많이 보이지 않지만, 쇼핑몰 등에서 해당 쿠폰을 찾아볼 수 있죠.

두 방식 모두 경험해보고 싶었습니다. 따라서 쿠폰 타입을 Super-Type인 쿠폰 타입(coupon_types)과 Sub-Type인 금액 쿠폰(coupon_type_cash), 퍼센트 쿠폰(coupon_type_percent)으로 나누었습니다.

쿠폰의 사용처

그렇다면 이번엔, 쿠폰의 사용처에 대해 생각해보도록 하겠습니다.

프랜차이즈

음식 주문 Application 위주로 살펴보았습니다.
이벤트로 열리는 쿠폰은 주로 프랜차이즈 위주입니다.

매장

하지만 이렇게, 개인 매장에서도 쿠폰을 받을 수 있습니다.

카테고리, 메뉴별 이벤트는 단순 가격 할인으로 취급되는 경우가 많아, 사용자가 이벤트로 발급받을 수 있는 쿠폰은 크게 이 두 가지 타입이라 볼 수 있습니다.

어디에서든

물론, 이렇게 어디에서든 사용 가능한 쿠폰도 존재합니다. 회원가입, 생일, 등급 쿠폰 등 사용자에게 제공되는 쿠폰들이 여기에 해당하겠군요.

따라서 쿠폰 사용처는 Super-Type인 쿠폰 사용처(coupon_usage)와 Sub-Type인 가맹점 쿠폰(coupon_usage_merchant), 매장 쿠폰(coupon_usage_store), 어디에서든 사용할 수 있는 쿠폰(coupon_usage_all)으로 나누었습니다.

쿠폰 발급

이제 쿠폰 발급에 대해 고민해봅시다.
위에 작성한 쿠폰 타입과 사용처로 쿠폰을 발급해준다면 어떤 정보들이 더 필요할 지 고민해 본 결과, 쿠폰 이름과 설명, 만료일자 등이 필요할 것이라 판단했습니다.

해당 정보들은 쿠폰별 데이터가 아니라 하나의 정책이라 생각하고, 정책에 대한 테이블을 추가하였습니다.

쿠폰 정책

쿠폰 정책은 위에서 정의한 쿠폰 타입과 쿠폰 사용처를 포함하며, 이름과 설명에 대해 포함하고 있습니다.

또한 며칠간 유효하게 사용할 수 있는지를 표기하는 발급 후 사용기간이 있으며, 쿠폰 정책이 삭제되거나 숨겨졌는지를 판단할 수 있는 필드도 작성하였습니다.

발행 쿠폰

해당 정책에 대해 쿠폰을 발행해주면 되겠다는 생각으로 발행된 쿠폰을 정의하는 테이블을 생성하였습니다.

보시면 회원 아이디와 수령일, 만료일이 NULL로 되어있는데요, 이는 쿠폰을 먼저 발행한 이후 유저에게 제공하는 선 발행, 후 지급 구조를 지니기 위함이었습니다.

왜 선 발행, 후 지급을 선택했는가?

오프라인으로도 쿠폰이 지급될 수 있다고 생각했기 때문입니다.
예를 들어, 쿠폰의 일련번호가 담긴 카드를 만들고 이를 사용자들에게 제공하기 위해서는 일련번호를 제외한 모든 데이터가 비어 있어야 합니다.

물론 온라인 전용 쿠폰으로 제한 수량이 없는 쿠폰도 생길 수 있을 테지만, 해당하는 방식은 식별번호를 생성하면서 바로 유저에게 제공하면 될 것이라는 판단 하에 테이블 구조를 이처럼 작성했습니다.

(물론, 온라인 쿠폰과 오프라인 쿠폰을 제대로 구현하기 위해서는 특정 필드 값에 의한 구분 또는 Sub-Type에 의한 구분이 필요할 듯 합니다. 이번에는 무제한 쿠폰을 구현하지 않았으나, 기회가 된다면 다음에 구현해보면서 어떠한 문제점들이 있는지 파악해보고 싶네요.)

쿠폰 내역

쿠폰이 사용된 기록을 남기기 위해 해당 테이블을 생성하였습니다.
주문 취소로 인한 쿠폰 환불이 이루어질 수도 있기에, 쿠폰 내역 타입 코드를 사용하여 해당 쿠폰이 정말 사용된 것인지, 아니면 다른 이유로 인해 사용되지 못했는지도 기록으로 남기도록 하였습니다.

동시성 이슈

위에서 설명드렸듯, 쿠폰을 유저에게 지급해주는 방식은 선 발행, 후 지급입니다.
그렇다는 것은 Insert가 아닌 Update로 쿠폰이 지급된다는 것이고, 이는 곧 동시성 문제를 일으킬 수 있습니다.

가장 간편하면서도 정석적인 해결책

사실, 제일 쉬운 방법은 쿼리를 통해 동시성이 일어나지 않도록 하면 됩니다.

UPDATE issue_coupons
SET account_id = 3,
    receipt_date = NOW(),
    expiration_date = DATE_ADD(NOW(), INTERVAL 14 DAY)
WHERE account_id IS NULL
LIMIT 1;

이러한 쿼리를 도출하도록 잘 설정하여 적용하면 되겠죠.
하지만 저는 (너무나 마음 아프게도) 다른 방향의 가능성을 먼저 생각하다가, 결국 난이도에 비해 너무나 많은 것을 건드리고 맙니다..

정석을 생각하지 못 하고 다른 방향으로 해결한 방법

RabbitMQ 도입

사실, RabbitMQ를 도입한 것은 쿠폰 지급보다는 '많은 요청이 몰렸을 때 최대한 서버의 부하를 줄이는 방법'에 의한 것이었습니다.
그러나 이를 도입하며, '요청에 대한 줄을 세웠으니, 동시성 문제는 일어나지 않겠지?'하는 안일한 생각이 있었습니다.

MQ를 도입한 후, 동시성 문제가 일어나 확인해 보았습니다.
여럿의 컨슈머가 동시에 쿠폰 발행을 시도했는데, '현재 발급되지 않은 쿠폰'을 SELECT하는 과정에서 같은 튜플을 바라보게 되는 문제가 있었습니다.

그렇다고 Isolation LevelSERIALIZABLE로 올려버리면, 너무나 느린 성능에 발목이 잡힐 것 같았습니다.

Redisson 분산락 도입

따라서 Redisson을 사용해 분산락을 도입하였습니다. 쿠폰 정책 id를 key로 지정하고 락을 걸면 적어도 SERIALIZABLE보다는 빠를 테니, 괜찮을 것이라 판단했기 때문이죠.

그러나 이는 아주 약간만 성능의 향상을 불러올 뿐, 실제는 SERIALIZABLE과 다를 바가 없었습니다.

테스트를 돌려 보니 1,000개의 요청을 처리하는 데 30초가 걸리더군요.
(여기서 잘못됨을 깨닫고 다른 방향을 찾아봤어야 하는데, 우직하게 밀고 나아가 버립니다..)

Redis에서 쿠폰 코드 얻어오기 전략

Redisson의 분산락 대신, 쿠폰 발급 시 Redis에 해당 쿠폰 코드들을 Bulk Insert하여 값을 들고 있게 했습니다.

컨슈머가 쿠폰 지급 요청을 받았을 때, Redis에서 쿠폰 코드 하나를 pop하여 업데이트를 진행합니다.

이로 인해 느린 속도 및 동시성 문제를 해결할 수 있었습니다.

정합성 문제로 인한 Bulk Insert Request

하지만, 트랜잭션이 롤백되거나 Redis가 다운되었을 경우 정합성 문제가 발생할 수 있었습니다.
DB에는 데이터가 있는데, Redis에는 데이터가 사라졌으니까요.

이를 위해 Redis에는 데이터가 없지만 DB에 데이터가 있다고 판단되는 경우, Redis에 정합성 해결을 위한 Bulk Insert를 요청하는 방식을 구현해 보았습니다.

다만, 해당 요청이 계속 일어난다면 오히려 정합성이 더 깨지게 되므로 해당 로직을 Redisson 분산락을 통해 정합성이 깨졌을 때만 동작하도록 하였습니다.

그럼에도 불구하고 해결되지 않는 문제

분명 모든 처리를 다 했다고 생각했으나, 테스트코드는 여전히 Fail을 가르키고 있었습니다.
따라서 해당하는 구조를 되짚어보기로 하였습니다.

Redis에 남아있던 마지막 쿠폰 코드를 받아 커밋이 진행되고 있습니다.

이 때, C2를 통해 쿠폰 발급 요청이 들어왔습니다.
Redis가 비어있으니 Bulk Insert 요청이 발생합니다.
F1A9A619는 아직 커밋이 진행중이므로 소유한 유저가 없습니다.

따라서 해당 쿠폰 코드가 Redis에 등록됩니다.

같은 쿠폰 코드에 대해 커밋이 2개 발생하게 됩니다..

UPDATE Query 수정

여기까지 와서 업데이트 쿼리를 수정하게 됩니다.
소유한 유저가 없을 때만 업데이트되게끔 말이죠.
이로 인해 하나의 요청만 정상 업데이트가 됩니다.

일부러 정합성이 깨지도록 유도한 테스트코드에서도 정상적으로 처리가 되고, 속도도 처음보다 훨씬 빨라진 것을 느낄 수 있습니다만..

결국 업데이트 쿼리를 수정하는 게 최선의 수라는 것을 너무 뒤늦게 깨달아 버렸습니다.

그러나.. 정말 너무나도 늦게 깨달아버린 탓에, 발표 전 코드 수정을 진행하지 못 했고..
결국 위와 같은 로직의 코드로 발표를 진행하게 됩니다 ㅠㅠ

마치며

가장 많이 고민한 쿠폰에 대해 정리해 보았습니다.

마무리가 아쉬웠지만 그래도 많은 것을 고민하고 시도하였으니, 나쁜 경험은 아니라 생각합니다.
다음 번엔 더 좋은 방향성을 설정할 수 있을 테니까요.

작성한 코드 및 포인트, 주문 쪽에 대해서도 더 이야기를 하고 싶으나 너무 난잡해지는 것 같아 이만 줄이겠습니다. 기회가 된다면 해당 글 뒤에 덧붙여봐야겠어요.(지금은 노트북 반납이 코앞이기 때문에.. 본가 가서 고려해보겠습니다!)

앞으로 더 많은 고민을 녹여보도록 하겠습니다!

profile
나누며 타오르는 프로그래머, 타프입니다.

1개의 댓글

comment-user-thumbnail
2023년 9월 10일

야호

답글 달기