CI/CD
개요
큰 규모의 프로젝트를 만든다고 하자. 많은 사람이 각자 맡은 파트의 코드를 작성한다.
그런 다음 작업이 모두 완료되었다고 하면 각자의 파일을 합친다.
당장 조별과제만 생각해봐도 굉장히 피곤한 일이다.
코드 통합시에도 다양한 충돌과 에러가 발생하고 막대한 시간이 소요된다. 또한 통합이 끝난 뒤 테스트가 시작되기 때문에 문제를 발견하면 전체 일정이 지연되었다.
그러다 1990년대 VCS(버전 관리 시스템)이 도입되기 시작했다. 대표적으론 CVS, SVN이 있다.
이러한 도구들은 개별 코드 작업을 진행하고 코드 충돌을 줄이는 데 도움을 주었지만 여전히 통합 주기가 길고 수고스러운 일이었다.
2000년대 초반 XP나 애자일과 같은 방법론의 확산으로 코드 통합을 자주하고 자동화하려는 필요성이 논의되었다.
그 결과 CruiseControl과 같은 도구가 개발되어 팀이 일정 주기로 코드를 통합하고 테스트할 수 있게 되었다.
- 빈번히 통합
- 자동화 테스트를 수행하여 코드를 배포 가능한 상태로 유지한다.
- 통합 뿐만 아니라 배포까지의 과정을 자동화 하여 빠르게 배포가 나가고 변경사항이 빠르게 적용될 수 있도록 한다.
CI는 조별과제로 예를 들면 자료조사 담당이 자료를 업로드할 때 그 자료에 대한 형식이나 사실 여부를 프로그램이 자동으로 체크해주는 것이다.
체크가 완료되지 않았다면 자료조사 담당이 다시 검토 할 것이고 그게 완료된 다음 사람은 자신의 일만 하면 된다.
코드 저장소에 코드를 올리면 자동으로 빌드가 수행된다. 테스트를 돌리고싶어도 어플리케이션이 실행가능해야 돌릴 수 있기 때문이다.
이렇게 해서 빠르게, 자동으로 코드를 병합하고 전체 코드 안전성을 보장하기 위해 필요한 것이 바로 CI다.
CD는 만약 웹 서버를 구성하고 배포하려고 한다. 그러려면 원격 공간에 빌드파일을 전달해야한다. 가장 간단하게 생각할 수 있는 방법으로는
원격 서버에 접속하여 git clone을 받고 빌드한 뒤 빌드파일을 실행하는 것이다.
현재 서비스되고있는 웹사이트들은 배포가 수시로 나간다. 그럴 때마다 이걸 매번 수동으로 할 수는 없다.
따라서 이걸 자동화하는 방법을 생각하게 된 것이다.
코드가 특정 브랜치에 푸쉬될 경우 이를 감지하여 일련의 작업이 자동으로 수행되도록 한다.
원격 서버에 접속하도록 하고, 코드를 내려받고 실행.
한마디로 설명하면…
“특정 이벤트가 발생했을 때 실행될 스크립트를 짜는 것.”
젠킨스라던지 여러 CI/CD 툴들이 있지만 가장 배우기 쉽고 많이 쓰이는 GitHub Actions에 대해 알아보자.
GitHub Actions
“GitHub Actions는 빌드, 테스트 및 배포 파이프라인을 자동화할 수 있는 플랫폼이다.”
레포지토리에 변경 내용을 푸시할 때마다 테스트를 수행할 수 있고 배포 브랜치에 합쳐진 변경 사항을 배포하는 워크 플로우를 만들 수 있다.
깃허브 액션 워크플로우는 YAML이라는 형식으로 작성되는데 이는 주로 스크립트나 명세를 만들 때 쓰이는 형식이다.
첫 스텝은 가장 메인에 .github/workflows 디렉토리에 yml 확장자의 파일을 생성하는 것으로 시작한다.
name: CI Workflow
on: [push]
jobs:
build:
runs-on: ubuntu-latest
steps:
- run: echo "Hello Actions"
구성 요소는 다음과 같다.
- 이벤트: 어떤 이벤트가 감지되면 해당하는 워크플로우를 수행한다. 위의 코드에서 on에 해당한다. 공식문서
- 머신: 하나의 워크플로우는 격리된 가상 컴퓨터에서 실행된다. 위의 코드에서 ubuntu와 같이 명시되어있는 부분은 운영체제를 로드하고 수행한다.공식문서
- 스탭: 최소 실행 단위로 실제 실행하고 싶은 작업들이 정의된다. run 구문을 보면 실제 실행하는 명령어나 절차를 정의할 수 있다.공식문서
이렇게 동작한다.
일반적으론 위와 같이 단순히 리눅스 명령어를 실행시키는 것이 아니라 실제 코드를 로드해서 테스트를 수행한다던지 하는 작업이 필요하다.
그러기 위한 액션이 actions/checkout이다.
현재 워크플로우가 실행중인 레포지토리의 코드를 가상 머신으로 복제한다.
그러니 가상 머신 내부에는 git이 설치되어있고 git clone 명령어를 이용할 것이라고 추측할 수 있다.
uses는 깃허브액션에서 제공하는 스크립트의 이름을 말한다.
steps:
- name: Checkout code
uses: actions/checkout@v4
그런 다음 만약 자바로 작성된 프로그램이라면 자바, 파이썬으로 작성된 프로그램이라면 파이썬을 로드해야 소스코드를 실행시킬 수 있다.
-name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.13'
-name: Run Python script
run: Hello-world.py
이런 식으로 사용할 수 있다.
그럼 CI에 해당하는 스크립트는 다음과 같은 작업이 필요할 것이다.
- 만든 애플리케이션 실행(실행이 가능한 상태여야 안전하게 통합이 가능하다.)
- 테스트 수행(혹시 코드 수정으로 인해 예상치 못한 동작이 일어나는 것을 머지 전에 빠르게 알 수 있다.)
이걸 자바 버전 스크립트로 짜보면…
name: Build check
on:
push:
branches:
- 'feature/**'
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up JDK 17
uses: actions/setup-java@v4
with:
java-version: 17
distribution: 'temurin'
- name: Grant execute permission for gradlew
run: chmod +x gradlew
- name: Build with Gradle
run: ./gradlew build
shell: bash
- feature 브랜치에 푸쉬될 경우 해당 스크립트가 동작한다.
- 수행하는 잡(Job)은 빌드로 우분투 환경에서 하위의 스탭을 수행한다.
- 사용하는 자바 버전을 셋팅한다. 기본 틀은 아래와 같다.
```yml
steps:
- uses: actions/checkout@v4
- name: Set up JDK 11 for x64
uses: actions/setup-java@v4
with:
java-version: ‘11’
distribution: ‘temurin’
architecture: x64
```
여기서 어떤 스크립트(uses)를 사용하느냐에 따라 종종 에러가 발생하기도 하는데
예를 들어 v1을 사용하면 오직 Azul Zulu OpenJDK만 사용할 수 있고 v1은 Azul Zulu OpenJDK, Eclipse Temurin and AdoptOpenJDK를 사용할 수 있다.
또한 v2를 사용하려면 distribution을 필수로 적어주어야한다.(JDK별 차이점은 나중에 따로 정리해보려한다.) 구체적인 내용은 공식문서를 참조.
- 그 다음으론 gradle을 실행할 수 있도록 실행 권한을 부여하는 스크립트다. 이 부분은 깃이 로컬 파일의 권한까지도 관리하기 때문인데 일반적인 경우라면 문제가 생기지 않지만 gradlew 파일을 조작하다 무언가 맞지 않는 부분이 생기면 권한 에러가 생길 수 있다고 한다. 따라서 예방적으로 넣어주는 부분이다. (구체적으로 어떤 상황에서 문제가 생기는지 보지 못해 꼭 필요한지 확실히 모르겠다.)
gradle/actions/setup-gradle@v4
을 사용하면 공식 스크립트를 사용하여 gradle 설정을 알아서 해준다. 예를 들어 gredew 사용환경 구성, 빌드 환경 구성, 캐싱 등이 있다. 캐싱은 깃허브 액션 CI/CD 시스템에서 빌드시 필요한 외부 의존성들을 매번 웹에서 다운받지 않고 이전에 다운받은 것을 재사용하여 속도적 이점을 얻기 위한 기능이다.- 빌드 해준다.
이렇게 작성한 스크립트를 루트 경로에 .github/workflows
경로를 만들고 여기에 넣어준다.
이제 기본적인 스크립트 작성법은 다뤄봤고 배포를 해볼 차례다.
배포는 위에서 말한 것처럼 단순히 원격 서버에 올리고 실행시키기만하면 된다.
그렇기 때문에 아래와 같은 다양한 방법이 존재한다.
- AWS의 코드 디플로이를 이용하여 빌드한 파일을 압축시키고 s3에 업로드한 뒤 배포하는 방법
- 원격 서버에 깃을 설치해 클론 받은 뒤 파일을 실행하는 방법
- 도커 이미지를 도커 허브에 업로드한 뒤 원격 서버에서 pull받은 뒤 컨테이너를 실행 시키는 방법
- NCP를 사용한다면 도커 이미지를 객체 스토리지(AWS에서의 s3에 해당하는)저장하여 배포하는 방법(Container Registry)
구체적인 방법은 3번째인 도커를 이용한 방법만 정리해볼 생각이다.
이렇게 보면 단순한데 조금 까다로운 부분이 있다. 바로 애플리케이션에서 민감한 API Key나 데이터베이스 접속정보등을 사용해야하는 경우다.
어떻게 해야할까? 여기도 여러가지 방법이 있다.
스프링부트에서는 이를 환경변수로 처리하여 저장소에 업로드하고 빌드 중간에 값을 넣어주는 방법이 있다. 이는 코드 디플로이와 궁합이 잘 맞는 방법으로,
microsoft/variable-substitution@v1
스크립트를 사용하면 된다. yml과 json과 같이 계층형 구조에서 spring.datasource.url과 같이 인식시키면 이 값을 내가 원하는 값으로 대체시켜준다.
env:
RESOURCE_PATH: ./src/main/resources/application.yml
...
- name: Set yml file
uses: microsoft/variable-substitution@v1
with:
files: $
env:
spring.datasource.url: $
spring.datasource.username: $
spring.datasource.password: $
이런 식으로 넣으면 된다.
주의할 점은 이렇게 하고 파일을 어딘가 보내야한다. 동적으로 환경변수를 주입시켜 말하자면 하드코딩된 yml파일을 포함하여 이 결과물을 원격 서버에 그대로 보내야한다.
(나처럼 바보같이 그 다음 스탭을 원격 서버에 접속한 뒤 깃허브에서 pull 받는 식으로 해버리면 하드코딩시킨 설정이 그대로 전송되는 것이 아니라서 아무 의미가 없다.그래서 파일을 압축시켜 그대로 전송하는 코드 디플로이와 궁합이 좋다.)
도커 배포
도커 이미지를 생성하기 위해 도커 파일을 만들어야한다.
인텔리제이의 경우 새로운 파일 만들기를 보면 도커 파일이 있다. 그대로 사용하면 된다.
Dockerfile이란 도커 이미지를 구성하기 위해 있어야 할 패키지, 의존성, 소스코드 등을 하나의 file로 기록하여 이미지화 시킬 명령 파일을 말한다.
알아야할 문법은 다음과 같다.
- FROM: 사용할 기반 이미지를 말한다.
- ARG: 빌드 시점에서 사용할 변수를 말한다.
- COPY: 로컬에 있는 파일이나 디렉토리를 Docker 이미지의 파일 시스템으로 복사하는 것을 말한다.
- ENV: 컨테이너에서 사용할 환경 변수 지정
- ENTRYPOINT: 컨테이너가 실행되었을 때 항상 실행되어야 하는 커맨드 지정
얘시 파일은 다음과 같다.
# 1단계: 빌드 스테이지
FROM eclipse-temurin:17-jdk-alpine AS build
# 도커 내부에 작업 디렉토리 생성
WORKDIR /workspace/app
# Gradle wrapper, 빌드 설정 복사
COPY gradlew .
COPY gradle gradle
COPY build.gradle .
COPY settings.gradle .
COPY src src
# 실행 권한 부여 및 빌드
RUN chmod +x gradlew && ./gradlew bootJar --no-daemon
# 2단계: 실행 스테이지
FROM eclipse-temurin:17-jre-alpine
# 임시 디렉토리 마운트 (Spring Boot 기본 설정에 맞춤)
VOLUME /tmp
# 빌드 결과 복사 (jar 이름이 고정되지 않은 경우 가장 마지막 생성된 jar를 app.jar로 복사)
COPY --from=build /workspace/app/build/libs/*.jar app.jar
# 실행 명령어 (prod profile 적용 예시)
# 쉽게 설명하면 한 칸 스페이스를 기준으로 실행될 명령어들을 늘어놓는 것이다.
# java -Dspring.profiles.active=prod -jar app.jar
ENTRYPOINT ["java", "-Dspring.profiles.active=prod", "-jar", "app.jar"]
이제 로컬에서 빌드가 정상적으로 되는지 미리 확인해보고 올리면 된다.
그 다음엔 CD 파일을 작성해보자.
필요한 작업은 크게 3가지다.
- 도커 이미지화
- 도커 허브에 업로드
- 원격 서버에 ssh로 접속하여 명령어 실행(내려받고 기존 실행중인 컨테이너 중단후 삭제하고 새로운 이미지 컨테이너화)
name: Docker CI/CD
on:
push:
branches: [ main ]
jobs:
build-and-deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
- name: Login to Docker Hub
uses: docker/login-action@v2
with:
username: $
password: $
- name: Build and push Docker image
uses: docker/build-push-action@v4
with:
context: .
push: true
tags: $/$:latest
- name: Deploy via SSH
uses: appleboy/ssh-action@master
with:
host: $
username: $
key: $
port: $
script: |
docker pull $/$:latest
docker stop $ || true
docker rm $ || true
docker run -d \
--name $ \
--env-file /root/app/.env \
-p 8080:8080 \
$/$:latest
맨 마지막 부분의 key는 ssh 연결을 위한 키로 비대칭 암호화 방식을 사용한다.
따라서 ssh 키를 서버에서 생성해야하는데 대충 ssh-keygen -t rsa -b 4096 -C "your_email@example.com"
이렇다.
이렇게 하면 공개키와 비밀키가 생성된다. 이중에 비밀키를 begin~end 문구까지 포함하여 요청시 사용하는 것이다.
위의 스크립트는 서버에서 env 파일을 사용하여 환경변수를 주입시켜주는 부분인데
예를 들어 spring.datasource.url = ${플레이스 홀더 이름}
이런식으로 작성했다면 .env 파일에는 다음과 같이 작성해야한다.
아래의 명령어를 입력하고
vi .env
다음과 같이 작성해준다.
플레이스 홀더 이름=실제 url
microsoft/variable-substitution@v1
를 사용할 때는 yml혹은 json의 계층 구조를 명시하면 그 항목에 들어가는 것과는 다르다.
이렇게 할 수도 있고 환경변수들을 secrets로 관리하고 이를 원격 서버에 보내서 리눅스 명령어를 통해 .env 파일을 만들고 그 안에 secrets에 기록한 정보들을 넣어주는 식으로 하는 것도 가능하다. 또한 docker run 명령어와 함께 secrets 환경변수들을 입력해주는 것도 가능하다.
파일을 생성하는 방식은 다음과 같은 스크립트를 작성하면 된다.
script: |
cat <<EOF > /root/app/.env
DB_USER=$
DB_PASSWORD=$
DB_URL=$
SCHEMA_NAME=$
BASE_62=$
EOF
포트폴리오용 호스팅 추천
포트폴리오는 사실 언제나 열어둘 필요가 없고 개발 기간 + 일정 기간만 필요에 따라 열어두면 된다.
CI/CD 파이프라인을 직접 구성해보고 연습해보려면 AWS나 NCP와 같은 클라우드 컴퓨팅 서비스를 사용하는 것이 좋다. 어필이 될 수 있기 때문이다.
그러나 실제 운영환경과 개발환경을 나누어 사용한다던지 프리티어의 기간이 끝났다던지 하는 경우에는 무료 호스팅을 고려해보는 것도 좋다.
프론트 무료 호스팅의 경우 대표적으로 네트리파이와 버셀이 있다.
백엔드라면 koyeb이 있다. 다른 서비스는 직접 사용해보지 않아서 잘 모르겠다.
내 경우에는 프리티어 기간이 남아있어 비용을 면제받을 수 있었지만 데이터베이스 비용이 많이나왔다.
매일 켜두니 한달에 약 14만원이 나와버렸다(OTL)
서비스 | 무료 제공 내역 | 특징 |
---|---|---|
Koyeb | PostgreSQL 지원 (직접 연결은 불가, 앱 배포 내 DB만) | 애플리케이션 배포 중심, 외부 DB 연결 제한 |
Render | PostgreSQL 256MB (Free Tier) | 외부 접근 가능, 자동 슬립 |
Supabase | PostgreSQL 500MB (Free Tier) | Firebase 대체, 실시간 기능 탑재 |
Neon | PostgreSQL 10GB까지 (Free Tier) | 서버리스 DB, cold start 발생 |
PlanetScale | MySQL 호환 (3개 브랜치/5GB 저장소) | 분산형 MySQL, schema migration 우수 |
ElephantSQL | PostgreSQL 20MB (Free Plan) | 매우 작지만 빠르게 테스트 가능 |
Railway | PostgreSQL/MySQL/Redis (Free Tier 있음) | 앱 배포 및 DB 연결 쉬움 |
나는 Neon을 사용했다. 최신 버전을 사용할 수 있기도 하고 가장 용량이 크다.
이렇게 개발단계에서 달마다 나가는 10만원 돈을 아꼈다. 해피엔딩.
참고로 오랜 시간 실제 서비스를 운영할 계획이라면 예약 인스턴스나 세이빙 플랜을 고려해볼 수 있다.
온디맨드 요금제로 운영했을 때와 비교해서 최대 70%까지 할인받을 수 있다고 하니 추천