Node.js TIL 03
10 October, 2020 - Node - 10 min read
TDD로 만드는 Node.js API 서버
환경
- Node.js
- VSCode
- Terminal
Node.js 특징
- Node.js는 브라우저 밖에서 자바스크립트 코드를 실행할 수 있도록 해주는 실행환경
- 크롬의 V8 엔진(자바스크립트 코드 해석기)이 내장되어 있음
- 이벤트 기반의 비동기 I/O 프레임워크
- CommonJS를 구현한 모듈 시스템
이벤트 기반의 비동기 I/O 프레임워크
클라이언트에서 요청(request)을 보내면, Node.js는 이 요청을 이벤트로 만들어 이벤트 큐(Queue)에 넣는다. 그리고 이벤트 루프(Event Loop)는 이벤트 큐의 이벤트를 하나씩 꺼내 실행하는데, 이벤트 루프는 싱글스레드이기 때문에 한 번에 하나의 이벤트만 실행하며, 실행된 이벤트는 클라이언트로 응답(response)의 형태로 전달된다.
이때, 이벤트 루프를 거쳐 즉시 응답될 수 있는 이벤트가 있는 반면에 비교적 긴 시간이 소요되는 이벤트도 있다. 이러한 것들까지 이벤트 루프에서 실행하려 하면 블로킹이 걸리기 때문에 이럴 때에는 다른 쓰레드(worker)에게 이벤트를 위임하게 된다. 이벤트를 위임받은 worker는 이벤트 루프와는 별개로 이벤트를 처리한 뒤 결과만 이벤트 큐에 다시 돌려준다.
모듈 시스템
브라우저에서는 모듈 시스템을 구현하기 위해 window context를 사용하거나 RequireJS와 같은 의존성 로더를 사용한다. 반면에 Node.js에서는 파일형태로 모듈을 관리할 수 있는 CommonJS 로 구현한다. 다시 말해 Node.js는 서버측에서 사용되는 자바스크립트 환경인 만큼 OS를 비롯해 로컬의 여러 파일에도 접근을 할 수 있는데, 그러한 특성을 살려 보다 효율적으로 모듈을 관리하기 위해 CommonJS 스펙을 활용하여 파일형태로 모듈을 관리한다고 할 수 있다.
// browser
window.myModule = function () {
return "myModule"
}
myModule() // "myModule"
// Node.js
const http = require("http")
http.createServer()
위의 http
와 같이 Node.js에서 제공되는 모듈을 가져다 쓸 수도 있고,
// math.js
function sum(a, b) {
return a + b
}
module.exports = {
sum: sum,
}
// index.js
const math = require("./math")
const result = math.sum(1, 2)
위의 math
모듈처럼 사용자 정의 모듈을 생성하여 내보내고, 가져다 쓸 수 있다. 그 외에도 그때 그때 필요한 라이브러리를 설치하여 서드파티 모듈을 활용할 수도 있다.
참고:
비동기
자바스크립트에는 비동기 코드가 많고, Node.js 역시 마찬가지로 비동기 코드가 많다. 애초에 Node.js는 기본적으로 비동기로 동작한다. 파일을 읽을 때에도 비동기로 동작하며, 네트워크 통신을 할 때에도 비동기로 통신해야 한다. 그렇기 때문에 Node.js를 사용함에 있어서 비동기 처리에 익숙해질 필요가 있다.
대표적인 예시로 Node.js에는 readFile
과 readFileSync
라는 메서드가 있다. 목적만 놓고 보면 동일한 메서드인데, 전자는 비동기로, 후자는 동기로 동작하게 된다.
const fs = require("fs")
// 동기
const data = fs.readFileSync("./data.txt", "utf-8")
console.log(data) // This is data file
// 비동기
const data = fs.readFile("./data.txt", "utf-8", function (err, result) {
console.log(result) // This is data file
})
위의 경우에서 readFileSync
는 파일을 다 읽을 때까지 후속 코드의 실행을 블록킹하게 되지만, readFile
은 비동기적으로 동작하기 때문에 파일을 읽는 동안 후속 코드의 실행을 블록킹하지 않고, 파일을 다 읽었다는 이벤트가 발생했을 때 콜백함수가 실행된다. 만일 이때, 파일을 제대로 못 읽었다거나 그외 어떤 에러가 있다면 콜백함수의 첫 번째 파라미터인 err
에 값이 담기게 되고, 에러가 없다면 두 번째 파라미터에 값이 담긴다.
참고:
서버 실행하기
Node.js 공식문서에 서버를 실행할 수 있는 예시 코드가 있다.
// index.js
// Node.js의 기본 모듈 중 http라는 모듈을 가져와서 변수에 할당
const http = require("http")
// hostname과 port에 각각 원하는 값을 할당
const hostname = "127.0.0.1"
const port = 3000
// http 모듈의 메서드 중 createServer 메서드를 사용. request와 response를 파라미터로 받는 콜백함수를 파라미터로 넣은 값을 server라는 변수에 할당
const server = http.createServer((req, res) => {
// 서버에 요청이 들어왔을 때 동작하는 코드
res.statusCode = 200 // 요청에 대한 응답 상태코드
res.setHeader("Content-Type", "text/plain") // 응답 데이터의 종류와 형태 정보를 헤더에 담음
res.end("Hello Node") // 요청에 의해 'Hello Node'라는 문자열을 클라이언트로 보냄
})
// listen 메서드는 서버를 요청 대기상태로 만들어주는 함수다. 여기서 대기상태란, 서버가 클라이언트로부터 요청을 받기 위해 종료되지 않고 대기 중인 상태를 말한다.
// listen 메서드는 port, hostname, 그리고 listen 메서드가 완료되면 호출되는 콜백함수까지 총 3개의 파라미터를 받는다.
server.listen(port, hostname, () => {
console.log(`Server running at http://${hostname}:${port}/`)
})
이를 통해 간단히 서버를 실행하고 Hello Node를 확인할 수 있다. 서버를 띄우기 위해서는 터미널에서 node index.js
를 입력한 뒤 브라우저를 열어 localhost:3000
으로 접속해보면 확인할 수 있다. 또는, 다른 터미널을 하나 더 열어 curl -X GET 'localhost:3000'
을 입력하면 브라우저에 출력된 내용을 터미널에서 확인 가능하다.
그러나 매번 위와 같이 변경 사항이 있을 때마다 서버를 재실행 하는 것은 번거롭기 때문에 변경 사항이 자동으로 적용되도록 nodemon
을 설치하는 것을 추천한다.
127.0.0.1
은localhost
와 동일하다.
라우팅(Routing)
라우팅 처리를 하지 않으면, 모든 요청에 대해 동일한 내용으로만 응답을 하게 된다. 따라서 미리 어떤 경로(path)에서 어떤 응답을 보내줄 지 정해야 하는데, 이를 라우팅이라 한다.
클라이언트로부터의 요청은 request
에 객체 형태로 담기게 되며, 이 객체에는 다양한 데이터가 포함되어 있는데, 이 중 url
도 있기 때문에 이를 통하여 아래와 같이 요청에 따른 응답을 분기할 수 있다.
// index.js
const server = http.createServer((req, res) => {
if (req.url === "/") {
res.statusCode = 200
res.setHeader("Content-Type", "text/plain")
res.end("Hello Node")
} else if (req.url === "/users") {
res.statusCode = 200
res.setHeader("Content-Type", "text/plain")
res.end("User List")
} else {
res.statusCode = 404
res.end("Not Found")
}
})
위와 같은 조건으로 서버를 실행했을 때, curl -X GET '127.0.0.1:3000'
에는 'Hello Node'가, curl -X GET '127.0.0.1:3000/user'
에는 'Not Found'가 출력되는 것을 볼 수 있다. 특정한 요청에 특정한 응답을 전달해야 하는 API의 기본적인 원리가 이와 같다고 할 수 있다. 그러나, 보통의 경우 적지 않은 API가 필요한데 일일이 위와 같이 분기하는 것은 상당히 비효율적이다. 그래서 보다 효율적이고 간단하게 라우팅 처리를 할 수 있는 도구를 이용해야 하는 대표적으로 Express.js가 있다.
Express.js
Express.js는 Node.js로 만들어진 웹 프레임워크다. Express.js에는 대표적으로 다섯가지 개념이 있다.
- 어플리케이션
- 미들웨어 : 함수들의 배열이라고 할 수 있으며, Express.js에 어떠한 기능을 추가할 때 미들웨어의 형태를 통해 추가한다.
- 라우팅 : 접속 경로에 따른 응답을 체계적으로 나눌 수 있다.
- 요청객체, 응답객체 : 기본적으로 http 모듈 안에서는 request(요청객체)와 response(응답객체)가 있는데, Express.js에서는 이를 한 번 Wrapper로 감싸 더욱 편리하게 쓸 수 있도록 메서드 형태로 제공한다.
참고:
어플리케이션
Express.js의 인스턴스(객체)를 어플리케이션이라고 한다.
const express = require("express")
const app = express()
위와 같이 Express.js 모듈을 가져와 app
이라는 변수에 express
객체를 할당하는데, 이때 app
이 어플리케이션이라는 개념이다. 이렇게 만들어진 어플리케이션을 통하여 서버에 필요한 기능인 미들웨어를 추가 할 수 있고, 라우팅 설정 도 할 수 있으며,
const express = require("express")
const app = express()
const port = 3000
app.get("/", (req, res) => {
res.send("Hello Express")
})
app.listen(port, () => {
console.log(`Example app listening at http://localhost:${port}`)
})
위와 같이 listen
메서드를 쓸 수 있기 때문에 서버를 요청 대기 상태로 만들 수 도 있다.
미들웨어(Middleware)
미들웨어는 함수들의 연속이다. Express에 기능을 추가해야 할 때에는 이 미들웨어의 형태로 추가할 수 있다.
미들웨어 예시
const express = require("express")
const app = express()
function logger(req, res, next) {
console.log("I am logger")
next()
}
app.use(logger)
app.listen(3000, () => {
console.log(`Example app listening at http://localhost:3000`)
})
미들웨어를 사용할 때에는 use()
메서드에 사용할 미들웨어를 파라미터로 넣어준다.
미들웨어의 인터페이스는 정해져있다. 위의 예시에서 생성한 logger
라는 임의의 미들웨어를 보면 총 세 개의 파라미터를 받고 있는 것을 볼 수 있는데, 이는 미들웨어의 정해진 인터페이스다. 첫 번째 파라미터는 요청(request)객체, 두 번째 파라미터는 응답(response)객체 그리고 세 번째 파라미터로 next
를 받으며, 미들웨어는 해야 할 일을 마친 뒤에는 next()
를 호출해야 한다. 그래야 다음 동작을 수행할 수 있다.
참고:
미들웨어의 실행 순서
const express = require("express")
const app = express()
function logger(req, res, next) {
console.log("I am logger")
// next();
}
function logger2(req, res, next) {
console.log("I am logger2") // -> 출력되지 않음
next()
}
app.use(logger)
app.use(logger2)
app.listen(3000, () => {
console.log(`Example app listening at http://localhost:3000`)
})
만약 위와 같이 logger
내부에서 next()
를 호출하지 않는다면, 서버에 오청 시 서버 콘솔에는 I am logger
만 출력되고, 이후의 함수는 무시된다.
또한, 미들웨어는 use()
메서드를 통해 호출한 순서에 따라 실행된다. 따라서 아래와 같이 두 미들웨어의 호출 순서를 바꾼다면,
const express = require("express")
const app = express()
function logger(req, res, next) {
console.log("I am logger")
next()
}
function logger2(req, res, next) {
console.log("I am logger2")
next()
}
app.use(logger2)
app.use(logger)
app.listen(3000, () => {
console.log(`Example app listening at http://localhost:3000`)
})
서버 콘솔에는 I am logger2
가 먼저 출력되고 이후에 I am logger
가 출력되는 것을 확인할 수 있다.
에러 미들웨어
미들웨어는 일반 미들웨어 와 에러 미들웨어 로 구분할 수 있으며, 앞에서 만들어봤던 미들웨어와 npm을 통해 내려 받아 사용하는 서드파티 미들웨어는 모두 일반 미들웨어에 속한다.
일반 미들웨어는 req, res, next
세 개의 파라미터를 받지만, 에러 미들웨어는 err, req, res, next
네 개의 파라미터를 받는다.
const express = require("express")
const app = express()
function commonMiddleware(req, res, next) {
console.log("common middleware")
next(new Error("what the error!")) // <- 인위적으로 에러객체 전달
}
function errorMiddleware(err, req, res, next) {
console.log(err.message) // <- 전달 받은 에러객체의 메시지 출력 what the error!
next() // <- 만약 에러가 처리되지 않으면 next에 err을 담아 다음으로 넘길 수 있음
}
app.use(commonMiddleware)
app.use(errorMiddleware)
app.listen(3000, () => {
console.log(`Example app listening at http://localhost:3000`)
})
위와 같이 일반 미들웨어에서 에러가 발생한 경우, 에러 미들웨어는 에러객체를 파라미터로 받아 처리하게 된다.
참고:
라우팅(with Express)
어떤 요청이 왔을 때 해당 요청에 맞는 응답을 해주는 것을 라우팅(Routing)이라고 한다. 앞에서 Node.js만 사용하여 구현했던 라우팅 코드를 다시 한 번 살펴보자.
// without Express
const http = require("http")
const hostname = "127.0.0.1"
const port = 3000
const server = http.createServer((req, res) => {
if (req.url === "/") {
res.statusCode = 200
res.setHeader("Content-Type", "text/plain")
res.end("Hello Node")
} else if (req.url === "/users") {
res.statusCode = 200
res.setHeader("Content-Type", "text/plain")
res.end("User List")
} else {
res.statusCode = 404
res.end("Not Found")
}
})
server.listen(port, hostname, () => {
console.log(`Server running at http://${hostname}:${port}/`)
})
위와 같이 요청에 따른 조건들을 일일이 분기해줘야 하기 때문에 코드가 장황해지는 것이 불가피하다. 따라서 보다 간결하게 라우팅 처리를 하기 위해 Express.js를 활용한다. 정확하게는 Express.js의 어플리케이션 객체의 get()
과 post()
등의 메서드다.
나아가 라우팅을 더욱 구조적으로 하고 싶다면, 전용 Router
클래스를 활용하면 좋다.
참고:
요청객체와 응답객체
요청객체는 클라이언트에서 서버로 들어온 요청 정보를 담은 객체 를 말하며, Express.js에서의 요청객체는 http 모듈의 request 객체를 래핑(Wrapping)한 것이다. 이를 통해 req.params()
, req.query()
, req.body()
등의 Express.js의 메서드를 손쉽게 활용 가능하다.
응답객체는 서버에서 클라이언트로 전달하는 응답 정보를 담은 객체 를 말하며, 마찬가지로 http 모듈의 response 객체를 래핑한 것이다. res.send()
, res.status()
, res.json()
등의 메서드가 주로 사용된다.
Hello World in Express.js
// 1. express 모듈 가져옴
const express = require("express")
// 2. express 객체를 생성하여 app 변수에 할당
const app = express()
// 4. GET 요청 시의 라우팅 설정.
// 첫 번째 파라미터로는 요청 경로, 두 번째 파라미터로는 실행할 콜백 함수
// 콜백 함수의 파라미터로는 요청객체와 응답객체가 들어옴
app.get("/", (req, res) => {
res.send("Hello World") // -> Hello World 문자열을 클라이언트로 전송
})
// 3. listen 함수를 통해 서버를 구동.
// 첫 번째 파라미터로는 포트 번호, 두 번째 파라미터로는 서버 구동 시 실행되는 콜백 함수
app.listen(3000, () => {
console.log("server start")
})
요청 형식
모든 자원은 명사로 식별하며, HTTP 경로를 통해 자원을 요청한다. HTTP 메서드는 서버 자원에 대한 행동을 나타내며 동사로 표현한다.
HTTP Method
- GET: 자원을 조회
- POST: 자원을 생성
- PUT: 자원을 갱신
- DELETE: 자원을 삭제
위의 메서드들은 모두 Express.js 어플리케이션의 메서드로 구현되어 있다.
const express = require("express")
const app = express()
app.get("/users", (req, res) => {
res.send("this is users list")
})
app.listen(3000, () => {
console.log("server start")
})
위와 같이 /users
요청에 대한 get()
라우팅이 존재할 때, curl -X GET 'localhost:3000/users
를 통해 요청을 하면, this is users list
문자열이 응답으로 들어오는 것을 확인할 수 있다.
응답 형식
참고:
첫 API
/users
를 경로로 갖고 GET
메서드를 통해 사용자 목록을 조회하는 API를 만들어보자.
const express = require("express")
const app = express()
const morgan = require("morgan")
const users = [
{ id: 1, name: "Tom" },
{ id: 2, name: "Jane" },
{ id: 3, name: "Mike" },
]
app.use(morgan("dev"))
app.get("/users", (req, res) => {
res.json(users)
})
app.listen(3000, () => {
console.log("server start")
})
위와 같이 users라는 mock data를 만들어 응답을 해주도록 설정하고 요청을 해보면 다음과 같이 users가 json 형태로 돌아오는 것을 확인할 수 있다.
[
{ "id": 1, "name": "Tom" },
{ "id": 2, "name": "Jane" },
{ "id": 3, "name": "Mike" }
]