
개요
서비스 계층에서는 컨트롤러로 부터 요청 데이터를 받아 해당 데이터가 유효한 데이터인지, 비즈니스 로직을 수행해도 되는지 체크한 후에 서비스 로직을 수행하는 계층이다. 이 과정에서 데이터베이스를 활용한 CRUD가 필요한 경우 레포지토리에 필요한 인자를 매개변수로 전달하여 통신 결과를 토대로 응답을 작성하여 다시 컨트롤러로 리턴해주게 된다.
따라서 현재 내가 개발중인 소켓 서버에서는 서버, 세션 클래스와 같은 코어 계층과 더불어 가장 많은 코드가 작성된 로직이 바로 이 서비스 계층에 존재한다.
자세한 구현 내용은 하기에 소스코드를 통해 설명하도록 하겠다.
auth_service.h
// service/auth_service.h
#pragma once
#include <memory>
#include <nlohmann/json.hpp>
namespace game_server {
class UserRepository;
class AuthService {
public:
virtual ~AuthService() = default;
virtual nlohmann::json registerUser(const nlohmann::json& request) = 0;
virtual nlohmann::json loginUser(const nlohmann::json& request) = 0;
virtual nlohmann::json registerCheckAndLogin(const nlohmann::json& request) = 0;
virtual nlohmann::json updateNickName(const nlohmann::json& request) = 0;
static std::unique_ptr<AuthService> create(std::shared_ptr<UserRepository> userRepo);
};
} // namespace game_server
인증 관련 서비스 클래스의 헤더파일이다.
auth_service.cpp
// service/auth_service.cpp
// 인증 서비스 구현 파일
// 사용자 등록 및 로그인 비즈니스 로직을 처리
#include "auth_service.h"
#include "../util/password_util.h"
#include "../repository/user_repository.h"
#include <spdlog/spdlog.h>
#include <regex>
namespace game_server {
using json = nlohmann::json;
namespace {
// 사용자 이름 유효성 검증 함수
bool isValidUserName(const std::string& name) {
// 빈 이름은 유효하지 않음
if (name.empty()) {
return false;
}
// 30바이트 이내인지 확인
if (name.size() > 30) {
return false;
}
// "mirror" 단어가 포함되어 있는지 확인 (대소문자 구분 없이)
if (name.find("mirror") != std::string::npos) {
return false;
}
// 이메일 형식인지 확인
bool isEmail = (name.find('@') != std::string::npos) &&
(name.find('.', name.find('@')) != std::string::npos);
// 이메일이 아닌 경우 영어, 한글, 숫자, @ 문자만 포함하는지 확인
if (!isEmail) {
for (unsigned char c : name) {
// ASCII 영어와 숫자 확인
if ((c >= 'A' && c <= 'Z') ||
(c >= 'a' && c <= 'z') ||
(c >= '0' && c <= '9')) {
continue;
}
// 허용되지 않는 문자
return false;
}
}
else {
// 이메일인 경우 추가 검증 (간단한 이메일 형식 검사)
// 여기서는 표준적인 이메일 문자들(영어, 숫자, 일부 특수문자) 허용
for (unsigned char c : name) {
if ((c >= 'A' && c <= 'Z') ||
(c >= 'a' && c <= 'z') ||
(c >= '0' && c <= '9') ||
(c == '@') || (c == '.') ||
(c == '_') || (c == '-') || (c == '+')) {
continue;
}
// 이메일에 한글은 허용하지 않음 (IDN 이메일 제외)
return false;
}
}
return true;
}
bool isValidNickName(const std::string& str) {
// 정규식 패턴: 한글(가-힣), 영어(A-Za-z), 숫자(0-9)만 허용
if (str.size() > 24) return false;
try {
std::regex pattern("^[가-힣A-Za-z0-9]+$");
return std::regex_match(str, pattern);
}
catch (const std::regex_error& e) {
spdlog::error("닉네임 {}에 대한 검증 시 정규식 오류 발생 : {}",str, e.what());
return false;
}
}
}
// 서비스 구현체
class AuthServiceImpl : public AuthService {
public:
explicit AuthServiceImpl(std::shared_ptr<UserRepository> userRepo)
: userRepo_(userRepo) {
}
json registerUser(const json& request) override {
json response;
// 사용자명 유효성 검증
if (!request.contains("userName") || !request.contains("password")) {
response["status"] = "error";
response["message"] = "회원가입 요청에 필수 필드가 누락되었습니다.";
return response;
}
if (!isValidUserName(request["userName"])) {
response["status"] = "error";
response["message"] = "잘못된 형식의 아이디입니다.";
return response;
}
// 비밀번호 유효성 검증
if (request["password"].get<std::string>().size() < 6) {
response["status"] = "error";
response["message"] = "비밀번호는 최소 6자리 이상이어야 합니다.";
return response;
}
// 사용자명 중복 확인
const json& userInfo = userRepo_->findByUsername(request["userName"]);
if (userInfo["userId"] != -1) {
response["status"] = "error";
response["message"] = "이미 존재하는 아이디입니다.";
return response;
}
// PasswordUtil을 사용하여 비밀번호 해싱
std::string hashedPassword = PasswordUtil::hashPassword(request["password"]);
// 새 사용자 생성
int userId = userRepo_->create(request["userName"], hashedPassword);
if (userId < 0) {
response["status"] = "error";
response["message"] = "회원가입에 실패하였습니다.";
return response;
}
// 성공 응답 생성
response["action"] = "register";
response["status"] = "success";
response["message"] = "회원가입에 성공하였습니다.";
response["userId"] = userId;
response["userName"] = request["userName"];
spdlog::info("새로운 유저가 계정을 생성하였습니다, 유저 이름 : {} (ID: {})", request["userName"].get<std::string>(), userId);
return response;
}
json loginUser(const json& request) override {
json response;
// 사용자명 유효성 검증
if (!request.contains("userName") || !request.contains("password")) {
response["status"] = "error";
response["message"] = "로그인 요청에 필수 필드가 누락되었습니다.";
return response;
}
// 사용자 찾기
const json& userInfo = userRepo_->findByUsername(request["userName"]);
if (userInfo["userId"] == -1) {
response["status"] = "error";
response["message"] = "존재하지 않는 사용자입니다.";
return response;
}
// PasswordUtil을 사용하여 비밀번호 검증
if (!PasswordUtil::verifyPassword(request["password"], userInfo["passwordHash"])) {
response["status"] = "error";
response["message"] = "비밀번호가 일치하지 않습니다.";
return response;
}
// 로그인 시간 업데이트
userRepo_->updateLastLogin(userInfo["userId"]);
// 성공 응답 생성
response["action"] = "login";
response["status"] = "success";
response["message"] = "로그인에 성공하였습니다.";
response["userId"] = userInfo["userId"];
response["userName"] = userInfo["userName"];
response["nickName"] = userInfo["nickName"];
response["createdAt"] = userInfo["createdAt"];
response["lastLogin"] = userInfo["lastLogin"];
return response;
}
json registerCheckAndLogin(const nlohmann::json& request) {
json response;
// 사용자명 유효성 검증
if (!request.contains("userName") || !request.contains("password")) {
response["status"] = "error";
response["message"] = "회원가입 여부 확인 및 로그인 요청에 필수 필드가 누락되었습니다.";
return response;
}
// 사용자 찾기
json userInfo = userRepo_->findByUsername(request["userName"]);
int userId = -1;
if (userInfo["userId"] == -1) {
// PasswordUtil을 사용하여 비밀번호 해싱
std::string hashedPassword = PasswordUtil::hashPassword(request["password"]);
// 새 사용자 생성
userId = userRepo_->create(request["userName"], hashedPassword);
if (userId < 0) {
response["status"] = "error";
response["message"] = "사용재 생성에 실패하였습니다.";
spdlog::error("새로운 사용자를 생성하는 도중 에러가 발생하였습니다.");
return response;
}
}
userInfo = userRepo_->findByUsername(request["userName"]);
// 로그인 시간 업데이트
userRepo_->updateLastLogin(userId);
// 성공 응답 생성
response["action"] = "login";
response["status"] = "success";
response["message"] = "로그인에 성공하였습니다.";
response["userId"] = userInfo["userId"];
response["userName"] = userInfo["userName"];
response["nickName"] = userInfo["nickName"];
response["createdAt"] = userInfo["createdAt"];
response["lastLogin"] = userInfo["lastLogin"];
return response;
}
json updateNickName(const nlohmann::json& request) {
json response;
// 사용자명 유효성 검증
if (!request.contains("userId") || !request.contains("nickName")) {
response["status"] = "error";
response["message"] = "닉네임 변경 요청에 필수 필드가 누락되었습니다.";
return response;
}
if (!isValidNickName(request["nickName"])) {
response["status"] = "error";
response["message"] = "잘못된 형식의 닉네임입니다.";
return response;
}
// 사용자 찾기
if (!userRepo_->updateUserNickName(request["userId"], request["nickName"])) {
response["status"] = "error";
response["message"] = "닉네임 변경에 실패하였습니다.";
return response;
}
// 성공 응답 생성
response["action"] = "updateNickName";
response["status"] = "success";
response["message"] = "닉네임을 성공적으로 변경하였습니다.";
spdlog::info("유저 ID : {}가 닉네임을 {}로 변경하였습니다.", request["userId"].get<int>(), request["nickName"].get<std::string>());
return response;
}
private:
std::shared_ptr<UserRepository> userRepo_;
};
// 팩토리 메서드 구현
std::unique_ptr<AuthService> AuthService::create(std::shared_ptr<UserRepository> userRepo) {
return std::make_unique<AuthServiceImpl>(userRepo);
}
} // namespace game_server
인증 관련 비즈니스 로직을 수행하는 서비스 임플리케이션 클래스이다, 기본적으로 인증 서비스 관련 헤더 파일에서 해당하는 클래스를 상속 받아 로직을 구현한다.
해당 서비스 계층에서 담당하는 기능은 하기와 같다.
- ID, 닉네임 유효성 검증
- 회원가입 서비스 수행
- 로그인 서비스 수행
- 회원가입 체크, 이력이 없다면 회원가입 후 로그인 서비스 수행
- 닉네임 변경 서비스 수행
해당 기능 내에서 항상 요청 데이터의 유효성을 검증하고, 비밀번호 암호화 등 모든 요청이 논리적으로 수행될 수 있도록 검증 절차를 거치게 된다.
room_service.h
// service/room_service.h
#pragma once
#include <memory>
#include <string>
#include <vector>
#include <nlohmann/json.hpp>
namespace game_server {
class RoomRepository;
class RoomService {
public:
virtual ~RoomService() = default;
virtual nlohmann::json createRoom(nlohmann::json& request) = 0;
virtual nlohmann::json joinRoom(nlohmann::json& request) = 0;
virtual nlohmann::json exitRoom(nlohmann::json& request) = 0;
virtual nlohmann::json listRooms() = 0;
static std::unique_ptr<RoomService> create(std::shared_ptr<RoomRepository> roomRepo);
};
} // namespace game_server
방 관련 비즈니스 로직의 내용이 담긴 헤더 파일
room_service.cpp
#include "room_service.h"
#include "../repository/room_repository.h"
#include <spdlog/spdlog.h>
#include <random>
#include <string>
#include <nlohmann/json.hpp>
namespace game_server {
using json = nlohmann::json;
namespace {
// 방 이름 유효성 검증 함수
bool isValidRoomName(const std::string& name) {
// 빈 이름은 유효하지 않음
if (name.empty()) {
return false;
}
// 40바이트(UTF-8 표준) 이내인지 확인
if (name.size() > 40) {
return false;
}
// 영어, 한글, 숫자만 포함하는지 확인
int len = name.size();
for (int i = 0; i < len; ++i) {
// ASCII 영어와 숫자 확인
const char& c = name[i];
if (i == len - 1 && c == '$') continue;
if ((c >= 'A' && c <= 'Z') ||
(c >= 'a' && c <= 'z') ||
(c >= '0' && c <= '9') ||
(c == ' ')) {
continue;
}
// UTF-8 한글 범위 확인 (첫 바이트가 0xEA~0xED 범위)
if ((c & 0xF0) == 0xE0) {
// 한글 문자의 첫 바이트 가능성, 좀 더 정확한 확인 필요
continue;
}
// 한글 문자의 연속 바이트 (0x80~0xBF 범위)
if ((c & 0xC0) == 0x80) {
continue;
}
// 허용되지 않는 문자
return false;
}
return true;
}
}
// 서비스 구현체
class RoomServiceImpl : public RoomService {
public:
explicit RoomServiceImpl(std::shared_ptr<RoomRepository> roomRepo)
: roomRepo_(roomRepo) {
}
json createRoom(json& request) override {
json response;
try {
// 요청 유효성 검증
if (!request.contains("roomName") || !request.contains("userId") || !request.contains("maxPlayers")) {
response["status"] = "error";
response["message"] = "방 생성 요청에 필수 필드가 누락되었습니다";
return response;
}
if (!isValidRoomName(request["roomName"])) {
response["status"] = "error";
response["message"] = "방 이름은 1-40바이트 길이여야 하며 영어, 한글, 숫자만 포함해야 합니다";
return response;
}
if (request["maxPlayers"] < 2 || request["maxPlayers"] > 8) {
response["status"] = "error";
response["message"] = "최대 플레이어 수는 2~8 사이여야 합니다";
return response;
}
// 단일 트랜잭션으로 방 생성 및 호스트 추가
json result = roomRepo_->createRoomWithHost(
request["userId"], request["roomName"], request["maxPlayers"]);
if (result["roomId"] == -1) {
response["status"] = "error";
response["message"] = "방 생성에 실패했습니다";
return response;
}
// 성공 응답 생성
response["action"] = "createRoom";
response["status"] = "success";
response["message"] = "방이 성공적으로 생성되었습니다";
response["roomId"] = result["roomId"];
response["roomName"] = result["roomName"];
response["maxPlayers"] = result["maxPlayers"];
response["ipAddress"] = result["ipAddress"];
response["port"] = result["port"];
spdlog::info("사용자 {}가 새 방을 생성했습니다: {} (ID: {})",
request["userId"].get<int>(), request["roomName"].get<std::string>(), result["roomId"].get<int>());
}
catch (const std::exception& e) {
response["status"] = "error";
response["message"] = std::string("방 생성 오류: ") + e.what();
spdlog::error("createRoom 오류: {}", e.what());
}
return response;
}
json joinRoom(json& request) override {
json response;
try {
// 요청 유효성 검증
if (!request.contains("roomId") || !request.contains("userId")) {
response["status"] = "error";
response["message"] = "방 참가 요청에 필수 필드가 누락되었습니다";
return response;
}
int roomId = request["roomId"];
int userId = request["userId"];
// 방에 참가자 추가
if (!roomRepo_->addPlayer(roomId, userId)) {
response["status"] = "error";
response["message"] = "방 참가에 실패했습니다 - 방이 가득 찼거나 WAITING 상태가 아닙니다";
return response;
}
// 성공 응답 생성
response["action"] = "joinRoom";
response["roomId"] = roomId;
response["status"] = "success";
response["message"] = "방에 성공적으로 참가했습니다";
spdlog::info("사용자 {}가 방 {}에 참가했습니다", userId, roomId);
}
catch (const std::exception& e) {
response["status"] = "error";
response["message"] = std::string("방 참가 오류: ") + e.what();
spdlog::error("joinRoom 오류: {}", e.what());
}
return response;
}
json exitRoom(json& request) override {
json response;
try {
// 요청 유효성 검증
if (!request.contains("userId")) {
response["status"] = "error";
response["message"] = "방 퇴장 요청에 userId가 누락되었습니다";
return response;
}
int userId = request["userId"];
// 플레이어를 방에서 제거
if (!roomRepo_->removePlayer(userId)) {
response["status"] = "error";
response["message"] = "사용자가 어떤 방에도 없습니다";
return response;
}
// 성공 응답 생성
response["action"] = "exitRoom";
response["status"] = "success";
response["message"] = "방에서 성공적으로 퇴장했습니다";
spdlog::info("사용자 {}가 방에서 퇴장했습니다", userId);
}
catch (const std::exception& e) {
response["status"] = "error";
response["message"] = std::string("방 퇴장 오류: ") + e.what();
spdlog::error("exitRoom 오류: {}", e.what());
}
return response;
}
json listRooms() override {
json response;
try {
// 열린 방 목록 가져오기
auto rooms = roomRepo_->findAllOpen();
// 응답 생성
response["action"] = "listRooms";
response["status"] = "success";
response["message"] = "방 목록을 성공적으로 가져왔습니다";
response["rooms"] = json::array();
for (auto& room : rooms) {
room["currentPlayers"] = roomRepo_->getPlayerCount(room["roomId"]);
response["rooms"].push_back(room);
}
spdlog::info("{}개의 열린 방을 조회했습니다", response["rooms"].size());
}
catch (const std::exception& e) {
response["status"] = "error";
response["message"] = std::string("방 목록 조회 오류: ") + e.what();
spdlog::error("listRooms 오류: {}", e.what());
}
return response;
}
private:
std::shared_ptr<RoomRepository> roomRepo_;
};
// 팩토리 메서드 구현
std::unique_ptr<RoomService> RoomService::create(std::shared_ptr<RoomRepository> roomRepo) {
return std::make_unique<RoomServiceImpl>(roomRepo);
}
} // namespace game_server
방과 관련된 요청이 클라이언트로 부터 전달되었을 때 실제 비즈니스 로직을 수행하는 클래스이다.
주 기능은 하기와 같다.
- 방 생성 서비스 수행
- 방 참가 서비스 수행
- 방 퇴장 서비스 수행
- 방 목록 조회 서비스 수행
인증 서비스와 마찬가지로 요청 데이터 유효성 검사, 방과 관련된 요청이 알맞은지 논리적 검증 절차를 걸친 후 비즈니스 로직을 수행한다.
game_service.h
#pragma once
#include <memory>
#include <string>
#include <vector>
#include <nlohmann/json.hpp>
namespace game_server {
class GameRepository;
class GameService {
public:
virtual ~GameService() = default;
virtual nlohmann::json startGame(nlohmann::json& request) = 0;
virtual nlohmann::json endGame(nlohmann::json& request) = 0;
static std::unique_ptr<GameService> create(std::shared_ptr<GameRepository> roomRepo);
};
} // namespace game_server
게임 관련 비즈니스 로직이 정의된 헤더 파일
game_service.cpp
#include "game_service.h"
#include "../repository/game_repository.h"
#include <spdlog/spdlog.h>
#include <random>
#include <string>
#include <nlohmann/json.hpp>
namespace game_server {
using json = nlohmann::json;
// 서비스 구현체
class GameServiceImpl : public GameService {
public:
explicit GameServiceImpl(std::shared_ptr<GameRepository> gameRepo)
: gameRepo_(gameRepo) {
}
json startGame(json& request) {
json response;
try {
// 요청 유효성 검증
if (!request.contains("roomId") || !request.contains("mapId")) {
response["status"] = "error";
response["message"] = "게임 시작 요청에 필수 필드가 누락되었습니다";
return response;
}
// 게임 ID 얻기 실패 시 -1
json result = gameRepo_->createGame(request);
if (result["gameId"] == -1) {
response["status"] = "error";
response["message"] = "새 게임 기록 추가에 실패했습니다";
return response;
}
// 성공 응답 생성
response["action"] = "gameStart";
response["status"] = "success";
response["message"] = "게임이 성공적으로 생성되었습니다";
response["gameId"] = result["gameId"];
response["users"] = result["users"];
spdlog::info("방 {}가 새 게임 ID: {}를 생성했습니다",
request["roomId"].get<int>(), response["gameId"].get<int>());
return response;
}
catch (const std::exception& e) {
response["status"] = "error";
response["message"] = std::string("게임 생성 오류: ") + e.what();
spdlog::error("createGame 오류: {}", e.what());
return response;
}
}
json endGame(json& request) {
json response;
try {
// 요청 유효성 검증
if (!request.contains("gameId")) {
response["status"] = "error";
response["message"] = "게임 종료 요청에 필수 필드가 누락되었습니다";
return response;
}
json result = gameRepo_->endGame(request["gameId"]);
if (result["gameId"] == -1) {
response["status"] = "error";
response["message"] = "게임 종료 업데이트에 실패했습니다";
return response;
}
// 성공 응답 생성
response["action"] = "gameEnd";
response["status"] = "success";
response["roomId"] = result["roomId"];
response["message"] = "게임이 성공적으로 종료되었습니다";
response["users"] = result["users"];
spdlog::info("방 {}가 게임 ID: {}를 종료했습니다",
response["roomId"].get<int>(), request["gameId"].get<int>());
return response;
}
catch (const std::exception& e) {
response["status"] = "error";
response["message"] = std::string("게임 종료 오류: ") + e.what();
spdlog::error("endGame 오류: {}", e.what());
return response;
}
}
private:
std::shared_ptr<GameRepository> gameRepo_;
};
// 팩토리 메서드 구현
std::unique_ptr<GameService> GameService::create(std::shared_ptr<GameRepository> gameRepo) {
return std::make_unique<GameServiceImpl>(gameRepo);
}
} // namespace game_server
게임과 관련된 요청이 미러 서버로 들어왔을 때 이를 처리해주기 위한 비즈니스 로직이 작성된 파일이다.
특별한 점은 해당 서비스는 일반 클라이언트 세션으로 부터 인입되는 것이 아닌 데디케이트 서버인 유니티 미러 서버로부터 호출되는 API이다. 게임 시작이 감지되었을 때 API가 호출되며 요청 데이터가 유효하다면 트랜잭션이 호출되어 DB상태를 업데이트 하게 된다.
회고
서비스 계층의 경우 타 로직에 비해 소스코드가 길고 작성해줘야 할 요소가 많았다, 특히 서비스 계층에서 response를 작성해 주어 컨트롤러를 거쳐 바로 세션 계층으로 이동하기에 더욱 체크해야할 요소가 많았다.
특히 nlohmann/json 라이브러리를 사용하면서, 존재하지 않는 json키에 대한 참조를 할 경우 서버가 바로 뻗어버리는 크리티컬한 이슈가 존재했다, 따라서 해당 json객체를 참조할 땐 contains로 해당 키가 존재하는지 체크하고, 실제로 존재하는 키 값인지 눈으로 한번 더 확인, 값을 참조할 땐 .get<T>(), .as<T>() 등 명시적 형 변환을 해주게끔 습관이 생겼다.
또한 예외 처리를 각 발생 시점에 따라 로그를 다르게 찍어주어 오류가 발생햇을 경우 어떤 부분에서 발생했는지를 쉽게 알 수 있게끔 디버깅 하는 방법을 많이 배운 것 같다.
'프로젝트 > 메타버스 게임' 카테고리의 다른 글
[메타버스 게임] 캐쥬얼 배틀로얄 프로젝트 유틸리티 (1) | 2025.04.09 |
---|---|
[메타버스 게임] 캐쥬얼 배틀로얄 프로젝트 레포지토리 계층 (1) | 2025.04.09 |
[메타버스 게임] 캐쥬얼 배틀로얄 프로젝트 컨트롤러 계층 (0) | 2025.04.08 |
[메타버스 게임] 캐쥬얼 배틀로얄 프로젝트 코어 계층(서버, 세션) (1) | 2025.04.08 |
[메타버스 게임] 캐쥬얼 배틀로얄 프로젝트 계층형 아키텍쳐 구현 (0) | 2025.04.01 |