서버↔클라이언트 동기화 패킷
CustomPacketPayload + StreamCodec으로 CrusherProgressPayload를 설계하고, RegisterPayloadHandlersEvent로 핸들러를 등록하여 분쇄 진행도를 서버에서 클라이언트로 효율적으로 동기화합니다.
서버↔클라이언트 동기화 패킷
이 챕터에서는 분쇄기의 진행 상태(progress)를 서버에서 클라이언트로 동기화하는 패킷을 구현합니다. NeoForge 26.1의 CustomPacketPayload + StreamCodec 패턴을 사용하여 타입 안전한 페이로드를 정의하고, RegisterPayloadHandlersEvent로 핸들러를 등록합니다.
1. 패킷 아키텍처 개요
Minecraft 멀티플레이에서 BlockEntity 데이터는 서버가 관리합니다. 클라이언트 GUI(진행 바)가 올바른 값을 표시하려면 서버가 데이터를 명시적으로 전송해야 합니다.
서버 CrusherBlockEntity
│ progress 변경
└─ PacketDistributor.sendToPlayersTrackingChunk(...)
│
│ [CrusherProgressPayload] → 네트워크 전송
│
클라이언트 핸들러
└─ be.setProgress(payload.progress())
│
└─ CrusherScreen 진행 바 갱신
NeoForge 26.1 패킷 시스템 핵심 컴포넌트:
| 컴포넌트 | 역할 |
|---|---|
CustomPacketPayload | 패킷 데이터 컨테이너 인터페이스 |
StreamCodec | 바이트 버퍼 직렬화/역직렬화 |
PayloadRegistrar.playToClient | 서버→클라이언트 단방향 핸들러 등록 |
PacketDistributor | 대상 플레이어 선택 + 전송 |
2. CrusherProgressPayload
record로 선언하여 불변 데이터 컨테이너를 만듭니다. 두 필드(블록 좌표 + 진행 틱)만 전송하므로 패킷 크기가 최소화됩니다.
// examplemod-master-projects/machine/src/main/java/com/example/master/machine/network/CrusherProgressPayload.java
package com.example.master.machine.network;
import net.minecraft.core.BlockPos;
import net.minecraft.network.FriendlyByteBuf;
import net.minecraft.network.codec.ByteBufCodecs;
import net.minecraft.network.codec.StreamCodec;
import net.minecraft.network.protocol.common.custom.CustomPacketPayload;
import net.minecraft.resources.Identifier;
public record CrusherProgressPayload(BlockPos pos, int progress)
implements CustomPacketPayload {
public static final Type<CrusherProgressPayload> TYPE =
new Type<>(Identifier.fromNamespaceAndPath("master_machine", "crusher_progress"));
public static final StreamCodec<FriendlyByteBuf, CrusherProgressPayload> STREAM_CODEC =
StreamCodec.composite(
BlockPos.STREAM_CODEC, CrusherProgressPayload::pos,
ByteBufCodecs.INT, CrusherProgressPayload::progress,
CrusherProgressPayload::new);
@Override
public Type<? extends CustomPacketPayload> type() { return TYPE; }
}StreamCodec 조합 이해
StreamCodec.composite는 여러 필드를 순서대로 인코딩합니다.
인코딩 (서버→바이트버퍼):
1. BlockPos.STREAM_CODEC → pos를 VarInt x3로 직렬화 (약 3~9바이트)
2. ByteBufCodecs.INT → progress를 4바이트 int로 직렬화
디코딩 (바이트버퍼→클라이언트):
1. BlockPos 역직렬화
2. int 역직렬화
3. CrusherProgressPayload::new 생성자 호출
⚠️ 필드 순서 고정
인코딩과 디코딩의 순서가 반드시 동일해야 합니다.composite인수 순서를 바꾸면 잘못된 값이 역직렬화됩니다.
3. RegisterPayloadHandlersEvent 등록
MasterMachineMod.java에 MOD 버스 이벤트 핸들러를 추가합니다.
// MasterMachineMod.java (발췌)
import com.example.master.machine.network.CrusherProgressPayload;
import net.neoforged.neoforge.network.event.RegisterPayloadHandlersEvent;
import net.neoforged.neoforge.network.registration.PayloadRegistrar;
@SubscribeEvent
public static void onRegisterPayloads(RegisterPayloadHandlersEvent event) {
PayloadRegistrar registrar = event.registrar("1");
registrar.playToClient(
CrusherProgressPayload.TYPE,
CrusherProgressPayload.STREAM_CODEC,
(payload, ctx) -> {
var level = ctx.player().level();
if (level.getBlockEntity(payload.pos()) instanceof CrusherBlockEntity be) {
be.setProgress(payload.progress());
}
});
}핸들러 실행 컨텍스트
playToClient 핸들러는 클라이언트 메인 스레드에서 실행됩니다. ctx.player().level()이 클라이언트 월드를 반환하므로, 해당 좌표의 BlockEntity를 찾아 setProgress()를 호출하면 바로 GUI가 갱신됩니다.
registrar.playToXxx 메서드 | 방향 |
|---|---|
playToClient | 서버 → 클라이언트 |
playToServer | 클라이언트 → 서버 |
playBidirectional | 양방향 |
4. CrusherBlockEntity — lastSyncedProgress + setProgress
lastSyncedProgress 필드로 마지막 전송값을 추적합니다. progress가 변경될 때만 패킷을 보냅니다.
// CrusherBlockEntity.java (관련 부분)
int progress = 0;
int lastSyncedProgress = -1; // 초기값 -1 → 첫 tick에 반드시 동기화됨
public void setProgress(int progress) {
this.progress = progress; // 클라이언트 측 수신 메서드
}5. tick에서 변화 감지 후 전송
CrusherBlockEntity.tick() 정적 메서드에서 레시피 처리 후 진행도 변경 시에만 패킷을 전송합니다.
// CrusherBlockEntity.java — tick 메서드 (발췌)
public static void tick(Level level, BlockPos pos, BlockState state, CrusherBlockEntity be) {
if (level.isClientSide()) return;
// ... 레시피 처리 로직 ...
// 진행 변경 시에만 전송 (매 tick X)
syncIfChanged(level, pos, be);
}
private static void syncIfChanged(Level level, BlockPos pos, CrusherBlockEntity be) {
if (be.progress != be.lastSyncedProgress) {
PacketDistributor.sendToPlayersTrackingChunk(
(ServerLevel) level,
new ChunkPos(pos),
new CrusherProgressPayload(pos, be.progress)
);
be.lastSyncedProgress = be.progress;
}
}sendToPlayersTrackingChunk는 해당 청크를 로드한 모든 플레이어에게 전송합니다. 분쇄기를 보고 있는 모든 플레이어가 동기화됩니다.
6. getTicker 등록 (CrusherBlock)
BlockEntity tick이 호출되려면 CrusherBlock.getTicker()가 필요합니다.
// CrusherBlock.java
@Nullable
@Override
public <T extends BlockEntity> BlockEntityTicker<T> getTicker(
Level level, BlockState state, BlockEntityType<T> type) {
if (level.isClientSide()) return null;
if (type == MasterMachineMod.CRUSHER_BE.get()) {
//noinspection unchecked
return (BlockEntityTicker<T>) (BlockEntityTicker<CrusherBlockEntity>) CrusherBlockEntity::tick;
}
return null;
}클라이언트에서는 null을 반환해 서버 로직이 클라이언트에서 실행되지 않도록 합니다.
⚠️ 안티패턴 — 매 tick 전송
매 tick 패킷 전송 → 네트워크 폭주
// ❌ 매 tick 전송 (20 packets/sec)
public static void tick(...) {
PacketDistributor.sendToPlayersTrackingChunk(...); // 조건 없이 매 tick
}
// ✅ 변화 감지 후 전송
if (be.progress != be.lastSyncedProgress) {
PacketDistributor.sendToPlayersTrackingChunk(...);
be.lastSyncedProgress = be.progress;
}20 TPS 환경에서 조건 없이 전송하면 초당 20개의 패킷이 발생합니다. lastSyncedProgress로 변경 감지를 하면 진행 중(tick마다 +1)에는 여전히 패킷이 발생하지만, **아무것도 가공하지 않는 상태(progress == 0)**에서는 패킷이 전송되지 않습니다. 실제 최적화는 UI 렌더링이 허용하는 범위 내에서 전송 주기를 늘리는 것입니다(예: 5 tick마다 1회).
네트워크 대역폭 + 클라이언트 처리 비용 절감
7. 인게임 검증 안내
양 플레이어 동기화를 검증하려면:
- 서버 실행 (단일 플레이어 또는 로컬 서버)
- 분쇄기 블록 설치 후 입력 슬롯에 재료 투입
- 두 번째 플레이어가 같은 청크에서 분쇄기 GUI를 열면 진행 바가 동일하게 움직여야 함
- F3 화면에서
"Packets"카운터로 패킷 발생량 확인 가능
ℹ️ 참고용 스크린샷
인게임 멀티플레이 동기화 검증 단계는 실제 서버 실행 환경이 필요합니다. 본 캡처는 코드 구조 illustration으로만 사용됩니다.
정리
| 클래스 | 추가 내용 |
|---|---|
CrusherProgressPayload | CustomPacketPayload record + TYPE + STREAM_CODEC |
CrusherBlockEntity | lastSyncedProgress 필드 + setProgress() + syncIfChanged() |
CrusherBlock | getTicker() — 서버 tick 연결 |
MasterMachineMod | onRegisterPayloads — playToClient 핸들러 등록 |
다음 챕터에서는 전체 모드를 빌드하고 인게임에서 최종 동작을 검증합니다.