[출처] - KRDS (한국 디자인 시스템)
웹 개발을 결심했다면 결국 SSR(Server Side Rendering) 에 관심을 가지지 않을 수는 없다. 그나마 쉽게 만든 것이 Next.js 라고 하는데 개인적으론 이것도 어렵다. 그래도 하고 싶은 것만 할 수는 없으니 특히나 어려운 것을 직관적으로 설명하고자 이 게시글을 작성해본다.
한 주제로 이렇게 오랫동안 앱을 만들기를 망설여본 프레임워크는 처음이다.
개인적으론 공부 -> 아무거나 하나 만들기 -> 다시 공부 (깊게) -> 아무거나 또 하나 만들기 (깊게) 를 반복하면서 계속 깊이를 더해가는(+ 이력서에 기술 스택을 추가하며) 편이다.
솔직히 서버, 클라이언트 컴포넌트 도 제대로 사용 못했다. 서버 컴포넌트에 useState 를 비롯한 Hook 을 못쓰는데 어떻게 앱을 만들라는 건가?
최대한 서버 컴포넌트 안에 클라이언트 컴포넌트를 작게 만들어서 어떻게든 해봤는데 내부 작동방식도 모르고 그냥 하니 찝찝하다. 컴포넌트의 렌더링과 동작방식은 다음 포스팅에 다뤄봐야겠다.
신세한탄은 여기까지 하고 이것만큼 어려웠던 Intercepting & Parallel Routes 를 이해해보는 시간을 가져보고자 한다. (아 useState 쓰게 해줬으면 모달 만들려고 이 난리 안피워도 되잖슴)
대충 이런 앱을 만들어볼 것이다.
대시보드를 보여주는 페이지이다.
- 왼쪽 상단에는 데이터에 대한 Chart 를 보여준다.
- 오른쪽 상단에는 데이터를 표로 보여준다.
- 아래는 데이터를 격자 형태로 보여준다.
- 위의 세 개를 클릭하면 모달을 보여준다.
하루동안 AI 와 함께 열심히 만든 화면이다. (GitHub링크)
sticky 된 부분이 강조된 것, "School Dashboard" 라는 컴포넌트가 사라졌다 말았다 하는 부분을 해결하지 못하고 넘어가는 것이 아쉽지만 굳이 이런 부분을 고치기보단 다루려는 주제를 빠르게 다루는게 우선이라고 생각했다.
복잡한 UI 는 라이브러리를 통해 해결하였다. 참고할 내용부터 간단히 짚고 넘어가자.
Next.js 는 어떤 화면을 Layout 이라는 컴포넌트에 담는다. Layout 은 Page 를 포함하는 컴포넌트로 한번 로드된 후에는 다시 로드되지 않는다. Page 내 하위 페이지가 Layout 을 정의하지 않는다면 하위 Page 는 상위 Page 의 Layout 에 그대로 속한다.
물론 Layout 안에는 Page 를 넣지 않을수도, Page 를 여러 개 넣을 수도 있다. 물론 이렇게 하나의 Layout 안에 여러 개의 Page 를 넣으려면 추가 작업이 필요하다.
src 디렉토리를 선호하나 간략히 표현하고자 제외. Parallel Routes 와 직접적인 관련이 없는 경우 또한 제외.
app
|-@chart
|-page.tsx
|-@grid
|-page.tsx
|-@table
|-page.tsx
-layout.tsx
-page.tsx
@chart 내에는 URL 화면 / 내의 파이 차트 부분을 담당한다. @grid, @table 은 각각 전체, 테이블 을 담당한다.
Next.js 는 파일시스템 기반의 라우팅 방식을 적용한다. app 폴더 내의 다른 폴더는 URL 의 Context-Path 를 담당한다는 뜻이다. 예를 들어 app 폴더 내 detail 폴더가 있다면 /detail 을 의미한다.
# 실제 앱에는 detail 말고는 다른 폴더는 존재하지 않을 것이다.
app
|-detail # /detail
|-page.tsx
|-setting # /setting
|-page.tsx
|-detail # /setting/detail
|-page.tsx
|-my # /my
|-page.tsx
-layout.tsx
-page.tsx
그런데 이것만으론 부족한데
그렇기 때문에 Slot 이라는 개념을 도입한다.
예제의 화면은 대시보드 화면이다. 상당히 많은 정보와 반짝반짝한 컴포넌트가 들어갈 것이다. 만들기 전부터 여러가지 작업들이 예상된다. 그렇다면 이를 분리해서 개발할 수 있다면 좋겠다. (레이아웃만 짜고 다른 개발자에게 부탁을 드릴 수도 있겠다.
Slot 은 최종 Page 컴포넌트를 만들기 위한 컴포넌트이다. 그리고 Routing 에 영향을 주지 않는다. Slot 은 폴더이름에 @ 를 붙이는데, 예를 들어 @chart 폴더 내에 viewChart 폴더가 있을 경우 /@chart/viewChart 가 아닌 /viewChart 로 접근해야 한다.
화면은 layout.tsx 에서 시작한다. layout.tsx 파일이 없다면 부모경로의 layout.tsx, 타고 올라가서 없다면 루트 경로의 layout.tsx 를 참고한다.
app
|-@chart
|-default.tsx
|-layout.tsx
|-page.tsx
|-@grid
|-default.tsx
|-layout.tsx
|-page.tsx
|-@table
|-default.tsx
|-layout.tsx
|-page.tsx
-layout.tsx
-page.tsx
그럼 루트 경로의 layout.tsx 를 간단히 소개해보자.
/**
* 아래와 같은 레이아웃
* [-][-]
* [----]
*/
type Props = Readonly<{ children: ReactNode, chart: ReactNode, grid: ReactNode, table: ReactNode }>
export default function RootLayout({ children, chart, grid, table }: Props) {
return (
<html>
<body>
{children} // 루트 경로의 page.tsx
<div className="grid grid-rows-[auto_1fr] gap-1 p-4">
<div className="grid grid-cols-[auto_1fr] gap-1">
{chart} // @chart 내 layout.tsx 혹은 page.tsx
{table} // @table 내 layout.tsx 혹은 page.tsx
</div>
<div className="grid grid-cols-[1fr] gap-4">
{grid} // @grid 내 layout.tsx 혹은 page.tsx
</div>
</div>
</body>
</html>
);
}
RootLayout 은 하나의 컴포넌트이며, 다른 컴포넌트를 같이 렌더링하고 있다. 바로 slot 이다.
원래 존재하는 children 을 제외한, 같은 경로에 @ 를 앞에 붙인 디렉토리는 모두 slot 인 것이다. 만약 chart 만 업데이트 된다면 chart 만 업데이트 될 것이다.
한가지 주의사항이 있는데 내가 소개한 것은 정적 slot 이다. @[foldername] 은 동적 slot 인데 한 레벨의 slot 에는 모든 slot 을 정적 혹은 동적 하나로 통일해야 한다.
자세한 건 지금 같이 작성하는 공식문서 읽어보기 시리즈가 있으니 거기서 다뤄보겠다.
현재 레이아웃에 다른 Routing 컴포넌트를 불러오는 것이다. 사용자가 현재 컨텍스트에서 벗어나지 않으면서도 다른 화면을 보여주는 데 유용하다(ex. 모달창, 확인창)
공식문서의 예시는 인스타그램같은 피드가 /feed 이고 어떤 사진을 보여주는 모달창은 /photo/123 일 경우를 들고 있다. /feed 컨텍스트 내에서 /photo/123 에서 보여줄 모달창을 보여주고 싶은 상황인 것이다.
파일 시스템 내에선 괄호 안에 넣는 . 의 갯수에 따라 어떤 라우팅을 Intercept 할지 정하는데 그 규칙은 다음과 같다.
app
|-feed
-layout.tsx
-page.tsx
|-(..)photo
|-[id]
-page.tsx
|-photo
|-[id]
-page.tsx
-layout.tsx
-page.tsx
필자는 처음 보고 정신이 좀 멍해졌다. 제대로 설명할 수 있을지 좀 난감하긴 하지만 해보겠다.
우선 (..) 를 빼고 보면 photo 폴더가 루트에 하나, feed 폴더에 하나다. 아까 예시에 사용자가 feed 내 위치해 있다고 가정하고 사진을 보는 모달을 띄우고 싶다고 하자. 사진을 띄우는 모달은 루트 내 photo 폴더에 만들어 뒀다.
만약 사용자에게 모달을 보여주려면 feed 폴더 내에 새로 모달을 만들어도 되지만 위처럼 (..)photo 폴더를 만들고 page.tsx 에서 photo 폴더의 page.tsx 를 임포트하는 것만으로 재사용이 가능할 것이다.
즉 위의 예시에서 /feed 에 위치한 상태로 /photo/123 으로 이동하는 코드가 아래와 같이 준비되어 있다면 /feed 화면 내에 다른 화면 이동 없이 /photo/123 을 보여주는 것이다.
// ---- 클라이언트 컴포넌트 ----
import { useRouter } from 'next/navigation';
router.replace('/photo/123');
// ------ 서버 컴포넌트 -------
import Link from 'next/link';
<Link href={'/photo/123'}>
<Image src="/COOLIMAGE.svg" width={200} height={200}/>
</Link>
위의 Parallel Routes 처럼 자세히 알아볼 포스트를 작성할 것이다.
개인적으로는 일반적인 모달창을 만들어놓고 싶었다. 그래야 나중에 모달을 추가할 일이 있더라도 작업을 최소화하고 싶었기 때문이다.
우선 모달창을 만들어 놓은 /modal 경로의 구조는 다음과 같다.
app
|-modal
|-[type] # 보여주고 싶은 모달을 동적으로 처리하기 위해 동적 슬롯 적용
-ChartModalPage.tsx # chart 의 모달
-GridModalPage.tsx # grid 의 모달
-page.tsx # 모든 모달은 page.tsx 내 분기하는 코드에 의해 렌더링 됨.
-TableModalPage.tsx # table 의 모달
-CommonModal.tsx # 모달 배경과 루트 컴포넌트 담당
-default.tsx # 렌더링 되지 않았을 경우 보여줄 컴포넌트
-layout.tsx # URL modal 경로에서 보여줄 컴포넌트의 레이아웃
-OverviewModal.tsx # 이번 예제에서 보여줄 공통 버튼과 컨텐츠 위치 잡아줌
|-(..)photo
|-[id]
-page.tsx
|-photo
|-[id]
-page.tsx
-layout.tsx
-page.tsx
그럼 시작해보자. 우선 CommonModal 을 만들어 공통적인 부분을 미리 만들자.
'use client' // import excluded
export default function CommonModal({ children }: { children: React.ReactNode }) {
const router = useRouter();
const dialogRef = useRef<HTMLDialogElement>(null);
const onDismiss = useCallback(() => {
router.back()
}, [router]);
useEffect(() => {
const handleEscape = (e: KeyboardEvent) => {
if (e.key === 'Escape') onDismiss();
}
document.addEventListener('keydown', handleEscape);
return () => {
document.removeEventListener('keydown', handleEscape);
}
}, [onDismiss]);
const Dialog = () => {
return <dialog
ref={dialogRef}
className="inset-0 bg-gray-500 bg-opacity-75 transition-opacity flex flex-col items-center justify-center"
aria-labelledby="modal-title"
onClick={() => router.back()}
>
<div
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
className="fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 bg-white rounded-lg shadow-xl"
onClick={(e) => e.stopPropagation()}
>
{children}
</div>
</dialog>
}
return createPortal(
<Dialog/>,
document.body
)
}
createPortal 을 이용해 해당 컴포넌트가 다른 리액트 컴포넌트 트리에 라우팅 되도록 하였다. 정적인 zIndex 값을 관리하는 것보다 유용하다는 의견이 많아서 나도 해봤다.
CommonModal 은 잘 보면 알겠지만 우리가 예상할 수 있는 "확인" 혹은 "닫기" 버튼 등이 없다. 모달 창은 여러 요소를 가질 수 있다. 이런 버튼은 동적으로 처리해야 한다.
이번에 구현할 모달창은 공통적으로 닫기 버튼이 우측상단에 존재하고 아래에 컨텐츠가 들어간다. 그러므로 OverviewModal.tsx 가 필요한 것이다.
'use client' // import excluded
export default function OverviewModal({ children }: { children: ReactNode }) {
const CommonModal = dynamic(() => import('./CommonModal'), {
ssr: false,
});
const router = useRouter();
const [isPending, startTransition] = useTransition();
const handleClose = () => {
startTransition(() => {
router.back();
router.refresh();
});
}
const ModalButton = () => {
return <button
className={`px-4 py-2 ${isPending ? 'bg-gray-300' : 'bg-blue-500'} text-white rounded mt-2 me-2`}
onClick={handleClose}
disabled={isPending}
>
{'닫기'}
</button>
}
return <CommonModal>
<div className="flex flex-col items-end justify-end">
<ModalButton/>
{children}
</div>
</CommonModal>
}
이 모든 요소를 포함한 layout.tsx 이다. 회색의 투명한 배경가 모달창 위치를 잡아준다.
import {ReactNode} from "react";
export default async function ModalLayout({children}: { children: ReactNode }) {
return <div style={{
height: '100vh',
width: '100vw',
backgroundColor: 'rgba(105, 105, 105, 0.5)',
position: 'fixed',
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}>
<div className={`fixed inset-0 flex flex-col items-end justify-end`}>
{children}
</div>
</div>
}
default.tsx 는 아무것도 반환하지 않는다. 기본적으로 보여줘야 할 컴포넌트는 없다.
export default function Default() {
return null;
}
내가 하고 싶은 건 이것이다. 만약 사용자가 '/' 에 있을 경우
그러기 위해 @chart, @grid, @table 에는 아래와 같은 layout.tsx 가 존재한다. 예시는 @chart/layout.tsx 다.
// 서버 컴포넌트
export default function Layout({ children }: { children: ReactNode }) {
return <div className={`bg-gray-50 rounded-2xl w-[400px] h-[400px] flex justify-items-start content-start flex-col m-2`}>
<div className={`flex ml-2 mr-2 mt-2 justify-between items-center`}>
<div className="flex-1"/>
<h2 className={`text-xl m-1 text-center font-bold flex-1`}>차트</h2>
<div className="flex-1 flex justify-end">
<Link href={'/modal/chart'}> // 모달 창 띄움.
<Image
src="/ButtonModal.svg"
alt="Modal button"
width={24}
height={24}
className="w-6 h-6"
/>
</Link>
</div>
</div>
<div className={`p-2 h-full`}>
<Suspense fallback={<div>Loading...</div>}>
{children}
</Suspense>
</div>
</div>
}
위와 같은 /modal/chart 를 포함한 /modal/grid, /modal/table 를 대응하려면 어떻게 해야 할까? 내 아이디어는 dynamic segment 였다.
app
|-modal
|-[type] # 요거다
-ChartModalPage.tsx
-GridModalPage.tsx
-page.tsx # 모든 모달은 page.tsx 내 분기하는 코드에 의해 렌더링 됨.
-TableModalPage.tsx
-CommonModal.tsx
-default.tsx
-layout.tsx
-OverviewModal.tsx
|-(..)photo
|-[id]
-page.tsx
|-photo
|-[id]
-page.tsx
-layout.tsx
-page.tsx
[type] 폴더는 구체적으로 정해진 라우팅 외의 라우팅을 전체적으로 다룬다. 즉, type 내의 page.tsx 가 /modal/?? 에서 chart 인지, grid 인지, table 인지만 알 수 있으면 된다. 그러므로 layout.tsx 필요없이 page.tsx 를 다음과 같이 만든다.
// 서버 컴포넌트. import excluded
export default async function ModalPage({params}: { params: Promise<{ type: string }> }) {
const {type} = await params;
if (type === 'table') {
return <TableModalPage/>
} else if (type === 'chart') {
return <ChartModalPage/>
} else if (type === 'grid') {
return <GridModalPage/>
} else {
return <div>asdf</div>
}
}
동적 라우팅의 값은 위와같이 Promise 객체에 담겨져서 오므로 잘 꺼내 쓰면 된다.
그러므로 이제 각 모달을 만들어주면 된다. 우리는 모달창의 배경, 모달 자체 화면을 공통으로 분리했다. 그러므로 모달 자체 화면 안에 들어갈 내용만 넣어주면 된다.
// 서버 컴포넌트. import excluded
export default function ChartModalPage() {
return <OverviewModal>
<div className="bg-white rounded-lg p-6 w-[400px] text-center">
차트 모달입니당
</div>
</OverviewModal>
}
다른 모달 페이지들은 단지 문구만 바뀌어 있다.
사실 Next.js 를 틈날때마다 짬짬이 본 일은 많지만 실제 뭘 만들어 볼 엄두가 나지 않아서 실력을 많이 못 올린 것 같은데 이런 도전을 자주 하면 실력 올리기에 참 좋은 것 같다.
앞으로도 이런식으로 헷갈릴만한 내용을 정리하며 괜찮은 FE 개발자가 되면 아주 조켓다!