I'm FanJae.

Unity 게임 개발 캠프 개인 프로젝트 10일차. Credential 기반 로그인 인증과 GameServer 입장 통합 테스트 본문

Projects/MyToyMapleServer

Unity 게임 개발 캠프 개인 프로젝트 10일차. Credential 기반 로그인 인증과 GameServer 입장 통합 테스트

FanJae 2026. 9. 23. 23:29

1. 시작에 앞서

- 이전 작업에서는 MySQL 개발 환경을 구성하고, LoginServer에서 Account와 Character 데이터를 DB를 통해 조회할 수 있도록 구조를 변경했다.

- 이에 따라 코드 내부에 직접 작성해두었던 임시 계정과 캐릭터 데이터를 실제 Database에서 가져올 수 있는 기반은 만들어졌다.

- 다만 로그인 과정 자체는단순하게 처리해두었다. 클라이언트가 accountId를 직접 전달하면 해당 계정이 DB에 존재하는지만 확인하는 구조였기 때문이다.

Client
   ↓
accountId 전달
   ↓
LoginServer
   ↓
Account 존재 여부 확인
   ↓
로그인 성공

- 하지만 실제 로그인에서는 클라이언트가 내부에서 사용하는 accountId를 직접 알고 있을 필요가 없다.

- 일반적으로 사용자가 입력하는 것은 로그인 ID와 비밀번호이고, 서버에서 해당 정보를 검증한 뒤 내부 Account ID를 찾아 사용하는 형태가 자연스럽다.

- 또한 DB를 사용하면서 데이터가 존재하지 않는 경우와 DB 자체에 문제가 발생한 경우를 동일한 실패로 처리해서는 안 된다는 문제도 있었다.

- 따라서 이번 작업에서는 로그인 패킷을 ID/PW 기반으로 변경하고, PBKDF2 기반 비밀번호 검증을 추가했으며, Repository 조회 결과를 세분화하여 DB 오류를 별도로 처리하도록 구조를 정리했다.

- 마지막으로 TestClient를 확장하여 로그인부터 캐릭터 선택, 인증 티켓 발급, GameServer 입장까지 전체 정상 흐름을 한 번에 확인할 수 있도록 통합 테스트를 구성했다.


2. 기존 로그인 방식의 문제

- 기존 LoginRequest에서는 클라이언트가 직접 accountId를 전달했다.

struct LoginRequest
{
    uint32_t accountId = 0;
};

- LoginServer에서는 전달받은 ID가 실제 DB에 존재하는지만 확인했다.

accountId 전달
      ↓
AccountRepository
      ↓
계정 존재 여부 확인
      ↓
Success / InvalidAccount

- 서버 구조를 확인하기 위한 단계에서는 간단하게 사용할 수 있었지만, 실제 로그인 기능으로 보기에는 부족한 점이 있었다.

- 우선 accountId는 서버 내부에서 계정을 식별하기 위한 값이다.

- 클라이언트가 해당 값을 직접 알고 전달하도록 만들 필요가 없다.

- 또한 계정 ID만 존재하면 인증이 완료되기 때문에 비밀번호 검증이라는 과정도 존재하지 않았다.

- 따라서 로그인 요청 자체를 실제 사용자 입력에 가까운 형태로 변경할 필요가 있었다.


3. 공용 Protocol 구조 정리

- 로그인 구조를 수정하기 전에 패킷 정의 위치도 먼저 정리했다.

- 기존에는 로그인 패킷은 LoginServer, 게임 입장 패킷은 GameServer 프로젝트 내부에 각각 존재했다.

LoginServer
 └─ LoginPacket.h

GameServer
 └─ GamePacket.h

- 하지만 해당 패킷은 서버 내부에서만 사용하는 것이 아니다.

- LoginServer와 통신하는 Client 역시 LoginPacket의 구조를 알아야 하고, GameServer에 접속하는 Client 역시 GamePacket을 알아야 한다.

- 따라서 패킷 정의를 별도의 Protocol 영역으로 이동했다.

Protocol
 ├─ LoginPacket.h
 ├─ GamePacket.h
 └─ ServerPacket.h

- 결과적으로 각 실행 프로젝트에서는 공통 Protocol을 참조하도록 변경했다.

TestClient ─┐
LoginServer ├──▶ Protocol
GameServer ─┘

- 즉, 패킷의 소유 주체를 특정 프로젝트가 아닌 공통의 프로토콜로 묶었다.


4. Repository 결과 모델 정리

- DB 조회 구조를 사용하면서 하나의 문제가 더 있었다. 기존 Repository 함수는 bool이나 빈 vector 등을 이용해 결과를 반환하고 있었다. 

- 예를 들어 캐릭터 소유 여부 확인에서 false가 반환되면 다음 두 경우를 구분하기 어렵다.

캐릭터가 존재하지 않음

DB Query 자체가 실패함

- 두 상황은 의미가 완전히 다르다. 캐릭터가 존재하지 않는 것은 정상적인 조회 결과지만, DB 연결이나 Query 처리에 실패했다면 서버 문제로 처리해야 한다.

- 이를 구분하기 위해 RepositoryStatus를 추가했다.

enum class RepositoryStatus
{
    Success,
    NotFound,
    DatabaseError
};

- 조회 결과를 다음 세 가지 상태로 분리한 것이다.

Success → 정상 조회
NotFound → Query는 성공했지만 데이터 없음
DatabaseError → DB 처리 과정 자체에서 오류 발생

- 이에 따라 상위 패킷 처리 로직에서도 실패의 원인을 구분할 수 있게 되었다.


5. CharacterRepository 결과 처리 변경

- 캐릭터 목록 조회 역시 단순한 vector<CharacterInfo> 반환 방식에서 결과 상태를 같이 전달하도록 변경했다.

struct CharacterListQueryResult
{
    RepositoryStatus status = RepositoryStatus::DatabaseError;
    std::vector<CharacterInfo> characters;
};

- 정상적으로 Query가 끝나면 Success를 반환한다.

CharacterRepository
       ↓
DB Query
       ↓
성공
       ↓
RepositoryStatus::Success
       +
Character 목록

- 반대로 SQL 처리 과정에서 예외가 발생하면 별도의 상태를 반환한다.

DB Query 실패
     ↓
RepositoryStatus::DatabaseError

- 캐릭터 선택 시 소유 여부를 확인하는 함수도 bool 대신 RepositoryStatus를 반환하도록 변경했다.

Success
→ 해당 계정이 캐릭터 보유

NotFound
→ 해당 계정의 캐릭터가 아님

DatabaseError
→ DB 조회 실패

- DB에서 데이터가 없다는 것과 DB를 사용할 수 없다는 것을 상위 로직에서 구분할 수 있게 된 것이다.


6. 로그인 패킷을 Credential 기반으로 변경

- 이후 로그인 패킷을 accountId 기반에서 실제 Credential 기반으로 변경했다.

- 변경된 LoginRequest는 다음과 같다.

struct LoginRequest
{
    char loginId[MAX_LOGIN_ID_LENGTH] = {};
    char password[MAX_PASSWORD_LENGTH] = {};
};

- 클라이언트에서는 이제 다음과 같은 정보를 전달한다.

Login ID
Password

- 내부 accountId는 클라이언트가 전달하지 않는다.

- 전체 흐름은 다음과 같이 변경된다.

Client
   │
   │ Login ID / Password
   ▼
LoginServer
   │
   │ loginId로 Account 조회
   ▼
Database
   │
   │ accountId
   │ passwordHash
   ▼
LoginServer
   │
   │ Password 검증
   ▼
LoginSession

- 사용자가 알고 있는 인증 정보와 서버 내부에서 사용하는 식별자를 분리하게 된 것이다.


7. Login ID를 이용한 계정 조회

- AccountRepository 역시 기존의 accountId 존재 여부 검사에서 Login ID 기반 조회로 변경했다.

AccountQueryResult FindByLoginId(
    const std::string& loginId
);

- 실제 Query에서는 다음 정보를 가져온다.

id
password_hash

- 결과를 저장하기 위한 구조도 추가했다.

struct AccountData
{
    uint32_t accountId = 0;
    std::string passwordHash;
};

- 전체 흐름은 다음과 같다.

loginId
   ↓
SELECT id, password_hash
FROM accounts
WHERE login_id = ?
   ↓
AccountData

- LoginServer에서는 DB에서 조회한 accountId를 이후 내부 세션 식별자로 사용한다.

- 따라서 클라이언트는 서버 내부 계정 ID를 직접 알 필요가 없어졌다.


8. 평문 비밀번호를 저장하지 않는 이유

- 로그인에 ID와 Password를 사용하면서 DB에 비밀번호를 어떤 방식으로 저장할 것인지도 고려해야 했다.

- 가장 단순한 방법은 사용자가 입력한 비밀번호를 그대로 저장하는 것이다.

login_id : test
password : test1234

- 하지만 DB 내용이 노출될 경우 사용자의 비밀번호가 그대로 노출된다는 문제가 있다.

- 따라서 DB에는 평문 Password 대신 Password Hash를 저장하도록 변경했다.

Account
 ├─ login_id
 └─ password_hash

- 클라이언트가 입력한 비밀번호 역시 DB 값과 문자열 자체를 직접 비교하지 않는다.

- 입력된 비밀번호를 같은 방식으로 계산한 뒤 저장된 Hash와 비교하도록 구성했다.


9. PBKDF2 기반 비밀번호 검증

- 비밀번호 검증 역할은 별도의 PasswordVerifier로 분리했다.

class PasswordVerifier
{
public:
    bool Verify(
        const std::string& password,
        const std::string& passwordHash
    ) const;
};

- DB에는 다음과 같은 형식의 값이 저장된다.

- 서버에서는 저장된 문자열을 파싱한 뒤 해당 값을 이용해 사용자가 입력한 Password의 Hash를 다시 계산한다.

입력 Password
      +
저장된 Salt
      +
Iteration
      ↓
PBKDF2-HMAC-SHA256
      ↓
Derived Hash

- 이후 DB에 저장되어 있던 Hash와 비교한다.

Derived Hash
      ↓
Stored Hash와 비교
      ↓
일치 / 불일치

- 실제 구현에서는 Windows BCrypt API의 BCryptDeriveKeyPBKDF2를 이용했다.


10. Salt와 Iteration

- Password Hash에는 Hash 결과만 저장하지 않고 Salt와 반복 횟수도 같이 포함했다.

- Salt는 같은 비밀번호를 사용하더라도 동일한 Hash가 항상 만들어지는 것을 방지하기 위해 같이 사용하는 값이다.

Password
   +
Salt
   ↓
Hash

- 따라서 같은 Password라도 Salt가 다르면 결과가 달라질 수 있다.

- Iteration은 PBKDF2 계산을 반복해서 수행하는 횟수다.

- 현재 테스트 계정에서는 해당 값까지 Hash 문자열에 저장해두고, PasswordVerifier에서 이를 읽어 실제 검증에 사용한다.

password_hash
 ├─ Algorithm
 ├─ Iteration
 ├─ Salt
 └─ Hash

- DB에는 비밀번호 검증에 필요한 정보를 하나의 문자열 형태로 저장하고 서버가 이를 해석하도록 구성했다.


11. Hash 비교 방식

- Password 검증 과정에서는 계산된 Hash와 저장된 Hash를 비교해야 한다.

- 이번 구현에서는 두 vector<uint8_t>를 단순한 일반 문자열 비교 대신 각 Byte를 끝까지 확인하는 형태의 비교 함수를 사용했다.

uint8_t difference = 0;

for (size_t i = 0; i < lhs.size(); ++i)
{
    difference |= lhs[i] ^ rhs[i];
}

return difference == 0;

- 값이 다르다고 중간에 바로 종료하지 않고 전체 값을 비교한 뒤 결과를 확인하도록 한 것이다.

- 인증 정보를 비교하는 부분인 만큼 Hash 생성뿐만 아니라 비교 과정 역시 별도의 처리로 구현했다.

 


12. 로그인 처리 흐름 변경

- LoginPacketHandler의 로그인 과정은 최종적으로 다음과 같이 변경되었다.

LoginRequest
     ↓
Login ID / Password 길이 검사
     ↓
빈 Credential 검사
     ↓
Login ID로 Account 조회
     ↓
Repository 결과 확인
     ↓
Password Hash 검증
     ↓
LoginSession 인증

- Repository에서 DB 오류가 발생한 경우에는 서버 오류로 처리한다.

DatabaseError
      ↓
LoginResult::ServerError

- 계정이 존재하지 않는 경우에는 인증 실패로 처리한다.

NotFound
   ↓
InvalidCredential

- Password가 틀린 경우 역시 같은 결과를 반환한다.

Password 불일치
      ↓
InvalidCredential

- 따라서 클라이언트 입장에서는 존재하지 않는 계정과 잘못된 Password를 구분하지 않는다.

존재하지 않는 계정

잘못된 Password

 

- 두 경우 모두 InvalidCredential로 처리했다.


13. 계정 존재 여부와 비밀번호 오류를 구분하지 않도록 변경

- 기존처럼 Account 조회 결과를 그대로 클라이언트에게 전달하면 다음과 같은 응답을 만들 수도 있다.

AccountNotFound

InvalidPassword

- 하지만 이렇게 하면 클라이언트가 특정 Login ID의 존재 여부를 서버 응답만으로 구분할 수 있다.

- 이번 구조에서는 두 경우를 하나로 묶었다.

계정 없음 ─────┐
               ├─▶ InvalidCredential
Password 오류 ─┘

- 반면 Database 자체에 문제가 있는 경우는 사용자 입력 오류와 다른 상황이기 때문에 ServerError로 구분했다.

InvalidCredential
→ 사용자가 전달한 Credential로 인증할 수 없음

ServerError
→ 서버 내부 처리 또는 DB 문제

- 사용자 인증 실패와 서버 내부 실패를 구분하면서도 Credential 실패의 구체적인 원인은 외부에 노출하지 않는 구조로 변경했다.


14. 로그인 성공 이후 AccountId 처리

- Credential 검증이 정상적으로 완료되면 Repository에서 조회한 내부 accountId를 LoginSession에 저장한다.

session.SetAuthenticated(true);
session.SetAccountId(queryResult.account.accountId);

- 이 부분부터는 기존에 구현한 구조를 그대로 사용할 수 있다.

Credential 인증
     ↓
AccountId 획득
     ↓
LoginSession 저장
     ↓
Character 조회
     ↓
Character 선택
     ↓
AuthTicket 발급

- 즉, Login 방식은 변경되었지만 Login 이후 캐릭터 선택과 GameServer 이동 구조는 그대로 유지된다.


15. Character 목록 조회의 오류 처리

- Repository 상태 모델을 추가하면서 캐릭터 목록 응답에도 결과 값을 추가했다.

enum class CharacterListResult : uint8_t
{
    Success = 0,
    ServerError = 1
};

- 이전에는 캐릭터 목록이 비어 있으면 캐릭터가 없는 계정과 DB 오류를 명확하게 구분하기 어려웠다.

- 변경 이후 DB Query 자체가 정상적으로 끝난 경우에는 캐릭터가 0개이더라도 Success로 처리할 수 있다.

Query 성공
 +
Character 0개
     ↓
Success

- DB 오류가 발생한 경우에만 ServerError를 반환한다.

- 빈 데이터 역시 하나의 정상적인 조회 결과로 표현할 수 있도록 변경한 것이다.


16. 캐릭터 선택의 DB 오류 처리

- 캐릭터 선택에서도 Repository 결과를 그대로 활용한다.

Success
→ 캐릭터 선택 진행

NotFound
→ InvalidCharacter

DatabaseError
→ ServerUnavailable

- 이전에는 캐릭터를 찾지 못한 경우와 DB 오류가 동일한 false로 처리될 가능성이 있었다.

- 이제는 사용자가 잘못된 캐릭터를 선택한 것인지, 서버 내부 데이터 조회가 실패한 것인지 구분할 수 있다.

- 이와 같이 Repository에서 결과의 의미를 구분해두면 패킷 처리 계층에서도 각 상황에 맞는 응답을 만들 수 있다.


17. LoginSession의 역할 확장

- Credential 검증을 추가하면서 LoginSession에서 사용하는 객체도 하나 늘어났다.

LoginSession
 ├─ GameServerClient
 ├─ AuthKeyGenerator
 ├─ AccountRepository
 ├─ CharacterRepository
 └─ PasswordVerifier

- LoginPacketHandler에서는 직접 BCrypt나 DB Connection을 다루지 않고 Session을 통해 필요한 기능에 접근한다.

- 각각의 역할은 다음과 같이 나뉜다.

AccountRepository
→ 계정 데이터 조회

CharacterRepository
→ 캐릭터 데이터 조회

PasswordVerifier
→ Password 검증

AuthKeyGenerator
→ 게임 서버 입장용 AuthKey 생성

GameServerClient
→ GameServer에 AuthTicket 전달

- 로그인 처리 과정이 복잡해지면서 기능별 책임도 조금씩 분리되는 형태가 되었다.


18. 통합 테스트 범위 확장

- 이전 작업에서 LoginServer 통합 테스트용 TestClient를 추가했지만 당시에는 LoginServer 쪽 흐름 확인이 중심이었다.

- 이번에는 Credential 로그인 구조가 추가된 만큼 TestClient도 변경했다.

- 우선 LoginRequest에서 accountId를 보내는 대신 테스트 계정의 Credential을 전달한다.

LoginRequest loginRequest;

strcpy_s(loginRequest.loginId, "test");
strcpy_s(loginRequest.password, "test1234");

- 이후 로그인 결과를 확인하고 캐릭터 목록을 요청한다.

LoginRequest
     ↓
LoginResponse
     ↓
CharacterListRequest
     ↓
CharacterListResponse

- 캐릭터 목록 응답에서는 이번에 추가한 CharacterListResult 역시 확인한다.


19. GameServer 입장까지 통합 테스트

- 이번 TestClient 변경에서 중요한 부분은 테스트 범위를 LoginServer에서 끝내지 않았다는 점이다.

- 캐릭터 선택에 성공하면 LoginServer에서 다음 정보를 받는다.

AuthKey
GameServerPort

- TestClient는 기존 LoginServer 연결을 종료하고 실제 GameServer에 새로 연결한다.

TestClient
    │
    ├─ LoginServer 연결
    │
    └─ GameServer 연결

- 이후 LoginServer에서 발급받은 authKey를 이용해 EnterGameRequest를 전달한다.

CharacterSelectResponse
       ↓
AuthKey 획득
       ↓
GameServer 연결
       ↓
EnterGameRequest
       ↓
EnterGameResponse

- 최종적으로 EnterGameResult::Success까지 확인하도록 했다.

- 따라서 하나의 TestClient 실행으로 다음 전체 흐름을 확인할 수 있다.

Credential Login

       ↓

DB Account 조회

       ↓

Password 검증

       ↓

Character 목록 조회

       ↓

Character 선택

       ↓

AuthKey 생성

       ↓

GameServer에 AuthTicket 등록

       ↓

GameServer 연결

       ↓

EnterGameRequest

       ↓

GameSession 인증

20. 공용 패킷 구조와 TestClient

- TestClient가 LoginServer뿐만 아니라 GameServer까지 접근하게 되면서 공용 Protocol 분리의 필요성도 더 명확해졌다.

- 기존 구조였다면 TestClient가 각 서버 프로젝트 내부의 패킷 파일을 직접 참조해야 했다.

TestClient
 ├─ LoginServer/LoginPacket.h
 └─ GameServer/GamePacket.h

- 공용 Protocol로 이동한 이후에는 다음과 같이 사용할 수 있다.

TestClient
     ↓
Protocol
 ├─ LoginPacket
 └─ GamePacket

- 서버와 클라이언트가 동일한 패킷 정의를 참조하므로 프로토콜 구조를 한 곳에서 관리할 수 있게 되었다.


21. 전체 로그인 구조 변화

- DB를 처음 연결했을 때와 이번 작업 이후를 비교하면 로그인 구조가 크게 달라졌다.

- 초기 DB 로그인은 다음과 같았다.

Client
   ↓
accountId
   ↓
LoginServer
   ↓
DB 존재 여부 확인
   ↓
Login 성공

- 변경 이후에는 다음과 같은 형태가 된다.

Client
   │
   │ Login ID
   │ Password
   ▼
LoginServer
   │
   │ Login ID 조회
   ▼
AccountRepository
   │
   ▼
Database
   │
   │ accountId
   │ passwordHash
   ▼
PasswordVerifier
   │
   │ PBKDF2 검증
   ▼
LoginSession
   │
   │ accountId 저장
   ▼
CharacterRepository
   │
   ▼
캐릭터 선택
   │
   ▼
AuthTicket 발급
   │
   ▼
GameServer

- LoginServer가 단순히 Account 존재 여부를 확인하던 단계에서 실제 Credential을 검증하고 내부 Account ID를 기준으로 이후 게임 입장 과정을 처리하는 구조로 확장된 것이다.


22. 고려해 볼 만한 사항

- 현재 PasswordVerifier에서는 DB에 저장된 Hash 문자열을 직접 파싱하고 PBKDF2-HMAC-SHA256을 이용해 검증하고 있다.

- Password Hash는 보안과 직접 연결되는 부분이기 때문에 이후에도 형식 검증과 알고리즘 변경 가능성을 고려할 필요가 있다.

- 예를 들어 향후 Hash 정책을 변경하면 기존 계정과 새로운 계정이 서로 다른 Algorithm이나 Iteration을 사용할 수도 있다.

기존 Account
→ 기존 Hash 정책

새로운 Account
→ 새로운 Hash 정책

- 현재 Hash 문자열에 Algorithm과 Iteration을 포함시킨 구조는 이런 확장을 고려할 때 사용할 수 있는 정보가 된다.

- 또한 현재 Repository는 하나의 DB Connection을 참조하고 있으며 Query도 패킷 처리 과정에서 직접 수행된다.

- 동시 접속자가 늘어나면 DB Query가 네트워크 패킷 처리 흐름에 미치는 영향도 고려해야 한다.

다수의 Client
      ↓
Login 요청 증가
      ↓
DB Query 증가

- 따라서 이후에는 DB Connection 관리, Connection Pool, DB 작업 분리 등의 부분도 고려할 수 있다.

- 이번 단계에서는 우선 Credential 기반 인증과 DB 조회 결과 처리를 실제 GameServer 입장 흐름까지 정상적으로 연결하는 것에 중점을 두었다.


25. 정리

- 기존에는 클라이언트가 내부 accountId를 직접 전달하고 서버에서는 해당 계정이 존재하는지만 확인하는 형태로 로그인 처리를 하고 있었다.

- 이번 작업에서는 LoginRequest를 loginId와 password 기반으로 변경하고, AccountRepository가 Login ID를 이용해 내부 accountId와 password_hash를 조회하도록 변경했다.

- 평문 비밀번호를 DB에 저장하지 않고 PBKDF2-HMAC-SHA256 기반 Hash를 저장한 뒤 PasswordVerifier를 통해 입력된 Password를 검증하도록 구성했다.

- 존재하지 않는 계정과 잘못된 Password는 모두 InvalidCredential로 처리하고, DB 오류는 ServerError로 별도로 구분했다.

- 또한 Repository의 조회 결과를 Success, NotFound, DatabaseError로 구분하여 데이터가 없는 경우와 DB 처리 자체가 실패한 경우를 상위 패킷 처리 로직에서 구분할 수 있도록 변경했다.

- 로그인과 게임 패킷 정의를 공용 Protocol 영역으로 이동하여 LoginServer, GameServer, TestClient가 같은 패킷 구조를 사용하도록 정리했다.

- TestClient 역시 Credential 기반 로그인부터 캐릭터 목록 조회, 캐릭터 선택, AuthKey 발급, GameServer 연결, EnterGameRequest까지 수행하도록 확장했다.

- 이를 통해 LoginServer의 로그인 기능만 따로 확인하는 것이 아니라 실제 DB 인증부터 GameServer의 GameSession 인증까지 전체 입장 흐름을 하나의 통합 시나리오로 확인할 수 있게 되었다.

- 이전에는 DB에서 Account와 Character를 조회할 수 있는 기반을 만드는 단계였다면, 이번 작업에서는 실제 Credential 검증을 DB 데이터와 연결하고 그 결과가 최종 GameServer 입장까지 이어지는 전체 인증 흐름을 완성하는 방향으로 확장했다.

Comments