Node.js/TypeScript REST API 빌드, 1부: Express.js
게시 됨: 2022-03-11Node.js에서 REST API를 어떻게 작성합니까?
REST API용 백엔드를 구축할 때 Express.js는 종종 Node.js 프레임워크 중에서 첫 번째 선택입니다. 정적 HTML 및 템플릿 구축도 지원하지만 이 시리즈에서는 TypeScript를 사용한 백엔드 개발에 중점을 둘 것입니다. 결과 REST API는 모든 프론트 엔드 프레임워크 또는 외부 백엔드 서비스가 쿼리할 수 있는 API가 됩니다.
너는 필요할거야:
- JavaScript 및 TypeScript에 대한 기본 지식
- Node.js에 대한 기본 지식
- REST 아키텍처에 대한 기본 지식(필요한 경우 이전 REST API 기사의 이 섹션 참조)
- Node.js의 준비된 설치(버전 14 이상 권장)
터미널(또는 명령 프롬프트)에서 프로젝트에 대한 폴더를 생성합니다. 해당 폴더에서 npm init
를 실행합니다. 그러면 필요한 기본 Node.js 프로젝트 파일이 생성됩니다.
다음으로 Express.js 프레임워크와 몇 가지 유용한 라이브러리를 추가합니다.
npm i express debug winston express-winston cors
이러한 라이브러리가 Node.js 개발자가 즐겨찾는 라이브러리인 데에는 그럴만한 이유가 있습니다.
-
debug
는 애플리케이션을 개발하는 동안console.log()
호출을 피하기 위해 사용할 모듈입니다. 이런 식으로 문제 해결 중에 디버그 문을 쉽게 필터링할 수 있습니다. 또한 수동으로 제거할 필요 없이 프로덕션 단계에서 완전히 끌 수 있습니다. -
winston
은 API에 대한 요청과 반환된 응답(및 오류)을 기록하는 일을 담당합니다.express-winston
은 Express.js와 직접 통합되므로 모든 표준 API 관련winston
로깅 코드가 이미 완료되었습니다. -
cors
는 출처 간 리소스 공유를 가능하게 하는 Express.js 미들웨어입니다. 이것이 없으면 API는 백엔드와 정확히 동일한 하위 도메인에서 제공되는 프런트 엔드에서만 사용할 수 있습니다.
백엔드는 실행될 때 이러한 패키지를 사용합니다. 그러나 TypeScript 구성을 위한 일부 개발 종속성도 설치해야 합니다. 이를 위해 다음을 실행합니다.
npm i --save-dev @types/cors @types/express @types/debug source-map-support tslint typescript
이러한 종속성은 Express.js 및 기타 종속성이 사용하는 유형과 함께 앱 자체 코드에 대해 TypeScript를 활성화하는 데 필요합니다. 이것은 WebStorm이나 VSCode와 같은 IDE를 사용할 때 코딩하는 동안 일부 기능 메서드를 자동으로 완료할 수 있도록 하여 많은 시간을 절약할 수 있습니다.
package.json
의 최종 종속성은 다음과 같아야 합니다.
"dependencies": { "debug": "^4.2.0", "express": "^4.17.1", "express-winston": "^4.0.5", "winston": "^3.3.3", "cors": "^2.8.5" }, "devDependencies": { "@types/cors": "^2.8.7", "@types/debug": "^4.1.5", "@types/express": "^4.17.2", "source-map-support": "^0.5.16", "tslint": "^6.0.0", "typescript": "^3.7.5" }
이제 필요한 모든 종속성을 설치했으므로 자체 코드를 작성해 보겠습니다.
TypeScript REST API 프로젝트 구조
이 자습서에서는 세 개의 파일만 만들 것입니다.
-
./app.ts
-
./common/common.routes.config.ts
-
./users/users.routes.config.ts
프로젝트 구조의 두 폴더( common
및 users
) 뒤에 있는 아이디어는 고유한 책임이 있는 개별 모듈을 갖는 것입니다. 이러한 의미에서 우리는 결국 각 모듈에 대해 다음 중 일부 또는 전부를 갖게 될 것입니다.
- API가 처리할 수 있는 요청을 정의하는 경로 구성
- 데이터베이스 모델 연결, 쿼리 수행 또는 특정 요청에 필요한 외부 서비스 연결과 같은 작업을 위한 서비스
- 경로의 최종 컨트롤러가 세부 사항을 처리하기 전에 특정 요청 유효성 검사를 실행하기 위한 미들웨어
- 데이터 저장 및 검색을 용이하게 하기 위해 주어진 데이터베이스 스키마와 일치하는 데이터 모델 을 정의하기 위한 모델
- 최종적으로 (미들웨어 이후) 경로 요청을 처리하고, 필요한 경우 위의 서비스 기능을 호출하고, 클라이언트에 응답을 제공하는 코드에서 경로 구성을 분리하기 위한 컨트롤러
이 폴더 구조는 기본 REST API 디자인, 이 튜토리얼 시리즈의 나머지 부분을 위한 초기 시작점, 그리고 연습을 시작하기에 충분합니다.
TypeScript의 공통 경로 파일
common
폴더에서 common.routes.config.ts
파일을 생성하여 다음과 같이 보이도록 합시다.
import express from 'express'; export class CommonRoutesConfig { app: express.Application; name: string; constructor(app: express.Application, name: string) { this.app = app; this.name = name; } getName() { return this.name; } }
여기에서 경로를 생성하는 방법은 선택 사항입니다. 그러나 TypeScript로 작업하고 있기 때문에 우리의 라우트 시나리오는 곧 보게 될 extends
키워드로 상속을 사용하는 연습을 할 수 있는 기회입니다. 이 프로젝트에서 모든 경로 파일은 동일한 동작을 갖습니다. 이름(디버깅 목적으로 사용)과 기본 Express.js Application
개체에 대한 액세스 권한이 있습니다.
이제 사용자 경로 파일 생성을 시작할 수 있습니다. users
폴더에서 users.routes.config.ts
를 만들고 다음과 같이 코딩을 시작하겠습니다.
import {CommonRoutesConfig} from '../common/common.routes.config'; import express from 'express'; export class UsersRoutes extends CommonRoutesConfig { constructor(app: express.Application) { super(app, 'UsersRoutes'); } }
여기에서 UsersRoutes
클래스를 가져와서 CommonRoutesConfig
라는 새 클래스로 확장합니다. 생성자를 사용하여 앱(기본 express.Application
개체)과 이름 UsersRoutes를 CommonRoutesConfig
의 생성자에 보냅니다.
이 예제는 매우 간단하지만 여러 경로 파일을 생성하도록 확장할 때 중복 코드를 방지하는 데 도움이 됩니다.
이 파일에 로깅과 같은 새로운 기능을 추가하려고 한다고 가정합니다. CommonRoutesConfig
클래스에 필요한 필드를 추가하면 CommonRoutesConfig
를 확장하는 모든 경로가 이에 액세스할 수 있습니다.
클래스 간에 유사한 기능을 위해 TypeScript 추상 함수 사용
이러한 클래스 간에 유사한 기능(예: API 끝점 구성)을 갖고 싶지만 각 클래스에 대해 다른 구현이 필요한 경우에는 어떻게 합니까? 한 가지 옵션은 추상화 라는 TypeScript 기능을 사용하는 것입니다.
UsersRoutes
클래스(및 향후 라우팅 클래스)가 CommonRoutesConfig
에서 상속할 매우 간단한 추상 함수를 만들어 보겠습니다. 모든 경로에 configureRoutes()
라는 함수(공통 생성자에서 호출할 수 있도록)가 있어야 한다고 가정해 보겠습니다. 여기에서 각 라우팅 클래스 리소스의 끝점을 선언합니다.
이를 위해 common.routes.config.ts
에 세 가지 빠른 항목을 추가합니다.
- 이 클래스에 대한 추상화를 활성화하기 위해
class
라인에 키워드abstract
를 사용합니다. - 클래스 끝에 새로운 함수 선언
abstract configureRoutes(): express.Application;
. 이렇게 하면CommonRoutesConfig
를 확장하는 모든 클래스가 해당 서명과 일치하는 구현을 제공하도록 강제합니다. 그렇지 않으면 TypeScript 컴파일러에서 오류가 발생합니다. -
this.configureRoutes();
이제 이 함수가 존재한다는 것을 확신할 수 있기 때문에 생성자의 끝에서.
결과:
import express from 'express'; export abstract class CommonRoutesConfig { app: express.Application; name: string; constructor(app: express.Application, name: string) { this.app = app; this.name = name; this.configureRoutes(); } getName() { return this.name; } abstract configureRoutes(): express.Application; }
이를 통해 express.Application
를 확장하는 모든 클래스에는 CommonRoutesConfig
객체를 반환하는 configureRoutes()
라는 함수가 있어야 합니다. 즉, users.routes.config.ts
를 업데이트해야 합니다.
import {CommonRoutesConfig} from '../common/common.routes.config'; import express from 'express'; export class UsersRoutes extends CommonRoutesConfig { constructor(app: express.Application) { super(app, 'UsersRoutes'); } configureRoutes() { // (we'll add the actual route configuration here next) return this.app; } }
우리가 만든 것을 요약하자면:
먼저 common.routes.config
파일을 가져온 다음 express
모듈을 가져옵니다. 그런 다음 우리는 CommonRoutesConfig
기본 클래스를 확장하기를 원한다고 UserRoutes
클래스를 정의 configureRoutes()
.
CommonRoutesConfig
클래스와 함께 정보를 보내기 위해 클래스의 constructor
를 사용하고 있습니다. 다음 단계에서 더 자세히 설명할 express.Application
개체를 수신할 것으로 예상합니다. super()
를 사용하여 CommonRoutesConfig
의 생성자에 애플리케이션과 경로 이름(이 시나리오에서는 UsersRoutes)을 전달합니다. ( super()
는 차례로 configureRoutes()
구현을 호출합니다.)
사용자 끝점의 Express.js 경로 구성
configureRoutes()
함수는 REST API 사용자를 위한 끝점을 생성하는 곳입니다. 여기에서 우리는 Express.js의 애플리케이션과 해당 라우트 기능을 사용할 것입니다.
app.route()
함수를 사용하는 아이디어는 코드 중복을 피하기 위한 것입니다. 이는 잘 정의된 리소스로 REST API를 생성하기 때문에 쉽습니다. 이 튜토리얼의 주요 리소스는 users 입니다. 이 시나리오에는 두 가지 경우가 있습니다.
- API 호출자가 새 사용자를 만들거나 기존 사용자를 모두 나열하려는 경우 엔드포인트는 처음에 요청된 경로 끝에
users
만 있어야 합니다. (이 기사에서는 쿼리 필터링, 페이지 매김 또는 기타 쿼리에 대해 다루지 않을 것입니다.) - 호출자가 특정 사용자 레코드와 관련된 작업을 수행하려는 경우 요청의 리소스 경로는
users/:userId
패턴을 따릅니다.
Express.js에서 .route()
가 작동하는 방식을 사용하면 일부 우아한 연결로 HTTP 동사를 처리할 수 있습니다. .get()
, .post()
등은 모두 첫 번째 .route()
호출이 수행하는 동일한 IRoute
인스턴스를 반환하기 때문입니다. 최종 구성은 다음과 같습니다.
configureRoutes() { this.app.route(`/users`) .get((req: express.Request, res: express.Response) => { res.status(200).send(`List of users`); }) .post((req: express.Request, res: express.Response) => { res.status(200).send(`Post to users`); }); this.app.route(`/users/:userId`) .all((req: express.Request, res: express.Response, next: express.NextFunction) => { // this middleware function runs before any request to /users/:userId // but it doesn't accomplish anything just yet--- // it simply passes control to the next applicable function below using next() next(); }) .get((req: express.Request, res: express.Response) => { res.status(200).send(`GET requested for id ${req.params.userId}`); }) .put((req: express.Request, res: express.Response) => { res.status(200).send(`PUT requested for id ${req.params.userId}`); }) .patch((req: express.Request, res: express.Response) => { res.status(200).send(`PATCH requested for id ${req.params.userId}`); }) .delete((req: express.Request, res: express.Response) => { res.status(200).send(`DELETE requested for id ${req.params.userId}`); }); return this.app; }
위의 코드를 사용하면 모든 REST API 클라이언트가 POST
또는 GET
요청으로 users
엔드포인트를 호출할 수 있습니다. 마찬가지로 클라이언트가 GET
, PUT
, PATCH
또는 DELETE
요청으로 /users/:userId
끝점을 호출할 수 있습니다.

그러나 /users/:userId
의 경우 get()
, put()
, patch()
또는 delete()
함수보다 먼저 실행되는 all()
함수를 사용하여 일반 미들웨어도 추가했습니다. 이 기능은 (나중에) 인증된 사용자만 액세스할 수 있는 경로를 만들 때 유용합니다.
다른 미들웨어와 마찬가지로 .all()
함수에는 Request
, Response
및 NextFunction
의 세 가지 유형의 필드가 있다는 것을 눈치채셨을 것입니다.
- 요청은 Express.js가 처리할 HTTP 요청을 나타내는 방식입니다. 이 유형은 기본 Node.js 요청 유형을 업그레이드하고 확장합니다.
- 응답은 마찬가지로 Express.js가 HTTP 응답을 나타내는 방식으로 기본 Node.js 응답 유형을 다시 확장합니다.
- 덜 중요한 것은
NextFunction
이 콜백 함수 역할을 하여 제어가 다른 미들웨어 기능을 통과할 수 있도록 하는 것입니다. 그 과정에서 모든 미들웨어는 컨트롤러가 최종적으로 요청자에게 응답을 보내기 전에 동일한 요청 및 응답 개체를 공유합니다.
Node.js 진입점 파일, app.ts
이제 몇 가지 기본 경로 스켈레톤을 구성했으므로 애플리케이션의 진입점 구성을 시작합니다. 프로젝트 폴더의 루트에 app.ts
파일을 만들고 다음 코드로 시작하겠습니다.
import express from 'express'; import * as http from 'http'; import * as winston from 'winston'; import * as expressWinston from 'express-winston'; import cors from 'cors'; import {CommonRoutesConfig} from './common/common.routes.config'; import {UsersRoutes} from './users/users.routes.config'; import debug from 'debug';
이 기사의 현재 시점에서 이러한 가져오기 중 2개만 새롭습니다.
-
http
는 Node.js 네이티브 모듈입니다. Express.js 애플리케이션을 시작하는 데 필요합니다. -
body-parser
는 Express.js와 함께 제공되는 미들웨어입니다. 제어가 자체 요청 처리기로 이동하기 전에 요청을 구문 분석합니다(우리의 경우 JSON으로).
이제 파일을 가져왔으므로 사용할 변수를 선언하기 시작합니다.
const app: express.Application = express(); const server: http.Server = http.createServer(app); const port = 3000; const routes: Array<CommonRoutesConfig> = []; const debugLog: debug.IDebugger = debug('app');
express()
함수는 http.Server
개체에 추가하는 것으로 시작하여 코드 전체에 전달할 기본 Express.js 응용 프로그램 개체를 반환합니다. ( express.Application
을 구성한 후 http.Server
를 시작해야 합니다.)
일반적으로 앱의 프런트 엔드에 사용되는 표준 포트 80(HTTP) 또는 443(HTTPS) 대신 Number
가 자동으로 유추하는 포트 3000에서 수신합니다.
왜 포트 3000인가?
포트가 3000이어야 한다는 규칙은 없습니다. 지정하지 않으면 임의의 포트가 할당됩니다. 그러나 3000은 Node.js와 Express.js에 대한 문서 예제 전체에서 사용되므로 여기에서 전통을 이어갑니다.
Node.js가 프런트 엔드와 포트를 공유할 수 있습니까?
백엔드가 표준 포트의 요청에 응답하기를 원하는 경우에도 여전히 사용자 정의 포트에서 로컬로 실행할 수 있습니다. 특정 도메인이나 하위 도메인이 있는 포트 80 또는 443에서 요청을 수신하려면 역방향 프록시가 필요합니다. 그런 다음 내부 포트 3000으로 리디렉션합니다.
routes
배열은 아래에서 볼 수 있듯이 디버깅 목적으로 경로 파일을 추적합니다.
마지막으로 debugLog
는 console.log
와 유사한 기능으로 종료되지만 더 나은 점은 파일/모듈 컨텍스트를 호출하려는 범위가 자동으로 지정되기 때문에 미세 조정이 더 쉽습니다. (이 경우 문자열로 debug()
생성자에 전달할 때 "app"이라고 불렀습니다.)
이제 모든 Express.js 미들웨어 모듈과 API 경로를 구성할 준비가 되었습니다.
// here we are adding middleware to parse all incoming requests as JSON app.use(express.json()); // here we are adding middleware to allow cross-origin requests app.use(cors()); // here we are preparing the expressWinston logging middleware configuration, // which will automatically log all HTTP requests handled by Express.js const loggerOptions: expressWinston.LoggerOptions = { transports: [new winston.transports.Console()], format: winston.format.combine( winston.format.json(), winston.format.prettyPrint(), winston.format.colorize({ all: true }) ), }; if (!process.env.DEBUG) { loggerOptions.meta = false; // when not debugging, log requests as one-liners } // initialize the logger with the above configuration app.use(expressWinston.logger(loggerOptions)); // here we are adding the UserRoutes to our array, // after sending the Express.js application object to have the routes added to our app! routes.push(new UsersRoutes(app)); // this is a simple route to make sure everything is working properly const runningMessage = `Server running at http://localhost:${port}`; app.get('/', (req: express.Request, res: express.Response) => { res.status(200).send(runningMessage) });
expressWinston.logger
는 Express.js에 연결되어 debug
와 동일한 인프라를 통해 완료된 모든 요청에 대해 세부 정보를 자동으로 로깅합니다. 우리가 전달한 옵션은 디버그 모드에 있을 때 더 자세한 로깅(기본값)과 함께 해당 터미널 출력을 깔끔하게 형식화하고 색상을 지정합니다.
expressWinston.logger
를 설정한 후 경로를 정의해야 합니다.
마지막으로 그리고 가장 중요한 것은:
server.listen(port, () => { routes.forEach((route: CommonRoutesConfig) => { debugLog(`Routes configured for ${route.getName()}`); }); // our only exception to avoiding console.log(), because we // always want to know when the server is done starting up console.log(runningMessage); });
이것은 실제로 우리의 서버를 시작합니다. 일단 시작되면 Node.js는 디버그 모드에서 우리가 구성한 모든 경로의 이름을 보고하는 콜백 함수를 실행합니다. 지금까지는 UsersRoutes
. 그 후 콜백은 프로덕션 모드에서 실행 중일 때도 백엔드가 요청을 수신할 준비가 되었음을 알려줍니다.
TypeScript를 JavaScript로 변환하고 앱을 실행하도록 package.json
업데이트
이제 골격을 실행할 준비가 되었으므로 먼저 TypeScript 변환을 활성화하기 위한 상용구 구성이 필요합니다. 프로젝트 루트에 tsconfig.json
파일을 추가해 보겠습니다.
{ "compilerOptions": { "target": "es2016", "module": "commonjs", "outDir": "./dist", "strict": true, "esModuleInterop": true, "inlineSourceMap": true } }
그런 다음 다음 스크립트 형식으로 package.json
에 최종 수정을 추가하기만 하면 됩니다.
"scripts": { "start": "tsc && node --unhandled-rejections=strict ./dist/app.js", "debug": "export DEBUG=* && npm run start", "test": "echo \"Error: no test specified\" && exit 1" },
test
스크립트는 이 시리즈의 뒷부분에서 대체할 자리 표시자입니다.
start
스크립트의 tsc 는 TypeScript에 속합니다. TypeScript 코드를 JavaScript로 변환하여 dist
폴더에 출력하는 역할을 합니다. 그런 다음 node ./dist/app.js
로 빌드된 버전을 실행합니다.
--unhandled-rejections=strict
를 Node.js에 전달합니다(Node.js v16+에서도 가능). 실제로는 직접적인 "크래시 및 스택 표시" 접근 방식을 사용하여 디버깅하는 것이 expressWinston.errorLogger
개체를 사용하는 더 멋진 로깅보다 더 간단하기 때문입니다. 이는 처리되지 않은 거부에도 불구하고 Node.js가 계속 실행되도록 하면 서버가 예기치 않은 상태가 되어 추가(그리고 더 복잡한) 버그가 발생할 수 있는 프로덕션 환경에서도 가장 자주 발생합니다.
debug
스크립트는 start
스크립트를 호출하지만 먼저 DEBUG
환경 변수를 정의합니다. 이것은 우리의 모든 debugLog()
문(그리고 우리가 수행하는 것과 동일한 debug
모듈을 사용하는 Express.js 자체의 유사한 문)을 활성화하여 터미널에 유용한 세부 정보를 출력하도록 하는 효과가 있습니다. 표준 npm start
가 있는 프로덕션 모드의 서버.
npm run debug
를 직접 실행하고 npm start
와 비교하여 콘솔 출력이 어떻게 변경되는지 확인합니다.
팁: DEBUG=*
대신 DEBUG=app
을 사용하여 디버그 출력을 app.ts
파일의 자체 debugLog()
문으로 제한할 수 있습니다. debug
모듈은 일반적으로 매우 유연하며 이 기능도 예외는 아닙니다.
export
는 Mac 및 Linux에서 작동하는 방식이므로 Windows 사용자는 export
를 SET
로 변경해야 할 것입니다. 프로젝트에서 여러 개발 환경을 지원해야 하는 경우 교차 환경 패키지가 여기에서 간단한 솔루션을 제공합니다.
Live Express.js 백엔드 테스트
npm run debug
또는 npm start
가 계속 진행되면 REST API는 포트 3000에서 요청을 처리할 준비가 됩니다. 이 시점에서 cURL, Postman, Insomnia 등을 사용하여 백엔드를 테스트할 수 있습니다.
사용자 리소스에 대한 스켈레톤만 생성했으므로 본문 없이 요청을 보내 모든 것이 예상대로 작동하는지 확인할 수 있습니다. 예를 들어:
curl --request GET 'localhost:3000/users/12345'
백엔드는 GET requested for id 12345
응답을 다시 보내야 합니다.
POST
ing의 경우:
curl --request POST 'localhost:3000/users' \ --data-raw ''
이것과 우리가 스켈레톤을 구축한 다른 모든 유형의 요청은 매우 유사하게 보일 것입니다.
TypeScript를 사용한 신속한 Node.js REST API 개발 준비
이 기사에서는 프로젝트를 처음부터 구성하고 Express.js 프레임워크의 기본 사항을 살펴봄으로써 REST API를 만들기 시작했습니다. 그런 다음 이 시리즈의 다음 기사에서 재사용할 패턴인 UsersRoutesConfig
를 확장하는 CommonRoutesConfig
로 패턴을 구축하여 TypeScript를 마스터하기 위한 첫 번째 단계를 밟았습니다. 애플리케이션을 빌드하고 실행하기 위한 스크립트와 함께 새로운 경로와 package.json
을 사용하도록 app.ts
진입점을 구성하여 완료했습니다.
그러나 Express.js와 TypeScript로 만든 REST API의 기본 사항도 상당히 관련되어 있습니다. 이 시리즈의 다음 부분에서는 사용자 리소스에 대한 적절한 컨트롤러를 만드는 데 중점을 두고 서비스, 미들웨어, 컨트롤러 및 모델에 대한 몇 가지 유용한 패턴을 파헤칩니다.
전체 프로젝트는 GitHub에서 사용할 수 있으며 이 기사의 마지막 코드는 toptal-article-01
분기에서 찾을 수 있습니다.