동기
인터랙션 디자인 기술을 공부하던 중, rauno의 프로젝트 페이지를 발견했습니다. 여기서 각 프로젝트를 렌더링할 때 사용되는 애니메이션 효과가 저에게 시각적으로 너무나 매력적으로 다가왔습니다.
이를 구현해보고 싶었고 이 애니메이션을 재현하는 과정과 그 과정에서 얻은 통찰, 그리고 직면한 도전 과제들을 공유하고자 합니다. 생각보다 디테일이 많이 들어간 애니메이션이었고 그만큼 구현하고 나서 해냈다는 보람이 커서 좋았습니다. :)

애니메이션 이해하기
이 애니메이션의 핵심은 점진적 문자 변환입니다. 각 문자가 무작위 문자에서 최종 문자로 순차적으로 변환되면서 자연스러운 타이핑 효과를 만들어냅니다:
초기 상태
모든 텍스트는 공백 상태로 시작하고, 각 요소(제목, 설명, 선)가 왼쪽에서 오른쪽으로 점진적으로 나타납니다.
타이밍과 순서
애니메이션은 동시에 시작되지만, 각 문자가 안정화되는 시점이 다릅니다. 첫 번째 문자부터 순서대로 안정화되어 파도처럼 퍼지는 효과를 만들어냅니다.
문자별 변환 메커니즘
실제 구현에서는 각 문자 위치마다 iterations 횟수만큼 무작위 문자를 표시한 후, 원래 문자로 고정됩니다. 이 과정에서 문자 유형을 유지하는 것이 자연스러움의 핵심이라 생각했습니다.
첫 번째 시도
랜덤한 문자를 위해 jh3y-GRLKMPY의 GLYPHS 변수를 사용했습니다:
const GLYPHS = 'ラドクリフマラソンわたしワタシんょンョたばこタバコとうきょうトウキョウ0123456789±!@#$%^&*()_+ABCDEFGHIJKLMNOPQRSTUVWXYZ';각 요소(제목, 설명, 연도)의 텍스트를 문자열 배열로 변환하고, 각 문자를 GLYPHS에서 무작위로 선택한 문자로 대체하는 과정을 각 프레임마다 반복했습니다. 하지만 결과적으로 나온 애니메이션은 부자연스러워 보였습니다:
애니메이션 개선
추가 연구 끝에 Shuffle Text Effect With jQuery에서 영감💡을 얻어 몇 가지 중요한 통찰을 얻었습니다.
자연스러운 애니메이션을 위한 핵심 원칙
첫 번째 시도에서 부자연스러웠던 이유를 분석해보니 다음과 같은 원칙들이 중요했습니다:
1. 문자 유형 일관성 (Character Type Consistency)
각 문자의 원본 특성을 유지하면서 변환해야 합니다:
- 영어 소문자 → 영어 소문자 랜덤 생성
- 한글 → 한글 랜덤 생성
- 숫자 → 숫자 랜덤 생성
- 특수문자 → 특수문자 랜덤 생성
이렇게 해야 사용자의 눈이 “비슷한 형태”로 인식해서 자연스럽게 느껴집니다.
2. 공백 문자 보존 (Whitespace Preservation)
공백과 줄바꿈은 변환하지 않고 그대로 유지해야 텍스트의 구조가 깨지지 않습니다.
3. 최적의 타이밍 (Optimal Timing)
- iterations: 각 문자당 몇 번의 랜덤 변환을 거칠지 (기본값: 8)
- fps: 초당 몇 번 업데이트할지 (기본값: 30)
이 값들을 실험해볼 수 있는 자그마한 데모를 만들었습니다:
Shuffle Letters Playground
`iterations`와 `fps` 값을 조정하여 애니메이션 효과를 실험해보세요
결과
• 텍스트를 입력하고 옵션을 조정한 후 "Shuffle" 버튼을 클릭하세요
• 애니메이션 중 ESC 키로 언제든 중단할 수 있습니다
• iterations: 각 문자당 랜덤 변환 횟수
• fps: 초당 프레임 수 (높을수록 빠름)
다양한 텍스트와 설정값을 시도해보면서 자연스러운 애니메이션을 만드는 원리를 이해할 수 있습니다:
한글 처리
한글 문자 생성에서 마주한 도전은 문자 집합의 크기였습니다:
문제 상황
- 영어: 소문자 26개, 대문자 26개 → 간단한 배열로 처리 가능
- 한글: 가능한 조합이 11,172개 → 배열로 저장하기엔 너무 방대함
유니코드 기반 해결책
완성형 한글은 유니코드에서 연속된 구간이므로 배열 대신 계산으로 생성합니다. 영어·숫자·특수문자는 작은 문자 풀에서 뽑고, 한글만 코드포인트 계산으로 만듭니다:
type CharType = 'space' | 'lowerCase' | 'upperCase' | 'digit' | 'symbol' | 'korean';
const charPools = {
lowerCase: 'abcdefghijklmnopqrstuvwxyz',
upperCase: 'ABCDEFGHIJKLMNOPQRSTUVWXYZ',
digit: '0123456789',
symbol: '!@#$%^&*()_+-=[]{}|;:,.<>?',
};
function getRandomCharacterForType(charType: CharType): string {
// 한글은 '가'(0xAC00)부터 연속된 11,172개 중 하나를 계산으로 생성
if (charType === 'korean') {
return String.fromCharCode(0xac00 + Math.floor(Math.random() * 11172));
}
const pool = charPools[charType];
return pool[Math.floor(Math.random() * pool.length)];
}한글 문자 감지 로직
한글 감지도 같은 원리입니다. 코드포인트가 연속 구간 안에 있는지 범위 비교 한 번으로 판별합니다:
function isKorean(char: string): boolean {
const code = char.charCodeAt(0);
return code >= 0xac00 && code <= 0xd7a3; // '가' ~ '힣'
}애니메이션 구현하기
이제 자연스럽게 변환되는 애니메이션을 구현해 보겠습니다.
텍스트 분석 — 세 가지 자료구조 준비
애니메이션을 시작하기 전에 원본 텍스트를 한 번만 순회하면서 세 가지 자료구조를 만듭니다:
const charsArray = Array.from(element.textContent ?? '');
// 위치별 문자 유형
const charsTypes = charsArray.map(char => {
if (/\s/.test(char)) return 'space';
if (isKorean(char)) return 'korean';
if (/[a-z]/.test(char)) return 'lowerCase';
if (/[A-Z]/.test(char)) return 'upperCase';
if (/[0-9]/.test(char)) return 'digit';
return 'symbol';
});
// 공백을 제외한, 애니메이션 대상 인덱스 목록
const charsPositions = charsArray.reduce<number[]>((acc, char, index) => {
if (!/\s/.test(char)) acc.push(index);
return acc;
}, []);
// 입력이 "Hi 월드 1"이라면:
// charsArray → ['H', 'i', ' ', '월', '드', ' ', '1']
// charsTypes → [대문자, 소문자, 공백, 한글, 한글, 공백, 숫자]
// charsPositions → [0, 1, 3, 4, 6]charsArray— 원본 문자 배열입니다. 매번 이 배열의 복사본에서 렌더링을 시작하므로 언제든 원본으로 복원할 수 있습니다.charsTypes— 위치별 문자 유형(공백/한글/소문자/대문자/숫자/특수문자)입니다. 랜덤 문자를 원본과 같은 유형으로 생성할 때 참조합니다.charsPositions— 공백을 제외한 인덱스 목록입니다. 진행 위치step이 이 배열 위에서만 움직이므로 공백은 변환 대상에서 자연히 빠집니다.
한 step 그리기 — 파도의 단면
renderStep은 진행 위치 step을 받아 화면에 그릴 문자열을 계산합니다. 시간 개념 없이 step 값만으로 결과가 정해집니다:
function renderStep(step: number) {
const result = [...charsArray]; // 항상 원본에서 시작
for (let i = Math.max(step, 0); i < charsPositions.length; i++) {
const pos = charsPositions[i];
if (i < step + iterations) {
result[pos] = getRandomCharacterForType(charsTypes[pos]); // ② 끓는 중인 구간
} else {
result[pos] = ''; // ③ 아직 도달하지 않은 구간
}
}
// i < step인 위치는 복사본 그대로 → ① 원본으로 안정화된 구간
element.textContent = result.join('');
}step을 기준으로 텍스트는 세 구간으로 나뉩니다:
- ① 원본으로 확정된 구간 —
step이전 위치. 복사본 그대로 남아 원본 문자가 보입니다. - ② 랜덤 문자로 끓는 구간 —
step부터iterations길이만큼의 창. - ③ 아직 비어 있는 구간 — 창 너머, 빈 문자열로 가려진 위치.
step이 1 커질 때마다 끓는 구간이 오른쪽으로 한 칸 이동하며 파도를 만듭니다.
시간 축 스케줄링 — rAF 타임스텝
스케줄러는 step을 시간에 따라 전진시킵니다:
const stepDuration = 1000 / fps; // step 하나가 차지하는 시간(ms)
let step = -iterations; // 음수에서 시작해 첫 글자도 같은 횟수만큼 끓도록
let lastStepTime: number | null = null;
let rafId: number;
function tick(now: number) {
// now: 브라우저가 매 프레임 전달하는 타임스탬프
if (lastStepTime === null) lastStepTime = now;
const elapsed = now - lastStepTime;
if (elapsed >= stepDuration) {
// 프레임 드랍으로 누락된 step은 한 번에 따라잡는다
step = Math.min(step + Math.floor(elapsed / stepDuration), charsPositions.length);
lastStepTime = now - (elapsed % stepDuration);
renderStep(step); // 마지막 step은 원본 전체를 복원
if (step >= charsPositions.length) {
onComplete(element);
return; // 다음 프레임을 예약하지 않으면 루프 종료
}
}
rafId = requestAnimationFrame(tick);
}
renderStep(step);
rafId = requestAnimationFrame(tick);
return () => cancelAnimationFrame(rafId); // cancel — 호출 시 애니메이션 중단처음 구현은 setTimeout을 1000 / fps 간격으로 연쇄 호출하는 방식이었습니다. rAF 타임스텝으로 바꾸면서 네 가지가 좋아졌습니다:
- 프레임 정렬: DOM 변경이 프레임 경계에서만, 프레임당 최대 1회 일어납니다. 글 목록에서 요소 수십 개가 동시에 셔플돼도 타이머가 프레임 사이에 끼어들지 않습니다.
- 일정한 재생 길이: 프레임이 드랍되면 경과 시간만큼
step을 한 번에 건너뜁니다. 기기 성능과 무관하게 애니메이션 총 길이가 같습니다. - 백그라운드 자동 정지: 브라우저는 탭이 보이지 않으면 rAF를 호출하지 않습니다. setTimeout 체인은 백그라운드에서도 계속 돌며 CPU를 소비했습니다.
- 종료 보장:
fps나iterations에 NaN이 들어오면elapsed >= stepDuration비교가 영원히 거짓이 되어 루프가 끝나지 않습니다. 그래서 함수 진입 시점에 두 값이 유한한 수인지 검증합니다.
파도 효과(Wave Effect)의 원리
위 로직에서 핵심은 진행 위치 step입니다:
- step < 0: 아직 아무것도 보이지 않음
- step = 0: 첫 번째 문자가 랜덤하게 변환 시작
- step = 1: 첫 번째 문자는 안정화, 두 번째 문자가 랜덤 변환 시작
- step = n: n번째 문자까지 안정화, n+1번째부터 랜덤 변환
이렇게 step이 전진할 때마다 안정화되는 문자가 하나씩 늘어나면서 왼쪽에서 오른쪽으로 퍼지는 파도 효과가 만들어집니다.
최종 결과와 성능 최적화
모든 개선사항을 적용한 최종 애니메이션입니다:
프로덕션 레벨 최적화
실제 사용을 위해 추가로 구현한 최적화 기법들:
1. 성능 최적화
- 인터섹션 옵저버 활용:
useInView훅으로 뷰포트에 보이는 요소만 애니메이션 실행 - rAF 기반 스케줄링: 여러 요소가 동시에 셔플돼도 DOM 변경이 프레임 경계에 정렬되고, 백그라운드 탭에서는 자동 정지
- 메모리 관리: 컴포넌트 언마운트 시 cancel 함수로 예약된 프레임을 정리해, 분리된 DOM을 계속 변경하는 누수 방지
- 배치 업데이트: step당
textContent1회 변경으로 DOM 조작 최소화
2. 사용자 경험 개선
- ESC 키 중단: 진행 중인 애니메이션을 언제든 중단 가능
- 즉시 복원: 중단 시 원본 텍스트로 즉시 복원
- 상태 관리:
isAnimating상태로 중복 실행 방지
3. 시각적 완성도
- 선 애니메이션:
scaleX변환으로 왼쪽에서 오른쪽으로 자연스럽게 확장 - 투명도 조절:
opacity값으로 부드러운 페이드인 효과 - 타이밍 튜닝: 적절한
iterations,fps값 설정
실제 사용 흐름
shuffleLetters는 시작과 동시에 cancel 함수를 반환합니다. 컴포넌트가 언마운트될 때 cancel을 호출해 진행 중인 애니메이션을 정리하세요:
const cancel = shuffleLetters(element, {
iterations: 8, // 글자당 끓는 횟수
fps: 30, // step 전진 속도
onComplete: el => {
// 완료 시 처리
},
});
// 라우트 이동 등으로 컴포넌트가 언마운트될 때
cancel();회고와 배운 점
기술적 성과
rauno의 텍스트 애니메이션을 재현하면서 생각했던 것보다 재밌게 고민할 수 있던 부분이 많았습니다. 특히:
- 자연스러운 애니메이션을 위한 문자 유형별 일관성 구현
- 한글 처리를 위한 유니코드 활용
개발 과정에서의 인사이트
- 첫 시도의 부자연스러움 → 문자 유형 분류의 필요성 발견
- 한글 문자 → 효율적인 유니코드 기반 해결책 도출
- 단순한 랜덤 교체 → 파도 효과를 통한 시각적 완성도 향상
- 글 목록에서 요소 수십 개 동시 실행 → setTimeout 체인의 프레임 비정렬 한계 발견, rAF 타임스텝으로 전환
이 작업을 통해 기술적 구현과 디자인적 완성도 사이의 균형을 맞추는 것이 얼마나 중요한지 다시 한번 느낄 수 있었습니다.
연구할수록 자연스러워지는 애니메이션 동작을 볼 때마다 보람찼고, 최종적으로 원하던 결과를 얻어 좋았습니다.