[TIL] 2026-07-15 — 러버밴딩의 뿌리를 뽑다: 견인 주입에서 리쉬 이동 제한으로
TeamCarry(4인 협동 이사 게임, 리슨서버 멀티)의 공동 운반에서 계속 잡히지 않던 러버밴딩의 뿌리가 “서버가 CMC 위에 한 번 더 보정한다”는 구조 자체에 있다는 결론에 도달했다. 플레이어를 되끌어오는 견인 주입을 걷어내고, 입력 단계에서 이동을 제한하는 리쉬(leash) 로 전환했다. 방향은 하루 만에 잡혔지만 함정이 셋 있었다 — 축차 클램프가 만드는 한쪽 쏠림, 여유 반경의 방향, 그리고 견인 진입 반경과 클램프 한계의 순서. 검증은 키보드 하나로 줄다리기를 재현하는 콘솔 치트를 만들어 해결했다.
1. 문제 — “되끌기”는 태생적으로 밴딩을 만든다
증상: 2인 가구를 들면 파트너가 기차처럼 덜컹거리며 끌려오고(러버밴딩), 벌어짐이 한계를 넘으면 그랩이 뚝 끊어진다. 1인 가구도 충돌 후 캐릭터가 가구 위치로 되끌려오는 스냅백이 어색하다.
기존 견인 방식 — 서 있는 파트너가 주입 견인으로 덜컹거리며 끌려오다, 벌어지자 그랩이 끊어진다
원인은 구조다. 언리얼 CMC(CharacterMovementComponent)는 이미 완결된 보정 체계를 갖고 있다 — 소유 클라이언트가 입력으로 예측 이동하고, ServerMove로 올라온 같은 입력을 서버가 재생하고, 결과가 어긋나면 ClientAdjustPosition으로 클라를 교정한다. 기존 견인은 이 위에 서버가 매 틱 CMC->Velocity를 직접 덮어쓰는 층을 하나 더 얹은 것이었다. 서버가 써넣은 속도는 클라이언트가 예측한 적 없는 값이라, 서버 재생 결과와 클라 예측이 매 틱 어긋나고, 그 차이만큼 교정 패킷이 날아온다. 이중 보정 = 러버밴딩. 서버+소유 클라 동시 주입으로 어긋남을 줄이는 시도도 해봤지만, 원격 지연이 있는 한 두 주입 시점이 완전히 일치할 수 없어 잔여 밴딩이 남았다.
2. 설계 전환 — 되돌리지 말고, 애초에 못 가게
발상을 뒤집었다. 플레이어가 대형을 이탈한 뒤에 되끌어오는 게 아니라, 이탈하는 입력 자체를 지운다. 운반 중 각 플레이어에게 자기 대형 지점(Att) 중심의 리쉬 반경을 두고, 입력의 원심(바깥 방향) 성분만 제거한다. 접선 성분은 남기므로 경계를 따라 도는 건 자유다.
1
2
3
4
// 반경 밖: 대형에서 멀어지는(원심) 성분만 제거 — 접선 이동은 유지
const FVector Away = -ToAtt / Dist;
const float Outward = FVector::DotProduct(WorldInput, Away);
return (Outward > 0.0f) ? WorldInput - Away * Outward : WorldInput;
핵심은 이 필터가 소유 클라이언트의 입력 생성 지점에서 돈다는 것. 잘린 입력이 그대로 ServerMove로 올라가면 서버는 같은 가속을 재생하고, 결과가 일치하니 교정 패킷 자체가 발생하지 않는다. 서버에는 안전망으로 “반경 밖 바깥 방향 속도 성분 감쇠”만 남겼는데, 이것도 주입이 아니라 깎기라 예측과 충돌하지 않는다.
함정 ①: 여유 반경의 방향. 클라이언트는 보간된 가구 트랜스폼을 보니 서버와 수 uu 오차가 있다. 처음엔 클라 반경을 서버보다 넓게(+8) 줬다 — “오차를 포용한다”는 직관이었다. 결과는 정반대. 클라가 서버 한계 밖까지 걸어갔다가 서버 안전망에 걸려 되끌려오는 밴딩이 재발했다. 여유는 좁게(-8) 줘야 한다. 클라가 서버 한계에 닿기 전에 스스로 멈추면 서버가 개입할 일이 없다.
3. 대칭 리쉬 — 그리고 축차 클램프가 만든 한쪽 쏠림
플레이어만 묶으면 반쪽이다. 한 명이 걸으면 가구가 따라가고, 가구가 가면 그 사람의 대형 지점도 같이 밀려나서 리쉬 경계가 계속 도망간다 — 서 있는 파트너는 방치된 채 벌어지다 자동 해제. 그래서 가구의 이동 목표도 모든 운반자의 리쉬 안으로 클램프했다(대칭 리쉬).
첫 구현은 플레이어를 순서대로 돌며 위반량만큼 가구 목표를 당기는 축차 방식이었는데, 줄다리기 테스트에서 가구가 한쪽으로 꾸준히 흘러갔다. 종이에 적어보니 수학이 명확했다. 양쪽 다 한계 L을 초과량 g만큼 넘은 대칭 상황에서:
- P1 보정: 가구가 P1 쪽으로
g−L이동 → P2의 위반이 그만큼 커짐 - P2 보정: 커진 위반 기준으로
2(g−L)이동 - 순이동 = 매 틱
g−L만큼 마지막 순번 쪽으로
보정을 원본 후보 기준으로 동시에 계산해 합산 적용하는 것으로 바꿨다. 반대 방향 위반은 합산에서 상쇄되니 가구가 중간에 머문다. 순서가 결과를 바꾸는 축차 갱신은 이럴 때 늘 의심해야 한다.
운반 디버그(F9) HUD — 능동 Δ/허용 반경, 가구 실속도, 막힘 플래그를 매 틱 수치로 보면서 쏠림·진동을 판별했다
4. 무입력 파트너는 끌려와야 한다 — 하이브리드와 교착의 함정
리쉬로 전부 바꾸고 나니 새 문제가 왔다. 서 있는 파트너를 끌어줄 수단이 없다. 한 명이 걸으면 가구가 파트너의 리쉬 끝에서 멈추고, 걷는 쪽 경계도 함께 동결 — 아무도 못 움직인다. “밴딩이 싫다”와 “파트너는 끌려와야 한다”는 둘 다 참이다. 답은 역할 분담이었다.
| 상황 | 담당 |
|---|---|
| 이동 입력 중인 운반자 | 리쉬 (주입 없음 — 밴딩 없음) |
| 2인+ 무입력 파트너 | 기존 견인 (가구를 따라 끌려옴) |
| 서로 반대로 당김 | 둘 다 입력자 → 리쉬 + 대칭 클램프로 중간 정지 |
| 1인 운반 | 항상 리쉬 (스냅백 제거) |
함정 ③: 반경의 순서. 하이브리드를 켰는데 견인이 전혀 발동하지 않았다. 견인 진입 반경(70uu)이 대칭 클램프 한계(48uu)보다 바깥에 있었기 때문이다 — 가구는 48에서 멈추는데 견인은 70부터 걸리니, 발동 조건에 영원히 도달할 수 없다. 무입력 피동의 견인 진입을 25uu로 당겨 클램프보다 안쪽에 두자 자연스러운 견인이 돌아왔다. 문턱값이 여러 개면 반드시 순서를 그려본다 — 견인 진입(25) < 클램프 한계(48) < 리쉬 서버 한계.
5. 검증 — 키보드 하나로 줄다리기 재현하기
“서로 반대 방향 동시 입력”은 PIE 두 창에서 한 키보드로는 테스트할 수 없다(포커스는 하나뿐). 콘솔 치트를 만들었다 — 켜면 가구를 잡은 로컬 폰 전원이 가구 반대 방향으로 자동 입력을 넣는다. 자동 입력도 실제 입력 경로처럼 리쉬 필터를 통과시켜서, 진짜 플레이어의 줄다리기와 동일한 코드 경로를 검증한다. 기존 방식과의 A/B 비교는 모든 가구의 리쉬 모드를 일괄 토글하는 명령을 만들어 게임 중 즉시 전환할 수 있게 했다.
리쉬 방식 줄다리기 — 양쪽이 자동으로 반대로 당기지만 가구는 중간에 고정되고, 아무도 튕기지 않고, 그랩도 끊어지지 않는다
자동 줄다리기 진행 중 — 가구 실속도와 운반자막힘/가구막힘 플래그가 실시간으로 찍힌다
이 치트가 잡아준 게 3장의 축차 클램프 쏠림이었다. 눈으로는 “왠지 흘러간다” 수준이던 게, 손을 떼고 관찰하니 일정 속도의 드리프트라는 게 명확해졌고, 그래서 수식으로 검산할 생각을 하게 됐다. 재현 장치를 먼저 만들면 관찰이 측정이 된다.
오늘의 교훈
- 예측 시스템 위에 보정을 얹지 마라. CMC가 이미 예측-재생-교정을 하고 있다면, 그 위의 두 번째 보정은 곧 밴딩이다. 서버가 손대는 대신 입력을 다듬어 서버·클라가 같은 결론에 도달하게 하라.
- 여유 반경은 좁은 쪽에 준다. 클라 한계를 서버보다 넓게 잡으면 “포용”이 아니라 “위반 후 교정”이 된다.
- 축차 갱신은 순서 편향을 만든다. 여러 제약을 순서대로 적용해 결과가 흐르면, 원본 기준 동시 계산·합산부터 시도한다.
- 문턱값들의 순서를 그린다. 발동 반경이 정지 반경보다 바깥이면 그 기능은 조용히 죽는다.
- 재현 장치가 먼저다. 손으로 못 만드는 상황(동시 반대 입력)은 치트로 만들어라. 관찰이 측정이 되고, 측정이 수식이 된다.