AWS GameLift와 Serverless로 구현하는 매칭 백엔드 아키텍처 (2부) — WSS연결로 매칭 요청/취소 처리하기

Jong Ha Shin
20 min readJan 9, 2025

AWS GameLift와 Serverless로 구현하는 매칭 백엔드 아키텍처 (1부)에서 이어집니다

들어가며

이번 포스트에서는 지난 번에 봤던 내용에 이어서, 클라이언트가 WebSocket으로 AWS APIGateway와 연결해 매칭/캔슬 요청을 보내면, 이를 처리하는 기능에 대해서 소개합니다. 참고로 이 시리즈는 모바일 게임 <마블스냅>의 매칭 아키텍쳐를 참고하여 제가 진행했던 <WILD BOWL : ZOOPORTS> 게임에 적용한 사례입니다.

AWS API Gateway에서 WSS 설정하기

WebSocket API 생성

  1. AWS Console에 로그인한 뒤 API Gateway 메뉴로 이동합니다.
  2. Create API 버튼을 클릭하여, WebSocket 유형의 API를 새로 생성합니다.
  • 기존에는 REST API를 주로 사용했지만, 매칭처럼 서버와 지속적인 연결이 필요한 경우 WebSocket을 사용해야 합니다.

Route(라우트) 설정

WebSocket API를 만들었다면, 이제 Route를 설정해야 합니다. Route는 “어떤 액션(또는 메시지 타입)으로 요청이 오면, 어떤 Lambda 함수나 VPC 엔드포인트로 연결할 것인지”를 결정하는 역할을 합니다.

예를 들어 다음과 같은 Route를 정의한다고 가정해 봅시다:

  • queueing: 매칭을 요청해서 매칭 대기열에 유저를 넣는 액션
  • ticketStatus: 현재 매칭 상태를 확인하는 액션
  • cancel: 매칭을 취소하는 액션

API Gateway의 콘솔에서 각 Route(예: queueing)를 만들고, Integration Target을 Lambda 함수로 연결해주면 됩니다.

클라이언트에서 WebSocket을 연결을 요청하고 메세지 보내기

이제 클라이언트에서 요청을 보내야 합니다. 클라이언트 측에서는 WebSocket 연결을 열고 메시지를 보낼 때, 해당 Route 이름을 포함해 요청을 전송해야 합니다.

  • 예: { "action": "queueing", "data": { ... } }

action이 “queueing”이면, API Gateway에서 queueing Route로 라우팅되어 매칭을 담당하는 Lambda 함수가 실행됩니다.

.NET의 WebSocketClient를 사용해 요청을 보내는 코드 예시


/// <summary>
/// WebSocket API와의 연결을 담당하는 클래스 <br/>
///
/// 사용 방식 <br/>
/// 1. WebSocketClient 생성 <br/>
/// 2. MessageReceived EventHandler에 응답을 처리할 메서드 등록 (생성자로 함수 포인터 받아서 초기화 하는 방안 고려) <br/>
/// 3. ConnectAsync(uri) 호출 <br/>
/// 4. 보낼 메시지가 있다면 SendWebRequest(request) 호출 <br/>
/// 5. 요청 취소 시 CancelAsync() 호출 <br/>
/// </summary>
public class WebSocketClient
{
static readonly ILogger Logger = LogFactory.GetLogger(typeof(WebSocketClient));

/// <summary>
/// WebSocket 연결로부터 메시지를 받으면 트리거 되는 이벤트
/// </summary>
public event EventHandler<string> MessageReceived;

private ClientWebSocket _webSocket;
private CancellationTokenSource _cancellationTokenSource;

/// <summary>
/// WebSocket 연결을 생성하고 Listening을 시작합니다.
/// </summary>
/// <param name="uri">WebSocket URI</param>
public async UniTask ConnectAsync(string uri)
{
_cancellationTokenSource?.Dispose();
_cancellationTokenSource = new CancellationTokenSource();

_webSocket?.Dispose();
_webSocket = new ClientWebSocket();

try
{
await _webSocket.ConnectAsync(new Uri(uri), _cancellationTokenSource.Token);
StartListening().Forget();
}
catch (OperationCanceledException)
{
Logger.Log("WebSocket connection attempt was canceled.");
}
catch (Exception ex)
{
Logger.LogError(ex.StackTrace);
}
}

/// <summary>
/// WebSocket으로부터 메시지 Listening을 시작합니다.
/// </summary>
private async UniTask StartListening()
{
var buffer = new byte[1024 * 4];

try
{
Debug.Log("WebSocket Listening was started.");

while (_webSocket.State == WebSocketState.Open)
{
var result = await _webSocket.ReceiveAsync(new ArraySegment<byte>(buffer), _cancellationTokenSource.Token);
if (result.MessageType == WebSocketMessageType.Close)
{
Debug.Log("WebSocket closed by server.");
break; // 서버에 의해 닫힘
}

if (result.MessageType == WebSocketMessageType.Text)
{
string message = Encoding.UTF8.GetString(buffer, 0, result.Count);
OnMessageReceived(message);
Array.Clear(buffer, 0, buffer.Length); // 버퍼 클리어
}
}

Debug.Log("WebSocket Listening was stopped.");
}
catch (OperationCanceledException)
{
Debug.Log("Listening was canceled."); // 명시적인 취소 로깅
}
catch (Exception ex)
{
Logger.LogError($"{ex.Message} \n {ex.StackTrace}");
throw; // 여기서 처리하지 않고 상위로 예외를 전파
}
}

private void OnMessageReceived(string message)
{
MessageReceived?.Invoke(this, message);
}

/// <summary>
/// WebSocket 서버로 요청을 전송합니다.
/// </summary>
/// <param name="request">전송할 데이터</param>
public async UniTask SendWebRequest<TRequest>(
WebSocketRequest<TRequest> request
)

{
var serializedWebSocketRequest = JsonConvert.SerializeObject(request);
await SendMessageAsync(serializedWebSocketRequest);
}

/// <summary>
/// WebSocket 서버로 메시지를 전송합니다.
/// </summary>
/// <param name="message">전송할 메시지</param>
private async UniTask SendMessageAsync(string message)
{
if (_webSocket.State == WebSocketState.Open)
{
var messageBuffer = Encoding.UTF8.GetBytes(message);
var segment = new ArraySegment<byte>(messageBuffer);
await _webSocket.SendAsync(segment, WebSocketMessageType.Text, true, _cancellationTokenSource.Token);
}
}

/// <summary>
/// WebSocket Connect or Listening을 취소합니다.
/// </summary>
public async UniTask CancelAsync()
{
// 연결이 이미 닫혀있거나, 연결 중이 아닌 경우 로그를 남기고 반환
if (_webSocket == null || _webSocket.State == WebSocketState.Closed || _webSocket.State == WebSocketState.None)
{
Logger.Log("WebSocket is not connected or already closed.");
return;
}

try
{
// 먼저 연결을 정상적으로 종료합니다.
await _webSocket.CloseAsync(WebSocketCloseStatus.NormalClosure, string.Empty, CancellationToken.None);
Logger.Log("WebSocket closed");

// 이후 취소 토큰을 사용하여 모든 대기 중인 작업을 취소합니다.
_cancellationTokenSource.Cancel();
}
catch (Exception ex)
{
Logger.LogError($"{ex.Message} \n {ex.StackTrace}");
}
finally
{
// 리소스 정리
_cancellationTokenSource?.Dispose();
_cancellationTokenSource = null;
_webSocket?.Dispose();
_webSocket = null;
Logger.Log("WebSocket resources released");
}
}

}
}

이 코드에서는 요청을 보내는 경로로 파라미터 url을 받는데, 이 함수의 호출자에서 action을 경로에 붙여서 url을 만든 다음에 넣어주는 방식을 사용했습니다.

Lambda에서 클라이언트의 요청을 처리하기

.NET Lambda에서 클라이언트의 매칭 큐잉 요청을 처리하는 코드 샘플


/// <summary>
/// UserMatchingQueue를 처리하는 클래스
/// </summary>
public class UserMatchQueueingRequestHandler : WssRequestHandlerBase<UserMatchQueueingRequest, UserMatchQueueingResponse>
{
private readonly IAmazonGameLift _gameLiftClient;
private readonly string _connectionId;
private readonly string _connectionStage;
private readonly string _configurationName;

/// <summary>
/// default constructor
/// </summary>
/// <param name="dynamoDB"></param>
/// <param name="request"></param>
/// <param name="action"></param>
/// <param name="gameLiftClient"></param>
/// <param name="configurationName"></param>
/// <returns></returns>
public UserMatchQueueingRequestHandler(
IAmazonDynamoDB dynamoDB,
APIGatewayProxyRequest request,
string action,
IAmazonGameLift gameLiftClient,
string configurationName
)
: base(dynamoDB, request, action)

{
_gameLiftClient = gameLiftClient;
_connectionId = request.RequestContext.ConnectionId;
_connectionStage = request.RequestContext.Stage;
_configurationName = configurationName;
}

/// <summary>
/// 매칭을 요청하는 메서드
/// </summary>
/// <returns></returns>
protected override async Task<UserMatchQueueingResponse> GenerateResponseTask()
{
try
{
using DynamoDBContext dBContext = new DynamoDBContext(_dynamoDB);

// 가장 최근 매칭 정보 쿼리
Task<UserLastSettingItem> userLastSettingItemLoadTask = dBContext.LoadAsync<UserLastSettingItem>(_requestData.UserNumber, UserLastSettingItem.GetUserLastSettingItemSK());
Task<UserProfileItem> userNicknameItemLoadTask = dBContext.LoadAsync<UserProfileItem>(_requestData.UserNumber, UserProfileItem.GetUserProfileItemSK() );
await Task.WhenAll(userLastSettingItemLoadTask, userNicknameItemLoadTask);

/* 신규 매칭 요청 */
UserLastSettingItem userLastSettingItem = userLastSettingItemLoadTask.Result;
UserProfileItem userProfileItem = userNicknameItemLoadTask.Result;
UserCharacterInfoItem userCharacterInfoItem = await dBContext.LoadAsync<UserCharacterInfoItem>(_requestData.UserNumber, UserCharacterInfoItem.GetUserCharacterInfoItemSK(userLastSettingItem.LastSelectedCharacterName));
List<string> userCharacterRuneList = new List<string>
{
userCharacterInfoItem.RuneSlot.FirstNormalRune.ToString(),
userCharacterInfoItem.RuneSlot.SecondNormalRune.ToString(),
userCharacterInfoItem.RuneSlot.PositionRune.ToString(),
userCharacterInfoItem.RuneSlot.SpecialRune.ToString()
};

StartMatchmakingRequest startMatchmakingRequest = new StartMatchmakingRequest
{
ConfigurationName = _configurationName,
Players = new List<Player>
{
new Player
{
PlayerId = _requestData.UserNumber,
PlayerAttributes = new Dictionary<string, AttributeValue>()
{
{ "userName", new AttributeValue { S = userProfileItem.UserNickname } },
{ "characterName", new AttributeValue { S = userCharacterInfoItem.CharacterName.ToString() } },
{ "characterSkinId", new AttributeValue { N = userCharacterInfoItem.CurrentSkinId } },
{ "level", new AttributeValue { N = userCharacterInfoItem.Level } },
{ "position", new AttributeValue { S = userCharacterInfoItem.CharacterPosition.ToString() } },
{ "runeSlot", new AttributeValue { SL = userCharacterRuneList } }
}
}
}
};
StartMatchmakingResponse response = await _gameLiftClient.StartMatchmakingAsync(startMatchmakingRequest);
await dBContext.SaveAsync(new UserLatestMatchingInfoItem(_requestData.UserNumber, _connectionId, response.MatchmakingTicket.TicketId, _connectionStage));

return new UserMatchQueueingResponse(response.MatchmakingTicket.TicketId);
}
catch (Exception ex)
{
Function.LambdaContext?.Logger.LogLine(ex.Message);
Function.LambdaContext?.Logger.LogLine(ex.StackTrace);
throw;
}
}
  • Body 파싱: action 값이 queueing인지 확인하고, userId를 추출합니다. 다만 이는 비즈니스 로직을 수행하는 클래스 밖에서 수행합니다.
  • DynamoDB 조회: 유저 정보를 DB에서 로드해 필요한 데이터(스킬 점수 등)를 가져올 수 있습니다.
  • GameLift StartMatchmaking 호출: 가져온 유저 정보를 기반으로 GameLift 매칭 큐에 유저를 넣는 로직을 수행합니다.
  • 필요한 경우 매칭 취소(StopMatchmaking)나 매칭 상태 조회도 유사한 방식으로 구현 가능합니다.

AWS 셋업 시에 팁

실제 AWS에서 라이브 환경을 구축할 때는, 몇 가지 추가로 작업해야 되는 내용들이 있는데 아래에 소개합니다.

Route53 커스텀 도메인 사용

만약 Route53을 사용 중이라면, 서브도메인(sub.domain.com)을 따로 만들어 API Gateway에 매핑하는 것이 좋습니다.

  • 예: wssapi.example.com → WebSocket API Gateway
  • 예: restapi.example.com → REST API Gateway

API Gateway의 Custom Domain Names 설정 메뉴에서 도메인 매핑을 진행하면, 혹여나 있을 라우팅 주소 변경 등에 대응하기에 용이하고, 여러 region마다 다른 API gateway 주소를 사용할 필요도 없어집니다. 클라이언트 사이드에 직접적으로 AWS 내의 도메인이 노출되지 않는 효과도 있구요.

Stage 구성(dev, prod 등)

API Gateway에서 Stage별로 환경을 분리해두면, 개발(테스트) 환경과 라이브(운영) 환경을 쉽게 구분할 수 있습니다. 이는 API Gateway 서비스에서 셋업할 수 있습니다.

  • wssapi.example.com/dev
  • wssapi.example.com/prod

이렇게 분리하면, 실수로 라이브 매칭 서버에 요청을 보내는 사태를 방지할 수 있고, 별도의 Lambda 버전/별도 자원을 안전하게 운영할 수 있습니다.

마치며

이번 2부에서는 API GatewayWebSocket API를 어떻게 설정하고, 실제로 클라이언트가 매칭 요청을 보내는 방법, 그리고 Lambda가 이를 받아서 GameLift 매칭 큐에 유저를 넣는 기본 로직을 살펴봤습니다.

다음 글에서는 매칭 큐에 들어간 요청이 GameLift FlexMatch를 거쳐 매칭이 성사되거나 타임아웃될 때 어떤 이벤트가 발생하는지, 그리고 그 이벤트를 백엔드(Lambda/SNS/SQS)에서 어떻게 처리하고, 클라이언트에게 다시 알려주는지 등을 다룰 예정입니다.

궁금한 점이나 더 알고 싶은 내용이 있다면 언제든지 댓글로 알려주세요. 다음 포스팅에서 더 자세한 내용을 공유하겠습니다.

감사합니다!

Next : AWS GameLift와 Serverless로 구현하는 매칭 백엔드 아키텍처 (3부) — FlexMatch Rule Set을 구성해보자

Sign up to discover human stories that deepen your understanding of the world.

No responses yet

Write a response