2024. 4. 12. 20:09ㆍJava/OOP
작성 배경
이전에 "스프링 입문을 위한 자바 객체 지향의 원리와 이해"라는 책에서도 OOP 관한 설명이 있었다. 그 책에는 SOLID원칙과 캡상추다!라면서 캡슐화, 상속, 추상화, 다형성에 관한 설명이 있었다. 처음 배우는 내용이기도 했고 저자에게는 미안한 얘기지만 예시가 부실해서 이해가 안됐다.
지금은 여러 강의도 듣고, 책을 보면서 조금은 이해가 된 것 같다. 그 과정에서 드는 생각은, 프로그래밍이 공부를 할수록 같은 것도 다르게 보이는게 있어서 참 좋은 것 같다.
개인적으로 객체 지향에서 말하는 4가지 요소를 공부하며 느낀 바는, 캡슐화와 나머지로 묶는게 이해가 편하지 않나 싶다. 캡슐화에 관한 이야기는 앞의 getter에 관한 글에 어느정도 정리가 되어있으니, 생략하도록 하고 이 글에서는 나머지에 관해 이야기해보고자 한다.
1. 상속, 추상, 다형
Getter를 설명하면서 이야기한 캡슐화는 다른 코드에 영향을 최소화하고 객체의 내부 구현을 변경할 수 있는 유연함을 제공한다. 반면 상속, 추상, 다형은 기존의 타입 혹은 선언들을 변경하지 않고 추가 구현을 할 수 있게 해주는 방법이다.
상속을 받으면 부모의 형질을 물려받을 수 있다. 또한 상속을 받으면 형질을 물려 받는 것 뿐만 아니라 다형성으로 인해 부모의 타입으로 자식을 사용할 수 있다. 그 예시를 보면 아래와 같다. 자동차를 예시로 해보자. 자동차 클래스를 다음과 같이 정의하고 그를 상속 받은 가솔린차와 전기차를 만들었다.
public abstract class Car {
private Engine engine;
private Fuel fuel;
public void run(Fuel fuel, Engine engine) {
// 작동과 관련된 코드
}
}
public class ElectricCar extends Car {
}
public class GasolineCar extends Car {
}
이를 통해 다음과 같이 아래처럼 코드를 작성할 수 있다. 아래는 상속과 다형성을 사용한 코드이며 주석을 통해 내용을 확인할 수 있다.
public static void main(String[] args) {
List<Car> cars = new ArrayList<>();
GasolineCar gasolineCar = new GasolineCar();
ElectricCar electricCar = new ElectricCar();
Fuel fuel = new Fuel(100);
Engine engine = new Engine();
/**
* 상속으로 인해 gasolineCar에서 정의가 안했지만
* 부모에 정의된 코드로 작동함.
*/
gasolineCar.run(fuel, engine);
/**
* 다형성으로 인해 자동차 리스트에는 Car 타입 뿐만 아니라
* GasolineCar,ElectricCar 또한 담을 수 있음
*/
cars.add(gasolineCar);
cars.add(electricCar);
}
개인적으로 처음에 다형성을 배우며, 다형성에서 Car에 GasolineCar와 electricCar를 담을 수 있는게 뭐가 대단한가 싶었다. 부모 타입으로 자식 타입을 담을 수 있는 건 변경이 일어났을 때 엄청난 역할을 한다.
기존의 엔진이 아닌 하이브리드 엔진이 나왔다고 해보자. 그래서 가솔린차의 엔진을 바꿔주고 싶다. 이 때 다형성이 없었다면? 아마도 Car 내부에 있는 Engine 필드의 타입을 수정하고 HybirdEngine으로 바꿔주어야 할 것이다. 각각의 엔진과 연료의 조합에 따라 새로운 Car 클래스를 만들게 만들며 필드 수가 늘어날 수록 가짓수가 엄청나게 될 것이다. 아래처럼 말이다.
public abstract class Car {
private HybridEngine engine;
private Fuel fuel;
public void run(Fuel fuel, HybridEngine engine) {
// 작동과 관련된 코드
}
}
하지만, 다형성 덕분에 Car 클래스는 전혀 수정없이 사용할 수 있다. 아래처럼 말이다. 이게 다형성이 가진 강력함이라 생각한다. 또한 예시를 보았듯 상속과 다형성은 분리가 되기 힘들다고 생각한다.
public static void main2(String[] args) {
GasolineCar gasolineCar = new GasolineCar();
Fuel fuel = new Fuel(100);
HybridEngine hybridEngine = new HybridEngine();
/**
* 다형성으로 인해 hybridEngine으로 Engine을 바꾸어도
* 기존 Car 클래스를 수정할 필요가 없음.
*/
// gasolineCar.run(fuel, engine);
gasolineCar.run(fuel, hybridEngine);
}
상속에는 인터페이스 상속과 구현 상속이 있다. 이에 대해서는 크게 예시를 들이지 않겠다. 구현 상속은 구체적인 내용을 보통 부모로부터 물려받는다. 그래서 부모의 함수를 쓰고 싶다면 그대로 쓰면 되고 재정의가 필요하다면 오버라이드해서 사용하면 된다. 이 때, 구현 상속은 최대한 공통적인 내용에 대해 반박의 여지없이 크게 작성할 수록 작성하면 잘 작성한 것이다. 일종의 분류와 같다.
하지만 공통점을 크게 작성하기는 쉽지 않다. 예를 들면, 조류는 날 수 있다라는 의미로 Bird라는 클래스에 fly라는 메서드를 만들면 문제가 생긴다. 왜냐하면 펭귄은 조류이지만 날 수 없다. 꼭 새만 날 수 있는 건 아니다. 그래서 이런 형질은 인터페이스로 분리해서 최대한 작게 만들어서 제공해주면 좋다. 그게 인터페이스 상속이고 보통 가능하다는 의미로 runnable 같은 형태로 제공되며 다중상속을 가능하게 만들어준다.
그럼 추상은 무엇일까? 계속 고민을 해본 결과, 추상화는 개념을 나타내는 것이라고 생각한다. 추상화를 하면 방식의 변경에 대해 유연함을 얻을 수 있다. 이 또한 상속과 다형성을 이용해도 되고 아니어도 된다. 단지, 생각한 걸 개념화 시킨다고 생각하면 된다.
날짜를 나타내려면 어떻게 해야될까를 고민해보자. 누구는 년, 월, 일로 구분해서 각각을 int나 String 형으로 나타내자고 하는 사람이 있을 수 있다. 반대로 누구는 필드 하나로만 하자. 특정일을 기준으로 몇 초가 지났는지로 나타내자고 할 수 있다. 이렇게 실제 개념을 표현하는 방식은 여러 가지겠지만 날짜와 시간으로 추상화가 가능하다.
이렇게 추상화가 가능하면 내부의 구현에는 관심없이 추상화된 Date라는 개념을 정의할 수 있다. 그리고, 내부 구현을 본인의 방식대로 알아서 끼워맞추게 하면 해당 Date를 사용하고 있는 내부 구현방식이 변해도 해당 Date를 사용하는 클래스를 바꿀 필요가 없다. 꼭 새로 클래스를 정의할 필요없이 구체 클래스 StringDate가 바뀌어도 해당 StringDate를 꽂아쓰는 Date를 변경하지 않아도 된다는 것을 의미하기도 한다.
public interface Date {
public int getYear();
public int getMonth();
public int getDay();
}
public class StringDate implements Date {
private String year;
private String month;
private String date;
// 오버라이드된 메서드들
}
public class IntDate implements Date {
private int year;
private int month;
private int date;
// 오버라이드된 메서드들
}
2. 상속에서의 주의사항
상속 계층도의 크기가 커짐에 따라 변경에 취약해짐
앞에서 상속에 관해 설명할 때, 공통점을 크게 작성하는 것이 쉽지 않다고 이야기를 했다. 그 이유는 상속 계층도가 커질수록 변경에 취약해지기 때문이다.
아래의 그림의 구조로 상속도가 그려지면 A 클래스에서의 변경은 나머지 5개의 클래스를 변경시킨다. AA 클래스의 변경은 AAA와 BBB 클래스를 변경시킨다. 즉, 계층도가 커짐에 따라 상위 클래스를 변경하는 게 어려워진다. 상속의 특징으로 인해 모든 하위 클래스에 영향을 주기 때문이다.
따라서 최대한 공통점이 크게 잘 만들면 당연히 잘 만든 것이고 좋겠지만, 계층이 커질 것으로 예상되는 클래스의 최상위 클래스는 작게 만드는 게 좋다.
자바에서 다중 상속을 하지 않으려는 이유
앞의 Engine 예시로 다시 돌아와보자. 만약에 FuelEngine과 ElectricEngine이 있고 이를 표현하기 위해 ElectricEngine과 FuelEngine을 상속 받은 클래스가 생겼다고 해보자. 그리고 이제 HydrogenEngine이 있어서 그걸 조합한 ElectricEngine과 조합한 클래스가 있다고 해보자. 이렇게 상속으로 기능 재사용을 하려고 들면 계층도가 복잡해지고 클래스 수가 기능이 늘어남에 따라 무한정 늘어나게 된다. 이는 잘못된 설계이다. 따라서 상속을 통한 재사용이 항상 좋은 것이 아님을 인지해야 한다.
상속과 메서드 검색
과거에는 이름에 관해 크게 신경쓰지 않았는데, 특히나 캡슐화된 메서드에서 이름의 중요성에 대해 깨닫고 있는 것 같다. 클린 코드에서도 나온 내용이지만, 사람은 본인이 사용하는 클래스의 자세한 구현 내용에 대해 관심을 가지려고 하지 않는다. 코드가 있음에도 그걸 읽는 것보다 일단 그럴싸한 이름을 가진 걸 가져다가 일단 써본다. 따라서 메서드의 이름을 잘 짓는 것은 중요하다.
책에 나온 예시로 Container를 ArrayList를 상속 받아 구현하는 코드가 있었다. 해당 코드는 어느정도 ArrayList를 상속 받아서 사용해서 해당 메서드의 기능을 제공 받았고 쉽게 구현을 할 수 있었다. 그런데 상속의 문제는 부모의 메서드도 사용이 가능하기에 put과 add가 IDE에서 메서드를 검색할 때 모두 나타나는 일이 발생했다. 이 때, put도 되고 add도 된다면? 친숙한 이름으로 고를 것이다. 그런데 add를 고르면 put에 있는 예외 처리를 제대로 사용하지 못한다.
public class Container extends ArrayList<Luggage> {
private int maxSize;
private int currentSize;
public Container(int maxSize) {
this.maxSize = maxSize;
}
public void put(Luggage lug) throws NotEnoughSpaceException {
if (!canContain(lug))
throw new NotEnoughSpaceException();
super.add(lug);
currentSize += lug.size();
}
위의 예시는 극단적인 예시이지만, 충분히 가능한 일이라 생각했다. 여기서 고민해볼 건 이건 누구의 책임이냐는 것이다. 문서화를 제대로 했지만 구현을 잘못한 개발자인가 아니면 문서를 안 읽고 사용한 개발자냐는 문제에서 저자는 전자의 잘못이 크다고 애기한다.
이것을 보며 상속에서의 조심할 사항도 배울 수 있지만 왜 메서드 명칭을 잘 짓고 불필요한 메서드를 만들면 안되는지도 생각해볼 수 있었다. 이 점으로 이 문제를 다시 한번 봐보면 좋을 것 같다.
[ 참고 자료 ]
이미지참고 :https://velog.io/@cyhse7/%EC%9E%90%EB%B0%94-%EC%83%81%EC%86%8D-%EC%97%B0%EC%8A%B52
클린 코드 - 로버트 C.마틴
개발자가 반드시 정복해야 할 객체 지향과 디자인 패턴 - 최범균
'Java > OOP' 카테고리의 다른 글
스프링 컨테이너와 서비스 로케이터가 필요한 이유 (0) | 2024.04.16 |
---|---|
자바와 SOLID 원칙(TIL) (0) | 2024.04.13 |
자바의 Getter에 대하여 (1) | 2024.04.12 |