JavaScript Proxy를 활용한 상태 추적 도구 개발기

3 days ago 5

1. 레거시가 주는 고통

세일즈서비스팀에서는 사장님의 배달의민족 입점을 도와드리기 위한 입점신청, 계약 검수 및 승인 시스템, 전자계약서’ 등 여러 지면을 개발하고 있습니다. 그중 전자계약서는 업주 정보, 가게 정보, 광고 정보 등을 입력하고 전자 서명을 통해 계약을 완료하는 핵심 지면입니다. 하지만 이 지면은 상태 관리 라이브러리(Redux, Zustand 등) 없이, 거대한 단일 객체에 모든 계약 관련 데이터를 담아 처리하는 구조로 되어 있습니다. 이로 인해, 입점하려는 사용자가 가게 정보를 입력했을 뿐인데, 업주 정보나 광고 정보까지도 영향을 받는 등 예측 불가능한 연쇄 변화가 발생하곤 합니다.

문제는 이 관계들이 코드에 명시적으로 드러나 있지 않고, 문서화도 되어 있지 않다는 점입니다.

따라서, 예상치 못한 값의 변경이 발생할 때마다 아래와 같은 방식으로 확인해야 했습니다.

"어디서 이 필드를 바꾸는 거지?"
→ console.log()를 이곳저곳에 심어서 추적
→ 예상치 못한 값 변경 발견
→ 다시 전체 흐름 재분석

이러한 구조는 개발자에게 지속적인 고통을 주었습니다.

  • 빈번한 버그 핫픽스: 상태 변화의 원인을 명확히 파악하기 어려워, 유사한 이슈가 반복 발생하기도 함.
  • 영향 범위 예측의 어려움: 어떤 값을 바꾸면 어디까지 영향이 갈지 파악하기 어려워, 개발자가 선뜻 손대기 어려운 구조가 됨.
  • 개발자 생산성 저하: 디버깅과 기능 추가에 많은 시간이 소모되며, 생산성이 떨어짐.

문제를 해결하고자 상태 관리 라이브러리 도입을 고려했지만, 현실적인 제약이 있었습니다.

상태 관리 라이브러리 도입이 어려웠던 이유

사실 상태 관리 라이브러리를 도입해 구조적으로 문제를 해결하는 방안은 팀 내에서도 여러 차례 논의됐습니다. 특히 데이터 흐름이 복잡하고 연쇄적인 상태 변화가 많은 상황에서는, 명시적인 액션과 상태 변화를 추적할 수 있는 Redux 같은 도구가 큰 도움이 됩니다.

하지만 여느 회사에서도 그렇듯, 리소스가 부족했습니다. 스프린트마다 우선순위가 높은 업무를 할당하다 보면, 관련 코드 전반을 마이그레이션할 시간을 확보하는 건 거의 불가능했습니다. 설계부터 개발, 테스트까지 전체 마이그레이션 과정을 감당할 여력이 없었습니다. 즉, 근본적인 해결을 하고 싶어도, 지금은 할 수 없는 타이밍이었던 겁니다. 이런 배경 속에서 고민이 시작됐습니다. “상태 관리 라이브러리를 도입하지는 않더라도 최소한 상태의 ‘변화’만이라도 추적할 수 있으면 어떨까?”

라이브러리 도입 없이 상태 변화 추적하기

객체의 상태가 언제, 어떻게 변했는지 가시적으로 추적하는 기능이 절실히 필요했습니다. 특히 복잡하게 얽힌 상태 변화 흐름을 한눈에 파악하지 못하면, 버그 원인 찾기와 수정 작업이 너무 어려웠거든요. Redux가 제공하는 ‘Redux DevTools’의 가장 큰 강점은, 상태 변화의 과정을 시간 순서대로 깔끔하게 보여주고, 변화된 값을 직관적으로 비교할 수 있다는 점입니다. 하지만 Redux를 쓸 수 없는 상황에서, 상태 변화 추적 기능만이라도 갖출 방법을 고민하게 되었습니다.

그렇다면, Redux 같은 별도의 상태 관리 라이브러리를 도입하지 않고도 어떻게 Redux DevTools와 비슷하게 상태 변화를 추적할 수 있을까요? 답은 바로 JavaScript Proxy API에 있었습니다. Proxy를 활용하면 기존 코드에 거의 손대지 않고도 객체의 상태 변화를 가로채서 감시할 수 있기 때문입니다.

2. JavaScript Proxy를 활용한 상태 추적 방식과 구현

JavaScript Proxy란?

JavaScript의 Proxy는 객체의 동작을 가로채서 제어할 수 있는 메커니즘입니다.

예를 들어, 누군가 객체의 name 프로퍼티에 접근하거나 값을 변경할 때 이를 가로채서 로그를 남기거나, 어떤 조건에 따라 다른 동작을 수행하게 만들 수 있습니다.

const user = { name: "Baemin", age: 15, }; const proxy = new Proxy(user, { get(target, prop) { console.log(`GET: ${target[prop]}`); return target[prop]; }, set(target, prop, value) { console.log(`SET: ${prop} = ${value}`); target[prop] = value; return true; }, }); proxy.name; // GET: Baemin proxy.age = 16; // SET: age = 16

이처럼 get, set 등의 트랩 메서드를 정의하면, 객체의 속성 접근이나 변경이 일어날 때마다 우리가 원하는 동작을 삽입할 수 있습니다.

이 아이디어를 확장해서, "객체의 필드가 언제 어떤 값으로 바뀌었는지 로그를 쌓고 추적"하는 도구를 만들 수 있었습니다.

이제 이 도구가 어떤 구조로 만들어졌는지 소개하겠습니다.

사용처에서 감시 대상 객체를 감싸주기

사용처에서는 상태 변화를 감시하고자 하는 객체를 다음과 같이 감싸주기만 하면 됩니다.

this.data = ProxyLogger(new 전자계약서());

이제 객체의 어떤 필드가 변화할 때마다 console.log를 사용하지 않고도, 상태 변화를 추적할 수 있게 됩니다. 제품 코드의 기존 로직은 유지한 채, 상태 변화 추적 기능만 덧붙인 셈입니다.

다음으로, 내부 구조는 다음과 같습니다.

ProxyLogger 함수

import { cloneDeep, isEqual } from 'lodash-es'; export const ProxyLogger = <T extends object>( obj: T, basePath: string = '', ): T => { const handler: ProxyHandler<T> = { get: (target: T, prop: string | symbol, receiver: any) => { const value = Reflect.get(target, prop, receiver); // 현재 프로퍼티의 전체 경로를 생성 const currentPropString = String(prop); const currentPath = basePath ? `${basePath}.${currentPropString}` : currentPropString; // 중첩 객체일 경우 재귀호출 if (typeof value === 'object' && value !== null) { return ProxyLogger(value, currentPath); } return value; }, set: (target: T, prop: string | symbol, value: any, receiver: any): boolean => { const currentPropString = String(prop); const fullPath = basePath ? `${basePath}.${currentPropString}` : currentPropString; const oldValue = Reflect.get(target, prop, target); const copiedOldValue = cloneDeep(oldValue); const copiedNewValue = cloneDeep(value); const areEqual = isEqual(copiedOldValue, copiedNewValue); const didSet = Reflect.set(target, prop, value, receiver); // 값이 실제로 바뀐 경우에만 로그 전송 if (didSet && !areEqual) { const logData = { property: fullPath, oldValue: copiedOldValue, newValue: copiedNewValue, timestamp: new Date().toISOString(), }; window.postMessage({ type: 'DEBUGGER_LOG_EVENT', detail: logData }, '*'); } return didSet; }, }; return new Proxy(obj, handler); };

이 함수는 객체를 Proxy로 감싸 값의 변화를 추적합니다. 본문 코드는 핵심 로직만 단순화한 것으로, 실제 구현 시에는 다양한 예외 처리가 필요합니다.
cloneDeep과 isEqual을 활용해 값이 실제로 변경되었는지 비교하며, 값이 변경되었을 때만 변경된 객체의 경로와 이전 데이터, 새 데이터, 변경 시각을 Chrome DevTools 패널에 전달합니다.

Chrome DevTools 패널 시각화

window.addEventListener("message", (event) => { if (event.source === window && event.data?.type === "DEBUGGER_LOG_EVENT") { chrome.runtime.sendMessage({ type: "DEBUGGER_LOG", data: event.data.detail, }); } });

DevTools 패널에서는 ProxyLogger에서 전달한 데이터를 수신하고, 로그를 시각화합니다.

  • 변화된 객체의 경로 출력
  • 이전 값과 새 값 출력
  • 시간순 로그 출력

이제 DevTools 패널을 열어보는 것만으로도 “어떤 필드가 언제 바뀐 거지?"라는 의문을 해결할 수 있게 되었습니다.

3. 도입 효과

상태 변화의 흐름을 가시화하여 디버깅 속도 향상

도구를 적용한 이후 가장 먼저 체감된 건 상태 변화의 흐름이 눈에 보이기 시작했다는 점입니다. 이전엔 "여기 console.log 찍어볼까? 아님 저기?" 하며 추측성 디버깅을 반복해야 했지만, 이제는 어떤 필드가 언제 바뀌었는지, 그전과 후의 값은 무엇이었는지를 시간순으로 추적할 수 있게 되었습니다.

DevTools 패널에 객체의 상태 변화가 순차적인 로그 형태로 정리되어 나타나기 때문에, 단 한 줄의 변화도 놓치지 않고 확인할 수 있었습니다. 예전 같으면 몇 시간을 디버깅하던 상황도, 이젠 몇 초 만에 원인을 파악할 수 있는 수준이 되었습니다.

팀 내 생산성 향상

과거에는 이슈가 발생하면 프런트엔드 개발자만이 개발 서버를 띄우고 원인을 찾을 수 있었기 때문에 많은 시간이 소요되었습니다. 특히 도메인 지식이 부족하다면 문제 지점을 찾기 위한 디버깅 시간은 더욱 길어졌습니다.

하지만 이제는 기획자, 백엔드, 프런트엔드 누구나 별도의 개발 서버를 실행하지 않고도 DevTools 패널에서 로그를 확인하며 문제 원인을 파악할 수 있게 되었습니다. 덕분에 문제의 원인 파악이 훨씬 빨라져 팀 내 생산성이 크게 향상되었습니다.

최소한의 코드 변경으로 큰 효과를 체감

기존 전자계약서 시스템에 Proxy 기반 상태 추적 도구를 도입하면서, 아주 적은 코드 변경만으로도 큰 효과를 체감할 수 있었습니다. Redux처럼 제품 구조를 대대적으로 바꿀 필요도 없었고, 복잡한 설정이나 보일러 플레이트 코드를 작성할 필요도 없었습니다.

기존의 상태 객체를 ProxyLogger()로 감싸기만 하면 되기 때문에, 학습이나 도입 부담이 거의 없었습니다. 덕분에 팀원들도 빠르게 도구를 이해하고 바로 실무에 적용할 수 있었고요.

4. 마치며

이번 경험에서 얻은 중요한 교훈이 하나 있습니다. 변화를 만들어내기 위해 반드시 거창한 도구가 필요한 것은 아니라는 점입니다.

단순히 Proxy를 적용하는 것만으로도, 별도의 상태 관리 라이브러리 없이 "값이 왜 바뀌었는지"에 대한 명확한 해답을 얻을 수 있었습니다. 기존 객체를 Proxy로 감싸는 것 외에는 거의 손댈 필요가 없었고, 그 결과 디버깅 속도는 물론 생산성에도 확실한 개선을 체감할 수 있었습니다.

이전에는 빈번한 버그 핫픽스, 영향 범위 예측의 어려움, 그리고 개발자 생산성 저하 등 여러 문제가 반복되었으나, Proxy를 활용함으로써 이런 문제들을 효과적으로 해결할 수 있었습니다.

상태 관리 라이브러리 도입이 부담스러운 상황에서, 디버깅이 어렵거나 값이 왜 바뀌었는지 추적이 필요한 상황이라면 Proxy를 한 번쯤 활용해보시길 추천드립니다.

Read Entire Article