Customer Service 관리 시스템 개발
전자상거래의 CS 관리 시스템이라는 건 생각보다 복잡하다.
- 회원정보 조회
- 회원 정보 (아이디, 계좌, 주소지 …때로는 탈퇴처리) 변경
- 이 회원이 주문한 내역
- 이 회원이 최근 주문한 상품
- 이 회원의 상담 이력
- 상품 판매 업체와 연동
- 택배 연동
- 메시지, 전화 등의 Channel 연동
- etc …
사실상 전자상거래의 모든 도메인이 다뤄진다고 볼 수 있다.
그만큼 의존성이 문어발 식으로 연결되어있고, 그 만큼 타 도메인의 변화에 영향을 많이 받는다.
나 말하나...?
과장하면 타 도메인에서 재채기를 하면 감기에 걸릴수도 있는게 CS 관리 프로그램이다.
버틸수가 없다
MSA(M
icro S
ervice A
rchitecture) 환경에서 동시다발적으로 벌어지는 각 도메인들의 변화를 추적하고 반영하다보면, 쉴새없는 오류와, 호환성 문제를 겪게 된다.
이런 상황을 잘 관리하지 못하면 결과는 끔찍하다.
쓰레기를 가득 실은 트럭이 도로 제한속도를 넘어 과속하는 것만으로 위험한데, 이 트럭이 사고가 나 전복된다면 쓰레기들이 도로를 가득 메우는 상황이 펼쳐질 것이다.
아마 CS 개발자 대부분이 어떤 로직에 추가사항을 넣으려고 할때
이 코드는 누가 만들었지?!
하며 git blame
을 (혹은 annotation) 을 안해본 사람이 없을 것이다.
(때로는 만든 사람이 자신인 웃기는 경우도 있다…)
테스트 코드 부재는 예사이고, 팀원들끼리 충분한 커뮤니케이션이 없으면 중복된 코드가 각자의 개성으로 암세포처럼 자라난다.
그 코드가 관련된 도메인이 변할 경우 이곳저곳에서 자란 해당 코드 모두가 수정되어야 한다.
새 기능이 추가될 때 하나라도 이 부분을 누락하면 바로 장애로 이어지거나 유지보수 이슈가 등록된다.
리팩토링에도 한계가 있다.
리팩토링 범위를 아무리 최소로 해도 하다보면 너무나 광범위한 영역을 다루게 되며 결국 포기하는 일이 많다. 리팩토링이 때로는 새로운 버그를 만들기도 한다. 이건 이 경우와는 별개로 애초에 잘못된 습관 탓도 있고, 다른 상황에서도 마찬가지인 경우가 많긴 하다.
버틸수가 없다!
이런 환경속에서 부족한 리소스로 일을 진행하면서 몇차례의 개편을 하다보니 코드의 유지보수성에 많은 생각을 하게 되었다.
- 외부 변화에 큰 타격이 없을 것
- 도메인 대응이 늦어도 해당 도메인의 기능을 제외하고는 정상 동작해야 할것
- 서버와 클라이언트의 분리가 될 것
- 새 기능의 추가가 쉬울 것
- 프로그램 속도에 문제가 없을 것
- 코드가 Readable 할 것
- 재미있을 것(?)
이런 생각이 정리되어 가던 때에 드디어 다시한번 개편을 시도할 기회가 생기게 되었다. 신규 주문정보의 개편이 시작된 것이다. 이번이 나에겐 4번째이다.
그동안 고민했던 문제들을 쭉 펼쳐보고 기존에서 바꾼 방법을 하나하나 적어보겠다.
모든 것은 팀원들과의 회의를 통해 결정
했다. 이 과정은 상당히 길고 잦었지만 그 시간은 상당히 유익했던 시간이었다
새로운 방법
Layer
기존의 구조는 Spring 의 Controller - Service - Repository or Domain API 의 정석적인(?) 구조였다.
DDD 방식으로의 전환도 고려해보았지만, 5년을 넘게 일해도 이해할 수 없는 여러 비즈니스들과 도메인 개념들, 그리고 팀 전체적으로 (나 포함) 낮은 DDD 숙련도 등으로 그냥 전통적인 Controller - Service - Repository or Domain API 로 결정했다.
약간 아쉽기도 하지만, 시간이라는 제약도 있고 익숙하지 않은 모험을 하기에는 약간 위험했다.
다만 저 기본적인 흐름에 레이어링을 적용하기로 했다.
Controller
- 외부 요청을 처리하는 용도. 일반적인 Controller.
Service
- 요청에 대한 비즈니스 로직의 묶음. 비즈니스가 변할 경우 서비스만 재작성하면 된다.
- 여러 비즈니스 단위 모듈 의존성을 주입받아 처리한다. 하나의 의존성만 있을 수 있고, 여러 비즈니스가 얽힌 의존성이 처리될 수도 있다.
Business Logic Behavior
- 단일 비즈니스를 처리하기 위한 모듈
- Repository 등에 의존성이 있다.
Repository
- 팀 오너십 데이터에 대한 CRUD 및 외부 API 에 대한 래퍼.
- Repository 와 Api 를 나누려고 했으나 그냥 하나의 레이어로 래핑하기로 함.
Helper
- 유틸리티. 무상태이거나 Controller, Service, Behavior, Repository 에는 의존성이 없는 모듈.
- 순수 함수들의 집합.
Helper 를 제외한 각 레이어끼리는 의존성을 걸지 않는게 기본이다.
초창기에는 Collector Layer 도 추가했지만, 나중에 설명할 데이터 토막치기
덕분에 잘 쓰이지 않아 사장되었다. Collector 는 각 데이터를 Aggregation 하는 레이어였다.
DTO
DTO 도 구분했다.
데이터의 포장에도 각자의 목적이 있다
기본적으로 Request 로 받는 Condition 류를 제외한 모든 DTO 에는 모든 필드가 final
로 불변객체이다.
불변이 아닐 경우 각 로직이나 레이어를 거치면서 전달되는 객체의 필드가 실제 값이 있는지, 중간에 값이 어떻게 변하는지, 등의 상황에서 한 레이어만 보고서는 추적이 되지 않기 때문이다.
1 2 3 4 5 6 7 8 9 10 11
|
DetailOrderDTO order = this.readOrderData(orderId);
order = productModule.appendProductData(order);
order = vendorModule.appendVendorData(order);
|
이러한 로직이 있을 경우 productModule.appendProductData
, vendorModule.appendVendorData
는 주문의 특정 필드의 nullable 여부가 중요해진다. 게다가 상품에 업체정보가 있으므로 앞선 로직에서 상품정보가 정상적이지 않을 경우 다음 업체정보도 얻을 수 없게 된다.
이 상황에서는 필드의 초기화 여부와 각 모듈의 호출 순서가 매우 중요하다. 이 규칙아래 에서 모듈 의존성 뿐 아니라 로직 의존성까지 발생한다.
리팩토링을 할 때도 문제가 된다.
각 모듈 호출 순서를 반드시 지켜야 하며 각 모듈안의 로직을 자세히 살펴보고 완전히 로직을 파악한 뒤에야 리팩토링을 할 수 있다.
하지만, 모든 필드가 final
일 경우 어떠한 DTO 를 전달받았을 경우 각 필드들이 반드시 초기화가 되었다는 걸 의미하기에 앞선 문제의 대부분이 해소된다. 어디선가 전달받은 객체라도 값의 내용물에 대해 안심하고 쓸 수 있다는 뜻이다. (필드의 Null 여부가 아니라 초기화 여부를 말한다)
각 레어이간 데이터는 다음과 같은 기준으로 정했다. 네이밍이 약간 이상한것 같지만 그런가보다 하자;
VO
- Repository 등에서 얻는 기본 데이터.
Condition
- Client 의 요청 데이터. 불변처리가 힘들기에 일반적인 Setter 가 달려있다.
Form
- 비즈니스 로직 단위의 요청 폼. 대부분 서비스에서 생성되어 각 비즈니스 처리기에 전달된다.
Result
- 각 VO 를 수집하여 Client 가 요구하는 데이터로 빌드되는 DTO
예를 들면, MemberFindCondition 으로 요청되면 서비스는 그 요청으로 각 비즈니스에 MemberFindForm, MemberBlockForm, MemberXXXForm 등을 만들어 처리하고 그 결과를 MemberFoundResult 로 응답한다.
Data Aggregation
다양한 도메인을 한번에 다루는 CS 특성상 여러 도메인의 데이터를 조합하는 경우가 많다.
재료를 잘 섞어야 맛있다.
어디에도 끼는 회원이나 상품 말고도 CS의 99% 이상의 문의가 주문 관련이니 주문 데이터와 주문에 따라오는 배송 데이터 등은 항상 데이터 조합 대상이다.
기존 시스템은 클라이언트 요청에 서버는 각 도메인의 데이터를 한번에 합쳐서 보여주는 방식으로 동작했다. 가령 회원이 최근에 주문한 데이터를 봐야 한다면 회원정보, 주문정보, 상품정보, 배송정보, 취소정보를 읽은 뒤 조합했다.
요청이 하나만 있을 경우에는 이 방법도 나쁘지 않지만, 요청이 다수가 겹칠 경우 문제가 될 수 있다. 요청하는 데이터끼리 중복되는 데이터를 포함할 수 있기 때문이다.
최근 주문목록을 보여주는 컴포넌트가 있고 주문목록에서 특정 주문을 선택할 경우 다시 해당 주문의 상세를 보여주는 UI가 있다고 가정한다면,
매 요청의 응답에는 공통적으로 연관 상품정보, 취소정보, 결제정보, 배송정보가 포함되게 된다.
불필요한 반복적 요청이 되는 셈이다.
게다가 비즈니스의 변화로 데이터에 변화가 생길 경우 각 화면별로 더 추가되거나 제거될 수 있어 수정도 동시에 여러 군데에서 일어난다.
이런 방법보다 데이터의 조합은 연관결합도가 높은 것끼리만 하고, 공통적인 데이터는 분할 요청하는 방식을 선택했다.
난 이걸 데이터 토막치기
라고 (나 혼자 쓰는 용어이다) 명명했다.
데이터 토막치기
토막쳐보자... 부우우우우웅!!
최근 주문목록에 필요한 데이터를 조합한다고 가정해보자
- 회원정보 요청
- 회원이 주문한 내역 리스트 데이터를 최근 순으로 요청
- 주문내의 정보로 다음 정보 요청
- 주문 내의 상품정보로 상품 정보 요청
- 주문 아이디로 결제 정보 요청
- 주문 아이디로 취소 정보 요청
- 상품 정보가 응답되면 그 정보로 다시 업체 정보 요청
- 상품 정보로 상품의 각 배송타입, 유형, 카테고리 정보 요청
각 정보를 조합해서 화면에 표시한다.
여기서 다시 특정 주문의 상세를 보고 싶다고 한다면 앞서 요청한 상품상세와 각 메타데이터, 업체, 결제, 취소 정보는 요청하지 않아도 된다.
필요한 상세 데이터를 추가 요청한뒤 데이터를 조합하면 끝이다.
그리고 다른 주문번호를 보다가 다시 같은 주문 상세를 조회할 경우, 이미 로딩된 정보를 활용할 수도 있다.
어떤 데이터 종류는 갱신이 자주 되는 데이터는 만료 관리가 필요하거나 아예 새로 로딩해야 할 때도 있지만, 대부분의 경우 앞서 설명한 로딩된 데이터끼리의 조합
방식이 훨씬 유리하다.
이렇게 데이터를 최대한 분할하여 재활용성과 서버 자원 낭비를 줄이고 성능 향상도 고려했다. 모든 데이터를 토막치는게 아닌 비즈니스나 UI 상황, 효율및 결합도에 따라 데이터 구성을 하여 합치는게 유리하다는 것도 잊지 않았다.
마침 적용하려고 하는 상태관리기 Redux 의 selector 개념과 이를 보좌해주는 Reselect 는 이런 방식에 찰떡 궁합이었고, 디렉토리 구조도 그에 맞게 가져갔다.
서버 기반을 수정해보자
서버 개발에서는 특별한 개선을 하기 어려웠다.
프로젝트를 온전하게 새로 설정했으면 좋았겠지만, 기존부터 쌓인 코드에 의존성이 상당하고 타 팀의 코드도 섞여 있기에 서버의 완전한 새판 짜기는 불가능했다. 위에 설명했던 레이어링 및 패키지와 설정 파일의 분리 정도가 가능했고, 나머지 모듈 의존성 등은 크게 손댈 수 없었다.
기회가 된다면 Spring Boot 부터 Mbean 등을 좀 더 잘 써보고 싶은데… 한다면 팀 스탠드얼론이 가능한 프로젝트에나 도입할 수 있을 것 같아 아쉽다.
OTL
클라이언트 기반을 수정해보자
클라이언트는 이야기가 달라서 완전한 재설정이 가능했다.
클라이언트쪽은 기존의 나쁜 냄새를 모두 제거하기 위해 바닥부터 새로 시작하기로 했다.
- Global N Sub 방식의 Multi-Store
- 클라이언트 사이드 라우팅
- 스크립트 용량 축소
- 컴포넌트의 재사용성
- 사용자 액션 추적
- 에러 리포트
Multi Store
Redux 는 기본적으로 단일 스토어를 추천한다.
하지만 새로 개편하는 어플리케이션에는 단일 스토어의 이점이 전혀 떠오르지 않았다.
데이터가 각 회원 혹은 주문 단위로 휘발성이며 상태들의 재사용성이나 히트율이 낮고, 동일한 구조의 회원이나 주문 등의 컨텍스트만 다른 데이터가 대다수이다.
이 역시 컨텍스트가 바뀌면 버려진다.
게다가 개편 대상인 어플리케이션은 동적 탭 단위의 구조이다. 같은 탭이 여러개 열릴 수도 있다. 그러면 탭마다 관리되는 상태는 컨텍스트마다 종속 데이터를 관리해야 한다.
또한 탭이 바뀌거나 하면 화면의 모든 요소를 새 화면의 컨텍스트에 맞게 계산하고, 컴포넌트를 렌더링해야 한다. 탭 하나가 굉장히 많은 데이터를 가질텐데, 단순한 탭의 스위칭만으로 모든 요소가 새로 그려질 것이고 그만큼 화면의 부하는 커진다.
컨텍스트에 따른 reducer - state 설계도 만만치 않은데다, 성능저하는 단일 스토어에 대해 깊이 생각해보게 되었다.
결론은 전역 스토어는 하나 두고, 특정 컨텐츠 탭에 대해서는 서브 스토어를 생성하며 스토어를 가진 탭이 닫힐 경우 상태를 정리하는 것보다 그냥 그 스토어를 버리는 구조로 정하게 되었다.
자식 스토어는 선택적으로 부모 스토어에서 상태를 구독할 수 있고, 액션중 특정 Symbol 을 통해 전역 스토어에도 dispatch 를 할 수 있도록 설계했다.
클라이언트 라우팅
이리저리 가시오
라우팅 기능을 하는 React-Router 라는 훌륭한 라이브러리가 이미 존재하고, 이걸 쓰면 되겠지 라고 생각했다.
그러나 이리저리 돌려본 결과는 실제 CS 툴에는 그리 어울리지 않다는 결론을 내렸다.
다음과 같은 이유에서다.
- 단일 스토어에 최적화되어 있다.
- 라우팅이 바뀔 경우 현재 스토어의 state 에 따라 컴포넌트를 새로 렌더링하는데, 만들려는 어플리케이션은 잦은 라우팅 변경이 있어서 성능 문제가 생긴다
- props 가 아닌 state 의 관리가 어렵다
결국 React Router 에서 Route 기능만을 빌려와서 직접 라우팅 시스템을 구현할 수밖엔 없었다.
겸사겸사 React Router 에서 지원하기 좀 애매한 동적 모듈 로딩 라우팅도 적용했다. (개발 당시에는 없었는데 지금 버전에서는 잘 지원하고 있더라…)
사용한 라이브러리는 Univasal-Router 이다.
단순하지만 프로젝트에서 필요로 하는 모든 기능이 들어있었다.
스크립트 용량 축소
위에 잠깐 언급되었지만 기존 시스템의 스크립트 용량은 무려 5mb 였다.
어플리케이션에 사용되는 모든 스크립트를 하나의 파일로 만들어서 한번어 로딩하는 방식이었기 떄문이다.
이 방법으로 인해 성능이 느리거나 네트워크가 불량할 경우 어플리케이션이 상당히 느려졌었고, 브라우저으 javascript 성능이 다소 안좋을 경우 (IE…) 페이지가 한참동안 흰색으로 보이거나 Timeout 에 걸리는 백화 현상
이라는 일이 발생했었다.
이번에는 필요한 자원이 있을때 로딩하는 동적 로딩을 도입하기로 했다.
동적 로딩은 간단했다. webpack, babel 조합으로 간단히 import 구문으로 구현할 수 있었고, webpack 의 chunkName 조합으로 디버깅 및 파일 이름 지정도 가능했다.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29
| import React from 'react'
const loadComponent = () => import('component/DynamicComponent');
class MyComponent extends React.Component {
this.state = { DynamicComponent: null, };
componentDidMount() { loadComponent().then(DynamicComponent => { this.setState({ DynamicComponent }) }); }
render() { const { DynamicComponent } = this.state; if(!DynamicComponent) { return null; } return ( <DynamicComponent /> ); } }
|
적용 후 첫 로딩 스크립트의 용량이 300kb 도 안될 정도로 좋아진 걸 보고 꽤나 좋았던 기억이 난다.
컴포넌트 재사용성
이 문제는 참 어렵다.
재사용성을 아무리 고려해도 실제 이리저리 재활용을 하려고 하면 각자의 조금씩 다른 요구사항과 스타일 등에 버려지는 케이스가 많기 때문이다. 재활용성이 높은 컴포넌트는 무엇보다 이런 고민이 잘 커버되는 설계가 상당히 중요하다.
설계 능력이 그리 좋지 못한 덕분에 나는 아직도 재활용성이 높은 컴포넌트가 뭔지 헤메이고 있다.
그래도 노력이 나를 조금이나마 동정했는지, 몇몇 컴포넌트는 재사용성을 확보할 수 있었다. 디자인 요소를 배제할수록, 도메인 이해도가 높을 수록 재사용성이 높았던 것 같다.
- PureComponent 를 최대한 다수를 생성하려고 했다. 재활용의 시작이 되니까. React 16.6 에서는 React.memo 라는 것도 지원해서 PureComponent 사용이 더 좋아졌다.
- 재활용 요소는 가급적 React.Fragment 로 래핑한다. 이 요소가 어느 레이아웃으로 재활용될지 모르기 때문이다.
- 스타일 요소는 하나하나를 스타일링하지 말고 Styled-Component 로 대체할 수 있게 디자인한다.
사용자 액션 추적 / 에러 리포트
이 문제는 사실 Redux의 middleware 만 잘 활용하면 달성이 쉽다.
다만, 액션으로 잡히지 않는 것까지 모두 처리하려면 직접적인 상태 변화가 없는 (다시 말하면 reducer 에서 처리하지 않는) 액션까지 디테일하게 설계해야 한다.
이런 건 보통 사이드이펙트 뿐인 작업을 수행할 때 발생하는데, 이런 것에 잘 어울리는 방식을 찾다가 Redux-Saga 를 도입하기로 했다.
Redux-Saga 는 Redux 플로우에서 사이드이펙트를 관리하기 위한 미들웨어이다.
Redux Saga 로 사용자의 모든 행동은 Saga 를 통해 로깅되며 사이드이펙트로 서버에 리포트되도록 했다.
이런 액션 로깅은 Elastic Search 등으로 쌓아서 통계나 어플리케이션 사용 행태를 분석하는 용도로 쓰려고 준비중이다.
사용자의 흐름이나 행동 및 발생하는 에러를 분석하면 버그나 기능 개선, 사용율 체크에 큰 도움이 되지 않을까 한다. 에러 시에는 현재 상태의 스냅샷을 전송한다면 재현도 어렵지 않게 할 수 있기에 처리가 좀 더 쉬울것이라 예상한다.
서비스를 해봐야 알겠지만…
클라이언트 단독 개발이 가능하도록
프론트 엔드 성 아래의 백 엔드 심해
요청사항에 따라 클라이언트 개발만 진행하거나 소소한 수정건이 있을 수 있다. 이런 경우 예전의 구조에서는 클라이언트 수정이라도 로컬 서버를 먼저 실행시키고 로컬 서버를 구동하여 개발을 진행했다.
이 방법이 나쁜건 아니나 어차피 현재 구조에서는 페이지 라우팅을 클라이언트에서 하는데다가, 인증과 데이터 말고는 서버가 화면에 하는 일이 없기때문에 굳이 클라이언트 수정할때 무거운 local WAS 를 실행시킬 필요가 없다고 생각했다.
이 구조에서는 데이터만 제공된다면 클라이언트 개발에는 무리가 없다.
가상 데이터를 제공할 수 있는 mock 서버를 시작하고 가상 데이터를 내려주는 로컬 서버를 시작하고 webpack-dev-server 를 mock 서버에 연결하는 작업으로 로컬서버만으로 개발이 가능하게 되었다.
mock 서버는 node-mock-server 을 사용했다.
문서가 다소 부족해서 사용법 사용에 애를 먹었지만, 파일 기반으로 GET, POST, PUT, DELETE 등 지원에 커스텀 파라미터에 따른 커스텀 데이터 생성기능까지 쓸만한 기능은 다 있어서 단순한 기능 사용에는 문제가 없었다.
webpack-dev-server 와 mock 연동에는 express-http-proxy 를 사용했다.
이 방법으로 mock 연계를 하고나니 좀 더 나아가서 실제 서버로 화면 개발을 진행할 수 있을것 같았다.
추가 개발은 proxy 에 https 지원을 추가하고 npm 스크립트를 몇개 수정한 것 뿐으로 훌륭한 실서버 <==> webpack-dev-server 의 연계가 만들어졌다.
이 작업으로 클라이언트 개발 매우 편해져서 작업 효율이 크게 증가했다.
아직이다
지금까지 개선과 설계 방향을 쭉 나열한 것 같다.
다음 글 에는 이러한 개념을 적용하며 겪은 문제점과 아쉬운 부분을 나열해보겠다.