대수적 데이터 타입과 리액트 상태 관리

대수적 데이터 타입(ADT: Algebraic Data Type)이란?

대수적 데이터 타입에는 곱타입(product type)과 합타입(sum type)이 있습니다.

곱타입(Product type)


1type boolAndInt = (bool, int)

boolAndInt 타입은 bool 타입과 int 타입을 가지는 튜플 타입입니다. boolAndInt 타입이 표현할 수 있는 데이터의 개수는 int 타입으로 표현할 수 있는 수([-2^31 .. 2^31-1]) 곱하기 2 입니다.

1(true, 1)
2(false, 1)
3(true, 2)
4(false, 2)
5...

그래서 튜플은 곱타입(product type)이라고 합니다. 튜플, 레코드 등은 곱타입에 속합니다.

합타입(Sum type)


1type boolOrInt = Bool(bool) | Int(int)

boolOrInt 타입은 bool 혹은 int 중 하나가 되는 배리언트입니다. boolOrInt로 표현할 수 있는 데이터의 개수는 int 타입으로 표현할 수 있는 수([-2^31 .. 2^31-1]) 더하기 2 입니다.

1true,
2false,
31,
42,
5...

그래서 배리언트는 합타입(sum type)이라고 합니다. 배리언트, 태그드 유니언 등은 합타입에 속합니다.

대수적 데이터 타입이란 위의 예와 같이 곱하기 혹은 더하기로 조합된 데이터 타입을 말합니다. 예를 들어 어떤 두 개의 타입 A와 B를 곱하거나 더해서 C라는 타입으로 조합하면, 이것을 대수적 데이터 타입이라고 부를 수 있습니다.

숫자를 곱하거나 더하듯이, 숫자 대신 타입을 곱하거나 더하는 대상으로 사용하기 때문에 대수적(algebraic)이라고 합니다.

대수적 데이터 타입으로 본 리액트 상태관리

아래 리액트 컴포넌트는 bool과 int 타입, 두 개의 상태를 갖고 있습니다.

1@react.component
2let make = () => {
3 let (isError, setError) = React.useState(_ => false)
4 let (data, setData) = React.useState(_ => 1)
5
6 ...
7}

이 컴포넌트가 가질 수 있는 상태는 위에서 살펴본 곱타입과 같이 int로 표현할 수 있는 정수의 개수 곱하기 2가 됩니다. 즉, 상태가 하나씩 늘어날 때마다 컴포넌트는 곱으로 증가하는 상태를 가지게 됩니다. 상태가 늘어날수록 컴포넌트의 복잡도는 곱으로 늘어납니다.

1type status = (bool, int)
2
3@react.component
4let make = () => {
5 let ((isError, data), setStatus) = React.useState(_ => (false, 1))
6
7 ...
8}

두 개의 상태 값을 하나의 튜플로 만들고 useState를 하나만 사용하더라도, 상태가 줄어들어 컴포넌트의 복잡도가 낮아지지 않았다는 것을 알 수 있습니다. 왜냐하면 튜플은 곱타입이고, 여전히 int로 표현할 수 있는 정수의 개수 곱하기 2의 상태값을 갖고 있기 때문입니다. 레코드나 오브젝트로 튜플을 대체해도 마찬가지입니다. 레코드와 오브젝트 모두 곱타입이기 때문입니다.

합타입으로 상태를 정의하자.

자바스크립트에는 배리언트나 태그드 유니언이 없기 때문에, 보통 오브젝트로 데이터를 모델링하는 경우가 많습니다. 자바스크립트 라이브러리인 swr도 데이터 요청 응답의 결과를 오브젝트로 반환합니다.

1// 리스크립트의 레코드로 바인딩 한 swr의 응답 결과
2let {data, error} = Swr.useSwr(url, fetcher, options)

이 경우 총 4가지의 상태가 존재할 수 있습니다.

  • data(X), error(X) => 로딩중
  • data(O), error(X) => 성공
  • data(X), error(O) => 에러
  • data(O), error(O) => ??

error와 data는 런타임에 동시에 존재할 수는 있지만, 양립할 수 없는 상태라고 볼 수 있습니다. 즉, error와 data를 곱타입이 아닌 합타입으로 모델링한다면, 상태를 줄이고 복잡도를 낮출 수 있습니다.

자바스크립트에는 적절한 데이터 타입이 존재하지 않지만, 타입스크립트에서는 서로소 합집합 타입, 리스크립트에서는 배리언트 타입을 이용하여 합타입으로 상태를 모델링 할 수 있습니다. 그리고 모든 상태에 대해 대응하였는지 여부까지 컴파일 타임에 체크가 가능합니다.

1module Orders = {
2 type result = Loading | Loaded(Js.Json.t) | Error(Js.Promise.error)
3
4 let use = () => {
5 let {data, error} = Swr.useSwr(url, fetcher, options)
6
7 switch error {
8 | Some(error') => Error(error')
9 | None =>
10 switch data {
11 | Some(data') => Loaded(data')
12 | None => Loading
13 }
14 }
15 }
16}
17
18let status = Orders.use() // Loading | Loaded | Error

배리언트 타입의 응답 데이터를 반환하는 커스텀 훅을 만들어서 상태를 다시 정의할 수 있습니다. 합타입으로 상태를 정의하면 얻을 수 있는 이점이 있습니다.

  • 직교(orthogonal)하는 상태로 컴포넌트의 상태를 정의할 수 있습니다.
  • 불필요한 상태를 제거하여 컴포넌트의 복잡도를 줄일 수 있습니다.

합타입과 패턴매칭

합타입으로 조합된 타입들 각각은 동시에 존재할 수 없습니다. 리스크립트에서 패턴매칭은 모든 가능한 경우를 처리했는 지(Exhaustiveness checking)를 컴파일러가 보장해줍니다. 그래서 배리언트 타입의 상태와 함께 사용하면, 가능한 모든 상태에 대한 처리를 했는지 컴파일 타임에 체크할 수 있습니다.

1switch status {
2| Loading => <Loading />
3| Error(error) => <Error error />
4| Loaded(orders) => <Orders orders />
5}

결론

일견 복잡해보이는 구조의 데이터도 결국 원시 타입의 자료형으로 이루어졌을 것입니다. 만약 어떤 데이터가 원시 타입의 값을 합과 곱으로 조합한 대수적 데이터 타입이고, 그 데이터를 더하거나 곱할 수 있는 연산을 정의한다면, 복잡해 보이는 데이터도 더하고 곱할 수 있습니다. 역으로, 대수적 데이터 타입으로 데이터를 바라보면 합과 곱이라는 연산으로 데이터를 모델링하거나, 복잡한 구조로 보이는 데이터의 구조를 쉽게 파악할 수 있습니다.

리액트 컴포넌트의 상태는 컴포넌트의 복잡도를 결정합니다. 합타입을 이용하여 직교하는 상태로 정의하면 복잡도를 낮출 수 있습니다.



Gravatar for woonki.moon@gmail.com
문운기프론트엔드 개발자
2021. 08. 31.


참고자료


추천 콘텐츠