코드스피츠 2강 오브젝트 - 1회차
책 오브젝트 11장를 기반으로 하는 코드스피츠 강의 오브젝트2 - 1회차 를 정리한 내용입니다.
책의 방향 소개
책의 내용에서 전반부는 SOLID 원칙이나 SOLID 원칙을 확장한 내용을 가지고 설명하지만
후반부는 JVM, C++ Compiler 가 어떻게 객체지향 언어를 결과를 내는지와 그 시스템을 어떻게 이용해서 객체지향을 구현하는지 다루고 있다.
주 내용은 Pointer of Pointer 이다.
직접참조를 하지 않고 간접참조를 어떻게 언어에서 키워드를 사용하면 되는지 다뤄본다.
메모리에 객체가 올라가면 LinkedList 가 형태를 띄게된다.
Interface, Abstract class, Concreate class 의 3개가 생성되었다면 LinkedList 에서 주소 값을 통해 찾아가는 과정이다.
상속과 확장
확장 (extend)
원래 A가 있었는데 A를 상속받아서 뭔가 덧붙이는 개념이다.
프로그램 세계에서는 확장이라는 의미로 Overriding, Overloading 이 있다.
상속 계층 잘못한 일 중 하나는 자동차가 있고 자동차에 날개를 달아서 비행기를 만들려고 하는 것이다.
프로그래밍 세계에서는 과격한 변화 (자동차를 비행기로 만드는) 확장으로 인정하지 않는다.
유산을 물려받지 말 것 - 부모의 method 를 override 하지말것, protected (public 제외)
대리역할을 하지 말것 - 부모인 척 하지말라는 의미
확장하는 쪽이 부분 책임만 지도록 해야 한다.
부모가 모든 일을 다하고 자식은 딱 한가지 일만 시킨다.
나쁜 확장 2가지
super 는 나쁘다
현실세계에서는 result -> result3 으로 바뀌면서 base' 수정하게 되면 result2 는 깨지게 된다.
override 는 나쁘다
부모 함수에 숨기고 싶은 어떤 Context 를 가지고 있거나 아니면 그 여파가 다른 곳에 미친다.
우리는 그 안에 Context, 여파를 정확히 알 수있을까?
정확히 알지 못하기 때문에 Override 는 실패한다.
좋은 확장
부모의 모든 메소드는 final, private, abstract protected, 부모의 생성자는 인자를 받지 않음. 😢
위에 것들을 지킬 수 없다면 상속을 포기하면 된다. (합성을 사용하자!)
책에서는 조합 폭발을 피하기 위해서는 합성 모델로 만들어야 한다고 설명하고 있지만
조합 폭발이 일어나지 않는 경우 + 위에 좋은 확장을 지킬 수만 있다면 상속을 사용해도 나쁘지 않다.
좋은 부모 Plan
abstract class Plan {
// 생성자 인자를 받지 않음
// private 상태
private Set<Call> calls = new HashSet<>();
// final 키워드
public final void addCall(Call call){
calls.add(call);
}
// final 키워드
public final Money calculateFee(){
Money result = Money.ZERO;
for(Call call:calls) result = result.plus(calcCallFee(call));
return result;
}
// Hook 메소드
abstract protected Money calcCallFee(Call call);
}
좋은 자식 PricePerTime
우리가 상속받은 메소드 (calcCallFee) 는 super 에 대한 의존성이 전혀 없다.
심지어 부모의 속성도 참조할 수 없다.
public class PricePerTime extends Plan {
// 본인 상태에 대한 것만 하고 있음.
private final Money price;
private final Duration second;
public PricePerTime(Money price, Duration second){
this.price = price;
this.second = second;
}
@Override
protected Money calcCallFee(Call call) {
return price.times((call.getDuration().getSeconds() / second.getSeconds()));
}
}
좋은 자식 NightDiscount
PricePerTime 와 마찬가지
public class NightDiscount extends Plan {
private final Money dayPrice;
private final Money nightPrice;
private final Duration second;
public NightDiscount(Money dayPrice, Money nightPrice, Duration second){
this.dayPrice = dayPrice;
this.nightPrice = nightPrice;
this.second = second;
}
@Override
protected Money calcCallFee(Call call) {
Money price = call.getFrom().getHour() >= 22 ? nightPrice : dayPrice;
return price.times((call.getDuration().getSeconds() / second.getSeconds()));
}
}
합성
위에 상속예제를 합성으로 바꾸어 보자!
앞으로 확장가능성이 있고
위에서 말했던 좋은 부모의 조건을 지킨다면 class 에 final 을 붙이지 않아도 된다.
final, private, abstract protected, 부모의 생성자는 인자를 받지 않음.
그러나 그 조건이 하나라도 지켜지지 않는다면 당장 final class 를 걸어야 한다.
대상 객체 Plan
// final, private, abstract protected, 부모의 생성자는 인자를 받지 않음을 만족하고 있음.
// public final class Plan {
public class Plan {
private Calculator calc;
private Set<Call> calls = new HashSet<>();
public final void addCall(Call call){
calls.add(call);
}
public final void setCalculator(Calculator calc){
this.calc = calc;
}
public final Money calculateFee(){
Money result = Money.ZERO;
for(Call call:calls) result = result.plus(calc.calcCallFee(call));
return result;
}
}
좋은 부모를 상속하고 있는 객체는 전략패턴으로 바꾸어도 코드의 변화가 없다는 것을 알 수있다.
public interface Calculator {
Money calcCallFee(Call call);
}
// public class PricePerTime extends Plan {
public class PricePerTime implements Calculator {
private final Money price;
private final Duration second;
public PricePerTime(Money price, Duration second){
this.price = price;
this.second = second;
}
@Override
public Money calcCallFee(Call call) {
return price.times((call.getDuration().getSeconds() / second.getSeconds()));
}
}
포워딩
합성한 객체에게 일부의 책임을 요청하지만 컨텍스트의 공유가 없는 경우를 말하고 있다.
하지만 컨텍스트 공유를 해야한다면 위임이라고 부른다.
연결되는 합성객체
완전히 독립된 메모리 공간에 있는 컴퓨터 두대가 있다면
어떻게 협력할지를 결정해줘야 한다.
프로그램에서는 독립되어 있는 모듈간에 병합된 결과를 주고 받기 위한 Interface 를 정의한다는 행위와 개념이다.
객체내에 특정 메소드에서 다른 객체의 특정 메소드에게 전달하는 방식이 객체간 메시지 전달하는 일련의 과정이다.
위 처럼 4개의 지식을 알 필요없이 다음으로 메시지가 보내질 하나의 형만 알면 된다.
이 패턴이 바로 데코레이터 패턴이다.
데코레이터 패턴 UML
/*
public interface Calculator {
Money calcCallFee(Call call);
}
*/
하나의 책에서 Call 집합으로 늘어났다.
public interface Calculator {
Money calcCallFee(Set<Call> calls, Money result);
}
위에서는 calcCallFee(calls:result:) 는 인자 2개 생겨났다.
위같은 함수는 함수 2개로도 구현할 수 있지만 지금은 함수 1개로 구현하는 것이 좋다.
그 이유는 함수 2개를 고르고 컨텍스트를 대신하게 하면 순서가 생긴다.
순서형 메소드를 만들 경우가 생기면 함수 1개와 인자 2로 Aggregator 형태 함수를 만드는 것을 선호한다.
public interface Calculator {
Money calcCallFee(Set<Call> calls, Money result);
}
public class Plan {
private Calculator calc;
private Set<Call> calls = new HashSet<>();
public final void addCall(Call call){
calls.add(call);
}
public final void setCalculator(Calculator calc){
this.calc = calc;
}
public final Money calculateFee(){
return calc.calcCallFee(calls, Money.ZERO);
}
}
Plan의 2가지 책임
Calculator 를 관리하는 것
Calculator 에서 List<Call> 를 전달하는 것
Calculator 책임
Call 의 계산
public class PricePerTime implements Calculator {
private final Calculator next;
private final Money price;
private final Duration second;
public PricePerTime(Calculator next, Money price, Duration second){
this.next = next;
this.price = price;
this.second = second;
}
@Override
public Money calcCallFee(Set<Call> calls, Money result) {
for(Call call:calls){
result = result.plus(price.times((call.getDuration().getSeconds() / second.getSeconds())));
}
// 체이닝으로 동작하며 next 가 없다면 함수는 끝난다.
return next == null ? result : next.calcCallFee(calls, result);
}
}
public class NightDiscount implements Calculator {
private final Calculator next;
private final Money dayPrice;
private final Money nightPrice;
private final Duration second;
public NightDiscount(Calculator next, Money dayPrice, Money nightPrice, Duration second){
this.next = next;
this.dayPrice = dayPrice;
this.nightPrice = nightPrice;
this.second = second;
}
@Override
public Money calcCallFee(Set<Call> calls, Money result) {
for(Call call:calls){
Money price = call.getFrom().getHour() >= 22 ? nightPrice : dayPrice;
result = result.plus(price.times((call.getDuration().getSeconds() / second.getSeconds())));
}
// 체이닝으로 동작하며 next 가 없다면 함수는 끝난다.
return next == null ? result : next.calcCallFee(calls, result);
}
}
public class Tax implements Calculator {
private final Calculator next;
private final double ratio;
public Tax(Calculator next, double ratio){
this.next = next;
this.ratio = ratio;
}
@Override
public Money calcCallFee(Set<Call> calls, Money result) {
result = result.plus(result.times(ratio));
return next == null ? result : next.calcCallFee(calls, result);
}
}
public class AmountDiscount implements Calculator {
private final Calculator next;
private final Money amount;
public RateDiscount(Calculator next, Money amount){
this.next = next;
this.amount = amount;
}
@Override
public Money calcCallFee(Set<Call> calls, Money result) {
result = result.minus(amount);
return next == null ? result : next.calcCallFee(calls, result);
}
}
다른 병행적 자식들도 마찬가지이다.
Main
public static void main(String[] args) {
Plan plan = new Plan();
plan.setCalculator(
new PricePerTime(
new AmountDiscount(
new Tax(null, 0.1),
Money.of(10000)
),
Money.of(18),
Duration.ofSeconds(60)
)
);
}
뭔가 류가 장풍을 쓰는 느낌의 코드이다.
장풍쓰는 코드가 굉장히 기분이 썩 좋지 않다.
중복제거 및 고도화
Interface Calulator 자식들은 모두 아래와 같은 중복 코드가 있기 때문에 Dry 원칙을 위반하고 있다.
부모 클래스에 위에 중복 코드를 넣어주자!
abstract protected Hook 을 통해 자식들은 계산 책임을 위임하자.
public interface Calculator {
Money calcCallFee(Set<Call> calls, Money result);
}
public abstract class Calculator {
private Calculator next;
public final Calculator setNext(Calculator next){
this.next = next;
return this;
}
public final Money calcCallFee(Set<Call> calls, Money result){
result = calc(calls, result);
return next == null ? result : next.calcCallFee(calls, result);
}
// Hook 을 통해 자식들은 계산 책임을 위임하자!
abstract protected Money calc(Set<Call> calls, Money result);
}
Plan 은 코드의 변화가 없다!
public class Plan{
private Calculator calc;
private Set<Call> calls = new HashSet<>();
public final void addCall(Call call){
calls.add(call);
}
public final void setCalculator(Calculator calc){
this.calc = calc;
}
public final Money calculateFee(){
return calc.calcCallFee(calls, Money.ZERO);
}
}
PricePerTime는 보면 좋은 부모 자식 관계를 가지는 것을 확인할 수 있다.
public class NightDiscount extends Calculator {
private final Money dayPrice;
private final Money nightPrice;
private final Duration second;
public NightDiscount(Money dayPrice, Money nightPrice, Duration second){
this.dayPrice = dayPrice;
this.nightPrice = nightPrice;
this.second = second;
}
@Override
public Money calc(Set<Call> calls, Money result) {
for(Call call:calls){
Money price = call.getFrom().getHour() >= 22 ? nightPrice : dayPrice;
result = result.plus(price.times((call.getDuration().getSeconds() / second.getSeconds())));
}
return result;
}
}
Main 의 코드는 전보다 안정적으로 줄어들었다!
Calculator 에 setNext 를 통해 드디어 장풍에서 벗어났다!
// public static void main(String[] args) {
// Plan plan = new Plan();
// plan.setCalculator(
// new PricePerTime(
// new AmountDiscount(
// new Tax(null, 0.1),
// Money.of(10000)
// ),
// Money.of(18),
// Duration.ofSeconds(60)
// )
// );
// }
public static void main(String[] args) {
Plan plan = new Plan();
plan.setCalculator(
new PricePerTime(Money.of(18), Duration.ofSeconds(60))
.setNext(new AmountDiscount(Money.of(10000)))
.setNext(new Tax(0.1))
);
}
다시 합성으로
모든 상속이 위에 조건을 만족한다면 기계적으로 합성으로 바꿀수 있다고 했다.
final, private, abstract protected, 부모의 생성자는 인자를 받지 않음.
Calculator 자체가 구상 클래스이고
abstract protected calc() 이 전략 객체이다.
// 구상 클래스
public class Calculator {
// Calc 는 전략 객체
private Set<Calc> calcs = new HashSet<>();
public Calculator(Calc calc){
calcs.add(calc);
}
public final Calculator setNext(Calc next){
calcs.add(next);
return this;
}
public Money calcCallFee(Set<Call> calls, Money result){
for(Calc calc:calcs) result = calc.calc(calls, result);
return result;
}
}
Calculator 는 더이상 상속 계층이 아니라 구상 클래스가 되어야 하고
Calc 라는 전략 객체를 소유하게 된다.
public interface Calc {
Money calc(Set<Call> calls, Money result);
}
public class Tax implements Calc {
private final double ratio;
public Tax(double ratio) {
this.ratio = ratio;
}
@Override
public Money calc(Set<Call> calls, Money result) {
return result.plus(result.times(ratio));
}
}
설계는 코드의 배치이기 때문에 Interface 포장지의 변화밖에 없다.
// public static void main(String[] args) {
// Plan plan = new Plan();
// plan.setCalculator(
// new PricePerTime(
// new AmountDiscount(
// new Tax(null, 0.1),
// Money.of(10000)
// ),
// Money.of(18),
// Duration.ofSeconds(60)
// )
// );
// }
// public static void main(String[] args) {
// Plan plan = new Plan();
// plan.setCalculator(
// new PricePerTime(Money.of(18), Duration.ofSeconds(60))
// .setNext(new AmountDiscount(Money.of(10000)))
// .setNext(new Tax(0.1))
// );
// }
public static void main(String[] args) {
Plan plan = new Plan();
plan.setCalculator(
new PricePerTime(Money.of(18), Duration.ofSeconds(60)),
new AmountDiscount(Money.of(10000)),
new Tax(0.1)
);
}
이제 최종적으로 Main 함수를 살펴보자!
메인은 이제 전략 객체를 생성하도록 코드가 변경되었다!
public class Calculator {
private Set<Calc> calcs = new HashSet<>();
public Calculator(Calc calc){
calcs.add(calc);
}
public final Calculator setNext(Calc next){
calcs.add(next);
return this;
}
public Money calcCallFee(Set<Call> calls, Money result){
for(Calc calc:calcs) result = calc.calc(calls, result);
return result;
}
}
public class Plan{
private Calculator calc;
private Set<Call> calls = new HashSet<>();
public final void addCall(Call call){
calls.add(call);
}
public final void setCalculator(Calculator calc){
this.calc = calc;
}
public final Money calculateFee(){
return calc.calcCallFee(calls, Money.ZERO);
}
}
Calculator 기능을 Plan 에 포함해야 하는지 말아야 하는지?
우리가 Calculator 를 분리한 만큼 확장성이 뛰어나야 한다면 지금처럼 Calculator를 분리하는 모델이어야 한다.
Plan 에 강하게 Binding 되어야 한다면 아래 조건이 만족하면 수용할 수 있다.
- 모든 요금제는 Calculator 조합으로 일어난다.
Plan 이 요금에 대한 계산 시스템(1:1)이 아니라 멤버 등급(임원, 직원) 과 같은 Call 의 관여한 요금에 따라 계산(1:N)을 한다면
Calculator가 Binding 된 Plan 은 더이상 사용할 수 없게 된다.
Calculator 이름은 굉장히 포괄적인 이름이므로 CalculatorBaseCalls 가 적절하다.
Call 에 기반한 계산로직이 포함되어 있기 때문이다.
마무리
우리는 지금까지 Template method pattern <-> Strategy pattern 코드를 변경해 가는 과정을 살펴보았다.
설계는 코드의 배치이고 균형을 잡는 일이기 때문에 요구사항과 의사결정에 따라서 적절한 코드의 배치를 찾아볼 수 있었으면 좋겠다.
지금까지 이론을 살펴보았지만 실질적으로 현업에서 코드로 녹이는 데 까지는 오랜 시간의 학습이 필요하다~