본문 바로가기
SPA/Next

[Next] Next13의 새로운 기능 알아보기 (1) - Routing과 RSC

by J4J 2023. 5. 29.
300x250
반응형

안녕하세요. J4J입니다.

 

이번 포스팅은 Next13의 새로운 기능 알아보기 첫 번째인 Routing과 RSC에 대해 적어보는 시간을 가져보려고 합니다.

 

 

 

Routing 기본 방법

 

next13 이전에는 routing 처리를 위해 pages폴더의 하위 경로에 원하는 경로를 구성하는 방법을 사용했습니다.

 

next13에서도 해당 방식을 지속적으로 지원을 하고 있기에 동일하게 사용해도 문제없지만 pages대신 app을 이용한 새로운 routing 처리 방식이 등장했습니다.

 

pages를 이용하여 routing 경로를 구성했던 것과 유사하게 app 하위 경로에 입력한 폴더 명을 기준으로 routing 경로가 구성되기 때문에 pages를 이용하여 환경을 구성했던 것과 비슷한 개발 경험을 제공해주고 있습니다.

 

 

반응형

 

 

Page와 Layout

 

next13부터는 app 하위 경로에 routing을 구성할 수 있는데 환경을 세팅하기 위해서는 page와 layout의 개념을 필수적으로 알고 계셔야 합니다.

 

단어만 보고 뜻을 바로 이해하실 분들도 있겠지만 page와 layout은 다음과 같이 정의할 수 있습니다.

 

  • page → 경로에 접근했을 때 사용자 화면에 보이는 페이지
  • layout → 페이지를 둘러싸고 있는 레이아웃 컴포넌트

 

 

 

그리고 page와 layout은 next13 이전에 사용하던 방식으로 생각해 보면 page는 pages 하위 경로에 있는 파일들, layout은 layouts 하위 경로에 있는 파일들로도 말해볼 수 있습니다.

 

다만 next13부터 app을 사용하게 될 경우 pages와 layouts 하위 경로들에 작성되던 파일들이 app 하위 경로로 모두 옮겨졌다고 생각하면 이해하는데 도움이 될 것으로 보입니다.

 

 

 

이제는 간단한 소스를 작성하여 조금 더 자세히 얘기해 보겠습니다.

 

next13 이전에 개발되어 있는 폴더 구조를 확인해 보면 다음과 같이 pages 경로 아래에 _app.tsx, index.tsx 등의 파일들이 생성되어 있을 겁니다.

 

next13 이전 폴더 구조

 

// _app.tsx
import React from 'react';
import { AppProps } from 'next/app';

const App = ({ Component, pageProps }: AppProps) => {
    return <Component {...pageProps} />;
};

export default App;


// index.tsx
const Index = () => {
    return (
        <main>
            <h2>Next13 Before Index Page</h2>
        </main>
    );
};

export default Index;

 

 

 

이 두 파일들은 app을 사용하게 되면 다음과 같이 대체되어 사용됩니다.

 

next13 이후 폴더 구조

 

// layout.tsx
import { ReactNode } from 'react';

interface Props {
    children: ReactNode;
}

const RootLayout = (props: Props) => {
    return (
        <html lang="ko">
            <body>{props.children}</body>
        </html>
    );
};

export default RootLayout;


// page.tsx
const RootPage = () => {
    return (
        <main>
            <h2>Next13 Index Page</h2>
        </main>
    );
};

export default RootPage;

 

 

 

 

이번엔 새로운 routing 경로를 만들어 보겠습니다.

 

만약 next13 이전에 product 페이지를 구성한다고 가정하면 다음과 같이 layout과 page를 작성해 볼 수 있습니다.

 

next13 이전 폴더 구조

 

// productLayout.tsx
import { ReactNode } from 'react';

interface Props {
    children: ReactNode;
}

const ProductLayout = (props: Props) => {
    return (
        <div>
            <h2>Product Layout</h2>
            <div>{props.children}</div>
        </div>
    );
};

export default ProductLayout;


// product.tsx
import ProductLayout from '../layouts/productLayout';

const Product = () => {
    return (
        <ProductLayout>
            <div>
                <p>Next13 Before Product Page</p>
            </div>
        </ProductLayout>
    );
};

export default Product;

 

 

 

위와 같이 작성되던 파일들은 next13 이후에는 다음과 같이 변경됩니다.

 

next13 이후 폴더 구조

 

 

// product/layout.tsx
import { ReactNode } from 'react';

interface Props {
    children: ReactNode;
}

const ProductLayout = (props: Props) => {
    return (
        <div>
            <h2>Product Layout</h2>
            <div>{props.children}</div>
        </div>
    );
};

export default ProductLayout;


// product/page.tsx
const ProductPage = () => {
    return (
        <div>
            <p>Next13 Product Page</p>
        </div>
    );
};

export default ProductPage;

 

 

 

 

Page와 Component

 

특정 목적을 위해 한 페이지에 작성될 소스들을 여러 컴포넌트들로 분기시킨다고 가정해 보겠습니다.

 

next13 이전에는 다른 곳에 재사용되는 컴포넌트들이 아님에도 불구하고 pages 하위 경로에 파일을 구성할 수 없기 때문에 다음과 같이 다른 폴더를 이용하여 관리를 하셨을 겁니다.

 

next13 이전 폴더 구조

 

// footer.tsx
const ProductFooter = () => {
    return (
        <div>
            <ul>
                <li>
                    <p>Product Page Footer - 1</p>
                </li>

                <li>
                    <p>Product Page Footer - 2</p>
                </li>

                <li>
                    <p>Product Page Footer - 3</p>
                </li>
            </ul>
        </div>
    );
};

export default ProductFooter;


// product.tsx
import ProductFooter from '../components/product/footer';
import ProductLayout from '../layouts/productLayout';

const Product = () => {
    return (
        <ProductLayout>
            <div>
                <p>Next13 Before Product Page</p>

                <ProductFooter />
            </div>
        </ProductLayout>
    );
};

export default Product;

 

 

 

next13 이후로 app 경로를 사용하게 되면 위와 같은 구분을 해줄 필요 없이 page와 동일한 depth에 원하는 컴포넌트 파일들을 구성해 줄 수 있습니다.

 

next13 이후 폴더 구조

 

// product/footer.tsx
const ProductFooter = () => {
    return (
        <div>
            <ul>
                <li>
                    <p>Product Page Footer - 1</p>
                </li>

                <li>
                    <p>Product Page Footer - 2</p>
                </li>

                <li>
                    <p>Product Page Footer - 3</p>
                </li>
            </ul>
        </div>
    );
};

export default ProductFooter;


// product/page.tsx
import ProductFooter from './footer';

const ProductPage = () => {
    return (
        <div>
            <p>Next13 Product Page</p>

            <ProductFooter />
        </div>
    );
};

export default ProductPage;

 

 

 

app 하위 경로에서는 pages 경로에서 처럼 모든 파일들을 routing 목적으로 사용하지 않고 page, layout 등과 같이 정해진 파일명들에 대해서만 routing 목적으로 활용됩니다.

 

그러므로 이전처럼 불필요하게 새로운 폴더 구조를 구성하는 작업을 할 필요가 없기 때문에 사용자 입장에서 소스 관리를 더 효율적으로 할 수 있을 것으로 보입니다.

 

 

 

 

Nested Layout과 Route Group

 

app 구조에 대해 지식을 얻기 시작하면서 개인적으로 가장 먼저 생각났던 부분은 "동일한 부모 경로를 가질 때 서로 다른 레이아웃 적용을 어떻게 할까?" 였습니다.

 

"next13 이전의 방식처럼 layouts을 따로 또 구성을 해야 되는 건가?"라는 생각과 함께 공식 문서를 훑어본 결과 올바른 해답을 찾을 수 있었습니다.

 

 

 

해답을 얘기하기에 앞서 app을 사용하게 되면 nested layout (중첩 레이아웃) 구조가 만들어집니다.

 

여기서 말하는 nested layout은 layout 아래에 또 다른 layout이 구성이 되며 여러 layout들이 중첩되어 사용되는 것을 의미합니다.

 

nested layout

 

 

 

next13 이전에는 layouts 폴더에 layout을 위한 파일들을 각자 보관하여 필요한 곳에 사용만 하면 되었기 때문에 체감하지 못했으나 next13 이후부터는 경로마다 존재하는 layout들이 계속 중첩되어 화면에 보이기 때문에 위에서 언급한 문제점이 도출될 수 있습니다.

 

"동일한 부모 경로를 가질 때 서로 다른 레이아웃 적용을 어떻게 할까?"

 

 

 

 

next에서는 해당 문제점에 대한 해답으로 route group을 제시하는 것으로 보였습니다.

 

route group은 페이지 경로에 영향을 주는 것 없이 route 별로 그룹화하는 것을 도와줍니다.

 

간단한 소스 예시로 음식 상품과 의류 상품에 대해 서로 다른 레이아웃을 보여준다고 가정했을 때 next13 이전에는 다음과 같이 구성해 볼 수 있습니다.

 

next13 이전 폴더 구조

 

// layouts/product/clothesLayout.tsx
import { ReactNode } from 'react';

interface Props {
    children: ReactNode;
}

const ProductClothesLayout = (props: Props) => {
    return (
        <div>
            <h2 style={{ color: 'skyblue' }}>Product Clothes Layout</h2>
            <div>{props.children}</div>
        </div>
    );
};

export default ProductClothesLayout;


// layouts/product/foodLayout.tsx
import { ReactNode } from 'react';

interface Props {
    children: ReactNode;
}

const ProductFoodLayout = (props: Props) => {
    return (
        <div>
            <h2 style={{ color: 'olive' }}>Product Food Layout</h2>
            <div>{props.children}</div>
        </div>
    );
};

export default ProductFoodLayout;


// pages/product/shoes.tsx
import ProductClothesLayout from '../../layouts/product/clothesLayout';
import ProductLayout from '../../layouts/product/layout';

const ProductShoes = () => {
    return (
        <ProductLayout>
            <ProductClothesLayout>
                <div>
                    <p>Next13 Before Product Shoes Page</p>
                </div>
            </ProductClothesLayout>
        </ProductLayout>
    );
};

export default ProductShoes;


// pages/product/cake.tsx
import ProductFoodLayout from '../../layouts/product/foodLayout';
import ProductLayout from '../../layouts/product/layout';

const ProductCake = () => {
    return (
        <ProductLayout>
            <ProductFoodLayout>
                <div>
                    <p>Next13 Before Product Cake Page</p>
                </div>
            </ProductFoodLayout>
        </ProductLayout>
    );
};

export default ProductCake;


// pages/product/steak.tsx
import ProductFoodLayout from '../../layouts/product/foodLayout';
import ProductLayout from '../../layouts/product/layout';

const ProductSteak = () => {
    return (
        <ProductLayout>
            <ProductFoodLayout>
                <div>
                    <p>Next13 Before Product Steak Page</p>
                </div>
            </ProductFoodLayout>
        </ProductLayout>
    );
};

export default ProductSteak;


// pages/product/index.tsx (page/product.tsx에서 변경)
import ProductFooter from '../../components/product/footer';
import ProductLayout from '../../layouts/product/layout';

const Product = () => {
    return (
        <ProductLayout>
            <div>
                <p>Next13 Before Product Page</p>

                <ProductFooter />
            </div>
        </ProductLayout>
    );
};

export default Product;

 

 

 

next13에서는 route group을 이용하여 구성해 보겠습니다.

 

route group은 폴더명을 "( )"의 형태로 작성하여 활용할 수 있으며 해당 구조는 페이지 경로에 포함되지 않고 생략됩니다.

 

next13 이후 폴더 구조

 

// product/(clothes)/layout.tsx
import { ReactNode } from 'react';

interface Props {
    children: ReactNode;
}

const ProductClothesLayout = (props: Props) => {
    return (
        <div>
            <h2 style={{ color: 'skyblue' }}>Product Clothes Layout</h2>
            <div>{props.children}</div>
        </div>
    );
};

export default ProductClothesLayout;


// product/(clothes)/shoes/page.tsx
const ProductShoes = () => {
    return (
        <div>
            <p>Next13 Product Shoes Page</p>
        </div>
    );
};

export default ProductShoes;


// product/(food)/layout.tsx
import { ReactNode } from 'react';

interface Props {
    children: ReactNode;
}

const ProductFoodLayout = (props: Props) => {
    return (
        <div>
            <h2 style={{ color: 'olive' }}>Product Food Layout</h2>
            <div>{props.children}</div>
        </div>
    );
};

export default ProductFoodLayout;


// product/(food)/cake/page.tsx
const ProductCake = () => {
    return (
        <div>
            <p>Next13 Product Cake Page</p>
        </div>
    );
};

export default ProductCake;


// product/(food)/steak/page.tsx
const ProductSteak = () => {
    return (
        <div>
            <p>Next13 Product Steak Page</p>
        </div>
    );
};

export default ProductSteak;

 

 

 

 

Loading UI

 

next13 이후부터는 컴포넌트가 로딩되는 동안 화면에 보일 페이지도 page, layout 등과 같은 방식을 활용하여 제공해 줄 수 있습니다.

 

먼저 next13 이전에 로딩 UI를 만드는 케이스를 간단하게 다음과 같이 구현해 볼 수 있습니다.

 

사용될 api

 

next13 이전 폴더 구조

 

// components/product/loading.tsx
const ProductLoading = () => {
    return (
        <div>
            <h1>상품 페이지가 로딩중입니다.</h1>
        </div>
    );
};

export default ProductLoading;


// pages/product/index.tsx
import { useEffect, useState } from 'react';
import ProductFooter from '../../components/product/footer';
import ProductLoading from '../../components/product/loading';
import ProductLayout from '../../layouts/product/layout';

const Product = () => {
    const [data, setData] = useState<number[]>([]);
    const [isLoading, setIsLoading] = useState<boolean>(false);

    const getData = async () => {
        setIsLoading(true);

        const res = await fetch('http://localhost:8080/getData', {
            headers: {
                Accept: 'application/json',
            },
        });

        if (res) {
            setData(await res.json());
            setIsLoading(false);
        }
    };

    useEffect(() => {
        getData();
    }, []);

    return (
        <ProductLayout>
            {isLoading ? (
                <ProductLoading />
            ) : (
                <div>
                    <p>Next13 Before Product Page</p>
                    <ProductFooter />

                    <div>
                        {data.map((value) => {
                            return <span key={value}>{value}</span>;
                        })}
                    </div>
                </div>
            )}
        </ProductLayout>
    );
};

export default Product;

 

 

 

 

이와 동일한 결과를 만드는 것을 next13에서는 loading.tsx 파일을 만들어 대체해 볼 수 있습니다.

 

로딩 UI가 필요한 페이지에 page, layout과 동일한 depth로 loading 파일을 만들면 page가 서버로부터 데이터를 전달받는 동안 page 파일 대신 loading 파일이 대체하여 보입니다.

 

next13 이후 폴더 구조

 

// product/loading.tsx
const ProductLoading = () => {
    return (
        <div>
            <h1>상품 페이지가 로딩중입니다.</h1>
        </div>
    );
};

export default ProductLoading;


// product/page.tsx
import ProductFooter from './footer';

const getData = async () => {
    const res = await fetch('http://localhost:8080/getData', {
        headers: {
            Accept: 'application/json',
        },
    });

    return res.json();
};

const ProductPage = async () => {
    const data: number[] = await getData();

    return (
        <div>
            <p>Next13 Product Page</p>
            <ProductFooter />

            <div>
                {data.map((value) => {
                    return <span key={value}>{value}</span>;
                })}
            </div>
        </div>
    );
};

export default ProductPage;

 

 

 

만약 page가 아닌 컴포넌트들에 대해 로딩 UI가 필요하거나 또는 loading.tsx 파일을 사용하고 싶지 않을 경우에는 Suspense를 활용해 볼 수 있습니다.

 

로딩 UI가 필요한 곳에 다음과 같이 Suspense를 덮어 높으면 loading 파일을 만들어 둔 곳과 동일한 결과를 만들 수 있습니다.

 

next13 이후 suspense 폴더 구조

 

// components/product/loading.tsx
const ProductLoading = () => {
    return (
        <div>
            <h1>상품 페이지가 로딩중입니다.</h1>
        </div>
    );
};

export default ProductLoading;


// app/product/layout.tsx
import { ReactNode, Suspense } from 'react';
import ProductLoading from '../../components/product/loading';

interface Props {
    children: ReactNode;
}

const ProductLayout = (props: Props) => {
    return (
        <div>
            <h2>Product Layout</h2>
            <Suspense fallback={<ProductLoading />}>
                <div>{props.children}</div>
            </Suspense>
        </div>
    );
};

export default ProductLayout;

 

 

 

 

Error Handling

 

next13 이전에 사용되던 대표적인 에러 페이지는 _error.tsx이 있습니다.

 

pages의 root 경로에 _error.tsx를 다음과 같이 생성하게 되면 특정 페이지에서 에러가 발생될 경우 해당 페이지가 보이도록 도와줍니다.

 

next13 이전 폴더 구조

 

// _error.tsx
const Error = () => {
    return (
        <div>
            <h1>페이지에 에러가 발생했습니다.</h1>
        </div>
    );
};

export default Error;

 

 

 

이와 동일한 기능을 적용하기 위해서 next13 이후로는 page, layout과 동일한 depth에 error 파일을 생성해 볼 수 있습니다.

 

next13 이후 폴더 구조

 

// error.tsx
'use client'; // error 페이지에 필수

interface Props {
    error: Error;
    reset: () => void;
}

const RootError = (props: Props) => {
    return (
        <div>
            <h1>페이지에 에러가 발생했습니다.</h1>
            <button onClick={() => props.reset()}>ReTry</button>
        </div>
    );
};

export default RootError;

 

 

 

위의 경우는 _error.tsx처럼 전체 에러에 대해 대응하기 위한 방법이며 page, layout과 동일한 depth에 error파일을 생성하기 때문에 page 별로 서로 다른 error 페이지를 보이도록 만들 수 있습니다.

 

그러므로 상품 페이지에 대한 error 페이지를 추가적으로 구성하고 싶으면 다음과 같이 생성해 볼 수 있습니다.

 

next13 이후 페이지별 에러 핸들링 구조

 

// product/error.tsx
'use client'; // error 페이지에 필수

interface Props {
    error: Error;
    reset: () => void;
}

const ProductError = (props: Props) => {
    return (
        <div>
            <h1>상품 페이지에 에러가 발생했습니다.</h1>
            <button onClick={() => props.reset()}>ReTry</button>
        </div>
    );
};

export default ProductError;

 

 

 

 

RSC (React Server Component) 

 

next13에서 routing 처리를 위해 사용되는 app 폴더에 대해 더 자세하게 알기 위해서는 RSC라는 개념에 대한 이해도 필요합니다.

 

RSC는 최근 react 진영에서 나오고 있는 새로운 개념으로 사용자에게 보여줄 컴포넌트들의 최종 모습을 그동안 client에서 html 파일을 전달받아 hydration을 통해 생성했다면, 이제는 server에서 만들어져 있는 컴포넌트를 전달받아 사용자에게 보여주는 것을 의미합니다.

 

 

 

공식 문서를 통해 확인해 봤을 때 RSC를 사용하게 될 경우 이점은 다음과 같이 있는 것으로 보입니다.

 

첫 번째는 client에서 전달받을 bundle 사이즈가 작아집니다.

 

그동안 사용자가 접근한 페이지를 보여주기 위해서는 server로부터 사이즈 가 큰 bundle을 전달받은 뒤 브라우저에서 관련된 패키지를 전부 내려받는 과정을 거치게 됩니다.

 

하지만 RSC를 활용하면 이러한 과정들이 많이 생략됩니다.

 

server에 이미 관련 패키지를 이용하여 사용자에게 보여 줄 컴포넌트가 이미 구성되어 있기에 client는 해당 컴포넌트를 server로부터 전달받기만 하면 됩니다.

 

즉, 그동안 해왔던 것처럼 bundle에 많은 양의 정보들이 담겨있을 필요가 없어지기 때문에 interactive를 위한 최소한의 bundle 사이즈만 server로부터 전달받아서 사용자에게 더 빠른 속도로 UI를 확인할 수 있게 도와줍니다.

 

 

 

두 번째는 첫 번째 이유 때문에 애플리케이션의 크기가 커지더라도 bundle 사이즈가 늘어나지 않게 됩니다.

 

interactive 한 소스가 아니라면 bundle에 관련 정보들이 담겨있지 않고 server에서 컴포넌트 정보들을 보관하고 있기 때문에 규모가 커지더라도 bundle 사이즈가 늘어나지 않는 모습을 확인할 수 있습니다.

 

 

 

세 번째는 저장되어 있는 데이터를 가져올 때 더 빠른 속도로 가져올 수 있게 도와줍니다.

 

그동안 데이터를 조회할 때 client에서 조회를 해왔지만 RSC를 사용하면 DB와 더 가까이 있는 server에서 데이터를 조회하여 더 좋은 퍼포먼스를 보여줍니다.

 

또한 API 키 값과 같은 중요 정보들이 외부에 노출되지 않도록 방지해 주며, 데이터를 server에서 조회한 뒤 컴포넌트를 client에 전달해 주기 때문에 지역마다 다를 수 있는 데이터 조회 속도가 향상된다고 합니다.

 

 

 

위의 정보들은 모두 공식 문서를 통해서 확인한 정보들이며 이 외에도 다양한 이점들을 더 확인해 볼 수 있었습니다.

 

또한 개인적으로는 RSC가 SSR과는 서로 상이한 개념이지만 페이지마다 적용 가능했던 SSR과 달리 컴포넌트들마다 RSC를 적용할 수 있다는 것은 정말 큰 영향을 미칠 수 있는 부분이라고 생각하고 있습니다.

 

 

 

그리고 app 폴더에 대해 위의 RSC 내용들을 알고 있어야 하는 이유는 app 폴더에 작성되는 모든 컴포넌트들은 RSC가 default로 적용되기 때문입니다.

 

그래서 RSC로 활용되지 않고 client component로 사용되길 원하는 컴포넌트들은 파일 상단에 "use client"를 작성해줘야 합니다.

 

위의 내용만 봤을 땐 RSC가 정말 좋아 보이기 때문에 모든 파일에 RSC를 적용하려고 할 수 있겠지만 모든 파일에 RSC를 적용하는 것은 애플리케이션의 모든 페이지가 조회만을 위한 페이지들이 아니면 거의 불가능에 가깝습니다.

 

왜냐하면 위에도 언급되긴 했지만 interactive 한 기능은 server에서 구성될 수 없고 항상 client에서 적용되기 때문입니다.

 

또한 여기서 말하는 interactive 한 기능은 useState, useEffect, onChange와 같이 동적 처리가 포함된 것을 의미합니다.

 

 

 

간단한 예시로 다음과 같이 볼 수 있습니다.

 

만약 아래처럼 RSC에서 useState, onChange 등을 사용한다면 다음과 같은 에러를 보게 됩니다.

 

import { useState } from 'react';

const RscServerComponentPage = () => {
    const [data, setData] = useState<string>('');

    return (
        <div>
            <h2>Server Component Page</h2>
            <input type="text" onChange={(e) => setData(e.target.value)} />
            <p>입력값 : {data}</p>
        </div>
    );
};

export default RscServerComponentPage;

 

RSC interactive 에러

 

 

 

올바르게 사용하는 방법은 아래처럼 "use client"를 상단에 선언해야 합니다.

 

'use client';

import { useState } from 'react';

const RscClientComponentPage = () => {
    const [data, setData] = useState<string>('');

    return (
        <div>
            <h2>Client Component Page</h2>
            <input type="text" onChange={(e) => setData(e.target.value)} />
            <p>입력값 : {data}</p>
        </div>
    );
};

export default RscClientComponentPage;

 

 

 

 

 

 

 

 

이상으로 Next13의 새로운 기능 알아보기 첫 번째인 Routing과 RSC에 대해 간단하게 알아보는 시간이었습니다.

 

읽어주셔서 감사합니다.

 

 

 

 

728x90
반응형

댓글