[JS] Vanilla JS와 Serverless로 SSR 사이트 만들기 - 세팅

thru·2024년 2월 26일
1

혼자 JS, Serverless 갖고 놀기 - 프로젝트 세팅편


소개

JS로 서버를 만드는 것은 Express의 세팅이 쉬워서 별로 어렵지 않다. 하지만 Serverless 배포 방식에 맞게 프로젝트를 세팅하는 것은 생소한 작업이라 개인적으로 어려웠다.


프로젝트 생성

일단 API 작성을 편리하게 하기 위해 express 세팅부터 시작한다.

npm init -y
npm i express

npm으로 프로젝트를 생성하고 express를 설치한다.

app.jsexpress로 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

서버리스 구조를 쉽게 설계하고 운영하기 위해 Serverless Framework를 설치한다. 또한 Serverless FrameworkExpress를 연결하기 위해 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 처리가 되어 요금을 줄일 수 있다고 한다. 지금은 개발 용이성을 위해 모든 요청을 허용해놨다.

Aws 권한 설정

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 Offline

개발 중에 결과물을 배포로만 확인해야한다면 매우매우 불편하고 요금도 걱정될 것이다. 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

Webpack

Serverless 기본 값으로 배포를 진행하면 현재 디렉토리 구조와 파일이 그대로 서버에 올라간다. 함수마다 용량 제한이 빡빡한 lambda로서는 바람직한 상황은 아니다.

WebpackServerless 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.jsoutput.libraryTarget을 삭제하면 나타나는 오류

때문에 현재 웹팩 설정의 entry에 브라우저에서 실행할 main.js를 끼워넣으면 작동은 하지만 브라우저 콘솔에 오류가 나타난다.

🔽 webpack.config.jsentrymain.js을 그대로 추가하면 나타나는 오류

사실 libraryTarget은 서버와 웹 서로 호환이 되는 옵션이 있다고 한다. 다만 target은 아예 nodeweb(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의 번들링이 먼저 일어난 뒤 브라우저 코드 번들링이 이루어져야 하지만, webpackserverless offline보다 딜레이가 있어서 저 순서로도 정상작동한다.

문제점

현 상태로도 패키징 및 배포는 성공적으로 수행된다. 하지만 serverless-offlinereloadHandler옵션이 외부의 웹팩 번들링까지 연결되진 못하므로 새로고침해도 변경 사항이 반영되지 않는다. 이는 개발용이성이 떨어지므로 롤백하고 내부에서 설정을 구분할 방법을 모색한다.

채택 - 설정 내부에서 분리

우리가 보통 웹팩 설정 파일 하나당 단일 설정을 사용하지만, 사실 여러 설정을 동시에 사용할 수 있다.

const slsConfig = {
  /**@ 생략 */
};

const srcConfig = {
  /**@ 생략 */
};

module.exports = [slsConfig, srcConfig];

위 코드처럼 module.exports에 배열로 config 값을 전달하면 여러 설정을 분리해서 번들링 할 수 있다.

그런데 서버용 코드와 브라우저용 코드의 설정을 한 파일로 가져와서 배열로 연결한 뒤 배포를 시도해보면 패키징에서 오류가 발생한다.

🔽 webpack.config.js에 기존 설정들을 가져와서 배열로 내보내면 나타나는 오류

serverless-offline으로 실행한 로컬 환경에서는 해당 오류가 발생하지 않고 정상적으로 작동한다. 패키징에서만 오류를 확인할 수 있다.

해결법

포스팅을 위해 재점검을 하는 중 우연히 발견한 사실인데, 두 설정 객체의 output.path가 동일하면 해당 오류가 발생한다. 브라우저용 설정에서 entryoutput.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-offlinereloadHandler 옵션도 원하던 대로 작동한다.

사실 기존 entry의 key가 수정을 거듭하며 비효율적으로 작성되어 있었다. 근데 그 업보로 node_modules에서 오류를 띄우는 건 너무 가혹하지 않나..

profile
프론트 공부 중

1개의 댓글

comment-user-thumbnail
2024년 3월 11일

express 의 인터페이스로 lambda를 활용할 수 있는 라이브러리가 있었군요!! 좋은 글 잘 보고가요 👍😁

답글 달기