2026. 3. 3. 15:45ㆍJava/마인크래프트
안녕하세요. 지난 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 메뉴를 만들어, 명령어 없이도 기능을 선택하는 인터페이스를 구현해보겠습니다.
'Java > 마인크래프트' 카테고리의 다른 글
| [Paper 플러그인 실전 제작기 #09] 비동기 처리와 성능 최적화: 메인 스레드 멈춤 없이 통계 조회하기 (3) | 2026.03.06 |
|---|---|
| [Paper 플러그인 실전 제작기 #06] 데이터 저장 2: SQLite로 랭킹/통계 관리 (0) | 2026.03.02 |
| [Paper 플러그인 실전 제작기 #05] 데이터 저장 1: YAML로 유저별 설정 저장 (0) | 2026.02.26 |
| [Paper 플러그인 실전 제작기 #04] config.yml로 기능 on/off + 메시지 커스터마이징 (3) | 2026.02.25 |
| [Paper 플러그인 실전 제작기 #03] 이벤트 리스너 실전 (스폰 보호구역에서 블록 설치/파괴 막기) (0) | 2026.02.23 |