[TypeScript] HTML의 어트리뷰트로 특정 타입의 값을 저장하고 이벤트리스너에서 그 값을 적절하게 수신하는 방법

telnet turtle·2023년 1월 24일
0
post-thumbnail

문제

타입스크립트에서 HTML의 어트리뷰트로 특정 타입의 값을 저장하고 이벤트리스너에서 그 값을 적절하게 수신하는 방법은 무엇일까?

나는 지난 몇 년간 타입스크립트를 사용해오며 이 고민에 여러 차례 맞닥뜨렸다.

예를 들면 다음과 같은 코드가 있다고 해보자. 이것은 내가 여러번 반복해서 쓰던 코드를 단순화한 것이다.

// 단순화된 예제
import { cx } from '@emotion/css'
import { useState } from 'react'

type AKG = 'A' | 'K' | 'G'

function Component() {
  const [akgState, setAkgState] = useState<AKG>('A')

  const onClick = (e: React.MouseEvent<HTMLButtonElement>) => {
    const target = e.target as HTMLButtonElement
    setAkgState(target.value as AKG) // target.value의 타입은 AKG이다. 하지만 컴파일러는 그걸 모른다.
  }

  return (
    <>
      {/* 버튼 3개는 value로서 타입 AKG의 값을 가지고 있다. */}
      <button value="A" onClick={onClick} className={cx({ active: akgState === 'A' })}>
        Button 1
      </button>
      <button value="K" onClick={onClick} className={cx({ active: akgState === 'K' })}>
        Button 2
      </button>
      <button value="G" onClick={onClick} className={cx({ active: akgState === 'G' })}>
        Button 3
      </button>
    </>
  )
}

이 예제 코드의 의도는 이러하다.

  1. 타입 AKG가 취할 수 있는 값들은 각 버튼에 하나씩 할당되었다.
  2. 버튼을 누르면 상태는 그 값으로 바뀐다.
  3. 상태가 그 값일 때 버튼은 active된다.

className 부분을 이해하려면 Emotion의 cx 함수를 알아야 하지만 그건 중요한 게 아니다. 이 프로그램은 자바스크립트로도 단순하게 작성 가능하며, 타입스크립트나 Emotion 프레임워크를 몰라도 이해가 가능할 지도 모른다.

하지만 타입스크립트를 사용하는 우리는 다음과 같은 생각을 하게 된다.

  1. 타입 AKG는 유한하며, 값의 개수가 매우 적은 타입이다.
  2. 프로그램의 작성자는 일종의 enum으로서 타입 AKG를 사용하고 있다. 그리고 가능한 모든 값에 버튼을 하나씩 할당했다.
  3. 그렇다면 우리는 버튼의 value와 className에 있는 값들을 타입 AKG임이 명확하게 하고, 또한 핸들러 안쪽의 target.value가 AKG임을 프로그램에 전달해야하지 않을까?

고민

분명히 지금의 프로그램은 버튼과 핸들러에서 타입이 AKG임을 모른다. 나는 이 문제를 주로 2가지 방법으로 해결 혹은 회피해 왔다.

1: 컴포넌트 분리

각 버튼이 공통된 구조를 사용하므로 쉽게 컴포넌트로 분리할 수 있다.

  return (
    <>
      {/* 버튼 3개는 value로서 타입 AKG의 값을 가지고 있다. */}
      <Button value="A" onClick={onClick} akgState={akgState}>
        Button 1
      </Button>
      <Button value="K" onClick={onClick} akgState={akgState}>
        Button 2
      </Button>
      <Button value="G" onClick={onClick} akgState={akgState}>
        Button 3
      </Button>
    </>
  )
}

interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
  value: AKG
  akgState: AKG
}

function Button({ value, akgState, ...buttonProps }: ButtonProps) {
  return <button value={value} className={cx({ active: value === akgState })} {...buttonProps} />
}

분리한 컴포넌트인 Button 의 props를 어떻게 배치할지는 글의 주제가 아니다. 이렇게 하면 컴파일러는 ButtonvalueAKG임을 알 수 있고, 그것이 className에 동일하게 사용된다는 것 까지 알게 된다.

2: 핸들러에서 as 사용

  const onClick = (e: React.MouseEvent<HTMLButtonElement>) => {
    const target = e.target as HTMLButtonElement
    const value = target.value as AKG
    // target.value의 타입은 string이지만, 강제로 AKG로 인식시킨다.
    // 핸들러는 그저 핸들러 바깥에서 value가 AKG로 제대로 매겨지기를 기도해야 한다.
    setAkgState(value) 
  }

불행히도 나는 아직 핸들러에서 어떻게 이 문제를 해결해야 하는지 제대로 된 답을 찾지 못했다. 내가 아는 최선의 방법은 핸들러에서 valueAKG로 취급하는 것이다.

핸들러 안쪽에서 target.value는 그저 string이다. HTML의 네이티브 버튼을 이용하는 한 그러하다. 아마 MUI나 antd의 <Button /> 태그를 이용해도 매한가지일 것이다.

나는 이 문제를 위의 컴포넌트 분리와 더불어 사용함으로써 그럭저럭 회피했다. 커스텀 컴포넌트인 Button을 이용하는 한 valueAKG이다. 약간이나마 강제력이 생긴 셈이다. 그 값이 외부에서 온 값이 아니라 내부에서 생성된 값이라면, 최소한 프로그래머의 손에서 나온 값이라면 더욱 믿을 수 있다. (사용자를 통해 외부에서 입력되거나 네트워크를 통해 받은 값이라면, 가장 외부와 가까운 장소에서 검증하거나 정규화하는게 좋다는게 내 생각이다.)

결론

어느정도 고민은 해결되었지만 아직 만족할 만한 단계는 아니다. 커스텀 컴포넌트를 추가하는 것은 코드를 읽는 속도를 느리게 만들 수 있다. 핸들러에서 사용한 as는 어쩔 수 없이 그렇게 한 것에 가깝다.

이 문제는 다른 태그에서도 적용할 수 있다. 예를 들면, select에서 사용하는 option의 value가 특정 type일 때 그걸 onChange에게 알리는 가장 좋은 방법은 무엇일까?

나는 더 좋은 방법이 있을 거라 생각하지만 아직까지 그것을 찾아내지는 못했다. 네이티브 HTML API를 사용하는 타입스크립트 프로그램의 한계인 것일까? 나중 어느때에 더 멋진 해결안을 찾길 바라며 포스팅을 작성하였다.

profile
프론트엔드 엔지니어

0개의 댓글