Navigation

SK AX GAS PP

SK AX GAS Position Play는 원자재 스프레드 분석 및 모니터링 서비스입니다. LPG와 LNG 스프레드의 과거 반복 패턴과 현재 흐름을 비교해 후보를 찾고 진입 등록한 포지션을 워치리스트에서 모니터링합니다. 호스트 앱에 동적으로 탑재되는 Module Federation 리모트 프론트엔드를 PRD 분석 단계부터 맡아 런타임, 인증, 분석 화면, 차트와 검증 환경을 설계하고 구현했습니다.

핵심 기여

과제판단과 구현확인한 결과
호스트 통합과 단독 실행두 진입점이 공용 Provider와 상대 경로 라우트를 공유하고 진입 파일과 해시 자산의 캐시 정책을 나눔호스트, 단독 개발, e2e 빌드가 같은 화면 코드로 실행
VDI 접속 제약개발계 실응답을 파일로 남기는 Vite 플러그인과 백엔드 기준 실행 모드 넷캡처 기준으로 모킹 데이터를 다시 만들고 응답 예외를 화면 규칙에 반영
데이터 의존과 로딩응답을 공유하는 단위로 오류 경계 12곳과 대기 경계 7곳 배치그래프가 실패해도 제목과 뒤로가기가 남음
시계열 차트달력 격자에 고정한 날짜 눈금, 인스턴스 수명과 옵션 적용 분리패닝해도 눈금 간격 유지
측정 뒤 변경순차 요청 병렬화, 방어용 메모이제이션 제거, ECharts 모듈 등록요청 3건 동시 시작, setOption 호출 수 동일, 라우트 청크 4,988KB에서 3,646KB
회귀 범위와 규약세 층 테스트, role과 label 기반 locator, Biome과 GritQL, stylelint 소유권 규칙화면을 테스트와 함께 병합하고 경계 위반은 커밋 전에 차단

개요

항목내용
기간2026.04 ~ 현재
역할리모트 프론트엔드 설계와 구현. 런타임, 인증, 분석 화면, 시각화, 테스트와 개발 규약
협업과 인원FE 1명(본인). BE와 디자인은 외부 협력사, PM은 SK AX
서비스원자재 스프레드 패턴 탐색, 진입 등록, 워치리스트 모니터링, 상세 분석. 실제 주문을 체결하는 거래 시스템은 아닙니다
현재 화면워치리스트, 실시간 패턴, 계절성 패턴 목록, 패턴 상세(계절성, 실시간, 워치리스트 공용), File Update
기술React, TypeScript, Vite, Module Federation, React Router, TanStack Query, Zustand, nuqs, Axios, react-hook-form, MUI, Emotion, ECharts, Kubb, OpenAPI, MSW, Faker, es-toolkit, dayjs, Playwright, Vitest, c8, Biome, Stylelint, Husky, lint-staged
재편 전 화면의 기술React Flow, dagre, marked, TanStack Table. 2026.09 화면 재편과 함께 걷어냈습니다
링크고객사 내부 서비스라 공개 링크와 저장소가 없습니다

진행 단계

기능의 성격으로 묶은 구분입니다. 계약상 단계나 확정된 일정은 아닙니다.

작업 묶음주요 작업
초기 구축리모트 런타임과 정적 서빙 설정, 인증 연동, 패턴 발굴과 모니터링 화면, ECharts 차트, driver 도식표, AI 챗봇, Report Update
계절성 분석계절성 목록과 상세, 드라이버 시각화 패널, 세 층 테스트, Kubb v5, 차트와 URL 계약, 레이어 규약
화면 재편구형 화면 제거, 대기와 오류 경계 재배치, VDI 실응답 캡처, 실시간 목록과 상세, 워치리스트, 상세 화면 공용화, Vitest 전환, 테스트 시나리오 Excel
검증 도구 정리Biome 전환과 GritQL 규칙, Playwright와 Vitest 커버리지 결과서, 캡처 전용 실행 명령, 404 안내 화면

맡은 범위

본인 구현

리딩과 협업

동료와 다른 조직의 범위

문제와 해결

코드는 저장소에서 판단이 드러나는 부분만 줄여 옮겼습니다. 주석은 뜻을 유지한 채 다듬었고 일부 이름은 짧게 바꿨습니다.

호스트 안에서도 혼자서도 같은 화면 코드로 돌게 하기

리모트는 호스트 앱이 런타임에 불러오지만 개발과 테스트는 단독으로 돌려야 했습니다. 두 환경에서 라우팅과 Provider 구성이 갈리면 화면 코드가 둘로 나뉩니다. 배포 쪽에도 제약이 있었습니다. 호스트는 매번 같은 주소의 remoteEntry.js를 읽기 때문에 이 파일이 캐시되면 새 배포가 호스트에 반영되지 않습니다.

React, React DOM, React Router를 shared singleton으로 두고 리모트는 상대 경로 라우트만 소유하게 했습니다. 리모트 진입점은 공용 Provider와 라우트만 감싸 내보냅니다. 단독 진입점은 같은 조합 위에 BrowserRouter와 호스트 상단 영역을 흉내 낸 셸을 더합니다. 라우터와 화면 외곽은 진입점이 맡고 QueryClient, 테마, 모달과 최종 오류 경계는 공용 Provider가 맡도록 나눴습니다. e2e와 Vitest 모드에서는 federation 플러그인을 빼고 단독 진입점만 빌드합니다. 리모트 번들은 공유 모듈을 호스트에서 받는 구조라 혼자 열면 빈 화면이 되기 때문입니다.

정적 서빙의 캐시는 파일 성격으로 나눴습니다. remoteEntry.js와 단독 실행용 HTML은 매번 새로 받게 하고 해시가 붙은 나머지 자산은 1년 동안 바뀌지 않는 파일로 선언했습니다. 진입 파일만 매번 확인하면 새 배포의 청크는 이름이 달라 옛 캐시와 섞이지 않습니다. 호스트가 다른 출처에서 자산을 읽으므로 CORS 헤더도 붙였습니다. Dockerfile 원형은 동료가 만들었고 본인은 의존성 설치 단계를 분리하고 이 정적 서빙 설정을 새로 작성했습니다.

flowchart LR
    Host["호스트 앱"] -->|매번 새로 읽기| Entry["remoteEntry.js"]
    Entry -->|해시 파일명| Assets["청크와 CSS, 1년 캐시"]
    Entry --> RemoteApp["리모트 진입점"]
    Standalone["단독 진입점과 BrowserRouter"] --> Shell["공용 Provider"]
    RemoteApp --> Shell
    Shell --> Routes["상대 경로 라우트"]
리모트 정적 서빙의 캐시 규칙nginx
# 호스트가 매번 읽는 진입 파일은 캐시하지 않는다
location = /assets/remoteEntry.js {
  try_files $uri =404;
  add_header Access-Control-Allow-Origin "*" always;
  add_header Cache-Control "no-store, no-cache, must-revalidate" always;
}

# 해시가 붙은 나머지 자산은 내용이 바뀌면 이름도 바뀐다
location /assets/ {
  try_files $uri =404;
  add_header Access-Control-Allow-Origin "*" always;
  add_header Cache-Control "public, max-age=31536000, immutable" always;
}

호스트 통합, 단독 개발과 e2e 빌드가 같은 라우트와 Provider를 씁니다. 이후 화면은 모두 이 구조 위에 추가했습니다. 캐시 정책을 나눈 효과를 배포 지연이나 캐시 적중률로 측정하지는 않았습니다.

인증 만료를 요청 경로마다 한 번의 갱신으로 묶기

인증은 호스트가 저장한 토큰을 이어받고 갱신은 호스트 인증 API가 맡습니다. 업무 API는 Kubb가 생성한 클라이언트가 부르지만 갱신 API는 명세 밖에 있어 주소를 따로 둬야 했습니다. 화면 하나가 여러 요청을 동시에 보내므로 만료 응답도 한꺼번에 여럿 돌아옵니다.

초기 설정에는 동료가 만든 세션 만료 처리와 대기열 방식의 갱신이 있었습니다. 본인은 이 처리를 Kubb v5 transport에 연결한 단일 Axios 인스턴스로 옮기고 대기열을 공유 Promise 하나로 줄였습니다. 첫 만료 요청만 갱신을 시작하고 뒤따른 요청은 같은 Promise를 기다립니다. 이전 토큰으로 보낸 요청의 실패 응답이 갱신 뒤에 늦게 도착하면 다시 갱신하지 않고 저장된 새 토큰으로 바로 재전송합니다. 재전송한 요청까지 만료되면 갱신을 반복하지 않고 세션 만료 모달을 띄웁니다.

만료 요청이 공유하는 토큰 갱신ts
if (code === 'AUTH-003' && originalRequest) {
    // 새 토큰으로 재전송한 요청도 만료되면 갱신을 반복하지 않는다
    if (originalRequest.authRetried) {
        void NiceModal.show(WgSessionExpiredModal)
        throw error
    }
    originalRequest.authRetried = true

    // 이전 토큰의 실패 응답이 늦게 도착하면 저장된 새 토큰으로 바로 재전송한다
    const currentToken = useAuthStore.getState().token
    if (currentToken && originalRequest.headers.get('Authorization') !== `Bearer ${currentToken}`) {
        return apiClient.request(originalRequest)
    }

    // 첫 만료 요청만 갱신을 시작하고 뒤따른 요청은 같은 Promise를 기다린다
    pendingTokenRefresh ??= refreshAccessToken()
        .catch((refreshError) => { void NiceModal.show(WgSessionExpiredModal); throw refreshError })
        .finally(() => { pendingTokenRefresh = undefined })
    await pendingTokenRefresh
    return apiClient.request(originalRequest)
}

운영에서는 챗봇 응답만 인증 만료 뒤 복구되지 않는다는 피드백이 왔습니다. 챗봇 스트림은 Axios를 거치지 않는 fetch 경로라 인터셉터가 만료 응답을 보지 못했습니다. 별도 스테이징 없이 운영에서 피드백을 받는 환경이라 배포 전에 이 경로를 확인하지 못했습니다. 스트림 요청에도 같은 규칙을 붙였습니다. 실패 응답은 복제본에서 만료 코드만 읽어 원본 본문을 소비하지 않습니다. 저장소의 토큰이 요청 때와 다르면 다른 경로가 이미 갱신한 것으로 보고 그 토큰으로 재실행합니다. 토큰이 그대로면 스트림 요청끼리 공유하는 갱신 Promise를 기다린 뒤 한 번만 재실행합니다. 응답은 SSE block과 NDJSON line을 모두 읽도록 파서를 나눴고 Markdown은 raw HTML을 주입하지 않고 marked가 만든 토큰을 React node로 바꿔 링크 protocol과 표의 가로 넘침을 제어했습니다.

sequenceDiagram
    participant UI as 챗봇 화면
    participant S as 스트림 요청(fetch)
    participant T as 토큰 저장소
    participant R as 공유 갱신 Promise
    UI->>S: 스트림 시작(요청 시점 토큰)
    S-->>UI: 만료 코드(응답 복제본에서 확인)
    UI->>T: 현재 토큰 확인
    alt 다른 경로가 이미 갱신함
        T-->>UI: 새 토큰
    else 아직 갱신 전
        UI->>R: 갱신 요청(동시 요청은 하나를 공유)
        R-->>UI: 새 토큰
    end
    UI->>S: 한 번만 재실행
    S-->>UI: SSE 또는 NDJSON 응답
Axios 밖 스트림의 1회 재실행ts
let response = await requestChatStream(options.token)

if (await isExpiredAccessTokenResponse(response)) {
    // 다른 경로가 이미 갱신했으면 그 토큰으로 재실행하고 아니면 공유 갱신을 기다린다
    const currentToken = useAuthStore.getState().token
    const replayToken = currentToken !== null && currentToken !== options.token ? currentToken : await refreshChatAccessToken()
    response = await requestChatStream(replayToken)
}

// 만료 코드는 응답 복제본에서 읽는다. 원본 본문은 스트림으로 읽어야 한다
const isExpiredAccessTokenResponse = async (response: Response) =>
    !response.ok && (await response.clone().json().catch(() => null))?.code === 'AUTH-003'

만료 뒤에는 한 번만 재실행하고 다른 요청의 갱신과 겹치지 않습니다. 챗봇 화면은 2026.09 화면 재편으로 제거됐습니다. 인터셉터를 거치지 않는 전송 경로도 인증 경계 안에 넣어야 한다는 기준은 이후 구조에 남겼습니다.

조회 조건의 기준을 URL에 두기

패턴 유형, 등급, 지수와 월물 조건, 정렬, 페이지와 탭을 컴포넌트 지역 상태에만 두면 새로고침이나 뒤로가기 뒤에 사용자가 보던 화면을 복원할 수 없습니다. 상세에서 목록으로 돌아올 때도, 상세 링크를 새 탭에서 열 때도 같은 문제가 생깁니다. 재편 전 화면은 정렬 조건만 최대 6개였고 CSV 추출도 같은 조건으로 전체 결과를 내려받아야 했습니다.

URL을 조회 조건의 단일 기준으로 정했습니다. 이 원칙은 HCMS에서 정리한 규약에서 가져왔습니다. nuqs parser가 URL query의 허용 값과 기본값을 정의하고 잘못된 값은 기본값으로 되돌립니다. 페이지 크기도 화면이 허용한 선택지 밖이면 기본값으로 복원합니다. 재편 뒤에는 URL key를 API query 이름과 같게 맞춰 URL 상태를 변환 없이 조회 훅에 넘깁니다. 정렬 머리글은 오름차순, 내림차순, 정렬 없음 순으로 돌고 어느 경우든 첫 페이지로 이동합니다.

상세의 출처는 query가 아니라 경로로 정했습니다. 계절성, 실시간, 워치리스트 상세는 진입 파일 하나를 공유하고 라우트가 출처만 다르게 넘깁니다. 목록 행의 대상 연도는 serializer로 상세 URL에 담았습니다. history state에 두면 새 탭과 공유 링크에서 사라지기 때문입니다. 패널 열림처럼 그 화면 안에서만 의미 있는 상태는 지역 상태로 남겨 URL과 경계를 나눴습니다.

flowchart LR
    URL["URL query"] -->|parser가 허용 값 검사| State["조회 조건"]
    State -->|같은 이름 그대로| API["생성된 조회 훅의 query"]
    State --> Screen["필터, 정렬, 페이지"]
    Row["목록 행"] -->|serializer| Detail["상세 URL: 경로가 출처, query가 대상 연도"]
URL key와 API query 이름을 맞춘 계약ts
// 목록 URL 파서. key는 API query 이름 그대로 둔다
export const seasonalUrlParsers = {
    page: parseAsPageNumber.withDefault(page_number_default),
    pageSize: parseAsPageSize(seasonal_page_size_options).withDefault(seasonal_page_size_default),
    grade: parseAsStringLiteral(grade_options.map((option) => option.value)).withDefault(condition_draft_default.grade),
    startMonth: parseAsArrayOf(parseAsInteger).withDefault(filter_start_month_default),
    repeatRatioMin: parseAsFloat,
    // 지수, 월물, 상관계수와 중앙값 범위 생략
    sortCd: parseAsStringLiteral(sort_cd_values),
    columnSortTypeCd: parseAsStringLiteral(sort_direction_values),
}

// 화면은 URL 상태를 그대로 조회 조건으로 넘긴다
const [urlParams, setUrlParams] = useQueryStates(seasonalUrlParsers)
const responseRepeatPatterns = useSearchRepeatPatterns({
    query: { ...omit(urlParams, ['pageSize']), recordsPerPage: urlParams.pageSize, unit: unitStore.unit },
})

새로고침, 뒤로가기, 상세 복귀 뒤에도 이전 조회 상태가 그대로 남고 새 탭에서 연 상세도 같은 연도를 봅니다. 어떤 값이 URL에 있고 어떤 값이 화면에만 있는지가 계약으로 고정돼 화면이 늘어도 복원 규칙을 다시 정하지 않았습니다.

자주 바뀌는 API 명세를 생성 코드 밖에서 흡수하기

백엔드는 외부 협력사였고 명세가 자주 바뀌었습니다. 2026.09.28부터 10.20까지 명세 파일을 갱신한 날이 아홉 날이었습니다. 백엔드가 사내망에서만 열려 있어 명세도 그 안에서 내려받아 저장소에 두고 그 파일로 코드를 생성했습니다. 생성 코드를 손으로 고치면 재생성할 때마다 수정이 사라지고 화면 코드가 명세 구조에 묶입니다.

생성 코드는 고치지 않는다는 원칙을 세웠습니다. 생성 디렉터리는 실행할 때마다 통째로 비우고 lint 대상에서도 뺐습니다. 화면은 응답 변환 계층을 거친 화면 모델만 봅니다. 변환은 조회의 select와 combine에 둬서 응답이 바뀔 때만 다시 돌게 했습니다. LLM 도구용과 관리자용처럼 FE가 쓰지 않는 endpoint는 생성에서 제외했습니다. 내려받기 스크립트는 받은 문서에 OpenAPI 버전 필드가 없으면 저장하지 않습니다.

Kubb로 태그별 Axios 클라이언트, TypeScript 타입, Suspense 훅을 포함한 React Query 훅, Faker 모킹, MSW 핸들러와 Redoc 문서를 만듭니다. Kubb v4에서 v5로 옮기면서 transport 구조로 바꿔 생성 클라이언트가 인증 인터셉터를 단 Axios 인스턴스 하나를 쓰게 했습니다. MSW 핸들러는 e2e와 integration 테스트의 모킹 기반이 됩니다.

flowchart LR
    Spec["OpenAPI 명세 파일"] -->|매번 비우고 생성| Gen["생성 코드: 클라이언트, 타입, React Query 훅, Faker, MSW"]
    Gen --> Convert["응답 변환 계층(select, combine)"]
    Convert --> Model["화면 모델"]
    Model --> Page["화면"]
    Gen --> Mock["MSW 핸들러 위의 수동 모킹"]
    Spec -. 명세 변경 .-> Convert
생성 범위와 수동 코드의 경계ts
// 생성물 루트. 실행할 때마다 통째로 비우므로 수동 코드를 두지 않는다
const kubb_output_root = 'src/service/__kubb'

// FE가 쓰지 않는 endpoint는 생성하지 않는다
const kubb_exclude: Exclude[] = [
    { type: 'path', pattern: /^\/mcp\// },
    { type: 'path', pattern: /^\/api\/admin\// },
]

export default defineConfig({
    input: './openapi.json', // 사내망에서 받아 저장소에 둔 명세
    output: { path: kubb_output_root, clean: true, format: 'biome' },
    plugins: [
        pluginTs({ exclude: kubb_exclude }),
        pluginAxios({ exclude: kubb_exclude, returnType: 'data' }),
        pluginFaker({ exclude: kubb_exclude }),
        pluginMsw({ exclude: kubb_exclude, parser: 'faker', handlers: true }),
        pluginReactQuery({ exclude: kubb_exclude, client: 'axios', suspense: {} }),
    ],
})

명세 변경은 생성 코드를 다시 만든 뒤 응답 변환 계층에서 화면 모델에 맞추는 방식으로 대응했습니다. 생성 영역과 화면 영역의 변경 지점이 나뉘어 명세가 바뀌어도 고칠 곳을 찾는 범위가 좁았습니다.

VDI 밖에서도 실제 응답 모양으로 개발하기

개발계 백엔드는 사내망에 있어 VDI 안에서만 접속됐습니다. VDI에서는 클립보드도 쓸 수 없어 응답을 복사해 나올 수 없었습니다. 생성된 Faker 모킹만으로 화면을 만들면 실제 응답과의 차이를 배포 뒤에야 알게 됩니다. 실제로 초기 모킹의 시계열은 연 50~110점이었고 공통코드, 부호와 상대 날짜 형식도 실제와 달랐습니다.

VDI 안의 개발 서버가 받은 실응답을 파일로 남기고 저장소에 실어 밖으로 가져오는 경로를 만들었습니다. 명세 파일도 같은 경로로 옮깁니다. npm run dev:capture로 실행할 때만 응답 인터셉터가 응답을 개발 서버의 /__capture로 보내고 Vite 플러그인이 요청 URI를 파일 이름으로 바꿔 JSON으로 저장합니다. 캡처 파일은 VDI 안에서 커밋하고 밖에서 받아 모킹을 맞춘 뒤 지웠습니다.

실행 모드는 백엔드 기준으로 넷(mock, dev, prod, e2e)으로 줄이고 env/.env.<mode>와 1:1로 맞췄습니다. 같은 백엔드를 보던 dev와 vdi 모드는 하나로 합쳤고 vdi 문자열에 걸려 있던 분기는 실제 조건(개발 서버와 실제 API)으로 바꿨습니다. 모르는 모드로 빌드하면 설정에서 막습니다. 모드를 빠뜨리거나 오타를 내면 Vite 기본값 production으로 빈 설정을 빌드하기 때문입니다. 실제 API 모드일 때만 프록시를 켭니다.

flowchart LR
    subgraph VDI["VDI 안"]
        Dev["개발 서버(dev:capture)"] -->|응답 인터셉터| Plugin["Vite 플러그인"]
        Plugin --> Files["응답 JSON 파일"]
        SpecDl["명세 내려받기"] --> SpecFile["openapi.json"]
    end
    Files -->|커밋| Repo["저장소"]
    SpecFile -->|커밋| Repo
    Repo --> Mock["모킹 데이터 재구성"]
    Repo --> Gen["Kubb 재생성"]
    Mock --> Local["로컬 개발과 e2e"]
실응답 캡처 플러그인과 빌드 모드 검사ts
// `env/.env.<mode>`와 1:1. 모드가 틀리면 Vite 기본값 production으로 빈 설정을 빌드하므로 막는다
const build_modes = ['mock', 'dev', 'prod', 'e2e']

// 개발 서버가 받은 실응답을 src/service/capture/에 파일로 남긴다
const captureResponsePlugin: Plugin = {
    name: 'capture-response',
    configureServer(server) {
        server.middlewares.use('/__capture', (req, res) => {
            let body = ''
            req.on('data', (chunk) => (body += chunk))
            req.on('end', () => {
                const capture = JSON.parse(body)
                const name = capture.uri.replace(/^https?:\/\/[^/]+/, '').replace(/[^a-zA-Z0-9]+/g, '_').replace(/^_+|_+$/g, '')
                writeFileSync(`${capture_response_dir}/${name}.json`, JSON.stringify(capture, null, 2))
                res.end('ok')
            })
        })
    },
}

// 개발 서버, 실제 API, 캡처 명령이 모두 맞을 때만 플러그인을 붙인다
...(command === 'serve' && shouldProxyApi && env.VITE_CAPTURE === 'on' ? [captureResponsePlugin] : []),

캡처를 기준으로 모킹을 다시 만들었습니다. 시계열은 캡처한 실제 곡선을 만기일, 오프셋, 값으로 접어 담아 연 50~110점이던 모킹이 연 730점이 됐고 드라이버 시계열은 222점에서 4,784점이 됐습니다. 목록 값의 분포는 캡처한 261행의 십분위값으로 맞췄습니다. 모킹 코드는 요청을 읽고 상태 코드를 고르는 핸들러와 값을 만드는 모킹 데이터로 나누고 그 의존 방향을 grep 다섯 줄로 검사합니다. 캡처와 모킹은 화면 개발과 응답 구조 검증의 기반입니다. 백엔드의 업무 처리가 맞는지까지 검증한다는 뜻은 아닙니다.

데이터 의존 단위로 대기와 오류 경계 나누기

패턴 상세는 요약, 반복 패턴 그래프, 드라이버 시각화, 실시간 분석이 각각 다른 응답을 읽습니다. 경계를 화면 하나에 두면 한 응답이 늦거나 실패할 때 제목과 뒤로가기까지 사라집니다. 반대로 영역마다 무조건 두면 같은 응답을 읽는 두 영역이 따로 실패 화면을 띄웁니다.

처음에는 그래프 영역마다 오류 경계를 뒀습니다. 그러다 반복 패턴과 드라이버 시각화가 같은 계절성 응답을 읽어 한쪽만 살아남을 수 없다는 점을 보고 둘을 오류 경계 하나로 묶었습니다. 실시간 분석은 자체 응답을 읽으므로 경계를 따로 뒀습니다. 기준은 레이아웃이 아니라 응답을 공유하는 단위입니다. 대기 경계는 영역마다 둬서 늦은 영역만 스켈레톤을 보이게 했습니다. 오류를 풀 조건은 경계를 소유한 화면이 정합니다. 목록과 상세는 URL 경로나 query가 바뀔 때 다시 시도하고 그래프 영역은 가격 단위가 바뀔 때 다시 시도합니다. 현재 오류 경계는 12곳(루트 1, URL 기준 9, 단위 기준 2)이고 대기 경계는 7곳입니다.

경계 주변의 배선도 공용 부품으로 옮겼습니다. React Query의 조회 오류 해제를 호출부 여섯 곳이 반복하고 있었고 빠뜨려도 조용히 망가졌습니다. 이 해제를 공용 오류 경계 안으로 넣고 호출부의 onReset은 그 밖에 할 일이 있을 때만 쓰게 했습니다. 없는 패턴이나 지운 포지션처럼 404인 경우는 다시 시도해도 결과가 같습니다. 그래서 재시도 버튼 대신 진입한 목록으로 돌아가는 안내를 보이고 React Query 재시도에서도 404를 뺐습니다.

드라이버를 다시 고르면 이미 열린 대기 경계가 스켈레톤으로 돌아가는 문제도 있었습니다. Suspense 조회에는 이전 데이터를 유지하는 옵션을 쓸 수 없어 선택 값은 바로 바꾸고 조회에는 지연 값(useDeferredValue)을 넘겼습니다. 응답이 올 때까지 이전 그래프를 보여 주고 aria-busy로 대기 중임을 알립니다.

flowchart TB
    Root["루트 오류 경계(공용 Provider)"] --> Outlet["화면 외곽: URL 기준 오류 경계와 대기"]
    Outlet --> Detail["상세: URL 기준 오류 경계, 404 안내"]
    Detail --> Summary["요약 대기"]
    Detail --> RT["실시간 분석: 단위 기준 오류 경계와 대기"]
    Detail --> Shared["단위 기준 오류 경계 하나"]
    Shared --> Repeat["반복 패턴 대기"]
    Shared --> Driver["드라이버 시각화 대기"]
응답을 공유하는 두 그래프의 오류 경계tsx
// 대기 경계는 영역마다, 오류 경계는 같은 계절성 응답을 읽는 그래프 둘에 하나
// 실시간 분석은 자체 응답이라 오류 경계도 따로 둔다
return (
    <Fragment>
        {props.source !== 'seasonal' && (
            <UiErrorBoundary resetKeys={[unitStore.unit]}>
                <UiSuspense fallback={<PgChartSkeleton label={copy_realtime_analysis.title} />}>
                    <PgRealtimeAnalysis source={props.source} />
                </UiSuspense>
            </UiErrorBoundary>
        )}

        <UiErrorBoundary resetKeys={[unitStore.unit]}>
            <UiSuspense fallback={<PgChartSkeleton label={copy_repeat_pattern_title} />}>
                <PgRepeatPattern source={props.source} />
            </UiSuspense>
            <UiSuspense fallback={<PgChartSkeleton label={copy_driver_visualization_title} />}>
                <PgDriverVisualization source={props.source} />
            </UiSuspense>
        </UiErrorBoundary>
    </Fragment>
)

그래프 영역이 실패해도 상단 제목과 뒤로가기는 남습니다. 목록도 같은 규칙이라 조회가 500이면 표 자리만 오류 화면이 되고 제목 영역은 유지됩니다. 이 동작은 integration 테스트로 고정했습니다.

차트를 좌우로 옮겨도 날짜 간격이 흔들리지 않게 하기

ECharts dataZoom으로 같은 길이의 구간을 보면서 좌우로 패닝하면 달력상 위치에 따라 날짜 눈금 간격이 달라졌습니다. 확대 구간이 바뀌어도 Y축 범위는 전체 데이터 기준으로 남아 읽기 어려웠습니다. x축은 날짜 문자열을 칸으로 쓰는 category 축이라 ECharts가 라벨 위치를 스스로 정하지 못했습니다.

라벨 단위와 간격을 보이는 기간과 plot 폭으로만 정하고 창의 위치는 쓰지 않게 했습니다. 보이는 일수가 1,096일을 넘으면 연, 180일을 넘으면 월, 그 아래는 일 단위입니다. 간격은 라벨 하나의 픽셀 폭과 하루의 픽셀 폭을 비교해 겹치지 않는 가장 작은 후보로 고릅니다. 라벨은 달력 격자 칸에만 둡니다. 일 단위는 epoch 일수, 월 단위는 연과 월을 한 수로 바꾼 값을 간격으로 나눠 격자 번호를 만들므로 창을 옮겨도 라벨 자리가 그대로입니다. 축에 모든 날짜가 있지 않을 수 있어 격자마다 첫 칸에 라벨을 붙였습니다. Y축은 보이는 구간의 값으로 다시 계산합니다.

달력 격자에 고정한 날짜 눈금ts
// 날짜 칸이 속한 달력 격자 번호. 번호가 같으면 같은 라벨 대상이다
export const toCalendarBucket = (value: string, granularity: DateAxisLabelGranularity, step: number) => {
    const [year, month, day] = value.split('-').map(Number)
    if (granularity === 'year') return Math.floor(year / step)
    if (granularity === 'month') return Math.floor((year * 12 + month - 1) / step)
    return Math.floor(Date.UTC(year, month - 1, day) / DAY_MS / step)
}

// 단위는 보이는 일수로, 간격은 라벨 폭과 plot 폭으로 정한다. 창의 위치는 쓰지 않는다
const granularity = toGranularity(visibleDayCount)
const minimumStep = (labelPixelWidth[granularity] * visibleDayCount) / (plotWidth * daysPerUnit[granularity])
const step = candidates.find((candidate) => candidate >= minimumStep) ?? Math.ceil(minimumStep / largest) * largest

// 축에 모든 날짜가 있지는 않아 격자마다 첫 칸에 라벨을 둔다
const labelValues = new Set(Object.values(groupBy(categoryData, (value) => toCalendarBucket(value, granularity, step))).map((values) => values[0]))

차트 루트의 이펙트 다섯 개는 인스턴스 수명과 선언형 옵션 적용 두 관심사로 합쳤습니다. 생성, 크기 관찰, dataZoom과 클릭 구독, 해제는 인스턴스 수명에 두고 옵션의 입력이 바뀌면 setOption과 축 라벨 재계산을 함께 합니다. 통합 전후로 골든 7개가 바뀌지 않았고 option 덤프가 바이트 단위로 같았습니다. 이후 범례 선택은 legend.selected만 병합하는 세 번째 이펙트로 떼어 범례를 눌러도 series를 다시 만들지 않습니다. 렌더러는 canvas 하나로 정했습니다. svg는 이미지 저장에서 SVG를 돌려줘 충돌하고 인쇄나 확대가 필요한 화면이 없기 때문입니다.

이 계약 위에 x축을 공유하는 패널 넷, 만기연도 타임라인과 Vintage 범례, 확대 단계별 상대 날짜 라벨, 음영 구간 배지, x marker와 series area, 여러 색의 값 마커, 만기까지 남은 일수를 보이는 툴팁, 이미지 내보내기를 올렸습니다. 같은 확대 구간에서는 패닝해도 눈금 간격이 바뀌지 않습니다. 차트 부품이 한 곳에 모여 계절성, 실시간, 워치리스트 상세가 같은 엔진을 씁니다.

측정한 뒤에 병렬화하고 걷어내기

성능을 이유로 코드를 더하거나 뺄 때는 먼저 쟀습니다. 세 가지가 이 방식으로 바뀌었습니다.

먼저 Suspense가 만든 요청 대기열입니다. useSuspenseQuery를 나눠 부르면 첫 훅이 suspend하는 동안 아래 훅이 실행되지 않아 요청이 줄을 섭니다. 재편 전 상세 화면에서 재 보니 나눠 부른 경우 300ms 안에 요청 1건만 시작했습니다. 여섯 조회를 useSuspenseQueries 한 배열로 묶자 3건이 2ms 안에 함께 시작했습니다. 한 조회의 파라미터가 다른 응답에 묶여 있던 의존도 끊었습니다. 선택한 구간을 id가 아니라 구간 객체로 들고 있게 해 선택 전에는 파라미터를 보내지 않고 서버가 전체를 주게 했습니다. 현재 상세의 드라이버 시각화도 네 응답을 같은 방식으로 한 번에 요청합니다.

다음은 방어용 메모이제이션입니다. 차트 루트의 범례 Set과 표시 패널은 이펙트 의존성이라는 이유로 useMemo에 감싸여 있었습니다. 의존성을 이미 있던 문자열 키로 바꾸고 setOption 호출 수를 세어 보니 제거 전후가 같았습니다(마운트 10, 범례 토글 3, 전체 Spread 1, 확대 2). 이 메모는 이펙트 실행 횟수를 줄이지 못하고 있었습니다. 상세 화면의 응답별 useMemo 셋도 막으려던 재계산 경로가 실제로 일어나지 않는 것을 확인하고 combine 하나로 되접었습니다. 참조 동일성은 메모 대신 조회의 select와 combine에서 만들고 React Query의 구조 공유가 참조를 지키게 했습니다. 현재 생성 코드를 뺀 src에는 useMemo, useCallback, memo가 없습니다. 제거 뒤에는 골든 테스트와 픽셀 체크섬 비교로 화면이 같은지 확인했습니다.

마지막은 번들입니다. ECharts 전체를 불러오던 것을 echarts/core에 쓰는 차트와 컴포넌트 11개만 등록하는 방식으로 바꿨습니다.

쓰는 모듈만 등록한 EChartsts
import { LineChart, ScatterChart } from 'echarts/charts'
import { DataZoomComponent, GraphicComponent, GridComponent, LegendComponent, MarkAreaComponent, MarkLineComponent, ToolboxComponent, TooltipComponent } from 'echarts/components'
import { init, use } from 'echarts/core'
import { CanvasRenderer } from 'echarts/renderers'

// 전체 echarts를 불러오면 쓰지 않는 차트까지 번들에 들어간다
use([LineChart, ScatterChart, GridComponent, TooltipComponent, DataZoomComponent, LegendComponent, MarkLineComponent, MarkAreaComponent, GraphicComponent, ToolboxComponent, CanvasRenderer])

커밋 본문에 기록한 운영 빌드의 라우트 청크는 4,988KB에서 3,646KB로 약 27% 줄었습니다(gzip 기준 1,125KB에서 813KB). 이 값은 특정 청크의 빌드 크기입니다. 로딩 시간이나 앱 전체 크기의 개선율로 넓히지 않았습니다. 요청 병렬화의 측정도 재편 전 화면의 값이고 현재 화면의 응답 시간을 따로 재지는 않았습니다.

드라이버 통계를 새 창으로 열고 빈 값 규칙 정하기

드라이버 시각화의 평균과 표준편차 표는 OS 새 창으로 열어야 했습니다. 새 창은 다른 문서라 CSS가 상속되지 않고 React 트리 밖에 있습니다. 팝업 차단이 걸리면 버튼이 아무 반응도 없는 것처럼 보입니다. 표는 부모 화면에서 고른 드라이버와 Vintage 선택을 읽어야 했습니다.

새 창을 별도 라우트로 띄우지 않고 createPortal로 부모 트리 안에서 렌더했습니다. 드라이버 선택이 바뀌면 새 창의 표도 그대로 따라옵니다. 새 창이 선택을 읽어야 해서 Vintage 선택의 소유를 그래프에서 화면으로 올렸습니다. 창은 고정 이름으로 열어 다시 누르면 같은 창을 앞으로 가져오고 열 때 부모의 스타일을 복제합니다. window.open이 null을 돌려주면 차단으로 보고 안내를 띄웁니다. 부모가 언마운트되면 창도 닫습니다. 남겨 두면 갱신되지 않는 표만 남기 때문입니다. 복제한 스타일은 열 때의 것뿐이라 새 창의 표는 MUI 없이 자체 클래스로만 그렸습니다.

같은 창을 재사용하는 새 창 열기ts
const handleOpen = () => {
    // 이미 열려 있으면 앞으로 가져오기만 한다. 문서를 다시 쓰면 포털 렌더 결과가 사라진다
    if (isNotNil(popup) && !popup.closed) {
        popup.focus()
        return
    }

    const opened = window.open('', popup_window_name, popup_window_features)
    // null이면 브라우저가 막은 것이다. 버튼이 무반응으로 보이지 않게 안내한다
    setIsBlocked(isNil(opened))
    if (isNil(opened)) return

    // 다른 문서라 CSS가 상속되지 않아 부모의 스타일을 복제한다
    opened.document.head.replaceChildren(...[...document.querySelectorAll('style, link[rel="stylesheet"]')].map((node) => opened.document.importNode(node, true)))
    opened.document.title = copy_driver_stats.window_title
    opened.document.body.replaceChildren()
    setPopup(opened)
}

서버 통계는 분모가 없거나 날짜가 비어 올 수 있었습니다. 그대로 그리면 0으로 나눈 값이나 빈 날짜가 화면에 나타납니다. 개발계 캡처와 대조해 규칙을 정했습니다. 발행일 목차와 반복률 분모는 발생과 미발생 두 상태만 셉니다. 미도래와 진행중인 해도 구간 날짜를 갖고 오지만 분모에 들지 않아 뺐습니다. 처음에는 합계 행을 화면에서 다시 집계했지만 캡처와 백엔드 답변을 거쳐 지금은 모든 행이 서버 통계를 그대로 쓰고 만기연도 행은 관측 수가 3 미만이면 표준편차를 비웁니다. Spread 정보가 없는 해는 새 창의 행에서 뺐습니다.

서버가 "None" 글자로 준 빈 구간ts
// 창이 없는 해를 서버가 "None" 글자로 주는 캡처가 있어 날짜 형식이 아니면 없음으로 본다
const date_pattern = /^\d{4}-\d{2}-\d{2}$/

export const toVintageWindow = (window: DriverYearlyPatternDTO | null | undefined): VintageWindow | undefined => {
    const startDate = window?.windowStartDate
    const endDate = window?.windowEndDate
    if (isNil(startDate) || isNil(endDate) || !date_pattern.test(startDate) || !date_pattern.test(endDate)) return undefined
    return { startDate, endDate }
}

통계가 비어 있어도 화면이 깨지지 않고 어떤 상태인지가 보입니다. 화면은 이 응답 변환 계층이 보정한 화면 모델만 그립니다.

화면을 더할 때마다 회귀 범위를 고정하기

계절성, 실시간, 워치리스트 화면이 이어서 추가됐고 명세도 계속 바뀌어서 어디까지 다시 확인해야 하는지가 매번 흔들렸습니다.

검증 범위를 세 층으로 나누고 층마다 책임을 문서로 정했습니다. 층을 고르는 질문은 셋입니다. 브라우저 없이 답이 나오면 unit, 그려 봐야 알고 props나 응답을 바꿔 봐야 하면 integration, 화면을 넘어가야 하면 e2e입니다. 같은 상태를 두 층에서 보지 않습니다. e2e는 위젯을 한 번 지나가며 연결만 보고 위젯 안의 상태는 integration이 봅니다. 차트는 canvas라 DOM으로 볼 수 없습니다. 무엇을 그리라고 시켰는지는 골든이 option 데이터로 보고 그려진 결과는 차트별 첫 창과 확대 상태의 스크린샷 다섯 장을 픽셀 1% 허용 오차로 대조합니다.

e2e는 압축하지 않고 소스맵을 넣은 e2e 모드로 빌드해 preview 서버에 띄우고 MSW service worker가 API를 대신합니다. 개발 서버에 워커 여럿이 동시에 붙으면 빈 화면이 났기 때문입니다. integration은 같은 서버의 story gallery 위에 화면 구역을 mount하고 e2e와 같은 MSW 핸들러로 응답합니다. 테스트가 바꾸는 응답은 기본 모킹이 만들지 않는 빈 목록, 500, 401뿐입니다.

flowchart TB
    S["static: tsc, Biome, stylelint"] --> U["unit과 골든: Vitest, 함수 반환값과 차트 option 대조"]
    U --> I["integration: Playwright, story gallery, 응답 교체, 차트 스크린샷"]
    I --> E["e2e: Playwright, e2e 빌드와 MSW service worker, 화면 사이 연결"]
    E --> X["시나리오 Excel과 커버리지 결과서"]

locator는 role과 label을 우선했습니다. 테스트에서 getByRole을 377회, getByLabel을 74회 쓰고 코드에 data-testid는 없습니다. 테스트가 버튼과 링크의 접근 가능한 이름, 표 머리글의 aria-sort, 토글의 aria-pressed, 진행 막대의 aria-valuenow를 단언합니다. 그래서 접근성 속성이 빠지거나 바뀌면 테스트가 깨집니다. 접근성 속성이 테스트 계약이 된 셈입니다. 별도 접근성 감사 도구로 검사한 것은 아닙니다.

오류 경계와 접근성 속성을 단언하는 테스트ts
test('[WLS-005] 목록 조회가 500이면 표 자리만 오류 화면이 되고 제목 영역은 유지된다', async ({ mockApi, mount, page }) => {
    /**
     * GIVEN
     * 목록 조회가 500으로 실패하는 서버
     */
    await mockApi.use(getWatchPatternsHandler(() => getWatchPatternsHandlerResponse500(createGetWatchPatternsStatus500())))
    await mount('1-watchlist/1.1-watchlist/Default')

    /**
     * THEN
     * 다시 시도 버튼이 있는 오류 화면이 표시되고 새로고침 버튼은 유지됨
     */
    await expect(page.getByRole('button', { name: '다시 시도' })).toBeVisible()
    await expect(page.getByRole('button', { name: '새로고침' })).toBeVisible()
})

// 정렬과 진행 상태도 접근성 속성으로 확인한다
await expect(realtimePage.table.getByRole('columnheader', { name: '반복률' })).toHaveAttribute('aria-sort', 'descending')
await expect(firstRow.getByRole('progressbar', { name: '진행률' })).toHaveAttribute('aria-valuenow', '21')

커버리지는 실행된 코드의 범위를 확인하는 보조 지표로 붙였습니다. Playwright 실행에서는 테스트마다 V8 커버리지를 모으고 e2e 빌드의 소스맵으로 src 원본에 매핑해 c8로 istanbul 결과서를 만듭니다. 소스맵의 원본 경로를 절대 경로로 남긴 것도 이 매핑 때문입니다. Vitest는 coverage-v8로 같은 범위(생성 코드, 모킹, 캡처 제외)를 잽니다.

테스트 본문은 GIVEN, WHEN, THEN 블록으로 쓰고 제목은 QA와 기획이 쓰는 용어로 통일했습니다. 테스트 주석과 Playwright, Vitest의 JSON 결과를 모아 요약, 화면별, 단위 시트로 된 테스트 시나리오 Excel을 만드는 스크립트를 붙였고 제품 결정을 기다리는 케이스는 따로 목록으로 둡니다. 계절성 화면부터는 화면을 테스트와 함께 병합했고 시나리오 Excel은 QA와 PM에게 공유했습니다. 어떤 층이 깨졌는지로 버그인지 의도된 변경인지를 가늠합니다.

레이어 경계와 스타일 소유권을 lint로 강제하기

화면이 늘면서 page, widget, ui의 경계와 CSS 소유권이 흐려졌습니다. 문서로만 둔 규칙은 화면을 추가할 때마다 다시 확인해야 했습니다. 재편 전에는 실시간, Spike, 모니터링 표가 제각각 구현돼 정렬과 머리 셀 동작도 달랐습니다.

HCMS에서 정리해 분리한 규약을 기준으로 삼고 규칙을 lint 설정으로 옮겼습니다. Biome의 import 제한으로 ui는 widget과 page를, widget은 page를 가져오지 못하게 했습니다. 라우트 소유자(워치리스트, 실시간, 계절성, 패턴 상세, File Update)는 다른 화면의 내부를 가져오지 못합니다. dayjs는 날짜 유틸 래퍼로만 부르고 상대 경로 import는 동반 CSS만 허용합니다. 코드 형태 규칙 둘은 GritQL 플러그인으로 썼습니다. import.meta.env는 환경 설정 파일에서만 읽고 ??와 || 오른쪽에는 리터럴을 두지 않고 이름 붙인 상수를 참조합니다.

리터럴 fallback을 막는 GritQL 규칙text
language js

or {
    `$left ?? $right`,
    `$left || $right`
} where {
    $right <: or { string(), number(), `true`, `false`, `[]`, `{}` },
    register_diagnostic(span = $right, message = "Do not put a literal on the right of ?? or ||. Reference a declared name.")
}
레이어 방향과 라우트 소유자 격리jsonc
// ui는 widget과 page를 모른다
{
  "includes": ["src/component/ui/**"],
  "linter": { "rules": { "style": { "noRestrictedImports": { "level": "error", "options": { "patterns": [
    { "group": ["@/component/widget/**", "@/page/**"], "message": "ui must not import widget or page." }
  ] } } } } }
},
// 라우트 소유자는 다른 화면의 내부를 가져오지 않는다
{
  "includes": ["src/page/(main)/watchlist/**"],
  "linter": { "rules": { "style": { "noRestrictedImports": { "level": "error", "options": { "patterns": [
    { "group": ["@/page/**", "!@/page/(main)/watchlist/**"], "message": "Do not import the inside of another route owner." }
  ] } } } } }
}

CSS는 stylelint로 소유권을 강제했습니다. 레이어마다 클래스 접두사(pg_, wg_, ui_)와 요소, 수정자 형식을 정하고 다른 레이어의 접두사를 겨냥하는 선택자를 막았습니다. .Mui처럼 라이브러리 DOM을 직접 고치는 선택자는 라이브러리를 감싸는 ui 레이어에서만 허용하고 화면은 ui가 내놓은 이름이나 variant로 다룹니다. 지역 커스텀 프로퍼티 선언, !important, :not, 상태를 aria 속성 선택자로 다시 여는 것도 막고 상태는 수정자 클래스로 표현합니다.

이 규칙은 2026.10에 ESLint와 Prettier를 Biome으로 바꾸면서 함께 옮겼고 줄 주석 간격 검사는 별도 스크립트로 뺐습니다. pre-commit에서 lint-staged로 Biome 검사를 돌립니다. 정리 과정에서 죽은 모듈, prop, CSS 변수를 걷어내고 유틸은 es-toolkit과 dayjs 래퍼로 통일했으며 리터럴 fallback에는 이름을 붙여 경계로 옮겼습니다.

표는 재편 전에 TanStack Table 한 엔진으로 통일하고 UiTable compound(HeadCell, 정렬)로 묶었습니다. 재편 뒤 목록은 정렬과 페이지를 서버가 맡게 돼 클라이언트 표 엔진이 할 일이 없어졌습니다. 남은 표는 UiTable compound만으로 그리고 안 쓰는 패키지를 정리하면서 TanStack Table도 걷어냈습니다.

레이어 위반, 다른 화면 내부 참조, 리터럴 fallback, 라이브러리 DOM 직접 수정은 커밋 전에 막힙니다. 이후 화면 추가는 같은 규약을 따랐습니다.

발주사 요청으로 화면을 다시 세우고 진입에서 청산까지 잇기

2026.09 발주사 요청을 SK AX가 수락하면서 Fundamental과 MI 분석 패널, AI 챗봇, driver 도식표가 있던 구형 목록과 상세를 걷어내고 워치리스트, 실시간, 계절성 중심으로 재편해야 했습니다. 옛 코드와 새 화면이 섞이면 죽은 코드와 라우트가 남습니다.

구형 화면의 원본은 별도 저장소와 브랜치에 보존하고 작업 브랜치에서는 제거했습니다. 2026.09.17에 구형 화면과 함께 안 쓰는 위젯 13개와 그 골든, 사라진 endpoint의 모킹을 지우고 남은 유틸과 패키지를 정리했습니다. 메인 탭은 경로 없는 라우트 그룹 아래에 두고 계절성, 실시간, 워치리스트 상세는 진입 파일 하나를 공유하게 했습니다. README와 가이드도 현재 라우트에 맞게 고쳤습니다.

새 흐름은 실시간 패턴에서 진입을 등록하면 워치리스트에 나타나고 청산하면 빠지는 순서입니다. 진입 가능 날짜 같은 업무 규칙은 서버가 거절하기 전에 화면이 막았습니다. 진입 모달은 필드를 검증하고 진입일을 패턴 구간 시작 30일 전부터 오늘까지로 제한합니다. 진입과 청산 뒤에는 관련 목록 조회를 무효화해 화면과 서버 상태를 맞춥니다.

flowchart LR
    RT["실시간 목록"] -->|진입 모달, 구간 시작 30일 전부터 오늘까지| WL["워치리스트"]
    WL -->|행 클릭, 대상 연도| D1["패턴 상세(워치리스트)"]
    WL -->|청산 확인| Out["워치리스트에서 제외"]
    RT --> D2["패턴 상세(실시간)"]
    SS["계절성 목록"] --> D3["패턴 상세(계절성)"]

실시간 화면은 그룹 목록과 패턴 표, 조건 팝오버, 진입 모달로 구성했습니다. 워치리스트는 정렬, 페이지, 포지션 카드, 진입과 청산 표식, 청산 확인 모달을 갖췄습니다. 상세에는 AI 요약 상자, Today 표식, 최신 Vintage 차트, 발행일 카드, 드라이버 카드 넷과 서버 통계, KPI 다섯 칸, 고점과 저점 표식을 두고 정렬은 서버 정렬 코드와 연결했습니다.

운영자용 File Update도 같은 규칙을 따릅니다. 업로드 이력의 날짜, 정렬, 페이지는 다른 목록과 같은 URL 계약에 두고 여러 파일을 올리면 파일마다 진행률, 완료, 실패를 따로 보여 줍니다. 2026.10에 업로드 형식이 CSV에서 xlsx로 바뀌고 화면 이름도 Report Update에서 File Update로 바뀌었습니다. 파일명 규칙은 서버가 판정해 파일별 실패로 돌려주므로 화면은 확장자만 거르고 응답으로 파일마다 성공 여부를 정합니다.

현재 화면은 워치리스트, 실시간, 계절성 목록, 공용 패턴 상세, File Update로 정리됐습니다. 탐색에서 진입, 모니터링, 청산까지 한 흐름으로 이어지고 날짜와 필드처럼 화면에서 판단할 수 있는 조건은 요청 전에 검사합니다. 제거된 기능에 들인 작업은 수행 이력으로만 남기고 현재 기능으로는 쓰지 않습니다.

결과와 근거

확인한 결과근거
호스트, 단독 개발, e2e 빌드가 같은 라우트와 Provider 사용vite.config.ts의 federation 설정과 모드 분기, 두 진입점 파일
진입 파일은 캐시 금지, 해시 자산은 1년 캐시nginx.remote.conf
동시 만료 요청이 갱신 하나를 공유하고 늦은 실패는 새 토큰으로 재전송인증 인터셉터 코드, 세션 e2e와 integration 테스트
새로고침, 뒤로가기, 새 탭에서 조회 상태 유지nuqs parser 계약, 목록 e2e 테스트
실응답 기준 모킹 재구성캡처 플러그인, 모킹 재구성 커밋에 남긴 점 수
오류 경계 12곳, 대기 경계 7곳과 부분 실패 동작경계 배치 코드, integration 테스트
요청 3건이 2ms 안에 동시 시작재편 전 상세 화면 커밋 본문의 측정값
메모 제거 전후 setOption 호출 수 동일커밋 본문의 계측값, 골든 테스트
라우트 청크 4,988KB에서 3,646KBECharts 모듈 등록 커밋 본문의 운영 빌드 값
패닝해도 눈금 간격 유지날짜 축 함수, 차트 골든과 스크린샷 대조
회귀 범위 고정세 층 테스트, role과 label 기반 locator, 커버리지 결과서, 시나리오 Excel
레이어와 스타일 위반 차단biome.jsonc, GritQL 규칙 둘, stylelint.config.js

측정값은 모두 해당 커밋 시점의 특정 화면이나 빌드에서 잰 값입니다. 사용자 응답 속도, 운영 비용, 이용자 수 같은 효과로 넓히지 않았습니다.

배운 것