대표 프로젝트

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

GitHub

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

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

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

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

대표 성과: 공용 스탯 계산 계층 위에서 여러 서버 데이터 형식을 하나의 클라이언트 생성 흐름으로 통합하고, NGUI에 Arabic 문자열 호환 처리를 추가했으며 기존 버프 시스템을 조건형 옵션으로 확장했습니다.

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


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. 기존 버프 시스템의 조건형 옵션 확장

기획 요구사항: 신규 장비와 보스 패시브에 공격 타입, 피격 여부, 장비와 세트 구성, 캐릭터와 DP 비율처럼 서로 다른 조건으로 버프를 적용해야 했습니다.

구현 방법: 기존 BuffSystem을 유지하면서 조건 판정은 SkillSystem과 BossSkillSystem에 추가했습니다. 공격 이벤트, 라운드 시작과 장비 상태에서 조건을 확인하고 조건이 성립하면 공통 ApplyBuff를 호출하도록 구성했습니다.

동작 흐름: 보스전 시작 시 캐릭터와 장비 조건을 평가하고, 전투 중에는 AttackResult의 공격자, 피격자와 AttackType을 비교해 APPLY_BUFF_ON_HIT 조건을 처리합니다. 조건이 해제되는 옵션은 RemoveBuff를 호출해 이전 상태가 남지 않게 했습니다.

선택 이유: BuffSystem 안에 모든 콘텐츠 조건을 넣으면 전투 이벤트와 장비 데이터에 대한 의존성이 커집니다. 조건을 알고 있는 시스템에서 판정하고 기존 버프 적용 API를 재사용해 버프 수명 관리와 콘텐츠 규칙을 분리했습니다.

검토한 대안: 옵션마다 별도 버프 클래스를 만들면 비슷한 적용과 해제 코드가 반복되고, 조건을 테이블 문자열로만 처리하면 컴파일 단계에서 잘못된 대상과 파라미터를 확인하기 어려워 enum 기반 조건을 확장했습니다.

결과: 공격 타입, 피격, 게임 시작, 장비, 장비 세트, 캐릭터와 DP 비율 조건을 기존 버프 적용 흐름에 연결해 신규 장비 옵션과 보스 패시브를 같은 방식으로 확장할 수 있게 했습니다.

한계: 조건 분기가 BossSkillSystem에 누적되어 옵션이 계속 늘면 조건 평가기를 별도 객체로 분리할 필요가 있습니다.

관련 코드 보기 - 라운드 시작과 피격 조건 버프

캐릭터와 장비 세트 조건에 따른 적용과 해제

if (_bossPasiveSkillDataDict.TryGetValue(eBotSkillTriggerType.APPLY_BUFF_TARGET_CHARACTER, out var characterBuffs))
{
    foreach (BotSkillData data in characterBuffs)
    {
        if (data.TriggerData.Target.HasFlag(eBotSkillTriggerTarget.Target) && target.CharId == data.TriggerData.Value)
            ApplyBuff(data);
        else
            RemoveBuff(data);
    }
}

if (_bossPasiveSkillDataDict.TryGetValue(eBotSkillTriggerType.APPLY_BUFF_EQUIP_TARGET_GEARSET, out var gearSetBuffs))
{
    foreach (BotSkillData data in gearSetBuffs)
    {
        if (data.TriggerData.Target.HasFlag(eBotSkillTriggerTarget.Target) && CheckGearPotential(target, data.TriggerData.Value))
            ApplyBuff(data);
        else
            RemoveBuff(data);
    }
}

실제 피해가 발생한 공격 타입과 대상 확인

if (result.isAnyHitWithDamage &&
    _bossPasiveSkillDataDict.TryGetValue(eBotSkillTriggerType.APPLY_BUFF_ON_HIT, out var hitBuffs))
{
    foreach (BotSkillData data in hitBuffs)
    {
        bool attackTypeMatched = result.hitter.curAttack.type == (AttackType)data.TriggerData.Param ||
                                 data.TriggerData.Param == 4;

        if (attackTypeMatched &&
            result.hitter == target &&
            data.TriggerData.Target.HasFlag(eBotSkillTriggerTarget.Target))
        {
            ApplyBuff(data);
        }
    }
}

[멘티스코/뉴웨이블] 모바일 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와 View를 생성하는 주체를 아직 정하지 않아 둘 다 Monobehaviour로 설정해놓았으나 추후 Manager패턴등을 통해 UI 이벤트를 받아올 필요가 있는 View만 적용하는 방향으로 개선할 예정이였습니다.

관련 코드 보기 - 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 클라이언트 프로그래머

대표 성과: 프로토콜별 요청 큐와 오류 복구를 개선하고, 길드 생성부터 권한, 기부와 채팅까지 클라이언트 흐름을 구현했으며 Client와 Server용 테이블 생성을 자동화했습니다.

핵심 기술: Unity, C#, UniTask, UniRx, uGUI, REST API, 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. 길드 콘텐츠와 상태별 채팅 갱신

개발 요구사항: 길드 생성과 검색부터 가입 신청, 승인, 권한, 탈퇴와 해산까지 하나의 UI 흐름으로 연결하고, 출석과 기부 및 길드 채팅 상태를 서버 데이터와 일치시켜야 했습니다.

구현 방법: 길드 기능별 REST 요청과 응답 타입을 분리하고 생성, 목록, 멤버 관리, 기부와 채팅 UI에서 해당 패킷을 호출했습니다. 성공 응답 이후에만 길드 데이터와 화면을 갱신하고 서버 오류 코드는 공통 알림으로 처리했습니다.

동작 흐름: 길드 생성 시 이름 규칙과 재화를 먼저 확인한 뒤 서버 요청을 보냅니다. 가입 이후에는 멤버 권한, 출석과 기부 횟수를 서버 응답으로 갱신합니다. 채팅은 팝업 표시 여부와 절전 상태에 따라 2초, 7초 또는 중지로 조회 주기를 변경합니다.

선택 이유: 길드 기능마다 요청과 UI 갱신 조건이 달라 패킷 타입과 화면 책임을 분리했습니다. 채팅 조회 주기를 화면 상태에 따라 조절해 사용 중 반응성과 비활성 상태의 요청 비용을 함께 고려했습니다.

검토한 대안: 클라이언트 상태를 요청 전에 먼저 변경하면 서버 실패 시 되돌리기 로직이 필요해 성공 응답 뒤에 갱신했습니다. 채팅을 항상 짧은 주기로 조회하는 방식은 팝업을 닫거나 절전 모드에서도 불필요한 요청이 발생해 제외했습니다.

결과: 길드 생성, 가입과 승인, 강퇴, 권한, 출석, 기부, 랭킹과 채팅을 서버 패킷과 클라이언트 UI 상태가 일치하는 하나의 콘텐츠 흐름으로 연결했습니다.

한계: 채팅은 서버 푸시가 아닌 주기 조회 방식이므로 메시지 도착과 표시 사이에 최대 조회 주기만큼 지연될 수 있습니다.

관련 코드 보기 - 길드 생성과 채팅 조회 주기

길드명과 재화 검증 후 서버 성공 응답에서 UI 갱신

private async void OnClickCreate()
{
    if (string.IsNullOrWhiteSpace(_guildNameInput.text))
        return;

    if (UserInventoryDataController.Instance.GetItemCount(COST_ITEM_ID) < _cost)
    {
        OnPurchaseFailure();
        return;
    }

    RES_CreateGuild response = await REST_CreateGuild.SendPacket(
        new REQ_CreateGuild { guildName = _guildNameInput.text });

    if (response.result == ResponseCode.SUCCESS)
    {
        onCreateGuild?.Invoke();
        Close();
    }
}

표시 상태와 절전 모드에 따른 채팅 조회 간격

private readonly Dictionary<eChatState, ChatStateSetting> _chatStateSettings = new()
{
    { eChatState.SHOWING, new ChatStateSetting { refreshTime = 2f } },
    { eChatState.HIDING, new ChatStateSetting { refreshTime = 7f } },
    { eChatState.SLEEP, new ChatStateSetting { refreshTime = 0f } }
};

private void Update()
{
    if (_chatState == eChatState.SLEEP)
        return;

    if (_isLock == false &&
        (DateTime.UtcNow - _lastCheckTime).TotalSeconds > _chatStateSettings[_chatState].refreshTime)
    {
        ChatCheck();
    }
}

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.12)

타워 디펜스 게임의 전투, 웨이브, 유닛, 스킬과 버프 구조를 구현하고, 모드별 공통 UI, 게임 배속, 결제와 보상 서버 API, 데이터 도구와 배포 환경까지 연결했습니다.

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

대표 성과: 데이터 기반 전투와 비동기 종료 규칙을 구성하고, 모드별 인게임 UI를 공통화했으며 결제 영수증 검증부터 서버 보상 지급까지 연결했습니다.

핵심 기술: 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();
}

5. 게임 모드별 인게임 UI 공통화와 배속

개발 문제: 일반, 무한과 일일 던전이 덱, 웨이브, 점수와 설정 UI를 각 프리팹에 중복 보유해 공통 기능을 변경할 때 여러 프리팹과 스크립트를 함께 수정해야 했습니다.

구현 방법: 공통 직렬화 참조와 UI 동작을 UICommonInGame으로 분리하고, 모드별 UIInGamePanel이 공통 객체를 조합하도록 변경했습니다. 게임 배속은 TimeManager와 UIGameSpeedMultiplier로 분리해 1배와 2배 상태를 UI와 Time.timeScale에 반영했습니다.

동작 흐름: 공통 객체가 덱과 인벤토리 슬롯 생성, 웨이브 시간, 점수, 일시 정지와 전투 씬 정리를 담당합니다. 모드별 상태는 필요한 공통 메서드만 호출하고 배속 버튼은 현재 상태의 다음 배율과 이미지를 선택합니다.

선택 이유: 상속 계층에 모드별 예외를 계속 추가하는 대신 공통 UI를 조합 객체로 두어 공유 동작과 각 모드의 전투 규칙을 분리했습니다.

결과: 공통 UI 참조와 동작을 한곳으로 모았고, 관련 프리팹 변경 커밋에서 중복 직렬화 데이터가 약 3만 줄 감소했습니다. 배속은 현재 코드 기준 1배와 2배를 전환합니다.

한계: UICommonInGame이 여러 공통 기능을 함께 담당하므로 기능이 더 늘면 덱, 웨이브와 상태 UI 단위로 추가 분리할 필요가 있습니다.

관련 코드 보기 - 공통 UI와 1배, 2배 전환

모드가 공유하는 웨이브와 전투 종료 처리

public async UniTask PrepareForBattleUnload(SceneInstance loadedScene, Battle battle)
{
    if (loadedScene.Scene.IsValid())
    {
        battle?.Dispose();
        await SceneManager.UnloadSceneAsync(loadedScene.Scene);
    }
}

public void SetWaveInfo(int current, int max)
{
    WaveInfoLabel.text = $"WAVE {current}/{max}";
    WaveTimerLabel.gameObject.SetActive(current < max);
}

1배와 2배 범위 제한 및 UI 전환

public const float NORMAL_TIME_SCALE = 1f;
public const float MAX_TIME_SCALE = 2f;

public void SetGameTimeScale(float value)
{
    GameTimeScale = Mathf.Clamp(value, NORMAL_TIME_SCALE, MAX_TIME_SCALE);
    Time.timeScale = GameTimeScale;
}

public void OnClick()
{
    float currentTimeScale = TimeManager.Instance.GameTimeScale;
    int nextIndex = 0;
    for (int i = 0; i < _uiDatas.Length; i++)
    {
        if (Mathf.Approximately(currentTimeScale, _uiDatas[i].Multiplier))
        {
            nextIndex = (i + 1) % _uiDatas.Length;
            break;
        }
    }

    TimeManager.Instance.GameTimeScale = _uiDatas[nextIndex].Multiplier;
    _buttonImage.sprite = _uiDatas[nextIndex].ButtonSprite;
    _contentImage.sprite = _uiDatas[nextIndex].ContentSprite;
    Time.timeScale = TimeManager.Instance.GameTimeScale;
}

6. 결제 영수증 검증과 서버 보상 지급

운영 요구사항: 모바일 인앱 결제 결과를 클라이언트 판단만으로 보상에 반영하지 않고, 서버가 판매 조건과 영수증을 확인한 뒤 아이템과 캐릭터 보상을 지급해야 했습니다.

구현 방법: Unity IAP의 영수증, 스토어와 상품 정보를 Buy API로 전달했습니다. 서버는 판매 기간과 구매 제한을 확인하고 Google Play 또는 Apple 검증 서버에 영수증을 요청한 뒤 보상 지급을 트랜잭션으로 처리했습니다.

동작 흐름: 클라이언트가 플랫폼 상품을 구매하면 PendingOrder의 영수증을 서버에 전달합니다. 서버는 상품 테이블에서 가격과 보상을 다시 구성하고 영수증 검증 성공 후 아이템, 뽑기 보상과 캐릭터를 지급합니다. 클라이언트는 성공 응답의 지급 목록으로 보상 팝업을 표시합니다.

선택 이유: 상품 가격, 구매 제한과 지급 대상을 서버 테이블 기준으로 결정해 클라이언트 요청값 변조가 보상 수량과 종류를 직접 바꾸지 못하게 했습니다.

결과: Google Play와 Apple 결제를 서버 영수증 검증 및 보상 트랜잭션과 연결하고, 구매 제한, 업적과 지급 결과를 하나의 API 응답으로 처리했습니다.

한계: 결제 완료와 서버 응답 사이에 앱이 종료되는 경우를 대비한 미처리 주문 재조회와 중복 영수증 방어는 별도 운영 정책이 필요합니다.

관련 코드 보기 - 영수증 전달과 서버 지급 트랜잭션

Unity IAP 결과를 서버 구매 API로 전달

BuyResponse response = await ShopApiClient.BuyProductAsync(new BuyRequest
{
    CategoryName = shopCategory,
    ProductId = shopId,
    Receipt = order.Info.Receipt,
    Store = storeName,
    Arguments = args
});

if (response.Status == HttpStatusCode.OK)
{
    UIRewardPopup.ShowPopup(response.Items, response.Packmons).Forget();
}

검증 이후 보상 저장과 트랜잭션 확정

using var transaction = await _db.Database.BeginTransactionAsync();

if (payload.GachaRewardIds?.Count > 0)
{
    resultItems.AddRange(await _itemService.TakeReward(
        user, payload.GachaRewardIds, 1, false));
}

if (payload.ItemIds?.Count > 0)
{
    resultItems.AddRange(await _itemService.TakeItem(
        user,
        payload.ItemIds.Select(item => new ReqItemData(item.ItemId, item.Count)),
        false));
}

await _db.SaveChangesAsync();
await transaction.CommitAsync();

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, gzip

주요 기능

  • 여러 서버의 Swagger JSON 명세를 병합하고 컨트롤러별로 분리해 독립적인 API 클라이언트 파일을 생성.
  • paths와 components의 JSON 객체를 병합하고 중복 경로는 HTTP 메서드 단위로 갱신.
  • 생성 코드에 UnityWebRequest, UniTask, 취소 토큰, JWT 인증과 gzip 헤더를 반영.
  • OpenAPI의 상속 모델을 DTO 관계에 유지해 공통 응답과 파생 응답을 같은 타입 체계로 생성.
  • 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);
}