Nginx / AWS EC2를 활용하여 React와 Node 서버 배포해보기 3탄 - CI / CD 및 CORS 이슈 해결

CI / CD란 무엇일까?

Continuous Integration은 영역별로 나눠서 개발할 때, 인터페이스 등의 충돌을 미리 발견하기 위해 매일 또는 매시간 저장소를 자동으로 빌드하는 개념이다. 

Continuous Delivery는 저장소 코드의 변경이 발생하면 개발 서버 또는 운영계에 자동으로 배포하는 작업이다.

 

CI/CD 파이프라인의 구성요소

  1. 버전 관리 시스템: 코드 저장소
    Git: 소스 코드를 관리하고 변경 사항을 추적하는 분산 버전 관리 시스템
  2. CI 서버: 코드 변경 사항을 자동으로 빌드하고 테스트하는 서버
    Jenkins: 오픈 소스 자동화 서버. (다양한 플러그인을 통해 빌드, 테스트, 배포 파이프라인을 구성할 수 있음)
    Travis CI: Github 프로젝트와 통합하여 빌드, 테스트, 배포를 자동화할 수 있는 호스팅된 CI 서비스
    Circle CI: 빠르고 쉽게 설정할 수 있는 CI/CD 플랫폼. 여러 개발 환경에서 사용 가능
    GitHub Actions: GitHub 레포지토리와 직접 통합되어 워크플로우를 자동화할 수 있는 CI/CD 서비스
  3. 빌드도구: 코드를 컴파일하고 패키징하는 도구
    npm
    yarn
  4. 테스트 프레임워크: 자동화된 테스트를 실행하는 프레임워크
    Jest
    Vitest
    Cypress
  5. 배포 도구: 애플리케이션을 다양한 환경(스테이징, 프로덕션)에 배포하는 도구 Ex) K8s, Docker, AWS CodeDeploy
    Docker: 애플리케이션을 컨테이너화하여 이식성과 일관성을 제공
    K8s: 컨테이너화된 애플리케이션의 배포, 스케일링, 운영을 자동화하는 오픈 소스 시스템
    AWS CodeDeploy: AWS에서 제공하는 배포 서비스로, EC2 인스턴스, Lambda 함수 등에 애플리케이션을 배포할 수 있음
    Vercel: Next.js를 포함한 정적 사이트, 프론트엔드 애플리케이션을 배포하는데 특화된 플랫폼

 

CI/CD 흐름

1. 개발자가 코드 변경 사항을 저장소에 푸시

2. CI 서버가 변경사항을 감지하고, 빌드 및 테스트 파이프라인을 실행

3. 테스트가 성공하면 빌드된 결과물을 스테이징/프로덕션 환경에 배포

 

1. CI / CD를 위한 yaml 파일 만들기

저장소 루트에 ./github/worksflow 폴더를 만들고 폴더 내에 deploy와 관련된 yaml 파일을 만들자.

yaml 파일 내에 다음과 같이 작성하자.

name: remote ssh command for deploy
on:
  push:
    branches: [main] # main 브랜치에 push 이벤트가 발생할 때, jobs을 실행 (내부적으로는 도커)
jobs:
  build:
    name: Build
    runs-on: ubuntu-latest
    steps:
      - name: executing remote ssh commands using key
        uses: appleboy/ssh-action@master
        with:
          host: ${{ secrets.HOST }}
          username: ${{ secrets.USERNAME }}
          key: ${{ secrets.KEY }}
          port: ${{ secrets.PORT }}
          script: |
            ./deploy.sh
  • `name`: 워크플로우의 이름. Github Actions 인터페이스에서 워크플로우를 식별하는데 사용된다.
  • `on`: 워크플로우가 언제 실행될지를 정의한다. 특정 이벤트 발생시 자동으로 워크플로우가 실행되도록 설정할 수 있음
    • `push` 
      • `branches`:  특정 브랜치에 푸시될 때 사용
    • `pull_request`: 특정 브랜치에 풀 리퀘스트가 생성되거나 업데이트될 때 워크플로우가 실행
    • `workflow_dispatch`: 수동으로 워크플로우를 실행할 수 있는 트리거. Github UI를 통해 워크플로우를 직접 실행할 수 있음
    • `schedule`: 
      • `cron`: 크론 표현식을 사용하여 일저에 따라 워크플로우를 실행할 수 있음
    • `release`: 릴리스가 생성, 업데이트될 때 실행
    • `issues`: 이슈가 생성되거나 업데이트될 때 실행
    • `push_tag`: 특정 태그에 푸시될 때 실행
  • `jobs`: 워크플로우 내에서 실행될 작업들을 정의하는 역할. 각 작업은 여러 단계(step)로 구성되며 서로 독립적으로 실행될 수 있음
    • `build`, `test`, `deploy`, ... 
      • `name`: job의 이름을 설정
      • `runs-on`: 러너(job이 실행될 환경)을 지정.
        러너는 Github에서 제공하는 호스팅된 러너(ubuntu-latest, windows-latest) 또는 자가 호스팅 러너를 사용할 수 있음
      • `steps`: 각 작업은 여러 단계로 구성될 수 있으며, 각 단계는 특정 작업(스크립트 실행, 파일 복사 등)을 수행
      • `needs`: 종속성 설정 (기본적으로 작업은 병렬로 실행됨)
      • `uses`: 특정 Github Action을 사용 (위의 예시에서는 appleboy/ssh-action)
        • `with`: 환경변수 설정
        • `script`: 실행할 스크립트를 정의 (여기서는 `./deploy.sh`라는 스크립트를 원격 서버(EC2)에서 실행하도록 설정)

위의 yaml 파일을 정리하면
"main 브랜치에 코드가 푸시될 때 트리거 되는 Github Actions 워크플로우를 정의하는 파일이며, 워크플로우는 최신 Ubuntu 환경에서 실행된다. SSH 프로토콜을 사용하여 원격서버에 접속하여 deploy.sh 스크립트를 실행하고, 이 때 SSH 접속에 필요한 호스트, 사용자 이름, 키, 포트번호는 Github Secrests에서 관리하도록 도와준다"다.

 

여기서 secrets에 해당하는 내용은 Github Action 환경 변수이므로 Github의 레포지토리로 이동하여 환경변수를 설정해주도록 하자. 

 

여기서 New repository secret을 눌러서 HOST, USERNAME, KEY, PORT에 해당하는 환경 변수를 넣어주자.

HOST는 도메인, USERNAME은 ec2-user, KEY는 AWS에서 발급받은 pem키, PORT는 22로 넣으면 된다.

 

 

2. EC2에서 deploy.sh 파일을 만들기

 

다음과 같은 명령어를 통해 EC2에 접속하고, deploy.sh 파일을 만들자

ssh -i <키의경로> <Public IP>
vi ~/deploy.sh

 

sh 파일이란?
'sh' 파일은 셸 스크립트 파일로, 셸이라고 불리는 명령어 해석기에서 실행되는 명령어들의 모음을 포함한다.

sh 파일이 해주는 역할
1. 명령어 자동화: 반복적으로 수행해야 하는 명령어들을 스크립트 파일에 넣어 자동으로 실행
2. 시스템 관리: 시스템 설정, 소프트웨어 설치 및 업데이트, 백업 등의 시스템 관리 작업을 자동화
3. 프로그램 실행: 특정 프로그램이나 프로세스를 실행하고 제어. ex) 서버를 시작하거나 종료하는 스크립트를 작성
4. 작업 스케줄링: 정해진 시간에 특정 작업을 실행 ex) 크론(cron)과 같은 작업 스케줄러와 함께 사용하여 주기적인 작업을 실행
5. 조건부 실행: 조건문을 사용하여 특정 조건에 따라 명령어를 실행
6. 변수와 함수 사용

이제 deploy.sh 파일에 다음과 같은 명령어들을 작성하도록 하자.

#!/bin/bash
source ~/.bash_profile

cd ~/git/ci-cd-study/
git pull origin main
cd fe/
npm i
npm run build
cp -rf dist/* ../be/public

cd ../be/
npm i
pm2 stop web
pm2 start bin/www --name web --update-env
sleep 2
pm2 list
  • `#!/bin/bash`: 이 스크립트가 Bash 셸에서 실행됨을 명시
  • `source ~/.bash_profile`: 현재 사용자의 Bash Profile을 로드하여 환경변수, 셸 설정등을 가져옴
  • `cd ~/git/ci-cd-study/`: 프로젝트(ci-cd-study)로 이동 (각자가 설정한 디렉토리로 이동)
  • `git pull origin main`: 원격저장소의 `main`브랜치로부터 최신 변경 사항을 가져와서 현재 디렉토리에 반영
  • `cd fe/`: 프론트엔드 디렉토리로 이동
  • `npm i`: package.json 파일에 정의된 모든 의존성 설치
  • `npm run build`: 프론트엔드 프로젝트 빌드 (최적화된 정적 파일 생성)
  • `cp -rf dist/* ../be/public`: 빌드된 정적 파일을 백엔드 프로젝트의 `public` 디렉토리에 복사. 백엔드 서버에서 정적파일을 서빙할 수 있도록 함.
  • `cd ../be/`: 백엔드 프로젝트 디렉토리로 이동
  • `npm i`: 백엔드 프로젝트의 package.json 파일에 정의된 모든 의존성 설치
  • `pm2 stop web`: 현재 실행 중인 `web` 이름의 애플리케이션 중지
  • `pm2 start bin/www --name web --update-env`: 백엔드 애플리케이션을 다시 시작. 애플리케이션 이름을 `web`으로 설정하고, 환경 변수를 업데이트함
    `bin/www`: 시작 스크립트
  • `sleep 2`: 2초 대기. 프로세스가 안정적으로 시작되도록 약간의 시간 텀을 주기 위함

이제 모든 설정을 마쳤으니 로컬 저장소의 내용을 원격 저장소로 Push해보자...! 
화면과 같이 각 액션들이 실행되고, 문제가 발생하지 않는다면 성공적으로 배포되었음을 확인할 수 있다

 

3. 로컬 CORS 오류 해결하기

 

다음과 같이 로컬의 클라이언트 앱에서 로컬 서버의 API를 호출하여 코드에 반영하도록 작성해보자

import { useEffect } from "react";
import "./App.css";

function App() {

  function getHello() {
    const $greet = document.querySelector("#greet")!;
    fetch("http://localhost:4000/api/hello")
      .then((response) => response.json())
      .then((data) => ($greet.innerHTML = JSON.stringify(data)));
  }

  useEffect(() => {
    getHello();
  }, []);

  return (
    <>
      <h1>Vite + React</h1>
      <div className="card">
        <p>
          API 호출 결과: <code id="greet"></code>
        </p>
      </div>
    </>
  );
}

export default App;

 

이렇게 코드를 작성한 후에 서버와 클라이언트를 모두 로컬 환경에서 실행하면 정상적인 화면이 나오겠구나 싶었습니다. 

그러나 정상적인 화면이 나오지 않고, 개발자 도구에서는 다음과 같은 오류가 발생한 것이죠.

CORS 란?
Cross-Origin-Resource Sharing의 약어이며, 웹 브라우저가 한 출처에서 실행 중인 웹 애플리케이션이 다른 출처의 자원에 접근할 수 있도록하는 메커니즘이다. 서버는 특정 헤더(`Access-Control-Allow-Origin`)를 사용하여 어떤 출처에서 오는 요청을 허용할지 명시하게 됨.

* 출처란? 프로토콜 + 도메인 + 포트의 조합
기본적으로 웹 브라우저는 보안상의 이유로 한 출처에서 로드된 웹 페이지가 다른 출처의 리소스에 접근하는 것을 제한함

 

먼저 이 문제를 해결하기 위해서 Backend에서 CORS 관련 라이브러리를 설치하고 이를 설정해봅시다.

npm i --save cors

 

`app.js` 파일에 다음과 같은 코드를 추가해봅시다.

var cors = require("cors"); 
app.use(cors());

 

수정 후에 클라이언트 화면을 보게 되면 다음과 같이 CORS 오류가 해결되었음을 알 수 있습니다.

API 호출 결과가 잘 나오네여!

 

그러나 실제 배포된 프로덕션에서는 이것이 제대로 실행되지 않습니다. 이를 위해서는 추가적인 처리가 필요합니다.

 

4. 프로덕션 환경에서도 CORS 오류를 해결하기 (feat. 환경변수 설정)

먼저 Frontend 프로젝트의 루트에 `.env.local` 파일을 생성하고 다음과 같이 로컬 서버를 URL을 추가한다.

VITE_API_SERVER=http://localhost:4000

 

그리고 환경변수로 설정한 URL을 api 서버로 인식하도록 fetch 코드를 다음과 같이 수정해보자.

function getHello() {
    const $greet = document.querySelector("#greet")!;
    fetch(`${import.meta.env.VITE_API_SERVER}/api/hello`)
      .then((response) => response.json())
      .then((data) => ($greet.innerHTML = JSON.stringify(data)));
  }

 

이렇게 수정을 하게 되면 로컬 환경에서는 잘 동작한다. 그러나 아직 프로덕션 환경에서는 잘 적용되지 않는데 그 이유는 서버에는 환경변수를 설정해주지 않았기 때문이다. 

그냥 배포된 환경을 보면 우리가 설정해준 환경변수가 `undefined`로 변환되어 들어가있음을 확인할 수 있다.

 

 

EC2 서버에 접속하여 환경변수를 추가해주도록 하자

cd ~/git/ci-cd-study/fe  # 프론트엔드 프로젝트로 이동하여
vi .env  # env 파일 추가(생성)

 

여기서 환경변수를 다음과 같이 세팅해주자.

#빈문자열로 설정
VITE_API_SERVER=

 

변경을 하고나서는 다음과 같은 명령어를 통해 deploy.sh 파일을 실행해주자.

~/deploy.sh

 

이렇게 설정을 완료하면 다음과 같이 API 호출 연결까지 잘 되는 것을 확인할 수 있다.