본문 바로가기

프로그래밍

Navigation API + View Transitions로 프레임워크 없이 SPA 라우팅과 전환 애니메이션 구현 가이드

반응형

프레임워크 없이도 SPA가 필요한 이유

 

단일 페이지 애플리케이션(SPA)처럼 빠른 전환과 뒤로 가기 지원을 원하지만, 프레임워크 도입·번들 구성·러닝커브가 부담스러운 경우가 많습니다. 브라우저 표준인 Navigation API와 View Transitions를 조합하면 가벼운 코드로 라우팅과 전환 애니메이션을 동시에 해결할 수 있습니다.

 

이 방법은 링크 클릭 -> URL 변경 -> 화면 조각 교체의 흐름을 브라우저가 직접 도와줍니다. DOM 스냅샷을 활용해 자연스러운 페이드·슬라이드 같은 전환도 손쉽게 붙일 수 있습니다.

 

초보자 입장에서는 “어떤 파일 구조와 상태 관리가 필요한가”가 가장 막막합니다. 이 글은 최소한의 HTML/JS만으로 페이지 전환, 히스토리 동작, 애니메이션을 구성하는 구체적 절차를 제공해 초기 진입 장벽을 낮춥니다.

 

다만 최신 표준이어서 브라우저 지원과 폴리필 판단이 필요합니다. 실무에서는 대상 브라우저 범위를 먼저 정하고 점진적 향상 전략으로 적용하는 편이 안전합니다.

 

사례로 보는 부드러운 전환

 

Navigation API는 링크 클릭을 가로채 페이지를 새로 고치지 않고 URL만 바꿉니다. 그다음 스크립트로 필요한 영역만 다시 그려 클릭→URL 변경→부분 업데이트를 표준 이벤트로 처리합니다.

 

View Transitions는 바뀌기 전·후의 화면을 캡처해 자연스럽게 잇습니다. 전체 페이드도 되고, 요소에 이름을 붙여 목록→상세처럼 이어지는 애니메이션을 만들 수 있습니다.

 

블로그 목록에서 상세로 갈 때는 아래 세 단계면 충분합니다.

 

- a 클릭을 Navigation API로 감지한다
- 현재 경로에 맞는 HTML 조각을 렌더링한다
- View Transitions로 전환을 감싼다

 

아래 코드는 링크 가로채기, 라우팅, 전환을 합친 최소 예시입니다.

 

<main id="app"></main>

<script>
  const routes = {
    '/': () => '<h1>목록</h1><a href="/post/1">첫 글 보기</a>',
    '/post/1': () => '<a href="/" data-vt="back">뒤로</a><h1>첫 글</h1>'
  };

function render(url) {
    const view = routes[url] ? routes[url]() : '<h1>404</h1>';
    document.getElementById('app').innerHTML = view;
  }

navigation.addEventListener('navigate', e => {
    if (e.canIntercept) {
      e.intercept({
        handler: () =>
          document.startViewTransition(() =>
            render(new URL(e.destination.url).pathname)
          )
      });
    }
  });

// 초기 진입 및 새로고침
  render(location.pathname);
</script>

 

요지는 navigate에서 e.intercept로 기본 이동을 막고, startViewTransition 안에서 DOM을 바꾸는 것입니다. 이러면 주소, 히스토리, 전환 효과가 함께 유지됩니다.

 

> 실무 팁: 전환 중 거대한 DOM을 한 번에 바꾸면 프레임 드롭이 생길 수 있습니다. 큰 목록은 가상 스크롤이나 부분 렌더링을 고려하세요.

 

단계별 적용 간단 체크리스트

 

먼저 브라우저 지원을 확인하세요. 지원이 없으면 일반 링크로 폴백하면 안전합니다.

 

- Chrome·Edge 최신 우선 점검
- Safari·Firefox는 기능 감지로 분기
- 미지원이면 전체 새로고침 허용

 

폴더는 작게 시작하세요. public에는 정적 파일, routes에는 화면별 HTML 조각을 둡니다.

 

- index.html, app.js 기본 구조
- /routes/home.js, /routes/post.js로 화면 함수 분리
- 전역·전환 스타일은 styles.css 하나로 관리

 

코드는 “링크 감지 → 라우팅 → 전환 래핑”만 잡으면 됩니다. 아래 예시는 흐름을 보여주는 최소 형태입니다.

 

<main id="app"></main>
<script type="module">
  const routes = {
    '/': () => '<h1>홈</h1><a href="/post/1">상세</a>',
    '/post/1': () => '<a href="/">뒤로</a><article>포스트 1</article>',
  };

function render(pathname) {
    app.innerHTML = routes[pathname] ? routes[pathname]() : '<h1>404</h1>';
  }

navigation?.addEventListener('navigate', (e) => {
    const url = new URL(e.destination.url);
    if (url.origin !== location.origin) return;
    e.intercept({
      handler: async () => {
        await document.startViewTransition(() => render(url.pathname))?.finished;
        history.pushState({}, '', url.pathname);
      }
    });
  });

window.addEventListener('popstate', () => render(location.pathname));
  render(location.pathname);
</script>

 

같은 출처만 가로채고, startViewTransition 내부에서 render를 호출하세요. 뒤로 가기는 popstate로 URL과 화면을 동기화합니다.

 

실무 체크포인트입니다. 작은 규칙만 지키면 예외가 줄어듭니다.

 

- 외부 링크(target="_blank" 포함)는 가로채지 않기
- 스크롤은 라우트별 저장·복구 또는 전환 뒤 맨 위로
- 전환 중 중복 클릭 방지(pointer-events나 로딩 상태)

 

> 안티패턴: 전환 콜백에서 오래 대기하는 fetch로 블로킹하지 마세요. 데이터는 미리 요청하고, 전환은 DOM 교체 구간만 짧게 묶는 편이 안전합니다.

 

흔한 실수와 선택 기준 한 번에 정리

 

Navigation API와 View Transitions는 표준이지만, 기본 링크·포커스·스크롤을 임의로 바꾸면 접근성과 히스토리가 틀어집니다. 기본 동작을 보존하고 필요한 부분만 덮어쓰는 전략이 안전합니다.

 

전환 품질은 좋지만, 복잡한 상태 공유를 넣으면 라우팅 코드가 커집니다. 상태가 많다면 전역 스토어 대신 화면별 모듈로 나누고, 서버 렌더 조합 여부를 미리 정하세요.

 

- a 태그를 버튼처럼 쓰지 말고, 실제 경로를 가진 href를 유지
- navigation API 감지 실패 시 자연스러운 하위 호환(기본 이동) 보장
- 전환 중 중복 클릭 방지, fetch 취소(AbortController)로 경합 줄이기
- View Transition은 레이아웃 점프에 취약하므로 고정 높이·이미지 ratio 예약
- 스크롤 위치: 목록↔상세 이동 시 restore/scrollTo를 명시적으로 처리

 

> 안티패턴: 전환이 매번 느리다면 모든 화면을 transition으로 감싸는 대신, 변화가 큰 영역만 부분 전환하고 나머지는 즉시 갱신하세요. 불필요한 전체 스냅샷은 렌더링 비용을 키웁니다.

 

선택 기준은 간단합니다. 라우트 수가 적고 페이지 간 공통 상태가 작으면 이 방식이 가볍습니다.

 

반대로 폼 상태 보존, 복잡한 권한, 중첩 라우트가 많다면 프레임워크 라우터 도입을 고려하세요.

 

지금 바로 적용하는 5단계

 

가벼운 SPA는 표준 API로 링크→URL→부분 렌더링→전환 흐름만 잡으면 충분합니다. 작은 파일 구성으로도 뒤로 가기와 전환 애니메이션을 갖출 수 있습니다.

 

해야 할 일을 목록으로 정리하고, 실행 순서대로 진행하세요. 브라우저 지원과 폴백(대체 방법)만 준비하면 적용이 수월합니다.

 

- 지원 확인: navigation, document.startViewTransition 여부로 분기
- 최소 구조: index.html, app.js, routes/… 등 2~3개 파일
- 라우터 뼈대: pathname을 화면 함수에 매핑
- 전환 래핑: 렌더 함수를 startViewTransition으로 감싸기
- 폴백: 미지원 시 기본 링크 이동, 전환은 CSS 페이드 대체

 

아래 예시는 라우팅과 전환을 한 파일에서 시험하는 최소 코드입니다. 흐름이 보이면 화면 모듈을 분리하세요.

 

<main id="app"></main>
<script type="module">
  const routes = {
    '/': () => '<h1>홈</h1><a href="/post/1">상세</a>',
    '/post/1': () => '<a href="/">뒤로</a><h1>상세1</h1>'
  };

const render = () => {
    app.innerHTML = routes[location.pathname]?.() || '<h1>404</h1>';
  };

addEventListener('click', (e) => {
    const a = e.target.closest('a[href]');
    if (!a || a.origin !== location.origin) return;
    e.preventDefault();
    navigation.navigate(a.href, { history: 'push' });
  });

navigation.addEventListener('navigate', (e) => {
    if (!e.canIntercept) return;
    e.intercept({
      handler: async () => {
        const update = () => history.replaceState({}, '', e.destination.url);
        const run = () => { update(); render(); };
        if (document.startViewTransition) {
          await document.startViewTransition(run).finished;
        } else {
          run();
        }
      }
    });
  });

render();
</script>

 

링크 클릭은 가로채되 href는 보존하고, navigate 이벤트에서 URL 반영→렌더→전환을 한 번에 처리하세요. 이미지나 동적 높이는 전환 전 크기를 예약해 레이아웃 점프를 줄이세요.

 

선택적 페칭과 전환 동기화

 

상세 페이지는 데이터 로딩과 화면 전환을 같은 타이밍에 맞추면 깜빡임이 줄어듭니다. 데이터가 준비된 뒤 View Transition을 시작하세요.

 

- 흐름: 경로 파싱 → 데이터 요청 → HTML 만들기 → 전환 시작
- 느릴 때는 스켈레톤(자리표시자)로 뼈대만 먼저 보여줍니다.
- 취소는 AbortController로 이전 요청을 중단합니다.

 

아래 예시는 로딩 완료 후에만 전환을 실행해 화면 튐을 줄입니다.

 

<main id="app"></main>
<script type="module">
  const app = document.getElementById('app');
  let controller;

async function navigateTo(path) {
    controller?.abort();
    controller = new AbortController();

// 스켈레톤
    app.innerHTML = '<article class="skeleton">로딩 중…</article>';

// 데이터 요청
    const res = await fetch(`/api${path}`, { signal: controller.signal });
    const data = await res.json();
    const nextHTML = `<article><h1>${data.title}</h1><p>${data.body}</p></article>`;

// 전환과 렌더 동기화
    if (document.startViewTransition) {
      document.startViewTransition(() => { app.innerHTML = nextHTML; });
    } else {
      app.innerHTML = nextHTML; // 폴백
    }
  }

// 링크 클릭 처리
  addEventListener('click', (e) => {
    const a = e.target.closest('a[href]');
    if (!a || a.origin !== location.origin) return;
    e.preventDefault();
    history.pushState(null, '', a.pathname);
    navigateTo(a.pathname);
  });

addEventListener('popstate', () => navigateTo(location.pathname));
  navigateTo(location.pathname);
</script>

 

전환 콜백 안에서 DOM을 교체해야 스냅샷이 정확합니다. 네트워크가 느리면 스켈레톤이 유지되다 완료 시 한 번만 전환됩니다.

 

> 팁: 이미지는 aspect-ratio로 자리를 먼저 확보해 레이아웃 점프를 줄이세요.

반응형