지금은 아직 Java 연수기간... 오늘은 상속, 다형성, 캡슐화(거의 못했음)을 복습했다. 처음엔 단순히 코드를 줄이기 위해(재사용성) 상속을 쓴다고 생각했지만, 오늘 직접 자산(Asset) 관리 클래스를 설계해 보며 '객체지향은 코드를 줄이는 기술이 아니라, 미래의 실수를 막는 통제 수단'이라는 것을 깨달았다."
// [수정 전] 내가 처음 설계했던 코드 (추상 메서드 남용)
public abstract class TangibleAsset {
String name;
int price;
String color;
// 공통 속성을 꺼내는 메서드를 전부 추상 메서드로 강제함
public abstract String getName();
public abstract int getPrice();
public abstract String getColor();
}
public class Computer extends TangibleAsset {
String makerName;
public Computer(String name, int price, String color, String makerName) {
// 자식 클래스에서 일일이 부모의 필드를 초기화
this.name = name;
this.price = price;
this.color = color;
this.makerName = makerName;
}
// 부모가 강제한 추상 메서드를 자식들이 각각 오버라이딩
@Override public String getName() { return this.name; }
@Override public int getPrice() { return this.price; }
@Override public String getColor() { return this.color; }
public String getMakerName() { return this.makerName; }
}코드를 입력하세요
// [수정 후] 공통 기능과 생성자를 부모로 끌어올린 코드
public abstract class TangibleAsset {
String name;
int price;
String color;
// 1. 부모 클래스에 생성자를 만들어 공통 필드 초기화를 담당
public TangibleAsset(String name, int price, String color) {
this.name = name;
this.price = price;
this.color = color;
}
// 2. 추상 메서드가 아닌 일반 메서드(Concrete)로 구현하여 자식들의 중복을 없앰
public String getName() { return this.name; }
public int getPrice() { return this.price; }
public String getColor() { return this.color; }
}
public class Computer extends TangibleAsset {
String makerName;
public Computer(String name, int price, String color, String makerName) {
// super()를 통해 부모 생성자를 호출하여 코드 간소화
super(name, price, color);
this.makerName = makerName;
}
// 이제 중복되던 공통 Getter 메서드들을 자식 클래스에 작성할 필요가 없다
public String getMakerName() { return this.makerName; }
}코드를 입력하세요
유체물(TangibleAsset)을 상속받는 Computer와 Book 클래스를 직접 설계해 보았다.
처음에는 부모 클래스에 abstract 메서드만 잔뜩 선언해 두고 자식 클래스에서 전부 구현하게 만들었다. 하지만 공통된 필드(name, price 등)가 있다면 부모에서 생성자(super)와 일반 메서드로 미리 구현해 두는 것이 훨씬 효율적이라는 것을 배웠다.
추상 메서드가 없어도 abstract 클래스로 만드는 이유는, '유체물'이라는 실체 없는 개념이 new 키워드로 생성되는 것을 막기 위한 구조적 안전장치다.
인터페이스를 공부하다 보니 '왜 Java 8부터는 인터페이스에 몸통이 있는 default 메서드를 허용했을까?'라는 의문이 들었다.
알고 보니 이는 기존에 만들어진 수많은 시스템(특히 보수적인 환경의 레거시 시스템)들의 하위 호환성(Backward Compatibility)을 지키기 위한 타협이었다. 기존 코드를 망가뜨리지 않으면서 새로운 공통 기능을 추가해야 하는 프레임워크 설계자들의 고충을 간접적으로 느낄 수 있었다.
가장 어려웠던 다형성(Polymorphism)은 '운전자와 자동차'의 관계로 이해했다.
부모 클래스(운전 가능한 자동차) 타입으로 객체를 다루면, 미래에 어떤 새로운 자동차(자식 클래스)가 출시되더라도 운전자(메서드)의 코드는 수정할 필요가 없다.
1) 다형성이 없다면?
만약 다형성이라는 개념이 없다면, 운전자(Driver)는 새로운 브랜드의 자동차가 출시될 때마다 운전법을 새로 배워야(메서드를 추가해야) 할 것이다.
// 다형성이 없는 코드
public class Driver {
public void drive(Tesla t) { t.move(); }
public void drive(Hyundai h) { h.move(); }
// 새로운 차가 나올 때마다 메서드를 계속 추가해야 함
}코드를 입력하세요
2) 업캐스팅(Upcasting)이 가져온 유연함
하지만 모든 자동차가 Car라는 부모 클래스(또는 인터페이스)를 상속받는다면 이야기가 완전히 달라진다. 부모 타입 변수 하나로 모든 자식 객체를 담을 수 있는 '업캐스팅' 덕분이다.
// 부모 타입 하나로 모든 자식 객체를 받는다!
public class Driver {
public void drive(Car car) {
car.move();
}
}
// 실행 코드
Driver me = new Driver();
me.drive(new Tesla());
me.drive(new Hyundai());코드를 입력하세요
**-> 어떤 메서드를 쓸지는 '타입'이 정한다. (부모 클래스에 없는 메서드는 사용할 수 없기 때문(Compile-Time)) => 컴파일러의 시선: 자바의 문법 검사기(컴파일러)는 코드를 실행하기 전에는 변수 안에 '테슬라'가 들어올지 '현대차'가 들어올지 알 수 없다. 오직 변수의 타입만 확인한다. 그래서 부모 타입에 없는 메서드를 사용하려고 하면 컴파일 에러를 띄우는 것.전문 용어: 이를 코드가 실행되기 전 문법 단계에서 결정된다고 하여 정적 바인딩(Static Binding)이라고 부른다.
-> 메서드의 내용은 '객체'가 정한다. (자식 클래스가 오버라이딩한 메서드가 사용되기 때문(Run-Time)) => JVM의 시선: 문법 검사를 무사히 통과하고 프로그램이 실제로 실행(Run)되면, 자바 가상 머신(JVM)은 메서드가 아니라 실제로 연결된 메모리에 생성된 객체를 찾아간다. 그리고 그 객체가 부모의 것을 오버라이딩한 메서드를 실행한다. 전문 용어: 이를 프로그램이 실행되는 도중에 동적으로 어떤 메서드를 실행할지 결정한다고 하여 동적 바인딩(Dynamic Binding)이라고 부른다.
부모 타입으로 묶어서 관리하면 편하지만, 단점도 있다. 부모 타입에서는 자식만이 가진 고유 메서드를 사용할 수 없다는 것이다.
이때는 다시 자식 타입으로 되돌리는 다운캐스팅(Downcasting)이 필요하다. 하지만 무턱대고 형변환을 시도하면 ClassCastException이라는 무서운 런타임 에러를 만날 수 있다.
public void drive(Car car) {
car.move();
// 이 차가 테슬라가 맞는지 먼저 확인
if (car instanceof Tesla) {
Tesla tesla = (Tesla) car; // 다운캐스팅
tesla.autoPilot(); // 테슬라만의 고유 메서드 사용 가능
}
}코드를 입력하세요
다형성은 코드의 유연함을 극대화해 주지만, 그만큼 개발자에게 '타입을 안전하게 다루는 책임(instanceof 활용)'을 요구한다는 것을 명심해야겠다.