[Paper 플러그인 실전 제작기 #09] 비동기 처리와 성능 최적화: 메인 스레드 멈춤 없이 통계 조회하기

2026. 3. 6. 22:39Java/마인크래프트

안녕하세요.
지난 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) 자주 하는 실수와 해결

  1. 비동기 스레드에서 월드/플레이어 객체 직접 수정
  • 증상: 경고 로그, 불안정한 동작
  • 해결: Bukkit API 호출은 runTask(...)로 메인 스레드 복귀 후 실행
  1. 예외 처리를 생략
  • 증상: 조용히 실패해서 원인 파악 어려움
  • 해결: exceptionally(...)에서 사용자 메시지 + 로그 처리
  1. 스레드 풀 종료 누락
  • 증상: 서버 종료 지연 가능성
  • 해결: onDisable()에서 shutdown()

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

  1. /svstats steve 실행 시 즉시 "조회 중" 메시지가 먼저 뜨는가
  2. 약간의 지연 후 최종 통계 메시지가 도착하는가
  3. 권한 없는 계정에서 실행 시 권한 거부 메시지가 뜨는가
  4. 인자 없이 /svstats 실행 시 사용법 안내가 뜨는가
  5. 조회 실패 상황(예외 유도)에서 오류 메시지가 뜨고 서버가 멈추지 않는가

마무리

이번 편에서는 비동기 처리의 핵심 패턴 하나를 실전형으로 정리했습니다.

  • 느린 작업은 async
  • Bukkit API는 main thread
  • 실패 케이스는 메시지로 명확히 안내

다음 10편에서는 최종 운영 편으로, 배포/업그레이드/로그 점검 루틴까지 묶어서 마무리해보겠습니다.