ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • 입출금 내역 분석기 (1) - 응집도와 결합도에 따른 클래스 분리
    언어/객체지향 2024. 5. 24. 17:12

     

    1. 목표

    • 단일책임원칙을 배워보자
    • 응집도와 결합도에 대해 생각해보자 

    2. 요구사항

    • 텍스트 파일이 콤마로 분리된 값으로 주어진다. 
    • 이 값을 파싱해서 결과를 얻을 것이다.
      • 입출금 내역의 총 수입과 총 지출은 각각 얼마인가? 결과는 양수인가 음수인가?
      • 특정 달엔 몇 건의 입출금 내역이 발생했는가?
      • 지출이 가장 높은 상위 10건은 무엇인가?
      • 돈을 가장 많이 소비하는 항목은 무엇인가?
    30-01-2017,-100,Deliveroo
    //..

    3. KISS 원칙 

    • keep it short and simple 원칙을 의미한다. 
    • 코드 설계나 내용을 불필요하게 복잡하게 만들지 말자는 원칙 
    public class BankTransactionAnalyzerSimple {
    	 private static final String RESOURCES ="src/main/resources/";
    	 
    	 
    	 //명령줄 인수로 csv 파일을 전달받음 
    	 public static void main(final String...args) throws IOException {
    		 
    	 //Path 클래스는 파일 시스템 경로를 가리킴 
    	 final Path path = Paths.get(RESOURCES + args[0]);
    	 final List<String> lines = Files.readAllLines(path);
    	 double total = 0d;
    	 for(final String line: lines) {
    		 final String[] columns = line.split(",");
    		 final double amount = Double.parseDouble(columns[1]);
    		 total += amount;
    	 }
    	 
    		 System.out.println("The total for all transcation is "+total);
    
    		 
    	}
    }
    • 콤마로 열 분리
    • 금액 추출
    • 금액을 double로 파싱 

    2.2 6월에 사용한 돈만 구하는 경우 

     

             //특정 월의 값 total값 알고 싶어
    		 final Path path = Paths.get(RESOURCES + args[0]);
    		 final List<String> lines = Files.readAllLines(path);
    		 double total = 0d;
             //입력받은 csv에 문자열 패턴 입력
    		 final DateTimeFormatter DATE_PATTERN = DateTimeFormatter.ofPattern("dd-MM-yyyy");
    		 
    		 for(final String line: lines) {
    			 final String[] columns = line.split(",");
                 //LocalDate Type으로 변환 
    			 final LocalDate date = LocalDate.parse(columns[0],DATE_PATTERN);
    			 if(date.getMonth() == Month.JANUARY) {
    			 final double amount = Double.parseDouble(columns[1]);
    			 total += amount;
    			 }
    		 }
    		 
    		 System.out.println("The total for all transcation in January is "+total);

     

    • 일단 기존 코드를 복붙해서 6월을 찾아서 계산하도록 만들었다.

     

    2.2.1 final 변수 

    • final은 변수 값 재할당을 막는다. final 사용은 장담점이 모두 있다. 
    • 먼저 장점은 final을 통해 객체의 상태가 바뀔 수 있을지 없을지 명확하게 구분 가능
    • 하지만, 더 많은 코드를 추가해야할 수도 있다. 
      • 어떤 팀에서는 메서드 파라미터에 final 필드를 포함시켜, 지역변수가 아니며, 다시 할당할 수 없음을 명시하기도 한다.
    • 단, 추상 메서드의 메서드 파라미터에 final을 사용하는 상황에서는 키워드 의미가 무력하다.

    3. 코드 유지보수성과 안티 패턴 고려 

     

    • 유지보수성을 높이기 위해 코드가 가졌으면 하는 속성을 목록으로 만들어보자
      • 특정 기능을 담당하는 코드를 쉽게 찾을 수 있어야 하다.
      • 코드가 어떤 일을 수행하는지 쉽게 이해할 수 있어야 한다.
      • 새로운 기능을 쉽게 추가하거나 기존 기능을 쉽게 제거할 수 있어야 한다.
      • 캡슐화가 잘되어 있어야 한다. 즉, 코드 사용자에게는 세부 구현 내용이 감춰줘 있으므로 사용자가 쉽게 코드를 이해하고 기능을 바꿀 수 있어야 한다.

    궁극적으로 개발자의 목표는 만들고 있는 응용프로그램의 복잡성을 관리하는 것이다. 

    복사 붙여넣기로 해결하는 것은 안티 패턴이다.

    • 하나의 거대한 갓 클래스 때문에 코드를 이해하기 어렵다
    • 코드 중복 때문에 코드가 불안정하고 변화에 쉽게 망가진다.

    3.1 갓 클래스 

     

    한 개의 파일에 모든 코드를 구현하다 보면 결국 하나의 거대한 클래스가 탄생하면서, 클래스의 목적이 무엇인지 이해하기 어려워진다. 이 거대한 클래스가 모든 일을 수행하기 떄문이다.

    갓 클래스 안티패턴은 이와 같이 한 클래스로 모든 것을 해결하는 패턴이다. 

     

    3.2 코드 중복 

     

    만약 위의 CSV 대신 JSON 파일로 입력 형식이 바뀐다면? 혹은 다양한 파일 형식을 지원해야 한다면?

    코드가 중복되고 하드코딩되어 있으면, 결과적으로 변화가 생기면 모든 것을 바꿔야한다. 

    이는 새로운 버그가 발생할 가능성을 내포한다. 

     

    * 결론적으로 코드를 간결하게 유지하는 것도 중요하지만, KISS 원칙을 남용하지 말자 

    응용 프로그램의 전체 설계를 되돌아보고, 한 문제를 작은 개별 문제로 분리해 더 쉽게 관리할 수 있는지 파악해야한다. 

    이 과정을 통해 더 이해하기 쉽고, 쉽게 유지보수하며, 새로운 요구 사항도 쉽게 적용하는 결과를 만들 수 있다.


    4. 단일 책임 원칙 (SRP)

     

    • 단일 책임 원칙은 쉽게 관리하고 유지보수하는 코드를 구현하는 데 도움을 주는 포괄적인 소프트웨어 개발 지침이다.
    • SRP는 다음 두 가지를 보완하기 위해 SRP를 적용한다.
      • 한 클래스는 한 기능만 책임진다.
      • 클래스가 바뀌어야 하는 이유는 오직 하나여야 한다.
      • SRP는 클래스와 메서드에 적용한다. 
    • 현재 클래스는 너무 많은 책임을 수행하고 있다! 
      • 입력 읽기
      • 주어진 형식의 파싱 
      • 결과 처리
      • 결과 요약 리포트 
    • 이 모든 것을 개별로 분리해야 한다. 일단 파싱을 위한 클래스를 만들어보자
    //도메인 클래스
    public class BankTransaction {
    	private final LocalDate date;
    	private final double amount;
    	private final String description;
    	
    	public BankTransaction(final LocalDate date, final double amount, final String descroption) {
    		this.date = date;
    		this.amount = amount;
    		this.description = descroption;
    	}
    
    	public LocalDate getDate() {
    		return date;
    	}
    
    	public double getAmount() {
    		return amount;
    	}
    
    	public String getDescription() {
    		return description;
    	}
    
    	@Override
    	public String toString() {
    		return "BankTransaction [date=" + date + ", amount=" + amount + ", description=" + description + "]";
    	}
    	
        @Override
    	public int hashCode() {
    		final int prime = 31;
    		int result = 1;
    		long temp;
    		temp = Double.doubleToLongBits(amount);
    		result = prime * result + (int) (temp ^ (temp >>> 32));
    		result = prime * result + ((date == null) ? 0 : date.hashCode());
    		result = prime * result + ((description == null) ? 0 : description.hashCode());
    		return result;
    	}
    
    	@Override
    	public boolean equals(Object obj) {
    		if (this == obj)
    			return true;
    		if (obj == null)
    			return false;
    		if (getClass() != obj.getClass())
    			return false;
    		BankTransaction other = (BankTransaction) obj;
    		if (Double.doubleToLongBits(amount) != Double.doubleToLongBits(other.amount))
    			return false;
    		if (date == null) {
    			if (other.date != null)
    				return false;
    		} else if (!date.equals(other.date))
    			return false;
    		if (description == null) {
    			if (other.description != null)
    				return false;
    		} else if (!description.equals(other.description))
    			return false;
    		return true;
    	}
    	
        
    }

     

    • 도메인 클래스비즈니스 문제와 동일한 단어와 용어를 사용한다. 
    // 파싱 담당 클래스 
    public class BankStatementCSVParser {
    	private static final DateTimeFormatter DATE_PATTERN
    	= DateTimeFormatter.ofPattern("dd-MM-yyyy");
    	
        //line 하나를 읽어서 파싱하는 역할
    	private BankTransaction parseFromCSV(final String line) {
    		final String[] columns = line.split(",");
    		
    		final LocalDate date = LocalDate.parse(columns[0],DATE_PATTERN);
    		final double amount = Double.parseDouble(columns[1]);
    		final String description = columns[2];
    		
    		return new BankTransaction(date, amount, description);
    	}
    	//여러 라인을 읽어서, 파싱기에 넣고 결과를 반환하는 역할
    	public List<BankTransaction> parseLineFromCSV(final List<String> lines){
    		final List<BankTransaction> bankTransactions = new ArrayList<>();
    		for(final String line : lines) {
    			bankTransactions.add(parseFromCSV(line));
    		}
    		
    		return bankTransactions;
    	}
    
    }
    • 이제 파싱 담당 객체를 추가해서 리팩토링 해보자
    public class BankTransactionAnalyzerSimple {
    	private static final String RESOURCES = "src/main/resources/";
    
    	// KISS 원칙이 적용되었다함?
    	// 명령줄 인수로 csv 파일을 전달받음
    	public static void main(final String... args) throws IOException {
    
    		final BankStatementCSVParser baCsvParser = new BankStatementCSVParser();
    
    		final String fileName = args[0];
    
    		// 특정 월의 값 total값 알고 싶어
    		final Path path = Paths.get(RESOURCES + fileName);
    		final List<String> lines = Files.readAllLines(path);
    
    		final List<BankTransaction> bankTransactions = baCsvParser.parseLineFromCSV(lines);
    
    		System.out.println(calculateTotalAmount(bankTransactions));
    		System.out.println(selectInMonth(bankTransactions, Month.JANUARY));
    		;
    
    		final DateTimeFormatter DATE_PATTERN = DateTimeFormatter.ofPattern("dd-MM-yyyy");
    
    	}
    
    	private static List<BankTransaction> selectInMonth(List<BankTransaction> bankTransactions, Month month) {
    		final List<BankTransaction> bankTransactionInMonth = new ArrayList<>();
    		for (final BankTransaction bank : bankTransactions) {
    			if (bank.getDate().getMonth() == month) {
    				bankTransactionInMonth.add(bank);
    			}
    		}
    
    		return bankTransactionInMonth;
    	}
    
    	private static double calculateTotalAmount(List<BankTransaction> bankTransactions) {
    
    		double total = 0d;
    		for (final BankTransaction bank : bankTransactions) {
    			total += bank.getAmount();
    		}
    		return total;
    
    	}
    }

     

    • 파싱하는 부분과  토탈을 계산하는 부분(calculateTotalAmount), 월별 계산하는 부분(selectInMonth)로 나누었다.
    • 파싱 기능을 다른 클래스와 메서드에 위임해서 독립적 구현이 가능해짐 
    • 다양한 문제를 처리해야 하는 새 요구 사항이 들어오면, 해당 클래스를 재사용해 구현하면된다.
    • 또한, BankTransaction클래스 덕에 다른 코드가 특정 데이터 형식에 의존하지 않아도 된다.
    • 메서드 구현 시 놀람 최소화 원칙을 따라야한다. 그래야 코드를 보고 무슨 일이 일어나는지 명확하게 이해한다.
      • 누군가가 놀라지않도록, 메서드 일관성 유지하는 범위에서 코드를 구현할 것, 메서드가 수행하는 일을 바로 이해할 수 있도록 자체 문서화를 제공하는 메서드명을 사용한다. 또한, 코드의 다른 부분이 파라미터의 상태에 의존할 수 있으므로 파라미터 상태를 바꾸지 않는다.

    5. 응집도

     

    • 소프트웨어 엔지니어링과 관련해 응집도는 코드 구현에서 중요한 특성이다.
    • 응집도는 서로 어떻게 관련되어 있는지를 가리킨다. 클래스나 메서드의 책임이 서로 얼마나 강하게 연결되어 있는지를 측정한다.
    • 즉, 어떤 것이 여기저기 모두 속해 있는지를 말한다. 
    • BansTatementCSVParser의 응집도는 꽤 높다. 해당 클래스에서 CSV 데이터 파싱하는 작업과 관련된 두 메서드를 한 그룹으로 만들었기 때문이다. 
    • 보통 응집도의 개념은 클래스(클래스 수준)에 적용한다. (메서드에도 적용할 수 있다)
    • 진입점인 BankStatementAnlyzer클래스를 살펴보면 파서, 계산, 화면으로 결과 전송 등을 수행한다. 
    • 하지만, 해당 클래스에 요구하는 바는 csv파싱, 결과 출력이다. 중간 중간 계산하는 작업은 직접적인 연관관계가 없기 때문에, 응집도를 떨어지는 클래스가 된다.
    • 계산을 대신하는 BankStatementProcessor를 추출해보자
    public class BankStatementProcessor {
    	private final List<BankTransaction> bankList;
    	
    	public BankStatementProcessor(final List<BankTransaction> bankList) {
    		this.bankList = bankList;
    	}
    	
    	public double calculate() {
    		double total =0;
    		for(final BankTransaction bank : this.bankList) {
    			total += bank.getAmount();
    		}
    		return total;
    	}
    	
    	public double calcMonth(final Month month) {
    		double total =0;
    		for(final BankTransaction bank:this.bankList) {
    			if(bank.getDate().getMonth() == month) {
    				total += bank.getAmount();
    			}
    		}
    		return total;
    	}
    	
    	public double calculTotalForCategory(final String category) {
    		double total=0;
    		for(final BankTransaction bank : this.bankList) {
    			if(bank.getDescription().equals(category)) {
    				total += bank.getAmount();
    			}
    		}
    		return total;
    	}
    	
    }

     

    5.1 클래스 수준 응집도 

     

    보통 다음 여섯 가지 방법으로 그룹화한다.

    • 기능
    • 정보
    • 유틸리티
    • 논리
    • 순차
    • 시간

    그룹화 하는 메서드의 관련성이 약하면 응집도가 낮아진다. 그룹화하는 방법을 살펴보자 

     

    기능

    • BankStatementCSVParser를 구현할 떄 기능이 비슷한 메서드를 그룹화했다.
    • parseFrom(), parseLineFrom()은 CSV 형식의 행을 파싱한다. 둘은 그룹화된 메서드라 이해하기 쉽고 응집도를 높이는 방향이다. 
    • 다만, 기능 응집에 집착하면, 한 개의 메서드를 갖는 클래스를 과도하게 만들려는 경향이 발생할 수 있다는 약점이 있다.  

    정보

    • 같은 데이터도메인 객체를 처리하는 메서드를 묶으려는 경향이다. 
    • 예를 들어 BankTransaction 객체를 만들고, 읽고, 갱신하고, 삭제하는 기능이 필요하면 이를 제공하는 클래스를 만든다. 
    • 정보 응집은 여러 기능을 그룹화하면서, 필요한 일부 기능을 포함하는 클래스 전체를 추가한다는 단점 존재 
      • 풀어서 말하자면, 한 객체(데이터) 처리하기 위한 모든 기능들 모아두었는데, 특정 기능을 수행하기 위해 타 클래스가 필요할 때, 이 클래스의 부분만 가져올 수 있는게 아니라, 전체를 가지고와야한다는 점 

    유틸리티

    • 관련성이 없는 메서드를 한 클래스로 포함시킨다.  어디에 속해야하는지 결정하기 어려울 때는 만능 유틸클래스를 추가하기도 한다. 
    • 보통 해당 클래스를 사용하면 응집도가 낮아질 수 있기 떄문에 자제해야한다. 

    논리

    • CSV,JSON,XML을 파싱하는 코드를 구현해보자 
    public class BankTransactionParser {
    	public BankTransaction parseFromCSV(final String line) {
    		throw new UnsupportedOperationException();
    	}
    	
    	public BankTransaction parseFromJSON(final String line) {
    		throw new UnsupportedOperationException();
    	}
    	
    	public BankTransaction parseFromXML(final String line) {
    		throw new UnsupportedOperationException();
    	}
    }
    • 이와 같이 파싱이라는 논리(행위)로 그룹화할 수 있다. 
    • 모든 메서드는 서로 관련이 없지만, 같은 작업 논리로 묶음 (기능보다 쫌 더 큰 작업 단위)
    • 다만, 이 클래스는 네 가지 책임을 갖게 되므로, SRP를 위배 -> 권장x 

    순차

    • 파일을 읽고, 파싱하고, 처리하고, 저장하는 메서드들을 한 클래스로 그룹화한다. 
    • 파일을 읽는 결과는 파싱의 입력이 되고, 파싱의 결과는 처리 과정의 입력이된다.
    • 이와 같이 입출력이 순차적으로 흐르는 것을 순차 응집이라 부른다. 
    • 하지만, 순차 응집을 적용하면 한 클래스를 바꿔야할 여러 이유가 존재하므로 SRP를 위배한다.
    • 데이터 처리, 요약, 저장하는 방법이 다양하므로 결국 이 기법은 클래스를 복잡하게 만든다. 따라서, 각 책임을 개별적으로 응집된 클래스로 분리하는 것이 더 좋은 방법이다. 

    시간

    • 여러 연산 중 시간과 관련된 연산을 그룹화한다. 
    • 어떤 처리 작업을 시작하기 전과 뒤에 초기화, 뒷정리 작업(데이터베이스 연결 종료)을 담당하는 메서드를 포함하는 클래스가 예이다.
    응집도 수준 장점 단점
    기능(높은 응집도) 이해하기 쉬움 너무 단순한 클래스 생성
    정보(중간 응집도) 유지보수하기 쉬움 불필요한 디펜던시
    순차(중간 응집도) 관련 동작 찾기 쉬움 SRP 위배 가능성
    논리(중간 응집도) 높은 수준의 카테고리화 SRP 위배 가능성
    유틸리티(낮은 응집도) 간단히 추가 가능 클래스의 책임 파악이 어려움
    시간(낮은 응집도) 판단 불가 각 동작 이해하고 사용하기 어려움

     

     

    메서드 수준의 응집도 

    • 메서드가 연관이 없는 여러 일을 처리한다면 응집도가 낮아진다. 응집도가 낮은 메서드는 여러 책임을 포함
    • 응집도가 낮은 메서드는 테스트가 어렵다. 
    • 일반적으로 클래스나 메서드 파라미터의 여러 필드를 바꾸는 if/else 블록이 여러 개 포함한다면, 더 작은 메서드로 분리하는 것을 고려해보자 

    7. 결합도

     

    • 결합도는 한 기능이 다른 클래스에 얼마나 의존하고 있는지를 가늠한다.
    • 클래스를 구현하는 데 얼마나 많은 자식(다른 클래스) 참조했는가로 설명 가능하다.
    • 시계를 예로 들면, 동작하는 방식을 몰라도 시간을 알아내는 데 문제가 없다.
    • 사람은 시계 내부 구조에 의존하지 않기 때문이다. 
    • 위 예시에서 BankStatementAnalyzer는 BankStatementCSVParser클래스에 의존한다.
    • 이때 JSON으로 인코딩된 거래 내역을 파싱해야한다면? 어떻게 해야 할까? 
    • 이를 인터페이스와 구현의 분리로 유연성을 유지할 수 있다. (DIP)
    public interface BankStatementParser{
    	BankTransaction parseFrom(String line);
        List<BankTransaction> parseLinesFrom(List<String> lines);
    }
    
    public class BankStatementCSVPArser implemnets BankStatementParser{
    	//..
    }
    • 이제 BankStatementAnlayzer와 BankStatementCSVParser 구현의 결합을 제거해보자 
    • 이떄 인터페이스를 사용한다. BankTranscationParser를 인수로 받는 analyze()메서드를 새로 만들어 특정 구현에 종속되지 않도록 클래스를 개선해보자 
    public void analyze(final String fileName, final BankStatementParser bankStatementParser) throws IOException {
    		
    		final Path path = Paths.get(RESOURCES+fileName);
    		final List<String> lines = Files.readAllLines(path);
    		
    		final List<BankTransaction> bankTransactions = bankStatementParser.parseLinesFrom(lines);
    		
    		final BankStatementProcessor bankStatementProcessor = new BankStatementProcessor(bankTransactions);
    		collectSummary(bankStatementProcessor);
    		
    	}
    • 이와같이 특정 Parser에 직접 의존하지 않고, 인터페이스를 통해 파라미터로 받으면 결합도를 낮춰 사용할 수 있다.

    8. 테스트 코드 커버리지 

    • 테스트 집합이 소프트웨어의 소스코드를 얼마나 테스트했는가를 가리키는 척도이다.
    • 보통 70~90퍼센트의 코드 커버리지를 목표로 잡는다. 
      • 100%는 어렵고 무모하다 (코드에는 getter,setter같은 테스트가 필요없는 코드도 존재한다)
    • 자바에서는 자코코,에마,코베르투라 같은 코드 커버리지 도구를 많이 사용한다. 
    • 보통 구문 커버리지를 통해 코드 커버리지를 계산하는데, 분기문을 한 구문으로 취급하는 단점이 존재한다.
    • 따라서 각 분기문을 확인하는 분기 커버리지를 사용하는 것이 좋다.

    8.1 테스트 코드 커버리지 측정 

    • 화이트 박스 테스트를 사용 
    • 보통 구문,조건,결정 커버리지를 사용한다.

    8.2 구문 커버리지 

    • 라인 커버리지라고 부르기도 한다. 
    • 코드 한 줄이 한 번 이상 실행된다면 충족된다.
    void foo(int x){
    
    	System.out.println("line1");
        if(x>0)//line2 {
        	System.out.println("line3");
        }
        System.out.println("line4");
    
    }

     

    • 만약 위 라인에서 x가 0을 초과하면, 구문 커버리지는 100%
    • -1로 통과하지 못한다면, 구문 커버리지는 3/4 = 75%가 된다. 

    8.3 조건 커버리지 

    • 모든 조건식의 내부 조건이 true/false를 가지게 된다면 충족한다.
    void foo (int x, int y) {
        System.out.println("start line"); // 1번
        if (x > 0 && y < 0) { // 2번
            System.out.println("middle line"); // 3번
        }
        System.out.println("last line"); // 4번
    }
    • 내부 조건식 x>0과 y<0 모두 각각 true,false가 되도록 테스트 데이터를 집어 넣어서 확인하는 방법이다. 

    8.4 결정 커버리지

    • 모든 조건식이 true/false을 가지게 되면 충족한다. 
    void foo (int x, int y) {
        System.out.println("start line"); // 1번
        if (x > 0 && y < 0) { // 2번
            System.out.println("middle line"); // 3번
        }
        System.out.println("last line"); // 4번
    }
    • if 문의 조건에 대해 true/false를 모두 가질 수 있도록 테스트 케이스를 대입해서 검사한다. 

    • 갓 클래스와 중복코드는 피하자 
    • 단일 책임 원칙에 따라 클래스를 관리하면, 유지보수하기 쉬운 코드 구현에 도움을 준다.
    • 높은 응집도와 낮은 결합도는 유지보수가 가능한 코드가 가져야할 특징이다. 

     

     


    참고자료

    https://velog.io/@lxxjn0/%EC%BD%94%EB%93%9C-%EB%B6%84%EC%84%9D-%EB%8F%84%EA%B5%AC-%EC%A0%81%EC%9A%A9%EA%B8%B0-1%ED%8E%B8-%EC%BD%94%EB%93%9C-%EC%BB%A4%EB%B2%84%EB%A6%AC%EC%A7%80Code-Coverage%EA%B0%80-%EB%AD%94%EA%B0%80%EC%9A%94

    https://ebook-product.kyobobook.co.kr/dig/epd/ebook/E000002942460

     

Designed by Tistory.