API Gateway 엔드포인트 로컬에서 호출하기

우리의 노트 앱 백엔드에는 API Gateway 엔드포인트가 있습니다. 이 엔드포인트를 로컬에서 개발할 수 있도록 하기 위해 serverless-offline 플러그인을 사용하여 로컬 웹 서버를 시작할 것입니다.

로컬에서 API 호출하기

모든 API 서비스가 이 플러그인을 필요로 하기 때문에, 위 플러그인을 레포지토리 루트에 설치했습니다. notes-api 폴더 안의 serverless.yml 파일을 열어보세요. serverless-offline이 플러그인 목록에 있는 것을 확인할 수 있습니다.

service: notes-api

plugins:
  - serverless-offline

이제 로컬 웹 서버를 시작해 보겠습니다.

$ cd notes-api
$ serverless offline

기본적으로 서버는 http://localhost 주소와 3000 포트에서 시작됩니다. 엔드포인트에 요청을 보내보겠습니다.

$ curl http://localhost:3000/notes

Cognito Identity Pool 인증 모킹하기

우리 API 엔드포인트는 Cognito Identity Pool을 사용해 보안이 설정되어 있습니다. serverless-offline 플러그인을 사용하면 요청 헤더를 통해 Cognito 인증 정보를 전달할 수 있습니다. 이를 통해 Cognito Identity Pool에 의해 인증된 것처럼 Lambda 함수를 호출할 수 있습니다.

User Pool 사용자 ID를 모킹하려면:

$ curl --header "cognito-identity-id: 13179724-6380-41c4-8936-64bca3f3a25b" \
  http://localhost:3000/notes

Lambda 함수 내에서 event.requestContext.identity.cognitoIdentityId를 통해 이 ID에 접근할 수 있습니다.

Identity Pool 사용자 ID를 모킹하려면:

$ curl --header "cognito-authentication-provider: cognito-idp.us-east-1.amazonaws.com/us-east-1_Jw6lUuyG2,cognito-idp.us-east-1.amazonaws.com/us-east-1_Jw6lUuyG2:CognitoSignIn:5f24dbc9-d3ab-4bce-8d5f-eafaeced67ff" \
  http://localhost:3000/notes

Lambda 함수 내에서 event.requestContext.identity.cognitoAuthenticationProvider를 통해 이 ID에 접근할 수 있습니다.

여러 서비스 작업하기

우리 앱은 여러 API 서비스로 구성되어 있습니다. notes-apibilling-api는 두 개의 별도 Serverless Framework 서비스입니다. 각각 /notes/billing 경로에 응답합니다.

serverless-offline 플러그인은 전체 API 엔드포인트를 에뮬레이트할 수 없습니다. 요청을 처리하고 해당 서비스로 라우팅할 수 없습니다. 이는 플러그인이 앱 수준이 아닌 서비스 수준에서 동작하기 때문입니다.

그래도 /notes/billing을 각각의 서비스로 라우팅하면서 8080 포트에서 서버를 실행할 수 있는 간단한 스크립트를 제공합니다.

#!/usr/bin/env node

const { spawn } = require("child_process");
const http = require("http");
const httpProxy = require("http-proxy");
const services = [
  { route: "/billing/*", path: "services/billing-api", port: 3001 },
  { route: "/notes/*", path: "services/notes-api", port: 3002 },
];

// 각 서비스에 대해 `serverless offline` 시작
services.forEach((service) => {
  const child = spawn(
    "serverless",
    ["offline", "start", "--stage", "dev", "--port", service.port],
    { cwd: service.path }
  );
  child.stdout.setEncoding("utf8");
  child.stdout.on("data", (chunk) => console.log(chunk));
  child.stderr.on("data", (chunk) => console.log(chunk));
  child.on("close", (code) => console.log(`child exited with code ${code}`));
});

// 8080 포트에서 프록시 서버 시작, URL 경로 기반으로 요청 전달
const proxy = httpProxy.createProxyServer({});
const server = http.createServer(function (req, res) {
  const service = services.find((per) => urlMatchRoute(req.url, per.route));
  // 케이스 1: 매칭되는 서비스가 있으면 요청을 해당 서비스로 전달
  if (service) {
    proxy.web(req, res, { target: `http://localhost:${service.port}` });
  }
  // 케이스 2: 매칭되는 서비스가 없으면 사용 가능한 라우트 표시
  else {
    res.writeHead(200, { "Content-Type": "text/plain" });
    res.write(
      `Url path "${req.url}" does not match routes defined in services\n\n`
    );
    res.write(`Available routes are:\n`);
    services.map((service) => res.write(`- ${service.route}\n`));
    res.end();
  }
});
server.listen(8080);

// 라우트 매칭 확인
// - 예: url은 '/notes/123'
// - 예: route는 '/notes/*'
function urlMatchRoute(url, route) {
  const urlParts = url.split("/");
  const routeParts = route.split("/");
  for (let i = 0, l = routeParts.length; i < l; i++) {
    const urlPart = urlParts[i];
    const routePart = routeParts[i];

    // 케이스 1: 둘 중 하나가 undefined면 매칭되지 않음
    if (urlPart === undefined || routePart === undefined) {
      return false;
    }

    // 케이스 2: route part가 match all(*)이면 매칭
    if (routePart === "*") {
      return true;
    }

    // 케이스 3: 정확히 일치하면 계속 확인
    if (urlPart === routePart) {
      continue;
    }

    // 케이스 4: route part가 변수면 계속 확인
    if (routePart.startsWith("{")) {
      continue;
    }
  }

  return true;
}

이 스크립트는 샘플 저장소startServer로 포함되어 있습니다. 이 스크립트가 어떻게 동작하는지 간단히 살펴보겠습니다. 크게 4가지 섹션으로 나뉩니다:

  1. 가장 상단에서 시작할 서비스를 정의합니다. 새로운 서비스를 추가할 때 이 부분을 수정하면 됩니다.
  2. serverless-offline 플러그인을 사용해 정의된 포트로 각 서비스를 시작합니다.
  3. 8080 포트에서 HTTP 서버를 시작합니다. 요청 처리 로직에서 매칭되는 라우트를 가진 서비스를 찾습니다. 매칭되는 서비스가 있으면 요청을 해당 서비스로 프록시합니다.
  4. 하단에는 라우트가 URL과 매칭되는지 확인하는 함수가 있습니다.

프로젝트 루트에서 이 서버를 로컬로 실행할 수 있습니다:

$ ./startServer

이제 Lambda 함수를 로컬에서 개발하는 방법을 잘 알게 되었으니, 새로운 기능을 위한 환경을 생성할 때 어떤 일이 일어나는지 살펴보겠습니다.