이 프로젝트를 통해 다음과 같은 핵심 기술들을 학습했습니다:
- 레이어드 아키텍처: Presentation(Command/Listener) → Service → Repository → Database
- 의존성 주입: 생성자 기반 DI를 통한 느슨한 결합
- 싱글톤 패턴: 플러그인 인스턴스 관리
- JPA/Hibernate: 객체-관계 매핑을 통한 데이터베이스 추상화
- HikariCP: 고성능 커넥션 풀링
- MySQL: 관계형 데이터베이스 연동
- Bukkit/Paper API: 이벤트 시스템, 명령어 처리
- 플러그인 라이프사이클: onEnable/onDisable
- 권한 시스템: plugin.yml을 통한 권한 관리
- Gradle: 빌드 자동화, 의존성 관리
- Shadow Plugin: Fat JAR 생성
- Lombok: 보일러플레이트 코드 제거
# 프로젝트 빌드
cd tiny_pg_hunterAPI
./gradlew clean shadowJar
# JAR 파일 확인
ls -la build/libs/
# 마인크래프트 서버에 배포
cp build/libs/tiny_pg_hunterAPI-1.0-SNAPSHOT.jar /path/to/minecraft/server/plugins/자세한 설정은 database/README.md를 참조하세요.
-- MySQL에서 실행
CREATE DATABASE minecraft_db CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE USER 'minecraft_user'@'localhost' IDENTIFIED BY 'your_password';
GRANT ALL PRIVILEGES ON minecraft_db.* TO 'minecraft_user'@'localhost';
FLUSH PRIVILEGES;<!-- src/main/resources/META-INF/persistence.xml에서 수정 -->
<property name="jakarta.persistence.jdbc.url"
value="jdbc:mysql://localhost:3306/minecraft_db?useSSL=false&serverTimezone=Asia/Seoul"/>
<property name="jakarta.persistence.jdbc.user" value="minecraft_user"/>
<property name="jakarta.persistence.jdbc.password" value="your_password"/>[INFO] [TinyPG] === Tiny PG Hunter API 플러그인 시작 ===
[INFO] [TinyPG] 데이터베이스 연결을 초기화합니다...
[INFO] [TinyPG] === 플러그인이 성공적으로 활성화되었습니다! ===
/hello # 플러그인 테스트
/hi # hello 명령어의 별칭
/안녕 # 한글 별칭
- 플레이어 접속 시: 자동으로 데이터베이스에 플레이어 정보 저장/업데이트
- 마지막 로그인 시간: 접속할 때마다 자동 갱신
- 플레이 시간 추적: 세션별 플레이 시간 누적 (향후 구현)
// 다른 플러그인의 onEnable()에서
Tiny_pg_hunterAPI api = Tiny_pg_hunterAPI.getInstance();
if (api != null) {
PlayerDataRepository repository = api.getPlayerRepository();
MessageService messageService = api.getMessageService();
}// 특정 플레이어의 데이터 가져오기
String playerUUID = player.getUniqueId().toString();
PlayerData data = repository.findByUuid(playerUUID);
if (data != null) {
double money = data.getMoney();
long playTime = data.getPlayTimeMinutes();
// 데이터 활용...
}// 플레이어 돈 추가
PlayerData data = repository.findByUuid(playerUUID);
if (data != null) {
data.addMoney(100.0); // 100원 추가
repository.save(data);
}
// 플레이 시간 추가
data.addPlayTime(60L); // 60분 추가
repository.save(data);// 돈이 많은 상위 10명 조회
List<PlayerData> topPlayers = repository.findTopPlayersByMoney(10);
for (PlayerData player : topPlayers) {
System.out.println(player.getPlayerName() + ": " + player.getMoney());
}# 당신의 플러그인의 plugin.yml에 추가
depend: [Tiny_PG_HunterAPI]@Entity
@Table(name = "guild_data")
@Data
@NoArgsConstructor
@AllArgsConstructor
public class GuildData {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "guild_name", unique = true, nullable = false)
private String guildName;
@Column(name = "leader_uuid", nullable = false)
private String leaderUuid;
@Column(name = "member_count")
private Integer memberCount = 0;
@Column(name = "created_at")
@Temporal(TemporalType.TIMESTAMP)
private Date createdAt = new Date();
}public class GuildDataRepository {
private final DatabaseManager databaseManager;
public GuildDataRepository(DatabaseManager databaseManager) {
this.databaseManager = databaseManager;
}
public GuildData findByName(String guildName) {
return databaseManager.executeInTransaction(em -> {
TypedQuery<GuildData> query = em.createQuery(
"SELECT g FROM GuildData g WHERE g.guildName = :name",
GuildData.class
);
query.setParameter("name", guildName);
return query.getResultStream().findFirst().orElse(null);
});
}
public GuildData save(GuildData guild) {
return databaseManager.executeInTransaction(em -> {
return em.merge(guild);
});
}
}<persistence-unit name="minecraft-plugin-db">
<class>io.github.louis5103.tiny_pg_hunterAPI.model.entity.PlayerData</class>
<class>io.github.louis5103.tiny_pg_hunterAPI.model.entity.GuildData</class>
<!-- 새로운 엔티티 추가 -->
</persistence-unit>public class MoneyCommand implements CommandExecutor {
private final PlayerDataRepository playerRepository;
private final MessageService messageService;
public MoneyCommand(PlayerDataRepository playerRepository, MessageService messageService) {
this.playerRepository = playerRepository;
this.messageService = messageService;
}
@Override
public boolean onCommand(CommandSender sender, Command command, String label, String[] args) {
if (!(sender instanceof Player)) {
sender.sendMessage(messageService.getErrorMessage("플레이어만 사용할 수 있는 명령어입니다."));
return true;
}
Player player = (Player) sender;
String uuid = player.getUniqueId().toString();
PlayerData data = playerRepository.findByUuid(uuid);
if (data != null) {
String message = String.format("보유 금액: %.2f원", data.getMoney());
player.sendMessage(messageService.getCommandMessage(message));
} else {
player.sendMessage(messageService.getErrorMessage("플레이어 데이터를 찾을 수 없습니다."));
}
return true;
}
}commands:
hello:
description: "플러그인 테스트 명령어"
usage: "/<command>"
permission: tinypg.command.hello
money:
description: "보유 금액 확인"
usage: "/<command>"
permission: tinypg.command.money
aliases: ["돈", "재산"]private void registerCommands() {
HelloCommand helloCommand = new HelloCommand(messageService);
this.getCommand("hello").setExecutor(helloCommand);
// 새 명령어 등록
MoneyCommand moneyCommand = new MoneyCommand(playerRepository, messageService);
this.getCommand("money").setExecutor(moneyCommand);
}public class PlayerDeathListener implements Listener {
private final PlayerDataRepository playerRepository;
private final MessageService messageService;
public PlayerDeathListener(PlayerDataRepository playerRepository, MessageService messageService) {
this.playerRepository = playerRepository;
this.messageService = messageService;
}
@EventHandler
public void onPlayerDeath(PlayerDeathEvent event) {
Player player = event.getEntity();
String uuid = player.getUniqueId().toString();
// 사망 시 돈 일부 차감
PlayerData data = playerRepository.findByUuid(uuid);
if (data != null && data.getMoney() > 0) {
double penalty = data.getMoney() * 0.1; // 10% 차감
data.addMoney(-penalty);
playerRepository.save(data);
String message = String.format("사망으로 인해 %.2f원이 차감되었습니다.", penalty);
player.sendMessage(messageService.getErrorMessage(message));
}
}
}private void registerListeners() {
PlayerJoinListener joinListener = new PlayerJoinListener(playerRepository, messageService);
getServer().getPluginManager().registerEvents(joinListener, this);
// 새 리스너 등록
PlayerDeathListener deathListener = new PlayerDeathListener(playerRepository, messageService);
getServer().getPluginManager().registerEvents(deathListener, this);
}// 메인 클래스의 onEnable()에서
public void onEnable() {
// ... 기존 초기화 코드 ...
// 5분마다 플레이 시간 업데이트
getServer().getScheduler().runTaskTimerAsynchronously(this, () -> {
updatePlayTimes();
}, 20L * 300L, 20L * 300L); // 20틱 = 1초, 300초 = 5분
}
private void updatePlayTimes() {
// 온라인 플레이어들의 플레이 시간 업데이트
for (Player player : getServer().getOnlinePlayers()) {
String uuid = player.getUniqueId().toString();
playerRepository.updatePlayTime(uuid, 5L); // 5분 추가
}
}public class PlayerStatsGUI {
public static void openStatsMenu(Player player, PlayerData data) {
Inventory gui = Bukkit.createInventory(null, 27, "플레이어 정보");
// 돈 정보 아이템
ItemStack moneyItem = new ItemStack(Material.GOLD_INGOT);
ItemMeta moneyMeta = moneyItem.getItemMeta();
moneyMeta.setDisplayName("§6보유 금액");
moneyMeta.setLore(Arrays.asList("§f" + data.getMoney() + "원"));
moneyItem.setItemMeta(moneyMeta);
gui.setItem(4, moneyItem);
// 플레이 시간 아이템
ItemStack timeItem = new ItemStack(Material.CLOCK);
ItemMeta timeMeta = timeItem.getItemMeta();
timeMeta.setDisplayName("§a플레이 시간");
timeMeta.setLore(Arrays.asList("§f" + data.getPlayTimeMinutes() + "분"));
timeItem.setItemMeta(timeMeta);
gui.setItem(13, timeItem);
player.openInventory(gui);
}
}public class CachedPlayerDataRepository {
private final PlayerDataRepository repository;
private final Map<String, PlayerData> cache = new ConcurrentHashMap<>();
private final long cacheExpireTime = 5 * 60 * 1000; // 5분
public PlayerData findByUuid(String uuid) {
PlayerData cached = cache.get(uuid);
if (cached != null && !isExpired(cached)) {
return cached;
}
PlayerData fresh = repository.findByUuid(uuid);
if (fresh != null) {
cache.put(uuid, fresh);
}
return fresh;
}
private boolean isExpired(PlayerData data) {
// 캐시 만료 로직 구현
return false;
}
}public CompletableFuture<PlayerData> findByUuidAsync(String uuid) {
return CompletableFuture.supplyAsync(() -> {
return playerRepository.findByUuid(uuid);
});
}
// 사용 예시
findByUuidAsync(playerUUID).thenAccept(data -> {
if (data != null) {
// UI 업데이트는 메인 스레드에서
Bukkit.getScheduler().runTask(plugin, () -> {
player.sendMessage("데이터 로드 완료!");
});
}
});java.lang.ClassNotFoundException: org.hibernate.jpa.HibernatePersistenceProvider
해결책: Shadow JAR이 제대로 빌드되지 않았습니다. ./gradlew clean shadowJar로 다시 빌드하세요.
jakarta.persistence.PersistenceException: Unable to build EntityManagerFactory
해결책:
- persistence.xml의 데이터베이스 설정 확인
- MySQL 서버 실행 상태 확인
- 네트워크 연결 확인
Access denied for user 'minecraft_user'@'localhost' to database 'minecraft_db'
해결책:
GRANT CREATE, ALTER, DROP ON minecraft_db.* TO 'minecraft_user'@'localhost';
FLUSH PRIVILEGES;<!-- 대규모 서버용 설정 -->
<property name="hibernate.hikari.maximumPoolSize" value="20"/>
<property name="hibernate.hikari.minimumIdle" value="5"/>// 여러 플레이어 데이터를 한 번에 업데이트
public void batchUpdatePlayTime(Map<String, Long> playTimes) {
databaseManager.executeInTransaction(em -> {
playTimes.forEach((uuid, time) -> {
PlayerData player = em.find(PlayerData.class, uuid);
if (player != null) {
player.addPlayTime(time);
}
});
return null;
});
}-- 자주 조회되는 컬럼에 인덱스 추가
CREATE INDEX idx_player_money ON player_data(money);
CREATE INDEX idx_last_login ON player_data(last_login);- GitHub: https://github.com/louis5103/tiny_pg_hunterAPI
- 이슈 트래킹: GitHub Issues 사용
- 문서: 이 README와 코드 주석 참조
- Fork the repository
- Create a feature branch
- Make your changes
- Write tests
- Submit a pull request
이 프로젝트를 완료하셨다면, 다음과 같은 실무 기술들을 익히신 것입니다:
✅ 백엔드 개발: JPA/Hibernate를 활용한 데이터베이스 연동
✅ 아키텍처 설계: 레이어드 아키텍처와 의존성 주입
✅ 게임 서버 개발: 마인크래프트 플러그인 개발
✅ 빌드 시스템: Gradle을 활용한 빌드 자동화
✅ 데이터베이스 설계: MySQL 스키마 설계 및 최적화
이제 이 지식을 바탕으로 더 복잡하고 흥미로운 플러그인들을 개발해보세요! 🚀