JIGGAG

null을 허용하지 않는 객체

2021년 9월 27일

능동적이고 안정적인 객체를 위해

인자로 null을 허용하지 말자

함수의 인자 또는 반환값을 null을 허용하는 코드로 작성할 수 있는데 이것은 정적 메서드(ch 3.2), 가변 객체(ch 2.6) 과 마찬가지로 객체의 능동성과 안정성을 떨어뜨리게 된다.

전달할 객체가 없으므로 null을 보내는데 값이 없는 것으로 이해해주세요라고 코드를 작성하는 것이 일반적이다.

만약 find 함수에서 찾아달라고 요청하려는 대상이 없다면 그냥 전체를 반환하는 것으로 처리할 수 있다.

const systemList = [...];
const find = (target: Target) {
  // systemList에서 target이 있는 경우 해당 객체를 반환해주는 코드
}

findfindAll 두가지 기능을 분리하여 만들 수 있는 것을 null을 허용하므로써 하나의 함수에서 처리할 수 있다는 장점이 있다.

그러나 찾고자 하는 조건이 늘어나게 된다면 find(target1, target2, target3...) 각각 조건별로 null이 들어올 수 있는 케이스를 대응하도록 find1(target2, target3), find2(target1, target3), find3()... 처럼 만들어줘야할까?

이렇게 전달되어지는 객체가 온전한 객체인지를 판단하는 것을 코드에서 직접 해준다는 자체가 객체의 능동성을 빼앗는 형태이다. 객체가 스스로 자신이 올바른 객체임을 확인하고 동작하도록 해줘야하는데 if (target !== null)이라는 조건을 추가해주는 것 자체가 객체 스스로 생각할 수 없게하는 것이다.

const find = (target: Target | null) {
  if (target === null) { // null을 허용해서 해당 조건을 직접 확인하고 로직을 분리하는 대신
    ...
  } else {
    ...
  }
}

const find = (target: Target) {
  if (!target.isTarget()) { // Target 객체라면 확인할 수 있는 조건을 이용한다
    ...
  } else {
    ...
  }
}

find의 인자로 전달되는 Target 객체 스스로 존재여부를 확인해서 로직을 결정할 수 있도록 해주었다.

그러나 인자의 값으로 null을 허용하게 되면 if (target !== null) 조건을 사용할 수 밖에 없게 된다. 객체에 접근하려고 하면 매번 객체의 존재여부를 확인하는 작업이 필요해지게 된다. 객체 자체가 스스로 판단해서 동작하도록 하는 것이 아니라 직접 수동적으로 존재를 확인해주어야 하기 때문에 객체는 우리가 확인해주기만을 기다려야한다.


왜 null을 사용하게 되었을까?

절차적 프로그래밍에서 포인터라는 개념에서 시작되었다.

포인터는 데이터를 찾을 수 있도록 해당 데이터가 저장된 메모리 주소를 제공하는데 이 메모리 주소값이 0x00000000인 경우 접근할 수 없다고 약속하였다.(어떤 자료구조도 이 주소에 저장하는 것이 불가능하기 때문이다) 그래서 이 메모리 주소값을 null로 처리하기로 약속하였다. 포인터는 단순하게 데이터가 저장되어 있는 메모리 주소만을 가리키고 있어서 해당 포인터를 역참조(메모리 주소 자체를 사용하는 것이 아닌 해당 주소에 저장된 데이터를 처리하겠다)하여 데이터를 가져와야만 했다. 따라서 접근할 수 없는 주소(0x00000000)에 역참조를 하게 되면 오류가 발생하게 되므로 null 처리가 필요했던 것이다.

하지만 이런 포인터 개념을 더 이상 사용하지 않음에도 계속 사용하고 있다.


그렇다면 null을 대체할 수 없을까?

일반적으로 아무것도 없음을 전달하고 싶을때 null을 전달하고 있다.

그렇다면 이런 null의 역할을 대체할 수 있게 된다면 null을 허용하지 않아도 되지 않을까?

이번에도 마찬가지로... 항상 그랬듯이 전달할 것이 없다면 비어있는 것처럼 행동하는 객체를 전달하는 것이다. 전달한 인자가 객체인지 null인지를 확인하는 것이 아니라 항상 객체를 전달하도록 하고 객체가 자신의 행동에 맞지 않는 요청이 들어온 경우 응답하지 않도록 처리하는 것이다.

interface Target {
  isTarget(): boolean;
}

class AnyTarget implements Target {
  isTarget() {
    return false;
  }
}

const target = new AnyTarget();
console.log(target.isTarget()); // 항상 false

전달할 것이 없는 경우 인자로 AnyTarget을 전달하여 항상 false를 반환하도록 하여 null을 전달하던 것을 완전히 대체할 수 있게 되었다. find 함수에서는 인자가 Target 객체가 맞는지 확인하는 일이 없어지고 무조건 실행하게 되는 것이다.


그럼에도 null이 존재한다면

그럼에도 불구하고 null이 인자로 넘어올 수 있다. (예를 들어 오픈소스에서 무조건 AnyTarget을 사용하리라고 확신할 수 없다)

이런 경우 target === null을 다시 넣어주고 에러를 던지도록 처리할 수 밖에 없다... 다만 기존 방식과 차이점이라면 에러가 던져진다는 것이다. 사용하는 입장에서 올바른 결과값이 아니라 에러가 발생하기 때문에 후처리가 이뤄질 것이라 예상할 수 있다.

이 책에서는 null이 오지 않는다고 가정하고 아무런 대비를 하지 않는다를 제시하고 있다. 하지만 운영하는 서비스에서 이런 예외적인 케이스를 대응하지 않고 바로 에러를 던진다면 항상 오류를 대비하고 있어야하는 위험이 따르지 않을까? 물론 오류가 발생한만큼 이런 케이스를 빨리 찾아서 null을 사용하지 않도록 수정할 수 있을지 모른다... 그렇지만 너무 위험해서 이 방법을 선택하기 어려울 것 같아 (QA하면서 모두 찾아서 대응한다면 좋겠지만...)


가변 보다 불변, 불변 보다 상수

class WebPage {
  private uri: string;

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

  content() {
    // ... this.uri를 이용해 읽어온 데이터 반환
    return ...;
  }
}

WebPage의 상태값이 uri는 처음 객체가 생성되는 시점에 정해지고 변경되지 않았다. 하지만 매번 content 함수를 호출할 때마다 다른 결과값이 반환되는 WebPage 객체는 불변일까? 가변일까?

결과값이 매번 바뀌더라도 객체 자체는 불변이다. 객체의 행동이나 반환값이 중요한 것이 아니라 객체의 상태가 변하는지 여부에 따라 정해지는 것이다.

불변 객체의 메서드를 호출할 때마다 상수처럼 매번 동일한 결과값을 반환하리라 기대하고 있지만 이는 상수 객체불변 객체의 더 작은 범주에 속한다.

객체는 실제 엔티티를 대표하는 것이다. (ex. 실제 모든 자동차를 대표하는 자동차 객체)

모든 객체는 식별자, 상태, 행동을 포함하여 식별자로 동일한 객체인지 구분할 수 있다. 하지만 불변 객체에서는 식별자가 존재하지 않는다. 불변 객체라는 것은 상태를 변경할 수 없다는 것을 의미하고 이는 상태가 곧 식별자임을 의미한다.

동일한 상태 uri를 가지는 WebPage 객체를 생성했을 때 각각 객체는 서로 다른 객체일까? 각각 인스턴스를 생성했다고 하더라도 실제로 동일한 uri를 대표하는 객체이기 때문에 동일한 객체이다. 따라서 동일한 상태를 캡슐화하는 인스턴스를 중복해서 생성하지 말아야하는 이유이기도 하다.

불변 객체는 자신이 대표하는 엔티티를 절대 변경하지 않고 항상 동일한 엔티티를 대표한다. 반면에 가변 객체는 처음에 대표했던 엔티티 uri = a.comuri = b.com으로 변경할 수 있다. 그렇기 때문에 가변 객체에서는 상태와는 별개로 객체들을 구분할 수 있는 식별자를 두어야 하는 것이다.


상수 객체

상수 객체에서는 상태를 변경하는 메서드를 호출해도 항상 새로운 상수 객체를 반환하게 된다. (기존 객체에 수정하는 것은 가변)

마치 불변 객체와 비슷해 보이지만, 객체가 대표하는 실제 엔티티와 상태가 동일한 경우 이를 상수 객체라고 사용한다. 하지만 모든 상수 객체는 불변 객체의 특별한 케이스이기 때문에 불변 객체를 사용하는 것에 중점을 두자.


상태? 데이터?

const [state, setState] = useState(data);

상태랑 데이터가 헷갈려서 리액트 state로 정리를 시도해보았다!

상태가 state이고 그 상태에 데이터를 setState해서 들고 있는 형태이다. 불변 객체에서 상태는 곧 식별자이기 때문에 state 자체는 동일하다. 하지만 그 상태에 넣을 수 있는 데이터는 setState로 언제든 변경해줄 수 있는 것이다.

가변 객체에서는 상태가 useState가 아니라 let state = data라고 생각해보았다. 상태가 대표하는 엔티티를 변경할 수 있기 때문이다.

그렇다면 상수 객체에서는 useState, let 둘 다 아닌 const state = DATA 형태인건가?

상태에 데이터를 담고 있다