본문 바로가기

책 리뷰/엘레강트 오브젝트

엘레강트 오브젝트 [2장 - (3)]

⌗ 엘레강트 오브젝트 책을 리뷰해보도록 하겠습니다.

책의 내용은 다양한 언어로 되어있지만, 저는 예시 코드로 Typescript 를 사용해 제 사족을 조금씩 붙일 예정입니다.

 

퍼블릭 상수를 사용하지 마세요

 

class Records {
  private static readonly DELIMITER: string = "-";

  convert(input: string): void {
    const list = input.split(Records.DELIMITER);
    ...
  }
}

 

저자는 java 기준으로 이 책을 작성했고, java 의 public static final 프로퍼티를 강하게 반대합니다.

typescript 에선 final 키워드가 없으니 이를 비슷하게 작성하면 static readonly 상수가 될 것입니다.

 

객체들은 어떤 것도 공유해서는 안되고, 독립적이면서 닫혀 있어야 합니다.

상수를 이용한 공유 메커니즘은 캡슐화와 객체지향적인 사고 전체를 부정하는 일입니다.

 

class Rows {
  private static readonly DELIMITER: string = "-";
  ...
}

 

만약 위의 Records 라는 클래스를 작성하고 로직을 구현중에 Rows 라는 클래스도 작성하게 되었을 때,

Records 와 Rows 는 서로 어떤것으로도 연결되어 있지 않더라도 공통으로 DELIMITER 라는 private 상수를 정의하고 있습니다.

이또한 중복이고, typescript 에선 이 중복을 해결하기 위해 constant 등으로 따로 빼서 사용하게 될 것입니다.

 

이렇게 중복을 해결하고 constant 를 재사용할 수 있게 되어 문제가 해결된 것처럼 보이지만, 이로 인해 결합도가 높아졌고 응집도는 낮아지게 되었습니다.

 

- 결합도 증가

 

class Constants {
  public static readonly DELIMITER: string = '-';
}

class Records {
  convert(input: string): void {
    const list = input.split(Constants.DELIMITER);
    ...
  }
}

class Rows {
  convert(input: string): void {
    const list = input.split(Constants.DELIMITER);
    ...
  }
}

 

Records 와 Rows 두 클래스는 모두 같은 객체에 의존하고 있고, 이 의존성은 하드 코딩되어 있습니다.

이 경우 의존성을 쉽게 분리하기란 어렵습니다. Constatns.DELIMITER 를 수정하면 두 클래스의 행동은 예상할 수 없게 변경될 수 있는데 Constatns.DELIMITER 를 변경하는 입장에선 이 값이 어떻게 사용되고 있는지 모르기 때문입니다.

비교적 간단한 상수이고 위 예시에서처럼 너무 명확해서 별 문제가 되지 않을 수 있지만, 상수가 복잡해질수록 문제는 더 커지고 결국 유지보수성이 크게 저하될 것입니다.

 

- 응집도 저하

 

응집도가 낮아진다는 것은 객체가 자신의 문제를 해결하는데 덜 집중한다는 의미입니다.

위 예시에서 발생하는 문제를 해결하기 위한 저자의 대안은, 객체 사이에 데이터를 중복해서는 안되고 지능을 공유할 수 있도록 새로운 클래스를 만드는 것입니다.

 

class Split {
  private readonly input: string;

  constructor(input: string) {
    this.input = input;
  }

  list(): Array<string> {
    return this.input.split('-');
  }
}

class Records {
  convert(input: string): void {
    const list = new Split(input).list();
    ...
  }
}

class Rows {
  convert(input: string): void {
    const list = new Split(input).list();
    ...
  }
}

 

기존 Records 와 Rows 에서 DELIMITER 를 사용하던 부분의 로직을 떼서 이 기능을 제공하는 새로운 클래스를 추가한 코드입니다.

Records 와 Rows 에선 이제 해당 로직을 포함하지 않고, 어떤 식으로 동작하는지 정확한 방법도 알지 못합니다.

이것이 또다른 결합이 증가한 것 아니냐고 할 수 있지만, Split 과의 결합은 계약을 통해 추가된 것이며 계약에 의한 결합은 언제든 분리가 가능해 유지보수성을 저하시키지 않습니다.

 

이 예시대로면, 저자는 퍼블릭 상수마다 계약의 의미를 캡슐화하는 새로운 클래스를 만들어야 한다는 것을 얘기하고 있습니다.

마이크로 클래스가 굉장히 많이 생기겠지만, 클래스 사이에 중복 코드가 없고 클래스가 작아질수록 코드는 더 깔끔해집니다.

어플리케이션을 구성하는 클래스의 수가 많을수록 설계가 더 좋아지고 유지보수하기가 쉬워진다고 얘기합니다.

 

이 부분은 저도 클래스가 많아질수록 설계는 복잡해지고 그 어플리케이션의 구조를 파악하기 어려워지는 것은 아닐까 하는 생각을 갖고 있었는데, 구조를 파악하기 어렵게 클래스를 잘못 설계했던 것은 아닐지, 구조 파악은 다소 어렵지만 유지보수 관점에서는 좋아졌던 것일까? 라는 생각으로 작업했고 관리중인 서버들에 대해 다시금 봐야할 것 같습니다.

 

const data = new HttpRequest().method('POST').fetch();

const data = new HttpRequest().method(HttpMethods.POST).fetch();

const data = new PostRequest(new HttpRequest()).fetch();

 

약간 슈도코드처럼 작성한 코드 예시입니다. method 부분에 'POST' 로 넣던 것을 HttpMethods.POST 라는 리터럴로 정리했지만, 위에서 지금까지 얘기한 것처럼 이 자체가 OOP 와 어긋나있습니다. PostRequest 와 같이 단순한 클래스를 많이 만드는 편이 좋습니다.

OOP 에서 퍼블릭 상수를 절대로 사용해서는 안됩니다.

 

불변 객체로 만드세요

 

모든 클래스를 상태 변경이 불가능한 불변 클래스로 구현하면 유지보수성을 크게 향상시킬 수 있습니다.

생성한 객체의 상태 변경이 가능하면 가변 객체라하고, 상태 변경을 할 수 없으면 불변 객체라 합니다.

 

// 가변 객체
class Cash {
  private dollars: number;

  constructor(dollars: number) {
    this.dollars = dollars;
  }

  public mul(factor: number): void {
    this.dollars *= factor;
  }
}

// 불변 객체
class Cash {
  private readonly dollars: number;

  constructor(dollars: number) {
    this.dollars = dollars;
  }

  public mul(factor: number): Cash {
    return new Cash(this.dollars * factor);
  }
}

// 가변 객체 사용
const five = new Cash(5);
five.mul(10);
console.info(five); // Cash { dollars: 50 }

// 불변 객체 사용
const five = new Cash(5);
const fifty = five.mul(10);
console.info(five); // Cash { dollars: 5 }
console.info(fifty); // Cash { dollars: 50 }

 

동일한 작업을 하는 가변 객체와 불변 객체의 예시입니다.

불변 객체는 어떤 방식으로든 자기 자신을 수정할 수 없습니다. 원하는 상태를 가지는 새로운 객체를 생성해서 반환해야 합니다.

위 예시 중 가변 객체를 사용했을 경우, five 라는 변수는 결국 50 dollars 를 갖게 됩니다. 여기부터가 어지러운데, 그럼 변수명을 바꾸면 되지 않냐? 라고 할 수 있습니다. const money = new Cash(5) 이런 식이죠.

이 해결방법은 아주 간단한 경우에만 적용할 수 있고, 구체적인 이름을 추상적인 이름으로 대체한 것 뿐입니다.

 

사실 불변을 사용하면 좋다는 것은 OOP 가 아니어도 이미 알고있는 내용이긴 하지만, 메모리나 성능에 대한 우려를 같이 하게 되는 것 같습니다. 요즘 시대에 사용하는 장비에서 그런 부분은 걱정할게 거의 없다고 하긴 하지만요.

하지만 가변 객체는 존재해선 안되고, 사용을 금해야한다는 것은 다시한번 상기하고 가겠습니다.

 

- 식별자 가변성

 

불변 객체에는 식별자 가변성 문제가 없습니다. 이 문제는 동일해 보이는 두 객체를 비교한 후 객체의 상태를 변경할 때 문제가 됩니다.

 

class Cash {
  private dollars: number;

  constructor(dollars: number) {
    this.dollars = dollars;
  }

  public mul(factor: number): void {
    this.dollars *= factor;
  }
}

const map: Map<Cash, string> = new Map();

const five = new Cash(5);
const ten = new Cash(10);

map.set(five, 'five');
map.set(ten, 'ten');

five.mul(2);

console.info(map); // Map { Cash { dollars: 10 } => 'five', Cash { dollars: 10 } => 'ten' }

 

가변 객체인 Cash 에서 처음에 five 와 ten 이라는 동일하지 않은 두 객체를 생성하고 map 에 추가했습니다.

map 은 두개의 독립적인 엔트리를 생성한 후 five 객체가 10 으로 상태 변화가 일어났지만, map 은 변경이 일어난 사실을 전혀 인식하지 못합니다. 결과적으로 아주 혼란스러운 상태의 map 이 된 것입니다. 이러한 문제가 식별자 가변성 으로 알려져 있습니다.

 

- 실패 원자성

 

class Cash {
  private dollars: number;
  private cents: number;

  constructor(dollars: number, cents: number) {
    this.dollars = dollars;
    this.cents = cents;
  }

  public mul(factor: number): void {
    this.dollars *= factor;

    if (/* something wrong */) {
      throw new Error('...');
    }

    this.cents *= factor;
  }
}

 

사실 가변 객체를 사용하더라도 이런식으로 코드를 짜는 사람은 잘 없을것이라 생각되긴 합니다만, 위 예시에서 mul 메서드를 실행하는 도중 예외가 던져진다면 객체의 절반만 수정되고 나머지는 원래 값을 유지합니다.

불변 객체는 내부 어떤 것도 수정할 수 없기 때문에 이런 결함이 발생하지 않습니다.

 

class Cash {
  private readonly dollars: number;
  private readonly cents: number;

  constructor(dollars: number, cents: number) {
    this.dollars = dollars;
    this.cents = cents;
  }

  public mul(factor: number): Cash {
    if (/* something wrong */) {
      throw new Error('...');
    }
    return new Cash(this.dollars * factor, this.cents * factor);
  }
}

 

제가 적은 것처럼 가변 객체를 사용하더라도 저런식으로 코드를 작성하지 않으면 실패 원자성 이라는 목표를 달성할 수 있지만 이를 위해선 개발자가 주의를 기울여야 합니다. 반면 불변 객체를 사용할 경우엔 별도 처리 없이 원자성을 얻을 수 있습니다.

 

- 시간적 결합

 

불변 객체를 사용해서 얻을 수 있는 또 다른 장점은 시간적 결합을 제거할 수 있다는 것입니다.

시간적 결합이 무엇인지 간단한 예시를 보겠습니다.

 

const price = new Cash();
price.setDollars(29);
price.setCents(95);
console.info(price); // 29.95

const price = new Cash();
price.setDollars(29);
console.info(price); // 29
price.setCents(95);

 

가변 객체를 인스턴스화하고 초기화하는 과정에서, console.info 를 호출하는 시점에 약간의 차이가 있습니다.

코드는 당연히 실패하지 않지만, 어떤 이유로든 위처럼 코드를 재정렬할 경우 시간적 결합을 이해해야 합니다.

가변 객체들은 처리하는 연산들의 순서를 일일이 기억해야 한다면.. 유지보수는 정말 끔찍하고 이런 적이 많았던 것 같습니다..

불변 객체에선 인스턴스화와 초기화를 분리할 수 없기때문에, 애초에 이런 문제가 발생하지 않습니다.

 

- 부수효과(SideEffect) 제거

 

class Cash {
  private dollars: number;

  constructor(dollars: number) {
    this.dollars = dollars;
  }

  public mul(factor: number): void {
    this.dollars *= factor;
  }
}

const printCash = (price: Cash): void => {
  console.info('price: ', price); // price: Cash { dollars: 5 }
  price.mul(2);
  console.info('price: ', price); // price: Cash { dollars: 10 }
}

const five = new Cash(5);
printCash(five);
console.info(five); // price: Cash { dollars: 10 }

 

약간 억지스러운 예시일 수 있지만, 가변 객체로 작업했을 경우 개발자가 위와 같은 실수를 저지르기 쉽습니다.

printCash 에서 실수로 객체의 mul 메서드를 호출해버렸고, 이제 printCash 를 호출할 때 부수효과가 발생합니다.

불변일 경우엔, 이런 일이 벌어지지 않겠죠.

 

- NULL 참조 없애기

 

NULL 참조라고 했지만 javascript 에선 undefined 로 표현하겠습니다. 가변 객체를 사용하고 그로인해 인스턴스화와 초기화가 분리되었을 경우에, 처음 인스턴스가 생성될 땐 프로퍼티 값이 undefined 로 할당됩니다.

이후 setter 에 의해 초기화가 되면 undefined 가 없어지겠지만, 우리는 이 객체를 사용할때마다 undefined 인지 아닌지를 체크해야 합니다. 이렇게 되면 객체가 유효한지, 언제 상태가 변하는지 이해하기 어렵기 때문에 유지보수성이 저하됩니다.

 

javascript 라고 쓴 이유는, typescript 에선 멤버 변수의 초기화를 생성자에서 하지 않을 경우 빌드 실패가 되기 때문에 javascript 로 표현하였습니다.

모든 객체를 불변으로 만들면 객체 안에 undefined 를 포함시키는 것이 불가능합니다. 작고 견고한 응집도 높은 객체를 생성할 수 밖에 없도록 강제되기 때문에 유지보수하기에도 훨씬 용이해집니다.

 

- 스레드 안전성

 

typescript 에선 사실 이 부분을 걱정할 일이 거의 없기 때문에, 그냥 책에 있는 내용을 적고 넘어가도록 하겠습니다.

 

스레드 안전성이란 글자 그대로 객체가 여러 스레드에서 동시에 사용될 수 있으며 그 결과를 항상 예측가능하도록 유지할 수 있는 객체의 품질을 의미합니다.

 

class Cash {
  private readonly dollars: number;
  private readonly cents: number;
  
  public mul(factor: number): void {
    this.dollars *= factor;
    this.cents *= factor;
  }
}

 

코드 자체에 별 결함은 없어 보이지만, 두개의 병렬 스레드 안에서 객체를 생성하고 mul 메서드를 호출하면 매번 다른 값이 출력됩니다.

불변 객체는 실행 시점에 상태를 수정할 수 없게 금지함으로써 이 문제를 완벽하게 해결합니다. 상태를 수정할 수 없기 때문에 많은 스레드가 객체에 접근해도 문제가 없습니다.

 

물론 java 를 예시로 들었을 때 명시적인 동기화를 사용해 스레드 안전성을 확보할 수 있지만, 가변 클래스에 스레드 안전성을 추가하는건 꽤나 귀찮은 일이고 동기화 로직 추가는 성능상의 비용을 초래합니다. 데드락 또한 발생할 수 있는 문제가 있구요.

 

- 더 작고 더 단순한 객체

 

불변성은 단순성을 갖고 있습니다. 객체가 단순해질수록 응집도는 높아지고 유지보수는 쉬워집니다.

대부분의 경우 단순하다는 것은 더 적은 코드 라인 수를 얘기하고, java 에선 대략 250줄을 넘는 클래스는 리팩토링이 필요합니다.

ruby 의 경우엔 100 줄 이하를 권장하네요.

 

불변 객체를 아주 크게 만드는 일은 불가능하기 때문에 일반적으로 불변 객체는 가변 객체보다 더 작습니다.

불변 객체가 작은 이유는 생성자 안에서만 상태를 초기화할 수 있기 때문입니다.

 

불변 객성은 클래스를 더 짧고 깔끔하게 만듭니다. 이것이 가장 큰 장점이라고 할 수 있겠습니다.