본문 바로가기

공부/유니티

[Unity] 빌드 자동화 툴 만들기

상황

운영 중인 유니티 프로젝트가 수십 개에 달하다 보니, 맵 리소스나 공통 기능 업데이트 한 번 하기가 너무 고역이었다.

각 프로젝트를 일일이 열어 맵을 덮어씌우고, 빌드 설정을 확인한 뒤 빌드 버튼을 누르는 과정을 80번 가까이 반복해야 했기 때문이다.

이 단순 반복 작업에 하루의 절반을 뺏기는 느낌이 들어, 퇴근 직전에 빌드를 걸어두고 다음 날 아침에 결과물만 확인하는 '자동화 파이프라인'을 구축하기로 결심했다.


원인 (왜 자동화가 필요한가)

수동 빌드의 가장 큰 적은 '시간 낭비'와 '휴먼 에러'다. 프로젝트 하나 빌드될 때까지 멍하니 모니터를 바라보고 있다가, 다음 프로젝트로 넘어가는 타이밍을 놓치면 퇴근 시간이 한없이 밀린다. 게다가 수십 번 같은 작업을 하다 보면, 사람인지라 프로젝트 하나쯤 빌드 설정을 빼먹거나 버전을 갱신하지 않는 실수가 꼭 발생한다.

특히 프로젝트마다 에디터 버전이 다르고 키스토어 관리가 복잡해, 이를 일관성 있게 처리할 수 있는 중앙 관리 툴이 절실했다.


해결 방법 (파이썬 기반 빌드 매니저)

파이썬의 GUI 라이브러리인 Tkinter를 사용하여 빌드 매니저 앱을 제작했다. 이 툴의 핵심 기능은 다음과 같다.

  • 자동 버전 파싱: 프로젝트 내 ProjectVersion.txt를 읽어 에디터 버전을 자동 매칭한다.
  • 스마트 빌드업: 현재 번들 코드를 자동으로 +1 증가시켜 버전을 관리한다.
  • 백그라운드 빌드: 유니티를 GUI 모드가 아닌 -batchmode로 실행해 리소스를 절약하고 자동으로 빌드를 수행한다.
  • 키스토어 연동: 텍스트 파일로 비밀번호를 관리하여 빌드 시 자동으로 암호를 입력한다.

 

1. C# 빌드 수신기

유니티 프로젝트의 Assets/Editor/ 폴더 안에 아래 스크립트를 넣어 외부 명령을 받을 수 있게 한다.

using UnityEditor;
using UnityEngine;
using System;
using System.Collections.Generic;

public class AutoBuilder
{
    private const string KEYSTORE_NAME = "KLP.keystore";
    private const string KEYALIAS_NAME = "klp";
    private const string KEYSTORE_PASS = "maca1234";
    private const string KEYALIAS_PASS = "maca1234";

    public static void PerformAndroidBuild()
    {
        // 파이썬이 던진 명령어 받아오기
        string[] args = Environment.GetCommandLineArgs();

        bool isAAB = true;
        string outputPath = "Builds/AutoBuild_Release";

        for (int i = 0; i < args.Length; i++)
        {
            if (args[i] == "-buildFormat" && i + 1 < args.Length)
            {
                isAAB = (args[i + 1] == "AAB");
            }
            else if (args[i] == "-outputPath" && i + 1 < args.Length)
            {
                outputPath = args[i + 1];
            }
        }

        // AAB / APK 포맷 설정
        EditorUserBuildSettings.buildAppBundle = isAAB;
        string extension = isAAB ? ".aab" : ".apk";

        // 키스토어 비밀번호 적용
        PlayerSettings.Android.useCustomKeystore = true;
        PlayerSettings.Android.keystoreName = KEYSTORE_NAME;
        PlayerSettings.Android.keyaliasName = KEYALIAS_NAME;
        PlayerSettings.Android.keystorePass = KEYSTORE_PASS;
        PlayerSettings.Android.keyaliasPass = KEYALIAS_PASS;

        // Build Settings에 활성화되어 있는 씬들만 긁어오기
        string[] scenes = GetEnabledScenes();

        // 빌드 설정 및 저장 경로
        BuildPlayerOptions options = new BuildPlayerOptions
        {
            scenes = scenes,
            locationPathName = outputPath + extension,
            target = BuildTarget.Android,
            options = BuildOptions.None
        };

        // 빌드 시작
        BuildPipeline.BuildPlayer(options);
    }

    private static string[] GetEnabledScenes()
    {
        List<string> scenes = new List<string>();
        foreach (var scene in EditorBuildSettings.scenes)
        {
            if (scene.enabled) scenes.Add(scene.path);
        }
        return scenes.ToArray();
    }
}

 

2. 파이썬 빌드 매니저

파이썬으로 만든 GUI 툴은 각 프로젝트의 상태를 보여주고 체크박스로 빌드할 대상을 선택하게 한다. subprocess를 사용해 유니티 에디터를 호출한다.

import os
import re
import tkinter as tk
from tkinter import ttk, messagebox, filedialog
import threading
import time
import subprocess

BG_MAIN = "#F4F6F9"
BG_CARD = "#FFFFFF"
COLOR_PRIMARY = "#3B82F6"
COLOR_SUCCESS = "#10B981"
COLOR_TEXT = "#374151"
COLOR_BORDER = "#E5E7EB"
COLOR_HIGHLIGHT = "#DBEAFE"

class BuildManagerApp:
    def __init__(self, root):
        self.root = root
        self.root.title("자동 배치 빌드 매니저")
        self.root.geometry("1100x630") 
        self.root.configure(bg=BG_MAIN)
        
        self.style = ttk.Style()
        self.style.theme_use("clam")
        self.style.configure("TCheckbutton", background=BG_CARD)
        self.style.configure("TFrame", background=BG_CARD)
        
        self.workspace_path = ""
        self.unity_hub_path = r"C:\Program Files\Unity\Hub\Editor" 
        self.project_rows = []
        
        font_main = ("Malgun Gothic", 10)
        font_bold = ("Malgun Gothic", 10, "bold")

        # 상단 컨트롤 패널
        top_card = tk.Frame(root, bg=BG_CARD, padx=15, pady=15, highlightbackground=COLOR_BORDER, highlightthickness=1)
        top_card.pack(fill="x", padx=20, pady=(20, 10))
        
        # 유니티 허브 폴더 선택 줄
        frame_unity = tk.Frame(top_card, bg=BG_CARD)
        frame_unity.pack(fill="x", pady=(0, 5))
        tk.Button(frame_unity, text="🎯 Unity Hub 폴더 지정", font=font_bold, bg="#E5E7EB", fg=COLOR_TEXT, relief="flat", padx=10, pady=5, cursor="hand2", command=self.select_hub).pack(side="left")
        self.lbl_unity = tk.Label(frame_unity, text=self.unity_hub_path, font=font_main, bg=BG_CARD, fg=COLOR_PRIMARY)
        self.lbl_unity.pack(side="left", padx=15)
        
        # 작업 폴더 선택 줄
        frame_workspace = tk.Frame(top_card, bg=BG_CARD)
        frame_workspace.pack(fill="x", pady=(5, 0))
        tk.Button(frame_workspace, text="📁 최상위 작업 폴더", font=font_bold, bg="#E5E7EB", fg=COLOR_TEXT, relief="flat", padx=10, pady=5, cursor="hand2", command=self.select_folder).pack(side="left")
        self.lbl_path = tk.Label(frame_workspace, text="선택된 작업 폴더가 없습니다.", font=font_main, bg=BG_CARD, fg="#9CA3AF")
        self.lbl_path.pack(side="left", padx=15)
        
        # 우측 버튼들
        tk.Button(frame_workspace, text="🔍 프로젝트 스캔", font=font_bold, bg=COLOR_PRIMARY, fg="white", relief="flat", padx=15, pady=5, cursor="hand2", command=self.load_projects).pack(side="right")
        
        self.build_format = tk.StringVar(value="AAB")
        ttk.Radiobutton(frame_workspace, text="테스트용 (APK)", variable=self.build_format, value="APK").pack(side="right", padx=10)
        ttk.Radiobutton(frame_workspace, text="스토어용 (AAB)", variable=self.build_format, value="AAB").pack(side="right", padx=10)

        # ==========================================
        
        # 메인 리스트 패널
        list_card = tk.Frame(root, bg=BG_CARD, padx=15, pady=10, highlightbackground=COLOR_BORDER, highlightthickness=1)
        list_card.pack(fill="both", expand=True, padx=20, pady=5)
        
        toolbar_frame = tk.Frame(list_card, bg=BG_CARD)
        toolbar_frame.pack(fill="x", pady=(0, 10))
        tk.Button(toolbar_frame, text="☑ 전체 선택/해제", font=font_main, bg="#F3F4F6", relief="flat", cursor="hand2", command=self.toggle_all).pack(side="left")

        self.canvas = tk.Canvas(list_card, bg=BG_CARD, highlightthickness=0)
        scrollbar = ttk.Scrollbar(list_card, orient="vertical", command=self.canvas.yview)
        self.scrollable_frame = tk.Frame(self.canvas, bg=BG_CARD)
        
        self.scrollable_frame.bind("<Configure>", lambda e: self.canvas.configure(scrollregion=self.canvas.bbox("all")))
        self.canvas.create_window((0, 0), window=self.scrollable_frame, anchor="nw")
        self.canvas.configure(yscrollcommand=scrollbar.set)
        
        self.canvas.pack(side="left", fill="both", expand=True)
        scrollbar.pack(side="right", fill="y")
        
        # 헤더에 Unity Ver 추가 및 너비 재조정
        headers = ["선택", "Project Name", "Unity Ver", "Current Version", "Current Bundle", "Target Version", "Target Bundle", "Status"]
        widths = [5, 23, 12, 13, 14, 13, 14, 16] 
        
        for i, h in enumerate(headers):
            lbl = tk.Label(self.scrollable_frame, text=h, font=font_bold, bg="#F3F4F6", fg=COLOR_TEXT, width=widths[i], anchor="center", pady=8)
            lbl.grid(row=0, column=i, sticky="nsew", padx=1, pady=1)

        # ==========================================
        
        # 하단 빌드 실행 패널
        bottom_frame = tk.Frame(root, bg=BG_MAIN)
        bottom_frame.pack(fill="x", padx=20, pady=(10, 20))
        
        tk.Button(bottom_frame, text="🚀 체크된 프로젝트 빌드 시작", font=("Malgun Gothic", 12, "bold"), bg=COLOR_SUCCESS, fg="white", relief="flat", padx=20, pady=10, cursor="hand2", command=self.start_build_process).pack(side="right")

    def select_hub(self):
        folder_selected = filedialog.askdirectory(title="Unity Hub Editor 폴더 선택")
        if folder_selected:
            self.unity_hub_path = folder_selected
            self.lbl_unity.config(text=self.unity_hub_path, fg=COLOR_PRIMARY)

    def select_folder(self):
        folder_selected = filedialog.askdirectory(title="유니티 프로젝트들이 모여있는 최상위 폴더 선택")
        if folder_selected:
            self.workspace_path = folder_selected
            self.lbl_path.config(text=self.workspace_path, fg=COLOR_TEXT)

    def parse_unity_data(self, proj_path):
        cur_ver, cur_bundle, editor_ver = "1.0", "1", "Unknown"
        
        # 앱 버전 및 번들 추출
        settings_path = os.path.join(proj_path, "ProjectSettings", "ProjectSettings.asset")
        try:
            with open(settings_path, 'r', encoding='utf-8') as f:
                content = f.read()
                ver_match = re.search(r'bundleVersion:\s*(.*)', content)
                bundle_match = re.search(r'AndroidBundleVersionCode:\s*(\d+)', content)
                if ver_match: cur_ver = ver_match.group(1).strip()
                if bundle_match: cur_bundle = bundle_match.group(1).strip()
        except Exception: pass
        
        # 에디터 버전 추출
        version_path = os.path.join(proj_path, "ProjectSettings", "ProjectVersion.txt")
        try:
            with open(version_path, 'r', encoding='utf-8') as f:
                content = f.read()
                editor_match = re.search(r'm_EditorVersion:\s*(.*)', content)
                if editor_match: editor_ver = editor_match.group(1).strip()
        except Exception: pass
        
        return cur_ver, cur_bundle, editor_ver

    def load_projects(self):
        if not self.workspace_path:
            messagebox.showwarning("경고", "최상위 작업 폴더를 선택해주세요!")
            return

        for widget in self.scrollable_frame.winfo_children():
            if int(widget.grid_info()["row"]) > 0:
                widget.destroy()
        self.project_rows.clear()

        project_dirs = [d for d in os.listdir(self.workspace_path) if os.path.isdir(os.path.join(self.workspace_path, d))]
        
        row_idx = 1
        for proj_name in project_dirs:
            proj_path = os.path.join(self.workspace_path, proj_name)
            
            # ProjectSettings.asset 파일이 있어야 유니티 프로젝트로 간주
            if os.path.exists(os.path.join(proj_path, "ProjectSettings", "ProjectSettings.asset")):
                
                # 데이터 파싱
                cur_ver, cur_bundle, editor_ver = self.parse_unity_data(proj_path)
                try: target_bundle_default = str(int(cur_bundle) + 1)
                except: target_bundle_default = "1"
                
                unity_exe = os.path.join(self.unity_hub_path, editor_ver, "Editor", "Unity.exe")
                has_unity = os.path.exists(unity_exe)
                
                row_bg = BG_CARD if row_idx % 2 != 0 else "#F9FAFB"
                
                chk_var = tk.BooleanVar(value=False)
                chk = tk.Checkbutton(self.scrollable_frame, variable=chk_var, bg=row_bg, activebackground=row_bg, cursor="hand2")
                chk.grid(row=row_idx, column=0, sticky="nsew")
                
                lbl_name = tk.Label(self.scrollable_frame, text=proj_name, bg=row_bg, anchor="w", padx=10, cursor="hand2")
                lbl_name.grid(row=row_idx, column=1, sticky="nsew")
                
                ver_color = COLOR_TEXT if has_unity else "#EF4444"
                lbl_uver = tk.Label(self.scrollable_frame, text=editor_ver, bg=row_bg, fg=ver_color, font=("", 9, "bold"), cursor="hand2")
                lbl_uver.grid(row=row_idx, column=2, sticky="nsew")
                
                lbl_cver = tk.Label(self.scrollable_frame, text=cur_ver, bg=row_bg, cursor="hand2")
                lbl_cver.grid(row=row_idx, column=3, sticky="nsew")
                
                lbl_cbun = tk.Label(self.scrollable_frame, text=cur_bundle, bg=row_bg, cursor="hand2")
                lbl_cbun.grid(row=row_idx, column=4, sticky="nsew")
                
                ent_t_ver = tk.Entry(self.scrollable_frame, width=12, justify="center", relief="solid", bd=1, bg=row_bg)
                ent_t_ver.insert(0, cur_ver)
                ent_t_ver.grid(row=row_idx, column=5)
                
                ent_t_bun = tk.Entry(self.scrollable_frame, width=12, justify="center", relief="solid", bd=1, bg=row_bg)
                ent_t_bun.insert(0, target_bundle_default)
                ent_t_bun.grid(row=row_idx, column=6)
                
                status_txt = "빌드 대기중" if has_unity else "❌ 미설치 버전"
                status_color = "#9CA3AF" if has_unity else "#EF4444"
                
                lbl_status = tk.Label(self.scrollable_frame, text=status_txt, bg=row_bg, fg=status_color, font=("", 9, "bold"), cursor="hand2")
                lbl_status.grid(row=row_idx, column=7, sticky="nsew")
                
                if not has_unity:
                    chk.config(state="disabled")
                    ent_t_ver.config(state="disabled")
                    ent_t_bun.config(state="disabled")
                
                # 라벨 클릭 시 체크박스 토글 (유니티 설치된 프로젝트만)
                def toggle_checkbox(event, var=chk_var, valid=has_unity):
                    if valid: var.set(not var.get())
                    
                lbl_name.bind("<Button-1>", toggle_checkbox)
                lbl_uver.bind("<Button-1>", toggle_checkbox)
                lbl_cver.bind("<Button-1>", toggle_checkbox)
                lbl_cbun.bind("<Button-1>", toggle_checkbox)
                lbl_status.bind("<Button-1>", toggle_checkbox)
                
                bg_widgets = [chk, lbl_name, lbl_uver, lbl_cver, lbl_cbun, lbl_status, ent_t_ver, ent_t_bun]
                
                def on_check_change(*args, var=chk_var, widgets=bg_widgets, original_bg=row_bg):
                    color = COLOR_HIGHLIGHT if var.get() else original_bg
                    for w in widgets:
                        w.config(bg=color)
                        if isinstance(w, tk.Checkbutton):
                            w.config(activebackground=color)
                
                chk_var.trace_add("write", on_check_change)
                
                self.project_rows.append({
                    "chk_var": chk_var, "proj_name": proj_name, "cur_ver": cur_ver,
                    "cur_bun": cur_bundle, "ent_t_ver": ent_t_ver, "ent_t_bun": ent_t_bun,
                    "lbl_status": lbl_status, "unity_exe": unity_exe # ✨ 실행 파일 경로도 같이 저장
                })
                row_idx += 1

    def toggle_all(self):
        if not self.project_rows: return
        
        # 활성화(유니티가 설치된)된 첫 번째 항목의 반대 상태를 찾음
        valid_rows = [r for r in self.project_rows if r["chk_var"].nametowidget(r["chk_var"]._name).cget("state") != "disabled"]
        if not valid_rows: return
        
        new_state = not valid_rows[0]["chk_var"].get()
        for row in valid_rows: 
            row["chk_var"].set(new_state)

    def start_build_process(self):
        selected_rows = [row for row in self.project_rows if row["chk_var"].get()]
        if not selected_rows:
            messagebox.showwarning("경고", "체크된 프로젝트가 없습니다!")
            return
        threading.Thread(target=self.run_builds, args=(selected_rows,), daemon=True).start()

    def run_builds(self, target_rows):
        format_flag = self.build_format.get()

        for row in target_rows:
            proj_name = row["proj_name"]
            proj_path = os.path.join(self.workspace_path, proj_name)
            unity_path = row["unity_exe"]
            
            t_ver, t_bun = row["ent_t_ver"].get(), row["ent_t_bun"].get()
            row["lbl_status"].config(text="🛠️ 빌드 중...", fg=COLOR_PRIMARY)
            
            output_target = os.path.join(self.workspace_path, "Builds", f"{proj_name}_{t_ver}")

            build_command = [
                unity_path, "-quit", "-batchmode", "-projectPath", proj_path,
                "-executeMethod", "AutoBuilder.PerformAndroidBuild",
                "-buildFormat", format_flag,
                "-outputPath", output_target
            ]
            
            try:
                print(f"[{proj_name}] 빌드 시작: {t_ver} ({t_bun}) / Unity: {unity_path}")

                subprocess.run(build_command, check=True) 
                
                row["lbl_status"].config(text="✅ 빌드 완료", fg=COLOR_SUCCESS)
            except subprocess.CalledProcessError:
                row["lbl_status"].config(text="❌ 실패", fg="#EF4444")
                
        messagebox.showinfo("빌드 끝", "체크된 모든 프로젝트의 빌드 작업이 완료되었습니다!")

if __name__ == "__main__":
    root = tk.Tk()
    app = BuildManagerApp(root)
    root.mainloop()

결과 및 도입 효과

실제로는 맵 에디터를 암호화하는 코드가 포함되어 있다. 필요한 기능이 있다면 자유롭게 추가하여 사용하면 좋을 것이다.

자동화 툴을 도입한 후, 매일 반복되던 수동 빌드 지옥에서 벗어날 수 있었다.

퇴근 전 체크박스를 누르고 빌드를 걸어두면, 다음 날 아침에는 80개의 빌드 결과물이 경로별로 깔끔하게 정리되어 있다.

반복적인 에러도 사라졌고, 무엇보다 개발자로서의 귀중한 시간을 '빌드 기다리기'가 아닌 '더 나은 기능 개발'에 집중할 수 있게 되었다. 만약 프로젝트가 많아 빌드에 지친 동료 개발자가 있다면 이 방식을 적극 추천한다.