객체 지향의 특징은 아래와 같습니다.
이 중 상속에 대해서 다루는 것이 이 포스팅의 목적입니다.
다른 클래스가 가지고 있는 기능과 특성의 모음을 새로 만들 클래스에 작성하기 않고 상속을 받아 새 클래스가 자신의 기능 및 특성처럼 사용할 수 있는 기능
누구나 상속(Inherit) 이란 것은 들어보았을 것이다. 흔히 상속은 부모가 자식에게 재산이든 빚이든 물려주는 것을 의미한다고 알려져있다. 객체지향에서도 상속은 비슷한 의미로 사용이 된다. 그러면 상속은 왜 등장했을까?
사실 객체지향이 아닌 과거 절차지향 프로그래밍에서도 "라이브러리"를 통해서 남이 짜놓은 소스 코드를 가져와 사용할 수 있었다.
하지만 내 의도에 맞게 수정하게되면 기존과는 다른 라이브러리가 되어 버전에 따라 동작하지 않을 수 있고 불필요한 코드의 수정작업을 해야한다.
이런 문제를 해결하기 위해 상속이라는 것을 도입하였다.
상속은 부모클래스의 속성과 기능을 그대로 이어받아 사용할 수 있게하고 기능의 일부분을 변경해야 할 경우 상속받은 자식클래스에서 해당 기능만 다시 수정(정의)하여 사용할 수 있게 하는 것이다.
※ 다중 상속은 불가하다.
클래스의 상속 관계에서 혼란을 줄 수 있기 때문에, 상속은 반드시 하나만 가능하고 필요에 따라 인터페이스를 사용할 수 있게 했다.
이러한 상속을 통해 얻을 수 있는 장점은 아래와 같다.
- 보다 적은 양의 코드로 새로운 클래스 작성 가능
- 코드를 공통적으로 관리하기 때문에 코드의 추가 및 변경 용이
- 코드의 중복을 제거하여 프로그램의 생산성과 유지보수에 크게 기여
한번 예시를 살펴보도록 하자.
대학 동창과 직장 동료와 같은 인맥 관리 프로그램을 생각해보자. 우선 상속 없이 단순히 두 클래스에 대해서만 작성해보면 다음과 같다.
public class UnivFriend {
private String name;
private String major;
private String phone;
public UnivFriend(String name, String major, String phone) {
this.name = name;
this.major = major;
this.phone = phone;
}
public void showInfo() {
System.out.println("이름: " + name);
System.out.println("전공: " + major);
System.out.println("전화: " + phone);
}
}
public class CompFriend {
private String name;
private String department;
private String phone;
public CompFriend(String name, String department, String phone) {
this.name = name;
this.department = department;
this.phone = phone;
}
public void showInfo() {
System.out.println("이름: " + name);
System.out.println("부서: " + department);
System.out.println("전화: " + phone);
}
}
자세히 보면 두 클래스는 이름과 전화가 공통적으로 나타나는 것을 확인할 수 있다. 클래스를 만드는데 사실 이 두 정보는 다른 쪽에서 한번에 관리하는 편이 효율적이지 않을까 생각해볼 수 있다. 이 다른 쪽이 바로 부모 클래스가 될 것이다.
뿐만 아니라 실제로 이 두 클래스만으로 객체로 사용했을 때 다른 문제가 있다.
...
public static void main(String[] args) {
...
UnivFriend[] ufrns = new UnivFriend[10];
CompFriend[] cfrns = new CompFriend[10];
ufrns[0] = new UnivFriend...
ufrns[1] = ...
...
cfrns[0] = new CompFriend...
cfrns[1] = ...
...
// 번갈아 출력
System.out.println(ufrns[0]);
System.out.println(cfrns[0]);
System.out.println(ufrns[1]);
System.out.println(cfrns[1]);
...
}
지금은 관리 대상이 둘이라 둘만 초기화 및 출력은 직접 적을 수 있지만, 대상이 늘어난다면...? 특히 출력부는 번갈아서 출력한다면 일일이 다 적어서 비효율적으로 출력해야되며, 반복문을 쓰더라도 따져야 될 조건이 더 많을 것이다. 이를 상속을 통해 리펙토링해보자.
Friend 라는 부모 클래스를 만들어보자.
public class Friend {
private String name;
private String phone;
public Friend(String name, String phone) {
this.name = name;
this.phone = phone;
}
public void showInfo() {
System.out.println("이름: " + name);
System.out.println("전화: " + phone);
}
}
이 클래스에서 대학 동료와 직장 동료의 공통적인 속성 이름, 전화번호를 묶은 것을 확인할 수 있다. 즉 연관된 일련의 클래스들에 대해 공통적인 규약을 정의 및 적용한 모습이다.
나머지 두 클래스는 이 부모 클래스 Friend
를 활용하면 어떨지 확인해보자.
public class UnivFriend {
private String major;
public UnivFriend(String name, String major, String phone) {
super(name, phone);
this.major = major;
}
@Override
public void showInfo() {
super.showInfo();
System.out.println("전공: " + major);
}
}
public class CompFriend {
private String department;
public CompFriend(String name, String department, String phone) {
super(name, phone);
this.department = department;
}
@Override
public void showInfo() {
super(name, phone);
System.out.println("부서: " + department);
}
}
공통된 부분을 부모 클래스에 넣어 놓으니 단순히 super
를 통해서 부모의 기능들을 가지고 올 수 있다. 그러면 단순히 아래와 같이 main문을 작성 가능하다.
...
public static void main(String[] args) {
...
Friend[] friends = new Friend[10];
friends[0] = new UnivFriend...
friends[1] = new CompFriend...
...
// 번갈아 출력
for(int i = 0; i < cnt; i++) {
friends[i].showInfo();
}
}
입력부는 부모 타입으로 자식을 받는 것을 확인할 수 있는데, 이를 업케스팅(up-casting) 이라고 한다. 그리고 단순히 부모 타입으로 된 참조변수만으로 출력부를 반목분을 통해 쉽게 구현 가능한 것을 확인할 수 있다. 그 이유는 오버라이딩된 메소드는 무조건 자식을 따르기 때문에 부모 타입이라도 메소드는 자식의 것이 실행된다!
상속을 통해서 사실 코드의 추가 변경이 용이해질 뿐만 아니라, 코드 재사용이 용이해진다. 그러나, 상속을 남발할 경우 통한 재사용을 할 때 나타나는 단점이 발생한다.
부모 클래스의 변경이 불편해진다. 부모 클래스에 의존하는 자식 클래스가 많을 때 부모 클래스 변경이 필요하다면 자식 클래스도 모두 수정해주어야 한다. 즉 결합도가 높아 객체 지향에 바람직하지 않다.
역시 높은 결합도로 인한 문제로 불필요한 클래스의 유사 기능 확장시, 필요 이상의 불필요한 클래스를 만들어야할 수 있다.
IS-A
관계가 아닐 때 상속을 남발한다면, 클래스 및 객체를 사용하는 입장에서 오해를 살 수 있다. IS-A
관계에 대해서는 바로 아래에 설명이 되어 있다.
그렇다면 이러한 문제는 어떻게 해결할까?
바로 구성(Composition) 을 통해 해결하는 것이다. 상속 대신 필드에 넣는 것으로 대체하는 것이다. 보통 이 경우에는 IS-A
관계보단 HAS-A
관계에서 사용하게 된다.
정리해보면,
상속은 반드시
IS-A
관계 성립할 때재사용 관점이 아닌, 기능의 확장 관점으로 사용해야 한다.
상속을 코드 재사용의 개념으로 이해하면 안 된다. 코드를 재사용할 수 있다고 무조건 상속을 사용할 경우 결합도가 높아져서 객체지향에 바람직하지 않다.
"자식클래스는 (하나의) 부모클래스이다." 관계로, 부모를 자식이 상속
(예시) Circle is a Shape.
Circle 클래스는 하나의 Shape 클래스이다.
자바 코드로 살펴보도록 하자.
public class Shape {
private double area;
public void calcArea() {...} // 넓이 구하기
}
public class Circle extends Shape {
private int x; // 중심점 x좌표
private int y; // 중심점 y좌표
private int r; // 반지름
@Override
public void calcArea() {...};
}
이런식으로 Shape 에서는 넓이를 구하는 메소드를 만들었으면, 원에서는 중심점의 좌표와 반지름이라는 정보를 추가하고 원의 넓이를 구하는 메소드를 오버라이드한다. 즉 코드를 보면 재사용이라기보단 '원'이라는 정보로 확장하는 편에 가깝다는 것을 알 수 있다.
한 클래스의 멤버변수로 다른 클래스 타입의 참조변수를 선언
(예시) Circle has a Point.
Circle 클래스는 Point 클래스를 가지고 있다.
자바 코드로 살펴보도록 하자.
public class Point {
private int x; // 중심점 x좌표
private int y; // 중심점 y좌표
}
public class Circle {
private Point center = new Point();
private int r; // 반지름
}
기존 Circle 에서 중심점에 해당하는 것이 사실은 Point 객체에 해당하는 것을 알 수 있다. 이런 관계에서는 기능 확장의 요소는 보이지 않고 다만 재사용에 가깝다는 사실을 알 수 있다. 이 때 우리는 상속 대신 구성 관계로 표현해서 필드에 Point 객체를 생성한다.
위 두 관계를 정리해보자.
Circle is a Shape. - Circle 클래스는 하나의 Shape 클래스이다.
Circle has a Point. - Circle 클래스는 Point 클래스를 가지고 있다.
이 둘을 클래스 다이어그램을 통해 표현하면 다음과 같다.
자세한 클래스 다이어그램 표기사항 등 관련된 내용은 첨부해 두었다. 이렇게 클래스 다이어그램을 사용하여 클래스들 간 관계를 명확하게 표현할 수 있다.