대표 프로젝트

Unity 클라이언트 개발을 중심으로 문제 상황, 설계 선택, 구현 근거와 결과를 정리했습니다. 서버는 클라이언트 기능을 완성하기 위해 직접 연동한 범위에서 설명합니다.

GitHub

[네시삼십삼분] 복싱스타 라이브 서비스 & 시스템 리팩토링

챔피언스튜디오 사원 (2018.09 ~ 2022.02)

라이브 중인 모바일 게임 <복싱스타>에서 기존 클라이언트 시스템을 리팩토링하고 신규 콘텐츠와 플랫폼 이슈에 대응했습니다. 서버-클라이언트 공용 라이브러리 기반 스탯 데이터 조합, NGUI Arabic 호환과 AssetBundle 로딩 구조를 개선했습니다.

역할: Unity 클라이언트 프로그래머

대표 성과: 공용 스탯 계산 계층 위에서 여러 서버 데이터 형식을 하나의 클라이언트 생성 흐름으로 통합하고, NGUI에 Arabic 문자열 호환 처리를 추가했으며 AssetBundle 의존성과 중복 로드를 관리했습니다.

핵심 기술: Unity, C#, 서버-클라이언트 공용 라이브러리, NGUI, AssetBundle, Regex, Builder Pattern


Unity 클라이언트 리팩토링 사례

1. 서버 검증과 클라이언트 전투 스탯 규칙 공유

기존 문제: 계정 전체 데이터, 단일 캐릭터, 상대 프로필과 매치 Bot처럼 스탯을 만드는 입력 형식이 달랐습니다. 레벨, 체육관, 장비, 코스튬과 BioGear가 추가될 때 서버와 각 클라이언트 화면이 계산 규칙을 따로 관리하면 정상 스탯의 기준이 달라질 수 있었습니다.

원인: 클라이언트가 계산한 스탯은 변조될 수 있으므로 서버도 보유 데이터와 같은 공식으로 정상 수치를 계산할 수 있어야 했습니다. 동시에 캐릭터 생성, 프로필 비교와 매치 미리보기는 입력 데이터 형식이 달라도 같은 스탯 결과가 필요했습니다.

구현 방법: 서버와 클라이언트가 함께 사용하는 공용 라이브러리에 UserStatSystemBase, AccountStatTable, CharacterStatTable, 스탯 합산과 최대 HP 계산 규칙을 두었습니다. 서버는 이 공식을 비정상 스탯 사용자를 필터링하는 검증 기준으로 사용하고, Unity의 UserStatSystemBuilder는 여러 서버 데이터 형식을 공용 계산 구조에 맞는 테이블로 변환했습니다.

변경 흐름: SetTable은 공용 라이브러리에서 테이블을 정적 스탯과 전투 중 변경되는 동적 스탯으로 분류하고, GetLevel과 GetValue는 계정 단위와 캐릭터 단위 테이블을 합산합니다. 서버는 보유한 사용자 데이터에 같은 규칙을 적용해 정상 수치를 검증하고, Unity는 Builder로 생성한 테이블과 최대 HP 캐시를 ApplySystem을 통해 전투 Character에 적용합니다.

선택 이유: 서버가 클라이언트 값을 신뢰하지 않고 직접 스탯을 재계산하려면 양쪽의 공식이 항상 일치해야 했습니다. 계산 규칙은 공용 라이브러리로 공유하고, Unity 프로젝트에는 서버 응답을 게임 객체로 조합하는 Builder와 클라이언트 전용 초기화만 두었습니다.

검토한 대안: 서버와 Unity에 계산 코드를 각각 구현하면 공식 변경 시 검증 기준이 달라져 정상 사용자를 잘못 판정하거나 비정상 수치를 놓칠 수 있습니다. 각 화면에서 직접 합산하는 방식과 object 타입을 분기하는 하나의 초기화 메서드도 변경 중복과 타입 검증 문제로 제외했습니다.

결과와 한계: 서버는 클라이언트와 같은 공식으로 정상 스탯을 계산해 비정상 사용자를 필터링할 수 있고, Unity의 여러 입력 형식은 하나의 UserStatSystem 생성 흐름을 사용하게 됐습니다. 공용 라이브러리의 공식이나 테이블 구조가 바뀌면 서버 배포와 Unity 라이브러리 갱신 시점을 함께 관리해야 합니다.

관련 코드 보기 - 공용 라이브러리, Builder, 테이블 조합과 전투 적용

입력 데이터 형식 선택과 계정 및 캐릭터 테이블 추가

public class UserStatSystemBuilder : IBuilder<UserStatSystem>
{
    private readonly Queue<BaseClientDataTable> _dataQueue
        = new Queue<BaseClientDataTable>();
    private BaseCharacterData _serverData1;
    private UserProfileData _serverData2;
    private MatchUserProfile _serverData3;
    private BasePlayerData _serverData4;
    private string _name;

    public UserStatSystemBuilder(BaseCharacterData data)
    {
        _serverData1 = data;
    }

    public UserStatSystemBuilder(UserProfileData data)
    {
        _serverData2 = data;
    }

    public UserStatSystemBuilder(MatchUserProfile data)
    {
        _serverData3 = data;
    }

    public UserStatSystemBuilder(BasePlayerData data)
    {
        _serverData4 = data;
    }

    public UserStatSystemBuilder AddData(AccountStatTable statData)
    {
        _dataQueue.Enqueue(statData);
        return this;
    }

    public UserStatSystemBuilder AddData(CharacterStatTable statData)
    {
        _dataQueue.Enqueue(statData);
        return this;
    }

    public UserStatSystem Build()
    {
        var system = new UserStatSystem(_name);

        if (_serverData1 != null)
            system.InitSystem(_serverData1);
        else if (_serverData2 != null)
            system.InitSystem(_serverData2);
        else if (_serverData3 != null)
            system.InitSystem(_serverData3);
        else if (_serverData4 != null)
            system.InitSystem(_serverData4);

        while (_dataQueue.Count > 0)
            system.SetTable(_dataQueue.Dequeue());

        return system;
    }
}

단일 캐릭터와 계정 전체 데이터를 같은 테이블 조합으로 변환

public void InitSystem(BaseCharacterData data)
{
    if (data == null || data.Owner == null)
        return;

    SetTable(new LevelStatTable(data.Owner.LevelStat));
    SetTable(new GymStatTable(data.ID, data.GymStatData));
    SetTable(new CostumeCollectionStatTable(data.Owner.CostumeCollectionList));
    SetTable(new GearStatTable(data.ID, data.GetEquippedAllGearItemList(), data.EquippedOmegaSet, data.Owner.GearPotentialList));
    SetTable(new BioGearStatTable(data.ID, data.EquippedBioInfoList));

    _isNPC = data is AICharacterData;
    CalcMaxHP(data.ID);
    DownReducePercent = data.GetDownReduceHp();
}

public void InitSystem(BasePlayerData data)
{
    if (data?.CharacterDataDict == null)
        return;

    var gymTable = new GymStatTable();
    var gearTable = new GearStatTable();
    var bioGearTable = new BioGearStatTable();

    foreach (BaseCharacterData character in data.CharacterDataDict.Values)
    {
        gymTable.SetLevel(character.ID, character.GymStatData);
        gearTable.SetGearData(character.ID, character.GetEquippedAllGearItemList(), character.EquippedOmegaSet, data.GearPotentialList);
        bioGearTable.SetBioGearInfo(character.ID, character.EquippedBioInfoList);
    }

    SetTable(new LevelStatTable(data.LevelStat));
    SetTable(new CostumeCollectionStatTable(data.CostumeCollectionList));
    SetTable(gymTable);
    SetTable(gearTable);
    SetTable(bioGearTable);

    _isNPC = data.IsBot;
    foreach (BaseCharacterData character in data.CharacterDataDict.Values)
    {
        CalcMaxHP(character.ID);
    }
}

서버와 Unity가 상속하는 공용 계산 기반

public class UserStatSystemServer : UserStatSystemBase
{
}

public class UserStatSystem : UserStatSystemBase
{
    public UserStatSystem() : base()
    {
    }

    public UserStatSystem(string name) : base(name)
    {
    }
}

public void SetTable(BaseClientDataTable table)
{
    if (table == null)
        return;

    table.OwnerSystem = this;

    if (_DynamicStatDataTypes.Contains(table.StatDataType))
    {
        _dynamicStatDataDic[table.StatDataType] = table;
    }
    else
    {
        _staticStatDataDic[table.StatDataType] = table;
    }

    RefreshMaxHP();
}

public int GetLevel(int charId, StatType type)
{
    int level = 0;

    foreach (var item in _staticStatDataDic)
    {
        if (item.Value is AccountStatTable)
        {
            level += (item.Value as AccountStatTable).GetLevel(type);
        }
        else if (item.Value is CharacterStatTable)
        {
            level += (item.Value as CharacterStatTable).GetLevel(charId, type);
        }
    }

    foreach (var item in _dynamicStatDataDic)
    {
        if (item.Value is AccountStatTable)
        {
            level += (item.Value as AccountStatTable).GetLevel(type);
        }
        else if (item.Value is CharacterStatTable)
        {
            level += (item.Value as CharacterStatTable).GetLevel(charId, type);
        }
    }

    return level;
}

public void ApplySystem(UserStatSystemBase targetSystem)
{
    foreach (var table in targetSystem.GetTables())
        SetTable(table.Value);

    DownReducePercent = targetSystem.DownReducePercent;
    _isNPC = targetSystem._isNPC;

    _maxHpDic.Clear();
    _reciprocalMaxHpDic.Clear();

    foreach (var item in targetSystem._maxHpDic)
        _maxHpDic[item.Key] = item.Value;

    foreach (var item in targetSystem._reciprocalMaxHpDic)
        _reciprocalMaxHpDic[item.Key] = item.Value;
}

public Character BuildCharacter()
{
    Character character = CreateCharacter();
    character.statSystem.ApplySystem(_playerData.StatSystem);
    return character;
}
리팩토링 리뷰 발표 자료 보기

스탯 시스템의 기존 문제, 공용 라이브러리와 Unity 클라이언트의 역할 분리, 테이블 기반 합산 구조와 리팩토링 결과를 정리한 코드 리뷰 발표 자료입니다.

2. NGUI Arabic 문자열 호환

기존 문제: NGUI UILabel은 Arabic 문자의 연결 형태와 오른쪽에서 왼쪽으로 읽는 배치를 처리하지 못했습니다. 영문, 숫자, BBCode와 괄호가 섞인 문자열은 단순 역순 변환만으로 표시할 수 없었습니다.

원인: Arabic 문자는 앞뒤 글자와의 연결 여부에 따라 표시 형태가 달라지며, 오른쪽에서 왼쪽으로 재배치하는 과정에서도 Arabic 이외의 문자열과 NGUI 색상 태그 내부 순서를 보존해야 했습니다.

구현 방법: ArabicConverter가 BBCode, 괄호와 비 Arabic 문자열을 임시 토큰으로 분리한 뒤 ArabicTool.FixLine으로 Arabic 문자의 연결 형태와 RTL 배치를 처리하도록 했습니다. 변환 후 토큰을 원래 문자열로 복원하고 NGUI UILabel의 WrapText 직후에 변환기를 연결했습니다.

변경 흐름: UILabel이 줄바꿈을 계산한 문자열을 FixNGUI에 전달합니다. Arabic 포함 여부를 확인하고 태그와 혼합 문자열을 보관한 뒤 특수 규칙이 있는 Arabic 문자 조합, 글자 위치별 형태와 문장 부호를 변환합니다. 마지막으로 비 Arabic 문자열과 괄호 내용을 복원해 mProcessedText에 반영합니다.

선택 이유: 모든 UI 호출부를 수정하는 대신 UILabel 렌더링 경로에 연결하면 기존 NGUI 화면에 같은 규칙을 적용할 수 있습니다. 태그 분리와 Arabic 변환을 나눠 혼합 문자열의 손상을 줄였습니다.

검토한 대안: 상용 Arabic 플러그인은 프로젝트 문자열 구성에서 문제가 있었고, TextMesh Pro 전환은 라이브 중인 NGUI 화면 전체를 교체해야 하므로 현재 렌더링 경로를 확장했습니다.

결과와 한계: 기존 UILabel 사용 코드를 유지하면서 Arabic가 포함된 혼합 문자열을 정상 표시할 수 있게 했습니다. 정규식 변환 중 사용하는 임시 목록이 static이므로 동시에 여러 문자열을 처리하는 구조로 확장하려면 상태를 호출 단위로 분리해야 합니다.

관련 코드 보기 - NGUI Arabic 호환 처리

UILabel의 처리 텍스트 생성과 실제 렌더링 연결

public string processedText
{
    get
    {
        if (mLastWidth != mWidth || mLastHeight != mHeight)
        {
            mLastWidth = mWidth;
            mLastHeight = mHeight;
            mShouldBeProcessed = true;
        }

        if (shouldBeProcessed)
            ProcessText();

        return mProcessedText;
    }
}

public void ProcessText(bool legacyMode = false, bool full = true)
{
    if (!isValid)
        return;

    mChanged = true;
    shouldBeProcessed = false;

    if (full)
        UpdateNGUIText();

    // rect, region, font size와 Overflow 계산은 기존 NGUI 로직을 사용합니다.
    NGUIText.Update(false);
    bool fits = NGUIText.WrapText(printedText, out mProcessedText, false, false, mOverflow == Overflow.ClampContent && mOverflowEllipsis);

    ArabicSupport.ArabicConverter.FixNGUI(ref mProcessedText, this);
    mCalculatedSize = NGUIText.CalculatePrintedSize(mProcessedText);
}

public override void OnFill(List<Vector3> verts, List<Vector2> uvs, List<Color> cols)
{
    if (!isValid)
        return;

    string text = processedText;
    UpdateNGUIText();
    NGUIText.Print(text, verts, uvs, cols);
}

Arabic 구간과 혼합 문자열 분리 및 복원

public static void FixNGUI(ref string text, UILabel label = null)
{
    if (IsRtl(text) == false)
        return;

    bbcodeDatas.Clear();
    braketDatas.Clear();
    nonArabicDatas.Clear();

    ConvertString(ref text, label);
    DeconvertString(ref text);
}

private static void ConvertString(ref string input, UILabel label)
{
    string result = Regex.Replace(
        input.TrimStart(),
        BBCodePattern,
        match =>
        {
            bbcodeDatas.Add(match.Value);
            return BBCodeConvertKeyWord;
        });

    result = Regex.Replace(
        result,
        BracketPattern,
        match =>
        {
            braketDatas.Add(match.Value);
            return BracketPatternKey;
        });

    result = Regex.Replace(
        result,
        NonArabicPattern,
        match =>
        {
            if (match.Value.Contains(BracketPatternKey.Trim()) || match.Value.Contains(BBCodeConvertKeyWord.Trim()))
            {
                return match.Value;
            }

            nonArabicDatas.Add(match.Value);
            return NonArabicPatternKey;
        });

    input = Fix(result, false, false);
}

private static void DeconvertString(ref string input)
{
    int index = 0;
    input = Regex.Replace(input, NonArabicDePattern, _ => nonArabicDatas[index++]);

    index = 0;
    input = Regex.Replace(
        input,
        BracketDePattern,
        _ =>
        {
            string value = braketDatas[index++];
            char first = value[0];
            char last = value[value.Length - 1];
            string body = ArabicTool.FixLine(value.Substring(1, value.Length - 2));
            return $"{first}{body}{last}";
        });
}

특수 규칙이 있는 Arabic 문자 조합, 글자 위치별 형태와 문장 부호 처리

internal static string FixLine(string text)
{
    List<TashkeelLocation> tashkeelLocations;
    string origin = RemoveTashkeel(text, out tashkeelLocations);

    char[] source = origin.ToCharArray();
    char[] result = origin.ToCharArray();

    for (int index = 0; index < source.Length; index++)
        source[index] = (char)ArabicTable.ArabicMapper.Convert(source[index]);

    for (int index = 0; index < source.Length; index++)
    {
        bool combined = false;
        if (source[index] == (char)IsolatedArabicLetters.Lam && index < source.Length - 1)
        {
            if (source[index + 1] == (char)IsolatedArabicLetters.Alef)
            {
                source[index] = (char)0xFEF9;
                result[index + 1] = (char)0xFFFF;
                combined = true;
            }
            else if (source[index + 1] == (char)IsolatedArabicLetters.AlefHamza)
            {
                source[index] = (char)0xFEF5;
                result[index + 1] = (char)0xFFFF;
                combined = true;
            }
        }

        if (IsIgnoredCharacter(source[index]) == false)
        {
            if (IsMiddleLetter(source, index))
                result[index] = (char)(source[index] + 3);
            else if (IsFinishingLetter(source, index))
                result[index] = (char)(source[index] + 1);
            else if (IsLeadingLetter(source, index))
                result[index] = (char)(source[index] + 2);
        }

        if (PunctuationMapper.TryGetValue(source[index], out char punctuation))
        {
            result[index] = punctuation;
        }

        if (combined)
            index++;
    }

    if (showTashkeel)
        result = ReturnTashkeel(result, tashkeelLocations);

    Array.Reverse(result);
    return new string(result.Where(value => value != (char)0xFFFF).ToArray());
}

3. AssetBundle 의존성과 중복 로드 관리

기존 문제: 패치 리소스가 개별 번들과 packing 번들로 나뉘어 있었고, 에셋을 로드하기 전에 의존 번들을 준비하지 않거나 같은 packing 번들을 반복해서 열면 로딩 실패와 중복 메모리 사용이 발생할 수 있었습니다.

원인: 호출부가 실제 번들 파일명, MD5 에셋 키와 의존성 순서를 모두 알기 어려웠고, 로컬 빌드와 다운로드 패치의 기준 경로도 달랐습니다.

구현 방법: AssetBundleTable이 파일 목록, packinginfo의 에셋 MD5와 번들 매핑, manifest와 로드된 번들 캐시를 함께 관리하도록 했습니다. 에셋 경로를 MD5로 변환해 개별 번들 또는 packing 번들을 선택하고 manifest 의존성을 먼저 로드했습니다.

변경 흐름: 초기화 시 패치 디렉터리를 순회해 개별 번들과 packinginfo 매핑을 구성합니다. LoadAssetBundle은 에셋 MD5로 저장 위치를 찾고, prepareLoad가 manifest의 모든 의존 번들을 LoadABFromCache로 준비합니다. packing 번들은 처음 로드한 인스턴스를 Dictionary에 보관하고 메모리 정리 시 명시적으로 Unload합니다.

선택 이유: 에셋 이름과 실제 파일 배치를 테이블에서 분리하면 호출부는 논리 경로만 사용하고, 로더가 의존성 순서와 중복 로드를 통제할 수 있습니다.

검토한 대안: 호출부에서 AssetBundle.LoadFromFile을 직접 사용하면 의존성 처리와 캐시 정책이 화면마다 반복되고, 모든 packing 번들을 시작 시 로드하면 초기 메모리와 로딩 비용이 증가해 필요 시 캐시하는 방식을 선택했습니다.

결과와 한계: 개별 번들과 packing 번들을 하나의 조회 경로로 통합하고 의존 번들과 이미 열린 번들을 재사용할 수 있게 했습니다. 에셋 검색이 문자열 Contains와 MD5 매핑 파일에 의존하고, 일부 비동기 경로가 오래된 WWW API와 고정 버전을 사용한다는 한계가 있습니다.

관련 코드 보기 - MD5 매핑, 의존성 준비와 번들 캐시

패치 파일과 packinginfo에서 에셋 위치 테이블 구성

public AssetBundleTable()
{
    string rootPath = assetBundlesPath;
    if (Directory.Exists(rootPath) == false)
        return;

    foreach (string path in Directory.GetFiles(rootPath))
    {
        string extension = Path.GetExtension(path);
        if (extension == string.Empty)
        {
            _fileNameTable.Add(Path.GetFileNameWithoutExtension(path));
        }
        else if (extension.Equals(".packinginfo"))
        {
            string bundleName = Path.GetFileNameWithoutExtension(path);

            foreach (string assetMd5 in File.ReadAllLines(path))
            {
                if (_packingFileTable.ContainsKey(assetMd5) == false)
                    _packingFileTable.Add(assetMd5, bundleName);
            }
        }
    }
}

에셋 위치 선택, packing 번들 재사용과 의존 번들 선행 로드

public T LoadAssetBundle<T>(string assetPath, string name) where T : class
{
    string md5 = MakeMd5(assetPath.ToLower());

    if (_fileNameTable.Contains(md5))
    {
        string fullPath = Path.Combine(assetBundlesPath, md5);
        AssetBundle bundle = LoadAssetBundle(fullPath);
        T asset = bundle.mainAsset as T;
        bundle.Unload(false);
        return asset;
    }

    if (_packingFileTable.TryGetValue(md5, out string bundleName) == false)
    {
        return null;
    }

    string packingPath = Path.Combine(assetBundlesPath, bundleName);
    AssetBundle packingBundle = LoadPackingAssetBundle(packingPath);
    if (packingBundle == null)
        return null;

    string assetName = getAssetNameWithFullPath(packingBundle.GetAllAssetNames(), name ?? assetPath);
    if (packingBundle.Contains(assetName) == false)
        return null;

    return packingBundle.LoadAsset(assetName) as T;
}

private AssetBundle LoadPackingAssetBundle(string fullPath)
{
    if (_packingAssetBundle.TryGetValue(fullPath, out AssetBundle cached))
    {
        return cached;
    }

    AssetBundle bundle = LoadAssetBundle(fullPath);
    _packingAssetBundle.Add(fullPath, bundle);
    return bundle;
}

private AssetBundle LoadAssetBundle(string fullPath)
{
    string bundleName = Path.GetFileNameWithoutExtension(fullPath);
    prepareLoad(bundleName);
    return LoadABFromCache(fullPath);
}

private void prepareLoad(string bundleName)
{
    foreach (string dependency in GetAssetbundleDependency(bundleName))
    {
        string path = Path.Combine(assetBundlesPath, dependency);
        LoadABFromCache(path);
    }
}

private AssetBundle LoadABFromCache(string path)
{
    if (_loadedAssetTable.TryGetValue(path, out AssetBundle bundle) == false || bundle == null)
    {
        bundle = AssetBundle.LoadFromFile(path);
        _loadedAssetTable[path] = bundle;
    }

    return bundle;
}

[멘티스코/뉴웨이블] 모바일 RPG 슈퍼콜라 택틱스 신규 개발

사원 (2023.12 ~ 2024.12)

URP 기반 모바일 RPG의 신규 개발부터 라이브 운영까지 참여했습니다. 전투 구조, MVP 기반 UI, 재사용 스크롤과 UI Toolkit 개발 도구를 구현했습니다.

역할: Unity 클라이언트 프로그래머

대표 성과: 화면 로직을 MVP 구조로 통일하고, 대량 목록을 위한 재사용 스크롤과 전투 데이터 검증을 위한 EditorWindow 도구를 제작했습니다.

핵심 기술: Unity, URP, MVP, UniRx, UniTask, uGUI, UI Toolkit, State Machine, Command


Unity 클라이언트 구현 사례

1. MVP 기반 UI와 재사용 스크롤

기획 요구사항: 전투 선택, 캐릭터, 인벤토리와 상점 등 화면이 계속 추가되는 환경에서 화면 로직과 표시 코드를 같은 방식으로 구성하고, 많은 셀을 표시하는 목록에서 오브젝트 생성을 제한해야 했습니다.

구현 방법: BasePresenterN에 View와 Model 타입을 제네릭으로 지정하고, View는 Presenter를 통해 입력을 전달하도록 구성했습니다. 목록 UI는 Unity ScrollRect를 상속한 RecyclingScrollRect와 방향별 RecyclingSystem으로 구현했습니다.

동작 흐름: Presenter가 Model을 연결하고 View 이벤트를 처리한 뒤 Model 상태를 화면에 반영합니다. 스크롤이 이동하면 RecyclingSystem이 뷰포트 밖의 셀을 반대쪽 위치로 옮기고 데이터만 다시 바인딩합니다. PopupManager는 생성한 PopupBase를 목록에 보관해 다음 표시 요청에서 재사용합니다.

선택 이유: 제네릭으로 View와 Model 관계를 고정하면 화면마다 반복되는 형 변환과 연결 코드를 줄일 수 있습니다. ScrollRect 상속 방식은 기존 uGUI 이벤트와 설정을 유지하면서 프로젝트 데이터 바인딩 규칙을 적용할 수 있습니다.

검토한 대안: 화면마다 MonoBehaviour에 로직을 직접 작성하면 기능이 늘수록 화면 상태와 데이터 변경이 섞입니다. 모든 셀을 한 번에 생성하는 기본 목록은 데이터 수에 따라 GameObject와 레이아웃 계산이 증가해 재사용 방식을 선택했습니다.

결과와 한계: 화면마다 같은 Presenter, View, Model 연결 방식을 적용하고 목록 셀을 재사용할 수 있게 했습니다. PopupStyle과 Resources 경로를 함께 관리해야 하며 Presenter가 MonoBehaviour를 상속하므로 Unity 수명에 의존합니다.

관련 코드 보기 - MVP와 재사용 스크롤

Presenter, View, Model 연결과 초기화 순서

public abstract class BasePresenterN<ViewType, ModelType> : BasePresenterN where ViewType : class, new() where ModelType : class, new()
{
    public ViewType View => _baseView as ViewType;
    public ModelType Model => _baseModel as ModelType;

    public override async UniTask Init(params object[] data)
    {
        if (_baseModel == null)
        {
            _baseModel = (new ModelType() as BaseModel).SetModel(this);
        }

        await base.Init(data);
    }
}

public abstract class BasePresenterN : MonoBehaviour
{
    [SerializeField] protected BaseView _baseView;
    protected BaseModel _baseModel;

    public virtual async UniTask Init(params object[] data)
    {
        FacadeManager.Server.ReceivePacketObservable.Where(packet => packet != null).Subscribe(OnPacket).AddTo(gameObject);

        if (_baseView != null)
            await _baseView.Init(data);
    }

    public virtual async UniTask Show(params object[] data)
    {
        if (_baseView != null)
            await _baseView.Show(data);
    }
}

public abstract class BaseView<T> : BaseView where T : BasePresenterN
{
    public T Presenter => _basePresenter as T;
}

Popup 최초 생성과 이후 인스턴스 재사용

public PopupBase Get(PopupStyle style, params object[] data)
{
    var popup = _listPopup.Find(item => item.name.Equals(GetPopupName(style)));

    if (popup != null)
        return popup;

    GameObject prefab = Resources.Load<GameObject>($"{PopupPath}{GetPopupName(style)}");
    if (prefab == null)
        return null;

    PopupBase instance = Instantiate(prefab.GetComponent<PopupBase>());
    instance.name = prefab.name;
    _listPopup.Add(instance);
    return instance;
}

public async UniTask<PopupBase> Show(PopupStyle style, bool showLoading, params object[] data)
{
    var popup = Get(style, data);
    popup.gameObject.SetActive(true);

    if (popup.isLoad == false)
        await popup.Load();

    await popup.Show(data);
    return popup;
}

스크롤 방향별 시스템 생성과 셀 재배치

private void Init()
{
    if (_direction == EDirectionType.Vertical)
    {
        _recyclingSystem = new VerticalRecyclingSystem(_baseCell, this, _handler, _isGrid, LineCount, _initCount, _isControlChildsSize);
    }
    else
    {
        _recyclingSystem = new HorizontalRecyclingSystem(_baseCell, this, _handler, _isGrid, LineCount, _initCount, _isControlChildsSize);
    }

    _recyclingSystem.Init(() => onValueChanged.AddListener(OnScrollValueChanged));
}

public override Vector2 OnValueChangedListener(Vector2 direction)
{
    RefreshBounds();

    if (direction.y > 0 && _cellPool[_lastIndex].MaxY() > _recyclableViewBounds.min.y)
    {
        return RecycleTopToBottom();
    }

    if (direction.y < 0 && _cellPool[_firstIndex].MinY() < _recyclableViewBounds.max.y)
    {
        return RecycleBottomToTop();
    }

    return Vector2.zero;
}

private Vector2 RecycleTopToBottom()
{
    float nextY = _cellPool[_lastIndex].anchoredPosition.y
        - _cellPool[_lastIndex].sizeDelta.y;

    _cellPool[_firstIndex].anchoredPosition = new Vector2(_cellPool[_firstIndex].anchoredPosition.x, nextY);

    Handler?.OnRecycling(_cellList[_firstIndex], _currentItemCount);
    indexInfoDict[_cellList[_firstIndex]] = _currentItemCount;

    _lastIndex = _firstIndex;
    _firstIndex = (_firstIndex + 1) % _cellPool.Count;
    _currentItemCount++;

    return Vector2.zero;
}

2. State Machine과 Command 기반 전투 구조

기획 요구사항: 전투 모드와 유닛 AI마다 진입, 실행과 종료 조건이 달랐고, 플레이어 입력과 실제 이동 및 행동 실행을 분리해 자동 전투와 디버그 기능에서도 같은 동작을 호출해야 했습니다.

구현 방법: Enum과 StateBase를 Dictionary에 등록하는 StateMachine을 만들고 Enter와 Leave로 전환 수명을 관리했습니다. 이동과 행동은 ICommand 인터페이스 구현으로 분리하고 CommandManager가 입력 조건과 실행을 연결했습니다.

동작 흐름: ChangeState가 현재 상태의 Leave를 호출한 뒤 새 상태를 저장하고 Enter를 실행합니다. CommandManager는 매 프레임 입력 조건을 확인하고 MoveCommandPattern 또는 ActionCommandPattern의 Execute를 호출합니다.

선택 이유: 상태의 진입과 종료 코드를 클래스별로 분리하면 전투 모드와 유닛 AI가 같은 전환 규칙을 사용할 수 있습니다. Command는 키 입력과 행동 실행을 분리해 입력 주체가 바뀌어도 실행 로직을 재사용할 수 있습니다.

검토한 대안: Animator State Machine은 애니메이션에는 적합하지만 게임 상태 전환, 서버 응답 대기와 전투 객체 정리를 한 흐름에서 관리하기 어렵고, 입력 분기에서 행동을 직접 실행하면 자동 전투와 디버그 경로에서 같은 코드를 반복하게 되어 제외했습니다.

결과와 한계: 전투 상태와 입력 행동을 독립된 단위로 추가하고 교체할 수 있게 했습니다. Enum 등록과 상태 전환 가능 조건은 각 State Machine에서 명시적으로 관리해야 합니다.

관련 코드 보기 - State Machine과 Command

기존 상태 종료 후 다음 상태를 시작하는 전환 순서

public void ChangeState(Enum nextState, params object[] args)
{
    if (states.ContainsKey(nextState) == false)
        throw new Exception("Invalid State " + nextState);

    if (currentState != null && states.ContainsKey(currentState))
    {
        states[currentState].Leave(args);
    }

    currentState = nextState;
    states[currentState].Enter(args);
}

키 입력 감지와 진행 중인 Command 실행을 분리한 갱신 흐름

public void SetKeyCmd(ICommand<Player, Model> command, KeyCode keyCode, Func<KeyCode, bool> inputCondition)
{
    keyMatchingDict.TryAdd(keyCode, command);
    updateKeyCodeList.Add(new KeyMatchData<Player, Model>
    {
        keyAction = inputCondition,
        key = keyCode,
        command = command
    });
}

public void CommandUpdate(float deltaTime)
{
    foreach (var command in commandDict.Values)
        command.ReSet();

    for (int i = 0; i < updateKeyCodeList.Count; i++)
    {
        var keyMatch = updateKeyCodeList[i];
        if (keyMatch.keyAction.Invoke(keyMatch.key))
            keyMatch.command.Catch(keyMatch.key);
    }

    foreach (var command in commandDict.Values)
    {
        if (command.IsProgress() || command.IsCatch())
            command.Execute(deltaTime);
        else
            command.Idle(deltaTime);
    }
}

행동이 진행 중이면 계속 실행하고 종료 후 새 패턴을 선택하는 Command

public override void Execute(float deltaTime)
{
    if (isProgress)
    {
        actionPattern.Execute(deltaTime);
        if (actionPattern.IsEnd())
        {
            isProgress = false;
            ReSet();
        }
        return;
    }

    var enemies = mainModel.unitModel.GetRoundEnemies(player);
    var target = Const.FindShortestTarget(player, enemies, 10000f);

    player.GetBoard().TargetManager.SetMainTarget(target, true);
    isProgress = actionPattern.TryPickAnyPattern();
}

3. UI Toolkit 기반 개발 및 QA 도구

개발 요구사항: 전투 데이터와 테이블을 확인하기 위해 매번 별도 디버그 코드를 작성하지 않고, 기획과 개발 과정에서 반복하는 조회, 필터와 기록 작업을 Unity Editor 안에서 수행해야 했습니다.

구현 방법: AttackDebugger, CharacterViewer, TableViewer와 InventoryViewer를 EditorWindow로 만들고 UI Toolkit으로 화면을 구성했습니다. 공통 도구는 ISuperKolaTool을 구현하고 SuperKolaTools가 Reflection으로 검색해 하나의 메뉴에 버튼을 자동 생성하도록 했습니다.

동작 흐름: SuperKolaTools를 열면 ISuperKolaTool 구현 형식을 검색해 OpenWindow 호출 버튼을 만듭니다. AttackDebugger는 공격자, 피격자와 기록을 DropdownField로 필터링하고 JSON 파일에 저장한 상세 데이터를 TextField에 표시합니다.

선택 이유: EditorWindow는 게임 실행 코드와 분리하면서 Unity 객체와 프로젝트 데이터를 직접 조회할 수 있습니다. UXML과 VisualElement를 사용하면 도구 화면과 처리 코드를 나누고 새로운 도구를 공통 메뉴에 추가할 수 있습니다.

검토한 대안: 게임 내부 치트 UI는 빌드에 포함될 수 있고 런타임 화면 제약을 받습니다. 개별 메뉴 항목을 수동으로 등록하면 도구가 늘 때 통합 창도 함께 수정해야 해서 인터페이스 검색 방식을 선택했습니다.

결과와 한계: 전투 기록, 캐릭터, 인벤토리와 테이블 조회 도구를 공통 진입점에서 실행할 수 있게 했습니다. Reflection 방식은 OpenWindow 정적 메서드 규칙을 컴파일 시점에 강제하지 못하므로 도구 작성 규칙을 지켜야 합니다.

관련 코드 보기 - 도구 자동 등록과 공격 기록 조회

인터페이스 구현 형식을 검색해 통합 도구 창에 실행 버튼 등록

foreach (Type type in Assembly.GetAssembly(typeof(ISuperKolaTool)).GetTypes())
{
    if (type.GetInterface(nameof(ISuperKolaTool)) == null)
        continue;

    MethodInfo openWindow = type.GetMethod("OpenWindow", BindingFlags.Static | BindingFlags.Public);

    Button button = new Button(() => openWindow.Invoke(null, null));
    button.text = type.Name;
    root.Add(button);
}

공격자와 피격자 조건을 순서대로 적용하는 기록 조회

private IEnumerable<KeyValuePair<DateTime, AttackDebugInfo>> GetDatas()
{
    IEnumerable<KeyValuePair<DateTime, AttackDebugInfo>> filtered;

    if (string.IsNullOrEmpty(_dropdownHitter.value) || _dropdownHitter.value == "ALL")
    {
        filtered = _debugInfos;
    }
    else
    {
        filtered = _debugInfos.Where(item => item.Value.hitter == _dropdownHitter.value);
    }

    if (string.IsNullOrEmpty(_dropdownVictim.value) == false && _dropdownVictim.value != "ALL")
    {
        filtered = filtered.Where(item => item.Value.victim == _dropdownVictim.value);
    }

    return filtered;
}

private void SelectRecord(int index)
{
    if (index < 0 || index >= _infoList.Count)
        return;

    _currentInfo = _infoList[index];
    _textBoxBody.value = _currentInfo.body;
}

전투 이벤트 구독, 기록 추가와 JSON 저장

private void OnClickRecord()
{
    _isRecord = !_isRecord;
    _stream?.Dispose();

    if (_isRecord)
    {
        _stream = RTPawnData._EDITOR_RTPAWN_ONATTACK.Subscribe(OnAttack);
    }
}

public void OnAttack((RTPawnData hitter, RTPawnData victim, string body) data)
{
    if (_isRecord == false)
        return;

    var record = new AttackDebugInfo
    {
        hitter = data.hitter.GetName().ToTableText(),
        victim = data.victim.GetName().ToTableText(),
        body = data.body
    };

    _debugInfos.Add(DateTime.Now, record);
    File.WriteAllText(_DATA_PATH, JsonConvert.SerializeObject(_debugInfos));
}

[쓰리케이엠] 방치형 모바일 게임 개발 및 출시

유니티 클라이언트 프로그래머 (2025.01 ~ 2025.07)

'팩몬 키우기'의 Unity 클라이언트 개발과 라이브 운영에 참여했습니다. 서버 요청 흐름, 원격 콘텐츠 로딩, 광고 보상형 뽑기와 Excel 기반 테이블 변환처럼 기능과 운영 데이터가 연결되는 시스템을 구현했습니다.

역할: Unity 클라이언트 프로그래머

대표 성과: 프로토콜별 요청 직렬화와 hash 기반 AssetBundle 캐시를 구성하고, 광고와 재화 결제를 하나의 뽑기 흐름으로 연결했으며 Client와 Server용 테이블 생성을 자동화했습니다.

핵심 기술: Unity, C#, UniTask, UniRx, uGUI, AssetBundle, Google Mobile Ads, .NET, Roslyn


Unity 클라이언트 구현 사례

1. 프로토콜별 요청 직렬화와 응답 연결

개발 요구사항: 같은 프로토콜의 요청이 연속으로 발생해도 요청과 응답의 처리 순서가 섞이지 않아야 했습니다. 호출부에서는 콜백을 직접 관리하지 않고 응답 DTO를 UniTask 반환값으로 받아야 했습니다.

구현 방법: RestProtocolType별 작업 큐와 응답 대기 큐를 만들었습니다. 요청마다 UniTaskCompletionSource를 생성하고, 해당 프로토콜의 소비자가 작업을 하나씩 실행해 응답 완료 후 다음 작업으로 이동하도록 구성했습니다.

동작 흐름: SendConcurrencyPacket이 전송 작업과 응답 대기 객체를 큐에 등록합니다. TriggerProtocolProcessing은 같은 프로토콜의 소비자가 이미 실행 중인지 확인한 뒤 FIFO로 작업을 실행합니다. MessageBroker로 응답 DTO가 발행되면 SubscribeResponse가 대기 중인 UniTaskCompletionSource를 꺼내 완료시킵니다.

선택 이유: 호출부를 async와 await 흐름으로 유지하면서도 같은 프로토콜의 전송과 응답 처리를 한 줄로 직렬화할 수 있습니다. 프로토콜별 큐를 사용해 서로 다른 API의 대기열은 분리했습니다.

검토한 대안: 하나의 전역 큐는 관련 없는 프로토콜까지 함께 대기시키고, 각 화면에서 응답 이벤트를 직접 구독하면 화면 종료 시 구독 해제와 요청 순서 관리가 반복되어 제외했습니다.

결과와 한계: 호출부에서 응답을 UniTask 결과로 받아 같은 프로토콜의 연속 요청을 순서대로 처리할 수 있게 했습니다. 현재 응답 연결은 프로토콜별 도착 순서를 전제로 하므로 요청 식별자가 필요한 병렬 처리에는 적합하지 않고, 소비자가 async void인 점도 예외 전달 범위를 제한합니다.

관련 코드 보기 - 요청 등록, 순차 실행과 응답 완료

요청 작업과 응답 대기 객체 등록

public async UniTask<T> SendConcurrencyPacket<T>(RestProtocolType protocol, object request = null, int reSendCount = 5)
{
    var tcs = new UniTaskCompletionSource<T>();
    var queue = _protocolQueues.GetOrAdd(protocol, _ => new ConcurrentQueue<Func<UniTask>>());

    queue.Enqueue(async () =>
    {
        _responseTasks.GetOrAdd(protocol, _ => new ConcurrentQueue<object>()).Enqueue(tcs);

        SendRest(new RestMessageData
        {
            protocol = (int)protocol,
            requestPacket = request,
            reSendCount = reSendCount
        });

        await tcs.Task;
    });

    TriggerProtocolProcessing(protocol);
    return await tcs.Task;
}

private void SendRest(RestMessageData restPacket)
{
    JObject jsonObject;

    if (restPacket.requestPacket != null)
    {
        jsonObject = JObject.FromObject(restPacket.requestPacket);
    }
    else
    {
        jsonObject = JObject.FromObject(new RESTRequestBase());
    }

    RestController.Instance.SendObject((RestProtocolType)restPacket.protocol, jsonObject, restPacket.reSendCount);
}

프로토콜별 단일 소비자와 FIFO 실행

private async void TriggerProtocolProcessing(RestProtocolType protocol)
{
    if (_isProcessingProtocol.TryAdd(protocol, true) == false)
        return;

    try
    {
        var queue = _protocolQueues[protocol];
        while (queue.TryDequeue(out var workItem))
        {
            try
            {
                await workItem();
            }
            catch (Exception exception)
            {
                Debug.LogError($"Processing exception for {protocol}: " + exception);
            }
        }
    }
    finally
    {
        _isProcessingProtocol.TryRemove(protocol, out _);
    }
}

public void SubscribeResponse<T>(RestProtocolType protocol) where T : RESTResponseBase
{
    MessageBroker.Default.Receive<T>().Subscribe(response =>
        {
            if (_responseTasks.TryGetValue(protocol, out var queue) && queue.TryDequeue(out object source) && source is UniTaskCompletionSource<T> tcs)
            {
                tcs.TrySetResult(response);
            }
        }).AddTo(this);
}

2. Manifest와 hash 기반 원격 콘텐츠 로딩

운영 요구사항: 앱을 다시 배포하지 않고 리소스를 갱신하고, 변경되지 않은 AssetBundle은 다시 다운로드하지 않으면서 전체 다운로드 진행률을 화면에 표시해야 했습니다.

구현 방법: 원격 메인 번들에서 AssetBundleManifest를 읽고 각 번들명과 hash로 CachedAssetBundle을 생성했습니다. 다운로드한 번들의 에셋 경로를 정규화해 이름과 AssetBundle 참조를 Dictionary에 등록했습니다.

동작 흐름: 기존 번들을 Unload한 뒤 manifest를 내려받고 모든 번들을 순회합니다. UnityWebRequest의 downloadProgress를 UI에 반영하고, 완료된 번들의 지원 확장자 에셋을 조회용 키로 등록합니다. 이후 LoadAsset과 LoadAssetAsync가 같은 Dictionary에서 번들과 내부 경로를 찾아 로드합니다.

선택 이유: Unity의 manifest hash와 내장 캐시를 사용하면 별도의 버전 비교 파일 없이 변경된 번들만 내려받을 수 있습니다. 에셋 이름을 공통 키로 등록해 호출부가 실제 번들명을 알 필요가 없게 했습니다.

검토한 대안: Resources에 모든 콘텐츠를 포함하면 앱 업데이트 없이 교체하기 어렵고, 실행할 때마다 전체 번들을 내려받으면 변경되지 않은 데이터까지 반복해서 전송하게 되어 제외했습니다.

결과와 한계: 원격 콘텐츠를 hash 기준으로 캐시하고 다운로드 상태를 초기 로딩 UI에 연결했습니다. 에셋 키 생성이 프로젝트 폴더 문자열과 확장자 목록에 의존하고, 일부 번들 실패 후 재개 위치를 저장하지 않는 한계가 있습니다.

관련 코드 보기 - manifest, hash 캐시와 에셋 등록
using var manifestRequest = UnityWebRequestAssetBundle.GetAssetBundle(AssetBundleResourcePath);
await manifestRequest.SendWebRequest();

var mainBundle = DownloadHandlerAssetBundle.GetContent(manifestRequest);
var manifest = mainBundle.LoadAsset<AssetBundleManifest>("AssetBundleManifest");
var bundleNames = manifest.GetAllAssetBundles();
string[] loadableExtensions =
{
    ".png", ".asset", ".prefab", ".csv",
    ".wav", ".mp3", ".mat", ".fontsettings"
};

for (int i = 0; i < bundleNames.Length; i++)
{
    string bundleName = bundleNames[i];
    var cachedBundle = new CachedAssetBundle(bundleName, manifest.GetAssetBundleHash(bundleName));

    using var request = UnityWebRequestAssetBundle.GetAssetBundle($"{ServerConfig.ASSET_BUNDLE_URL}/{bundleName}", cachedBundle);
    request.SendWebRequest();

    processHeader?.Init(1, $"Game Data Downloading ({i + 1}/{bundleNames.Length})");

    while (request.isDone == false && request.error == null)
    {
        processHeader?.SetProcess(request.downloadProgress);
        await UniTask.Yield();
    }

    if (request.error != null)
    {
        Debug.LogError(request.error);
        break;
    }

    AssetBundle assetBundle = DownloadHandlerAssetBundle.GetContent(request);
    assetBundleList.Add(assetBundle);

    foreach (string assetName in assetBundle.GetAllAssetNames())
    {
        string extension = Path.GetExtension(assetName);
        if (loadableExtensions.Contains(extension) == false)
            continue;

        string key = assetName.Replace("assets/3km/resource/", "").Replace(extension, "");
        assetBundles[key] = new ThreeKmAssetBundle(assetName, assetBundle);
    }
}

3. 광고와 재화 결제를 통합한 뽑기 흐름

기획 요구사항: 스킬과 장비 뽑기는 티켓을 사용하는 상품과 보상형 광고 상품을 같은 결과 화면으로 연결해야 했습니다. 연출 중 중복 입력을 막고 서버 성공 응답 이후에만 횟수, 광고 기록과 능력치를 갱신해야 했습니다.

구현 방법: ProcessPayment에서 summonCost를 기준으로 광고와 재화 검증을 분리했습니다. Gacha는 해금 확인, 진행 팝업, 결제, 서버 요청, 결과 이벤트와 후처리의 실행 순서를 하나의 UniTask 흐름으로 관리했습니다.

동작 흐름: 진행 중 flag와 콘텐츠 해금을 먼저 확인한 뒤 GachaProgressPopup을 표시합니다. 광고 상품은 ShowRewardedAd 완료를 기다리고, 재화 상품은 소모 아이템 종류와 보유량을 확인합니다. 서버가 성공을 반환하면 뽑기 횟수와 이벤트를 갱신하고 광고 상품의 시청 시간을 저장한 뒤 능력치를 다시 계산합니다. finally에서 flag를 해제하고 진행 팝업의 완료 연출을 보장합니다.

선택 이유: 지불 조건을 결과 처리와 분리해 광고와 티켓이 이후 서버 요청 및 결과 UI를 공유하도록 했습니다. try와 finally로 성공 여부와 관계없이 입력 잠금과 진행 UI를 정리할 수 있습니다.

검토한 대안: 광고 상품과 재화 상품을 별도 메서드로 끝까지 구현하면 해금, 서버 요청과 결과 반영 코드가 중복되고, UI 버튼에서 광고와 패킷 호출을 직접 연결하면 연출 중 입력 잠금과 실패 정리가 화면마다 달라질 수 있어 제외했습니다.

결과와 한계: 두 지불 방식을 하나의 뽑기 결과 흐름으로 연결하고 성공 이후의 상태 갱신 순서를 명확히 했습니다. 정적 flag가 모든 뽑기를 하나로 잠그고 지불 실패가 bool로만 반환되어 광고 취소와 재화 부족을 구분하기 어렵습니다. 광고 상품 설정 ID도 코드에 고정되어 있어 테이블 관계로 옮길 여지가 있습니다.

관련 코드 보기 - 결제 분기와 서버 결과 반영

광고 또는 티켓 사용 조건 확인

private static async UniTask<bool> ProcessPayment(GachaType gachaType, SummonConfig config)
{
    if (config.summonCost <= 0)
    {
        eAdsCategory category = gachaType == GachaType.Skill ? eAdsCategory.Ads_Skill : eAdsCategory.Ads_Equip;

        var adResult = await AdvertisementManager.Instance.ShowRewardedAd(category);
        return adResult != null;
    }

    ItemEffect expectedEffect = gachaType == GachaType.Skill ? ItemEffect.Consume_SkillTicket : ItemEffect.Consume_EquipTicket;
    var consumeItem = ItemDataCollection.FindById(config.consumeItemIdx);

    if (consumeItem.itemEffect != expectedEffect)
        return false;

    int itemCount = UserInventoryDataController.Instance.GetItemCount(consumeItem.index);
    return itemCount >= config.summonCost;
}

입력 잠금, 서버 요청과 성공 후 상태 갱신

public static async UniTask<bool> Gacha(GachaType gachaType, SummonConfig config, Action<GachaResult> onComplete)
{
    if (flag)
        return false;

    if (IsUnlock(gachaType) == false)
    {
        onComplete?.Invoke(new GachaResult(false, 0, null, gachaType, eGachaResult.Fail));
        return false;
    }

    GachaResult resultData = null;
    bool success = false;
    var progress = PopupUIManager.Instance.ShowGachaProgressPopupUI();

    try
    {
        flag = true;
        bool paid = await ProcessPayment(gachaType, config);
        if (paid)
        {
            var response = await REST_GachaItem.SendPacket(
                new REQ_GachaItem
                {
                    summonConfigIndex = config.index
                });

            success = response.result == ResponseCode.SUCCESS;
            if (success)
            {
                횟수증가(gachaType, config.rewardCount);
                resultData = new GachaResult(true, config.rewardCount, response.ResultItem, gachaType);

                GameEventManager.Publish(new GachaEventParam
                {
                    count = config.rewardCount,
                    gachaType = gachaType
                });

                if (config.index == 1 || config.index == 6)
                {
                    eAdsCategory category = gachaType == GachaType.Skill ? eAdsCategory.Ads_Skill : eAdsCategory.Ads_Equip;
                    await AdvertisementManager.Instance.SaveAdTime(category);
                }

                UserDataManager.UpdateTotalStat(true).Forget();
            }
        }
    }
    catch (Exception exception)
    {
        Debug.LogError(exception);
    }
    finally
    {
        flag = false;
        resultData ??= new GachaResult(false, config.rewardCount, null, gachaType, eGachaResult.ServerError);
        await progress.Complete();
    }

    onComplete?.Invoke(resultData);
    return success;
}

4. Excel 기반 Client와 Server 테이블 생성 자동화

운영 요구사항: 기획 테이블 한 곳에서 Unity 클라이언트와 서버가 사용할 CSV를 각각 만들고, 컬럼 타입과 enum 변경을 C# 테이블 코드에도 같은 기준으로 반영해야 했습니다.

구현 방법: Excel 워크북을 Client와 Server용 CSV 및 C# 코드로 변환하는 XlsxToCSVExporter를 제작해 프로젝트에 사용했습니다. Excel COM으로 시트를 UTF-8 CSV로 내보내고, 빌드 대상 행의 Client, Server와 Both 값을 기준으로 컬럼을 분리했습니다. Roslyn SyntaxFactory로 테이블 데이터 클래스와 enum 코드를 생성했습니다.

동작 흐름: 실행 인수로 전달된 Excel 파일 또는 폴더를 열고 '_'로 시작하는 시트는 제외합니다. 일반 시트와 'enum_' 시트를 분리한 뒤 Client와 Server용 CSVReader를 각각 생성합니다. CSV와 C# 코드를 output에 만든 후 config.json에 지정한 클라이언트와 서버 경로로 복사합니다.

선택 이유: 테이블 데이터와 코드 타입을 같은 Excel 메타데이터에서 생성하면 CSV 컬럼과 C# 프로퍼티를 별도로 수정할 때 생기는 불일치를 줄일 수 있습니다. Client와 Server 전용 컬럼도 원본 시트를 나누지 않고 하나의 빌드 대상 행으로 관리할 수 있습니다.

검토한 대안: Excel에서 CSV를 수동 저장하면 시트 수만큼 반복 작업이 필요하고, 런타임에 모든 타입을 문자열로 해석하면 잘못된 컬럼명과 데이터 타입을 컴파일 전에 확인하기 어려워 제외했습니다.

결과와 한계: Excel 수정부터 양쪽 CSV, 테이블 클래스와 enum 생성까지 하나의 실행 흐름으로 연결했습니다. Excel COM을 사용하므로 Windows와 Microsoft Excel 설치가 필요하고, 메타데이터 행과 시트 이름 규칙을 지켜야 합니다.

관련 코드 보기 - 시트 분류, CSV 분리와 C# 코드 생성

워크북 시트를 일반 테이블과 enum으로 분류

for (int index = 1; index <= worksheets.Count; index++)
{
    Worksheet sheet = (Worksheet)worksheets[index];
    try
    {
        if (sheet.Name.StartsWith("_"))
            continue;

        string csvPath = $"{outputPath}/{sheet.Name}.csv";
        sheet.SaveAs(csvPath, XlFileFormat.xlCSVUTF8, Local: true);

        if (sheet.Name.StartsWith("enum_", StringComparison.OrdinalIgnoreCase))
        {
            enumList.Add(csvPath);
        }
        else
        {
            tableList.Add(csvPath);
        }
    }
    finally
    {
        ReleaseComObject(sheet);
    }
}

같은 시트에서 Client와 Server 결과를 각각 생성

foreach (string csvPath in tableList)
{
    var client = new CSVReader(csvPath, CSVWriteType.Client);
    var server = new CSVReader(csvPath, CSVWriteType.Server);

    string tableName = Path.GetFileName(csvPath);
    if (tableName.EndsWith("Table.csv", StringComparison.OrdinalIgnoreCase) == false)
    {
        tableName = tableName.Replace(".csv", "Table.csv");
    }

    FileExtension.ProcessCreateFile(PATH_OUTPUT_CLIENT_CSV, tableName, client.GetCSV());
    FileExtension.ProcessCreateFile(PATH_OUTPUT_SERVER_CSV, tableName, server.GetCSV());

    string scriptName = tableName.Replace(".csv", ".cs");

    string clientCode = client.GetClassCode();
    if (clientCode != null)
    {
        FileExtension.ProcessCreateFile(PATH_OUTPUT_CLIENT_SCRIPT, scriptName, clientCode);
    }

    string serverCode = server.GetClassCode();
    if (serverCode != null)
    {
        FileExtension.ProcessCreateFile(PATH_OUTPUT_SERVER_SCRIPT, scriptName, serverCode);
    }
}

CSVReader의 출력 대상, 캐시와 입력 파일 구성

public enum CSVWriteType
{
    None = 0,
    Server = 1 << 0,
    Client = 1 << 1
}

private static readonly HashSet<string> s_numericTypes = new(StringComparer.OrdinalIgnoreCase)
    {
        "int", "long", "short", "float", "double",
        "uint", "ulong", "ushort"
    };

private readonly Dictionary<int, string> columns = new();
private readonly Dictionary<int, string> dataTypes = new();
private readonly Dictionary<int, Dictionary<int, string>>
    resultDataLines = new();
private readonly string _csvPath;
private readonly CSVWriteType _buildType;

public CSVReader(string csvPath, params CSVWriteType[] targets)
{
    _csvPath = csvPath;
    _buildType = CSVWriteType.None;

    foreach (CSVWriteType target in targets)
        _buildType |= target;
}

CSV 파싱과 빌드 대상별 컬럼 및 데이터 선택

private void LoadData()
{
    if (columns.Count != 0)
        return;

    string sheetName = Path.GetFileName(_csvPath);
    string text = File.ReadAllText(_csvPath, Encoding.UTF8);
    List<string[]> records = ParseCsv(text);

    string[] columnNames = GetRecord(records, Config.Default.ColumnNameRow, sheetName);
    string[] buildTargets = GetRecord(records, Config.Default.BuildTargetRow, sheetName);
    string[] sourceTypes = GetRecord(records, Config.Default.DataTypeRow, sheetName);

    int metadataColumnCount = Math.Max(columnNames.Length, Math.Max(buildTargets.Length, sourceTypes.Length));
    int startColumn = Math.Max(0, Config.Default.StartColumn);

    for (int column = startColumn; column < metadataColumnCount; column++)
    {
        string name = GetCell(columnNames, column);
        string target = GetCell(buildTargets, column);
        string type = GetCell(sourceTypes, column);

        if (string.IsNullOrWhiteSpace(name) || name.StartsWith('#') || string.IsNullOrWhiteSpace(target) || string.IsNullOrWhiteSpace(type))
        {
            continue;
        }

        bool included = target.Equals("both", StringComparison.OrdinalIgnoreCase) || target.Equals(_buildType.ToString(), StringComparison.OrdinalIgnoreCase);
        if (included == false)
            continue;

        columns.Add(column, name);
        dataTypes.Add(column, type.Replace("\"", ""));
    }

    for (int row = Config.Default.DataRow; row < records.Count; row++)
    {
        if (records[row].All(string.IsNullOrWhiteSpace))
            continue;

        foreach (int column in columns.Keys)
        {
            string cell = GetCell(records[row], column);
            if (s_numericTypes.Contains(dataTypes[column]))
                cell = cell.Replace(",", "");

            if (resultDataLines.ContainsKey(row) == false)
            {
                resultDataLines[row] = new Dictionary<int, string>();
            }
            resultDataLines[row][column] = cell;
        }
    }
}

private static string[] GetRecord(List<string[]> records, int index, string sheetName)
{
    if (index < 0 || index >= records.Count)
    {
        throw new InvalidDataException($"{sheetName}: CSV 설정 행 {index}을 찾을 수 없습니다.");
    }

    return records[index];
}

private static string GetCell(string[] cells, int index)
{
    return index >= 0 && index < cells.Length ? cells[index] : string.Empty;
}

따옴표, 쉼표와 줄바꿈을 유지하는 CSV 파싱과 출력

private static List<string[]> ParseCsv(string text)
{
    var records = new List<string[]>();
    var fields = new List<string>();
    var field = new StringBuilder();
    bool isQuoted = false;

    for (int index = 0; index < text.Length; index++)
    {
        char current = text[index];
        if (isQuoted)
        {
            if (current == '\"')
            {
                if (index + 1 < text.Length && text[index + 1] == '\"')
                {
                    field.Append('\"');
                    index++;
                }
                else
                {
                    isQuoted = false;
                }
            }
            else
            {
                field.Append(current);
            }
            continue;
        }

        if (current == '\"' && field.Length == 0)
        {
            isQuoted = true;
        }
        else if (current == ',')
        {
            fields.Add(field.ToString());
            field.Clear();
        }
        else if (current == '\r' || current == '\n')
        {
            if (current == '\r' && index + 1 < text.Length && text[index + 1] == '\n')
            {
                index++;
            }

            fields.Add(field.ToString());
            field.Clear();
            records.Add(fields.ToArray());
            fields.Clear();
        }
        else
        {
            field.Append(current);
        }
    }

    if (isQuoted)
        throw new FormatException("닫히지 않은 CSV 필드입니다.");

    if (field.Length > 0 || fields.Count > 0)
    {
        fields.Add(field.ToString());
        records.Add(fields.ToArray());
    }

    return records;
}

private static string EscapeCsvField(string value)
{
    if (value.IndexOfAny(
        new[] { ',', '\"', '\r', '\n' }) < 0)
    {
        return value;
    }

    return $"\"{value.Replace("\"", "\"\"")}\"";
}

public string GetCSV()
{
    LoadData();
    var builder = new StringBuilder();
    builder.AppendLine(string.Join(',', columns.Values.Select(EscapeCsvField)));

    if (dataTypes.Count > 0)
    {
        builder.AppendLine(string.Join(',', dataTypes.Values.Select(EscapeCsvField)));
    }

    foreach (var row in resultDataLines.Values)
    {
        builder.AppendLine(string.Join(',', row.Values.Select(EscapeCsvField)));
    }

    return builder.ToString();
}

컬럼 타입으로 테이블 데이터 클래스와 조회 키 생성

public string GetClassCode()
{
    LoadData();
    if (resultDataLines.Count == 0)
        return null;

    string className = Path.GetFileName(_csvPath).Replace(".csv", "");
    if (className.EndsWith("Table", StringComparison.OrdinalIgnoreCase) == false)
    {
        className += "Table";
    }

    var indexColumn = columns.FirstOrDefault(column => dataTypes[column.Key].Equals("Index", StringComparison.OrdinalIgnoreCase));
    string indexType = indexColumn.Value == null || int.TryParse(resultDataLines.First().Value[indexColumn.Key], out _) ? "int" : "string";

    var properties = columns.Select(column => SyntaxFactory.PropertyDeclaration(SyntaxFactory.ParseTypeName(column.Key == indexColumn.Key ? indexType : dataTypes[column.Key]), column.Value).AddModifiers(SyntaxFactory.Token(SyntaxKind.PublicKeyword)).AddAccessorListAccessors(SyntaxFactory.AccessorDeclaration(SyntaxKind.GetAccessorDeclaration).WithSemicolonToken(SyntaxFactory.Token(SyntaxKind.SemicolonToken)), SyntaxFactory.AccessorDeclaration(SyntaxKind.SetAccessorDeclaration).WithModifiers(SyntaxFactory.TokenList(SyntaxFactory.Token(SyntaxKind.PrivateKeyword))).WithSemicolonToken(SyntaxFactory.Token(SyntaxKind.SemicolonToken)))).ToList();

    MethodDeclarationSyntax getKeyMethod;
    if (indexColumn.Value == null)
    {
        properties.Add(SyntaxFactory.PropertyDeclaration(SyntaxFactory.ParseTypeName("int"), "Index_AutoIncremented").AddModifiers(SyntaxFactory.Token(SyntaxKind.PublicKeyword)).AddAccessorListAccessors(SyntaxFactory.AccessorDeclaration(SyntaxKind.GetAccessorDeclaration).WithSemicolonToken(SyntaxFactory.Token(SyntaxKind.SemicolonToken)), SyntaxFactory.AccessorDeclaration(SyntaxKind.SetAccessorDeclaration).WithModifiers(SyntaxFactory.TokenList(SyntaxFactory.Token(SyntaxKind.PrivateKeyword))).WithSemicolonToken(SyntaxFactory.Token(SyntaxKind.SemicolonToken))));

        getKeyMethod = SyntaxFactory.MethodDeclaration(SyntaxFactory.ParseTypeName(indexType), "GetKey").AddModifiers(SyntaxFactory.Token(SyntaxKind.PublicKeyword), SyntaxFactory.Token(SyntaxKind.OverrideKeyword)).WithExpressionBody(SyntaxFactory.ArrowExpressionClause(SyntaxFactory.IdentifierName("Index_AutoIncremented"))).WithSemicolonToken(SyntaxFactory.Token(SyntaxKind.SemicolonToken));
    }
    else
    {
        getKeyMethod = SyntaxFactory.MethodDeclaration(SyntaxFactory.ParseTypeName(indexType), "GetKey").AddModifiers(SyntaxFactory.Token(SyntaxKind.PublicKeyword), SyntaxFactory.Token(SyntaxKind.OverrideKeyword)).WithExpressionBody(SyntaxFactory.ArrowExpressionClause(SyntaxFactory.IdentifierName(indexColumn.Value))).WithSemicolonToken(SyntaxFactory.Token(SyntaxKind.SemicolonToken));
    }

    var dataClass = SyntaxFactory.ClassDeclaration($"{className}Data").AddModifiers(SyntaxFactory.Token(SyntaxKind.PublicKeyword), SyntaxFactory.Token(SyntaxKind.PartialKeyword)).AddBaseListTypes(SyntaxFactory.SimpleBaseType(SyntaxFactory.ParseTypeName($"BaseTableData<{indexType}>"))).AddMembers(properties.ToArray()).AddMembers(getKeyMethod);

    var tableClass = SyntaxFactory.ClassDeclaration(className).AddModifiers(SyntaxFactory.Token(SyntaxKind.PublicKeyword), SyntaxFactory.Token(SyntaxKind.PartialKeyword)).AddBaseListTypes(SyntaxFactory.SimpleBaseType(SyntaxFactory.ParseTypeName($"BaseTable<{className}Data, {indexType}>")));

    var compilationUnit = SyntaxFactory.CompilationUnit().AddUsings(SyntaxFactory.UsingDirective(SyntaxFactory.ParseName("System")), SyntaxFactory.UsingDirective(SyntaxFactory.ParseName("System.Collections.Generic")));

    if (string.IsNullOrWhiteSpace(Config.Default.ResultNameSpace) == false)
    {
        compilationUnit = compilationUnit.AddMembers(SyntaxFactory.NamespaceDeclaration(SyntaxFactory.ParseName(Config.Default.ResultNameSpace)).AddMembers(dataClass, tableClass));
    }
    else
    {
        compilationUnit = compilationUnit.AddMembers(dataClass, tableClass);
    }

    return compilationUnit.NormalizeWhitespace().ToFullString();
}

출력 폴더 생성과 파일 저장, Excel COM 객체 정리

public static string ProcessCreateFile(string directory, string fileName, string body)
{
    string path = Path.Combine(directory, fileName);
    string parent = Path.GetDirectoryName(path);

    if (parent != null && Directory.Exists(parent) == false)
    {
        Directory.CreateDirectory(parent);
    }

    File.WriteAllText(path, body, Encoding.UTF8);
    return path;
}

private static void ReleaseComObject(object comObject)
{
    if (comObject != null && Marshal.IsComObject(comObject))
    {
        Marshal.FinalReleaseComObject(comObject);
    }
}

[쓰리케이엠] 디펜스 게임 개발 및 출시

Unity 클라이언트 중심 단독 개발 (2025.07 ~ 2025.10)

타워 디펜스 게임의 전투, 웨이브, 유닛, 스킬과 버프 구조를 구현하고, 클라이언트 기능에 필요한 서버 API, 데이터 도구와 배포 환경까지 연결했습니다.

역할: Unity 클라이언트 설계 및 구현, 서버 API 연동, 개발 도구와 CI/CD

대표 성과: 데이터로 확장 가능한 전투 구조와 비동기 작업의 종료 규칙을 구성하고, API 코드 생성과 환경별 모바일 빌드를 자동화했습니다.

핵심 기술: Unity, C#, UniTask, Addressables, Unity IAP, ASP.NET Core, Swagger, NSwag, Jenkins


Unity 클라이언트 구현 사례

1. 웨이브와 유닛의 비동기 작업 취소

기획 요구사항: 웨이브 시간과 생성 간격을 비동기로 제어하면서, 씬 전환, 전투 강제 종료와 유닛 제거 시 관련 작업이 즉시 중단되어야 했습니다. 유닛은 오브젝트 풀로 재사용해야 하므로 전투에서 제거할 때 GameObject를 Destroy할 수 없었습니다.

구현 방법: 씬, 전투와 유닛의 수명에 따라 CancellationToken의 소유자를 분리했습니다. UI는 destroyCancellationToken, BattleSpawner와 Unit은 각각 별도의 CancellationTokenSource를 사용했습니다.

동작 흐름: NextWave와 SpawnMonsters의 대기 작업에는 씬 토큰과 전투 토큰을 함께 연결했습니다. BattleSpawner.Dispose에서는 전투 토큰을 취소하고, Unit.OnDead에서는 유닛 토큰을 취소한 뒤 Dispose합니다. 따라서 유닛을 Destroy하지 않고 비활성화해 풀로 반환해도 버프와 AI 작업을 종료할 수 있습니다.

선택 이유: Unity 기본 destroyCancellationToken은 GameObject가 파괴될 때만 취소되므로, 씬을 유지한 전투 종료와 오브젝트 풀 반환을 표현할 수 없습니다. GameObject의 존재 기간과 전투에서 사용하는 기간이 다르기 때문에 토큰도 씬, 전투와 유닛 단위로 나눴습니다.

검토한 대안: 씬 파괴 토큰만 사용하는 방식은 풀링 객체를 처리할 수 없고, 하나의 전역 전투 토큰만 사용하면 개별 유닛의 사망과 부활 수명을 구분하기 어렵습니다. 모든 비동기 작업을 직접 찾아 중단하는 방식은 신규 버프와 AI 기능이 추가될 때 정리 코드가 누락될 가능성이 있어 제외했습니다.

결과와 한계: 씬, 전투와 유닛 수명에 맞춰 비동기 작업을 종료하고 취소와 실행 예외를 구분할 수 있게 했습니다. CancellationTokenSource를 생성한 객체가 직접 Cancel과 Dispose를 호출한다는 규칙을 지켜야 합니다.

관련 코드 보기 - 비동기 작업 취소와 정리

씬과 전투 종료 조건을 연결한 웨이브 대기

try
{
    await UniTask.Delay(TimeSpan.FromSeconds(targetWave.Duration), cancellationToken: _parent.UI.GetCancellationTokenOnDestroy()).AttachExternalCancellation(_spawnCts.Token);
}
catch (OperationCanceledException)
{
    return;
}

public void Dispose()
{
    _spawnCts?.Cancel();
    _spawnCts?.Dispose();
    _spawnCts = null;
}

Destroy 없이 풀로 반환되는 유닛의 수명 종료

private CancellationTokenSource _destroyOrDeadCts;

public new CancellationToken destroyCancellationToken
{
    get
    {
        if (_destroyOrDeadCts == null)
            _destroyOrDeadCts = new CancellationTokenSource();

        return _destroyOrDeadCts.Token;
    }
}

protected async virtual UniTask OnDead(bool canRevive)
{
    _destroyOrDeadCts?.Cancel();
    _destroyOrDeadCts?.Dispose();
    _destroyOrDeadCts = null;

    await SkillHandler.OnDead(Killer);
}

2. 데이터 기반 전투 확장

기획 요구사항: 웨이브마다 생성 위치와 간격을 다르게 구성하고, 스킬은 탐색 범위, 대상 진영, 체력 조건과 효과 적용 조건을 조합할 수 있어야 했습니다. 기획자가 기존 규칙의 조합을 코드 수정 없이 테이블에서 변경할 수 있어야 했습니다.

구현 방법: 변하지 않는 전투 실행 흐름과 콘텐츠마다 달라지는 규칙을 분리했습니다. 웨이브 생성은 IWavePattern 구현으로 나누고, 스킬 탐색은 DetectionType과 SkillTargetType 조합으로 구성했습니다.

동작 흐름: BattleSpawner는 테이블의 Pattern 값으로 FixedLine, Progressive, Random, Uniform 구현을 선택합니다. SkillData는 Circle, TileXY, UnderHpRatio와 같은 탐색 규칙을 실행한 뒤 공통 유효성 검사와 거리 정렬을 적용합니다. 이후 SkillEffectData가 대상별 효과 적용 가능 여부를 확인합니다.

선택 이유: 생성 방식, 대상 조건과 효과를 서로 독립적으로 조합하면 콘텐츠별 전용 클래스를 반복해서 만들지 않고 테이블 한 행으로 하나의 콘텐츠 구성을 표현할 수 있습니다.

검토한 대안: 콘텐츠별 전용 클래스는 조합 수만큼 코드가 증가하고, 하나의 큰 조건문은 스폰, 탐색과 효과 책임이 한곳에 모입니다. 모든 규칙을 ScriptableObject로 분리하는 방식도 가능하지만 서버와 공유하는 테이블 중심 작업 흐름을 유지하기 위해 현재 구조를 선택했습니다.

결과와 한계: 기존 규칙의 조합은 테이블 변경으로 처리해 콘텐츠별 중복과 수정 범위를 줄였습니다. 새로운 탐색 방식 자체를 추가할 때는 DetectionType 분기와 구현 코드가 필요합니다.

관련 코드 보기 - 데이터 기반 전투

테이블 값으로 웨이브 생성 전략 선택

switch (targetWave.Pattern)
{
    case WavePattern.Progressive:
        targetWavePattern = new ProgressiveWave(targetWave.PatternParameter[0]);
        break;
    case WavePattern.Uniform:
        targetWavePattern = new UniformWave(targetWave.PatternParameter[0]);
        break;
    case WavePattern.FixedLine:
        targetWavePattern = new FixedLineWave(targetWave.PatternParameter);
        break;
    default:
        targetWavePattern = new RandomWave(_parent.BattleField.CreatedRow, targetWave.PatternParameter[0]);
        break;
}

공통 인터페이스 뒤에서 중앙 라인을 기준으로 생성 범위를 넓히는 Progressive 전략

public interface IWavePattern
{
    Vector3Int GetSpawnTileCoordinate();
}

public sealed class ProgressiveWave : IWavePattern
{
    private readonly int _createdRow;
    private readonly int _center;
    private readonly HashSet<int> _lines = new();
    private int _step;

    public ProgressiveWave(int createdRow)
    {
        _createdRow = createdRow;
        _center = createdRow / 2;
        _lines.Add(_center);
    }

    public Vector3Int GetSpawnTileCoordinate()
    {
        if (_step < _createdRow / 2)
        {
            int upper = _center - _step;
            int lower = _center + _step;

            if (upper >= 0)
                _lines.Add(upper);
            if (lower < _createdRow)
                _lines.Add(lower);

            _step++;
        }

        int index = UnityEngine.Random.Range(0, _lines.Count);
        int selectedLine = _lines.ElementAt(index);
        return new Vector3Int(-3, selectedLine);
    }
}

DetectionType에 따른 스킬 대상 탐색

case DetectionType.Circle:
{
    Collider2D[] hits = Physics2D.OverlapCircleAll(Caster.transform.position, TableData.DetectionValue, LayerMask.GetMask("Unit"));

    foreach (var col in hits)
    {
        var enemy = col.GetComponent<Unit>();
        if (enemy == null || CheckCasting(enemy, type) == false)
            continue;

        if (Effects.All(effect => effect.IsValid(enemy)))
            candidates.Add(enemy);
    }
    break;
}

3. SwaggerToUnity 제작과 서버 연동 자동화

개발 요구사항: 서버 API가 변경될 때 Unity 요청 메서드와 DTO도 같은 API 명세를 따라야 했습니다. 인증, 전투 결과, 랭킹과 결제처럼 여러 기능의 API를 반복 작성하지 않고 클라이언트에 연결해야 했습니다.

구현 방법: OpenAPI 명세에서 Unity용 코드를 생성하는 SwaggerToUnity 도구를 직접 제작해 프로젝트에 적용했습니다. UnityWebRequest, UniTask, JWT 인증과 프로젝트 공통 응답 처리를 생성 코드에 포함했습니다.

동작 흐름: 여러 서버의 Swagger JSON을 병합한 뒤 API를 컨트롤러 단위로 분리합니다. NSwag로 컨트롤러별 API 클라이언트와 DTO를 생성하고 Unity 프로젝트의 Generated 디렉터리에 반영합니다. 서버 명세가 변경되면 도구를 다시 실행해 통신 코드를 갱신합니다.

선택 이유: OpenAPI를 공통 명세로 사용하면 서버와 Unity가 서로 다른 실행 환경을 유지하면서도 동일한 명세에서 타입과 요청 코드를 생성할 수 있습니다. 프로젝트별 템플릿을 적용할 수 있어 범용 생성 코드와 Unity 통신 규칙을 함께 관리할 수 있었습니다.

검토한 대안: 요청 코드와 DTO를 수동으로 관리하면 같은 API 명세를 서버와 Unity에 각각 반영해야 하고, 서버 어셈블리 공유는 Unity 호환성과 서버 배포 구조의 결합도가 높아질 수 있어 제외했습니다.

결과와 한계: API 변경 반영 과정을 도구 실행으로 통합해 반복 작성 범위와 누락 가능성을 줄였습니다. 생성기 템플릿과 공통 네트워크 설정은 Unity 버전과 통신 정책이 바뀔 때 함께 관리해야 합니다.

4. 환경별 빌드와 콘텐츠 배포

운영 요구사항: DEV, QA, LIVE 환경별 서버 주소, 로그 설정, 앱 버전과 빌드 형식을 분리하고, 모바일 실행 파일과 Addressables 콘텐츠를 같은 배포 기준으로 생성해야 했습니다.

구현 방법: Unity Editor 빌드 코드를 Jenkins 파라미터로 호출하도록 구성했습니다. 버전, 빌드 번호, 환경 심볼, Android APK와 AAB 및 iOS 출력을 빌드 입력값으로 받았습니다.

동작 흐름: 선택한 환경에 맞춰 define symbol과 Addressables 프로필을 적용합니다. content state 파일이 있으면 콘텐츠 업데이트 빌드를 실행하고, 없으면 전체 빌드를 실행합니다. LIVE 프로필의 원격 경로를 기준으로 버전별 에셋 산출물을 생성합니다.

선택 이유: Unity API를 사용하는 빌드 코드는 프로젝트 설정과 Addressables 상태를 직접 확인할 수 있고, Jenkins는 같은 입력값과 실행 순서를 반복할 수 있어 두 역할을 연결했습니다.

검토한 대안: Unity Editor에서 수동으로 설정하면 환경 값이 섞일 수 있고, 외부 스크립트가 프로젝트 파일을 직접 수정하면 Unity 내부 설정과 실제 값이 달라질 수 있어 제외했습니다.

결과와 한계: 환경 설정과 산출물 종류를 빌드 파라미터로 명시해 수동 설정 누락 가능성을 줄이고, 클라이언트 버전과 원격 콘텐츠를 같은 배포 흐름에서 관리할 수 있게 했습니다. 빌드 머신의 SDK, 인증 정보와 Unity 라이선스는 별도 운영 설정으로 관리해야 합니다.

관련 코드 보기 - 빌드와 콘텐츠 배포

Jenkins 입력값에 따른 환경 심볼 설정

switch (buildType.ToLower())
{
    case "dev":
        defineSymbols = "LOCAL;NET_LOG;DEV;DEBUG";
        break;
    case "qa":
        defineSymbols = "STAGING";
        break;
    case "live":
        defineSymbols = "LIVE";
        break;
}

PlayerSettings.SetScriptingDefineSymbols(namedBuildTarget, defineSymbols);

content state 유무에 따른 Addressables 빌드 분기

string previousStatePath = $"Assets/AddressableAssetsData/{buildTarget}/" + "addressables_content_state.bin";

if (File.Exists(previousStatePath))
{
    ContentUpdateScript.BuildContentUpdate(settings, previousStatePath);
}
else
{
    AddressableAssetSettings.BuildPlayerContent();
}

SwaggerToUnity - 오픈소스 코드 생성 도구

개인 오픈소스 프로젝트 (github.com/kissttokki/SwaggerToUnity)

Swagger(OpenAPI) 명세를 읽어 Unity용 C# API 클라이언트와 DTO를 자동 생성하는 커맨드라인 도구입니다. Unity 클라이언트와 서버 API를 연동하는 반복 작업을 줄이기 위해 직접 설계하고 구현했습니다.

역할: 단독 설계 및 구현

대표 성과: 여러 서버의 API 명세를 병합하고 컨트롤러별 Unity API 클라이언트와 DTO를 자동 생성해, API 변경이 클라이언트 통신 코드에 즉시 반영되도록 파이프라인을 구축했습니다.

핵심 기술: C#, NSwag, OpenAPI(Swagger), UnityWebRequest, UniTask, JWT Bearer

주요 기능

  • 여러 서버의 Swagger JSON 명세를 병합하고 컨트롤러별로 분리해 독립적인 API 클라이언트 파일을 생성.
  • 생성 코드에 UnityWebRequest, UniTask 비동기 처리, JWT Bearer 헤더를 자동 삽입해 Unity 프로젝트와 즉시 연동 가능.
  • API 명세 변경 시 도구를 재실행하는 것만으로 클라이언트 코드와 DTO가 자동 갱신됨.

코드 예시

여러 OpenAPI 문서의 paths와 components를 하나의 문서로 병합하고, 같은 경로가 겹치면 HTTP 메서드 단위로 갱신합니다.

public async Task<OpenApiDocument> LoadAndMergeAsync(IEnumerable<string> swaggerUrls)
{
    JObject merged = new JObject
    {
        ["openapi"] = "3.0.0",
        ["info"] = new JObject
        {
            ["title"] = "Merged API",
            ["version"] = "1.0"
        },
        ["paths"] = new JObject(),
        ["components"] = new JObject()
    };

    foreach (string url in swaggerUrls)
    {
        string json = await httpClient.GetStringAsync(url);
        JObject source = JObject.Parse(json);

        if (source["openapi"] is not JValue || source["paths"] is not JObject sourcePaths)
        {
            throw new InvalidDataException("OpenAPI 3.0.x 문서가 아닙니다.");
        }

        JObject targetPaths = (JObject)merged["paths"];
        foreach (var path in sourcePaths.Properties())
        {
            if (targetPaths[path.Name] is JObject target && path.Value is JObject current)
            {
                MergePathItem(target, current);
            }
            else
            {
                targetPaths[path.Name] = path.Value.DeepClone();
            }
        }

        if (source["components"] is JObject components)
            MergeComponents((JObject)merged["components"], components);
    }

    return await OpenApiDocument.FromJsonAsync(merged.ToString());
}

병합된 API를 태그별로 다시 분리하고 NSwag에 Unity용 Liquid 템플릿을 적용해 컨트롤러와 DTO 파일을 생성합니다.

var groups = document.Paths.SelectMany(path => path.Value.ActualPathItem.Select(operation => new
        {
            Path = path.Key,
            Operation = operation,
            Tag = operation.Value.Tags.FirstOrDefault() ?? "Default"
        })).GroupBy(item => item.Tag);

foreach (var group in groups)
{
    var controllerJson = JObject.Parse(document.ToJson());
    var controllerPaths = (JObject)controllerJson["paths"];
    var includedOperations = group.Select(item => (item.Path, Method: item.Operation.Key.ToString().ToLowerInvariant())).ToHashSet();

    foreach (var path in controllerPaths.Properties().ToArray())
    {
        if (path.Value is not JObject pathItem)
            continue;

        foreach (var operation in pathItem.Properties().Where(item => OpenApiOperationNames.IsHttpOperation(item.Name)).ToArray())
        {
            if (includedOperations.Contains((path.Name, operation.Name.ToLowerInvariant())) == false)
            {
                operation.Remove();
            }
        }
    }

    var controllerDocument = await OpenApiDocument.FromJsonAsync(controllerJson.ToString());
    string className = outputNameResolver.ResolveClassName(config.ClassName, group.Key);
    var settings = new CSharpClientGeneratorSettings
    {
        ClassName = className,
        UseBaseUrl = true,
        AdditionalNamespaceUsages = new[]
        {
            "UnityEngine.Networking",
            "Cysharp.Threading.Tasks"
        },
        CSharpGeneratorSettings =
        {
            TemplateDirectory = "./Templates",
            Namespace = config.Namespace
        }
    };

    string code = new CSharpClientGenerator(controllerDocument, settings).GenerateFile(ClientGeneratorOutputType.Implementation);

    File.WriteAllText(Path.Combine(outputRoot, $"{className}.cs"), NormalizeLineEndings(code));
}

아래는 Liquid 템플릿이 생성하는 요청 메서드의 핵심 형태를 단순화한 예시입니다. UnityWebRequest에 JSON 본문과 JWT 헤더를 설정하고 UniTask로 응답을 기다린 뒤 DTO로 역직렬화합니다.

public static async UniTask<TResponse> SendAsync<TResponse>(string path, object body, CancellationToken cancellationToken)
{
    string url = NetworkConfig.BaseUrl.TrimEnd('/') + path;
    byte[] bodyRaw = Encoding.UTF8.GetBytes(JsonConvert.SerializeObject(body));

    using var request = new UnityWebRequest(url, "POST")
    {
        uploadHandler = new UploadHandlerRaw(bodyRaw),
        downloadHandler = new DownloadHandlerBuffer()
    };
    request.SetRequestHeader("Content-Type", "application/json");

    if (string.IsNullOrEmpty(NetworkConfig.JwtToken) == false)
    {
        request.SetRequestHeader("Authorization", $"Bearer {NetworkConfig.JwtToken}");
    }

    await request.SendWebRequest().ToUniTask(cancellationToken: cancellationToken);

    if (request.result != UnityWebRequest.Result.Success)
    {
        Debug.LogError(request.error);
        return default;
    }

    return JsonConvert.DeserializeObject<TResponse>(request.downloadHandler.text);
}