재입고 알림 신청 개발기

Jeenz
29CM TEAM
Published in
10 min readNov 2, 2022

인사말

안녕하세요. 입사한지 3개월이 조금 지난 29CM 백엔드 개발자 김형진입니다. 입사 후 처음 맡은 재입고 알림 개선 프로젝트를 개발하면서 겪은 이슈와 해결 방법, 배운 점을 공유하려고 합니다.

많은 분들께 도움이 됐으면 좋겠습니다.

재입고 알림 소개

쇼핑 중 구매하고 싶은 상품이 품절이 되는 상황을 한 번쯤은 경험해 보셨을 것 같습니다. 이때 품절된 상품이 입고되었는지 확인하기 위해 수시로 앱에 접속하게 된다면, 이는 유저에게는 매우 번거로운 일이 됩니다.

이 때문에 원하는 상품이 재입고가 되면 이를 알려주는 기능이 필요합니다. 품절되었던 상품이 재입고 됐을 때 이를 유저에게 알려주는 기능이 있다면, 유저는 즐거운 마음으로 구매를 시도하게 될 것입니다.

이러한 이유로 저희 스쿼드에서는 고객 경험을 위해 재입고 알림 기능을 개발하게 됐습니다.

재입고 시나리오

  1. 유저가 쇼핑을 하다가 품절된 상품을 발견합니다.
  2. 상품 상세 보기 페이지에서 재입고 알림 신청을 합니다.
  3. 해당 상품이 재입고가 됐을 때 알림 센터에서 재입고 소식을 받습니다.

재입고 알림 기능이 처음 오픈되었을 때 (V1 기능), 많은 유저들이 재입고 알림 기능을 신청하고 사용했습니다

재입고 알림 V1 기능을 오픈했을 때, 유저 입장에서 아쉬웠던 점은 다음과 같았습니다.

[재입고 V1 알림 기능에서 아쉬운 점]

  • 상품 옵션 별로 재입고 신청이 불가능했다.
  • 모든 옵션이 품절이 돼야만 신청이 가능했다.
  • 원하는 옵션 별로 신청이 불가능했다.
  • 마케팅, 세일즈 등 상품에 대한 분석이 불가능했다.
  • 앱 내에서만 확인이 가능했다.

상품의 경우 다양한 사이즈가 있을 것이고 다양한 색상이 있을 것입니다. 그런데 내가 원하는 사이즈, 색상은 품절인데 모든 옵션이 품절이 아니면 재입고 신청 자체가 불가능했습니다. 상품에 옵션을 지정하여 재입고 신청을 받지 않고 상품단위로만 재입고 신청을 받았기 때문입니다.

또 마케팅, 세일즈에서 품절된 상품 중 어떤 옵션이 구매 수요가 많은지 해당 상품의 재입고 주기는 어떻게 되는지 등 다양한 분석을 할 수 없었습니다. 일일이 상품을 들어가지 않고 알림 센터만 확인을 하면 되지만 결국 앱을 수시로 접속해야 했습니다. 보통 품절된 상품의 입고 소식을 받는다는 것은 그 상품에 대한 구매로 이어질 가능성이 높고 이는 매출에도 직접적인 영향을 끼칠 것입니다.

그래서 이러한 불편한 점들을 개선하고자 재입고 알림 v2를 개발하게 됐습니다.

[재입고 알림 V2 기능 개선 포인트]

재입고 알림 v2는 유저에게 더 나은 사용 경험을 위해 아래와 같은 개선 포인트를 개발 목표로 잡았습니다.

  • 상품 옵션 별로 재입고 신청이 가능해야 한다.
  • 옵션별로 데이터를 쌓아 재입고 대시보드를 만들고 마케팅, 세일즈 등에서 상품에 대한 다양한 분석이 가능해야 한다.
  • 재입고 주기를 파악할 수 있어야 한다.
  • 자체 메시지 플랫폼을 통하여 푸시를 전송해야 한다.
  • 문자, 알림톡 등을 사용할 때보다 비용 절감이 가능해야 한다.
  • 푸시와 알림을 동기화하여 푸시의 즉시성과 장점을 살리면서 휘발성 단점을 알림으로 보완해야 한다.

v2 재입고 신청 화면은 다음과 같습니다.

재입고 알림 신청 V2는 어떻게 개발을 했는가?

기존 데이터 마이그레이션 & api 하위 호환성 고려

v1 → v2로 가기 위해서는 기존 데이터 마이그레이션과 api 호환성을 고려해야 합니다.

하지만 이번 개발에서는 데이터 마이그레이션과 api 호환성을 고려하지 않기로 결정했습니다.

마이그레이션, api 하위 호환성에 투자되는 리소스와 이것을 통해 얻을 수 있는 임팩트를 고려했을 때 마이그레이션은 skip 하고 재입고 알림 v2를 빠르게 개발하는 것이 임팩트가 더 크다고 판단해서였습니다.

이런 맥락에 따라 먼저 알림 센터에서 재입고 관련 공지를 띄우고 재입고 신청 버튼을 삭제해서 기존 재입고 신청 노출을 중단 시켰습니다.

재입고 주기를 구분하기 위해 한 번의 재입고 주기를 관리하는 그룹을 만들고 그 그룹 안에는 재입고 신청을 한 유저들을 포함시켰습니다. 다음으로 옵션 별로 재입고 알림 신청을 받기 위해 item_id, option_id도 구분하여 그룹을 생성했습니다.

설계

NotificationGroup: 한번의 재입고 신청을 단위로 한 사이클로 돌며 User들을 Grouping해주는 엔티티

NotificationGroupUser: 실제 재입고 신청을 한 유저 엔티티

이때 NotificationGroup의 status는 재입고 신청중(Recruit)와 발행완료(Complete) 상태로 구분 됩니다.

위에 내용을 정리하면 아래와 같은 정책으로 정리됩니다.

  1. 모집 중인 Group의 경우 상품단위 당 1개만 생성되어야 한다.
  2. 발행이 완료된 여러 개의 그룹이 존재할 수 있다.
  3. 한번 재입고 알림이 나간 상품이 다시 재입고 신청을 받을 시 새로운 Group이 생성된다.

V2 배포후 동작상의 오류

위와 같은 목표와 설계를 기반으로 재입고 알림 v2 기능을 오픈했습니다. 오픈 이후 목표했던 것이 잘 동작하는지, 오류는 없는지 모니터링을 진행하다가 특정 상품에서 유저가 재입고 신청 시 재입고 신청 실패 오류가 나는 상황 이 발견되었습니다.

배포후 슬랙에 알림 발생….

이는 V2 개발 과정에서의 목표였던 1번 요구사항을 만족시키지 못하는 상황이었습니다.

여러 개의 그룹이 생성되어 있는 모습 (Recruit상태 + 동일한 itemId, optionId)

이로 인해 로직 상 JPA 레포지터리에서 1개만 조회되어야 할 함수에서 여러 개의 엔티티가 조회되어 NonUniqueResultException 에러가 발생한 것입니다.

해결을 위한 첫 번째 아이디어

처음에는 동시성 이슈가 발생할 수 있음을 인정하고 조회 시 클리닝 작업을 하는 방향으로 생각했습니다.

동일한 상품(itemId, optionId) 의 그룹을 조회했을 때 여러 개가 나온다면 신청한 유저의 목록을 하나로 Merge하고 그룹도 하나로 정리하려고 했습니다.

사실 좋은 방향 같진 않았고, 로직도 너무 복잡해지는 문제가 있었습니다.

두 번째 아이디어

중복 insert를 막기 위해 간단한 Unique Index를 걸어서 해결하려고 했습니다.

NotificationGroup에 (itemId + optionId + status )로 Unique Index를 생성했습니다.

이 아이디어도 처음에는 잘 동작하나, 결국 Complete 상태의 그룹이 여러 개 생길 수가 없는 구조였습니다.

ex)

itemId: 1 + optionId: 1 + status: Complete + createdAt: 2022-10-24
itemId: 1 + optionId: 1 + status: Complete + createdAt: 2022-10-25
itemId: 1 + optionId: 1 + status: Complete + createdAt: 2022-10-26

세번째 아이디어

두 번째 아이디어는 Complete가 됐던 기존에 NotificationGroup들이 문제였습니다.

그렇다고 NotificationGroup를 삭제하는 것은 재입고 내역을 기반으로 다양한 데이터 활용이 불가능하기 때문에 피하고 싶었습니다.

그래서 soft-delete 방식을 사용하기로 했습니다. 생성 시에는 deleted_at = null 이후 Complete가 되면 deleted_at에 현재 시간을 업데이트하는 방식입니다.

그리고 (itemId + optionId + status + deleted_at) 조합으로 Unique Index를 생성했습니다.

ex)

itemId:1 + optionId:1 + status:Complete + deleted_at:2022–10–26…itemId:1 + optionId:1 + status:Recruit + deleted_at:null
itemId:1 + optionId:1 + status:Recruit + deleted_at:null
(중복 에러가 발생 할 것)

깨알 퀴즈

MySQL 에서 name + deleted_at에 Unique Index를 걸고 Insert 한다면 어떻게 될까요?

ex)

insert name='홍길동', deleted_at=null;
insert name='홍길동', deleted_at=null;
  1. null 값이 고유하게 데이터가 삽입된다. (위 의도대로 동작 — 익셉션 발생)
  2. null 값이 고유하지 않게 데이터가 삽입된다. (위 의도와 다르게 동작 — 정상 처리)
  • 정답은? -> 2번

저는 1번의 방식대로 동작을 할 것이라고 생각했는데 의도와 다르게 동작했던 것입니다….

최종 해결 방법

별도의 락을 관리하는 테이블을 구현하기로 했습니다.

NotificationGroup 생성시 NotificationGroupDuplicateLock을 함께 생성하고lockKey(itemId:optionId)를 Unique Index로 잡아줬습니다.

ex) lockKey = 19241:10401011

재입고 발송이 되었을 때 lockKey 를 null로 만들어 unique 제약을 회피하는 방법입니다. (lockKey가 Unique Index를 위배하는 상황 = 동일한 NotificationGroup 중복 생성)

재입고 알림 메세지 전송 후 NotificationGroup -> Complete LockTable -> lockKey=null로 변경합니다.

최종 해결 방법으로 수정후 의도대로 동작을 했고 재입고 알림 v2 기능을 정식으로 오픈했습니다.

배운점

처음엔 요구사항만 보고 간단한 작업이라고 생각했습니다.

실제로 개발하다 보니 동시성을 생각하지 못했었고 이슈가 발생했을 때 간단한 Unique Index를 걸면 해결이 될 것이라고 생각했는데 생각처럼 간단하게 해결되지 않았습니다.

그 과정에서 이런저런 방법들을 생각하면서 인사이트가 넓어진 것 같습니다.

아직 인사이트가 많이 부족해서 다양한 문제를 고려하지 못한 점이 아쉽습니다.

앞으로 더 좋은 인사이트를 가진 개발자가 될 수 있도록 노력해야겠습니다.

맺음말

기술적으로 복잡도가 높은 기능은 아니었지만 비즈니스적으로 임팩트가 컸으며 입사한지 얼마 안 된 상황에서 처음으로 진행한 작업이어서 다른 분들께 그 과정을 공유드리고 싶었습니다.

비슷한 동시성 문제로 고민하고 계신 분들께 조금이라도 도움이 됐으면 좋겠습니다.

기술적으로 조언을 주신 강호길 팀장님과 해당 기능을 개발하는 데 있어서 도움을 주시고 같이 고생하신 moment 스쿼드 분들 감사합니다!

[함께 성장할 동료를 찾습니다]

29CM ((주)무신사)는 3년 연속 거래액 2배의 성장을 이루었습니다.
이제 더 큰 성장을 위해 기존 모놀리틱 서비스 구조를 마이크로서비스 구조로 전환하고, 앵귤러 기반 프론트엔드 코드를 리액트로 전환하는 등의 기술적인 시도를 진행하고 있습니다. 모바일 앱 내부 구조도 모듈러 아키텍처로 개선하는 과정에 있습니다. 함께 성장하고 유저 가치를 만들어낼 동료 개발자분들을 찾습니다.

🚀 29CM 채용 페이지 : https://www.29cmcareers.co.kr/

--

--