[Paper 플러그인 실전 제작기 #05] 데이터 저장 1: YAML로 유저별 설정 저장

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

안녕하세요.

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.ymlpref 등록 안 됨
  • 명령어 이름 오타 (pref vs prefs)

2) 서버 재시작 후 값이 초기화된 것처럼 보인다

증상:

  • /pref notify off 했는데 재시작 후 다시 on

원인:

  • 저장(save)은 안 하고 메모리 값만 바꿈
  • 파일명에 닉네임을 써서 닉변 후 다른 파일을 읽음
  • YAML 키 이름 변경 후 이전 데이터 마이그레이션 없음

3) 저장 실패 메시지가 뜬다

증상:

  • IOException 발생

원인:

  • 폴더 생성 실패
  • 파일 권한 문제
  • 디스크/경로 문제

실전에서는 이럴 때 콘솔 로그에 UUID와 경로를 같이 남겨두면 추적이 훨씬 빨라집니다.


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

  • /pref show 입력 시 기본값이 정상 출력된다.
  • /pref notify off 후 다시 /pref show 하면 off로 보인다.
  • /pref autorepair onUUID.ymlauto-repair: true가 기록된다.
  • 서버 재시작 후 /pref show를 해도 값이 유지된다.
  • /pref notify maybe 입력 시 에러 메시지가 나온다.
  • 콘솔에서 /pref show 입력 시 "플레이어만" 메시지가 나온다.
  • 탭완성에서 notify, autorepair, on, off가 제안된다.

이번 편 정리

이번 편에서는 config.yml(서버 전체 설정)과 별도로,
플레이어별 YAML 파일을 사용해 유저 설정을 저장하는 구조를 만들었습니다.

핵심은 아래 3가지입니다.

  • UUID 기반 파일명으로 저장
  • Repository로 파일 입출력 분리
  • 명령어/이벤트에서 실제로 읽어서 기능에 연결

이 구조를 익혀두면 다음부터는

  • 개인 알림 설정
  • 튜토리얼 완료 여부
  • 개인 UI 옵션
  • 간단한 쿨다운 기록

같은 기능을 훨씬 빠르게 만들 수 있습니다.

다음 편(#06)에서는 이 흐름을 확장해서 SQLite로 랭킹/통계 관리를 다뤄보면,
YAML 저장 방식과 DB 방식의 차이(검색/집계/정렬)가 더 명확하게 보일 겁니다.