본문으로 건너뛰기

안동민 개발노트

본문 시작

예약 작업과 큐 운영

지연 작업과 반복 스케줄을 등록하고 Worker 동시성·큐 이벤트·메트릭·안전한 종료까지 운영 기준을 세웁니다.

앞 절까지 즉시 실행 작업의 접수, 처리, 재시도, 중복 방지를 구현했습니다.

마지막 절에서는 특정 시각 이후 실행하는 작업, 반복 작업, Worker 처리량, 이벤트와 메트릭, 안전한 종료를 하나의 운영 흐름으로 묶습니다.

BullMQ 5.16에서 기존 repeatable job API를 대신하는 Job Scheduler가 도입되었습니다.

아래 API와 최초 작업 생성 규칙은 BullMQ v5.77.6의 타입과 소스를 기준으로 검증했습니다. 이 문서 사이트 자체의 package.json과 lockfile에는 bullmq@nestjs/bullmq가 설치되어 있지 않으므로 실습 애플리케이션에서는 검증한 버전을 고정합니다.

반복 작업은 upsertJobScheduler()로 등록합니다.

같은 scheduler ID를 다시 등록하면 중복 생성하지 않고 설정을 갱신할 수 있습니다.

BullMQ에서 즉시 작업, 한 번 지연 작업, 반복 Job Scheduler를 선택하고 upsert, cron 시간대, 최초 작업과 실제 시작 시각의 경계를 구분하는 표

Nest · BullMQ Scheduling

등록 API는 생성 횟수와 실행 가능 시각으로 고른다

예약 시각은 완료 약속이 아니라 Worker가 작업을 가져갈 수 있는 가장 이른 경계입니다. 큐 적체와 Worker 가용성에 따라 실제 시작은 늦어질 수 있습니다.

즉시·한 번 지연·반복 작업 선택 기준
선택 생성 방식 첫 실행 가능 경계 운영 기준
즉시 작업 요청마다 한 번 추가 등록 직후 waiting Worker가 비면 active로 이동
지연 작업 요청마다 한 번 추가하고 delay 지정 지연 시간이 지난 뒤 delayed에서 이동 특정 시각 이후 한 번 실행
Job Scheduler 안정된 ID로 반복 규칙을 upsert 반복 옵션의 최초 작업 규칙을 따름 같은 ID면 규칙과 template 갱신
즉시

요청마다 한 번

등록 직후 waiting에 들어가고 Worker가 비면 active로 이동합니다.

지연

지정 시각 이후 한 번

delay가 끝나야 가져갈 수 있으며 실제 시작은 더 늦을 수 있습니다.

반복

같은 규칙으로 계속 생성

안정된 scheduler ID를 upsert해 반복 규칙과 job template을 관리합니다.

Upsert

ID는 배포마다 바꾸지 않는다

새 ID면 scheduler를 만들고, 같은 ID면 기존 반복 설정과 job template을 갱신합니다. 동일 규칙의 scheduler를 중복 생성하지 않습니다.

upsertJobScheduler(id, repeat, template)
First job

pattern과 every의 최초 작업은 다르다

  • pattern 기본값: 다음 cron 일치 시각
  • immediately: true: 해당 pattern upsert의 첫 작업을 지금 생성
  • 5.19+ every: 새 ID의 첫 작업을 즉시 생성
  • 기존 every ID 재-upsert: 별도 즉시 반복 없음
Cron + timezone

서울 기준 다음 오전 3시에 실행 가능해진다

0 0 3 * * * · Asia/Seoul

이 pattern은 최초 작업도 다음 일치 시각에 delayed로 만듭니다. 그 시각에 Worker가 없거나 큐가 밀리면 active 전환은 늦어집니다.

Deployment

pattern + immediately는 재-upsert 때도 주의한다

부팅 때 같은 ID를 매번 upsert하면 배포 시점에도 즉시 작업이 다시 예약될 수 있습니다. cron 전에 실행할 의도가 있을 때만 사용합니다.

선택 순서: 한 번인가 반복인가 → 지금인가 미래인가 → 반복이면 ID, 최초 작업, 시간대, 지연 허용 범위를 함께 정합니다.


지연 작업 예약하기

delay는 지금 큐에 넣되 지정한 밀리초가 지난 뒤 처리 가능하게 만드는 옵션입니다.

정확한 실행 시각을 보장하는 타이머가 아니라 그 시각 이후 Worker가 가져갈 수 있다는 의미입니다.

큐가 밀렸거나 Worker가 꺼져 있으면 더 늦게 시작합니다.

예약 요청 DTO를 추가합니다.

src/reports/dto/schedule-report.dto.ts
import { IsDateString, IsInt, IsString, Max, Min } from 'class-validator';

export class ScheduleReportDto {
  @IsString()
  reportId: string;

  @IsString()
  requestedBy: string;

  @IsInt()
  @Min(1)
  @Max(100_000)
  rows: number;

  @IsDateString()
  runAt: string;
}

ReportsService에 예약 메서드를 추가합니다.

BadRequestException은 3절에서 Injectable, NotFoundException과 합친 기존 @nestjs/common import를 그대로 사용합니다.

src/reports/reports.service.ts (enqueueAt 추가)
import { ScheduleReportDto } from './dto/schedule-report.dto';

async enqueueAt(dto: ScheduleReportDto) {
  const runAt = new Date(dto.runAt).getTime();
  const delay = runAt - Date.now();

  if (delay <= 0) {
    throw new BadRequestException('runAt must be in the future');
  }

  const job = await this.reportsQueue.add(
    GENERATE_REPORT_JOB,
    {
      reportId: dto.reportId,
      requestedBy: dto.requestedBy,
      rows: dto.rows,
    },
    {
      delay,
      attempts: 4,
      backoff: { type: 'exponential', delay: 1_000, jitter: 0.5 },
      deduplication: { id: dto.reportId },
      removeOnComplete: 100,
      removeOnFail: 100,
    },
  );

  return {
    jobId: job.id,
    state: await job.getState(),
    runAt: new Date(runAt).toISOString(),
  };
}

Controller에서 별도 경로로 노출합니다.

src/reports/reports.controller.ts (예약 API 추가)
import { ScheduleReportDto } from './dto/schedule-report.dto';

@Post('scheduled')
@HttpCode(HttpStatus.ACCEPTED)
schedule(@Body() dto: ScheduleReportDto) {
  return this.reportsService.enqueueAt(dto);
}

Node.js로 현재 시각보다 1분 뒤의 ISO 문자열을 만들어 요청하고 상태를 확인합니다.

이 명령은 셸의 날짜 문법에 의존하지 않아 Windows와 macOS, Linux에서 같은 방식으로 실행할 수 있습니다.

node -e "const runAt=new Date(Date.now()+60000).toISOString(); fetch('http://localhost:3000/reports/scheduled',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({reportId:'scheduled-demo',requestedBy:'user-42',rows:3000,runAt})}).then(async r=>console.log(r.status,await r.text()))"

실행 가능 시각 전에는 delayed, 이후 Worker가 가져가면 active가 됩니다.

API 서버와 Redis 서버의 시계가 크게 어긋나면 예상 시각도 어긋나므로 운영 환경에서는 시간 동기화를 확인합니다.

Job Scheduler로 반복 작업 만들기

매일 오래된 보고서 메타데이터를 정리하는 작업을 추가합니다.

먼저 작업 이름과 데이터 타입을 확장합니다.

src/reports/reports.constants.ts
export const REPORT_QUEUE = 'reports';
export const GENERATE_REPORT_JOB = 'report.generate';
export const CLEANUP_REPORTS_JOB = 'reports.cleanup';
src/reports/report-job.types.ts (정리 작업 추가)
export interface CleanupReportsData {
  keepDays: number;
}

export interface CleanupReportsResult {
  removed: number;
}

애플리케이션이 부팅될 때 scheduler를 등록하는 provider를 만듭니다.

scheduler ID는 배포마다 바뀌지 않는 상수여야 합니다.

src/reports/report-scheduler.service.ts
import { InjectQueue } from '@nestjs/bullmq';
import { Injectable, OnApplicationBootstrap } from '@nestjs/common';
import { Queue } from 'bullmq';
import {
  CLEANUP_REPORTS_JOB,
  REPORT_QUEUE,
} from './reports.constants';

@Injectable()
export class ReportSchedulerService implements OnApplicationBootstrap {
  constructor(
    @InjectQueue(REPORT_QUEUE)
    private readonly reportsQueue: Queue,
  ) {}

  async onApplicationBootstrap() {
    await this.reportsQueue.upsertJobScheduler(
      'daily-report-cleanup',
      {
        pattern: '0 0 3 * * *',
        tz: 'Asia/Seoul',
      },
      {
        name: CLEANUP_REPORTS_JOB,
        data: {
          keepDays: 30,
        },
        opts: {
          attempts: 3,
          backoff: { type: 'exponential', delay: 5_000 },
          removeOnComplete: 30,
          removeOnFail: 30,
        },
      },
    );
  }
}

0 0 3 * * *tz: 'Asia/Seoul'을 기준으로 매일 오전 3시를 뜻하는 6필드 cron 패턴입니다.

호스트 운영체제의 로컬 시간대에 해석을 맡기지 않습니다.

이 예제처럼 pattern만 지정하면 최초 작업도 다음 cron 일치 시각의 delayed 작업으로 생성됩니다. cron과 무관하게 한 번 먼저 실행해야 할 때만 immediately: true를 명시합니다.

patternimmediately: true는 해당 upsert 호출의 첫 iteration에 적용됩니다. 애플리케이션 부팅 때 같은 ID를 매번 upsert하는 코드에 넣으면 배포 때도 즉시 작업이 다시 예약될 수 있으므로 의도 없이 사용하지 않습니다.

BullMQ 5.19부터 every로 새 scheduler를 등록하면 immediately를 쓰지 않아도 첫 작업이 즉시 생성됩니다. 이는 every의 규칙이며 pattern의 기본 동작과 섞어 해석하지 않습니다.

같은 scheduler ID를 다시 upsert하면 기존 scheduler의 반복 설정과 job template을 갱신합니다. 배포 때마다 새 scheduler를 만들지 않도록 안정된 ID를 사용합니다.

cron 시각과 delay 만료 시각은 작업이 실행 가능해지는 경계입니다. Worker가 없거나 큐가 밀리면 실제 active 전환은 더 늦을 수 있으므로 현재 시각이 정확히 오전 3시인지 검사해 늦게 시작한 정상 작업을 버리지 않습니다.

Job Scheduler는 마지막 작업이 active로 이동할 때 다음 작업을 만듭니다.

Worker 처리량이 부족하면 설정한 간격보다 실제 생성 주기가 느려질 수 있으므로 정확한 초 단위 타이머로 해석하지 않습니다.

등록된 반복 규칙은 scheduler ID로 조회하고 제거합니다.

src/reports/report-scheduler.service.ts (관리 메서드)
getSchedulers() {
  return this.reportsQueue.getJobSchedulers(0, 20, true);
}

removeDailyCleanup() {
  return this.reportsQueue.removeJobScheduler('daily-report-cleanup');
}

Job Scheduler가 만드는 작업은 내부적으로 특별한 ID를 사용하므로 job template에 임의 jobId를 지정하지 않습니다.

업무 중복 방지가 필요하면 Worker의 멱등성 키를 사용합니다.

@nestjs/schedule@Cron()은 한 프로세스 안에서 콜백을 실행할 때 간단합니다.

하지만 모든 API 복제본에서 동시에 실행될 수 있고 실행 상태가 Redis에 남지 않습니다.

재시도와 상태 보관이 필요한 업무 작업은 Job Scheduler로 큐에 넣는 편이 안전합니다.

Worker에서 작업 이름 분기

BullMQ의 WorkerHost는 큐마다 process() 하나를 구현합니다.

작업 이름이 여러 개라면 switch로 명시적으로 분기합니다.

아래 코드는 앞 절의 보고서 생성 로직과 이번 절의 정리 작업, 동시성, Worker 이벤트를 합친 최종 형태입니다.

src/reports/reports.processor.ts
import { OnWorkerEvent, Processor, WorkerHost } from '@nestjs/bullmq';
import { Logger } from '@nestjs/common';
import { Job, UnrecoverableError } from 'bullmq';
import { ReportArtifactsService } from './report-artifacts.service';
import {
  CleanupReportsData,
  CleanupReportsResult,
  GenerateReportData,
  GenerateReportResult,
} from './report-job.types';
import {
  CLEANUP_REPORTS_JOB,
  GENERATE_REPORT_JOB,
  REPORT_QUEUE,
} from './reports.constants';

const wait = (milliseconds: number) =>
  new Promise((resolve) => setTimeout(resolve, milliseconds));

@Processor(REPORT_QUEUE, { concurrency: 3 })
export class ReportsProcessor extends WorkerHost {
  private readonly logger = new Logger(ReportsProcessor.name);

  constructor(private readonly artifacts: ReportArtifactsService) {
    super();
  }

  async process(job: Job): Promise<unknown> {
    switch (job.name) {
      case GENERATE_REPORT_JOB:
        return this.generateReport(
          job as Job<GenerateReportData, GenerateReportResult>,
        );
      case CLEANUP_REPORTS_JOB:
        return this.cleanupReports(
          job as Job<CleanupReportsData, CleanupReportsResult>,
        );
      default:
        throw new UnrecoverableError(`unsupported report job: ${job.name}`);
    }
  }

  @OnWorkerEvent('active')
  onActive(job: Job) {
    this.logger.log(`report job active: ${job.id}`);
  }

  @OnWorkerEvent('failed')
  onFailed(job: Job | undefined, error: Error) {
    this.logger.error(`report job failed: ${job?.id ?? 'unknown'} ${error.message}`);
  }

  private async generateReport(
    job: Job<GenerateReportData, GenerateReportResult>,
  ): Promise<GenerateReportResult> {
    if (job.data.rows < 1) {
      throw new UnrecoverableError('rows must be greater than zero');
    }

    const existing = this.artifacts.find(job.data.reportId);
    if (existing) return { ...existing, reused: true };

    if (job.attemptsMade < (job.data.failUntilAttempt ?? 0)) {
      throw new Error(`temporary storage failure on attempt ${job.attemptsMade + 1}`);
    }

    await job.updateProgress({ step: 'load', percent: 20 });
    await wait(400);
    await job.updateProgress({ step: 'render', percent: 70 });
    await wait(400);
    await job.updateProgress({ step: 'upload', percent: 95 });
    await wait(400);
    await job.updateProgress({ step: 'upload', percent: 100 });

    return this.artifacts.saveOnce({
      reportId: job.data.reportId,
      objectKey: `reports/${job.data.reportId}.csv`,
      generatedRows: job.data.rows,
      reused: false,
    });
  }

  private async cleanupReports(
    job: Job<CleanupReportsData, CleanupReportsResult>,
  ): Promise<CleanupReportsResult> {
    const removed = await this.artifacts.removeOlderThan(job.data.keepDays);
    return { removed };
  }
}

실습의 ReportArtifactsService에는 호출 형태를 확인할 수 있도록 다음 메서드를 추가합니다.

메모리 저장소에는 생성 시각을 저장하지 않았으므로 0을 반환하고, 운영 구현에서는 데이터베이스의 생성 시각을 기준으로 삭제합니다.

src/reports/report-artifacts.service.ts (정리 메서드 추가)
async removeOlderThan(_keepDays: number) {
  return 0;
}

기능 모듈에 ReportSchedulerService도 provider로 등록합니다.

src/reports/reports.module.ts (scheduler 등록 단계)
import { ReportArtifactsService } from './report-artifacts.service';
import { ReportSchedulerService } from './report-scheduler.service';

providers: [
  ReportsService,
  ReportsProcessor,
  ReportArtifactsService,
  ReportSchedulerService,
],

동시성과 처리량 조절

위 Worker처럼 I/O 대기가 많은 작업에 concurrency: 3을 설정하면 한 Worker가 최대 세 작업을 함께 처리할 수 있습니다.

동시성은 무조건 크게 잡지 않습니다.

  • 데이터베이스 connection pool보다 많은 작업을 동시에 실행하면 대기만 늘어납니다.
  • 외부 API의 rate limit을 넘으면 실패와 재시도가 함께 증가합니다.
  • CPU 집약 작업은 Node.js 이벤트 루프를 막을 수 있으므로 별도 Worker 프로세스나 sandboxed processor를 검토합니다.
  • 여러 인스턴스를 띄우면 총 동시성은 인스턴스 수 × 인스턴스별 concurrency가 됩니다.

queue의 global concurrency를 더 작게 설정했다면 active 작업 수는 그 값으로 제한됩니다. rate limit은 동시 active 개수가 아니라 일정 시간 동안 새로 시작하는 작업 수를 별도로 제한합니다.

이벤트와 큐 상태 관찰하기

@OnWorkerEvent는 위 코드처럼 현재 Worker의 active와 failed 흐름을 관찰할 때 사용합니다.

여러 Worker에서 발생한 큐 전체 이벤트는 QueueEventsListener를 사용합니다.

src/reports/report-queue-events.listener.ts
import {
  OnQueueEvent,
  QueueEventsHost,
  QueueEventsListener,
} from '@nestjs/bullmq';
import { Logger } from '@nestjs/common';
import { REPORT_QUEUE } from './reports.constants';

@QueueEventsListener(REPORT_QUEUE)
export class ReportQueueEventsListener extends QueueEventsHost {
  private readonly logger = new Logger(ReportQueueEventsListener.name);

  @OnQueueEvent('completed')
  onCompleted({ jobId }: { jobId: string }) {
    this.logger.log(`report job completed: ${jobId}`);
  }

  @OnQueueEvent('failed')
  onFailed({ jobId, failedReason }: { jobId: string; failedReason: string }) {
    this.logger.error(`report job failed: ${jobId} ${failedReason}`);
  }

  @OnQueueEvent('deduplicated')
  onDeduplicated({
    jobId,
    deduplicatedJobId,
  }: {
    jobId: string;
    deduplicatedJobId: string;
  }) {
    this.logger.warn(`duplicate ${deduplicatedJobId} kept ${jobId}`);
  }
}

리스너도 기능 모듈의 providers에 등록해야 동작합니다.

src/reports/reports.module.ts (providers 최종)
import { ReportQueueEventsListener } from './report-queue-events.listener';

providers: [
  ReportsService,
  ReportsProcessor,
  ReportArtifactsService,
  ReportSchedulerService,
  ReportQueueEventsListener,
],

큐 길이를 확인하는 운영 API를 추가합니다.

src/reports/reports.service.ts (getQueueHealth 추가)
async getQueueHealth() {
  return this.reportsQueue.getJobCounts(
    'waiting',
    'active',
    'delayed',
    'completed',
    'failed',
  );
}
src/reports/reports.controller.ts (큐 상태 API 추가)
@Get('queue/health')
getQueueHealth() {
  return this.reportsService.getQueueHealth();
}

waiting이 계속 증가하면 Worker 처리량이 유입량보다 낮은 것입니다.

failed 증가율, 작업 대기 시간, 처리 시간, stalled 이벤트를 함께 봐야 합니다.

stalled는 조회 가능한 job state가 아니라 lock을 잃은 active 작업이 waiting으로 돌아가거나 허용 횟수를 넘겨 failed로 이동할 때 발생하는 이벤트입니다.

completed 누계만으로는 현재 병목을 알 수 없습니다.


안전하게 종료하기

Nest 애플리케이션이 종료 신호를 받아 lifecycle hook을 실행하도록 설정합니다.

src/main.ts (종료 hook 추가)
async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  app.enableShutdownHooks();
  app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true }));
  await app.listen(process.env.PORT ?? 3000);
}

Nest의 BullMQ 통합이 관리하는 Worker와 Queue는 애플리케이션 종료 과정에서 연결을 정리합니다.

raw BullMQ Worker를 직접 만든 경우에는 종료 시 await worker.close()를 호출해야 합니다.

close()는 새 작업을 가져오지 않고 현재 작업이 끝나기를 기다리며 자체 timeout이 없으므로 작업 코드에도 종료 가능한 상한을 둡니다.

강제 종료되면 active 작업은 stalled 감지를 거쳐 다른 Worker가 다시 가져갈 수 있습니다.

이때도 결과가 안전하려면 3절의 멱등성 규칙이 필요합니다.

현재 BullMQ는 stalled 작업 감지를 자체적으로 처리하므로 별도 보조 클래스를 만들지 않습니다.

BullMQ Worker의 총 동시성을 인스턴스 수와 로컬 concurrency의 곱으로 계산하고 Redis, 데이터베이스, 외부 API, CPU 병목과 queue 상태, stalled 재처리, SIGTERM 종료 순서를 함께 점검하는 운영 표

Nest · BullMQ Operations

동시성은 병목까지, 종료는 active 작업이 끝날 때까지

설정 숫자만 올리지 말고 실제 자원 상한과 queue 흐름을 함께 봅니다. 종료 신호를 받으면 새 작업 수신을 멈추고 처리 중인 작업을 비운 뒤 연결을 닫습니다.

Capacity

이론상 최대 active 작업 수

instances × local concurrency

예를 들어 인스턴스 4개가 각각 concurrency 3이면 최대 12개를 함께 처리합니다. global concurrency가 더 작다면 그 값이 active 상한이며, rate limit은 시작 속도를 별도로 제한합니다.

Boundary

I/O와 CPU는 같은 숫자로 늘지 않는다

I/O 대기가 많은 작업은 로컬 concurrency의 이점을 얻습니다. CPU 집약 작업은 이벤트 루프와 lock 갱신을 막을 수 있어 sandboxed processor나 별도 프로세스를 검토합니다.

concurrency보다 먼저 확인할 실제 병목
자원 실제 상한 위험 신호
Redis 연결 수, latency, 처리 명령량 queue 조작과 lock 갱신 지연
데이터베이스 connection pool, lock, 쿼리 처리량 pool 대기와 transaction 지연
외부 API rate limit과 응답 시간 429, timeout, 재시도 증가
CPU core와 이벤트 루프 여유 처리 지연과 stalled 이벤트 반복
Redis

연결과 latency

queue 조작과 lock 갱신이 늦어지는지 봅니다.

DB

pool과 lock

동시 작업보다 connection pool과 transaction 상한이 작은지 봅니다.

API

rate limit

429, timeout, 재시도가 함께 늘어나는지 봅니다.

CPU

이벤트 루프

CPU 점유가 처리와 lock 갱신을 막으면 프로세스를 분리합니다.

waiting

유입 대비 처리량

지속 상승하면 Worker 처리량이 부족합니다.

active

현재 처리 중

총 동시성 안에서 변하고 오래 고정되지 않아야 합니다.

delayed

예약과 backoff

미래 실행과 재시도 대기 규모를 확인합니다.

failed

최종 실패

원인별 증가율과 복구 절차를 확인합니다.

stalled event

상태가 아닌 재처리 신호

lock을 잃은 active 작업은 waiting으로 돌아가며, 허용 횟수를 넘기면 failed가 됩니다.

Graceful shutdown

SIGTERM 이후 순서를 보장한다

  1. 새 작업 수신 중지

    Nest lifecycle이 시작되고 등록된 Worker는 더 이상 새 job을 가져오지 않습니다.

  2. active 작업 drain

    현재 처리 중인 작업이 completed 또는 failed로 끝날 때까지 기다립니다. Worker close에는 자체 timeout이 없습니다.

  3. Redis 연결 종료

    Worker와 Queue가 사용하던 연결을 정리한 뒤 프로세스를 종료합니다.

Forced exit

drain 전에 종료되면 stalled 재처리가 일어날 수 있다

lock을 잃은 작업은 다른 Worker가 다시 가져갈 수 있으므로 외부 결과 쓰기는 멱등해야 합니다. stalled는 독립 job state가 아니라 active에서 waiting 또는 failed로 이동할 때의 이벤트입니다.

운영 순서: 총 동시성 계산 → 실제 병목 확인 → waiting·active·delayed·failed와 stalled 이벤트 관찰 → 종료 유예 시간 안에서 drain.

운영 점검표

관찰 항목정상 신호위험 신호
waiting부하가 줄면 다시 감소지속적으로 우상향
active설정한 총 동시성 안에서 변화오래 같은 작업이 머묾
delayed예약·백오프 작업 수와 비슷과거 시각 작업이 계속 남음
failed원인별 알림과 복구 절차 존재같은 원인이 빠르게 반복
stalled드물게 발생하고 복구됨CPU 정지나 강제 종료로 반복
종료 시간배포 유예 시간 안에 완료close()가 끝없이 대기

6장에서는 긴 HTTP 요청을 작업 큐로 분리하고, Producer와 Worker의 계약, 상태 조회, 재시도와 멱등성, 지연·반복 실행, 동시성, 관측, 종료까지 연결했습니다.

작업 큐는 마이크로서비스의 전제 조건은 아니지만, 실행 책임을 프로세스 밖의 영속 상태로 옮기는 첫 경험을 제공합니다.

다음 장에서는 이 경계를 서비스 간 통신으로 확장하고 gRPC와 Kafka의 책임을 비교합니다.