✅ 오늘의 학습 목표
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()로 응답받을 때까지 코루틴 대기한다.


🚨 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 예외처리는 지워주었다!
