<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0" xmlns:cc="http://cyber.law.harvard.edu/rss/creativeCommonsRssModule.html">
    <channel>
        <title><![CDATA[당근 기술 블로그 - Medium]]></title>
        <description><![CDATA[동네를 여는 문, 당근. 로컬의 모든 것을 연결해, 동네의 숨은 가치를 깨워요. - Medium]]></description>
        <link>https://medium.com/daangn?source=rss----4505f82a2dbd---4</link>
        <image>
            <url>https://cdn-images-1.medium.com/proxy/1*TGH72Nnw24QL3iV9IOm4VA.png</url>
            <title>당근 기술 블로그 - Medium</title>
            <link>https://medium.com/daangn?source=rss----4505f82a2dbd---4</link>
        </image>
        <generator>Medium</generator>
        <lastBuildDate>Thu, 05 Nov 2026 03:25:57 GMT</lastBuildDate>
        <atom:link href="https://medium.com/feed/daangn" rel="self" type="application/rss+xml"/>
        <webMaster><![CDATA[yourfriends@medium.com]]></webMaster>
        <atom:link href="http://medium.superfeedr.com" rel="hub"/>
        <item>
            <title><![CDATA[국내 최초 광고 MCP를 출시하며, 개발자의 역할은 어디까지 넓어졌을까?]]></title>
            <link>https://medium.com/daangn/%EA%B5%AD%EB%82%B4-%EC%B5%9C%EC%B4%88-%EA%B4%91%EA%B3%A0-mcp%EB%A5%BC-%EC%B6%9C%EC%8B%9C%ED%95%98%EB%A9%B0-%EA%B0%9C%EB%B0%9C%EC%9E%90%EC%9D%98-%EC%97%AD%ED%95%A0%EC%9D%80-%EC%96%B4%EB%94%94%EA%B9%8C%EC%A7%80-%EB%84%93%EC%96%B4%EC%A1%8C%EC%9D%84%EA%B9%8C-8dea7fe41c07?source=rss----4505f82a2dbd---4</link>
            <guid isPermaLink="false">https://medium.com/p/8dea7fe41c07</guid>
            <category><![CDATA[product-management]]></category>
            <category><![CDATA[ai-agent]]></category>
            <category><![CDATA[engineering]]></category>
            <category><![CDATA[mcps]]></category>
            <category><![CDATA[software-engineering]]></category>
            <dc:creator><![CDATA[Jake]]></dc:creator>
            <pubDate>Tue, 03 Nov 2026 09:27:47 GMT</pubDate>
            <atom:updated>2026-11-03T09:27:46.027Z</atom:updated>
            <content:encoded><![CDATA[<figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*o_diPNkXrL4mOMOuefHDmg.png" /></figure><p>안녕하세요. 당근 광고실 광고주플랫폼팀에서 백엔드 엔지니어로 일하는 제이크(Jake)예요.</p><p>얼마 전 저희 팀 엔지니어링 매니저 Iltaek이 <a href="https://medium.com/daangn/%ED%94%84%EB%A1%A0%ED%8A%B8%EC%97%94%EB%93%9C%EC%99%80-%EB%B0%B1%EC%97%94%EB%93%9C%EB%A5%BC-%ED%95%9C-%ED%8C%80%EC%9C%BC%EB%A1%9C-%ED%95%A9%EC%B9%98%EB%A9%B4-%EC%96%B4%EB%96%A4-%EC%9D%BC%EC%9D%B4-%EC%9D%BC%EC%96%B4%EB%82%A0%EA%B9%8C-f8b32edb2eb1">프론트엔드와 백엔드를 한 팀으로 합친 이야기</a>를 글로 나눴어요. 그 글이 팀의 경계를 넘어 일하는 이야기였다면, 이번 글은 제가 당근 광고 MCP를 만들며 <strong>개발자의 역할을 넓혀 본 이야기</strong>예요.</p><p>MCP(Model Context Protocol)는 AI 서비스가 외부 서비스의 데이터를 불러올 때 쓰는 표준 연결 방식이에요. 광고주가 Claude 같은 AI 서비스에 “지난주 광고 성과 어땠어?”라고 물으면, AI가 광고 MCP를 거쳐 당근 광고의 계정 정보와 광고 정보, 성과 데이터를 가져와서 답해 줘요. 해외 광고 플랫폼에서는 구글, 메타, 아마존이 이미 MCP를 공개했고, 국내에서는 당근이 처음으로 공식 광고 MCP를 출시했어요.</p><p>저는 개발자로 이 프로젝트에 참여하는 줄 알았는데, 하다 보니 로드맵과 PRD부터 출시까지 맡아야 했어요. 이 글에서는 그 과정에서 겪은 일과, 제가 일하는 방식이 어떻게 달라졌는지 이야기해 보려고 해요.</p><h3>로드맵과 PRD를 처음 써 봤어요</h3><p>그동안은 PM과 함께 일하면서 엔지니어로서 개발에 필요한 의견을 나누고, PRD를 어떻게 구현할지 고민했어요. 그런데 이번에는 Iltaek에게 로드맵과 PRD까지 맡아 달라는 요청을 받았어요.</p><p>솔직히 요청을 받았을 때는 “이것도 개발자인 내 일인가?” 싶었어요. 로드맵은 PM이 그리는 거 아닌가, 내가 로드맵을 제시하는 게 맞나 하는 생각이 먼저 들었어요. 해 본 적 없는 일이라 부담도 됐고요.</p><p>그때 Iltaek이 오너라는 마음가짐으로 해 보라고 했어요. 그 말대로 “내 프로젝트”라고 생각하다 보니, 이 프로젝트를 더 잘되게 하려면 뭘 해야 할지 고민하게 되더라고요. 스스로 던지는 질문부터 달라졌어요.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*kB15h43f5fHJZ6t88xKY_w.png" /></figure><p>로드맵을 그리려고 하니, 당근 광고 플랫폼은 생각보다 크고 복잡했어요. 전문가모드, 간편모드, 대행사 플랫폼¹까지 합치면 광고주가 할 수 있는 일이 정말 많아요. 광고 보고서, 광고 수정, 소재 만들기, 타겟팅, 전환 추적, 결제와 충전까지요. 이걸 한 번에 다 MCP로 열 수는 없어서, 범위를 좁혀야 했어요.</p><p>혹시나 잘못되더라도 광고주에게 손해가 없는 일인지, 화면보다 AI로 했을 때 더 편한 일인지, 더 많은 광고주에게 도움이 되는 일인지, 이렇게 세 가지 기준을 세워 봤어요. 그래서 리스크가 가장 낮은 조회 기능부터 열었어요. 광고를 수정하는 기능은 충분히 검증한 뒤에 열기로 했어요. AI가 예산이나 입찰가를 바꾸다 실수하면 광고비가 의도와 다르게 나갈 수 있어서요. 결제나 광고 삭제처럼 AI로 하는 게 오히려 불편하거나 위험한 일은 아예 열지 않기로 했어요. 그리고 당근 광고에서 가장 많이 집행되는 디스플레이 광고부터 시작했어요.</p><p>그렇게 로드맵을 그리고, 그 안에서 이번에 할 일의 범위를 정했어요. 그다음 저희 팀에서 쓰는 PTBSMAF라는 프레임워크로 PRD를 썼어요. 문제 정의(Problem Statement), 트레이드오프(Trade-off), 벤치마크(Benchmark), 해결책(Solution), 지표(Metrics), 중단 조건(Andon), 피드백 루프(Feedback Loop)의 앞 글자를 딴 이름이에요.</p><p>그중에서도 문제 정의, 트레이드오프, 벤치마크가 특히 도움이 됐어요. 문제를 제가 이해한 대로 풀어 쓰다 보니 생각이 정리됐고, 조회만 먼저 열면 무엇을 얻고 무엇을 포기하는지 적으면서 트레이드오프가 분명해졌어요. 특히 벤치마크에서는 다른 광고 플랫폼의 사례를 찾아보면서, 그대로 참고할 부분과 우리 상황에 맞게 바꿔야 할 부분이 구분됐어요.</p><p>PRD를 쓰면서 이전에는 생각해 보지 못했던 부분까지 고민하게 됐어요. 이 기능으로 플랫폼이 무엇을 얻을지, 어느 제품부터 고려할지, 뭐가 더 나은 방향인지 같은 제품 관점의 고민은 전부 처음이었어요.</p><p>이렇게 정한 것들이 정답인지 100% 확신할 수는 없었지만, 적어도 근거를 가지고 정했다는 점은 지금 돌아봐도 만족스러워요. 로드맵은 PM이 그리는 거라고 생각했던 것도, 해 보고 나니 제 선입견이었던 것 같아요.</p><h3>API를 그대로 옮기면 될 줄 알았어요</h3><p>로드맵과 PRD로 무엇을 하고 무엇을 하지 않을지 정하고 나니, 이제 도구를 어떻게 만들지 고민할 차례였어요.</p><p>광고주센터 뒤에는 이미 잘 돌아가는 API가 있어서, 그걸 MCP 도구로 하나씩 감싸기만 하면 될 거라고 생각했어요. 그런데 API를 그대로 도구로 만들면, AI가 어떤 도구를 써야 할지 헷갈려하고, 필요한 정보를 놓쳐 잘못된 답을 할 수 있더라고요.</p><p>예를 들어 광고주가 “이 광고 왜 안 나가?”라고 물었다고 해 볼게요. 광고주센터였다면 광고 정보, 노출 상태, 심사 결과가 한 화면에 같이 보였을 거예요. 그런데 API마다 도구를 따로 만들면, AI가 그중 뭘 부를지 스스로 골라야 해요. 광고 정보만 보고 “켜져 있으니 괜찮아요”라고 답하면서, 광고 심사에서 막힌 건 놓치는 식이에요.</p><p>메타 광고 MCP를 직접 연결해 보니, 도구가 많고 설명 방식도 제각각이라 AI가 광고주의 요청을 다르게 이해하는 경우가 있었어요. 로드맵을 그리며 광고주가 AI에게 무엇을 물어볼지 고민했던 게 여기서 도움이 됐어요. API를 그대로 옮기는 대신, 광고주의 질문을 기준으로 도구를 설계하기로 했어요. 그렇게 정리해 보니 좋은 API와 좋은 도구의 기준은 꽤 달랐어요.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*TuXq9VSDixKNLWXMxkk-Aw.png" /></figure><p>이 기준으로, 가장 흔한 요청인 “지난주 성과 분석해 줘”를 예시로 도구를 설계해 봤어요. 이 요청은 어느 계정인지 정하고, 대상을 찾고, 성과를 보는 세 단계로 나눌 수 있어요. 그래서 단계마다 도구를 하나씩 두고 광고주센터 뒤의 여러 API를 3개의 도구로 정리했어요. 앞으로 광고 수정처럼 기능이 늘어나면 도구도 함께 늘어나겠지만, 그때도 이 기준을 지켜 가려고 해요.</p><h3>제품으로 내놓으려면 챙길 게 많았어요</h3><p>도구를 다 만들었다고 바로 내놓을 수 있는 건 아니었어요. AI가 광고주의 계정을 대신 다룰 수 있도록 약관을 검토하고 법무팀의 확인을 받아야 했어요. 기존 서버에 부하가 가지 않도록 적절한 호출 한도를 정해야 했고, 광고주용 연결 가이드와 서비스 내 안내 화면도 준비해야 했어요. 그리고 출시 후 운영을 위한 대시보드도 만들어야 했어요.</p><p>처음에는 좀 막막했어요. 일정을 챙기고, 회의를 잡고, 다른 팀과 일정을 맞추는 일은 개발자인 저에게 익숙하지 않았으니까요. 그럴 때마다 함께 진행한 PM인 Moana와 이야기하면서 하나씩 풀어 나갔어요.</p><p>챙길 일 중에서도 가장 걱정했던 건 <strong>인증</strong>이었어요. MCP는 인증 방식이 표준으로 정해져 있어요. OAuth 2.1을 따라야 하고, MCP를 연결할 때 광고주 본인의 동의를 거쳐야 해요. 사내에서만 쓰는 게 아니라 외부 광고주에게 공개하려면, 이러한 표준을 반드시 지원해야 했어요. 다행히 당근의 인증을 맡고 있는 Identity Service Team에서 이미 SSO(Single Sign-On)를 제공하고 있었어요.² 그래서 MCP 인증도 지원해 달라고 요청드렸고, 인증 서버를 표준에 맞게 고쳐 주셨어요.</p><p>그리고 나서 광고 MCP에 인증 서버를 연동하고 AI 서비스에 연결해 봤어요. 그런데 OpenAI의 Codex에서는 로그인을 누르면 에러가 뜨고 그대로 끊겼어요. OpenAI 문서에는 이 표준을 지원한다고 나와 있어서 더 당황했는데, 알고 보니 그때 이 인증 방식에 대한 지원은 Codex 정식 버전에는 아직 들어가지 않았고 알파 버전에만 막 들어온 참이었어요. 이대로 출시하면 Codex에서는 광고 MCP를 연결할 수 없는 상황이었어요. 그래서 틈날 때마다 새 버전이 나왔는지 들여다봤어요. 출시 전에 정식 버전에서도 지원하게 돼서, 그제야 한숨 돌렸어요.</p><p>그렇게 9월 16일, 광고 MCP를 공개 베타로 출시했어요.</p><h3>출시 후엔 광고주가 어떻게 쓰는지 궁금했어요</h3><p>원래라면 개발하고 QA하고 프로덕션에 배포하면 끝났다고 생각했을 것 같아요. 그런데 제품을 맡고 보니, 배포하고 나서 의도한 대로 동작하는지가 궁금했어요. 광고주들이 이걸 실제로 어떻게 쓰는지, 어떻게 하면 더 잘 쓰게 할 수 있을지 계속 고민하게 됐어요. 그래서 도구가 호출될 때마다 어떤 광고주가 어떤 AI 서비스에서 어떤 도구를 불렀는지 남기고, 계속해서 MCP를 쓰는지까지 대시보드에서 볼 수 있게 만들었어요.</p><p>출시 직후에는 틈날 때마다 대시보드를 열어 봤어요. 간편모드 사장님이 많은지 전문가모드 마케터가 많은지, 대행사 마케터도 쓰는지, 어떤 업종에서 많이 쓰는지 살펴보다 보니, 만들 때 고민했던 것들이 실제로 광고주에게 도움이 되는지 더 알고 싶어졌어요. 누가 시킨 건 아니었지만, 주간 리포트도 써 보고 데이터도 직접 분석해 봤어요.</p><p>광고실에서는 광고주 경험을 직접 이해하기 위해 실제 광고를 운영해 보는 개밥먹기(dogfooding)를 하고 있어요. 저도 한 빵집 광고를 맡고 있었는데, 가게 주변 좁은 지역에만 광고를 집행하다 보니 성과가 점점 떨어지고 있었어요. 그래서 광고 MCP를 연결해 에이전트에게 성과 분석을 맡겨 봤어요.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*6IQltQk5Dno9W-J5MnoAIg.png" /><figcaption><em>이해를 돕기 위한 예시 화면이에요. 실제 광고주의 데이터는 담지 않았어요.</em></figcaption></figure><p>에이전트는 소재 피로도가 높다며 새 소재를 제안했어요. 성과가 낮은 광고를 찾아 끄고, 그 예산을 다른 광고로 옮기라는 제안도 했어요. 소재 피로도까지 짚어낼 줄은 몰라 놀랐어요. 실제 광고를 운영할 때 참고할 만한 제안이었어요.</p><p>이 경험을 통해 광고주가 MCP를 잘 쓰고 있는지는 호출 횟수만으로 판단할 수 없다고 느꼈어요. 분석이 광고주의 판단과 실제 운영에 얼마나 도움이 되는지가 더 중요했어요. 그래서 로드맵에서 다음 단계로 미뤄 둔 광고 수정 기능의 필요성도 분명해졌어요. 분석 결과를 실제 광고에 반영해야 운영에 변화가 생기니까요.</p><h3>다음은 AI로 광고를 고치는 거예요</h3><p>지금 광고 MCP는 조회만 할 수 있어요. 분석 결과를 광고에 반영하려면 광고주가 광고주센터에 들어가 직접 수정해야 해요. 다음 단계에서는 조회와 수정을 연결해 AI가 광고를 고치고 만들 수도 있게 하려고 해요.</p><p>장기적으로는 AI 서비스를 직접 사용하기 어려운 간편모드 사장님도 당근 안에서 에이전트의 도움을 받아 광고를 더 잘 운영할 수 있도록 하고 싶어요. 지금 만든 도구는 외부 AI 서비스뿐 아니라 당근 안의 에이전트에도 그대로 연결할 수 있어서, 도구를 새로 만들지 않아도 돼요.</p><p>기능을 넓히는 만큼, 이게 정말 광고주에게 도움이 되는지도 계속 확인하려고 해요. 광고를 더 자주, 더 잘 고칠 수 있으면 광고 성과가 좋아지고, 좋아진 성과는 광고주의 매출로, 다시 당근 광고의 매출로 이어질 거라고 보고 있어요. 광고 MCP는 이 흐름의 첫 단계인 “광고를 더 잘 고치는 일”을 AI로 더 쉽게 만들어 준다고 생각해요.</p><h3>마치며</h3><p>사내에 출시 공지를 올리며 감사 인사를 적다 보니, 고마운 분들이 많아서 빠진 분이 없는지 몇 번이나 다시 확인했어요. 제품 하나를 내놓기까지 이렇게 많은 분의 도움이 필요하다는 걸 이번에 알았어요. 갑작스러운 요청에도 함께해 주신 모든 분께 감사드려요.</p><p>처음에는 “이것도 개발자인 내 일인가?” 싶었어요. 그런데 내 프로젝트라고 생각하니 질문이 바뀌었어요. “어떻게 구현할까?”에서 “어떤 문제를 풀고, 문제가 실제로 해결됐는지 어떻게 확인할까?”로요. 제가 해 보고 도움이 됐던 습관을 세 가지로 정리해 봤어요.</p><ol><li>구현하기 전에, 이 일이 어떤 문제를 푸는지 내 언어로 표현해 봐요.</li><li>배포하고 나서, 문제가 정말 풀렸는지 검증해 봐요.</li><li>내가 만든 기능을 사용자처럼 직접 써 봐요.</li></ol><p>이 글이 개발자로서 역할을 넓혀 보려는 분께 작은 참고가 되면 좋겠어요. 궁금한 점이나 비슷한 경험이 있다면 댓글이나 제 링크드인으로 편하게 남겨 주세요.</p><h3>각주</h3><ol><li>당근 광고는 광고주의 환경에 맞춰 서비스가 나뉘어요. 간편모드는 쉽고 빠르게 광고를 집행하고 싶은 소상공인 광고주를 위한 서비스이고, 전문가모드는 캠페인, 광고그룹, 소재 단위로 전략을 세우고 정밀한 타겟팅과 성과 분석이 필요한 마케팅 담당자와 광고주를 위한 서비스예요. 대행사 플랫폼은 여러 광고주의 광고를 대신 운영하는 대행사를 위한 서비스예요.</li><li>당근 SSO가 어떻게 만들어져 왔는지는 Identity Service Team이 쓴 <a href="https://medium.com/daangn/%EB%8B%B9%EA%B7%BC%EC%9D%98-%EC%9D%B8%EC%A6%9D%EC%9D%80-%EC%96%B4%EB%96%BB%EA%B2%8C-%EB%8B%AC%EB%9D%BC%EC%A0%B8-%EC%99%94%EC%9D%84%EA%B9%8C%EC%9A%94-a50dd4b7b44c">당근의 인증은 어떻게 달라져 왔을까요?</a>에서 볼 수 있어요.</li><li>같은 광고 소재를 같은 사람들에게 오래 보여 주면, 사람들이 익숙해져서 점점 반응하지 않게 되는 현상이에요. 클릭률이 시간이 지날수록 떨어지는 모습으로 나타나요.</li></ol><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=8dea7fe41c07" width="1" height="1" alt=""><hr><p><a href="https://medium.com/daangn/%EA%B5%AD%EB%82%B4-%EC%B5%9C%EC%B4%88-%EA%B4%91%EA%B3%A0-mcp%EB%A5%BC-%EC%B6%9C%EC%8B%9C%ED%95%98%EB%A9%B0-%EA%B0%9C%EB%B0%9C%EC%9E%90%EC%9D%98-%EC%97%AD%ED%95%A0%EC%9D%80-%EC%96%B4%EB%94%94%EA%B9%8C%EC%A7%80-%EB%84%93%EC%96%B4%EC%A1%8C%EC%9D%84%EA%B9%8C-8dea7fe41c07">국내 최초 광고 MCP를 출시하며, 개발자의 역할은 어디까지 넓어졌을까?</a> was originally published in <a href="https://medium.com/daangn">당근 기술 블로그</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[모델만 바꿨는데 LLM 비용이 3배가 된 이유]]></title>
            <link>https://medium.com/daangn/%EB%AA%A8%EB%8D%B8%EB%A7%8C-%EB%B0%94%EA%BF%A8%EB%8A%94%EB%8D%B0-llm-%EB%B9%84%EC%9A%A9%EC%9D%B4-3%EB%B0%B0%EA%B0%80-%EB%90%9C-%EC%9D%B4%EC%9C%A0-5ae60ab5108b?source=rss----4505f82a2dbd---4</link>
            <guid isPermaLink="false">https://medium.com/p/5ae60ab5108b</guid>
            <category><![CDATA[engineering]]></category>
            <category><![CDATA[ai]]></category>
            <dc:creator><![CDATA[Jaemin Seo]]></dc:creator>
            <pubDate>Wed, 21 Oct 2026 09:28:26 GMT</pubDate>
            <atom:updated>2026-10-21T09:28:25.046Z</atom:updated>
            <content:encoded><![CDATA[<p>안녕하세요, 당근 커뮤니티홈팀에서 백엔드를 개발하는 제이(Jay)예요. 당근 앱의 커뮤니티 탭에는 이웃이 알려 주는 동네 정보부터 소소한 일상과 취미까지 나누는 동네생활, 모임, 카페, 아파트가 모여 있어요. 커뮤니티홈팀은 이 서비스들의 글을 한데 모아 보여 주는 피드를 맡고 있어요.</p><p>피드를 맡으며 가장 고민하는 건 사용자에게 더 알맞은 글을 어떻게 보여 줄지예요. 그래서 기존 머신러닝 추천에 더해, LLM으로 글의 다양한 메타데이터를 뽑아 추천에 쓰고 있어요. 그중 하나가 LLM에 카테고리 목록과 글을 주고 어느 주제에 속하는지 고르게 하는 주제 분류예요. 추천은 보여 줄 글 후보를 모으는 단계와 그 순서를 정하는 단계로 나뉘는데, 글마다 주제가 붙어 있으면 첫 단계부터 사용자가 관심 있는 주제의 글을 골라 올 수 있어요.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*gMuZUjAf_-B7vdwzKsiORQ.png" /></figure><p>주제 분류를 할 때 LLM에 보내는 요청을 열어 보면 대부분이 매번 똑같은 지시문과 카테고리 목록이고, 글 본문은 끝에 조금 붙어 있을 뿐이에요. 이 글에서는 요청마다 똑같은 이 부분을 고정부라고 부를게요.</p><p>2026년 8월, 이 작업의 모델을 Gemini에서 단가가 20% 낮은 OpenAI GPT-5.6으로 바꿨어요. 응답은 정상이었고 에러도 한 건 없었어요.</p><p>닷새 뒤 보니 단가는 내렸는데 요청당 비용은 3.2배가 돼 있었어요. 원인은 프롬프트 캐시였어요. 입력 중 캐시에서 읽어 온 비율인 캐시 히트율이 85.8%에서 2.3%로 떨어져 있었죠.</p><p>교체 전후의 응답을 나란히 놓으면 달라진 곳은 사용량 정보 안의 캐시 필드 하나뿐이었어요. 캐시가 깨져도 에러는 나지 않아요. 비용만 늘어요.</p><p>이 글은 그 일을 해결하며 알게 된 것을 정리한 기록이에요. 캐시가 무엇을 재사용하는지, 비용은 어떻게 매겨지는지, 언제 켜지고 어디까지 저장되는지, 캐시에서 읽었는지는 어떻게 확인하는지를 OpenAI, Anthropic, Google Gemini를 나란히 놓고 살펴봐요. 같은 지시문에 입력만 바꿔 LLM을 대량으로 부르거나, 챗봇이나 에이전트처럼 앞부분이 같은 요청을 되풀이해 보내는 분이라면 참고가 될 거예요.</p><h3>프롬프트 캐시의 원리</h3><p>신입 검수자에게 매번 규정집 20쪽과 오늘 볼 글 한 편을 함께 준다고 해 볼게요. 규정집은 늘 같고 글만 바뀌는데, 이 검수자는 기억을 못 해서 글마다 규정집을 처음부터 다시 읽어요. 하루 10만 편이면 규정집을 10만 번 읽는 셈이에요.</p><p>LLM도 똑같이 요청마다 프롬프트 전체를 처음부터 다시 읽어요. 이 낭비를 줄이는 게 프롬프트 캐시예요.</p><h4>앞 토큰을 돌아보는 어텐션</h4><p>LLM은 글을 토큰 단위로 하나씩 이어 써요. 다음 토큰을 고르기 전에는 앞 토큰들을 돌아보며 무엇을 참고할지 따지는데, 이 계산을 어텐션(attention)이라고 해요.</p><p>“어제 시장에서 산 사과를 깎아서” 다음에 올 토큰을 고른다고 해 볼게요. 사람은 바로 “먹었다”를 떠올려요. “사과”와 “깎아서”가 가장 관련 있고 “어제”나 “시장에서”는 덜 중요하다는 걸 알기 때문이에요. 모델은 이 판단을 마지막 토큰 “깎아서” 자리에서 숫자로 해요.</p><p>어텐션에는 토큰마다 세 가지 값이 쓰여요. 도서관에서 자료를 찾는 일에 비유해 볼게요.</p><ul><li><strong>Q(Query, 질문)</strong>: 마지막 토큰이 다음 토큰을 고르려고 들고 있는 질문이에요. “무엇을 했는지 이어 쓰려면 어떤 정보가 필요하지?” 같은 것이에요.</li><li><strong>K(Key, 분류표)</strong>: 앞 토큰마다 붙은 꼬리표예요. 책의 분류표처럼 “먹을 수 있는 물건”, “때를 나타내는 말” 같은 정보를 담아요.</li><li><strong>V(Value, 내용)</strong>: 앞 토큰이 실제로 건네주는 정보예요. 책의 본문에 해당해요.</li></ul><p>마지막 토큰 “깎아서”는 자기 Q를 앞 토큰들(자기 자신 포함)의 K와 하나씩 맞춰 보고 점수를 매겨요. “사과”와 “깎아서”는 높은 점수를, “어제”는 낮은 점수를 받아요. 그다음 점수 비율대로 V를 섞고 그 결과로 다음 토큰 “먹었다”를 골라요. 분류표(K)로 관련 있는 책을 고른 뒤, 그 책들의 본문(V)을 점수만큼 읽는 것과 같아요.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*Lwc8Nl4z0Z47xIcRl1Mv5w.png" /></figure><p>실제 Q, K, V는 사람이 읽을 수 있는 문장이 아니라 숫자 묶음(벡터)이고, 위 설명은 그 역할을 말로 푼 거예요.</p><h4>한 번 만들면 계속 쓰는 K와 V</h4><p>“먹었다”를 고른 뒤에는 “먹었다”가 Q를 들고 같은 일을 해요. 이때 “어제”부터 “깎아서”까지의 K와 V는 앞 단계에서 만든 것과 똑같아요. 뒤에 어떤 토큰이 붙어도 앞 토큰의 K와 V는 바뀌지 않거든요.</p><p>그래서 모델은 한 번 만든 K와 V를 캐시에 쌓아 두고 다시 꺼내 써요. 이 캐시를 KV 캐시라고 해요. 새로 계산하는 건 새 토큰의 Q, K, V뿐이고, Q는 그 토큰이 앞을 볼 때 한 번 쓰고 끝나서 쌓아 두지 않아요. 캐시가 없다면 토큰을 하나 쓸 때마다 앞 토큰 전부를 처음부터 다시 계산해야 해요.</p><p>입력도 마찬가지예요. 모델은 답을 쓰기 전에 입력 전체를 한꺼번에 읽으며 K와 V를 만들어 캐시를 채워요. 이 단계를 prefill이라고 하는데, 입력이 길수록 오래 걸려요.</p><h4>KV 캐시를 다음 요청에 물려주는 프롬프트 캐시</h4><p>KV 캐시는 원래 요청 하나가 끝나면 함께 사라져요. 프롬프트 캐시는 그 앞부분을 요청이 끝난 뒤에도 서버에 남겨 두는 기능이에요. 다음 요청의 앞부분이 같으면 prefill을 건너뛰고 남겨 둔 것을 그대로 꺼내 써요. 캐시에서 읽은 앞부분은 Q, K, V 어느 것도 다시 계산하지 않고 새로 들어온 토큰만 계산해요. 규정집을 읽으며 만든 메모를 다음 글에도 쓰는 셈이에요. 제공사는 아낀 계산만큼 값을 깎아 주는데, OpenAI는 캐시에서 읽은 입력 토큰을 최대 95%까지 할인해요.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*CSLEIwvbDJEwVW01Qk4_WQ.png" /></figure><p><strong>헷갈리기 쉬운 것</strong></p><ul><li><strong>프롬프트 캐시는 응답 캐시가 아니에요.</strong> 응답 캐시는 같은 질문에 저장해 둔 답을 그대로 돌려줘요. 뜻이 비슷한 질문에도 답을 돌려주는 시맨틱 캐싱도 응답 캐시의 한 종류예요. 프롬프트 캐시는 입력을 읽는 계산만 재사용하고 출력은 매번 새로 만들어요.</li><li><strong>캐시를 켜도 답의 품질은 그대로예요.</strong> 답을 만드는 과정은 캐시가 없을 때와 똑같다고 OpenAI와 Anthropic 문서 모두 적어 두었어요.</li><li><strong>캐시는 입력 비용만 깎아요.</strong> 출력 토큰은 캐시와 상관없이 정가예요.</li><li><strong>응답도 빨리 시작돼요.</strong> 입력을 처리하는 시간이 줄어서예요. 다만 공식 문서에 일반적인 수치는 없어요.</li></ul><h4>앞부분이 글자 하나까지 같아야 하는 이유</h4><p>모델 구조 때문이에요. 모델은 여러 층으로 되어 있고, 층마다 가중치가 달라서 같은 어텐션이라도 층마다 다른 질문을 던져요. 아래층의 결과는 위층의 입력이 돼요. 예를 들어 아래층에서 “깎아서”가 “사과를”과 묶이면, 위층의 “깎아서”는 이미 “사과를 깎아서”라는 뜻을 담고 다시 앞을 봐요. 이렇게 층을 지날수록 앞 문맥이 쌓이니, 위층의 K와 V는 그 토큰 하나가 아니라 앞 토큰 전체에 묶여 있어요. 앞에서 토큰 하나만 달라져도 그 뒤의 K와 V는 모두 달라져요.</p><p>위치도 따져요. K에는 “몇 번째 토큰인가”라는 정보가 섞여 있어서 같은 문장도 100번째와 200번째에서 K가 달라요.</p><p>그래서 프롬프트 캐시가 다시 쓸 수 있는 건 처음부터 똑같은 부분뿐이에요. 중간에 한 번이라도 다른 토큰이 나오면, 그 뒤가 아무리 똑같아도 다시 계산해요.</p><p>예를 들어 프롬프트 맨 앞의 시각 2026-09-04 09:00이 1분 뒤 09:01로 바뀌면, 토큰 하나 차이인데도 그 뒤 지시문 전체를 다시 계산해요. 시각을 맨 뒤로 옮기면 끝의 몇 토큰만 새로 계산해요.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*7Hg50lqnFVcZcFm99DdrTA.png" /></figure><h3>앞부분을 흔드는 것들</h3><p>OpenAI 문서에 따르면 캐시되는 앞부분에는 OpenAI가 자체로 넣는 지시문, 개발자 메시지, 도구 정의, 대화 이력까지 들어가요. 그래서 프롬프트 문구를 고치지 않아도, 요청마다 끼어드는 값이나 함께 보내는 설정이 바뀌면 앞부분이 달라져요.</p><ul><li><strong>앞쪽에 넣은 시각과 날짜.</strong> OpenAI 문서도 캐시가 깨지는 예로 지시문 속 타임스탬프를 들어요.</li><li><strong>요청 ID, UUID, 난수.</strong> 추적용으로 프롬프트에 넣었다면 맨 뒤로 옮기세요.</li><li><strong>키 순서가 흔들리는 JSON.</strong> 같은 데이터라도 직렬화할 때마다 키 순서가 바뀌면 다른 글자가 돼요. Anthropic 문서는 도구의 input_schema JSON이 매번 다르게 직렬화되는 경우를 도구 변경 원인으로 들어요.</li><li><strong>도구 목록과 그 순서.</strong> 도구를 더하거나 빼거나 순서만 바꿔도 앞부분이 달라져요. OpenAI는 어떤 요청에서 도구를 쓰지 않게 하려면 정의를 지우지 말고 tool_choice를 &quot;none&quot;으로 두라고 권해요.</li><li><strong>출력 형식과 스키마.</strong> 구조화 출력 스키마는 입력에 함께 들어가요.</li><li><strong>추론 강도 같은 설정.</strong> OpenAI는 reasoning.effort와 text.verbosity를 앞부분을 바꿀 수 있는 설정으로, Anthropic은 thinking과 effort 설정 변경을 캐시가 깨지는 원인으로 꼽아요.</li></ul><h3>캐시를 켜면 오히려 비싸지는 경우</h3><p>캐시에서 읽으면 싸지만, 캐시에 저장할 때 추가 비용을 받는 제공사도 있어요. 이걸 모르면 캐시는 켜 두기만 하면 무조건 이득이라고 착각하기 쉬워요.</p><p><strong>읽기</strong>는 대부분 입력 정가의 0.1배예요. OpenAI GPT-6.1 Sol, Anthropic Claude Opus 5.5와 Sonnet 5.5는 0.05배, Claude Fable 5.1은 0.025배로 더 싸요. Gemini는 캐시에서 읽은 토큰을 90% 할인해요.</p><p><strong>쓰기</strong> 요금은 제공사마다 달라요. 캐시에 저장할 때 발생하는 추가 비용을 이 글에서는 쓰기 할증이라고 할게요.</p><ul><li><strong>OpenAI</strong>: GPT-5.6 이후 모델은 정가의 1.25배예요. GPT-5.5 이하는 할증이 없어요.</li><li><strong>Anthropic</strong>: 기본 5분짜리 캐시는 1.25배, 1시간짜리 캐시는 2배예요.</li><li><strong>Gemini</strong>: 자동으로 거는 implicit 캐시는 할증이 없어요. 미리 올려 두는 explicit 캐시는 저장해 둔 시간만큼 저장료를 따로 내요.</li></ul><p>쓰기 할증이 있으면 캐시를 켠 쪽이 더 비쌀 수도 있어요. 고정부 토큰 하나는 캐시에서 읽을 때 0.1배, 못 읽고 새로 저장할 때 1.25배를 내요. 고정부 토큰 가운데 캐시에서 읽는 비율을 h라고 하면 평균 배율은 아래와 같아요.</p><blockquote><em>0.1 × h + 1.25 × (1 − h)</em></blockquote><p>h가 약 22%보다 낮으면 이 값이 1을 넘어, 캐시가 없을 때보다 오히려 비싸져요. 프롬프트 길이와는 상관없고, 읽기가 0.05배인 모델도 약 21%로 거의 같아요. OpenAI 문서도 요청 열 번 중 처음 한 번만 저장하고 아홉 번을 읽으면 2.15배로, 캐시 없이 열 번 처리하는 10배보다 훨씬 싸다고 설명해요.</p><h4>캐시 상태별 비용 비교</h4><p>고정부가 요청의 95%인 작업에서, 쓰기 할증이 1.25배인 모델의 입력 비용을 캐시가 없을 때를 100으로 두고 계산했어요.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*9JD2h4at66Qm2Wz8osKe3A.png" /></figure><p>같은 요청인데도 캐시 상태에 따라 비용이 여섯 배 넘게 벌어져요. 비용을 견적할 때 단가표와 함께 예상 히트율이 필요한 이유예요. 쓰기 할증이 없는 모델이라면 못 읽어도 캐시 없음과 같은 100이에요.</p><h3>캐시를 켜는 법과 구분점 위치</h3><p>캐시는 구분점(breakpoint) 단위로 저장돼요. 구분점은 “처음부터 여기까지를 한 덩어리로 저장해 두라”는 표시이고, 다음 요청은 앞부분이 그 덩어리 전체와 같아야 읽어요. 캐시를 켜는 방법과 구분점을 누가 어디에 찍는지가 제공사마다 달라요.</p><h4>OpenAI: 모델마다 다른 자동 구분점 위치</h4><p>OpenAI는 캐시가 기본으로 켜져 있고 구분점도 자동으로 찍어요. 그런데 GPT-5.6에서 자동 구분점의 위치가 바뀌었어요.</p><ul><li><strong>GPT-5.5</strong>: 2,048토큰 간격으로 찍어요.</li><li><strong>GPT-5.6 이후</strong>: 가장 최근 메시지의 끝에 하나 찍어요. GPT-6 계열도 같아요.</li></ul><h4>저희 비용이 3배가 된 원인</h4><p>저희 요청은 고정된 지시문을 앞에, 글 본문을 뒤에 두고 메시지 하나로 보냈어요. Gemini를 쓸 때는 대부분의 요청이 같은 지시문 부분을 캐시에서 읽었어요. GPT-5.5처럼 2,048토큰 간격으로 구분점을 찍는 모델이었어도 긴 지시문 안에 구분점이 여러 개 생겨, 글이 달라도 지시문 쪽 덩어리는 다시 읽을 수 있었을 거예요. 하지만 GPT-5.6에서는 구분점이 글 본문 뒤에 하나만 찍혀서, 저장되는 덩어리는 “지시문 + 글 A” 전체예요. 다음 요청 “지시문 + 글 B”는 지시문까지는 같지만 글에서 갈라지니, 이 덩어리 전체와 같지 않아 읽지 못해요. “지시문까지”만 담은 덩어리는 한 번도 저장되지 않았고요. 거의 모든 요청이 1.25배를 내고 저장만 했어요. 앞의 비용 그림에서 본 125가 바로 이 상태예요.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*sEvA7Ue-r4DjwpmZZ3J2gA.png" /></figure><p>OpenAI 문서도 이 함정을 <a href="https://developers.openai.com/api/docs/guides/prompt-caching#a-shared-prefix-is-not-always-a-cached-prefix">주의할 점(Gotchas)</a>의 첫 항목으로 적어 두었어요.</p><blockquote><em>A shared prefix is not always a cached prefix. This is particularly prevalent when migrating from earlier models to GPT-5.6 or later due to the change in implicit caching behaviour.<br>(앞부분이 같다고 해서 그 앞부분이 캐시돼 있는 건 아니에요. 자동 캐시 동작이 바뀌어서 이전 모델에서 GPT-5.6 이후로 옮길 때 특히 자주 생겨요.)</em></blockquote><h4>고정부 끝에 찍는 명시 구분점</h4><p>해결 방법도 문서에 있어요. GPT-5.6 이후로 옮길 때의 안내는 “자동 구분점에 요청마다 바뀌는 부분이 들어가면, 고정부 뒤에 명시 구분점을 더하라”고 써요. 고정부가 끝나고 바뀌는 부분이 시작되기 직전이 그 자리예요. OpenAI API로 직접 보낸다면 아래처럼 써요.</p><pre>{<br>  &quot;model&quot;: &quot;gpt-6.1-sol&quot;,<br>  &quot;prompt_cache_options&quot;: { &quot;mode&quot;: &quot;explicit&quot; },<br>  &quot;input&quot;: [<br>    {<br>      &quot;role&quot;: &quot;developer&quot;,<br>      &quot;content&quot;: [<br>        {<br>          &quot;type&quot;: &quot;input_text&quot;,<br>          &quot;text&quot;: &quot;(매번 같은 지시문, 규칙, 예시)&quot;,<br>          &quot;prompt_cache_breakpoint&quot;: { &quot;mode&quot;: &quot;explicit&quot; }<br>        }<br>      ]<br>    },<br>    { &quot;role&quot;: &quot;user&quot;, &quot;content&quot;: &quot;(요청마다 바뀌는 입력)&quot; }<br>  ]<br>}</pre><p>prompt_cache_breakpoint가 붙은 블록까지가 한 덩어리로 저장되므로 입력이 바뀌어도 지시문 덩어리는 다시 읽혀요. prompt_cache_options.mode를 explicit으로 두면 자동 구분점이 꺼지고 직접 찍은 곳에서만 저장해서, 매번 바뀌는 입력에는 쓰기 할증이 붙지 않아요. 자동 구분점과 함께 쓸 수도 있고, 요청 하나가 만드는 캐시 쓰기는 최대 4개예요.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*HoJOfqgfWA1waW0-5dC7SA.png" /></figure><p>저희는 프롬프트를 사내 LLM 플랫폼의 템플릿으로 관리하고 코드는 변수 값만 넘겨요. 그래서 플랫폼이 템플릿의 첫 변수 앞에 구분점을 찍도록 고쳤어요. 고친 뒤 주제 분류의 히트율은 95% 안팎으로 올랐고, 요청당 비용은 약 5분의 1로 줄었어요.</p><h4>Anthropic: 직접 켜야 하는 캐시</h4><p>Anthropic은 캐시가 기본으로 켜져 있지 않아서 요청에 cache_control을 붙여야 해요. 붙이는 자리는 두 가지예요.</p><ul><li><strong>요청 맨 바깥</strong>: model, messages와 같은 최상위 단계에 하나만 붙이면 마지막 블록 끝에 구분점이 찍혀요. 대화가 이어지면 구분점도 새 메시지 끝으로 옮겨 가고, 직전 요청까지 저장된 부분은 그대로 읽혀요. 이전 대화에 새 메시지만 덧붙이는 챗봇에 잘 맞아요. 반대로 지시문 뒤에 매번 다른 글을 붙이는 작업에서는 구분점이 바뀌는 글 끝에 찍혀 캐시를 읽지 못한다고 문서가 경고해요. GPT-5.6에서 격은 것과 같은 함정이에요.</li><li><strong>블록에 직접</strong>: 아래 예시처럼 고정부가 끝나는 블록 안에 붙여요. 4개까지 찍을 수 있어요.</li></ul><p>앞부분은 도구 정의(tools), 시스템 프롬프트(system), 메시지(messages) 순서로 만들어지므로 system 끝에 붙이면 도구 정의와 지시문이 함께 저장돼요.</p><pre>{<br>  &quot;model&quot;: &quot;claude-opus-5-5&quot;,<br>  &quot;max_tokens&quot;: 1024,<br>  &quot;system&quot;: [<br>    {<br>      &quot;type&quot;: &quot;text&quot;,<br>      &quot;text&quot;: &quot;(매번 같은 지시문, 규칙, 예시)&quot;,<br>      &quot;cache_control&quot;: { &quot;type&quot;: &quot;ephemeral&quot; }<br>    }<br>  ],<br>  &quot;messages&quot;: [<br>    { &quot;role&quot;: &quot;user&quot;, &quot;content&quot;: &quot;(요청마다 바뀌는 입력)&quot; }<br>  ]<br>}</pre><h4>Gemini: 자동인 implicit과 미리 올려 두는 explicit</h4><p>Gemini의 implicit 캐시는 기본으로 켜져 있어서 따로 할 일이 없어요. 다만 문서는 “캐시에서 읽었을 때(when cache hits occur)” 할인된다고만 써서 읽는다는 보장은 없어요. 구분점을 찍는 방법도 없어서 Google 문서는 대신 이렇게 권해요.</p><blockquote><em>Place large and common contents at the beginning of your prompt. Send requests with a similar prefix in a short amount of time.<br>(크고 공통된 내용을 프롬프트 앞에 두세요. 앞부분이 비슷한 요청을 짧은 시간 안에 보내세요.)</em></blockquote><p>explicit 캐시는 고정부를 미리 서버에 올려 두고 요청에서는 그 이름만 부르는 방식이에요. OpenAI의 explicit 모드와는 다른 기능이에요. 올려 둔 내용 전체가 구분점 하나와 같은 역할을 해요. 문서는 이 방식이 할인을 보장한다(ensures)고 쓰지만, 저장해 둔 시간만큼 저장료를 따로 내요. Gemini 4부터는 explicit 캐시를 쓸 수 없어요. Gemini API 문서에 그렇게 적혀 있고, Google Cloud 문서의 지원 모델 목록에도 Gemini 4는 없어요.</p><pre># 1) 고정부를 미리 올리기 (응답의 name이 캐시 이름)<br>POST https://LOCATION-aiplatform.googleapis.com/v1/projects/PROJECT_ID/locations/LOCATION/cachedContents<br>{<br>  &quot;model&quot;: &quot;projects/PROJECT_ID/locations/LOCATION/publishers/google/models/gemini-3.5-flash&quot;,<br>  &quot;contents&quot;: [{ &quot;role&quot;: &quot;user&quot;, &quot;parts&quot;: [{ &quot;text&quot;: &quot;(매번 같은 지시문, 규칙, 예시)&quot; }] }],<br>  &quot;ttl&quot;: &quot;3600s&quot;<br>}<br><br># 2) 요청에서는 캐시 이름만 부르고 바뀌는 부분만 보내기<br>POST https://LOCATION-aiplatform.googleapis.com/v1/projects/PROJECT_ID/locations/LOCATION/publishers/google/models/gemini-3.5-flash:generateContent<br>{<br>  &quot;cachedContent&quot;: &quot;projects/PROJECT_NUMBER/locations/LOCATION/cachedContents/CACHE_ID&quot;,<br>  &quot;contents&quot;: [{ &quot;role&quot;: &quot;user&quot;, &quot;parts&quot;: [{ &quot;text&quot;: &quot;(요청마다 바뀌는 입력)&quot; }] }]<br>}</pre><h3>최소 길이와 유지 시간</h3><p>캐시를 켜고 구분점을 맞게 찍어도, 고정부가 충분히 길고 같은 앞부분이 캐시가 사라지기 전에 다시 와야 캐시에서 읽어요.</p><h4>최소 길이</h4><ul><li><strong>OpenAI</strong>: GPT-5.6 이후는 1,024토큰이에요. 문서는 모델을 바꿀 때마다 고정부 길이를 새 모델 기준으로 다시 재 보라고 권해요.</li><li><strong>Anthropic</strong>: 같은 세대라도 모델마다 달라요. 2026년 10월 문서 기준으로 512토큰(Opus 5.5, Opus 5, Sonnet 5.5, Fable 5.1, Haiku 5.5), 1,024토큰(Opus 4.8, Sonnet 5), 2,048토큰(Opus 4.7), 4,096토큰(Opus 4.6, Opus 4.5, Haiku 4.5)이에요. 3,000토큰짜리 프롬프트는 Opus 5에서는 캐시되고 Opus 4.6에서는 캐시되지 않아요. 최소 길이보다 짧으면 cache_control을 붙여도 캐시하지 않고 에러도 돌려주지 않아요.</li><li><strong>Gemini</strong>: Gemini 2 계열은 2,048, Gemini 3과 4 계열은 4,096토큰이에요. 일부 모델은 문서마다 값이 달라요. Google Cloud 문서는 Gemini 3.7 Flash, 3.8 Flash 등의 implicit 최소를 6,144로, Gemini API 문서는 4,096으로 적어요. 쓰는 경로의 문서로 확인하세요.</li></ul><h4>유지 시간</h4><ul><li><strong>OpenAI</strong>: GPT-5.6 이후는 마지막으로 쓰거나 읽은 뒤 최소 30분 유지돼요. prompt_cache_options.ttl로 정하는데, 지금 고를 수 있는 값은 기본값인 &quot;30m&quot; 하나예요.</li><li><strong>Anthropic</strong>: 기본 5분이고 읽을 때마다 연장돼요. &quot;cache_control&quot;: { &quot;type&quot;: &quot;ephemeral&quot;, &quot;ttl&quot;: &quot;1h&quot; }로 1시간짜리를 쓸 수 있지만 쓰기가 2배라서, 요청 간격이 5분보다 길고 1시간보다 짧을 때 따져 볼 만해요.</li><li><strong>Gemini</strong>: implicit은 Google Cloud 문서 기준으로 길어야 24시간이고, 대부분은 그 전에 사라진다고 해요. explicit은 기본 60분이고 늘릴 수 있지만 늘린 만큼 저장료도 늘어요.</li></ul><p>같은 앞부분이 유지 시간보다 드물게 오면 캐시는 저장만 되고 읽히지 않아요. 쓰기 할증이 있는 모델이라면 저장할 때마다 할증만 내서, 캐시를 쓰지 않을 때보다 비싸질 수 있어요.</p><h3>캐시에서 읽었는지 확인하기</h3><h4>응답의 캐시 필드</h4><p>앞부분은 같고 끝의 입력만 다른 요청 두 개를 연달아 보내고, 두 번째 응답에서 캐시로 읽은 토큰이 0보다 큰지부터 보세요. 똑같은 요청을 두 번 보내면 구분점이 잘못 찍혀 있어도 캐시에서 읽은 것처럼 보여요. 필드 이름은 제공사마다 달라요. 숫자는 예시예요.</p><pre>// OpenAI (Responses API)<br>&quot;usage&quot;: {<br>  &quot;input_tokens&quot;: 5000,                // 전체 입력<br>  &quot;input_tokens_details&quot;: {<br>    &quot;cached_tokens&quot;: 4800,             // 캐시에서 읽음<br>    &quot;cache_write_tokens&quot;: 0            // 캐시에 새로 저장함 (쓰기 할증)<br>  }<br>}<br><br>// Anthropic<br>&quot;usage&quot;: {<br>  &quot;input_tokens&quot;: 200,                 // 캐시를 거치지 않은 나머지<br>  &quot;cache_read_input_tokens&quot;: 4800,     // 캐시에서 읽음<br>  &quot;cache_creation_input_tokens&quot;: 0     // 캐시에 새로 저장함 (쓰기 할증)<br>}<br>// Anthropic은 세 값을 더해야 전체 입력이에요<br><br>// Gemini<br>&quot;usageMetadata&quot;: {<br>  &quot;promptTokenCount&quot;: 5000,            // 전체 입력<br>  &quot;cachedContentTokenCount&quot;: 4800      // 캐시에서 읽음<br>}</pre><p>구분점을 직접 찍었는데도 읽기 필드가 계속 0이라면, 게이트웨이나 SDK 래퍼가 그 구분점을 제공사까지 그대로 전달하는지 확인하세요. 저희도 요청을 조립하는 중간 계층이 일부 요청에서 cache_control을 빠뜨려, 그 요청들은 캐시를 전혀 읽지 못한 적이 있어요.</p><h4>히트율의 두 가지 기준</h4><p>요청 기준 히트율은 캐시를 한 토큰이라도 읽은 요청의 비율이고, 토큰 기준 히트율은 캐시에서 읽은 토큰을 전체 입력 토큰으로 나눈 값이에요. 앞부분 6,000토큰 중 1,000토큰만 읽어도 요청 기준으로는 히트로 세서, 요청 기준이 대개 더 후하게 나와요. OpenAI 문서는 토큰 기준 히트율을 사용자나 날짜 단위로 묶어 보라고 권해요. 이 글에 나온 히트율도 모두 토큰 기준이에요.</p><h4>제공사 진단 기능</h4><p>OpenAI와 Anthropic은 이전 응답과 이번 요청을 비교해 캐시가 어디서 갈렸는지 알려 주는 진단 기능도 제공해요. 요청에 OpenAI는 prompt_cache_options.comparison_response_id를, Anthropic은 diagnostics를 넣으면 응답에 tools_changed(도구 정의가 바뀜) 같은 미스 사유가 와요. 쓰는 법은 <a href="https://developers.openai.com/api/docs/guides/prompt-caching/diagnostics">OpenAI 문서</a>와 <a href="https://platform.claude.com/docs/en/build-with-claude/cache-diagnostics">Anthropic 문서</a>에 있어요. Gemini에는 아직 이런 기능이 없어요.</p><h4>캐시에서 못 읽을 때 보는 순서</h4><p>캐시가 의심되면 위에서부터 차례로 확인하고 해당하는 단계에서 오른쪽에 적은 대로 고쳐요.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*ytpAa3dtdtvMK1edn3DwkQ.png" /></figure><h3>마치며</h3><p>모델 하나를 바꿨을 뿐인데 에러 한 줄 없이 비용이 세 배가 됐어요. 원인은 모델이 바뀌면서 달라진 캐시 규칙이었고, 그 규칙은 문서에 적혀 있었어요.</p><p>앞부분이 매번 같은지, 그 길이가 내 모델의 최소 길이를 넘는지, 유지 시간 안에 같은 앞부분이 다시 오는지 물어보세요. 셋 다 “예”라면 캐시가 비용을 크게 바꿔요. 그때는 고정부를 앞에, 바뀌는 부분을 뒤에 두고 구분점이 그 경계에 찍히는지 확인하세요. 시각이나 요청 ID, JSON 키 순서, 도구 목록 순서처럼 앞부분을 몰래 흔드는 것들도 치우세요.</p><p>돌아보면 Gemini에서 히트율이 높았던 건 저희가 요청을 잘 짜서가 아니었어요. 끝이 달라도 같은 앞부분까지는 알아서 읽어 주던 제공사 동작 덕분이었는데, 저희는 거기에 기대고 있다는 걸 몰랐어요. 잘 돌아가던 이유를 모르면 그 이유가 사라져도 알아채기 어려워요.</p><p>그래서 모델 교체는 코드 한 줄이 아니라 운영 조건을 바꾸는 일로 다뤄야 해요. 응답이 맞게 나오는지에 더해 요청당 비용, 응답 시간, 캐시 히트율을 교체 전과 나란히 비교하세요. 이 비교는 몇 건으로는 할 수 없어요. 캐시는 앞선 요청이 데워 둬야 읽히고 요청이 얼마나 자주 오느냐에 따라 달라져서, 몇 건만 보내 보면 실제와 다른 숫자가 나와요. 운영 트래픽에서 하루 이상 잴 값으로 보고, 배포한 뒤에도 이 지표를 계속 지켜보세요. 저희도 배포 다음 날 히트율만 봤다면 닷새가 아니라 하루 만에 알아챘을 거예요.</p><p>이 경험은 한 모델의 캐시 정책에서 끝나지 않아요. LLM을 프로덕션에서 쓰면 모델이나 제공사 동작, 프롬프트 구조가 바뀔 때 에러 없이 비용과 품질이 달라지고, 그 변화는 운영 지표에서만 드러나요. 그래서 지켜야 할 규칙은 사람보다 시스템에 두고, 나머지는 지표로 지켜봐야 해요. 저희도 구분점은 플랫폼이 찍게 하고, 사람은 “바뀌는 부분은 템플릿 끝에 둔다” 하나만 지키게 했어요.</p><p>마지막으로 세 제공사의 차이를 한 장에 모았어요.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*ImjKGn5lHzxpwXvne420_Q.png" /></figure><h4>참고한 자료</h4><p>이 글에 쓴 제공사 동작은 2026년 10월 공식 문서 기준이에요. 캐시 규칙은 모델 버전마다 바뀌니, 적용하기 전에 다시 확인해 보세요.</p><ul><li>OpenAI: <a href="https://developers.openai.com/api/docs/guides/prompt-caching">Prompt caching</a>, <a href="https://developers.openai.com/api/docs/guides/prompt-caching/diagnostics">Prompt cache diagnostics</a></li><li>Anthropic: <a href="https://platform.claude.com/docs/en/build-with-claude/prompt-caching">Prompt caching</a>, <a href="https://platform.claude.com/docs/en/build-with-claude/cache-diagnostics">Cache diagnostics</a></li><li>Google: <a href="https://docs.cloud.google.com/gemini-enterprise-agent-platform/models/context-cache/context-cache-overview">Context caching overview</a>, <a href="https://ai.google.dev/gemini-api/docs/caching">Gemini API context caching</a></li></ul><p>당근 커뮤니티실은 누구나 자신의 동네에서 소속감을 느끼며 살아갈 수 있도록, 이웃을 잇는 지역 커뮤니티를 만들고 있어요. 이 글의 주제 분류처럼 AI도 제품 곳곳에 직접 붙여 가고 있고요. 서버부터 화면까지, 제품이 사용자에게 닿는 끝까지 함께 고민할 <a href="https://careers.daangn.com/jobs/role/7762062003/">백엔드 엔지니어</a>와 <a href="https://careers.daangn.com/jobs/role/7689088003/">프론트엔드 엔지니어</a>를 찾고 있어요. 관심이 있다면 공고를 확인해 주세요.</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=5ae60ab5108b" width="1" height="1" alt=""><hr><p><a href="https://medium.com/daangn/%EB%AA%A8%EB%8D%B8%EB%A7%8C-%EB%B0%94%EA%BF%A8%EB%8A%94%EB%8D%B0-llm-%EB%B9%84%EC%9A%A9%EC%9D%B4-3%EB%B0%B0%EA%B0%80-%EB%90%9C-%EC%9D%B4%EC%9C%A0-5ae60ab5108b">모델만 바꿨는데 LLM 비용이 3배가 된 이유</a> was originally published in <a href="https://medium.com/daangn">당근 기술 블로그</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Aurora MySQL 인덱스 DDL이 Reader 버퍼풀을 비운다?]]></title>
            <link>https://medium.com/daangn/aurora-mysql-%EC%9D%B8%EB%8D%B1%EC%8A%A4-ddl%EC%9D%B4-reader-%EB%B2%84%ED%8D%BC%ED%92%80%EC%9D%84-%EB%B9%84%EC%9A%B4%EB%8B%A4-16c6328dea50?source=rss----4505f82a2dbd---4</link>
            <guid isPermaLink="false">https://medium.com/p/16c6328dea50</guid>
            <category><![CDATA[data]]></category>
            <dc:creator><![CDATA[Dmsgptpeo]]></dc:creator>
            <pubDate>Tue, 06 Oct 2026 10:41:52 GMT</pubDate>
            <atom:updated>2026-10-06T10:42:27.985Z</atom:updated>
            <content:encoded><![CDATA[<h4>미사용 인덱스를 정리하다 만난 Aurora 엔진 결함, 그리고 3.14 릴리즈까지</h4><p>안녕하세요. 당근 DB팀에서 근무하고 있는 Eden(이든)이에요. 당근 DB팀에서는 Aurora MySQL / Aurora PostgreSQL 및 MongoDB 를 운영하고 있어요. 이번에 Aurora MySQL 을 운영하며 경험했던 이슈를 소개해 드리려고 해요.</p><p>“인덱스를 INVISIBLE 로 바꾸는 건 메타데이터만 건드리는 작업이니까 DB 에 부하를 주지 않는다.” DBA 라면 누구나 이렇게 말할 거예요. 저도 그랬고요. 그런데 Aurora MySQL 에서는 이 문장이 반쪽만 맞았어요. Writer 는 정말 아무 일도 없었지만, Reader 에서는 버퍼풀이 통째로 무효화되면서 CPU 와 ReadIOPS 가 동시에 튀었거든요.</p><p>오늘은 미사용 인덱스를 정리하다가 이 현상을 발견하고, 버퍼풀 안에서 무슨 일이 벌어지는지 직접 들여다보고, DDL 종류별로 재현해서 AWS 에 리포트하고, 결국 엔진 결함으로 확인되어 다음 릴리즈에 fix 가 실리기까지의 과정을 정리해 보려고 해요. Aurora MySQL 을 운영하는 분들께 실질적인 도움이 되면 좋겠어요.</p><h3>Aurora 는 Reader 의 캐시를 어떻게 동기화할까</h3><p>먼저 배경부터 짧게 짚고 갈게요. Aurora 는 인스턴스와 스토리지가 분리되어 있어요. Writer 는 데이터 페이지를 스토리지에 쓰지 않고 redo 로그만 보내고, 스토리지 계층이 그 로그로 페이지를 만들어요. Reader 는 같은 스토리지를 바라보지만 자기만의 버퍼풀을 갖고 있고요. Writer 가 어떤 페이지를 바꾸면 그 redo 로그가 Reader 에게 전달되고, Reader 는 버퍼풀에 그 페이지가 있으면 갱신해요.</p><p>이 구조에서 제가 알고 있는 규칙은 이랬어요.</p><ul><li>DDL 중에서 COPY 또는 rebuild 를 동반하는 INPLACE 알고리즘이 사용되는 경우(컬럼 타입 변경, NOT NULL 변경 등)에는 내부적으로 새 테이블을 만들어 데이터를 옮기고 옛 테이블을 지워요. InnoDB 에서는 tablespace 가 새로 생기는 것이라 (SPACE id 가 바뀜), Reader 가 갖고 있던 옛 tablespace 의 캐시 페이지는 전부 의미가 없어져요. <strong>즉 Reader 버퍼풀에서 해당 테이블의 캐시된 페이지는 소멸하게 돼요.</strong></li><li>DDL 중에서 INSTANT 또는 rebuild 없는 INPLACE 알고리즘이 사용되는 경우에는 기존 테이블의 데이터·인덱스 페이지를 손대지 않아요. 인덱스 INVISIBLE 이나 컬럼 DEFAULT 변경처럼 데이터 딕셔너리만 고치거나, CREATE INDEX 처럼 새 B-tree 를 하나 더 만들 뿐이에요. <strong>그래서 Reader 버퍼풀에서 해당 테이블의 캐시된 페이지는 그대로 유지돼야 해요.</strong></li></ul><p>두 번째 줄이 오늘 이야기의 전제이자, 깨진 가정이에요.</p><h3>발단: 미사용 인덱스 6개</h3><p>당근의 한 서비스는 Aurora MySQL 3.10.x 클러스터를 Writer 1대 + Reader 3대로 운영하고 있어요. 이 클러스터에는 인덱스가 20개 넘게 달린 테이블이 있었어요. 인덱스가 많으면 쓰기 비용이 늘고, 옵티마이저가 실제 쿼리 실행에 사용되지 않는 인덱스까지 평가하느라 실행 계획을 세우는 비용도 커져요. 그래서 안 쓰는 인덱스를 정리하기로 했어요.</p><p>미사용 판정은 performance_schema.table_io_waits_summary_by_index_usage 를 썼어요. 이 테이블은 인스턴스가 시작된 이후 인덱스별 읽기/쓰기 횟수를 누적하니까, 일주일 간격으로 두 번 스냅샷을 떠서 그 사이에 count_read 가 늘지 않은 인덱스를 골라냈어요.</p><pre>SELECT object_name, index_name, count_star, count_read, count_write<br>FROM performance_schema.table_io_waits_summary_by_index_usage<br>WHERE object_name = &#39;store_reviews&#39;;</pre><p>(여기서 한 가지 팁. 이 조회는 반드시 모든 노드에서 해야 해요. 노드별로 쿼리 패턴이 완전히 달라서, Writer 에서 count_read = 0 인 인덱스가 특정 Reader 에서는 많이 사용될 수 있거든요. 4개 노드 × 2주 동안 스냅샷 모두에서 읽기가 0 인 인덱스 6개를 최종 후보로 골랐어요.)</p><p>바로 DROP 하지는 않았어요. 인덱스를 INVISIBLE 로 바꿔서 옵티마이저가 못 보게 만든 뒤 며칠 관찰하고, 문제가 없으면 DROP 하는 절차로 진행하려고 했어요. INVISIBLE 은 인덱스 자체는 그대로 두니까 무언가 잘못되면 VISIBLE 로 즉시 되돌릴 수 있어요. 메타데이터만 바꾸는 작업이니 부담 없이, 인덱스 하나씩 텀을 두고 실행했어요.</p><pre>ALTER TABLE store_reviews ALTER INDEX idx_sentiment_store_id_score_created_at INVISIBLE;<br>ALTER TABLE store_reviews ALTER INDEX idx_store_id_score_created_at INVISIBLE;<br>ALTER TABLE store_reviews ALTER INDEX idx_region_id_score_category_id INVISIBLE;</pre><h3>현상: Writer 는 조용한데 Reader 만 튄다</h3><p>CloudWatch 를 보니 ALTER INDEX INVISIBLE 세 번에 Reader 스파이크 세 번이 정확히 1:1 로 대응하고 있었어요.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*b-XNWJOgCA51ULmy04joow.png" /><figcaption>그림 1. INVISIBLE 3회에 1:1 로 대응하는 Reader 의 CPU · ReadIOPS 스파이크 (Reader 3대 중 1대)</figcaption></figure><p>Reader 3대가 동시에 튀었어요.</p><ul><li>ReadIOPS 는 평소 수백에서 3.1K 로 올라갔어요.</li><li>CPU 는 30% 에서 50~60% 로 올라갔어요.</li><li>BufferCacheHitRatio 는 순간적으로 떨어졌어요.</li></ul><p>같은 시각 Writer 는 ReadIOPS / CPU / BufferCacheHitRatio 모두 <strong>평소와 같았어요</strong>.</p><p>BufferCacheHitRatio 가 떨어지고 ReadIOPS 가 튄다는 건, <strong>버퍼풀에서 데이터 페이지가 순간적으로 사라져 스토리지에서 다시 읽어 오는 상황</strong>이 아닐까 의심했어요.</p><p>그래서 INDEX INVISIBLE DDL 직전과 직후에 Reader 에서 인덱스별 캐시 페이지 수가 변경되는 게 있는지 확인해 봤어요.</p><pre>SELECT INDEX_NAME, COUNT(*) AS pages, ROUND(COUNT(*) * 16 / 1024) AS size_mb<br>FROM information_schema.INNODB_BUFFER_PAGE<br>WHERE TABLE_NAME LIKE &#39;%store_reviews%&#39;<br>GROUP BY INDEX_NAME<br>ORDER BY pages DESC;</pre><p>(위 쿼리를 할 때 주의할 점이 하나 있어요. 이 뷰는 버퍼풀 전체를 훑기 때문에 버퍼풀이 큰 인스턴스에서는 조회 자체가 수 초 걸리고 부하도 줘요. 운영 중에 습관처럼 돌리는 쿼리는 아니에요. 공식 문서도 <a href="https://dev.mysql.com/doc/refman/8.0/en/information-schema-innodb-buffer-page-table.html">운영 시스템에서는 성능 영향을 확인한 뒤에만 조회하라고</a> 경고해요.)</p><pre>-- DDL 직전<br>INDEX_NAME                                       pages  size_mb<br>---------------------------------------------------------------<br>PRIMARY                                         252527     3946<br>idx_author_id_visibility_score                   33963      531<br>idx_author_id_created_at_product_id_visibility   33456      523<br>idx_author_id_product_id_created_at              30904      483<br>…<br>합계                                             511866     7998<br> <br>-- DDL 직후<br>INDEX_NAME                                       pages  size_mb<br>---------------------------------------------------------------<br>PRIMARY                                         252656     3948<br>idx_author_id_visibility_score                   33965      531<br>idx_author_id_created_at_product_id_visibility   33464      523<br>idx_author_id_product_id_created_at              30916      483<br>…<br>합계                                             511847     7998</pre><p>위 결과에서 볼 수 있듯 INDEX INVISIBLE 작업 이후에도 Reader 에서 해당 테이블이 메모리에 캐시된 페이지 수는 변하지 않았어요.<br>그렇다면 왜 Reader 버퍼풀의 페이지 수는 그대로인데 스토리지 읽기만 폭증했던 걸까요?</p><h3>버퍼풀 안에서 무슨 일이: IS_STALE</h3><p>Reader 버퍼풀의 페이지 수는 그대로인데 스토리지 읽기가 폭증한다는 건, <strong>페이지가 메모리 안에서 더 이상 쓰이지 못하게 무효화(invalidate)되는 건 아닐까</strong> 하는 의심이 들었어요.</p><p>이 의문에 대한 답은 information_schema.INNODB_BUFFER_PAGE 에서 찾을 수 있었어요. 이 뷰에는 IS_STALE 이라는 컬럼이 있는데, MySQL 8.0.24 에 추가된 표준 컬럼이에요 (Aurora 전용이 아니에요). <strong>이 컬럼은 더 이상 메모리에서 유효하지 않은 페이지를 나타내요.</strong></p><p>원래 용도는 이래요. MySQL 8.0.23 에서 InnoDB 는 큰 테이블을 DROP 하거나 TRUNCATE 할 때의 성능을 개선했어요. 이전에는 tablespace 를 지우면서 버퍼풀 전체를 훑어 그 tablespace 의 페이지를 즉시 걷어냈는데, 버퍼풀이 큰 인스턴스에서는 이 스캔 자체가 큰 부하였거든요. 8.0.23 부터는 페이지를 바로 지우지 않아요. 삭제된 tablespace 쪽에 “삭제됨” 표시와 새 버전 번호만 남겨 두고, 이후 조회나 LRU 스캔이 그 tablespace 의 페이지를 만나면 그때 버려요 (WL#14100).</p><p>8.0.24 는 이 상태를 INNODB_BUFFER_PAGE 의 IS_STALE 컬럼으로 볼 수 있게 했어요. 다만 페이지마다 플래그를 써 두는 건 아니에요. 페이지는 자기가 올라올 때의 tablespace 버전을 기억하고 있고, 만날 때마다 그 버전이 tablespace 의 현재 버전과 다른지 비교해서 stale 로 판정해요. IS_STALE 은 조회하는 시점에 그 판정 결과를 YES/NO 로 보여 주는 거예요. <strong>즉 커뮤니티 MySQL 에서 stale 은 “삭제되어 더 이상 참조될 일이 없는 tablespace 의 페이지라서, 접근되거나 LRU 스캔에 걸릴 때 걷어낼 대상” 이라는 뜻</strong>이에요.</p><p>처음 가졌던 의문인 <strong>“Aurora 에서는 혹시 인덱스 작업 시 Reader 에서 해당 페이지들이 더 이상 유효하지 않게 되는 거 아닐까?”</strong> 를 확인해 보기 위해, Aurora Reader 한 대에서 INDEX INVISIBLE 작업 직전과 직후로 IS_STALE 값을 비교해 보았어요. (지면상 몇 개의 인덱스만 표시했어요)</p><pre>SELECT TABLE_NAME, INDEX_NAME,<br>       COUNT(*)                                         AS pages,<br>       SUM(IS_STALE = &#39;YES&#39;)                            AS stale_pages,<br>       ROUND(SUM(IS_STALE = &#39;YES&#39;) / COUNT(*) * 100, 1) AS stale_pct<br>FROM information_schema.INNODB_BUFFER_PAGE<br>WHERE TABLE_NAME LIKE &#39;%store_reviews%&#39;<br>GROUP BY TABLE_NAME, INDEX_NAME<br>ORDER BY stale_pages DESC;</pre><pre>-- INDEX INVISIBLE 직전 (평상시)<br>INDEX_NAME                          pages   stale_pages  stale_pct<br>------------------------------------------------------------------<br>PRIMARY                            252527             0          0<br>idx_user_id_created_at_visibility   21316             0          0<br>idx_author_id_visibility_score      33963             0          0<br> <br>-- INDEX INVISIBLE 수행<br> <br>ALTER TABLE store_reviews ALTER INDEX idx_user_id_created_at_visibility INVISIBLE;<br> <br>-- INDEX INVISIBLE 직후<br>INDEX_NAME                          pages   stale_pages  stale_pct<br>------------------------------------------------------------------<br>PRIMARY                            252656        245625       97.2<br>idx_user_id_created_at_visibility   21254         20874       98.2<br>idx_author_id_visibility_score      33965         32111       94.5</pre><p>이 표에서 확인할 수 있는 건 세 가지예요.</p><ul><li>pages 는 그대로인데 stale_pages 만 급증했어요. <strong>페이지가 버퍼풀에서 사라진 게 아니라, “유효하지 않음” 상태로 바뀌었어요. 그래서 페이지 수는 그대로인데 스토리지 읽기만 폭증한 거죠.</strong></li><li>같은 시각 Writer 는 stale 이 0 이었어요. <strong>Reader 쪽에서만 일어나는 무효화였어요</strong>.</li><li><strong>INVISIBLE 로 바꾼 인덱스는 하나인데, PRIMARY (데이터 페이지) 와 나머지 모든 인덱스까지 94~98% 가 stale 이 됐어요</strong>. 무효화 단위가 인덱스가 아니라 테이블 전체예요. (stale 이 100% 가 아닌 이유는, 실시간으로 조회하는 쿼리들이 해당 페이지를 다시 스토리지에서 읽어왔기 때문이에요)</li></ul><p>결과적으로 Aurora 는 INDEX INVISIBLE 작업 시 Reader 에서 버퍼풀에 있는 해당 테이블의 페이지를 “유효하지 않음” 상태로 만들게 되고, 해당 페이지 읽기 요청이 오면 스토리지에서 새로 읽어 오는 것으로 확인했어요. 이러한 동작으로 인해 우리가 처음에 겪었던 상황인 INDEX INVISIBLE 직후 Reader 에서 CPU / ReadIOPS / BufferCacheHitRatio 가 모두 튀는 상황이 발생했던 거죠.</p><h3>재현: DDL 종류별로 Reader 버퍼풀은 어떻게 반응할까</h3><p>그럼 혹시 INDEX INVISIBLE 작업 시에만 이렇게 동작하는 걸까? 아니면 다른 DDL 도 같은 문제가 발생할까? 이 답을 얻기 위해 DDL 알고리즘 종류별로 아래 7가지 케이스를 테스트했어요.</p><ul><li><strong>INSTANT</strong> : ADD COLUMN</li><li><strong>INPLACE, rebuild 없음</strong> : ALTER COLUMN … SET DEFAULT</li><li><strong>INPLACE, rebuild 있음</strong> : MODIFY COLUMN … NOT NULL</li><li><strong>COPY</strong> : MODIFY COLUMN (데이터 타입 변경)</li><li><strong>INDEX 관련 DDL : </strong>CREATE / DROP / RENAME INDEX</li></ul><p>각 케이스 모두 아래 쿼리로 버퍼풀 상태를 조회했어요. (a, b 는 SPACE 없이 조회했어요)</p><pre>SELECT GROUP_CONCAT(DISTINCT SPACE ORDER BY SPACE) AS spaces,<br>       TABLE_NAME, INDEX_NAME, COUNT(*) AS pages, ROUND(COUNT(*) * 16384 / 1024 / 1024, 1) AS size_mb,<br>       SUM(IS_STALE = &#39;YES&#39;) AS stale_pages, ROUND(SUM(IS_STALE = &#39;YES&#39;) / COUNT(*) * 100, 1) AS stale_pct<br>FROM information_schema.INNODB_BUFFER_PAGE<br>WHERE TABLE_NAME LIKE &#39;%order_logs%&#39;<br>GROUP BY TABLE_NAME, INDEX_NAME<br>ORDER BY stale_pages DESC LIMIT 30;</pre><p><strong>a. ADD COLUMN (INSTANT)</strong></p><pre># Reader 버퍼풀 상태 조회<br>reader&gt; (버퍼풀 조회 쿼리)<br>+---------------------+------------+-------+---------+-------------+-----------+<br>| TABLE_NAME          | INDEX_NAME | pages | size_mb | stale_pages | stale_pct |<br>+---------------------+------------+-------+---------+-------------+-----------+<br>| `test`.`order_logs` | idx_order  |   262 |     4.1 |           0 |       0.0 |<br>| `test`.`order_logs` | PRIMARY    |   869 |    13.6 |           0 |       0.0 |<br>+---------------------+------------+-------+---------+-------------+-----------+<br>2 rows in set (0.38 sec)<br> <br># Writer DDL 수행<br>writer&gt; alter table order_logs add column tt int,algorithm=instant;<br>Query OK, 0 rows affected (0.21 sec)<br>Records: 0  Duplicates: 0  Warnings: 0<br> <br># Reader 버퍼풀 상태 조회<br>reader&gt; (버퍼풀 조회 쿼리)<br>Empty set (0.46 sec)<br> <br>reader&gt; select * from order_logs limit 1;<br>+----+----------+---------+-------------+---------------------+------+<br>| id | order_id | action  | log_message | logged_at           | tt   |<br>+----+----------+---------+-------------+---------------------+------+<br>|  4 |    42986 | shipped | log entry 4 | 2026-03-22 11:09:07 | NULL |<br>+----+----------+---------+-------------+---------------------+------+<br>1 row in set (0.01 sec)<br> <br>reader&gt; (버퍼풀 조회 쿼리)<br>+---------------------+------------+-------+---------+-------------+-----------+<br>| TABLE_NAME          | INDEX_NAME | pages | size_mb | stale_pages | stale_pct |<br>+---------------------+------------+-------+---------+-------------+-----------+<br>| `test`.`order_logs` | idx_order  |   262 |     4.1 |           0 |       0.0 |<br>| `test`.`order_logs` | PRIMARY    |   869 |    13.6 |           0 |       0.0 |<br>+---------------------+------------+-------+---------+-------------+-----------+</pre><p><strong>→ Reader 버퍼풀 영향 없음 (stale page = 0)</strong></p><p>(DDL 직후 첫 조회가 Empty set 인 건 테이블 정의 캐시가 새로 고쳐지면서 TABLE_NAME 매핑이 잠시 비는 것이고, 캐시된 페이지 자체가 없어진 건 아니에요. SELECT ... LIMIT 1 로 테이블을 다시 열면 페이지가 그대로 보여요. 아래 케이스들도 같아요.)</p><p><strong>b. ALTER COLUMN SET DEFAULT (INPLACE / NO REBUILD)</strong></p><pre># Reader 버퍼풀 상태 조회<br>reader&gt; (버퍼풀 조회 쿼리)<br>+---------------------+------------+-------+---------+-------------+-----------+<br>| TABLE_NAME          | INDEX_NAME | pages | size_mb | stale_pages | stale_pct |<br>+---------------------+------------+-------+---------+-------------+-----------+<br>| `test`.`order_logs` | idx_order  |   262 |     4.1 |           0 |       0.0 |<br>| `test`.`order_logs` | PRIMARY    |   869 |    13.6 |           0 |       0.0 |<br>+---------------------+------------+-------+---------+-------------+-----------+<br> <br># Writer DDL 수행<br>writer&gt; alter table order_logs alter column tt set default 0,algorithm=inplace;<br>Query OK, 0 rows affected (0.01 sec)<br>Records: 0  Duplicates: 0  Warnings: 0<br> <br># Reader 버퍼풀 상태 조회<br>reader&gt; (버퍼풀 조회 쿼리)<br>Empty set (0.41 sec)<br> <br>reader&gt; select * from order_logs limit 1;<br>1 row in set (0.00 sec)<br> <br>reader&gt; (버퍼풀 조회 쿼리)<br>+---------------------+------------+-------+---------+-------------+-----------+<br>| TABLE_NAME          | INDEX_NAME | pages | size_mb | stale_pages | stale_pct |<br>+---------------------+------------+-------+---------+-------------+-----------+<br>| `test`.`order_logs` | idx_order  |   262 |     4.1 |           0 |       0.0 |<br>| `test`.`order_logs` | PRIMARY    |   869 |    13.6 |           0 |       0.0 |<br>+---------------------+------------+-------+---------+-------------+-----------+</pre><p><strong>→ Reader 버퍼풀 영향 없음 (stale page = 0)</strong></p><p><strong>c. MODIFY COLUMN … NOT NULL (INPLACE / REBUILD)</strong></p><pre># Reader 버퍼풀 상태 조회<br>reader&gt; (SPACE 포함 버퍼풀 조회 쿼리)<br>+--------+---------------------+---------------+-------+---------+-------------+-----------+<br>| spaces | TABLE_NAME          | INDEX_NAME    | pages | size_mb | stale_pages | stale_pct |<br>+--------+---------------------+---------------+-------+---------+-------------+-----------+<br>| 98989  | `test`.`order_logs` | idx_logged_at |   258 |     4.0 |           0 |       0.0 |<br>| 98989  | `test`.`order_logs` | idx_order     |   260 |     4.1 |           0 |       0.0 |<br>| 98989  | `test`.`order_logs` | PRIMARY       |   844 |    13.2 |           0 |       0.0 |<br>+--------+---------------------+---------------+-------+---------+-------------+-----------+<br>3 rows in set (0.15 sec)<br> <br># Writer DDL 수행<br>writer&gt; alter table order_logs modify column log_message varchar(256) not null, algorithm=inplace, lock=none;<br>Query OK, 0 rows affected (1.36 sec)<br>Records: 0  Duplicates: 0  Warnings: 0<br> <br># Reader 버퍼풀 상태 조회<br>reader&gt; (SPACE 포함 버퍼풀 조회 쿼리)<br>Empty set (0.26 sec)<br> <br>reader&gt; select * from order_logs limit 1;<br>+----+----------+-----------+-------------+---------------------+<br>| id | order_id | action    | log_message | logged_at           |<br>+----+----------+-----------+-------------+---------------------+<br>|  1 |    64088 | cancelled | log entry 1 | 2026-03-23 19:05:10 |<br>+----+----------+-----------+-------------+---------------------+<br>1 row in set (0.03 sec)<br> <br>reader&gt; (SPACE 포함 버퍼풀 조회 쿼리)<br>+--------+---------------------+------------+-------+---------+-------------+-----------+<br>| spaces | TABLE_NAME          | INDEX_NAME | pages | size_mb | stale_pages | stale_pct |<br>+--------+---------------------+------------+-------+---------+-------------+-----------+<br>| 98990  | `test`.`order_logs` | PRIMARY    |     2 |     0.0 |           0 |       0.0 |<br>+--------+---------------------+------------+-------+---------+-------------+-----------+<br>1 row in set (0.17 sec)</pre><p><strong>→ tablespace 변경(98989→98990)으로 Reader 에서 해당 테이블의 캐시된 페이지 전부 소멸 (예상된 동작)</strong></p><p><strong>d. MODIFY COLUMN (COPY)</strong></p><pre># Reader 버퍼풀 상태 조회<br>reader&gt; (SPACE 포함 버퍼풀 조회 쿼리)<br>+--------+---------------------+------------+-------+---------+-------------+-----------+<br>| spaces | TABLE_NAME          | INDEX_NAME | pages | size_mb | stale_pages | stale_pct |<br>+--------+---------------------+------------+-------+---------+-------------+-----------+<br>| 98423  | `test`.`order_logs` | idx_order  |   186 |     2.9 |           0 |       0.0 |<br>| 98423  | `test`.`order_logs` | PRIMARY    |   834 |    13.0 |           0 |       0.0 |<br>+--------+---------------------+------------+-------+---------+-------------+-----------+<br> <br># Writer DDL 수행<br>writer&gt; alter table order_logs modify order_id bigint not null,algorithm=copy;<br>Query OK, 221640 rows affected (6.87 sec)<br>Records: 221640  Duplicates: 0  Warnings: 0<br> <br># Reader 버퍼풀 상태 조회<br>reader&gt; select * from order_logs limit 1;<br>1 row in set (0.03 sec)<br> <br>reader&gt; (SPACE 포함 버퍼풀 조회 쿼리)<br>+--------+---------------------+------------+-------+---------+-------------+-----------+<br>| spaces | TABLE_NAME          | INDEX_NAME | pages | size_mb | stale_pages | stale_pct |<br>+--------+---------------------+------------+-------+---------+-------------+-----------+<br>| 98424  | `test`.`order_logs` | PRIMARY    |     2 |     0.0 |           0 |       0.0 |<br>+--------+---------------------+------------+-------+---------+-------------+-----------+</pre><p><strong>→ tablespace 변경(98423→98424)으로 Reader 에서 해당 테이블의 캐시된 페이지 전부 소멸 (예상된 동작)</strong></p><p><strong>e. CREATE INDEX</strong></p><pre># Reader 버퍼풀 상태 조회<br>reader&gt; (SPACE 포함 버퍼풀 조회 쿼리)<br>+--------+---------------------+---------------+-------+---------+-------------+-----------+<br>| spaces | TABLE_NAME          | INDEX_NAME    | pages | size_mb | stale_pages | stale_pct |<br>+--------+---------------------+---------------+-------+---------+-------------+-----------+<br>| 98992  | `test`.`order_logs` | idx_logged_at |   200 |     3.1 |           0 |       0.0 |<br>| 98992  | `test`.`order_logs` | PRIMARY       |   831 |    13.0 |           0 |       0.0 |<br>+--------+---------------------+---------------+-------+---------+-------------+-----------+<br>2 rows in set (0.24 sec)<br> <br># Writer DDL 수행<br>writer&gt; alter table order_logs add index idx_action(action);<br>Query OK, 0 rows affected (0.84 sec)<br>Records: 0  Duplicates: 0  Warnings: 0<br> <br># Reader 버퍼풀 상태 조회<br>reader&gt; (SPACE 포함 버퍼풀 조회 쿼리)<br>Empty set (0.28 sec)<br> <br>reader&gt; select * from order_logs limit 1;<br>+----+----------+---------+-------------+---------------------+<br>| id | order_id | action  | log_message | logged_at           |<br>+----+----------+---------+-------------+---------------------+<br>|  1 |    89836 | shipped | log entry 1 | 2026-04-09 23:50:37 |<br>+----+----------+---------+-------------+---------------------+<br>1 row in set (0.02 sec)<br> <br>reader&gt; (SPACE 포함 버퍼풀 조회 쿼리)<br>+--------+---------------------+---------------+-------+---------+-------------+-----------+<br>| spaces | TABLE_NAME          | INDEX_NAME    | pages | size_mb | stale_pages | stale_pct |<br>+--------+---------------------+---------------+-------+---------+-------------+-----------+<br>| 98992  | `test`.`order_logs` | PRIMARY       |   831 |    13.0 |         829 |      99.8 |<br>| 98992  | `test`.`order_logs` | idx_logged_at |   200 |     3.1 |         200 |     100.0 |<br>+--------+---------------------+---------------+-------+---------+-------------+-----------+<br>2 rows in set (0.23 sec)</pre><p><strong>→ Reader 에서 해당 테이블의 캐시된 페이지 전부 무효화 (⚠️ 예상 외)</strong></p><p><strong>f. DROP INDEX</strong></p><pre># Reader 버퍼풀 상태 조회<br>reader&gt; (SPACE 포함 버퍼풀 조회 쿼리)<br>+--------+---------------------+---------------+-------+---------+-------------+-----------+<br>| spaces | TABLE_NAME          | INDEX_NAME    | pages | size_mb | stale_pages | stale_pct |<br>+--------+---------------------+---------------+-------+---------+-------------+-----------+<br>| 98992  | `test`.`order_logs` | idx_logged_at |   200 |     3.1 |           0 |       0.0 |<br>| 98992  | `test`.`order_logs` | idx_order     |   186 |     2.9 |           0 |       0.0 |<br>| 98992  | `test`.`order_logs` | PRIMARY       |   831 |    13.0 |           0 |       0.0 |<br>+--------+---------------------+---------------+-------+---------+-------------+-----------+<br>3 rows in set (0.30 sec)<br> <br># Writer DDL 수행<br>writer&gt; alter table order_logs drop index idx_order;<br>Query OK, 0 rows affected (0.02 sec)<br>Records: 0  Duplicates: 0  Warnings: 0<br> <br># Reader 버퍼풀 상태 조회<br>reader&gt; (SPACE 포함 버퍼풀 조회 쿼리)<br>Empty set (0.27 sec)<br> <br>reader&gt; select * from order_logs limit 1;<br>+----+----------+---------+-------------+---------------------+<br>| id | order_id | action  | log_message | logged_at           |<br>+----+----------+---------+-------------+---------------------+<br>|  1 |    89836 | shipped | log entry 1 | 2026-04-09 23:50:37 |<br>+----+----------+---------+-------------+---------------------+<br>1 row in set (0.02 sec)<br> <br>reader&gt; (SPACE 포함 버퍼풀 조회 쿼리)<br>+--------+---------------------+---------------+-------+---------+-------------+-----------+<br>| spaces | TABLE_NAME          | INDEX_NAME    | pages | size_mb | stale_pages | stale_pct |<br>+--------+---------------------+---------------+-------+---------+-------------+-----------+<br>| 98992  | `test`.`order_logs` | PRIMARY       |   831 |    13.0 |         829 |      99.8 |<br>| 98992  | `test`.`order_logs` | idx_logged_at |   200 |     3.1 |         200 |     100.0 |<br>+--------+---------------------+---------------+-------+---------+-------------+-----------+<br>2 rows in set (0.27 sec)</pre><p><strong>→ Reader 에서 해당 테이블의 캐시된 페이지 전부 무효화 (⚠️ 예상 외)</strong></p><p><strong>g. RENAME INDEX</strong></p><pre># Reader 버퍼풀 상태 조회<br>reader&gt; (SPACE 포함 버퍼풀 조회 쿼리)<br>+--------+---------------------+---------------+-------+---------+-------------+-----------+<br>| spaces | TABLE_NAME          | INDEX_NAME    | pages | size_mb | stale_pages | stale_pct |<br>+--------+---------------------+---------------+-------+---------+-------------+-----------+<br>| 98992  | `test`.`order_logs` | idx_logged_at |   200 |     3.1 |           0 |       0.0 |<br>| 98992  | `test`.`order_logs` | idx_order     |   186 |     2.9 |           0 |       0.0 |<br>| 98992  | `test`.`order_logs` | PRIMARY       |   831 |    13.0 |           0 |       0.0 |<br>+--------+---------------------+---------------+-------+---------+-------------+-----------+<br>3 rows in set (0.24 sec)<br> <br># Writer DDL 수행<br>writer&gt; alter table order_logs rename index idx_logged_at to removed_idx_logged_at;<br>Query OK, 0 rows affected (0.02 sec)<br>Records: 0  Duplicates: 0  Warnings: 0<br> <br># Reader 버퍼풀 상태 조회<br>reader&gt; (SPACE 포함 버퍼풀 조회 쿼리)<br>Empty set (0.26 sec)<br> <br>reader&gt; select * from order_logs limit 1;<br>+----+----------+---------+-------------+---------------------+<br>| id | order_id | action  | log_message | logged_at           |<br>+----+----------+---------+-------------+---------------------+<br>|  1 |    89836 | shipped | log entry 1 | 2026-04-09 23:50:37 |<br>+----+----------+---------+-------------+---------------------+<br>1 row in set (0.02 sec)<br> <br>reader&gt; (SPACE 포함 버퍼풀 조회 쿼리)<br>+--------+---------------------+-----------------------+-------+---------+-------------+-----------+<br>| spaces | TABLE_NAME          | INDEX_NAME            | pages | size_mb | stale_pages | stale_pct |<br>+--------+---------------------+-----------------------+-------+---------+-------------+-----------+<br>| 98992  | `test`.`order_logs` | PRIMARY               |   831 |    13.0 |         829 |      99.8 |<br>| 98992  | `test`.`order_logs` | removed_idx_logged_at |   200 |     3.1 |         200 |     100.0 |<br>| 98992  | `test`.`order_logs` | idx_order             |   186 |     2.9 |         186 |     100.0 |<br>+--------+---------------------+-----------------------+-------+---------+-------------+-----------+<br>3 rows in set (0.23 sec)</pre><p><strong>→ Reader 에서 해당 테이블의 캐시된 페이지 전부 무효화 (⚠️ 예상 외)</strong></p><h4>정리하면 아래와 같아요.</h4><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*ElTCDkrCFLLuUx2RQWsLvQ.png" /><figcaption>그림 2. DDL 종류별 Reader 버퍼풀 반응 요약 (a~h) (h 는 앞 운영 실측(INVISIBLE))</figcaption></figure><p>a, b 처럼 INSTANT 혹은 테이블 rebuild 가 발생하지 않는 INPLACE DDL 은 Reader 버퍼풀에 아무 영향을 주지 않아요.</p><p>c, d 처럼 COPY 혹은 테이블 rebuild 가 발생하는 INPLACE DDL 은 테이블이 다시 만들어져 tablespace 가 바뀌므로 캐시가 사라지는 건 정상이에요.</p><p><strong>하지만 tablespace 가 그대로인 인덱스 DDL 작업 e, f, g, h 에서 테이블 전체가 무효화되는 건 예상 밖의 동작이었어요.</strong></p><h3>원인: Aurora MySQL 3.08.0 버전의 결함</h3><p>기술 지원을 통해서 위 테스트 내용을 첨부하며 “tablespace 가 바뀌지 않는 인덱스 DDL 에서 Reader 버퍼풀이 초기화되는 게 버그인가요, 의도된 동작인가요?” 를 문의했어요.</p><p>얼마 후 Aurora MySQL 서비스팀의 조사 결과가 왔어요. 요지는 이랬어요.</p><ul><li>확인 결과 INDEX DDL 작업이 Reader 에서 해당 테이블의 버퍼풀 페이지 무효화를 트리거한 게 맞고, INDEX 작업은 tablespace 와 데이터 페이지가 바뀌지 않는 metadata-only 작업이므로 <strong>이 무효화는 원래 불필요한 동작</strong>.</li><li>이 현상은 Aurora MySQL 3.08.0 에서 기능 개선 일환으로 도입되었고, INDEX INVISIBLE, CREATE INDEX, DROP INDEX 같은 DDL 에 의도치 않게 필요 이상으로 광범위한 무효화가 적용되고 있음.</li><li><strong>엔진 결함으로 식별</strong>하였고, metadata-only DDL 은 Reader 에서 테이블 정의 캐시만 새로 고치도록 하는 수정을 향후 릴리즈에 포함할 예정. 그전까지는 인덱스 DDL 을 저트래픽 시간대에 실행하기를 권고.</li></ul><h3>해결 방안</h3><p>이후 기술 지원에서 <strong>Aurora MySQL 3.14 에 위 이슈에 대한 fix 가 포함될 예정</strong>이라는 답을 받았어요.</p><p>영향을 받는 건 이 동작이 들어온 3.08.0 부터 3.14 이전까지의 버전이라, 이 구간을 쓰고 있다면 3.14 가 나온 뒤 업그레이드하는 것이 근본적인 해결이에요.</p><p>다만 3.14 이전 버전으로 백포트할 계획은 현재로서는 없다고 해요. 당근 DB팀에서는 LTS 버전인 3.10.x 를 쓰고 있기 때문에 3.14 가 나와도 바로 올리기 어려워서, fix 가 포함된 다음 LTS 를 기다리고 있어요.</p><p>그 전까지는 인덱스 DDL 을 “Reader 버퍼풀을 비우는 작업” 으로 보고 아래와 같은 기준으로 운영하면 좋을 것 같아요.</p><ul><li>건수가 많고 트래픽이 높은 테이블일수록 버퍼풀 무효화 뒤 다시 데워지는 동안 ReadIOPS 와 CPU 가 크게 튈 수 있어서, 트래픽이 적은 시간대에 실행해요.</li><li>인덱스 여러 개를 바꿀 땐 한 ALTER 문으로 묶어요. Reader 버퍼풀 무효화를 N 번에서 1 번으로 줄일 수 있어요.</li></ul><h3>마무리</h3><p>인덱스 작업 하나가 모든 Reader 의 버퍼풀을 비우는 동작은 서비스에 따라 치명적으로 느껴질 수 있어요. 다행히 AWS 가 빠르게 결함을 확인한 뒤 fix 까지 진행해 줘서, 다음 버전부터는 같은 이슈가 재발하지 않을 것으로 기대하고 있어요.</p><p>비슷한 이슈를 겪으셨거나 앞으로 겪을 수 있는 분들께 조금이나마 도움이 되면 좋겠어요.</p><p>긴 글 읽어 주셔서 감사해요.</p><h3>참고</h3><ul><li>MySQL 8.0 Reference Manual, The INFORMATION_SCHEMA INNODB_BUFFER_PAGE Table: <a href="https://dev.mysql.com/doc/refman/8.0/en/information-schema-innodb-buffer-page-table.html">https://dev.mysql.com/doc/refman/8.0/en/information-schema-innodb-buffer-page-table.html</a></li><li>MySQL 8.0.23 Release Notes, DROP/TRUNCATE 시 버퍼풀 페이지 지연 제거 (WL#14100): <a href="https://dev.mysql.com/doc/relnotes/mysql/8.0/en/news-8-0-23.html">https://dev.mysql.com/doc/relnotes/mysql/8.0/en/news-8-0-23.html</a></li><li>MySQL 8.0.24 Release Notes, INNODB_BUFFER_PAGE 에 IS_STALE 컬럼 추가: <a href="https://dev.mysql.com/doc/relnotes/mysql/8.0/en/news-8-0-24.html">https://dev.mysql.com/doc/relnotes/mysql/8.0/en/news-8-0-24.html</a></li></ul><p>당근에서 함께 데이터베이스를 고민하고 싶다면 여기를 눌러 당근 채용 공고를 확인해보세요! <a href="https://team.daangn.com/jobs/">https://team.daangn.com/jobs/</a></p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=16c6328dea50" width="1" height="1" alt=""><hr><p><a href="https://medium.com/daangn/aurora-mysql-%EC%9D%B8%EB%8D%B1%EC%8A%A4-ddl%EC%9D%B4-reader-%EB%B2%84%ED%8D%BC%ED%92%80%EC%9D%84-%EB%B9%84%EC%9A%B4%EB%8B%A4-16c6328dea50">Aurora MySQL 인덱스 DDL이 Reader 버퍼풀을 비운다?</a> was originally published in <a href="https://medium.com/daangn">당근 기술 블로그</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[한 문장으로 앱을 테스트하기까지]]></title>
            <link>https://medium.com/daangn/%ED%95%9C-%EB%AC%B8%EC%9E%A5%EC%9C%BC%EB%A1%9C-%EC%95%B1%EC%9D%84-%ED%85%8C%EC%8A%A4%ED%8A%B8%ED%95%98%EA%B8%B0%EA%B9%8C%EC%A7%80-5340546886b5?source=rss----4505f82a2dbd---4</link>
            <guid isPermaLink="false">https://medium.com/p/5340546886b5</guid>
            <category><![CDATA[engineering]]></category>
            <dc:creator><![CDATA[lily.keum]]></dc:creator>
            <pubDate>Tue, 06 Oct 2026 02:55:29 GMT</pubDate>
            <atom:updated>2026-10-06T10:44:21.180Z</atom:updated>
            <content:encoded><![CDATA[<figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*kstF51DHjmjLF7HzQqvCYQ.png" /></figure><p>안녕하세요! 당근 모바일실에서 Test Automation Engineer로 일하고 있는 Lily예요.</p><p>모바일실은 여러 팀이 당근 앱을 더 빠르고 안정적으로 만들 수 있게 그 바탕이 되는 플랫폼을 만드는 곳이에요. 그 안에서 저는 테스트 자동화를 맡고 있어요. 실제 기기에서 테스트를 실행할 수 있는 기반을 만들고, 매일 안정적으로 돌아가도록 다듬고 있어요. 각 팀이 직접 품질을 확인할 수 있게 하는 게 목표예요.</p><p>이 글에서는 QAHub를 만들면서 풀었던 문제들을 이야기해보려고 해요. 한 문장으로 시작한 요청이 실제 기기에서 돌아가는 테스트가 되기까지의 과정이에요.</p><h3>한눈에 보는 QAHub</h3><p>QAHub는 테스트를 <strong>만들고, 관리하고, 실행하는</strong> 전 과정을 담고 있어요.</p><p>처음에는 흩어진 테스트 기록을 한곳에 모으는 것부터 시작했어요. 그다음에는 QA가 아니어도 테스트를 실행할 수 있게 만들었고요. 지금은 AI가 테스트를 만들고 실행하는 데까지 확장하고 있어요.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*wG_KCojtI-kkO-OTOKwesw.png" /><figcaption>QAHub 대시보드</figcaption></figure><h3>한 문장에서 실제 기기 실행까지</h3><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*pnSPRbKJPlgQW1Os_9cWKw.png" /></figure><p>QAHub에서는 이 한 문장으로 테스트를 시작할 수 있어요.</p><p>사용자가 한 문장을 입력하면 AI가 먼저 테스트 Flow를 만들어요. 그다음 실제 기기에서 앱을 직접 조작해 테스트해요.</p><p>당근은 목적조직마다 QA를 따로 두지 않고, 각 팀이 직접 테스트하고 배포해요. 그래서 AI Tests는 누구나 쉽게 테스트를 만들고 실행할 수 있게 하는 데 초점을 맞췄어요.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*IPc9Ig-7ARQztrVnC2EUxQ.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*cmH2PLtyWj4GnelE3V2ReA.png" /></figure><p>Flow 안에는 코드 스텝과 AI 스텝이 함께 들어갈 수 있어요. 로그인이나 딥링크처럼 이미 정해진 동작은 코드로 실행하고, 화면을 보고 판단해야 하는 일은 AI가 맡아요. 탭, 입력 같은 기본 동작부터 이미지 매칭, 푸시 알림 열기, 로그 이벤트 검증까지, 스텝을 직접 골라 Flow를 만들 수도 있어요. 채팅처럼 상대가 있어야 하는 기능은 기기 두 대로 테스트해요.</p><p>만든 Flow는 저장해 다시 실행할 수 있고, 반복 검증이 필요하면 정식 리그레션 테스트로 편입해요.</p><p>물론 처음부터 한 문장만 입력하면 테스트가 돌아갔던 건 아니에요. 흩어진 테스트 기록과 케이스를 한곳에 모으고, 실제 기기를 제어하고, AI가 테스트를 끝까지 수행할 수 있는 실행 환경을 갖춰야 했죠.</p><p>이제 이 과제들을 어떻게 풀었는지, QAHub의 테스트 기록과 자동화 기반, AI 실행 구조를 차례로 소개해볼게요.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*7K5oZp-uwbafXzsFVm_NsA.png" /></figure><h3>테스트 데이터의 중앙화</h3><p>처음부터 AI 테스트를 만들 생각은 아니었어요. 가장 먼저 한 일은 여기저기 흩어져 있던 테스트 기록을 한곳에 모으는 것이었어요. 외부 툴, 노션, 구글 시트에 따로 있던 것들이요. 지금은 22개 프로젝트, 2,000개 이상의 테스트케이스가 이 위에서 관리되고 있어요.</p><p>기록을 한곳에 모은 다음에는 테스트를 만드는 과정에도 AI를 활용하기 시작했어요. 같은 기능을 테스트해도 작성자마다 적는 방식이 달랐어요. 어떤 케이스는 아주 자세했고, 어떤 케이스는 한두 줄뿐이었죠. 읽고 관리하는 데 시간이 꽤 들었어요.</p><p>그래서 만든 것이 Test Copilot이에요. 기획 문서와 GitHub 코드를 분석하고, 사람이 분석 결과를 검수하면 테스트 설계·테스트케이스·BDD 시나리오를 생성해요. 형식은 자연스럽게 통일되고, 놓치기 쉬운 테스트 범위도 함께 확인할 수 있게 됐어요.</p><p>이걸 만들면서 테스트케이스를 어떻게 써야 하는지도 다시 보게 됐어요. AI가 클릭 순서를 만들고 실행까지 할 수 있다면, 사람이 더 명확히 적어야 할 건 “어떤 순서로 누르는가(How)”보다 “무엇이 지켜져야 하는가(What)”라고 생각해요.</p><p>지금은 클릭 순서를 나열하기보다, 제품과 기능이 반드시 보장해야 할 것을 테스트 명세에 적고 있어요.</p><h3>자동화 실행 환경의 표준화</h3><p>QAHub 이전에도 코드 기반 자동화 테스트는 있었어요. 자동화 테스트가 있다고 해서 누구나 바로 실행할 수 있는 건 아니었어요. 어떤 저장소를 열어야 하는지, 어떤 스크립트를 골라야 하는지, 어떤 빌드와 기기를 써야 하는지 아는 사람만 실행할 수 있었어요.</p><p>그래서 QAHub에서 누구나 테스트를 돌릴 수 있게 만들었어요. 저장소나 스크립트를 몰라도, 버튼 몇 번이면 돼요. 시나리오를 고르고 플랫폼(Android·iOS·Web), 빌드 타입, 로케일(KR·JP·CA·US)을 선택하면 실제 기기에서 테스트가 실행돼요.</p><p>결과는 테스트케이스 단위로 QAHub에 쌓이고, 실패한 케이스에는 스텝별 스크린샷과 레코딩 영상이 함께 남아요.</p><p>이 테스트들은 매일 아침 최신 빌드에서 자동으로 실행되고, 필요할 때는 버튼 하나로 다시 실행할 수 있어요.</p><h3>Self-healing 기반 실행 안정화</h3><p>갑자기 뜨는 팝업이나 바뀐 Selector 때문에 테스트가 멈추면, 제품에 문제가 있는지 확인하기 어려워요. Selector는 화면에서 버튼 같은 요소를 찾는 주소예요. 앱 코드가 바뀌면 이 주소도 달라질 수 있어요.</p><p>팝업이 생길 때마다 코드를 추가하고, Selector가 바뀔 때마다 수정하기엔 손이 너무 많이 가요. 팝업은 계속 새로 생기고 나라마다 문구도 다르니까요. 매번 사람이 고치지 않아도 테스트를 이어갈 수 있게, 처음부터 Self-healing을 넣었어요.</p><p>복구는 막힌 이유에 따라 세 가지로 나뉘어요.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*CbVCO5Uo_jVLpeKAANee8Q.png" /></figure><p>Selector Healing은 버튼 이름이 바뀌거나, 겉모습은 그대로여도 앱 코드가 달라져 기존 Selector로 요소를 찾지 못할 때 동작해요.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*TJZHnBGPOcnwuhaCBRC1Sg.png" /></figure><p>직전 스텝도 함께 보내요. “방금 글쓰기 화면에 들어왔다”는 걸 알면 AI가 찾아야 할 요소를 더 정확히 짚을 수 있거든요.</p><p>화면에 보이는 글자로 찾기 때문에 언어가 달라도 괜찮아요. 코드에는 ‘제목’이라고 적혀 있어도 일본어 화면에서는 ‘タイトル’를 찾아요.</p><p>복구에 성공하면 “실패한 Selector → 추천 Selector”가 슬랙으로 와요. 테스트는 먼저 실행을 이어가고, 나중에 사람이 추천된 Selector를 코드에 반영할 수 있어요.</p><p>Popup Healing은 동작할 때마다 화면을 다시 확인해요. 동네 인증처럼 테스트가 거쳐야 하는 화면은 닫지 않고 진행해요.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*IURdVt5nul5tqIImlmCniQ.png" /></figure><p>실제 사례를 하나 볼게요. ‘나의 당근’ 탭을 열려는데 시스템 권한 창이 화면을 가리고 있었어요. AI가 ‘허용’을 눌러 창을 넘기고, 화면에 보이는 ‘나의 당근’ 글자로 탭을 다시 찾아 테스트를 끝까지 통과시켰어요.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*uNgfW30VizpBemH4EfVtUw.png" /></figure><p>복구했다고 그냥 통과시키지는 않아요. AI가 버튼을 대신 눌렀다면 다음 화면이 기대와 맞는지 다시 확인하고, 다르면 실패로 기록해요. 스텝마다 Self-healing을 쓸지 말지도 코드에서 정할 수 있어요.</p><p>테스트는 한 달에 3,000건 넘게 실행돼요. 8월에는 실행 중 복구가 필요했던 테스트 가운데 90% 넘게는 사람의 개입 없이 끝까지 통과했어요.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*XUIHb07EYcSD6ScPaLWZog.png" /><figcaption>8월 복구 후 통과율</figcaption></figure><p>여기까지 만들고 나니, 다음 고민은 코드만으로 자동화하기 어려웠던 영역까지 어떻게 가져올 것인가였어요.</p><h3>코드 기반 자동화와 AI의 결합</h3><p>기존에는 Appium과 Selenium으로 모바일 앱과 웹을 자동화해 왔어요. 정해진 동작을 반복하는 테스트는 코드로 빠르고 안정적으로 실행할 수 있었어요. 다만 화면을 보고 판단해야 하는 동작은 구현과 유지보수에 드는 비용이 커서 자동화하기 어려웠어요.</p><p>화면을 보고 판단해야 하는 동작은 AI에게 맡겨보기로 했어요. Self-healing에서는 AI가 막힌 순간에만 개입했다면, 이번에는 테스트 동작 자체를 맡겼어요. AI가 화면을 해석하고 다음 동작을 스스로 결정하도록 한 거예요.</p><p>전체를 맡겨보니 실행 시간이 길어지고 비용도 늘었어요. 같은 테스트에서도 버튼을 찾지 못해 멈추는 등 결과가 일정하지 않았고요.</p><p>반복해서 실행하려면 실행 시간을 줄이고 결과도 일정하게 만들어야 했어요. 그래서 로그인이나 화면 이동처럼 이미 빠르고 안정적으로 자동화된 동작은 코드 스텝으로 실행하고, 화면을 보고 판단하거나 탐색해야 하는 부분만 AI 스텝으로 실행하기로 했어요.</p><p>문제는 AI 스텝을 반복할 때였어요. 매번 같은 판단을 다시 시키면 실행 시간과 비용이 늘고, 결과도 일정하지 않았어요. 한 번 성공한 경로를 다음 실행에서 다시 쓸 수 있게 만들었어요.</p><h3>Path Replay 기반 AI 실행 경로 재사용</h3><p>AI가 한번 성공한 길을 다음에도 쓰기로 했어요. 어떤 화면에서 무엇을 눌렀는지, 그때 화면이 어떻게 생겼는지를 함께 저장해요. 다음 실행에서 이 경로를 그대로 재생하는 기능이 Path Replay예요. 개별 글자나 사진보다 화면의 전체적인 구조를 기억해서, 게시글 내용이나 닉네임이 달라져도 배치가 같으면 같은 화면으로 인식할 수 있어요. 화면 스크린샷을 64비트 지문으로 줄여 비교하고, 다른 비트가 10개 이하면 같은 화면으로 봐요.</p><p>다음 실행에서 같은 화면을 만나면, AI를 다시 호출하는 대신 저장한 경로를 그대로 재생해요. 이때는 AI 호출 비용 없이 더 빠르게, 매번 같은 방식으로 움직여요.</p><p>화면이 달라졌다면 저장된 경로를 쓰지 않고, AI가 다시 길을 찾아요.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*pavxWAHfIorrLU1Jfa3tAg.png" /></figure><p>비슷하게 생긴 화면을 잘못 인식하는 경우를 막는 안전장치도 있어요. 기억한 동작을 실행하기 전에 실제로 대상 요소가 예상한 위치에 있는지 다시 확인해요. 맞지 않으면 기존 경로를 사용하지 않고 AI에게 판단을 넘겨요.</p><p>성공한 스텝만 저장해요. 잘못 학습된 경로는 사람이 직접 지울 수 있게 했고요. 로케일이 바뀌면 화면 지문도 달라지기 때문에, 경로는 로케일별로 따로 쌓여요.</p><p>8월 기준 리그레션에 등록된 AI 스텝 80개 중 35개(44%)가 이미 실행 경로를 기억하고 있어요. 이 35개 스텝은 AI 호출 없이 수 초 안에 재생되고, 나머지 45개에서만 AI를 호출해요.</p><p>효과는 같은 시나리오를 반복해서 돌릴 때 가장 잘 보여요. 시나리오 하나를 예로 들어 볼게요.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*akuq13KVCpqxTN9bFpsSKQ.png" /></figure><p>실행을 반복할수록 기억한 경로가 늘어나면서, AI 호출은 5분의 1 수준으로, 실행 시간은 3분의 1 수준으로 줄었어요.</p><p>코드로 만들고 유지하기 어려워 자동화를 미뤄두었던 화면들도, 이 방식으로 하나씩 자동화하고 있어요.</p><p>테스트가 지나간 화면도 모으고 있어요. 이렇게 앱 전체의 화면 카탈로그를 만들어요. 카탈로그에 없는 화면을 만나면 진입 경로와 함께 새 화면 후보로 제안하고, 실제 등록 여부는 사람이 판단해요.</p><h3>페르소나 기반 사용성 검증</h3><p>AI Tests에는 페르소나를 붙일 수도 있어요.</p><p>페르소나는 나이와 상황, 당근을 사용하는 목적을 설정한 가상의 사용자예요. AI는 그 설정에 맞춰 앱을 사용하면서 문구를 이해할 수 있는지, 다음 행동을 찾기 쉬운지 같은 점도 살펴봐요.</p><p>예를 들어 “이 유저가 이 문구를 이해할 수 있을까?”, “다음에 무엇을 해야 할지 바로 알 수 있을까?” 같은 것들이에요.</p><p>이런 관찰이 테스트의 성공/실패를 결정하지는 않아요. 별도의 참고 리포트로 남기고, 최종 판단은 사람이 해요.</p><p>기능이 동작하는지 확인하는 데 더해, 페르소나로는 사용 중 헷갈릴 만한 문구나 화면도 살펴볼 수 있어요.</p><h3>실패 분석과 디버깅 자동화</h3><p>자동화를 운영해보면 실행 자체보다 실패 원인을 찾는 데 가장 많은 시간이 들어요. “실패했다”는 결과만 남으면 사람이 다시 화면과 로그를 뒤져야 하거든요.</p><p>그래서 QAHub의 결과 화면은 실패 원인을 빠르게 찾기 위한 디버깅 도구로 만들었어요.</p><ul><li><strong>스텝별 스크린샷 리플레이</strong>: 실행 과정을 장면 단위로 되짚어보며 어디서부터 예상과 달라졌는지 확인해요.</li><li><strong>실패 영상</strong>: 실패 직전부터 종료까지 필요한 구간만 잘라 자동으로 첨부해요.</li><li><strong>스텝별 AI 판단 기록</strong>: AI가 무엇을 보고 왜 그 행동을 선택했는지 남겨요.</li><li><strong>AI 실패 분석</strong>: 무엇을 하려다 어디서, 왜 막혔는지와 문제의 성격이 무엇인지 짧게 정리해요.</li></ul><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*9K4c7qEyoOI8DuK4u9UCzA.png" /></figure><p>AI 서버가 잠시 응답하지 않아 멈춘 테스트가 있었어요. 분석 AI는 원인을 “탭 상태 인식이 지연됐다”고 설명했어요. 그럴듯했지만 실제 원인과 달랐어요.</p><p>이런 오진을 줄이려고 분석 AI에게는 스텝별 판단 기록과 실제 에러만 근거로 줬어요. 기록에 없는 내용은 추정하지 않도록 했어요.</p><p>실패 원인을 설명할 때는 실제로 남은 기록 안에서 말해야 한다고 생각했어요. 그럴듯한 오진을 따라가면 원인을 찾는 데 오히려 더 오래 걸릴 수 있으니까요.</p><h3>마치며</h3><p>한 문장으로 앱을 테스트한다는 건, 한 문장만으로 되는 일이 아니었어요. 흩어진 기록을 모으고, 누구나 실행할 수 있는 환경을 만들고, 실행 중 막힌 테스트가 다시 이어질 수 있게 한 뒤에야 AI가 그 위에서 움직일 수 있었어요.</p><p>그 과정에서 몇 가지 원칙이 분명해졌어요.</p><ul><li><strong>데이터를 먼저 모을 것.</strong> 흩어져 있던 기록이 한곳에 쌓이자 테스트 생성부터 실패 분석까지 같은 데이터를 활용할 수 있었어요.</li><li><strong>AI를 어디에 쓸지보다 어디에 쓰지 않을지를 정할 것.</strong> 정해진 동작은 코드로 빠르고 안정적으로 실행하고, 화면을 보고 판단해야 하는 부분에만 AI를 활용했어요.</li><li><strong>실패를 다음 실행에 반영할 것.</strong> AI가 불안정하게 움직이거나 실패 원인을 잘못 설명할 때마다, 같은 문제가 반복되지 않도록 규칙과 안전장치를 보탰어요.</li></ul><p>그렇게 쌓인 건 실행 결과만이 아니에요. 저장한 Flow는 리그레션 자산이 되고, AI가 찾은 경로는 다음 실행의 기억이 되고, 거쳐 간 화면은 앱의 지도가 돼요. <strong>테스트를 돌릴수록 다음 테스트가 쉬워지는 구조예요.</strong></p><p>앞으로는 한 문장으로 시작한 테스트가 자연스럽게 리그레션 자산으로 이어지는 구조를 더 단단하게 만들어가려고 해요. 다음 글에서는 이 방식이 실제 테스트를 어떻게 바꿨는지, 더 많은 숫자로 이야기해볼게요.</p><p>당근 모바일실에서는 이런 문제를 함께 풀어 갈 동료를 찾고 있어요. 테스트 자동화와 AI로 여러 팀이 스스로 품질을 확인할 수 있게 돕는 일에 관심이 있다면, <a href="https://about.daangn.com/jobs/"><strong>당근 채용 공고</strong></a>를 확인해 주세요.</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=5340546886b5" width="1" height="1" alt=""><hr><p><a href="https://medium.com/daangn/%ED%95%9C-%EB%AC%B8%EC%9E%A5%EC%9C%BC%EB%A1%9C-%EC%95%B1%EC%9D%84-%ED%85%8C%EC%8A%A4%ED%8A%B8%ED%95%98%EA%B8%B0%EA%B9%8C%EC%A7%80-5340546886b5">한 문장으로 앱을 테스트하기까지</a> was originally published in <a href="https://medium.com/daangn">당근 기술 블로그</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[당근 동네걷기 위에서 동시에 쏟아지는 오버레이들을 우아하게 제어하기]]></title>
            <link>https://medium.com/daangn/%EB%8B%B9%EA%B7%BC-%EB%8F%99%EB%84%A4%EA%B1%B7%EA%B8%B0-%EC%9C%84%EC%97%90%EC%84%9C-%EB%8F%99%EC%8B%9C%EC%97%90-%EC%8F%9F%EC%95%84%EC%A7%80%EB%8A%94-%EC%98%A4%EB%B2%84%EB%A0%88%EC%9D%B4%EB%93%A4%EC%9D%84-%EC%9A%B0%EC%95%84%ED%95%98%EA%B2%8C-%EC%A0%9C%EC%96%B4%ED%95%98%EA%B8%B0-097551289b2a?source=rss----4505f82a2dbd---4</link>
            <guid isPermaLink="false">https://medium.com/p/097551289b2a</guid>
            <category><![CDATA[engineering]]></category>
            <category><![CDATA[frontend-engineering]]></category>
            <dc:creator><![CDATA[camel]]></dc:creator>
            <pubDate>Wed, 30 Sep 2026 03:32:44 GMT</pubDate>
            <atom:updated>2026-09-30T03:32:43.623Z</atom:updated>
            <content:encoded><![CDATA[<figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*_41vp2UXyAElE82Vy22kSA.jpeg" /></figure><h3>들어가며</h3><p>안녕하세요. 당근 로컬비즈니스실 동네걷기팀에서 FE 인턴으로 일하고 있는 카멜(진나영)이에요. 당근 <strong>동네걷기</strong>는 걸음 수를 기록하고 보상을 제공하는 서비스예요. 그런데 모달, 바텀시트, OS 권한 팝업이 각기 다른 조건에서 열리다 보니, <strong>한 화면에 여러 UI가 겹쳐 나타나는 문제</strong>가 있었어요.</p><p>이 글은 제가 이 복잡도를 어떻게 풀었는지에 대한 기록이에요. 다만 “이렇게 만들었습니다” 보다는, 어떻게 문제를 정의하고 고민하며 해결해나갔는지 서술해보려고 해요.</p><p><strong>이런 분들이 읽으면 재미있을 것 같아요</strong></p><ul><li>한 화면에 오버레이를 띄우는 조건이 계속 늘어나서 onClose 체이닝이 감당이 안 되기 시작한 분</li><li>웹뷰-네이티브 환경에서 <strong>웹뷰가 제어할 수 없는 OS 팝업</strong>까지 같은 흐름으로 다루고 싶은 분</li><li>비동기 스케쥴링에 대한 이해가 실무에 어떻게 활용될 수 있을지 궁금한 분</li></ul><p><strong>나올 키워드들</strong></p><p>문제 재정의 · 추상화 단위(데이터 vs 함수) · Promise deferred 패턴 · ES2024 Promise.withResolvers · 실행 컨텍스트와 렉시컬 환경 · Job(ECMAScript의 비동기 작업 단위) · queueMicrotask</p><h3>1. 동네걷기 홈은 오버레이가 참 많아요</h3><figure><img alt="" src="https://cdn-images-1.medium.com/max/800/1*QgAY5Z2f59CV6N_yd2EFSg.gif" /></figure><p>동네걷기 홈은 도메인 요구사항 대부분이 모달, 바텀시트 같은 오버레이로 나타나는 화면이에요. 페이지에 들어가자마자 뜰 수 있는 것만 적어볼게요.</p><ul><li>OS 신체활동 권한 팝업 (<strong>네이티브가 띄워요. 웹뷰가 제어할 수 없어요</strong>)</li><li>신체 활동 권한 안내 (웹뷰 UI)</li><li>앱 알림 설정 유도 (웹뷰 UI)</li><li>온보딩 가이드 (코치마크 → 0걸음 보상 → 획득 애니메이션)</li><li>걸음 수 알림 모달</li><li>연속 방문 모달</li><li>그때그때 추가/제거되는 프로모션 오버레이들</li></ul><p>문제는 이 오버레이 컴포넌트들이 <strong>서로의 존재를 전혀 모른 채 독립적으로 마운트되고, 각자의 조건을 보고 발화한다</strong>(스스로 뜨려고 함)는 거였어요. 이 오버레이들은 유저가 눌러서 뜨지 않아요. 웹뷰 바깥 맥락(앱버전, 네이티브 권한 상태, 서버 응답, 정책 조건 등)이 충족되면 스스로 발화해요.</p><p>그런데 사용자에게는 반드시 <strong>한 번에 하나씩, 앞의 오버레이가 닫힌 다음에</strong> 그 다음 오버레이가 보여야 하잖아요. 모달과 바텀시트, 토스트가 한꺼번에 뜨면 사용자는 무엇부터 확인해야 할지 혼란스러우니까요.</p><p>이전까지의 해법은 컴포넌트끼리 직접 연결하는 거였어요.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*ryKZqBWB8Wa8q_Sm.png" /></figure><p>이 구조의 비용은 꽤 분명했어요.</p><ul><li><strong>각 컴포넌트가 “자기 다음에 뭐가 와야 하는지”를 알아야 해요.</strong> 오버레이 하나가 다른 오버레이의 존재를 알고 있어야 순서가 성립하니까요.</li><li>onClose, onAnimateEnd 핸들러가 prop으로 계속 뚫려요.</li><li>새 이벤트성 오버레이가 중간에 끼어들면 <strong>체인을 끊고 다시 이어야 해요.</strong> 기후캠페인처럼 한시적 프로모션이 붙을 때마다 전체 분기와 prop 핸들러를 손대야 했어요.</li><li>도메인 로직 사이에 맥락을 알기 어려운 분기 조건이 섞여요. 그리고 이게 QA 사각지대가 돼요. 발화 조건 조합의 경우의 수만큼 순서가 깨질 수 있으니까요.</li><li>가장 큰 문제는 플래그였어요. 서로의 비동기 데이터가 준비됐는지, 조건을 평가했는지 등을 알리기 위한 플래그가 하나 둘 늘었고, 흐름은 점점 읽기 어려워졌어요.</li></ul><h3>2. 근데 진짜 골치는 웹뷰 밖에 있었어요</h3><p>여기에 문제는 하나 더 있었어요. 동네걷기는 웹뷰라서, 유저의 걸음 수를 가져오려면 네이티브 브릿지를 통해야 해요. 그런데 이 호출은 <strong>OS 시스템 권한 팝업을 띄워요.</strong></p><figure><img alt="" src="https://cdn-images-1.medium.com/max/819/1*PQrFCw-ks-uEIXXVCqtcUA.png" /></figure><pre>await bridge.getStepCount({})<br>// 권한을 처음 요청할 때면 OS가 시스템 팝업을 띄우고,<br>// 사용자가 응답할 때까지 이 Promise는 pending</pre><p>이 팝업은 웹뷰가 띄우는 게 아니에요. 그래서 웹뷰가 닫을 수도, 지금 떠 있는지 조회할 수도 없어요. 웹뷰가 알 수 있는 유일한 신호는 <strong>브릿지가 돌려준 Promise가 팝업이 떠있는 동안은 pending이라는 사실</strong> 하나뿐이었어요.</p><p>즉 “지금 화면 위에 뭔가 떠 있다”를 표현할 수 있는 채널이 Promise의 상태밖에 없었던 거예요.</p><p>이 쯤에서 “오버레이 복잡도는 overlay-kit 같은 기존 라이브러리를 쓰면 되는 거 아닌가?”라고 궁금하실 수 있을 것 같아요. 하지만 이런 라이브러리가 푸는 건 “어떻게 띄울지”예요. 저희 문제는 ‘오버레이를 지금 띄워도 되는지’였어요.</p><ul><li>open() 호출 순서가 그대로 노출 순서가 돼요. 그런데 우리는 그 호출 순서가 곧 서버 응답 도착 순서라 매번 달랐어요. 라이브러리를 깔아도 “지금 떠도 되나”를 판단하는 플래그는 그대로 남아요.</li><li>웹뷰 밖 오버레이를 웹뷰는 알 수 없어요. 걸음수 세기 브릿지가 띄우는 OS 권한 팝업은 React 트리 밖이라 어떤 오버레이 라이브러리도 그 pending을 알 수 없었어요.</li><li>순서는 도메인 정책이라 라이브러리 밖에 남아요. “권한 결손은 진행 중인 걸 밀어낸다” 같은 건 어떤 라이브러리도 대신 정해주지 않아요.</li></ul><h3>3. 처음엔 “오버레이를 한 곳에 등록하자” 였어요</h3><p><strong>눈에 보이는 문제가 오버레이니까, 오버레이를 한 곳에 등록해서 제어하자.</strong></p><p>처음엔 ebay/nice-modal-react처럼 컴포넌트를 전역에 register해두고 호출처는 id로 부르되, 거기 없는 우선순위 큐잉을 얹는 형태를 생각했어요. 그렇게 버디한테 공유했던 멘탈 모델은 이랬어요.</p><blockquote><em>오버레이는 우체국 창구처럼 줄을 서요. 한 번에 한 명만 창구에 있고, 다음 사람은 앞 사람이 끝나야 들어와요. </em><strong><em>VIP는 새치기할 수 있고, VIP가 오면 지금 창구에 있는 사람은 즉시 자리를 비워요.</em></strong></blockquote><p>처음 그린 설계는 지극히 자연스러운 생각의 흐름이었어요.</p><p>그리고 이 비유는 결국 최종 설계까지 그대로 남았지만, <strong>줄을 서는 대상</strong>은 완전히 바뀌게 돼요.</p><h3>4. 버디가 기존 설계의 트레이드오프를 짚어줬어요</h3><p>설계 리뷰에서 돌아온 피드백은 이거였어요.<br><strong>도메인과 강하게 결합된 오버레이가 전역 레이어로 끌어올려지면 응집도가 깨진다.</strong></p><p>이 피드백을 계속 곱씹어보니, register 방식의 매력이었던 “한눈에 보는 제어”에는 구체적으로 이런 비용이 붙어있었어요.</p><ul><li><strong>로직과 UI가 서로 다른 곳에 정의돼요.</strong> 호출처는 register만 하고 실제 렌더는 전역 호스트에서 일어나요. 오버레이 하나를 이해하려면 호출처 / 렌더 맵 / 정책 카탈로그 세 곳을 따라가야 흐름이 보여요.</li><li><strong>도메인 로직이 전역 state로 흘러가요.</strong> 호출처가 navigation이나 보상 처리 함수를 payload에 담아 등록하면, 그 클로저가 전역 state에 보관된 채 호스트에서 호출돼요. 호출처가 언마운트되거나 리렌더돼도 클로저는 <strong>등록 시점 스냅샷 그대로</strong> 살아남아요.</li><li><strong>변경 영향 범위가 커져요.</strong> 새 오버레이 하나 추가하려면 호출처, 렌더 맵, 정책 카탈로그를 다 건드려야 해요.</li></ul><p>그러니까 “한눈에 보는 제어”는 <strong>여러 도메인 맥락이 전역 레이어로 올라와 섞이는 비용</strong>을 치르고 사는 거였어요. 그러고 나니 제가 문제를 보는 시각 자체에 찜찜함이 남았어요.</p><p><em>애초에 내가 문제를 너무 좁게 정의한 건 아닐까?</em></p><h3>5. 문제 재정의</h3><p>문제 정의 자체에 대한 의심은 들었지만 그렇다고 쉽게 추상화가 되지는 않았어요. 그래서 의식적으로 큰 맥락을 보려고, 하루를 통째로 마인드맵 그리는 데 썼어요. 모든 생각에 의문을 달고, 그 의문에 다시 생각을 다는 식으로요.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*MSFWLjpkvwR8qz4nNyG_iA.png" /></figure><p>그렇게 한참을 째려보다가 도달한 재정의는 이거였어요.</p><blockquote><strong>‘복잡도의 본질은 오버레이가 아니라, 여기저기서 제멋대로 시작되는 비동기 작업이었구나!’</strong></blockquote><p>오버레이는 그 작업이 지금 화면에 드러나는 모양일 뿐이었어요.</p><p>같은 발화가 다른 도메인에서는 토스트일 수도, 네이티브 팝업일 수도, 화면 전환일 수도 있어요. 제가 줄을 세워야 하는 건 UI가 아니라 <strong>오버레이 발화 그 자체</strong>였던 거예요.</p><h3>6. 재정의하고 나니 해법이 따라왔어요</h3><p>문제를 “비동기 발화의 동시성 제어”라고 재정의 하는 순간, 해법이 거의 동시에 생각났어요. 자바스크립트에는 이미 비동기 동시성을 우아하게 다루라고 만들어둔 상태머신이 있잖아요.<br><strong>Promise와 async/await</strong>요.</p><p>그리고 오버레이의 생명주기가 Promise의 상태에 거짓말처럼 맞아떨어졌어요.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*okhiEpCJQGk_J7Xi.png" /></figure><p>여기서부터 쓸 용어를 먼저 정리할게요. 큐에 줄 세우는 단위를 엔트리(entry), 엔트리가 들고 있는 비동기 함수를 trigger(코드에서는 fn 필드), 지금 실행 중인 엔트리를 active라고 부를게요. 또, 호출처가 큐에 엔트리를 넣는 건 예약(reserve) 이라고 할게요.</p><p><strong>오버레이가 떠 있다 = 그 엔트리의 Promise가 pending 상태다.</strong></p><p>그렇다면 큐 동기화를 명령형으로 짤 이유가 없어요. 이 한 줄이면 되니까요.</p><pre>await entry.fn()  // 오버레이가 닫힐 때까지 이 줄에서 멈춰 있어요<br>// 이 await이 풀리는 순간(= 사용자가 닫아서 resolve가 호출된 순간) 큐가 다음 엔트리로 넘어가요</pre><p>Promise 는 비동기의 진행 상태를 알려주는 JS의 상태 머신이에요. 실행 순서는 마이크로태스크 큐가 정하며, 코드 실행을 기다렸다가 다시 이어하는 복잡한 제어는 await 과 브라우저 런타임이 책임져요.</p><p>호출처가 달라지는 모습은 이래요. 각 컴포넌트는 자기 우선순위만 얹어서 <strong>예약</strong>하고, 줄 세우기는 큐가 해요.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*XXSO4h9zK7tLbVfT.png" /></figure><h4>추상화 단위를 데이터에서 함수로</h4><p>큐에 줄 세우는 대상을 오버레이 정보(데이터)에서 실행할 일(함수)로 바꿨어요. 큐는 그 안에서 뭘 하는지 모르고, 끝날 때까지 기다리기만 해요.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*vbLnII01ndHrGMd3505Y1w.png" /></figure><p>여기서 재밌었던 게, 아이디어가 정해지고 나서 코드베이스를 살펴보니 <strong>팀에 이미 같은 문제를 다룬 선행 설계(OverlayManager)가 있었다</strong>는 거예요. 출발한 문제 정의도, 우선순위 큐라는 아이디어까지도 비슷했는데 추상화 단위가 달랐어요. 그쪽은 오버레이를 정책을 가진 <strong>이벤트 객체</strong>로 표현하는 데이터 중심 모델이었거든요.</p><p>그래서 이걸 “다른 방식”이 아니라 <strong>“그 기반 위에서 추상화 단위를 한 칸 올린 형태”</strong> 로 포지셔닝했어요. 자료구조와 정렬 알고리즘, dedupe 개념은 거의 그대로 차용하고, 상태 관리 모델만 바꿨어요.</p><p>이 한 칸 덕분에 별도 메커니즘 없이 풀리는 영역이 세 개 생겼어요.</p><h4>(1) 웹뷰 밖 비동기, 그러니까 네이티브 팝업의 통합</h4><p>이벤트 객체 모델에서는 OS 팝업이 떠 있는 동안 큐가 그 사실을 알 채널이 없어요. 브릿지 호출과 응답이 큐 바깥에서 처리되고, 결과를 받은 뒤에 다시 이벤트로 등록하는 식으로 우회해야 해요. 그동안 큐는 다른 엔트리를 active로 승급시킬 수 있고, 그러면 OS 팝업과 웹뷰 모달이 겹쳐 보여요.</p><p>함수 단위에서는 그냥 trigger 안에서 await 하면 돼요.</p><pre>reserve(&#39;high&#39;, async () =&gt; {<br>  await bridge.getStepCount({})  // OS 팝업 떠 있는 동안 pending<br>  // 응답 후 후속 처리<br>})</pre><p><strong>브릿지 Promise의 pending 기간이 그대로 큐의 active 기간이 돼요.</strong> 웹뷰가 제어할 수는 없지만, 떠 있는 동안 기다릴 수는 있게 된 거예요. 2장에서 “웹뷰가 알 수 있는 신호가 Promise뿐”이라고 했던 게 이 구조에서는 자산이 됐어요.</p><h4>(2) 복합 비동기 플로우의 원자성</h4><p>권한 요청 → 결과 → 분기 모달 같은 흐름은 본질적으로 여러 비동기 단계가 묶인 하나의 시퀀스예요. 데이터 모델에서는 이걸 큐 바깥의 콜백으로 엮어야 하고, 콜백 안에서 다시 등록하는 중첩 구조가 생기고, 중간에 다른 오버레이가 끼어들면 흐름이 깨져요.</p><p>함수 단위에서는 await 체이닝 한 번이에요.</p><pre>reserve(&#39;high&#39;, async () =&gt; {<br>  const result = await bridge.requestPermission()<br>  if (result === &#39;granted&#39;) await grantedModal.open()<br>  else await deniedModal.open()<br>})</pre><p>이 trigger의 Promise가 settled되기 전에는 큐의 다른 엔트리가 시작되지 않아요. <strong>흐름 전체가 큐의 한 단위</strong>가 되면서 다른 trigger와 섞이지 않아요.</p><h4>(3) 호출처 응집도 보존</h4><p>trigger 함수가 호출처 컴포넌트 안에 머무니까, UI 렌더와 도메인 로직도 거기 머물러요.</p><pre>function PedometerPageExit() {<br>  const modal = useReserveOverlay(OVERLAY_PRIORITY.UNCLAIMED_REWARD_ALERT)<br>  const { pop } = useNavigator()</pre><pre>const handleExit = async () =&gt; {<br>    if (rewardableMilestones) {<br>      await modal.open()  // 사용자가 닫을 때까지 기다려요<br>      pop()               // navigation이 호출처 클로저에 머물러 있어요<br>    } else {<br>      pop()<br>    }<br>  }<br>  return modal.isOpen<br>    ? &lt;UnclaimedRewardAlert onClose={modal.close} /&gt;<br>    : null<br>}</pre><p>pop이 전역 state로 흘러가지 않으니 노출 범위가 줄어요. 큐는 priority랑 trigger 함수만 받으니까 도메인 로직과 UI는 큐의 책임 영역 밖에 남고요.</p><h3>7. 그런데 Promise의 resolve를 밖에서 불러도 되나요?</h3><p>여기까지 오면 구현상 걸리는 지점이 하나 생겨요. Promise는 <strong>settle 제어권(resolve/reject 호출)이 생성자 콜백 안에 갇혀 있어요.</strong> fetch 응답처럼 결과가 자체적으로 도출되는 경우엔 문제가 없는데, <strong>사용자의 닫기 클릭</strong>으로 settle을 일으켜야 하는 우리 케이스에는 부족하잖아요.</p><p>찾아보니 이건 자바스크립트 생태계가 오래 겪어온 고민이었고, 그만큼 오래된 해법이 있었어요. <strong>deferred 패턴</strong>이라고 불렸는데, 생성자 콜백 안에서 resolve/reject를 외부 변수에 캡처해두고 나중에 밖에서 호출하는 방식이에요. jQuery의 $.Deferred, Bluebird의 Promise.defer, Q 라이브러리까지 여러 구현체가 같은 형태를 제공해왔더라고요.</p><p>이러한 Promise 내부의 제어권을 외부에 노출하는 패턴은 캡슐화를 해친다는 우려가 있었어요.</p><p>하지만 복잡한 비동기 상태 전이를 다룰 때 이 구조가 얼마나 자주 필요한지 커뮤니티가 증명해 냈고, 결국 <strong>ES2024에서 Promise.withResolvers라는 정식 스펙으로 편입</strong>되었어<strong>요</strong>.</p><p>표준화가 이 패턴의 완벽함을 보증하는 것은 아니에요. 다만, 자바스크립트 환경에서 비동기 상태 머신을 더 유연하게 통제하려는 실무적 요구사항이 그만큼 지배적이었음을 보여주는 증거였어요.</p><p>Promise.withResolvers 라는 API 는 명세상 다음 코드와 동등해요.</p><pre>let resolve, reject;<br>const promise = new Promise((res, rej) =&gt; {<br>  resolve = res;<br>  reject = rej;<br>});</pre><p>이 말인즉, 구현 비용이 거의 없다는 뜻이기도 해요. 브라우저 지원 범위 때문에 폴리필을 붙일지 잠깐 고민했지만, 9줄짜리 헬퍼로 끝나는 일이라 의존성을 늘릴 이유가 없다고 판단했어요.</p><pre>// withResolvers.ts<br>export function withResolvers&lt;T&gt;() {<br>  let resolve!: (value: T) =&gt; void;<br>  let reject!: (reason?: unknown) =&gt; void;<br>  const promise = new Promise&lt;T&gt;((res, rej) =&gt; {<br>    resolve = res;<br>    reject = rej;<br>  });<br>  return { promise, resolve, reject };<br>}</pre><p>이 시스템의 큐 동기화는 전부 이 패턴 위에 서 있어요.</p><h3>8. 하지만 생각보다 스케줄링은 직접 해야 했어요</h3><p>설계를 코드로 옮기기 시작하면서 처음 만난 벽은 이거였어요. <strong>Promise랑 이벤트 루프를 쓰면 스케줄링이 공짜로 될 줄 알았는데, 그게 아니었던 거에요.</strong></p><p>문제를 정확히 적어보면 이래요.</p><ol><li>페이지가 마운트되면 자식 컴포넌트들이 같은 커밋 페이즈에서 마운트되고, 각자의 useEffect가 <strong>같은 동기 태스크 안에서</strong> 연이어 실행돼요.</li><li>그러면 reserve 호출도 같은 태스크 안에 줄줄이 쌓여요. 그런데 <strong>호출 순서와 우선순위 순서는 아무 상관이 없어요.</strong></li><li>만약 reserve 직후에 곧바로 큐 처리(process)를 동기 실행하면, 가장 먼저 호출된 엔트리가 그대로 active가 돼버려요. 우선순위 정렬이 무의미해지죠.</li><li>그러니까 <strong>동기 코드는 Promise를 만들어두기만 하고, 실제 실행 결정은 “이번 동기 태스크에 들어온 요청이 전부 모인 뒤”에 한 번만</strong> 내려야 해요.</li></ol><p>그 “전부 모인 뒤”가 정확히 어디냐. 여기서 제 이해가 모호하다는 걸 알게 됐어요.</p><h4>모호했던 두 가지</h4><p>원래 알고 있던 흐름은 대충 이랬어요.</p><p><em>동기 코드가 먼저 실행되고, </em><em>await을 만나면 async 함수가 콜스택에서 빠졌다가, Promise가 settled되면 다시 돌아와서 나머지가 실행된다.</em></p><p>그런데 막상 구현하려니 두 지점이 막혔어요.</p><ul><li><strong>“콜스택이 비워진다”는 기준이 정확히 뭐지?</strong> 전역 실행 컨텍스트까지 제거되는 순간이라면, 그 안에서 돌던 함수의 지역 변수도 같이 사라지는 건데, 큐는 어떻게 자기가 어떤 엔트리를 기다리고 있었는지 기억한 채 이어서 실행되지?</li><li><strong>await 이후 라인은 정확히 언제, 어느 사이클에 실행되지?</strong> 마이크로태스크 큐랑 매크로태스크 큐가 각각 한 번씩 비워지고 난 다음 사이클인가?</li></ul><h4>명세를 따라가서 정리한 답</h4><p><strong>“콜스택이 비었다”는 전역 실행 컨텍스트까지 빠진 상태예요.</strong> 스크립트가 끝까지 실행되면 스크립트 자신의 컨텍스트도 스택에서 내려가요. 마이크로태스크는 그다음에 돌아요.</p><p>그럼 변수는 왜 안 사라질까요? await이 실행 컨텍스트를 통째로 챙겨두기 때문이에요. await을 만나면 async 함수의 실행 컨텍스트는 스택에서 내려가지만 버려지지 않아요. Promise에 걸린 핸들러가 그걸 붙잡아 둬요. 변수가 담긴 렉시컬 환경도, “어디까지 실행했는지”도 전부 그 안에 들어 있어요. Promise가 settle되면 이 컨텍스트가 스택에 다시 올라와 멈춘 줄부터 이어가요. 명세는 이때 큐에 올라가는 작업 단위를 Promise Reaction Job이라고 불러요. 큐가 몇 초를 멈춰 있어도 깨어나서 그 entry를 resolve할 수 있는 건 이것 때문이에요. 저는 실행 컨텍스트와 렉시컬 환경을 같은 걸로 알고 있었는데, 여기서 처음 둘을 구분하게 됐어요.</p><p><strong>또, await 다음 줄은 await을 만난 사이클이 아니라, settle된 사이클의 마이크로태스크에서 실행돼요</strong>. 사용자가 모달을 닫기까지 5초가 걸렸다면, 그 5초 동안 이벤트 루프는 수없이 돌고 닫는 순간에야 다음 줄이 실행돼요. 큐 입장에서 보면 await entry.fn()에서 몇 초씩 멈춰 있는 동안에도 바깥은 계속 돌아간다는 뜻이에요. 이게 뒤에서 재진입 플래그가 필요해지는 이유예요.</p><h4>명확한 이해가 구현에 준 두 가지 확신</h4><p><strong>첫째, 같은 태스크 안의 race 걱정은 사라졌어요.</strong> async 함수가 제어권을 되찾는 시점이 “모든 동기 코드가 끝난 뒤”니까, 동기 코드에서 여러 컴포넌트가 Promise를 만들며 reserve를 호출하는 것과 큐가 그걸 정렬해 처리하는 것 사이에 경합이 생길 수가 없어요. 명세 기준으로 보장되는 순서였어요.</p><p><strong>둘째, </strong><strong>queueMicrotask를 쓰는 이유가 확정됐어요.</strong></p><pre>reserve(priority, fn, options) {<br>  // ... 정렬 삽입<br>  set((state) =&gt; ({ queue: sortQueue([...state.queue, entry]) }));<br>  queueMicrotask(() =&gt; { void process(); });  // ★<br>}</pre><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*iQLbggNqNl8i_6K_.png" /><figcaption>같은 태스크에 들어온 예약은 이렇게 한 번에 정렬돼요</figcaption></figure><p>같은 태스크에 들어온 예약은 이렇게 한 번에 정렬돼요.</p><p>매크로태스크(setTimeout(0))로 미루면 그 사이에 다른 태스크가 끼어들 수 있어요. 마이크로태스크로 미루면 <strong>같은 동기 태스크에 들어온 모든 </strong><strong>reserve 호출이 전부 쌓인 뒤 한 번에 정렬되어 처리되는 게 보장돼요.</strong> queueMicrotask는 WHATWG 표준이고 당근의 브라우저 최소 지원 스펙도 커버하는 데다, 의미적으로도 &quot;이 태스크 직후에 한 번&quot;이라는 의도를 그대로 드러내서 이걸로 정했어요.</p><p>설계랑 엔진 동작이 그대로 맞닿는 지점이라, 개인적으로 이 프로젝트에서 제일 재밌었던 대목이에요.</p><h3>9. 그래서 이렇게 만들었어요</h3><p>쓰는 쪽에서 보이는 모습부터 시작해서, 정책, 내부 구현 순서로 내려가 볼게요.</p><h4>호출처가 보는 API</h4><p>호출처는 이 훅 하나만 알면 돼요.</p><pre>useReserveOverlay&lt;T = void&gt;(options: { priority: Priority; id?: string; subOrder?: number }): {<br>  isOpen: boolean                                  // 렌더 조건<br>  open: (options?: { onOpen?: () =&gt; void }) =&gt;<br>    Promise&lt;ReserveResult&lt;T | undefined&gt;&gt;          // 큐에 예약<br>  close: (value?: T) =&gt; void                       // 닫기 + 큐 진행<br>}</pre><p>호출처는 priority, id, subOrder를 직접 적지 않고, 카탈로그에 정의해둔 값을 꺼내 그대로 넘겨요.</p><p>onOpen은 예약한 시점이 아니라 실제로 화면에 뜬 시점에 불려요. 우선순위에 밀려 늦게 뜨거나 아예 못 뜰 수도 있어서, 노출 로그는 여기서 찍어요.</p><h4>우선순위는 코드가 아니라 카탈로그에</h4><p>호출처가 &#39;high&#39; 같은 문자열을 직접 쓰지 않게 했어요. <strong>순서 요구사항이 바뀌었을 때 한 파일만 열면 되도록</strong> 하는 게 이 설계의 실질적인 목표 중 하나였거든요.</p><pre>// overlayPriorities.ts<br>export const OVERLAY_PRIORITY = {<br>  PERMISSION_ALERT_DIALOG: { priority: &quot;critical&quot;, id: &quot;permission-alert-dialog&quot; },<br>  APP_VERSION_GUIDE_BOTTOM_SHEET: { priority: &quot;high&quot;, id: &quot;app-version-guide-bottom-sheet&quot;, subOrder: 20 },<br>  ZERO_STEP_ONBOARDING: { priority: &quot;normal&quot;, id: &quot;zero-step-onboarding&quot;, subOrder: 0 },<br>  STREAK_SECTION_DIALOG: { priority: &quot;normal&quot;, id: &quot;streak-section-dialog&quot;, subOrder: 40 },<br>} as const satisfies Record&lt;string, OverlayConfig&gt;;</pre><h4>우선순위와 결과 시맨틱</h4><p>우선순위는 4단계로 고정했어요.</p><pre>type Priority = &#39;critical&#39; | &#39;high&#39; | &#39;normal&#39; | &#39;low&#39;<br>// 정렬 가중치 1000 / 100 / 10 / 1 — 간격을 넉넉히 둔 건 중간 단계 확장 여지 때문이에요</pre><ul><li>critical: active 엔트리를 강제 종료하고 즉시 끼어들어요. 시스템 전제 조건이 깨진 긴급 상황(권한 거부 같은)에만 써요. 단, 강제 종료는 웹뷰가 닫을 수 있는 오버레이에만 동작해요. 가령, OS 팝업은 강제로 종료할 수 없어요.</li><li>high : active 엔트리가 끝난 직후 실행되도록 앞쪽에 예약돼요.</li><li>normal:일반 안내예요.</li><li>low:온보딩 같은 보조 안내. 항상 뒤예요.</li></ul><p>같은 priority 안에서는 호출 순서(FIFO)를 따르고, 명시적 순서가 필요할 때만 subOrder로 조정해요. 큐가 비어 있으면 모든 priority가 즉시 승급하고요.</p><p>그리고 결과는 취소와 에러를 다르게 다뤄요.</p><pre>export type ReserveResult&lt;T&gt; =<br>  | { cancelled: false; value: T }<br>  | { cancelled: true; reason: &quot;preempted&quot; | &quot;flushed&quot; | &quot;duplicate&quot; };</pre><p>취소 사유는 세 가지예요. preempted는 critical 엔트리에 밀려난 경우, flushed는 화면을 떠나면서 큐가 비워진 경우, duplicate는 같은 id가 이미 예약되어 있던 경우예요.</p><ul><li><strong>취소는 정상 플로우라 </strong><strong>resolve로 돌려줘요.</strong> { cancelled: true, reason } 형태예요. 취소를 reject로 던지면 호출처마다 try/catch를 강제하게 되고, 안 잡으면 unhandled rejection이 떠요. 선점당하거나 화면이 정리된 건 예외가 아니라 결과예요.</li><li><strong>trigger가 throw한 진짜 에러만 </strong><strong>reject로 전달해요.</strong> 큐가 삼키면 호출처가 실패를 모르게 되니까요. 다만 process의 try/catch가 그걸 받아서 <strong>엔트리 하나의 에러가 큐 전체를 멈추지는 않게</strong> 해요. 루프는 계속 돌고, 실패는 그 엔트리의 호출처만 받아요.</li></ul><pre>const result = await modal.open();<br>if (result.cancelled) return;<br>if (result.value === &quot;exit&quot;) proceed();</pre><p>이렇게 모달을 그냥 닫았을 때, 취소했을 때, 진행했을 때 각 다른 분기로 뻗어나가는 흐름도 이벤트 체이닝을 끊어내고 큐의 결과를 이용해 언어의 멘탈모델 아래서 사용할 수 있게 됐어요.</p><h3>내부 구현</h3><h4>큐 스토어</h4><p>큐는 Zustand 스토어로 관리했어요.</p><pre>export interface Entry&lt;T = unknown&gt; {<br>  priority: Priority;<br>  /** trigger. 차례가 오면 실행되고, 반환한 Promise가 pending인 동안 active */<br>  fn: () =&gt; Promise&lt;T&gt;;<br>  /** critical 선점 / flush 시 호출. UI도 함께 닫히도록. */<br>	onForceClose?: () =&gt; void;<br>  /** 호출처에 반환된 Promise의 resolve. */<br>  resolve: (result: ReserveResult&lt;T&gt;) =&gt; void;<br>  /** trigger 자체가 throw했을 때의 reject. */<br>  reject: (reason?: unknown) =&gt; void;<br>  // … id, subOrder, createdAt (중복 판별·정렬용)<br>}</pre><pre>interface State {<br>  queue: Entry[];<br>  active: Entry | null;<br>  running: boolean;<br>}</pre><p>한 엔트리에는 Promise가 두 개 관여하고, 둘 다 withResolvers로 만들어요. 방향이 반대라는 게 포인트예요.</p><ul><li><strong>fn()이 반환하는 Promise</strong> - “오버레이가 닫혔나”를 알려요. useReserveOverlay가 fn 안에서 만들고 resolve를 ref에 보관해뒀다가, 사용자가 닫아 close()가 불릴 때 resolve해요. process가 await하는 대상이 이거고요. UI에서 큐로 가는 신호예요.</li><li><strong>reserve()가 반환하는 Promise</strong> - “내 예약이 어떻게 끝났나”를 알려요. 큐가 resolve(정상 close 또는 취소)나 reject(trigger가 throw)로 매듭지어요. await modal.open()이 받는 게 이거예요.</li></ul><pre>const { promise, resolve, reject } = withResolvers&lt;ReserveResult&lt;T&gt;&gt;();</pre><h4>process</h4><p>process는 이 시스템의 심장인데, 실질적으로 await entry.fn() 한 줄이에요. 나머지는 전부 그 한 줄을 안전하게 감싸는 장치예요.</p><pre>const process = async () =&gt; {<br>  if (get().running) return;<br>  if (get().active) return;<br>  set({ running: true });</pre><pre>try {<br>    for (;;) {<br>      const { queue } = get();<br>      if (queue.length === 0) break;<br>      const [entry, ...rest] = queue;<br>      set({ queue: rest, active: entry });<br>      try {<br>        const value = await entry.fn();<br>        entry.resolve({ cancelled: false, value });<br>      } catch (error) {<br>        entry.reject(error);<br>      } finally {<br>        set({ active: null });<br>      }<br>    }<br>  } finally {<br>    set({ running: false });<br>  }<br>};</pre><h4>useReserveOverlay 구현</h4><p>내부 구현의 본질은 <strong>fn 안에서 만든 Promise의 resolve를 </strong><strong>close에서 호출할 수 있게 다리를 놓는 것</strong>이고, 그 다리가 useRef예요.</p><pre>export function useReserveOverlay&lt;T = void&gt;({ priority, id, subOrder }: Options) {<br>  const reserve = useReservePromiseQueue();<br>  const [isOpen, setIsOpen] = useState(false);<br>  const resolverRef = useRef&lt;((value: T | undefined) =&gt; void) | null&gt;(null);</pre><pre>const close = useCallback((value?: T) =&gt; {<br>    setIsOpen(false);<br>    const resolve = resolverRef.current;<br>    resolverRef.current = null;<br>    if (resolve) resolve(value);<br>  }, []);<br>  const open = useCallback(({ onOpen }: OpenOptions = {}) =&gt; {<br>    return reserve&lt;T | undefined&gt;(<br>      priority,<br>      () =&gt; {<br>        const { promise, resolve } = withResolvers&lt;T | undefined&gt;();<br>        resolverRef.current = resolve;<br>        setIsOpen(true);<br>        onOpen?.();<br>        return promise;<br>      },<br>      { id, subOrder, onForceClose: () =&gt; close() },<br>    );<br>  }, [reserve, priority, id, subOrder, close]);<br>  // … 언마운트 처리 (기다리던 resolve 정리, 언마운트 뒤 실행 방지)<br>  return { isOpen, open, close };<br>}</pre><p>여기서 헷갈리기 쉬운 네 개념을 분리해두는 게 중요했어요.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*UYx3sJrYyDWiu_aIBdEf-g.png" /></figure><p>onClose는 &quot;resolve를 부르는 쪽&quot;일 뿐 fn의 본체가 아니에요. 이 연결을 호출처가 직접 다루게 하면 러닝커브가 생겨서, 저수준 API(useReservePromiseQueue)는 래퍼 훅 안에 숨겼어요.</p><h4>await이 막는 게 어디까지인지</h4><p>큐가 한 엔트리를 await하고 있을 때 막는 건 <strong>큐의 다음 엔트리 진행</strong>뿐이에요. await은 &quot;해당 async 함수 스코프의 일시 중지&quot;지 &quot;메인 스레드 블로킹&quot;이 아니거든요.</p><ul><li>렌더, setState, effect - 정상</li><li>사용자 인터랙션(클릭, 입력) - 정상</li><li>백그라운드 fetch, 폴링, setInterval - 정상</li><li>다른 컴포넌트의 reserve 호출 - 정상 (큐에 쌓이고 차례를 기다려요)</li></ul><p>막히는 건 오직 “한 번에 하나의 trigger만 실행한다”는 원칙을 지키기 위한 큐 진행이에요.</p><p>entry.fn()은 사용자가 닫을 때까지 pending이라, 그 사이 이벤트 루프가 온전한 턴을 여러 번 돌아요. 그때 다른 코드가 process를 다시 부를 수 있고, state.running은 그걸 막기 위한 플래그예요. 8장에서 본 “settle이 일어난 사이클 기준”이 여기서 값을 치르는 셈이에요.</p><h4>한 사이클로 돌려보면 이런 그림이에요</h4><p>같은 태스크에 들어온 예약들이 마이크로태스크 경계에서 한 번에 정렬되고, 한 번에 하나씩 active가 되고, 중간에 들어온 critical이 active 엔트리를 밀어내는 것까지 담았어요.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*GF01-v-ERXHLT-Gk.gif" /></figure><h3>10. 이게 임시방편이면 어떡하지, 싶었어요</h3><p>구현하는 내내 찜찜한 게 하나 있었어요. <strong>이 스케줄링이 제가 급조한 임시방편은 아닐까?</strong></p><p>그래서 동시성 프로그래밍 책을 샀어요. Rust의 비동기 런타임 구현 부분이 목적이었는데, Rust는 async 런타임을 라이브러리 레벨에서 직접 구현하기 때문에 JS에서는 엔진에 가려 보이지 않는 구조가 코드로 드러나거든요.</p><p>그리고 대응 관계가 비슷하게 맞아떨어졌어요.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/690/1*qGIR95onnYAZSmpWPJhjJg.png" /></figure><p>자바스크립트는 Promise/await이 Future/Waker를 자동으로 추상화해주기 때문에 코드가 훨씬 짧아져요.</p><p>구조가 똑같진 않지만, 완료 신호를 밖에서 주입하고 런타임이 재개시키는 패턴은 비슷했어요.</p><p>솔직히 책 사고 나서 “학습한 개념이랑 JS 구현이 시원하게 연결이 안 되는데, 괜히 복잡도만 키우는 건 아닐까” 싶은 순간도 있었어요. 하지만 결론적으로 이건 단순한 지적 만족이 아니라 실용적인 학습이자 소득이었어요. 설계의 흐름을 <strong>명세로 환원</strong>해 비교하면서 설계 구멍과 얻어걸렸던 안정성을 발견하고 이해할 수 있었거든요.</p><h3>11. 배포하고 나서야 알게 된 이 설계의 경계</h3><p>여기까지가 설계와 구현 이야기예요. 그런데 이 글에서 사실 제일 하고 싶은 이야기는 그다음이에요.</p><p>시스템이 잘 돌아가니까, 저는 <strong>화면 위의 모든 순서 문제를 이 큐로 풀려고 했어요.</strong> 대표적인 게 보상 흐름이었어요.</p><blockquote><em>보상 받기 클릭 → 쿠폰 다이얼로그 → 배지 획득 페이지 → 첫 보상 안내 nudge</em></blockquote><p>이 체이닝도 큐에 넣으면 복잡도가 사라질 거라고 기대했어요. 결과는 정반대였어요.</p><ul><li>순서가 호출처를 떠나서 <strong>전역 카탈로그(subOrder)로 흩어졌어요.</strong></li><li>발화가 handleRewardClick + handleAnimationEnd + effect로 <strong>쪼개졌어요.</strong></li></ul><p>복잡도가 완벽하게 준 게 아니라 <strong>이동하고 은닉된</strong> 거였어요. 제가 처음에 register 방식을 기각했던 바로 그 이유가, 다른 얼굴로 돌아온 거죠.</p><h4>큐가 원래 푸는 문제로 되돌아가기</h4><p>여기서 원점으로 돌아갔어요. <strong>큐는 무엇을 위한 도구였지?</strong></p><p>진입/세션 중에 <strong>여러 독립 조건이 각각 다른 hook/effect에서, 서로의 존재를 모른 채</strong> blocking 오버레이를 띄우려 하는 상황.</p><blockquote><strong><em>큐 = 서로를 모르는 채 오버레이를 띄우려는 코드들의 조율기</em></strong><em><br> 이게 큐의 존재 이유이자 유일한 핵심 역할이에요.</em></blockquote><p>반면 보상 흐름은 애초에 서로를 모르는 독립적인 오버레이가 아니었어요. 쿠폰을 여는 그 핸들러는 배지도, 넛지도 <strong>같은 자리에서 이미 알고 있거든요.</strong> 스스로 순서를 잡을 수 있는 하나의 흐름이었어요.</p><blockquote><strong><em>큐는 “흐름 내부 순서(체이닝)”를 위한 도구가 아니에요.</em></strong><em> 체이닝이 문제였던 게 아니라, 체이닝이 </em>암묵적이고 분산되어<em> 있던 게 문제였어요. 그 해법은 큐가 아니라 </em><strong><em>명시적인 로컬 시퀀서</em></strong><em>예요.</em></blockquote><pre>// 한 흐름이 세트를 다 아는 경우 — 큐가 아니라 그냥 async/await<br>await showCoupon()          // close까지<br>await pushBadgeAndReturn()  // 홈 복귀까지<br>await showNudge()</pre><h4>판별 기준을 한 문장으로</h4><p>그래서 “무엇을 큐에 넣을 것인가”의 기준을 코드로 판별 가능한 형태로 정리했어요.</p><p>부록처럼 붙여둔 한 문장은 이거예요.</p><blockquote><strong><em>“이 blocking 오버레이가 뜨려는 그 순간, 그걸 여는 코드가 ‘지금 나 말고 뭐가 뜨려는지’를 알고 있나?”</em></strong><em><br> 안다(같은 흐름) → 로컬<br> 모른다(독립 발화) → 큐</em></blockquote><p>우리가 실제로 답해야 하는 건 이 오버레이를 누가 소유하나가 아니라, 여는 코드가 무엇을 보고 있나가 기준이에요. 이렇게 잡으면 판별 기준이 코드 구조에 붙기 때문에, 나중에 발화 코드를 리팩터해서 시야가 달라지면 재분류도 쉬워지고요.</p><p>따라서 서로의 존재를 알고 결합되어있을 수 밖에 없는 보상 흐름 내부의 순서는 로컬 시퀀서에서 관리했어요. 그리고 다른 독립적인 오버레이와 겹치지 않도록, 보상 흐름 전체를 관리하는 로컬 시퀀서 전체를 하나의 엔트리로 큐에 예약했어요.</p><h3>12. 정리하며</h3><p>큐 이전에는 동네걷기 홈 화면이 오버레이의 순서를 직접 들고 있었어요.</p><pre>condition:<br>isZeroStepOnboardingResolved &amp;&amp;<br>isPermissionGranted &amp;&amp;<br>!wasShownStreakDialogAlready,</pre><p>이렇게 서로 다른 도메인의 플래그 셋을 한 자리에서 엮어야 “지금 이 오버레이를 띄워도 되나”가 나왔어요. 새 오버레이가 하나 끼면 이 조합을 다시 짜야 했고요.</p><p>지금 이 파일에는 오버레이 순서 코드가 한 줄도 없어요. 오버레이 순서를 조율하고 겹쳐뜨는 것을 방지하기 위해 order, condition, candidates가 나오던 일곱 곳이 전부 사라졌어요. 남은 건 예약 훅 호출 한 줄이에요.</p><p>플래그 자체가 없어진 건 아니에요. “권한이 허용됐나”, “이번 주에 봤나”는 도메인이 답해야 하는 질문이라 그대로 있어요. 사라진 건 그 플래그들을 한곳에 모아 조합하던 자리예요. 지금은 각 예약 훅이 자기 조건만 보고, 서로의 조건은 모른 채 응집되었어요.</p><p>그럼 순서는 어디로 갔냐면, 위에서 소개한 우선순위 카탈로그 한 파일로 갔어요.</p><p>순서를 실제로 바꿀 때 어떤 모양인지 하나만 볼게요. 기후캠페인 스탬프 흐름을 연속 출석 모달 뒤로 옮긴 변경에서 바뀐 코드는 이게 전부였어요.</p><p>subOrder: 10 → 40</p><p>큐를 만들고 4개월 반 동안 오버레이의 순서 요구사항은 15번 바뀌었어요.</p><p>예전이었다면 순서 변경마다 복잡한 조건 조합을 다시 짜고, 그게 다른 오버레이의 조건과 부딪히지 않는지 확인해야 했을 거에요.</p><p>또, 이제 오버레이 하나가 삭제되더라도 남은 오버레이의 순서를 다시 엮는 작업은 없었어요.</p><p>마지막으로 검증하는 방식이 바뀌었어요. 예전에는 순서를 하나 건드리면 그게 맞는지 확인할 방법이 화면을 직접 띄워보는 것밖에 없었어요. 조건 조합의 경우의 수만큼 다시 봐야 했고요. 지금은 그 확인이 테스트 파일 4개, 케이스 29개로 내려가 있어요.</p><ul><li><em>호출 순서가 아닌 priority 순으로 entry가 처리된다</em></li><li><em>active만 강제 종료하고 대기 큐는 유지한 채 critical을 먼저 처리한 뒤 본래 순서로 이어간다</em></li><li><em>active 컴포넌트가 close 없이 언마운트되어도 다음 entry가 진행된다</em></li></ul><p>이 테스트가 보장되는 한 오버레이는 순서대로 하나씩 발화될테니 화면을 직접 띄워 확인할 필요가 없어졌어요. 순서에 대한 확인이 수동 QA에서 CI로 옮겨간 거에요.</p><p>돌아보면, 이 프로젝트는 크게 세 번의 전환으로 이루어졌어요.</p><p><strong>첫째, 문제 정의의 전환.</strong> 눈에 보이는 문제(오버레이 충돌)에 머물지 않고 “이 정의가 너무 좁은 건 아닐까?”를 의식적으로 의심한 게 결정적이었어요. 버디의 피드백이 그 의심의 계기가 됐고, 마인드맵으로 큰 그림을 보는 시간이 추상화 도약의 토대가 됐어요. 재정의가 되고 나니, 해법에 대한 아이디어는 거의 바로 나왔고요.<br><strong>좋은 해법이 좋은 문제 정의를 만드는 게 아니라, 좋은 문제 정의가 좋은 해법을 만들었어요.</strong></p><p><strong>둘째, 추상화 단위의 전환.</strong> 데이터에서 함수로 한 칸 올린 것만으로 네이티브 비동기 통합, 복합 플로우의 원자성, 호출처 응집도 보존이 대부분 따라왔어요. 추상화는 뭔가를 더 얹는 일이라기보다, 이미 언어가 가지고 있는 능력에 문제를 갖다 붙이는 일에 더 가까웠어요.</p><p><strong>셋째, 이해의 깊이.</strong> Promise를 상태머신으로 활용하는 흐름을 제대로 이해하려고 deferred 패턴의 계보랑 ES2024 표준, ECMAScript의 Job 실행 모델, 다른 언어의 검증된 런타임 구조까지 찾아 들어갔어요.</p><p>그리고 11장이 말해주듯, 잘 만든 시스템은 그다음에 <strong>“이걸 어디까지 쓸 것인가”</strong> 라는 새로운 문제를 만들어요. 큐를 만든 것보다 <strong>큐를 어디에 쓰지 않을지를 정한 것</strong>이 이 프로젝트에서 더 어려운 판단이었어요.</p><p>혹시 지금 화면 위에서 오버레이들이 서로 밀치고 있다면, 먼저 이 질문부터 던져보시길 권해요.</p><blockquote><em>이 오버레이를 여는 코드는, 지금 나 말고 뭐가 뜨려는지 알고 있나요?</em></blockquote><p>답이 “모른다”면, 독립적으로 실행되는 흐름을 조율하기 위해 큐를 고려해볼 수 있어요.</p><p>하지만 “안다”면, 필요한 건 큐가 아니라 의미를 담은 체이닝일 수 있어요.</p><h3>참고한 자료</h3><ul><li><a href="https://developer.mozilla.org/ko/docs/Web/JavaScript/Reference/Global_Objects/Promise/withResolvers">MDN — </a><a href="https://developer.mozilla.org/ko/docs/Web/JavaScript/Reference/Global_Objects/Promise/withResolvers">Promise.withResolvers()</a></li><li><a href="https://developer.mozilla.org/ko/docs/Web/API/Window/queueMicrotask">MDN — </a><a href="https://developer.mozilla.org/ko/docs/Web/API/Window/queueMicrotask">queueMicrotask()</a></li><li>ECMAScript 명세 — ScriptEvaluation, async function 재개 (Job / Realm / LexicalEnvironment)</li><li><a href="https://github.com/sindresorhus/p-queue">sindresorhus/p-queue</a> — process 재진입 방지, clear 시 대기 Promise 처리의 반면교사</li><li><a href="https://github.com/eBay/nice-modal-react">eBay/nice-modal-react</a> — register 기반 오버레이 관리 패턴</li><li>다카노 유키, 『동시성 프로그래밍』(한빛미디어) — Executor / Waker / Task 모델</li></ul><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=097551289b2a" width="1" height="1" alt=""><hr><p><a href="https://medium.com/daangn/%EB%8B%B9%EA%B7%BC-%EB%8F%99%EB%84%A4%EA%B1%B7%EA%B8%B0-%EC%9C%84%EC%97%90%EC%84%9C-%EB%8F%99%EC%8B%9C%EC%97%90-%EC%8F%9F%EC%95%84%EC%A7%80%EB%8A%94-%EC%98%A4%EB%B2%84%EB%A0%88%EC%9D%B4%EB%93%A4%EC%9D%84-%EC%9A%B0%EC%95%84%ED%95%98%EA%B2%8C-%EC%A0%9C%EC%96%B4%ED%95%98%EA%B8%B0-097551289b2a">당근 동네걷기 위에서 동시에 쏟아지는 오버레이들을 우아하게 제어하기</a> was originally published in <a href="https://medium.com/daangn">당근 기술 블로그</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[당근의 인증은 어떻게 달라져 왔을까요?]]></title>
            <link>https://medium.com/daangn/%EB%8B%B9%EA%B7%BC%EC%9D%98-%EC%9D%B8%EC%A6%9D%EC%9D%80-%EC%96%B4%EB%96%BB%EA%B2%8C-%EB%8B%AC%EB%9D%BC%EC%A0%B8-%EC%99%94%EC%9D%84%EA%B9%8C%EC%9A%94-a50dd4b7b44c?source=rss----4505f82a2dbd---4</link>
            <guid isPermaLink="false">https://medium.com/p/a50dd4b7b44c</guid>
            <category><![CDATA[identity]]></category>
            <category><![CDATA[engineering]]></category>
            <category><![CDATA[software-engineering]]></category>
            <dc:creator><![CDATA[Key Kim]]></dc:creator>
            <pubDate>Thu, 20 Aug 2026 02:25:36 GMT</pubDate>
            <atom:updated>2026-08-20T02:25:34.530Z</atom:updated>
            <content:encoded><![CDATA[<p>안녕하세요? Identity Service Team의 Software Engineer 키(Key)입니다.</p><p>Identity Service Team은 당근의 회원과 인증을 책임지고 있는 팀이에요.</p><p>당근뿐 아니라 대부분의 서비스에서, 인증은 서비스를 시작하려면 꼭 한 번은 거쳐야 하는 단계예요. SMS 인증부터 본인인증까지 방식은 다양하지만, 모든 인증이 하려는 일은 결국 하나예요. <strong>‘이 계정의 주인이 나’라는 걸 확인하는 것.</strong></p><p>그럼 당근은 ‘이 계정의 주인이 나’라는 걸 어떻게 확인해 왔을까요? 서비스가 성장하면서 어떤 문제를 만났고, 그 문제를 어떻게 풀어왔을까요? 지금부터 당근 인증의 역사를 하나씩 들려드릴게요.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*iGqWt-E-YNbgt2b4WrcINQ.png" /></figure><h3>당근의 초창기 로그인</h3><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*u6xC38A68IIQkFJc86I5Eg.png" /></figure><p>처음에는 SMS 인증이었어요.</p><p>당근마켓은 모바일 앱으로 시작한 서비스예요. 모바일 기기는 전화와 문자를 주고받을 수 있죠.</p><p>앱에 자신의 전화번호를 입력하면 그 번호로 인증 문자가 오고, 도착한 인증번호를 다시 당근에 입력하는 거예요.</p><p>문자로 온 인증번호를 입력할 수 있다는 건 “이 전화번호를 쓰는 단말기를 지금 손에 쥐고 있다”는 뜻이에요. 말 그대로 번호의 ‘점유’를 확인하는 거죠.</p><p>동네 사람끼리 중고거래를 하는 데 필요한 인증으로는 그 정도 확인으로도 충분했어요.</p><p>무엇보다 간편하고 직관적이었고요. 가입 문턱을 낮춰서 더 많은 사람이 당근을 쓰게 하는 데 집중한, 최소한의 인증이었어요.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*VbhN2wERqcppQTF9k49bhA.png" /></figure><h3>“이 계정은 분명히 제 것이에요”</h3><p>그런데 서비스가 성장하면서 문제가 하나둘 나타나기 시작해요.</p><p>가장 많이 마주친 건 계정을 잃어버린 사용자였어요.</p><p>전화번호는 영원히 내 것이 아니거든요. 쓰던 번호를 해지하면 통신사는 일정 기간이 지난 뒤 그 번호를 다른 사람에게 다시 배정해요. 그래서 번호를 바꾼 뒤 새 번호로 당근에 로그인하면, 내가 쓰던 계정이 아니라 새 계정이 만들어져요. 그동안 거래하고 대화한 기록은 예전 계정에 남은 채로요.</p><p>기기를 바꿔도 마찬가지였어요. 번호 말고는 이 사람이 예전의 그 사람이라는 걸 확인할 방법이 없으니까요.</p><p>“번호를 변경해서 인증은 못하지만, 이 계정은 분명히 제 것”이라며 계정을 되찾아 달라는 문의가 매일 들어왔어요.</p><p>번호가 곧 계정이던 시절엔, 이런 요청이 오면 고객센터에서 통신사 서류를 기반으로 옛 계정의 닉네임·동네·전화번호를 하나하나 대조해 손으로 계정을 옮겨 줘야 했어요. 사용자는 며칠을 기다려야 했고, 고객센터는 같은 일을 매일 반복해야 했죠.</p><p>백업 수단으로 이메일 인증도 추가해 봤어요. 번호를 잃어버려도 이메일로 계정을 되찾을 수 있게요. 그런데 미리 이메일을 등록해 두는 사용자가 많지 않았어요. 정작 계정을 잃어버린 사람에겐 등록된 이메일이 없었던 거예요.</p><p>여기에 사기 문제도 겹쳤어요. 알뜰폰처럼 번호를 쉽게 발급받을 수 있는 수단이 늘면서, 사기꾼들이 번호를 수백 개씩 만들어 계정을 찍어냈어요. 한 계정이 제재되면 다른 번호로 새 계정을 만들어 사기를 이어 갔고요.</p><p>번호를 확인하는 것만으로는, 계정을 잃은 사람도 되찾아 줄 수 없고 돌아오는 사기꾼도 막을 수 없었어요.</p><h3>본인인증을 도입했어요</h3><p>그래서 본인인증을 도입했어요. 본인확인기관을 통해 실명과 CI를 확인하는 단계예요. CI(연계정보)는 사람마다 하나씩만 부여되는 값이라, 번호가 바뀌든 기기가 바뀌든 ‘같은 사람’이라는 걸 증명할 수 있어요.</p><p>※ CI란? 주민등록번호를 한 번 비가역적으로 암호화해 만든 값이에요. 그래서 서비스가 달라도 같은 사람이면 같은 CI가 나와요.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*D3_4HcWK807X-qLWvyUMMQ.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*cf31N5YtWzAlH8Nh3Bq6xQ.png" /></figure><p>사실 어려운 건 본인인증을 붙이는 일 자체가 아니었어요. <strong>이미 당근을 쓰고 있는 수천만 명의 사용자를 어떻게 할 것인가</strong>였죠.</p><p>신규 가입자에게 본인인증을 요구하는 건 비교적 간단해요. 문제는 이미 번호만으로 가입해 잘 쓰고 있는 기존 사용자예요. 어느 날 갑자기 앱을 열었더니 “본인인증을 해야 계속 쓸 수 있어요”라는 화면이 뜬다면, 상당수는 인증 대신 앱을 닫아 버릴 테니까요.</p><p>새로 가입하는 사용자에게는 본인인증을 강제화하는 선택을 할 수 있었어요.</p><p>다만 기존 사용자에게는 본인인증을 강제하는 대신 <strong>본인인증을 할 이유를 만들어 주는 쪽</strong>을 택했어요. 본인 확인이 자연스럽게 필요한 서비스를 접점으로 삼은 거예요. 예를 들어 생년월일이 필요한 사주 서비스처럼, 사용자가 원해서 쓰는 서비스 안에 본인인증을 놓아두면 사용자는 운세를 보러 온 김에 본인인증까지 마치게 돼요.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*40hMCVgWNHAVCT6HoDg1Fg.png" /><figcaption>본인인증하고 신년 운세 확인하기</figcaption></figure><p>그렇게 시간을 들여 본인인증을 확산시켰어요.</p><p>이제 전화번호를 여러 개 만들어 사기를 치는 일이 어려워졌어요. 일반 사용자도 본인인증만 해두면, 전화번호를 바꾸더라도 쓰던 당근 계정을 그대로 이어서 쓸 수 있게 됐고요. 고객센터가 손으로 계정을 옮겨 주던 일도 크게 줄었어요.</p><p>이제 ‘이 계정의 주인이 누구인가’를 정확하게 확인할 수 있게 됐어요. 그런데 이번엔 다른 종류의 문제가 생기기 시작해요.</p><h3>앱 말고 PC에서도 당근을 쓰고 싶어요</h3><p>당근이 중고거래를 넘어 부동산, 중고차, 알바로 넓어지면서, 당근을 앱 밖에서 쓰고 싶은 사람들이 나타나기 시작했어요.</p><p>부동산 중개인과 중고차 딜러는 PC로 매물을 등록하고 싶고, 카페 사장님은 태블릿으로 공고를 올리고 싶어요. 그런데 당근 계정은 아직 모바일 앱 안에서만 쓸 수 있었어요.</p><p>처음엔 웹도 앱과 같은 인증 방식을 그대로 사용했어요. 전화번호 문자 인증으로 시작해서, PC 환경에서 많이 사용하는 QR 로그인을 도입했어요.</p><p>그런데 예상하지 못한 곳에서 문제가 생겼어요. 바로 모바일 웹이었어요.</p><p>PC에서는 QR 로그인이 잘 동작했어요. 당근에 로그인되어 있는 휴대기기로 QR 화면을 찍기만 하면 되니까요. 그런데 휴대기기에서 브라우저를 통해 접속한 사람은 QR 인증을 할 수 없었어요. 남은 인증 수단은 문자 인증인데, 이건 브라우저와 문자 앱을 오가며 번호를 옮겨 적어야 하죠. 그 사이에 적지 않은 사람들이 로그인을 포기했고, 모바일 웹의 인증 전환율은 눈에 띄게 낮았어요.</p><p>그래서 모바일 웹에서는 클릭 한 번으로 끝나는 당근앱으로 로그인을 만들었어요. 이미 휴대기기에 로그인된 당근 앱이 있다면, 웹에서 버튼을 누르는 것만으로 당근앱이 열려 인증을 확인하고 다시 웹으로 돌아오는 방식이에요.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*yLpZ8fgSvsmF9CjIz_OU-Q.png" /></figure><p>어떤 서비스를 사용해도 동일한 인증 경험과 보안을 챙기는 것도 필요했어요.</p><p>부동산, 중고차, 알바를 비롯해 새로운 서비스가 계속 생겼고, 앞으로도 더 생길 예정이었어요. 이 서비스들이 모두 같은 당근 계정으로 로그인되어야 해요. 그러려면 여러 도메인에 걸친 서비스가 동일한 인증 토큰을 다뤄야 해요. 그런데 인증 화면과 로직, 토큰 관리를 서비스마다 각자 맡게 두면, 보안이 가장 약한 서비스가 전체 계정의 보안 수준이 돼요. 보안 취약점이 한 군데만 있어도 모든 서비스의 계정이 위험해지는 구조인 거죠.</p><p>그래서 웹 인증을 accounts.daangn.com이라는 하나의 도메인으로 통합했어요.</p><p>당근부동산을 예로 들어 볼게요. 부동산 웹(realty.daangn.com)에서 ‘당근으로 로그인’을 누르면 브라우저가 accounts.daangn.com으로 넘어가요. 인증은 거기서만 이뤄져요. 전화번호와 본인인증 같은 인증 수단으로 ‘이 계정의 주인이 맞는지’를 확인해요. 확인이 끝나면 인증 토큰과 함께 다시 부동산으로 돌아오고요.</p><p>이 구조는 OAuth 2.0/OIDC 표준 위에서 동작해요. 당근만 아는 방식이 아니라 인증 업계 표준을 따랐어요.</p><p>덕분에 각 서비스는 사용자의 인증을 고민하지 않아도 돼요. 로그인 화면을 매번 새로 만들 필요도 없고요.</p><p>이러한 기반 위에서 2차 인증을 비롯해 각 서비스의 요구에 맞는 인증 수단도 하나씩 더해 나갔어요.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*LgMAVtg_00ieWGux2UeiFg.png" /></figure><p>당근 SSO 덕분에 기업부터 소상공인까지, 여러 외부 서비스도 당근 계정으로 로그인할 수 있게 됐어요.</p><h3>글로벌은요?</h3><p>당근은 북미와 일본에서 Karrot이라는 브랜드로 성장해 나가고 있어요. 그런데 한국에서 해온 인증 방식이, 국경을 넘는 순간 잘 통하지 않게 돼요.</p><h4>처음 보는 앱에 전화번호를 알려 주기는 싫어요</h4><p>한국에서 당근은 이미 많은 사람이 아는 서비스예요. 하지만 북미와 일본에서 Karrot은 아직 초기 서비스예요.</p><p>처음 써보는 앱이 전화번호부터 요구하면, 상당수가 중간에 이탈해요. 한국에선 거부감 없이 사용되는 휴대전화 인증이, 글로벌에선 가입 퍼널의 가장 큰 장벽이 된 거예요.</p><p>그래서 소셜 로그인을 도입했어요. 이미 쓰고 있는 다른 서비스의 계정으로 시작할 수 있게 해서 첫 관문을 낮추고, 가입 전환율을 끌어올린 거죠.</p><p>다만 나라마다 많이 쓰는 계정이 달라요. 일본에서는 LINE이 생활 깊숙이 자리 잡아 많은 서비스가 LINE 로그인을 쓰거든요. 그래서 일본은 LINE·Google·Apple을, 북미는 Google·Apple·Facebook을 붙였어요. 같은 ‘소셜 로그인’이라도, 나라의 생활 방식에 맞춰 조합을 다르게 가져간 거예요.</p><h4>문자가 오지 않아요</h4><p>북미는 땅이 넓고 통신사도 제각각이에요. 인증 문자가 늦게 오거나 아예 안 오는 일이 한국보다 훨씬 잦아요. 통신사의 스팸 정책에 걸려 문자가 차단되는 경우도 있고요.</p><p>적지 않은 마케팅 비용을 들여 데려온 사용자가, 문자 한 통이 안 와서 가입을 포기하고 돌아가는 거예요.</p><p>그래서 인증 문자를 받지 못한 사용자를 위해 Voice Call 인증을 도입했어요.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*6at2S5ECwPlOpmHlxGx0qw.png" /></figure><p>“문자가 오지 않았나요? Voice Call로 인증하기” 같은 버튼을 보여 주고, 문자 인증 때 입력한 휴대전화번호로 전화를 걸어 음성으로 인증번호를 불러 주는 방식이에요.</p><p><a href="https://www.twilio.com/blog/silent-network-authentication-sna-overview">SNA(Silent Network Authentication)</a>라는 방식도 실험해 봤어요. 사용자가 인증번호를 입력하는 대신, 유심을 통해서 통신사 네트워크가 “이 번호가 지금 이 기기에서 실제로 쓰이고 있는지”를 확인해 주는 SIM 기반 인증 방식이에요. 퍼널도 단축되고 문자가 아예 오가지 않으니, 문자가 안 와서 이탈하는 문제 자체가 사라지죠.</p><p>실험 결과 인증 정확도와 사용성은 실제로 좋아졌어요. 그런데 정작 우리가 기대했던 가입 전환율 상승은 끝내 검증되지 않았어요. 그래서 배포하지 않기로 했어요.</p><p>모든 시도가 배포로 이어지진 않아요. 실험해 보고, 아니라고 판단되면 과감히 중단해요.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*xPyxK0GJtJEX6cX1QQXctw.png" /></figure><h4>이 사람을 믿어도 될까요?</h4><p>거래를 하다 보면 상대의 신원을 확인해야 할 때도 있어요. 북미에서도 사기 문제가 심해지면서, 사용자끼리 서로를 믿을 수 있게 만드는 일이 중요해졌어요.</p><p>한국에서는 본인인증이 이 문제까지 자연스럽게 풀어 줬지만, 북미엔 그런 공통 신원 체계가 없죠.</p><p>그래서 Karrot은 KYC(Know Your Customer) 서비스인 <a href="https://docs.withpersona.com/how-persona-works">Persona</a>를 도입했어요. 여권이나 운전면허증 같은 정부 발급 신분증을 촬영하면 위조 여부를 확인하고, 셀피를 찍어 신분증 속 사진과 같은 사람인지까지 대조하는 방식이에요.</p><p>한국에서 잘 되던 방식을 고집하기보다는, 그 나라의 통신·법·신원 환경에 맞는 인증을 계속 고민해 나가고 있어요.</p><h3>AI가 당근에 로그인하려면요?</h3><p>최근에는 새로운 종류의 ‘사용자’가 찾아왔어요. 사람이 아니라 AI예요.</p><p>Claude나 ChatGPT에게 “내 당근 광고 성과 좀 정리해 줘”라고 부탁하는 시대가 됐어요. 당근의 정보를 AI가 활용할 수 있도록 MCP(Model Context Protocol) 서버를 준비하면서, 새로운 인증 문제와 마주했어요.</p><p><strong>AI가 당근에 접근할 때, 어떻게 인증하고, 어떤 권한으로 어디까지 허용할 것인가</strong>예요.</p><p>MCP 표준은 OAuth 2.1 기반 인증을 요구하고, 모든 연결은 반드시 사용자 본인의 동의를 거쳐야 해요. 다행히 당근 SSO는 처음부터 OAuth 표준 위에 만들어 뒀기 때문에, 표준을 따라 자연스럽게 확장할 수 있었어요.</p><p>‘이 계정의 주인이 나’라는 걸 확인하던 인증이, 이제 ‘내가 허락한 만큼만 AI에게 위임하는’ 인증으로 확장되고 있어요.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*vKlsvnxdL4stoDUCNwi3kA.png" /><figcaption>AI 클라이언트는 사용자 동의를 거쳐, 필요한 만큼만 잘라낸 권한을 받아요</figcaption></figure><h3>같이 만들어요</h3><p>Identity Service Team은 가입 퍼널의 병목을 없애고 유저의 전환율을 높이면서도, 가장 안전한 인증 방식은 무엇일지 고민하는 팀이에요.</p><p>이런 고민을 같이 해나갈 분을 찾고 있어요. 당근의 회원/인증을 함께 만들어나가고 싶으시다면 주저하지 말고 <a href="https://careers.daangn.com/jobs/role/5046759003/">지원</a>해주세요!</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*7zTdm0bWK8NzO79ILlIeXQ.png" /><figcaption>2분기 워크샵, 우리가 풀어야 할 문제들을 좁혀 가는 중이에요</figcaption></figure><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=a50dd4b7b44c" width="1" height="1" alt=""><hr><p><a href="https://medium.com/daangn/%EB%8B%B9%EA%B7%BC%EC%9D%98-%EC%9D%B8%EC%A6%9D%EC%9D%80-%EC%96%B4%EB%96%BB%EA%B2%8C-%EB%8B%AC%EB%9D%BC%EC%A0%B8-%EC%99%94%EC%9D%84%EA%B9%8C%EC%9A%94-a50dd4b7b44c">당근의 인증은 어떻게 달라져 왔을까요?</a> was originally published in <a href="https://medium.com/daangn">당근 기술 블로그</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Laying the Rails Beyond WebView: Why Karrot Chose Lynx]]></title>
            <link>https://medium.com/daangn/laying-the-rails-beyond-webview-why-karrot-chose-lynx-db1aa92af5e4?source=rss----4505f82a2dbd---4</link>
            <guid isPermaLink="false">https://medium.com/p/db1aa92af5e4</guid>
            <category><![CDATA[english]]></category>
            <category><![CDATA[engineering]]></category>
            <dc:creator><![CDATA[Tony W]]></dc:creator>
            <pubDate>Wed, 12 Aug 2026 09:04:24 GMT</pubDate>
            <atom:updated>2026-08-12T09:04:23.205Z</atom:updated>
            <content:encoded><![CDATA[<figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*QzTrArCkGbZiDTI3SQn6dw.png" /></figure><p>Hi, I’m Tony, the lead of Karrot’s Frontend Core Team. Our team builds the development environment and deployment infrastructure used by frontend engineers at Karrot. This post is a record of how we evaluated Lynx — a technology with very few real-world references at the time — and brought it into our production app. If you’ve ever had to decide whether to adopt a new technology, I hope our decision-making process gives you something useful to take away.</p><h3>WebView and the Performance Problem</h3><p>Many screens in the Karrot app are built with WebView. When a WebView screen opens, it needs to fetch web documents and data over the network before executing them, and this initial loading cost has been a long-standing challenge for us. SSR can reduce performance bottlenecks from JavaScript evaluation and DOM manipulation by rendering the initial web document on the server. But the path that fetches the web document and initial data over the network still remains.</p><p>There are other approaches as well. We can display a native screen that closely resembles the actual web screen before the WebView finishes loading. We can also cache the client application with a Service Worker so the UI can render before making a network request. But these optimizations increase product complexity. Optimizing within the constraints we already had was important, but what we really wanted was an execution environment that could render the UI we intended from the very first frame.</p><p>The problem we truly wanted to solve was not shaving a few more milliseconds off loading time. We wanted users to stop perceiving loading altogether.</p><h3>What We Needed From a New Runtime</h3><ol><li>The first frame users see needed to be the actual product UI, not a separate temporary screen.</li><li>We needed to be able to adopt it screen by screen while keeping the existing native app intact.</li><li>Frontend bundles needed to be deployable independently of the app binary.</li><li>We needed to be able to start without a large dedicated organization — with just one frontend engineer, one iOS engineer, and one Android engineer.</li><li>If something went wrong, we needed a path back to the existing WebView implementation.</li></ol><p>Replacing the entire Karrot app with a new cross-platform framework was never our goal. Core product surfaces — such as the Home feed, where users discover content, and Chat, where actual transactions take place — needed to remain on our fast and stable native stack. What we were looking for was a rendering layer that could replace some of the screens previously handled by WebView, while giving us more room for performance optimization.</p><p>In March 2025, one of my colleagues shared a post about Lynx in Slack. As I started reading through it, I kept finding design decisions that matched what I had been looking for.</p><h3>Lynx?</h3><p><a href="https://lynxjs.org/">Lynx</a></p><p>Lynx is a cross-platform technology that lets you build iOS and Android UIs with a development model close to React and CSS. You can build an entire mobile UI with Lynx, or embed it as a UI block inside an existing native application.</p><p>When evaluating Lynx, the first things I looked at were not its React APIs or CSS syntax. What caught my attention first were the JavaScript engine it chose and the way it rendered the first screen.</p><h4>1. A Small JavaScript Engine That Starts Fast</h4><p>When evaluating a JavaScript engine, we usually start by comparing execution performance. Processing speed for complex computations and benchmark scores are certainly important, but for mobile UI, the cost of preparing the runtime also directly affects the user’s loading experience. After all, what the user experiences is the moment a screen opens.</p><p>Lynx’s main-thread runtime uses PrimJS (QuickJS). PrimJS is designed to be small and easy to embed in applications, with low runtime initialization cost as one of its key characteristics. For the problem we were trying to solve, startup cost mattered more than maximum throughput. It was a good fit for the sequence of preparing a runtime for a screen, executing the necessary JavaScript, and displaying the first UI.</p><ul><li><a href="https://lynxjs.org/guide/scripting-runtime/main-thread-runtime">Main Thread Runtime - Lynx</a></li><li><a href="https://github.com/lynx-family/primjs">GitHub - lynx-family/primjs: JavaScript Engine Optimized for Lynx</a></li></ul><p>A small, embeddable runtime also gives you more control over the execution environment itself. You can limit which APIs the host application exposes, place shared polyfills and commonly used code closer to the runtime, and evolve toward an architecture where resources are prepared before a user enters a screen.</p><p>We felt this made Lynx a strong foundation for building an execution environment we could control.</p><h4>2. A Dual-Thread Architecture Built Around the First Frame</h4><p>ReactLynx code plays different roles on the main thread and the background thread.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*2LKrS-iD_YU8F3CU.png" /><figcaption><a href="https://lynxjs.org/react/lifecycle">https://lynxjs.org/react/lifecycle</a></figcaption></figure><p>The main thread renders the first screen and applies subsequent UI updates. The background thread runs the full React runtime and handles component lifecycles and side effects. In other words, the first screen does not wait for background work to finish before rendering.</p><p>This architecture enables Lynx to provide IFR — Instant First-Frame Rendering. If the bundle and initial data are already available, the main thread can render the actual product UI immediately. Instead of passing through a blank screen or temporary state, the application can display the intended content in its very first frame.</p><p><a href="https://lynxjs.org/guide/interaction/ifr">Instant First-Frame Rendering (IFR) - Lynx</a></p><p>Of course, IFR comes with clear conditions. If the bundle must be downloaded asynchronously, you still have to wait for that download. If the data required for the first screen only becomes available after a network request, you have to wait for that data as well. The official Lynx documentation also explains that IFR cannot be achieved when the bundle and critical content cannot be prepared synchronously.</p><p>Interestingly, these constraints actually gave us a way to design the first screen at both the product and platform levels. We could decide whether to preload bundles, inject initial data from native, and determine which parts of the UI should appear in the first frame. The optimization surface was fundamentally different from simply trying to reduce loading time inside a WebView.</p><h4>3. A Familiar Toolchain and Incremental Integration</h4><p>Lynx applications can be built with a dedicated bundler called Rspeedy. Rspeedy is based on Rspack and Rsbuild and supports HMR. Rspack, which sits underneath it, is compatible with the Webpack ecosystem familiar to frontend engineers. That meant we could reuse many of the tools and concepts we were already accustomed to in frontend development.</p><p><a href="https://lynxjs.org/rspeedy/">Lynx</a></p><p>Bundles can also be operated separately from the app binary. At Karrot, we serve Lynx bundles through a CDN and have the native app fetch them by URL. This means we do not need to release a new version of the app every time frontend code changes. (Example: <a href="https://lynxjs.org/lynx-examples/hello-world/dist/main.lynx.bundle)">https://lynxjs.org/lynx-examples/hello-world/dist/main.lynx.bundle)</a></p><p>You can also choose how deeply Lynx integrates with an existing application. A LynxView can fill an entire screen inside the native View Hierarchy, or occupy only part of a screen between native Views. This approach — incrementally introducing Lynx where needed without rewriting the existing app — is known as Brownfield adoption.</p><p><a href="https://lynxjs.org/guide/embed-lynx-to-native.html">Embedding LynxView into Native View - Lynx</a></p><p>CSS support can be checked through Lynx’s compatibility data. Lynx publishes platform and version support for APIs, built-in elements, and individual CSS properties. For us, clearly documenting where Lynx differs from the web was just as important as providing web-like syntax.</p><p><a href="https://lynxjs.org/api/css/properties.html">Lynx</a></p><h3>The Alternatives</h3><p>The cross-platform ecosystem already has mature technologies such as React Native and Flutter. Both have large ecosystems and extensive production track records. So why not choose one of them? I wasn’t interested in deciding which technology was objectively better. I wanted to find the one that best matched the problem Karrot was trying to solve.</p><p>What we needed was not a new application framework responsible for the entire app. We needed a rendering layer that could replace individual screens.</p><h4>vs “React Native”</h4><p>React Native can also be introduced incrementally into an existing native app at the screen or View level. Its React ecosystem and rich collection of native modules are major advantages for product development. But what we were trying to build was not an entirely new app development environment. We wanted a rendering layer with a deliberately limited role inside our existing native application.</p><p>Under this model, allowing a single native module does not end with installing a package. You need to own its compatibility across iOS and Android, its interaction with the app lifecycle and permissions, its security implications, and its performance over the long term. If frontend code is deployed independently from the app binary, you also need to ensure that the native module version expected by a remote bundle matches the version actually installed in the app. Gradual rollout and rollback strategies become part of your responsibility as well. And the larger the ecosystem, the greater the expectations around the breadth of integrations the platform should support. If we ended up continuously restricting which libraries developers could use because of these operational costs, we risked creating a platform with a rich ecosystem on paper but where developers felt they “couldn’t actually use what they needed.”</p><p>Instead of maximizing the size of the ecosystem, we wanted an execution environment where we could explicitly choose which capabilities the host application would provide — and which operational responsibilities we were willing to own. At the time, Lynx’s limited surface area felt less like a limitation and more like a boundary we could control.</p><h4>vs “Flutter”</h4><p>Flutter also supports incremental integration into an existing native app through Add-to-app, at either the screen or View level. Its own rendering model is particularly strong at producing consistent UI and predictable performance across platforms, and it also provides accessibility support through Semantics.</p><p>However, Karrot wanted to preserve the OS behavior and accessibility standards of the existing native application while replacing some of the screens previously handled by WebView with a React-and-CSS development model and an independently deployable bundle workflow. Because Flutter renders UI through its own rendering model rather than through the OS, we would need to separately verify whether all the behavior accumulated in our existing native product would be preserved. Adopting Flutter would also mean operating and validating a new Dart- and Widget-based development stack, a Flutter-specific design system, the lifecycle of the engine and plugins, and accessibility in screens where Flutter and native Views are mixed.</p><p>In addition, under Flutter’s standard mobile deployment model, compiled Dart application code and assets are included in the deployment lifecycle of the app binary. That differed from the operating model we wanted, where frontend bundles could be served from a CDN and deployed independently from the app. It wasn’t that Flutter lacked capability. We simply concluded that it was further away from the limited, controllable rendering layer Karrot was trying to build at the time.</p><p>Ultimately, we were not comparing the number of things each technology could do, but the scope of new responsibilities we would have to own in order to reach the operating model we wanted.</p><p>There are clear limitations to this evaluation. We did not build equivalent PoCs with all three technologies, nor was this an attempt to rank their performance. This was a decision made under a specific set of constraints: keep the existing native app, start by replacing only some WebView screens, and begin the adoption with just three engineers.</p><h3>How We Evaluated a Technology With Few References</h3><p>The biggest uncertainty during adoption was the lack of real-world references. When introducing a new runtime and rendering engine into a production app, a list of features in the documentation is not enough. We needed to understand when runtimes were created and destroyed, how far errors were isolated, when native resources were released, and how implementations differed across platforms.</p><p>There were no blog posts or Q&amp;As that answered these questions. So how could we verify them? If the answers didn’t exist, we decided to read the code ourselves. We cloned the Lynx codebase locally, ran AI agents against it, searched for the implementation of specific APIs, compared iOS and Android code paths, traced runtime lifecycles, and checked whether the code matched the documentation.</p><pre># Clone the Lynx codebase locally<br>$ git clone https://github.com/lynx-family/lynx<br><br># Open an AI agent and ask what you want to know<br>$ claude<br>$ codex</pre><p>Code is the closest source of truth to how a system actually behaves today. AI agents reduced the cost of reaching the truth I wanted to find inside a large codebase. That is why we cared more about how much we could verify ourselves than about the number of references available.</p><h3>Keeping Uncertainty From Becoming Adoption Risk</h3><p>Technologies with few references inevitably come with uncertainty. Unexpected problems may surface. But uncertainty does not automatically mean risk. Risk depends on the damage caused when something fails, and on your ability to detect that failure and recover from it.</p><p>We started by applying Lynx to screens that already had equivalent WebView implementations. If something went wrong, we could switch back to the existing WebView. We did not migrate the entire application at once. We limited the blast radius of failure to individual screens and built the recovery path first.</p><p>We also separated questions that could be answered through architecture from those that needed to be verified through a PoC. By narrowing the scope of what we needed to prove with the PoC, even if one of our assumptions turned out to be wrong, the impact would remain contained within that scope. We did not understand everything about the technology before starting. We couldn’t have. Instead, we started only after establishing a way to investigate what we didn’t know — and a way back if we failed.</p><h3>A Production PoC With Two Screens</h3><p>We did not start by building an entire platform. Instead, we ran a PoC with two screens that had very different characteristics.</p><p>The first was the <strong>All Services</strong> page. Because its content changes relatively infrequently, we used it to validate static layouts and initial rendering.</p><p>The second was the <strong>Marketplace category feed</strong>. This screen loads real data, continuously appends items, and can be scrolled for long periods of time. It allowed us to validate data fetching and infinite scrolling behavior that would be difficult to understand from a static screen alone.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*YYFo8t8zgC-XYactrlBTnQ.png" /><figcaption>(Left) All Services / (Right) Marketplace Category Feed</figcaption></figure><p>On the frontend side, Gina, who was an intern at the time, implemented the Lynx views. Ray handled iOS, and Luke handled Android. Our success criteria were:</p><ol><li>Do not degrade existing business metrics.</li><li>Improve the time until the first content becomes visible compared with the existing WebView.</li><li>Operate reliably in production on both iOS and Android.</li><li>Be able to fall back to the existing WebView if something goes wrong.</li></ol><h4>Results</h4><p>We ran an experiment with a total of 1.9 million users, exposing 50% to the WebView and 50% to the Lynx view. We measured the time from the user’s tap on the previous screen until the first screen was rendered, using p75.</p><ul><li><strong>Android:</strong> Lynx was 18% faster. (735ms → 610ms)</li><li><strong>iOS:</strong> Lynx was 37% faster. (479ms → 302ms)</li></ul><p>To be honest, looking at the numbers alone, I was a little disappointed that the difference wasn’t larger. But the WebView implementation we were comparing against had gone through years of optimization, while this was our first Lynx implementation and we had very little experience with the framework, so it was far from optimized.</p><p>We only realized just how under-optimized it had been as we gained more experience later. We did not yet have basic policies around bundle optimization, chunk splitting, or Cache-Control, nor had we implemented more advanced optimizations such as bundle preloading or passing initial data directly into the first frame. Even in that state, however, Lynx performed better than a WebView we had spent years refining. That gave us a sense of how much room there was left to improve.</p><p>And the PoC screens actually ran in production. This was our first attempt at integrating a new JavaScript runtime and rendering engine into the app, and during the PoC period, we did not observe any crashes attributable to Lynx. That was enough for us to feel we could move on to the next stage.</p><h3>A New Alternative We Could Operate Together</h3><p>The benefits and costs of WebView were being felt by different parts of the organization. From a frontend perspective, WebView was an important tool that enabled fast development and independent deployment. From a mobile perspective, it was an execution environment whose memory usage and impact on app performance had to be continuously managed. Neither perspective was wrong. We were simply building the same product while directly bearing different costs.</p><p>We couldn’t solve this by simply saying, “Let’s use fewer WebViews,” or, “Let’s optimize them more.” There was no realistic alternative that could replace the development speed and deployment workflow WebView provided. But simply deciding to keep using WebView left the costs borne by mobile engineers unresolved. We needed an alternative that could accommodate both perspectives.</p><p>Lynx showed the potential to become that alternative. Frontend engineers could continue using React and CSS while preserving a bundle deployment workflow similar to what they were already used to. Mobile engineers, meanwhile, gained an option based on a lightweight runtime rather than WebView.</p><p>But this was not a technology that either frontend or mobile could complete alone. On the frontend side, we needed to create a development experience comparable to — or better than — WebView and establish migration paths for existing production screens. On the mobile side, we needed to design the lifecycle of runtimes and Views, resource loading, caching, native modules, and error isolation. And once deployment, interfaces, and the design system entered the picture, we had to own the same problem together.</p><p>Lynx could not remain solely a frontend technology or solely a mobile technology. We had to connect both sides of the implementation before it could actually be used in production. And when something went wrong, we needed to investigate the cause together.</p><p>I think this cultural change was every bit as important as the technical validation. Meeting more often can certainly improve collaboration, but the way teams work changes much more fundamentally when they encounter a shared problem that neither side can solve without the other. Lynx was that kind of problem. Rather than simply adopting a framework, we were laying a new set of rails that frontend and mobile could build and operate together.</p><h3>Turning It Into a Platform</h3><p>At first glance, Lynx and WebView may not look very different: both can fetch a remote bundle and render a screen. The real difference lies in the optimization opportunities that become available afterward. Once we had rendered our first screens, we began building an environment that would allow multiple service teams to develop with Lynx conveniently and reliably.</p><h4>1. Building the Bundle Deployment Flow on Top of Warp</h4><p>We did not start by building an entirely new deployment platform for Lynx bundles. Instead, we reused <strong>Warp</strong>, the static web hosting platform built by our Frontend Core Team. Built Lynx bundles are served through a CloudFront CDN. The native application receives a URL, fetches the Lynx bundle from it, and renders the screen.</p><p>The Lynx engine can fetch bundles through a resource loader injected by the host application. This allowed us to build the loading layer around the download and web caching policies already used by the app. It became the foundation for building native UI while preserving the frontend development and deployment workflow.</p><h4>2. Balancing Bundle Freshness and Startup Cost</h4><p>We apply a caching strategy that combines ETag and max-age to Lynx bundles. While a bundle is within the lifetime defined by max-age, we use the local copy without network revalidation. When revalidation is required, if the ETag still matches, we can verify freshness without downloading the entire file again. Combined with the native loader’s policy, this can also support limited offline scenarios.</p><p>We can also consider policies such as stale-while-revalidate. With this approach, the cached screen is shown first while the application checks for a newer bundle in the background. Depending on the characteristics of the product, we can choose where to balance freshness against the ability to open the first screen quickly.</p><h4>3. Reusing Shared Runtime Assets</h4><p>There is no need to include the same common polyfills in every screen bundle. If multiple Lynx screens reference the same version of immutable JavaScript assets, those assets only need to be downloaded once. By versioning URLs so they change only when their contents change, and applying long-lived caching policies, we can also increase cache hit rates. This architecture allows each screen’s product code to be deployed independently while sharing common runtime assets.</p><h4>4. Defining Native Interfaces With Our Own DSL</h4><p>Lynx screens need access to functionality provided by the native app. They need to read contextual information from the app, navigate to other screens, select images for composing posts, emit logging and analytics events, and call functionality that requires native permissions.</p><p>If bridge code is implemented manually on each platform, the interfaces can easily drift apart. iOS, Android, and frontend engineers might all interpret the same capability differently. To solve this, we created <strong>Lydl</strong>, our own Domain-Specific Language built with Langium, along with a Codegen pipeline that integrates those declarations into the codebase.</p><p>A single DSL definition acts as the source of truth. From it, we automatically generate the interface code needed by frontend, iOS, and Android, as well as Lynx bundles that can be used to test the behavior. By managing types and calling conventions in one place, we reduce duplicated implementation and errors caused by manual work.</p><pre>module MyModule {<br>  version &quot;1.0.0&quot;;<br><br>  error UnknownError = UNKNOWN_ERROR({});<br><br>  fn greet(req: { name: string }): string throws UnknownError;<br>}<br><br>docs {<br>  greet({ name: &quot;Tony&quot; }); // Hello, Tony!<br>}</pre><h4>5. Bringing SEED Design to Lynx</h4><p>Karrot’s design system, <strong>SEED Design</strong>, has also begun supporting Lynx. Lynx projects can use SEED’s design tokens, styles, and components. Packages such as @seed-design/lynx-react and @seed-design/lynx-css bring Karrot’s product language into Lynx screens.</p><p>Design system support is about more than matching colors and typography. It is about sharing product standards — including accessibility and interaction behavior — across multiple execution environments. It also provides a foundation that prevents Lynx screens from feeling like a separate experimental UI.</p><p><a href="https://seed-design.io/lynx">Lynx</a></p><p>As deployment, caching, shared assets, native interfaces, and the design system came together, Lynx began evolving from a single View into a platform that multiple teams could use.</p><h3>Choosing Which Screens to Expand Through Brownfield Adoption</h3><p>Karrot’s goal is not to rebuild the app in Lynx. We do not plan to migrate every secondary screen in bulk, nor do we intend to set a migration target and require every team to adopt it.</p><p>We evaluate candidates based on the characteristics of each screen.</p><ul><li>Screens with high PV, where improvements to initial loading and memory usage can have a meaningful impact</li><li>Screens where sharing an implementation between iOS and Android provides significant value</li><li>Screens where fast development and independent bundle deployment are important</li><li>Screens where we can safely fall back to the existing implementation if something goes wrong</li></ul><p>Some screens will therefore remain native, while for others WebView may continue to be the most economical option. What matters is not standardizing every screen on a single technology, but increasing the number of options available and reducing the cost of each choice.</p><p>There are also more optimizations left to explore. We still need to evaluate the effects and trade-offs of bundle preloading, runtime preparation — or prewarming — and native initial-data injection. Before expanding Lynx to more screens, we also need to continue improving observability, error isolation, and compatibility policies between bundle and engine versions.</p><h3>One More Option</h3><p>Back in March 2025, I decided to push forward with Lynx after seeing a small JavaScript runtime, a Dual-Thread Architecture centered around the first frame, a toolchain familiar to frontend engineers, and an architecture that could be introduced into an existing app only where needed.</p><p>But choosing a technology and building a development platform that multiple organizations can use reliably and comfortably are two very different things. Gina implemented the first screens. Ray and Luke integrated the runtime into the production iOS and Android apps, respectively. We built a bundle deployment flow on top of Warp, designed caching and native interfaces, and SEED Design began supporting Lynx as well.</p><p>I don’t think Lynx is the one right answer that should replace every screen. It is closer to an additional option — one that lets us preserve the native foundation of the app while building some of the screens previously handled by WebView in a different way.</p><p>Karrot no longer has to choose only between native and WebView. We are beginning to have a new execution environment that can be selected based on the characteristics of each screen, along with the rails required to operate that environment. Those rails were not built by a single technology. They are the result of frontend and mobile engineers owning the same problem and solving it together.</p><h3>Where We Use Lynx Today</h3><p>Karrot currently uses Lynx on the screens below, and several teams are planning to introduce Lynx to additional key product surfaces. I’ll continue updating this post as things change. <em>(Updated August 12, 2026)</em></p><ul><li><strong>[Korea] Marketplace Detail → Share Bottom Sheet</strong> (Appears when you tap the Share icon in the upper-right corner.)</li><li><strong>[Korea] My Karrot → Recently Viewed</strong></li><li><strong>[Korea] My Karrot → Favorites</strong> (Appears when you pull down on the My Karrot screen.)</li></ul><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*4mT9jJZiKWV1Qjame0bEEw.png" /><figcaption>(Left) Share Bottom Sheet / (Center) Recently Viewed / (Right) Favorites</figcaption></figure><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=db1aa92af5e4" width="1" height="1" alt=""><hr><p><a href="https://medium.com/daangn/laying-the-rails-beyond-webview-why-karrot-chose-lynx-db1aa92af5e4">Laying the Rails Beyond WebView: Why Karrot Chose Lynx</a> was originally published in <a href="https://medium.com/daangn">당근 기술 블로그</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[웹뷰 다음의 레일을 깔다: 당근이 Lynx를 선택한 이유]]></title>
            <link>https://medium.com/daangn/%EC%9B%B9%EB%B7%B0-%EB%8B%A4%EC%9D%8C%EC%9D%98-%EB%A0%88%EC%9D%BC%EC%9D%84-%EA%B9%94%EB%8B%A4-%EB%8B%B9%EA%B7%BC%EC%9D%B4-lynx%EB%A5%BC-%EC%84%A0%ED%83%9D%ED%95%9C-%EC%9D%B4%EC%9C%A0-34dd50abbfb7?source=rss----4505f82a2dbd---4</link>
            <guid isPermaLink="false">https://medium.com/p/34dd50abbfb7</guid>
            <category><![CDATA[software-engineering]]></category>
            <category><![CDATA[engineering]]></category>
            <category><![CDATA[lynx]]></category>
            <dc:creator><![CDATA[Tony W]]></dc:creator>
            <pubDate>Wed, 12 Aug 2026 09:03:40 GMT</pubDate>
            <atom:updated>2026-08-12T09:46:36.451Z</atom:updated>
            <content:encoded><![CDATA[<figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*QzTrArCkGbZiDTI3SQn6dw.png" /></figure><p>안녕하세요, 당근 프론트엔드코어팀 리더 Tony입니다. 저희 팀은 프론트엔드 엔지니어들이 쓰는 개발 환경과 배포 인프라를 만들어요. 이 글은 레퍼런스가 거의 없던 기술 Lynx를 검토해서 프로덕션 앱에 넣기까지의 기록이에요. 새로운 기술을 도입할지 고민해 본 분이라면 판단의 과정이 참고가 될 거예요.</p><h3>웹뷰와 성능 문제</h3><p>당근 앱의 여러 화면은 웹뷰로 만들어져 있습니다. 웹뷰는 화면을 열 때 웹 문서와 데이터를 네트워크에서 가져와 실행하는데, 이 초기 로딩 문제가 오래된 과제였어요. SSR을 활용하면 서버에서 미리 웹 문서를 그리는 식으로 JavaScript 평가와 DOM 조작의 성능 병목을 줄일 수 있어요. 하지만 웹 문서와 초기 데이터를 네트워크로 가져와야 하는 경로 자체는 남습니다.</p><p>실제 웹 화면과 비슷한 네이티브 화면을 웹뷰가 로드되기 전에 보여주는 방법도 있어요. 서비스 워커로 클라이언트 앱을 캐싱해 네트워크 요청 이전에 UI를 그리는 방법도 있고요. 다만 이런 최적화는 제품 복잡도를 높입니다. 주어진 제약 안에서 최적화하는 것도 중요하지만, 개발자가 의도한 UI를 첫 프레임부터 그릴 수 있는 실행 환경이 필요했어요.</p><p>우리가 정말 풀고 싶었던 문제는 로딩 시간을 몇 밀리초 더 줄이는 데 있지 않았습니다. 사용자가 로딩을 느끼지 못하게 만들고 싶었습니다.</p><h3>새로운 실행 환경에 필요했던 조건</h3><ol><li>사용자가 만나는 첫 프레임에는 분리된 임시 화면이 아닌 실제 제품 UI가 보여야 했습니다.</li><li>네이티브 앱을 유지한 채 화면 단위로 적용할 수 있어야 했습니다.</li><li>프론트엔드 번들은 앱 바이너리와 독립적으로 배포할 수 있어야 했습니다.</li><li>큰 전담 조직 없이 프론트엔드, iOS, Android 엔지니어 한 명씩으로 시작할 수 있어야 했습니다.</li><li>문제가 생기면 기존 웹뷰로 돌아갈 수 있어야 했습니다.</li></ol><p>당근 앱 전체를 새로운 크로스플랫폼 프레임워크로 교체하는 일은 목표가 아니었어요. 당근에서 콘텐츠를 만나는 핵심 경험인 홈 피드, 실제 거래가 일어나는 채팅처럼 비즈니스의 중심이 되는 화면은 빠르고 안정적인 네이티브 스택으로 유지해야 했고요. 우리가 찾은 것은 웹뷰가 맡아온 일부 화면을 더 빠르고 깊게 최적화할 수 있는 렌더링 계층이었습니다.</p><p>2025년 3월, 한 동료가 슬랙에 Lynx를 소개하는 글을 올렸어요. 제가 찾던 조건과 맞닿은 설계가 하나씩 눈에 들어왔습니다.</p><h3>Lynx?</h3><p><a href="https://lynxjs.org/">Lynx</a></p><p>Lynx는 React와 CSS에 가까운 개발 모델로 iOS와 Android의 UI를 만들 수 있는 크로스플랫폼 기술이에요. 모바일 UI 전체를 Lynx로 구성할 수도 있고, 기존 네이티브 구성 안에 UI 블록으로 넣을 수도 있어요.</p><p>Lynx를 검토하며 가장 먼저 살펴본 것은 React나 CSS의 사용법이 아니었습니다. Lynx가 채택한 JavaScript 엔진과 첫 화면의 렌더링 과정부터 눈에 들어왔어요.</p><h4>1. 작고 빠르게 시작하는 JavaScript 엔진</h4><p>JavaScript 엔진을 평가할 때 보통 실행 성능부터 비교해요. 복잡한 연산의 처리 속도와 벤치마크 점수도 중요한 기준이지만, 모바일 UI에서는 런타임을 준비하는 비용도 사용자의 로딩 경험에 직접 영향을 줍니다. 사용자는 화면이 열리는 순간을 경험하니까요.</p><p>Lynx의 메인 스레드 런타임은 PrimJS(QuickJS)를 사용해요. PrimJS는 작고 애플리케이션에 삽입하기 쉬운 엔진으로 설계됐고, 낮은 런타임 초기화 비용이 주요 특성이에요. 우리가 풀려던 문제에서는 최대 처리량보다 엔진 시작 비용이 더 중요했습니다. 화면 하나를 위해 런타임을 준비하고, 필요한 JavaScript를 실행하고, 첫 UI를 보여주는 과정에 잘 맞는 특성이었어요.</p><ul><li><a href="https://lynxjs.org/guide/scripting-runtime/main-thread-runtime">Main Thread Runtime - Lynx</a></li><li><a href="https://github.com/lynx-family/primjs">GitHub - lynx-family/primjs: JavaScript Engine Optimized for Lynx</a></li></ul><p>작고 임베드 가능한 런타임은 실행 환경을 직접 구성할 여지도 줘요. 호스트 앱이 노출할 API를 제한할 수 있고, 공통 Polyfill과 자주 사용하는 코드를 런타임 가까이에 둘 수 있어요. 화면 진입 전에 필요한 리소스를 준비하는 구조로 발전시킬 수도 있고요.</p><p>통제할 수 있는 실행 환경을 만드는 출발점으로 적합하다고 판단했습니다.</p><h4>2. 첫 프레임을 위한 Dual-Thread Architecture</h4><p>ReactLynx의 코드는 메인 스레드와 백그라운드 스레드에서 서로 다른 역할을 수행해요.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*2LKrS-iD_YU8F3CU.png" /><figcaption><a href="https://lynxjs.org/react/lifecycle">https://lynxjs.org/react/lifecycle</a></figcaption></figure><p>메인 스레드는 첫 화면을 그리고 이후 UI 변경을 반영해요. 백그라운드 스레드는 완전한 React 런타임을 바탕으로 컴포넌트 생명주기와 부수 효과를 처리하고요. 첫 화면을 백그라운드 작업이 끝난 뒤에 그리는 구조가 아닙니다.</p><p>이 구조를 바탕으로 Lynx는 IFR(Instant First-Frame Rendering, 즉각적인 첫 프레임 렌더링)을 제공해요. 번들과 초기 데이터가 준비돼 있다면 메인 스레드에서 제품 UI를 바로 그릴 수 있어요. 흰 화면이나 임시 상태를 거치지 않고 애플리케이션이 의도한 콘텐츠를 첫 화면에 표시합니다.</p><p><a href="https://lynxjs.org/guide/interaction/ifr">Instant First-Frame Rendering (IFR) - Lynx</a></p><p>물론 IFR에도 명확한 조건이 있어요. 번들을 비동기로 내려받아야 한다면 다운로드를 기다려야 하고, 첫 화면의 핵심 데이터가 네트워크 요청 뒤에 준비된다면 데이터도 기다려야 해요. Lynx 공식 문서도 번들과 핵심 콘텐츠를 동기적으로 준비할 수 없는 경우 IFR을 달성할 수 없다고 설명합니다.</p><p>흥미롭게도 이 제약 덕분에 첫 화면을 제품과 플랫폼 차원에서 설계할 수 있었어요. 번들을 미리 내려받을지, 초기 데이터를 네이티브에서 주입할지, 어떤 UI를 첫 프레임에 포함할지 결정할 수 있었죠. 웹뷰 안에서 로딩을 줄이는 작업과는 최적화의 위치가 달랐습니다.</p><h4>3. 익숙한 툴체인과 점진적인 통합</h4><p>Lynx 애플리케이션은 Rspeedy라는 별도의 번들러로 구성할 수 있어요. Rspeedy는 Rspack과 Rsbuild를 기반으로 하며 HMR을 지원해요. 근간인 Rspack은 프론트엔드 엔지니어에게 익숙한 Webpack 생태계와 호환되는 툴체인이라, 기존 프론트엔드 개발에서 사용하던 도구와 개념을 그대로 활용할 수 있었습니다.</p><p><a href="https://lynxjs.org/rspeedy/">Lynx</a></p><p>번들은 앱 바이너리와 분리해 운영할 수 있어요. 당근에서는 Lynx 번들을 CDN으로 제공하고 네이티브 앱이 URL을 통해 가져오도록 구성했어요. 프론트엔드 코드를 수정할 때마다 앱을 새로 배포할 필요가 없는 구조입니다. (예시: <a href="https://lynxjs.org/lynx-examples/hello-world/dist/main.lynx.bundle">https://lynxjs.org/lynx-examples/hello-world/dist/main.lynx.bundle</a>)</p><p>기존 앱과의 통합 범위도 선택할 수 있어요. LynxView를 네이티브 View Hierarchy에 넣어 화면 전체를 구성할 수도 있고, 여러 네이티브 View 사이의 일부 영역에만 배치할 수도 있어요. 이렇게 기존 앱을 다시 작성하지 않고 필요한 영역부터 점진적으로 도입하는 방식을 Brownfield라고 부릅니다.</p><p><a href="https://lynxjs.org/guide/embed-lynx-to-native.html">Embedding LynxView into Native View - Lynx</a></p><p>CSS 지원 범위는 호환성 데이터로 확인할 수 있어요. Lynx는 API, 내장 Element, CSS 속성별로 지원 플랫폼과 버전을 공개해요. 웹과 비슷한 문법을 제공하는 것만큼, 웹과 다른 지점을 분명히 설명하는 태도가 중요했습니다.</p><p><a href="https://lynxjs.org/api/css/properties.html">Lynx</a></p><h3>다른 선택지</h3><p>크로스 플랫폼 영역에는 React Native, Flutter 같은 성숙한 기술들이 이미 자리를 잡고 있어요. 두 기술 모두 큰 생태계와 충분한 프로덕션 사례를 갖고 있고요. 그런데 왜 이 둘이 아니었냐고요? 저는 누가 더 좋은 기술인지 가리고 싶지는 않았습니다. 당근이 풀고 싶은 문제와 더 잘 맞는 쪽을 찾고 싶었어요.</p><p>우리에게 필요한 것은 앱 전체를 책임지는 새로운 애플리케이션 프레임워크가 아니라, 화면 단위로 대체할 수 있는 렌더링 계층이었습니다.</p><h4>vs “React Native”</h4><p>React Native도 기존 네이티브 앱에 화면이나 View 단위로 점진적으로 도입할 수 있어요. React 생태계와 풍부한 네이티브 모듈은 제품 개발에 큰 강점이고요. 하지만 우리가 만들려던 것은 새로운 앱 개발 환경 전체가 아니라, 기존 네이티브 앱 안에서 제한된 역할을 수행하는 렌더링 계층이었습니다.</p><p>이 조건에서 네이티브 모듈을 하나 허용하는 일은 패키지를 설치하는 데서 끝나지 않아요. iOS와 Android의 호환성, 앱의 생명주기와 권한, 보안, 성능까지 장기적으로 소유해야 해요. 특히 프론트엔드 코드를 앱 바이너리와 독립적으로 배포하려면, 원격 번들이 기대하는 네이티브 모듈 버전과 앱에 실제로 들어 있는 버전이 어긋나지 않게 관리하는 일, 점진 배포와 롤백까지 책임져야 하고요. 생태계가 클수록 지원해야 할 통합 범위에 대한 기대도 함께 커집니다. 이런 부담 때문에 일부 라이브러리 사용을 계속 제한한다면, 생태계는 풍부하지만 “필요한 것을 사용할 수 없는 플랫폼”이라는 인식을 줄 위험이 있었어요.</p><p>따라서 우리는 생태계의 범위를 좁히는 대신, 호스트 앱이 제공할 기능과 운영 책임을 명시적으로 선택할 수 있는 실행 환경을 원했어요. Lynx의 제한된 표면은 당시 우리에게 부족함이라기보다 통제 가능한 경계에 가까웠습니다.</p><h4>vs “Flutter”</h4><p>Flutter도 Add-to-app을 통해 기존 네이티브 앱에 화면이나 View 단위로 점진적으로 도입할 수 있어요. 자체 렌더링 모델은 플랫폼 간 일관된 UI와 예측 가능한 성능을 만드는 데 강점이 있고, Semantics를 통한 접근성 지원도 제공하고요.</p><p>다만 당근은 기존 네이티브 앱의 OS 동작과 접근성 기준을 유지하면서, 웹뷰가 담당하던 일부 화면을 React·CSS 개발 모델과 독립적인 번들 배포 흐름으로 대체하려 했어요. Flutter는 OS가 아니라 자체 렌더링 모델로 UI를 그리기 때문에, 기존 네이티브 제품에서 축적한 동작이 그대로 보존되는지는 별도로 검증해야 해요. Flutter를 도입하면 Dart와 Widget 기반의 개발 체계, Flutter용 디자인 시스템, 엔진과 Plugin의 생명주기, 네이티브 View와 혼합된 화면의 접근성을 새롭게 운영하고 검증해야 하고요.</p><p>또한 Flutter의 표준 모바일 배포 모델에서는 컴파일된 Dart 애플리케이션 코드와 자산이 앱 바이너리의 배포 주기에 포함돼요. 프론트엔드 번들을 CDN으로 제공하고 앱과 독립적으로 배포하려던 우리의 운영 방식과는 차이가 있었어요. Flutter의 기능이 부족해서라기보다, 당시 당근이 만들려던 제한적이고 통제 가능한 렌더링 계층까지의 거리가 더 멀다고 판단했습니다.</p><p>결국 비교한 것은 각 기술이 할 수 있는 기능의 수가 아니라, 당근이 원하는 운영 모델까지 가기 위해 새롭게 소유해야 할 책임의 범위였습니다.</p><p>이 판단에는 분명한 한계도 있어요. 두 기술로 같은 수준의 PoC를 진행해 비교한 결과가 아니고, 세 기술의 성능 순위를 정한 평가도 아니에요. 기존 네이티브 앱을 유지하고, 웹뷰가 맡은 일부 화면부터 적용하고, 단 세 명으로 도입을 시작한다는 조건 아래 내린 선택이었습니다.</p><h3>레퍼런스가 적은 기술을 검증하는 방법</h3><p>도입 평가에서 가장 큰 불확실성은 시장의 레퍼런스가 적다는 점이었어요. 프로덕션 앱에 새로운 런타임과 렌더링 엔진을 넣으려면 문서의 기능 목록만으로는 부족해요. 런타임이 언제 생성되고 해제되는지, 오류가 어디까지 격리되는지, 네이티브 리소스가 언제 정리되는지, 플랫폼별 구현이 어떻게 다른지 알아야 하니까요.</p><p>이런 질문에 답해 주는 블로그나 질의응답은 없었어요. 그럼 어떻게 검증할까요? 답이 없다면 코드를 직접 보기로 했습니다. Lynx의 코드베이스를 내려받아 AI 에이전트를 실행해 특정 API의 구현 위치를 찾고, iOS와 Android의 코드 경로를 비교하고, 런타임의 생명주기를 따라가고, 문서와 코드가 일치하는지 확인했어요.</p><pre># 로컬에 Lynx 코드베이스를 Clone<br>$ git clone https://github.com/lynx-family/lynx<br><br># AI 에이전트 열어서 궁금한 것 물어보기<br>$ claude<br>$ codex</pre><p>코드는 현재 동작에 가장 가까운 진실의 원천입니다. AI 에이전트는 큰 코드베이스에서 내가 찾고 싶은 진실에 접근하는 비용을 낮췄어요. 레퍼런스의 수보다 직접 검증할 수 있는 범위를 더 중요하게 본 이유입니다.</p><h3>불확실성을 도입 위험으로 만들지 않기</h3><p>레퍼런스가 적은 기술에는 불확실성이 있어요. 예상하지 못한 문제가 발견될 수 있고요. 하지만 불확실성이 곧 위험을 뜻하지는 않습니다. 위험은 실패했을 때의 피해와, 실패를 발견하고 되돌리는 능력에 따라 달라져요.</p><p>같은 기능을 제공하는 웹뷰가 이미 있는 화면부터 Lynx 적용을 시작했어요. 문제가 생기면 기존 웹뷰로 돌아갈 수 있도록 구성했고, 앱 전체를 한 번에 전환하지 않았어요. 실패의 영향을 특정 화면으로 제한하고 복구 경로를 먼저 만들었습니다.</p><p>아키텍처로 설명할 수 있는 부분과 PoC로 확인해야 하는 부분도 나눴어요. PoC로 확인할 범위를 좁혀둔 덕분에, 검증이 틀리더라도 영향이 그 범위 안에 갇혀요. 기술의 모든 것을 알고 시작한 것은 아니에요. 그럴 수도 없었고요. 대신 모르는 부분을 확인하는 방법과 실패했을 때 돌아갈 방법을 갖춘 뒤에 시작했습니다.</p><h3>두 화면으로 진행한 프로덕션 PoC</h3><p>처음부터 플랫폼 전체를 만들지는 않았어요. 성격이 다른 두 화면으로 PoC를 진행했습니다.</p><p>첫 번째는 전체 서비스 보기 페이지였어요. 콘텐츠 변화가 비교적 적은 화면에서 정적인 레이아웃과 초기 렌더링을 검증했어요.</p><p>두 번째는 중고거래 카테고리 피드였어요. 실제 데이터를 불러오고 목록을 추가하며 사용자가 긴 시간 스크롤하는 화면이라, 정적인 화면만으로는 알기 어려운 데이터 페칭과 무한 스크롤의 동작을 확인할 수 있었어요.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*YYFo8t8zgC-XYactrlBTnQ.png" /><figcaption>(좌) 전체 서비스 보기 화면 / (우) 중고거래 카테고리 피드</figcaption></figure><p>프론트엔드에서는 당시 인턴이었던 Gina가 Lynx 뷰를 구현했고, iOS에서는 Ray가, Android에서는 Luke가 함께했어요. 성공 기준은 다음과 같습니다.</p><ol><li>기존 비즈니스 지표를 악화시키지 않을 것</li><li>첫 콘텐츠가 보이는 시점을 기존 웹뷰보다 개선할 것</li><li>iOS와 Android의 프로덕션 환경에서 안정적으로 동작할 것</li><li>문제가 생기면 기존 웹뷰로 돌아갈 수 있을 것</li></ol><h4>측정 결과</h4><p>총 190만명을 대상으로 웹뷰 50%, Lynx 뷰 50%를 노출했어요. 이전 화면에서의 터치 시간부터 첫 화면이 그려지기까지의 시간을 측정했어요. (p75 기준)</p><ul><li>Android: Lynx가 18% 더 빨랐어요. (735ms -&gt; 610ms)</li><li>iOS: Lynx가 37% 더 빨랐어요. (479ms -&gt; 302ms)</li></ul><p>솔직히 결과만 봤을 때는 변화가 크지 않아 조금 아쉬웠어요. 하지만 비교 대상인 웹뷰는 오랜 기간 여러 최적화를 거친 화면이었고, Lynx는 경험이 적은 상태에서 처음 만든 구현이다보니 최적화가 부족했어요.</p><p>최적화가 부족했다는 사실은 점차 경험이 쌓이면서 나중에 알게 되었어요. 번들 최적화, Chunk Splitting, Cache-Control 같은 기본적인 정책도 갖춰지지 않았고, 번들 프리로딩이나 초기 데이터를 첫 프레임에 전달하는 등 수준 높은 최적화도 진행하지 못했어요. 다만, 그런 상태에서도 오래 다듬은 웹뷰보다 더 나은 결과를 냈습니다. 이 결과로 앞으로 개선할 수 있는 폭을 확인했어요.</p><p>그리고 PoC 화면은 실제 프로덕션에서 동작했어요. 새로운 JavaScript 런타임과 렌더링 엔진을 앱에 통합한 첫 시도였는데, PoC 기간에 Lynx로 인한 크래시는 발견할 수 없었어요. 이 정도면 다음 단계로 가볼 수 있겠다는 생각이 들었어요.</p><h3>함께 운영할 수 있는 대안이 생기다</h3><p>웹뷰의 장점과 비용은 서로 다른 영역에서 체감되고 있었어요. 프론트엔드 관점에서 웹뷰는 빠른 개발과 독립적인 배포를 가능하게 하는 중요한 도구였어요. 모바일 관점에서는 메모리와 앱 성능을 계속 신경 써야 하는 실행 환경이었고요. 어느 한쪽이 틀린 문제는 아니었습니다. 같은 제품을 만들면서 서로 다른 비용을 직접 마주하고 있었을 뿐이에요.</p><p>“웹뷰 사용을 줄이자”, “더 최적화하자”라는 말만으로는 이 문제를 풀 수 없었어요. 빠른 개발과 배포 흐름을 대신할 현실적인 선택지가 없었으니까요. 웹뷰를 계속 쓰자는 결론만으로는 모바일에서 감당하는 비용이 남았고요. 두 관점을 함께 가져갈 수 있는 대안이 필요했습니다.</p><p>Lynx는 그 대안이 될 가능성을 보여줬어요. 프론트엔드에서는 React와 CSS를 활용하고 기존과 비슷한 번들 배포 흐름을 유지할 수 있었어요. 모바일에서는 웹뷰가 아닌 경량 런타임 기반의 선택지를 확보할 수 있었고요.</p><p>어느 한 영역만으로 완성할 수 있는 기술은 아니었어요. 프론트엔드에서는 웹뷰와 비슷하거나 그보다 더 나은 개발 경험을 만들고, 운영 중인 제품 화면의 마이그레이션 Path도 만들어야 했어요. 모바일에서는 런타임과 View의 생명주기, 리소스 로딩, 캐시, 네이티브 모듈, 오류 격리를 설계해야 했고요. 배포와 인터페이스, 디자인 시스템까지 연결하려면 같은 문제를 함께 소유해야 했습니다.</p><p>Lynx는 프론트엔드와 모바일 중 어느 한 영역의 기술로 남을 수 없었어요. 서로의 구현을 연결해야 실제 프로덕션에서 쓸 수 있었고, 문제가 생겼을 때도 함께 원인을 찾아야 했어요.</p><p>저는 이 문화적 변화가 기술 검증만큼 중요했다고 생각합니다. 협업을 위해 더 자주 만나는 것도 필요하지만, 서로 없이는 풀 수 없는 공통의 문제가 생길 때 일하는 방식은 더 크게 달라져요. Lynx는 그런 문제였어요. 하나의 프레임워크를 도입한 것이 아니라, 프론트엔드와 모바일이 함께 만들고 운영할 수 있는 새로운 레일을 만든 셈입니다.</p><h3>플랫폼으로 만들기</h3><p>원격 번들을 받아 화면을 그리는 것만 보면 Lynx와 웹뷰가 크게 달라 보이지 않을 수 있어요. 하지만 차이는 이후에 쌓을 수 있는 최적화 지점에서 생깁니다. 첫 화면을 만든 뒤에는 여러 서비스 팀이 편하고 안정적으로 개발할 수 있는 환경을 구축했어요.</p><h4>1. Warp 위에 번들 배포 흐름을 만들다</h4><p>Lynx 번들을 위해 새로운 배포 플랫폼부터 만들지는 않았어요. 대신 프론트엔드코어팀이 만든 Warp를 활용했어요. Warp는 정적 웹 호스팅 플랫폼으로, 빌드된 Lynx 번들은 CloudFront CDN으로 제공돼요. 네이티브 앱은 전달받은 URL로 Lynx 번들을 불러와 화면을 렌더링합니다.</p><p>Lynx 엔진은 호스트 애플리케이션이 주입한 리소스 로더를 통해 번들을 가져올 수 있어요. 덕분에 기존 앱의 다운로드 및 웹 캐시 정책에 맞춰 로딩 계층을 구성할 수 있었어요. 프론트엔드의 개발과 배포 흐름을 유지하면서 네이티브 UI를 만드는 기반이 됐습니다.</p><h4>2. 번들 캐시의 최신성과 시작 비용을 함께 다루다</h4><p>Lynx 번들에는 ETag와 max-age를 조합한 캐싱을 적용했어요. max-age로 정한 유효기간에는 네트워크 재검증 없이 로컬 번들을 사용해요. 재검증이 필요한 시점에 ETag가 일치하면 전체 파일을 다시 받지 않고 최신 여부만 확인하고요. 네이티브 로더의 정책과 결합하면 제한적인 오프라인 환경도 지원할 수 있습니다.</p><p>stale-while-revalidate 정책도 검토할 수 있어요. 캐시된 화면을 먼저 보여주고 백그라운드에서 새 번들을 확인하는 구조예요. 최신성을 유지하는 방식과 첫 화면을 빠르게 여는 방식 사이에서, 제품 특성에 맞는 정책을 선택할 수 있습니다.</p><h4>3. 공통 실행 자산을 재사용하다</h4><p>공통 Polyfill을 모든 화면 번들에 반복해서 넣을 필요는 없어요. 여러 Lynx 화면이 같은 버전의 불변 JavaScript 자원을 바라보게 만들면 한 번 받은 자산을 다시 사용할 수 있어요. 콘텐츠가 바뀔 때만 URL이 바뀌도록 버전화하고 긴 캐시 정책을 적용하면 캐시 적중률도 높아지고요. 이 구조에서는 각 화면의 제품 코드를 독립적으로 배포하면서 공통 실행 환경을 공유할 수 있습니다.</p><h4>4. 자체 DSL로 네이티브 인터페이스를 정의하다</h4><p>Lynx 화면은 네이티브 앱의 기능을 사용해야 해요. 네이티브 앱에 있는 맥락 정보를 읽고, 다른 화면으로 이동하고, 글쓰기에 필요한 이미지를 고르고, 이벤트 로깅과 분석, 권한이 필요한 기능을 호출해야 하죠.</p><p>플랫폼마다 브리지 코드를 수동으로 작성하면 인터페이스가 어긋나기 쉬워요. iOS와 Android, 프론트엔드가 같은 기능을 다르게 이해할 수도 있고요. 이 문제를 해결하기 위해 Langium 기반의 자체 DSL(Domain-specific Language)인 Lydl과 해당 선언이 코드와 통합되는 Codegen 파이프라인을 만들었습니다.</p><p>하나의 DSL 정의를 진실의 원천으로 사용하면서 프론트엔드와 iOS, Android에 필요한 인터페이스 코드와 동작을 테스트해 볼 Lynx 번들을 자동으로 생성하고요. 타입과 호출 규약을 한 곳에서 관리해 반복 구현과 수동 작업에서 생기는 오류를 줄였습니다.</p><pre>module MyModule {<br>  version &quot;1.0.0&quot;;<br><br>  error UnknownError = UNKNOWN_ERROR({});<br><br>  fn greet(req: { name: string }): string throws UnknownError;<br>}<br><br>docs {<br>  greet({ name: &quot;Tony&quot; }); // Hello, Tony!<br>}</pre><h4>5. SEED Design을 연결하다</h4><p>당근의 디자인 시스템인 SEED Design도 Lynx를 지원하기 시작했어요. Lynx 프로젝트에서 SEED의 디자인 토큰과 스타일, 컴포넌트를 사용할 수 있어요. @seed-design/lynx-react 와 @seed-design/lynx-css 같은 패키지가 당근의 제품 언어를 Lynx 화면에 연결해요.</p><p>디자인 시스템 지원은 색상과 타이포그래피를 맞추는 데서 끝나지 않아요. 접근성과 인터랙션을 포함한 제품의 기준을 여러 실행 환경에서 공유하는 일이에요. Lynx 화면이 별도의 실험적인 UI로 보이지 않게 만드는 기반이기도 합니다.</p><p><a href="https://seed-design.io/lynx">Lynx</a></p><p>배포, 캐싱, 공통 자산, 네이티브 인터페이스, 디자인 시스템이 연결되며, Lynx는 하나의 View에서 여러 팀이 사용할 수 있는 플랫폼으로 바뀌기 시작했습니다.</p><h3>Brownfield로 확장할 화면을 고르다</h3><p>당근의 목표는 앱을 Lynx로 다시 만드는 것이 아니에요. 세부 화면을 일괄적으로 마이그레이션할 계획도 없고, 전환 목표를 정해 모든 팀에 적용하는 방식도 생각하지 않아요.</p><p>적용 후보는 화면의 특성으로 판단합니다.</p><ul><li>PV가 높아 초기 로딩과 메모리 개선 효과가 큰 화면</li><li>iOS와 Android에서 공통 구현의 가치가 큰 화면</li><li>빠른 개발과 독립적인 번들 배포가 중요한 화면</li><li>문제 발생 시 기존 구현으로 안전하게 복귀할 수 있는 화면</li></ul><p>따라서 어떤 화면은 계속 네이티브로 남고, 웹뷰가 가장 경제적인 화면도 있을 거예요. 모든 화면을 하나의 기술로 통일하는 일보다 선택지를 확보하고 각 선택의 비용을 낮추는 일이 중요합니다.</p><p>앞으로 검토할 최적화도 남아 있어요. 번들 프리로딩, 런타임 준비(Prewarm), 네이티브 초기 데이터 주입의 효과와 비용을 확인해야 해요. 더 많은 화면에 적용하기 전에 관측성과 오류 격리, 번들 및 엔진 버전 호환 정책도 계속 다듬어야 하고요.</p><h3>다음의 선택지</h3><p>2025년 3월에는 작은 JavaScript 런타임, 첫 프레임을 중심에 둔 Dual-Thread Architecture, 프론트엔드에 익숙한 툴체인, 기존 앱에 필요한 만큼 넣을 수 있는 구조를 보고 Lynx 도입을 추진하기로 마음먹었어요.</p><p>하지만 기술을 선택하는 일과, 여러 조직이 안정적이고 편하게 사용할 수 있는 개발 플랫폼을 만드는 일은 달랐습니다. Gina가 첫 화면을 구현했고, Ray와 Luke가 각각 iOS와 Android의 프로덕션 앱에 런타임을 통합했어요. Warp 위에 번들 배포 흐름을 만들고 캐시와 네이티브 인터페이스를 설계했고요. SEED Design도 Lynx를 지원하기 시작했어요.</p><p>Lynx가 모든 화면을 대체하는 정답이라고 생각하지는 않아요. 네이티브의 기반을 유지하면서, 웹뷰가 맡던 일부 화면을 다른 방식으로 만들 수 있게 해 주는 선택지에 가까워요.</p><p>당근은 이제 네이티브와 웹뷰 사이에서만 선택하지 않습니다. 화면의 특성에 따라 사용할 수 있는 새로운 실행 환경과, 그 환경을 운영할 레일을 갖추기 시작했어요. 그 레일은 하나의 기술만으로 만들어지지 않았습니다. 프론트엔드와 모바일이 같은 문제를 함께 소유하고 풀어가며 만든 결과입니다.</p><h3>현재 적용 현황</h3><p>현재 당근에서는 아래 화면들에 Lynx를 사용하고 있어요. 그리고 현재 여러 팀들이 주요 화면에 Lynx 도입을 계획하고 있어요. 업데이트가 있다면 이 글에 지속적으로 추가할 예정이에요. (2026년 8월 12일 업데이트)</p><ul><li>[한국] 중고거래 상세페이지 &gt; 공유하기 Bottom Sheet (우상단 공유하기 아이콘을 누르면 나옵니다)</li><li>[한국] 나의 당근 &gt; 최근 본 글 화면</li><li>[한국] 나의 당근 &gt; 즐겨찾기 화면 (나의 당근 화면을 아래로 당겼을 때 나옵니다)</li></ul><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*4mT9jJZiKWV1Qjame0bEEw.png" /><figcaption>(좌) 공유하기 Bottom Sheet / (중앙) 최근 본 글 / (우) 즐겨찾기</figcaption></figure><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=34dd50abbfb7" width="1" height="1" alt=""><hr><p><a href="https://medium.com/daangn/%EC%9B%B9%EB%B7%B0-%EB%8B%A4%EC%9D%8C%EC%9D%98-%EB%A0%88%EC%9D%BC%EC%9D%84-%EA%B9%94%EB%8B%A4-%EB%8B%B9%EA%B7%BC%EC%9D%B4-lynx%EB%A5%BC-%EC%84%A0%ED%83%9D%ED%95%9C-%EC%9D%B4%EC%9C%A0-34dd50abbfb7">웹뷰 다음의 레일을 깔다: 당근이 Lynx를 선택한 이유</a> was originally published in <a href="https://medium.com/daangn">당근 기술 블로그</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[실험을 더 편하게 설계할 수 있게: 당근 실험플랫폼 이야기]]></title>
            <link>https://medium.com/daangn/%EC%8B%A4%ED%97%98%EC%9D%84-%EB%8D%94-%ED%8E%B8%ED%95%98%EA%B2%8C-%EC%84%A4%EA%B3%84%ED%95%A0-%EC%88%98-%EC%9E%88%EA%B2%8C-%EB%8B%B9%EA%B7%BC-%EC%8B%A4%ED%97%98%ED%94%8C%EB%9E%AB%ED%8F%BC-%EC%9D%B4%EC%95%BC%EA%B8%B0-3fa344b4391b?source=rss----4505f82a2dbd---4</link>
            <guid isPermaLink="false">https://medium.com/p/3fa344b4391b</guid>
            <category><![CDATA[data]]></category>
            <dc:creator><![CDATA[bboddo]]></dc:creator>
            <pubDate>Tue, 04 Aug 2026 01:34:28 GMT</pubDate>
            <atom:updated>2026-08-31T03:24:27.552Z</atom:updated>
            <content:encoded><![CDATA[<p>안녕하세요. 당근 데이터 가치화팀이에요.</p><p>저희 팀은 ‘매일 데이터를 통해 사용자를 위한 의사결정을 해요’라는 비전 아래, 전사 구성원이 데이터로 더 나은 결정을 내릴 수 있도록 인프라와 도구들을 만들고 있어요. 그중 하나가 <strong>실험플랫폼</strong>이에요. 어떤 안이 더 나은지 A/B 테스트로 검증할 수 있도록, 실험의 설계부터 결과 분석까지 한곳에서 지원하는 사내 플랫폼이죠.</p><p>약 3년 전, <a href="https://medium.com/daangn/100-%ED%8C%80%EC%9B%90%EC%9D%98-%EC%9D%98%EC%82%AC%EA%B2%B0%EC%A0%95%EC%97%90-%EC%98%81%ED%96%A5%EC%9D%84-%EC%A3%BC%EB%8A%94-data-scientist-decision-5c939e8a3ea9">100+ 팀원의 의사결정에 영향을 주는 Data Scientist, Decision : 실험플랫폼</a>에서 실험플랫폼이 처음 만들어진 과정을 소개한 적이 있어요. 그때는 누적 실험이 350여 개, 실험 오너가 80여 명 수준이었어요. 지금은 누적 실험 4,600여 개, 실험 오너 280여 명, 활성 도메인도 중고거래·검색·피드·커뮤니티·동네생활·광고·알바·중고차 등 60여 개에 달해요. 월간으로 보면 당시 20개 수준이던 진행 실험이 지금은 290여 개로 약 14배 늘었어요. 규모가 커진 만큼 운영 방식을 어떻게 바꿔야 할지 고민도 점점 커졌어요.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*QTzXWlYEyC6PN7dwATnuYQ.png" /></figure><h3>실험이 늘어나며 마주한 두 가지 문제</h3><p><strong>첫째, 실험 한 사이클을 완료하기까지 너무 오랜 시간이 걸렸어요.</strong> 매주 수십 건의 실험이 진행되는 환경인데,지표를 정의하는 데만 1~2시간이 걸렸고, 실험 설계 문서를 쓰고 플랫폼에 등록하는 데 또 시간이 들었어요. 지표 리뷰 과정에서 데이터 가치화팀과 서비스팀 사이에 핑퐁이 여러 번 오갔고, 실험을 빠르게 반복하고 싶은 서비스팀은 데이터 가치화팀의 일정에 묶여 페이스가 끊겼던 거죠.</p><p><strong>둘째, 같은 시점에 진행되는 실험들이 서로 간섭하기 시작했어요</strong>. 같은 화면에서 UI 실험 두 개가 동시에 돌아가면, 같은 사용자에게 두 변화가 함께 노출돼서 결과가 어느 실험의 효과인지 가려내기 어려워졌어요. 실험을 통한 인과 추론의 토대가 흔들리는 거죠. 사용자 입장에서도 갑자기 너무 많은 것이 한 번에 바뀌면 어색함을 느끼게 되고요.</p><p>지난 시간 동안 이 두 문제를 풀려고 많은 걸 바꿔왔는데, 오늘은 그중 실험을 <strong>만들고 운영하는 과정이 어떻게 더 편해졌는지</strong>를 세 가지 변화로 나눠 이야기해 보려고 해요.</p><ol><li><strong>지표를 코드 없이, UI로</strong> — 지표 정의·등록의 진입장벽을 낮춰 사이클의 첫 병목을 해소했어요.</li><li><strong>상호배제 그룹</strong> — 같은 지면의 실험들이 충돌 없이 동시에 돌아가게 했어요</li><li><strong>Experiment MCP</strong> — 실험 한 사이클의 반복 작업을 LLM에게 맡겨 남은 병목을 줄였어요</li></ol><h3>1. 지표를 코드 없이, UI로</h3><h4>왜 지표 등록이 데이터 가치화팀의 일이 됐을까?</h4><p>실험에서 지표 정의는 가장 중요하면서도 가장 손이 많이 가는 작업이었어요. 기존에는 지표를 <strong>코드로 정의</strong>했어요. 보통 세 종류의 파일을 한 세트였는데요. GitHub의 데이터 파이프라인 저장소에 직접 작성해야 했죠. 가장 위에는 지표의 정의(metrics.yaml), 그 사이에는 유저의 행동 정의(subject_events.yaml), 가장 아래에는 실제 데이터 쿼리(events/*.sql)가 있었어요. 양 끝의 두 파일만 잠깐 보여드릴게요.</p><pre># metrics.yaml — 지표의 정의<br>avg_number_of_item_click_per_user:<br>  description: &quot;1인당 item 평균 클릭수&quot;<br>  metric_type: &quot;count&quot;<br>  numerator: click_item<br>  ...</pre><pre>-- events/click_item.sql — 실제 데이터 쿼리<br>SELECT<br>    CAST(user_id AS string) AS user_id,<br>    ...<br>FROM `&lt;프로젝트&gt;.&lt;데이터셋&gt;.&lt;테이블&gt;`<br>WHERE DATE(event_timestamp, &#39;{{ timezone }}&#39;)<br>    BETWEEN &#39;{{ start_date }}&#39; AND &#39;{{ end_date }}&#39;</pre><p>지표 하나를 추가하려면 보통 이런 흐름을 거쳐야 했어요. YAML과 SQL을 작성해서 PR을 올리고, CI 검사를 통과시키고, 테스트 Airflow에서 결과를 확인하고, 데이터 가치화팀의 리뷰를 거쳐, 머지한 뒤 약 1~2시간 뒤 실험플랫폼에 반영되는 식이었죠.</p><p>개발자가 아니라면 YAML 문법은 물론,GitHub PR 흐름 자체가 낯설어요. PM·DA 분들은 시작 단계부터 막혔고, 팀 내 엔지니어에게 요청하거나 지표 등록을 미루게 되는 일이 발생하고 있었죠. 그런데 더 큰 문제는 <strong>데이터 가치화팀이 병목이 될 수 있다</strong>는 점이었어요. 실험을 진행하는 팀이 늘어날수록 리뷰 대기 시간도 함께 늘어났으니까요.</p><h4>UI로 옮긴 뒤</h4><p>위와 같은 어려움을 극복하기 위해 지표 정의와 등록 과정을 <strong>실험플랫폼 UI</strong>로 옮겼어요.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*ZTlMQw9WmJl-GLGk14r-Kw.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*ytIFwbj4qkRa1LOETLq0Tw.png" /></figure><p>기존에 진행하던 세 단계(event → subject event → metric)를 한 화면 안에서 진행할 수 있어요. 이미 쌓이고 있는 사용자 이벤트를 그대로 가져와 지표로 활용하거나, 원한다면 새로운 사용자 행동(subject event)을 정의하고 어떤 종류의 지표(count/ratio/sum 등)로 집계할지 정해요. 이렇게 구성한 지표는 버튼 한 번으로 테스트를 실행해서 실제 결과가 어떻게 나오는지, 쿼리에는 문제가 없는지 바로 확인할 수 있고 등록 즉시 실험에서 바로 사용할 수 있어요.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*DJIiDHirXf-q61uZGp-ewg.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*WSS8xtJBhTqVIxly9CzLKw.png" /></figure><p>코드 기반에서 1~2시간이 걸리던 일이 이제 <strong>몇 분 안에</strong> 끝나요. YAML 문법을 몰라도, GitHub PR 흐름을 몰라도, 도메인을 가장 잘 아는 사람이 스스로 지표를 만들 수 있어요.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*fo-PqUoqbxCCw3HhLov7AQ.png" /></figure><p>UI 도입 이후 신규 지표는 모두 코드 없이 UI에서 만들어지고 있어요. 등록 전 별도의 리뷰 과정도 사라졌어요. 각 팀이 지표를 만들고 자체적으로 테스트하며 검증할 수 있는 환경이 되었거든요.</p><h4>데이터 가치화팀이 더 이상 길목에 있지 않게</h4><p>가장 큰 변화는 <strong>데이터 가치화팀이 모든 지표 등록의 길목에 서 있지 않아도 된다</strong>는 점이에요. 각 도메인의 PM·DA·엔지니어가 지표를 직접 정의하고, 데이터 가치화팀은 컨벤션 관리나 품질 점검 같은 더 본질적인 역할에 집중할 수 있게 됐어요. 실험 도중 새로운 가설이 떠올라 지표를 추가해야 할 때도, 서비스팀이 즉시 추가하고 다음 날 결과를 바로 확인할 수 있어요. 실험을 가설 검증의 도구로 쓰려면 빠른 반복이 핵심인데, 그 사이클이 한 단계 짧아진 셈이에요.</p><h3>2. 충돌 없이 동시에 실험을 돌릴 수 있게 — 상호배제 그룹</h3><h4>같은 화면에서 두 실험이 만나면</h4><p>당근에서는 다양한 팀이 동시다발적으로 실험을 진행하고 있어요. 다른 화면, 다른 도메인의 실험이라면 보통은 문제가 없어요. 그런데 같은 화면에서 두 실험이 동시에 돌아가면 실험 해석이 어려워질 수 있어요.</p><p>예를 들어 한 화면에서 글자 색을 바꾸는 실험과 배경 색을 바꾸는 실험이 같은 시점에 진행된다고 해볼게요. 각자 따로 보면 의도가 분명해요. 글자 색 실험은 어떤 톤이 더 잘 읽히는지, 배경 색 실험은 어떤 톤이 화면 분위기에 더 잘 맞는지 보려는 거니까요. 그런데 한 사용자가 두 실험에 동시에 노출되면 글자는 A 변형, 배경은 B 변형이 함께 적용되면서 의도하지 않은 조합이 만들어져요. 어두운 배경 위에 어두운 글자처럼 가독성이 떨어지는 화면이 사용자에게 그대로 노출될 수도 있고요. 이때 다음과 같은 문제가 생겨요.</p><ul><li>글자 색 실험의 효과가 배경 색 때문인지 헷갈리고, 반대도 마찬가지예요</li><li>무작위 배분 덕에 평균 효과 자체가 크게 왜곡되진 않더라도, 측정된 효과가 글자 색 단독의 것인지 배경 색과 만났을 때만 나타나는 것인지 구분할 수 없어서 효과가 과소·과대평가될 수 있어요</li><li>사용자 입장에서도 의도치 않은 조합이 함께 적용돼 경험이 어색해질 수 있어요</li></ul><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*j_cQ5LGmIx2lcr5JBTAPGA.png" /></figure><h4>슬롯 기반으로 사용자를 나누다</h4><p>위와 같은 문제를 방지하기 위해 실험플랫폼에 <strong>상호배제 그룹</strong> 기능을 만들었어요. 핵심 아이디어는 아래와 같아요.</p><blockquote><em>1부터 10,000까지의 </em><strong><em>슬롯</em></strong><em>을 정의하고, 사용자를 슬롯에 무작위로 배분해요.</em></blockquote><blockquote><em>상호배제하고 싶은 실험들에는 </em><strong><em>서로 다른 슬롯을 할당</em></strong><em>해요.</em></blockquote><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*vHku4RxtEWb61RD4910fow.png" /><figcaption>슬롯을 공유하지 않는 두 실험은 상호배제된다</figcaption></figure><p>가장 단순한 방법은 실험 하나하나에 직접 슬롯 범위를 지정하는 거예요. 실험 A에는 1~3000번 슬롯, 실험 B에는 3001~6000번 슬롯을 주면, 한 사용자는 둘 중 한 실험에만 노출돼요. 슬롯 배분은 사용자 ID의 해시 값으로 결정되기 때문에 같은 사용자는 언제나 같은 슬롯에 배정돼요.</p><p>한 그룹에서 돌리는 실험이 많아지면 그룹 안에 <strong>레이어</strong>를 두는 방식도 쓸 수 있어요. 레이어에 먼저 슬롯을 배정해 두고, 실험을 레이어에 할당하면 그 안에서 슬롯이 자동으로 분배돼요. 자주 실험을 추가·제거하는 도메인에서는 레이어 단위로 운영하면 슬롯을 매번 다시 조절하지 않아도 돼서 운영이 한결 수월해져요.</p><p>이런 레이어링은 저희만 쓰는 방식이 아니에요. 아래 Optimizely 문서의 그림처럼 전체 트래픽을 서로 겹치지 않는 조각으로 나누고 각 실험이 자기 조각의 사용자에게만 노출되게 하는 건, Optimizely나 Statsig 같은 업계 실험 플랫폼들이 공통적으로 채택한 원리예요. 저희의 슬롯과 레이어도 같은 원리 위에 있어요.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1000/1*oSvESXPh1FWYeXa7N6zBHQ.png" /><figcaption>출처: <a href="https://support.optimizely.com/hc/en-us/articles/4410289064205-Mutually-exclusive-experiments">https://support.optimizely.com/hc/en-us/articles/4410289064205-Mutually-exclusive-experiments</a></figcaption></figure><h4>사례</h4><p><strong>“실험을 잘게 쪼개서 겹치지 않게 반복하고 싶어요”</strong> — 반복 실험이 많은 도메인은 사용자를 최소 단위로 잘게 나눠 실험 사이의 영향을 분리해야 했어요. 실험플랫폼이 슬롯 기반 분배를 지원하기 전에는 서비스 서버 엔진에서 자체적으로 슬롯을 관리했지만, 지금은 실험플랫폼의 상호배제 그룹으로 옮겨서 여러 실험을 슬롯 충돌 없이 안전하게 진행할 수 있어요.</p><p><strong>“성격이 다른 여러 실험을 한 시점에 함께 돌리고 싶어요”</strong> — 결이 다른 실험들을 동시에 운영해야 하는 도메인에서는 다음과 같이 레이어 단위로 슬롯 구조를 정리했어요.</p><pre>holdout 레이어   10%   (전용 슬롯)<br>A 레이어         10%   (전용 슬롯)<br>B 레이어         80%   (남은 슬롯을 C 레이어와 직교로 공유)<br>C 레이어         80%   (남은 슬롯을 B 레이어와 직교로 공유)</pre><p>숫자만 보면 합이 100%를 넘어서 오류가 있는 것처럼 보이는데요. holdout(실험을 적용하지 않고 남겨두는 비교용 집단)·A 레이어가 전용 슬롯 20%를 점유하고, 남은 80%의 슬롯 위에서 B·C 레이어가 <strong>서로 직교(orthogonal)하게</strong> 겹쳐 돌아가는 구조예요. 같은 사용자가 B 레이어의 실험과 C 레이어의 실험에는 동시에 들어갈 수 있지만, <strong>같은 레이어 안의 실험들끼리는 상호배제</strong>돼요. B와 C처럼 서로 간섭할 일이 없는 성격의 실험들은 이렇게 겹쳐 돌려서 트래픽을 최대한 활용하고, 간섭할 수 있는 실험들만 같은 레이어에 넣어 분리하는 거죠.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*18QTLm4ggMeVpELy-mUivg.png" /></figure><p>각 레이어가 어떤 슬롯을 점유하는지를 미리 정해 두니, 새 실험을 추가할 때 슬롯 할당으로 머리를 싸맬 일이 줄어들었죠. 도메인 안의 누구든 새 실험을 만들 때 ‘이 실험은 어느 레이어에 가야 하는가’만 결정하면 되니까, 구성원 사이의 커뮤니케이션 비용도 줄어들었어요.</p><h4>장점과 한계</h4><p>이 구조의 장점은 명확해요.</p><ul><li>여러 도메인에 걸친 실험도 같은 분배 메커니즘 위에서 안전하게 진행돼요.</li><li>새 실험을 추가할 때 매번 코드로 슬롯 충돌을 방지하지 않아도 돼요.</li><li>한 사용자가 동시에 여러 실험에 노출되지 않으니, 결과 해석이 깔끔해져요.</li></ul><p>물론 한계도 있어요.</p><ul><li>슬롯을 나눠 쓰면 그만큼 <strong>각 실험에 도달하는 표본 수가 줄어들어요</strong>. 슬롯을 10개 실험이 나눠 쓰면 한 실험의 표본 수는 전체의 약 10분의 1이 돼요.</li><li>표본 수가 적으면 같은 효과 크기여도 통계적으로 잡아내기 어려워져요.</li></ul><p>그래서 모든 실험을 무조건 상호배제 그룹에 넣지는 않아요. 같은 화면을 건드리는 실험들, 결과 해석에 서로 영향을 줄 가능성이 있는 실험들만 그룹에 묶고, 그 외 실험은 자유롭게 트래픽을 활용하도록 운영해요. 줄어든 표본 수의 부담은 분산을 줄여 검정력을 보완하는 CUPED로라는 기법으로 일부 상쇄할 수 있는데, 이 이야기는 다음 글에서 자세히 다룰게요.</p><h3>3. 실험 한 사이클을 LLM과 함께 — Experiment MCP</h3><h4>반복되던 한 사이클</h4><p>지표 등록을 UI로 옮기고 나니, 그 다음에 보인 건 그 앞뒤로 늘어선, 손이 많이 가는 작업들이었어요. 실험 한 사이클은 보통 다음 단계를 거쳐요.</p><ol><li>실험 설계 문서를 쓰면서 가설을 정리해요</li><li>어떤 지표로 측정할지 결정해요</li><li>실험플랫폼에 실험을 등록해요. 이때 대조군·실험군, 상호배제 그룹 적용 여부, CUPED 활성화 여부 같은 설정을 정해요. CUPED는 실험 전 데이터를 활용해 지표의 분산을 줄여서, 같은 표본 수로도 효과를 더 민감하게 잡아내는 기법이에요.</li><li>실험이 끝나면 결과 페이지에서 지표별 결과를 읽어요</li><li>결과를 정리해서 팀 내에 공유해요</li></ol><p>각 단계가 어렵진 않지만, 매번 같은 패턴이 반복됐어요. 그리고 누가 진행하느냐에 따라 산출물의 품질이 달라지기도 했어요. 익숙한 사람은 실험 설계 문서를 30분 만에 작성했지만, 처음 쓰는 사람은 반나절이 걸렸고, 결과 정리도 일관된 양식이 없으니 어떤 실험은 한 줄짜리 슬랙 메시지로 끝나고 어떤 실험은 긴 노션 문서로 정리되는 식이었어요.</p><h4>도구와 노하우를 열다 — Experiment MCP와 스킬</h4><p>이 반복을 자동화하려면 실험플랫폼을 사람이 클릭하는 UI가 아니라 <strong>LLM이 호출할 수 있는 도구</strong>로 노출할 필요가 있었어요. 그래서 실험과 지표, 사용자 행동 이벤트를 조회·생성·수정할 수 있는 인터페이스를 MCP(Model Context Protocol) 형태로 묶었어요. 이게 <strong>Experiment MCP</strong>예요.</p><p>Experiment MCP가 있으면 Claude나 Claude Code 같은 LLM 클라이언트가 실험 생성, 지표 등록, allowlist 수정, 결과 지표 조회 같은 작업을 직접 수행해요. 사용자는 자연어로 실험 문서나 가설을 던지고, LLM이 MCP 도구를 차례로 호출해서 실험을 구성해 줘요.</p><p>도구만 연 건 아니에요. 그 도구들을 잘 쓰는 <strong>분석 노하우도 스킬(skill) 형태로</strong> 함께 제공해요. 예를 들어 experiment-followup-analysis 스킬은 Experiment MCP로 실험 정보와 결과를 조회하고, BigQuery MCP로 실험 파이프라인 테이블을 직접 쿼리해서, 파이프라인이 계산해 주는 결과 너머의 심층 후속 분석을 수행해요. 세그먼트별 효과, 시계열 트렌드, novelty effect 확인, 코호트 슬라이싱, subject-level 원본 데이터 확인 같은 커스텀 분석을 &quot;실험 후속분석 해줘&quot; 한마디로 시작할 수 있죠. 이런 스킬 역시 뒤에서 이야기할 에이전트들이 가져다 쓰는 재료가 돼요.</p><h4>Experiment Agent</h4><p>Experiment MCP를 활용해 다양한 에이전트를 구성할 수 있어요. 저희 데이터 가치화팀은 모든 팀이 공통으로 쓸 수 있는 <strong>기본적인 실험 구성 에이전트</strong>를 만들어 두었어요. 실험 문서나 슬랙 스레드를 입력으로 주면 가설을 정리하고, 어떤 지표를 쓸지 제안하고(필요하면 새 지표를 만들고), 실험 등록까지 자연스럽게 이어줘요. 첫 실험을 만들어보는 사람이 어디서 시작해야 할지 막막할 때 가장 먼저 찾을 수 있는 도구예요.</p><p>실험에 더 익숙하고 도메인 컨텍스트를 깊이 아는 팀은 <strong>자체 에이전트를 구성</strong>해서 Experiment MCP를 활용해요. 도메인에서 자주 쓰는 지표·실험 패턴·정책을 에이전트에 미리 담아두면, 매번 같은 맥락을 반복해서 설명하지 않아도 돼요. 예를 들어 피드팀은 도메인의 테이블·이벤트·실험 정책을 모아 둔 분석 스킬을 만들어서, 그 안에서 Experiment MCP와 다른 데이터 MCP들(BigQuery·Event·Superset 등)을 함께 호출해 실험을 빠르게 돌려요. 슬랙 스레드를 입력으로 받는 분석 에이전트를 만들어 결과 해석까지 자동화하는 팀도 있고요. Experiment MCP를 팀 내 워크플로우에 끼워 넣어 실험 생성 자체를 자연어로 수행하는 사례도 늘고 있어요.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*glBWMqSzNjpd7JrStqANNA.png" /></figure><p>각 팀이 에이전트를 빌드할 수 있도록 데이터 가치화팀은 재료를 제공하고, 각 팀이 자기 컨텍스트에 맞게 레시피를 짜는 셈이에요.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*sCg2lHBiXgMDk5v_9XgRuQ.png" /></figure><h4>짧아지는 리드타임, 높아지는 기본 품질</h4><p>실험 한 사이클의 리드타임이 짧아졌어요. 실험 문서 작성에서 플랫폼 등록까지 거쳐야 했던 단계와 시간이 크게 줄었어요. 누가 진행하든 문서·등록의 기본 품질이 일정 수준 위로 올라왔고요. 반복 작업이 자동화되면서 남는 시간은 <strong>가설 설계</strong> 같은 더 본질적인 부분으로 옮겨갔어요.</p><p>특히 신규 입사자나 실험을 처음 해보는 분들에게 변화가 컸어요. 예전에는 ‘실험을 어떻게 시작하는지’ 자체가 진입장벽이었지만, 이제는 에이전트와 대화하면서 첫 실험을 만들어봐요.</p><h3>그리고 앞으로</h3><p>지금까지 이야기한 세 가지 변화는 모두 실험을 더 편하게 만들고 운영할 수 있게 하기 위한 변화였어요. 앞으로 저희가 보고 있는 방향은 그 연장선이에요.</p><ul><li><strong>실험 대상의 정교한 타겟팅</strong> — 지금은 실험 대상이 주로 트리거 포인트 기반으로 정해지지만, 신규·복귀 유저나 특정 지역, 특정 행동 패턴 같은 더 세밀한 기준으로 실험 대상을 좁힐 수 있다면 작은 그룹의 효과까지 정확하게 측정할 수 있어요. 올해 팀 목표 중 하나로 잡고 있는 방향이에요.</li><li><strong>실험 사이클을 한 곳에서</strong> — 지금은 실험 문서, 결과 정리 같은 산출물이 Notion·Slack 등 여러 도구에 흩어져 있어요. 앞으로는 실험 문서를 실험플랫폼 안에 내재화하는 식으로, 실험 한 사이클이 한 곳에서 자연스럽게 흘러갈 수 있도록 실험플랫폼을 발전시켜 나갈 예정이에요.</li></ul><h3>마무리</h3><p>AI 시대에서 데이터 실험은 이제 데이터 가치화팀의 전문 영역이 아니라 도메인을 가장 잘 아는 사람이 직접 설계하고 다루는 일이 됐어요. 지표 등록의 진입장벽이 낮아지고, 동시에 진행되는 실험이 충돌 없이 돌아가고, 한 사이클의 반복 작업이 에이전트로 줄어들면서요.</p><p>다음 글에서는 그 실험들의 결과를 더 잘 읽고 해석할 수 있도록 만든 변화를 이어서 이야기해볼게요. 결과 페이지 재설계, LLM 자동 요약, CUPED 이야기 등이 남아있어요.</p><p>궁금한 점은 댓글이나 제 <a href="https://www.linkedin.com/in/seung-hyeon-jo-902b82210">링크드인</a>으로 남겨주시면 답변드릴게요!</p><p>긴 글 읽어 주셔서 감사해요.</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=3fa344b4391b" width="1" height="1" alt=""><hr><p><a href="https://medium.com/daangn/%EC%8B%A4%ED%97%98%EC%9D%84-%EB%8D%94-%ED%8E%B8%ED%95%98%EA%B2%8C-%EC%84%A4%EA%B3%84%ED%95%A0-%EC%88%98-%EC%9E%88%EA%B2%8C-%EB%8B%B9%EA%B7%BC-%EC%8B%A4%ED%97%98%ED%94%8C%EB%9E%AB%ED%8F%BC-%EC%9D%B4%EC%95%BC%EA%B8%B0-3fa344b4391b">실험을 더 편하게 설계할 수 있게: 당근 실험플랫폼 이야기</a> was originally published in <a href="https://medium.com/daangn">당근 기술 블로그</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[프론트엔드와 백엔드를 한 팀으로 합치면 어떤 일이 일어날까?]]></title>
            <link>https://medium.com/daangn/%ED%94%84%EB%A1%A0%ED%8A%B8%EC%97%94%EB%93%9C%EC%99%80-%EB%B0%B1%EC%97%94%EB%93%9C%EB%A5%BC-%ED%95%9C-%ED%8C%80%EC%9C%BC%EB%A1%9C-%ED%95%A9%EC%B9%98%EB%A9%B4-%EC%96%B4%EB%96%A4-%EC%9D%BC%EC%9D%B4-%EC%9D%BC%EC%96%B4%EB%82%A0%EA%B9%8C-f8b32edb2eb1?source=rss----4505f82a2dbd---4</link>
            <guid isPermaLink="false">https://medium.com/p/f8b32edb2eb1</guid>
            <category><![CDATA[backend-development]]></category>
            <category><![CDATA[frontend]]></category>
            <category><![CDATA[engineering]]></category>
            <category><![CDATA[software-engineering]]></category>
            <category><![CDATA[backend]]></category>
            <dc:creator><![CDATA[iltaek]]></dc:creator>
            <pubDate>Thu, 23 Jul 2026 09:39:17 GMT</pubDate>
            <atom:updated>2026-07-24T07:44:04.112Z</atom:updated>
            <content:encoded><![CDATA[<p>안녕하세요. 당근 광고실 광고주플랫폼팀에서 엔지니어링 매니저를 맡고 있는 일택(Iltaek)이에요.</p><p>제가 속한 광고주플랫폼팀은 광고주가 당근의 내부 구조를 몰라도 광고를 만들고, 운영하고, 결과를 이해할 수 있는 플랫폼을 만들어요. 광고를 시작하려고 광고 만들기 버튼을 누른 순간부터 집행 결과 리포트를 확인하기까지, 광고주가 만나는 모든 여정을 저희가 책임지고 있죠. 그리고 그 경험이 매끄럽게 흘러가려면, <em>웹 화면부터 그 뒤의 서버까지가 하나의 흐름으로 이어져야 해요.</em></p><p>그런데 올해 초까지만 해도, 저희의 일상은 이 흐름과 사뭇 달랐어요. 광고주가 보는 화면을 만드는 게 프론트엔드(FE), 그 뒤에서 데이터를 처리하는 서버를 만드는 게 백엔드(BE)인데, 저희는 이 두 직군이 서로 다른 팀이었거든요.</p><p>기능 하나를 내려면 할 일 단위인 티켓이 프론트엔드와 백엔드에 따로 만들어졌고, 두 팀의 스프린트 일정도 서로 맞춰야 했어요. 화면과 서버가 주고받을 약속인 API 스펙이 정해질 때까지, 화면 작업은 기다려야 했고요. 한쪽이 급한 일로 밀리면 반대쪽도 같이 밀렸죠. 서로 잘하고 있는데도 <strong>직군의 경계</strong>에서 조율과 대기가 계속 생기는 구조였어요.</p><p>그러다 이런 발상이 나왔어요. 아예 두 팀을 하나로 합쳐보면 어떨까? 그럴듯한 발상이면 일단 실험해 보는 게 저희가 일하는 방식이라, 이번에도 우선 해보기로 했던 거죠.</p><p>그렇게 지난 4월, 저희 팀은 <strong>백엔드 엔지니어 4명과 프론트엔드 엔지니어 4명</strong>, 직군이 다른 두 팀을 하나로 합쳤어요. 화면부터 서버까지가 한 팀의 일이 됐죠. 그렇게 한 분기를 보낸 지금은, 엔지니어 한 명이 화면부터 서버까지 문제를 끝까지 해결하는 일이 흔해졌어요.</p><p>사실 이 실험은 걱정과 함께 시작됐어요. 작은 팀이라면 애초에 직군의 경계가 옅죠. 초기 스타트업에서는 한 사람이 프론트엔드와 백엔드를 오가며 개발하는 게 흔한 일이니까요. 저희 상황은 달랐어요. 이미 많은 광고주가 쓰는 서비스를 두 팀이 나눠 운영하고 있었고, 각자 쌓아온 전문성과 지켜야 할 안정성이 있는, 말하자면 <strong>잃을 게 많은 조직</strong>이었거든요. 이런 조직이 잘 돌아가던 것들을 흔들지 않으면서 직군 통합에 적응한 사례를, 저희는 들어보지 못했어요. 그래서 걱정이 먼저였어요.</p><p>걱정을 키운 건 <strong>시스템의 규모</strong>였어요. 두 팀은 마이크로서비스 구조 위에서 각자 여러 개의 서비스를 운영하고 있었고, 팀을 합친다는 건 서로가 그만큼 많은 낯선 서비스를 새로 배워야 한다는 뜻이기도 했죠. 걱정은 두 갈래였어요. 하나는 안정성이었어요. 장애가 나면 당번을 서며 대응하는 온콜을 이제 여덟 명이 함께 서야 하는데, 잘 모르는 서비스에서 장애가 나면 제때 대응하지 못할 수 있으니까요. 다른 하나는 속도였고요. 낯선 영역을 배우느라 팀 전체가 느려질 수 있으니까요.</p><p>결론부터 말하면, 속도 걱정은 기우였어요. 안정성 걱정은 4월에 실제로 현실이 됐는데요. 그 이야기는 뒤에서 다시 들려드릴게요.</p><h3>그대로였던 속도, 그리고 두 가지 원칙</h3><p>먼저 이 두 걱정 중 속도부터 짚어볼게요. 비교 기준은 이렇게 잡았어요. 통합 전에 스프린트마다 끝낸 일의 양, 그러니까 완료량을 기록해 온 건 백엔드 팀(4명)이라, 그 기록을 기준선으로 삼았어요. 인원이 두 배가 됐으니 기대치도 그만큼 높여 잡고, 새로 합류한 지 얼마 안 된 멤버의 몫은 보수적으로 계산해서 나란히 놓고 봤어요. 결과는요?</p><p><em>8명이 함께 보낸 첫 분기의 완료량은 줄지 않았습니다.</em></p><p>스프린트 단위로 측정했을 때도 유의미했어요. 저희 팀은 <strong>2주 단위의 스프린트</strong>로 일해요. 할 일을 티켓으로 만들어 그 기간 안에 소화할 만큼 계획하고, 끝나면 돌아보고 다시 계획하는 방식으로 움직여요. 통합 후 네 번의 스프린트 동안 완료량은 큰 변동 없이 비슷한 수준으로 유지됐어요. 낯선 직군을 배우는 과도기였는데도 결과가 그대로였다는 게, 저희에게도 가장 뜻밖이었어요.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*3AJ4d0oY_q4GFJ0BYiX5lQ.png" /></figure><p>물론 완료량은 <strong>스토리 포인트</strong>라는 팀 내부 추정 단위로 집계해요. 다른 팀과 비교할 수 있는 절대적인 숫자는 아니라서, 저희는 늘었는지 줄었는지 유지됐는지, 전체적인 흐름을 중점으로 파악했어요. 그 기준으로 확인한 사실은 딱 하나예요.</p><p><em>“떨어질 줄 알았는데, 떨어지지 않았다.”</em></p><p>참고로 2분기가 한가한 분기였던 것도 아니에요. 새 기능 개발이 아니라 서비스를 유지하는 일, 즉 <strong>운영성 업무가 완료량의 3분의 1</strong>을 차지했고, 인시던트(장애) 대응도 여러 건 있었어요. 이 과도기를 버티게 한 거창한 온보딩 프로그램은 없었어요. 대신 두 가지를 지켰어요.</p><p>첫 번째는 <strong>배분</strong>이에요. <em>모든 팀원이 스프린트마다 최소 1개는 반대 직군 티켓을 맡았어요.</em> 저희 팀 과제는 애초에 프론트엔드와 백엔드가 세트로 움직이는 일이 많아서, 억지로 만들 필요 없이 일의 흐름 속에서 자연스럽게 고를 수 있었어요. 크기도 각자 소화할 수 있는 것부터 시작했어요. 프론트엔드 엔지니어에게는 온콜에서 나온 간단한 운영·유지보수 티켓이나 제품 과제의 간단한 백엔드 티켓을 배정했고, 백엔드 엔지니어는 자기 작업에서 파생되는 프론트엔드 개발을 직접 진행했어요. 억지로 기회를 만든 게 아니라, 일의 흐름에 이미 있던 기회를 나눈 셈이죠.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*51KDi7t4b7ib6r7A3WaU6A.png" /></figure><p>두 번째는 <strong>시범</strong>이에요. 팀 리드이자 백엔드 엔지니어인 제가 프론트엔드 작업을 먼저 해 보였습니다. 간단한 티켓부터 어느 정도 규모가 있는 마이그레이션(시스템을 옮겨 다시 만드는) 작업까지 직접 진행했고, 그 과정에서 만든 검증 도구는 팀이 재사용할 수 있도록 남겨뒀어요. “백엔드 엔지니어인 나도 하니까 된다”를 말이 아니라 <strong>결과</strong>로 보여주고 싶었거든요. 반대 직군 작업을 향한 막연한 두려움과 거부감을 낮추는 데 이 시범이 도움이 됐다고 생각해요.</p><h3>팀의 지식이 AI의 연료가 되기까지</h3><p>돌이켜보면 두 원칙이 통한 건, 팀의 <strong>기본기</strong>가 이미 갖춰져 있었기 때문이라고 생각해요. “우리는 어떻게 일하는 팀인가”에 대한 기본 합의가 문서로 있었어요(팀 매니페스토와 Working Agreements). 1분기에 도입한 2주 스프린트는 통합 시점엔 팀의 습관으로 자리잡고 있었죠. 모든 일이 티켓으로 보이고, 크기가 추정되고, 각자 소화할 수 있는 업무량에 맞춰 분배되니, 새 멤버에게 반대 직군 일을 “적정한 크기로” 맡기는 운영이 가능했던 거죠.</p><p>작업마다 기획서와 설계 문서를 쓰고 시작하는 습관도 있었죠. 시스템 구조를 그린 아키텍처 다이어그램이나 데이터 구조를 정리한 DB 스키마 문서 같은 도메인 지식도 1분기부터 각 서비스 저장소에 쌓여 있었고요. 이 문서들은 뒤에서 말할 AI의 연료이기 전에, 새로 합류한 사람의 온보딩 자료이기도 했어요. <em>새 멤버들은 백지에 합류한 게 아니라, 이미 돌아가는 시스템 위에 올라탄 쪽에 가까웠어요.</em></p><p>이 기본기 위에서 두 가지가 맞물렸어요. 하나는 <strong>AI</strong>예요. 백엔드 엔지니어가 프론트엔드 코드를, 프론트엔드 엔지니어가 백엔드 코드를 작성할 때 AI 코딩 도구가 초안의 대부분을 만들어 주면서, 낯선 영역에 들던 진입 비용이 크게 줄었죠. 두 팀이 쓰는 언어부터 다른데 가능하냐 싶으실 수 있어요. 저희도 백엔드는 JVM 계열, 프론트엔드는 주로 React를 기반으로 쓰는데, AI와 일해 보니 언어나 기술 스택은 더 이상 큰 걸림돌이 아니었어요.</p><p>다른 하나는 팀에 쌓인 <strong>지식</strong>이에요. 그런데 AI만으로 되는 일은 아니었어요. AI가 낯선 코드베이스에서 쓸 만한 결과물을 내려면 맥락이 입력으로 있어야 하는데, 저희에게 그 맥락이 바로 문서였거든요. <em>팀의 지식이 AI의 연료였습니다.</em></p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*gYulW-SBcYwCPHl7kqEByg.png" /></figure><p>1분기에 만들어둔 것들의 덕도 톡톡히 봤어요. 대표적인 게 <strong>온콜 Runbook</strong>(장애 대응 매뉴얼)과 모니터링 대시보드죠. 낯선 서비스의 장애 알림을 받았을 때, 예전 같으면 그 시스템을 아는 사람부터 찾아야 했을 거예요. 이번 분기에는 Runbook을 열면 증상별 대응 절차가 정리돼 있으니 일단 혼자 움직일 수 있었죠.</p><p>온콜을 서 본 팀원들의 말도 비슷했어요. Aaron은 “팀이 합쳐지면서 특히 온콜이 걱정됐었는데, Runbook 덕분에 잘 처리할 수 있던 것 같아요”라고 했고, Kian은 “Runbook의 FAQ 섹션을 통해서 특정 누구에게 blocking되지 않고 팀이 일할 수 있어서 좋았어요”라고 했어요. 1분기에 쓴 문서가 2분기의 온보딩 역할까지 해준 거예요.</p><p>기존 지식을 꺼내 쓰기만 한 건 아니에요. 2분기에는 <strong>팀 기술 문서 저장소</strong>를 새로 만들어 흩어져 있던 도메인 지식을 한곳으로 모았어요. 한 분기 동안 문서와 수정 기록이 수백 개 단위로 쌓였는데, 흥미로운 건 만드는 일과 쓰는 일이 동시에 일어났다는 점이에요. 프로젝트를 진행하며 새로 남긴 문서가 곧바로 다음 작업과 포스트모템(사후 회고)에서 AI의 입력으로 쓰였거든요.</p><p>Aaron은 “LLM wiki를 활용하는 것, 프로젝트를 별도 레포로 진행하는 방식을 많이 배웠어요”라며 과거 경험상 비슷한 프로젝트보다 빠르게 완료한 것 같다고 돌아봤고, “글 쓰는 것이 비단 사람에게뿐만 아니라 에이전트에게도 도움이 되는 것 같아요”라는 Noel의 말이 이 발견을 한 줄로 요약해 줘요. <em>지식 구축과 활용은 순서대로가 아니라, 동시에 시작해도 복리가 붙더라고요.</em> 요즘은 이 순환을 학습으로도 확장하는 중이에요. 챕터별 인터랙티브 자료로 각자 되는 시간에 공부하고 그 결과물이 다시 AI의 연료로 돌아오는 <strong>비동기 스터디</strong>를, 첫 책부터 한 챕터씩 채워가고 있어요. 같은 문서가 사람과 AI를 함께 가르치는 구조가 된 거죠.</p><p>문서가 쌓일수록 낡은 내용도 함께 남는 것 아니냐는 의문이 들 수 있어요. 맞는 걱정이라, 기획서와 테크스펙을 리뷰하는 단계마다 계속 보정하고 있어요. 아직까지는 실제로 문제가 될 만한 이슈를 만나지는 않았고요.</p><h3>막연한 두려움이 “이게 되네요?”로</h3><p>도입에서 말한 변화를 더 구체적으로 보여드릴게요. 배분 원칙대로 매 스프린트 전원이 한 번씩은 경계를 넘었으니 어찌 보면 자연스러운 결과인데, 갈수록 간단한 티켓의 범위를 넘어섰어요. 백엔드 엔지니어가 “[FE] 대행사 계정 예산 연동” 티켓을 완료하고, 프론트엔드 엔지니어가 “[BE] 세금계산서 정보 타입 전환 API” 티켓을 완료하는 식이에요. 글 첫머리에서 말한 그 비용, 그러니까 티켓이 직군 사이를 오가며 기다리던 <strong>조율과 대기</strong>가 크게 줄었죠.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*98rhUmQqTOUEnoUdTHQ3Vw.png" /></figure><p>다만 “혼자 끝까지”가 “혼자 다 안다”는 뜻은 아니에요. AI 덕분에 반대 직군 코드를 작성할 수는 있게 됐지만, 두려움은 각자에게 있었어요. 프론트엔드 엔지니어는 AI가 작성한 백엔드 코드를 보며 “이게 정말 맞나” 싶고, 백엔드 엔지니어도 프론트엔드 코드 앞에서 마찬가지였는데요.</p><p>부족한 부분은 서로의 <strong>전문성</strong>으로 채웠어요. 초안은 혼자 끝까지 진행하고, 확신이 안 서는 지점만 옆자리 전문가에게 묻는 식이죠. 한 팀이고 옆자리니까, 물어보는 일은 특별한 이벤트가 아니었어요. 특정 장면을 꼽기 어려울 만큼 흔한 일상이었죠. 그렇게 묻고 확인하는 몇 분의 비용은, 티켓이 직군 사이를 오가며 기다리던 예전의 대기에 비하면 훨씬 작았고요.</p><p>물론 옆자리에 묻는 걸로 다 되는 것은 아니에요. 간단한 확인은 그렇게 하지만, 기술적인 변경은 설계 문서인 테크스펙을 작성해 팀과 실 단위의 리뷰를 거쳐요. AI가 작성한 코드가 그 직군 전문가의 눈에는 미흡하지 않냐는 질문도 받아요. 맞는 지적이라 AI를 쓰는 팀의 규칙과 도구인 하네스를 보강해가며 다듬고 있어요. 다만 완성도를 100%로 만드는 데 시간을 쓰기보다는, 80~90%를 받아들이고 대신 속도를 얻는 쪽으로 합의했어요.</p><p>그러면서 남는 리스크는 “문제가 생기면 빠르게 감지하고, 빠르게 대응하자”는 방식으로 감당해요. 앞서 말한 온콜 Runbook(장애 대응 매뉴얼)과 모니터링 대시보드가 그 안전망이고, 이 감지와 대응에도 AI를 붙이면서 예전보다 더 빠르고 효과적으로 움직이게 됐고요.</p><p>돌이켜보면 옆자리에 묻든 리뷰로 거르든, 무엇이 확신이 안 서는 지점인지 아는 것부터가 전문성이었어요. <em>저희 팀에서 전문성은 AI가 넘어서는 장벽이 아니라, AI의 결과물을 걸러내는 최종 검증자로 자리를 옮겨가고 있어요.</em></p><p>팀원들의 감각도 한 분기 사이에 달라졌어요. 프론트엔드에서 백엔드로 경계를 넘은 Sarah는 “해보지 않았던 일이라 불안함과 스트레스를 많이 받았지만, ‘하면 된다’는 생각이 들면서 부담이 적어지고 있어요”라고 했어요. “경계를 무너트리니까 빨리 작업할 수 있어서 좋았어요”라는 Henry.Koo의 말처럼, 스키마 변경을 백엔드에 바로 반영하는 속도가 일상이 됐고요. 반대 방향으로 넘은 Noel의 회고가 이 변화를 그대로 담고 있는데요.</p><blockquote><em>“FE 작업이 처음에는 잘 될까 했지만, 직접 해보니까 잘 되네요? 업무 영역의 확장도 처음에는 두렵지만 막상 해보니까, 이게 되네요?” — Noel</em></blockquote><p>그럼 도입에서 말한 안정성 걱정은 어떻게 됐을까요. 4월, 결제(빌링) 시스템이 멈춘 장애를 포함해 인프라 인시던트가 몰렸어요. 원인은 통합과 무관했어요. 이전부터 쌓여 있던 인프라 문제가 하필 이 시기에 드러난 거였죠. 다만 서로의 시스템이 아직 낯선 팀에게는 이 시기가 첫 번째 큰 시험이었고, 저희는 분기 내내 온콜과, 장애를 줄이는 기반 시스템인 신뢰성 인프라에 투자하는 것으로 대응했어요.</p><p><strong>6월에는 신규 인시던트가 0건</strong>이었어요. 한 달간의 0건이 투자의 효과인지 운 좋게 조용한 달이었는지는 더 지켜봐야 알지만, 낯선 시스템을 안고 보낸 분기치고 잦아드는 방향이었다는 것까지는 말할 수 있을 것 같아요. 위에서 말한 <strong>상호 검증</strong>도 품질의 다른 축이었어요. 낯설다고 검증을 건너뛴 게 아니라, 확신이 없으니 오히려 더 묻게 됐거든요.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*IAZD9OFv7jPJ3A5w4ndE7Q.png" /></figure><h3>이득과 함께 따라온 비용들</h3><p>물론 대가도 있었어요. 성공과 별개의 실패가 아니라, 이득과 함께 따라온 비용들이었죠. 먼저 혼자서 화면부터 서버까지 다 다루는 <strong>1인 풀스택 자체의 리스크</strong>예요. 직군 관점의 리뷰가 옅어질 수 있고, 특정 영역의 지식이 한 사람에게 쏠릴 수 있고, 길게는 각자의 전문성이 얕아질 수도 있어요. 상호 검증, 그리고 앞서 말한 스터디처럼 직군을 넘어 깊이를 다시 쌓는 투자가 저희의 대응이지만, 솔직히 말하면 관리하고 있다기보다 인지하고 과제로 안고 있는 단계에 가까워요.</p><p>이미 치른 비용도 있는데요. 정확히는 직군 통합 자체의 대가라기보다, 이 분기의 처리량을 만든 일하는 방식의 대가예요. AI의 도움으로 각자 여러 일을 동시에 진행하면, 실행 중에 발견되는 일과 급한 요청이 다음 계획을 기다리지 않고 바로 그 스프린트에 추가돼요. 그래서 계획했던 티켓은 대체로 기간 안에 끝냈는데도, <strong>실제로 한 일의 3분의 2는 계획에 없던 일</strong>이었어요. 앞에서 스프린트 완료량이 안정적이었다고 했는데, 그 총량을 채운 일의 내용이 계획과는 달랐던 셈이죠. <em>계획을 세우고 약속을 하는 입장에서는 아쉬움이 크게 남았어요.</em></p><p>팀이 AI에 숙련되며 실제 처리 시간은 줄었는데, 추정하는 감각은 아직 예전 속도에 맞춰져 있어요. 그래서 일을 실제보다 조금 크게 잡는 것 아닌가 하는 가설을 회고에서 검증하는 중이에요. 숫자로 보이지 않는 비용도 있었어요.</p><blockquote><em>“각자 과제를 맡아 진행하는 방식으로 일하다 보니 다른 팀원들이 무슨 일을 하는지 잘 몰랐고, 1분기처럼 ‘팀으로서 일한다’는 느낌이 덜했던 것 같아요.” — Jake</em></blockquote><p>이처럼 각자 병렬로 빨라진 만큼, 같이 일한다는 감각은 옅어졌어요. 작게는 회의와 추가 업무가 스프린트 중간에 끼어들어 야근으로 이어진 날이 있었고, 온콜 차례가 오면 고객 문의(CS) 처리에 생각보다 많은 품이 들었어요. AI 도구를 팀의 표준 습관으로 만드는 일도 아직 진행형이고요. 이런 날들이 반복되지 않게 이 방식을 지속가능하게 만드는 게, 특히 <strong>예측가능성</strong>을 되찾는 게 다음 분기의 숙제예요.</p><p>마지막으로, 이 경험이 성립한 전제를 적어둘게요. 비슷한 선택을 고민하는 팀이 있다면, 결과보다 이 전제부터 대조해 보시면 좋겠어요.</p><ul><li>한 기능이 프론트엔드와 백엔드를 관통하는 <strong>제품 구조</strong>라, 경계를 넘는 만큼 직군 사이의 대기가 사라지는 팀이었어요.</li><li><strong>AI 툴링과 활용 습관</strong>이 팀에 이미 성숙해 있었어요. AI 레버리지가 낯선 팀이라면 그 장벽부터가 과제일 거예요.</li><li>지식을 <strong>문서</strong>로 남기는 습관이 있었어요. Runbook은 1분기의 투자였고, 기술 문서 저장소는 2분기에 일과 함께 만들었어요.</li><li>스프린트 주기와 일하는 방식에 대한 <strong>팀의 합의</strong>가 먼저 있었어요.</li><li><strong>8명 소규모 팀</strong>이고, 통합이라는 시점의 맥락이 있었어요.</li></ul><p><em>이런 팀의 맥락이 있었기에 가능했던 시도라고 생각해요.</em> 그래도 저희가 가져가는 건 분명해요. AI가 직군의 진입장벽을 실제로 낮췄다는 것, 전문성이 “생산자”에서 AI의 결과물을 걸러내는 “검증자”로 옮겨가고 있다는 것, 그리고 그 AI의 연료는 팀이 쌓아둔 문서였다는 것이에요. 전문성이 검증자로 옮겨간다는 건, 코드에 한정된 이야기는 아닐 거예요.</p><p>직군 사이의 벽이 낮아지는 게 프론트엔드와 백엔드만의 이야기일지는, 각자의 영역에서 판단해 보시면 좋겠어요. 앞의 전제 다섯 개가 그 판단의 체크리스트예요.</p><p>돌아보면 경계 없이 일한다는 말은 그동안에도 많이 해왔어요. 그런데 말로 하던 것과 한 분기를 실제로 그렇게 살아보는 건 다르더라고요. 직접 해보니 배우는 것도 느끼는 것도 훨씬 많았고, 솔직히 왜 이제야 이렇게 일했나 싶은 순간도 있었어요. 한 분기를 지나고, 회고에 팀원이 남긴 문장으로 이 글을 마무리할게요.</p><blockquote><em>“영역의 확장이 시작됐다.”</em></blockquote><p>정답이 아니라 저희가 겪은 경험입니다. 당근이 강조하는 <strong>경계 없음</strong>이 저희 팀에서는 이런 모습이었고요. 비슷한 고민을 하는 팀에 참고가 되면 좋겠습니다. 더 궁금한 점이 있다면 댓글이나 <a href="https://www.linkedin.com/in/iltaek">제 링크드인</a>으로 편하게 물어봐 주세요.</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=f8b32edb2eb1" width="1" height="1" alt=""><hr><p><a href="https://medium.com/daangn/%ED%94%84%EB%A1%A0%ED%8A%B8%EC%97%94%EB%93%9C%EC%99%80-%EB%B0%B1%EC%97%94%EB%93%9C%EB%A5%BC-%ED%95%9C-%ED%8C%80%EC%9C%BC%EB%A1%9C-%ED%95%A9%EC%B9%98%EB%A9%B4-%EC%96%B4%EB%96%A4-%EC%9D%BC%EC%9D%B4-%EC%9D%BC%EC%96%B4%EB%82%A0%EA%B9%8C-f8b32edb2eb1">프론트엔드와 백엔드를 한 팀으로 합치면 어떤 일이 일어날까?</a> was originally published in <a href="https://medium.com/daangn">당근 기술 블로그</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
    </channel>
</rss>