응집도 : 흩어져 있는 것들

hyyyynjn·2024년 3월 28일
0

응집도 : 흩어져 있는 것들

응집도란?

  • 모듈 내부에 있는 데이터와 로직 사이의 관계가 얼마나 강한지 나타내는 지표다.

응집도가 높은 구조?

  • 변경이 쉽고 바람직한 구조라 볼 수 있다
  • 응집도가 낮다면 그만큼 코드가 분산되어있다는 의미다 -> 구조 변경시 문제가 발생하기 쉽다.

이 책에서 다음과 같은 응집도가 낮은 구조에 대한 예시를 보여준다

  • static 메소드 오용
  • 초기화 로직 분산
  • 범용 처리 클래스
  • 결과를 리턴하는데 매개변수 사용하지 않기
  • 매개변수가 너무 많은 경우
  • 메소드 체인

지금부터 예시에 대한 문제점과 함께 이를 해결하는 방법에 대해 알아보자.



5.1 static 메소드 오용

class OrderManager {
  static int add(int money1, int money2) {
    return money1 + money2;
  }
}

// moneyData1, moneyData2는 데이터 클래스
moneyData1.amount = OrderManager.add(moneyData1.amount, moneyData2.amount);

static 메소드는 클래스의 인스턴스를 생성하지 않고도 호출 가능하다.
위 처럼 데이터 클래스와 함께 사용하는 경우가 흔하다.

문제점

static 메소드는 구조적으로 인스턴스 변수를 사용할 수 없다. (내부 인스턴스를 사용한다면 컴파일 에러가 발생한다.)

다시 말해 데이터(인스턴스 변수)와 이를 조작하는 로직(static 메소드)이 분리될 수 밖에 없어서 -> 응집도를 낮춘다.

인스턴스 변수를 사용하는 구조로 변경

class MoneyData {
  private final int amount;

  // 생성자 생략

  public MoneyData add(MoneyData other) {
    return new MoneyData(this.amount + other.amount);
  }
}

MoneyData addedMoneyData = moneyData1.add(moneyData2);

위와 같이 인스턴스 변수와 인스턴스 변수를 사용하는 로직을 같은 클래스에 만드는게 응집도를 높이는 방법이다.

인스턴스 메소드 인척하는 static 메소드 주의하기

class PaymentManager {
  private int discountRate; // unused instance variable

  int add(int moneyAmount1, int moneyAmount2) {
    return moneyAmount1 + moneyAmount2;
  }
}

PaymentManager#add 메소드는 인스턴스 변수를 사용하지 않는다. 다시 말하면 static 메소드와 다를바가 없다.
이와 같은 메소드를 인스턴스 메소드 인척하는 static 메소드라 한다.

왜 static 메소드를 사용할까?

절차 지향적 언어의 접근 방식을 객체 지향 언어에 억지로 적용하려 하기 때문이다.

  • 인스턴스를 생성하지 않고도 메소드를 활용

static 메소드의 사용은 응집도를 매우 낮추기 때문에 남용하지 않도록 주의해야 한다.

어떤 상황에서 static 메소드를 사용해야 할까?

응집도에 영향을 받지 않는 경우 사용해도 좋다

  • 응집도와 관계 없는 기능들 : 로그 출력, 포맷 전환
  • 팩토리 메소드도 static으로 설계하는 것이 좋다.


5.2 초기화 로직 분산

class GiftPoint {
  private static final int MIN_PONT = 0;
  final int value;

  GiftPoint(final int point) {
    if (point < MIN_POINT) {
      throw new IllegalArgumentException();
    }

    value = point;
  }
}

// 표준 회원 가입 포인트
GiftPoint standardMembershipPoint = new GiftPoint(3000);
// 프리미엄 회원 가입 포인트
GiftPoint premiumMembershipPoint = new GiftPoint(10000);

문제점

위 처럼 생성자를 노출시키면 자연스럽게 초기화 로직이 분산될 수 밖에 없다.

  • private-package 또는 public 접근 제한자가 붙은 생성자는 외부에서 호출할 수 있다

초기화 로직 또한 로직이다.
로직이 분산되면 유지 보수가 힘들어진다.

5.2.1 private 생성자 + 팩토리 메소드로 목적에 따라 초기화하자

private 접근제한자로 생성자를 은닉하여 클래스 내부에서만 인스턴스를 생성하도록 하는게 바람직하다
대신 외부에서 목적에 맞게 인스턴스를 생성할 수 있도록 팩토리 메소드를 제공하도록 하자.

  • 비지니스 로직에서 표준 또는 프리미엄 고객의 회원 가입 포인트가 각각 얼마인지 알 필요 없다. 상황에 맞게 제공된 팩토리 메소드를 호출하면 된다.
class GiftPoint {
  private static final int MIN_PONT = 0;
  final int value;

  private GiftPoint(final int point) {
    if (point < MIN_POINT) {
      throw new IllegalArgumentException();
    }

    value = point;
  }

  static forStandardMembershipPoint() {
    return new GiftPoint(3000); 
  }

  static forPremiumMembershipPoint() {
    return new GiftPoint(10000); 
  }
}

// 표준 회원 가입 포인트
GiftPoint standardMembershipPoint = GiftPoint.forStandardMembershipPoint();
// 프리미엄 회원 가입 포인트
GiftPoint premiumMembershipPoint = GiftPoint.forPremiumMembershipPoint();

5.2.2 생성 로직이 너무 많아지면 팩토리 클래스를 고려해 보자

추후에 생성 로직이 너무 많아지면 클래스가 하는 일이 무엇인지 파악하기 어려워진다.
이럴 경우 생성 전용 팩토리 클래스를 분리하는 방법을 고려해볼 수 있다.



5.3 범용 처리 클래스

똑같은 일을 수행하는 코드가 많아지면 코드를 재사용하기 위해서 범용 클래스를 만든다. 이때 static 메소드로 구현되는 경우가 많다.

  • 보통 범용 클래스를 Common, Util이라는 이름으로 짓는다.
  • 범용의 의미와 재사용성을 잘못 이해하여 -> 관련성이 적은 로직들을 모아두는 경우를 많이 볼 수 있다.
  • 재사용성은 설계의 응집도를 높이면 저절로 높아진다.

같은 일을 수행하는 코드가 여러 군데 분산되어 있다는 말은 해당 코드가 한 곳에서만 수행할 수 있도록 응집도를 높이는 리팩토링이 필요하다는 의미라 생각된다.

  • 코드 중복 -> 범용 클래스를 생성하기? 응집도가 높도록 다시 설계하기!

5.3.3 횡단 관심사

다만 횡단 관심사에 해당하는 기능이라면 범용 코드로 만들어도 괜찮다

  • 로그 출력
  • 오류 확인
  • 디버깅
  • 예외 처리
  • 캐시
  • 동기화
  • 분산 처리
Logger.trace("traceId = " + generateId()); // 트레이스 아이디 찍기 (횡단 관심사1)
try {
  doSomething(); // 비지니스 로직
} catch (IllegalArgumentException e) {
  Logger.report("error occurs"); // 에러 로그 찍기 (횡단 관심사2)
}


5.4 결과를 리턴하는데 매개변수 사용하지 않기

class ActorManager {
  void shift(Location location, int shiftX, int shiftY) {
    location.x += shiftX;
    location.y += shiftY;
  }
}

class DiscountManager {
  void set(MoneyData money) {
    money.amount -= 200;
    if (money.amount < 0) {
      money.amount = 0;
    }
  }
}

메소드가 매개변수의 값을 변경하고 있다.

  • location, money과 같은 메소드에 의해 값이 변경되어 외부로 전달되는 매개변수를 출력 매개변수라 한다
  • 데이터 조작 대상과 조작 로직가 다른 클래스에 있다 -> 응집도가 낮은 구조
class Location {
  final int x;
  final int y;

  Location(final int x, final int y) {
    this.x = x;
    this.y = y;
  }

  Location shift(final int shiftX, final int shiftY) {
    return new Location(this.x + shiftX, this.y + shiftY);
  }
}

출력 매개변수로 설계하지 말고 -> 데이터와 데이터 조작 로직을 같은 클래스에 배치하도록 하자.



5.5 매개변수가 너무 많은 경우

int recoverMagicPoint(int currentMaxMagicPoint, 
                      int originalMaxMagicPoint, 
                      List<Integer> maxMagicPointIncrements,
                      int recoveryAmount) {
  // do something
}

위 처럼 메소드에 너무 많은 매개변수를 전달 받으면, 로직 내부에서 잘못된 값을 대입할 가능성이 높다.

  • 매개변수가 많다는 건 하나의 메소드에 많은 기능을 처리하고 싶다는 의미이다.
  • 처리할게 많아지면 로직이 복잡해지고 중복 코드가 생길 가능성이 높아진다.

5.5.1 기본 자료형에 대한 집착

boolean, int, float, double, String처럼 프로그래밍 언어가 표준적으로 제공하는 자료형을 기본 자료형이라 한다.

recoverMagicPoint 메소드에서 매개변수와 리턴 값에 모두 기본 자료형을 사용하고 있다. 이렇게 기본 자료형을 남용하는 현상을 기본 자료형 집착(primitive obsession)이라 한다.

기본 자료형은 중복 코드를 야기한다.

int regularPrice;

// regularPrice라는 기본 자료형에 대한 유효성 검사 로직이 여기저기 생기기 쉽다
class PriceUtil {
  boolean isFairPrice(int regularPrice) {
    if (regularPrice < 0) { // regularPrice에 대한 유효성 검사
      throw new IllegalArgumentException();
    }
  }
}

class Common {
  int discountPrice(int regularPrice, float discountRate) {
    if (regularPrice < 0) { // regularPrice에 대한 유효성 검사
      throw new IllegalArgumentException();
    }
    if (discountRate < 0.0f) { // discountRate에 대한 유효성 검사
      throw new IllegalArgumentException();
    }

    return (int) regularPrice * discountRate;
  }
}

Common#discountPrice 메소드

  • 파라미터가 기본 자료형이므로 메소드 내부에서 유효성 검사가 필요하다.
  • PriceUtil#isFairPrice에서 수행하는 유효성 검사 로직이 중복되고 있다.

이처럼 기본 자료형으로만 구현하면 중복 코드가 많아진다.

새로운 자료형을 설계해라

기본 자료형에 대한 로직을 하나의 클래스에 모을 수 있도록 새로운 자료형을 설계해보자.

class RegularPrice { // 정가를 의미하는 새로운 자료형
  final int amount;

  RegularPrice(final int amount) {
    if (amount < 0) {
      throw new IllegalArgumentException();
    }

    this.amount = amount;
  }
}

class DiscountedRate { // 할인율을 의미하는 새로운 자료형
  final float rate;

  DiscountedRate(final float rate) {
    if (rate < 0.0f) {
      throw new IllegalArgumentException();
    }

    this.rate = rate;
  }
}

class DiscountedPrice {
  final int amount;

  DiscountedPrice(final RegularPrice regularPrice, final DiscountedRate discountedRate) {
    this.amount = (int) regularPrice.amount * discountedRate.rate;
  }
}

DiscountedPrice 생성자

  • RegularPrice, DiscountedRate 생성자 내부에서 유효성 검사를 하기 때문에 파라미터 유효성 검사가 필요없다.

5.5.2 의미 있는 단위는 모두 클래스로 만들기

int recoverMagicPoint(int currentMagicPoint, 
                      int originalMaxMagicPoint, 
                      List<Integer> maxMagicPointIncrements,
                      int recoveryAmount) {
  // 1. 매직 포인트 최댓값 구하기
  int currentMaxMagicPoint = originalMaxMagicPoint;
  for (int each : maxMagicPointIncrements) {
    currentMaxMagicPoint += each;
  }

  // 2. 매직 포인트 회복하기
  final int recoverdMagicPoint = Math.min(currentMagicPoint + recoveryAmount, currentMaxMagicPoint);
  
  // 3. 매직 포인트 결과 반환
  return recoverdMagicPoint;
}

recoverMagicPoint 메소드의 파라미터들을 의미있는 단위의 클래스의 인스턴스 변수로 구성해보자.

class MagicPoint {
  private int currentAmount;
  private int originalMaxAmount;
  private final List<Integer> maxIncrements;

  int max() {
    // 매직 포인트 최댓값 구하기
    int amount = this.originalMaxAmount;
    for (int each : this.maxIncrements) {
      amount += each;
    }
    return amount;
  }

  void recover(final int recoveryAmount) {
    // 매직 포인트 회복하기
    currentAmount = Math.min(this.currentAmount + recoveryAmount, max());
  }

  int current() {
    // 매직 포인트 결과 반환
    return this.currentAmount;
  }
}
  • recoverMagicPoint 메소드 파라미터 -> MagicPoint 클래스의 인스턴스 변수화
  • recoverMagicPoint 메소드 로직 -> MagicPoint 클래스 메소드로 구현


5.6 메소드 체인

void equipArmor(int memberId, Armor newArmor) {
	if(party.members[memberId].equipments.canChange) {
		party.members[memberId].equipments.armor = newArmor;
	}
}

.으로 여러 메소드를 연결하여 리턴 값의 요소에 차례차례 접근하는 방법을 메소드 체인이라고 부른다. 이 방법도 응집도를 낮출 수 있어 좋지 않다.

party를 통해 members, equipments, canChange, armor에 접근 또는 값을 할당할 수 있다.
할당하는 코드가 여러 곳에 중복 작성될 가능성이 있다.

사용하는 객체 내부를 알아서는 안된다는 데메테르의 법칙이 있다. 메소드 체인은 이 법칙을 위반한다고 할 수 있다.

5.6.1 묻지 말고 명령하기

묻지 말고, 명령하기(Tell, Don't Ask)

  • 다른 객체 내부의 상태(변수)를 기반으로 판단하거나 제어하려하지 말고, 메소드로 명령해서 해당 객체가 알아서 판단하도록 설계하라는 격언!
class Equipments {
  private boolean canChange;
  private Equipment head;
  private Equipment armor;
  private Equipment arm;

  void equipArmor(final Equipment newArmor) {
    if (canChange) {
      this.armor = newArmor;
    }
  }
}

이를 가능케 하기 위해서 인스턴스 변수를 private으로 변경하여 외부에서 접근하지 못하게 하자.
자연스럽게 인스턴스 변수를 조회하거나 조작하는 로직은 클래스 내부에 존재하게 된다.

0개의 댓글