ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • 8. 강한 결합 : 복잡하게 얽혀서 풀 수 없는 구조
    Study/내 코드가 그렇게 이상한가요? 2024. 1. 17. 00:44
    반응형

    결합도란 '모듈 사이의 의존도를 나타내는 지표'라고 할 수 있다. 어떤 클래스가 다른 클래스에 많이 의존하고 있는 구조를 강한결합(tightlycoupling)이라고 부른다. 강한 결합 코드는 이해하기 힘들고, 변경하기도 굉장히 힘들다. 이 장에서는 강한 결합을 결합도가 낮은 구조인 느슨한 결합(loose coupling)으로 개선하여 코드 변경과 이해가 쉬운 구조를 만드는 방법을 알아본다.

    1. 결합도와 책무

    소프트웨어가 출력, 금액 계산, 데이터베이스 등의 다양한 관심사를 가질 때, 만약 출력에 버그가 있는데 데이터베이스 로직을 수정하는 것은 말이 안 된다. 출력을 제대로 하는 것은 출력하는 로직의 책임이라고 할 수 있다. 즉 소프트웨어의 책임이란 '자신의 관심사와 관련해서, 정상적으로 동작하도록 제어하는 것'이라고 볼 수 있다. 이때 중요한 것이 바로 단일 책임 원칙이다.

    단일 책임 원칙
    모든 클래스는 하나의 책임만 가지며, 클래스는 그 책임을 완전히 캡슐화해야 한다. 클래스가 제공하는 모든 기능은 이 책임과 주의 깊게 부합해야 한다.

    단일 책임 원칙을 위반하는지를 유의깊게 살펴보면, 결합도와 관련된 문제들을 찾을 수 있다.

    책임이 하나가 되게 클래스 설계하기

    이러한 문제들을 해결하려면, 단일 책임 원칙을 지키도록 설계를 바꿔야 한다.

    // 정가 클래스
    class RegularPrice {
        private static final int MIN_AMOUNT = 0;
        final int amount;
        
        RegularPrice(final int amount) {
            if (amount < MIN_AMOUNT) {
                thorw new IllegalArgumentException("가격은 0 이상이어야 합니다.");
            }
            this.amount = amount;
        }
    }
    // 일반 할인 클래스
    class RegularDiscountedPrice {
        private static final int MIN_AMOUNT = 0;
        private static final int DISCOUNT_AMOUNT = 4000;
        final int amount;
        
        RegularDiscountedPrice(final RegularPrice price) {
            int discountedAmount = price.amount - DISCOUNT_AMOUNT;
            if (discountedAmount < MIN_AMOUNT) {
                discountedAmount = MIN_AMOUNT;
            }
            amount = discountedAmount;
        }
    }
    // 여름 할인 클래스
    class SummerDiscountedPrice {
        private static final int MIN_AMOUNT = 0;
        private static final int DISCOUNT_AMOUNT = 3000;
        final int amount;
        
        SummerDiscountedPrice(final RegularPrice price) {
            int discountedAmount = price.amount - DISCOUNT_AMOUNT;
            if (discountedAmount < MIN_AMOUNT) {
                discountedAmount = MIN_AMOUNT;
            }
            amount = discountedAmount;
        }
    }

    일반 할인 가격, 여름 할인 가격이 모두 할인 가격을 나타내는 클래스라고 하여도 각각은 다른 관심사를 가지고 있으므로 클래스를 분리하여 구분하였다. 이와 같이 관심사에 따라 분리해서 독립되어 있는 구조를 느슨한 결합이라고 부르고, 이러한 구조는 할인과 관련된 사양이 변경되어도 서로 영향을 주지 않는다. 일반적으로 느슨한 결합의 설계를 권한다.

    ※ 잘못된 예(강한 결합)
    일반 할인 클래스를 만든 후, 일반 할인 클래스를 참조하여 추가 로직을 더함으로써 여름 할인 클래스를 설계하는 경우. 이런 경우 일반 할인 클래스의 변경이 여름 할인 클래스에 영향을 줄 수 있다.

    DRY 원칙의 잘못된 적용

    위 코드에서 RegularDiscountedPrice와 SummerDiscountedPrice의 로직은 대부분 같다. 이를 보고 '중복 코드가 작성된 것은 아닐까?'라고 생각할 수 있다.

    DRY 원칙(Don't Repeat Yourself) : 반복을 피해라!

    위 원칙을 소개한 《실용주의 프로그래머 20주년 기념판》에서는 아래와 같이 설명한다.

    모든 지식은 시스템 내에서 단 한 번만, 애매하지 않고, 권위 있게 표현되어야 한다.

    여기서 '지식'을 소프트웨어가 대상으로 하는 '비즈니스 지식'의 관점에서 생가갷보면, 일반 할인과 여름 할인은 서로 다른 개념이다. DRY는 각각의 개념 단위 내에서 반복을 하지 말라는 의미이다. 같은 로직, 비슷한 로직이라도 개념이 다르면 중복을 허용해야 한다. 개념적으로 다른 것까지도 무리하게 중복을 제거하려고 하면, 단일 책임 원칙을 깨고 강한 결합 상태가 된다.

    2. 다양한 강한 결합 사례와 대처 방법

    강한 결합은 다양한 원인으로 발생한다. 다양한 강한 결합 사례와 대처 방법을 알아보자.

    2.1 상속과 관련된 강한 결합

    상속은 주의해서 다루지 않으면, 곧바로 강한 결합 구조를 유발하는 위험한 문법이다. (이 책에서는 상속 자체를 권장하지 않는다.) 예를 들어 살펴보자.

    // 물리 공격 클래스
    class PhysicalAttack {
        // 단일 공격 대미지 리턴
        int singleAttackDamage() { ... }
        
        // 2회 공격 대미지 리턴
        int doubleAttackDamage() { ... }
    }
    
    // 격투가는 물리 공격 시 추가 대미지 부여(단일 공격 20, 2회 공격 10)
    class FighterPhysicalAttack extends PhysicalAttack {
        @Override
        int singleAttackDamage() {
            return super.singleAttackDamage() + 20;
        }
        
        @Override
        int doubleAttackDamage() {
            return super.doubleAttackDamage() + 10;
        }
    }

    위 코드에서 FighterPhysicalAttack은 PhysicalAttack 클래스를 상속한다. 이러한 구조에서, 어느날 PhysicalAttack 클래스의 doubleAttackDamage 메서드의 구현이 개별 구현에서 singleAttackDamage()를 두 번 호출하도록 변경되었다고 하자. 이 때, 격투가의 doubleAttackDamage()는 10의 추가 피해가 아니라, 50(20+20+10)의 추가 피해를 입히게 되어 버그가 발생한다.

    이처럼 상속 관계에서 서브 클래스는 슈퍼 클래스에 굉장히 크게 의존한다. 따라서 서브 클래스는 슈퍼 클래스의 구조를 하나하나 신경 써야 하며, 슈퍼 클래스의 변화를 놓치는 순간 버그가 생길 수 있다.(하지만, 일반적으로 슈퍼 클래스는 서브 클래스를 딱히 신경 쓰지 않고 변경된다.)

    상속보다 컴포지션

    슈퍼 클래스 의존으로 인한 강한 결합을 피하려면, 상속보다 컴퓨지션을 사용하는 것이 좋다. 컴포지션이란 사용하고 싶은 클래스를 아래 코드처럼 private 인스턴스 변수로 갖고 사용하는 것을 의미한다.

    // 격투가 물리 공격 클래스(컴포지션 사용)
    class FighterPhysicalAttack {
        private final PhysicalAttack physicalAttack;
        
        ...
        
        int singleAttackDamage() {
            return physicalAttack.singleAttackDamage() + 20;
        }
        
        int doubleAttackDamage() {
            return physicalAttack.doubleAttackDamage() + 10;
        }
    }

    이처럼 컴포지션 구조를 사용하면, PhysicalAttack의 로직을 변경해도 FighterPhysicalAttack이 영향을 적게 받는다.

    이처럼 상속은 반드시 단일 책임 원칙을 염두에 두고 구현하여야 한다. 그리고 값 객체와 컴포지션 등 다른 설계를 사용할 수는 없는지 반드시 검토해야 한다.

    2.2 인스턴스 변수별로 클래스 분할이 가능한 로직

    // 온라인 쇼핑몰에서 사용되고 있는 가상의 코드
    class Util {
        private int reservationId;		// 상품 예약 ID
        private ViewSettings viewSettings;	// 화면 표시 설정
        private MailMagazine mailMagazine;	// 메일 매거진
        
        void cancelReservation() { ... }	// reservationId 사용
        
        void darkMode() { ... }	// viewSettings 사용
        
        void beginSendMail() { ... }	// mailMagazine 사용
    }

    cancelReservation(예약 취소), darkMode(다크 모드로 전환), beginSendMail(메일 보내기)은 모두 책임이 완전히 다른 메서드이지만, 모두 Util이라는 클래스 하나에 정의되어 있다. 책임이 다른 메서드가 한 클래스 안에 정의되어 있으면, 여러 문제가 발생할 수 있다. 즉, 위와 같은 Util 클래스는 아래와 같은 3개의 클래스로 분리하면, 강한 결합 문제가 사라진다.

    // 예약 클래스
    class Reservation {
        private final int reservatinoId;
        ...
        void cancel() { ... }
    }
    
    // 화면 모드 관련 클래스
    class ViewCustomizing {
        private final ViewSettings viewSettings;
        ...
        void darkMode() { ... }
    }
    
    // 메일 매거진 서비스 클래스
    class MailMagazineService {
        private final MailMagazine mailMagazine;
        ...
        void beginSend() { ... }
    }

    의존 관계가 단순했던 위의 Util 클래스와 다르게 클래스 간 의존 관계가 훨씬 복잡한 코드에서 클래스를 잘 분리하려면, 각각의 인스턴스 변수와 메서드가 무엇과 관련이 있는 지 잘 파악해야 한다. 관계를 파악할 때는 의존 관계를 그림으로 그려보면 도움이 된다.(영향 스케치)

    2.3 특별한 이유 없이 public 사용하지 않기

    특별한 이유 없이 붙이는 public은 강한 결합 구조를 만들 수 있습니다. 이러한 경우, 관계를 맺지 않았으면 하는 클래스끼리도 결합되어 영향 범위가 확대될 수 있고, 결과적으로 유지 보수가 어려운 강한 결합 구조가 된다. 강한 결합을 피하려면, 접근 수식자로 필요한 범위의 가시성을 적절하게 제어해야 한다. 자바의 접근 수식자는 아래와 같다.

    • public : 모든 클래스에서 접근 가능
    • protected : 같은 클래스와 서브 클래스에서 접근 가능
    • 없음 : 같은 패키지에서만 접근 가능, package private이라고 부름.
    • private : 같은 클래스에서만 접근 가능

    왜 접근 수식자를 생략한 디폴트 상태(기본 값)은 package private일까? 이는 패키지들의 불필요한 의존 관계를 피할 때 package private이 적절하기 때문이다. 패키지는 밀접한 클래스끼리 응집하게 설계하고, 패키지 바깥의 클래스와는 느슨하게 결합하도록 설계한다. 즉, 외부에서는 접근할 수 없게 하기 위해 package private이 적절한 것이다. 외부에 정말로 공개하고 싶은 클래스에만 한정해서 public을 붙인다.

    2.4 private 메서드가 너무 많다는 것은 책임이 너무 많다는 것

    private 메서드가 너무 많이 쓰인 클래스는 하나가 아닌 너무 많은 책임을 갖고 있을 가능성이 높다. 책임이 다른 메서드는 다른 클래스로 분리하는 것이 좋다.

    2.5 높은 응집도를 오해해서 생기는 강한 결합

    관련이 깊은 데이터와 로직을 한 곳에 모은 구조를 응집도가 높은 구조라고 한다. 응집도가 높다는 개념을 염두에 두고, 관련이 깊다고 생각되는 로직을 한곳에 모으려고 했지만, 결과적으로 강한 결합 구조를 만드는 상황은 매우 자주 일어난다. 각각의 개념을 분리해야, 느슨한 결합 구조로 만들 수 있다. 즉, '결합이 느슨하고 응집도가 높은 설계'가 한 덩어리가 되어야 한다.

    응집도를 높이는 설계를 할 때는 다른 개념이 섞여 들어와 강한 결합을 만드는게 아닌지 항상 주의해야 한다.

    이외에도 아래와 같은 강한 결합을 만드는 사례들이 있다.

    • 스마트 UI : 화면 표시에 관한 책무와 그렇지 않은 책무가 강하게 결합되어 있기 때문에 변경하기가 매우 힘들다.
    • 거대 데이터 클래스 : 거대 데이터 클래스는 다양한 데이터를 가지므로 수많은 유스케이스에서 사용된다. 결국, 전역 변수와 같은 성질을 띠게 되며, 여러 폐해를 불러 일으킨다.
    • 트랜잭션 스크립트 패턴 : 메서드 내부에 일련의 처리가 하나하나 길게 작성되어 있는 구조로, 남용하게 되면 응집도는 낮아지고 결합은 강해진다.
    • 갓 클래스 : 트랜잭션 스크립트 패턴에서 한 단계 나아간 단계이다. 하나의 클래스 내부에서 수천에서 수만 줄의 로직을 담고 있으며, 수많은 책임을 담당하는 로직이 난잡하게 섞여 있는 클래스이다.

    위에서 설명한 수많은 강한 결합 클래스에 대처하는 방법은 모두 같다. 바로, 객체 지향 설계와 단일 책임 원칙에 따라 제대로 설계하는 것이다.

    반응형
Designed by Tistory.