반응형 프로그래밍이란 무엇인가?

강정진 · 원프레딕트 프론트엔드 엔지니어
November 08, 2023

프론트 데스크에서 발표한 내용을 엔지니어링 블로그에 공유합니다. 주1

Intro

Reactive Programming(반응형 프로그래밍)을 간단하게 설명하기 좋은 예시는 스프레드시트입니다. 어떠한 수식을 선언적으로 작성해 두고 수식에서 사용되는 데이터가 변경되면 수식에 반영되는 데이터 흐름을 확인할 수 있습니다. 이렇게 데이터의 흐름과 변경 사항의 전파에 중점을 둔 선언적 프로그래밍을 하고자 하는 패러디임을 반응형 프로그래밍이라고 합니다. 우리가 사용하는 React는 반응형 프로그래밍일까요?

useState를 사용하여 UI를 업데이트하는 React Component를 구사하는 방식은 반응형 프로그래밍과 같아 보이기도 하지만, React는 스케줄링을 설명할 때 데이터가 갱신되면 “Push”하는 방식이 아닌 계산이 필요할 때까지 지연될 수 있는 “Pull” 방식을 고수한다고 합니다. 이 말은 위에서 설명한 데이터의 흐름과 변경 사항의 전파로 처리되는 반응형 프로그래밍의 방식과는 거리가 있어 보입니다. 또한 본인들은 완전한 반응형(fully reactive)이 되길 원치 않는다고 합니다.

그럼 완전한 반응형 프로그래밍이란 무엇일까요? 이것을 더 잘 다루는 프레임워크(라이브러리)는 Vue.js, Svelte, SolidJS, Knockout.js, MobX 등이 있는데 SolidJS에서는 Fine-Grained Reactivity이라는 개념을 이야기합니다.

Fine-Grained Reactivity

Fine-Grained Reactivity란 말 그대로 세밀한 반응형입니다. React처럼 상태가 변경되었을 때 컴포넌트 전체가 리렌더링 되는 것이 아니라, 일부 변경이 필요한 곳에서만 갱신이 일어나죠. 아래에서 SolidJS에서 쓰이는 반응형 프로그래밍의 세 가지 요소를 살펴보겠습니다.

Signal(신호)

Signal은 반응형 프로그래밍에서 기본적인 타입입니다. gettersetter로 구성되어 있습니다. 반응형 프로그래밍에서 사용되는 데이터로서, 변화를 추적합니다.

import { createSignal } from "solid-js"

const Counter = () => {
  const [count, setCount] = createSignal(0)

  return (
    <div>
      <button onClick={() => setCount(count => count + 1)}>
        Count: {count()}
      </button>
    </div>
  )
}

export default Counter

Reaction(반응)

Signal을 관찰하고 그 값이 업데이트될 때마다 Signal을 다시 실행합니다. 의존성을 명시하는 것이 아니라, 그를 사용하는 것 자체를 구독합니다.

import { createEffect, createSignal } from "solid-js"

const Counter = () => {
  const [count, setCount] = createSignal(0)

  console.log("outer log, count: " + count())

  createEffect(() => {
    console.log("inner log, count: " + count())
  })

  return (
    <div>
      <button onClick={() => setCount(() => count() + 1)}>
        Count: {count()}
      </button>
    </div>
  )
}

export default Counter

Derivations(파생)

Signal의 파생 데이터는 어떻게 할까요? 이 파생 데이터를 Reaction에서 사용하게 된다면 매번 재연산하여 비용이 클 것입니다. 이를 메모이제이션을 하고자 할 때 아래와 같이 사용합니다.

import { createEffect, createSignal, createMemo } from "solid-js"

console.log("1. Signals 생성")
const [a, setA] = createSignal("옐로우")
const [b, setB] = createSignal("망고")

const c = createMemo(() => {
  console.log("파생 데이터 생성/변경")
  return `${a()} ${b()}`
})

console.log("2. Reactions 생성")
createEffect(() => console.log(c(), "를 먹었다."))
createEffect(() => console.log(c(), "는 맛있었다."))

console.log("3. a 갱신")
setA("애플")

// 1. Signals 생성
// 2. Reactions 생성
// 파생 데이터 생성/변경
// 옐로우 망고를 먹었다.
// 옐로우 망고는 맛있었다.
// 3. a 갱신
// 파생 데이터 생성/변경
// 애플 망고를 먹었다.
// 애플 망고는 맛있었다.

위 방식들은 React에서도 흔히 사용되는 useState, useEffect, useMemo와 같은 것들과 대조할 수 있는데, 결과적으로는 어떠한 차이가 나는지 구현을 어떻게 했는가에 따라 다르겠지만 그들이 추구하는 방식이 어떤 결과로 이어지는지 아래 간단히 렌더링 결과로 차이를 확인해 보겠습니다.

React

import { useState } from "react"

const Hello = () => <div>hello</div>

const Counter = () => {
  const [count, setCount] = useState(0)

  return (
    <div>
      <button onClick={() => setCount(count + 1)}>
        You pressed me {count} times
      </button>
      <Hello />
    </div>
  )
}

export default Counter

React의렌더링

SolidJS

import { createEffect, createSignal } from "solid-js"

const Hello = () => <div>hello</div>

const Counter = () => {
  const [count, setCount] = createSignal(0)

  return (
    <div>
      <button onClick={() => setCount(() => count() + 1)}>
        Count: {count()}
      </button>
      <Hello />
    </div>
  )
}

export default Counter

그림

React는 컴포넌트 내용이 전부 리렌더링되고 SolidJS는 상태를 구독하는 일부만 리렌더링이 되고 있습니다.

반응형 모델

SolidJS가 반응형을 어떤 방식으로 구현했는지 살펴보겠습니다.

Signal(createSignal)

Reaction을 담고 있는 runningContext가 존재하고, Signal은 자신을 구독하는 구독 리스트로 subscriptions를 둡니다. 이 두 가지로 의존성을 추적하게 되고 변화에 따라 반응이 일어나도록 `read`` 함수를 실행시킵니다.

const runningContext = []

function subscribe(running, subscriptions) {
  subscriptions.add(running)
  running.dependencies.add(subscriptions)
}

function createSignal(value) {
  const subscriptions = new Set()

  const read = () => {
    const currentRunning = runningContext[runningContext.length - 1]
    if (currentRunning) {
      subscribe(currentRunning, subscriptions)
    }
    return value
  }
  const write = nextValue => {
    value = nextValue

    for (const sub of [...subscriptions]) {
      sub.run()
    }
  }

  return [read, write]
}

Reaction(createEffect)

Reaction이 실행될 때 자신이 어떤 값에 의존하고 있는지 관리하기 위한 dependencies 속성을 두고, 자신이 실행될 때마다 구독을 cleanup하고 재구독하는 과정으로 동작합니다. 이 과정을 통해 Signal의 의존성을 동적으로 관리할 수 있습니다.

function cleanup(running) {
  for (const dep of running.dependencies) {
    dep.delete(running)
  }
  running.dependencies.clear()
}

function createEffect(fn) {
  const run = () => {
    cleanup(running)
    runningContext.push(running)
    try {
      fn()
    } finally {
      runningContext.pop()
    }
  }

  const running = {
    run,
    depdendencies: new Set(),
  }

  run()
}

Derivations

파생은 Signal과 React을 사용해 계산된 값을 반환합니다.

function createMemo(fn) {
  const [computed, set] = createSignal()
  createEffect(() => set(fn()))
  return computed
}

마무리

여기까지 간단하게 반응형 프로그래밍에서 사용되는 세 가지 요소와 이점이 무엇인지 살펴보았습니다. 이외에 반응형 프로그래밍을 구현하기 위해 유의해야 할 사항들이 더 존재합니다. 이는 SolidJS 공식 문서를 확인해 보실 수 있습니다(무려 한글입니다!). 반응형 프로그래밍을 살펴보면서 프레임워크들이 앞으로 어떤 방향성을 가지고 개발되고 있는지 한번 확인해 보면 좋은 공부가 될 것입니다.


  1. 원프레딕트의 모든 프론트엔드 엔지니어는 매주 목요일에 기술 주제를 나누는데, 이 모임의 이름이 ‘프론트 데스크’입니다.
원프레딕트는 더 나은 제품을 고민하며 기술적인 문제를 함께 풀어낼 동료를 찾고 있습니다.
자세한 내용은 채용 사이트를 참고해 주세요.