이번 장에서는 다양한 소프트웨어 개발 원칙을 다루며, 이와 함께 문서 관리 시스템을 구현합니다.
문서 관리 시스템을 구현하며, 특히 클래스 간의 상속 관계를 고려하며 인터페이스를 어떻게 구현하는지를 신경 쓰고 LSP(Liscov Substitution Principle)에 대해서 자세히 다룹니다.
이번 장은 뭔가 책에서 모든 코드를 자세히 설명하기보다는 개발을 이어 가며 해당 방식을 선택한 이유를 설명하는 데에 더 집중하는 것 같습니다.
그렇기에 책만 따라가서는 전체 코드를 파악하기 어려워 repository를 필수적으로 참고해야 할 것 같습니다. (갈수록 일부 코드는 언급도 안 되는 것 같습니다..)
https://github.com/Iteratr-Learning/Real-World-Software-Development
저는 다음 repository에 실습을 진행하면서 책을 따라가고 있습니다.
https://github.com/rkdehdgns1230/Real-World-Software-Development
점점 뭔가 책에서 다루고자 하는 내용이 얇고 넓어지고 있는 것 같으며, 예시나 설명도 잘 와닿지 않는 것 같지만 계속 공부를 이어 나가보도록 하겠습니다.
문서 관리 시스템의 요구 사항
- 기존 환자 정보 파일을 읽어 색인을 추가하고 검색할 수 있는 형태의 정보로 변환해야 한다.
- 리포트, 우편물, 이미지 3가지 형식의 문서를 다룬다.
- 문서에 포함된 특정 정보를 기준으로 문서 검색이 가능해야 한다.
설계 작업 (Importer interface, Document domain class구현)
3가지 형식의 문서를 import 하는 작업은 문서 관리 시스템의 핵심이며, 문서 임포트 기능을 하나의 메서드로 처리하는 경우 다음과 같이 작성할 수 있을 것이다.
switch(extension) {
case "letter":
// import code
break;
case "report":
// import code
break;
case "jpg":
// import code
break;
default:
throw new UnknownFileTypeException("For file: " + path);
}
이렇게 작성하는 경우 새로운 타입을 import하는 기능 확장이 이루어지는 경우 기존에 작성된 메서드 코드를 지속적으로 수정해야 하는 번거로움이 발생한다.
Main 클래스의 file import 부분을 깔끔하게 유지하기 위해 interface를 사용하는 방법을 제안하고 있고, 이는 Importer interface를 만들어 각각의 파일 타입에 알맞은 *Importer 구현체를 기능 구현마다 만들어주는 대안이다.
코드는 다음과 같다.
이 때 importFile method의 인자로 java.io.File 객체를 사용하는데, 이는 강한 형식 (strong typed) 원칙을 적용한 상황으로 간략히 설명하자면, 더 구체적인 타입(이를 더 강한 타입이라고 한다.)을 명시적으로 사용함으로써 오류 발생 범위를 명확하게 하고, 오류 파악을 더 쉽게 할 수 있는 원칙을 뜻한다.
다음으로 Document class를 설계하는데, 이는 3가지 형식의 파일을 다루기 위한 클래스로 도메인 객체를 만들기 위해 사용한다.
(구현하는 프로그램의 도메인과 연관된 클래스의 인스턴스를 도메인 객체라고 부른다고 한다.)
Map <String, String> 형식으로 파일 관련 속성을 관리할 수도 있지만, 별도로 Document class를 만들어 사용하는 이유를 정리하면 다음과 같다.
- 별도의 도메인 객체를 사용하는 것이 동료나 고객과 소통할 때에 있어서 훨씬 유리
- 소통에 사용하는 언어를 코드로 매핑하면, 변경해야 할 부분이 명확히 보이는데 이를 발견성(discoverability)라고 한다.
- Document 클래스를 불변 객체로 만들어 관리하는 것이 더 유리하다. (이후에 인스턴스의 변경 가능성 자체를 차단함으로써, setter 없는 코딩을 선호하는 것과 같은 맥락)
- HashMap을 상속해서 Document를 만드는 방식??
- 공통 기능이 이미 구현되어 있어 유리할 수 있다.
- 다만, 불필요한 기능 또한 같이 상속된다는 것은 좋지 못함
기존 코드 확장과 재사용
다시 돌아가서 각 타입에 알맞은 Importer 구현체를 만들어준다.
package com.study.chapter4;
import com.study.chapter4.domain.Document;
import com.study.chapter4.domain.TextFile;
import java.io.File;
import java.io.IOException;
import java.util.Map;
public class InvoiceImporter implements Importer{
private static final String NAME_PREFIX = "Dear ";
private static final String AMOUNT_PREFIX = "Amount: ";
@Override
public Document importFile(File file) throws IOException {
final TextFile textFile = new TextFile(file);
textFile.addLineSuffix(NAME_PREFIX, Attributes.PATIENT);
textFile.addLineSuffix(AMOUNT_PREFIX, Attributes.AMOUNT);
final Map<String, String> attributes = textFile.getAttributes();
attributes.put(Attributes.TYPE, "INVOICE");
return new Document(attributes);
}
}
package com.study.chapter4;
import com.study.chapter4.domain.Document;
import com.study.chapter4.domain.TextFile;
import java.io.File;
import java.io.IOException;
import java.util.Map;
public class LetterImporter implements Importer{
private static final String NAME_PREFIX = "Dear ";
@Override
public Document importFile(File file) throws IOException {
final TextFile textFile = new TextFile(file);
textFile.addLineSuffix(NAME_PREFIX, Attributes.PATIENT);
final int lineNumber = textFile.addLines(2, String::isEmpty, Attributes.ADDRESS);
textFile.addLines(lineNumber + 1, (line) -> line.startsWith("regards,"), Attributes.BODY);
final Map<String, String> attributes = textFile.getAttributes();
attributes.put(Attributes.TYPE, "LETTER");
return new Document(attributes);
}
}
위 코드에서 체크할 점은 다음과 같다.
- Attributes라는 별도의 클래스의 정적 멤버를 이용해 상수를 관리 및 사용하고 있다.
- 이는 컴파일 타임에서 오타로 인한 오작동을 막아줄 수 있다는 장점과
- 객체로 관리함으로써 수정에 용이하다는 장점을 취할 수 있다.
- TextFile이라는 class를 이용해 파일(청구서, 우편물 등..)에서 원하는 데이터를 추출하는 작업을 공통 처리하고 있다.
유틸리티 클래스 생성, 상속 사용, 도메인 클래스 사용
3가지의 방법을 각각의 파일 타입에서 원하는 suffix에 따른 값을 추출하는 기능을 구현하기 위한 방법으로 책에서는 고려하고 있었다.
package com.study.chapter4.domain;
import com.study.chapter4.Attributes;
import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.function.Predicate;
import static java.util.stream.Collectors.toList;
public class TextFile {
private final Map<String, String> attributes;
private final List<String> lines;
public TextFile(final File file) throws IOException {
attributes = new HashMap<>();
attributes.put(Attributes.PATH, file.getPath());
lines = Files.lines(file.toPath()).collect(toList());
}
public void addLineSuffix(final String prefix, final String attributeName){
for(final String line : lines){
if(line.startsWith(prefix)){
attributes.put(attributeName, line.substring(prefix.length()));
break;
}
}
}
public int addLines(
final int start,
final Predicate<String> isEnd,
final String attributeName
){
final StringBuilder accumulator = new StringBuilder();
int lineNumber;
for(lineNumber = start; lineNumber < lines.size(); lineNumber++) {
final String line = lines.get(lineNumber);
if (isEnd.test(line)) {
break;
}
accumulator.append(line);
accumulator.append("\n");
}
attributes.put(attributeName, accumulator.toString().trim());
return lineNumber;
}
public Map<String, String> getAttributes() {
return attributes;
}
public List<String> getLines() {
return lines;
}
}
그중에서 TextFile이라는 domain class를 구현하는 것을 이 책의 저자는 선택했고, 이유는 유틸리티 클래스를 사용하는 경우 시간이 점차 흐를 경우 갓 클래스의 탄생을 유발할 수 있다는 점
상속을 사용하는 경우 TextImporter라는 별도의 Importer를 text에서 데이터를 추출하는 Importer에 대해서 구현한 뒤 상속받아 사용하게 하는 방법을 제안했는데,
상속 관계를 통해 코드를 재사용하는 방법 자체가 적절하지 않는다는 이유로 domain class를 개발하는 방식을 선택했다고 한다.
리스코프 치환 원칙 (LSP)
책에서는 Importer 구현 이전에 리스코프 치환 원칙에 대해서 설명하고 있는데, 이에 대해서도 이쯤에서 한 번 정리해보려고 한다.
우선 몇 가지 관련 용어를 정리하면
- 형식(type): 클래스나 인터페이스에 대한 용어
- 하위형식(subtype): 두 개의 형식이 상속이나 구현 관계에 있을 때, 한 형식이 다른 형식의 하위형식이라고 한다.
다음으로 네 가지의 부분으로 LSP를 쪼갤 수 있다고 한다.
- 하위형식에서 선행조건을 더할 수 없음
- 선행조건은 어떤 코드가 동작하는 조건을 의미
- 예를 들어 Importer의 구현은 임포트 하려는 파일이 존재하며, 읽을 수 있을 것이라는 선행조건을 가진다.
- 이는 Importer interface에서 별도로 이에 대한 선행조건을 설정하지 않았기 때문인 것으로 보임
- 따라서 DocumentManangementSystem에서 이 조건에 대한 검증을 수행하는 메서드를 별도로 작성한 것으로 보인다. (구현체에서 선행조건을 더할 수 없기 때문)
- 하위형식에서 후행조건을 악화시킬 수 없음
- 후행조건은 어떤 코드를 실행하고 난 뒤에 만족해야 하는 조건을 의미
- 예를 들어 유효한 파일에 대해 importFile() (구현체의 method)를 실행하면, contents() method의 반환 목록에 그 파일이 반드시 포함되어 있어야 한다.
- 즉, 부모가 부작용을 포함하거나 어떤 값을 반환한다면, 자식도 이에 따라야 함을 의미
- 슈퍼형식의 불변자는 하위형식에서 보존됨
- 불변자는 항상 변하지 않는 것을 의미
- 상속 관계에서 부모 클래스에서 유지되는 불변자는 자식 클래스에서도 유지되어야 함을 의미
- 히스토리 규칙
- LSP에서 가장 이해하기 힘든 규칙 (사실 앞의 것들도 별로 잘 이해되진 않는 것 같다..)
- 책에서는 Document 라는 불변 객체를 다룰 때를 예로 들었는데, 잘 이해가 가지는 않는다.
- 간단히 이해한대로 정리해 보자면, 부모 클래스에서의 불변 객체 상태라면, 자식 클래스에서도 이에 대한 상태를 변화시킬 수 없음을 의미하는 것 같다.
마지막으로..
이후에는 이렇게 만든 코드를 제공하는 예시 파일을 이용해 테스트 코드를 작성하는 부분과 테스트 코드를 작성하는 데 있어서 중요한 몇 가지 요소를 정리하고 있는데, 이 부분을 간단히 정리하면 다음과 같다.
- 테스트 이름을 잘 짓자
- 구현이 아닌 동작을 테스트 하자. (public 접근 제한자로 공개된 method에 대한 테스트만)
- 중복 배제 (테스트 코드 상에서 중복된 동작은 method로 따로 빼서 구현)
- 좋은 진단 (명시적으로)
- 중복해서 사용하는 값들은 상수를 활용
'Book' 카테고리의 다른 글
[실전 자바 소프트웨어 개발] 5. 비즈니스 규칙 엔진 (0) | 2023.09.04 |
---|---|
[실전 자바 소프트웨어 개발] 3. 입출금 내역 분석기 확장판 (0) | 2023.08.29 |
[실전 자바 소프트웨어 개발] 2. 입출금 내역 분석기 (0) | 2023.08.28 |
[리뷰] "스프링부트와 AWS로 혼자 구현하는 웹 서비스"를 읽고나서 (0) | 2023.08.27 |
댓글