배경
나두아이오의 데이터 모델, 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엔지니어 @ 나두모두