Carrot
본문 바로가기
Unity/멋쟁이사자처럼 부트캠프

[멋쟁이사자처럼부트캠프] 유니티 게임 개발 5기(77일차) - 모바일 틱택토 게임 구현 (5), 회원가입&로그아웃&점수조회 기능

by 독기품은토끼 2025. 9. 8.
✅ 오늘의 학습 목표
1. 모바일 틱택토 게임 구현 (5)
- 회원 가입 마무리
- 로그아웃 기능
- 점수 기능 도입 → 등록&조회
- 멀티플레이 구현(기본 틀)

1. 회원가입

로그인 화면을 구현하였고 DB정보를 불러와 로그인하는 것 까지 구현이 완료되었으니

이번에는 회원가입 기능을 넣으려고 한다.

1. 회원가입 UI

using TMPro;
using UnityEngine;

public struct SignupData
{
    public string nickname;
    public string username;
    public string password;
}

public class SignupPanelController : PanelController
{
    [SerializeField] private TMP_InputField nicknameInputField;
    [SerializeField] private TMP_InputField usernameInputField;
    [SerializeField] private TMP_InputField passwordInputField;
    [SerializeField] private TMP_InputField confirmPasswordInputField;

    public void OnClickConfirmButton()
    {
        string nickname = nicknameInputField.text;
        string username = usernameInputField.text;
        string password = passwordInputField.text;
        string confirmPassword = confirmPasswordInputField.text;

        if (string.IsNullOrEmpty(nickname) || string.IsNullOrEmpty(username) || string.IsNullOrEmpty(password) || string.IsNullOrEmpty(confirmPassword))
        {
            // TODO : 누락된 값을 입력하도록 요청
            Shake();
            return;
        }

        // Confim Password 확인
        if (password.Equals(confirmPassword))
        {
            var signupData = new SignupData();
            signupData.nickname = nickname;
            signupData.username = username;
            signupData.password = password;

            StartCoroutine(NetworkManager.Instance.Signup(signupData,
            () => // 로그인 성공했을 때
            {
                GameManager.Instance.OpenConfirmPanel("회원가입에 성공했습니다.", () =>
                {
                    Hide();
                });
            },
            (result) => // 로그인 실패했을 때
            {
                if (result == 0)
                {
                    GameManager.Instance.OpenConfirmPanel("이미 존재하는 사용자입니다.", () =>
                    {
                        nicknameInputField.text = "";
                        usernameInputField.text = "";
                        passwordInputField.text = "";
                        confirmPasswordInputField.text = "";
                    });
                }
            }));
        }
    }

    public void OnClickCancelButton()
    {
        Hide();
    }
}

 

  • 입력값을 받아 SignupData 구조체에 저장한다.
  • 비밀번호와 확인 비밀번호가 일치할 때만 서버로 요청을 보낸다.
  • 회원가입 성공 시 확인 패널을 띄우고 실패 시 메시지를 출력해준다.
  • 회원가입이 싫을 수 있으니 취소 버튼도 넣어주었다.

 

[구조체 VS 클래스]

  구조체 (struct) 클래스 (class)
저장 방식 값 타입 (Value Type) → 스택에 저장 참조 타입 (Reference Type) → 힙에 저장
복사 동작 대입 시 값 자체가 복사됨 대입 시 참조(주소)만 복사됨
기본 상속 상속 불가능 (인터페이스만 구현 가능) 상속 가능
생성자 기본 생성자가 자동 제공됨 생성자 직접 선언 가능
메모리 부담 작은 데이터 묶음에 유리 큰 데이터/복잡한 객체에 유리
사용 예시 좌표, 색상, 간단한 데이터 컨테이너 게임 오브젝트, 매니저, UI 컨트롤러

 

[SignupData를 구조체로 만든 이유]

1. 단순 데이터 묶음이기 때문에

 nickname. username. password 처럼 문자열 몇 개만 담아서 서버로 전달하는 목적이므로

 클래스의 기능인 상속, 복잡한 메서드 등이 필요 없다.

2. 값 타입의 특성을 활용하기 때문에

 구조체는 함수나 코루틴에 넘길 때 값 자체가 복사되므로 원본을 실수로 수정하는 위험이 줄어든다.

 회원가입 정보 같은 입력값은 '그 순간 전달해서 서버에 보낼 임시 데이터'라서 안전하게 값으로 다루는 게 적절하다.

3. 메모리 부담이 적다.

 구조체는 스택에 할당되기 때문에 작은 단위의 데이터 교환에 적합하다.

 반대로 MonoBehaviour를 상속받는 게임 오브젝트 컨트롤러 같은 건 반드시 클래스여야 한다.

 

 

2. 로그인 화면에 회원가입 버튼 추가

기존 로그인 UI에서 회원가입 버튼을 추가하고

버튼을 누르면 SingupPanel이 나타나도록 구현해 주었다.

// GameManager 스크립트
public void OpenSignupPanel()
{
    if (_canvas != null)
    {
        var signupPanelObject = Instantiate(signupPanel, _canvas.transform);
        signupPanelObject.GetComponent<SignupPanelController>().Show();
    }
}

 

3. 서버 통신

이제 클라이언트에서 입력한 데이터를 서버로 전송해 DB에 저장해야 한다.

이를 위해 NetworkManager 스크립트에 회원가입 요청 함수를 작성하였다.

public IEnumerator Signup(SignupData signupData, Action success, Action<int> failure)
{
    string jsonString = JsonUtility.ToJson(signupData);
    byte[] byteRaw = System.Text.Encoding.UTF8.GetBytes(jsonString);

    using (UnityWebRequest www = new UnityWebRequest(Constants.ServerURL + "/users/signup",
        UnityWebRequest.kHttpVerbPOST))
    {
        www.uploadHandler = new UploadHandlerRaw(byteRaw);
        www.downloadHandler = new DownloadHandlerBuffer();
        www.SetRequestHeader("Content-Type", "application/json");

        yield return www.SendWebRequest();

        if (www.result == UnityWebRequest.Result.ConnectionError)
        {
            // TODO : 서버 연결 오류에 대해 알림
        }
        else
        {
            var resultString = www.downloadHandler.text;
            var result = JsonUtility.FromJson<SigninResult>(resultString);

            if (result.result == 2) // 회원가입 성공
            {
                success?.Invoke();
            }
            else
            {
                failure?.Invoke(result.result);
            }
        }
    }
}

 

  • SignupData 구조체를 JSON 문자열로 변환한다.
  • UnityWebRequest를 통해 POST 방식으로 서버에 전송한다.
  • 서버 응답을 파싱해 성공/실패 여부를 클라이언트에 전달한다.

 

[이전 포스팅 복습 내용]

  • UploadHandlerRaw → JSON 데이터를 byte 배열로 바꿔서 요청 바디에 실어 보내는 역할
  • DownloadHandlerBuffer → 서버에서 내려주는 응답을 메모리에 모두 담아 문자열로 꺼낼 수 있게 해줌
  • www.SetRequestHeader("Content-Type", "application/json") → 서버가 JSON 요청임을 알 수 있도록 해줌

 

2. 로그아웃 (서버)

로그인은 세션을 생성해 사용자 정보를 서버에 저장했었다.

이번에는 로그아웃 기능을 추가하여 더이상 세션이 유지되지 않도록 세션을 삭제하는 로직을 구현해줄 것이다.

// users.js 로그아웃
router.get('/signout', function(req, res, next) {
  if (req.session){
    // 세션 삭제
    req.session.destroy(function(err) {
      if (err) {
        return res.status(500).json({ message: 'Failed to log out.'});
      } else {
        return res.json({message: 'Logged out successfully.'});
      }
    });
  } else {
    res.json({message: 'No active session.'});
  }
});

 

  • router.get('/signout', ...) : /users/signout 경로로 요청이 들어오면 로그아웃 처리를 수행한다.
  • if (req.session) : 현재 요청에 세션이 존재하는지 확인한다.
  • req.session.destroy() : 세션을 삭제한다. 콜백으로 에러 여부를 확인한다.
    • 에러 발생 시 → 500 Internal Server Error 응답 반환
    • 정상 처리 시 → { message: 'Logged out successfully.' } 반환
  • 세션이 존재하지 않을 경우에는 "No active session."이라는 메시지를 반환한다.

 

 

3. 점수 기능 (서버)

1. 점수 업데이트 (POST)

사용자가 게임을 마쳤을 때 마지막 점수를 DB에 반영할 수 있도록 점수 업데이트 API를 만들어주었다.

// 마지막 점수 업데이트
router.post('/addScore', async function(req, res, next) {
  try {
    if (!req.session.isAuthenticated) {
      return res.status(401).json({message: "Unauthorized"});
    }

    var userId = req.session.userId;
    var score = req.body.score;

    if (!score || isNaN(score)) {
      return res.status(400).json({message: 'Invaild score'});
    }

    var database = req.app.get('database');
    var users = database.collection('users');

    const result = await users.updateOne(
      {_id : new ObjectId(userId)},
      {$set: {
        score: Number(score),
        updatedAt: new Date()
      }}
    );

    if (result.matchedCount === 0) {
      return res.status(404).json({message: 'User not found'});
    }

    res.status(200).json({message: 'Score updated successfully'});
  } catch (error) {
    console.error('Error updating score : ', error);
    res.status(500).json({message: 'Internal server error'});
  }
});

 

  • if (!req.session.isAuthenticated) : 로그인 상태가 아니면 401 Unauthorized 반환
  • req.body.score : 클라이언트에서 보낸 점수를 받아온다
  • isNaN(score) : 숫자가 아닌 경우 잘못된 요청으로 처리
  • users.updateOne(...) : MongoDB에서 해당 사용자 _id를 찾아 score와 updatedAt 필드를 갱신
  • matchedCount === 0 : 해당하는 사용자가 없을 경우 404 반환
  • 정상 업데이트 시 "Score updated successfully" 응답

점수는 로그인 세션을 통해 특정 사용자에게 귀속된다.

클라이언트가 점수를 보낼 때마다 DB에서 사용자의 문서를 찾아 덮어쓰기(update)방식으로 반영한다.

 

[updatedAt 필드를 어디서 선언했지?]

updatedAt 필드는 어디에도 선언하지 않았는데 에러가 안 나타나는 부분이 의아했다.

MySQL같은 RDBMS는 스키마를 정의해야 새로운 칼럼을 사용할 수 있는데

MongoDB같은 NoSQL의 경우 JSON처럼 아무 필드를 자유롭게 추가할 수 있다고 한다.

(자바스크립트는 동적 타입 언어라 객체에 없는 속성을 추가하면 바로 생성해버린다)

 

 

2. 점수 등록 (GET)

이번에는 로그인한 사용자의 점수를 확인할 수 있는 점수 조회 API를 만들어 주었다.

// users.js 점수 조회
router.get('/score', async function(req, res, next) {
  try {
    if(!req.session.isAuthenticated) {
      return res.status(401).json({message: "Unauthorized"});
    }
    var userId = req.session.userId;

    var database = req.app.get('database');
    var users = database.collection('users');

    const user = await users.findOne(
      {_id: new ObjectId(userId)}
    );

    if (!user) {
      return res.status(404).json({message: 'User not found'});
    }

    res.json({
      id: user._id.toString(),
      username: user.username,
      nickname: user.nickname,
      score: user.score || 0
    });

  } catch (error) {
    console.error('Error fetching score : ', error);
    res.status(500).json({message: 'Internal server error'});
  }
});

 

  • 클라이언트는 로그인 상태에서만 자신의 점수를 볼 수 있다.
  • DB에서 해당 사용자를 찾아 점수를 꺼내오고, 다른 기본 정보와 함께 반환한다.
  • 점수 저장/조회는 같은 컬렉션(users)을 사용하기 때문에 별도의 테이블(콜렉션) 없이도 쉽게 관리할 수 있다.

클라이언트는 로그인 상태에서만 자신의 점수를 조회할 수 있다.

DB에서 해당 사용자를 찾아 점수를 꺼내오고 다른 기본 정보와 함께 반환한다.

 

 

 

4. 멀티플레이 구현

멀티플레이는 두 클라이언트가 거의 동시에 서로의 입력을 공유해야한다.

HTTP 요청/응답은 매번 연결을 새로 열고 닫는 구조라 지연이 생기기 쉽다.

그래서 Socket.IO(WebSocket 기반)를 활용하여 서버와 클라이언트 사이에 항상 열린 통로를 만들고

그 위에서 방을 묶어 이벤트만 툭툭 전달하는 것을 구현하려고 한다.

🥕 예행 작업
1. Socket IO 패키지 다운로드
 

GitHub - itisnajim/SocketIOUnity: A Wrapper for socket.io-client-csharp to work with Unity.

A Wrapper for socket.io-client-csharp to work with Unity. - itisnajim/SocketIOUnity

github.com

 

1. 서버

1.1. bin/www 에 소켓 서버 켜기

var game = require('../game');
// Create Socket.io Server
game(server);

 

HTTP 서버가 준비되면 소켓 서버를 함께 구동하도록 game(server)를 연결해준다.

이렇게 해주면 동일 포트에서 HTTP와 WebSocket이 함께 동작한다.

 

1.2. 방 매칭 로직과 이벤트 라우팅

game.js는 실제 Socket.io 서버를 생성하고

접속한 클라이언트를 대기열 → 방 배정 → 게임 시작 흐름으로 연결한다.

const { v4: uuidv4 } = require('uuid');

module.exports = function(server) {
    
    const io = require('socket.io')(server, {
        transports: ['websocket']
    });

    // 방 정보
    var rooms = [];
    var socketRooms = new Map();

    io.on('connection', (socket) => { // 클라이언트가 서버에게 연결된다면 socket 매개변수로 아래 내용 동작

        // 서버 구현
        console.log('A user connected :', socket.id);

        // 특정 Socket(클라이언트)이 입장했을 때 처리
        // 1. 대기방에 있으면 입장
        // 2. 대기방에 방이 없으면 새로 생성 후 입장
        if (rooms.length > 0) {
            var roomId = rooms.shift(); // shift = 배열에 있는 값을 꺼낸다.
            socket.join(roomId);
            socket.emit('joinRoom', { roomId: roomId }); // 클라이언트(소켓 대상)에게 메세지 보내기
            socket.to(roomId).emit('StartGame', {roomId: roomId});
            socketRooms.set(socket.id, roomId);
        }else {
            var roomId = uuidv4();
            socket.join(roomId);
            socket.emit("createRoom", {roomId: roomId});
            rooms.push(roomId);
            socketRooms.set(socket.id, roomId);
        }

        // 특정 Socket(클라이언트)이 방이 나갔을 때 처리
        socket.on('leaveRoom', function(data) {
            var roomId = data.roomId;
            socket.leave(roomId);
            socket.emit('exitRoom');
            socket.to(roomId).emit('endGame');

            // 혼자 들어간 방에서 나갈 때 방 삭제
            const roomIdx = rooms.indexOf(roomId);
            if (roomId !== -1) {
                rooms.splice(roomIdx, 1);
                console.log('Room Deleted : ', roomId);
            }

            // 방 나간 소켓 정보 삭제
            socketRooms.delete(socket.id);
        });

        // Socket(클라이언트)이 특정 Block을 터치했을 때 처리
        socket.on('doPlayer', function(playerInfo) {
            var roomId = playerInfo.roomId;
            var blockIndex = playerInfo.blockIndex;

            console.log('Player action in room :', roomId, 'Block index : ', blockIndex);
            socket.to(roomId).emit('doOpponent', {blockIndex: blockIndex});
        });
    });
};

 

[전체 흐름]

  • 대기열(rooms): 아직 1명만 들어온 방의 ID를 저장한다. 새로운 플레이어가 접속하면 이 대기열에서 하나 꺼내 짝을 맞춰준다
  • 소켓-방 매핑(socketRooms): 어떤 소켓이 어느 방에 있는지 빠르게 찾기 위한 자료구조다
  • socket.join(roomId): 소켓을 특정 방에 묶는다. 이후 socket.to(roomId).emit(...)으로 그 방의 상대에게만 이벤트를 보낼 수 있다
  • 이벤트 흐름
    • createRoom: 내가 새 방을 만들고 대기 중임
    • joinRoom: 대기 중인 방에 두 번째 플레이어로 합류함
    • StartGame/startGame: 상대가 들어왔으니 게임을 시작하라는 신호
    • doPlayer → doOpponent: 내 입력을 서버가 받아 같은 방의 상대에게만 전달함
    • leaveRoom → exitRoom/endGame: 방에서 나가거나 연결이 끊긴 경우 정리 신호

[메서드 설명]

  • io.on('connection', (socket) => { ... }) : 어떤 클라이언트가 연결되면 이 콜백이 실행되고 그 연결을 대표하는 객체가 socket이다.
  • socket.on('이벤트명', (payload) => { ... }) : 해당 소켓에서 도착한 커스텀 이벤트를 받는다.
  • socket.emit('event', data) : 해당 소켓에만 이벤트를 보낸다
  • socket.to(roomId).emit('event', data) : 같은 방의 다른 사람들에게 이벤트를 보낸다 (나 자신 제외)
  • (참고) 나 포함 모두에게 이벤트를 보내고싶을 땐 io.to(roomId).emit(..)을 사용한다.

이 메서드들은 모두 Socket.IO 서버 라이브러리가 제공하는 API 이다.

 

 

[uuid v4 ?]

UUID는 고유 식별자이고

v4는 랜덤 기반으로 128비트의 문자열을 만든다.

방 ID를 1, 2, 3같은 연속 숫자로 만들면 추측/충돌 위험이 커지는데 이러한 문제점을 방지하기 위하여 uuid v4를 사용해준다고 한다.

 

[playerInfo]

서버의 socket.on('doPlayer', function(playerInfo) { ... })에서 playerInfo는 클라이언트가 보낸 페이로드를 그대로 받은 매개변수이다.

 

2. 클라이언트

2.1. 소켓 서버 주소와 상태 정의

멀티 플레이에서 사용할 서버의 주소와 상태값을 enum 타입으로 정의해 주었다.

public static class Constants
{
    public const string SocketServerURL = "ws://localhost:3000";

    public enum MultiplayContollerState
    {
        CreateRoom,     // 방 생성
        JoinRoom,       // 생성된 방에 참여
        StartGame,      // 생성한 방에 다른 유저가 참여해서 게임을 시작
        ExitRoom,       // 클라이언트가 방을 빠져 나왔을 때
        EndGame         // 상대방이 접속을 끊거나 방을 나갔을 때
    }
}

 

2.2. 소켓 연결과 이벤트 핸들러 틀

using System;
using Newtonsoft.Json;
using SocketIOClient;
using UnityEngine;

// joinRoom/createRoom 이벤트 전달할 때 전달되는 정보의 타입
public class RoomData
{
    [JsonProperty("roomId")]
    public string roomId { get; set; }
}

// 상대방이 둔 마커 위치
public class BlockData
{
    [JsonProperty("blockIndex")]
    public int blockIndex { get; set; }
}

public class MultiplayController
{
    private SocketIOUnity _socket;



    public MultiplayController()
    {
        var uri = new Uri(Constants.SocketServerURL);
        _socket = new SocketIOUnity(uri, new SocketIOOptions
        {
            Transport = SocketIOClient.Transport.TransportProtocol.WebSocket
        });

        _socket.On("createRoom", CreateRoom);
        _socket.On("joinRoom", JoinRoom);
        _socket.On("startGame", StartGame);
        _socket.On("exitGame", ExitGame);
        _socket.On("endGame", EndGame);
        _socket.On("doOpponent", DoOpponent);
    }

    private void CreateRoom(SocketIOResponse response)
    {
        var data = response.GetValue<RoomData>();

    }

    private void JoinRoom(SocketIOResponse response)
    {
        var data = response.GetValue<RoomData>();
    }

    private void StartGame(SocketIOResponse response)
    {
        var data = response.GetValue<RoomData>();

    }

    private void ExitGame(SocketIOResponse response)
    {
    }

    private void EndGame(SocketIOResponse response)
    {
    }

    private void DoOpponent(SocketIOResponse response)
    {
        var data = response.GetValue<BlockData>();
    }
}

 

  • 생성자에서 Constants.SocketServerURL로 접속하고 전송 프로토콜을 WebSocket으로 지정한다.
  • 서버가 발행하는 이벤트명에 대응하는 콜백을 등록한다.
    • createRoom, joinRoom, startGame, exitGame, endGame, doOpponent
  • 각 콜백에서 서버가 준 페이로드를 강타입 모델(RoomData, BlockData)로 역직렬화하여 후속 로직의 입력으로 사용한다.