Unity UGC 시스템 구축
Unity에서 UGC(사용자 생성 콘텐츠) 시스템 구축
섹션 제목: “Unity에서 UGC(사용자 생성 콘텐츠) 시스템 구축”UGC 시스템이란?
섹션 제목: “UGC 시스템이란?”UGC(User Generated Content)는 플레이어가 직접 게임 내 콘텐츠를 제작하고, 공유하고, 다른 플레이어의 콘텐츠를 다운로드해 플레이하는 시스템입니다. 레벨 에디터, 커스텀 맵, 스킨 에디터, 아이템 제작소 등이 대표적인 사례입니다.
대표적인 구현 사례:
- 레벨 에디터: 플레이어가 직접 스테이지를 제작하고 공유 (예: Super Mario Maker, Dreams)
- 맵 빌더: 멀티플레이어 게임에서 커스텀 맵 제작 및 업로드
- 스킨/아이템 에디터: 캐릭터, 무기, 건물 외형 커스터마이징 후 공유
왜 UGC 시스템이 필요한가?
섹션 제목: “왜 UGC 시스템이 필요한가?”- 플레이어 리텐션 극대화: 공식 콘텐츠 소진 후에도 커뮤니티가 게임을 지속시킴
- 개발 비용 절감: 플레이어가 콘텐츠 생산을 분담
- 커뮤니티 형성: 창작자와 소비자 간 유기적 생태계 구축
- 장기 운영 지원: 새로운 업데이트 없이도 풍부한 경험 제공
1. 데이터 구조 설계 및 직렬화
섹션 제목: “1. 데이터 구조 설계 및 직렬화”1.1 UGC 데이터 모델 설계
섹션 제목: “1.1 UGC 데이터 모델 설계”UGC 시스템의 핵심은 플레이어가 제작한 데이터를 안정적으로 저장하고 복원하는 것입니다. Unity의 [Serializable] 어트리뷰트와 JsonUtility를 기반으로 데이터 구조를 설계합니다.
using System;using System.Collections.Generic;using UnityEngine;
// 레벨 전체를 표현하는 루트 데이터 구조[Serializable]public class UGCLevelData{ public string levelId; // 고유 식별자 (GUID) public string title; // 레벨 제목 public string authorId; // 제작자 ID public string version; // 포맷 버전 (하위 호환용) public long createdAt; // Unix 타임스탬프 public long updatedAt; public List<UGCObjectData> objects = new(); public UGCLevelSettings settings = new();}
// 레벨 내 배치된 각 오브젝트의 데이터[Serializable]public class UGCObjectData{ public string objectType; // 프리팹 식별자 키 public SerializableVector3 position; public SerializableVector3 rotation; public SerializableVector3 scale; public string customJson; // 오브젝트별 추가 데이터 (JSON 문자열)}
// 레벨 전반 설정[Serializable]public class UGCLevelSettings{ public string skyboxName = "default"; public string bgmId = "none"; public float gravity = -9.81f; public int maxPlayers = 4;}
// Unity Vector3은 JsonUtility에서 바로 직렬화되나,// 명시적 구조체를 쓰면 외부 JSON 라이브러리와의 호환성이 높아짐[Serializable]public struct SerializableVector3{ public float x, y, z;
public SerializableVector3(Vector3 v) { x = v.x; y = v.y; z = v.z; } public Vector3 ToVector3() => new(x, y, z); public static implicit operator SerializableVector3(Vector3 v) => new(v); public static implicit operator Vector3(SerializableVector3 s) => s.ToVector3();}1.2 ScriptableObject 기반 오브젝트 레지스트리
섹션 제목: “1.2 ScriptableObject 기반 오브젝트 레지스트리”UGC에서 배치 가능한 오브젝트 목록을 ScriptableObject로 관리하면 에디터 통합과 런타임 성능 두 가지 이점을 동시에 얻을 수 있습니다.
using UnityEngine;using UnityEngine.AddressableAssets;
// 배치 가능한 오브젝트 하나를 정의하는 ScriptableObject[CreateAssetMenu(menuName = "UGC/PlaceableObject")]public class UGCObjectDefinition : ScriptableObject{ [Header("식별 정보")] public string objectTypeKey; // UGCObjectData.objectType과 매핑 public string displayName; public Sprite thumbnail;
[Header("에셋 참조 (Addressables)")] public AssetReferenceGameObject prefabReference;
[Header("제약 조건")] public bool allowRotation = true; public bool allowScale = false; public Vector3 defaultScale = Vector3.one; public int maxCountPerLevel = 100;}
// 전체 오브젝트 목록을 관리하는 레지스트리[CreateAssetMenu(menuName = "UGC/ObjectRegistry")]public class UGCObjectRegistry : ScriptableObject{ [SerializeField] private List<UGCObjectDefinition> definitions = new();
private Dictionary<string, UGCObjectDefinition> _lookup;
private void OnEnable() { RebuildLookup(); }
public void RebuildLookup() { _lookup = new Dictionary<string, UGCObjectDefinition>(); foreach (var def in definitions) { if (!string.IsNullOrEmpty(def.objectTypeKey)) _lookup[def.objectTypeKey] = def; } }
public bool TryGet(string key, out UGCObjectDefinition def) => _lookup.TryGetValue(key, out def);}1.3 JSON 직렬화 및 저장
섹션 제목: “1.3 JSON 직렬화 및 저장”using System.IO;using UnityEngine;
public static class UGCSerializer{ private const string FILE_EXTENSION = ".ugc"; private static readonly string SaveDirectory = Path.Combine(Application.persistentDataPath, "ugc_levels");
// 레벨 데이터를 JSON으로 직렬화하여 로컬에 저장 public static bool SaveLevel(UGCLevelData levelData) { try { Directory.CreateDirectory(SaveDirectory); levelData.updatedAt = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
string json = JsonUtility.ToJson(levelData, prettyPrint: true); string path = Path.Combine(SaveDirectory, levelData.levelId + FILE_EXTENSION); File.WriteAllText(path, json, System.Text.Encoding.UTF8);
Debug.Log($"[UGC] 레벨 저장 완료: {path}"); return true; } catch (Exception ex) { Debug.LogError($"[UGC] 레벨 저장 실패: {ex.Message}"); return false; } }
// 로컬 파일에서 레벨 데이터를 역직렬화 public static bool TryLoadLevel(string levelId, out UGCLevelData levelData) { levelData = null; string path = Path.Combine(SaveDirectory, levelId + FILE_EXTENSION);
if (!File.Exists(path)) { Debug.LogWarning($"[UGC] 레벨 파일 없음: {path}"); return false; }
try { string json = File.ReadAllText(path, System.Text.Encoding.UTF8); levelData = JsonUtility.FromJson<UGCLevelData>(json); return levelData != null; } catch (Exception ex) { Debug.LogError($"[UGC] 레벨 로드 실패: {ex.Message}"); return false; } }
// 저장된 모든 레벨 ID 목록 반환 public static List<string> GetSavedLevelIds() { var ids = new List<string>(); if (!Directory.Exists(SaveDirectory)) return ids;
foreach (var file in Directory.GetFiles(SaveDirectory, "*" + FILE_EXTENSION)) ids.Add(Path.GetFileNameWithoutExtension(file));
return ids; }}2. 에셋 검증 및 샌드박스 안전성
섹션 제목: “2. 에셋 검증 및 샌드박스 안전성”2.1 검증 파이프라인 설계
섹션 제목: “2.1 검증 파이프라인 설계”UGC 데이터를 그대로 로드하면 게임 크래시, 성능 저하, 또는 보안 문제로 이어질 수 있습니다. 반드시 로드 전 검증 단계를 거쳐야 합니다.
using System.Collections.Generic;using UnityEngine;
public enum ValidationResult { Valid, Warning, Error }
public class UGCValidationReport{ public ValidationResult OverallResult { get; private set; } = ValidationResult.Valid; public List<string> Messages { get; } = new();
public void AddError(string message) { Messages.Add($"[오류] {message}"); OverallResult = ValidationResult.Error; }
public void AddWarning(string message) { Messages.Add($"[경고] {message}"); if (OverallResult != ValidationResult.Error) OverallResult = ValidationResult.Warning; }
public bool IsPlayable => OverallResult != ValidationResult.Error;}
public class UGCValidator{ private const int MAX_OBJECTS_PER_LEVEL = 500; private const int TITLE_MAX_LENGTH = 50; private readonly UGCObjectRegistry _registry;
public UGCValidator(UGCObjectRegistry registry) { _registry = registry; }
public UGCValidationReport Validate(UGCLevelData level) { var report = new UGCValidationReport();
ValidateMetadata(level, report); ValidateObjectList(level, report); ValidateSettings(level, report);
return report; }
private void ValidateMetadata(UGCLevelData level, UGCValidationReport report) { if (string.IsNullOrWhiteSpace(level.levelId)) report.AddError("levelId가 비어있습니다.");
if (string.IsNullOrWhiteSpace(level.title)) report.AddWarning("레벨 제목이 없습니다. 기본값으로 대체됩니다.");
if (level.title?.Length > TITLE_MAX_LENGTH) report.AddError($"제목이 너무 깁니다. (최대 {TITLE_MAX_LENGTH}자)");
// 버전 호환성 체크 if (!IsVersionCompatible(level.version)) report.AddError($"지원하지 않는 레벨 버전: {level.version}"); }
private void ValidateObjectList(UGCLevelData level, UGCValidationReport report) { if (level.objects == null) { report.AddError("오브젝트 목록이 null입니다."); return; }
if (level.objects.Count > MAX_OBJECTS_PER_LEVEL) report.AddError($"오브젝트 수 초과: {level.objects.Count} / {MAX_OBJECTS_PER_LEVEL}");
var countByType = new Dictionary<string, int>();
foreach (var obj in level.objects) { // 알 수 없는 오브젝트 타입 체크 if (!_registry.TryGet(obj.objectType, out var def)) { report.AddWarning($"알 수 없는 오브젝트 타입: '{obj.objectType}' - 스킵됩니다."); continue; }
// 타입별 최대 개수 체크 countByType.TryGetValue(obj.objectType, out int count); countByType[obj.objectType] = count + 1; if (countByType[obj.objectType] > def.maxCountPerLevel) report.AddError($"'{obj.objectType}' 오브젝트 수 초과 (최대 {def.maxCountPerLevel}개)");
// 위치 범위 체크 (NaN, Infinity 방어) if (!IsValidPosition(obj.position.ToVector3())) report.AddError($"오브젝트 위치 값 비정상: {obj.objectType}"); } }
private void ValidateSettings(UGCLevelData level, UGCValidationReport report) { float g = level.settings.gravity; if (g < -50f || g > 50f) report.AddWarning($"중력 값이 비정상 범위입니다: {g}");
if (level.settings.maxPlayers < 1 || level.settings.maxPlayers > 16) report.AddError("maxPlayers는 1~16 범위여야 합니다."); }
private bool IsValidPosition(Vector3 pos) => !float.IsNaN(pos.x) && !float.IsNaN(pos.y) && !float.IsNaN(pos.z) && !float.IsInfinity(pos.x) && !float.IsInfinity(pos.y) && !float.IsInfinity(pos.z);
private bool IsVersionCompatible(string version) { // 지원되는 버전 목록 var supported = new HashSet<string> { "1.0", "1.1", "2.0" }; return supported.Contains(version ?? ""); }}2.2 콘텐츠 샌드박스 원칙
섹션 제목: “2.2 콘텐츠 샌드박스 원칙”UGC 시스템에서 보안을 유지하기 위한 핵심 원칙들입니다.
허용 목록(Allowlist) 방식 사용
- 오브젝트 타입을 레지스트리에 등록된 것만 허용하고, 미등록 타입은 무시합니다.
- 파일 경로, 씬 이름 등 문자열 입력값은 화이트리스트와 정확히 일치하는 경우에만 허용합니다.
Addressables를 통한 에셋 격리
- 유저가 제공한 경로로 직접
Resources.Load()를 호출하지 않습니다. - 모든 프리팹은
AssetReferenceGameObject로 참조하고, Addressables 카탈로그에 등록된 키만 사용합니다.
스크립트 실행 차단
- 런타임에 유저가 코드를 업로드하거나 실행할 수 없도록 합니다.
Reflection을 통한 타입 생성을 UGC 경로에서 사용하지 않습니다.
3. 저장 및 로딩 시스템
섹션 제목: “3. 저장 및 로딩 시스템”3.1 Addressables 기반 런타임 로딩
섹션 제목: “3.1 Addressables 기반 런타임 로딩”using System;using System.Collections.Generic;using System.Threading.Tasks;using UnityEngine;using UnityEngine.AddressableAssets;using UnityEngine.ResourceManagement.AsyncOperations;
public class UGCLevelLoader : MonoBehaviour{ [SerializeField] private UGCObjectRegistry registry;
private readonly List<AsyncOperationHandle> _loadedHandles = new(); private readonly List<GameObject> _spawnedObjects = new();
// 레벨 데이터를 읽어 씬에 오브젝트들을 배치 public async Task<bool> LoadLevelAsync(UGCLevelData levelData) { // 1. 기존 레벨 정리 await UnloadCurrentLevel();
// 2. 검증 var validator = new UGCValidator(registry); var report = validator.Validate(levelData);
if (!report.IsPlayable) { foreach (var msg in report.Messages) Debug.LogError(msg); return false; }
foreach (var msg in report.Messages) Debug.LogWarning(msg); // 경고는 로그만 출력
// 3. 오브젝트 순차/비동기 로딩 foreach (var objData in levelData.objects) { if (!registry.TryGet(objData.objectType, out var def)) continue;
await SpawnObjectAsync(def, objData); }
return true; }
private async Task SpawnObjectAsync(UGCObjectDefinition def, UGCObjectData data) { var handle = def.prefabReference.LoadAssetAsync<GameObject>(); _loadedHandles.Add(handle);
await handle.Task;
if (handle.Status != AsyncOperationStatus.Succeeded) { Debug.LogWarning($"[UGC] 프리팹 로드 실패: {data.objectType}"); return; }
var instance = Instantiate( handle.Result, data.position, Quaternion.Euler(data.rotation) ); instance.transform.localScale = data.scale;
_spawnedObjects.Add(instance); }
// 씬에 배치된 오브젝트와 로드된 에셋 해제 public async Task UnloadCurrentLevel() { foreach (var go in _spawnedObjects) { if (go != null) Destroy(go); } _spawnedObjects.Clear();
foreach (var handle in _loadedHandles) { if (handle.IsValid()) Addressables.Release(handle); } _loadedHandles.Clear();
// 프레임 대기로 Destroy 완료 보장 await Task.Yield(); }}3.2 클라우드 업로드/다운로드 (Unity Gaming Services 연동)
섹션 제목: “3.2 클라우드 업로드/다운로드 (Unity Gaming Services 연동)”using System;using System.Text;using System.Threading.Tasks;using UnityEngine;using UnityEngine.Networking;
// Unity UGC Bridge 또는 자체 백엔드 API와 통신하는 클라이언트public class UGCCloudClient{ private readonly string _baseUrl; private readonly string _authToken;
public UGCCloudClient(string baseUrl, string authToken) { _baseUrl = baseUrl; _authToken = authToken; }
// 레벨 데이터를 서버에 업로드 public async Task<bool> UploadLevelAsync(UGCLevelData levelData) { string json = JsonUtility.ToJson(levelData); byte[] body = Encoding.UTF8.GetBytes(json);
using var request = new UnityWebRequest($"{_baseUrl}/levels", "POST"); request.uploadHandler = new UploadHandlerRaw(body); request.downloadHandler = new DownloadHandlerBuffer(); request.SetRequestHeader("Content-Type", "application/json"); request.SetRequestHeader("Authorization", $"Bearer {_authToken}");
var tcs = new TaskCompletionSource<bool>(); var op = request.SendWebRequest(); op.completed += _ => tcs.SetResult(request.result == UnityWebRequest.Result.Success);
bool success = await tcs.Task; if (!success) Debug.LogError($"[UGC] 업로드 실패: {request.error}");
return success; }
// 서버에서 레벨 데이터를 다운로드 public async Task<UGCLevelData> DownloadLevelAsync(string levelId) { using var request = UnityWebRequest.Get($"{_baseUrl}/levels/{levelId}"); request.SetRequestHeader("Authorization", $"Bearer {_authToken}");
var tcs = new TaskCompletionSource<string>(); var op = request.SendWebRequest(); op.completed += _ => { if (request.result == UnityWebRequest.Result.Success) tcs.SetResult(request.downloadHandler.text); else tcs.SetResult(null); };
string responseJson = await tcs.Task; if (string.IsNullOrEmpty(responseJson)) { Debug.LogError($"[UGC] 다운로드 실패: {request.error}"); return null; }
return JsonUtility.FromJson<UGCLevelData>(responseJson); }}4. 멀티플레이어 동기화
섹션 제목: “4. 멀티플레이어 동기화”4.1 Netcode for GameObjects를 활용한 UGC 동기화
섹션 제목: “4.1 Netcode for GameObjects를 활용한 UGC 동기화”멀티플레이어 환경에서는 모든 클라이언트가 동일한 레벨을 로드하도록 동기화해야 합니다. 핵심 원칙은 레벨 데이터 자체를 전송하지 않고, 레벨 ID만 동기화하는 것입니다.
using Unity.Netcode;using UnityEngine;using System.Collections;
public class UGCNetworkManager : NetworkBehaviour{ [SerializeField] private UGCLevelLoader levelLoader; [SerializeField] private UGCObjectRegistry registry;
// 네트워크를 통해 동기화되는 현재 레벨 ID private NetworkVariable<Unity.Collections.FixedString64Bytes> _currentLevelId = new(default, NetworkVariableReadPermission.Everyone, NetworkVariableWritePermission.Server);
public override void OnNetworkSpawn() { _currentLevelId.OnValueChanged += OnLevelIdChanged;
// 늦게 접속한 클라이언트: 현재 레벨을 즉시 로드 if (!IsServer && _currentLevelId.Value.Length > 0) StartCoroutine(LoadLevelCoroutine(_currentLevelId.Value.ToString())); }
// 서버(호스트)가 새 레벨 로드를 요청 [ServerRpc(RequireOwnership = false)] public void RequestLoadLevelServerRpc(string levelId, ServerRpcParams rpcParams = default) { // 서버에서만 레벨 ID 값 변경 (모든 클라이언트에 자동 전파) _currentLevelId.Value = levelId; }
private void OnLevelIdChanged( Unity.Collections.FixedString64Bytes prev, Unity.Collections.FixedString64Bytes next) { if (next.Length > 0) StartCoroutine(LoadLevelCoroutine(next.ToString())); }
private IEnumerator LoadLevelCoroutine(string levelId) { // 로컬 캐시에서 먼저 찾고, 없으면 서버에서 다운로드 if (!UGCSerializer.TryLoadLevel(levelId, out var levelData)) { Debug.Log($"[UGC] 서버에서 레벨 다운로드 중: {levelId}"); // 실제 구현에서는 UGCCloudClient.DownloadLevelAsync() 호출 yield break; }
var task = levelLoader.LoadLevelAsync(levelData); yield return new WaitUntil(() => task.IsCompleted);
if (!task.Result) Debug.LogError($"[UGC] 레벨 로드 실패: {levelId}"); }}4.2 오브젝트 상태 실시간 동기화
섹션 제목: “4.2 오브젝트 상태 실시간 동기화”레벨 내 오브젝트가 변경 가능한 상태를 가지는 경우(문 개폐, 상자 파괴 등), NetworkVariable로 상태를 동기화합니다.
using Unity.Netcode;using UnityEngine;
// UGC 레벨의 상호작용 가능한 오브젝트 예시public class UGCInteractable : NetworkBehaviour{ // bool 타입 NetworkVariable로 활성화 상태 동기화 private NetworkVariable<bool> _isActive = new( true, NetworkVariableReadPermission.Everyone, NetworkVariableWritePermission.Server );
private Renderer _renderer;
private void Awake() { _renderer = GetComponent<Renderer>(); }
public override void OnNetworkSpawn() { _isActive.OnValueChanged += (_, newValue) => UpdateVisual(newValue); UpdateVisual(_isActive.Value); }
// 클라이언트가 상호작용 요청 [ServerRpc(RequireOwnership = false)] public void ToggleServerRpc() { _isActive.Value = !_isActive.Value; }
private void UpdateVisual(bool isActive) { if (_renderer != null) _renderer.enabled = isActive; }}5. 주의사항 및 성능 최적화 팁
섹션 제목: “5. 주의사항 및 성능 최적화 팁”5.1 직렬화 주의사항
섹션 제목: “5.1 직렬화 주의사항”| 항목 | 권장 사항 |
|---|---|
| Dictionary 사용 | JsonUtility는 Dictionary를 지원하지 않음. 별도 Key-Value 배열 구조로 변환 필요 |
| UnityEngine 타입 | Color, Vector3 등은 JsonUtility로 직렬화 가능하나, Newtonsoft.Json 사용 시 커스텀 컨버터 필요 |
| null vs 빈 컬렉션 | List는 항상 new() 초기화 권장. 역직렬화 후 null 체크 필수 |
| 버전 관리 | version 필드를 두어 구버전 데이터 마이그레이션 경로 확보 |
| ScriptableObject 직렬화 | JsonUtility.FromJson()은 일반 클래스에만 동작. ScriptableObject는 FromJsonOverwrite() 사용 |
5.2 성능 최적화
섹션 제목: “5.2 성능 최적화”에셋 로딩 최적화
- Addressables의
LoadAssetsAsync<T>(label, ...)로 동일 레이블 에셋을 한 번에 로드합니다. - 로드 완료 후
Addressables.Release(handle)호출을 잊지 않아야 메모리 누수를 방지할 수 있습니다. - 자주 쓰이는 프리팹은 오브젝트 풀로 관리해 Instantiate/Destroy 비용을 줄입니다.
직렬화 성능
JsonUtility는 Unity 네이티브 구현으로 빠르지만, 복잡한 중첩 구조에는 Newtonsoft.Json(JSON.NET) 고려합니다.- 대형 레벨 데이터(500개 이상 오브젝트)는 비동기 스레드에서 역직렬화 후 메인 스레드에 전달합니다.
네트워크 최적화
- 레벨 전체 JSON을 네트워크로 전송하지 않습니다. 레벨 ID만 동기화하고 각 클라이언트가 개별 다운로드하도록 합니다.
NetworkVariable의 값 변경은 필요한 경우에만 수행합니다. 매 프레임 업데이트는 대역폭 낭비입니다.- 큰 데이터 전송이 필요한 경우
CustomMessagingManager의 Named Messages를 사용합니다.
5.3 콘텐츠 모더레이션
섹션 제목: “5.3 콘텐츠 모더레이션”UGC 시스템은 기술 구현 외에 커뮤니티 관리 정책이 필요합니다.
- 서버 측 검증: 클라이언트 검증은 편의를 위한 것이고, 최종 검증은 반드시 서버에서 수행합니다.
- 신고 시스템: 부적절한 콘텐츠를 다른 플레이어가 신고할 수 있는 UI와 백엔드를 구축합니다.
- 검토 큐: 자동 검증을 통과한 콘텐츠도 일정 신고 수 이상이면 사람이 검토하도록 파이프라인을 설계합니다.
- 작성자 책임: 업로드 시 이용 약관 동의를 받고, 위반 콘텐츠 작성자 계정을 관리할 수 있는 구조를 갖춥니다.
6. 버전 마이그레이션 패턴
섹션 제목: “6. 버전 마이그레이션 패턴”UGC 데이터 스키마가 업데이트되면 기존 저장 파일을 새 형식으로 변환해야 합니다.
public static class UGCMigrator{ public static UGCLevelData Migrate(string json) { // 버전 필드만 먼저 파싱 var probe = JsonUtility.FromJson<VersionProbe>(json);
return probe.version switch { "1.0" => MigrateV1_0(json), "1.1" => MigrateV1_1(json), "2.0" => JsonUtility.FromJson<UGCLevelData>(json), _ => null // 알 수 없는 버전 — 거부 }; }
private static UGCLevelData MigrateV1_0(string json) { var old = JsonUtility.FromJson<UGCLevelDataV1_0>(json); return new UGCLevelData { levelId = old.id, // 필드명 변경 대응 title = old.name, authorId = old.creator, version = "2.0", objects = old.items?.ConvertAll(item => new UGCObjectData { objectType = item.type, position = item.pos, rotation = Vector3.zero, // v1.0에 없던 필드: 기본값 scale = Vector3.one }) ?? new List<UGCObjectData>(), settings = new UGCLevelSettings() }; }
private static UGCLevelData MigrateV1_1(string json) { // v1.1 → v2.0 은 구조 변화가 적으므로 직접 파싱 후 조정 var data = JsonUtility.FromJson<UGCLevelData>(json); data.version = "2.0"; return data; }
[System.Serializable] private class VersionProbe { public string version; }
// v1.0 구조 (구버전 호환용 클래스) [System.Serializable] private class UGCLevelDataV1_0 { public string id; public string name; public string creator; public List<V1_0_Object> items; }
[System.Serializable] private class V1_0_Object { public string type; public SerializableVector3 pos; }}로드 흐름에 마이그레이션을 통합하세요:
public static bool TryLoadWithMigration(string levelId, out UGCLevelData levelData){ levelData = null; string path = Path.Combine(SaveDirectory, levelId + ".ugc"); if (!File.Exists(path)) return false;
string json = File.ReadAllText(path); levelData = UGCMigrator.Migrate(json);
if (levelData == null) return false;
// 마이그레이션됐으면 새 버전으로 덮어쓰기 if (levelData.version != "2.0") SaveLevel(levelData);
return true;}