CSS로 만드는 호버 & 커서 이펙트
Part 1에서는 텍스트 애니메이션을 다뤘다 — CSS @keyframes부터 Canvas 2D, WebGL Shader까지. Part 2에서는 마우스/터치 입력에 반응하는 인터랙션을 다룬다. 인터랙션 애니메이션 28개를 분석해보니, 기술 선택의 기준은 "얼마나 정밀한 제어가 필요한가"였다. CSS만으로 충분한 것부터 WebGL 유체 시뮬레이션까지 — 이 시리즈에서는 그 스펙트럼을 하나씩 올라간다.
이번 편은 라이브러리 없이 CSS + 기본 DOM API만으로 가능한 인터랙션이다. 5개 컴포넌트를 세 가지 카테고리로 나눈다:
| 카테고리 | 컴포넌트 | 핵심 기술 | JS 역할 |
|---|---|---|---|
| Pure CSS 호버 | GlareHover | CSS gradient + background-position | props → CSS 변수 전달만 |
| Pure CSS 호버 | StarBorder | CSS @keyframes + radial-gradient | props → inline style만 |
| 마그넷 효과 | Magnet | getBoundingClientRect + transform | 마우스 좌표 → 오프셋 계산 |
| 마그넷 효과 | MagnetLines | CSS Grid + 동적 회전 | 마우스 좌표 → 각도 계산 |
| 무한 루프 | LogoLoop | ResizeObserver + rAF | 속도 보간 + 무한 스크롤 |
Pure CSS 호버 — GlareHover, StarBorder
💡 CSS만으로 호버 효과를 만드는 두 가지 전략
CSS 호버 효과의 핵심은 :hover 의사 클래스와 transition의 조합이다. JavaScript가 하는 일은 React props를 CSS 커스텀 속성으로 전달하는 것뿐 — 실제 애니메이션은 CSS가 처리한다.
두 가지 전략이 있다:
- background-position 이동 (GlareHover) —
:hover에서 gradient의 위치를 바꾸고,transition이 보간한다 - @keyframes 무한 반복 (StarBorder) — 항상 돌아가는 애니메이션을
overflow: hidden으로 보이는 영역만 노출한다
GlareHover — CSS gradient 반사 효과
카드에 마우스를 올리면 빛이 대각선으로 스와이프된다 — 글레어 크기와 각도를 조절해보자.
GlareHover의 JSX는 73줄이지만, 애니메이션 로직은 0줄이다. React 컴포넌트가 하는 유일한 일은 props를 CSS 커스텀 속성으로 변환하는 것이다.
1단계: hex → rgba 변환
// hex 색상 문자열을 rgba()로 변환 — CSS 변수에 투명도를 포함시키기 위함
const hex = glareColor.replace('#', '');
let rgba = glareColor;
if (/^[0-9A-Fa-f]{6}$/.test(hex)) {
const r = parseInt(hex.slice(0, 2), 16);
const g = parseInt(hex.slice(2, 4), 16);
const b = parseInt(hex.slice(4, 6), 16);
rgba = `rgba(${r}, ${g}, ${b}, ${glareOpacity})`;
}
#ffffff를 rgba(255, 255, 255, 0.5)로 변환한다. CSS에서 hex 색상에 직접 opacity를 적용할 수 없기 때문이다. glareOpacity prop이 rgba의 네 번째 인자로 들어간다.
2단계: CSS 커스텀 속성 전달
const vars = {
'--gh-width': width, // '500px'
'--gh-height': height, // '500px'
'--gh-bg': background, // '#000'
'--gh-br': borderRadius, // '10px'
'--gh-angle': `${glareAngle}deg`, // '-45deg'
'--gh-duration': `${transitionDuration}ms`, // '650ms'
'--gh-size': `${glareSize}%`, // '250%'
'--gh-rgba': rgba, // 'rgba(255, 255, 255, 0.5)'
'--gh-border': borderColor // '#333'
};
9개의 CSS 커스텀 속성이 style prop으로 전달된다. React 쪽 작업은 여기서 끝이다.
3단계: CSS가 하는 모든 일
/* ::before 가상 요소 = 반사광 레이어 */
.glare-hover::before {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(
var(--gh-angle), /* -45deg → 좌상단에서 우하단 방향 */
hsla(0, 0%, 0%, 0) 60%, /* 0~60%: 투명 */
var(--gh-rgba) 70%, /* 60~70%: rgba(255,255,255,0.5) 반사광 */
hsla(0, 0%, 0%, 0) 100% /* 70~100%: 투명 */
);
transition: var(--gh-duration) ease; /* 650ms */
background-size: var(--gh-size) var(--gh-size); /* 250% 250% */
background-position: -100% -100%; /* 초기: 좌상단 밖 → 보이지 않음 */
}
/* 호버 시: 우하단으로 이동 → transition이 650ms 동안 보간 */
.glare-hover:hover::before {
background-position: 100% 100%;
}
데이터 흐름:
React props → CSS 커스텀 속성 (--gh-*)
→ ::before의 linear-gradient (반사광 모양 결정)
→ :hover → background-position 변경 (-100% -100% → 100% 100%)
→ CSS transition이 650ms 동안 보간
→ 대각선 스와이프 효과
| 속성 | 초기값 | 호버 시 | 시각적 효과 |
|---|---|---|---|
| background-position | -100% -100% | 100% 100% | 좌상단 밖 → 우하단 밖으로 이동 |
| background-size | 250% 250% | (변경 없음) | gradient가 요소보다 2.5배 큼 |
| transition | 650ms ease | (변경 없음) | 부드러운 보간 |
background-size: 250%가 핵심이다. 요소보다 gradient가 훨씬 크기 때문에, -100%에서 100%로 이동하는 동안 반사광의 좁은 밴드만 요소를 가로지른다. 100%면 gradient 전체가 보여서 반사 효과가 사라진다.
4단계: playOnce 모드
/* transition을 평소에 제거하고, 호버 시에만 적용 */
.glare-hover--play-once::before {
transition: none; /* 마우스 빼도 즉시 리셋 (되감기 없음) */
}
.glare-hover--play-once:hover::before {
transition: var(--gh-duration) ease; /* 호버 시에만 애니메이션 */
}
기본 모드에서는 마우스를 빼면 반사광이 되감긴다. playOnce를 켜면 마우스를 빼는 순간 transition: none이므로 즉시 원위치로 돌아가고, 다시 올리면 처음부터 재생된다.
StarBorder — CSS @keyframes 테두리 빛
버튼 테두리에 빛이 왕복한다 — 속도와 두께를 조절해보자.
StarBorder는 GlareHover와 달리 항상 돌아가는 애니메이션이다. :hover가 아니라 @keyframes로 무한 반복한다.
1단계: 레이어 구조
star-border-container (overflow: hidden)
├── border-gradient-bottom (z-index: 0) ← 하단 빛
├── border-gradient-top (z-index: 0) ← 상단 빛
└── inner-content (z-index: 1) ← 실제 콘텐츠
inner-content가 z-index: 1로 빛 레이어를 가린다. 빛은 inner-content의 테두리 바깥 틈으로만 보인다. 이 틈의 크기가 thickness prop이다:
style={{ padding: `${thickness}px 0` }}
padding: 1px 0이면 컨테이너와 inner-content 사이에 상하 1px 틈이 생기고, 그 틈으로 빛이 보인다.
2단계: radial-gradient 빛
// 점 형태의 빛 — 중심에서 바깥으로 10%까지만 불투명
background: `radial-gradient(circle, ${color}, transparent 10%)`,
animationDuration: speed // '6s'
radial-gradient(circle, white, transparent 10%)에서 10%는 gradient 반지름의 10%까지만 색이 채워진다는 뜻이다. 나머지 90%는 투명하므로 결과적으로 중앙에 작은 점만 빛난다. 이 요소의 width: 300%와 조합하면, 실제 빛 점의 지름은 300% × 10% × 2 = 60% — 즉 요소 폭의 약 60% 크기가 된다. width를 키울수록 빛 점이 커지고, transparent 퍼센트를 높일수록 더 선명하게 날카로워진다.
3단계: @keyframes로 왕복
/* 하단 빛: 오른쪽에서 왼쪽으로 이동하며 투명해짐 */
@keyframes star-movement-bottom {
0% { transform: translate(0%, 0%); opacity: 1; }
100% { transform: translate(-100%, 0%); opacity: 0; }
}
/* 상단 빛: 왼쪽에서 오른쪽으로 이동 (반대 방향) */
@keyframes star-movement-top {
0% { transform: translate(0%, 0%); opacity: 1; }
100% { transform: translate(100%, 0%); opacity: 0; }
}
animation: star-movement-bottom linear infinite alternate에서 alternate가 핵심이다. 0% → 100% 후 자동으로 100% → 0%로 되돌아간다. 상단과 하단 빛이 반대 방향으로 이동하므로, 한쪽에서 빛이 사라지면 반대쪽에서 나타난다.
| 시간 | 하단 빛 위치 | 하단 opacity | 상단 빛 위치 | 상단 opacity |
|---|---|---|---|---|
| 0% | translate(0%, 0%) | 1 | translate(0%, 0%) | 1 |
| 50% | translate(-50%, 0%) | 0.5 | translate(50%, 0%) | 0.5 |
| 100% | translate(-100%, 0%) | 0 | translate(100%, 0%) | 0 |
| 100% → 0% | translate(-100%) → (0%) | 0 → 1 | translate(100%) → (0%) | 0 → 1 |
두 빛이 alternate로 왕복하면서 테두리 전체를 감싸는 효과를 만든다.
마그넷 효과 — Magnet, MagnetLines
💡 getBoundingClientRect + transform — 마우스 추적의 기본 패턴
앞서 GlareHover와 StarBorder는 JavaScript 로직이 거의 없었다. 마그넷 효과부터는 JavaScript가 매 mousemove 이벤트마다 좌표를 계산한다.
기본 패턴:
// 1. 요소의 화면 위치와 크기를 가져온다
const { left, top, width, height } = element.getBoundingClientRect();
const centerX = left + width / 2;
const centerY = top + height / 2;
// 2. 마우스와 요소 중심 사이의 거리를 계산한다
const distX = e.clientX - centerX;
const distY = e.clientY - centerY;
// 3. 거리에 따라 transform을 적용한다
element.style.transform = `translate3d(${distX / 2}px, ${distY / 2}px, 0)`;
getBoundingClientRect()는 요소의 화면 기준 위치를 반환한다 — 스크롤이나 부모 요소의 위치에 상관없이 브라우저 뷰포트 좌상단을 원점으로 한다. e.clientX/clientY도 동일한 좌표계이므로 직접 뺄셈할 수 있다.
Magnet — 마우스 방향으로 요소 끌어당기기
마우스를 요소 근처로 가져가면 끌려온다 — padding과 자석 강도를 조절해보자.
Magnet은 85줄의 간결한 컴포넌트다. CSS 파일이 없고, 모든 스타일이 inline으로 처리된다.
1단계: 감지 영역 판별
const { left, top, width, height } = magnetRef.current.getBoundingClientRect();
const centerX = left + width / 2;
const centerY = top + height / 2;
const distX = Math.abs(centerX - e.clientX);
const distY = Math.abs(centerY - e.clientY);
// 요소 경계 + padding 범위 안에 마우스가 있는지 판별
if (distX < width / 2 + padding && distY < height / 2 + padding) {
// 범위 안 → 끌어당김
} else {
// 범위 밖 → 원위치
}
감지 영역은 사각형이다. 요소 크기가 128x128px이고 padding: 100이면:
| 값 | 계산 | 결과 |
|---|---|---|
| 요소 반폭 | 128 / 2 | 64px |
| 감지 범위 | 64 + 100 | 164px (중심에서) |
| 감지 영역 크기 | 164 * 2 | 328 x 328px |
마우스가 중심에서 164px 이내에 들어오면 끌어당김이 시작된다.
2단계: 이동 거리 계산
const offsetX = (e.clientX - centerX) / magnetStrength;
const offsetY = (e.clientY - centerY) / magnetStrength;
setPosition({ x: offsetX, y: offsetY });
magnetStrength로 나누는 것이 핵심이다. 마우스가 중심에서 100px 떨어져 있고 magnetStrength: 2이면, 요소는 100/2 = 50px만 이동한다. 값이 클수록 약하게 끌린다.
| magnetStrength | 마우스 거리 100px일 때 이동 | 체감 |
|---|---|---|
| 1 | 100px (마우스와 동일) | 달라붙음 |
| 2 | 50px | 적당한 끌림 |
| 5 | 20px | 미세한 끌림 |
| 10 | 10px | 거의 안 움직임 |
3단계: transition으로 부드러운 이동
const transitionStyle = isActive ? activeTransition : inactiveTransition;
// isActive = true → 'transform 0.3s ease-out' (빠르게 따라감)
// isActive = false → 'transform 0.5s ease-in-out' (천천히 원위치)
데이터 흐름:
window.mousemove → getBoundingClientRect() → 거리 판별
→ 범위 안?
→ offsetX = (mouseX - centerX) / magnetStrength
→ setPosition({ x: offsetX, y: offsetY })
→ transform: translate3d(offsetX, offsetY, 0)
→ transition: 0.3s ease-out (따라감)
→ 범위 밖?
→ setPosition({ x: 0, y: 0 })
→ transform: translate3d(0, 0, 0)
→ transition: 0.5s ease-in-out (원위치 복귀)
활성/비활성 상태에서 다른 transition을 적용하는 것이 자연스러운 느낌의 핵심이다. 끌려올 때는 빠르게(0.3s), 돌아갈 때는 여유롭게(0.5s).
MagnetLines — CSS Grid + 동적 회전
마우스를 움직이면 그리드의 모든 라인이 마우스 방향을 가리킨다 — 행/열 수와 색상을 바꿔보자.
MagnetLines는 Magnet과 같은 getBoundingClientRect 패턴을 쓰지만, 하나의 요소가 아닌 N*N개의 요소가 각각 독립적으로 반응한다.
1단계: CSS Grid로 라인 배치
// rows * columns개의 span을 생성
const total = rows * columns; // 9 * 9 = 81개
const spans = Array.from({ length: total }, (_, i) => (
<span
key={i}
style={{
'--rotate': `${baseAngle}deg`, // 초기 각도 (예: -10deg)
backgroundColor: lineColor,
width: lineWidth, // '1vmin'
height: lineHeight // '6vmin'
}}
/>
));
CSS Grid가 81개의 span을 9x9 격자로 자동 배치한다:
gridTemplateColumns: `repeat(${columns}, 1fr)`,
gridTemplateRows: `repeat(${rows}, 1fr)`,
CSS에서 각 span은 transform: rotate(var(--rotate))로 회전한다. JavaScript가 --rotate를 바꾸면 즉시 반영된다.
2단계: 각도 계산 — 삼각함수
const onPointerMove = (pointer: { x: number; y: number }) => {
items.forEach(item => {
const rect = item.getBoundingClientRect();
const centerX = rect.x + rect.width / 2;
const centerY = rect.y + rect.height / 2;
const b = pointer.x - centerX; // x 거리 (밑변)
const a = pointer.y - centerY; // y 거리 (높이)
const c = Math.sqrt(a * a + b * b) || 1; // 빗변 (유클리드 거리)
// acos(b/c) = 벡터와 x축 사이의 각도
const r = ((Math.acos(b / c) * 180) / Math.PI)
* (pointer.y > centerY ? 1 : -1);
item.style.setProperty('--rotate', `${r}deg`);
});
};
삼각함수로 포인터 방향의 각도를 계산한다:
r = acos(b / c) × 180 / π × (y방향 보정)
acos(b/c)는 x축과 빗변 사이의 각도(라디안)를 반환한다. 180/Math.PI로 도(degree)로 변환한다.
| 포인터 위치 | b (x) | a (y) | b/c | acos(b/c) | 최종 각도 |
|---|---|---|---|---|---|
| 오른쪽 수평 | 100 | 0 | 1 | 0deg | 0deg |
| 위 수직 | 0 | -100 | 0 | 90deg | -90deg |
| 왼쪽 수평 | -100 | 0 | -1 | 180deg | 180deg |
| 아래 수직 | 0 | 100 | 0 | 90deg | 90deg |
pointer.y > centerY ? 1 : -1은 y축 방향을 보정한다. acos은 항상 0~180도를 반환하므로, 포인터가 위에 있으면 음수로 뒤집어야 전체 360도 범위를 표현할 수 있다.
3단계: 초기 상태 설정
// 마운트 시 그리드 중앙 라인을 기준으로 초기 각도 설정
if (items.length) {
const middleIndex = Math.floor(items.length / 2); // 81개 중 40번째
const rect = items[middleIndex].getBoundingClientRect();
onPointerMove({ x: rect.x, y: rect.y });
}
마운트 직후에 가상의 포인터를 그리드 중앙에 놓아 자연스러운 초기 상태를 만든다. 이 코드가 없으면 모든 라인이 baseAngle으로 일률적으로 기울어져 있어 어색하다.
무한 루프 — LogoLoop
💡 CSS가 아닌 rAF를 쓰는 이유
무한 스크롤 애니메이션은 CSS @keyframes로도 만들 수 있다:
@keyframes scroll {
0% { transform: translateX(0); }
100% { transform: translateX(-50%); }
}
.track { animation: scroll 10s linear infinite; }
하지만 이 방식에는 세 가지 한계가 있다:
- 호버 시 정지/감속이 부자연스럽다 —
animation-play-state: paused는 즉시 멈추고, 감속 효과를 줄 수 없다 - 리사이즈 대응이 불가능하다 — 컨테이너 크기가 바뀌면
translateX(-50%)의 의미가 달라지는데, CSS만으로는 동적으로 복제 수를 조정할 수 없다 - 속도를 px/s 단위로 제어할 수 없다 —
duration: 10s는 콘텐츠 길이에 상관없이 10초에 한 바퀴이므로, 콘텐츠가 길면 빨라지고 짧으면 느려진다
LogoLoop은 이 세 가지를 모두 해결하기 위해 requestAnimationFrame 기반 커스텀 루프를 사용한다.
LogoLoop — ResizeObserver + rAF 무한 스크롤
로고들이 무한히 흘러간다 — 속도와 방향을 바꿔보자.
- React
- Next.js
- TypeScript
- Tailwind
- Vite
- Node.js
388줄의 가장 큰 컴포넌트다. 3개의 커스텀 훅으로 분리되어 있다.
1단계: useResizeObserver — 복제 수 동적 계산
const updateDimensions = useCallback(() => {
const containerWidth = containerRef.current?.clientWidth ?? 0;
const sequenceWidth = seqRef.current?.getBoundingClientRect()?.width ?? 0;
if (sequenceWidth > 0) {
setSeqWidth(Math.ceil(sequenceWidth));
// 뷰포트를 채우기 위해 필요한 복제 수 + 여유분 2개
const copiesNeeded = Math.ceil(containerWidth / sequenceWidth)
+ ANIMATION_CONFIG.COPY_HEADROOM;
setCopyCount(Math.max(ANIMATION_CONFIG.MIN_COPIES, copiesNeeded));
}
}, []);
ResizeObserver가 컨테이너나 시퀀스의 크기 변경을 감지하면 updateDimensions를 호출한다.
| 컨테이너 폭 | 시퀀스 폭 | 필요 복제 수 | + 여유분 | 최종 |
|---|---|---|---|---|
| 800px | 400px | ceil(800/400) = 2 | 2+2 = 4 | 4 |
| 1200px | 400px | ceil(1200/400) = 3 | 3+2 = 5 | 5 |
| 300px | 400px | ceil(300/400) = 1 | 1+2 = 3 | 3 (최소 2) |
여유분 2개(COPY_HEADROOM)는 스크롤 중 빈 공간이 노출되지 않도록 한다. seqRef는 첫 번째 <ul>에만 연결되어 "한 세트"의 크기를 측정한다.
2단계: useAnimationLoop — 속도 보간 + modulo 래핑
const animate = (timestamp: number) => {
const deltaTime = (timestamp - lastTimestampRef.current) / 1000; // 초 단위
// 지수 보간으로 속도를 부드럽게 변경
const easingFactor = 1 - Math.exp(-deltaTime / ANIMATION_CONFIG.SMOOTH_TAU);
velocityRef.current += (target - velocityRef.current) * easingFactor;
// 오프셋 갱신 — modulo로 한 세트 범위 안에 유지
let nextOffset = offsetRef.current + velocityRef.current * deltaTime;
nextOffset = ((nextOffset % seqSize) + seqSize) % seqSize;
offsetRef.current = nextOffset;
track.style.transform = `translate3d(${-offsetRef.current}px, 0, 0)`;
requestAnimationFrame(animate);
};
두 가지 핵심 기법이 있다:
지수 보간 (exponential smoothing):
velocityRef.current += (target - current) * (1 - e^(-dt/tau))
SMOOTH_TAU = 0.25일 때, 매 프레임마다 목표 속도의 약 63%씩 접근한다. 호버로 target이 0이 되면 현재 속도가 120 → 44 → 16 → 6 → 2 → ...처럼 감속한다. CSS animation-play-state: paused의 즉시 멈춤과 달리 관성 있는 감속이 된다.
modulo 래핑:
nextOffset = ((nextOffset % seqSize) + seqSize) % seqSize;
오프셋이 seqSize(한 세트의 폭)를 넘으면 0으로 되감긴다. 동일한 리스트가 여러 개 복제되어 있으므로, 오프셋이 0으로 점프해도 시각적으로 끊김이 없다. ((x % n) + n) % n은 음수 modulo를 양수로 변환하는 JavaScript 관용구다 — direction: 'right'일 때 음수 오프셋을 처리한다.
3단계: useImageLoader — 이미지 로드 대기
const useImageLoader = (seqRef, onLoad, dependencies) => {
useEffect(() => {
const images = seqRef.current?.querySelectorAll('img') ?? [];
if (images.length === 0) {
onLoad(); // 이미지 없으면 즉시 실행
return;
}
let remainingImages = images.length;
const handleImageLoad = () => {
remainingImages -= 1;
if (remainingImages === 0) onLoad(); // 모든 이미지 로드 완료
};
images.forEach(img => {
if ((img as HTMLImageElement).complete) handleImageLoad();
else img.addEventListener('load', handleImageLoad, { once: true });
});
}, dependencies);
};
이미지가 로드되기 전에 getBoundingClientRect()를 호출하면 크기가 0이다. useImageLoader가 모든 이미지 로드를 기다린 후 updateDimensions를 호출해서 정확한 크기로 복제 수를 계산한다.
4단계: 페이드 아웃 — CSS ::before/::after
.logoloop--fade::before {
left: 0;
background: linear-gradient(
to right,
var(--logoloop-fadeColor) 0%,
rgba(0, 0, 0, 0) 100%
);
}
.logoloop--fade::after {
right: 0;
background: linear-gradient(
to left,
var(--logoloop-fadeColor) 0%,
rgba(0, 0, 0, 0) 100%
);
}
양쪽 가장자리에 pointer-events: none + z-index: 10인 가상 요소로 페이드를 만든다. clamp(24px, 8%, 120px)으로 컨테이너 크기에 비례하되 최소 24px, 최대 120px의 페이드 폭을 유지한다.
참고: prefers-reduced-motion 접근성 대응
@media (prefers-reduced-motion: reduce) {
.logoloop__track {
transform: translate3d(0, 0, 0) !important;
}
}
사용자가 시스템 설정에서 "모션 줄이기"를 켜면 트랙이 멈춘다. !important로 rAF가 설정하는 inline transform을 덮어쓴다. 접근성은 선택이 아닌 필수다.
CSS만으로 가능한 인터랙션의 범위
5개 컴포넌트를 살펴보면서 분명해진 것이 있다 — CSS의 한계는 "상태 전이"까지다.
| 기법 | 가능한 것 | 불가능한 것 |
|---|---|---|
| CSS transition | A → B 상태 전환 보간 | 다단계 시퀀싱 |
| CSS @keyframes | 무한 반복 루프 | 동적 속도 변경, 감속 |
| CSS 커스텀 속성 | JS에서 값 전달 | 프레임 단위 계산 |
| :hover | 마우스 올림/뺌 감지 | 근접 거리 감지, 이동 추적 |
GlareHover와 StarBorder는 CSS만으로 완성된다. Magnet과 MagnetLines는 좌표 계산에 JavaScript가 필요하지만, 렌더링은 CSS transform이 담당한다. LogoLoop은 rAF 기반 커스텀 루프가 필수적이지만, 페이드나 레이아웃은 CSS가 처리한다.
결론: CSS가 할 수 있는 것은 CSS에 맡기고, JavaScript는 CSS가 못하는 것만 한다. 이 원칙은 다음 편에서 GSAP이 등장해도 변하지 않는다.
다음 포스트에서는
CSS만으로는 부족한 순간 — GSAP으로 커서를 추적하고 트레일을 만든다. BlobCursor의 유체 시뮬레이션과 TargetCursor의 정밀한 위치 추적을 분석한다.