ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • 5. 응집도 : 흩어져 잇는 것들
    Study/내 코드가 그렇게 이상한가요? 2023. 12. 20. 21:48
    반응형

    응집도(cohension)란 '모듈 내부에 있는 데이터와 로직 사이의 관계가 얼마나 강한지 나타내는 지표'이다. 여기서 모듈은 클래스, 패키지, 레이어 등을 모두 포함할 수 있다. 응집도가 높은 구조는 변경하기 쉬우며 바람직한 구조인 반면, 응집도가 낮은 구조는 변경 시 문제가 발생하기 쉽다. 이러한 응집도를 낮추는 사례들을 알아보자.

    5.1 static 메서드 오용


    static 메서드를 오용하면 응집도가 낮아질 수 있다. 아래 예시를 살펴보자.

    // 주문을 관리하는 클래스
    class OrderManager {
        static int add(int moneyAmount1, int moneyAmount2) {
        	return moneyAmount1 + moneyAmount2;
        }
    }
    
    // static메서드를 사용하는 상황
    moneyData1.amount = OrderManager.add(moneyData1.amount, moneyData2.amount);

    이러한 경우 데이터는 MoneyData 클래스에 있고, 데이터를 조작하는 로직은 OrderManager에 있게 된다. 즉 데이터와 로직이 서로 다른 클래스에 작성되게 되어 응집도가 낮은 구조가 된다.

    5.1.1 static 메서드는 인스턴스 변수를 사용할 수 없음

    static 메서드는 인스턴스 변수를 사용할 수 없다. 따라서 static 메서드로 만든 시점에 이미 데이터와 데이터를 조작하는 로직 사이에 괴리가 생겨 응집도가 낮아진다.

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

    응집도란 '클래스 내부에서 데이터와 로직의 관계가 얼마나 강한지 나태내는 지표'라고 했다. 따라서 '인스턴스 변수'와 '인스턴스 변수를 사용하는 로직을 같은 클래스에 만드는 것이 응집도를 높이는 방법이다. OrderManager 클래스도 응집도가 높은 구조가 될 수 있도록 인스턴스 변수를 사용해서 계산하는 구조로 설계를 변경하는 것이 좋다.

    // 주문을 관리하는 클래스 V2
    class OrderManager {
        final int amount;
        
        int add(OrderManager increment) {
        	return amount + increment.amount;
        }
    }

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

    static 키워드가 붙어 있지 않을 뿐, static 메서드와 같은 문제를 갖고 있는 인스턴스 메서드도 주의해야 한다.

    class PaymentManager {
        private int discountRate;	// 할인율
        
        int add(int moneyAmount1, int moneyAmount2) {
        	return moneyAmount1 + moneyAmount2;
        }
    }

    위 코드에서 PaymentManager.add 메서드는 인스턴스 메서드이지만, 인스턴스 변수 discountRate를 전혀 사용하지 않는다. 이와 같은 메서드는 앞에 static 키워드를 붙여도 아무 문제 없이 동작한다.
    어떤 것이 인스턴스 메서드인 척하는 static 메서드인지 구분하는 쉬운 방법은, 메서드 앞에 static 키워드를 붙여보는 것이다. 메서드에서 인스턴스 변수가 사용되고 있다면 컴파일 오류가 발생할 것이다.

    5.1.4 왜 static 메서드를 사용할까?

    static 메서드를 사용하는 이유는 데이터와 로직이 따로 존재하도록 설계하는 절차 지향적인 접근 방법에서 기인한다. 우리가 사용하는 객체 지향 언어에서는 데이터와 로직을 하나의 클래스에 존재하도록 설계하여 응집도가 높은 코드를 설계해야 한다.

    5.1.5 어떠한 상황에서 static 메서드를 사용해야 좋을까?

    static 메서드를 사용하면 좋은 상황도 물론 있다. 아래의 경우와 같은 응집도의 영향을 받지 않는 경우에는 static 메서드를 사용해도 좋다.

    • 로그 출력 전용 메서드
    • 포맷 변환 전용 메서드
    • 팩토리 메서드(5.2.1)

     

    5.2 초기화 로직 분산


    클래스를 잘 설계해도, 초기화 로직이 분산되면 응집도가 낮은 구조가 될 수 있다.

    class GiftPoint {
        private static final int MIN_POINT = 0;
        final int value;
        
        GiftPoint(final int point) {	// Public 생성자
        	if (point < MIN_POINT) {
                throw new IllegalArgumentException("포인트를 0 이상 입력해야 합니다.");
            }
            value = point;
        }
        
        // ...
    }

    회원가입 시 지급하는 무료 포인트를 나타내는 위 클래스는 아래와 같이 사용될 수 있다.

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

    이 경우 회원 가입 포인트를 변경하고 싶을 때, 전체 소스를 확인해야 하는 응집도가 낮은 코드이다. 여러 곳에서 중복하여 인스턴스를 생성하여 사용하는 문제도 발생할 수 있다.

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

    생성자를 private으로 만들고, 대신 목적에 따라 팩토리 메서드를 만들어보자.

    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;
        
        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);
        }
        
        // ...
    }

    생성자를 private으로 만들면, 클래스 내부에서만 인스턴스를 생성할 수 있어서 응집도를 높일 수 있다. 인스턴스를 생성하는 기능은 static 팩토리 메서드에서 생성자를 호출하도록 추가한다. GiftPoint 클래스를 사용하는 회원 가입 로직은 아래와 같이 팩토리 메서드를 사용하는 형태로 바뀌게 된다.

    GiftPoint standardMembershipPoint = GiftPoint.forStandardMembership();
    GiftPoint premiumMembershipPoint = GiftPoint.forPrimiumMembership();

     
     
    상황에 따라 생성 로직이 너무 많아진다면, 생성 전용 팩토리 클래스를 분리하는 방법을 고려하는 것이 좋다.

    5.3 범용 처리 클래스 (Common/Util)


    Common, Util이라는 이름이 붙는 범용 처리 클래스는 static 메서드를 가장 빈번하게 볼 수 있는 클래스이다. 이러한 범용 처리 클래스도 여러 문제를 만들 수 있다.

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

    Common과 Util이라는 이름 자체가 '범용'이라는 뜻이기 때문에 이 코드를 읽는 사람은 '범용적으로 사용하고 싶은 로직은 Common 클래스에 모아 두면 되겠구나'라고 생각하게 만든다.
    근본적인 원인은 범용의 의미와 재사용성을 잘못 이해하고 있기 때문이다. 설계의 응집도를 높이면, 특정 데이터의 조작이 해당 클래스에서만 이루어지므로 재사용성이 저절로 높아지고, 범용적으로 사용될 일이 없어진다.
    꼭 필요한 경우가 아니라면, 범용 처리 클래스를 만들지 않는 것이 좋다.

    5.3.3 횡단 관심사

    로그 출력과 오류 확인 등 애플리케이션의 모든 동작에 필요한 기능들은 범용 코드로 만들어도 괜찮다. 다양한 상황에서 넓게 활용되는 기능을 횡단 관심사(cross-cutting concern)라고 부르며, 아래와 같은 것들이 있다.

    • 로그 출력
    • 오류 확인
    • 디버깅
    • 예외 처리
    • 캐시
    • 동기화
    • 분산 처리

     

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


     
    출력으로 사용되는 매개변수를 출력 매개변수라고 부른다. 출력 매개변수는 응집도가 낮아지는 문제를 발생시킨다.

    class ActorManager {
        // 게임 캐릭터 위치를 이동
        void shift(Location location, int shiftX, int shiftY) {
        	location.x += shiftX;
            location.y += shifty;
        }
    }

    ActorManager.shift 메서드는 이동 대상 인스턴스를 location으로 전달받고, 이를 변경한다. 즉, 조작되는 데이터의 대상은 Location 클래스에 있고, 조작 로직은 ActorManager 클래스에 있는 응집도가 낮은 구조이다.
     
    매개변수는 입력으로 전달하는 것이 일반적이다. 매개변수를 출력으로 사용해 버리면, 매개변수가 입력인지 출력인지 매번 메서드 내부 로직을 확인해야 하여 가독성이 좋지 않다. '데이터'와 '데이터를 조작하는 로직'은 하나의 클래스에 배치하여 응집도를 높인다.

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


    매개변수가 너무 많은 메서드는 응집도가 낮아지기 쉽다.

    /*
    * 매직포인트 회복하기
    * currentMaxMagicPoint 현재 매직포인트 잔량
    * originalMaxMagicPoint 원래 매직포인트 최댓값
    * maxMagicPointIncrements 장비로 증가하는 매직포인트 최댓값 증가량
    * recoveryAmount 회복량
    * @return 회복 후의 매직포인트 잔량
    */
    
    int recoverMagicPoint(int currentMaxMagicPoint, int originalMaxMagicPoint, List<Integer> maxMagicPointIncrements, int recoveryAmount) {
    	int currentMaxMagicPoint = originalMaxMagicPoint;
    	for (int each : maxMagicPointIncrements) {
        	currentMaxMagicPoint += each;
        }
    	return Math.min(currentMagicPoint + recoveryAmount, currentMagicPoint);
    }

    위와 같이 너무 많은 매개변수를 받는 메서드는 실수로 잘못된 값을 대입할 가능성이 높아진다.  메서드에 매개변수를 전달한다는 것은 해당 매개변수를 사용해서 어떤 기능을 수행하고 싶다는 의미이다. 즉, 매개변수가 많다는 것은 많은 기능을 처리하고 싶다는 의미가 된다. 하지만, 처리할 게 많아지면 로직이 복잡해지거나, 중복 코드가 생길 가능성이 높아진다.

    5.5.1 기본 자료형에 대한 집착

    recoverMagicPoint 메서드는 매개변수와 리턴 값에 모두 기본 자료형만 쓰고 있다. 이와 같이 기본 자료형을 남용하는 현상을 기본 자료형 집착(primitive obession)이라고 한다.
    기본 자료형만으로도 '동작하는 코드'를 작성할 수 있지만, 관련 있는 데이터와 로직을 집약하기 힘들고 중복 코드가 많이 생긴다. 이는 버그가 생기기 쉽고, 가독성이 떨어지는 코드로 이어진다.

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

    매개변수가 너무 많아지는 문제를 피하려면, 기본 자료형 집착에서 벗어나서 개념적으로 의미 있는 클래스를 만들면 된다.  recoverMagicPoint를 개선하기 위해 매직포인트를 나타내는 클래스 MagicPoint를 만들어보자.

    class MagicPoint {
    	private int currentAmount;
    	private int originalMaxAmount;
    	private final List<Integer> maxIncrements;
        
        // 현재 매직포인트 잔량
    	int current() {
    		return currentAmount;
    	}
    	
        // 매직포인트 최댓값
    	int max() {
    		int amount = originalMaxAmount;
    		for (int each : maxIncrements) {
    			amount += each;
    		}
    		return amount;
    	}
    	
        // 매직포인트 회복하기
        void recover(final int recoveryAmount) { ... }
        
        // 매직포인트 소비하기
        void consume(final int consumeAmount) { ... }
    }

    매직포인트와 관련된 인스턴스 변수들을 하나로 모으고, 관련된 로직들도 메서드로 만들어 한 클래스에 작성한다.

    int recoverMagicPoint(MagicPoint magicPoint, int recoveryAmount) {
    	// ...
    }

    이에 따라 recoveryMagicPoint는 위와 같이 변경된다.

    5.6 메서드 체인


    아래와 같이 .(점)으로 여러 메서드를 연결해서 리턴 값의 요소에 차례차례 접근하는 방법을 메서드 체인이라고 한다.

    /** 갑옷 입기
    * @param memberId 장비를 변경하고 싶은 멤버의 ID
    * @param newArmor 입을 갑옷
    */
    void equipArmor(int memberId, Armor newArmor) {
        if(party.members[memberId].equipments.canChange) {
        	party.members[memberId].equipments.armor = newArmor;
        }
    }

    이 방법도 응집도를 낮출 수 있는 좋지 않은 작정 방법이다. armor, member, equipments에 할당하는 코드는 어디에서나 작성될 수 있고, 이는 코드의 중복을 유발한다.

    데메테르의 법칙

    데메테르의 법칙은 사용하는 객체 내부를 알아서는 안 된다는 법칙이다. (모르는 사람에게 말을 걸지 않기). 메서드 체인으로 내부 구조를 돌아다닐 수 있는 설계는 항상 객체의 내부 구조를 알아야 하므로 데메테르의 법칙을 위반한다.

    5.6.1 묻지 말고 명령하기

    소프트웨어 설계에는 '묻지 말고, 명령하기(Tell, Don't Ask, TDA)'라는 유명한 격언(법칙?)이 있다. 이는 다른 객체 내부 상태(변수)를 기반으로 판단하거나 제어하려고 하지 말고, 메서드로 명령해서 객체가 알아서 판단하고 제어하도록 설계하라는 의미이다. (데메테르의 법칙과 일통하는 부분이 있다.).
    이를 적용한 좋은 설계는 인스턴스 변수를 private으로 변경해서 외부에서 접근할 수 없게 하고, 인스턴스 변수에 대한 제어는 외부에서 메서드로 명령하는 형태로 객체를 설계하여 상세한 판단과 제어는 명령을 받는 쪽에서 담당하게 하면 된다. 이에 따라 장비하고 있는 방어구 목록을 나타내는 Equipments 클래스를 정의해보자.

    class Equipments {
        private boolean canChange;
        private Equipment head;
        private Equipment armor;
        private Equipment arm;
        
        /**
        * 갑옷 장비하기
        * @param newArmor 장비할 갑옷
        */
        void equipArmor(final Equipment newArmor) {
        	if (canChange) {
                armor = newArmor;
            }
        }
        
        // ...
    }

    이렇게 하면 방어구의 탈착과 관련된 로직이 Equipments 클래스에 응집된다. 

    반응형
Designed by Tistory.