I'm FanJae.

Unity 게임 개발 캠프 개인 프로젝트 8일차. 인증 키 생성과 인증 티켓 유효 시간 관리 본문

Projects/MyToyMapleServer

Unity 게임 개발 캠프 개인 프로젝트 8일차. 인증 키 생성과 인증 티켓 유효 시간 관리

FanJae 2026. 9. 22. 18:37

1. 시작에 앞서

- 이전 작업에서는 로그인 이후 캐릭터를 선택하면 LoginServer에서 인증 티켓을 생성하고, 이를 GameServer에 미리 등록한 뒤 클라이언트가 해당 인증 키를 이용해 GameServer에 접속하도록 구성했다.

- 이를 통해 다음과 같은 기본적인 입장 흐름을 만들 수 있었다.

로그인
   ↓
캐릭터 선택
   ↓
인증 티켓 발급
   ↓
GameServer에 티켓 등록
   ↓
클라이언트 GameServer 접속
   ↓
인증 티켓 확인

- 기본적인 동작은 가능했지만, 인증 티켓을 실제 입장 인증 정보로 사용하려고 보면 추가로 고려해야 하는 부분이 있었다.

- 우선 인증 키는 단순히 1, 2, 3 ... 형태로 증가하는 값을 사용하고 있었다.

- 또한 한번 발급된 인증 티켓에는 별도의 유효 시간이 없었기 때문에, 사용하지 않은 티켓이 계속 GameServer에 남을 수 있었다.

- 인증 키가 중복되는 경우 역시 별도로 처리하지 않고 있었으며, 만료된 티켓을 주기적으로 제거할 방법도 필요했다.

- 이번 작업에서는 인증 키의 생성 방식을 변경하고, 인증 티켓에 중복 검사와 유효 시간을 추가한 뒤 IOCP의 Timeout을 이용해 만료된 티켓을 주기적으로 정리하도록 구조를 보완했다.


2. 임시 캐릭터 데이터 구조 정리

- 인증 구조를 수정하기 전에 이전 작업에서 임시로 사용하고 있던 캐릭터 데이터도 먼저 정리했다.

- 기존에는 캐릭터 목록을 반환하는 부분과 캐릭터 선택이 유효한지 검사하는 부분에서 각각 별도의 값을 사용하고 있었다.

캐릭터 목록 반환
→ 1001, 1002 캐릭터 데이터 직접 작성

캐릭터 선택 검증
→ characterId == 1001 || characterId == 1002

- 기능 자체는 동작하지만 동일한 캐릭터 정보를 여러 곳에서 따로 관리하고 있었다.

- 예를 들어 테스트 캐릭터가 하나 추가된다면 다음 두 부분을 모두 수정해야 한다.

캐릭터 목록 데이터 수정
        +
캐릭터 선택 검증 조건 수정

- 이를 하나의 임시 캐릭터 목록으로 통합했다.

std::array<CharacterInfo, 2> CreateCharacters()
{
    std::array<CharacterInfo, 2> characters{};

    characters[0].characterId = 1001;
    memcpy(characters[0].name, "Warrior", strlen("Warrior"));
    characters[0].level = 10;

    characters[1].characterId = 1002;
    memcpy(characters[1].name, "Magician", strlen("Magician"));
    characters[1].level = 15;

    return characters;
}

- 캐릭터 목록을 반환할 때도 해당 데이터를 사용하고, 캐릭터 선택을 검증시 같은 데이터를 조회하도록 변경했다.

for (size_t i = 0; i < characters.size(); ++i)
{
    response.characters[i] = characters[i];
}
for (const CharacterInfo& character : characters)
{
    if (character.characterId == characterId)
        return true;
}

- 아직 실제 DB를 사용하는 단계는 아니지만, 같은 데이터를 기준으로 목록 조회와 선택 검증이 이루어지도록 정리한 것이다.


3. 기존 인증 키 생성 방식

- 이전에는 인증 키를 다음과 같이 단순 증가시키는 방식으로 생성했다.

uint64_t nextAuthKey = 1;
uint64_t authKey = nextAuthKey++;

- 테스트 단계에서는 가장 단순하게 사용할 수 있는 방식이다.

- 생성된 값 역시 다음과 같이 예측 가능하다.

1
2
3
4
5
...

- 하지만 인증 키는 클라이언트가 GameServer 입장 시 전달하는 임시 인증 정보다.

- 따라서 실제 인증 정보에 가까운 용도로 사용하려면 단순 증가 값보다는 쉽게 예측할 수 없는 값을 사용하는 것이 적절하다.

- 또한 인증 키 생성 코드가 LoginPacketHandler 내부에 존재하고 있었기 때문에 패킷 처리와 키 생성이라는 서로 다른 역할이 한 곳에 들어가 있었다.


4. AuthKeyGenerator 분리

- 인증 키 생성 역할을 별도로 관리하기 위해 AuthKeyGenerator를 추가했다.

class AuthKeyGenerator
{
public:
    bool Generate(uint64_t& authKey);
};

- 이후 캐릭터 선택 과정에서는 직접 값을 증가시키는 대신 AuthKeyGenerator에 인증 키 생성을 요청한다.

uint64_t authKey = 0;

if (!session.GetAuthKeyGenerator().Generate(authKey))
{
    return false;
}

- 전체적인 책임은 다음과 같이 나눌 수 있다.

LoginPacketHandler
      │
      │ 인증 키 필요
      ↓
AuthKeyGenerator
      │
      │ AuthKey 생성
      ↓
LoginPacketHandler
      │
      ↓
GameServer 티켓 등록

- LoginPacketHandler에서는 인증 키를 어떤 방식으로 생성하는지 알 필요 없이, 생성된 값을 받아서 인증 티켓 등록에 사용한다.

- 인증 키 생성 방식과 로그인 패킷 처리 로직을 분리한 것이다.


5. 난수 기반 인증 키 생성

- 실제 인증 키는 Windows에서 제공하는 BCryptGenRandom을 이용해 생성하도록 했다.

NTSTATUS status = BCryptGenRandom(
    nullptr,
    reinterpret_cast<PUCHAR>(&authKey),
    sizeof(authKey),
    BCRYPT_USE_SYSTEM_PREFERRED_RNG
);

- 생성하는 값은 기존 인증 티켓에서 사용하고 있던 uint64_t 형식을 그대로 유지했다.

AuthKey
→ 64bit Random Value

- 또한 기본값으로 사용하고 있는 0은 정상적인 인증 키로 발급하지 않도록 했다.

do
{
    ...
}
while (authKey == 0);

- 결과적으로 기존의 순차 증가 방식은 64비트 난수 형태로 변경되었다.

- 인증 키 자체를 암호처럼 사용하는 것은 아니지만, GameServer 입장을 허용하는 임시 인증 값인 만큼 쉽게 추측할 수 있는 순차 값보다 난수 형태가 적절하다고 판단했다.


6. 인증 티켓 중복 등록 문제

- 난수 기반으로 변경하더라도 같은 값이 절대 생성되지 않는다고 가정할 수는 없다.

- 64비트 공간에서 충돌 가능성은 낮지만, 인증 티켓을 저장하는 쪽에서도 동일한 키가 이미 존재하는지 확인할 필요가 있다.

- 기존 AuthTicketManager에서는 다음과 같이 값을 저장했다.

_tickets[authKey] = AuthTicket{ ... };

- 이 방식에서는 같은 authKey가 다시 들어오면 기존 티켓의 값이 새로운 값으로 덮어써질 수 있다.

기존 Ticket
authKey = A
accountId = 1

        ↓

새로운 Ticket
authKey = A
accountId = 2

        ↓

기존 데이터 덮어쓰기

- 인증 키를 기준으로 사용자를 구분하고 있는 상황에서는 이런 동작을 허용하지 않는 편이 적절하다.


7. emplace를 이용한 중복 등록 방지

- 이를 해결하기 위해 인증 티켓 등록 시 operator[] 대신 emplace()를 사용하도록 변경했다.

auto result = _tickets.emplace(
    authKey,
    AuthTicket{ accountId, characterId, authKey, expiresAt }
);

return result.second;

- emplace()는 동일한 Key가 이미 존재하면 새로운 값을 삽입하지 않는다.

bool Add(
    uint32_t accountId,
    uint32_t characterId,
    uint64_t authKey
);

- 성공 여부에 따라 다음과 같이 처리한다.

새로운 AuthKey
      ↓
등록 성공
      ↓
Success

기존에 존재하는 AuthKey
      ↓
등록 실패
      ↓
Failed

- GameServer에서는 이 결과를 RegisterAuthTicketResponse에 담아 LoginServer로 반환한다.

- 결과적으로 인증 키 충돌이 발생하더라도 기존 티켓을 덮어쓰지 않고 등록 실패로 처리하도록 변경했다.


8. 인증 티켓에 유효 시간이 필요한 이유

- 다음으로 고려한 부분은 '발급된 인증 티켓을 언제까지 사용할 수 있도록 할 것인가'에 있다.

- 이전 구조에서는 인증 티켓이 다음 두 경우 중 하나가 될 때까지 유지된다.

티켓 발급
   ↓
클라이언트가 사용
   ↓
Consume 후 제거

 

티켓 발급
   ↓
클라이언트가 사용하지 않음
   ↓
계속 유지

- 두 번째 경우가 문제였다.

- 예를 들어 캐릭터 선택까지 완료한 뒤 클라이언트가 GameServer에 접속하지 않거나 연결이 끊어진다면, 해당 티켓은 사용할 일이 없더라도 계속 _tickets에 남아 있게 된다.

- 또한 오래전에 발급된 인증 키를 계속 사용할 수 있도록 둘 필요도 없다.

- 인증 티켓은 GameServer에 입장하기 위한 짧은 시간 동안만 필요한 임시 인증 정보이기 때문이다.


9. 인증 티켓에 만료 시간 추가

- 이를 위해 AuthTicket에 만료 시간을 추가했다.

struct AuthTicket
{
    uint32_t accountId = 0;
    uint32_t characterId = 0;
    uint64_t authKey = 0;

    std::chrono::steady_clock::time_point expiresAt;
};

- 현재는 티켓 등록 시점을 기준으로 30초 동안 유효하도록 설정했다.

auto expiresAt =
    std::chrono::steady_clock::now()
    + std::chrono::seconds(30);

- 따라서 인증 티켓의 상태는 다음과 같이 볼 수 있다.

티켓 생성
   ↓
30초 동안 유효
   ↓
사용
또는
만료

- 시간 측정에는 std::chrono::steady_clock을 사용했다.

- 인증 티켓에서 필요한 것은 실제 날짜와 시간이 아니라 발급 이후 일정 시간이 경과했는지 여부이기 때문에 경과 시간을 측정하기 위한 Clock을 사용했다.


10. Consume 시 만료 여부 확인

- 클라이언트가 GameServer에 입장하면 인증 키를 이용해 Consume()을 호출한다.

- 기존에는 인증 키가 존재하면 바로 티켓을 반환하고 제거했다.

- 변경 이후에는 티켓을 찾은 뒤 만료 여부를 먼저 확인한다.

if (std::chrono::steady_clock::now() >= it->second.expiresAt)
{
    _tickets.erase(it);
    return false;
}

- 아직 유효한 경우에만 실제 인증 티켓을 반환한다.

ticket = it->second;
_tickets.erase(it);

return true;

- 따라서 전체 과정은 다음과 같다.

AuthKey 전달
    ↓
티켓 존재 확인
    ↓
만료 여부 확인
    ↓
 ┌──────────────┐
 │              │
유효           만료
 │              │
 ↓              ↓
티켓 반환      티켓 삭제
 │              │
 ↓              ↓
게임 입장      인증 실패

- 또한 기존과 동일하게 정상적으로 사용된 티켓 역시 바로 제거된다.

- 즉, 하나의 인증 티켓은 한 번만 사용할 수 있으며 일정 시간이 지나면 사용할 수 없도록 구성했다.


11. 사용되지 않은 만료 티켓

- Consume()에서 만료 여부를 확인하는 것만으로는 한 가지 문제가 남는다.

- 클라이언트가 티켓을 아예 사용하지 않는 경우다.

인증 티켓 생성
      ↓
클라이언트 GameServer 미접속
      ↓
Consume 호출 안 됨
      ↓
만료 여부 검사 안 됨

- 이 경우 티켓은 이미 만료되었어도 _tickets 안에 그대로 남아 있게 된다.

- 따라서 만료된 티켓을 별도로 찾아 제거할 수 있도록 CleanupExpired()를 추가했다.

void AuthTicketManager::CleanupExpired()
{
    auto now = std::chrono::steady_clock::now();

    for (auto it = _tickets.begin(); it != _tickets.end();)
    {
        if (now >= it->second.expiresAt)
        {
            it = _tickets.erase(it);
        }
        else
        {
            ++it;
        }
    }
}

- 이를 통해 인증 요청이 들어왔을 때만 만료 여부를 확인하는 것이 아니라, 사용되지 않은 티켓도 주기적으로 정리할 수 있는 구조를 만들었다.


12. 만료 티켓을 언제 정리할 것인가

- CleanupExpired()를 만들고 나면 다음 문제는 이를 언제 호출할 것인가였다.

- GameServer에서는 IOCP를 통해 네트워크 이벤트를 처리하고 있었다.

- 당시 메인 루프에서는 다음과 같이 IOCP 이벤트를 무한정 기다리고 있었다.

worker.Dispatch(INFINITE);

- INFINITE로 기다리면 네트워크 이벤트가 없는 동안에는 Dispatch()가 반환하지 않는다.

- 따라서 아래와 같은 주기적인 관리 작업을 실행하기 어렵다.

- 종료된 Session 정리
- 만료된 AuthTicket 정리
- 추후 추가될 주기 작업

- 네트워크 요청이 계속 들어온다면 루프가 반복되겠지만, 요청이 없는 상태에서도 정리 작업은 실행될 필요가 있었다.


13. IOCP Timeout 적용

- 이를 위해 GameServer의 IOCP 대기 시간을 무한 대기에서 1초로 변경했다.

worker.Dispatch(1000);

- 이제 완료된 I/O가 없다면 최대 1초 이후에는 Dispatch()가 반환하게 된다.

IOCP Dispatch
     ↓
I/O Event 존재
     ↓
이벤트 처리
     ↓
정리 작업

 

IOCP Dispatch
     ↓
1초 동안 이벤트 없음
     ↓
Timeout
     ↓
정리 작업

- 이렇게 하면 네트워크 이벤트가 없는 상태에서도 주기적으로 메인 루프로 제어권이 돌아온다.


14. Timeout과 오류 구분

- 다만 GetQueuedCompletionStatus()는 Timeout이 발생한 경우에도 실패 값을 반환한다.

- 따라서 기존 구조에서 단순히 반환값만 확인하면 다음 두 상황을 구분하기 어렵다.

실제 IOCP 오류

Timeout

- Timeout은 서버를 종료해야 하는 오류가 아니다.

- 단순히 설정한 시간 동안 완료 이벤트가 없었다는 의미이기 때문이다.

- 따라서 GetCompletion()에 timedOut 값을 별도로 추가했다.

bool GetCompletion(
    DWORD& bytes,
    ULONG_PTR& key,
    OVERLAPPED*& overlapped,
    bool& ioSuccess,
    bool& timedOut,
    DWORD timeoutMs
);

- GetQueuedCompletionStatus()가 실패하고 WAIT_TIMEOUT이 확인되면 다음과 같이 처리한다.

if (GetLastError() == WAIT_TIMEOUT)
{
    timedOut = true;
    return true;
}

- 즉, 전체 함수 호출 자체는 정상적으로 처리된 것으로 보고 Timeout 여부만 별도로 전달한다.

- IocpWorker에서도 Timeout은 실패로 처리하지 않는다.

if (timedOut)
{
    return true;
}

- 이를 통해 다음 세 가지 상황을 구분할 수 있게 되었다.

I/O 완료
→ 정상 이벤트 처리

Timeout
→ 오류가 아님
→ 메인 루프로 복귀

실제 오류
→ Dispatch 실패 처리

 


15. Timeout을 이용한 주기적인 정리

- IOCP에서 1초마다 제어권을 반환할 수 있게 되면서 GameServer의 메인 루프에서 정리 작업을 수행하도록 했다.

while (true)
{
    if (!worker.Dispatch(1000))
    {
        ...
    }

    sessionManager.Cleanup();
    authTicketManager.CleanupExpired();
}

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

IOCP 이벤트 대기
      ↓
I/O 완료 또는 1초 Timeout
      ↓
Session 정리
      ↓
만료 AuthTicket 정리
      ↓
다시 IOCP 이벤트 대기

- AuthTicketManager에서 만료 기능만 만들어두는 것으로 끝내지 않고, 실제로 해당 정리 함수가 일정한 주기로 실행될 수 있도록 서버 루프와 연결한 것이다.


16. 최종 인증 티켓 흐름

- 이번 작업 이후 인증 티켓의 전체 흐름은 다음과 같이 정리할 수 있다.

캐릭터 선택
     ↓
AuthKeyGenerator
     ↓
64bit 난수 AuthKey 생성
     ↓
LoginServer
     ↓
GameServer에 Ticket 등록 요청
     ↓
중복 AuthKey 검사
     ↓
30초 만료 시간 설정
     ↓
AuthTicket 저장

- 이후 클라이언트가 GameServer에 입장하는 경우에는 다음과 같이 동작한다.

Client
   ↓
AuthKey 전달
   ↓
GameServer
   ↓
AuthTicket 조회
   ↓
만료 여부 확인
   ↓
 ┌──────────────┐
 │              │
유효           만료
 │              │
 ↓              ↓
Consume        제거
 │              │
 ↓              ↓
GameSession    인증 실패

- 사용되지 않은 티켓 역시 IOCP Timeout을 통해 주기적으로 정리한다.

1초 Timeout
    ↓
CleanupExpired()
    ↓
만료 Ticket 제거

 


17. 변경하면서 얻은 점

- 전날까지는 인증 티켓을 이용해 LoginServer와 GameServer를 연결하는 흐름 자체를 만드는 것에 집중했다.

- 하지만 실제로 인증 티켓을 계속 관리하려고 보면 단순히 값을 생성하고 저장하는 것만으로는 부족했다.

- 기존 구조에서는 다음과 같은 문제가 있었다.

1. 예측 가능한 인증 키
2. 동일한 인증 키 등록 가능
3. 사용되지 않은 티켓이 계속 남음
4. 오래된 인증 티켓도 사용 가능
5. IOCP가 무한 대기하면 주기적인 정리가 어려움

- 이를 각각 다음과 같이 변경했다.

순차 증가 AuthKey
        ↓
난수 기반 AuthKey
------------------
덮어쓰기 방식 등록
        ↓
중복 등록 거부
------------------
무기한 Ticket
        ↓
30초 유효 시간
------------------
Consume 시에만 제거
        ↓
주기적인 만료 Ticket 정리
------------------
IOCP 무한 대기
        ↓
Timeout 기반 주기 처리

- 특히 마지막 IOCP Timeout 처리는 인증 티켓만을 위한 기능이라기보다, 네트워크 이벤트가 없는 상태에서도 서버가 일정 주기로 관리 작업을 수행할 수 있게 만들었다는 점에서 의미가 있었다.


18. 고려해 볼 만한 사항

- 현재 인증 티켓의 유효 시간은 30초로 고정되어 있다.

- 실제 서비스 환경에서는 클라이언트의 네트워크 상태나 서버 이동 과정 등을 고려하여 적절한 시간을 결정할 필요가 있다.

- 너무 짧게 설정하면 정상적인 사용자도 GameServer에 접속하기 전에 티켓이 만료될 수 있다.

유효 시간이 너무 짧음
       ↓
네트워크 지연
       ↓
GameServer 접속 전에 만료

- 반대로 너무 길게 설정하면 이미 필요하지 않은 티켓이 오랫동안 유지된다.

- 현재 CleanupExpired() 역시 1초마다 전체 티켓을 순회한다.

- 지금처럼 인증 티켓 수가 적은 단계에서는 문제가 되지 않지만, 티켓 수가 많아진다면 매번 전체 컨테이너를 순회하는 방식의 비용도 고려할 필요가 있다.

- 또한 현재 구조는 단일 GameServer 프로세스 안에 AuthTicketManager가 존재하기 때문에, 서버를 여러 대로 확장한다면 어느 GameServer가 어떤 인증 티켓을 가지고 있을 것인지에 대한 구조도 추가로 필요하다.

- 이번 단계에서는 우선 인증 티켓이 중복되지 않고 일정 시간이 지나면 사용할 수 없으며, 사용되지 않은 티켓도 서버에서 정리되는 흐름을 만드는 데 중점을 두었다.


19. 정리

- 기존에는 인증 키를 단순 증가 값으로 생성하고 있었지만, 이번 작업에서 AuthKeyGenerator를 추가하고 BCryptGenRandom을 이용한 64비트 난수 기반 인증 키 생성 방식으로 변경했다.

- 인증 키 생성 책임 역시 LoginPacketHandler에서 분리하여 별도의 객체에서 관리하도록 했다.

- GameServer의 AuthTicketManager에서는 동일한 인증 키가 이미 존재할 경우 기존 데이터를 덮어쓰지 않고 등록을 거부하도록 변경했다.

- 또한 인증 티켓에 30초의 유효 시간을 추가하고, 만료된 인증 티켓으로는 GameServer에 입장할 수 없도록 처리했다.

- 클라이언트가 사용하지 않은 인증 티켓도 제거할 수 있도록 CleanupExpired()를 추가했다.

- 이를 실제 서버에서 주기적으로 실행하기 위해 IOCP의 무한 대기를 1초 Timeout 방식으로 변경하고, Timeout을 실제 오류와 별도로 구분하도록 IOCP 처리 구조도 보완했다.

- 이후 GameServer 메인 루프에서 종료된 Session과 만료된 인증 티켓을 주기적으로 정리하도록 연결했다.


 

Comments