혼자 JS, Serverless 갖고 놀기 - 프로젝트 세팅편
JS로 서버를 만드는 것은 Express의 세팅이 쉬워서 별로 어렵지 않다. 하지만 Serverless 배포 방식에 맞게 프로젝트를 세팅하는 것은 생소한 작업이라 개인적으로 어려웠다.
일단 API 작성을 편리하게 하기 위해 express
세팅부터 시작한다.
npm init -y
npm i express
npm으로 프로젝트를 생성하고 express
를 설치한다.
app.js
에 express
로 API를 작성한다.
설정 파일과 섞이는 걸 방지하기 위해 폴더를 하나 만들고 그 안에서 파일을 생성한다.
/**@ server/app.js */
import express from "express";
import routes from "./routes";
const app = express();
app.use("/", routes);
app.use((req, res, next) => {
res.status(404).send();
});
app.use((err, req, res, next) => {
res.status(err.status || 500).send();
});
export default app;
/**@ server/routes/index.js */
import { Router } from "express";
import { baseModel } from "../../components/baseModel";
import { intro } from "../../components/intro/model";
const router = Router();
router.get("/intro", (req, res) => {
res.status(200).send(baseModel(intro));
});
export default router;
이렇게만 해도 서버를 실행해서 HTML 응답을 받는 게 가능하다.
하지만 이 프로젝트에선 서버가 계속 돌아가는게 아니라 서버리스 방식으로 동작하길 원한다.
서버리스 구조를 쉽게 설계하고 운영하기 위해 Serverless Framework
를 설치한다. 또한 Serverless Framework
랑 Express
를 연결하기 위해 aws-serverless-express
도 같이 설치한다.
npm i -g serverless
npm i aws-serverless-express
원래 serverless 활용을 위해서는 Aws Lambda와 Client 사이에서 HTTP 요청에 대한 라우팅을 처리하는 API Gateway하고 인터페이스를 맞춰야한다. 이를
aws-serverless-express
의 도움없이 하려면 Lambda의 event 구조나 Route 설정 등 진입장벽이 높다고 한다.비슷한 역할로
serverless-http
도 있는데 npm 사용량은 비슷하니 맘에 드는 걸 선택하면 될 것 같다.
Serverless
작동을 위해선 핸들러와 설정 파일이 필요하다.
/**@ server/handler.js */
import * as serverlessExpress from "aws-serverless-express";
import app from "./app.js";
const server = serverlessExpress.createServer(app);
export const handler = (event, context) => {
serverlessExpress.proxy(server, event, context);
};
#**@ serverless.yml *#
service: nameYouWant
provider:
name: aws
runtime: nodejs20.x
region: ap-northeast-2
apiName: nameYouWant
functions:
funtionNameYouWant:
handler: server/handler.handler
events:
- http:
path: /{proxy+}
method: any
설정 파일에선 배포할 환경과 핸들러를 지정한다. region
의 경우 우리나라에 맞게 수정해주자.
핸들러에서 .
앞은 핸들러 파일 경로이고, 뒤는 핸들러에서 export한 객체 이름이다. 꼭 handler
가 이름일 필요는 없다.
events
는 허용할 path와 method를 지정해줄 수 있다. 딱 맞게 지정해주면 맞지 않는 요청을 해도 lambda가 처리하기 전에 404 처리가 되어 요금을 줄일 수 있다고 한다. 지금은 개발 용이성을 위해 모든 요청을 허용해놨다.
Serverless Framework
가 복잡한 Lambda 배포를 대신 해주기 위해선 Aws 권한이 필요하다.
Aws IAM Access Key 발급 가이드
위 링크의 1~15번을 수행해서 Access Key와 Secret Access Key를 복사해둔다.
serverless config credentials --provider aws --key 7YEE7ANQHFDGLZAKIAQR --secret yyyMEboMvA/IXUFI7djIoMRBJ3b0kFQ8p8TN6pKW
위 커맨드의 --key
부분과 --secret
부분을 복사해둔 Access Key와 Secret Access Key로 각각 대체해서 실행하면 권한을 줄 수 있다.
serverless deploy
deploy 커맨드를 실행하면 Serverless Framework
가 프로젝트를 Lambda로 배포하기 시작한다.
배포가 완료되면 url을 확인할 수 있다. Aws Lambda에 직접 들어가도 배포된 걸 확인할 수 있다.
개발 중에 결과물을 배포로만 확인해야한다면 매우매우 불편하고 요금도 걱정될 것이다. Serverless Framework
의 배포 결과를 로컬 환경에서 재현해주는 플러그인이 serverless-offline
이다.
Serverless Framework
는 여러 추가 기능이 플러그인으로 구현되어 있고, 쉽게 적용할 수 있다. 먼저 설치를 진행한다.
npm i -D serverless-offline
설치한 모듈을 serverless.yml
설정 파일에 추가한다.
#**@ serverless.yml *#
service: nameYouWant
plugin:
- serverless-offline #**@new *#
custom:
serverless-offline: #**@new *#
httpPort: 4000
lambdaPort: 4001
provider:
name: aws
runtime: nodejs20.x
region: ap-northeast-2
apiName: nameYouWant
functions:
funtionNameYouWant:
handler: server/handler.handler
events:
- http:
path: /{proxy+}
method: any
원래는 plugin
에 추가해주기만 해도 된다. 그런데 종종 포트가 이미 사용 중이라는 오류가 있어 custom
에서 포트를 변경시켰다.
설정을 마친 후 다음 명령어를 커맨드에 입력하면 실행된다.
serverless offline start
사용해보면 이상함을 느낄 것이다. hot reload가 안되는 것은 그럴 수 있겠구나 싶지만, 새로고침해도 수정사항이 반영되지 않는다.
--reloadHandler
옵션을 넣어주면 새로고침 시 반영된다.
serverless offline start --reloadHandler
Serverless 기본 값으로 배포를 진행하면 현재 디렉토리 구조와 파일이 그대로 서버에 올라간다. 함수마다 용량 제한이 빡빡한 lambda로서는 바람직한 상황은 아니다.
Webpack
을 Serverless Framework
에 연결해서 번들링해 파일의 용량 감소 및 불필요한 네트워크 요청을 줄일 수 있다. serverless-webpack
플러그인을 이용해 쉽게 연결이 가능하다.
npm i -D serverless-webpack
플러그인을 설정한다.
#**@ serverless.yml *#
service: nameYouWant
plugin:
- serverless-offline
- serverless-webpack #**@new *#
custom:
serverless-offline:
httpPort: 4000
lambdaPort: 4001
webpack: #**@new *#
webpackConfig: 'webpack.config.js'
includeModules: true
packager: 'npm'
provider:
name: aws
runtime: nodejs20.x
region: ap-northeast-2
apiName: nameYouWant
functions:
funtionNameYouWant:
handler: server/handler.handler
events:
- http:
path: /{proxy+}
method: any
includeModules
는 결과물 패키지에 node_modules
폴더를 추가하는 옵션이다. true
로 설정해야 프로젝트 의존성으로 있는 패키지를 사용할 수 있다.
웹팩 자체 설정에 필요한 의존성도 설치한다.
npm i -D webpack webpack-node-externals
npm i -D babel-loader @babel/core @babel/preset-env
npm i -D css-loader sass-loader mini-css-extract-plugin
웹팩 설정을 구성한다.
/**@ webpack.config.js */
const path = require('path');
const serverlessWebpack = require('serverless-webpack');
const nodeExternals = require('webpack-node-externals');
module.exports = {
// entry를 자동으로 가져와줌, 기본적으로 handler.js
entry: serverlessWebpack.lib.entries,
output: {
libraryTarget: 'commonjs',
path: path.join(__dirname, '.webpack/service'),
filename: '[name].js',
},
// handler는 서버에서 돌아가므로 node 타겟
target: 'node',
mode: serverlessWebpack.lib.webpack.isLocal ? 'development' : 'production',
// 번들링에서는 의존성을 포함하지 않겠다는 옵션,
// includeModules를 통해 패키징에서 포함하고 있으므로 설정함
externals: [nodeExternals()],
resolve: {
// server 디렉토리 내부에서 absolute import를 사용하기 위한 용도
modules: [path.resolve('./server'), 'node_modules'],
},
module: {
rules: [
{
test: /\.js$/,
exclude: /node_modules/,
use: [
{
loader: 'babel-loader',
options: {
presets: ['@babel/preset-env'],
},
},
],
},
],
},
};
이제 serverless offline start
을 실행하면 디렉토리에 .webpack
폴더가 생성되며 내부에 handler.js
가 있는 걸 볼 수 있다.
또한 serverless package
를 실행하면 배포 직전의 패키징 결과를 확인할 수 있는데 .serverless
폴더에 압축된 파일을 풀어보면 번들링 결과물과 node_modules
가 들어있는 걸 볼 수 있다.
위 단계까지는 서버의 api를 관리하는 handler.js
만 번들링했다. 이 프로젝트는 웹 브라우저에서 실행될 파일도 서버에 받아와야 하므로 웹팩에 해당 파일들도 추가해야 한다.
현재 serverless-webpack
의 설정은 output.libraryTarget
이 'commonjs'
로 설정되어있어 번들링한 JS 파일 모듈을 commonjs 방식으로 로드한다.
🔽
webpack.config.js
의output.libraryTarget
을 삭제하면 나타나는 오류
때문에 현재 웹팩 설정의 entry
에 브라우저에서 실행할 main.js
를 끼워넣으면 작동은 하지만 브라우저 콘솔에 오류가 나타난다.
🔽
webpack.config.js
의entry
에main.js
을 그대로 추가하면 나타나는 오류
사실
libraryTarget
은 서버와 웹 서로 호환이 되는 옵션이 있다고 한다. 다만target
은 아예node
와web(default)
병행이 불가능하다. 현재 상태에서 동작은 하더라도 설정을 분리하는 게 안전할 거라고 생각했다.
웹팩 설정을 파일에 맞춰 분리할 방법을 고민해보자
Serverless Framework
는 배포와 패키징을 분리해서 실행하는 방법이 있다.
serverless deploy --package [패키지 경로]
위 명령어는 serverless package
로 만들어진 패키지를 그대로 배포에 보내는 기능을 한다. 원랜 CI/CD 목적으로 만들었다고 한다.
목표는 만들어진 패키지에 브라우저용 코드 번들링 결과물을 끼워넣고 배포에 보내는 것이다. 먼저 웹팩을 단독 실행하기 위한 의존성을 설치한다.
npm i -D webpack-cli
설정 파일을 분리해서 새로운 하나의 파일로 만든다. 이 프로젝트에선 브라우저용 파일을 server/src
폴더에 몰아놨기에 파일 이름에 src
를 넣어 구분했다.
/**@ webpack.src.config.js */
const path = require('path');
const serverlessWebpack = require('serverless-webpack');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
module.exports = {
entry: {
'server/src/main': './server/src/main.js',
'server/src/normalize': './server/src/normalize.css',
'server/src/style': './server/src/style.scss',
'server/src/intro': './server/src/intro.scss',
'server/src/profile': './server/src/profile.scss',
'server/src/project-list': './server/src/project-list.scss',
'server/src/project': './server/src/project.scss',
},
output: {
path: path.join(__dirname, '.webpack/service'),
},
// serverless-webpack의 설정과 mode를 맞추기 위함
mode: serverlessWebpack.lib.webpack.isLocal ? 'development' : 'production',
plugins: [new MiniCssExtractPlugin()],
module: {
rules: [
{
test: /\.js$/,
use: [
{
loader: 'babel-loader',
options: {
presets: ['@babel/preset-env'],
},
},
],
exclude: /node_modules/,
},
{
test: /\.(sa|sc|c)ss$/i,
use: [MiniCssExtractPlugin.loader, 'css-loader', 'sass-loader'],
exclude: /node_modules/,
},
],
},
devtool: 'source-map',
};
CSS 파일을 style-loader
를 사용하지 않고 번들링하고 있다. 이는 현재 프로젝트에서 CSS를 JS에 import하지 않고 style tag를 직접 헤더에 추가하는 방식을 사용하고 있기 때문이다. 따라서 entry
에도 CSS 파일을 모두 입력하고, MiniCssExtractPlugin
으로 loader를 거친 결과물을 별도의 파일로 저장하고 있다.
번들링은 아래 커맨드로 실행할 수 있다.
webpack --config webpack.src.config.js
.webpack
폴더 내부에서 결과물을 볼 수 있다.
만들어진 결과물을 serverless package
로 만들어진 패키지에 삽입하려고 한다. 만들어진 패키지는 .zip
형식으로 압축되어 있다. 압축된 파일에 쉽게 데이터를 추가하기 위해 adm-zip
을 사용한다.
npm i -D adm-zip
다른 압축 관련 라이브러리에 비해 복잡한 사전 지식을 익히거나 콜백 문법에 익숙해질 필요가 없다.
압축 삽입 기능을 수행할 코드를 작성한다.
/**@ buildSrc.js */
const fs = require('fs');
const Zip = require('adm-zip');
function main() {
// 패키지와 삽입할 데이터가 있는지 확인
if (
!(
fs.existsSync('.webpack/service/server') &&
fs.existsSync('.serverless/portfolio.zip')
)
) {
console.error('webpack & package not done.');
return;
}
// zip 파일 데이터를 메모리에 불러옴
const zip = new Zip('.serverless/portfolio.zip');
// zip 파일 데이터에 local에 있는 폴더 데이터를 추가
zip.addLocalFolder('.webpack/service/server', './server');
// 메모리에 있는 데이터를 local에 반영
zip.writeZip();
}
main();
아래 명령어로 코드를 실행할 수 있다.
node buildSrc.js
배포할 때 마다 명령어를 여러 개 작성하기는 번거로우니 script로 작성해둔다.
/**@ package.json */
{
// 생략
"scripts": {
"start": "npm run webpack & serverless offline start --reloadHandler",
"package": "serverless package",
"webpack": "webpack --config webpack.src.config.js",
"insert": "node buildSrc.js",
"deploy": "npm run package && npm run webpack && npm run insert && serverless deploy --package .serverless"
},
// 생략
}
npm run deploy
를 실행하면 패키징 => 브라우저 코드 번들링 => 패키지에 삽입 => 배포
의 순서로 배포를 진행할 수 있다.
추가로 이젠 serverless-offline
을 실행할 때도 브라우저 코드 번들링을 같이 해줘야한다. serverless offline start --reloadHandler
는 실행 후 프로세스가 포그라운드에서 계속 돌아가는 방식이므로 앞에 &
로 브라우저 코드 번들링 스크립트를 백그라운드에서 실행시켜준다.
원랜 serverless의 번들링이 먼저 일어난 뒤 브라우저 코드 번들링이 이루어져야 하지만,
webpack
이serverless offline
보다 딜레이가 있어서 저 순서로도 정상작동한다.
현 상태로도 패키징 및 배포는 성공적으로 수행된다. 하지만 serverless-offline
의 reloadHandler
옵션이 외부의 웹팩 번들링까지 연결되진 못하므로 새로고침해도 변경 사항이 반영되지 않는다. 이는 개발용이성이 떨어지므로 롤백하고 내부에서 설정을 구분할 방법을 모색한다.
우리가 보통 웹팩 설정 파일 하나당 단일 설정을 사용하지만, 사실 여러 설정을 동시에 사용할 수 있다.
const slsConfig = {
/**@ 생략 */
};
const srcConfig = {
/**@ 생략 */
};
module.exports = [slsConfig, srcConfig];
위 코드처럼 module.exports
에 배열로 config 값을 전달하면 여러 설정을 분리해서 번들링 할 수 있다.
그런데 서버용 코드와 브라우저용 코드의 설정을 한 파일로 가져와서 배열로 연결한 뒤 배포를 시도해보면 패키징에서 오류가 발생한다.
🔽
webpack.config.js
에 기존 설정들을 가져와서 배열로 내보내면 나타나는 오류
serverless-offline
으로 실행한 로컬 환경에서는 해당 오류가 발생하지 않고 정상적으로 작동한다. 패키징에서만 오류를 확인할 수 있다.
포스팅을 위해 재점검을 하는 중 우연히 발견한 사실인데, 두 설정 객체의 output.path
가 동일하면 해당 오류가 발생한다. 브라우저용 설정에서 entry
와 output.path
를 수정한다.
const srcConfig = {
entry: {
main: './server/src/main.js',
normalize: './server/src/normalize.css',
style: './server/src/style.scss',
intro: './server/src/intro.scss',
profile: './server/src/profile.scss',
'project-list': './server/src/project-list.scss',
project: './server/src/project.scss',
},
output: {
path: path.join(__dirname, '.webpack/service/server/src'),
},
/**@ 생략 */
이젠 패키징에서 오류도 발생하지 않고 serverless-offline
의 reloadHandler
옵션도 원하던 대로 작동한다.
사실 기존
entry
의 key가 수정을 거듭하며 비효율적으로 작성되어 있었다. 근데 그 업보로node_modules
에서 오류를 띄우는 건 너무 가혹하지 않나..
express 의 인터페이스로 lambda를 활용할 수 있는 라이브러리가 있었군요!! 좋은 글 잘 보고가요 👍😁