상속이란?
자바에는 상속(Inheritance)이라는 개념이 존재합니다.
쉽게 말해 부모 클래스(상위 클래스)와 자식 클래스(하위 클래스)가 있으며, 자식 클래스는 부모 클래스를 선택해서, 그 부모의 멤버를 상속받아 그대로 쓸 수 있게 됩니다.
상속을 하는 이유는 간단합니다. 이미 마련되어 있던 클래스를 재사용해서 만들 수 있기 때문에 효율적이고, 개발 시간을 줄여주게 됩니다.
상속을 하더라도 자식 클래스가 부모의 모든 것들을 물려받는 것은 아닙니다.
- 부모 클래스의 private 접근 제한을 갖는 필드 및 메소드는 자식이 물려받을 수 없습니다.
- 부모와 자식 클래스가 서로 다른 패키지에 있다면, 부모의 default 접근 제한을 갖는 필드 및 메소드도 자식이 물려받을 수 없습니다.
(default 접근 제한은 ‘같은 패키지에 있는 클래스’만 접근이 가능하게끔 하는 접근 제한자이기 때문입니다.) - 그 이외의 경우는 모두 상속의 대상이 됩니다.
클래스 상속
상속받고자 하는 자식 클래스명 옆에 extends 키워드를 붙이고, 상속할 부모 클래스명을 적습니다.
자바는 다중 상속을 허용하지 않으므로, extends 뒤에는 하나의 부모 클래스만 와야 합니다.
public Class Parent{ .... }; // 부모 클래스
public Class Child extends parent { .... }; // 자식 클래스
public class ParentBook{
String name; //필드
int price;
public void Print(){ // 메소드
System.out.println("책의 이름과 가격 : "+name+" "+price);
}
public class ChildBook extends ParentBook{
ChildBook (String name, int price){ // 생성자
this.name = name; // ChildBook이 ParetBook의 필드를 상속받아서 가능한 선언
this.price = price; // "
}
public static void main (String[] args){
ChildBook Child = new ChildBook("나의 라임오렌지 나무", 10000);
System.out.print("[구현 결과 1] ");
Child.Print();
}
[구현 결과 1] 책의 이름과 가격 : 나의 라임오렌지 나무 10000
ChildBook 클래스가 ParentBook의 필드와 메소드를 상속받았습니다.
그러므로 ChildBook 클래스 내에 따로 필드나 메소드가 선언되어 있지 않았는데도,
생성자의 this.name 선언이나, main 메소드의 Child.Print() 가 컴파일 에러가 나지 않게 됩니다.
어렵게 생각하지 마시고 부모의 필드와 메소드를 그대로 가져다 쓴다고 생각하시면 편합니다.
부모 생성자의 호출 : super(…);
자바에서는 자식 객체를 생성하면, 부모 객체를 먼저 생성한 후, 자식 객체가 그 다음에 생성됩니다.
위에서 살펴보았던 코드에서는 따로 부모 객체를 생성하는 과정이 코드상에는 구현되어 있지 않았는데,
사실 내부적으로는 부모 객체 생성 후, 자식 객체가 생성되는 것이었습니다.
객체는 생성자를 호출해야만 생성되는데, 부모 객체를 생성할 때 부모 생성자를 어디서 호출할까요? 계속 위에서 살펴보았던 코드로 설명을 하겠습니다.
일단 생성자는 ‘명시적인 생성자 선언’이 없다면, 컴파일러는 알아서 기본 생성자를 생성하여 호출합니다.
위에서 봤던 코드에서는 부모 클래스(ParentBook)는 명시적 생성자 선언이 없었고, 자식 클래스(ChildBook)는 명시적 생성자 선언이 존재합니다.
그러면 이 때 부모 클래스의 생성자 선언은, 자식 클래스의 생성자 선언 내부에 맨 첫줄에 super(); 라고 생성됩니다.
부모의 기본 생성자 선언을 포함해서 명시적 생성자 선언이 없었으므로, 컴파일러가 super(); 를 알아서 생성합니다. 그리고 그 super(); 는 부모 클래스의 기본 생성자를 컴파일러가 호출하는 것입니다.
앞으로 ‘super’ 키워드는 부모 클래스(객체)를 나타내는 것임을 기억하면 됩니다.
그렇다면 부모에게 기본 생성자는 없고, 매개 변수가 있는 명시적 생성자만 있다면 어떻게 될까요?
자식 클래스에서는 반드시 생성자 내부 첫줄에 super( 매개값 , 매개값, …); 과 같이 선언해 주어야 합니다.\
컴파일러는 기본 생성자만 담당해 주기 때문입니다.
public class ParentBook{
String name; //필드
int price;
public ParentBook (String name, int price){ // 부모의 생성자가 있는 경우
this.name = name;
this.price = price;
}
public void Print(){ // 메소드
System.out.println("책의 이름과 가격 : "+name+" "+price);
}
public class ChildBook extends ParentBook{
ChildBook (){ // 자식 생성자
super("나의 라임오렌지 나무", 10000); // 반드시 자식 생성자 첫줄에 선언
}
public static void main (String[] args){
ChildBook Child = new ChildBook();
System.out.print("[구현 결과 2] : ");
Child.Print();
}
[구현 결과 2] 책의 이름과 가격 : 나의 라임오렌지 나무 10000
만일 부모에게 매개변수를 포함한 명시적 생성자 선언이 있었는데, 자식 생성자에서 super 를 통하여 호출해주지 않았을 경우 컴파일 에러가 납니다.
또한, 앞에서도 말했다시피 반드시 자식 생성자 내부의 첫 줄에 super(…..); 를 써야만 컴파일 에러가 나지 않습니다.
우리가 알고 있는 상속의 장점은 다음과 같습니다.
- 코드의 확장성, 재사용성 상승
- 중복된 코드 제거가능
- 객체지향 프로그래밍에서의 다형성
다만, 상속에 대한 이해가 부족한 상태에서 상속을 사용하게 되면, 상속에도 많은 단점이 존재합니다.
상속은 코드를 재사용하는 강력한 수단이지만, 항상 최선은 아니다. 잘못 사용하면 오류를 내기 쉬운 소프트웨어를 만들게 된다.
- 이펙티브 자바 3판
※ 이제부터 나오는 상속의 단점은, 인터페이스 상속(implements)가 아닌, 구현 상속에서 나오는 문제점입니다.
1) 캡슐화를 깨트린다
캡슐화 : 객체의 속성(데이터 필드)와 행위(메서드)를 하나로 묶고, 구현 내용을 접근 제어자를 통해 정보 은닉합니다.
이것을 다른 말로 풀어서 쓴다면, 부모 클래스가 어떻게 구현되느냐에 따라 하위 클래스의 동작에 이상이 생길 수 있습니다.
이를 해결하려면 부모 클래스의 구현 내용을 알아야 하위 클래스에서 해결할 수 있고, 이러한 불필요한 구현 내용 노출은 캡슐화를 깨트리는 행위입니다.
만약, HashSet 자료구조를 상속받아 새로운 자료구조를 만든다고 가정합시다.
이 새로운 자료구조(AwesomeHashSet)는 인스턴스를 생성한 이후에 들어온 원소의 숫자를 카운팅하는 기능이 있습니다.
얼핏 보면, 정상적으로 작동하는 코드 같습니다.
여러 개의 원소를 addAll하게 되면, 해당 원소의 길이만큼 카운팅 한 다음 부모의 addAll을 호출하고
한 개의 원소를 add 하게 되면, 카운팅을 하나 늘리고 부모의 add 함수를 호출하기 때문에 문제가 없어보입니다.
그러나, 이 코드는 정상적으로 작동되지 않습니다.
HashSet의 내부를 들여다보면, addAll 메소드 내부에는 add 메소드를 호출하고 있습니다.
그렇기 때문에 "문제가 발생하는 코드"처럼 addAll 메소드를 사용하게 되면, AwesomeHashSet.addAll에서 원소 3개가 카운팅 되고, 부모의 addAll을 호출하게 됩니다.
부모의 addAll을 호출하면, AbstractCollection.java에 구현되어 있는 addAll을 그대로 사용합니다.
이 내부에서 add 메서드를 호출하게 됩니다. 셀프 참조에 의해 AwesomeHashSet부터 오버라이딩 된 add 메서드가 있는지 탐색합니다.
마침 AwesomeHashSet.add에서 오버라이딩이 되었군요!
그래서 AwesomeHashSet.add가 총 3번 호출되어 카운팅이 3번 더 올라갑니다.
따라서 문제가 발생하는 코드를 입력했을 때, 원래 기대한 값은 3이 카운팅되어야 하지만, 총 6이 카운팅되는 모습을 볼 수 있습니다.
오버라이딩과 오버로딩 및 셀프 참조에 대한 내용 (왜 이렇게 호출되는지 이해가 안 된다면)을 모른다면 '망나니 개발자'님의 이 글을 보면 도움이 됩니다.
[Java] 메소드 오버라이딩/ 메소드 오버로딩을 통한 상속 다형성에 대한 이해와 Self 참조
아래의 내용은 오브젝트(Object)를 읽으면서 정리한 내용입니다. Java에 한정되는 이야기이므로 다른 언어에는 적용되지 않을 수 있습니다. 1. 상속을 이용한 다형성과 메소드 오버라이딩/ 메소드
mangkyu.tistory.com
이처럼 상속을 사용한 후, 메서드를 재정의 한 경우에 문제가 생길 수 있습니다.
이러한 문제를 해결하려면, 부모 클래스가 어떻게 구성되어 있는지 알아야 한다는 것이 기저에 깔려있어야 합니다.
즉 내부 구현을 불필요하게 노출하는 꼴이 되는 것이죠.
만약 addAll 메서드가 add를 호출하는 것을 알고, AwesomeHashSet에서 addAll 메서드를 오버라이딩 하지 않았다고 합시다.
그렇다면 지금 당장 프로그램은 정상적으로 돌아가지만, 다음 릴리즈에서도 이럴 것이라는 보장이 없습니다.
2) 부모 클래스의 필드와 메서드를 찾는데 불편함이 있다.
자식 클래스에서는 부모 클래스의 필드와 메소드는 보이지 않습니다.
그러나 첫 번째 단점에서 설명했듯이 부모 클래스의 내부 구현을 알아야 할 일이 생긴다면, 부모 클래스에 가서 확인을 해야 합니다.
또한 부모의 필드를 이용할 때에도, 자식 클래스에는 해당 필드가 선언되어 있지 않은 형태이다보니 확인이 필요한 경우 불편함을 겪을 수 있습니다.
3) 상속은 결함 역시도 상속한다.
만약 부모 클래스의 API에 결함이 있다면, 자식 클래스도 역시 이 결함이 전파됩니다.
따라서 상속을 사용하려면 이러한 결함이 있는지 확인해야 하는 검증이 반드시 필요합니다.
이러한 검증을 하는 방법은 직접 하위 클래스를 만들어보는 방법이 유일합니다.
4) 상속은 API 문서에도 영향을 끼친다.
첫 번째 단점을 막으려면, 재정의를 했을 때 어떤 일이 일어나는 지 정확하게 명시해 개발자에게 알려주면 됩니다.
따라서 상속용 클래스는 재정의하는 메소드들을 내부적으로 어떻게 이용하는지(자기 사용) 문서로 남겨야만 합니다.
그러나 "좋은 API 문서란 '어떻게'가 아니라 '무엇'을 하는지를 설명해야 한다"라는 격언이 있습니다.
내부적인 구현의 노출은 격언과 반대과 반대되는 상황으로 상속이 캡슐화를 해치기 때문에 어쩔 수 없이 나타나는 단점입니다.
5) 상속을 하는 것은 굉장히 까다롭다.
상속을 사용했을 때의 치명적인 단점을 막기 위해서는,
- 상속용 클래스를 생성해서,
- 해당 내부 구현을 노출한 API 문서를 작성한 뒤,
- 하위 클래스를 만들어서 상속용 클래스에 문제가 없다는 것을 검증해야 합니다.
- 상속용 클래스의 생성자는 직, 간접적으로 재정의 가능 메소드를 호출해서는 안 됩니다 등등..
엄청난 노력이 들고, 클래스에 안기는 제약이 상당합니다. 또한 부모 클래스에 지속적인 변화가 있다면 이 변화가 생길 때 마다 하위 클래스의 오동작을 염려하게 된다는 것은 굉장히 까다로운 기술임을 알 수 있습니다.
😭 3. 뭐야, 상속은 언제 써요..?
상속을 사용하는 이유는, 중복되는 코드를 피하기 위한 '재사용성'에 있다고 했습니다.
위의 단점을 모두 가져갔을 때의 타격과 '재사용성'을 통해 얻는 이점 두 가지를 저울질해야 합니다.
또한 상속 말고, private 키워드로 기존 클래스(상속에서 부모 클래스로 사용하려던 것)의 인스턴스를 참조하도록 하는 '구성(Composition)'을 사용할 수 있는 경우가 있습니다.
구성을 사용하게 된다면, 메서드 재정의 혹은 새로운 메서드 정의로부터 오는 문제를 피해가는 장점을 가지면서, 기존의 클래스를 인스턴스로 사용하기 때문에 재사용하는 것도 가능합니다.
상속이 적절한 경우는 클래스의 행동을 확장(extend)하는 것이 아니라 정제(refine)할 때라고 합니다.
여기서 확장이란 새로운 행동을 덧붙여 기존의 행동을 부분적으로 보완하는 것을 의미하고, 정제란 부분적으로 불완전한 행동을 완전하게 만드는 것을 의미합니다.
🤔 정말 is-a 관계인가?
하위 클래스가 정말 상위 클래스의 하위 타입인지 고려해야 합니다.
아닌 경우라면, 대부분 구성을 사용해야 합니다.
🤔 상속 대신 컴포지션(구성)을 사용할 수는 없나?
구성은 상속의 여러 문제점을 해결합니다. 캡슐화를 깨트리는 상속보다는, 구성을 사용하는 것을 권장합니다.
🤔 상속으로 인해 내부 구현을 불필요하게 노출하지는 않나?
상속을 하게 되면, 메소드 재정의 중 내부 구현을 알아야 하는 경우가 생깁니다.
🤔 확장하려는 클래스에 정말 결함이 없는가? 혹은 결함 역시 전파되어도 괜찮은가?
하위 클래스는 상위 클래스의 결함 역시 상속하기 때문에 신중하게 선택해야 합니다.
글을 작성하면서 상속에 대한 부정적 이야기를 굉장히 많이 접하게 되었습니다.
심지어 "상속은 그저 재사용을 위한 절자치향 기술일 뿐이며, 이는 객체지향을 망가뜨린다"라며, 아예 상속의 사용을 지양해야 한다는 글도 보았습니다.
분명 상속은 객체지향의 주요 특징(추상화, 캡슐화, 상속, 다형성)에 들어갈 만큼 강력한 키워드인데 말이죠.
객체지향이 탄생할 때에는 이러한 단점을 고려하지 못하고 코드의 재사용성을 위해 만들어졌지만, 점점 시스템에 변화가 많아지는 현대의 경우 유연성이 더 중요한 개념으로 자리잡아 캡슐화를 깨트리는 상속은 결국 미운털이 박힌 것 같습니다.
물론 위의 단점들을 고려했을 때 지양해야 한다는 근거는 충분하지만, 어떤 것이 문제인지 알고 지양하는 것과 무조건적인 지양에는 차이가 있다고 생각합니다.
상황에 따라 적절한 기술의 선택이 굉장히 중요함을 깨닫게 되는 학습이었습니다.
'면접' 카테고리의 다른 글
추상 클래스(Abstract Class)와 인터페이스(Interface)의 특징 및 차이점 (0) | 2025.05.15 |
---|---|
SOLID (0) | 2025.05.13 |
객체 지향 패러다임의 4가지 주요 특성 - Encapsulation(캡슐화) (0) | 2025.04.30 |
객체 지향 패러다임의 4가지 주요 특성 - Polymorphism(다형성) (0) | 2025.04.30 |
Class(클래스) 와 Object(객체) (0) | 2025.04.29 |