2026. 3. 2. 07:46ㆍJava/마인크래프트
안녕하세요. 지난 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: oppermissions를 미리 선언해 두면 운영 중 권한 플러그인(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 스킬 운영에서 필요한 "남용 방지" 패턴을 만들어보겠습니다.
'Java > 마인크래프트' 카테고리의 다른 글
| [Paper 플러그인 실전 제작기 #05] 데이터 저장 1: YAML로 유저별 설정 저장 (0) | 2026.02.26 |
|---|---|
| [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 |