Vortex DAO
디지털 자산과 커뮤니티 기능을 결합한 사용자 서비스와 그 운영 백오피스의 프론트엔드를 맡았습니다. 2023년 상반기에는 Ant Design 기반 운영 백오피스의 관리 화면을 만들었고 하반기에 사용자 서비스 프론트엔드 개발자가 떠나면서 미션, 채널 피드, 이미지 편집까지 이어받았습니다. 이미지 처리, 목록 상태, 미션 규칙처럼 두 앱에 걸친 문제를 양쪽 코드에서 같은 기준으로 맞춘 일이 이 프로젝트의 중심입니다.
핵심 기여
| 과제 | 본인 기여 | 근거 |
|---|---|---|
| 썸네일 편집 중 브라우저 멈춤 | 원본 전체를 그리던 크롭을 크롭 영역만 출력 크기 캔버스에 그리는 방식으로 바꾸고 원본은 객체 URL로 넘김 | 사용자 서비스 155100bd, b5306824, 8461e4f1 |
| 홈 피드 노출 운영 | 게시물, 채널, 이벤트, 랭킹 테마를 themeType을 가진 Theme 모델 하나와 공통 생성, 수정, 삭제, 순서 변경 훅으로 정리 | 운영 백오피스 useTheme.ts, ThemeManagementModal.tsx |
| 운영 목록의 조회 조건 | 검색어, 필터, 기간, 페이지를 URL query에만 두고 0부터 세는 서버 페이지와 1부터 세는 표 페이지를 한 곳에서 변환 | useRouter.ts, useTableProps.ts, useDate.ts |
| 긴 목록의 복귀 맥락 | 가상 스크롤 컨테이너와 브라우저 history 항목별 캐시로 행 높이와 스크롤 위치를 복원 | VirtualScrollContainer.tsx, cacheSlice.ts |
| 두 앱에 걸친 미션 규칙 | 운영 백오피스의 미션 유형별 수정 규칙과 사용자 서비스의 UTC 출석 시각, 지갑 미연동 보상 예외 | MissionEditModal.tsx, useMissions.ts, useMissionConverter.ts |
| 일정 안의 운영 화면 | Ant Design의 표, 폼, 모달을 그대로 쓰고 URL 상태, 크롭 업로드, 통계 변환을 공통 훅으로 얹음 | useTableProps.ts, upload.ts, usePageData.ts |
개요
| 항목 | 내용 |
|---|---|
| 기간 | 2022.12 ~ 2024.01 |
| 역할 | 운영 백오피스 프론트엔드 개발, 2023.10부터 사용자 서비스 프론트엔드 인수 |
| 협업과 인원 | 사용자 서비스 FE 2명, 운영 백오피스 FE 2명, BE 5명. 처음에는 운영 백오피스를 맡았고 사용자 서비스 FE 1명이 떠난 뒤 사용자 서비스까지 맡았습니다 |
| 서비스 | 채널, 홈 피드, 미션과 업적, 디지털 자산 화면을 제공하는 사용자 서비스와 공지, 채널, 광고, 통계, 미션, 피드와 테마를 관리하는 운영 백오피스 |
| 사용자 서비스 기술 | Next.js, React, TypeScript, React Query, Zustand, next-translate, react-virtualized, react-easy-crop, Canvas, SCSS |
| 운영 백오피스 기술 | React, TypeScript, React Router, React Query, Zustand, Ant Design, react-easy-crop, xlsx, SCSS |
| 링크 | vortexgaming.io |
진행 단계
작업은 두 저장소에서 본인 커밋이 몰린 시기를 기준으로 다섯 단계였습니다.
| 단계 | 시기 | 주요 작업 |
|---|---|---|
| 사용자 서비스 초기 기반 | 2023.01 | 다국어 프로토타입(도메인 방식과 sub-path 방식), next-i18next에서 next-translate로 전환, context menu 리서치, husky와 pre-commit, ESLint 규칙, SCSS 폴더 구조 |
| 운영 백오피스 구축 | 2023.01 ~ 2023.04 | 공지, 채널, 광고, 인기 게시물과 인기 채널, 노출 기준, 직원 게시판, 활동 로그, 통계 화면, 썸네일 크롭과 업로드 |
| 미션 운영과 사용자 미션 | 2023.10 | 운영 백오피스의 미션 관리, 미션 통계, 엑셀 다운로드, 대시보드 필터. 사용자 서비스의 미션 팝업, 출석, 업적, SBT 보상 |
| 사용자 서비스 인수 | 2023.10 ~ 2024.01 | 썸네일 편집 멈춤과 프로필 화질 수정, 가상 스크롤과 복귀 캐시, 채널 피드, 채널 이벤트, 채널 검색, 해시태그 검색 |
| 피드와 테마 관리 재편 | 2023.12 ~ 2024.01 | 네 종류 테마 관리, 테마 아이템 등록과 순서 변경, 썸네일 자동 추출, 미사용 운영 화면 라우트 제거 |
맡은 범위
본인 구현, 동료와 함께 다듬은 부분, 동료 구현을 나눠 적었습니다. 구분 기준은 두 저장소의 커밋 작성자와 줄 단위 작성 이력입니다.
본인 구현
- 사용자 서비스 초기 기반. 다국어 방식을 프로토타입으로 비교하고 next-translate와 Next.js 기본 i18n 라우팅으로 정리했습니다. husky와 pre-commit, ESLint 규칙, SCSS 폴더 구조도 이때 세웠습니다.
- 운영 백오피스 화면. 공지, 채널, 광고, 인기 게시물과 인기 채널, 노출 기준, 직원 게시판, 활동 로그, 통계 화면의 API 연동과 화면을 만들었습니다. 조회 조건을 URL에 두는
useRouter,useTableProps,useDate와 검색 입력, 기간 선택, 다중 선택 부품, Ant Design 스타일 덮어쓰기 대부분을 작성했습니다. - 이미지 처리. 운영 백오피스에서 크롭 모달을 Promise로 감싼 업로드 흐름과 출력 크기 리사이즈를 만들었습니다. 사용자 서비스에서는 크롭 팝업의 멈춤과 프로필 화질 문제를 고쳤습니다.
- 통계와 미션. 통계 응답 타입 가드와 데이터 훅, 미션 관리 탭과 수정 모달, 미션 통계와 엑셀의 미션 시트, 사용자 서비스 미션 팝업과 응답 변환 훅을 작성했습니다.
- 사용자 서비스 목록. 가상 스크롤 컨테이너와 props 훅, history 항목별 캐시 slice를 만들고 채널 피드, 채널 이벤트, 채널 검색, 해시태그 검색에 적용했습니다.
- 피드와 테마 관리. 테마와 테마 아이템 훅, 테마 관리 모달, 네 테마의 목록, 상세, 아이템 등록 화면, 선택 행 위치 이동, 썸네일 자동 추출을 만들었습니다. 옛 인기 콘텐츠 화면은 라우트에서 걷어 냈습니다.
동료와 함께 다듬은 부분
- 공통 표 컬럼 정의와 업로드 부품은 처음 구조를 만든 뒤 동료와 함께 고쳤습니다. 공통 컴포넌트 전체를 혼자 설계한 것으로 보지 않습니다.
- 스크롤 복원은 컨테이너와 캐시를 제가 만들었습니다. window 스크롤 위치 저장, 목록 아래 “모두 읽음” 영역의 높이 보정, 전체화면 비디오에서 돌아올 때의 보정은 동료와 나눠 다듬었습니다.
- 테마 상세의 드래그 순서 변경과 노출 여부 컬럼은 동료가 더했습니다. 저는 선택 행을 지정 위치로 옮기는 기존 방식을 유지하고 네 화면의 컬럼 순서를 맞췄습니다.
- 미션 엑셀은 동료가 만든 Excel 공통 유틸 위에 미션 시트 구성만 더했습니다.
동료 구현
- Sentry와 Firebase 설정, Excel 공통 유틸
- 피드 엔티티를 정규화해 보관하는 스토어와 게시물 변환 훅. 저는 채널 화면을 이 스토어에 연결하는 일을 맡았습니다
- 제품의 언어 선택 팝오버와 로그인 사용자의 계정 언어 적용
- 홈 피드와 스크랩 화면의 가상 스크롤 적용
문제와 해결
코드는 두 저장소에서 본인이 작성한 부분만 필요한 줄을 남겨 옮겼습니다. 내부 주소와 식별자는 지웠고 일부 이름을 줄였습니다.
다국어 방식을 프로토타입으로 고르고 운영 코드에 남길 범위 정하기
처음 Next.js로 만드는 사용자 서비스에 국영문 지원이 필요했습니다. 검색 노출과 서버 렌더링(SSR) 요구가 있어 언어별 화면도 서버에서 바로 그려져야 했습니다. 언어를 바꾼 사용자가 다시 들어왔을 때 브라우저 기본 언어로 돌아가지 않아야 했습니다.
2023.01 첫 주에 next-i18next로 프로토타입 두 개를 만들었습니다. 하나는 언어마다 도메인을 나누는 방식이고 다른 하나는 /en처럼 경로 앞에 언어를 붙이는 sub-path 방식입니다. 도메인 방식은 언어마다 도메인 설정이 따로 필요하고 sub-path 방식은 한 도메인 안에서 경로로 언어를 나눕니다. 최종 구조는 sub-path였습니다. 패키지는 next-translate로 바꿨고 locale 감지는 Next.js 기본 i18n 라우팅에 맡겼습니다. 선택한 언어는 Next.js가 재진입 때 읽는 NEXT_LOCALE cookie에 남기는 방식을 샘플 페이지에서 확인했습니다.
설정은 locales, 기본 언어, 페이지별 namespace를 파일 하나에 두었습니다.
module.exports = {
locales: ['ko', 'en'],
defaultLocale: 'ko',
localeDetection: true,
pages: {
'*': ['common'],
'/404': ['error'],
},
};export const persistLocaleCookie = (locale, days = 100) => {
jsCookie.set('NEXT_LOCALE', locale, { expires: days });
};
const SelectLanguage: FC = () => {
const { locales, locale } = useRouter();
useEffect(() => {
persistLocaleCookie(locale); // 경로의 locale이 바뀔 때마다 cookie를 같이 맞춘다
}, [locale]);
const changeLanguageHandler = ({ target: { value: locale } }) => {
return setLanguage(locale); // next-translate가 /en 같은 sub-path로 이동시킨다
};
// ...
};flowchart LR
Select["언어 선택"] -->|setLanguage| Path["sub-path 경로 /en"]
Path -->|locale 변경| Cookie["NEXT_LOCALE cookie"]
Revisit["재진입"] --> Detect["Next.js locale 감지"]
Cookie --> Detect
Detect --> Pathlocales 설정, next-translate, sub-path 경로는 제품 코드에 그대로 남았습니다. 반면 cookie를 쓰는 언어 선택 컴포넌트는 샘플 페이지에만 붙어 있었고 2023.04 동료가 샘플 페이지를 정리할 때 화면에서 빠졌습니다. 제품의 언어 선택은 동료가 만든 팝오버와 로그인 사용자의 계정 언어 설정으로 대체됐습니다. 그래서 이 사례는 언어 유지 기능을 완성한 일이 아니라 다국어 라우팅의 기반을 고르고 남긴 작업으로 봅니다.
운영 화면은 Ant Design을 그대로 쓰고 프로젝트 규칙만 얹기
2023.01부터 넉 달 사이에 공지, 채널, 광고, 인기 게시물과 인기 채널, 노출 기준, 직원 게시판, 활동 로그, 통계 화면을 만들어야 했습니다. 화면 대부분은 검색과 필터, 표, 등록 폼, 확인 모달의 조합이었습니다. 프로젝트는 Ant Design 4가 이미 들어 있는 사내 백오피스 보일러플레이트에서 출발했습니다.
선택지는 두 가지였습니다. 사용자 서비스처럼 디자인 시안에 맞춘 컴포넌트를 직접 만드는 방법과 Ant Design의 표, 폼, 모달을 그대로 쓰고 그 위에 프로젝트 규칙만 얹는 방법입니다. 많은 운영 화면을 일정 안에 같은 모양으로 내야 했기에 후자를 택했습니다. 정렬과 행 선택, 페이지네이션이 있는 Table, 규칙 기반 검증이 있는 Form, Modal, DatePicker, Upload를 손대지 않고 썼습니다.
대신 프로젝트마다 달라지는 규칙은 Ant Design 바깥의 훅과 작은 부품으로 모았습니다. 목록의 페이지와 행 선택은 useTableProps, 조회 조건은 useRouter, 기간과 단위는 useDate와 기간 선택 부품, 업로드는 Upload의 customRequest에 크롭과 직접 업로드를 끼우는 함수, 통계 응답 변환은 useGetPageData가 맡았습니다. 화면은 이 훅과 Ant Design 컴포넌트를 조합만 합니다. 아래는 2023.12 게시물 테마 목록 화면의 구성입니다.
const { navigate, pathname, queryParams, replaceQueryString } = useRouter();
const { data } = useGetThemesView({
requestParams: { themeType: 'POST', search: queryParams.search },
});
const { rowSelection, selectedRow, resetRows } = useTableProps<ThemeResponse>();
return (
<>
<InputSearch
defaultValue={queryParams.search}
onSearch={(value: string) => replaceQueryString({ search: value })}
/>
<Table
rowKey={'id'}
rowSelection={rowSelection}
dataSource={memoizedThemes}
onRow={record => ({ onClick: () => navigate(`${pathname}/detail/${record.id}`) })}
>
{/* Column 정의 */}
</Table>
</>
);flowchart TB
subgraph AntD["Ant Design 4"]
T["Table"]
F["Form"]
M["Modal"]
D["DatePicker"]
U["Upload"]
end
subgraph Rules["프로젝트 규칙"]
TP["useTableProps: URL 페이지와 행 선택"]
DT["useDate: 기간과 단위"]
UP["customRequest: 크롭과 직접 업로드"]
PD["useGetPageData: 통계 응답 변환"]
end
T --> TP
D --> DT
U --> UP
TP --> Screen["운영 화면"]
DT --> Screen
UP --> Screen
PD --> Screen
F --> Screen
M --> Screen이 선택에는 대가가 있었습니다. Ant Design Table의 페이지 번호는 1부터 시작하고 서버의 페이지 번호는 0부터 시작해서 둘을 바꾸는 코드가 필요했습니다. 이 변환은 아래 URL query 절에서 다루는 useTableProps에 모았습니다. Ant Design 4의 DatePicker는 moment 객체를 값으로 받기 때문에 기간 선택 훅도 moment로 계산했고 운영 백오피스의 날짜 처리는 대부분 moment에 묶였습니다. Ant Design 5로 올리면 날짜 값이 dayjs로 바뀌어 이 코드를 함께 고쳐야 하는 구조입니다. 화면 모양은 Ant Design 기본에 가깝게 두고 업로드 영역, 이미지 미리보기, 폼 항목 간격처럼 꼭 필요한 곳만 _ant-design.scss 한 파일에서 덮어썼습니다. 폼 값은 Ant Design Form 인스턴스 안에 있어서 필수값이 비면 등록 버튼을 막는 것 같은 파생 상태는 Form.Item의 shouldUpdate 렌더 함수에서 읽었습니다.
그 결과 2023.01부터 2023.04까지 공지부터 통계까지의 화면이 같은 틀을 썼고 2023.12 테마 관리 재편도 같은 틀 위에서 진행했습니다. 일정 단축 폭은 측정하지 않았습니다.
크롭 모달을 Promise로 받아 업로드 흐름을 한 줄로 잇기
2023.02 공지 폼부터 썸네일, 커버, 프로필 이미지를 올리는 폼이 계속 늘었습니다. 운영자가 이미지를 고르면 비율에 맞게 잘라 출력 크기로 줄인 뒤 스토리지에 올리고 폼에는 CDN 주소만 남겨야 했습니다. Ant Design Upload는 customRequest에서 업로드를 직접 처리하게 해 줍니다. 하지만 중간에 크롭 모달을 띄우고 운영자의 선택을 기다렸다가 업로드를 이어 가는 흐름을 콜백으로 짜면 모달 닫힘과 업로드 완료 처리가 서로 다른 곳에 흩어집니다.
모달을 여는 함수가 Promise를 돌려주게 해서 customRequest 안에서 await 한 줄로 크롭 결과를 받게 했습니다. 모달은 닫힐 때 잘라 낸 Blob을 넘기고 취소하면 undefined를 넘깁니다. 모달의 등록 버튼은 크롭 영역을 출력 크기 캔버스에 그려 Blob을 만듭니다. 이 계산은 뒤의 사용자 서비스 멈춤 문제를 고칠 때 그대로 옮겨 갔습니다. 파일은 API 서버를 거치지 않고 presigned URL로 스토리지에 바로 올렸습니다.
export const showImageCropModal = (
imageSrc: string,
type: UPLOAD_TYPE,
open: PopupManagerOpenFunction
) => {
return new Promise<Blob | undefined>(resolve => {
open(ImageCropModal, {
type,
imageSrc,
onClose: (blobImage: Blob | undefined) => resolve(blobImage),
});
});
};customRequest: async ({ file, onSuccess }) => {
const { name: fileName, uid } = file as RcFile;
try {
const { presigned, cdn } = await createPresignedUrl({ fileName, type: fileType ?? FILE_TYPE.COMMENT });
const blobImage = await showImageCropModal(await readFile(file as RcFile), UPLOAD_TYPE.THUMBNAIL, open);
if (blobImage === undefined) return; // 크롭 취소
const { status } = await axios({
url: presigned,
data: blobImage,
method: REQUEST.PUT,
withCredentials: false,
headers: { 'Content-Type': blobImage?.type },
});
if (status === 200) {
onSuccess?.(status);
setFieldValue(name, { uid, url: cdn, name: fileName, type: LINK_TYPE.IMAGE, status: 'done' });
}
} catch (error) {
errorMessage(error as string);
}
},sequenceDiagram
participant O as 운영자
participant U as Upload customRequest
participant S as API 서버
participant M as 크롭 모달
participant ST as 스토리지
O->>U: 이미지 선택
U->>S: presigned URL 요청
S-->>U: presigned 주소와 CDN 주소
U->>M: await showImageCropModal
M-->>U: 출력 크기 Blob 또는 undefined
U->>ST: PUT Blob
U->>U: 폼 값에 CDN 주소 기록썸네일, 커버, 원형 프로필, 채널 네트워크 토큰 이미지의 네 업로드 부품이 같은 함수를 씁니다. 이후 동료가 파일 이름 소문자 변환과 오류 안내를 더하면서 순서를 일부 바꿨지만 Promise로 크롭을 기다리는 구조는 바뀌지 않았습니다. 다만 presigned URL을 크롭보다 먼저 받기 때문에 운영자가 크롭을 취소하면 발급한 주소는 쓰이지 않고 버려집니다.
운영 목록의 조회 조건을 URL query에만 두기
운영 백오피스의 관리와 통계 화면은 검색어, 날짜 범위와 단위, 여러 필터, 페이지와 크기를 한꺼번에 조회 조건으로 씁니다. 이 값을 컴포넌트 상태에 두면 새로고침하거나 상세 화면에 다녀온 뒤 조회 조건이 사라지고 운영자끼리 같은 화면을 주고받을 수도 없습니다.
목록 상태는 URL query에만 두고 화면은 URL을 읽어서 그리게 했습니다. 시작은 2023.02 광고 관리 화면에서 query string 갱신을 replaceQueryParams 함수 하나로 모은 것이었습니다. 2023.04 통계 화면에서 이를 useRouter 훅으로 감쌌고 2023.10 미션 화면에서 표의 페이지까지 useTableProps로 묶었습니다.
몇 가지 규칙을 같이 정했습니다. 조건을 바꾸는 동작은 모두 navigate의 replace로 URL을 바꿉니다. 필터를 바꿀 때마다 history 항목이 늘지 않아 뒤로가기는 이전 화면으로 돌아가고 상세에서 목록으로 돌아오면 목록 URL에 남은 조건이 그대로 적용됩니다. 검색어, 필터, 기간이 바뀌면 페이지를 0으로 되돌립니다. 기존 query와 합칠 때 기본은 _.assign으로 키 단위 덮어쓰기를 합니다. 다중 선택 필터 배열을 _.merge로 합치면 인덱스끼리 섞이기 때문에 중첩 값을 합칠 때만 deep을 켭니다. 미션 관리의 탭, 내부 탭, 언어처럼 API에는 보내지 않고 화면만 쓰는 값도 같은 URL에 두되 타입을 UrlParams로 나눴습니다.
export const useRouter = <TQueryParams = QueryParams & QueryParamKeys, TUrlParams = UrlParams>() => {
const navigate = useNavigate();
const location = useLocation();
const queryParams: TQueryParams = getQueryString(location.search);
const urlParams: TUrlParams = getQueryString(location.search);
const replaceQueryString = (queryParams: Partial<TQueryParams>, deep = false) => {
const merge = _.merge({}, getQueryString(location.search), queryParams);
const assign = _.assign({}, getQueryString(location.search), queryParams);
navigate(
{ pathname: location.pathname, search: qs.stringify(deep ? merge : assign) },
{ replace: true }
);
};
return { navigate, location, queryParams, urlParams, replaceQueryString /* ... */ };
};서버의 페이지 번호는 0부터, Ant Design Table의 current는 1부터 셉니다. 이 차이를 화면마다 맞추면 어디선가 1이 어긋나기 쉬워 변환은 useTableProps에서만 했습니다. URL에는 서버 기준인 0부터의 값을 두고 표에 넘길 때 1을 더하고 표에서 받을 때 1을 뺍니다.
const { queryParams, replaceQueryString } = useRouter<QueryParams['common']>();
const { page, size } = queryParams;
const defaultPaginationProps: TablePaginationConfig = {
pageSize: _.toNumber(size) || 10,
current: _.toNumber(page) + 1, // URL과 서버는 0부터, 표는 1부터
onChange: (current: number, pageSize: number) => {
return replaceQueryString({ page: current - 1, size: pageSize });
},
};
return {
selectedRow,
rowSelection: _.merge({}, defaultRowSelection, rowSelection),
pagination: _.merge({}, defaultPaginationProps, pagination),
resetRows: () => setSelectedRow([]),
};flowchart LR
URL["location.search"] -->|getQueryString| Q["queryParams"]
Q --> Table["표 페이지와 크기, 1 더하기"]
Q --> Date["날짜 범위와 단위"]
Q --> Filter["검색어와 필터"]
Q --> API["조회 API 요청, 0부터"]
Table -->|"1 빼서 replace"| URL
Date -->|"page 0으로 replace"| URL
Filter -->|"page 0으로 replace"| URL통계와 관리 화면의 조회 조건이 새로고침과 상세 복귀 뒤에도 남고 URL만 넘기면 같은 화면을 볼 수 있게 됐습니다. 한계는 두 가지입니다. query 값은 문자열이라 _.toNumber로 바꾸기만 하고 잘못된 값을 걸러 내지 않았습니다. 또 초기 화면 상당수는 replaceQueryParams를 직접 부르고 이후 화면은 훅을 써서 두 방식이 섞인 채 남았습니다.
통계 탭 네 개를 데이터 훅 하나와 타입 가드로 나누기
통계 화면의 포인트 순위, 인기 채널, 인기 게시물, 인기 광고 탭은 응답 구조가 서로 달랐습니다. 포인트 순위는 합계와 사용자 페이지가 담긴 객체였고 나머지 셋은 배열이었습니다. 필드가 비거나 null로 오는 일도 잦아서 응답을 그대로 표에 넣으면 빈 값에서 화면이 깨졌습니다.
탭마다 변환 코드를 따로 두는 대신 useGetPageData 훅 하나가 응답 모양을 판별해 맞는 변환 함수를 고르게 했습니다. 판별은 응답 타입의 키 목록으로 검사하는 런타임 타입 가드가 맡습니다. 처음에는 필드마다 !== undefined를 비교했지만 2023.04 말에 필수 키를 keyof 응답 타입의 배열로 두는 방식으로 바꿨습니다. 응답 타입에서 필드 이름이 바뀌면 키 배열이 타입 검사에서 걸립니다.
type DailyChannelResponseKeys = keyof DailyChannelResponse;
type PointStatsResponseKeys = keyof PointStatsResponse;
export const isDailyChannelsResponse = (data: unknown): data is DailyChannelResponse[] => {
if (!Array.isArray(data)) return false;
const response = data as DailyChannelResponse[];
const requiredKeys: DailyChannelResponseKeys[] = ['channel', 'followCount', 'visitCount', 'rank'];
return response.every(value => requiredKeys.every(key => key in value));
};
export const isPointStatsResponse = (data: unknown): data is PointStatsResponse => {
const response = data as PointStatsResponse;
const requiredKeys: PointStatsResponseKeys[] = [
'totalAccumulatedPoint',
'totalPointByDate',
'totalPointToEndDate',
'users',
];
return requiredKeys.every(key => key in response);
};export const useGetPageData = <TResponse, TReturn, TOtherData = undefined>(
data: TResponse | undefined,
otherData?: TOtherData
) => {
const [pageData, setPageData] = useState<TReturn>();
useEffect(() => {
if (data) {
if (isDailyChannelsResponse(data)) return setPageData(getDailyChannels(data) as TReturn);
if (isDailyPostStatsResponse(data)) return setPageData(getDailyPosts(data) as TReturn);
if (isDailyMissionsResponse(data) && isMissionsResponse(otherData)) {
return setPageData(getDailyMissionStats(data, otherData) as TReturn);
}
if (isPointStatsResponse(data)) return setPageData(getPointStat(data) as TReturn);
}
setPageData([] as TReturn);
}, [data, otherData]);
return { pageData };
};네 탭과 인기 게시물 등록 모달이 이 훅을 씁니다. 어떤 응답이 어떤 표가 되는지 한 파일에서 보이고 채널이나 게시물이 비어 오는 인기 게시물 행은 변환 함수에서 걸렀습니다. 허점도 있었습니다. 빈 배열은 모든 배열 가드를 통과해서 첫 번째 가드인 인기 채널 변환을 탑니다. 결과가 빈 표라 화면에서는 드러나지 않았지만 판별 순서에 기대는 구조였습니다. 반환 타입도 호출하는 쪽이 지정한 TReturn으로 단언하므로 가드와 반환 타입의 짝은 사람이 맞춰야 합니다.
미션 통계에 필터와 엑셀 다운로드 붙이기
운영자는 기간과 미션 유형별 달성 현황을 화면에서 보고 보고용으로 내려받아야 했습니다. 대시보드에도 같은 통계가 필요했습니다.
조회 조건은 앞 절의 방식대로 URL query에 두었습니다. 기간과 단위, 미션 유형과 퀘스트 유형 다중 선택, 미션 제목 검색을 URL에 두고 조건을 바꾸면 페이지를 0으로 돌립니다. 회원가입 때 장르를 고르는 미션은 UX 요구에 따라 퀘스트별 표에서 빼고 따로 보여 줬습니다. 달성 횟수가 null로 오는 경우는 0으로 표시했습니다.
엑셀은 동료가 만든 Excel 공통 유틸을 쓰고 저는 미션 통계용 시트 구성만 더했습니다. 기간 기준 인원과 누적 달성자 수를 담은 전체 시트와 미션별 달성 횟수 시트 두 장을 만들고 표에 쓰는 화면 모델을 그대로 시트 행으로 옮겼습니다. 대시보드에도 같은 미션 통계와 필터, 엑셀 다운로드를 붙였습니다.
운영자는 화면에서 보던 조건 그대로 엑셀을 받습니다. 다만 퀘스트별 표는 서버가 페이지 단위로 주는 응답이라 엑셀에도 현재 페이지의 행만 담깁니다. 전체 행을 받으려면 다운로드용 조회를 따로 두어야 했습니다.
이어받은 사용자 서비스에서 먼저 고칠 흐름 정하기
2023년 하반기에 사용자 서비스를 맡던 프론트엔드 개발자가 떠나면서 운영 백오피스를 맡던 제가 사용자 서비스까지 이어받았습니다. 2023.01 초기 작업 이후 아홉 달 동안 많이 바뀐 코드를 처음부터 다시 익힐 시간은 없었습니다.
먼저 두 저장소의 프리티어 설정을 같은 기준(printWidth 100)으로 맞췄습니다. 그다음은 미션 기능, 버그 티켓으로 들어온 문제, 긴 목록의 탐색 경험 순서였습니다.
| 시기 | 작업 | 이어지는 절 |
|---|---|---|
| 2023.10 | 미션 팝업의 출석, 업적, 특별 미션과 보상 받기 | 미션 규칙을 양쪽에 맞추기 |
| 2023.10 말 ~ 2023.11 | 썸네일 편집 중 멈춤, 프로필 사진 열화, 에디터 모드 글의 태그 덧붙음 | 썸네일 편집 멈춤 |
| 2023.11 ~ 2024.01 | 채널 화면을 동료의 피드 스토어에 연결하고 가상 스크롤 적용 | 목록 복귀 맥락 |
미션과 업적처럼 백엔드 응답이 자주 바뀌는 화면은 응답을 화면 모델로 바꾸는 변환 훅에서 변경을 받았습니다. 화면 컴포넌트는 변환된 값만 읽게 해서 백엔드 변경이 화면 여러 곳으로 번지지 않게 했습니다. 인수 뒤 석 달의 작업은 아래 세 절로 이어집니다.
미션 규칙을 운영 백오피스와 사용자 서비스 양쪽에 맞추기
미션은 운영 백오피스에서 지급 포인트와 지급 기준을 정하고 사용자 서비스에서 진행도와 보상 받기로 보이는 기능입니다. 2023.10에 두 쪽을 모두 제가 구현하게 되면서 같은 규칙이 양쪽 화면에서 같은 뜻으로 보여야 했습니다.
운영 백오피스의 미션 관리는 출석, 업적(개척자, 정착자), 특별 미션 탭으로 나눴습니다. 수정 모달은 미션 유형에 따라 고칠 수 있는 필드가 다릅니다. 일회성 미션에는 지급 기준이 없고 누적 미션은 지급 기준을 2회 이상으로 제한하고 일일 카운트 제한을 따로 받습니다. 출석 미션은 일차별 포인트 배열을 통째로 보냅니다. 퀘스트 제목과 설명은 언어별로 따로 저장해서 수정 모달도 언어를 URL의 화면 전용 값으로 받았습니다.
사용자 서비스의 미션 팝업은 응답 변환 훅에서 운영 백오피스의 지급 기준(progressMaximumCount)을 진행도의 분모로 씁니다. 받을 수 있는 미션을 위로, 진행 중인 미션을 그다음, 이미 받은 미션을 아래로 정렬했습니다. 프로필 편집 미션은 페이지 이동 대신 편집 팝업을 열도록 동작 값을 붙였습니다.
여기서 두 가지 예외를 따로 처리했습니다. 첫째는 다음 출석까지 남은 시간입니다. 처음 코드는 사용자 기기의 로컬 자정까지 남은 시간을 셌는데 출석 기준은 UTC 0시였습니다. 한국 사용자에게는 오전 9시에 출석이 바뀌는데 화면은 자정을 가리키고 있었습니다. 계산 기준을 setUTCHours로 바꿨습니다. 둘째는 SBT 보상입니다. 지갑을 연결하지 않은 사용자가 SBT 보상을 받으려 하면 서버가 오류 코드 1088을 돌려줬는데 화면은 일반 오류만 띄웠습니다. 이 코드를 지갑 연동 안내로 바꿔 보여 줬습니다.
export const calculateTimeRemaining = () => {
const currentTime: Date = new Date();
const endTime: Date = new Date(currentTime);
endTime.setUTCHours(24, 0, 0, 0); // 이전: setHours(24, 0, 0, 0), 기기 로컬 자정
const diffMillisecond = endTime.getTime() - currentTime.getTime();
const min: number = Math.floor((diffMillisecond % (1000 * 60 * 60)) / (1000 * 60));
const hour: number = Math.floor(diffMillisecond / (1000 * 60 * 60));
return { hour, min };
};onError: (error, _variables, _context) => {
const { message } = error as { message: number; text: string };
return open(PopupToast, {
type: 'error',
message: message === 1088 ? t('toastPopup.failWipinIntegration') : t('toastPopup.error'),
});
},flowchart LR
subgraph Admin["운영 백오피스"]
Edit["미션 수정 모달: 지급 포인트, 지급 기준, 일일 제한, 언어별 문구"]
end
subgraph API["미션 API"]
Rule["미션 규칙과 사용자 진행도"]
end
subgraph FE["사용자 서비스"]
Conv["응답 변환 훅: 진행도 분모, 정렬, 동작 값"]
Popup["미션 팝업"]
Timer["다음 출석: UTC 0시"]
Wallet["오류 코드 1088: 지갑 연동 안내"]
end
Edit --> Rule
Rule --> Conv
Conv --> Popup
Timer --> Popup
Wallet --> Popup운영자가 바꾼 지급 기준이 사용자 팝업의 진행도 분모로 그대로 이어지고 출석 시각과 지갑 예외가 서버 기준과 맞게 표시됩니다. 다만 남은 시간은 사용자 기기 시계로 계산하므로 기기 시간이 틀리면 표시도 틀립니다. 오류 코드 1088은 상수로 정의돼 있었지만 이 처리에서는 숫자를 그대로 비교했습니다.
썸네일 편집 중 브라우저가 멈추던 문제
사용자 서비스를 이어받은 직후 썸네일 편집 중 브라우저가 멈춘다는 버그가 들어왔습니다. 채널 커버, 채널 프로필, 토큰 이미지, 게시물 썸네일이 모두 같은 크롭 팝업을 썼습니다.
원인으로 본 지점은 두 곳입니다. 기존 크롭 함수는 원본 이미지 크기만 한 캔버스를 만들어 이미지 전체를 그린 뒤 getImageData로 크롭 영역 픽셀을 읽어 다시 putImageData로 붙였습니다. 휴대폰 사진처럼 해상도가 큰 원본이면 원본 크기 비트맵과 크롭 영역 픽셀 배열이 모두 메인 스레드에서 만들어집니다. 그리고 크롭 팝업에는 원본 파일을 FileReader로 읽은 base64 data URL 문자열이 넘어갔습니다. 메모리 사용량과 처리 시간은 측정하지 않았고 코드 구조로 원인을 좁혔습니다.
크롭 영역만 최종 출력 크기의 캔버스에 바로 그리도록 바꿨습니다. drawImage의 원본 영역과 대상 영역 인자를 쓰면 원본 전체를 그리거나 픽셀을 다시 읽지 않고도 크롭과 축소가 한 번에 끝납니다. 출력 크기는 용도별로 정했습니다. 커버는 1920x460, 게시물 썸네일은 760x430, 프로필은 180x180입니다. 이 계산은 운영 백오피스의 크롭 모달에서 2023.02부터 써 온 방식을 그대로 옮긴 것입니다. 쓰지 않던 회전 기능도 함께 걷어 냈습니다.
export const getResizeConfigFromUploadType = type => {
switch (type) {
case 'cover':
return { resizeWidth: 1920, resizeHeight: 460 };
case 'profile':
return { resizeWidth: 180, resizeHeight: 180 };
default:
return { resizeWidth: 760, resizeHeight: 430 };
}
};
export const getCroppedImg = async (src: string, pixelCrop: Area, resizeConfig) => {
const image = await createImage(src);
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
if (!ctx) return null;
const { width, height, x, y } = pixelCrop;
const { resizeWidth, resizeHeight } = resizeConfig;
canvas.width = resizeWidth; // 원본 크기가 아니라 출력 크기
canvas.height = resizeHeight;
const resizeRatio = Math.max(resizeWidth / width, resizeHeight / height);
const scaledWidth = width * resizeRatio;
const scaledHeight = height * resizeRatio;
// 원본의 크롭 영역만 출력 캔버스에 축소해 그린다
ctx.drawImage(image, x, y, width, height,
(resizeWidth - scaledWidth) / 2, (resizeHeight - scaledHeight) / 2, scaledWidth, scaledHeight);
return new Promise(resolve => {
canvas.toBlob(file => resolve({ file, url: URL.createObjectURL(file as Blob) }), 'image/jpeg');
});
};3주 뒤 두 번째 수정에서는 크롭 팝업에 넘기는 원본을 data URL 문자열에서 객체 URL로 바꿨습니다. 파일을 base64 문자열로 바꿔 넘기지 않고 브라우저가 가진 파일을 가리키는 짧은 주소만 넘깁니다. 네 업로드 부품에 같은 변경을 넣었고 운영 백오피스의 파일 읽기 함수도 2023.12에 같은 방식으로 맞췄습니다.
fileReader.onload = e => {
open(PopupCropImage, {
type: 'cover',
src: URL.createObjectURL(currentFiles[0]), // 이전: e.target?.result, base64 data URL
// ...
});
};
fileReader.readAsDataURL(currentFiles[0]);flowchart TB
subgraph Before["수정 전"]
B1["FileReader data URL"] --> B2["원본 크기 캔버스에 전체 그리기"]
B2 --> B3["getImageData로 크롭 영역 읽기"]
B3 --> B4["putImageData로 다시 붙이기"]
end
subgraph After["수정 후"]
A1["객체 URL"] --> A2["출력 크기 캔버스"]
A2 --> A3["drawImage 한 번으로 크롭과 축소"]
end같은 시기 프로필 사진 화질이 크게 떨어진다는 버그도 함께 고쳤습니다. 크롭 팝업의 프로필 출력은 첫 수정에서 표시 크기와 같은 94px로 잡았다가 같은 날 약 두 배인 180px로 올렸습니다. 저장 단계의 업로드 리사이즈도 180px에서 두 배인 360px로 올렸습니다.
같은 버그 제목의 수정은 2023.10.31과 2023.11.20 두 번에 걸쳐 들어갔고 개선 폭은 측정하지 않았습니다. 남은 한계는 세 가지입니다. FileReader 읽기는 팝업을 여는 신호로 남아 있어 data URL 변환 자체가 없어지지는 않았습니다. 크롭 흐름에서 만든 객체 URL을 해제하는 코드가 없습니다. 프로필은 두 수정이 하루 간격으로 다른 파일에 들어가면서 크롭 출력(180px)이 업로드 리사이즈(360px)보다 작아졌고 업로드 단계에서 다시 확대되는 구조가 남았습니다. 출력 크기를 한 곳에서 정했어야 했습니다.
가상 스크롤 위에서 목록 복귀 맥락 유지하기
채널 상세와 검색 결과는 무한히 길어지는 목록이라 react-virtualized로 화면에 보이는 행만 그려야 했습니다. 그런데 가상 스크롤을 넣으면 상세 화면에 다녀온 뒤 측정한 행 높이와 스크롤 위치가 사라져 사용자가 목록을 처음부터 다시 내려야 했습니다.
가상 스크롤, 스크롤 위치, 목록 캐시를 한 문제로 묶었습니다. 기준은 브라우저 history 항목입니다. Next.js는 history 항목마다 key를 넣는데 같은 URL이라도 새로 들어오면 새 key가 생기고 뒤로가기로 돌아오면 이전 key가 돌아옵니다. 이 key로 캐시를 고르면 새로 들어온 목록은 처음부터, 돌아온 목록은 보던 자리에서 시작하게 나눌 수 있습니다. 화면을 떠날 때 행 높이와 스크롤 위치를 저장하고 오래된 캐시는 30분 뒤 버립니다.
Zustand store의 cache slice가 history key를 hash로 삼아 항목을 관리합니다. 앱 루트에서는 라우팅마다 현재 key의 캐시를 만들거나 갱신합니다.
구현 코드: cacheSlice.ts
saveCacheData: <T>(data: T) => {
set(({ cacheSlice }) => {
const cache = _.find(cacheSlice.caches, ({ hash }) => hash === cacheSlice.hash);
if (cache === undefined) return;
if (cache.data) cache.data = _.merge(cache.data, data);
else cache.data = data;
cache.timestamp = dayjs().format();
});
},
loadCacheData: <T>() => {
const slice = get().cacheSlice;
const cache = _.find(slice.caches, ({ hash }) => hash === slice.hash);
if (cache === undefined) return undefined;
return cache.data ? (cache.data as T) : undefined;
},
clear: () => {
set(({ cacheSlice }) => {
cacheSlice.caches = _.filter(cacheSlice.caches, ({ timestamp }) =>
dayjs(timestamp).isAfter(dayjs().subtract(30, 'minute'))
);
});
},useEffect(() => {
if (isHydrated) {
const hash = window.history.state.key; // history 항목마다 다른 key
const path = window.history.state.as;
cacheSlice.updateHash(hash);
cacheSlice.findCache() ? cacheSlice.updateCache(path) : cacheSlice.createCache(path);
cacheSlice.clear(); // 30분 지난 캐시 정리
}
}, [router, isHydrated, auth?.id]);가상 스크롤 쪽은 렌더링된 행의 측정 높이를 모아 두었다가 다음 진입 때 CellMeasurerCache에 다시 넣습니다. 이미지가 있는 행은 측정 전 기본 높이와 측정 뒤 높이가 달라서 같은 scrollTop을 넣어도 높이가 없으면 다른 행이 보였습니다.
// NOTE: 측정된 행 높이를 저장하기 위한 셀 캐시
const memoizedCache = useMemo(() => {
const cache = new CellMeasurerCache({ fixedWidth: true, defaultHeight: height || 166 });
if (initRowsHeight) {
(cache as any)._rowHeightCache = { ...initRowsHeight };
(cache as any)._cellHeightCache = { ...initRowsHeight };
}
return cache;
}, [...(cacheFlag || [])]);
const saveRowsHeight = ({ index }: ListRowProps) => {
rowsCacheHeight.current.set(`${index}-0`, memoizedCache.rowHeight({ index }));
};sequenceDiagram
participant L as 목록 화면
participant C as cache slice
participant D as 상세 화면
L->>C: routeChangeStart에 행 높이와 스크롤 위치 저장
L->>D: 상세로 이동
D-->>L: 뒤로가기 (history key 동일)
L->>C: 같은 key의 캐시 읽기
C-->>L: 행 높이, windowScroll, virtualScroll
L->>L: CellMeasurerCache에 높이 주입 후 scrollTop 적용제가 만든 부품은 채널 피드, 채널 이벤트, 채널 검색, 해시태그 검색에 먼저 들어갔고 홈 피드와 스크랩 화면은 동료가 같은 부품으로 옮겼습니다. 위치 보정 일부를 동료와 나눈 범위는 맡은 범위에 적었습니다.
캐시는 메모리에만 있어 새로고침하면 사라지고 30분 뒤 만료됩니다. 행 높이 주입은 CellMeasurerCache의 내부 필드를 직접 고치는 방식이라 라이브러리 버전이 바뀌면 깨질 수 있습니다. 이때 겪은 문제와 지금 다시 본 기준은 react-virtualized 스크롤 복원에 따로 적었습니다.
인기 콘텐츠 관리를 Theme 모델 하나로 다시 묶기
인기 게시물, 인기 채널, 신규 이벤트 관리가 각각 따로 있어서 홈 피드에 무엇을 어떤 순서로 보여 줄지가 화면마다 흩어져 있었습니다. 2023년 말 운영 방식이 테마 단위로 바뀌면서 구조를 다시 짜야 했습니다.
화면을 종류별로 복제하는 대신 콘텐츠 유형과 운영 동작을 나눴습니다. 게시물, 채널, 이벤트, 랭킹 테마는 themeType 값만 다른 Theme 모델 하나로 다룹니다. 테마 목록 조회, 생성, 수정, 삭제는 유형과 관계없이 같은 훅을 씁니다. 테마 아이템은 조회만 유형별로 나눴습니다. 게시물 아이템은 게시물 요약을, 채널 아이템은 채널 요약을 받아야 하기 때문입니다. 등록, 수정, 삭제, 순서 변경은 공통 mutation 하나씩이고 성공하면 네 유형의 아이템 조회 캐시를 함께 무효화합니다.
export const useGetThemesView = ({ requestParams, options } = {}) => {
const defaultQueryParams: QueryParams['themeView'] = { themeType: 'CHANNEL', page: 0, size: 20 };
const mergedQueryParams = _.merge({}, defaultQueryParams, requestParams);
return useQuery({
queryKey: [endpoints.themeView.getThemes, mergedQueryParams],
queryFn: () => getThemesView(mergedQueryParams),
keepPreviousData: true,
...options,
});
};
export const useReorderThemeItems = ({ options } = {}) => {
const queryClient = useQueryClient();
const queryKey = [
endpoints.themeItemView.post,
endpoints.themeItemView.channel,
endpoints.themeItemView.event,
endpoints.themeItemView.ranking,
];
return useMutation({
mutationFn: requestParams => reorderThemeItems(requestParams),
onSuccess: () => _.each(queryKey, key => queryClient.invalidateQueries([key])),
...options,
});
};순서 변경은 운영자가 여러 행을 고르고 “N번 위치로”를 입력하는 방식으로 만들었습니다. 고른 행을 목록에서 빼고 지정 위치에 끼운 전체 아이템 id 순서를 서버에 보냅니다. 서버는 순서 배열 하나만 받으면 되고 화면은 성공 뒤 선택 행과 입력값을 초기화합니다.
const reorderItemHandler = async () => {
const index = (orderNumber as number) - 1;
const itemIds = _.map(selectedRow, 'itemId');
const reorderIds = _.chain(tableData)
.map(({ itemId }) => (_.includes(itemIds, itemId) ? null : itemId)) // 고른 행을 빼고
.compact()
.thru(result => _.concat(_.slice(result, 0, index), itemIds, _.slice(result, index))) // 지정 위치에 끼운다
.value();
try {
await reorderItems({ path: { themeId: slug }, body: reorderIds });
setIsReorderButtonOpen(false);
setOrderNumber(null);
resetRows();
} catch (e) {
if (e instanceof CustomError) return errorMessage(e.data.message);
}
};flowchart LR
Post["게시물 테마"] --> Theme["Theme 모델과 themeType"]
Channel["채널 테마"] --> Theme
Event["이벤트 테마"] --> Theme
Ranking["랭킹 테마"] --> Theme
Theme --> Edit["공통 생성, 수정, 삭제와 순서 변경"]
Edit --> Invalidate["네 유형의 아이템 조회 무효화"]
Edit --> Feed["사용자 홈 피드의 콘텐츠 구성"]네 테마의 목록, 상세, 아이템 등록 화면과 옛 화면 정리가 2023.12.06부터 12.14 사이 커밋으로 들어갔습니다. 인기 게시물, 인기 채널, 신규 이벤트 관리는 라우트에서 걷어 냈습니다. 이후 노출 여부 컬럼을 맨 앞으로 옮기거나 정렬이 동작하지 않던 문제를 고칠 때도 네 화면에 같은 방식으로 반영했습니다.
한계는 목록 화면에 있습니다. 공통으로 묶은 것은 훅과 관리 모달이고 네 목록 페이지는 themeType만 다른 사본으로 남았습니다. 위의 컬럼 이동과 정렬 수정도 네 파일을 각각 고쳐야 했습니다. themeType을 받는 페이지 하나로 합치는 것이 다음 단계였습니다. 아이템 하나만 바뀌어도 네 유형의 조회 캐시를 모두 무효화하는 것도 단순함을 택한 대가였습니다.
게시물 테마의 썸네일을 자동으로 고르는 순서
게시물 테마 아이템은 운영자가 게시물 URL만 넣으면 등록되게 해야 했습니다. 게시물마다 썸네일을 가진 경우, 외부 링크만 있는 링크 게시물, 첨부 미디어만 있는 경우, 본문 HTML 안에만 이미지가 있는 경우가 섞여 있었습니다.
후보를 우선순위대로 확인하는 규칙을 정했습니다. 게시물에 지정된 썸네일이 있으면 그대로 씁니다. 없으면 링크 게시물인 경우 첫 링크의 OG 이미지를 서버 API로 받고 다른 게시물은 첫 첨부 미디어를 씁니다. 첨부 목록이 없으면 본문 HTML을 DOMParser로 읽어 첫 img의 주소를 꺼냅니다. DOMParser로 만든 문서는 이미지를 내려받지 않아 본문을 화면에 그리지 않고 주소만 읽을 수 있습니다. 끝까지 찾지 못하면 운영자에게 안내하고 직접 업로드하게 했습니다.
let thumbnail: LinkUrl | undefined = undefined;
if (post?.thumbnail?.url) {
thumbnail = post.thumbnail; // 1. 게시물에 지정된 썸네일
} else if (post.urlList) {
if (post.postType === POST_TYPE.LINK) {
const url = await getOgImageUrl(post.urlList[0].url); // 2. 링크 게시물은 서버가 읽은 OG 이미지
thumbnail = url ? { url, type: LINK_TYPE.IMAGE } : undefined;
} else {
thumbnail = post.urlList[0]; // 3. 첫 첨부 미디어
}
} else {
const doc = new DOMParser().parseFromString(post.contents, 'text/html');
const imageElement = doc.querySelector('img'); // 4. 본문 HTML의 첫 이미지
if (imageElement) {
const url = imageElement.getAttribute('src');
thumbnail = url ? { url, type: LINK_TYPE.IMAGE } : undefined;
}
}
if (!thumbnail) errorMessage(TOAST_POPUP_MESSAGE.ERROR_NONE_EXTRACT_THUMBNAIL);
else form.setFields([{ name: 'thumbnail', value: thumbnail }]);flowchart TB
Start["게시물 URL 입력"] --> Has{"게시물 썸네일이 있나"}
Has -->|예| Use["그대로 사용"]
Has -->|아니요| List{"첨부 목록이 있나"}
List -->|링크 게시물| OG["서버 API로 OG 이미지"]
List -->|그 외| Media["첫 첨부 미디어"]
List -->|없음| Html["본문 HTML의 첫 img"]
OG --> Check{"찾았나"}
Media --> Check
Html --> Check
Check -->|예| Form["폼 썸네일 값에 기록"]
Check -->|아니요| Toast["안내 후 직접 업로드"]후보가 하나라도 있는 게시물은 URL 입력만으로 썸네일까지 채워집니다. 빈틈도 하나 있습니다. 첨부 목록이 빈 배열로 오면 조건은 참이 되지만 첫 항목이 없어 실패 안내로 끝나고 본문 이미지 단계까지 내려가지 않습니다. 배열 길이를 함께 확인했어야 했습니다.
결과와 근거
- 사용자 서비스 크롭 팝업이 원본 크기 캔버스 없이 크롭 영역만 출력 크기로 그립니다. 근거는 사용자 서비스
src/components/PopupCropImage/cropImage.ts와 커밋155100bd,b5306824, 객체 URL 전환 커밋8461e4f1입니다. 멈춤 시간이나 메모리 사용량은 측정하지 않았습니다. - 운영 백오피스의 이미지 업로드 네 곳이 Promise로 크롭을 기다린 뒤 presigned URL로 직접 올립니다. 근거는
src/utils/functions/upload.ts의showImageCropModal과 업로드 부품의customRequest입니다. - 운영 목록의 조회 조건이 URL에 남아 새로고침과 상세 복귀 뒤에도 유지되고 페이지 번호 변환은
src/hooks/useTableProps.ts한 곳에 있습니다. - 통계 탭 네 개와 등록 모달 하나가
src/hooks/usePageData.ts와src/hooks/typeGuards.ts로 응답을 판별합니다. - 미션은 운영 백오피스
MissionEditModal.tsx와 사용자 서비스useMissionConverter.ts,useMissions.ts에서 같은 지급 기준을 씁니다. UTC 출석 시각은 커밋8cb29128, 지갑 미연동 예외는bd24b043입니다. - 가상 스크롤을 적용한 목록이
VirtualScrollContainer.tsx와cacheSlice.ts로 상세에서 돌아온 위치를 복원합니다. - 네 종류 테마가
src/query/theme/useTheme.ts의 공통 훅과ThemeManagementModal.tsx를 씁니다. 옛 인기 콘텐츠 화면의 라우트 제거는 커밋5310a477입니다. - 이 페이지의 사례에는 본인이 작성한 자동 테스트가 없습니다. 결과는 코드 경로와 커밋 작성 이력으로 확인했습니다. 일정 단축이나 운영 문의 감소 같은 효과는 측정하지 않아 쓰지 않았습니다.
배운 것
- URL 상태의 타입. 여기서는 query 값을 문자열로 읽어
_.toNumber로 바꾸고 페이지 번호 변환을 훅 한 곳에 두는 데서 멈췄습니다. 이후 LG Shield 관리자와 SK AX GAS Position Play에서는 nuqs parser로 URL 값의 타입과 기본값을 계약으로 두는 쪽으로 바꿨습니다. - 운영 화면의 UI 프레임워크. 일정이 우선인 운영 화면은 프레임워크 컴포넌트를 그대로 쓰고 페이지 번호나 날짜 객체 같은 규약 차이를 훅에서 흡수한다는 판단을 이어 갔습니다. LG Shield 관리자와 HCMS의 운영 화면도 Ant Design으로 만들었습니다.
- 상태의 수명. history 항목으로 “새로 들어온 목록”과 “돌아온 목록”을 나눈 경험에서 복원할 상태를 조회 조건, 목록 데이터, 화면 위치, 행 측정값으로 나눠 보게 됐습니다. HCMS에서는 조회 조건은 URL, 서버 데이터는 React Query, 표시 설정은 영속 스토어, 편집 초안은 화면 상태로 저장 위치를 처음부터 나눴습니다.