블랙잭 미션에서 상태 패턴을 적용하려고 시도 하면서 인터페이스와 추상클래스를 모두 사용하게 되었다. 미션을 진행할 때 당시에는 다형성에 대한 개념도 잘 모르면서 무작정 인터페이스와 추상클래스를 사용했었는데 각각에 대해 공부해보고 언제 사용해야 하는지 개인적인 기준을 정립해보고자 했다.
해당 포스팅은 인터페이스와 추상클래스에 대한 개인적인 생각을 정리한 것이지 정답이 아니기에 틀린 내용이 있을 수 있습니다. 😂
인터페이스에 대한 특징들을 간단하게 정리하면 다음과 같다.
- 인터페이스는 모든 메서드가 추상 메서드로 선언된다.
- 미리 사용할 메서드를 인터페이스에 선언해두고 구현 시 선언된 추상 메서드들을 구현하면 된다.
- 이는 자식 클래스에게 구현을 강제시켜 구현 객체의 동일 동작을 보장한다고 볼 수 있다.
- 상속보다는 다형성의 개념에 더 가깝다.
- 다형성은 하나의 타입에 대입되는 객체에 따라서 동일한 기능의 실행 결과가 다르게 나오는 성질을 말한다.
- 인터페이스는 다형성을 제공하기 위해 반드시 구현해야하는 메소드를 포함한 틀이라고 얘기할 수 있다.
인터페이스에 대해 공부하면서 한 마디로 재정의를 해본 결과 객체 간 다형성을 보장하기 위한 틀이라고 얘기할 수 있을 것 같다.
추상 클래스에 대한 특징들을 간단하게 정리하면 다음과 같다.
- 추상 클래스는 클래스가 abstract 키워드로 선언되어 클래스 내 추상 메서드가 하나 이상 포함되는 경우이다.
- 추상 클래스에는 여러 메서드가 존재하므로 다음의 방법들 중 선택해서 사용한다.
- 추상 클래스(상위 클래스)의 기존 메서드가 구현된 그대로 가져다 쓴다.
- 추상 클래스(상위 클래스)의 기존 메서드를 오버라이드해서 재구현해 사용한다.
- 이 경우는 상위 클래스의 구현방식 및 수정에 따른 예상치 못한 스노우 볼이 구를 수 있으니 무조건적으로 하지 말라고 한다. (상속에서 상위 클래스의 메서드를 재구현하는 방식의 고질적인 문제)
- 해당 내용은 상속과 조합 내용을 정리하는 포스팅에서 따로 다뤄보고자 한다.
- 추상 클래스(상위 클래스)에 abstract로 선언된 메서드를 구현해서 사용한다.
- 추상 클래스는 인스턴스를 직접적으로 생성하지는 못하지만 클래스이기 때문에 상속을 사용해서 추상 메서드를 모두 구현해줘야만 한다.
인터페이스에 대해 학습을 했었을 때는 다형성을 보장하기 위한 틀이라는 인식이 바로 들었던 반면에 추상 클래스에 대해서는 학습하고 나서도 이걸 언제, 어떻게 사용해야하는지 바로 와닿지는 않았다. 하위 클래스에게 구현을 강제해서 다형성의 장점을 활용하고 싶다면 인터페이스를 사용하면 될텐데 상속의 문제점들을 그대로 안고 있는 추상클래스는 왜 존재하는 것일까?
이 질문에 대한 결론을 내리기에 앞서서 그러면 상속이 적절하게 사용되는 경우란 무엇인지에 대해서 생각해봐야 한다.
해당 부분은 학습하는데 큰 도움을 줬던 레퍼런스 내용을 일부 인용해보겠다.
상속이 적절한 경우란 클래스의 행동을 확장(extend) 하는 것이 아니라 정제(refine)할 때이다.
확장이란 새로운 행동을 덧붙여서 기존의 행동을 부분적으로 보완하는 것을 의미한다.
정제란 부분적으로 불완전한 행동을 완전하게 만드는 것을 의미한다.
이렇게 말로만 들으면 어떤 차이인지 이해하기 힘드니 코드를 보며 좀 더 구체화시켜 보자.
public class ExtendedHashSet<E> extends HashSet<E> {
private int addCount = 0;
public ExtendedHashSet() {}
@Override
public boolean add(E e) {
addCount++;
return super.add(e);
}
@Override
public boolean addAll(Collection<? extends E> collection) {
addCount += collection.size();
return super.addAll(collection);
}
public int getAddCount() {
return addCount;
}
}
위 코드는 상속의 고질적인 문제를 얘기할 때 자주 등장하는 hashSet 구현체 상속 코드이다. 여기에서 add와 addAll 메서드가 재구현된 방식을 잘 보면 addCount를 계산하는 기능 + 기존 상위 클래스가 수행하던 메서드의 기능을 수행하도록 되어있다. 이런 방식을 두고 우리는 상위 클래스의 기능을 확장했다라고 표현할 수 있다. 이 코드가 어떠한 문제점을 갖고 있는지는 구글링하면 쉽게 찾아 볼 수 있으므로 넘어가도록 하겠다.
public abstract class Student{
public abstract void openMajorBook();
public abstract void doMajorAssignment();
}
public class ComputerEngineeringStudent extends Student{
@Override
public void openMajorBook() {
System.out.println("컴퓨터 공학 전공 서적을 펼칩니다.");
}
@Override
public void doMajorAssignment() {
System.out.println("컴퓨터 공학 전공 과제를 수행합니다.");
}
}
위 코드는 Student라는 추상 클래스의 추상 메서드를 하위 클래스인 ComputerEngineeringStudent가 구현해 사용하는 코드이다. 이처럼 하위 클래스에서 상위 클래스의 미구현된 기능들을 구현해주는 것을 두고 상위 클래스의 기능을 정제한다 라고 표현할 수 있다.
지금까지 상속을 할 때 확장의 개념으로 사용하는 것과 정제의 개념으로 사용하는 방식을 살펴봤다. 그리고 적절한 상속의 사용 방식은 정제의 개념으로 사용되는 것이라는 것도 알아봤다. 다시 본론으로 돌아와서 추상 클래스는 어떤 시점에 왜 사용해야 하는가에 대해 생각해보자.
객체의 다형성을 활용하기 위해 인터페이스를 사용하는 한 가지 예시를 들어보겠다.
아래는 해당 이미지 구조를 가지는 구현 코드이다.
public interface People {
void sleep();
void eat();
void walk();
void run();
void stop();
void sit();
void stand();
}
public class ComputerEngineeringStudent implements People {
@Override
public void sleep() {
System.out.println("잠을 잡니다.");
}
@Override
public void eat() {
System.out.println("식사를 합니다.");
}
@Override
public void walk() {
System.out.println("걷는 상태로 전환합니다.");
}
@Override
public void run() {
System.out.println("뛰는 상태로 전환합니다.");
}
@Override
public void stop() {
System.out.println("움직임을 정지합니다.");
}
@Override
public void sit() {
System.out.println("앉은 상태로 전환합니다.");
}
@Override
public void stand() {
System.out.println("일어선 상태로 전환합니다.");
}
public void openMajorBook() {
System.out.println("컴퓨터 공학 전공 서적을 펼칩니다.");
}
public void doMajorAssignment() {
System.out.println("컴퓨터 공학 전공 과제를 수행합니다.");
}
}
컴퓨터 공학 전공생 객체는 People 인터페이스를 구현하여 사람이 수행하는 기능을 모두 수행할 수 있음을 보장하고 있다. 이는 추후 People 인터페이스를 구현한 다른 객체들과 함께 사용될 때 다형성의 장점을 취할 수 있게 해줄 것이다.
그런데 우리가 구현해야 하는 전공생의 객체 종류가 하나가 아니라면 어떻게 될까? 아래는 철학 전공생 객체를 구현한 코드이다.
public class PhilosophyStudent implements People {
@Override
public void sleep() {
System.out.println("잠을 잡니다.");
}
@Override
public void eat() {
System.out.println("식사를 합니다.");
}
@Override
public void walk() {
System.out.println("걷는 상태로 전환합니다.");
}
@Override
public void run() {
System.out.println("뛰는 상태로 전환합니다.");
}
@Override
public void stop() {
System.out.println("움직임을 정지합니다.");
}
@Override
public void sit() {
System.out.println("앉은 상태로 전환합니다.");
}
@Override
public void stand() {
System.out.println("일어선 상태로 전환합니다.");
}
public void openMajorBook() {
System.out.println("철학 전공 서적을 펼칩니다.");
}
public void doMajorAssignment() {
System.out.println("철학 전공 과제를 수행합니다.");
}
}
코드를 보면 알겠지만 people로부터 구현해주는 기능이 완전히 동일하다. 그럼에도 불구하고 People 인터페이스를 통한 다형성을 활용하기 위해선 인터페이스에 명시된 추상 메서드를 구현해줄 수 밖에 없다. 이런 방식으로 모든 대학교의 전공생 객체를 구현해야 한다고 하면 정말 많은 분량의 중복 코드가 발생할 것이다.
또 다른 문제점은 Student 객체마다 구현되어 있는 openMajorBook과 doMajorAssignment 메서드들을 사용해야 할 때 나타난다. People 타입으로 객체를 저장해 누릴 수 있었던 다형성을 포기하고 각 구현체 타입으로 다운캐스팅해야만 두 기능들을 사용할 수 있게 되는 것이다.
정리하자면 위에서 예시로 들었던 구조의 단점은 다음과 같다.
1. 다형성을 활용하기 위해서는 People의 추상 메서드들을 구현해줘야 하는데 매번 중복 코드가 발생한다.
2. People의 기능을 이용할 수 있는 다형성 활용에서는 전공생으로서의 기능을 함께 사용할 수 없다. 사용하기 위해서는 본래의 Student 클래스로 다운 캐스팅 해줘야만 한다.
혹자는 People 인터페이스를 상속하는 Student를 인터페이스로 설계해서 구현하면 되는 것 아니냐 라고 질문을 던질 수 있겠다. 하지만 이 경우에도 모든 전공생 클래스마다 People 기능에 대한 구현 코드 중복을 피할 수는 없다.
여기에서 우리는 추상 클래스의 도입을 고려해볼 수 있다. 아래는 Student라는 추상 클래스를 설계하고 컴퓨터, 철학 전공생 객체들이 Student 추상 클래스를 상속받아 구현하도록 리팩토링한 구조이다.
public abstract class Student implements People {
@Override
public void sleep() {
System.out.println("잠을 잡니다.");
}
@Override
public void eat() {
System.out.println("식사를 합니다.");
}
@Override
public void walk() {
System.out.println("걷는 상태로 전환합니다.");
}
@Override
public void run() {
System.out.println("뛰는 상태로 전환합니다.");
}
@Override
public void stop() {
System.out.println("움직임을 정지합니다.");
}
@Override
public void sit() {
System.out.println("앉은 상태로 전환합니다.");
}
@Override
public void stand() {
System.out.println("일어선 상태로 전환합니다.");
}
public abstract void openMajorBook();
public abstract void doMajorAssignment();
}
각 전공생 객체들이 일일이 구현해주고 있던 People 인터페이스 구현 코드들을 모두 Student 추상 클래스에 분리했다. 또한 People인터페이스에는 명시할 수 없었던 학생과 관련된 기능들도 추상 클래스로 명시를 해줬다. 그러면 Student를 상속받는 전공생 객체들은 어떻게 변화되었을까?
public class ComputerEngineeringStudent extends Student {
@Override
public void openMajorBook() {
System.out.println("컴퓨터 공학 전공 서적을 펼칩니다.");
}
@Override
public void doMajorAssignment() {
System.out.println("컴퓨터 공학 전공 과제를 수행합니다.");
}
}
public class PhilosophyStudent extends Student {
@Override
public void openMajorBook() {
System.out.println("철학 전공 서적을 펼칩니다.");
}
@Override
public void doMajorAssignment() {
System.out.println("철학 전공 과제를 수행합니다.");
}
}
전공생 객체들마다 구현되어야 했던 People 구현 코드들이 분리되면서 한결 가독성이 향상된 모습이다. 또한 Student 타입 변수에 객체를 저장하면 기존에 의도했던 다형성을 활용해 전공생 객체들을 관리할 수 있다. 게다가 People과 Student의 모든 기능을 다운 캐스팅 없이도 사용할 수 있다. 마지막으로 상속을 사용하면서 상위 클래스의 추상 메서드만을 구현해주고 있기 떄문에 정제의 경우에 속해 적합하게 상속을 사용했다고 얘기할 수 있다.
여기에서 추상 클래스를 어떤 이유로 언제 사용해야 하는지에 대한 결론을 내려볼 수 있을 것 같다.
- 기존 인터페이스를 통한 다형성 활용 과정에서 공통 기능에 대한 구현 코드 중복을 방지하고 싶은 경우.
학습을 진행하면서 추상 클래스를 코드의 중복을 피하기 위해 사용한다는 내용들을 많이 접했다. 하지만 이렇게만 얘기하면 다형성 활용의 관점을 최우선적으로 생각하지 않고 단순하게 코드 중복만을 피하기 위한 수단으로써만 추상 클래스를 바라보게 될 가능성이 존재한다고 생각한다. 무엇이 더 우선 순위가 높은 본질인지 잘 알아보고 결정해서 사용하도록 하자.
레퍼런스
상속은 캡슐화를 깨뜨린다? - https://livenow14.tistory.com/33
추상클래스 VS 인터페이스 - https://myjamong.tistory.com/150
'우아한테크코스 > 학습 정리' 카테고리의 다른 글
[Level 2] Layered Architecture에 대한 개인적인 고찰 (0) | 2023.04.27 |
---|---|
[Level 2] Repository와 Dao를 분리하는 기준 (5) | 2023.04.21 |
[Level 2] 공식문서를 통한 프레임워크 학습 방법에 대한 고찰 (1) | 2023.04.16 |
[Level 1] Dto 사용에 관한 개인적인 고찰 (3) | 2023.03.19 |
[Level 1] 좋은 객체의 7가지 덕목 (4) | 2023.03.11 |