스위터 회고록

한 달 간의 팀 프로젝트의 경험 정리하기

thumbnail

프로젝트는 끝난지 세 달이나 지났는데 이제서야 작성하는 회고입니다. 늦은 회고라 기억나지 않는 내용들이 많아 아쉬운 것들이 많은 것 같습니다. 앞으로 할 프로젝트에서는 기록을 성실히 하여 놓치지 않고 기록해야겠다는 생각이 들기도 합니다. 그럼 기억을 다시 되살려가면서 경험들을 정리해보도록 하겠습니다.

구체적인 프로젝트 내용이 궁금하신 분들은 깃허브 레포지토리를 통해 확인해주시면 감사하겠습니다.


동기

계기는 연락하던 지인분께서 개인 프로젝트를 하신다는 얘기를 듣고 기획 내용을 공유받아 시작하게 되었습니다. 프로젝트 내용은 모바일 게임 전략을 공유하기 위한 간단한 커뮤니티 서비스였고 서비스를 사용할 대상이 있다는 점과 프로젝트를 완성한 경험이 없다는 아쉬움이 있어 온전히 하나를 만들어보자는 마음으로 시작하게 되었습니다.

기획 내용은 어느 정도 구체화가 되어 있던 상황이라 크게 논의할 내용은 없었고 기능 구현을 위한 기술 스택을 협의를 통해 선정했습니다. 이전에도 프로젝트를 함께 진행했던 분이라 초기 설정도 신속하게 진행할 수 있었습니다.


목표 설정

어느 정도의 기획이 완성된 이후에 구체적인 목표를 설정하고 팀원과 공유하는 과정을 거쳤습니다. 개인적인 목표를 굳이 공유할 필요는 없을 수 있지만 이렇게 서로의 목표를 공유하는 것은 여러가지 장점이 있다고 생각했습니다. 같은 목표를 갖고 있다면 이후의 커뮤니케이션에서 동일한 목표라는 합의를 갖고 소통할 수 있기에 불필요한 고민도 줄일 수 있고 공동 목표를 설정하는 과정에서 프로젝트 방향성을 더 명확하게 할 수 있기 때문입니다.

공통된 주요 목적은 학습 목적이었고 이를 기준으로 세부 목적을 다음과 같이 설정해봤습니다.

  • Next.js App Router(Next.js 13) 학습
    • app 디렉토리 적극적으로 활용
    • 별도의 백엔드 서버 없이 백엔드 기능 구축
  • 다양한 기술 스택 학습
    • Planet Scale, Prisma 등
  • 기한 내 서비스 완성하기

학습 목적의 세부 목표 두 가지와 기한이 정해져있던 프로젝트라 일정 내 서비스를 완료하겠다는 세부 목표 한 가지를 추가했습니다. 프로젝트 규모에 비해 부족한 일정은 아니었지만 기한을 신경쓰지 않고 여유롭게 진행하다 보면 긴장감이 없어지고 열정이 식을 수도 있다는 이전의 경험을 기반으로 철저히 일정을 산정하여 진행하도록 하였습니다. 이번 프로젝트의 동기였던 프로젝트 완성도 중요했기 때문입니다. 무엇보다도 서비스를 사용할 대상이 있다는 점도 그랬을 겁니다.


간략한 기능 설명

간략한 기능 설명에 앞서 대상으로 하는 모바일 게임에선 3마리의 몬스터로 구성된 이라는 단위로 유저 간 대항전을 진행할 수 있고 상대방의 공격을 막아내는 덱을 방어덱, 상대방의 덱을 공격하기 위한 덱을 공격덱이라고 함을 알고 가시면 좋습니다. 서비스 주요 기능은 다음과 같이 요약할 수 있습니다.

  • 유저는 방어덱을 추가할 수 있고 해당 덱에 효율적인 공격덱을 댓글 형식으로 작성할 수 있다.
  • 유저의 권한은 일반, 관리자로 나뉘며 관리자 유저는 일반 유저의 가입 승인을 할 수 있다.
  • 관리자 유저는 덱에 추가할 수 있는 몬스터 관리를 할 수 있다.

저희는 이를 위해 일반적인 커뮤니티 서비스에서 활용하는 피드와 댓글 구조를 활용하여 하나의 방어덱(피드에 해당)에 여러 개의 공격덱(댓글에 해당)을 작성할 수 있는 형식으로 기능을 구상했습니다.


고민한 부분

앞에서 언급한 기능 구현 부분을 포함하여 해당 프로젝트 개발을 진행하면서 고민했던 부분들과 그 결과로 반영된 의도들을 코드베이스와 함께 확인해보도록 하겠습니다.

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

저희는 회원가입 승인 여부에 따라 접근 가능한 영역을 디렉토리 구조에 보여질 수 있도록 하고 싶었습니다. 예를 들어 디렉토리 구조를 확인했을 때 특정 라우트에선 가입 승인이 되지 않은 유저가 접근이 가능하지 않다는 것을 알 수 있게 말이죠.

그렇지만 다들 아시다시피 Next.js에선 디렉토리 구조에 기반하여 라우트 구조가 형성되고 임의로 기획 내용을 디렉토리 구조에 반영하기엔 불필요한 URL 구조가 형성되고 보여지고 싶지 않은 정보를 URL에 보여지는 것에 거부감이 있었습니다.

이를 해결하기 위해 Next.js 13 버전부터는 URL 경로 구조에 영향을 주지 않는 라우트 그룹(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 워크플로우 작성 가이드 라인은 다음 링크를 참고해주세요.

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

서버 컴포넌트를 활용하겠다고 마음 먹었는데 어디까지 활용해야 할지 감을 잡아야만 했습니다. 공식 문서에서 설명하는 서버 렌더링의 장점들은 다음과 같습니다.

  • 데이터 페칭을 데이터에 더 가까운 서버에서 진행할 수 있습니다.
  • 토큰 및 API 키 같은 민감한 데이터를 클라이언트에 노출할 필요가 없습니다.
  • 결과를 캐시하기에 렌더링 및 데이터 페칭 양이 줄어들어 비용이 절감됩니다.
  • 클라이언트 사이드의 번들 사이즈가 줄어든다.

이외에도 여러 이점이 있으며 더 자세한 내용은 공식 문서를 참고해주시면 감사하겠습니다.

대략적으로 서버 측에서 렌더링을 진행하기 때문에 클라이언트 측 데이터 요청보다 데이터 접근성이 좋고 보안이 좋다는 장점을 확인할 수 있었습니다. 기본적으로 app 디렉토리 내에서 작성되는 컴포넌트들은 서버 컴포넌트로서 활용되기에 클라이언트 컴포넌트로 렌더링해야 하는 경우가 아니면 최대한 서버 컴포넌트로 활용하도록 진행했습니다. 클라이언트 컴포넌트를 사용하는 경우는 공식 문서에서 몇 가지 케이스 체크를 통해 제공되고 있습니다.

이렇게 정하고 나니 컴포넌트 분리 기준도 명확해졌습니다. 사실 컴포넌트 분리를 어디까지 해야할 것인가라는 부분이 꽤 많은 논의가 필요한 부분일 수 있는데 1차적으로 클라이언트 컴포넌트를 격리해야 한다는 조건이 있으니 컴포넌트로의 분리도 수월해졌다 싶었습니다.

추가적으로 레이아웃 컴포넌트(layout.tsx)는 되도록이면 서버 컴포넌트로 유지하도록 규약을 정했습니다. 아무래도 레이아웃 컴포넌트는 대체로 UI 구성에 있어 가장 우선적으로 렌더링 되어야 UX적으로 좋다고 생각했고 이벤트 핸들러나 브라우저 API를 비교적 적게 활용할 수 있는 영역이라 생각하여 이와 같이 지정하였습니다.


트러블 슈팅

프로젝트를 진행하며 몇 가지 문제해결 과정이 있었는데 시간이 좀 지나 모든 내용이 생각나진 않지만 몇 가지 적어보며 해결 과정과 결과를 작성해보도록 하겠습니다.

Prisma 인스턴스 중복 생성

Next.js와 Prisma를 함께 사용하다 보면 다음과 같은 에러를 마주할 수 있습니다.

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

예상과 다르게 10개 이상의 Prisma 인스턴스가 생성되었다는 에러입니다. 공식 문서에서 해당 오류의 원인을 다음과 같이 설명하고 있습니다.

In development, the command next dev clears Node.js cache on run. This in turn initializes a new PrismaClient instance each time due to hot reloading that creates a connection to the database. This can quickly exhaust the database connections as each PrismaClient instance holds its own connection pool.

번역하자면 개발 중 next dev 명령이 실행되는 동안 Node.js 캐시를 지우는데 그러면 데이터베이스 연결을 생성하는 핫 리로드로 인해 매번 새로운 PrismaClient 를 생성한다는 것입니다. 또한 이어지는 내용에서 해당 문제에 대한 솔루션을 제공하고 있습니다.

해결 방법은 globalThis 객체에 prisma 가 존재하는지 여부로 인스턴스를 생성하도록 분기를 작성하는 방식입니다. 개발 중(next dev 명령어를 사용하는 경우)에만 해당 오류가 발생하므로 환경에 따라 globalThis 객체로의 할당문을 분기합니다.

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 스키마를 수정하다가 실수로 모든 데이터베이스가 초기화 되는 문제가 생겼습니다. 터미널에서 경고도 반환했으나 이를 정확히 인지하지 못한 저의 잘못이었습니다. 몬스터 데이터가 꽤나 많기도 했고 이를 수작업으로 하나씩 채웠기에 다시금 해당 데이터를 채우는 대참사는 없어야만 했습니다.

/images/posts/switter-review-messed-up.jpg

일단 이전 백업 데이터를 확보할 수 있는지 여부를 확인했습니다. 확인해본 결과 Planet Scale은 자동 백업을 무료로 지원하며 모든 데이터베이스 브랜치를 매일 백업하며 보존 기간은 2일이라고 합니다. 이를 로컬로 다운받을 수 있기에 JSON 형식으로 다운로드 받아 이를 활용해보고자 했습니다.

그러다 개발 중 필요한 초기 더미 데이터를 생성하기 위해 활용하는 Prisma의 Seeding 기능을 알게 되었습니다. 미리 추출해둔 JSON 파일이 있으니 JSON 파일을 읽어 Seeding 기능을 통해 데이터 생성을 진행하면 되지 않을까라는 생각으로 연결되며 이를 실행해보고자 바로 Seeding 적용을 진행했습니다.

우선 package.json 파일에 "prisma" 키를 가진 객체에 명령어를 추가합니다. 아래와 같이 추가하면 npx prisma db seed 명령어를 통해 Seeing을 진행할 수 있습니다.

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

이후 Seeding을 위한 스크립트를 작성해줍니다. Seeding 스크립트는 Prisma 인스턴스에서 사용할 수 있는 클라이언트 API를 통해 자유롭게 작성할 수 있습니다.

아래에선 예시로 JSON 파일을 읽어 데이터를 추가하는 방식으로 진행했지만 faker.js 와 같은 라이브러리를 통해 임의의 더미 데이터를 생성할 수도 있습니다.

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에 있었고 몇 가지 명령어를 실행하고 package.json 을 일부 수정하여 이를 해결했습니다.

# ts-node 및 타입 글로벌 설치
yarn add -D ts-node @types/node
"prisma": {
    "seed": "ts-node --compiler-options {\"module\":\"CommonJS\"} prisma/seed.ts"
}

다행히도 JSON 파일을 정상적으로 읽을 수 있었고 데이터베이스를 성공적으로 복구할 수 있었습니다.

/images/posts/switter-review-clear.jpg

이후 추가적으로 별도의 데이터베이스를 구축해야 하는 경우가 발생했었는데 그때도 같은 데이터를 생성하기 위한 방법으로 해당 Seeding 세팅을 활용하여 원활하게 진행하여 큰 도움이 되었던 경험이었습니다.


후기

이후 해당 서비스를 대항전에서 활용하셨다는 소식을 듣고 꽤나 기분이 좋았습니다. 대항전 이후엔 목적이 애매해져 리팩토링이나 추가 개발이 진행되기 애매한 상황에 놓여 그대로 방치된건 아쉽지만 그래도 좋은 경험이었습니다.

하나의 프로젝트를 온전히 완성해보는 것이 처음이었기에 이번 계기를 통해 초기 MVP 기획 설정이 얼마나 중요한지 다시 한 번 깨닫게 되었습니다. MVP를 설정할 때 어떤 기능들이 기획 상에서 필수로 필요한 기능이며 비즈니스가 있다면 비즈니스 모델을 실행하기 위한 최소 요건이 무엇인지 고민해야 리소스를 줄이면서 빠른 피봇을 진행하거나 서비스를 개발할 수 있음을 깨달았던 것 같습니다.

추가적으로 백엔드 팀의 고민도 간접적으로나마 느껴본 계기가 되었습니다. API 작성을 하면서 어떤 고민을 하는지, DB 구조를 만들 때 어떤 사항을 고려할 지와 같은 내용들을 고민해보고 실제로 백엔드 팀과 어떻게 스키마를 작성했는지 소통하는 계기도 되었던 것 같습니다. 확실히 몸으로 직접 배울 때 가장 잘 이해할 수 있는 것 같습니다.

그리고 무엇보다도 미루던 Next.js 13 버전을 실습 형식으로 학습할 수 있었다는 것이 너무 좋았습니다. 이론식 학습도 중요하지만 실제 프로젝트로서 구현하면서 좋았던 점과 아쉬웠던 점을 직접 느끼면서 이를 이해하는 것도 중요하다는 생각이 다시금 들었습니다.

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