Next.js 폴더/파일 구조 잡기

Next.js 폴더/파일 구조 잡기

개발 이야기
Junhyuk Lee

주니어 개발자들이 가장 많이 물어보는 질문이 폴더 구조에 대해 묻는 것이다.

사실 이런 질문들은 항상 답이 정해져있다. “그때 그때 달라요”

아니 시발 그때 그때 다른게 어딨어? 하고 빡이 칠법도 하다. 참 막막하겠지. 내가 그 기분 알지.

사실 저런 답변은 내가 가장 싫어하는 종류중 하나인데, 나는 내가 틀린 답을 낼 지언정 빠르게 적용하고 바로 효과를 볼 수 있는걸 주고 싶어한다. (나는 액상과당 같은 사람이니까 과용하면 해롭다) 그리고 내 생각이 영원히 옳지 않다는 것을 알고 있으므로 더 좋은게 나오면 빠르게 갈아탄다.

자바스크립트라는 언어가 좋은 점이 유연하다는 점도 있지만, 보통의 경우에 유연함은 편안함과는 거리가 멀다. 뭘 해야 할지 모르겠고, 막막하고, 내가 가는 길이 옳은지 감이 안잡히기 때문에 항상 불안해 할 수 밖에 없다. 인간이라는 종족은 근본적으로 노예같은 놈들이기 때문에 (사극 악당 말투) 대부분 시키는대로 하는게 편하다. Next.js가 기본적으로 프레임워크기 때문에 사용법이 어느 정도 정해져 있는데, 그럼에도 불구하고 더 짓눌리는걸 원하는 바퀴벌레 같은 당신을 위해 이 글을 적었다.

자 형이 알려주는대로만 해. 이제부터 반말이다.

Untitled.pngUntitled.png

일단 루트 폴더 내용물을 보자.

모든 것의 시작이 되는 package.json이 보일 것이다. 자세한 설명은 생략한다.

.eslintrc.json

그리고 루트 폴더에는 반드시 .eslintrc.json 파일이 있어야한다. 만약 없다면, 면접에서 서류 탈락하게 될 것이다. 솔직히 말해 현업에서 린터를 쓰지 않는 사람은 진짜 인간 이하의 그 무언가로 취급 받는다. 대부분의 프론트엔드 개발자 커뮤니티에 올라오는 개 쓰레기 똥 질문 중 대부분은 들여쓰기가 4칸이라던가, 린터를 안쓴다던가 하는 사람들이 올린다. 내 오랜 경험상 99% 정도는 맞다. 99% 정도면 솔직히 올모스트 올오브뎀 갓댐 시발 전부 아니냐? 러시안룰렛 1% 확률로 해서 방아쇠 당길 때 마다 1억씩 준다면 나는 100번 당기고 행복해할거다.

들여쓰기 4칸, 음, 만약 니가 백엔드 개발자면 인정. 근데 프론트엔드 개발자가 4칸인것은.. 뭐랄까, 이 사람은 마크업쪽은 하나도 다루지 않는걸까? 마크업을 하는데 4칸 들여쓰기이면 모니터가 못버틸텐데. 아 몰라 대부분의 웹 관련 라이브러리나 프레임워크들이 2칸 들여쓰기를 기본으로 하고 있지만, 일부 코드는 4칸 들여쓰기도 조금 보이니까 호불호나 취향의 차이로 생각하고 넘어가자고. 근데, 린터는 호불호의 영역이 아냐 ㅋㅋ

지금 린터를 안쓰고 있다면 당장 세팅해라. 솔까 내가 린터 쓰라고 윽박질렀을 때 바로 따라하는 놈들은 다음에 똥질문 안올린다. 근데 린터 안쓰는 사람들은 진짜 계속 쓰레기같은 질문을 올려댄다. 그리고 심지어 그런 질문을 하고서 매우 당당해. “이렇게 하면 되는데요?” 그래 시발놈아, 되기야 되겠지. 니 항문으로도 수분 섭취 가능한거 나도 알고 있지, 인체는 신비하니까. 그렇다고 항문으로 아메리카노 원샷 때릴거 아니잖아?

KakaoTalk_Photo_2023-05-28-07-16-29.pngKakaoTalk_Photo_2023-05-28-07-16-29.png

이런 개같은 코드 보소.. 배열에 useState를 푸시한다. 댄 아브라모프형! 여기 이단자가 있습니다, 여기 사탄에 대악마가 있어요!! 아니 물론 똥 같은 코드 짤 수 있어. 대문호 알렉산더 푸쉬킨에 빙의해서 대단한 시를 쓸 수 있어. 막 곳곳이 다 시적 허용이야. 자바스크립트는 말랑말랑하니까. 근데 저 코드를 보고 사람들이 예의 갖춰서 ‘선생님 저렇게 하면 안됩니다’ 하니 “아니 이렇게 하면 된다니까요??” 하면서 답변자를 바보 취급 하더라. 물론 그 다음에 강퇴당했지만.

Untitled.pngUntitled.png

봐, 린터 쓰니까 알아서 빨간줄 쳐서 “안돼, 돌아가!” 하잖아? 린터가 좋은 점은 내가 ‘누가 봐도 쓰레기같은’ 똥코드를 짜는걸 알아서 막아준다. 여기다가 Prettier를 적용하면 들여쓰기 좀 잘못한것 정도는 저장 버튼만 눌러도 알아서 수정해준다. 펑션과 펑션 사이에 엔터 하나 안친거 이런것도 다 커버쳐주고.

eslintrc가 없다는 것은, 린터를 사용하지 않는다는 말이며, 린터를 사용하지 않는다는 말은 입문 단계라 린터가 뭔지 존재도 모른다던가, 아니면 나는 코드를 아주 마이웨이로 짜기 때문에 팀플레이는 고려하지 않는 사람이라던가, 아니면 수십년간의 경험으로 손린터가 린터보다 더 대단한 경지에 올랐기 때문일 것이다. 키보드에 0과 1 버튼만 있는 사람이 아니라면 린터를 쓰는게 맞다. 린터를 쓰지 않는 것은, 코드를 아무 규칙 없이 개같이 짠다는 말이고, 린터를 쓰지 않는 것은, 진짜 근본도 없는 개쌍놈이라는 것을 뜻하며, 린터를 쓰지 않는 것은, .. 그만..

아무튼 린터를 써라. 린터가 켜져 있다면 우리는 방안에 엄마가 아홉명 들어와 있는 것 처럼 참견 받으면서 코딩을 할 수 있다. 야 너 거기 들여쓰기 3칸, 야 너 거기 let 말고 const .. 빨간줄 노란줄 쳐가면서 모든 문법적인 문제들을 지적해주기 때문에 우리가 실수를 하지 않을 수 있다. 사실 내 경력 십몇년의 대부분은 린터빨이다. 린터를 쓰는 자는 경력이 3년은 늘어 보일 것이요, 린터를 쓰지 않는자는 0년차보다 못한 대우를 받을 것이다. 아무튼, 린터 써라.

내 경우 리액트에 한창 입문하던 2016년쯤 처음으로 린터를 사용하기 시작했는데, 지능이 떨어지다보니 린터를 제대로 설치하는데 2주가 걸렸다. 카페에서 며칠 밤을 새가며 (하루에 커피 3잔 시켰다, 빵도 시켰다) 린터를 설정해냈고, 그게 지금까지 남아 내 개발 인생에 아주 큰 도움을 주고 있다. 린터 설정 파일이 막막한 사람은 그냥 내거 복붙해라.

https://github.com/miriyas/miriya/blob/main/.eslintrc.json

내가 린터 예찬론자라서 린터 이야기가 길었는데 여기까지 하고..

.env

이제 .env를 보자.

루트 폴더에 혹시 .env나 .env.local, .env.development, .env.copy 등이 있는가?

우리가 개발을 할 때 내 컴퓨터, 그러니까 로컬 개발 환경과 스테이징 환경, 프로덕션 환경 등등 여러 단계를 밟아가며 개발을 하게 된다. 내 로컬에서 작업하고, 확인하고, 잘 된것 같으면 커밋해서 깃에 푸시한다. 팀원들이 깃허브에서 Pull Request 보고 “올ㅋ LGTM” 하면 머지해서 개발 서버에 배포를 해서 다른 사람들도 볼 수 있게 한다. 그 다음에는 최대한 프로덕션과 동일하게 돌아가는 스테이징 서버에 배포를 해서 QA팀이 테스트 한다던가, 디자인팀이나 PM들이 구경하러 온다던가.. 그리고 마지막으로 프로덕션 서버에 올려서 별 탈이 없으면 진짜 배포가 되는 식인거다. 아마 회사 마다 다르겠지만 거의 다 대동소이 할 것이다.

Untitled.pngUntitled.png

근데 개발 하다보면 내 로컬 개발 환경과 프로덕션 환경이 약간 뭔가 다르게 설정되어야 할 때가 있다. 내 경우에 백엔드 엔드포인트 주소를 http://localhost:3001 로 적어놓는데, 이게 프로덕션에 배포되면 https://server.miriya.net 이 되어야한다.

이때 나는 .env.local 파일에는 NEXT_PUBLIC_BE_URL="http://localhost:3001" 이라고 입력해두고, 프로덕션 환경에는 NEXT_PUBLIC_BE_URL="https://server.miriya.net" 이라고 설정해둔다.

음, 여기서 중요한 부분은 NEXT_PUBLIC 접두사인데, 이게 붙어있으면 실제 배포되었을 때 사용자 브라우저에도 저 정보가 노출되며, 브라우저 코드에서 사용할 수 있다. NEXT_PUBLIC_BE_URL은 외부에 노출되어도 보안상 문제가 없으며, 저걸 활용해서 실제로 백엔드로 요청 보내는 주소로 쓰기 때문에 NEXT_PUBLIC이 붙는다. 저걸 안붙인다면 브라우저에서 undefined로 열심히 요청을 보내고 개작살 나는 상황을 볼 수 있을 것이다. 저게 붙지 않은 GITHUB_ACCESS_TOKEN이나 SPACES_SECRET 파일 등은 프론트엔드 ‘서버’ 단에서 사용하기 때문에 브라우저에서 알 필요가 없으며, 알려져서도 안된다.

이렇게 환경별로 달라질 수 있는 값을 적는 용도로 .env 파일을 쓰기도 하지만, 비밀스러운 정보를 은닉할 때도 사용한다. 가령 내 경우 미니홈에서 하단에 보이는 깃허브 커밋 메시지 목록을 불러오는 기능이 있는데, 이때 깃허브 옥토캣 라이브러리를 쓰기 위해 깃허브 엑세스 토큰을 .env에 저장해둔다.

주의할 점은 저 .env 파일은 반드시 .gitignore 처리가 되어있어야 한다는 말이다. 루트 폴더에 .gitignore 파일이 있고, 그 안에 내용물이

# local env files
.env
.env*.local

이런 식으로 있다면, .env 파일과 .env.local 파일 등은 깃에 커밋되지 않는다. 내 컴퓨터에는 있지만 git add가 되지 않고, 깃허브에는 올라가지 않는다는 말이다. 만약 깃허브에 저게 올라가게 되면, 사고가 난다. 가령 내가 이 블로그 소스 코드를 퍼블릭으로 다 공개해놨는데, 나쁜 사람이 내가 멍청하게 .env 파일을 깃에 올려둔걸 보게 되면 “옼 깃허브 엑세스 토큰 있네, 개꿀!” 하고 긁어다가 마치 내 깃허브 비밀번호처럼 악용할 수도 있다. 이걸 통해 접근 권한을 탈취하면 민감한 정보가 들어있는 프라이빗 백엔드 코드를 열어볼 수도 있고, 온갖 여러가지 안좋은 일들이 생길 수도 있다.

또한 민감 정보는 NEXT_PUBLIC 접두사를 붙이지 않도록 주의하자. 만약 유출되면… 내 CDN에 야동이 올라가서 공유가 된다거나, 아니면 내 블로그 대표 이미지에 음란한 이미지가 올라간다던가, 아니면 뭐 내 레포를 싹 밀어버린다던가 하는 일이 실제로 발생할 수도 있는거지.

Untitled.pngUntitled.png

물론 깃허브는 똑똑하기 때문에, 소스코드에서 깃허브 엑세스 토큰을 발견하게 되면 엑세스 토큰을 강제로 expire 시켜버리고 이메일로 “덜떨어진놈, 내가 대신 지워줬음”이라면서 통보해온다. 물론 내가 최근에 이 소스코드에 .env.copy 파일을 실수로 올리다가 직접 겪은 일이다. 아무튼 .env는 반드시 .gitignore 되어있도록 확인하자.

Untitled.pngUntitled.png

/public

에.. 루트 폴더는 이정도면 되었고, public 폴더로 가보자. Create React App과 다르게, Next.js에서는 public 폴더에 이미지 등등을 저장해두라고 권장한다. CRA에서는 public이 프로젝트 외곽의 무엇이라 생각하는데 반해, Next.js는 public 폴더도 프로젝트 번들의 일부라고 생각하는 느낌적인 느낌이다. 보통은 이 안에 favicon, manifest 파일 등등이 지저분하게 널려있을 건데, 나는 여기 더해 images 폴더와 fonts, svgs 폴더가 더 있다.

Untitled.pngUntitled.png

public/fonts

이 폴더는 그냥 폰트만 넣어놓는 용도라는걸 알 수 있다. 라떼는 otf니 ttf니 별의 별걸 다 fallback으로 넣어놨는데, 요즘 브라우저들은 다들 woff2를 지원하기 때문에 웹폰트는 woff나 woff2만 넣어도 된다. 어 근데 왜 저기 ttf가 있지?; 저건 웹폰트용이 아니라 내 블로그 미리보기용 og-image 생성용으로 쓰는 폰트이기 때문이다. ttf가 컴파일 속도가 더 빨라서 라이브러리 만든 놈들이 ttf로 넣으라고 권장하고 있어서다.

public/svgs

svg 파일만 넣어두는 용도다. 단, 여기서 약간 귀여운 처리를 좀 해주었다.

// public/svgs/index.ts
import IconInfo from './info.svg';
import IconComment from './comment.svg';
import IconGithub from './github.svg';
import IconEyeTemplate from './eye-template.svg';
import IconEyeBall from './eye-ball.svg';
import IconEyeClosed from './eye-closed.svg';
import IconClose from './close.svg';
import IconEdit from './edit.svg';
import IconChevronRight from './chevron-right.svg';
import IconProviderFacebook from './provider-facebook.svg';
import IconProviderGithub from './provider-github.svg';
import IconProviderGoogle from './provider-google.svg';
import IconRSS from './rss.svg';
import IconSearch from './search.svg';

export {
  IconInfo,
  IconComment,
  IconGithub,
  IconEyeTemplate,
  IconEyeBall,
  IconEyeClosed,
  IconClose,
  IconEdit,
  IconChevronRight,
  IconProviderFacebook,
  IconProviderGithub,
  IconProviderGoogle,
  IconRSS,
  IconSearch,
};

이렇게 해주면 나중에 컴포넌트에서 svg 땡겨 쓸 때 바로 <IconInfo /> 이런 식으로 편리하게 쓸 수 있다. 아직도 <img src='/svgs/info.svg'> 이렇게 쓰고 있니?

Next.js가 아니라 CRA로 쓸 경우에는 export { ReactComponent as Logo } from './logo.svg' 이런 식으로 ReactComponent as 를 붙여줘야한다. svgo 라이브러리랑 관련 있으니 뭐 안되는것 같으면 검색좀 해보고.

public/images

이건 이미지 넣어두는 용도고.. 나는 가능한한 jpg/png 등의 이미지들은 여기다 다 넣어두는걸 선호하는데, 사람에 따라 컴포넌트가 있는 위치에 함께 두고 싶어할 수도 있다. 존중한다. 나도 가끔 그러고.

/src/app

src 폴더로 가보자. 소스 폴더잖아? 우린 주로 여기서 일을 할거다. 어지간한 변태가 아닌 이상 src 폴더를 다른 이름으로 바꾼다던가 등등 손댈 일은 거의 없을 것이다. 당신이 만약 변태라면 여기 어울리지 않는다. 이 블로그의 최고 변태는.. 아 여기까지..

Untitled.pngUntitled.png

맨 위에 app 폴더부터 시작해서, 맨 아래 utils 까지 있다.

app 폴더는 내가 Next.js App Route를 사용하니까 그런거고, 대부분의 사람들은 app 대신에 pages 폴더를 가지고 있을 것이다. pages 폴더는 내가 잘 모른다.

Untitled.pngUntitled.png

내 app 폴더 내용물은 이런식이다. 대부분 Next.js의 공식문서를 따라 하고 있는 것을 알 수 있다. 다만 app 폴더 내부는 오직 라우팅용으로만 사용한다. 실제로 뭐 컴포넌트 안에 글씨가 뭐가 있네 스타일이 어떠네 하는 것들은 죄다 containers 폴더로 옮겨놓았다. 왜냐하면 app 폴더는 좀 청결성이 요구되는 곳이기 때문이다. 저기다가 page.tsx 파일을 넣으면 Next가 아 이거 라우트 정의 파일에 해당하는군- 하고 거기에 맞는 작업들을 해준다. 만약 저 안에 뭐 Categoryies/Item.tsx 이런 파일들이 이리저리 널려있으면 라우터 관련된 부분들을 찾기가 참 힘들어진다. 마치 내 책상과도 같지.

Next.js의 공식 문서에서는 모든게 다 App Route 하나로 다 통일될 것 같고, 모든게 다 SSR에 SSG에 ISG에 다 잘 될것 같이 적어놨지만 실제 현실은 좀 다르다. UN이 있다고 세계가 전쟁을 안하는게 아니잖아. 대부분의 경우 SSR이나 Static 렌더링은 껍데기 부분에만 사용될 것이고, 동적으로 업데이트가 필요한 내부는 CSR로 돌아가게 된다. 별로 동적 업데이트가 필요하지 않은 부분도 나중엔 결국 번들 청크 용량 문제 때문에 dynamic import를 쓰게 될 것이다. SSG에 revalidate 해서 ISG를 써보고 싶었지만 버그 천국이라 포기해버리게 된다.

아무튼 내 이야기는.. app 폴더 안에는 라우팅 관련된 파일만 넣어두자는거다. page.tsx, layout.tsx, opengraph-image.tsx 등등.. 그 외에 page.tsx 안에서 보여줄 컨텐츠들은 containers 폴더에 넣어두고 땡겨 쓰자. 이런 식으로.

// src/app/idols/page.tsx

import Idols from '@/containers/idols';
import { getIdolYearsDataApi } from '@/services/idols';

import { getMetaData } from '@/app/sharedMetadata';

export const metadata = getMetaData({
  url: 'https://miriya.net/idols',
  title: 'History of Idols',
  description: '시대별로 정리된 한국의 아이돌들',
  imageUrl: 'https://miriya.net/images/idols/og.jpg',
  keywords: ['idols', 'korea', '한국', '아이돌'],
});

const IdolsPage = async () => {
  const years = await getIdolYearsDataApi()
    .then((res) => res.data)
    .catch(() => []);

  return <Idols years={years} />;
};

export default IdolsPage;

보면 메타데이터 관련된것이랑, 초기 데이터 관련된것들 빼곤 containers/idols에서 가져오도록 해놨다. 나는 이렇게 app 폴더를 깔끔하게 유지하는 것을 권장한다.

src/containers

Untitled.pngUntitled.png

이제 좀 뭔가 익숙한게 보이는지? 아까 app 폴더에서 타고 들어와서 이 안에서 본론을 시작하게 된다. 여기서 뭐 버튼을 뭘 보여주고 훅을 걸고 자시고를 다 하는거다.

여기서 states.ts가 뜬금없이 보일텐데, 나는 예전에 Redux 쓰던 시절에 전역 상태관리를 남발한 적이 있었다. 잘못 사용하면 페이지 전체 리랜더를 유발하는 전역 상태관리보다는 페이지 단위로 자잘자잘하게 상태관리를 하는 것이 더 나아보이더라. 그래서 나는 src/states 폴더에 내용물이 거의 없고, 대부분 작은 단위로 사용한다.

우리가 useState를 사용하면 그 state는 해당 파일과 그 아래에 자식에게만 영향을 준다. 자식에게는 state를 props로 넘겨주면 되는데, 이게 깊이가 깊어지면 이게 뭐하는 짓거린가- 하는 생각이 들게 된다. 이런 props 드릴링을 막기 위해 나는 Jotai를 사용했다. Jotai 말고 Recoil을 사용해도 된다. 어차피 그놈이 그놈이라.. Jotai로 상태관리를 하게 되면 훅 안에서 사용해도 상태가 유지되어 useState보다 한결 편하다.

또한 useIdols.ts도 보인다. 나는 state와 마찬가지로 훅도 한 영역 안에서만 사용할 경우 이렇게 손 잘 닿는 곳에 만들어두는걸 선호한다.

src/containers에서는 이런 식으로 tsx, css, state, hooks이 들어가게 된다. src/containers/idols 에는 idol 페이지 관련된 것들만 다 들어가게 되는거다.

src/compnents

Untitled.pngUntitled.png

위 containers에서 배타적으로 자기 나와바리만 관리하고 있었다면, components는 좀 전국구다. 이 곳에는 여러 페이지에서 공통으로 사용할 수 있는 공통 컴포넌트를 모아둔다. 가령 표준으로 곳곳에서 사용되는 Button이나 Loading 컴포넌트 같은걸 여기다 넣어두고 그때그때 @/components/Loading 이런 식으로 불러와서 쓴다.

보통은 src/containers에서 만들었던 컴포넌트를 다른데서도 사용하게 되면서 이쪽으로 이사하게 되는 식으로 사용된다. (그리고 보통은 재사용/공통화 보다는 따로 만드는게 차라리 나았다고 후회하게 되지)

Untitled.pngUntitled.png

src/constants

src/contants에는 여러 페이지에서 공통으로 사용하게 될 상수 값을 넣어둔다. 지금은 ga.ts만 보이는데, 내용물은 다음과 같다.

// src/constants/ga.ts

import { ValueOf } from '@/types';

export const COMMON = {
  COMMON_GITHUB_CLICK: 'common_github_click',
} as const;

export const IDOL = {
  IDOL_OPEN: 'idol_open',
  IDOL_DESC_CLICK: 'idol_desc_click',
  IDOL_CATEGORY_CLICK: 'idol_category_click',
  IDOL_YEAR_CLICK: 'idol_year_click',
} as const;

export const CAMERA = {
  CAMERA_MAKER_CLICK: 'CAMERA_MAKER_CLICK',
  CAMERA_YEAR_CLICK: 'camera_year_click',
} as const;

export type DataType = {
  [IDOL.IDOL_OPEN]: {
    name: string;
  };
  [IDOL.IDOL_CATEGORY_CLICK]: {
    category: string;
  };
  [IDOL.IDOL_YEAR_CLICK]: {
    year: string;
  };
  [CAMERA.CAMERA_YEAR_CLICK]: {
    year: string;
  };

  [key: string]: unknown;
};

export type EventNameTypes = ValueOf<typeof COMMON & typeof IDOL & typeof CAMERA>;

이런 식으로 여러 페이지에서 사용하는 GA 관련된 상수들을 넣어놨다. 그리고 저것들은 다음과 같이 사용된다.

import { IDOL } from '@/constants/ga';

const IdolCard = (props: Props) => {
  const { idol, sort } = props;
  const { gaEvent } = useGA();
  const [opened, setOpened] = useRafState(false);
  const setCoverUrl = useSetAtom(coverIdolAtom);

  const onClickOpen = () => {
    setCoverUrl(idol.id);
    setOpened(true);
    setTimeout(() => {
      sort();
    }, 50);
    gaEvent(IDOL.IDOL_OPEN, { name }); // <--
  };

이런 식으로 여러 페이지에 GA 이벤트 걸 때 종종 땡겨올 수 있는 상수를 넣어두었다. 지금 생각해보니 src/constants 말고 src/types에 넣어놔도 될것 같이 생겼다. 내 경우엔 상수들을 src/types 안에 넣어두는 경우가 많아 자연스럽게 src/constants가 좀 비어보인다.

src/hooks

src/hooks에는 페이지 곳곳에서 사용되는 공통 훅을 넣어두었다. useGA나 useAlert 같은건 곳곳에서 버튼 누를 때 GA 이벤트를 쏘거나, 아니면 얼럿 창을 띄울 때 사용되기 때문에, 특정 container 안에 넣어두기에는 애매해서 이 곳에 모아두었다.

src/libs

src/libs에는 외부 라이브러리를 넣어두었다. 보통은 package.json으로 의존성 설치를 하기 때문에 쓸 일이 거의 없지만, 종종 많이 튜닝해서 쓴다던가 할 때 써야하는 경우가 있다. 내 경우에는 isotope-layout을 수정해서 쓰기 위해 이 안에 넣어두었다. 물론 해당 라이브러리를 포크 떠서 수정하는 것도 대안이 된다.

src/services

이 곳에는 각종 API 요청들이 들어가게 된다.

Untitled.pngUntitled.png

파일 하나 열어보면 이런 식으로 되어 있다.

// src/services/idols.ts

import { apiBe, apiFe } from '@/services';
import { Idol, IdolCore, YearDesc } from '@/types/idols.d';

export const crawlIdolData = () => apiFe('/idols/crawl');

export const getIdolsDataApi = () => apiBe<Idol[]>('/idols');

export const getIdolYearsDataApi = () => apiBe<YearDesc[]>('/idols/years');

export const postIdolDataApi = (newIdol: IdolCore) => apiBe.post('/idols', { newIdol });

export const editIdolDataApi = (idolId: string, newIdol: Partial<Idol>, changed: string[]) =>
  apiBe.patch(`/idols/${idolId}`, { newIdol, changed });

저기 보이는 apiFe, apiBe 같은건 그냥 axios 인스턴스다. 타겟이 프론트엔드 서버냐 백엔드 서버냐에 따라 달리 쓰기 위해 저렇게 나눠둔 것이다. 보면 아이돌 데이터를 GET 하는게 있고, POST 하거나 PATCH 하는 것이 있다. 이렇게 services에 API 요청을 전부 모아두면 나중에 컴포넌트들 안에서 사용할 때 간편하게 땡겨 쓸 수 있다. 그리고 뭔가 엔드포인트 주소가 변경되었을 때 편하게 수정할 수 있는건 덤이다.

// src/containers/idols/useIdols.ts

import { useQuery } from '@tanstack/react-query';

import { getIdolsDataApi } from '@/services/idols';

const useIdols = () => {
  const { data: idols = [], refetch: refetchIdols } = useQuery(['getIdolsDataApi'], () =>
    getIdolsDataApi().then((res) => res.data),
  );

  return {
    idols,
    refetchIdols,
  };
};

export default useIdols;

그리고 이런 식으로 가져와서 사용하게 된다. 상당히 편하므로 services에 API들을 다 몰아넣어두는걸 권장한다. 계란은 전부 한바구니에 담아야 제맛이지..

src/states

Untitled.pngUntitled.png

페이지 곳곳에서 사용될 state들을 모아두는 곳이다. 이 곳이 최대한 적어야 내 서비스의 유지보수성이 올라간다고 생각하면 될것이다. 나는 가능한한 전역 상태관리를 하지 않으려고 한다. 저기 유일하게 보이는 alert.ts의 내용물은 다음과 같다.

// src/states/alert.ts

import { ReactNode } from 'react';
import { atom } from 'jotai';

export interface Alert {
  title?: string;
  message: string | ReactNode;
  onConfirm?: () => void;
  onCancel?: () => void;
  confirmLabel?: string;
  cancelLabel?: string;
}

export const alertState = atom<Alert | undefined>(undefined);

음.. 별거 없다. 그리고 지금 생각해보니 저 alertState는 오직 useAlert.ts 안에서만 사용되고 있으므로 옮겨버려도 될것 같아 보인다. 내 경우에는 이렇게 극단적으로 전역 상태관리를 배제하는데 성공했다. 아마 실무에서 사용하다보면 전역이 꼭 필요한 경우가 있을 수 있다. 하지만 이게 꼭 전역으로 사용해야 하는지 곰곰히 생각해보고 쓰는게 좋을 것이다. 사용자 로그인 정보 (닉네임, 프로필사진 url 등등..)의 경우 react-query의 캐시에 담아서 사용하고 있기 때문에 상태관리가 따로 필요없더라. 다 형이 전역 상태관리 쓰다가 피똥싸보고 하는 말이다..

src/styles

스타일 시트 관련 공통 요소들을 모아두는 곳으로 사용하고 있다.

globals.scss

src/app/layout.tsx에서 임포트하여 전체에 적용하도록 하고 있다. 이 안에는 웹폰트 선언 파일, 주로 쓰는 컬러값, CSS 리셋이나 각종 상수 값들을 넣고 있다.

levels.scss

각종 z-index 값들을 다 모아놨다. z-index 값은 이렇게 따로 모아서 쓰는게 좋은데, z-index를 방만하게 관리하다보면 어느날 모달 쉐도우 위에 푸터가 떠 있다던가 하는 개떡같은 불상사를 겪을 수 있다.

variable.scss

온갖 종류의 CSS variable을 모아두었다.

:root {
  --color-white: #ffffff;
  --color-black: #000000;
  --color-black-rgb: 0, 0, 0;
  --color-gray-1: #111111;
  --color-gray-2: #232323;
  --color-gray-4: #4c4c4c;
  --color-gray-4-rgb: 76, 76, 76;
  --color-gray-7: #767676; /* Minimum WCAG AA Pass */
  --color-gray-8: #8c8c8c; /* Fail to WCAG AA */
  --color-gray-9: #999999;
  --color-gray-a: #aaaaaa;
  --color-gray-c: #cccccc;
  --color-gray-d: #dddddd;
  --color-gray-e: #eeeeee;
  --color-gray-f: #f6f6f6;

  --color-bg: #f6f6f6;
  --color-blue: #0071f0; /* Minimum WCAG AA Pass */
  --color-blue-dark: #0053b3;
  --color-red: #eb0d00; /* Minimum WCAG AA Pass */
  --color-red-rgb: 235, 13, 0;
  --color-orange: #ff9500;
  --color-yellow: #ffcc00;
  --color-green: #26873e; /* Minimum WCAG AA Pass */
  --color-teal: #5ac8fa;
  --color-indigo: #5856d6;
  --color-purple: #af52de;
  --color-pink: #ff2d55;

  --color-em: #ff59e3;

  /* Sizes */
  --w-idol-m: 132px;
  --w-idol: 140px;
  --h-idol: 220px;
  --h-nav: 44px;
  --h-footer: 32px;
  --x-scroll-aid: 52px;

이런 식으로 컬러와 수치 정보를 한곳에 다 모아두면, 나중에 아주 편해진다. 가령 디자인 팀에서 어느날 “우리 디자인 시스템 도입할거에요! 목 닦아두고 각오하세요!” 하면 “큭, 무적의 프론트엔드 팀은 당신이 입사하기 전부터 다 준비해두었다. 네가 방만하게 사용한 컬러값, #d6d6d6, #d4d4d4, #d4d4d3 같은건 여기 다 남아있지!” 하면 된다.

내 CSS 파일을 보면 알겠지만, 컬러값을 바로 박아두는건 엄금하고 오직 variable로만 넣고 있다.

.box {
  color: #d6d6d6; // Wrong
  color: var(--color-border); // Cool
}

나중에 디자이너 맘 바뀌었을 때 전체선택해서 하나하나 바꾸는 짓을 하는 수고가 매우 줄어든다. 책상은 더러워도 코드는 항상 깨끗하게 유지하자.

animations.scss

곳곳에서 사용하는 키프레임들을 다 모아두었다.

@keyframes loading {
  0% {
    transform: rotate(0deg);
  }

  100% {
    transform: rotate(360deg);
  }
}

보통 키프레임들은 css 파일의 맨 마지막에 들어가야 제대로 작동하기 때문에, 컴포넌트 CSS에서 관리하게 되면 좀 지저분해진다. @keyframes로 시작되는 코드들은 한곳에 모아놔야 나중에 똑같은 짓을 하는 코드가 8군데에서 중복 발견되는 문제를 사전에 막을 수 있다.

_reset.module.scss

여기는 에릭 마이어가 2011년에 만들어둔 CSS reset 내용을 넣어두는 곳이다. 다들 아시다시피 크롬, 사파리, 파이어폭스 등의 브라우저들은 각각 유저 에이전트 스타일시트를 따로 따로 가지고 있다. 같은 셀렉트박스만 해도 브라우저별로 모양이 다 다른게 그 이유다. 이걸 막기 위해 CSS reset을 사용해서 모든 CSS 기본값들을 다 초기화하는거다. 물론 지금은 2023년이라 저게 생긴지 12년이 지난 상태. 나는 그래서 저거 이상으로 더 리셋이나 초기값이 필요한 부분의 경우 _base.module.scss를 사용한다.

_base.module.scss

위 reset에 더해 좀 더 튜닝하고 싶은 내용들을 여기 넣는다. 가령 나는 <a> 태그에 마우스를 hover 하면 항상 밑줄이 그어지게 하고 싶다거나, 아니면 <button> 이 disabled일 경우 cursor: restricted로 하고 싶을 경우 여기를 수정한다.

src/styles/mixins

여기는 각종 SCSS 믹스인을 모아두는 곳이다. 예를 들면 이렇다.

.closeButton {
  position: absolute;
  top: 14px;
  right: 14px;
  bottom: auto;
  left: auto;
}

.closeButton {
  @include m.absolute(14px 14px auto auto);
}

.pictureLabel {
  position: absolute;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
}

.pictureLabel {
  @include m.middleBox;
}

.box {
  display: flex;
  align-items: center;
  justify-content: center;
}

.box {
  @include m.flexbox;
}

.box2 {
  display: flex;
  align-items: center;
  justify-content: start;
}

.box2 {
  @include m.flexbox(start, center);
}

.container {
  .head {
    margin-top: 14px;

    @media only screen and (max-width: 768px) {
		  margin-top: 24px;
    }

		@media only screen and (max-width: 1200px) {
		  margin-top: 32px;
    }
  }

	.head {
		margin-top: 14px;

		@include after(SD) {
			margin-top: 24px;
		}

		@include after(1200px) {
			margin-top: 32px;
		}
	}
}

스타일이야 너희 팀이 SCSS가 아니라 Emotion을 쓴다던가 아니면 혐오스러운 Tailwind를 쓴다던가 식으로 달라질 수 있으니 패스하자.

src/types

각종 타입스크립트 타입 정의가 들어가는 곳이다. 지금으로부터 대략 7년 전, 내가 면접을 볼 때 면접관에게 이 말을 들은 적이 있다. “아직도 타입스크립트를 안쓴다구요? 그러고도 프론트엔드 개발자라고 말할 수 있나요?” 그때 자존심이 팍삭 상하는 충격을 받고 타입스크립트를 쓰기 시작한 이래로, 나는 주위에 타입스크립트를 널리 권하고 있다.

사실 위에서 ESlint 안쓰는 놈은 서류 탈락이라 적었지만, TS의 경우에도 요즘은 필수 요소중에 하나다. 실제로 나는 입사 지원자들의 깃허브에 들어가서 TS가 없는 사람들을 다 탈락시킨 적이 있다. 아 무슨 6년차 개발잔데 졸업을 작년에 했어.. 그리고 2주 전에 짠 TodoList가 올라와있더라고. 그것도 JS로.

TS가 처음에는 전부 다 any로 처발라버리고 싶은 분노를 느끼게 하지만, 한번 정의가 단단하게 잘 되어있으면 리팩토링을 할 때 이렇게 소중하고 아름다울 수 없는 놈이다. 시간을 들여 배울 가치가 있다. 아니 배워야한다.. 요즘 회사들 Job Description을 보면 백이면 구십구는 TS 사용자를 원한다.

TS의 좋은점, 간단한 예 하나를 들면, 서버 개발자놈이 API 리턴값을 바꿨다 치자.

[{
	name: '김꿀꿀',
	category: '인간',
	mealCount: 69_112_119,
}]

// 원래 이렇게 왔는데

[{
	name: '김꿀꿀',
	category: '인간',
	count: {
		meal: 69_112_119,
		poop: 12_345_678,
  },
}]

// 이렇게 바꿨다고 한다.

그럼 나는 mealCount를 전체 검색해서 count.meal로 바꾸겠지. 어, 근데 백엔드 개발자가 마음이 또 바뀌었어.

[{
  name: '김꿀꿀',
  category: '인간',
  mealCount: 69_112_119,
}]

// 원래 이렇게 왔는데

[{
  name: '김꿀꿀',
  category: '인간',
  count: {
    meal: 69_112_119,
    poop: 12_345_678,
    ...
  },
}]

// 이렇게 바꿨다고 한다.

[{
  uid: '김꿀꿀의id',
  name: '김꿀꿀',
  category: '인간',
}]

[{
  uid: '김꿀꿀의id',
  meal: 69_112_119,
  poop: 12_345_678,
  ...
}, {
  uid: '이꿀꿀의id',
  meal: 69_112_119,
  poop: 12_345_678,
  ...
}, {
  uid: '최꿀꿀의id',
  meal: 69_112_119,
  poop: 12_345_678,
  ...
}]

// 분리!

생각해보니까 카운트들이랑 사용자 정보랑 따로 분리하는게 좋으니 이렇게 하자는거야.. 자 그럼 우린 어떻게 해야해, count 찾아서 바꿔야 하잖아? count로 전체검색 때리니까 오만 잡놈의 count들이 다 나오네? (feat. node_modules) 그럼 어쩔거니? 다 하나하나 눈으로 보고 판단해서 해야 할거 아냐. 이거 진짜 빡쎄다구. 그리고 내가 작업하면서도 나를 믿을 수가 없어. 나 한명의, 그리고 팀원의 안목을 믿고 수정해야한다. 그리고 실제로 배포했을 때 작살이 안난다는 보장이 없고..

근데 만약 저 김꿀꿀이 어쩌고 정보를 타입스크립트로 타이핑 해두었다면 문제가 되는 부분이 있으면 애초에 화면에 경고가 빡 떠버린다. 지금 보고 있는 화면이 아니라면 yarn build 단계에서 실패해버리고. 그렇기 때문에 타입 정의가 잘 된 부분은 자질구레하게 오타 같은걸 신경쓰지 않고 매우 쾌적하게 리팩토링할 수 있다. 나중에는 타입스크립트가 없으면 어떻게 내가 개발을 해왔나 궁금해하며 회상할 시간도 올 것이다. 재빨리 배우고, 주위에 원래 알았던 척 해라. 제네릭 사용법 정도 까지만 배워두면 된다.

여튼 나는 저런 타입스크립트 정의 파일을 src/types에 다 모아두고 사용하고 있다.

import { TimeStamp, WithAuthor } from '@/types';

export type Category = 'total' | 'mixed-group' | 'girl-group' | 'boy-group' | 'girl-solo' | 'boy-solo';

export const CATEGORIES: Category[] = ['total', 'mixed-group', 'girl-group', 'boy-group', 'girl-solo', 'boy-solo'];

export interface IdolCore {
  name: string;
  category: Category;
  debutYear: string;
  endYear?: string;
  descMelon?: string;
  descNamu?: string;
  descVibe?: string;
  descTitle?: string;
  youtubeStartsAt?: number;
  youtubeUrl?: string;
}

export interface Idol extends IdolCore, WithAuthor, TimeStamp {
  id: string;
  comments: {
    id: string; // 카운트용
  }[];
  likes: {
    id: string; // 카운트용
  }[];
}

export interface YearDesc {
  year: number;
  desc: string;
}

// Crawl =======================================================================

export type CrawlIdol = {
  name: string;
  url: string;
};

export type CrawlGeneration = Record<string, CrawlIdol[]>;

이런 식으로..

src/utils

Untitled.pngUntitled.png

이 곳에는 곳곳에서 사용되는 유틸성 함수들을 넣어서 관리한다. 예를 들어 date.ts 파일을 보자.

import dayjs from 'dayjs';
import 'dayjs/locale/ko';

export const getTSBefore = (value: number, unit: 'hour' | 'day' | 'week' | 'year') => {
  return dayjs().subtract(value, unit).toDate();
};

dayjs.locale('ko');

export const getTimeDiff = (_date?: dayjs.ConfigType) => {
  const now = dayjs();
  const date = typeof _date === 'number' ? dayjs.unix(_date) : dayjs(_date);

  const diff = {
    second: now.diff(date, 'second'),
    minute: now.diff(date, 'minute'),
    hour: now.diff(date, 'hour'),
    day: now.diff(date, 'day'),
    week: now.diff(date, 'week'),
    month: now.diff(date, 'month'),
    year: now.diff(date, 'year'),
  };

  return diff;
};

export const getTimeDiffText = (_date?: dayjs.ConfigType, preserveDay?: boolean) => {
  const diff = getTimeDiff(_date);
  const date = typeof _date === 'number' ? dayjs.unix(_date) : dayjs(_date);
  switch (true) {
    case diff.year > 0:
      return preserveDay ? dayjs(date).format('YYYY.MM.DD') : `${diff.year}년 전`;
    case diff.month > 0:
      return preserveDay ? dayjs(date).format('YYYY.MM.DD') : `${diff.month}달 전`;
    case diff.week > 0:
      return preserveDay ? dayjs(date).format('YYYY.MM.DD') : `${diff.week}주 전`;
    case diff.day > 0:
      return preserveDay ? dayjs(date).format('YYYY.MM.DD') : `${diff.day}일 전`;
    case diff.hour > 0:
      return `${diff.hour}시간 전`;
    case diff.minute > 0:
      return `${diff.minute}분 전`;
    default:
      return '방금 전';
  }
};

export { dayjs };

뭐 이렇게 만들어두고, 나중에 서버에서 오는 createdAt 같은 것들을 getTimeDiffText(createdAt) 이렇게 넣어주면 자동으로 방금 전, 1시간 전 등등으로 깔끔하게 보여주는거다. 물론 곳곳에서 쓰이지 않고 특정 장소에서만 쓰이는 유틸들은 나는 그 근처에 utils.ts를 만들어두고 쓰는걸 선호한다. 말했지, 손 닿는 곳에 두고 쓰자고.

맺음말

이상 내가 사용하고 있는 Next.js 폴더 구조에 대해 이야기해보았다. 혹시 본인의 경험에 따라 이건 이래야 한다, 저건 저래야 한다 댓글 달아주면 전혀 고깝게 듣지 않고 매우 감사히 여길것이다. 내가 어그로를 끌기 위해 말을 단정적으로 할 뿐이지 내가 짠 코드 한달 후에 보면 쓰레기도 이런 쓰레기가 없거든. 우린 항상 발전해야한다. 그리고 꼴받는 댓글 달아서 내가 자극 받으면 나에게 좋은거고. 키배 뜨면 공부 되어서 좋은거고.

여튼 주니어들 질문 올라오는 것 중에 꽤 많은게 폴더 구조 문의인데, 이 글에서 나는 이렇다- 정도로 적고 넘어가볼까 한다. 폴더 구조는 정해진게 없으니까, 내건 참고만 하고 각각의 프로젝트 사정에 맞게 튜닝해서 사용하자. 다들 이제 그 정도 짬은 되잖아? 장광설 들어줘서 고맙다. 그리고 린터 꼭 써라 시발 안쓰면 집 문앞에 콘크리트 부어서 못나오게 할거다. 린터 안쓰는 놈은 솔까 세금 더 걷어야한다. 호흡세, 방귀세, 민폐세.. 아오 내가 이렇게 말 처 해도 오지게 안쓰지 린터..

Untitled.pngUntitled.png

린터 안쓰는 놈들은 나의 적이다.