Client Component의 props로 Server Component를 어떻게 전달하는걸까요?

김철준·2024년 12월 1일
0

next.js

목록 보기
14/18

의문의 계기

프로젝트를 진행하다 퀴즈라는 특정 도메인 전체에서 클라이언트 비즈니스 로직을 사용하고자하는 경우가 생겼습니다.

여기서 말하는 클라이언트 비즈니스 로직이란 클라이언트 컴포넌트에서 사용할 비즈니스 로직을 말합니다.

프로젝트에서 퀴즈 풀기 관련 도메인이 있는데요. 저는 응집도 있게 관련 로직을 다음과 같이 하나의 클래스로 관리하고 있어요.

import {QuizLogicHandler} from "@/app/(page)/quiz/[detailUrl]/_helper/QuizLogicHandler";
import {QuizNavigator} from "@/app/(page)/quiz/[detailUrl]/_helper/QuizNavigator";
import {QuizStorageManager} from "@/app/(page)/quiz/[detailUrl]/_helper/QuizStoreManager";
import {ArrayUtils} from "@/app/_utils/function/ArrayUtils";
import {QuizApiHandler} from "@/app/services/quiz/QuizApiHandler";


export class QuizHelper {

    constructor(
        private storageManager: QuizStorageManager,
        private navigator: QuizNavigator,
        private logicHandler: QuizLogicHandler
    ) {}

    // 퀴즈 시작
    async startQuiz(apiHandler: QuizApiHandler) {
        await this.logicHandler.fetchAndSaveQuizUrlList(apiHandler);
        const unsolvedQuiz = this.logicHandler.getUnsolvedQuiz();
        if (unsolvedQuiz.length > 0) {
            const randomQuiz = ArrayUtils.pickRandomOne<string>(unsolvedQuiz);
            this.navigator.moveToQuizPage(randomQuiz);
        } else {
            this.navigator.moveToStartPage();
        }
    }

    // 다음 문제 이동
    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);
        }
    }

    // 현재 경로가 solvedQuizList 스토리지에 있는 url에 있는 경우, 다른 문제로 이동
    redirectIfQuizSolved(currentQuizUrl:string){
        if(this.storageManager.getSolvedQuiz().includes(currentQuizUrl)){
            const unsolvedOne=  ArrayUtils.pickRandomOne<string>(this.logicHandler.getUnsolvedQuiz())
            this.navigator.moveToQuizPage(unsolvedOne);
        }


    }

    // 모든 퀴즈를 푼 경우, 퀴즈 완료 페이지로 이동
    redirectToCompletionPageIfAllSolved(){
        if(this.logicHandler.isAllQuizSolved()) {
            this.navigator.moveToCompletedPage();
        }
    }

    clearQuizStorage() {
        this.storageManager.clearStorage();
    }
}

하지만 위 클래스는 클라이언트에 종속되어있어 클라이언트 컴포넌트에서만 사용할 수 있어요.
이유는 localStorage와 useRouter 기능을 사용해야하기 때문이죠.

다음과 같이 QuizHelper 인스턴스를 생성하기위해서는 종속되는 QuizStorageManagerQuizNavigator 클래스를 사용하는데, 이 인스턴스들을 호출하기 위해서는 브라우저 API인localStorage나 nextjs client 전용useRouter를 사용해야하죠.

        const storageManager = new QuizStorageManager(new StorageAdapter(localStorage));
        const navigator = new QuizNavigator((path) => router.push(path));
        const logicHandler = new QuizLogicHandler(storageManager);
        const quizHelper =   new QuizHelper(storageManager, navigator, logicHandler)

        ...
        
            useEffect(() => {

        // 현재 경로가 solvedQuizList 스토리지에 있는 url에 있는 경우, 다른 문제로 이동
        quizHelper?.redirectIfQuizSolved(detailUrl as string)

        // 모든 퀴즈를 푼 경우, 퀴즈 완료 페이지로 이동
        quizHelper?.redirectToCompletionPageIfAllSolved()



    }, [detailUrl])
        
        

quizHelper 인스턴스를 사용하기 위해서는 위처럼 많은 인스턴스를 선언해줘야하는데요.

quizHelper 인스턴스를 사용할 때마다 ,위처럼 보일러 플레이트 코드를 선언하기보다는 한 곳에서 선언하여 관리하고 싶었어요.

그리고 quizHelper사용처는 위처럼 번잡히 선언할 필요없이 quizHelper만 가져다 쓰게하고 싶었죠.

context API 사용해보기

그래서 저는 quiz 관련 최상단에서 Context API를 사용하고 싶었어요.

layout.tsx에서 Context로 래핑해주는 것이죠. 그런데 Context는 client component에서만 사용할 수 있는데 어떻게 클라이언트 컴포넌트로 사용하지?라는 의문이 들었어요.

layout.tsx 하위에는 다음과 같이 서버 컴포넌트도 있을테니 말이죠.

// 하위 Page 서버 컴포넌트
import {Metadata} from "next";
import React from 'react';

import QuizOptionSettingPart from "@/app/(page)/quiz/_components/quizOptionSettingPart";

export const metadata: Metadata = {
    title: '퀴즈 시작하기',
    description: '퀴즈를 통해 개발 지식을 테스트해 보세요.' +
        '프론트 엔드, 백엔드, 데이터베이스, 네트워크, 알고리즘 등 다양한 주제의 퀴즈를 풀어보세요.',

}


const Page = async () => {

    return (
            <div className={"w-full"}>
                <div className={"flex flex-col gap-2 mt-24"}>
                    <h1 className={"text-title1 text-center"}>개발 퀴즈</h1>
                    <p className={"mb-10 text-title2Normal text-center"}>퀴즈를 통해 개발 지식을 테스트해 보세요!</p>
                </div>
                <QuizOptionSettingPart/>
            </div>

    );
};

export default Page;

그래서 문서를 찾아봤어요.

클라이언트 props로 서버 컴포넌트 전달하기

하지만 문서의 컴포넌트 사용법을 살펴보면, 다음과 같은 가이드 라인이 있어요.

클라이언트 props로 서버 컴포넌트 전달하기

다음과 같이 클라이언트의 children props children에 Server Component를 전달하는 것이죠.

'use client'
 
import { useState } from 'react'
 
export default function ClientComponent({
  children,
}: {
  children: React.ReactNode
}) {
  const [count, setCount] = useState(0)
 
  return (
    <>
      <button onClick={() => setCount(count + 1)}>{count}</button>
      {children}
    </>
  )
}
// This pattern works:
// You can pass a Server Component as a child or prop of a
// Client Component.
import ClientComponent from './client-component'
import ServerComponent from './server-component'
 
// Pages in Next.js are Server Components by default
export default function Page() {
  return (
    <ClientComponent>
      <ServerComponent />
    </ClientComponent>
  )
}

클라이언트의 props로 서버 컴포넌트를 전달한다 이 말이 저는 잘 이해가 되지 않았어요.

제 머릿속에서는

클라이언트의 props로 서버 컴포넌트를 전달되면 계층 구조로 보았을 때,서버 컴포넌트가 클라이언트 컴포넌트에 아래에 있는 것 아닌가?

이러한 의문이 들었죠.

문서를 계속 살펴보니 다음과 같은 설명이 있더군요.

<ClientComponent>는 children이 최종적으로 Server Component의 결과로 채워질 것이라는 사실을 알지 못합니다. <ClientComponent>의 유일한 책임은 children이 최종적으로 배치될 위치를 결정하는 것입니다.

즉, 위처럼 감싸는 클라이언트 컴포넌트의 역할은 서버컴포넌트가 어디에 위치할 것인지에 대한 정보만 가지고 있는거죠.

위처럼 이해를 하고 나니 이제 위 구조에 대한 의구심이 해결되었어요. 결국엔 클라이언트 컴포넌트가 서버 컴포넌트의 상위 트리에 있다고 하여도 하위에 있는 서버 컴포넌트도 클라이언트 컴포넌트의 영향을 받는 것이 아니구나 라는 것을 알게 되었죠.

클라이언트 컴포넌트의 역할은 다만 배치될 위치만 알려줄 뿐!

프로젝트 적용

그래서 저는 결국에 다음과 같이 적용했습니다.

"use client"

import {QuizHelper} from "@/app/(page)/quiz/[detailUrl]/_helper/QuizHelper";
import {QuizLogicHandler} from "@/app/(page)/quiz/[detailUrl]/_helper/QuizLogicHandler";
import {QuizNavigator} from "@/app/(page)/quiz/[detailUrl]/_helper/QuizNavigator";
import {QuizStorageManager} from "@/app/(page)/quiz/[detailUrl]/_helper/QuizStoreManager";
import QuizHelperContext from "@/app/_context/quizHelperContext";
import {StorageAdapter} from "@/app/_utils/StorageService";
import {useRouter} from "next/navigation";
import React, {useEffect} from 'react';

// 퀴즈 헬퍼 프로바이더(클라이언트용)
function QuizHelperProvider({children}:{children:React.ReactNode}) {

    const router = useRouter();

    const [quizHelper, setQuizHelper] = React.useState<QuizHelper | null>(null);

    useEffect(() => {
        const storageManager = new QuizStorageManager(new StorageAdapter(localStorage));
        const navigator = new QuizNavigator((path) => router.push(path));
        const logicHandler = new QuizLogicHandler(storageManager);
        const quizHelper =   new QuizHelper(storageManager, navigator, logicHandler)
        setQuizHelper(quizHelper);
    }, []);


    return (
        <QuizHelperContext.Provider value={quizHelper}>{ children}</QuizHelperContext.Provider>
    );
}

export default QuizHelperProvider;

contextuseRouter를 사용할 클라이언트 컴포넌트, Provider를 하나 만들어줬어요.

그리고 quiz 도메인 최상단 layout에서 감싸줬죠.

import InnerContainer from "@/app/_layout/innerContainer";
import QuizHelperProvider from "@/app/_provider/quizHelperProvider";
import React from 'react';

const QuizLayout = ({
    children
                    }:{
    children:React.ReactNode
}) => {
    return (
        <QuizHelperProvider>
            <InnerContainer className={"!justify-start"}>
                {children}
            </InnerContainer>
        </QuizHelperProvider>
    );
};

export default QuizLayout;

위처럼 사용하면 이제 quizHelper 인스턴스를 사용하고자하는 컴포넌트에서 처음처럼 인스턴스를 전부 다 선언할 필요없어지죠.


    const quizHelper = useQuizHelperContext(); // quizHelper Context입니다!

...
    useEffect(() => {

        // 현재 경로가 solvedQuizList 스토리지에 있는 url에 있는 경우, 다른 문제로 이동
        quizHelper?.redirectIfQuizSolved(detailUrl as string)




    }, [detailUrl])
profile
FE DEVELOPER

0개의 댓글