본문 바로가기

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

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

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

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

 

인자의 값으로 Null 을 절대 허용하지 마세요

 

typescript 에 이것을 그대로 적용한다면 Null 또는 Undefined 를 절대 허용하지 말아야 할 것입니다.

하나의 메서드를 예시로 보도록 하겠습니다.

 

public find(mask?: string | null): Iterable<File> {
  // 디렉토리를 탐색해서 *.txt 와 같은 형식의 mask 에 일치하는 파일을 모두 찾음
  // mask 거 null 혹은 undefined 일 경우 모든 파일을 반환
}

 

위 설계를 바탕으로 이제 null 또는 undefined 를 허용하는 find 메서드를 구현하기 위해선 아래와 같이 분기처리를 할 필요가 있습니다.

 

import * as _ from 'lodash';

public find(mask?: string | null): Iterable<File> {
  if (_.isNil(mask)) {
    // 모든 파일을 찾음
  } else {
    // 마스크를 사용해 파일을 찾음
  }
}

 

이 코드에서 문제가 되는 부분은 _.isNil(mask) 입니다. mask 객체에게 얘기하는 대신, 이 객체를 피하고 무시합니다.

객체에 대한 존중이 전혀 없는 코드가 된 것입니다. 그리고 실제로 실무에선 이런 코드를 많이 쓰고 있습니다.

 

public find(mask: Mask): Iterable<File> {
  if (mask.empty()) {
    // 모든 파일을 찾음
  } else {
    // 마스크를 사용해 파일을 찾음
  }
}

 

객체를 존중한다면, 이렇게 코드가 변경되어야 합니다. string, null, undefined 타입으로 정의되어있던 mask 파라미터가 Mask 객체로 바뀐 것도 보이실겁니다. 여기서 더 개선한다면 아래와 같습니다.

 

public find(mask: Mask): Iterable<File> {
  const files: Array<File> = [];

  for (const file of /* all files */) {
    if (mask.matches(file)) {
      files.push(file);
    }
  }

  return files;
}

 

mask 객체를 존중했다면 조건의 존재 여부를 객체 스스로 결정하게 했다는 부분입니다. 확실히 저는 아직 이런 형태의 사고가 바로 이루어지지는 않는 것 같습니다.

인자의 값으로 null 또는 undefined 를 허용하면 _.isNil(mask) 와 같은 비교문을 사용할 수 밖에 없습니다. 이 체크를 직접 함으로써 객체가 맡아야 하는 상당량의 책임을 빼앗게 됩니다.

 

하지만 실제로 로직을 구현하다가, find 메서드에 전달할 인자 값이 없다면 어떻게 해결해야할까요?

OOP 에서는 이럴 경우 null object 를 이용해 해결해야 합니다. 전달할 것이 없다면, 비어있는 것처럼 행동하는 객체를 전달하면 됩니다.

전달한 인자가 객체인지 null 인지를 확인하는 책임을 구현자에게 떠넘기지 않고, 항상 객체를 전달하되 객체에게 무리한 요청을 한다면
응답을 거부하도록 객체를 구현해야 합니다. 메서드 인자로 절대 null 또는 undefined 를 허용하지 마세요.

 

충성스러우면서 불변이거나, 아니면 상수이거나

 

세상은 가변적이지만, 불변 객체로 세상을 모델링할 수 없는 것은 아닙니다.

이 점이 항상 혼란스러운 이유는, 서로 다른 개념인 상태와 데이터에 대해 오해하고 있기 때문입니다.

 

class WebPage {
  private readonly uri: string;

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

  public content(): string {
    // HTTP GET 요청을 전송해 컨텐츠를 읽은 후 UTF-8 문자열로 반환
  }
}

 

이 WebPage 는 불변일까요 가변일까요?

결론부터 말하자면 content 메서드를 호출할 때마다 서로 다른 값이 반환되더라도 이 객체는 불변입니다.

핵심은 객체가 살아있는 동안 상태가 변하지 않는다는 사실입니다. 조금 더 설명이 필요할 것 같네요.

 

기본적으로 모든 객체는 식별자, 상태, 행동을 포함합니다. 식별자는 객체를 다른 객체와 구별합니다.

상태는 객체가 알고있는 것입니다. 행동을 객체가 할 수 있는 작업을 나타냅니다.

불변 객체와 가변 객체의 중요한 차이는 불변 객체엔 식별자가 존재하지 않으며, 상태를 절대 변경할 수 없습니다.

정확히 말해서 불변 객체의 식별자는 객체의 상태와 완전히 동일합니다.

 

비록 언어 레벨에선 상태가 동일하더라도 서로 다른 객체라고 판단하지만, 위의 WebPage 에서 동일한 uri 를 가진 두 객체가 있다면, 이는 동일한 객체이며 동일한 행동을 노출합니다. 객체들은 웹 상의 페이지를 대표하며, 유일한 상태는 string 형태의 uri 뿐입니다.

 

반면 가변 객체는 완전히 다른 방식으로 동작합니다. 가변 객체의 상태는 변경이 가능하기 때문에 상태에 독립적인 식별자를 별도로 포함해야 합니다.

 

불변 객체는 좌표를 알고, 이 좌표를 상태라고 부릅니다. 다시 말해 불변 객체는 자신이 대표하는 실세계의 엔티티에 충성합니다.

불변 객체는 엔티티의 좌표를 절대로 변경하지 않습니다. 어떤 경우에도 항상 동일한 엔티티를 대표합니다.

반면 가변 객체는 대표하는 엔티티의 좌표를 변경할 수 있기 때문에 충성스럽지 않습니다.

 

절대 getter 와 setter 를 사용하지 마세요

 

- 객체 대 자료구조

 

객체와 자료구조의 차이점은 무엇이고 자료구조가 OOP 에서 해로운 이유는 무엇일까요?

차이점을 설명하기 위해 책에서 사용하는 C, C++ 코드를 그대로 인용하도록 하겠습니다.

 

// 자료구조
struct Cash {
  int dollars;
}

// 객체
#include <string>
class Cash {
public:
  Cash(int v): dollars(v) {};
  std::string print() const;
private:
  int dollars;
};

// 자료구조 Cash 를 사용
printf("Cash value is %d", cash.dollars);

// 객체를 사용해 위와 동일한 작업 수행
printf("Cash value is %s", cash.print());

 

struct 의 경우, 멤버인 dollars 에 직접 접근해 해당 값을 정수로 취급합니다. struct 를 가지고는 어떤 일도 하지 않습니다.

struct 는 어떤 개성도 지니지 않은 단순한 데이터 가방일 뿐입니다.

 

클래스는 다릅니다. 어떤 식으로든 멤버에게 접근하는 것을 허용하지 않고, 자신의 멤버를 노출하지도 않습니다.

클래스 안에 dollars 라는 멤버가 있는지조차 알 수 없습니다. 우리가 가능한건 print 하라고 요청하는 것 뿐이죠.

print 가 실제로 어떤 방식으로 동작하는지 알 수 없고, 캡슐화된 어떤 멤버가 이 작업에 개입하는지도 알 수 없습니다.

이것이 바로 캡슐화이며, OOP 가 지향하는 가장 중요한 설계 원칙 중 하나입니다.

 

자료구조는 투명하지만, 객체는 불투명합니다. 자료구조는 글래스 박스이지만, 객체는 블랙 박스 입니다. 자료구조는 수동적이고, 객체는 능동적입니다. 자료구조는 죽어있지만, 객체는 살아있습니다.

이것들은 모두 유지보수성과 관련이 있습니다. 모든 프로그래밍 스타일의 핵심 목표는 가시성의 범위를 축소해서 사물을 단순화하는 것입니다. 특정 시점에 이해해야 하는 범위가 작을수록 유지보수성이 향상되고 이해하고 수정하기도 쉬워집니다.

 

객체지향적이고 선언형 스타일을 유지하기 위해서는 데이터를 객체 안에 감추고 절대로 외부에 노출해서는 안됩니다.

정확하게 무엇을 캡슐화하고 있고, 자료구조가 얼마나 복잡한 지는 오직 객체만 알고 있어야 합니다. 데이터를 그대로 노출시켜선 안됩니다!

 

- 좋은 의도, 나쁜 결과

 

근본적으로 getter 와 setter 는 캡슐화 원칙을 위반하기 위해 설계되었습니다.

겉으로는 메서드처럼 보이지만, 실제로 데이터에 직접 접근한다는 불쾌함을 가리기위한 도구일 뿐입니다.

 

- 접두사에 관한 모든 것

 

getter 와 setter 안티 패턴에서 유해한 부분은 두 접두사인 get 과 set 이라는 사실이 중요합니다.

이런 메서드가 들어간 객체는 대화를 원하지 않고, 그저 어떤 데이터를 넣어주거나 꺼내주기만을 바랄 뿐입니다.

 

class Cash {
  private readonly value: number;

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

  public dollars(): number {
    return this.value;
  }
}

class Cash {
  private readonly value: number;

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

  public getDollars(): number {
    return this.value;
  }
}

 

두 클래스의 차이점이 보이시나요?

첫번째 예시처럼 어떤 데이터를 반환하는 메서드를 포함하는 것은 괜찮지만, 두번째 예시처럼 메서드 이름을 짓는 것은 적절하지 않습니다.

뭔가 말장난 같은 느낌은 들죠? 이는 결국 OOP 사고방식과 관련이 있습니다.

 

getDollars 는 데이터 중에 dollars 를 찾은 후 반환하라고 말하는 것이고, dollars 는 얼마나 많은 달러가 필요한가요? 라고 묻는 것입니다. 이 사소한 차이로 인해 dollars 를 객체의 데이터 저장소로 취급하지 않고, 존중하게 되는 것입니다.

확실히 이런 사고방식을 기르는 것은 상당히 많은 훈련(?)이 스스로에게 필요할 것 같습니다.