테스팅 프레임워크 Jest에 대해 알아보기

Node.js를 통해 사용하고 있는 언어인 JavaScript에서 사용이 가능한 테스팅 프레임워크의 종류는 매우 다양하다.

이 중 Jest를 사용해보자.

 

Jest는 다른 테스팅 프레임워크와 비교하면 여러가지 장점이 있지만, 그 중 가장 제일이라고 생각되는 것은 테스트 코드의 표현이 다른 프레임워크보다 훨씬 간결하다.

 

jest 모듈 설치

# yarn 프로젝트를 초기화합니다.
yarn init -y

# DevDependencies로 jest를 설치합니다.
yarn add -D jest

 

 

계층형 아키텍처 패턴 프로젝트 - 템플릿

https://hanghae99-node-example.s3.ap-northeast-2.amazonaws.com/re/layered-architecture-pattern-template.zip

 

위 링크에서 파일을 다운로드 받아 압축을 해제한다.

 

  • .env 파일에서 DB 속성을 변경한다.

꼭, DATABASE_URL을 대여한 RDS의 속성값으로 수정한다.

사용할 데이터베이스 명은 layerd_architecture_pattern_db 로 한다.

 

// .env

# MySQL의 데이터베이스 URL입니다.
DATABASE_URL="mysql://아이디:비번@RDS_EndPoint/layered_architecture_pattern_db"

 

yarn 패키지를 설치하고, Prisma로 DB 및 테이블 정보를 동기화 한다.

 

# yarn 패키지 설치
yarn

# Prisma를 이용하여 DB 및 테이블 생성
npx prisma db push

 

계층형 아키텍처 패턴 프로젝트 ( 디렉토리 구조 )

내 프로젝트 폴더 이름
├── package.json
├── prisma
│   └── schema.prisma
├── src
│   ├── app.js
│   ├── controllers
│   │   └── posts.controller.js
│   ├── middlewares
│   │   ├── error-handling.middleware.js
│   │   └── log.middleware.js
│   ├── repositories
│   │   └── posts.repository.js
│   ├── routes
│   │   ├── index.js
│   │   └── posts.router.js
│   ├── services
│   │   └── posts.service.js
│   └── utils
│       └── prisma
│           └── index.js
└── yarn.lock

 

이 프로젝트의 디렉토리 구조는 기존 프로젝트와는 조금 다르다.

새롭게 controllers, services, repositories 라는 폴더가 보이는데, 이 폴더들은 각 계층병 역할을 담당한다.

 

새로운 디렉토리 구조에 맞춰 3계층 아키텍처 프로젝트를 구성해보자.


 

컨트롤러( Controller )

프레젠테이션 계층( Presentation Layer ) 

3계층 아키텍처 패턴에서 가장 먼저 클라이언트의 요청( Request )을 만나게 되는 계층이고, 대표적으로 컨트롤러( Controller )가 이 역할을 담당한다.

  • 하위 계층( 서비스 계층, 저장소 계층 )에서 발생하는 예외( Exception )를 처리한다.
  • 클라이언트가 전달한 데이터에 대해 유효성을 검증하는 기능을 수행한다.
  • 클라이언트의 요청을 처리한 후 서버에서 처리된 결과를 반환( Response )한다.

 

컨트롤러( Controller )

클라이언트의 요청( Request )을 처리하고, 서버에서 처리된 결과를 반환( Response )하는 역할을 담당한다.

컨트롤러( Controller )는 추가적으로 아래의 역할을 담당한다.

  • 클라이언트의 요청( Request )을 수신한다.
  • 요청( Request )에 들어온 데이터 및 내용을 검증한다.
  • 서버에서 수행된 결과를 클라이언트에게 반환( Response )한다.

 

Express로 구현하는 컨트롤러

 

Express에서는 컨트롤러라우터( Router )를 연결하기 위해서는 express.Router를 사용해, 라우터가 클라이언트의 요청( Request )에서 특정 URIHTTP Method를 전달받았을 때 컨트롤러의 특정 메서드로 요청된 내용을 전달하도록 구현해야한다.

 

그렇기 때문에 routes 폴더에서 posts.router.js 라는 파일을 만들어 PostsController와 연결하도록 구성해보자.

 

routes/posts.router.js

// src/routes/posts.router.js

import express from 'express';
import { PostsController } from '../controllers/posts.controller.js';

const router = express.Router();

// PostsController의 인스턴스를 생성합니다.
const postsController = new PostsController();

/** 게시글 조회 API **/
router.get('/', postsController.getPosts);
/** 게시글 작성 API **/
router.post('/', postsController.createPost);

export default router;

 

router.get('/', postsContoller.getPosts); 

  • router.get은 저희가 Express를 이용해 API를 만들때 매번 사용했던 코드다.
  • 이 메서드는 / URI에 대한 GET 메서드 요청이 들어올 경우, postsController.getPosts를 호출하라는 의미다.
  • 즉, 클라이언트의 요청이 들어오면, PostsController 클래스에서 정의된 getPosts메서드를 실행하도록 라우터를 설정한 것이다.

위 결과로 Post에 해당하는 컨트롤러와 라우터를 연결할 수 있게 되었다.

 

posts.router.js 파일을 통해 컨트롤러라우터연결했으니, 다음 단계로 posts.controller.js 파일을 작성해 Post컨트롤러를 구현해보자.

 

controllers/posts.controller.js

// src/controllers/posts.controller.js

import { PostsService } from '../services/posts.service.js';

// Post의 컨트롤러(Controller)역할을 하는 클래스
export class PostsController {
  postsService = new PostsService(); // Post 서비스를 클래스를 컨트롤러 클래스의 멤버 변수로 할당합니다.

  getPosts = async (req, res, next) => {
    try {
      // 서비스 계층에 구현된 findAllPosts 로직을 실행합니다.
      const posts = await this.postsService.findAllPosts();

      return res.status(200).json({ data: posts });
    } catch (err) {
      next(err);
    }
  };

  createPost = async (req, res, next) => {
    try {
      const { nickname, password, title, content } = req.body;

      // 서비스 계층에 구현된 createPost 로직을 실행합니다.
      const createdPost = await this.postsService.createPost(
        nickname,
        password,
        title,
        content,
      );

      return res.status(201).json({ data: createdPost });
    } catch (err) {
      next(err);
    }
  };
}

 

await this.postsService.findAllPosts();

  • await this.postsService.findAllPosts(); 는 PostsController 클래스의 postService 인스턴스에서 findAllPost 메서드를 호출한다.
  • 컨트롤러는 하위 계층내부 구조에 대해 신경쓰지 않는다. 대신, 외부에 공개된 메서드를 호출하기만 한다. 이것이 가능한 이유는 추상화( Absctraction )의 특성 덕분이다.
  • PostsController 클래스는 전달된 요청( Request )을 처리하기 위해 PostsService를 호출하도록 구현했다. 여기서 컨트롤러비즈니스 로직을 직접 수행하지 않고, 클라이언트요청서비스 계층으로 바로 전달 하도록 구현한 것을 확인 할 수 있다.

결국, PostsController 클래스클라이언트의 요청( Request )서비스 계층으로 전달하는 역할을 수행하며, 서비스 계층이 어떠한 내부 구조를 통해 비즈니스 로직을 수행하는지는 상위 계층컨트롤러에게는 중요하지 않다.

 


 

서비스( Service )

서비스 계층( Service Layer )

다른 이름으로는 비즈니스 로직 계층( Business logic layer )은 아키텍처의 가장 핵심적인 비즈니스 로직을 수행하고 클라이언트가 원하는 요구사항을 구현하는 계층이다.

  • 프레젠테이션 계층( Persentation Layer )데이터 엑세스 계층( Data Access Layer ) 사이에서 중간 다리 역할을 하고, 서로 다른 두 계층이 직접 통신하지 않게 만들어 준다.
  • 서비스( Service )데이터가 필요할 때, 저장소( Repository )에게 데이터를 요청한다.
  • 어플리케이션의 규모가 커질수록, 서비스 계층의 역할코드의 복잡성도 점점 더 커지게 된다.
  • 어플리케이션의 핵심적인 비즈니스 로직을 수행하고, 클라이언트들의 요구사항을 반영해 원하는 결과를 반환해주는 계층이다.

 

서비스 계층의 장단점

서비스 계층의 장점

  • 사용자의 유즈 케이스( Use Case )워크플로우( Workflow )를 명확히 정의하고 이해할 수 있도록 도와준다.
  • 비즈니스 로직이 API 뒤에 숨겨져 있으므로, 서비스 계층의 코드를 자유롭게 수정하거나 리팩터링할 수 있다.
  • 저장소 패턴( Repository Pattern )가짜 저장소( Fake Repository )와 조합하면 높은 수준테스트작성할 수 있다.

서비스 계층의 단점

  • 서비스 계층 또한 다른 추상화 계층이므로, 잘못 사용하면 코드의 복잡성을 증가시킬 수 있다.
  • 한 서비스 계층이 다른 서비스 계층에 의존하는 경우, 의존성 관리가 복잡해질 수 있다.
  • 서비스 계층에 너무 많은 기능을 넣으면 빈약한 도메인 모델( Anemic Domain Model )과 같은 안티 패턴이 생길 수 있다.

 

Express로 구현하는 서비스 계층

Service

  • 사용자의 요구사항을 처리하는 핵심 부분
  • DB 정보가 필요할 때는 Repository에게 요청한다.

PostsController 클래스가 클라이언트의 요청( Request )을 PostsServer 클래스에게 전달하는 과정을 살펴봤다.

이번에는 서비스 계층( Service Layer )에게 비즈니스 로직을 어떻게 수행하고, 필요한 데이터를 어떻게 저장소( Repository )로부터 요청하는지 확인해보도록 하자.

 

posts.service.js

// src/services/posts.service.js

import { PostsRepository } from '../repositories/posts.repository.js';

export class PostsService {
  postsRepository = new PostsRepository();

  findAllPosts = async () => {
    // 저장소(Repository)에게 데이터를 요청합니다.
    const posts = await this.postsRepository.findAllPosts();

    // 호출한 Post들을 가장 최신 게시글 부터 정렬합니다.
    posts.sort((a, b) => {
      return b.createdAt - a.createdAt;
    });

    // 비즈니스 로직을 수행한 후 사용자에게 보여줄 데이터를 가공합니다.
    return posts.map((post) => {
      return {
        postId: post.postId,
        nickname: post.nickname,
        title: post.title,
        createdAt: post.createdAt,
        updatedAt: post.updatedAt,
      };
    });
  };

  createPost = async (nickname, password, title, content) => {
    // 저장소(Repository)에게 데이터를 요청합니다.
    const createdPost = await this.postsRepository.createPost(
      nickname,
      password,
      title,
      content,
    );

    // 비즈니스 로직을 수행한 후 사용자에게 보여줄 데이터를 가공합니다.
    return {
      postId: createdPost.postId,
      nickname: createdPost.nickname,
      title: createdPost.title,
      content: createdPost.content,
      createdAt: createdPost.createdAt,
      updatedAt: createdPost.updatedAt,
    };
  };
}

 

이번 서비스 계층( Service Layer )에서 PostsService 클래스가 PostsRepository의 findAllPosts, createPost 메서드를 호출하는 것을 확인할 수 있다. 해당 코드는 서비스가 비즈니스 로직을 수행하는 데 필요한 데이터를 저장소 계층( Repository Layer )에게 요청해 가져오는 것을 확인 할 수 있다.

또한, 서비스 계층에서는 return posts.map(post => {}); 와 같이 데이터를 가공하는 작업이 이루어진다.

만약, 저장소 계층에서 받은 데이터를 그대로 클라이언트에게 전달한다면, 사용자의 비밀번호와 같은 민감한 정보까지 노출되는 보안 문제가 발생해, 서버의 보안성이 떨어지는 결과를 낳는다.

 


 

저장소( Repository )

저장소 계층( Repository Layer )

데이터 엑세스 계층( Data Access Layer )이라고 불린다. 주로 데이터베이스와 관련된 작업을 처리하는 계층이다.

  • 데이터 접근과 관련된 세부 사항을 숨기는 동시에, 메모리상에 데이터가 존재하는 것처럼 가정해 코드를 구현한다.
  • 저장소 계층을 도입하면, 데이터 저장 방법을 더욱 쉽게 변경할 수 있고, 테스트 코드 작성시 가짜 저장소( Mock Repository )를 제공하기가 더 쉬워진다.
  • 어플리케이션의 다른 계층들은 저장소의 세부 구현 방식에 대해 알지 못하더라도 해당 기능을 사용할 수 있다. 즉, 저장소 계층의 변경 사항이 다른 계층에 영향을 주지 않는 것이다. ( 객체 지향의 개념 중 추상화( Abstraction)와 관계가 있다. )
  • 저장소 계층은 데이터 저장소를 간단히 추상화한 것으로, 이 계층을 통해 모델 계층과 데이터 계층을 명확하게 분리할 수 있다.

대표적인 저장소 계층의 메서드

  • add(), create() : 새 원소를 저장소에 추가한다.
  • get(), find() : 이전에 추가한 원소를 저장소에 가져온다.

 

저장소 계층의 장단점

 

저장소 계층의 장점

  • 데이터 모델과 데이터 처리 인프라에 대한 사항을 분리했기 때문에 단위 테스트( Unit test )를 위한 가짜 저장소( Mock Repository )를 쉽게 만들 수 있다.
  • 도메인 모델을 미리 작성해, 처리해야할 비즈니스 문제에 더 잘 집중할 수 있다.
  • 객체를 테이블에 매칭하는 과정을 원하는 대로 제어할 수 있어서 DB 스키마를 단순화할 수 있다.
  • 저장소 계층에 ORM을 사용하면 필요할 때 MySQL과 Postgre와 같은 다른 데이터베이스로 쉽게 전환할 수 있다.

저장소 계층의 단점

  • 저장소 계층이 없더라도 ORM은 모델과 저장소의 결합도를 충분히 완화시켜 줄 수 있다. ( ORM이 없을 때 대부분의 코드는 Raw Query로 작성되어 있기 때문 )
  • ORM 매핑을 수동으로 하려면 개발 코스트가 더욱 소모된다. ( 여기서 설명하는 ORM은 이전에 사용한 Prisma와 같은 라이브러리를 말한다. )

 

Express로 구현하는 저장소 계층

Repository

  • 데이터베이스 관리( 연결, 해제, 자원 관리 ) 역할을 담당한다.
  • 데이터베이스의 CRUD 작업을 처리한다.

3계층 아키텍처의 마지막 계층인 저장소 계층( Repository Layer )이다.

이전에 작성했던 코드에서 서비스 계층( Service Layer )인 PostsServices에서 PostsRepository를 호출해 데이터를 요청하는 것을 확인할 수 있었다.

이번에는 저장소 계층( Repository Layer )이 어떻게 데이터베이스의 데이터를 가져와 상위 계층에게 반환하는지 확인해보도자.

 

posts.repository.js

// src/repositories/posts.repository.js

import { prisma } from '../utils/prisma/index.js';

export class PostsRepository {
  findAllPosts = async () => {
    // ORM인 Prisma에서 Posts 모델의 findMany 메서드를 사용해 데이터를 요청합니다.
    const posts = await prisma.posts.findMany();

    return posts;
  };

  createPost = async (nickname, password, title, content) => {
    // ORM인 Prisma에서 Posts 모델의 create 메서드를 사용해 데이터를 요청합니다.
    const createdPost = await prisma.posts.create({
      data: {
        nickname,
        password,
        title,
        content,
      },
    });

    return createdPost;
  };
}

 

이번 저장소 계층( Repository Layer )에서는 PostRepository 클래스에서 Prisma의 메소드를 사용해 데이터를 조회하거나 생성하는 것이 가장 핵심적인 내용이다.

 

위 예제에서는 단일 테이블만 활용해 Prisma를 사용했기 때문에 코드가 복잡해지지 않았다. 당연히 어플리케이션의 규모가 커지거나, 데이터베이스의 구성이 복잡해지면 저장소 계층의 구조 또한 복잡해진다.

게시글 생성 API 비즈니스 로직

  1. 게시글을 작성하려는 클라이언트가 로그인된 사용자인지 검증한다.
  2. 게시글 생성을 위한 title, content를 body로 전달받는다.
  3. Posts 테이블에 게시글을 생성한다.

 

게시글은 사용자 ( Users )는 여러개의 게시글 ( Posts )을 등록할 수 있다는 조건에 따라 사용자와 1:N의 관계를 가지고,

현재 로그인 한 사용자의 정보가 존재했을 때만 게시글을 생성할 수 있도록 구현해야 한다.

 

사용자를 생성했을 때와 동일하게, routers/posts.router.js 파일을 생성하고,  app.js 파일에 라우터를 추가해보자.

 

게시글 생성 API

// src/routes/posts.router.js

import express from 'express';
import { prisma } from '../utils/prisma/index.js';
import authMiddleware from '../middlewares/auth.middleware.js';

const router = express.Router();

/** 게시글 생성 API **/
router.post('/posts', authMiddleware, async (req, res, next) => {
  const { userId } = req.user;
  const { title, content } = req.body;

  const post = await prisma.posts.create({
    data: {
      userId: +userId,
      title,
      content,
    },
  });

  return res.status(201).json({ data: post });
});

export default router;

 

사용자 인증 미들웨어를 통해 게시글을 작성하려는 클라이언트가 로그인된 사용자 인지 검증하였고,

전달된 userId 값을 이용해, 사용자와 1:N 관계를 맺고 있는 게시글을 생성하도록 구현했다.

 

PostsRouter를 등록한 app.js

// src/app.js

import express from 'express';
import cookieParser from 'cookie-parser';
import LogMiddleware from './middlewares/log.middleware.js';
import ErrorHandlingMiddleware from './middlewares/error-handling.middleware.js';
import UsersRouter from './routes/users.router.js';
import PostsRouter from './routes/posts.router.js';

const app = express();
const PORT = 3018;

app.use(LogMiddleware);
app.use(express.json());
app.use(cookieParser());
app.use('/api', [UsersRouter, PostsRouter]);
app.use(ErrorHandlingMiddleware);

app.listen(PORT, () => {
  console.log(PORT, '포트로 서버가 열렸어요!');
});

 

게시글 조회 API

게시글 목록 조회 API와 게시글 상세 조회 API는 이전에 prisma-curd에서 구현한 Posts 테이블과 동일한 코드로 구성되어 있다.

 

게시글 목록 조회 API

// src/routes/posts.router.js

/** 게시글 목록 조회 API **/
router.get('/posts', async (req, res, next) => {
  const posts = await prisma.posts.findMany({
    select: {
      postId: true,
      userId: true,
      title: true,
      createdAt: true,
      updatedAt: true,
    },
    orderBy: {
      createdAt: 'desc', // 게시글을 최신순으로 정렬합니다.
    },
  });

  return res.status(200).json({ data: posts });
});

 

 

게시글 상세 조회 API

// src/routes/posts.router.js

/** 게시글 상세 조회 API **/
router.get('/posts/:postId', async (req, res, next) => {
  const { postId } = req.params;
  const post = await prisma.posts.findFirst({
    where: {
      postId: +postId,
    },
    select: {
      postId: true,
      userId: true,
      title: true,
      content: true,
      createdAt: true,
      updatedAt: true,
    },
  });

  return res.status(200).json({ data: post });
});

+ Recent posts