화면 라우팅

앱 화면은 iframe 안에서 동작합니다. 따라서 사용자가 보는 브라우저 주소창과 뒤로 가기 버튼은 앱이 아니라 웹 콘솔이 소유합니다. 앱이 iframe 안에서 주소를 직접 바꾸어도 주소창에는 반영되지 않고, 사용자가 뒤로 가기를 눌러도 앱은 그 사실을 알 수 없습니다.

이 문제를 해결하기 위해 웹 콘솔과 앱은 메시지를 주고받습니다. 앱이 이 규약을 구현하면 화면을 새로 고쳐도 같은 상태가 복원되고, 브라우저 뒤로 가기가 앱 내부의 이동에도 동작하며, 사용자가 주소를 복사해 다른 사람에게 전달할 수 있습니다.

메시지 규약

주고받는 메시지는 두 가지입니다.

방향메시지발생 시점
웹 콘솔 → 앱{ type: 'SYNC_URL', url }최초 로드, 메뉴 선택, 브라우저 뒤로·앞으로
앱 → 웹 콘솔{ type: 'NAVIGATE_URL', url, replace }앱 내부에서 화면을 이동하거나 필터를 변경할 때

SYNC_URL은 앱이 표시해야 할 주소를 알려줍니다. 브라우저 뒤로·앞으로도 이 메시지로 전달되므로, 앱은 뒤로 가기를 별도로 처리하지 않아도 됩니다.

NAVIGATE_URL은 앱이 웹 콘솔에 주소창 갱신을 요청하는 메시지입니다. url에는 앱 코드를 포함한 전체 경로를 넣습니다. replacetrue이면 방문 기록에 새 항목을 남기지 않고 현재 항목을 대체합니다. 앱 코드가 바뀌지 않는 한 웹 콘솔은 iframe을 다시 불러오지 않으므로, 이 메시지로 화면이 초기화되지는 않습니다.

진입점 구현

src/main/ui/src/main.tsx가 이 규약을 처리합니다.

import { StrictMode, useCallback, useEffect, useMemo, useState } from 'react';
import { createRoot } from 'react-dom/client';
import './styles/global.css';
import { applyTheme, initialTheme, watchHostTheme } from './common/theme';
import SubnetGroupsPage from './subnet-groups/SubnetGroupsPage';

const APP_CODE = detectAppCode();

function detectAppCode(): string {
  const parts = window.location.pathname.split('/').filter(Boolean);
  const i = parts.findIndex(s => s === 'app' || s === 'app-loader');
  return i >= 0 && parts[i + 1] ? parts[i + 1] : 'sample';
}

function toAppPath(fullUrl: string): string {
  const hashless = fullUrl.split('#')[0];
  const qIdx = hashless.indexOf('?');
  const path = qIdx >= 0 ? hashless.slice(0, qIdx) : hashless;
  const query = qIdx >= 0 ? hashless.slice(qIdx) : '';
  const parts = path.split('/').filter(Boolean);
  const i = parts.findIndex(s => s === 'app' || s === 'app-loader');
  const rest = i >= 0 ? parts.slice(i + 2) : parts;
  return '/' + rest.join('/') + query;
}

function App() {
  const [nav, setNav] = useState(() => toAppPath(window.location.pathname + window.location.search));

  const search = useMemo(() => {
    const q = nav.indexOf('?');
    return new URLSearchParams(q >= 0 ? nav.slice(q + 1) : '');
  }, [nav]);

  useEffect(() => watchHostTheme(), []);

  useEffect(() => {
    const handler = (e: MessageEvent) => {
      if (e.data?.type === 'SYNC_URL' && typeof e.data.url === 'string')
        setNav(toAppPath(e.data.url));
    };

    window.addEventListener('message', handler);
    return () => window.removeEventListener('message', handler);
  }, []);

  const navigate = useCallback((appPath: string, replace = false) => {
    try {
      window.parent?.postMessage({ type: 'NAVIGATE_URL', url: `/app/${APP_CODE}${appPath}`, replace }, '*');
    } catch { /* standalone (no host) — nothing to sync */ }

    setNav(appPath);
  }, []);

  return <SubnetGroupsPage profile={search.get('profile')} onNavigate={navigate} />;
}

applyTheme(initialTheme());

createRoot(document.getElementById('root')!).render(
  <StrictMode>
    <App />
  </StrictMode>
);

구현의 핵심은 상태의 출처를 하나로 만드는 것입니다. nav 값은 앱 내부 경로와 질의 문자열을 담으며, 웹 콘솔이 보낸 SYNC_URL과 앱이 호출한 navigate()가 모두 이 값을 갱신합니다. 화면은 이 값에서 필요한 정보를 읽습니다. 상태를 여러 곳에 나누어 두면 주소창과 화면이 어긋납니다.

앱 코드를 고정하지 않는 이유

detectAppCode()는 앱 코드를 주소에서 읽습니다. 코드에 'sample'로 적어 두면 될 것 같지만 그렇지 않습니다.

같은 화면이 /app/sample/.../app-loader/sample/... 두 경로에서 모두 열릴 수 있고, 하나의 번들을 여러 앱 코드로 배포하는 구성도 가능합니다. 주소에서 읽으면 이 모든 경우에 같은 코드가 동작합니다.

toAppPath()는 반대 방향의 처리입니다. 웹 콘솔이 전달한 전체 주소에서 /app/{앱 코드} 또는 /app-loader/{앱 코드} 접두사를 제거하여 앱이 해석할 부분만 남깁니다.

방문 기록을 직접 처리하지 않습니다

앱은 popstate 이벤트를 듣지 않습니다. 브라우저 뒤로·앞으로는 웹 콘솔이 받아서 SYNC_URL로 전달하므로, 앱이 이 이벤트를 함께 처리하면 같은 이동을 두 번 처리하게 됩니다.

같은 이유로 앱은 history.pushState()를 직접 호출하지 않습니다. 방문 기록은 웹 콘솔이 관리하며, 앱은 NAVIGATE_URL로 요청만 합니다.

필터 상태를 주소에 반영하기

앱 예제의 화면은 선택된 접속 프로파일을 질의 문자열에 담습니다.

const onSelectProfile = (name: string) => {
  setKeyword('');
  setSelected(null);
  onNavigate(`/subnet-groups?profile=${encodeURIComponent(name)}`);
};

사용자가 직접 프로파일을 바꾼 것이므로 방문 기록에 남깁니다. 반면 화면이 스스로 기본값을 정한 경우에는 기록을 남기지 않습니다.

useEffect(() => {
  if (!profile && profiles && profiles.length > 0)
    onNavigate(`/subnet-groups?profile=${encodeURIComponent(profiles[0].name)}`, true);
}, [profile, profiles, onNavigate]);

사용자가 하지 않은 이동을 방문 기록에 남기면, 뒤로 가기를 눌렀을 때 아무 일도 일어나지 않는 것처럼 보입니다. 사용자의 행동은 기록하고, 화면이 스스로 한 일은 대체합니다.

검색어처럼 입력할 때마다 바뀌는 값을 주소에 반영할 때도 replace를 사용합니다. 그렇지 않으면 글자 하나마다 방문 기록이 쌓여 뒤로 가기가 사실상 동작하지 않게 됩니다.

화면이 여러 개인 경우

화면이 여러 개면 경로에서 화면을 구분합니다.

function resolveRoute(appPath: string): Route {
  const qIdx = appPath.indexOf('?');
  const p = qIdx >= 0 ? appPath.slice(0, qIdx) : appPath;
  const rest = p.split('/').filter(Boolean);
  return { screen: rest[0] || 'subnet-groups', itemId: rest[1] };
}

화면이 많아지면 처음 불러오는 시간이 길어지므로, React의 지연 로딩으로 분리합니다.

const SubnetGroupsPage = lazy(() => import('./subnet-groups/SubnetGroupsPage'));
const PolicyPage = lazy(() => import('./policy/PolicyPage'));

// 화면 전환 중에는 Suspense의 대체 화면이 표시됩니다.
<Suspense fallback={<Loading />}>
  {route.screen === 'policy' ? <PolicyPage ... /> : <SubnetGroupsPage ... />}
</Suspense>

화면 안에 탭이 있는 경우, 어떤 탭이 있는지는 진입점이 아니라 화면 컴포넌트가 알고 있게 만드는 것이 좋습니다. 진입점이 탭 목록까지 관리하면 탭을 하나 추가할 때마다 진입점과 매니페스트를 함께 고쳐야 합니다. 진입점은 경로의 첫 조각만 보고 화면을 고르고, 나머지 해석은 화면에 넘기면 탭 추가가 화면 안에서 끝납니다.

확인할 것

화면 이동은 개발 서버에서 확인할 수 없습니다. 웹 콘솔이 없으면 SYNC_URL을 보낼 주체가 없기 때문입니다. 앱을 설치한 뒤 다음을 직접 확인하시기 바랍니다.

  • 메뉴를 선택하면 화면이 열리는지
  • 화면에서 필터를 바꾸면 주소창이 따라 바뀌는지
  • 브라우저를 새로 고치면 같은 상태가 복원되는지
  • 브라우저 뒤로 가기와 앞으로 가기가 한 단계씩 이동하는지
  • 주소를 복사해 새 탭에서 열면 같은 화면이 표시되는지

특히 뒤로 가기와 앞으로 가기는 여러 단계를 오가며 확인해야 합니다. 한 단계만 확인하면 이동이 누적되는 문제를 놓치기 쉽습니다.

에이전트 프롬프트

화면 이동 구현을 에이전트에게 맡길 때 사용합니다.

로그프레소 앱 화면의 라우팅을 구현한다.

앱 코드: sample
화면 구성: (화면과 경로를 나열)
주소에 반영할 상태: (필터, 선택 항목 등)

지킬 것:
- 앱 코드는 window.location에서 읽는다. 코드에 고정하지 않는다
- SYNC_URL 수신으로 상태를 갱신한다
- NAVIGATE_URL 송신으로 주소창을 갱신한다
- popstate를 듣지 않고 history.pushState를 직접 호출하지 않는다
- 사용자의 이동은 방문 기록에 남기고, 화면이 정한 기본값은 replace로 대체한다
- 상태의 출처는 하나로 유지한다

참조: https://docs.logpresso.com/ko/app-sdk/ui-routing

구현 후 설치하여 새로 고침·뒤로·앞으로·주소 복사를 여러 단계에 걸쳐 확인하라.
개발 서버에서는 화면 이동을 검증할 수 없다.

다음 절에서는 화면이 서버 데이터를 읽어오는 방법을 설명합니다.