스폰서LobeHubLobeHub자세히 알아보기
dshfind

7과: 이펙트 함수와 합성: 역변환의 자동 합성

한 문장 요약: 지난 과에서는 「역함수를 누군가가 이미 작성해 두었다」고 가정했습니다. 이번 과에서는 모든 이펙트가 스스로 역함수를 몸에 지니고 다니게 하고, 합성할 때 역이 후입선출 방식으로 자동으로 조립되도록 규정합니다. 이제부터는 무엇을 올려놓든 분해할 수 있고, 절반만 분해하는 것도 가능합니다.

1. 왜 「지난 과의 역함수가 주어져 있다」는 것만으로는 부족한가?

지난 과의 모델을 기억하시나요? 상태 변환 f에 역변환 f⁻¹를 짝지어 두고, 취소할 때는 recover가 한 단계로 누적된 모든 이펙트를 통째로 복원하는 모델이었습니다. 이 모델에는 비현실적인 부분이 두 가지 있습니다(논문 3.1.2절 서두의 원문 그대로):

문제 1: 역함수는 미리 알려져 있지 않습니다. 현실에서 이펙트의 역은 반드시 호출자가 그 자리에서 제공해야 합니다. 예를 들어 「팝업 열기」라는 이펙트는 창을 어디에 열고 어떤 내용을 표시할지 시작한 사람만 알기 때문에, 「그것을 어떻게 닫는지」도 시작한 사람만 설명할 수 있습니다. 다른 누군가가 미리 대신 작성해 둘 수는 없습니다.

문제 2: recover는 전부 아니면 전무입니다. 모두 취소하거나 아예 취소하지 않거나 둘 중 하나일 뿐, 다른 이펙트는 유지한 채 그중 하나의 이펙트만 취소할 수 없습니다. 하지만 현실에서는 부분 취소를 원하는 경우가 많습니다. 예를 들어 「2단계의 수정」은 취소하되 「1단계」의 결과는 유지하고 싶은 경우입니다.

비교지난 과(track / recover)이번 과(이펙트 함수)
역함수를 누가 제공하는가「이미 주어졌다」고 가정호출자가 그 자리에서 제공
취소의 단위전부 아니면 전무부분 취소 가능
복원 방식전체를 원자적으로 복원후입선출, 필요에 따라 재생

그럼 어떻게 해야 할까요? 논문의 방법은 아주 소박합니다. **모델의 입력 쪽과 출력 쪽 양쪽에 동시에 「재료를 추가」**하는 것입니다.

  • 입력 쪽: 변환 결과만 반환하는 것이 아니라 이번 이펙트의 역함수를 함께 반환하여(Γ → Γ×(Γ→Γ)) 「수동 추적」 능력을 얻습니다.
  • 출력 쪽: 추적이 달린 컨텍스트 ∂Γ에서도 마찬가지로 역함수를 반환하여(∂Γ → ∂²Γ) 「부분 복원」 능력을 얻습니다.

한 문장으로 말하면: 「역」을 시스템의 가정에서, 각 이펙트가 몸에 지니고 다니는 짐으로 바꾸는 것입니다.

2. 이펙트 함수 𝔈Γ: 상태 + 역함수를 함께 돌려준다

먼저 형식 정의를 살펴 보겠습니다(논문의 정의 6):

𝔈Γ ≔ Γ → Γ × (Γ → Γ)

쉬운 말로 옮기면: 이펙트 함수는 현재 컨텍스트 γ(감마)를 받아 2-튜플을 돌려줍니다.

  • 첫 번째 성분 δ(델타): 변환 후의 새로운 상태;
  • 두 번째 성분 g: 이번 이펙트의 역함수. 이것을 새로운 상태에 적용하면 이번 이펙트를 취소할 수 있습니다.

의사 코드로 작성하면 형태는 다음과 같습니다:

function 打开弹窗(当前状态) {
  const 新状态 = 当前状态 + ',弹窗已打开';
  const 撤销函数 = (新状态) => 去掉弹窗(新状态); // 이것이 이번 이펙트의 역
  return [新状态, 撤销函数]; // 핵심: 역함수를 상태와 함께 반환
}

이 「함께 반환」에 주목하세요. 이펙트를 일으킨 사람이 그 자리에서 취소 방법을 낼 수 있어야 합니다. 시스템은 더 이상 역이 기성품이라고 가정하지 않습니다. 이것이 바로 앞 절에서 말한 「호출자가 그 자리에서 제공」입니다.

엄밀 이펙트 함수 𝔈Γ∗: 역은 진짜 역이어야 한다

「역함수를 하나 갖고 다니는」것만으로는 부족합니다. 호출자가 가짜 역을 낸다면 어떻게 될까요? 예를 들어 「팝업 열기」의 역함수가 사실 「아무것도 하지 않기」라면, 취소는 사기일 뿐입니다.

그래서 논문은 제약을 하나 더 추가했습니다(정의 6의 둘째 줄): 임의의 γ에 대해 e(γ) = (δ, g)라면 반드시

g(δ) = γ

를 만족해야 합니다.

즉, 역함수를 새로운 상태에 적용하면 정확히 이전 상태로 돌아가야 합니다. 이 제약을 만족하는 이펙트 함수를 엄밀 이펙트 함수(𝔈Γ∗)라고 부릅니다. 논문은 교환 도형(commutative diagram)을 통해 반환되는 두 번째 성분이 실제로 변환 자체의 역이지 아무 함수나 되는 것이 아님을 보증합니다.

🎁 비유하자면: 일반 이펙트 함수는 「반품 약속」과 같고, 엄밀 이펙트 함수는 「도착 시 검사」와 같습니다. 반품된 물건이 정말 원래 모습으로 돌아가는지 검증해야 약속이 유효해집니다.

다행스러운 소식도 있습니다(정리 8). 엄밀성은 합성 과정에서 사라지지 않습니다. 엄밀 이펙트 함수는 effect를 통해 ∂Γ로 들어 올려진 후에도 여전히 엄밀하며, 품질 보증은 전 과정에서 유효합니다.

3. 이펙트 합성 ⋄: 합성의 역은 LIFO로 자동 조립된다

이펙트 함수가 반환하는 것은 2-튜플이며 더 이상 「상태 → 상태」의 일반 함수가 아니므로, 일반 함수처럼 직접 합성할 수 없습니다. 이를 위해 논문은 새로운 연산 ⋄(「다이아몬드」라고 읽음)를 정의합니다.

정의 9의 구조를 살펴 보세요. 두 이펙트 함수 f, g가 주어졌을 때:

function 复合(f, g) {
  return (当前状态) => {
    const [中间状态, s] = g(当前状态);      // 1단계: 먼저 g를 실행
    const [最终状态, t] = f(中间状态);      // 2단계: 그다음 f를 실행
    return [最终状态, (状态) => s(t(状态))]; // 합성 역 = t 먼저, 그다음 s
  };
}
  • 실행 순서: g를 먼저, f를 나중에(함수 합성 f∘g의 표기 관습과 일치합니다: ⋄ 왼쪽에 쓰인 것이 나중에 실행되는 것입니다).
  • 합성 역: s는 g의 역, t는 f의 역이며, 합성 역은 s∘t = 먼저 t(f의 역)를 적용하고, 그다음 s(g의 역)를 적용합니다. 즉, 나중에 실행한 f를 먼저 취소하고, 먼저 실행한 g를 나중에 취소합니다.

이것이 바로 유명한 **후입선출(LIFO)**입니다. 마지막에 실행된 이펙트가 가장 먼저 취소됩니다.

先装 f(外层)记录逆 f⁻¹后装 g(内层)记录逆 g⁻¹复合变换 = f ⋄ g组合后:记录逆 = g⁻¹ 先、f⁻¹ 后先拆 g → 应用 g⁻¹(后装的先拆)再拆 f → 应用 f⁻¹(先装的后拆)恢复顺序 = 加载顺序的反转:后进先出(LIFO)复合效应的逆,由组合自动推导——不用手写

⋄ 运算:任意复合效应的逆,都能按 LIFO 自动组合出来

위 그림은 같은 원리를 「f를 먼저 올리고, g를 나중에 올린다」로 그려 놓았습니다. 내릴 때는 g의 역을 먼저 적용하고, 그다음 f의 역을 적용합니다. 복원 순서는 로드 순서의 정확한 역순입니다.(그림 속의 f, g는 단지 예시 레이블일 뿐이며, 핵심은 「나중에 올린 것을 먼저 분해한다」는 원칙입니다.)

🎁 비유: 접시를 쌓거나 외투를 입는 것과 같습니다. 나중에 올린 접시를 먼저 가져가고, 나중에 입은 외투를 먼저 벗습니다. 마지막에 들어온 사람이 가장 먼저 나갑니다.

정리 11이 이 절의 하이라이트입니다:

effectΓ(f) ⋄ effectΓ(g) = effectΓ(f ⋄ g)

쉬운 말로 옮기면: **「합성의 추적 = 추적의 합성」**입니다. f와 g를 먼저 합성한 뒤 전체를 추적할 수도 있고, 각각 추적한 뒤 합성할 수도 있습니다. 두 경로의 결과는 완전히 같습니다.

이것이 무엇을 의미할까요? 임의의 합성 이펙트의 역은 합성으로부터 자동으로 유도되며, 손으로 작성할 필요가 전혀 없습니다. 각 「원자」 이펙트마다 역함수 하나만 제공하면, 합성된 전체의 역은 자동으로 LIFO로 조립됩니다.

💡 이것이 「역변환의 자동 합성」의 의미입니다. 블록을 쌓을 때 각 블록이 분해 방법을 스스로 갖추고 있어서, 조립된 전체도 자동으로 분해할 수 있습니다. 「전체」를 위한 분해 방법을 따로 작성할 필요는 영원히 없습니다.(정리 10도 보증합니다: 두 엄밀 이펙트를 합성해도 여전히 엄밀하며, 품질 보증은 사라지지 않습니다.)

4. 로드 = 역의 누적, 언로드 = 역의 재생

마지막으로 컴포넌트 시나리오로 돌아가 이 부품들을 조립해 보겠습니다. 논문의 표현은 참으로 아름답습니다. 시간적 합성 가능성(temporal composability):

  • 컴포넌트 로드: 일련의 이펙트 함수를 차례로 적용하여 각각의 역을 하나의 「누적 역함수」φ에 누적하는 것과 같습니다.
  • 컴포넌트 언로드: φ를 적용하여 컨텍스트를 합성 전의 상태로 복원하는 것과 같습니다.

의사 코드:

let 当前状态 = 初始状态;
let 累积逆 = (状态) => 状态; // 처음에는: 취소할 것이 아무것도 없음

function 加载组件(效应函数) {
  const [新状态, 本次逆] = 效应函数(当前状态);
  累积逆 = (状态) => 累积逆(本次逆(状态)); // 새 역을 바깥에 쌓기: 가장 최신 것을 먼저 취소
  当前状态 = 新状态;
}

function 卸载组件() {
  当前状态 = 累积逆(当前状态); // 누적 역 재생 = LIFO로 자동 되감기
  累积逆 = (状态) => 状态;     // 비우기
}

시스템은 「누구를 먼저 로드하고 누구를 나중에 로드했는지」를 전혀 기억할 필요가 없습니다. 순서는 누적 역의 중첩 구조 안에 인코딩되어 있어서, 언로드할 때 자연스럽게 후입선출로 재생됩니다. 논문에서 이 단계는 effectΓ가 ∂Γ 위에서 하는 일입니다: 입력 (γ, φ)를 받고 e를 적용하여 (δ, g)를 얻으면, 새 컨텍스트는 (δ, φ∘g)가 됩니다. 새 역이 옛 역의 바깥에 쌓이는 것입니다.

그렇다면 「두 컴포넌트가 서로 간섭하지 않을까?」라는 문제는 어떨까요? 논문이 제시하는 판별 기준은 ⋄ 아래에서의 **교환성(commutativity)**입니다:

  • f ⋄ g = g ⋄ f(두 이펙트가 교환 가능)이면, 어느 것을 먼저 분해해도 마찬가지이고 서로 간섭하지 않으므로, 각자 독립된 컴포넌트 안에 안전하게 살 수 있습니다.
  • 교환 가능하지 않으면, 시스템은 반드시 순서 제약을 가해야 합니다: 컴포넌트 내부에서는 「후입선출 규율」로(3.3.2절의 이펙트 이터레이터), 컴포넌트 사이에서는 「의존 주도의 활성화 순서」로(3.2절의 반응형 코이펙트).

⚠️ 이 실마리를 기억해 두세요. 「교환 가능」은 분해 순서가 중요한지를 결정하고, 「자동 유도」는 역이 반드시 존재함을 보증합니다. 다음 과에서는 컴포넌트 내부에서 LIFO 규율을 강제하는 「이펙트 이터레이터」를 살펴 보겠습니다.

핵심 요점 정리

  1. 역함수는 미리 알 수 없습니다: 이펙트를 적용할 때 호출자가 그 자리에서 제공해야 합니다. recover는 전부 아니면 전무이므로, 부분 취소가 가능한 모델이 필요합니다.
  2. 이펙트 함수 𝔈Γ = Γ → Γ×(Γ→Γ): 현재 상태를 입력받아 「새 상태 + 이번 이펙트의 역함수」를 반환합니다.
  3. 엄밀 이펙트 함수 𝔈Γ∗: g(δ) = γ라는 제약이 하나 더해져, 역이 진짜 역임을 보증합니다.
  4. 이펙트 합성 f ⋄ g: g를 먼저 실행하고 f를 나중에 실행합니다. 합성 역 = f를 먼저 취소하고 g를 나중에 취소. 후입선출(LIFO).
  5. 정리 11: effect는 ⋄ 연산을 보존합니다(「합성의 추적 = 추적의 합성」). 임의의 합성 이펙트의 역은 합성으로부터 자동으로 유도되며, 손으로 작성할 필요가 없습니다.
  6. 로드 = 역의 누적, 언로드 = 역의 재생: 복원 순서는 자동으로 LIFO입니다. 교환 가능한 이펙트는 독립적으로 공존할 수 있고, 교환 불가능한 것은 순서 규율로 제약됩니다.

🚀 다음 과 예고: 「교환되지 않는 이펙트는 순서대로 진행해야 한다」면, 그 순서는 누가 보증할까요? 답은 컴포넌트 내부에서 「후입선출 규율」을 강제하는 구조인 이펙트 이터레이터(3.3.2절)입니다.

자가 테스트 · 이펙트 합성

답을 모두 선택한 후 「답안 제출」을 클릭하면 정답 여부와 해설을 확인할 수 있습니다.

1. 이펙트 함수 e(γ)는 무엇을 반환하나요?
2. 「엄밀 이펙트 함수」가 일반 이펙트 함수보다 더 갖는 제약은 무엇인가요?
3. 논문의 정의대로 f ⋄ g를 합성할 때(g를 먼저 실행하고 f를 나중에 실행), 복원(취소) 순서는?
4. 왜 「합성 이펙트의 역」을 손으로 작성하지 않아도 되나요?