Immer 톺아보기(+zustand)

이희제·2024년 9월 2일
post-thumbnail

zustand 공식 문서를 통해 immer를 사용하면 불변성을 지키면서 편리하게 값을 업데이트 할 수 있다는 것을 보았다.

따라서 immer에 대해 알아보고 zustand 및 react에서 어떻게 활용할 수 있을 지 알아보고자 한다.

1. 불변성

기본적으로 개발을 할 때 불변성을 지키는 것이 중요하다.

리액트의 경우 객체의 내부값이 변경되었음에도 불변성을 지키지 않는다면 변경점이 감지가 되지 않을 것이다.

따라서 아예 신규 메모리 주소를 가지는 값을 생성하고, 수정한 다음 상태값을 업데이트 해줘야 한다.

그래서 보통 spread 연산자를 통해 객체(배열 포함)을 복사한다. 하지만 spread 연산자는 얕은 복사를 하기 때문에 중첩된 객체의 속성에 대해서는 기존 메모리 주소값을 가지고 있게 된다.

const user = {
  name: "John Doe",
  age: 30,
  address: {
    city: "New York",
    country: "USA"
  },
  hobbies: ["reading", "swimming"]
};

// 새로운 객체 생성 (얕은 복사)
const nextUser = {...user}

console.log(user === nextUser)

// 불변성을 지키지 않고 nextUser 객체 값을 직접 변경
nextUser.name = "Jane Doe";  // 직접 속성 변경
nextUser.age += 1;  // 숫자 값 증가
nextUser.address.city = "Los Angeles";  // 중첩 객체의 속성 변경
nextUser.hobbies.push("painting");  // 배열에 새 요소 추가

console.log("Original user:", user);
console.log("Next user:", nextUser);

콘솔에 찍힌 값을 확인해 보면 중첩된 속성값은 깊은 복사가 되지 않았기 때문에 중첩된 속성의 값은 모두 같은 메모리 주소를 가지고 있다.

따라서 nextUser를 변경했음에도 기존 user가 같이 변경된다.

불변성을 지키지 않으면 여러 가지 문제가 발생할 수 있다.

  • 예측 불가능한 상태 변화: 불변성을 지키지 않으면 객체의 상태가 언제, 어디서 변경되었는지 추적하기 어려진다. 로직 동작을 예측하기 어렵게 만들고, 버그를 찾아내기 힘들게 한다.
  • 참조 동일성 문제: React는 객체의 참조 동일성을 기반으로 변경 여부를 판단한다. 객체를 직접 수정하면 참조는 그대로이기 때문에, 이러한 라이브러리들이 변경을 감지하지 못해 컴포넌트 리렌더링 등의 필요한 작업을 수행하지 않을 수 있다.
  • 의도치 않은 부작용: 한 부분에서 객체를 수정하면, 그 객체를 참조하고 있는 다른 모든 부분에 영향을 미친다. 이는 의도치 않은 사이드 이펙트를 일으키는 것이다.

따라서 불변성을 유지하면서 데이터를 핸들링하는 것이 중요할 것이다.

중첩되어 있는 객체 데이터를 원본에 영향을 주지 않고 변경하기 위해서는 깊은 복사를 해야 한다. 그렇다면 깊은 복사를 하기 위해서는 어떻게 해야할까?

깊은 복사(Deep-Copy)

1. JSON.parse()와 JSON.stringify() 메서드 사용

보편적으로 가장 간단하게 사용할 수 있는 방법이다.

// 원본 객체
const originalObj = {
    name: "John",
    age: 30,
    hobbies: ["reading", "swimming"],
    address: {
        street: "123 Main St",
        city: "New York"
    }
};

// 깊은 복사 수행
const deepCopyObj = JSON.parse(JSON.stringify(originalObj));

// 복사된 객체의 내부 값 변경
deepCopyObj.name = "Jane";
deepCopyObj.hobbies.push("running");
deepCopyObj.address.city = "Los Angeles";

// 결과 출력
console.log("원본 객체:", originalObj);
console.log("깊은 복사 객체:", deepCopyObj);

// 참조 비교
console.log("원본과 복사본이 같은 객체인가?", originalObj === deepCopyObj);
console.log("원본과 복사본의 주소가 같은 객체인가?", originalObj.address === deepCopyObj.address);

2. 외부 라이브러리 사용(loadash)

const cloneDeep = require('lodash/cloneDeep');

// 원본 객체
const user = {
  name: "John Doe",
  age: 30,
  address: {
    city: "New York",
    country: "USA"
  },
  hobbies: ["reading", "swimming"]
};

// Lodash를 사용한 깊은 복사
const nextUser = cloneDeep(user);

// 복사된 객체의 값을 변경
nextUser.name = "Jane Doe";
nextUser.age = 31;
nextUser.address.city = "Los Angeles";
nextUser.hobbies.push("painting");

console.log("Original user:", user);
console.log("Next user:", nextUser);

3. structuredClone 사용

비교적 최신 문법으로 기본적으로 제공하는 api이다. 최신 문법이기 때문에 호환성 체크를 필수적으로 하고 사용하자.(can-i-use)

낮은 브라우저 버전에서도 해당 기능을 사용하면서 서비스를 구성하고 싶다면 pollyfill도 있다.

structuredClone는 함수, 에러 객체를 복사할 수 없다.(참고)

// 원본 객체 (간단한 구조)
const originalObj = {
    name: "John",
    age: 30,
    hobbies: ["reading", "swimming"],
    address: {
        city: "New York",
        country: "USA"
    }
};

// structuredClone을 사용한 깊은 복사
const deepCopyObj = structuredClone(originalObj);

// 복사된 객체의 내부 값 변경
deepCopyObj.name = "Jane";
deepCopyObj.hobbies.push("running");
deepCopyObj.address.city = "Los Angeles";

// 결과 출력
console.log("원본 객체:", originalObj);
console.log("깊은 복사 객체:", deepCopyObj);

// 참조 비교
console.log("원본과 복사본이 같은 객체인가?", originalObj === deepCopyObj);
console.log("원본과 복사본의 주소가 같은 객체인가?", originalObj.address === deepCopyObj.address);

2. immer 사용법

depth가 1-2 정도의 객체라면 spread 연산자를 통해 불변성을 유지하면서 데이터를 업데이트할 수 있을 것이다. 하지만 트리와 같이 몇 곂의 중첩 객체 데이터를 핸들링하려면 힘들 것이다.

추가로 위에서 봤던 객체의 데이터를 깊은 복사를 하고 수정을 하면 되겠지만 깊은 복사는 성능상 비용이 많이드는 작업니다.

immer을 사용하면 객체 변경을 불변성을 안 지키는 것처럼하지만 불변성을 유지된 채로 값을 변경할 수 있고 변경된 부분만 수정이 되고 나머지는 기존 참조를 사용할 수 있다. (구조적 공유)

immer의 카피-온-라이트 방식을 따른다.

기본 사용법

produce 함수를 통해 reicpe 콜백 함수에서 draftState를 받고 draftState를 수정한다. 수정이 끝나게 되면 draft를 기반으로 새로운 상태가 생성된다. (nextState 반환)

produce(baseState, recipe: (draftState) => void): nextState


(참조: https://immerjs.github.io/immer/)

import {produce} from "immer"

const baseState = [
    {
        title: "Learn TypeScript",
        done: true
    },
    {
        title: "Try Immer",
        done: false
    }
]

const nextState = produce(baseState, draftState => {
    draftState.push({title: "Tweet about it"})
    draftState[1].done = true
})

React에서 사용

코드를 기준으로 살펴 보면 앞서 봤던 것 같이 baseState를 따로 넘겨주지 않고 있다.
바로 recipe 콜백 함수를 produce의 첫 번째 인자로 넘긴다.

내부 코드를 살펴보면 recipe 함수만 넘겼을 경우 produce는 curriedProduce 함수를 리턴한다.

위 이미지 속 base2에 prevState가 넘겨지게 되고 그 값으로 기반으로 draftState가 생성되어 해당 값을 수정하는 것이다.

import React, { useState } from 'react';
import { produce } from 'immer';

type Todo = {
  id: number;
  text: string;
  completed: boolean;
}

type User = {
  name: string;
  email: string;
  todos: Todo[];
}

const initialUser: User = {
  name: "John Doe",
  email: "john@example.com",
  todos: [
    { id: 1, text: "Learn React", completed: false },
    { id: 2, text: "Learn Immer", completed: false },
  ],
};

function UserProfile() {
  const [user, setUser] = useState<User>(initialUser);

  const updateEmail = (newEmail: string) => {
    setUser(
      produce((draft) => {
        draft.email = newEmail;
      })
    );
  };

  const addTodo = (text: string) => {
    setUser(
      produce((draft) => {
        draft.todos.push({
          id: draft.todos.length + 1,
          text,
          completed: false,
        });
      })
    );
  };

  const toggleTodo = (id: number) => {
    setUser(
      produce((draft) => {
        const todo = draft.todos.find((t) => t.id === id);
        if (todo) {
          todo.completed = !todo.completed;
        }
      })
    );
  };

  return (
    <div>
      <h1>{user.name}'s Profile</h1>
      <p>Email: {user.email}</p>
      <button onClick={() => updateEmail("newemail@example.com")}>
        Update Email
      </button>
      <h2>Todos:</h2>
      <ul>
        {user.todos.map((todo) => (
          <li
            key={todo.id}
            style={{ textDecoration: todo.completed ? "line-through" : "none" }}
            onClick={() => toggleTodo(todo.id)}
          >
            {todo.text}
          </li>
        ))}
      </ul>
      <button onClick={() => addTodo("New Todo")}>Add Todo</button>
    </div>
  );
}

export default UserProfile;

zustand에서 사용

zustand에서 제공해주는 middleware를 통해 immer 적용할 수 있다.

import create from 'zustand'
import { immer } from 'zustand/middleware/immer'

type User = {
  id: number
  name: string
  address: {
    street: string
    city: string
    country: string
  }
  orders: {
    id: number
    items: {
      id: number
      name: string
      quantity: number
    }[]
  }[]
}

type UserState = {
  user: User
  updateUserCity: (newCity: string) => void
  addOrderItem: (orderId: number, item: { id: number; name: string; quantity: number }) => void
}

// Immer를 사용하지 않은 경우
const useUserStoreWithoutImmer = create<UserState>((set) => ({
  user: {
    id: 1,
    name: "John Doe",
    address: {
      street: "123 Main St",
      city: "Old City",
      country: "Countryland"
    },
    orders: [
      {
        id: 1,
        items: [
          { id: 1, name: "Book", quantity: 2 }
        ]
      }
    ]
  },
  updateUserCity: (newCity) => set((state) => ({
    user: {
      ...state.user,
      address: {
        ...state.user.address,
        city: newCity
      }
    }
  })),
  addOrderItem: (orderId, newItem) => set((state) => ({
    user: {
      ...state.user,
      orders: state.user.orders.map(order => 
        order.id === orderId
          ? { ...order, items: [...order.items, newItem] }
          : order
      )
    }
  }))
}))

// Immer를 사용한 경우
const useUserStore = create<UserState>()(immer((set) => ({
  user: {
    id: 1,
    name: "John Doe",
    address: {
      street: "123 Main St",
      city: "Old City",
      country: "Countryland"
    },
    orders: [
      {
        id: 1,
        items: [
          { id: 1, name: "Book", quantity: 2 }
        ]
      }
    ]
  },
  updateUserCity: (newCity) => set((state) => {
    state.user.address.city = newCity
  }),
  addOrderItem: (orderId, newItem) => set((state) => {
    const order = state.user.orders.find(o => o.id === orderId)
    if (order) {
      order.items.push(newItem)
    }
  })
})))

3. immer의 동작 원리

immer은 어떻게 동작할까?

내부적으로 Proxy를 사용해서 처리한다고 한다.

draft에 먼저 변경 사항을 반영하고 그 후에 새로운 데이터에 반영한다. 앞서 언급했듯이 이때 변경점이 없는 데이터는 그대로 사용한다. (구조적 공유)

지금부터 immer의 내부 코드를 볼 것인데 글 쓰는 시점의 최신 버전인 v10.1.1 코드를 기준으로 한다.

주로 사용하는 produce 함수의 내부 구현체를 중심으로 살펴보자.

curring 함수 처리

immerClass.ts > produce 함수

produce: IProduce = (base: any, recipe?: any, patchListener?: any) => {
		
        // base가 함수일 경우, recipe 함수를 base로 설정하고, curriedProduce 함수를 반환한다.
		if (typeof base === "function" && typeof recipe !== "function") {
			const defaultBase = recipe
			recipe = base

			const self = this
			return function curriedProduce(
				this: any,
				base = defaultBase,
				...args: any[]
			) {
				return self.produce(base, (draft: Drafted) => recipe.call(this, draft, ...args)) // prettier-ignore
			}
		}

  //...

produce 함수의 맨 처음 부분으로 curring 함수를 반환한다.

  • base가 함수이고 recipe가 함수가 아니라면 recipe에 base를 할당
  • curriedProduce를 반환한다. (produce를 반환)

scope 생성

base 값이 draft를 생성할 수 있는 값이라면 scope, proxy를 생성한다. 먼저 scope 생성에 대해 보자.

immerClass.ts > produce 함수

//https://github.com/immerjs/immer/blob/e2d222bd4fb26abded04075c936290715e9ee335/src/core/immerClass.ts#L93

if (isDraftable(base)) {
  // 스코프 생성
  const scope = enterScope(this)
  //...
}

enterScope 함수를 호출해서 scope를 생성한다.

scope.ts > enterScope와 createScope 함수

enterScope 함수

let currentScope: ImmerScope | undefined

export function enterScope(immer: Immer) {
	return (currentScope = createScope(currentScope, immer))
}

enterScope 함수에서는 createScope 함수를 호출하여 현재 스코프를 기준으로 새로운 스코프를 생성하고 현재 스코프에 할당 후 리턴한다.

이는 현재 활성화된 스코프를 항상 추적하는 것이다.

createScope 함수

function createScope(
	parent_: ImmerScope | undefined,
	immer_: Immer
): ImmerScope {
	return {
		drafts_: [],
		parent_,
		immer_,
		// Whenever the modified draft contains a draft from another scope, we
		// need to prevent auto-freezing so the unowned draft can be finalized.
		canAutoFreeze_: true,
		unfinalizedDrafts_: 0
	}
}
  • drafts_ 배열에는 해당 스코프 내에서 생성된 draft(proxy 객체)들이 들어간다.
  • immer_는 현재 Immer class가 담긴다.
  • createScope 함수에 전달된 현재 스코프는 새로 생성될 스코프의 부모가 된다.
  • unfinalizedDrafts_ 는 아직 finalize되지 않는 draft 수를 나타낸다.

proxy 생성

immerClass.ts

//https://github.com/immerjs/immer/blob/e2d222bd4fb26abded04075c936290715e9ee335/src/core/immerClass.ts#L94

if (isDraftable(base)) {
  //... 
  const proxy = createProxy(base, undefined)
  //... 		
  }

createProxy 함수를 살펴보자. base 값을 기반으로 proxy가 생성될 것으로 예상된다.

immmerClass.ts > createProxy 함수

export function createProxy<T extends Objectish>(
	value: T, // base로 넘겨진 값
	parent?: ImmerState
): Drafted<T, ImmerState> {
	// precondition: createProxy should be guarded by isDraftable, so we know we can safely draft

   // value의 타입에 따라 draft 생성
   // 마지막 map, set이 아닐 경우 createProxyProxy를 통해 프록시 생성
	const draft: Drafted = isMap(value)
		? getPlugin("MapSet").proxyMap_(value, parent)
		: isSet(value)
		? getPlugin("MapSet").proxySet_(value, parent)
		: createProxyProxy(value, parent)

	const scope = parent ? parent.scope_ : getCurrentScope()
	scope.drafts_.push(draft) // 현재 스코프의 drafts_ 배열에 draft를 추가한다.
	return draft
}

value 타입에 따라서 각 타입에 맞게 proxy 객체를 생성해준다. map, set이 아닌 경우 createProxyProxy 함수를 호출한다. 해당 함수부터 보자.

immmerClass.ts > createProxyProxy 함수

//https://github.com/immerjs/immer/blob/e2d222bd4fb26abded04075c936290715e9ee335/src/core/proxy.ts#L57

export function createProxyProxy<T extends Objectish>(
	base: T,
	parent?: ImmerState
): Drafted<T, ProxyState> {
	const isArray = Array.isArray(base)

    // 기본 proxy 상태 생성
	const state: ProxyState = {
		type_: isArray ? ArchType.Array : (ArchType.Object as any),
		// Track which produce call this is associated with.
		scope_: parent ? parent.scope_ : getCurrentScope()!,
		// True for both shallow and deep changes.
		modified_: false,
		// Used during finalization.
		finalized_: false,
		// Track which properties have been assigned (true) or deleted (false).
		assigned_: {},
		// The parent draft state.
		parent_: parent,
		// The base state.
		base_: base,
		// The base proxy.
		draft_: null as any, // set below
		// The base copy with any updated values.
		copy_: null,
		// Called by the `produce` function.
		revoke_: null as any,
		isManual_: false
	}
    //...
}

먼저 proxy state 객체를 생성한다. 객체에 담긴 정보는 다음과 같다.

  • type_

    • 객체의 타입이다.
    • 배열이면 ArchType.Array이고, 그렇지 않으면 ArchType.Object이다.
  • scope_

    • 현재 상태가 속한 스코프이다.
    • 부모가 있으면 부모의 스코프이고, 없으면 현재 활성화된 스코프이다.
  • modified_

    • 객체가 변경되었는지를 나타내는 플래그이다.
    • 중첩된 객체에서 자식이 변경되면 부모까지 모두 modified_ 값이 true로 변경된다.
  • finalized_

    • 상태가 finalize되었는지를 나타내는 플래그이다.
  • assigned_

    • 어떤 속성이 할당되었는지(true) 또는 삭제되었는지(false)를 추적하는 객체이다.
  • parent_

    • 부모 draft(proxy 객체) 상태이다.
    • 중첩된 객체의 경우 사용된다.
  • base_

    • 원본 상태 객체이다. produce 함수에서의 첫 번째 인자이다.
    • 변경 전의 초기 상태를 나타낸다.
  • draft_

    • 현재 상태의 proxy 객체이다.
  • copy_

    • 업데이트된 값을 포함한 기본 상태의 복사본이다.
    • recipe 함수를 통해 변경된 사항이 여기에 저장된다.
    • 변경이 발생하면 생성된다.
  • revoke_

    • proxy 객체를 폐기할 수 있는 메서드가 담긴다.
    • 해당 메서드가 실행된 proxy 객체는 GC에 의해 수거된다.
  • isManual_

    • 수동 모드인지를 나타내는 플래그이다.
    • 기본값은 false이다.

ProxyState 객체는 Immer가 상태 변경을 추적하고 관리하는 데 사용되는 핵심 구조이다. 각 필드는 상태의 다양한 측면을 추적하고, 불변성을 유지하면서 효율적인 상태 업데이트를 가능하게 한다.

다음 코드로 넘어가자.

//https://github.com/immerjs/immer/blob/e2d222bd4fb26abded04075c936290715e9ee335/src/core/proxy.ts#L86

export function createProxyProxy<T extends Objectish>(
	base: T,
	parent?: ImmerState
): Drafted<T, ProxyState> {
  
  	//...
    let target: T = state as any
    // traps 함수 생성 => get, set이 핵심
	let traps: ProxyHandler<object | Array<any>> = objectTraps // proxy handler => get, set
	if (isArray) {
		target = [state] as any
		traps = arrayTraps
	}

	const {revoke, proxy} = Proxy.revocable(target, traps) // revoke할 수 있는 proxy 생성, traps 포함
	state.draft_ = proxy as any // 현재 state에 proxy 할당
	state.revoke_ = revoke // revoke 메서드 할당
	return proxy as any
}

동작을 가로채는 objectTraps 객체를 traps에 할당해주는 것을 확인할 수 있다.

그리고 현재 state를 그대로 가지고 있는 target을 기반으로 revoke할 수 있는 proxy 객체를 생성한다.

현재 proxy 객체를 저장해두는 state.draft_에 생성한 proxy 객체를 할당한다.

proxy 객체를 폐기할 수 있는 revoke 메서드도 state.revoke_에 할당한다.

그리고 마지막으로 생성한 proxy 객체를 반환한다.

여기서 핵심은 objectTraps 객체이다. produce 힘수에서 draft로 받아서 수정하는 것이 결국 proxy 객체를 통해 수정하는 것인데, get, set 트랩을 가지고 있는 objectTraps 가 동작을 가로챈다.

immmerClass.ts > objectTraps 객체

objectTraps에서 핵심인 get, set 메서드만 살펴보도록 하자.

앞서 언급했듯이 트랩(trap)은 동작을 가로채는 매서드가 담긴 객체이다. (참고)

get 트랩은 target의 프로퍼티를 읽을 때, set 트랩은 target의 프로퍼티를 쓸 때 활성화된다.

우선 get, set에서 사용하고 있는 peek 함수와 lastest 함수 이다.

peek 함수

function peek(draft: Drafted, prop: PropertyKey) {  
  const state = draft[DRAFT_STATE]  
  const source = state ? latest(state) : draft  
  return source[prop]  
}

만약 draft가 Immer의 Proxy객체라면, 이 작업으로 ProxyState 객체를 얻게 된다. 일반 객체라면 undefined가 된다.

state가 존재한다면 (즉, draft가 Immer의 Proxy객체):

  • latest(state)를 호출하여 가장 최신 상태를 가져온다.
  • 이는 보통 state.copy_가 있으면 그것을, 없으면 state.base_를 반환한다.

state가 없다면 (즉, draft가 일반 객체라면):

  • draft 자체를 소스로 사용하여 반환한다.

lastest 함수

export function latest(state: ImmerState): any {
	return state.copy_ || state.base_
}

단순하다. state.copy_ 가 있다면 이를 반환하고 없다면 state.base_를 반환한다.


이제 get, set 트랩에 대해 알아보자.

get 트랩

export const objectTraps: ProxyHandler<ProxyState> = {
	//...
	get(state, prop) {
			// draft[DRAFT_STATE]로 접근할 경우 state를 바로 반환해준다.
			if (prop === DRAFT_STATE) return state
	
			const source = latest(state) // _copy가 있으면 _copy를 반환, 없으면 _base를 반환
			if (!has(source, prop)) {
				// non-existing or non-own property...
				return readPropFromProto(state, source, prop) // 속성이 없으면 프로토타입 체인을 통해 읽어온다.
			}
			const value = source[prop]
			if (state.finalized_ || !isDraftable(value)) {
				return value
			}
			// Check for existing draft in modified state.
			// Assigned values are never drafted. This catches any drafts we created, too.
	    // 동일하면 이 속성이 아직 수정되지 않았음을 의미한다.
	    // 따라서 복사본을 준비하고 프록시를 생성한다.
	    // 이는 중첩된 객체나 배열에 대해 지연 프록시 생성을 구현한다.
			if (value === peek(state.base_, prop)) {
				prepareCopy(state) // _base를 _copy로 복사
				return (state.copy_![prop as any] = createProxy(value, state)) // _copy에 대한 프록시 생성
			}
			return value
		}
		//...
}

위 코드를 좀 더 나눠서 살펴 보자

if (prop === DRAFT_STATE) return state

제일 먼저 DRAFT_STATE 심볼을 키로 접근할 경우 바로 state를 반환한다. 이는 draft(proxy 객체)를 바로 가지고 오기 위한 로직이다.

const source = latest(state) 

lastest 함수를 통해 proxy 객체인 state에서 copy_가 있으면 copy_를 반환, 없으면 base_를 반환한다.

if (!has(source, prop)) {
 return readPropFromProto(state, source, prop)
}

만약, 접근하려는 prop 이 현재 source에 없다면 readPropFromProto 함수를 통해 프로토타입 체인을 통해 값을 읽어 오고 반환한다.

if (state.finalized_ || !isDraftable(value)) {
  return value
}

현재 state 가 finalized 상태(return될 준비 완료)이거나 object나 array 같은 mutable 하지 않는 경우는 바로 value를 반환한다. 이는 불필요한 proxy 생성을 방지한다.

if (value === peek(state.base_, prop)) {
  prepareCopy(state)
  return (state.copy_![prop as any] = createProxy(value, state))
}

peek 함수를 통해 원본 데이터인 base_ 에서 prop을 키로 가지는 값을 가지고 온다.

  • 일반적으로 base_는 원본 상태이므로, peek 함수는 프록시가 아닌 원래 값을 반환할 것이다.

valuepeek 함수를 통해 가지고 온 값(base_[prop])이 같으면 아직 수정되지 않았음을 의미한다. 따라서 해당 속성에 대해 프록시 생성이 필요하다.

  • 먼저 base_copy_로 얕은 복사한다. (prepareCopy)
  • copy_[prop] 에 proxy 객체를 생성해 할당하고 반환한다.
  • 이는 중첩된 객체나 배열에 대해 지연 프록시 생성을 구현하는 것이다.
  • 필요한 시점에만 프록시를 생성함으로써, Immer는 성능을 최적화하고 불필요한 객체 생성을 피할 수 있다.

set 트랩

이번에는 수정 시에 동작을 가로 채는 set 트랩을 보자.

먼저 내부에서 사용되는 markChanged 함수부터 보자.

export function markChanged(state: ImmerState) {  
  if (!state.modified_) {  
   state.modified_ = true  
   if (state.parent_) {  
    markChanged(state.parent_)  
   }  }  
}

재귀적으로 본인을 포함해서 부모가 있으면 부모까지 접근을 해서 state.modified_를 true로 변경하여 수정되었음을 설정한다.

export const objectTraps: ProxyHandler<ProxyState> = {
	//...
	set(
		state: ProxyObjectState,
		prop: string /* strictly not, but helps TS */,
		value // 신규 값
	) {
		//...
		if (!state.modified_) {
			// the last check is because we need to be able to distinguish setting a non-existing to undefined (which is a change)
			// from setting an existing property with value undefined to undefined (which is not a change)
			const current = peek(latest(state), prop)
			// special case, if we assigning the original value to a draft, we can ignore the assignment
			const currentState: ProxyObjectState = current?.[DRAFT_STATE]
			if (currentState && currentState.base_ === value) {
				state.copy_![prop] = value
				state.assigned_[prop] = false
				return true
			}
			if (is(value, current) && (value !== undefined || has(state.base_, prop)))
				return true
			prepareCopy(state)
			markChanged(state) // 모든 부모를 타고 올라가서 변경되었음을 표시한다.
		}

		if (
			(state.copy_![prop] === value &&
				// special case: handle new props with value 'undefined'
				(value !== undefined || prop in state.copy_)) ||
			// special case: NaN
			(Number.isNaN(value) && Number.isNaN(state.copy_![prop]))
		)
			return true

		// @ts-ignore
		state.copy_![prop] = value // 새로운 값을 복사본에 할당한다. _base는 변경하지 않는 것을 알 수 있다.
		state.assigned_[prop] = true
		return true
	},
	//...
}

해당 코드도 한번에 보기 힘들기 때문에 나눠서 보자.

export const objectTraps: ProxyHandler<ProxyState> = {
	//...
	set(
		state: ProxyObjectState,
		prop: string /* strictly not, but helps TS */,
		value // 신규 값
	) {
		//...
		if (!state.modified_) {
			const current = peek(latest(state), prop)
			const currentState: ProxyObjectState = current?.[DRAFT_STATE]
			if (currentState && currentState.base_ === value) {
				state.copy_![prop] = value
				state.assigned_[prop] = false
				return true
			}
			if (is(value, current) && (value !== undefined || has(state.base_, prop)))
				return true
			prepareCopy(state)
			markChanged(state) // 모든 부모를 타고 올라가서 변경되었음을 표시한다.
		}
	//...
}

수정 여부를 나타내는 modified_ flag가 false일 경우는 아직 수정되지 않은 상태를 의미한다.

  1. latest(state)로 최신 proxy 상태를 가지고 온다.
  2. peek 함수를 사용해 최신 상태의 속성값을 가지고 온다.
  3. DRAFT_STATE 심볼을 통해 current가 draft(proxy 객체)인지 확인한다.
  4. 현재 값이 draft(proxy 객체)이고 그 draft(proxy 객체)의 원본 값(base_)이 새로 설정하려는 값과 같은 경우
    • draft 에 원본 값을 다시 할당하는 특별한 경우를 처리하는 로직이다.
    • state.copy_![prop] = value: 복사본에 새로운 값을 설정한다. (사실 기존에 currentState.base_ 가 가지고 있는 기존 존재하는 값일 것이다) => 기존값 그대로 사용
    • true를 리턴하여 set 트랩을 종료한다.
    • 위 동작을 통해 원본 데이터와의 참조 유지를 할 수 있다.(구조적 공유)
    • 메모리 사용을 최적화하고, 불필요한 객체 생성을 방지
  5. 현재 값과 새 값이 같고 새 값이 undefined가 아니거나 원본 데이터(state.base_)에 이미 prop이 존재하면 변경할 필요가 없는 것이다. (재사용)
    • 따라서 바로 true 리턴한다.
  6. 위 모든 조건에 걸리지 않는다면 prepareCopy, markChanged를 통해 값을 복사(얕은 복사)하고 본인 포함 부모까지 modified_true로 변경하는 작업을 해준다.
if (
  (state.copy_![prop] === value &&
   // special case: handle new props with value 'undefined'
   (value !== undefined || prop in state.copy_)) ||
  // special case: NaN
  (Number.isNaN(value) && Number.isNaN(state.copy_![prop]))
)
  return true

valuestate.copy_![prop]와 같고, 값이 undefined가 아니거나 해당 속성이 이미 존재하는 경우 또는 새 값과 현재 상태의 값이 모두 NaN인 경우

  • 상태 업데이트가 필요하지 않기 때문에 바로 true를 리턴한다.
  • 불필요한 상태 업데이트와 그에 따른 렌더링을 방지하기 위한 최적화 기법
// @ts-ignore
state.copy_![prop] = value // 새로운 값을 복사본에 할당한다. _base는 변경하지 않는 것을 알 수 있다.
state.assigned_[prop] = true
return true

위 모든 조건에서 걸리지 않는다면 state.copy_![prop] = value 를 통해 새로운 값은 복사본에 할당한다.

  • 즉, 변경한 부분에만 새로운 값을 할당하는 것이다. (state.copy_state.base_의 얕은 복사)
  • 변경되지 않은 부분은 기존 참조 유지 (구조적 공유)
  • true를 리턴하여 set 트랩을 종료한다.

핵심은 변경이 발생하면 base_를 수정하는 것이 아닌 copy_를 수정한다는 것이다.

finalize

이제 마지막으로 변경값을 실제로 반영해주는 finailze를 해야 한다.

finalize.ts > processResult 함수

주요 로직만 뽑아서 보도록 하자.

export function processResult(result, scope) {
    const baseDraft = scope.drafts_[0];
    const isReplaced = result !== undefined && result !== baseDraft

    if (isReplaced) {
        if (isDraftable(result)) {
			result = finalize(scope, result) // 반영
			if (!scope.parent_) maybeFreeze(scope, result)
		}
    } else {
        result = finalize(scope, baseDraft, []);
    };
}

result가 없거나 객체가 완전 변경되지 않았을 경우에는 baseDraft 기준으로 finalize를 진행한다. recipe 함수 내에서 완전 다른 객체를 리턴한 경우라고 할 수 있다.

result가 완전 새로운 객체로 변경되었고 draft 가능한 객체라면 해당 result 값을 기준으로 finalize를 진행한다.

완전 새로운 객체 반환 예시)

const nextState = produce(baseState, draft => {
    // draft를 수정하는 대신 완전히 새로운 객체를 반환
    return {
        ...baseState,
        someNewProperty: 'newValue'
    }
})

finalize.ts > finalize 함수

finalize 함수를 살펴보자.

function finalize(rootScope: ImmerScope, value: any, path?: PatchPath) {  
  // Don't recurse in tho recursive data structures  
  if (isFrozen(value)) return value  
  
  const state: ImmerState = value[DRAFT_STATE]  
  // A plain object, might need freezing, might contain drafts  
  if (!state) {  
   each(value, (key, childValue) =>  
    finalizeProperty(rootScope, state, value, key, childValue, path)  
   )  
   return value  
  }
  if (!state.modified_) {
		maybeFreeze(rootScope, state.base_, true)
		return state.base_
  }
  //...
  // Not finalized yet, let's do that now  
  if (!state.finalized_) {  
   state.finalized_ = true  
   state.scope_.unfinalizedDrafts_--  
   const result = state.copy_  
   let resultEach = result  
   
   each(resultEach, (key, childValue) =>  
    finalizeProperty(rootScope, state, result, key, childValue, path, isSet)  
   )  
   //...
  }  
  return state.copy_  
}

일반 객체일 경우 바로 finalizeProperty 함수를 통해 자식 객체를 모두 finalize 진행한다. (앞서 봤던 완전 새로운 객체를 반환했을 때 해당 조건에 걸릴 것이다.)

위 조건에 안 걸린다면 value로 넘어온 값은 const baseDraft = scope.drafts_[0]; 로 가장 최초 호출 시에 최상위 root proxy이다. 재귀적으로 호출 되었다면 특정 객체 또는 배열일 것이다.

modified_를 false로 갖고있다면 자식을 포함한 내부 객체가 한번도 변경된 적이 없다는 의미이다. 따라서 state.base_ (원본)를 그대로 리턴한다.

state.finalized_ 가 false일 경우 finalize를 진행해야 된다는 뜻이므로 진행한다.

현재 state.copy_ 를 기반으로 finalize를 진행한다.

그리고 마지막으로 state.copy_를 리턴한다.

중첩 객체 데이터일 경우 finalize 함수는 재귀적으로 호출될 것인데, 코드에서 알 수 있듯이 수정이 되지 않았다면(!state.modified_) 기존 원본(stata.base_)를 그대로 반환한다. 이는 immer이 변경점 이외에 데이터는 참조를 유지하여 구조적 공유의 사용을 확인할 수 있는 것이다.

immer 정리

  • immer는 내부적으로 Proxy 객체를 사용하여 get과 set 트랩을 통해 동작을 가로 채기 때문에 객체를 불변성을 유지하지 않는 것처럼 변경해도 set 트랩에서 base를 변경하지 않고 copy로 shallow copy하여 copy_ 객체를 수정한다.

immer 라이브러리를 사용하면 recipe 함수 내부에서 proxy를 통해 불변성이 안 지키는 것처럼 업데이트를 할 수 있지만 내부적으로 copy_ 값을 변경하기 때문에 불변성이 지켜지는 방식으로 값을 업데이트 할수 있다.

  • get 트랩을 통해 접근하는 객체를 모두 createProxy 함수를 통해 proxy 객체로 리턴하여 객체 깊숙한 곳을 참조하더라도 proxy를 생성한다.

객체의 속성에 직접 접근했을 때만 proxy 객체를 생성하는 것으로 지연 proxy 생성을 구현하는 것이다. 다시 접근하게 되면 이전에 생성된 proxy 객체를 반환함으로써 재사용한다.

  • objectTraps 를 기반으로 생성된 proxy 객체의 set, get 트랩을 통해 recipe 함수 내의 수정을 완료하면 변경된 객체는 업데이트 된 객체(copy_)를 사용하고 변경되지 않은 객체는 기존 객체(base_)를 사용하여 구조적 공유를 사용한다.

이는 finalize 함수에서 확인할 수 있다.


4. 느낀점

그냥 라이브러리를 가져다 쓰는 것보다 내부 동작 원리를 파악하니 내가 가지고 있던 궁금증이 해소되었다.

앞으로는 깊게 파서 공부하는 습관을 들이는 게 좋을 것 같다.

처음에 코드를 보며 막막했지만 여러 자료 및 AI 툴을 활용하면 할 만한 것 같다.


참고
https://immerjs.github.io/immer/
https://hmos.dev/deep-dive-to-immer#deep-dive-%EC%A0%84-immer%EC%97%90-%EB%8C%80%ED%95%B4%EC%84%9C
https://ko.javascript.info/proxy

profile
그냥 하자

0개의 댓글