본문 바로가기

프로젝트 구조 설계

회원가입과 로그인, 프로필 화면을 갖춘 풀 스택 애플리케이션을 만들기 위해 클라이언트와 서버, 공유 타입을 한 저장소의 세 패키지로 나누고, 각 패키지가 맡을 일과 의존하는 방향, 공유 패키지에 무엇을 둘지 정하는 기준을 설계합니다.

이 장에서는 지금까지 배운 것을 모아 실제로 동작하는 애플리케이션을 만듭니다. 사용자는 회원가입을 하고 로그인한 뒤 자기 정보를 볼 수 있고, 관리자는 사용자를 관리하는 API를 쓸 수 있습니다. 화면은 React와 Vite로, 서버는 NestJS와 TypeORM, PostgreSQL로 만들며, 마지막 두 절에서는 빌드와 배포, 운영을 다룹니다.

코드를 쓰기 전에 먼저 정할 것은 구조입니다. 어떤 코드를 어느 폴더에 두고, 어느 부분이 어느 부분에 의존할지를 정해 두어야, 기능을 더하거나 고칠 때 바꿔야 할 범위가 좁게 유지됩니다. 이 절은 저장소를 세 패키지로 나누고, 각 패키지가 맡을 일과 공유할 코드를 고르는 기준을 정합니다.

구조를 나누는 기준

구조를 나눌 때의 첫 기준은 관심사의 분리(separation of concerns)입니다. 화면을 그리는 코드, 업무 규칙을 적용하는 코드, 데이터베이스와 이야기하는 코드는 바뀌는 이유가 서로 다르므로, 다른 파일과 모듈에 둡니다. 그러면 화면의 모양을 바꿀 때 업무 규칙을 건드리지 않고, 데이터베이스를 바꿀 때 화면을 건드리지 않습니다.

두 번째 기준은 의존의 방향입니다. 화면은 서비스를 부르고, 서비스는 데이터베이스를 다루는 저장소(repository)를 부르지만, 그 반대 방향의 의존은 만들지 않습니다. 패키지 사이에서도 마찬가지로, 클라이언트와 서버는 서로의 내부 파일을 가져오지 않고 둘이 함께 쓰기로 약속한 공개 패키지에만 의존합니다.

세 번째 기준은 일관성입니다. 폴더와 파일의 이름, 모듈을 나누는 방식, 코드 스타일을 프로젝트 전체에서 같게 두면, 처음 보는 사람도 비슷한 기능이 어디에 있을지 짐작할 수 있습니다. 이름과 스타일은 ESLint나 Prettier 같은 도구로 검사해 사람의 기억에 맡기지 않습니다.

저장소의 구조

이 장의 애플리케이션은 하나의 저장소 안에 여러 패키지를 두는 모노레포(monorepo)로 만듭니다. 다음 트리에서 packages 아래의 세 폴더가 각각 하나의 패키지이며, 화면을 그리는 client, API를 제공하는 server, 둘이 함께 쓰는 타입을 두는 shared로 나뉩니다. 오른쪽의 설명은 각 폴더에 무엇을 두는지 적은 것입니다.

my-fullstack-app/
├── packages/
│   ├── client/                 React와 Vite로 만든 화면
│   │   ├── src/
│   │   │   ├── components/     재사용하는 UI 컴포넌트
│   │   │   ├── pages/          경로마다 보여 줄 화면
│   │   │   ├── services/       서버 API를 부르는 코드
│   │   │   ├── store/          여러 화면이 함께 쓰는 상태
│   │   │   ├── App.tsx
│   │   │   └── main.tsx
│   │   ├── package.json
│   │   ├── tsconfig.app.json
│   │   └── vite.config.ts
│   ├── server/                 NestJS로 만든 API 서버
│   │   ├── src/
│   │   │   ├── auth/           회원가입, 로그인, 토큰, 역할 검사
│   │   │   ├── user/           사용자의 엔티티, DTO, 서비스, 컨트롤러
│   │   │   ├── app.module.ts
│   │   │   └── main.ts
│   │   ├── package.json
│   │   └── tsconfig.json
│   └── shared/                 클라이언트와 서버가 함께 쓰는 공개 타입
│       └── src/
│           ├── interfaces/
│           │   └── user.ts
│           └── index.ts
├── .gitignore
└── README.md

client와 server는 각자 package.json과 잠금 파일을 가진 독립된 npm 프로젝트여서, 그 폴더에서 npm install과 npm run을 실행합니다. npm의 workspaces나 pnpm 같은 도구로 패키지들을 묶어 한 번에 설치할 수도 있지만, 이 장은 각 패키지를 따로 설치하고 실행해 설정을 단순하게 유지합니다. 버전은 패키지마다 고정하며, 서버의 버전은 다음 절에서 밝힙니다.

MONOREPO PUBLIC BOUNDARY

앱은 서로의 내부가 아니라 공개 패키지에 의존한다

본문의 packages/client·server·shared 구조를 책임 경계로 읽습니다. client와 server는 shared의 공개 wire 타입을 소비하고 서버 Entity는 server 내부에 둡니다. 공유 런타임 schema와 별도 utility package는 필요할 때 확장하는 설계입니다.

앱은 서로의 내부가 아니라 공개 패키지에 의존한다 프런트엔드와 백엔드 애플리케이션이 공개 wire schema와 utility 패키지에만 의존하고 서로의 내부를 참조하지 않는 모노레포 구조. Frontend appUI · client stateBackend apppolicy · persistenceWire schema공개 wire 계약Public utils필요할 때 추출소스 의존 방향
architecture 핵심 계약
  1. 두 소비자: client·server

    두 앱은 shared의 공개 계약을 각각 참조합니다. 이 목록은 실행 순서가 아닙니다.

  2. Shared wire 계약

    서버 Entity 대신 공개 payload 타입을 노출합니다. 타입만으로 JSON이 검증되지는 않으며 런타임 schema는 별도 구현합니다.

  3. 선택적인 Public utils

    여러 소비자가 같은 안정된 기능을 필요로 할 때만 추출합니다. 한 앱의 내부 변경은 해당 앱에 남깁니다.

  • 핵심 경계
  • 소스 의존

화살표는 소스 의존 방향입니다. 디렉터리 배치만으로 package exports·빌드 순서·접근 제한이 생기지 않으며 이를 별도로 설정해야 합니다.

그림의 화살표는 소스 코드가 의존하는 방향입니다. 클라이언트와 서버는 각자 shared의 공개 타입을 참조할 뿐 서로의 내부를 참조하지 않고, 서버의 데이터베이스 엔티티는 서버 안에만 둡니다. 다만 폴더를 이렇게 배치한다고 그 방향이 저절로 지켜지는 것은 아니며, 어디서 무엇을 가져올 수 있는지는 각 패키지의 설정과 코드 리뷰로 지켜야 합니다.

클라이언트 패키지

client는 사용자가 보는 화면을 맡습니다. components에는 버튼이나 입력 필드처럼 어느 화면에서든 다시 쓰는 컴포넌트를, pages에는 회원가입, 로그인, 프로필처럼 경로마다 보여 줄 화면을 둡니다. 화면 컴포넌트는 서버의 주소나 요청의 형식을 직접 알지 않고, services의 함수를 불러 데이터를 받습니다.

services는 서버 API를 부르는 코드를 한곳에 모아, 요청의 주소와 인증 헤더, 응답의 확인을 맡습니다. store에는 로그인한 사용자처럼 여러 화면이 함께 보는 상태를 Zustand 스토어로 둡니다. 한 화면에서만 쓰는 상태는 그 컴포넌트의 useState에 두고, 정말 여러 곳에서 필요할 때만 스토어로 올립니다.

서버 패키지

server는 NestJS의 규칙대로 기능마다 모듈을 둡니다. user 모듈은 사용자의 엔티티와 요청 DTO, 데이터베이스 작업을 하는 서비스, 관리자용 API의 컨트롤러를 담고, auth 모듈은 회원가입과 로그인, JWT 토큰의 발급과 검증, 역할을 검사하는 가드를 담습니다. app.module.ts는 이 모듈들과 데이터베이스 연결을 묶는 루트 모듈이고, main.ts는 서버를 시작하면서 전역 파이프와 CORS를 설정합니다.

서버 안에서도 의존의 방향을 지킵니다. 컨트롤러는 요청을 받아 서비스에 넘기고 결과를 공개 응답으로 바꿀 뿐 데이터베이스를 직접 다루지 않으며, 서비스는 HTTP 요청 객체를 모른 채 DTO를 받아 일합니다. auth 모듈은 사용자를 찾기 위해 user 모듈의 서비스를 쓰지만, user 모듈은 auth 모듈을 가져오지 않아 두 모듈이 서로를 가져오는 순환을 만들지 않습니다.

공유 패키지

shared에는 클라이언트와 서버가 HTTP로 주고받는 데이터의 모양만 둡니다. 공개 사용자 정보인 PublicUserDto, 회원가입과 로그인의 요청, 로그인 응답 같은 타입이 여기에 들어가며, 비밀번호 해시나 관리자 여부를 가진 서버의 User 엔티티는 들어가지 않습니다. 엔티티를 공유하면 클라이언트가 서버 내부의 필드와 TypeORM의 데코레이터까지 알게 되고, 서버가 저장 방식을 바꿀 때마다 클라이언트도 함께 바뀌어야 하기 때문입니다.

이 장의 shared는 타입만 내보내므로, 클라이언트는 import type으로 가져오고 tsconfig.app.json의 paths로 그 위치를 알려 줍니다. import type은 컴파일하면 사라지므로 실행 중에는 아무것도 가져오지 않으며, 그래서 shared를 빌드하거나 패키지로 설치할 필요가 없습니다. 반대로 타입은 실행 중에 서버의 응답을 검사하지 않으므로, 응답의 모양은 클라이언트의 services에서 따로 확인합니다.

코드를 어디에 둘까

새 코드를 만들 때마다 그 코드를 어느 패키지에 둘지 정해야 합니다. 두 곳에 비슷한 코드가 있다는 것만으로 공유 패키지로 옮기지는 않습니다.

PACKAGE PLACEMENT

소유자와 변경 결합도로 코드의 자리를 결정한다

단일 앱의 구현은 앱 내부에 둡니다. 소비자가 여럿이고 변경 소유자·공개 계약이 안정되면 공유 패키지를 검토합니다. 소유권이나 책임이 불명확하면 먼저 경계를 다시 나눕니다. 파일 중복만으로 추출을 결정하지 않습니다.

소유자와 변경 결합도로 코드의 자리를 결정한다 코드의 변경 소유자와 소비자 수, 공개 계약의 안정성에 따라 앱 내부 또는 공유 패키지로 배치하는 결정 흐름. 단일 공유 불명확 변경 이유 확인 owner · cadence 안정된 공유 계약? multiple consumers 앱 내부 private implementation 공개 패키지 versioned surface 소유자 재검토 split responsibility
flowchart 핵심 계약
  1. 변경 이유·소유자 확인

    같이 바뀌는 코드와 실제 소비자를 확인합니다.

  2. 선택 기준: 안정된 공유 계약?

    소비자 수뿐 아니라 변경 결합도와 API 안정성을 판단합니다. 아래 세 가지는 선택 분기입니다.

  3. 단일 앱 → 앱 내부

    그 앱만의 private implementation을 유지합니다.

  4. 안정된 공유 필요 → 공개 패키지

    의도한 surface와 버전·의존 경계를 관리합니다.

  5. 책임 불명확 → 소유자 재검토

    공유하기 전에 책임을 분리합니다.

  • 핵심 경계
  • 진행·선택 경로

그림의 기준대로, 먼저 그 코드가 무슨 이유로 바뀌고 누가 그 변경을 정하는지를 봅니다. 한 앱만 쓰는 코드라면 그 앱 안에 두고, 여러 패키지가 쓰면서 그 모양이 안정적으로 유지되는 약속이라면 공유 패키지에 둡니다. 누가 정하는지가 분명하지 않다면 공유하기 전에 누가 무엇을 맡을지부터 다시 나눕니다. 공유 패키지에 둔 코드는 그것을 쓰는 모든 패키지에 영향을 주므로, 공개하는 범위를 작게 유지합니다.

정리

  • 구조는 관심사의 분리, 의존의 방향, 일관성을 기준으로 나누며, 바뀌는 이유가 다른 코드는 다른 곳에 둡니다.
  • 이 장의 저장소는 client, server, shared 세 패키지로 이루어지고, client와 server는 각자 설치하고 실행하는 독립된 npm 프로젝트입니다.
  • 클라이언트와 서버는 서로의 내부를 참조하지 않고 shared의 공개 타입에만 의존하며, 그 방향은 설정과 리뷰로 지킵니다.
  • 서버는 기능마다 모듈을 두고, 컨트롤러와 서비스, 데이터베이스를 다루는 저장소의 역할을 나누며, 모듈 사이의 순환 의존을 만들지 않습니다.
  • shared에는 HTTP로 주고받는 공개 데이터의 타입만 두고, 데이터베이스 엔티티와 서버 내부의 정책은 서버에 둡니다.
  • 공유할지는 코드가 비슷한지가 아니라, 여러 패키지가 쓰는 안정된 약속인지와 그 변경을 누가 정하는지로 판단합니다.

연습 문제

1. 날짜 형식 함수의 자리

클라이언트의 두 화면에서 날짜를 2026. 10. 31. 모양으로 바꾸는 함수가 필요합니다. 이 함수는 어디에 두는 것이 좋을까요? 나중에 서버가 같은 모양의 문자열로 이메일을 보내야 한다면 어떻게 판단하나요?

해설 보기

지금은 클라이언트만 쓰므로 client/src 안의 유틸리티 폴더에 둡니다. 서버도 같은 모양이 필요해지면, 두 곳의 모양이 언제나 같아야 하는 약속인지부터 봅니다. 화면과 이메일의 형식이 각자 바뀔 수 있다면 따로 두고, 반드시 같아야 하며 그 변경을 한 사람이 정한다면 그때 shared로 옮기면서 실행되는 코드를 공유하는 설정을 함께 더합니다.

2. 엔티티를 공유하면

서버의 User 엔티티를 shared로 옮겨 클라이언트가 그대로 쓰게 하면 어떤 문제가 생기나요?

해설 보기

엔티티에는 비밀번호 해시와 관리자 여부 같은 내부 필드가 있어서, 클라이언트 코드가 응답에 그 필드가 있다고 믿게 되고 서버가 실수로 그 필드를 내보내도 타입이 막아 주지 않습니다. 또 엔티티는 TypeORM의 데코레이터에 의존하므로 클라이언트가 서버의 라이브러리까지 알아야 하고, 서버가 컬럼을 바꿀 때마다 클라이언트도 함께 바뀌어야 합니다. 그래서 공개할 필드만 가진 PublicUserDto를 따로 둡니다.

3. 타입이 아닌 값을 공유하면

shared에 사용자 이름을 검사하는 함수 isValidName을 더하고, 클라이언트에서 import { isValidName } from "@shared/index"로 가져왔습니다. 타입 검사는 통과했는데 개발 서버에서 모듈을 찾지 못한다는 오류가 납니다. 왜 그런가요?

해설 보기

tsconfig.app.json의 paths는 타입 검사에서만 그 경로를 찾게 해 줄 뿐, Vite가 실행할 코드를 가져올 때 쓰는 경로를 바꾸지 않습니다. 타입만 가져오는 import type은 실행 중에 사라져 문제가 없었지만, 함수는 실제로 가져와야 하므로 Vite가 @shared/index를 찾지 못합니다. 실행되는 코드를 공유하려면 Vite의 resolve.alias나 workspaces로 패키지를 연결하는 설정을 함께 더해야 합니다.

다음 절 API 서버 구현에서는 이 구조의 server 패키지에서 NestJS와 TypeORM으로 사용자의 엔티티와 DTO, 서비스, 컨트롤러를 구현합니다.