Java - static method

응집도와 static method의 상관관계

Posted by Yan on November 14, 2023

응집도

  • 모듈 내부에 있는 데이터와 로직 사이의 관계가 얼마나 강한지 나타내는 지표
  • 모듈은 클래스, 패키지, 레이어 등을 모두 포함한다.
  • 응집도가 높은 구조는 변경하기 쉬운 바람직한 구조
  • 응집도가 낮은 구조는 변경시 문제가 발생하기 쉽다.

응집도가 낮아지는 이유

1. static method의 오용

  • static 메서드로 정의하면 클래스의 인스턴스를 생성하지 않고도, 메서드를 호출할 수 있다.

🤢 static 메서드가 정의되어 있는 클래스를 데이터 클래스와 함께 사용하는 예시

1
2
3
4
5
6
// static 메서드가 정의되어 있는 OrderManager클래스
class OrderManager {
  static int add(int moneyAmount1, int moneyAmount2) {
    return moneyAmount1 + moneyAmount2;
  }
}
1
2
// moneyData클래스는 데이터 클래스
moneyData1.amount =  OrderManager.add(moneyData1.amount, moneyData2.amount);

이러한 구조의 문제 : 데이터는 moneyData에 있고 데이터를 조작하는 조직은 orderManager에 있다는 것 => 데이터와 로직이 서로 다른 클래스에 작성되어 있음

  • static 메소드는 인스턴스 변수를 사용할 수 없다. 따라서 어떤 메소드를 static 메소드로 만든 시점에 이미 데이터와 로직 사이에 괴리가 생긴다. 당연히 응집도가 낮아질 수밖에 없다.

인스턴스 변수를 사용하는 구조로 변경해 응집도를 높인다.

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

👌 금액을 인스턴스 변수 amount로 갖게 하고, add 메서드는 amount를 사용하여 계산하도록 설계하기

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
import java.util.Currency;

class Money {
  final int amount;
  final Currency currency;

  Money(final int amount, final Currency currency) {
    if (amount < 0) {
      throw new IllegalArgumentException("금액은 0 이상의 값을 지정해 주세요.");
    }

    if (currency == null) {
      throw new NullPointerException("통화 단위를 지정해 주세요");
    }

    this.amount = amount;
    this.currency = currency;
  }

  Money add(final Money other) {
    if (!currency.equals(other.currency)) {
      throw new IllegalArgumentException("통화 단위가 다릅니다");
    }

    final int added = amount + other.amount;
    return new Money(added, currency);
  }
}

인스턴스 메서드인 척 하는 static 메서드를 잡아라

  • static 키워드가 붙어있지 않지만, static 메서드와 같은 문제를 갖고 있는 인스턴스 메소드도 있다.
1
2
3
4
5
6
7
class PaymentManager {
  private int discountRate; 

  int add(int moneyAmount1, int moneyAmount2) {
    return moneyAmount1 + moneyAmount2;
  }
}
  • add 메소드는 인스턴스 메소드이지만 인스턴스 변수를 전혀 사용하지 않고 있음
  • add 메소드에 static 키워드를 붙여도 아무 문제 없이 동작할 것.
  • 이렇게 인스턴스 메소드인 척 하는 static 메소드도 응집도를 낮추고 있음.
  • 구분하기 어렵다면 메소드 앞에 static 키워드를 추가해보자
    • IDE가 ‘내부에서 인스턴스 변수가 사용되고 있다’는 오류를 발생시키거나 컴파일 자체가 되지 않으면 인스턴스 변수를 사용하는 것이다.
    • 컴파일/정적코드 분석에 아무 오류가 없다면 인스턴스 메소드인 척 하는 static 메소드인 것.

static 메소드를 사용하는 이유

  • 절차지향 언어의 접근 방법을 객체지향 언어에서도 사용하려고 하기 때문.
  • 절차지향 언어에서는 데이터와 로직이 따로 존재하도록 설계함
  • 이러한 접근 방식으로 객체지향 언어를 사용하면 클래스의 인스턴스를 생성하지 않고도 사용할 수 있는 static 메소드를 활용하게 되는 것.
  • static 메소드는 클래스의 인스턴스를 만들지 않아도 되므로, 간단하게 사용할 수 있지만 응집도를 낮추는 문제를 일읨.

어떤 상황에서 static 메소드를 사용해도 되는지?

  • 응집도의 영향을 받지 않은 경우
  • 예) 로그 출력 전용 메소드, 포맷 변환 전용 메소드 같이 응집도와 관계 없는 기능, 팩토리 메소드

2. 초기화 로직 분산

🤢 생성자를 public으로 만든 예시

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
class GiftPoint {
  private static final int MIN_POINT = 0;
  final int value;

  GiftPoint(final int point) {
    if (point < MIN_POINT) {
      throw new IllegalArgumentException("포인트는 0 이상 입력해야 합니다.");
    }

    value = point;
  }
  
  /**
   * 포인트 추가하기
   * 
   * @param  other 추가 포인트
   * @return 추가 후 남은 포인트 
   */
  GiftPoint add(final GiftPoint other) {
    return new GiftPoint(value + other.value);
  }

  /**
   * @return 남은 포인트가 소비 포인트 이상이라면 true
   */
  boolean isEnough(final ConsumptionPoint point) {
    return point.value <= value;
  }

  /**
   * 포인트 소비하기
   * 
   * @param  point 소비 포인트
   * @return 소비 후 남은 포인트
   */
  GiftPoint consume(final ConsumptionPoint point) {
    if (!isEnough(point)) {
      throw new IllegalArgumentException("포인트가 부족합니다.");
    }
    return new GiftPoint(value - point.value);
  }
}

포인트를 추가하는 메소드와 소비하는 메소드가 정의되어 있으므로, 기프트 포인트와 관련된 로직이 데이터와 응집되어 있는 것 처럼 보이지만 그렇지 않다.

1
2
3
4
5
// 표준 회원 가입 포인트
GiftPoint standardMembershipPoint = new GiftPoint(3000);

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

생성자를 public 으로 만들면, 의도하지 않은 용도로 사용될 수 있다.

private 생성자와 팩토리 메소드를 사용해 목적에 따라 초기화하자

  • 초기화 로직의 분산을 막으려면 생성자를 private으로 만들고, 목적에 따라 팩토리 메소드를 만들면 된다.
  • 인스턴스를 생성하기 위한 static 팩토리 메소드에서 생성자를 호출하여 사용한다.
  • 팩토리 메소드는 목적에 따라 만들어 두는 것이 일반적이다.

👌 팩토리 메소드를 사용한 GiftPoint 클래스

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
class GiftPoint {
  private static final int MIN_POINT                  = 0;
  private static final int STANDARD_MEMBERSHIP_POINT  = 3000;
  private static final int PREMIUM_MEMBERSHIP_POINT   = 10000;
  final int                value;

  // 외부에서는 인스턴스를 생성할 수 없다. 클래스 내부에서만 생성할 수 있다.
  private GiftPoint (final int point) {
    if (point < MIN_POINT) {
      throw new IllegalArgumentException("포인트는 0 이상이어야 합니다.");
    }
    
    value = point;
  }

  /**
   * @return 표준 가입 기프트 포인트
   */
  static GiftPoint forStandardMembership() {
    return new GiftPoint(STANDARD_MEMBERSHIP_POINT);
  }

    /**
   * @return 프리미엄 가입 기프트 포인트
   */
  static GiftPoint forPremiumMembership() {
    return new GiftPoint(PREMIUM_MEMBERSHIP_POINT);
  }
  // 생략
}

신규 가입 포인트와 관련된 로직이 GiftPoint클래스에 응집된다. 표준 회원 가입, 프리미엄 회원 가입 로직은 팩토리 메소드를 사용하는 형태로 개선할 수 있다.

1
2
3
4
5
// 표준 회원 가입 포인트
GiftPoint standardMembershipPoint = GiftPoint.forStandardMembership();

// 프리미엄 회원 가입 포인트
GiftPoint premiumMembershipPoint = GiftPoint.forPremiumMembership();

생성 로직이 너무 많아지면 팩토리 클래스를 고려하기

상황에 따라 생성 로직이 너무 많아져서 해당 클래스가 무엇을 하는 클래스인지 알아보기 어려워질 때, 생성 전용 팩토리 클래스를 분리하는 것도 괜찮다.

3. 범용 처리 클래스 Common Util

  • static 메소드를 빈번하게 볼 수 있는 클래스로, 범용 처리를 위한 클래스가 있는데, 응집도가 낮은 구조가 만들어질 수 있는 위험이 있다.

너무 많은 로직이 한 클래스에 모이는 문제

  • CommonUtil이 ‘범용’이라는 뜻이기 때문에, 범용적으로 사용하고 싶은 로직을 Common 클래스에 모두 모아두게 되면서 관련 없는 범용 처리가 한 클래스에 모이기도 한다.

  • 꼭 필요한 경우가 아니면, 범용처리 클래스를 만들지 않는 것이 좋다.

횡단 관심사 (cross-cutting concern)

모든 동작에 필요한 기능들을 횡단 관심사에 해당하는 기능이라고 볼 수 있는데, 이런 기능은 범용 코드로 만들어도 괜찮다.

  • 로그출력
  • 오류 확인
  • 디버깅
  • 예외 처리
  • 캐시
  • 동기화
  • 분산처리
1
2
3
4
5
6
try {
  shoppingCart.add(product);
} catch (IllegalArgumentException e) {
  // report는 로그 출력 전용 static 메소드
  Logger.report("문제가 발생했습니다. 장바구니에 상품을 추가할 수 없습니다.");
}

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

출력으로 사용되는 매개변수를 출력 매개변수라고 한다. 매개변수를 잘못 다루면 응집도가 낮아지는 문제가 발생한다.

🤢 매개변수를 변경하고 있는 코드 예제

1
2
3
4
5
6
7
class ActorManager {
  // 게임 캐릭터 위치를 이동
  void shift(Location location, int shiftX, int shiftY) {
    location.x += shiftX;
    location.y += shiftY;
  }
}
  • 데이터 조작 대상은 Location, 조작 로직은 ActorManager
  • 데이터와 로직이 각자 다른 클래스에 있는 경우 -> 응집도가 낮은 구조
  • 응집도가 낮은 구조는 중복을 만듦. 의도하지 않게 다른 사람이 같은 코드를 다른 클래스에 구현할 수도 있음.
  • 매개변수는 입력으로 전달하는 것이 일반적이고, 출력으로 사용해 버리면 매개변수가 입력인지 출력인지 메소드 내부의 로직을 확인해야 한다.
  • 출력 매개변수로 설계하지 말고, 객체 지향 설계의 기본으로 돌아가서 ‘데이터’와 ‘데이터를 조작하는 로직’을 같은 클래스에 배치해야 한다.

👌 매개변수를 변경하지 않는 구조로 개선

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
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) {
    final int nextX = x + shiftX;
    final int nextY = y + shiftY;
    return new Location(nextX, nextY);
  }
}

5. 매개변수가 너무 많은 경우

  • 메소드에 매개변수를 전달한다는 것은 해당 매개변수를 사용해서 어떤 기능을 수행하고 싶다는 의미 -> 매개변수가 많다는 것은 많은 기능을 처리하고 싶다는 의미가 된다.
  • 처리할 게 많아지면 로직이 복잡해지거나, 중복 코드가 생길 가능성이 높아진다.

기본 자료형에 대한 집착

  • primitive type에 집착하면, 코드 중복이 쉽게 발생한다.

🤢 기본 자료형에 집착하는 코드 예제

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
class Common {
  /**
   * @param regularPrice 정가
   * @param discountRate 할인율
   * @return 할인가격
   */
  int discountedPrice(int regularPrice, float discountRate) {
    if (regularPrice < 0) {
      throw new IllegalArgumentException();
    }

    if (discountRate < 0.0f) {
      throw new IllegalArgumentException();
    }
  }
}

class Util {
  /**
   * @param regularPrice 정가
   * @return 적절한 가격이라면 true
   */
  boolean isFairPrice(int regularPrice) {
    if (regularPrice < 0) {
      throw new IllegalArgumentException();
    }
  }
}
  • 기본 자료형 만으로도 동작하는 코드를 작성할 수 있지만, 관련 있는 데이터와 로직을 집약하기 힘들다.
  • 데이터를 사용한 계산과 제어 로직이 모두 분산될 수 있다.
  • 객체지향 설계를 기반으로 일단 관련있는 것을 클래스로 만들기 위해서 접근 방법을 바꿔야 한다.

👌 ‘정가’라는 구체적인 자료형 설계하고, 유효성 검사를 캡슐화하기

1
2
3
4
5
6
7
8
9
10
11
12
13
class RegularPrice {
  final int amount;

  /**
   * @param amount 금액
   */
  RegularPrice(final int amount) {
    if (amount < 0) {
      throw new IllegalArgumentException();
    }
    this.amount = amount;
  }
}

👌 ‘할인 요금’ 클래스에 기본 자료형이 아니라 클래스 자료형 전달하기

1
2
3
4
5
6
7
8
9
10
11
12
class DiscountedPrice {
  final int amount;

  /**
   * @param regularPrice 정가
   * @param discountRate 할인율
   * @return 할인가격
   */
  DiscountedPrice(final RegularPrice regularPrice, final DiscountPrice discountPrice) {
    // regualrPrice와  DiscountRate를 사용하여 계산
  }
}

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

매개변수가 너무 많아지는 문제를 피하려면, 개념적으로 의미 있는 클래스를 만들어야한다.

🤢 매개변수가 너무 많은 메소드 예제

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/**
 * 매직포인트 회복하기
 * @param currentMagicPoint 현재 매직포인트 잔량
 * @param originalMaxMagicPoint 원래 매직포인트 최댓값
 * @param maxMagicPointIncrements 장비로 증가하는 매직포인트 최댓값 증가량
 * @param recoveryAmount 회복량
 * @return 회복 후의 매직포인트 잔량
 */
int recoverMagicPoint(int currentMagicPoint, int originalMaxMagicPoint, 
              List<Integer> maxMagicPointIncrements, int recoveryAmount) {
  int currentMaxMagicPoint = originalMaxMagicPoint;
  for (int each : maxMagicPointIncrements) {
    currentMaxMagicPoint += each;
  }
}

👌 매개변수가 아니라 인스턴스 변수로 표현한 매직포인트 예제

1
2
3
4
5
6
7
8
9
10
class MagicPoint {
  // 현재 잔량
  int currentAmount;

  // 원래 최댓값
  int originalMaxAmount;

  // 장비 착용에 따른 최댓값 증가량
  List<Integer> maxIncrements;
}
  • 다른 클래스에서 불필요한 조작을 하지 못하게 인스턴스 변수를 private으로 변경

👌 매직포인트와 관련된 로직 캡슐화하여 응집하기

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
class MagicPoint {
  private int currentAmount;
  private int originalMaxAmount;
  private final List<Integer> maxIncrements;

  // 생략

  /**
   * @return 현재 매직포인트 잔량
   */
  int current() {
    return currentAmount;
  }

  /**
   * @return 매직포인트 최댓값
   */
  int max() {
    int amount = originalMaxAmount;
    for (int each : maxIncrements) {
      amount += each;
    }
    return amount;
  }

  /**
   * 매직포인트 회복하기
   * @param recoveryAmount 회복량
   */
  void recover(final int recoveryAmount) {
    currentAmount = Max.min(currentAmount + recoveryAmount, max());
  }

  /**
   * 매직포인트 소비하기
   * @param consumeAmount 소비량
   */
  void consume(final int consumeAmount) {
    //생략
  }
}

6. 메소드 체인

메소드 체인 : .으로 여러 베소드를 연결해서 리턴 값의 요소에 차례차례 접근하는 방법

  • 전역변수와 같이 어디서든 아무 요소에나 접근할 수 있는 구조라서 응집도를 낮춘다.
  • 메소드 체인으로 내부 구조를 돌아다닐 수 있느게 하는 설계는 데메테르의 법칙을 위반한다.

    데메테르의 법칙 “사용하는 객체 내부를 알아서는 안된다.”

‘묻지 말고 명령하기’

  • 인스턴스 변수를 private으로 변경해서 외부에서 접근할 수 없도록 하라.
  • 다른 객체의 내부 상태(변수)를 기반으로 판단하거나 제어하려고 하지 말고, 메소드로 명령해서 객체가 알아서 판단하고 제어하도록 설계해라.
reference

내 코드가 그렇게 이상한가요?