Express는 http 통신시 클라이언트와 주고 받는 기본 데이터들을 개발자가 손쉽게 다룰 수 있게 지원하는 도구들이 있다. Node.js의 http 모듈 기능만을 사용하며 느꼈을 불편함은 Express로 해소 가능하다.
아래는 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
객체에 접근하게 된다.
Request 객체
에 존재하는 다양한 메소드들은 아래와 같다.
다음은 직접 간단히 작성한 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
},
... 이하 생략
Response 객체
에 존재하는 다양한 메소드들은 아래와 같다.
다음은 직접 간단히 작성한 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’은 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 기능은 대표적으로 아래와 같다.
우리는 위 기능중 백엔드 개발자로서 프론트와의 네트워크 통신을 위해 필수로 활용해야하는 ‘app.use()’와 ‘app.HTTPmethod()’ 이 두가지 메소드를 중점적으로 살펴보자.
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()
는 외부에서 들어오는 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에 무수히 많이 내장되어있다.
이제 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
API 상세설명
http://localhost:3000/
” 라는 URI를 기준으로 클라이언트가 원하는 자원에 POST 접근을 할 수 있게 설정한다. 그 후 의도한 자원이 Book임을 명시하는 리소스 target인/books
를, ‘path' 매개변수를 작성해주어야 하는 자리에 문자열 형태로 추한다. 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 상세설명
http://localhost:3000/
” 라는 URI를 기준으로 클라이언트가 원하는 자원에 GET 접근을 할 수 있게 설정한다. 그 후 의도한 자원이 ‘책’이라는 것을 명시하는 엔드포인트 /books
를, ‘path' 매개변수를 기입하는 자리에 문자열 형태로 추가한다.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 상세설명
http://localhost:3000/
” 라는 URI를 기준으로 클라이언트가 원하는 자원에 PUT 접근을 할 수 있게 설정한다. 그 후 의도한 자원이 ‘책’임을 명시하는 엔드포인트 /books
를, ‘path' 매개변수를 기입하는 자리에 문자열 형태로 추가한다.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 상세설명
http://localhost:3000/
” 라는URI를 기준으로 클라이언트가 원하는 자원에 DELETE 접근을 허용해준다. 그 후 내부 설정으로 /books/:bookId
로 타겟 엔드포인트를 설정한다. 이때 새롭게 등장한 :bookId
의 개념은 클라이언트 선에서 뒷 부분에 책의 id값을 의도대로 기입할 수 있게 설정하는 방법이다.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" });
});