들어가며
멀티플레이 게임 개발에서 물리 연산과 캐릭터 동작을 어디에서 처리할지를 결정하는 건 프로젝트의 핵심 과제 중 하나였습니다. 초기 설계에서는 백엔드가 모든 물리 연산을 담당하는 구조를 선택했습니다. 이 방식은 데이터 신뢰성과 치팅 방지 측면에서는 유리했지만, 구현 과정에서 여러 한계에 부딪혔는데요.
이러한 문제를 해결하기 위해 프론트엔드가 물리 연산을 처리하고, 백엔드는 데이터 검증과 동기화에 집중하는 구조로 전환하게 되었습니다. 이 글에서는 해당 논의의 과정과 최종 결정을 내리게 된 이유를 공유하려 합니다.
백엔드에서의 물리 연산의 한계에 부딪힌 이유
[1] 맵 구성과 충돌 판정의 복잡도
- 맵 데이터 수작업 문제:
프론트엔드에서는 3D 모델링 파일인 GLB를 React Three Fiber의 useGLTF 훅을 사용해 TSX 컴포넌트로 변환하고, @react-three/rapier와 같은 물리 엔진 라이브러리를 활용하여 각 3D 오브젝트에 간단하게 물리 속성을 부여할 수 있습니다. 반면 백엔드에서 Cannon.js로 물리 연산을 처리하려면, GLB 파일의 모든 오브젝트에 대해 위치, 크기, 모양, 반발 계수, 마찰력 등의 물리적 특성을 하나하나 수동으로 정의해야 하는 번거로움이 있습니다.
// 서버에서 충돌 판정을 처리하기 위해 매번 충돌 오브젝트를 수동 등록해야 하는 예
world.addBody(new CANNON.Body({
shape: new CANNON.Sphere(1),
position: new CANNON.Vec3(0, 1, 0),
material: new CANNON.Material({ friction: 0.5, restitution: 0.7 }),
}));
이 코드는 단순한 하나의 충돌 오브젝트를 등록하는 예지만, 실제 게임 환경에서는 수백 개의 오브젝트가 맵에 존재할 수 있는데요. 그 모든 오브젝트의 위치, 형태, 물리적 특성을 수동으로 정의하고 등록하는 작업은 작업 공수를 크게 늘리고, 코드의 유지 보수성을 떨어트렸습니다.
- 기본 충돌체 한계:
그리고 인간형 캐릭터나 부드러운 비대칭 형태의 오브젝트를 표현하려면 커스텀 충돌체를 만들어야 하는데, 백엔드에서 선택한 물리엔진 라이브러리인 Cannon.js는 이를 간단하게 지원하지 않았습니다. Cannon.js에서는 기본적으로 구(Sphere), 직육면체(Box)와 같은 단순한 형태의 충돌체만 제공하며, 복잡한 형태의 충돌체를 만들려면 여러 개의 기본 충돌체를 조합하거나 사용자 정의 형태를 수동으로 정의해야 합니다. 또한 이러한 커스텀 충돌체를 시각적으로 확인하고 조정하기 위해서는 별도의 UI 도구가 필요한데, 이는 서버 환경에서 구현하기에 적합하지 않았습니다.
const sphereShape = new CANNON.Sphere(0.5); // 캐릭터의 충돌체를 Sphere로 설정
const characterBody = new CANNON.Body({ mass: 1, shape: sphereShape });
간단한 Sphere나 Box 충돌체를 사용하면 캐릭터가 쉽게 미끄러지거나 예상치 못한 회전이 발생하는 아래의 영상과 같은 문제가 발생했습니다.
[2] 서버-클라이언트 데이터 전달의 복잡성
또한, 캐릭터와 카메라의 회전을 처리하는 방식에 대해 프론트와 백엔드 간 의견 차이가 있었습니다. 게임에서 캐릭터의 회전은 두 가지 입력에 의해 결정됩니다. 두 가지 사용자 입력에 의해 결정되는데, 각각의 특성이 달라 처리 방식에 대한 논의가 필요했습니다:
- 마우스 이동
- 플레이어가 마우스를 좌우로 움직일 때 캐릭터가 그 방향으로 즉시 회전
- 예: 마우스를 오른쪽으로 움직이면 캐릭터가 오른쪽으로 고개를 돌림
- 빠른 반응성이 필수적이므로, 클라이언트에서 계산 후 서버에 각도 값만 전송하기로 합의
- 키보드 조작
- WASD 키로 캐릭터가 이동할 때 진행 방향으로 자연스럽게 회전
- 예: 'D' 키를 누르면 캐릭터가 오른쪽으로 이동하면서 그 방향으로 몸을 돌림
핵심 쟁점은 이 두 회전 메커니즘의 계산 주체를 어디로 할 것인가였습니다. 마우스 회전은 즉각적인 반응이 중요하므로 클라이언트 처리에 동의했지만, 이동 방향에 따른 회전을 누가 계산할지에 대해서는 의견이 갈렸습니다.
서버팀 친구의 의견
- 클라이언트 중심의 회전 계산
- 모든 회전 계산을 클라이언트에서 처리
- 마우스로 인한 회전각도와 WASD 키 입력으로 인한 이동 방향 회전을 모두 클라이언트가 계산
- 계산된 최종 회전 각도를 서버로 전송
- 서버의 단순화된 역할
- 서버는 받은 회전 각도를 다른 클라이언트들에게 전달하는 역할만 수행
- 이는 플레이어가 자신의 캐릭터 방향을 가장 정확하게 알 수 있다는 점에 기반
프론트엔드팀의 내 의견
- 회전 계산의 분리
- 마우스 회전: 클라이언트에서 처리 (즉각적인 반응성 필요)
- 이동 방향 회전: 서버에서 처리
- 서버 중심의 이동 처리
- 서버가 이미 캐릭터의 위치와 속도를 계산하고 있음
- 이동 방향에 따른 회전은 위치/속도 계산의 연장선상에 있으므로, 서버에서 함께 처리하는 것이 더 효율적
- 클라이언트의 계산 부담을 줄이고 로직을 단순화할 수 있음
예를 들어, 플레이어가 'D' 키를 눌러 오른쪽으로 이동할 때:
- 서버팀 방식: 클라이언트가 이동 방향(오른쪽)에 따른 회전 각도를 계산하여 서버로 전송
- 프론트엔드팀 방식: 클라이언트는 'D' 키 입력만 전송하고, 서버가 이동 처리와 함께 캐릭터가 오른쪽을 향하도록 회전 각도 계산"
이 부분의 이해를 돕기 위한 간략한 변경 전 프론트 코드는 다음과 같습니다.
// SocketController.tsx
// 마우스 움직임으로 인한 캐릭터 회전 처리
useEffect(() => {
// 마우스 움직임 이벤트 핸들러
const handleMouseMove = (event: MouseEvent) => {
// 포인터락: 게임 중 마우스가 화면 밖으로 나가지 않도록 고정하는 기능
const isPointerLocked = document.pointerLockElement !== null;
if (isPointerLocked) {
// movementX: 마우스의 좌우 이동량
// 마이너스: 마우스 오른쪽 이동 시 시계방향 회전을 위해 부호 반전
angle.current -= event.movementX * MOUSE_SENSITIVITY;
// 계산된 회전 각도를 서버로 전송
socketRef.current?.emit('angle', angle.current);
}
};
// 이벤트 리스너 등록 및 정리
document.addEventListener('mousemove', handleMouseMove);
return () => {
document.removeEventListener('mousemove', handleMouseMove);
};
}, []);
// RabbitController.tsx
// 캐릭터의 이동과 회전을 처리하는 컴포넌트
if (velocity.x) {
// 이동 방향에 따른 회전 처리 - 현재 서버/클라이언트 처리 방식 논의 중인 부분
// velocity.x가 양수면 오른쪽, 음수면 왼쪽으로 회전
rotationTarget.current += 0.01 * velocity.x;
}
// 캐릭터의 실제 회전 적용
if (velocity.x || velocity.z) {
// 현재 이동 속도 저장
vel.x = velocity.x;
vel.z = velocity.z;
// lerpAngle: 현재 각도에서 목표 각도로 부드럽게 보간
// character.rotation.y: 캐릭터의 Y축 회전 (좌우 회전)
// facingAngleRad: 서버가 계산한 캐릭터가 바라봐야 할 방향
// VITE_ROTATION_LERP_FACTOR: 회전 보간 속도 (값이 클수록 빠르게 회전)
character.current.rotation.y = lerpAngle(
character.current.rotation.y,
facingAngleRad,
import.meta.env.VITE_ROTATION_LERP_FACTOR
);
}
/*
코드 구조 관련 생각:
- 현재 SocketController와 RabbitController에서 회전 각도를 각각 ref로 관리 중
- 전역 상태로 관리하지 않은 이유:
1. 마우스 이동시 매우 빈번한 상태 업데이트 발생
2. 전역 상태 업데이트의 성능 부하 우려
지금와서 생각해보면 이게 낫지 않을까?:
- 커스텀 훅으로 회전 로직 분리
예: useCharacterRotation() 훅을 만들어 회전 관련 로직 통합
- 이를 통해 코드 중복 제거 및 관리 포인트 일원화 가능
*/
해결 방안: 클라이언트 물리 연산, 서버 검증 및 동기화
오랜 고민과 논의 끝에, 저희는 프로젝트의 핵심 요구사항들을 다시 한번 꼼꼼히 살펴보게 되었습니다. 그리고 크래프톤 현직자이신 멘토님과의 멘토링을 통해서 교훈을 얻기도 했습니다! 게임에서 무엇보다 가장 중요한 건 플레이어가 느끼는 조작감이었습니다. 아무리 좋은 기능이 있어도 캐릭터가 버벅거리거나 의도한 대로 움직이지 않는다면 게임의 재미가 급격히 떨어질 수밖에 없으니까요.
또한 위에서 언급한대로 서버에서 물리 엔진을 컨트롤 하기엔 기술적인 측면에서도 여러 어려움이 있었습니다:
- 맵 데이터 처리의 복잡성: 서버에서 물리 연산을 처리하려니 맵 데이터를 다루기가 너무 복잡
- 물리 엔진의 한계: Cannon.js로는 캐릭터의 자연스러운 움직임을 구현하기 어려움
- 네트워크 지연 문제: 모든 물리 연산을 서버에서 처리하면 필연적으로 지연이 발생
개발 효율성 측면에서도 프론트엔드의 물리 엔진 도구들이 더 성숙하고 다양했습니다. 특히 시각적으로 디버깅할 수 있다는 점이 큰 장점이었고, 이런 여러 요소들을 종합적으로 고려한 끝에 다음과 같은 구조로 가닥을 잡았습니다:
- 프론트엔드: 물리 엔진으로 캐릭터의 움직임과 충돌을 처리하고 사용자 입력에 즉각 반응
- 백엔드: 클라이언트 간 데이터 동기화와 치팅 방지에 집중
이렇게 결정한 덕분에 얻을 수 있었던 장점들은 다음과 같았어요:
- 향상된 사용자 경험: 입력에 대한 즉각적인 반응성 확보
- 서버 부하 감소: 서버는 검증과 동기화에만 집중
- 명확한 역할 분담: 새로운 기능 추가나 맵 수정이 훨씬 수월해짐
물론 이 결정이 완벽한 해결책은 아닐 수 있습니다. 하지만 현재 프로젝트의 상황과 우리가 가진 리소스를 고려했을 때, 가장 합리적인 선택이었다고 생각합니다.
앞으로의 과제
이 결정은 지금까지의 개발 효율성과 사용자 경험을 우선시한 결정이긴 하지만, 여전히 해결해야 할 과제들이 남아 있습니다.
- 데이터 검증 강화
클라이언트에서 계산된 데이터를 어떻게 하면 더 안전하게 검증할 수 있을지 고민이 필요합니다. 서버의 검증 로직을 더 정교하게 설계해야 할 것 같아요. - 네트워크 지연 문제 완화
클라이언트와 서버 사이의 지연 시간을 어떻게 자연스럽게 처리할 수 있을까요? 예측 보정 기술(prediction and correction)이나 델타값 계산 같은 방법을 도입해볼까 고민 중입니다. 아직은 이 부분에 대해 공부가 더 필요하네요! - Cannon.js 한계 극복
서버에서 좀 더 정확하고, 부드러운 작업을 위해 물리 엔진을 함께 돌리게 되면, 기본적인 충돌체 이상의 복잡한 물리 연산이나 캐릭터 모양의 커스텀 충돌체를 지원하기 위한 추가 작업이나 대안 엔진 검토가 필요할 수 있습니다.
이번 주는 정말 치열한 한 주였네요. 밤낮으로 이어진 열띤 토론과 코딩... 하루 17시간씩 개발에 몰두하다 보니 체력적으로는 힘들지만, 이렇게 열정 넘치는 팀원들과 함께여서 즐거운 시간이었습니다.
다음 주에는 이번 결정으로 인한 변화와 그 과정에서 겪은 시행착오들을 정리해볼 계획입니다. 정글의 마지막 프로젝트인 만큼, 후회 없이 마무리하고 싶습니다. 체력 관리도 잘하면서, 끝까지 최선을 다해보려고 해요! 화잍팅!
'SW정글 9기' 카테고리의 다른 글
정글 4주차 회고, 꿈과 현실 (0) | 2024.08.31 |
---|---|
1주차 회고: 찬찬히 나를 돌아보는 시간 (0) | 2024.08.11 |