캡스톤 프로젝트의 시작: 도메인 주도 설계
우리는 지난 챕터들에서 Java 언어의 기초 문법부터 제어문, 자료구조, 객체지향 패러다임, 파일 처리, 그리고 실무 도구와 AI 협업 기법까지 폭넓게 탐험했습니다. 이제 배운 모든 지식을 하나의 유기적인 완성형 애플리케이션으로 엮어낼 시간입니다.
최종 캡스톤 프로젝트인 Java Adventure는 텍스트 기반의 voxel 모험 롤플레잉 게임입니다. 초보 개발자가 흔히 저지르는 실수는 main 메서드 안에 수백 줄의 if-else와 while 루프를 마구 섞어 넣는 ’스파게티 코드’를 작성하는 것입니다. 그렇게 만들어진 코드는 동작은 할지언정 새로운 몬스터를 하나 추가하거나 아이템 기능을 바꿀 때마다 시스템 전체가 무너집니다.
우리는 실무 소프트웨어 엔지니어링 원칙에 따라, 먼저 게임을 구성하는 **핵심 도메인(Core Domain)**의 역할과 책임을 명확히 분리하여 설계합니다.
4대 핵심 컴포넌트 설계
1부에서 구축할 핵심 아키텍처는 다음 네 가지 요소로 구성됩니다.
[Item (장비/소모품)] ──(장착)──> [Player (플레이어)]
│
(대결) ──> [CombatEngine (전투 판정)]
│
[Monster (공격 패턴)] ───────────────┘
- Player (플레이어 엔티티): 고유 이름, 최대 체력, 현재 체력, 장착 장비를 관리하며 피해를 입거나 회복하는 생명주기를 캡슐화합니다.
- Monster (몬스터 추상화 및 인터페이스): 이름, 기본 공격력과 함께 특수 공격(일반 타격, 독 공격, 관통 공격)을 다형성으로 구사합니다.
- Item (장비 값 객체): 무기(공격력 추가)와 방어구(피해 흡수)의 불변 스펙을 정의합니다.
- CombatEngine (전투 조정자): 플레이어와 몬스터의 턴을 관리하고, 방어력 공식을 적용해 실제 대미지를 계산하며 전투 결과를 판정합니다.
전투 계산 공식: 고정 감쇄 모델과 밸런스
전투 시스템에서 대미지 공식은 게임의 재미와 안정성을 결정짓는 핵심 수식입니다. 본 캡스톤에서는 초보자가 직관적으로 이해할 수 있는 고정 감쇄 모델을 채택했습니다.
실제 적용 피해 = Max(1, 공격력 - 방어구 감쇄력)
만약 방어력이 몬스터의 공격력보다 높아지면 피해가 음수가 되어 오히려 플레이어의 체력이 차오르는 황당한 버그가 생길 수 있습니다. 따라서 Math.max(1, ...)를 적용하여 어떤 강력한 방어구를 착용하더라도 최소 1의 유효 타격을 입도록 강제합니다. 이처럼 사소해 보이는 수학적 경계 처리가 캡슐화 로직 내부에 단단히 박혀 있어야만 시스템의 신뢰도가 유지됩니다.
객체지향 캡슐화와 불변성의 조화
Java 25에서는 변경되는 상태를 안전하게 은닉하는 전통적인 class와, 한 번 생성되면 변하지 않는 데이터를 안전하게 전달하는 record를 적재적소에 조합할 때 가장 깔끔한 아키텍처가 완성됩니다.
Player의health필드는private으로 숨기고,takeDamage(int)와heal(int)메서드를 통해서만 증감되도록 하여 체력이 0 미만이 되거나 최대치를 초과하는 버그를 줄입니다.Item과CombatResult는record로 선언하여 불변 값을 표현하고equals,hashCode,toString의 보일러플레이트 코드를 줄입니다.- 상태 머신(State Machine) 역할을 하는
CombatEngine은 두 엔티티의 생존 플래그(isAlive())를 지속적으로 감시하여 턴 종료와 루프 탈출을 정밀하게 제어합니다.
아래는 1부의 모든 요구사항이 녹아들어 바로 실행 가능한 전체 소스 코드입니다.
실행 가능한 핵심 시스템 전체 코드
import java.util.Objects;
import java.util.Optional;
public class Main {
// 1. 불변 장비 아이템 모델 (Record)
enum ItemType { WEAPON, ARMOR }
record EquipmentItem(String name, ItemType type, int powerBonus) {
public EquipmentItem {
Objects.requireNonNull(name, "아이템 이름은 필수입니다.");
Objects.requireNonNull(type, "아이템 타입은 필수입니다.");
if (powerBonus < 0) {
throw new IllegalArgumentException("보너스 수치는 0 이상이어야 합니다: " + powerBonus);
}
}
}
// 2. 플레이어 도메인 엔티티 (상태 캡슐화 클래스)
static class PlayerCharacter {
private final String name;
private final int maxHealth;
private int currentHealth;
private final int baseAttack;
private EquipmentItem weapon;
private EquipmentItem armor;
public PlayerCharacter(String name, int maxHealth, int baseAttack) {
this.name = Objects.requireNonNull(name, "플레이어 이름은 필수입니다.");
if (maxHealth <= 0) throw new IllegalArgumentException("최대 체력은 양수여야 합니다.");
this.maxHealth = maxHealth;
this.currentHealth = maxHealth;
this.baseAttack = Math.max(1, baseAttack);
}
public void equip(EquipmentItem item) {
Objects.requireNonNull(item, "장착할 아이템이 null입니다.");
if (item.type() == ItemType.WEAPON) {
this.weapon = item;
System.out.printf("[%s] 무기 장착 완료: %s (+%d 공격력)%n", name, item.name(), item.powerBonus());
} else if (item.type() == ItemType.ARMOR) {
this.armor = item;
System.out.printf("[%s] 방어구 장착 완료: %s (+%d 방어력)%n", name, item.name(), item.powerBonus());
}
}
public int getTotalAttackPower() {
int bonus = (weapon != null) ? weapon.powerBonus() : 0;
return this.baseAttack + bonus;
}
public int getTotalDefense() {
return (armor != null) ? armor.powerBonus() : 0;
}
public void takeDamage(int rawDamage) {
int effectiveDamage = Math.max(1, rawDamage - getTotalDefense());
this.currentHealth = Math.max(0, this.currentHealth - effectiveDamage);
System.out.printf(" >> [%s] %d의 피해를 입음! (남은 HP: %d/%d)%n",
this.name, effectiveDamage, this.currentHealth, this.maxHealth);
}
public boolean isAlive() {
return this.currentHealth > 0;
}
public String getName() { return name; }
public int getCurrentHealth() { return currentHealth; }
public int getMaxHealth() { return maxHealth; }
}
// 3. 다형성을 갖춘 몬스터 인터페이스 및 구현체
interface Monster {
String getName();
int getHealth();
int attack(PlayerCharacter target);
void takeDamage(int damage);
boolean isAlive();
}
static class VoxelZombie implements Monster {
private final String name;
private int health;
private final int attackPower;
public VoxelZombie(String name, int health, int attackPower) {
this.name = name;
this.health = health;
this.attackPower = attackPower;
}
@Override public String getName() { return name; }
@Override public int getHealth() { return health; }
@Override public boolean isAlive() { return health > 0; }
@Override
public void takeDamage(int damage) {
this.health = Math.max(0, this.health - damage);
System.out.printf(" << [몬스터 %s] %d의 반격을 받음! (남은 HP: %d)%n", name, damage, health);
}
@Override
public int attack(PlayerCharacter target) {
System.out.printf("[%s] 괴성을 지르며 %s을(를) 강타합니다!%n", name, target.getName());
target.takeDamage(this.attackPower);
return this.attackPower;
}
}
// 4. 턴제 전투 중재 엔진
static class CombatEngine {
public static boolean executeDuel(PlayerCharacter player, Monster monster) {
System.out.printf("%n=== 전투 개시: %s VS %s ===%n", player.getName(), monster.getName());
int round = 1;
while (player.isAlive() && monster.isAlive()) {
System.out.printf("--- Round %d ---%n", round++);
// 1) 플레이어의 선제공격
int playerDamage = player.getTotalAttackPower();
System.out.printf("[%s] 공격 개시! (위력: %d)%n", player.getName(), playerDamage);
monster.takeDamage(playerDamage);
if (!monster.isAlive()) {
System.out.printf("★ 승리! %s이(가) %s을(를) 쓰러뜨렸습니다!%n", player.getName(), monster.getName());
return true;
}
// 2) 몬스터의 반격
monster.attack(player);
if (!player.isAlive()) {
System.out.printf("♨ 패배... %s이(가) 전투 중 쓰러졌습니다.%n", player.getName());
return false;
}
}
return player.isAlive();
}
}
public static void main(String[] args) {
System.out.println("=== Java Adventure: 캡스톤 1부 도메인 시스템 ===");
// [플레이어 생성 및 장비 세팅]
PlayerCharacter alex = new PlayerCharacter("Alex", 60, 10);
alex.equip(new EquipmentItem("다이아몬드 검", ItemType.WEAPON, 15));
alex.equip(new EquipmentItem("철 갑옷", ItemType.ARMOR, 5));
// [몬스터 생성]
Monster caveZombie = new VoxelZombie("동굴 좀비", 45, 14);
// [1차 전투 실행]
boolean victory = CombatEngine.executeDuel(alex, caveZombie);
System.out.println("\n=== 전투 결과 요약 ===");
System.out.printf("전투 성공 여부: %s%n", victory ? "승리" : "패배");
System.out.printf("플레이어 최종 체력: %d/%d%n", alex.getCurrentHealth(), alex.getMaxHealth());
}
}
예상 출력
=== Java Adventure: 캡스톤 1부 도메인 시스템 ===
[Alex] 무기 장착 완료: 다이아몬드 검 (+15 공격력)
[Alex] 방어구 장착 완료: 철 갑옷 (+5 방어력)
=== 전투 개시: Alex VS 동굴 좀비 ===
--- Round 1 ---
[Alex] 공격 개시! (위력: 25)
<< [몬스터 동굴 좀비] 25의 반격을 받음! (남은 HP: 20)
[동굴 좀비] 괴성을 지르며 Alex을(를) 강타합니다!
>> [Alex] 9의 피해를 입음! (남은 HP: 51/60)
--- Round 2 ---
[Alex] 공격 개시! (위력: 25)
<< [몬스터 동굴 좀비] 25의 반격을 받음! (남은 HP: 0)
★ 승리! Alex이(가) 동굴 좀비을(를) 쓰러뜨렸습니다!
=== 전투 결과 요약 ===
전투 성공 여부: 승리
플레이어 최종 체력: 51/60
아키텍처 관점에서의 핵심 배움
- 상태 불변식(Invariant)의 수호
PlayerCharacter의currentHealth는 외부에서 직접 대입할 수 없으며, 반드시takeDamage내부의Math.max(0, ...)를 거쳐 계산됩니다. 이것이 객체지향 캡슐화가 주는 가장 큰 안전망입니다. - 다형적 개방 폐쇄 원칙 (OCP)
새로운 몬스터(예: 원거리 화살을 쏘는
VoxelSkeleton이나 자폭하는VoxelCreeper)를 추가할 때CombatEngine이나PlayerCharacter코드는 단 한 줄도 수정할 필요가 없습니다. 오직Monster인터페이스를 구현하는 새 클래스만 만들면 됩니다. - 단위 테스트 친화적 설계 (Testability)
콘솔 입출력(
System.out)과 핵심 전투 계산을 분리해 두었기 때문에, 추후 JUnit 5 테스트에서 모의 객체(Mock)를 쓰지 않고도 순수 자바 객체 인스턴스만 생성하여 승패와 대미지 연산의 무결성을 즉시 검증할 수 있습니다.
직접 해보기
제공된 실습 코드를 바탕으로 몬스터 다형성과 방어 공식을 확장해 보세요.
public class Main {
// 캡스톤 1부 도메인 확장 실습을 진행합니다.
public static void main(String[] args) {
System.out.println("Java Adventure 1부 확장을 시작합니다.");
}
}
미션 과제
- 원거리 스켈레톤 클래스 구현:
Monster인터페이스를 구현하는VoxelSkeleton클래스를 만드세요. 플레이어의 방어구를 무시하고 고정 관통 대미지를 주는 공격 패턴을 작성하세요. - 크리티컬 히트(치명타) 시스템:
PlayerCharacter의 공격 시 20%의 확률로 대미지가 1.5배로 들어가는 크리티컬 로직을 추가해 보세요. - 체력 회복 물약 아이템:
ItemType.CONSUMABLE타입을 추가하고, 플레이어가 전투 중 또는 전투 후 체력을 20 회복하는drinkPotion()메서드를 도메인 모델에 추가하세요. - ToolPado 도구 연계: 캐릭터의 생성 시점 및 게임 세션 기록을 위해 UUID Generator로 유니크 세션 ID를 발급하고 JSON Formatter로 전투 결과 리포트를 구조화해 보세요.