오봉이와 함께하는 개발 블로그

결합도와 책무 - 내 코드가 그렇게 이상한가요? 본문

자바

결합도와 책무 - 내 코드가 그렇게 이상한가요?

오봉봉이 2023. 11. 14. 23:36
728x90

결합도

코드를 작성할 때 중요한 부분은 특정 단위(클래스 혹은 모듈 등이지만, 이제 클래스라고 표현 하겠다) 사이에서 결합도를 낮추는 코드를 작성하는 것이다.
여기서 결합도란 클래스 사이의 의존도라 할 수 있겠다.
클래스A와 클래스B의 결합도가 높다면 높은 확률로(필연적이라 표현해도 문제 없을 거 같다.) 클래스A를 수정할 때 클래스B도 같이 수정해야 할 것이다.
혹은 클래스A만 수정했는데 클래스B에서 원하지 않는 사이드이펙트가 발생할 것이다. 이 외에도 새로운 클래스C를 작성해서 문제를 풀어내야 할 수도 있을 것이다.
보통 우리는 회사를 다닐테고, 회사의 목적은 이윤창출. 즉 회사에서 하는 모든 일은 비용을 생각해야 하기 때문에 유지보수 비용이 낮은 코드를 작성해야 한다. 또한 그래야만 우리 같은 개발자들의 변태같은 만족감도 충족시켜주지 않나... 하는 생각도 한다.

그럼 결합도를 낮추기 위해서는 어떤 생각을 가지고 코드를 작성해야 하냐 묻는다면

클래스를 생성한 이유와 책임에 대해 명확하게 정의하여 해당 클래스만의 책임을 갖도록, 즉 단일 책임 원칙을 지켜야 한다.

고 하고 싶다.

이제부터 코드를 통해 확인해보자.

코드 예시

아래 코드는 온라인 쇼핑몰을 예시로 하여 작성된 코드다.
쇼핑몰에 할인 서비스가 추가되었을 때 요구 사항은 다음과 같다.

  • 하나당 3,000원 할인
  • 최대 200,000원까지 상품 추가 가능
public class DiscountManager {
    List<Product> discountProducts;
    int totalPrice;

    public boolean add(Product product, ProductDiscount productDiscount) {
        if (product.id < 0) {
            throw new IllegalArgumentException();
        }
        if (product.name.isEmpty()) {
            throw new IllegalArgumentException();
        }
        if (product.price < 0) {
            throw new IllegalArgumentException();
        }
        if (product.id != productDiscount.id) {
            throw new IllegalArgumentException();
        }
        int discountPrice = getDiscountPrice(product.price);

        int tmp;
        if (productDiscount.canDiscount) {
            tmp = totalPrice + discountPrice;
        } else {
            tmp = totalPrice + product.price;
        }

        if (tmp <= 200000) {
            totalPrice = tmp;
            discountProducts.add(product);
            return true;
        }
        return false;
    }
    public static int getDiscountPrice(int price) {
        int discountPrice = price - 3000;
        if (discountPrice < 0) {
            discountPrice = 0;
        }
        return discountPrice;
    }
}

public class Product {
    int id;
    String name;
    int price;
}

public class ProductDiscount {
    int id;
    boolean canDiscount;
}

DiscountManager.add는 아래 기능이 있다.

  • 유효성 검사
  • getDiscountPrice를 통해 할인 가격 계산
  • ProductDiscount.canDiscount를 통해 할인 가능한 경우 할인 가격을 더하고, 불가능한 경우 원래 가격 더하기
  • 총 가격이 200,000원 이내일 경우 상품 리스트에 추가

이후 PM의 요구사항으로 여름 특별 할인을 요청하여 DiscountManager를 개발한 개발자가 아닌 다른 개발자가 개발을 해야 한다.

  • 상품 하나당 3,000원 할인
  • 최대 300,000원까지 상품 추가 가능

이제 다른 개발자는 SummerDiscountManager를 구현한다.

public class SummerDiscountManager {
    DiscountManager discountManager;

    boolean add(Product product) {
        if (product.id < 0) {
            throw new IllegalArgumentException();
        }
        if (product.name.isEmpty()) {
            throw new IllegalArgumentException();
        }

        int tmp;
        if (product.canDiscount) {
            tmp = discountManager.totalPrice + DiscountManager.getDiscountPrice(product.price);
        } else {
            tmp = discountManager.totalPrice + product.price;
        }
        if (tmp < 300000) {
            discountManager.totalPrice = tmp;
            discountManager.discountProducts.add(product);
            return true;
        }
        return false;
    }
}

public class Product {
    int id;
    String name;
    int price;
    boolean canDiscount; // SummerDiscountManager를 위해 새로 추가한 필드
}

SummerDiscountManage.add는 다음과 같이 실행되지만 기존 DiscountManager.add와 유사하게 실행된다.

  • 유효성 검사
  • 할인 금액 계산 (DiscountManager.getDiscountPrice를 통해 계산)
  • product.canDiscount를 통해 할인 가능한 경우 할인 후 가격 더하고, 불가능한 경우 원래 상품 가격을 더한다.
  • 총 가격이 300,000원 이내일 경우 상품 리스트에 추가

요구사항 변경

쇼핑몰의 공격적인 경영을 위해 기존 일반 할인이 3,000원에서 4,000원으로 올랐다고 가정하자.

그럼 DiscountManager.getDiscountPrice를 변경해야 하는데 다음과 같이 변경했다.

public class DiscountManager {
    // 생략
    public static int getDiscountPrice(int price) {
        int discountPrice = price - 4000;
        if (discountPrice < 0) {
            discountPrice = 0;
        }
        return discountPrice;
    }
}

여기서 무슨 문제가 발생하나면 DiscountManager.getDiscountPrice를 SummerDiscountManager도 사용하기 때문에 여름 할인도 4,000원씩 할인되는 문제가 발생한다.

문제점

현재 DiscountManager(SummerDiscountManager)는 상품 정보 확인, 할인 가격 계산, 할인 적용 여부 판단, 총액 상환 확인 등 너무 많은 일을 하고 있다.
product의 필드로 존재하는 정보의 유효성 검사 또한 DiscountManager(SummerDiscountManager)가 하고 있고, ProductDiscount.canDiscount와 Product.canDiscount의 이름이 유사해서 어떤 것이 일반 할인을 위해 적용하고 여름 할인을 위해 적용하는지 구분이 어렵다.
또한 여름 할인 가격 계산을 위해 DiscountManager.getDiscountPrice를 적용해서 사용한다.

DiscountManager(SummerDiscountManager)는 하는 일이 많은 반면 Product와 ProductManager는 하는 일이 너무 없는 게으른 클래스가 되었다.

단일 책임 원칙

소프트웨어(어플리케이션)에는 다양한 관심사가 있지만, 다양한 관심사 중 특정 하나의 관심사를 구현하기 위한 코드에는 하나의 관심사에 대한 코드만 작성해야 한다는 것이 단일 책임 원칙이라고 간단하게 표현할 수 있다.
단익 책임 원칙을 생각하고 위 예시 코드들을 보면 문제가 있다는 것을 느낄 수 있을 것이다.

예시 코드에서 보면 DiscountManager.getDiscount는 일반 할인을 위해 설계된 메서드지만 여름 할인을 위해 사용되거나 DiscountManager.add에서 Product의 유효성을 검사하고 있다.

코드 수정

새로운 클래스 RegularPrice를 생성하자. RegularPrice에는 생성 시 유효성 검사를 진행하기 때문에 다른 코드에서 유효성 검사를 진행할 필요가 없어 코드 중복이 사라질 것이다.

public class RegularPrice {
    private static final int MIN_AMOUNT = 0;
    public final int amount;

    public RegularPrice(final int amount) {
        if (amount < MIN_AMOUNT) {
            throw new IllegalArgumentException("가격은 0 이상이어야 합니다.");
        }
        this.amount = amount;
    }
}

이제 일반 할인과 여름 할인을 개별적으로 책임지는 클래스를 만들어보자.

public class RegularDiscountedPrice {
    private static final int MIN_AMOUNT = 0;
    private static final int DISCOUNT_AMOUNT = 4000;
    public final int amount;

    public RegularDiscountedPrice(final RegularPrice price) {
        int discountedAmount = price.amount - DISCOUNT_AMOUNT;
        if (discountedAmount < MIN_AMOUNT) {
            discountedAmount = MIN_AMOUNT;
        }
        this.amount = discountedAmount;
    }
}


public class SummerDiscountedPrice {
    private static final int MIN_AMOUNT = 0;
    private static final int DISCOUNT_AMOUNT = 3000;
    public final int amount;

    public SummerDiscountedPrice(final RegularPrice price) {
        int discountedAmount = price.amount - DISCOUNT_AMOUNT;
        if (discountedAmount < MIN_AMOUNT) {
            discountedAmount = MIN_AMOUNT;
        }
        this.amount = discountedAmount;
    }
}

이제 각각 구분되어 동작하기 때문에 일반 할인 정책이 변경되어도 여름 할인 정책은 변경되지 않는다.
위 코드와 같이 관심사가 각각 분리되어 있는 구조를 느슨한 결합(Loose coupling)이라 한다.

DRY(Don't Repeat Yourself) 원칙

하지만 RegularDiscountedPrice와 SummerDiscountedPrice는 DISCOUNT_AMOUNT의 값만 제외하고 모든 코드가 동일하다.
이러면 나와 같은 사람은 중복 코드가 작성되어 매우 불편함을 느낄 것이다.
만약 여기서 여름 할인 혹은 일반 할인이 DISCOUNT_AMOUNT를 통해 고정된 할인 가격을 제공하는 것이 아니라 상품 가격의 n%를 할인 하거나 하는 등 요구 사항이 변경된다면 RegularDiscountedPrice와 SummerDiscountedPrice의 로직은 상이하게 달라질 것이다.

여기서 얻을 수 있는 아이디어는 책임을 생각하지 않고 마냥 코드가 중복된다고 공통 클래스로 생성하여 여기 저기서 사용하게 된다면 결국 일을 두배로 하게 된다는 것이다.
즉, 책임을 생각하지 않고 중복 코드를 제거하는 패착을 두어선 안 된다.

DRY(Don't Repeat Yourself) 이라는 것이 있는데 반복을 피해라 라는 의미이다. DRY를 생각해서 중복 코드는 작성하지 말아야지 라고 생각할 수 있는데(어설프게 생각하고 코드를 작성하면 화자는 지금도 그렇게 하는 거 같다) DRY를 적용하는 기준은 전체 코드에서가 아니라 각 클래스에서의 책임을 기준으로 두고 적용하는 것이 맞다고 이해하면 될 거 같다.
책임이 다름에도 불구하고 중복 코드를 제거하면 어느 순간 클래스 간 결합도가 높아지는 결과를 맞이하게 될 것이다.
어느 순간 단일 책임 원칙이 깨지게 되어 비용 절감을 위해 한 일이 오히려 비용을 늘리는 일을 한 것이 될 수 있다.

To-Do

이제 강한 결합에서 발생하는 문제를 확인했으니, 다음 글은 강한 결합을 풀어내는 방법에 대해 작성하겠다.

728x90
Comments