
우선 가장 최소한의 기능을 가지고 있는 웹사이트를 만들고 배포한 후, 앞서 계획한 다언어, 다크모드, 인터렉션 등을 구현할 것이다. 먼저 웹사이트의 기본적인 기술 스택을 선택하였다.
프레임워크로 Next.js를 선택했다. Next.js 13의 app router를 좀 더 자세히 살펴보고 싶어 선택했다. Vue.js도 써보고 싶었지만 그건 다음에 다른 프로젝트에서 사용해보기로 미뤄두었다.
또, 추후에 검색엔진최적화 작업도 고려하고 있어서, SEO를 고려했을 때 Next.js가 적절하다고 생각했다.
create-next-app을 실행하면 Typescipt를 사용할 것인지 물어본다. 개인적으로 TypeScript를 사용하지 않은 이유를 찾는 것이 더 어렵다고 생각한다. 물론 작업하다가 타입 에러를 해결하느라 시간을 많이 쓰기도 하지만, 거기에 드는 시간보다 TypeScript가 아껴주는 시간이 더 많기 때문에 TypeScript를 선택했다.
create-next-app 실행시 Tailwind CSS를 세팅해줄지 물어보길래, 이참에 사용해보자는 생각으로 선택했다. 사실 다른 프로젝트에서는 주로 Styled-components를 사용했고, tailwind는 사용해본적 없었다. 별도로 CSS 파일을 작성할 필요 없이 classname만 입력해주면 되는 방식이 무척 간단해보여 별 기대없이 선택했다.
그런데 작업을 하면서 쓰면 쓸수록 생각보다 너무 효자여서 감동이었다...
사용 방법이 간단한 거는 뭐 말해 뭐해, 변수 관리도 쉽고, darkmode 구현도 너무 쉬웠다.
global.css의 root에 background/foreground 색상을 변수로 등록, root:dark에도 등록
:root {
--foreground-rgb: 34, 34, 34;
--background-rgb: 255, 255, 255;
/* blue */
--color-primary: 0, 122, 255;
}
@media (prefers-color-scheme: dark) {
:root {
--foreground-rgb: 255, 255, 255;
--background-rgb: 15, 24, 42;
/* green */
--color-primary: 0, 209, 125;
}
}
tailwind.config.ts에서 앞서 등록한 css 변수를 사용하는 theme color 작성
const config: Config = {
// ...
theme: {
extend: {
colors: {
foreground: "rgba(var(--foreground-rgb), <alpha-value>)",
background: "rgba(var(--background-rgb), <alpha-value>)",
primary: "rgba(var(--color-primary), <alpha-value>)",
},
},
},
};
foreground/background 클래스 사용하기 (끝)
const MainSection = () => {
return (
<div className="bg-background">
<p className="text-foreground">Hello</p>
<Icon className="text-foreground/10" /> // opacity 10%
</div>
)
};
그렇게 나중에 할 작업으로 미뤄놨던 다크 모드를 해결했다. tailwind 짱.
마지막으로 ESLint 세팅까지 완료해주고 작업을 시작했다. 사실 린트는 매번 세팅할 때마다 좀 헷갈린다. 이번에도 조금 버벅거렸는데, Next.js에서 ESLint 세팅하는 것과 관련해서 공식문서가 있어서, 많이 헤매지 않고 금방 끝낼 수 있었다.
복잡하지 않은 UI라, 마크업 작업은 무난하게 완료할 수 있었다.
작업하면서 해결한 에러나 새롭게 배운 것들만 간단하게 남겨본다.
프로젝트 상세 섹션에 아래와 같은 UI를 만들어두었다.
각 카드를 클릭하면 프로젝트의 상세 내용을 확인할 수 있다.

이때 각 카드 별로 svg shape를 만들어서 붙여줬다. ProjectCard의 역할과 재사용성을 고려하여, ProjectSection에서 각 ProjectCard 컴포넌트에 props로 project 데이터와 함께 SVG Component를 내려보내주도록 구현했다.
const ProjectSection = () => {
return (
<section>
<ProjectCard data={data[0]} shape={<Shape1 />} />
</section>
)
};
그 후 Shape에 css를 적용하려 했는데, props의 컴포넌트에 className을 입력하는 방식으로 작성하니 코드가 너무 지저분해 졌다. 동일한 classname을 반복해서 입력하는 것도 좋지 않아 보였고, Shape의 크기를 결정하는 것은 카드의 UI를 그리는 역할을 가진 ProjectCard의 일인데 ProjectSection에서 이루어지고 있는 점도 좋지 않아 보였다.
const ProjectSection = () => {
return (
<section>
<ProjectCard data={data[0]} shape={<Shape1 className="w-8 md:w-10 xl:w-12 ..." />} />
<ProjectCard data={data[1]} shape={<Shape2 className="w-8 md:w-10 xl:w-12 ..." />} />
<ProjectCard data={data[2]} shape={<Shape3 className="w-8 md:w-10 xl:w-12 ..." />} />
<ProjectCard data={data[3]} shape={<Shape4 className="w-8 md:w-10 xl:w-12 ..." />} />
{ /* ... */ }
</section>
)
};
그래서 ProjectCard에서 props로 받아온 ShapeComponent를 render하기 전에 className을 끼워넣기로 했다.
const ProjectCard = ({ shape }: ProjectCardProps) => {
return (
<div className="card>
{cloneElement(shape, { className: "w-8 md:w-10 xl:w-12 ..." })}
{ /* ... */ }
</div>
)
}
사용방법이 조금 헷갈려서 React 공식문서를 참고하려 했는데, cloneElement를 사용하는 것이 코드를 취약하게 만들 수 있다는 경고문이 적혀있었다.
Pitfall
Using cloneElement is uncommon and can lead to fragile code. See common alternatives.
친절하게도 대안책도 함께 소개해주었다. 나는 대안책 중 Passing data with a render prop를 선택해서 작업했다. component를 render하는 함수를 prop으로 내려보내주는 방식이다.
interface ProjectCardProps {
renderShape: (options: { className: string }) => React.ReactNode;
}
const ProjectCard = ({ renderShape }: ProjectCardProps) => {
return (
<div className="card">
{renderShape({ className: "w-8 md:w-10 xl:w-12 ..." })}
</div>
)
};
const ProjectSection = () => {
return (
<section>
<ProjectCard
data={data[0]}
renderShape={options => <Shape1 {...options} />}
/>
</section>
)
};
생각해보니 react-hook-form에서 render할 controller를 내려보내주는 방식과 유사했다.
아무쪼록 코드도 더 깔끔해져서 좋았다. 굿
전반적인 UI 구현을 완료하고, 각 섹션마다 scroll 애니메이션을 주기 위해 framer의 motion.div로 감싸주었다. 그러자 다음과 같은 에러가 발생했다.
TypeError: (0 , react__WEBPACK_IMPORTED_MODULE_0__.createContext) is not a function
에러의 원인을 찾아보니 client component에서 상단에 use client를 명시해주지 않은 것 때문이었다. Next.js 13에서는 기본적으로 모든 컴포넌트가 server component이고, client component를 사용할 때에는 별도에 declare가 필요하다고 한다.
주로 다음과 같이 client side에서 렌더링할 때 사용하는 기능을 포함한 컴포넌트들이 client component로 취급된다.
1. useState, useEffect 등을 사용하는 component
2. onClick과 같이 eventListner를 사용하는 component
3. browser API를 사용하는 component
그리고 use client를 명시하는 것은 client boundary를 명시하는 것이다. dom render tree에서 어느 노드부터 client에서 렌더링할 지 경계를 나누는 것이다. use client가 포함된 컴포넌트와 해당 컴포넌트에 import된 컴포넌트, 자식들 모두 client에서 렌더링되게 된다.

각 섹션 컴포넌트의 상단에 "use client"를 명시해주니 오류가 사라졌다. 그러나 애니메이션 하나 때문에 전체 섹션이 client component가 되는 것이 탐탁치 않았다... 거의 header 빼고 모든 컴포넌트가 client component가 되는 꼴이었다.
다행히 공식문서에서 client API를 사용하는 third-party library를 server component에서 "use client" 없이 사용하는 방법을 알려주었다.
To fix this, you can wrap third-party components that rely on client-only features in your own Client Components
'use client'
import { Carousel } from 'acme-carousel'
export default Carousel
위 방법을 참고하여 화면에 섹션이 들어오면 slide up 애니메이션을 실행하는 클라이언트 컴포넌트를 만들었다.
// SlideUpInView.tsx
"use client";
import { motion } from "framer-motion";
const SlideUpInView = ({ children, ...props }: SlideUpInViewProps) => {
return (
<motion.div
initial={{ opacity: 0, translateY: 100 }}
whileInView={{ opacity: 1, translateY: 0 }}
transition={{ duration: 0.6, delay: 0.1, ease: "easeOut" }}
{...props}
>
{children}
</motion.div>
);
};
export default SlideUpInView;
// MainSection.tsx
import SlideUpInView from "@/components/SlideUpInView";
const MainSection = () => {
return (
<SlideUpInView>
<section>{/* ... */}</section>
</SlideUpInView>
)
};
다만 구조를 생각했을 때 SlideUpInView 하위로 MainSection이 들어가는데, Main Section도 client component가 되는 것은 아닌가 헷갈렸다.
구조 상으로는 하위에 있는 것처럼 보여도, SlideUpInView가 MainSection의 props를 결정하지 않기 때문에 SlideUpInView가 선언하는 client boundary에는 포함되지 않게 된다. 그래서 MainSection은 slideUpInView가 어떤 컴포넌트인지와 관계 없이 server component로 남아있을 수 있다.
처음에는 이해하기 조금 어려웠는데, 요즘 IT의 RSC 관련 글에 자세하게 잘 설명되어있어서 잘 이해할 수 있었다.
vercel을 이용하여 배포했다. 처음에는 AWS Amplify를 고려했으나, 추후에 @vercel/postgres를 붙일 것을 고려하여 그냥 vercel을 선택했다. 아무래도 서비스를 같은 회사의 것으로 맞추는게 튜토리얼 찾기가 용이하다.
vercel 배포를 이용해보니 전반적으로 쉽고 친절했다. 정확히 어떤 것을 해야하는지 스텝 바이 스텝으로 잘 알려주었다. 공식 문서도 무척 깔끔하다.
main branch에 도메인도 연결하였다.
main branch가 아닌 branch는 preview 모드로 배포가 되는데, preview 모드에서는 댓글을 남길 수 있다. 나중에 친구들에게 피드백 받을 때나 셀프로 QA를 진행할 때 유용해보인다.
and...
갈 길이 멀다. 화이팅!