I'm FanJae.

Unity 게임 개발 캠프 개인 프로젝트 6일차. LoginServer와 GameServer 인증 연동 및 네트워크 송수신 안정성 보완 작업 본문

Projects/MyToyMapleServer

Unity 게임 개발 캠프 개인 프로젝트 6일차. LoginServer와 GameServer 인증 연동 및 네트워크 송수신 안정성 보완 작업

FanJae 2026. 9. 19. 22:24

1. 시작에 앞서

- 이전 작업에서는 LoginServer와 GameServer를 별도의 서버로 구성하고, 두 서버 사이에서 인증 정보를 전달하기 위한 기본 구조를 만들었다.

- GameServer에는 일반 클라이언트가 접속하는 GameSession과 서버 간 통신을 담당하는 ServerSession을 분리했고, 별도의 내부 Listener를 통해 인증 티켓을 전달받을 수 있도록 구성했다.

- 또한 AuthTicketManager를 추가하여 이후 LoginServer에서 전달받은 인증 정보를 저장하고 사용할 수 있는 기반까지 마련했다.

- 다만 이전 작업에서는 아래 흐름이 모두 연결되어 있는 상태는 아니었다.

LoginServer
    ↓
GameServer에 인증 티켓 등록
    ↓
Client에 authKey 전달
    ↓
Client가 GameServer 접속
    ↓
GameServer에서 실제 티켓 검증

 

- 따라서 이번 작업에서는 LoginServer에서 GameServer까지 인증 정보를 실제로 전달하고, 클라이언트가 해당 인증 정보를 이용해 GameServer에 입장할 수 있도록 전체 인증 흐름을 연결했다.

- 이와 함께 실제 TCP 통신 과정에서 발생할 수 있는 Partial Send와 비정상적인 패킷 크기에 대한 검증도 추가했다.


2. LoginServer에서 인증 키 생성하기

- 이전에는 전체 인증 흐름을 확인하기 위해 LoginServer와 GameServer 양쪽에서 다음과 같은 고정 인증 키를 사용하고 있었다.

authKey = 123456789

- 하지만 이 방식은 LoginServer가 실제로 인증 정보를 발급했다고 보기 어렵다.

- GameServer 역시 단순히 같은 숫자인지만 확인할 뿐이기 때문에, 서버 간 인증 관계가 존재하지 않았다.

 

- 따라서 이번에는 로그인 성공 시 LoginServer에서 직접 authKey를 생성하도록 변경했다.

- 현재 단계에서는 복잡한 토큰 생성 시스템보다는 인증 흐름 자체를 검증하는 것이 목적이기 때문에 증가하는 값을 사용하도록 구성했다.

uint64_t authKey = nextAuthKey++;

- 즉 로그인할 때마다 새로운 인증 키가 만들어지게 된다.

첫 번째 로그인 → authKey = 1
두 번째 로그인 → authKey = 2
세 번째 로그인 → authKey = 3
...

- 아직 보안용 난수 토큰을 생성하는 단계는 아니지만, 적어도 클라이언트마다 별개의 인증 정보를 발급하고 전달하는 구조로 변경됐다.


3. 인증 키를 클라이언트에게만 보내면 되는가?

- 여기서 다시 서버 분리 구조에 따른 문제가 발생한다.

- LoginServer가 새로운 authKey를 생성해서 Client에게 전달했다고 가정한다.

LoginServer
     │
     │ authKey = 10
     ▼
   Client

- 이후 Client가 GameServer에 접속해서 다음 값을 전달한다.

Client
    │
    │ authKey = 10
    ▼
GameServer

- 하지만 GameServer 입장에서는 여전히 알 수 없는 값이다.

"authKey = 10이 정말 LoginServer가 발급한 값인가?"

- LoginServer와 GameServer는 서로 다른 프로세스이기 때문에 LoginServer 메모리에 있는 정보를 GameServer가 직접 확인할 수 없다.

- 따라서 로그인 성공 시 Client에게 인증 키를 반환하기 전에 GameServer에도 동일한 인증 정보를 등록해야 한다.


4. GameServerClient 추가

- LoginServer에서 GameServer로 인증 정보를 전달하기 위해 GameServerClient를 추가했다.

- 역할은 비교적 단순하다.

LoginServer
     │
     ▼
GameServerClient
     │
     │ RegisterAuthTicket
     ▼
GameServer : 7778

- GameServerClient::RegisterAuthTicket()에서는 GameServer의 내부 인증 포트인 7778에 연결한다.

- 그리고 다음 정보를 전달한다.

accountId
authKey

- 이를 위한 패킷은 이전에 만들어 두었던 RegisterAuthTicketRequest를 사용한다.

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

Login 성공
    ↓
authKey 생성
    ↓
GameServerClient::RegisterAuthTicket()
    ↓
GameServer 내부 포트 연결
    ↓
RegisterAuthTicketRequest 전송

- 즉 LoginServer가 인증의 주체가 되고, GameServer는 LoginServer가 전달한 인증 정보를 자신의 AuthTicketManager에 보관하는 형태다.


5. 서버 간 패킷을 공통 Protocol로 분리

- 기존 ServerPacket.h는 GameServer 프로젝트 내부에 위치하고 있었다.

- 하지만 이제 해당 패킷은 GameServer만 사용하는 것이 아니다.

LoginServer → RegisterAuthTicketRequest 생성
GameServer  → RegisterAuthTicketRequest 처리

- 위처럼 양쪽 서버에서 같은 패킷 정의를 사용해야 한다.

- 따라서 ServerPacket.h를 별도의 Protocol 영역으로 이동했다.

Protocol
 └─ ServerPacket.h

- 결과적으로 서버 간 통신 규격 자체는 어느 한쪽 서버에 종속되지 않게 됐다.

- 구조적으로 보면 다음과 같은 형태다.

             Protocol
                 │
       ServerPacket.h
          /          \
         /            \
 LoginServer        GameServer

- 이렇게 공통 프로토콜을 분리하면 이후 서버 종류가 늘어나더라도 동일한 서버 간 패킷 규격을 공유하기 쉬워진다.


6. 로그인 성공과 인증 티켓 등록 연결

- LoginServer의 로그인 처리도 변경했다.

- 기존 흐름은 다음과 같았다.

LoginRequest
     ↓
계정 확인
     ↓
고정 authKey 반환

- 이번에는 다음과 같이 변경됐다.

LoginRequest
     ↓
계정 확인
     ↓
authKey 생성
     ↓
GameServer에 인증 티켓 등록
     ↓
등록 성공
     ↓
Client에 authKey 반환

- 즉 인증 키를 만들었다고 바로 로그인 성공 응답을 보내는 것이 아니다.

- 먼저 RegisterAuthTicket()을 호출하여 GameServer에 인증 티켓을 등록한다.

RegisterAuthTicket(accountId, authKey)

- GameServer에 티켓 등록이 실패한다면 Client에게 정상적인 로그인 성공을 반환해서는 안 된다.

- 그렇게 되면 Client는 정상적으로 로그인했다고 생각하지만 GameServer에는 해당 인증 정보가 존재하지 않는 상태가 되기 때문이다.

- 따라서 현재 구조에서는 GameServer에 인증 티켓 등록이 성공한 이후에야 Client에게 로그인 성공과 authKey를 반환한다.


7. 전체 로그인 흐름

여기까지의 흐름을 정리하면 다음과 같다.

Client
   │
   │ LoginRequest
   ▼
LoginServer
   │
   │ 계정 확인
   ▼
authKey 생성
   │
   ├─────────────────────────────┐
   │                             │
   │ RegisterAuthTicket          │
   ▼                             │
GameServer : 7778                │
   │                             │
   ▼                             │
ServerSession                    │
   │                             │
   ▼                             │
AuthTicketManager                │
   │                             │
   │ Ticket 등록 완료             │
   └─────────────────────────────┘
                                 │
                           LoginResponse
                                 │
                          authKey / port
                                 ▼
                              Client

- 이제 Client에게 전달되는 authKey와 GameServer 내부에 등록된 authKey가 동일한 인증 흐름에서 만들어진 정보가 된다.


8. GameServer의 실제 인증 티켓 검증

- 전날 GameServer의 입장 인증에서는 임시로 고정된 인증 키를 비교하고 있었다.

request.authKey != 123456789

- 이 방식도 이번에 제거했다.

- 이제 Client가 EnterGameRequest를 보내면 해당 값을 AuthTicketManager에서 직접 조회한다.

EnterGameRequest(authKey)
         ↓
GamePacketHandler
         ↓
AuthTicketManager::Consume(authKey)

- 인증 티켓이 존재한다면 해당 LoginServer가 미리 등록한 정상적인 인증 정보라고 판단할 수 있다.

- 반대로 존재하지 않는다면 다음과 같은 경우일 수 있다.

발급되지 않은 인증 키
이미 사용한 인증 키
잘못된 인증 키

- 이 경우 InvalidAuthKey를 반환하도록 했다.


9. 인증 티켓을 일회성으로 사용하기

- 이번 인증 구조에서 중요한 부분 중 하나는 Find()가 아니라 Consume()을 사용한다는 점이다.

- Consume()은 인증 키를 찾기만 하는 것이 아니라 정상적으로 조회했다면 저장된 티켓을 제거한다.

AuthTicketManager

authKey = 10 존재
       ↓
Consume(10)
       ↓
AuthTicket 반환
       ↓
authKey = 10 제거

- 따라서 같은 인증 키를 다시 사용하면 다음 요청은 실패한다.

첫 번째 요청
authKey = 10
     ↓
인증 성공
     ↓
Ticket 제거


두 번째 요청
authKey = 10
     ↓
Ticket 없음
     ↓
인증 실패

- 이 방식은 인증 티켓을 GameServer에 입장하기 위한 일회성 증명 정보로 사용할 수 있게 만든다.

- 인증 키가 단순 조회 방식으로 계속 남아 있다면 탈취된 키 또는 이전 키를 재사용할 수 있기 때문에, 입장 과정에서는 한 번 사용한 티켓을 소비하는 편이 구조적으로 적절하다.


10. 인증 후 accountId를 Session에 저장

- 기존 GameSession은 인증 여부만 가지고 있었다.

_authenticated

- 하지만 인증 이후 게임 처리를 생각하면 단순히 이 Session이 인증되었다는 사실만으로는 부족하며, 결국 해당 Session이 어떤 계정의 연결인지 알고 있어야 한다. 

- 따라서 티켓 인증에 성공하면 티켓 안에 저장되어 있던 accountId를 GameSession에 저장하도록 변경했다.

AuthTicket

accountId
authKey
    │
    │ Consume
    ▼
GameSession
    │
    ├─ authenticated = true
    └─ accountId 저장

- 이제 이후 캐릭터 목록 조회나 캐릭터 선택, 게임 데이터 로딩 등의 기능을 구현할 때 현재 연결이 어떤 계정에 속하는지 Session을 기준으로 확인할 수 있게 된다.


11. 전체 인증 흐름 완성

아래와 같은 구조로 연결을 진행하였다. 

① Client → LoginServer

LoginRequest(accountId)
          ↓

② LoginServer

계정 검증
    ↓
authKey 생성
    ↓
GameServer에 AuthTicket 등록
    ↓

③ GameServer 내부 연결

ServerSession
    ↓
ServerPacketHandler
    ↓
AuthTicketManager::Add()
    ↓

④ LoginServer → Client

LoginResponse
 ├─ authKey
 └─ gameServerPort
    ↓

⑤ Client → GameServer

EnterGameRequest(authKey)
    ↓

⑥ GameServer

AuthTicketManager::Consume(authKey)
    ↓
인증 성공
    ↓
GameSession에 accountId 저장

- 이제 LoginServer와 GameServer를 분리한 상태에서도 Client가 LoginServer에서 받은 인증 결과를 이용해 GameServer에 입장할 수 있게 됐다.


12. 인증 구조 외에 확인한 TCP 송수신 문제

- 인증 흐름을 연결하면서 네트워크 코드 역시 다시 확인했다.

- 그 과정에서 Send Queue 구현에 문제가 하나 있었다.

- 기존 구현에서는 WSASend()를 호출한 뒤 Send Completion이 발생하면 해당 Send Buffer 전체가 전송된 것으로 보고 Queue에서 제거했다.

- 대략 다음과 같은 구조였다.

SendBuffer 100 bytes
       ↓
WSASend
       ↓
Completion
       ↓
Queue에서 제거

- 하지만 TCP 송신에서는 항상 요청한 모든 데이터가 한 번에 전송 완료된다고 가정해서는 안 된다.

- 예를 들어 100 Byte를 전송 요청했더라도 완료된 크기가 다음과 같을 수 있다.

요청 : 100 bytes
완료 : 60 bytes

- 그런데 기존 코드처럼 Completion이 발생했다는 이유만으로 Buffer를 제거하면 남은 40 Byte가 사라진다.


13. Partial Send 처리

- 이를 처리하기 위해 Send Queue에 단순히 vector<char>만 저장하지 않고 실제 전송 진행 상태도 함께 저장하도록 변경했다.

struct SendBuffer
{
    std::vector<char> buffer;
    size_t sentBytes = 0;
};

- sentBytes는 현재까지 실제로 전송 완료된 byte 수를 의미한다.

- 예를 들어 다음 데이터가 있다고 가정한다.

전체 데이터 : 100 bytes
전송 완료   : 60 bytes

- 그러면 다음 WSASend에서는 처음부터 다시 보내는 것이 아니라 위치에서 남은 40 Byte만 전송한다.

buffer + 60
100 Byte SendBuffer
        ↓
WSASend
        ↓
60 Byte 완료
        ↓
sentBytes = 60
        ↓
남은 40 Byte WSASend
        ↓
40 Byte 완료
        ↓
sentBytes = 100
        ↓
Queue 제거

즉 전체 데이터가 실제로 모두 전송된 것이 확인됐을 때만 Send Queue에서 제거하도록 변경했다.


14. 최대 패킷 크기 정의

- 패킷 처리에서도 추가적인 검증이 필요했다.

- 현재 패킷에는 전체 크기를 나타내는 size가 들어 있다.

struct PacketHeader
{
    uint16_t size;
    uint16_t opcode;
};

- 그런데 Client가 비정상적인 값을 전달할 수도 있다.

- 예를 들어, size가 50000인 값이 들어온다면, 현재 서버의 수신 버퍼 범위를 넘어서는 패킷을 기다리게 될 수 있다.

-

따라서 최대 패킷 크기를 명시적으로 정의했다.

constexpr uint16_t MAX_PACKET_SIZE = 4096;

- 그리고 RecvBuffer 역시 같은 값을 기준으로 생성하도록 변경했다.

MAX_PACKET_SIZE
      │
      ├─ 패킷 최대 크기
      │
      └─ RecvBuffer 크기

- 이를 통해 패킷 규격과 실제 수신 버퍼의 크기를 하나의 기준으로 맞췄다.


15. 비정상적인 패킷 크기 검증

- 패킷을 처리할 때는 기존에도 최소한 PacketHeader보다 작은 값인지 확인하고 있었다.

header.size < sizeof(PacketHeader)

- 여기에 최대 크기에 대한 검사도 추가했다.

header.size > MAX_PACKET_SIZE

- 즉 다음 두 경우 모두 정상적인 패킷으로 취급하지 않는다.

size < PacketHeader 크기 → 비정상

size > MAX_PACKET_SIZE → 비정상

- 비정상적인 패킷 크기가 확인되면 해당 Session의 연결을 종료하도록 했다.

- 이는 잘못된 데이터로 인해 서버가 계속해서 존재하지 않는 나머지 패킷을 기다리는 상황을 방지하기 위한 처리다.


16. TCP에서 Packet과 Recv는 동일하지 않다

- 패킷 처리 구조를 구현한 뒤 실제로 TCP 특성에 따른 수신 상황도 다시 확인했다.

- 특히 다음 두 가지 경우를 테스트했다.

 

① 하나의 패킷이 여러 번의 Recv로 들어오는 경우

Packet A

[Header][Payload----------------]
        ↓ TCP
Recv #1 : [Header][Payload 일부]
Recv #2 : [나머지 Payload]

- 첫 번째 Recv만으로 패킷이 완성되지 않았기 때문에 RecvBuffer에 데이터를 남겨둔다.

- 이후 두 번째 데이터가 들어오면 기존 데이터 뒤에 이어 붙인 뒤 전체 size가 충족됐는지 다시 확인한다.

- 완전한 패킷이 만들어진 시점에만 OnPacket()으로 전달한다.


② 여러 패킷이 한 번의 Recv에 들어오는 경우

- 반대 상황도 확인했다.

Recv #1

[Packet A][Packet B][Packet C]

- 이 경우 첫 패킷 하나만 처리하고 나머지를 버려서는 안 된다.

- ProcessPackets()에서는 반복문을 통해 현재 버퍼 안에 완전한 패킷이 존재하는 동안 계속해서 패킷을 분리한다.

RecvBuffer
    ↓
Packet A 처리
    ↓
남은 데이터 존재
    ↓
Packet B 처리
    ↓
남은 데이터 존재
    ↓
Packet C 처리

- 따라서 TCP에서 한 번의 Recv와 하나의 Packet이 일치하지 않는 상황을 모두 처리할 수 있는지 확인했다.


17. 이번 작업에서 확인한 전체 흐름

- 이번 작업으로 서버의 인증 과정은 다음과 같은 형태가 됐다.

                     ┌──────────────┐
                     │    Client    │
                     └──────┬───────┘
                            │
                     LoginRequest
                            │
                            ▼
                     ┌──────────────┐
                     │ LoginServer  │
                     └──────┬───────┘
                            │
                       authKey 생성
                            │
             ┌──────────────┴──────────────┐
             │                             │
 RegisterAuthTicket                  LoginResponse
             │                        authKey
             ▼                             │
     ┌──────────────┐                      │
     │  GameServer  │                      │
     │    :7778     │                      │
     └──────┬───────┘                      │
            │                              │
      ServerSession                        │
            │                              │
            ▼                              │
   AuthTicketManager                       │
            │                              │
       Ticket 등록                         │
            │                              │
            └──────────────┐               │
                           │               ▼
                           │            Client
                           │               │
                           │    EnterGame(authKey)
                           │               │
                           │               ▼
                           │        GameServer :7777
                           │               │
                           └──────────────▶│
                                           ▼
                                AuthTicketManager
                                           │
                                     Consume()
                                           │
                                           ▼
                                GameSession 인증
                                           │
                                      accountId 저장

- 어제 각각의 구성 요소를 만들었다면, 이를 하나의 인증 과정으로 연결하는 작업을 진행했다.


18. 아직 고려해야 할 부분

- 현재 인증 흐름 자체는 연결됐지만 실제 서비스에서 그대로 사용할 수 있는 인증 시스템은 아니다.

- 우선 현재 authKey는 단순 증가값이다.

nextAuthKey++;

- 따라서 값의 예측이 가능하다.

- 실제 인증 토큰으로 사용하려면 충분히 예측하기 어려운 값을 생성해야 하며, 티켓 자체에도 유효 시간 등의 개념을 추가할 필요가 있다.

- 또한 현재 LoginServer의 GameServerClient는 인증 티켓을 등록할 때마다 GameServer에 새 TCP 연결을 만들고 패킷을 전송한 뒤 연결을 종료하는 구조다.

- 따라서 이후 서버 간 통신이 많아진다면 LoginServer와 GameServer간 지속적인 ServerSession 형태의 장기 연결을 유지하는 구조도 필요할 수 있다.

- AuthTicketManager 역시 현재 메모리에 티켓을 저장하기 때문에 서버 재시작이나 다중 GameServer 구성을 고려하기 시작하면 별도의 인증 정보 공유 방식이 필요할 수 있다.

- 하지만 현재 단계에서는 우선 분리된 두 서버 사이에서 인증 정보를 전달하고 이를 이용해 Client Session을 인증하는 전체 흐름을 직접 구현하는 것에 초점을 맞췄다.


19. 정리

- 이번 작업에서는 전날 구성했던 LoginServer와 GameServer 인증 구조를 실제로 연결했다.

- LoginServer는 로그인 성공 시 새로운 authKey를 생성하고, GameServerClient를 통해 GameServer의 내부 Listener로 accountId와 authKey를 전달한다.

- GameServer에서는 이를 AuthTicketManager에 등록하고, 이후 Client가 GameServer 입장을 요청하면 전달받은 authKey를 실제 저장된 인증 티켓과 비교한다.

- 정상적인 티켓이라면 이를 한 번 사용한 뒤 제거하고, 해당 티켓에 들어 있던 accountId를 GameSession에 저장한다.

- 결과적으로 아래와 같은 기본 인증 흐름을 완성했다.

로그인 인증
    ↓
인증 티켓 발급
    ↓
서버 간 인증 정보 공유
    ↓
Client에게 인증 키 전달
    ↓
GameServer 입장
    ↓
인증 티켓 검증 및 소비
    ↓
Session과 Account 연결

- 또한 네트워크 계층에서는 WSASend의 Partial Send를 고려하도록 Send Queue를 수정하고, 최대 패킷 크기를 4096 Byte로 제한하여 잘못된 패킷 크기에 대한 검증을 추가했다.

- 마지막으로 하나의 패킷이 여러 Recv에 나뉘어 전달되는 경우와 여러 패킷이 하나의 Recv에 같이 전달되는 경우를 테스트하면서 이전에 구현한 RecvBuffer와 패킷 분리 구조가 정상적으로 동작하는 것도 확인했다.

Comments