LYSC
Development Insight

대규모 업데이트: 멀티플레이어 도입 실패기 (Postmortem)

2022.04.18

Photon(PUN2)을 이용해 Car Open World에 멀티플레이를 도입하려다 동기화 이슈와 서버 비용 문제로 중단하게 된 실패의 기록.

프로젝트의 야심찬 시작: 왜 멀티플레이어인가?

'Car Open World' 프로젝트를 진행하며 가장 많이 받은 요청은 "친구와 함께 달리고 싶다"는 것이었습니다. 1인 개발자로서 멀티플레이어 구현은 거대한 도전이었지만, 당시 가장 대중적이었던 Photon Engine(PUN2)을 선택하며 낙관적으로 시작했습니다. 하지만 이 선택이 앞으로 겪을 고난의 시작이 될 줄은 몰랐습니다.

선택의 이유: PUN2의 접근성

  • 로비, 매칭, 룸 시스템이 이미 완성되어 있음
  • Unity와의 높은 호환성 및 풍부한 레퍼런스
  • 무료 티어 제공으로 초기 비용 부담 제로

첫 번째 난관: 물리 기반 차량 동기화의 지옥

차량 게임에서 가장 중요한 것은 '물리'입니다. 하지만 네트워크 환경에서 물리 엔진(PhysX)을 동기화하는 것은 차원이 다른 문제였습니다. PUN2의 기본 `PhotonView`와 `PhotonTransformView`만으로는 차량의 고속 주행과 충돌을 부드럽게 표현할 수 없었습니다.

특히 차량이 서로 부딪힐 때 발생하는 '피드백 루프'가 치명적이었습니다. 클라이언트 A가 충돌을 감지하여 튕겨 나가면, 서버는 이를 클라이언트 B에게 전달하고, B는 또 다른 위치에서 충돌을 계산하는 과정에서 캐릭터가 허공으로 날아가거나 땅속으로 꺼지는 현상이 빈번했습니다.

// [실패 사례] 단순 Transform 동기화의 한계
void Update() {
    if (!photonView.IsMine) {
        // 네트워크로 받은 위치로 단순히 보간하면 물리가 깨짐
        transform.position = Vector3.Lerp(transform.position, networkPos, Time.deltaTime * 10);
    }
}

두 번째 난관: RPC 남용과 상태 관리의 붕괴

상태 동기화를 위해 RPC(Remote Procedure Call)를 무분별하게 사용한 것도 패착이었습니다. 차량의 등화장치, 경적, 부스터 상태 등을 모두 RPC로 처리하다 보니, 패킷이 꼬이거나 늦게 도착했을 때의 예외 처리가 불가능한 수준에 이르렀습니다. 이른바 'RPC 지옥'에 빠진 것입니다.

// [안 좋은 예시] 모든 상태 변화를 RPC로 전송
[PunRPC]
void SetBoostActive(bool active) {
    boostEffect.SetActive(active);
    isBoosting = active;
}

// 호출부
if (Input.GetKeyDown(KeyCode.LeftShift)) {
    photonView.RPC("SetBoostActive", RpcTarget.All, true);
}

위 방식의 문제점은 '현재 상태'가 아닌 '이벤트'에 의존한다는 것입니다. 중간에 접속한 플레이어(Late Joiner)는 현재 다른 차량이 부스터를 쓰고 있는지 알 방법이 없었고, 이를 위해 별도의 `OnPhotonPlayerConnected` 로직을 추가하며 코드는 더욱 스파게티가 되었습니다.

대안: Custom Properties 활용

이벤트가 아닌 '상태'를 동기화하기 위해서는 Photon의 Custom Properties나 `OnPhotonSerializeView`를 활용했어야 했습니다.

// [개선된 접근법] 데이터 스트림을 통한 상태 동기화
public void OnPhotonSerializeView(PhotonStream stream, PhotonMessageInfo info) {
    if (stream.IsWriting) {
        stream.SendNext(isBoosting);
        stream.SendNext(vehicleSpeed);
    } else {
        this.isBoosting = (bool)stream.ReceiveNext();
        this.vehicleSpeed = (float)stream.ReceiveNext();
    }
}

결정적 중단 이유: 서버 비용과 확장성 문제

기술적 난제보다 더 무서웠던 것은 '비용'이었습니다. 오픈월드 특성상 한 세션에 많은 플레이어가 머물러야 하는데, PUN2의 CCU(Concurrent User) 기반 과제 모델은 인디 개발자가 감당하기에 리스크가 컸습니다. 동접자가 늘어날수록 기하급수적으로 늘어나는 비용에 비해, 수익 모델(BM)이 빈약했던 'Car Open World'는 결국 적자 구조를 면치 못할 것이 명확해졌습니다.

포스트모템: 실패를 통해 배운 것들

이번 프로젝트를 통해 얻은 가장 큰 교훈은 **"게임 기획 단계부터 네트워크 아키텍처를 설계해야 한다"**는 것입니다. 싱글 플레이어 게임에 멀티플레이를 '끼워 넣는' 방식은 결코 성공할 수 없음을 깨달았습니다.

만약 다시 멀티플레이어 게임을 만든다면, PUN2보다는 저수준 API를 제공하는 Mirror나, 에픽 온라인 서비스(EOS), 혹은 최근 각광받는 Unity Netcode for Entities(DOTS 기반)를 사용하여 물리 예측과 롤백 시스템을 직접 구축하는 방향으로 접근할 것입니다.

실패는 쓰지만, 그 과정에서 배운 네트워크 프로그래밍의 원리와 물리 동기화의 깊은 이해는 저에게 무엇과도 바꿀 수 없는 자산이 되었습니다. 'Car Open World'의 멀티플레이어는 여기서 멈추지만, 이 경험은 다음 프로젝트인 'Nuclear Survive'에서 더욱 견고한 시스템으로 다시 태어날 것입니다.

심화 분석: 기술적 도전과 해결책

프로젝트의 성공은 기술력뿐만 아니라 팀 내 원활한 커뮤니케이션과 체계적인 파이프라인 구축에 달려 있습니다. 자동화된 빌드 시스템과 코드 리뷰 프로세스는 개발 속도를 비약적으로 높여줍니다. 1인 개발일지라도 스스로의 작업 규칙을 명확히 하는 것이 중요합니다.

기술적 구현의 디테일

저는 이번 개발 과정에서 모든 기능을 모듈화하여 독립적으로 테스트할 수 있는 환경을 구축했습니다. 이는 추후 기능 확장이나 버그 수정 시 발생할 수 있는 사이드 이펙트를 최소화하는 데 큰 역할을 했습니다. 또한 문서화를 병행하여 기술 부채가 쌓이는 것을 방지했습니다.

성능 벤치마크 및 최적화 지표

협업 툴 및 자동화 시스템 도입 이후 작업 히스토리 추적 시간이 50% 단축되었으며, 휴먼 에러로 인한 빌드 실패율이 눈에 띄게 줄어들었습니다. 이는 전체적인 개발 사이클을 20% 이상 단축시키는 결과를 가져왔습니다.

실무 적용 시 주의사항

완벽한 설계를 추구하기보다 빠르게 프로토타입을 만들고 피드백을 수용하는 애자일(Agile)한 자세가 특히 중요합니다. 기술에 매몰되기보다 유저가 실제로 느끼는 가치에 집중하는 균형 잡힌 시각을 유지하세요.

Drag to Rotate Cube
작성자 프로필

LYSC Studio

1인 게임 개발과 웹 기술에 관심이 많은 개발자입니다. 경험을 통해 배운 것을 공유하고, 함께 성장하는 것을 즐깁니다.