Issue 01중국 AI
AC POST
중국 AI 목록
掘金2026년 9월 19일 22:24중국어 → 한국어

Shopify의 React Native 중단과 Agent용 헤드리스 아키텍처 재현

Shopify가 6년간의 React Native 사용을 철회한 뒤, 로직과 UI를 분리해 CLI로 Agent에 빠른 반복을 제공하는 헤드리스 아키텍처를 Node로 재현했다.

중국어 원문을 AI로 번역했습니다. 고유명사와 수치는 원문 표기를 우선하며, 중요한 판단에는 아래 출처 원문을 함께 확인하세요.

지난 목요일, Shopify 모바일 부문 책임자 Mustafa Ali가 공식 엔지니어링 블로그에 글을 올렸는데, 제목부터 가림이 없었다. 《Native is now the future of mobile at Shopify》. 내가 이번 주에 확인했을 때, Hacker News의 토론 스레드는 이미 1272점까지 쌓여 있었다.

먼저 사실을 분명히 하자. Shopify는 "2주 써보니 별로라서" 돌아선 길거리 사용자가 아니다. 2020년 그들은 요란하게 React Native에 all-in 했고, Shop, Shopify, Point of Sale, Inbox 등 여러 플래그십 앱을 전부 여기에 올인했으며, 생태계에 React Native Skia, FlashList, Restyle처럼 거의 카테고리 기본 선택이 된 라이브러리들까지 기여했다. 2025년 1월, Mustafa Ali는 RN의 미래가 여전히 밝다며 Shopify가 계속 투자하겠다고专门 쓴 글까지 올렸다.

1년여 뒤, 방향을 틀었다. 그것도 점진적 마이그레이션이 아니다. Shop 앱은 처음부터 다시 rewrite 했고, AI의 도움을 받아 엔지니어 6명이 12주 만에 PoC부터 앱스토어 출시까지 전 과정을 끝냈다. 그들의 가장 큰 메인 앱, 화면이 300개가 넘고 데스크톱 위젯과 Apple Watch 앱, Siri 단축어까지 딸린 그 앱도 연내에 rewrite를 마쳤다.

셈이 어떻게 틀어졌나

공식 블로그에는 드문 디테일이 하나 있다. 그들은 RN을 한바탕 욕하고 떠난 게 아니다. 원문은 "React Native apps can be fast. Ours are." 였다. 우리 집 RN 앱은 꽤 빠르게 돌아간다. 방향을 튼 이유는 성능이 아니라, 셈의 변수가 달라졌기 때문이다.

2020년 RN을 선택한 이유는 세 가지였다. 같은 기능을 두 번 쓰지 않기, 모바일 배경이 없는 엔지니어도 적응하기, 두 플랫폼의 기능 정렬을 쫓아다니지 않기. 이 논리의 핵심 가정은 한 문장이었다. 기능을 두 번 쓰는 것은 두 배의 일과 같다.

이 가정은 6년을 버텼다. 그러다 LLM이 그것을 부숴버렸다. Shopify는 2021년부터 이미 내부에서 LLM으로 코드를 작성했는데, ChatGPT 공개보다 1년 앞선 것이다. 2025년 말에 이르러 그들은 agent가 iOS 버전 코드를 참고 삼아 같은 기능을 Android에서 구현할 수 있고, 반대 방향도 가능하다는 걸 발견했다. Shop 앱을 rewrite 하기 전에, 그들은 먼저 LLM으로 몇 개 대형 앱의 핵심 모듈을 Swift와 Kotlin으로 다시 만들어 검증했고, 효과는 "놀라울 정도로 좋았다"——이게 원문 표현이다.

그래서 진짜 장부는 이렇다. 크로스 플랫폼 프레임워크가 파는 것은 "두 번째 구현을 생략하는" 인건비이고, 그 비용의 상당 부분을 이제 agent가 대신 지불한다. 인력을 아끼는 기둥이 무너졌지만, 네이티브의 장점은 하나도 줄지 않았다. 플랫폼 능력에 밀착하고, 퍼스트파티 도구 체인을 쓰고, 프레임워크와 의존성의 격리층이 몇 겹 줄어든다. 이 셈은 일류 회사 입장에서 답이 어렵지 않다.

촉매도 하나 있었다. RN의 New Architecture 마이그레이션이다. 공식 블로그는 이번 업그레이드가 native module 통합, 렌더링 계층, 공유 코드와 플랫폼 코드의 경계를 다시 들여다봐야 한다는 뜻이라고 인정한다. 어차피 뼈를 깎는 대공사라면, 차라리 먼저 멈추고 모든 선택지를 다시 펼쳐놓자는 것이다.

진짜로 내 손을 움직이게 한 대목

솔직히 "대기업이 RN을 버린다"는 일은 해마다 있다. Airbnb는 2018년에 이미 한 번 했다. 이번에 내가 손을 대고 싶어진 건, 블로그 후반부의 엔지니어링 실천 부분——그들이 어떻게 agent를 정말 효율적으로 일하게 만드는가였다.

먼저 Helix, 그들이 직접 만든 rewrite 시스템. 사고방식은 매우 절제되어 있다. one-shot은 안 한다. 당신이 화면 하나를 가리키면, Helix가 대응하는 RN 코드를 읽고, 그리고 순서대로 잘린 checkpoint 한 줄기를 제안하는데, 각각은 몇 분이면 리뷰를 끝낼 만큼 작다. 각 checkpoint는 반드시 테스트로 자기 동작을 증명하고, 실행 중인 앱과 시각적으로 비교하고, 두 차례의 대립적 코드 리뷰를 통과하고, 마지막으로 사람 한 명이 고개를 끄덕여야 비로소 커밋되고, 다음으로 넘어간다. 모든 리뷰 피드백은 기억되며, 뒤로 갈수록 루프는 더 자동화된다.

하지만 블로그 전체에서 내가 가장 값지다고 느낀 건 이 큰 실토다. agent는 몇 초면 코드를 다 고치지만, 결과를 한 번 검증하는 데는 몇 분이 걸린다——그들이 계속 시뮬레이터를 "babysitting" 하고 있기 때문이다. 시뮬레이터 상호작용은 accessibility tree나 스크린샷에 의존하는데, 느리고 취약하다. 모델이 아무리 좋아도 자기 일을 테스트할 수 없으면, 반복은 공허한 말이다.

그들의 해법은 단 하나의 아키텍처 원칙이었다. 비즈니스 로직은 UI와 완전히 분리되어야 하고, 모바일 화면을 떠나 데스크톱 환경에서 헤드리스로 실행될 수 있어야 한다. 그리고 CLI 하나로 그것을 agent에 노출시켜, agent가 분 단위가 아니라 밀리초 단위의 리듬으로 조작을 dispatch하고, 상태를 확인하고, 결과를断言하게 한다. 시뮬레이터는 정말로 UI 검증이 필요할 때만 연결하고, 원격 모드로 E2E 테스트를 한다.

이 문장은 나에게 자연스러운 검증 충동을 일으켰다. 디커플링에 CLI라면, 문턱이 그렇게 높지 않은데, 내가 직접 한 번 돌려볼 수 있지 않을까?

나는 한 번 재현했고, 세 개의 함정을 밟았다

나는 최소한의 demo를 세웠다. 장바구니 도메인 모듈 하나에 CLI 진입점 하나, Node 환경, 수십 줄. 목표는 아주 구체적이었다. agent가 UI를 전혀 건드리지 않고, 순전히 커맨드라인으로 조작을 dispatch하고, 상태를 읽고, 결과를断言한 뒤, 이 피드백 루프가 대체 얼마나 빠른지 재는 것.

첫 번째 함정은 생각보다 빨리 왔고, 문법 차원이었다. 나는 CLI 인자 파싱에 이런 한 줄을 썼다.

if ( rest [i] .startsWith ('--')) flags [rest[i] .slice ( 2 )] = rest [i + 1++] ;

나는 표현식 i + 1에 후위 증가를 했고, Node는 곧바로 SyntaxError: Invalid left-hand side expression in postfix operation 을 던졌다. JS는 표현식 평가 결과에 ++를 허용하지 않으며, 두 문장으로 나누면 된다. 저급하지만, 실제다.

두 번째 함정은 좀 더 짚고 넘어갈 가치가 있다. 하필 Shopify 아키텍처의 원칙을 정면으로 밟았기 때문이다. 내 첫 버전은 상태를 메모리에 두었는데, 돌려보니 틀렸다. CLI 호출은 매번 새 프로세스이고, 내 첫 명령에서 담기가 성공했지만, 두 번째 명령에서 상태를 조회하니 장바구니가 비어 있었다——프로세스가 죽으면 상태도 다 사라진다. agent가 마주한 것은 "살아 있는" 앱이 아니라, 명령을 한 번 칠 때마다 기억을 잃는 앱이다. 그래서 나는 디스크 쓰기를 추가했다. dispatch 후 상태를 JSON 파일에 쓰고, 프로세스 시작 시 복원한다. 이 버전은 통과했고, agent가 CLI로 조작하고断言하는 전체 경로가 다 돌아간다.

$ node agent_cli.js dispatch add_item SKU - 001 2 --price 99 { "ok" :true } $ node agent_cli.js dispatch add_item SKU - 001 1 --price 99 { "ok" :true } $ node agent_cli.js state { "items" : [ { "sku" : "SKU-001" , "qty" : 3 , "price" : 99 } ], "status" : "open" } $ node agent_cli.js dispatch checkout { "ok" :true , "total" : 297 }

잘못된 입력도 agent가 감지할 수 있다. 음수 수량은 바로 거부되고, 0이 아닌 종료 코드가 나오며, 결제 후 추가 담기를 시도해도 깔끔하게 실패한다.

$ node agent_cli.js dispatch add_item SKU -003 -5 --price 10 { "ok" : false , "error" : "invalid qty: -5" }

세 번째 함정은 성능에 관한 것이고, 좀 반직관적이다. 디스크 쓰기 버전은 그리 빠르지 않았다. 나는 bench를 하나 썼는데, "리셋, 담기, 결제, 상태断言"을 1000라운드 반복했고, 평균 한 라운드에 300밀리초 안팎이 걸렸다. 살펴보니 오버헤드는 전부 매 조작의 동기 디스크 쓰기에 있었다. 영속화를 필요할 때만 켜도록 바꾸고——CLI 모드는 디스크에 쓰고, 순수 반복 모드는 메모리를 쓴다——숫자가 바로 자릿수 하나가 바뀌었다.

1000 loops x (2 actions + 1 state read), 3000 assertions in total total: 3.8 ms | per loop: 0.004 ms(순수 메모리 모드, 디스크 미기록)

3000번의断言, 3.8밀리초. 디스크 쓰기 버전과는 7, 8만 배 차이고, Shopify 블로그에 묘사된 "시뮬레이터 경로는 으레 몇 분"과는 7개 자릿수 차이다. 물론 내 장난감 demo는 그들의 프로덕션 시스템과 비교할 수 없다——그들의 CLI에는 원격 모드도 있고, 실제 시뮬레이터를 구동해 E2E를 한다——하지만 "agent가 밀리초 단위로 상태断言을 받는다"는 이 경로는, 나는 직접 확인해 통과시켰다고 할 수 있다.

이 세 함정은 모두 내가 재현할 때 실제로 부딪힌 것이다. 두 번째 함정(프로세스 기억상실)은 특히 가치 있다. 그것 덕분에 나는 Shopify가 왜 굳이 CLI가 실행 중인 시뮬레이터에 "원격 연결"할 수 있다는 점을 강조하는지 이해했다. agent는 계속 같은 살아 있는 앱 인스턴스를 마주해야 하고, 이는 아키텍처에서 선택 사항이 아니다.

찬물도 끼얹어야 한다

커뮤니티에서 비교적 많이 제기된 몇 가지 의문은, 나는 모두 타당하다고 본다. 써야 한다.

먼저 데이터. Shopify가 공개한 그 성능 데이터——iOS 콜드 스타트 23% 감소, Android 50% 감소, 크래시 세션율 0.5%에서 0.05%로 압축, Android 빌드 75% 가속——대조군은 구 아키텍처의 RN이지, New Architecture가 아니다. InfoQ와 Reddit에서 모두 이 점을 지적했다. 즉, "New Architecture로 마이그레이션해도 비슷한 숫자를 얻었을지 모른다"는 가능성이 배제되지 않았다. RN은 이 데이터에서 사형 선고를 받지 않았다.

그다음, 완전 네이티브의 실질적 대가. OTA 업데이트는 못 하게 되고, 매번 배포할 때마다 앱스토어 심사를 거쳐야 한다. HN 댓글 하나가 아주 꿰뚫어 말했다. 네이티브 배포는 롤백이 더 어렵기 때문에, 팀은 배포에 더 많은 도구를 세우고, 더 독하게 테스트하고, 몇 주에 한 번 큰 버전을 낼 수밖에 없다. 이것은 문화 차이이자 실제 비용이며, 어떻게 저울질하느냐의 문제다.

그리고 한 가지 오독하지 말 것. 이번에 철수한 것은 플래그십 앱이지, RN 자체가 아니다. 블로그는 아주 분명히 썼다. 소규모 내부 도구, 관리 인터페이스는 계속 RN을 쓴다. FlashList는 주간 다운로드 200만이고, 여전히 장기 인수 유지보수자를 찾고 있으며, Skia는 누군가 넘겨받아 계속 유지보수한다. Shopify의 그 체급과 agent 엔지니어링 역량이 없는 절대다수 팀에게 2020년 그 크로스 플랫폼 셈은 오늘날에도 여전히 맞아떨어진다.

그래서 내 결론은 이렇다. 이것은 RN의 장례식이 아니라, 기술 선정에 독립변수가 하나 더 생긴 것이다. 예전에 이런 셈을 할 때 변수는 팀 규모, 인건비, 플랫폼 커버리지였다. 이제는 한 줄을 더 넣어야 한다. agent가 당신 대신 얼마나 많은 "두 번째 구현"을 떠맡아 줄 수 있는가. 이 숫자는 매달 변하고, 변하면 다시 계산해야 한다. Shopify는 재계산 결과를 공개적으로 내놓은 첫 대기업일 뿐이고, 마지막은 아닐 거라고 나는 짐작한다.

그럼 프런트엔드 엔지니어는?

지난 몇 년간 크로스 플랫폼 프레임워크는 프런트엔드 엔지니어의 해자 중 하나였다. JS를 알면 모바일에 손을 뻗을 수 있었다. 이제 agent가 한 플랫폼의 구현을 참고 삼아 다른 플랫폼의 코드를 번역해낼 수 있으니, 순전히 언어 차원의 문턱은 빠르게 가치가 떨어지고 있다. 그리고 Shopify가 제시한 새 아키텍처 원칙 안에는, 더 베팅할 만한 방향이 숨어 있다. 당신의 코드를 agent가 검증할 수 있게 만들라. 비즈니스 로직과 UI의 디커플링, 헤드리스 실행 가능, 상태 관측 가능, 조작에 명확한 성공/실패 신호——이 세트는 그들의 블로그에서는 agent의 빠른 반복을 위한 것이지만, 프런트엔드 웹 개발, 백엔드 서비스로 옮겨도 똑같이 성립한다. 내가 재현할 때 밟은 두 번째 함정이 바로 증거다. 상태가 영속되지 않으면 agent는 기억을 잃고, 피드백이 빠르지 않으면 agent는 헛돈다.

아키텍처 역량은 가치가 떨어지지 않았다. 다만 평가 기준이 바뀌었을 뿐이다. 예전에는 "당신의 코드를 사람이 고치기 좋은가"를 시험했다면, 이제는 "당신의 코드를 agent가 테스트하기 좋은가"가 한 줄 더 붙었다. 이 뒷줄에서, 대부분의 프로젝트는 지금 낙제점이고, 나 자신의 이전 프로젝트 상당수도 그렇다. demo는 나에게 달라고 할 필요 없이, 바로 아래 붙여 둔다(세 파일 총 160줄). 복사하면 돌아간다. 그 숫자 묶음의 차이를 몸으로 느끼고 싶다면, bench를 두 번 돌려 비교하라. CART_PERSIST=1 node bench.js (디스크 쓰기 모드)와 node bench.js (순수 메모리). 차이는 내 글을 보는 것보다 훨씬 직접적일 것이다.

cart_core.js(66줄)—— UI 의존성이 전혀 없는 장바구니 코어, persist 스위치가 바로 여기 있다

// cart_core.js —— 장바구니 핵심 로직, UI 의존성 제로, Node에서 헤드리스로 실행 가능 // Shopify 블로그의 원칙에 맞춤: 비즈니스 로직과 UI를 완전히 분리, 상태를 agent가 직접 관측 가능 const fs = require('fs'); const STORE = __dirname + '/cart_state.json'; // 프로세스가 시작될 때마다 지난번 상태를 복원, 그렇지 않으면 CLI가 호출될 때마다 state가 리셋됨(직접 겪은 함정) let state = { items: [], // { sku, qty, price } status: 'open', // open | checked_out }; if (fs.existsSync(STORE)) { state = JSON.parse(fs.readFileSync(STORE, 'utf8')); }

function persist() { if (!process.env.CART_PERSIST) return; // bench는 순수 메모리로 실행, CLI 모드에서만 디스크에 기록(그렇지 않으면 매 라운드 300ms, 전부 디스크 오버헤드) fs.writeFileSync(STORE, JSON.stringify(state)); }

function getState() { return JSON.parse(JSON.stringify(state)); // 외부에서 참조를 바꾸는 것 방지 }

function dispatch(action) { const result = doDispatch(action); persist(); return result; }

function doDispatch(action) { switch (action.type) { case 'add_item': { if (state.status !== 'open') { return { ok: false, error: 'cart already checked out' }; } const qty = Number(action.qty ?? 1); if (!Number.isInteger(qty) || qty <= 0) { return { ok: false, error: `invalid qty: ${action.qty}` }; } const found = state.items.find((it) => it.sku === action.sku); if (found) { found.qty += qty; } else { state.items.push({ sku: action.sku, qty, price: action.price ?? 0 }); } return { ok: true }; } case 'checkout': { if (state.items.length === 0) { return { ok: false, error: 'cart is empty' }; } state.status = 'checked_out'; return { ok: true, total: state.items.reduce((s, it) => s + it.qty * it.price, 0) }; } case 'reset': { state.items = []; state.status = 'open'; return { ok: true }; } default: return { ok: false, error: `unknown action: ${action.type}` }; } }

module.exports = { dispatch, getState };

agent_cli.js(72행)—— agent가 쓰는 헤드리스 조작 진입점

#!/usr/bin/env node // agent_cli.js —— agent가 쓰는 헤드리스 조작 진입점 // 사용법: // node agent_cli.js state // node agent_cli.js dispatch add_item SKU-001 2 --price 99 // node agent_cli.js dispatch checkout // node agent_cli.js run < script.txt (배치 스크립트, 한 줄에 명령 하나) const { dispatch, getState } = require('./cart_core');

function parseArgs(argv) { const [cmd, type, ...rest] = argv; const flags = {}; const pos = []; for (let i = 0; i < rest.length; i++) { if (rest[i].startsWith('--')) { flags[rest[i].slice(2)] = rest[i + 1]; i++; } else pos.push(rest[i]); } return { cmd, type, pos, flags }; }

function main(argv) { const { cmd, type, pos, flags } = parseArgs(argv); if (cmd === 'state') { console.log(JSON.stringify(getState(), null, 2)); return 0; } if (cmd === 'dispatch') { const action = { type }; if (type === 'add_item') { action.sku = pos[0]; action.qty = pos[1]; if (flags.price !== undefined) action.price = Number(flags.price); } const result = dispatch(action); console.log(JSON.stringify(result)); return result.ok ? 0 : 1; // 0이 아닌 종료 코드, agent가 한눈에 실패를 알아봄 } // 배치 모드: 한 줄에 하나의 dispatch/state 명령, agent가 연속 반복하는 시나리오를 모사 if (cmd === 'run') { const lines = require('fs').readFileSync(0, 'utf8').split('\n').filter(Boolean); let fails = 0; const t0 = performance.now(); for (const line of lines) { const [c, t, ...r] = line.trim().split(/\s+/); if (c !== 'dispatch') continue; const action = { type: t }; if (t === 'add_item') { action.sku = r[0]; action.qty = r[1]; const p = r.indexOf('--price'); if (p !== -1) action.price = Number(r[p + 1]); } const result = dispatch(action); if (!result.ok) { console.log(`FAIL ${line} -> ${result.error}`); fails++; } } const ms = (performance.now() - t0).toFixed(2); console.log(`--- ${lines.length} actions done, ${fails} failed, ${ms} ms total`); return fails ? 1 : 0; } console.error('unknown command:', cmd); return 2; }

process.exit(main(process.argv.slice(2)));

bench.js(22행)—— "조작→단언" 반복 루프 벤치마크

// bench.js —— agent의 "조작→단언" 반복 루프를 모사, 헤드리스 경로의 실제 피드백 속도를 측정 const { dispatch, getState } = require('./cart_core'); const N = 1000; let assertions = 0; const t0 = performance.now(); for (let i = 0; i < N; i++) { dispatch({ type: 'reset' }); const r1 = dispatch({ type: 'add_item', sku: 'SKU-' + i, qty: 1, price: 10 }); if (!r1.ok) throw new Error('unexpected: ' + r1.error); assertions++; const r2 = dispatch({ type: 'checkout' }); if (!r2.ok || r2.total !== 10) throw new Error('assert failed: total=' + r2.total); assertions++; if (getState().status !== 'checked_out') throw new Error('state assert failed'); assertions++; } const ms = performance.now() - t0; console.log(`${N} loops x (2 actions + 1 state read), ${assertions} assertions in total`); console.log(`total: ${ms.toFixed(1)} ms | per loop: ${(ms / N).toFixed(3)} ms(순수 메모리 모드, 디스크 미기록)`);

여기까지 쓰겠다, 나중에 Shopify의 그 Shop 마이그레이션 심층 장문에 세부 내용이 나오면 다시 얘기하자.