카테고리 없음

면접 준비 Game Server Feat.Neon Rush

LetsCoding 2026. 10. 15. 03:28

1. 면접 답변용: “위치 동기화를 어떻게 보간했나요?”
초기에는 서버에서 수신한 위치와 회전을 transform에 즉시 적용했는데, 패킷 자체는 50Hz로 충분히 자주 오더라도 실제 도착 간격은 네트워크 지터 때문에 일정하지 않아 원격 차량이 순간적으로 끊겨 보이는 문제가 있었습니다.

그래서 수신한 Position과 Rotation을 바로 적용하지 않고 targetPosition, targetRotation으로 저장한 뒤, 렌더링 프레임마다 Vector3.Lerp와 Quaternion.Slerp를 이용해 현재 Transform이 목표값을 따라가도록 구성했습니다.

이후에는 고정된 보간 속도만 사용하면 빠른 차량에서는 뒤처지고, 저속에서는 불필요한 미세 보정이 발생할 수 있어 이전 수신 위치와 현재 수신 위치의 거리, 패킷 도착 시간 차이를 이용해 추정 속도를 계산하고, 속도가 높을수록 보간 속도를 증가시키도록 개선했습니다.

한 가지 기술적인 디테일로, Lerp(current, target, deltaTime * smoothing)은 엄밀히 말하면 스냅샷 A와 B 사이를 시간축으로 재생하는 전통적인 snapshot interpolation이라기보다 목표값을 추종하는 exponential smoothing에 가까워.


그래서 면접에서 너무 세게
“정확한 Time-based Snapshot Interpolation을 구현했습니다.”
라고 말하기보다는,
“수신 Transform을 목표값으로 두고 프레임 단위 보간을 적용했으며, 이동 속도에 따라 추종 속도를 동적으로 조정했습니다.”
라고 말하는 게 정확해.


Q. 50Hz로 보내는데 왜 끊겨 보였나요?
송신 주기가 일정하다고 수신 주기까지 일정한 것은 아니기 때문입니다.

서버가 20ms마다 보내더라도 실제 클라이언트에서는:
20ms
21ms
18ms
45ms
 5ms
20ms

처럼 패킷이 불규칙하게 도착할 수 있어.
그걸 바로:
transform.position = receivedPosition;

으로 적용하면:
현재 위치 --------> 다음 위치
          순간 이동

처럼 보여.
반면 보간하면:
현재 ──→──→──→ 목표 위치

형태로 렌더링하니까 시각적인 끊김을 줄일 수 있는 거지.

Q. 면접에서 “Snapshot Interpolation 구현했나요?”라고 물으면:
“정확한 snapshot buffer 기반 interpolation은 아니고, 최신 수신 Transform을 target으로 두고 Lerp/Slerp로 추종하는 smoothing 구조입니다.”

Q. 왜 그렇게 했어요?
“프로젝트 규모와 당시 요구사항에서는 Snapshot Buffer까지 두는 방식보다 최신 Transform을 바로 목표값으로 삼아 smoothing하는 방식이 구현 복잡도와 체감 품질 사이에서 적절하다고 판단했습니다.
차량 Transform을 50Hz로 비교적 자주 수신하고 있었고, 가장 큰 문제는 위치 정확도 자체보다 패킷 도착 간격의 불균형 때문에 발생하는 시각적 Jitter였습니다. 그래서 우선 Lerp/Slerp로 최신 값을 부드럽게 추종하고, 속도에 따라 smoothing 값을 조절하는 방식으로 해결했습니다.
Snapshot Interpolation은 여러 Snapshot을 버퍼링한 뒤 과거 시점을 재생하기 때문에 지터에는 더 안정적이지만, 의도적인 렌더링 지연과 버퍼 관리 복잡도가 추가됩니다. 당시에는 레이싱 게임에서 원격 차량의 반응성을 너무 늦추지 않는 것도 중요해서 이 방식을 선택했습니다.”

 

핵심은 세 가지 Trade-off야.

내 방식
Latest Transform + Lerp/Slerp

장점
→ 구현이 단순함
→ 추가 버퍼가 필요 없음
→ 최신 위치를 빠르게 추종
→ 렌더링 지연이 비교적 적음

단점
→ 네트워크 Jitter가 심하면 완전히 일정한 움직임을 보장하기 어려움
→ 최신 패킷 하나에 의존

반면 Snapshot Interpolation은:

Snapshot A
Snapshot B
Snapshot C
        ↓

일부러 약간 과거 시점을 렌더링
        ↓

A ───── interpolation ───── B

이렇게 여러 패킷을 확보한 뒤 그 사이를 보간하니까 더 안정적이지만, 예를 들어 100ms 정도 버퍼를 둔다면 원격 플레이어가 실제보다 약 100ms 과거 상태로 보이는 비용이 생길 수 있어.

그리고 꼬리질문으로:

“지금 다시 만든다면 똑같이 구현할 건가요?”

라고 하면 이렇게 답하면 좋아.

“프로토타입이나 소규모 멀티플레이에서는 현재 구조도 충분히 선택할 수 있다고 생각합니다. 다만 실제 서비스 수준에서 네트워크 품질 변화까지 안정적으로 대응해야 한다면 Timestamp를 포함한 Snapshot을 일정 개수 버퍼링하고, 일정한 interpolation delay를 두어 두 Snapshot 사이를 시간 기준으로 보간하는 구조로 개선할 것 같습니다. 패킷이 잠시 끊기는 경우에는 짧은 구간에 한해 extrapolation을 적용하는 것도 검토할 수 있습니다.”

이 답이 좋은 이유는 네가 현재 구현의 한계까지 정확하게 알고 있다는 것을 보여주기 때문이야.

면접용으로 한 줄만 외운다면:

“50Hz로 최신 Transform을 충분히 자주 받고 있었기 때문에, 추가 지연과 복잡도가 있는 Snapshot Buffer 대신 최신 값을 동적으로 smoothing하는 방식으로 먼저 Jitter를 해결했고, 더 높은 네트워크 안정성이 필요하다면 Snapshot Interpolation으로 확장할 수 있다고 판단했습니다.

 

 

면접 꼬리질문 정리

왜 Input Relay에서 위치 오차가 누적됐나요? 같은 입력을 전달해도 Unity Rigidbody 물리는 완전히 결정론적이지 않고, 클라이언트별 프레임/물리 실행 시점과 패킷 도착 상태도 다르기 때문에 각 클라이언트가 조금씩 다른 위치를 계산했습니다. 작은 차이가 계속 누적되면서 위치·회전 오차가 커졌습니다.
50Hz로 보내는데 왜 Jitter가 생겼나요? 50Hz는 송신 주기일 뿐 실제 수신 간격이 항상 20ms라는 뜻은 아닙니다. 네트워크 latency와 jitter 때문에 패킷이 불규칙하게 도착할 수 있고, 수신 위치를 즉시 적용하면 그 불규칙성이 화면상 순간이동처럼 보일 수 있습니다.
Lerp/Slerp는 어떻게 적용했나요? 수신한 최신 Position과 Rotation을 targetPosition, targetRotation으로 저장하고, 매 렌더링 프레임마다 현재 위치는 Vector3.Lerp, 회전은 Quaternion.Slerp로 목표값을 따라가도록 했습니다.
왜 Lerp와 Slerp를 구분했나요? Position은 Vector3이므로 두 위치 사이를 선형 보간하는 Lerp를 사용했고, Rotation은 Quaternion이므로 회전 보간에 적합한 Slerp를 사용했습니다.
Dynamic Smoothing은 왜 넣었나요? 고정된 보간 속도에서는 고속 차량이 목표 위치를 너무 늦게 따라갈 수 있습니다. 연속 수신 위치의 거리와 도착 시간 차이로 속도를 추정하고, 빠를수록 smoothing 값을 높여 목표 Transform을 더 빠르게 추종하도록 했습니다.
오차가 너무 커지면 어떻게 했나요? 일정 거리 이상 벌어지면 계속 Lerp하는 것보다 잘못된 상태가 오래 보일 수 있기 때문에 teleportThreshold를 두고 임계치를 넘으면 목표 위치로 즉시 보정했습니다.
Snapshot Interpolation은 왜 안 썼나요? 프로젝트 규모에서는 50Hz Transform 송신과 최신 Transform 기반 smoothing만으로도 문제를 해결할 수 있었고, Snapshot Buffer를 추가하면 구현 복잡도와 의도적인 렌더링 지연이 생깁니다. 다만 네트워크 품질 변화까지 더 안정적으로 대응해야 한다면 Timestamp 기반 Snapshot Buffer 방식으로 개선할 수 있습니다.
그러면 이 방식은 Server Authoritative인가요? 아닙니다. 현재 구조는 클라이언트가 자기 Transform을 계산하고 서버가 이를 중계하므로 Client-authoritative Transform + Server Relay에 가깝습니다. 서버가 직접 물리를 계산하는 완전한 Server Authoritative 구조는 아닙니다.
그럼 치팅에 취약하지 않나요? 맞습니다. 클라이언트가 거짓 위치를 보낼 가능성이 있습니다. 그래서 기존 Input을 함께 유지하면 서버에서 이전 위치, 시간, 입력을 비교해 최대 속도·가속도·회전량·비정상 순간이동 등을 검증하는 방식으로 확장할 수 있습니다.
왜 Input 패킷도 남겨뒀나요? Transform만 있으면 “어디로 이동했는지”만 알 수 있지만 Input까지 있으면 “어떤 입력으로 그 결과가 나왔는지”를 비교할 수 있습니다. 그래서 비정상 이동 검증과 물리·네트워크 디버깅에 활용할 수 있습니다.
리플레이에는 어떻게 활용하나요? Unity 물리가 비결정적이라 Input만 재생하면 동일 결과가 보장되지 않습니다. 그래서 Timestamp와 Position/Rotation Snapshot을 저장하고, 재생할 때 Snapshot 사이를 Lerp/Slerp하는 방식이 더 안정적입니다. Input은 추가 분석 데이터로 함께 저장할 수 있습니다.

특히 마지막 두 개는 이렇게 기억하면 편해.

Input
= "왜 이렇게 움직였나?"

Transform
= "실제로 어디로 움직였나?"

둘을 같이 가지고 있으면:

Input + Transform
      ↓
정상 움직임인가?
      ↓
치팅 검증 / 디버깅

이 가능해지는 거야.

그리고 면접에서 **“Transform Relay라는 표현이 정확합니까?”**라고 집요하게 물으면 이렇게 답하면 가장 깔끔해.

“Unity의 Transform 전체를 직렬화한다는 의미보다는, Transform을 구성하는 상태 중 멀티플레이에 필요한 Position과 Rotation을 서버를 통해 중계한다는 의미로 사용했습니다. Scale은 동기화 대상에 포함하지 않았습니다.

 

 

면접에서는 “Quaternion을 쓰는 이유 → Euler Angle의 한계 → Gimbal Lock → 내 프로젝트에서 왜 Slerp를 썼는지” 순서로 답하면 깔끔해.

Q. 왜 회전에 Quaternion을 사용했나요?

3D 회전을 Euler Angle로 표현하면 각 축의 회전을 순차적으로 적용하는 과정에서 특정 자세에서 두 회전축이 겹치는 Gimbal Lock 문제가 발생할 수 있습니다.
Quaternion은 3차원 회전을 4개의 값 (x, y, z, w)으로 표현하여 Euler Angle의 이런 특이점을 피할 수 있고, 두 회전 사이를 부드럽게 보간하기에도 적합합니다.

제 프로젝트에서는 네트워크로 받은 원격 차량의 회전을 부드럽게 적용해야 했기 때문에 Quaternion.Slerp()를 사용했습니다.

Unity에서도 Transform.rotation 자체가 Quaternion이기 때문에 자연스러운 선택이기도 해.


Q. Gimbal Lock이 뭔가요?

쉽게 말하면:

Euler Angle로 3차원 회전을 표현할 때 특정 회전 상태에서 두 회전축이 겹쳐 하나의 회전 자유도를 잃는 현상이야.

예를 들어 Euler Angle을:

X = Pitch
Y = Yaw
Z = Roll

이라고 하자.

원래는:

X축 회전
Y축 회전
Z축 회전

세 개가 독립적이어야 하는데, 특정 축을 90도로 회전하면 두 축이 같은 방향으로 정렬되는 상황이 발생할 수 있어.

개념적으로:

정상 상태

X ─→
Y ↑
Z ↗

각 축이 서로 다른 방향

이었던 게 특정 회전 이후:

X ─→
Y ↑
Z ─→

처럼 두 축의 회전 효과가 겹쳐버리는 거야.

그러면 원래 3 Degrees of Freedom이던 것이 실질적으로 2 Degrees of Freedom처럼 되어 특정 방향의 회전을 독립적으로 표현하기 어려워져.

이게 Gimbal Lock.


Q. Quaternion은 왜 Gimbal Lock을 피할 수 있나요?

여기서 면접에서 이렇게 말하면 좋아.

Euler Angle은 X/Y/Z 축 회전을 순서대로 적용해서 방향을 표현하지만, Quaternion은 회전 자체를 하나의 4차원 표현으로 나타내기 때문에 Euler Angle처럼 축이 겹치는 특이점에 의존하지 않습니다.

중요한 건:

**“Quaternion은 축 4개를 사용해서 Gimbal Lock이 없다”**라고 설명하면 안 돼.

x, y, z, w는 단순히 4개의 회전축이 아니야. Quaternion은 수학적으로 회전을 표현하는 다른 방식이야.

그리고 Quaternion을 쓰더라도 중간에 계속:

transform.eulerAngles

로 변환해서 Euler 기반 연산을 해버리면 Euler 표현의 문제를 다시 만날 수 있어.

 

 

Q. "Unity에서도 Euler Angle을 사용하면 무조건 Gimbal Lock이 발생하나요?"

라고 물으면 

“Unity의 Transform 회전 자체는 내부적으로 Quaternion으로 관리됩니다. 다만 Euler Angle을 이용해 축별 회전을 누적하거나 특정 회전 순서에 의존하는 로직을 작성하면 Euler 표현의 특이점인 Gimbal Lock 문제를 만날 수 있습니다. 그래서 연속적인 3D 회전이나 회전 보간에는 Quaternion을 사용하는 것이 안전합니다.”

 

 

Q. 그런데 왜 Lerp가 아니라 Rotation에는 Slerp를 사용했나요?

네 프로젝트하고 연결하면 이 답이 좋아.

위치는 Vector3이므로 Vector3.Lerp()를 이용해 두 점 사이를 보간했습니다. 반면 회전은 Quaternion으로 표현되어 있기 때문에 Quaternion.Slerp()를 사용했습니다. Slerp는 두 Quaternion 사이를 구면 선형 보간하여 회전 변화가 자연스럽게 이어지도록 할 수 있습니다.

즉:

// 위치
transform.position =
    Vector3.Lerp(
        transform.position,
        targetPosition,
        t);

// 회전
transform.rotation =
    Quaternion.Slerp(
        transform.rotation,
        targetRotation,
        t);

여기서 핵심은:

Position
Vector3
→ Lerp

Rotation
Quaternion
→ Slerp

야.


Q. Euler Angle은 그러면 쓰면 안 되나요?

아니야. 이것도 자주 나오는 함정 질문이야.

Euler Angle은 사람이 30°, 90°, 0°처럼 이해하고 편집하기 쉬워서 UI나 Inspector, 특정 축의 각도 제어 등에 유용합니다. 다만 실제 3D 회전 누적이나 회전 보간에서는 Quaternion을 사용하는 것이 더 적합합니다.

예를 들어:

// 사람이 이해하기 편함
Vector3 euler = new Vector3(0, 90, 0);

하지만 내부 회전 적용은:

Quaternion rotation =
    Quaternion.Euler(0, 90, 0);

식으로 하는 거지.


네 프로젝트 기준 30초 답변

이거 하나 외워도 충분해.

“원격 차량의 회전은 Unity의 Transform.rotation과 동일하게 Quaternion으로 처리했습니다. Euler Angle은 축별 회전을 순차적으로 표현하기 때문에 특정 자세에서 두 축이 겹쳐 자유도 하나를 잃는 Gimbal Lock이 발생할 수 있습니다. Quaternion은 이런 Euler 표현의 특이점을 피하면서 3D 회전을 안정적으로 표현할 수 있고, Quaternion.Slerp()를 이용해 네트워크로 수신한 현재 회전과 목표 회전 사이를 자연스럽게 보간할 수 있어서 사용했습니다.”

 

Q. 쿼터니언의 (x,y,z,w) 가 정확히 뭐죠?

즉 개념적으로는:

(x, y, z)
→ 회전축 방향과 관련된 벡터 부분

w
→ 회전각과 관련된 스칼라 부분

이야.

 

“Quaternion의 x, y, z는 회전축에 sin(θ/2)를 곱한 벡터 성분이고, w는 cos(θ/2)에 해당하는 스칼라 성분입니다. 따라서 네 값은 각각 독립적인 회전각이 아니라 하나의 3차원 회전을 함께 표현하는 값입니다.”

 

그래서 면접에서 정말 짧게 말하면:

“Quaternion은 회전축과 회전각을 4개의 값으로 변환해 표현한 방식이고, xyz는 축과 관련된 벡터 성분, w는 각도와 관련된 스칼라 성분입니다.”

 

 

2. PlayerInput → 0.9 Bytes(7bits)는 표현을 바꾸는 게 좋음

7bit / 8 = 0.875 byte라고 수학적으로 말할 수는 있지만, 실제 네트워크 버퍼는 일반적으로 byte 단위로 다뤄.

따라서 7bit를 사용하더라도 실제 byte 배열에는 최소:

1 byte

가 필요해.

그래서:

약 0.9 Bytes

보다는:

유효 정보 7bit, 최소 1byte에 패킹

 

게다가 서버가 다른 클라이언트에게 브로드캐스트할 때는 누구의 Transform인지 식별할 ClientId도 필요해. 실제 클라이언트 수신 코드도 TransformUpdate에서 clientId를 읽고 Position 3개와 Quaternion 4개를 읽고 있어. 붙여넣은 텍스트(1)

그래서 포트폴리오에서는:

Position 3개 + Quaternion 4개로 Transform 데이터 자체는 28byte이며, PacketType과 ClientId 등의 프로토콜 필드를 포함하면 실제 애플리케이션 payload는 그보다 커집니다.

라고 쓰는 게 안전하고 정확해.

 

 

 

클라이언트 OnNetworkReceive()에서 매번:

byte[] data = new byte[length];
Array.Copy(...);

를 하고 있어. 붙여넣은 텍스트(1)

서버도 마찬가지로:

byte[] data = new byte[length];
Array.Copy(...);

를 수행하고 있어. NetworkManager

즉 패킷마다 managed byte[] 할당이 발생할 수 있으므로 Zero-GC 구조는 아니야.

따라서:

LiteNetLib와 함께 활용해 GC 부하 발생 없이

는 삭제해야 해.

대신:

JSON 문자열 생성·파싱 및 객체 직렬화에 필요한 할당을 줄이고, 필요한 데이터만 binary payload로 직렬화하여 GC 부담을 낮추는 방향으로 구성했습니다.

 

 

5. 서버 CPU 부하와 병목이 감소했다도 측정 여부가 중요함

JSON은 보통:

숫자 → 문자열 변환
필드 이름 포함
문자열 parsing
JSON tokenizer/parser
객체 생성

등의 비용이 들어가고,

 

Binary protocol은:

정해진 위치에서
정해진 bit/byte만 읽기

때문에 훨씬 단순할 수 있어.

하지만 실제로:

CPU 병목이 감소했다

라고 결과로 쓰려면 CPU profiling 데이터가 있는 게 가장 좋아.

없다면:

문자열 파싱과 불필요한 객체 생성을 줄여 직렬화/역직렬화 비용을 낮출 수 있도록 구성했습니다.

로 써.

 

설계상 기대효과와 측정된 성과는 구분하는 게 포트폴리오에서 굉장히 중요해.

BitWriter / BitReader 기반의 커스텀 binary protocol을 통해 게임에서 필요한 상태만 정해진 bit 수로 직렬화했습니다. 예를 들어 방향 입력은 Horizontal의 좌/중립/우 상태를 2bit, Vertical의 입력 여부를 1bit로 표현하여 총 3bit로 양자화했습니다.

이를 통해 두 float 입력값을 그대로 전송하는 대신 필요한 의미 정보만 하나의 byte에 패킹했으며, JSON 방식에서 발생하던 필드명·문자열 표현 등의 payload 오버헤드와 문자열 parsing/객체 생성 비용을 줄였습니다.

Position/Rotation과 같이 정밀도가 필요한 값은 float 기반 binary 데이터로 유지하고, PacketType이나 Input/Effect 등 표현 가능한 상태 수가 제한적인 정보에 대해서만 bit packing을 적용하여 정확성과 전송 효율 사이의 균형을 맞췄습니다.

성과 부분은:

입력 데이터의 경우 두 float 기준 64bit의 정보를 그대로 전송하는 대신 실제 필요한 상태를 3bit로 표현했으며, PacketType 등의 프로토콜 정보를 포함한 소형 binary packet으로 구성했습니다. Transform은 Position 3개와 Quaternion 4개를 7개의 float로 전송하여 JSON의 필드명과 문자열 표현 오버헤드를 제거했습니다.

전체 wire traffic은 IP/UDP/LiteNetLib 헤더의 영향을 받으므로 payload 감소율과 동일하지 않지만, 고빈도로 송수신되는 실시간 데이터의 애플리케이션 payload를 줄이고 JSON parsing 및 불필요한 할당 비용을 감소시키는 방향으로 최적화했습니다.

 

 

현재 서버의 역할

 

전체 아키텍처를 한 문장으로 정리하면:

“세션과 게임 진행은 서버 중심으로 관리하되, 고비용인 차량 물리 시뮬레이션은 클라이언트에서 수행하고 Transform/Input/Effect를 서버가 세션 단위로 중계하도록 구성한 경량 멀티플레이 서버입니다.”

“서버에서 순위 계산은 일원화했지만 점수 생성 자체까지 완전한 server-authoritative 구조로 만들지는 않았습니다.”

 

4. 이 서버가 경량화된 이유도 최신 코드에서 확인 가능함

서버 자체에는 Unity Rigidbody, Physics Simulation, 렌더링 같은 게 없어.

Main loop도:

while (true)
{
    server.PollEvents();
    Thread.Sleep(15);
}

형태야. NetworkManager

즉 서버의 고빈도 작업은 대부분:

패킷 수신
→ PacketType 해석
→ Dictionary lookup
→ GameSession lookup
→ Event
→ 다른 Peer에게 패킷 전송

정도야.

차량 물리를 서버에서 50Hz로 8대씩 직접 계산하는 것보다 훨씬 가벼운 구조라고 볼 수 있어.

 

 

6. 서버 사양/비용은 실제로 어떻게 잡나?

공식만으로 결정하지 않고 계산으로 대략 추정한 다음 Load Test로 결정해.

개념식은:

Ingress msgs/sec
= CCU × 클라이언트당 송신률

Broadcast Egress msgs/sec
= Session 수
× Player 수
× (Player 수 - 1)
× Broadcast Hz

Bandwidth
≈ msgs/sec × 평균 wire packet size

야.

그다음 Bot client를 만들어:

50 CCU
100 CCU
200 CCU
500 CCU
...

로 올리면서 실제 서버의:

CPU 사용률 / RAM / GC / Network Mbps / PPS / packet loss / p95·p99 latency

를 측정해서 한 서버당 안전한 CCU를 결정해.

그래서 포트폴리오에서는:

“서버 비용을 줄이기 위해 Relay 방식을 사용하여 CPU 연산량을 낮췄다.”

는 충분히 합리적인 설명이고,

더 정확하게는:

“서버에서 차량 물리를 직접 계산하지 않아 CPU 비용을 줄이는 대신, 세션 단위 Broadcast로 인한 네트워크 Egress가 주요 확장 비용이 되는 구조를 선택했다.”

라고 말하면 꽤 깊이 이해하고 있는 답이 돼.

 

최신 코드에서 추가로 눈에 띄는 부분 두 개

서버 구조 자체는 이전 파일보다 훨씬 완성되어 있어. 다만 면접 전에 알아두면 좋은 게 있어.

Matchmaker의 System.Timers.Timer는 별도 ThreadPool thread에서 실행될 수 있는데, waitingPlayers와 sessions는 일반 List/Dictionary야. 네트워크의 AddPlayer/RemovePlayer와 Timer의 TryStartMatch()가 동시에 접근하면 Race Condition 가능성을 검토해야 해. Matchmaker Matchmaker

또 GameSession의:

gamePlayTime = 180000; // 5분초

는 실제로는 180초 = 3분이야. 5분을 의도했다면 300000ms가 맞아. GameSession

둘 다 아키텍처 문제라기보다는 코드 리뷰에서 잡힐 수 있는 디테일이라 미리 알고 있으면 좋아.

최신 코드 기준으로는 이제 “4bit 프로토콜 불일치” 문제는 없고, 서버 구조는 Session-authoritative + Client-authoritative movement relay라고 설명하는 게 가장 정확하다.

 

 

따라서 네 최적화는 이런 구조라고 보면 돼.

전송 빈도 최적화
Input       20Hz
Transform   50Hz
       ↓
PPS 결정


Payload 최적화
JSON → Binary / Bit Packing
       ↓
packet당 byte 감소
       ↓
Bandwidth 감소

그래서 면접에서:

“작은 패킷이면 PPS가 높아져서 오히려 안 좋은 것 아닌가요?”

라고 하면 이렇게 답하면 좋아.

“패킷 크기 감소 자체가 PPS를 증가시키는 것은 아닙니다. PPS는 전송 빈도에 의해 결정됩니다. 제 프로젝트에서는 Input 20Hz, Transform 50Hz로 전송 빈도를 별도로 제한했고, 그 주기 안에서 payload를 bit packing하여 packet당 전송량을 줄였습니다. 다만 지나치게 작은 패킷을 높은 빈도로 보내면 고정 헤더와 packet processing 비용 때문에 비효율적일 수 있기 때문에, payload 크기뿐 아니라 전송 주기와 PPS도 같이 고려해야 합니다.”


  1. System.Timers.Timer 때문에 Race Condition이 생길 수 있다는 건 무슨 뜻인가?

이건 최신 서버에서 실제로 신경 쓸 만한 부분이야.

현재 서버의 기본 네트워크 처리는:

while (true)
{
    server.PollEvents();
    Thread.Sleep(15);
}

에서 실행돼. NetworkManager

즉 보통:

Server Main Thread
        ↓
PollEvents()
        ↓
OnNetworkReceive()
        ↓
Matchmaker.AddPlayer()
또는
Matchmaker.RemovePlayer()

흐름이야.

그런데 Matchmaker에서는:

matchmakingTimer =
    new System.Timers.Timer(maxWaitTime * 1000);

matchmakingTimer.Elapsed += (sender, e) =>
{
    ...
    TryStartMatch(force: true);
};

를 사용하고 있어. Matchmaker

System.Timers.Timer.Elapsed는 네 서버의 PollEvents() 루프와는 별도의 스레드에서 실행될 수 있어.

그러면 이런 일이 가능해져.

Thread A
NetworkManager / PollEvents
        ↓
AddPlayer()

동시에

Thread B
Timer.Elapsed
        ↓
TryStartMatch()

그런데 둘 다 같은:

private readonly List<Player> waitingPlayers;

를 건드려. Matchmaker

예를 들어 현재 대기 인원이 7명이라고 해보자.

waitingPlayers
[A B C D E F G]

그 순간 두 스레드가 동시에 실행돼.

Network Thread

AddPlayer(H)
    ↓
waitingPlayers.Add(H)


Timer Thread

TryStartMatch(true)
    ↓
waitingPlayers.Take(...)
    ↓
RemoveRange(...)

List<T>는 기본적으로 thread-safe가 아니기 때문에, 한쪽에서 Add하는 동안 다른 쪽에서 Take, Count, RemoveRange 같은 작업을 하면 데이터 상태가 일관되지 않을 수 있어.

더 위험한 부분도 있어.

TryStartMatch()는:

var matchPlayers =
    waitingPlayers.Take(MatchSize).ToList();

waitingPlayers.RemoveRange(
    0,
    matchPlayers.Count);

nextClientId = 0;

까지 해. Matchmaker

동시에 AddPlayer()는:

ushort assingedId = nextClientId++;

...

waitingPlayers.Add(player);

를 하고 있어. Matchmaker

그래서 타이밍이 나쁘면:

Network Thread
nextClientId++


Timer Thread
nextClientId = 0

가 겹칠 수도 있어.

그러면 원래:

0 1 2 3 4 ...

식으로 부여하려던 ID 상태가 예상과 다르게 될 가능성도 생겨.

그리고 더 중요한 부분은 Timer thread의 TryStartMatch()가:

NetworkManager.Instance.RegisterSession(session);

까지 호출한다는 거야. Matchmaker

그 RegisterSession()은 다시:

playerToSession[player] = session;

으로 NetworkManager의 Dictionary를 수정해. NetworkManager

그런데 동시에 Network thread에서는:

playerToSession.TryGetValue(...)

를 하고 있을 수 있지.

즉 상황이:

Thread A
Dictionary 읽는 중

Thread B
Dictionary 수정 중

이 될 수 있어.

일반 Dictionary<TKey,TValue>도 여러 스레드의 동시 읽기/쓰기 용도로 설계된 자료구조가 아니야.


그럼 lock을 걸면 되나?

가능해.

예를 들어:

private readonly object _sync = new();

public void AddPlayer(Player player, ...)
{
    lock (_sync)
    {
        waitingPlayers.Add(player);
        ...
    }
}
private void TryStartMatch(...)
{
    lock (_sync)
    {
        ...
    }
}

처럼 할 수 있어.

하지만 이 프로젝트에서는 더 좋은 방법이 있어.

서버가 이미:

while (true)
{
    server.PollEvents();
    Thread.Sleep(15);
}

라는 하나의 main loop를 가지고 있잖아.

그러면 굳이 System.Timers.Timer로 새로운 thread를 만들지 않고 매치 타이머도 같은 서버 thread에서 체크하는 것이 훨씬 단순해.

예를 들어 개념적으로:

while (true)
{
    server.PollEvents();

    matchmaker.Update();

    Thread.Sleep(15);
}

그리고:

public void Update()
{
    if (!isMatchmakingTimerRunning)
        return;

    if (DateTime.UtcNow >= matchmakingDeadline)
    {
        TryStartMatch(force: true);
        isMatchmakingTimerRunning = false;
    }
}

처럼 만드는 거야.

그러면:

Network Receive
AddPlayer()
RemovePlayer()
TryStartMatch()
RegisterSession()

전부:

Server Main Thread 하나

에서 순서대로 실행돼.

즉:

멀티스레드

Thread A → AddPlayer
Thread B → TryStartMatch
              ↓
          Race 가능

를:

Single Thread

AddPlayer
   ↓
끝
   ↓
Update
   ↓
TryStartMatch

로 바꾸는 거야.



 

굳이 ConcurrentDictionary, lock, 복잡한 synchronization을 넣기보다,

게임/세션 상태를 하나의 서버 스레드에서만 수정하도록 설계한다

 

“당시에는 매치메이킹 timeout을 System.Timers.Timer로 구현했는데, Timer callback은 네트워크 PollEvents 루프와 다른 스레드에서 실행될 수 있습니다. 이 때문에 일반 List와 Dictionary에 AddPlayer와 TryStartMatch가 동시에 접근할 경우 Race Condition 가능성이 있습니다. 현재 다시 설계한다면 lock을 추가하는 방법도 있지만, 이 서버는 이벤트 루프 기반이므로 매치메이킹 timeout도 서버 main loop에서 체크하여 모든 세션 상태 변경을 단일 스레드로 직렬화하는 방식을 선택할 것 같습니다.”

 

 

NetworkManager의 PollEvents() 루프에서 AddPlayer() 같은 네트워크 이벤트 처리가 실행되고, System.Timers.Timer.Elapsed는 그 루프와는 별개의 스레드에서 실행될 수 있어. 그런데 두 경로가 모두 Matchmaker의 waitingPlayers와 nextClientId, 그리고 이후 NetworkManager의 playerToSession 같은 공유 상태를 건드릴 수 있기 때문에 race condition 가능성이 생기는 거야. 실제로 AddPlayer()는 nextClientId++와 waitingPlayers.Add(player)를 수행하고, TryStartMatch()는 같은 리스트를 읽고 RemoveRange()를 수행하면서 nextClientId = 0으로 초기화해. Matchmaker Matchmaker

그리고 TryStartMatch()는 여기서 끝나는 게 아니라 NetworkManager.Instance.RegisterSession(session)까지 호출하고, 그 안에서 playerToSession[player] = session으로 또 다른 공유 Dictionary를 수정해. Matchmaker NetworkManager

그래서 네 말대로 핵심은:

두 개의 스레드가 같은 공유 자원을 동시에 읽거나 수정할 수 있는 구조라서, 실행 순서에 따라 예상하지 못한 상태가 나올 수 있다.

야.

한 가지 표현만 조심하면 돼.
“두 스레드가 같은 자원을 동시에 수정하는 상황이 계속 발생하고 있다”보다는,

“동시에 수정할 가능성이 존재한다”

가 더 정확해. 실제로 매번 충돌하는 건 아니고, 타이밍이 겹칠 때 문제가 발생할 수 있기 때문이야.

그리고 해결책도 네가 말한 게 맞아.

현재 서버는 이미:

while (true)
{
    server.PollEvents();
    Thread.Sleep(15);
}

라는 단일 이벤트 루프 구조를 가지고 있어. NetworkManager

따라서 System.Timers.Timer를 따로 돌리지 않고:

while (true)
{
    server.PollEvents();

    matchmaker.Tick();

    Thread.Sleep(15);
}

처럼 메인 서버 루프에서 매치메이킹 timeout을 확인하면,

AddPlayer
RemovePlayer
TryStartMatch
RegisterSession

이 전부 같은 스레드에서 순차적으로 실행되게 만들 수 있어.

예를 들면:

private DateTime matchmakingDeadline;
private bool isMatchmakingTimerRunning;

public void AddPlayer(Player player, ...)
{
    waitingPlayers.Add(player);

    if (!isMatchmakingTimerRunning)
    {
        isMatchmakingTimerRunning = true;
        matchmakingDeadline = DateTime.UtcNow.AddSeconds(20);
    }

    TryStartMatch();
}

public void Tick()
{
    if (!isMatchmakingTimerRunning)
        return;

    if (DateTime.UtcNow >= matchmakingDeadline)
    {
        TryStartMatch(force: true);
        isMatchmakingTimerRunning = false;
    }
}

이런 식이지.

이 방식의 장점은 단순해.

lock 필요성 감소
Thread-safe collection 필요성 감소
race condition 가능성 감소
상태 흐름 추적이 쉬움
디버깅 쉬움

물론 멀티스레드 서버가 필요해지는 규모에서는 lock, ConcurrentDictionary, job queue 같은 방법을 써야 할 수 있어. 하지만 네 현재 서버처럼 비교적 단순한 세션/릴레이 구조에서는 공유 게임 상태를 하나의 서버 메인 스레드에서만 수정하도록 만드는 방식이 훨씬 깔끔한 설계야.

면접에서는 이렇게 답하면 좋다.

“현재 구현에서는 네트워크 이벤트 루프와 System.Timers.Timer.Elapsed가 서로 다른 스레드에서 실행될 수 있고, 둘 다 waitingPlayers, nextClientId, playerToSession 같은 공유 상태를 수정하기 때문에 race condition 가능성이 있습니다. 이를 개선한다면 별도 Timer thread를 두기보다 서버 main loop에서 matchmaking timeout을 검사하여 모든 세션 상태 변경을 단일 스레드에서 직렬화하는 방식으로 구성하겠습니다.”