JIGGAG

객체지향, 클래스 그리고 생성자

2021년 8월 16일

객체지향 프로그래밍

절차적 -> 객체지향

- 명령어를 순서대로 처리하고 각 명령어가 데이터를 조작/제어하는 절차적 프로그래밍
- 각각의 객체로 구성되어 객체에서 객체로 메세지를 전달하고 호출하는 객체지향 프로그래밍

모든 프로그래밍의 기본이 절차적으로 되어 있고 이를 객체지향하겠다는 흐름으로 이어진다.

예를 들면 A가 B와 메일을 주고 받는 과정을 절차적으로 본다면 1~10까지 순서를 지키면서 호출해야만 하는 것이다.

1. A가 메일을 쓴다
2. A가 B에게 메일을 보낸다
3. B는 A가 보낸 메일을 받는다
4. B가 메일을 쓴다
5. B가 A에게 메일을 보낸다

이걸 객체지향하는 방법으로 변경해보면 A, B라는 객체가 메일을 쓴다, 보낸다, 받는다라는 기능을 갖고 있고 서로의 기능을 호출하여 동작한다.

[A: 메일을 쓴다, 보낸다, 받는다]
[B: 메일을 쓴다, 보낸다, 받는다]

1. A.메일을쓴다
2. A.메일을보낸다
3. B.메일을받는다
4. B.메일을쓴다
5. B.메일을보낸다

다시 보면 절차적객체지향이 유사해보인다 🙀 차이점이 있다면 절차적에서 각각 순서에 호출되는 함수는 개별적으로 동작하고 있고 객체지향에서 호출되는 함수는 객체에 속한 상태이다.

절차적 프로그래밍에서 각각의 함수가 독립되어 있기 때문에 순서를 지키지 않고 호출한다면 전혀 다른 결과가 나오게 된다. 반면에 객체지향 프로그래밍에서는 각각의 객체에 속해 있기 때문에 순서를 지키지 않아도 그 함수를 호출하게 되면 그 객체에게 원하는 결과값을 줄 수 있다.


함수형 프로그래밍

절차적 -> 객체지향 비교 예시를 작성하다보니 함수형 프로그래밍과의 차이점이 궁금해졌다. 위에서 느낀 것처럼 절차적객체지향이 유사해보이는데 가장 큰 이유가 각각 분리되어 있는 함수를 시간 순서에 따라 실행하기 때문이라고 생각했다. 그래서 함수형 프로그래밍을 떠올리면 A.쓴다().보낸다(B).받는다().쓴다().보낸다(A)처럼 파이프로 이어진다.


그래서

객체지향이 결국 절차적 프로그래밍에서부터 시작되었기 때문에 클래스, 객체라는 요소를 사용하기는 하지만 명령과 순서에 따른 실행은 여전히 남아있다. 그럼에도 절차적 보다 객체를 지향하는 것이 시작된 이유가 있겠지?

절차적에서 시간 순서대로 동작을 명령하는 코드를 유지보수하기에 어려움이 있다. 기존에 1 -> 2 -> 3만 존재하는 코드에 1 -> 3 -> 2A하는 결과값을 얻기 위해 추가되어야하는 코드는 이미 존재하는 함수에 대해서는 다시 호출하면 되지만 호출하는 순서가 변경되었기 때문에 완전히 새로운 결과값이 나오게 되고 내부적으로 역할은 같겠지만 새로운 결과값에 대한 처리를 위해서 재사용을 하지 못하고 2A라는 새로운 함수를 만들어야한다.

살짝 순서만 바꿨을 뿐인데 모든 코드를 수정해야하는 어려움이 생기게 되며 이처럼 유지보수성이 떨어진다.

이런 이유로 객체지향을 위해 클래스도 만들고 상속 받고 객체가 객체가 객체하는데 막상 객체지향적이지 못한 느낌을 받는 이유에 대해 엘레강트 오브젝트와 생각해보려고 한다.

참고: 절차적, 객체지향 프로그래밍


객체 출생

객체의 유효범위

객체를 지향하면서 유지보수성을 향상시키는 것이 목표이다. 코드를 이해하기 위해 봐야하는 곳이 많고 수정하기 위해 되돌아가고 펼쳤다가 접었다 반복해야한다면 유지보수가 어렵다고 느낀다. 그렇기 때문에 객체가 살아서 존재하는 유효범위를 최소화하여 모듈성과 응집도를 높여야한다.


클래스 네이밍 *er를 지양하자

객체 자신이 주체가 될 수 있도록 {Name}er 보다는 Name으로 명확하게 자신을 드러낼 수 있도록 한다.

class CashFormatter {
  ...
}

const instance = new CashFormatter();

클래스의 인스턴스, 객체를 생성하였다. 클래스가 객체를 만들어내는 역할을 하고 있기 때문에 클래스의 이름이 객체 그 자체를 나타내어야 한다. class CashFormatter로부터 생성되는 객체는 무엇일까? 3000 -> 3,000원로 변환하는 클래스라면 Cash라는 클래스의 won이라는 메소드를 구현하면 된다. 객체가 담고 있는 기능을 표현하는 네이밍이 아니라 객체가 반환하는 것이 무언인지를 표현해줘서 클래스가 좀 더 자립적이고 능동적인 주체가 될 수 있도록 해준다.

객체지향에서 클래스의 객체가 무엇을 캡슐화하였는지를 알려주어야 하는 것이다.

무엇을 하는지(what it does)가 아니라 무엇인지(what it is)


부 생성자 -> 주 생성자

클래스에 생성자가 많을수록, 메서드가 적을수록 더 응집도가 높아진다. (생성자가 여러개 존재하려면 오버로딩이 가능해야한데 ts에서는 불가능하고 생성자에 전달하는 인자를 옵셔널이나 유니온 타입으로 설정한다)

하나의 클래스에서 다양한 인자를 처리하고 동일한 객체를 생성한다는 것은 클래스가 유연하게 동작한다는 것이다. 같은 내용을 생성자가 아니라 메서드로 처리하게 된다면 인자 타입마다 코드가 분리 작성되면서 클래스의 초기 목적이 흐려질 수 있는 위험도(SRP 위반)가 있다.

그렇다면 클래스에서 주 생성자가 아닌 오버로딩을 통해 추가된 부 생성자에서는 어떤 기능을 하면 될까? 클래스를 어떤 인자를 전달하더라도 동일한 객체가 나오기 위해서는 오버로딩된 부 생성자에서 주 생성자를 호출하는 형태가 되어야한다. 이러한 형태는 중복 코드를 방지하고 간결하게 만들어 유지보수를 쉽게 도와준다.

class Cash {
  private dollars?: number;

  constructor(dollar?: number | string) {
    if (typeof dollar === 'number') {
      // 여기를 주 생성자라고 가정한다
      this.dollars = dollar;
    } else if (typeof dollar === 'string') {
      this.dollars = parseInt(dollar, 10);
    } else {
      this.dollars = 0;
    }
  }
}

위 코드에서는 생성자마다 각각 초기화를 하고 있어서 만약 초기화하기 전에 유효성 검사와 같은 로직이 추가되어야한다면 각각 생성자 코드에 작성해주어야한다.

class Cash {
  private dollars?: number;

  __constructor(dollar?: number | string) {
    if (typeof dollar === 'number') {
      // 여기를 주 생성자라고 가정한다
      return dollar;
    } else if (typeof dollar === 'string') {
      this.__constructor(parseInt(dollar, 10));
    } else {
      this.__constructor(0);
    }
  }

  constructor(dollar?: number | string) {
    this.dollars = this.__constructor(dollar);
  }
}

생성자 역할을 대신하도록 __constructor를 만들어서 인자에 따라 조건 분기하여 주/부 생성자 역할을 하도록하였다. 내부 프로퍼티 초기화를 주 생성자 한 곳에서만 처리할 수 있도록하여 중복된 코드 작성을 줄일 수 있었다.


생성자에 코드를 제거하자

주 생성자는 객체를 초기화하는 유일한 장소이기 떄문에 어떤 인자도 누락되지 않고 중복되지 않도록 완전해야한다. 그렇다면 생성자로 받은 인자들이 가공되기 시작한다면 그 데이터가 완전한지 확인할 수 있을까?

객체를 초기화하는 생성자에는 code free 코드가 없어야하고 인자를 가공하지 않은 raw form 그대로 캡슐화해야한다.

class Cash {
	private dollars?: number;

  constructor(dollar: string) {
		// 1. 로직이 있는 경우
    this.dollars = parseInt(dollar, 10);

		// 2. 로직이 없는 경우
		this.dollars = new StringAsInteger(dollar);
  }
}

이처럼 전달받은 인자를 포맷팅하는 로직이 생성자에 들어가있는 것이 아니라 다른 인스턴스를 호출해서 캡슐화한다.

객체지향에서 인스턴스화하는 것은 더 작은 객체들을 조합하여 더 큰 객체를 만드는 것이다. 새로운 객체를 만들기 위해 조합해야하는 이유는 기존과는 다른 새로운 엔티티가 필요하기 때문이다. string을 parseInt해서 number로 만드는 것이 아니라 string을 인자로 받아 number를 갖는 객체를 만들어낸 것이다.

생성자에서는 단순 초기화만 진행하고 메서드를 통해 파싱하도록 로직을 분리한다면 파싱 로직의 최적화 작업이나 실행 여부를 제어하는 등 객체가 요청을 하는 시점에 실행되도록 할 수 있다. 생성자에서 파싱을 진행하게 된다면 객체를 생성할 때마다 파싱 작업이 실행되기 때문에 파싱된 데이터가 필요하지 않은 경우에도 실행되면서 불필요한 자원이 되어버린다.

객체 초기화와 로직을 분리하면서 객체를 인스턴스하는 동안에는 객체를 만드는 일 build 외에는 어떤 일도 수행하지 않도록하였다. 실제 작업은 객체의 메서드가 처리하도록 하면서 객체의 동작을 최적화하고 직접 제어할 수 있기 때문에 재사용하기도 쉽다. 이 클래스가 미래에 어떻게 다시 쓰일지 예상할 수 없으니까 미리 Lazy하게 만들어두는 것 (컴포넌트가 언제 다시 사용될지 모르니깐 memoize하는 것과 닮았다)

내부에 포함된 모든 객체들을 생성하고 동작할 수 있도록 준비만 시켜두고 필요한 시점에 호출한다.