웹에서 화면이 전환될 때 벌어지는 일:
1. 현재 DOM 트리 파괴 ❌
2. 새로운 DOM 트리 생성 🆕
3. 리페인트 & 리플로우 🎨
이 0.03초 동안 사용자의 뇌는 '맥락 상실'을 경험합니다.
앱에서는 이미 해결한 문제인데, 웹은 왜 아직일까요?
시니어 프론트엔드 개발자들은 이 문제를 알고 있습니다. 그리고 해결하려 노력합니다.
하지만 기존 솔루션들은 한계가 명확했죠.
이 글에서는 SSGOI(https://ssgoi.dev)가 어떻게 이 문제를 해결했는지, 그 동작 원리와 구현 과정을 깊이 있게 살펴보겠습니다.
프론트엔드 개발자라면 한 번쯤 이런 고민을 해보셨을 겁니다:
"페이지가 전환될 때 요소가 사라지기 전에 애니메이션을 주고 싶은데..."
간단해 보이지만, 실제로는 복잡한 문제입니다:
// 이상적인 코드 (하지만 동작하지 않음)
if (shouldRemove) {
element.classList.add('fade-out'); // 애니메이션 추가
await sleep(300); // 애니메이션 대기
element.remove(); // 그 다음 제거
}
문제는 React, Vue 같은 프레임워크는 선언적으로 동작한다는 점입니다. 컴포넌트가 언마운트되면 DOM은 즉시 사라집니다. 애니메이션을 기다려주지 않죠.
SSGOI의 핵심은 DOM 요소의 생성과 소멸 시점을 가로채는 것입니다. 이를 통해 애니메이션을 삽입할 타이밍을 확보합니다.
┌─────────────────────────────────────────────────────┐
│ 일반적인 DOM 생명주기 │
├─────────────────────────────────────────────────────┤
│ │
│ 생성 ──────> 렌더링 ──────> 언마운트 ──────> 제거 │
│ │
└─────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────┐
│ SSGOI의 DOM 생명주기 │
├─────────────────────────────────────────────────────┤
│ │
│ 생성 ──┬──> [IN 애니메이션] ──> 렌더링 │
│ │ │
│ └──> 언마운트 ──> [OUT 애니메이션] ──> 제거 │
│ │
└─────────────────────────────────────────────────────┘
// 크롬에서만 동작하는 코드
if (document.startViewTransition) {
document.startViewTransition(() => {
// 페이지 전환
});
}
// Firefox, Safari 사용자는? 🤷♂️
View Transition API는 멋지지만 크롬 전용입니다. 전체 사용자의 30%는 이 경험을 할 수 없죠.
.page-enter {
animation: fadeIn 0.3s ease-in;
}
CSS 애니메이션은 간단하지만:
<Link>
router-link
next/link
goto()
각자 다른 방식으로 동작하는데, 이걸 하나로 통합하는 게 쉬울까요?
SSGOI의 핵심은 스프링 물리 기반 애니메이션 엔진입니다:
// 실제 SSGOI의 타입 정의
interface TransitionConfig {
// 스프링 물리 설정
spring?: {
stiffness: number; // 강성: 얼마나 빠르게 목표에 도달할지
damping: number; // 감쇠: 얼마나 부드럽게 멈출지
};
// 매 프레임마다 호출되는 콜백
tick?: (progress: number) => void;
// 애니메이션 시작 전 준비
prepare?: (element: HTMLElement) => void;
// 생명주기 훅
onStart?: () => void;
onEnd?: () => void;
}
핵심 인사이트: progress
는 단순한 0-1 값이 아닙니다. 스프링 물리 엔진이 생성하는 자연스러운 곡선입니다.
progress 값의 변화 (스프링 물리)
┌─────────────────────────────────┐
│ 1.2 ┤ ╭─╮ │ 오버슈트
│ 1.0 ┤ ╭─╯ ╰───────── │ (자연스러운 바운스)
│ 0.8 ┤ ╱ │
│ 0.6 ┤ ╱ │
│ 0.4 ┤╱ │
│ 0.2 ┤ │
│ 0.0 ┴──────────────────── │
└─────────────────────────────────┘
시간 →
이 레이어가 SSGOI의 핵심 마법이 일어나는 곳입니다:
// 실제 구현의 핵심 로직
export function createTransitionCallback(
getTransition: () => Transition,
options?: { onCleanupEnd?: () => void }
): TransitionCallback {
let currentAnimation: { animator: Animator; direction: "in" | "out" } | null = null;
let currentClone: HTMLElement | null = null;
return (element: HTMLElement | null) => {
if (!element) return;
// 1. 요소가 마운트될 때: IN 애니메이션 실행
runEntrance(element);
// 2. cleanup 함수 반환 (React의 useEffect cleanup과 유사)
return () => {
// 3. 요소가 언마운트될 때: 복제본 생성 후 OUT 애니메이션
const cloned = element.cloneNode(true) as HTMLElement;
runExitTransition(cloned);
};
};
}
핵심 트릭: 요소가 제거될 때 복제본을 생성하여 애니메이션을 계속 진행합니다!
언마운트 시 동작 과정:
┌────────────────────────────────────────────────┐
│ 1. React가 컴포넌트 언마운트 시작 │
│ └─> cleanup 함수 호출 │
│ │
│ 2. SSGOI가 DOM 복제본 생성 │
│ └─> 원본과 동일한 위치에 삽입 │
│ │
│ 3. 원본 DOM 제거 (React에 의해) │
│ └─> 사용자는 복제본을 보고 있음 │
│ │
│ 4. 복제본에서 OUT 애니메이션 실행 │
│ └─> 애니메이션 완료 후 복제본도 제거 │
└────────────────────────────────────────────────┘
각 프레임워크는 DOM을 다루는 방식이 다릅니다. SSGOI는 이를 추상화합니다:
// React Adapter
export function transition(options: TransitionOptions) {
const callback = createTransitionCallback(/* ... */);
// React의 ref 패턴 활용
return (element: HTMLElement | null) => {
if (element) {
// React는 ref가 변경될 때마다 이전 cleanup을 호출
return callback(element);
}
};
}
// Svelte Adapter
export function transition(node: HTMLElement, options: TransitionOptions) {
const cleanup = createTransitionCallback(/* ... */)(node);
// Svelte의 action 패턴
return {
destroy() {
cleanup?.();
}
};
}
SSGOI 문서 사이트에서 실제로 사용하는 스크롤 전환을 예시로 살펴보겠습니다:
// 문서 네비게이션 설정
const transitions = [
{
from: '/docs/introduction',
to: '/docs/quick-start',
transition: scroll({
direction: 'up', // 다음 페이지로 갈 때는 위로
spring: { stiffness: 20, damping: 7 } // 부드러운 스프링 설정
})
},
{
from: '/docs/quick-start',
to: '/docs/introduction',
transition: scroll({
direction: 'down', // 이전 페이지로 갈 때는 아래로
spring: { stiffness: 20, damping: 7 }
})
}
];
시간 흐름 →
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
T0: Introduction 페이지 (현재)
┌─────────────────────────┐
│ # SSGOI 소개 │
│ │
│ SSGOI는 웹에서 네이티브 │
│ 앱과 같은 자연스러운... │
│ │
│ [다음: Quick Start →] │ ← 사용자 클릭
└─────────────────────────┘
T1: 전환 시작 (DOM 복제 발생)
┌─────────────────────────┐
│ # SSGOI 소개 [복제본] │ ← OUT 애니메이션
│ │ translateY: 0 → -100%
│ SSGOI는 웹에서 네이티브 │
│ 앱과 같은 자연스러운... │
└─────────────────────────┘
↑ 위로 스크롤되며 사라짐
T2: 새 페이지 진입
↓ 아래에서 올라옴
┌─────────────────────────┐
│ # Quick Start │ ← IN 애니메이션
│ │ translateY: 100% → 0
│ SSGOI를 시작하는 가장 │
│ 빠른 방법을 알아봅시다 │
└─────────────────────────┘
T3: 전환 완료
┌─────────────────────────┐
│ # Quick Start │
│ │
│ SSGOI를 시작하는 가장 │
│ 빠른 방법을 알아봅시다 │
│ │
│ [← 이전] [다음 →] │
└─────────────────────────┘
// scroll 전환 효과의 실제 구현
export const scroll = (options: ScrollOptions = {}): SggoiTransition => {
const isUp = options.direction === "up";
return {
in: (element) => ({
spring: { stiffness: 20, damping: 7 },
tick: (progress) => {
// progress: 0 → 1
const translateY = isUp
? (1 - progress) * 100 // 100% → 0 (아래에서 위로)
: (1 - progress) * -100; // -100% → 0 (위에서 아래로)
element.style.transform = `translateY(${translateY}%)`;
}
}),
out: (element) => ({
prepare: prepareOutgoing, // 복제본을 절대 위치로 고정
tick: (progress) => {
// progress: 1 → 0
const translateY = isUp
? (1 - progress) * -100 // 0 → -100% (위로 사라짐)
: (1 - progress) * 100; // 0 → 100% (아래로 사라짐)
element.style.transform = `translateY(${translateY}%)`;
}
})
};
};
이렇게 마치 연속된 문서를 스크롤하듯 자연스럽게 페이지가 전환됩니다!
왜 CSS transition 대신 JavaScript 스프링 물리를 선택했을까요?
/* CSS Transition의 한계 */
.fade-out {
transition: opacity 300ms ease-out;
opacity: 0;
}
문제점:
// 스프링 물리의 장점
const animator = new Animator({
spring: { stiffness: 300, damping: 30 },
onUpdate: (progress) => {
// 언제든 중단, 역방향, 속도 변경 가능
element.style.opacity = progress;
}
});
// 사용자가 빠르게 클릭하면?
animator.reverse(); // 즉시 반대 방향으로
SSGOI는 requestAnimationFrame
을 통해 브라우저의 렌더링 주기와 동기화됩니다:
// Popmotion 라이브러리 활용
import { animate } from "popmotion";
// 60fps 유지를 위한 최적화
this.controls = animate({
from: this.currentValue,
to: target,
velocity: this.velocity * 1000,
stiffness: this.options.spring.stiffness,
damping: this.options.spring.damping,
onUpdate: (value: number) => {
// RAF와 동기화되어 프레임 드롭 최소화
this.currentValue = value;
this.options.onUpdate(value);
}
});
SSGOI의 강력한 기능 중 하나는 전환 전략(Transition Strategy)입니다:
// 애니메이션이 진행 중일 때 방향이 바뀌면?
const strategy = (context: StrategyContext) => ({
async runIn(configs) {
const { currentAnimation } = context;
if (currentAnimation?.direction === "out") {
// OUT 애니메이션 중이면 현재 상태에서 역방향
return {
config: await configs.in,
state: currentAnimation.animator.getCurrentState(),
direction: "backward",
from: 1,
to: 0
};
}
// 기본: 0에서 1로
return {
config: await configs.in,
state: { position: 0, velocity: 0 },
direction: "forward",
from: 0,
to: 1
};
}
});
// 모바일에서는 단순하게, 데스크톱에서는 화려하게
const responsiveTransition = {
in: (element) => ({
spring: {
stiffness: window.innerWidth > 768 ? 300 : 500,
damping: window.innerWidth > 768 ? 30 : 40
},
tick: (progress) => {
if (window.innerWidth > 768) {
// 데스크톱: 3D 회전 + 스케일
element.style.transform = `
perspective(1000px)
rotateY(${90 * (1 - progress)}deg)
scale(${0.8 + 0.2 * progress})
`;
} else {
// 모바일: 단순 페이드
element.style.opacity = progress;
}
}
})
};
Instagram 스토리처럼 요소가 화면을 넘나드는 효과:
// 상품 이미지가 리스트에서 상세 페이지로 이동하는 효과
const heroTransition = {
out: async (element) => {
const rect = element.getBoundingClientRect();
return {
prepare: (el) => {
// 절대 위치로 고정
el.style.position = 'fixed';
el.style.top = `${rect.top}px`;
el.style.left = `${rect.left}px`;
el.style.width = `${rect.width}px`;
el.style.height = `${rect.height}px`;
},
tick: (progress) => {
// 화면 중앙으로 이동하며 확대
const scale = 1 + (2 - 1) * (1 - progress);
const x = (window.innerWidth / 2 - rect.left - rect.width / 2) * (1 - progress);
const y = (window.innerHeight / 2 - rect.top - rect.height / 2) * (1 - progress);
element.style.transform = `
translate(${x}px, ${y}px)
scale(${scale})
`;
}
};
}
};
SSGOI를 만들면서 깨달은 것은, 웹과 앱의 경계가 점점 흐려지고 있다는 점입니다.
과거에는 "웹은 문서, 앱은 애플리케이션"이라는 명확한 구분이 있었지만, 이제 웹도 충분히 풍부한 인터랙션을 제공할 수 있습니다.
선언적 UI의 한계를 극복하는 방법은 있다
물리 기반 애니메이션이 자연스러움의 핵심
프레임워크 중립적 설계의 중요성
# React
npm install @ssgoi/react
# Svelte
npm install @ssgoi/svelte
# Vue (Coming Soon)
npm install @ssgoi/vue
// 단 3줄로 시작 +layout.tsx
import { Ssgoi } from '@ssgoi/react';
import { fade } from '@ssgoi/react/view-transitions';
<Ssgoi config={{ defaultTransition: fade() }}>
{children}
</Ssgoi>
이 글이 여러분의 웹 개발 여정에 새로운 관점을 제공했기를 바랍니다.
웹을 더 부드럽게, 더 자연스럽게, 더 아름답게.
그것이 SSGOI의 미션입니다. 🎯
UTM 파라미터가 추가된 링크와 함께 블로그 글 마지막에 넣을 수 있는 소개 문구를 만들어드릴게요:
🔗 참고로 이력서 서비스 운용하고 있습니다. 한번 둘러봐주세용 👀✨
AI 이력서 분석 서비스: 여기!
PDF 업로드만으로 30초 안에 이력서 점수와 개선점을 확인할 수 있어요! 현직 개발자이자 채용 담당자가 만든 AI 분석으로 더욱 정확한 피드백을 받아보세요 📝
Unlimited music Spotify Mod https://spotimodi.com/ offers users unrestricted access to millions of songs with premium features like offline mode, high-quality audio, and no ads. With Unlimited music Spotify Mod, enjoy nonstop streaming without limitations on your Android device.
The Enneagram Personality Test on EnneagramTypes101.com is a quick, insightful tool designed to help you uncover your core type, motivations, and emotional patterns.
https://enneagramtypes101.com/
Thanks for sharing. Let me introduce you to Grow A Garden Generator, a super healing interactive website. With just a few clicks, you can create a colorful, unique digital garden. Each plant is unique, perfect for your profile picture, wallpaper, or simply gifting it to a friend as a "digital plant pet." 💐
📎 Try it now:
👉 https://growagardengenerator.xyz/
Add some green healing to your daily life! 💚
SSGOI 너무 실용적인 오픈소스인 거 같습니다! 👍👍👍