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, 네이티브 브릿지 |
맡은 범위
- 개발 기반. 환경 변수 흐름, Swagger 기반 API 갱신 스크립트, Stackflow Activity 생성 스크립트, 이미지와 폰트 경로 정리 스크립트를 초기에 정리했습니다.
- 인증과 회원 여정. 로그인, 회원가입, 아이디와 비밀번호 찾기, 카카오와 애플 소셜 로그인, NICE 본인인증, 14세 미만 보호자와 자녀 분기, 계정 잠금, 자동 로그인, 디바이스와 FCM token 등록.
- 홈. 배너, 이번 주 포커스 컬처 혜택, 컬처 콘텐츠, 컬처코멘트, 팝업과 약관과 앱 권한 모달.
- 내 주변 지도. Naver Map 위에서 위치 권한, 뷰포트 계산, 마커, 바텀시트 스냅, 검색과 최근 검색어, 목록과 상세 전환.
- 혜택과 콘텐츠. 목록, 상세, 좋아요, 신청, 댓글, 결과를 React Query 캐시와 가상 목록으로 연결했습니다.
- 마이페이지. 혜택 신청 내역, 쿠폰함, 컬처코멘트, 컬처마일, FAQ와 공지와 1:1 문의, 회원정보 변경, 탈퇴, Coocon 주소 검증 기반 경기도민 인증.
- 네이티브 브릿지 연결. 앱이 보내는 앱 재개, 위치와 푸시 권한, 딥링크, 푸시 알림, FCM 등록, 로그아웃 이벤트를 화면 이동과 데이터 갱신에 연결했습니다. 브릿지 자체는 앱 쪽 프론트엔드가 만들었습니다.
- 백오피스 리딩. 후임 2명이 맡은 React Router 7 기반 백오피스의 메뉴와 정보 구조, 공통 폼과 테이블과 검색 패턴 방향을 잡아 줬습니다.
- API 변경 대응. 백엔드 Swagger 변경이 잦아 스펙 갱신, 에러 코드, 응답 변경을 계속 반영했습니다.
문제와 해결
코드는 실제 저장소에서 발췌했고 내부 주소, 설정값, 일부 이름은 바꾸거나 뺐습니다.
웹에 없는 스택 내비게이션을 Activity 구조로 세우기
문제. 앱 안에서 도는 웹이라 페이지 이동이 아니라 앱처럼 화면을 쌓고 되돌아가는 전이가 필요했습니다. 인증, 혜택, 지도와 마이페이지가 각각 여러 단계로 나뉘어 화면마다 라우트와 파라미터 타입을 함께 관리해야 했습니다.
판단. 웹에 스택 내비게이션이 없어 직접 찾아 Stackflow를 도입했습니다. 본인 결정입니다. Activity 등록은 사람이 하지 않고 파일에서 읽어 생성하기로 했습니다. 각 Activity 파일의 @route 주석과 Params 인터페이스가 등록 정보입니다.
구현. 생성 스크립트가 src/activity 아래 파일을 훑어 Activity 상수, 라우트 표, 파라미터 타입 레지스터, 컴포넌트 맵을 만듭니다. Stackflow 설정은 생성 결과만 읽고 history sync, 바텀탭, Provider, 디바이스 정보, 앱 정보, 브릿지 내비게이션 플러그인을 얹습니다. 스택을 처음부터 다시 쌓는 useStackReset과 스택 상태를 저장했다가 복원하는 store도 두었습니다.
// @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] : '/'}',`)
})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로 보냅니다.
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%만 반영해 시트 바로 위 결과가 잘리지 않게 했습니다.
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)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를 비교해 다음 페이지를 정합니다.
구현 코드: 앵커 저장과 복원
// 떠날 때 첫 가상 항목의 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()
})
}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로 보냅니다.
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)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를 키로 두고 요청 때 조회합니다.
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}`]))
}// 자동 생성 파일. 생성 시점 기준 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에서 한 번에 받아 시스템 오류일 때만 공용 모달을 띄우고 이전 화면으로 돌립니다.
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으로 검증한 뒤 팝업을 엽니다. 소프트 키보드가 올라오면 버튼 영역 높이를 다시 재서 입력 필드가 가려지지 않게 했습니다.
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명이 했고 저는 구조와 패턴의 방향을 잡고 검토했습니다.
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 생성 흐름을 씁니다.
결과와 근거
- 사용자 웹을 단독 구축했고 Activity 등록 정보는 생성 스크립트로 관리했습니다.
- 앱 이벤트가 브릿지 한 곳에서 CustomEvent로 들어와 Activity 전이로 바뀝니다.
- 내 주변 지도는 보이는 영역만 검색하고 권한을 거부해도 탐색이 이어집니다.
- 긴 목록은 상세를 다녀와도 같은 자리로 돌아오고 좋아요는 즉시 반영됩니다.
- 운영 백오피스는 후임 2명이 구현했고 저는 메뉴, 라우트와 공통 패턴의 방향을 잡아 검토했습니다.
배운 것
- 등록 정보와 파라미터 타입을 화면 파일에서 생성해 수동으로 맞춰야 하는 지점을 줄였습니다.
- 앱 안의 웹은 페이지가 아니라 상태 전이로 봐야 합니다. 브릿지 이벤트를 화면 전이로 바꾸는 한 층이 전체 복잡도를 받아 줬습니다.
- 가상 목록의 위치 복원은 scrollTop이 아니라 항목 key와 offset을 저장해야 합니다. 높이는 복원 중에도 바뀝니다.
- 외부 서비스 장애는 막을 수 없지만 실패 화면과 다시 시도 흐름은 미리 둘 수 있습니다.