2026. 2. 26. 21:11ㆍJava/마인크래프트
안녕하세요.
1편에서 개발환경 세팅과 첫 플러그인 로딩,
2편에서 명령어 시스템(/heal, 권한, 인자 파싱, 탭완성),
3편에서 이벤트 처리,
4편에서 config.yml로 기능 on/off + 메시지 커스터마이징을 다뤘다면,
이번 5편에서는 실전 서버에서 바로 자주 쓰는 패턴인 유저별 데이터 저장을 만들어보겠습니다.
이번 편의 핵심은 config.yml(서버 전체 설정)과 다르게,
플레이어마다 다른 값을 YAML 파일로 저장/복원하는 구조를 만드는 것입니다.
이번 예제에서 만들 기능은 아래 2가지입니다.
- 플레이어별 알림 수신 여부 (
notify) - 플레이어별 자동수리 사용 여부 (
auto-repair)
서버를 껐다 켜도 값이 유지되도록, 플레이어마다 UUID.yml 파일로 저장하겠습니다.
이번 편 목표
예제로 /pref 명령어를 만듭니다.
/pref show/pref notify on|off/pref autorepair on|off
저장 위치는 이런 형태입니다.
plugins/플러그인명/players/<uuid>.yml
즉, 운영자가 나중에 유저별 설정 문제를 확인할 때도 파일 단위로 추적하기 쉬운 구조가 목표입니다.
먼저 알아둘 용어 (초보자 기준)
YAML- 사람이 읽기 쉬운 설정 파일 형식입니다.
key: value형태를 많이 씁니다.
- 사람이 읽기 쉬운 설정 파일 형식입니다.
UUID- 플레이어의 고유 식별자입니다. 닉네임이 바뀌어도 유지됩니다.
Repository- 저장/조회 로직을 한곳에 모아둔 클래스입니다. 파일 입출력 코드가 흩어지는 걸 막아줍니다.
저장될 파일 예시
플레이어 UUID가 1234...abcd라고 하면, 파일은 이런 식으로 저장됩니다.
notify: true
auto-repair: false
last-name: Steve
updated-at: "2026-02-26T21:00:00"
포인트는 2개입니다.
- 파일명은 닉네임이 아니라
UUID로 저장 - 사람이 직접 열어봐도 이해 가능한 형태로 저장
실전 운영에서는 이게 디버깅할 때 꽤 큰 장점입니다.
1) plugin.yml 설정
한 줄 요약: /pref 명령어를 서버에 등록합니다.
name: yaml-user-settings-demo
version: 1.0.0
main: com.example.yamlsettings.YamlUserSettingsPlugin
api-version: "1.21"
commands:
pref:
description: Player preference command
usage: /pref <show|notify|autorepair> [on|off]
plugin.yml에 명령어가 등록되어 있어야 서버가 /pref를 인식합니다.
이 설정이 빠지면 코드에 CommandExecutor를 만들어도 명령어가 연결되지 않습니다.
2) 플레이어 설정 데이터 클래스 만들기
한 줄 요약: 메모리에서 다룰 유저 설정 구조를 먼저 만듭니다.
package com.example.yamlsettings;
public class PlayerSettings {
private boolean notifyEnabled = true;
private boolean autoRepairEnabled = false;
private String lastName = "";
private String updatedAt = "";
public boolean isNotifyEnabled() {
return notifyEnabled;
}
public void setNotifyEnabled(boolean notifyEnabled) {
this.notifyEnabled = notifyEnabled;
}
public boolean isAutoRepairEnabled() {
return autoRepairEnabled;
}
public void setAutoRepairEnabled(boolean autoRepairEnabled) {
this.autoRepairEnabled = autoRepairEnabled;
}
public String getLastName() {
return lastName;
}
public void setLastName(String lastName) {
this.lastName = lastName;
}
public String getUpdatedAt() {
return updatedAt;
}
public void setUpdatedAt(String updatedAt) {
this.updatedAt = updatedAt;
}
}
지금은 단순한 클래스(POJO)로 충분합니다.
처음부터 복잡한 직렬화 라이브러리를 붙이기보다, Paper의 YamlConfiguration으로 실전 기능을 먼저 완성하는 편이 학습/유지보수 모두 유리합니다.
3) YAML 저장/불러오기 Repository 만들기
한 줄 요약: 플레이어별 YAML 파일 입출력을 한 클래스에 모읍니다.
package com.example.yamlsettings;
import java.io.File;
import java.io.IOException;
import java.time.LocalDateTime;
import java.util.UUID;
import org.bukkit.configuration.file.YamlConfiguration;
public class PlayerSettingsRepository {
private final File playersDir;
public PlayerSettingsRepository(File dataFolder) {
this.playersDir = new File(dataFolder, "players");
if (!playersDir.exists() && !playersDir.mkdirs()) {
throw new IllegalStateException("Could not create players directory: " + playersDir.getAbsolutePath());
}
}
public PlayerSettings load(UUID uuid) {
File file = new File(playersDir, uuid + ".yml");
PlayerSettings settings = new PlayerSettings();
if (!file.exists()) {
return settings;
}
YamlConfiguration yaml = YamlConfiguration.loadConfiguration(file);
settings.setNotifyEnabled(yaml.getBoolean("notify", true));
settings.setAutoRepairEnabled(yaml.getBoolean("auto-repair", false));
settings.setLastName(yaml.getString("last-name", ""));
settings.setUpdatedAt(yaml.getString("updated-at", ""));
return settings;
}
public void save(UUID uuid, String playerName, PlayerSettings settings) throws IOException {
File file = new File(playersDir, uuid + ".yml");
YamlConfiguration yaml = new YamlConfiguration();
yaml.set("notify", settings.isNotifyEnabled());
yaml.set("auto-repair", settings.isAutoRepairEnabled());
yaml.set("last-name", playerName);
yaml.set("updated-at", LocalDateTime.now().toString());
yaml.save(file);
}
}
여기서 중요한 포인트는 yaml.getBoolean("notify", true) 같은 기본값(default value) 입니다.
운영 중에 파일이 비어 있거나 키가 일부 빠져도 플러그인이 최대한 계속 동작하게 만들기 위해서입니다.
실전 서버에서는 "설정 파일 한 줄 실수로 전체 기능이 죽는 상황"을 줄이는 게 중요합니다.
4) 메인 플러그인 클래스에서 초기화 + 명령어 등록
한 줄 요약: 플러그인 시작 시 Repository를 만들고 /pref 명령어를 연결합니다.
package com.example.yamlsettings;
import org.bukkit.plugin.java.JavaPlugin;
public class YamlUserSettingsPlugin extends JavaPlugin {
private PlayerSettingsRepository repository;
@Override
public void onEnable() {
saveDefaultConfig(); // 이번 편 핵심은 유저별 YAML이지만 config.yml도 기본 생성
this.repository = new PlayerSettingsRepository(getDataFolder());
PrefCommand prefCommand = new PrefCommand(this, repository);
getCommand("pref").setExecutor(prefCommand);
getCommand("pref").setTabCompleter(prefCommand);
getServer().getPluginManager().registerEvents(new PlayerJoinListener(repository), this);
getLogger().info("YamlUserSettingsPlugin enabled.");
}
public PlayerSettingsRepository getRepository() {
return repository;
}
}
setExecutor(...), setTabCompleter(...)를 같이 등록한 이유는 명령 처리와 탭완성을 한곳에서 관리하기 위해서입니다.
작은 예제에서는 이 구성이 특히 읽기 쉽고, 사용성도 바로 좋아집니다.
5) 접속 시 기본 설정 파일 자동 생성 (Join 이벤트)
한 줄 요약: 처음 접속한 플레이어도 바로 설정 파일이 생기도록 만듭니다.
package com.example.yamlsettings;
import java.io.IOException;
import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler;
import org.bukkit.event.Listener;
import org.bukkit.event.player.PlayerJoinEvent;
public class PlayerJoinListener implements Listener {
private final PlayerSettingsRepository repository;
public PlayerJoinListener(PlayerSettingsRepository repository) {
this.repository = repository;
}
@EventHandler
public void onJoin(PlayerJoinEvent event) {
Player player = event.getPlayer();
PlayerSettings settings = repository.load(player.getUniqueId());
try {
repository.save(player.getUniqueId(), player.getName(), settings);
} catch (IOException e) {
player.sendMessage("§c설정 파일 저장에 실패했습니다. 관리자에게 문의하세요.");
e.printStackTrace();
}
}
}
처음 접속한 유저는 파일이 없기 때문에 load()에서 기본값 객체가 반환됩니다.
그 값을 바로 저장하면 기본 템플릿 파일이 생성되는 흐름입니다.
이 패턴은 나중에 "첫 접속 보상 설정", "튜토리얼 진행 여부" 같은 기능에도 그대로 확장할 수 있습니다.
6) /pref 명령어 구현 (조회 + on/off 변경)
한 줄 요약: 유저가 자신의 설정을 직접 보고 바꿀 수 있게 합니다.
package com.example.yamlsettings;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
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 PrefCommand implements CommandExecutor, TabCompleter {
private final YamlUserSettingsPlugin plugin;
private final PlayerSettingsRepository repository;
public PrefCommand(YamlUserSettingsPlugin plugin, PlayerSettingsRepository repository) {
this.plugin = plugin;
this.repository = repository;
}
@Override
public boolean onCommand(CommandSender sender, Command command, String label, String[] args) {
if (!(sender instanceof Player player)) {
sender.sendMessage("이 명령어는 플레이어만 사용할 수 있습니다.");
return true;
}
PlayerSettings settings = repository.load(player.getUniqueId());
if (args.length == 0 || args[0].equalsIgnoreCase("show")) {
sendStatus(player, settings);
return true;
}
if (args.length < 2) {
player.sendMessage("§e사용법: /pref <notify|autorepair> <on|off>");
return true;
}
String target = args[0].toLowerCase();
String value = args[1].toLowerCase();
Boolean enabled = parseOnOff(value);
if (enabled == null) {
player.sendMessage("§c값은 on 또는 off만 사용할 수 있습니다.");
return true;
}
switch (target) {
case "notify" -> settings.setNotifyEnabled(enabled);
case "autorepair" -> settings.setAutoRepairEnabled(enabled);
default -> {
player.sendMessage("§c알 수 없는 옵션입니다. notify 또는 autorepair를 사용하세요.");
return true;
}
}
try {
repository.save(player.getUniqueId(), player.getName(), settings);
} catch (IOException e) {
player.sendMessage("§c저장 실패: 콘솔 로그를 확인하세요.");
plugin.getLogger().severe("Failed to save settings for " + player.getUniqueId());
e.printStackTrace();
return true;
}
player.sendMessage("§a설정이 저장되었습니다: " + target + " = " + (enabled ? "on" : "off"));
return true;
}
private void sendStatus(Player player, PlayerSettings settings) {
player.sendMessage("§6[내 설정]");
player.sendMessage("§f- notify: " + (settings.isNotifyEnabled() ? "on" : "off"));
player.sendMessage("§f- autorepair: " + (settings.isAutoRepairEnabled() ? "on" : "off"));
}
private Boolean parseOnOff(String value) {
if (value.equals("on")) return true;
if (value.equals("off")) return false;
return null;
}
@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 ("show".startsWith(input)) result.add("show");
if ("notify".startsWith(input)) result.add("notify");
if ("autorepair".startsWith(input)) result.add("autorepair");
return result;
}
if (args.length == 2) {
String input = args[1].toLowerCase();
if ("on".startsWith(input)) result.add("on");
if ("off".startsWith(input)) result.add("off");
return result;
}
return result;
}
}
이번 코드에서 꼭 봐야 할 포인트는 3가지입니다.
sender instanceof Player- 콘솔/커맨드블록에서 들어오는 경우를 먼저 막아 예외 상황을 줄입니다.
args.length검사 먼저args[0],args[1]접근 전에 길이 체크를 하지 않으면 런타임 예외가 나기 쉽습니다.
parseOnOff()분리- 문자열 처리 로직을 분리해두면 나중에 옵션이 늘어나도
onCommand()가 덜 복잡해집니다.
- 문자열 처리 로직을 분리해두면 나중에 옵션이 늘어나도
7) 저장한 설정을 실제 기능에서 사용하는 예시 (이벤트 연동)
한 줄 요약: 저장만 하지 말고, 실제 이벤트 로직에 연결해봅니다.
package com.example.yamlsettings;
import org.bukkit.event.EventHandler;
import org.bukkit.event.Listener;
import org.bukkit.event.player.PlayerItemDamageEvent;
public class AutoRepairBlockListener implements Listener {
private final PlayerSettingsRepository repository;
public AutoRepairBlockListener(PlayerSettingsRepository repository) {
this.repository = repository;
}
@EventHandler
public void onItemDamage(PlayerItemDamageEvent event) {
PlayerSettings settings = repository.load(event.getPlayer().getUniqueId());
if (!settings.isAutoRepairEnabled()) {
return;
}
// 데모용: 자동수리가 켜져 있으면 내구도 감소를 막음
event.setCancelled(true);
}
}
지금은 설명을 단순하게 하기 위해 이벤트 때마다 파일을 읽었습니다.
하지만 이벤트가 자주 발생하는 기능이라면 이 방식은 비효율적일 수 있습니다.
실전에서는 보통 이렇게 확장합니다.
- 접속 시 메모리에 로드 (캐시)
- 변경 시 메모리 갱신 + 파일 저장
- 퇴장 시 저장/정리
이 캐시 패턴은 다음 편들(특히 통계/랭킹)로 넘어갈 때도 매우 중요해집니다.
자주 나오는 실수 (실패 케이스)
1) getCommand("pref")가 null이다
증상:
- 플러그인 enable 시
NullPointerException
원인:
plugin.yml에pref등록 안 됨- 명령어 이름 오타 (
prefvsprefs)
2) 서버 재시작 후 값이 초기화된 것처럼 보인다
증상:
/pref notify off했는데 재시작 후 다시on
원인:
- 저장(
save)은 안 하고 메모리 값만 바꿈 - 파일명에 닉네임을 써서 닉변 후 다른 파일을 읽음
- YAML 키 이름 변경 후 이전 데이터 마이그레이션 없음
3) 저장 실패 메시지가 뜬다
증상:
IOException발생
원인:
- 폴더 생성 실패
- 파일 권한 문제
- 디스크/경로 문제
실전에서는 이럴 때 콘솔 로그에 UUID와 경로를 같이 남겨두면 추적이 훨씬 빨라집니다.
수동 테스트 체크리스트 (실전 운영 기준)
/pref show입력 시 기본값이 정상 출력된다./pref notify off후 다시/pref show하면off로 보인다./pref autorepair on후UUID.yml에auto-repair: true가 기록된다.- 서버 재시작 후
/pref show를 해도 값이 유지된다. /pref notify maybe입력 시 에러 메시지가 나온다.- 콘솔에서
/pref show입력 시 "플레이어만" 메시지가 나온다. - 탭완성에서
notify,autorepair,on,off가 제안된다.
이번 편 정리
이번 편에서는 config.yml(서버 전체 설정)과 별도로,
플레이어별 YAML 파일을 사용해 유저 설정을 저장하는 구조를 만들었습니다.
핵심은 아래 3가지입니다.
UUID기반 파일명으로 저장Repository로 파일 입출력 분리- 명령어/이벤트에서 실제로 읽어서 기능에 연결
이 구조를 익혀두면 다음부터는
- 개인 알림 설정
- 튜토리얼 완료 여부
- 개인 UI 옵션
- 간단한 쿨다운 기록
같은 기능을 훨씬 빠르게 만들 수 있습니다.
다음 편(#06)에서는 이 흐름을 확장해서 SQLite로 랭킹/통계 관리를 다뤄보면,
YAML 저장 방식과 DB 방식의 차이(검색/집계/정렬)가 더 명확하게 보일 겁니다.
'Java > 마인크래프트' 카테고리의 다른 글
| [Paper 플러그인 실전 제작기 #06] 데이터 저장 2: SQLite로 랭킹/통계 관리 (0) | 2026.03.02 |
|---|---|
| [Paper 플러그인 실전 제작기 #04] config.yml로 기능 on/off + 메시지 커스터마이징 (2) | 2026.02.25 |
| [Paper 플러그인 실전 제작기 #03] 이벤트 리스너 실전 (스폰 보호구역에서 블록 설치/파괴 막기) (0) | 2026.02.23 |
| [Paper 플러그인 실전 제작기 #02] 명령어 시스템 실전 (/heal 권한, 인자 파싱, 탭 완성) (0) | 2026.02.21 |
| [Paper 플러그인 실전 제작기 #01] Java 21 + Maven으로 첫 플러그인 만들기 (0) | 2026.02.20 |