By kimcoder
2024.02.04

ts-pattern을 활용하여 해결했던 것

현재 운영 중인 서비스에서 도입한 ts-pattern 이라는 라이브러리를 통해 여러 문제들을 해결했던 내용들에 대해 작성해 볼 예정이다.

패턴 매칭은 자바스크립트도 제안 단계이기 때문에 알고 있는 사람들도 있겠지만, 일반적인 경우에 자바스크립트로만 개발을 해왔던 개발자라면 패턴 매칭이라는 개념 자체도 생소할 수 있다.

따라서, 글의 주제인 ts-pattern 을 활용하여 해결했던 것을 이야기하기 전에, 패턴 매칭이라는 것부터 친숙해질 수 있는 시간을 가져보자.

패턴 매칭?

패턴 매칭(pattern matching)데이터를 검색할 때 특정 패턴이 출현하는지, 또한 어디에 출현하는지 등을 특정하는 방법의 일종이다.

패턴 매칭은 검색, 분석, 정규화, 추출 등 다양한 응용 분야에서 사용하고 있으며, 자바스크립트에서도 정규표현식을 활용하여 문자열에서 특정 패턴을 찾아 검색, 추출, 대체 등을 수행할 때 사용되는 개념이다.

현재 많은 함수형 언어(Rust, Swift, Elixir, Haskell, Erlang, F#, Scala 등)에서 직접적인 패턴 매칭이 지원되며, Python도 3.10 버전에서 구조적 패턴 매칭(Structural Pattern Matching)이라는 이름으로 도입되었다.

직접 패턴 매칭 기능을 활용하면 데이터의 값, 자료구조, 타입 등 여러가지 유형의 패턴으로 검색을 수행할 수 있고, 특정 데이터 구조에서 원하는 값을 추출할 수도 있다. 이는 적은 양의 코드로 많은 양의 데이터를 읽고 다루기에 아주 좋은 표현 방법이며, 이로 인해 코드의 가독성과 유지보수성도 향상시킬 수 있다.

자바스크립트에서의 사용

아직까지 자바스크립트에서는 사용할 수 없다.

앞서 말한 듯이 ECMA 표준이 되기 위한 제안 단계(TC39 process, Stage 1)일뿐이다. 아래와 같은 문제들을 가지고 제안이 시작되었으니 간단히 맥락 정도만 짚고 넘어가 보자.

  • 자바스크립트에 값을 일치시키는 방법은 많지만, 문자열을 위한 정규표현식 외에 패턴을 일치시키는 방법은 없다.
  • switch에는 많은 제약이 있다.
    • 유일한 비교는 일치 연산자(===)만이 사용 가능
    • 우발적인 실패를 방지하기 위해선 각 case별로 break 문이 필요
    • case 의 애매한 scope ( 중괄호로 감싸지 않는다면 블록 범위 변수는 다른 case 에서도 접근 가능 )
    • 기타 등등

오래전 자바스크립트 생태계와 달리 다룰 수 있는 것들 것들이 넓고 더 깊어지면서 크고 다양한 값들을 처리하기 위한 방법이 필요해진 것으로 맥락을 해석할 수도 있을 것 같다.

매년 연말에 실시하는 State of JavaScript에서도 패턴 매칭 기능에 대한 니즈를 보여주고 있다.

top_currently_missing_from_js

자바스크립트 생태계는 아니지만 파이썬의 패턴 매칭 도입에 얽힌 이야기도 같이 읽어보면 재미있을 것 같다.

결국 현시점에서 자바스크립트에서 사용은 별도 라이브러리의 도움을 받을 수밖에 없는 상황이고, 이를 도와주는 ts-pattern 과 몇 가지 사례를 소개하겠다.

ts-pattern

TC39로의 제안 깃허브에도 소개된 ts-pattern은 타입스크립트 환경에서 패턴 매칭을 쉽게 표현할 수 있게 해주는 라이브러리이다.

더 안전하고 나은 조건을 작성할 수 있고, 복잡함을 간결하게 표현할 수 있으며 그에 따라 코드 가독성도 좋아지고 철저한 타입 체크를 통해 케이스 누락도 방지할 수 있다.

지난 2년간의 npm trends만 보더라도 라이브러리의 꾸준한 인기와 엄청난 기세를 확인할 수 있다.

npm-trends-of-ts-pattern

ts-pattern의 주요 기능과 API들을 간단히 둘러보고 이것들을 바탕으로 어떤 문제들을 해결했는지 확인해 보자.

주요 기능

  • 중첩된 객체, 배열, 튜플, 셋, 맵 및 모든 기본 유형 등 모든 데이터 구조에 대해 패턴 매치 가능
  • 타입 추론을 통한 타입 안전성 확보
  • 철저한 타입 체크를 통해 가능한 모든 경우를 .exhaustive()와 일치하도록 강제
  • 패턴을 사용하여 isMatching으로 데이터 형태를 검증 가능
  • catch-all, type-specific wildcards를 제공하여 표현력 좋은 API
    • catch-all: P._
    • type-specific wildcards: P.string, P.number, P.array, etc..
  • 복잡한 경우를 위해 예측, 결합, 교차 및 제외 패턴을 지원
  • P.select(name?) 함수를 통해 속성 선택을 지원
  • ~2kB 밖에 되지 않은 작은 번들 크기

주요 API

위에서 나열했던 기능들 대비하여, ts-pattern은 사용하기 위해 사전에 알아두어야 할 API가 많지는 않다.

다른 언어에서 패턴 매칭을 사용하는 문법들이 익숙하다면 아주 쉽게 접근할 수 있을 것이다. 그렇지 않더라도 어려운 것은 없으니 크게 걱정할 것은 없다. 천천히 예시 코드들과 API들에 대해 확인해 보자.

기본적인 코드의 형태는 아래와 같은 모습이다.

1import { match } from 'ts-pattern';
2
3const value = [1, 2, 3];
4
5const result = match(value)
6  .with([1, 2, 3, 4], () => 'it is [1, 2, 3, 4]')
7  .with([1, 2, 3], () => 'it is [1, 2, 3]')
8  .with([1, 2], () => 'it is [1, 2]')
9  .run();
10
11console.log(result); // it is [1, 2, 3]

match(value)

ts-pattern은 내부적으로 빌더 패턴으로 디자인된 MatchExpression이라는 클래스를 사용하여 매칭을 처리한다.

match(value)는 이 클래스의 인스턴스를 생성하여 반환해 주는 아주 간단한 함수이다. 따라서, 이 함수의 실행은 항상 선행되어야 한다.

.with(pattern, [...patterns], handler)

입력 값을 패턴별로 매칭을 시켜주는 메서드이다.

패턴 인자의 타입은 일반적으로 입력 값의 타입에 의해 결정된다. (물론, 아래처럼 입력의 타입 추론이 힘든 경우도 있다. )

1import { match, P } from 'ts-pattern';
2
3const toString = (value: unknown): string =>
4  match(value) // -> value가 unknown이기 때문에 아래 with 함수의 패턴 인자로 여러 타입을 넣을 수 있다.
5    .with(P.string, (str) => str)
6    .with(P.number, (num) => num.toFixed(2))
7    .with(P.boolean, (bool) => `${bool}`)
8    .with({ kimcoder: '😊' }, (obj) => JSON.stringify(obj))
9    .otherwise(() => 'Unknown');
10
11console.log(toString('aa')); // 'aa'
12console.log(toString(123)); // 123.00
13console.log(toString({ kimcoder: '😊' })); // {"kimcoder":"😊"}

타입스크립트를 사용하면서 많은 타입들에 대한 정의가 되어 있어 추론이 잘 되는 상황이라면, 위의 예시는 예외적인 상황이라고 볼 수 있다.

또, .with 메서드는 오버로딩된 함수로, 아래처럼 여러 패턴들을 나열한 형태로 호출할 수도 있다.

1import { match } from 'ts-pattern';
2
3const upperCase: string = 'A';
4const printUpper = () => 'it is upper';
5const printLower = () => 'it is lower';
6
7const first = match(upperCase)
8  .with('a', printLower)
9  .with('b', printLower)
10  .with('A', printUpper)
11  .with('B', printUpper)
12  .otherwise(() => 'where is it?');
13
14const second = match(upperCase)
15  .with('a,', 'b', printLower)
16  .with('A', 'B', printUpper)
17  .otherwise(() => 'where is it?');
18
19console.log(first); // 'it is upper'
20console.log(second); // 'it is upper'

.with가 체이닝되는 모습처럼 호출 순서가 위에서 아래로 흐른다는 점은 꼭 기억해두어야 한다.

중첩 객체와 같이 복잡한 구조를 가진 값의 패턴들을 검사하고자 한다면 항상 세부적인 형태의 패턴은 먼저 검사를 해야 한다. ( 이는 다른 언어에서도 마찬가지이다. )

.exhaustive(), .otherwise(), .run()

위 함수들은 패턴 매칭 표현식을 실행하고, 결과를 반환하는 MatchExpression의 메서드이다.

메서드설명
exhaustive(): TOutput- 입력 값과 일치하는 패턴이 없으면 타입 체크가 실패한다.
(철저한 타입 체크, Exhaustiveness Type Check)
- 일치하는 패턴이 런타임에 꼭 있어야 한다.
(그렇지 않으면 오류가 발생한다.)
otherwise(handler: (value: TInput) => TOutput): TOutput- 일치하는 패턴이 없을 경우에 실행된다.
- switchdefault로 생각하면 편하다.
run(): TOutput- 일치하는 패턴이 런타임에 꼭 있어야 한다.
(그렇지 않으면 오류가 발생한다.)
1match('2024 years!').exhaustive();
2
3/*
4  위 코드는 NonExhaustiveError 명목으로 타입 체크가 실패한다.
5  만약, 타입 불일치 경고를 무시하고 코드를 컴파일하고 실행하면, 런타임에서 아래와 같은 오류가 발생한다.
6  Error: Pattern matching error: no pattern matches value "2024 years!"
7  
8  아래와 같이 수정하면 정상적으로 타입 체크를 수행할 수 있다.
9
10  match("2024 years!")
11    .with("2024 years!", () => "okay")
12    .exhaustive();
13*/
1match('2024 years!').run();
2
3/*
4  위 코드를 컴파일하고 실행하면, 런타임에서 아래와 같은 오류가 발생한다.
5  Error: Pattern matching error: no pattern matches value "2024 years!"
6*/

위 메서드들은 결과를 반환하기 위해서는 필히 써야 하니, 위와 같은 특징들을 잘 인지하고 있어야 한다.

isMatching(pattern, value?)

isMatching는 타입 가드 함수이다. 커링이 된 버전과 그렇지 않은 버전, 2가지 버전으로 사용할 수 있다.

  • isMatching(pattern, value): boolean: 패턴과 값의 매치가 성공하면 결과를 boolean 값으로 알려준다.
  • isMatching(pattern): (value) => boolean : 패턴만 넣고 함수를 실행하면 해당 패턴에 대한 타입 가드 함수가 반환된다.

커링이 된 버전은 아래와 같이 좀 더 의미 있는 이름으로 표현하여 사용할 수도 있다.

1import { isMatching } from 'ts-pattern';
2
3const isDiscountProduct = isMatching({
4  salePrice: P.number.gt(0),
5  discountedSalePrice: P.number.gt(0),
6});
7
8if (isDiscountProduct(res.data)) {
9  // do something!
10}

Patterns

.with 메서드와 isMatching 함수의 인자로 들어가는 패턴들에 대해서도 알아야 한다.

패턴으로 사용할 수 있는 형태는 아주 많고, 표현력도 좋은 편이라 코드를 작성하는데도 쉽고 읽는데도 어려움이 없다.

아래와 같이 많은 패턴들이 존재하지만, 이 글에서는 몇 가지만 다룰 예정이다. 자세한 건 ts-pattern 문서에서도 확인할 수 있다.

  • Literals
  • Wildcards
  • Objects
  • Tuples (arrays)
  • Sets
  • Maps
  • P.array patterns
  • P.when patterns
  • P.not patterns
  • P.select patterns
  • P.optional patterns
  • P.instanceOf patterns
  • P.union patterns
  • P.intersection patterns
  • P.string predicates
  • P.number and P.bigint predicates

P.select(name?)

P.select는 입력한 데이터 구조에서 특정 값을 추출하고, 핸들러 함수에 값을 주입시킬 수 있다.

이는 복잡한 구조에서 특정 값만 추출하여 반환하고자 할 때 유용하게 쓰일 수 있다.

1import { match, P } from 'ts-pattern';
2
3const message = match(data)
4  .with({ order: { showOrderCount: true, orderCount: P.select() } }, (count) => {
5    return match(count)
6      .with(P.number.gt(0), () => `${count}번이나 주문했던 고객입니다!`)
7      .with(0, () => '첫 주문 고객입니다!')
8      .with(null, () => undefined)
9      .exhaustive();
10  })
11  .otherwise(() => undefined);

또, P.select를 호출 시 인자로 이름을 지정할 수도 있다. 입력받은 이름으로 핸들러 함수에서 값을 참조할 수 있다.

1type Data = {
2  product: {
3    name: string;
4    price: number;
5    isSale: boolean;
6    relateProducts: Product[];
7  };
8  ad: {
9    url: string;
10    bannersImages: string[];
11  };
12};
13
14match(res)
15  .with(
16    {
17      product: { name: P.select('name'), price: P.select('price') },
18      ad: { url: P.select('adUrl') },
19    },
20    ({ name, price, adUrl }) => {
21      return { name, price: price.toLocaleString(), adUrl };
22    },
23  )
24  .otherwise(() => '');

P.union()

P.union(...subpatterns)는 인자로 주어진 패턴들 중 하나라도 일치하면 매칭이 성공하는 것으로 처리시킬 수 있는 패턴이다.

1import { match, P } from 'ts-pattern';
2
3type Input =
4  | { type: 'user'; name: string }
5  | { type: 'org'; name: string }
6  | { type: 'text'; content: string }
7  | { type: 'img'; src: string };
8
9const output = match(input)
10  .with({ type: P.union('user', 'org') }, (userOrOrg) => {
11    // userOrOrg: User | Org
12    return userOrOrg.name;
13  })
14  .otherwise(() => '');

주어진 패턴들이 모두 일치해야지만 매칭이 되는 P.intersection(...subpatterns)와 같은 패턴도 있다.

Wildcards

자바스크립트의 원시 타입들에 대한 와일드카드로도 검사를 수행할 수 있다.

와일드카드의 종류는 다음과 같이 있고, 설명은 이름 그 자체로도 충분하다.

P._(=P.any) P.string P.number P.bigint P.boolean P.symbol P.nullish

wildcards
1import { match, P } from 'ts-pattern';
2
3const output = match<unknown>(input)
4  .with(P.string, () => 'it is a string!')
5  .with(P.number, () => 'it is a number!')
6  .with(P.bigint, () => 'it is a bigint!')
7  .with(P.boolean, () => 'it is a boolean!')
8  .with(P.symbol, () => 'it is a symbol!')
9  .with(P.nullish, () => 'it is either null or undefined!')
10  .with(P._, () => 'it is any!')
11  .run();

Predicates

숫자와 문자 유형의 값들에 대한 검사를 수행 시, predicates들을 사용하여 좀 더 표현력 좋고 간결한 코드를 작성할 수 있다.

1import { match, P } from 'ts-pattern';
2
3const output = match('kimcoder')
4  .with(P.string.startsWith('c'), () => '😊')
5  .with(P.string.regex(/sparkle/), () => '⭐')
6  .with(P.string.minLength(100), () => 'it is so long')
7  .with(P.string.includes('coder'), () => 'coder')
8  .otherwise(() => 'it is default handler');
9
10console.log(output); // coder
11
12const output2 = match(2024)
13  .with(P.number.between(1, 10), () => '1 ~ 10')
14  .with(P.number.negative(), () => 'it is negative number')
15  .with(P.number.gte(2024), () => '2024! 🎉')
16  .otherwise(() => 'it is default handler');
17
18console.log(output2); // 2024! 🎉

해결했던 문제

현재 운영 중인 배민사장님앱의 웹뷰를 개발하면서 ts-pattern을 적용하여 해결했던 문제들을 공유하고자 한다.

배민사장님앱은 사용자가 배달의민족의 여러 유형의 주문들을 접수하고 상태를 볼 수 있는 기능을 제공하고 있다. 주문 접수 처리 및 주문의 상태를 화면에 보여주기 위해선 API를 호출하여 데이터를 받아오고 있고, 이 API의 응답 객체는 사용되는 내용들에 비해 아주 크고, 깊은 뎁스를 가진 속성들도 있다.

사실 위의 내용이 큰 문제는 아니지만, 가장 까다롭고 어려운 문제는 이 하나의 데이터 구조에서 너무 많은 분기가 일어난다는 것이다. 그리고 앞으로 서비스가 고도화될수록 더 복잡해질 가능성이 크다는 것이다.

위의 내용을 단순화하여 도식으로 그려보면 아래처럼 표현할 수 있겠다.

단순하게 주문의 유형만 표시했지만 내부적으로는 렌더링을 하지 않도록 처리가 필요한 곳이 있고, 주문의 상태에 따라 분기가 또 필요하고, 배달 주문의 경우 배달 현황 상태 노출 등의 처리도 필요하다.

주문의 배달 현황을 노출하는 로직을 단순화하여 패턴 매칭 사용 전/후를 가볍게 비교해 보자.

useOrderRiderStatus.ts
1import { match, P } from 'ts-pattern';
2
3/*
4  라이더 상태에 대한 임의의 정책을 구성하여 [라이더 현황 텍스트]를 처리한다.
5
6    1. 신규, 취소 주문 상태에서는 화면에 노출하지 않는다.
7    2. 배달 주문에서만 화면에 노출한다.
8    3. 음식양이 많아 라이더가 여러 명인 경우 현황 텍스트에 설명 문구를 추가한다.
9    4. 라이더의 상태에 따른 문구를 노출한다.
10    5. 배차가 완료된 예약 주문인 경우엔 현황 텍스트를 다르게 노출한다.
11*/
12type Order = {
13  status: '신규' | '접수중' | '취소' | '완료';
14  type: '배달' | '포장' | '매장';
15  reservation: boolean;
16  rider: null | {
17    riderCompany: string;
18    status: '배차 전' | '배차 완료' | '배차 취소' | '오는 중' | '배달 중' | '배달 완료';
19    riderAssignTime: string;
20    riderCount: number;
21  };
22}; // 임의의 정책에 맞게 필요한 부분만 타이핑
23
24const getStatusText = (data: Order) => {
25  if (data.status === '신규' || data.status === '취소') return '';
26  if (data.type !== '배달') return '';
27  if (!data.rider) return '';
28
29  if (data.rider.status === '배차 전') return '라이더 배차를 기다리고 있어요.';
30  if (data.rider.status === '배차 완료') {
31    if (data.reservation) {
32      return '주문의 예약 시간을 확인하셨나요? 배차가 방금 막 되었어요.';
33    }
34    return '라이더 배차가 완료되었어요.';
35  }
36  // 일부 로직 생략
37};
38
39const getStatusTextWithPatterMatching = (data: Order) => {
40  const getEmpty = () => '';
41
42  return match(data)
43    .with({ status: P.union('신규', '취소') }, { type: P.not('배달') }, getEmpty)
44    .with({ rider: { status: '배차 전' } }, () => '라이더 배차를 기다리고 있어요.')
45    .with(
46      { reservation: true, rider: { status: '배차 완료' } },
47      () => '주문의 예약 시간을 확인하셨나요? 배차가 방금 막 되었어요.',
48    )
49    .with({ reservation: false, rider: { status: '배차 완료' } }, () => '라이더 배차가 완료되었어요.')
50    .otherwise(getEmpty);
51  // 일부 로직 생략
52};

패턴 매칭을 사용한 코드에서는 많은 조건식과 블럭들이 사라졌다. 연산자 또한 사라진 모습이다.

단지 요구사항 구현에 부합하는 패턴들을 나열하고 구현 처리를 해주는 함수들을 선언해두었을 뿐이다.

인라인으로 넣은 객체 형식의 패턴들이 많아 가독성 측면에서 조금 불편할 수 있다면, 이 패턴들을 변수로 선언하여 조금 더 의미 있는 값으로 표현하고 재사용성도 높일 수 있다. 주문 관련 영역에서 비슷한 요구사항들을 처리하는 곳이 많아서 { status: P.union('신규', '취소') } 패턴을 상수로 선언하고 그 상수를 참조하는 것으로도 코드의 재사용성과 가독성을 높일 수 있을 것이다.

.with({ status: P.union('신규', '취소') }, handler) -> .with(NEW_ORDER, CANCELED_ORDER, handler)

결과적으로 보면 동일한 동작을 수행하는 코드는 아래와 같은 모습으로 바뀌게 된다.

ASIS-TOBE
1if (data.status === '신규' || data.status === '취소') return ''; // ASIS
2
3.with(NEW_ORDER, CANCELED_ORDER, handler); // TOBE

마치며

앞서 이야기했듯이 아직은 자바스크립트가 패턴 매칭을 직접적으로 사용할 수 있는 환경은 아니다.

따라서, 서비스에 외부 의존성을 하나 추가하고 새로운 유형의 코드를 읽어야 한다는 것이 가장 큰 허들이 될 것이다.

그럼에도 불구하고 다루고 있는 문제들의 복잡도가 높고 안정적으로 서비스를 제공하는데 조금 더 포커스를 맞추고자 한다면, 사용을 고민하고 동료와 논의해 보는 것만으로도 좋은 시간이 될 것이라 확신한다. 이러한 시간을 가지는 것만으로도 패턴 매칭이란 것에 대해 인지할 수 있고 복잡한 문제 해결을 위한 옵션들이 늘어나는 것이라고 생각한다.

만약 현재 마주하고 있는 문제들의 복잡도가 높고 처리하는 데 많은 시간을 할애하고 있다면 ts-pattern이 우리를 도와줄 것이다.

참고