ts-pattern과 fxTs를 사용한 선언적 프로그래밍

정동환·2024년 7월 29일

티맥스 업무일지

목록 보기
5/8

00. 서론

백엔드에서 내려주는 응답값을 이용해 채팅방에 표시되는 이름(displayNick)과 이미지(displayPhotos)를 내려주는 작업을 진행했습니다.

복잡한 조건문을 읽기 쉽게 작성하기 위해 ts-pattern과 fxTs를 이용했습니다.

01. 요약

01.1 처음 코드


const { roomTypeName, representativeMemberList, roomName, roomProfileImagePath, roomId } =
          roomProfile;

const isDM = roomTypeName === DM_ROOM_TYPE;

if (isDM) {
  const memberListExcludingMe = representativeMemberList
  .filter(({ personaId }) => personaId !== CURRENT_PERSONA_ID);
  const isAlone = memberListExcludingMe.length === 0;

  const displayNick = isAlone
  ? '탈퇴 회원'
  : memberListExcludingMe.map(({ personaNick }) => personaNick).join(', ');

  const displayPhoto = [
    isAlone 
    ? getDefaultImageURL({
      type: 'PROFILE',
      personaId: CURRENT_PERSONA_ID,
    })
    : getUserProfileImageURL({
      profileImageFilepath: memberListExcludingMe[0].profileImageFilepath,
      personaId: memberListExcludingMe[0].personaId,
    })
  ];

  return {
    displayNick,
    displayPhoto,
  };
}

const displayNick = roomName ?? representativeMemberList.map(({ personaNick }) => personaNick).join(', ');

const displayPhoto = roomProfileImagePath ? 
  [
    getRoomProfileImageURL({
      roomId,
      extension: roomProfileImagePath,
    })
  ] : 
    representativeMemberList.map({ profileImageFilepath, personaId }) => getUserProfileImageURL({
     profileImageFilepath,
     personaId,
   }),
 );

 return {
   displayNick,
   displayPhoto,
 };
}

01.2 최종코드

const getMemberListWithoutMe = (memberList: RepresentativeMember[]) =>
    pipe(
      memberList,
      reject(({ personaId }) => personaId === CURRENT_PERSONA_ID),
    );

const isEmptyMemberListWithoutMe = (memberList: RepresentativeMember[]) => pipe(memberList, getMemberListWithoutMe, toArray, isEmpty);

const displayNick = match<RoomProfile, string>(roomProfile)
  .with(
    {
      roomTypeName: DM_ROOM_TYPE,
      representativeMemberList: P.when(isEmptyMemberListWithoutMe),
    },
    () => '탈퇴 회원',
  )
  .with({ roomTypeName: DM_ROOM_TYPE }, ({ representativeMemberList }) =>
    pipe(representativeMemberList, getMemberListWithoutMe, join(', ')),
  )
  .with({ roomName: P.nonNullable }, ({ roomName }) => roomName)
  .otherwise(({ representativeMemberList }) =>
    pipe(
      representativeMemberList,
      map(({ personaNick }) => personaNick),
      join(', '),
    ),
  );

const displayPhoto = match<RoomProfile, string[]>(roomProfile)
  .with(
    { roomTypeName: DM_ROOM_TYPE },
    {
      representativeMemberList: P.when(isEmptyMemberListWithoutMe),
    },
    () => [
      getUserProfileImageURL({ personaId: CURRENT_PERSONA_ID }),
    ],
  )
  .with({ roomTypeName: DM_ROOM_TYPE }, ({ representativeMemberList }) => {
    const theOtherMember = pipe(representativeMemberList, getMemberListWithoutMe, head);

    return [
      getUserProfileImageURL({
        extension: theOtherMember!.profileImageFilepath,
        personaId: theOtherMember!.personaId,
      }),
    ];
  })
  .with({ roomProfileImagePath: P.nonNullable }, ({ roomId, roomProfileImagePath }) => [
    getRoomProfileImageURL({
      roomId,
      extension: roomProfileImagePath,
    }),
  ])
  .otherwise(({ representativeMemberList }) =>
    pipe(representativeMemberList, map(getUserProfileImageURL), toArray),
  );

  return {
    displayNick,
    displayPhoto,
  };

02. 문제

백엔드에서 내려주는 채팅방의 데이터에 따라 프론트에서 채팅방의 이름(displayNick)과 이미지(displayPhoto)를 조합해서 채팅방을 보여줘야하는데 로직이 복잡했습니다.

03. 개선 과정

03.1 ts-patern

ts-pattern은 선언적 프로그래밍을 도와주는 라이브러리입니다.

패턴 매칭이 뭔지 Claude에게 물어보니 다음과 같이 말해주네요

패턴 매칭은 데이터 구조를 분석하고 그 구조에 따라 다른 동작을 수행하는 프로그래밍 기법입니다. 복잡한 if-else 문을 대체할 수 있어 코드를 더 읽기 쉽고 간결하게 만듭니다.

하지만 TypeScript에는 기본적으로 패턴 매칭 기능이 없기 때문에, ts-pattern을 통해 패턴 매칭의 이점을 활용할 수 있습니다

아래의 예제 코드를 살펴보겠습니다.

import { match, P } from 'ts-pattern';

type Result = { status: 'success'; data: string } | { status: 'error'; error: Error };

const handleResult = (result: Result) =>
  match(result)
    .with({ status: 'success' }, ({ data }) => console.log(`성공: ${data}`))
    .with({ status: 'error' }, ({ error }) => console.error(`에러: ${error.message}`))
    .exhaustive();
  • 위 예시에서는 match 함수가 result 객체를 분석해 with의 조건과 일치할 때 해당 동작을 진행합니다.
  • match의 마지막 체이닝으로 작성된 exhaustive를 이용해 모든 가능한 경우를 처리했는지 확인할 수 있습니다.
  • exhaustive 대신 otherwise를 이용해서 with절의 조건이 아닐 때의 기본 동작을 정의할 수 있습니다.

ts-pattern을 이용 전후의 displayNick 계산 로직들을 살펴보겠습니다

before

const isDM = roomTypeName === DM_ROOM_TYPE;

if (isDM) {
  const memberListExcludingMe = representativeMemberList.filter(
    ({ personaId }) => personaId !== currentPersonaId,
  );
  
  const isAlone = memberListExcludingMe.length === 0;

  const displayNick = isAlone
    ? '탈퇴 회원'
    : memberListExcludingMe.map(({ personaNick }) => personaNick).join(', ');

  return {
    displayNick,
  };
}

const displayNick = roomName ?? 
      representativeMemberList.map(({ personaNick }) => personaNick).join(', ');

after

const getMemberListWithoutMe = (memberList: RepresentativeMember[]) =>
  memberList
  .filter(({ personaId }) => personaId !== selectedPersonaId)

const isEmptyMemberListWithoutMe = (memberList:RepresentativeMember[]) => 
  getMemberListWithoutMe(memberList).length === 0;

const displayNick = match<RoomProfile, string>(roomProfile)
  .with(
  {
    roomTypeName: DM_ROOM_TYPE,
    representativeMemberList: P.when(isEmptyMemberListWithoutMe),
  },
    () => '탈퇴 회원',
  )
  .with({ roomTypeName: DM_ROOM_TYPE }, ({ representativeMemberList }) => 
    getMemberListWithoutMe(representativeMemberList).join(', '))
  .with({ roomName: P.nonNullable }, ({ roomName }) => roomName)
  .otherwise(({ representativeMemberList }) =>
    representativeMemberList
    .map(({ personaNick }) => personaNick))
    .join(', ')
  );

03.2 fxTs

마플의 개발자분들이 개발한 fxTs는 함수형 프로그래밍을 도와주는 라이브러리입니다. 선언적, 함수형으로 프로그래밍을 진행할 수 있으며 iterator를 이용한 지연평가로 성능 또한 높일 수 있습니다. 또한 array에서 제공하는 map, filter, reduce등의 함수를 다른 iterator에서 사용 가능하며 reject와 같이 더 많은 유틸 함수들을 제공합니다.

fxts가 제공하는 pipe와 유틸 함수들을 이용해 위의 코드를 수정해 보았습니다.

const getMemberListWithoutMe = (memberList: RepresentativeMember[]) =>
  pipe(
    memberList,
    reject(({ personaId }) => personaId === CURRENT_PERSONA_ID),
  );

const isEmptyMemberListWithoutMe = (memberList: RepresentativeMember[]) => 
  pipe(memberList, getMemberListWithoutMe, toArray, isEmpty);

const displayNick = match<RoomProfile, string>(roomProfile)
  .with(
    {
      roomTypeName: DM_ROOM_TYPE,
      representativeMemberList: P.when(isEmptyMemberListWithoutMe),
    },
    () => '탈퇴 회원',
  )
  .with({ roomTypeName: DM_ROOM_TYPE }, ({ representativeMemberList }) =>
    pipe(representativeMemberList, getMemberListWithoutMe, join(', ')),
  )
  .with({ roomName: P.nonNullable }, ({ roomName }) => roomName)
  .otherwise(({ representativeMemberList }) =>
    pipe(
      representativeMemberList,
      map(({ personaNick }) => personaNick),
      join(', '),
    ),
  );

느낀 점

  • 복잡한 조건문을 쉽게 작성할 수 있는 장점이 있었지만 ts-pattern을 학습하는데 약간의 러닝커브가 존재했습니다.
  • 실제로 이슈가 발생했을 때도 팀원들이 스스로 해결하지 못하고 제가 해결해야 했습니다.
  • 이러한 진입 장벽에도 불구하고 ts-pattern을 이용한 패턴 매칭은 도입할 가치가 있다고 생각합니다.
profile
Software developer

2개의 댓글

comment-user-thumbnail
2024년 8월 12일

오 잘봤읍니다. EffectTs로도 비슷하게 작업 가능하며, 더 엄격하게 작업가능하니 한번 찍어먹어보시는것도 추천드려용
타입매칭까지는 아니여도, 값 기반 매칭은 가능했던걸로 기억해요

1개의 답글