오늘 배운 것
1. 과제 : TicTacToe 스코어 보드 연동, 서버와 연동해서 스코어 보드 띄우기
2. SocketIO에 관하여
1. TicTacToe 스코어 보드 연동
오전 시간에는 쭈욱 TicTacToe에서 ID마다 스코어를 할당하고, 그 스코어를 리더보드로 띄우는 것을 과제로 했다.
다음은 완성된 내 코드들이다.
우선 서버에서 스코어 정보들을 가져와야 하기 때문에 서버에 다음과 같은 코드가 있다.
* Mongodb의 Find() 메서드는 데이터베이스에서 정보를 검색하는 가장 기본적인 도구
db.컬렉션이름.find(쿼리, 프로젝션, 옵션)
- 쿼리 : 검색 조건을 정의하는 객체
예시 : { username: "test", score: { $gt: 100 } }
username 필드가 test이고, score 필드가 100보다 큰 문서를 검색.
*$gt 는 > 의 뜻이다, $gte 는 >=, $lt은 < $lte는 <=
쿼리를 생략하거나, 빈 객체 {}를 전달하면 컬렉션의 모든 문서를 반환함
- 프로젝션 : 결과에서 반환될 필드를 지정하는 객체
예시 : { projection: { username: 1, score: 1, _id: 0 } }
username과 score 필드만 결과에 포함하고 _id 필드는 제외한다.
프로젝션을 생략하면 모든 필드가 결과에 포함
- 옵션 : 검색 결과를 정렬, 제한 , 건너뛰기 등 다양한 방식으로 제어하는 객체
예시 : { sort: { score: -1 }, limit: 10 }
score 필드를 내림차순으로 정렬하고, 결과를 10개로 제한
즉 , 다음 코드는 모든 문서를 검색해서 _id, username, nickname, score를 포함한 문서들을 정렬한 후
배열로 만들어 allUsers라는 변수 안에 담고, Json 형식으로 응답에 넣어서 보내겠다는 의미이다.
const allUsers = await users.find({}, {projection: {_id: 1 ,username: 1, nickname: 1, score: 1, }}).sort({score: -1}).toArray();
res.json(allUsers);
이렇게 받아온 정보들을 유니티에서 사용할 수 있게 바꿔준다.
public IEnumerator GetAllScore(Action<List<ScoreResult>> sucess, Action failure)
{
using (UnityWebRequest www =
new UnityWebRequest(Constants.ServerURL + "/users/allscore", UnityWebRequest.kHttpVerbGET))
{
string sid = PlayerPrefs.GetString("sid");
www.downloadHandler = (DownloadHandler)new DownloadHandlerBuffer();
if (!string.IsNullOrEmpty(sid))
{
www.SetRequestHeader("Cookie", sid);
}
yield return www.SendWebRequest();
if (www.result == UnityWebRequest.Result.ConnectionError ||
www.result == UnityWebRequest.Result.ProtocolError)
{
if (www.responseCode == 403)
{
Debug.Log("로그인이 필요합니다.");
}
failure?.Invoke();
}
else
{
var result = www.downloadHandler.text;
List<ScoreResult> userScores = JsonConvert.DeserializeObject<List<ScoreResult>>(result);
sucess?.Invoke(userScores);
}
}
}
List<ScoreResult> userScores = JsonConvert.DeserializeObject<List<ScoreResult>>(result);
다음 코드를 통해 받아온 result값을 역직렬화해 ScoreResult 타입의 List로 반환한다.
그리고 그 userScores 리스트를 콜백 함수에 넣어 실행한다.
ScoreResult 타입은 다음과 같다.
public struct ScoreResult
{
[JsonProperty("_id")] // JSON 키 이름 지정
public string id;
public string username;
public string nickname;
public int score;
}
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;
public class ScoreBoardController : MonoBehaviour
{
[SerializeField] private GameObject scoreBoard;
[SerializeField] private RectTransform content;
[SerializeField] private GameObject cell;
[SerializeField] private Button closeButton;
void Start()
{
closeButton.onClick.AddListener(() =>
{
Destroy(gameObject);
});
InitScoreBoard();
}
void InitScoreBoard()
{
StartCoroutine(NetworkManager.Instance.GetAllScore((userScores) =>
{
for (int i = 0; i < userScores.Count; i++)
{
var scoreCell = Instantiate(cell, content).GetComponent<ScoreCell>();
scoreCell.SetData(userScores[i]);
}
}, null));
}
}
콜백함수는 다음과 같이 실행된다. userScores리스트를 바탕으로 하나하나 셀을 만들고 데이터를 설정해준다.
using System.Collections;
using System.Collections.Generic;
using TMPro;
using UnityEngine;
using UnityEngine.UI;
public class ScoreCell : MonoBehaviour
{
[SerializeField] private TextMeshProUGUI playerNameText;
[SerializeField] private TextMeshProUGUI scoreText;
public void SetData(ScoreResult scoreResult)
{
playerNameText.text = scoreResult.username;
scoreText.text =": " + scoreResult.score.ToString();
var CurrentuserId = PlayerPrefs.GetString("userId");
Debug.Log($"Current userId: {CurrentuserId} 그리고 scoreResulet.id : {scoreResult.id}");
if (CurrentuserId == scoreResult.id)
{
Color color = Color.red;
Image currentImage = GetComponent<Image>();
currentImage.color = color;
}
}
}
SetData 함수는 ScoreResult를 받아서 자신의 셀 텍스트를 설정한다.
그리고 userId가 자신의 Id와 같다면, 표시를 해 주는 함수이다.
헤맸던 부분 : Parsing 관련
public struct ScoreResult
{
[JsonProperty("_id")] // JSON 키 이름 지정
public string id;
public string username;
public string nickname;
public int score;
}
이 부분을 보면 [JsonProperty("_id")] 라고 되어 있고, public string id;라고 되어 있다.
이것은 서버에서 데이터를 보낼 때 이름을 다르게 보내기 떄문인데 ,
스코어 하나만 보낼 때는 다음과 같이 응답을 준다.
그리고 모든 스코어를 보낼 때는 id가 아닌 _id로 응답을 준다.
둘의 이름이 다르기에 , ScoreResult에 들어가는 string 값의 이름을 하나로 통일해야 했다.
이 부분에서 문제가 발생했다. _id로 통일하니 ScoreResult에서 id만 _를 붙이는 것 같아 마음에 안 들었고,
id로 통일하려고하니 find에서 이름을 바꾸는 방법을 몰랐다.
var userScore = JsonUtility.FromJson<ScoreResult>(result);
List<ScoreResult> userScores = JsonConvert.DeserializeObject<List<ScoreResult>>(result);
위 둘의 차이점 때문에 발생한 일이었고, 알아보니 역직렬화 하는 것은 똑같지만 동작 방식과 지원하는 기능이 다르다.
JsonUtility.FromJson<T>()
- Unity의 기본 파서 (UnityEngin.JsonUtiltiy)
- C#의 구조체 또는 클래스로 변환 가능
- JSON 키와 C# 변수명이 완전히 일치해야만 변환됨(이름이 다르면 무시)
- 배열 또는 리스트를 직접 변활할 수 없음
JsonConvert.DeserializeObjedct<T>()
- Newtonsoft.Json 라이브러리 제공
- JSON키와 변수명이 다르더라도 [JsonProperty]로 매칭 가능
- 배열과 리스트를 변환할 수 있음
- 복잡한 JSON 구조도 변환 가능
다음 이유 때문에 변환이 안되었고, 계속해서 Null값이 나왔던 것이다.
직렬화와 역직렬화, JSON, 그리고 Parser에 관해서는 다음에 더 자세히 알아볼 것이다.
강사님이 만드신 리더보드 코드(약식)이다.
우선, 리더보드용 파일을 따로 만들고, 그 파일을 불러와서 Express앱에 라우터를 등록했다.
그리고 leaderboard로 들어오는 요청을 leaderboardRouter에서 처리하도록 경로 처리를 해 준다.
이런 식으로 정보를 다 가져온다 !
점수별로 정렬하고 싶을 떄
정렬한 후 리스트로 만들어서 저장하고 보낼 것이다.
그런데 지금은 감싸지지 않은 형태를 갖고 있다면 , 한 단계 감싸주어서 보낼 것이다.
서버로부터 받아올 때에도 Scores 안에 배열들이 있는 것.
단순한 문자열을 객체의 형태로 바꿔서 ,변수명을 통해 접근하기 위해 변환하는 과정을 거친다.
이것을 보통 Parsing이라고 한다. JSON 형태로 있는 문자열을 Parsing한다고 해서 JSON Parsing
JSON을 파싱해주는 것을 Parser 라고 하고 우리는 이것을 데이터를 변환하는데 사용할 것이다.
데이터가 일정한 규칙이 없을 때에는 잘라서 써야 한다.
다만 지금은 일정한 규칙에 따라서 오기 때문에 굳이 직접 자를 필요는 없다
JSON Parser들 마다 약간의 기능적인 차이, 버그, 보안, 안정성이 조금씩 차이가 나게 됨
JsonUtility를 이용해서 파싱을 할 때는, 단일 형식의 오브젝트일 때는 그냥 해도 되는데
우리의 경우 SDcoreInfo 타입이 Scores안에 있음, 이 경우 [Serializable]를 붙여주어야 한다.
그래야 직렬화가 되어서 제대로 파싱이 가능해진다.
이런 식으로 파싱해서 전달해서 데이터를 띄우면 된다.
02. Unity SockeIO를 사용한 멀티 플레이
HTTP 말고 TCP 통신을 해야 할 때, 웹 브라우저랑 서버랑 소켓통신을 할 수 있게 해주는 웹소켓을 사용하기 용이하게 만들어주는 ...
추가 개념 정리
Socket.IO란 ?
WebSocket 기반의 실시간 양방향 실시간 통신 라이브러리.
WebSocket은 클라이언트와 서버가 지속적으로 연결을 유지하며 데이터를 주고 받을 수 있는 통신 방식
기존 HTTP 요청/응답과 달리 실시간으로 데이터를 주고받을 수 있어 멀티플레이 게임, 채팅 실시간 알림 서비스에 이용
주의사항
유니티는 기본적으로 싱글스레드 환경에서 동작한다. (즉, 대부분의 경우 메인 스레드에서 동작)
하지만 Socket.IO는 네트워크 이벤트를 처리할 때 별도의 서브스레드에서 실행되기 때문에
Socket.IO 내에서 유니티 관련 코드를 직접 실행하면 문제(오류)가 생길 수 있다.
따라서 다음과 같은 방법으로 메인스레드에서 실행될 수 있도록 작업을 옮겨주는 방법이 필요하다.
그 방법이 다음 두 가지이다.
- OnUnityThread()
socket.On("receiveMessage", (data) => {
this.OnUnityThread(() => {
Debug.Log("메시지를 받았습니다: " + data);
myGameObject.transform.position = new Vector3(1, 1, 1); // ✅ 정상 작동
});
});
SocketIO 이벤트가 발생했을 때 유니티의 메인 스레드에서 실행되도록 예약할 수 있음.
- executeInUpdate()
다른 방식으로 Update를 활용하여 메인 스레드에서 실행되도록 예약하는 방법.
private Queue<Action> executeInUpdate = new Queue<Action>();
실행 예약할 작업들을 저장하기 위한 큐를 만들고
socket.On("receiveMessage", (data) => {
executeInUpdate.Enqueue(() => {
Debug.Log("메시지를 받았습니다: " + data);
myGameObject.transform.position = new Vector3(1, 1, 1); // ✅ 안전한 실행
});
});
이벤트를 받았을 때 넣어준다.
void Update() {
while (executeInUpdate.Count > 0) {
executeInUpdate.Dequeue().Invoke();
}
}
그리고 업데이트에서 실행해주면 된다.
추가 개념 정리 2 : module.exports = router란 ?
module.exports란 ?
Node.js에서는 특정 함수를 외부 파일에서 사용할 수 있도록 내보내는 역할을 하는 객체가 바로
module.exports 다. 예를 들어보자면
// math.js
function add(a, b) {
return a + b;
}
module.exports = add;
이렇게 하면 다른 파일에서 math.js를 불러와 사용할 수 있다.
그럼 module.exports = router는 무엇인가 ?
결국 router 객체를 외부에서 사용할 수 있도록 내보내는 것이다.
간단한 예제, SoketIO에 익숙해지기위한.
이런 식으로 하게 될 것이다.
기존의 HTTP 방식의 통신은 연결 끊고, 필요할 때 연결하고, 연결끊고 연결하고였다면,
이번에는 연결된 상태에서 통신할 것 !
Room이라는 방에 만들어서 그 안에 있는 사람들끼리 통신할 수 있도록 !
새로운 파일 game.js를 만든다. Socket.IO와 관련된 함수들이 들어갈 것이다.
추가 개념 정리 3 : 코드 해석
위 코드를 빠르게 배우기도 했고, 뭐가 기본 기능인 것인지, 뭐가 제공되는 것이고 뭐가 받아오는 것인지 제대로 이해하지 못해서 처음부터 코드를 다시 해석해 보려고 한다.
우선 첫 부분이다.
모듈을 외부에서 실행할 수 있게 내보낸다, 이 모듈은 server 객체를 받아서 실행하는 모듈이다.
그 다음부터는 모듈의 내용이 나오게 된다.
Socket.IO 라이브러리를 가져와서 server를 넘겨준다. 이 HTTP 서버에 웹소켓 기능을 추가하는 것이다.
결과적으로 io 객체가 생성된다. io 객체를 사용해서 클라이언트와 실시간으로 데이터를 주고받을 수 있다.
'connection'은 Socket.IO에서 자동으로 제공하는 기본 이벤트로 클라이언트가 서버에 연결될 때 실행된다.
Socket은 서버가 클라이언트의 연결을 감지하면 자동으로 생성되는 객체이다. socket에는 클라이언트의 ID(socket.id),
방 정보, 연결 상태 등이 포함되지만 , 닉네임 같은 추가적인 정보는 클라이언트가 직접 보내야 한다.
(변수명은 바꿀 수 있다)
room 관련 기능도 socket.IO에서 자동으로 제공하는 기능 중 하나이다.
Socket.IO는 기본적으로 join(roomId)와 socket.leave(roomId) 같은 기능을 제공하여 방을 만들고, 참가하고 ,떠날 수 있도록 한다.
Room
- 여러 소켓들이 참여(Join)하고 떠날 수 있는(leave) 채널
- 특정 그룹의 클라이언트들에게만 메세지를 보내고 싶을 때 사용
- 서버에서만 관리됨
- 기본적으로 모든 소켓은 자동적으로 자신만의 고유한 room에 참여함, 이 Room의 이름은 소켓 ID와 동일
관련 메서드
- join(room) : 소켓을 특정 room에 참여시킴
- leave(room) : 소켓을 특정 room에서 탈퇴시킴
- to(room) : 특정 room에 메세지를 보냄
- sockets.adapter.rooms : 서버에 생성된 모든 room의 정보를 담고 있는 객체
룸이 하나도 없을 때, 새로운 룸 아이디를 랜덤으로 (uuidv4())로 만들고
소켓을 그 룸에 집어넣음.
그리고 소켓에게 creatRoom이라는 메세지와 함께 어떤 방에 들어갔는지 ID를 보낸다.
또 rooms( 대기 목록)에 방을 추가한다.
방이 있다면, 룸 아이디는 있는 방의 아이디이고, 그 방에 진입시킨 뒤 방에 진입했다는 소식을 보내고
그 방에 있는 사람들에게 게임이 시작되었다는 메세지를 보낸다.
위 둘도 비슷한 코드이다.
다만 매개변수로 받아오는 message는 유니티(클라이언트)에서 받아오는 것이다.
(자동 생성되었던 connect 때와 다르게)
connect와 마찬가지로 기본 제공되는 함수이다.
disconnect는 클라이언트가 창을 닫거나, 인터넷이 끊기거나, 서버에서 강제 종료되었을 때 자동으로 실행된다.
서버에서 강제로 연결을 끊을 수도 있다.(socket.disconnect(true))
끝까지 이해가 안갔던 부분
const io = require('socket.io')(server);
socket.io모듈에 받아온 서버를 전달해서 생긴 io객체가 실시간 통신을 관리하는 핵심 객체가 된다는 것이 이해가 안갔다.
socket.io 모듈이 어떻게 생겨먹었길래 ... ?
그냥 socket.IO가 기존 HTTP 서버 위에서 실행되도록 서버를 넘기는 것이라고 한다.
기존 HTTP 서버는 그대로 사용하면서 모듈을 통해 웹소켓 기능이 추가되는 것이라고... 정확히 이해하진 못했지만
파고들기엔 너무 방대한 내용인 것 같아 일단 넘어간다.
Room VS RoomId
기본적으로 모든 소켓은 자신만의 Room에 자동으로 참여한다, 그리고 그 Room의 이름은 socket.id와 동일하다.
그에 반해 RoomId는 직접 만드는 것이다 (자동으로 생성되지 않는다) 그리고 이 RoomId를 통해 Join이나 levea를 실행할 수 있다. 또한 소켓은 여러개의 Room에 참가할 수 있다.
이 자동적으로 생성되는 룸 이름과, RoomId가 서로 다른 것인가 ? 라고 생각했는데
GPT선생님께 물어본 결과,
같은 개념이지만, 일반적으로 사용하지 않는다고 한다.
그 이유는 기본적으로 socket.id는 한 명의 유저에게만 메세지를 보내는 용도로 사용되고
관리하기도 어렵고, disconnect하면 그 Room도 사라지고, 보안 문제로 인해 사용되지 않는다.
채팅방 UI 만들기 (이하 생략)
'TIL' 카테고리의 다른 글
[멋쟁이사자처럼 부트캠프 TIL회고] 0310 : 틱택토 마무리 (0) | 2025.03.11 |
---|---|
[멋쟁이사자처럼 부트캠프 TIL회고] Unity 게임개발 0307 : 채팅, 멀티플레이 (0) | 2025.03.07 |
[멋쟁이사자처럼 부트캠프 TIL회고] Unity게임개발 3기 0305 : 유니티와 서버 (0) | 2025.03.05 |
[멋쟁이사자처럼 부트캠프 TIL회고] Unity 게임 개발 : 0304 서버 맛보기 (0) | 2025.03.04 |
[멋쟁이사자처럼 부트캠프 TIL회고] Unity 게임 개발 2.28일 : 출시 설정 (0) | 2025.02.28 |