[오브젝트] 상속과 코드 재사용

상속과 중복 코드

중복 코드는 변경을 방해한다. 프로그램의 본질은 비즈니스와 관련된 지식을 코드로 변환하는 것이다. 하지만 이 비즈니스 지식은 항상 변한다. 이런 측면에서 중복 코드가 가지는 큰 문제는 코드를 수정하는데 필요한 노력을 증가시킨다는 점이다. 중복 여부를 판단하는 기준은 변경이다. 요구사항이 변경됐을 때 두 코드를 함께 수정해야 한다면 이 코드는 중복이다. 함께 수정할 필요가 없다면 중복이 아니다. 모양이 유사하다는 것은 중복의 징후일 뿐이며 중복 여부를 결정하는 기준은 코드가 변경에 반응하는 방식이다. 신뢰적인 소프트웨어를 만드는 방법 중 하나는 중복을 제거하는 것이다. 이를 DRY(Don`t Repeat Yourself) 원칙이다. 이는 Once and Only Once 원칙 혹은 Single-Point Control 원칙이라고도 부른다. 중복의 문제점을 위해 간단한 주문 어플리케이션을 구현하자. 주문 금액을 계산하는데 필요한 총 금액을 계산하는 코드이다.

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
public class Product {
private DayOfWeek orderedDay;
private long fee;

public Product(DayOfWeek orderedDay, long fee) {
this.orderedDay = orderedDay;
this.fee = fee;
}

public long getFee() {
return fee;
}

public DayOfWeek getOrderedDay() {
return orderedDay;
}
}

public class Order {
private List<Product> products = new ArrayList<>();

public Order(List<Product> products) {
this.products.addAll(products);
}

public void buy(Product product) {
products.add(product);
}

public long calculateFee() {
long fee = 0;

for(Product product : products) {
fee += product.getFee();
}

return fee;
}
}

이제 여기서 매주 월요일마다 구매한 물품에 대해서는 10%의 할인을 적용해주는 요구사항이 추가된다고 가정해보자. 이 요구사항을 쉽게 해결하는 방법은 Order 코드를 복사해 DiscountOrder 클래스를 만드는 방법이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public class DiscountOrder {
private List<Product> products = new ArrayList<>();

public DiscountOrder(List<Product> products) {
this.products.addAll(products);
}

public void buy(Product product) {
products.add(product);
}

public long calculateFee() {
long fee = 0;

for(Product product : products) {
if(product.getOrderedDay().equals(DayOfWeek.MONDAY))
fee += product.getFee() * 0.9;
else
fee += product.getFee();
}

return fee;
}
}

이제 여기서 또 다른 요구사항인 총 요금에 세금을 10% 부과하는 요청을 만들어야 한다고 가정해 보자. 그렇다면 코드는 다음과 같이 수정될 것이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
public class DiscountOrder {
public long calculateFee() {
...
return fee * 1.1;
}
}

public class Order {
public long calculateFee() {
...
return fee * 1.1;
}
}

이는 중복이 보여주는 단점을 잘 나타낸다. 많은 코드에서 어떤 코드가 중복인지를 찾아내는 것은 쉽지 않다. 또한 중복인 코드는 중복을 부른다. 중복이 많아질 수록 코드를 변경하는데 시간이 오래 걸리고 버그는 증가한다.

타입 코드 사용하기

중복을 제거하는 방식중 하나는 타입 코드를 사용하는 방법이다. 하지만 타입 코드를 사용하게 되면 낮은 응집도와 높은 결합도를 유발한다.

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
public class Order {
enum OrderType { REGULAR, DISCOUNT }
private List<Product> products = new ArrayList<>();
private OrderType type;

public Order(List<Product> products, OrderType type) {
this.products.addAll(products);
this.type = type;
}

public void buy(Product product) {
products.add(product);
}

public long calculateFee() {
long fee = 0;

for(Product product : products) {
if(type.equals(OrderType.REGULAR)) {
fee += product.getFee();
} else {

if(product.getOrderedDay().equals(DayOfWeek.MONDAY))
fee += product.getFee() * 0.9;
else
fee += product.getFee();
}
}

return fee;
}
}

하지만 객체지향에서는 상속을 통해 이를 해결할 수도 있다.

상속을 통한 중복 코드 제거

Order를 DiscountOrder가 상속해 코드를 재사용하면 다음과 같다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class DiscountOrder extends Order{
private List<Product> products = new ArrayList<>();

public DiscountOrder(List<Product> products) {
super(products);
}

@Override
public long calculateFee() {
long fee = super.calculateFee();

for(Product product : products) {
if(product.getOrderedDay().equals(DayOfWeek.MONDAY))
fee -= product.getFee() * 0.1;
}

return fee;
}
}

이 코드에는 이상한 부분이 있다. super를 통해 부모 클래스의 calculateFee를 호출해 전체 요금을 계산하고 거기서 월요일에 구매한 물품의 값을 빼는 것이다. 이런 코드는 개발자가 모든 상속 구조간 코드를 이해하고 있어야만 한다. 이런 코드는 이해를 어렵게 만든다. 또 다른 문제는 Order와 DiscountOrder가 강하게 결합되어 있는 점이다. 여기서 세금을 더하는 로직을 추가할 경우 부모 클래스의 calculateFee와 자식 클래스의 calculateFee에 모두 세금을 더하는 로직을 추가해야 한다. super 참조를 통해 부모 클래스의 메소드를 직접 호출할 경우 두 클래스는 강하게 결합된다.

추상화에 의존하기

위 코드의 문제는 부모 클래스와 자식 클래스가 강하게 결합되어 있는 점이다. 이를 해결하는 일반적인 방법은 자식 클래스와 부모 클래스 모두 추상화에 의존하게 만드는 방법이다. 중복 제거를 위해 상속을 도입할 때는 두 원칙을 고려하면 된다.

차이를 메소드로 추출하라

차이를 메소드로 추출하는 방법은 변하는 것으로부터 변하지 않는 것을 분리하라라는 의미이다. 기존의 Order와 DiscountOrder의 변하는 부분과 변하지 않는 부분을 분리하면 다음과 같다.

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
public class Order {
public long calculateFee() {
long fee = 0;

for(Product product : products) {
fee += calculateProductFee(product);
}

return fee;
}

private long calculateProductFee(Product product) {
return product.getFee();
}
}

public class DiscountOrder {
public long calculateFee() {
long fee = 0;

for(Product product : products) {
fee += calculateProductFee(product);
}

return fee;
}

private long calculateProductFee(Product product) {
if(product.getOrderedDay().equals(DayOfWeek.MONDAY))
return (long)(product.getFee() * 0.9);
else
return product.getFee();
}
}

중복 코드를 부모 클래스로 올려라

이제 부모 클래스를 추가하면 된다. 모든 클래스들이 추상화에 의존하도록 만드는 것이 목표이기 때문에 추상 클래스로 만드는 것이 적합하다. 그리고 이 클래스를 Order와 DiscountOrder가 상속 받도록 하고 중복인 코드를 올리면 된다.

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
43
44
45
46
47
48
public abstract class AbstractOrder {
private List<Product> products = new ArrayList<>();

public AbstractOrder(List<Product> products) {
this.products.addAll(products);
}

public void buy(Product product) {
products.add(product);
}

public long calculateFee() {
long fee = 0;

for(Product product : products) {
fee += calculateProductFee(product);
}

return fee;
}

abstract protected long calculateProductFee(Product product);
}

public class Order extends AbstractOrder{
public Order(List<Product> products) {
super(products);
}

@Override
protected long calculateProductFee(Product product) {
return product.getFee();
}
}

public class DiscountOrder extends AbstractOrder{
public DiscountOrder(List<Product> products) {
super(products);
}

@Override
protected long calculateProductFee(Product product) {
if(product.getOrderedDay().equals(DayOfWeek.MONDAY))
return (long)(product.getFee() * 0.9);
else
return product.getFee();
}
}

공통 코드를 이동시키면 각 클래스는 서로 다른 변경 이유를 가지게 된다. 또한 새로운 주문 요금 계산 방식을 처리하기도 쉬워진다. AbstractOrder를 상속받은 후 calculateProductFee를 오버라이딩하면 된다. 이렇게 바꾼 뒤에는 의도에 맞는 이름으로 수정하자.

1
2
3
public abstract class Order { ... }
public class RegularOrder extends Order { ... }
public class DiscountOrder extends Order { ... }

여기서 세금에 대한 추가 요금을 부여하기 위해서는 어떻게 해야할까? 답은 간단하다. Order를 수정하면 된다.

1
2
3
4
5
6
public abstract class Order {
public long calculateFee() {
...
return fee * 1.1;
}
}
Author: Song Hayoung
Link: https://songhayoung.github.io/2021/01/17/Object/object10/
Copyright Notice: All articles in this blog are licensed under CC BY-NC-SA 4.0 unless stating additionally.