Navigation

EZL Culturepass

경기도민에게 문화 혜택, 문화소비쿠폰, 공연과 전시 콘텐츠, 주변 사용처 탐색, 컬처마일을 제공한 모바일 앱형 서비스입니다. 다른 FE가 만든 Flutter 앱의 웹뷰 안에서 도는 React 사용자 웹을 설계하고 개발했습니다. 스택 라우팅, 소셜 로그인과 외부 인증 팝업, 인증 만료 처리, 명세 기반 API 계층, 네이버맵 API 기반 지도, 배포 설정을 맡았고 운영 백오피스는 FE 2명이 구현하도록 정보 구조와 공통 UI 패턴의 설계 가이드를 제공했습니다.

핵심 기여

개요

항목내용
기간2025.05 ~ 2025.12
역할사용자 웹 설계와 개발, 운영 백오피스 FE 설계 가이드
협업과 인원사용자 웹 FE 1명(본인, 초기 Stackflow 설정과 로깅 등 일부에 동료 FE 참여), Flutter 앱과 공통 브리지 FE 1명, 운영 백오피스 FE 2명, 퍼블리싱과 BE 별도
서비스로그인과 회원가입, 본인인증, 홈, 검색, 컬처 혜택과 콘텐츠, 컬처코멘트, 내 주변 지도, 알림, 마이페이지, 쿠폰함, 컬처마일, 경기도민 인증
사용자 웹 기술React, TypeScript, Vite, Stackflow, React Query, Zustand, React Hook Form, SCSS, @tanstack/react-virtual, oidc-client-ts, Naver Maps, swagger-typescript-api, Nginx, Docker, Kubernetes
운영 백오피스 기술React Router, React Query, Zustand, Ant Design, Quill, Storybook, Vitest, Playwright, MSW
링크서비스 운영 종료로 링크 없음

맡은 범위

본인 구현

리딩과 협업

동료 구현

문제와 해결

코드는 실제 저장소에서 제가 작성한 부분만 발췌했고 내부 주소, 설정값, 일부 이름은 줄이거나 뺐습니다.

웹뷰에서 화면을 쌓고 되돌아가는 구조

앱 안에서 도는 웹이라 주소만 바뀌는 페이지 이동이 아니라 앱처럼 화면을 쌓고 되돌아가는 전이가 필요했습니다. 인증, 혜택, 지도, 마이페이지가 각각 여러 단계로 나뉘어 최종 등록 Activity는 74개였습니다. 목록에서 상세를 다녀오거나 혜택 상세에서 로그인을 거쳐 돌아오는 흐름이 많아 이전 화면의 입력과 스크롤 위치가 남아 있어야 했습니다.

웹 라우터로 페이지를 바꾸면 이전 화면은 언마운트되고 뒤로가기는 브라우저 히스토리에만 기댑니다. 웹에서 스택 내비게이션을 제공하는 라이브러리를 찾아 Stackflow 도입을 결정했습니다. Activity를 스택에 쌓아 이전 화면을 남겨 두고 history sync 플러그인으로 브라우저 히스토리와 스택을 맞추는 방식입니다.

Activity 등록은 동료 FE가 만든 생성 스크립트를 썼습니다. Activity 파일의 @route 주석과 Params 인터페이스에서 Activity 상수, 라우트 표, 파라미터 타입을 만들어 push 호출의 파라미터를 타입으로 검사합니다. 저는 그 위에 앱 실행 환경을 붙이는 플러그인과 흐름 제어를 더했습니다. 디바이스 정보와 앱 정보 플러그인은 제가 Stack을 감싸는 자리와 store를 만들었고 브리지 요청의 세부는 앱 FE가 채웠습니다. 앱 바에는 앱이 알려 준 상태바 높이를 CSS 변수로 받아 안전 영역 여백을 줬습니다.

flowchart LR
    File["Activity 파일: @route 주석, Params 인터페이스"] -->|동료의 생성 스크립트| Gen["Activity 상수, 라우트 표, 파라미터 타입"]
    Gen --> Config["Stackflow 설정"]
    Config --> Mine["본인: 디바이스 정보, 앱 정보 플러그인, 앱 바 안전 영역"]
    Config --> AppFE["앱 FE: 브리지 내비게이션 플러그인"]
    Config --> Peer["동료 FE: 화면 이동 로깅, 점검 화면 전환"]
    Mine --> Stack["Stack"]
    AppFE --> Stack
    Peer --> Stack

회원가입 완료, 쿠콘 주소 인증 결과, 경기도민 인증 결과, 비밀번호 변경처럼 이전 단계로 돌아가면 안 되는 흐름도 있었습니다. 결과 화면에서 뒤로가기를 누르면 이미 끝난 인증 단계가 다시 열립니다. 스택을 맨 아래 하나만 남기고 한 번에 꺼낸 뒤 그 자리를 목적 화면으로 바꾸는 훅을 두었습니다. 이런 결과 화면과 애플 로그인 콜백이 이 훅을 쓰고 나중에 동료가 만든 점검 화면 전환 플러그인도 같은 훅을 씁니다.

이전 스택을 비우고 목적 화면 하나로 교체ts
export const useStackReset = () => {
  const stack = useStack()
  const { replace, pop } = useFlow()

  return <K extends RegisteredActivityName>(
    activityName: K,
    activityParams: InferActivityParams<K>,
    options?: { animate?: boolean }
  ) => {
    const activities = stack.activities.length
    // 맨 아래 하나만 남기고 애니메이션 없이 한 번에 꺼낸다
    if (activities > 1) {
      pop(activities - 1, { animate: false })
    }
    // 남은 자리를 목적 화면으로 바꾸면 뒤로가기로 끝난 단계에 돌아가지 않는다
    replace(activityName, activityParams, options ?? { animate: false })
  }
}

화면을 추가할 때 등록 지점을 손으로 맞추지 않았고 결과 화면의 뒤로가기 문제는 한 훅으로 처리했습니다. 등록 생성은 동료의 기반이라 여기서는 사용한 구조로만 설명합니다.

앱 FE와 나눈 브리지 이벤트의 경계

위치와 알림 권한, 기기 정보, 푸시와 딥링크, 앱 재개는 앱이 웹으로 이벤트를 보내거나 웹이 앱에 작업을 요청해야 하는 기능이었습니다. Flutter 앱과 공통 브리지는 앱 FE가 맡았습니다. 브리지는 MessagePort로 받은 메시지를 CustomEvent로 다시 내보내고 웹이 부를 작업은 Promise를 돌려주는 함수로 열어 두었습니다. 푸시와 딥링크를 받아 type별로 Activity를 고르는 훅, FCM 토큰 등록, 앱 재개 처리도 앱 FE의 코드입니다.

제가 맡은 쪽은 이 작업 함수를 사용자 화면에서 부르는 순서와 멈추는 지점이었습니다. 위치 권한이 대표적입니다. 내 주변 화면의 현재 위치 버튼은 로그인, 웹의 안내 모달, 앱의 권한 요청, 실제 켜짐 확인, 위치 기반 서비스 약관 동의를 차례로 거쳐야 했습니다. 앱의 권한 요청 결과와 별도로 위치가 실제로 켜졌는지 한 번 더 확인했습니다. 어느 단계에서 거부하든 그 자리에서 멈추고 현재 위치 기능만 빠집니다. 목록과 지도 탐색은 기본 중심 좌표로 계속 쓸 수 있습니다.

위치 권한을 단계별로 묻는 흐름ts
if (!userStore.isLoggedIn) {
  modalLoginRequest.show({ onConfirm: () => push(ACTIVITIES.LOGIN, {}) })
  return
}
if (!locationStore.hasLocationPermission) {
  if (!(await modalLocationPermission.show())) return        // 웹의 안내 모달
  if (!(await requestLocationPermission()).success) return   // 브리지로 앱에 권한 요청
  const { isOn } = await isOnLocationPermission()            // 실제로 켜졌는지 다시 확인
  if (!isOn) return
  locationStore.setLocationState({ hasLocationPermission: isOn, isLocationInitialized: false })
}
if (!userStore.locationTermsAgreed) {
  if (!(await bottomSheetLocationPermission.show())) return  // 위치 기반 서비스 약관
  mutationUpdateProvisionAgreement.mutate({
    params: { userId: toString(userStore.id)! },
    body: { provisionId: responseActiveProvisions.data?.id!, provisionType: 'LOCATION_BASED_TERMS' }
  })
}
const { latitude, longitude } = await getLocation()
sequenceDiagram
    participant Screen as 사용자 웹 화면(본인)
    participant Bridge as 공통 브리지(앱 FE)
    participant App as Flutter 앱
    participant API as 서비스 API
    Screen->>Screen: 로그인 확인, 안내 모달
    Screen->>Bridge: requestLocationPermission()
    Bridge->>App: 권한 요청 메시지
    App-->>Bridge: 허용 여부
    Bridge-->>Screen: success
    Screen->>Bridge: isOnLocationPermission()
    Bridge-->>Screen: isOn
    Screen->>API: 위치 기반 서비스 약관 동의
    Screen->>Bridge: getLocation()
    Bridge-->>Screen: 위도, 경도

첫 실행 때는 알림, 사진, 위치 권한을 한 안내 모달에서 차례로 요청하고 위치 결과를 위치 상태 store에 넣었습니다. 기기 정보 플러그인이 받은 테넌트 값은 API 클라이언트가 인증 요청의 헤더로 싣습니다. 푸시 경로는 앱 FE의 훅을 그대로 두고 쿠폰함처럼 화면이 늘어날 때 대상 Activity와 파라미터를 맞췄습니다.

브리지 통신과 OS 차이는 앱 FE의 모듈에 남고 사용자 웹은 작업 함수의 결과만 보고 다음 단계를 정했습니다. 푸시 분기와 FCM 토큰 등록은 앱 FE의 구현이라 제 기여는 화면 쪽 경로와 권한 흐름으로 한정합니다.

공급자마다 다른 인증 결과를 한 회원 여정으로

일반 로그인, 카카오와 애플 소셜 로그인, NICE 본인인증, 14세 미만 보호자 인증, 계정 잠금이 하나의 회원 여정이어야 했습니다. 공급자마다 돌려주는 값과 실행 환경이 달랐습니다. 카카오는 웹에서 OIDC로 인증해 ID 토큰을 받고 애플은 iOS 앱의 네이티브 로그인에서 authorization code를 받습니다. 둘 다 공급자 타입과 함께 서비스의 소셜 로그인 API로 보내되 웹 OIDC 설정 하나로 두 경로를 묶지 않았습니다. 애플 버튼은 iOS 앱 안에서 브리지가 연결됐을 때만 동작합니다.

카카오는 처음에 전체 페이지 리다이렉트로 구현했습니다. 웹뷰가 카카오 인증 페이지로 넘어갔다 돌아오면 SPA가 새로 뜨면서 Stackflow 스택이 비워집니다. 그래서 떠나기 전에 스택의 Activity 목록을 localStorage에 저장하고 콜백 화면에서 첫 화면은 replace로, 나머지는 push로 애니메이션 없이 다시 쌓았습니다. 이 방식에서는 복원 시점과 실패 처리를 콜백 화면이 모두 떠안았습니다.

리다이렉트에서 돌아와 스택을 다시 쌓던 방식ts
restoreActivities: ({ push, replace }) => {
  const activities = get().activities
  if (activities.length === 0) return
  // 저장해 둔 목록의 첫 화면으로 바닥을 바꾸고 나머지를 순서대로 쌓는다
  const [first, ...rest] = activities
  replace(first.name as RegisteredActivityName, first.params, { animate: false })
  forEach(rest, ({ name, params }) => {
    push(name as RegisteredActivityName, params, { animate: false })
  })
}

2025-08-04에 oidc-client-ts의 팝업 인증으로 바꿨습니다. 로그인 화면이 그대로 남아 있으니 복원 과정이 필요 없어졌고 콜백 화면은 팝업 콜백만 처리하게 됐습니다. 애플의 웹 리다이렉트 경로에는 저장과 복원 방식이 남아 있습니다.

공급자마다 다른 값을 같은 서비스 API로ts
// 카카오: 웹의 OIDC 팝업에서 받은 ID 토큰
const handleKakaoLoginButtonClick = async () => {
  if (mutationSocialLogin.isPending) return
  const manager = getUserManager(PROVIDER.KAKAO)
  try {
    const user = await manager.signinPopup()
    if (!user || !user.id_token) throw new Error()
    niceStore.setProvider(PROVIDER.KAKAO)
    mutationSocialLogin.mutate({ providerType: PROVIDER.KAKAO, token: user.id_token })
  } catch (error) {
    logger.error(error)
  }
}
// 애플: iOS 앱 안에서만 네이티브 호출로 받은 authorization code
const handleAppleLoginButtonClick = async () => {
  if (mutationSocialLogin.isPending) return
  const isNative = SimpleBridge.getInstance().isConnected()
  if (!(isIOS() && isNative)) return
  try {
    const result = await SimpleTasks.signInWithApple()
    if (!result?.success || !result.authorizationCode) throw new Error()
    niceStore.setProvider(PROVIDER.APPLE)
    mutationSocialLogin.mutate({ providerType: PROVIDER.APPLE, authorizationCode: result.authorizationCode })
  } catch (error) {
    logger.error(error)
  }
}

가입되지 않은 소셜 계정이면 서버가 A3044를 돌려줍니다. 이때 providerId를 보관하고 소셜 회원가입으로 보내 NICE 본인인증을 거치게 했습니다. 14세 미만이면 보호자와 자녀가 각각 NICE 인증을 하고 두 요청 번호를 따로 저장합니다. 비밀번호 실패가 쌓이면 서버가 실패 횟수를 내려 주는데 입력란 오류에 그 횟수를 그대로 보여 주고 5회부터는 잠금 화면으로 보냈습니다. 자동 로그인 선택값은 사용자 store에 넣었고 앱 저장소 기록과 앱 시작 때의 로그아웃 처리는 앱 FE가 맡았습니다.

flowchart TD
    Kakao["카카오: 웹 OIDC 팝업의 ID 토큰"] --> Social["서비스 소셜 로그인 API"]
    Apple["애플: iOS 앱의 authorization code"] --> Social
    Social -->|성공| Done["토큰 저장, 사용자 정보 조회"]
    Social -->|A3044 미가입 소셜 계정| Signup["providerId 보관, 소셜 회원가입"]
    Social -->|A3011 비활성 계정| Suspend["비활성 계정 화면"]
    Signup --> Nice["NICE 본인인증"]
    Nice -->|14세 미만| Guardian["보호자와 자녀 NICE 인증"]
    Nice --> Info["가입 정보 입력"]
    Guardian --> Info
구현 코드: 실패 횟수와 미가입 소셜 계정 분기
로그인 API와 소셜 로그인 API의 오류 분기ts
// 로그인 API: 실패가 쌓이면 서버가 lockCount를 내려 준다
if (errorInfo.code === API_ERROR_CODES.A3012) {
  if (toNumber(cause.data.body.lockCount) >= 5) {
    push(ACTIVITIES.LOGIN_ACCOUNT_LOCK, {}, { animate: true })
  }
  form.setError('loginId', { type: 'manual' })
  form.setError('password', {
    type: 'manual',
    message: `잘못된 아이디 또는 비밀번호입니다. 다시 시도해주세요. (${cause.data.body.lockCount}/5)`
  })
  return
}
// 소셜 로그인 API: 가입되지 않은 소셜 계정이면 providerId를 들고 회원가입으로
if (errorInfo.code === API_ERROR_CODES.A3044) {
  niceStore.setProviderId(cause.data.body.providerId)
  push(ACTIVITIES.SIGNUP, { isSocial: true }, { animate: true })
  return
}

소셜 인증에서 받은 값은 공급자 타입으로 구분해 같은 API로 보냈고 그 응답 코드가 가입, 잠금, 비활성 화면으로 이어집니다. 팝업으로 바꾼 뒤에는 카카오 로그인이 스택 복원에 기대지 않습니다.

본인인증 팝업이 보낸 결과를 믿는 조건

NICE 본인인증과 쿠콘 주소 인증은 외부 인증 화면을 새 창으로 띄우고 결과를 원래 화면이 받아야 했습니다. 흐름은 같습니다. 서버가 암호화한 요청값을 만들어 주면 웹이 이름을 붙인 빈 창을 열고 숨긴 form을 그 창으로 POST합니다. 외부 인증이 끝나면 팝업은 우리 도메인의 반환 경로로 돌아오고 반환 화면이 window.opener.postMessage로 부모 창에 결과를 알립니다.

message 이벤트는 이 창의 참조를 가진 다른 창이나 프레임도 보낼 수 있어 인증 결과로 믿으려면 조건이 필요했습니다. 발신 출처가 현재 출처와 같은지, 발신 창이 방금 연 팝업인지, 데이터가 약속한 타입과 모양인지를 차례로 검사해 셋을 모두 통과한 메시지만 처리했습니다. 반환 화면도 targetOrigin을 자기 출처로 고정해 다른 출처의 창에는 결과를 보내지 않습니다.

메시지에는 다음 화면을 정할 action만 담았습니다. 인증 결과 자체는 반환 화면이 서버 API로 확인합니다. 부모 창은 인증 준비 때 받은 요청 번호를 localStorage에 저장하는 store에 넣어 둡니다. 같은 출처의 반환 화면이 그 번호로 사용자 검증, 아이디 찾기, 비밀번호 찾기, 휴대폰 번호 변경 API를 호출하고 결과에 맞는 action을 보냅니다. 부모 창은 action을 Activity 이동으로 바꿉니다. 이미 가입된 계정, 정지된 계정, 계정 없음, 아이디 불일치가 각각 다른 화면입니다.

사용자가 팝업을 그냥 닫으면 메시지가 오지 않아 로딩이 끝나지 않습니다. 300ms마다 팝업의 closed를 확인해 닫혔으면 리스너와 타이머를 지우고 로딩을 내렸습니다. 팝업이 차단되면 안내를 띄우고 멈춥니다.

결과 메시지를 처리하는 세 가지 조건ts
const popup = window.open('', NICE_CONFIG.POPUP.NAME, NICE_CONFIG.POPUP.FEATURES)
if (!popup) {
  alert(NICE_CONFIG.POPUP_BLOCKED_MESSAGE)
  return
}
popupRef.current = popup
setIsVerifying(true)

const onWindowMessage: NiceMessageEventHandler = (event) => {
  if (event.origin !== window.location.origin) return // 다른 출처 무시
  if (event.source !== popup) return                   // 방금 연 팝업이 아니면 무시
  if (!isNicePopupMessage(event.data)) return          // 약속한 타입과 모양인지 검사
  const { type, data: eventData } = event.data
  if (type === NICE_CONFIG.MESSAGE_TYPES.SUCCESS) {
    processSuccessMessage(eventData)
    cleanupPopup()
  } else if (type === NICE_CONFIG.MESSAGE_TYPES.ERROR) {
    processErrorMessage(eventData)
    cleanupPopup()
  }
}
messageHandlerRef.current = onWindowMessage
window.addEventListener('message', onWindowMessage)
// 사용자가 창을 닫으면 결과 없이 로딩만 남지 않게 정리한다
popupCheckIntervalRef.current = window.setInterval(() => {
  if (!popupRef.current || popupRef.current.closed) cleanupPopup()
}, 300)
formRef.current.submit()
sequenceDiagram
    participant Web as 사용자 웹(부모 창)
    participant API as 서비스 API
    participant Popup as 팝업 창
    participant Ext as NICE
    Web->>API: 인증 준비 요청
    API-->>Web: 암호화한 요청값, 요청 번호
    Web->>Web: 요청 번호를 store에 저장
    Web->>Popup: 빈 창을 열고 form POST
    Popup->>Ext: 본인인증 진행
    Ext-->>Popup: 반환 경로로 이동
    Popup->>API: 요청 번호로 결과 검증
    API-->>Popup: 검증 결과
    Popup->>Web: postMessage(type, action), targetOrigin 고정
    Web->>Web: 출처, 발신 창, 타입 검사 뒤 Activity 이동

가입, 아이디 찾기, 비밀번호 찾기, 휴대폰 번호 변경 네 진입점이 같은 컴포넌트를 쓰고 trigger ref로 14세 미만 여부와 보호자 여부만 다르게 넘깁니다. 쿠콘 주소 인증 화면도 같은 발신 출처와 발신 창 검사를 거칩니다. 다만 쿠콘 쪽은 타입 가드 함수 없이 type 값만 비교해 데이터 모양 검사는 NICE보다 느슨합니다.

POST로 돌아오는 쿠콘 결과와 외부 장애

경기도민 인증은 쿠콘 주소 인증으로 거주지를 확인했습니다. 화면에서는 시군구 선택, 주민등록번호 뒷자리, 고유식별번호 제공 동의를 React Hook Form으로 검증한 뒤 팝업을 엽니다. 소프트 키보드가 올라오면 하단 버튼 영역의 높이를 다시 재서 입력란이 가려지지 않게 했습니다.

쿠콘은 인증이 끝나면 결과를 반환 경로로 POST합니다. 사용자 웹은 Nginx가 정적 파일로 서빙하는 SPA라 POST 요청에 index.html을 돌려줄 수 없었습니다. 처음에는 반환 경로를 SPA로 넘기는 규칙만 넣었고 같은 날 POST를 같은 경로의 GET으로 302 리다이렉트하는 규칙을 더했습니다. 302를 받은 브라우저는 GET으로 다시 요청합니다. 이때 본문은 버려지지만 query의 거래 번호(tranNo)는 남습니다. 결과는 반환 화면이 그 번호로 서버의 결과 조회 API를 불러 받으므로 본문이 없어도 충분했습니다.

POST로 돌아오는 결과를 GET 진입으로nginx
location /coocon/redirect {
    # 쿠콘은 결과를 POST로 보낸다. 같은 경로의 GET으로 돌려 SPA가 받게 한다
    if ($request_method = POST) {
        return 302 /coocon/redirect?$args;
    }
    try_files $uri /index.html;
}

# SPA 라우팅: 파일이 없으면 index.html
location / {
    try_files $uri /index.html;
}

NICE도 처음에는 같은 Nginx 규칙을 썼습니다. NICE 요청에는 결과 전달 방식을 지정하는 필드가 있어 다음 날 그 값을 GET으로 바꾸고 Nginx 규칙을 지웠습니다. NICE는 요청 쪽에서 해결했고 쿠콘 경로에는 Nginx 규칙을 남겼습니다.

반환 화면은 결과 조회가 MATCHED가 아니면 곧바로 실패로 끝내지 않고 3초 간격으로 세 번 더 조회합니다. 그래도 맞지 않으면 주소 없음, 개인정보 형식 오류, 주소 불일치, 그 밖의 실패로 나눠 부모 창에 알립니다. 성공과 실패는 별도 Activity로 나눴고 결과 화면을 닫으면 마이페이지 하나만 남도록 스택을 초기화했습니다.

거래 번호로 결과를 다시 조회하는 반환 화면ts
onSuccess: (data, variables) => {
  if (data === 'MATCHED') {
    sendSuccessMessage(COOCON_CONFIG.SUCCESS_ACTIONS.ADDRESS_VERIFIED)
    return
  }
  // 바로 실패로 끝내지 않고 3초 간격으로 최대 3번 더 조회한다
  if (retryCount < MAX_RETRY_COUNT) {
    setRetryCount((prev) => prev + 1)
    setTimeout(() => mutationGetScrapingResult.mutate(variables), RETRY_DELAY)
    return
  }
  switch (data) {
    case 'NOT_FOUND':
      sendErrorMessage(COOCON_CONFIG.ERROR_ACTIONS.ADDRESS_NOT_FOUND)
      break
    case 'INVALID_FORMAT':
      sendErrorMessage(COOCON_CONFIG.ERROR_ACTIONS.INVALID_PERSONAL_INFO)
      break
    case 'NOT_MATCHED':
      sendErrorMessage(COOCON_CONFIG.ERROR_ACTIONS.ADDRESS_VERIFICATION_FAILED)
      break
    default:
      sendErrorMessage(COOCON_CONFIG.ERROR_ACTIONS.COOCON_FAILED)
  }
}

출시 뒤 몇 주의 대응 기간에 쿠콘 서버 장애로 인증이 되지 않는 일이 있었습니다. 프론트엔드가 막을 수 없는 장애였고 화면은 실패 Activity와 다시 시도 흐름으로 받아 냈습니다. 고객 응대는 고객사 CS가 맡았고 구축 프로젝트라 운영 장애를 직접 담당하지는 않았습니다. 외부 장애를 막지는 못했지만 실패가 빈 화면이나 끝나지 않는 로딩으로 남지는 않았습니다.

두 가지 만료 신호를 한 번의 갱신으로

인증 만료는 두 모양으로 왔습니다. HTTP 401 응답이 오기도 하고 HTTP 200 응답 본문에 result: 'ERROR'와 code: '2'가 담겨 오기도 했습니다. Axios는 200을 성공으로 넘기므로 401만 보는 인터셉터로는 본문 쪽 만료를 놓칩니다. 홈처럼 사용자 팝업, 마이페이지 요약, 혜택 수령 금액 같은 인증 요청을 한꺼번에 보내는 화면에서 토큰이 만료되면 요청마다 갱신을 시도해 갱신 요청이 요청 수만큼 나갑니다.

응답 인터셉터의 성공 핸들러에서는 본문의 code=2를, 오류 핸들러에서는 401을 잡아 둘 다 토큰 store의 refreshAndRetry로 보냈습니다. 갱신은 모듈 범위의 플래그로 한 번만 실행합니다. 갱신 중에 들어온 요청은 대기열에 콜백으로 넣었다가 새 토큰이 나오면 헤더를 바꿔 재전송합니다. 갱신이 실패하면 대기열 전체를 실패시키고 토큰과 사용자 상태를 비웁니다. 요청마다 _retry 표시를 남겨 갱신 뒤에도 다시 만료 응답을 받은 요청이 갱신을 반복하지 않게 했습니다.

만료가 아닌 result: 'ERROR'는 코드와 데이터를 cause에 담은 Error로 바꿔 reject했습니다. HTTP 상태와 상관없이 React Query와 각 화면이 같은 방식으로 실패를 받게 했고 뒤의 오류 정책도 이 위에서 동작합니다.

두 가지 만료 신호를 같은 갱신 경로로ts
apiClient.interceptors.response.use(
  (response) => {
    const { result, code, msg, data } = response.data
    // HTTP 200이어도 본문이 만료를 알리면 갱신한다
    if (result === 'ERROR' && code === '2') {
      return useTokenStore.getState().refreshAndRetry(response.config)
        .catch((error) => { useUserStore.getState().reset(); return Promise.reject(error) })
    }
    // 그 밖의 업무 오류는 코드를 cause에 담아 실패로 넘긴다
    if (result === 'ERROR') {
      return Promise.reject(new Error(msg || '잠시 후 다시 시도해 주세요.', { cause: { code, data } }))
    }
    return response
  },
  async (error) => {
    if (!error.response || error.response.status !== 401 || !error.config) return Promise.reject(error)
    return useTokenStore.getState().refreshAndRetry(error.config)
      .catch((error) => { useUserStore.getState().reset(); return Promise.reject(error) })
  }
)
갱신은 한 번, 나머지는 대기열에서 기다린다ts
refreshAndRetry: async (originalRequest) => {
  const req = originalRequest as InternalAxiosRequestConfig & { _retry?: boolean }
  if (req._retry) return Promise.reject(new Error('이미 토큰 갱신을 시도했습니다.'))
  req._retry = true
  if (isTokenRefreshing) {
    return new Promise((resolve, reject) => {
      pendingRequests.push((newToken) => {
        if (!newToken) return reject(new Error('토큰 갱신에 실패했습니다.'))
        req.headers.set('Authorization', `Bearer ${newToken}`)
        resolve(apiClient.request(req))
      })
    })
  }
  isTokenRefreshing = true
  try {
    const refreshToken = get().refreshToken
    if (!refreshToken) throw new Error('갱신에 쓸 토큰이 없습니다.')
    const newToken = await apiAuthService.refreshToken.fetch({ refreshToken })
    get().setAccessToken(newToken!)
    isTokenRefreshing = false
    pendingRequests.forEach((callback) => callback(newToken)) // 기다리던 요청을 새 토큰으로 재전송
    pendingRequests = []
    req.headers.set('Authorization', `Bearer ${newToken}`)
    return apiClient.request(req)
  } catch (error) {
    isTokenRefreshing = false
    pendingRequests.forEach((callback) => callback(undefined)) // 기다리던 요청도 함께 실패
    pendingRequests = []
    get().reset()
    throw new Error('토큰 갱신에 실패했습니다.', { cause: error })
  }
}
sequenceDiagram
    participant A as 요청 A
    participant B as 요청 B
    participant C as API 클라이언트
    participant S as 토큰 store
    participant Auth as 인증 API
    A->>C: HTTP 401
    C->>S: refreshAndRetry(A)
    S->>Auth: 갱신 요청
    B->>C: HTTP 200, code=2
    C->>S: refreshAndRetry(B)
    S->>S: 갱신 중이라 B를 대기열에 넣음
    Auth-->>S: 새 액세스 토큰
    S->>C: 대기열의 B를 새 토큰으로 재전송
    S->>C: A를 새 토큰으로 재전송

동시에 만료된 요청은 갱신 한 번을 기다렸다가 새 토큰으로 다시 나갑니다. 한계도 두 가지 있습니다. 갱신 요청도 같은 클라이언트와 인터셉터를 지나므로 갱신 요청 자체가 401이나 code=2를 받으면 갱신 중 플래그 때문에 대기열에 들어가 풀리지 않는 구조였습니다. 또 토큰은 localStorage에 저장했고 이 구조를 보안 강화 사례로 내세우지 않습니다.

OAS 명세에서 API 계층과 인증 맵 만들기

인증, 쿠폰, 콘텐츠, 지원 네 서비스의 Swagger 명세가 개발 기간 내내 바뀌었습니다. 엔드포인트마다 토큰이 필요한지도 달랐습니다. 로그인, 지도, 배너, 공지처럼 토큰 없이 부르는 API와 혜택 목록, 찜, 회원 정보처럼 토큰이 필요한 API가 섞여 있었습니다. 이미지 업로드는 서버에서 받은 presigned URL로 S3에 직접 올렸습니다. 어떤 요청에 토큰을 실을지 사람이 목록으로 관리하면 명세가 바뀔 때마다 어긋납니다.

명세를 기준으로 두고 필요한 코드를 모두 명세에서 생성했습니다. update-api-all 명령 하나가 환경별로 네 서비스의 명세를 내려받아 하나로 병합하고 네 가지를 만듭니다. swagger-typescript-api로 만든 라우트 타입, 오퍼레이션마다 쿼리 키와 요청 함수를 묶은 서비스 객체, 응답 코드에서 뽑은 에러 코드 상수와 메시지, 인증 엔드포인트 맵입니다. 생성 디렉터리는 실행할 때마다 비우고 다시 만들며 생성 파일은 손으로 고치지 않았습니다.

flowchart LR
    Spec["네 서비스의 OAS 명세"] -->|환경별 다운로드| Merge["병합 명세"]
    Merge --> Types["라우트 타입: swagger-typescript-api"]
    Merge --> Service["서비스 객체: 쿼리 키와 요청 함수"]
    Merge --> Errors["에러 코드 상수와 메시지"]
    Merge --> Security["인증 엔드포인트 맵"]
    Security --> Client["API 클라이언트의 요청 인터셉터"]
    Errors --> Policy["React Query와 화면의 오류 처리"]

인증 맵은 각 오퍼레이션의 security 선언에 Bearer 인증이 있는지로 정했습니다. 경로에서 서비스 접두 경로를 떼어 METHOD:경로 키를 만들고 요청 인터셉터는 실제 요청 URL을 같은 모양으로 정규화해 찾습니다. 정확히 일치하는 키가 없으면 {id} 같은 경로 변수를 정규식으로 바꿔 다시 맞춥니다. 2025-11-06 생성 기준으로 엔드포인트 104개 중 53개가 인증 대상이었고 나머지 51개에는 토큰을 싣지 않습니다.

명세의 security 선언에서 인증 맵을 만든다js
for (const [pathUrl, pathMethods] of Object.entries(swagger.paths)) {
  for (const [method, operation] of Object.entries(pathMethods)) {
    // 서비스 prefix를 떼어 요청 URL과 같은 모양의 키로 만든다
    const cleanPath = pathUrl.replace(/^\/.*?-service\/api\/v\d+/, '')
    const key = `${method.toUpperCase()}:${cleanPath}`
    const hasSecurity =
      operation.security &&
      Array.isArray(operation.security) &&
      operation.security.some((securityItem) => securityItem['Bearer Authentication'] !== undefined)
    securityMap.set(key, {
      path: cleanPath,
      method: method.toUpperCase(),
      requiresAuth: Boolean(hasSecurity),
      operationId: operation.operationId || null
    })
  }
}

요청 인터셉터는 이 맵만 보고 토큰을 실을지 정합니다. S3 주소로 가는 업로드 요청은 서명이 주소에 들어 있어 헤더를 건드리지 않고 보냈습니다. 인증 관련 요청에는 앱이 알려 준 테넌트 값을 별도 헤더로 실었습니다.

요청마다 토큰을 실을지 정한다ts
apiClient.interceptors.request.use((config) => {
  const tokenStore = useTokenStore.getState()
  const tenantId = useDeviceInfoStore.getState().tenantId
  const url = config.url ?? ''
  // presigned URL 업로드는 서명이 주소에 있어 헤더를 건드리지 않는다
  if (url.includes('amazonaws.com')) return config
  const isAuth = url.includes('/auth')
  const isRefresh = url.includes('/auth/refresh')
  const isNiceValidation = url.includes('/nice/user-validation')
  if ((isAuth && !isRefresh && tenantId) || (isNiceValidation && tenantId)) {
    config.headers.set('X-Tenant-Id', tenantId)
  }
  // 명세에서 생성한 맵이 인증을 요구할 때만 Bearer 토큰을 싣는다
  if (isAuthenticatedEndpoint(config?.url, config?.method) && tokenStore.accessToken) {
    config.headers.set('Authorization', `Bearer ${tokenStore.accessToken}`)
  }
  return config
})

서비스 객체의 쿼리 키는 URL 경로 조각에서 만들었습니다. 혜택 상세의 키가 ['benefits', id]처럼 자원 이름으로 시작하므로 화면에서는 queryKey[0]만 보고 혜택 관련 쿼리를 한꺼번에 취소하거나 무효화할 수 있습니다. 변경 요청은 키를 null로 생성해 캐시 키로 쓰지 않게 했습니다.

생성 결과: 쿼리 키와 요청 함수를 묶은 서비스 객체
생성된 혜택 상세 오퍼레이션ts
getBenefitDetail: {
  key: (params: CouponService.GetBenefitDetail.RequestParams) => [COUPON_SERVICE_KEY.BENEFITS, params.id],
  fetch: async (params: CouponService.GetBenefitDetail.RequestParams) => {
    const response: AxiosResponse<CouponService.GetBenefitDetail.ResponseBody> =
      await apiClient.request({
        method: 'GET',
        url: `coupon-service/api/v1/benefits/${params.id}`
      })
    return response.data.data
  }
},

명세가 바뀌면 명령 한 번으로 라우트 타입, 서비스 객체, 에러 코드, 인증 맵이 함께 갱신됐습니다. 프로젝트 후반에는 동료 FE도 같은 명령으로 명세를 갱신했습니다. 한계는 명세의 정확도에 기댄다는 점입니다. security 선언이 빠지거나 틀리면 토큰도 함께 빠지고 맵에 없는 요청은 경고 로그만 남긴 채 토큰 없이 나갑니다.

정적 마크업 위에 오류 정책 얹기

초기 화면 기준은 퍼블리싱 팀의 정적 산출물이었습니다. 그대로 옮기면 인증, 권한, 예외, 브리지, 데이터 갱신이 없는 화면이 됩니다. 화면 구조는 참고하되 상호작용은 요구사항과 API 흐름에 맞게 다시 짰습니다. 오류가 났을 때의 동작은 정적 산출물에 전혀 없었습니다.

명세에서 뽑은 에러 코드는 88개였고 코드마다 화면이 할 일이 달랐습니다. 시스템 오류는 사용자가 할 수 있는 일이 없고 잠시 뒤 성공할 수도 있습니다. 로그인 실패나 미가입 소셜 계정은 화면이 다음 단계를 안내해야 합니다. 모든 오류를 공통 모달 하나로 받으면 안내가 필요한 오류까지 같은 문구로 끝나고 모든 오류를 화면마다 받으면 시스템 오류 처리가 화면 수만큼 흩어집니다. 그래서 처리 위치를 층으로 나눴습니다.

오류처리 위치동작
인증 만료(HTTP 401, 본문 code=2)API 클라이언트한 번 갱신한 뒤 재전송
그 밖의 result: 'ERROR'API 클라이언트코드와 데이터를 cause에 담아 실패로 전달
시스템 오류 A0001React Query 공통 설정조회를 최대 3회 다시 시도한 뒤 공통 모달, 확인하면 이전 화면
업무 오류각 화면조회는 1회 다시 시도, 로그인 잠금이나 소셜 가입 분기처럼 화면이 안내
시스템 오류만 공통으로 받는다ts
new QueryClient({
  queryCache: new QueryCache({
    onError: (error) => {
      logger.error(error)
      // 시스템 오류만 공통 모달로 받는다. 확인하면 이전 화면으로 돌아간다
      if (parseApiError(error).code === API_ERROR_CODES.A0001) {
        modalQueryError.show()
      }
    }
  }),
  defaultOptions: {
    queries: {
      // 시스템 오류는 최대 3회, 그 밖의 오류는 1회만 다시 요청한다
      retry: (failureCount, error) =>
        parseApiError(error).code === API_ERROR_CODES.A0001 ? failureCount < 3 : failureCount < 1,
      staleTime: 60 * 1000
    }
  }
})

홈, 혜택, 콘텐츠, 알림, 마이페이지의 화면 구조를 다시 짜면서 인증 상태, 권한, 예외 처리, 브리지 이벤트, 캐시 갱신을 이 정책 위에 얹었습니다. 시스템 오류만 공통 모달로 끝나고 나머지는 화면이 코드에 맞는 안내를 했습니다. 업무 오류도 조회라면 한 번 더 요청하는 설정이라 결과가 바뀌지 않는 오류에도 요청이 한 번 더 나갔습니다. 공통 처리는 조회에만 걸려 있어 변경 요청의 오류는 각 화면의 onError가 받습니다.

바텀시트에 가려진 지도에서 보이는 영역만, 요청할 때만 조회하기

내 주변 화면은 지도 위에 헤더가 있고 아래에서 스냅 바텀시트가 올라옵니다. 지도 전체 경계로 조회하면 헤더와 시트에 가려진 지역의 결과까지 목록에 나옵니다. 지도 인스턴스가 준비되기 전에는 높이나 투영 정보가 없어 경계를 읽을 수 없습니다. 드래그와 줌마다 조회하면 손가락을 움직이는 동안 목록이 계속 바뀌고 요청도 그만큼 나갑니다.

경계는 실제로 보이는 영역으로 계산했습니다. 위쪽은 헤더의 아래 끝까지, 아래쪽은 시트의 스냅 높이에서 지도 위치를 뺀 만큼을 가려진 영역으로 봤습니다. 시트가 중간 단계일 때만 가려진 높이의 75%를 빼 시트 경계에 걸친 결과까지 잘라 내지 않게 했고 펼친 단계에서는 다시 검색 버튼을 숨겼습니다. 위경도를 화면 비율로 곧장 자르지 않고 지도의 투영 좌표로 바꾼 뒤 위아래를 잘라 다시 위경도로 돌렸습니다. 웹 지도의 메르카토르 투영에서는 화면의 세로 비율과 위도 차이가 정확히 비례하지 않습니다.

지도 요소의 높이, 투영, 경계가 모두 있을 때만 경계를 확정했습니다. 준비되지 않았으면 100ms 간격으로 최대 100번 확인한 뒤 지도 이벤트를 등록합니다. 경계 계산이 실패하면 지도 전체 경계로, 그것도 없으면 중심 좌표 주변의 작은 사각형으로 물러납니다.

조회 시점은 두 값으로 나눴습니다. liveViewportRef는 드래그, 줌, idle 때마다 지금 보이는 영역으로 갱신되는 ref라 리렌더링을 일으키지 않습니다. 조회 조건인 searchBounds는 state이고 첫 진입, 다시 검색 버튼, 카테고리 변경, 로그인 뒤 재진입에서만 liveViewportRef의 값을 복사해 바뀝니다. 장소 목록 쿼리의 키가 searchBounds에 묶여 있어 조회는 이 네 시점에만 일어납니다. 지도를 움직이면 다시 검색 버튼만 나타납니다.

flowchart LR
    Move["드래그, 줌, idle"] --> Live["liveViewportRef: 지금 보이는 영역"]
    Move --> Button["다시 검색 버튼 표시"]
    Button -->|누름| Search["searchBounds: 조회 조건"]
    Live -.->|값 복사| Search
    Category["카테고리 변경"] --> Search
    Enter["첫 진입, 로그인 뒤 재진입"] --> Search
    Search --> Query["장소 목록 쿼리와 마커"]
보이는 영역을 계산하고 조회 조건과 분리한다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)
  liveViewportRef.current = bounds            // 지금 보이는 영역은 계속 갱신한다
  if (!searchBounds) setSearchBounds(bounds)  // 조회 조건은 첫 진입 때만 자동으로 정한다
  return true
}
listeners.push(
  naver.maps.Event.addListener(mapInstance, 'dragend', () => {
    setIsViewDragged(true) // 다시 검색 버튼을 띄운다
    setIsMyLocationMarkerCenter(false)
    tryCommitBounds()
  }),
  naver.maps.Event.addListener(mapInstance, 'zoom_changed', () => {
    setIsViewDragged(true)
    setIsMyLocationMarkerCenter(false)
    tryCommitBounds()
  }),
  naver.maps.Event.addListener(mapInstance, 'idle', () => {
    tryCommitBounds()
  })
)
조회는 사용자가 요청할 때만ts
const handleSearchAgainButtonClick: MouseEventHandler<HTMLButtonElement> = (_e) => {
  markersRef.current.forEach((marker) => marker.setMap(null))
  markersRef.current.clear()
  // 조회 조건이 바뀌어야 쿼리 키가 바뀌고 다시 조회한다
  if (liveViewportRef.current) {
    setSearchBounds(liveViewportRef.current)
  }
  setSelectedPlace(null)
  setIsViewDragged(false)
}

마커는 id로 비교해 사라지는 것만 fade로 지우고 남는 것은 아이콘과 위치만 갱신합니다. 선택한 마커는 zIndex를 올립니다. 현재 위치로 이동할 때는 시트가 중간 단계면 보이는 영역의 가운데에 오도록 지도 중심을 보정했고 이동 거리에 따라 애니메이션 시간을 300~900ms 사이로 정했습니다. 권한 흐름은 앞의 브리지 절에서 설명한 단계를 따릅니다.

시트 높이와 지도 위치가 바뀌어도 목록과 마커에는 조회 시점에 보이던 영역의 결과만 나오고 조회는 사용자가 요청한 시점에만 일어납니다. 요청량 감소율과 체감 성능은 측정하지 않았습니다.

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

혜택, 댓글, 알림, 컬처마일 목록은 길고 상세를 다녀오면 보던 위치로 돌아와야 했습니다. 가상 스크롤 목록은 항목 높이가 측정 전후로 달라져 scrollTop만 저장해서는 같은 자리로 돌아오지 못합니다. Stackflow는 Activity마다 스크롤 컨테이너가 따로 있어 virtualizer가 어느 요소를 스크롤 대상으로 삼을지도 화면마다 정해야 했습니다. 좋아요는 누르자마자 목록과 상세 양쪽에 보여야 했습니다.

화면을 떠날 때 첫 가상 항목의 key와 그 항목 안에서 내려간 거리를 앵커로 저장합니다. 돌아오면 그 key의 index로 이동한 뒤 거리를 더합니다. 높이가 다시 측정되면서 위치가 흔들리므로 다음 프레임에서 목표와 현재 위치의 차이, 전체 높이의 변화를 확인하고 둘 다 수렴할 때까지 반복하되 10회에서 멈춥니다. 항목 높이는 key별로 캐시해 다시 그릴 때 추정 높이로 씁니다. 스크롤 컨테이너는 Activity 이름별로 store에 등록해 각 화면의 virtualizer가 자기 컨테이너를 찾게 했습니다.

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

// 돌아오면 key의 index로 간 뒤 거리만큼 더 내린다. 높이가 수렴할 때까지 최대 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에서 관련 쿼리를 취소하고 목록과 상세 캐시를 먼저 바꿉니다. 실패하면 둘 다 되돌리고 비로그인 오류면 로그인 화면으로 보냅니다. 끝나면 찜 목록만 무효화합니다. 쿼리 키가 자원 이름으로 시작하게 생성돼 있어 취소 대상을 queryKey[0]으로 골랐습니다.

좋아요의 낙관적 갱신ts
onMutate: async (variables) => {
  const queryKey = getQueryKey()
  await queryClient.cancelQueries({
    predicate: (query) =>
      query.queryKey[0] === COUPON_SERVICE_KEY.BENEFITS ||
      (query.queryKey.includes(COUPON_SERVICE_KEY.BENEFITS) && query.queryKey.includes(variables.id))
  })
  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 && context?.queryKey) queryClient.setQueryData(context.queryKey, context.previousData)
  if (context?.previousDetailData && context?.benefitId) {
    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 })

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

로컬 HTTPS 호스트와 배포 설정

카카오 OIDC, 애플 로그인, NICE 본인인증은 모두 공급자에 등록한 주소로 돌아오는 흐름이었습니다. 일반 로컬 환경(localhost, HTTP)에서는 카카오와 NICE의 반환 경로까지는 확인할 수 있었지만 애플의 웹 리다이렉트 주소는 HTTPS 도메인이어야 해서 넣을 수 없었습니다. 팝업 메시지는 출처가 같아야 처리하므로 반환 화면도 개발 서버와 같은 출처여야 했습니다.

서비스 도메인의 하위 호스트를 hosts 파일에 127.0.0.1로 넣고 빼는 스크립트(setup-host, remove-host)를 만들었습니다. 같은 호스트를 앱 주소로 쓰는 env 파일을 두고 그 주소가 https로 시작하면 Vite에 basic SSL 플러그인을 붙였습니다. dev:ssl로 띄우면 카카오, 애플, NICE 반환 주소가 모두 같은 HTTPS 출처를 가리키므로 팝업의 출처 검사까지 배포 환경과 같은 조건으로 확인할 수 있었습니다. 환경 변수 타입은 env 파일에서 생성해 키 누락을 타입 검사로 잡았습니다.

앱 주소가 HTTPS면 로컬 인증서를 붙인다ts
// package.json
//   "setup-host": "node src/scripts/update-host.cjs add"    hosts 파일에 로컬 호스트 등록
//   "dev:ssl": "env-cmd -f env/.env.local.ssl vite --host"  인증 반환 주소가 모두 같은 HTTPS 출처
export default defineConfig(({ mode }) => {
  const env = loadEnv(mode, process.cwd(), '')
  const isSSL = env.VITE_APP_URL?.startsWith('https')
  const plugins = [react(), tsconfigPaths(), createHtmlPlugin({ inject: { data: { ...env } } })]
  if (isSSL) {
    plugins.push(basicSsl())
  }
  return { plugins }
})

배포 쪽은 빌드 결과를 Nginx 이미지에 싣는 Dockerfile, Nginx 설정, 개발 환경 Kubernetes Deployment와 Service를 작성했습니다. Nginx 설정에는 파일이 없으면 index.html로 넘기는 SPA 규칙, 정적 자원 확장자에만 거는 6개월 캐시, gzip, 앞에서 설명한 쿠콘 POST 규칙이 들어 있습니다. Vite 빌드 파일 이름에는 해시가 붙어 정적 자원에 긴 캐시를 걸어도 새 배포가 반영되고 index.html은 이 캐시 규칙에 들지 않습니다. 운영 Kubernetes 설정은 동료가 작성했고 저는 개발과 운영의 자원 요청값을 조정했습니다.

빌드 결과를 Nginx 이미지에 싣는다dockerfile
# 레지스트리 주소는 뺐습니다
FROM nginx:1.28

COPY ./dist /app/html
COPY ./robots.txt /app/html
COPY ./deploy-config/nginx/nginx.conf /etc/nginx/conf.d/default.conf

EXPOSE 80

ENV TZ=Asia/Seoul
RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone

CMD ["nginx", "-g", "daemon off;"]

인증 리다이렉트와 팝업 메시지 검증을 로컬에서 끝까지 확인했습니다. hosts 파일을 고치려면 관리자 권한이 필요하고 basic SSL 인증서는 자체 서명이라 처음 접속할 때 브라우저 경고를 한 번 넘겨야 합니다.

운영 백오피스의 정보 구조 리딩

운영 백오피스는 혜택, 쿠폰, 제휴사, 홈 배너, 팝업, 공지, FAQ, 1:1 문의, 콘텐츠 코멘트와 금지어, 회원, 정산, 통계, 관리자 권한을 다뤘습니다. 사용자 웹이 이용자의 관점에서 다루는 도메인을 백오피스는 운영자의 관점에서 다룹니다. FE 2명이 처음부터 만들어야 했고 저는 사용자 웹을 혼자 맡고 있었으므로 제 역할은 구조와 패턴의 기준을 주는 일이었습니다.

직전에 제가 만든 LG Shield CMS를 참고 구조로 삼았습니다. 운영 백오피스 저장소의 초기 설정 커밋에도 그 CMS를 레퍼런스로 프로젝트 구조를 잡았다고 남아 있습니다. 라우트 설정에 id, label, 메뉴 노출 여부를 함께 적어 메뉴와 브레드크럼이 설정에서 나오게 했습니다. API는 사용자 웹처럼 Swagger 명세에서 생성하고 목록 화면의 검색 조건은 URL query에 두는 방향을 잡았습니다. 공통 폼, 표, 에디터와 검색 패턴의 설계 가이드를 주었고 화면 구현은 FE 2명이 했습니다.

flowchart LR
    Ref["LG Shield CMS 구조(본인)"] --> Guide["설계 가이드: 메뉴와 정보 구조, 공통 폼과 표, 검색 패턴"]
    Guide --> Route["라우트 설정: id, label, 메뉴 노출 여부"]
    Route --> Menu["메뉴와 브레드크럼"]
    Guide --> Impl["FE 2명의 화면 구현"]
    Spec["Swagger 명세"] --> Impl

운영 메뉴와 화면이 한 라우트 설정을 기준으로 구성됐고 사용자 웹과 같은 명세 생성 흐름을 씁니다. 개별 화면 구현과 테스트는 FE 2명의 작업이라 제 구현으로 쓰지 않습니다.

결과와 근거

확인한 결과근거
두 만료 신호를 같은 갱신 경로로 처리하고 갱신 중 요청은 대기열에서 재전송src/api/api-client.ts, src/store/use-token-store.ts, 커밋 58590e7
명세의 security 선언으로 엔드포인트 104개 중 53개에만 토큰 첨부src/scripts/update-api-secure.js, 생성 파일 src/api/api-authenticated-endpoint.ts(2025-11-06 생성)
에러 코드 88개 생성과 시스템 오류 A0001의 재시도와 공통 모달src/scripts/update-api-error.js, src/library/react-query/react-query.ts
팝업 메시지의 발신 출처, 발신 창, 타입 검증src/activity/user/act-nice/_local/nice-auth.tsx, src/activity/my-page/act-coocon/act-coocon-address.tsx
쿠콘 POST 결과의 302 GET 전환, NICE 반환 방식 변경deploy-config/nginx/nginx.conf, 커밋 922e7ac, c95ea57, c4d2ac5
카카오 팝업 전환과 애플 네이티브 분기src/activity/user/act-login/act-login.tsx, 커밋 86edae7, b8cc33f
지도의 보이는 영역 계산과 명시적 재조회src/activity/place/act-place.tsx, src/util/util-viewport.ts, 커밋 0678630
가상 스크롤 위치 복원과 좋아요의 낙관적 갱신src/activity/culutre/act-culture/act-culture-explore.tsx
로컬 HTTPS 호스트와 배포 설정vite.config.ts, src/scripts/update-host.cjs, Dockerfile, deploy-config/k8s/dev/deployment.yaml, 커밋 f9fb962, 72c4daf
운영 백오피스의 출발 구조운영 백오피스 저장소의 초기 설정 커밋 메시지

사용자 웹 저장소에는 자동화 테스트가 없어 근거는 코드, 생성 파일, 설정과 커밋입니다. 등록자 수, 요청량 감소율, 업무 효과는 검증된 수치가 없어 쓰지 않았습니다. 구축 이후의 서비스 장애 운영은 담당 범위에 넣지 않았습니다.

배운 것