본문 바로가기
IT 공부/객체지향 설계 공부

코드 컴플리트 6장 - 클래스 다루기 요약

by exdus3156 2023. 11. 21.
6장은 클래스를 다루기 위한 핵심 조언을 다룬다.

 

1. 클래스의 토대 : 추상 데이터형

1-1. ADT 사용의 좋은점

추상 데이터형(Abstract Data Type, wiki)이란 자료와 그 자료를 다루기 위한 연산을 정의한 것으로, 자료구조와는 달리 구체적인 구현을 숨긴 추상화된 데이터 타입을 뜻한다. 즉, ADT는 저수준의 구현이 아니라 문제 영역과 관련된 자료에 대한 개념과 그에 따른 조작만을 구축한다.

예를 들어, 워드 프로그램에서 텍스트의 폰트를 바꾸는 기능을 개발해보자. ADT를 사용할 경우, [CurrentFont]라는 추상 자료형을 설계할 수 있다. 그리고 이 추상 데이터(폰트)를 조작하는 일련의 연산을 아래와 같이 정의한다.

  • CurrentFont.setSizeInPoints()
  • CurrentFont.setSizeInPixels()
  • CurrentFont.setBoldOn()
  • CurrentFont.setBoldOff()
  • CurrentFont.setItalicOn()
  • CurrentFont.setItalicOff()

여기서 핵심은 폰트(Current Font)라는 데이터의 구현부를 전혀 고려하지 않는다는 것이다. CurrentFont 추상 데이터형이 어떤 기본 데이터 타입과 자료구조로 구성되었는지 알 수 없다. (알 필요도 없다)

이렇게 현실 세계의 엔티티를 자료구조나 구조체가 아니라 ADT로 설계할 경우, 다음과 같은 이점이 있다.

  1. 구현 세부 사항을 감춘다. 구현 세부 사항이 달라져도 프로그램의 다른 곳은 변경되지 않는다.
  2. 변경이 전체에 영향을 끼치지 않는다. 예를 들어, 폰트에 '취소선' 옵션을 추가하고 싶을 때, ADT만 수정하면 된다. 프로그램의 다른 부분은 건들지 않는다.
  3. 코딩 실수가 줄어든다. currentFont.bold = true; 라고 작성하는 것보다 currentFont.setBoldOn(); 이라고 작성하는 것이 실수를 줄여준다.
  4. 가독성이 좋아진다. 기본 연산을 수행하며 의미를 추론하는 것보다 데이터에 대한 개념과 인터페이스만 드러내는 것이 훨씬 더 이해하기 편하다.
  5. 기본 데이터형의 조합으로 복잡하게 엔티티를 이해할 필요가 없다. CurrentFont처럼 현실 세계의 객체를 직관적으로 다룰 수 있다.

 

1-2. ADT와 관련된 몇 가지 조언

첫 번째, 저수준 데이터 타입을 ADT로 설계하라.

ADT 사용이 필수는 아니다. 하지만 적어도 구조체나 자료구조 및 기본 데이터형보다 우수하다는 것이 연구로 밝혀졌다. (Woodfiled, Dunsmore, and Shen, 1981) 저수준 데이터형을 ADT로 표현하라. 스택이나 큐, 리스트와 같은 자료구조도 마찬가지다. 예를 들어, 스택으로 직원들의 집합을 다루고 싶다면 스택에 직원 데이터를 넣는 대신, "Employee"라는 ADT를 설계하고 내부적으로 스택을 사용해 구현하는 것이다.

두 번째, 파일과 같은 일반적인 객체를 ADT로 취급하라. 

운영체제는 디스크에 데이터를 읽고 쓰는 복잡한 계산을 파일 스트림에 대한 연산이라는 개념으로 추상화한 라이브러리를 제공한다. 이 조언은 ADT를 설계할 때, 보다 더 추상화된 개념을 사용하라는 뜻이다.

세 번째, 간단한 객체도 ADT로 취급하라.

고작 한 두개의 기본 데이터 타입으로 구성될 수 있는 엔티티조차 굳이 ADT로 설계하는 것은 지나친 낭비라고 생각할지도 모른다. 그러나 ADT로 설계하면 코드를 읽기 쉬우며, 혹시나 필요한 변경에 대해 유연하게 대처할 수 있다. (이 부분은 조금 고민해 볼 필요가 있지 않을까? -> 링크)

마지막으로, 저장된 매체와 독립적으로 ADT를 참조하라.

ADT의 데이터 쿼리 루틴에 저장 매체에 대한 정보를 숨기라는 조언이다. 예를 들어, 보험료 테이블 자료를 다루는 프로그램을 상상해보자. 테이블 용량이 매우 커서 메모리에 담을 수가 없다. 파일로 다루고 있다고 가정하자. 즉, 데이터를 읽고 싶다면 디스크에서 파일을 읽어야 한다. 그래서 RateTableFile.read(); 라고 ADT를 설계했다. 하지만 이 방식은 구현에 대한 정보가 노출되어 있는 나쁜 구조다. 저장소가 파일에 있다고 외부에 알려주기 때문이다. RateTable.read(); 정도로 설계하는 것이 더 나은 선택이다.

(※ 일반적으로 ADT는 클래스로 설계한다. 그러나 ADT와 클래스는 같은 개념이 아니다. 자세한 설명은 여기 > 링크)

 

2. 좋은 클래스 인터페이스

2-1. 좋은 추상화

객체지향에서 좋은 클래스 디자인의 핵심은 인터페이스다. (참고 자료)

클래스 추상화에 대한 평가는 외부로 공개된 인터페이스의 추상성을 기초로 평가한다. 충분히 추상화된 인터페이스는 내부 구현 정보를 숨기고 클래스의 역할을 직관적으로 드러낸다.

(※ 클래스 내부에서만 사용하는 private 루틴 또한 좋은 인터페이스를 갖추도록 설계해야 한다. 내부 루틴은 외부 객체가 알 수 없기 때문에 내부 루틴의 인터페이스는 간과하기 쉽다. 그러나 개발자가 코드를 읽고 이해하기 위한 조치로서 public이나 private에 상관없이 좋은 인터페이스를 설계하는 것이 품질 향상에 도움을 준다.)

아래는 나쁜 인터페이스 추상화 목록과 조언들이다. 아래의 조언들로 인터페이스의 추상화를 평가할 수 있다.

 

2-1-1. 내부 구현 정보가 지나치게 드러난다.

내부 구현 정보를 숨기지 못해 추상화가 실패한 사례는 어렵지 않게 찾아볼 수 있다. 예를 들어, 어떤 자료구조를 사용했는지 인터페이스 이름으로 드러내는 사례가 있다.

  • Program.initializeCommandStack();
  • Program.shutDownCommandStack();
  • Program.pushCommand(command);
  • Program.popCommand(command);

위 Program 클래스는 명령어 초기화 및 처리 로직을 스택 자료구조를 이용해 구현했다는 사실을 드러내고 있다. (애초에 Program 클래스가 과연 명령어 처리 로직을 담당해야 하는지도 의심스럽다.) 아래와 같이 디자인 하는 것이 좋은 인터페이스 설계다.

  • Program.initializeUserInterface();
  • Program.shutDownUserInterface();

Program 클래스는 최상위 모듈로서 UI 클래스를 초기화하는 것이 좋다. 명령어를 처리하는 로직은 UserInterface 클래스가 담당하는 것이 직관에도 맞고, 이해도 쉽다. 명령어 처리 로직이 스택으로 구현된다는 정보는 내부로 숨겼다. 그 과정에서 적절한 클래스에게 루틴을 재할당했다.

 

2-1-2. 추상화 레벨이 뒤죽박죽이다.

반대로 추상화의 레벨이 맞지 않아 표현력을 잃는 나쁜 디자인도 있다. 아래의 사례를 보자.

  • class Employee implements List..
  • Employee.addEmployee( employee );
  • Employee.removeEmployee( employee );
  • Employee.NextItemList();
  • Employee.FirstItem();
  • Employee.LastItem();

Employee 클래스가 List 자료구조를 상속하고 있다. 어쩔 수 없이 Next Item List 와 같은 List 자료구조의 인터페이스를 그대로 상속하고 있다. 위의 Employee 인터페이스는 추상화 수준이 같지 않다. "직원Employee" 데이터를 다루다가 갑자기 리스트의 "아이템Item"이라는 한 단계 높은 추상 개념을 다루고 있기 때문이다.

아래와 같이 리스트는 상속이 아니라 Composition(합성) 패턴을 사용해 내부로 숨기고, 인터페이스 이름에도 Item과 같은 지나친 추상화를 피해야 한다.

  • Employee.addEmployee( employee );
  • Employee.removeEmployee( employee );
  • Employee.nextEmployee();
  • Employee.firstEmployee();
  • Employee.lastEmployee();

※ 위 예시는 상속의 위험을 고려하지 않고 코드를 재사용하면서 생긴 문제다. 상속은 "is a" 관계에서 사용 가능하다. 상속은 부모 클래스의 인터페이스를 그대로 재사용하기 때문에 부모가 속한 시스템의 역할을 그대로 수행해야 한다. 즉, 부모와 같은 추상성 레벨에 속해야 한다. 상속을 잘못 사용하면 추상화 수준이 뒤죽박죽이 되어 버린다.

 

2-1-3. 클래스가 구현하는 추상화된 역할이 무엇인지 모르는 경우

아래 사례를 읽어보자.

저자는 엑셀처럼 테이블로 데이터를 편집하는 프로그램을 작성하고 있었다. 원하는 기능은 기껏해야 15개 정도가 전부인, 아주 간단한 프로그램이었다.

그런데 만들다보니 테이블 셀의 색깔을 변경하는 기능을 누락해버렸다. 기능 추가를 고민하던 중, 예전에 개발했던 스프레드시트 클래스가 생각났다. 스프레드시트 클래스는 현재 만들고 있는 프로그램의 모든 기능을 포함하면서 색깔 변경 기능도 갖추고 있었다.

문제는 스프레드시트 클래스가 현재 작성하는 프로그램의 규모에 비해 너무 크다는 점이었다. 제공하는 인터페이스가 무려 150개나 되었다. 당연히 이 클래스는 현재 개발하는 프로그램에 알맞는 역할을 수행하지 않는다. 따라서 현재 프로그램에 알맞은 역할로 감싸는 래퍼 클래스(Wrapper Class)를 만들기로 결정했다. 내부적으로 스프레드시트 클래스를 재사용하고 있다는 사실을 숨기려고 했던 것이다.

저자는 동료 개발자에게 래퍼 클래스를 작성해달라고 부탁했다. 그러나 동료 개발자는 그 요구사항이 귀찮다고 생각했다. 기능을 그대로 사용하기만 하면 되는데 왜 굳이 래퍼 클래스를 만들어야 하는지 모르겠다는 이유였다.

하지만 16개 정도의 루틴 정도면 충분한 프로그램에서 150개나 되는 루틴을 제공하는 객체를 사용했기 때문에, 이제 이 간단한 프로그램은 150개나 되는 변경 사항에 대해 일일이 대비해야 하는 심각한 문제를 떠앉는다. 그 개발자는 왜 최소 인터페이스가 필요한지 전혀 이해하질 못했던 것이다.

게다가 역할이 전혀 다른데 단지 같은 기능을 일부 포함하고 있다는 이유만으로 객체를 다른 시스템에 이식해버렸다. 서로 다른 시스템에서 사용하던 객체는 그 타입과 이름 및 역할이 현재 사용하는 프로그램과 전혀 일치하지 않는다. 결국 이는 소스코드를 읽는 개발자를 더욱 힘들게 할 뿐이다.

 

2-1-4. 의미론적인 부분은 프로그래밍으로 강제하라.

의미론적인 부분이란 예를 들어, 루틴A는 루틴B가 실행되기 전에 먼저 실행되어야 한다는 식이다. 이것을 RoutineFirst, RoutineNext 라는 인터페이스 이름을 붙인다고 해결이 될까? 혹은 주석을 작성한다고 해결이 되는 문제일까?

이런 의미론적 부분은 프로그래밍화하는 것이 좋다. 만약 루틴B가 루틴A의 실행 전에 호출되는 것이 싫다면 인터페이스나 주석으로 작성하는 것이 아니라 프로그래밍 내부로 구현을 해야 한다. 에러 처리 코드를 작성하거나 내부 알고리즘으로 옮겨야 하는 것이다.

 

2-1-5. 코드 변경 시 추상화 레벨을 해치면 안 된다.

코드를 추가하거나 변경하는 과정에서 의외로 빈번하게 발생하는 문제가 특정 클래스가 점점 응집성이 낮은 클래스가 된다는 점이다. (※ 객체의 응집성이란 서로 관련있는 데이터와 메소드가 한 객체에 모인 정도를 의미한다.)

이런 문제가 발생하는 이유는 코드 변경이 점진적으로 이루어지는 과정에서 이전까지 추상화가 괜찮았다가 시간이 흘러 애매모호해지는 상황이 발생하기 때문이다.

예를 들어, 직원 정보에 대한 SQL 쿼리문을 얻는 루틴을 넣어야 하는 상황에서 이 기능을 어떻게 어디서 구현할지 애매하다고 하자. 직원 정보를 가져오므로 왠지 Employee 클래스가 맞다고 생각하고는 DB 조회 루틴을 Employee에 작성할 수도 있다. 그러나 SQL과 같은 데이터베이스는 설계에 있어 세부 사항에 속한다. 너무 구체적이다. Employee 클래스의 추상성을 해친다.

어떠한 방식의 코드 변경이라도, 클래스의 일관성을 해친다면 최대한 다른 방법을 써야 한다. 귀찮다고 그럴듯한 객체에 이것저것 코드를 추가하다보면 내부 복잡도를 이기지 못하고 클래스의 추상성은 자멸한다. 응집도도 낮아진다. 클래스가 무슨 역할을 수행하는지 애매해진다.

 

2-2. 좋은 캡슐화

캡슐화는 추상화보다 더 강력한 기법이다. 왜냐하면 추상화가 시스템에서 객체가 차지하는 역할에 대한 개요를 표현해준다면, 캡슐화는 그러한 개요와 관련되지 않은 다른 모든 세부 사항을 외부에서 절대 알지 못하게 정보를 숨기는 섬세한 전략이기 때문이다.

캡슐화는 데이터와 프로세스를 묶고 맴버를 private 제어한다고 쉽게 달성되는 그런 단순한 개념이 아니다. 아래의 조언들은 캡슐화가 어떻게 깨질 수 있는지 알려준다.

캡슐화는 한 번 깨지면 추상화도 깨지게 되므로 아주 주의 깊게 다뤄야 한다. 저자는 자신의 경험 상, 시스템은 추상화와 캡슐화를 둘 다 가지고 있던가, 아니면 둘 다 없던가, 둘 중 하나라고 했다. 추상화는 지켰는데 캡슐화가 깨졌다거나 하는 식은 없다. 즉, 중간은 없다.

 

2-2-1. 클래스와 멤버의 접근을 최소화하라.

접근 최소화는 어떤 루틴이나 멤버를 public으로 공개하고, private으로 막아야 하는지 고민하게 해준다. 사실 public인지 private인지 고민하고 있다면, 되도록 private으로 접근을 막는 선택이 현명하다. (Meyers 1998, Bloch 2001)

특히 멤버의 경우는 되도록 접근을 막아야 한다. 멤버 데이터 노출은 외부에서 해당 데이터를 수정할 수 있으므로 값의 변경을 제어할 수 없으며, 구현 세부 사항이 외부로 노출되는 것뿐만 아니라 구현의 단서를 제공해준다는 측면에서도 좋지 않다.

만약 데이터 쿼리를 꼭 사용해야 한다면 아래와 같이 사용하라.

  • void Point.getX();
  • void Point.getY();
  • float Point.setX( float x );
  • float Point.setY( float y );

위와 같은 방식은 내부적으로 Point 객체가 float 데이터형으로 x와 y를 취급하는지 아닌지 알 수 없게 만든다. 또한 데이터를 설정하는 과정에서 x와 y 값 사이에 서로 어떤 영향을 주는지 가려져 있다. 예를 들어, setX 에서 x 값을 메모리에 저장할지, 데이터베이스에 저장할지 외부에서 알 수가 없다.

그러나 이러한 방식을 관습적으로 사용할 경우, 사실상 public으로 멤버 변수를 드러내는 것과 다를 바 없다. 멤버 변수의 접근을 막으라는 조언은 단순히 private으로 접근을 제어하라는 뜻이 아니라, getter나 setter와 같은 메소드 사용도 주의하라는 뜻이다. 반드시 데이터 쿼리 메소드가 필요한 경우가 아니라면, 처음부터 getter와 setter를 사용해서는 안 된다.

(※ 확실히 이 조언은 생각해 볼 여지가 있다. 일반적인 경우에 getter와 setter를 피하는 것이 올바른 원칙이라고 생각한다. 하지만 소프트웨어 시스템에서 객체가 구체적인 역할을 수행하지 않는 경우가 간혹 있다. 예를 들면 DTO처럼 값 묶음용 객체가 그렇다. 내 생각에는 DTO 같이 구조체에 근접한 객체는 캡슐화의 대상이 아니라고 본다.)

 

2-2-2. 내부 구현 사항을 클래스의 인터페이스에 입력하지 말라.

책에 따르면 C++과 같이 클래스의 선언부와 구현부가 분리된 문법을 제공하는 프로그래밍 언어의 경우, 헤더 파일에 구현에 대한 단서들이 작성되는 경우가 있다. 아래의 예제를 보자. 아래는 클래스의 선언만이 담긴 헤더 파일이다.

class Employee {
public:
    Employee(FullName name, TaxId taxIdNumber, JobClassification jobClass);
    FullName getName() const;
    TaxId getTaxID() const;
    //...

private:
    String m_name;
    int m_jobClass;
    //...
};

왜 저자는 이러한 방식이 문제가 된다고 말하는 것일까? 이유는 선언부와 구현부를 분리했음에도 불구하고, 구현이 어떻게 되어 있는지 단서가 노출되고 있기 때문이다.

헤더 파일을 보고 있는 개발자는 아마 JobClassification(직무 번호)가 정수형(int m_jobClass)으로 저장되고 있다는 사실을 간파할 수 있다. FullName(이름) 또한 문자형(String)을 사용해 이름을 저장하고 있다고 추측할 수 있다. 

스콧 마이어스에 따르면 위와 같은 문제를 방지하기 위해 private 제어 안으로 구조체와 같이 구현 상세 사항을 따로 작성한 뒤, 클래스 내부에는 해당 구현부의 포인터를 대신 넣도록 권장하고 있다.

class Employee {
public:
    Employee(...);
    //...

private:
    EmployeeImplementation* m_implementation;
};

이 조언은 C++과 관련된 조언이지만, private으로 접근을 제한한다고 해서 반드시 캡슐화가 달성되지 않는다는 점을 보여준다. 접근을 제어하는 것으로 캡슐화를 달성할 수 있는 것이 아니다. 개발자가 마음만 먹으면 블랙박스를 열어서 언제든지 코드를 읽을 수도 있다. 만약 선언부에서 구현부에 대한 단서가 드러나게 되면 해당 클래스를 사용하는 개발자가 무의식적으로 그러한 정보에 기반한 호출 구조를 짤 수도 있는 위험이 있다.

(※ 사실 개발자가 객체의 인터페이스를 보고 그 상세 사항이 궁금해 블랙 박스를 열어 보는 것은 좋지 않은 습관이다. 특히 단순 호기심이 아니라 인터페이스가 정확히 어떤 역할을 하는지 알 수가 없어서 코딩을 지속할 수 없는 경우는 더욱 그렇다. 이때는 해당 객체의 개발자에게 문의하여 해결해야 한다. 해당 개발자가 인터페이스를 알아보기 쉽게 재설계하거나, 혹은 문서를 정확하게 작성하도록 요구한다.)

 

2-2-3. 클래스의 사용자를 가정하지 마라.

위와 같이 구현에 대한 단서가 외부로 노출될 때, 그 클래스를 사용하는 클라이언트가 해당 정보를 바탕으로 추가 조치를 취할 수 있는데, 이것은 캡슐화를 위반한 것이다.

예를 들어, Class A의 x와 y가 0.0이 되면 안 된다는 사실이 노출되었다. A를 사용하는 Class B는 x와 y의 값을 미리 1.0으로 초기화하고 Class A를 호출했다. 만약 Class A의 문서에 x와 y의 값이 0이 되면 안 되니까 다른 값으로 미리 초기화하라고 설명하는 문서나 주석이 있고, 클라이언트가 문서를 보며 그것을 지켜야 한다면 캡슐화를 위반한 것이다. 

assertion을 사용해 사전조건에 대해 코드로 명시하는 것도 좋지만 사실 assert 또한 남발하는 것은 좋지 않다. 클래스는 호출될 때 되도록이면 자신이 어떻게 사용될지 가정하면 안 된다. 즉, 호출하는 클래스가 알아서 잘 하겠거니 짐작하면서 자신의 책임 일부를 떠넘기면 안 된다는 것이다. 사용자를 지나치게 가정하는 경우, 사용자에게 책임을 떠넘기는 과정에서 두 클래스는 너무나 밀접하게 연결된다. 의미론적으로 두 클래스가 서로 단단히 결합된 경우에는 결합을 느슨하게 만들기 곤란해진다.

 

2-2-4. 의미론적 위반을 주의하라.

캡슐화의 의미론적 위반이란 컴파일로 포착할 수 없는 위반을 말한다. 즉, 개발자에 의해 알게 모르게 캡슐화가 깨지는 경우다. 캡슐화는 추상화된 인터페이스 말고는 다른 모든 세부 구현 사항을 숨겨야 한다. 이때  private 안에 다른 데이터나 메소드를 밀어 넣는다고 해서 캡슐화가 달성되리라는 것은 착각이다. 아래 예시를 보자.

  • 클래스 A의 method1()이 public 인터페이스인 method2()를 내부적으로 호출하고 있다는 것을 개발자가 알게 되었다. 그 개발자는 method2()가 어차피 호출되니까 method1()만 호출하기로 결정했다.
  • Employee.retrieve() 메소드가 호출될 때, 만약 데이터베이스와 연결되지 않은 경우 자동으로 재연결을 시도한다는 것을 알게 되었다. 그래서 그 개발자는 Database.connect() 메소드를 호출하지 않았다.
  • Obect A 가 생성한 Object B가 static 공간에 생성되었다는 사실을 알게 되었다. 그래서 Object A의 공간에서 벗어난 뒤에도 다른 객체에서 Object B에 접근하는 코드를 작성했다.

 

도대체 왜 위와 같은 일들이 발생할까? 호출되는 클래스의 코드에 문제가 있어서가 아니다. 문제는 클라이언트가 해당 객체의 private으로 제어된 부분을 이미 읽고 그에 맞춰서 코드를 짜기 때문에 발생한다. 클라이언트는 객체에게 자신이 하지 못하는 부분을 메시지로 요청하는 것이 아니라, 그저 메소드를 분리한 뒤 호출하기 위한 용도로서 다른 객체를 취급하고 있는 오류를 저지르고 있는 것이다. (객체지향 언어를 사용하면서도 정작 절차적 프로그래밍을 하고 있다는 증거다)

인터페이스 문서로 클래스의 용도를 해석하기 힘든 경우, 절대 구현 코드 내부를 뜯어서 이해하려고 하면 안 된다. 그 클래스 개발자에게 연락해서 "이 클래스의 사용법을 모르겠습니다" 하고 물어야 한다. 그리고 클래스 개발자는 대화로 내부 구현과 관련해 사용법을 알려주는 것이 아니라 다시 코드와 문서를 수정해서 나중에 어떤 사람이 와도 이해될 수 있도록 고쳐야 한다. 

느슨한 결합이 객체지향에서 자주 강조된다. 느슨한 결합은 단순히 문법적으로 해결될 수 있는 것이 아니다. 위와 같은 사례에서 알 수 있듯이, 결합이 느슨해지기 위해서는 문법적 차원뿐만 아니라 의미론적으로도 캡슐화를 지켜야 한다. 혹시나 내부 정보가 누수되고 있지는 않은지 세심하게 살펴야 한다.

 

3. 설계와 구현 문제

3-1. 포함 (has a) 관계

언제나 상속보다 포함 관계를 먼저 고려하는 것이 좋다. 포함(composition)이 클래스 사이의 연결(link)에서 가장 자주 사용되는 방식이다. 포함 관계를 상속을 통해 구현하는 것이 불가능하진 않지만 절대 권장되지 않는  방법이다. has a 관계에서도 상속을 사용하려는 이유는 protected 멤버에 접근하기 위해서인데, 이는 캡슐화를 위반한다.

그리고 포함과 관련된 설계의 경우, 지나치게 많은 포함 관계를 가진 경우가 있다. 만약 멤버 데이터나 클래스가 7개를 넘어간다면 클래스를 분리하는 것을 진지하게 고민해야 한다.

 

3-2. 상속 (is a) 관계

3-2-1. is a 관계에서만 상속하라!

새로운 파생 클래스는 기본 클래스와 is a 관계여야 한다. 만약 파생 클래스가 기본 클래스의 인터페이스를 따르지 않거나, 혹은 따르더라도 추상화의 레벨이 다른 경우 is a 관계가 아닌 것이다. 상속하면 안 된다.

 

3-2-2. 상속됨을 고려해 코드를 짜라. 

클래스를 코딩할 때 상속이 될 수도 있는 부분을 명심하라. 즉, 상속이 될 필요가 없다고 판단되는 경우 문서로 상속을 제한하거나, 프로그래밍 언어의 문법을 사용해 상속을 막아야 하는 주의를 기울어야 한다. C++의 경우 non-virtual로, 자바의 경우 final 문법 키워드로 상속을 막아라.

 

3-2-3. 리스코프 치환 원칙

기본 클래스의 서브 클래스가 무엇인가에 따라 호출하는 코드가 달라지면 LSP를 위반한 것이다.

(※ 이전에 요약한 <클린 아키텍처>에서 LSP의 위반 사례들이 나열되어 있다. -> 링크)

또한 객체의 의미론적인 차이점에 대해 클라이언트가 고민하고 있다면 LSP가 깨지고 있다는 증거로 봐도 된다. 상속하는 객체는 기본 객체와 동일해야 한다. 같은 행동을 해야 한다. 만약 어떤 행동을 하지 않거나, 한다고 하더라도 의미적으로 다른 경우 LSP는 깨진다.

 

3-2-4. 상속을 받아야 할 때를 구분하라.

개발자가 상속을 해야 하나 고민하게 만드는 경우는 아래 세 가지다.

  • 인터페이스만 있고 구현부는 없는 경우.
  • 인터페이스가 구현되어 있고, 그 구현을 오버라이드할 수 있는 경우.
  • 인터페이스가 구현되어 있고, 그 구현을 오버라이드할 수 없는 경우.

첫 번째의 경우, 기본 클래스가 추상 클래스나 인터페이스(interface)를 뜻한다. 이 경우 처음부터 상속을 통해 구현을 강제하는 것이다. 상속을 해야 한다.

두 번째의 경우는 주의한다. 왜냐하면 이 클래스를 상속하려 할 때, 인터페이스를 상속하려고 하는지, 아니면 구현을 상속하고 싶은지 헷갈리기 때문이다. 만약 인터페이스가 아니라 구현이 필요하다면 상속이 아니라 포함이 맞다. 반대로 인터페이스가 필요하다면 정말로 해당 객체와 동일한 역할을 수행하는 객체를 작성하고 있는지, 그리고 오버라이딩을 할 때 리스코프 치환 원칙을 저해하고 있지는 않은지 검토한다.

세 번째의 경우 오버라이드가 불가능하기 때문에 상속이 아니라 인터페이스를 사용하는 방식이 맞다.

사실 위 사항들이 한 클래스에 복잡하게 얽혀 있는 경우가 많기 때문에 상속과 포함 사이에서 고민을 하게 된다. 인터페이스 상속이 필요한데 내부 구현이 함께 딸려 올 수도 있고, 구현이 필요한데 상속을 하는 경우 인터페이스가 외부로 노출될 수도 있다.

 

3-2-5. 파생 클래스가 하나뿐인 경우 의심하라.

기본 클래스를 상속하는 파생 클래스가 겨우 하나라면, 그리고 그것이 인터페이스나 추상클래스를 구현하는 것도 아니라면, 어쩌면 이것은 지나치게 미래를 고려한 설계일 수도 있다. 굳이 필요하지도 않은데 클래스를 추상화하려고 애쓰는 것은 올바르지 못하다. 나중에 요구사항이 변경되어 필요할 때가 되어서야 추상화해도 괜찮다.

미리 앞서서 클래스의 계층을 설계할 필요는 없다. Student 객체만 필요한데도 의미적으로 Person 객체를 추상화하는 것은 너무 앞서나가는 설계다. 심지어 이 Person 객체에 그 어떤 클라이언트도 의존하지 않는다면 더더욱 이상한 상황이다. 지나친 설계를 의심해야 한다.

 

3-2-6. 루틴을 오버라이드해서 아무것도 하지 않는 클래스들을 의심하라.

Bird 클래스에 fly() 인터페이스가 있고, 그것을 상속한 Ostrich(타조) 파생 클래스가 있다. 타조는 날지 못한다. 그런데 Bird의 한 종류다. Bird를 상속하는 대신 fly() 인터페이스의 구현부를 없애기 위해 내부를 비워놓기만 하는 경우가 간혹 있다. 그러나 이는 잘못된 설계다.

협력이라는 문맥을 고려하지 않고 그저 직관적인 의미에서만 추상화를 시도하는 것은 위험하다. 근본적으로 LSP를 위반한다. 어떤 클라이언트가 Bird 클래스의 fly()를 요청하고 있다는 협력 구조를 알고 있다면, 그리고 타조는 날지 않는다는 것을 알고 있다면, 타조가 새라는 말이 그럴듯하게 들려도 Ostrich를 Bird의 파생 클래스로 만들면 안 된다는 감각을 갖춰야 한다.

Bird가 필요한 클라이언트와 Ostrich가 필요한 클라이언트는 완전히 다른 역할에 의존하고 있는 것이다. 굳이 Ostrich를 Bird와 연계하고 싶다면, Ostrich는 Bird를 상속하는 것이 아니라 has a 관계로 포함하여 Bird의 코드를 재사용하는 것이 낫다.

그러나 가장 기본적인 전략은 Ostrich를 필요로 하는 클라이언트가 오직 자신이 필요로 하는 역할을 수행하는 인터페이스나 추상클래스에 의존하고, Ostrich는 Bird가 아니라 바로 이 인터페이스를 상속하는 것이다.

 

3-2-7. 깊은 상속 구조를 피하라.

상속은 저자의 경험 상, 두 세 번만 상속해도 머리에서 처리하는 범위를 넘어서게 된다. 우리가 상속을 사용하는 이유는 설계 상의 복잡도를 줄이기 위함이다. 그런데 상속을 통해 오히려 복잡성을 창발시켜버리면 배보다 배꼽이 큰 상황이다. 상속의 본질을 잘못 알고 사용하는 것이다. 상속은 한 두 단계 정도면 충분하다. 

 

3-2-8. 타입 검사보다 다형성을 선택하라.

부모 클래스나 인터페이스 타입으로 데이터를 받고 그것이 어떤 서브 클래스의 일종인지 확인하려는 코드 구조는 잘못된 방식이다. 다형성의 매력을 충분히 활용하지 못하는 경우다. 

void drawShape( Shape shape ) {
    switch ( shape.type ) {
        case SHAPE_CIRCLE:
            shape.drawCircle();
            break;
        case SHAPE_RECT:
            shape.drawRect();
            break;
        //...
    }
}

위 코드는 다형성으로 작업하면 너무나 쉽고 가독성이 좋은 코드로 나타낼 수 있다.

void drawShape( Shape shape ) {
    shape.draw();
}

 

switch나 if 문을 사용해야 하는 경우는 호출하는 객체가 정말로 서로 다른 타입인 경우다. 아래의 코드를 보자.

switch ( ui.command() ) {
    case COMMAND_OPEN_FILE:
        file.open();
        break;
    case COMMAND_PRINT:
        console.print();
        break;
    case COMMAND_SHUTDOWN:
        program.shutdown();
        break;
        
    //...
};

위와 같이 서로 다른 타입의 객체 메소드를 호출하는 경우 switch나 if 문이 적절하다.

물론 위 예제는 인터페이스를 만들 수도 있다. program.doCommand(); 와 같은 추상 인터페이스를 만들어 file, console 같은 객체가 상속하도록 만드는 것이다. 나쁜 접근은 아니지만 위 예제의 경우, doCommand와 같은 인터페이스는 지나치게 추상적으로 작성되어 정확히 무엇을 하는지 해석하기 어렵다. 차라리 switch가 훨씬 더 나은 선택이다.