TIL

데이터 중심 동기화 (Data-oriented Synchronization)에 대하여 (Node.js 중심)

추운날_너를_기다리며 2024. 11. 9. 00:48

데이터 중심 동기화(Data-oriented Synchronization)는 네트워크 환경에서 데이터의 일관성을 유지하기 위해, 클라이언트와 서버 간의 데이터 동기화 방식을 최적화하는 기법입니다. 특히 실시간 멀티 플레이 게임이나 분산 시스템에서 데이터의 상태를 효율적으로 동기화하는 데 사용됩니다. 이 방법은 주로 상태의 변화를 기준으로 동기화하거나, 데이터의 중요한 부분만 전송하여 네트워크 자원의 효율성을 높입니다.

 

개념 설명

  • 상태 기반(State-based): 클라이언트는 주기적으로 전체 상태를 전송하거나 동기화하고, 서버는 이를 바탕으로 상태를 업데이트합니다. 주기적인 스냅샷이 필요하므로, 자주 변경되는 상태에는 다소 비효율적일 수 있습니다.
  • 변화 기반(Changed-based): 상태가 변화할 때만 해당 부분을 전송하는 방식입니다. 델타(변화량)만 전송하여 네트워크 대역폭을 절약할 수 있습니다. 일반적으로 실시간 애플리케이션에서 자주 사용됩니다.

구현 예시

게임에서 여러 사용자가 각자 캐릭터를 조작한다고 가정합시다. 각 캐릭터의 좌표와 상태를 서버와 클라이언트 사이에서 동기화해야 합니다.

1. 데이터 구조 설계 서버는 각 캐릭터의 상태를 저장하고 이를 주기적으로 모든 클라이언트에 전송합니다.

// 예시: 캐릭터 상태를 저장하는 데이터 구조
const characterStates = {
  'player1': { x: 100, y: 200, health: 90 },
  'player2': { x: 150, y: 250, health: 80 },
  // ...
};

 

2. 클라이언트에서 상태 변경 시 이벤트 전송 클라이언트가 캐릭터의 위치를 변경할 때 서버에 해당 변화를 전송합니다.

socket.emit('updatePosition', {
  playerId: 'player1',
  x: 105,
  y: 210,
});

 

3. 서버에서 상태 업데이트 및 동기화 서버는 클라이언트에서 수신한 데이터를 기반으로 상태를 업데이트하고, 나머지 클라이언트에 전파합니다.

socket.on('updatePosition', (data) => {
  characterStates[data.playerId] = {
    ...characterStates[data.playerId],
    x: data.x,
    y: data.y,
  };

  // 모든 클라이언트에게 동기화 전송
  io.emit('syncState', { playerId: data.playerId, x: data.x, y: data.y });
});

 

4. 클라이언트에서 상태 수신 및 적용 클라이언트는 서버로부터 상태를 수신할 때 해당 데이터를 업데이트합니다.

socket.on('syncState', (data) => {
  updateCharacterPosition(data.playerId, data.x, data.y);
});

 

최적화 방법

  • 압축 사용: 전송되는 데이터의 크기를 줄이기 위해 압축 알고리즘을 적용합니다.
  • 보간(Interpolation): 서버에서 업데이트 사이에 캐릭터의 움직임을 부드럽게 하기 위해 클라이언트는 보간을 사용할 수 있습니다.
  • 데드 리코닝(Dead Reckoning): 예상되는 위치를 기반으로 캐릭터를 예측적으로 이동시켜 네트워크 지연을 보완합니다.

압축 사용

네트워크를 통해 전송되는 데이터의 양을 줄여 대역폭을 절약할 수 있습니다. 이를 위해 데이터 압축 기술을 적용할 수 있습니다.

 

예시: JSON 데이터를 압축하여 전송

// JSON 데이터 예시
const data = { playerId: 'player1', x: 105, y: 210, health: 90 };

// 전송 전 압축 (Node.js zlib 사용)
const zlib = require('zlib');
const compressedData = zlib.gzipSync(JSON.stringify(data));

// 클라이언트 전송
socket.emit('syncState', compressedData);

// 서버에서 수신 후 압축 해제
socket.on('syncState', (compressedData) => {
  const decompressedData = JSON.parse(zlib.gunzipSync(compressedData));
  console.log(decompressedData); // { playerId: 'player1', x: 105, y: 210, health: 90 }
});

이 방식은 대량의 데이터를 전송할 때 효과적이며, 네트워크 지연을 줄이고 성능을 향상시킬 수 있습니다.

보간(Interpolation)

보간은 클라이언트에서 두 지점 사이의 상태를 부드럽게 연결해 시각적인 끊김을 줄이는 기술입니다. 예를 들어, 서버가 1초에 한 번씩 캐릭터의 위치를 업데이트할 때, 클라이언트는 중간 위치를 계산하여 자연스러운 움직임을 구현할 수 있습니다.

 

예시: 클라이언트에서 보간 처리

let lastPosition = { x: 100, y: 200 };
let targetPosition = { x: 150, y: 250 };
let currentTime = 0;
const updateInterval = 1000; // 서버 갱신 주기

function interpolatePosition(deltaTime) {
  currentTime += deltaTime;
  const t = currentTime / updateInterval; // 0에서 1 사이의 비율
  const interpolatedX = lastPosition.x + t * (targetPosition.x - lastPosition.x);
  const interpolatedY = lastPosition.y + t * (targetPosition.y - lastPosition.y);
  
  // 캐릭터 위치 업데이트
  updateCharacterPosition(interpolatedX, interpolatedY);
}

// 이 코드는 requestAnimationFrame 등을 사용하여 호출

이 코드로 클라이언트는 서버에서 새 데이터르 받을 때까지 자연스러운 애니메이션을 제공합니다.

데드 리코닝(Dead Reckoning)

데드 리코닝은 클라이언트가 서버의 데이터 전송 간격 동안 캐릭터의 예상 위치를 예측하여 업데이트하는 기술입니다. 이 방법은 네트워크 지연을 보완하고 사용자 경험을 개선하는 데 사용됩니다.

 

예시: 클라이언트에서 데드 리코닝 적용

let lastUpdateTime = Date.now();
let lastPosition = { x: 100, y: 200 };
let velocity = { x: 5, y: 3 }; // 서버에서 받은 속도 정보

function predictPosition() {
  const currentTime = Date.now();
  const timeElapsed = (currentTime - lastUpdateTime) / 1000; // 초 단위로 변환

  const predictedX = lastPosition.x + velocity.x * timeElapsed;
  const predictedY = lastPosition.y + velocity.y * timeElapsed;

  // 캐릭터 위치 업데이트
  updateCharacterPosition(predictedX, predictedY);
}

// 주기적으로 호출하여 위치 예측
setInterval(predictPosition, 16); // 약 60fps

이렇게 하면 서버가 갱신을 보내기 전까지 클라이언트가 부드러운 움직임을 유지할 수 있습니다. 새로운 서버 데이터를 받으면 lastUpdateTime과 lastPosition을 업데이트해 정확도를 맞춥니다.

요약

  • 압축 사용은 데이터 전송 효율을 높여 네트워크 비용을 줄입니다.
  • 보간(Interpolation)은 서버의 업데이트 간격 동안의 시각적 끊김을 부드럽게 연결합니다.
  • 데드 리코닝(Dead Reckoning)은 클라이언트가 서버에서 받는 데이터 사이에 예상되는 위치를 보정하여 네트워크 지연 문제를 완화합니다.

데이터 구조 및 서버 설정 (Node.js)

Node.js 서버의 클라이언트로부터 위치 데이터를 수신하고, 변경된 위치를 다른 클라이언트들에게 BroadCasting합니다.

 

Node.js TCP 서버 코드:

const net = require('net');

let clients = [];
let playerStates = {}; // 모든 클라이언트의 상태를 저장

const server = net.createServer((socket) => {
  console.log('새 클라이언트 연결됨');
  clients.push(socket);

  // 클라이언트가 위치를 업데이트할 때 데이터 수신
  socket.on('data', (data) => {
    const receivedData = data.toString();
    console.log(`수신된 데이터: ${receivedData}`);

    try {
      const parsedData = JSON.parse(receivedData);
      playerStates[socket.id] = {
        x: parsedData.x,
        y: parsedData.y,
        timestamp: Date.now(),
      };

      // 모든 클라이언트에 동기화 브로드캐스트
      broadcastState();
    } catch (error) {
      console.error('데이터 파싱 오류:', error);
    }
  });

  socket.on('end', () => {
    console.log('클라이언트 연결 종료');
    clients = clients.filter((client) => client !== socket);
    delete playerStates[socket.id];
  });

  socket.on('error', (err) => {
    console.error(`소켓 에러: ${err.message}`);
  });
});

function broadcastState() {
  const stateUpdate = JSON.stringify(playerStates);
  clients.forEach((client) => {
    client.write(stateUpdate);
  });
}

server.listen(3000, () => {
  console.log('TCP 서버가 3000 포트에서 실행 중입니다...');
});

 

Unity C# 클라이언트 (상태 수신 및 처리)

 

Unity 클라이언트는 서버에서 주기적으로 보내는 상태 데이터를 수신하고 이를 반영합니다.

 

C# TCP 클라이언트 코드:

 

using System;
using System.Net.Sockets;
using System.Text;
using UnityEngine;

public class TCPClient : MonoBehaviour
{
    private TcpClient client;
    private NetworkStream stream;
    private byte[] buffer = new byte[4096];
    private Vector3 targetPosition;
    private float interpolationSpeed = 5f;

    void Start()
    {
        ConnectToServer();
    }

    void Update()
    {
        // 사용자 입력으로 위치를 이동시킬 경우, 서버로 데이터 전송
        if (Input.GetKey(KeyCode.W))
        {
            Vector3 newPosition = transform.position + Vector3.forward * Time.deltaTime * 5;
            transform.position = newPosition;

            string message = JsonUtility.ToJson(new { x = newPosition.x, y = newPosition.z });
            byte[] data = Encoding.UTF8.GetBytes(message);
            stream.Write(data, 0, data.Length);
        }

        // 위치 보간 적용
        transform.position = Vector3.Lerp(transform.position, targetPosition, interpolationSpeed * Time.deltaTime);
    }

    void ConnectToServer()
    {
        try
        {
            client = new TcpClient("localhost", 3000);
            stream = client.GetStream();
            Debug.Log("서버에 연결됨");

            // 데이터 수신 대기
            stream.BeginRead(buffer, 0, buffer.Length, OnDataReceived, null);
        }
        catch (Exception e)
        {
            Debug.LogError($"서버 연결 오류: {e.Message}");
        }
    }

    void OnDataReceived(IAsyncResult ar)
    {
        try
        {
            int bytesRead = stream.EndRead(ar);
            if (bytesRead > 0)
            {
                string dataReceived = Encoding.UTF8.GetString(buffer, 0, bytesRead);
                Debug.Log($"서버로부터 수신된 데이터: {dataReceived}");

                // 서버에서 수신한 JSON 데이터를 파싱하여 위치 업데이트
                var stateData = JsonUtility.FromJson<ServerState>(dataReceived);

                foreach (var state in stateData.PlayerStates)
                {
                    if (state.Id == "player1") // 클라이언트의 ID에 따라 처리
                    {
                        targetPosition = new Vector3(state.X, 0, state.Y);
                    }
                }

                // 다음 수신 준비
                stream.BeginRead(buffer, 0, buffer.Length, OnDataReceived, null);
            }
        }
        catch (Exception e)
        {
            Debug.LogError($"데이터 수신 오류: {e.Message}");
        }
    }

    void OnApplicationQuit()
    {
        stream?.Close();
        client?.Close();
    }
}

// 서버에서 수신한 상태 데이터를 파싱하기 위한 클래스
[Serializable]
public class ServerState
{
    public PlayerState[] PlayerStates;
}

[Serializable]
public class PlayerState
{
    public string Id;
    public float X;
    public float Y;
}

 

설명:

  • Node.js 서버는 모든 클라이언트의 위치 데이터를 유지하고, 변경이 발생할 때마다 모든 클라이언트에게 전체 상태를 브로드캐스트합니다.
  • Unity C# 클라이언트는 주기적으로 서버에서 수신한 데이터를 통해 다른 플레이어들의 위치를 업데이트하고 보간(Interpolation)을 통해 부드러운 이동을 제공합니다.

이 방식은 TCP의 안정적인 연결 특성을 이용하여 데이터 전송을 보장하고, 클라이언트 간의 실시간 동기화를 유지할 수 있도록 합니다.