본문 바로가기
개발

백그라운드 프로세스 시스템 구축하기 (NestJS, CronJob 스케줄러)

by 최승환 2024. 2. 19.

NestJS 서버 프로젝트 기반으로 백그라운드 프로세스 시스템을 구축하고, 해당 백그라운드 프로세스CronJob 으로 활용 했던 경험을 기록으로 남겨 본다.

 

나는 회사 업무로 유저가 구매/판매에 참여하는, 오픈 마켓과 유사한 디자이너 마켓플레이스를 개발 하게 되었다.

오픈 마켓인 만큼, 당연히 유저의 판매 내역 산출과 그에 따른 정산 산출이 필요하게 되었다.

이외에도 앞으로 통계나 하루/한달 단위로 데이터를 산출해야 할 필요가 많아질 것 같아, 스케줄러와, 이 스케줄러가 동작 시킬 백그라운드 프로세스를 시스템화 하여 구현하기로 하였다.

 


개발 컨셉 & 요구(필요)사항

  • 기존 백엔드 코드를 활용할 수 있으면 구현 시간이 절약 되겠다.
  • 기존 코드의 ORM 기반 DB Model/Schema 가 정의 되어 있는 코드를 활용할 수 있으면 좋겠다.
  • DB 조회, 데이터 마이그레이션 등의 스크립트를 돌릴 수 있는 시스템을 구축할 것
  • Docker와 같은 컨테이너 도구로 개별 환경을 구성하여 프로세스를 구동할 수 있다.
  • 인프라가 EKS로 되어 있으므로, Kubernetes 의 Job, CronJob을 이용하면 좋겠다.

 


NestFactory.createApplicationContext

위와 같은 목적으로 기술 조사를 하였을 때, 마침 NestJs에서 NestFactory.createApplicationContext 기능을 제공하고 있었다. 이 기능은 Nest 인스턴스를 생성하는 기능으로 HTTP 서버 없이 Nest 앱이 boot up 되며, 기존에 HTTP 서버를 띄울 때와 마찬가지로, 모듈에 등록된 모든 요소들을 인스턴스화 하고 의존성이 주입 되게 된다. 따라서 기존 Nest 서버를 작업할 때와 동일하게 기존 코드 기반으로 특정 목적의 Task 를 돌리는 작업을 할 수 있게 된다.

 

간단한 예시는 아래의 boot() 함수와 같다.

export const boot = async (): Promise<any> => {
  const nestApp = await NestFactory.createApplicationContext(AppModule, { logger: new TaskLogger() });
  return nestApp;
};

 

nest-boot.ts

위 기능을 토대로 nest-boot.ts 를 작업 하였다. nest-boot.ts 는 Nest boot()close() 를 수행하는 util 코드가 짜여 있다.

이제 비즈니스 목적을 구현한 xxx-task.ts 를 만들어 실제 백그라운드 프로세스가 동작하게 되는 시작점으로 만들 예정이며, 이 xxx-task.ts 에서 boot()closeApp() 을 가져와 사용하게 할 예정이다.

...

export const boot = async (): Promise<INestApplicationContext> => {
  const nestApp = await NestFactory.createApplicationContext(AppModule, { logger: new ScriptLogger() });
  return nestApp;
};

export const closeApp = async (nestApp: INestApplicationContext): Promise<void> => {
  await nestApp.close();
};

...

 


CLI (Command Line Interface)

컨테이너 환경, 혹은 디버깅 환경에서, CLI 에서 명령 argument를 받아 프로세스를 실행할 수 있도록 커맨드로 전달된 argument 로 프로세스가 실행되도록 하였다.

 

다음은 base-task.ts 코드이다.

import { execSync } from 'child_process';
import path from 'path';
import fs from 'fs';
import { commandOptionsParser, createCommandArgs } from '../src/modules/task/task.util';

const runTask = async () => {
  try {
    const fileName = process.argv[2];
    const fileDir = fileName;
    const filePath = path.join(__dirname, `${fileDir}/${fileName}.ts`);

    const options = commandOptionsParser(1);
    const command = createCommandArgs(options);

    if (!fileName) throw new Error('none file name error');
    if (!fs.existsSync(filePath)) throw new Error('file not exist error');

    const result = execSync(`ts-node "${filePath}" ${command}`, { stdio: 'inherit' });
  } catch (e) {
    console.error(e);
    process.exit(1);
  }
};

runTask();
// package.json
{
    ...
  "scripts": {
    ...
    "run-task": "... && ts-node tasks/base-task.ts"
  },
...

 

yarn run-task market-purchase-history —-targetYear 2023 -—targetMonth 5

예를 들어, 프로세스는 위와 같은 커맨드로 실행한다.

 

argument 파싱

위 예시 커맨드를 보면, argument 로 실행할 태스크의 파일 이름 market-purchase-history과, 실행 옵션을 받고 있다. —-targetYear, —-targetMonth

 

task.util.ts

task.util.ts 에 백그라운드 프로세스 관련 util 을 구현해 두었고, argumenttask.util.ts 에의

commandOptionsParser()createCommandArgs(options) 으로 파싱한다.

// task.util.ts
export const commandOptionsParser = (optionIndex = 0): Object => {
  const args = process.argv.slice(2);

  const options = {};

  for (let i = optionIndex; i < args.length; i += 2) {
    const key = args[i].replace('--', '');
    const value = args[i + 1];
    options[key] = value;
  }

  return options;
};

export const createCommandArgs = (options: object) => {
  const args = [];

  for (let key in options) args.push(`--${key} ${options[key]}`);

  let commandArgs = args.join(' ');
  return commandArgs;
};

 

commandOptionsParser‘-—’ 이 prefix로 시작하는 옵션명과, 값을 받아 options Object를 반환한다.

 

이렇게 반환된 options Object는 다시 스크립트 동작을 할 child process 에 적용하기 위해 createCommandArgs(options) 을 통해 문자열 command 로 반환 된다.

 

child process

execSync() 를 이용해 child process 를 생성하고, ts-node 커맨드로 task 파일을 실행한다.

 

Node 런타임 환경에서는 execSync(문서) 함수의 인자로 주어진 command를 수행하기 위한 child process 를 생성한다.

 

execSync 함수가 호출되면, Node는 child process 를 생성하고 그 안에서 OS의 shell을 실행시킨다. (일반적으로 Unix 계열 운영 체제에서는 /bin/sh (bash) 을 실행시키고, windows 에서는 cmd.exe 을 실행함. 이 실행된 shell 은 명령어를 해석하여 명령을 수행한다.

 

추가로, Node가 child process를 실행 시킨다고 하기 보다는 Node가 운영 체제에 process 를 실행하라는 신호를 보낸다고 하는 게 더 정확하겠다.


Node는 child_process.js 라는 모듈을 사용하여 OS child process 와 관련된 기능(OS 시그널)을 수행한다.
execSync 함수는 child_process.js 모듈에서 제공하는 함수 중 하나로, shell 프로그램을 동기적으로 실행하는 기능을 수행한다. execSync 함수는 내부적으로 spawnSync 함수를 사용하며, spawnSync 함수는 C++로 구현된 내부 로직에 따라 운영 체제에 맞는 프로세스 생성 호출을 보낸다.

 

launch.js (디버깅) & package.json 설정

당연히 모든 개발에 디버깅이 빠질 순 없으므로 VsCode 디버거를 활용하기 위해 launch.json 에서 디버깅 설정을 해 준다.

{
  "type": "node",
  "request": "launch",
  "name": "task-debug",
  "cwd": "${workspaceFolder}/server",
  "runtimeExecutable": "yarn",
  "runtimeArgs": ["run-task", "monthly-market-purchase-history", "--targetYear", "2023", "--targetMonth", "5"]
}

 

type: node 은 NodeJs 디버깅 환경을 사용하겠다는 것을 뜻한다.

request 는 디버깅을 시작할 때, 어떤 방식으로 디버깅 세션을 시작할 지를 지정하며, launch 값은 디버그 프로세스를 새로 시작하는 것을 뜻한다

 

VsCode는 디버깅 세션을 관리하기 위해 Debug Adapter Protocol(DAP) 을 사용한다. DAP는 디버깅 도구와 디버깅 대상 프로세스 간에 통신하기 위한 프로토콜이다.

DAP는 디버깅 동작을 수행하기 위한 여러 종류의 프로세스 간 통신용 메세지를 정의한다. 예를 들어, 중단점 설정, 스택 추적, 변수 확인, 실행 제어 등의 디버깅 동작과 관련된 메세지를 정의한다.

 

DAP는 Microsoft와 다른 개발 도구 제공 업체들간의 협업 오픈 소스 프로젝트이다.
조금 더 알아보니, MS의 VsCode 뿐만 아니라, IntelliJ 에서도 쓰이는 프로토콜 인 것 같다. 하긴 IDE 마다 서로 다른 프로토콜을 사용하는 낭비를 할 순 없으니, DAP는 디버깅 프로세스 관리를 위해 범용적으로 사용되는 규약인 것이다.
이러한 범용성, 표준화를 갖춘 프로토콜로 덕분에 개발 업체에선 DAP를 준수하는 Debug Adapter 만들고 여러 IDE에서 공통적으로 사용할 수 있게 된다.

 

정리.
Debug Adapter : IDE, 디버깅 도구와 디버깅 프로세스 사이에서 동작하는 소프트웨어 컴포넌트. Debug AdapterDAP 프로토콜을 준수하여 디버깅 도구와 통신 및 상호작용 함
Debug Adapter Protocol(DAP) : Debug Adapter와 디버깅 도구 사이에서 메세지 교환을 정의하는 프로토콜

 

 

package.json 에서 run-task 라는 script로 base-task.ts 를 실행할 수 있도록 해주었다.

// package.json

...
"scripts": {
    ...
    "run-task": "...  && ts-node tasks/base-task.ts"
}

 

이제 Docker로 백그라운드 프로세스 구동 환경을 만들어 줄 때, 위 커맨드로 실행, Argument 를 전달 하는 방식으로 구축해 가면 된다.

 


NestJs 구조 & 백그라운드 프로세스 Task 예시

다음은 월단위 매출 내역을 산출 해주는 Task 예시이다.

 

monthly-market-purchase-history.ts

// monthly-market-purchase-history.ts
...

const run = async () => {
    ... // config 관련 동작 생략

    const nestApp  await boot(); // NestJs 인스턴스 bootup
    const taskService: TaskService = nestApp.get(TaskService); // get TaskService

    const options = commandOptionsParser(0) // 부모 프로세스에서 전달한 argument 파싱
    const { targetYear, targetMonth } = options;

    ... // argumnet 관련 validation 처리

    const csvDataResult = await taskService.getMarketPurchaseData({ targetYear, targetMonth });

    ... // 산출된 데이터 처리 로직

    await closeApp(nestApp); // NestJs 인스턴스 종료
}

run();

 

NestApp.get() 을 이용하여 TaskService 를 가져왔다. nestApp.get()은 Nest 앱의 인스턴스에서 서비스를 찾고, 해당 서비스를 반환한다. 그 후, taskServicegetMarketPurchaseData() 를 수행한다.

 

NestJS는 모듈 단위로 의존성을 주입하며 설계할 수 있도록 되어 있는 프레임워크다.

프로젝트의 모듈 별 관심사 분리를 위해 Task 프로세스의 진입점이 되는 인터페이스를 TaskModule, TaskService로 분리 하였다.

 

TaskService는 (예를 들어,SettlementService, MarketService 등) 본래의 로직 처리를 위한 Service 의존성을 주입 받고, 해당 Service 에서 로직 처리를 위한 함수를 호출한다.

// TaskService 예시
// Task 프로세스의 진입점이 되는 코드
async getMarketPurchasesData({ targetYear, targetMonth }: { targetYear: number; targetMonth: number }) {
    try {

            // 당월 매출 내역. SettlementService 에서 본래의 로직 처리를 함
      const marketPurchaseResult: MarketPurchase[] = await this.settlementService.getMonthlyMarketPurchasesHistory(
        targetYear,
        targetMonth,
      );

            // csv 데이터로 컨버팅
      const csvResult = createCsvFromMarketPurchasesHistory(marketPurchaseResult);
      return csvResult;
    } catch (e) {
            //... 예외 처리 로직
    }
  }

 

또한, 업무상으로도 기존 Nest 서버 프로젝트의 코드를 활용할 수 있게 되면, Task 관련 작업을 하면서 다른 모듈 (Settlement, User, Market, 등 도메인을 담당하는 서비스) 에 비즈니스 로직을 채워 넣을 수 있게 된다. 이는 추후에 API 작업 할 때에도 마찬가지로 활용될 수 있다.

 


도커라이징, AWS(EKS, ECR), 배포 Pipeline (bitbucket)

위 내용까지는 Task 를 어떻게 작업 했는지에 대해 알아 보았다면, 이제 Task 를 어떻게 배포할 지에 대해 알아보아야 한다.

 

현재 팀에서는 AWS, EKS 를 도입해 놓은 상황이다. 코드 저장소는 Bitbucket 을 사용하고 있으며, bitbucket-pipeline 으로 CI/CD 동작을 수행할 수 있다.

Kubernetes 에서는 백그라운드 프로세스를 돌릴 수 있도록 CronJob 을 지원하고 있다.

작업한 Task 를 배포/운영 하기 위해선, 우선 도커라이징을 통해 컨테이너 이미지를 만들고, 이를 Pipeline 을 통해, ECR → EKS 로 배포하면 된다.

 

도커라이징

FROM --platform=linux/amd64 node:18

WORKDIR /usr/task-app

COPY ./server ./

RUN yarn install

ARG DEPLOYMENT_ENV

COPY ./server/.env.${DEPLOYMENT_ENV} ./.env

...

ENTRYPOINT ["yarn", "run-task", "example-task"]

 

example-task.kube.yml

apiVersion: batch/v1
kind: CronJob
metadata:
  name: example-task
  namespace: task
  labels:
    app: task-example
spec:
  schedule: '0 0 * * *'  # 매일 자정에 실행
  jobTemplate:
    spec:
      template:
        spec:
          containers:
            - name: example-task
              image: $IMAGE_PATH
          restartPolicy: OnFailure

EKS 에서 수행 될 CronJob 예시이다.

기능/로직에 맞게 해당 내용은 수정하면 된다.

 

pipeline 설정

bitbucket-pipelines.yml

image: atlassian/default-image:3

...

pipelines:
    ...
    branches:
        job-dev/example-task:
      - step:
          <<: *job-deploy
          name: job example-task [DEV]
          deployment: dev-example-task

...

definitions:
    steps:
        - step: &job-deploy
            ...
            script:
                # AWS Settings
                - curl "https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip" -o "awscliv2.zip"
        - unzip awscliv2.zip
        - ./aws/install
        - aws --version
        - apt-get update && apt-get install gettext-base

        - aws configure set aws_access_key_id "${AWS_ACCESS_KEY_ID}"
        - aws configure set aws_secret_access_key "${AWS_SECRET_ACCESS_KEY}"
        - aws ecr get-login-password --region ${AWS_DEFAULT_REGION} | docker login --username AWS --password-stdin ${AWS_REGISTRY_URL}

        # Deploy ECR
        - VERSION=${BITBUCKET_BUILD_NUMBER}
        - IMAGE_PATH=${AWS_REGISTRY_URL}/${IMAGE_NAME}-${DEPLOYMENT_ENV}:${VERSION}

        - export DEPLOYMENT_ENV=${DEPLOYMENT_ENV}
        - export IMAGE_PATH=$IMAGE_PATH

        - docker build -t ${IMAGE_NAME}-${DEPLOYMENT_ENV} -f server/${IMAGE_NAME}.dockerfile . --build-arg DEPLOYMENT_ENV=${DEPLOYMENT_ENV}
        - docker tag ${IMAGE_NAME}-${DEPLOYMENT_ENV} ${AWS_REGISTRY_URL}/${IMAGE_NAME}-${DEPLOYMENT_ENV}:${VERSION}
        - docker push $IMAGE_PATH

        # Deploy EKS

        - RESOURCE_PATH=server/tasks/${IMAGE_NAME}
        - envsubst < ${RESOURCE_PATH}/${IMAGE_NAME}.kube.tpl.yaml > ${RESOURCE_PATH}/${IMAGE_NAME}.kube.yaml

        - pipe: atlassian/aws-eks-kubectl-run:2.2.0
          variables:
            AWS_ACCESS_KEY_ID: ${AWS_ACCESS_KEY_ID}
            AWS_SECRET_ACCESS_KEY: ${AWS_SECRET_ACCESS_KEY}
            AWS_DEFAULT_REGION: '${AWS_DEFAULT_REGION}'
            CLUSTER_NAME: ${EKS_CLUSTER_NAME}
            KUBECTL_COMMAND: 'apply'
            RESOURCE_PATH: '${RESOURCE_PATH}/${IMAGE_NAME}.kube.yml'
            DEBUG: 'true'

 

위 내용은 bitbicket pipeline yaml 이다. bitbucket 에서 제공하는 문법대로 작성 하였다.

요약하자면, job-dev/example-task 라는 브랜치에 코드가 머지 될 때마다 &job-deploy 라는 step 을 실행하는 것이다.

&job-deploy 는 AWS 설정을 한 후, Docker 빌드를 한 후, ECR 에 이미지를 Push 한다.

그 후, #Deploy EKS 에 정의한 대로, ${IMAGE_NAME}.kube.yml (여기에선 example-task.kube.yml 이 되겠다) 리소스를 EKS Cluster 에 배포(apply)하게 된다.

 


위와 같이 NestJS 기존 백엔드 프로젝트를 기반으로 한 백그라운드 프로세스 시스템을 개발해 보았다.

분명 조금 더 나은 방법이 있겠지만, 이러한 작업 접근 방법이 있다는 것에 도움을 받을 분을 위해 공유해 본다.