객체지향은 역할과 책임을 정리하는 방법
객체지향의 목표는 클래스를 많이 만드는 것이 아닙니다. 데이터가 지켜야 할 규칙과 그 데이터를 사용하는 행동을 가까이 두고, 서로 협력하는 역할을 분명하게 만드는 것이 핵심입니다.
캡슐화로 상태 보호하기
체력을 private으로 숨기면 외부에서 음수 값을 직접 넣을 수 없습니다. 상태 변경은 규칙을 아는 메서드에 맡깁니다.
class Player {
private int health;
Player(int health) {
this.health = Math.max(health, 0);
}
void takeDamage(int amount) {
if (amount > 0) {
health = Math.max(health - amount, 0);
}
}
int getHealth() {
return health;
}
}
setHealth로 모든 값을 허용하기보다 takeDamage처럼 의도가 드러나는 행동을 제공하면 객체가 스스로 규칙을 지킬 수 있습니다.
인터페이스와 다형성
서로 다른 탐험 대상이 공통으로 interact 행동을 제공하도록 인터페이스를 정의해 봅시다.
public class Main {
public static void main(String[] args) {
Interactable[] targets = {
new Chest(),
new Portal(),
new Campfire()
};
for (Interactable target : targets) {
System.out.println(target.interact());
}
}
}
interface Interactable {
String interact();
}
class Chest implements Interactable {
@Override
public String interact() {
return "보관함을 열었습니다.";
}
}
class Portal implements Interactable {
@Override
public String interact() {
return "다음 지역으로 이동합니다.";
}
}
class Campfire implements Interactable {
@Override
public String interact() {
return "캠프에서 체력을 회복합니다.";
}
}
예상 출력:
보관함을 열었습니다.
다음 지역으로 이동합니다.
캠프에서 체력을 회복합니다.
배열의 타입은 모두 Interactable이지만 실행되는 메서드는 실제 객체의 클래스에 따라 달라집니다. 이것이 다형성입니다.
상속은 언제 사용할까요?
상속은 부모 클래스의 상태와 구현을 자식 클래스가 이어받습니다. 코드 몇 줄을 줄이기 위한 목적으로만 상속하면 부모의 변화가 모든 자식에 강하게 퍼집니다. 실제로 같은 종류라는 관계가 분명하고 공통 구현을 공유해야 할 때 사용하세요.
공통 행동만 약속하면 되는 상황에서는 인터페이스가 더 느슨한 결합을 만듭니다. 여러 행동을 조합하기도 쉽습니다.
@Override가 주는 안전장치
@Override는 부모나 인터페이스의 메서드를 올바르게 재정의했는지 컴파일러가 검사하게 합니다. 메서드 이름이나 매개변수를 잘못 적으면 새 메서드로 조용히 만들어지는 대신 컴파일 오류로 알려줍니다.
직접 해보기
Door클래스를 만들고Interactable을 구현합니다.String name()메서드를 인터페이스에 추가하고 각 클래스에서 이름을 반환합니다.Player에 회복량 상한을 지키는heal메서드를 추가합니다.
Door 힌트
class Door implements Interactable을 선언하고 public String interact()에서 "문을 열었습니다."를 반환하세요. 만든 객체를 targets 배열에 추가하면 기존 반복문은 수정하지 않아도 됩니다.
설계 기준
클래스 이름은 명사 역할을, 메서드 이름은 행동을 나타내게 합니다. 외부에서 알아야 할 최소한만 public으로 열고 나머지는 숨기면 나중에 내부 구현을 바꾸기 쉬워집니다.
캡슐화는 숨기기가 아니라 규칙의 위치 정하기입니다
캡슐화를 “필드에 private을 붙이는 것”으로만 외우면 금방 한계가 옵니다. private은 수단이고, 목표는 객체가 자기 규칙을 스스로 지키게 만드는 것입니다. 플레이어 체력은 0보다 작아질 수 없고, 최대 체력을 넘을 수도 없다는 규칙이 있다면 그 규칙은 체력을 바꾸는 메서드 안에 있어야 합니다. 외부 코드가 매번 Math.max와 Math.min을 기억하게 만들면 규칙이 흩어집니다.
class Player {
private final String name;
private final int maxHealth;
private int health;
Player(String name, int maxHealth) {
this.name = name;
this.maxHealth = Math.max(maxHealth, 1);
this.health = this.maxHealth;
}
void takeDamage(int amount) {
if (amount <= 0) {
return;
}
health = Math.max(health - amount, 0);
}
void heal(int amount) {
if (amount <= 0) {
return;
}
health = Math.min(health + amount, maxHealth);
}
String status() {
return name + " " + health + "/" + maxHealth;
}
}
이 클래스는 체력을 직접 대입하는 방법을 제공하지 않습니다. 대신 피해를 받거나 회복하는 행동만 열어 둡니다. 객체 외부에서는 내부 필드 배치를 몰라도 됩니다. 나중에 체력 계산 방식을 바꾸거나 보호막 필드를 추가해도 takeDamage와 heal의 의미가 유지되면 호출 코드는 그대로 둘 수 있습니다.
getter도 무조건 나쁜 것은 아닙니다. 화면에 체력을 표시해야 한다면 읽기 메서드가 필요합니다. 다만 setter를 자동으로 전부 만들면 private 필드가 사실상 public 필드처럼 됩니다. “외부에서 이 값을 어떤 의미로 바꾸어도 되는가”를 먼저 묻고, 값 대입보다 행동 이름을 제공할 수 있는지 생각하세요.
상속보다 조합을 먼저 떠올리기
상속은 강력하지만 결합도 강합니다. Warrior extends Player, Mage extends Player처럼 만들면 처음에는 편해 보입니다. 하지만 직업이 바뀌거나, 한 캐릭터가 여러 능력을 가질 수 있거나, 이벤트에 따라 공격 방식만 바뀌어야 한다면 상속 구조가 금방 굳어집니다. 부모 클래스의 필드와 메서드가 모든 자식에게 영향을 주기 때문입니다.
조합은 객체가 다른 객체를 필드로 가지고 협력하는 방식입니다.
interface AttackStrategy {
int damage(int baseAttack);
}
class SwordAttack implements AttackStrategy {
@Override
public int damage(int baseAttack) {
return baseAttack + 5;
}
}
class MagicAttack implements AttackStrategy {
@Override
public int damage(int baseAttack) {
return baseAttack * 2;
}
}
class Adventurer {
private final String name;
private AttackStrategy attackStrategy;
Adventurer(String name, AttackStrategy attackStrategy) {
this.name = name;
this.attackStrategy = attackStrategy;
}
int attack(int baseAttack) {
return attackStrategy.damage(baseAttack);
}
void changeAttackStrategy(AttackStrategy attackStrategy) {
this.attackStrategy = attackStrategy;
}
}
이 구조에서는 모험가 클래스가 직업별 자식 클래스로 나뉘지 않아도 공격 방식을 바꿀 수 있습니다. 새로운 공격 방식이 필요하면 AttackStrategy 구현을 추가하면 됩니다. 기존 Adventurer 코드를 크게 바꾸지 않으므로 변경 범위가 작습니다.
상속이 적절한 경우도 있습니다. 모든 하위 타입이 정말로 상위 타입의 특수한 종류이고, 공통 상태와 구현을 공유하는 것이 자연스럽다면 사용할 수 있습니다. 다만 “코드를 재사용하고 싶어서”가 유일한 이유라면 조합이나 유틸리티 메서드가 더 나을 수 있습니다.
인터페이스는 호출하는 쪽의 의존성을 줄입니다
다형성의 장점은 구현을 추가할 때 호출하는 코드를 덜 바꾸는 데 있습니다. 반복문이 Chest, Portal, Campfire를 각각 알 필요 없이 Interactable만 알면 됩니다. 호출하는 쪽이 구체 클래스를 많이 알수록 새 기능을 넣을 때 조건문이 늘어납니다.
static void runInteractions(List<Interactable> targets) {
for (Interactable target : targets) {
System.out.println(target.interact());
}
}
이 메서드는 어떤 클래스가 들어오는지 몰라도 됩니다. Interactable 계약만 지키면 새 클래스도 같은 반복문에서 동작합니다. 이것이 인터페이스를 변수 타입, 매개변수 타입, 반환 타입으로 사용하는 이유입니다.
인터페이스를 만들 때는 너무 많은 메서드를 한꺼번에 넣지 않도록 주의합니다. Interactable에 open, heal, teleport, save, render를 모두 넣으면 구현 클래스 대부분이 의미 없는 메서드를 억지로 구현해야 합니다. 작은 인터페이스를 목적별로 나누면 클래스가 필요한 계약만 선택할 수 있습니다.
다형성과 조건문 제거
객체지향을 배우면 조건문이 모두 사라진다고 오해하기 쉽습니다. 조건문은 여전히 필요합니다. 다만 타입에 따라 다른 행동을 선택하는 긴 조건문은 다형성으로 옮길 수 있습니다.
static String interact(String type) {
if (type.equals("chest")) {
return "보관함을 열었습니다.";
}
if (type.equals("portal")) {
return "다음 지역으로 이동합니다.";
}
return "아무 일도 일어나지 않았습니다.";
}
이 방식은 타입이 늘 때마다 조건문을 수정해야 합니다. 반대로 각 클래스가 interact를 구현하면 새 타입을 추가할 때 기존 조건문을 건드리지 않아도 됩니다. 물론 객체를 생성하는 한 곳에서는 어떤 클래스를 만들지 결정해야 합니다. 다형성은 모든 분기를 없애는 마법이 아니라, 변화하는 행동을 각 구현으로 보내는 설계 방법입니다.
접근 제어자를 설계 언어로 사용하기
Java의 접근 제어자는 코드가 어디까지 볼 수 있는지 정합니다. 입문 과정에서는 private, package-private, public 세 가지 감각을 먼저 익히면 충분합니다. private은 같은 클래스 안에서만 접근할 수 있습니다. 아무 접근 제어자를 쓰지 않는 package-private은 같은 패키지 안에서 접근할 수 있습니다. public은 외부 패키지에서도 접근할 수 있습니다.
작은 예제에서는 모든 클래스를 한 파일에 두기 때문에 접근 제어가 크게 느껴지지 않을 수 있습니다. 실무 프로젝트에서는 패키지가 모듈의 경계가 됩니다. 공개 API로 삼을 것만 public으로 열고, 내부 구현은 package-private이나 private으로 두면 바깥 코드가 내부 세부사항에 의존하는 일을 줄일 수 있습니다.
필드에는 거의 항상 private을 먼저 고려하세요. 메서드도 정말 외부에서 호출해야 하는지 생각합니다. 공개 범위가 작을수록 나중에 내부를 바꾸기 쉽습니다. 한 번 public으로 공개된 메서드는 다른 코드가 사용하기 시작할 수 있어 이름과 동작을 바꾸기 어려워집니다.
equals와 객체 동일성 맛보기
객체지향 코드를 다루다 보면 “같은 객체인가”와 “같은 값을 가진 객체인가”를 구분해야 합니다.
Player first = new Player("Alex", 10);
Player second = new Player("Alex", 10);
Player third = first;
System.out.println(first == second);
System.out.println(first == third);
first와 second는 같은 필드 값을 가질 수 있지만 서로 다른 객체입니다. ==는 참조가 같은지 확인합니다. third는 first와 같은 객체를 가리키므로 ==가 true입니다. 값 비교가 필요하면 equals를 적절히 재정의해야 하지만, 그 주제는 컬렉션과 함께 조금씩 확장해서 다루는 편이 좋습니다.
이 구분은 Set이나 Map에서 객체를 key로 쓸 때 중요합니다. 아직은 문자열처럼 표준 라이브러리가 이미 값 비교 규칙을 제공하는 타입을 먼저 사용하고, 직접 만든 객체의 값 비교는 필요가 분명할 때 학습하세요.
설계 냄새를 알아차리는 질문
객체지향 설계에는 정답보다 판단 질문이 중요합니다. 클래스를 만들거나 고칠 때 다음 질문을 던져 보세요.
- 이 클래스의 책임을 한 문장으로 말할 수 있나요?
- 필드 값이 지켜야 할 규칙이 클래스 안에 있나요?
- 외부 코드가 내부 필드를 너무 자세히 알고 있지 않나요?
- 새 종류의 행동을 추가할 때 기존 조건문을 많이 수정해야 하나요?
- 상속을 쓰는 이유가 진짜 is-a 관계인가요, 아니면 코드 재사용뿐인가요?
이 질문 중 여러 개에서 답이 흐려지면 설계를 나눌 때입니다. 객체지향은 처음부터 완벽한 구조를 맞히는 기술이 아니라, 변화가 생겼을 때 책임을 다시 배치하는 기술에 가깝습니다.
이번 장의 정리
객체지향의 핵심은 클래스 문법을 많이 쓰는 것이 아니라 책임과 규칙을 적절한 위치에 두는 것입니다. 캡슐화는 상태를 보호하고, 인터페이스는 호출하는 쪽과 구현하는 쪽의 결합을 낮추며, 다형성은 같은 메시지에 대해 객체마다 다른 행동을 가능하게 합니다. 상속은 신중히 사용하고, 변경 가능한 행동은 조합으로 분리할 수 있는지 먼저 생각하세요. 이 기준이 잡히면 다음 장의 컬렉션에서도 객체들을 어떤 타입으로 묶고 관리할지 더 분명하게 판단할 수 있습니다.