asp.net core를 활용하여 모바일 게임 만들기 (7일차)
전에는 ServerConnectionTester에서 요청을 처리하는 코드를 제작했었습니다. 하지만 다양한 기능을 추가하면 UnityWebRequest 코드가 반복되는 문제가 있습니다. 따라서 이를 해결하기 위해 공통된 네트워크 처리를 해줄 ApiClient를 만들어보겠습니다.
namespace Work.LCM._01.Scripts.Network
{
public class ApiResult<T>
{
public bool IsSuccess { get; }
public long StatusCode { get; }
public T Data { get; }
public string ErrorCode { get; }
public string ErrorMessage { get; }
private ApiResult(
bool isSuccess,
long statusCode,
T data,
string errorCode,
string errorMessage)
{
IsSuccess = isSuccess;
StatusCode = statusCode;
Data = data;
ErrorCode = errorCode;
ErrorMessage = errorMessage;
}
public static ApiResult<T> Success(
long statusCode,
T data)
{
return new ApiResult<T>(
true,
statusCode,
data,
string.Empty,
string.Empty);
}
public static ApiResult<T> Failure(
long statusCode,
string errorCode,
string errorMessage)
{
return new ApiResult<T>(
false,
statusCode,
default(T),
errorCode,
errorMessage);
}
}
}
ApiResult는 Unity가 Api를 호출하면 성공 데이터만 받는게 아니라
- 요청 성공 여부
- HTTP 상태 코드
- 정상 응답 데이터
- 서버 오류 코드
- 서버 오류 메시지
등을 같이 받는데 이 정보를 하나로 묶기 위한 코드가 ApiResult 코드입니다. T에는 정상 응답 DTO가 들어갑니다.
또한 서버한테 요청을 할때 오류가 나면 asp.net core에서 오류를 보내주는데 이를 받기 위한 DTO도 만들도록 하겠습니다.
using System;
namespace Work.LCM._01.Scripts.Network
{
[Serializable]
public class ApiErrorResponse
{
public string code;
public string message;
public string title;
}
}
이제 유니티에서 플레이어 생성 요청을 보내면 서버가 이를 처리하는걸 만들어보겠습니다.
일단 이전에 만들었던 서버는 플레이어 생성을 위해 닉네임을 데이터로 받았습니다. 이를 위해 RequestDTO도 닉네임을 가진 DTO로 만들어주겠습니다.
using System;
namespace Work.LCM._01.Scripts.Network
{
[Serializable]
public class CreatePlayerRequest
{
public string nickname;
}
}
그리고 이제 플레이어가 생성되었다면 서버에서 클라이언트에게 생성된 플레이어의 ID,닉네임,레벨을 전달해주기 때문에 이를 받을 ResponseDTO도 만들어주겠습니다.
using System;
namespace Work.LCM._01.Scripts.Network
{
[Serializable]
public class PlayerSummaryResponse
{
public int playerId;
public string nickname;
public int level;
}
}
자 이제 서버에게 플레이어 생성 요청을 보낼 DTO, 생성된 플레이어 정보를 받을 DTO, 오류 메세지를 받을 DTO까지 전부 만들어졌으니 ApiClient 코드를 작성해보겠습니다.
using System;
using System.Collections;
using System.Text;
using UnityEngine;
using UnityEngine.Networking;
namespace Work.LCM._01.Scripts.Network
{
public class ApiClient : MonoBehaviour
{
[SerializeField] private string serverBaseUrl =
"http://localhost:5098";
public IEnumerator Get<TResponse>(
string path,
Action<ApiResult<TResponse>> onCompleted)
{
string url = BuildUrl(path);
using (UnityWebRequest request = UnityWebRequest.Get(url))
{
yield return SendRequest(request, onCompleted);
}
}
public IEnumerator Post<TRequest, TResponse>(
string path,
TRequest body,
Action<ApiResult<TResponse>> onCompleted)
{
string url = BuildUrl(path);
string json = JsonUtility.ToJson(body);
byte[] bodyBytes = Encoding.UTF8.GetBytes(json);
using (UnityWebRequest request =
new UnityWebRequest(
url,
UnityWebRequest.kHttpVerbPOST))
{
request.uploadHandler =
new UploadHandlerRaw(bodyBytes);
request.downloadHandler =
new DownloadHandlerBuffer();
request.SetRequestHeader(
"Content-Type",
"application/json");
yield return SendRequest(request, onCompleted);
}
}
private IEnumerator SendRequest<TResponse>(
UnityWebRequest request,
Action<ApiResult<TResponse>> onCompleted)
{
yield return request.SendWebRequest();
if (request.result == UnityWebRequest.Result.Success)
{
string json = request.downloadHandler.text;
if (string.IsNullOrWhiteSpace(json))
{
onCompleted?.Invoke(
ApiResult<TResponse>.Success(
request.responseCode,
default(TResponse)));
yield break;
}
try
{
TResponse data =
JsonUtility.FromJson<TResponse>(json);
onCompleted?.Invoke(
ApiResult<TResponse>.Success(
request.responseCode,
data));
}
catch (Exception exception)
{
onCompleted?.Invoke(
ApiResult<TResponse>.Failure(
request.responseCode,
"RESPONSE_DESERIALIZATION_FAILED",
$"서버 응답 변환에 실패했습니다. {exception.Message}"));
}
yield break;
}
onCompleted?.Invoke(
CreateFailureResult<TResponse>(request));
}
private static ApiResult<TResponse>
CreateFailureResult<TResponse>(
UnityWebRequest request)
{
string errorCode = request.result.ToString();
string errorMessage = request.error;
string responseBody =
request.downloadHandler?.text ?? string.Empty;
if (request.result ==
UnityWebRequest.Result.ProtocolError)
{
ApplyServerError(
responseBody,
ref errorCode,
ref errorMessage);
}
return ApiResult<TResponse>.Failure(
request.responseCode,
errorCode,
errorMessage);
}
private static void ApplyServerError(
string responseBody,
ref string errorCode,
ref string errorMessage)
{
if (string.IsNullOrWhiteSpace(responseBody))
{
return;
}
try
{
ApiErrorResponse error =
JsonUtility.FromJson<ApiErrorResponse>(
responseBody);
if (!string.IsNullOrWhiteSpace(error.code))
{
errorCode = error.code;
}
if (!string.IsNullOrWhiteSpace(error.message))
{
errorMessage = error.message;
return;
}
if (!string.IsNullOrWhiteSpace(error.title))
{
errorMessage = error.title;
return;
}
}
catch (Exception)
{
}
errorMessage = responseBody;
}
private string BuildUrl(string path)
{
string baseUrl = serverBaseUrl.TrimEnd('/');
string relativePath =
path.StartsWith("/") ? path : $"/{path}";
return $"{baseUrl}{relativePath}";
}
}
}
코드를 보면 중요한 5가지 파트로 이루어져있습니다.
- BuildUrl()
- Get()
- Post()
- SendRequest()
- CreateFailureResult(), ApplyServerError()
BuildUrl()은 서버 기본 주소와 API 주소를 합쳐주는 역할입니다.
string baseUrl = serverBaseUrl.TrimEnd('/');
만약 주소 마지막에 http://localhost:5098/ 처럼 입력되면 마지막 /가 두개가 되기 때문에 이를 지워주는 코드입니다.
string relativePath =
path.StartsWith("/") ? path : $"/{path}";
호출하는 쪽에서
- /api/players
- api/players
둘 다 제대로 경로가 입력되도록 하는 코드입니다.
Get()은 서버 데이터를 조회할 때 사용됩니다.
public IEnumerator Get<TResponse>(
string path,
Action<ApiResult<TResponse>> onCompleted)
{
string url = BuildUrl(path);
using (UnityWebRequest request = UnityWebRequest.Get(url))
{
yield return SendRequest(request, onCompleted);
}
}
사용 예시는 아래 코드처럼 씁니다.
StartCoroutine(
apiClient.Get<ServerStatusResponse>(
"/api/server/ping",
HandlePingResult));
매개변수는 각각 TResponse는 성공 응답을 변환할 DTO이고, path는 호출할 API 경로, onCompleted는 요청 완료 후 실행할 메서드입니다.
Get()에서 실제 서버에게 요청을 하는게 아닌 요청을 위한 준비만 하고 실제 요청은 SendRequest()에서 합니다.
Post()는 유니티에서 서버에게 데이터를 보낼 때 사용합니다.
public IEnumerator Post<TRequest, TResponse>(
string path,
TRequest body,
Action<ApiResult<TResponse>> onCompleted)
{
string url = BuildUrl(path);
string json = JsonUtility.ToJson(body);
byte[] bodyBytes = Encoding.UTF8.GetBytes(json);
using (UnityWebRequest request =
new UnityWebRequest(
url,
UnityWebRequest.kHttpVerbPOST))
{
request.uploadHandler =
new UploadHandlerRaw(bodyBytes);
request.downloadHandler =
new DownloadHandlerBuffer();
request.SetRequestHeader(
"Content-Type",
"application/json");
yield return SendRequest(request, onCompleted);
}
}
사용하는 코드는 아래처럼 사용합니다.
StartCoroutine(
apiClient.Post<
CreatePlayerRequest,
PlayerSummaryResponse>(
"/api/players",
request,
HandleCreateResult));
TRequest는 서버에 보낼 DTO, TResponse는 서버한테 받을 DTO입니다.
이때 클라이언트와 서버가 통신하기 위해 직렬화가 필요합니다.
string json = JsonUtility.ToJson(body);
여기서 매개변수에서 받은 DTO를 JSON으로 변환해줍니다.
byte[] bodyBytes = Encoding.UTF8.GetBytes(json);
또한 json을 UTF-8을 사용해 byte로 변환하면 영문뿐만 아니라 한글도 깨지지 않기 때문에 UTF-8 변환을 해줍니다.
using (UnityWebRequest request =
new UnityWebRequest(
url,
UnityWebRequest.kHttpVerbPOST))
주소로 보낼 POST 요청을 만드는 코드입니다.
request.uploadHandler =
new UploadHandlerRaw(bodyBytes);
UploadHandler는 유니티에서 서버로 보내는 데이터를 관리합니다.
앞서 만든 json 바이트가 들어가는데 json 바이트가 Request Body로 서버에 전송됩니다.
request.downloadHandler =
new DownloadHandlerBuffer();
DownloadHandler는 UploadHandler에 반대로 서버에서 유니티로 오는 응답을 받습니다.
request.SetRequestHeader(
"Content-Type",
"application/json");
Content-Type을 설정하는 코드로 서버에게 지금 보내는 Request Body가 JSON 타입이라는걸 알려주는 코드입니다.
private IEnumerator SendRequest<TResponse>(
UnityWebRequest request,
Action<ApiResult<TResponse>> onCompleted)
SendRequest()는 서버에게 실제 요청을 보내는 함수입니다.
요청을 보냈을 때 성공 응답을 받으면 서버가 넘겨준 JSON을 DTO로 변환하고 ApiResult로 묶어서 onCompleted로 넘겨주는 역할을 합니다.
코드가 길어서 이해하기 좀 어려웠지만 메소드 단위로 보면 생각보다 어렵지 않았던 것 같습니다.