

2편의 주제로 Room을 골랐었다.
채팅 서버보다 조금 더 어렵고, 거대한 MMORPG 서버보다는 훨씬 작은 문제.
그런데 Room을 고르고 나니 바로 다음 문제가 생겼다.
누가 이 Room 안에 있는가?
아주 간단한 질문처럼 보인다. 클라이언트가 접속했으면 그 클라이언트가 있는 거 아닌가. 클라-서버 구조니 당연히도 네트워크 소켓이 열렸을테니 그 소켓이 곧 플레이어 아닌가.
뭐 대충 그렇게 생각할 수도 있다. 하지만 조금 더 생각해보니 이건 뭔가 이상하다.
소켓은 연결이고, Session은 그 연결 위에 서버가 붙인 런타임 정체성이다.
이 둘을 섞으면 처음엔 편하다. 그런데 Room, Ready, Battle, Leave, disconnect가 붙는 순간부터 애매해진다.
이번 글은 그 애매함을 줄이려고 Session을 따로 잡은 이야기다.

이 글은 가제: Loot of Legends 게임 서버 제작기의 세 번째 기록이다.
1편에서는 결과를 클라가 확정하게 두지 않겠다고 했다.
2편에서는 그 원칙을 붙잡을 작은 게임 상태 경계로 Room을 골랐다.
이쯤 돼서 떠오르는 질문은,
Room command를 보낸 주체를 서버는 어떻게 식별할 것인가?
단순히 소켓 번호만 보면 될까? 아니면 서버가 별도의 sessionId를 발급하고, 그 값을 기준으로 Room 안의 상태를 추적해야 할까.
나는 후자에 가까운 방향을 잡았다.

소켓은 중요하다. 소켓이 없으면 패킷도 못 받고, 응답도 못 보낸다. 연결이 끊겼는지, 읽을 수 있는지, 쓸 수 있는지 같은 것도 결국 소켓에서 시작한다.
하지만 소켓은 너무 low level의 물건이다.
게임 서버 입장에서 알고 싶은 건 보통 이런 쪽이다.
이 요청을 보낸 플레이어는 누구인가?
이 플레이어는 지금 어느 Room에 있는가?
마지막으로 패킷을 보낸 시각은 언제인가?
이 연결은 아직 유효한가?
이 플레이어의 Ready 상태는 어디 Room에 묶여 있는가?
disconnect되면 Room 상태를 어떻게 정리해야 하는가?
이 질문들에 매번 소켓으로 답하려고 하면 곧바로 골치가 아파질 것이다.
소켓은 통로다. 그럼 통로로 플레이어를 만들 수 있는가?
이걸 구분하지 않으면 서버는 어느 순간부터 “연결을 관리하는 코드”와 “게임 상태를 관리하는 코드”를 계속 헷갈리게 된다.
나는 그게 싫었다.
그래서 Session을 따로 둔다.
여기서 Session은 계정도 아니고, 캐릭터도 아니고, 유저 프로필도 아니다.
그냥 지금 게임 서버 프로세스 안에서 살아 있는 런타임 식별자에 가깝다.
처음 기준은 꽤 작았다.
sessionId
remoteKey
lastHeard

sessionId는 서버가 발급한 식별자다. Room은 결국 이 값을 보고 “누가 들어왔는지”, “누가 ready인지”, “누가 loot를 시도했는지”를 판단한다.
remoteKey는 연결을 찾기 위한 키다. 당장 TCP 단계에서는 클라이언트 연결을 구분하는 데 필요하다.
lastHeard는 마지막으로 소식을 들은 시각이다. 일정 시간 동안 아무 말이 없으면 서버는 그 session을 더 이상 살아 있다고 보기 어렵다.
딱 이 정도였다. 하지만 이 정도만 있어도 소켓과 플레이어를 직결하는 것보다는 훨씬 나은 것 같다.
처음 흐름을 단순화하면 이렇다.


새 연결이 들어온다.
서버는 그 연결을 보고 remoteKey를 만든다.
이미 알고 있는 연결이면 기존 Session을 찾고, 처음 보는 연결이면 새 Session을 만든다.
그리고 클라에게 sessionId를 알려준다.
이후 Room 쪽은 소켓을 직접 믿지 않는다. sessionId를 기준으로 “이 사람이 어느 Room에 있는지”, “지금 이 command를 받아도 되는지”를 본다.
이 흐름이 마음에 들었던 이유는 단순하다. 소켓은 네트워크 쪽에 남고, Session은 서버 런타임 경계가 되고, Room은 게임 상태를 본다.
각자의 위치가 점점 명확해진다. 이게 중요했다.
Session 하나만으로는 부족하다.
누군가는 Session을 만들고, 찾고, 지워야 한다.
그래서 SessionManager가 필요했다.
처음 역할은 대략 이랬다.
findOrCreate(remoteKey)
find(remoteKey)
findBySessionId(sessionId)
remove(remoteKey)
tick(now)
굉장히 평범하다.
그런데 이런 평범한 것들이 없으면 코드가 이상한 곳에서 복잡해진다.
Room command를 처리하는 곳에서 새 session을 만들면 이상하다. 패킷 파서가 session timeout을 처리하고 있으면 이상하다. Room이 연결의 주소를 직접 들여다보고 있으면 더 이상하다.
SessionManager가 중간에 있으면 최소한 이 선은 그을 수 있다.
연결을 Session으로 바꾸는 일: SessionManager
Session이 어느 Room에 속하는지 보는 일: RoomManager
Room 안의 상태를 바꾸는 일: Room

아직 작고, 단순하고, 고쳐야 할 부분도 많지만 그래도 최소한의 역할은 나눴다.
Session을 만들었으면 클라에게 알려줘야 한다.
그래서 처음 들어가는 패킷 중 하나가 Welcome이었다.
이름 그대로,
서버 -> 클라: 너의 sessionId는 이거임.
이라고 알려주기 위한 프로세스다. 이게 있어야 클라도 “아, 내가 서버에서 어떤 식별자로 보이는구나”를 알 수 있다.
다시 한 번 강조하지만, Session을 계정처럼 말하면 과장이 된다. 반대로 Session을 소켓처럼 말하면 실제 의도보다 너무 low하게 느껴진다.
내가 잡고 싶었던 위치는 그 사이였다.
Session은 Room과 만나는 순간부터 의미가 생긴다. Room을 만들 때도 sessionId가 필요하다. Room에 들어갈 때도 sessionId가 필요하다. Ready를 누를 때도 sessionId가 필요하다.
나중에 템을 먹으려고 해도 결국 “어느 session이 시도했는가”를 봐야 한다. 그러니까 2편에서 Room을 골랐다면, 3편에서 Session을 잡는 건 꽤 자연스러운 순서였다.
Room은 상태의 경계다.
Session은 그 상태를 건드리려는 주체의 경계다.
이 둘이 붙어야 “누가 어떤 상태를 바꾸려고 하는가”가 보인다.

나는 소켓을 Player1처럼 다루고 싶지 않았다. 그래서 Session을 두었다.
소켓과 Room 사이에 있는 얇지만 없으면 금방 코드가 흐려지는 경계인 것이다.