JIGGAG

불변객체로 안전하게

2021년 9월 5일

가변 보다는 불변

불변 객체

모든 클래스를 불변 클래스로 구현하여 유지보수성을 향상시킬 수 있다.

불변 클래스를 사용하면 인스턴스를 생성한 뒤에는 상태를 변경할 수 없기 때문에 상태를 변경하고 싶다면 변경된 상태를 갖는 새로운 객체를 생성해야만한다.

class Cash {
  private readonly dollars: number;

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

	initial(val: number) {
    this.dollars = val; // readonly라서 생성자가 아닌곳에서 변경이 불가능하다
  }

	mul(val: number) {
    return new Cash(this.dollars * val);
  }
}

지연로딩

불변 객체를 사용하게 되면 생성자에서 무조건 초기화를 해야하는데 지연로딩이 가능한 것일까?

지연로딩이란 실행시간을 단축하고자 실제로 사용되는 순간까지 초기화를 지연시키는 것인데, 그렇다면 불변객체에서는 지연로딩이 불가능한 것이다!

(코틀린에서는 지원하고 있지만 일반적으로 불변 객체로 지연로딩 시키는 것은 불가능해보인다...)


가변객체를 사용했다면

어느날 갑자기 의도와는 다르게 동작하는 코드를 만나게 되고 코드상으로 찾는 것이 어려운 숨어있는 문제점... 가변성🚨

로직상으로는 문제되는 것이 없어보이는데 어디서 잘못된 값이 들어가고 있을까 디버깅하다보면 정말 의외의 지점에서 순식간에 값이 변경되고 있음을 발견하게 된다.

만약 가변 객체를 사용한다면 의도와 다른 동작을 하게 될 위험이 크기 때문에 불변 객체를 사용하는 것이 좋다. 좀 더 와닿도록 가변성이 주는 여러 위험도를 미리 확인해보자.


식별자 가변성 (Identity Mutability)

Mapkey값으로 가변객체를 사용하게 되면 해당 객체가 변경되었을때 key값이 변경되어버린다.

class Cash {
  private dollars: number;

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

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

const map = new Map<Cash, number>();
const cash1 = new Cash(1);
const cash2 = new Cash(2);
map.set(cash1, 1);
map.set(cash2, 2);
cash1.mul(2);
// Map(2) { Cash { dollars: 1 } => 1, Cash { dollars: 2 } => 2 }
// Map(2) { Cash { dollars: 2 } => 1, Cash { dollars: 2 } => 2 }

가변 객체 cash의 상태값이 변경되면서 Mapkey값으로 보고 있던 Cash { dollars: 2 }가 동일해져버렸다.

Mapkey는 동일하게 입력하면 값이 덮어씌워지는데 가변객체로 변경된 key는 인지하지 못하기 때문에 발생하는 현상이다.

이렇게 식별자가 가변으로 설정되면 의도치 않게 key값이 변경될 수 있어서 곤란하다!


실패 원자성 (Failure Atomicity)

성공하던가 실패하는 둘 중 하나가 아닌 중간의 값을 가지게 되면 안된다.

가변객체의 상태 변경 처리 도중 에러가 발생하여 이후 로직을 실행하지 못했다면, 가변객체의 일부만 상태가 변경된 상황이 발생한다.

class Cash {
  private dollars: number;
  private cents = 0;

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

  mul(val: number) {
    this.dollars *= val;
    // 만약 에러가 발생한다면 아래 로직은 처리되지 않는다
    this.cents *= val;
  }
}

이런 상태는 의도하지 않은 결과를 갖게 되므로 중간에 실패하는 경우 아무것도 변경되지 않는 원자성을 지켜주어야한다.

  mul(val: number) {
		// 에러 조건을 위에서 처리하고 성공하는 경우에만 아래 로직을 처리하도록 할 수 있다
    this.dollars *= val;
    this.cents *= val;
  }

가변 객체에서도 에러 처리 이후에만 상태값을 변경하는 로직을 구현한다면 원자성을 지킬 수 있지만 모든 로직을 이렇게 성공 이후 처리하거나 롤백하도록 작성한다는 약속을 해야한다.

각각 메서드마다 로직이 복잡해지기 때문에 이 약속에 어긋난다면 전혀 다른 값이 되면서 오류를 찾는 것도 어려워질 것이다.

따라서 모두가 약속하고 매번 확인하는 것보다 불변객체를 사용해 마음 편히 갖자!


시간적 결합 (Temporal Coupling)

가변 객체를 사용하게 되면 상태를 변경하는 시점에 따라 다른 값을 갖게 된다.

class Cash {
  private dollars: number;
  private cents = 0;

  constructor(val: number) {
    this.dollars = val;
  }
  
  setCents(val: number) {
    this.cents = val;
  }
  setDollars(val: number) {
    this.dollars = val;
  }
}
const cash1 = new Cash(1);
cash1.setDollars(10);
cash1.setCents(9);
console.log(cash1); // Cash { cents: 9, dollars: 10 }

const cash2 = new Cash(1);
cash2.setDollars(10);
console.log(cash2); // Cash { cents: 0, dollars: 10 }
cash2.setCents(9);

출력 시점에 따라 상태값이 다르다... 코드가 시간적 결합에 의존적이 상태를 갖고 있어서 유지보수에 어려움이 있게 된다. 실행 순서가 항상 유지되어야 하기 때문인데, 인스턴스화와 초기화를 분리하는 가변 객체에서 발생하는 문제를 불변 객체를 사용해 해결할 수 있다.

이런 코드는 항상 많이 보고 어느순간 작성하고 있게 된다. 코드의 역할이 분산될수록 이런 형태의 코드를 작성하게 되는 것 같다. 역시 코드리뷰와 리팩토링을 통해 수정해가다보면 이렇게 많이 간결해지다니! 하는 것을 느낄 정도...


부수효과 제거 (Side effect free)

객체가 가변적일 때에는 언제든 상태가 변경이 가능하기 때문에 언제든 의도치 않게 변경될 수 도 변경해버릴 수 도 있다... 실제로 변경되는 코드가 있는 곳이 아닌 인스턴스만을 사용하고 있는 다른 곳에서는 놀라버린다!


Null 참조 제거

클래스 생성자가 아닌 초기값을 null로 주게 되면 인스턴스를 생성할 때 생성자에서 설정해주지 않아도 null로 세팅되어있다. 이것은 인스턴스를 사용하는 모든곳에서 null인지 아닌지를 먼저 확인하는 작업이 필요해진다. if (data !== null) 조건을 통해서 데이터 안전성을 확인하고나서야 이후 작업이 진행되는데, 초기화 전이라 null인 것인지 실제 데이터가 null인 것인지 구분이 어려워 결국 또 다른 무언가 구분값이 필요하게 된다.


스레드 안전성

멀티 스레드에서 동시에 사용될 수 있는데 가변 객체를 사용하게 되면 스레드마다 값을 변경하게 되었을때, 스레드마다 다른 값을 바라보게 될 수 있다. 불변 객체를 이용해서 스레드마다 객체 상태를 변경하는 일을 방지해서 안전성을 가질 수 있도록 한다.


작고 단순한 객체

불변성을 이용해 코드의 안전성을 유지할 수 있고 그만큼 코드에 불필요한 것들이 존재하지 않기 때문에 단순하게 유지할 수 있다. 코드가 단순해지면 유지보수하기 쉽다!