Published on

[후기][FEops] 내부 로컬 배포 플랫폼 럼버잭 제작기

Authors
  • avatar
    Name
    Kim, Dong-Wook
    Twitter
Table of Contents

개론

내부 코어로직을 로컬 머신에서 배포 및 테스트 할 수 있는 데스크탑 앱 기반 플랫폼을 만들었습니다.

프레임워크의 동작원리 보다는 프로젝트를 진행하면서 겪었던 문제점들에 대해 소개합니다.

발생했던 문제를 반복하지 않기 위한 회고 목적의 글 입니다

따라서 다른 글에 비해 읽기 힘들 수 있습니다.

프로젝트의 목적

해당 프로젝트의 목적은 의사소통 비용을 줄인다입니다.

내부 코어 로직 서비스를 비 Backend 개발자가 실행시키는 것은 대체로 고통스럽습니다.

대략 다음과 같은 과정을 거쳐야하기 때문입니다.


1. Project를 Clone한다
2. Dependency를 설치한다
3. Docker가 필요시 Docker를 설치한다
4. Env를 세팅한다
5. Project를 실행한다

또한 프로젝트 특성상 Windows 환경에서도 실행이 가능해야합니다.

위의 과정이 적어놓은 것에 비해 훨씬 복잡했기 때문에 이를 해결할 내부 PaaS가 필요합니다.

기존 개발 프로세스

따라서 내부적으로 Vercel과 같은 Cloud Platform(이하 CP라고 하겠습니다)을 제작하여 사용하고 있었고

다음과 같은 프로세스로 개발이 이루어졌습니다

  1. CP에서 배포할 서비스를 선택한다
  2. 배포할 서비스가 K8S Pod에 배포될 때 까지 기다린다
  3. 배포 후, 각자의 서비스(iOS, Android, Client)에 reverse proxy port에 연결하여 개발한다

하지만 다음과 같은 불편함이 있었습니다.

  1. 배포하는데 반영까지 최소 20분 이상 소요는 점
    1. Binary Build와 상관없는 간단한 Config Map 수정임에도 Minimum Time이 소요된다
      1. Core 서비스의 사용자인 Client 개발자에게는 Config Map수정이 주된 변경이다
    2. 변경이 이루어진건지 확인하기 어렵다
  2. 필요없는 Core Service도 함께 배포되기 때문에 Pod의 용량이 크다

해결책

따라서 위의 문제를 해결하기 위해, 팀 내부적으로 논의된 해결책은 다음과 같았습니다

  • Core Service들을 로컬 머신에 띄울 수 있는 Local Deploy Platform을 만든다
    • QA 및 Native Client(Unreal, Android, iOS)를 위해 Cross-platform(Windows, Mac)을 지원한다
  • VM 충돌 문제를 방지하기 위해 Docker Free를 지향한다
    • 따라서 Core Service가 사용하는 third-party service는 직접 build 후 Local Deploy Platform이 내장한다.(Ex. PostgreSQL, DynamoDB 등)

위의 논의를 바탕으로 DRI를 가지고 Local Deploy Platform인 Lumberjack을 제작했습니다.

고민사항

개발 시간 단축, 리서치 과정에서 될지 않될지 판단을 빠르게 하는게 가장 큰 숙제였습니다.

또한 팀 내에 개발 리소스가 부족했기 때문에, 우선 의사소통 비용 최적화확장 가능성을 위주 고민했습니다.

위의 고민을 토대로 세운 원칙과 의사결정 사항은 다음과 같습니다.

  • 비 Frontend 개발자도 쉽게 유지보수가 가능할 것
    • Copy & Paste로 개발이 되도록 설계한다
      • Type Extraction Utility 제작
      • API 정의, 구현, 사용처의 폴더구조를 통일화
      • AI 기반 UI를 만들어주는 UI System을 사용
    • 패턴, 구조 등의 문서화를 자동화 한다
      • Docusaurus와 같은 문서 자동화 도구를 도입
  • Frontend와 Backend 개발자간의 의사소통 비용을 최적화한다
    • GraphQL codegen과 같은 API Schema 기반으로 시스템적으로 의사소통
      • 단, 유지보수 문제로 GraphQL은 사용하지 말 것
    • Frontend와 Backend 빌드 프로세스를 분리할 것
    • Format & Build 자동화
      • 핵심적인 내용만 코드리뷰가 가능하도록 할 것
      • Main branch의 모든 commit은 Build되는 버전 일 것
  • 사람이 놓치기 쉬운 부분은 자동화 한다
    • 앱 Relase는 Github Action & Github Release를 통해 자동화한다
    • 앱이 요구하는 Redis, DynamoDB 등의 바이너리 빌드를 자동화한다

기술 스택

위의 고민을 통해 결정한 기술 스택은 다음과 같습니다

  • FE
    • Electron, React 18.2.0, React Query v5, React Hook Form, Shadn UI, Zod, TS-Rest
    • UI Gen with v0
  • BE
  • Bundling
    • PNPM
    • Electron-vite
    • Github Release
  • Automation
    • Husky
    • Github Action

변경 가능성확장 가능성에 초점을 두고 기술 스택을 선택했습니다.

의사결정과 회고 - 잘했던 결정

Rest API 채택

의사 결정 과정

Desktop App을 만드는 가장 큰 이유는 File System에 종속된 기능을 만들기 위함입니다.

ChatGPT Desktop App이나 Postman, Notion 등의 Native App들도 내부적으로 File System을 이용해서 chat log, system log들을 쌓고 이를 이용해 Native App을 제작합니다.(물론 WebView를 적극적으로 활용합니다)

하지만 럼버잭은 조금 다릅니다. File System 외에도 Core Service 바이너리 실행, Folder Permission 허용 기능을 포함해야하고 또한 Octokit 등을 활용한 기능도 존재합니다. 가장 비슷한 서비스는 Redis Insight 라고 볼 수 있겠네요.

위와 같이 구현해야하는 Backend 기능이 많았고, 이를 IPC를 통해 구현하게 되면 코드 복잡도 관리가 어렵고 유용한 Library(Ts-rest, Swagger, Helmet, Winston Logger 등)을 제대로 사용하기 힘들었습니다

따라서 Electron App의 IPC(Internal Procedure Call)을 이용하여 Node 기능을 사용하기 보다는

Rest API를 채택하여 Frontend와 Backend 개발 프로세스를 명시적으로 구분하는게 더 낫다고 판단했습니다.

검증 과정

가장 우선적으로 검증해야할 것은 다음과 같았습니다

  • 하나의 Desktop App이 여러개의 Port를 점유할 수 있는가?
    • 이유:
      • Rest API 서비스를 서빙할 별도의 Backend Application을 특정 포트에 띄어야하기 떄문입니다.
    • 가능하다면 Electron Entry File(main.js)에서 Frontend App과 Backend App의 라이프 사이클 제어는 어떻게 해야하는가?
    • Backend App이 가지는 Shutdown Hook(Module Lifecycle 및 SubProcess)이 의도한 대로 작동할 것인가?

문제와 해결 과정

우선 기존 Electron 앱에 간단한 Nest.js 앱을 포함했습니다.

그리고 기존 Electron 앱에서 사용하던 Custom IPC중 하나인 spawnBinaryIPC를 Rest API로 포팅했습니다.

하지만 포팅 중간에 Electron-vite 번들러가 Experimental 기능인 Decorator를 지원하지 않는다는 사실을 알게되었습니다.

이 부분은 공식 문서인 Electron-vite Decorator 를 참고해서 해결했는데요, 번들러에서 제공해주는 swc plugin을 설치하여 해결할 수 있었습니다.

위의 문제를 해결하고 럼버잭 앱에서 해당 Rest API를 호출하는지 PoC하고 잘 작동하는 점을 확인했습니다.

장점

  • Frontend와 Backend 개발 프로세스를 분리
    • 기존 Rest API 기반 레퍼런스 참고 가능
    • 코드 리뷰 명확성 제공
  • 럼버잭 외의 서비스에 Rest API 제공 가능
    • 로컬에 띄어진 다른 서비스(Unreal, Android, iOS App)에서도 럼버잭의 API(Spawn Binary, Get File Log)등을 그대로 사용 가능

Contract 기반 개발 프로세스 도입

의사 결정 과정

기존 개발 프로세스는 다음과 같았습니다

  1. Backend 개발자가 API를 제작한다
  2. 업데이트된 API Request, Response를 OpenAPI 기반 문서에 업데이트한다
  3. 해당 OpenAPI 문서를 이용해서 Client에서 Typegen을 한다
  4. Typegen된 API를 기반으로 API Fetching을 한다

해당 방식의 문제점은 다음과 같습니다

  • Backend 개발자가 수기로 OpenAPI에 명시된 API Request, Response를 업데이트 해야한다.
    • 휴먼 에러가 발생할 가능성이 높다
  • API Schema가 정의될 때 까지 Frontend 개발자가 상상해서 코딩하고 있어야한다.

위의 병목과 휴먼 에러를 방지하면, 기능 개발을 신속하게 할 수 있을 것이라 생각했습니다

검증 과정

Contract 기반으로 간단한 Blog 게시물을 내려주는 서비스를 만들었습니다.

다음과 같이 진행하여 한 서비스를 완성하고, 문제점을 보완한 후, 본 프로젝트에 적용했습니다.

  1. Contract를 함께 정의한다
    1. Blog Post에 대한 리소스(/blog/post, /blog/post/:id, /blog/post/:id/content, ...etc) 정의
    2. 정의된 리소스 기반으로, 리소스 당 CRUD Operation 추가
    3. 각 Endpoint에 Option 및 Request / Response Schema 추가
  2. 함께 동시에 개발한다
    1. Frontend Engineer는 Contract기반 생성된 OpenAPI 3.0 스펙 기반 문서로 Mock Autogen을 한다
      1. 이후 실제 서비스가 완성되면, 실제 서비스 기반으로 API Testing을 진행한다
    2. Backend Engineer는 Contract기반으로 Controller를 Typesafe라게 생성하고, 비지니스 로직을 추가한다
  3. 함께 테스팅 후, 배포한다

Sample 프로젝트를 진행하여 기존 방식 대비 장점을 함께 이해하려 했습니다.

장점

따라서 위의 검증과정을 정리하면 다음과 같습니다.

  1. REST API 기반으로 통신할 것
  2. 개발 시작 전 필요한 REST API의 Request, Response를 미리 정의할 것
    1. 단 세부적인 Parameter naming 변경은 Backend 개발자에게 위임한다
    2. Typescript 레벨에서 검사가 되기 때문에, 변경 비용은 감당할 수 있다
  3. REST API 설계시 Resource 단위로 분리하여제작할 것`
  4. Schema에 대한 실제 API가 정의되기 전에는, Mock Autogen을 통해 개발 프로세스를 진행할 것

위의 합의를 통해, Frontend와 Backend 개발을 동시에 개발할 수 있는 환경이 조성되었습니다.

또한 Contract를 우선적으로 정의했기 때문에, 더 좋은 API Interface설계에 대해 함께 고민할 수 있게 되어 좋았습니다.

Copy and Paste Development 개발 환경 구축

현재 속해있는 팀은 Frontend 리소스가 부족합니다.

복사 붙여넣기 개발 환경이 되기 위해 다음과 같은 의사결정을 했습니다.

Shadcn UI 및 v0 도입

내부 배포툴 특성상 UI의 중요성 보다는 기능의 완성도가 중요합니다.

그리고 Backend 개발자가 내부 배포툴의 기능을 제작할 가능성도 있었습니다.

따라서 UI 자동 생성 도구를 도입하고, UI는 AI에, 로직은 개발자에게 맡기는게 최선이라 생각했습니다.

장점과 단점

장점은 UI 개발에 대한 스트레스가 줄었다는 점이었습니다. UI 코드는 Shadcn UI와 v0에 맡김으로써, 대화를 통해 함께 개선이 가능했던 점 입니다.

하지만 생각하지 못했던 단점도 있었습니다. 기존 Antd에 익숙해져 있던 사람에게는 Shadcn UI이 제공하는 코드에 대해 익숙하지 못해 온보딩이 느리다는 단점이 있었습니다.

어떻게 해야 온보딩을 쉽게 할지 고민 포인트입니다.

Contract와 API Controller & API Fetching Hook 구조 통일화

Folder 구조

해당 프로젝트는 다음과 같은 폴더구조를 가지고 있습니다.

src/
├── main        # Electron App Entry Point(Handling renderer app, server app init and close lifecycle)
├── preload     # Electron App Context Bridge` `for` `IPC(Internal Procedure call)
├── renderer    # Electron App Renderer(For now. written in React. It could be replaced to Vue, Svelte, Solid, etc..)
├── server      # Electron App Backend Server(For now. written in Nestjs, Bind to src/main)
└── shared      # Contract(Api Spec Manager) and common utility defined

그리고 shared 폴더 안에는 contract(api schema 명세)가 정의되어있습니다

contract


shared/
├── contract
        ├── environment     # contract for controlling lumberjack app(app log, app metadata(redis, dynamodb, etc...))
        ├── farms           # contract for deploy farm
        ├── local-builds    # contract for searching downloaded builds(searching downloaded build list)
        ├── remote-builds   # contract for searching remote builds(searching remote branch list, searching remote builds, etc...)
        └── ...define your own contract
└── ...etc

그리고 위의 폴더 구조 그대로 Backend API Controller와 Frontend Api Fetching Hook을 그대로 만듭니다

Frontend API Fetching


renderer/
├── api
        ├── environment
            ├── query-key.ts    # define react-query key for managing caching, invalidation policy
            └── ...define your own React Query Hook base on api call
        ├── farms
            ├── query-key.ts    # define react-query key for managing caching, invalidation policy
            └── ...define your own React Query Hook base on api call
        └── ...define your own React Query Hook

└── pages, hooks, components, lib, config, locales, styles, types, etc...

Backend controller


server/
├── modules
        ├── environment                 # modules.. defined by contract
        ├── farms                       # modules..
        ├── local-builds                # modules..
        ├── remote-builds               # modules..
        └── ...define your own modules`

└── ...some nestjs app entry and configuration files

위의 구조를 가지기 전, 과거 프로젝트들이 가졌던 공통적인 문제는 다음과 같습니다

  1. API 에 대한 명세와 실제 구현이 다른 경우가 지속적으로 발생했다
      • API 명세에는 있지만 Backend Controller에는 구현되어 있지 않아도 타입 에러가 발생하지 않았다
  2. API로 불러온 데이터가 페이지별로 처리하는 로직이 다른 경우가 있었다.
    1. 같은 API임에도 Caching Hook을 통해 공통화되지 못해 여러번 호출이 되었다.
장점

위의 구조를 통해서 가지는 이점은 다음과 같습니다

  1. API Spec을 폴더 구조를 통해 한 눈에 파악이 가능합니다
    1. 또한 API 호출 및 정의에 대해 Frontend, Backend 동일하게 가져가기 때문에 의도를 파악하기 쉽습니다
  2. API 사용 및 Cache & Invalidation에 대한 로직을 설계하기 쉽습니다
    1. 한 API에 대한 정의는 오직 정의된 API Fetching Hook을 사용하기 떄문입니다.
      1. 다만 예외적인 케이스(Invalidation)를 처리하기 위해서, 타입 확장성을 열어놔야합니다.
    2. 추가적으로 Contract에 정의된 내용 + File 구조를 기반으로 Backend Controller와 Frontend API Fetching Hook을 자동으로 생성가능하도록 자동화가 가능합니다

따라서 자동화 가능성, 일관성을 통한 의도 파악 시간 단축 등을 이유로 잘했던 결정이라 생각합니다.

의사결정과 회고- 아쉬운 결정

Monorepo 채택 및 제거

의사 결정 과정

초기에 Monorepo 형상을 채택했던 점이 가장 큰 아쉬움이었습니다.

처음에는 Frontend Repo와 Backend Repo를 분리하고 Pnpm workspace + Turborepo를 이용하여 Monorepo 형상으로 프로젝트를 설계했습니다.

그리고 두 프로젝트를 분리해서 빌드 후, Electron App Bundling 타임에 함께 포함하려고 했습니다만.. 매우 어려운 작업이라는 걸 깨달았습니다. (참고. Stack Overflow 질문)

왜냐하면 다음과 같은 프로세스를 거쳐야하기 때문입니다.

  1. Electron App을 번들하기 전, Backend App을 빌드한다
  2. Backend App 결과물과 node_modules 폴더를 Electron App 레포에 복사한다
    1. Backend App이 있을 것 이란 가정하에 Electron Frontend / Backend App 라이프 사이클을 설계한다
  3. Electron App을 번들링한다

따라서 다음과 같은 이유로 포기했습니다

  1. Electron App Main Process에서 두 서비스(Frontend, Backend)의 라이프사이클 제어가 어렵다
  2. node_modules에 있는 종속성을 빌드 전 Electron App 폴더에 복사해야하는 작업은 매우 위험하다
  3. 위의 사항을 검증할 시간이 부족하다

채택한 대안

Monorepo 형상을 제거하고 monolith로 전환할 수 밖에 없었습니다.

덕분에 Type 안정성과 패키지 종속성 깨짐 방지 등을 얻긴 했지만 빌드 속도가 다소 늦어진다는 단점을 얻게 되었습니다.

만약 위의 문제를 해결하는 방법을 알고 있다면 채널톡 버튼을 통해 알려주시면 감사드립니다.

Docker Free

의사 결정 과정

Docker Free는 이 프로젝트의 핵심 과제였습니다.

이유는 럼버잭을 사용하는 사용자의 컴퓨터 대부분에서 가상머신을 사용중이기 때문입니다.(BlueStack, Android VM)

이는 특히 Windows 환경에서 문제가 컸는데, VM(BlueStack, Android VM)과 WSL2 Docker와 함께 사용하면 충돌이 나기 때문입니다.

따라서 럼버잭의 목적(Core 로직 서비스를 쉽게 실행)을 달성해도, Docker 사용을 전재로 하면, 럼버잭 앱 설치가 아예 불가능한 케이스가 발생할 수 있었습니다.

따라서 Docker를 사용하지 않는 걸 전재로 앱을 개발해야했습니다.

해결 과정

바이너리를 직접 빌드했습니다.

예를 들면 Redis와 DynamoDB에 대한 종속성이 필요한 경우, Electron 앱 안에 Redis와 DynamoDB를 내장했습니다.

electron-builder.json에 아래와 같이 명시하면,

빌드시 resources/${os}에 있는 폴더를 빌드된 앱 Package안에 Resources/bin 안에 복사해줍니다.


## electron-builder.json

 "build": {
    "extraFiles": [
      {
        "from": "resources/${os}", // $os => "mac/linux/win"
        "to": "Resources/bin",     // for Linux => "resources/bin"
        "filter": ["**/*"]
      }
    ],
  }

 -> for example, when build, if $os is mac, ./resources/mac will be copied to ./Resources/bin inside the app


또한 복사된 폴더의 경로를 불러올 수 있기 때문에, dev/prod 모드별로 바이너리 위치를 가져올 수 있습니다

import { homedir } from 'os'
import path from 'path'
import { app } from 'electron'

export function getResourcePath() {
  const { isPackaged } = app

  const resourcePath = isPackaged
    ? path.join(process.resourcesPath)
    : path.join(app.getAppPath(), 'resources')

  return resourcePath
}

발생한 문제들

발생한 문제는 다음과 같습니다.

  1. Core 로직 서비스에 점차 Thrid-party 서비스(Message Queue Service, RDB 등)를 추가할 경우, 공수가 크다
    1. 추가할 경우, Mac / Windows 바이너리를 모두 빌드 후 Electron 앱에 내장 및 Spawn하는 Controller를 분석 및 제작해야한다.
    2. 특히 Windows의 경우, 바이너리 빌드하는 방식이 어렵거나 없는 경우가 있다
  2. Binary의 Lifecycle이 각각 다를 수 있다
    1. Nest.js Dynamic Module을 통해 Binary를 다루는 Custom Module을 제작했습니다. 하지만 각 Binary마다 Spawn & Health Check하는 로직이 달라 모듈 제작에 어려움이 있었습니다

이 부분을 어떻게 해결해야할지가 다음 과제입니다

사소한 문제들

프로젝트를 진행하면서 발생한 문제는 다음과 같습니다.

  • 배포 환경별 Folder Permission이 다른 문제
  • Nest.js Shutdown Hook 설정이 안되어서 Module이 정상적으로 종료되지 않는 문제
  • Redis Flush를 하나의 redis-client에서 여러번 호출시 redis 바이너리 crash가 발생하는 문제
  • IPC Event가 중복 등록되는 문제

후기

솔직히 좀 많이 힘들었습니다.

Fullstack을 할 수는 있었지만(그렇다고 Node.js에 대해서 잘 알고 있다는 말은 아닙니다) 개인적으로 Frontend를 더 좋아합니다.

제 개인적인 아젠다가 조직에 도움이 되면 무엇이든 하겠다. 문제 해결할 수만 있다면 된다이었고

그래서 지금 회사에서 개발 시간을 최적화하기 위해 Platform을 제작한다는 기회가 왔을때, 프로젝트 리드로써 임할 수 있었습니다.

하지만 개발 난이도에 대해서 오만하게 생각했던 것 같습니다.

왜냐하면 Frontend, Backend 로직을 넘어서 Desktop Native Build, Binary Build, Codesign 프로세스 등을 모두 해야했습니다.

게다가 개인적인 목표인 개발 프로세스를 빠르게 만들어 성공하는 제품을 만든다는 목표와 함께 기존 서비스에 있던 문제점들(자동화의 부제, Runtime에서 테스트가 필요한 부분)을 반복하지 않는다도 있었기 때문입니다.

위의 과정이 필요한지 몰랐기 때문에 무식하면 용감하다와 같이 적극적으로 임했던게 아닌가 싶습니다. 알았으면 시도조차 안했을 것 같습니다 🥲

다만 위의 과정을 통해 엔지니어링 역량 뿐만 아니라 결정 사항이 프로젝트에 어떤 영향을 끼치는지 파악할 수 있었기 때문에 큰 도움이 되었습니다.

감사합니다.