Thursday, December 27, 2018

AWS Lambda와 Firebase 연동, 더 이상 실패하지 않는 빌드 및 배포 전략

AWS Lambda의 강력한 서버리스 컴퓨팅 능력과 Firebase의 편리한 백엔드 서비스(BaaS)를 결합하는 것은 현대적인 애플리케이션 개발에서 매우 효율적인 아키텍처입니다. 인증, 데이터베이스, 스토리지 등 Firebase가 제공하는 다양한 기능을 Lambda 함수 내에서 활용하면, 개발자는 인프라 관리 부담 없이 비즈니스 로직에만 집중할 수 있습니다. 하지만 이 강력한 조합을 실제로 구현하는 과정에서 많은 개발자들이 예상치 못한 장벽에 부딪히게 됩니다. 바로 "내 컴퓨터에서는 잘 동작했는데, 왜 Lambda에 올리기만 하면 에러가 발생하지?"라는 의문입니다.

특히 Firebase SDK, 그중에서도 Firestore와 같은 서비스를 사용하는 경우, `npm install`로 간단히 의존성을 설치하고 코드를 압축해 업로드하는 방식은 대부분 실패로 돌아갑니다. 에러 로그는 'invalid ELF header'나 'cannot find module'과 같이 직관적이지 않은 메시지를 띄우며 개발자를 혼란에 빠뜨립니다. 이 문제의 근본적인 원인을 이해하고, 어떤 상황에서도 안정적으로 Firebase 의존성을 Lambda에 배포할 수 있는 다양한 해결책과 모범 사례를 심도 있게 탐구해 보겠습니다.

왜 평범한 'npm install'은 Lambda에서 실패하는가?

문제 해결의 첫걸음은 원인을 정확히 아는 것입니다. 로컬 환경(예: Windows, macOS)에서 실행한 `npm install` 결과물이 AWS Lambda 환경에서 동작하지 않는 이유는 두 환경의 근본적인 차이 때문입니다.

1. 실행 환경(Execution Environment)의 불일치

AWS Lambda 함수는 AWS가 관리하는 특정 실행 환경 내부에서 동작합니다. 이 환경은 일반적인 개인용 컴퓨터와 다릅니다.

  • 운영체제(OS): AWS Lambda는 Amazon Linux라는 특정 버전의 Linux 배포판을 기반으로 실행됩니다. 2023년 기준으로 대부분의 Node.js 런타임은 Amazon Linux 2를 사용합니다. 여러분이 개발에 사용하는 컴퓨터가 Windows나 macOS라면, 운영체제부터 이미 다릅니다.
  • 아키텍처(Architecture): Lambda 함수는 생성 시 실행 아키텍처를 선택할 수 있습니다. 전통적인 x86_64와 AWS가 자체 개발한 arm64 (Graviton2) 프로세서 중 하나를 선택하게 됩니다. 여러분의 로컬 PC가 Intel/AMD CPU를 사용한다면 x86_64일 것이고, Apple Silicon(M1/M2 등) Mac을 사용한다면 arm64입니다. 이 아키텍처가 다르면 프로그램 실행 방식 자체가 호환되지 않습니다.

2. 네이티브 애드온(Native Add-ons)의 존재

문제의 핵심에는 '네이티브 애드온'이 있습니다. Node.js 패키지 중 일부는 순수 JavaScript로만 작성되지 않고, 성능 최적화나 하위 시스템 연동을 위해 C, C++ 등의 저수준 언어로 작성된 코드를 포함합니다. 이 코드들은 `npm install` 과정에서 각 환경에 맞게 컴파일(compile)되어 .node 확장자를 가진 바이너리 파일로 생성됩니다.

Firebase SDK와 gRPC: Firebase, 특히 실시간 동기화가 중요한 Firestore 클라이언트는 내부적으로 gRPC (Google Remote Procedure Call)라는 기술을 사용합니다. Node.js 환경에서 gRPC를 사용하기 위한 패키지인 grpc 또는 최신의 @grpc/grpc-js는 C++로 작성된 네이티브 코드를 포함하고 있습니다.

따라서 여러분이 Windows PC에서 `npm install firebase-admin`을 실행하면, npm은 Windows 환경 및 x86_64 아키텍처에 맞는 `grpc_node.node` 바이너리 파일을 생성합니다. 이 파일이 포함된 `node_modules` 폴더를 압축해서 Amazon Linux 환경의 Lambda에 업로드하면 어떻게 될까요? Lambda의 Node.js 런타임은 Windows용으로 컴파일된 .node 파일을 읽으려다 "이건 내가 이해할 수 없는 형식의 파일이야!"라며 에러를 발생시킵니다. 이것이 바로 'invalid ELF header' 에러의 정체입니다. ELF(Executable and Linkable Format)는 Linux 시스템에서 사용하는 실행 파일 형식이므로, Windows의 실행 파일(PE 형식)과는 호환되지 않습니다.

3. 오래된 해결책의 함정: 버전 고정

과거에는 특정 Node.js 런타임 버전에 맞는 특정 Firebase 버전을 설치하면 문제가 해결되는 경우가 있었습니다. 예를 들어 "Node.js 8.10 에서는 `firebase@4.12.1` 버전을 사용하세요"와 같은 해결책이 그것입니다. 이는 해당 Firebase 버전에 포함된 pre-compiled gRPC 바이너리가 우연히 해당 Lambda 런타임 환경과 맞았기 때문입니다. 하지만 이 방법은 매우 불안정하며, 다음과 같은 치명적인 단점이 있습니다.

  • 일시적인 해결책: Lambda 런타임이 업데이트되거나 Firebase SDK 버전이 올라가면 언제든 다시 문제가 발생할 수 있습니다.
  • 보안 취약점: 오래된 버전의 패키지를 사용하는 것은 알려진 보안 취약점에 그대로 노출되는 위험한 행위입니다.
  • 최신 기능 사용 불가: 새로운 기능이나 성능 개선이 이루어진 최신 SDK를 사용할 수 없습니다.

따라서, 버전 고정은 임시방편일 뿐 근본적인 해결책이 아닙니다. 우리는 어떤 버전의 Node.js와 Firebase를 사용하든 안정적으로 동작하는 견고한 배포 파이프라인을 구축해야 합니다.

근본적인 해결책 1: Lambda 실행 환경 복제 (Docker)

가장 확실하고 전문가들이 선호하는 방법은 빌드(npm install) 과정 자체를 실제 Lambda 실행 환경과 동일한 환경에서 수행하는 것입니다. 이를 위해 Docker를 사용하는 것이 가장 이상적입니다. AWS는 Lambda 실행 환경과 거의 동일한 Docker 이미지를 공식적으로 제공하므로, 이를 활용해 신뢰도 높은 빌드 프로세스를 구축할 수 있습니다.

단계별 가이드 (Docker 사용)

먼저 로컬 컴퓨터에 Docker가 설치되어 있어야 합니다.

  1. 프로젝트 구조 확인:

    프로젝트가 아래와 같은 구조를 가지고 있다고 가정합니다.

    
    my-lambda-project/
    ├── index.js         // Lambda 핸들러 함수
    ├── package.json
    └── package-lock.json
    
  2. Dockerfile 작성:

    프로젝트 루트에 `Dockerfile`이라는 파일을 생성하고, 사용하려는 Lambda 런타임에 맞춰 아래 내용을 작성합니다. 예를 들어 Node.js 18.x 런타임을 사용한다면 다음과 같이 작성합니다.

    
    # 1. 베이스 이미지 선택
    # AWS에서 제공하는 공식 Lambda Node.js 18.x 이미지를 사용합니다.
    FROM public.ecr.aws/lambda/nodejs:18
    
    # 2. 작업 디렉토리 설정
    WORKDIR ${LAMBDA_TASK_ROOT}
    
    # 3. 의존성 정의 파일 복사
    # 먼저 package.json과 package-lock.json을 복사하여 Docker의 레이어 캐시를 활용합니다.
    # 이렇게 하면 소스 코드가 변경되어도 의존성은 다시 설치하지 않아 빌드 속도가 향상됩니다.
    COPY package*.json ./
    
    # 4. 의존성 설치
    # --production 플래그는 devDependencies를 제외한 실제 운영에 필요한 패키지만 설치합니다.
    # npm ci는 package-lock.json을 기반으로 정확한 버전의 패키지를 설치하여 일관성을 보장합니다.
    RUN npm ci --production
    
    # 5. 소스 코드 복사
    COPY . .
    
    # 6. 핸들러 지정 (실행 목적이 아닌 빌드 목적이므로 이 부분은 생략 가능)
    # CMD [ "index.handler" ]
    

    참고: 만약 arm64(Graviton2) 아키텍처용으로 빌드해야 한다면, Dockerfile은 동일하게 유지하되 빌드 명령어에서 플랫폼을 지정해주면 됩니다.

  3. Docker 이미지 빌드:

    터미널에서 프로젝트 루트 디렉토리로 이동한 후 아래 명령어를 실행하여 Docker 이미지를 빌드합니다.

    
    # x86_64 아키텍처용 빌드
    docker build -t my-lambda-builder .
    
    # arm64 아키텍처용 빌드 (Apple Silicon Mac 등에서 필요)
    # docker buildx를 사용해야 할 수도 있습니다.
    docker build --platform linux/arm64 -t my-lambda-builder .
            
  4. 빌드 결과물(배포 패키지) 추출:

    이제 빌드된 이미지 내부에 생성된 `node_modules`와 소스 코드를 로컬 파일 시스템으로 복사하여 배포용 zip 파일을 만들 차례입니다. 방금 빌드한 이미지를 임시 컨테이너로 실행하고 `docker cp` 명령어를 사용합니다.

    
    # 1. 빌드 결과물을 담을 로컬 디렉토리 생성
    mkdir -p build
    
    # 2. 임시 컨테이너 생성 및 실행
    # --rm 옵션은 컨테이너가 중지되면 자동으로 삭제해줍니다.
    docker run --name temp-builder -d my-lambda-builder
    
    # 3. 컨테이너 내부의 빌드 결과물을 로컬 'build' 폴더로 복사
    # ${LAMBDA_TASK_ROOT}는 Dockerfile에서 /var/task로 설정되어 있습니다.
    docker cp temp-builder:/var/task ./build
    
    # 4. 임시 컨테이너 중지 및 삭제
    docker stop temp-builder
    docker rm temp-builder # --rm 옵션을 썼다면 이 과정은 생략 가능
    
    # 5. 배포용 zip 파일 생성
    cd build/task
    zip -r ../../deployment.zip .
    cd ../../
            

이제 프로젝트 루트에 생성된 `deployment.zip` 파일을 AWS Lambda에 업로드하면, 네이티브 모듈이 완벽하게 호환되어 아무런 문제 없이 실행됩니다. 이 방법은 다소 복잡해 보일 수 있지만, 가장 안정적이고 재현 가능한 배포 패키지를 만드는 모범 사례입니다.

근본적인 해결책 2: CI/CD 파이프라인을 통한 자동화 (GitHub Actions)

Docker를 사용한 빌드 방법을 매번 수동으로 실행하는 것은 번거롭습니다. 이 과정을 자동화하기 위해 GitHub Actions, AWS CodeBuild, Jenkins와 같은 CI/CD (지속적 통합/지속적 배포) 도구를 사용하는 것이 좋습니다. 여기서는 GitHub Actions를 예로 들어 설명하겠습니다.

GitHub 저장소에 코드를 push하면, 자동으로 Lambda에 맞는 빌드를 수행하고 배포까지 완료하는 워크플로우를 구성할 수 있습니다.

GitHub Actions 워크플로우 예제

프로젝트 루트에 `.github/workflows/deploy.yml` 파일을 생성하고 아래 내용을 작성합니다.


name: Deploy to AWS Lambda

# main 브랜치에 push가 발생했을 때 이 워크플로우를 실행합니다.
on:
  push:
    branches:
      - main

jobs:
  build-and-deploy:
    # 워크플로우가 실행될 가상 환경을 지정합니다. ubuntu-latest는 Linux 환경입니다.
    runs-on: ubuntu-latest
    
    # 사용할 Node.js 버전을 지정합니다. Lambda 런타임 버전과 맞추는 것이 좋습니다.
    strategy:
      matrix:
        node-version: [18.x]

    steps:
      # 1. 소스 코드 체크아웃
      - name: Checkout repository
        uses: actions/checkout@v3

      # 2. Node.js 환경 설정
      - name: Set up Node.js ${{ matrix.node-version }}
        uses: actions/setup-node@v3
        with:
          node-version: ${{ matrix.node-version }}
          cache: 'npm' # npm 캐시를 사용하여 빌드 속도 향상

      # 3. 의존성 설치
      # GitHub Actions의 러너는 Linux 기반이므로, 여기서 설치된 네이티브 모듈은 Lambda와 호환됩니다.
      - name: Install dependencies
        run: npm ci --production

      # 4. 배포용 zip 파일 생성
      - name: Create deployment package
        run: zip -r deployment.zip . -x ".git/*" ".github/*" "README.md"

      # 5. AWS 자격 증명 설정
      # GitHub Secrets에 AWS_ACCESS_KEY_ID와 AWS_SECRET_ACCESS_KEY를 미리 등록해야 합니다.
      - name: Configure AWS credentials
        uses: aws-actions/configure-aws-credentials@v2
        with:
          aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
          aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
          aws-region: ap-northeast-2 # 본인의 Lambda 함수 리전으로 변경

      # 6. AWS Lambda에 배포
      - name: Deploy to Lambda
        run: |
          aws lambda update-function-code \
            --function-name my-firebase-lambda-function \ # 본인의 Lambda 함수 이름으로 변경
            --zip-file fileb://deployment.zip

이 워크플로우를 설정해두면, `main` 브랜치에 코드를 push하기만 하면 빌드부터 배포까지의 모든 과정이 자동으로 처리됩니다. 이는 개발 생산성을 극대화하고, 휴먼 에러를 방지하는 가장 현대적이고 효율적인 방법입니다.

실용적인 대안 1: AWS Lambda Layers 활용하기

Lambda Layers는 여러 Lambda 함수에서 공통으로 사용하는 코드나 의존성을 분리하여 패키징할 수 있는 기능입니다. `firebase-admin`과 같이 용량이 크고 자주 변경되지 않는 라이브러리를 Layer로 만들어두면 다음과 같은 장점이 있습니다.

  • 함수 코드 용량 감소: 배포 패키지에는 비즈니스 로직 코드만 포함되므로 업로드 속도가 빨라지고 관리 용이성이 증가합니다.
  • 의존성 재사용: 여러 함수가 동일한 Layer를 공유하여 중복을 제거할 수 있습니다.
  • 캐싱 효과: Layer는 Lambda 실행 환경에 캐시될 수 있어 콜드 스타트 시간을 단축하는 데 도움이 될 수 있습니다.

Lambda Layer 생성 단계

Layer를 만드는 과정 역시, 반드시 Lambda와 호환되는 환경에서 빌드되어야 합니다. 앞서 설명한 Docker나 CI/CD 파이프라인, 또는 AWS Cloud9과 같은 Linux 기반 환경에서 다음 단계를 진행해야 합니다.

  1. 필요한 디렉토리 구조 생성:

    Lambda Layer는 특정 디렉토리 구조를 따라야 Node.js 런타임이 인식할 수 있습니다. `nodejs/node_modules` 구조를 만들어야 합니다.

    
    # Layer를 위한 폴더 생성
    mkdir firebase-layer && cd firebase-layer
    mkdir -p nodejs
            
  2. 의존성 설치:

    생성한 `nodejs` 디렉토리 안에서 `npm`을 사용하여 Firebase SDK를 설치합니다.

    
    # nodejs 폴더로 이동하여 package.json 생성 및 의존성 설치
    cd nodejs
    npm init -y
    npm install firebase-admin
    # 필요 시 다른 공통 패키지도 함께 설치
    cd ..
            
  3. Layer용 zip 파일 생성:

    `nodejs` 폴더 전체를 압축합니다.

    
    zip -r firebase_admin_layer.zip nodejs
            
  4. AWS Management Console에서 Layer 생성 및 연결:
    1. AWS Lambda 콘솔로 이동하여 좌측 메뉴에서 '계층(Layers)'을 선택합니다.
    2. '계층 생성(Create layer)' 버튼을 클릭합니다.
    3. 이름(예: `FirebaseAdminLayer`)을 입력하고, 방금 생성한 `firebase_admin_layer.zip` 파일을 업로드합니다.
    4. 호환되는 아키텍처(x86_64 또는 arm64)와 런타임(예: Node.js 18.x)을 선택하고 계층을 생성합니다.
    5. 이제 Firebase를 사용할 Lambda 함수 설정으로 이동합니다.
    6. '코드' 탭 아래의 '계층(Layers)' 섹션에서 '계층 추가(Add a layer)'를 클릭합니다.
    7. '사용자 지정 계층(Custom layers)'을 선택하고, 방금 생성한 Layer와 버전을 선택한 후 추가합니다.

이제 함수 코드에서는 `require('firebase-admin')` 또는 `import 'firebase-admin'` 구문을 통해 마치 로컬에 설치된 것처럼 Firebase SDK를 바로 사용할 수 있습니다. 함수 코드 배포 시에는 더 이상 `node_modules`를 포함할 필요가 없습니다.

실용적인 대안 2: 클라우드 개발 환경 및 가상 머신 활용

Docker나 CI/CD 설정이 부담스럽다면, 처음부터 Lambda와 유사한 클라우드 기반의 Linux 환경에서 개발 및 빌드를 진행하는 방법도 있습니다.

1. AWS Cloud9 사용

AWS Cloud9은 브라우저 기반의 클라우드 IDE입니다. Cloud9 환경은 기본적으로 Amazon Linux를 실행하는 EC2 인스턴스 위에 구축되므로, Lambda 실행 환경과 매우 유사합니다. Cloud9에서 직접 코드를 작성하고 `npm install`을 실행한 후, 터미널에서 바로 배포 패키지를 만들고 AWS CLI를 통해 Lambda에 배포할 수 있습니다. 이는 로컬 환경과의 불일치 문제를 원천적으로 차단하는 간단하고 효과적인 방법입니다.

2. Amazon EC2 인스턴스 직접 사용

가장 기본적인 방법으로, 프리 티어로 사용 가능한 `t2.micro` 또는 `t3.micro` 타입의 Amazon Linux 2 EC2 인스턴스를 하나 생성합니다. 그리고 다음 단계를 따릅니다.

  1. SSH를 통해 인스턴스에 접속합니다.
  2. NVM(Node Version Manager)을 사용하여 Lambda 런타임과 동일한 버전의 Node.js를 설치합니다.
  3. Git을 사용하여 소스 코드 저장소를 클론(clone)합니다.
  4. 프로젝트 디렉토리로 이동하여 `npm install --production`을 실행합니다.
  5. `zip` 명령어를 사용하여 배포 패키지를 생성합니다.
  6. 생성된 zip 파일을 AWS S3로 업로드하거나, 로컬 컴퓨터로 다운로드(scp 사용)한 후 Lambda에 업로드합니다.

이 방법은 수동 작업이 많지만, Docker나 다른 도구에 익숙하지 않은 사용자에게 직관적인 해결책을 제공합니다.

3. Windows 사용자를 위한 WSL (Windows Subsystem for Linux)

Windows 10/11 사용자는 WSL을 설치하여 Windows 내에서 직접 Linux 배포판(예: Ubuntu)을 실행할 수 있습니다. WSL 환경에서 Node.js를 설치하고 `npm install`을 실행하면, Linux 환경용으로 네이티브 모듈이 컴파일됩니다. 이는 완벽하게 동일한 환경은 아니지만(커널 버전이나 glibc 버전 등이 다를 수 있음), 대부분의 경우 Amazon Linux 2와 호환되는 결과물을 생성해 문제를 해결할 수 있습니다. 로컬 Windows 환경에서 직접 빌드하는 것보다는 훨씬 안정적인 방법입니다.

최신 Firebase SDK (v9+) 사용 시 주의사항

Firebase SDK는 버전 9부터 모듈러(Modular) 방식으로 크게 변경되었습니다. 서버 측에서 사용하는 `firebase-admin` 패키지도 10.0.0 버전부터 모듈화된 구문을 지원하기 시작했습니다.


// 기존 방식 (v10 이전)
// const admin = require('firebase-admin');
// admin.initializeApp();
// const db = admin.firestore();

// 모듈러 방식 (v10 이후 권장)
import { initializeApp, cert } from 'firebase-admin/app';
import { getFirestore } from 'firebase-admin/firestore';

initializeApp({
  credential: cert(serviceAccount)
});

const db = getFirestore();

이러한 SDK 사용 방식의 변화와 별개로, Firestore를 사용하는 한 내부적으로 gRPC에 의존한다는 사실은 변하지 않습니다. 따라서 최신 `firebase-admin` SDK를 사용하더라도, 위에서 설명한 네이티브 애드온 문제와 그에 따른 빌드 환경의 중요성은 여전히 유효합니다. 반드시 Lambda 런타임과 호환되는 환경에서 `npm install`을 실행해야 한다는 핵심 원칙을 잊지 말아야 합니다.

문제 해결을 위한 최종 체크리스트

AWS Lambda와 Firebase 연동 시 문제가 발생했다면, 다음 사항들을 순서대로 점검해보세요.

  • CloudWatch 로그 확인: 가장 먼저 Lambda 함수의 CloudWatch 로그 그룹을 확인하세요. `invalid ELF header`, `module not found`, `Illegal instruction` 등의 에러 메시지는 네이티브 모듈 호환성 문제일 가능성이 매우 높습니다.
  • 빌드 환경 확인: `npm install`을 실행한 환경이 어디였나요? 로컬 Windows나 macOS였다면, 이것이 문제의 원인일 확률이 99%입니다. Docker, CI/CD, Cloud9, EC2 등 Linux 기반 환경에서 빌드했는지 다시 확인하세요.
  • 아키텍처 일치 여부 확인: Lambda 함수의 아키텍처 설정(x86_64 또는 arm64)과 빌드 환경의 아키텍처가 일치하는지 확인하세요. 특히 Apple Silicon Mac 사용 시 arm64용으로 빌드해야 합니다.
  • Node.js 런타임 버전 일치: Lambda 함수의 런타임(예: Node.js 18.x)과 빌드 환경에서 사용한 Node.js 버전이 동일하거나 호환되는지 확인하세요. NVM을 사용하여 버전을 정확히 맞추는 것이 좋습니다.
  • `package-lock.json` 활용: `npm install` 대신 `npm ci`를 사용하여 `package-lock.json`에 명시된 정확한 버전의 의존성을 설치하세요. 이는 빌드의 일관성과 재현성을 보장합니다.
  • 배포 패키지 구조 확인: Layer를 사용하지 않는 경우, zip 파일의 최상위에 `index.js`와 `node_modules` 폴더가 위치해야 합니다. 상위 폴더로 한번 더 감싸여 있지 않은지 확인하세요. Layer의 경우, `nodejs/node_modules` 구조를 따랐는지 확인하세요.

정리: 성공적인 Lambda-Firebase 배포를 위한 핵심

AWS Lambda에서 Firebase와 같은 네이티브 의존성을 포함한 패키지를 안정적으로 사용하기 위한 핵심은 단 하나입니다: "Lambda 함수가 실행될 환경과 동일하거나 호환되는 환경에서 빌드(npm install)하라." 로컬 컴퓨터 환경에 의존한 빌드는 실패의 지름길입니다.

개인 프로젝트나 간단한 테스트라면 Cloud9, EC2, WSL을 활용하는 것이 빠른 해결책이 될 수 있습니다. 하지만 협업이 필요하거나 상용 서비스를 운영하는 실무 환경이라면, Docker를 이용한 빌드 프로세스를 구축하고, 이를 GitHub Actions나 AWS CodePipeline과 같은 CI/CD 파이프라인으로 자동화하는 것이 장기적으로 가장 안정적이고 효율적인 투자입니다. 이러한 견고한 배포 파이프라인을 통해 여러분은 더 이상 예측 불가능한 배포 오류에 시간을 낭비하지 않고, 비즈니스 가치를 창출하는 코드에 온전히 집중할 수 있게 될 것입니다.


0 개의 댓글:

Post a Comment