Navigation

SK AX 원자재 스프레드 분석 및 모니터링 서비스

LPG와 LNG 원자재 스프레드의 과거 패턴과 현재 흐름을 비교해 후보를 찾고 진입 등록한 패턴의 상태를 모니터링하는 의사결정 지원 서비스입니다. Host 앱에 동적으로 탑재되는 Module Federation Remote 프론트엔드를 처음부터 혼자 구현했고 지금도 이어서 맡고 있습니다.

개요

항목내용
기간2026.04 ~ 현재
역할Remote 앱 프론트엔드 단독 구현. 런타임, 인증, 화면, 시각화, 테스트, 규약
인원프론트엔드 1명(본인). 백엔드와 UI/UX는 외부 조직, PM은 SK AX 담당
서비스원자재 스프레드 패턴 탐색, 진입 등록, 모니터링, 분석. 실제 주문을 체결하는 거래 시스템은 아닙니다
현재 화면워치리스트, 실시간 패턴, 계절성 패턴 목록, 패턴 상세, 계절성 상세, File Update
기술React 19, TypeScript, Vite, Module Federation, React Router 7, TanStack Query, TanStack Table, Zustand, nuqs, Axios, react-hook-form, MUI 9, Emotion, ECharts, Kubb v5, OpenAPI, MSW, Faker, es-toolkit, dayjs, Playwright, Vitest, ESLint 9, Prettier, Stylelint, Husky, lint-staged
제거된 화면의 기술React Flow, dagre, marked. 2026.09 화면 재편 전까지 쓰였습니다

세 단계로 진행했습니다.

단계시기내용
초기 구축2026.04 ~ 2026.07Remote 런타임과 인증, 패턴 발굴과 모니터링 화면, ECharts 차트, driver 도식표, AI 챗봇, Report Update
계절성 분석2026.08 이후계절성 패턴 목록과 상세, 드라이버 시각화 pane, 표 엔진 통일, 세 층 테스트, Kubb v5, 차트와 URL 계약, 코드 규약
실시간과 워치리스트2026.09.22 ~ 2026.10.13발주사 요청에 따른 화면 재편 뒤 실시간 목록과 상세, 워치리스트, 계절성 상세 고도화, Vitest 전환, 테스트 시나리오 Excel

맡은 범위

문제와 해결

코드는 고객사 저장소의 실제 코드를 옮긴 것이 아니라 같은 구조를 재구성한 예시입니다. 이름과 세부는 바꿨고 동작 규칙은 실제와 같습니다.

Host 안에서도 혼자서도 같은 라우팅으로 돌게 하기

문제. Remote 앱은 Host 앱에 동적으로 탑재되지만 개발 중에는 혼자서도 실행돼야 했습니다. 두 환경에서 라우팅과 인증이 다르게 동작하면 화면 코드가 둘로 갈립니다.

판단. React, React DOM, React Router를 shared singleton으로 두고 Remote가 상대 경로를 기준으로 라우트를 소유하게 했습니다. 인증은 Host가 저장한 상태를 그대로 초기화하되 업무 API와 token 갱신 API의 주소는 분리했습니다.

구현. Remote 진입점과 Standalone 진입점이 같은 Provider shell과 라우트를 공유합니다. Kubb가 생성한 Axios 인스턴스에 Bearer token을 주입하고 인증 만료 코드가 오면 refresh 요청을 하나만 보내고 대기 중인 요청을 그 뒤에 재실행합니다. 이후 token 갱신 대기열을 단순화하고 세션 만료 처리를 더했으며 dev, mock, prod, e2e 환경 설정을 하나로 합쳤습니다.

flowchart LR
    Host["Host 앱"] -->|동적 로드| Remote["Remote 진입점"]
    Standalone["Standalone 진입점"] --> Shell["공용 Provider shell"]
    Remote --> Shell
    Shell --> Routes["상대 경로 라우트"]
    Shell --> Auth["인증 상태 초기화"]
    Auth --> Biz["업무 API 클라이언트"]
    Auth --> Refresh["token 갱신 API"]
인증 만료와 단일 refreshts
// refresh는 한 번만 보낸다. 같은 시점에 만료된 요청들은 같은 promise를 기다렸다가 재실행한다
let refreshing: Promise<string> | null = null
const refreshToken = () => (refreshing ??= requestNewToken().finally(() => { refreshing = null }))

api.interceptors.response.use(undefined, async (error) => {
  if (!isAuthExpired(error) || error.config.retried) throw error
  const token = await refreshToken()
  return api({ ...error.config, retried: true, headers: { ...error.config.headers, Authorization: `Bearer ${token}` } })
})

결과. Host 통합과 Standalone 개발을 한 코드로 실행합니다. 라우트와 인증 경계가 한 곳에 있어서 이후 화면 추가는 이 구조를 그대로 탔습니다.

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

문제. 패턴 유형, threshold, 조건 조합, 최대 6개의 정렬 조건, 페이지, 탭, 패널 열림 상태를 컴포넌트 지역 상태에만 두면 새로고침이나 뒤로가기 뒤에 사용자가 보던 화면을 복원할 수 없습니다. 상세에서 목록으로 돌아올 때도 같은 문제가 생깁니다.

판단. URL을 조회 조건의 단일 기준으로 정했습니다. nuqs parser로 URL query schema를 정의하고 잘못된 값은 허용 범위로 정규화하며 상세 화면에도 진입 출처와 탭, 모드, 방향을 남겨 목록으로 돌아갈 때 맥락을 유지합니다. 이 원칙은 HCMS에서 만든 규약에서 왔고 LG Shield에서도 같은 방식을 썼습니다.

구현. parser를 URL 계약으로 두고 serializer가 같은 계약을 공유하도록 묶었습니다. 탭마다 독립된 URL 상태를 가지며 URL 상태와 화면 지역 상태의 경계를 정의했습니다. 같은 조회 조건을 API 요청과 React Query 키에 그대로 쓰고 CSV 추출도 조회 조건을 유지한 전체 결과를 내려받습니다.

flowchart LR
    URL["URL query"] -->|parser| State["조회 조건 상태"]
    State -->|serializer| URL
    State --> Query["API 요청과 React Query 키"]
    State --> Screen["필터, 정렬, 페이지, 탭, 패널"]
    Detail["상세 화면"] -->|출처, 탭, 모드, 방향| URL
조회 조건 계약ts
// parser가 URL을 읽고 serializer가 같은 계약으로 URL을 만든다. 잘못된 값은 기본값으로 돌아간다
const patternQuery = {
  type: parseAsStringEnum(['seasonal', 'realtime']).withDefault('seasonal'),
  threshold: parseAsInteger.withDefault(70),
  sort: parseAsArrayOf(parseAsString).withDefault([]), // 최대 6개. 넘치면 앞에서 자른다
  page: parseAsInteger.withDefault(1),
  tab: parseAsStringEnum(['summary', 'history']).withDefault('summary'),
  from: parseAsStringEnum(['seasonal', 'realtime', 'watchlist']),
}
const [query, setQuery] = useQueryStates(patternQuery)
const serialize = createSerializer(patternQuery)

// 상세로 갈 때 출처와 탭을 남긴다. 목록으로 돌아오면 같은 계약으로 읽는다
navigate(serialize(`/pattern/${id}`, { from: query.type, tab: 'summary' }))
// 같은 조회 조건이 API 요청과 React Query 키가 된다
useQuery({ queryKey: ['patterns', query], queryFn: () => listPatterns(toRequest(query)) })

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

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

문제. 백엔드가 외부 조직이고 명세가 자주 바뀌었습니다. 2026.09.28부터 10.13까지 보름 동안 명세를 다섯 번 다시 받았습니다. 생성 코드를 손으로 고치면 재생성할 때마다 수정이 사라지고 화면 코드가 명세 구조에 묶입니다.

판단. 생성 코드는 수정하지 않는다는 원칙을 세웠습니다. clean 대상을 생성 디렉터리로 제한하고 화면은 mapper와 adapter를 거친 ViewModel만 봅니다. 명세가 바뀌면 생성 파일이 아니라 mapper와 adapter를 고칩니다.

구현. Kubb로 OpenAPI 태그별 Axios 클라이언트, TypeScript 타입, React Query 훅, Faker mock, MSW 핸들러를 생성합니다. 이후 Kubb v4에서 v5로 옮기면서 transport 구조를 정리했고 명세 갱신마다 재생성만으로 대응했습니다. MSW 핸들러는 그대로 e2e 테스트의 mock이 됩니다.

flowchart LR
    Spec["OpenAPI 명세"] -->|Kubb 생성| Gen["생성 코드: Axios 클라이언트, 타입, React Query 훅, Faker mock, MSW"]
    Gen --> Mapper["mapper와 adapter"]
    Mapper --> VM["ViewModel"]
    VM --> Page["화면"]
    Spec -. 명세 변경 .-> Mapper
생성 코드와 화면 사이의 mapperts
// 생성 타입은 그대로 두고 화면이 쓰는 모양만 여기서 정한다. 명세가 바뀌면 이 파일만 고친다
import type { PatternResponse } from '@/service/__kubb/type' // Kubb가 다시 만들어도 손대지 않는다

export interface PatternRow { id: string; name: string; score: number; window: [Date, Date]; status: '진입 가능' | '모니터링 중' | '종료' }

export const toPatternRow = (res: PatternResponse): PatternRow => ({
  id: String(res.patternId),
  name: res.patternNm ?? '',
  score: res.score ?? 0,
  window: [parseDate(res.startDt), parseDate(res.endDt)],
  status: STATUS[res.statusCd] ?? '종료',
})
// 골든 테스트는 이 함수의 출력 JSON을 대조한다. 명세가 바뀌어 출력이 달라지면 여기서 먼저 깨진다

결과. 명세 변경이 화면 코드까지 번지지 않았습니다. 다섯 번의 재생성 동안 생성 파일을 고친 적이 없고 변경은 mapper와 adapter 안에서 끝났습니다.

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

문제. ECharts dataZoom으로 같은 길이의 구간을 보면서 좌우로 패닝하면 달력상 위치에 따라 날짜 tick 간격이 달라졌습니다. 확대 구간이 바뀔 때 Y축 범위도 전체 데이터 기준으로 남아 읽기 어려웠습니다.

판단. 시간축은 viewport 기간과 실제 차트 폭을 기준으로 일, 월, 연도 간격을 고르고 epoch 기준으로 tick을 만들도록 했습니다. 같은 길이의 구간은 위치와 관계없이 같은 간격을 쓴다는 패닝 불변성을 규칙으로 뒀습니다. Y축은 보이는 구간의 값으로 다시 계산합니다.

구현. 차트 이펙트 다섯 개를 인스턴스 수명과 선언형 옵션 적용 두 개로 통합해 ECharts를 두 관심사로 나눴습니다. 이 계약 위에 x축을 공유하는 pane 네 개, 만기연도 타임라인과 Vintage 범례, 확대 단계별 상대 날짜 라벨(월, 반월, 일), 음영 구간 배지, x marker와 series area, 여러 색의 값 마커, countdown tooltip, 이미지 내보내기를 올렸습니다. 평균과 표준편차는 새 창으로 엽니다.

패닝 불변 날짜 tickts
// 간격은 보이는 기간과 차트 폭으로만 정한다. 같은 기간이면 달력상 위치가 달라도 같은 간격이다
function tickStep(viewportMs: number, chartWidthPx: number): 'day' | 'month' | 'year' {
  const pxPerDay = chartWidthPx / (viewportMs / DAY_MS)
  if (pxPerDay >= 40) return 'day'
  if (pxPerDay >= 2) return 'month'
  return 'year'
}
// tick은 epoch 기준 달력 경계에 붙인다. 시작점이 하루 움직여도 tick 위치는 그대로다
function ticks(from: number, to: number, step: ReturnType<typeof tickStep>) {
  const out: number[] = []
  for (let t = floorToBoundary(from, step); t <= to; t = nextBoundary(t, step)) out.push(t)
  return out
}

결과. 같은 확대 구간에서는 패닝해도 tick 간격이 바뀌지 않습니다. 차트 부품이 ui/chart 한 곳으로 모여 계절성 상세와 패턴 상세, 실시간 상세가 같은 엔진을 씁니다.

Axios 밖에서 도는 챗봇 스트림의 인증 만료 고치기

문제. 운영에서 챗봇 응답만 인증 만료 뒤 복구되지 않는다는 피드백이 왔습니다. 다른 요청은 Axios interceptor가 token을 갱신했지만 챗봇 스트림은 fetch로 따로 도는 경로라 인증 만료 응답을 잡지 못했습니다.

판단. 스트림도 같은 인증 경계 안에 넣되 갱신은 한 번만 하도록 했습니다. 인증 만료 코드일 때만 공용 refresh promise를 기다린 뒤 스트림 요청을 한 번 재실행합니다. SSE block과 NDJSON line을 모두 읽을 수 있게 parser를 분리했습니다.

구현. 재시도 로직을 스트림 요청에 붙이고 Markdown 응답은 raw HTML을 주입하지 않고 marked token을 React node로 바꿔 링크 protocol과 표의 가로 overflow를 제어했습니다.

sequenceDiagram
    participant UI as 챗봇 화면
    participant S as 스트림 요청(fetch)
    participant R as 공용 refresh
    UI->>S: 스트림 시작
    S-->>UI: 인증 만료 코드
    UI->>R: token 갱신 요청(하나만)
    R-->>UI: 새 token
    UI->>S: 스트림 요청 한 번 재실행
    S-->>UI: SSE 또는 NDJSON 응답
Axios 밖 스트림의 1회 재시도ts
// interceptor를 거치지 않는 fetch 스트림도 같은 refreshToken을 기다린다. 재시도는 한 번뿐이다
async function streamChat(body: ChatBody, retried = false): Promise<Response> {
  const res = await fetch(CHAT_URL, { method: 'POST', body: JSON.stringify(body), headers: authHeaders() })
  if (isAuthExpired(res) && !retried) {
    await refreshToken()
    return streamChat(body, true)
  }
  return res
}
// 응답은 SSE block과 NDJSON line 두 형식을 같은 reader로 읽는다
for await (const chunk of readStream(res.body)) {
  for (const event of parseSseOrNdjson(chunk)) append(event)
}

결과. 만료 뒤 한 번만 재실행하고 끝나며 다른 요청의 갱신과 겹치지 않습니다. 이 화면은 발주사 요청에 따른 2026.09 화면 재편으로 제거됐지만 interceptor 밖의 경로도 인증 경계에 넣어야 한다는 기준은 이후 구조에 남았습니다.

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

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

판단. 검증 범위를 세 층으로 나누고 층마다 책임을 문서로 정했습니다. unit과 골든은 mapper 출력 JSON을 대조합니다. integration은 story gallery와 page.route로 화면을 고정하고 스크린샷 대조를 포함합니다. e2e는 MSW service worker 위에서 흐름을 봅니다. 계절성 화면부터는 테스트를 함께 작성해 병합합니다.

구현. 공용 fixture를 세우고 unit과 골든을 Vitest로 옮겼습니다. 테스트 본문은 GIVEN, WHEN, THEN 블록으로 쓰고 제목은 QA 용어로 통일했습니다. 테스트 주석과 Playwright, Vitest의 JSON 결과를 모아 화면별 시트와 단위 시트의 테스트 시나리오 Excel을 만드는 스크립트를 붙였고 제품 결정을 기다리는 케이스는 따로 목록으로 둡니다.

flowchart TB
    U["unit과 골든: Vitest, mapper 출력 JSON 대조"] --> I["integration: Playwright, story gallery와 page.route, 스크린샷 대조"]
    I --> E["e2e: Playwright, MSW service worker"]
    E --> X["테스트 시나리오 Excel: GIVEN, WHEN, THEN 주석과 실행 결과"]
GIVEN, WHEN, THEN 블록ts
// 시나리오 Excel 스크립트가 이 주석을 읽어 화면별 시트의 행을 만든다
test('워치리스트에서 모니터링을 종료하면 목록에서 사라진다', async ({ page }) => {
  // GIVEN 진입 등록한 패턴이 하나 있다
  await page.route('**/watchlist*', mockWatchlist([entered]))
  await page.goto('/watchlist')
  // WHEN 모니터링 종료를 누르고 확인한다
  await page.getByRole('button', { name: '모니터링 종료' }).click()
  await page.getByRole('button', { name: '확인' }).click()
  // THEN 목록이 비어 있다
  await expect(page.getByRole('row')).toHaveCount(1)
})

결과. 계절성 화면부터 회귀 확인 범위가 고정됐고 시나리오 Excel을 QA와 PM에게 공유했습니다. 어떤 층이 깨졌는지로 버그인지 의도된 변경인지를 가늠합니다.

레이어 경계와 표 엔진을 한 규약으로 모으기

문제. 화면이 늘면서 page, widget, ui의 경계와 CSS 소유권이 흐려졌고 실시간, Spike, 모니터링 표가 제각각 구현돼 정렬과 머리 셀 동작이 달랐습니다.

판단. HCMS에서 분리한 규약 저장소를 기준으로 삼았습니다. page, widget, ui 레이어와 CSS 소유권 규칙, 비공개 파일 표기를 정하고 lint로 경계를 강제합니다. 표는 TanStack Table 한 엔진으로 통일합니다.

구현. 표 세 종을 TanStack Table v9로 옮기고 UiTable compound(HeadCell, 정렬 컨텍스트)로 묶었습니다. 목록 화면의 Index 섹션, 조건 초안, 정렬 토글을 공용 위젯으로 빼고 죽은 모듈, prop, CSS 변수를 걷어냈으며 유틸은 es-toolkit과 dayjs로 통일했습니다. 리터럴 fallback에는 이름을 붙여 경계로 옮기고 ECharts는 쓰는 컴포넌트만 등록하며 e2e 외 빌드는 minify합니다. 커밋은 영어 동사 제목에 pre-commit 검사를 붙였습니다.

결과. 이후 화면 추가가 같은 규약을 따릅니다. 정렬과 머리 셀은 공용 부품 하나로 수렴했고 레이어 위반은 lint에서 막힙니다.

발주사 요청으로 화면을 들어내고 다시 세우기

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

판단. 구형 화면은 별도 저장소에 원본을 보존하고 작업 브랜치에서는 제거했습니다. 패턴 상세는 출처가 계절성이든 실시간이든 워치리스트든 한 화면을 재사용하고 출처만 URL로 구분합니다.

구현. 2026.09.17에 구형 화면을 제거하고 라우트를 다시 구성한 뒤 남은 유틸과 패키지를 걷어냈습니다. README와 가이드를 현재 라우트에 맞게 동기화했습니다. 그 위에 3주 동안 실시간 목록과 상세, 워치리스트, 계절성 상세 고도화를 올렸습니다. 상세 화면에는 AI 요약 상자, Today 표식, 최신 Vintage 차트, 만기까지 남은 일수 tooltip을 두고 정렬은 서버 정렬 코드와 연결했습니다.

결과. 현재 화면 6개 구성으로 정리됐습니다. 제거된 기능에 들인 작업은 기록으로 남기고 현재 기능으로는 쓰지 않습니다.

실시간 진입에서 워치리스트 종료까지 한 흐름으로 잇기

문제. 실시간 패턴에서 진입을 등록하면 워치리스트에 나타나고 모니터링 종료까지 이어져야 합니다. 진입 가능 날짜 같은 업무 규칙은 서버가 거절하기 전에 화면이 막아야 했습니다.

판단. 진입 모달에서 필드를 검증하고 진입 가능 날짜를 패턴 창 시작 30일 전까지로 제한했습니다. 진입 뒤에는 목록을 다시 조회해 화면과 서버 상태를 맞춥니다.

구현. 실시간 화면은 그룹 목록과 패턴 표, 조건 popover, 진입 모달로 구성했습니다. 워치리스트는 정렬, 페이지, 모니터링 종료, 포지션 카드, 종료와 진입 표식을 갖추고 진입 뒤 재조회합니다. 계절성 상세에는 발행일 카드, 드라이버 카드 넷과 서버 통계, KPI 다섯 칸, 고점과 저점 표식을 두었습니다.

결과. 탐색에서 진입, 모니터링, 종료까지 한 흐름으로 이어지고 규칙 위반은 서버에 닿기 전에 화면에서 걸립니다.

값이 없는 통계를 화면이 어떻게 보일지 정하기

문제. 계절성 상세의 서버 통계는 분모가 없거나 날짜가 비어 올 수 있었습니다. 그대로 그리면 0으로 나눈 값이나 빈 날짜가 화면에 나타났습니다.

판단. 운영 화면 캡처와 대조해 분모 상태마다 표시를 정하고 날짜가 없는 경우를 따로 보정했습니다.

구현. 드라이버 통계 endpoint를 연결하면서 분모 상태별 표시와 날짜 None 보정을 mapper에 두었습니다. 화면은 보정된 ViewModel만 그립니다.

결과. 통계가 비어 있어도 화면이 깨지지 않고 어떤 상태인지가 보입니다.

운영자 파일 업로드의 진행 상태 보여 주기

문제. 운영자가 데이터 파일을 여러 개 올리고 파일별 처리 상태와 이력을 확인하는 흐름이 분석 화면과 별도로 필요했습니다.

판단. 조회 조건인 날짜, 정렬, 페이지는 다른 화면과 같은 URL 계약에 두고 업로드는 파일별 진행, 완료, 실패 상태로 나눠 보여 줍니다.

구현. 다중 CSV 업로드와 파일별 상태, 날짜 조회와 정렬과 페이지 URL 상태를 구현했습니다. 2026.10에 업로드 형식을 xlsx로 바꾸고 화면 이름을 Report Update에서 File Update로 바꿨습니다.

결과. 운영자가 올린 파일마다 어디까지 처리됐는지 보이고 조회 조건은 새로고침 뒤에도 남습니다.

결과와 근거

범위를 보여 주는 수치만 적습니다. 현재 화면 6개, 정렬 조건 최대 6개, 테스트 3층, 환경 설정 4종, x축 공유 pane 4개입니다. 성능 개선율이나 사용자 수 같은 운영 지표는 공개할 수 있는 측정값이 없어 적지 않습니다.

배운 것