목차.
- 풀스택 틱톡 클론 따라하기
- tiktok_clone_fullstack 폴더 생성
- 프론트엔드 프로젝트 폴더 생성
- Postgres는 docker로 준비
- Prisma 셋업
- GraphQL 셋업하기
- AppModule 설정
- NestJS의 웹 앱 초기화 및 구성
- NestJS CLI를 사용하여 모듈과 서비스 생성
- NestJS와 GraphQL을 사용하여 데이터 모델 정의
- DTO 클래스 정의
- 인증과 사용자 관리 서비스 정의
풀스택 틱톡 클론 따라하기
NestJS를 배우기 위한 목적으로 찾은 강의. 풀스택 틱톡 클론: NestJS, GraphQL, Prisma, Postgres, React, Apollo Client, Zustand & Tailwind
이 글은 영상의 40분 30초까지의 내용을 다룬다.
tiktok_clone_fullstack 폴더 생성
tiktok_clone_fullstack 폴더를 생성하고 이동 후, NestJS cli 준비와 프로젝트 생성 보일러플레이트 입력.
npm install -g @nestjs/cli
nest new tiktok_clone_backend
.eslintrc.js에서 prettier 설정 변경.
// .eslintrc.js
module.exports = {
...
extends: [
'plugin:@typescript-eslint/recommended',
// 'plugin:prettier/recommended',
],
...
};
프론트엔드 프로젝트 폴더 생성
프론트엔드 프로젝트 폴더로 만들어둔다.
npm create vite@latest tiktok_clone_frontend
vite로 react, typesciprt 프로젝트 생성.
vscode를 백엔드 폴더를 루트 폴더로 실행. prisma 세팅하기.
Postgres는 docker로 준비
services:
postgress:
image: postgres:latest
container_name: postgres_tiktok_clone
restart: always
environment:
POSTGRES_USER: dev_tiktok_clone
POSTGRES_PASSWORD: 1234
POSTGRES_DB: db_tiktok_clone
ports:
- 5432:5432
volumes:
- my_postgres_data:/var/lib/postgresql/data
volumes:
my_postgres_data:
Prisma 셋업
npm install prisma @prisma/client
npx prisma init
https://docs.nestjs.com/recipes/prisma - netjs의 prisma 가이드 문서.
NestJS 서비스에서 Prisma 클라이언트 사용
이제 프리즈마 클라이언트로 데이터베이스 쿼리를 전송할 수 있습니다.
프리즈마 클라이언트로 쿼리를 작성하는 방법에 대해 자세히 알아보려면 API 설명서를 확인하세요.
NestJS 애플리케이션을 설정할 때 서비스 내에서 데이터베이스 쿼리를 위해
Prisma 클라이언트 API를 추상화할 수 있습니다.
시작하려면 PrismaClient 인스턴스화 및 데이터베이스 연결을
처리하는 새 PrismaService를 생성하면 됩니다.
src 디렉터리 내에 prisma.service.ts라는 새 파일을 생성하고 다음 코드를 추가합니다.
// src/prisma.service.ts
import { Injectable, OnModuleInit } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
@Injectable()
export class PrismaService extends PrismaClient implements OnModuleInit {
async onModuleInit() {
await this.$connect();
}
}
prisma 스키마에서 유저, 포스트, 코멘트, 라이크 모델 작성.
...
model User {
id Int @id @default(autoincrement())
fullname String
bio String?
image String?
email String @unique
password String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
posts Post[]
comments Comment[]
likes Like[]
}
model Post {
id Int @id @default(autoincrement())
userId Int @map("user_id")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
text String
video String
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
comments Comment[]
likes Like[]
}
model Comment {
id Int @id @default(autoincrement())
userId Int @map("user_id")
postId Int @map("post_id")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
post Post @relation(fields: [postId], references: [id], onDelete: Cascade)
text String
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
}
model Like {
id Int @id @default(autoincrement())
userId Int @map("user_id")
postId Int @map("post_id")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
post Post @relation(fields: [postId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@unique([userId, postId])
}
모델 정의 애트리뷰트 중 @@unique 애트리뷰트는 여러 개의 필드를 조합해서 하나의 유니크를 만든다. userId와 postId로 만들었으니, user1과 post1를 생성하였다면, 또 user1과 post1를 만들 수 없다. 대신 user1과 post2, user2와 post1은 가능하다. 좋아요, 싫어요에 딱 맞는 기능이다.
참고 - https://pyh.netlify.app/prisma/start_prisma_with_model/
이제 docker로 postgres 컨테이너 생성하고,
docker compose up
prisma 스키마를 db로 마이그레이트.
npx prisma migrate dev --name init
npx prisma studio
prisma 스튜디오를 실행하여 user, post, comment, like 모델이 보인다면 정상적으로 되었다.
GraphQL 셋업하기
https://docs.nestjs.com/graphql/quick-start - nestjs의 graphql 가이드 문서.
npm i @nestjs/graphql @nestjs/apollo @apollo/server graphql
npm i @nestjs/config bcrypt class-transformer class-validator cookie-parser @types/cookie-parser @nestjs/jwt
AppModule 설정
AppModule은 NestJS의 모듈 시스템에 중요한 역할을 한다. 앱에서 주요 구성 요소를 정의하고, 다른 모듈 및 컴포넌트를 가져와서 앱을 구성한다.
// src/app.module.ts
import { Module } from '@nestjs/common';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { join } from 'path';
import { GraphQLModule } from '@nestjs/graphql';
import { ApolloDriver, ApolloDriverConfig } from '@nestjs/apollo';
import { ConfigModule } from '@nestjs/config';
@Module({
// imports 배열은 AppModule이 다른 모듈을 가져와서 이용할 수 있도록 설정함.
imports: [
// Graphql 모듈을 설정하고 앱에 GraphqlQL 서버를 통합. ApolloDriverConfig는 GraphQL 서버를 구성하기 위한 설정을 담고 있음.
GraphQLModule.forRoot<ApolloDriverConfig>({
// GraphQL 서버 설정. 아폴로 드라이버는 아폴로 서버를 사용하여 GraphQL 서버를 실행하는데 사용.
driver: ApolloDriver,
// Graphql 스키마 자동 생성.
autoSchemaFile: join(process.cwd(), 'src/schema.gql'),
// 스키마의 정의된 타입을 알파벳 순으로 정렬.
sortSchema: true,
// GraphQL 쿼리 테스트 웹 데브 툴.
playground: true,
// GraphQL의 리볼저 함수에서 사용한 컨텍스트 정의.
context: ({ req, res }) => ({ req, res }),
}),
// ConfigModule는 NestJS 앱의 설정을 관리하는 모듈. .env 파일 자동 로드.
ConfigModule.forRoot({}),
],
controllers: [AppController],
providers: [AppService],
})
export class AppModule {}
NestJS의 웹 앱 초기화 및 구성
NestJS의 웹 앱을 초기화하고 구성하기. CORS 설정, 쿠키 파싱, 데이터 유효성 검사 등등.
// src/main.ts
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import * as cookieParser from 'cookie-parser';
import { BadRequestException, ValidationPipe } from '@nestjs/common';
async function bootstrap() {
// NestFactory 클래스를 사용하여 NestJS 앱을 생성.
const app = await NestFactory.create(AppModule);
app.enableCors({
origin: 'http://127.0.0.1:5173',
credentials: true,
allowedHeaders: [
'Accept',
'Content-Type',
'Authorization',
'X-Requested-With',
// Apollo GraphQL 클라이언트에서 사용되는 헤더, 실제 요청 전에 사전에 요청을 보냄
'apollo-require-preflight',
],
methods: ['GET', 'POST', 'PUT', 'DELETE'],
});
app.use(cookieParser());
// 모든 요청에 대해 유효성 검사 수행.
app.useGlobalPipes(
// 유효성 검사 객체 생성.
new ValidationPipe({
// 유효성 검사 전에 요청 데이터를 자동을 변환. 예를 들어, 문자열로 전송된 숫자를 해당 데이터 유형으로 변환.
transform: true,
// 유효하지 않은 속성이 요청 데이터에 포함된 경우 무시됨. 이렇게 하면 요청 데이터에 불필요한 속성이 포함되어도 오류가 발생하지 않음.
whitelist: true,
// 유효겅 검사 오류가 발생할 때 호출되는 사용자 정의 예외 팩토리 함수. 유효성 검사 오류를 처리하고, 그 오류를 더 적절한 형식을 변환 가능.
exceptionFactory: (errors) => {
// 오류 객체의 property 속성을 사용하여 새로운 형식의 오류 객체를 만들 것이다.
// 오류 객체의 constraints 속성에서 가져온 제약 조건을 쉼표로 고분하여 문자열로 결합하여서, 읽기 쉬운 형식을 변환될 것.
const formattedErrors = errors.reduce((accumulator, error) => {
accumulator[error.property] = Object.values(error.constraints).join(
', ',
);
return accumulator;
}, {});
throw new BadRequestException(formattedErrors);
},
}),
);
await app.listen(3000);
}
bootstrap();
NestJS CLI를 사용하여 모듈과 서비스 생성
NestJS CLI를 사용하여 모듈과 서비스 기본 파일 자동 생성하기.
nest generate module auth
nest generate service auth
nest generate module user
nest generate service user
nest generate resolver user
NestJS와 GraphQL을 사용하여 데이터 모델 정의
NestJS와 GraphQL을 사용하여 데이터 모델을 정의하는 클래스를 만들 수 있다.
// src/user/user.model.ts
import { Field, ObjectType } from '@nestjs/graphql';
// @ObjectType 데코레이터는 클래스를 GraphQL 객체 타입으로 정의한다.
@ObjectType()
export class User {
// @Field 데코레이터는 클래스의 필드를 GraphQL 필드로 정의한다.
@Field()
id?: number;
@Field()
fullname: string;
@Field()
email?: string;
@Field({ nullable: true })
bio?: string;
@Field({ nullable: true })
image: string;
@Field()
password: string;
@Field()
createdAt: Date;
@Field()
updatedAt: Date;
}
DTO 클래스 정의
사용자 등록과 로그인 기능을 다루기 위한, 클라이언트와 서버 간 데이터 전송 및 유효겅 검사를 위한 구조를 정의할 DTO 클래스 정의하기.
// src/auth/dto.ts
import { InputType, Field } from '@nestjs/graphql';
import { IsEmail, IsNotEmpty, MinLength, IsString } from 'class-validator';
// @InputType 데코레이터는 이 클래스가 GraphQL 입력 객체로 사용되게 하여 GraphQL 스키마와 연동되게 함.
@InputType()
export class RegisterDto {
@Field()
@IsNotEmpty({ message: 'Fullname is required.' })
@IsString({ message: 'Fullname must be a string.' })
fullname: string;
@Field()
@IsNotEmpty({ message: 'Password is required.' })
@MinLength(8, { message: 'Password must be at least 8 characters.' })
password: string;
// confirm password must be the same as password
@Field()
@IsNotEmpty({ message: 'Confirm Password is required.' })
// must be the same as password
confirmPassword: string;
@Field()
@IsNotEmpty({ message: 'Email is required.' })
@IsEmail({}, { message: 'Email must be valid.' })
email: string;
}
@InputType()
export class LoginDto {
@Field()
@IsNotEmpty({ message: 'Email is required.' })
@IsEmail({}, { message: 'Email must be valid.' })
email: string;
@Field()
@IsNotEmpty({ message: 'Password is required.' })
password: string;
}
인증과 사용자 관리 서비스 정의
인증과 사용자 관리를 처리하는 서비스 정의하기. 사용자의 등록, 로그인, 로그아웃, 토큰 발급 및 갱신 기능 구현.
// src/auth/auth.service.ts
import {
BadRequestException,
Injectable,
UnauthorizedException,
} from '@nestjs/common';
import { Request, Response } from 'express';
import * as bcrypt from 'bcrypt';
import { ConfigService } from '@nestjs/config';
import { JwtService } from '@nestjs/jwt';
import { PrismaService } from 'src/prisma.service';
import { User } from '@prisma/client';
import { LoginDto, RegisterDto } from './dto';
// @Injectable 데코레이터는 클래스를 서비스(provider)로 선언하고 주입 가능한(dependency injectable) 클래스로 표시하기 위해 사용.
@Injectable()
export class AuthService {
constructor(
private readonly jwtService: JwtService,
private readonly prisma: PrismaService,
private readonly configService: ConfigService,
) {}
// 클라이언트의 리프레시 토큰 검증, 새로운 액세스 토큰 발급.
async refreshToken(req: Request, res: Response): Promise<string> {
const refreshToken = req.cookies['refresh_token'];
if (!refreshToken) {
throw new UnauthorizedException('Refresh token not found');
}
let payload;
// 리프레시 토큰 검증.
try {
payload = this.jwtService.verify(refreshToken, {
secret: this.configService.get<string>('REFRESH_TOKEN_SECRET'),
});
} catch (error) {
throw new UnauthorizedException('Invalid or expired refresh token');
}
const userExists = await this.prisma.user.findUnique({
where: { id: payload.sub },
});
if (!userExists) {
throw new BadRequestException('User no longer exists');
}
const expiresIn = 15000; // seconds
const expiration = Math.floor(Date.now() / 1000) + expiresIn;
const accessToken = this.jwtService.sign(
{
...payload,
exp: expiration,
},
{
secret: this.configService.get<string>('ACCESS_TOKEN_SECRET'),
},
);
res.cookie('access_token', accessToken, { httpOnly: true });
return accessToken;
}
// 사용자에게 액세스 토큰과 리프레시 토큰을 발급.
private async issueToken(user: User, response: Response) {
const payload = { username: user.fullname, sub: user.id };
const accessToken = this.jwtService.sign(
{ ...payload },
{
secret: this.configService.get<string>('ACCESS_TOKEN_SECRET'),
expiresIn: '150sec',
},
);
const refreshToken = this.jwtService.sign(payload, {
secret: this.configService.get<string>('REFRESH_TOKEN_SECRET'),
expiresIn: '7d',
});
response.cookie('access_token', accessToken, { httpOnly: true });
response.cookie('refresh_token', refreshToken, { httpOnly: true });
return { user };
}
// 사용자가 제공한 로그인 정보를 검증.
async validateUser(loginDto: LoginDto): Promise<any> {
const user = await this.prisma.user.findUnique({
where: { email: loginDto.email },
});
if (user && (await bcrypt.compare(loginDto.password, user.password))) {
return user;
}
return null;
}
// 새로운 사용자 등록.
async register(registerDto: RegisterDto, response: Response) {
console.trace('registerDto : ', registerDto);
const existingUser = await this.prisma.user.findUnique({
where: { email: registerDto.email },
});
if (existingUser) {
throw new Error('Email already in use'); // Provide a proper error response
}
const hashedPassword = await bcrypt.hash(registerDto.password, 10);
const user = await this.prisma.user.create({
data: {
fullname: registerDto.fullname,
password: hashedPassword,
email: registerDto.email,
},
});
console.trace('user : ', user);
return this.issueToken(user, response); // Issue tokens on registration
}
// 사용자의 로그인 처리.
async login(loginDto: LoginDto, response: Response) {
const user = await this.validateUser(loginDto);
if (!user) {
throw new Error('Invalid credentials'); // Provide a proper error response
}
return this.issueToken(user, response); // Issue tokens on login
}
// 사용자 로그아웃.
async logout(response: Response) {
response.clearCookie('access_token');
response.clearCookie('refresh_token');
return 'Successfully logged out';
}
}
이후에는 프론트엔드 작업으로 넘어간다.
'NodeJS' 카테고리의 다른 글
풀스택 틱톡 클론 따라하기 - 3. Resolver, ProtectedRoutes (1) | 2023.11.20 |
---|---|
풀스택 틱톡 클론 따라하기 - 2. 프론트엔드 셋업 (2) | 2023.11.06 |
타입스크립트 시작하기 (1) | 2023.01.02 |
타입스크립트에서 세션에 커스텀 속성 사용하기 (0) | 2022.03.03 |
next.js에서 Router를 통해 props 기능 구현 (0) | 2022.01.22 |