프로젝트 요약유니티의 레거시 네트워크 시스템인 UNET으로 만든 3D 멀티플레이어 슈팅 게임 프로토타입이다. 서버와 클라이언트 간의 동기화, 권한(Authority) 분리, 구글 애드몹 광고 연동을 학습하려고 개발했다.
Project SummaryA 3D multiplayer shooter prototype built with Unity's legacy networking system, UNET. I built it to practice server-client synchronization, authority separation, mobile touch controls, and Google AdMob integration.

무엇을 만들었나What I Built

좌우로 밀어서 표 전체 내용을 확인하세요
항목내용
엔진Unity 2018.3.12f1
플랫폼Android
기여도100% (1인 개발)
네트워크UNET (NetworkBehaviour · [Command] · [ClientRpc] · [SyncVar])
상태완성
Swipe horizontally to view the full table
ItemDetails
EngineUnity 2018.3.12f1
PlatformAndroid
Role100% (Solo development)
NetworkingUNET (NetworkBehaviour · [Command] · [ClientRpc] · [SyncVar])
StatusCompleted

모바일 환경을 고려한 조작 체계와, 네트워크 상에서 서로 상호작용(공격·피격·리스폰)할 수 있는 멀티플레이 기초 환경을 구성하는 것을 목표로 잡았다. 구현한 요소는 네 덩어리다. UNET 동기화, 가상 조이스틱, 서버 사이드 전투·스폰, 그리고 애드몹 연동.

The goal was to set up mobile-friendly controls alongside a foundational multiplayer environment where players could shoot, take damage, and respawn across the network. The implementation came down to four parts: UNET state sync, a virtual touch joystick, server-side combat and spawning, and AdMob integration.

호스트로 실행한 UNET 슈팅 프로토타입의 유니티 에디터 화면
호스트로 띄운 상태. Hierarchy 에 Enemy(Clone) 여덟과 Player(Clone) 하나가 스폰돼 있고, Game 뷰 좌하단에 가상 조이스틱, 우측에 배너 광고 토글이 보인다.Running as Host. Eight Enemy(Clone) instances and one Player(Clone) are spawned in the Hierarchy, with the virtual joystick at the bottom-left of the Game view and banner ad toggles on the right.

리소스 대신 로직에 시간을 쓰기Spending Time on Netcode Instead of Art Assets

캐릭터와 적, 총알은 유니티 내장 3D 기본 도형(Cube, Capsule 등)만으로 구성했다. 네트워크 동기화 로직 구현에 집중하기 위한 선택이었다.

Characters, enemies, and bullets were built entirely out of Unity's built-in 3D primitives (cubes and capsules) so I could spend my time on network synchronization logic.

플레이 전 씬 구성이 보이는 유니티 에디터 화면
플레이 전 씬 구성. Spawn Position 두 개와 Enemy Spawner, Admob Banner, Admob Screen Ad 가 한 씬에 올라가 있다.Scene setup before hitting Play. Two Spawn Positions, an Enemy Spawner, Admob Banner, and Admob Screen Ad sit in a single scene.

발사: 요청은 클라이언트, 생성은 서버Firing: Clients Request, the Server Spawns

isLocalPlayer 로 자기 캐릭터의 입력만 받는다. 이 검사가 없으면 한 클라이언트의 키 입력이 화면에 있는 모든 캐릭터를 움직인다.

Checking isLocalPlayer ensures a client only processes input for its own character. Without that check, pressing a key on one client would move every player character in the scene.

void Update()
{
    if (!isLocalPlayer) return;

    var x = Input.GetAxis("Horizontal") * Time.deltaTime * 150.0f;
    var z = Input.GetAxis("Vertical") * Time.deltaTime * 3.0f;
    transform.Rotate(0, x, 0);
    transform.Translate(0, 0, z);

    if (Input.GetKeyDown(KeyCode.Space))
    {
        CmdFire();
    }
}

[Command]
void CmdFire()
{
    GameObject bullet = Instantiate(bulletPreFab, bulletSpawn.position, bulletSpawn.rotation);
    bullet.GetComponent<Rigidbody>().velocity = bullet.transform.forward * 6;
    NetworkServer.Spawn(bullet); // 서버에서 클라이언트들로 스폰 브로드캐스트
    Destroy(bullet, 2.0f);
}
void Update()
{
    if (!isLocalPlayer) return;

    var x = Input.GetAxis("Horizontal") * Time.deltaTime * 150.0f;
    var z = Input.GetAxis("Vertical") * Time.deltaTime * 3.0f;
    transform.Rotate(0, x, 0);
    transform.Translate(0, 0, z);

    if (Input.GetKeyDown(KeyCode.Space))
    {
        CmdFire();
    }
}

[Command]
void CmdFire()
{
    GameObject bullet = Instantiate(bulletPreFab, bulletSpawn.position, bulletSpawn.rotation);
    bullet.GetComponent<Rigidbody>().velocity = bullet.transform.forward * 6;
    NetworkServer.Spawn(bullet); // Broadcast the spawned bullet from server to all clients
    Destroy(bullet, 2.0f);
}

발사는 두 단계로 나뉜다. 클라이언트는 CmdFire() 로 요청만 보내고, 총알을 실제로 만들고 날리는 것은 서버다. NetworkServer.Spawn() 이 서버가 만든 총알을 모든 클라이언트에 브로드캐스트하고, Destroy(bullet, 2.0f) 가 2초 뒤 정리한다.

Firing is split into two steps. The client sends only a request by calling CmdFire(), while the server actually instantiates and launches the projectile. NetworkServer.Spawn() broadcasts the server-spawned bullet to all connected clients, and Destroy(bullet, 2.0f) cleans it up two seconds later.

UNET 의 명명 규칙[Command] 가 붙은 함수는 이름이 Cmd 로, [ClientRpc] 는 Rpc 로 시작해야 한다. 지키지 않으면 컴파일 단계에서 걸린다. 통신 방향을 이름으로 강제하는 셈이다.
UNET's Naming ConventionMethods marked with [Command] must start with the prefix Cmd, and [ClientRpc] methods must start with Rpc. If you break that naming rule, UNET's Weaver rejects it at compile time—enforcing network directionality through method names.

체력: SyncVar 의 hook 으로 UI까지 따라오게Health: Updating UI via a SyncVar Hook

[SyncVar(hook = "OnChangeHealth")]
public int currentHealth = maxHealth;

public void TakeDamage(int amount)
{
    if (!isServer) return;

    currentHealth -= amount;
    if(currentHealth <= 0)
    {
        if (destroyOnDeath) {
            Destroy(gameObject);
        } else {
            currentHealth = maxHealth;
            RpcRespawn();
        }
    }
}

void OnChangeHealth(int health)
{
    healthSlider.value = health;
}

[ClientRpc]
void RpcRespawn()
{
    if (isLocalPlayer)
    {
        Vector3 spawnPoint = spawnPoints[Random.Range(0, spawnPoints.Length)].transform.position;
        transform.position = spawnPoint;
    }
}

TakeDamage 는 !isServer 면 즉시 반환한다. 즉 피격 판정은 서버에서만 일어난다. 사망하면 destroyOnDeath 여부에 따라 오브젝트를 파괴하거나, 체력을 되돌리고 RpcRespawn() 으로 클라이언트에 리스폰을 알린다.

TakeDamage returns immediately if !isServer, meaning damage calculation only happens on the server. When health drops to 0 or below, the server either destroys the object (for enemies) or resets health and calls RpcRespawn() to tell the client to move back to a spawn point.

체력은 순간적인 사건이 아니라 계속 유지되는 값이라 [SyncVar] 로 뒀다. hook 으로 지정한 OnChangeHealth 가 값이 바뀐 클라이언트에서 호출되면서 슬라이더까지 함께 따라온다.

Because health is a persistent state rather than a one-off event, it is marked with [SyncVar]. Whenever the server modifies currentHealth, the OnChangeHealth hook runs on each client and updates the health bar slider.

좌우로 밀어서 표 전체 내용을 확인하세요
어트리뷰트방향쓰는 곳
[Command]클라이언트 → 서버발사 요청 등 '하고 싶다'
[ClientRpc]서버 → 모든 클라이언트리스폰 통보 등 '이렇게 됐다'
[SyncVar]서버 → 모든 클라이언트 (값)체력처럼 유지되는 상태
Swipe horizontally to view the full table
AttributeDirectionUse Case
[Command]Client → ServerPlayer intent, e.g., requesting to fire
[ClientRpc]Server → All ClientsOne-off server events, e.g., forcing a respawn
[SyncVar]Server → All Clients (state)Continuous state values like current health

모바일 조작: 가상 조이스틱Mobile Controls: Virtual Joystick

public void OnDrag(PointerEventData eventData)
{
    Vector2 value = eventData.position - (Vector2)rect_Background.position;
    value = Vector2.ClampMagnitude(value, radius);
    rect_Joystick.localPosition = value;

    float distance = Vector2.Distance(rect_Background.position, rect_Joystick.position) / radius;
    value = value.normalized;
    movePosition = new Vector3(value.x * moveSpeed * distance * Time.deltaTime, 0f, value.y * moveSpeed * distance * Time.deltaTime);
}

PointerEventData 로 배경 중심에서의 드래그 벡터를 구하고, ClampMagnitude 로 반경 안에 가둔다. 중심에서 떨어진 거리를 반경으로 나누면 0~1 사이 값이 나오는데, 이걸 정규화한 방향 벡터에 곱해 이동량을 만든다. 스틱을 살짝 밀면 천천히, 끝까지 밀면 최고 속도로 움직이는 이유가 이 distance 항이다.

Using PointerEventData, the script computes the drag offset from the joystick background's center and clamps it within radius via ClampMagnitude. Dividing the stick's distance from the center by radius yields a 0–1 ratio, which scales the normalized direction vector. That distance factor is why nudging the stick walks slowly while pushing it to the edge moves at full speed.

애드몹 붙이기Integrating Google AdMob

private void InitAd()
{
    string id = Debug.isDebugBuild ? test_unitID : unitID;
    screenAd = new InterstitialAd(id);
    AdRequest request = new AdRequest.Builder().Build();

    screenAd.LoadAd(request);
}

private IEnumerator ShowScreenAd()
{
    while (!screenAd.IsLoaded())
    {
        yield return null;
    }
    screenAd.Show();
}

Debug.isDebugBuild 로 테스트 광고 단위와 실제 단위를 가른다. 광고는 로드가 끝나야 띄울 수 있어서, IsLoaded() 가 참이 될 때까지 코루틴으로 기다린 뒤 Show() 한다. 전면 광고와 하단 배너 광고의 노출 제어를 이런 스크립트로 나눠 뒀다.

Debug.isDebugBuild switches between Google's test ad unit ID and the production ID. Because interstitial ads cannot be shown until they finish loading, a coroutine waits until IsLoaded() returns true before calling Show().

정리Wrap-Up

참고UNET 은 이후 지원이 종료됐다. 다만 [Command] / [ClientRpc] / [SyncVar] 에 해당하는 개념은 Mirror 나 Netcode for GameObjects 에 이름만 바뀐 채 그대로 남아 있다.
NoteUnity later discontinued UNET, but the exact same concepts behind [Command], [ClientRpc], and [SyncVar] live on under slightly different names in Mirror and Netcode for GameObjects.

클라이언트가 직접 총알을 만들고 체력을 깎는 대신, 서버에 요청을 보내고 서버가 바꾼 결과를 받아 그리는 흐름을 이 프로토타입에서 처음 손으로 짜 봤다. 프레임워크가 바뀌어도 이 구조 자체는 크게 달라지지 않는다.

Instead of letting a client spawn its own bullets and subtract health locally, this prototype was the first time I wired up the full loop of sending a request to the server and rendering the server-authoritative result. Even when the networking library changes, that underlying flow stays the same.