[Paper 플러그인 실전 제작기 #03] 이벤트 리스너 실전 (스폰 보호구역에서 블록 설치/파괴 막기)

2026. 2. 23. 23:11Java/마인크래프트

안녕하세요.
지난 2편에서 /heal 명령어를 만들면서 명령 처리, 권한, 인자 파싱, 탭 완성을 실전으로 다뤘습니다.

이번 3편에서는 Paper 플러그인에서 정말 자주 쓰는 핵심 기능인 이벤트 리스너(Event Listener) 를 실전으로 다뤄보겠습니다.

이번 목표는 간단하지만 서버 운영에 바로 도움이 되는 기능입니다.

  • 스폰 주변 일정 반경을 보호구역으로 지정
  • 일반 플레이어는 보호구역에서 블록 설치/파괴 불가
  • 관리자 권한이 있으면 예외 허용
  • 차단 이유를 플레이어에게 메시지로 안내

이 정도만 구현해도 "명령어 다음 단계"로 넘어가는 감각이 확실히 잡힙니다.


오늘 만들 기능 요약

기능명(예시): 스폰 보호구역 블록 보호

  • 기준 위치: 월드 스폰(world.getSpawnLocation())
  • 보호 반경: 10칸 (코드 상수로 시작)
  • 막는 행동:
    • 블록 설치 (BlockPlaceEvent)
    • 블록 파괴 (BlockBreakEvent)
  • 예외 대상:
    • paperstarter.spawnprotect.bypass 권한이 있는 플레이어

먼저 알아둘 용어 (초보자 기준 설명)

  • event (이벤트): 서버 안에서 발생한 사건 (예: 블록 설치, 플레이어 접속, 데미지 발생)
  • listener (리스너): 특정 이벤트가 발생했을 때 실행할 코드를 모아둔 클래스
  • cancel (이벤트 취소): 원래 일어날 행동을 막는 것
    예: 블록 설치 이벤트를 취소하면 실제로 블록이 설치되지 않음
  • permission node (권한 문자열 이름): 권한 플러그인(LuckPerms 등)에서 관리하는 문자열 키
    예: paperstarter.spawnprotect.bypass

plugin.yml 설정

한 줄 요약: 플러그인 기본 정보와 권한 노드를 등록합니다.

name: PaperStarter
version: 1.0.0
main: com.example.paperstarter.PaperStarterPlugin
api-version: '1.21'

permissions:
  paperstarter.spawnprotect.bypass:
    description: Allows bypassing spawn protection block restrictions
    default: op

permissions에 권한 노드를 등록해두면, 나중에 운영할 때 권한 체계가 훨씬 깔끔해집니다.
default: op로 두면 서버 OP는 자동으로 우회 권한을 가집니다.


메인 플러그인 클래스: 리스너 등록

한 줄 요약: 서버가 플러그인을 켤 때 이벤트 리스너를 등록합니다.

package com.example.paperstarter;

import org.bukkit.plugin.java.JavaPlugin;

public final class PaperStarterPlugin extends JavaPlugin {

    @Override
    public void onEnable() {
        getServer().getPluginManager().registerEvents(new SpawnProtectListener(this), this);
        getLogger().info("PaperStarter enabled");
    }

    @Override
    public void onDisable() {
        getLogger().info("PaperStarter disabled");
    }
}

여기서 핵심은 registerEvents(...)입니다.
이 호출을 해야 서버가 "이 클래스가 이벤트를 처리하는 리스너구나"라고 인식하고, 실제 이벤트 발생 시 메서드를 호출합니다.


이벤트 리스너 클래스 구현

한 줄 요약: 블록 설치/파괴 이벤트를 받아서 보호구역이면 취소합니다.

package com.example.paperstarter;

import org.bukkit.ChatColor;
import org.bukkit.Location;
import org.bukkit.World;
import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler;
import org.bukkit.event.Listener;
import org.bukkit.event.block.BlockBreakEvent;
import org.bukkit.event.block.BlockPlaceEvent;

public final class SpawnProtectListener implements Listener {

    private static final String BYPASS_PERMISSION = "paperstarter.spawnprotect.bypass";
    private static final int SPAWN_PROTECT_RADIUS = 10;

    private final PaperStarterPlugin plugin;

    public SpawnProtectListener(PaperStarterPlugin plugin) {
        this.plugin = plugin;
    }

    @EventHandler
    public void onBlockPlace(BlockPlaceEvent event) {
        Player player = event.getPlayer();

        if (canBypass(player)) {
            return;
        }

        if (isInsideSpawnProtection(event.getBlockPlaced().getLocation())) {
            event.setCancelled(true);
            player.sendMessage(ChatColor.RED + "스폰 보호구역에서는 블록을 설치할 수 없습니다.");
        }
    }

    @EventHandler
    public void onBlockBreak(BlockBreakEvent event) {
        Player player = event.getPlayer();

        if (canBypass(player)) {
            return;
        }

        if (isInsideSpawnProtection(event.getBlock().getLocation())) {
            event.setCancelled(true);
            player.sendMessage(ChatColor.RED + "스폰 보호구역에서는 블록을 파괴할 수 없습니다.");
        }
    }

    private boolean canBypass(Player player) {
        return player.hasPermission(BYPASS_PERMISSION);
    }

    private boolean isInsideSpawnProtection(Location target) {
        World world = target.getWorld();
        if (world == null) {
            return false;
        }

        Location spawn = world.getSpawnLocation();

        if (!world.equals(spawn.getWorld())) {
            return false;
        }

        double dx = target.getX() - spawn.getX();
        double dz = target.getZ() - spawn.getZ();

        return (dx * dx + dz * dz) <= (SPAWN_PROTECT_RADIUS * SPAWN_PROTECT_RADIUS);
    }
}

핵심 포인트는 3가지입니다.

  • @EventHandler: 이 메서드가 이벤트 처리 메서드라는 표시
  • event.setCancelled(true): 실제 행동(설치/파괴)을 막는 핵심 코드
  • hasPermission(...): 관리자/운영자 예외 처리를 위해 꼭 필요한 분기

왜 이 줄이 중요한가? (실전 포인트)

hasPermission(...)를 왜 넣나?

서버 운영에서는 "모두 금지"보다 "일반 유저만 금지, 운영자는 가능"이 거의 항상 필요합니다.
이 분기가 없으면 운영자가 직접 스폰 정리/수정을 할 때도 막혀서 불편해집니다.

반경 계산을 왜 distance() 대신 직접 계산했나?

distance()는 내부적으로 제곱근 계산이 들어가서, 이벤트가 매우 자주 발생하는 상황에서는 불필요한 비용이 생길 수 있습니다.
여기서는 dx*dx + dz*dz 비교로 충분해서 더 가볍게 처리했습니다.

왜 Y축은 무시했나?

스폰 보호는 보통 "수평 범위(XZ)" 기준으로 운영하는 경우가 많습니다.
처음에는 단순하게 만들고, 필요하면 나중에 Y축 제한을 추가하는 게 유지보수에 좋습니다.


자주 나오는 실수 (실패 케이스)

1) registerEvents(...)를 안 한 경우

증상:

  • 코드가 맞아 보이는데 아무 것도 막히지 않음

원인:

  • 리스너 클래스는 만들었지만 서버에 등록하지 않아서 이벤트가 호출되지 않음

2) @EventHandler를 빼먹은 경우

증상:

  • 메서드는 있는데 이벤트가 실행되지 않음

원인:

  • 리스너 메서드 표시가 없어서 Bukkit/Paper가 이벤트 핸들러로 인식하지 않음

3) 권한 노드 오타

증상:

  • OP가 아닌데 우회되거나, OP인데도 막힘(권한 플러그인 설정에 따라 혼동)

원인:

  • 코드 문자열과 plugin.yml 권한 노드 이름이 다름

수동 테스트 체크리스트 (서버 운영 관점)

  • 서버 기동 후 에러 없이 플러그인이 enable 되는지 확인
  • 일반 플레이어로 스폰 근처에서 블록 설치 시 차단되는지 확인
  • 일반 플레이어로 스폰 근처에서 블록 파괴 시 차단되는지 확인
  • 차단 메시지가 이해 가능하게 출력되는지 확인
  • 스폰 보호 반경 밖에서는 정상 설치/파괴 되는지 확인
  • OP(또는 우회 권한 보유 계정)는 스폰 안에서도 작업 가능한지 확인

이번 편 정리

이번 편에서 한 일은 단순하지만, 실제 플러그인 개발에서 매우 중요한 패턴입니다.

  • 이벤트를 듣고 (Listener)
  • 조건을 검사하고
  • 필요하면 취소한다 (setCancelled(true))

이 패턴은 이후에도 그대로 반복됩니다.
예를 들어 다음에는 이런 것들로 확장할 수 있습니다.

  • 특정 월드에서만 보호 적용
  • 특정 블록만 설치 금지
  • 시간대/게임모드별 예외 처리
  • 설정파일(config.yml)로 반경 변경 가능하게 만들기