귀멸의 칼날 호흡 테스트 - 나는 어떤 호흡의 계승자일까?
- 프론트엔드 : Next, TypeScript,
- 백엔드 : Node.js
- 팀 : 1인 개발
- 깃허브 : https://github.com/changchangwoo/kimetsu-breath-test
- 배포주소 : https://kimetsu-breath-test.site
- SEO를 고려한 페이지 설계
메타 태그 및 OpenGraph 활용- 성능 최적화를 통한 사용자 경험 향상
- S3+CloudFront, Lambda+DynamoDB 를 활용한 서비스 배포
GIT ACTION을 활용한 CI/CD- 개발 생산성 향상의 AI 도구 적극 활용
주요 성향 1 | 주요 성향 2 | 주요 성향 3 | 결과 호흡 |
---|---|---|---|
침착↑ | 신중↑ | 물 | |
열정↑ | 협력↑ | 결단 | 화염 |
방어↑ | 협력↑ | 침착 | 바위 |
공격↑ | 결단↑ | 바람 | |
.. | .. | .. | .. |
/* results/[type]/page.tsx */
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { type } = await params;
const typedType = type as Ttypes;
const breathData = breathMetadata[typedType];
const baseUrl = process.env.NEXT_PUBLIC_BASE_URL;
const currentUrl = `${baseUrl}/${type}`;
return {
title: `${breathData.title}`,
description: breathData.description,
openGraph: {
type: 'website',
locale: 'ko_KR',
url: currentUrl,
title: breathData.title,
description: breathData.description,
siteName: '귀멸의 칼날 호흡 성향 테스트',
images: [
{
url: `${baseUrl}/${breathData.ogImage}`,
width: 1200,
height: 630,
alt: breathData.title,
},
],
},
twitter: {
card: 'summary_large_image',
title: breathData.title,
description: breathData.description,
images: [`${baseUrl}/${breathData.ogImage}`],
},
robots: {
index: false,
follow: false,
},
};
}
generateMetadata
를 활용하여 총 13개의 결과 URL에 맞춰 메타데이터를 빌드시점에 생성하였다.🤔 정적 페이지에서 쿼리스트링을 통한 런타임 렌더 이슈
URL을 처리할 때, 결과 페이지에서 공유한 사용자의 그래프 데이터를 볼 수 있도록 쿼리스트링
id
값을 함께 공유하도록 했다.이 과정은 공유된 결과 화면 접속 ⇒ 쿼리스트링을 통한 API 호출 ⇒ 공유 사용자 그래프 식별 흐름으로 동작한다.
하지만 이때 사용한
useSearchParams
훅은 클라이언트에서만 값을 알 수 있어 정적 생성 시점에서는 처리할 수 없다.
즉, 정적 생성 단계에서는 쿼리스트링 값이 없기 때문에 런타임 렌더링이 필요하다는 제약이 생긴다.
이 문제를 해결하기 위해 Next.js에서 제공하는<Suspense>
컴포넌트로 해당 부분을 감싸주면,
쿼리스트링 값이 준비되기 전에는 로딩 상태를, 준비된 이후에는 실제 데이터를 보여줄 수 있어 안정적으로 결과 화면을 렌더링할 수 있다.
짐승의 호흡 결과 OG | 번개의 호흡 결과 OG | 공유 결과 OG |
---|---|---|
![]() |
![]() |
![]() |
framer-motion
의 AnimatePresence
처럼 컴포넌트 언마운트 시점에만 애니메이션을 적용하는 방식은 그대로 쓸 수 없었다.// contexts/PageTransitionContext.tsx
export function PageTransitionProvider({ children }: { children: ReactNode }) {
const [isTransitioning, setIsTransitioning] = useState(false);
const pathname = usePathname();
// 페이지가 이동되면 항상 초기값으로 false 가진다
useEffect(() => {
setIsTransitioning(false);
}, [pathname]);
// isTransitioning 전역변수를 true로 하여 페이지 전환이 동작한다
// 전환과 동시에 700ms 이후 콜백을 실행한다.
const triggerTransition = (callback?: () => void) => {
setIsTransitioning(true);
setTimeout(() => {
if (callback) callback();
}, 700);
};
return (
<PageTransitionContext.Provider
value={{ isTransitioning, triggerTransition }}
>
{children}
</PageTransitionContext.Provider>
);
}
export function usePageTransition() {
const context = useContext(PageTransitionContext);
if (context === undefined) {
throw new Error('컨텍스트 없음');
}
return context;
}
isTransitioning
은 현재 전환 상태를 나타내는 전역 상태이다triggerTransition
함수가 호출되면 전환이 시작되고, 700ms 이후 콜백을 실행한다.PageTransitionContext
컴포넌트를 매개로 하여 모든 페이지가 전역 상태를 공유할 수 있다.// animation/PageTransition.tsx
export default function PageTransition({
children,
}: {
children: React.ReactNode;
}) {
const { isTransitioning } = usePageTransition();
const itemVariants = {
initial: {
opacity: 1,
},
exit: {
opacity: 0,
transition: {
duration: 0.7,
ease: [0.7, 0.1, 0.4, 1] as const,
},
},
};
return (
<>
<motion.div
className="w-full h-full overscroll-y-none"
variants={itemVariants}
initial="initial"
animate={isTransitioning ? 'exit' : 'initial'}
>
{children}
</motion.div>
</>
);
}
// quiz/QuestionList.tsx
triggerTransition(() => {
router.push(href);
});
}
PageTransition
컴포넌트에서 전역 상태 isTransitioning
을 구독해 exit 애니메이션을 동작한다.
<ul className="flex flex-col gap-3">
{normalizedOptions.map((option, idx) => (
<RightToLeft delay={0.3 + 0.05 * idx} key={option.id}>
<SelectedItem
isSelected={selectedId === option.id}
hasAnySelection={selectedId !== null}
onSelectAnimationComplete={
selectedId === option.id
? handleSelectAnimationComplete
: undefined
}
>
<li
className={`flex items-center justify-center border rounded-2xl
w-[90%]
mx-auto
cursor-pointer transition-all hover:scale-105 font-nanumB text-center
text-white py-2 whitespace-pre-line text-descript
bg-lightGray/20 border-border/50
${
option.text === ''
? 'opacity-0 pointer-events-none h-[38.33px]'
: ''
}
`}
onClick={() =>
option.text !== '' && handleOptionClick(option.id)
}
>
{option.text}
</li>
</SelectedItem>
</RightToLeft>
))}
</ul>
애니메이션 순서를 함수로서 제어하여 다음과 같은 타임라인을 가지고 동작한다.
컴포넌트 생성시RightToLeft
애니메이션 실행
→ 문항 클릭
→SelectedItem
애니메이션 실행
→SelectedItem
제거
→RightToLeft
애니메이션 제거가 동작
const handleSelectOption = (
optionId: string,
activeDetermination: boolean
) => {
const newAnswers = [...answers];
newAnswers[step - 1] = {
id: optionId,
weights:
currentScript.options.find(option => option.id === optionId)?.weights ||
{},
};
if (activeDetermination) {
newAnswers[step - 1].weights['결단'] = 1;
}
setAnswers(newAnswers);
};
const handleNextButton = async () => {
if (step < scripts.length) {
const newStep = step + 1;
setStep(newStep);
pushStepToHistory(newStep);
} else if (step === scripts.length) {
const weights: { [key in Tweights]: number } = {
침착: 0,
협력: 0,
신중: 0,
공격: 0,
헌신: 0,
결단: 0,
창의: 0,
열정: 0,
};
for (const answer of answers) {
if (answer) {
for (const [key, value] of Object.entries(answer.weights)) {
weights[key as Tweights] += value || 0;
}
}
}
try {
const result = await fetchData(`/results`, 'POST', { weights });
const type = result.type as string;
const id = result.id as string;
const href = `/results/${type}?id=${id}`;
localStorage.setItem('id', JSON.stringify(id));
localStorage.setItem('type', JSON.stringify(type));
triggerTransition(() => {
router.push(href);
});
} catch (err) {
console.error('API 요청 실패:', err);
}
}
};
handleSelectOption
는 사용자의 선택 정보에 대한 가중치를 상태에 저장한다.handleNextButton
문항의 단계상태를 관리하며, 마지막 단계일 경우 fetchAPI를 통해 결과를 서버로 전송한다.
// results/[type]/page.tsx
export async function generateStaticParams() {
return Object.keys(breathingColors).map(type => ({
type: type as Ttypes,
}));
}
generateStaticParams
및getnerateMetadata
를 통해 결과 페이지에 대한 정적 경로 및 메타데이터를 빌드 시점에서 정의하였다.ResultHeader
컴포넌트에서 서버로부터 해당 아이디에대한 가중치를 불러온다. useEffect(() => {
if (!graphRef.current) return;
const observer = new IntersectionObserver(
entries => {
if (entries[0].isIntersecting) {
setShowGraph(true);
observer.disconnect();
}
},
{ threshold: 0.3 }
);
observer.observe(graphRef.current);
return () => observer.disconnect();
}, []);
IntersectionObserber
를 활용하여 사용자의 시야가 차트에 진입하는 순간, 차트 그래프를 보여주도록 하였다.🙂 최적화는 다음과 같은 방법을 진행하였다.
1. 이미지 webp 확장자 변경
2. 폰트 확장자 woff, woff-2 변경
3. 프리로딩을 통한 이미지 캐싱
✨ webp 변환은 webP 변환 사이트를 활용하였다.
@font-face {
font-family: 'shilla';
src: url('https://cdn.jsdelivr.net/gh/projectnoonnu/noonfonts_2206-02@1.0/Shilla_CultureB-Bold.woff2')
format('woff2');
font-weight: 700;
font-display: swap;
}
// ClientLoadingWrapper.tsx
export default function ClientLoadingWrapper({
children,
}: ClientLoadingWrapperProps) {
const [isLoading, setIsLoading] = useState(true);
const [fontsLoaded, setFontsLoaded] = useState(false);
const [imagesLoaded, setImagesLoaded] = useState(false);
useEffect(() => {
const checkFonts = async () => {
try {
await document.fonts.ready;
setFontsLoaded(true);
} catch (error) {
console.log('Font loading check failed:', error);
setFontsLoaded(true);
}
};
const checkImages = () => {
const criticalImages = [
'/imgs/bg.webp',
'/imgs/og/OG_01.webp',
'/imgs/og/OG_02.webp',
];
const imagePromises = criticalImages.map(src => {
return new Promise<void>(resolve => {
const img = new Image();
img.onload = () => resolve();
img.onerror = () => {
console.log(`Failed to load image: ${src}`);
resolve();
};
img.src = src;
});
});
Promise.all(imagePromises).then(() => {
setImagesLoaded(true);
});
};
if (typeof window !== 'undefined') {
checkFonts();
checkImages();
}
}, []);
useEffect(() => {
if (fontsLoaded && imagesLoaded) {
const timer = setTimeout(() => {
setIsLoading(false);
}, 1000);
return () => clearTimeout(timer);
}
}, [fontsLoaded, imagesLoaded]);
return (
<>
{isLoading && <LoadingScreen />}
<div
className={`transition-all duration-700 ease-out ${
isLoading ? 'opacity-0 translate-y-4' : 'opacity-100 translate-y-0'
}`}
>
{children}
</div>
</>
);
}
// QuestionList.tsx
useEffect(() => {
if (currentScript.id < scripts.length) {
const img = new Image();
img.src = `/imgs/q${currentScript.id + 1}.webp`;
}
}, [currentScript.id]);
Next Image 컴포넌트에 레이지로딩 속성이 있더라도, Image의 src를 선언한순간 메모리에 올라 간 후 캐시에 저장하기 때문에, 사용자가 이미지 요청 시, 바로 제공할 수 있다.