Chapter 3 입출금 내역 분석기 확장판 - 개방/폐쇄 원칙, 인터페이스,API

1. 프로젝트 목표

2장에서 만든 입출금 내역 분석기의 기능을 확장하게 된다. 현재 분석기의 기능이 상당히 제한적이기 때문에, 다양한 종류의 입출금 내역을 검색하고, 여러 포맷을 지원, 처리하며, HTML 등의 형식으로 리포트를 나타낼 수 있도록 기능을 추가한다. 이 장에서 추가할 내용은 다음과 같다.

  1.  특정 입출금 내역을 검색할 수 있는 기능. (ex. 주어진 날짜 범위, 특정 범주)
  2.  검색 결과의 요약 통계를 텍스트, HTML 등 다양한 형식으로 제작

 

2. 개방/폐쇄 원칙(Open-Closed Principle)

개방/폐쇄 원칙의 정의를 알아보기 전에 입출금 내역 분석기에 기능을 추가하는 상황을 예시로 들어 이해해보자. 먼저 입출금 내역을 검색하는 기능을 추가할 것이다. 기존 분석기에 없던 새 기능을 추가하는 것이므로 단일 책임 원칙 또는 높은 응집도를 위해 새로운 클래스를 만들 수 있을 것이다. 그러나 이 책은 그럴 경우 여러 객체가 생기면서 프로젝트가 복잡해지고 전체적인 동작을 이해하기 어렵다는 점을 들어 기존 클래스에 검색 기능을 넣기로 한다. 이전 구현에서 KISS원칙을 무시하는 대신 단일 책임 원칙을 지켰지만, 지금은 반대로 단일 책임 원칙을 일부 무시하고 KISS 원칙을 지킨 것이다. 어쨌든 검색 기능이 들어갈 클래스는 데이터 처리라는 공통점이 있는 BankStatementProcessor클래스이다. (3장에는 알 수 없는 이유로 이 클래스 명 대신 BankTransactionProcessor 라는 이름을 사용하지만 여기에선 기존의 이름을 계속 사용한다.) BankStatementProcessor 안에 특정 금액 이상의 입출금 내역을 검색하는 기능의 메소드를 추가하자면 다음과 같을 것이다.

public List<BankTransaction> findTransactionsGreaterThanEqual(int amount) {
	final List<BankTransaction> result  = new ArrayList<>();
    for (final BankTransaction bankTransaction: bankTransactions) {
    	if (bankTransaction.getAmount() >= amount) {
        	result.add(bankTransaction);
        }
    }
    return result;
}

 

특정 금액을 파라미터로 받아 for 문을 돌며 특정 금액 이상의 거래를 결과 List에 추가하는 메소드이다. 특정 월 단위로 거래를 검색하는 메소드 또한 비슷한 방식으로 동작할 것이다. 다음과 같이 말이다.

public List<BankTransaction> findTransactionsInMonth(Month month) {
	final List<BankTransaction> result  = new ArrayList<>();
    for (final BankTransaction bankTransaction: bankTransactions) {
    	if (bankTransaction.getDate().getMonth() == month) {
        	result.add(bankTransaction);
        }
    }
    return result;
}

 

그런데 이미 위의 두 코드는 조건만 다를 뿐 loop를 돌며 반복 수행하는 부분이 같다. 2장의 코드 중복 문제에서도 보았듯이 이는 좋지 않은 방식이다. 또한 검색 조건이 더 생겨날 수록 이 문제는 심화될 것이다. 이 책에서도 이 방식의 문제로 다음의 3가지를 뽑았다.

  • 거래 내역의 여러 속성을 조합할 수록 코드가 점점 복잡해진다.
  • 반복 로직과 비즈니스 로직이 결합되어 분리하기 어려워 진다.
  • 코드를 반복한다.

여기에 개방/폐쇄 원칙을 적용한다. 개방/폐쇄 원칙의 정의는 다음과 같다.

개방 폐쇄 원칙은 소프트웨어 개체가 확장에 대해서는 열려있어야 하고, 수정에 대해서는 닫혀있어야 한다는 원리이다.

수정에 대해 닫혀있으면서 확장에 대해 열려있다는 것은 프로그램의 코드를 수정하지 않고도 새로운 기능을 추가할 수 있다는 의미이다. 이 책은 이것이 인터페이스를 통해 가능하다고 설명한다. 다음의 인터페이스를 보자

@FunctionalInterface
public interface BankTransactionFilter {
	boolean test(BankTransaction bankTransaction);
}

BankTransactionFilter는 하나의 메소드만 가진 함수형 인터페이스(Functional Interface)이다. 유일한 메소드인 test()BankTransaction객체를 파라미터로 받아서 특정 조건에 따라 true나 false를 반환한다. 예를 들어 금액이 1000이 넘는 2월달의 거래 내역을 검색하려면 다음과 같이 클래스를 구현하면 될 것이다.

class BankTransactionsIsInFebruaryAndExpensive implements BankTransactionFilter {
	@Override
    public boolean test(final BankTransaction bankTransaction) {
    	return bankTransaction.getDate().getMonth() == Month.FEBRUARY
        		&& bankTransaction.getAmount() >= 1000);
        }
} 

기존의 loop에서 직접 특정 조건을 확인하던 모든 검색 메소드는 하나의 메소드로 바뀔 수 있다.

public List<BankTransaction> findTransactions(final BankTransactionFilter bankTransactionFilter) {
	final List<BankTransaction> result  = new ArrayList<>();
    for (final BankTransaction bankTransaction: bankTransactions) {
    	if (bankTransactionFilter.test(bankTransaction) {
        	result.add(bankTransaction);
        }
    }
    return result;
}

BankTransactionFilter를 구현하는 클래스만 달리하여 파라미터로 전달하면 findTransactions() 메소드는 조건을 달리해도 수정없이 실행될 수 있다. 수정에 대해서는 닫혀있지만 확장에 대해 열려있는 것이다. 그러나 이 방식은 한 가지 문제를 만든다. 새로운 검색 조건이 있을 때마다 클래스를 추가해야 한다는 점이다. 그럼 결국 수 많은 비슷한 조건 클래스들이 생겨날 것이다. 이는 람다 표현식을 통해 해결이 가능하다. 람다 표현식은 자바 8부터 생겨난 기능으로 익명 함수를 파라미터로 전달 할 수 있게 해준다. 그러므로 BankTransactionIsInFebruaryAndExpensive와같은 클래스를 생성할 필요없이 다음과 같은 방식으로 간단히 같은 결과를 낼 수 있다.

final List<BankTransaction> transactions
	= bankStatementProcessor.findTransactions(bankTransaction ->
    		bankTransaction.getDate().getMonth() == Month.FEBRUARY
            && bankTransaction.getAmount() >= 1000);

람다 표현식으로 간단하게 개방/폐쇄 원칙을 지킬 수 있으며, 이 책에서 제시하는 개방/폐쇄 원칙의 장점은 다음과 같다.

  • 기존 코드를 바꾸지 않기 때문에 기존 코드가 잘못될 가능성이 줄어든다.
  • 코드가 중복되지 않으므로 기존 코드의 재사용성이 높아진다.
  • 결합도가 낮아지므로 코드 유지보수성이 좋아진다.

 

3. 인터페이스 문제

2장에서 배웠던 갓 클래스 문제는 인터페이스에도 똑같이 적용된다. 한 인터페이스에 모든 기능을 추가하는 것을 갓 인터페이스라고 부르며 이 역시 갓 클래스와 똑같이 문제가 된다. 예를 들어 BankStatementProcessor가 인터페이스 였다면 다음과 같은 갓 인터페이스가 되었을 것이다. 

interface BankStatementProcessor {
	double caculateTotalAmount();
    double calculateTotalInMonth(Month month);
    double calculateTotalForCategory(String description);
    List<BankTransaction> findTransactions(BankTransactionFilter bankTransactionFilter);
}

위의 갓 인터페이스는 다음의 두 가지 형식의 결합을 만든다

  • 인터페이스의 모든 연산은 구현 클래스에서 코드를 제공해야 한다. 그러므로 인터페이스를 바꾸면 이를 구현한 코드도 바뀐 내용을 지원하도록 갱신해야 한다. 인터페이스에 더 많은 연산을 추가할 수록 더 자주 코드가 바뀔 것이다.
  • 인터페이스가 bankTransaction의 날짜, 금액, 설명의 특정 접근자에 종속된다. 이러한 객체의 세부 내용이 바뀌면 인터페이스도 바뀌어야 한다. 그러면 구현 코드 또한 수정해야 한다.

그렇다면 갓 인터페이스가 생기지 않도록 모든 기능을 분리하여 인터페이스를 따로 만들면 문제가 없을까? 그러나 이 책은 지나치게 인터페이스가 세밀해도 코드 유지보수에 방해가 된다고 설명한다. 왜냐하면 안티-응집도의 문제가 발생하기 때문이다. 즉, 기능이 여러 인터페이스로 분산되기 때문에 필요한 기능을 찾기 어려워진다. 또한 인터페이스가 너무 세말하면 복잡도가 높아지며, 새로운 인터페이스가 계속 프로젝트에 추가된다.

 

4. 명시적 API vs 암묵적 API

그렇다면 어떻게 해야할까. 우선 BankStatementProcessor를 굳이 인터페이스로 만들 필요는 없다. 데이터 처리에 다양한 구현이 필요하지 않고, 전체 프로그램에 도움이 되는 메소드를 제공하는 것도 아니기 때문이다. 추상화는 불필요한 과정인 것이다. 그리고 위에서 개방/폐쇄 원칙을 적용시켜 검색 기능을 하나의 메소드를 이용해 만들었다. findTransactions()가 그것이다. 이 방식은 확장에는 열려있는 덕분에 필요한 모든 상황을 단순한 API로 처리할 수 있지만, 처음 접하면 사용하기가 어렵고, 문서화를 잘 해놓아야 한다는 단점이 있다. 이전의 findTransactionsGreaterThan

Equal()과 같은 메소드는 자체적으로 어떤 동작을 수행하는지 잘 설명되어 있고, 사용하기도 쉽다. API의 가독성을 높이도록 메소드 명을 서술적으로 만들었기 때문이다. 그러나 이전에서 보았듯이 메소드의 용도가 특정 상황에만 국한되기 때문에, 즉 확장에 닫혀있기 때문에 각 상황 마다 맞는 새로운 메소드를 많이 만들어야 한다. 이런 딜레마를 명시적 API제공 vs 암묵적 API 제공 문제라고 하며 여기에 정확한 답은 없다. 상황에 맞게 둘 중 하나를 선택하거나 둘을 적당히 섞을 수도 있을 것이다. 이 책에서는 BankStatementProcessor클래스에서 가장 흔히 사용할 연산인 findTransactionsGreate

rThanEqual()을 명시적 API로 남겼다.