
최근 포트폴리오 사이트를 제작하며 소개 페이지가 심심하다는 느낌을 받았다. 따라서, 랜덤한 위치에 원을 배치하고 부드러운 애니메이션 효과를 추가하는 기능을 구현했는데, 이 때 고민한 내용을 공유하고자 한다.
우선 최종 결과는 화면 로딩 이후, 5개의 원이 무작위로 생성되어 움직인다.
이 포스팅에서 코드를 분석하고, 왜 이렇게 설계되었는지 그 이유를 자세히 살펴보고자 한다.
"use client";
import { useEffect, useState } from "react";
import { ContactInfo, AboutHeader } from "@/components";
const getDistance = (x1: number, y1: number, x2: number, y2: number) => {
return Math.sqrt(Math.pow(x2 - x1, 2) + Math.pow(y2 - y1, 2));
};
const isOverlapping = (
circle1: { top: number; left: number },
circle2: { top: number; left: number },
minDistance: number
) => {
return (
getDistance(circle1.left, circle1.top, circle2.left, circle2.top < minDistance
);
};
export default function Home() {
const [circles, setCircles] = useState<
{ top: string; left: string; animation: string }[]
>([]);
useEffect(() => {
if (circles.length > 0) return;
const positions: { top: string; left: string; animation: string }[] = [];
const minDistance = 65;
const animationClasses = [
"animate-float-1",
"animate-float-2",
"animate-float-3",
"animate-float-4",
"animate-float-5",
];
const maxAttempts = 200;
let attempts = 0;
for (let i = 0; i < 5; i++) {
let validPosition = false;
let randomTop = 0,
randomLeft = 0;
while (!validPosition && attempts < maxAttempts) {
randomTop = Math.random() * 90;
randomLeft = Math.random() * 90;
attempts++;
const overlapping = positions.some((pos) =>
isOverlapping(
{ top: randomTop, left: randomLeft },
{ top: parseFloat(pos.top), left: parseFloat(pos.left) },
minDistance
)
);
if (!overlapping) {
validPosition = true;
}
}
if (attempts >= maxAttempts) {
break;
}
positions.push({
top: `${randomTop}%`,
left: `${randomLeft}%`,
animation:
animationClasses[Math.floor(Math.random() * animationClasses.length)],
});
}
setCircles(positions);
}, []);
return (
<main className="relative flex flex-col w-full min-h-screen items-center justify-center bg-white">
<div className="absolute inset-0 overflow-hidden">
{circles.map((pos, index) => (
<div
key={index}
className={`absolute w-[100px] h-[100px] xl:w-[200px] xl:h-[200px] bg-gray-100 rounded-full opacity-40 ${pos.animation}`}
style={{
top: pos.top,
left: pos.left,
}}
/>
))}
</div>
<AboutHeader />
<ContactInfo />
</main>
);
}
const getDistance = (x1: number, y1: number, x2: number, y2: number) => {
return Math.sqrt(Math.pow(x2 - x1, 2) + Math.pow(y2 - y1, 2));
};
위에서 정의한 getDistance 함수는 두 점 사이의 유클리드 거리를 구하는 역할을 한다.
하지만, 이미 배치된 원들과 새로운 원이 일정 거리 이상 떨어져 있어야 한다는 문제가 있다.
이를 해결하기 위해 isOverlapping 함수를 활용한다.
const isOverlapping = (
circle1: { top: number; left: number },
circle2: { top: number; left: number },
minDistance: number
) => {
return (
getDistance(circle1.left, circle1.top, circle2.left, circle2.top) <
minDistance
);
};
isOverlapping 함수는 새로운 원과 기존 원 사이의 거리를 측정하고,
거리가 minDistance보다 작으면 true 크면 false로 새 원을 추가할 수 있는 지 여부를 분기했다.
이제, 원을 추가할 때 기존 원들과 겹치는지 검사하는 과정이 필요하다.
해당 로직은 useEffect 내부의 while 문에서 동작한다.
처음에는 원을 단순히 랜덤한 위치에 배치하고 끝내는 방식이었지만,
정적인 화면이 되어버려 심심한 느낌이 들었다.
const animationClasses = [
"animate-float-1",
"animate-float-2",
"animate-float-3",
"animate-float-4",
"animate-float-5",
];
따라서, 위 코드처럼 5가지 Tailwind CSS 애니메이션 클래스를 미리 정의한 뒤, 각 원이 생성될 때 랜덤하게 애니메이션을 할당했다.
위와 같은 문제를 해결하니, useState를 사용하여 동적으로 요소를 렌더링할 때, Hydration 오류가 발생했다.
Hydration 오류는 간단하게 설명하면, 서버에서 렌더링된 HTML과 클라이언트에서 렌더링된 HTML이 일치하지 않을 때 발생한다. 예시로, useEffect 내부에서 setState를 호출하면, 초기 렌더링 시점에서는 빈 배열이지만, 이후 동적으로 값이 변경되므로 차이가 발생하는데 이 때, Hydration 오류가 난다.
export default function Page() {
const [count, setCount] = useState<number>(Math.random());
나는 위와 같은 형태로 코드를 작성했다.
당연하게 서버에서 렌더링된 값과 클라이언트에서 렌더링된 값이 다르므로 Hydration 오류가 발생함을 깨달았다.
이후 Hydration 오류를 방지하는 핵심 원칙을 찾아보았고, 아래 두 가지 해결책을 알아냈다.
서버에서 생성한 HTML과 클라이언트에서의 초기 렌더링 결과가 일치해야 한다.
→ useState의 초기값을 서버에서도 동일하게 보장할 수 있는 값(예: [], null, 0 등)으로 설정한다.
Hydration 후 상태를 변경해야 한다.
→ 서버에서 초기 HTML을 보낸 후, 클라이언트에서만 실행되는 useEffect를 활용하여 상태를 변경한다.
따라서 다음과 같이 코드를 변경하여 문제를 해결할 수 있었다.
const [circles, setCircles] = useState<
{ top: string; left: string; animation: string }[]
>([]);
다시 말해 useState([])는 서버에서 빈 배열([])로 렌더링되고, 클라이언트에서 useEffect를 통해 실제 데이터를 채워 넣는다. 이렇게 하면 초기 렌더링 시 서버와 클라이언트의 HTML이 일치하기 때문에 Hydration 오류를 방지할 수 있다.

위에처럼 여러 이슈를 해결하며, 애니메이션 효과를 구현할 수 있었다.
코드도 계속 다듬어서, 최종 결과물은 2월 말까지 만들어 공유하고자 한다.