■ 모듈화된 코드의 이점
■ 이상적인 코드 모듈화가 되지 않는 일반적인 방식
■ 코드를 좀 더 모듈화하기 위한 방법
모듈화의 주된 목적 중 하나는 코드가 향후에 어떻게 변경되거나 재구성될지 정확히 알지 못한 상태
에서 변경과 재구성이 용이한 코드를 작성하는 것이다. 이를 달성하기 위한 핵심 목표는 각각의 기능 (또는 요구 사항)이 코드베이스의 서로 다른 부분에서 구현되어야 한다는 것이다.
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
의 구현체에 의존해서 코드를 구현하면 다른 구현으로 코드를 재설정할 수 없다.
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));
코드를 작성하다 보면 나중에 의존성 주입을 사용하고 싶어도 사용이 거의 불가능한 코드가 짜여질 수 있기 때문에 이후에 의존성 주입을 사용할 가능성이 있다면 이런 방식으로 코드를 작성하는 것은 피해야 한다.
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)
을 사용할 수 없기 때문이다.
어떤 클래스에 의존하고 있는데 그 클래스가 어떤 인터페이스를 구현하고 필요한 기능이 그 인터페이스에 모두 정의되어 있으면, 클래스에 직접 의존하기보다는 인터페이스에 의존하는 것이 일반적으로 더 바람직하다.
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) {
...
}
}
NorthAmericaRoadMap
은 interface
인 RoadMap
의 구현체이다. RoutePlanner
클래스에서 생성자로 의존성 주입을 받고있지만 interface
에 의존하는것이 아닌 RoadMap
의 특정 구현체인 NorthAmericaRadMap
에 의존하고 있어 유연하지 못하다.
구체적인 구현 클래스에 의존하면 인터페이스를 의존할 때보다 적응성이 제한되는 경우가 많다.
class RoutePlanner {
private final RoadMap roadMap;
RoutePlanner(RoadMap roadMap) {
this.roadMap = roadMap;
}
Route planRoute(LatLong startPoint, LatLong endPoint) {
...
}
}
interface
에 의존하게 됨으로써 개발자는 원하는 로드맵은 무엇이든 사용해서 RoutePlanner
의 인스턴스를 생성할 수 있다.
의존성 역전 원리
보다 구체적인 구현보다는 추상화에 의존하는 것이 낫다는 생각은 의존성 역전 원리(dependency inversion principle)
의 핵심이다.
쉼표로 구분된 정수를 가지고 있는 파일을 열어 정수를 하나씩 읽어 들이는 클래스를 작성해야 한다고 가정해보자. 이 문제에 대해 생각해보면 다음과 같은 하위 문제를 파악할 수 있다.
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
클래스는 FileValueReader
와 FileValueWriter
의 두 가지 인터페이스를 구현한다.
클래스 상속예제
/*** 파일로부터 숫자를 하나씩 읽어 들이는 유틸리티 * 파일은 쉼표로 구분된 값을 가지고 있어야 한다. */
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
클래스를 확장한다. 즉, IntFileReader
는 CsvFileHandler
의 서브클래스 혹은 CsvFileHandler
클래스는 IntFileReader
의 슈퍼클래스라는 의미다.IntFileReader
생성자는 CsvFileHandler
의 생성자를 호출하여 CsvFileHandler
인스턴스를 만들어야 한다. 이 작업은 super()를 호출하여 수행한다.IntFileReader
클래스는 슈퍼클래스인 CsvFileHandler
의 함수를 마치 자신의 함수인 것처럼 액세스할 수 있으므로 IntFileReader
클래스 내에서 NextValue()
를 호출하면 슈퍼클래스의 함수가 호출된다.상속의 주요 특징 중 하나는 서브클래스가 슈퍼클래스에 의해 제공되는 모든 기능을 상속한다는 점인데, 따라서 IntFileReader
클래스의 인스턴스는 close()
함수와 같이 CsvFileHandler
에 의해 제공된 함수 중 어느 것이라도 호출할 수 있다.
상속은 추상화 계층에 방해가 될 수 있다.
한 클래스가 다른 클래스를 확장하면 슈퍼클래스의 모든 기능을 상속한다. 이 기능은 close()
함수의 경우처럼 유용할 때가 있지만, 원하는 것보다 더 많은 기능을 노출할 수도 있다. 이로 인해 추상화 계층이 복잡해지고 구현 세부 정보가 드러날 수 있다.
클래스의 일부 기능을 외부로 개방하는 경우 적어도 그 기능을 사용하는 개발자가 있을 것이라고 예상할 수 있다. 몇 개월 혹은 몇 년 후 코드베이스 곳곳에서 getNextValue()
및 writeValue()
함수가 호출될 수 있다. 이렇게 되면 IntFileReader
클래스를 변경하기가 매우 어렵다.
상속을 사용한 원래 동기는 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
이라고 한다.두 클래스가 진정한 is-a
관계를 맺고 있다면 상속이 타당할 수 있다고 언급했다. 그러나 두 클래스가 진정으로 is-a
관계일 때조차 상속하는 것이 좋은 접근법인지에 대해서는 명확하지 않을 수 있다. 안타깝게도 이에 대한 답은 없으며 주어진 상황과 작업 중인 코드에 따라 다르다. 하지만 진정한 is-a
관계가 있다 하더라도 상속은 여전히 문제가 될 수 있다는 점을 알아야 한다.
취약한 베이스 클래스 문제 : 서브클래스가 슈퍼클래스에서 상속되고 해당 슈퍼클래스가 나중에 수정되면 서브클래스가 작동하지 않을 수도 있다. 따라서 코드를 변경할 때 그 변경이 문제없을지 판단하기가 어려운 경우가 있을 수 있다.
문제가 있는 계층 구조 : 많은 언어가 다중 상속을 지원하지 않으므로 클래스는 오직 하나의 클래스만 직접 확장할 수 있다.
모듈화의 핵심 목표 중 하나는 요구 사항이 변경되면 그 변경과 직접 관련된 코드만 수정한다는 것이다. 단일 개념이 단일 클래스 내에 완전히 포함된 경우라면 이 목표는 달성할 수 있다. 어떤 개념과 관련된 요구 사항이 변경되면 그 개념에 해당하는 단 하나의 클래스만 수정하면 된다.
Book
클래스에는 장의 단어 수를 세는 getChapterWordCount()
함수가 포함되어 있다. 이 함수는 Book
클래스에 속해 있지만 Chapter
클래스에만 관련된 사항을 다룬다. 이것은 Chapter
클래스에 대한 많은 세부 사항이 Book
클래스에 하드 코딩된다는 것을 의미한다.
getChapterWordCount()
함수를 Book
클래스에 두면 코드가 모듈화되지 않는다. 요구 사항이 변경되어 장의 끝에 요약을 포함해야 한다면, getChapterWordCount()
기능도 수정해서 요약에 있는 단어들도 셀 수 있도록 해야 한다.
코드 모듈화를 유지하고 한 가지 사항에 대한 변경 사항이 코드의 한 부분만 영향을 미치도록 하기 위해, Book
과 Chapter
클래스는 가능한 한 자신의 기능에만 충실하도록 해야 한다.
이제 Chapter
클래스에는 wordCount()
라는 멤버 함수가 있으며 Book
클래스는 이 함수를 사용한다. Book
클래스는 Chapter
클래스의 세부 사항을 다룰 필요가 없고 자기 자신만 신경 쓰면 된다. 장 끝에 요약이 있어야 하는 것으로 요구 사항이 변경된 경우 Chapter
클래스만 수정하면 된다.
디미터의 법칙
디미터의 법칙은 한 객체가 다른 객체의 내용이나 구조에 대해 가능한 한 최대한으로 가정하지 않아야 한다는 소프트웨어 공학의 원칙이다. 이 원칙은 특히 한 객체는 직접 관련된 객체와만 상호작용해야 한다고 주장한다.
디미터의 법칙에 의하면Book
클래스는Chapter
클래스 인스턴스와만 상호작용해야 하고 그 외의 어떤 객체와도, 예를 들어 서두와 절을 나타내는TextBlock
과는 상호작용하지 않아야 한다.
원래 코드에 있는Chapter.getPreude().wordCount()
와 같은 라인은 명백하게 이 점을 위반하고 있기 때문에 이 경우 디미터의 법칙을 사용한다면 원본 코드의 문제를 발견할 수 있을 것이다.
글 너무 잘 읽었습니다. 책이 궁금해지는 포스팅이네요!!