[Paper 플러그인 실전 제작기 #06] 데이터 저장 2: SQLite로 랭킹/통계 관리

2026. 3. 2. 07:46Java/마인크래프트

안녕하세요. 지난 5편에서는 YAML 파일로 플레이어별 설정을 저장해봤습니다.
이번 6편에서는 한 단계 확장해서, 여러 플레이어 데이터를 모아 조회하기 쉬운 구조SQLite를 사용해 랭킹/통계를 만들어보겠습니다.

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

  • 몬스터 처치 수(kills) 누적 저장
  • 플레이어별 통계 조회 (/svstat show)
  • 상위 킬 랭킹 조회 (/svrank)

YAML은 1명 단위 설정 저장에 강하고, SQLite는 "여러 명 데이터 정렬/집계"에 강합니다.
실전 서버에서 랭킹, 통계, 시즌 데이터를 다룰 때 SQLite를 많이 쓰는 이유가 바로 이 부분입니다.

이번 편에서 완성할 결과

  • /svstat addkill <player> <amount>: 관리자용 킬 수 누적
  • /svstat show [player]: 본인 또는 대상 플레이어 통계 조회
  • /svrank: 킬 상위 10명 랭킹 조회
  • 저장 위치: plugins/플러그인명/data/stats.db
  • 서버 재시작 후에도 통계 유지

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

  • SQLite: 서버 안에 파일 하나(.db)로 동작하는 경량 데이터베이스입니다.
  • schema: 테이블 구조 정의입니다. 어떤 컬럼을 어떤 타입으로 저장할지 정합니다.
  • upsert: "있으면 업데이트, 없으면 삽입"을 한 번에 처리하는 방식입니다.
  • SQLException: SQL 실행 중 발생하는 예외입니다. 경로/문법/잠금 문제를 여기서 확인합니다.

0) Maven 의존성 추가 (sqlite-jdbc)

한 줄 목적: Java 코드에서 SQLite 파일에 접속할 JDBC 드라이버를 추가합니다.

<dependency>
  <groupId>org.xerial</groupId>
  <artifactId>sqlite-jdbc</artifactId>
  <version>3.46.0.0</version>
</dependency>

이 의존성이 없으면 jdbc:sqlite: URL로 연결할 수 없습니다. 빌드 후 플러그인 jar에 드라이버가 포함되도록 Maven 설정을 확인해 주세요.

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

한 줄 목적: Paper가 명령어를 인식하고 권한 체크를 할 수 있게 기본 정의를 등록합니다.

name: sqlite-stats-demo
version: 1.0.0
main: com.example.sqlitestats.SqliteStatsPlugin
api-version: "1.21"

commands:
  svstat:
    description: statistics command
    usage: /svstat <addkill|show> [player] [amount]
  svrank:
    description: ranking command
    usage: /svrank

permissions:
  sv.stats.admin:
    description: can modify stats
    default: op

permissions를 미리 선언해 두면 운영 중 권한 플러그인(LuckPerms 등)과 연결하기가 쉬워집니다.

2) SQLite 연결/스키마 초기화 클래스

한 줄 목적: DB 파일 경로를 만들고, 서버 시작 시 테이블을 자동 생성합니다.

package com.example.sqlitestats;

import java.io.File;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.SQLException;
import java.sql.Statement;

public class SqliteManager {
    private final File dbFile;
    private Connection connection;

    public SqliteManager(File dataFolder) {
        File dbDir = new File(dataFolder, "data");
        if (!dbDir.exists() && !dbDir.mkdirs()) {
            throw new IllegalStateException("Could not create DB directory: " + dbDir.getAbsolutePath());
        }
        this.dbFile = new File(dbDir, "stats.db");
    }

    public void connect() throws SQLException {
        String url = "jdbc:sqlite:" + dbFile.getAbsolutePath();
        this.connection = DriverManager.getConnection(url);
        initializeSchema();
    }

    public Connection getConnection() {
        if (connection == null) {
            throw new IllegalStateException("Database connection is not initialized");
        }
        return connection;
    }

    private void initializeSchema() throws SQLException {
        String sql = """
            CREATE TABLE IF NOT EXISTS player_stats (
                player_uuid TEXT PRIMARY KEY,
                last_name   TEXT NOT NULL,
                kills       INTEGER NOT NULL DEFAULT 0,
                updated_at  TEXT NOT NULL
            )
            """;

        try (Statement statement = connection.createStatement()) {
            statement.execute(sql);
        }
    }

    public void close() {
        if (connection == null) {
            return;
        }
        try {
            connection.close();
        } catch (SQLException ignored) {
        }
    }
}

스키마를 코드에 포함하면, 서버 첫 기동 시 테이블 생성이 자동으로 이뤄져서 초기 배포가 편해집니다.

3) 통계 DTO(데이터 객체) 만들기

한 줄 목적: 조회 결과를 자바 객체로 받아서 명령어에서 쉽게 출력합니다.

package com.example.sqlitestats;

public record PlayerStat(String playerUuid, String lastName, int kills, String updatedAt) {
}

record를 쓰면 getter/생성자를 간결하게 만들 수 있어 읽기 쉬워집니다.

4) 저장/조회 Repository 구현

한 줄 목적: SQL 코드를 한곳에 모아 upsert, 단건 조회, 랭킹 조회를 담당하게 합니다.

package com.example.sqlitestats;

import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.UUID;

public class PlayerStatRepository {
    private final SqliteManager sqliteManager;

    public PlayerStatRepository(SqliteManager sqliteManager) {
        this.sqliteManager = sqliteManager;
    }

    public void addKills(UUID uuid, String name, int amount) throws SQLException {
        String sql = """
            INSERT INTO player_stats (player_uuid, last_name, kills, updated_at)
            VALUES (?, ?, ?, ?)
            ON CONFLICT(player_uuid)
            DO UPDATE SET
                last_name = excluded.last_name,
                kills = player_stats.kills + excluded.kills,
                updated_at = excluded.updated_at
            """;

        try (PreparedStatement ps = sqliteManager.getConnection().prepareStatement(sql)) {
            ps.setString(1, uuid.toString());
            ps.setString(2, name);
            ps.setInt(3, amount);
            ps.setString(4, LocalDateTime.now().toString());
            ps.executeUpdate();
        }
    }

    public Optional<PlayerStat> findByUuid(UUID uuid) throws SQLException {
        String sql = "SELECT player_uuid, last_name, kills, updated_at FROM player_stats WHERE player_uuid = ?";

        try (PreparedStatement ps = sqliteManager.getConnection().prepareStatement(sql)) {
            ps.setString(1, uuid.toString());
            try (ResultSet rs = ps.executeQuery()) {
                if (!rs.next()) {
                    return Optional.empty();
                }
                return Optional.of(new PlayerStat(
                    rs.getString("player_uuid"),
                    rs.getString("last_name"),
                    rs.getInt("kills"),
                    rs.getString("updated_at")
                ));
            }
        }
    }

    public List<PlayerStat> findTopKills(int limit) throws SQLException {
        String sql = """
            SELECT player_uuid, last_name, kills, updated_at
            FROM player_stats
            ORDER BY kills DESC, updated_at ASC
            LIMIT ?
            """;

        List<PlayerStat> result = new ArrayList<>();
        Connection connection = sqliteManager.getConnection();

        try (PreparedStatement ps = connection.prepareStatement(sql)) {
            ps.setInt(1, limit);
            try (ResultSet rs = ps.executeQuery()) {
                while (rs.next()) {
                    result.add(new PlayerStat(
                        rs.getString("player_uuid"),
                        rs.getString("last_name"),
                        rs.getInt("kills"),
                        rs.getString("updated_at")
                    ));
                }
            }
        }
        return result;
    }
}

ON CONFLICT ... DO UPDATE가 핵심입니다. 플레이어가 이미 있으면 kills를 누적하고, 없으면 새로 만듭니다.

5) /svstat 명령어 구현 (권한 + 인자 검증 + 에러 처리)

한 줄 목적: 관리자 누적 명령과 일반 조회 명령을 하나의 커맨드에서 처리합니다.

package com.example.sqlitestats;

import java.sql.SQLException;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
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 StatCommand implements CommandExecutor, TabCompleter {
    private final SqliteStatsPlugin plugin;
    private final PlayerStatRepository repository;

    public StatCommand(SqliteStatsPlugin plugin, PlayerStatRepository repository) {
        this.plugin = plugin;
        this.repository = repository;
    }

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

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

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

            if (args.length < 3) {
                sender.sendMessage("§e사용법: /svstat addkill <player> <amount>");
                return true;
            }

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

            int amount;
            try {
                amount = Integer.parseInt(args[2]);
            } catch (NumberFormatException e) {
                sender.sendMessage("§camount는 숫자만 입력할 수 있습니다.");
                return true;
            }

            if (amount <= 0) {
                sender.sendMessage("§camount는 1 이상의 정수여야 합니다.");
                return true;
            }

            try {
                repository.addKills(target.getUniqueId(), target.getName(), amount);
            } catch (SQLException e) {
                sender.sendMessage("§cDB 저장 중 오류가 발생했습니다. 콘솔 로그를 확인해 주세요.");
                plugin.getLogger().severe("Failed to add kills: " + e.getMessage());
                return true;
            }

            sender.sendMessage("§a저장 완료: " + target.getName() + " kills +" + amount);
            return true;
        }

        if (sub.equals("show")) {
            Player targetPlayer;

            if (args.length >= 2) {
                targetPlayer = Bukkit.getPlayerExact(args[1]);
                if (targetPlayer == null) {
                    sender.sendMessage("§c대상 플레이어가 온라인이 아닙니다.");
                    return true;
                }
            } else {
                if (!(sender instanceof Player player)) {
                    sender.sendMessage("§c콘솔에서는 /svstat show <player> 형태로 입력해 주세요.");
                    return true;
                }
                targetPlayer = player;
            }

            Optional<PlayerStat> found;
            try {
                found = repository.findByUuid(targetPlayer.getUniqueId());
            } catch (SQLException e) {
                sender.sendMessage("§cDB 조회 중 오류가 발생했습니다.");
                plugin.getLogger().severe("Failed to fetch stat: " + e.getMessage());
                return true;
            }

            if (found.isEmpty()) {
                sender.sendMessage("§e아직 저장된 통계가 없습니다.");
                return true;
            }

            PlayerStat stat = found.get();
            sender.sendMessage("§6[통계] " + stat.lastName());
            sender.sendMessage("§f- kills: " + stat.kills());
            sender.sendMessage("§7- updated-at: " + stat.updatedAt());
            return true;
        }

        sender.sendMessage("§c알 수 없는 하위 명령입니다. addkill/show 중 하나를 사용해 주세요.");
        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 ("addkill".startsWith(input)) result.add("addkill");
            if ("show".startsWith(input)) result.add("show");
            return result;
        }

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

        if (args.length == 3 && args[0].equalsIgnoreCase("addkill")) {
            if ("1".startsWith(args[2])) result.add("1");
            if ("5".startsWith(args[2])) result.add("5");
            if ("10".startsWith(args[2])) result.add("10");
            return result;
        }

        return result;
    }
}

hasPermission(...)를 먼저 검사하는 이유는, 권한 없는 사용자가 관리자 기능을 실행하기 전에 빠르게 차단하기 위해서입니다.

Integer.parseInt(...)는 숫자 검증의 핵심입니다. try-catch로 감싸지 않으면 문자열 입력 시 예외가 터져 커맨드가 비정상 종료될 수 있습니다.

args.length 분기를 명확히 나누면 탭 완성과 실제 처리 로직이 같은 규칙으로 움직여서 사용자 입력 실수가 줄어듭니다.

6) /svrank 명령어 구현 (상위 10명)

한 줄 목적: 누적된 데이터를 정렬해서 운영자가 바로 확인할 수 있는 랭킹을 출력합니다.

package com.example.sqlitestats;

import java.sql.SQLException;
import java.util.List;
import org.bukkit.command.Command;
import org.bukkit.command.CommandExecutor;
import org.bukkit.command.CommandSender;

public class RankCommand implements CommandExecutor {
    private final SqliteStatsPlugin plugin;
    private final PlayerStatRepository repository;

    public RankCommand(SqliteStatsPlugin plugin, PlayerStatRepository repository) {
        this.plugin = plugin;
        this.repository = repository;
    }

    @Override
    public boolean onCommand(CommandSender sender, Command command, String label, String[] args) {
        List<PlayerStat> ranking;
        try {
            ranking = repository.findTopKills(10);
        } catch (SQLException e) {
            sender.sendMessage("§c랭킹 조회 중 DB 오류가 발생했습니다.");
            plugin.getLogger().severe("Failed to load ranking: " + e.getMessage());
            return true;
        }

        if (ranking.isEmpty()) {
            sender.sendMessage("§e표시할 랭킹 데이터가 없습니다.");
            return true;
        }

        sender.sendMessage("§6[킬 랭킹 TOP 10]");
        int rank = 1;
        for (PlayerStat stat : ranking) {
            sender.sendMessage("§f" + rank + ". " + stat.lastName() + " - " + stat.kills());
            rank++;
        }
        return true;
    }
}

랭킹은 조회 부하가 작을 때는 동기 실행으로도 충분하지만, 데이터가 커지면 다음 편(비동기/성능)에서 메인 스레드 분리를 고려하면 더 안전합니다.

7) 메인 플러그인에서 등록/종료 처리

한 줄 목적: DB 연결, 명령어 실행기 등록, 종료 시 연결 해제를 한곳에서 관리합니다.

package com.example.sqlitestats;

import java.sql.SQLException;
import org.bukkit.plugin.java.JavaPlugin;

public class SqliteStatsPlugin extends JavaPlugin {
    private SqliteManager sqliteManager;

    @Override
    public void onEnable() {
        this.sqliteManager = new SqliteManager(getDataFolder());

        try {
            sqliteManager.connect();
        } catch (SQLException e) {
            getLogger().severe("Failed to connect sqlite: " + e.getMessage());
            getServer().getPluginManager().disablePlugin(this);
            return;
        }

        PlayerStatRepository repository = new PlayerStatRepository(sqliteManager);

        StatCommand statCommand = new StatCommand(this, repository);
        getCommand("svstat").setExecutor(statCommand);
        getCommand("svstat").setTabCompleter(statCommand);

        getCommand("svrank").setExecutor(new RankCommand(this, repository));

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

    @Override
    public void onDisable() {
        if (sqliteManager != null) {
            sqliteManager.close();
        }
    }
}

setExecutor(...)setTabCompleter(...)를 함께 등록하면 명령 처리와 자동완성을 같은 클래스 문맥에서 유지할 수 있어 유지보수성이 좋아집니다.

onDisable()에서 연결을 닫는 이유는, 서버 종료/리로드 시 파일 잠금 문제를 줄이고 데이터 손상 가능성을 낮추기 위해서입니다.

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

  • No suitable driver 오류
    • 원인: sqlite-jdbc 의존성 누락
  • getCommand("svstat") == null
    • 원인: plugin.yml 명령어 이름 오타
  • amount에 문자열 입력
    • 원인: Integer.parseInt 예외 처리 누락
  • DB 파일 권한 문제
    • 원인: 호스팅 환경의 쓰기 권한 제한
  • 오프라인 플레이어 조회 실패
    • 현재 예제는 Bukkit.getPlayerExact 기반이라 온라인 대상 중심

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

  • /svstat addkill <온라인플레이어> 5 실행 시 저장 성공 메시지가 출력된다.
  • /svstat show <온라인플레이어>에서 kills가 누적 반영된다.
  • /svrank에서 킬 순서대로 상위 목록이 출력된다.
  • 서버 재시작 후 /svrank 결과가 유지된다.
  • /svstat addkill <player> abc 입력 시 숫자 검증 메시지가 출력된다.
  • 권한 없는 계정에서 addkill 실행 시 권한 거부 메시지가 출력된다.

이번 편 정리

이번 편에서는 YAML 단건 저장에서 확장해, SQLite 기반 통계/랭킹 구조를 만들었습니다.
핵심은 스키마 초기화, upsert, 명령어 인자 검증, SQL 예외 처리 4가지입니다.

다음 편(#07)에서는 이번 통계 구조에 쿨다운/사용 제한 로직을 붙여서,
실전 PvE/PvP 스킬 운영에서 필요한 "남용 방지" 패턴을 만들어보겠습니다.