2026. 3. 6. 22:39ㆍJava/마인크래프트
안녕하세요.
지난 8편에서 GUI 메뉴를 만들었다면, 이번 9편에서는 운영 서버에서 꼭 필요한 성능 안정화 패턴을 다룹니다.
이번 편 핵심은 간단합니다.
- 느린 작업(DB 조회, 파일 IO)은 비동기로 돌리고
- 플레이어에게 메시지를 보내는 작업은 메인 스레드로 다시 돌아와 처리하기
이 패턴을 익히면 "명령어 치면 서버가 순간 멈추는 느낌"을 크게 줄일 수 있습니다.
이번 편에서 만들 기능
/svstats <player>명령어로 플레이어 통계 조회- 조회 작업은 비동기 처리
- 결과 메시지는 메인 스레드에서 안전하게 전송
- 실패/예외 상황 메시지 분리
먼저 개념 정리 (초보자 기준)
main thread(메인 스레드): Paper 서버의 핵심 게임 로직이 도는 스레드async(비동기): 메인 스레드와 분리된 작업 스레드에서 처리CompletableFuture: 비동기 결과를 이어서 처리하기 쉬운 Java 도구
중요한 원칙 한 줄:
Bukkit/Paper API 대부분은 메인 스레드 전용이므로, 비동기 스레드에서 월드/엔티티 직접 접근은 피해야 합니다.
1) plugin.yml 등록
한 줄 목적: 명령어와 권한 노드를 먼저 정의해 서버 운영 시 혼선을 줄입니다.
name: async-demo
version: 1.0.0
main: com.example.asyncdemo.AsyncDemoPlugin
api-version: "1.21"
commands:
svstats:
description: show player stats asynchronously
usage: /svstats <player>
permission: asyncdemo.stats
permissions:
asyncdemo.stats:
description: allow using /svstats
default: true
permission을 선언해 두면 LuckPerms 같은 권한 플러그인과 연결할 때 관리가 쉬워집니다.
2) 메인 플러그인에서 명령어 연결
한 줄 목적: 서비스와 명령어 실행기를 enable 시점에 연결합니다.
package com.example.asyncdemo;
import org.bukkit.plugin.java.JavaPlugin;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public final class AsyncDemoPlugin extends JavaPlugin {
private ExecutorService ioExecutor;
@Override
public void onEnable() {
this.ioExecutor = Executors.newFixedThreadPool(2);
StatsRepository repository = new StatsRepository();
StatsService statsService = new StatsService(this, repository, ioExecutor);
getCommand("svstats").setExecutor(new StatsCommand(statsService));
}
@Override
public void onDisable() {
if (ioExecutor != null) {
ioExecutor.shutdown();
}
}
}
setExecutor(...)는 /svstats 입력을 어느 클래스로 보낼지 정하는 핵심 연결 지점입니다.
Executor를 닫아주지 않으면 종료 시 스레드가 남을 수 있어 onDisable() 정리가 중요합니다.
3) 느린 조회 로직(예시 Repository)
한 줄 목적: DB/파일처럼 느릴 수 있는 작업을 분리합니다.
package com.example.asyncdemo;
public class StatsRepository {
public PlayerStats findByName(String name) {
// 실제 환경에서는 JDBC 조회가 들어가는 자리
// 예시로 지연을 넣어 "느린 작업" 상황을 재현
try {
Thread.sleep(800L);
} catch (InterruptedException ignored) {
}
// 데모 데이터
return new PlayerStats(name, 37, 12);
}
}
실전에서는 JDBC 쿼리, 파일 파싱 같은 작업이 들어갑니다.
이런 코드를 메인 스레드에서 돌리면 틱 지연(렉 체감)이 생길 수 있습니다.
4) 비동기 서비스 + 메인 스레드 복귀
한 줄 목적: 느린 작업은 비동기, Bukkit API는 메인 스레드에서 처리합니다.
package com.example.asyncdemo;
import org.bukkit.Bukkit;
import org.bukkit.command.CommandSender;
import org.bukkit.plugin.Plugin;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.Executor;
public class StatsService {
private final Plugin plugin;
private final StatsRepository repository;
private final Executor ioExecutor;
public StatsService(Plugin plugin, StatsRepository repository, Executor ioExecutor) {
this.plugin = plugin;
this.repository = repository;
this.ioExecutor = ioExecutor;
}
public void queryAndSend(CommandSender sender, String targetName) {
CompletableFuture
.supplyAsync(() -> repository.findByName(targetName), ioExecutor)
.thenAccept(stats -> Bukkit.getScheduler().runTask(plugin, () -> {
sender.sendMessage("[" + stats.name() + "] Kills=" + stats.kills() + ", Deaths=" + stats.deaths());
}))
.exceptionally(ex -> {
Bukkit.getScheduler().runTask(plugin, () -> {
sender.sendMessage("통계 조회 중 오류가 발생했습니다.");
});
return null;
});
}
}
Bukkit.getScheduler().runTask(...)를 쓰는 이유는 "메시지 전송 같은 Bukkit API 호출을 메인 스레드에서 안전하게" 하기 위해서입니다.
이 경계를 지키면 async 관련 이상 동작을 크게 줄일 수 있습니다.
5) 명령어 클래스(권한/인자 검증 포함)
한 줄 목적: 잘못된 입력을 빠르게 차단하고, 서비스 호출은 한 곳으로 모읍니다.
package com.example.asyncdemo;
import org.bukkit.command.Command;
import org.bukkit.command.CommandExecutor;
import org.bukkit.command.CommandSender;
public class StatsCommand implements CommandExecutor {
private final StatsService statsService;
public StatsCommand(StatsService statsService) {
this.statsService = statsService;
}
@Override
public boolean onCommand(CommandSender sender, Command command, String label, String[] args) {
if (!sender.hasPermission("asyncdemo.stats")) {
sender.sendMessage("권한이 없습니다. (asyncdemo.stats)");
return true;
}
if (args.length != 1) {
sender.sendMessage("사용법: /svstats <player>");
return true;
}
String target = args[0].trim();
if (target.isEmpty()) {
sender.sendMessage("플레이어 이름을 입력해 주세요.");
return true;
}
sender.sendMessage("통계를 조회 중입니다...");
statsService.queryAndSend(sender, target);
return true;
}
}
hasPermission(...)은 운영 서버에서 명령어 접근 제어의 기본입니다.args.length 검증을 먼저 해두면 예외 대신 안내 메시지로 안정적으로 처리할 수 있습니다.
6) 자주 하는 실수와 해결
- 비동기 스레드에서 월드/플레이어 객체 직접 수정
- 증상: 경고 로그, 불안정한 동작
- 해결: Bukkit API 호출은
runTask(...)로 메인 스레드 복귀 후 실행
- 예외 처리를 생략
- 증상: 조용히 실패해서 원인 파악 어려움
- 해결:
exceptionally(...)에서 사용자 메시지 + 로그 처리
- 스레드 풀 종료 누락
- 증상: 서버 종료 지연 가능성
- 해결:
onDisable()에서shutdown()
7) 수동 테스트 체크리스트 (실전 운영 기준)
/svstats steve실행 시 즉시 "조회 중" 메시지가 먼저 뜨는가- 약간의 지연 후 최종 통계 메시지가 도착하는가
- 권한 없는 계정에서 실행 시 권한 거부 메시지가 뜨는가
- 인자 없이
/svstats실행 시 사용법 안내가 뜨는가 - 조회 실패 상황(예외 유도)에서 오류 메시지가 뜨고 서버가 멈추지 않는가
마무리
이번 편에서는 비동기 처리의 핵심 패턴 하나를 실전형으로 정리했습니다.
- 느린 작업은 async
- Bukkit API는 main thread
- 실패 케이스는 메시지로 명확히 안내
다음 10편에서는 최종 운영 편으로, 배포/업그레이드/로그 점검 루틴까지 묶어서 마무리해보겠습니다.
'Java > 마인크래프트' 카테고리의 다른 글
| [Paper 플러그인 실전 제작기 #07] 쿨다운/제한 시스템: 스킬 재사용 대기시간 만들기 (0) | 2026.03.03 |
|---|---|
| [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 |