웹뷰 디버깅 환경을 만들기까지의 여정

웹뷰 개발 중 웹에 앱 화면 전환 효과를 주기 위한 시도를 녹여보았습니다!

업로드 날짜: 2026년 2월 10일


웹뷰 환경에서 웹과 앱의 디버깅 환경 통일하기

최근 저는 웹뷰(WebView) 기반으로 개발한 앱을 출시하기 위해 막바지 QA를 진행하고 있습니다. 그런데 검증을 하다 보니 환경마다 디버깅 방식이 모두 달라, 작업 흐름이 계속 끊기고 문제를 재현하거나 원인을 파악하는 과정도 생각보다 훨씬 까다롭다는 점을 체감했습니다.

예를 들어 네이티브 환경에서는 Expo DevTools를 사용하고, 웹뷰 환경에서는 iOS는 Safari, Android는 Chrome 개발자 도구를 통해 확인해야 합니다. 이때 웹뷰는 실제로 렌더링이 시작된 뒤에야 디버깅 연결이 가능하기 때문에 앱 첫 화면에서 발생하는 요청은 아예 확인조차 할 수 없고, 웹뷰가 아닌 네이티브 화면으로 전환되면 디버깅이 자동으로 끊기기까지 합니다.

이 때문에 화면이 바뀔 때마다 매번 디버깅 도구를 다시 열어야 하고, 여기서 오는 복잡함은 개발자뿐 아니라 함께 QA를 진행하는 PM 등 비개발 구성원에게 설명하기도 어려웠습니다.

결국 QA 과정에서는 Safari, Chrome, Expo DevTools 등 다양한 도구들과 콘솔 창들이 뒤섞여 열리고 닫히기를 반복하게 되었고, 일관된 디버깅 흐름을 만들기 어렵다는 점이 큰 문제로 다가왔습니다.

그래서 자연스럽게 고민을 하게 되었습니다

웹뷰 개발 환경에서 하나의 통합된 디버깅 경험을 만들 수는 없을까요?

데스크 리서치

일관된 디버깅 환경을 만들기 위해 먼저 기존에 나와 있는 도구들과 사례들을 조사해 보았습니다. 이미 많은 개발자들이 비슷한 문제를 겪어왔을 것이고, 그 속에서 참고할만한 접근 방식이나 힌트를 얻을 수 있을 것이라 기대했습니다.

유사 도구 검토

먼저, 현재 모바일 환경에서 자주 언급되는 디버깅 도구들을 살펴봤습니다.

  • Flipper

    모바일 디버깅 도구로 한때 많이 사용되었지만, 현재는 프로젝트가 유지보수 중단(deprecated)되어 더이상 사용되지 않습니다.

  • Reactron

    네트워크·상태 등 다양한 로그를 수집해서 보여주는 데는 괜찮았지만, 웹뷰 내부의 동작을 디버깅할 수 없다는 한계가 있었습니다.

    Reactotron 자체를 커스텀 할 수 있는 방법이 없으므로, 고려하기 어려웠습니다.

  • Expo DevTools

    Expo 환경에서는 기본적으로 제공되는 도구지만, 개발 모드에서만 사용할 수 있다는 제약이 있습니다. 또한 웹뷰 내부 통신이나 렌더링을 직접 들여다볼 수 없어 QA 단계에서 필요한 정보를 얻기엔 부족했습니다.

결론적으로, 기존에 널리 알려진 도구들 중에서는 웹뷰와 네이티브를 아우르는 디버깅 솔루션은 찾기 어려웠습니다.

참고 자료 조사

원리적인 접근을 해보기 위하여 다른 개발자들의 이야기를 찾아보았습니다.

  • FEConf – 디버거 개발 과정 소개

    https://www.youtube.com/watch?v=ul_pozst_EU

    이 발표에서는 Chrome DevTools Protocol(CDP) 을 활용해 자체 디버거를 구현하는 과정을 소개하고 있습니다. CDP의 구조와 동작 방식을 이해하는 데 도움이 되었습니다.

  • 웹뷰 원격 디버깅 환경 만들기

    https://www.youtube.com/watch?v=VeH70q7p9NM

    웹뷰 화면을 원격으로 디버깅하는 방법을 설명합니다.

이와 관련해 비슷한 문제를 겪고 정리해둔 사람을 기대만큼 찾을 수는 없었습니다. 그나마 문제점을 어느정도 공감한 자료는 웹뷰 원격 디버깅 환경 만들기 영상이었습니다. 다만 이는 제가 해결하려는 방향과는 조금 다르게 해결을 하였습니다.

아무래도 React Native 커뮤니티 규모가 다른 플랫폼에 비해 작다는 점도 한 이유일 수 있고, 필요한 도구나 경험이 있어도 이를 먼저 정리해주는 ‘선구자’가 부족한 경우가 종종 보인다는 느낌도 들었습니다.

이왕 이렇게 된거, 내가 선구자가 되어보자!라는 마음가짐으로 웹뷰 환경에서 웹과 앱을 동시에 디버깅 할 수 있는 툴을 만들어 보기로 마음 먹었습니다.

첫 방향성 : Chrome DevTools Front_end

가장 먼저 고려한 접근 방향은 Chrome DevTools Frontend를 직접 활용하는 것이었습니다.

Chrome DevTools는 다양한 도구를 제공하기 때문에, 이를 기반으로 개발 환경을 구성하는 것이 결과적으로 가장 안정적이고 완벽한 개발 경험을 제공할 것이라고 판단했습니다.

Chromiun DevTools 문서 학습

참고한 문서는 아래와 같습니다.

해당 문서를 기반으로 DevTools의 구조와 동작 방식을 우선적으로 파악했습니다.

image.png

주고 받는 프로토콜은 개발자 도구ExpreimentsProtocol Monitor 선택 →개발자 도구 재 시작 순서로 들어가면 확인이 가능합니다.

image.png

CDP에서 주고 받는 메시지들은 모두 특정 형식이 존재합니다. 이에 대한 명세는 Chrome DevTools Protocol 문서에서 전부 확인이 가능합니다.

그리고 우리가 직접 볼 수 있고 만져볼 수 있는 개발자 도구의 UI 및 기능들은 chrome devtools frontend에 있습니다.

빌드 절차는 다음과 같습니다.

mkdir devtools cd devtools fetch devtools-frontend cd devtools-frontend gclient sync npm run build

추가로, depot_tools 설치 및 GN/Ninja 기반 빌드 과정을 아래와 같이 진행했습니다.

cd ~/Documents/Coding/devtools/devtools/devtools-frontend # 1. depot_tools 설치 (아직 없다면) cd ~ git clone https://chromium.googlesource.com/chromium/tools/depot_tools.git export PATH="$HOME/depot_tools:$PATH" # .zshrc 또는 .bashrc에 추가 echo 'export PATH="$HOME/depot_tools:$PATH"' >> ~/.zshrc # 2. 소스 새로고침 cd ~/Documents/Coding/devtools/devtools/devtools-frontend gclient sync # 3. 빌드 설정 gn gen out/Default # 4. 빌드 autoninja -C out/Default

DevTools는 Google에서 사용하는 Ninja 빌드 시스템을 활용해 빌드되며, 결과물은 /out 디렉터리에 생성됩니다.

해당 디렉터리를 확인해보면, 브라우저 내부에서 사용하는 다양한 DevTools HTML 페이지들이 존재하는 것을 알 수 있습니다.

이를 로컬에서 직접 서빙해보면, 파일명에 따라 서로 다른 목적의 DevTools 인터페이스가 제공되고 있음을 확인할 수 있습니다.

image.png

이를 서빙하는 환경을 만들고, 실제로 확인해보면 html에 있는 이름에 맞추어, 다양한 환경에서 사용할 수 있는 개발 툴이 존재하는 것을 확인할 수 있습니다.

서빙하는 서버를 여는 명령어

서빙하는 서버를 여는 명령어

서빙하는 페이지에 들어가면 보이는 화면

서빙하는 페이지에 들어가면 보이는 화면

inspector.html 에 들어가면 보이는 모습

inspector.html 에 들어가면 보이는 모습

그렇다면 이렇게 존재하는 Chrome devtools Front_end 와 실제 돌아가는 페이지와 어떻게 CDP 연결을 할 수 있을까요? 이는 ws 파라미터로 연결 가능한 웹소캣 주소를 주는 방식으로 가능합니다. Chrome devtools Front_end는 해당 주소와 연결을 시도하고 CDP를 통해서 통신을 진행합니다.

Chrome devtools Front_end 웹 소캣 연결해보기

이와 관련한 정보는 https://chromedevtools.github.io/devtools-protocol/ 여기 페이지에서 확인 가능합니다.

  1. 아래의 명령어를 통해서 크롬을 실행합니다.

    /Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome \ --remote-debugging-port=9222 \ --user-data-dir=/tmp/chrome-debug-profile-new \ --remote-allow-origins=http://localhost:8090,http://127.0.0.1:8090
    • 9222번 포트로 원격 디버깅 포트를 연다. (포트 번호는 사용중이지 않는 편한 번호로 한다.)
    • --remote-allow-origins을 통하여 Chrome Devtool Front_end의 주소를 작성해준다.
  2. 열었던 포트로 /json 엔드포인트로 요청(GET)을 보내 소캣 주소를 얻어냅니다

    • curl http://localhost:9222/json

    • 응답값의 예시는 아래와 같습니다.

      [ { "description": "", "devtoolsFrontendUrl": "/devtools/inspector.html?ws=localhost:9222/devtools/page/DAB7FB6187B554E10B0BD18821265734", "id": "DAB7FB6187B554E10B0BD18821265734", "title": "Yahoo", "type": "page", "url": "https://www.yahoo.com/", "webSocketDebuggerUrl": "ws://localhost:9222/devtools/page/DAB7FB6187B554E10B0BD18821265734" } ]

    image.png

  3. 서빙된 페이지에 들어가 ws=[webSocketDebuggerUrl] 파라미터를 전달합니다

    image.png

DevTool Frontend 수정해보기

웹뷰와 앱을 동시에 디버깅할 수 있는 환경을 구성하기 위해, 우선적으로 DevTools에 새로운 패널을 추가해보기로 했습니다. 이를 위해서는 DevTools Frontend의 구조를 이해하고, 직접 코드를 수정할 수 있어야 했습니다.

DevTools의 패널 관련 코드는 다음 경로에서 확인할 수 있습니다.

  • /front_end/panels

해당 영역은 별도의 프레임워크나 라이브러리를 사용하지 않고, HTML, CSS, JavaScript 형태로 구성되어 있습니다. DevTools는 브라우저 내부에서 바로 동작해야 하기 때문에 이러한 구조를 유지하고 있는 것으로 보입니다.

구조를 파악하기 위해 먼저 간단한 수정 작업을 진행해보았습니다.

예를 들어, 콘솔 패널의 색상을 변경하여 빌드 및 반영이 정상적으로 이루어지는지 확인했습니다.

image.png

처음에는 기존에 구현되어 있는 Console 또는 Network 패널의 코드를 참고하여, 이를 확장하는 방식으로 새로운 패널을 만들어보려 했습니다. 이미 검증된 패널의 구조를 활용하면 개발 속도를 높일 수 있을 것이라 판단했기 때문입니다.

하지만 실제로 코드를 분석해보니, 단순한 UI 레벨을 넘어서 다양한 내부 모듈과의 상호작용이 깊게 얽혀 있어, 구조를 이해하는 것 자체가 예상보다 쉽지 않았습니다. 패널 생성 흐름, 메시지 전달 방식, 렌더링 로직 등이 모두 DevTools 특유의 구조로 구성되어 있어 분석 과정에서 막연함을 크게 느꼈습니다.

또한, 패널을 생성하고 빌드까지는 가능했지만, 이 수정된 DevTools Frontend를 실제 환경에서 어떻게 배포하고 사용자가 접근할 수 있게 할 것인가에 대한 명확한 방법이 없었습니다. DevTools는 크롬 내부에 번들된 형태로 배포되기 때문에, 단순한 웹 애플리케이션처럼 배포할 수 있는 구조가 아니었습니다.

이러한 기술적 한계와 구조적 복잡성을 고려하여, 해당 방식은 우선 보류하고 보다 실현 가능성이 높은 다른 접근 방식을 찾는 것으로 방향을 전환했습니다.


두 번째 방향성 : Expo DevTools Client Plugin

기한 내에 실질적인 결과를 만들어낼 수 있는 방안을 고민한 끝에, Expo DevTools Client Plugin을 활용하는 방법을 선택했습니다. Expo에서 제공하는 공식적인 확장 플러그인이기 때문에, DevTools Frontend를 직접 수정·배포하는 방식보다 구현 난이도가 낮을 것이라 생각하였습니다.

Dev tools plugins

image.png

Expo DevTools Client Plugin이란?

Expo DevTools Client Plugin은 로컬 개발 환경에서 앱을 디버깅하는 데 도움을 주기 위해 Expo에서 제공하는 커스텀 도구입니다.

이 플러그인을 개발 후 프로젝트에 적용하면, expo run 명령을 실행할 때 터미널을 통해 Expo DevTools 웹 페이지가 함께 실행되며 플러그인이 동작하게 됩니다.

expo에서 로컬 개발 환경에서 앱을 디버깅하는 데 도움을 주는 커스텀 툴입니다.

Expo DevTools Client Plugin을 사용하면, 웹과 앱은 다음과 같은 구조를 통해 상호 통신하게 됩니다.

import { useDevToolsPluginClient } from 'expo/devtools'; export default App() { const client = useDevToolsPluginClient('my-devtools-plugin'); useEffect(() => { // receive messages client?.addMessageListener("ping", (data) => { alert(`Received ping from ${data.from}`); }); // send messages client?.sendMessage("ping", { from: "app" }); }, []); return (/* rest of your app */) }
개발 환경 설정

플러그인을 실제로 적용해보며 개발하기 위해서는 기존 Expo 프로젝트가 필요했습니다. 이에, 현재 진행 중인 프로젝트인 https://github.com/JNU-econovation/Sangyeol-FE레포지토리에 플러그인을 직접 연결하여 테스트하는 방식으로 개발 환경을 구성했습니다.

패키지를 독립된 환경에서 개발하면서 동시에 실제 프로젝트에서도 바로 테스트하기 위해서는 npm link 개념을 활용해야 합니다. 이를 통해 로컬에서 개발 중인 패키지를 전역 링크로 등록하고, 실제 프로젝트에서 해당 패키지를 의존성처럼 바로 사용할 수 있습니다. 이러한 설정 덕분에 플러그인을 수정할 때마다 별도의 배포 과정 없이 즉시 테스트할 수 있는 개발 흐름을 구성할 수 있었습니다.

💡 npm 배포하기 전 로컬 테스트 하기

npm link는 로컬에서 패키지를 개발할 때 실제로 npm에 배포하지 않고도 다른 프로젝트에서 테스트할 수 있게 해주는 명령어입니다.

작동 방식:

  1. 개발 중인 패키지 폴더에서 npm link를 실행하면, 그 패키지가 전역 node_modules에 심볼릭 링크로 등록됩니다.
  2. 테스트하려는 프로젝트에서 npm link <패키지명>을 실행하면, 전역에 등록된 패키지가 해당 프로젝트의 node_modules에 링크됩니다.

그러나 npm link를 통해 로컬 패키지를 연결하는 과정에서 예상치 못한 문제가 발생했습니다.

packages/app/src/app (require.context) iOS Bundling failed 1024ms node_modules/expo-router/entry.js (2686 modules) Unable to resolve "@sangyeol/unified-dev-tool" from "packages/app/src/app/_layout.tsx" 8 | import { StatusBar } from "react-native"; 9 | import Toast from "react-native-toast-message"; > 10 | import { useUnifiedDevTool } from "@sangyeol/unified-dev-tool"; | ^ 11 | 12 | export default function RootLayout() { 13 | useUnifiedDevTool(); Import stack: packages/app/src/app/_layout.tsx | import "@sangyeol/unified-dev-tool" packages/app/src/app (require.context)

image.png

npm link 방식이 제대로 동작하지 않아 원인을 조사하던 중, 해당 문제가 Yarn Berry를 사용할 때의 링크 메커니즘 차이에서 비롯된 것임을 확인했습니다. Yarn Berry 환경에서는 link: 프로토콜이 기존 npm 방식과 다르게 작동하며, 공식 문서에서도 로컬 패키지를 연결할 때 portal: 프로토콜 사용을 권장하고 있었습니다.

ref : https://yarnpkg.com/protocol/portal

💡 yarn berry에서 portal 사용하기

Portal은 파일 시스템의 특정 경로를 직접 참조하는 dependency 타입입니다. 심볼릭 링크 대신 실제 파일 경로를 사용하기 때문에 Yarn Berry의 PnP(Plug'n'Play) 모드와도 완벽하게 호환됩니다.

상대 경로/절대 경로 모두 가능합니다.

{ "dependencies": { "my-package": "portal:../my-package" } }

portal을 통하여 가져와서 사용해보려고 하는데 아래와 같은 에러가 발생했습니다.

iOS Bundling failed 1248ms node_modules/expo-router/entry.js (2493 modules) Unable to resolve "plugin-test" from "packages/app/src/app/index.tsx" 10 | ReanimatedLogLevel, 11 | } from "react-native-reanimated"; > 12 | import { useMyPlugin } from "plugin-test"; | ^ 13 | 14 | SplashScreen.preventAutoHideAsync(); 15 | Import stack: packages/app/src/app/index.tsx | import "plugin-test" packages/app/src/app (require.context)

번들을 제대로 읽지 못하고 있다는 것을 인지하였고, 이와 관련하여 리서치 해본 결과 metro에서 번들을 인식하지 못했을 때 발생할 수 있다는 것을 확인하였습니다.

이에 따라 metro.config.js 파일을 아래와 같이 수정하였습니다.

const pluginTestPath = path.resolve( __dirname, "../../../devtools/plugin-test/plugin-test", ); //... onfig.resolver = { ...resolver, //... unstable_enableSymlinks: true, extraNodeModules: { "plugin-test": pluginTestPath, }, }; config.watchFolders = [workspaceRoot, pluginTestPath];

하지만 이 경우에도 아래와 같은 문제가 발생했습니다.

에러를 보니 React.useState의 코드에서 React가 null 이라는 것을 확인할 수 있었고, React 버전이 안 맞아서 발생하는 것으로 추정되었습니다.

ERROR [TypeError: Cannot read property 'useState' of null] [Stack] Code: useMyPlugin.js 5 | const ENV = "development"; 6 | export function useMyPlugin() { > 7 | const client = useDevToolsPluginClient("plugin-test"); | ^ 8 | useEffect(() => { 9 | // Production 환경에서는 아무 작동도 하지 않음 10 | if (ENV === "production") { Call Stack useMyPlugin (../devtools/plugin-test/plugin-test/build/useMyPlugin.js:7:43) Index (packages/app/src/app/index.tsx:22:14) Code: _layout.tsx 18 | <ModalProvider> 19 | <StatusBar barStyle={"dark-content"} /> > 20 | <Stack | ^ 21 | screenOptions={{ 22 | headerShown: false, 23 | contentStyle: { Call Stack QueryErrorResetBoundary.props.children (packages/app/src/app/_layout.tsx:20:17) RootLayout (packages/app/src/app/_layout.tsx:13:5) Code: _layout.tsx 18 | <ModalProvider> 19 | <StatusBar barStyle={"dark-content"} /> > 20 | <Stack | ^ 21 | screenOptions={{ 22 | headerShown: false, 23 | contentStyle: { Call Stack QueryErrorResetBoundary.props.children (packages/app/src/app/_layout.tsx:20:17) RootLayout (packages/app/src/app/_layout.tsx:13:5)

리서치를 통하여 번들러가 정확히 모듈의 위치를 찾을 수 있도록 설정해주었습니다.

결론적으로 패키지 연결을 완료할 수 있었습니다.

//metro.donfig.js const pluginTestPath = path.resolve( __dirname, "../../../devtools/plugin-test/plugin-test", ); config.resolver = { ...resolver, unstable_enableSymlinks: true, extraNodeModules: { "plugin-test": pluginTestPath, // React와 React Native를 워크스페이스의 단일 인스턴스로 강제 react: path.resolve(workspaceRoot, "node_modules/react"), "react-native": path.resolve(workspaceRoot, "node_modules/react-native"), }, // React 중복 방지를 위한 코드 resolveRequest: (context, moduleName, platform) => { // React 모듈을 항상 워크스페이스의 인스턴스로 리다이렉트 if (moduleName === "react" || moduleName === "react-native") { return { filePath: path.resolve( workspaceRoot, "node_modules", moduleName, "index.js", ), type: "sourceFile", }; } // 기본 resolution return context.resolveRequest(context, moduleName, platform); }, config.watchFolders = [pluginTestPath];

ref. https://github.com/JNU-econovation/Sangyeol-FE/blob/feat/41-dev-tool-plugin/packages/app/metro.config.js

방향성 구상

가장 핵심으로는 console과 network 이 두 기능으로 생각이 되었습니다. 이에 따라서 구현 최소 목표를 console과 network를 지원하는 것으로 잡게 되었습니다.

결국 동일한 plugin-id로 메시지를 송수신하고, 이를 받아서 웹에 적절한 UI를 표시하는 방식으로 동작하기 때문에, 우선적으로 메시지 형식을 정의할 필요가 있다고 생각했습니다.

메시지 형식은 CDP(Chrome DevTools Protocol)의 형식을 어느 정도 참고했습니다. CDP는 검증된 개발자 도구 프로토콜이기 때문에, 굳이 시행착오를 겪으면서 새로운 메시지 형식을 만들기보다는 기존의 안정적인 형식을 활용하는 것이 효율적이라고 판단했습니다.

image.png

method: string, params: any

[method] : console
params : interface ConsoleMessage { level: ConsoleLevel; args: any[]; timestamp: number; }

그렇다면 어떻게 메시지를 전송하도록 구현할 수 있을까요? 이는 우아한테크코스에서 제공하는 **javascript-mission-utils** 에서 영감을 얻어, 콘솔 함수와 fetch 함수를 오버라이딩하는 방식으로 구현할 수 있을 것이라고 생각했습니다.

핵심 개념을 코드로 표현하면 다음과 같습니다.

const originalLog = console.log; console.log = function (...args: any[]) { originalLog.apply(console, args); const message: ConsoleMessage = { level: "log", args, timestamp: Date.now(), }; client.sendMessage("console", message); };

우선 메시지 출처를 구분하기 위해 method에 prefix를 추가하는 방식을 고려했습니다. web:consoleapp:console처럼 출처를 명시하면 웹과 앱에서 발생한 로그를 구분할 수 있을 것으로 생각했습니다.

그런데 문제는 메시지를 최종적으로 client 객체를 통해 전송해야 한다는 점이었습니다. 이 코드는 앱 환경에서만 동작하기 때문에, 웹은 자신의 동작을 즉시 앱에 알려줄 방법이 필요했습니다. 브리지를 통해 동작을 전달하고, onMessage로 메시지를 받아 client로 전달하는 방식으로 구현하면 자연스럽게 처리할 수 있을 것 같았습니다.

하지만 여기서 큰 문제가 발생했습니다. 브리지 통신을 사용하면 다른 브리지 통신 라이브러리와 충돌이 일어날 수 있다는 것을 깨달았습니다. webview-bridge 라이브러리 혹은 제가 이전에 만든 라이브러리 등과 함께 사용할 경우 문제가 생길 수 있었습니다.

정말 막막했습니다. 설상가상으로 axios 라이브러리를 사용하는 프로젝트에서는 XMLHttpRequest를 오버라이딩해야 하는데, axios가 이미 이를 변형하여 사용하고 있어 충돌이 발생했습니다. 직접 오버라이딩하니 로그가 제대로 출력되지 않았고, axios 인터셉터를 추가하는 방법도 고려했지만, 제 라이브러리가 axios에 의존성을 가지는 것이 적절한지 의문이 들었습니다.

결론적으로 다시한번 방향성을 고려할 필요가 있다는 결론에 이르게 되었습니다.


세 번째 방안 : 멀티 CDP

github. https://github.com/geongyu09/multi-devtool

동시에 하나의 개발자 도구에 모든 것을 띄우기 어렵다면, 개발자 도구를 두 개 띄우면 되지 않을까 하는 생각이 들었습니다. 이전 경험을 통해 개발자 도구 또한 URI를 가지는 하나의 페이지라는 것을 알게 되었기 때문에, iframe을 활용하여 서로 다른 주소를 가진 두 개의 개발자 도구를 한 화면에 함께 표시할 수 있을 것으로 판단했습니다.

웹 화면은 Node.js를 통해 페이지를 서빙하는 방식으로 구현하고자 했습니다.

앱의 개발자 도구 띄우기

어떻게 하면 앱의 개발자도구를 띄울 수 있을까요?

먼저 Expo 환경에서 'j' 키를 누르면 디버깅 도구가 나타나는 것을 확인했습니다. 이것 또한 Chrome 개발자 도구였고, WebSocket 연결과 CDP를 사용하는 같은 원리가 아닐까 추측했습니다. 실제로 CDP를 사용한다는 것을 확인할 수 있었습니다.

image.png

개발자 도구의 개발자 도구를 열어 확인한 결과, 개발자 도구가 열린 주소를 알 수 있었습니다.

image.png

해당 주소를 다른 브라우저에 그대로 입력해보니 'j' 키를 눌렀을 때 나오는 개발자 도구와 완벽히 동일한 화면을 볼 수 있었습니다.

image.png

그렇다면 이 주소를 코드단에서 어떻게 알 수 있을까요?

Expo는 npm을 사용해 빌드하므로 Node.js를 사용할 것이라고 생각했습니다. /json 엔드포인트로 요청을 보내보니, 운이 좋게도 devtoolsFrontendUrl 주소를 확인할 수 있었고, 해당 주소로 접속하니 원하는 페이지가 나타났습니다.

image.png

이를 활용하여 Express로 Node.js 서버를 만들었습니다. Metro Bundler의 /json 엔드포인트에서 devtoolsFrontendUrl 정보를 가져와 해당 주소로 리다이렉트하는 방식으로 구현했고, 성공적으로 작동했습니다.

const express = require('express'); const app = express(); const PORT = 13000; app.use((req, res, next) => { res.header('Access-Control-Allow-Origin', '*'); res.header('Access-Control-Allow-Methods', 'GET, POST, OPTIONS'); res.header('Access-Control-Allow-Headers', '*'); next(); }); app.get('/', async (req, res) => { try { // json 요청 후 얻을 수 있는 const metroResponse = await fetch('http://localhost:8081/json'); const metroTargets = await metroResponse.json(); const rnTarget = metroTargets[0]; const devtoolsFrontendUrl = rnTarget?.devtoolsFrontendUrl; if(!devtoolsFrontendUrl) { throw new Error('[ERROR] metro bundler에서 devtoolsFrontendUrl 정보를 가져올 수 없습니다.'); } const metroUrl = `http://127.0.0.1:8081${devtoolsFrontendUrl}`; return res.redirect(metroUrl); } catch (error) { res.send(`Error: ${error.message}`); } }); app.listen(PORT, () => { console.log(`devtool : http://localhost:${PORT}`); });

image.png

웹의 개발자 도구 띄우기

Android의 경우 WebView를 디버깅하기 위해서는 chrome://inspect/#devices에 접속하여 포트포워딩된 remote target을 감지해 디버깅 도구를 띄울 수 있습니다.

image.png

image.png

공식 문서를 참고하며 포트포워딩을 하고 /json 엔드포인트를 통해 개발자 도구의 위치를 찾을 수 있다고 판단했습니다. 시뮬레이터를 실행한 뒤 ADB를 통해 현재 WebView 소켓을 확인하고, 포트포워딩을 추가한 다음 /json으로 GET 요청을 보내 개발자 도구 엔드포인트를 확인하는 방식으로 진행했습니다.

image.png

제가 시행했던 방법을 구체적으로 작성하면 아래와 같습니다.

  1. 시물레이터를 실행합니다

  2. 현재 WebView 소켓을 확인합니다.

    # 현재 WebView 소켓 확인 adb shell "cat /proc/net/unix | grep devtools_remote" # 아래와 같은 결과가 나온다 0000000000000000: 00000003 00000000 00000000 0001 02 0 @stetho_com.google.android.apps.messaging_devtools_remote 0000000000000000: 00000002 00000000 00000000 0001 02 0 @stetho_com.google.android.apps.messaging_devtools_remote 0000000000000000: 00000002 00000000 00000000 0001 02 0 @stetho_com.google.android.apps.messaging_devtools_remote 0000000000000000: 00000002 00000000 00000000 0001 02 0 @stetho_com.google.android.apps.messaging_devtools_remote 0000000000000000: 00000003 00000000 00000000 0001 02 0 @stetho_com.google.android.apps.messaging_devtools_remote 0000000000000000: 00000002 00000000 00000000 0001 02 0 @stetho_com.google.android.apps.messaging_devtools_remote 0000000000000000: 00000002 00000000 00000000 0001 02 0 @stetho_com.google.android.apps.messaging_devtools_remote 0000000000000000: 00000002 00000000 00010000 0001 01 37074 @webview_devtools_remote_4267 0000000000000000: 00000002 00000000 00010000 0001 01 22702 @stetho_com.google.android.apps.messaging_devtools_remote 0000000000000000: 00000002 00000000 00010000 0001 01 13124 @stetho_com.google.android.apps.messaging:rcs_devtools_remote
  3. 포워딩을 추가합니다.

    # 3. 새로운 PID로 포워딩 (위에서 찾은 포워딩 id) adb forward tcp:9223 localabstract:webview_devtools_remote_[새로운_PID]
  4. /json 으로 GET 요청을 통해 개발자 도구 엔드포인트를 확인합니다.

하지만 결론적으로 올바르게 연결되지 않았습니다.

image.png

image.png

마지막으로 front_end를 직접 서빙하도록 만들어 ws 파라미터를 직접 전달해보았지만, 여전히 소켓 연결이 되지 않았습니다.

image.png

image.png

Origin 문제일 가능성이 있어 보였습니다. 이전에 첫 번째 방법으로 진행할 때도 동일한 문제가 있었기 때문입니다. WebView 컴포넌트의 props를 확인해서 필요한 설정을 추가할 수 있는지 확인하거나, 프록시를 두는 방법도 고려해볼 필요가 있을 것 같았습니다.

브라우저에서 직접 CDP 통신을 수행할 수 없다는 점이 문제라면, 일반적인 WebSocket 연결을 먼저 수립한 뒤 CDP 형식의 메시지를 전달하는 방식으로 해결할 수 있지 않을까 생각했습니다.

또한 최종적으로는 Chrome DevTools Frontend에 표시되어야 하므로, 중간 단계에서 실제 CDP 포맷으로 한 차례 변환해 주는 구조를 만들면 되겠다고 판단했습니다.

이때 구상했던 전체 구조를 다이어그램으로 표현하면 아래와 같습니다.

image.png

핵심적인 요소는 다음과 같습니다.

Chrome DevTools Frontend는 CDP 통신을 수행할 대상을 WebSocket 쿼리 파라미터로 전달받습니다. 그리고 이 프로토콜은 결국 소켓 통신 위에서 정해진 형식으로 메시지를 주고받는 구조이기 때문에, 동일한 형식을 모방한다면 실제 브라우저 환경이 아니더라도 브라우저처럼 DevTools에 결과를 출력할 수 있을 것이라고 판단했습니다.

각 요소를 연결해보자

우선 일반적인 웹 페이지를 프록시 서버와 연결하기 위한 로직을 먼저 작성했습니다. 해당 로직은 일반 HTML 문서에 스크립트 형태로 삽입해 바로 사용할 수 있도록 구성했습니다.

// devtools-client.js (function () { "use strict"; const SERVER_URL = "ws://localhost:3002"; const ws = new WebSocket(SERVER_URL); ws.onopen = () => { console.log("[RemoteDevTools] 서버에 연결됨"); // 페이지 등록 ws.send( JSON.stringify({ type: "register", url: window.location.href, title: document.title, userAgent: navigator.userAgent, }), ); }; ws.onmessage = async (event) => { })();
  • 즉시 실행 함수(IIFE) 형태로 작성하여 스크립트가 로드되는 즉시 실행되도록 했습니다.
  • 실행과 동시에 프록시 서버와 소켓 연결을 시도하도록 구현했습니다.

사용시에는 아래와 같이 스크립트를 주입하여 사용하면 됩니다.

<DOCTYPE html> <html> ... <script src="http://localhost:3002/devtools-client.js"></script> </html>

프록시 서버에서는 아래와 같이 작성하였습니다.

const WebSocket = require("ws"); const registeredPages = new Map(); const wss = new WebSocket.Server({ noServer: true }); const server = app.listen(3002, () => { console.log("CDP 프록시 서버 실행 중: http://localhost:3002"); }); server.on("upgrade", (request, socket, head) => { const pathname = new URL(request.url, "http://localhost").pathname; // 웹페이지 등록 연결 wss.handleUpgrade(request, socket, head, (ws) => { handlePageRegistration(ws); }); }); // 웹페이지 등록 처리 function handlePageRegistration(ws) { let pageId = null; ws.on("message", (data) => { const msg = JSON.parse(data); if (msg.type === "register") { pageId = Date.now().toString(); registeredPages.set(pageId, { id: pageId, url: msg.url, title: msg.title, userAgent: msg.userAgent, connection: ws, registeredAt: new Date(), }); console.log(`✓ 페이지 등록: ${msg.title} (${pageId})`); ws.send( JSON.stringify({ type: "registered", id: pageId, }) ); } }); }
  • 메시지를 수신하면 내용을 파싱하도록 구현했습니다. 이때 연결을 위한 초기 메시지를 수신하면 registeredPages Map에 해당 정보를 저장하도록 했습니다.
  • 이후 정상적으로 등록이 완료되었음을 응답 메시지로 다시 전달하도록 구성했습니다.

또한 실제로 Chrome DevTools Frontend를 서빙하는 서버를 별도로 구성하여, 해당 정보와 연결할 수 있도록 구현했습니다.

// 등록된 페이지 목록 조회 API app.get("/api/pages", (req, res) => { const pages = Array.from(registeredPages.values()).map((page) => ({ id: page.id, url: page.url, title: page.title, userAgent: page.userAgent, registeredAt: page.registeredAt, })); res.json(pages); });

이후 Chrome DevTools Frontend를 서빙하는 서버에서는 HTML 페이지를 제공하면서, fetch 요청을 통해 등록된 페이지 목록을 조회할 수 있도록 구현했습니다. 또한 조회된 목록을 기반으로 사용자가 연결할 페이지를 직접 선택할 수 있도록 구성했습니다.

const app = express(); const PORT = 3001; const devtoolsPath = path.join(__dirname, "out/Default/gen/front_end"); app.use(express.static(devtoolsPath)); // 루트: 등록된 페이지 목록 보여주기 app.get("/", async (_req, res) => { try { const response = await fetch("http://localhost:3002/api/pages"); const pages = await response.json(); res.send(` <!DOCTYPE html> <html lang="ko"> <head> </head> <body> ${pages.length === 0 ? ` <div class="no-pages"> <p>등록된 페이지가 없습니다.</p> <p>웹페이지에 다음 스크립트를 추가하세요:</p> <code>&lt;script src="http://localhost:3002/devtools-client.js"&gt;&lt;/script&gt;</code> </div> ` : ` <ul class="page-list"> ${pages.map(page => ` <li class="page-item"> <div class="page-title">${page.title || '(제목 없음)'}</div> <div class="page-url">${page.url}</div> <div class="page-meta"> ID: ${page.id} | 등록 시간: ${new Date(page.registeredAt).toLocaleString()} </div> <a href="/inspector.html?ws=localhost:3002/devtools/page/${page.id}" class="inspect-btn" target="_blank"> 🔍 Inspect </a> </li> `).join('')} </ul> `} </body> </html> `); } });

image.png

지금까지의 각 요소들의 연결을 도식화 하면 아래와 같습니다.

image.png

위의 사진에서 서빙 서버의 inspect 버튼을 클릭하면 해당 등록 페이지에 대한 소캣 주소와 함께 Chrome DevTools Front_end를 열도록 하였습니다.

<a href="/inspector.html?ws=localhost:3002/devtools/page/${page.id}" class="inspect-btn" target="_blank" > 🔍 Inspect </a>;
동작하기_콘솔

콘솔 기능이 정상적으로 동작하도록 하기 위해서는 먼저 CDP의 콘솔 메시지 구조를 이해해야 합니다.

CDP 메시지 형식은 아래 문서에서 확인할 수 있습니다.

https://chromedevtools.github.io/devtools-protocol/tot/Console/#method-clearMessages

우선 Chrome DevTools에서 콘솔을 사용할 수 있는 상태임을 알리기 위해 Console.enable 메시지를 전송해야 합니다. 이 메시지는 DevTools와 연결이 수립되자마자 전송되는 여러 초기 메시지 중 하나입니다.

이후에는 실제 콘솔 이벤트가 발생할 때마다, 어떤 메시지가 출력되었는지를 정해진 CDP 메시지 형식에 맞추어 전달하도록 구현하면 됩니다.

image.png

구현을 진행하면서, 프록시 서버에서 일반 메시지와 CDP 메시지를 구분해 변환하는 과정이 불필요하다고 판단했습니다. 따라서 모든 메시지를 CDP 형식으로 통일하는 방식이 더 단순하고 명확하다고 생각했습니다. 이에 따라 스크립트 내부에서부터 CDP 메시지를 직접 전송하도록 구조를 변경해 구현했습니다.

또한 콘솔이 호출될 때마다 CDP 메시지를 전송할 수 있도록 별도의 로직을 작성했습니다. 이 과정은 앞서 사용했던 오버라이딩 방식을 활용해 구현했습니다. 기존 콘솔의 기본 동작은 그대로 유지하면서, 콘솔이 호출될 때마다 동시에 CDP 메시지를 프록시 서버로 전달하도록 구성했습니다.

// 원본 콘솔 메서드 저장 (오버라이드 전에 저장) const originalConsole = { log: console.log.bind(console), error: console.error.bind(console), warn: console.warn.bind(console), info: console.info.bind(console), debug: console.debug.bind(console), }; // 콘솔 캡처 let consoleEnabled = false; function setupConsoleCapture() { if (consoleEnabled) { originalConsole.log("[RemoteDevTools] 콘솔 캡처 이미 활성화됨"); return; } consoleEnabled = true; const methods = ["log", "error", "warn", "info", "debug"]; originalConsole.log("[RemoteDevTools] 콘솔 캡처 설정 시작...", methods); methods.forEach((method) => { const original = originalConsole[method]; console[method] = function (...args) { // CDP 로그 메시지는 무한 루프 방지를 위해 필터링 const firstArg = args[0]; if (typeof firstArg === 'string' && firstArg.startsWith('[RemoteDevTools]')) { original.apply(console, args); return; } const cdpMessage = { method: "Runtime.consoleAPICalled", params: { type: method, args: args.map((arg) => ({ type: typeof arg, value: arg, description: String(arg), })), timestamp: Date.now() / 1000, stackTrace: { callFrames: [] }, }, }; // 원래 콘솔 함수 호출 (브라우저 콘솔에 출력) original.apply(console, args); // CDP로 전송 originalConsole.log("[RemoteDevTools] 콘솔 이벤트 전송:", method, args); sendMessage(cdpMessage); }; }); originalConsole.log("[RemoteDevTools] 콘솔 캡처 설정 완료!"); }

이때 해당 메시지를 전달 받은 프록시 서버는 단순히 전달받은 메시지를 그대로 다음 호스트로 전달만 해주는 역할을 하도록 하였습니다.

// DevTools와 웹페이지 간 메시지 중계 function handleDevToolsConnection(devtoolsWs, pageId) { const page = registeredPages.get(pageId); if (!page) { console.log(`페이지를 찾을 수 없음: ${pageId}`); devtoolsWs.close(); return; } console.log(`✓ DevTools가 페이지에 연결됨: ${page.title}`); // DevTools → 웹페이지 devtoolsWs.on("message", (data) => { // Buffer를 문자열로 변환 const message = data.toString('utf-8'); console.log(`[DevTools → Page] ${message.substring(0, 200)}`); if (page.connection.readyState === WebSocket.OPEN) { page.connection.send(message); } }); // 웹페이지 → DevTools const pageMessageHandler = (data) => { // Buffer를 문자열로 변환 const message = data.toString('utf-8'); console.log(`[Page → DevTools] ${message.substring(0, 200)}`); if (devtoolsWs.readyState === WebSocket.OPEN) { devtoolsWs.send(message); } }; page.connection.on("message", pageMessageHandler); devtoolsWs.on("close", () => { console.log(`✗ DevTools 연결 종료: ${pageId}`); page.connection.off("message", pageMessageHandler); }); }

테스트를 하기 위해 만든 HTML 파일에 스크립트를 넣고, 직접 테스트 해본 결과 아래와 같이 콘솔이 잘 찍히는 것을 확인했습니다.

Jan-20-2026 22-00-34.gif

동작하기_네트워크

CDP의 네트워크 메시지는 아래 문서에서 확인할 수 있습니다.

https://chromedevtools.github.io/devtools-protocol/tot/Network/

네트워크 도메인에는 매우 다양한 메서드와 타입이 존재하지만, 우선 최소 기능 구현을 목표로 아래 이벤트부터 지원하도록 했습니다.

  • Network.requestWillBeSent : 요청 직전에 전송되는 메시지로, 응답 이전의 UI 상태를 표시하는 데 사용됩니다.
  • Network.responseReceived : 응답을 수신한 직후 전송되는 메시지로, 응답 도착 여부를 UI에 반영합니다.
  • Network.loadingFinished : 응답이 정상적으로 완료되었음을 알리는 메시지로, 타이밍 그래프(간트 차트) 계산에 필요한 정보를 포함합니다.
  • Network.loadingFailed : 요청 처리 중 오류가 발생했음을 알리는 메시지로, DevTools에서는 해당 요청이 빨간색으로 표시됩니다.

또한 fetch와 XMLHttpRequest를 사용하는 경우에도 동일하게 CDP 메시지를 전송할 수 있어야 했습니다. 이를 위해 기존 동작을 해치지 않는 범위에서 함수를 오버라이딩하는 방식을 다시 적용했습니다.

fetch 함수의 경우 구현 흐름은 다음과 같습니다.

const originalFetch = window.fetch; window.fetch = function (...args) { const requestId = 'fetch-' + Math.random().toString(36).substring(2, 11); const url = typeof args[0] === "string" ? args[0] : args[0].url; const method = args[1]?.method || "GET"; const headers = args[1]?.headers || {}; // 요청 시작 이벤트 전송 sendMessage({ method: "Network.requestWillBeSent", params: { requestId, request: { url, method, headers }, timestamp: performance.now() / 1000, wallTime: Date.now() / 1000, type: "Fetch", }, }); return originalFetch.apply(this, args) .then(async (response) => { // 응답 본문 저장 (스트림이므로 clone 필요) const clonedResponse = response.clone(); const contentType = response.headers.get('content-type') || ''; let body = await extractResponseBody(clonedResponse, contentType); // 응답 본문을 맵에 저장 responseBodyMap.set(requestId, { body, base64Encoded: isBinaryContent(contentType) }); // 응답 수신 이벤트 전송 sendMessage({ method: "Network.responseReceived", params: { requestId, response: buildResponseObject(response) } }); // 로딩 완료 이벤트 전송 sendMessage({ method: "Network.loadingFinished", params: { requestId, timestamp: performance.now() / 1000 } }); return response; }) .catch((error) => { // 네트워크 오류 이벤트 전송 sendMessage({ method: "Network.loadingFailed", params: { requestId, errorText: error.message } }); throw error; }); }; // 헬퍼 함수들 async function extractResponseBody(response, contentType) { const contentLength = parseInt(response.headers.get('content-length') || '0'); if (contentType.includes('application/json') || contentType.includes('text/')) { return await response.text(); } // 바이너리 데이터는 base64로 인코딩 const blob = await response.blob(); return new Promise((resolve) => { const reader = new FileReader(); reader.onloadend = () => resolve(reader.result); reader.readAsDataURL(blob); }); } function isBinaryContent(contentType) { return !contentType.includes('text/') && !contentType.includes('application/json'); }
  • 요청 시작 시 URL, method, headers 등의 요청 정보를 수집하여 Network.requestWillBeSent 이벤트를 전송합니다.
  • 이후 원본 fetch를 그대로 실행합니다.
  • 응답을 수신하면 Network.responseReceived와 Network.loadingFinished 이벤트를 순차적으로 전송합니다.
  • 요청 처리 중 오류가 발생하면 Network.loadingFailed 이벤트를 전송합니다.

Jan-20-2026 22-42-01.gif

위와 같이 실제 DevTools의 Network 탭에 메시지가 표시되는 것을 확인할 수 있습니다.

여기서 상세 정보를 확인하기 위해서는 Network.getResponseBody 요청에 대한 응답 처리가 추가로 필요합니다. 이 메시지는 DevTools에서 네트워크 요청 항목을 클릭했을 때 상세 응답 데이터를 가져오기 위해 전송됩니다.

이를 위해 스크립트 내부에 Map을 생성하여 모든 응답 데이터를 requestId 기준으로 저장하도록 했습니다. 이후 Network.getResponseBody 메시지를 수신하면, 해당 requestId에 해당하는 응답 데이터를 Map에서 조회하여 CDP 메시지 형식으로 다시 전달하도록 구현했습니다.

// CDP 메시지 처리 function handleCDPMessage(msg) { try { switch (msg.method) { case "Network.enable": //... break; case "Network.getResponseBody": const responseData = responseBodyMap.get(msg.params.requestId); // 맵에서 응답값을 가져와 전달해준다. if (responseData) { response.result = { body: responseData.body, base64Encoded: responseData.base64Encoded, }; } else { response.error = { code: -32000, message: `No resource with given identifier found`, }; console.warn( "[RemoteDevTools] 응답 본문 없음:", msg.params.requestId ); } break; default: console.log("[RemoteDevTools] 미지원 메서드:", msg.method); response.result = {}; } } catch (error) { console.error("[RemoteDevTools] CDP 처리 오류:", error); response.error = { code: -32000, message: error.message, }; } sendMessage(response); }

XMLHttpRequest의 경우에는 아래와 같이 오버라이딩을 하였습니다.

const originalXHR = window.XMLHttpRequest; window.XMLHttpRequest = function() { const xhr = new originalXHR(); const requestId = 'xhr-' + Math.random().toString(36).substring(2, 11); let requestMethod, requestUrl; const requestHeaders = {}; // open 메서드 오버라이드 const originalOpen = xhr.open; xhr.open = function(method, url) { requestMethod = method; requestUrl = url; return originalOpen.apply(this, arguments); }; // setRequestHeader 오버라이드 const originalSetRequestHeader = xhr.setRequestHeader; xhr.setRequestHeader = function(header, value) { requestHeaders[header] = value; return originalSetRequestHeader.apply(this, arguments); }; // send 메서드 오버라이드 const originalSend = xhr.send; xhr.send = function(data) { // Network.requestWillBeSent 전송 sendMessage({ method: "Network.requestWillBeSent", params: { requestId, request: { url: requestUrl, method: requestMethod, headers: requestHeaders }, timestamp: performance.now() / 1000, wallTime: Date.now() / 1000, type: "XHR" } }); // readystatechange 리스너 xhr.addEventListener('readystatechange', function() { if (xhr.readyState === 4) { // 응답 본문 저장 responseBodyMap.set(requestId, { body: xhr.responseText, base64Encoded: false }); // Network.responseReceived 전송 sendMessage({ method: "Network.responseReceived", params: { requestId, response: buildResponseObject(xhr) } }); // Network.loadingFinished 전송 sendMessage({ method: "Network.loadingFinished", params: { requestId, timestamp: performance.now() / 1000 } }); } }); return originalSend.apply(this, arguments); }; return xhr; };

최종적으로 각 디버깅 툴을 iframe을 통하여 한 화면에 동시에 띄우도록 구현하였습니다.

Feb-10-2026 14-09-26.gif

여기까지 구현을 진행하면서, 웹뷰 앱 개발 환경에서 앱과 웹뷰의 디버깅 정보를 하나의 DevTools 화면에서 함께 확인할 수 있는 애플리케이션을 제작하게 되었습니다.

전체 구현 코드는 소스 코드 링크에서 확인하실 수 있습니다.

다만 현재 기준으로는 웹뷰 환경에서 콘솔(Console)과 네트워크(Network) 기능만 우선적으로 지원하는 상태입니다. 앞으로는 Elements, Storage, Performance 등 DevTools의 다양한 부가 기능을 점진적으로 적용할 예정입니다.

또한 현재는 소스 코드를 직접 내려받은 뒤 터미널에서 올바른 명령어를 순서대로 입력해야만 디버깅 환경을 구성할 수 있어 사용성이 다소 제한적입니다. 향후에는 이를 하나의 데스크톱 애플리케이션 형태로 통합하여, 보다 간편하게 실행하고 활용할 수 있는 환경을 제공하고자 합니다.

마무리

이번 프로젝트를 돌아보면, 무엇보다도 제가 정말 만들고 싶었던 것을 직접 만들어봤다는 것이 가장 마음에 남는 것 같습니다. 명확한 레퍼런스가 거의 없었기에 모든 과정이 쉽지 않았고, 예상치 못한 문제와 마주하며 방향성을 여러 번 수정해야 했습니다.

그럼에도 불구하고 끝까지 포기하지 않고 현실적인 방법을 찾아가며 결과물을 만들어냈다는 점에서 스스로에게 의미있는 도전의 시간이었다고 생각합니다! 익숙한 웹 프론트엔드 영역을 넘어, CDP와 개발자 도구라는 완전히 새로운 분야를 깊이 있게 탐색해보면서 기술적으로도 많은 성장을 할 수 있던 값진 경험이었습니다!