대표 프로젝트
Unity 클라이언트 개발을 중심으로 문제 상황, 설계 선택, 구현 근거와 결과를 정리했습니다. 서버는 클라이언트 기능을 완성하기 위해 직접 연동한 범위에서 설명합니다.
[네시삼십삼분] 복싱스타 라이브 서비스 & 시스템 리팩토링
챔피언스튜디오 사원 (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;
}
[네시삼십삼분] 실시간 멀티플레이 논타겟 액션 게임
챔피언스튜디오 팀장 (2022.02 ~ 2023.02)
실시간 멀티플레이 논타겟 액션 게임 신규 개발 프로젝트를 리드하며, Tick 기반 입력 예측과 서버 보정, 중력과 마찰 및 충돌 반응을 직접 계산하는 CharacterController 기반 물리 시스템을 구현했습니다.
역할: 팀장 / 리드 클라이언트 개발자
대표 성과: 네트워크 Tick용 Observable 확장, 순환 버퍼 서버 보정과 재실행 가능한 커스텀 물리 상태를 하나의 이동 흐름으로 구성했습니다.
핵심 기술: Unity, Unity NetCode for GameObjects, Photon Realtime Transport, UniRx, Custom Physics, CharacterController, ScriptableObject
Unity 클라이언트 구현 사례
1. 렌더링 입력과 네트워크 Tick 분리
기획 요구사항: 화면 프레임이 변해도 이동과 스킬 입력을 일정한 네트워크 Tick 단위로 서버에 전달해야 했습니다. 로컬 입력은 즉시 반응하면서 서버와 클라이언트가 같은 Tick 번호를 기준으로 상태를 처리해야 했습니다.
구현 방법: UniRx의 EveryUpdate에서 매 프레임 입력 상태를 갱신하고, 직접 구현한 ObservableEx가 NGO NetworkTickSystem.Tick을 UniRx 스트림으로 변환하도록 했습니다. EveryTick에서 InputPayload를 생성해 로컬 이동과 ServerRpc를 실행하고 Photon Realtime Transport를 NGO의 전송 계층으로 사용했습니다.
동작 흐름: 프레임 입력 수집, 이동 방향 갱신, Tick 발생, InputPayload 생성, 로컬 이동 예측, 입력 버퍼 저장, ServerRpc 전송 순서로 처리합니다.
선택 이유: 가변 프레임 입력과 고정 Tick 게임 로직을 분리하면 입력 감지는 매 프레임 유지하면서 네트워크 상태는 NGO Tick을 공통 기준으로 기록할 수 있습니다.
검토한 대안: Update에서 이동과 전송을 모두 처리하면 프레임 수에 따라 전송 빈도가 달라지고, FixedUpdate만 사용하면 NGO Tick 번호와 별도의 시간 기준을 다시 관리해야 해서 제외했습니다.
결과와 한계: 렌더링 입력과 네트워크 로직의 실행 주기를 분리하고 Tick별 입력 스냅샷을 만들 수 있게 했습니다. 호스트는 서버와 클라이언트 역할을 함께 수행하므로 이동 처리 분기를 별도로 유지해야 합니다.
관련 코드 보기 - 입력과 Tick 분리
프레임 입력과 직접 구현한 Network Tick 스트림 분리
_tickStream = ObservableEx.EveryTick().Do(_ => HandleTick()).Subscribe().AddTo(component);
Observable.EveryUpdate().Do(ClientInput).Subscribe().AddTo(component);
public void HandleTick()
{
int bufferIndex = _CurrentTick % BUFFER_SIZE;
_inputBuffer[bufferIndex] = new InputPayload
{
tick = _CurrentTick,
inputDir = _target.Physics.MoveSpeed,
rotation = _target.transform.rotation,
actionEvent = ProcAction(),
skillShotData = _target.Module.shotData ?? SkillShotData.Default
};
_positionBuffer[bufferIndex] = ProcessMovement(_inputBuffer[bufferIndex]);
_controller.OnClientInputServerRpc(_inputBuffer[bufferIndex]);
}
NGO NetworkTickSystem을 UniRx 스트림으로 변환한 ObservableEx
public static class ObservableEx
{
public static IObservable<long> EveryTick()
{
return Observable.FromEvent(
add => NetworkManager.Singleton.NetworkTickSystem.Tick += add,
remove =>
{
if (NetworkManager.Singleton != null && NetworkManager.Singleton.NetworkTickSystem != null)
NetworkManager.Singleton.NetworkTickSystem.Tick -= remove;
}).Select(_ =>
{
if (NetworkManager.Singleton != null && NetworkManager.Singleton.NetworkTickSystem != null)
return (long)NetworkManager.Singleton.NetworkTickSystem.LocalTime.Tick;
return 0;
});
}
public static IObservable<long> TickTimer(TimeSpan time)
{
int ticks = (int)(time.TotalSeconds * NetworkManager.Singleton.NetworkTickSystem.TickRate);
if (ticks <= 0)
return Observable.Return<long>(NetworkManager.Singleton.NetworkTickSystem.LocalTime.Tick);
return EveryTick().Skip(ticks).Take(1);
}
}
2. 순환 버퍼 기반 입력 예측과 서버 보정
기획 요구사항: 네트워크 응답을 기다리지 않고 이동이 즉시 반응해야 하며, 서버의 확정 위치와 차이가 발생하면 저장된 입력을 이용해 현재 상태를 다시 계산해야 했습니다.
구현 방법: 크기 1024의 InputPayload와 PositionPayload 배열을 만들고 tick % BUFFER_SIZE를 인덱스로 사용해 Tick별 입력과 예측 결과를 순환 저장했습니다.
동작 흐름: 클라이언트는 입력을 즉시 실행해 위치를 저장하고 서버에 전송합니다. 서버 상태를 받으면 같은 Tick의 예측 위치와 비교하고, 오차가 기준값보다 크면 서버 위치와 속도를 적용합니다. 이후 서버 Tick 다음 입력부터 현재 Tick 전까지 버퍼의 입력을 순서대로 재적용합니다.
선택 이유: 단순히 현재 서버 위치로 이동하는 것보다 서버가 확정한 시점 이후의 입력을 다시 실행해야 사용자가 이미 입력한 이동과 스킬 상태를 현재 시점까지 복원할 수 있습니다.
검토한 대안: 위치 보간만 사용하는 방식은 표시를 부드럽게 만들 수 있지만 예측 상태의 오류를 해소하지 못하고, 모든 입력 이력을 계속 보관하면 메모리가 증가하므로 고정 크기 순환 버퍼를 선택했습니다.
결과와 한계: 위치, 속도와 이동 상태를 서버 기준으로 복원한 뒤 입력을 재적용할 수 있게 했습니다. 현재 보정에서는 회전 복원이 제외되어 있고 오차 기준값이 고정되어 있어 상황별 조정이 필요합니다.
관련 코드 보기 - 서버 보정과 입력 재적용
int serverBufferIndex = lastestServerPosition.tick % BUFFER_SIZE;
Vector3 positionError = lastestServerPosition.position
- _positionBuffer[serverBufferIndex].position;
if (positionError.sqrMagnitude > 0.001f)
{
_target.transform.position = lastestServerPosition.position;
_target.Physics.velocity = lastestServerPosition.velocity;
_target.Physics.MoveSpeed = lastestServerPosition.moveSpeed;
Physics.SyncTransforms();
int tickToProcess = lastestServerPosition.tick + 1;
while (tickToProcess < _CurrentTick)
{
int bufferIndex = tickToProcess % BUFFER_SIZE;
_positionBuffer[bufferIndex] = ProcessMovement(_inputBuffer[bufferIndex]);
tickToProcess++;
if (tickToProcess < _CurrentTick)
_target.Physics.ProcPhysics();
}
}
3. Network Tick 기반 커스텀 물리와 충돌 반응
기획 요구사항: 논타겟 액션의 이동, 공중 제어, 넉백과 충돌 반응을 네트워크 Tick 단위로 실행하고, 서버 보정 시 위치뿐 아니라 이동을 결정한 속도 상태도 복원해 같은 입력을 다시 계산해야 했습니다.
구현 방법: PhysicsSystem이 Rigidbody 대신 velocity와 MoveSpeed를 직접 소유하고 TickDeltaTime을 기준으로 중력, 지상 마찰, 공기 저항과 상태별 공중 이동을 계산하도록 했습니다. CharacterController는 계산된 변위를 적용하고 충돌 정보를 제공하는 역할로 제한했습니다. OnControllerColliderHit에서 받은 충돌은 큐에 넣은 뒤 Tick에서 소비하고, 충돌 법선과 양쪽 스킬 상태를 기준으로 반사 속도, 탄성 적용 대상과 스킬 취소 여부를 결정했습니다.
동작 흐름: ObservableEx가 NGO Tick을 발행하면 PhysicsSystem이 중력과 저항으로 velocity를 갱신합니다. 입력에서 만든 MoveSpeed와 velocity를 합산해 CharacterController.Move에 전달하고, 발생한 충돌은 다음 물리 처리에서 반사 속도와 피격 상태로 변환합니다. 서버 보정 시에는 서버의 velocity와 MoveSpeed를 복원한 뒤 저장된 입력과 ProcPhysics를 다시 실행합니다.
선택 이유: 물리 상태를 명시적인 값으로 관리하면 Tick별 스냅샷에 속도와 이동값을 저장하고 보정 시점 이후의 입력을 같은 계산 코드로 재실행할 수 있습니다. 스킬에서 PhysicsSystem의 velocity에 힘을 더하거나 감속하면 같은 물리 계산과 충돌 처리를 적용할 수 있습니다.
검토한 대안: Rigidbody에 힘을 적용하면 내부 물리 상태와 FixedUpdate 시점을 네트워크 Tick 및 입력 재실행 흐름에 맞춰 복원해야 했습니다. 스킬마다 transform을 직접 이동하는 방식은 중력, 마찰과 충돌 반응이 분리되어 제외했습니다.
결과와 한계: 기본 이동, 공중 상태, 넉백, 감속과 충돌 탄성을 하나의 Tick 기반 속도 모델로 처리하고 서버 보정에서도 같은 상태를 재사용할 수 있게 했습니다. 최종 충돌 해결은 Unity CharacterController에 의존하므로 클라이언트와 서버에서 항상 같은 결과를 보장하지는 않으며, 경사면 전용 속도 보정은 구현 검토 후 적용하지 않았습니다.
관련 코드 보기 - Tick 단위 속도 계산, 이동 적용과 충돌 탄성
TickDeltaTime 기준 중력, 마찰과 공기 저항 계산
public void ProcPhysics()
{
Vector3 deForce = Vector3.zero;
float deltaTime = TimeEx.TickDeltaTime;
Vector3 gravityForce = Physics.gravity * _gravityScale;
Vector3 gravityForceDelta = gravityForce * deltaTime;
if (IsGrounded == false)
{
_velocity += gravityForceDelta;
if (_customMidAirDrag != 0f && _target.State.HasState(eCharacterState.NoMidAirDragGroup) == false)
deForce += _velocity.normalized * _customMidAirDrag;
}
else
{
if (applyFriction && _target.State.HasState(eCharacterState.NoFrictionGroup) == false)
{
float friction = Mathf.Cos((90 - Vector3.Angle(_velocity, gravityForce)) * Mathf.Deg2Rad) * gravityForce.magnitude * _mass * _friction;
deForce += _velocity.normalized * friction;
}
if (_velocity.y < gravityForceDelta.y)
_velocity.y = gravityForceDelta.y;
}
if (_airDrag != 0f && _target.State.HasState(eCharacterState.NoAirDragGroup) == false)
{
deForce += IsGrounded ? new Vector3(_velocity.x, 0, _velocity.z).normalized * _airDrag : _velocity.normalized * _airDrag;
}
deForce *= deltaTime;
_velocity = _velocity.sqrMagnitude > deForce.sqrMagnitude ? _velocity - deForce : gravityForceDelta;
}
직접 계산한 속도와 입력 이동값을 CharacterController에 적용
private PositionPayload ProcessMovement(InputPayload input)
{
if (NetworkManager.Singleton.IsServer == false)
{
_target.Physics.MoveSpeed = input.inputDir;
Vector3 displacement = (_target.Physics.velocity + _target.Physics.MoveSpeed) * TimeEx.TickDeltaTime;
_target.Physics.charController?.Move(displacement);
}
return new PositionPayload
{
tick = input.tick,
position = _target.transform.position,
velocity = _target.Physics.velocity,
moveSpeed = _target.Physics.MoveSpeed
};
}
충돌을 Tick에서 소비하고 법선 기준 반사 속도 계산
public void OnControllerColliderHit(ControllerColliderHit hit)
{
_collisionHits.Enqueue(hit);
}
private void HandleTick()
{
while (_collisionHits.Count > 0)
OnCollision(_collisionHits.Dequeue());
ProcPhysics();
}
private void ProcCollision(IPhysics hitter, ControllerColliderHit hit)
{
if (applyElastic == false || hit.moveDirection == Vector3.down)
return;
if (GameSetting.ElasticObjectLayer.Mask(hit.gameObject.layer) == false)
return;
Vector3 result = Vector3.Reflect(hitter.Physics.velocity, hit.normal) * _elasticPower;
MoveBaseModule skill = hitter.Module.CurMoveModule;
if (skill != null)
{
result *= skill.ElasticPowerRatio;
skill.StartBrake(hitter, eCharacterStreamType.SkillBrake);
}
hitter.Physics.velocity = result;
}
4. ScriptableObject 기반 돌진 스킬과 감속
기획 요구사항: 돌진 스킬의 속도 변화, 지속 시간, 충격 범위와 감속 구간을 코드 수정 없이 에디터에서 조정하고 여러 스킬에서 감속 규칙을 재사용해야 했습니다.
구현 방법: BasicRushModule을 ScriptableObject로 만들고 속도 커브와 충격 파라미터를 데이터로 분리했습니다. 감속 설정은 BrakeData로 분리해 에디터에서 지상과 공중 감속값, 지속 시간과 종료 속도를 조정하도록 했습니다. 이동은 Rigidbody 대신 CharacterController와 직접 관리하는 velocity 및 MoveSpeed를 사용했습니다.
동작 흐름: 스킬 시작 시 speedGraph 스트림으로 Tick마다 이동 속도와 범위 충격을 적용합니다. 지속 시간이 끝나면 BrakeData가 지상과 공중 상태를 구분해 속도를 단계적으로 줄이고 종료 조건에서 스트림을 정리합니다.
선택 이유: ScriptableObject는 스킬 설정을 Asset으로 분리해 같은 실행 코드를 여러 값 조합에 재사용할 수 있습니다. BrakeData를 별도 객체로 분리하면 돌진 스킬마다 감속 코드를 중복해서 작성하지 않아도 됩니다.
검토한 대안: MonoBehaviour와 프리팹별 설정은 스킬 데이터 공유가 어렵고, Rigidbody 기반 이동은 서버 보정 시 복원할 속도와 이동 상태의 제어 범위가 넓어져 CharacterController 방식을 선택했습니다.
결과와 한계: 속도 커브, 충격과 감속을 커스텀 물리 시스템에 조합해 돌진 스킬을 Tick 단위로 실행할 수 있게 했습니다. BasicRushModule은 돌진과 감속을 다루며 잡기와 투사체처럼 별도 규칙이 필요한 스킬은 각각의 Module 구현으로 분리했습니다.
관련 코드 보기 - 돌진과 감속 데이터
[CreateAssetMenu(fileName = "BasicRushModule", menuName = "PlayerActionModule/BasicRushModule")]
public class BasicRushModule : MoveBaseModule
{
public AdditiveAnimationCurve speedGraph;
[SerializeField] private float _effectDuration;
[SerializeField] private float _forceRadius;
[SerializeField] private BrakeData _brakeData;
public override void DoAction(Player target)
{
var moveStream = speedGraph.GetSpeedGraphStream(target, null).Finally(() => _brakeData.Brake(target, eCharacterStreamType.SkillBrake));
target.StreamDict[eCharacterStreamType.Move] = ObservableEx.EveryTick().TakeUntil(ObservableEx.TickTimer(TimeSpan.FromSeconds(_effectDuration * target.Power))).Merge(moveStream).Subscribe().AddTo(target);
}
}
돌진 종료 후 지상과 공중 상태에 따라 속도를 줄이는 감속 처리
public void Brake(IPhysics target, eCharacterStreamType streamType)
{
if (target == null || target.StreamDict[streamType] != null)
return;
float sqrLimit = deaccelLimit * deaccelLimit;
target.StreamDict[streamType] = ObservableEx.EveryTick().TakeUntil(ObservableEx.TickTimer(TimeSpan.FromSeconds(deaccelDuration))).Do(_ =>
{
Vector3 velocity = target.Physics.velocity;
if (velocity.sqrMagnitude < sqrLimit)
{
target.StreamDict[streamType] = null;
return;
}
float power = target.Physics.IsGrounded ? deaccelPow : deaccelMidAirPow;
Vector3 acceleration = -velocity.normalized
* power
* TimeEx.TickDeltaTime;
if (target.Physics.IsGrounded)
{
Vector3 result = velocity + new Vector3(acceleration.x, 0, acceleration.z);
target.Physics.velocity = result.magnitude < deaccelLimit ? result.normalized * deaccelLimit : result;
}
else
{
target.Physics.velocity += new Vector3(acceleration.x, Mathf.Min(0, acceleration.y), acceleration.z);
}
}).DoOnCompleted(() => target.StreamDict[streamType] = null).Subscribe().AddTo(target.gameObject);
}
[멘티스코/뉴웨이블] 모바일 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);
}