Skip to content

Unity UGC 시스템 구축 가이드

Unity에서 UGC(사용자 생성 콘텐츠) 시스템 구축

Section titled “Unity에서 UGC(사용자 생성 콘텐츠) 시스템 구축”

UGC(User Generated Content)는 플레이어가 직접 게임 내 콘텐츠를 제작하고, 공유하고, 다른 플레이어의 콘텐츠를 다운로드해 플레이하는 시스템입니다. 레벨 에디터, 커스텀 맵, 스킨 에디터, 아이템 제작소 등이 대표적인 사례입니다.

대표적인 구현 사례:

  • 레벨 에디터: 플레이어가 직접 스테이지를 제작하고 공유 (예: Super Mario Maker, Dreams)
  • 맵 빌더: 멀티플레이어 게임에서 커스텀 맵 제작 및 업로드
  • 스킨/아이템 에디터: 캐릭터, 무기, 건물 외형 커스터마이징 후 공유
  • 플레이어 리텐션 극대화: 공식 콘텐츠 소진 후에도 커뮤니티가 게임을 지속시킴
  • 개발 비용 절감: 플레이어가 콘텐츠 생산을 분담
  • 커뮤니티 형성: 창작자와 소비자 간 유기적 생태계 구축
  • 장기 운영 지원: 새로운 업데이트 없이도 풍부한 경험 제공

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 기반 오브젝트 레지스트리

Section titled “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);
}
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. 에셋 검증 및 샌드박스 안전성

Section titled “2. 에셋 검증 및 샌드박스 안전성”

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 ?? "");
}
}

UGC 시스템에서 보안을 유지하기 위한 핵심 원칙들입니다.

허용 목록(Allowlist) 방식 사용

  • 오브젝트 타입을 레지스트리에 등록된 것만 허용하고, 미등록 타입은 무시합니다.
  • 파일 경로, 씬 이름 등 문자열 입력값은 화이트리스트와 정확히 일치하는 경우에만 허용합니다.

Addressables를 통한 에셋 격리

  • 유저가 제공한 경로로 직접 Resources.Load()를 호출하지 않습니다.
  • 모든 프리팹은 AssetReferenceGameObject로 참조하고, Addressables 카탈로그에 등록된 키만 사용합니다.

스크립트 실행 차단

  • 런타임에 유저가 코드를 업로드하거나 실행할 수 없도록 합니다.
  • Reflection을 통한 타입 생성을 UGC 경로에서 사용하지 않습니다.

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 연동)

Section titled “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.1 Netcode for GameObjects를 활용한 UGC 동기화

Section titled “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 오브젝트 상태 실시간 동기화

Section titled “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;
}
}

항목권장 사항
Dictionary 사용JsonUtility는 Dictionary를 지원하지 않음. 별도 Key-Value 배열 구조로 변환 필요
UnityEngine 타입Color, Vector3 등은 JsonUtility로 직렬화 가능하나, Newtonsoft.Json 사용 시 커스텀 컨버터 필요
null vs 빈 컬렉션List는 항상 new() 초기화 권장. 역직렬화 후 null 체크 필수
버전 관리version 필드를 두어 구버전 데이터 마이그레이션 경로 확보
ScriptableObject 직렬화JsonUtility.FromJson()은 일반 클래스에만 동작. ScriptableObjectFromJsonOverwrite() 사용

에셋 로딩 최적화

  • Addressables의 LoadAssetsAsync<T>(label, ...) 로 동일 레이블 에셋을 한 번에 로드합니다.
  • 로드 완료 후 Addressables.Release(handle) 호출을 잊지 않아야 메모리 누수를 방지할 수 있습니다.
  • 자주 쓰이는 프리팹은 오브젝트 풀로 관리해 Instantiate/Destroy 비용을 줄입니다.

직렬화 성능

  • JsonUtility는 Unity 네이티브 구현으로 빠르지만, 복잡한 중첩 구조에는 Newtonsoft.Json(JSON.NET) 고려합니다.
  • 대형 레벨 데이터(500개 이상 오브젝트)는 비동기 스레드에서 역직렬화 후 메인 스레드에 전달합니다.

네트워크 최적화

  • 레벨 전체 JSON을 네트워크로 전송하지 않습니다. 레벨 ID만 동기화하고 각 클라이언트가 개별 다운로드하도록 합니다.
  • NetworkVariable의 값 변경은 필요한 경우에만 수행합니다. 매 프레임 업데이트는 대역폭 낭비입니다.
  • 큰 데이터 전송이 필요한 경우 CustomMessagingManager의 Named Messages를 사용합니다.

UGC 시스템은 기술 구현 외에 커뮤니티 관리 정책이 필요합니다.

  • 서버 측 검증: 클라이언트 검증은 편의를 위한 것이고, 최종 검증은 반드시 서버에서 수행합니다.
  • 신고 시스템: 부적절한 콘텐츠를 다른 플레이어가 신고할 수 있는 UI와 백엔드를 구축합니다.
  • 검토 큐: 자동 검증을 통과한 콘텐츠도 일정 신고 수 이상이면 사람이 검토하도록 파이프라인을 설계합니다.
  • 작성자 책임: 업로드 시 이용 약관 동의를 받고, 위반 콘텐츠 작성자 계정을 관리할 수 있는 구조를 갖춥니다.