Search

7. 사용자 데이터 생성 및 저장

사용자 생성과 저장

이번 장에서는 UserServicecreate 메서드를 작성하고, 이를 UsersController와 연결하는 과정을 살펴보자.

1. UserService에 리포지토리 연결하기

먼저, UserService에서 TypeORM의 Repository를 주입해야 한다. 이를 위해 @InjectRepository 데코레이터를 사용한다.
import { Injectable } from '@nestjs/common'; import { Repository } from 'typeorm'; import { InjectRepository } from '@nestjs/typeorm'; import { User } from './users.entity'; @Injectable() export class UsersService { constructor(@InjectRepository(User) private repo: Repository<User>) {} }
TypeScript
복사
@InjectRepository(User)를 사용하면 NestJS의 의존성 주입 시스템이 자동으로 User 엔터티와 연결된 Repository 인스턴스를 주입한다.

2. create, save 메서드 구현하기

이제 create 메서드를 작성한다. 사용자의 emailpassword를 받아 새로운 사용자 엔터티를 생성하고, save() 메서드로 이를 데이터베이스에 저장한다.
import { Injectable } from '@nestjs/common'; import { Repository } from 'typeorm'; import { InjectRepository } from '@nestjs/typeorm'; import { User } from './users.entity'; @Injectable() export class UsersService { constructor(@InjectRepository(User) private repo: Repository<User>) {} create(email: string, password: string) { const user = this.repo.create({ email, password }); return this.repo.save(user); } }
TypeScript
복사

3. UsersController에서 create 메서드 호출하기

UsersController에서 UsersService를 주입하고, create 메서드를 호출하여 사용자 생성 API를 구현한다.
import { Body, Controller, Post } from '@nestjs/common'; import { CreateUserDto } from './dtos/create-user.dto'; import { UsersService } from './users.service'; @Controller('auth') export class UsersController { constructor(private usersService: UsersService) {} @Post('/signup') createUser(@Body() body: CreateUserDto) { this.usersService.create(body.email, body.password); } }
TypeScript
복사
@Body() 데코레이터를 사용하여 클라이언트 요청의 본문에서 emailpassword를 받아 create 메서드에 전달한다.

4. API 요청 테스트하기

이제 API 클라이언트를 사용하여 POST 요청을 보내고, 사용자가 정상적으로 생성되는지 확인한다.
### Create a new user POST http://localhost:3000/auth/signup content-type: application/json { "email": "test@test.com", "password": "test" }
JSON
복사
성공적으로 처리되었다면, 데이터베이스에 새로운 사용자가 저장된 것을 확인할 수 있다.

엔터티 인스턴스를 사용하는 이유

create(email: string, password: string) { const user = this.repo.create({ email, password }); return this.repo.save(user); }
TypeScript
복사
아까 우리는 위에서 유저를 생성할 때 create와 save 메서드를 이용해서 데이터베이스에 데이터를 저장했다.
TypeORM을 사용할 때 create와 save의 차이점을 이해하는 것은 매우 중요하다.
위 사진은 create와 save의 차이점을 설명해주고 있다.
create()는 엔티티 객체를 생성하지만, 아직 데이터베이스에는 저장되지 않는다.
save()는 엔티티를 데이터베이스에 저장하고, 저장된 엔티티를 반환한다.
참고로, create()를 안쓰고 save({ email, password})로 사용해도 된다.
하지만 이렇게 하면 안되는 이유에 대해서 한가지 사례를 들어 설명을 해보겠다.

TypeORM 후크(Hook) 기능

TypeORM에서는 엔터티에 특정 이벤트 발생 시 자동으로 실행되는 후크(Hook) 기능을 제공한다.
예를 들어, AfterInsert, AfterUpdate, AfterRemove 같은 후크를 사용하면 데이터 변경이 발생할 때마다 특정 로직을 실행할 수 있다.
users.entity.ts
import { AfterInsert, AfterRemove, AfterUpdate, Entity, Column, PrimaryGeneratedColumn, } from 'typeorm'; @Entity() export class User { @PrimaryGeneratedColumn() id: number; @Column() email: string; @Column() password: string; @AfterInsert() logInsert() { console.log('Inserted User with id', this.id); } @AfterUpdate() logUpdate() { console.log('Updated User with id', this.id); } @AfterRemove() logRemove() { console.log('Removed User with id', this.id); } }
TypeScript
복사
위와 같이 후크를 사용하면 데이터가 삽입, 수정, 삭제될 때 콘솔에 로그를 남길 수 있다.
여기서 중요한 점은 엔터티 인스턴스를 save()에 전달하면 후크가 실행되는데, 일반 객체를 save()에 전달하면 후크가 실행되지 않는다.
잘못된 예시 (후크가 실행되지 않음)
return this.repo.save({ email, password });
TypeScript
복사
올바른 예시 (후크가 실행됨)
const user = this.repo.create({ email, password }); return this.repo.save(user);
TypeScript
복사
TypeORM에는 save()remove() 외에도 insert(), update(), delete() 같은 메서드가 있다.
save()remove()는 엔티티 인스턴스를 사용하며, 후크가 실행된다.
insert(), update(), delete()는 데이터베이스에 직접 접근하며, 후크가 실행되지 않는다.
잘못된 예시 (후크가 실행되지 않음)
await userRepository.insert({ email, password }); // 후크 실행되지 않음 await userRepository.update(1, { email: "new@example.com" }); // 후크 실행되지 않음 await userRepository.delete(1); // 후크 실행되지 않음
TypeScript
복사
그래서, 프로젝트에서 후크를 사용하기로 결정했다면 항상 엔터티 인스턴스를 사용하여 저장, 업데이트, 삭제를 수행해야 한다.
팀 내에서 일반 객체를 사용하여 저장하는 경우, 후크가 실행되지 않아 예기치 않은 버그가 발생할 가능성이 높아진다.

사용자 조회, 수정, 삭제

사용자를 생성하는 create 메서드를 만들었으니 이제 나머지 메서드들(findOne, find, update, remove)을 구현해보자.

findOne() 메서드

특정 id 값을 이용하여 사용자를 검색
findOne(id: number) { return this.repo.findOneBy({ where: { id } }); }
TypeScript
복사

find() 메서드

특정 이메일을 기반으로 사용자를 조회
find(email: string) { return this.repo.find({ where: { email } }); }
TypeScript
복사

update() 메서드

특정 사용자의 정보를 수정
우리가 update 메서드를 구현하는 방법에는 크게 두 가지가 존재한다.

첫 번째 설계 방식

다음과 같이 이메일과 비밀번호를 개별 인자로 받는 방법이 있다.
async update(id: number, newEmail: string, newPassword: string) { // 업데이트 로직 }
TypeScript
복사
이 방법은 비효율적이다. 업데이트할 속성이 많아질 경우, 인자 목록이 길어지고 관리가 어려워진다.
또한 특정 속성만 업데이트하고 싶을 때, 불필요한 값(null)을 전달해야 하는 문제가 발생한다.

두 번째 설계 방식

async update(id: number, attrs: Partial<User>) { // 업데이트 로직 }
TypeScript
복사
TypeScript의 Partial<T> 타입을 활용하면, 업데이트할 속성을 유연하게 받을 수 있다.
이렇게 하면 attrs 객채에 포함된 속성만 업데이트할 수 있고, 필요하지 않은 속성은 전달하지 않아도 된다.
업데이트 로직을 구현하는 방법도 크게 두 가지가 있다.

update() 메서드 활용

async update(id: number, attrs: Partial<User>) { return this.userRepository.update(id, attrs); }
TypeScript
복사
update() 메서드는 엔터티 인스턴스 없이도 실행할 수 있어, 데이터베이스와 한 번만 통신하여 원하는 데이터를 업데이트할 수 있다.
하지만, 후크나 유효성 검사는 실행할 수 없다는 단점이 있다.

save() 메서드 활용

async update(id: number, attrs: Partial<User>) { const user = await this.repo.findOne({ where: { id } }); if (!user) { throw new Error('user not found'); } Object.assign(user, attrs); return this.repo.save(user); }
TypeScript
복사
save() 메서드는 엔터티 인스턴스를 필요로 하며, 후크와 유효성 검사를 실행할 수 있다.
그러나 먼저 유저 데이터를 조회한 후 저장해야 하므로 데이터베이스와 두 번 통신해야 한다는 단점이 있다.

remove() 메서드

특정 사용자를 삭제하는 기능
async remove(id: number) { const user = await this.findOne(id); if (!user) { throw new Error('user not found'); } return this.repo.remove(user); }
TypeScript
복사
remove() 메서드도 마찬가지로 유저 엔터티 객체를 넘겨주어야 하므로 먼저 findOne()으로 해당 사용자가 존재하는지 확인해야 한다.

라우트 핸들러 추가하기

이제 사용자 정보를 가져오거나 수정하는 여러 라우트 핸들러를 컨트롤러에 추가해보자.

특정 사용자 찾기

먼저, 특정 ID를 가진 사용자를 가져오는 핸들러를 추가하자.
이를 위해 @Get() 데코레이터와 @Param 데코레이터를 사용한다.
@Get('/:id') findUser(@Param('id') id: string) { return this.usersService.findOne(parseInt(id)); }
TypeScript
복사
이때, 주의할 점은 요청의 URL 경로에 있는 id는 문자열로 저장된다.
하지만 애플리케이션에서는 id가 숫자 타입이므로 parsetInt()를 사용하여 변환해야 한다.

특정 이메일을 가진 모든 사용자 찾기

다음으로, 특정 이메일을 가진 사용자를 찾는 핸들러를 추가하자.
이때 @Query() 데코레이터를 사용하여 쿼리스트링에서 이메일 정보를 가져온다.
@Get() findAllUsers(@Query('email') email: string) { return this.usersService.find(email); }
TypeScript
복사
클라이언트에서 GET /auth?email=test@test.com 요청을 보내면 이메일이 test@test.com인 모든 사용자를 검색한다.
존재하는 경우, 해당 이메일을 가진 사용자 목록을 반환하고, 존재하지 않는 경우 빈 배열 []을 반환한다.

특정 사용자 삭제하기

다음으로, 특정 id값을 가진 사용자를 찾아 삭제하는 핸들러를 추가하자.
이를 위해 @DELETE() 데코레이터와 @Param 데코레이터를 사용한다.
@Delete('/:id') removeUser(@Param('id') id: string) { return this.usersService.remove(parseInt(id)); }
TypeScript
복사
이 경우에도 마찬가지로 parsetInt()를 사용하여 id를 숫자 타입으로 변환하자.

특정 사용자 업데이트하기

이제 특정 사용자의 정보를 업데이트하는 기능을 구현해보자.
이 기능은 @Patch 데코레이터를 사용하여 구현할 수 있으며, 사용자가 보낸 데이터만 업데이트할 수 있도록 UpdateUserDto 타입을 활용한다.
CreateUserDto와 다른점은 UpdateUserDto는 필수 필드가 없으며, 부분 업데이트를 허용한다는 것이다.
따라서 class-validator의 @IsOptional() 데코레이터를 사용해서 각 필드를 선택적으로 입력할 수 있도록 설정해야 한다.
update-user.dto.ts
import { IsEmail, IsString, IsOptional } from 'class-validator'; export class UpdateUserDto { @IsEmail() @IsOptional() email: string; @IsString() @IsOptional() password: string; }
TypeScript
복사
또는 @nestjs/mapped-types라이브러리PartialType을 사용하면 기존 DTO를 기반으로 모든 필드를 선택적으로 만드는 새로운 DTO를 쉽게 생성할 수 있다.
update-user.dto.ts
import { PartialType } from '@nestjs/mapped-types'; import { CreateUserDto } from './create-user.dto'; export class UpdateUserDto extends PartialType(CreateUserDto) {}
TypeScript
복사
user.controller.ts
@Patch('/:id') updateUser(@Param('id') id: string, @Body() body: UpdateUserDto) { return this.usersService.update(parseInt(id), body); }
TypeScript
복사

오류 처리

기존의 코드에서는 오류가 발생했을 때 Error 객체를 던지고 있었으나, NestJS는 HTTP 요청에 특화된 예외(NotFoundException, BadRequestException)를 사용하여 오류를 처리하는 것을 권장한다. 이를 통해, 오류 발생 시 NestJS는 적절한 HTTP 상태 코드를 응답으로 반환할 수 있다.
user.service.ts
async update(id: number, attrs: Partial<User>) { const user = await this.repo.findOne({ where: { id } }); if (!user) { throw new Error('user not found'); } Object.assign(user, attrs); return this.repo.save(user); } async remove(id: number) { const user = await this.findOne(id); if (!user) { throw new Error('user not found'); } return this.repo.remove(user); }
TypeScript
복사
NotFoundException을 사용하는 이유는 주로 데이터를 찾을 수 없을 때이다. 예를 들어, 사용자를 찾지 못했을 때 NotFoundException을 던져 404 오류를 반환하게 할 수 있다. 아래는 usersService에서 NotFoundException을 던지는 예시이다.
user.service.ts
async remove(id: number) { const user = await this.findOne(id); if (!user) { throw new NotFoundException('user not found'); } return this.repo.remove(user); } async update(id: number, attrs: Partial<User>) { const user = await this.repo.findOne({ where: { id } }); if (!user) { throw new NotFoundException('user not found'); } Object.assign(user, attrs); return this.repo.save(user); }
TypeScript
복사
그리고 현재 findOne 메서드에서 사용자가 조회되지 않더라도 200 상태 코드를 반환하고 있다.
이는 RESTful API 설계 원칙과 맞지 않는 방법이다. 일반적으로 특정 리소스를 조회하는 요청에서 해당 리소스가 존재하지 않으면 404 Not Found 상태 코드를 반환하는 것이 적절하다. 이를 위해 컨트롤러에서 findOne 호출 후 결과가 없을 경우 NotFoundException을 던져 404 응답을 반환하도록 처리했다.
user.service.ts
async findOne(id: number) { const user = await this.repo.findOne({ where: { id } }); if (!user) { throw new NotFoundException('user not found'); } return user; }
TypeScript
복사
NestJS는 HTTP 외에도 WebSocket, GRPC와 같은 다른 통신 프로토콜을 지원한다.
NotFoundException과 같은 HTTP 특화 예외를 사용하면, 다른 프로토콜에서는 해당 예외를 제대로 처리할 수 없다. 따라서 서버에서 예외를 던지는 방식에 따라, 다른 통신 프로토콜을 사용하는 컨트롤러와의 호환성 문제를 고려해야 한다.