w99hyun
프로필프로젝트포스트연락처
목록
w99hyun

본 사이트는 개인 포트폴리오 게시용으로 제작되었습니다.

ⓒ 2026. w99hyun(Woohyun Cho) All rights reserved.

홈페이지
게임진행중Ussistant Studio 협업
개발기간2026. 02. 15. ㅡ
팀5인 팀 (아트 2, 개발 3)
역할중앙 서버 / UGS 연동 / GPGS 연동 / 콘솔 웹사이트 / Firebase 연동 / UI · UX
사용 기술
UnityC#ASP.NET CoreMirror Network

라스트 오브 펭귄

시연 영상

스크린샷

개요

모바일 MOBA 게임입니다.
이 프로젝트에서 GPGS 같은 플랫폼 SDK를 연동하고, 매치메이킹 · 계정 인증 · 푸시 메시지, UI · UX 등 게임 서버와 클라이언트 인프라를 담당하여 개발하고 있습니다.

기술적 챌린지

매치메이킹

Unity Gaming Services (UGS)의 Matchmaker와 Hosting을 조합하면 간편하게 매치메이킹을 구현할 수 있었지만, Hosting 서비스가 2026년 3월부로 종료되어 자체 중앙 서버를 구축하는 방향으로 설계했습니다.

UGS Matchmaker와 중앙 서버를 연결하기 위해 UGS Cloud Code를 중간 레이어로 활용했습니다. 매치메이커를 자체 구현하는 방안도 검토했지만, Cloud Save와 리더보드 등 UGS의 추가 모듈과의 통합을 고려하면 UGS 생태계를 유지하는 것이 장기적으로 유리하다고 판단했습니다.

최종 네트워크 아키텍처:
UGS

1v1 매치 성사 시, 두 플레이어가 참여할 세션을 Mirror Network API 데디케이티드 서버로 할당합니다.
중앙 서버는 ASP.NET Core로 구축하고, Cloud Code가 REST POST 요청으로 세션 정보를 수신하는 구조입니다.
UGS 서버에서 호출되는 구조상 로컬 실행이 불가능하므로 AWS에 호스팅했습니다.

코드 보기코드 닫기
C#
[HttpPost("allocate")]
public IActionResult Allocate([FromBody] AllocateRequest request)
{
    var allocationId = Guid.NewGuid().ToString("N");
    return Ok(new AllocateResponse { AllocationId = allocationId, Status = "pending" });
}

다음으로, UGS Matchmaker가 직접 중앙 서버와 통신할 수 없기 때문에 UGS Cloud Code에 모듈을 생성하여 사용합니다.
UGS Matchmaker는 UGS Cloud Code를 통해 중앙 서버와 통신합니다.

코드 보기코드 닫기
C#
[CloudCodeFunction("Matchmaker_AllocateServer")]
public async Task<AllocateResponse> Allocate(IExecutionContext context, AllocateRequest request)
{
    var requestDto = ParseAllocateRequest(request);
    try
    {
        var allocationId = await AllocateCoreAsync(context, requestDto);
        return new AllocateResponse(AllocateStatus.Created)
        {
            AllocationData = new Dictionary<string, object>
            {
                { "allocationId", allocationId },
                { "status", "pending" }
            }
        };
    }
    catch (Exception ex)
    {
        return new AllocateResponse(AllocateStatus.Error) { Message = ex.Message };
    }
}

이후, UGS Matchmaker에서 UGS Cloud Code를 인식할 수 있도록 Queue와 Pool을 생성하여 매칭 규칙을 설정합니다.
UGS_MatchMaker

현재 게임에서는 다른 사용자와 플레이하는 1v1 매치와 AI 봇과 플레이하는 매치를 지원하므로 매칭 Queue를 2개로 나누어 각각 Pool을 생성하고, Pool마다 매칭 규칙을 설정합니다.

코드 보기코드 닫기
JSON
//1vs1 매치
{
  "Name": "1v1",
  "MatchDefinition": {
    "Teams": [
      {
        "Name": "Team",
        "TeamCount": {
          "Min": 1,
          "Max": 1
        },
        "PlayerCount": {
          "Min": 2,
          "Max": 2
        }
      }
    ],
    "MatchRules": []
  },
  "BackfillEnabled": false
}

//AI 봇 매치
{
  "Name": "AIBot Solo",
  "MatchDefinition": {
    "Teams": [
      {
        "Name": "Solo",
        "TeamCount": {
          "Min": 1,
          "Max": 1
        },
        "PlayerCount": {
          "Min": 1,
          "Max": 1
        }
      }
    ],
    "MatchRules": []
  },
  "BackfillEnabled": false
}

인증

플레이어의 로그인 처리는 UGS Authentication 모듈을 사용합니다. Google OIDC와 GPGS를 Provider로 사용했습니다. (Apple ID도 연동 예정입니다.)
우선, 크로스플랫폼의 인증을 대응하기 위한 인터페이스를 설계합니다.

코드 보기코드 닫기
C#
public readonly struct GoogleAccountLinkState
{
    public bool IsGooglePlayGamesLinked { get; init; }
    public bool IsGoogleOidcLinked { get; init; }
    public bool CanLinkCrossPlatform => IsGooglePlayGamesLinked && !IsGoogleOidcLinked;
    public bool CanUnlinkCrossPlatform => IsGooglePlayGamesLinked && IsGoogleOidcLinked;
}

public interface IAuthService
{
    bool RequiresExplicitLoginChoice { get; }
    Task InitializeAndSignIn();
    Task<GoogleAccountLinkState> GetLinkStateAsync();
    Task<(bool success, string message)> StartCrossPlatformGoogleLinkAsync();
    Task<(bool success, string message)> UnlinkUnityPlayerAccountAsync();
    void PrepareGoogleLogin();
    void PrepareGooglePlayLogin();
}

Android - Google OIDC / GPGS
Android에서는 Google Play 로그인을 기본으로 하되, Google OIDC 로그인도 지원하여 기존 크로스플랫폼 유저가 동일한 데이터를 사용할 수 있게 합니다.
또한 기존 GPGS 로그인 유저가 Google 계정 연동을 할 수 있는 수단을 제공하여, Google ID token을 LinkWithOpenIdConnectAsync로 연결합니다.

코드 보기코드 닫기
C#
public async Task InitializeAndSignIn()
{
    if (UnityServices.State != ServicesInitializationState.Initialized)
        await UnityServices.InitializeAsync();

    if (selectedLoginPath == LoginPath.GoogleOidc)
    {
        string idToken = await GoogleOidcAuthUtility.StartSignInAndGetIdTokenAsync();
        await AuthenticationService.Instance.SignInWithOpenIdConnectAsync(
            GoogleOidcAuthUtility.IdProviderName, idToken);
        return;
    }

    await SignInWithGooglePlayGamesAsync();
}

public async Task<(bool success, string message)> StartCrossPlatformGoogleLinkAsync()
{
    GoogleAccountLinkState linkState = await GetLinkStateAsync();
    if (!linkState.IsGooglePlayGamesLinked)
        return (false, "Google Play Games 로그인 계정에서만 연동할 수 있습니다.");

    string idToken = await GoogleOidcAuthUtility.StartSignInAndGetIdTokenAsync();
    await AuthenticationService.Instance.LinkWithOpenIdConnectAsync(
        GoogleOidcAuthUtility.IdProviderName, idToken);

    return (true, "Google 계정 연동이 완료되었습니다.");
}

Windows - 브라우저 OAuth

Windows는 네이티브 Google SDK 대신 브라우저 OAuth Authorization Code Flow를 사용합니다. 로컬 콜백 포트로 인증 코드를 받고, Web Client ID/secret으로 ID token을 교환한 뒤 UGS OIDC에 로그인합니다.

코드 보기코드 닫기
C#
public const string IdProviderName = "oidc-google";
private const string GOOGLE_WEB_CLIENT_ID = "...apps.googleusercontent.com";
private const int DESKTOP_REDIRECT_PORT = 57891;

private static async Task<string> StartDesktopSignInAndGetIdTokenAsync()
{
    string redirectUri = "http://localhost:" + DESKTOP_REDIRECT_PORT + "/";
    return await StartBrowserCodeFlowAndGetIdTokenAsync(GOOGLE_WEB_CLIENT_ID, redirectUri);
}

private static async Task<string> ExchangeCodeForIdTokenAsync(
    string code,
    string codeVerifier,
    string redirectUri,
    string clientId)
{
    string url = GameManager.CentralBaseUrlProd.TrimEnd('/') + "/api/auth/google-oidc/token";
    string bodyText = JsonUtility.ToJson(new GoogleOidcTokenExchangeRequest
    {
        code = code,
        codeVerifier = codeVerifier,
        redirectUri = redirectUri,
        clientId = clientId
    });

    using var request = new UnityWebRequest(url, UnityWebRequest.kHttpVerbPOST);
    request.uploadHandler = new UploadHandlerRaw(Encoding.UTF8.GetBytes(bodyText));
    request.downloadHandler = new DownloadHandlerBuffer();
    request.timeout = 10;
    request.SetRequestHeader("Content-Type", "application/json");
    var operation = request.SendWebRequest();
    while (!operation.isDone)
        await Task.Yield();

    if (request.result != UnityWebRequest.Result.Success)
        throw new InvalidOperationException(request.responseCode + ": " + request.downloadHandler.text);

    var response = JsonUtility.FromJson<GoogleOidcTokenExchangeResponse>(
        request.downloadHandler.text);
    if (response == null || string.IsNullOrWhiteSpace(response.idToken))
        throw new InvalidOperationException("token match error");

    return response.idToken;
}

public async Task InitializeAndSignIn()
{
    string idToken = await GoogleOidcAuthUtility.StartSignInAndGetIdTokenAsync();
    await AuthenticationService.Instance.SignInWithOpenIdConnectAsync(
        GoogleOidcAuthUtility.IdProviderName, idToken);
}

iOS - GoogleSignIn SPM

iOS는 Windows와 마찬가지로 Google 로그인을 사용하지만, 추후 Apple Game Center와 AppleID 로그인을 연동할 예정입니다.

코드 보기코드 닫기
C#
[DllImport("__Internal", CharSet = CharSet.Ansi)]
private static extern void GoogleOidcSignIn(
    string clientId,
    string serverClientId,
    GoogleOidcNativeCallback callback);

private static Task<string> StartIosSignInAndGetIdTokenAsync()
{
    nativeCompletion = new TaskCompletionSource<string>();
    GoogleOidcSignIn(IOS_CLIENT_ID, GOOGLE_WEB_CLIENT_ID, NativeCallback);
    return nativeCompletion.Task;
}

중복 로그인 처리

같은 UGS PlayerID로 다른 기기에서 로그인하면 중앙 서버의 월드 채팅 WebSocket 연결 정보를 기준으로 이전 연결을 닫습니다. 클라이언트는 reconnected close reason을 받으면 세션을 정리하고 로그인 씬으로 되돌아가므로, 새로 로그인한 플랫폼이 우선권을 갖습니다.

코드 보기코드 닫기
C#
if (_connections.TryGetValue(playerId, out var oldConnection))
    await oldConnection.Socket.CloseAsync(WebSocketCloseStatus.NormalClosure, "reconnected", CancellationToken.None);

_connections[playerId] = new WorldChatConnection(playerId, socket);

if (result.MessageType == WebSocketMessageType.Close &&
    result.CloseStatusDescription == "reconnected")
{
    AuthenticationService.Instance.SignOut(true);
    SceneManager.LoadScene("Login", LoadSceneMode.Single);
}

인앱 결제

https://www.youtube.com/watch?v=1AbVchUABOY

InAppPurchase
인앱 결제는 클라이언트에서 구글 플레이 결제 후 영수증 값을 중앙 서버로 전달합니다.
중앙 서버에서는 Google Play Android Developer API를 사용하여 영수증을 검증하고, 영수증이 유효하면 유료재화인 레드젬 지급을 승인합니다.
구매 기록을 Cloud Save에 저장하므로, 만약 레드젬 지급이 되지 않은 경우 구매 복원을 지원합니다.

인앱 광고

InAppAds
인앱 광고는 Unity Ads를 사용하여 구현했습니다.
Reward 광고는 게임 내에서 광고 시청을 통해 인게임 재화인 '레드젬'을 지급하며(하루 2회 제한), Interstitial 광고는 게임이 종료된 후, 게임 결과 화면에서 광고를 시청한 후 메인 화면으로 돌아올 수 있도록 합니다.

InAppAds2
광고는 LevelPlay에서 bidding 방식으로 운영하여, ironSource와 Unity Ads 중 높은 수익을 제공하는 광고가 노출됩니다.

랜덤 박스 (확률형 아이템)

RandomBox
Skin

인게임 재화인 레드젬을 활용하여 랜덤 박스를 구매할 수 있습니다. 랜덤 박스는 스킨 랜덤 박스와 크로마 랜덤 박스로 나뉩니다.
랜덤박스는 중앙서버에서 판정한 후, 클라이언트에서 결과를 표시합니다.

코드 보기코드 닫기
C#
"SkinRandomBoxes": {
"skin_random_box": {
    "BoxInventoryItemId": "RANDOM_BOX_SKIN",
    "VirtualPurchaseId": "BUY_SKIN_RANDOM_BOX",
    "Rewards": [
    { "SkinId": "emperor", "InventoryItemId": "SKIN_EMPEROR", "Weight": 30 },
    { "SkinId": "gentoo", "InventoryItemId": "SKIN_GENTOO", "Weight": 1 },
    { "SkinId": "king", "InventoryItemId": "SKIN_KING", "Weight": 9 },
    { "SkinId": "magellanic", "InventoryItemId": "SKIN_MAGELLANIC", "Weight": 40 },
    { "SkinId": "rockhopper", "InventoryItemId": "SKIN_ROCKHOPPER", "Weight": 20 }
    ]
},
"chroma_random_box": {
    "BoxInventoryItemId": "RANDOM_BOX_CHROMA",
    "VirtualPurchaseId": "BUY_CHROMA_RANDOM_BOX",
    "AllowDuplicates": false,
    "Rewards": [
    { "ChromaId": "adelie_b", "InventoryItemId": "CHROMA_ADELIE_B", "Weight": 1 },
    { "ChromaId": "adelie_g", "InventoryItemId": "CHROMA_ADELIE_G", "Weight": 1 },
    { "ChromaId": "adelie_o", "InventoryItemId": "CHROMA_ADELIE_O", "Weight": 1 },
    { "ChromaId": "adelie_p", "InventoryItemId": "CHROMA_ADELIE_P", "Weight": 1 },
    { "ChromaId": "adelie_pp", "InventoryItemId": "CHROMA_ADELIE_PP", "Weight": 1 },
    { "ChromaId": "adelie_y", "InventoryItemId": "CHROMA_ADELIE_Y", "Weight": 1 },
    { "ChromaId": "emperor_b", "InventoryItemId": "CHROMA_EMPEROR_B", "Weight": 1 },
    { "ChromaId": "emperor_g", "InventoryItemId": "CHROMA_EMPEROR_G", "Weight": 1 },
    { "ChromaId": "emperor_o", "InventoryItemId": "CHROMA_EMPEROR_O", "Weight": 1 },
    { "ChromaId": "emperor_p", "InventoryItemId": "CHROMA_EMPEROR_P", "Weight": 1 },
    { "ChromaId": "emperor_pp", "InventoryItemId": "CHROMA_EMPEROR_PP", "Weight": 1 },
    { "ChromaId": "emperor_y", "InventoryItemId": "CHROMA_EMPEROR_Y", "Weight": 1 },
    { "ChromaId": "gentoo_b", "InventoryItemId": "CHROMA_GENTOO_B", "Weight": 1 },
    { "ChromaId": "gentoo_g", "InventoryItemId": "CHROMA_GENTOO_G", "Weight": 1 },
    { "ChromaId": "gentoo_o", "InventoryItemId": "CHROMA_GENTOO_O", "Weight": 1 },
    { "ChromaId": "gentoo_p", "InventoryItemId": "CHROMA_GENTOO_P", "Weight": 1 },
    { "ChromaId": "gentoo_pp", "InventoryItemId": "CHROMA_GENTOO_PP", "Weight": 1 },
    { "ChromaId": "gentoo_y", "InventoryItemId": "CHROMA_GENTOO_Y", "Weight": 1 },
    { "ChromaId": "king_b", "InventoryItemId": "CHROMA_KING_B", "Weight": 1 },
    { "ChromaId": "king_g", "InventoryItemId": "CHROMA_KING_G", "Weight": 1 },
    { "ChromaId": "king_o", "InventoryItemId": "CHROMA_KING_O", "Weight": 1 },
    { "ChromaId": "king_p", "InventoryItemId": "CHROMA_KING_P", "Weight": 1 },
    { "ChromaId": "king_pp", "InventoryItemId": "CHROMA_KING_PP", "Weight": 1 },
    { "ChromaId": "king_y", "InventoryItemId": "CHROMA_KING_Y", "Weight": 1 },
    { "ChromaId": "magellanic_b", "InventoryItemId": "CHROMA_MAGELLANIC_B", "Weight": 1 },
    { "ChromaId": "magellanic_g", "InventoryItemId": "CHROMA_MAGELLANIC_G", "Weight": 1 },
    { "ChromaId": "magellanic_o", "InventoryItemId": "CHROMA_MAGELLANIC_O", "Weight": 1 },
    { "ChromaId": "magellanic_p", "InventoryItemId": "CHROMA_MAGELLANIC_P", "Weight": 1 },
    { "ChromaId": "magellanic_pp", "InventoryItemId": "CHROMA_MAGELLANIC_PP", "Weight": 1 },
    { "ChromaId": "magellanic_y", "InventoryItemId": "CHROMA_MAGELLANIC_Y", "Weight": 1 },
    { "ChromaId": "rockhopper_b", "InventoryItemId": "CHROMA_ROCKHOPPER_B", "Weight": 1 },
    { "ChromaId": "rockhopper_g", "InventoryItemId": "CHROMA_ROCKHOPPER_G", "Weight": 1 },
    { "ChromaId": "rockhopper_o", "InventoryItemId": "CHROMA_ROCKHOPPER_O", "Weight": 1 },
    { "ChromaId": "rockhopper_p", "InventoryItemId": "CHROMA_ROCKHOPPER_P", "Weight": 1 },
    { "ChromaId": "rockhopper_pp", "InventoryItemId": "CHROMA_ROCKHOPPER_PP", "Weight": 1 },
    { "ChromaId": "rockhopper_y", "InventoryItemId": "CHROMA_ROCKHOPPER_Y", "Weight": 1 }
    ]
}

아이템 구매와 가격 등은 UGS의 Economy 모듈을 사용하여 관리합니다.

Economy

푸시 메시지

Firebase를 사용합니다. 운영용 콘솔 웹을 별도 제작하여 웹에서 푸시 메시지를 발송할 수 있습니다.

코드 보기코드 닫기
C#
private void CreateDefaultChannelSync()
{
    try
    {
        using (var unityPlayer = new AndroidJavaClass("com.unity3d.player.UnityPlayer"))
        using (var activity = unityPlayer.GetStatic<AndroidJavaObject>("currentActivity"))
        using (var context = activity.Call<AndroidJavaObject>("getApplicationContext"))
        using (var notificationManagerClass = new AndroidJavaClass("android.app.NotificationManager"))
        {
            var notificationManager = context.Call<AndroidJavaObject>("getSystemService", "notification");
            int importanceHigh = notificationManagerClass.GetStatic<int>("IMPORTANCE_HIGH");
            using (var channel = new AndroidJavaObject("android.app.NotificationChannel", "default", "일반", importanceHigh))
            {
                channel.Call("setDescription", "Push notifications");
                channel.Call("enableVibration", true);
                channel.Call("enableLights", true);
                notificationManager.Call("createNotificationChannel", channel);
                Debug.Log("[FCMTopicPush] Created channel: default (IMPORTANCE_HIGH)");
            }
        }
    }
    catch (Exception e)
    {
        Debug.LogWarning(e.Message);
    }
}

Push Console

Cloud Save (DB)

플레이어의 데이터와 매칭 데이터를 UGS Cloud Save에 저장합니다. 데이터 저장은 별도의 DB를 구축해도 되지만 UGS 생태계를 활용하기 위해 Cloud Save를 활용하기로 결정했습니다.

플레이어 데이터

CloudSavePlayerData
플레이어 데이터에서는 플레이어와 관련된 정보가 기록됩니다. 플레이어가 직접 읽고 쓸 수 있는 데이터는 Default에, 서버만 읽고 쓸 수 있는 데이터는 Protected에 배치합니다.

매칭 데이터

CloudSaveGameData

게임 매칭 정보를 매치마다 기록합니다. 게임이 마무리된 경우, UGS Matchmaker의 매칭 정보와 참여한 플레이어 정보 등이 기록됩니다.

게임 결과

result
UGS2
게임이 마무리되면 데디케이티드 서버는 중앙 서버로 게임 결과(승/무/패)를 전송합니다. 게임 결과를 전송받은 중앙 서버는 UGS에 매칭 데이터와 플레이어 데이터를 업데이트하고, 게임 기록을 정리해 데디케이티드 서버로 응답을 돌려줍니다.
응답을 돌려받은 데디케이티드 서버는 TargetRpc로 각 플레이어에게 게임 결과를 전달합니다.

UI

UI
모든 UI는 UI Toolkit을 사용해 구현했습니다. 소규모로 게임을 제작할 때 uGUI는 UI 개발과 애니메이션 구현에 많은 시간이 필요했기 때문에, UI Toolkit의 Transition을 사용해 작업 효율과 성능을 비교했습니다.

목차

개요기술적 챌린지매치메이킹인증인앱 결제인앱 광고랜덤 박스 (확률형 아이템)푸시 메시지Cloud Save (DB)플레이어 데이터매칭 데이터게임 결과UI
다음 프로젝트

Embers

오픈월드 MMORPG 게임