서로 관련된 상태와 행동 묶기
이름, 체력, 회복 코드를 따로 흩어 놓으면 플레이어가 여러 명일 때 어느 체력이 누구 것인지 관리하기 어렵습니다. 클래스는 관련된 데이터와 동작을 하나의 새로운 자료형으로 묶습니다.
Player 클래스는 플레이어라면 공통으로 가질 설계이고, new Player(...)로 만든 각 값은 서로 다른 객체입니다.
Player 클래스 만들기
public class Main {
public static void main(String[] args) {
Player alex = new Player("Alex", 15);
Player robin = new Player("Robin", 8);
alex.heal(3);
robin.heal(5);
alex.printStatus();
robin.printStatus();
}
}
class Player {
String name;
int health;
Player(String name, int health) {
this.name = name;
this.health = health;
}
void heal(int amount) {
health += amount;
}
void printStatus() {
System.out.println(name + "의 체력: " + health);
}
}
예상 출력:
Alex의 체력: 18
Robin의 체력: 13
두 객체는 같은 Player 설계를 사용하지만 name과 health는 각각 따로 보관합니다. alex.heal(3)은 Alex 객체의 체력만 바꿉니다.
필드, 생성자, 메서드
String name,int health: 객체가 기억하는 필드입니다.Player(String name, int health): 새 객체의 첫 상태를 만드는 생성자입니다.heal,printStatus: 객체의 상태를 사용하거나 바꾸는 인스턴스 메서드입니다.
생성자의 this.name = name에서 왼쪽은 현재 객체의 필드이고 오른쪽은 전달받은 매개변수입니다.
올바른 상태를 생성자에서 확인하기
체력이 음수인 객체가 만들어지지 않게 생성자에서 규칙을 적용할 수 있습니다.
Player(String name, int health) {
this.name = name;
this.health = Math.max(health, 0);
}
객체가 처음부터 유효한 상태로 시작하면 이후 메서드가 처리해야 할 예외가 줄어듭니다.
객체 배열
여러 플레이어도 배열로 관리할 수 있습니다.
Player[] party = {
new Player("Alex", 15),
new Player("Robin", 8)
};
for (Player player : party) {
player.printStatus();
}
배열의 각 칸에는 Player 객체를 가리키는 참조가 들어갑니다.
직접 해보기
int level필드와 생성자 매개변수를 추가합니다.- 피해량을 받아 체력을 줄이는
takeDamage(int amount)메서드를 만듭니다. - 체력이 0보다 큰지 반환하는
boolean isAlive()를 만듭니다.
isAlive 힌트
boolean isAlive() { return health > 0; }처럼 현재 객체의 health를 비교한 결과를 반환할 수 있습니다.
책임을 객체 안에 두기
외부 코드가 player.health = -100처럼 필드를 마음대로 바꿀 수 있으면 규칙이 깨집니다. 다음 Chapter에서는 필드를 숨기고 허용된 메서드로만 상태를 바꾸는 캡슐화를 배웁니다.
클래스는 데이터 묶음보다 넓은 개념입니다
처음 클래스를 배울 때는 필드 여러 개를 한곳에 모으는 상자로 이해해도 괜찮습니다. 하지만 실무에서 클래스는 단순한 상자보다 더 중요한 역할을 합니다. 클래스는 어떤 값이 유효한지, 그 값으로 어떤 행동을 할 수 있는지, 외부 코드가 무엇을 알아도 되는지를 정하는 경계입니다. Player 클래스가 체력과 이름을 가진다는 사실보다 더 중요한 것은 “플레이어의 체력은 음수가 될 수 없다” 같은 규칙을 어디에서 지킬 것인가입니다.
예를 들어 이름과 체력을 따로 변수로 관리하면 모든 코드가 체력 규칙을 기억해야 합니다. 어떤 곳은 Math.max를 적용하고 어떤 곳은 깜빡하면 상태가 깨집니다. 반대로 Player 생성자와 메서드 안에서만 체력을 바꾸게 만들면 규칙이 한곳에 모입니다. 이것이 객체를 사용하는 큰 이유입니다.
class Player {
String name;
int health;
Player(String name, int health) {
this.name = name;
this.health = Math.max(health, 0);
}
}
위 예제는 아직 필드가 외부에 열려 있지만, 적어도 생성 시점의 규칙은 클래스 안에 들어왔습니다. 다음 장의 캡슐화에서는 필드를 private으로 숨겨 생성 이후의 규칙도 객체가 지키게 만듭니다.
객체마다 상태가 따로 저장됩니다
같은 클래스로 만든 객체들은 설계는 공유하지만 상태는 공유하지 않습니다. alex와 robin은 모두 Player 타입이지만 서로 다른 이름과 체력을 가집니다. 이 감각이 잡히지 않으면 “메서드를 호출했는데 왜 한 객체만 바뀌는가” 또는 “왜 다른 객체는 그대로인가” 같은 혼란이 생깁니다.
Player alex = new Player("Alex", 15);
Player robin = new Player("Robin", 8);
alex.heal(5);
alex.printStatus();
robin.printStatus();
heal 메서드는 호출 대상인 alex의 필드를 바꿉니다. robin은 같은 클래스로 만들어졌지만 별개의 객체이므로 영향을 받지 않습니다. 점 표기법의 왼쪽에 있는 값이 “누구의 메서드를 실행하는가”를 알려 줍니다.
이 차이는 static과 비교하면 더 선명해집니다. static 필드나 메서드는 특정 객체가 아니라 클래스 자체에 속합니다. 입문 예제에서 main은 static이지만, 플레이어의 체력처럼 객체마다 달라져야 하는 값은 인스턴스 필드로 두어야 합니다. 모든 플레이어가 같은 체력을 공유한다면 게임 규칙이 이상해지듯이, 객체별 상태와 클래스 공통 상태를 구분해야 합니다.
생성자는 객체의 출발선을 정합니다
생성자는 새 객체가 처음 만들어질 때 반드시 지나가는 관문입니다. 그래서 생성자에는 “이 객체가 시작부터 쓸 수 있는 상태인가”를 확인하는 코드가 들어가기 좋습니다. 이름이 비어 있거나 체력이 음수인 플레이어가 만들어지면 이후 모든 메서드가 방어 코드를 가져야 합니다.
Player(String name, int health) {
if (name == null || name.isBlank()) {
this.name = "Unknown";
} else {
this.name = name;
}
this.health = Math.max(health, 0);
}
이 예제는 잘못된 이름을 기본값으로 바꿉니다. 어떤 프로젝트에서는 기본값을 넣는 대신 예외를 던지는 정책을 선택할 수도 있습니다. 중요한 것은 정책이 생성자에 모여 있다는 점입니다. 객체를 만드는 모든 코드가 같은 규칙을 거치므로 상태가 더 예측 가능해집니다.
생성자가 여러 개 필요할 때는 오버로딩할 수 있습니다. 예를 들어 체력을 생략하면 기본 체력 10으로 시작하게 만들 수 있습니다.
Player(String name) {
this(name, 10);
}
Player(String name, int health) {
this.name = name;
this.health = Math.max(health, 0);
}
this(name, 10)은 같은 클래스의 다른 생성자를 호출합니다. 중복 초기화 코드를 줄이고 모든 생성자가 같은 검증 규칙을 사용하게 만들 수 있습니다.
this를 읽는 두 가지 방법
this는 현재 객체 자신을 가리킵니다. 가장 흔한 사용은 필드와 매개변수 이름이 같을 때입니다.
Player(String name, int health) {
this.name = name;
this.health = health;
}
왼쪽의 this.name은 현재 객체의 필드이고, 오른쪽의 name은 생성자 매개변수입니다. 이름을 다르게 지으면 this 없이도 컴파일되지만, 생성자에서는 같은 이름을 쓰는 관례가 많습니다. 필드 이름과 매개변수 이름이 일치하면 어떤 값이 어디로 들어가는지 바로 보입니다.
두 번째 사용은 현재 객체를 다른 메서드에 전달할 때입니다. 입문 과정에서는 자주 쓰지 않지만, 콜백이나 비교 로직에서 “나 자신”을 넘겨야 할 때 this를 사용할 수 있습니다. 지금은 this를 “현재 객체의 필드를 분명히 가리키는 표시”로 이해하면 충분합니다.
참조 타입과 null
Player alex = new Player("Alex", 15);에서 변수 alex에는 객체 자체가 통째로 들어 있는 것이 아니라 객체를 가리키는 참조가 들어 있습니다. 그래서 두 변수가 같은 객체를 가리킬 수도 있습니다.
Player first = new Player("Alex", 15);
Player second = first;
second.heal(5);
first.printStatus();
second로 회복했지만 first로 출력해도 체력이 바뀐 것을 볼 수 있습니다. 두 변수가 같은 객체를 바라보기 때문입니다. 이 특성은 편리하지만, 의도하지 않게 같은 객체를 공유하면 버그가 됩니다. 객체를 넘겨받은 메서드가 내부 상태를 바꿀 수 있다는 점을 항상 기억하세요.
참조 변수에는 아무 객체도 가리키지 않는 null이 들어갈 수 있습니다. null 상태에서 player.printStatus()를 호출하면 NullPointerException이 발생합니다. 객체를 만들기 전에 사용하거나, 배열의 칸을 채우지 않은 상태로 접근할 때 흔합니다.
Player[] party = new Player[2];
party[0] = new Player("Alex", 15);
party[0].printStatus();
// party[1].printStatus(); // 아직 null이므로 예외가 발생합니다.
배열을 만들었다고 각 칸의 객체까지 자동으로 만들어지는 것은 아닙니다. 참조 타입 배열은 기본값이 null이라는 점을 기억하세요.
필드 초기화와 기본값
Java의 인스턴스 필드는 값을 직접 넣지 않으면 기본값을 가집니다. int는 0, boolean은 false, 참조 타입은 null입니다. 하지만 기본값에 기대어 객체를 만드는 습관은 좋지 않습니다. 0이나 null이 실제로 의미 있는 상태인지 분명하지 않기 때문입니다.
class Player {
String name; // 기본값 null
int health; // 기본값 0
}
이 클래스는 생성자 없이도 객체를 만들 수 있지만, 이름 없는 체력 0 플레이어가 만들어집니다. 예제는 단순해 보이지만, 실제 애플리케이션에서는 이런 불완전한 객체가 나중에 오류를 일으킵니다. 필수 값은 생성자에서 받도록 만드는 편이 좋습니다.
필드가 많아질 때도 무조건 생성자 매개변수를 늘리기보다 객체의 책임을 다시 봐야 합니다. 플레이어 클래스에 이름, 체력, 레벨, 경험치, 위치, 인벤토리, 설정, 네트워크 상태가 모두 들어간다면 너무 많은 역할을 맡고 있을 수 있습니다. 일부는 Inventory, Position, PlayerProfile 같은 별도 클래스로 나눌 수 있습니다.
객체를 출력할 때 생기는 일
객체를 그대로 출력하면 처음에는 낯선 값이 보입니다.
Player alex = new Player("Alex", 15);
System.out.println(alex);
기본 출력은 클래스 이름과 해시 기반 문자열입니다. 사람이 읽기 좋은 상태를 보고 싶다면 printStatus 같은 메서드를 만들거나, 나중에 toString을 재정의할 수 있습니다. 입문 단계에서는 객체를 디버깅할 때 필드를 직접 출력하거나 상태 출력 메서드를 만드는 것으로 충분합니다.
void printStatus() {
System.out.println(name + "의 체력: " + health);
}
상태 출력 메서드도 객체의 책임에 포함됩니다. 외부 코드가 필드를 하나씩 꺼내 조합하는 대신 객체 자신이 표시 방식을 제공하면 변경에 강해집니다.
이번 장의 정리
클래스는 관련된 상태와 행동, 그리고 상태를 지키는 규칙을 묶는 단위입니다. 객체는 클래스로 만든 개별 값이며, 같은 클래스를 사용해도 각 객체의 필드는 따로 저장됩니다. 생성자는 객체가 유효한 출발선에서 시작하도록 돕고, this는 현재 객체의 필드를 분명히 가리킵니다. 참조 타입과 null의 특성을 이해하면 객체 배열과 메서드 호출에서 생기는 오류를 훨씬 빨리 찾을 수 있습니다. 다음 장에서는 필드를 숨기고 공개 메서드로만 객체와 협력하는 객체지향 설계를 더 깊게 다룹니다.