Chapter 8. 코드를 모듈화하라

w-beom·2023년 4월 23일
1
post-thumbnail

Chapter 8. 코드를 모듈화하라

■ 모듈화된 코드의 이점
■ 이상적인 코드 모듈화가 되지 않는 일반적인 방식
■ 코드를 좀 더 모듈화하기 위한 방법

모듈화의 주된 목적 중 하나는 코드가 향후에 어떻게 변경되거나 재구성될지 정확히 알지 못한 상태
에서 변경과 재구성이 용이한 코드를 작성하는 것이다. 이를 달성하기 위한 핵심 목표는 각각의 기능 (또는 요구 사항)이 코드베이스의 서로 다른 부분에서 구현되어야 한다는 것이다.

8.1 의존성 주입의 사용을 고려하라.

8.1.1 하드 코드화된 의존성은 문제가 될 수 있다.


class RoutePlanner {
  private final RoadMap roadMap;
  
  RoutePlanner() {
  	// roadMap은 interface
    // NorthAmericaRoadMap 객체를 직접 생성하고 있다.
    this.roadMap = new NorthAmericaRoadMap(); 
  }
  Route planRoute(LatLong startPoint, LatLong endPoint) {
    ...
  }
}


interface RoadMap {
  List <Road> getRoads();
  List <Junction> getJunctions();
}

class NorthAmericaRoadMap implements RoadMap {
  @Override 
  List <Road> getRoads() {
    ...
  }
  @Override 
  List <Junction> getJunctions() {
    ...
  }
}

예제 코드는 자동차 여행 플래너를 구현하는 클래스를 보여준다. RoutePlanner 클래스는 RoadMap 인스턴스에 대한 의존성을 갖는다. RoadMap은 여러 개의 다른 구현체를 갖는 인터페이스다. 그러나 이 예제에서 RoutePlanner 클래스는 생성자에서 NorthAmericaRoadMap을 생성하는데, 이는 RoadMap의 특정 구현체에 대한 의존성이 하드 코드로 되어 있음을 의미한다. 따라서 RoutePlanner 클래스는 북미 여행 계획에만 사용될 수 있고 그 외의 다른 지역을 여행하는 데는 전혀 쓸모가 없다.

위와 같이 RoadMap의 구현체에 의존해서 코드를 구현하면 다른 구현으로 코드를 재설정할 수 없다.

8.1.2 해결책 : 의존성 주입을 사용하라.

class RoutePlanner {
  private final RoadMap roadMap;
  
  RoutePlanner(RoadMap roadMap) {
    this.roadMap = roadMap;
  }
  
  Route planRoute(LatLong startPoint, LatLong endPoint) {
    ...
  }
}

이제 원하는 로드맵을 사용하여 RoutePlanner의 인스턴스를 생성할 수 있다.

RoutePlanner europeRoutePlanner = new RoutePlanner(new EuropeRoadMap());

RoutePlanner northAmericaRoutePlanner = new RoutePlanner(new NorthAmericaRoadMap(true, false));

8.1.3 의존성 주입을 염두에 두고 코드를 설계하라.

코드를 작성하다 보면 나중에 의존성 주입을 사용하고 싶어도 사용이 거의 불가능한 코드가 짜여질 수 있기 때문에 이후에 의존성 주입을 사용할 가능성이 있다면 이런 방식으로 코드를 작성하는 것은 피해야 한다.

static 함수에 의존하는 예제

class RoutePlanner {
  Route planRoute(LatLong startPoint, LatLong endPoint) {
    List <Road> roads = NorthAmericaRoadMap.getRoads();
    List <Junction> junctions = NorthAmericaRoadMap.getJunctions();
  }
}

class NorthAmericaRoadMap {
  static List <Road> getRoads() {}
  static List <Junction> getJunctions() {}
}

RoutePlanner 클래스 안에서 NorthAmericaRoadMap 객체의 static 함수를 사용하고 있어 RoutePlanner 클래스를 다른 방향의 코드로 전환하기가 어렵다.

정적매달림
정적 함수(또는 변수)에 과도하게 의존하는 것을 정적 매달림(static cling)이라고 한다. 이에 대한 잠재적 문제는 잘 알려져 있고 문서화도 잘 되어 있다. 단위 테스트 코드에서 특히 문제가 될 수 있는데, 그 이유는 정적 매달림이 많은 코드에 대해서는 테스트 더블(test doubles)을 사용할 수 없기 때문이다.

8.2 인터페이스에 의존하라.

어떤 클래스에 의존하고 있는데 그 클래스가 어떤 인터페이스를 구현하고 필요한 기능이 그 인터페이스에 모두 정의되어 있으면, 클래스에 직접 의존하기보다는 인터페이스에 의존하는 것이 일반적으로 더 바람직하다.

8.2.1 구체적인 구현에 의존하면 적응성이 제한된다.

interface RoadMap {
  List <Road> getRoads();
  List <Junction> getJunctions();
}

class NorthAmericaRoadMap implements RoadMap {
  ...
}

class RoutePlanner {
  private final NorthAmericaRoadMap roadMap;
  
  RoutePlanner(NorthAmericaRoadMap roadMap) {
    this.roadMap = roadMap;
  }
  Route planRoute(LatLong startPoint, LatLong endPoint) {
    ...
  }
}

NorthAmericaRoadMapinterfaceRoadMap의 구현체이다. RoutePlanner 클래스에서 생성자로 의존성 주입을 받고있지만 interface에 의존하는것이 아닌 RoadMap의 특정 구현체인 NorthAmericaRadMap에 의존하고 있어 유연하지 못하다.

8.2.2 해결책 : 가능한 경우 인터페이스에 의존하라.

구체적인 구현 클래스에 의존하면 인터페이스를 의존할 때보다 적응성이 제한되는 경우가 많다.

class RoutePlanner {
  private final RoadMap roadMap;
  
  RoutePlanner(RoadMap roadMap) {
    this.roadMap = roadMap;
  }
  Route planRoute(LatLong startPoint, LatLong endPoint) {
    ...
  }
}

interface에 의존하게 됨으로써 개발자는 원하는 로드맵은 무엇이든 사용해서 RoutePlanner의 인스턴스를 생성할 수 있다.

의존성 역전 원리
보다 구체적인 구현보다는 추상화에 의존하는 것이 낫다는 생각은 의존성 역전 원리(dependency inversion principle)의 핵심이다.

8.3 클래스 상속을 주의하라.

8.3.1 클래스 상속은 문제가 될 수 있다.

쉼표로 구분된 정수를 가지고 있는 파일을 열어 정수를 하나씩 읽어 들이는 클래스를 작성해야 한다고 가정해보자. 이 문제에 대해 생각해보면 다음과 같은 하위 문제를 파악할 수 있다.

  • 파일에서 데이터를 읽는다.
  • 쉼표로 구분된 파일 내용을 개별 문자열로 나눈다.
  • 각 문자열을 정수로 변환한다.

CSV 파일을 읽는 클래스

interface FileValueReader {
  String getNextValue();
  void close();
}
interface FileValueWriter {
  void writeValue(String value);
  void close();
} 

/*** 쉼표로 구분된 값을 가지고 있는 파일을 읽거나 쓰기 위한 * 유틸리티 */
class CsvFileHandler implements FileValueReader, FileValueWriter {
  CsvFileReader(File file) {
    ...
  }
  @Override 
  public String getNextValue() {
    ...
  }
  @Override 
  public void writeValue(String value) {
    ...
  }
  @Override 
  public void close() {
    ...
  }
}

CsvFileHandler 클래스는 FileValueReaderFileValueWriter의 두 가지 인터페이스를 구현한다.

클래스 상속예제

/*** 파일로부터 숫자를 하나씩 읽어 들이는 유틸리티 * 파일은 쉼표로 구분된 값을 가지고 있어야 한다. */
class IntFileReader extends CsvFileHandler {
  public IntFileReader(File file) {
    super(file);
  }
  
  public Integer getNextInt() {
    String nextValue = getNextValue();
    if (nextValue == null) {
      return null;
    }
    return Integer.parseInt(nextValue, Radix.BASE_10);
  }
}

CsvFileHandler 클래스를 사용하여 상위 수준의 문제를 해결하려면 이 클래스를 우리가 작성할 코드에 통합해야 한다.

  • IntFileReader 클래스는 CsvFileHandler 클래스를 확장한다. 즉, IntFileReaderCsvFileHandler의 서브클래스 혹은 CsvFileHandler 클래스는 IntFileReader의 슈퍼클래스라는 의미다.
  • IntFileReader 생성자는 CsvFileHandler의 생성자를 호출하여 CsvFileHandler 인스턴스를 만들어야 한다. 이 작업은 super()를 호출하여 수행한다.
  • IntFileReader 클래스는 슈퍼클래스인 CsvFileHandler의 함수를 마치 자신의 함수인 것처럼 액세스할 수 있으므로 IntFileReader 클래스 내에서 NextValue()를 호출하면 슈퍼클래스의 함수가 호출된다.

상속의 주요 특징 중 하나는 서브클래스가 슈퍼클래스에 의해 제공되는 모든 기능을 상속한다는 점인데, 따라서 IntFileReader 클래스의 인스턴스는 close() 함수와 같이 CsvFileHandler에 의해 제공된 함수 중 어느 것이라도 호출할 수 있다.

상속은 추상화 계층에 방해가 될 수 있다.

한 클래스가 다른 클래스를 확장하면 슈퍼클래스의 모든 기능을 상속한다. 이 기능은 close() 함수의 경우처럼 유용할 때가 있지만, 원하는 것보다 더 많은 기능을 노출할 수도 있다. 이로 인해 추상화 계층이 복잡해지고 구현 세부 정보가 드러날 수 있다.

클래스의 일부 기능을 외부로 개방하는 경우 적어도 그 기능을 사용하는 개발자가 있을 것이라고 예상할 수 있다. 몇 개월 혹은 몇 년 후 코드베이스 곳곳에서 getNextValue()writeValue() 함수가 호출될 수 있다. 이렇게 되면 IntFileReader 클래스를 변경하기가 매우 어렵다.

8.3.2 해결책 : 구성을 사용하라.

상속을 사용한 원래 동기는 IntFileReader 클래스를 구현하는 데 도움이 되고자 CsvFileHandler 클래스의 일부 기능을 재사용하는 것이었다.

CsvFileHandler의 기능을 재사용하는 다른 방법으로는 구성을 사용하는 것이다. 즉, 클래스를 확장하기보다는 해당 클래스의 인스턴스를 가지고 있음으로써 하나의 클래스를 다른 클래스로부터 구성 compose한다는 것을 의미한다.

  • FileValueReader 인터페이스는 구현하려는 기능을 정의하고 있기 때문에 CsvFileHandler 클래스를 직접 사용하는 대신 FileValueReader 인터페이스를 사용한다. 따라서 추상화 계층이 더 간결해지고 코드는 재설정하기가 쉬워진다.
  • IntFileReader 클래스는 CsvFileHandler 클래스를 확장하는 대신 FileValueReader의 인스턴스를 참조할 멤버 변수를 갖는다. 이런 의미에서 IntFileReader 클래스는 FileValueReader의 인스턴스로 이루어져 있다(이것이 구성으로 불리는 이유다).
  • FileValueReader의 인스턴스는 IntFileReader 클래스의 생성자를 통해 의존성 주입으로 제공된다.
  • IntFileReader 클래스는 더 이상 CsvFileHandler 클래스를 확장하지 않으므로 close() 메서드를 상속하지 않는다. 대신 IntFileReader 클래스의 사용자가 파일을 닫을 수 있도록 이 클래스에 close() 함수를 추가한다. 이 함수는 FileValueReader 인스턴스의 close() 함수를 호출한다. IntFileReader.close() 함수는 파일을 닫는 명령을 FileValueReader.close() 함수로 전달하기 때문에 이를 전달forwarding이라고 한다.

8.3.3 진정한 is-a 관계는 어떤가?

두 클래스가 진정한 is-a 관계를 맺고 있다면 상속이 타당할 수 있다고 언급했다. 그러나 두 클래스가 진정으로 is-a 관계일 때조차 상속하는 것이 좋은 접근법인지에 대해서는 명확하지 않을 수 있다. 안타깝게도 이에 대한 답은 없으며 주어진 상황과 작업 중인 코드에 따라 다르다. 하지만 진정한 is-a 관계가 있다 하더라도 상속은 여전히 문제가 될 수 있다는 점을 알아야 한다.

  • 취약한 베이스 클래스 문제 : 서브클래스가 슈퍼클래스에서 상속되고 해당 슈퍼클래스가 나중에 수정되면 서브클래스가 작동하지 않을 수도 있다. 따라서 코드를 변경할 때 그 변경이 문제없을지 판단하기가 어려운 경우가 있을 수 있다.

  • 문제가 있는 계층 구조 : 많은 언어가 다중 상속을 지원하지 않으므로 클래스는 오직 하나의 클래스만 직접 확장할 수 있다.

8.4 클래스는 자신의 기능에만 집중해야 한다.

모듈화의 핵심 목표 중 하나는 요구 사항이 변경되면 그 변경과 직접 관련된 코드만 수정한다는 것이다. 단일 개념이 단일 클래스 내에 완전히 포함된 경우라면 이 목표는 달성할 수 있다. 어떤 개념과 관련된 요구 사항이 변경되면 그 개념에 해당하는 단 하나의 클래스만 수정하면 된다.

8.4.1 다른 클래스와 지나치게 연관되어 있으면 문제가 될 수 있다.

Book 클래스에는 장의 단어 수를 세는 getChapterWordCount() 함수가 포함되어 있다. 이 함수는 Book 클래스에 속해 있지만 Chapter 클래스에만 관련된 사항을 다룬다. 이것은 Chapter 클래스에 대한 많은 세부 사항이 Book 클래스에 하드 코딩된다는 것을 의미한다.

getChapterWordCount() 함수를 Book 클래스에 두면 코드가 모듈화되지 않는다. 요구 사항이 변경되어 장의 끝에 요약을 포함해야 한다면, getChapterWordCount() 기능도 수정해서 요약에 있는 단어들도 셀 수 있도록 해야 한다.

8.4.2 해결책 : 자신의 기능에만 충실한 클래스를 만들라

코드 모듈화를 유지하고 한 가지 사항에 대한 변경 사항이 코드의 한 부분만 영향을 미치도록 하기 위해, BookChapter 클래스는 가능한 한 자신의 기능에만 충실하도록 해야 한다.

이제 Chapter 클래스에는 wordCount()라는 멤버 함수가 있으며 Book 클래스는 이 함수를 사용한다. Book 클래스는 Chapter 클래스의 세부 사항을 다룰 필요가 없고 자기 자신만 신경 쓰면 된다. 장 끝에 요약이 있어야 하는 것으로 요구 사항이 변경된 경우 Chapter 클래스만 수정하면 된다.

디미터의 법칙
디미터의 법칙은 한 객체가 다른 객체의 내용이나 구조에 대해 가능한 한 최대한으로 가정하지 않아야 한다는 소프트웨어 공학의 원칙이다. 이 원칙은 특히 한 객체는 직접 관련된 객체와만 상호작용해야 한다고 주장한다.

디미터의 법칙에 의하면 Book 클래스는 Chapter 클래스 인스턴스와만 상호작용해야 하고 그 외의 어떤 객체와도, 예를 들어 서두와 절을 나타내는 TextBlock과는 상호작용하지 않아야 한다.

원래 코드에 있는 Chapter.getPreude().wordCount()와 같은 라인은 명백하게 이 점을 위반하고 있기 때문에 이 경우 디미터의 법칙을 사용한다면 원본 코드의 문제를 발견할 수 있을 것이다.

profile
습득한 지식과 경험을 나누며 다른 사람들과 문제를 함께 해결해 나가는 과정에서 서로가 성장할 수 있는 기회를 만들고자 노력합니다.

1개의 댓글

comment-user-thumbnail
2023년 8월 23일

글 너무 잘 읽었습니다. 책이 궁금해지는 포스팅이네요!!

답글 달기