| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | 2 | 3 | ||||
| 4 | 5 | 6 | 7 | 8 | 9 | 10 |
| 11 | 12 | 13 | 14 | 15 | 16 | 17 |
| 18 | 19 | 20 | 21 | 22 | 23 | 24 |
| 25 | 26 | 27 | 28 | 29 | 30 | 31 |
- git
- docker
- BOJ
- Photon
- team project
- multi-thread
- design pattern
- Online Judge
- Network Programming
- Data Structure
- System Programming
- Toy Project
- PS
- C++
- c#
- Unity
- 독서
- Today
- Total
I'm FanJae.
Unity 게임 개발 캠프 개인 프로젝트 6일차. LoginServer와 GameServer 인증 연동 및 네트워크 송수신 안정성 보완 작업 본문
Unity 게임 개발 캠프 개인 프로젝트 6일차. LoginServer와 GameServer 인증 연동 및 네트워크 송수신 안정성 보완 작업
FanJae 2026. 9. 19. 22:241. 시작에 앞서
- 이전 작업에서는 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와 패킷 분리 구조가 정상적으로 동작하는 것도 확인했다.
'Projects > MyToyMapleServer' 카테고리의 다른 글
| Unity 게임 개발 캠프 개인 프로젝트 7일차. Overlapped I/O - 요청과 완료가 분리되는 구조 (개인 공부 내용) (0) | 2026.09.21 |
|---|---|
| Unity 게임 개발 캠프 개인 프로젝트 7일차. 캐릭터 선택 이후 게임 서버 입장 흐름 구현 (0) | 2026.09.20 |
| Unity 게임 개발 캠프 개인 프로젝트 6일차. Non-Blocking Socket과 Polling의 한계 (개인 공부 내용) (0) | 2026.09.19 |
| Unity 게임 개발 캠프 개인 프로젝트 5일차. LoginServer · GameServer 인증 구조 구성 (0) | 2026.09.19 |
| Unity 게임 개발 캠프 개인 프로젝트 4일차. IOCP 기반 비동기 네트워크 처리 구조 구현 (0) | 2026.09.17 |