상속은 있는 클래스를 물려받아 확장하는 것이다. 직원과 관리자는 이름과 급여가 겹치고, 관리자는 팀 관리가 더해진다. 관리자를 처음부터 짜는 대신 직원을 물려받아 필요한 것만 더한다. extends로 부모를 지정하면 자식은 부모의 필드와 메서드를 물려받은 채 자기 것을 추가한다. 코드 중복을 줄이고 공통을 한곳에 모으는 도구다.
상속은 is-a 관계일 때만 쓴다. "관리자는 직원의 한 종류다"는 성립하니 상속이 맞다. 반대로 "자동차는 엔진의 한 종류다"는 틀리다. 자동차는 엔진을 가질 뿐이니 상속이 아니라 앞 편의 합성, 즉 엔진을 필드로 품는 게 맞다. 이 is-a 판별이 상속을 옳게 쓰는 첫 기준이다.
public class Employee {
protected String name;
protected long salary;
public Employee(String name, long salary) {
this.name = name; this.salary = salary;
}
public long pay() { return salary; }
}
public class Manager extends Employee {
private long bonus;
public Manager(String n, long s, long bonus) {
super(n, s); // 부모 생성자 먼저
this.bonus = bonus;
}
}
super는 부모를 가리킨다. 자식 생성자 첫 줄 super(n, s)가 핵심이다. 자식 객체는 부모 부분이 먼저 완성돼야 하므로 부모 생성자를 먼저 부르며, 이 호출은 생성자 첫 줄에 와야 한다. 직접 안 적으면 자바가 super()를 넣는데, 부모에 그 생성자가 없으면 컴파일 에러가 난다. super.pay()처럼 부모 메서드를 부를 때도 쓴다.
오버라이딩은 물려받은 동작을 바꿔 끼우는 것이다. 자식은 물려받은 메서드를 자기에게 맞게 다시 정의할 수 있다. 관리자 급여는 기본급에 보너스를 더하니 pay를 재정의한다. 재정의 위에는 @Override를 붙인다. 이름이나 매개변수를 잘못 적어 실제로는 덮어쓰지 못하면 컴파일러가 잡아 주는 안전장치다.
@Override
public long pay() {
return super.pay() + bonus; // 부모 급여에 보너스
}
오버라이딩과 오버로딩은 다르다. 오버로딩은 한 클래스에서 이름은 같고 매개변수가 다른 메서드를 여럿 두는 것이고, 오버라이딩은 부모 메서드를 자식이 같은 서명으로 다시 쓰는 것이다. 철자와 뜻을 또렷이 갈라 두면 개념이 안 엉킨다.
다형성은 하나의 타입으로 여러 종류를 다루는 것이다. 자식 객체는 부모 타입 변수에 담긴다. Employee e = new Manager(...)가 된다. 직원 목록에 일반 직원도 관리자도 섞어 담고 하나씩 pay를 부르면 각자 제 방식으로 급여를 계산한다. 부르는 쪽은 그게 어떤 종류인지 신경 쓰지 않아도 된다.
List<Employee> staff = new ArrayList<>();
staff.add(new Employee("kim", 3000));
staff.add(new Manager("lee", 4000, 1000));
for (Employee e : staff)
System.out.println(e.pay()); // 3000, 5000
동적 바인딩이 다형성을 작동시킨다. 위 반복문에서 변수 타입은 둘 다 Employee인데 관리자는 5000을 내놓는다. 자바는 e.pay()에서 변수의 겉 타입이 아니라 실제로 담긴 객체의 진짜 타입을 보고 그쪽 메서드를 부르기 때문이다. 실행하는 순간 진짜 타입이 결정한다. 덕분에 나중에 임원 클래스를 추가해도 이 반복문은 한 줄도 고칠 필요가 없다.
업캐스팅은 자동, 다운캐스팅은 수동이다. 자식을 부모 타입에 담는 업캐스팅은 늘 안전해 자동으로 된다. 반대로 부모 타입 변수를 자식으로 되돌리는 다운캐스팅은 그 안에 진짜 그 자식이 들었는지 보장이 없어 위험하다. 잘못하면 ClassCastException(형변환 예외)이 터지니 instanceof로 먼저 확인한다. JDK 16부터는 확인과 형변환을 하나로 합친 패턴 매칭 instanceof가 정식이다.
// 16+ 패턴 매칭: 확인과 형변환을 한 번에
if (e instanceof Manager m)
System.out.println(m.pay());
모든 클래스는 Object를 물려받는다. extends를 안 적어도 모든 클래스는 최상위 부모 Object를 물려받아 toString, equals, hashCode를 기본으로 가진다. equals의 기본 구현은 ==처럼 같은 객체인지만 보니, 값이 같으면 같다고 보고 싶으면 equals를 재정의하고 이때 hashCode도 함께 재정의해야 한다는 계약이 있다.
protected는 상속을 위한 접근 단계다. private은 그 클래스 안에서만 보여 자식조차 접근하지 못하고, public은 온 세상에 열린다. protected는 그 중간으로 자식 클래스와 같은 패키지에는 열지만 완전한 외부에는 감춘다. 위 예에서 name과 salary를 protected로 둔 이유가 자식인 관리자가 직접 쓸 수 있게 하려는 것이다. 다만 필드를 직접 노출하면 부모와 자식이 더 얽히므로, 필드는 private으로 감추고 protected 메서드로 여는 편이 더 안전하다.
final로 상속이나 재정의를 막는다. 클래스에 final을 붙이면 아무도 물려받지 못한다. String이 대표적인 final 클래스라 아무도 상속해 바꿀 수 없다. 메서드에 붙이면 자식이 그 메서드만은 재정의하지 못한다. 상속을 염두에 두고 설계하지 않았다면 상속을 막으라는 조언이 자바 세계에 있다.
생성자 안에서는 재정의 가능한 메서드를 부르지 않는다. 자식 객체는 부모부터 완성되며 만들어진다. new Manager(...)는 super()로 부모를 먼저 초기화하고 자식 필드를 나중에 채운다. 그래서 생성자 안에서 재정의된 메서드를 부르면 자식 필드가 아직 초기화되기 전에 그 메서드가 실행되는 미묘한 함정이 생긴다. 반쯤 지어진 객체를 건드리는 셈이라서다.
상속은 강력하지만 남용하면 독이 된다. 상속은 부모와 자식을 단단히 묶어, 부모를 고치면 여파가 모든 자식에게 번진다. 계층이 깊어질수록 어떤 동작이 어느 조상에서 왔는지 추적하기 어렵다. 그래서 코드 재사용이 목적이라면 합성을 먼저 고려하고, 진짜 is-a이면서 다형성이 필요할 때만 상속을 꺼낸다. 계층은 한두 단계로 얕게 유지하는 편이 오래간다.
다음 편에서는 인터페이스와 추상 클래스를 다룬다. 자바에서 다형성의 더 중요한 축은 사실 인터페이스다. 무엇을 할 수 있는지 약속만 정하고 구현은 각자에게 맡기는 인터페이스, 공통 뼈대와 빈칸을 함께 주는 추상 클래스, 둘을 언제 어떻게 나눠 쓰는지를 코드로 파고든다. 이번 편의 부모 타입으로 자식들을 묶는 감각이 그대로 이어진다.