노코드 툴 만드는 사람들 – 자바스크립트 오브젝트의 무결성 보장하기

배경

나두아이오의 데이터 모델, NADOO DOM(이하 _DOM)

이번 글은 내부 시스템에 대해 자세히 설명하는 내용은 아니지만 주제와 관련있는 내부 DOM, NADOO DOM의 컨텍스트에 대해 먼저 간략히 공유하려고 한다. 실제로는 더 복잡한 구조를 가지지만 극도로 추상화해보았다.

로우코드에는 최소한의 코드가 개입하고, 노코드는 코드를 요구하지 않는다. 그렇다면 코드 없이 어떻게 개발하는 걸까? 이는 각 Low-Code/No-Code(LCNC) 솔루션의 철학에 따라 다르다. 일반적으로는 GUI 편집을 위한 에디터, 그리고 모델과 변수 시스템을 가진다.

우리의 솔루션 또한 비슷하다. 별도의 데이터 모델, NADOO DOM(이하 _DOM, 브라우저의 DOM과 구분하기 위함이다)과 Variable System을 기반으로 한다.

런타임 시점의 _DOM = Read-only

이제 나의 엉성한 그림을 _DOM에 대한 읽기/쓰기권한이 나타나는 구조로 변환해보았다. 엉성한 User-side도 추가되었다. Nadoo.io로 배포한 앱의 사용자를 의미한다.

_DOM에 대한 쓰기 권한은 에디터, 즉 편집모드에서만 유효하다. 앱이 실행되는 Runtime 시점에는 VariableSystem을 기반으로 파싱된 _DOM을 읽기 권한으로만 접근할 수 있는 단방향 플로우다.

예를들어 사용자가 TextField(input) 컴포넌트에 글자를 입력하면 사용자의 인터렉션은 _DOM에 연결된 콜백함수를 호출해 Variable System을 통해 변수를 업데이트한다. 그리고 업데이트된 변수 기반으로 재해석(resolve)된 _DOM이 출력된다.

결론적으로 Runtime 환경에서 원본 _DOM은 변경되지 않는다

그런데 어느날, 변조된 _DOM을 관찰하게 된다.

트러블슈팅

어디선가 오브젝트를 변조하고 있다

_DOM을 업데이트할 만한 함수를 추적해봤는데 전부 호출되지 않는다. 그럼에도 어디선가 _DOM을 변경한다. 당연히 의심가는 함수 중에서 발생한 오류일 것이라는 전제를 깨지 못해 디버깅에 시간이 걸렸다. 다시 처음부터 오브젝트가 변경된다면 setter가 호출될 것이다라는 가설에서 출발했고, 바로 찾아낼 수 있었다.

아래의 디버깅 함수로 오브젝트에 프록시를 걸어 setter가 호출되는 지점의 콜스택을 확인했다.

function createDeepProxy(obj) {
  const handler = {
    get(target, prop) {
      const value = target[prop];
      if (typeof value === "object" && value !== null) {
        return new Proxy(value, handler);
      }
      return value;
    },
    set(target, prop, value) {
      console.log(`Property ${prop} modified:`);
      console.log("New value:", JSON.stringify(value, null, 2));
      console.trace();
      debugger;
      target[prop] = value;
      return true;
    },
  };

  return new Proxy(obj, handler);
}

export default function App() {
  const person = {
    name: "김철수",
    age: 30,
    address: {
      city: "서울",
      country: "대한민국",
    },
  };

  const proxiedPerson = createDeepProxy(person);

  function modifyPerson() {
    proxiedPerson.name = "김영희";
    proxiedPerson.address.city = "부산";
  }

  function handleClick() {
    modifyPerson();
    console.log(proxiedPerson);
  }

  return (
    <div className="App">
      <button onClick={handleClick}>Modify Person</button>
    </div>
  );
}

원본 오브젝트에 연결된 참조값

다시 사용자가 TextFIeld 컴포넌트에 ‘hi!’라고 입력한 시나리오로 돌아가보자. Variable System 내부에서는 **_DOM**을 기반으로 변수를 파싱하고, 이를 실제 값으로 업데이트한다.

<변수 파싱 및 resolve 결과>

{ TextField.value: ‘hi!’ }

이번에는 위의 변수를 다른 컴포넌트에서 참조한 경우를 살펴보자. 이 컴포넌트는 중첩된 변수 구조를 가진다. 이 경우 내부 메커니즘에 따라 여러 단계의 해석(resolve)을 거친다.

<변수 파싱 결과>

{
	component.query: {
		rules: [{ value: $TextField.value}] // TextField.value를 변수로 참조
	},
	component.query.rules[0].value: $TextField.value, // TextField.value를 변수로 참조
}

<1차 resolve 결과>

{
	component.query: {
		rules: [{ value: $textField.value }]
	},
	component.query.rules[0].value: ‘hi’, // 먼저 resolve됨
}

<2차 resolve 결과>

{
	component.query: {
		rules: [{ value: ‘hi’ }] // component.query.rules[0].value값이 들어감
	},
	component.query.rules[0].value: ‘hi’,
}
  • 각 value는 원본 _DOM에서 파싱된 결과다. 즉 별도의 깊은 복사* 연산이 없었다면 원본 _DOM에 대한 참조값이 유지된다.
  • component.query를 key값으로 가지는 변수의 value는 오브젝트다. Resolve 과정에서 오브젝트의 일부가 component.query.rules[0].value값으로 대체된다.
  • 이 과정에서 원본 _DOM도 변형된다.

*자바스크립트의 객체 복사

  • 얕은 복사: 최상위 레벨에 대해서만 다른 참조값을 가진다. 즉 중첩된 구조의 경우 참조값이 연결되어 있다.
    • 방법: Object.assign(), 스프레드 연산자(…), Array.slice()
  • 깊은 복사: 모든 레벨의 프로퍼티에 대해 다른 참조값이 생성된다. 복사된 대상을 변경해도 원본 오브젝트에 영향을 미치지 않는다.
    • 방법: JSON.parse(JSON.stringify()), 재귀 함수를 사용한 수동 복사, Lodash 라이브러리의 _.cloneDeep() 메서드

근데 왜 지금까지 직접적인 문제 현상이 나타나지 않았을까?

이전에는 페이지를 전환할 때 전체 _DOM이 다시 로드되어 페이지네이션된 부분을 보여주었다. 그러나 성능 문제로 인해 클라이언트 사이드 라우팅 적용을 고려하는 과정에서, 이전에 방문했던 페이지로 돌아갔을 때 변조된 _DOM으로 인해 이전과 다르게 동작하는 현상을 관찰하게 된 것이다.

해결방안

개발자의 실수로인한 오브젝트 변형 방지하기, 오브젝트 동결

원본 _DOM으로부터 객체를 추출할 때마다 매번 깊은 복사 연산을 수행하는 것은 비용이 높다. 그리고 여전히 문제 상황과 같은 실수가 발생할 수 있다.

그래서 내가 제시한 솔루션은 오브젝트를 동결시키는 것이다. 오브젝트를 동결시킬 수 있는 방법 중에서도 Object.freeze는 각 속성을 readonly로 만든다.

const obj = { a: 100 };

console.log(Object.getOwnPropertyDescriptors(obj));
/* {
  a: {
    configurable: true,
    enumerable: true,
    value: 100,
    writable: true
  }
}
*/

Object.freeze(obj);

console.log(Object.getOwnPropertyDescriptors(obj));
/* {
  a: {
    configurable: false,
    enumerable: true,
    value: 100,
    writable: false
  }
}
*/

원본 오브젝트가 수정되면 strict모드 여부에 따라 타입에러를 뱉거나 조용히 무시된다. 그리고 중첩된 구조라면 재귀적으로 변경해야 한다.

// <https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/freeze#deep_freezing>
  function deepFreeze(object) {
    const propNames = Reflect.ownKeys(object);

    for (const name of propNames) {
      const value = object[name];

      if ((value && typeof value === "object") || typeof value === "function") {
        deepFreeze(value);
      }
    }

    return Object.freeze(object);
  }

  const obj2 = {
    internal: {
      a: null,
    },
  };

  deepFreeze(obj2);

  obj2.internal.a = "anotherValue"; // fails silently in non-strict mode
  (() => {
    "use strict";
    obj2.internal.a = "anotherValue"; // Uncaught TypeError: Cannot assign to read only property 'a' of object '#<Object>'
  })();

결론적으로 위의 deepFreeze 함수로 _DOM을 감싸면 _DOM으로 부터 일부를 파싱하고, 재해석하는 과정에서 의도치않게 원본 _DOM까지 변경되는 것을 막을 수 있다.


이번 작업을 통해 에디터의 안정성에 기여할 수 있었다. 구조적 관점에서의 이슈를 발견할 수 있었던 새로운 경험이기도 했다.

뿐만 아니라 안정성 측면에서 보완이 필요한 작업들을 리스트업하는 데 계기가 되기도 하였다. 다음 편에서는 이와 관련해 리서치하고 적용해본 내용들을 공유하려고 한다.


솔루션의 비즈니스 가치를 고민하며 성장하는 개발자 김지후입니다.

—김지후, SW엔지니어 @ 나두모두