IT일상

8시간 만에 서비스 만들고 운영까지 (fastify, react) 본문

우아한테크코스

8시간 만에 서비스 만들고 운영까지 (fastify, react)

solo5star 2023. 9. 9. 21:53
리뉴얼 된 블로그로 보기: https://solo5star.dev/posts/55/

 

제목이 좀 자극적이긴 한데... ㅎㅎ 이번에 빠르게 서비스를 만들면서 어떤 고민을 했는지 정리해보고자 쓴 글입니다. 8시간이 정말로 8시간이냐고 물으면, 아닌거 같기도 해요. 지금까지 쌓은 경험이 많은 것을 단축시켜주었으니까요.

 

지하철을 타고 집에 가면서 서비스와 기술 스택을 구상하였고, 집에 도착한 후 IDE를 켜고 약 6시간동안 구현을 진행하였습니다. 나머지 2시간은 다음 날 배포 직전에 자잘한 오류 및 디테일을 잡는 데 사용하였네요.

 

 

배경

저는 우아한테크코스 5기 프론트엔드 과정(이하 우테코)을 진행하고 있습니다. 우테코 교육 과정에는 다양한 미션이 있는데요, 이번에 진행했던 미션이 웹 사이트의 성능을 개선해보는 미션입니다. (미션 레포지토리: https://github.com/woowacourse/perf-basecamp)

 

처참한 Lighthouse 측정 결과 점수

주어진 memegle 사이트는 곳곳에 성능을 저하시키는 요소들로 잔뜩 있는데요, 이를 개선하고 그 결과를 Chrome의 Lighthouse를 사용하여 측정합니다.

Chrome의 Lighthouse는 웹 사이트의 성능, 접근성, PWA, SEO를 측정하고 LCP, FCP, SI, CLS 등의 지표와 권장 사항을 제시해주는 도구입니다.

미션의 개선 목표는 Lighthouse로 측정하였을 때 나오는 성능 점수가 95점 이상이어야 합니다.

 

여기서, 저는 다른 크루들의 점수가 어떻게 나오는지, 또 점수가 잘 나오는 크루의 웹 사이트를 보며 배우고 싶었는데요! 여기저기 물어보고 다니기엔 개개인의 미션 진행도 다를테고... 번거롭기도 해서 😓 Lighthouse 측정 결과를 한 군데에 모아서 볼 수 있는 사이트를 만들어보자! 라는 아이디어를 생각해냈습니다.

 

 

기획

당시 머릿속으로 생각한 것을 그림판으로 재현해보았습니다

간단합니다. 한 줄로 요약하자면,

사용자들이 본인의 사이트의 Lighthouse 점수를 측정하고 메인 화면에서 결과들을 볼 수 있는 사이트입니다.

 

기능을 세부적으로 나누고 중요도 순으로 나열하자면,

  1. 메인 화면에서 Lighthouse 측정 결과를 볼 수 있어야 합니다
  2. 사용자들은 자신의 사이트를 측정할 수 있어야 합니다
  3. Lighthouse 측정 결과를 상세히 볼 수 있어야 합니다
  4. Lighthouse 측정 이력(history)을 볼 수 있어야 합니다

주요 사용자는 우테코 크루들이고 내부에서만 사용합니다.

로그인 기능은 고려하지 않았는데, 핵심 가치를 전달하는데에 중요하지 않기 때문입니다.

 

 

기술 스택

프론트엔드는 개인적으로 숙련도가 가장 높은 React를 선택하였습니다. React로 소스 코드를 작성하고 빌드 시 출력되는 정적 파일은 Nginx로 서빙합니다.

 

백엔드는 React와 같은 언어인 JavaScript 중에서 알아보았는데, 가장 유명한 express는 커뮤니티가 크고 사용법이 간단하다는 장점이 있지만 프로덕션으로 운영하기엔 너무 날 것에 가깝다고 생각하였습니다. 제가 경험했던 django나 nest.js에서는 일부 라우트에서 에러가 발생하더라도 서버가 종료되지 않고 계속 동작합니다. 그러나 express는 에러를 항상 catch하여 next 함수에 전달해주어야 합니다. (https://expressjs.com/en/guide/error-handling.html)

 

핸들링하지 못한 예기치 않은 에러는 비즈니스에 치명적일 수 있기 때문에 안전한 운영을 위해서 서버가 아예 종료되어야 한다는 관점도 있지만, 본 서비스에서는 다소 치명적인 에러더라도 계속 이용할 수 있도록 하는 것이 더 중요하다고 생각했습니다. 따라서 선택지에서 express는 제외하였습니다.

 

nest.js는 express 다음으로 많이 언급되는 프레임워크인데요, Java Spring과 유사한 DI(Dependency Injection, 의존성 주입)을 통해 IoC(Inversion of Control, 제어의 역전)를 실현함으로서 프레임워크가 의존성을 효과적으로 제어해주지만, 가볍게 만들기엔 너무 복잡하다고 생각되어 제외하였습니다.

 

express의 압도적 차이

위와 같은 이유로, express와 nestjs를 제외한 라이브러리 중 에러 핸들링, 라우팅 등 기본적인 영역에서 신경쓰지 않아도 되면서 비즈니스 로직을 쉽고 빠르게 작성할 수 있는 점에 가중치를 주었습니다.

 

사실 백엔드 프레임워크가 express, nest.js 외에 어떤 것이 있는지 잘 몰랐는데, 그럴 때 npm trends 사이트를 이용하면 좋습니다. 잘 아는 라이브러리의 이름을 입력하면 유사한 것을 추천해줍니다. 추천을 통해 저는 fastify, hapi, koa, restify 4개를 비교해보았는데 koa와 fastify가 좀 더 메이저인 것 같아 두 개를 비교하였습니다.

 

// koa
const Koa = require('koa');
const Router = require('@koa/router');

const app = new Koa();
const router = new Router();

router.get('koa-example', '/', (ctx) => {
  ctx.body = 'Hello World';
});

app
  .use(router.routes())
  .use(router.allowedMethods());

app.listen(1234);

koa는 express의 저자(TJ Holowaychuk)가 만든 modern 프레임워크입니다. 동일한 저자가 만들어서 그런지... express와 굉장히 유사합니다. 예제만 보았을 땐, router도 모듈화가 되어있는 것으로 보아 express보다 모듈이 더 많이 나누어져 있는 것 같습니다. koa는 built-in error handler가 있다는 점과 express와 유사하여 학습이 적다는 점이 좋았지만 모듈화가 너무 많이 되어있는 것 같아 비즈니스 로직에만 집중할 수 있을 것 같지않아 보입니다.

 

// fastify
import Fastify from 'fastify'

const fastify = Fastify({ logger: true })

// Declare a route
fastify.get('/', function (request, reply) {
  reply.send({ hello: 'world' })
})

// Run the server!
fastify.listen({ port: 3000 }, function (err, address) {
  if (err) {
    fastify.log.error(err)
    process.exit(1)
  }
  // Server is now listening on ${address}
})

fastify도 express처럼 작고 가볍게 시작할 수 있습니다. built-in error handler도 지원하구요. 대략 보았을 때 express 만큼 쉽고, 간단한 앱을 만들기 위한 정도의 기능들은 built-in으로 제공하는 것 같아 fastify를 선택하였습니다.

 

DB는 lowdb를 사용합니다. 정말 단순하게 로컬에 json으로 읽고 저장하는 DB입니다. MySQL이나 MariaDB와 함께 ORM을 사용하여도 생산성은 충분히 나오지만 6시간이라는 크게 제한된 시간 내에선 복잡도가 훨씬 낮은 lowdb가 적합하다고 판단하였습니다. (성능 또한 예상 접속 인원 내에서 크게 문제될 것이라 보진 않았습니다)

 

만약 장기적인 운영을 고려한다면? RDBMS(MySQL, MariaDB) 혹은 NoSQL(MongoDB)를 도입할 것 같습니다.

 

 

구현: lighthouse cli와 상호작용

lighthouse 점수 측정은 lighthouse cli 외부 프로세스를 호출하여 측정하는데요, 커맨드를 입력하게 되면 진행 상황이 실시간으로 출력됩니다. 프로세스를 생성하는 것은 node.js의 exec을 쓰면 되지만, exec은 프로그램 호출이 종료된 후 출력을 한꺼번에 반환(return)해주기 때문에 출력을 사용자에게 실시간으로 전달하는 용도로는 적합하지 않습니다.

 

대안으로, node.js의 spawn을 사용하면 출력을 실시간으로 전달받을 수 있습니다.

import { spawn } from "child_process";

const requestedUrl = "https://<target_url>";
const childProcess = spawn("lighthouse", [
  "--max-wait-for-load=300000",
  "--disable-dev-shm-usage",
  "--only-categories=performance",
  "--output=json",
  "--chrome-flags=\"--headless --disable-gpu\"",
  requestedUrl,
]);
childProcess.stdout.on('data', (data) => { /* */ });
childProcess.stderr.on('data', (data) => { /* */ });

표준 출력(stdout)과 표준 에러(stderr)을 구독하여 출력을 실시간으로 전달받습니다. 참고로 진행 상황 출력 같은 것은 표준 에러(stderr)로 받고, 측정 결과 JSON은 표준 출력(stdout)을 통해 받을 수 있습니다.

 

 

구현: lighthouse cli 출력 실시간 전송

lighthouse cli에서 실시간으로 받는 진행 상황 출력을 브라우저에게도 실시간으로 보내주기 위한 방법으로는 SSE(Server-Sent Events)를 선택하였습니다.

 

HTTP 통신의 특성 상 클라이언트의 요청이 있어야만 응답을 보낼 수 있는데, 이를 거스르고(?) 서버→클라이언트로 통신을 할 수 있는 방법에는 3가지가 있습니다. Long Polling, SSE(Server-Sent Events), WebSocket 인데요, 이번에는 서버 측에서 이벤트가 발생할 때 마다 일방적으로 보내주기만 하면 되기 때문에 상태관리와 구현의 복잡도가 비교적 높은 WebSocket은 제외하였습니다. Long Polling은 브라우저에서 WebSocket을 지원하지 않을 때 대안으로 선택하는 방법인데다 Poll을 제어하는 것이 복잡할 수 있다고 생각하여 Long Polling 또한 제외하였습니다. 결과적으로 남은건 SSE네요!

 

복잡도를 차치하더라도 ... 사실 SSE(Server-Sent Events)가 이 상황에서 가장 적합한 모델이라 생각합니다! 제어도 간단한 것 같아 구현도 쉬울 것이라 보았습니다.

// fastify
import fastify from "fastify";
import { Observable } from "rxjs";

const server = fastify();

server.get('/lighthouse', async (request, reply) => {
  reply.raw.writeHead(200, {
    'Content-Type': 'text/event-stream',
    Connection: 'keep-alive',
    'Cache-Control': 'no-cache',
  });
  
  const childProcess$ = new Observable((subscriber) => {
    const childProcess = spawn('lighthouse', [ /* ... */ ]);
    childProcess.stderr.on('data', (data) => {
      subscriber.next({ type: 'message', data: data.toString() });
    });
    childProcess.on('close', () => subscriber.complete());
  });
  
  childProcess$.subscribe({
    next: ({ type, data }) => {
      reply.raw.write(
        `event: ${type}\n`
        + `data: ${JSON.stringify(data)}\n\n`
      );
    },
    complete: () => reply.raw.end(),
  });
});

// browser
const eventSource = new EventSource('http://localhost:8080/lighthouse');
eventSource.addEventListener('message', (event) => {
  console.log(event.data);
});

fastify 측에서는 Content-Type을 text/event-stream으로 해주고 이벤트가 발생할 때 마다 지정된 포맷에 맞게 body를 전송해주면 됩니다. browser측에서는 EventSource 객체를 통해 이벤트를 수신할 수 있습니다. 자세한 내용은 MDN 문서를 참고해주세요.

 

SSE를 수신하여 화면에 표시

lighthouse 측정은 약 10초~1분 정도 진행하는데, 그동안 아무런 피드백이 오지 않으면 사이트가 멈췄다고 오해하여 이탈할 수도 있겠다는 생각에 진행 상황을 실시간으로 피드백해주는 것을 우선순위를 높게 주었었습니다.

 

 

데이터 저장

프로젝트 혹은 서비스를 운영하기 위한 데이터를 저장할 때는 확장성, 유지보수성, 동시성, 성능 등 여러가지를 고려합니다. 그렇지만 사용자 수가 적고 짧게 운영되는 서비스이기 때문에 모두 고려하지 않았습니다. 덕분에 빠르게 구현을 마칠 수 있었습니다.

 

JSON 파일로 저장할 수도 있죠 ㅎㅎ;;

import { Low } from "lowdb";
import { JSONFile } from "lowdb/node";
import { join } from "node:path";
import { WORKDIR } from "../environment";
import { Light, LightHistory } from "./types";

const filename = join(WORKDIR, "data/db.json");

type DB = {
  lights: Light[];
  lightHistories: LightHistory[];
};

const db = new Low<DB>(new JSONFile(filename), {
  lights: [],
  lightHistories: [],
});

await db.read();

export default db;

lowdb 사용법은 정말 간단합니다. 읽어올 파일의 이름, 읽기(read) 동작 수행, 그리고 쓰기(write)만 잘 하면 됩니다.

 

운영 기간동안 쌓인 데이터는 52KB 밖에 되지 않습니다. (minify를 하지 않았음에도 불구하고) lowdb 특성상 모든 데이터가 메모리에 적재되는데, 현대의 컴퓨터 사양을 생각한다면 전혀 문제될 수준은 아닙니다. 브라우저에게 한꺼번에 전송하더라도 크게 문제될 수준은 아니네요.

 

lighthouse 측정 결과는 json으로 저장하는데 개별 용량이 꽤 커서 따로 저장하도록 했습니다.

 

 

구현: 프론트엔드

package.json

빠른 구현을 위해서 여러가지 라이브러리들을 깔아서 썼습니다. 정신없이 구현하느라 안쓴건데 지우지 않은 라이브러리도 있습니다...

 

주요 라이브러리만 소개하자면,

* vite: 번들링을 건드리지 않고 비즈니스에 집중하기 위해 사용하였습니다.

* react: 프론트엔드 라이브러리 중 기술 친숙도가 가장 높아 선택하였습니다.

* typescript: 런타임 에러로 인한 시간 소모를 줄이기 위해 사용하였습니다.

* tailwindcss: 유틸리티 식으로 CSS를 인라인으로 빠르게 작성할 수 있다는 점에서 선택하여 사용하였습니다.

* react-query, @suspensive/react-query: server state를 쉽게 다룰 수 있으며 suspense를 쉽게 사용할 수 있다는 점에서 선택하여 사용했습니다.

* react2-lighthouse-viewer: lighthouse 측정 결과 데이터 json을 props로 넣으면 결과 화면이 생성되는 라이브러리입니다.

 

빠르게 구현하는 데엔 react-query의 도움이 가장 컸습니다. react-query를 사용함으로서 서버로 부터 받아오는 데이터를 상태로 쉽게 관리할 수 있었고, suspense와의 연동으로 성공의 경우에만 집중할 수 있었습니다.

 

 

배포

개인 NAS가 있어서 배포는 어렵지 않았습니다. AWS에서 돈을 내고 EC2 인스턴스를 생성할 필요가 없었고 또 NAS와 굉장히 친숙했기 때문에... 비교적 가장 쉬운 단계였습니다.

 

proxmox가 설치되어 있는 i5-10400, 40GB RAM의 NAS

 

FROM node:18 as build

WORKDIR /app

# Install node modules
COPY package.json .
COPY package-lock.json .
RUN npm ci

# Copy all source codes and build
COPY . .
RUN npm run build

FROM nginx as production

COPY --from=build /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf

EXPOSE 80

CMD ["nginx", "-g", "daemon off;"]

배포 도구로 docker를 적극적으로 사용하였는데요, docker 컨테이너는 호스트의 환경과 격리되어 있어 호스트의 환경에 영향없이 배포할 수 있어 예기치 못한 문제를 최소한으로 줄일 수 있습니다.

 

프론트엔드는 nginx docker image를 기반으로 간단하게 빌드한 후 사용하였습니다. 백엔드는 외부 프로세스를 호출해야 하는데 lighthouse cli는 호스트에 설치되어 있기 때문에 부득이하게 dockerize를 할 수 없었습니다.

 

version: "3"
services:
  client:
    build: client
    restart: unless-stopped
    network_mode: host

 

docker-compose.yml을 작성하고 docker compose up -d 를 하면 클라이언트 배포가 끝납니다.

 

screen -S server
npm run start
<Ctrl + Alt + d>

백엔드는 linux screen 명령을 사용하여 백그라운드에서 동작하도록 했습니다.

 

도메인은 기존에 사용하던 것에 서브 도메인을 추가하여 사용하였습니다. DDNS(Duck DNS)를 사용하기 때문에 CNAME으로 DDNS 주소를 추가해주었습니다.

 

제 NAS에는 nginx-proxy-manager가 셋업되어 있었기 때문에 도메인→IP 포워딩과 HTTPS 인증서 발급은 1분안에 완료할 수 있었습니다.

 

여기까지! IDE를 켠 시간을 기준으로 6시간이 지났습니다. 우테코 크루들에게 배포하기 전에 오류를 점검하고 디테일을 잡는데에 2시간 정도 더 소모하였습니다.

 

페이지 구성은 총 4개로 되어있습니다. 목록 페이지와 측정하기 페이지, 상세 정보 페이지, Lighthouse 측정 결과 페이지로 구성되어 있습니다.

 

 

운영

우테코 크루들에게 배포했고 많은 분들이 사용해주었습니다! 점수가 잘 나온 사람들의 lighthouse 측정 결과를 보면서 비교해보고 더욱 개선함으로서 (개인적으로도) 많이 유용하게 사용한 것 같습니다.

 

운영 기간 중 발생했던 이슈는 크게 없었습니다. 화면 우측 아래에 표시되는 측정하기 버튼을 실수로 position: absolute로 했다가 뷰포트에 위치가 제대로 고정되지 않았던 것을 position: fixed로 수정한 것 외에는 큰 이슈는 없었네요.

 

9월 4일~9월 9일동안 약 40개의 사이트를 측정하였습니다.

 

측정 횟수는 총 127회입니다! 😁 많은 분들이 사용해주셔서 정말 고마웠습니다.

 

 

후기

평소 아이디어를 종종 떠올리지만, 실행에 옮기는 경우는 적습니다. 설령 실행에 옮기더라도 완성하고 배포까지 도달하는 경우는 더더욱 적습니다. 그래서 그런지 운영까지 성공적으로 해낼 수 있었던 것이 굉장히 좋았습니다.

 

우테코에 처음 들어왔을 때 부터 강조하는 말이 있습니다.

코드가 더러워도 상관없다. 돌아가는 쓰레기부터 만들어라.

 

아무리 코드를 깔끔하게 짜고 아키텍트를 탄탄히 해도 완성하지 못하면 의미가 없습니다. 이런 점에서 이번 프로젝트는 더더욱 의미있었던 경험이었습니다.

 

서비스적인 측면에서도 보자면, 개인적으로 유용하게 사용하기도 했지만 다른 분들도 재밌게 사용해주신 것 같아 또 정말 좋았습니다. 근본적으로 서비스를 만드는 개발자는 이런 점에서 즐거움을 느끼는게 아닐까 싶습니다 😁 앞으로도 사용자들에게 긍정적인 경험을 제공해주는 서비스를 만들기 위해 더더욱 노력하고 싶네요. ☺️

Comments