JIGGAG

자료구조가 아닌 진정한 객체

2021년 10월 4일

진정한 객체


getter, setter를 사용하지 말자

class Cash {
  private dollars?: number;

  getDollars() {
    return this.dollars;
  }

  setDollars(val: number) {
    this.dollars = val;
  }
}

Cash 클래스의 dollars 프로퍼티를 외부에서 Cash.dollars 형태로 직접적으로 접근할 수 없도록 private으로 숨겨두기는 했으나 getDollars, setDollars을 이용해 외부에서 프로퍼티에 대한 정보를 가져갈 수 있는 형태이다.

클래스에서 public 프로퍼티를 사용하지 않기 위해 private으로 만들고 getter, setter를 사용했다. 하지만 이것은 프로퍼티에 직접적으로 접근해 정보를 가져가고 변경할 수 있는 가변 클래스이며 진정한 객체로써 의미를 갖고 있지 않다.

진정한 객체가 아니라면 이 클래스는 무엇일까?


객체 vs 자료구조

위의 Cash 클래스는 객체를 지향하고 있지만 객체로써 능동적으로 동작하지 않고 사용자가 직접 get, set 해주어야만 하는 형태로 단순 자료구조일뿐이다.

객체와 자료구조의 가장 큰 차이점은 객체가 능동적인 역할을 한다면 자료구조는 정말 데이터만 갖고 있는 구조이다.

// 자료구조
const CashStruct = {
  dollars: 0
};

CashStruct.dollars = 1;
console.log(CashStruct.dollars); // 1

// 객체
class CashClass {
  private dollars = 0;

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

  print() {
    return this.dollars;
  }
}
const cashClass = new CashClass(1);
console.log(cashClass.print()); // 1

CashStruct는 클래스가 아닌 단순 자료구조 형태로 작성하였지만 CashClass 객체와 동일하게 동작하는 것으로 보인다.

자료구조에서는 프로퍼티가 public이기도 하지만 어떤 형태로든 직접 접근하여 해당 값을 얻거나 수정할 뿐 단순한 데이터 모음인 것이다. 반면 클래스에서는 어떤 방법으로도 직접적으로 프로퍼티에 접근하는 것을 허용하지 않으며 외부에 직접 노출되지 않는다. (= 캡슐화)

그렇기 때문에 자료구조에서 CashStruct.dollars로 직접 요청하던 방식과는 다르게 cashClass.print()라고 객체에게 값을 알려달라고 요청해야한다.

자료구조는 수동적으로 사용자에게 직접 요청을 당하지만 객체는 능동적으로 사용자가 요청한 것을 판단해서 반환해주는 것이다.

그럼 자료구조이면서 객체 형태를 가지면 안되는 것일까?


자료구조보다 객체를 선택하는 이유

그동안 계속 이야기 했던 것은 모든것을 객체로 만들어서 사용하는 것이였다. (정적메서드, 상수 모두...)

단순하게 데이터를 담아둘 곳이 필요한 상황에서도 자료구조가 아니라 객체로 만들어야만 하는 이유도 동일하다. 모든것은 유지보수를 위한 것이다.

가시성의 범위를 축소하고 대상을 단순화하여 특정 시점에 이해해야하는 범위를 작게 만들어 유지보수성을 향상시키고자 한 것이다. (자료구조로 만들어도 충분히 축소하고 단순화할 수 있을 것이라 생각이 들고 있지만)

3.2장에서 정적메서드 대신 객체를 사용하는 내용에서도 나왔듯 결국 객체를 합성하기 위해 객체를 준비해야하는데 중간에 객체가 아닌 자료구조가 껴있다면 통일성도 줄어들기 때문에 유지보수에 감점요인이 될 수 있다.

3.3장에서 null이 처음 나오게된 예시로 절차적 언어에서의 포인터를 예시로 들었었다. 포인터를 통해 데이터의 위치를 찾아 접근해야하는데 데이터 묶음이 필요한 경우 하나의 자료구조로 묶어서 하나의 포인터로 쉽게 사용하고자 하였다. 그러나 객체지향이 되면서 포인터 개념이 사라지고 객체가 능동적으로 데이터를 판단하도록 하면서 자료구조의 역할을 대체할 수 있게 되었다.

객체지향에서는 객체가 캡슐화된 데이터를 능동적으로 판단하고 요청에 따라 반환하도록 해야한다.


객체 설계 원칙: 캡슐화를 넘어서고자

객체를 자료구조로 바꾸는 과정에서 getter, setter는 객체의 캡슐화 원칙을 위반하기 위해 도입되었다.

자료구조에서는 직접적으로 데이터에 접근이 가능해야하는데, 객체에서는 private으로 되어있어서 이를 직접적으로 접근할 수 있도록 비밀 통로를 만들어준 것이다. 클래스 프로퍼티를 public으로 만들면 자료구조와 동일하게 동작할 수 있지만 이것은 클래스 기본 원칙을 아예 위반하는 것으로 최소한 지킬 것을 지키고자 했던 결과가 getter, setter이다.

내부 프로퍼티에 외부에서 접근하는 것을 허용하지 않으면서 외부에 직접적으로 노출하지 않는다는 캡슐화 원칙을 위반하고자 메서드 형태를 하고 있지만 실질적으로 데이터에 접근할 수 있는 getter, setter를 만들었다. 데이터를 객체가 능동적으로 판단해서 처리하는 것이 아니라 단순히 요청에 의해 전달하는 역할만 하게 된 것이다.

근데 객체지향인데 왜 객체를 자료구조로 다시 바꾸려고 했던 것일까...??


의존성 주입을 위해 부생성자에서만 new를 허용하자

class Cash {
  private dollars: number;

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

  euro() {
    return new Exchange().rate() * this.dollars;
  }
}

euro 메서드에서 new를 사용하게 되면서 Exchange 클래스에 직접적으로 의존되어 있는 형태이다.

만약 외부 라이브러리가 이처럼 의존되어 있는 형태이고 euro 메서드를 단위 테스트 해야하는데 의존된 Exchange 클래스에서 문제가 발생한다면 목적에 어긋나게 된다. 이 의존성을 끊기 위해 직접적으로 Cash 코드를 수정한다면 레포를 포크해서 수정하고 라이브러리 버전 변경에 따라 또 수정하고... 이런 불필요한 작업이 계속 이어지게 될 것이다.

그렇기 때문에 new 연산자를 사용하는 것을 최소화하려고 한다.

메서드에서 사용하는 것을 모두 제거하고 생성자에서만 new를 사용할 수 있도록 제한하였다. 그리고 사용자가 필요에 의해 의존성을 처리할 수 있어야하므로 주생성자, 부생성자로 나눠서 직접 인자로 넘겨받을 수 있도록 해야한다.

class Cash {
  private dollars: number;
  private exchange: Exchange;

 // 주생성자 constructor(dollars, exchange)
 // 부생성자 constructor(dollars)
  constructor(dollars: number, exchange = new Exchange()) {
    this.dollars = dollars;
    this.exchange = exchange;
  }

  euro() {
    return this.exchange.rate() * this.dollars;
  }
}

(ts에서 부생성자 형태를 만들어보기 위해 옵셔널로 추가하였다)

주생성자에서 dollars, exchange를 둘 다 인자로 받아서 처리한다면 부생성자에서는 dollars만 인자로 받고 내부적으로 의존된 인스턴스 Exchange를 생성해서 사용하도록 하였다.

이렇게 되면 의존성을 제어하는 주체가 Cash가 아니라 사용자가 된다는 점에서 차이가 생겼고 Cash는 Exchange에 의존하지 않아도 괜찮은 상태가 되었다. (의존은 하지만 실제로 의존성 제어를 사용자가 하게 되면서 Cash는 조금 더 자유로워진 형태)

객체가 의존성을 직접 생성하지 않고 우리가 생성자를 통해 주입하는 것이다.


타입 캐스팅, 인트로스펙션을 사용하지 말자

타입 캐스팅이나 인트로스펙션을 활용하면 런타임에 객체의 타입을 체크해서 타입 조건에 따라 코드를 분기할 수 있다.

if (item instanceOf Collection) {
  ...
} else if (item instanceOf Iterable) {
 ...
}

코드가 런타임에 item이 Collection의 인스턴스인지 확인하고 이후 로직을 수행한다는 점에서 오히려 최적화된 것처럼 보인다.

하지만 오히려 이런 타입에 따라 객체를 차별하고 로직을 수행한다는 것은 item 객체가 어떤 방법으로 처리되는지를 능동적으로 자신이 결정하는 것이 아니라 사용자가 직접 차별을 통해 결정을 해주는 형태이다.

런타임에 타입을 조사하는 것은 클래스 사이의 결합도가 오히려 높아지고 (item이 Collection나 Iterable 모두 들어올 수 있다는 것이기 때문에) 각각 모든 인터페이스를 의존하고 있는 것이다.

외부에서는 이 로직에서 타입을 조사하고 있는지 알 수 없기 때문에 인스턴스를 어떤것을 보내야 의도한 로직이 수행될 것인지 알 수 없고 오히려 유지보수에 악영향을 주게 된다.

타입에 따라 다른 로직을 수행하여야한다면 메서드 오버로딩을 통해 인스턴스마다 수행할 수 있는 로직을 아예 분리하도록 하자.


의존성 주입(dependency injection)? 제어역전(inversion of control)?

의존성 주입이란 말그대로 의존성을 외부에서 주입해주는 것이다. (생성자에서 받거나, 파라미터로 받거나)

그렇다면 같이 이야기 되는 제어 역전은 무엇일까?

관련 내용을 찾다보니 스프링이 많이 예시로 나왔다. 스프링 어노테이션 Autowired을 사용해 클래스에서 객체를 생성하고 관리해주는데 이것이 제어역전이다.

내가 직접 생성한 것이 아니라 스프링이 생성해주니깐 제어권이 역전된 상태이고 스프링이 직접 객체를 생성하면서 필요한 인스턴스를 주입하게 되는데, 이것은 의존성 주입이 된다. (이렇게 제어역전에서 의존성 주입으로 자연스럽게 넘어간다고?)

js에서 제어역전을 어디서 쓰고 있을지 생각을 해보고 있는데 지금은 콜백 이외에 생각이 나지 않는다ㅠㅠ

const load = (params: any, callback: (result: string) => void) => {
  const result = 길고_오래_걸리는_무언가_로직
  ...
  return callback(result);
};

const main = () => {
  const print = (result: string) => {
    console.log(result);
  }

  load(['param'], print);
};

무언가 오래걸리는 함수 load에 파라미터로 콜백을 전달하였다. 결과가 구해지면 콜백을 호출해달라고 요청하는 것이다. 이것은 콜백을 호출하는 주체가 내가 아니라 load 함수에게로 넘어간것이다. (=제어역전)