Express와 TypeORM 활용 CRUD API 생성

류예린·2022년 8월 3일
0

1. Express 탐구하기


Express는 http 통신시 클라이언트와 주고 받는 기본 데이터들을 개발자가 손쉽게 다룰 수 있게 지원하는 도구들이 있다. Node.js의 http 모듈 기능만을 사용하며 느꼈을 불편함은 Express로 해소 가능하다.

[req | res] 의 정체

아래는 Express가 간단히 적용된 서버 동작을 확인하는 소스 코드입니다.

const express = require('express')
const cors = require('cors')
const app = express()

app.use(cors())

app.get('/ping', function (req, res, next) {
  response.json({message : 'pong'})
})

app.listen(3000, function () {
  'listening on port 3000')
})

Express를 도입하며 자주 등장하게 될 req | res 이 두 단어는 ‘요청’과 ‘응답’으로 쉽게 번역될 수 있는 request와 request를 의미하는 변수명이다. 여기서 req는 HTTP 통신시 요청에 대한 정보를 담는 객체를 의미한다. 그리고 이 req에 상응하여 응답에 대한 정보를 담는 객체가 바로 res 다.

백엔드 서버 입장에서 클라이언트측에서 전달한 여러 메타 정보를 확인해야할 때는 req 객체에, 백엔드 서버로서 내보내야하는 다양한 메타 정보들을 담아내야 할 땐 res 객체에 접근하게 된다.

✔️ req - 요청 객체 (Request)

Request 객체에 존재하는 다양한 메소드들은 아래와 같다.

  • req.params : 이름 요청시 넘겨져온 path 파라미터를 담는다.
  • req.query : GET 방식으로 넘어오는 query string 파라미터를 담고 있다.
  • req.body : POST 방식으로 넘어오는 파라미터를 담고있다. HTTP의 BODY 부분에 담겨져있는데, 이 부분을 파싱하기 위해 body-parser와 같은 패키지가 필요하다. 과거에는 body-parser를 꼭 별도로 설정했어야 했으나 express에 그 기본 기능이 내장되어 이제는 불필요하다. app.use(express.json()) 코드가 실행되면 자동으로 parsing 해주는 기능이 있다.
  • req.route : 현재 라우트에 관한 정보를 담고 있다.
  • req.headers : HTTP의 Header 정보를 가지고 있다.
  • req.ip : 클라이언트의 IP Address를 호출한다.
  • req.path : 클라이언트가 요청한 경로. 프로토콜, 호스트, 포트, 쿼리스트링을 제외한 순수 요청 경로입니다.
  • req.host : 요청 호스트 이름을 반환하는 간단한 메서드. 조작될 수 있으므로 보안 목적으로는 사용되어서는 안된다.
  • req.protocol : 현재 요청의 프로토콜 (http / https 등)

다음은 직접 간단히 작성한 API 서버에서 Request 객체를 console.log(req)로 찍어본 결과물이다.

<ref *2> IncomingMessage {
  _readableState: ReadableState {
    objectMode: false,
    highWaterMark: 16384,
    buffer: BufferList { head: null, tail: null, length: 0 },
    length: 0,
    pipes: [],
    flowing: true,
    ended: true,
    endEmitted: true,
    reading: false,
    constructed: true,
    sync: false,
    needReadable: false,
    emittedReadable: false,
    readableListening: false,
    resumeScheduled: false,
    errorEmitted: false,
    emitClose: true,
    autoDestroy: true,
    destroyed: false,
    errored: null,
    closed: false,
    closeEmitted: false,
    defaultEncoding: 'utf8',
    awaitDrainWriters: null,
    multiAwaitDrain: false,
    readingMore: false,
    dataEmitted: true,
    decoder: null,
    encoding: null,
    [Symbol(kPaused)]: false
  },

... 이하 생략

✔️res - 응답 객체 (Response)

Response 객체에 존재하는 다양한 메소드들은 아래와 같다.

  • res.status(code) : HTTP 응답 코드를 설정한다.
  • res.set(name, value) : 응답 헤더를 설정한다. 일반적으로 직접 사용자가 쓸 일은 드문 편이다.
  • res.send(body), res.send(status, body) : 클라이언트에 응답을 보내며 상태 코드에 대한 추가 여부는 선택사항이다. 기본 콘텐츠 타입은 text/html이므로 text/plain을 보내려면 res.set(‘Content-Type’, ‘text/plain’)을 먼저 호출 해야한다. JSON 형태의 전송은 바로 다음 이어지는 res.json을 사용.
  • res.json(json), res.json(status, json) : 클라이언트로 JSON 형태의 데이터를 보낸다.
  • res.type(type) : Contents-Type 헤더를 설정할 수 있는 간단한 메서드다.
  • res.sendFile(path, [options], [callback]) : path의 파일을 읽고 해당 내용을 클라이언트로 전송한다.

다음은 직접 간단히 작성한 API 서버에서 Response 객체를 console.log(res)로 찍어본 결과물이다.

<ref *2> ServerResponse {
  _events: [Object: null prototype] {
    finish: [ [Function: bound resOnFinish], [Function: onevent] ],
    end: [Function: onevent]
  },
  _eventsCount: 2,
  _maxListeners: undefined,
  outputData: [],
  outputSize: 0,
  writable: true,
  destroyed: false,
  _last: false,
  chunkedEncoding: false,
  shouldKeepAlive: true,
  maxRequestsOnConnectionReached: false,
  _defaultKeepAlive: true,
  useChunkedEncodingByDefault: true,
  sendDate: true,
  _removedConnection: false,
  _removedContLen: false,
  _removedTE: false,
  _contentLength: null,
  _hasBody: true,
  _trailer: '',
  finished: false,
  _headerSent: false,
  _closed: false,
  socket: <ref *1> Socket {
    connecting: false,
    _hadError: false,
    _parent: null,
    _host: null,
    _readableState: ReadableState {
      objectMode: false,

... (이하 생략)

[app.xxx( )] 의 정체

‘app’은 express가 프레임워크로서 기본으로 제공하는 다양한 내부 기능 (Application: 공식문서 내 영문명칭)을 담아내기 위해 사용하는 객체입니다. 그리고 변수명 네이밍 컨벤션에 의거, Application의 가장 세 앞글자를 본 따 ‘app’이라는 명칭으로 부르게 됐다. 소스코드 내에서 이 ‘app’이 가장 먼저 등장하는 시점은 바로 최상위단에서 함수 형태의 express를 불러오는 순간으로, 이는 곧 express가 직접 실행되는 시점을 의미한다.

const express = require('express'); // --- (1)
const app = express();              // --- (2)

위 소스 코드의 내용은 (1) require 메소드를 통해서 “express 모듈을 임포트하여 객체를 생성하고 express 변수가 참조하도록 하는 행위”와 (2) "express()" 함수를 호출하고, app 이라는 변수안에 담는 행위를 포함하고 있다. 다시 말해서, ‘express()’ 라는 일종의 클래스 기능을 ‘app’이라는 새로운 변수안에 담아 객체 형태로 선언하는 것이다.

위와 같이 선언한 객체는 그 내부에 존재하는 다양한 메소드를 활용할 때 빈번히 쓰이게 된다. 왜냐하면 express()라는 함수를 통해서 생성된 app이라는 객체는, 전체 API 서버의 기능을 정의하고 서버를 실행시키는 주 객체로 활용되기 때문이다. 즉, app 객체와 상호작용 하는 과정에서 우리가 의도하는 API 기능이 실행/구현되는 것이라고 정의할 수 있다.

express가 제공하는 Application 기능은 대표적으로 아래와 같다.

  • HTTP 요청 라우팅 기능
  • 미들웨어 상세설정
  • HTML 렌더링 기능
  • 템플릿 엔진 렌더링 기능

우리는 위 기능중 백엔드 개발자로서 프론트와의 네트워크 통신을 위해 필수로 활용해야하는 ‘app.use()’와 ‘app.HTTPmethod()’ 이 두가지 메소드를 중점적으로 살펴보자.

✔️ app.use()

Express는 미들웨어 함수의 호출/실행으로 이루어지는 프레임워크라고 표현할 수 있다. 여기서 미들웨어 함수란, ‘req’ 객체, ‘res’ 객체, 그리고 미들웨어 함수를 호출시키는 ’next’를 매개변수로 받는 함수를 의미한다. “함수와 함수 사이에 존재하여 이 둘을 연결 짓는 또 다른 함수” 정도로 이해한다면 이어지는 내용을 이해하는데 큰 문제는 없을 것이다.

우리가 이곳에서 다룰 app.use()는 일종의 미들웨어를 추가하는 함수다. 매개변수의 형태로 들어오는 다양한 함수들을 받아 app에 middleware로 추가해준다. app.use() 의 소괄호 내부에는 외부 요청시 경로로 활용되는 path가 먼저 자리하고, 이후 기타 callback 함수들이 매개변수로 놓인다. path에 명시되어 있는 경로 내용 중 ‘/' 이후의 경로를 일부분이라도 공유하고 있는 메소드는 무조건 호출되게 된다. 예를들어, app.use('/drinks', ...) 는 ‘/drinks’, ‘/drinks/coffee’, ‘/drinks/wine’’, ‘/drinks/coffee/coldbrew’ 등의 경로들과 매칭되어서 해당 경로를 품고 있는 메소드들을 활성화 시키는 효과를 준다.

app.use() 의 기본 디폴트 path는 ‘/‘ 다. 따라서 만일 app.use() 내부 path 부분에 아무것도 적혀 있지 않았다면, 클라이언트가 그 어떠한 요청을 보내더라도 app.use는 매번 호출되게 된다. 이 때 초기 세팅 시 app.use()에 우리가 무심코 추가하였던 morgan, express 등의 동작원리에 대한 힌트를 얻을 수 있다. 바로 path를 따로 지정하지 않음으로써 런서버 환경의 모든 요청에 필히 동작할 수 있게 설정하는 목적이 있었던 것이다.

app.use(cors());
app.use(morgan('combined'));
app.use(express.json());

✔️ app.httpMethod()

app.httpMethod()는 외부에서 들어오는 HTTP 네트워크 요청을 라우팅한다. HTTP 네트워크 요청이란, CRUD 기능에 상응하는 세부 HTTP 메서드 (GET, POST, PUT, DELETE) 들을 의미한다. 이를 express 소스코드 내에서 사용하려면 [app.get(), app.post(), app.put(), app.delete()]등 의 형태로 작성한다.

app.use()로 수렴되는 모든 http 메소드를, 각각의 요청에 맞게 의도한 별도의 callback 함수만이 동작하도록 아래와 같이 분기 처리하는 방법을 사용한다. 예를들어, 클라이언트가 GET에 해당하는 데이터 조회 요청을 ' / ' path로 보낸다면 app.get(' / ' , functionForGet) 요청만을 처리하게 되는 것이다.

app.post('/', functionForPost);               // 클라이언트의 post요청
app.get('/', functionForGet);                 // 클라이언트의 get요청
app.put('/', functionForPut;                  // 클라이언트의 put요청
app.delete('/', functionForDelete);           // 클라이언트의 delete요청

app.xxx 형태의 Application 메소드가 Express에 무수히 많이 내장되어있다.

2. Express와 TypeORM이 적용된 CRUD API 만들기


이제 TypeORM에서 제공하는 연결(Pooling) 기능을 사용하여, 데이터베이스에 Create, Read, Update, Delete 작업을 수행할 수 있는 API로 발전시켜 작은 프로젝트 앱을 완성시킬 차례다.

데이터베이스 테이블 생성

우선, 가장 상위 root 디렉토리에서 db 라는 명칭을 지닌 폴더를 생성한다. 해당 폴더는 dbmate가 관리하는 migrations 파일들과 schema.sql 을 보관하는 역할을 한다. 이 db라는 명칭은 dbmate가 기본으로 migration/shema 파일이 저장되도록 설정한 디렉토리 명칭이니 별도의 네이밍을 원한다면 공식문서를 참고하여 설정할 수 있다.

그 후 dbmate 고유의 테이블 생성 명령어을 입력하여서 다음의 tree 구조와 같이 migration 파일들을 생성한다.

$ dbmate new create_books_table
$ dbmate new create_authors_table
$ dbmate new create_book_authors_table
db
├── migrations
│   ├── 20220420123425_create_books_table.sql
│   ├── 20220420123437_create_authors_table.sql
│   └── 20220420124024_create_book_authors_table.sql
└── schema.sql

책 테이블을 생성하고, 내부 컬럼 필드, 데이터 속성을 정의하는 SQL문을 아래와 같이 작성한다. 아래는 예시로, 20220420123425_create_books_table.sql 파일에 해당되는 내용이다.

-- migrate:up
CREATE TABLE books (
    id INT NOT NULL AUTO_INCREMENT PRIMARY KEY,
    title VARCHAR(100) NOT NULL,
    description VARCHAR(2000) NULL,
    cover_image VARCHAR(1000) NULL,
    created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);

-- migrate:down
DROP TABLE books;

위 테이블이 생성되면 다음과 같이 mysql 서버에 접속하여 좀 전에 만든 테이블의 상세 정보를 확인한다. 이 때 사용하는 명령어는 > desc table_name; 이며, 테이블 내부 속성값들이 본인이 의도한 대로 잘 반영되었는지 체크해보자.

mysql> desc books;

+-------------+---------------+------+-----+-------------------+-----------------------------------------------+
| Field       | Type          | Null | Key | Default           | Extra                                         |
+-------------+---------------+------+-----+-------------------+-----------------------------------------------+
| id          | int           | NO   | PRI | NULL              | auto_increment                                |
| title       | varchar(100)  | NO   |     | NULL              |                                               |
| description | varchar(2000) | YES  |     | NULL              |                                               |
| cover_image | varchar(1000) | YES  |     | NULL              |                                               |
| created_at  | timestamp     | NO   |     | CURRENT_TIMESTAMP | DEFAULT_GENERATED                             |
| updated_at  | timestamp     | NO   |     | CURRENT_TIMESTAMP | DEFAULT_GENERATED on update CURRENT_TIMESTAMP |
+-------------+---------------+------+-----+-------------------+-----------------------------------------------+

작가의 테이블을 정의하는 SQL문을 아래와 같이 작성. 아래는 예시로, 20220420123425_create_authors_table.sql 파일에 해당되는 내용이다.

-- migrate:up
CREATE TABLE authors (
    id INT NOT NULL AUTO_INCREMENT PRIMARY KEY,
    first_name VARCHAR(100) NOT NULL,
    last_name VARCHAR(100) NOT NULL,
    age INT NULL,
    created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);

-- migrate:down
DROP TABLE authors;
mysql> desc authors;

+------------+--------------+------+-----+-------------------+-----------------------------------------------+
| Field      | Type         | Null | Key | Default           | Extra                                         |
+------------+--------------+------+-----+-------------------+-----------------------------------------------+
| id         | int          | NO   | PRI | NULL              | auto_increment                                |
| first_name | varchar(100) | NO   |     | NULL              |                                               |
| last_name  | varchar(100) | NO   |     | NULL              |                                               |
| age        | int          | YES  |     | NULL              |                                               |
| created_at | timestamp    | NO   |     | CURRENT_TIMESTAMP | DEFAULT_GENERATED                             |
| updated_at | timestamp    | NO   |     | CURRENT_TIMESTAMP | DEFAULT_GENERATED on update CURRENT_TIMESTAMP |
+------------+--------------+------+-----+-------------------+-----------------------------------------------+

작가와 책을 연결하는 중간테이블 역할을 하는 테이블을 생성하고 book과 author를 외래키로 연결짓는 SQL문을 작성한다. 아래는 예시로, 20220420123425_create_authors_table.sql 파일에 해당되는 내용이다.

-- migrate:up
CREATE TABLE books_authors (
    id INT NOT NULL AUTO_INCREMENT PRIMARY KEY,
    book_id INT NOT NULL,
    author_id INT NOT NULL,
    CONSTRAINT book_authors_book_id_fkey FOREIGN KEY (book_id) REFERENCES books(id),
    CONSTRAINT book_authors_author_id_fkey FOREIGN KEY (author_id) REFERENCES authors(id)
);

-- migrate:down
DROP TABLE books_authors;
mysql> desc books_authors;

+-----------+------+------+-----+---------+----------------+
| Field     | Type | Null | Key | Default | Extra          |
+-----------+------+------+-----+---------+----------------+
| id        | int  | NO   | PRI | NULL    | auto_increment |
| book_id   | int  | NO   | MUL | NULL    |                |
| author_id | int  | NO   | MUL | NULL    |                |
+-----------+------+------+-----+---------+----------------+

이후 아래의 명령어를 입력하여 각 migration 파일들을 직접 테이블 옮기는 migrate 작업을 진행한다. 이후 schema.sql이 생성된다. 이 sql 파일은 migration이 정상 작동을하며 데이터베이스에 준 모든 변화를 기록하고 있다.

$ dbmate up

CRUD API 작성하기

✔️ 책 정보 생성 엔드포인트 구현

API 상세설명

  • Express의 app.post() 기능을 활용, “http://localhost:3000/” 라는 URI를 기준으로 클라이언트가 원하는 자원에 POST 접근을 할 수 있게 설정한다. 그 후 의도한 자원이 Book임을 명시하는 리소스 target인/books를, ‘path' 매개변수를 작성해주어야 하는 자리에 문자열 형태로 추한다.
  • req, res 객체를 통해서 post 메소드 전송시 함께 들어온 body 에 담긴 내용을 구조 분해하여 각각의 변수에 담아준다. 이 때 mysql 테이블 컬럼의 snake_case와 자바스크립트 상 사용되는 camelCase 사이의 차이점에 주의해야 한다.
  • 이후 클라이언트가 db 에 입력 및 생성하기를 원하는 내용을 테이블에 반영할 수 있게 SQL Raw Query 문을 작성해줍니다.
 app.post('/books', async (req, res) => {
	const { title, description, coverImage } = req.body

	await myDataSource.query(
		`INSERT INTO books(
			title,
			description,
			cover_image
		) VALUES (?, ?, ?);
		`,
		[ title, description, coverImage ]
	);
     res.status(201).json({ message : "successfully created" });
	})

✔️ 책 + 작가 정보 조회 엔드포인트 구현

API 상세설명

  • Express의 app.get() 기능을 이용, “http://localhost:3000/” 라는 URI를 기준으로 클라이언트가 원하는 자원에 GET 접근을 할 수 있게 설정한다. 그 후 의도한 자원이 ‘책’이라는 것을 명시하는 엔드포인트 /books를, ‘path' 매개변수를 기입하는 자리에 문자열 형태로 추가한다.
  • 이후 클라이언트가 db 에서 조회를 원하는 내용을 테이블에서 꺼내어 조회할 수 있게 SQL Raw Query 문을 작성해 준다. 이 때, 중간 테이블을 가지고 있는 책과 작가 테이블의 관계를 유심히 관찰하며 다대다(many to many) 관계에서의 각 테이블간 조합을 어떻게 SQL Raw Query문으로 표현할지 고민해본다.
app.get('/books', async(req, res) => {
    await myDataSource.query(
		`SELECT
            books.id,
            books.title,
            books.description,
            books.cover_image,
            authors.first_name,
            authors.last_name,
            authors.age
        FROM books_authors ba
        INNER JOIN authors ON ba.author_id = authors.id
        INNER JOIN books ON ba.book_id = books.id`
		,(err, rows) => {
      res.status(200).json(rows);
	});
});

✔️ 책 정보 수정 엔드포인트 구현

API 상세설명

  • Express의 app.put() 기능을 활용, “http://localhost:3000/” 라는 URI를 기준으로 클라이언트가 원하는 자원에 PUT 접근을 할 수 있게 설정한다. 그 후 의도한 자원이 ‘책’임을 명시하는 엔드포인트 /books를, ‘path' 매개변수를 기입하는 자리에 문자열 형태로 추가한다.
  • req, res 객체를 통해서 post 메소드 전송시 함께 들어온 body 에 담긴 내용을 구조 분해하여 각각의 변수에 담아준다. 이 때 mysql 테이블 컬럼의 snake_case와 자바스크립트 상 사용되는 camelCase 사이의 차이점을 주의한다.
  • 이후 클라이언트가 db에 입력 및 수정하기를 원하는 내용을 테이블에 반영할 수 있게 SQL Raw Query 문을 작성해 준다.
app.put('/books', async(req, res) => {
	const { title, description, coverImage, bookId } = req.body

	await myDataSource.query(
		`UPDATE books
			SET
				title = ?,
				description = ?,
				cover_image = ?
				WHERE id = ?
		`,
		[ title, description, coverImage, bookId ]
	);
      res.status(201).json({ message : "successfully updated" });
	});

✔️ 책 정보 삭제 엔드포인트 구현

API 상세설명

  • Express의 app.delete() 라우터 기능을 활용하여 “http://localhost:3000/” 라는URI를 기준으로 클라이언트가 원하는 자원에 DELETE 접근을 허용해준다. 그 후 내부 설정으로 /books/:bookId로 타겟 엔드포인트를 설정한다. 이때 새롭게 등장한 :bookId 의 개념은 클라이언트 선에서 뒷 부분에 책의 id값을 의도대로 기입할 수 있게 설정하는 방법이다.
  • req 객체에 담겨 전송된 path parameter 정수 값을 읽어올 수 있게 req.params 내부의 값을 구조분해 할당하여 가져 온다.
  • 이후 클라이언트가 db 에 입력 및 삭제하기를 원하는 내용을 테이블에 반영할 수 있게 SQL Raw Query 문을 작성한다.
app.delete('/books/:bookId', async(req, res) => {
	const { bookId } = req.params;

    await myDataSource.query(
		`DELETE FROM books
		WHERE books.id = ${bookId}
		`);
     res.status(204).json({ message : "successfully deleted" });
	});
profile
helloworld

0개의 댓글