NestJS로 웹 API 만들기

아래와 같이 교재에서 나오는 3단계로 블로그를 만든다. 이번 장에서는 ‘1단계 API 만들기’를 다루도록 하겠다.

1단계 API 만들기 2단계 의존성 주입 3단계 몽고DB 연동
1. 프로젝트 생성하기
2. 컨트롤러 만들기
3. 블로그 API 작성하기
4. 메모리와 파일로 블로그 API 만들기
1. 의존성 주입 설정하기
2. 서비스에 리포지토리 의존성 주입하기
3. 컨트롤러에 서비스 의존성 주입하기
1. 의존성 설치하기
2. 스키마 만들기
3. 몽고DB 리포지토리 생성하기
4. 서비스에서 몽고DB를 사용하도록 변경하기

1. 프로젝트 생성과 설정

순서 1. nest-cli는 CLI 프로그램이므로 global 옵션을 사용해 어디서든지 사용하도록 설치

npm install -g @nestjs/cli

순서 2. 프로젝트 생성

// 방법 1. 이 명령어에 에러가 발생한다면, '방법 2' 로 실행
nest new blog 

// 방법 2
npx @nestjs/cli new blog

순서 3. blog 프로젝트 구조 확인

.
├─src
│      app.controller.spec.ts // 1. 컨트롤러
│      app.controller.ts // 2. 컨트롤러 테스트 코드
│      app.module.ts // 3. 모듈
│      app.service.ts // 4. 서비스
│      main.ts // 5. 서비스 메인 파일
.

1번, 3번. 컨트롤러, 컨트롤러 테스트 코드, 모듈을 각각 따로 만들어주는 것이 정석

2번. 컨트롤러 테스트를 위한 파일. NestJs는 jest와 supertest를 사용해 테스트

3번. main.ts는 익스프레스에서의 index.js와 같은 서버 기동 시의 시작 파일


순서 4. 프로젝트 실행

npm run start

image-20230822151713573

2. 컨트롤러 만들기

컨트롤러 란?

  • 정의: 컨트롤러는 클라이언트가 보낸 HTTP 요청을 받아 처리하고, 적절한 응답을 반환하는 역할을 한다. HTTP 요청 시 헤더, URL 매개변수, 쿼리, 바디 등읜 정보가 있는데, 이 정보를 바탕으로 적절한 데코레이터가 붙어 있는 컨트롤러 코드를 실행시킨다.

  • 역할: HTTP 요청을 수신하고, 해당 요청을 처리하는 데 필요한 로직을 호출하며, 최종적으로 클라이언트에게 응답을 반환한다.
  • 예시: GET, POST, PUT, DELETE 등의 HTTP 메서드와 연관된 라우팅 핸들러를 정의한다.

익스프레스에서 자유롭게 만들었던 것과는 다르게, NestJS에서 컨트롤러는 **<모듈명>.controller.ts** 파일로 만든다.


순서 1. 디렉터리 파일 초기화

  • ‘main.ts’ 와 ‘app.module.ts’를 제외하고 모두 삭제하고, 아래와 같이 모두 다시 만든다.

image-20230822154702950


순서 2. 3개의 파일(‘app.module.ts’, ‘blog.controller.ts’, ‘blog.service.ts’)의 코드를 아래와 같이 수정.

모듈 이란?

  • 정의: 모듈은 관련된 컨트롤러와 서비스를 그룹화하고, 애플리케이션의 전체 구조를 쉽게 이해하고 유지보수하는데 도움이 된다.
  • 역할: 응집력 있는 기능 집합을 모듈 단위로 나누어 구조화하고, 애플리케이션의 전체 구조를 쉽게 이해하고 유지보수하는데 도움이 된다.
  • 예시: 아래 예시코드(app.module.ts)의 경우, 컨트롤러와 서비스를 묶어냈다.
// blog > src > app.module.ts

import { Module } from "@nestjs/common";
import { BlogController } from "./blog.controller";
import { BlogService } from './blog.service';

@Module({
  imports: [],
  controllers: [BlogController],
  providers: [BlogService]
})
export class AppModule { }
// blog > src > blog.controller.ts

export class BlogController { }
// blog > src > blog.service.ts

export class BlogService { }

3. 블로그 API 만들기

순서 1. 아래 블로그 API 스펙으로 기능을 만들어보자.

URL HTTP 메서드 설명
/ GET 글 목록을 가져온다.
/blog POST 글을 작성한다.
1. 아이디(id)
2. 제목(title)
3. 작성자(name)
4. 내용(content)
5. 생성일시(createdDt)
6. 수정일시(updatedDt)
/blog/:id PUT 게시글 아이디가 id인 글을 수정한다.
1. 제목(title)
2. 내용(content)
/blog/:id DELETE 게시글 아이디가 id인 글을 삭제한다.
/blog/:id GET 게시글 아이디가 id인 글을 가져온다.

순서 2. 위 스펙에 따라 board.controller.ts에 API 함수들을 추가한다.

// blog.controller.ts

// ★ 컨트롤러는 HTTP 요청을 특정함수가 실행하게 하는 역할
// 1. 데코레이터 함수 임포트, 코드 작성시 자동으로 추가됨
import { Body, Controller, Delete, Get, Param, Post, Put } from "@nestjs/common";

@Controller('blog') // 2. 클래스에 붙이는 Controller 데코레이터
export class BlogController {
    // 3. GET 요청 처리
    @Get()
    getAllPosts() {
        console.log('모든 게시글 가져오기');
    }
    // ... 3

    // 4. POST 요청 처리
    @Post()
    createPost(@Body() post: any) { // 5. HTTP 요청의 body 내용을 post에 할당
        console.log('게시글 작성');
        console.log(post);
    }
    // ... 4

    // 6. GET에 URL 매개변수에 id가 있는 요청 처리
    @Get('/:id')
    getPost(@Param('id') id: string) {
        console.log(`[id: ${id}]게시글 하나 가져오기`);
    }
    // ... 6

    // 7. DELETE 방식에 URL 매개변수로 id가 있는 요청 처리
    @Delete('/:id')
    deletePost() {
        console.log('게시글 삭제');
    }
    // ... 7

    // 8. PUT 방식에 URL 매개변수로 전달된 id가 있는 요청 처리
    @Put('/:id')
    updatePost(@Param('id') id, @Body() post: any) {
        console.log(`[${id}] 게시글 업데이트`);
        console.log(post);
    }
    // ... 8
}

1번. (설명 생략)

2번. Controller(‘blog’)의 의미는 {서버 주소}/blog 이하의 요청을 처리한다는 뜻

3~8번. 함수에 붙이는 데코레이터

5번. @Body와 @Param은 매개변수에 붙이는 데코레이터. Body는 함수의 body로 오는 값, Param은 URL param의 값


순서 3. 메모리에 데이터를 저장하는 API 만들기

위에서 작성한 컨트롤러는 HTTP 요청을 특정 함수가 실행하게 하는 것이었다.

실제 비즈니스 로직은 ‘서비스(blog.service.ts)’에 담아 작성한다.

서비스 란?

  • 정의: 서비스는 비즈니스 로직을 포함하고, 데이터베이스와의 상호작용을 담당하는 계층이다.
  • 역할: 컨트롤러부터 요청을 받아 실제 비즈니스 로직을 수행하고, 데이터베이스와의 CRUD 작업을 수행한다. 로직이 분리되어 있으므로 재사용성이 높다.
  • 예시: 사용자의 정보를 생성, 조회, 업데이트, 삭제하는 로직을 구현할 수 있다.
// blog.service.ts

import { PostDto } from './blog.model'; // 1번 게시글의 타입 정보 임포트

// ★ 서비스에는 비즈니스 로직을 담는다.
export class BlogService {
    posts = []; // 2번 게시글 배열 선언

    // 3번 모든 게시글 가져오기
    getAllPosts() {
        return this.posts;
    }
    // ... 3

    // 4번 게시글 작성
    createPost(postDto: PostDto) {
        const id = this.posts.length + 1;
        this.posts.push({
            id: id.toString(),
            ...postDto,
            createdDt: new Date()
        });
    }
    // ... 4

    // 5번 게시글 하나 가져오기
    getPost(id) {
        const post = this.posts.find((post) => {
            return post.id = id;
        });
        console.log(post);
        return post;
    }
    // ... 5

    // 6번 게시글 삭제
    deletePost(id) {
        const filteredPosts = this.posts.filter((post) => post.id !== id);
        this.posts = [...filteredPosts];
    }
    // ... 6

    // 7번 게시글 업데이트
    updatedPost(id, postDto: PostDto) {
        let updateIndex = this.posts.findIndex((post) => post.id === id);
        const updatePost = { id, ...postDto, updatedDt: new Date() };
        this.posts[updateIndex] = updatePost;
        return updatePost;
    }
    // ... 7
}

1번. blog.model.ts로부터 PostDto를 임포트. Dto는 data transfer object의 약자. 이런 유형의 클래스나 인터페이스에는 데이터를 나타내는 필드들이 있고, 함수가 있는 경우는 필드의 값을 가져오거나 설정하는 함수만 있음

2번. 게시글 배열. 로직에서 이 게시글 데이터를 수정하거나 반환

3번. (설명 생략)

4번. 게시글을 작성 시에는 postDto 객체를 받아서 작성. 타입스크립트에서 함수의 매개변수 선언부에 매개변수명:매개변수타입, 매개변수명2: 매개변수타입2… 형식으로 선언할 수 있음.

5번. getPost() 함수는 id를 주고 게시글 하나를 가져옴. .find() 배열 메서드 사용

6번. delete() 함수는 매개변수로 받은 id에 해당하는 게시글을 삭제. .filter()에 걸리지 않는 배열만 새로 만들어 posts에 재할당

7번. 업데이트할 게시글을 찾기 위해 id를 받고, 내용을 업데이트하기 위해 postDto를 받음. findIndex를 사용해 인덱스를 찾고 해당 인덱스의 값을 updatePost를 통해서 업데이트하는 방식


순서 4. 블로그 게시글의 타입 정의

아래 코드(PostDto)는 게시글의 데이터를 나타내는 타입. 타입스크립트에서는 데이터만 가지고 있는 타입을 선언할 때 클래스보다 인터페이스를 많이 사용

// blog.model.ts

export interface PostDto { // 1. 게시글의 타입을 인터페이스로 정의
    id: string;
    title: string;
    content: string;
    name: string;
    createdDt: Date;
    updatedDt?: Date; // 2. 수정 일시는 필수가 아님
}

1번. (설명 생략)

2번. PostDto는 id(게시글의 인덱스), title(게시글 제목), content(게시글 내용), name(작성자), createdDt(작성일시)에 대한 필드들을 가지며 이 값들은 필수 값을 의미. updatedDt(수정일시)는 null이 될 수 있으므로 뒤에 ?를 붙여주었음. ?가 있다는 얘기는 필수값이 아니라는 뜻.


순서 5. 작성한 BlogService API를 컨트롤러에서 사용할 수 있도록 수정한다.

// blog.controller.ts

import { Body, Controller, Delete, Get, Param, Post, Put } from "@nestjs/common";
import { BlogService } from "./blog.service"; // 1. 블로그 서비스 임포트

@Controller('blog')
export class BlogController {

    // 2. 생성자에서 블로그 서비스 생성
    blogService: BlogService;
    constructor() {
        this.blogService = new BlogService();
    }
    // ... 2


    @Get()
    getAllPosts() {
        console.log('모든 게시글 가져오기');
        return this.blogService.getAllPosts(); // 3. 추가
    }


    @Post()
    createPost(@Body() postDto) { // 4-1. 변경
        console.log('게시글 작성');
        // 4-2. 추가
        this.blogService.createPost(postDto);
        return 'success';
        // ... 4-2
    }


    @Get('/:id')
    getPost(@Param('id') id: string) {
        console.log(`[id: ${id}]게시글 하나 가져오기`);
        return this.blogService.getPost(id); // 5. 추가
    }


    @Delete('/:id')
    deletePost(@Param('id') id: string) { // 6-1. 추가
        console.log('게시글 삭제');
        // 6-2. 추가
        this.blogService.deletePost(id);
        return 'success';
        // ... 6-2
    }


    @Put('/:id')
    // 7. 추가
    updatePost(@Param('id') id: string, @Body() postDto) {
        console.log(`[${id}] 게시글 업데이트`, id, postDto);
        return this.blogService.updatedPost(id, postDto);
        // ... 7
    }
}

1번. 서비스(BlogService) 사용을 위한 임포트

2번. (설명 생략)

3번 ~ 7번. 서비스 연동을 위해 기존 컨트롤러(BlogController)에 코드를 추가, 수정


순서 6. 블로그 API 테스트를 위한 HTTP 파일

API 테스트를 위한 HTTP 코드를 작성한다. Delete는 생략했다.

// src > blog.http

@server = http://localhost:3000

# 게시글 조회
GET /blog

### 게시글 생성
POST /blog
Content-Type: application/json

{
    "title": "안녕하세요",
    "content": "처음 인사드립니다",
    "name": "이름"
}

### 특정 게시글 조회
GET /blog/<게시글ID>

### 게시글 수정
PUT /blog/1
Content-Type: application/json

{
    "title": "타이틀 수정3",
    "content": "본문수정3",
    "name": "Lee DongKyoo"
}

순서 7. HTTP 테스트하기

아래는 HTTP 테스트를 위한, VS CODE 확장 도구와 실행결과이다.

image-20230823111338299

image-20230823111407825

4. 메모리가 아닌 파일에 정보를 저장하도록 API 업그레이드하기

지금까지 작성한 API는 정보를 메모리에 저장해서 영속성 면에서 좋지못하다. 이번에는 정보를 파일로 저장하기 위해, 인터페이스(BlogRepository)를 만들고 BlogRepository를 구현한 BlogFileRepository를 만들어 프로그램을 확장하겠다.

순서 1. 정보를 파일로 저장하기 위한, 인터페이스와 이를 구현한 프로그램을 작성한다. 그리고 파일을 읽고 쓰기 위한 json 파일도 생성한다.

// src > blog.repository.ts

import { readFile, writeFile } from "fs/promises"; // 1. 파일을 읽고 쓰는 모듈(fs/promises) 임포트
import { PostDto } from "./blog.model";

// 2. 블로그 리포지토리 인터페이스 정의
export interface BlogRepository {
    getAllPost(): Promise<PostDto[]>;
    createPost(posDto: PostDto);
    getPost(id: String): Promise<PostDto>;
    deletePost(id: String);
    updatePost(id: String, postDto: PostDto);
}

// 3. BlogRepository를 구현한 클래스. 파일 읽고 쓰기
export class BlogFileRepository implements BlogRepository {
    FILE_NAME = './src/blog.data.json';

    // 4. 파일을 읽어서 모든 게시글 불러오기
    async getAllPost(): Promise<PostDto[]> {
        const datas = await readFile(this.FILE_NAME, 'utf8');
        const posts = JSON.parse(datas);
        return posts;
    }

    // 5. 게시글 쓰기
    async createPost(postDto: PostDto) {
        const posts = await this.getAllPost();
        const id = posts.length + 1;
        const createPost = {
            id: id.toString(),
            ...postDto,
            createdDto: new Date()
        };
        posts.push(createPost);
        await writeFile(this.FILE_NAME, JSON.stringify(posts));
    }

    // 6. 게시글 하나 가져오기
    async getPost(id: string): Promise<PostDto> {
        const posts = await this.getAllPost();
        const result = posts.find((post) => post.id === id);
        return result;
    }

    // 7. 게시글 하나 삭제
    async deletePost(id: String) {
        const posts = await this.getAllPost();
        const filteredPosts = posts.filter((post) => post.id !== id);
        await writeFile(this.FILE_NAME, JSON.stringify(filteredPosts));
    }

    // 8. 게시글 하나 수정하기
    async updatePost(id: String, postDto: PostDto) {
        const posts = await this.getAllPost();
        const index = posts.findIndex((post) => post.id === id);
        const updatePost = {
            id,
            ...postDto,
            updatedDt: new Date()
        };
        posts[index] = updatePost;
        await writeFile(this.FILE_NAME, JSON.stringify(posts));
    }
}
// src > blog.data.json

[]

순서 2. 기존 작성했던 서비스(blog.service.ts)를 ‘리포지토리’로 사용하는 단순한 로직으로 변경한다.

// blog.service.ts

import { PostDto } from './blog.model';

// 1. 리포지토리 클래스와 인터페이스 임포트
import { BlogFileRepository, BlogRepository } from './blog.repository';

export class BlogService {
    blogRepository: BlogRepository;

    // 2. 블로그 리포지토리 객체 생성
    constructor() {
        this.blogRepository = new BlogFileRepository();
    }
    // ... 2

    // 3. 모든 게시글 가져오기
    async getAllPosts() {
        return await this.blogRepository.getAllPost();
    }
    // ... 3

    // 4. 게시글 작성
    createPost(postDto: PostDto) {
        this.blogRepository.createPost(postDto);
    }
    // ... 4

    // 5. 게시글 하나 가져오기
    async getPost(id) {
        return await this.blogRepository.getPost(id);
    }
    // ... 5

    // 6. 게시글 삭제
    deletePost(id) {
        this.blogRepository.deletePost(id);
    }
    // ... 6

    // 7번 게시글 업데이트
    updatedPost(id, postDto: PostDto) {
        this.blogRepository.updatePost(id, postDto);
    }
    // ... 7
}

순서 3. 기존 작성했던 컨트롤러(blog.controller.ts)를 async await을 지원하도록 컨트롤러를 수정한다.

// blog.controller.ts

// ... 생략
@Controller('blog')
export class BlogController {    
	@Get('/:id')
    // 1. 비동기를 지원하는 메서드로 변경
    async getPost(@Param('id') id: string) {
        console.log('게시글 하나 가져오기');

        // 2. 블로그 서비스에서 사용하는 메서드를 비동기로 async/await 사용
        const post = await this.blogService.getPost(id);
        console.log(post);
        return post;
    }
}
// ... 생략

순서 4. 아래와 같이 HTTP 테스트를 해보면, blog.data.json에 정보가 제대로 저장됨을 확인할 수 있다.

image-20230823145359285

정리

  • NestJS는 익스프레스의 대안으로 아키텍처 고민을 덜 수 있게 하는 프레임워크이다.
  • 데코레이터란? 최신 자바스크립트의 기능으로 함수나 클래스에 붙여서 특정한 기능을 수행할 수 있다.
  • DTO란? Data Transfer Object의 약자로 데이터만 담고 있는 객체이다.
  • NestIS의 기본적인 구조는 모듈, 컨트롤러, 서비스로 되어있다.
  • 모듈이란? 관련된 컨트롤러와 서비스를 그룹화하여 애플리케이션의 구조를 조직화한다.
  • 컨트롤러란? 컨트롤러는 클라이언트가 보낸 요청을 받아 처리하고, 적절한 응답을 반환하는 역할을 한다.
  • 서비스란? 서비스는 비즈니스로직을 포함하고, 데이터베이스와의 상호작용을 담당하는 계층이다.
  • REST client를 이용하여 API 테스트를 쉽게 할 수 있다.

참고

다음에 다루게 될 것

  • NestJS로 블로그 API 만들기 - 2단계 의존성 주입

댓글남기기