13년차의 서버실

서버, 인프라, 홈랩 기술 블로그

[카테고리:] cloud

  • [Cloud] Terraform vs Pulumi: IaC 도구 비교 및 선택 가이드

    IaC 도구 선택, 생각보다 중요한 문제입니다

    인프라를 코드로 관리하기 시작한 게 벌써 7~8년 전 일인데요, 그때만 해도 Terraform(테라폼)이 사실상 IaC(Infrastructure as Code, 인프라를 코드로 관리하는 방식)의 표준처럼 여겨졌습니다. 그런데 최근 몇 년 사이에 Pulumi(풀루미)가 치고 올라오면서 팀 내에서도 “우리 이제 Pulumi로 갈아타야 하는 거 아니야?“라는 얘기가 슬슬 나오기 시작하더라고요.

    저도 처음엔 “또 새로운 도구 나왔네” 하고 넘겼는데, 실제로 홈랩에서 Terraform과 Pulumi를 나란히 써보면서 생각이 좀 바뀌었습니다. 단순히 어느 게 더 낫다는 게 아니라, 팀 상황과 사용 목적에 따라 선택이 완전히 달라지는 도구라는 걸 직접 느꼈거든요.

    이 글에서는 Terraform vs Pulumi를 실제 사용 경험을 바탕으로 비교해드리려고 합니다. IaC 도구 비교 글들이 많긴 한데, 대부분 공식 문서 요약 수준이라 아쉬웠거든요. 실무에서 어떤 차이가 나는지 한번 풀어볼게요.

    ▲ Terraform과 Pulumi — 둘 다 훌륭한 IaC 도구지만, 철학이 다릅니다

    Terraform과 Pulumi, 각각 어떤 도구인가요?

    Terraform — HCL로 선언하는 인프라

    Terraform은 HashiCorp(해시코프)가 만든 오픈소스 IaC 도구입니다. HCL(HashiCorp Configuration Language, 해시코프 설정 언어)이라는 자체 DSL(Domain-Specific Language, 도메인 특화 언어)을 사용하는데요. 쉽게 말해 “이런 인프라가 존재해야 해”라고 선언하면 Terraform이 현재 상태와 비교해서 필요한 작업을 자동으로 처리하는 방식입니다.

    2014년에 처음 나왔으니까 이미 10년이 넘었네요. 덕분에 Provider(프로바이더, 클라우드/서비스 연동 플러그인) 생태계가 정말 풍부합니다. AWS, GCP, Azure는 물론이고 GitHub, Datadog, PagerDuty까지 대부분의 주요 서비스를 지원해요.

    다만 2023년에 HashiCorp가 라이선스를 BSL(Business Source License)로 변경하면서 커뮤니티가 반발했습니다. 이 때문에 OpenTofu(오픈토푸)라는 포크 프로젝트도 생겼어요. 라이선스 정책은 도구 선택 시 꼭 고려해야 할 사항입니다.

    Pulumi — 진짜 프로그래밍 언어로 인프라를

    Pulumi는 2018년에 등장한 IaC 도구인데, Terraform과는 완전히 다른 접근을 취합니다. Python, TypeScript, Go, C#, Java 같은 범용 프로그래밍 언어로 인프라를 정의한다는 게 핵심이에요.

    처음 들었을 땐 “그게 뭐가 좋아?” 싶었는데, 직접 써보니 차이가 꽤 크더라고요. 반복문, 조건문, 함수, 클래스 같은 프로그래밍 언어의 모든 기능을 인프라 코드에 그대로 쓸 수 있거든요. 기존 패키지 생태계(npm, pip 등)도 활용 가능합니다.

    State(상태, 인프라의 현재 상태를 저장하는 파일) 관리는 Pulumi Cloud를 이용하거나, AWS S3나 Azure Blob 같은 스토리지에 직접 저장할 수 있어요.

    핵심 철학 차이: 선언형 vs 명령형

    Terraform과 Pulumi의 가장 근본적인 차이는 선언형(Declarative) vs 명령형(Imperative) 접근 방식입니다. 이 차이가 실제 개발 경험에 큰 영향을 미칩니다.

    항목 Terraform Pulumi
    언어 HCL (자체 DSL) Python, TypeScript, Go, C#, Java 등
    패러다임 선언형 (Declarative) 선언형 + 명령형 혼합
    State 관리 로컬 파일 또는 원격 Backend Pulumi Cloud 또는 자체 Backend
    라이선스 BSL 1.1 (2023년 변경) Apache 2.0
    Provider 생태계 매우 풍부 (수천 개) 풍부 (Terraform Provider 재활용 가능)
    학습 곡선 HCL 문법 별도 학습 필요 기존 언어 지식 활용 가능
    테스트 별도 도구 필요 (Terratest 등) 기존 테스트 프레임워크 활용
    IDE 지원 플러그인 필요 언어별 IDE 지원 그대로

    실제 코드로 보는 Terraform vs Pulumi 차이

    말로만 설명하면 와닿지 않으니까, 같은 작업을 두 도구로 작성한 코드를 비교해볼게요. AWS S3 버킷 3개를 만드는 간단한 예시입니다.

    Terraform 방식 — count와 for_each

    Terraform에서 여러 리소스를 만들려면 count나 for_each를 써야 합니다. 처음엔 문법이 좀 낯설 수 있어요.

    # variables.tf
    variable "bucket_names" {
      type    = list(string)
      default = ["my-app-logs", "my-app-backups", "my-app-temp"]
    }
    
    # main.tf
    resource "aws_s3_bucket" "app_buckets" {
      for_each = toset(var.bucket_names)
      bucket   = each.value
    }
    
    resource "aws_s3_bucket_versioning" "app_buckets" {
      for_each = aws_s3_bucket.app_buckets
      bucket   = each.value.id
    
      versioning_configuration {
        status = "Enabled"
      }
    }

    Pulumi 방식 — 프로그래밍 언어처럼

    Pulumi에서는 Python으로 같은 작업을 이렇게 씁니다. 훨씬 직관적이죠?

    import pulumi
    import pulumi_aws as aws
    
    bucket_names = ["my-app-logs", "my-app-backups", "my-app-temp"]
    
    for bucket_name in bucket_names:
        bucket = aws.s3.Bucket(
            bucket_name,
            bucket=bucket_name
        )
        
        versioning = aws.s3.BucketVersioning(
            f"{bucket_name}-versioning",
            bucket=bucket.id,
            versioning_configuration={
                "status": "Enabled"
            }
        )

    Pulumi 코드가 더 간결하고 읽기 쉽지 않나요? 이게 바로 범용 프로그래밍 언어를 쓰는 장점입니다. 변수, 함수, 클래스 등 익숙한 개념들을 그대로 활용할 수 있거든요.

  • [Cloud] Docker Compose 실전 가이드: 로컬 개발 환경 구축 및 관리

    “팀원 컴퓨터에서는 되는데 제 컴퓨터에서는 왜 안 되죠?”

    인프라 일을 13년 하면서 개발팀에서 가장 많이 들은 말 중 하나입니다. 그리고 솔직히 말씀드리면, 저도 초창기엔 이 문제로 꽤 많이 고생했거든요. 환경변수 하나 차이로 밤새 디버깅하고, OS 버전 달라서 라이브러리 충돌 나고… 생각만 해도 아찔하네요.

    그래서 오늘은 Docker Compose를 활용해서 로컬 개발 환경을 제대로 구축하는 방법을 공유하려고 합니다. “내 컴퓨터에서는 되는데”라는 말을 팀에서 영원히 추방할 수 있는 방법이에요. 실제로 제가 홈랩이랑 팀 프로젝트에서 직접 써보면서 정리한 내용이라 꽤 실용적일 거라 생각합니다.

    ▲ Docker Compose로 구성한 로컬 개발 환경의 전체 구조 — 웹 서버, 앱 서버, 데이터베이스가 하나의 네트워크로 묶이는 모습

    Docker Compose가 뭔지 먼저 짚고 넘어가요

    Docker 자체는 이미 알고 계신 분들이 많을 텐데, Docker Compose(도커 컴포즈)는 조금 다른 개념이에요. 쉽게 말해서, 여러 개의 컨테이너(Container)를 하나의 파일로 정의하고 한 번에 관리하는 도구거든요.

    예를 들어 웹 애플리케이션 하나를 띄우려면 보통 이런 것들이 필요하잖아요:

    • 프론트엔드 서버 (React, Vue 등)
    • 백엔드 API 서버 (Node.js, FastAPI 등)
    • 데이터베이스 (PostgreSQL, MySQL 등)
    • 캐시 서버 (Redis 등)

    이걸 Docker 명령어로 하나하나 실행하면… 솔직히 너무 번거롭더라고요. 컨테이너마다 네트워크 연결하고, 볼륨 설정하고, 환경변수 넣고… 저도 처음엔 그렇게 했었는데 한 달도 안 돼서 포기했어요 ㅎㅎ.

    그런데 Docker Compose를 쓰면 docker-compose.yml이라는 파일 하나에 이 모든 걸 정의하고, 명령어 한 줄로 전체를 올리고 내릴 수 있어요. 개발 생산성 측면에서 정말 게임 체인저였습니다.

    Docker Compose vs. 일반 Docker 명령어 비교

    항목 Docker 단독 사용 Docker Compose 사용
    서비스 시작 컨테이너마다 개별 명령어 실행 docker compose up 하나로 끝
    네트워크 설정 수동으로 네트워크 생성 및 연결 자동으로 네트워크 생성 및 연결
    환경 관리 각 명령어에 -e 옵션 반복 입력 yml 파일에 한 번에 정의
    팀 공유 실행 명령어 문서화 필요 yml 파일을 Git에 올리면 끝
    재현성 낮음 (사람마다 다르게 실행 가능) 높음 (동일한 환경 보장)

    시작 전 준비사항

    본격적으로 들어가기 전에 환경 세팅부터 확인해 봅시다. 요즘은 Docker Desktop을 설치하면 Docker Compose도 함께 딸려오거든요. 예전엔 따로 설치해야 했는데, 이 부분은 많이 편해졌어요.

    1. Docker Desktop 공식 사이트에서 OS에 맞는 버전 다운로드 및 설치
    2. 설치 완료 후 터미널에서 버전 확인
    # Docker 버전 확인
    docker --version
    
    # Docker Compose 버전 확인 (v2 기준)
    docker compose version

    💡 팁: 예전에는 docker-compose(하이픈 포함)로 실행했는데, 요즘 Compose V2부터는 docker compose(스페이스)로 바뀌었어요. 둘 다 동작하긴 하는데, 새 프로젝트라면 V2 방식으로 쓰는 게 좋습니다.

    실전: docker-compose.yml 파일 작성하기

    자, 이제 진짜 시작입니다. 실제 웹 애플리케이션 개발 환경을 예시로 만들어볼게요. Node.js 백엔드 + PostgreSQL 데이터베이스 + Redis 캐시 조합으로 가겠습니다. 제가 홈랩에서 사이드 프로젝트 할 때 자주 쓰는 스택이거든요.

    프로젝트 디렉토리 구조

    my-project/
    ├── docker-compose.yml       # 핵심 설정 파일
    ├── docker-compose.override.yml  # 로컬 개발용 오버라이드
    ├── .env                     # 환경변수 파일
    ├── backend/
    │   ├── Dockerfile
    │   └── src/
    └── frontend/
        ├── Dockerfile
        └── src/

    기본 docker-compose.yml 작성

    version: '3.8'
    
    services:
      # 백엔드 API 서버
      backend:
        build:
          context: ./backend
          dockerfile: Dockerfile
        container_name: myapp-backend
        ports:
          - "3000:3000"
        environment:
          - NODE_ENV=development
          - DATABASE_URL=postgresql://myuser:mypassword@db:5432/mydb
          - REDIS_URL=redis://cache:6379
        volumes:
          - ./backend:/app          # 소스 코드 마운트 (핫 리로드 가능)
          - /app/node_modules       # node_modules는 컨테이너 것 사용
        depends_on:
          db:
            condition: service_healthy  # DB 헬스체크 통과 후 시작
          cache:
            condition: service_started
        networks:
          - app-network
        restart: unless-stopped
    
      # PostgreSQL 데이터베이스
      db:
        image: postgres:15-alpine
        container_name: myapp-db
        environment:
          - POSTGRES_USER=myuser
          - POSTGRES_PASSWORD=mypassword
          - POSTGRES_DB=mydb
        volumes:
          - postgres-data:/var/lib/postgresql/data  # 데이터 영속성 보장
          - ./db/init.sql:/docker-entrypoint-initdb.d/init.sql  # 초기화 스크립트
        ports:
          - "5432:5432"   # 로컬에서 DB 클라이언트로 직접 접속 가능
        healthcheck:
          test: ["CMD-SHELL", "pg_isready -U myuser -d mydb"]
          interval: 10s
          timeout: 5s
          retries: 5
        networks:
          - app-network
    
      # Redis 캐시 서버
      cache:
        image: redis:7-alpine
        container_name: myapp-redis
        ports:
          - "6379:6379"
        volumes:
          - redis-data:/data
        networks:
          - app-network
    
    # 볼륨(Volume) 정의: 컨테이너가 삭제돼도 데이터 유지
    volumes:
      postgres-data:
      redis-data:
    
    # 네트워크 정의: 서비스 간 내부 통신
    networks:
      app-network:
        driver: bridge

    여기서 중요한 포인트! depends_on을 쓸 때 단순히 서비스 이름만 쓰면 컨테이너가 시작된 것만 확인하고 실제로 준비가 됐는지는 모릅니다. condition: service_healthy와 healthcheck를 함께 써야 PostgreSQL이 실제로 쿼리를 받을 준비가 됐을 때 백엔드가 시작돼요. 이거 몰라서 처음에 백엔드가 DB 연결 못 한다고 에러 뜨는 삽질을 꽤 했었더라고요 ㅎㅎ.

    환경변수 파일(.env) 관리

    민감한 정보는 절대 docker-compose.yml에 직접 넣으면 안 됩니다. .env 파일을 따로 만들고 Git에는 올리지 마세요.

    # .env 파일
    POSTGRES_USER=myuser
    POSTGRES_PASSWORD=super_secret_password
    POSTGRES_DB=mydb
    NODE_ENV=development
    APP_PORT=3000
    # docker-compose.yml에서 .env 변수 참조
    services:
      db:
        environment:
          - POSTGRES_USER=${POSTGRES_USER}
          - POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
          - POSTGRES_DB=${POSTGRES_DB}
    # .gitignore에 반드시 추가
    .env
    .env.local
    .env.production

    ▲ docker-compose.yml로 정의된 서비스들이 내부 네트워크(app-network)로 연결되고, 필요한 포트만 호스트에 노출되는 구조

    개발 환경 전용 오버라이드 파일

    이건 좀 꿀팁인데요. docker-compose.override.yml을 만들면 docker compose up할 때 자동으로 합쳐져서 적용됩니다. 로컬 개발에서만 필요한 설정을 분리할 수 있어서 정말 편하더라고요.

    # docker-compose.override.yml (로컬 개발 전용)
    version: '3.8'
    
    services:
      backend:
        # 개발 중엔 빌드 없이 소스 코드 직접 마운트
        command: npm run dev   # nodemon 등 핫 리로드 명령어
        environment:
          - DEBUG=true
          - LOG_LEVEL=debug
    
      # 개발 환경에서만 DB 관리 UI 추가
      adminer:
        image: adminer
        container_name: myapp-adminer
        ports:
          - "8080:8080"
        networks:
          - app-network

    자주 쓰는 Docker Compose 명령어 모음

    이제 실제로 실행해 봅시다. 제가 매일 쓰는 명령어들 위주로 정리했어요.

    # 전체 서비스 시작 (백그라운드 실행)
    docker compose up -d
    
    # 빌드 포함해서 시작 (코드 변경 후)
    docker compose up -d --build
    
    # 특정 서비스만 시작
    docker compose up -d backend
    
    # 전체 서비스 중지
    docker compose down
    
    # 중지 + 볼륨까지 삭제 (DB 초기화할 때)
    docker compose down -v
    
    # 실행 중인 서비스 상태 확인
    docker compose ps
    
    # 특정 서비스 로그 보기 (실시간)
    docker compose logs -f backend
    
    # 실행 중인 컨테이너에 접속
    docker compose exec backend sh
    
    # 데이터베이스 컨테이너에서 psql 실행
    docker compose exec db psql -U myuser -d mydb
    
    # 서비스 재시작
    docker compose restart backend

    ⚠️ 실제로 겪은 트러블슈팅 사례들

    이론은 깔끔한데, 실제로 쓰다 보면 별별 문제가 다 생기더라고요. 제가 겪었던 것들 공유합니다.

    문제 1: 포트가 이미 사용 중이라는 에러

    # 에러 메시지
    Error response from daemon: Ports are not available: 
    exposing port TCP 0.0.0.0:5432 -> 0.0.0.0:0: listen tcp 0.0.0.0:5432: 
    bind: address already in use

    로컬에 PostgreSQL이 이미 설치돼서 실행 중인 경우 자주 발생해요. 해결법은 두 가지예요:

    # 방법 1: 로컬 PostgreSQL 서비스 중지
    sudo systemctl stop postgresql   # Linux
    brew services stop postgresql    # macOS
    
    # 방법 2: docker-compose.yml에서 포트 변경
    ports:
      - "5433:5432"   # 호스트 포트를 5433으로 변경

    문제 2: 볼륨 마운트 후 node_modules 사라짐

    이거 처음 겪으면 진짜 당황스러워요. 소스 코드 볼륨 마운트하면 로컬의 node_modules(없는 경우)가 컨테이너 안의 것을 덮어씌워버리는 현상이거든요.

    # 해결법: node_modules를 별도 볼륨으로 분리
    services:
      backend:
        volumes:
          - ./backend:/app
          - /app/node_modules   # 이 한 줄이 핵심!
                                # 익명 볼륨으로 컨테이너 내부 것을 보호

    문제 3: 컨테이너 간 통신이 안 될 때

    백엔드에서 DB 접속할 때 localhost로 접속하려다가 실패하는 경우가 많아요. 컨테이너 안에서 localhost는 그 컨테이너 자신을 가리키거든요.

    # ❌ 잘못된 방법 (백엔드 컨테이너 안에서)
    DATABASE_URL=postgresql://myuser:mypassword@localhost:5432/mydb
    
    # ✅ 올바른 방법 (서비스 이름을 호스트로 사용)
    DATABASE_URL=postgresql://myuser:mypassword@db:5432/mydb
    # 'db'는 docker-compose.yml에서 정의한 서비스 이름

    같은 네트워크에 묶인 컨테이너끼리는 서비스 이름이 곧 호스트명이 됩니다. Docker의 내장 DNS가 자동으로 처리해줘요. 이거 알고 나면 진짜 편해집니다.

    문제 4: M1/M2 Mac에서 이미지 아키텍처 오류

    # 에러 메시지
    WARNING: The requested image's platform (linux/amd64) does not match 
    the detected host platform (linux/arm64/v8)
    
    # 해결법: platform 명시
    services:
      db:
        image: postgres:15-alpine
        platform: linux/amd64   # 또는 linux/arm64/v8

    요즘 Apple Silicon Mac 쓰시는 분들 많은데, arm64를 지원하는 이미지를 쓰거나 platform을 명시해주면 해결돼요. 대부분의 공식 이미지들은 멀티 아키텍처를 지원하니까 크게 걱정 안 하셔도 됩니다.

    ✅ 결과 확인: 제대로 떴는지 검증하기

    드디어 됐다! 이제 제대로 동작하는지 확인해봅시다.

    # 전체 서비스 상태 확인
    docker compose ps
    
    # 예상 출력
    NAME              IMAGE                COMMAND                  SERVICE    CREATED        STATUS                    PORTS
    myapp-backend     myapp-backend        "docker-entrypoint.s…"   backend    2 minutes ago  Up 2 minutes              0.0.0.0:3000->3000/tcp
    myapp-db          postgres:15-alpine   "docker-entrypoint.s…"   db         2 minutes ago  Up 2 minutes (healthy)    0.0.0.0:5432->5432/tcp
    myapp-redis       redis:7-alpine       "docker-entrypoint.s…"   cache      2 minutes ago  Up 2 minutes              0.0.0.0:6379->6379/tcp

    STATUS 컬럼에서 Up이면 실행 중, (healthy)면 헬스체크까지 통과한 것입니다. 🎉

    # 네트워크 확인
    docker network ls
    docker network inspect myproject_app-network
    
    # 컨테이너 간 통신 테스트
    docker compose exec backend ping db
    docker compose exec backend ping cache
    
    # 백엔드 API 응답 확인
    curl http://localhost:3000/health

    ▲ docker compose ps 명령어로 확인한 서비스 상태 — 모든 서비스가 Up 상태이고 DB는 healthy 헬스체크까지 통과한 모습

    🎉 팀 협업에서 빛나는 Docker Compose의 진짜 가치

    제가 Docker Compose를 팀 프로젝트에 도입하고 나서 가장 크게 달라진 점은 온보딩 시간이었어요. 예전엔 새 팀원이 오면 환경 세팅하는 데 하루 이상 걸리는 경우도 있었거든요. 근데 이제는 이렇게 끝납니다:

    # 새 팀원 온보딩 전체 과정
    git clone https://github.com/myteam/myproject.git
    cd myproject
    cp .env.example .env   # 환경변수 파일 복사 후 값 채우기
    docker compose up -d   # 끝!

    이 세 줄이면 끝이에요. 진짜로요. OS가 다르든, 로컬에 뭐가 설치돼 있든 상관없이 동일한 환경이 뜹니다. 개발 생산성 측면에서 이게 얼마나 큰 차이인지는 직접 경험해보시면 바로 느끼실 거예요.

    Docker Compose 활용의 주요 이점

    • ✅ 환경 일관성: “내 컴퓨터에서는 되는데” 문제 완전 해결
    • ✅ 빠른 온보딩: 새 팀원도 몇 분 안에 개발 시작 가능
    • ✅ 격리된 환경: 프로젝트별로 독립된 환경, 충돌 없음
    • ✅ 버전 관리: 인프라 설정도 코드로 관리 (Infrastructure as Code)
    • ✅ 프로덕션 근접: 로컬과 운영 환경의 차이 최소화

    ▲ Docker Compose 도입 전후 로컬 개발 환경 비교 — 온보딩 시간, 환경 일관성, 협업 효율성 측면에서의 개선 효과

    자주 묻는 질문 (FAQ)

    Q. Docker Desktop 없이 Docker Compose만 쓸 수 있나요?

    네, Linux 환경에서는 Docker Engine을 설치하고 Compose 플러그인을 별도로 설치하면 돼요. macOS나 Windows라면 Docker Desktop이 가장 간편한 방법이더라고요.

    Q. docker-compose.yml 파일을 Git에 올려도 되나요?

    네, 올려야 합니다! 이게 바로 팀 환경 공유의 핵심이거든요. 단, .env 파일은 절대 올리면 안 됩니다. .env.example 파일을 만들어서 어떤 변수가 필요한지만 공유하세요.

    Q. 프로덕션 배포에도 Docker Compose를 쓰나요?

    소규모 서비스라면 쓸 수 있어요. 하지만 스케일링이나 고가용성이 필요하다면 Kubernetes(쿠버네티스) 같은 오케스트레이션 도구를 고려해야 합니다. 이 부분은 다음 글에서 다룰 예정이에요.

    Q. 컨테이너를 내렸다 올리면 데이터가 사라지나요?

    docker compose down만 하면 볼륨은 유지돼요. 데이터까지 지우려면 docker compose down -v를 써야 하니까 주의하세요!

    마무리: 이제 로컬 개발 환경 걱정은 끝

    오늘 다룬 내용을 정리해볼게요:

    1. Docker Compose의 기본 개념과 일반 Docker 대비 장점
    2. 실전 docker-compose.yml 작성법 (서비스, 볼륨, 네트워크)
    3. 환경변수 분리와 오버라이드 파일 활용
    4. 자주 쓰는 명령어와 실제 트러블슈팅 사례

    처음 Docker Compose를 접하면 yml 파일 문법이 좀 낯설게 느껴질 수 있어요. 저도 들여쓰기 하나 틀려서 에러 뜨는 걸 수십 번은 겪었으니까요. 근데 한 번 손에 익으면 이것 없이 개발하는 게 상상이 안 될 정도로 편해집니다.

    다음 글에서는 Docker Compose로 구성한 환경을 기반으로 CI/CD 파이프라인과 연동하는 방법을 다뤄볼 예정이에요. 로컬에서 테스트한 환경을 그대로 자동화 배포에 활용하는 내용인데, 꽤 실용적인 내용이 될 것 같습니다.

    궁금한 점이나 삽질 경험 있으시면 댓글로 편하게 남겨주세요. 저도 비슷한 거 겪어봤을 확률이 높거든요 😄

  • [Cloud] Pulumi vs Terraform: 멀티 클라우드 IaC 선택 가이드 및 실전 활용

    [Cloud] Pulumi vs Terraform: 멀티 클라우드 IaC 선택 가이드 및 실전 활용

    IaC(Infrastructure as Code)는 왜 필요할까요?

    안녕하세요, 13년차 서버실 지킴이입니다. 오늘도 여전히 복잡한 인프라 구성에 머리 싸매고 계신가요? 요즘 같은 멀티 클라우드(Multi-Cloud) 시대에는 온프레미스(On-Premise)와 여러 클라우드 벤더(Cloud Vendor)를 넘나들며 인프라를 관리해야 하는 경우가 흔하죠. 저도 처음엔 수동으로 서버 올리고 네트워크 설정하고… 휴, 생각만 해도 아찔하네요. 사람이 일일이 하다 보니 휴먼 에러(Human Error)는 기본이고, 속도는 느려터지고, 무엇보다 재현성이 없어서 문제였습니다.

    그래서 등장한 개념이 바로 IaC(Infrastructure as Code, 코드형 인프라)예요. 쉽게 말해, 인프라를 코드로 정의하고 관리하는 방식이죠. Git 같은 버전 관리 시스템으로 관리할 수 있고, 자동화는 물론 팀원들과의 협업도 훨씬 수월해집니다. 제가 홈랩에서 이것저것 테스트해볼 때도, IaC 덕분에 환경을 순식간에 구축하고 파괴하면서 실험할 수 있었거든요. 시간 절약이 어마어마해요!

    멀티 클라우드 환경에서 IaC를 사용하는 인프라 프로비저닝 개념 다이어그램

    Terraform vs Pulumi: 핵심 개념 파헤치기

    IaC 도구는 정말 다양합니다만, 현재 시장에서 가장 강력한 양대 산맥을 꼽으라면 단연 Terraform(테라폼)과 Pulumi(풀루미)일 겁니다. 두 도구 모두 클라우드 인프라를 코드로 정의하고 배포할 수 있게 해주지만, 접근 방식에 큰 차이가 있어요. 저도 처음엔 뭐가 뭔지 헷갈려서 한참을 삽질했었죠.

    Terraform (테라폼)

    • 선언적 언어(Declarative Language): Terraform은 HCL(HashiCorp Configuration Language)이라는 자체 언어를 사용해요. "최종 상태가 이렇게 되어야 한다"라고 선언하면, Terraform이 현재 상태와 비교해서 필요한 변경 사항을 알아서 적용해줍니다. 예를 들어, aws_instance 리소스 블록을 정의하면, Terraform이 AWS에 해당 인스턴스를 생성하거나 업데이트하죠.
    • 상태 파일(State File) 관리: Terraform은 배포된 인프라의 현재 상태를 .tfstate 파일에 기록해요. 이 상태 파일을 통해 다음에 어떤 변경 사항을 적용할지 판단하는데, 이 파일 관리가 꽤 중요하고 때론 골치 아울 때도 있어요. S3 같은 원격 스토리지(Remote Storage)에 저장하고 잠금(Locking) 기능을 사용하는 게 일반적이에요.
    • 모듈(Modules): 재사용 가능한 인프라 구성 단위를 모듈로 만들 수 있어서, 복잡한 인프라도 효율적으로 관리할 수 있어요.

    Pulumi (풀루미)

    • 범용 프로그래밍 언어(General-Purpose Programming Language): Pulumi의 가장 큰 특징은 Python, TypeScript, Go, C# 등 여러분이 익숙한 프로그래밍 언어를 그대로 사용한다는 점이에요. 이게 진짜 매력적입니다! 일반 애플리케이션 개발하듯이 인프라 코드를 작성할 수 있어요. 조건문, 반복문, 함수 등 프로그래밍 언어의 모든 기능을 활용할 수 있다는 것이 큰 장점이죠.
    • 상태 관리(State Management): Pulumi도 인프라 상태를 관리하지만, 기본적으로 Pulumi 서비스에서 관리해주거나 S3, Azure Blob Storage 등 다양한 백엔드(Backend)를 지원해요. Terraform처럼 로컬 .tfstate 파일에 얽매이지 않아서 좀 더 유연하더라고요.
    • 테스트 용이성(Testability): 프로그래밍 언어의 장점을 살려 단위 테스트(Unit Test), 통합 테스트(Integration Test) 등을 적용하기가 훨씬 쉬워요. 저도 TypeScript로 Pulumi 코드를 작성하면서 Jest 같은 프레임워크로 테스트를 붙여보니, 안정성이 확 올라가더라고요.

    멀티 클라우드 환경에서 Pulumi와 Terraform 비교 분석

    자, 그럼 두 도구를 멀티 클라우드 관점에서 비교해볼까요? 제가 여러 프로젝트에 적용해보면서 느낀 점들을 솔직하게 말씀드릴게요.

    특징 Terraform Pulumi
    언어 HCL (HashiCorp Configuration Language) Python, TypeScript, Go, C#, Java 등 범용 프로그래밍 언어
    학습 곡선 HCL 학습 필요 (비교적 쉬움) 선택 언어에 익숙하다면 낮음
    추상화 수준 선언적, 모듈 기반 프로그래밍 언어의 강력한 추상화 및 로직 구현 가능
    상태 관리 .tfstate 파일 (로컬/원격), 잠금 기능 필수 Pulumi 서비스 또는 다양한 백엔드 지원, 더 유연함
    멀티 클라우드 지원 각 클라우드 프로바이더(Provider) 플러그인 각 클라우드 SDK 기반 라이브러리 (더 깊은 통합 가능)
    테스트 terraform plan, 외부 도구 활용 프로그래밍 언어의 테스트 프레임워크 활용 용이
    확장성 프로바이더 개발, 프로비저너(Provisioner) 프로그래밍 언어의 모든 기능 활용, SDK 개발
    커뮤니티/생태계 오래되고 매우 활발함, 방대한 자료 빠르게 성장 중, 비교적 최신

    Terraform은 꽤 오랫동안 IaC 시장을 지배해왔기 때문에 커뮤니티 자료나 레퍼런스가 정말 많아요. 저도 처음엔 Terraform으로 시작했고요. 반면 Pulumi는 비교적 최근에 떠오른 도구지만, 프로그래밍 언어의 유연성 때문에 개발자 친화적이라는 평이 많습니다. 특히 복잡한 로직이 필요한 인프라 구성이나 기존 개발 워크플로우(Workflow)와 통합하고 싶을 때 Pulumi가 빛을 발하더라고요.

    실전 활용: 간단한 웹 서버 배포 (Terraform vs Pulumi)

    백문이 불여일견이죠! AWS에 간단한 EC2 인스턴스(Instance)를 배포하는 예제를 통해 두 도구의 차이를 직접 느껴보시죠. 제 홈랩에서도 늘 이렇게 시작하곤 합니다.

    Terraform 예제

    main.tf 파일에 다음과 같이 작성해요.

    # main.tf
    
    provider "aws" {
      region = "ap-northeast-2" # 서울 리전
    }
    
    resource "aws_instance" "web_server" {
      ami           = "ami-0a0c4fdd9d45e4895" # Ubuntu 20.04 LTS (서울 리전 기준)
      instance_type = "t2.micro"
      tags = {
        Name = "MyWebServer-Terraform"
      }
    }
    

    명령어는 간단해요.

    terraform init
    terraform plan
    terraform apply --auto-approve
    

    terraform init으로 프로바이더를 초기화하고, terraform plan으로 어떤 변경 사항이 생길지 미리 확인합니다. 마지막으로 terraform apply로 인프라를 배포하죠. 정말 직관적이에요!

    Pulumi 예제 (TypeScript)

    index.ts 파일에 다음과 같이 작성해요.

    // index.ts
    
    import * as aws from "@pulumi/aws";
    
    const webServer = new aws.ec2.Instance("web-server-pulumi", {
        ami: "ami-0a0c4fdd9d45e4895", // Ubuntu 20.04 LTS (서울 리전 기준)
        instanceType: "t2.micro",
        tags: {
            Name: "MyWebServer-Pulumi",
        },
    });
    
    export const instanceId = webServer.id;
    export const publicIp = webServer.publicIp;
    

    명령어는 다음과 같아요.

    pulumi stack init dev # 스택 초기화 (환경 분리)
    pulumi up --yes # 인프라 배포
    

    Pulumi는 스택(Stack) 개념이 있어서 개발, 스테이징, 프로덕션 환경을 깔끔하게 분리할 수 있어요. pulumi up 명령어로 배포하고, --yes 옵션으로 확인 과정을 생략할 수 있습니다. 프로그래밍 언어의 문법을 그대로 따르기 때문에, 개발자라면 훨씬 친숙하게 느껴지실 거예요.

    Terraform과 Pulumi로 생성된 AWS EC2 인스턴스 목록

    ⚠️ 제가 겪었던 삽질과 트러블슈팅 경험

    13년차 엔지니어도 삽질은 피할 수 없죠! 저도 이 두 도구를 쓰면서 여러 번 멘붕에 빠졌어요. 몇 가지 기억에 남는 삽질 경험과 해결법을 공유할게요.

    Terraform: 상태 파일 관리의 늪

    제일 많이 겪었던 건 상태 파일(State File) 충돌이에요. 팀원 여럿이 동시에 같은 인프라를 건드리다 보면 .tfstate 파일이 꼬이는 경우가 생기더라고요. 특히 협업 툴(Collaboration Tool)이나 CI/CD 파이프라인(Pipeline) 없이 수동으로 작업할 때 심했습니다. 상태 파일이 꼬이면 terraform plan 결과가 이상하게 나오거나, 심지어 실제 인프라와 상태 파일 간의 불일치(Drift)가 발생해서 엉뚱한 리소스가 삭제될 뻔한 적도 있었죠. 😱

    • 해결법: 항상 원격 백엔드(Remote Backend) (예: AWS S3 + DynamoDB Lock)를 사용하고, 상태 잠금(State Locking)을 활성화해야 해요. 그리고 CI/CD 파이프라인을 구축해서 모든 변경 사항은 파이프라인을 통해서만 적용되도록 강제하는 것이 가장 안전합니다.

    Pulumi: 언어 버전 의존성

    Pulumi는 범용 프로그래밍 언어를 사용하다 보니, 언어 버전이나 패키지 의존성(Package Dependency) 문제로 삽질한 적도 있어요. 예를 들어, 특정 Pulumi AWS 라이브러리가 특정 Node.js 버전에서만 제대로 동작한다든지, npm install 과정에서 에러가 나는 경우요. 이게 진짜 별것 아닌 것 같아도, 디버깅(Debugging)하다 보면 시간을 꽤 잡아먹어요. 😅

    • 해결법: package.json(TypeScript/Node.js)이나 requirements.txt(Python)에 정확한 버전 정보를 명시하고, nvm(Node Version Manager)이나 pyenv(Python Version Manager) 같은 도구로 개발 환경을 일관되게 유지하는 것이 중요해요. 그리고 Pulumi 관련 라이브러리들도 최신 버전으로 꾸준히 업데이트해주는 게 좋아요.

    공통: 리프레시 명령어의 함정

    가끔 실제 인프라가 변경되었는데 상태 파일에 반영이 안 되는 불일치가 생기면 terraform refresh나 pulumi refresh 명령어를 사용하게 되죠. 그런데 이 명령어를 너무 맹신하면 안 돼요. 특히 중요한 리소스에 대해서는 리프레시 전에 항상 백업(Backup)을 해두거나, plan 명령어로 예상 변경 사항을 꼼꼼히 확인하는 습관을 들이세요. 예전에 리프레시 한 번 잘못했다가 스테이징 환경이 날아갈 뻔한 아찔한 경험도 있습니다. ⚠️

    그래서, 어떤 IaC 도구를 선택해야 할까요?

    결론부터 말씀드리자면, "정답은 없다"예요! (싱거운가요? ㅎㅎ) 하지만 여러분의 상황에 맞는 최적의 선택은 분명히 있습니다.

    Terraform을 선택해야 하는 경우

    • 인프라 전문 팀/운영 팀이 주도적으로 IaC를 도입할 때: HCL은 인프라 관리에 특화되어 있어 직관적이에요.
    • 방대한 기존 레퍼런스와 안정적인 커뮤니티 지원이 중요할 때: 오래된 만큼 자료가 정말 많습니다.
    • 비교적 단순하고 선언적인 인프라 구성이 많을 때: 복잡한 로직 없이 리소스 정의만으로 충분한 경우요.
    • GitOps(깃옵스) 워크플로우를 강력하게 따르고자 할 때: Terraform Cloud/Enterprise 같은 솔루션과 통합이 잘 되어 있어요.

    Pulumi를 선택해야 하는 경우

    • 개발 팀이 직접 인프라를 관리하거나, 개발 워크플로우에 IaC를 통합하고 싶을 때: 익숙한 프로그래밍 언어로 개발 생산성을 높일 수 있어요.
    • 복잡한 로직이나 조건부 인프라 구성이 필요할 때: 프로그래밍 언어의 모든 기능을 활용하여 동적인 인프라를 만들 수 있습니다.
    • 인프라 코드에 단위 테스트/통합 테스트를 적극적으로 적용하여 안정성을 높이고 싶을 때요.
    • 서버리스(Serverless) 아키텍처나 컨테이너 기반 환경(Containerized Environment)에 더 깊이 통합하고 싶을 때: Lambda 함수나 Kubernetes 리소스를 직접 코드로 제어하기가 편해요.

    Terraform과 Pulumi 선택 가이드 요약 인포그래픽

    마무리하며: 13년차 인프라 엔지니어의 조언

    오늘은 Pulumi(풀루미)와 Terraform(테라폼), 두 가지 강력한 IaC 도구에 대해 깊이 있게 다뤄봤어요. 멀티 클라우드 환경에서 인프라를 효율적으로 관리하고 자동화하는 것은 이제 선택이 아닌 필수가 되었죠. 저도 처음엔 "이걸 언제 다 배우지?" 했었는데, 막상 익숙해지니 정말 편하더라고요. 삽질 끝에 드디어 원하는 대로 인프라가 배포될 때의 그 희열은 경험해본 사람만 알 겁니다! 🎉

    두 도구 모두 강력한 장점을 가지고 있으니, 여러분의 팀 구성원들이 어떤 언어에 더 익숙한지, 어떤 유형의 인프라를 주로 다루는지를 고려해서 현명하게 선택하시길 바랍니다. 추가로 어떤 수준의 추상화와 유연성이 필요한지도 함께 생각하면서요. 가능하다면 작은 프로젝트에 두 가지를 모두 적용해보면서 장단점을 직접 느껴보는 것도 좋은 방법입니다.

    인프라 코드를 작성하는 것은 마치 소프트웨어 개발과 같아요. 지속적으로 리팩토링(Refactoring)하고, 버전 관리를 철저히 하며, 테스트를 통해 안정성을 확보하는 것이 중요해요. 이 글이 여러분의 IaC 여정에 조금이나마 도움이 되었기를 바랍니다. 다음번에는 더 유익한 주제로 찾아올게요. 감사합니다!

    홈랩에서 IaC 코드를 작성하는 13년차 인프라 엔지니어

  • [Cloud] Ansible로 멀티 클라우드 인프라 자동화: AWS, Azure, GCP 통합 관리 가이드

    [Cloud] Ansible로 멀티 클라우드 인프라 자동화: AWS, Azure, GCP 통합 관리 가이드

    Ansible로 멀티 클라우드 인프라 자동화: AWS, Azure, GCP 통합 관리 가이드

    안녕하세요, 13년차 서버실 지킴이입니다. 요즘 인프라 환경을 보면 단순히 한 클라우드에만 갇혀 있지 않죠? 멀티 클라우드(Multi-Cloud)는 이제 선택이 아니라 필수가 되어가는 시대인 것 같습니다. 저도 처음엔 AWS만 주력으로 썼었는데, 프로젝트가 커지고 요구사항이 다양해지면서 Azure나 GCP도 함께 다루게 되더라고요. 근데 이게 여러 클라우드를 동시에 관리하려니 여간 복잡한 게 아니었습니다. 각 클라우드마다 CLI도 다르고, API도 다르고… 수동으로 관리하다가는 퇴근은커녕 야근만 늘겠다 싶었죠. 😭

    이런 고민을 하던 중에 저의 든든한 동반자, Ansible(앤서블)을 떠올렸습니다. Ansible은 이미 온프레미스 환경에서 서버 자동화에 요긴하게 써왔던 도구거든요. ‘이걸로 멀티 클라우드도 통합 관리할 수 있지 않을까?’ 하는 생각에 홈랩에서 이것저것 실험해봤습니다. 결과는 대만족이었습니다! 오늘은 제가 직접 경험하며 삽질했던 내용과 함께, Ansible로 AWS, Azure, GCP 인프라를 한 번에 자동화하는 방법을 여러분께 알려드리려고 합니다. 이 글을 통해 멀티 클라우드 관리의 복잡성을 확 줄여보시길 바랍니다. 자, 그럼 시작해볼까요? 🎉

    Ansible을 활용한 멀티 클라우드 통합 관리 아키텍처 다이어그램.

    Ansible, 왜 멀티 클라우드 자동화에 최적일까요?

    먼저 Ansible이 어떤 녀석인지, 그리고 왜 멀티 클라우드 환경에 딱 맞는지 간단히 짚고 넘어가죠. 쉽게 말해 Ansible(앤서블)은 에이전트리스(Agentless) 기반의 자동화 도구(Automation Tool)입니다. 관리 대상 서버에 별도의 에이전트를 설치할 필요 없이 SSH(Secure Shell)나 WinRM(Windows Remote Management) 프로토콜을 이용해 명령을 실행하죠. 이게 멀티 클라우드 환경에서 엄청난 강점입니다.

    • 단일 제어 플레인(Single Control Plane): 각 클라우드 콘솔이나 CLI를 오갈 필요 없이, Ansible 컨트롤러 노드(Control Node) 하나에서 모든 클라우드 인프라를 관리할 수 있습니다.
    • 모듈(Modules) 기반의 추상화: Ansible은 각 클라우드 프로바이더(Cloud Provider)별로 수많은 모듈을 제공합니다. 예를 들어, AWS EC2 인스턴스를 만들 때는 amazon.aws.ec2_instance 모듈을, Azure VM을 만들 때는 azure.azcollection.azure_rm_virtualmachine 모듈을 사용하죠. 각 클라우드 API의 복잡성을 몰라도 모듈만 잘 사용하면 됩니다.
    • 멱등성(Idempotency): 플레이북(Playbook)을 여러 번 실행해도 항상 동일한 최종 상태(Desired State)를 보장합니다. 이미 생성된 리소스는 건드리지 않고, 필요한 변경사항만 적용되니 안심하고 재실행할 수 있습니다.
    • YAML(YAML Ain’t Markup Language) 기반의 쉬운 학습 곡선: 복잡한 프로그래밍 언어 대신 사람이 읽고 쓰기 쉬운 YAML 문법으로 플레이북을 작성합니다. 인프라 엔지니어에게는 정말 친숙한 방식이죠.

    결론적으로 Ansible은 코드로서의 인프라(Infrastructure as Code, IaC)를 구현하기에 아주 적합하며, 특히 이종 클라우드 환경을 통합 관리하는 데 강력한 성능을 발휘합니다.

    실전 구현: AWS, Azure, GCP 인프라 자동화

    자, 이제 직접 Ansible로 멀티 클라우드 인프라를 구축해볼 차례입니다. 목표는 각 클라우드에 웹 서버 역할을 할 가상 머신(Virtual Machine)을 하나씩 생성하고, 간단한 웹 서비스(Nginx)를 설치하는 것입니다.

    1. Ansible 및 클라우드 연동 환경 설정

    먼저 Ansible 컨트롤러 노드에 필요한 도구들을 설치해야 합니다. 저는 Ubuntu 환경에서 진행했습니다.

    #!/bin/bash
    # Ansible 설치
    sudo apt update && sudo apt install -y ansible python3-pip
    
    # AWS 연동을 위한 boto3 라이브러리 설치
    pip3 install boto3 botocore
    
    # Azure 연동을 위한 Azure CLI 및 모듈 설치
    sudo apt install -y azure-cli
    ansible-galaxy collection install azure.azcollection
    
    # GCP 연동을 위한 Google Cloud SDK 및 모듈 설치
    echo "deb [signed-by=/usr/share/keyrings/cloud.google.gpg] https://packages.cloud.google.com/apt cloud-sdk main" | sudo tee -a /etc/apt/sources.list.d/google-cloud-sdk.list
    curl https://packages.cloud.google.com/apt/doc/apt-key.gpg | sudo apt-key --keyring /usr/share/keyrings/cloud.google.gpg add -
    sudo apt update && sudo apt install -y google-cloud-sdk
    ansible-galaxy collection install google.cloud
    

    각 클라우드 인증 정보 설정도 중요합니다:

    • AWS: AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY 환경 변수 설정 또는 ~/.aws/credentials 파일 설정
    • Azure: az login 명령으로 로그인 또는 서비스 주체(Service Principal) 생성 후 환경 변수 설정
    • GCP: 서비스 계정(Service Account) 키 파일 다운로드 후 GOOGLE_APPLICATION_CREDENTIALS 환경 변수 설정

    ⚠️ 인증 정보 관리 중요: 실제 운영 환경에서는 AWS Secrets Manager, Azure Key Vault, GCP Secret Manager 같은 비밀 관리 서비스(Secret Management Service)를 활용하거나 Ansible Vault를 사용하여 인증 정보를 안전하게 보관해야 합니다. 절대 민감한 정보를 플레이북에 직접 넣지 마세요!

    2. 동적 인벤토리(Dynamic Inventory) 설정

    클라우드 환경은 리소스가 수시로 생성/삭제되기 때문에 고정된 인벤토리 파일보다는 동적 인벤토리를 사용하는 것이 효율적입니다. Ansible은 각 클라우드 프로바이더를 위한 동적 인벤토리 플러그인을 제공하거든요.

    # aws_ec2.yml (AWS EC2 인스턴스 동적 인벤토리)
    plugin: amazon.aws.ec2
    regions:
      - ap-northeast-2
    keyed_groups:
      - key: tags.env
        prefix: env_
    
    # azure_rm.yml (Azure VM 동적 인벤토리)
    plugin: azure.azcollection.azure_rm
    include_vm_resource_groups:
      - my-ansible-rg
    
    # gcp_compute.yml (GCP Compute Engine 동적 인벤토리)
    plugin: google.cloud.gcp_compute
    projects:
      - your-gcp-project-id
    zones:
      - asia-northeast3-a
    

    이제 ansible-inventory -i aws_ec2.yml --list 등으로 각 클라우드의 인벤토리를 확인할 수 있습니다.

    Ansible으로 생성된 각 클라우드 가상 머신 목록.

    3. 클라우드 리소스 생성 플레이북 작성

    각 클라우드에 가상 머신을 생성하고 Nginx를 설치하는 플레이북입니다. 하나의 플레이북에서 여러 클라우드를 대상으로 할 수 있다는 점이 핵심입니다.

    ---
    # multi_cloud_infra.yml
    # AWS, Azure, GCP에 동시에 웹 서버를 배포하는 플레이북
    
    - name: AWS 인프라 준비
      hosts: localhost
      connection: local
      gather_facts: no
    
      vars:
        aws_region: ap-northeast-2
        ssh_key_path: ~/.ssh/id_rsa.pub
    
      tasks:
        # AWS Security Group 먼저 생성
        - name: AWS 보안 그룹 생성
          amazon.aws.ec2_security_group:
            name: ansible-sg-aws
            description: "Ansible managed AWS webserver security group"
            region: "{{ aws_region }}"
            rules:
              - proto: tcp
                ports: 22
                cidr_ip: 0.0.0.0/0
              - proto: tcp
                ports: 80
                cidr_ip: 0.0.0.0/0
            rules_egress:
              - proto: all
                cidr_ip: 0.0.0.0/0
          register: aws_sg_output
    
        # AWS EC2 인스턴스 생성
        - name: AWS EC2 인스턴스 생성
          amazon.aws.ec2_instance:
            name: ansible-aws-webserver
            image_id: ami-0c802847a7dd848c0  # Ubuntu 22.04 LTS (ap-northeast-2)
            instance_type: t2.micro
            region: "{{ aws_region }}"
            security_group: ansible-sg-aws
            key_name: your_aws_keypair
            tags:
              env: dev
              project: ansible-multi-cloud
            state: running
            wait: yes
          register: aws_ec2_output
    
    - name: Azure 인프라 준비
      hosts: localhost
      connection: local
      gather_facts: no
    
      vars:
        azure_resource_group: my-ansible-rg
        azure_location: koreacentral
        ssh_key_path: ~/.ssh/id_rsa.pub
    
      tasks:
        # Azure Virtual Network 생성 (필수 선행 작업)
        - name: Azure Virtual Network 생성
          azure.azcollection.azure_rm_virtualnetwork:
            resource_group: "{{ azure_resource_group }}"
            name: ansible-vnet
            location: "{{ azure_location }}"
            address_prefixes:
              - "10.0.0.0/16"
          register: azure_vnet_output
    
        # Azure Subnet 생성
        - name: Azure Subnet 생성
          azure.azcollection.azure_rm_subnet:
            resource_group: "{{ azure_resource_group }}"
            name: default
            address_prefix: "10.0.0.0/24"
            virtual_network: ansible-vnet
          register: azure_subnet_output
    
        # Azure NSG 생성
        - name: Azure 네트워크 보안 그룹 생성
          azure.azcollection.azure_rm_networksecuritygroup:
            resource_group: "{{ azure_resource_group }}"
            name: ansible-azure-nsg
            location: "{{ azure_location }}"
            rules:
              - name: AllowSSH
                protocol: Tcp
                destination_port_range: 22
                access: Allow
                priority: 100
                direction: Inbound
              - name: AllowHTTP
                protocol: Tcp
                destination_port_range: 80
                access: Allow
                priority: 110
                direction: Inbound
          register: azure_nsg_output
    
        # Azure VM 생성
        - name: Azure VM 생성
          azure.azcollection.azure_rm_virtualmachine:
            resource_group: "{{ azure_resource_group }}"
            name: ansible-azure-webserver
            vm_size: Standard_B1s
            admin_username: azureuser
            ssh_password_enabled: false
            ssh_public_keys:
              - path: /home/azureuser/.ssh/authorized_keys
                key_data: "{{ lookup('file', ssh_key_path) }}"
            image:
              offer: 0001-com-ubuntu-server-jammy
              publisher: Canonical
              sku: 22_04-lts-gen2
              version: latest
            location: "{{ azure_location }}"
          register: azure_vm_output
    
    - name: GCP 인프라 준비
      hosts: localhost
      connection: local
      gather_facts: no
    
      vars:
        gcp_project: your-gcp-project-id
        gcp_zone: asia-northeast3-a
        gcp_network: default
        ssh_key_path: ~/.ssh/id_rsa.pub
        gcp_user: ansible
    
      tasks:
        # GCP Firewall 규칙 생성
        - name: GCP 방화벽 규칙 생성
          google.cloud.gcp_compute_firewall:
            name: ansible-gcp-allow-http-ssh
            project: "{{ gcp_project }}"
            allowed:
              - IPProtocol: tcp
                ports: [ '22', '80' ]
            source_ranges: [ '0.0.0.0/0' ]
            target_tags: [ 'http-server' ]
            state: present
          register: gcp_fw_output
    
        # GCP Compute Engine 인스턴스 생성
        - name: GCP Compute Engine 인스턴스 생성
          google.cloud.gcp_compute_instance:
            name: ansible-gcp-webserver
            project: "{{ gcp_project }}"
            zone: "{{ gcp_zone }}"
            machine_type: e2-micro
            disks:
              - auto_delete: 'true'
                boot: 'true'
                initialize_params:
                  source_image: projects/ubuntu-os-cloud/global/images/family/ubuntu-2204-lts
                  disk_size_gb: 20
            network_interfaces:
              - name: "{{ gcp_network }}"
                access_configs:
                  - name: "External NAT"
                    type: "ONE_TO_ONE_NAT"
            metadata:
              ssh-keys: "{{ gcp_user }}:{{ lookup('file', ssh_key_path) }}"
            tags:
              items:
                - http-server
            state: present
          register: gcp_instance_output
    
    - name: 모든 클라우드 웹서버에 Nginx 설치
      hosts: all
      gather_facts: yes
    
      pre_tasks:
        - name: SSH 연결 대기
          ansible.builtin.wait_for_connection:
            timeout: 300
    
      tasks:
        - name: Nginx 설치
          ansible.builtin.apt:
            name: nginx
            state: present
            update_cache: yes
          become: yes
    
        - name: Nginx 서비스 시작
          ansible.builtin.service:
            name: nginx
            state: started
            enabled: yes
          become: yes
    

    위 플레이북은 각 클라우드별로 분리된 구조로 작성되어 있습니다. 첫 번째부터 세 번째까지는 각각 AWS, Azure, GCP 인프라를 생성하는 hosts: localhost 플레이북입니다. 마지막 플레이북은 동적 인벤토리에서 수집된 모든 호스트에 Nginx를 설치합니다.

    플레이북 실행은 간단합니다:

    ansible-playbook -i aws_ec2.yml -i azure_rm.yml -i gcp_compute.yml multi_cloud_infra.yml
    

    ⚠️ 삽질 경험 및 트러블슈팅

    제가 이 과정을 진행하면서 겪었던 몇 가지 삽질 경험과 해결 방법을 공유합니다. 여러분은 저처럼 헤매지 마시길 바랍니다. ㅎㅎ

    1. 클라우드 인증 실패

    문제: 플레이북 실행 시 Unauthorized, Access Denied, Credential Error 같은 메시지가 뜨면서 클라우드 리소스 생성이 안 되는 경우가 많았습니다.

    해결:

    • AWS: ~/.aws/credentials 파일의 내용이 정확한지, 또는 환경 변수(AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY)가 올바르게 설정되었는지 확인했습니다. 그리고 해당 IAM 사용자에게 EC2 생성, Security Group 관리 등 필요한 권한이 제대로 부여되었는지 IAM 정책을 검토했습니다.
    • Azure: az login으로 로그인 세션이 유효한지 확인하거나, 서비스 주체(Service Principal)를 사용한다면 AZURE_CLIENT_ID, AZURE_SECRET, AZURE_TENANT, AZURE_SUBSCRIPTION_ID 환경 변수가 정확한지 확인했습니다. 해당 서비스 주체에 기여자(Contributor) 또는 그에 준하는 역할이 할당되어야 합니다.
    • GCP: 서비스 계정 키 파일(JSON)의 경로가 GOOGLE_APPLICATION_CREDENTIALS 환경 변수에 올바르게 지정되었는지 확인했습니다. 또한, 서비스 계정에 Compute Engine Instance Admin (v1), Service Account User 등 필요한 역할이 부여되어 있는지 GCP IAM 콘솔에서 확인했습니다.

    2. SSH 접속 문제 (Nginx 설치 단계)

    문제: 인스턴스는 잘 생성됐는데, Nginx 설치 단계에서 SSH Connection refused 또는 Timeout 에러가 발생했습니다.

    해결:

    • 보안 그룹/방화벽 규칙: 각 클라우드에서 생성된 가상 머신의 보안 그룹(AWS), 네트워크 보안 그룹(Azure), 방화벽 규칙(GCP)에 Ansible 컨트롤러 노드의 IP 주소 또는 0.0.0.0/0(테스트용)에서 22번 포트(SSH) 접속을 허용하는 규칙이 있는지 확인했습니다.
    • SSH 키페어: 플레이북에서 지정한 ssh_key_path의 퍼블릭 키가 각 클라우드 인스턴스에 올바르게 등록되었는지 확인했습니다. AWS는 키페어 이름을, Azure/GCP는 직접 퍼블릭 키 내용을 전달하는 방식이므로 차이에 유의했습니다.
    • 사용자 이름: 각 클라우드 인스턴스의 기본 사용자 이름이 다릅니다. AWS Ubuntu는 ubuntu, Azure Ubuntu는 azureuser, GCP Ubuntu는 ubuntu로 설정했습니다. 플레이북에 ansible_user: 사용자명으로 명시하면 더 안전합니다.
    • 인스턴스 부팅 시간: 가끔 인스턴스가 완전히 부팅되기 전에 Ansible이 SSH 접속을 시도하여 실패하는 경우가 있습니다. ansible.builtin.wait_for_connection 모듈을 사용해 SSH 포트가 열릴 때까지 기다리도록 플레이북에 추가했습니다. 이건 정말 중요해요!

    3. Azure 네트워크 구성 누락

    문제: Azure VM을 생성하려니 Virtual Network와 Subnet이 없다고 에러가 나왔습니다.

    해결: Azure는 AWS나 GCP와 달리 기본 네트워크 구조가 없으므로, VM 생성 전에 Virtual Network와 Subnet을 명시적으로 생성해야 합니다. 위의 플레이북에서 이를 추가했으니 참고하세요.

    검증 및 결과 확인 ✅

    플레이북 실행이 성공적으로 완료되었다면, 이제 각 클라우드 콘솔에 접속하여 리소스가 잘 생성되었는지 확인해볼 차례입니다. AWS EC2, Azure VM, GCP Compute Engine 목록에서 ansible-aws-webserver, ansible-azure-webserver, ansible-gcp-webserver라는 이름의 인스턴스가 실행 중인 것을 볼 수 있을 겁니다.

    각 인스턴스의 퍼블릭 IP 주소로 웹 브라우저에서 접속해보세요. Nginx 기본 페이지가 보인다면 성공입니다! 드디어 됐다! 🎉

    이 과정에서 여러분이 직접 각 클라우드의 콘솔을 열어 인스턴스를 생성하고, 보안 그룹을 설정하고, SSH로 접속해서 Nginx를 설치하는 수고를 덜 수 있었다는 것을 체감할 수 있을 겁니다. 자동화의 힘은 정말 대단하죠.

    성공적으로 배포된 Nginx 웹 서버 페이지.

    마무리하며: 클라우드 여정의 다음 단계

    오늘은 Ansible을 활용해서 AWS, Azure, GCP 멀티 클라우드 환경에 인프라를 자동화하고 웹 서버를 배포하는 과정을 함께 해봤습니다. 제가 직접 해보니, 처음엔 각 클라우드 모듈과 인증 방식에 적응하는 데 시간이 좀 걸렸지만, 한 번 체계를 잡아두니 그 이후부터는 정말 편하더라고요. 삽질 끝에 얻은 귀한 경험이었습니다. 💡

    이 글이 여러분의 멀티 클라우드 여정에 작은 등대가 되었기를 바랍니다. 여기서 멈추지 않고, 더 나아가 다음과 같은 내용들을 탐구해보시면 좋을 것 같습니다:

    • Ansible Vault: 민감한 정보를 안전하게 관리하는 방법.
    • CI/CD 파이프라인 통합: GitOps 워크플로우를 통해 변경 사항이 자동으로 배포되도록 구성.
    • Terraform과의 연동: Terraform으로 인프라를 프로비저닝하고, Ansible로 프로비저닝된 인스턴스에 소프트웨어를 구성하는 하이브리드 접근 방식.
    • 삭제 플레이북: 생성된 리소스를 깔끔하게 정리하는 삭제 플레이북 작성 (state: absent 활용).

    저도 홈랩에서 이런저런 실험을 계속하면서 새로운 인사이트를 얻고 있습니다. 다음번에는 더 재미있고 유익한 경험담으로 찾아뵙겠습니다. 혹시 이 글을 읽으시면서 궁금한 점이나 공유하고 싶은 삽질 경험이 있으시다면 언제든지 댓글로 남겨주세요! 감사합니다!

    Ansible을 통한 멀티 클라우드 자동화 요약.

  • [Cloud] GitHub Actions OIDC로 AWS 인증하기: 보안 강화 및 자격 증명 관리

    [Cloud] GitHub Actions OIDC로 AWS 인증하기: 보안 강화 및 자격 증명 관리

    GitHub Actions에서 AWS 인증, 아직도 IAM Access Key 쓰시나요?

    안녕하세요, 13년차 인프라 엔지니어이자 서버실 주인장입니다. 지난 몇 년간 수많은 CI/CD 파이프라인(Continuous Integration/Continuous Delivery Pipeline)을 구축하고 관리해왔는데, GitHub Actions와 AWS를 연동해서 사용하는 경우가 정말 많았거든요. 처음에는 저도 많은 분들처럼 AWS IAM(Identity and Access Management) 사용자의 Access Key와 Secret Key를 GitHub Secrets에 넣어 사용했습니다. 편리하긴 했죠. 근데 이게 뭔가 찜찜하더라고요.

    Access Key는 말 그대로 영구적인 자격 증명(Long-lived Credentials)이라 탈취 위험이 늘 존재하고, 정기적으로 Key를 교체(Rotation)해야 하는 관리 부담도 만만치 않았습니다. 만약 여러 레포지토리에서 같은 키를 쓴다면 더더욱 골치 아파지고요. 혹시 이런 경험 있으신가요? 저도 처음엔 이 방식밖에 없나 싶었는데, 다행히 더 안전하고 효율적인 방법이 등장했습니다. 바로 GitHub Actions OIDC(OpenID Connect)를 활용한 AWS 인증입니다! 오늘은 이 OIDC를 이용해 GitHub Actions에서 AWS에 안전하게 접근하는 방법을 제 경험을 바탕으로 자세히 알려드릴게요. CI/CD 파이프라인의 보안을 한 단계 끌어올리는 중요한 포인트가 될 겁니다.

    GitHub Actions OIDC를 이용한 AWS 인증의 전체적인 아키텍처 다이어그램입니다. GitHub Actions가 OIDC Provider 역할을 하고, AWS IAM이 이를 신뢰하여 임시 자격 증명을 발급하는 과정을 보여줍니다.

    OIDC(OpenID Connect)와 워크로드 아이덴티티(Workload Identity)가 뭔가요?

    본격적인 설정에 들어가기 전에, OIDC와 워크로드 아이덴티티라는 개념을 잠시 짚고 넘어가면 좋습니다. 복잡하게 들릴 수 있지만, 쉽게 말해볼게요.

    • OIDC (OpenID Connect, 오픈아이디 커넥트): OAuth 2.0 위에 구축된 인증(Authentication) 프로토콜입니다. 사용자(혹은 서비스)가 어떤 Identity Provider(신원 제공자)에게 “나 누구야”라고 인증받으면, Identity Provider는 ID Token이라는 걸 발급해줍니다. 이 토큰 안에는 “이 사람이 누구고, 어떤 방식으로 인증했는지” 같은 정보가 담겨있죠. AWS의 경우, GitHub Actions가 Identity Provider 역할을 하고, AWS IAM이 이 ID Token을 신뢰하는 Relying Party(의존 당사자)가 되는 겁니다.
    • 워크로드 아이덴티티 (Workload Identity): CI/CD 파이프라인의 워크로드(Workload)나 컨테이너처럼 사람이 아닌 시스템에 신원(Identity)을 부여하는 개념입니다. 기존의 Access Key 방식처럼 영구적인 자격 증명을 주지 않고, 필요할 때만 임시 자격 증명(Temporary Credentials)을 발급받아 사용하는 방식이에요. 이렇게 하면 자격 증명 탈취 위험을 최소화하고, 키 관리 부담을 없앨 수 있습니다.

    결론적으로, GitHub Actions OIDC를 사용하면 GitHub Actions 워크플로우가 직접 Access Key 없이 AWS에 “나 GitHub Actions의 이 레포지토리, 이 브랜치에서 실행된 애야!”라고 증명하고, AWS는 이 신원을 확인한 후 특정 권한을 가진 임시 자격 증명을 주는 방식이라고 이해하시면 됩니다. 진짜 편하고 안전하더라고요!

    GitHub Actions OIDC로 AWS 인증 설정 단계별 가이드

    자, 이제 실제로 GitHub Actions OIDC를 이용해 AWS에 인증하는 방법을 단계별로 따라해 볼까요? 제가 직접 해보니 몇 가지 포인트만 잘 기억하면 어렵지 않게 설정할 수 있었습니다.

    1단계: AWS IAM Identity Provider 생성하기

    가장 먼저 AWS IAM에서 GitHub Actions를 신뢰할 수 있는 OIDC Identity Provider로 등록해야 합니다. AWS 콘솔에서 진행하는 것이 가장 직관적입니다.

    1. AWS 콘솔에 로그인 후 IAM 서비스로 이동합니다.
    2. 왼쪽 탐색 메뉴에서 Access management (액세스 관리) > Identity Providers (자격 증명 공급자)를 클릭합니다.
    3. Add provider (공급자 추가) 버튼을 클릭합니다.
    4. Provider type (공급자 유형)으로 OpenID Connect를 선택합니다.
    5. Provider URL (공급자 URL)에 <code>https://token.actions.githubusercontent.com 을 입력합니다.
    6. Get thumbprint (지문 가져오기) 버튼을 클릭하여 서버 인증서 지문을 자동으로 가져옵니다.
    7. Audience (대상)에는 sts.amazonaws.com 을 입력하고 Add provider (공급자 추가)를 클릭하여 완료합니다.

    💡 팁: sts.amazonaws.com은 AWS Security Token Service의 기본 대상입니다. 다른 용도로 특정 서비스에만 OIDC를 사용한다면 해당 서비스의 Audience를 사용할 수도 있습니다.

    AWS IAM 콘솔에서 OIDC Identity Provider를 생성하는 화면입니다. Provider URL과 Audience를 정확히 입력하는 것이 중요합니다.

    2단계: AWS IAM Role 생성하기

    이제 이 OIDC Identity Provider를 통해 GitHub Actions가 임시 자격 증명을 요청할 수 있는 IAM Role을 생성해야 합니다. 이 역할에는 GitHub Actions가 AWS에서 수행할 작업에 대한 권한을 부여하게 됩니다.

    1. IAM 콘솔에서 Access management (액세스 관리) > Roles (역할)로 이동합니다.
    2. Create role (역할 생성) 버튼을 클릭합니다.
    3. Select type of trusted entity (신뢰할 수 있는 개체 유형 선택)에서 Custom trust policy (사용자 지정 신뢰 정책)를 선택합니다.
    4. 다음과 같이 신뢰 정책을 작성합니다. 여기서 StringEquals 조건이 핵심입니다!
    {
      "Version": "2012-10-17",
      "Statement": [
        {
          "Effect": "Allow",
          "Principal": {
            "Federated": "arn:aws:iam::YOUR_AWS_ACCOUNT_ID:oidc-provider/token.actions.githubusercontent.com"
          },
          "Action": "sts:AssumeRoleWithWebIdentity",
          "Condition": {
            "StringEquals": {
              "token.actions.githubusercontent.com:aud": "sts.amazonaws.com",
              "token.actions.githubusercontent.com:sub": "repo:YOUR_GITHUB_ORG/YOUR_GITHUB_REPO:ref:refs/heads/main"
            }
          }
        }
      ]
    }

    ⚠️ 주의사항:

    • YOUR_AWS_ACCOUNT_ID: 여러분의 AWS 계정 ID로 교체해야 합니다.
    • YOUR_GITHUB_ORG/YOUR_GITHUB_REPO: GitHub 조직(Organization) 이름과 레포지토리(Repository) 이름으로 교체해야 합니다.
    • ref:refs/heads/main: 이 부분은 특정 브랜치(Branch)에서만 역할 가정을 허용하겠다는 의미입니다. *를 사용하여 모든 브랜치를 허용할 수도 있지만, 보안을 위해 특정 브랜치를 지정하는 것을 권장합니다. 예를 들어, 특정 태그(tag)에서만 허용하려면 ref:refs/tags/v*와 같이 설정할 수 있습니다.
    1. 정책 검토 후 다음 단계로 넘어갑니다.
    2. 이 역할에 필요한 Permissions (권한)을 부여합니다. 예를 들어 S3 버킷에 파일을 업로드해야 한다면 AmazonS3FullAccess 대신 필요한 최소한의 권한(Least Privilege)을 부여하는 것이 좋습니다. 저는 테스트를 위해 AmazonS3ReadOnlyAccess를 잠시 붙여봤습니다.
    3. 역할 이름을 지정하고(예: GitHubActionsOIDC-S3AccessRole) 역할을 생성합니다.

    3단계: GitHub Actions Workflow 설정하기

    마지막으로 GitHub Actions 워크플로우 YAML 파일에 OIDC를 통한 AWS 인증 로직을 추가합니다. configure-aws-credentials 액션을 사용하면 아주 쉽게 설정할 수 있습니다.

    name: Deploy to AWS S3 via OIDC
    
    on:
      push:
        branches:
          - main
    
    permissions:
      id-token: write # OIDC ID Token을 요청하기 위해 필수
      contents: read # 레포지토리 코드 읽기 권한
    
    jobs:
      deploy:
        runs-on: ubuntu-latest
        steps:
          - name: Checkout code
            uses: actions/checkout@v4
    
          - name: Configure AWS Credentials with OIDC
            uses: aws-actions/configure-aws-credentials@v4
            with:
              role-to-assume: arn:aws:iam::YOUR_AWS_ACCOUNT_ID:role/GitHubActionsOIDC-S3AccessRole # 2단계에서 생성한 역할 ARN
              aws-region: ap-northeast-2 # 여러분의 AWS 리전
              # role-session-name: optional-session-name # (선택 사항)
    
          - name: List S3 buckets (for verification)
            run: aws s3 ls

    ⚠️ 여기서 중요한 포인트! permissions: id-token: write 설정은 반드시 필요합니다. 이 권한이 없으면 GitHub Actions가 OIDC ID Token을 생성할 수 없어 AWS 인증이 실패합니다. 처음엔 이걸 몰라서 삽질 좀 했습니다 ㅎㅎ

    삽질 경험: “AccessDenied” 에러의 늪에서 벗어나기 ⚠️

    제가 처음 GitHub Actions OIDC를 설정하면서 가장 많이 겪었던 문제는 다름 아닌 “AccessDenied” 에러였습니다. 워크플로우 로그를 보면 An error occurred (AccessDenied) when calling the AssumeRoleWithWebIdentity operation: Not authorized to perform sts:AssumeRoleWithWebIdentity 이런 메시지가 뜨더라고요. 진짜 답답했죠.

    주요 원인은 대부분 IAM Role의 Trust Policy (신뢰 정책) 조건 문제였습니다. 제가 겪었던 실수와 해결 방법은 다음과 같습니다.

    • Audience 불일치: IAM Identity Provider를 생성할 때 Audience를 sts.amazonaws.com으로 설정했는데, IAM Role Trust Policy의 "token.actions.githubusercontent.com:aud" 조건에도 정확히 "sts.amazonaws.com"이 들어가야 합니다. 한 글자라도 다르면 인증이 실패합니다.
    • sub 조건 오류: 가장 흔한 실수입니다. "token.actions.githubusercontent.com:sub" 조건에 지정된 GitHub 레포지토리, 브랜치 정보가 정확해야 합니다. 예를 들어, repo:YOUR_GITHUB_ORG/YOUR_GITHUB_REPO:ref:refs/heads/main 에서 오타가 있거나, 워크플로우가 실행되는 브랜치와 다르면 에러가 발생합니다. 저는 처음에 main 브랜치로 설정해놓고 develop 브랜치에서 테스트하다가 한참을 헤맸습니다.
    • permissions: id-token: write 누락: GitHub Actions 워크플로우 자체에 OIDC 토큰을 생성할 권한이 없어서 생기는 문제입니다. 이 한 줄 때문에 몇 시간을 날린 적도 있어요. 꼭 확인하세요!
    • AWS 계정 ID 또는 역할 ARN 오타: 기본적인 실수지만, 의외로 자주 발생합니다. ARN을 복사 붙여넣기 할 때 다시 한번 확인하는 습관을 들이세요.

    문제가 발생하면 AWS CloudTrail 로그를 확인하는 것이 가장 좋습니다. AssumeRoleWithWebIdentity 호출이 실패한 이유가 자세히 기록되어 있으니 꼭 살펴보세요. 삽질 끝에 드디어 성공했을 때의 그 쾌감이란! 진짜 이 맛에 엔지니어 하는 것 같아요.

    드디어 성공! OIDC로 안전하게 AWS 리소스 접근하기 🎉

    위에 설명드린 대로 모든 설정을 마치고 GitHub Actions 워크플로우를 실행하면, 아래와 같이 성공적으로 AWS 리소스에 접근하는 모습을 볼 수 있습니다.

    GitHub Actions 워크플로우가 성공적으로 AWS CLI 명령어를 실행하여 S3 버킷 목록을 가져오는 로그입니다. OIDC 기반 인증이 정상적으로 작동했음을 보여줍니다.

    워크플로우 로그를 보면 aws s3 ls 명령어가 문제없이 실행되고, S3 버킷 목록이 출력되는 것을 확인할 수 있습니다. 이제 더 이상 GitHub Secrets에 Access Key를 저장할 필요가 없어졌습니다! 🎉

    이 방식의 가장 큰 장점은 바로 단기 자격 증명(Short-lived Credentials)이라는 점입니다. GitHub Actions 워크플로우가 실행될 때만 임시 자격 증명을 발급받아 사용하고, 워크플로우가 끝나면 만료되죠. 덕분에 키 탈취로 인한 보안 위험이 현저히 줄어들고, 키 교체 주기를 관리할 필요도 없어졌습니다. 관리적인 측면에서도 엄청난 이득이라고 생각해요.

    OIDC, CI/CD 보안의 새로운 표준이 되다

    오늘은 GitHub Actions OIDC를 이용해 AWS에 안전하게 인증하는 방법을 자세히 알아봤습니다. 제가 직접 경험하며 얻은 삽질 경험과 해결 과정도 솔직하게 공유해 드렸는데요. 이 방식은 단순히 편리함을 넘어 CI/CD 파이프라인의 보안을 근본적으로 강화하는 핵심 기술이라고 생각합니다.

    기존의 Access Key 방식과 OIDC를 활용한 AWS 인증 방식의 주요 특징을 비교한 표입니다. 보안성, 관리 용이성 등 여러 측면에서 OIDC의 장점을 한눈에 보여줍니다.

    핵심 장점을 다시 정리해볼까요?

    • 보안 강화: 영구적인 Access Key 없이 임시 자격 증명 사용으로 탈취 위험 최소화.
    • 관리 용이성: Access Key 생성, 배포, 교체 등의 관리 부담 해소.
    • 정교한 권한 제어: 특정 레포지토리, 특정 브랜치, 특정 태그에서만 AWS 리소스 접근 허용 가능.
    • 감사 추적 용이: CloudTrail을 통해 어떤 GitHub Actions 워크플로우가 어떤 역할로 AWS에 접근했는지 명확하게 추적 가능.

    저도 홈랩에서 다양한 CI/CD 환경을 구성하면서 OIDC의 강력함을 몸소 체험하고 있습니다. 앞으로는 OIDC 기반의 워크로드 아이덴티티가 CI/CD 보안의 표준으로 자리매김할 것이라고 확신합니다. 혹시 아직 Access Key를 사용하고 계시다면, 이번 기회에 OIDC로 전환해 보시는 것을 강력히 추천합니다! 처음엔 조금 낯설 수 있지만, 한번 설정해두면 정말 든든하거든요.

    다음 글에서는 AWS S3에 정적 웹사이트를 배포하는 과정을 GitHub Actions OIDC와 함께 자동화하는 방법에 대해 다뤄볼까 합니다. 기대해주세요! 그럼 다음 글에서 만나요! 👋

  • [Cloud] Terraform State 관리 완벽 가이드: S3, DynamoDB 활용 모범 사례

    [Cloud] Terraform State 관리 완벽 가이드: S3, DynamoDB 활용 모범 사례

    안녕하세요, 13년차의 서버실 주인장입니다. 오늘은 인프라 엔지니어라면 한 번쯤은 머리를 싸매고 고민했을 주제, 바로 Terraform State 관리에 대해 이야기해보려고 합니다.

    제가 처음 Terraform을 접했을 때, 로컬에 생성되는 <code>terraform.tfstate 파일이 그렇게 귀찮을 수가 없더라고요. 혼자 작업할 때는 괜찮았는데, 팀원들과 함께 프로젝트를 진행하면서부터는 ‘이거 큰일 나겠다!’ 싶었습니다. 서로 다른 사람이 동시에 terraform apply를 실행하면 어떻게 될까요? 네, 상상만 해도 끔찍하죠. State 파일이 꼬여서 인프라가 엉망진창이 되는 대참사를 막기 위해, 오늘은 AWS S3와 DynamoDB를 활용한 Terraform State 관리 모범 사례를 완벽하게 파헤쳐 보려고 합니다. 💡

    Terraform State 관리의 전체 아키텍처 다이어그램입니다. S3와 DynamoDB가 어떻게 상호작용하는지 한눈에 볼 수 있습니다.

    Terraform State, 왜 중요한가요?

    Terraform State(테라폼 상태 파일)는 Terraform이 관리하는 실제 인프라 자원들의 현황을 기록한 파일입니다. 쉽게 말해, “지금 내가 관리하고 있는 인프라가 어떤 모습으로 생겼는지”를 알려주는 지도 같은 거죠. 이 파일이 있어야 Terraform은 다음에 apply를 실행했을 때, 현재 인프라 상태와 새로운 설정 파일(.tf) 간의 차이점을 파악하고, 필요한 변경 사항만 적용할 수 있습니다.

    만약 State 파일이 없거나, 여러 사람이 각자 다른 버전을 가지고 있다면 어떻게 될까요? Terraform은 인프라의 실제 상태를 알 수 없어서 엉뚱한 자원을 생성하거나, 심지어는 멀쩡한 자원을 삭제해버리는 불상사가 발생할 수 있습니다. 그래서 안정적인 IaC(Infrastructure as Code, 코드형 인프라) 운영을 위해서는 Terraform State 관리가 정말 중요합니다. 특히 팀 협업 환경에서는 이 State 파일을 안전하고 일관되게 관리하는 것이 핵심 중의 핵심입니다.

    S3 백엔드로 원격 State 관리하기

    로컬에 State 파일을 두는 것은 개인적인 실험 환경에서는 괜찮지만, 실제 프로덕션 환경이나 팀 프로젝트에서는 절대 금물입니다. 제가 예전에 홈랩에서 이것저것 테스트하다가 USB를 날려먹으면서 State 파일도 같이 날려버린 적이 있었거든요. 그때 복구하느라 며칠 밤낮을 새웠던 걸 생각하면 아직도 아찔합니다. 😨

    그래서 우리는 S3 백엔드(S3 backend)를 사용해서 Terraform State 파일을 원격으로 저장할 겁니다. AWS S3는 객체 스토리지 서비스로, 높은 내구성(durability), 가용성(availability), 그리고 버전 관리(versioning) 기능을 제공해서 Terraform State를 저장하기에 아주 적합합니다. S3에 State 파일을 저장하면 팀원 누구나 안전하게 접근할 수 있거든요.

    1. S3 버킷 생성하기

    먼저 Terraform State 파일을 저장할 S3 버킷을 생성해야 합니다. 버킷 이름은 전역적으로 고유해야 하며, 버전 관리(Versioning)를 활성화하는 것을 강력히 권장합니다. 혹시 모를 실수로 State 파일이 변경되거나 삭제되더라도 이전 버전으로 복구할 수 있거든요. 저도 예전에 실수로 terraform destroy를 잘못 날렸다가 S3 버전 관리 덕분에 한숨 돌린 적이 있습니다.

    aws s3api create-bucket \
      --bucket my-terraform-state-bucket-13years-infra \
      --region ap-northeast-2 \
      --create-bucket-configuration LocationConstraint=ap-northeast-2
    
    aws s3api put-bucket-versioning \
      --bucket my-terraform-state-bucket-13years-infra \
      --versioning-configuration Status=Enabled
    

    콘솔에서 직접 생성하셔도 됩니다. 이때 중요한 건, “버전 관리”를 꼭 켜주세요! 그리고 필요하다면 서버 측 암호화(Server-side encryption)도 적용하는 게 좋습니다. 💡

    Terraform State 저장을 위한 AWS S3 백엔드 버킷 설정 화면입니다. 버전 관리와 암호화 설정이 중요합니다.

    2. Terraform 구성 파일에 S3 백엔드 설정 추가하기

    이제 Terraform 프로젝트의 main.tf (또는 별도의 backend.tf) 파일에 S3 백엔드 설정을 추가합니다.

    terraform {
      backend "s3" {
        bucket         = "my-terraform-state-bucket-13years-infra" # 위에서 생성한 S3 버킷 이름
        key            = "global/terraform.tfstate"                 # S3 버킷 내 State 파일 경로
        region         = "ap-northeast-2"                           # S3 버킷 리전
        encrypt        = true                                       # State 파일 암호화 활성화
        # dynamodb_table = "my-terraform-locks"                     # State Locking을 위해 나중에 추가 (지금은 주석 처리)
      }
    }
    
    # 예시: VPC를 생성하는 리소스
    resource "aws_vpc" "main" {
      cidr_block = "10.0.0.0/16"
      tags = {
        Name = "MyVPC-ManagedByTerraform"
      }
    }
    

    여기서 key는 S3 버킷 내에서 State 파일이 저장될 경로와 파일명을 지정합니다. 저는 보통 프로젝트별로 또는 환경별로 구분해서 project-name/env/terraform.tfstate 이런 식으로 관리하곤 합니다.

    3. `terraform init` 실행하기

    설정을 추가했다면, 이제 terraform init 명령어를 실행해야 합니다. 이 명령어가 백엔드 설정을 초기화하고, S3 버킷에 State 파일을 생성하거나 기존 파일을 연결합니다.

    terraform init
    

    성공적으로 실행되면 다음과 같은 메시지를 볼 수 있습니다.

    Initializing the backend...
    Successfully configured the backend "s3"! Terraform will now
    persist and retrieve its state from this configuration.
    
    ... (다른 초기화 메시지) ...
    
    Terraform has been successfully initialized!
    

    이제 여러분의 State 파일은 안전하게 S3에 저장됩니다. ✅

    DynamoDB로 State Locking 구현하기

    S3 백엔드만으로는 협업 시 발생할 수 있는 문제를 완전히 해결할 수 없습니다. 바로 동시성 문제(concurrency issue)죠. 여러 사람이 동시에 terraform apply를 실행하면 어떻게 될까요? State 파일이 꼬여버리거나, 한 사람이 작업하는 동안 다른 사람이 같은 자원을 변경해서 예상치 못한 결과가 나올 수 있습니다. 제가 처음 이 문제에 부딪혔을 때, “이거 진짜 난감하네…” 싶었습니다. 😅

    그래서 필요한 게 바로 State Locking(상태 잠금)입니다. Terraform은 AWS DynamoDB를 활용하여 이 State Locking을 구현할 수 있습니다. DynamoDB는 NoSQL 데이터베이스 서비스로, 높은 성능과 가용성을 제공하며, 특히 잠금 메커니즘을 구현하기에 아주 적합합니다. 한 번에 한 사람만 State 파일을 변경할 수 있도록 잠금을 걸어주는 거죠.

    1. DynamoDB 테이블 생성하기

    DynamoDB 테이블을 생성합니다. 이때 테이블 이름은 자유롭게 지정할 수 있지만, 파티션 키(Partition Key)는 반드시 LockID로 설정해야 합니다. 이 LockID를 기준으로 잠금 레코드가 저장되기 때문입니다.

    AWS CLI를 사용한 생성 예시입니다.

    aws dynamodb create-table \
      --table-name my-terraform-locks \
      --attribute-definitions AttributeName=LockID,AttributeType=S \
      --key-schema AttributeName=LockID,KeyType=HASH \
      --billing-mode PAY_PER_REQUEST
    

    PAY_PER_REQUEST 모드를 사용하면 사용한 만큼만 비용을 지불하므로, 트래픽이 많지 않은 State Locking 용도로는 비용 효율적입니다.

    2. Terraform 구성 파일에 DynamoDB 설정 추가하기

    이제 이전에 설정했던 S3 백엔드 블록에 dynamodb_table 인자를 추가합니다.

    terraform {
      backend "s3" {
        bucket         = "my-terraform-state-bucket-13years-infra"
        key            = "global/terraform.tfstate"
        region         = "ap-northeast-2"
        encrypt        = true
        dynamodb_table = "my-terraform-locks" # 새로 생성한 DynamoDB 테이블 이름
      }
    }
    
    # ... (나머지 리소스 정의) ...
    

    3. `terraform init` 다시 실행하기

    백엔드 설정을 변경했으니, terraform init 명령어를 다시 실행해야 합니다. Terraform이 변경된 백엔드 설정을 인식하고 DynamoDB 테이블을 활용하기 시작합니다.

    terraform init
    

    이제부터 terraform apply나 terraform destroy 같은 State를 변경하는 명령어를 실행할 때, Terraform은 DynamoDB 테이블에 잠금 레코드를 생성하여 다른 사용자가 동시에 작업을 하지 못하도록 막아줍니다. 협업 환경에서 안정성을 크게 높여주는 핵심 기능이죠! 🎉

    ⚠️ 삽질 경험: 흔한 실수와 해결책

    제가 이 설정을 하면서 겪었던 몇 가지 삽질 경험과 그 해결책을 공유해 드릴게요. 여러분은 저처럼 고생하지 마시라고요! 😅

    • S3 버킷 권한 문제: terraform init이나 apply 시 “Access Denied” 오류가 발생하면, Terraform을 실행하는 IAM 사용자 또는 역할에 S3 버킷에 대한 s3:GetObject, s3:PutObject, s3:ListBucket 등의 권한이 제대로 부여되었는지 확인해야 합니다. 특히 S3 버킷 정책(Bucket Policy)이 설정되어 있다면 더욱 꼼꼼히 봐야 합니다.
    • DynamoDB 테이블 이름 오타 또는 권한 부족: DynamoDB 테이블 이름을 backend "s3" 블록에 잘못 적거나, 테이블에 대한 dynamodb:GetItem, dynamodb:PutItem, dynamodb:DeleteItem 권한이 없으면 잠금 오류가 발생합니다. 반드시 LockID 파티션 키로 테이블이 생성되었는지, 그리고 IAM 권한이 충분한지 확인하세요.
    • `terraform init` 재실행 누락: 백엔드 설정을 변경하고 terraform init을 다시 실행하지 않아서 변경사항이 적용되지 않는 경우가 많습니다. 저도 자주 까먹어서 “분명히 설정했는데 왜 안 되지?” 하고 한참 헤맸던 적이 있습니다. 🤦‍♂️ 백엔드 설정이 바뀌면 무조건 terraform init! 기억하세요.
    • S3 버전 관리 미활성화: 실수로 terraform state rm 같은 명령어를 잘못 실행해서 State 파일이 삭제되었을 때, S3 버전 관리가 활성화되어 있다면 이전 버전으로 복구할 수 있습니다. 이거 정말 생명줄 같은 기능이니 꼭 활성화하세요!

    이런 작은 실수가 큰 문제로 이어질 수 있으니, 항상 주의 깊게 확인하는 습관을 들이는 것이 중요합니다.

    Terraform State 관리 모범 사례를 요약한 인포그래픽입니다. 핵심 원칙들을 한눈에 파악할 수 있습니다.

    검증 및 결과: 이제 안심하고 협업하세요!

    모든 설정이 완료되었다면, 이제 팀원들과 함께 걱정 없이 Terraform을 사용할 수 있습니다. State Locking이 제대로 작동하는지 확인하는 가장 좋은 방법은, 두 명의 사용자가 동시에 terraform apply를 실행해보는 것입니다. 한 명의 작업이 진행되는 동안 다른 한 명은 잠금 오류 메시지를 받게 될 거예요. 이렇게 되면 성공입니다!

    또한, terraform state list 명령어를 통해 S3에 저장된 State 파일의 내용을 확인하거나, AWS 콘솔에서 S3 버킷과 DynamoDB 테이블의 항목들을 직접 확인해볼 수도 있습니다. DynamoDB 테이블에는 LockID를 가진 항목이 잠금 상태를 나타냅니다.

    terraform state list
    

    이제 여러분의 IaC 상태 관리(IaC state management)는 훨씬 더 안정적이고 견고해졌을 겁니다. 팀원들과의 Terraform 협업(Terraform collaboration)도 한층 부드러워질 거고요. 드디어 됐다! 이 안정감, 진짜 편하더라고요. 👍

    Terraform 명령어를 실행하여 State가 성공적으로 관리되고 있음을 보여주는 스크린샷입니다. DynamoDB 락도 확인해볼 수 있습니다.

    마무리하며: 더 나은 IaC 여정을 위해

    오늘은 Terraform State를 S3와 DynamoDB를 활용하여 안전하게 관리하는 방법에 대해 자세히 알아봤습니다. 13년차 엔지니어로서 직접 겪었던 경험과 삽질까지 솔직하게 공유해 드렸는데, 도움이 되셨으면 좋겠습니다.

    Terraform State 관리는 단순한 파일 저장을 넘어, 안정적인 인프라 운영과 효율적인 팀 협업을 위한 필수적인 요소입니다. S3의 내구성과 버전 관리, 그리고 DynamoDB의 강력한 잠금 기능을 활용하면, 여러분의 IaC 여정은 훨씬 더 순탄해질 거예요.

    다음 글에서는 Terraform State 파일을 더욱 안전하게 보호하기 위한 추가적인 보안 설정(예: IAM 정책 강화, S3 버킷 정책, VPC Endpoint 활용 등)에 대해 더 깊게 다뤄볼 예정입니다. 기대해주세요! 😊

  • [Cloud] GitHub Actions 모노레포: 매트릭스 빌드로 CI/CD 최적화하기

    모노레포에서 CI/CD, 이게 생각보다 복잡하더라고요

    처음 팀에서 모노레포(Monorepo)로 전환했을 때 솔직히 자신 있었거든요. “뭐, CI/CD 파이프라인 하나 잘 짜면 되는 거 아니야?” 했는데… 현실은 달랐습니다. 프론트엔드, 백엔드 API, 공통 라이브러리가 한 레포에 다 들어가 있으니까 서로 상관없는 변경사항인데도 전체 빌드가 돌고, 빌드 시간은 점점 길어지고. 결국 개발자들이 “PR 올리면 20분 기다려야 해요”라고 불만을 터뜨리기 시작했죠.

    그때 제대로 파고든 게 GitHub Actions 모노레포 환경에서의 매트릭스 빌드(Matrix Build) 전략이었습니다. 인프라 업무를 오래 하면서 CI/CD 툴을 여럿 써봤는데, GitHub Actions의 매트릭스 전략은 모노레포 환경에서 정말 강력하더라고요. 오늘은 그 경험을 바탕으로 실전에서 바로 쓸 수 있는 가이드를 공유해 드리려 합니다.

    ▲ GitHub Actions 모노레포 환경의 전체 CI/CD 파이프라인 흐름 — 변경된 패키지만 선택적으로 빌드/테스트하는 구조

    GitHub Actions 모노레포와 매트릭스 빌드, 쉽게 이해해 봅시다

    모노레포(Monorepo)란?

    쉽게 말해, 여러 개의 프로젝트나 패키지를 하나의 Git 저장소에서 관리하는 방식입니다. 예전에는 프로젝트마다 레포를 따로 만드는 멀티레포(Multi-repo) 방식이 흔했는데, 요즘은 Google, Meta, Microsoft 같은 대형 기업들도 모노레포를 적극 활용하고 있죠.

    • ✅ 코드 공유와 재사용이 쉬움
    • ✅ 의존성 관리가 일원화됨
    • ✅ 변경 사항의 영향 범위를 한눈에 파악 가능
    • ⚠️ CI/CD 파이프라인 설계가 까다로워짐
    • ⚠️ 레포 크기가 커질수록 빌드 시간이 늘어날 수 있음

    GitHub Actions 매트릭스 빌드(Matrix Build)란?

    GitHub Actions의 strategy.matrix 기능은 여러 변수 조합에 대해 병렬로 잡(Job)을 실행하는 기능입니다. 예를 들어 Node.js 16, 18, 20 버전에서 동시에 테스트를 돌린다거나, 여러 패키지를 동시에 빌드할 때 쓰죠. 모노레포에서는 이걸 “변경된 패키지 목록”과 조합해서 쓰면 진짜 강력해집니다.

    방식 빌드 대상 빌드 시간 장단점
    전체 빌드 (Naive) 모든 패키지 길다 설정 단순, 시간 낭비 큼
    매트릭스 + 변경 감지 변경된 패키지만 짧다 설정 복잡, 효율 극대화
    캐시 + 매트릭스 변경된 패키지만 매우 짧다 가장 최적화된 방식

    GitHub Actions 모노레포 CI/CD 실전 구현: 단계별로 따라해 보세요

    1단계: 레포 구조 잡기

    먼저 제가 실제로 운영하는 모노레포 구조를 보여드릴게요. 완벽할 필요는 없고, 이런 식으로 패키지가 분리되어 있으면 됩니다.

    my-monorepo/
    ├── .github/
    │   └── workflows/
    │       ├── ci.yml          # 메인 CI 워크플로우
    │       └── detect-changes.yml
    ├── packages/
    │   ├── frontend/           # React 프론트엔드
    │   │   ├── package.json
    │   │   └── src/
    │   ├── api-server/         # Node.js API 서버
    │   │   ├── package.json
    │   │   └── src/
    │   └── shared-lib/         # 공통 라이브러리
    │       ├── package.json
    │       └── src/
    ├── package.json            # 루트 package.json (워크스페이스 설정)
    └── pnpm-workspace.yaml     # pnpm 워크스페이스 설정

    2단계: 변경된 패키지 감지하기

    여기가 핵심입니다. 어떤 패키지가 바뀌었는지 감지해야 매트릭스를 동적으로 구성할 수 있거든요. 저는 git diff와 간단한 스크립트를 조합해서 씁니다.

    # .github/workflows/ci.yml
    name: Monorepo CI
    
    on:
      push:
        branches: [main, develop]
      pull_request:
        branches: [main, develop]
    
    jobs:
      # 1. 변경된 패키지 목록을 동적으로 생성
      detect-changes:
        runs-on: ubuntu-latest
        outputs:
          matrix: ${{ steps.set-matrix.outputs.matrix }}
          has-changes: ${{ steps.set-matrix.outputs.has-changes }}
        steps:
          - name: Checkout
            uses: actions/checkout@v4
            with:
              fetch-depth: 0  # 전체 히스토리 필요 (diff 비교용)
    
          - name: Detect changed packages
            id: set-matrix
            run: |
              # PR이면 base 브랜치와 비교, push면 이전 커밋과 비교
              if [ "${{ github.event_name }}" = "pull_request" ]; then
                BASE_SHA=${{ github.event.pull_request.base.sha }}
              else
                BASE_SHA=${{ github.event.before }}
              fi
    
              CHANGED_PACKAGES='[]'
    
              # packages/ 하위 디렉토리를 순회하며 변경 여부 확인
              for pkg_dir in packages/*/; do
                pkg_name=$(basename $pkg_dir)
                changed=$(git diff --name-only $BASE_SHA ${{ github.sha }} -- $pkg_dir | head -1)
                if [ -n "$changed" ]; then
                  CHANGED_PACKAGES=$(echo $CHANGED_PACKAGES | jq --arg p "$pkg_name" '. + [$p]')
                fi
              done
    
              echo "Changed packages: $CHANGED_PACKAGES"
    
              if [ "$CHANGED_PACKAGES" = "[]" ]; then
                echo "has-changes=false" >> $GITHUB_OUTPUT
              else
                echo "has-changes=true" >> $GITHUB_OUTPUT
              fi
    
              echo "matrix={\"package\":$CHANGED_PACKAGES}" >> $GITHUB_OUTPUT

    💡 팁: fetch-depth: 0 설정이 없으면 git diff가 제대로 안 됩니다. 처음에 이거 빠뜨려서 삽질 좀 했어요. 얕은 클론(shallow clone)에서는 비교 기준이 되는 커밋을 못 찾거든요.

    3단계: 매트릭스 빌드 잡 구성

    이제 감지된 패키지 목록으로 매트릭스를 구성합니다. needs 키워드로 앞 단계에 의존하게 만들고, if 조건으로 변경 사항이 없을 때는 스킵하게 하면 됩니다.

      # 2. 변경된 패키지만 병렬로 빌드 & 테스트
      build-and-test:
        needs: detect-changes
        if: needs.detect-changes.outputs.has-changes == 'true'
        runs-on: ubuntu-latest
        strategy:
          matrix: ${{ fromJson(needs.detect-changes.outputs.matrix) }}
          fail-fast: false  # 하나 실패해도 나머지는 계속 실행
        steps:
          - name: Checkout
            uses: actions/checkout@v4
    
          - name: Setup Node.js
            uses: actions/setup-node@v4
            with:
              node-version: '20'
              cache: 'pnpm'
    
          - name: Install pnpm
            uses: pnpm/action-setup@v3
            with:
              version: 8
    
          - name: Install dependencies
            run: pnpm install --frozen-lockfile
    
          # 캐시 설정 — 빌드 시간 단축에 핵심!
          - name: Cache build artifacts
            uses: actions/cache@v4
            with:
              path: packages/${{ matrix.package }}/.next
              key: ${{ runner.os }}-${{ matrix.package }}-${{ hashFiles('packages/${{ matrix.package }}/package.json') }}
              restore-keys: |
                ${{ runner.os }}-${{ matrix.package }}-
    
          - name: Build package
            run: pnpm --filter ${{ matrix.package }} build
    
          - name: Run tests
            run: pnpm --filter ${{ matrix.package }} test
    
          - name: Upload test results
            if: always()  # 테스트 실패해도 결과는 업로드
            uses: actions/upload-artifact@v4
            with:
              name: test-results-${{ matrix.package }}
              path: packages/${{ matrix.package }}/test-results/

    ▲ 매트릭스 빌드가 실행될 때 GitHub Actions 화면 — 변경된 패키지들이 병렬로 동시에 빌드되는 것을 확인할 수 있음

    4단계: 의존성 있는 패키지 처리

    근데 여기서 문제가 하나 생깁니다. shared-lib가 변경되면 이걸 쓰는 frontend나 api-server도 다시 빌드해야 하거든요. 이 의존성 체인을 처리하는 게 모노레포 CI/CD의 진짜 어려운 부분입니다.

    #!/bin/bash
    # scripts/detect-affected.sh
    # 변경된 패키지 + 그에 의존하는 패키지까지 모두 감지
    
    CHANGED_DIRECT=$1  # 직접 변경된 패키지 (JSON 배열 문자열)
    AFFECTED_PACKAGES=$CHANGED_DIRECT
    
    # 각 패키지의 package.json을 읽어서 의존성 체인 추적
    for pkg_dir in packages/*/; do
      pkg_name=$(basename $pkg_dir)
      pkg_json="$pkg_dir/package.json"
    
      if [ -f "$pkg_json" ]; then
        # 이 패키지가 변경된 패키지에 의존하는지 확인
        for changed in $(echo $CHANGED_DIRECT | jq -r '.[]'); do
          deps=$(cat $pkg_json | jq -r '.dependencies // {} | keys[]' 2>/dev/null)
          if echo "$deps" | grep -q "$changed"; then
            # 의존성이 있으면 affected 목록에 추가
            AFFECTED_PACKAGES=$(echo $AFFECTED_PACKAGES | jq --arg p "$pkg_name" '. + [$p] | unique')
          fi
        done
      fi
    done
    
    echo $AFFECTED_PACKAGES

    이 스크립트를 detect-changes 잡에서 호출하면 됩니다. 물론 Nx나 Turborepo 같은 모노레포 전용 툴을 쓰면 이런 의존성 그래프를 자동으로 처리해 주기도 하죠. 규모가 커지면 Turborepo로 마이그레이션하는 걸 고려하고 있어요.

    ⚠️ 실제로 겪은 GitHub Actions 모노레포 트러블슈팅

    문제 1: BASE_SHA가 비어있는 경우

    첫 번째 커밋이거나 새 브랜치를 push할 때 github.event.before가 0000000000000000000000000000000000000000(제로 SHA)로 오는 경우가 있습니다. 이때 git diff가 에러를 뱉거든요.

          - name: Detect changed packages
            id: set-matrix
            run: |
              # 제로 SHA 처리
              BASE_SHA=${{ github.event.before }}
              ZERO_SHA="0000000000000000000000000000000000000000"
    
              if [ "$BASE_SHA" = "$ZERO_SHA" ] || [ -z "$BASE_SHA" ]; then
                # 첫 커밋이면 모든 패키지를 빌드 대상으로
                echo "First push or new branch — building all packages"
                ALL_PACKAGES=$(ls packages/ | jq -R -s -c 'split("\\n") | map(select(length > 0))')
                echo "matrix={\"package\":$ALL_PACKAGES}" >> $GITHUB_OUTPUT
                echo "has-changes=true" >> $GITHUB_OUTPUT
              else
                # 기존 로직 실행
                # ... (이전 코드)
                :
              fi

    문제 2: 매트릭스가 빈 배열일 때 워크플로우 에러

    변경된 패키지가 없어서 매트릭스가 빈 배열 []이 되면 GitHub Actions가 에러를 냅니다. has-changes 출력값으로 if 조건을 걸어두는 게 중요한 이유가 여기 있어요. 또한 최종 상태 체크 잡을 별도로 두는 것도 좋은 패턴입니다.

      # 모든 빌드 완료 후 최종 상태 확인 잡
      ci-success:
        needs: [detect-changes, build-and-test]
        if: always()
        runs-on: ubuntu-latest
        steps:
          - name: Check all jobs status
            run: |
              if [ "${{ needs.detect-changes.result }}" != "success" ]; then
                echo "detect-changes job failed"
                exit 1
              fi
    
              # build-and-test가 스킵됐거나 성공한 경우 모두 OK
              if [ "${{ needs.build-and-test.result }}" = "failure" ]; then
                echo "Some builds failed!"
                exit 1
              fi
    
              echo "All checks passed! 🎉"

    문제 3: pnpm 워크스페이스에서 filter가 안 먹힐 때

    패키지 이름이 package.json의 name 필드와 달라서 --filter가 안 먹히는 경우가 있었습니다. 디렉토리명으로 필터하려면 --filter ./packages/패키지명 형식을 써야 합니다.

          - name: Build package
            run: |
              # 디렉토리 경로로 필터 (이름 불일치 문제 방지)
              pnpm --filter ./packages/${{ matrix.package }} build

    전체 GitHub Actions 워크플로우 완성본

    지금까지 설명한 내용을 하나로 합친 완성본입니다. 실제로 제가 운영 중인 설정을 기반으로 정리했어요.

    # .github/workflows/ci.yml
    name: Monorepo CI/CD
    
    on:
      push:
        branches: [main, develop]
      pull_request:
        branches: [main, develop]
    
    concurrency:
      group: ${{ github.workflow }}-${{ github.ref }}
      cancel-in-progress: true  # 같은 브랜치 중복 실행 방지
    
    jobs:
      detect-changes:
        runs-on: ubuntu-latest
        outputs:
          matrix: ${{ steps.set-matrix.outputs.matrix }}
          has-changes: ${{ steps.set-matrix.outputs.has-changes }}
        steps:
          - uses: actions/checkout@v4
            with:
              fetch-depth: 0
    
          - name: Detect changed packages
            id: set-matrix
            run: |
              if [ "${{ github.event_name }}" = "pull_request" ]; then
                BASE_SHA=${{ github.event.pull_request.base.sha }}
              else
                BASE_SHA=${{ github.event.before }}
              fi
    
              ZERO_SHA="0000000000000000000000000000000000000000"
              CHANGED_PACKAGES='[]'
    
              if [ "$BASE_SHA" = "$ZERO_SHA" ] || [ -z "$BASE_SHA" ]; then
                CHANGED_PACKAGES=$(ls packages/ | jq -R -s -c 'split("\\n") | map(select(length > 0))')
              else
                for pkg_dir in packages/*/; do
                  pkg_name=$(basename $pkg_dir)
                  changed=$(git diff --name-only $BASE_SHA ${{ github.sha }} -- $pkg_dir | head -1)
                  if [ -n "$changed" ]; then
                    CHANGED_PACKAGES=$(echo $CHANGED_PACKAGES | jq --arg p "$pkg_name" '. + [$p]')
                  fi
                done
              fi
    
              echo "Affected packages: $CHANGED_PACKAGES"
    
              if [ "$CHANGED_PACKAGES" = "[]" ]; then
                echo "has-changes=false" >> $GITHUB_OUTPUT
              else
                echo "has-changes=true" >> $GITHUB_OUTPUT
              fi
              echo "matrix={\"package\":$CHANGED_PACKAGES}" >> $GITHUB_OUTPUT
    
      build-and-test:
        needs: detect-changes
        if: needs.detect-changes.outputs.has-changes == 'true'
        runs-on: ubuntu-latest
        strategy:
          matrix: ${{ fromJson(needs.detect-changes.outputs.matrix) }}
          fail-fast: false
        steps:
          - uses: actions/checkout@v4
    
          - uses: pnpm/action-setup@v3
            with:
              version: 8
    
          - uses: actions/setup-node@v4
            with:
              node-version: '20'
              cache: 'pnpm'
    
          - name: Install dependencies
            run: pnpm install --frozen-lockfile
    
          - name: Cache build
            uses: actions/cache@v4
            with:
              path: |
                packages/${{ matrix.package }}/.next
                packages/${{ matrix.package }}/dist
              key: ${{ runner.os }}-build-${{ matrix.package }}-${{ github.sha }}
              restore-keys: |
                ${{ runner.os }}-build-${{ matrix.package }}-
    
          - name: Build
            run: pnpm --filter ./packages/${{ matrix.package }} build
    
          - name: Test
            run: pnpm --filter ./packages/${{ matrix.package }} test
    
          - name: Upload artifacts
            if: always()
            uses: actions/upload-artifact@v4
            with:
              name: results-${{ matrix.package }}
              path: packages/${{ matrix.package }}/test-results/
              retention-days: 7
    
      ci-success:
        needs: [detect-changes, build-and-test]
        if: always()
        runs-on: ubuntu-latest
        steps:
          - name: Final status check
            run: |
              if [ "${{ needs.build-and-test.result }}" = "failure" ]; then
                echo "Build failed!"
                exit 1
              fi
              echo "CI passed! 🎉"

    ▲ 완성된 CI/CD 파이프라인 실행 결과 — detect-changes → build-and-test (병렬) → ci-success 순서로 실행되는 것을 확인

    ✅ 결과: 빌드 시간이 얼마나 줄었을까요?

    이 방식을 적용하고 나서 체감이 꽤 컸습니다. 물론 레포마다 상황이 다르겠지만, 제 경우엔 이랬어요.

    • 📦 전체 패키지 수: 6개
    • ⏱️ 기존 전체 빌드 시간: 약 18~22분
    • ⚡ 매트릭스 빌드 적용 후 (1~2개 패키지 변경 시): 4~7분
    • 🎉 개발자들 PR 머지 속도 체감상 확 빨라짐

    특히 공통 라이브러리 변경이 없는 일반적인 기능 개발 PR에서 효과가 컸습니다. 프론트엔드만 건드렸을 때 백엔드 빌드까지 기다릴 필요가 없으니까요. 당연한 말 같지만, 이걸 자동화로 구현하는 게 핵심이죠.

    추가로 concurrency 설정으로 같은 브랜치에서 중복 실행을 방지한 것도 빌드 큐 낭비를 줄이는 데 도움이 됐습니다. GitHub Actions 무료 플랜은 동시 실행 잡 수에 제한이 있으니까요.

    💡 CI/CD 최적화 추가 팁

    캐시 전략으로 빌드 시간 단축

    • actions/cache의 key를 package.json 해시 기반으로 설정하면 의존성이 바뀔 때만 캐시가 무효화됩니다
    • 빌드 결과물(dist, .next)도 캐시하면 재빌드 시 시간 절약 가능
    • 캐시 hit rate는 Actions 실행 로그에서 확인 가능 — 이걸 모니터링하는 습관을 들이세요

    Turborepo와 함께 쓰기

    패키지가 10개 이상으로 늘어나면 Turborepo(터보레포) 같은 모노레포 빌드 시스템을 도입하는 게 좋습니다. 의존성 그래프 분석, 원격 캐시, 병렬 실행 최적화를 자동으로 해주거든요. 다음 글에서 Turborepo와 GitHub Actions를 함께 쓰는 방법을 다룰 예정입니다.

    Branch Protection Rules 연동

    앞서 만든 ci-success 잡을 GitHub 브랜치 보호 규칙(Branch Protection Rules)의 필수 상태 체크로 등록해 두세요. 매트릭스 빌드가 스킵된 경우에도 ci-success는 항상 실행되므로, PR 머지 조건으로 쓰기에 딱입니다.

    ▲ GitHub Actions 모노레포 CI/CD 최적화 전략 요약 — 변경 감지, 매트릭스 빌드, 캐시 전략의 세 가지 축

    자주 묻는 질문

    Q. Nx를 쓰면 이런 GitHub Actions 설정이 필요 없나요?

    Nx(엔엑스)를 쓰면 nx affected 명령어로 변경된 패키지 감지를 자동화할 수 있습니다. 다만 Nx 자체 학습 곡선이 있고, 기존 프로젝트에 도입하는 비용이 있어요. 팀 규모와 프로젝트 복잡도에 따라 선택하시면 됩니다. 오늘 설명한 방식은 외부 툴 없이 GitHub Actions만으로 구현할 수 있다는 게 장점이에요.

    Q. PR이 아닌 직접 push에서도 잘 되나요?

    네, 됩니다. 다만 github.event.before가 제로 SHA로 오는 케이스(새 브랜치 최초 push)를 반드시 처리해야 합니다. 위 코드에 그 처리가 포함되어 있으니 참고하세요.

    Q. 모노레포에서 Docker 이미지 빌드도 같은 방식으로?

    동일한 패턴으로 적용 가능합니다. 매트릭스의 각 항목에 대해 docker build를 실행하고 레지스트리에 push하면 되죠. 이 내용은 이전 글에서 다룬 Docker 멀티 스테이지 빌드 최적화와 함께 보시면 더 이해가 쉬울 거예요.

    마무리: GitHub Actions 모노레포 CI/CD, 처음엔 복잡해 보여도

    처음에 이 구조를 짤 때 “이게 맞나?” 싶은 순간이 여러 번 있었습니다. 특히 동적 매트릭스 생성 부분에서 fromJson이 제대로 안 먹혀서 한참 헤맸던 기억이 나네요. 근데 한번 제대로 잡아두면 이후에는 거의 손댈 일이 없어요.

    핵심을 정리하면 이렇습니다.

    1. 변경 감지: git diff로 변경된 패키지만 추려냄
    2. 동적 매트릭스: 감지 결과를 JSON으로 출력해서 매트릭스에 주입
    3. 병렬 빌드: strategy.matrix로 변경된 패키지들을 동시에 빌드
    4. 캐시 활용: actions/cache로 반복 빌드 시간 단축
    5. 안전장치: ci-success 잡으로 브랜치 보호 규칙과 연동

    혹시 레포 규모가 더 크거나, Turborepo/Nx 같은 툴과 함께 쓰는 방법이 궁금하신 분들은 댓글로 남겨주세요. 다음 글에서 다뤄볼게요. 오늘도 긴 글 읽어주셔서 감사합니다! 🎉

  • [Cloud] Terraform AWS EKS 모듈 활용: 프로덕션 클러스터 배포 및 관리 가이드

    [Cloud] Terraform AWS EKS 모듈 활용: 프로덕션 클러스터 배포 및 관리 가이드

    안녕하세요, 13년차 서버 인프라 엔지니어입니다. 오늘은 Terraform AWS EKS 모듈을 활용해서 프로덕션 환경에 바로 적용할 수 있는 쿠버네티스(Kubernetes) 클러스터를 배포하고 관리하는 방법을 나눠볼게요. 사실 저도 쿠버네티스를 처음 접했을 땐 ‘이걸 어떻게 프로덕션에 안정적으로 올리지?’ 고민이 정말 많았거든요. 수많은 설정과 의존성 때문에 삽질을 꽤 했었습니다. ㅎㅎ

    그런데 Terraform(테라폼)과 AWS EKS 모듈을 써보니까, 정말 복잡했던 배포 과정이 깔끔하게 정리되더라고요. 마치 복잡한 미로에서 벗어나는 기분이었죠. 오늘은 제가 직접 경험했던 노하우를 바탕으로, Terraform EKS 모듈이 왜 프로덕션 환경에 필수적인지, 그리고 어떻게 활용하는지 자세히 알려드릴게요.

    Terraform으로 관리되는 AWS EKS 클러스터의 일반적인 아키텍처입니다.

    개념 정리: 왜 Terraform과 EKS 모듈이 필요할까요?

    본격적인 실전으로 들어가기 전에, 핵심 개념들을 간단히 짚고 넘어갈게요. 이미 잘 알고 계신 분들도 있겠지만, 혹시 모르니까 멘토처럼 쉽게 설명해드릴게요.

    • IaC (Infrastructure as Code, 코드형 인프라스트럭처): 인프라를 코드로 관리한다는 뜻이에요. 예전에는 서버 한 대 놓으려면 직접 전산실 가서 설치하고 케이블 연결하고 그랬잖아요? 요즘은 그런 모든 과정을 코드로 정의하고 자동화합니다. Terraform이 바로 이 IaC를 구현하는 대표적인 도구거든요. 코드로 인프라를 관리하면 반복 가능하고, 버전 관리도 되고, 무엇보다 휴먼 에러가 확 줄어든다는 게 최고의 장점입니다. 제가 직접 써보니 정말 그렇더라고요!

    • AWS EKS (Amazon Elastic Kubernetes Service): AWS에서 제공하는 관리형 쿠버네티스 서비스예요. 쿠버네티스는 컨테이너화된 워크로드를 배포하고 관리하는 오픈소스 시스템인데, EKS는 이 쿠버네티스 컨트롤 플레인(Control Plane)을 AWS가 대신 관리해준다는 뜻입니다. 덕분에 우리는 마스터 노드 관리 부담 없이 워커 노드(Worker Node)에만 집중할 수 있죠. 프로덕션 환경에선 안정성이 생명인데, AWS가 관리해주니 정말 마음이 든든합니다.

    • Terraform AWS EKS 모듈: Terraform 오픈소스 커뮤니티가 만들어 놓은 ‘모듈(Module)’이 있습니다. 이 EKS 모듈은 AWS EKS 클러스터를 배포하는 데 필요한 모든 리소스(VPC, 서브넷, IAM 역할, EKS 클러스터 자체, 노드 그룹 등)를 미리 정의해둔 템플릿 모음이에요. 이걸 사용하면 수백 줄이 넘을 수 있는 Terraform 코드를 몇 줄로 줄일 수 있거든요. 처음엔 직접 다 만들었었는데, 이 모듈을 발견하고 나서는 ‘아, 이래서 다들 모듈을 쓰는구나!’ 하고 무릎을 탁 쳤습니다. 생산성 향상에 정말 최고예요. 🚀

    실전 구현: Terraform으로 EKS 클러스터 배포하기

    이제 실제로 Terraform AWS EKS 모듈을 사용해서 프로덕션용 EKS 클러스터를 배포해볼게요. 단계별로 차근차근 따라오면 됩니다. 제가 홈랩에서 여러 번 테스트해보고 가장 안정적인 방법을 알려드릴게요.

    1. 프로젝트 구조 및 초기 설정

    먼저 다음과 같은 디렉토리 구조를 만들어주세요.

    mydir/
    ├── main.tf
    ├── variables.tf
    └── outputs.tf
    

    main.tf 파일에 AWS Provider(프로바이더)를 설정하고, Terraform EKS 모듈을 정의합니다.

    # main.tf
    
    terraform {
      required_version = ">= 1.0.0"
      required_providers {
        aws = {
          source  = "hashicorp/aws"
          version = "~> 5.0"
        }
      }
    }
    
    provider "aws" {
      region = var.aws_region
    }
    
    module "vpc" {
      source  = "terraform-aws-modules/vpc/aws"
      version = "~> 5.0"
    
      name = "${var.cluster_name}-vpc"
      cidr = "10.0.0.0/16"
    
      azs             = data.aws_availability_zones.available.names
      private_subnets = ["10.0.1.0/24", "10.0.2.0/24", "10.0.3.0/24"]
      public_subnets  = ["10.0.101.0/24", "10.0.102.0/24", "10.0.103.0/24"]
    
      enable_nat_gateway = true
      single_nat_gateway = true
    
      tags = {
        "kubernetes.io/cluster/${var.cluster_name}" = "owned"
        "kubernetes.io/role/internal-elb"           = "1"
        "kubernetes.io/role/elb"                    = "1"
      }
    }
    
    module "eks" {
      source  = "terraform-aws-modules/eks/aws"
      version = "~> 20.0" # Terraform Registry에서 최신 안정 버전을 확인하세요!
    
      cluster_name    = var.cluster_name
      cluster_version = var.cluster_version
    
      vpc_id     = module.vpc.vpc_id
      subnet_ids = module.vpc.private_subnets
    
      # EKS 클러스터 로깅 활성화 (프로덕션 필수!)
      cluster_enabled_log_types = ["api", "audit", "authenticator", "controllerManager", "scheduler"]
    
      # 관리형 노드 그룹 (Managed Node Groups)
      managed_node_groups = {
        general = {
          name            = "general-nodes"
          instance_types  = ["t3.medium"]
          min_size        = 2
          max_size        = 5
          desired_size    = 3
          disk_size       = 50
          labels          = { env = "production", role = "general" }
          capacity_type   = "ON_DEMAND"
          # EKS 노드에 SSH 접속이 필요하다면 아래 주석 해제 후 키 페어 이름 설정
          # key_name = "your-ssh-key-name"
        }
        # 추가 노드 그룹이 필요하면 여기에 정의
        # spot = {
        #   name          = "spot-nodes"
        #   instance_types  = ["t3.small", "t3.medium"]
        #   min_size        = 0
        #   max_size        = 10
        #   desired_size    = 1
        #   capacity_type   = "SPOT"
        #   disk_size       = 20
        # }
      }
    
      tags = {
        Project     = "EKS-Production"
        Environment = "Prod"
      }
    }
    
    data "aws_availability_zones" "available" {}
    

    ⚠️ 주의사항: t3.medium은 테스트용으로 괜찮지만, 실제 프로덕션 워크로드에는 더 큰 인스턴스 타입(예: m5.large, c5.large)을 고려하세요. 그리고 version = "~> 20.0" 부분은 항상 Terraform Registry에서 최신 안정 버전을 확인해서 사용하세요. Terraform AWS EKS 모듈이 워낙 빠르게 업데이트돼서 제가 작성한 시점과 다를 수 있거든요.

    variables.tf 파일에는 클러스터 이름, AWS 리전, EKS 버전 등 변경될 수 있는 값들을 정의합니다.

    # variables.tf
    
    variable "aws_region" {
      description = "AWS region."
      type        = string
      default     = "ap-northeast-2" # 서울 리전
    }
    
    variable "cluster_name" {
      description = "Name of the EKS cluster."
      type        = string
      default     = "my-prod-eks-cluster"
    }
    
    variable "cluster_version" {
      description = "Kubernetes version."
      type        = string
      default     = "1.28" # 사용 가능한 EKS 버전 확인 후 지정
    }
    

    outputs.tf 파일에는 배포 후 필요한 정보를 출력하도록 설정합니다. 클러스터 엔드포인트나 kubeconfig 명령어 같은 것들이죠.

    # outputs.tf
    
    output "cluster_endpoint" {
      description = "Endpoint for EKS Control Plane."
      value       = module.eks.cluster_endpoint
    }
    
    output "kubeconfig_command" {
      description = "Command to configure kubectl."
      value       = "aws eks update-kubeconfig --region ${var.aws_region} --name ${var.cluster_name}"
    }
    
    output "cluster_security_group_id" {
      description = "Security group ID of the EKS cluster."
      value       = module.eks.cluster_security_group_id
    }
    

    Terraform EKS 모듈을 위한 주요 구성 파일들의 역할과 관계를 보여줍니다.

    2. Terraform 명령 실행

    파일들을 모두 작성했다면, 터미널을 열고 해당 디렉토리로 이동해서 다음 명령어를 실행합니다.

    1. Terraform 초기화 (Initialize): 필요한 프로바이더와 모듈을 다운로드합니다.

      terraform init
      
    2. Terraform 실행 계획 (Plan): 실제로 어떤 리소스들이 생성/변경/삭제될지 미리 보여줍니다. 이 단계에서 항상 꼼꼼히 확인하는 습관을 들이셔야 해요. 저는 여기서 실수 몇 번 해보고 크게 깨달았습니다. 😅

      terraform plan
      
    3. Terraform 적용 (Apply): 계획대로 리소스를 AWS에 배포합니다. 시간이 좀 걸릴 수 있어요. 커피 한 잔 마시면서 기다려보세요. 😊

      terraform apply --auto-approve
      

      --auto-approve 옵션은 실제 프로덕션 환경에서는 신중하게 사용해야 합니다. 보통은 terraform apply만 실행해서 직접 승인하는 과정을 거치는 게 안전하거든요.

    ⚠️ 주의사항: 삽질 경험과 해결책

    제가 13년간 인프라 엔지니어로 일하면서 느낀 건, 아무리 잘 만들어진 도구라도 ‘삽질’은 피할 수 없다는 거예요. Terraform EKS 모듈도 마찬가지입니다. 몇 가지 흔한 문제와 제가 겪었던 해결책을 공유해드릴게요.

    • VPC Subnet Tag 누락: EKS 클러스터가 서브넷을 제대로 인식하지 못해서 배포가 실패하는 경우가 종종 있어요. 특히 다른 모듈로 VPC를 만들었거나 수동으로 서브넷을 구성했을 때 그렇더라고요. EKS는 워커 노드를 프로비저닝할 때 특정 태그(kubernetes.io/cluster/YOUR_CLUSTER_NAME과 kubernetes.io/role/internal-elb 또는 kubernetes.io/role/elb)가 있는 서브넷을 찾습니다. main.tf의 VPC 모듈 설정에서 태그를 꼭 넣어주세요.

      tags = {
        "kubernetes.io/cluster/${var.cluster_name}" = "owned"
        "kubernetes.io/role/internal-elb"           = "1"
        "kubernetes.io/role/elb"                    = "1"
      }
      
    • IAM 권한 부족: Terraform을 실행하는 IAM 사용자 또는 역할에 EKS, EC2, IAM, VPC 관련 권한이 충분히 부여되지 않으면 문제가 생길 수 있습니다. 특히 EKS Administrator와 유사한 관리자 권한을 가진 정책을 사용하거나, 필요한 최소 권한을 직접 설정해야 하죠. 저는 처음에 너무 최소 권한만 주려다가 여러 번 권한 에러를 만났습니다. 그럴 땐 일단 잠시 넓은 권한을 줘서 문제가 권한 때문인지 확인하고, 잘 되면 다시 최소 권한으로 조이는 방법을 썼어요.

    • 모듈 버전 충돌 또는 비호환성: Terraform AWS EKS 모듈은 빠르게 업데이트됩니다. 특정 Terraform 버전, AWS Provider 버전, EKS 클러스터 버전과의 호환성을 항상 확인해야 해요. Terraform Registry에서 해당 모듈의 Required Providers와 Requirements 섹션을 꼭 확인하세요. 버전이 맞지 않으면 예상치 못한 에러가 발생할 수 있거든요.

    • 노드 그룹의 인스턴스 타입/AMI 문제: EKS 워커 노드가 정상적으로 클러스터에 조인되지 않는 경우가 있습니다. 주로 EKS 버전과 호환되지 않는 AMI(Amazon Machine Image)를 사용했거나, 인스턴스 타입이 해당 리전에서 지원되지 않을 때 발생합니다. Terraform EKS 모듈은 기본적으로 EKS 최적화 AMI를 사용하지만, 사용자 지정 AMI를 사용할 경우 주의해야 합니다.

    검증 및 결과: 클러스터 확인하기

    Terraform apply가 성공적으로 완료되었다면, 이제 EKS 클러스터가 잘 배포되었는지 확인해볼 차례예요. 🎉

    1. Kubeconfig 설정

    먼저 outputs.tf에서 출력된 kubeconfig_command를 실행해서 kubectl이 EKS 클러스터에 접속할 수 있도록 설정합니다. 이 명령어를 실행하면 ~/.kube/config 파일이 업데이트될 겁니다.

    aws eks update-kubeconfig --region ap-northeast-2 --name my-prod-eks-cluster
    

    2. 노드 확인

    이제 kubectl 명령어로 클러스터 노드들을 확인해봅시다. 워커 노드들이 Ready 상태로 잘 올라와 있어야 합니다.

    kubectl get nodes
    
    NAME                                           STATUS   ROLES    AGE     VERSION
    ip-10-0-1-123.ap-northeast-2.compute.internal   Ready    <none>   5m20s   v1.28.x
    ip-10-0-2-234.ap-northeast-2.compute.internal   Ready    <none>   5m15s   v1.28.x
    ip-10-0-3-345.ap-northeast-2.compute.internal   Ready    <none>   5m10s   v1.28.x
    

    3. AWS Console에서 확인

    AWS Management Console(관리 콘솔)에 로그인해서 EKS 서비스로 이동하면, 방금 배포한 클러스터가 목록에 보일 거예요. 클러스터 이름을 클릭해서 상세 정보를 확인하고, 노드 그룹 탭에서 워커 노드들이 정상적으로 실행 중인지도 확인해볼 수 있습니다.

    AWS EKS 콘솔에서 배포된 클러스터와 노드 그룹의 상태를 확인하는 모습입니다.

    마무리, 그리고 다음 단계

    오늘은 Terraform AWS EKS 모듈을 활용해서 프로덕션 레디(Production-Ready) 쿠버네티스 클러스터를 배포하고 관리하는 방법을 자세히 알아봤습니다. 제가 직접 겪은 삽질 경험과 해결책도 함께 공유해드렸는데, 도움이 되셨으면 좋겠네요.

    Terraform EKS 모듈 덕분에 우리는 복잡한 EKS 인프라를 빠르고 안정적으로 구축할 수 있게 됐어요. IaC의 강력함을 다시 한번 느낄 수 있었던 경험이었죠. 처음엔 진입 장벽이 좀 있다고 느낄 수 있지만, 한번 익숙해지면 이만큼 편리한 게 없습니다. 정말 강력한 도구거든요.

    Terraform AWS EKS 모듈을 사용했을 때 얻을 수 있는 주요 이점들을 시각적으로 요약했습니다.

    다음 단계로는 이렇게 배포된 EKS 클러스터에 Ingress Controller(인그레스 컨트롤러, 외부 트래픽을 클러스터 내부 서비스로 라우팅), Cert-Manager(인증서 관리), Prometheus(프로메테우스, 모니터링 시스템) 같은 필수 애드온(Add-on)들을 Terraform으로 함께 배포하는 방법을 다뤄볼 예정이에요. 그리고 CI/CD 파이프라인(Continuous Integration/Continuous Deployment Pipeline, 지속적 통합/배포 파이프라인)과 연동해서 인프라 변경을 자동화하는 방법도 흥미로운 주제가 될 것 같습니다.

    궁금한 점이나 추가적인 삽질 경험이 있으시다면 댓글로 자유롭게 남겨주세요! 함께 고민하고 해결해나가는 게 인프라 엔지니어의 묘미 아니겠습니까? 😊

  • [AWS 배포] GitHub Actions로 S3/CloudFront 정적 웹사이트 배포 자동화 가이드

    수동 배포, 이제 그만 — GitHub Actions로 S3/CloudFront 배포 자동화하기

    혹시 이런 경험 있으신가요? 프론트엔드 코드 수정하고, 빌드하고, AWS 콘솔 열고, S3에 파일 올리고, CloudFront 캐시 무효화(Invalidation)하고… 이 반복 작업을 매번 손으로 하다가 어느 순간 ‘내가 지금 뭘 하고 있나’ 싶은 그 느낌. 저도 예전에 딱 그랬거든요.

    13년 동안 인프라 일 하면서 느낀 건데, 반복 작업은 반드시 자동화해야 합니다. 사람이 하면 언젠가는 실수가 납니다. 저도 한 번은 CloudFront 캐시 무효화 깜빡해서 사용자들이 한참 동안 옛날 버전 보고 있었던 적 있어요. 그날 이후로 배포 자동화는 선택이 아니라 필수라고 생각하게 됐습니다.

    오늘은 GitHub Actions를 활용해서 AWS S3와 CloudFront에 정적 웹사이트를 자동으로 배포하는 CI/CD 파이프라인을 처음부터 끝까지 만들어보겠습니다. React나 Vue, Next.js 정적 빌드 등 어떤 프레임워크든 적용할 수 있는 방법이에요.

    ▲ GitHub Actions → S3 업로드 → CloudFront 캐시 무효화로 이어지는 전체 배포 파이프라인 구조

    GitHub Actions AWS 배포의 핵심 개념 — CI/CD, S3, CloudFront

    일단 개념부터 간단히 정리하고 갈게요. 저도 처음엔 이 용어들이 뒤섞여서 헷갈렸거든요.

    GitHub Actions란?

    GitHub Actions는 GitHub에서 제공하는 CI/CD(지속적 통합/지속적 배포) 자동화 플랫폼입니다. 쉽게 말해서, 코드를 push하거나 PR을 올리는 등 특정 이벤트가 발생했을 때 미리 정의해둔 작업을 자동으로 실행해주는 도구예요. 빌드, 테스트, 배포까지 전부 코드로 관리할 수 있고, 무료 플랜도 꽤 넉넉해서 소규모 프로젝트엔 비용 걱정 없이 쓸 수 있습니다.

    S3 + CloudFront 조합이 왜 좋은가?

    AWS S3(Simple Storage Service)는 파일 저장소인데, 정적 웹사이트 호스팅 기능도 있어요. CloudFront는 AWS의 CDN(콘텐츠 전송 네트워크)으로, 전 세계 엣지 서버에 콘텐츠를 캐싱해서 사용자에게 빠르게 전달해줍니다. 이 둘을 조합하면 서버 없이도 빠르고 안정적인 웹사이트를 운영할 수 있어요. 저도 여러 프로젝트에서 이 구조를 썼는데, 정말 편하더라고요.

    항목 S3 단독 호스팅 S3 + CloudFront
    HTTPS 지원 ❌ (별도 설정 복잡) ✅ ACM 인증서 연동
    글로벌 속도 단일 리전 기준 CDN 엣지 캐싱으로 빠름
    커스텀 도메인 제한적 자유롭게 가능
    비용 저렴 약간 추가 (트래픽 기준)

    실제 프로덕션 환경이라면 S3 + CloudFront 조합을 강력 추천합니다. 오늘 가이드도 이 조합 기준으로 진행할게요.

    사전 준비 — AWS IAM 권한 설정부터

    GitHub Actions에서 AWS 리소스에 접근하려면 적절한 권한을 가진 IAM(Identity and Access Management) 사용자가 필요합니다. 여기서 많은 분들이 귀찮다고 AdministratorAccess 권한을 통째로 주는 경우가 있는데, 보안상 절대 권장하지 않아요. 최소 권한 원칙(Principle of Least Privilege)을 지켜야 합니다.

    IAM 정책 만들기

    AWS IAM 콘솔에서 아래 내용으로 커스텀 정책을 만들어주세요. S3 버킷 이름과 CloudFront Distribution ID는 본인 것으로 바꾸세요.

    {\n  "Version": "2012-10-17",\n  "Statement": [\n    {\n      "Effect": "Allow",\n      "Action": [\n        "s3:PutObject",\n        "s3:PutObjectAcl",\n        "s3:DeleteObject"\n      ],\n      "Resource": "arn:aws:s3:::your-bucket-name/*"\n    },\n    {\n      "Effect": "Allow",\n      "Action": [\n        "cloudfront:CreateInvalidation"\n      ],\n      "Resource": "arn:aws:cloudfront::YOUR-AWS-ACCOUNT-ID:distribution/YOUR-DISTRIBUTION-ID"\n    }\n  ]\n}
  • [클라우드 비용 관리] Terraform Cloud 비용 최적화: RUM 모델과 절감 전략

    [클라우드 비용 관리] Terraform Cloud 비용 최적화: RUM 모델과 절감 전략

    [클라우드 비용 관리] Terraform Cloud 비용 최적화: RUM 모델 이해 및 절감 전략

    안녕하세요, 13년차의 서버실 주인장입니다. 오늘은 인프라 자동화 좀 해봤다 하는 분들이라면 한 번쯤은 만나봤을 Terraform Cloud에 대한 이야기를 해볼까 합니다. 특히, "어? 이거 왜 이렇게 비용이 많이 나왔지?" 하고 고개를 갸웃하게 만드는 그 미스터리, 바로 RUM (Resource Usage Model) 모델과 그 비용을 최적화하는 전략에 대해 제 경험을 바탕으로 솔직하게 풀어보려고 합니다.

    처음 Terraform Cloud를 도입했을 때, 저는 그 편리함에 감탄했었죠. 원격 상태 관리(Remote State Management), 팀 협업, CI/CD 통합까지… IaC(Infrastructure as Code)의 생산성을 정말 한 단계 끌어올려 주더라고요. 근데 어느 날 청구서를 받아보니, 예상했던 것보다 훨씬 많은 금액에 깜짝 놀랐습니다. terraform apply 한두 번 했을 뿐인데 이게 무슨 일인가 싶었죠. 혹시 여러분도 이런 경험 없으신가요? ⚠️

    이건 바로 Terraform Cloud의 독특한 과금 방식인 RUM 모델 때문이거든요. 저도 삽질 좀 하면서 이 모델을 파고들었고, 그 결과 몇 가지 효과적인 비용 절감 전략을 찾을 수 있었습니다. 오늘은 그 노하우를 여러분과 공유해볼까 합니다. 자, 그럼 함께 Terraform Cloud 비용 청구의 비밀을 파헤쳐 볼까요?

    Terraform Cloud RUM 모델 개요: 리소스가 어떻게 비용으로 연결되는지 보여주는 다이어그램입니다.

    Terraform Cloud RUM (Resource Usage Model)이란 무엇인가요?

    Terraform Cloud의 비용 구조에서 가장 핵심적인 부분은 바로 RUM (Resource Usage Model, 리소스 사용 모델)입니다. 쉽게 말해, Terraform Cloud가 "관리하는 리소스의 개수"에 따라 비용을 청구하는 방식이에요.

    그럼 어떤 리소스가 RUM에 포함될까요? terraform state list 명령어를 실행했을 때 출력되는 모든 리소스가 RUM 카운트에 포함된다고 생각하시면 됩니다. 예를 들어, AWS EC2 인스턴스, S3 버킷, VPC, Subnet 등 resource "aws_instance" "my_server" 이런 식으로 HCL(HashiCorp Configuration Language)에 선언된 모든 것들이요. Terraform Cloud는 이런 리소스들을 워크스페이스(Workspace) 단위로 관리하고, 각 워크스페이스가 관리하는 리소스의 총합에 따라 과금하는 방식이에요.

    여기서 중요한 포인트는 💡 데이터 소스 (Data Source)나 로컬 리소스 (Local Resource)는 RUM 카운트에 포함되지 않는다는 점입니다! 즉, data "aws_ami" "latest"나 locals { ... } 블록은 아무리 많이 써도 비용에 영향을 주지 않아요. 이 점을 잘 활용하면 비용을 효과적으로 절감할 수 있거든요.

    Terraform Cloud 비용 절감 핵심 전략

    이제 본격적으로 Terraform Cloud 비용을 최적화하는 전략들을 알아볼 시간입니다. 제가 직접 써보고 효과를 본 방법들이니, 여러분 환경에도 적용해보시면 분명 도움이 될 거예요.

    1. 불필요한 워크스페이스 정리하기

    이건 정말 기본 중의 기본이자 가장 효과적인 방법입니다. 개발 초기 단계나 테스트 목적으로 만들었다가 방치된 워크스페이스, 혹은 더 이상 사용하지 않는 프로젝트의 워크스페이스가 있다면 과감히 정리해야 합니다. 각 워크스페이스는 관리하는 리소스 수에 따라 RUM 비용을 발생시키기 때문이죠. 😅

    저도 처음엔 테스트용으로 워크스페이스를 여러 개 만들었는데, 나중에 보니 수십 개의 워크스페이스가 활성 상태로 남아있더라고요. 이걸 정리했더니 월별 청구액이 꽤 많이 줄었습니다. 🎉

    워크스페이스를 정리할 때는 다음 단계를 따르세요:

    1. 상태 파일 백업: 혹시 모를 상황에 대비해 terraform state pull > my_backup.tfstate 명령어로 상태 파일을 로컬에 백업해두는 게 좋습니다.
    2. 리소스 삭제: 워크스페이스가 관리하는 실제 클라우드 리소스를 terraform destroy 명령어로 삭제합니다. 이 과정을 빼먹으면 클라우드 비용만 계속 나갑니다!
    3. 워크스페이스 삭제: Terraform Cloud UI나 API를 통해 워크스페이스를 삭제합니다.

    만약 수많은 워크스페이스를 일일이 확인하기 어렵다면, tfe-cli 같은 도구를 활용해서 스크립트로 자동화하는 것도 좋은 방법이에요.

    # 예시: 특정 태그를 가진 오래된 워크스페이스 목록 확인 (tfe-cli 예시)
    tfe workspace list --json | jq '.[] | select(.tags[] | contains("test")) | select(.updated-at < "2023-01-01T00:00:00Z")'
    
    # 삭제는 더 신중하게 접근해야 합니다.
    # tfe workspace delete [WORKSPACE_NAME]
    

    2. 리소스 설계 최적화: Data Sources 및 Locals 활용

    앞서 언급했듯이 Data Sources (데이터 소스)와 Locals (로컬 변수)는 RUM 카운트에 포함되지 않습니다. 이 점을 최대한 활용하여 resource 블록의 수를 줄이는 방향으로 설계를 최적화할 수 있어요.

    • Data Sources 활용: 이미 존재하는 리소스의 정보를 가져올 때 data "aws_vpc" "existing"처럼 데이터 소스를 사용하세요. 특히, 자주 바뀌지 않거나 다른 Terraform 스택에서 관리하는 리소스 정보를 가져올 때 유용합니다. 제가 해보니, AMI ID나 특정 보안 그룹 ID 같은 것들을 데이터 소스로 가져오면 resource 블록을 하나 줄일 수 있더라고요.
    • Locals 활용: 복잡한 표현식의 결과를 저장하거나, 여러 리소스에서 공통으로 사용되는 값을 정의할 때 locals 블록을 사용하면 코드 가독성도 높이고, 불필요한 리소스 선언을 피할 수 있어요.
    # RUM에 포함되지 않는 Data Source 예시
    data "aws_ami" "ubuntu" {
      most_recent = true
      filter {
        name   = "name"
        values = ["ubuntu/images/hvm-ssd/ubuntu-focal-20.04-amd64-server-*"]
      }
      owners = ["099720109477"]
    }
    
    # RUM에 포함되지 않는 Locals 예시
    locals {
      instance_type = "t3.micro"
      common_tags = {
        Project     = "MyService"
        Environment = "Development"
      }
    }
    
    # RUM에 포함되는 Resource 예시 (이것의 수가 과금의 기준이 됩니다)
    resource "aws_instance" "web_server" {
      ami           = data.aws_ami.ubuntu.id
      instance_type = local.instance_type
      tags          = local.common_tags
    }
    

    3. Sentinel Policy 활용하여 비용 낭비 방지

    Terraform Cloud의 Sentinel (센티넬)은 정책 기반 코드(Policy as Code)를 통해 인프라 변경 사항을 검증하는 강력한 도구입니다. 이 Sentinel을 활용하면 비용 낭비를 사전에 막을 수 있어요. 예를 들어:

    • 고가용성 리소스 제한: 특정 고가 리소스(예: 고성능 데이터베이스 인스턴스)의 생성을 제한하거나, 특정 환경(예: 개발 환경)에서는 특정 인스턴스 타입 이상을 생성하지 못하게 정책을 걸 수 있어요.
    • 리소스 개수 제한: 특정 타입의 리소스(예: EC2 인스턴스)가 한 워크스페이스 내에서 일정 개수 이상 생성되지 못하도록 막을 수 있거든요. 저도 실수로 count 값을 너무 높게 설정해서 수십 개의 인스턴스를 한 번에 배포할 뻔한 적이 있는데, Sentinel 덕분에 막을 수 있었죠. 휴~ 😮‍💨
    
    # 예시: EC2 인스턴스의 개수를 5개로 제한하는 Sentinel Policy (pseudo-code)
    
    # import "tfplan/v2" as tfplan
    
    # instance_count = length(tfplan.resource_changes as r, r.type is "aws_instance" and r.change.actions contains "create")
    
    # main = rule {
    #   instance_count <= 5
    # }
    

    실제 Sentinel 정책은 위 예시보다 더 복잡하지만, 핵심은 원하는 제약을 코드로 정의하여 terraform apply 전에 검증함으로써 불필요한 리소스 생성을 막는다는 거죠.

    Terraform Cloud 워크스페이스와 Sentinel 정책: 중앙 정책 적용으로 비용 낭비를 막는 방법을 보여줍니다.

    4. Run 실행 횟수 관리 (간접적 영향)

    RUM 모델 자체는 리소스 개수에 초점을 맞추지만, 상위 티어에서는 Run (실행) 횟수도 과금 요소가 될 수 있어요. 그리고 잦은 Run은 불필요한 리소스 변경으로 이어질 가능성을 높여 RUM 카운트에도 간접적으로 영향을 줄 수 있거든요.

    • 변경 사항 신중하게 검토: terraform plan 결과를 항상 꼼꼼하게 확인하고, 꼭 필요한 변경 사항만 apply 하세요.
    • CI/CD 파이프라인 최적화: 불필요한 트리거로 Run이 실행되지 않도록 CI/CD 파이프라인을 설계하는 게 중요합니다. 예를 들어, 모든 커밋마다 Run을 돌리기보다는, 특정 브랜치에 머지될 때만 실행되도록 설정하는 식이죠.

    비용 최적화 결과 확인하기

    위 전략들을 적용했다면, 이제 그 효과를 확인해야겠죠? Terraform Cloud는 자체적으로 Billing & Usage (청구 및 사용량) 대시보드를 제공합니다. 여기서 월별 RUM 사용량과 청구 금액을 확인할 수 있어요.

    제 경험상, 불필요한 워크스페이스를 정리하고 리소스 설계를 조금만 변경해도 눈에 띄게 RUM 카운트가 줄어드는 것을 볼 수 있었습니다. 처음엔 이게 뭔가 싶었는데, 막상 수치가 줄어드는 걸 보니 뿌듯하더라고요. ✅

    대시보드에서 Managed Resources (관리되는 리소스) 그래프를 꾸준히 모니터링하면서, 어떤 워크스페이스가 많은 리소스를 관리하고 있는지, 그리고 그 추이가 어떻게 변하는지 확인해보세요. 이걸 보면서 "아, 이 워크스페이스는 리소스가 너무 많네. 줄여야겠다" 같은 의사결정을 할 수 있거든요.

    Terraform Cloud Billing & Usage 대시보드: 비용 절감 전략 적용 후 월별 RUM 사용량이 감소하는 가상의 그래프입니다.

    마무리하며: 삽질을 줄이는 현명한 비용 관리

    오늘은 Terraform Cloud의 RUM 모델을 이해하고, 이를 바탕으로 비용을 최적화하는 여러 전략에 대해 이야기해봤습니다. 13년차 인프라 엔지니어로서 제가 직접 겪었던 "비용 폭탄" 경험과 그 해결 과정을 공유하면서, 여러분의 삽질을 조금이나마 줄여드리고 싶었어요. 💡

    핵심은 결국 "내가 무엇을 관리하고 있고, 그게 과금에 어떻게 영향을 미치는지 정확히 아는 것"이에요. Terraform Cloud는 정말 강력한 도구지만, 그만큼 현명하게 사용해야 예상치 못한 비용 문제로 당황하지 않을 수 있거든요.

    여러분도 이 글에서 소개한 전략들을 바탕으로 Terraform Cloud 비용을 최적화하고, 더 효율적인 IaC 환경을 구축하시길 바랍니다. 다음 글에서는 Terraform Cloud의 원격 실행 환경(Remote Operations)과 로컬 실행 환경(Local Operations)의 장단점을 비교해보는 시간을 가져볼게요. 기대해주세요! 😄

    Terraform Cloud 비용 절감 핵심 전략 요약: 주요 절감 팁을 한눈에 볼 수 있는 인포그래픽입니다.