29CM 상품 대량등록 기능 소개

Grey Lee
20 min readJun 7, 2021

--

29CM은 이제껏 상품 대량등록 기능을 제공하지 않아 파트너들이 상품 등록 시 상품을 건별로 등록하고 있었습니다.

패션 커머스의 특성상 하나의 상품이 가지고 있는 옵션이 많을 수 밖에 없고 대량등록 기능이 없다면 상품 한 종만 등록하려고 해도 수 차례에 걸쳐 상품을 등록해야 하는 번거로움이 발생합니다.

상품등록이 불편하다보니, 등록 주체인 파트너(입점사)와 등록 및 검수의 전반적인 프로세스를 가이드하는 MD들의 리소스 낭비가 이만저만이 아니었는데요.
향후 29CM의 마켓플레이스가 더욱 성장하려면 파트너의 거래경험을 극대화 해야 한다는 판단하에 상품 대량등록 기능의 우선순위를 높여 개발을 진행하기로 결정하였습니다.

개발 목표

편리하고 직관적인 UX를 제공해야 한다

E-Commerce 플랫폼에서 어드민 화면의 UX는 구성원들의 생산성 및 업무효율과 직결됩니다.
대량등록 기능은 주로 파트너가 사용하는 기능인데요. 파트너들은 29CM 에만 입점되어 있지 않고 타 커머스 플랫폼에서도 판매를 진행하고 있을 가능성이 높으며 자연스럽게 다른 플랫폼과 비교를 하게 됩니다. 다른 플랫폼에서 제공하고 있는 기능을 지원하지 못한다면 경쟁력있는 플랫폼이 될 수 없겠죠.

기존에 비해 더 나은 파트너 경험을 제공하고 구성원들의 원활한 업무 진행을 위해 어드민 화면의 개발을 결정하였습니다. 처음에는 엑셀 기반의 대량등록을 고려하였으나 다음과 같은 이유로 화면을 개발하는 것으로 방향을 선회하였습니다.

  • 상품 속성에 검증 로직이 적용되는 속성들이 많은데, 이를 엑셀 수식으로 검증하기엔 한계가 있고 검증 로직이 엑셀에 포함되어 있는 경우 로직 변경 시 서버쪽 로직과 엑셀 문서 로직을 같이 변경해줘야 함
  • 엑셀 문서 입력 시 여러가지 케이스들을 모두 커버하기엔 다소 힘들고 검증 로직에서 에러 발생 시 Root Cause(이하 RC)를 MD나 개발팀에서 직접 가이드하는게 아닌 시스템으로 자동화하여 운영비용을 최소화

Presentation Layer에 로직을 포함시키지 않는다

29CM의 기존 어드민 화면은 로직이 다수 포함되어 있습니다.
상황이 이렇다 보니 시스템 확장과 유지보수 측면에서 많은 어려움이 발생하고 있습니다. 로직 수정 시 프론트엔드와 백엔드를 모두 고려해야 하다보니 로직이 파편화되고 많은 개발자의 손을 거쳐가야 합니다. 이는 개발 생산성을 저하시키고 최종적으로 표시되는 정보와 영속화 된 값 간의 괴리를 발생시키는 요인입니다.

29CM의 상품 모델에는 앞서 언급하였듯이 검증 로직을 적용받는 속성이 많습니다. 이러한 로직을 전부 서버쪽에서 책임지도록 변경하고 프론트는 되도록 DB가 들고있는 컨텍스트의 표시와 사용자가 입력한 정보만을 책임지도록 구현하였습니다. 서버에서 로직을 책임지니 기존에 공통화 되어있는 상품 검증 로직을 그대로 적용할 수 있고 원래 의도했던 값의 표시가 가능해졌습니다.
간혹 부득이하게 프론트에서 로직을 들고 있어야 할 케이스가 있었는데요. 이 부분은 후술할 내용에서 언급하도록 하겠습니다.

프레젠테이션 레이어와 서버 로직을 명확하게 분리하기 위해 템플릿 엔진의 적용을 결정하였고 Spring Boot에서 공식적으로 지원하는 Handlebars를 사용하였습니다.

사용자가 입력한 정보의 변경은 자유로워야 하고 브라우저가 닫혀도 휘발되지 않아야 한다

등록할 상품 종 수가 많아질수록 입력할 정보도 이와 비례하게 증가하며 이로 인해 사용자가 원하는 수준의 정보 구성에 도달하기까지 많은 시간이 소요될 수 있습니다. 한번에 목표 달성이 어려울 수 있기 때문에 입력한 정보를 임시로 저장하고 이를 편집할 수 있는 기능이 필요합니다. 이러한 기능을 구현하기 위해 세션 모델을 도입하게 되었습니다.

세션은 타겟 엔티티의 정합성을 보장하고 동시성과 경합 상황을 방지하며 1회의 트랜잭션으로 데이터를 반영하여 운영데이터의 오염을 최소화하는 일종의 버퍼 역할을 담당합니다.
사용자가 작업중인 컨텍스트를 타겟 엔티티에 실시간으로 반영한다면 당연히 좋지 않겠죠? 상품 등록 최종단계 이전까지는 세션만 변경하고 최종단계에서 세션 데이터를 상품 테이블에 반영하도록 구현하였습니다.

Entity Model

대량등록 세션 ERD

Session

사용자의 계정 식별자와 상태를 관리하는 엔티티입니다.
계정 식별자 + Session.status =PENDING or REGISTRATION 에 해당하는 Active 세션이 존재한다면 계정 소유자가 현재 작업중인 세션이 있는 것으로 간주하고 해당 세션을 불러옵니다.
사용자 별로 하나의 Active 세션만 보장되어야 하며 DDL - Unique Index와 같은 제약사항을 걸지 않고 로직으로 풀어냅니다. 세션 엔티티는 일종의 Audit 역할을 담당할 수 있고, 경우에 따라 만료된 세션을 살려달라는 요청이 있을 수 있기 때문입니다.

처리 완료나 세션 만료같은 액션을 처리하기 위해서 별도의 상태를 들고 있어야 하며 세션 만료는 생성일자 기준으로 특정 기간이 경과하면 배치를 통해 EXPIRED 상태로 전환합니다.

세션 상태 FLOW

계정 식별자는 SSO의 고유 식별자를 사용합니다.
SSO 연동은 Spring Security, Keycloak을 사용하였으며 별도의 아티클로 다루도록 하겠습니다.

Base

상품의 공통정보를 표현하는 엔티티입니다.
Session 엔티티와 1:1 관계이며 카테고리, 상품 종 수, 유형 등의 정보가 포함되어 있습니다. 대량등록 시 같은 카테고리에 포함된 상품만 대량 등록이 가능한 제약조건이 있습니다.

Detail

상품 속성을 표현하는 엔티티입니다.
Base 엔티티와 1:N 관계이며 상품을 표현하는 최소단위입니다. 상품 속성과 1:1로 대부분 대응되기 때문에 검증로직이 가장 많이 적용되는 엔티티입니다. 화면에서는 상품의 건별 편집이 가능해야 하기 때문에 엔티티 그래프의 중간부터 탐색하기 위한 Token이 부여됩니다.

Notice

상품 고시정보를 표현하는 엔티티입니다.
Base 엔티티와 1:N 관계이며 공통정보에 포함된 카테고리에 따라서 고시정보 항목이 변동됩니다.

Option

옵션 상품 속성을 표현하는 엔티티입니다.
Detail 엔티티와 1:N 관계입니다. 이 엔티티에는 옵션 상품의 추가 가격과 수량이 포함되며 옵션 상품이 설정되지 않았다면 생성하지 않습니다.

UX

상품 기본정보 등록

상품 기본, 고시정보 등록 화면

상품의 기본 및 공통정보(Base, Notice 엔티티)를 입력하는 화면이며 Session.status = PENDING 에 대응됩니다.
Disabled 처리되지 않은 모든 필드들은 필수 입력값이며 소 카테고리 선택 시 카테고리에 해당하는 상품 고시 정보 항목이 활성화됩니다.

브랜드(3) 항목은 파트너가 보유한 브랜드만 표시됩니다.
옵션 여부(7) 토글 버튼을 활성화 시 옵션과 연관된 항목들이(8, 9, 10) 노출됩니다.
적용(12) 버튼을 누를 경우 Session.status =REGISTRATION 상태로 저장되며 입력받은 상품 수(2)에 만큼 Detail 엔티티를 생성합니다.
만약 옵션 여부 항목이 활성화 되어 있다면 옵션 개수(9)만큼 Detail 엔티티 하위에 Option 엔티티를 생성합니다.

기존에 이미 생성된 세션이 있다면?

등록화면 진입 시 현재 로그인 한 사용자 식별자 + Session.status =PENDING or REGISTRATION 인 세션이 존재할 경우 다음의 다이얼로그를 표시합니다.

세션 불러오기 다이얼로그

확인 버튼을 누를 경우
현재 세션 상태에 대응되는 화면을 표시하고 DB에 저장된 값을 각 항목에 불러옵니다.

취소 버튼을 누를 경우
기존에 있는 세션을 만료처리 하고 대량 등록 작업을 처음부터 시작합니다.

상품 상세정보 등록

상품 상세정보 등록 화면-1 (안가람님 감사합니다. 😘)

상품 상세정보(Detail 엔티티)를 입력하는 화면이며 Session.status = REGISTRATION 에 대응됩니다.

이전 화면에서 입력한 공통정보들을 후속 화면에서 확인하기 쉽게 화면 상단에 카드 컴포넌트와 모달로 배치하였습니다.
상품 등록 리스트(2) 항목의 빈칸을 전부 기입하고 적용(5) 버튼을 누를 경우 각각의 엔티티에 입력한 사항이 저장됩니다. 저장시엔 체크박스에 체크된 열에 포함된 값들만 영속화가 됩니다.

각 열엔 Detail 엔티티 생성 시 채번된 Token이 hidden 필드로 포함되며 CRUD API 호출 시 Token이 조회키로 직렬화되어 Payload에 포함됩니다.

뒤로가기(3) 버튼을 누를 경우 현재 생성된 모든 Detail 엔티티들을 제거하고 세션 상태를 PENDING 상태로 전환 후 상품 기본정보 등록 화면으로 이동합니다.
삭제(4) 버튼을 누를 경우 체크박스로 선택된 Detail 엔티티를 삭제합니다.

검증로직 오류 발생 시 메세지 표시 방안

29CM의 기존 어드민은 에러 발생 시 사용자에게 응답코드와 한줄 텍스트로 구성된 단순한 정보만을 전달하고 있었습니다.
에러가 발생할 경우 사용자는 어떠한 부분이 에러인지를 판단하기 위해 시스템의 힘을 빌려야 하는데 에러 메시지가 한줄 텍스트로 구성된다면 여러번 검증 로직을 태워 RC를 확인해야 하는 불편함이 발생합니다.

실제로 유사 스펙을 제공하는 화면에서 에러가 발생하여 한줄 메시지로 가이드 된다면 사용자는 어떤 부분에서 에러가 발생하는지 명확히 알 수 없고 에러가 발생하지 않을 때까지 동일한 작업을 계속해야하기 때문에 개발팀으로 이슈를 문의하는 케이스가 자주 발생하고 있습니다.

이러한 경우 업무상 불필요한 오버헤드가 발생하고 좋지 못한 사용자 경험을 유발하기 때문에 개발팀에서는 신규 어드민 개발시 에러메시지의 수준을 사용자가 인지할 수 있는 수준으로 내려주는 것을 원칙으로 정하고 Request 단계에서 검증 로직을 적용해야 할 경우 Spring Bean Validation을 적극적으로 사용하기로 하였습니다.

Spring Bean ValidationBean Validation 1.0 (JSR-303), Bean Validation 1.1 (JSR-349)에 명시된 유효성 검증 로직이 추상화되어 있으며 Spring Boot에 통합되어 있어 편리하게 사용이 가능합니다.

Annotation 기반 Bean Validation

Bean Validation은 클래스 필드에 제약 조건에 해당하는 Annotation을 부여하여 검증 로직을 구성합니다.
만약 유효성을 검사해야 하는 다른 객체가 필드로 포함된 경우 이 필드에도 @Valid 를 추가해야 합니다.(위 예제의 notices 필드가 이에 해당됩니다) 이러한 케이스를 공식 문서에서는 Complex Type 이라고 명시하고 있습니다.

지원하는 Annotation은 다음과 같습니다.

@RequestBody 를 매개변수로 받는 컨트롤러 메소드에 @Valid 를 추가합니다. 이러한 경우 Spring은 다른 작업을 수행하기 전에 먼저 객체를 Validator에 전달하여 유효성을 검사합니다.

Controller 메소드에서 @RequestBody가 사용되는 케이스

유효성 검사에 실패했다면, MethodArgumentNotValidException 이 발생됩니다. Spring은 이 예외 발생시 HttpStatus.BAD_REQUEST(code: 400)를 반환하는데요. 상품 서비스에서는 ControllerAdvice 클래스를 정의하고 ExceptionHandler를 사용하여 해당 예외를 오버라이딩 하도록 구현합니다.

MethodArgumentNotValidException을 ExceptionHandler로 오버라이딩

Bean Validation이 완료된 후 BindingResult.getFieldErrors() 를 사용하여 발생한 에러들을 전부 캡쳐할 수 있습니다. 표시할 메시지 포맷은 자유롭게 커스터마이징이 가능하며 위의 예제는 프론트에서 다음과 같은 형태로 표시됩니다.

상품 상세정보 적용 실패 모달

에러 메시지들을 최대한 자세하게 내려줌으로써 사용자가 어떠한 부분에서 에러가 났는지 비교적 명확하게 인지할 수 있습니다.

상품 상세정보 등록 화면-2
상품 상세정보 등록 화면-3
상품 상세정보 등록 화면-4

화면에서 검증 로직 적용 시

앞서 Presentation Layer에 로직을 포함시키지 않는다 는 원칙을 언급했습니다.
하지만 특정 필드의 입력값에 따라 다른 필드의 값을 자동완성 해야하는 요구사항이 발생했습니다. UX는 다음과 같습니다.

  • 마진율(6), 소비자가(7), 판매가(8)의 입력 값에 따라 할인율(9), 공급원가(11), 과세액(12), 부가세 제외액(13)의 값이 동적으로 변해야 함

이러한 경우 화면에 서버쪽에서 사용하고 있는 로직을 일부 녹여내야 합니다. 하지만 계산된 결과값은 사용자가 입력한 값에 기반하여 추정값을 알려주는 역할로 한정합니다. 저장시엔 폼에 자동완성된 값을 사용하지 않고 입력받은 파라미터를 기반으로 서버에서 다시 계산하여 영속화 하도록 구현하였습니다.
검증 로직 변경 시 서버와 화면 양쪽 전부 변경해줘야 하는 부담이 발생하지만 최소한 서버쪽 로직은 화면에서 변경한 로직의 영향을 받지 않도록 구현하여 독립성을 보장합니다.
단, 화면에서 보여지는 값이 영속화 된 값과 차이가 없도록 구현되어야 합니다.

최종 등록 버튼을 누르면 세션에 영속화 된 컨텍스트를 기반으로 상품을 생성합니다.
상품 생성은 기존에 이미 구현된 서비스 구현체를 그대로 사용하면 되고 검증 로직에 사용된 클래스도 동일하기 때문에 세션에 저장된 데이터는 상품 생성 메소드를 검증 로직의 에러가 발생하지 않도록 수행되기 위한 전처리가 완료된 상태입니다.

최종 등록이 완료된다면 조회 화면으로 이동하고 생성된 상품만 표시되도록 사용자에게 보여주는 것으로 상품 대량등록 절차가 마무리됩니다.

등록된 상품을 조회화면에서 제공

Multi DataSource 적용에 따른 트랜잭션 설정

29CM의 상품 서비스는 향후 MSA 전환의 최종 단계인 DB분리 및 이전을 위해 현재 메인 DB로 사용중인 PostgreSQL과 향후 이관될 DB인 MySQL (AWS Aurora) 양쪽 DB를 Multi DataSource로 참조하도록 구성되었습니다.

이는 메인 DB를 MySQL로 점진적으로 이관하기 위한 듀얼라이트와 같은 DB 마이그레이션 전략을 용이하게 수행하기 위함이고 또한 29CM의 메인 DB인 PostgreSQL이 가지고 있는 몇 가지 이슈에 기인합니다.

대표적인 이슈는 다음과 같은데요.

  • PostgreSQL은 Update, Delete 트랜잭션의 결과로 Dead Tuple이 발생하는데 Dead Tuple이 대량으로 발생하면 이를 클렌징하는 Auto Vacuum이 오랜 시간 지속되어 DB 성능에 영향을 줌
    (Auto Vacuum은 DB 자원을 매우 많이 소모합니다. 장시간 수행되는 경우 DB 성능에 큰 영향을 주며 관련 아티클은 다음을 참조해 주세요.)
  • 운영 DB의 버전이 낮아 Default Value가 있는 컬럼 추가 시 ALTER 대상 테이블에 락이 걸리는 이슈

DB의 버전업은 영향도가 높은 작업이며 경우에 따라 신규 DB로의 이관과 비슷한 비용이 발생한다고 보고 있어서 MySQL (AWS Aurora)로 이전을 지속적으로 고려하고 있습니다.

이로 인하여 해당 데이터소스의 트랜잭션을 책임지는 트랜잭션 매니저도 별도로 구성된 상태인데요. 이번 프로젝트에서는 구성원의 MySQL 접근성을 높이고 신규 기능은 되도록 신규 DB에 영속화 하고자 하는 생각이 강해서 MySQL DB에 세션 기능을 구현했습니다.

하지만 상품 테이블은 PostgreSQL DB에 위치하기 때문에 서비스를 조합하는 Facade 레이어에서는 다른 트랜잭션 매니저가 같이 동작하는 상황이 발생할 수 있습니다.
이러한 경우 해당 메소드에는 @Transactional 을 사용할 수 없으며 예외 발생 시 이전에 발생했던 트랜잭션의 내용은 이미 커밋된 상태라서 롤백이 되지 않아 정합성 이슈가 발생할 수 있습니다.

흥미롭게도 Spring Data에서는 두 개의 트랜잭션 매니저를 체이닝 해주는 ChainedTransactionManager 를 제공합니다.

문서에는 본 스펙이 @Deprecated 되었고, 이걸 되도록 사용하지 말고 트랜잭션 수준을 단순화 하라는 권고사항이 명시되어 있습니다.
실제로 ChainedTransactionManager 를 잘못 사용한다면 좋지 못한 코드가 나올 수 있는데요. 간단하게는 다음과 같은 문제가 발생할 수 있으며 트랜잭션 매니저의 명칭은 각각 TxManager-1, TxManager-2로 가정하겠습니다.

  1. ChainedTransactionManager를 사용하는 트랜잭션 시작
  2. TxManager-1을 사용하는 트랜잭션 시작 > 종료
  3. TxManager-2를 사용하는 트랜잭션 시작 > 종료
  4. TxManager-1을 사용하는 트랜잭션 시작 > 예외 발생

이러한 경우 2번 상황에서 TxManager-1을 사용하여 영속화 된 데이터는 이미 커밋이 완료된 상태기 때문에 롤백이 불가능합니다.
하나의 로직에서 다수의 트랜잭션 매니저가 사용되는 케이스는 지양되어야 하지만 부득이하게 사용해야 하는 경우 다음의 케이스를 고려해야 합니다.

  • 후행 트랜잭션은 예외가 발생하는 상황이 없어야 한다
  • 그렇기 때문에 후행 트랜잭션의 로직은 최대한 단순화 되어야 한다
  • 후행 트랜잭션의 수행이 무조건 보장되어야 한다

그렇다면 후행 트랜잭션을 보장하기 위해 어떤식으로 구현하는게 좋을까요? 여러가지 방안이 있겠지만 개발팀은 다음과 같이 코드를 구성하였습니다.

위의 메소드는 Facade 클래스에 구현되어 있으며, 프론트에서 최종등록 버튼을 누를때 호출됩니다. 내부적으로 호출하는 메소드들은 각 DataSource에 해당하는 @Transactional 이 적용되어 있습니다.
로직의 마지막 단계에서 세션의 상태를 COMPLETED로 변경하기 위해 MySQL 트랜잭션 매니저를 다시 열어야하는 상황이 발생합니다. 해당 트랜잭션의 동작성을 보장하고 빨리 끝내기 위해 로직을 단순화하고 AWS Simple Queue Service (이하 SQS)를 사용하였습니다.

해당 Queue의 Consumer 는 다음과 같이 구현하였으며 서비스 메소드 내부에서 MySQL 트랜잭션이 시작됩니다.

메시지를 Consume 시 세션의 상태를 COMPLETED로 변경하는 서비스 메소드를 호출하며 SQS Deletion 정책을 SqsMessageDeletionPolicy.ON_SUCCESS 로 설정하여 정상적으로 로직이 수행되는 경우에만 enqueue된 메시지를 삭제 처리하도록 설정합니다.

인스턴스 및 네트워크 장애와 같은 예기치 않은 상황이 발생하여 Consumer에서 메시지를 정상적으로 처리하지 못하는 상황을 대비해 Queue에 리드라이브 정책을 추가하고 별도의 Dead-Letter Queue를 연결시키는 것도 좋습니다.

대량 등록 FLOW를 트랜잭션 매니저 단위로 도식화 한다면 다음과 같습니다.

상품 대량등록 FLOW

맺음말

상품 대량등록 기능을 최대한 빨리 구현하기 위해 최소한의 스펙으로 설계하고 구현했는데요.
사용성을 높이기 위해선 상품에 매핑될 이미지의 벌크 업로드 기능이 추가로 구현되어야 합니다. 다음 아티클에서는 대량등록 기능을 고도화 하고 이미지 벌크 업로드 기능을 어떤식으로 구현하는지에 대해 설명드리고자 합니다.

덧붙여 이번 기능을 개발하면서 상품 모델을 어떻게 개선해야 할지에 대해 많은 생각을 하게 되었는데요. 현재의 상품 모델은 가격, 재고 정보들이 포함되어 있어 SKU의 역할을 하지 못하고 여러 도메인에 걸쳐 사용되는 일종의 컨텍스트 홀더 역할을 하고 있습니다. 이는 상품 엔티티 본래의 목적이 변질되는 원인이 되어 상품을 위한 기능 추가 및 확장에 큰 장애물이 되고 있습니다. 여러곳에서 사용하기 때문에 Race Condition이 자주 발생하며 이는 시스템 병목의 원인이 될 수 있겠죠.

또한, 옵션의 구성을 상품 서비스에서 책임지고 있어서 상품 관련한 기능을 추가 시 항상 옵션을 고려하면서 개발해야 하는 부담이 발생합니다. 이러한 기능들을 상품 서비스에서 점진적으로 분리하고 상품 서비스의 과도한 책임 범위를 축소시켜 별도의 서비스로 분산시키고자 합니다.
옵션은 상품 서비스 개발팀을 매우 힘들게 하는 녀석인데요. 프로덕트 카탈로그를 통해 이를 분리해 내려고 합니다. 조만간에 이를 풀어낼 아키텍쳐를 29CM 기술 블로그를 통해 상세하게 소개하도록 하겠습니다.

도전적이고 흥미로운 주제들이 앞으로도 많이 남아있고 서비스가 고도화 됨에 따라 상품 서비스 관련해서 29CM 기술 블로그에 쓸 내용이 많아질거라 기대합니다.

읽어주셔서 감사합니다.

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

29CM (에이플러스비) 는 3년 연속 거래액 2배의 성장을 이루었습니다.

함께 성장하고 유저 가치를 만들어낼 동료 개발자분들을 찾습니다.
많은 지원 부탁합니다!

--

--