본문 바로가기
마인크래프트/플러그인 제작 강좌(자바)

[자바로 마크 Paper 플러그인 만들기]5. 유저 데이터 관리하기(4) - Config로 데이터 저장하기(Config 만들기, 직렬화 사용)

by Zepelown 2024. 8. 5.

*본 강의는 1.20.2 Paper 기준으로 제작되었습니다*

 

이전 강의

[자바로 마크 Paper 플러그인 만들기]4. 유저 데이터 관리하기(3) - 다른 플레이어 조회하기 (tistory.com)

 

[자바로 마크 Paper 플러그인 만들기]4. 유저 데이터 관리하기(3) - 다른 플레이어 조회하기

*본 강의는 1.20.2 Paper 기준으로 제작되었습니다* 이전 강의[자바로 마크 Paper 플러그인 만들기]3. 유저 데이터 관리하기(2) - 명령어 만들기 (tistory.com) [자바로 마크 Paper 플러그인 만들기]3. 유저

zepelown.tistory.com

 

참고하면 좋은 강의

[인텔리제이로 마크 플러그인 개발하기]11. config.yml 제작하기 3편 (플레이어 데이터 저장 및 신규 유저 환영 메시지 작성하기) (tistory.com)

 

[인텔리제이로 마크 플러그인 개발하기]11. config.yml 제작하기 3편 (플레이어 데이터 저장 및 신규

*이 글은 Spigot 1.19.3 버전을 기준으로 하여 제작되었습니다. 이전 강의 https://zepelown.tistory.com/51 [인텔리제이로 마크 플러그인 개발하기](보충) 플레이어 퇴장시 공지사항(PlayerQuitEvent 와 PlayerKick *

zepelown.tistory.com

 

 

 

 

강의에 사용한 전체 코드

Zepelown/PaperBlogPosting: Minecraft Paper API Blog Posting Repository (github.com)

 

GitHub - Zepelown/PaperBlogPosting: Minecraft Paper API Blog Posting Repository

Minecraft Paper API Blog Posting Repository. Contribute to Zepelown/PaperBlogPosting development by creating an account on GitHub.

github.com

 

 


 

미리 보는 결과물 

그림 1. 이전에 만든 유저 데이터가 저장된 모습

 

 

 


 

개요

이제 이 유저 데이터 관리하기 시리즈의 가장 중요한 내용인 데이터 저장입니다.

 

유저 데이터를 저장하는 방법은 db, json 등을 사용할 수 있습니다만,

 

Config로 데이터를 저장하는 것이 마크의 전통이자 근본(?)이기 때문에

 

이 글에선 Bukkit의 Configuration API를 사용하여 유저의 데이터를 저장합니다.

(Bukkit은 대부분의 마인크래프트 서버 API의 조상으로 Paper 또한 기반이 Bukkit이다)

 

다만, 최적화를 위해 기존 코드를 많이 변경시킨 관계로

 

알아보기 힘들 수도 있지만, 최대한 하나하나 다 설명하며 진행할게요.

 

모르시는 내용은 댓글 언제나 환영입니다!!

 


외부 라이브러리 불러오기

 

이번 강의에선 Lombok 이라는 라이브러리를 사용할 예정입니다.

 

build.gradle

    compileOnly 'org.projectlombok:lombok:1.18.34'
    annotationProcessor 'org.projectlombok:lombok:1.18.34'

    testCompileOnly 'org.projectlombok:lombok:1.18.34'
    testAnnotationProcessor 'org.projectlombok:lombok:1.18.34'

 

그림 2. 라이브러리 implementation 예시

 

위와 같이 넣어주시면 됩니다.

 

/ (참고) Lombok에 대하여 /


패키지(폴더) 구조 설명

그림 3. 패키지 구조

 

아마 패키지 구조를 보시면 머리가 어지러울 실 텐데요.

 

그림을 보시죠.

 

그림 4. 패키지 구조 도식화

 

크게 보시면 data, domain, view, controller가 있습니다.

 

controller는 유저의 진입점 같은 곳으로 view와 data 사이의 중재자 역할을 합니다.

 

그리고 domain은 실질적인 기능 로직을 수행하는 곳으로 data에서 데이터를 받아와 처리합니다.

 

이 때문에, domain은 data와 controller 사이에 있다고 할 수 있습니다.

 

이제 하나하나 세부적으로 다 들여다보도록 하겠습니다.

 

 


기능 구현 단계 

그림 5. config 결과물

 

먼저, 만들어진 Config 내용부터 다시 볼게요. 

uuid :
 money:
 displayName:
 prefix:
 rank:
 job:

 

Config 형식이 위와 같습니다.

 

Config는 path 기준을 통해 관리가 되는데, 예를 들어 prefix에 접근하기 위해선

 

"uuid.prefix"라는 String 형태의 path를 사용해야 합니다.

 

사실, 원래의 의도대로라면 Bukkit의 Configuration API는 일일이. 단위로 하드코딩을 해줘야 했습니다.

(money 부분을 작성하기 위해선 uuid.money  + 돈값 과 같이 String 연산을 해야 했었다)

 

정말 귀찮고 String 값을 직접 작성해줘야 하는 점에서 실수가 자주 발생하는 문제점이 있습니다.

 

이를 해결하기 위해 객체를 직렬화할 겁니다.

그림 6. 이전에 만든 User 객체

이전에 우리는 User 클래스를 사용하여 유저 데이터를 객체로 정의했었습니다.

 

"어차피 데이터는 정의되어 있는데 그냥 바로 가져다가 쓰면 안 될까?"라는 발상에서 나오는 것이

 

바로 객체의 직렬화, 역직렬화입니다.

 

즉, 객체를 풀어서 일정한 형식으로 변환해 주는 과정을 직렬화, 이 반대를 역직렬화라고 해요.

 

Config에 들어갈 데이터 형식인 객체를 만들기 위해 UserData 클래스를 만듭니다.

(데이터 형식 역할을 하는 객체를 entity라고 합니다)

data.entity.UserData

package org.blog.paperedu.user.management.data.entity;

import lombok.Getter;
import lombok.Setter;
import org.bukkit.configuration.serialization.ConfigurationSerializable;
import org.bukkit.configuration.serialization.SerializableAs;
import org.jetbrains.annotations.NotNull;

import java.util.HashMap;
import java.util.Map;

@SerializableAs("UserData")
@Getter
@Setter
public class UserData implements ConfigurationSerializable {
    private String displayName;

    private String rank;

    private Double money;

    private String job;

    private String prefix;
    public UserData(Map<String, Object> data){
        this.displayName = (String) data.get("displayName");
        this.rank = (String) data.get("rank");
        this.money = (Double) data.get("money");
        this.job = (String) data.get("job");
        this.prefix = (String) data.get("prefix");
    }

    public UserData(String displayName, String rank,Double money, String job, String prefix) {
        this.displayName = displayName;
        this.rank = rank;
        this.money = money;
        this.job = job;
        this.prefix = prefix;
    }

    @Override
    public @NotNull Map<String, Object> serialize() {
        HashMap<String, Object> mapSerializer = new HashMap<>();
        mapSerializer.put("displayName", this.displayName);
        mapSerializer.put("rank", this.rank);
        mapSerializer.put("money", this.money);
        mapSerializer.put("job", this.job);
        mapSerializer.put("prefix",this.prefix);
        return mapSerializer;
    }

}

 

ConfigurationSerializable 인터페이스를 구현하고, Lombok을 사용해 Getter, Setter를 생략하였습니다.

 

여기서 직렬화에 별명(Alias)을 부여하는 @SerializableAs("UserData") 어노테이션을 붙여줘야 합니다.

 

추가로, 직렬화나 역직렬화에 필요한 메소드가 두 개가 있습니다.

 

직렬화 관련 메소드

    @Override
    public @NotNull Map<String, Object> serialize() {
        HashMap<String, Object> mapSerializer = new HashMap<>();
        mapSerializer.put("displayName", this.displayName);
        mapSerializer.put("rank", this.rank);
        mapSerializer.put("money", this.money);
        mapSerializer.put("job", this.job);
        mapSerializer.put("prefix",this.prefix);
        return mapSerializer;
    }

 

직렬화를 이용할 때 Map 자료 구조가 필요합니다.

 

이는 그냥 변수 그대로 해주시면 됩니다.

 

역직렬화 관련 메소드(생성자)

public UserData(Map<String, Object> data){
    this.displayName = (String) data.get("displayName");
    this.rank = (String) data.get("rank");
    this.money = (Double) data.get("money");
    this.job = (String) data.get("job");
    this.prefix = (String) data.get("prefix");
}

 

역직렬화는 생성자를 통해 진행됩니다.

 

Map<String, Object> 로 받아지기 때문에 Object에서 기본 변수로 변환이 필요하므로 캐스팅을 진행하였습니다.

 

Config 데이터 형식을 만들었으니 Config를 만들어볼까요!

common.config

package org.blog.paperedu.common.config;

import org.bukkit.configuration.file.FileConfiguration;
import org.bukkit.configuration.file.YamlConfiguration;

import java.io.File;

public abstract class Config {
    private File file;
    private FileConfiguration config;

    public Config(String basePath, String fileName){
        this.file = new File(basePath, fileName);
        this.config = YamlConfiguration.loadConfiguration(this.file);
        loadDefaults();
        applySettings();
        store();
    }

    public FileConfiguration getConfig(){
        return config;
    }

    public void store(){
        if(config == null)
            return;
        try{
            this.config.save(this.file);
        } catch (Exception e){
            e.printStackTrace();
        }
    }

    public boolean exists(){
        return file != null && file.exists();
    }

    public void reload(){
        if(!exists())
            return;
        config = YamlConfiguration.loadConfiguration(file);
    }

    public abstract void loadDefaults();
    public abstract void applySettings();
}

 

추상 클래스로 앞으로 만들 Config 파일 모두는 이 클래스를 상속받아 제작됩니다.

 

Paper에서 Config 입출력을 처리할 때, FileConfiguration 객체를 이용하여 진행하며 

 

파일 읽고 쓰기는 자바의 File 객체를 사용합니다.

 

언제나 Config를 수정한 내용을 저장하기 위해선 store() 를 이용해야 합니다.

 

추상 메소드 loadDefaults()와 applySettings()는 Config에 기본값을 적용할 필요가 있을 때를 대비하여

 

강제로 구현하도록 만들었습니다.

 

 

그러면 이 추상 클래스를 어떻게 사용할까요??

data.source.UserDataConfig

package org.blog.paperedu.user.management.data.source;

import org.blog.paperedu.common.config.Config;
import org.blog.paperedu.user.management.data.entity.UserData;

import java.util.UUID;

public class UserDataConfig extends Config {
    public UserDataConfig(String basePath, String fileName) {
        super(basePath, fileName);
        loadDefaults();
    }

    public void storeUserData(UserData userData, UUID uuid){
        getConfig().set(uuid.toString(), userData);
        super.store();
    }

    public UserData loadUserData(UUID uuid){
        return (UserData) getConfig().get(uuid.toString());
    }

    public boolean hasUserData(UUID uuid){
        return getConfig().contains(uuid.toString());
    }

    @Override
    public void loadDefaults() {

    }

    @Override
    public void applySettings() {
        getConfig().options().copyDefaults(true);
    }
}

 

유저 데이터를 저장할 것이므로 Config의 기본값은 필요가 없습니다.

 

때문에 여기선 loadDefaults() 랑 applySettings() 는 사실 의미가 없습니다.

 

우리가 Config에서 필요한 기능은 데이터 저장, 불러오기, 유저 데이터 여부 확인입니다.

그림 7. Config 데이터 추가, 삭제, 확인

 

 

 

store()의 경우 getConfig()를 통해 FileConfiguration 객체를 가져오고 set(path, 데이터) 을 사용하여 저장할 수 있습니다.

 

직렬화 정의가 되어있기 때문에, 클래스 단위로 넣으시면 됩니다.

 

load()의 경우엔  get(path)로 받아온 후 (UserData) 캐스팅을 진행하면 됩니다.

 

hasUserData()의 경우엔 contains(path)를 메소드를 사용하여 boolean 리턴을 받으면 됩니다.

 

data.repository.UserDataRepository

package org.blog.paperedu.user.management.data.repository;

import org.blog.paperedu.PaperEdu;
import org.blog.paperedu.user.management.data.entity.UserData;
import org.blog.paperedu.user.management.domain.model.User;
import org.blog.paperedu.user.management.data.source.UserDataConfig;

import java.util.UUID;

public class UserDataRepository {

    private static final String CONFIG_FILE_NAME = "userdata.yml";
    private final String configBasePath;

    private final UserDataConfig userDataConfig;
    public UserDataRepository(){
        configBasePath = PaperEdu.getServerInstance().getDataFolder().getAbsolutePath();
        userDataConfig = new UserDataConfig(configBasePath,CONFIG_FILE_NAME);
    }

    public void reloadConfig(){
        userDataConfig.reload();
    }

    public void saveConfig(){
        userDataConfig.store();
    }

    public boolean hasUser(UUID uuid){
        return userDataConfig.hasUserData(uuid);
    }

    public void storeUserData(User user){
        UserData userData = new UserData(
                user.getDisplayName(),
                user.getRank(),
                user.getMoney(),
                user.getJob(),
                user.getPrefix()
        );
        userDataConfig.storeUserData(userData, user.getUuid());
    }

    public User loadUserData(UUID uuid){
        UserData userData = userDataConfig.loadUserData(uuid);
        User user = new User(
                uuid,
                userData.getDisplayName(),
                userData.getRank(),
                userData.getMoney(),
                userData.getJob(),
                userData.getPrefix()
        );
        return user;
    }


}

 

UserDataConfig 인스턴스를 가지고 있는 객체입니다.

 

여기서 UserData 객체에서 기존에 만들어둔 User 객체로의 변환이 일어납니다.

 

그리고 Config를 저장하거나, reload 할 경우에도 UserDataRepository에 접근한다고 생각하시면 됩니다.

 

이전에 설명한 내용들이 대부분이니 넘어가겠습니다.

 

domain.service.UserManager

package org.blog.paperedu.user.management.domain.service;

import java.util.HashMap;
import java.util.UUID;

import org.blog.paperedu.user.management.domain.model.User;
import org.blog.paperedu.user.management.data.repository.UserDataRepository;
import org.bukkit.entity.Player;


import static org.blog.paperedu.common.util.HashMapUtil.findKeyByValue;

public class UserManager {
    private HashMap<UUID, User> onlineUserData = new HashMap<>();

    private final UserDataRepository userDataRepository;

    public UserManager(UserDataRepository userDataRepository) {
        this.userDataRepository = userDataRepository;
    }

    public void addNewUser(Player player) {
        UUID playerUUID = player.getUniqueId();
        User newUser = new User(
                playerUUID,
                player.getDisplayName(),
                "newbie",
                1000.0,
                "jobless", "[뉴비]");
        onlineUserData.put(playerUUID, newUser);
        userDataRepository.storeUserData(newUser);
    }

    public void addUser(Player player){
        User userData = userDataRepository.loadUserData(player.getUniqueId());
        onlineUserData.put(player.getUniqueId(), userData);
    }

    public void removeUser(UUID playerUUID){
        User userData = onlineUserData.remove(playerUUID);
        userDataRepository.storeUserData(userData);
    }

    public boolean hasUser(UUID uuid){
        return userDataRepository.hasUser(uuid);
    }

    public User getUserData(UUID playerUUID){
        return onlineUserData.get(playerUUID);
    }

    public User getUserData(String playerName){
        return onlineUserData.get(findKeyByValue(onlineUserData, new User(playerName)));
    }

}

 결국 이 데이터들이 필요한 곳은 유저 관리를 직접적으로 진행하는 UserManager입니다.

 

생성자를 통해 Repository를 연결하고 유저 추가랑, 삭제 부분을 건드려 줍니다.

 

그림 8. UserManager 수정된 부분

 

새로운 유저는 데이터를 새로 만들어줘야 하니 addNewUser() 와 addUser()를 분리했습니다.

 

addUser는 기존 유저이므로 config에서 데이터를 불러온 뒤 HashMap에 넣는 형식을 취합니다.

 

remove를 한다는 건 서버에서 나갔다는 뜻이므로 데이터를 저장합니다.

 

 

 

Config 기능 구현은 끝났습니다.

 

Controller와 연결을 진행하겠습니다.

controller.UserConnectionController.java

package org.blog.paperedu.user.management.controller;

import org.blog.paperedu.user.management.domain.service.UserManager;
import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler;
import org.bukkit.event.Listener;
import org.bukkit.event.player.PlayerJoinEvent;
import org.bukkit.event.player.PlayerKickEvent;
import org.bukkit.event.player.PlayerQuitEvent;

public class UserConnectionController implements Listener {

    private final UserManager userManager;

    UserConnectionController(UserManager userManager){
        this.userManager = userManager;
    }

    @EventHandler
    public void onUserJoinServer(PlayerJoinEvent event) {
        Player player = event.getPlayer();
        if (!player.hasPlayedBefore() || !userManager.hasUser(player.getUniqueId())){
            userManager.addNewUser(event.getPlayer());
            return;
        }
        userManager.addUser(player);
    }

    @EventHandler
    public void onUserKickFromServer(PlayerKickEvent event) {
        userManager.removeUser(event.getPlayer().getUniqueId());
    }

    @EventHandler
    public void onUserQuitFromServer(PlayerQuitEvent event) {
        userManager.removeUser(event.getPlayer().getPlayerProfile().getId());
    }
}

 

크게 수정할 내용은 없고 플레이어가 입장했을 때, 새로운 유저인지 파악만 해주면 됩니다.

 

그림 9. 유저 Join 조건 확인

두 조건을 건 이유는 hasPlayedBefore()는 마크 월드 데이터 기준이기 때문에,

 

플러그인이 월드 생성 이후에 설치됐음을 고려하여 기존 데이터 여부도 한 번에 파악합니다.

 

그래서 없으면 기본 값을 넣게 되는 것이죠.

 

controller.UserManagementController.java

package org.blog.paperedu.user.management.controller;

import org.blog.paperedu.PaperEdu;
import org.blog.paperedu.user.management.data.entity.UserData;
import org.blog.paperedu.user.management.data.repository.UserDataRepository;
import org.blog.paperedu.user.management.domain.model.User;
import org.blog.paperedu.user.management.domain.service.UserManager;
import org.blog.paperedu.user.management.controller.commands.UserInfoCommand;
import org.blog.paperedu.user.management.view.UserManagementView;
import org.bukkit.configuration.serialization.ConfigurationSerialization;

public class UserManagementController {
    static {
        ConfigurationSerialization.registerClass(UserData.class, "UserData");
    }

    private static UserManager userManager;

    private final UserManagementView userManagementView;

    private final PaperEdu serverInstance;

    private final UserConnectionController userConnectionController;

    private final UserDataRepository userDataRepository;

    public UserManagementController() {
        this.serverInstance = PaperEdu.getServerInstance();
        this.userDataRepository = new UserDataRepository();
        this.userManagementView = new UserManagementView();

        userManager = new UserManager(userDataRepository);

        this.userConnectionController = new UserConnectionController(userManager);

        registerCommands();
        registerEvents();
    }

    public void saveUserData(){
        userDataRepository.saveConfig();
    }


    private void registerEvents() {
        serverInstance.getServer().getPluginManager().registerEvents(userConnectionController, serverInstance);
    }

    private void registerCommands() {
        serverInstance.getServer().getPluginCommand("uinfo").setExecutor(new UserInfoCommand(userManager, userManagementView));
    }


}

 

여기선 Repository 생성과 만들어둔 UserData(ConfigurationSerializable)을 정적 변수로 등록해 줍니다.

 

그림 10. ConfigurationSerializable 등록

 

여기선 아까 어노테이션으로 정했던 Alias를 똑같이 입력해 주면 됩니다.

 

static으로 선언해줘야 하기 때문에 static 범위 함수를 사용했습니다.

(다른 방식도 가능)

 

PaperEdu.java (메인 클래스)

package org.blog.paperedu;

import org.blog.paperedu.user.management.controller.UserManagementController;
import org.blog.paperedu.user.management.data.entity.UserData;
import org.bukkit.configuration.serialization.ConfigurationSerializable;
import org.bukkit.configuration.serialization.ConfigurationSerialization;
import org.bukkit.plugin.java.JavaPlugin;

public final class PaperEdu extends JavaPlugin {

    private static PaperEdu serverInstance;
    private static UserManagementController userManagement;


    @Override
    public void onEnable() {
        getLogger().info("플러그인 시작 테스트");
        serverInstance = this;
        userManagement = new UserManagementController();
    }

    @Override
    public void onDisable() {
        userManagement.saveUserData();
        serverInstance = null;
        userManagement = null;

        getLogger().info("플러그인 종료 테스트");
    }

    public static PaperEdu getServerInstance() {
        return serverInstance;
    }

    public static UserManagementController getUserManagement(){
        return userManagement;
    }
}

그림 11. 서버 종료 시 config 저장

서버가 꺼질 때 세이브할 수 있도록 메소드를 넣어줍니다.

 

/ (참고) User 객체 코드 개선 /

더보기

domain.model.User

package org.blog.paperedu.user.management.domain.model;

import lombok.Getter;
import lombok.Setter;
import org.bukkit.configuration.serialization.SerializableAs;
import org.jetbrains.annotations.NotNull;

import java.util.Objects;
import java.util.UUID;

@Getter
@Setter
public class User implements Comparable<User> {
    private UUID uuid;
    private String displayName;

    private String rank;

    private Double money;

    private String job;

    private String prefix;

    public User(String displayName){
        this.displayName = displayName;
    }

    public User(UUID uuid,String displayName, String rank, Double money, String job, String prefix) {
        this.uuid = uuid;
        this.displayName = displayName;
        this.rank = rank;
        this.money = money;
        this.job = job;
        this.prefix = prefix;
    }
    @Override
    public int compareTo(@NotNull User o) {
        return this.displayName.compareTo(o.displayName);
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        User user = (User) o;
        return Objects.equals(displayName, user.displayName);
    }

    @Override
    public int hashCode() {
        return Objects.hash(displayName);
    }
}

 


결과물

 

서버를 나갔다 들어와도 데이터가 유지되는 것을 볼 수 있습니다.

댓글