Github Actions로 Next.js 프로젝트 CI/CD 자동화, 배포까지 (Feat. AWS EC2, Docker)

리액트 딥다이브 책을 읽던 중 Github Actions 주제를 다루는 내용을 읽다가 한번 CI/CD 자동화 경험, 인프라 운영에 대한 이해도 또한 조금이라도 높이고 싶었습니다. 생각보다 험난한 과정이었고 나중에 혹여나 쓸일이 있다면 유용할듯 싶어 기록 해둡니다.
(작업은 모두 macOS 기준에서 기록했습니다)

데브옵스의 데자도 모르기에 잘못된 부분이 있다면 지적 감사 하겠습니다🫡

🔥🔥🔥🔥 테스트 하면서 약간의 과금이 발생할 것 입니다ㅠㅠ 과금을 원치 않으시면 따라하시면 안됩니다!🔥🔥🔥🔥

전체 배포 과정 개요

1️⃣ EC2 인스턴스 설정 (서버 준비)
2️⃣ 도메인 & Nginx 설정 (HTTPS & 리버스 프록시)
3️⃣ Dockerfile 작성 & GitHub Actions 설정 (CI/CD 자동화)
4️⃣ GitHub Actions → Docker Hub → EC2 자동 배포
5️⃣ 실제 도메인 연결 및 최종 확인

1. EC2 인스턴스 설정 (서버 준비)

AWS EC2: AWS에서 제공 하는 가상 서버 -> 쉽게 말해 컴퓨터 한대를 AWS에서 빌려 준다고 생각

기존의 리액트 앱은 S3 같은 정적 호스팅만으로도 충분히 배포가 가능하다고 합니다. 하지만 Next.js 같은 경우는 기본적으로 SSR이기도 하고 Docker를 사용할 것이기에 가상 서버가 필수적으로 필요합니다.

1-1. EC2 인스턴스 생성

AWS 콘솔에 접속해 인스턴스를 생성해줘야 합니다.

  • 인스턴스 시작 버튼 클릭

운영체제-> Ubuntu 선택
인스턴스 유형-> 프리티어 선택 (t2.micro)

  • 키 페어 생성

    여기서 생성한 키 페어 pem 파일이 자동으로 다운로드 되는데 꼭 안전한 파일에 잃어버리지 않도록 저장합니다.

1.2. SSH로 EC2 접속 (과금 주의)

이제 AWS가 빌려준 컴퓨터에 SSH를 통해 접속이 잘되는지 시도해보겠습니다.

기본적으로 AWS가 할당해준 퍼블릭 IP 주소가 있지만 인스턴스를 중지하고 새로 시작하면 다른 IP 주소가 할당 된다고 합니다.

그대로 진행한다면 인스턴스가 중지될때마다 설정을 다 바꿔줘야하기에, 실제 배포 환경이라고 생각하고 먼저 탄력적 IP를 할당 받도록 하겠습니다. (과금 주의)

주의: 🔥🔥탄력적 IP를 할당 받게되면 시간당 과금이 발생하니 테스트하고 꼭 인스턴스를 중지하셔야 합니다!!🔥🔥

좌측 메뉴 탄력적 IP -> 설정 그대로 생성 (지역은 인스턴스를 생성한 지역과 맞춰주셔야 합니다)

생성하게 되면 IP 주소가 할당되었다는 초록색 창이 뜨는데 탄력적 IP 주소 연결 버튼을 클릭합니다.

방금 생성한 인스턴스를 선택해주고, 프라이빗 IP 주소는 자동으로 그 인스턴스의 주소가 뜰텐데 그 주소를 선택해 줍니다.

이제 진짜 터미널 창에서 인스턴스에 접속 해보겠습니다
키 페어가 저장 된 위치에서 터미널 열기 -> ssh -i "키 페어 파일" ubuntu@방금 할당 받은 탄력적 IP 주소

접속에 성공했다면 다음 명령어를 입력해줍니다.

// 패키지 목록 업데이트 & 업그레이드
sudo apt update && sudo apt upgrade -y

// Docker 설치
sudo apt install docker.io -y

// Docker를 부팅 시 자동 실행되도록 설정
sudo systemctl enable docker

// Docker 서비스 시작
sudo systemctl start docker

완료 후 도커 버전 확인 명령어를 통해 잘 설치됬나 확인해봤습니다 -> docker --version

Docker version 26.1.3, build 26.1.3-.. 같이 나오면 성공입니다!

2. 도메인 & Nginx 설정 (HTTPS & 리버스 프록시)

2.1 도메인 구매 및 IP 연결

도메인을 구매한 뒤 인스턴스 IP를 AWS Route 53을 통해 연결해주는 작업이 필요합니다.

도메인 구매는 가비아에서 실습을 위해 단돈 만원에 구매했습니다!

구매한 도메인 연결은 해당 글에 매우 설명이 잘되있어 여기서 보고 따라 진행했습니다.

Route 53 도메인 연결

2.2 Nginx 설치 및 https 설정

다시 SSH로 인스턴스에 접속한뒤 해당 명령어를 입력 해줍니다.

// nginx 설치
sudo apt install nginx -y
sudo systemctl start nginx
sudo systemctl enable nginx

앞선 단계까지 진행이 잘 되었다면 브라우저에서 http://EC2 인스턴스 IP 접속 시 Nginx Welcome Page 같은 기본 페이지가 뜨면 성공입니다!!

✅다음으로는 https 사용을 가능하게 하기 위해 Let's Encrypt라는 ß인증서를 설치합니다. (무료)

sudo apt install certbot python3-certbot-nginx -y
  • certbot: Let's Encrypt 인증서를 발급하고 관리하는 툴
  • python3-certbot-nginx: Certbot이 Nginx 설정과 연동되도록 도와줌
sudo certbot --nginx -d example.com -d www.example.com

구매한 도메인을 example.com 대신 입력해줍니다.

3. Dockerfile & GitHub Actions 설정 (CI/CD 자동화)

3.1 Dockerfile 작성

우선 도커 이미지 빌드, 컨테이너 생성을 위해 원하는 프로젝트의 루트 디렉토리에 Dockerfile을 작성해줍니다.

✅도커 허브를 이용할것이기 때문에 도커 허브 계정이 필요합니다.
https://hub.docker.com

✅ 도커 이미지? 프로그램 실행에 필요한 모든걸 포함한 패키지 (코드, 라이브러리, 환경 설정 등을 담고 있음)

✅ 도커 컨테이너? 그 이미지를 기반으로 한 이미지를 실행한 프로그램

# 빌드 단계
FROM node:lts AS builder
 
WORKDIR /app
 
COPY package*.json ./
RUN npm install --legacy-peer-deps
 
COPY . .
RUN npm run build
 
# 실행 단계
FROM node:18-alpine
 
WORKDIR /app
 
COPY --from=builder /app/package*.json ./
COPY --from=builder /app/.next ./.next
COPY --from=builder /app/public ./public
COPY --from=builder /app/node_modules ./node_modules
 
EXPOSE 3000
CMD ["npm", "run", "start"]

기존의 리액트 프로젝트(vite, cra..)를 도커라이징 할 때는 프로젝트가 정적인 파일을 생성하기 때문에 nginx로 서빙했다면 Next.js는 기본적으로 SSR을 제공하기 때문에 Node.js 런타임 환경이 필요하다는 특징이 있는 것 같습니다.

node_modules
.next
.git
.gitignore
Dockerfile

.dockerignore 파일도 작성해 주었습니다.

3.2 GitHub Actions 설정

GitHub Actions를 이용해 코드가 main 브랜치에 푸시될 때 자동으로 EC2 서버에 배포하는 과정입니다.

우선 프로젝트 루트 경로에 다음과 같은 파일을 작성했습니다.
.github/workflows/deploy.yml (파일 이름은 자유)

name: deploy myapp
 
on:
  push:
    branches:
      - main
 
jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - name: 코드 체크아웃
        uses: actions/checkout@v3
 
      - name: Docker 이미지 빌드, 푸시
        run: |
          echo "${{ secrets.DOCKER_PASSWORD }}" | docker login -u "${{ secrets.DOCKER_USERNAME }}" --password-stdin
          docker build -t ${{ secrets.DOCKER_USERNAME }}/myapp:latest .
          docker push ${{ secrets.DOCKER_USERNAME }}/myapp:latest
 
      - name: EC2에 SSH 접속 후 배포
        uses: appleboy/ssh-action@master
        with:
          host: ${{ secrets.EC2_HOST }}
          username: ubuntu
          key: ${{ secrets.EC2_SSH_KEY }}
          script: |
            sudo docker pull ${{ secrets.DOCKER_USERNAME }}/myapp:latest
            sudo docker stop myapp || true
            sudo docker rm myapp || true
            sudo docker run -d -p 3000:3000 --name myapp ${{ secrets.DOCKER_USERNAME }}/myapp:latest

workflow 과정

name: deploy myapp

GitHub Actions에서 실행될 워크플로우의 이름

on:
  push:
    branches:
      - main

main 브랜치에 코드가 푸시될 때 실행

jobs:
  deploy:
    runs-on: ubuntu-latest

배포 작업을 실행할 환경을 ubuntu-latest로 지정 (GitHub 제공 가상 머신에서 실행)

1. 코드 체크아웃

- name: 코드 체크아웃
  uses: actions/checkout@v3

현재 저장소의 코드를 가져옴 (GitHub Actions 내에서 사용하기 위해)

2. Docker 이미지 빌드 및 도커 허브에 푸시

- name: Docker 이미지 빌드, 푸시
  run: |
    echo "${{ secrets.DOCKER_PASSWORD }}" | docker login -u "${{ secrets.DOCKER_USERNAME }}" --password-stdin
    docker build -t ${{ secrets.DOCKER_USERNAME }}/myapp:latest .
    docker push ${{ secrets.DOCKER_USERNAME }}/myapp:latest

Docker Hub 로그인

  • secrets.DOCKER_USERNAME → GitHub Secrets에 저장된 Docker Hub 아이디
  • secrets.DOCKER_PASSWORD → Docker Hub 비밀번호
  • Docker 이미지 빌드 & Docker hub에 push

✅ 여기서 Github Secrets를 설정해줘야 합니다!
GitHub → Repository → Settings → Secrets and Variables -> Actions

DOCKER_USERNAME = Docker Hub 계정 아이디
DOCKER_PASSWORD = Docker Hub 비밀번호
EC2_HOST = EC2 인스턴스에서 할당 받았던 탄력적 IP
EC2_SSH_KEY = 키 페어 파일이 저장 된 위치 -> cat '인스턴스 생성시 발급 받은 키 페어 파일' 실행 후 복사

🔥🔥여기서 키페어 파일을 복사할때 주의 할 점이 있습니다🔥🔥

-----BEGIN OPENSSH PRIVATE KEY-----
쌸라쌸라...fdfdfdfd
-----END OPENSSH PRIVATE KEY-----

구분선 안의 내용만 복사하는것이 아닌 -----BEGIN OPENSSH PRIVATE KEY----- 부터 -----END OPENSSH PRIVATE KEY----- 이부분까지 전부 복사해야 합니다!!

3. EC2 서버에 SSH 접속 후 배포

- name: EC2에 SSH 접속 후 배포
  uses: appleboy/ssh-action@master
  with:
    host: ${{ secrets.EC2_HOST }}
    username: ubuntu
    key: ${{ secrets.EC2_SSH_KEY }}
    script: |
      sudo docker pull ${{ secrets.DOCKER_USERNAME }}/myapp:latest
      sudo docker stop myapp || true
      sudo docker rm myapp || true
      sudo docker run -d -p 3000:3000 --name myapp ${{ secrets.DOCKER_USERNAME }}/myapp:latest

EC2 서버에 SSH 접속한뒤 다음과 같은 과정을 거칩니다.

✅ EC2에서 Docker 컨테이너 실행 과정

  • 최신 Docker 이미지를 도커 허브에서 pull
  • 기존 컨테이너 myapp이 실행 중이라면 stop
  • 기존 컨테이너를 rm (삭제)
  • 새로운 컨테이너를 실행 (3000번 포트에서 실행)

✅ 작성한 yml 파일에서 uses라는 명령어가 자주 보이는데 다른 사람이 만든 액션(Action) 또는 GitHub 제공 기본 액션을 사용할 때 쓰이는 키워드라고 합니다 -> 이미 만들어진 액션을 불러와서 쉽게 사용할 수 있도록 도와주는 역할!

4. Nginx를 리버스 프록시(reverse proxy) 설정

이제 거의다 왔습니다!!
마지막으로 nginx를 리버스 프록시로 사용하여 Next.js (포트 3000번) 서버로 요청을 전달하는 과정이 필요합니다.

4.1 Nginx 리버스 프록시 설정

다시 커맨드 창에서 ssh에 접속해 해당 명령어를 입력했습니다.

sudo nano /etc/nginx/sites-available/default

✅ Nginx의 기본 설정 파일(default)을 nano 에디터로 수정하는 명령어 입니다.

server {
    listen 80;
    listen [::]:80;
    server_name mywebsite.com www.mywebsite.com;
    return 301 https://mywebsite.com$request_uri; # HTTP → HTTPS 리디렉션
}

server {
    listen 443 ssl;
    listen [::]:443 ssl;
    server_name mywebsite.com www.mywebsite.com;

    ssl_certificate /etc/letsencrypt/live/mywebsite.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/mywebsite.com/privkey.pem;
    include /etc/letsencrypt/options-ssl-nginx.conf;
    ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;

    location / {
        proxy_pass http://localhost:3000;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}

https 설정시 Certbot이 자동으로 심어준 설정도 있었습니다.
주석도 많고 지저분해 자동으로 심어준 설정을 일부만 남기고 다시 작성 해주었습니다.

첫번째 server 블록 (80번 포트 -http 처리)

server {
    listen 80;
    listen [::]:80;
    server_name mywebsite.com www.mywebsite.com;
    return 301 https://mywebsite.com$request_uri; # HTTP → HTTPS 리디렉션
}

http 요청으로 접속하게 되면 https로 자동으로 리다이렉션 시켜주는 역할입니다

두번째 server 블록 (443번 포트 - https 요청 처리)

server {
    listen 443 ssl;
    listen [::]:443 ssl;
    server_name mywebsite.com www.mywebsite.com;

    ssl_certificate /etc/letsencrypt/live/mywebsite.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/mywebsite.com/privkey.pem;
    include /etc/letsencrypt/options-ssl-nginx.conf;
    ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;

    location / {
        proxy_pass http://localhost:3000; # Next.js 서버로 프록시
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}

https 포트에서 요청을 받게 되면 작동하는 부분입니다.

Let's Encrypt SSL 인증서 적용 된 부분은 남겨놓았습니다.

location / {
    proxy_pass http://localhost:3000;
    proxy_set_header Host $host;
    proxy_set_header X-Real-IP $remote_addr;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_set_header X-Forwarded-Proto $scheme;
}

이 부분은 사용자가 지정한 도메인으로 접속하면 nginx가 요청을 Next.js 서버로 전달해주는 부분입니다.

✅ 현재 AWS EC2의 인스턴스에서 실행되고 있는 도커 컨테이너가 3000번 포트에서 Next.js 서버를 실행 중
✅ 사용자가 지정한 URL로 요청하면 nginx가 해당 요청을 컨테이너로 프록시하여 접속할 수 있도록 해주는 역할을 합니다.

즉 nginx가 EC2의 "localhost:3000"을 바라보고 컨테이너로 연결해 주는 역할을 합니다

5. 최종 확인

// @/app/page.tsx
export default function Home() {
  return (
    <div>
      <h1>안녕하세요 next.js 앱입니다.</h1>
    </div>
  );
}

CNA로 세팅한 작업 내용을 메인 리포지토리에 push합니다.

✅리포지토리의 Actions 탭에 들어가면 Github Actions가 수행중인 로그를 볼수 있습니다.

오류 없이 정상적으로 모든 action들이 수행되었다면 다음과 같은 화면이 나옵니다 성공!

설정한 도메인으로 접속하게 되면 모든 과정이 자동으로 처리되어 배포한 Next.js 앱이 정상적으로 실행된 모습입니다! 굳😁

추가적인 페이지를 만들어 다른 작업을 진행해보았습니다.
✅외부 API 호출하고 데이터를 받아 오는 페이지를 만들었습니다.

// @app/todo/page.tsx
export default async function Page() {
  const res = await fetch("https://jsonplaceholder.typicode.com/todos");
  const todo = (await res.json()) as {
    userId: number;
    id: number;
    title: string;
    completed: boolean;
  }[];
 
  return (
    <h1>
      {todo.map(({ userId, id, title, completed }) => (
        <div key={id}>
          <h2>{userId}</h2>
          <h3>{title}</h3>
          <div>{completed ? "했음" : "안헀음"}</div>
        </div>
      ))}
    </h1>
  );
}

작업 한뒤 메인에 push하게 되면 다시 Github Actions가 실행됩니다.

모든 작업이 정상적으로 수행됬고 todo 페이지에 접속해봤습니다.


변동사항이 적용되었고 배포가 잘되었습니다. 개꿀~😁

만약에 이 글을 보고 따라하신 분이 있다면 꼭 인스턴스 종료해주세요!!! 안그러면 과금 계속 됩니다ㅠㅠ

_느낀점: Github Actions를 다뤄보는게 주된 내용이 될것 같았지만 세팅하는 부분이 대부분이라 많은 삽질이 있었습니다. 저는 기존에 도커 이미지 빌드, 컨테이너 실행 정도만 다룰줄 아는 수준이었는데 이런 과정을 직접 해보면서 배포와 인프라 운영에 대한 실무적인 감각을 익힐 수 있었습니다!!
_