[Paper 플러그인 실전 제작기 #07] 쿨다운/제한 시스템: 스킬 재사용 대기시간 만들기

2026. 3. 3. 15:45Java/마인크래프트

안녕하세요. 지난 6편에서는 SQLite로 랭킹/통계를 저장하고 조회하는 구조를 만들었습니다.
이번 7편에서는 실전 서버 운영에서 꼭 필요한 남용 방지 로직, 즉 쿨다운(cooldown) 시스템을 구현해보겠습니다.

이번 글에서 만드는 기능은 아래와 같습니다.

  • /svskill cast: 스킬 사용
  • 스킬 사용 후 15초 재사용 대기시간 적용
  • 대기 중 사용 시 남은 시간 안내
  • 관리자용 쿨다운 초기화: /svskill reset <player>

PvE/PvP 서버에서 쿨다운이 없으면 강력한 기능이 과하게 반복되어 밸런스가 무너집니다.
그래서 "지금 사용 가능한가?"를 빠르게 판단하는 구조가 필수입니다.

이번 편에서 완성할 결과

  • 플레이어별 마지막 사용 시각 저장
  • 남은 시간(ms) 계산 후 사용자 메시지 출력
  • 권한 기반 관리자 초기화 기능
  • 탭 자동완성으로 명령어 입력 실수 감소

먼저 개념 정리 (초보자 기준)

  • cooldown: 기능 재사용까지 기다려야 하는 시간입니다.
  • timestamp: 특정 시점(예: Unix epoch ms)을 숫자로 저장한 값입니다.
  • args: 명령어 뒤에 붙는 인자 목록입니다.
  • permission node: 권한 문자열 이름(예: sv.skill.admin)입니다.

1) plugin.yml 명령어/권한 등록

한 줄 목적: /svskill 명령어와 관리자 권한을 Paper에 등록합니다.

name: cooldown-skill-demo
version: 1.0.0
main: com.example.cooldown.CooldownPlugin
api-version: "1.21"

commands:
  svskill:
    description: skill command
    usage: /svskill <cast|reset|left> [player]

permissions:
  sv.skill.admin:
    description: can reset cooldown of others
    default: op

permissions를 미리 정의해 두면, 운영 중 LuckPerms 같은 권한 플러그인과 연동할 때 훨씬 명확합니다.

2) 쿨다운 저장 서비스 구현

한 줄 목적: 플레이어별 마지막 스킬 사용 시점을 메모리에 저장하고, 남은 시간을 계산합니다.

package com.example.cooldown;

import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;

public class CooldownService {
    private final Map<UUID, Long> lastUsedAt = new ConcurrentHashMap<>();
    private final long cooldownMillis;

    public CooldownService(long cooldownMillis) {
        this.cooldownMillis = cooldownMillis;
    }

    public long getRemainingMillis(UUID playerId, long nowMillis) {
        Long last = lastUsedAt.get(playerId);
        if (last == null) {
            return 0L;
        }

        long elapsed = nowMillis - last;
        long remain = cooldownMillis - elapsed;
        return Math.max(remain, 0L);
    }

    public boolean tryUse(UUID playerId, long nowMillis) {
        long remain = getRemainingMillis(playerId, nowMillis);
        if (remain > 0L) {
            return false;
        }
        lastUsedAt.put(playerId, nowMillis);
        return true;
    }

    public void reset(UUID playerId) {
        lastUsedAt.remove(playerId);
    }
}

ConcurrentHashMap을 쓴 이유는 서버 환경에서 동시 접근이 발생해도 안전하게 동작하도록 하기 위해서입니다.
Math.max(remain, 0)으로 음수 시간이 출력되지 않게 막아 사용자 메시지를 안정적으로 유지합니다.

3) /svskill 명령어 구현 (권한 + 인자 검증 + 탭완성)

한 줄 목적: cast, left, reset 하위 명령을 하나의 커맨드에서 처리합니다.

package com.example.cooldown;

import java.util.ArrayList;
import java.util.List;
import org.bukkit.Bukkit;
import org.bukkit.command.Command;
import org.bukkit.command.CommandExecutor;
import org.bukkit.command.CommandSender;
import org.bukkit.command.TabCompleter;
import org.bukkit.entity.Player;

public class SkillCommand implements CommandExecutor, TabCompleter {
    private final CooldownPlugin plugin;
    private final CooldownService cooldownService;

    public SkillCommand(CooldownPlugin plugin, CooldownService cooldownService) {
        this.plugin = plugin;
        this.cooldownService = cooldownService;
    }

    @Override
    public boolean onCommand(CommandSender sender, Command command, String label, String[] args) {
        if (args.length == 0) {
            sender.sendMessage("§e사용법: /svskill <cast|left|reset> [player]");
            return true;
        }

        String sub = args[0].toLowerCase();

        if (sub.equals("cast")) {
            if (!(sender instanceof Player player)) {
                sender.sendMessage("§c콘솔에서는 사용할 수 없습니다.");
                return true;
            }

            long now = System.currentTimeMillis();
            long remain = cooldownService.getRemainingMillis(player.getUniqueId(), now);
            if (remain > 0L) {
                sender.sendMessage("§c아직 사용할 수 없습니다. 남은 시간: " + (remain / 1000.0) + "초");
                return true;
            }

            boolean used = cooldownService.tryUse(player.getUniqueId(), now);
            if (!used) {
                sender.sendMessage("§c쿨다운 처리 중 다시 시도해 주세요.");
                return true;
            }

            sender.sendMessage("§a스킬을 사용했습니다. 다음 사용까지 15초 대기!");
            return true;
        }

        if (sub.equals("left")) {
            if (!(sender instanceof Player player)) {
                sender.sendMessage("§c콘솔에서는 사용할 수 없습니다.");
                return true;
            }

            long remain = cooldownService.getRemainingMillis(player.getUniqueId(), System.currentTimeMillis());
            if (remain <= 0L) {
                sender.sendMessage("§a지금 바로 사용할 수 있습니다.");
                return true;
            }
            sender.sendMessage("§e남은 쿨다운: " + (remain / 1000.0) + "초");
            return true;
        }

        if (sub.equals("reset")) {
            if (!sender.hasPermission("sv.skill.admin")) {
                sender.sendMessage("§c권한이 없습니다. (sv.skill.admin)");
                return true;
            }

            if (args.length < 2) {
                sender.sendMessage("§e사용법: /svskill reset <player>");
                return true;
            }

            Player target = Bukkit.getPlayerExact(args[1]);
            if (target == null) {
                sender.sendMessage("§c대상 플레이어가 온라인이 아닙니다.");
                return true;
            }

            cooldownService.reset(target.getUniqueId());
            sender.sendMessage("§a" + target.getName() + "의 쿨다운을 초기화했습니다.");
            return true;
        }

        sender.sendMessage("§c알 수 없는 하위 명령입니다. cast/left/reset 중 하나를 사용해 주세요.");
        return true;
    }

    @Override
    public List<String> onTabComplete(CommandSender sender, Command command, String alias, String[] args) {
        List<String> result = new ArrayList<>();

        if (args.length == 1) {
            String input = args[0].toLowerCase();
            if ("cast".startsWith(input)) result.add("cast");
            if ("left".startsWith(input)) result.add("left");
            if ("reset".startsWith(input)) result.add("reset");
            return result;
        }

        if (args.length == 2 && args[0].equalsIgnoreCase("reset")) {
            for (Player online : Bukkit.getOnlinePlayers()) {
                String name = online.getName();
                if (name.toLowerCase().startsWith(args[1].toLowerCase())) {
                    result.add(name);
                }
            }
            return result;
        }

        return result;
    }
}

hasPermission(...)를 먼저 검사하는 이유는 권한 없는 사용자의 관리자 기능 접근을 가장 빠르게 차단하기 위해서입니다.
args.length 분기를 명확히 나누면, 실제 명령 처리와 탭완성 기준이 맞아떨어져 입력 실수가 줄어듭니다.

4) 메인 플러그인에서 등록

한 줄 목적: 쿨다운 서비스와 명령어 실행기를 서버 시작 시 연결합니다.

package com.example.cooldown;

import org.bukkit.plugin.java.JavaPlugin;

public class CooldownPlugin extends JavaPlugin {
    @Override
    public void onEnable() {
        CooldownService cooldownService = new CooldownService(15_000L);

        SkillCommand skillCommand = new SkillCommand(this, cooldownService);
        getCommand("svskill").setExecutor(skillCommand);
        getCommand("svskill").setTabCompleter(skillCommand);

        getLogger().info("CooldownPlugin enabled.");
    }
}

setExecutor(...)setTabCompleter(...)를 함께 등록하는 이유는 같은 명령어 규칙을 한 클래스에서 관리하기 위해서입니다.
관리 포인트가 분산되지 않아 기능 확장(예: 스킬 종류 추가) 시 실수가 줄어듭니다.

자주 하는 실수와 실패 케이스

  • getCommand("svskill") == null
    • 원인: plugin.yml 명령어 이름 오타
  • 관리자 권한 없이 /svskill reset 실행
    • 원인: sv.skill.admin 권한 부재
  • 쿨다운 계산 음수 출력
    • 원인: Math.max(..., 0) 처리 누락
  • 콘솔에서 /svskill cast 실행
    • 원인: 플레이어 전용 명령 분기 누락

수동 테스트 체크리스트 (실전 운영 기준)

  • 플레이어가 /svskill cast를 처음 실행하면 즉시 성공한다.
  • 같은 플레이어가 15초 이내 재실행하면 남은 시간이 출력된다.
  • /svskill left에서 남은 시간이 0초 이하일 때 사용 가능 메시지가 출력된다.
  • OP 또는 권한 보유 계정이 /svskill reset <player>를 실행하면 대상 쿨다운이 초기화된다.
  • 권한 없는 계정이 /svskill reset <player>를 실행하면 거부 메시지가 출력된다.

이번 편 정리

이번 편에서는 쿨다운 시스템의 핵심인 시각 저장, 남은 시간 계산, 명령어 인자/권한 검증을 구현했습니다.
실전 서버에서 기능 남용을 막으려면 쿨다운은 거의 필수이며, 오늘 만든 구조를 기반으로 "스킬별 다른 쿨다운"도 쉽게 확장할 수 있습니다.

다음 편(#08)에서는 인벤토리 클릭 기반 GUI 메뉴를 만들어, 명령어 없이도 기능을 선택하는 인터페이스를 구현해보겠습니다.