junyeokk
Blog
Serverless·2025. 11. 18

serverless-offline

서버리스 아키텍처로 백엔드를 구성하면 한 가지 불편한 점이 생긴다. 코드를 수정할 때마다 AWS에 배포해서 테스트해야 한다는 것이다. Lambda 함수 하나 고치는 데 sls deploy로 배포하고, CloudFormation 스택 업데이트 기다리고, API Gateway에서 응답 확인하고... 간단한 수정에도 몇 분씩 소요된다. 개발 속도가 전통적인 Express 서버 개발과 비교도 안 될 만큼 느려진다.

serverless-offline은 이 문제를 해결하는 Serverless Framework 플러그인이다. AWS Lambda와 API Gateway를 로컬에서 에뮬레이션해서, node 프로세스 하나로 서버리스 환경을 시뮬레이션한다. 코드를 수정하면 즉시 반영되고, 실제 AWS 리소스를 소비하지 않으니 비용도 들지 않는다.


왜 필요한가

서버리스 개발에서 피드백 루프의 차이를 보면 이해가 빠르다.

serverless-offline 없이:

text
코드 수정 → sls deploy (1~3분) → API Gateway 엔드포인트 호출 → 로그 확인 (CloudWatch)

serverless-offline 사용:

text
코드 수정 → 자동 재시작 → localhost 호출 → 터미널에서 로그 즉시 확인

배포 없이 로컬에서 바로 테스트할 수 있다는 건 단순히 시간 절약만이 아니다. 디버깅도 쉬워진다. CloudWatch Logs에서 로그를 찾아 헤매는 대신 터미널에서 바로 에러 스택트레이스를 볼 수 있고, console.log를 넣어도 즉시 결과를 확인할 수 있다. Node.js 디버거를 붙여서 브레이크포인트를 걸 수도 있다.


설치와 설정

npm으로 설치하고 serverless.yml의 plugins에 추가하면 된다.

bash
npm install serverless-offline --save-dev
yaml
# serverless.yml
plugins:
  - serverless-offline

이것만으로 기본적인 사용이 가능하다. sls offline 명령어를 실행하면 로컬에 HTTP 서버가 뜬다.

bash
npx sls offline

# 출력:
# Starting Offline at stage dev (us-east-1)
# Offline [http for lambda] listening on http://localhost:3002
# Routes for app:
# ANY /{proxy*} (λ: app)

기본 포트는 3000이다. 다른 서비스와 충돌하면 custom 섹션에서 변경할 수 있다.

yaml
custom:
  serverless-offline:
    httpPort: 3001

동작 원리

serverless-offline의 내부 동작을 이해하면 문제가 생겼을 때 디버깅이 훨씬 쉬워진다.

1. serverless.yml 파싱

플러그인이 시작되면 먼저 serverless.ymlfunctions 섹션을 읽는다. 각 함수에 정의된 events에서 HTTP 이벤트를 추출하고, 이를 기반으로 라우팅 테이블을 구성한다.

yaml
functions:
  app:
    handler: dist/lambda.handler
    events:
      - http:
          method: ANY
          path: /{proxy+}

이 설정을 읽고 ANY /{proxy+} 패턴에 매칭되는 모든 HTTP 요청을 app 함수로 라우팅한다.

2. HTTP 서버 생성

내부적으로 hapi.js 서버를 생성한다. Express나 Koa가 아니라 hapi를 쓰는 이유는 라우팅 시스템이 API Gateway의 path parameter 패턴({proxy+} 같은)과 호환성이 좋기 때문이다.

3. 요청 → Lambda 이벤트 변환

HTTP 요청이 들어오면 이를 Lambda가 받는 형태의 이벤트 객체로 변환한다. 실제 AWS에서 API Gateway가 Lambda를 호출할 때 전달하는 이벤트와 동일한 구조다.

javascript
// 실제로 Lambda 핸들러에 전달되는 event 객체 (간략화)
{
  httpMethod: "GET",
  path: "/api/users",
  headers: { "content-type": "application/json" },
  queryStringParameters: { page: "1" },
  body: null,
  requestContext: {
    stage: "dev",
    identity: { sourceIp: "127.0.0.1" }
  }
}

이 변환이 정확하기 때문에 로컬에서 동작하는 코드가 AWS에 배포했을 때도 동일하게 동작한다. API Gateway v1(REST API)과 v2(HTTP API) 이벤트 형식을 모두 지원한다.

4. 핸들러 실행

변환된 이벤트 객체를 Lambda 핸들러 함수에 전달하고 실행한다. 핸들러가 반환하는 응답 객체를 다시 HTTP 응답으로 변환해서 클라이언트에 돌려준다.

javascript
// Lambda 핸들러가 반환하는 응답
{
  statusCode: 200,
  headers: { "content-type": "application/json" },
  body: JSON.stringify({ users: [...] })
}

주요 옵션

httpPort / host

서버가 바인딩할 포트와 호스트를 지정한다.

yaml
custom:
  serverless-offline:
    httpPort: 3001      # 기본값: 3000
    host: 0.0.0.0       # 기본값: localhost

host: 0.0.0.0으로 설정하면 같은 네트워크의 다른 기기에서도 접근할 수 있다. 모바일 앱 개발에서 실기기로 테스트할 때 유용하다.

lambdaPort

Lambda 함수를 별도 포트에서 직접 호출할 수 있게 한다. API Gateway를 거치지 않고 Lambda를 직접 invoke하는 시나리오를 테스트할 때 사용한다.

yaml
custom:
  serverless-offline:
    lambdaPort: 3002    # 기본값: 3002

noPrependStageInUrl

기본적으로 serverless-offline은 URL에 stage를 붙인다. /dev/api/users 처럼. 프론트엔드에서 /api/users로 호출하고 있다면 이 옵션으로 stage prefix를 제거할 수 있다.

yaml
custom:
  serverless-offline:
    noPrependStageInUrl: true

reloadHandler

코드가 변경되면 핸들러를 자동으로 다시 로드한다. 기본적으로 serverless-offline은 핸들러를 캐싱하는데, 이 옵션을 켜면 매 요청마다 핸들러를 새로 불러온다. 개발 중 코드 변경을 즉시 반영하고 싶을 때 유용하다.

yaml
custom:
  serverless-offline:
    reloadHandler: true

다만 매 요청마다 모듈을 다시 로드하므로 응답 시간이 약간 느려질 수 있다. TypeScript를 사용하면 빌드 파이프라인과 함께 nodemon이나 watch 모드를 조합하는 것이 더 실용적이다.

useChildProcesses / useWorkerThreads

기본적으로 serverless-offline은 메인 프로세스에서 핸들러를 실행한다. 실제 AWS Lambda는 각 함수가 독립된 실행 환경에서 돌아가는데, 이 차이가 문제가 될 수 있다. 한 함수에서 전역 상태를 변경하면 다른 함수에 영향을 주거나, 한 함수가 크래시하면 전체 서버가 죽는 식이다.

yaml
custom:
  serverless-offline:
    useChildProcesses: true    # 각 함수를 별도 child process에서 실행
    # 또는
    useWorkerThreads: true     # 각 함수를 별도 worker thread에서 실행

useChildProcesses는 완전한 프로세스 격리를 제공하지만 오버헤드가 크고, useWorkerThreads는 더 가볍지만 격리 수준이 낮다. 대부분의 개발 시나리오에서는 기본 모드(인프로세스 실행)로 충분하다.

allowCache

Lambda 핸들러의 require 캐시를 유지할지 결정한다. false로 설정하면 매 호출마다 핸들러 모듈을 새로 로드한다.

yaml
custom:
  serverless-offline:
    allowCache: false

NestJS와 함께 사용

serverless-offline이 특히 빛을 발하는 케이스가 NestJS 같은 프레임워크와의 조합이다. NestJS 앱을 Lambda에서 실행하려면 @codegenie/serverless-express 같은 어댑터로 래핑하는데, serverless-offline이 이 전체 파이프라인을 로컬에서 재현한다.

typescript
// lambda.ts
import { NestFactory } from '@nestjs/core';
import { ExpressAdapter } from '@nestjs/platform-express';
import serverlessExpress from '@codegenie/serverless-express';
import express from 'express';
import { AppModule } from './app.module';

let cachedServer: any;

async function bootstrap() {
  const expressApp = express();
  const adapter = new ExpressAdapter(expressApp);
  const app = await NestFactory.create(AppModule, adapter);
  await app.init();
  return serverlessExpress({ app: expressApp });
}

export const handler = async (event: any, context: any) => {
  if (!cachedServer) {
    cachedServer = await bootstrap();
  }
  return cachedServer(event, context);
};

이 구조에서 sls offline을 실행하면:

  1. serverless-offline이 HTTP 서버를 시작
  2. 요청이 들어오면 Lambda 이벤트로 변환
  3. handler 함수가 NestJS 앱을 부트스트랩 (첫 요청 시)
  4. serverless-express가 이벤트를 Express 요청으로 변환
  5. NestJS 파이프라인이 요청을 처리하고 응답

로컬에서 이 전체 흐름이 동작하기 때문에, Lambda 환경에서만 발생하는 이슈(cold start 동작, 이벤트 변환 문제 등)를 배포 전에 잡을 수 있다.


환경 변수 처리

serverless-offline은 serverless.yml에 정의된 환경 변수를 자동으로 Lambda 핸들러에 주입한다.

yaml
provider:
  environment:
    NODE_ENV: ${self:provider.stage}

functions:
  app:
    environment:
      DATABASE_URL: postgres://localhost:5432/mydb

provider 레벨과 function 레벨 환경 변수가 모두 process.env에 설정된다. 다만 실제 AWS에서 SSM Parameter Store나 Secrets Manager를 참조하는 경우는 다르다.

yaml
# 이 설정은 serverless-offline에서 동작하지 않는다
environment:
  DATABASE_URL: !Sub 'arn:aws:ssm:${AWS::Region}:${AWS::AccountId}:parameter/...'

SSM 참조는 AWS 런타임에서만 해석되기 때문에, 로컬에서는 .env 파일이나 dotenv 라이브러리로 대체해야 한다. serverless-dotenv-plugin을 함께 사용하면 이 처리를 자동화할 수 있다.

yaml
plugins:
  - serverless-dotenv-plugin
  - serverless-offline
bash
# .env.dev
DATABASE_URL=postgres://localhost:5432/mydb
JWT_SECRET=local-dev-secret

한계와 주의점

API Gateway 기능 완전 미지원

serverless-offline은 API Gateway의 핵심 기능 대부분을 에뮬레이션하지만, 완벽하지는 않다.

  • Authorizer: Lambda Authorizer는 지원하지만, Cognito User Pool Authorizer는 기본적으로 무시된다
  • Request Validation: API Gateway의 request model validation은 에뮬레이션하지 않는다
  • Usage Plan / Throttling: API key나 요청 제한은 로컬에서 적용되지 않는다
  • VPC 연결: 로컬 환경에서는 VPC 내부 리소스(RDS 등)에 직접 접근해야 한다

Cold Start 시뮬레이션의 부정확성

실제 Lambda의 cold start는 컨테이너 초기화, 런타임 부트스트랩, 핸들러 로드 단계를 거친다. serverless-offline은 이 과정을 정확히 재현하지 못한다. 로컬에서는 빠르게 동작하지만 실제 Lambda에서 타임아웃이 발생할 수 있으므로, cold start가 중요한 로직은 실제 환경에서 테스트해야 한다.

동시성 모델 차이

AWS Lambda는 요청마다 별도의 실행 환경을 제공한다(또는 warm 컨테이너를 재사용한다). serverless-offline은 기본적으로 단일 프로세스에서 모든 요청을 처리한다. 전역 변수를 사용하는 코드가 있다면 로컬에서는 문제가 없다가 Lambda에서 예상치 못한 동작을 할 수 있다.

다른 AWS 서비스 트리거

HTTP 이벤트 외에 SQS, SNS, DynamoDB Streams 같은 이벤트 소스는 별도의 플러그인이나 도구가 필요하다.

  • serverless-offline-sqs: SQS 이벤트 에뮬레이션
  • serverless-offline-sns: SNS 이벤트 에뮬레이션
  • serverless-dynamodb-local: DynamoDB 로컬 실행

실전 구성 예시

실제 프로젝트에서 자주 사용하는 조합을 보자.

yaml
service: my-backend

plugins:
  - serverless-esbuild        # TypeScript 번들링
  - serverless-offline         # 로컬 에뮬레이션

custom:
  serverless-offline:
    httpPort: 3001
    noPrependStageInUrl: true
    reloadHandler: true

  esbuild:
    bundle: true
    minify: false
    sourcemap: true

provider:
  name: aws
  runtime: nodejs20.x
  stage: ${opt:stage, 'dev'}

functions:
  app:
    handler: src/lambda.handler
    events:
      - http:
          method: ANY
          path: /{proxy+}

package.json에 스크립트를 추가한다.

json
{
  "scripts": {
    "dev": "sls offline --stage dev",
    "deploy:dev": "sls deploy --stage dev",
    "deploy:prod": "sls deploy --stage prod"
  }
}

npm run dev로 로컬 개발 서버를 시작하고, 배포할 때는 npm run deploy:dev를 사용한다. 같은 serverless.yml로 로컬 개발과 클라우드 배포를 모두 처리할 수 있다.


대안 비교

AWS SAM Local

AWS에서 공식으로 제공하는 로컬 테스트 도구다. Docker 컨테이너 안에서 Lambda를 실행하기 때문에 실제 환경과 더 가깝지만, 그만큼 느리고 Docker가 필수다. Serverless Framework 대신 SAM 템플릿을 사용하는 프로젝트에 적합하다.

LocalStack

AWS 서비스 전체를 로컬에서 에뮬레이션하는 도구다. Lambda뿐 아니라 S3, DynamoDB, SQS 등도 로컬에서 사용할 수 있다. serverless-offline보다 범위가 넓지만 설정이 복잡하고 리소스 사용량이 크다.

직접 Express 서버로 개발

serverless-express 어댑터를 사용하는 경우, Lambda 래퍼 없이 Express 앱을 직접 실행해서 개발하는 방법도 있다. 가장 빠르고 단순하지만 Lambda 특유의 동작(이벤트 객체 구조, context, cold start)을 테스트할 수 없다.

도구속도정확도설정 복잡도Docker 필요
serverless-offline빠름중간낮음아니오
SAM Local느림높음중간
LocalStack느림높음높음
Express 직접 실행매우 빠름낮음없음아니오

대부분의 경우 serverless-offline이 개발 속도와 정확도의 균형에서 가장 실용적인 선택이다. Lambda 환경 특유의 이슈를 테스트해야 한다면 배포 전에 SAM Local이나 실제 AWS 환경에서 검증하는 것을 병행하면 된다.


관련 문서