Navigation

EZL Culturepass

경기도민에게 문화 혜택, 문화소비쿠폰, 공연과 전시, 주변 사용처 탐색, 컬처마일을 제공하는 모바일 앱형 서비스와 운영 백오피스입니다. React 웹이 다른 프론트엔드가 만든 Flutter 앱 안에서 동작합니다. 사용자 웹을 혼자 구축했고 운영 백오피스는 후임 2명이 만드는 동안 구조 방향을 잡아 주며 리딩했습니다.

핵심 기여

과제본인 기여확인한 동작
앱 안의 웹 내비게이션Stackflow 도입과 Activity 등록 생성스택 전이와 브릿지 이벤트를 사용자 웹에서 연결
지도와 바텀시트의 간섭보이는 지도 영역으로 조회 bounds 계산시트에 가려진 영역을 조회 조건에서 보정
서로 다른 소셜 인증 경로카카오 ID token과 애플 authorization code의 API 연결웹 인증 처리와 네이티브 앱 담당 범위를 구분

개요

항목내용
기간2025.06 ~ 2026.02
역할사용자 웹 프론트엔드 단독 구축, 운영 백오피스 후임 2명 리딩
인원React 웹 1명(본인). Flutter 앱과 브릿지는 다른 프론트엔드, 백오피스는 후임 2명, 초기 화면 기준은 퍼블리싱 팀, 백엔드는 별도
서비스로그인과 회원가입, 본인인증, 홈, 검색, 컬처 혜택과 콘텐츠, 컬처코멘트, 내 주변 지도, 알림, 마이페이지, 쿠폰함, 컬처마일, 경기도민 인증
링크culturepass.co.kr
사용자 웹 기술React 19, TypeScript 5, Vite 6, Stackflow, React Query, Zustand, React Hook Form, SCSS, @tanstack/react-virtual, oidc-client-ts, GSAP, Naver Maps, FCM
백오피스 기술React 19, React Router 7, React Query v5, Zustand v5, Ant Design 5, Quill v2, @ant-design/charts, xlsx, Storybook, Vitest, Playwright, MSW
공통swagger-typescript-api, Biome, Husky, 네이티브 브릿지

맡은 범위

문제와 해결

코드는 실제 저장소에서 발췌했고 내부 주소, 설정값, 일부 이름은 바꾸거나 뺐습니다.

웹에 없는 스택 내비게이션을 Activity 구조로 세우기

문제. 앱 안에서 도는 웹이라 페이지 이동이 아니라 앱처럼 화면을 쌓고 되돌아가는 전이가 필요했습니다. 인증, 혜택, 지도와 마이페이지가 각각 여러 단계로 나뉘어 화면마다 라우트와 파라미터 타입을 함께 관리해야 했습니다.

판단. 웹에 스택 내비게이션이 없어 직접 찾아 Stackflow를 도입했습니다. 본인 결정입니다. Activity 등록은 사람이 하지 않고 파일에서 읽어 생성하기로 했습니다. 각 Activity 파일의 @route 주석과 Params 인터페이스가 등록 정보입니다.

구현. 생성 스크립트가 src/activity 아래 파일을 훑어 Activity 상수, 라우트 표, 파라미터 타입 레지스터, 컴포넌트 맵을 만듭니다. Stackflow 설정은 생성 결과만 읽고 history sync, 바텀탭, Provider, 디바이스 정보, 앱 정보, 브릿지 내비게이션 플러그인을 얹습니다. 스택을 처음부터 다시 쌓는 useStackReset과 스택 상태를 저장했다가 복원하는 store도 두었습니다.

Activity 파일에서 등록 정보를 뽑는 스크립트ts
// @route 주석과 Params 인터페이스가 등록 정보다. 새 화면은 파일만 만들면 된다
files.forEach((filePath) => {
  const content = fs.readFileSync(filePath, 'utf-8')
  const paramMatches = [...content.matchAll(/export\s+interface\s+(Act\w+Params)\s+{[\s\S]*?}/g)]
  paramMatches.forEach(([_, interfaceName]) => {
    const activityName = interfaceName.replace(/Params$/, '')
    registerLines.push(`  ${activityName}: ${interfaceName},`)
  })
  const componentName = content.match(/const\s+(Act\w+)\s*[:=]/)?.[1]
  if (!componentName) return
  const routeMatch = content.match(/@route\s+([^\s]+)/)
  activityConstLines.push(`  ${toSnakeCase(componentName)}: '${componentName}',`)
  routeMapLines.push(`  ${componentName}: '${routeMatch ? routeMatch[1] : '/'}',`)
})
생성 결과만 읽는 Stackflow 설정ts
const config = defineConfig({
  activities: (Object.keys(ACTIVITY_ROUTES) as Array<keyof typeof ACTIVITY_ROUTES>).map((name) => ({ name, route: ACTIVITY_ROUTES[name] })),
  transitionDuration: 350
})
export const { Stack } = stackflow({
  config,
  components: activityComponents,
  plugins: [
    historySyncPlugin({ config, fallbackActivity: () => ACTIVITIES.MAIN }),
    basicRendererPlugin(),
    basicUIPlugin({ theme: 'cupertino', appBar: { height: '60px', minSafeAreaInsetTop: 'var(--safe-area-top, 0px)' } }),
    pluginDevice(), pluginBridgeNavigation(), pluginAppInfo(), pluginBottomTab(), pluginProvider(), pluginActivityLogger()
  ]
})
flowchart LR
    File["Activity 파일: @route 주석, Params 인터페이스"] -->|생성 스크립트| Gen["생성 파일: ACTIVITIES, ACTIVITY_ROUTES, Register, activityMap"]
    Gen --> Config["Stackflow 설정과 플러그인"]
    Config --> Stack["Stack"]

결과. Activity 등록 정보와 파라미터 타입을 파일에서 생성했습니다. 화면 추가 때 직접 맞춰야 하는 등록 지점을 줄였고 push 호출의 파라미터는 타입으로 검사합니다.

네이티브 브릿지 이벤트를 화면 흐름에 연결하기

문제. 앱에서 딥링크, 푸시 알림, FCM token과 로그아웃 이벤트가 들어오면 웹의 인증 상태와 Activity 스택에 맞게 처리해야 했습니다. 앱과 브릿지는 다른 프론트엔드가 맡았고 저는 사용자 웹에서 그 이벤트를 화면 이동과 데이터 갱신에 연결했습니다.

판단. 동료가 만든 MessagePort 수신과 CustomEvent 전달 구조를 사용했습니다. 포트 등록과 OS 차이는 공통 브릿지에 두고 사용자 웹의 훅에서 로그인 여부, 알림 종류와 이동할 Activity를 판단했습니다. 브릿지 초기화와 통신 모듈 자체를 제 구현으로 묶지 않았습니다.

구현. 푸시 알림의 type마다 이동할 Activity와 파라미터를 연결했습니다. 당첨 결과, 컬처마일 적립과 차감, 1:1 문의 답변, 공지 상세가 각각 다른 화면입니다. FCM token은 브릿지가 전달한 이벤트를 웹 훅에서 받고 access token이 있을 때 디바이스 등록 API로 보냅니다.

푸시 type을 화면 이동으로 바꾸는 훅ts
const handlePushNotification = (event: CustomEvent) => {
  const payloadData = JSON.parse(event.detail.payload.payloadString)
  switch (payloadData.type) {
    case 'BENEFIT_WON': {
      const params = payloadData.pathParameters || {}
      push(ACTIVITIES.MY_BENEFIT_HISTORY_RESULT, { isWinner: params.isWinner, name: params.benefitTitle, tag: params.benefitCategoryName })
      break
    }
    case 'MY_FRIEND_CULTURE_MILE_EARN':
      push(ACTIVITIES.MY_CULTURE_MILE, { filter: 'REFERRAL' })
      break
    case 'MY_INQUIRY_REPLY':
      push(userStore.isLoggedIn ? ACTIVITIES.INQUIRY_PERSONAL : ACTIVITIES.LOGIN, {})
      break
  }
}
sequenceDiagram
    participant App as Flutter 앱
    participant Init as 공통 브릿지(다른 FE)
    participant Win as window
    participant Hook as 사용자 웹의 이벤트 처리
    App->>Init: capturePort(MessagePort)
    Init->>Init: 싱글턴에 포트 저장
    App->>Init: pushNotification(payload)
    Init->>Win: CustomEvent 재전송
    Win->>Hook: 이벤트 수신
    Hook->>Hook: type에 맞는 Activity push

결과. 공통 브릿지를 통해 들어온 딥링크와 푸시를 사용자 웹의 Activity 이동에 연결했습니다. 네이티브 통신 구현과 웹의 인증, 화면 전이 책임을 나눠 설명할 수 있는 구조입니다.

바텀시트에 가려진 지도에서 보이는 영역만 검색하기

문제. 내 주변 화면은 지도 위에 스냅 바텀시트가 올라옵니다. 지도 전체 bounds로 검색하면 시트에 가려진 아래쪽 결과까지 나오고 지도가 준비되기 전에 bounds를 읽으면 값이 없습니다. 위치 권한이 없거나 거부하면 탐색 자체가 막힙니다.

판단. 시트의 스냅 높이로 하단 inset을 계산해 실제로 보이는 영역의 bounds만 검색 범위로 씁니다. bounds는 지도의 idle이나 size_changed 신호를 한 번 받은 뒤 두 프레임 기다려 커밋하고 신호가 안 오면 1.6초 뒤 안전장치로 커밋합니다. 권한은 로그인, 권한 안내 모달, 앱 권한 요청, 실제 켜짐 확인, 위치 약관 동의 순서로 묻고 어느 단계에서 멈추면 현재 위치 기능만 빠집니다.

구현. 마커는 id 기준으로 비교해 사라지는 것만 fade로 지우고 남는 것은 재사용합니다. 목록은 시트 안 스크롤 컨테이너를 가상 목록으로 그립니다. 시트가 중간 높이일 때는 inset의 75%만 반영해 시트 바로 위 결과가 잘리지 않게 했습니다.

보이는 영역의 bounds만 커밋ts
const tryCommitBounds = () => {
  const top = headerElement?.getBoundingClientRect().bottom ?? 0
  const bottom = computeBottomInset(mapInstance, sheetLevel, snapConfig, { ratio: sheetLevel === 'middle' ? 0.75 : 0 })
  let bounds = getViewportBoundsWithInset(mapInstance, bottom, top)
  if (!bounds) bounds = getViewPortBounds(mapInstance)
  if (!bounds) bounds = getFallbackBounds(mapInstance, centerLocation)
  if (!bounds) return false
  liveViewportRef.current = bounds
  if (!searchBounds) setSearchBounds(bounds)
  return true
}
// 지도 신호를 한 번만 받고 두 프레임 뒤에 커밋한다. 신호가 없으면 1.6초 뒤 안전장치
const onOnceSignal = () => { commitAfterStabilized().finally(clearOnce) }
onceIdle = naver.maps.Event.addListener(mapInstance, 'idle', onOnceSignal)
onceSizeChanged = naver.maps.Event.addListener(mapInstance, 'size_changed', onOnceSignal)
const safety = setTimeout(() => { tryCommitBounds(); clearOnce() }, 1600)
권한을 단계별로 묻는 흐름ts
if (!userStore.isLoggedIn) { modalLoginRequest.show({ onConfirm: () => push(ACTIVITIES.LOGIN, {}) }); return }
if (!locationStore.hasLocationPermission) {
  if (!(await modalLocationPermission.show())) return          // 안내 모달
  if (!(await requestLocationPermission()).success) return      // 앱에 권한 요청
  if (!(await isOnLocationPermission()).isOn) return            // 실제로 켜졌는지 확인
  locationStore.setLocationState({ hasLocationPermission: true, isLocationInitialized: false })
}
if (!userStore.locationTermsAgreed) {
  if (!(await bottomSheetLocationPermission.show())) return     // 위치 기반 약관
  mutationUpdateProvisionAgreement.mutate({ params: { userId }, body: { provisionId, provisionType: 'LOCATION_BASED_TERMS' } })
}
const { latitude, longitude } = await getLocation()

결과. 시트 높이를 바꾸거나 지도를 움직여도 보이는 영역의 결과만 나오고 지도 준비 신호를 기다린 뒤 bounds를 계산하도록 했습니다. 권한을 거부해도 목록과 상세 탐색은 유지됩니다.

긴 목록의 위치 복원과 좋아요의 즉시 반영

문제. 혜택, 댓글, 알림, 컬처마일 목록은 길고 상세를 다녀오면 보던 위치로 돌아와야 합니다. 가상 목록은 항목 높이가 측정 전후로 달라져 scrollTop만 저장해서는 같은 자리로 못 돌아갑니다. 좋아요는 누르자마자 목록과 상세 양쪽에 보여야 합니다.

판단. 화면을 떠날 때 첫 가상 항목의 key와 그 안의 offset을 앵커로 저장하고 돌아오면 그 key의 index로 이동한 뒤 offset을 더합니다. 높이가 수렴할 때까지 프레임마다 다시 맞추되 최대 10회에서 멈춥니다. 좋아요는 onMutate에서 목록과 상세 캐시를 먼저 바꾸고 실패하면 둘 다 되돌리며 끝나면 찜 목록만 다시 불러옵니다.

구현. 항목 높이는 key별로 캐시해 estimateSize에 넣고 페이지는 누적 개수와 totalCount를 비교해 다음 페이지를 정합니다.

구현 코드: 앵커 저장과 복원
앵커 저장과 복원ts
// 떠날 때 첫 가상 항목의 key와 항목 안 offset을 저장한다
useEffect(() => {
  const first = virtualItems[0]
  if (!first || !scrollContainer || activity.isActive) return
  anchorRef.current = { key: first.key, offsetInItem: Math.max(0, scrollContainer.scrollTop - first.start) }
}, [activity.isActive])

// 돌아오면 key의 index로 간 뒤 offset만큼 더 내린다. 높이가 수렴할 때까지 최대 10회
const loop = () => {
  restoreAttemptsRef.current += 1
  cancel = tryRestore()
  const totalBefore = virtualizer.getTotalSize()
  requestAnimationFrame(() => {
    const totalDiff = Math.abs(virtualizer.getTotalSize() - totalBefore)
    const miss = Math.abs(scrollContainer.scrollTop - target)
    if ((miss <= EPS && totalDiff <= 0.5) || restoreAttemptsRef.current >= MAX_ATTEMPTS) { restoreInProgressRef.current = false; return }
    loop()
  })
}
좋아요의 낙관적 갱신ts
onMutate: async (variables) => {
  await queryClient.cancelQueries({ predicate: (query) => query.queryKey[0] === COUPON_SERVICE_KEY.BENEFITS })
  const previousData = structuredClone(queryClient.getQueryData(queryKey))
  const previousDetailData = structuredClone(queryClient.getQueryData(apiCouponService.getBenefitDetail.key({ id: variables.id })))
  updateBenefitScrapStatus(variables.id, true) // 목록과 상세 캐시를 먼저 바꾼다
  return { previousData, previousDetailData, queryKey, benefitId: variables.id }
},
onError: (error, _variables, context) => {
  if (context?.previousData) queryClient.setQueryData(context.queryKey, context.previousData)
  if (context?.previousDetailData) queryClient.setQueryData(apiCouponService.getBenefitDetail.key({ id: context.benefitId }), context.previousDetailData)
  if (parseApiError(error).code === PERMISSION_DENIED) push(ACTIVITIES.LOGIN, {})
},
onSettled: () => queryClient.invalidateQueries({ queryKey: apiCouponService.getScraps.key({}), exact: false })

결과. 상세에서 돌아와도 보던 항목이 같은 자리에 있고 좋아요는 누른 즉시 목록과 상세에 반영됩니다. 실패하면 두 캐시가 함께 원래대로 돌아갑니다.

인증 여정을 한 흐름으로 잇기

문제. 일반 로그인, 카카오와 애플 소셜 로그인, NICE 본인인증, 14세 미만 보호자 인증, 계정 잠금, 자동 로그인, 디바이스와 FCM 등록이 한 여정이어야 했습니다. 화면마다 따로 처리하면 잠금 횟수나 자동 로그인 값이 어긋납니다.

판단. 카카오는 oidc-client-ts의 팝업 인증에서 받은 ID token을 서비스 인증 API로 보냅니다. 애플은 네이티브 앱에서 받은 authorization code를 같은 서비스 API에 전달합니다. 웹 설정과 네이티브 인증 경로를 구분했습니다. 잠금은 서버가 내려 주는 실패 횟수를 그대로 보여 주고 5회에 도달하면 잠금 화면으로 보냅니다. 자동 로그인 값은 앱 저장소에 남겨 다음 실행에서 씁니다. 14세 미만은 보호자 NICE 인증 Activity를 거칩니다.

구현. NICE 인증은 trigger ref를 가진 공용 컴포넌트로 두고 보호자 여부 옵션만 다르게 넘깁니다. FCM token은 브릿지의 CustomEvent를 받은 웹 훅에서 access token을 확인한 뒤 디바이스 등록 API로 보냅니다.

잠금 횟수와 자동 로그인ts
if (toNumber(cause.data.body.lockCount) >= 5) {
  push(ACTIVITIES.LOGIN_ACCOUNT_LOCK, {}, { animate: true })
  return
}
toastStore.show(`잘못된 아이디 또는 비밀번호입니다. 다시 시도해주세요. (${cause.data.body.lockCount}/5)`, 'error')
// 성공하면 자동 로그인 선택값을 사용자 상태와 앱 저장소에 함께 남긴다
const autoLogin = form.getValues('autoLogin')
userStore.setUser({ ...userInfo, autoLogin })
await setStorageValue('autoLogin', autoLogin)
provider별 UserManagerts
const providerConfigs: Record<OAuthProvider, UserManagerSettings> = {
  KAKAO: { authority: env.KAKAO_AUTHORITY, client_id: env.KAKAO_CLIENT_ID, redirect_uri: env.KAKAO_REDIRECT_URI, scope: env.KAKAO_SCOPE, loadUserInfo: true, userStore },
  APPLE: { authority: env.APPLE_AUTHORITY, client_id: env.APPLE_CLIENT_ID, redirect_uri: env.APPLE_REDIRECT_URI, scope: env.APPLE_SCOPE, loadUserInfo: true, userStore }
}
export const getUserManager = (provider: OAuthProvider): UserManager => {
  if (!userManagerInstances.has(provider)) userManagerInstances.set(provider, new UserManager(providerConfigs[provider]))
  return userManagerInstances.get(provider)!
}

결과. 소셜 인증에서 받은 값은 provider별 요청으로 구분하고 성공 뒤 서비스 로그인 상태와 디바이스 등록 흐름에 연결했습니다.

서비스 넷의 Swagger를 생성 코드와 보안 맵으로 받기

문제. 인증, 쿠폰, 콘텐츠, 지원 서비스 넷의 Swagger가 자주 바뀌었습니다. 어떤 endpoint가 인증을 요구하는지 손으로 관리하면 빠뜨립니다.

판단. 스크립트가 환경별로 서비스 넷의 스펙을 받아 하나로 합치고 swagger-typescript-api로 라우트와 타입을 생성합니다. 인증 필요 여부도 스펙에서 뽑아 보안 맵으로 생성합니다. 생성 파일은 손대지 않습니다.

구현. 스펙 디렉터리는 받을 때마다 비우고 다시 만듭니다. 보안 맵은 method와 path를 키로 두고 요청 때 조회합니다.

환경별 스펙 수집ts
const SERVICE_ENDPOINTS = {
  'auth-service': '/api/auth/v3/api-docs',
  'coupon-service': '/api/coupon/v3/api-docs',
  'content-service': '/api/content/v3/api-docs',
  'support-service': '/api/support/v3/api-docs'
}
const getSwaggerUrls = (env = 'dev') => {
  const baseUrl = ENV_BASE_URLS[env] // 환경별 API 주소
  return Object.fromEntries(Object.entries(SERVICE_ENDPOINTS).map(([name, endpoint]) => [name, `${baseUrl}${endpoint}`]))
}
스펙에서 생성한 보안 맵ts
// 자동 생성 파일. 생성 시점 기준 endpoint 100개
const SECURITY_MAP: Record<string, SecurityInfo> = {
  'POST:/users/withdraw': { path: '/users/withdraw', method: 'POST', requiresAuth: true, operationId: 'withdraw' },
  'POST:/users/devices/{deviceId}': { path: '/users/devices/{deviceId}', method: 'POST', requiresAuth: true, operationId: 'registerOrUpdateConnectionDevice' },
  'POST:/provision-agreements/{userId}': { path: '/provision-agreements/{userId}', method: 'POST', requiresAuth: true, operationId: 'updateProvisionAgreement' }
}

결과. 스펙이 바뀌면 스크립트 한 번으로 라우트, 타입, 보안 맵이 같이 갱신됩니다. 에러 코드와 응답 변경은 생성 결과 위의 서비스 층에서 맞췄습니다.

정적 마크업을 서비스 화면으로 다시 짜기

문제. 초기 화면 기준은 퍼블리싱 팀의 정적 산출물이었습니다. 그대로 옮기면 인증, 권한, 예외, 브릿지, 데이터 갱신이 없는 화면이 됩니다.

판단. 화면 기준은 참고하되 구조와 상호작용은 요구사항과 API 흐름에 맞게 다시 짰습니다.

구현. 홈, 혜택, 콘텐츠, 알림, 마이페이지의 화면 구조를 재구성하고 인증 상태, 권한, 예외 처리, 브릿지 이벤트, 캐시 갱신을 얹었습니다. 서버 오류는 React Query의 QueryCache에서 한 번에 받아 시스템 오류일 때만 공용 모달을 띄우고 이전 화면으로 돌립니다.

서버 오류를 한 곳에서 받기ts
new QueryClient({
  queryCache: new QueryCache({
    onError: (error) => {
      if (parseApiError(error).code === API_ERROR_CODES.A0001) modalQueryError.show() // 시스템 오류만 공용 모달
    }
  }),
  defaultOptions: { queries: { retry: (count, error) => (parseApiError(error).code === API_ERROR_CODES.A0001 ? count < 3 : count < 1), staleTime: 60 * 1000 } }
})

결과. 화면이 정적 산출물의 복사본이 아니라 서비스 수준으로 완성됐습니다.

경기도민 인증의 외부 검증 흐름과 출시 뒤 장애

문제. 경기도민 인증은 외부 주소 검증 서비스를 새 창으로 띄우고 결과를 message 이벤트로 받습니다. 팝업이 차단되거나 사용자가 창을 닫으면 결과가 오지 않습니다. 출시 뒤 몇 주의 대응 기간에는 그 외부 서버 장애로 인증이 되지 않는 일이 있었습니다.

판단. 팝업 참조와 닫힘 감시 interval, message 핸들러를 ref로 들고 성공과 실패를 별도 Activity로 분리했습니다. 외부 장애는 프론트엔드가 막을 수 없어 실패 화면과 다시 시도 흐름으로 받아 냈고 고객 응대는 고객사 CS가 맡았습니다. 구축 프로젝트라 운영 장애를 직접 담당하지는 않았습니다.

구현. 시군구 선택과 주민등록번호 뒷자리, 고유식별번호 제공 동의를 React Hook Form으로 검증한 뒤 팝업을 엽니다. 소프트 키보드가 올라오면 버튼 영역 높이를 다시 재서 입력 필드가 가려지지 않게 했습니다.

외부 검증 팝업의 상태ts
const COOCON_POPUP_CONFIG = {
  NAME: 'coocon_popup',
  FEATURES: 'width=500,height=800,scrollbars=yes,resizable=yes',
  POPUP_BLOCKED_MESSAGE: '팝업이 차단되었습니다. 팝업 차단을 해제해주세요.'
} as const
const popupRef = useRef<Window | null>(null)
const popupCheckIntervalRef = useRef<number | null>(null)       // 창이 닫혔는지 감시
const messageHandlerRef = useRef<CooconMessageEventHandler | null>(null) // 결과 message 수신

결과. 검증 결과가 성공과 실패 화면으로 나뉘어 사용자가 다음 행동을 알 수 있습니다. 외부 장애는 막지 못했지만 실패가 빈 화면으로 끝나지는 않았습니다.

백오피스 구조를 리딩하기

문제. 운영 백오피스는 혜택, 쿠폰, 제휴사, 배너, 팝업, 공지, 문의, 회원, 정산, 통계, 관리자 권한을 다루고 후임 2명이 처음부터 만들어야 했습니다.

판단. 직전 프로젝트에서 제가 만든 LG Shield의 CMS 구조를 출발점으로 복사해 시작하게 했습니다. React Router 7의 라우트 설정에 id, label, 메뉴 노출 여부를 함께 적어 메뉴와 브레드크럼이 설정에서 나오게 하고 Swagger 생성, 공통 폼과 테이블과 에디터, URL query 기반 운영 상태를 같은 방식으로 가져갔습니다.

구현. 라우트 설정은 인증 레이아웃, 2FA 로그인, 비밀번호 설정부터 시작해 각 운영 영역으로 확장됐습니다. 구현은 후임 2명이 했고 저는 구조와 패턴의 방향을 잡고 검토했습니다.

설정에서 메뉴가 나오는 라우트ts
export type ExpandedRouteConfigEntry = Omit<RouteConfigEntry, 'children' | 'id'> & {
  id: ROUTE_IDS
  label?: string
  menu?: boolean
  dynamicLabel?: boolean
  children?: ExpandedRouteConfigEntry[]
}
export const ROUTES: ExpandedRouteConfigEntry[] = [
  { id: ROUTE_IDS.INDEX, label: '루트', path: '/', index: true, file: './routes/index.tsx' },
  {
    id: ROUTE_IDS.LAYOUT_AUTH, file: '../layouts/layout-auth.tsx', label: '인증 레이아웃',
    children: [
      { id: ROUTE_IDS.LOGIN_PAGE, label: '로그인', path: 'login', file: './routes/login-page.tsx' },
      { id: ROUTE_IDS.TWOFA_LOGIN_PAGE, label: '2FA 로그인', path: '2fa-login', file: './routes/2fa-login-page.tsx' }
    ]
  }
]

결과. 백오피스의 운영 메뉴와 화면이 한 라우트 설정을 기준으로 구성됐고 사용자 서비스와 같은 Swagger 생성 흐름을 씁니다.

결과와 근거

배운 것