Skip to content

Instantly share code, notes, and snippets.

@Phryxia
Last active February 13, 2025 06:07
Show Gist options
  • Save Phryxia/6161fe461f705faebe1713b6a62c1fef to your computer and use it in GitHub Desktop.
Save Phryxia/6161fe461f705faebe1713b6a62c1fef to your computer and use it in GitHub Desktop.
리액트 컴포넌트와 타입스크립트 다형성에 대한 주저리
type Big = { x: string }
type Small = Big & { y: string }
// 잘못된 다형적 컴포넌트
interface Props {
value: Big // Small도 Big이니까 넣어도 됨
onReport(newValue: Big): void // 이 코드의 의도는 (newValue: Small) => void도 전달받는 걸 상정함
}
function WrongPolymorphicComponent({ value, onChange }: Props) {
function modify(oldValue: Big) {
// 다형적이지 못함. y의 속성이 증발함.
// 하지만 타입은 문제가 없음
onReport({ x: oldValue.x + ' wow!' })
}
function create() {
onReport({ x: 'x' }) // y는?
}
}
// 사용처
function ComponentUsingSmall() {
const [small, setSmall] = useState<Small>({ /* 생략 */ })
return (
<WrongPolymorphicComponent
value={small}
onReport={setSmall} // 에러 안남
/>
)
}
@Phryxia
Copy link
Author

Phryxia commented Feb 13, 2025

위 코드는 잘못됐다. 그렇다면 컴포넌트 다형성은 어떻게 실현해야 하는가?
제네릭을 사용하면 안전한 다형성을 실현할 수 있다.

interface Props<T extends Big> {
  value: T
  onChange(newValue: T): void
}

function SafePolymorphicComponent<T extends Big>({ value, onChange }: Props<T>) {
  function modifySomething(oldValue: T) {
      // onChange({ x: oldValue.x + ' wow!' })  <--- 이상태론 T가 보장이 안되므로 에러
      onChange({ ...oldValue, x: oldValue.x })
  }
}

그런데 제네릭 없이는 이게 안되는가?

@Phryxia
Copy link
Author

Phryxia commented Feb 13, 2025

왜 안되지?

한편 리액트에선 왜 OOP적인 접근을 했을 때, 다형성 구현에 실패하는가?
내 생각으로 가변적(Mutable) 인 알고리즘과 불변적(Immutable) 알고리즘의 차이 때문이다.

우선 다형성(Polymorphism)에 대해서 짚고 넘어가보자.

다형성이란 하나의 동일한 현상이나 값을 다른 관점(a.k.a. 다른 특성으로의 접근) 으로 볼 수 있는 성질이다.

읽기만 하는 경우 TypeScript의 다형성은 특별히 문제가 없다.
그러나 쓰기의 경우 불변적일 때와 가변적일 때 차이가 발생한다.

읽기든 쓰기든 관계없이 Big이라는 관점으로 볼 때, 관측 가능한 특성은 x 뿐이다.
가변적인 경우, x 수정하는 행위가 Small의 특성(y)을 파괴하지는 않는다.
하지만 불변적인 경우, 전체를 복사하지 않는 한, Small의 특성을 파괴하게 된다.

생성의 딜레마

여기서 이론과 실무 상의 딜레마가 생긴다.
위에서 다룬 수정 시나리오에서는 그냥 값을 복사하면 된다.
문제는 새로운 값을 생성 할 때이다.

function foo<T extends Big>(bar: (value: T) => void) {
  bar({ x: 'x' }) // 에러! T는 x를 포함하지만 x 말고 다른 것들도 가질 수 있기 때문이다.
}

위 코드에서 에러가 나는 것은 이론적으로는 당연하다.
그러나 현실적으로는 새 값을 생성해야만 하는 경우가 종종 있다.
그럼 이렇게 하면?

function foo(bar: (value: Big) => void) {
  bar({ x: 'x' }) // 문제 없음
}

// 그저 문제를 호출자에게 전가할 뿐이다
foo((value: Big) => {
  onChangeSmall(value) // 안들어가짐
})

근본적인 문제가 해결이 되지 않는다.
사실 당연한 현상인게, OOP로 비유하면 abstract class의 인스턴스를 만들려고 하는 것과 똑같기 때문이다.

@Phryxia
Copy link
Author

Phryxia commented Feb 13, 2025

그래서 어떻게 해야됨?

생성 로직의 외부주입

OOP의 Factory 패턴처럼 해당 컴포넌트의 책임을 넘어서는 부분의 생성을 호출자에게 전가하는 것이다.
얼핏 보면 바로 직전의 코드와 유사하다.

function foo(
  createBig: () => Big, 
  respond: (value: Big) => void
) {
  respond({
    ...createBig(),
    x: 'wanted x'
  })
}

하지만 실행흐름이 부모와 자식을 오고가며 난잡해진다는 단점이 있다.

이벤트 기반의 접근

딜레마에서 소개했던 코드는 지극히 정상적이다. 단지 적절한 이름과 관점이 필요했을 뿐이다.

function foo(
  onCreate: (base: Big) => void
) {
  onCreate({ x: 'wanted x' })
}

// 사용처에서 알아서 책임진다
foo((base: Big) => {
  const small = { ...base, y: 'for small' }
  // small을 알아서 씀
})

@Phryxia
Copy link
Author

Phryxia commented Feb 13, 2025

객체지향과의 질감적 차이

리액트 컴포넌트 프로그래밍을 함수형이라고 뭉개기엔 어폐가 있지만, 불변성과 행위를 주고받는다는 관점에서 함수형이라고 부르겠다.

  • 객체지향 패러다임에선 추상적인 관심사가 관념상 높은 곳에 위치하며, 상속(extend)받아서 쓴다.
  • 함수형 패러다임에선 추상적인 관심사가 관념상 깊은 곳에 위치하며, 합성(composite)하여 쓴다.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment