티스토리 뷰

728x90
반응형

서론


  • 객체지향 프로그래밍의 장점 중 하나는 코드를 재사용하기 쉽다는 것입니다. 전통적인 패러다임에서 코드를 재사용하는 방법은 코드를 복사한 후 수정하는 것입니다. 이와 다르게 객체지향은 코드를 재사용하기 위해 "새로운 코드"를 추가합니다. 
  • 코드를 재사용하기 위해 새로운 코드를 추가한다? 이 말이 쉽게 와닿지 않을 수 있습니다. 객체지향에서 코드는 일반적으로 클래스 내부에 작성되기 때문에 객체지향에서 클래스를 재사용하는 전통적인 방법은 새로운 클래스를 추가하는 것입니다. 무슨 말인지 모르겠지만 지금부터 천천히 알아보도록 하겠습니다.

 

상속과 중복 코드


  • 중복 코드는 우리들의 마음속에 의심과 불신의 씨앗을 뿌립니다. 지금 내가 보고 있는 코드가 예전에 본 코드와 비슷하다면 우리는 이거 중복 코드인가? 비슷한 코드가 있는데 이거는 무슨 용도지? 찰나의 순간 많은 생각을 들게 합니다. 이처럼 중복 코드는 우리들을 주저하게 만들 뿐만 아니라 동료를 의심하게 만듭니다.

💡 DRY 원칙

  • 중복 코드는 변경을 힘들게 합니다. 중복 코드가 가지는 가장 큰 문제는 코드를 유지 보수하는데 필요한 노력을 몇 배로 증가시킵니다. 
    일단 어떤 코드가 중복인지 파악하고 파악한 내용을 기반으로 테스트하여 실제로 중복인지 결과물을 확인하고 많은 시간과 노력을 필요로 합니다. 
  • 중복 여부를 판단하는 기준은 변경입니다. 요구사항이 변경되었을 때 두 코드를 함께 수정해야 한다면 중복 코드입니다. 함께 수정될 필요가 없다면 중복 코드가 아닙니다. 중복 코드를 결정짓는 것은 유사한 모양이 아닙니다. 유사한 모양이다라는 것은 중복의 징후일 뿐입니다. 즉 중복 여부는 변경에 어떻게 반응하느냐입니다.
  • DRY는 "반복하지 마라" 라는 뜻의 Don't Repeat Yourself의 첫 글자를 모아 만든 용어입니다.

 

💡 중복과 변경

  • 아래 예제는 전화 요금을 계산하는 애플리케이션 예제입니다. Phone 클래스는 "기본 전화 요금제"를 계산하는 클래스입니다. 하지만 이 애플리케이션이 성공적으로 출시되고 추후에 "심야 할인 요금제" 라는 새로운 요금 방식을 추가해야 한다는 요구사항입니다.
  • 새로 추가된 심야 할인 요금제는 거의 비슷한 구조라는 것을 알 수 있습니다.
@Getter
@AllArgsConstructor
public class Call {

    private LocalDateTime startTime; // 통화시작 시간
    private LocalDateTime endTime;   // 통화종료 시간

    public Duration getDuration() {
        return Duration.between(startTime, endTime);
    }
}

// 기본 전화 요금제
@Getter
public class Phone {

    private Money amount;
    private Duration seconds;
    private List<Call> calls = new ArrayList<>();

    public Phone(Money amount, Duration seconds) {
        this.amount = amount;
        this.seconds = seconds;
    }

    public void call(Call call) {
        calls.add(call);
    }

    public Money calculateFee() {
        Money result = Money.ZERO;
        for (Call call : calls) {
            result = result.plus(amount.times(call.getDuration().getSeconds() / seconds.getSeconds()));
        }
        return result;
    }
}

// 심야 할인 요금제
@Getter
public class NightlyDiscountPhone {

    private static final int LATE_NIGHT_HOUR = 22;
    private Money nightlyAmount;
    private Money regularAmount;
    private Duration seconds;
    private List<Call> calls = new ArrayList<>();

    public NightlyDiscountPhone(Money nightlyAmount, Money regularAmount, Duration seconds) {
        this.nightlyAmount = nightlyAmount;
        this.regularAmount = regularAmount;
        this.seconds = seconds;
    }
    
    public void call(Call call) {
        calls.add(call);
    }

    public Money calculateFee() {
        Money result = Money.ZERO;
        for (Call call : calls) {
            if (call.getStartTime().getHour() >= LATE_NIGHT_HOUR) {
                result = result.plus(nightlyAmount.times(call.getDuration().getSeconds() / seconds.getSeconds()));
            } else {
                result = result.plus(regularAmount.times(call.getDuration().getSeconds() / seconds.getSeconds()));
            }
        }
        return result;
    }
}

 

🧨 중복 코드가 가지는 문제점

  • 만약 위의 예제에서 세율을 적용해야 한다고 가정했을 때 Phone 클래스와 NightlyDiscountPhone 클래스에 수정이 이루어져야 합니다.
  • 또한 각 클래스의 calculateFee 메서드의 반환 로직을 세밀히 봐야 합니다. 세율 변수는 추가되었지만 결과를 반환하는 로직은 차이점이 존재합니다.
  • 이렇게 중복 코드가 늘어날수록 애플리케이션은 변경에 취약해지고 버그가 발생할 가능성이 높아집니다.
@Getter
public class Phone {

    private double taxRate; // 추가

    public Phone(... 생략, double taxRate) {
        this.taxRate = taxRate; // 추가
    }
	
    ... 생략

    public Money calculateFee() {
        Money result = Money.ZERO;
        for (Call call : calls) {
            result = result.plus(amount.times(call.getDuration().getSeconds() / seconds.getSeconds()));
        }
        return result.plus(result.times(taxRate)); // 세율 적용
    }
}

@Getter
public class NightlyDiscountPhone {

    private double taxRate; // 추가

    public NightlyDiscountPhone(... 생략, double taxRate) {
        this.taxRate = taxRate; // 추가
    }
    
    ... 생략

    public Money calculateFee() {
        Money result = Money.ZERO;
        for (Call call : calls) {
            if (call.getStartTime().getHour() >= LATE_NIGHT_HOUR) {
                result = result.plus(nightlyAmount.times(call.getDuration().getSeconds() / seconds.getSeconds()));
            } else {
                result = result.plus(regularAmount.times(call.getDuration().getSeconds() / seconds.getSeconds()));
            }
        }

        return result.minus(result.times(taxRate)); // 세율 적용
    }
}

 

💪 enum을 통해 중복 코드를 제거하는 방법

  • enum을 사용하여 클래스를 하나로 만들고 구분을 할 수 있습니다. 하지만 enum을 사용하게 되면 낮은 응집도와 높은 결합도라는 문제에 시달리게 됩니다.
public enum PhoneType {

    REGULAR, NIGHTLY
}

@Getter
public class Phone {

    private static final int LATE_NIGHT_HOUR = 22;
    private Money amount;
    private Money nightlyAmount;
    private Money regularAmount;
    private Duration seconds;
    private PhoneType phoneType;
    private List<Call> calls = new ArrayList<>();

    // 기본 전화 요금제
    public Phone(Money amount, Duration seconds) {
        this(amount, Money.ZERO, Money.ZERO, seconds, PhoneType.REGULAR);
    }

    // 심야 할인 요금제 
    public Phone(Money nightlyAmount, Money regularAmount, Duration seconds) {
        this(Money.ZERO, nightlyAmount, regularAmount, seconds, PhoneType.NIGHTLY);
    }

    public Phone(Money amount, Money nightlyAmount, Money regularAmount, Duration seconds, PhoneType phoneType) {
        this.amount = amount;
        this.nightlyAmount = nightlyAmount;
        this.regularAmount = regularAmount;
        this.seconds = seconds;
        this.phoneType = phoneType;
    }

    public void call(Call call) {
        calls.add(call);
    }

    public Money calculateFee() {
        Money result = Money.ZERO;
        for (Call call : calls) {
            if (phoneType == PhoneType.REGULAR) {
                result = result.plus(amount.times(call.getDuration().getSeconds() / seconds.getSeconds()));
            } else {
                if (call.getStartTime().getHour() >= LATE_NIGHT_HOUR) {
                    result = result.plus(nightlyAmount.times(call.getDuration().getSeconds() / seconds.getSeconds()));
                } else {
                    result = result.plus(regularAmount.times(call.getDuration().getSeconds() / seconds.getSeconds()));
                }
            }
        }
        return result;
    }
}

 

💪 상속을 통해 중복 코드를 제거하는 방법

  • 상속을 사용하게 되면 빠르게 새로운 코드를 추가할 수 있습니다. 하지만 이 방법에는 많은 문제들이 포함되어 있습니다. 
@Getter
public class Phone {

    private Money amount;
    private Duration seconds;
    private List<Call> calls = new ArrayList<>();

    public Phone(Money amount, Duration seconds) {
        this.amount = amount;
        this.seconds = seconds;
    }

    public void call(Call call) {
        calls.add(call);
    }

    public Money calculateFee() {
        Money result = Money.ZERO;
        for (Call call : calls) {
            result = result.plus(amount.times(call.getDuration().getSeconds() / seconds.getSeconds()));
        }
        return result;
    }
}

@Getter
public class NightlyDiscountPhone extends Phone {

    private static final int LATE_NIGHT_HOUR = 22;
    private Money nightlyAmount;

    public NightlyDiscountPhone(Money amount, Duration seconds, Money nightlyAmount) {
        super(amount, seconds);
        this.nightlyAmount = nightlyAmount;
    }

    @Override
    public Money calculateFee() {
        Money result = super.calculateFee(); // 부모의 메서드 호출
        Money nightlyFee = Money.ZERO;
        for (Call call : getCalls()) {
            if (call.getStartTime().getHour() >= LATE_NIGHT_HOUR) {
                nightlyFee = nightlyFee.plus(getAmount().minus(nightlyAmount).times(call.getDuration().getSeconds() / getSeconds().getSeconds()));
            }
        }
        return result.minus(nightlyFee);
    }
}

 

🧨 상속이 가지는 문제점

  • 부모 클래스와 자식 클래스 간에 결합도가 높아집니다. 부모 클래스에서 세율을 적용하기 위한 변수가 필요하다면 이 영향을 자식 클래스에도 미치게 됩니다.
  • 자식 클래스는 부모 클래스의 내부 구현에 대해 알고 있어야 합니다. 이는 캡슐화를 약하게 만듭니다.
  • 부모 클래스의 메서드가 자식 클래스의 가지고 있는 규칙을 깨트릴 수 있습니다.
  • 자식 클래스가 부모 클래스의 메서드를 오버라이딩할 경우 부모 클래스가 자신의 메서드를 사용하는 방법에 자식 클래스가 결합될 수 있습니다.
  • 상속으로 인해 결합도가 높아져 자식 클래스와 부모 클래스의 구현을 영원히 변경하지 않거나, 동시에 변경하거나 둘 중 하나를 선택해야 합니다. 즉 어느 한쪽이라도 변경이 발생하면 둘 다 수정이 발생한다는 것입니다.

 

취약한 기반 클래스


  • 위에서 살펴본 것처럼 상속은 부모 클래스와 자식 클래스 간에 결합도를 높입니다. 이 강한 결합도로 인해 자식 클래스는 부모 클래스의 불필요한 내부 구현까지 알아야 하며 부모의 작은 변경에도 자식 클래스는 영향을 받게 됩니다. 이처럼 부모 클래스의 변경에 의해 자식  클래스가 영향을 받는 현상을 취약한 기반 클래스 문제라 합니다.
  • 취약한 기반 클래스 문제는 상속이라는 문맥 안에서 결합도가 초래하는 문제점을 가리키는 용어입니다. 상속 관계를 추가할수록
    전체 시스템의 결합도가 높아집니다. 상속은 자식 클래스를 점진적으로 추가해 기능을 확장하는데는 유리하지만 높은 결합도로 인해 부모 클래스를 개선하는것은 어렵게 만듭니다.
  • 또한 취약한 기반 클래스 문제는 캡슐화를 약화시킵니다. 상속은 자식 클래스가 부모 클래스의 내부 구현에 의존하도록 만들기 때문에 캡슐화를 약화시킵니다.
  • 우리가 객체를 사용하는 이유는 퍼블릭 인터페이스 뒤로 내부 구현을 감출 수 있기 때문입니다. 내부 구현을 감춘다는 것은 변경에 의한 파급 효과를 제어할 수 있기 때문에 가치가 있고  불안정한 요소를 감춤으로써 파급 효과를 걱정하지 않고 자유롭게 내부 구현을 변경할 수 있습니다. 하지만 상속을 사용하면 부모 클래스의 퍼블릭 인터페이스를 변경하더라도 자식 클래스가 영향을 받기 쉬워지며 상위 계층의 작은 변경으로 인해 하위 계층은 쉽게 요동치게 됩니다.

 

🧨 불필요한 인터페이스 문제

  • 아래는 자바의 초기 버전에서 상속을 잘못 사용한 대표적인 사례입니다. 잘못된 부모 클래스를 상속받게 되면 하위 클래스가 가지고 있는 규칙을 깨트릴 수 있습니다.
Stack<String> stack = new Stack<>();
stack.push("1st");
stack.push("2nd");
stack.push("3rd");
stack.add(0, "4th");
System.out.println(stack.pop().equals("4th")); // false

Properties properties = new Properties();
properties.setProperty("String", "문자");
properties.setProperty("Integer", "숫자");
properties.setProperty("Double", "숫자");
properties.put("Float", 52.22);
System.out.println(properties.getProperty("Float")); // null

 

🧨 메서드 오버라이딩의 오작동 문제

 

리펙토링


  • 기존 Phone 클래스와 NightlyDiscountPhone 클래스는 상속으로 인해 많은 문제점을 가지고 있었습니다. 이를 리펙토링하는 시간을 가져보겠습니다.

💡 추상화에 의존하자

  • 부모 클래스와 자식 클래스 모두 추상화에 의존한다면 이 문제를 해결할 수 있습니다.

 

💡 상속을 도입할 때 따르는 두 가지 원칙

  • 두 메서드가 유사하게 보인다면 차이점을 메서드로 추출하라. 메서드 추출을 통해 두 메서드가 동일한 형태로 보이도록 만들 수 있다.
  • 부모 클래스의 코드를 하위로 내리지 말고 자식 클래스의 코드를 상위로 올려라. 부모 클래스의 구체적인 메서드를 자식 클래스로 내리는 것보다 자식 클래스의 추상적인 메서드를 부모 클래스로 올리는 것이 재사용성과 응집도 측면에서 더 뛰어난 결과물을 얻을 수 있다.

리펙토링된 코드

public abstract class AbstractPhone {

    private List<Call> calls = new ArrayList<>();

    // 변하지 않는 부분
    public Money calculateFee() {
        Money result = Money.ZERO;
        for (Call call : calls) {
            result = result.plus(calculateCallFee(call));
        }
        return result;
    }

    // 문맥에 따라 변하는 부분
    abstract protected Money calculateCallFee(Call call);
}

@Getter
public class RegularPhone extends AbstractPhone {

    private Money amount;
    private Duration seconds;

    public RegularPhone(Money amount, Duration seconds) {
        this.amount = amount;
        this.seconds = seconds;
    }

    @Override
    protected Money calculateCallFee(Call call) {
        return amount.times(call.getDuration().getSeconds() / seconds.getSeconds());
    }
}

@Getter
public class NightlyDiscountPhone extends AbstractPhone {

    private static final int LATE_NIGHT_HOUR = 22;
    private Money nightlyAmount;
    private Money regularAmount;
    private Duration seconds;

    public NightlyDiscountPhone(Money nightlyAmount, Money regularAmount, Duration seconds) {
        this.nightlyAmount = nightlyAmount;
        this.regularAmount = regularAmount;
        this.seconds = seconds;
    }

    @Override
    protected Money calculateCallFee(Call call) {
        if (call.getStartTime().getHour() >= LATE_NIGHT_HOUR) {
            return nightlyAmount.times(call.getDuration().getSeconds() / seconds.getSeconds());
        } else {
            return regularAmount.times(call.getDuration().getSeconds() / seconds.getSeconds());
        }
    }
}

 

💡 어떤 과정을 통해 이루어 졌는가?

 

  1. 차이를 메서드로 추출하라
    • 변하는 부분과 변하지 않는 부분을 분리합니다. 변하는 부분을 추상 메서드로 선언한 뒤 구체 클래스에서 문맥에 따라 다르게 구현할 수 있습니다.
  2. 중복 코드를 부모 클래스로 옮겨라
    • 추상 클래스를 부모 클래스로 지정한 뒤 중복 코드를 추상 클래스에 선언합니다. 또한 문맥에 따라 변하는 부분은 구체 클래스에게 책임을 넘깁니다. 이는 역할은 추상 클래스에게 맡기고 구체적인 책임은 구체 클래스에게 할당하는 것입니다.
  3. 의도를 드러내는 오퍼레이션명을 지어라
    • 오퍼레이션명에는 어떻게가 아닌 무엇을해야하는지 명시해야합니다. 즉 "심야 할인 요금제에 의해 가격을 측정한다" 등이 아닌 
      "가격을 측정한다" 처럼 무엇을 해야하는지 남기고 내부 구현은 구체 클래스에게 맡깁니다.
  4. 추상화를 통한 개방 폐쇄 원칙
    • 새로운 할인 요금제가 추가되더라도 AbstractPhone을 상속받아 calculateCallFee 메서드만 재정의하면 됩니다. 이를 통해 확장에는 열려있고 수정에는 닫혀있는 모습을 볼 수 있습니다.

 

차이에 의한 프로그래밍


  • 상속을 사용하면 기존 클래스로부터 새로운 기능을 쉽고 빠르게 추가할 수 있습니다. 상속이 강력한 이유는 익숙한 개념을 이용해서
    새로운 개념을 쉽고 빠르게 추가할 수 있기 때문입니다.
  • 이처럼 기존 코드와 다른 부분만을 추가함으로써 애플리케이션의 기능을 확장하는 방법을 차이에 의한 프로그래밍이라 합니다.
    차이에 의한 프로그래밍의 목표는 중복 코드를 제거하고 코드를 재사용하는 것입니다. 
  • 객체지향에서 차이에 의한 프로그래밍 방법은 대표적으로 상속이 있는데 하지만 맹목적으로 상속을 사용하면 위험합니다. 그 이유는 지금까지 설명해왔으며 상속보다는 합성을 사용해야 합니다.

 

 

 

 

 

 

 

728x90
반응형