ETC
  • Review
  • Project

스위터 회고록

2023년 12월 17일

프로젝트는 끝난 지 세 달이나 지났는데 이제서야 쓰는 회고다. 늦게 쓰다 보니 기억이 나지 않는 내용이 많아서 아쉬움이 크다. 다음 프로젝트에서는 기록을 더 성실히 해두고, 놓치지 않고 남겨야겠다는 생각도 들었다. 그럼 기억을 다시 되살려가면서 경험들을 정리해보겠다.

구체적인 프로젝트 내용이 궁금한 분들은 깃허브 레포지토리에서 확인해주면 감사하겠다.

동기

계기는 연락하던 지인분이 개인 프로젝트를 한다는 얘기를 해주셨고, 기획 내용을 공유받으면서 시작하게 됐다. 프로젝트는 모바일 게임 전략을 공유하기 위한 간단한 커뮤니티 서비스였고, 서비스를 사용할 대상이 있다는 점이 동기부여가 됐다.

개인적으로는 “프로젝트를 완성해본 경험이 없다”는 아쉬움도 컸다. 이번에는 온전히 하나를 만들어보자는 마음이 더 강했다.

기획은 어느 정도 구체화돼 있었고, 크게 논의할 부분은 많지 않았다. 기능 구현을 위한 기술 스택은 협의를 통해 정했고, 이전에도 같이 프로젝트를 했던 분이라 초기 설정도 비교적 빠르게 진행할 수 있었다.

목표 설정

기획이 어느 정도 완성된 뒤에는 구체적인 목표를 설정하고 팀원과 공유하는 과정을 거쳤다. 개인적인 목표를 굳이 공유할 필요가 없다고 느낄 수도 있지만, 서로 목표를 공유하는 과정이 꽤 의미 있었던 것 같다.

같은 목표를 갖고 있다면 이후 커뮤니케이션에서 동일한 목표라는 합의를 바탕으로 소통할 수 있어서 불필요한 고민이 줄어들고, 공동 목표를 정하는 과정 자체가 프로젝트 방향성을 더 명확하게 만들어주기 때문이다.

공통된 주요 목적은 학습 목적이었고, 이를 기준으로 세부 목표를 다음과 같이 정리했다.

학습 목적의 세부 목표 두 가지에 더해, 기한이 정해져 있는 프로젝트였기 때문에 “일정 내 서비스를 완료한다”는 목표를 추가했다. 프로젝트 규모에 비해 일정이 무리한 편은 아니었지만, 여유롭게 진행하다 보면 긴장감이 느슨해지고 열정이 식을 수 있다는 경험이 있었기 때문이다.

무엇보다 이번 프로젝트의 핵심 동기 중 하나가 프로젝트 완성이었고, 서비스를 사용할 대상이 있다는 점도 “완성”의 중요도를 더 올려줬다.

간략한 기능 설명

기능 설명에 앞서, 대상이 되는 모바일 게임에서는 3마리의 몬스터로 구성된 단위로 유저 간 대항전을 진행한다. 상대 공격을 막는 덱을 방어덱, 상대 덱을 공격하기 위한 덱을 공격덱이라 부른다. 이 전제를 알고 보면 기능 구조가 조금 더 직관적이다.

서비스 주요 기능은 아래와 같다.

이를 위해 일반적인 커뮤니티 서비스에서 흔히 쓰는 피드-댓글 구조를 채택했다. 하나의 방어덱이 피드에 해당하고, 그 아래 여러 공격덱이 댓글로 쌓이는 형태다.

고민한 부분

기능 구현을 포함해 프로젝트를 진행하면서 고민했던 지점들과, 그 결과로 반영한 의도를 코드베이스와 함께 정리해보겠다.

디렉토리 구조에 기획 내용 반영하기

우리는 회원가입 승인 여부에 따라 접근 가능한 영역을 디렉토리 구조만 봐도 드러나게 만들고 싶었다. 예를 들어, 폴더 구조를 보면 “여긴 승인 전 유저가 접근하는 영역이구나” 같은 걸 바로 알 수 있게 하는 식이다.

근데 Next.js는 디렉토리 구조가 곧 라우트 구조가 되다 보니, 기획 내용을 그대로 폴더에 반영하면 불필요한 URL 구조가 생기거나, 보여주고 싶지 않은 정보가 URL에 노출되는 문제가 생길 수 있었다.

이걸 해결하기 위해 Next.js 13에서 제공하는 라우트 그룹(Route Group)을 활용했다.

# /app 디렉토리 구조
├── (account) # 가입 승인이 되지 않은 상태의 라우트 경로에 해당
├── layout.tsx # (account) 하위 디렉토리들은 해당 레이아웃을 포함
...
├── (auth) # 가입 승인이 된 상태의 라우트 경로에 해당
├── layout.tsx # (auth) 하위 디렉토리들은 해당 레이아웃을 포함

라우트 그룹은 소괄호(())로 감싸서 표현할 수 있고, 그룹 내부에 디렉토리를 추가해도 URL에는 반영되지 않는다. 예를 들어 /(account)/sign-up 페이지는 URL에서는 /sign-up로 노출된다.

이 구조 덕분에 협업하면서도 가입 승인 여부에 따른 라우팅 구성을 직관적으로 파악할 수 있었고, 디렉토리 관리도 편했다. 또한 그룹별로 layout.tsx를 두면 공통 레이아웃 적용이나 로그인/승인 상태에 따른 리다이렉트 처리도 깔끔하게 구현할 수 있었다.

// /(auth)/layout.tsx
export default async function AuthLayout({ children }: AuthLayoutProps) {
const account = await getServerAccount();
const isAuthorized = account?.user.status === userStatus.Enum.ACTIVE;
// 가입 승인이 되지 않은 상태이면 /waiting 경로로 리다이렉트 합니다.
if (!isAuthorized) {
redirect(pageRoute.Waiting);
}
// (auth) 라우트 그룹에 속해 있는 하위 디렉토리는 해당 레이아웃을 포함합니다.
return (
<div className="relative flex h-full flex-col">
<RootPusher role={account.user.role} />
<SiteHeader />
<RequestDialog />
<main className="h-full">{children}</main>
</div>
);
}

추가로 특정 도메인과 연관된 파일들은 도메인 폴더마다 동일한 구조로 관리하도록 컨벤션을 잡았다. 예시는 다음 링크에서 확인할 수 있다.

# 예시) sign-in
├── (account)
├── sign-in # sign-in 도메인 폴더
│ ├── page.tsx # 페이지 컴포넌트
│ └── src # sign-in 도메인 파일 디렉토리
│ ├── ui # sign-in 도메인 UI 모음
│ │ └── sign-in-form.tsx
│ └── hook # sign-in 도메인 Hook 모음

배포 자동화 설정하기

개발을 시작하기 전에 배포 방식도 먼저 정했다. 배포는 Vercel을 사용했고, 브랜치 전략에 맞춰 개발 서버와 라이브 서버 배포를 위한 각각의 GitHub Action 워크플로우를 작성했다.

혹시 브랜치 전략이 궁금하다면 'Git flow, 왜 적용했나요?'를 참고해줘.

GitHub Action을 선택한 이유는 가장 익숙한 방식이기도 했고, 특정 확장자 파일이 업데이트 되는 경우만 배포 트리거로 설정하는 등 세밀한 조정이 가능했기 때문이다. (Vercel에서도 가능한지 여부는 당시 확인하지 못했다. 혹시 아는 분이 있다면 댓글 남겨주면 감사하겠다.)

on:
push:
paths:
- "**/*.tsx"
paths-ignore:
- "**/*.md"

Vercel에서 제공하는 GitHub Action 가이드는 다음 링크를 참고해줘.

서버 컴포넌트 활용은 어디까지?

Next.js 13(App Router)을 쓰기로 마음먹으면서 “서버 컴포넌트를 어디까지 쓰는 게 맞을까?”라는 고민이 생겼다. 공식 문서가 말하는 서버 렌더링의 장점은 대략 이런 느낌이다.

더 자세한 내용은 공식 문서를 참고해줘.

우리는 기본적으로 app 디렉토리에서 작성되는 컴포넌트는 서버 컴포넌트로 동작한다는 전제를 깔고, 클라이언트 컴포넌트가 꼭 필요한 경우에만 use client를 붙이자는 방향으로 합의했다.

이렇게 정하고 나니 컴포넌트 분리 기준도 꽤 명확해졌다. “분리를 어디까지 해야 하냐”는 게 원래 논의가 길어질 수 있는데, “클라이언트 컴포넌트를 격리해야 한다”는 조건이 생기면서 분리의 기준점이 하나 생긴 느낌이었다.

추가로 layout.tsx는 되도록 서버 컴포넌트로 유지하자는 규약도 정했다. 레이아웃은 대체로 UI 구성에서 가장 먼저 렌더링되면 UX적으로 유리하다고 생각했고, 브라우저 API나 이벤트 핸들러가 비교적 덜 필요한 영역이라 서버 컴포넌트로 유지하기도 쉬웠다.

트러블 슈팅

진행하면서 몇 가지 문제 해결 과정이 있었는데, 시간이 지나서 다 기억나진 않는다. 그래도 인상 깊었던 것들을 몇 가지 정리해보겠다.

Prisma 인스턴스 중복 생성

Next.js에서 Prisma를 쓰다 보면 이런 경고를 마주할 수 있다.

warn(prisma-client) There are already 10 instances of Prisma Client actively running.

공식 문서에서는 개발 환경에서 next dev가 핫 리로드 과정에서 Prisma Client 인스턴스를 계속 새로 만들 수 있고, 그게 DB connection을 빠르게 고갈시킬 수 있다고 설명한다.

해결 방법은 흔히 알려진 방식대로, globalThis에 Prisma 인스턴스를 캐싱해서 재사용하도록 구성하는 방식이다. 개발 환경에서만 캐싱을 활성화하면 된다.

import { PrismaClient } from "@prisma/client";
const prismaClientSingleton = () => {
return new PrismaClient();
};
declare global {
var prisma: undefined | ReturnType<typeof prismaClientSingleton>;
}
const prisma = globalThis.prisma ?? prismaClientSingleton();
export default prisma;
if (process.env.NODE_ENV !== "production") globalThis.prisma = prisma;

몬스터 데이터 소실

DB 스키마를 수정하다가 실수로 DB가 초기화되는 사고가 있었다. 터미널에서 경고도 떴는데 제대로 인지하지 못한 내 잘못이었다. 몬스터 데이터가 꽤 많았고 수작업으로 채웠던 데이터라, 다시 다 입력하는 건 진짜 피하고 싶었다.

일단 백업이 가능한지 확인해봤고, PlanetScale이 자동 백업을 지원한다는 걸 알게 됐다. 매일 백업되고 보존 기간은 2일이었다. 로컬로 내려받아서 JSON 형태로 확보할 수 있었다.

이 데이터를 어떻게 복구할지 고민하다가, 개발 환경에서 초기 데이터를 채우는 용도로 사용할 수 있는 Prisma Seeding 기능을 알게 됐다. JSON이 이미 있으니 “이걸 seed로 넣으면 되지 않을까?”라는 결론으로 자연스럽게 이어졌다.

먼저 package.json에 seed 명령을 추가했다.

"prisma": {
"seed": "ts-node prisma/seed.ts"
}

그리고 seed 스크립트를 작성했다. 아래 예시는 JSON을 읽어 createMany로 넣는 방식이다.

import { readFileSync } from "fs";
import { PrismaClient } from "@prisma/client";
const prisma = new PrismaClient();
async function main() {
const monsterJsonFile = readFileSync("./prisma/monster.json", "utf8");
const monsterJson = JSON.parse(monsterJsonFile);
await prisma.monster.createMany({ data: monsterJson });
}
main();

근데 실행하자마자 이런 오류가 났다.

(node:34620) Warning: To load an ES module, set "type": "module" in the package.json or use the .mjs extension.

처음엔 import 문이 문제라고 생각해서 package.json"type": "module"을 추가해봤는데도 해결이 안 됐다. 그러다 stackoverflow에서 해결 방법을 찾았고, 몇 가지 패키지를 설치하고 seed 명령을 수정해서 해결했다.

yarn add -D ts-node @types/node
"prisma": {
"seed": "ts-node --compiler-options {\"module\":\"CommonJS\"} prisma/seed.ts"
}

결과적으로 JSON을 정상적으로 읽고 DB를 복구할 수 있었고, 그 이후에도 다른 DB를 새로 만들 때 동일한 seed 세팅을 재사용할 수 있어서 꽤 도움이 됐다.

이미지

후기

이후 실제로 서비스를 대항전에서 활용하셨다는 소식을 들었을 때 꽤 기분이 좋았다. 대항전 이후에는 목적이 애매해져서 리팩토링이나 추가 개발이 이어지기 어려웠고, 그대로 방치된 건 아쉽지만 그래도 좋은 경험이었다.

무엇보다 “하나의 프로젝트를 온전히 완성해보는 것”이 처음이었고, 이번 계기로 초기 MVP를 잘 잡는 게 얼마나 중요한지 다시 한 번 느꼈다. 어떤 기능이 필수인지, 그리고 비즈니스가 있다면 비즈니스 모델을 실행하기 위한 최소 요건이 뭔지를 제대로 고민해야 리소스를 줄이면서 빠르게 피봇하거나 서비스를 완성할 수 있다는 걸 체감했다.

추가로 백엔드 팀의 고민도 간접적으로나마 느껴본 계기가 됐다. API를 만들면서 어떤 고민을 하는지, DB 구조를 만들 때 무엇을 고려해야 하는지 같은 것들을 직접 고민해봤고, 실제로 백엔드와 스키마를 어떻게 맞춰가는지 소통해보는 경험도 할 수 있었다. 확실히 몸으로 겪어보면 이해가 훨씬 빠르다.

그리고 미뤄두던 Next.js 13을 실습 형식으로 학습할 수 있었던 것도 정말 좋았다. 이론식 학습도 중요하지만, 실제 프로젝트로 구현하면서 좋았던 점과 아쉬웠던 점을 직접 느끼는 과정도 꽤 중요하다는 생각이 다시 들었다.

그럼 이쯤에서 회고를 마무리하고, 다음 회고로 돌아오겠다.