화향 :: 블로그
GameCI를 졸업작품 프로젝트에 적용해보기 본문
0. 계기
2~3년 전쯤부터 GitHub를 사용하는 Node.js 프로젝트에 GitHub Actions를 활용하여 Push 시마다 빌드 테스트를 수행하도록 만들었었다.
매번 Push하기 이전에 직접 빌드 테스트를 할 수는 없었기에, 혹시나 Lint에서 잡아내지 못한 문제가 있다면 자동화된 빌드 테스트가 문제를 잡아낼 수 있어 유용하게 사용하였다.


Unity 프로젝트에도 이를 적용해보고 싶었다.
GitHub에는 .NET Actions 템플릿이 있지만, Unity는 많은 양의 자체 라이브러리를 사용하기 때문에 이 템플릿을 활용하기에는 무리가 있었다.
누군가는 Unity용 Actions 라이브러리를 만들었을 것 같아, 찾아보니 진짜 있었다.

1. ubuntu-latest Runner를 이용하여 빌드하기

문서 내용에 따라 라이선스 파일을 추출하고, GitHub Repository에 Secrets를 등록했다.

Actions 파일도 작성했다. 가장 저렴한 ubuntu-latest로 구동하려 하니, IL2CPP 프로젝트에서는 빌드하는 기기와 다른 OS PC를 타겟으로 빌드할 수 없다는 것을 깨달았다..
결국 빌드본 업로드를 생략하고 빌드가 되는지만 테스트하기로 결정했다.
혹시나 안드로이드나 WebGL로의 빌드를 계획하고 있다면, 그런 경우는 ubuntu-latest에서도 IL2CPP 빌드가 가능하니 업로드까지 구현해봐도 괜찮을 것 같다.
name: Unity Linux IL2CPP Build
on:
push:
branches: [ "main" ] # main 브랜치에 푸시될 때마다 실행
pull_request:
branches: [ "main" ] # main 브랜치로의 PR이 생성될 때마다 실행
workflow_dispatch: # 수동 실행 버튼 활성화
jobs:
build:
name: Build for Linux IL2CPP
runs-on: ubuntu-latest
steps:
- name: Checkout Repository
uses: actions/checkout@v4
with:
lfs: true # Git LFS 파일도 체크아웃
- name: Cache Library
uses: actions/cache@v3
with: # 캐시 설정(파일이 변경되지 않는 한 Library와 같은 캐시 폴더 재사용)
path: Library
key: Library-Linux-${{ hashFiles('Assets/**', 'Packages/**', 'ProjectSettings/**') }}
restore-keys: |
Library-Linux-
Library-
- name: Unity Build
uses: game-ci/unity-builder@v4
env:
UNITY_LICENSE: ${{ secrets.UNITY_LICENSE }}
UNITY_EMAIL: ${{ secrets.UNITY_EMAIL }}
UNITY_PASSWORD: ${{ secrets.UNITY_PASSWORD }}
with:
targetPlatform: StandaloneLinux64 # 빌드 타겟 설정
unityVersion: 6000.0.67f1 # Unity 버전 설정
2. Self-hosted Windows Runner로 이전하기
열심히 세팅하고 빌드를 돌려보니, 문제가 생겼다.

빌드가 너무 오래 걸린다.
프로젝트 세팅하고 아직 아무것도 안해서 프로젝트도 URP 3D 템플릿 그대로인데, 이대로면 나중에는 3시간동안 빌드하게 생겼다.
결국, 본가에 놀고있는 Windows 게이밍 노트북을 활용하기로 결정했다.
GameCI는 Docker 기반이기에, 노트북에 있는 Docker Desktop을 점검하고 self-hosted로 실행시켜봤다.
그런데..

얘가 계속 이상한 경로를 참조한다..
어짜피 노트북에 백업으로 Unity와 Git 등 필요한 프로그램은 다 설치해놨어서, 커맨드라인을 활용해 직접 빌드하는 방식을 사용하기로 결정했다.
Actions 파일을 수정하는 김에 GitHub Artifacts에 빌드파일 업로드도 구현해보려 했다.

근데 GitHub Artifacts의 기본 용량이 너무 적었다.
GitHub Student Developer Pack을 가지고 있어서 2GB까지 사용이 가능했지만, 그래도 너무 적은 용량이었다.
그래서 결국 Artifacts 업로드를 제외하고 Windows IL2CPP 빌드만 테스트하도록 Gemini 도움을 받아 수정했다.
혹시나 노트북이 사용 불가할 때를 대비해, ubuntu-latest 빌드도 남겨두었다.
name: Unity Build (Optimized Fallback)
on:
pull_request:
branches: [ "main" ]
types: [ opened, synchronize, reopened ]
jobs:
build-windows-self-hosted:
name: Build for Windows (Self-hosted Direct)
runs-on: [self-hosted, windows, x64]
timeout-minutes: 120 # 2시간동안 완료하지 못하면 타임아웃을 시킨다. (Self-hosted가 꺼져있을 때를 대비)
continue-on-error: true
steps:
- name: Checkout Repository
uses: actions/checkout@v4
with:
lfs: true
- name: Cache Library
uses: actions/cache@v4
with:
path: Library
key: Library-Windows-${{ hashFiles('Assets/**', 'Packages/**', 'ProjectSettings/**') }}
restore-keys: |
Library-Windows-
Library-
- name: Unity Build (Direct Command Line)
# Unity 경로는 본인 환경에 맞는 경로로 바꿔준다.
run: |
$UnityPath = "C:\Program Files\Unity\Hub\Editor\6000.0.67f1\Editor\Unity.exe"
& $UnityPath -batchmode -quit -nographics -projectPath "${{ github.workspace }}" -buildWindows64Player "build\StandaloneWindows64\Build.exe" -logFile - | Write-Output
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
build-linux-fallback: # Windows Self-hosted 빌드가 실패할 경우에만 실행되는 Linux 빌드
name: Build for Linux (Ubuntu Fallback)
needs: [build-windows-self-hosted]
if: always() && needs.build-windows-self-hosted.result != 'success'
runs-on: ubuntu-latest
steps:
- name: Checkout Repository
uses: actions/checkout@v4
with:
lfs: true
- name: Cache Library
uses: actions/cache@v4
with:
path: Library
key: Library-Linux-${{ hashFiles('Assets/**', 'Packages/**', 'ProjectSettings/**') }}
restore-keys: |
Library-Linux-
Library-
- name: Unity Build (Linux)
uses: game-ci/unity-builder@v4
env:
UNITY_LICENSE: ${{ secrets.UNITY_LICENSE }}
UNITY_EMAIL: ${{ secrets.UNITY_EMAIL }}
UNITY_PASSWORD: ${{ secrets.UNITY_PASSWORD }}
with:
targetPlatform: StandaloneLinux64
unityVersion: 6000.0.67f1
versioning: None # 이러면 프로젝트 세팅에서 설정한 버전으로 빌드한다.
일단 잘 동작한다. 이대로 당분간 사용하기로 결정했다.
3. IaaS로 이전하고, 빌드본을 구글 드라이브에 업로드하기
또 문제가 생겼다.

빌드 작업이 몰리니 소요 시간이 말도 안되게 불어났다.
그래서 원인을 파악하기 위해, 아무 작업이나 하나 열어봤다.


본가 인터넷이 100Mbps 회선이다.
Unity 프로젝트 캐시는 기본 GB 단위이기 때문에, 캐시 업로드에만 한참이 걸렸던 것이다.
물론 캐시 업/다운로드를 비활성화 하면 되지만, 나중에 프로젝트 용량이 커지면 동일한 문제가 발생할 것이다.
결국, IaaS로의 이전을 결정하게 되었다.
IaaS로 이전하기 위해, 다음과 같은 조건을 검토했다.
1. CPU 2코어 이상 (1코어에서 윈도우가 너무 버벅거림)
2. RAM 8GB 이상 (Unity 최소 실행요건)
3. Custom ISO 마운트가 가능해야 함. (Windows 수동설치)
4. SSD 128GB 이상
5. 되도록 1만원/월 이하의 요금 (학생이라 돈이 없다)
찾다가, netcup이라는 업체의 VPS를 찾았다.

유럽권 리전에 VPS를 설치할 때 이렇게 저렴한 요금이 나오는 업체였다.
어짜피 빌드 테스트용으로만 사용할것이라, VPS 리전보다는 대역폭과 트래픽 한도가 더 중요했기에 netcup으로 결정했다.

선결제 구조라 한달치를 결제하고, 윈도우를 직접 설치했다.
과거에 다른 IaaS 사용할 때도 윈도우 수동설치를 해봤는데, 다시해도 너무 복잡했다. 이렇게 특수한 상황이 아니고서는 그냥 기본 탑재 OS를 사용하고 싶을 정도로.
IaaS로 이전하는 김에 VPS를 최대한 활용하고 싶었다.
그래서, 빌드본도 업로드하게 수정하여 팀원들이 게임 개발에만 집중하게 만들고 싶었다. (사실 이게 CI/CD의 목적이기도 하고)
하지만 GitHub Artifacts의 용량 제한은 너무 적다. 돈을 내자니 돈이 없다.
클라우드 스토리지에 업로드 하는 방안이 없을까 싶어 찾아봤다.
물론 방법이 있었다. 내가 고민하는 것들은 다른 사람들도 고민하나보다.
마침 개발팀에서 공용으로 사용하는 구글드라이브 폴더가 있었기에, 거기에 업로드를 하기로 결정했다.
일단 업로드할 폴더의 ID를 추출해내고, VPS에 rclone을 설치 및 구글 계정으로 로그인했다.
(또) Gemini의 도움을 받아 Actions 파일을 수정했다.
name: Unity Build and Upload
on:
push:
tags: # 태그 푸시 시에도 빌드 및 업로드 하도록 수정했다. 보통 태그를 푸시할 때는 빌드본이 필요한 경우이기 때문이다.
- '*'
pull_request:
branches: [ "main" ]
types: [ opened, synchronize ]
jobs:
build-and-upload:
name: Build for Windows & Upload to GDrive
runs-on: [self-hosted, windows, x64]
timeout-minutes: 120
steps:
- name: Checkout Repository
uses: actions/checkout@v4
with:
lfs: true
- name: Cache Library
uses: actions/cache@v4
with:
path: Library
key: Library-Windows-${{ hashFiles('Assets/**', 'Packages/**', 'ProjectSettings/**') }}
restore-keys: |
Library-Windows-
Library-
- name: Extract Project Name
id: extract # 빌드 설정에서 프로젝트명을 빼내어 Unity 빌드 시 exe 파일명과 zip 파일명에 활용한다.
run: |
$productName = "Build"
if (Test-Path "ProjectSettings\ProjectSettings.asset") {
$line = Get-Content "ProjectSettings\ProjectSettings.asset" | Select-String -Pattern "^\s*productName:\s*(.*)$"
if ($line) {
$productName = $line.Matches[0].Groups[1].Value.Trim()
}
}
# Windows 파일명에서 금지된 문자 제거
$productName = $productName -replace '[<>:"/\\|?*]', '_'
Add-Content -Path $env:GITHUB_ENV -Value "PRODUCT_NAME=$productName"
Write-Output "Extracted product name: $productName"
- name: Unity Build (Direct Command Line)
run: |
$UnityPath = "C:\Program Files\Unity\Hub\Editor\6000.0.67f1\Editor\Unity.exe"
$ExeName = "$env:PRODUCT_NAME.exe"
& $UnityPath -batchmode -quit -nographics -projectPath "${{ github.workspace }}" -buildWindows64Player "build\StandaloneWindows64\$ExeName" -logFile - | Write-Output
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
- name: Determine Zip Name # 상황에 따라 적합한 파일명을 생성한다.
run: |
$eventName = "${{ github.event_name }}"
if ($eventName -eq "pull_request") {
$prNumber = "${{ github.event.pull_request.number }}"
$sha = "${{ github.event.pull_request.head.sha }}"
$shortSha = if ($sha.Length -ge 7) { $sha.Substring(0, 7) } else { $sha }
$zipFile = "${env:PRODUCT_NAME}-windows-pr${prNumber}-${shortSha}.zip"
} elseif ($eventName -eq "push" -and "${{ github.ref }}" -like "refs/tags/*") {
$tag = "${{ github.ref_name }}"
$zipFile = "${env:PRODUCT_NAME}-windows-tag-${tag}.zip"
} else {
$sha = "${{ github.sha }}"
$shortSha = if ($sha.Length -ge 7) { $sha.Substring(0, 7) } else { $sha }
$zipFile = "${env:PRODUCT_NAME}-windows-${shortSha}.zip"
}
# 정규식으로 파일명 금지 문자 최종 제거
$zipFile = $zipFile -replace '[<>:"/\\|?*]', '_'
Add-Content -Path $env:GITHUB_ENV -Value "ZIP_NAME=$zipFile"
Write-Output "Generated Zip Name: $zipFile"
- name: Compress Build Output # 불필요한 파일을 제거하고 빌드본을 압축한다.
run: |
$BuildDir = "build\StandaloneWindows64"
$ZipName = $env:ZIP_NAME
if (Test-Path $BuildDir) {
Write-Output "Removing DoNotShip folders..."
# 유니티 빌드 후 배포 시 제외해야 하는 폴더 삭제 (ex: *_BurstDebugInformation_DoNotShip, *_BackUpThisFolder_ButDontShipItWithYourGame)
Get-ChildItem -Path $BuildDir -Directory | Where-Object { $_.Name -match "DoNotShip|ButDontShipItWithYourGame" } | Remove-Item -Recurse -Force
Write-Output "Compressing $BuildDir to $ZipName..."
Compress-Archive -Path "$BuildDir\*" -DestinationPath $ZipName -Force
Write-Output "Compression finished: $ZipName"
} else {
Write-Error "Build directory ($BuildDir) not found!"
exit 1
}
- name: Setup Rclone # Actions Runner가 서비스 계정으로 실행되면 rclone이 PATH에 없을 수 있으므로, 자동으로 다운로드하여 사용할 수 있도록 한다. (rclone 버전 고정)
run: |
# 서비스 계정으로 동작할 때를 대비하여 rclone이 PATH에 없는 경우 자동 다운로드 및 구성
if (Get-Command "rclone" -ErrorAction SilentlyContinue) {
Write-Output "rclone is already in PATH."
} else {
Write-Output "rclone not found. Downloading standalone version..."
# 특정 rclone 버전 고정
$RcloneVersion = "1.65.2"
$RcloneBaseUrl = "https://downloads.rclone.org/v$RcloneVersion"
$RcloneZip = "rclone-v$RcloneVersion-windows-amd64.zip"
$RcloneZipUrl = "$RcloneBaseUrl/$RcloneZip"
Write-Output "Downloading rclone $RcloneVersion from $RcloneZipUrl"
Invoke-WebRequest -Uri $RcloneZipUrl -OutFile "rclone.zip"
Expand-Archive -Path "rclone.zip" -DestinationPath "rclone_bin" -Force
$RcloneDir = (Get-ChildItem -Path "rclone_bin" -Directory -Filter "rclone-*-windows-amd64").FullName
if ($RcloneDir) {
Add-Content -Path $env:GITHUB_PATH -Value $RcloneDir
Write-Output "Added $RcloneDir to GITHUB_PATH."
} else {
Write-Error "Failed to extract standalone rclone."
exit 1
}
}
- name: Upload to Google Drive via Rclone # Rclone을 사용하여 Google Drive에 파일을 업로드한다.
env:
RCLONE_CONF_CONTENT: ${{ secrets.RCLONE_CONF }}
RCLONE_CONFIG_GDRIVE_TYPE: drive
RCLONE_CONFIG_GDRIVE_ROOT_FOLDER_ID: ${{ secrets.GDRIVE_FOLDER_ID }}
run: |
# 1. OAuth 인증 정보가 담긴 설정 파일(rclone.conf) 생성
# (줄바꿈이 정상적으로 유지되도록 PowerShell의 Set-Content를 사용합니다.)
$Env:RCLONE_CONF_CONTENT | Set-Content -Path "rclone.conf" -NoNewline -Encoding utf8
try {
$ZipName = $env:ZIP_NAME
Write-Output "Uploading $ZipName to Google Drive..."
# 2. Rclone을 이용한 대용량 파일 업로드 처리
rclone copy $ZipName gdrive: --config rclone.conf --retries 5 --retries-sleep 10s --timeout 15m -v
$RcloneExitCode = $LASTEXITCODE
if ($RcloneExitCode -eq 0) {
Write-Output "======================================"
Write-Output "✅ Upload completed successfully!"
Write-Output "======================================"
} else {
Write-Error "❌ Upload failed with exit code $RcloneExitCode"
exit $RcloneExitCode
}
} finally {
# 3. 예외 발생 시에도 설정 파일 안전하게 삭제
if (Test-Path "rclone.conf") {
Remove-Item -Path "rclone.conf" -Force
Write-Output "Cleaned up rclone.conf in finally block."
}
}
- name: Fallback Cleanup Secrets
if: always()
run: |
if (Test-Path "rclone.conf") {
Remove-Item -Path "rclone.conf" -Force
Write-Output "Cleaned up rclone.conf in fallback step."
}
이제 진짜로 마지막 테스트를 돌려보자.




이제 진짜 다 작업했다.
PR 또는 태그 푸시가 발생하면 자동으로 빌드 및 구글 드라이브에 결과물 업로드를 하게 만들었다.
이로써 PR 병합 전에 빌드에 문제가 없는지 확인할 수 있고, 중간 빌드본이 필요할 때도 구글 드라이브에서 바로 찾아 공유할 수 있다.
4. 마치며
새로운 기술을 배우는 것은 언제나 재밌다. 내가 몰랐던 것들도 알게 되고, 이렇게 두고두고 사용할 수 있는 기술들은 배우는 보람도 있다.
그만큼 시간이 많이 소요되긴 했다. 1~3단계 과정에만 누적 9시간은 넘게 투자했다.
GameCI를 완전히 이해하기 위해 시간을 사용한 것도 있고, 중간중간 문제를 해결하지 못해 1시간 넘게 머리를 싸매다가 다음날 다시하기도 했다.
하지만 생각해보면, 이전까지의 프로젝트에서 빌드에 사용한 시간이 결코 적지 않았다.
빌드 한 번에 5분은 거뜬히 넘겼고, 주차마자 빌드본을 공유했다.
빌드 도중에는 다른 일을 하기도 어려워서 항상 유튜브나 보며 20분 이상을 허비했다.
비록 이 환경을 구축하는 데에 9시간이라는 긴 시간을 사용했지만, 이 환경을 구축하지 않았을 때의 허비될 시간을 생각하면 의미있는 투자라고 생각한다.
'DevOps와 CI/CD라는 개념이 결코 가볍지 않구나'라는 생각이 들었다.
