Hence
피드, 팔로우, 메시지 같은 커뮤니티 기능과 상품 탐색, 주문, 리뷰, 쿠폰, 마이쇼핑 같은 커머스 기능을 한 서비스 안에서 제공한 모바일 앱형 서비스입니다. React 웹이 네이티브 앱 안에서 WebView로 동작했고 커머스 백엔드는 서드파티인 Shopby API였습니다. 저는 FE 6명 가운데 커머스 영역을 맡았고 초기에는 Sendbird 기반 메시지 기능도 구축했습니다. 구축 프로젝트였고 서비스는 현재 종료됐습니다.
핵심 기여
| 과제 | 본인 기여 | 확인한 동작 |
|---|---|---|
| 서드파티 API와 UX의 차이 | Shopby 응답 변환과 카테고리 재귀 탐색 | 상품 표시와 브랜드 탐색을 화면 모델로 연결 |
| 긴 목록의 복귀 맥락 | 가상 목록의 위치와 측정 높이, 캐시 조건 연결 | 데이터가 유지된 경우 상세에서 돌아온 위치를 복원 |
| 커뮤니티와 커머스 연결 | Sendbird 화면을 서비스 헤더와 입력, 프로필 흐름에 통합 | 메시지 SDK와 서비스의 책임을 구분 |
개요
| 항목 | 내용 |
|---|---|
| 기간 | 2023.06 ~ 2024.01 |
| 역할 | FE 6명 중 커머스 담당. 커머스 전 여정 화면, 초기 메시지와 팔로우 흐름, 후반 가상 스크롤과 복원, Sentry |
| 인원 | FE 6명, 퍼블리셔 5명, 백엔드 4명. 네이티브 앱은 별도 |
| 서비스 | 커뮤니티(피드, 팔로우, 메시지)와 커머스(탐색, 상세, 주문, 리뷰, 쿠폰, 문의, 클레임, 마이쇼핑)가 결합된 사용자 서비스. 구축 뒤 종료 |
| 기술 | React 18, TypeScript, React Router DOM 5, React Query 4, Zustand, React Hook Form, Sendbird UIKit 3, react-virtualized, Swiper, SCSS와 CSS Module, Sentry, Shopby API, AWS S3와 CloudFront |
작업은 시기별로 무게가 달랐습니다.
| 시기 | 내용 |
|---|---|
| 2023.06 | Sendbird 초기 세팅, 메시지 목록과 대화와 생성 화면, 팔로우와 프로필 연동 |
| 2023.07 ~ 2023.08 | 커머스 메인 섹션, 카테고리와 검색, 상품 목록과 상세, 브랜드와 이벤트와 스페셜오더 |
| 2023.09 | 리뷰, 쿠폰, 주문과 배송, 상품문의, 클레임, 마이쇼핑 |
| 2023.10 | 이미지 리사이징, 리뷰 보정, Sentry 도입 |
| 2024.01 | 가상 스크롤, 스크롤과 필터 상태 복원, QA 대응 |
맡은 범위
- 커머스 화면. 커머스 메인의 추천, 주간 베스트, 뉴 아이템, 브랜드, 이벤트, 스페셜오더 섹션과 카테고리 페이지와 팝업, 검색, 상품 목록의 필터와 정렬과 브랜드와 가격과 색상 조건, 상품 상세의 옵션과 쿠폰과 관련 상품과 탭 유지, 브랜드와 이벤트와 스페셜오더 상세.
- 구매 후 경험. 리뷰 등록과 수정과 신고, 포토 리뷰, 쿠폰 다운로드와 보유와 사용과 만료 상태, 주문과 배송과 클레임, 상품문의, 마이쇼핑 요약과 주문 상태와 날짜 필터.
- 응답 변환. Shopby 응답을 converter에서 ViewModel로 바꾸고 화면 부품이 쓰는 모양으로 한 번 더 좁히는 두 단계 변환 층.
- 탐색 연속성. 긴 목록의 가상 스크롤, 스크롤 위치와 확장 상태 복원, 필터와 탭과 날짜 상태 유지, 캐시 만료 플래그, 스켈레톤.
- 커뮤니티와 메시지. Sendbird 기반 메시지 목록과 대화와 생성 화면, 롱탭 UI, 신고와 나가기, 팔로잉과 팔로워 바텀시트, 다른 사용자 프로필에서 팔로우와 메시지 진입.
- 안정화. CDN 이미지 리사이징, Sentry release와 sourcemap 파이프라인, QA 이슈 수정.
문제와 해결
아래 코드는 저장소의 실제 코드를 발췌한 것입니다. 내부 주소와 식별자, 카테고리 라벨 같은 세부는 바꾸거나 뺐습니다.
필드가 너무 많은 서드파티 응답을 화면 모양으로 좁히기
문제. 커머스 백엔드가 Shopby라는 서드파티 서비스였습니다. 응답 필드가 매우 많고 화면이 필요로 하는 값은 그중 일부인데 가격 계산, 할인율, 품절과 예약과 주문제작 같은 상태 표시, 날짜 분기가 페이지마다 흩어지면 같은 분기가 여러 곳에 복제됩니다.
판단. 응답을 화면에서 직접 읽지 않고 converter 훅에서 ViewModel로 바꾼 뒤 각 화면 부품이 쓰는 props로 한 번 더 좁히는 두 단계 변환을 두었습니다. 가격과 할인율과 상태 라벨 같은 계산은 ViewModel 단계에서 한 번만 합니다.
구현. 커머스 도메인의 응답을 converter 훅에서 변환했습니다. 상품 목록 converter는 무한 스크롤 페이지들을 평탄화한 뒤 할인가와 할인율, 좋아요 여부, 리뷰 요약, 상태 스티커를 계산합니다.
구현 코드: 상품 목록 응답을 ViewModel로
const getShopSearchProductListVM = (response: InfiniteData<ShopSearchProductListRes>, likeProductListRes?: ShopLikeProductListRes, rank = false, sticker = true) => {
const productListRes = extractPageData(response)
const searchProductList: ShopSearchProductVM[] = _.flatMap(productListRes, value =>
_.map(value.items, (value, index) => {
const isReservation = dayjs(value.reservationData?.reservationEndYmdt).isAfter(dayjs())
return {
uid: _.toString(value.productNo),
imageURL: value.listImageUrls[0],
title: value.productName,
brand: _.split(value.brandName, ' / ')[0],
price: value.salePrice - value.immediateDiscountAmt,
cost: value.salePrice,
rank: rank ? index + 1 : undefined,
discount: value.immediateDiscountAmt !== 0 ? floor((value.immediateDiscountAmt / value.salePrice) * 100) : undefined,
scrap: { status: !!_.find(likeProductListRes?.items, ({ productNo }) => productNo === value.productNo), count: value.likeCount },
review: { rate: value.reviewRating, count: value.totalReviewCount },
status: sticker ? { soldOut: value.isSoldOut, reservation: isReservation, customOrder: _.some(value.customProperties, { propName: '주문제작' }), overseas: _.some(value.customProperties, { propName: '해외배송' }) } : undefined,
} as ShopSearchProductVM
}),
)
// ...
}flowchart LR
API["Shopby 응답"] -->|converter 훅| VM["ViewModel: 가격, 할인율, 상태 라벨 계산"]
VM -->|화면별 변환| VCP["화면 부품 props"]
VCP --> List["상품 목록"]
VCP --> Detail["상품 상세"]
VCP --> Order["주문과 마이쇼핑"]결과. 같은 응답을 쓰는 화면이 늘어도 계산 분기는 converter 한 곳에만 있습니다. 다만 화면용 변환에 시간이 많이 들었고 API가 받쳐 주지 않는 UX 요구가 잦아 일정과 기능 모두 쉽지 않았습니다.
API에 없는 브랜드 탐색을 카테고리로 만들기
문제. 서비스는 브랜드 단위 탐색이 필요했지만 Shopby에는 브랜드 기능이 없었습니다. 운영자는 다단계 카테고리 트리의 라벨 규칙으로 스페셜 카테고리, 노말 카테고리, 라우팅 카테고리를 구분해 두었고 브랜드와 이벤트와 스페셜오더가 그 안에 섞여 있었습니다.
판단. 카테고리 트리를 라벨 규칙에 따라 재귀로 걸러 브랜드 목록, 이벤트 목록, 상품 목록의 카테고리 탭으로 바꿨습니다. 트리 깊이가 달라도 같은 함수가 돌도록 재귀로 두고 라벨 규칙은 한 곳에 모았습니다.
구현. 깊이에 관계없이 라벨이 맞는 노드를 찾는 재귀와 최상위 카테고리를 세 묶음으로 나누는 reduce를 유틸로 두고 converter가 그 결과를 탭과 필터 모양으로 바꿉니다.
구현 코드: 카테고리 트리 재귀
export const filterCategoryByLabel = (categories: ShopMultiCategoryVM[], labels: string[]) =>
flatMap(categories, category => {
if (category && includes(labels, category.label)) return [category]
if (category && category.children && category.children.length > 0) return filterCategoryByLabel(category.children, labels)
return []
})
// 최상위 트리를 세 묶음으로 나눈다. 라벨은 운영 규칙이라 상수로 뺐다
export const getShopExposureCategoryList = (shopMultiCategory: ShopMultiCategoryVM[]) =>
reduce(shopMultiCategory, ({ special, normal, route }, { children }) => {
forEach(children, ({ label, children }) => {
switch (label) {
case SPECIAL_LABEL: return special.push(...(children || []))
case NORMAL_LABEL: return normal.push(...(children || []))
case ROUTE_LABEL: return route.push(...(children || []))
}
})
return { special, normal, route }
}, { normal: [], special: [], route: [] } as ExposureCategoryType)결과. 브랜드 페이지, 브랜드 상세, 상품 목록의 브랜드 필터가 모두 한 재귀 결과를 씁니다. 운영자가 카테고리를 추가하면 코드 수정 없이 화면에 나타납니다.
조회 조건을 query string 계약으로 두기
문제. 상품 목록, 검색, 브랜드, 마이쇼핑에는 정렬, 가격 범위, 브랜드와 색상 필터, 날짜 범위, 페이지, 주문 상태 같은 조건이 많습니다. 화면마다 다른 이름으로 다루면 상세에서 돌아올 때와 WebView 재진입 때 조건이 어긋납니다.
판단. 화면별 query string의 모양을 타입으로 선언하고 라우팅 훅 하나가 읽고 쓰게 했습니다. 정렬 기준과 방향, 판매 상태 같은 값은 Shopby 열거형을 그대로 타입으로 가져와 잘못된 값이 들어가지 않게 했습니다.
구현. useRouter 훅이 location의 query string을 파싱해 화면별 타입으로 돌려주고 이동은 getNavigatePath로 query를 직렬화합니다.
export interface QueryParams {
shopMyOrderList: { orderRequestTypes?: ORDER_REQUEST_TYPE | string | Falsy; pageNumber?: number; pageSize?: number; startYmd?: string; endYmd?: string; keyword?: string }
shopProductList: { by?: BY; direction?: DIRECTION; soldout?: boolean; saleStatus?: SALE_STATUS; pageNumber?: number; pageSize?: number; 'filter.discountedPrices'?: string }
// ...
}
export const useRouter = <TQueryParams = QueryParams, TUrlParams = UrlParams, TState = any>() => {
const history = useHistory<TState>()
const location = useLocation<TState>()
const queryParams: TQueryParams = getQueryString(location.search)
const { push, goBack, action, replace } = history
return { action, history, location, queryParams, replace, push, goBack, pathname: location.pathname, search: location.search, state: location.state }
}
export const getNavigatePath = <T extends object | undefined>({ url, queryString, identifier }: NavigatePath<T>) =>
qs.stringifyUrl({ url, query: queryString as StringifiableRecord, fragmentIdentifier: identifier })결과. 목록과 상세 사이를 오가거나 필터를 바꿔도 조건이 URL에 남아 복원 규칙이 화면마다 달라지지 않았습니다. 이 경험이 이후 프로젝트에서 URL을 조회 조건의 기준으로 두는 원칙으로 이어졌습니다.
팀 공통 내비게이션에 상품 목록의 복귀 상태 연결하기
문제. WebView 안에서 상품 상세를 다녀왔을 때 목록의 필터, 탭과 스크롤을 이어서 보여 줘야 했습니다. 브라우저 URL의 조회 조건과 목록이 기억해야 하는 표시 상태를 함께 다뤄야 했습니다.
맡은 범위. 메모리 히스토리, entry별 상태와 스크롤 저장소, 네이티브 뒤로가기 처리는 팀이 만든 공통 기반입니다. 저는 기존 저장과 조회 API를 사용해 상품 목록과 커머스 화면의 복귀 상태를 연결했습니다.
구현. 목록은 현재 history entry에 대응하는 상태를 읽고 화면을 떠날 때 필터와 표시 상태, 가상 목록 위치와 측정 cache를 저장했습니다. 복귀 시 같은 entry의 값을 읽되 조회 데이터가 갱신됐는지를 함께 확인했습니다. 상품 목록에서 빈 데이터와 목록 렌더링의 분기도 보정했습니다.
flowchart LR
Shared["팀 공통 기반: history entry와 저장 API"] --> List["담당 화면: 상품 목록 조건과 표시 상태"]
List --> Detail["상품 상세"]
Detail -->|복귀| Restore["같은 entry의 상태와 cache 조회"]
Restore --> List결과. 팀 공통 내비게이션을 활용해 커머스 목록의 복귀 동작을 확장했습니다. 필터와 표시 상태, 스크롤 복원 조건을 같은 화면 흐름에서 관리했습니다.
긴 목록을 가상 스크롤로 그리고 돌아왔을 때 같은 자리에 두기
문제. 앱 안에서 긴 목록을 스크롤로 훑는 사용 패턴이라 상품, 브랜드, 이벤트, 리뷰, 팔로우 목록이 길어지면 렌더링이 무거워졌습니다. 상세에 다녀오면 목록이 처음으로 돌아가는 것이 QA에서 반복해서 지적됐습니다.
판단. react-virtualized의 WindowScroller로 window 스크롤과 동기화된 가상 목록을 만들고 복원에 필요한 두 값을 저장했습니다. 떠날 때의 가상 목록 scrollTop과 행마다 측정한 높이입니다. 라이브러리의 scrollToIndex 계열은 WindowScroller와 함께 제대로 동작하지 않아 다른 방식으로 복원했습니다.
구현. 복원할 때는 세 규칙을 지킵니다. CellMeasurerCache를 초기화하지 않고 저장한 높이를 주입합니다. 저장 데이터와 React Query 캐시가 같아진 뒤에 그립니다. React Query가 새 데이터를 받아 왔으면 복원하지 않습니다. 마지막 규칙은 isLoading이 켜질 때 만료 플래그를 세우는 방식으로 두었습니다. 가상 스크롤 래퍼와 무한 로더를 공용 부품으로 만들어 목록 화면이 같은 복원 규칙을 씁니다.
const cache = new CellMeasurerCache({ fixedWidth: true, defaultHeight: height })
if (initRowsHeight) {
(cache as any)._rowHeightCache = { ...initRowsHeight }
(cache as any)._cellHeightCache = { ...initRowsHeight }
}
const rowsCacheHeight = useRef<Map<string, number>>(new Map())
const saveRowsHeight = ({ index }: ListRowProps) => {
rowsCacheHeight.current.set(`${index}-0`, memoizedCache.rowHeight({ index }))
}
// 캐시가 비어 새로 받아 오면 저장한 위치는 버린다
useEffect(() => { if (isLoading) setIsExpired(true) }, [isLoading])결과. 상세에서 돌아와도 같은 자리와 같은 행 높이로 보입니다. 한계도 있었습니다. 복원 값이 React Query 캐시와 맞물려 있어 캐시가 만료되면 복원하지 않고 일정 시간 뒤 초기화해야 했습니다. 레이아웃 변화로 생긴 이슈 10건의 원인과 해결은 react-virtualized 스크롤 복원에 정리했습니다.
서버 상태와 화면 사이 상태의 책임 나누기
문제. 메시지, 커뮤니티, 커머스가 한 서비스 안에서 이어져서 팔로우, 좋아요, 쿠폰 보유, 읽지 않은 메시지 수 같은 변경이 여러 화면에 동시에 보여야 했습니다. React Query의 query key를 하나씩 무효화하면 화면이 늘수록 어느 키가 어디에 묶였는지 알기 어려워집니다.
판단. 서버에서 다시 받아도 되는 목록과 상세는 React Query 캐시에 두고 화면 사이를 가로지르는 변경과 복귀 맥락은 Zustand slice와 라우팅 계층에 두었습니다.
구현. 피드, 팔로우, 쿠폰, 댓글, Sendbird 같은 도메인별 slice를 하나의 스토어로 합쳤습니다. 메시지의 읽지 않은 수처럼 여러 화면이 보는 값은 slice가 들고 Sendbird 이벤트가 그 값을 갱신합니다.
export const createSendbirdSlice: StateCreator<IStore, [], [], SendbirdSlice> = set => ({
unreadMessageCount: 0,
setUnreadMessageCount: (count: number) => {
set(produce(draft => { draft.sendbird.unreadMessageCount = count }))
},
})결과. 변경을 반영할 화면이 늘어도 무효화할 key를 찾아다니지 않았습니다. 같은 시기 Vortex DAO 작업에서 정리한 기준은 React Query와 Zustand에 있습니다.
Sendbird UIKit을 서비스 흐름 안으로 넣기
문제. 메시지는 UIKit을 쓰되 서비스의 다른 여정과 붙어야 했습니다. 대화 화면 머리에서 상대 프로필로 가고 신고와 나가기를 하며 팔로우 목록에서 바로 메시지를 시작하는 흐름입니다. 기본 UIKit 화면으로는 이 연결이 없습니다.
판단. UIKit의 Channel과 ChannelList를 쓰되 머리와 입력 영역을 서비스 컴포넌트로 바꾸고 신고와 나가기는 서비스의 바텀시트와 API로 처리했습니다.
구현. 대화 화면은 renderChannelHeader와 renderMessageInput으로 서비스 컴포넌트를 끼우고 목록 화면은 ChannelListProvider의 queries로 채널 조회 조건을 바꿨습니다. 나가기는 이전 메시지를 숨기고 자동 복구를 허용하는 hide로 구현했습니다.
const channelProps: ChannelProps = {
channelUrl,
renderChannelHeader: () => <CustomChannelHeader {...customChannelHeaderProps} sheet={sheet} />,
renderMessageInput: () => <CustomMessageInput sheet={sheet} />,
}
return <Channel {...channelProps} />
const customChannelHeaderProps: CustomChannelHeaderProps = {
onClickReport: () => reportUserHandler(MEMBER_REPORT),
onClickLeave: async () => { await hideChannel({ store, channelUrl }) },
}결과. 메시지가 별도 위젯이 아니라 프로필, 팔로우, 신고와 같은 흐름 안에 있습니다. 롱탭 UI와 닉네임과 시간 묶음 표기도 서비스 규칙에 맞췄습니다.
이미지 요청 크기를 화면 자리마다 고정하기
문제. 상품과 브랜드와 이벤트 이미지가 화면 자리마다 다른 크기로 쓰이는데 원본을 그대로 받으면 목록이 무겁고 자리마다 임의의 크기를 붙이면 캐시가 흩어집니다.
판단. CDN 리사이징 query를 자리별 상수로 선언하고 converter가 이미지 URL을 만들 때 붙이도록 했습니다. 화면은 크기를 모릅니다.
구현. 키 비주얼, 카테고리, 브랜드 카드, 이벤트 카드, 상품 목록의 네 단계 크기 같은 자리별 값을 하나의 열거형에 두고 converter에서 사용했습니다.
export enum CDN_IMAGE_RESIZE {
SHOP_KEY_VISUAL = '780x1080',
SHOP_CATEGORY = '120x120',
SHOP_BRAND_CARD = '684x360',
SHOP_PRODUCT_LIST_SM = '220x220',
SHOP_PRODUCT_LIST_LG = '330x330',
SHOP_PRODUCT_DETAIL_KEY_VISUAL = '780x1000',
// ...
}
// converter가 URL을 만들 때 붙인다
imageURL: `${product.imageUrl}?${CDN_IMAGE_RESIZE.SHOP_PRODUCT_LIST_SM}`결과. 자리마다 같은 크기를 요청해 CDN 캐시가 모이고 크기를 바꿀 때 상수 하나만 고칩니다.
운영 오류를 읽을 수 있게 만드는 Sentry 파이프라인
문제. Sentry SDK를 붙이는 것만으로는 운영 오류가 minify된 stack trace로 남아 원인을 찾기 어렵습니다. release 이름과 sourcemap이 빌드와 맞아야 하고 sourcemap 자체는 배포물에 남으면 안 됩니다.
판단. 빌드마다 환경, 패키지 버전, 빌드 번호를 합친 release 이름을 만들고 sourcemap을 Sentry에 올린 뒤 배포물에서는 지우는 순서를 빌드 스크립트에 고정했습니다.
구현. 빌드 스크립트가 환경별 env를 복사해 빌드하고 Sentry 업로드 스크립트를 돌린 뒤 .map 파일을 지웁니다. 업로드 스크립트는 inject, release 생성, 업로드, finalize 순서로 sentry-cli를 호출합니다.
const combinedVersion = `${env}_${packageJSON.version}_${buildVersion}`
execSync(`sentry-cli sourcemaps inject --org ${org} --project ${project} ./build`)
execSync(`sentry-cli releases new ${combinedVersion} --org ${org} --project ${project}`)
execSync(`sentry-cli sourcemaps upload --org ${org} --project ${project} ./build --release=${combinedVersion}`)
execSync(`sentry-cli releases finalize ${combinedVersion} --org ${org} --project ${project}`)"build-prd": "cpx ./envs/prd/.env . && react-scripts build && yarn sentry:sourcemaps && rm build/static/**/*.map"결과. 운영 오류가 원본 파일과 줄 번호로 보였고 배포물에는 sourcemap이 남지 않았습니다. 환경과 버전이 release 이름에 있어 어느 빌드의 오류인지 바로 구분됩니다.
결과와 근거
- 커머스 전 여정 화면을 구현했고 Shopby 응답 변환이 converter 한 곳에 모였습니다.
- 브랜드 탐색을 API 없이 카테고리 재귀로 만들었습니다.
- 팀 공통 내비게이션의 저장 API를 사용해 상세 복귀 때 상품 목록의 필터와 스크롤을 복원했습니다.
- 가상 스크롤과 복원 규칙을 공용 부품으로 만들어 상품, 브랜드, 이벤트, 리뷰, 팔로우 목록에 적용했습니다.
- Sentry release와 sourcemap 파이프라인으로 운영 오류를 읽을 수 있게 했습니다.
배운 것
- 서드파티 백엔드는 응답을 화면 모양으로 좁히는 층이 먼저입니다. 그 층이 없으면 분기가 화면마다 복제됩니다.
- 복귀 맥락은 뒤로가기, 필터, 스크롤, 캐시가 얽힌 하나의 문제입니다. 후반에 몰렸던 복원과 상태 유지 작업은 라우팅과 query string 계약을 더 이른 시점에 공통화했다면 줄었을 것입니다.
- 복원은 한계를 같이 말해야 합니다. 캐시가 만료되거나 목록 데이터가 달라지면 초기화하는 규칙까지 복원 조건에 포함해야 했습니다.
- 오류 추적은 SDK 설치가 아니라 release와 sourcemap이 맞는 파이프라인까지가 한 단위입니다.