View Transitions API 로 목록↔상세 화면전환 만들기

👉🏼 KIM·2026년 7월 28일

배포 다음날 바로 롤백했다.
안드로이드 앱 웹뷰에서 장애가 나서 이 방식을 전부 걷어냈다.
아래 본문은 배포 시점에 쓴 그대로 두고, 맨 끝에 덧붙임 으로 정리했다.
앱에서 웹페이지를 띄우는 서비스라면 끝부터 읽어주세요.

들어가며

모바일 목록에서 상품을 누르면 앱처럼 화면이 스르륵 밀리게 해달라는 요청을 받았다.
처음엔 "웹에서 그게 되나? 화면 두 개 겹쳐놓고 JS로 직접 밀어야 하나" 싶었는데,
찾아보니 2024년부터 브라우저가 기본으로 제공하는 기능이 있었다.

문제는 환경이었다. 목록은 최신 프레임워크(Laravel + React), 상세는 10년 넘은 레거시 PHP.
심지어 그 레거시 파일은 EUC-KR 인코딩에 7,000줄이 넘었다. 양쪽을 다 건드려야 했다.

결론부터 말하면 CSS 40줄 정도로 끝났다. 그런데 거기까지 가는 길에 삽질을 여섯 번 했다.
"코드는 분명 넣었는데 아무 일도 안 일어나는" 상황이 계속 나왔는데,
에러도 안 나서 원인 찾기가 더 어려웠다. 검색해도 잘 안 나오는 것들이라 정리해둔다.

한 줄 요약

cross-document View Transitions 는 떠나는 문서와 도착하는 문서 양쪽 모두,
그것도 <head> 안에서 첫 페인트 이전에 opt-in 해야 동작한다.
방향(앞으로/뒤로) 판정은 직접 해야 하고, 미지원 브라우저에서는 조용히 무동작이라 반쪽 배포도 안전하다.


View Transitions API 란

페이지가 바뀔 때 브라우저가 이전 화면과 새 화면을 GPU 텍스처로 스냅샷해서 합성해주는 표준 API다.

핵심은 애니메이션 주체가 내 JS가 아니라 브라우저라는 점이다.
전환 중에는 JS 실행도, 레이아웃도, 페인트도 일어나지 않는다. 그래서 메인 스레드가 아무리 바빠도 끊기지 않는다.

직접 구현했다면 매 프레임 위치를 계산해야 했을 것이고, 이미지 로드나 다른 스크립트와 경합하면 뚝뚝 끊겼을 것이다.

두 가지 종류가 있다 (중요)

종류상황
same-documentSPA처럼 한 페이지 안에서 DOM만 바뀔 때
cross-document문서에서 문서로 실제 페이지 이동할 때

이 글은 cross-document 이야기다. caniuse에서 지원율을 볼 때도 이 둘을 구분해야 한다.
same-document 쪽이 먼저 나와서 지원율이 더 높게 보이는데, 그걸 보고 판단하면 어긋난다.

지원 범위

크롬 126+(2024.6), 사파리 18.2+(2024.12), 파이어폭스 144+.
작성 시점 기준 전 세계 약 83%.

주의: 크로미움 기반이라고 자동으로 지원되는 게 아니다.
삼성인터넷은 크로미움 기반이지만 작성 시점 기준 최신 버전(30)에서도 미지원이다.
국내 서비스라면 갤럭시 기본 브라우저인 만큼 체감 커버리지가 전 세계 수치보다 낮아진다.
서브 브라우저는 caniuse에서 셀에 마우스를 올려 툴팁으로 개별 확인하는 편이 안전하다.

미지원 브라우저에서는 아무 일도 일어나지 않는다. 에러도, 깨짐도 없이 기존 링크 이동 그대로다.
이 성질이 이번 작업 전체의 안전판이 됐다.


기본 적용

전환할 두 문서 양쪽 모두에 아래를 넣는다.

@view-transition { navigation: auto; }

::view-transition-old(root),
::view-transition-new(root) {
    mix-blend-mode: normal;   /* 기본 크로스페이드 제거 */
    animation-duration: 280ms;
    animation-timing-function: cubic-bezier(.32, .72, 0, 1);
    animation-fill-mode: both;
}

/* 앞으로: 새 화면이 오른쪽에서 덮으며 진입 */
::view-transition-new(root) { animation-name: slide-in-right; z-index: 2; }
::view-transition-old(root) { animation-name: slide-out-left;  z-index: 1; }

@keyframes slide-in-right { from { transform: translateX(100%); } to { transform: translateX(0);    } }
@keyframes slide-out-left { from { transform: translateX(0);    } to { transform: translateX(-25%); } }

mix-blend-mode: normal 은 넣는 편이 좋다. 기본값이 두 스냅샷을 섞도록 되어 있어서,
빼면 겹치는 구간이 뿌옇게 뜬다.

나가는 화면을 -25% 만 미는 것도 의도적이다. -100% 로 완전히 밀어내면 두 화면이 따로 노는 느낌이 나고,
살짝만 물러나야 "뒤에 깔려 있다"는 깊이감이 생긴다. 네이티브 앱의 push 전환이 이렇게 동작한다.


삽질 1. 양쪽 문서 모두 opt-in 해야 한다

cross-document 전환은 떠나는 문서와 도착하는 문서가 모두 @view-transition 을 선언해야 성립한다.

한쪽만 넣으면 에러 없이 그냥 아무 일도 안 일어난다. 이게 처음엔 꽤 헷갈렸다.
콘솔에 아무것도 안 찍히니 "왜 안 되지" 하면서 CSS만 계속 들여다보게 된다.

목록 [opt-in O]  →  상세 [opt-in O]   → 전환 O
목록 [opt-in O]  →  홈   [opt-in X]   → 전환 X (조용히 무동작)

오히려 이게 스코프 제어 수단이 됐다

목록과 상세에만 넣었더니, 하단 탭바로 다른 페이지에 갈 땐 자동으로 전환이 제외됐다.
"이 링크는 전환하지 마라"를 따로 막는 코드가 필요 없었다.

다만 부작용도 있다. 같은 상세 페이지라도 어디서 들어왔느냐에 따라 동작이 달라진다.
목록에서 들어오면 슬라이드, 홈에서 들어오면 즉시 이동. 이건 문서에 명시해 두는 편이 좋다.
나중에 "왜 여긴 안 되냐"는 질문이 반드시 나온다.


삽질 2. <head> 안에 있어야 한다

이게 제일 오래 잡아먹었다.

처음엔 레거시 PHP 파일에서 공통 헤더를 불러온 뒤에 스타일을 출력했다. 문법적으로는 아무 문제가 없다.
그런데 확인해 보니 그 위치가 문서 전체의 46% 지점이었다.

브라우저가 문서를 읽는 순서
  위 ─────────────────────────────▶ 아래
   │                    │
   │                    └─ 내 코드 (46% 지점)  ← 너무 늦음
   └─ 여기서 첫 페인트 = 전환 신호 발생

@view-transition opt-in 과 pagereveal 리스너는 첫 페인트 이전에 파싱되어야 한다.
그 뒤에 있으면 브라우저가 이미 화면을 그린 뒤라 전환이 등록되지 않는다.

해결 — 공통 파일을 안 건드리고 <head> 에 넣기

레거시 공통 헤더는 여러 페이지가 공유하고 있어서 직접 수정하기 부담스러웠다.
다행히 그 헤더가 <head> 안에서 출력해 주는 변수가 있었다.

// 공통 헤더를 부르기 '전에' 그 변수에 얹는다
ob_start();
include __DIR__ . '/inc_view_transition.php';
$page_css .= ob_get_clean();

include_once __DIR__ . '/common_head.php';   // 이 안에서 $page_css 를 <head> 에 출력

ob_start() / ob_get_clean() 은 "출력을 화면에 보내지 말고 잠깐 담아뒀다가 꺼내오는" PHP 함수다.
이걸로 include 결과를 문자열로 받아 변수에 붙였다.

결과적으로 코드가 문서 1.6% 지점으로 올라갔고, 공통 파일은 한 줄도 안 건드렸다.

레거시를 다룰 때의 요령: 공통 파일을 고치면 그걸 쓰는 수십 개 페이지가 같이 영향받는다.
건드리지 않고 목적을 달성하는 경로가 있는지 먼저 찾는 게 낫다.

인라인이어야 하는 이유도 같다

외부 CSS/JS 파일로 분리하면 로드가 늦어져 신호를 놓칠 수 있다. 40줄 남짓이라 인라인으로 두는 편이 안전하다.


삽질 3. 브라우저는 방향을 안 알려준다

전환은 브라우저가 해주지만, "지금 앞으로 가는 건지 뒤로 가는 건지"는 알려주지 않는다.
뒤로가기인데 앞으로 가는 애니메이션이 나오면 굉장히 어색하다.

pagereveal 이벤트에서 히스토리 순번을 비교해 직접 판정했다.

window.addEventListener('pagereveal', function () {
    let dir = 'forward';
    const act = window.navigation && window.navigation.activation;

    if (act && act.from && act.entry) {
        // Navigation API 지원 (크로미움 계열): 히스토리 인덱스 비교로 정확히 판정
        dir = act.from.index > act.entry.index ? 'back' : 'forward';
    } else {
        // 미지원 (사파리 등): 내비게이션 타입으로 근사
        const entry = performance.getEntriesByType('navigation')[0];
        if (entry && entry.type === 'back_forward') dir = 'back';
    }

    document.documentElement.dataset.vt = dir;
});

판정 결과를 <html data-vt="back"> 으로 심어두면, CSS가 그걸 보고 방향을 고른다.

html[data-vt="back"]::view-transition-new(root) { animation-name: slide-in-left;   z-index: 1; }
html[data-vt="back"]::view-transition-old(root) { animation-name: slide-out-right; z-index: 2; }

z-index 를 뒤집는 이유

[앞으로]  새 화면이 위에서 덮으며 진입     [뒤로]  옛 화면이 위에서 빠지며
                                                 그 아래 이전 화면이 드러남
  ┌──────┐                                 ┌──────┐
  │새 (2)│                                 │옛 (2)│
  ├──────┤                                 ├──────┤
  │옛 (1)│                                 │새 (1)│
  └──────┘                                 └──────┘

이걸 뒤집지 않으면 뒤로가기가 앞으로 가는 것처럼 보인다.

사파리 쪽은 정확도가 떨어진다

performance 방식은 뒤로와 앞으로를 구분하지 못한다. 둘 다 back_forward 로 나온다.
실사용에서 앞으로가기 버튼을 쓰는 경우가 드물어 "뒤로"로 처리했다. 완벽하진 않지만 실용적인 타협이다.

pagereveal 리스너 등록도 <head> 인라인이어야 한다. 이벤트가 문서 로드 극초반에 발생해서,
늦게 로드되는 스크립트는 놓친다.


삽질 4. 움직이는 물체를 자로 쟀다

목록으로 돌아왔을 때 추천 영역이 삽입되는데, 그 영역이 화면 하단 고정 탭바 바로 위에 딱 맞게
스크롤되도록 만들었다. 그런데 화면이 미세하게 덜덜 떨렸다.

원인은 내가 만든 등장 애니메이션이었다.

/* 추천 영역: 아래에서 떠오르며 등장 */
.recommend-section {
    animation: reveal 420ms cubic-bezier(.32, .72, 0, 1) both;
}
@keyframes reveal {
    from { opacity: 0; transform: translateY(14px); }
    to   { opacity: 1; transform: translateY(0); }
}

이 요소가 14px 아래에서 위로 움직이는 중인데, 그 위치를 이렇게 재고 있었다.

const rect = section.getBoundingClientRect();   // ❌ 지금 눈에 보이는 위치

getBoundingClientRect()transform 이 반영된 현재 위치를 준다.
즉 애니메이션 도중에 재면 값이 계속 달라진다. 움직이는 물체를 자로 잰 셈이다.

0ms   : 14px 아래에 있음  → 여기로 스크롤!
420ms : 제자리로 올라옴   → 14px 어긋났네 → 다시 스크롤!   ← 이게 떨림

해결 — offsetTop 은 transform 의 영향을 받지 않는다

// ⭕ 레이아웃상의 '원래 자리'. 애니메이션 중에도 값이 안 변한다.
const documentTopOf = (el) => {
    let top = 0;
    let node = el;
    while (node) {
        top += node.offsetTop;
        node = node.offsetParent;
    }
    return top;
};
무엇을 주나transform 영향
getBoundingClientRect()지금 보이는 위치받음
offsetTop / offsetHeight레이아웃상 위치안 받음

허용 오차도 함께 손봤다

보정 로직의 허용 오차가 1px 이었는데, 8px 로 키웠다.

1px 어긋난 건 사람 눈에 안 보이지만, 그걸 고치려는 움직임은 보인다.
"정확함"보다 "안 흔들림"이 나은 경우가 있다.

const tolerance = isRetry ? 8 : 2;
if (Math.abs(targetY - currentY) < tolerance) return;

임시방편은 걷어내자

떨림을 잡을 때 두 가지를 동시에 했다.

  1. 측정 방법 변경 (getBoundingClientRect()offsetTop)
  2. 측정 시점 지연 (애니메이션이 끝난 뒤로 미룸)

나중에 보니 1번만으로 이미 해결돼 있었다. 2번은 순수하게 낭비되는 대기 시간이라 되돌렸다.
둘 다 남겨두면 나중에 "왜 이렇게 느리지?"를 아무도 설명하지 못한다.


삽질 5. 공용 컴포넌트를 내 화면만 보고 고쳤다

가장 위험했던 실수다. 배포됐다면 엉뚱한 화면이 망가졌을 것이다.

스크롤 정렬 코드를 목록 컴포넌트에 넣었는데, 확인해 보니 그 컴포넌트를 세 화면이 공유하고 있었다.

목록 컴포넌트  ┬─ 모바일 검색결과   ← 내가 본 화면
              ├─ PC 검색결과       ← 몰랐음
              └─ 다른 목록 페이지   ← 몰랐음

PC에는 하단 고정 탭바가 없으니 엉뚱한 위치로 스크롤됐을 것이다.

첫 시도는 부족했다

처음엔 "탭바가 없으면 나가기"로 막으려 했다.

const bottomNav = document.querySelector('.bottom-nav');
if (!bottomNav) return;   // 부족했다

그런데 세 번째 화면이 모바일 페이지 안에 들어 있어서 탭바가 있었다. 이 조건을 통과해 버린다.

해결 — "있는 것"으로 구분하기

각 화면이 컴포넌트에 넘기는 뷰 타입 값이 다르다는 점을 이용했다.

// 모바일 그리드 뷰일 때만 동작
if (typeof viewType !== 'string' || viewType.indexOf('mobile-') !== 0) return;

"없는 걸로 구분"보다 "있는 것으로 구분"이 확실하다.

교훈 — 사용처부터 찾자

공용 파일을 수정하기 전에 이 한 줄이면 된다.

grep -rn "ComponentName" src/pages/ | grep import

내가 보는 화면만 생각하고 공용 파일을 고치는 것 — 실무에서 사고가 가장 많이 나는 패턴이다.


삽질 6. "크로미움 기반이니 크롬이랑 같겠지"

지원 범위를 정리하다가 뒤늦게 발견한 것.

삼성인터넷은 크로미움 기반인데도 미지원이었다. 최신 버전에서도 그렇다.
caniuse에서 해당 셀에 마우스를 올려 보면 툴팁에 명확히 나온다.

Samsung Internet 30
  Support info      ✗ Not supported
  Browser version   Released May 20, 2026

크로미움 기반 브라우저라도 베이스 버전이 한참 뒤처져 있거나, 벤더가 기능을 꺼둘 수 있다.
"크로미움 = 크롬"으로 넘겨짚으면 커버리지 계산이 통째로 어긋난다.

국내 서비스라면 특히 중요하다

전 세계 점유율만 보면 1% 남짓이라 무시하기 쉽다.
그런데 갤럭시의 기본 브라우저다. 사용자층에 따라 국내 비중은 그보다 훨씬 높다.

특히 기본 앱을 그대로 쓰는 성향의 사용자층이라면 — 브라우저를 바꾸거나 크롬을 따로 깔지 않는 —
전 세계 수치와 실제 커버리지의 격차가 크게 벌어진다.

그런데 앱 웹뷰는 또 다르다

자사 앱이 웹뷰로 웹페이지를 띄우는 구조라면, 같은 기기라도 엔진이 다르다.

접속 경로실제 엔진업데이트 주체
갤럭시 + 기본 브라우저삼성인터넷 자체 빌드벤더
갤럭시 + 자사 앱 웹뷰Android System WebView플레이스토어 자동
iOS + 자사 앱 웹뷰WKWebView (사파리 엔진)OS 업데이트

Android System WebView 는 삼성인터넷과 완전히 별개 컴포넌트이고, 플레이스토어가 자동으로 최신화한다.
기본 브라우저로 들어오면 미지원, 앱으로 들어오면 지원인 상황이 생긴다.

교훈: 커버리지는 "어떤 브라우저를 쓰나"만으로 계산하면 안 된다.
"어떤 경로로 접속하나" 까지 봐야 한다. 웹/앱 비중을 모르면 숫자가 반쪽이다.

실제 수치는 결국 자사 애널리틱스에서 브라우저 분포 + 앱 웹뷰 비중을 함께 봐야 나온다.
caniuse의 전 세계 수치는 출발점일 뿐이다.


배포가 나뉘어 있어도 안전하다

목록과 상세가 서로 다른 시스템이라 배포 시점이 다를 수밖에 없었다.
한쪽만 배포된 구간이 반드시 생긴다.

결론은 완전히 안전하다.

상태결과
상세만 배포전환 없음 = 배포 전과 동일
목록만 배포전환 없음 = 배포 전과 동일
양쪽 배포 완료전환 작동

한쪽만 opt-in 된 상태에서 브라우저는 시도했다 실패하는 게 아니라 시작을 안 한다.
깜빡임도 에러도 없다. 배포 순서도 상관없다.

이건 declarative opt-in 방식이라서 얻은 성질이다. JS 애니메이션 라이브러리로 직접 구현했다면,
"상대편이 협조할 줄 알았는데 안 해서" 화면이 깨지는 상황을 따로 처리해야 했을 것이다.


남은 한계

탭 후 바로 슬라이드가 시작되지 않는다.

브라우저는 새 문서의 첫 렌더가 준비될 때까지 전환을 시작하지 않는다.
상세 페이지 응답이 0.4초쯤 걸린다면 그만큼 대기가 그대로 보인다.
애니메이션 성능 문제가 아니라 응답 속도 문제이고, 개선하려면 prefetch 같은 별도 작업이 필요하다.

반대로 뒤로가기는 빠르다. 브라우저 캐시가 걸리면 거의 즉시라, 이쪽이 앱에 가장 가깝게 느껴진다.

드래그 백(손가락을 따라 화면이 끌려오는 동작)은 불가능하다. 웹의 구조적 한계다.


정리

  • 실제 애니메이션 코드는 CSS 40줄 남짓. 브라우저가 다 해준다
  • 양쪽 문서 모두 opt-in 해야 성립하고, 한쪽만이면 조용히 무동작
  • opt-in 과 pagereveal 리스너는 <head> 안, 첫 페인트 이전에 있어야 한다
  • 방향 판정은 직접 해야 한다. 크로미움은 Navigation API, 그 외는 근사치
  • 애니메이션 중인 요소의 위치는 offsetTop 으로 재라
  • 공용 컴포넌트를 고치기 전에 사용처부터 찾아라
  • 미지원 환경은 기존 동작 그대로 — 반쪽 배포도 안전하다

레거시가 섞인 환경이라 더 어려울 줄 알았는데, 오히려 선언적인 방식이라 레거시에 얹기 쉬웠다.
PHP든 React든 결국 <head> 에 CSS 한 덩어리를 넣는 문제로 수렴했다.


덧붙임 — 배포 다음날 롤백

위 글을 쓰고 배포한 다음날, 롤백했다.

증상

안드로이드 앱에서 상품 상세에 들어가면 페이지 안의 모든 링크가 반응하지 않는다.

  • 모바일 웹 브라우저(크롬·사파리) — 정상
  • PC — 정상
  • 안드로이드 앱 웹뷰 — 전부 먹통

원인

@view-transition { navigation: auto; }

이 선언은 해당 문서에서 나가는 모든 동일 오리진 이동을 브라우저가 가로채 전환을 시작하게 만든다.

그런데 앱은 웹페이지를 웹뷰로 띄우면서 네이티브가 링크 이동에 개입한다
(안드로이드의 shouldOverrideUrlLoading 계열).

둘이 같은 이동을 서로 가져가려다 충돌한 것으로 보인다.
브라우저는 "새 문서 렌더 준비"를 기다리는데 네이티브가 이동을 채가니,
전환이 시작된 채로 끝나지 않고 화면이 입력을 받지 못하는 상태가 된다.

위에서 쓴 것을 정정한다

본문에 이렇게 썼다.

"미지원 브라우저에서는 아무 일도 일어나지 않는다. 이 성질이 이번 작업 전체의 안전판이 됐다."
"배포가 나뉘어 있어도 완전히 안전하다."

둘 다 틀린 말은 아니지만, 범위를 잘못 잡았다.

그 안전성은 "기능을 아예 모르는 브라우저" 에 대한 것이다.
앱 웹뷰는 기능을 알긴 아는데 다른 계층(네이티브)과 충돌하는 경우였고, 완전히 다른 상황이다.

점진적 향상은 "모르는 환경"에 대한 대비다.
"아는데 다르게 동작하는 환경"은 직접 확인하는 수밖에 없다.

진짜 원인은 검증 범위였다

검증 환경했나
모바일 웹 브라우저O
PCO
앱 웹뷰X

앱이 있는 서비스라는 걸 알고 있었는데도 테스트 대상에 넣지 않았다.
브라우저 호환성은 표까지 그려가며 꼼꼼히 봤는데,
정작 사용자가 실제로 들어오는 통로는 확인하지 않았다.

호환성 표를 읽는 것과 그 환경에서 직접 눌러보는 것은 다른 일이다.

그나마 다행이었던 것

기존 코드를 지우지 않고 추가만 했고, 전환 코드를 별도 파일로 분리해 뒀다.
덕분에 롤백이 파일 삭제 + 몇 줄 제거로 끝났다.

되돌리기 쉽게 짜두는 건 잘 될 때는 티가 안 나지만, 이런 날에 값을 한다.

다시 시도한다면

UA로 앱을 판별해 앱 환경에서는 opt-in 자체를 출력하지 않는 방식이 현실적이다.
다만 그러면 앱 사용자에게는 전환이 보이지 않으니,
주 사용자층이 앱 중심이라면 이 기능의 가치부터 따져봐야 한다.

적용을 검토 중이라면, 본인 서비스에 앱이 있는지부터 확인해야 한다.


참고

profile
프론트는 순항중 ¿¿

0개의 댓글