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

[멋쟁이사자처럼부트캠프] 유니티 게임 개발 5기(76일차) - 모바일 틱택토 게임 구현 (4), 회원가입&로그인 로직 구현

by 독기품은토끼 2025. 9. 5.
✅ 오늘의 학습 목표
1. 회원가입 & 로그인 로직 구현
2. 서버&클라이언트 상호작용 구현

1. 서버 구현

회원가입이나 게임 기록 저장 같은 기능을 구현하려면 서버에서 데이터를 관리할 수 있는 DB가 필요하다.

MongoDB를 Node.js 서버와 연결하는 작업부터 해주자

 

1. DB 연결

MongoDB Compass을 통해 Connection을 생성한다.

var createError = require('http-errors');
var express = require('express');
var path = require('path');
var cookieParser = require('cookie-parser');
var logger = require('morgan');

// DB 설정
var mongodb = require('mongodb'); // 몽고db에 접속하겠다.
var MongoClient = mongodb.MongoClient;

var indexRouter = require('./routes/index');
var usersRouter = require('./routes/users');

var app = express();

// DB 연결
async function connectDB() {
  var databaseUrl = 'mongodb://localhost:27017';

  try {
    const database = await MongoClient.connect(databaseUrl, {
      useNewUrlParser: true,
      useUnifiedTopology: true,
    });
    console.log('Database connected successfully');
    app.set('database', database.db('tictactoe'));

    // 연결 종료
    process.on('SIGINT', async () => {
      await database.close();
      console.log('Database connection closed');
      process.exit(0);
    });
  } catch (error) {
    console.error('Database connection failed : ', error);
    process.exit(1);
  }
}

connectDB().catch(err => {
  console.error('Failed to connect to the database', err);
  process.exit(1);
});

// view engine setup
app.set('views', path.join(__dirname, 'views'));
app.set('view engine', 'jade');

app.use(logger('dev'));
app.use(express.json());
app.use(express.urlencoded({ extended: false }));
app.use(cookieParser());
app.use(express.static(path.join(__dirname, 'public')));

app.use('/', indexRouter);
app.use('/users', usersRouter);

// catch 404 and forward to error handler
app.use(function(req, res, next) {
  next(createError(404));
});

// error handler
app.use(function(err, req, res, next) {
  // set locals, only providing error in development
  res.locals.message = err.message;
  res.locals.error = req.app.get('env') === 'development' ? err : {};

  // render the error page
  res.status(err.status || 500);
  res.render('error');
});

module.exports = app;

 

  • mongodb 라이브러리를 불러와 MongoClient 객체를 만든다.
  • connectDB 함수 안에서 MongoClient.connect()로 실제 DB에 연결한다.
  • 연결이 성공하면 app.set('database', database.db('tictactoe'))로 Express 앱 안에 DB 객체를 저장한다. → 이렇게 하면 라우터(users.js 같은 곳)에서 req.app.get('database')로 DB에 접근할 수 있다.
  • process.on('SIGINT', … ) 부분은 서버가 종료될 때 DB 연결도 같이 끊어주도록 한 부분이다.

 

useNewUrlParser: true
연결 주소(mongodb://…)를 읽고 해석하는 로직을 최신 방식으로 적용한다.

useUnifiedTopology: true
MongoDB 클라이언트 내부에서 서버를 탐지하고 모니터링하는 방식을 바꾼다.

MongoDB 버전이 6.x 이상이면 사실 이 옵션들은 기본값이 이미 true로 들어가 있어서 필요하지 않다.

 

 


 

 

DB 연결만 있다고 끝이 아니라 실제로 클라이언트(브라우저)에서 서버로 데이터를 주고받는 방식도 알아야 한다.

Express에서는 이를 요청(request) 과 응답(response) 으로 처리한다.

그리고 요청의 종류에 따라 get, post 같은 메서드를 사용한다.

  설명 예시
req 클라이언트 → 서버로 보내는 요청 정보 객체 폼 입력값, URL 파라미터, 쿠키
res 서버 → 클라이언트로 돌려주는 응답 정보 객체 HTML 페이지, JSON 데이터, 상태 코드
get 데이터 조회/페이지 요청에 사용하는 HTTP 메서드 주소창 접근, 링크 클릭
post 데이터 전송/저장에 사용하는 HTTP 메서드 회원가입, 로그인, 댓글 작성

 

웹 통신은 HTTP 프로토콜 기반으로 동작한다.

  • 클라이언트 → 서버 : 요청(Request)
    • 요청 라인(GET, POST 등) + 헤더 + 바디로 구성
  • 서버 → 클라이언트 : 응답(Response)
    • 상태 코드(200, 404 등) + 헤더 + 바디로 구성
  • get은 주로 데이터 조회에 쓰이고 주소창에 파라미터가 노출된다.
  • post는 데이터 생성이나 로그인처럼 민감한 정보를 보낼 때 쓰이며 요청 바디에 숨겨져 전달된다.
  GET POST
데이터 위치 URL 쿼리스트링에 포함 요청 바디에 포함
용도 데이터 조회, 페이지 이동 데이터 전송, 회원가입/로그인
보안 노출되므로 보안에 취약 URL에 노출되지 않아 비교적 안전
캐싱 브라우저가 캐싱 가능 기본적으로 캐싱 불가
길이 제한 있음(브라우저마다 제한) 사실상 없음

 

 

2. 회원가입

게임 서버에서 사용자 계정을 관리하려면 회원가입 기능이 필요하다.

아이디와 비밀번호 저장 외에 중복 검사와 비밀번호 암호화 같은 보안 처리도 해주었다.

var express = require('express');
var router = express.Router();
var bcrypt = require('bcrypt'); // 암호화 사용하겠다.
var saltRounds = 10; // 암호화할 때 필요한 매개변수
const {ObjectId} = require('mongodb');

/* GET users listing. */
router.get('/', function(req, res, next) {
  res.send('respond with a resource');
});

// 회원가입
router.post('/signup', async function(req, res, next) {
  try {
    var username = req.body.username;
    var password = req.body.password;
    var nickname = req.body.nickname;

    // 빈 값 확인
    if (!username || !password || !nickname) {
      return res.status(400).json({message : "All fields are required."});
    }

    // DB 연결
    var database = req.app.get('database');
    var users = database.collection('users');

    // 중복된 username 확인
    var existingUser = await users.findOne({username : username});
    if (existingUser) {
      return res.status(409).json({message : "Username already exists."});
    }

    // 비밀번호 암호화
    var salt = bcrypt.genSaltSync(saltRounds);
    var hash = bcrypt.hashSync(password, salt);

    // DB에 사용자 정보 저장
    await users.insertOne({
      username: username,
      password: hash,
      nickname: nickname,
      createdAt: new Date()
    });

    res.status(201).json({message : 'User registered successfully.'});
  } catch (error) {
    console.error('Error during signup : ', error);
    res.status(500).json({message : 'Internal server error.'});
  }
});

module.exports = router;

 

회원가입 라우터는 POST /users/signup 경로로 동작한다.

  • router.post('/signup', async function(req, res, next) { … })
    → 클라이언트에서 전송한 데이터를 req.body로 받는다.
  • if (!username || !password || !nickname)
    → 입력값이 비어 있으면 400 에러 반환
  • var users = database.collection('users')
    → MongoDB의 users 컬렉션에 접근한다.
  • findOne({ username: username })
    → 이미 같은 아이디가 존재하는지 확인. 있으면 409 에러 반환.
  • bcrypt.genSaltSync(saltRounds) + bcrypt.hashSync(password, salt)
    → 입력받은 비밀번호를 해시 처리해서 보안 강화
  • insertOne({ … })
    → 암호화된 비밀번호와 닉네임을 DB에 저장

 

 

백엔드 개발 흐름을 이해하려면 "에디터(VS Code)–서버(Express)–DB(MongoDB)–클라이언트(Postman/브라우저)” 사이의 역할과 연결 고리가 어떻게 흘러가는지 알면 어디서 에러가 발생했을 때 빠르게 원인을 짚고 해결할 수 있다.

 

지금 서버 구동을 위해 4가지 소프트웨어를 사용하고 있기 때문에 이 흐름을 알아야 한다.

  • MongoClient.connect('mongodb://localhost:27017')로 Node 서버가 MongoDB 서버에 연결한다.
  • 연결되면 app.set('database', database.db('tictactoe'))로 DB 핸들을 앱 전역에 보관한다.
  • 라우터(/users/signup)에서 req.app.get('database')로 DB 핸들에 접근하고 users.insertOne(...) 같은 쓰기 작업을 수행한다.
  • Postman은 POST http://localhost:3000/users/signup 같은 HTTP 요청을 보내는 도구로 요청 바디를 서버에 넘기면 서버가 DB에 쓰고 응답을 돌려준다.

VS Code(코드 작성) → Node.js > npm start(Express 부팅 & MongoDB 연결) → Postman(HTTP 요청 전송) → 라우터(DB 쓰기/조회) → MongoDB(데이터 반영, 콜렉션 생성) → Compass(현재 DB 내용 시각화)

 

3. 로그인

회원가입으로 사용자 계정을 만들었으면 이제 사용자가 로그인해서 본인임을 증명할 수 있어야 한다.

세션(Session)을 통해 로그인 상태를 유지 및 해지 기능도 추가해 주겠다.

// Session 설정
var session = require('express-session');
var fileStore = require('session-file-store')(session);

// Session 연결
app.use(session({
  secret: process.env.SESSION_SECRET || 'session-login',
  resave: false,
  saveUninitialized: false, // 세션이 필요할 때만 저장하도록 설정 
  store: new fileStore({
    path: './sessions', // 세션 파일 저장 경로
    ttl: 24 * 60 * 60, // 세션 유효기간 (1일)
    reapInterval: 60 * 60 // 세션 정리 주기 (1시간)
  }),
  cookie: {
    httpOnly: true,
    secure: process.env.NODE_ENV === 'production',
    maxAge: 24 * 60 * 60 * 1000 // 쿠키 유효기간 (1일)
  }
}));

 

  • express-session : 세션을 관리하는 미들웨어
  • session-file-store : 세션 데이터를 파일로 저장하는 방식
  • secret : 세션 암호화를 위한 키
  • cookie : 로그인 유지 기간, 보안 옵션 설정

 

router.post('/signin', async function(req, res, next) {
  try {
    var username = req.body.username;
    var password = req.body.password;

    // 빈 값 확인
    if (!username || !password) {
      return res.status(400).json({message : "All fields are required."});
    }

    // DB 연결
    var database = req.app.get('database');
    var users = database.collection('users');

    // 사용자 조회
    const existingUser = await users.findOne({username: username});
    if (existingUser) {
      var compareResult = bcrypt.compareSync(password, existingUser.password);
      if (compareResult) {
        // 세션에 사용자 정보 저장
        req.session.isAuthenticated = true;
        req.session.userId = existingUser._id;
        req.session.username = existingUser.username;
        req.session.nickname = existingUser.nickname;
        res.json({message: 'Login successful.'});
      } else {
        res.status(401).json({message: 'Invalid password.'});
      }
    } else {
      res.status(401).json({message: 'User not found'});
    }    
  }catch (error) {
  console.error('Error during signin:', error);
  res.status(500).json({ message: 'Internal server error.' });
  }
});

 

  • bcrypt.compareSync() : 입력한 비밀번호와 DB에 저장된 해시 비교
  • 성공 시 → req.session에 로그인 정보 저장 → 로그인 상태 유지 가능
  • 실패 시 → 401 Unauthorized 반환

 

 

 

2. 클라이언트 구현

1. 로그인 화면 만들기

using TMPro;
using UnityEngine;

public class SigninPanelController : PanelController
{
    [SerializeField] private TMP_InputField usernameInputField;
    [SerializeField] private TMP_InputField passwordInputField;

    public void OnClickConfirmButton()
    {
        string username = usernameInputField.text;
        string password = passwordInputField.text;

        if (string.IsNullOrEmpty(username) || string.IsNullOrEmpty(password))
        {

            return;
        }

        // Signin 함수로 Username/password 전달하면서 로그인 요청

        Hide(() =>
        {

        });
    }
}

 

기존에 만들어두었던 PanelController 스크립트를 상속받아 SigninPanelController 스크립트를 작성해 주었다.

Show나 Close 부분은 사용하지 않기에 지워주었다.

 

확인 버튼을 누르면 입력한 username과 password가 전달될 수 있도록 우선 밑작업만 해주었다.

 

2. Network 연결

Postman에서 클라이언트 값을 넣었을 때 Json 값으로 보냈었다.

마찬가지로 Unity Client쪽에서도 값을 Json값으로 보내주어야 서버가 받아들일 수 있다.

 

🚨 왜 JSON으로 주고받아야 할까?

서버와 클라이언트는 서로 다른 언어, 프레임워크, 환경에서 동작한다.

이때 데이터를 문자열 기반의 공통 포맷으로 맞춰야 서로 해석할 수 있는데 가장 흔히 사용되는 것이 JSON이다.

(해외여행 갔을 때 공용 언어가 영어인 것처럼..)

using TMPro;
using UnityEngine;

public struct SigninData
{
    public string username;
    public string password;
}

public class SigninPanelController : PanelController
{
    [SerializeField] private TMP_InputField usernameInputField;
    [SerializeField] private TMP_InputField passwordInputField;

    public void OnClickConfirmButton()
    {
    	// ...
    
        var signinData = new SigninData();
        signinData.username = username;
        signinData.password = password;

        // Signin 함수로 Username/password 전달하면서 로그인 요청
    }
}

 

우선 json값으로 변환하기 위하여 username과 password를 구조체로 만들어주고

 

public class Constants
{
    public const string ServerURL = "http://localhost:3000";
}

 

서버 측 주소는 여러 클래스에서 자주 사용될 수 있으니 변수로 만들어주었다.

 


 

이제 네트워크 상호작용을 구현해 줄 차례이다.

클라이언트에서 서버로 Json 바디를 가진 Post 요청을 보내고

응답을 파싱 해서 성공/실패 신호로 바꿔주는 로직을 구현해 줄 것이다.

using System;
using System.Collections;
using UnityEngine;
using UnityEngine.Networking;
using UnityEngine.SceneManagement;

public class NetworkManager : Singleton<NetworkManager>
{
    // 로그인
    public IEnumerator Signin(SigninData signinData, Action success, Action<int> failure)
    {
        string jsonString = JsonUtility.ToJson(signinData); // 구조체 값을 json 형식의 문자열로 바꾸기

        // Post 방식으로 값 전달 -> btye 타입이어야 함
        byte[] byteRaw = System.Text.Encoding.UTF8.GetBytes(jsonString);

        using (UnityWebRequest www = new UnityWebRequest(Constants.ServerURL + "/users/signin",
            UnityWebRequest.kHttpVerbPOST))
        {
            www.uploadHandler = new UploadHandlerRaw(byteRaw);
            www.downloadHandler = new DownloadHandlerBuffer(); // 서버 응답 받아오기
            www.SetRequestHeader("Content-Type", "application/json"); // Http 프로토콜의 header 구조가 이럼

            yield return www.SendWebRequest(); // 서버에서 응답 오기 전까지 대기

            // 접속 에러가 발생했을 경우
            if (www.result == UnityWebRequest.Result.ConnectionError)
            {
                // TODO : 서버 연결 오류에 대해 알림
            }
            else
            {
                var resultString = www.downloadHandler.text;
                var result = JsonUtility.FromJson<SigninResult>(resultString); // Json을 구조체 형태로 바꾸기

                if (result.result == 2)
                {
                    success?.Invoke();
                }
                else
                {
                    failure?.Invoke(result.result);
                }
            }
        };
    }

    protected override void OnSceneLoad(Scene scene, LoadSceneMode mode)
    {
        
    }
}

 

[UnityWebRequest]

Unity에서 HTTP 요청을 보낼 때 사용하는 기본 클래스
쉽게 말하면 클라이언트 ↔ 서버 간 통신을 담당하는 본체 같은 존재이다.
GET, POST, PUT 같은 요청을 만들 수 있고 헤더를 세팅하거나 바디를 붙이는 것도 여기서 한다.

  • UnityWebRequest.kHttpVerbPOST : POST 방식으로 요청 보낼 때 사용
  • SetRequestHeader("Content-Type", "application/json") : 헤더 지정해서 서버가 JSON 요청임을 알 수 있게 함
  • SendWebRequest() : 실제로 요청을 날리고 서버 응답을 기다림 (yield return으로 코루틴에서 대기)

[UploadHandlerRaw]

클라이언트에서 서버로 데이터를 실어 보내는 역할을 한다.
우리가 만든 JSON 문자열을 UTF-8 바이트 배열로 바꿔서 UploadHandlerRaw에 담으면 이게 요청 바디가 된다.

  • new UploadHandlerRaw(byteRaw) : JSON → byte[]로 바꾼 걸 여기다 넣음
  • 보통 POST/PUT 요청에서 필수

[DownloadHandlerBuffer]

반대로 서버에서 클라이언트로 응답을 받아오는 역할을 한다.
서버가 내려준 JSON 문자열, 에러 메시지 같은 응답 본문을 여기서 전부 들고 있게 된다.

  • new DownloadHandlerBuffer() : 응답 데이터를 메모리에 전부 저장
  • www.downloadHandler.text : 서버가 보낸 본문을 문자열로 바로 꺼낼 수 있음

[흐름 요약]

 

  • 바디 만들기: JsonUtility.ToJson(signinData) → UTF-8 바이트로 변환
  • 업로드 핸들러: UploadHandlerRaw에 바디 세팅
  • 다운로드 핸들러: DownloadHandlerBuffer로 응답 본문 확보
  • 헤더: Content-Type: application/json
  • 전송/대기: yield return www.SendWebRequest()로 응답받을 때까지 코루틴 대기한다.

HTTP 헤더 구조 (출처: https://raonctf.com/essential/study/web/http)

 

 

🚨 Byte 배열로 변환하는 이유는?

HTTP 프로토콜에서 요청 바디는 결국 네트워크를 통해 이진 데이터로 전달된다.

문자열을 그대로 보내는 게 아니라 UTF-8같은 인코딩을 거쳐 byte 단위로 쪼개서 흘려보내야 한다.

 

다시 말해 네트워크는 문자열 자체를 전송하지 못하고

결국 모든 데이터는 TCP/IP 위에서 바이트 스트림(0, 1)으로 흘러가야 한다.

그래서 JSON 문자열을 UTF-8로 인코딩해 byte[]로 변환하는 과정이 필요한 것이다.

 

 

3. 클라이언트 <> 서버 응답 처리

클라이언트 쪽(NetworkManager.cs)에서 서버에게 POST 요청을 보냈으니

이제 서버에서는 Node.js 라우터(users.js)가 해당 요청을 받아서 로그인 검증을 진행&반환해야 한다.

 

그리고 클라이언트는 서버가 보낸 응답을 해석한 후 다음에 동작을 진행해야 한다.

서버 응답이 오기 전에 다음 로직이 실행되면 안 되므로 코루틴을 사용해 대기하고

로그인에 성공하면 메인 화면으로 전환 / 실패하면 에러 팝업을 띄우는 구조로 구현할 것이다.

var ResponseType = {
  INVALID_USERNAME: 0,
  INVALID_PASSWORD: 1,
  SUCCESS: 2,
}

// 로그인
router.post('/signin', async function(req, res, next) {
  try {
    var username = req.body.username;
    var password = req.body.password;

    // 빈 값 확인 ...

    // DB 연결 ...

    // 사용자 조회
    const existingUser = await users.findOne({username: username});
    if (existingUser) {
      var compareResult = bcrypt.compareSync(password, existingUser.password);
      if (compareResult) {
        // 세션에 사용자 정보 저장
        req.session.isAuthenticated = true;
        req.session.userId = existingUser._id;
        req.session.username = existingUser.username;
        req.session.nickname = existingUser.nickname;
        res.json({result: ResponseType.SUCCESS});
      } else {
        res.status(401).json({result: ResponseType.INVALID_PASSWORD});
      }
    } else {
      res.status(401).json({result: ResponseType.INVALID_USERNAME});
    }    
  }catch (error) {
  console.error('Error during signin:', error);
  res.status(500).json({ message: 'Internal server error.' });
  }
});

 

서버 쪽에서 클라이언트 측 로그인 시도가 있을 때

0. Username 불일치

1. Password 불일치

2. 모든 정보 일치

세 가지 타입으로 클라이언트에게 응답을 반환해 줄 것이다.

 

위에서 클라이언트가 Body로 묶어서 SigninData를 보냈으니

서버에서는 바디에서 SigninData를 꺼내서 값이 일치한 지 확인해야 한다.

 

DB에서 정보가 일치한 지 확인한 후 

res!!로 값을 클라이언트에게 반환해 준다.

 

using TMPro;
using UnityEngine;

public struct SigninData
{
    public string username;
    public string password;
}

public struct SigninResult
{
    public int result; // Json 타입의 키 값과 이름 동일해야함
}

public class SigninPanelController : PanelController
{
    [SerializeField] private TMP_InputField usernameInputField;
    [SerializeField] private TMP_InputField passwordInputField;

    public void OnClickConfirmButton()
    {
        string username = usernameInputField.text;
        string password = passwordInputField.text;

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

        var signinData = new SigninData();
        signinData.username = username;
        signinData.password = password;

        StartCoroutine(NetworkManager.Instance.Signin(signinData,
            () => // 로그인 성공했을 때
            {
                Hide();
            },
            (result) => // 로그인 실패했을 때
            {
                if (result == 0)
                {
                    GameManager.Instance.OpenConfirmPanel("유저 이름이 유효하지 않습니다.", () =>
                    {
                        // TODO : ??
                    });
                }
                else if (result == 1)
                {
                    GameManager.Instance.OpenConfirmPanel("패스워드가 유효하지 않습니다.", () =>
                    {
                        // TODO : ??
                    });
                }
            }));
    }
}

 

NetworkManager 스크립트에서 Signin 함수를 구현할 때 Action을 활용해 주었었다.

public IEnumerator Signin(SigninData signinData, Action success, Action<int> failure)
  • Action success → 매개변수가 없는 함수 (성공 시 실행)
  • Action<int> failure → int 매개변수를 받는 함수 (실패 코드와 함께 실행)

그래서

  • () => { ... } 는 성공 콜백. 아무 값도 전달받지 않고 "성공했을 때 이런 동작 해라"라는 의미이고
  • (result) => { ... } 는 실패 콜백으로 서버에서 받은 result 숫자 코드(0,1)를 받아서 다른 작업을 수행한다.

 

🚨 수업 도중 발생한 문제 - Protocol Error

// 접속 에러가 발생했을 경우
if (www.result == UnityWebRequest.Result.ConnectionError || UnityWebRequest.Result.ProtocolError)
{
    // TODO : 서버 연결 오류에 대해 알림
}

 

맨 처음 NetworkManager 스크립트에서 코루틴을 작성할 때 ProtocolError 에러 작업도 예외처리 두는 코드를 작성해 주었었다.

그런데 UnityWebRequest는 상태코드가 400 이상이면 Protocol Error으로 간주한다.

서버 스크립트를 보면 user 정보가 불일치하면 401 신호를 보내도록 구현하였으니 이 부분에서 에러 처리가 되어

팝업창이 나타나지 않는 현상이 있었다.

그래서 ProtocolError 예외처리는 지워주었다!