이번 리팩토링에서는 a 태그와 button 태그에 대해 다뤄보려고 합니다.
a 태그와 button 태그가 의도에 맞게 사용되도록 리팩토링을 진행해보려합니다.
이전 코드에서는 두 태그를 중첩하여 사용하거나 의도에 맞게 사용하지 않고 있었습니다.
바로 코드로 살펴보겠습니다.
nextjs에서는 URL 이동을 위해 a태그 대신 Link 태그를 사용합니다.
특정 URL로 이동하기 위해 다음과 같이 버튼 태그를 Link 컴포넌트를 중첩하였습니다.
이유는 Link 태그에 대해 별도로 커스텀하지 않기 위해서였습니다.
PrimaryButton은 이미 커스텀해둔 컴포넌트이기에 이를 그냥 중첩하여 사용하면 굳이 Link컴포넌트를 커스텀할 필요가 없으니까요.
<Link href={`/quiz/${detailUrl}/explanation`}>
// button 태그입니다.
<PrimaryButton text={"해설"} color={"primarySecondary"}/>
</Link>
하지만 위와 같이 사용하면 안됩니다.
Link 태그의 목적은 URL 이동이고 button은 특정 동작을 수행하기 위함인데 위 중첩 태그를 클릭하게 되면 2가지 동작이 동시에 일어나니깐요.
PrimaryButton
에서 onClick 이벤트가 없다하여도 부자연스럽죠.
DOM 트리가 크면 페이지 성능이 느려질 수 있다고 합니다.즉, 페이지 로드 속도와 사용자와의 상호작용이 느려질 수도 있어요.
DOM 트리가 크다는 것은 페이지의 태그 중첩도가 크다는 것을 말해요.
물론 DOM 트리 중첩도가 1000개쯤 되어야 성능 차이가 유의미하게 나타나긴 하지만 최대한 중첩을 방지하는 것이 좋겠죠.
퀴즈 풀어보기 버튼 DOM 구조를 살펴보면 3태그의 중첩으로 되어있죠.
과도한 DOM 중첩입니다.
<a>
<button>
<span>
퀴즈 풀어보기
</span>
</button>
</a>
Lighthouse에서 측정해보아도 위처럼 하나의 태그로 사용하면 될걸 3개를 중첩해놓으니 최대 DOM 깊이가 되어버렸어요.
따라서 위와 같은 중첩을 해결하고자 목적에 맞게 사용하도록 변경하였습니다.
Link
컴포넌트를 커스텀하여 다음과 같이 변경하였어요.
<PrimaryLink
color={"primarySecondary"}
href={`/quiz/${detailUrl}/explanation`}>
해설
</PrimaryLink>
이전(중첩 3단계)과 달리 1단계로 변경하였죠.
a,button 태그의 목적은 다음과 같습니다.
클릭했을 때, URL 이동 역할만 수행한다면 a 태그를,
여러가지 동작을 수행한다면 button 태그를 사용해야합니다.
시작하기 버튼을 누르게 되면 다음과 같은 동작들이 실행되야합니다.
그렇다면 위 시작하기는 a태그,button태그 어떤 것으로 구성해야할까요?
저는 처음에 결국엔 Link태그로 해놓았습니다. 결국에 URL 이동이니깐 Link 태그가 적절하다고 생각했죠.
"use client"
import {FIELD_OPTIONS, LANGUAGE_OPTIONS} from "@/app/(page)/quiz/constant";
import PrimaryButton from "@/app/_components/button/primaryButton";
import Select from "@/app/_components/select/select";
import useQuizHelperContext from "@/app/_context/useQuizContext";
import {Link} from "next-view-transitions";
import React, {useEffect, useState} from 'react';
// 퀴즈 옵션 설정 컴포넌트
function QuizOptionSettingPart() {
const [url,setUrl] = useState<string>("")
const quizHelper = useQuizHelperContext()
const [option,setOption] = React.useState<{field:string,lang:string}>({field:FIELD_OPTIONS[0].value,lang:LANGUAGE_OPTIONS[0].value});
function handleOptionChange(value:string,key:"field"|"lang"){
setOption({...option,[key]:value})
}
useEffect(() => {
async function getRandomUrl(){
const url = await quizHelper?.startQuiz()
if(url){
setUrl(url)
}
}
getRandomUrl()
}, [quizHelper]);
return (
<>
<div className={"flex flex-col gap-10 w-full"}>
<Select
label={"분야"}
options={FIELD_OPTIONS}
handleOptionChange={(value) => handleOptionChange(value as string,"field")}
/>
</div>
<Link href={`/quiz/${url}`}>
<PrimaryButton
// onClick={async () => await quizHelper?.startQuiz()}
text={"시작하기"}
color={"primary"}
className={"!w-full !h-[48px] !mt-14"}
/>
</Link>
</>
);
}
export default QuizOptionSettingPart;
하지만 button 형식이 맞다고 생각하여 변경하였습니다.
링크 이동뿐만 아니라 여러가지 역할을 하니깐요.
분야를 선택하는 옵션과 시작하기 버튼을 감싸 form으로 만들고 server action으로 API 통신하고 이후 로직은 클라이언트에서 처리하였죠.
"use client"
import useQuizOptionFormAction from "@/app/(page)/quiz/_hook/useQuizOptionFormAction";
import {FIELD_OPTIONS, LANGUAGE_OPTIONS} from "@/app/(page)/quiz/constant";
import PrimaryButton from "@/app/_components/button/primaryButton";
import Select from "@/app/_components/select/select";
import React from 'react';
// 퀴즈 옵션 설정 컴포넌트
function QuizOptionForm() {
const [option,setOption] = React.useState<{field:string,lang:string}>({field:FIELD_OPTIONS[0].value,lang:LANGUAGE_OPTIONS[0].value});
const {formAction} =useQuizOptionFormAction()
function handleOptionChange(value:string,key:"field"|"lang"){
setOption({...option,[key]:value})
}
return (
<form action={formAction}>
<div className={"flex flex-col gap-10 w-full"}>
{/*분야*/}
<>
<Select
label={"분야"}
options={FIELD_OPTIONS}
handleOptionChange={(value) => handleOptionChange(value as string,"field")}
/>
<input
type={"hidden"}
name={"field"}
value={option.field}
/>
</>
</div>
<PrimaryButton
type={"submit"}
color={"primary"}
className={"!w-full !h-[48px] !mt-14"}
text={"퀴즈 시작하기"}
/>
</form>
);
}
export default QuizOptionForm;
...
import {getQuizDetailUrlListAction} from "@/app/(page)/quiz/action";
import useQuizHelperContext from "@/app/_context/useQuizContext";
import {ArrayUtils} from "@/app/_utils/class/ArrayUtils";
import {useRouter} from "next/navigation";
import {useActionState, useEffect} from "react";
// 퀴즈 옵션 폼 관련 액션 커스텀 훅
function useQuizOptionFormAction() {
const quizHelper= useQuizHelperContext()
const router = useRouter()
const [state,formAction]=useActionState(getQuizDetailUrlListAction,{urlList:[],isAction:false})
useEffect(() => {
if(state.isAction){
quizHelper?.saveQuizUrlList(state.urlList)
const randomOne = ArrayUtils.pickRandomOne<string>(state.urlList)
router.push(`/quiz/${randomOne}`)
}
}, [state.isAction]);
return {formAction}
}
export default useQuizOptionFormAction;
다음 문제
를 클릭하면 이전에 풀었던 문제를 제외하고 그 중 랜덤으로 하나를 꼽아 이동해야합니다.
이전에는 Link가 아닌 button으로 구성하였어요.
코드는 다음과 같이 구성하였습니다.
<PrimaryButton
text={"다음 문제"}
color={"primary"}
onClick={async ()=>{
await quizHelper?.moveToNextQuiz(detailUrl as string)
}}
/>
...
// 다음 문제 이동
async moveToNextQuiz(currentQuiz: string) {
this.logicHandler.addSolvedQuiz(currentQuiz);
if (this.logicHandler.isAllQuizSolved()) {
this.navigator.moveToCompletedPage();
} else {
const unsolvedQuiz = this.logicHandler.getUnsolvedQuiz();
const randomQuiz = ArrayUtils.pickRandomOne<string>(unsolvedQuiz);
this.navigator.moveToQuizPage(randomQuiz);
}
}
이유는 아래와 여러가지 동작을 해야했기 때문이죠.
푼 문제 로컬스토리지
에 저장하지만 다시 생각해보니
URL 이동을 제외한 동작들이 굳이 위처럼 버튼을 눌렀을 때 한번에 발생해야하나?
라는 생각이 들었어요.
해당 퀴즈 페이지에 진입시
푼 문제 로컬스토리지
에 저장위 동작을 꼭 버튼이 눌렸을 때, 할 필요는 없다는 생각이 들었죠.
그래서 위 로직들은 따로 분리하고 다음 문제
버튼을 눌렀을 때에는 다른 동작없이 링크만 이동할 수 있도록 Link태그로 변경하였습니다.
다음과 같이요.
import useRandomUrl from "@/app/(page)/quiz/[detailUrl]/_helper/useRandomUrl";
import PrimaryLink from "@/app/_components/link/primaryLink";
import {useParams} from "next/navigation";
import React from 'react';
// 채점 후 버튼(해설, 다음문제)
function AfterCheckButtons() {
const {detailUrl} = useParams()
const randomUrl =useRandomUrl()
return (
<div className={"flex justify-center items-center gap-2 w-full"}>
<PrimaryLink
color={"primarySecondary"}
href={`/quiz/${detailUrl}/explanation`}>
해설
</PrimaryLink>
<PrimaryLink href={`/quiz/${randomUrl}`}>
다음 문제
</PrimaryLink>
</div>
);
}
export default AfterCheckButtons;
...
import useQuizHelperContext from "@/app/_context/useQuizContext";
import {useEffect, useState} from 'react';
// 안푼 문제 중 랜덤 URL 생성
function useRandomUrl() {
const [randomUrl,setRandomUrl] = useState<string>("")
const quizHelper = useQuizHelperContext()
useEffect(() => {
const url = quizHelper?.getRandomOneFromUnsolvedQuiz()
if(url){
setRandomUrl(url)
}
}, [quizHelper]);
return randomUrl
}
export default useRandomUrl;
...
// 안 푼 문제 중 랜덤으로 하나 반환
getRandomOneFromUnsolvedQuiz() {
const unsolvedQuiz = this.logicHandler.getUnsolvedQuiz();
return ArrayUtils.pickRandomOne<string>(unsolvedQuiz);
}
다음으로 리팩토링을 진행할 부분입니다.
퀴즈 완료 페이지의 버튼인데요.
다른 퀴즈 풀러가기
버튼의 역할은 localStorge를 비워주고 quiz 시작하기 페이지로 이동시켜주는 역할을 합니다.
이전에는 Button으로 구성하였습니다.
링크 이동뿐만 아니라 , localStorage를 비워주는 역할까지 하니깐요.
"use client"
import PrimaryButton from "@/app/_components/button/primaryButton";
import PrimaryLink from "@/app/_components/link/primaryLink";
import useQuizHelperContext from "@/app/_context/useQuizContext";
import React from 'react';
// 퀴즈 완료 링크
function QuizCompletedLink() {
const quizHelper = useQuizHelperContext()
return (
<PrimaryLink href={"/quiz"}>
<PrimaryButton
text={"다른 퀴즈 풀러가기"}
color={"primary"}
onClick={() => quizHelper?.clearQuizStorage() }
/>
</PrimaryLink>
);
}
export default QuizCompletedLink;
하지만 스토리지를 비워주는 동작을 꼭 버튼을 누를 때 해야할까요?
위 페이지 진입시에 스토리지를 비워줘도 됩니다.
즉, 버튼에서 일괄적으로 두가지 동작을 해결하지 않아도 되죠.
다음과 같이요.
import QuizCompletedDescription from "@/app/(page)/quiz/completed/_components/quizCompletedDescription";
import QuizCompletedLink from "@/app/(page)/quiz/completed/_components/quizCompletedLink";
import QuizCompletedManager from "@/app/(page)/quiz/completed/_components/quizCompletedManager";
import QuizCompletedTitle from "@/app/(page)/quiz/completed/_components/quizCompletedTitle";
import React from 'react';
export const dynamic = 'force-static'
// 퀴즈 완료 페이지
function Page() {
return (
<QuizCompletedManager>
<QuizCompletedTitle
title={"퀴즈 완료"}
/>
<QuizCompletedDescription
description={"축하드립니다. 모든 퀴즈를 다 푸셨습니다."}
/>
<QuizCompletedLink/>
</QuizCompletedManager>
);
}
export default Page;
...
"use client"
import useQuizHelperContext from "@/app/_context/useQuizContext";
import React, {useEffect} from 'react';
// 퀴즈 완료 로직 관리
// 페이지 진입시 퀴즈 스토리지를 비움
function QuizCompletedManager({
children
}:{
children: React.ReactNode
}) {
const quizHelper = useQuizHelperContext()
useEffect(() => {
quizHelper?.clearQuizStorage()
}, [quizHelper]);
return (
<>
{children}
</>
);
}
export default QuizCompletedManager;
...
import PrimaryLink from "@/app/_components/link/primaryLink";
import React from 'react';
// 퀴즈 완료 링크
function QuizCompletedLink() {
return (
<PrimaryLink href={"/quiz"}>
다른 퀴즈 풀러가기
</PrimaryLink>
);
}
export default QuizCompletedLink;
스토리지를 비우는 역할은 페이지를 감싸는 컴포넌트에서 해결해주고
기존 버튼을 Link로 변경하여 링크 이동의 역할만 부여해줍니다.
위와 같이 해줌으로써 각 컴포넌트가 단일 책임의 역할만 가지고 있을 뿐만 아니라
QuizCompletedLink
를 서버 컴포넌트로 변경함으로써 자바스크립트 번들도 줄일 수 있었죠.
여러가지 동작을 일괄적으로 실행할 때, 버튼을 사용한다고 하였습니다.
하지만 이런 경우에 앞서,
여러가지 동작이 꼭 해당 버튼을 눌렀을 때, 모두 실행되어야하나?
버튼 동작 이외 부분에서 실행되는 것이 적절하지 않은가?
위 부분을 생각해보도록 해주는 과정이었던 것 같습니다.