UNITY/네트워크

[네트워크] TCP/IP - 비동기 방식의 연결 (2)TCP 비동기 클라이언트

램플릿 2025. 6. 17. 00:45

 

TCP 비동기 클라이언트

using UnityEngine;
using UnityEngine.UI;
using System;
using System.Net.Sockets;
using System.Threading.Tasks;
using System.Text;

public class TcpClientAsync : MonoBehaviour
{
    [SerializeField] private string serverIP = "127.0.0.1";
    [SerializeField] private int serverPort = 8888;
    
    [SerializeField] private InputField inputField;
    [SerializeField] private Button sendButton;
    [SerializeField] private Text receivedText;
    
    private TcpClient client;
    private NetworkStream stream;
    
    private async void Start()
    {
        //UI 요소가 유효한 지 확인
        if (inputField == null||sendButton == null||receivedText == null)
        {
            Debug.Log("UI elements are not assigned");
            return;
        }
        
        sendButton.onClick.AddListener(SendData);   //버튼클릭 이벤트에 SendData 연결

        try
        {
            await ConnectToServer(); //비동기로 서버에 연결
        }
        catch (Exception ex)
        {
            Debug.LogError("Connection error: "+ex.Message);
            receivedText.text = "Connection failed.";
        }
    }
    private async Task ConnectToServer()
    {
        client = new TcpClient();
        await client.ConnectAsync(serverIP, serverPort); //비동기 연결
        stream = client.GetStream();
        receivedText.text = "Connected to server";
        Debug.Log("Connected to server");
    }
    public async void SendData()
    {
        if (client == null || !client.Connected)
        {
            Debug.LogError("Client is not connected");
            receivedText.text = "Not connected";
            return;
        }
        
        string msg = inputField.text;

        if (string.IsNullOrEmpty(msg)) //빈 문자열 방지
        {
            Debug.LogWarning("Msg is null or empty");
            return;
        }
        
        byte[] data = Encoding.UTF8.GetBytes(msg);

        try
        {
            //await stream.WriteAsync(data, 0, data.Length);  // 서버에 데이터 전송(비동기) <.NET 비동기 소켓 API
            await Task.Run(() => stream.Write(data, 0, data.Length));   //동기함수를 쓰레드로 감싸 비동기 처리 (쓰레드 생성으로 CPU 리소스 더 소비)
            Debug.Log("Sent: " + msg);

            byte[] buffer = new byte[1024];
            //int bytesRead = await stream.ReadAsync(buffer, 0, buffer.Length); //서버 응답 수신(비동기)
            int bytesRead = await Task.Run(() => stream.Read(buffer, 0, buffer.Length));  //별도 쓰레드로 비동기 처리
            string received = Encoding.UTF8.GetString(buffer, 0, bytesRead);
            receivedText.text = "Server says: " + received;
            Debug.Log("Received: " + received);
        }
        catch (Exception ex)
        {
            Debug.LogError("Send/Receive error: " + ex.Message);
            receivedText.text = "Error: " + ex.Message;
        }
    }

    private void OnDestroy()    //객체 파괴 시
    {
        //연결 종료
        if(stream!= null)
            stream.Close();
        if(client!=null)
            client.Close();
    }
}

 

  개선할 점   

 

1. Task.Run() 대신 진짜 비동기 메서드 사용 WriteAsync(), ReadAsync()를 사용해 더 효율적인 I/O 수행
2. UI 업데이트는 반드시 메인 스레드에서 처리 Unity는 UI 조작을 메인 스레드에서만 허용하므로 안전하게 처리해야 함
3. 예외 및 연결 상태 관리 보완 서버 연결 실패, 연결 끊김 등 처리 부족
4. Start() 메서드에서의 await 처리 개선 게임 로직 초기화 지연 방지
5. 네트워크 상태 UI 피드백 개선 연결 중 / 연결됨 / 실패 상태를 명확하게 표현하도록 UI 업데이트
6. 리팩토링으로 책임 분리 UI와 네트워크 로직 분리 (단일 책임 원칙 SRP 적용 가능)

 

1. WriteAsync, ReadAsync 사용 (성능 & 리소스 개선)

await stream.WriteAsync(data, 0, data.Length);
int bytesRead = await stream.ReadAsync(buffer, 0, buffer.Length);

2. Unity 메인 스레드에서 UI 수정 (쓰레드 충돌 방지)

Unity는 다른 쓰레드에서 Text.text를 직접 바꾸면 예외가 발생할 수 있으므로, 다음 방식 사용:

 

private void UpdateUI(string message)
{
    // 메인 스레드에서 실행
    UnityMainThreadDispatcher.Instance().Enqueue(() => {
        receivedText.text = message;
    });
}

UnityMainThreadDispatcher는 외부 유틸리티 패키지이며, 직접 구현할 수도 있어요. 아니면 Coroutine으로 우회 가능.

🎓 원래 코드에서는 receiveText.text=...처럼 비동기 함수 내에서 UI를 직접 업데이트하였다. 유니티는 await 이후에 원래의 Unity 스레드 컨텍스트로 돌아오기 때문에 안전하지만, 만약 await 없이 다른 스레드에서 UI를 업데이트하려면 UnitySynchronizationContext나 Dispatcher 등의 도구를 사용해야한다.

 


3. 연결 상태 UI 개선

연결 중일 때 로딩 메시지를 보여주거나 버튼 비활성화:

private async Task ConnectToServer()
{
    UpdateUI("Connecting...");
    client = new TcpClient();

    try
    {
        await client.ConnectAsync(serverIP, serverPort);
        stream = client.GetStream();
        UpdateUI("Connected to server");
    }
    catch (Exception ex)
    {
        UpdateUI("Connection failed: " + ex.Message);
    }
}

4. Start()에서 연결을 분리해 초기화 지연 방지

private void Start()
{
    if (inputField == null || sendButton == null || receivedText == null)
    {
        Debug.Log("UI elements are not assigned");
        return;
    }

    sendButton.onClick.AddListener(SendData);

    _ = TryConnect(); // 비동기 연결 시작
}

private async Task TryConnect()
{
    await ConnectToServer();
}

 

💡_ = TryConnect()에서 _는 TryConnect()가 반환하는 Task를 무시한다는 의미이다. TryConnect는 백그라운드에서 계속 실행된다. Task를 명시적으로 변수에 저장하고 관리하는 것이 더 정교한 비동기 처리에 유용할 수 있지만, 단순화를 위해 무시하기도 한다.

5. 네트워크 상태 및 에러 예외 처리 보강

  • 서버가 중간에 연결 끊으면?
  • SendData() 실행 중 stream == null이면?
  • 위와 같은 문제에 대비해 상태 체크 보완 필요:
if (client == null || !client.Connected || stream == null)
{
    Debug.LogError("Client not ready");
    UpdateUI("Not connected");
    return;
}

🧼 보너스: 리팩토링 제안

이 구조는 UI + 통신 로직이 모두 한 클래스에 섞여 있어요. 유지보수나 테스트를 쉽게 하려면 분리하는 게 좋습니다:

  • TcpClientHandler.cs → 네트워크 로직만 담당
  • TcpClientUI.cs → UI 처리만 담당, 이벤트 구독으로 UI 갱신

🧠 결론: 이 코드에서 가장 중요한 개선 3가지

  1. ✅ Task.Run 제거하고 진짜 비동기 (WriteAsync, ReadAsync) 사용
  2. ✅ UI는 반드시 메인 스레드에서만 업데이트
  3. ✅ 연결 실패나 끊김 등 예외 처리를 보완해 유저 경험 향상