본문 바로가기
Book

[실전 자바 소프트웨어 개발] 3. 입출금 내역 분석기 확장판

by kkkdh 2023. 8. 29.
728x90

이번 글은 지난 2장 정리글에 이어서 3장을 공부하고 기록합니다.

 

[2장 정리글 링크]

 

[실전 자바 소프트웨어 개발] 2. 입출금 내역 분석기

들어가면서.. 이 글은 실전 자바 소프트웨어 개발(Real-World Software Development) 책을 읽으며 공부한 점을 기록하기 위해 작성합니다. 이번 2장에서는 입출금 내역 분석기라는 소프트웨어를 개발하고,

kkkdh.tistory.com

 

이번 장에서는 2장에서 구현한 입출금 내역 분석기에 추가 기능을 구현하고, 이 과정에서 OCP(open/closed principle), 개방 폐쇄 원칙을 배웁니다.

 

또한 인터페이스를 사용하는 일반적인 기준과 maven, gradle과 같은 검증된 빌드 도구를 활용해 자바 프로젝트를 시스템적(systemically)으로 빌드하는 방법을 다룹니다.

 


확장된 입출금 분석기의 요구 사항

  • 특정 입출금 내역을 검색할 수 있는 기능. 예를 들어 주어진 날짜 범위 또는 특정 범주의 입출금 내역 얻기
  • 검색 결과의 요약 통계를 텍스트, HTML 등 다양한 형식으로 만들기

 


개방/폐쇄 원칙 (OCP)

먼저 간단한 기능부터 다음과 같이 구현한다.

 

특정 금액 이상의 모든 입출금 내역을 검색하는 메서드는 기존에 구현한 BankTransactionProcessor 클래스 내부에 구현한다. (BankTransaction 객체 관련 처리 작업에 해당하기 때문)

일정 금액 이상의 목록 검색, 특정 월의 입출금 내역 목록 검색 기능 구현

만약 여기서 "특정 월 + 특정 금액 이상"이라는 조건을 묶어 필터링하고 싶다면, 그에 맞는 find method를 또 구현해야 하는 코드 중복의 문제가 발생한다.

 

 

구현하는 것은 그렇다 쳐도 조건이 증가할수록 구현되어야 하는 코드의 복잡성은 더더욱 증가할 것이고, 이 또한 문제가 된다.

 

 

이러한 경우 개방/폐쇄 원칙을 적용하고, 개방/폐쇄 원칙을 적용하면 코드를 직접 변경하지 않고도 해당 메서드나 클래스의 동작을 바꿀 수 있다. (개방/폐쇄 원칙은 변화에는 닫혀있고, 확장에는 열려있는 코드를 작성함을 의미한다.)

 

 

이를 책에서는 BankTransactionFilter interface를 만들어 개방/폐쇄 원칙을 적용하고 있다.

코드를 보면 다음과 같다.

추상 메서드로 test를 하나 갖는 함수형 인터페이스이다.

인터페이스의 test method는 BankTransaction 객체를 하나 받아 boolean type 값을 반환하는데, 이는 특정 조건의 참 거짓 여부를 판단하기 위해 사용한다. (이는 Predicate라는 함수형 인터페이스와 동일한 역할을 수행한다고 볼 수 있다.)

 

다시 돌아가서 BankTransactionFilter interface를 이용해 find~ method를 위와 같이 리팩터링 한다.

 

 

코드가 훨씬 간단해지고, 이렇게 interface를 활용하는 경우 확장성 또한 크게 증가한다.

 

함수형 인터페이스의 구현체를 만들어

 

다만, 그대로 사용할 수는 없고 특정 조건에 대한 인터페이스 인스턴스를 만들어 parameter로 전달하거나, Java 8 버전 이후부터 제공하는 람다 표현식을 사용해야 원하는 조건에 맞춰 findTransactions method를 활용 가능하다.

(책에는 없으나 람다 표현식 대신 익명 객체를 만들거나 하는 방법 또한 가능할 것이다.)

 

 

두 가지 방식을 적용함으로써 기존 코드의 변경 없이, 새로 작성하는 코드에서 인터페이스에 맞는 적절한 구현체를 작성함으로써 기존 코드를 변경하지 않고, 새로운 기능을 추가할 수 있다는 점에서 OCP가 적용되었음을 파악할 수 있다.

 

// using instance of functional interface BankTransactionIsInFebruaryAndExpensive or
final List<BankTransaction> transactions =
	bankStatementProcessor.findTransactions(new BankTransactionIsInFebruaryAndExpensive());

// lambda expression
final List<BankTransaction> transactions = 
	bankStatementProcessor.findTransactions(bankTransaction ->
    						bankTransaction.getDate().getMonth().equals(Month.FEBRUARY) &&
                                                bankTransaction.getAmount() >= 1_000);

 

이처럼 개방/폐쇄 원칙을 적용해

  • 기존의 코드를 바꾸지 않고, 확장 가능
  • 코드가 중복되지 않으므로 기존 코드의 재사용성이 증가
  • 결합도가 낮아지므로 코드 유지보수성이 좋아짐

같은 이점을 얻을 수 있다.

 


인터페이스 문제

BankTransactionProcessor 클래스에는 findTransactions 외에도 다른 메서드 또한 존재했다.

 

그렇다면, 이 다른 메서드 들도 별도로 인터페이스로 옮겨야 할까? 아니면, 별도의 클래스로 옮겨야 할까?

 

여기서 문제가 발생한다.

 

  • calculateTotalAmount()
  • calculateTotalInMonth()
  • calculateTotalInCategory()

지나치게 많은 기능을 하나에 몰아넣은 인터페이스를 갓 인터페이스라 칭하는데, 이것 또한 문제고 (인터페이스가 무거워지면, 이에 의존한 구현체가 모든 기능을 구현해야 하는 문제와 조그마한 변경 사항에도 구현체가 모두 변경되어야 하는 문제 발생)

지나치게 많은 인터페이스에 기능을 세세하게 나누는 상황 또한 문제가 된다.

interface BankTransactionProcessor {
    double calculateTotalAmount();
    double calculateTotalInMonth(Month month);
    double calculateTotalInJanuary();
    ...
    double calculateAverageAmountForCategory(Category category);
    ...
}
interface CalculateTotalAmount {
	double calculateTotalAmount();
}

interface CalculateAverage {
	double calculateAverage();
}
...
interface CalculateTotalInMonth {
	double calculateTotalInMonth(Month month);
}

둘 다 어질어질하다.

 


명시적 API vs 암묵적 API

인터페이스를 어떻게 제공하느냐의 문제는 더 확장성 있는 메서드를 개방/폐쇄 원칙 적용을 통해 구현함으로써 해결할 수 있다.

 

 

더 일반적인 메서드를 만들어 이를 확장해서 활용하는 방식을 취하는 것이다.

 

 

앞서 만들었던 findTransactions method를 구현하면, findTransactionsGreaterThanEqual 같은 메서드를 따로 구현할 필요도 없으며, 이에 따라 인터페이스나 클래스가 커질 이유도 별도의 인터페이스를 만들 이유도 사라진다.

 

 

그렇지만, 더 일반적인 메서드를 만들어 제공하는 것이 만능 해답은 될 수 없다. 각각의 케이스에 장단점이 존재하기 때문

 

  • 명시적 API 제공
    • findTransactionsGreaterThanEqual 같이 메서드 이름을 통해 수행 작업을 명시적으로 파악 가능한 API 제공 방식을 의미한다.
    • 이름만 봐도 동작 방식이 파악 가능해 이해가 쉽고, 사용 또한 쉽다는 장점이 있다.
    • 다만, 이러한 메서드가 증가하면, 너무 많은 메서드를 만들어 제공해야 한다는 단점이 있다.
  • 암묵적 API 제공
    • findTransactions method와 같이 단순한 API를 제공하는 방식을 의미한다.
    • 하나의 메서드를 케이스에 따라 다양하게 활용 가능해 확장성이 좋고, 코드의 중복성을 낮춰준다.
    • 하지만, 람다 표현식 같은 문법을 모르거나 배경 지식이 부족하다면, 사용하기 어렵다는 단점이 있다.
    • 또한 한 눈에 동작 방식을 파악하기 어렵다.

 

이에 따라서 상황에 따라 두 방식을 혼재해야 한다. 예컨대 findTransactionsGreaterThanEqual 같은 메서드가 가장 흔히 사용되는 연산이라면, 해당 API를 구현해서 추가적으로 제공하는 것이 하나의 합리적인 방법이라고 할 수 있다.

 

 

이러한 규칙들에 따라 BankTransactionProcessor 코드를 다음과 같이 개선했다.

package com.study.chapter3;

import java.time.Month;
import java.util.ArrayList;
import java.util.List;

public class BankStatementProcessor {
    private final List<BankTransaction> bankTransactionList;

    public BankStatementProcessor(List<BankTransaction> bankTransactionList) {
        this.bankTransactionList = bankTransactionList;
    }

    public double summarizeTransactions(final BankTransactionSummarizer bankTransactionSummarizer) {
        double result = 0;
        for(final BankTransaction bankTransaction : bankTransactionList){
            result = bankTransactionSummarizer.summarize(result, bankTransaction);
        }
        return result;
    }

    public double calculateTotalAmount(){
        double total = 0;
        for(final BankTransaction bankTransaction : bankTransactionList){
            total += bankTransaction.getAmount();
        }
        return total;
    }

    public double calculateTotalInMonth(final Month month){
        return summarizeTransactions((acc, bankTransaction) ->
                bankTransaction.getDate().getMonth().equals(month) ?
                        acc + bankTransaction.getAmount() : acc);
    }

    public double calculateTotalForCategory(final String category){
        return summarizeTransactions((acc, bankTransaction) ->
                bankTransaction.getDescription().equals(category) ?
                acc + bankTransaction.getAmount() : acc);
    }

    public List<BankTransaction> findTransactions(BankTransactionFilter bankTransactionFilter){
        final List<BankTransaction> result = new ArrayList<>();

        for(final BankTransaction bankTransaction : bankTransactionList){
            if(bankTransactionFilter.test(bankTransaction)){
                result.add(bankTransaction);
            }
        }
        return result;
    }

    public List<BankTransaction> findTransactionsGreaterThanEqual(final int amount){
        return findTransactions(bankTransaction ->
                bankTransaction.getAmount() >= amount);
    }
}

 

summarizeTransactions method의 parameter로 사용된 BankTransactionSummarizer interface는 위와 같다.

 

마찬가지로 함수형 인터페이스이며, 누적자와 BankTransaction 객체를 입력으로 받아 조건에 맞는 경우 누적자에 입출금 금액을 누적하는 작업을 수행한다.

 


다양한 형식으로 내보내기

지금과 같이 double 타입으로 결과를 외부로 반환하는 경우 요구사항이 변경되어 출력 형태가 변경되는 상황만을 가정해도 모든 클래스의 메서드 반환 타입을 변경해야 하는 문제가 발생한다.

 

SummaryStatistics domain 객체로 통계 결과를 다룬다.

이를 별도의 통계에 대한 도메인 객체를 만들어 감싸는 방식으로 처리함으로써 추후의 변경에도 대응하기 용이하도록 구현할 수 있다.

 

Exporter interface를 구현해 다른 코드에서 추상화에 의존 가능하도록 설정

또한 Exporter interface를 별도로 구현해 추후 HTML이 아닌 다른 형식의 파일로 결과를 받고 싶다는 요구사항이 추가된다고 하더라도 이 또한 구현체를 추가 구현함으로써 기존 코드의 변경 폭을 줄일 수 있다.

 

HtmlExporter는 Exporter interface를 구현하도록

 

이후의 예외처리 부분이나 빌드 툴에 대한 내용은 생략

728x90

댓글