Navigation

Vortex DAO

디지털 자산과 커뮤니티 기능이 결합된 사용자 서비스와 그 운영 백오피스를 함께 다룬 프로젝트입니다. 사용자 서비스의 초기 다국어 기반을 잡은 뒤 운영 백오피스를 구축했습니다. 다른 프론트엔드 개발자가 떠난 뒤에는 사용자 서비스까지 이어받아 긴 목록의 탐색 경험을 고쳤습니다. 지금도 vortexgaming.io에서 운영 중입니다.

핵심 기여

과제본인 기여확인한 동작
담당자 이탈 뒤 서비스 인수백오피스 구축에 이어 사용자 서비스 FE 담당목록과 상세 탐색, 다국어 흐름을 이어서 개선
여러 화면의 동일 데이터피드 스토어와 converter, history별 목록 cache 연결엔티티 변경과 목록 복귀 맥락을 구분
운영 화면의 조회 조건공통 표와 URL query 연동검색, 기간과 페이지를 URL에서 다시 읽어 표시

개요

항목내용
기간2023.01 ~ 2024.01
역할운영 백오피스 구축 뒤 사용자 서비스 프론트엔드 인수
인원프론트엔드 2명, 백오피스 2명, 백엔드 5명. 처음에는 백오피스 담당이었고 프론트엔드 1명이 떠난 뒤 사용자 서비스까지 맡았습니다
서비스채널, 홈 피드, 미션과 업적, 디지털 자산 화면을 제공하는 사용자 서비스와 공지, 채널, 광고, 인기 콘텐츠, 통계, 포인트와 미션, 피드와 테마를 관리하는 백오피스
링크vortexgaming.io (운영 중)
사용자 서비스 기술Next.js, React, TypeScript, React Query, Zustand, next-translate, react-virtualized, SCSS, Sentry, Firebase, Cypress, Jest
백오피스 기술React, TypeScript, React Router, React Query, Zustand, Ant Design, Froala Editor, xlsx, SCSS

작업은 커밋이 몰린 시기 기준으로 다섯 단계였습니다.

단계시기내용
사용자 서비스 초기 기반2023.01다국어 프로토타입, next-translate 전환, sub-path routing, locale cookie, context menu 리서치, husky와 pre-commit, ESLint, SCSS 구조
운영 백오피스 구축2023.02 ~ 2023.05공지, 채널, 광고, 인기 게시물과 채널, 노출 기준, 직원 게시판, 활동 로그, 통계, 포인트와 미션 화면과 공통 표와 폼
백오피스 통계 보강2023.10 ~ 2023.11미션 통계와 대시보드 필터, 엑셀 다운로드, 광고 등록 수정
사용자 서비스 인수2023.10 ~ 2024.01가상 스크롤과 스크롤 복원, 캐시 저장, 홈 피드, 해시태그 검색, 미션과 업적 화면
피드와 테마 관리 재편2023.12 ~ 2024.01게시물, 채널, 랭킹, 이벤트 테마 관리와 미사용 운영 화면 제거

맡은 범위

문제와 해결

코드는 두 저장소의 실제 코드를 필요한 줄만 남겨 옮긴 것입니다. 내부 주소와 식별자는 지웠고 이름 일부를 조정했습니다.

문제. 처음 Next.js로 만드는 사용자 서비스에 국영문 지원이 필요했습니다. 언어를 바꾼 사용자가 페이지를 옮기거나 다시 들어왔을 때 언어가 브라우저 기본값으로 돌아가면 안 됐습니다.

판단. 다국어 패키지 두 가지를 프로토타입으로 비교한 뒤 next-i18next에서 next-translate로 바꿨습니다. 라우팅은 Next.js가 기본 제공하는 sub-path 방식을 쓰고 선택한 언어는 cookie에 남겨 재진입 때 같은 언어로 열리게 했습니다. 별도 middleware 없이 Next.js의 locale 감지와 cookie만으로 처리했습니다.

구현. locales와 기본 언어, 페이지별 namespace를 설정 파일 하나에 두었습니다. 언어 선택 컴포넌트는 setLanguage로 경로를 바꾸고 locale이 바뀔 때마다 cookie를 다시 씁니다.

i18n.jsjs
module.exports = {
  locales: ['ko', 'en'],
  defaultLocale: 'ko',
  localeDetection: true,
  pages: {
    '*': ['common'],
    '/404': ['error'],
  },
};
언어 선택과 locale cookietsx
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 --> Path

결과. 페이지 이동과 재진입 뒤에도 선택한 언어가 유지됩니다. 이후 다른 프로젝트에서 next-intl을 쓸 때 비교 기준이 됐습니다.

다른 개발자가 떠난 사용자 서비스를 이어받기

문제. 2023년 하반기에 사용자 서비스를 맡던 프론트엔드 개발자가 떠나면서 백오피스를 맡던 제가 사용자 서비스까지 이어받았습니다. 코드 전체를 처음부터 다시 익힐 시간은 없었고 사용자가 가장 자주 쓰는 흐름부터 고쳐야 했습니다.

판단. 채널 상세와 홈 피드처럼 긴 목록을 보는 화면의 탐색 경험을 먼저 잡고 미션과 업적처럼 백엔드 변경이 잦은 화면은 응답 변환 층에서 흡수하기로 했습니다.

구현. 가상 스크롤과 스크롤 복원을 묶어 처리한 뒤 홈 피드, 해시태그 검색, 채널 검색으로 같은 구조를 넓혔습니다. 미션과 업적은 응답을 ViewModel로 바꾸는 converter에서 백엔드 변경을 받았습니다.

결과. 인수 뒤 3개월 동안 사용자 서비스의 목록 화면 네 곳이 같은 스크롤 복원 구조를 쓰게 됐습니다.

가상 스크롤 위에서 목록 복귀 맥락 유지하기

문제. 채널 상세와 홈 피드는 무한히 길어지는 목록이라 react-virtualized로 windowing을 해야 했습니다. 그런데 windowing을 넣으면 상세 화면을 다녀온 뒤 행 높이와 스크롤 위치가 사라져 사용자가 목록을 처음부터 다시 내려야 했습니다.

판단. windowing, 스크롤 위치, 목록 캐시를 따로 보지 않고 한 문제로 묶었습니다. 브라우저 history 항목마다 캐시를 하나 두고 화면을 떠날 때 행 높이와 창 스크롤과 가상 스크롤 위치를 저장했다가 뒤로가기로 돌아오면 같은 history 항목의 캐시를 읽어 복원합니다. 오래된 캐시는 30분이 지나면 버립니다.

구현. Zustand store의 cache slice가 history key를 hash로 삼아 항목을 관리합니다. 앱 루트에서 라우팅마다 현재 history key로 캐시를 만들거나 갱신하고 뒤로가기인지 표시합니다.

구현 코드: cacheSlice.ts
cacheSlice.tsts
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'))
    );
  });
},
구현 코드: _app.tsx
_app.tsxtsx
// 채널 입장, back button을 통했는지 아닌지
router?.beforePopState(({ as }) => {
  const _url = as?.replace('/en', '');
  const isItem = sessionStorage.getItem(_url);
  if (isItem) {
    sessionStorage?.setItem(_url, 'false');
  }
  return true;
});

useEffect(() => {
  if (isHydrated) {
    const hash = window.history.state.key;
    const path = window.history.state.as;

    cacheSlice.updateHash(hash);
    cacheSlice.findCache() ? cacheSlice.updateCache(path) : cacheSlice.createCache(path);

    cacheSlice.clear();
  }
}, [router, isHydrated, auth?.id]);

가상 스크롤 쪽은 CellMeasurerCache에 저장해 둔 행 높이를 다시 넣고 WindowScroller의 scrollTop을 읽어 둡니다.

useVirtualScrollPropsGetter.tsts
// 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 getPosition = (): ScrollRestore => {
  return {
    windowScroll: windowY.current,
    virtualScroll: windowScroller.current?.state.scrollTop || 0,
  };
};
VirtualScrollContainer.tsxtsx
// NOTE: 스크롤을 한번 세팅한 다음 값을 없애야 정상적으로 작동함
useEffect(() => {
  if (!isUseForceScroll.current && (scrollY || virtualScrollY)) {
    isUseForceScroll.current = true;
    if (virtualScrollY) {
      setInitScrollY(() => Math.floor(virtualScrollY));
    } else {
      if (windowScroll && scrollY) {
        setTimeout(() => window.scrollTo({ top: Math.floor(scrollY) }), 100);
      }
    }
  }
}, [scrollY, virtualScrollY]);

채널 상세는 탭마다 목록이 달라서 탭별로 따로 저장합니다. 화면을 떠나는 routeChangeStart 시점에 저장하고 목록 아래의 “전체 보기” 영역이 보이던 상태면 그 높이를 보정값으로 함께 둡니다.

구현 코드: ChannelFeed.tsx
ChannelFeed.tsxtsx
useEffect(() => {
  const saveCacheDataHandler = () => {
    const rowsHeight = getRowHeight();
    const position = getPosition();
    const scrollY = position.windowScroll;
    const virtualScrollY = position.virtualScroll;

    cacheSlice.saveCacheData<CacheData['channel']>({
      tabs:
        tabTitle === CHANNEL_TAB_MENU.POST
          ? { post: { rowsHeight, scrollY, virtualScrollY, scrollDiffThreshold: scrollDiffThreshold.current } }
          : // information, tokenomics, tips 탭도 같은 형태
            undefined,
    });
  };

  events.on('routeChangeStart', saveCacheDataHandler);
  return () => {
    events.off('routeChangeStart', saveCacheDataHandler);
  };
}, [events]);
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분 뒤 만료되고 전체화면 비디오를 닫을 때와 페이지 맨 아래에서 돌아올 때는 보정값이 따로 필요했습니다. 이때 겪은 문제 열 가지는 react-virtualized 스크롤 복원에 따로 적었습니다.

운영 화면의 조회 조건을 URL query에 두기

문제. 백오피스의 통계와 관리 화면은 검색어, 날짜 범위와 날짜 단위, 여러 필터, 페이지와 크기를 한꺼번에 가집니다. 이 값을 컴포넌트 상태에만 두면 새로고침이나 뒤로가기 뒤에 조회 조건이 사라지고 운영자끼리 화면을 공유할 수도 없습니다.

판단. react-router의 location.search를 조회 조건의 기준으로 삼고 화면은 URL을 읽어서 그립니다. 조건을 바꾸는 모든 동작은 URL을 replace하는 한 함수를 거치게 했습니다.

구현. useRouter 훅이 query string을 읽고 합쳐서 replace합니다. 표의 페이지네이션은 URL의 page와 size를 읽고 바뀌면 다시 URL에 씁니다. 날짜 범위와 단위도 같은 방식으로 URL에 두고 조건이 바뀌면 page를 0으로 되돌립니다.

useRouter.tsts
export const useRouter = <TQueryParams = QueryParams & QueryParamKeys>() => {
  const navigate = useNavigate();
  const location = useLocation();
  const queryParams: TQueryParams = 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, replaceQueryString };
};
useTableProps.tsts
const { queryParams, replaceQueryString } = useRouter<QueryParams['common']>();
const { page, size } = queryParams;

const defaultPaginationProps: TablePaginationConfig = {
  pageSize: _.toNumber(size) || 10,
  current: _.toNumber(page) + 1,
  onChange: (current: number, pageSize: number) => {
    return replaceQueryString({ page: current - 1, size: pageSize });
  },
};
flowchart LR
    URL["location.search"] -->|getQueryString| Q["queryParams"]
    Q --> Table["표 페이지와 크기"]
    Q --> Date["날짜 범위와 단위"]
    Q --> Filter["검색어와 필터"]
    Q --> API["조회 API 요청"]
    Table -->|replaceQueryString| URL
    Date -->|replaceQueryString| URL
    Filter -->|replaceQueryString| URL

결과. 통계와 관리 화면의 조회 조건이 새로고침 뒤에도 남고 URL만 넘기면 같은 화면을 볼 수 있습니다. 이 방식은 이후 HCMS와 SK AX에서 URL 계약으로 발전했습니다.

통계 응답을 화면에 넣기 전에 모양을 확인하기

문제. 포인트, 게시물, 채널, 미션 통계 API는 응답 구조가 서로 다르고 필드가 비거나 null로 오는 일이 잦았습니다. 응답을 그대로 표에 넣으면 빈 값에서 화면이 깨졌습니다.

판단. 응답마다 런타임 타입 가드를 두고 필수 키가 다 있을 때만 화면용 데이터로 변환했습니다. 변환은 한 훅에 모아 어떤 응답이 어떤 표가 되는지 한 곳에서 보이게 했습니다.

구현. 타입 가드는 응답 타입의 키 목록을 그대로 검사합니다.

typeGuards.tsts
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 isMissionsResponse = (data: unknown): data is MissionStatsResponse => {
  const response = data as MissionStatsResponse;

  const requiredKeys: MissionStatsResponseKeys[] = ['total', 'totalByDate', 'totalToEndDate'];

  return requiredKeys.every(key => key in response);
};

결과. 통계 화면이 응답 변경과 빈 값에 덜 흔들렸고 미션 통계에서는 달성 횟수 null 처리와 회원가입 미션 분리처럼 UX 요구를 변환 층에서 받았습니다.

미션 통계에 필터와 엑셀 다운로드 붙이기

문제. 운영자가 기간과 미션 유형별 달성 현황을 보고 보고용으로 내려받아야 했습니다. 대시보드에도 같은 통계가 필요했습니다.

판단. 조회 조건은 URL query에 두고 엑셀은 화면이 보는 ViewModel을 그대로 시트로 바꿨습니다. 회원가입 장르 선택 미션은 다른 미션과 성격이 달라 표에서 분리했습니다.

구현. 기간과 단위, 미션 유형과 빈도 필터를 URL에 두고 필터를 바꾸면 페이지를 0으로 돌립니다. 엑셀은 전체 시트와 미션별 달성 횟수 시트 두 장으로 만들고 대시보드에도 같은 통계와 필터를 붙였습니다.

결과. 운영자가 화면에서 보던 조건 그대로 엑셀을 받습니다.

인기 콘텐츠 관리를 피드와 테마 관리로 다시 묶기

문제. 인기 게시물, 인기 채널, 신규 이벤트 관리가 각각 따로 있어서 홈 피드에 무엇을 어떤 순서로 보여 줄지가 화면마다 흩어져 있었습니다. 2023년 말 운영 방식이 테마 단위로 바뀌면서 구조를 다시 짜야 했습니다.

판단. 게시물, 채널, 랭킹, 이벤트 네 종류의 테마를 같은 틀로 만들었습니다. 테마 목록, 상세, 등록, 수정, 삭제와 테마 아이템 등록이 한 패턴을 따르고 옛 화면은 지웠습니다.

구현. 테마별 라우트와 API를 추가하고 테마 아이템 등록에서 순위 불러오기, 순서 변경 뒤 행 초기화, 삭제 중 비활성화를 처리했습니다. 게시물 테마는 URL을 입력하면 썸네일을 추출합니다. 인기 게시물, 인기 채널, 신규 이벤트 관리 페이지는 제거했습니다.

flowchart TB
    Feed["피드 관리"] --> P["게시물 테마"]
    Feed --> C["채널 테마"]
    Feed --> R["랭킹 테마"]
    Feed --> E["이벤트 테마"]
    P --> Item["테마 아이템: 순위 불러오기, 순서 변경, 삭제"]
    C --> Item
    R --> Item
    E --> Item

결과. 홈 피드의 노출 구성이 테마 한 곳에서 관리됩니다. 네 테마가 같은 틀이라 이후 노출 여부 컬럼 이동이나 sorter 수정 같은 변경이 한 번에 끝났습니다.

백오피스 화면을 공통 표와 폼 위에 세우기

문제. 두 달 사이에 공지, 채널, 광고, 인기 콘텐츠, 직원 게시판, 통계까지 관리 화면을 빠르게 만들어야 했습니다. 화면마다 표와 폼을 따로 짜면 운영 요구가 바뀔 때마다 같은 수정을 여러 곳에서 해야 합니다.

판단. 표의 컬럼 정의, 라우트 경로, API 타입과 endpoint를 한 곳에 모았습니다. 폼 필드 이름과 썸네일 업로드, URL 검색 입력은 공통 부품으로 두고 화면은 조합만 합니다.

구현. 컬럼 정의 파일이 모든 표의 제목과 dataIndex를 가지며 태그와 날짜 변환을 공통 함수로 씁니다. 썸네일 업로드는 presigned URL을 받아 crop 모달을 거친 뒤 올리고 폼 값에는 CDN 주소를 넣습니다. 이 공통 부품은 동료와 함께 다듬었습니다.

Columns.tsxts
export const columnInfo = {
  missions: {
    open: { title: '관리자 노출', dataIndex: 'open' },
    missionLabel: { title: '미션 유형', dataIndex: 'missionLabel' },
    missionType: { title: '퀘스트 유형', dataIndex: 'missionType' },
    missionTitle: { title: '퀘스트 제목', dataIndex: 'missionTitle' },
    rewordPoint: { title: '지급 포인트', dataIndex: 'rewordPoint' },
    achievementCount: { title: '달성 횟수', dataIndex: 'achievementCount' },
  },
};

결과. 공지, 채널, 광고 폼과 인기 콘텐츠 표가 같은 부품을 써서 운영 요구가 바뀔 때 수정 범위가 좁았습니다.

결과와 근거

배운 것