Hence
Hence는 피드, 팔로우, 메시지 같은 커뮤니티 기능과 상품 탐색, 주문, 리뷰, 쿠폰 같은 커머스 기능을 한 앱에서 제공한 서비스였고 구축 뒤 지금은 종료됐습니다. React 웹이 Flutter 앱의 웹뷰 안에서 동작했고 커머스 백엔드는 화면 전용 API 계층(BFF)이 없는 백엔드 커머스 솔루션(Shopby)이었습니다. 저는 FE 6명 가운데 커머스 화면과 솔루션 응답의 조합을 맡았고 초기에는 Sendbird 기반 메시지 화면을 만들었습니다.
핵심 기여
| 과제 | 본인 기여 | 확인한 동작 |
|---|---|---|
| BFF 없는 솔루션 응답 | 여러 응답을 한 화면 모델로 조합하는 응답 변환 계층과 두 단계 변환 훅 | 상품 상세가 솔루션 응답 6종과 서비스 응답 1종을 하나의 화면 모델로 받음 |
| 솔루션에 없는 브랜드 | 운영 카테고리 트리의 라벨 접두어 규칙과 재귀 탐색 | 추천 브랜드, 브랜드 필터, 상품 상세의 브랜드 이동이 같은 규칙을 사용 |
| 주문과 쿠폰의 상태 표시 | 서버가 허용한 후속 동작과 쿠폰 발급 현황으로 버튼 결정 | 주문 상태별 고정 분기 대신 후속 동작 11종을 버튼으로 표시 |
| 상세 복귀와 긴 목록 | 리스트 가상화의 복원 조건과 화면별 캐시 정책 | 캐시가 남아 있을 때만 이전 위치와 행 높이로 복원 |
| 커뮤니티와 커머스 연결 | Sendbird 화면을 서비스 헤더, 신고, 프로필 흐름에 연결 | 프로필에서 시작한 대화가 같은 상대의 기존 1:1 채널로 이어짐 |
| 운영 오류 추적 | 앱과 업로드 스크립트가 같은 Sentry 릴리스 이름을 쓰는 파이프라인 | stg, prd 배포물에는 소스맵이 남지 않음 |
개요
| 항목 | 내용 |
|---|---|
| 기간 | 2023.06 ~ 2024.02 |
| 역할 | FE 6명 중 커머스 담당. 커머스 탐색과 구매 후 관리 화면, 솔루션 응답 조합, 초기 메시지 화면, 후반 리스트 가상화 복원과 Sentry |
| 협업과 인원 | FE 6명, 퍼블리셔 5명, BE 4명. Flutter 앱은 별도 |
| 서비스 | 커뮤니티(피드, 팔로우, 메시지)와 커머스(탐색, 상세, 주문, 리뷰, 쿠폰, 문의, 클레임, 마이쇼핑)가 결합된 사용자 서비스 |
| 기술 | React, TypeScript, React Router, React Query, Zustand, React Hook Form, Sendbird UIKit, react-virtualized, Swiper, SCSS와 CSS Module, Sentry, Shopby API, AWS S3와 CloudFront |
| 링크 | 서비스 종료로 공개 링크 없음 |
진행 단계
| 시기 | 주요 작업 |
|---|---|
| 2023.06 | Sendbird 초기 세팅, 메시지 목록과 대화와 생성 화면, 팔로우와 프로필 연동 |
| 2023.07 ~ 2023.08 | 응답 변환 훅, 커머스 메인 섹션, 카테고리와 검색, 상품 목록과 상세, 브랜드와 이벤트와 스페셜오더 |
| 2023.09 | 리뷰, 쿠폰, 주문과 배송, 상품문의, 클레임, 마이쇼핑, 이미지 리사이징 프리셋 |
| 2023.10 | Sentry 릴리스와 소스맵, 리뷰와 옵션 QA 대응 |
| 2024.01 | 커머스 목록의 리스트 가상화와 복원, 캐시 정책 조정, QA 대응 |
맡은 범위
본인 구현
- 커머스 화면: 상품 목록의 탭과 필터와 정렬, 브랜드와 이벤트와 스페셜오더 상세, 카테고리 페이지, 리뷰 작성과 수정과 신고, 쿠폰 다운로드와 보유 쿠폰, 주문 배송 내역과 클레임 내역, 상품문의.
- 솔루션 연동: 솔루션 엔드포인트 정의 78개 중 38개와 호출 함수 86개 중 39개(기준 커밋의 blame), 응답 변환 훅
useConvert와 커머스 도메인의 변환 함수. - 메시지: 채널 목록의 닉네임 검색, 대화 화면의 메시지 렌더링과 롱탭 UI, 신고와 나가기, 채널 생성과 숨김 함수.
- 안정화: CDN 리사이징 프리셋, Sentry 릴리스와 소스맵 스크립트, 커머스 목록의 복원 조건과 위치 보정.
협업
- 다단 옵션 선택 시트는 동료 성승연 님과 함께 만들었습니다. 성승연 님이 시트 구조와 옵션 라벨 흐름을 작성했고 저는 옵션 트리 변환, 단계별 선택, 재고와 수량 한도를 맡았습니다.
- 상품 상세와 커머스 메인은 다른 동료와 나눠 작성했습니다. 상세에서는 응답 조합과 옵션, 쿠폰, 관련 상품을 맡았습니다.
- 주문 버튼은 제가 서버의 후속 동작 기반으로 바꿨고 이후 노출 조건 필터와 정렬, 동작 처리 훅은 동료가 확장했습니다.
- 리스트 가상화의 목록과 그리드 래퍼는 동료가 먼저 만들었습니다. 저는 커머스 목록에 적용하면서 복원 값 주입, 복원 조건과 위치 보정을 더했습니다.
동료 구현
- 메모리 히스토리, 방문 기록 항목별 상태 저장 API, 네이티브 뒤로가기 처리 같은 공통 내비게이션 기반.
- Zustand 스토어와 도메인 슬라이스, React Query 기본 설정.
- 장바구니와 주문서, 결제, 클레임 신청 흐름과 브랜드 목록 페이지. 브랜드 목록 페이지는 제가 만든 카테고리 변환 함수를 사용했습니다.
문제와 해결
아래 코드는 기준 커밋(develop@300e1de1e)에서 제가 작성한 부분을 줄여 발췌한 것입니다. 줄 단위 작성자는 git blame으로 확인했고 내부 주소와 식별자는 뺐습니다.
화면 전용 API가 없는 솔루션 응답을 화면 모델로 조합하기
커머스 백엔드는 솔루션 API를 그대로 썼고 서비스 화면에 맞춘 조합 API가 없었습니다. 응답 하나에 필드가 수십에서 수백 개였고 화면 하나에 여러 API가 필요했습니다. 상품 상세만 해도 상품 기본 정보, 옵션, 발급 가능 쿠폰, 리뷰, 관련 상품, 좋아요 목록을 솔루션에서 받고 상품이 태그된 게시물은 서비스 백엔드에서 받아야 했습니다. 서비스 개념 중 일부는 솔루션에 전용 필드가 없어 운영 규칙으로 표현됐습니다. 주문제작은 상품 추가 항목의 이름으로, 스페셜오더는 카테고리 라벨로, 예약판매는 예약 종료 시각으로 판단해야 했습니다.
컴포넌트가 응답을 직접 읽으면 할인율 계산과 판매 유형 판정이 화면마다 복제되고 응답 구조가 바뀔 때 고칠 곳을 찾기 어렵습니다. 그래서 응답을 화면 모델로 바꾸는 함수와 화면 모델을 컴포넌트 속성으로 좁히는 함수를 분리하고 두 단계를 한 훅에서 실행했습니다. 커머스 메인을 시작한 2023년 7월에 이 훅(useConvert)을 만들었고 이후 피드, 프로필, 검색 화면에서도 같은 훅을 사용했습니다.
상품 상세는 응답 여섯 개와 서비스 응답 하나를 한 번에 받아 화면 모델을 만듭니다. 어느 응답이 늦게 오더라도 도착하는 즉시 화면 모델을 다시 계산하도록 의존 목록에 넣었습니다.
const { data: productDetailRes, isError, error } = useGetShopProductDetail({ id });
const { data: productOptionRes } = useGetShopProductOption({ id });
const { data: productCouponRes } = useGetShopIssuableProductCoupons({ id });
const { data: productReviewRes } = useGetShopProductReviewList({ id });
const { data: itemTagPostListRes } = useGetItemTagPostList({ id }); // 서비스 백엔드
const { data: relatedProductListRes } = useGetShopRelatedProductList({ id });
const { getShopProductDetailVM, getShopProductDetailVCP } = useShopProductConverter();
const { viewComponentProps: productDetailVCP = initialProductDetailVCP } = useConvert({
response: productDetailRes,
vmConverter: (productDetailRes): ShopProductDetailVM =>
getShopProductDetailVM({
product: productDetailRes,
coupons: productCouponRes,
options: productOptionRes,
reviews: productReviewRes,
relatedPosts: itemTagPostListRes,
relatedProducts: relatedProductListRes,
likeProducts: likeProductListRes,
}),
vcpConverter: getShopProductDetailVCP,
dependencies: [productCouponRes, productOptionRes, productReviewRes, itemTagPostListRes, relatedProductListRes, likeProductListRes],
});flowchart LR
P["상품 기본 정보"] --> VM["상세 화면 모델: 할인율, 판매 유형, 브랜드 번호, 옵션 트리, 쿠폰 소진"]
O["옵션"] --> VM
C["발급 가능 쿠폰"] --> VM
R["리뷰"] --> VM
RP["관련 상품과 좋아요 목록"] --> VM
T["서비스 백엔드: 상품 태그 게시물"] --> VM
VM --> VCP["컴포넌트 속성"]
VCP --> UI["상품 상세 화면"]상품 목록 변환은 무한 스크롤로 받은 페이지를 평탄화하고 할인가와 할인율, 좋아요 여부, 상태 스티커를 계산합니다. 베스트 목록의 순위는 처음에 페이지 안의 순서로 매겨 다음 페이지에서 1위부터 다시 시작했습니다. QA에서 이 문제가 나와 페이지를 가로지르는 순번으로 바꾸고 100위에서 자르도록 고쳤습니다.
구현 코드: 상품 목록 응답을 화면 모델로
const getShopSearchProductListVM = (response: InfiniteData<ShopSearchProductListRes>, likeProductListRes?: ShopLikeProductListRes, rank = false, sticker = true) => {
const productListRes = extractPageData(response);
let currentIndex = 0;
const searchProductList: ShopSearchProductVM[] = _.flatMap(productListRes, value => {
return _.map(value.items, (value, index) => {
const isReservation = dayjs(value.reservationData?.reservationEndYmdt).isAfter(dayjs());
currentIndex++;
return {
uid: _.toString(value.productNo),
brand: _.split(value.brandName, ' / ')[0],
price: value.salePrice - value.immediateDiscountAmt,
cost: value.salePrice,
rank: rank ? currentIndex : undefined,
discount: value.immediateDiscountAmt !== 0 ? floor((value.immediateDiscountAmt / value.salePrice) * 100) : undefined,
scrap: { status: !!_.find(likeProductListRes?.items, ({ productNo }) => productNo === value.productNo), count: value.likeCount },
status: sticker
? { soldOut: value.isSoldOut, reservation: isReservation, customOrder: _.some(value.customProperties, { propName: '주문제작' }), overseas: _.some(value.customProperties, { propName: '해외배송' }) }
: undefined,
// ...
} as ShopSearchProductVM;
});
});
// ...
return { /* ... */ products: rank ? _.slice(products, 0, 100) : products };
};같은 응답을 쓰는 화면이 늘어도 계산 분기는 변환 함수에 모였고 컴포넌트는 솔루션 필드 이름을 몰라도 됐습니다. 다만 비용이 따랐습니다. 응답이 하나 도착할 때마다 상세 화면 모델 전체를 다시 계산했고 화면용 변환을 작성하는 데 일정의 상당 부분이 들어갔습니다. API가 받쳐 주지 않는 UX 요구는 아래 사례들처럼 운영 규칙과 프론트엔드 계산으로 메워야 했습니다.
솔루션에 없는 브랜드를 운영 카테고리 규칙으로 만들기
서비스는 추천 브랜드 섹션, 브랜드 목록과 상세, 상품 목록의 브랜드 필터처럼 브랜드 단위 탐색을 요구했습니다. 솔루션은 이 요구를 받쳐 주는 브랜드 기능을 제공하지 않았습니다. 대신 운영자가 다단계 카테고리 트리를 직접 구성할 수 있었고 브랜드를 카테고리로 표현하기로 했습니다.
처음 7월 구성은 최상위 아래 스페셜 카테고리, 노말 카테고리, 라우팅 카테고리 같은 부모 노드로 묶는 방식이었고 이에 맞춰 트리를 세 묶음으로 나누는 함수를 만들었습니다. 8월 초 카테고리 구성이 라벨 접두어 규칙으로 바뀌었습니다. S-는 메인 섹션, R-은 이벤트와 스페셜오더로 가는 라우팅, N-은 일반 카테고리, B-는 브랜드입니다. 저는 같은 날 변환 함수를 접두어 기준으로 다시 작성했습니다. 메인 섹션은 라벨을 정확히 일치시켜 배열 순서대로 노출하고 브랜드와 일반 카테고리는 접두어를 포함하는 노드를 모읍니다. 화면에는 접두어를 뗀 이름을 보여 줍니다.
브랜드가 카테고리가 되면서 서로 다른 응답을 브랜드 이름으로 이어 붙여야 했습니다. 추천 브랜드 섹션은 섹션 응답의 설명 필드에 운영자가 적은 브랜드 이름 목록에서 출발합니다. 이름마다 B- 접두어를 붙여 카테고리 트리를 깊이에 상관없이 재귀로 찾고 찾은 카테고리 번호로 브랜드별 인기 상품 3개를 따로 조회했습니다. 카테고리 아이콘은 브랜드 이미지로 썼습니다. 상품 상세의 브랜드 이동은 상품 응답의 브랜드 이름과 접두어를 뗀 카테고리 라벨을 비교해 카테고리 번호를 얻었고 상품 목록의 브랜드 필터는 선택한 브랜드의 카테고리 번호를 검색 조건의 카테고리 목록에 더했습니다.
flowchart LR
Section["섹션 응답: 운영자가 적은 브랜드 이름"] --> Labels["B- 접두어 라벨"]
Labels --> Find["카테고리 트리 재귀 탐색"]
Tree["브랜드 카테고리 트리"] --> Find
Find --> Brand["브랜드 카테고리 번호와 아이콘"]
Brand --> Products["브랜드별 인기 상품 3개 조회"]
Brand --> Card["추천 브랜드 카드"]
Products -->|브랜드 이름으로 병합| Cardexport 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 [];
});
const getShopBrandCategoryInfoVM = (sectionRes: ShopSectionRes, categoryListRes: ShopCategoryRes | undefined) => {
// 섹션 설명의 "브랜드A,브랜드B"를 "B-브랜드A", "B-브랜드B" 라벨로 바꿔 찾는다
const categories: ShopMultiCategoryVM[] = filterCategoryByLabel(
categoryListRes?.multiLevelCategories || [],
_.map(_.split(sectionRes?.sectionExplain, ','), value => `B-${value}`)
);
const brandCategoryInfo: ShopBrandCategoryVM[] = map(categories, category => ({
uid: category.categoryNo,
title: _.split(category.label, '-')[1],
imageURL: category.icon,
}));
return brandCategoryInfo;
};구현 코드: 메인 섹션과 접두어 묶음
export const findChildrenByLabels = (children: ShopMultiCategory[], labels: string[], include: boolean): ShopMultiCategoryVM[] => {
if (include) {
// 'B-', 'N-'처럼 접두어를 포함하는 노드를 모두 모은다
return _.filter(children, child => _.some(labels, label => _.includes(child.label, label)));
}
// 라벨 배열의 순서가 곧 메인 노출 순서다
return _.reduce(labels, (previous, current) => {
const child = _.find(children, { label: current });
if (child) {
previous = _.concat(previous, child);
}
return previous;
}, [] as ShopMultiCategoryVM[]);
};
const getShopMainCategoryListVM = (response: ShopCategoryRes) => {
const labels = ['S-MD 추천', 'S-BEST', 'S-NEW', 'R-EVENT', 'R-SPECIAL ORDER'];
const children = response.multiLevelCategories[0].children;
return findChildrenByLabels(children || [], labels, false);
};운영자가 카테고리를 추가하면 코드 수정 없이 브랜드와 섹션이 화면에 나타났고 동료가 만든 브랜드 목록 페이지도 같은 변환 함수를 사용했습니다. 이 방식은 문자열 관례에 기대는 구조입니다. 운영자가 접두어를 빠뜨리면 화면에서 사라지고 표시 이름은 첫 하이픈 뒤만 잘라 쓰기 때문에 이름에 하이픈이 들어간 브랜드는 잘릴 수 있었습니다. 상품과 브랜드도 이름 비교로 이어 붙여서 두 곳의 표기가 다르면 연결이 끊겼습니다. 독립적인 브랜드 데이터를 만든 것이 아니라 솔루션 모델 안에서 브랜드를 해석한 계층이었습니다.
주문 버튼을 서버가 허용한 후속 동작으로 그리기
주문 배송 내역의 버튼은 처음에 주문 상태마다 고정 분기로 그렸습니다. 주문 접수와 결제 완료에는 취소 신청을, 배송 완료에는 반품과 교환과 배송 조회와 구매 확정을, 주문제작 상품이면 그중 일부를 빼는 식이었습니다. 그런데 실제 가능 여부는 주문 상태, 클레임 진행 상태, 상품 속성이 함께 정했고 같은 주문 상태에서도 가능한 동작이 달랐습니다. 주문 상태만으로는 클레임이 진행 중인 주문을 구분할 수도 없었습니다.
솔루션 주문 응답에는 그 주문 옵션에서 지금 가능한 동작 목록(nextActions)이 있었습니다. 화면이 상태 조합을 다시 추론하지 않고 서버가 허용한 동작만 그리도록 바꿨습니다. 응답 문서의 문자열 유니온을 후속 동작 15종과 클레임 상태 24종의 열거형으로 옮겨 타입으로 고정했고 버튼은 후속 동작 목록을 순회하며 11종을 그립니다. 상태 문구는 클레임 상태가 있으면 그것을 먼저 보여 주도록 했습니다.
// 변환 함수: 주문 옵션마다 클레임 상태와 후속 동작을 화면 모델에 싣는다
claimStatusTypeLabel: option?.claimStatusTypeLabel || '',
claimStatusType: option?.claimStatusType as CLAIM_STATUS_TYPE,
nextActions: option.nextActions || [],
// 주문 목록: 클레임 상태를 우선 표시하고 서버가 허용한 동작만 버튼으로 그린다
<div className={cx('product_status')}>
{product.claimStatusTypeLabel || product.orderStatusTypeLabel}
</div>
<div className={cx('product_button')}>
{_.map(product.nextActions, ({ nextActionType, actionGroupType }) => {
switch (nextActionType) {
case NEXT_ACTION_TYPE.CANCEL:
return <Button appear="gray" size="md" onClick={() => onClick?.(product)}>취소 신청</Button>;
case NEXT_ACTION_TYPE.EXCHANGE:
return <Button appear="gray" size="md" onClick={() => onClick?.(product)}>교환 신청</Button>;
// RETURN, VIEW_CLAIM, VIEW_DELIVERY, DELIVERY_DONE, CONFIRM_ORDER,
// CHANGE_ADDRESS, WRITE_REVIEW, ISSUE_CASH_RECEIPT, VIEW_RECEIPT
default:
return null;
}
})}
</div>주문 상태 조합을 화면에서 다시 계산하는 분기가 사라졌고 클레임이 진행 중인 주문에는 클레임 상태와 그에 맞는 버튼이 보였습니다. 다만 서버 목록을 그대로 그리면 서비스 정책과 어긋나는 경우가 남았습니다. 이후 동료가 교환 가능 여부, 배송 상태, 송장 주소 같은 조건으로 후속 동작을 거르고 버튼 순서를 정렬하는 단계와 동작 처리 훅을 더했습니다. 서버가 허용한 동작과 서비스가 노출할 동작은 서로 다른 층에서 정해야 했습니다.
쿠폰 소진과 정률 쿠폰의 최대 할인액 표시
쿠폰 카드에는 받기, 다운완료, 소진 세 상태가 있었습니다. 솔루션 응답은 이 상태를 직접 주지 않고 발급 조건(1인 발급 한도 여부와 수량, 1일 발급 한도)과 발급 현황(내 발급 수, 오늘 내 발급 수, 남은 발급 가능 수량)을 따로 줬습니다. 화면에서 이 값들로 소진을 판정해야 했습니다.
소진은 두 경우로 판정했습니다. 1인 한도가 설정돼 있고 내 발급 수가 한도에 도달한 경우와 남은 발급 가능 수량이 0인 경우입니다. 두 경우 모두 오늘 내가 받은 수가 0일 때만 소진으로 봅니다. 그래서 상품 상세의 쿠폰 시트에서 오늘 받은 쿠폰은 소진이 아니라 다운완료로 보입니다. 마이쿠폰의 다운로드 목록에는 1인 발급 한도가 설정된 쿠폰 중 지금 받을 수 있는 쿠폰만 남기고 발급 마감이 가까운 순으로 정렬했습니다.
const couponsVM: ShopEventCouponItemProps[] = _.chain(response)
.map(value => {
const { dateInfo, discountInfo, couponStatus, useConstraint, issueConstraint } = value;
if (issueConstraint?.issuePerPersonLimit) {
return {
downloadable: value.downloadable || false,
maxDiscountAmt: discountInfo?.maxDiscountAmt,
soldOut:
(couponStatus?.myIssuedCntToday === 0 &&
issueConstraint?.issuePerPersonLimitCnt !== 0 &&
issueConstraint?.issuePerPersonLimitCnt === couponStatus?.myIssuedCnt) ||
(couponStatus?.myIssuedCntToday === 0 && couponStatus.issuableCnt === 0),
useDays: value.useConstraint?.useEndYmdt
? dayjs(value.useConstraint?.useEndYmdt).format('YYYY-MM-DD HH:mm')
: _.toString(value.useConstraint?.useDays) || '0',
// ...
} as ShopEventCouponItemProps;
}
return undefined;
})
.compact()
.filter({ downloadable: true })
.sortBy(({ date }) => date?.end)
.value();할인 문구도 쿠폰 종류에 따라 달라야 했습니다. 정액 쿠폰은 최소 구매 금액을 보여 주고 정률 쿠폰은 할인율만으로는 실제 혜택을 알 수 없어서 응답의 최대 할인액을 함께 보여 줘야 했습니다. 처음 정률과 정액 표기를 반영할 때 저는 사용 조건의 최대 판매가를 최대 할인 문구에 썼고 이후 동료 성승연 님이 카드 문구 분기를 정리하면서 할인 정보의 최대 할인액으로 바로잡았습니다. 저는 그 값을 응답에서 꺼내 화면 모델에 싣는 변환을 맡았습니다. 사용 기한을 만들 때 분 자리에 월을 찍던 HH:MM 형식도 같은 시기에 HH:mm으로 고쳤습니다.
같은 소진 판정이 마이쿠폰 변환, 상품 상세 변환, 상품 상세의 쿠폰 시트 세 곳에 들어가 있어 규칙이 바뀌면 세 곳을 함께 고쳐야 했습니다. 모두 제가 작성한 코드였고 판정을 하나의 함수로 빼지 않은 것이 남은 한계입니다.
다단 옵션을 재귀로 변환하고 재고와 수량 한도 맞추기
이 사례는 동료 성승연 님과 함께 작업했습니다. 성승연 님이 옵션 선택 시트의 구조와 단계별 라벨 흐름을 만들었고 저는 옵션 데이터 변환, 단계 선택 규칙, 재고와 수량 한도를 맡았습니다.
솔루션의 다단 옵션은 색상, 사이즈처럼 단계가 상품마다 다른 트리였습니다. 재고, 옵션 번호, 추가 금액은 마지막 단계에만 있었고 예약판매 상품은 일반 재고 대신 예약 재고를 써야 했습니다. 단계 수를 가정하지 않도록 트리를 재귀로 변환해 마지막 단계에만 구매에 필요한 값을 실었습니다.
const convertOption = (options: ShopMultiLevelOption[]): MultiOptionProps[] => {
return options.map(option => {
const converted: MultiOptionProps = {
label: option.label || '',
value: option.value || '',
};
if (option.children) {
converted.children = convertOption(option.children);
} else {
converted.optionNo = option.optionNo;
converted.stockCnt = product.status.reservation ? option.reservationStockCnt : option.stockCnt;
converted.addPrice = option.addPrice;
converted.salePrice = option.buyPrice;
}
return converted;
});
};시트에서는 n번째 단계를 다시 고르면 그 뒤 단계의 선택을 버리고 마지막 단계까지 골랐을 때만 선택 결과를 만듭니다. 선택 결과의 최대 수량은 구매 한도가 0이면 재고까지, 한도가 있으면 한도와 재고 중 작은 값입니다. 최소 구매 수량이 재고보다 크면 재고만큼만 담도록 했습니다.
// n번째 단계를 고르면 앞 단계만 남기고 새 선택을 붙인다
setSelectOption(prev => {
return _.chain(prev).splice(0, index).concat([{ ...item }]).value();
});
// 중간 단계에는 재고 값이 없으므로 품절로 막지 않는다
stockCnt={item.stockCnt ?? Number.MAX_SAFE_INTEGER}
// 선택 결과의 최대 수량
maxCnt={item.maxCnt === 0 ? item.stockCnt : Math.min(_.toNumber(item.maxCnt), _.toNumber(item.stockCnt))}품절 옵션이 비활성화되지 않는 QA 이슈도 이 구조에서 원인이 보였습니다. 옵션 재고를 item.stockCnt || product?.stockCnt로 넘기고 있어서 재고 0이 거짓으로 취급돼 상품 전체 재고로 바뀌었습니다. 옵션 재고만 쓰고 값이 없는 중간 단계만 ??로 구분하도록 고쳤습니다. 단일 옵션, 1단 옵션, 다단 옵션의 분기와 구매자 작성형 입력도 같은 시트에서 처리했고 옵션명 표시와 추가 금액 계산 수정은 2024년 1월 QA까지 이어졌습니다.
상세는 캐시 없이, 목록은 이전 데이터를 유지하며 조회하기
팀의 React Query 기본 설정은 데이터를 3분 동안 신선하다고 보는 것이었습니다. 상품 상세는 재고, 옵션별 재고, 가격, 쿠폰 소진 같은 값을 담고 있어 3분 전 값을 다시 보여 주면 구매 직전 판단이 어긋날 수 있었습니다. 반대로 목록은 필터를 바꿀 때마다 빈 화면이 깜박이는 쪽이 더 문제였습니다.
상품 상세와 리뷰 상세 조회는 신선 시간과 캐시 보관 시간을 모두 0으로 두어 진입할 때마다 새로 받게 했습니다. 상품 목록, 브랜드 상세, 필터 시트의 개수 미리보기는 조건이 바뀌는 동안 이전 결과를 유지하게 했습니다. 가격 범위를 움직일 때마다 상품 개수가 중간중간 0으로 보이던 QA 이슈가 이 설정으로 해결됐습니다. 추천 브랜드 섹션의 브랜드별 상품 조회도 신선 시간을 0으로 두었습니다.
// 상품 상세: 재고와 가격이 들어 있어 매 진입마다 새로 받는다
export const useGetShopProductDetail = ({ id, options }: UseQueryProps<ShopProductDetailRes> = {}) => {
return useQuery({
queryKey: ['useGetShopProductDetail', id],
queryFn: () => fetcher.shop.products.getProduct(_.toString(id)),
staleTime: 0,
cacheTime: 0,
...options,
});
};
// 상품 목록과 필터 시트: 새 조건의 결과가 올 때까지 이전 결과를 보여 준다
useGetShopProductList({ queryParams: memoizedQueryParams, options: { keepPreviousData: true } });
useGetShopProductList({ options: { staleTime: 0, keepPreviousData: true }, queryParams: memoizedQueryParams });이 구분은 이후 목록 복원과도 이어졌습니다. 목록 캐시가 남아 있는지가 곧 이전 위치를 다시 쓸지 정하는 기준이 됐습니다. 상세는 같은 상품에 다시 들어와도 초기값으로 그린 뒤 응답을 기다리는 비용을 감수했습니다.
조회 조건과 복귀 상태를 어디에 둘지 정하기
상품 목록, 검색, 브랜드 상세, 마이쇼핑에는 탭, 정렬, 가격 범위, 브랜드와 색상 필터, 날짜 범위, 주문 상태 같은 조건이 많았습니다. 서비스 필터 화면의 모양과 솔루션 상품 검색 요청의 모양도 달랐습니다. 색상은 상품 추가 항목 번호로, 브랜드는 카테고리 번호로, 가격 정렬은 할인가 기준 정렬과 방향으로 바꿔 보내야 했습니다.
조건은 두 계약으로 나눠 타입을 선언했습니다. 화면 URL query의 모양은 라우트별 타입으로, 솔루션 요청 조건은 솔루션의 정렬 기준과 판매 상태 열거형을 그대로 쓴 타입으로 두었습니다. 라우팅 훅 useRouter가 URL query를 화면별 타입으로 읽고 getNavigatePath가 직렬화합니다. 상품 목록은 화면 상태를 솔루션 상품 검색 요청으로 한곳에서 조합했습니다.
const memoizedQueryParams: QueryParams['shopProductList'] = useMemo(() => {
const categories = _.concat(
childTab || parentTab,
urlParams.title === 'BEST' ? undefined : productMenu.id,
memoizedBrandFilter // 브랜드도 카테고리 번호다
);
return {
pageSize: 30,
'filter.discountedPrices': generateIdListWithComma(
_.concat(_.toString(filterItems.price.value[0]), _.toString(filterItems.price.value[1]))
),
'filter.discountedComparison': FILTER_DISCOUNTED_COMPARISON.BETWEEN,
'filter.customProperties.propNos': _.join(memoizedColorFilter, ' '),
'filter.soldout': !filterItems.productInfo[2].checked,
'order.by':
sort.id === FILTER_SORT_TYPE.LOW_PRICE || sort.id === FILTER_SORT_TYPE.HIGH_PRICE
? ORDER_BY.DISCOUNTED_PRICE
: sort.id,
'order.direction': sort.id === FILTER_SORT_TYPE.LOW_PRICE ? ORDER_DIRECTION.ASC : ORDER_DIRECTION.DESC,
categoryNos: generateIdListWithComma(categories),
// ...
};
}, [sort, parentTab, childTab, memoizedBrandFilter, filterItems, productMenu]);복귀 상태의 저장 위치는 화면마다 달랐습니다. 주문 배송 내역은 주문 상태 필터와 날짜 범위를 URL query에 replace로 남겨 주문 상세에서 돌아와도 조건이 유지됐습니다. 상품 목록은 탭, 정렬, 필터 항목 목록을 통째로 저장해야 해서 팀 공통 내비게이션의 방문 기록 항목 상태에 두었습니다. 메모리 히스토리와 항목별 저장 API, 네이티브 뒤로가기 처리는 팀이 만든 기반이고 저는 기존 저장과 조회 API로 상품 목록과 브랜드 상세의 복귀 상태를 연결했습니다. 화면을 떠날 때 조건과 표시 상태, 불러온 항목 수, 가상 목록 위치와 측정 높이를 저장하고 돌아오면 같은 항목의 값을 읽습니다. 사용자가 돌아온 뒤 조건을 바꾸면 그때부터는 저장값을 쓰지 않습니다.
flowchart LR
Shared["팀 공통 기반: 메모리 히스토리와 항목별 저장 API"] --> List["상품 목록: 탭, 정렬, 필터, 항목 수, 위치, 측정 높이"]
List -->|떠날 때 저장| Detail["상품 상세"]
Detail -->|뒤로가기| Restore["같은 방문 기록 항목의 상태 조회"]
Restore --> List
Order["주문 배송 내역"] -->|상태 필터와 날짜를 URL query에 replace| Order목록과 상세를 오가도 조건이 유지됐지만 저장 위치가 화면마다 달라 복원 규칙도 화면마다 따로 작성했습니다. 후반에 몰린 복원과 상태 유지 작업은 조건의 저장 위치를 초기에 한 가지 원칙으로 정했다면 줄었을 것입니다.
리스트 가상화 목록을 상세 복귀 때 같은 자리로 되돌리기
앱 안에서 긴 목록을 스크롤로 훑는 사용 패턴이라 상품, 브랜드 상세, 이벤트, 스페셜오더, 리뷰 목록에 리스트 가상화가 필요했습니다. 화면에 보이는 행만 그리기 때문에 돌아왔을 때 같은 위치를 보여 주려면 스크롤 좌표만으로는 부족했습니다. 이미지가 있는 행은 측정 전 기본 높이와 측정 후 높이가 달라서 같은 좌표를 넣어도 다른 상품이 보였습니다. 2024년 1월 QA에서는 상세에 다녀오면 목록이 처음으로 돌아가거나 위치가 어긋나는 이슈가 반복됐습니다.
목록과 그리드 래퍼는 동료가 먼저 만들었고 측정 높이를 저장해 다시 넣는 방식도 피드 화면에서 먼저 쓰였습니다. 저는 이를 커머스 목록에 적용하면서 언제 복원하면 안 되는지를 정했습니다. 첫째, 저장한 항목 수만큼 데이터가 찬 뒤에만 목록을 그립니다. 둘째, 이번 진입에서 캐시 없이 처음부터 불러왔거나 사용자가 조건을 바꿨으면 위치를 0으로 둡니다. 셋째, 저장한 행 높이와 셀 높이를 측정 캐시에 넣고 이미지가 로드되면 다시 측정합니다.
// 상품 목록 화면: 이번 진입에서 한 번이라도 처음부터 불러왔다면 복원하지 않는다
useEffect(() => {
if (isLoading) {
loadingStatusRef.current = isLoading;
}
}, [isLoading]);
// 목록 컴포넌트: 저장한 항목 수만큼 찬 뒤에 그리고 조건이 맞을 때만 위치를 넘긴다
{itemState &&
itemState.length >=
(!loadedStatus && !isExpired ? discoveryHistoryData?.addedData?.itemLength || 0 : 0) && (
<GridVirtualScrollWrapper
data={itemState}
cache={cache}
scrollTop={!loadedStatus && !isExpired ? scrollY : 0}
virtualScrollTop={!loadedStatus && !isExpired ? discoveryHistoryData?.addedData?.virtualScrollTop : 0}
// ...
/>
)}두 번째 조건은 하루 만에 기준을 바꿨습니다. 처음에는 조회 데이터의 갱신 시각이 3분을 넘었는지 비교하는 훅을 만들었습니다. 하지만 이 시계는 React Query가 실제로 캐시를 들고 있는지와 따로 움직였습니다. 다음 날 이번 진입에서 로딩 상태를 한 번이라도 거쳤는지로 판단을 바꿔 복원 여부가 실제 캐시 상태와 같은 기준을 따르게 했습니다. 컴포넌트 속성 이름은 isExpired로 남았지만 값은 이 로딩 플래그입니다.
위치를 적용한 뒤에도 일부 기기에서는 레이아웃이 늦게 잡혀 위치가 어긋났습니다. 아이폰 13 미니에서 상품 상세 뒤로가기 후 스크롤 위치가 틀어지는 이슈가 대표적이었습니다. 위치를 한 번 적용하고 한 번 확인하던 방식을 50ms마다 200ms 동안 확인하고 목표와 5px 넘게 차이 나면 다시 적용한 뒤 강제 위치를 풀어 사용자 스크롤에 넘기도록 바꿨습니다.
useEffect(() => {
let ms = 0;
const intervalId = setInterval(() => {
if (ms <= INTERVAL_TIME.TWO_HUNDRED_MS) {
checkAndSetScrollTop();
ms += INTERVAL_TIME.FIFTY_MS;
} else {
clearInterval(intervalId);
setForceScrollTop(undefined); // 강제 위치를 풀어 사용자 스크롤에 넘긴다
}
}, INTERVAL_TIME.FIFTY_MS);
}, []);
const checkAndSetScrollTop = () => {
if (virtualScrollTop) {
const calculation = Math.floor(scrollRef.current - virtualScrollTop);
const isRecalculation = Math.abs(calculation) > THRESHOLD.SCROLL;
if (isRecalculation) setForceScrollTop(virtualScrollTop);
} else if (scrollTop) {
const top = Math.floor(scrollTop);
if (scrollEl) setTimeout(() => scrollEl.scrollTo({ top }), 10);
else setTimeout(() => window.scrollTo({ top }), 10);
}
};flowchart TD
Back["상세에서 목록으로 복귀"] --> Changed{"돌아온 뒤 조건을 바꿨나"}
Changed -->|예| Top["처음 위치로 표시"]
Changed -->|아니요| Loaded{"이번 진입에서 캐시 없이 불러왔나"}
Loaded -->|예| Top
Loaded -->|아니요| Length{"저장한 항목 수만큼 데이터가 있나"}
Length -->|아니요| Hold["목록을 그리지 않고 대기"]
Length -->|예| Apply["측정 높이 주입 후 저장 위치 적용"]
Apply --> Check["200ms 동안 50ms마다 오차 확인"]
Check --> Release["강제 위치 해제"]캐시가 남아 있으면 상세에서 돌아와도 같은 자리와 같은 행 높이로 보였고 캐시가 사라졌으면 처음부터 보여 줬습니다. 다만 우회책에 기댄 부분이 남았습니다. 측정 높이 주입은 라이브러리의 공개 계약이 아닌 내부 필드를 직접 바꾸는 우회책이었고 짧은 주기의 재적용은 시간에 기대는 보정이었습니다. 항목 수가 같아도 다른 항목일 수 있다는 점도 확인하지 않았습니다. 당시 방법과 지금 다시 쓸 기준은 react-virtualized 스크롤 복원에 구분해 정리했습니다.
Sendbird UIKit을 서비스 흐름 안으로 넣기
메시지는 UIKit을 쓰되 서비스의 다른 여정과 이어져야 했습니다. 다른 사용자 프로필에서 바로 대화를 시작하고 대화 화면 머리에서 신고와 나가기를 하며 목록에서는 상대 닉네임으로 채널을 찾는 흐름입니다. 기본 UIKit 화면에는 이 연결이 없었습니다.
UIKit의 채널 목록과 대화 컴포넌트는 그대로 쓰고 렌더링 지점만 서비스 컴포넌트로 바꿨습니다. 채널 목록은 조회 조건에 닉네임 검색을 넣었고 대화 화면은 메시지 렌더링을 서비스 메시지 컴포넌트로 바꿔 롱탭 UI와 닉네임과 시간 묶음 표기를 서비스 규칙에 맞췄습니다. 머리와 입력 영역의 마크업과 키보드 처리는 동료들과 나눠 작성했습니다. 채널 생성과 숨김은 공용 함수로 두어 프로필과 새 메시지 화면이 같은 함수를 쓰게 했습니다.
판단이 필요했던 지점은 같은 상대와의 채널을 몇 개 둘지와 나가기를 어떻게 처리할지였습니다. 같은 상대와 대화를 시작할 때마다 새 채널이 생기면 대화가 흩어지므로 1:1 채널을 고유 채널로 만들어 기존 채널을 다시 쓰게 했습니다. 나가기는 채널을 떠나는 대신 숨김으로 처리했습니다. 이전 메시지는 숨기고 상대가 새 메시지를 보내면 채널이 다시 나타나도록 했습니다.
export const createChannel = ({ store, user, params }: { store: SendBirdState; user: { id: string; nickname: string }; params?: GroupChannelCreateParams }): Promise<GroupChannel> => {
const defaultParams: GroupChannelCreateParams = {
isDistinct: true, // 같은 상대와는 기존 1:1 채널을 다시 쓴다
invitedUserIds: [user.id],
data: user.nickname,
};
const createGroupChannel = sendbirdSelectors.getCreateGroupChannel(store);
return createGroupChannel({ ...defaultParams, ...params });
};
export const hideChannel = async ({ store, channelUrl }: { store: SendBirdState; channelUrl: string }) => {
const params: GroupChannelHideParams = {
hidePreviousMessages: true, // 나간 뒤에는 이전 대화를 보이지 않는다
allowAutoUnhide: true, // 새 메시지가 오면 목록에 다시 나타난다
};
try {
const channel = await getChannel({ store, channelUrl });
await channel.hide(params);
} catch (e) {
console.error('hideChannel error=> ', e);
}
};메시지가 별도 위젯이 아니라 프로필, 팔로우, 신고와 같은 흐름 안에 들어왔습니다. 메시징 서버와 SDK 자체를 만든 것은 아니고 UIKit의 확장 지점에 서비스 흐름을 연결한 작업입니다. 읽지 않은 메시지 수를 스토어에 반영하는 이벤트 처리는 동료가 구현했습니다.
이미지 요청 크기를 화면 자리마다 고정하기
상품, 리뷰, 배너, 브랜드, 이벤트 이미지는 화면 자리마다 다른 크기로 쓰였습니다. 원본을 그대로 받으면 긴 목록이 무거워지고 자리마다 임의의 크기를 붙이면 같은 이미지라도 요청 URL이 흩어집니다.
이미지 URL 뒤에 크기 query를 붙이는 CDN 리사이징을 자리별 프리셋 열거형으로 묶었습니다. 2023년 9월 말에 열거형을 만들고 10월 초까지 상품 카드의 크기 단계, 리뷰 사진, 메인 배너, 브랜드와 이벤트와 스페셜오더 카드에 적용했습니다. URL은 응답 변환 계층이 만들 때 붙이므로 컴포넌트는 픽셀 값을 모릅니다. 피드와 상품 태그 화면에는 동료들이 같은 열거형을 적용했습니다.
export enum CDN_IMAGE_RESIZE {
SHOP_KEY_VISUAL = '390x540',
SHOP_CATEGORY = '60x60',
SHOP_BRAND_CARD = '342x180',
SHOP_EVENT_CARD = '342x180',
SHOP_PRODUCT_LIST_XS = '64x64',
SHOP_PRODUCT_LIST_SM = '110x110',
SHOP_PRODUCT_LIST_MD = '144x144',
SHOP_PRODUCT_LIST_LG = '165x165',
SHOP_PRODUCT_DETAIL = '390',
SHOP_PHOTO_LIST = '110x110',
// ...
}
// 응답 변환 계층이 이미지 URL을 만들 때 자리의 프리셋을 붙인다
imageURL: `${banner.imageUrl}?${CDN_IMAGE_RESIZE.SHOP_KEY_VISUAL}`,
imageURL: `${imageURL}?${CDN_IMAGE_RESIZE.SHOP_BRAND_CARD}`,
imageURL: `${image}?${CDN_IMAGE_RESIZE.SHOP_PHOTO_LIST}`,크기가 한곳에 모여 있던 덕분에 10월에 동료가 이미지 해상도를 두 배로 올릴 때 상수 파일의 프리셋 값만 고쳤습니다. 전송량이나 로딩 시간의 변화는 측정하지 않았습니다.
운영 오류를 원본 코드 위치로 읽게 만드는 Sentry 파이프라인
Sentry SDK를 붙이는 것만으로는 운영 오류가 압축된 코드 위치로 남아 원인을 찾기 어렵습니다. 앱이 보고하는 릴리스 이름과 소스맵을 올린 릴리스 이름이 같아야 원본 파일과 줄 번호로 보이고 소스맵 파일은 배포물에 남으면 안 됩니다. 9월 말 처음 붙일 때는 앱과 업로드 명령 모두 고정 문자열 릴리스를 썼습니다.
릴리스 이름을 환경 이름, package.json 버전, 환경별 버전 값으로 만들고 앱 초기화와 업로드 스크립트가 같은 계산식을 쓰게 했습니다. 빌드 스크립트는 환경별 env를 복사해 빌드하고 업로드 스크립트로 소스맵 주입, 릴리스 생성, 업로드, 완료를 차례로 실행합니다. stg와 prd 빌드는 그 뒤 .map 파일을 지웁니다. dev 빌드는 이후 동료가 소스맵을 남기도록 바꿨습니다.
const env = process.env.REACT_APP_ENV;
const version = process.env.REACT_APP_VERSION;
const releaseVersion = packageJSON.version;
const combinedVersion = `${env}_${releaseVersion}_${version}`;
try {
execSync(`sentry-cli sourcemaps inject --org ${sentryOrg} --project ${sentryProject} ./build`);
execSync(`sentry-cli releases new ${combinedVersion} --org ${sentryOrg} --project ${sentryProject}`);
execSync(`sentry-cli sourcemaps upload --org ${sentryOrg} --project ${sentryProject} ./build --release=${combinedVersion}`);
execSync(`sentry-cli releases finalize ${combinedVersion} --org ${sentryOrg} --project ${sentryProject}`);
} catch (error) {
console.error('Error uploading Sentry sourcemaps:', error);
process.exit(1);
}"build-stg": "cpx ./envs/stg/.env . && react-scripts build && yarn sentry:sourcemaps && rm build/static/**/*.map",
"build-prd": "cpx ./envs/prd/.env . && react-scripts build && yarn sentry:sourcemaps && rm build/static/**/*.map"flowchart LR
Env["환경 이름, package.json 버전, 환경별 버전 값"] --> Name["릴리스 이름"]
Name --> SDK["앱의 Sentry 초기화"]
Name --> Script["빌드 뒤 업로드 스크립트"]
Script --> Upload["소스맵 주입, 릴리스 생성, 업로드, 완료"]
Upload --> Remove["stg, prd 배포물에서 .map 삭제"]수집 범위도 두 번 조정했습니다. 10월 10일 앱 초기화가 같은 릴리스 이름과 환경 이름을 쓰게 바꾸면서 세션 리플레이를 붙였습니다. 일반 세션은 10%, 오류가 난 세션은 100%를 기록하는 설정이었습니다. 2주 뒤에는 리플레이를 다시 빼고 성능 추적 샘플링을 dev 외 환경에서 100%에서 10%로 낮췄고 앱 최상위에 오류 경계를 두었습니다. 다음 날에는 원인을 좁히기 어려웠던 두 지점에 브레드크럼을 남겼습니다. 상품 옵션을 끝까지 골랐는데 결과가 없는 경우와 피드 수정에서 이미지 주소가 없는 경우입니다.
const combinedVersion = `${env}_${releaseVersion}_${version}`;
Sentry.init({
dsn: REACT_APP_SENTRY_DSN,
release: combinedVersion,
environment: env,
integrations: [new Sentry.BrowserTracing()],
tracesSampleRate: env === 'DEV' ? 1.0 : 0.1,
});운영 오류가 원본 파일과 줄 번호로 보였고 stg와 prd 배포물에는 소스맵이 남지 않았습니다. 환경과 버전이 릴리스 이름에 있어 어느 빌드의 오류인지 바로 구분됐습니다. 오류 건수나 해결 시간의 변화는 측정하지 않았습니다.
결과와 근거
- 솔루션 응답을 조합하는 변환 훅과 커머스 변환 함수에 계산 분기가 모였고 상품 상세는 응답 일곱 개를 하나의 화면 모델로 받았습니다.
- 브랜드를 운영 카테고리의 라벨 접두어 규칙과 재귀 탐색으로 만들었고 추천 브랜드, 브랜드 필터, 상품 상세의 브랜드 이동이 같은 규칙을 썼습니다.
- 주문 버튼이 서버가 허용한 후속 동작을 따르게 됐고 쿠폰 소진과 정률 쿠폰의 최대 할인액이 화면 모델에서 정해졌습니다.
- 커머스 목록은 캐시가 남아 있을 때만 이전 위치와 행 높이로 복원됐고 기기별 위치 오차를 짧은 주기로 보정했습니다.
- Sentry 릴리스 이름을 앱과 업로드가 함께 쓰고 stg, prd 배포물에서 소스맵을 지웠습니다.
| 내용 | 근거 |
|---|---|
| 응답 조합과 두 단계 변환 | src/hooks/useConvert.ts, src/hooks/converter/useShopProductConverter.ts, src/pages/ShopProductDetail/index.tsx, 커밋 31962ad1, 026ac1ae |
| 카테고리 규칙과 브랜드 | src/utils/dataLogic.ts, src/hooks/converter/useShopCategoryConverter.ts, src/components/molecules/ShopSection/BrandItem/BrandItem.tsx, 커밋 3fc04c6f |
| 주문 후속 동작 | src/components/molecules/MyOrderProductList/MyOrderProductList.tsx, src/types/shopResponse.ts, 커밋 4adfd010 |
| 쿠폰 소진 판정 | src/hooks/converter/useShopCouponConverter.ts, 커밋 47c8497d, ef5a3d38, ad08cce5 |
| 옵션 트리와 수량 한도 | useShopProductConverter.ts의 convertOption, BottomSheetOption.tsx, 커밋 631a67db, ab5a94eb, 0e01a950 |
| 상세와 목록의 캐시 정책 | src/services/hooks/shop/useShopProduct.ts, 커밋 076a2153, 07633c91 |
| 목록 복귀 조건과 위치 보정 | MyProductList.tsx, ShopProductList/index.tsx, ShopBrandDetail/index.tsx, ListVirtualScrollWrapper.tsx, 커밋 68e28222, 11db95e4, 900b1057 |
| 메시지 연동 | src/utils/sendbirdFunction.ts, src/pages/Message/index.tsx, src/pages/MessageList/index.tsx |
| 리사이징 프리셋 | src/commons/constants.ts, 커밋 e3a5ba58 |
| Sentry 파이프라인 | src/sentry.js, src/index.tsx, package.json, 커밋 2e535af7, 53bbfbb6, 60a1a9f3 |
근거는 기준 커밋의 코드와 커밋 기록, 줄 단위 작성자 확인입니다. 기준 커밋에는 테스트 코드가 없었고 동작은 QA 이슈 대응으로 확인했습니다. 구매 전환, 렌더링 성능, 이미지 전송량, 오류 감소량은 측정하지 않았습니다.
배운 것
- 솔루션의 필드 구조와 화면의 표시 규칙은 다른 속도로 바뀝니다. Hence에서는 응답 변환 계층이 솔루션의 제약을 흡수했고 이후 SK AX GAS Position Play에서는 명세에서 생성한 타입과 클라이언트를 생성 디렉터리에 두고 화면 모델은 응답 변환 계층에서 만들어 두 변경 지점을 처음부터 나눴습니다.
- Hence에서는 조건의 저장 위치가 화면마다 달라 복원 규칙도 화면마다 작성했습니다. 이어진 Starter Kit 예제에서는 URL 조회 조건을 이미지 API와 응답 변환 계층에 넘기고 복원은 라우트의 location key 기준으로 묶었습니다. SK AX GAS Position Play에서는 URL 파서와 직렬화 함수가 같은 정의를 쓰게 하고 화면 임시 상태를 URL 조회 조건과 분리했습니다.
- 복원 여부는 별도 시계가 아니라 데이터가 실제로 남아 있는지와 같은 기준이어야 했습니다. 3분 타이머를 하루 만에 로딩 상태 기준으로 바꾼 경험을 바탕으로 이후 복원 글에서는 항목 수 대신 조회 조건과 항목 id, 순서까지 비교하고 실패와 만료에서는 대기를 끝내는 기준을 정리했습니다.