진정한 객체
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 함수에게로 넘어간것이다. (=제어역전)