React와 Typescript의 미묘한 불일치

리액트는 타입스크립트로 짜인 코드가 아니다. 이것은 타입스크립트와 리액트를 동시에 사용하려고 할 때, 특히 타입스크립트를 제대로 써 보고자 할 때 걸림돌로 작용한다.

리액트와 같이 타입스크립트로 짜여지지 않은 프로젝트를 위해, 타입스크립트 커뮤니티에서는 DefinitelyTyped라는 프로젝트를 운영하고 있다. 이 프로젝트는 마치 다른 언어의 인터페이스 파일처럼, 타입스크립트로 작성되지 않은 프로젝트의 인터페이스를 커뮤니티에서 제작하여 배포하는 프로젝트이다. 이것은 타입스크립트가 Flow에 대비해서 가지는 가장 큰 장점-커뮤니티 유지보수로 업데이트가 되므로, 라이브러리 제작자에게 기대지 않아도 되며, 커뮤니티의 크기만큼 지원되는 라이브러리가 많다-이지만, 실제 구현과는 다르게 인풋 아웃풋만을 체크할 수 있다는 점에서 정의의 어려움과 실제 동작과의 불일치가 종종 발생한다.

리액트 역시, 타입을 정의하기 힘든 — 전체 프로젝트가 아닌 expored된 함수/객체 타이핑만으로는 나타내기 어려운 — 자바스크립트 동작들이 있고, 불완전한 타입 정의를 가지고 있다. 그 중에서도 컴포넌트의 타입에 제네릭으로 정의된 Props는 이 문제의 중심이다. 여기에 타입스크립트를 제대로 활용해보고자 strictNullCheck 옵션을 켜고 나면, 재앙이 펼쳐진다. 이 글에서는 내가 겪은 재앙의 몇몇 단면을 소개하려고 한다.

HOC with Props injection

다음과 같은 HOC를 생각해보자.

function withFoo<P>(WrappedComponent: ComponentType<P>) {
return class extends Component<P> {
...
render() {
return <WrappedComponent foo="foo_bar" {...this.props} />;
}
}
}

withFoo함수는 ComponentType(ComponentClass | SFC)를 인자로 받아, 해당 컴포넌트에 foo라는 props를 추가하여 렌더링하는 어떤 HOC이다. 이 코드의 문제점은 무엇일까?

첫째로, withFoo에 전달되는 컴포넌트가 foo라는 props를 받을 수 있어야 한다는 점이다. 물론, 이 함수를 사용하는 개발자가 이 정도를 빼먹었을 리는 없다. 다만 제네릭 타입은 조금 고쳐져야 하겠다.

function withFoo<P extends { foo?: string }>(WrappedComponent: ComponentType<P>) {
return class extends Component<P> {
...
render() {
return <WrappedComponent foo="foo_bar" {...this.props} />;
}
}
}

두 번째 문제는 이 HOC를 사용할 때 나타난다.

interface SomeProps {
foo: string
}
class SomeComponent extends Component<SomeProps> {
render() {
return <span>{this.props.foo.split('_')[0]}</span>
}
}
const WrappedComponent = withFoo(SomeComponent)
// 여기까지는 문제가 없다.
class OuterComponent extends Component {
render() {
return (
<WrappedComponent />
)
}
}

<WrappedComponent />를 렌더링하는 시점에, SomeComponent의 props.foo가 옵셔널하지 않기 때문에, foo라는 props를 꼭 넣어달라는 에러가 발생한다.

이 문제를 처음 접하는 개발자라면 SomeComponent는 foo가 꼭 있어야 올바른 상황이라고 생각할 테지만, HOC를 통해 만들어진 WrappedComponent는 foo를 주입받으므로, 옵셔널하게 처리해도 괜찮아 보인다고 생각할 수 있다. 그렇다면 SomeProps의 foo를 옵셔널한 타입으로 변경하는게 쉬운 해결책으로 보인다.

interface SomeProps {
foo?: string
}

좋다. 에러가 사라졌다. 모든것이 완벽하다.

과연?

하지만 strictNullCheck 옵션을 켠 순간, 모든것이 무너지기 시작한다. 아래와 같은 에러가 나타날 것이기 때문이다.

class SomeComponent extends Component<SomeProps> {
render() {
return <span>{this.props.foo.split('_')[0]}</span>
foo is possibly 'undefined'
}
}

이 에러를 처리하기 위한 두 가지 방법이 있다.

가장 쉽게 해결할 수 있는 방법은 foo가 undefined가 아닐 거라고 — 그렇게 내가 확신한다고 — 타입스크립트 컴파일러에게 알려주는 방법이다.

<span>{this.props.foo!.split('_')[0]}</span>

만약, withFoo라는 HOC가 프로젝트 여기저기 위치한다면, withFoo를 사용하는 모든 컴포넌트를 찾아가 이 느낌표를 붙여주는 작업을 해야 한다. 좀 더 나은 방법을 알아보자.

문제의 핵심은, foo가 내부에서는 required props이지만, HOC를 통해 만들어진 컴포넌트는 withFoo를 통해 값이 주입되고 있기에 foo를 optional props로 여겨주길 원한다는 점이다. 그래서 HOC를 제공하는 라이브러리들은 다음과 같이 처리한다.

interface InjectedProps {
foo: string
}
function withFoo<P extends InjectedProps>(
WrappedComponent: React.ComponentType<P>
) {
class WrappingComponent extends Component<P> {
render() {
return <WrappedComponent foo="foo_bar" {...this.props} />
}
}
type ExposedProps = Omit<P, keyof InjectedProps> & Partial<InjectedProps>
return (WrappingComponent as any) as ComponentType<ExposedProps>
}

WrappedComponent의 Props 중 InjectedProps에 해당하는 항목을 지운 뒤, Partial 타입을 사용해 옵셔널한 항목으로 다시 주입하여 이 WrappingComponent의 타입이라고 눈속임 — 타입을 완전히 재정의 — 을 한다. 이 눈속임을 위해 as any as Type 같은 타입-무시가 잠시 이뤄지지만, 대체로 잘 동작한다.

static defaultProps

역시, HOC에 의한 Props 주입과 마찬가지로, 외부와 내부의 Props 인터페이스 차이가 발생하여 생기는 문제다. 다만 차이가 있다면 HOC는 내가 짠, 혹은 남이 짠 코드 — 라이브러리 — 에 의해 벌어지는 문제이지만, defaultProps는 리액트가 직접 설정해주는 항목이라는 점이다.

사실 이 문제는 타입 정의의 레거시에 해당하는 — 변경시 대혼란이 예상되기 때문에 쉽게 변경하지 못하는— 문제인데, Component의 타입 정의에 defaultProps가 포함되지 않았기 때문에 생긴 문제다. 만약 defaultProps 타입 역시 컴포넌트의 타입으로 포함시키려고 한다면, ComponentClass의 제네릭 타입이 Props, State 뿐만이 아닌 DefaultProps까지 셋으로 늘어나게 된다. 이 부분을 변경하기 위해서는… 아마도 React v17.0 정도는 나와야 하지 않을까… 하는 짐작을 하고 있다.

defaultProps의 문제 역시 HOC와 동일하지만 다시 한번 설명하자면

interface Props {
foo: number
bar: string
}
class MyComponent extends Component<Props> {
static defaultProps = {
foo: 1,
bar: 'string'
}
render() {...}
}
// in other render()
<div>
<MyComponent /> // TS error
<MyComponent foo={3} bar="test" /> // ok
</div>

이렇게 해도 에러

interface Props {
foo?: number
bar?: string
}
class MyComponent extends Component<Props> {
static defaultProps = {
foo: 1,
bar: 'string'
}
render() {
const { foo, bar } = this.props
return (
<span>{foo + 1} / {bar}</span>
'foo' is possibly 'undefined'
)
}
}

저렇게 해도 에러.

결국, 개발자는 HOC와 비슷한 문제에 부딛히게 된다. 하지만 HOC는 인풋-컴포넌트와 아웃풋-컴포넌트의 타입을 다르게 정의하여 해결할 수 있었다면, 이번 문제에서는 컴포넌트가 하나 뿐이라는 점이 중요한 차이를 유발한다.

현재 일년 반째 논의가 진행중인 이슈에서는 다음과 같은 해결책이 제시되었다.

  1. 컴포넌트를 export할 때 타입 정의를 덮어쓰기. HOC에서 했던 것과 같은 방식이지만, HOC의 인풋-아웃풋이 아닌, 컴포넌트의 정의-Export 사이에서 이뤄지는 것이다.
  2. static defaultProps를 사용하지 않고, HoC로 직접 defaultProps의 동작을 흉내내기. HOC로는 그래도 해결할 수 있으니까, 관점을 달리 본 것이다.
  3. 혹은, HoC로 Props 타입과 static 값을 덮어쓰기.

더 나은, 깔끔한 해결책을 원한다면 기다려보는 수밖에 없다. 얼마나 걸릴진 모른다.

React.cloneElement with Props

안타깝지만 이 문제는 답이 없다.

Props가 들어오는 시점이 Component나 HOC가 아니라 리액트코어 레벨에서 이루어지기 때문에, 이 코드에는 앞서 논의한 문제를 회피했던 것 처럼 타입 정의를 덮어쓸만한 틈이 없다.

아래와 같은 Buttons 컴포넌트를 작성했다고 생각해보자. common이라는 props를 모든 child element에게 주입해주는 역할이다.

class Buttons extends Component<ButtonsProps> {
render() {
const { common, children } = this.props
return this.props.children.map(child =>
React.cloneElement(child, { common })
)
}
}
class Button extends Component<ButtonProps> {
render() {
const { name, common }= this.props
return ...
}
}
<Buttons common="checkbox">
<Button name="test" /> // error: no props common
<Button name="test2" common="Hello" /> // but run as "checkbox"
<Buttons />

이때 JSX로 선언된 Button에서는 common이라는 props가 Buttons에 의해 주입될 수 있다는 사실을 알 수 없다.

이 문제에 대해 내가 제안할 수 있는 해결책은 다음과 같다.

  1. 이 모든 문제를 잊고, strictNullCheck 옵션을 끈다.
  2. cloneElement를 사용하지 않는 방식으로 컴포넌트를 다시 작성한다.

번외: Context

리액트의 Context는 prop-types에 절대적으로 의존하고 있기 때문에 리액트의 d.ts에서는 다루지 않고 있으며, 다룰 수 없다.

하지만 16.3의 새로운 ContextAPI 하에서는 훨씬 더 깔끔하게 Context의 타입을 처리할 수 있을 것이다. 이건 조금 더 사용해보고 다른 글에서 이야기해보도록 하자.

이상 React와 Typescript의 미묘한 불일치를 읽어주셔서 감사하다. 그렇다고 내가 타입스크립트 사용을 포기했나- 하면 그렇지는 않다. 한 프로젝트는 이 역경을 거친 후 strictNullCheck 옵션을 켜둔 채 개발하고 있으며, 다른 대형 프로젝트는 strictNullCheck 옵션을 끈 채로 개발하고 있다. 부디 이 글이 strictNullCheck에 고통받는 타입스크립트 개발자들에게 도움이 되었길.

One clap, two clap, three clap, forty?

By clapping more or less, you can signal to us which stories really stand out.