<?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[YOGIYO Tech Blog - 요기요 기술블로그 - Medium]]></title>
        <description><![CDATA[요기요 서비스 개발 및 함께 일하는 방식과 문화, 구성원들이 함께 성장하는 경험에 대한 이야기를 나눕니다. - Medium]]></description>
        <link>https://techblog.yogiyo.co.kr?source=rss----c1b33ccbbc42---4</link>
        <image>
            <url>https://cdn-images-1.medium.com/proxy/1*TGH72Nnw24QL3iV9IOm4VA.png</url>
            <title>YOGIYO Tech Blog - 요기요 기술블로그 - Medium</title>
            <link>https://techblog.yogiyo.co.kr?source=rss----c1b33ccbbc42---4</link>
        </image>
        <generator>Medium</generator>
        <lastBuildDate>Wed, 10 Jun 2026 06:28:59 GMT</lastBuildDate>
        <atom:link href="https://techblog.yogiyo.co.kr/feed" rel="self" type="application/rss+xml"/>
        <webMaster><![CDATA[yourfriends@medium.com]]></webMaster>
        <atom:link href="http://medium.superfeedr.com" rel="hub"/>
        <item>
            <title><![CDATA[n8n과 함께하는 모바일팀 온콜 자동화 여정기 — Part 1: 분석은 AI에게]]></title>
            <link>https://techblog.yogiyo.co.kr/n8n%EA%B3%BC-%ED%95%A8%EA%BB%98%ED%95%98%EB%8A%94-%EB%AA%A8%EB%B0%94%EC%9D%BC%ED%8C%80-%EC%98%A8%EC%BD%9C-%EC%9E%90%EB%8F%99%ED%99%94-%EC%97%AC%EC%A0%95%EA%B8%B0-part-1-%EB%B6%84%EC%84%9D%EC%9D%80-ai%EC%97%90%EA%B2%8C-622244c98d47?source=rss----c1b33ccbbc42---4</link>
            <guid isPermaLink="false">https://medium.com/p/622244c98d47</guid>
            <category><![CDATA[n8n]]></category>
            <category><![CDATA[yogiyo]]></category>
            <category><![CDATA[mobile]]></category>
            <category><![CDATA[post]]></category>
            <category><![CDATA[tech]]></category>
            <dc:creator><![CDATA[LeeWonJong]]></dc:creator>
            <pubDate>Thu, 23 Apr 2026 04:51:35 GMT</pubDate>
            <atom:updated>2026-04-23T05:10:57.918Z</atom:updated>
            <content:encoded><![CDATA[<h3>n8n과 함께하는 모바일팀 온콜 자동화 여정기 — Part 1: 분석은 AI에게</h3><p>안녕하세요. 요기요 모바일팀에서 Android 앱을 개발하는 이원종입니다.</p><p>플랫폼 서비스를 운영하는 조직이라면 장애나 이슈를 빠르게 감지하고 즉시 대응하기 위해 온콜(On-Call) 제도를 운용하고 있을 겁니다. 요기요 모바일팀의 온콜 담당자는 매일 크래시와 성능 지표를 확인하고, 앱 리뷰나 고객 피드백 같은 VoC 를 살피며 분석합니다.</p><p>그러나 온콜 담당자는 반복적인 온콜 업무로 일평균 30분 이상 고정적인 시간이 소비되어 본래 업무 생산성이 떨어질 수 밖에 없는 구조입니다. 모바일팀에서는 이를 해결하기 위해 반복되는 온콜 담당자의 루틴을 AI 에게 위임해 보기로 했고 이를 어떻게 지능화하고 자동화하여 온콜 담당자의 생산성을 90% 이상 향상시킬 수 있었는지 소개해 드리겠습니다.</p><ul><li>Part 1: 분석은 AI에게</li><li>Part 2: 판단은 사람이</li></ul><h3>배경</h3><p>모바일팀은 최고의 사용자 경험을 위해 3가지 지표를 정기적으로 확인하고 있습니다.</p><ol><li>Firebase Crashlytics 와 Datadog 을 사용한 크래시 분석</li><li>리포팅 채널과 App Review 를 통한 VoC 분석</li><li>Datadog 을 사용한 성능지표 분석</li></ol><p>온콜 담당자는 Slack 에 연결된 Firebase 알림을 확인하고 크래시 발생 건수와 중요도에 따라 원인을 분석하여 Jira 티켓을 생성하고 수정하였습니다. 또한 Slack 에 연결된 VoC 창구를 통해 사용자의 의견을 분석하고, 좋은 의견은 최고의 사용자 경험을 위해 배포에 반영하기도 합니다. 모바일팀은 정기적인 미팅을 통해 Datadog 의 성능지표를 분석하고 SLI 가 목표를 달성하지 못한다면 원인을 분석하여 수정합니다.</p><p>이와 같은 기존 온콜 업무 프로세스에서 3가지 문제를 발견했습니다.</p><ol><li><strong>반복적입니다</strong>. 매일 같은 채널을 확인하고 같은 패턴으로 분석하여 판단을 내립니다. 온콜 담당자는 시간을 반복적으로 소모하는 문제가 있습니다.</li><li><strong>컨텍스트의 전환이 큽니다</strong>. 크래시 하나를 확인하려면 Slack, Firebase, Jira, Source Code, Github 등 여러 도구를 오가며 정보를 조합해야 하므로 생산성이 떨어집니다.</li><li><strong>사람의 판단과 AI가 해도 되는 작업이 섞여 있습니다</strong>. 코드 기반으로 크래시의 원인을 분석하고, VoC 를 요약하고 정리하는 역할은 AI 가 잘하는 영역입니다. 긴급도를 판단하고 대응방향을 설정하는 것은 사람이 해야 할 영역입니다.</li></ol><p>온콜 담당자의 시간을 본래 업무와 중요한 판단에 집중시키기 위해 Part 1 에서는 반복되는 분석 업무를 AI 에게 위임하고 자동화 하기로 했습니다. Part 1 에서는 크래시와 앱 리뷰 자동 분석 기능을 만든 여정을 소개드리겠습니다.</p><h3>Skills</h3><p>첫 번째는 <a href="https://github.com/anthropics/skills">Claude Skills</a> 부터 시작했습니다. 온콜 담당자가 직접 컨텍스트를 전환하지 않고 로컬에서 간단한 명령어로 크래시와 앱 리뷰를 분석하게 하고 싶었고, 다음과 같은 장점을 얻을 수 있기 때문입니다.</p><ol><li><strong>스킬 파일 자체가 문서가 됩니다</strong>. 자연어로 작성된 스킬은 누구나 쉽게 로직의 흐름을 이해할 수 있고, 이후 AI 를 사용하여 파일 기반의 로직을 이관할 때 용이합니다.</li><li><strong>빠르게 실험하고 검증할 수 있습니다</strong>. 이 작업을 AI 가 잘 할 수 있는지 로컬에서 빠르게 확인 가능합니다. 안되면 프롬프트를 고치면 되므로 실패 비용이 거의 없습니다.</li></ol><p>스킬로 크래시, 앱 리뷰를 분석시킬 도구는 Slack 으로 통일했습니다. 이는 기존 문제였던 도구의 컨텍스트 전환 비용을 줄이기 위해 진입점을 통일하기 위함이고, 분석 결과물이 팀의 히스토리가 될 수 있기 때문입니다.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*ZIbU6C5LyBMGugY_5Y2EIA.png" /><figcaption>Skill Design</figcaption></figure><p>스킬의 설계는 그림과 같이 봇을 통해 슬랙의 메시지를 읽어 정보를 가져오고 분석하여 스레드에 결과를 남깁니다.</p><pre>/crash-analytics 어제 크래시 내용 분석해 줘<br>/crash-analytics 오늘 발생한 Android 크래시 분석해 줘<br>/crash-analytics 최근 3일간 iOS 크래시 분석해 줘</pre><pre>/app-review-comment 이번 주 월요일 앱 리뷰 내용 분석해 줘<br>/app-review-comment 지난 주 월요일부터 지난주 금요일까지 앱 리뷰 내용 분석해 줘<br>/app-review-comment 어제 앱 리뷰 분석해 줘</pre><p>사용자가 /crash-analytics 명령어를 입력하면, Slack 에 연결된 Firebase Crashlytics 채널의 메시지를 읽어와 크래시 정보를 수집합니다. 수집된 크래시 로그를 기반으로 AI가 Firebase Plugin 을 사용하여 기기정보, 발생 건수, 영향 범위, 스택트레이스를 분석하고, 발생한 크래시의 앱 버전에 맞는 소스코드 기반으로 원인을 분석하여 해당 Slack 스레드에 답글로 남깁니다.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/997/1*vvpo9de0Jmy81izAEzRZ8g.png" /><figcaption>/crash-analytics</figcaption></figure><p>사용자가 /app-review-comment 명령어를 입력하면, Slack 앱 리뷰 채널의 메시지를 읽어와 리뷰 내용을 수집합니다. AI 가 각 리뷰를 분석하고 카테고리별로 분류하여 해당 스레드에 답글로 요약을 남깁니다. 기존 운영정책 확인이 필요하다면 위키에서 관련 정책문서를 탐색하며 모바일팀 문제 발생 시 관련 크래시를 찾기도 합니다.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/637/1*zjyQFoe-9nQih4qXy2UF1A.png" /></figure><h3>n8n</h3><p>그러나 스킬은 여전히 사람이 트리거를 해야 하는 불편함이 있기에 모바일팀은 다음과 같이 이 문제를 n8n 을 사용하여 해결했습니다.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/512/1*io_bP1MtERZx_PRIoyLatw.png" /></figure><ol><li>n8n 은 Zapier, Make 와 다르게 <strong>셀프호스팅을 구축하면 무료로 사용할 수 있습니다</strong>. 모바일팀에서는 UI Test 를 위한 머신이 있었기 때문에 Docker 컨테이너로 n8n 을 실행하여 무료로 사용할 수 있었습니다.</li><li><strong>워크플로우의 가시성이 뛰어납니다. </strong>데이터의 흐름을 한 눈에 파악 가능하고 디버깅할 때 각 노드별로 입출력을 확인할 수 있어 커뮤니케이션 비용이 낮습니다. 워크플로우 자체로 누구나 로직의 흐름을 이해할 수 있습니다.</li><li><strong>풍부한 통합을 지원합니다. </strong>Slack, Jira, Github, AI 등 이미 회사에서 사용하고 있는 도구의 노드가 이미 존재합니다. 또한 HTTP 노드로 api 만 있다면 어떤 도구든지 연결 할 수 있습니다.</li></ol><p>n8n 구축에는 모바일팀 외에도 IT 팀과 Security 실의 도움이 필요했습니다. 서버 세팅, 권한 요청, 사내망 외부 연동 검토 등 인프라 측면의 허들이 있었지만 ,스킬을 통해 이미 기능을 충분히 검증한 상태였기에 “이 자동화가 가치가 있다” 라는 확신을 가지고 진행할 수 있었습니다.</p><h4>크래시 분석</h4><p>모바일팀에서는 모든 크래시를 분석하지 않습니다. 발생 건수와 앱버전, 디바이스 정보 등 크래시의 중요도에 따라 분석하고 수정하는 기준이 있는데요, n8n도 동일한 기준을 적용했습니다. 만약 모든 크래시를 분석한다면 토큰의 비용이 낭비되므로 지정된 필터링 로직을 통해 분석이 필요 없는 메시지에는 팀에서 지정한 이모지를 남기고 있습니다.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/824/1*K-hfUvXZBUmTt5mVVm0KtQ.png" /></figure><p>만약 분석이 필요한 크래시라면 Firebase 의 api 를 호출하여 앱 버전에 맞는 소스코드 정보를 github api 를 통해 가져와 스레드에 분석 내용을 다음과 같이 요약합니다.</p><ol><li>크래시 원인 분석</li><li>영향도 평가</li><li>소스코드 수정 제안</li><li>추가 조치 필요 내용</li><li>크래시 사용자 환경 요약</li></ol><h4>앱 리뷰 분석</h4><p>앱 리뷰는 스킬처럼 키워드의 유사도를 분석하여 카테고리를 분류합니다. 최근 n개월의 리뷰를 바탕으로 카테고리를 다음과 같이 분류하여 n8n Data Table 에서 관리하도록 설계했습니다.</p><ol><li>운영 정책 확인 필요</li><li>모바일팀 확인 필요</li><li>FE팀 확인 필요</li><li>BE팀 확인 필요</li><li>사용성 개선</li><li>성능 이슈</li></ol><figure><img alt="" src="https://cdn-images-1.medium.com/max/684/1*p_EcnJFIOFfEkBs2T7zFoQ.png" /></figure><p>앱 리뷰를 카테고리별로 자동으로 분류하니 여러개의 리뷰와 장문의 리뷰도 사람이 아닌 AI 의 요약으로 사용자의 목소리를 빠르게 검토하고 분석할 수 있는 환경이 구축되었습니다.</p><h3>겪었던 문제와 해결</h3><p>첫 번째로 초기 설계는 Slack 에 크래시 알림이 올라오면 n8n 이 이벤트를 받아 자동으로 분석하는 구조였습니다. 그러나 사내 방화벽 정책으로 인해 Slack 에서 n8n 서버로의 이벤트 전달이 불가능했습니다. 방화벽 작업을 요청하여 이후 해결되었지만 작업 당시에는 사용할 수 없었던 문제가 있었기에 메시지 트리거가 아닌 스케줄링 작업을 통해 잠시 문제를 해소했던 시기가 있었습니다.</p><p>두 번째로 n8n 에서 Firebase Crashlytics 접근이 불가능했습니다. 스킬에서는 Claude Code 의 Firebase MCP 를 통해 Firebase 에 접근이 가능했습니다. 그러나 n8n 에서는 MCP 를 사용할 수 없었고, Firebase Crashlytics 에 접근하려면 별도의 인증과 권한이 필요했습니다. 이 문제는 <a href="https://github.com/firebase/firebase-tools">Firebase MCP 의 오픈소스 코드</a>를 AI와 함께 직접 분석하여 해결했습니다. Crashlytics API의 호출 방식과 필요한 인증값을 파악한 뒤 n8n 의 HTTP 요청 노드에 인증 정보를 세팅하여 크래시 데이터를 직접 가져올 수 있게 되었습니다.</p><p>마지막으로 첫 번째 문제가 해소된 뒤 하나의 슬랙 봇은 하나의 Event Subscription URL 만 등록할 수 있었습니다. 기능별로 봇을 분리할 수도 있었지만 이는 별도의 Router 워크플로우를 구축하여 채널과 메시지 발신자 정보를 필터링하여 기존 분석 워크플로우를 호출하도록 구조를 일부 수정했습니다.</p><h3>개선하기</h3><p>첫 번째는 크래시를 분석할 때 github api 를 통해 크래시가 발생한 버전을 기반으로 파일을 가져와 분석하고 있습니다. 이를 매번 github 을 호출하지 않고 한 번 분석한 코드는 캐싱해 api 호출을 줄이려고 합니다.</p><p>두 번째는 앱 리뷰를 키워드 기반으로 카테고리를 분류하고 있습니다. 정확도를 높이기 위해 일정 주기마다 AI 가 놓친 리뷰를 보정하는 작업을 추가 할 예정입니다.</p><p>세 번째는 다른 회사의 앱 리뷰도 함께 분석하여 좋은 아이디어의 리뷰가 올라온다면 요기요 앱에도 실험하고 개선할 수 있도록 서비스의 사용성을 높이고 싶습니다.</p><p>마지막으로 Datadog 이나 장애티켓과의 연관성을 분석해서 외부요인을 함께 추적하려 합니다. 앱 리뷰나 크래시를 평면적으로 바라보지 않고 입체적으로 연관성을 추적하여 근본적인 원인을 해결할 수 있을 것입니다.</p><h3>Part 2 로 확장하기</h3><p>모바일팀에서는 오늘 소개해 드린 크래시와 앱 리뷰 분석 외에도 생산성 향상을 위해 AI 를 통한 지능화, 자동화 작업들을 시도하고 있습니다.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/743/1*HJZIqBD3gqsVUiio6Z3d6w.png" /><figcaption>Datadog 데일리 리포트(예시)</figcaption></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/616/1*FaNgdWDj4lVyYTC7VmmvEQ.png" /><figcaption>Datadog 비상 알림(예시)</figcaption></figure><p>오늘 소개드린 온콜 업무 분석 자동화는 현재 Datadog 리포트와 연계하여 다음과 같은 시스템을 디자인하고 있는데요, 궁극적으로는 크래시, 앱 리뷰, 앱 성능을 분석하여 사용자에게 최고의 사용자 경험을 제공하기 위해 온콜 업무를 위임하여 MTTD 와 MTTR 을 줄이려 합니다.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*0tQLkjQEXAMEWnT7X66Oow.png" /></figure><p>Part 2 에서는 코드 수정까지의 과정을 왜 AI 에게 모두 위임하여 완전 자동화를 하지 않고 사람의 판단이 필요한지에 대한 경험을 공유해 드리도록 하겠습니다.</p><h3>끝으로</h3><p>이번 글에서는 요기요 모바일팀에서 AI 를 활용하여 온콜 업무를 자동화하는 여정을 소개해 드렸습니다. 모든 자동화에 AI 를 활용하지는 않지만, 맥락을 이해하고 판단할 때 AI 는 강력한 도구임을 느낄 수 있었습니다. 요즘 개발자의 역할은 AI 의 등장 이후 빠르게 확장되고 있는데요, 이 글이 업무에서 AI 를 자동화하며 활용하고 싶으신 분들에게 도움이 되었길 바랍니다.</p><p>Part 2 에서는 왜 모바일팀에서 품질에 관한 판단을 AI 에게 모두 위임하지 않고 왜 사람에게 분배하였는지와 앞으로 어떻게 엔지니어의 역할을 확장하려 하는지에 대한 생각들을 공유해 드리도록 하겠습니다.</p><p>감사합니다.</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=622244c98d47" width="1" height="1" alt=""><hr><p><a href="https://techblog.yogiyo.co.kr/n8n%EA%B3%BC-%ED%95%A8%EA%BB%98%ED%95%98%EB%8A%94-%EB%AA%A8%EB%B0%94%EC%9D%BC%ED%8C%80-%EC%98%A8%EC%BD%9C-%EC%9E%90%EB%8F%99%ED%99%94-%EC%97%AC%EC%A0%95%EA%B8%B0-part-1-%EB%B6%84%EC%84%9D%EC%9D%80-ai%EC%97%90%EA%B2%8C-622244c98d47">n8n과 함께하는 모바일팀 온콜 자동화 여정기 — Part 1: 분석은 AI에게</a> was originally published in <a href="https://techblog.yogiyo.co.kr">YOGIYO Tech Blog - 요기요 기술블로그</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[AI가 바꾸는 UX 리서치: 설계부터 품질 관리까지]]></title>
            <link>https://techblog.yogiyo.co.kr/ai%EA%B0%80-%EB%B0%94%EA%BE%B8%EB%8A%94-ux-%EB%A6%AC%EC%84%9C%EC%B9%98-%EC%84%A4%EA%B3%84%EB%B6%80%ED%84%B0-%ED%92%88%EC%A7%88-%EA%B4%80%EB%A6%AC%EA%B9%8C%EC%A7%80-6649ee177183?source=rss----c1b33ccbbc42---4</link>
            <guid isPermaLink="false">https://medium.com/p/6649ee177183</guid>
            <category><![CDATA[post]]></category>
            <category><![CDATA[yogiyo]]></category>
            <category><![CDATA[product]]></category>
            <dc:creator><![CDATA[Seohyun Lee]]></dc:creator>
            <pubDate>Fri, 03 Apr 2026 00:34:26 GMT</pubDate>
            <atom:updated>2026-04-03T00:34:25.172Z</atom:updated>
            <content:encoded><![CDATA[<p><em>AI 기반의 고도화된 분석 파이프라인 구축기</em></p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*qMLW4IRYPBurvvU_GIwsbQ.png" /></figure><h3>들어가며</h3><p>요기요에서 리서처로 일한다는 것은<br>끊임없이 쏟아지는 비즈니스 가설들을 검증하는 과정의 연속입니다.</p><p>요기요 UX 리서치 팀은 급변하는 비즈니스 환경에 대응하기 위해<br>Research Operations(ReOps)에 AI를 적극 통합하여 <br>분석의 밀도와 속도를 동시에 높이는 시도를 이어가고 있습니다.</p><p>과거에는 속도에 대한 요구가 리서처에게 압박으로 다가왔으나<br>지금은 AI를 워크플로우의 핵심 파트너로 배치하여<br>효율적인 리서치 환경을 구축했습니다.</p><p>수많은 녹취록을 다시 들으며 분석하고 보고서를 작성하는 <br>반복되는 고충을 해결하고자 리서치 과정에 AI를 활용했습니다. <br>그리고 그 결과는 기대 이상이었습니다.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*xxVAWu-sCadhZJuoy-8F_Q.png" /></figure><p>결과적으로 보면, 주 단위로 환산하면 6주 가까이 걸리던 일이 <br>4주 만에 마무리 되어 약 25%의 시간을 아낄 수 있었습니다.</p><p>본 게시물에서는 ChatGPT, Gemini Pro, NotebookLM을 활용해<br>리서치 리드타임을 단축하고 인사이트의 깊이를 더한 과정을 소개합니다.</p><h3>[Step 1] 리서치 설계: 요구사항의 구조화</h3><p>조사 요청이 들어오는 단계에서 가장 중요한 것은<br>‘질문의 본질’을 파악하는 것입니다.</p><p>조사 요청이 들어오는 초기 단계에서 가장 큰 리스크는 <br>이해관계자의 <strong>모호한 비즈니스 언어</strong>입니다. <br>저는 ChatGPT를 단순한 챗봇이 아닌<br>비즈니스 요구사항을 리서치 설계로 변환하는 도구로 활용합니다.</p><h4>왜 ChatGPT인가?</h4><ul><li><strong>논리적 추론 능력:</strong> 복잡한 제약 조건과 논리적 맥락을 파악하는 능력이 매우 뛰어납니다. 비즈니스 가설 간의 충돌이나 논리적 비약을 잡아내는 데 최적화되어 있습니다.</li><li><strong>추상적 개념의 구체화:</strong> ‘사용자가 만족하는지 알고 싶어요’라는 <br>추상적인 요구를 ‘재방문 의사, 추천 지수(NPS), 태스크 완료율’ 등 <br>측정할 수 있는 지표로 분해하는 추론 능력이 탁월합니다.</li></ul><h4>어떻게 활용하는가?</h4><ul><li><strong>가설 해체:</strong> 요청서에 담긴 비즈니스 요구사항을 입력하고,<br>논리적 빈틈이나 모호한 개념을 식별합니다.</li><li><strong>리스크 방지:</strong> 설문지나 인터뷰 가이드 제작 시,<br>응답을 유도하는 질문이 있는지<br>질문의 흐름이 사용자 경험과 일치하는지 객관적으로 검토받습니다.</li><li><strong>효과:</strong> 킥오프 미팅 전 생각이 이미 구조화되어, 이해관계자와의<br>커뮤니케이션 과정이 획기적으로 줄어듭니다.</li></ul><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*sKeOU8bQI3fyT9aPbn9Xeg.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*DBg3iF000G0b_SLHC7xhWg.png" /></figure><h3>[Step 2] 데이터 분석: 비정형 Raw data 체계화</h3><p>조사 완료 후 쌓이는 방대한 인터뷰 스크립트와<br>정성적 데이터 분석에는 <strong>Gemini Pro</strong>를 활용합니다.</p><h4>왜 Gemini Pro인가?</h4><ul><li><strong>보안과 성능:</strong> 기업용 버전을 사용하여 데이터 보안을 유지하면서,<br>대량의 텍스트에서 패턴을 추출하는 Gemini의 강점을 극대화합니다.</li><li><strong>가설 기반 분석:</strong> 단순 요약이 아니라 “이 가설을 기준으로<br>찬성/반대 맥락을 분류해 줘”와 같은 구체적인 지침을 제공합니다.</li><li><strong>효과:</strong> 반복적인 단순 정리 작업은 AI에게 맡기고,<br>리서처는 데이터 사이의 숨겨진 맥락을 해석하고 전략을 도출하는<br>본질적인 업무에 집중할 수 있습니다.</li></ul><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*5VuQ2-KPiDyoal4TtlQxKA.png" /></figure><h4>End-to-End 리서치 파이프라인 5단계를 소개합니다.</h4><blockquote><strong>[Phase 1] 대규모 정성 데이터의 구조화</strong></blockquote><p>인터뷰가 끝나고 나면 수십 페이지의 스크립트가 남습니다. <br>리서처가 가장 많은 시간을 쓰는 구간이자<br>주관이 개입되기 쉬운 전사 및 코딩 단계입니다. <br>저는 이 과정을 자동화하기 위해 5단계의 의미 단위 분석 리서치 방법론을 <br>프롬프트에 녹여냈습니다.</p><p>이때 단순히 내용을 묻는 것이 아니라 <br>아래와 같은 구조화된 프롬프트를 설계하여 분석의 일관성을 유지했습니다.</p><ol><li><strong>Role Play:</strong> AI에게 UX 리서치 분석 전문가라는 페르소나를 부여해 전문적인 톤앤매너를 유도했습니다.</li><li><strong>Constraint:</strong> 한 문장에 하나의 의미만 담도록 제한하여, 이후 클러스터링이 용이하도록 설계했습니다.</li><li><strong>Data Cleaning:</strong> 리서처의 질문은 배제하고 사용자의 생각/행동/감정이라는 핵심 데이터만 추출하도록 명령했습니다.</li></ol><pre># Role: UX 리서치 분석 전문가<br># Task: 인터뷰 스크립트의 의미 단위 분해 및 정제<br><br>[Instruction]<br>아래 제공되는 인터뷰 스크립트를 분석하여 의미 단위로 분해해 줘.<br><br>[Constraints]<br>1. Atomic Unit: 한 문장은 반드시 하나의 의미만 담도록 분리할 것.<br>2. Filter: 사용자의 생각, 구체적인 행동, 감정이 드러나는 발언 위주로 추출할 것.<br>3. Clean-up: 모더레이터의 질문이나 단순한 추임새는 제외할 것.<br><br>[Output Format]<br>- [발언]: &quot;사용자의 실제 워딩&quot;<br>- [의미 요약]: 추출된 문장의 핵심 의도나 페인포인트 요약</pre><blockquote><strong>[Phase 2] 정성 데이터의 정량화: 태깅 및 속성 분류</strong></blockquote><p>의미 단위로 분해된 데이터는 파편화되어 있기 때문에, <br>이를 서비스 개선의 근거로 쓰기 위해서는 분류가 필수적입니다. <br>저는 AI에게 UX 리서치의 핵심 프레임워크인 <br><strong>Pain Point / Needs / Motivation / Barrier / Value</strong> 라는 5가지 태그를 <br>학습시켜 수만 줄의 데이터를 자동 분류했습니다.</p><ol><li><strong>Framework Mapping:</strong> UX 리서처가 흔히 사용하는 분석 프레임워크를 태그 기준으로 정의하여 AI와 리서처의 시각을 동기화했습니다.</li><li><strong>Multidimensional Analysis:</strong> 단순한 긍/부정 분석을 넘어, 사용자가 ‘왜(Motivation)’ 사용하는지와 ‘무엇이 막고 있는지(Barrier)’를 입체적으로 식별하도록 설계했습니다.</li></ol><pre># Role: UX 리서치 데이터 분석 전문가<br># Task: 사용자 발언 데이터의 속성 태깅 및 해석<br><br>[Tag Definition]<br>- Pain Point: 서비스 이용 중 겪는 구체적인 불편함이나 불만<br>- Needs: 사용자가 해결하고 싶어 하는 근본적인 요구사항<br>- Motivation: 서비스를 이용하게 만드는 심리적/환경적 동기<br>- Barrier: 서비스 이용을 주저하게 만들거나 중도 이탈하게 하는 요인<br>- Value: 사용자가 서비스를 통해 얻는 효익이나 긍정적 가치<br><br>[Output Format]<br>- [발언]: &quot;사용자 워딩&quot;<br>- [Tag]: 해당 발언에 맞는 태그 (중복 가능)<br>- [해석]: 리서처 관점에서의 데이터 함의 기술</pre><blockquote><strong>[Phase 3] 복잡한 데이터의 구조화: 테마 도출</strong></blockquote><p>세 번째 프롬프트는 리서치의 꽃이라고 할 수 있는 <br>Affinity Diagram 과정을 자동화하는 핵심 단계입니다.</p><p>수많은 포스트잇을 벽에 붙이며 그룹핑하던 아날로그 방식을 <br>비정형 데이터의 클러스터링이라는 기술적 관점으로 <br>재해석하여 프롬프트에 적용해 보았습니다.</p><ol><li><strong>Bottom-up Clustering:</strong> 개별 발언에서 시작해 상위 테마로 올라가는 귀납적 분석 방식을 AI에게 학습시켰습니다.</li><li><strong>Representative Selection:</strong> 수많은 발언 중 팀원들을 가장 잘 설득할 수 있는 Crucial Voice를 추출하도록 설계했습니다.</li><li><strong>Actionable Insight:</strong> 단순한 분류를 넘어, 비즈니스 액션으로 이어질 수 있는 사용자 니즈를 도출하는 데 집중했습니다.</li></ol><pre># Role: Senior UX Strategic Researcher<br># Task: 태깅된 발언 기반 어피니티 다이어그램(Affinity Diagram) 및 핵심 테마 도출<br><br>[Analysis Process]<br>1. Similarity Grouping: 유사한 태그와 맥락을 가진 사용자 발언들을 논리적으로 그룹핑할 것.<br>2. Naming: 각 그룹의 핵심 가치를 관통하는 직관적인 &#39;테마 명칭&#39;을 정의할 것.<br>3. Selection: 해당 테마를 가장 잘 대변하는 대표 발언을 선정할 것.<br><br>[Output Format]<br>- Theme: [테마 이름]<br>- 설명: 이 테마가 형성된 배경과 사용자의 전반적인 맥락 설명<br>- 대표 발언: &quot;사용자의 핵심 워딩&quot;<br>- 사용자 니즈: 이 테마를 통해 해결해야 하는 근본적인 갈증(Unmet Needs)</pre><blockquote><strong>[Phase 4] 데이터 기반의 가설 검증과 의사결정</strong></blockquote><p>리서치의 최종 목적은 우리가 세운 가설이 참인지 거짓인지 판별하고, <br>다음 액션을 결정하는 것입니다. 리서처의 확증 편향을 방지하고 <br>객관적인 가설 검증 파이프라인을 구축했습니다.</p><ol><li><strong>Strict Evidence-Based:</strong> 모든 평가는 반드시 앞서 도출된 테마와 Raw data에 기반하도록 강제했습니다.</li><li><strong>Balanced Perspective:</strong> 가설을 지지하는 증거뿐만 아니라 기각하는 증거를 동시에 추출하게 하여, 가설의 취약점을 입체적으로 파악했습니다.</li><li><strong>Actionable Conclusion:</strong> 분석에 그치지 않고, 리서치 리드의 관점에서 비즈니스 방향성을 제안하도록 설계했습니다.</li></ol><pre># Role: UX Research Lead &amp; Product Strategist<br># Task: 도출된 테마 및 사용자 발언 기반 비즈니스 가설 검증<br><br>[Hypothesis List]<br>- 가설 1: [내용 입력]<br>- 가설 2: [내용 입력]<br>- 가설 3: [내용 입력]<br><br>[Evaluation Criteria]<br>각 가설에 대해 다음 4가지 요소를 정밀하게 분석할 것:<br>1. 지지 Evidence: 가설을 뒷받침하는 사용자의 구체적인 발언 및 행동 패턴<br>2. 반대 Evidence: 가설과 배치되거나 예외적인 케이스의 데이터<br>3. 해석: 지지/반대 데이터를 종합했을 때 추출되는 핵심 인사이트<br>4. 결론: 해당 가설의 채택 여부(Accept/Reject/Modify) 및 권장 Action<br><br>[Output Format]<br>- 가설명:<br>- [지지 Evidence] / [반대 Evidence]<br>- [해석]<br>- [결론]</pre><blockquote><strong>[Phase 5] 액션 가능한 인사이트</strong></blockquote><p>마지막으로 리서치의 진짜 가치는 보고서 그 자체가 아니라, <br><strong>Product가 어떻게 바뀌어야 하느냐</strong>라는 질문에 답하는 데 있습니다. <br>저는 AI에게 리서치 데이터와 서비스의 도메인 지식을 결합하여<br>즉시 검토할 수 있는 수준의 Action Items를 도출하도록 요청합니다.</p><ol><li><strong>Domain Context:</strong> 주제에 대한 특수한 상황 등을 AI에게 인지시켰습니다.</li><li><strong>Logical Chain:</strong> [인사이트] → [실제 발언(증거)] → [심리 해석] → [Action Items]으로 이어지는 논리적 사슬을 구성하여 설득력을 높였습니다.</li></ol><pre># Role: UX Research Lead &amp; Product Strategist<br># Task: [주제] 핵심 인사이트 도출 및 제품 전략 제안<br><br>[Analysis Context]<br>앞선 가설 검증 결과와 태깅된 데이터를 기반으로, 서비스 이용 과정에서 발견된 가장 결정적인 인사이트 3가지를 정리할 것.<br><br>[Output Structure]<br>각 인사이트는 다음 4단계 구조를 엄격히 따를 것:<br>1. Insight: 사용자가 느끼는 핵심 가치나 결정적인 페인포인트 (한 문장 정의)<br>2. Evidence: 해당 인사이트를 뒷받침하는 실제 사용자 발언 (Quote)<br>3. Interpretation: 사용자의 심리적 배경이나 행동의 근본 원인 분석<br>4. Product Implication: 이를 해결/강화하기 위한 구체적인 제품 기능이나 UX 개선안</pre><h3>[Step 3] 의사결정 최적화: 근거 기반의 구조화된 리포팅</h3><p>리서치의 가치는 결국 유관 부서를 설득하고<br>실제 액션으로 이어지게 만드는 데 있습니다.</p><p>저는 <strong>NotebookLM</strong>을 통해 보고서의 전달력을 높입니다.</p><p>리서치 결과의 신뢰성은 근거에서 나옵니다. NotebookLM은 <br>제가 업로드한 Raw data 내에서 RAG 기반으로 답변을 생성하는 <br>Grounded Generation 방식을 취하기 때문에<br>AI가 지어낸 이야기가 아닌 실제 사용자의 목소리에 기반한 <br>슬라이드 구조를 잡는 데 최적의 도구였습니다.</p><ul><li><strong>다변화된 리포트:</strong> 인사이트와 원본 데이터를 학습시킨 뒤,<br>맥락 중심의 상세 버전과 의사결정권자를 위한<br>‘One Message’ 슬라이드 구조를 동시에 생성합니다.</li><li><strong>시각적 구조화:</strong> 비록 AI가 생성한 이미지나 텍스트에<br>일부 오탈자가 있더라도, 복잡한 정보를 한눈에 들어오게<br>시각화하는 것에 우선순위를 둡니다. 조직은 완벽한 문장보다<br>빠르고 명확한 맥락 공유를 더 필요로 하기 때문입니다.</li></ul><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*zKtzAQNjsRH46HHTtu0ahA.png" /></figure><p>AI 리서치의 핵심은 한 번의 완벽한 답변을 기대하는 것이 아니라, <br>여러 번의 변주 속에서 리서처가 최적의 맥락을 골라내는 <br>큐레이션에 있습니다.</p><p>많은 분이 AI에게 한 번 질문하고 나온 답변을 <br>그대로 결과물에 사용하곤 합니다. <br>하지만 저는 NotebookLM을 활용할 때 <br><strong>동일한 자료로 여러 번 뽑는 전략을 사용</strong>합니다.</p><h4>왜 여러 번 뽑아야 할까요?</h4><ol><li><strong>맥락의 재구성:</strong> 동일한 Raw date라도 AI가 매번 강조하는 지점이 미세하게 다를 수 있습니다. 이를 반복하면 리서처가 놓쳤던 사소한 맥락이 나타나기도 합니다.</li><li><strong>결과 교차 검증:</strong> 여러 번 생성했을 때 공통으로 등장하는 키워드는 확실한 근거가 있는 핵심 인사이트일 확률이 높습니다.</li></ol><h3>[Step 4] 리서치 품질 관리: AI 기반 모더레이팅 회고</h3><p>리서치 결과만큼 중요한 것이 리서처 자신의 태도와 질문의 품질입니다. <br> <br>저는 AI를 시니어 리서처 페르소나로 설정해 <br>제 인터뷰 스크립트를 객관적으로 평가받습니다.</p><p><strong>Prompt Intent</strong></p><ol><li><strong>Self-Objectification:</strong> 리서처 본인의 발언을 데이터화하여 분석 대상으로 삼습니다.</li><li><strong>Bias Detection:</strong> 자신도 모르게 던진 유도형 질문이나 편향된 추임새를 식별합니다.</li></ol><pre># Role: Senior UX Research Coach<br># Task: 인터뷰 모더레이팅 품질 리뷰 및 개선안 제안<br><br>[Context]<br>나는 이번 인터뷰를 진행한 모더레이터야. 내가 진행한 인터뷰 스크립트를 학습하고, 다음 관점에서 나에게 피드백을 줘.<br><br>[Review Points]<br>1. 유도형 질문: 답변을 특정 방향으로 유도하거나 &#39;네/아니요&#39;를 강요한 질문이 있는가?<br>2. 경청 및 심층 탐색: 사용자의 답변 중 추가 질문(Probing)이 필요했는데 놓친 지점은 없는가?<br>3. 중립성: 사용자의 부정적인 의견에 당황하거나, 긍정적인 답변에만 과하게 반응하지 않았는가?<br><br>[Output Format]<br>- 발견된 문제점: &quot;당시 나의 발언&quot;<br>- 개선 제안: &quot;다음에 다시 질문한다면 이렇게 물어보세요.&quot;<br>- 총평: 이번 인터뷰 진행의 전반적인 객관성 점수 및 조언</pre><h3>마치며: 대체가 아닌 사고의 확장</h3><p>리서처에게 AI는 내 자리를 위협하는 대체재가 아니라, <br>역량을 증폭시키는 가장 유능한 조력자입니다.</p><p>기존에는 리서처가 직접 데이터를 정리하고 요약하는 작업자에 가까웠다면<br>이제는 <strong>무엇을 물어야 할지 정의하고, 어떤 가설로 데이터를 볼지 선택하는<br></strong> 맥락의 편집자로 역할이 변화하고 있습니다.</p><p>도구가 예리해질수록 그것을 휘두르는 리서처의 감각은<br>더욱 중요해질 것입니다.</p><p>도구를 활용한 효율화는 단순히 업무 시간을 줄이는 것을 넘어<br>리서처가 더 높은 차원의 전략적 인사이트에 집중할 수 있는 <br>리서치 환경의 확장을 의미합니다.</p><pre>본 게시물에 사용된 이미지는 Gemini Pro와 Notebook LM을 활용하여 만들어졌습니다.</pre><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=6649ee177183" width="1" height="1" alt=""><hr><p><a href="https://techblog.yogiyo.co.kr/ai%EA%B0%80-%EB%B0%94%EA%BE%B8%EB%8A%94-ux-%EB%A6%AC%EC%84%9C%EC%B9%98-%EC%84%A4%EA%B3%84%EB%B6%80%ED%84%B0-%ED%92%88%EC%A7%88-%EA%B4%80%EB%A6%AC%EA%B9%8C%EC%A7%80-6649ee177183">AI가 바꾸는 UX 리서치: 설계부터 품질 관리까지</a> was originally published in <a href="https://techblog.yogiyo.co.kr">YOGIYO Tech Blog - 요기요 기술블로그</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[ChatGPT에서 요기요 배달 쓰기 — MCP + 위젯 연동 개발기]]></title>
            <link>https://techblog.yogiyo.co.kr/chatgpt%EC%97%90%EC%84%9C-%EC%9A%94%EA%B8%B0%EC%9A%94-%EB%B0%B0%EB%8B%AC-%EC%93%B0%EA%B8%B0-mcp-%EC%9C%84%EC%A0%AF-%EC%97%B0%EB%8F%99-%EA%B0%9C%EB%B0%9C%EA%B8%B0-c6636a9a11ff?source=rss----c1b33ccbbc42---4</link>
            <guid isPermaLink="false">https://medium.com/p/c6636a9a11ff</guid>
            <category><![CDATA[post]]></category>
            <category><![CDATA[tech]]></category>
            <category><![CDATA[yogiyo]]></category>
            <dc:creator><![CDATA[Hyoungjin Choi]]></dc:creator>
            <pubDate>Thu, 12 Mar 2026 04:30:40 GMT</pubDate>
            <atom:updated>2026-03-12T04:43:59.990Z</atom:updated>
            <content:encoded><![CDATA[<blockquote><em>ChatGPT Apps SDK와 MCP(Model Context Protocol)로 요기요 배달 서비스를 연동한 프로젝트의 기술적인 설계와 구현, 그리고 개발 과정에서 얻은 인사이트를 공유합니다.</em></blockquote><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*6Iai233kYih0i6c8aKZdAQ.png" /></figure><h3>1. 왜 ChatGPT App인가</h3><p>약 1년 전쯤, 한 경제 유튜버가 “곧 모든 앱이 AI로 대체되는 시대가 온다”며 배달 앱을 예로 든 적이 있었습니다. 배달 앱을 열어서 카테고리를 누르고, 가게를 고르고, 메뉴를 스크롤 하는 대신 — AI에게 “오늘 매운 거 먹고 싶어”라고 말하면 메뉴 추천부터 주문까지 한 번에 처리해 주는 세상이 올 거라는 이야기였습니다. 배달 플랫폼을 개발하는 입장에서 그 영상을 보면서 든 생각은 솔직히 회의적이었습니다. 수만 개 레스토랑의 메뉴 정보, 실시간 배달 가능 여부, 라이더 매칭, 결제 처리 — 이 모든 걸 AI가 어떻게 다 처리한다는 거지? <strong>배달 플랫폼이 직접 데이터와 기능을 열어주지 않는 한 불가능할 텐데</strong> 라고 생각했습니다.</p><p>그런데 2025년 12월, ChatGPT Apps가 출시되면서 그 “불가능”이 현실이 되는 구조가 만들어졌습니다. OpenAI가 제공하는 <strong>MCP(Model Context Protocol)</strong> 와 <strong>Apps SDK</strong>를 통해, 외부 서비스가 자신의 데이터와 기능을 ChatGPT에 연결할 수 있는 표준 인터페이스가 열린 것입니다. AI가 혼자서 배달 서비스를 만드는 게 아니라, <strong>배달 플랫폼이 MCP 서버를 통해 도구(Tool)를 제공하면 ChatGPT가 대화 맥락에 맞춰 그 도구를 호출하는 구조</strong> — 결국 플랫폼이 정보를 열어야 가능하다는 그때의 생각이 맞았고, MCP가 바로 그 “여는 방법”을 표준화한 프로토콜이었습니다.</p><p>여기서 주목할 점은 <strong>MCP 자체와 ChatGPT App의 차이</strong>입니다. 기존 MCP는 REST API처럼 AI가 외부 시스템의 기능을 호출할 수 있도록 Tool을 확장하는 프로토콜입니다. AI가 Tool을 호출하면 JSON 형태의 데이터를 받아서 텍스트로 요약해 응답하는 구조 — 결국 사용자가 보는 것은 텍스트뿐이었습니다. 그런데 ChatGPT Apps SDK는 여기에 <strong>위젯(Widget)</strong> 이라는 레이어를 추가했습니다. MCP 서버가 Tool 응답에 _meta.outputTemplate이라는 메타데이터를 포함하면, ChatGPT가 단순 텍스트 대신 <strong>HTML 기반의 인터랙티브 UI</strong>를 대화 안에 인라인으로 렌더링합니다. 사용자는 이 위젯 안에서 지도를 드래그하고, 가게를 클릭하고, 메뉴를 스크롤 하고, 주문 버튼을 누를 수 있습니다. 텍스트 응답을 읽는 것이 아니라 <strong>서비스와 직접 상호작용하는 경험</strong>이 가능해진 것입니다.</p><p>이제 사용자는 “강남역 근처 치킨 배달 가능한 곳 알려줘”라고 대화하는 것만으로, ChatGPT가 요기요의 레스토랑 검색 Tool을 호출하고, 그 결과를 가게 리스트·상세 정보·메뉴·리뷰·지도 같은 위젯으로 바로 보여줄 수 있게 됩니다. 기존 요기요 앱/웹의 전체 기능을 그대로 옮기는 것이 아니라, <strong>대화 흐름 속에서 필요한 데이터만 Tool로 가져오고, 그에 맞는 UI를 위젯으로 렌더링하는 구조</strong> — 이것이 이 프로젝트가 만들고자 한 경험이었습니다.</p><h3>2. 개발 환경 세팅</h3><p>ChatGPT App을 개발하고 테스트하려면 먼저 몇 가지 환경이 갖춰져야 합니다.</p><ul><li><strong>ChatGPT 워크스페이스 또는 Pro 계정</strong>: 개발 앱을 등록하고 테스트하려면 <a href="https://platform.openai.com/">ChatGPT 개발자 워크스페이스</a> 접근 권한이 있는 계정이 필요합니다. 무료 플랜에서는 개발 앱 등록이 불가능합니다.</li><li><strong>퍼블릭 도메인 또는 터널링 서비스</strong>: ChatGPT가 MCP 서버에 접속해야 하므로, 외부에서 접근 가능한 HTTPS 엔드포인트가 필요합니다. 프로덕션이라면 도메인과 SSL 인증서를, 로컬 개발이라면 <a href="https://ngrok.com/">ngrok</a> 같은 터널링 서비스를 사용하여 로컬 서버를 외부에 노출합니다.</li></ul><figure><img alt="" src="https://cdn-images-1.medium.com/max/926/1*EK06Td4AR6P1dOWv83SrMg.png" /><figcaption>Registering a Dev version app to the workspace</figcaption></figure><ul><li><strong>개발 앱의 Tool 갱신</strong>: 개발 앱으로 등록된 상태에서는 MCP 서버의 Tool 정의를 수정한 뒤, ChatGPT 대화창에서 <strong>새로고침</strong>만 하면 변경된 Tool 목록이 즉시 반영됩니다. 앱을 다시 등록하거나 재배포할 필요 없이 Tool 스키마를 빠르게 반복 테스트할 수 있어 개발 사이클이 매우 짧습니다.</li></ul><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*NG93Z-5bMtH1lKOdNiXNOQ.png" /><figcaption>Refresh MCP in Dev mode</figcaption></figure><h3>3. 개발 타임라인</h3><p>2025년 12월 초 ChatGPT Apps 서비스의 공개가 예정되어 있었기 때문에, 한정된 일정 안에 프로덕션 수준의 MCP 서버와 위젯을 완성해야 하는 상황이었습니다. 처음부터 모든 것을 구축하기보다는 OpenAI가 공개한 <a href="https://github.com/openai/openai-apps-sdk-examples">Apps SDK Examples</a> 레포지토리의 레퍼런스 구현체(Pizzaz/ecommerce 예제)를 기반으로 빠르게 프로토타이핑한 뒤, 요기요 배달 도메인에 맞춰 확장해 나가는 전략을 택했습니다. 특히 프로토타이핑 단계에서는 AI 코딩 어시스턴트(Claude)를 적극 활용한 <strong>바이브 코딩(Vibe Coding)</strong> 방식으로 개발 속도를 끌어올렸습니다. 예제 코드의 구조를 파악하고, 요기요 API 연동 코드를 생성하고, 위젯 컴포넌트의 초기 스캐폴딩을 만드는 과정에서 AI와의 대화 기반 개발이 반복적인 보일러플레이트 작업을 크게 줄여주었습니다.</p><p>기술 스택 선정에서도 개발 속도를 우선시했습니다. OpenAI의 예제 레포는 MCP 서버를 <strong>Python(</strong>FastMCP<strong>)</strong> 과 <strong>Node.js(</strong>@modelcontextprotocol/sdk<strong>)</strong> 두 가지 스택으로 제공하고 있었는데, 이번 프로젝트는 프론트엔드 개발자가 위젯(React/TypeScript)과 MCP 서버를 모두 담당하는 구조였기 때문에, 클라이언트와 서버 간 언어 컨텍스트 스위칭 없이 <strong>TypeScript 단일 스택</strong>으로 개발할 수 있는 <strong>Node.js 기반 MCP 서버</strong>를 선택했습니다. 이를 통해 위젯의 타입 정의(types.ts)를 서버와 공유하고, pnpm 워크스페이스로 모노레포를 구성하여 빌드·배포 파이프라인을 일원화할 수 있었습니다.</p><h3>Phase 1 — 도메인 전환 &amp; API 연동</h3><p><a href="https://github.com/openai/openai-apps-sdk-examples">OpenAI Apps SDK Examples</a>의 pizzaz_server_node 구현체를 fork하여, 요기요 배달 서비스에 맞게 재구성하는 것으로 시작했습니다.</p><ul><li>기존 데모의 하드코딩된 mock 데이터를 제거하고, <strong>요기요 내부 API와의 실제 연동</strong>으로 교체. Tool의 inputSchema를 배달 도메인에 맞춰 재설계하고, CallToolRequest 핸들러에서 내부 API를 호출하여 structuredContent로 응답하는 파이프라인을 구축했습니다.</li><li>위젯 ID를 기능 단위(shop-list, shop-detail, map, reviews, menu-list)로 정리하고, 각 위젯별 outputTemplate URI(ui://widget/*.html) 매핑을 구성했습니다.</li></ul><h3>Phase 2 — 위젯 UI/UX 고도화</h3><p>디자인팀과 협업하여 Figma 디자인 가이드를 위젯에 적용했습니다. OpenAI가 제공하는 <a href="https://developers.openai.com/apps-sdk/concepts/design-guidelines">App Design Guidelines</a>과 <a href="https://developers.openai.com/apps-sdk/concepts/ui-guidelines">UI Guidelines</a>를 기반으로, 인라인 카드·풀스크린 등 위젯 디스플레이 모드의 제약 사항을 반영하면서 요기요 브랜드 디자인을 적용했습니다.</p><ul><li><strong>리뷰 위젯</strong> 신규 추가: 가게별 리뷰 목록을 렌더링하는 reviews.html 위젯과 대응하는 get-restaurant-review-list Tool을 구현했습니다.</li><li>Figma 시안 기반으로 <strong>전체 위젯 UI를 재설계</strong>: 가게 리스트(shop-list), 가게 상세(shop-detail), 메뉴 리스트(menu-list), 지도(map) 등의 레이아웃·타이포그래피·컬러 토큰을 Tailwind CSS 커스텀 테마로 정의하고, 반응형 브레이크포인트와 다크 모드 대응을 적용했습니다.</li><li>CTA 버튼 스타일, 요기요 앱 딥링크 스킴, 코드 포맷팅 등 세부 사항 정리.</li></ul><h3>Phase 3 — 아키텍처 분리: 위젯 Static Hosting vs MCP Server</h3><p>초기에는 MCP 서버(Node.js)가 Tool 요청 처리와 위젯 정적 파일(HTML/JS/CSS/이미지) 서빙을 모두 담당하는 모놀리식 구조였습니다. 운영 환경을 구성하면서 이 두 책임을 분리하기로 결정했습니다.</p><p><strong>분리 결정의 근거:</strong></p><ul><li><strong>관심사 분리(Separation of Concerns)</strong>: MCP 서버는 tools/call 요청에 대한 비즈니스 로직 처리(API 호출, 데이터 가공, structuredContent 생성)에 집중하고, 위젯 렌더링에 필요한 정적 리소스(빌드된 JS 번들, CSS, 이미지 등)는 별도의 Static Hosting으로 분리하는 것이 각 계층의 역할을 명확히 합니다.</li><li><strong>배포 독립성</strong>: 위젯 UI만 수정된 경우 S3에 정적 파일만 재배포하면 되고, Tool 로직이 변경된 경우 MCP 서버만 재배포하면 됩니다. 서로의 배포 사이클이 독립적이므로 롤백과 핫픽스가 용이합니다.</li><li><strong>성능·확장성</strong>: 정적 파일을 S3 + CloudFront CDN을 통해 서빙하면 엣지 캐싱으로 레이턴시가 줄고, MCP 서버의 네트워크 대역폭과 이벤트 루프를 Tool 처리에만 할당할 수 있습니다.</li></ul><p>이에 따라 MCP 서버 엔드포인트 URL과 위젯 정적 리소스(S3/CDN) URL을 환경변수로 분리하고, ReadResourceRequest 핸들러가 반환하는 위젯 HTML 내의 JS/CSS 참조 경로가 위젯 CDN 도메인을 바라보도록 빌드 파이프라인을 구성했습니다.</p><h3>Phase 4 — CSP 정책·Tool 메타데이터·UX 개선</h3><p>ChatGPT의 sandbox iframe 환경에서 위젯이 외부 리소스에 접근하려면, MCP 서버가 _meta 응답에 허용 도메인을 명시적으로 선언해야 합니다.</p><ul><li><strong>CSP(Content Security Policy) 프로덕션 정리</strong>: connect_domains, resource_domains, script_domains에서 localhost를 제거하고, 프로덕션 도메인(*.yogiyo.co.kr, S3 오리진, Mapbox API 등)만 화이트리스트에 등록했습니다.</li><li>widgetDomain<strong> 메타</strong> 추가: ChatGPT UI 상단에 표시되는 브랜드 링크용 도메인을 pm2 ecosystem config로 관리.</li><li>suggestedNextTools 적용: Tool 응답에 다음 호출 후보를 포함시켜, ChatGPT가 맥락에 맞는 후속 Tool을 자동 제안하도록 구성.</li><li><strong>Mapbox 지도 위젯</strong>에 사용자 현재 위치 마커 추가, 필터 적용 시 스켈레톤 UI 도입.</li></ul><h3>Phase 5 — Observability 구축 &amp; 크로스 플랫폼 대응</h3><p>프로덕션 운영을 위한 모니터링 체계를 구축하고, iOS/다크 모드 등 다양한 클라이언트 환경에 대응했습니다.</p><ul><li><strong>Datadog APM 연동</strong>: dd-trace를 MCP 서버에 통합하고, SSE 연결(mcp.sse_connection)과 개별 메시지 전송(mcp.sse_send), POST 요청(mcp.post_message)에 대한 커스텀 스팬을 추가하여 요청별 트레이스를 수집했습니다.</li><li><strong>빌드 파일 해시</strong>: 위젯 번들에 content hash를 적용하여 CDN 캐시 무효화와 버전 추적이 가능하도록 구성.</li><li><strong>iOS 폰트 대응</strong>: SF Pro 폰트 패밀리를 플랫폼별로 분기 적용. <strong>다크 모드</strong> 렌더링 이슈 수정.</li><li><strong>OpenAI Apps 도메인 검증</strong>: .well-known/openai-apps-challenge 엔드포인트 추가로 앱 소유권 인증 처리.</li><li><strong>Tool </strong>annotations 설정: readOnlyHint, destructiveHint, openWorldHint 등을 명시하여 ChatGPT가 승인 프롬프트 없이 Tool을 호출할 수 있도록 구성.</li></ul><h3>Phase 6 — 안정화 &amp; 유지보수</h3><ul><li><strong>Datadog 트레이서 초기화 수정</strong>: dd-trace import 순서 이슈로 인한 트레이싱 누락 버그를 수정. ESM 환경에서 tracer가 다른 모듈보다 먼저 로드되도록 import 순서를 보장했습니다.</li></ul><h3>4. 전체 아키텍처</h3><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*yfF20rh16hE72WiEfYqXTQ.png" /><figcaption>Service Architecture</figcaption></figure><p><strong>MCP 서버 (Node.js / TypeScript)</strong></p><ul><li><strong>역할</strong>: 툴 목록 제공(tools/list), 툴 호출 처리(tools/call), 위젯용 HTML/리소스 정보 제공, <strong>CSP 메타데이터 선언</strong>.</li><li><strong>전송</strong>: SSE(Server-Sent Events)로 ChatGPT와 장기 연결 유지.</li><li><strong>실제 데이터</strong>: 요기요 내부 API를 호출해 위치·레스토랑·메뉴·리뷰·지도 데이터를 가져옵니다.</li></ul><p><strong>위젯 (React + Vite)</strong></p><ul><li>리스트·상세·메뉴·지도·리뷰 등 화면을 <strong>독립된 HTML/JS/CSS 번들</strong>로 빌드.</li><li>ChatGPT 쪽에서는 <strong>iframe + web sandbox</strong> 안에서 이 번들을 로드해 렌더링합니다.</li><li>위젯 ↔ ChatGPT: window.openai.callTool() 등 Apps SDK API로 통신.</li></ul><p><strong>배포</strong></p><ul><li><strong>위젯 정적 파일</strong>: S3 + CloudFront CDN으로 서빙.</li><li><strong>MCP 서버</strong>: EC2에서 실행, ALB를 통해 HTTPS로 노출.</li></ul><h3>5. MCP 서버와 툴 설계</h3><p>MCP 서버는 다음 세 가지를 구현합니다.</p><ol><li><strong>List tools</strong> — 사용 가능한 툴 목록과 입력 스키마, 그리고 <strong>어떤 위젯 HTML과 매핑할지</strong> (_meta.openai/outputTemplate) 를 응답.</li><li><strong>Call tools</strong> — ChatGPT가 선택한 툴과 인자를 받아, 요기요 API를 호출한 뒤 구조화된 결과 + 위젯 메타데이터를 반환.</li><li><strong>Read resources</strong> — 위젯 HTML 템플릿을 제공. ChatGPT가 해당 위젯을 iframe으로 띄울 때 사용.</li></ol><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*KHZ6ParbG8fYdG3UgUrHBQ.png" /></figure><p>각 툴은 readOnlyHint: true 어노테이션으로 “읽기 전용 도구”임을 표시해, 불필요한 사용자 확인을 줄였습니다.</p><h3>6. 위젯과 데이터 흐름</h3><p>위젯은 <strong>ChatGPT의 sandbox iframe</strong> 안에서 동작합니다. 우리가 빌드한 HTML/JS/CSS가 connector_*.web-sandbox.oaiusercontent.com 같은 도메인의 iframe으로 로드되고, 그 안에서만 실행됩니다.</p><ul><li><strong>데이터 수신</strong>: MCP 서버가 툴 결과와 함께 위젯 URL을 주면, ChatGPT가 해당 URL을 iframe으로 열고, <strong>tool output</strong>을 위젯에 전달합니다.</li><li><strong>상호작용</strong>: 위젯 안에서는 CSP 설정을 통해 외부 API를 직접 호출하는 것도 가능하지만, 대신 window.openai.callTool()을 사용했습니다. callTool을 거치면 요청이 ChatGPT를 통해 MCP 서버로 전달되기 때문에, ChatGPT가 사용자의 위젯 내 액션(어떤 Tool이 어떤 인자로 호출되었는지, 어떤 데이터가 반환되었는지)을 대화 맥락에 기록할 수 있습니다. 이를 통해 사용자가 “방금 본 가게 리뷰도 보여줘”처럼 후속 대화를 이어갈 때 맥락이 끊기지 않도록 했습니다.</li><li><strong>외부 이동</strong>: 결제 등 외부 페이지로 보낼 때는 window.openai.openExternal({ href: ‘…’ }) 를 사용해, iframe 밖 브라우저 탭으로 열도록 했습니다.</li></ul><h3>7. CSP: sandbox iframe 안에서의 보안 제약</h3><p>ChatGPT Apps 위젯 개발에서 <strong>가장 많이 부딪힌 부분</strong>이 CSP(Content Security Policy)였습니다.</p><p>ChatGPT는 위젯을 <strong>sandbox iframe</strong> 안에서 실행하며, 기본적으로 모든 외부 네트워크 요청을 차단합니다. 우리 위젯처럼 외부 JS(Mapbox GL), 이미지 CDN, MCP 서버 호출이 필요한 경우, MCP 메타데이터의 openai/widgetCSP 필드에 허용 도메인을 명시적으로 선언해야 합니다.</p><pre>&quot;openai/widgetCSP&quot;: {<br>  connect_domains: [mcpUrl, widgetUrl, &quot;https://api.mapbox.com&quot;, ...],  // fetch, callTool 등<br>  resource_domains: [widgetUrl, &quot;https://rev-static.yogiyo.co.kr&quot;, &quot;data:&quot;, ...],  // 이미지, CSS<br>  script_domains: [widgetUrl, &quot;https://api.mapbox.com&quot;, ...],  // 외부 JS 로드<br>}</pre><p>이 메타데이터에 없는 도메인으로의 요청은 <strong>브라우저가 조용히 차단</strong>합니다. 에러 팝업 없이 이미지가 안 뜨거나, 지도가 빈 화면이 되거나, API 호출이 실패합니다. 브라우저 콘솔의 Refused to… 로그가 유일한 단서이기 때문에, CSP 문제는 알고 있으면 5분, 모르면 반나절이 걸립니다.</p><p>개발 중 겪은 대표적인 CSP 이슈들:</p><ul><li><strong>지도 빈 화면</strong>: Mapbox 타일 도메인(api.mapbox.com, events.mapbox.com)이 connect_domains에서 빠지면 지도가 회색으로만 렌더링 — 토큰 문제로 오인하기 쉬움</li><li><strong>이미지 깨짐</strong>: 음식점 썸네일 CDN이나 data: URI(Base64 마커 아이콘)를 resource_domains에 빠뜨리면 조용히 실패</li><li>data:<strong> URI 누락</strong>: Mapbox 마커나 커스텀 아이콘을 Base64로 인라인 삽입할 때, resource_domains에 “data:”가 없으면 마커 이미지가 표시되지 않음 — 이것도 조용히 실패하는 유형이라 원인 파악에 시간 소요</li></ul><h3>8. 배포와 운영에서의 선택</h3><h3>URL 분리</h3><p>배포 환경에서는 세 가지 URL을 분리하여 관리합니다.</p><ul><li><strong>MCP 서버 URL</strong>: ChatGPT가 MCP 엔드포인트에 연결하는 주소</li><li><strong>위젯 CDN URL</strong>: 위젯 정적 파일(JS/CSS/HTML)이 서빙되는 S3/CloudFront 주소</li><li><strong>위젯 도메인</strong>: ChatGPT UI에서 위젯 상단 브랜드 링크 클릭 시 이동하는 서비스 도메인</li></ul><p>빌드 시점에 위젯 CDN URL을 주입하여 HTML 안의 스크립트/스타일 경로를 만들고, MCP 서버의 CSP에도 이 도메인을 허용하도록 맞췄습니다.</p><h3>서버에도 빌드가 필요한 이유</h3><p>MCP의 ReadResource에서 위젯 HTML 템플릿을 내려줄 때, HTML 안에 &lt;script src=”…”&gt; 로 링크된 JS 파일명에는 빌드 시 생성되는 <strong>콘텐츠 해시</strong>가 포함됩니다(예: shop-list.a3f2b1c.js). S3/CDN에 배포된 JS 파일과 이 해시가 일치해야 위젯이 정상 로딩되므로, 서버 배포 시에도 반드시 pnpm run build를 실행하여 최신 빌드 결과물의 해시를 맞춰야 합니다. 위젯 빌드 → S3 업로드 → MCP 서버 배포 순서가 어긋나면 해시 불일치로 위젯 로딩이 실패하기 때문에, CI/CD 파이프라인에서 이 순서를 명시적으로 보장하도록 구성했습니다.</p><h3>로컬 개발</h3><p>로컬에서는 MCP 서버와 위젯 정적 파일을 하나의 서버(포트 8000)로 함께 서빙한 뒤, ngrok 같은 터널링 서비스로 외부에 노출하고, ChatGPT의 <strong>개발자 워크스페이스</strong>에 개발 앱으로 등록하여 실제 ChatGPT 대화 안에서 E2E 테스트를 진행했습니다.</p><p>다만 위젯 UI를 수정할 때마다 ChatGPT를 거치면 반복 비용이 크기 때문에, <strong>위젯을 독립적으로 로컬 브라우저에서 바로 띄워 테스트할 수 있는 환경</strong>도 구성했습니다. test_widget/ 디렉토리에 각 위젯별 테스트 HTML(test-widget-shop-list.html, test-widget-map.html 등)을 만들어, 브라우저에서 직접 열어 위젯을 확인할 수 있도록 했습니다.</p><p>핵심은 mock-openai.js입니다. ChatGPT sandbox 안에서만 주입되는 window.openai 객체를 로컬에서 모의(mock)로 구현한 파일로, toolOutput, callTool, setWidgetState, openExternal 등 위젯이 의존하는 API를 동일한 인터페이스로 제공합니다. 특히 callTool은 로컬 MCP 서버에 실제 JSON-RPC 요청을 보내고 SSE 스트림으로 응답을 받아오도록 구현했기 때문에, ChatGPT 없이도 <strong>위젯 → MCP 서버 → 요기요 API</strong> 전체 흐름을 로컬에서 테스트할 수 있습니다. 테스트 HTML에서 updateWidgetProps()로 샘플 데이터를 주입하면 위젯이 즉시 렌더링되고, 위젯 안에서의 callTool 호출도 실제 MCP 서버를 거쳐 동작합니다.</p><p>또한 위젯 코드 자체에서도 useWidgetProps 훅에 defaultState 폴백을 두어, window.openai.toolOutput이 없는 환경에서도 빈 화면 대신 기본 UI가 렌더링되도록 처리했습니다.</p><h3>9. 모니터링: Datadog APM과 SSE</h3><p>운영에서 <strong>Datadog APM</strong>으로 트레이스와 성능을 보기로 했습니다. Node 서버에는 <strong>dd-trace</strong>를 쓰고, 호스트에 Datadog Agent가 떠 있으면 트레이스가 자동으로 수집됩니다.</p><p>여기서 두 가지를 특히 신경 썼습니다.</p><h3>Tracer 초기화 순서</h3><p>dd-trace는 <strong>다른 모듈(예: </strong>node:http<strong>)이 로드되기 전에</strong> init() 되어야, HTTP 등이 자동 계측됩니다. ESM에서는 <strong>모든 import가 먼저 평가</strong>된 뒤에 top-level 코드가 실행되므로, 같은 파일 안에서 순서를 바꿔도 소용이 없습니다.</p><p>그래서 <strong>tracer 전용 파일</strong>(tracer.ts)을 두고, <strong>진입점(</strong>server.ts<strong>)의 맨 첫 번째 import</strong>로 두어, node:http보다 먼저 tracer.init()이 실행되게 했습니다.</p><pre>// server.ts — 반드시 첫 번째 import<br>import tracer from &quot;./common/tracer.js&quot;;<br><br>import { createServer } from &quot;node:http&quot;; // 이 시점에서 이미 dd-trace가 패치 완료<br>// ...</pre><h3>SSE 트레이싱</h3><p>MCP는 SSE로 한 연결이 오래 유지되기 때문에, “요청 하나 = 스팬 하나”인 일반 HTTP와 다르게 보입니다. 그래서 <strong>커스텀 스팬</strong>을 넣었습니다.</p><ul><li><strong>mcp.sse_connection</strong>: 클라이언트가 GET /mcp 로 SSE 연결을 열 때 시작하고, 연결이 끊길 때 finish.</li><li><strong>mcp.sse_send</strong>: SSE로 JSON-RPC 메시지를 보낼 때마다 connection span의 <strong>자식 스팬</strong>으로 기록.</li></ul><p>Datadog APM에서 “GET /mcp → mcp.sse_connection → mcp.sse_send 여러 개”처럼 한 트레이스 트리로 보이게 했고, 태그(mcp.transport: sse)로 SSE만 필터링해 볼 수 있습니다.</p><h3>10. 앱 등록: 코드 완성이 끝이 아니었다</h3><p>개발이 마무리되면 바로 서비스를 올릴 수 있을 거라 생각했지만, 실제로는 <strong>앱 등록 절차</strong>라는 예상치 못한 관문이 기다리고 있었습니다.</p><p>ChatGPT Apps의 SDK 문서와 MCP 개발 가이드는 사전에 충분히 검토할 수 있었지만, 정작 앱을 OpenAI에 등록(submit)하는 과정에서 요구하는 항목들은 <strong>등록 페이지가 열리기 전까지는 전혀 알 수 없었습니다</strong>. 12월 15일, 앱 등록이 공개되자마자 제출을 시도했는데, 다음과 같은 항목들이 필요했습니다.</p><ul><li><strong>사업자 인증(Business Verification)</strong>: 법인/사업자 정보와 관련 서류 제출</li><li><strong>도메인 소유권 인증</strong>: .well-known/openai-apps-challenge 엔드포인트를 통한 MCP 서버 도메인의 소유권 검증</li><li><strong>테스트 케이스 작성</strong>: 앱의 주요 기능별 시나리오와 기대 결과를 문서화하여 제출</li><li><strong>OS별 구동 영상</strong>: iOS, Android, 데스크톱 등 각 플랫폼에서 앱이 정상 동작하는 모습을 녹화한 스크린 레코딩</li></ul><p>코드 한 줄 건드리지 않는 작업이었지만, 서류 준비부터 영상 촬영·편집까지 <strong>꼬박 하루</strong>가 소요되었습니다. 특히 도메인 인증의 경우 MCP 서버에 .well-known 경로를 서빙하는 핸들러를 급히 추가해야 했고, 테스트 케이스는 “사용자가 치킨을 검색 → 가게 상세 확인 → 요기요 앱으로 주문”과 같은 End-to-End 시나리오를 플랫폼별로 작성해야 했습니다.</p><p>등록 제출 이후에는 OpenAI의 리뷰 프로세스를 기다려야 했습니다. <strong>약 50일이 지난 뒤</strong> 앱이 <strong>Approved</strong> 상태가 되었고, 그제서야 ChatGPT 사용자들이 실제로 요기요 배달 앱을 사용할 수 있게 되었습니다. 개발 완료부터 실제 출시까지의 리드 타임이 생각보다 길었기 때문에, 앱 등록에 필요한 준비물을 개발 단계에서 미리 파악하고 병행하는 것이 중요하다는 교훈을 얻었습니다.</p><h3>11. 개발하면서 만난 것들</h3><p><strong>CSP 위반은 소리 없이 실패한다</strong></p><ul><li>가장 반복적으로 겪은 문제입니다. 이미지가 안 뜨거나 지도가 빈 화면이면, 코드 버그보다 CSP 도메인 누락을 먼저 의심해야 합니다. 브라우저 콘솔의 Refused to… 로그가 유일한 단서입니다.</li></ul><p><strong>CSP 도메인을 하드코딩하면 환경 전환이 어렵다</strong></p><ul><li>로컬, ngrok, 프로덕션에서 MCP URL이 다르므로, CSP 메타데이터에 들어가는 도메인도 동적으로 결정해야 합니다. 처음에는 하드코딩해두고 환경마다 수동으로 바꿨는데, getMcpPublicUrl()로 런타임 결정하는 방식으로 바꾸니 실수가 줄었습니다.</li></ul><p><strong>ESM의 import 평가 순서</strong></p><ul><li>server.ts 한 파일 안에서는 node:http 가 이미 로드된 다음에 tracer.init() 이 실행되어, dd-trace가 HTTP를 패치할 수 없었습니다.</li><li><strong>별도 모듈로 분리</strong>하면, 그 모듈을 로드하는 시점에 tracer.init() 이 실행되고, 그 다음에 server.ts의 node:http 가 로드되므로 패치가 적용됩니다.</li></ul><p><strong>SSE가 APM에 안 보이는 것처럼 느껴졌던 점</strong></p><ul><li>기본 HTTP 스팬만 있으면 “긴 연결 하나”로만 보이고, 내부에서 어떤 메시지를 몇 번 보냈는지 알기 어렵습니다.</li><li>커스텀 스팬 + 부모/자식 관계를 명확히 하니, 트레이스 트리와 태그로 SSE 트래픽을 분석하기 쉬워졌습니다.</li></ul><p><strong>위젯 버전과 MCP 서버</strong></p><ul><li>위젯을 S3에 새로 올렸을 때, MCP 서버가 참조하는 HTML 안의 JS/CSS 경로(해시)가 바뀌므로, <strong>MCP 서버도 같은 버전으로 빌드·배포</strong>하는 절차를 문서와 배포 스크립트에 넣었습니다.</li></ul><h3>12. 마치며</h3><p>ChatGPT Apps SDK와 MCP를 쓰면, “대화 + 구조화된 도구 호출 + 인라인 위젯”을 한 번에 설계할 수 있었습니다. 툴 스키마와 위젯 매핑을 잘 나누어 두면, 이후에 툴을 추가하거나 위젯만 바꾸는 확장도 수월했습니다.</p><p>특히 CSP는 ChatGPT sandbox 환경에서 위젯을 만들 때 피할 수 없는 주제입니다. “어떤 외부 리소스를 쓰는가”를 위젯 개발 초기부터 파악해두고, MCP 메타데이터에 빠짐없이 선언하는 것이 디버깅 시간을 크게 줄여줍니다. 조용히 실패하는 특성 때문에, CSP 관련 문제는 알고 있으면 5분, 모르면 반나절이 걸립니다.</p><p>앞으로는 더 많은 툴(예: 주문·결제 플로우), 위젯 UX 개선, 그리고 사용 지표 연동까지 이어갈 수 있을 것 같습니다.</p><p><em>이 글은 ChatGPT App(요기요 배달 연동) 프로젝트의 기술적 구현과 개발 (Vibe Code with AI)경험을 정리한 글입니다. 실제 서비스 정책이나 API 스펙은 OpenAI 문서를 참고해 주세요. 이 글의 초안 작성과 구성에는 AI가 활용되었으며, 개발자가 검토·보완하여 완성하였습니다.</em></p><h3>참고 링크</h3><ul><li><a href="https://developers.openai.com/apps-sdk">OpenAI Apps SDK 공식 문서</a></li><li><a href="https://developers.openai.com/apps-sdk/concepts/ux-principles">OpenAI UX Principles</a></li><li><a href="https://developers.openai.com/apps-sdk/concepts/ui-guidelines">OpenAI UI Guidelines</a></li><li><a href="https://github.com/openai/openai-apps-sdk-examples">OpenAI Apps SDK Examples (GitHub)</a></li><li><a href="https://modelcontextprotocol.io/">MCP(Model Context Protocol) 공식 사이트</a></li><li><a href="https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP">Content Security Policy (CSP) — MDN Web Docs</a></li></ul><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=c6636a9a11ff" width="1" height="1" alt=""><hr><p><a href="https://techblog.yogiyo.co.kr/chatgpt%EC%97%90%EC%84%9C-%EC%9A%94%EA%B8%B0%EC%9A%94-%EB%B0%B0%EB%8B%AC-%EC%93%B0%EA%B8%B0-mcp-%EC%9C%84%EC%A0%AF-%EC%97%B0%EB%8F%99-%EA%B0%9C%EB%B0%9C%EA%B8%B0-c6636a9a11ff">ChatGPT에서 요기요 배달 쓰기 — MCP + 위젯 연동 개발기</a> was originally published in <a href="https://techblog.yogiyo.co.kr">YOGIYO Tech Blog - 요기요 기술블로그</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[[디자인 시스템 어떻게 만들었어요?(3)] Tree Shaking과 구형 브라우저 대응]]></title>
            <link>https://techblog.yogiyo.co.kr/%EB%94%94%EC%9E%90%EC%9D%B8-%EC%8B%9C%EC%8A%A4%ED%85%9C-%EC%96%B4%EB%96%BB%EA%B2%8C-%EB%A7%8C%EB%93%A4%EC%97%88%EC%96%B4%EC%9A%94-3-tree-shaking%EA%B3%BC-%EA%B5%AC%ED%98%95-%EB%B8%8C%EB%9D%BC%EC%9A%B0%EC%A0%80-%EB%8C%80%EC%9D%91-d29474baf7ba?source=rss----c1b33ccbbc42---4</link>
            <guid isPermaLink="false">https://medium.com/p/d29474baf7ba</guid>
            <category><![CDATA[tech]]></category>
            <category><![CDATA[yogiyo]]></category>
            <category><![CDATA[post]]></category>
            <dc:creator><![CDATA[Sohyeon Ye]]></dc:creator>
            <pubDate>Thu, 26 Feb 2026 01:38:59 GMT</pubDate>
            <atom:updated>2026-02-26T01:38:57.843Z</atom:updated>
            <content:encoded><![CDATA[<figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*8GoSLWhTzvj6NLLPgzCk4g.png" /></figure><p>지난번 <a href="https://medium.com/deliverytechkorea/%EB%94%94%EC%9E%90%EC%9D%B8-%EC%8B%9C%EC%8A%A4%ED%85%9C-%EC%96%B4%EB%96%BB%EA%B2%8C-%EB%A7%8C%EB%93%A4%EC%97%88%EC%96%B4%EC%9A%94-2-radix-primitives%EC%99%80-panda-css%EB%A1%9C-%EC%9C%A0%EC%97%B0%ED%95%98%EA%B3%A0-%EB%8B%A8%EB%8B%A8%ED%95%9C-%EC%BB%B4%ED%8F%AC%EB%84%8C%ED%8A%B8-%EB%A7%8C%EB%93%A4%EA%B8%B0-7ead98362e17">YDS(Yogiyo Design System) v2 컴포넌트 라이브러리 구축기</a>에 이어, 이번 글에서는 YDS v2 컴포넌트 라이브러리와 같은 외부 라이브러리가 서비스 애플리케이션 번들 결과물에 미치는 영향을 살펴봅니다. 또한, 구형 브라우저에 대한 하위 호환성 확보를 위해 요기요 FE 팀이 거친 기술적인 고민과 그 해결 과정들을 공유하고자 합니다.</p><h3>들어가며</h3><p>현재 요기요 FE팀은 주요 FE 프로젝트의 기반 프레임워크로 Next.js를 채택하여 사용하고 있습니다. Next.js는 React 기반의 프레임워크로서 웹 개발에 필요한 도구들을 기본적으로 제공합니다. 이러한 편의성 덕분에 개발자는 인프라 설정보다 비즈니스 로직 구현에 더 집중할 수 있는 환경을 갖추게 됩니다. 반면, 라이브러리를 개발하는 과정에서는 다음과 같은 오해에 빠지기 쉽습니다.</p><p><strong>오해 1. Next.js가 알아서 Tree-shaking 해줄 것이다.</strong></p><blockquote>Next.js automatically optimizes bundles by code splitting, tree-shaking, and other techniques. However, there are some cases where you may need to optimize your bundles manually.</blockquote><p>Next.js는 Code Splitting과 Tree Shaking을 통해 번들 사이즈를 최적화합니다. 이런 특징 때문에 라이브러리 개발자는 사용하지 않는 코드가 빌드 과정에서 알아서 제거될 것이라고 기대하곤 합니다. 하지만 라이브러리가 Tree shaking이 가능한 요건들을 충족하지 못했다면, 프레임워크의 최적화 기능은 무용지물이 됩니다. 라이브러리가 어떤 모듈 시스템(ESM or CJS)으로 배포되는지, Side-Effect를 어떻게 관리하는지, 하나의 파일로 번들링되어 있는지 혹은 모듈 트리구조를 유지하고 있는지에 따라 Next.js는 불필요한 코드를 제거하지 못하고, 결국 사용되지 않는 코드까지 번들에 포함하는 상황이 발생할 수 있습니다.</p><p><strong>오해 2. Next.js를 쓰면 구형 브라우저 호환성 문제에서 자유롭다.</strong></p><p><a href="https://nextjs.org/docs/15/architecture/supported-browsers#polyfills">Next.js 공식문서</a>에 따르면 fetch, URL, Object.assign() 등 자주 사용되는 폴리필이 기본으로 제공됩니다. <a href="https://github.com/vercel/next.js/tree/canary/packages/next-polyfill-nomodule">next-polyfill-nomodule</a>을 통해 <a href="https://github.com/vercel/next.js/blob/canary/packages/next-polyfill-nomodule/src/index.js">다양한 polyfill</a>을 주입하지만, 이는 &lt;script nomodule&gt; 특성상 Chrome 64 미만의 구형 브라우저에서만 로드됩니다. 또한, ESM을 지원하는 브라우저용인 <a href="https://github.com/vercel/next.js/blob/canary/packages/next-polyfill-module/src/index.js">next-polyfill-module</a>은 제한적인 기능만 지원하고 있습니다. 이 때문에 Promise.allSettled()처럼 Chrome 76부터 지원되는 메서드를 사용할 경우, 64~76 버전 사이의 Chrome 브라우저에서는 polyfill을 지원 받지 못하는 사각지대가 발생합니다. 따라서 프로젝트에서 Next.js 기본 제공 범위를 초과하는 API를 사용하고 있다면, 호환성 보장을 위해 개발자가 직접 누락된 폴리필을 확인하고 대응해야 합니다.</p><p>요기요 FE에서는 이러한 문제를 해결하기 위해 라이브러리 번들링 전략 개선과 Custom Polyfill 서비스를 구축하였습니다. 자세한 내용은 아래에서 이어서 설명하겠습니다.</p><h3>Tree Shaking을 위한 여정</h3><h4>ESM</h4><p><a href="https://developer.mozilla.org/en-US/docs/Glossary/Tree_shaking">Tree shaking</a>은 빌드 과정에서 사용되지 않는 코드를 제거하여 최종 번들 크기를 최적화하는 기법입니다. 정적인 import와 export 구문을 분석해 모듈 간의 참조 관계를 파악하고, 실제 실행에 필요 없는 코드를 선별해 삭제합니다. 결국, 코드의 정적 분석이 가능한 ESM을 채택해야만 완전한 Tree Shaking을 구현할 수 있습니다. 따라서 서비스 애플리케이션의 번들 크기를 최적화해 주기 위해서는 라이브러리 차원에서의 ESM 지원이 필수적입니다.</p><h4>Side-Effect</h4><p>라이브러리를 ESM으로 배포했다고 해서, Tree Shaking이 자동으로 완벽하게 작동하는 것은 아닙니다. Webpack이나 Rollup 같은 번들러는 애플리케이션의 안전한 동작을 보장하기 위해서 기본적으로 라이브러리 내 모든 모듈이 Side-Effect가 있다고 판단합니다. 결과적으로 모듈 간의 정적 참조 관계가 확인되더라도, 번들러는 보수적인 관점에서 사용되지 않는 코드까지 최종 번들에 남겨두게 됩니다.</p><p>번들러는 코드의 Side-Effect 여부를 예측할 수 없기에, 개발자가 명시적인 정보를 제공하여 최적화를 유도해야 합니다. 가장 대표적인 방법은 package.json 파일에 sideEffects 필드를 설정하는 것입니다.</p><p>모든 파일에 Side-Effect가 없다면 아래와 같이 설정합니다. 이 경우 번들러는 사용되지 않는 모든 파일을 안전하게 제거합니다.</p><pre>// package.json<br>{<br>  &quot;sideEffects&quot;: false<br>}</pre><p>만약 전역 스타일(CSS)이나 초기화 스크립트처럼 참조 관계와 상관없이 유지해야 할 코드가 있다면, 배열 형태로 해당 경로를 명시하여 보호할 수 있습니다. 결과적으로 필요한 코드는 보존하면서 불필요한 코드만 제거해 최적화할 수 있습니다.</p><pre>// package.json<br>{<br>  &quot;sideEffects&quot;: [&quot;*.css&quot;, &quot;./src/global.js&quot;]<br>}</pre><h4>모듈 경계 유지하기</h4><p>라이브러리의 배포 형식으로 ESM을 채택하고 package.json에 sideEffects를 명시하는 것은 Tree Shaking을 위한 기초적인 요건입니다. 이는 서비스 번들러에게 최적화 가능성을 알리는 힌트일 뿐, 실제 효율은 라이브러리 배포 시 모듈 경계가 얼마나 엄격히 유지되고 있는지에 따라 결정됩니다. 특히 모든 소스 코드를 단일 파일로 병합하여 배포하는 방식은 서비스 번들러의 정적 분석 과정에서 불확실성을 유발하여 최종 번들 최적화의 큰 장애물이 됩니다.</p><p>라이브러리가 단일 파일로 배포되더라도, 서비스 번들러는 정적 분석을 통해 사용되지 않는 코드를 제거하는 특정 수준의 Tree Shaking을 수행합니다. 대표적으로 Named Export를 통한 함수 단위의 제거와 객체 리터럴의 최상위 속성에 대한 선별적 포함이 이에 해당합니다. 하지만 라이브러리 파일 최상위에 실행 코드가 존재하거나 즉시 실행 함수(IIFE)를 통한 초기화 로직이 포함된 경우 서비스 단계의 최적화는 실패합니다. 또한, 클래스의 static 블록이나 복잡한 모듈 간 의존성 구조 역시 서비스 번들러의 정적 분석에 모호함을 야기하며, 결과적으로 미사용 라이브러리 코드가 서비스 최종 번들에 포함되는 결과를 초래합니다.</p><p>이러한 구조적 한계는 Barrel File과 결합될 때 더욱 극대화됩니다. Barrel File은 index.ts와 같은 파일을 통해 여러 모듈을 한곳에 모아 re-export 함으로써 서비스 애플리케이션에서 라이브러리의 내부 구조를 몰라도 모듈에 쉽게 접근할 수 있게 돕는 유용한 패턴입니다. 그러나 라이브러리가 이미 단일 파일로 번들링된 상태라면, Barrel File은 서비스 애플리케이션의 번들러가 모든 모듈의 의존성 체인을 추적하게 만드는 거대한 진입점이 됩니다. 이 과정에서 특정 모듈 하나만 import 하더라도 번들러는 Barrel File에 엮인 모든 모듈의 Side-Effect 여부를 검증해야 하며, 만약 분석 과정에서 단 하나의 모듈이라도 정적 분석의 모호함이나 잠재적 부작용을 내포하고 있다면 서비스 번들러는 미사용 모듈까지 최종 번들에 포함하는 보수적인 선택을 하게 됩니다.</p><pre>// library/index.ts<br><br>export { default as module1 } from &#39;./module1&#39;;<br>export { default as module2 } from &#39;./module2&#39;;<br>export { default as module3 } from &#39;./module3&#39;;<br><br>// app/page.tsx<br><br>import { module2 } from &#39;library&#39;</pre><p>특히 라이브러리 빌드 시 외부 의존성을 external로 분리하지 않고 라이브러리의 단일 번들 결과물 내부에 직접 병합하는 구조는 서비스 애플리케이션의 Tree Shaking 실효성을 저하시킵니다. 서드파티 코드가 라이브러리 소스 코드와 하나의 파일로 물리적으로 결합되면 모듈 간의 경계가 불투명해지며, 서비스 번들러는 라이브러리 내의 어떤 컴포넌트가 어떤 서드파티 기능을 사용하는지 명확히 파악할 수 없는 상태에 놓이게 됩니다. 이 과정에서 서비스 번들러는 결국 안전을 위해 사용되지 않는 서드파티 구현체까지 모두 서비스 최종 번들에 포함하는 보수적인 선택을 내리게 됩니다.</p><pre>// vite.config.ts<br><br>import pkg from &#39;./package.json&#39;;<br><br>export default defineConfig({<br>  build: {<br>    // 생략...<br>    rollupOptions: {<br>      external: [<br>        ...Object.keys(pkg.dependencies),<br>        ...Object.keys(pkg.peerDependencies),<br>        /^@yogiyo\/yds2-icons($|\/)/,<br>        /^@yogiyo\/yds2-styled-system\/.*/,<br>        &#39;react/jsx-runtime&#39;,<br>      ],<br>    },<br>  },<br>});</pre><p>이러한 한계를 극복하기 위한 근본적인 해결책은 라이브러리 빌드 시 모듈 트리를 온전히 유지하는 것입니다. 모듈이 파일 단위로 명확히 분리되어 배포된다면 서비스 번들러는 사용되지 않는 파일 자체를 빌드 대상에서 제외하는 방식으로 최적화를 수행할 수 있습니다. 이를 위해 Rollup의 preserveModules: true 설정을 활용하면 원본의 모듈 구조를 최대한 유지하며 모든 소스 파일을 개별 단위의 결과물로 생성할 수 있습니다.</p><pre>// vite.config.ts<br><br>export default defineConfig({<br>  build: {<br>    // 생략...<br>    rollupOptions: {<br>      output: {<br>        preserveModules: true,<br>        preserveModulesRoot: &#39;src&#39;,<br>      },<br>    },<br>  },<br>});</pre><p>또한 개발자가 공개할 주요 진입점들을 직접 지정하는 Multi-entry 방식을 활용하는 것도 훌륭한 대안입니다. 이 방식은 필요한 기능들을 명시적으로 분리하여 빌드함으로써 라이브러리 사용자가 특정 모듈에만 접근하도록 제어하면서도 물리적인 파일 격리를 달성할 수 있게 해줍니다.</p><pre>// vite.config.ts<br><br>import { globbySync } from &#39;globby&#39;;<br><br>export default defineConfig({<br>  build: {<br>    lib: {<br>      entry: globbySync([&#39;src/**/index.ts&#39;]),<br>    },<br>    // 생략...<br>  },<br>});</pre><p>결국 preserveModules를 통해 구조를 자동 복제하거나 Multi-entry로 진입점을 관리하는 방식 모두 package.json에서 Subpath Exports를 설정할 수 있는 물리적 기반이 됩니다. 서비스 애플리케이션에서 이를 활용해 필요한 모듈만 Direct Import 하면 개발 환경에서 미사용 모듈까지 불필요하게 분석하던 Barrel Import의 고질적인 문제인 Startup 및 Rebuild 속도 저하를 방지할 수 있습니다.</p><p>만약 서비스 애플리케이션에서 Next.js를 사용한다면, Barrel Import 방식을 유지하면서도 Direct Import의 효과를 낼 수 있는 실험적 기능인 <a href="https://nextjs.org/docs/app/api-reference/config/next-config-js/optimizePackageImports">optimizePackageImports</a>를 활용할 수 있습니다. 이 옵션은 라이브러리가 모듈 트리 구조를 유지하고 있을 때, 개발자가 작성한 Barrel Import 구문을 분석하여, 실제 모듈 경로로 매핑해줍니다.</p><p>결론적으로 Tree Shaking의 실효성은 단순한 설정이 아닌 모듈 간의 물리적 독립성에서 기인합니다. sideEffects 설정, external을 통한 외부 의존성 분리, 모듈 구조 유지는 정밀한 Tree Shaking을 보장하고 쾌적한 개발 경험을 제공하기 위한 핵심 설계 전략입니다.</p><h4>번들 결과 확인</h4><p>지금까지 논의한 Tree Shaking 전략들이 실제 서비스 환경에서 어느 정도의 실효성을 갖는지 Next.js 프로젝트를 통해 단계별로 측정해 보았습니다. 실험은 Next.js 기본 세팅 상태에서 yds2-react를 설치한 뒤, 오직 SingleBadge 컴포넌트 하나만을 사용하여 빌드했을 때 메인 페이지 First Load JS 크기와 Next Bundle Analyzer가 측정한 yds2-react Total Size를 비교했습니다.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*etEXkjWiZ3huE3cOX1RSRg.png" /></figure><p>위 실험 결과가 시사하듯, 라이브러리의 설계와 서비스측 최적화가 맞물리며 1.58 MB에 달하던 점유 크기를 29 kB까지 줄여, 사용자에게 필요한 코드만 전달하는 가벼운 프로덕트를 완성할 수 있습니다.</p><h3>Next.js 환경에서 구형 브라우저 대응하기</h3><h4>Compile vs Transpile vs Polyfill</h4><p>서비스 애플리케이션에서 하위 호환성을 지원하기 위해서는 아래 개념들을 구분해야 합니다.</p><ul><li><strong>Compile</strong> : 한 프로그래밍 언어로 작성된 소스 코드를 다른 타겟 언어로 변환하는 과정을 의미합니다. 흔히 C언어와 같은 고수준 언어를 컴퓨터가 이해할 수 있는 기계어로 번역하는 작업을 떠올리지만, Java 소스 코드를 가상 머신이 실행할 수 있는 Bytecode로 변환하는 것 역시 Compile의 대표적인 사례입니다. 뒤에 언급할 Typescript를 Javascript로 변환하는 과정 또한 엄밀히 따지면 Transpile에 해당하지만, 큰 틀에서는 한 언어를 다른 언어로 재구성한다는 점에서 Compile의 범주 안에 포함된다고 할 수 있습니다.</li><li><strong>Transpile</strong> : 한 언어로 작성된 소스 코드를 비슷한 수준의 추상화를 가진 다른 언어로 변환하는 과정을 의미합니다. 대표적인 사례로는 Typescript 코드를 Javascript로 변환하는 과정이 있으며, 최신 문법으로 작성된 Javascript 코드를 구형 브라우저와의 호환성을 위해 구 문법으로 변환하는 작업 역시 이 범주에 속합니다. 즉, 개발자는 Transpile을 통해 생산성 높은 최신 언어를 사용하면서도 실행 환경의 제약을 동시에 해결할 수 있습니다.</li><li><strong>Polyfill : </strong>특정 실행 환경에서 제공되지 않는 표준 기능을 사용할 수 있도록, 해당 기능의 동작을 직접 구현해 제공하는 보조 스크립트를 의미합니다. ECMAScript 표준에 정의된 객체나 메서드(Promise, Array.prototype.at 등), 그리고 브라우저가 제공하는 Web API(fetch, IntersectionObserver등)를 대상으로 합니다. 트랜스파일이 문법 수준의 호환성을 해결하는 데 초점을 둔다면, 폴리필은 런타임에 실제로 존재하지 않는 기능을 보완함으로써 실행 환경 간의 기능 차이를 해결합니다. 이를 통해 개발자는 최신 표준을 기준으로 코드를 작성하면서도, 구형 브라우저나 제한된 환경에서도 동일한 동작을 보장할 수 있습니다.</li></ul><h4>하위 호환성의 책임 주체</h4><p>라이브러리를 최적화하여 번들 크기를 줄이는 것만큼 중요한 것은, 그 코드가 유저의 다양한 실행 환경에서 문제없이 동작하도록 보장하는 것입니다. 이를 위해 라이브러리 개발자는 하위 호환성 확보의 책임을 라이브러리 자체에서 질 것인지, 아니면 서비스 애플리케이션에 맡길 것인지 선택해야합니다.</p><p>만약 라이브러리가 사용될 서비스들의 브라우저 지원 범위가 동일하고, 서비스 측면에서 별도의 Polyfill 설정을 관리해야하는 포인트를 줄여주고 싶다면, <a href="https://github.com/babel/babel-polyfills/tree/main/packages/babel-plugin-polyfill-corejs3">babel-plugin-polyfill-corejs3</a>과 같은 플러그인의 usage-pure 모드를 활용해서 Polyfill을 라이브러리 코드 내에 직접 포함해 배포할 수 있습니다. 이 방식은 전역 환경을 오염시키지 않으면서도 라이브러리 자체적으로 하위 호환성을 완결 지을 수 있어 서비스 개발자가 추가적인 설정에 신경 쓰지 않아도 된다는 장점이 있습니다.</p><p>하지만 이러한 접근은 서비스마다 타겟 브라우저 범위가 제각각이라는 점을 고려할 때 한계가 명확합니다. 또한, 라이브러리가 폴리필을 내장하면 최신 브라우저를 사용하는 서비스의 유저에게도 불필요한 코드 로딩 비용을 강제하게 되며, 이는 결국 서비스별 최적화 유연성을 저해하는 결과로 이어집니다. 따라서 개발 효율을 위한 관리 편의성을 우선할 것인지, 혹은 각 서비스 애플리케이션이 자신의 지원 환경에 맞춰 최적화할 수 있도록 자율성을 부여할 것인지에 대한 전략적 판단이 필요합니다.</p><p>이러한 이유로 요기요 FE팀은 하위 호환성의 책임 주체를 라이브러리가 아닌 서비스 애플리케이션에서 가져가기로 결정했습니다. 구체적으로 어떤 기술적 방법으로 이러한 전략을 실현했는지 아래에서 자세히 살펴보겠습니다.</p><h4>Next.js의 Browser Support</h4><p>Next.js는 별도의 설정없이도 Modern Browser를 지원하며, Next.js v15 기준으로 지원하는 브라우저 사양은 다음과 같습니다.</p><ul><li>Chrome 64+</li><li>Edge 79+</li><li>Firefox 67+</li><li>Opera 51+</li><li>Safari 12+</li></ul><p>만약 서비스의 비즈니스 타겟에 따라 특정 브라우저를 지원해야 한다면, package.json에 browserslist 설정을 추가하면 됩니다. 별도의 설정을 하지 않을 경우 Next.js는 내부적으로 아래 지원 범위를 기본값으로 빌드를 진행합니다. 이 설정은 Next.js가 코드를 얼마나 낮은 버전으로 Transpile 할지 결정하는 기준이 됩니다. 다만, 여기서 유의할 점은 browserslist와 위에서 살펴보았던 <a href="https://github.com/vercel/next.js/blob/canary/packages/next-polyfill-module/src/index.js">next-polyfill-module</a>이 모든 하위 호환성 문제를 해결해주지 않는다는 것입니다.</p><pre>// package.json<br><br>{<br>  &quot;browserslist&quot;: [<br>    &quot;chrome 64&quot;,<br>    &quot;edge 79&quot;,<br>    &quot;firefox 67&quot;,<br>    &quot;opera 51&quot;,<br>    &quot;safari 12&quot;<br>  ]<br>}</pre><h4><strong>외부 라이브러리까지 꼼꼼하게 트랜스파일하기</strong></h4><p>Next.js는 프로젝트 소스 코드를 browserslist 설정에 맞춰 Transpile 합니다. 하지만 node_modules에 포함된 외부 라이브러리들은 기본적으로 Transpile 대상에서 제외된 채 빌드됩니다. 이 때문에 서비스의 타겟 브라우저 사양이 라이브러리의 지원 버전보다 낮을 경우, 구형 브라우저에서 예기치 못한 런타임 에러가 발생하게 됩니다.</p><p>이때 활용할 수 있는 옵션이 Next.js의 <a href="https://nextjs.org/docs/app/api-reference/config/next-config-js/transpilePackages">transpilePackages</a>입니다. 해당 옵션에 특정 패키지 명을 명시하면, Next.js는 빌드 타임에 해당 라이브러리까지 browserslist 기준에 맞춰 다시 Transpile을 수행합니다. 이를 통해 라이브러리 자체의 지원 범위에 구애받지 않고, 서비스 프로젝트가 목표로 하는 환경에 최적화된 최종 번들을 생성할 수 있습니다.</p><h4>Polyfill 사각지대 메우기</h4><p>transpilePackages 옵션을 통해 문법적인 호환성 문제를 해결했더라도, 런타임에 존재하지 않는 API를 메꿔주는 Polyfill 문제는 여전히 남아있습니다. Next.js는 <a href="https://github.com/vercel/next.js/blob/canary/packages/next-polyfill-module/src/index.js">next-polyfill-module</a>를 통해 일부 Polyfill을 자동으로 주입하지만, 모든 ECMAScript와 브라우저 Web API를 커버하지는 못합니다. 이러한 사각지대를 해결하기 위해, 서비스 애플리케이션은 프로젝트의 상황에 맞는 Polyfill 전략을 선택해야합니다.</p><p><strong>Case 1. 필요한 Polyfill 리스트를 명확히 아는 경우</strong></p><p>프로젝트 소스 코드와 외부 라이브러리에서 사용하는 API 스펙을 이미 파악하고 있다면, 필요한 폴리필을 직접 로드하는 방식이 효율적입니다. Next.js App Router 환경에서는 <a href="https://nextjs.org/docs/app/api-reference/file-conventions/instrumentation-client">instrumentation-client.js</a> 파일을 활용하는 것이 권장됩니다.</p><pre>import &#39;./lib/polyfills&#39;<br> <br>if (!window.ResizeObserver) {<br>  import(&#39;./lib/polyfills/resize-observer&#39;).then((mod) =&gt; {<br>    window.ResizeObserver = mod.default<br>  })<br>}</pre><p><strong>Case 2. 필요한 Polyfill 리스트를 일일이 파악하기 어려운 경우</strong></p><p>대규모 프로젝트이거나 의존성 구조가 복잡하여 모든 Polyfill 리스트를 수동으로 관리하기 어려운 상황이라면 다른 대응 전략을 수립해야 합니다. 이때는 프로젝트의 빌드 환경과 유저의 실행 환경 중 어디에 최적화의 방점을 찍을지에 따라 크게 두 가지 전략으로 나뉩니다.</p><p><strong>전략 A. Babel의 useBuiltIns 설정을 통한 주입</strong></p><p>서비스 애플리케이션의 .babelrc 설정에서 useBuiltIns: “usage” 옵션을 활성화하면, 프로젝트 내 소스 코드와 외부 라이브러리를 정적으로 분석하여 사용된 API에 대응하는 Polyfill을 알아서 채워 넣습니다.</p><pre>// .babelrc<br><br>{<br>  &quot;presets&quot;: [<br>    [<br>      &quot;next/babel&quot;,<br>      {<br>        &quot;preset-env&quot;: {<br>          &quot;useBuiltIns&quot;: &quot;usage&quot;,<br>          &quot;corejs&quot;: &quot;3.46&quot;<br>        }<br>      }<br>    ]<br>  ]<br>}</pre><p>하지만 이러한 편의성 이면에는 한계점이 존재합니다. Next.js는 v12부터 Rust 기반의 고성능 컴파일러인 SWC를 기본으로 사용하지만, Polyfill 자동 주입을 위해 Custom .babelrc를 설정하는 순간 Next.js는 SWC 대신 Babel로 Compile 방식을 전환하게 됩니다. 이는 빌드 속도를 저하시키고, 해당 Polyfill을 Native로 지원하는 최신 브라우저 유저들조차 불필요한 Polyfill 코드가 담긴 번들을 내려받아야 하는 성능상의 오버헤드가 발생합니다.</p><p><strong>전략 B. User-Agent 기반의 동적 Polyfill 서비스 사용하기</strong></p><p>Babel 설정의 한계를 극복하기 위해 검토할 수 있는 대안은 유저 브라우저의 User-Agent를 분석해 동적으로 Polyfill 스크립트를 생성하는 방식입니다. 예를 들어, 최신 버전의 Chrome 환경에서는 빈 스크립트가 내려가고, 구 버전에서는 그 버전에서 필요한 Polyfill 스크립트가 내려가게 됩니다.</p><p>외부 솔루션으로는 <a href="https://cdnjs.cloudflare.com/polyfill">cdnjs.cloudflare.com</a>이나 <a href="https://polyfill-fastly.io/">polyfill-fastly.io</a>처럼 동적 Polyfill 스크립트를 제공하는 서비스들이 존재하며, 아래와 같이 스크립트 태그 삽입 만으로 간단히 구현할 수 있다는 장점이 있습니다.</p><pre>&lt;script src=&quot;https://cdnjs.cloudflare.com/polyfill/v3/polyfill.min.js?version=4.8.0&quot;&gt;&lt;/script&gt;</pre><p>이러한 외부 서비스들은 별도의 인프라 구축 없이 바로 사용할 수 있다는 장점이 있지만, 요기요의 환경에서는 한계가 있었습니다. 요기요는 Native 앱에서 User-Agent를 커스텀하여 사용하고 있는데, 외부 서비스의 표준 파싱 로직이 이 특수한 UA를 제대로 해석하지 못하는 문제가 발생했기 때문입니다. 이 경우 외부 서비스는 브라우저 식별에 실패하여 보수적인 Polyfill 리스트를 응답하게 되고, 결과적으로 최신 환경의 유저들도 불필요하게 무거운 스크립트를 전송받는 비효율이 발생했습니다.</p><p>이 문제를 해결하기 위해 요기요 FE 팀은 Custom Polyfill 서비스를 개발하였습니다. ua-parser-js와 core-js-compat으로 유저 환경에 꼭 필요한 폴리필 목록을 추출한 뒤, 이를 esbuild로 번들링하여 응답하는 구조입니다. 이를 통해 요기요 서비스 환경에 최적화된 호환성 레이어를 제공할 예정입니다.</p><h3>마치며</h3><p>지금까지 YDS v2 라이브러리를 구축하며 직면했던 번들 최적화와 하위 호환성 대응에 대한 고민과 해결 과정을 살펴보았습니다. 라이브러리가 소비되는 서비스 애플리케이션의 성능과 안정성에 어떤 영향을 미칠지 깊게 고민해야 하는 과정이었습니다. 디자인 시스템은 한 번의 구축으로 끝나는 프로젝트가 아니라, 서비스와 함께 호흡하며 계속해서 발전해 나가는 생태계와 같습니다. 앞으로도 요기요 FE 팀은 사용자에게는 더 빠른 경험을, 개발자에게는 더 쾌적한 환경을 제공하기 위한 여정을 지속해 나가겠습니다.</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=d29474baf7ba" width="1" height="1" alt=""><hr><p><a href="https://techblog.yogiyo.co.kr/%EB%94%94%EC%9E%90%EC%9D%B8-%EC%8B%9C%EC%8A%A4%ED%85%9C-%EC%96%B4%EB%96%BB%EA%B2%8C-%EB%A7%8C%EB%93%A4%EC%97%88%EC%96%B4%EC%9A%94-3-tree-shaking%EA%B3%BC-%EA%B5%AC%ED%98%95-%EB%B8%8C%EB%9D%BC%EC%9A%B0%EC%A0%80-%EB%8C%80%EC%9D%91-d29474baf7ba">[디자인 시스템 어떻게 만들었어요?(3)] Tree Shaking과 구형 브라우저 대응</a> was originally published in <a href="https://techblog.yogiyo.co.kr">YOGIYO Tech Blog - 요기요 기술블로그</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[[요기요 카오스 엔지니어링 (2)] 카오스 실험 결과 정리하기]]></title>
            <link>https://techblog.yogiyo.co.kr/%EC%9A%94%EA%B8%B0%EC%9A%94-%EC%B9%B4%EC%98%A4%EC%8A%A4-%EC%97%94%EC%A7%80%EB%8B%88%EC%96%B4%EB%A7%81-2-%EC%B9%B4%EC%98%A4%EC%8A%A4-%EC%8B%A4%ED%97%98-%EA%B2%B0%EA%B3%BC-%EA%B3%B5%EC%9C%A0-7b09c0b2183b?source=rss----c1b33ccbbc42---4</link>
            <guid isPermaLink="false">https://medium.com/p/7b09c0b2183b</guid>
            <category><![CDATA[yogiyo]]></category>
            <category><![CDATA[post]]></category>
            <category><![CDATA[tech]]></category>
            <category><![CDATA[chaos-engineering]]></category>
            <dc:creator><![CDATA[Howon Yoon]]></dc:creator>
            <pubDate>Thu, 12 Feb 2026 04:42:47 GMT</pubDate>
            <atom:updated>2026-02-12T04:42:45.774Z</atom:updated>
            <content:encoded><![CDATA[<p>지난 (1)편에서는 카오스 엔지니어링을 시작하며 겪었던 이슈와 진행 과정을 소개했습니다. 이번 글에서는 카오스 실험을 통해 얻은 인사이트를 정리해보겠습니다.</p><p>작성에 앞서 실험 환경에 대해 소개하겠습니다.<br> 24시간 365일 서비스를 운영해야 하는 특성상, 운영 환경에 직접 장애를 주입하는 것은 리스크가 크다고 판단하여 <strong>Stage 환경에서 실험을 진행했습니다.</strong><br> 또한 운영 환경과 최대한 유사한 트래픽 패턴을 재현하기 위해 Locust를 사용해 <strong>일정한 시나리오 기반 트래픽을 실험 기간 동안 지속적으로 발생시키고</strong>, 그 상태에서 장애를 주입하는 방식으로 테스트를 수행했습니다.</p><h3>Pod Network Latency 주입 시나리오</h3><p>최초 계획은 1차 실험에서 500ms, 2차 실험에서 1000ms의 Pod 네트워크 지연(Latency)을 주입하는 것이었습니다. 그러나 1차 실험 결과를 바탕으로 2차 실험 목표를 <strong>250ms로 조정</strong>했습니다.</p><p>실험 결과는 <strong>인프라 지표</strong>와 <strong>클라이언트(Locust 기반) 지표</strong>를 각각 정리한 뒤, 서비스 영향도를 <strong>Happy Path Test</strong>로 확인하는 방식으로 검증했습니다.</p><p>사전 단계에서 “기본이 되는 요청”이 지속적으로 발생하는 상황에서 Pod Network Latency를 주입해야 의미 있는 결과를 얻을 수 있었습니다. 이 부분은 QA 팀의 지원을 받아 테스트 시작 전부터 트래픽을 주입했고, 그 결과 Membershipyo 서비스에 <strong>약 350 RPS</strong> 수준의 요청을 생성할 수 있었습니다.</p><h4>A. Pod Latency 실험 (500 ms)</h4><ul><li>수행시간 : 18:40 ~ 18:50</li></ul><blockquote><strong>1. 인프라 모니터링</strong></blockquote><ul><li>Pod CPU 사용량</li></ul><p>실험 전에는 Pod CPU 사용량이 <strong>0.1 ~ 0.2 core</strong> 수준이었으나, 장애 주입 중에는 <strong>0.01 ~ 0.03 core</strong>로 크게 감소했습니다. 이는 Pod가 정상적인 처리(컴퓨팅)를 거의 수행하지 못한 상태로 해석할 수 있습니다.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*n8vBmbpPL1dT7MIA9qXc3Q.png" /></figure><ul><li>요청 RPS (Request Per Second)</li></ul><p>실험 전 Membershipyo 서비스의 RPS는 약 <strong>350</strong>이었으나, 장애 주입 후에는 거의 <strong>0에 수렴</strong>하며 요청이 들어오지 못하는 것으로 보였습니다. 단순히 Latency가 증가한 것만으로 이 정도까지 처리량이 떨어지는 것은 비정상적으로 보여, 추가 원인이 있을 것으로 예상했습니다.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*ywvEmGZQpNYBYyhHiKAWEg.png" /></figure><ul><li>요청 Duration 시간 (P99)</li></ul><p>평소 <strong>25ms 이하</strong>로 처리되던 요청이 장애 주입 후 <strong>최대 30초</strong>까지 증가했습니다. 이 수준에서는 사실상 정상적인 서비스 제공이 어렵다고 판단할 수 있습니다.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*tmTiIEu2FXmcrehBNQRJaQ.png" /></figure><blockquote><strong>2. Client Side 모니터링</strong></blockquote><p>(참고) Locust 환경 설정</p><p><strong>vuser</strong> : 40 , <strong>Base RPS</strong> : 350</p><p>Locust는 기본적으로 응답 속도에 따라 RPS를 조정합니다.<br> “40 / 응답속도(초) ≈ 350 RPS”이므로 평균 응답속도는 약 <strong>0.11초</strong> 수준으로 추정할 수 있습니다.</p><ul><li><strong>Locust 메트릭</strong></li></ul><p>500ms Pod Latency 주입 시, RPS가 크게 요동치며 대부분 요청이 Failure로 처리되는 것을 확인했습니다. 특히 <strong>RPS와 Failure 지표가 함께 크게 출렁이는 현상</strong>이 특징적이었습니다.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*GVfeDl65a_u1rJR7hU6VzA.png" /></figure><p>Locust는 응답을 받으면 해당 응답 시간을 기준으로 RPS를 조정합니다. 그런데 RPS가 이 정도로 요동친다는 점은 일반적인 “지연 증가”만으로는 설명이 어려워, 뒤에서 추가 분석을 통해 원인을 더 살펴보겠습니다.</p><blockquote><strong>3. App 모니터링</strong></blockquote><ul><li>App Log 확인</li></ul><p>Datadog에서 확인되는 애플리케이션 로그 수는 많지 않았지만, 확인된 로그는 <strong>HTTP 500 응답 에러가 Uncaught Error로 처리</strong>되는 형태였습니다.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*zmgDjg8WRZqcNZgW0J6RJw.png" /></figure><ul><li>Trace Log 확인</li></ul><p>Trace를 따라가며 Redisson 관련 에러를 확인했습니다.</p><pre>Unable to write command into connection!<br>Check CPU usage of the JVM.<br>Try to increase nettyThreads setting.</pre><p>메시지 내용으로 보아 애플리케이션에서 Redis 연결 과정에서 사용할 <strong>Netty 스레드/커넥션 리소스가 소진</strong>된 상태로 해석할 수 있었습니다.</p><p>500ms Pod Latency를 주입했을 때, Pod가 Redis에 연결하는 단계에서 스레드가 대기(Queue)를 점유하면서, <strong>새로운 요청이 사용할 스레드가 부족해지는 현상</strong>이 발생했습니다. 이로 인해 Pod 상태가 악화되고, 높은 Failure로 이어진 것으로 판단했습니다.</p><blockquote><strong>4. 사용자 경험</strong></blockquote><ul><li><strong>비 구독자 주문</strong></li></ul><p>비구독자는 기능 반응이 지연되는 것을 체감했으나, 기능 자체는 정상 동작하는 것을 확인했습니다.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*bWv5eIEaHESCGbFKDFNViQ.png" /></figure><ul><li><strong>구독자 주문</strong></li></ul><p>구독자는 체크아웃 페이지로 진입이 불가능해 실제로 앱에서 주문이 불가능한 경험을 하게 됐습니다.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*iBgLs1rlUExTYYiwgltuXA.png" /></figure><p>이는 주문 단계에서 “멤버십 여부 확인”이 실패하면 더 이상 진행이 불가능한 구조로 보였고, 개선이 필요하다고 판단하여 유관 부서에 내용을 공유했습니다.</p><blockquote><strong>5. 추가 분석 (istio)</strong></blockquote><p>500ms Latency 주입 시 <strong>클라이언트 측 RPS가 요동친 현상</strong>과, 동시에 <strong>App 로그가 적게 남은 이유</strong>를 확인하기 위해 추가 분석을 진행했습니다.</p><ul><li><strong>트래픽 Flow</strong></li></ul><p>트래픽 흐름을 살펴보면 다음과 같습니다.</p><p>Locust → ALB → Istio IngressGateway → 서비스 Pod</p><ul><li><strong>Istio ingress gateway log 확인</strong> (<em>UH Response_flag를 가진 Log</em>)</li></ul><p>UH 플래그는 Unhealthy Upstream Host를 의미하며, 전달할 “건강한 업스트림 Pod가 없다”는 뜻입니다. 즉, Redis 연결 문제가 발생한 Pod가 정상적인 상태를 유지하지 못하면서, Istio Gateway가 해당 Pod를 라우팅 대상에서 제외했음을 의미합니다.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*fCfTcMUpMLIXexfrD-FAQQ.png" /><figcaption>실험 시간 동안 대량의 UH flag를 지닌 Packet이 istio ingress gw로 부터 즉시 응답되었습니다.</figcaption></figure><ul><li><strong>Log Sample</strong></li></ul><p>위에서 발생한 Log를 살펴보면 다음과 같은 HTTP 503 응답을 대량으로 발생시켰다는 것을 확인했습니다.</p><pre>{<br>  &quot;method&quot;: &quot;GET&quot;,<br>  &quot;user_agent&quot;: &quot;iOS/iPhone Simulator/12.0.0/yogiyo-ios-***&quot;,<br>  &quot;upstream_transport_failure_reason&quot;: null,<br>  &quot;response_code&quot;: 503,<br>  &quot;upstream_service_time&quot;: null,<br>  &quot;x_forwarded_for&quot;: &quot;*.*.*.*,*.*.*.*&quot;,<br>  &quot;response_flags&quot;: &quot;UH&quot;,<br>  &quot;response_code_details&quot;: &quot;no_healthy_upstream&quot;,<br>  . . .<br>  &quot;downstream_local_address&quot;: &quot;*.*.*.*:**&quot;,<br>  &quot;route_name&quot;: &quot;default&quot;,<br>  &quot;protocol&quot;: &quot;HTTP/1.1&quot;,<br>  &quot;authority&quot;: &quot;membershipyo.****&quot;,<br>  &quot;duration&quot;: 0,<br>  &quot;start_time&quot;: &quot;2025-04-03T09:49:59.963Z&quot;,<br>  &quot;connection_termination_details&quot;: null,<br>  &quot;bytes_received&quot;: 0,<br>  &quot;upstream_local_address&quot;: null,<br>  &quot;upstream_host&quot;: null<br>}</pre><p>Duration = 0 을 통해서 즉시 응답한 것을 알 수 있습니다.</p><ul><li><strong>Istio를 통과한 Log 개수 수집</strong></li></ul><p>100% 실패한 것은 아니며, 소수의 성공 패킷도 있었던 것으로 보입니다. 다만 이 성공 패킷은 <strong>매우 긴 응답 시간</strong>을 가진 것이 특징이었습니다.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*PuFPcohy5-Uc-zeyTbbu9g.png" /></figure><ul><li><strong>Client 지표에서 RPS가 요동친 이유</strong></li></ul><ol><li>Pod의 Health Check가 실패하면서 트래픽을 처리할 Pod가 없었습니다.</li></ol><p>2. Istio Proxy가 UH 플래그 응답을 매우 빠르게 반환하면서, Locust 입장에서는 낮은 응답속도를 반영하여 <strong>RPS를 일시적으로 높게 설정</strong>했습니다. (실제 서비스 로직 처리가 없었기 때문에 오히려 350 RPS를 초과하기도 했습니다)</p><p>3. 간헐적으로 Istio를 통과한 패킷은 <strong>응답 시간이 매우 길었고</strong>, Locust는 이를 다시 반영해 요청 RPS를 급격히 낮췄습니다.</p><p>4. 결과적으로 “빠른 실패 응답(즉시 반환)”과 “매우 느린 성공 응답”이 번갈아 발생하면서, 클라이언트 지표에서 RPS가 크게 요동친 것으로 해석할 수 있습니다.</p><p>5. 대부분의 요청이 Istio에서 차단 및 즉시 응답이 반환되어 App의 로그가 적게 남았던 점도 이 흐름과 일치합니다.</p><h4>B. Latency 주입 실험 (250 ms)</h4><p>수행시간 : 18:57 ~ 19:08</p><blockquote><strong>1 . 인프라 모니터링</strong></blockquote><ul><li>Pod CPU 사용량</li></ul><p>실험 전 <strong>0.1 ~ 0.2 core</strong> 수준이던 CPU 사용량이 장애 주입 후 <strong>0.02 ~ 0.03 core</strong>로 감소했습니다. 500ms에 비해서는 상대적으로 높은 CPU 사용량을 유지했습니다.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*BKLxONCPClTujRfIABXm5Q.png" /></figure><ul><li>요청 RPS 수 (Request Per Second)</li></ul><p>장애 주입 전 RPS는 약 <strong>350</strong>이었으나, 주입 후에는 약 <strong>17 RPS</strong>로 감소해 실험 시간 동안 비교적 일정하게 유지되었습니다. 앞선 500ms 실험에서 거의 0에 가깝게 떨어졌던 결과에 비하면 낮지만 “지속 가능한 수준”으로 유지된 점이 특징입니다.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*auRAyZT-yZV02WWJtUHcXg.png" /></figure><ul><li>요청 Duration 시간 (P99)</li></ul><p>평소 <strong>25ms 이하</strong>로 처리되던 요청이 장애 주입 후 <strong>약 5초</strong>로 증가했습니다. 이는 약 <strong>20배 증가</strong>이며, 위에서 관찰한 RPS가 약 <strong>20배 감소</strong>한 것과 반비례 관계로 나타났습니다.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*qfiNbfMnL7OHtbr3QVk4HQ.png" /></figure><blockquote><strong>2. Client Side 모니터링</strong></blockquote><ul><li>Locust 메트릭</li></ul><p>250ms Pod Latency 주입 시에는 RPS가 <strong>약 15 수준으로 안정화</strong>됐고, Failure가 거의 사라졌습니다.</p><p>응답 시간 중앙값이 약 <strong>2.7초</strong>였기 때문에, “40 / 2.7 ≈ 15 RPS”로 설명할 수 있습니다</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*TH_85naYnUaKsEZ7s-DKyA.png" /></figure><blockquote><strong>3. 기타 분석</strong></blockquote><p>App 모니터링에서 특이 사항은 없었습니다. 에러 로그도 발견되지 않았고, 사용자 경험에도 문제가 없었습니다.</p><p>즉 250ms Pod Latency로 인해 응답 속도는 느려졌지만, Pod 상태는 정상으로 유지됐고 Redis 연결도 안정적이었기 때문에 Happy Path Test도 정상 통과했다고 볼 수 있습니다.</p><h4>C. Pod Network Latency 실험 후 얻은 인사이트</h4><p>해당 실험을 통해서 얻은 인사이트를 5가지 정리해보면 다음과 같습니다.</p><ol><li><strong>Redisson Error 예외처리 추가</strong><br>Redisson Error 발생 시 예외 처리를 하지 않아서 Unknown Error로 정확한 원인을 직관적으로 알아보기 어려웠는데, 해당 에러 케이스를 추가하여 에러를 직관적으로 볼 수 있는 개선안을 찾을 수 있었습니다.</li><li><strong>구독 회원의 주문 실패 확인</strong><br>멤버십 서비스가 다운되었을 때, 구독 회원이 주문페이지에 랜딩하지 못하는 상황을 확인했습니다. 멤버십 서비스가 정상적으로 응답하지 못할 때 비 동기적으로 처리하여, 서비스를 지속적으로 사용할 수 있는 방안으로 개선 포인트를 찾을 수 있었습니다.</li><li><strong>멤버십요의 Bottleneck 확인</strong><br>EKS Pod의 헬스체크가 실패한 이유는 Redis의 Queue를 점유한 부분에서 발생하였는데, 해당 부분을 조금 더 확장하거나 능동적으로 Queue관리할 수 있는 로직을 추가함으로서 Queue가 부족하지 않도록 조정할 수 있는 개선 포인트를 찾을 수 있었습니다.</li><li><strong>Pod Network Latency 영향도 확인</strong><br>운영환경과 유사한 상황을 만들기 위해 Base Request를 Locust로 발생시켰습니다. 단순 Health Check가 아닌 시나리오를 기반해서 요청이 동작하기에 여러 연동 서비스를 거치게 되는데, Pod에 적용된 Latency는 매 요청마다 대기가 되기에 생각보다 영향이 컸습니다.</li><li><strong>AWS FIS 서비스 경험 및 제약조건 이해</strong><br>이번에 장애 주입 도구인 AWS FIS를 사용하면서 어떤 기능을 제공하는지 경험할 수 있었고, 제약조건도 이해할 수 있었습니다. AWS Console에서 간편하게 수행하거나 Stop Condition을 설정할수도 있다는 것은 큰 장점이였고, 권한 부여가 조직의 거버넌스와 맞물리게 되면 까다로울 수 있다는 점을 느꼈습니다. AWS FIS는 지속적으로 기능이 업데이트 되고 있는 것으로 보여 이후에는 또 다른 기능도 사용해보면 괜찮을 것 같습니다.</li></ol><h3>외부 API 통신장애 주입</h3><p>수행 시간: 19:10 ~ 19:30</p><p>외부 협력업체를 통해 가입한 멤버십의 경우, 탈퇴 시 외부 API를 호출해 탈퇴 처리합니다. 이 외부 API 통신에 장애를 주입했을 때 DB 정합성 및 다른 기능에 영향이 있는지 확인하는 테스트를 진행했습니다.</p><h4>A. 외부 API 차단</h4><ul><li>장애 주입</li></ul><p>외부 API에 대한 IP를 outbound 차단 정책을 이용해서 차단 하였습니다. 다행이 단일 IP만 차단하면 막히는 것으로 확인되어 간단하게 AWS NACL을 통해 쉽게 장애를 주입할 수 있었습니다.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*gSo37xUp3dk9m6kwMUP11Q.png" /></figure><blockquote><strong>1 . 인프라 모니터링</strong></blockquote><ul><li>탈퇴 API 상태 확인</li></ul><p>연계된 회원 탈퇴를 위해서는 외부 협력사에서 제공하는 <strong>wiithdraw-subscription</strong> API를 호출해서 탈퇴를 진행합니다.</p><p>Outbound IP를 차단 후, <strong>wiithdraw-subscription</strong> API에 에러율이 급증하는 것을 확인하여 의도한 대로 차단이 되었음을 확인했습니다.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*Tye86Kfqeo39jGxjVA7nRQ.png" /></figure><p>APM Dependency Map에서 100% 실패하는 것을 확인했습니다.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*1eWKbZUFPFA6QDiotrKtwA.png" /></figure><blockquote><strong>2 . App 모니터링</strong></blockquote><ul><li>HTTP Response Code 500 으로 응답되는 것을 확인했습니다.</li></ul><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*kkR07l2dNRQRp_vIZn4_yg.png" /></figure><ul><li>구독 관련 Data 정합성 확인</li></ul><p>DB의 멤버 정보에서 Started_at (구독 시작일) Finished_at (구독 종료일) 등에 문제가 없는지 정합성에 대한 확인을 하였고 이상 없다는 것을 확인 했습니다.</p><blockquote><strong>3 . 사용자 경험</strong></blockquote><p>Happy Path Test를 통해서 의도한 구독 해지 실패 기능 외에는 모두 정상 동작 했음을 확인 했습니다.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*wo0zGYczXqFUnvouUSf7Vw.png" /></figure><h4>B. 외부 API 통신 장애 결론 공유</h4><p>외부 API 호출 장애가 발생하더라도, 회원 탈퇴를 제외한 다른 멤버십 관련 기능이 정상 동작함을 확인했습니다. 즉, 해당 기능 범위에서는 외부 벤더 장애에 대해 일정 수준의 내성이 있음을 확인할 수 있었습니다.</p><h3>이번 카오스 엔지니어링 실험을 마치며 . . .</h3><p>이번 실험에서 가장 어려웠던 부분은 <strong>시나리오를 구체화하는 과정</strong>과, <strong>실험 단계에서 관찰해야 할 핵심 지표를 도출하는 과정</strong>이었습니다. 처음 카오스 엔지니어링을 기획할 때는 막연한 부분이 많았고, 장애 주입 도구 및 부하 테스트 도구에 대한 기술적 이해가 필요했습니다. 또한 우리 서비스 환경에 맞게 설정을 조정해야 했기 때문에 사전 학습과 테스트도 병행해야 했습니다.</p><p>그럼에도 여러 구성원과 논의하며 시나리오와 실험 방법을 점진적으로 구체화할 수 있었고, 실험 결과를 분석·정리하는 과정에서 예상하지 못했던 개선 포인트도 발견할 수 있었습니다. 또한 일부는 예상대로 잘 동작하는 부분을 확인할 수 있었던 점도 의미가 있었습니다. <br>개인적으로는 여러 부서와 협업하는 과정에서 각 팀의 업무에 대해서 더 잘 이해할 수 있었던 부분도 특히 좋았습니다.</p><p>이번에는 “멤버십”이라는 특정 서비스를 대상으로 진행했지만, 여러 마이크로서비스로 확장해 실험한다면 더 많은 인사이트를 얻을 수 있을 것으로 기대합니다. 함께 고생해주신 여러 조직원들께 감사의 마음을 전하며 글을 마치겠습니다.</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=7b09c0b2183b" width="1" height="1" alt=""><hr><p><a href="https://techblog.yogiyo.co.kr/%EC%9A%94%EA%B8%B0%EC%9A%94-%EC%B9%B4%EC%98%A4%EC%8A%A4-%EC%97%94%EC%A7%80%EB%8B%88%EC%96%B4%EB%A7%81-2-%EC%B9%B4%EC%98%A4%EC%8A%A4-%EC%8B%A4%ED%97%98-%EA%B2%B0%EA%B3%BC-%EA%B3%B5%EC%9C%A0-7b09c0b2183b">[요기요 카오스 엔지니어링 (2)] 카오스 실험 결과 정리하기</a> was originally published in <a href="https://techblog.yogiyo.co.kr">YOGIYO Tech Blog - 요기요 기술블로그</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[ 연말 모드 ON, R&D Center 집합]]></title>
            <link>https://techblog.yogiyo.co.kr/%EC%97%B0%EB%A7%90-%EB%AA%A8%EB%93%9C-on-r-d-center-%EC%A7%91%ED%95%A9-0c462d31f54e?source=rss----c1b33ccbbc42---4</link>
            <guid isPermaLink="false">https://medium.com/p/0c462d31f54e</guid>
            <category><![CDATA[yogiyo]]></category>
            <category><![CDATA[culture]]></category>
            <category><![CDATA[tech]]></category>
            <category><![CDATA[post]]></category>
            <dc:creator><![CDATA[Jihyun Hong]]></dc:creator>
            <pubDate>Thu, 29 Jan 2026 05:00:10 GMT</pubDate>
            <atom:updated>2026-01-29T05:00:08.678Z</atom:updated>
            <content:encoded><![CDATA[<blockquote><strong>연말엔 역시, 다 같이 모여야 제맛이죠</strong></blockquote><p>일은 잠시 내려두고, 사람에게 집중했던<strong><em> 2025 R&amp;D Center Year End Party </em></strong>이야기</p><h4>🌿 올해의 무대는, 조금 다른 공기였습니다</h4><p>이번 <strong><em>Year End Party</em></strong>는 형식보다 분위기에 마음을 둔 자리였습니다.</p><p>연말만큼은 닫힌 공간보다 불빛과 바람이 함께 있는 곳에서, 사람들과 나란히 시간을 보내고 싶었습니다. <br>불을 피우고 음식을 나누는 사이 이야기는 자연스럽게 이어졌고, 이 하루는 ‘행사’라기보다 함께 다녀온 연말의 한 장면처럼 기억될 수 있었습니다. ✨</p><h4><strong>🧐 이 하루를 만든 사람들부터 이야기해야죠</strong></h4><p>이번 Year End Party는 아주 여유롭게 준비된 이벤트는 아니었습니다.</p><p>행사를 약 <strong>한 달 앞둔 시점</strong>,<br> 행복한 R&amp;D Center 행사를 위해 <strong>동원 님의 주도로 긴급하게 TF가 창설</strong>됐어요.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*pdWn-xSFtTzPWDMKAZK9rQ.png" /></figure><p>행사 전에는 짧고 밀도 높은 회의가 이어졌고, 행사 당일에는 시작 전부터 마무리까지 TF 멤버들이 현장을 오가며 계속 체크하는 모습이 인상적이었어요.</p><p>무대 위보다 무대 뒤가 더 분주했던 하루.<br> 이 연말 파티의 시작에는, 행사 당일에는 바쁘게 움직이느라 티가 나지 않았지만, 이 파티의 시작에는 <strong><em>TF 멤버들의 준비</em></strong>가 있었습니다.</p><p>🙋🏻‍♀️🙋🏻‍♂️ <strong>TF 멤버</strong>를 소개합니다!!</p><p><strong>🧑🏻‍🏫 이동원</strong> — 전체 흐름을 정리하며 방향을 잡아준 든든한 중심<br><strong>️🕵🏻‍♂ ️이민구</strong> — 행사 전반을 넓게 바라보며 장소 선정부터 주요 결정까지 큰 틀을 잡아준 조율자<br><strong>👷🏻‍♂️️ 윤태민</strong> — 세부 운영과 커뮤니케이션을 꼼꼼하게 챙긴 실질적인 메인 리더<br>👩🏻‍💻 <strong>홍지현</strong> — 동료에게 빠르게 소식을 전하고, 행사 주변을 세심하게 챙긴 만능 지원군<br>👩🏻‍🎨 <strong>서지민</strong> — 분위기와 재미 포인트를 놓치지 않고 아이디어를 더해준 아이디어 뱅크<br>👩🏻‍🔧 <strong>이다윤</strong> — 포토월과 배너 제작에 재능을 아낌없이 나눠주고, 현장 조립까지 책임진 ‘조립의 달인’</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*bFk8v84a3dZehitCtdLsYA.jpeg" /></figure><p>각자 역할은 달랐지만, 목표는 하나였습니다.<br> <strong>“참가자들은 그냥 와서 즐기기만 하면 되는 하루를 만들자.”</strong></p><h4><strong>🛠 준비 과정, 이렇게 흘러갔어요</strong></h4><p>시간은 많지 않았지만,<br> 그래서 오히려 더 빠르게 결정하고, 더 촘촘하게 움직였습니다.</p><p>전체 흐름을 몇 번이고 다시 맞췄죠.</p><p>“이 타이밍엔 이게 필요하겠다.” 싶은 디테일까지 큰 그림과 디테일을 동시에 잡아야 했던 준비 과정이었습니다.<br> 장소 후보를 비교하고, 인원과 동선을 맞춰보고, 중간중간 일정이 바뀌기도 했지만, 그때마다 빠르게 의견을 모으고 방향을 정리해 나갔습니다.</p><p>행사 전날까지도 체크리스트를 하나씩 확인했고,<br> 당일에는 시작 전부터 마무리까지 TF 멤버들이 계속 현장을 오가며 흐름을 점검했습니다.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*SuRS5FhPIVyzAv938jzm8Q.jpeg" /></figure><p>덕분에 행사장은 눈에 띄는 혼선 없이, 자연스럽게 흘러갈 수 있었고<br><em> R&amp;D Center</em> 구성원 여러분들은 자리에 앉아 편하게 웃고 즐기면 되는 하루를 보낼 수 있었습니다.</p><h4><strong>❄️ 날씨는 겨울, 분위기는 바로 연말 모드</strong></h4><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*WUzMkww_pVbV2zlSuUSl3w.png" /></figure><p>행사 당일 연말답게 날씨는 꽤 쌀쌀했지만, 행사장 입구에 들어와서는 순간 공기가 달라졌습니다.</p><p>핫팩을 하나씩 챙기고 출석 체크를 마친 뒤<br> 각자 배정된 텐트로 이동하면서 <strong><em>“아, 연말 행사에 왔구나”</em></strong> 하는 기분이 자연스럽게 들었어요.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*Sg9dkAVS7Pfye_fBTM_3EQ.jpeg" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*HYuu1UaeR4LpSsyOVm2NWQ.jpeg" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*nah-x9GRmY-E5Nt66_SAqA.jpeg" /></figure><p>업무에서 잠시 벗어나 조금 느슨해진 얼굴들이 하나둘 보이기 시작한 순간이었습니다 🥸</p><h4><strong>⛺ 자리에 앉는 순간, 파티가 시작됐습니다</strong></h4><p>텐트는 단순히 앉는 공간이 아니라 각 팀이 모이는 작은 베이스캠프 같은 역할을 했습니다.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*6NJ52o1tRXzuXvBvpL0Q_Q.jpeg" /></figure><p>자리를 잡고, 고기를 굽고, 주변 텐트를 둘러보며 인사를 나누는 동안<br> 행사장은 점점 파티다운 분위기로 채워졌어요.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*jYu6PkFRvbWoX7zrbsjNew.jpeg" /></figure><p>회의실에서는 보기 힘든 장면들이 이 공간에서는 자연스럽게 이어졌습니다.</p><h4><strong>🎮 리듬을 바꾸는 한 판, 분위기는 급상승</strong></h4><p>행사 중반에는 단체 게임이 진행됐습니다. 🎤</p><p>각 텐트에서 대표가 참여하고,<br> 다른 구성원들은 응원과 관람 모드로 전환!</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*WH6wWMACQYAx1mCmrjV0eQ.jpeg" /></figure><p>여러 미니 게임이 순서대로 진행됐고,</p><p>그중에서도 특히 눈길을 끈 건 <strong>추억의 딱지치기</strong>. <br> 각자 스타일대로 딱지를 접고,<br> 생각보다 파워 넘치는 승부가 펼쳐지며 현장에 웃음이 끊이지 않았어요.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*HnO2YjSq7_0UxbCjPQ2F5Q.jpeg" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*KdmTGkQZjJ87y-SLbw-IGA.jpeg" /></figure><p>짧지만 템포 있는 게임들이 이어지면서 행사 흐름은 한 번 더 살아났고,<br> <strong>승리한 팀에게는 맛있는 음식을 경품으로 받는 순간</strong>까지 더해져<br> 몰입도는 자연스럽게 최고조로 👏🏻🍖</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*_BzEIhdjRQBQs2WuoGDLLQ.jpeg" /></figure><p>응원과 환호, 아쉬운 탄식이 뒤섞이면서 파티의 텐션은 <br>이 타이밍에 한 단계 상승 ⬆️<br> 게임이 끝난 뒤에도 각 텐트에서는 조금 전 플레이를 복기하는 이야기들이 이어졌다고 하네요!</p><h4><strong>🎁 모두의 시선이 한곳으로 모인 시간</strong></h4><p>경품 추첨이 시작되자 행사장 전체의 시선이 자연스럽게 한곳으로 모였습니다.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*jRK_7vWrY_k5MDB0CFs74w.jpeg" /></figure><p>번호가 하나씩 불릴 때마다 여기저기서 반응이 터져 나오고,<br> 당첨 여부와 상관없이 서로를 축하하는 분위기가 이어졌어요. 🎉</p><p>“누가 받느냐”보다<br> “같이 이 순간을 즐긴다”라는 느낌이 더 크게 남았습니다.</p><h4><strong>🏆 따뜻함으로 마무리한 2025년의 마지막 페이지</strong></h4><p>이어진 시상식은 올해를 한 번 더 돌아보는 시간이었습니다.</p><p>누군가를 조명하기도 했지만,<br> 그보다도 함께 보낸 시간과 과정이 자연스럽게 떠오르는 자리였어요.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*tvnvNy2fGr9jlSCi6SIwYg.jpeg" /></figure><p>시상자들을 위해 행사장을 채운 박수는 말보다 더 많은 의미를 담고 있었습니다.👏🏻</p><h4><strong>✨ 함께였기 때문에 더 좋았던 연말</strong></h4><p>텐트가 정리되고, 서로 인사를 나누며 귀가 준비를 하는 동안<br><strong><em> “오늘 재밌었다”</em></strong>라는 여운이 자연스럽게 남았어요. 🫶🏻</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*D7vJCYS2nbRwSbc1Xyip8Q.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*boVSc_Ycba4GNIGvzBjk5A.png" /></figure><p>이번<em> 2025 Year End Party</em>는 완벽한 연출보다 <strong><em>사람들이 만든 분위기</em></strong>가 중심이었습니다.<br>같은 목표를 향해 일해온 사람들이 같은 공간에 모여 한 해를 정리할 수 있었던 시간.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*ozQQMfw8FoafcL1RpZxvdA.jpeg" /></figure><p>이 하루 덕분에 다가올 2026년을 시작할 에너지를 조금 더 충전할 수 있었습니다. 💪🏻</p><p><strong>🙌🏻 </strong><em>2025</em>년의 기록은 여기까지.<br> 다음 챕터는 <strong><em>2026</em>년</strong>에서 이어집니다.<strong> 🚀</strong></p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=0c462d31f54e" width="1" height="1" alt=""><hr><p><a href="https://techblog.yogiyo.co.kr/%EC%97%B0%EB%A7%90-%EB%AA%A8%EB%93%9C-on-r-d-center-%EC%A7%91%ED%95%A9-0c462d31f54e">🎪 연말 모드 ON, R&amp;D Center 집합</a> was originally published in <a href="https://techblog.yogiyo.co.kr">YOGIYO Tech Blog - 요기요 기술블로그</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[마커 펼치기로 포장 지도 UX 개선하기 : Map Projection]]></title>
            <link>https://techblog.yogiyo.co.kr/%EB%A7%88%EC%BB%A4-%ED%8E%BC%EC%B9%98%EA%B8%B0%EB%A1%9C-%ED%8F%AC%EC%9E%A5-%EC%A7%80%EB%8F%84-ux-%EA%B0%9C%EC%84%A0%ED%95%98%EA%B8%B0-map-projection-5c77b63714fc?source=rss----c1b33ccbbc42---4</link>
            <guid isPermaLink="false">https://medium.com/p/5c77b63714fc</guid>
            <category><![CDATA[post]]></category>
            <category><![CDATA[tech]]></category>
            <category><![CDATA[yogiyo]]></category>
            <dc:creator><![CDATA[Yunhan Kim]]></dc:creator>
            <pubDate>Thu, 15 Jan 2026 06:21:38 GMT</pubDate>
            <atom:updated>2026-01-15T06:27:46.934Z</atom:updated>
            <content:encoded><![CDATA[<h3>마커 펼치기로 포장 지도 UX 개선하기 : Map Projection</h3><p>안녕하세요. 요기요 모바일팀 Android 개발자 김윤한입니다.</p><p>요기요의 포장 지도 화면에서 동일한 위경도에 여러 가게가 존재할 때 유저의 탐색 가독성이 떨어지는 문제가 있었습니다.</p><p>이 글에서는 <strong>Map Projection 기반의 마커 펼치기 구현 과정과 </strong>탐색 가독성을 유지하기 위해 했던 고민들을 소개 해 드리겠습니다.</p><h3>왜 이 개선이 필요했는가</h3><p>같은 건물이나 상가에 여러 가게가 입점한 경우 지도에서는 마커가 겹쳐 보입니다. 기존에는 핀 리스트로 이를 보완했지만 사용자가 지도에서 직관적으로 가게를 탐색하기 어려웠고 지도를 보며 선택하는 흐름이 끊기는 문제가 있었습니다.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/778/1*_hf4tMWVpJtrCbigo6yF3A.png" /><figcaption>개선 전 동일한 위치의 겹쳐진 가게들</figcaption></figure><p>마커가 겹치면 지도 위에서 개별 가게를 선택할 수 없어 사용자가 지도 화면을 이탈하는 경우가 많았습니다. 이번 개선의 목표는 동일 위치의 가게를 지도 위에서 분리해 보여주어 사용자가 탐색 흐름을 유지한 채로 원하는 가게를 선택할 수 있게 만드는 것입니다.</p><h3>탐색 경험을 개선하는 변화</h3><p>사용자의 탐색 경험을 개선하기 위해 적용한 변화는 크게 두 가지입니다.</p><ul><li><strong>지도 이동 시 자동 재검색</strong></li><li><strong>겹쳐진 마커를 펼치기</strong></li></ul><p>기존에는 지도 이동 후 재검색 버튼이 노출되고 사용자가 버튼을 눌러야 결과가 갱신되는 흐름이었습니다. 개선 후에는 지도 이동 시 자동 재검색을 통해 결과가 자연스럽게 갱신되도록 했습니다. 사용자가 별도의 버튼을 눌러 상태를 갱신하지 않아도 되기 때문에 지도를 움직이며 탐색하는 흐름이 끊기지 않게 되었습니다.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/400/1*lauu8UF8X2BeFtyZLQxl0Q.gif" /><figcaption>지도 이동을 통한 자동 재검색</figcaption></figure><p>기존에는 동일 위치 가게가 겹치면 핀 리스트를 통해 탐색해야 했습니다. 핀 리스트의 경우 지도 화면에서 탐색 가독성이 떨어졌습니다. 이를 해결하기 위해 지도 확대 시 같은 위치 가게들을 지도 위에서 펼쳐져 <strong>개별 선택</strong>이 가능하도록 개선하였습니다. 결과적으로 포장 화면에서의 탐색과 선택이 자연스러워 질 수 있었습니다.</p><p>그렇다면 UX 개선의 핵심인 “<strong>마커 펼치기</strong>” 계산을 어디에서 수행할까요?</p><h3>클라이언트 마커 펼치기: Projection과 Zoom Level</h3><p>마커 펼치기는 사용자의 현재 지도 상태(Projection, Zoom Level, 화면 좌표계)에 직접 영향을 받습니다. 따라서 서버에서 좌표를 계산해 내려주면 사용자가 보는 “현재 화면 기준”으로 일관된 결과를 보장하기 어렵습니다. 또한 지도 이동 시 자동 재검색이 동작하는 환경에서 서버 계산/전송을 늘리는 방식이 비용과 지연 측면에서 불리할 수 있습니다.</p><p>반면 클라이언트에서는 Map Projection을 활용해 실시간 계산이 가능했고 펼치기 개수/조건을 제한하면 성능도 안정적으로 관리할 수 있다고 판단했습니다. 이 결론을 바탕으로 <strong>겹친 마커를 어떤 형태로 펼칠 것인가</strong>를 결정했습니다.</p><h3>균등 분할과 최소 면적: 원형 배치</h3><p><strong>동일한 위경도</strong>를 가진 가게들을 펼쳐서 보여주는 것에 대한 다양한 논의가 있었고 <strong>원형</strong>으로 펼치는 의견을 제안했습니다. 원형 배치는 “균등 분할”이라는 단순한 원리를 가집니다.</p><pre>val radius = distance * factor // 반경<br>val angle = 2 * Math.PI * index / maxIndex // 각도<br>val dx = radius * Math.cos(angle)<br>val dy = radius * Math.sin(angle)</pre><ul><li>마커 N개일 때 각도 간격 = 360° / N</li><li>반경(radius)과 각도(angle)로 dx, dy를 만들고</li><li>기준점에서 dx, dy만큼 이동한 좌표로 마커를 배치합니다.</li></ul><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*876c51dDtnYkLKUTE6UcVw.jpeg" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*ILp4X4pkNLHAJzxJ7SsjgQ.jpeg" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*nU5T4UOxFJOWqBSQcVzzfg.jpeg" /><figcaption>개수 별 원형으로 펼쳐진 마커</figcaption></figure><p>3개의 마커라면 120°, 4개라면 90°, 5개라면 72° 간격을 두고 원형으로 배치됩니다.</p><p>다만 여기서 한 가지 문제가 있습니다. <strong>위경도 좌표계에 dx</strong>·<strong>dy를 그대로 더하면 지도 상 거리/형태가 일정하지 않습니다.</strong></p><p>위도에 따라 경도 1도의 실제 거리가 달라지기 때문입니다. 위경도에 dx·dy를 단순히 더하면 화면에서는 원형이 찌그러지거나 간격이 달라 보일 수 있습니다. 왜곡을 해결하기 위해서는 Map Projection이 필요합니다.</p><h3>Map Projection</h3><p>지구는 평면이 아닌 곡면이기 때문에 이를 평면으로 펼치는 과정에서 왜곡이 발생합니다. 곡면 좌표를 평면 좌표로 옮기는 과정에서는 발생한<strong> 왜곡을 해결하기 위해 Map Projection 활용</strong>하였습니다.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*4-7cDKaYqtbDWNe0shia0w.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*BiFfcWuAUc8gik5E-4zhhQ.png" /><figcaption>좌 : 위경도 기반 펼쳐진 마커/ 우 : Map Projection 기반 펼쳐진 마커</figcaption></figure><p>위경도 좌표계를 그대로 사용하면 왜곡이 발생해 타원 형태로 불균등하게 펼쳐집니다. 반면 Map Projection을 적용하면 원형으로 균등하게 펼쳐집니다.</p><p><strong>Map Projection</strong>은 <strong>3차원 지구 좌표(위경도)를 2차원 지도 화면 좌표(x, y)로 변환</strong>하는 메커니즘입니다. Map Projection을 활용하여 위경도 좌표를 지도 화면 좌표로 변환하는 계산을 진행했습니다.</p><p>Map Projection 적용은 아래 3단계로 정리됩니다.</p><ol><li>기준 위경도 → 지도 화면 좌표로 변환</li><li>지도 화면 좌표에서 dx, dy만큼 이동</li><li>이동한 지도 화면 좌표 → 위경도로 변환</li></ol><pre>val radius = distance * factor // 반경<br>val angle = 2 * Math.PI * index / maxIndex // 각도<br>val dx = radius * Math.cos(angle)<br>val dy = radius * Math.sin(angle)<br><br>val base = projection.toScreenLocation(baseLatLng) // 위경도 -&gt; 좌표 변환<br>val offset = PointF(base.x + dx, base.y + dy) // 좌표 이동<br>val result = projection.fromScreenLocation(offset) // 좌표 -&gt; 위경도 변환</pre><p>핵심은 위경도가 아닌 사용자가 보는 지도 화면 좌표계에서 계산한다는 점입니다. 지도 화면의 좌표계는 왜곡이 없는 평면이기 때문에 삼각함수로 계산한 dx, dy를 그대로 적용하여 원형으로 마커를 펼칠 수 있었습니다.</p><h3>마커 펼치기 최적화: 개수 제한과 면적 줄이기</h3><p>원형 배치는 균등하지만 동일한 위경도를 가지는 가게 수가 늘수록 펼침 면적이 커져 주변 가게/도로 정보를 가리거나 다른 그룹과 겹칠 수 있습니다. 그래서 서비스에서는 ‘항상 최대한 펼친다’가 아니라 선택성과 가독성을 우선하는 운영 정책을 함께 적용했습니다.</p><ul><li>최대 8개까지만 펼침</li><li>4개당 1 depth, 최대 2 depth</li></ul><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*PJK3yf1rGY12RSltVyLFpg.png" /><figcaption>원형 2 depth 를 통해 정사각형 모양으로 펼쳐진 마커</figcaption></figure><p>요기요에 입점한 가게들 중 9개 이상으로 같은 위경도를 가진 가게들의 케이스는 1% 미만이었습니다. 이 경우를 예외 케이스로 두고 나머지 99%에 집중하였습니다. 같은 위경도에서 최대 8개까지만 펼쳐 화면 점유 면적을 줄이기 위해 논의를 진행하였습니다. 이때 가장 효율적인 배치를 적용하기 위해 2depth로 원형 두 개를 겹쳐 정사각형 모양으로 펼치게 되었습니다.</p><h3>UX 개선 확장 : 펼침 강도와 반경 스케일링</h3><p>마커 펼치기를 항상 동일한 강도로 적용하지는 않았습니다. Zoom Level이 낮은 상태에서 많은 마커를 펼치면 화면이 급격히 복잡해지기 때문에, 확대 수준에 따라 펼침 강도를 단계적으로 올렸습니다.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/400/1*LwGENV6gxn7YBfgQhbM92g.gif" /><figcaption>Zoom Level에 따라 펼쳐지는 마커</figcaption></figure><p>Zoom Level이 낮을 때는 최소 개수만 펼쳐 화면 복잡도를 낮추고 사용자가 확대할수록 더 많은 가게를 펼쳐 상세 선택이 가능하도록 했습니다. 별도의 안내 없이도 확대할수록 더 자세히 탐색할 수 있다는 것은 사용자의 동작과 자연스럽게 연결되었습니다.</p><p>이 때 Zoom Level이 변경되는 동안 마커가 펼치는 반경이 고정값이면 체감 거리가 급격히 달라집니다. 그래서 Zoom Level을 반영하여 반경을 스케일링했습니다.</p><pre>val zoomDiffFactor = 2.0.pow(currentZoomLevel - standardZoomLevel)<br>val radiusPx = standardRadiusPx * zoomDiffFactor</pre><p>이 방식은 Zoom Level 달라져도 반경이 보정돼 동일한 거리로 마커를 펼칠 수 있게 됩니다. 마커가 무분별하게 펼쳐지는 현상을 줄여 탐색의 가독성을 일정하게 유지해줍니다.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*g162_lIU48Khy9VCDxyjAA.png" /></figure><h3>마치며</h3><p>이 글에서는 포장 화면 지도에서 동일 위경도 가게들로 인해 발생하던 탐색 가독성의 저하 문제를 Map Projection 기반 마커 펼치기와 정책으로 해결한 과정을 공유했습니다.</p><p>마커 펼치기를 통해 동일 위경도 가게들이 지도 위에서 분리되어 표시되면서 <strong>탐색과 선택이 자연스러워졌고</strong> 포장 화면의 핵심 지표(CVR, GMV)도 긍정적으로 개선되었습니다.</p><p>앞으로도 사용자 행동 흐름을 기준으로 문제를 정의하고 최적화하는 개선을 이어가겠습니다. 읽어주셔서 감사합니다.</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=5c77b63714fc" width="1" height="1" alt=""><hr><p><a href="https://techblog.yogiyo.co.kr/%EB%A7%88%EC%BB%A4-%ED%8E%BC%EC%B9%98%EA%B8%B0%EB%A1%9C-%ED%8F%AC%EC%9E%A5-%EC%A7%80%EB%8F%84-ux-%EA%B0%9C%EC%84%A0%ED%95%98%EA%B8%B0-map-projection-5c77b63714fc">마커 펼치기로 포장 지도 UX 개선하기 : Map Projection</a> was originally published in <a href="https://techblog.yogiyo.co.kr">YOGIYO Tech Blog - 요기요 기술블로그</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[사내 DB 관리 규정을 AI로 적용하다 : Amazon Bedrock 기반 DBA 리뷰봇 개발기]]></title>
            <link>https://techblog.yogiyo.co.kr/%EC%82%AC%EB%82%B4-db-%EA%B4%80%EB%A6%AC-%EA%B7%9C%EC%A0%95%EC%9D%84-ai%EB%A1%9C-%EC%A0%81%EC%9A%A9%ED%95%98%EB%8B%A4-amazon-bedrock-%EA%B8%B0%EB%B0%98-dba-%EB%A6%AC%EB%B7%B0%EB%B4%87-%EA%B0%9C%EB%B0%9C%EA%B8%B0-f845508e6055?source=rss----c1b33ccbbc42---4</link>
            <guid isPermaLink="false">https://medium.com/p/f845508e6055</guid>
            <category><![CDATA[post]]></category>
            <category><![CDATA[yogiyo]]></category>
            <category><![CDATA[search-engines]]></category>
            <category><![CDATA[tech]]></category>
            <category><![CDATA[dba]]></category>
            <dc:creator><![CDATA[Taejeong Park]]></dc:creator>
            <pubDate>Wed, 31 Dec 2025 01:17:58 GMT</pubDate>
            <atom:updated>2025-12-31T01:17:39.934Z</atom:updated>
            <content:encoded><![CDATA[<h3>사내 DB 관리 규정을 AI로 적용하다 : Amazon Bedrock 기반 DBA 리뷰봇 개발기</h3><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*1XmQ86I4O81_2iFTP6JaSQ.png" /></figure><p>DDL 관리는 어떻게 하고 계신가요?</p><p>마이크로서비스 환경에서 DB 스키마 변경은 빈번하게 발생합니다. <br>문제는 단일 DDL이 아니라, 여러 DDL이 한 요청에 섞여 들어오거나 CDC·Replication과 같은 복제 구조가 함께 고려되어야 하는 복잡도가 높은 작업에서 발생합니다. 이러한 요청을 리뷰하는 DBA의 부담은 요청 수와 복잡도에 비례해 빠르게 커집니다.</p><p>요기요에서는 이러한 스키마 변경을 내부 관리 포털인 DBportal(요기요의 DDL 관리 및 배포 시스템)을 통해 처리하고 있습니다.<br>개발팀이 DBportal에 DDL과 적용 일정을 등록하면, DBA 리뷰와 승인 과정을 거쳐 지정된 시점에 자동으로 배포가 이루어지는 구조입니다. <br>이때 DBA 리뷰는 자동 배포 이전에 서비스 안정성을 확보하기 위한 가장 중요한 단계입니다.</p><p>기존에는 이 리뷰 과정이 전적으로 DBA의 수동 판단에 의존하고 있었습니다. <br>사내 DB 관리 규정을 기준으로 ddl이 올바르게 요청되었는지, CDC 대상 여부, 복제 구조, 변경 방식의 적합성 등을 요청마다 직접 확인해야 했고, 요청이 몰리는 상황에서는 판단 누락이 발생할 여지도 있었습니다.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*jYpNYPEptfMTV6FbT8uN7g.png" /><figcaption>그림1. 기존 요기요 DDL 배포 프로세스</figcaption></figure><p>이를 개선하기 위해 요기요 DBA팀은 사내 DB 관리 규정을 기반으로 <br>DDL 요청을 1차적으로 정리·검토해 주는 DBA 리뷰봇을 도입했습니다. <br>이러한 구조에서 DBA 리뷰봇을 도입한 이후, 리뷰 과정에서는 다음과 같은 변화가 나타났습니다.</p><ul><li>다양한 DDL 요청을 리뷰봇이 1차적으로 분석하면서, <br> DBA가 반드시 확인해야 하는 크리티컬한 사항(CDC/Replication 대상, 위험한 ALTER 등)을 사전에 식별해 주어 리뷰 부담이 줄어들었습니다.<br> DBA는 반복적인 규정 확인 대신, 정말 중요한 판단에 더 집중할 수 있게 되었고 개발자 역시 요청 단계에서 작업의 위험도를 인지할 수 있게 되었습니다.</li><li>DDL 요청이 규정에 맞지 않거나 보완이 필요한 경우,<br> 기존처럼 DBA와 개발자 간에 여러 차례 의견을 주고받는 과정이 줄어들었습니다. 리뷰봇이 규칙 위반 사유와 권장 방향을 함께 제시함으로써, 커뮤니케이션 비용이 눈에 띄게 감소했습니다.</li><li>Slack 챗봇 형태로 제공되면서,<br> 사내 DB 관리 규칙을 확인하기 위해 별도로 Wiki 문서를 찾아보지 않아도<br> 필요한 시점에 바로 질의하고 답변을 받을 수 있게 되었습니다.</li></ul><p>이러한 변화로 인해, <strong>DDL 요청부터 리뷰 완료까지의 전체 처리 속도는<br>기존 대비 약 20% 정도 개선</strong>되는 효과를 확인할 수 있었습니다.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*kN-zq1vpqTA72LoXts-vmg.png" /><figcaption>그림2. Bedrock 을 활용한 DBA-review ai bot이 도입된후 변경된 프로세스</figcaption></figure><p>이 글에서는 이러한 변화를 만들어낸 DBA 리뷰봇의 설계 배경과, <br>Amazon Bedrock Knowledge Base와 Agent를 활용해 구현한 기술적 선택들을 소개합니다.</p><h4>DBA 리뷰봇 프로젝트 소개</h4><p>요기요 DBA팀은 <strong>사내 DB 스키마 변경 리뷰 과정을 보다 일관되고 효율적으로 수행하기 위해</strong>, AI 기반의 DBA 리뷰봇을 개발했습니다.</p><p>개발팀으로부터 들어오는 다양한 DDL 요청은 단순한 SQL 하나로 끝나지 않습니다. 여러 DDL 변경이 한 요청 안에 섞여 들어오거나, CDC·Replication 같은 복제 구조와 관련된 테이블들이 포함되는 경우가 많습니다. 이때 사내 DB 관리 규정에 따라 잠재적인 리스크를 검토하는 과정은 반복적이고 부담이 큰 작업입니다.</p><p>리뷰봇은 이러한 반복적인 판단을 1차적으로 보조하기 위해 만들어졌습니다. 입력된 DDL 요청을 기반으로 다음과 같은 정보를 자동으로 정리해줍니다.</p><ul><li>사내 DB 관리 규정과의 적합성 판단 및 규정을 준수하는 적합한 쿼리 제공</li><li>추가 확인 작업이 필요한 위험도가 높은 작업 판별</li><li>복제 대상(CDC, REPLICATION) 테이블 식별</li></ul><ol><li>리뷰봇 활용 case 1</li></ol><pre>##신청 ddl 예시<br>create table t3(id bigserial);<br>alter table t2 alter column abc type varchar(10);</pre><figure><img alt="" src="https://cdn-images-1.medium.com/max/656/1*Joj5dYz-Qik1md966YAzmw.png" /><figcaption>그림3. review bot 활용사례: <br>추가 확인이 필요한 작업(alter column) 판별 및 <br>cdc대상 테이블 식별</figcaption></figure><p>2. 리뷰봇 활용 case 2</p><pre>##신청 ddl<br>CREATE TABLE test1 (<br>.<br>&lt;생략&gt;<br>.<br>.<br>);<br>CREATE INDEX CONCURRENTLY ix_test1_diff_saved ON entity_change_history (diff_saved);<br>CREATE INDEX CONCURRENTLY ix_test1_created_at ON entity_change_history (created_at);<br>CREATE INDEX CONCURRENTLY ix_test1_updated_at ON entity_change_history (updated_at);<br>ALTER TABLE test12 ADD COLUMN created_by VARCHAR(50);</pre><figure><img alt="" src="https://cdn-images-1.medium.com/max/938/1*Xo8UJsXRfynHwYnLnJaRpQ.png" /><figcaption>그림4. review bot 활용사례:<br>ddl 리뷰 특이사항 없음</figcaption></figure><p>또한 DBA-리뷰봇은 DDL신청에 대한 리뷰 기능뿐만 아니라, 요기요의 공식 업무용 메신저인 Slack에 챗봇 형태로 배포되어 활용되고 있습니다.</p><p>이를 통해 개발자와 DBA는 별도의 페이지로 이동하지 않고도, 필요한 시점에 바로 리뷰봇을 호출할 수 있습니다.</p><p>예를 들어 DDL 요청을 준비하면서 참고해야 할 <strong>사내 DB 관리 규정이나 가이드가 필요한 경우</strong>, 기존처럼 Wiki 문서를 직접 찾아보지 않아도 Slack에서 리뷰봇에게 질문하고 관련 정보를 바로 확인할 수 있습니다. 또한 DDL 리뷰 결과 역시 Slack 대화를 통해 빠르게 공유할 수 있어, 커뮤니케이션 비용을 줄이는 데에도 도움이 됩니다.</p><p>DBA-리뷰봇은 Amazon Bedrock의 Knowledge Base와 Agent 기능을 활용해 구현되었으며, 사내 규정 문서를 지식으로 활용해 DDL 요청과 질의에 대한 응답을 생성합니다. 리뷰봇은 DDL을 자동으로 승인하거나 실행하지 않으며, 어디까지나 DBA 리뷰의 앞단에서 동작하는 <strong>보조 도구</strong>로 설계되었습니다.</p><p>이를 통해 반복적인 리뷰 작업과 규정 확인에 소요되는 시간과 오류를 줄이고, DBA와 개발팀 모두가 보다 중요한 판단과 논의에 더욱 집중할 수 있을 것으로 기대됩니다.</p><h4>Amazon Bedrock을 사용한 이유</h4><p>DBA-리뷰봇은 사내 DB 관리 규정과 같은 내부 문서를 기반으로 동작하는 생성형 AI 애플리케이션입니다. 이를 구현하기 위해서는 문서 수집, 임베딩, 검색, 추론, 응답 생성까지의 전 과정을 고려해야 했습니다.</p><p>하지만 제한된 시간과 리소스 안에서, 이러한 파이프라인을 처음부터 직접 구현하고 운영하기에는 현실적인 부담이 컸습니다. 특히 모델 선택과 운영, 보안 설정, 인프라 관리까지 함께 고려해야 하는 상황에서, 개발 공수가 빠르게 증가할 수 있었습니다.</p><p>이러한 배경에서 저희팀은 Amazon Bedrock을 선택했습니다. Bedrock은 Knowledge Base와 Agent 기능을 통해, 내부 문서를 지식으로 활용하는 생성형 AI 애플리케이션을 비교적 적은 구현 비용으로 구성할 수 있는 환경을 제공합니다.</p><p>이를 통해 모델 운영이나 인프라 구성에 대한 부담을 줄이고, 리뷰봇의 핵심 로직과 실제 사용 시나리오에 집중할 수 있었으며, DBA 팀이 직접 모델 운영을 감당하지 않아도 된다는 점이 결정적이었습니다.</p><h4>DBA-review bot 아키텍처</h4><p>현재 DBA-리뷰봇에서 Amazon Bedrock Knowledge 및 Agent를 이용해 답변하는 아키텍처는 다음과 같습니다.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*XztUfBb1kcGaN7S0Q6NukA.png" /><figcaption>그림5. bedrock을 활용한 AI DBA-review bot 아키텍처</figcaption></figure><ol><li>사용자가 DBportal로 DDL 신청을 하거나, Slack에서 @dbadmin-bot 을 멘션하여 직접 질의를 던집니다.</li><li>DBportal을 통해 DDL신청이 완료되었으면, Slack으로 신청에 대한 스레드가 오픈되고, 신청된 ddl에 대하여 Bedrock Agent에게 리뷰요청을 진행합니다.<br>슬랙으로 직접 유저가 리뷰봇을 호출하여 문의하였다면, 해당 이벤트가 api-gateway로 진입됩니다.</li><li>슬랙에서 발생된 이벤트가 api-gateway와 연결된 람다코드로 전송됩니다.</li><li>람다코드에서 이벤트처리를 진행합니다. 이때 Bedrock Agent를 호출하여 Slack에서 문의한 내용을 Agent로 전달합니다.</li><li>에이전트가 전달받은 질의에 대하여 연결된 LLM(claude sonnet3.5)을 이용하여 자연어를 분석하고, 지식기반의 답변이 필요하다면 knowledge base 검색을 시도합니다.</li><li>Knowledge Base에서 해당 답변과 가장 관련성이 높은 정보를 Amazon OpenSearch vector store에서 조회하여 제공합니다.</li><li>Amazon OpenSearch vector store에는 정보들이 벡터화되어 저장되어 있으며, 질문과 관련도가 가장 높은 답변을 찾아 Knowledge Base에 전달하게 되고, Agent는 다시 Knowledge Base로 부터 받은 답변을 사용자에게 제공합니다.</li></ol><h4>Confluence API를 활용한 사내 Wiki 문서 동기화 전략</h4><p>DBA-리뷰봇 프로젝트에서 가장 신경 썼던 부분 중 하나는, <strong>리뷰의 근거가 되는 사내 Wiki 문서를 어떻게 최신 상태로 유지할 것인가</strong>였습니다. 리뷰봇의 판단 품질은 결국 Knowledge Base에 저장된 규정 문서의 정확도와 최신성에 크게 의존하기 때문입니다.</p><p>Amazon Bedrock에서는 S3에 저장된 데이터를 기반으로 벡터 임베딩을 생성하고, 이를 Amazon OpenSearch Vector Store와 연동하는 기능을 서비스 형태로 제공합니다. 따라서 <strong>S3에 최신 문서를 안정적으로 적재 및 업데이트 하는 것</strong>이 전체 파이프라인의 핵심이었습니다.</p><p>문제는 사내 Wiki에 저장된 원본 문서가 언제, 어떤 내용으로 업데이트될지 예측하기 어렵다는 점이었습니다. 이를 해결하기 위해, Wiki 문서를 주기적으로 수집해 S3에 동기화하는 자동화 파이프라인을 구성했습니다.</p><p>구현 방식은 비교적 단순합니다.<br>Cron Job과 Confluence API를 활용해, Wiki에 저장된 문서들을 API로 주기적으로 조회하고 이를 S3에 저장하도록 구성했습니다. 해당 작업은 매일 오전 6시에 실행되도록 설정해, 새로운 문서나 변경 사항이 일정 주기로 반영되도록 관리했습니다.</p><p>S3 업데이트가 완료되면, Amazon Bedrock API를 호출해 <strong>S3와 Vector Store 간의 동기화 및 재임베딩 작업</strong>을 수행합니다. 이 과정을 통해 리뷰봇이 참조하는 Knowledge Base는 별도의 수동 개입 없이도 최신 상태를 유지할 수 있도록 설계했습니다.</p><p>이러한 구조를 통해 Wiki 문서 업데이트 → S3 반영 → Vector Store 갱신까지의 흐름을 자동화했고, DBA는 문서 동기화 상태를 별도로 관리하지 않아도 되는 환경을 만들 수 있었습니다.</p><pre>def main():<br>    &quot;&quot;&quot;<br>    Confluence Wiki → S3 적재 → Bedrock Knowledge Base 동기화(재임베딩)까지<br>    전체 파이프라인의 핵심 흐름만 요약한 예시 코드입니다.<br>    &quot;&quot;&quot;<br># 1) Confluence Wiki 문서 수집 (부모 페이지 기준 + 하위 페이지 포함)<br>    pages = get_confluence_pages_with_children(parent_page_id=PAGE_ID)<br># 2) 문서 내용을 추출해 S3에 저장<br>    for page in pages:<br>        title = page[&quot;title&quot;]<br>        content = download_page_content(page_id=page[&quot;id&quot;])  # body.storage 등에서 텍스트 추출<br>        upload_to_s3(text=content, filename=f&quot;{sanitize_title(title)}.txt&quot;)<br># 3) S3 변경 사항을 Bedrock Knowledge Base에 반영 (Vector Store 갱신)<br>    start_knowledge_base_ingestion(<br>        knowledge_base_id=KB_ID,<br>        data_source_id=DATA_SOURCE_ID,<br>    )<br># --- 아래는 구현 디테일을 숨긴 인터페이스(개념) 수준의 함수들입니다. ---<br>def get_confluence_pages_with_children(parent_page_id: str) -&gt; list[dict]:<br>    &quot;&quot;&quot;Confluence API로 부모/하위 페이지 목록을 수집해 반환&quot;&quot;&quot;<br>    raise NotImplementedError<br>def download_page_content(page_id: str) -&gt; str:<br>    &quot;&quot;&quot;Confluence 페이지의 본문을 내려받아 텍스트로 변환해 반환&quot;&quot;&quot;<br>    raise NotImplementedError<br>def upload_to_s3(text: str, filename: str) -&gt; None:<br>    &quot;&quot;&quot;정제된 텍스트를 S3 지정 경로로 업로드&quot;&quot;&quot;<br>    raise NotImplementedError<br>def start_knowledge_base_ingestion(knowledge_base_id: str, data_source_id: str) -&gt; None:<br>    &quot;&quot;&quot;Bedrock Knowledge Base ingestion job을 실행해 재임베딩/동기화를 트리거&quot;&quot;&quot;<br>    raise NotImplementedError<br>def sanitize_title(title: str) -&gt; str:<br>    &quot;&quot;&quot;S3 객체 키로 안전한 파일명 생성&quot;&quot;&quot;<br>    return &quot;&quot;.join(c for c in title if c.isalnum() or c in (&quot; &quot;, &quot;-&quot;, &quot;_&quot;)).strip()</pre><blockquote>##코드1.- wiki s3 upload &amp; Knowledge Base를 동기화하는 python 코드</blockquote><blockquote><em>Confluence Wiki 문서를 주기적으로 수집해 S3에 저장한 뒤,<br>Amazon Bedrock Knowledge Base의 ingestion job을 호출해 벡터 스토어를 갱신합니다.</em></blockquote><h4>Knowledge Base 구성 방식</h4><p>사내 Wiki 문서를 Knowledge Base에 그대로 적재하는 것만으로는, DDL 리뷰에 필요한 수준의 판단을 기대하기 어려웠습니다. <br>문서의 구조와 표현 방식이 제각각이기 때문에, <strong>리뷰봇이 어떤 규정을 언제 참고해야 하는지</strong>를 명확히 하기 어렵기 때문입니다.</p><p>요기요 DBA팀은 Knowledge Base를 단순한 문서 저장소가 아니라, <strong>DDL 리뷰를 위한 판단 근거 집합</strong>으로 활용하기 위해 문서 구조화에 집중했습니다. 이를 위해 사내 Wiki 문서를 다음과 같은 기준으로 전처리했습니다.</p><p>먼저, 규정 문서를 <strong>DDL작업 단위로 분리</strong>했습니다. 하나의 방대한 내용을 담고있는 문서를 그대로 임베딩하지 않고, DDL 작업유형별로 내용을 나누어 저장했습니다. 예를 들어 CREATE TABLE 규칙, Alter 규칙, Drop 규칙, 인덱스 관련 규칙등을 각각 독립적인 단위로 분리했습니다.</p><p>그리고, 규정 문서뿐만 아니라 <strong>올바른 DDL 예시와 잘못된 DDL 예시를 함께 Knowledge Base에 저장</strong>했습니다. 실제 운영 과정에서 자주 등장하는 요청 패턴을 기준으로, 규정에 부합하는 예시와 그렇지 않은 예시를 함께 정리해 지식으로 활용했습니다. 이를 통해 리뷰봇은 단순히 “규정 문장을 인용하는 역할”을 넘어, <strong>입력된 DDL과 유사한 사례를 기준으로 판단 근거를 제시</strong>할 수 있게 되었습니다.</p><p>이러한 구성 덕분에 리뷰봇은 다양한 형태의 DDL 요청에 대해 보다 폭넓은 검색이 가능해졌고, 결과적으로 <strong>리뷰 응답의 정확성과 일관성</strong>을 함께 개선할 수 있었습니다. 전처리된 문서들은 S3에 저장되고, Amazon Bedrock Knowledge Base 기능을 통해 벡터화되어 관리됩니다. 리뷰봇은 DDL 요청을 입력으로 받아, 관련성이 높은 규정과 예시를 함께 조회하고 이를 기반으로 1차 리뷰 결과를 생성합니다.</p><p>이 구조를 통해 Knowledge Base는 단순한 규정 검색 용도가 아니라, <strong>DDL 리뷰에 필요한 맥락과 사례를 함께 제공하는 판단 보조 도구</strong>로 활용될 수 있었습니다.</p><h4>Agent 설계와 프롬프트 전략</h4><p>DBA-리뷰봇에서 Agent의 역할은 단순히 Knowledge Base를 조회하는 것이 아니라, <strong>DDL 요청을 리뷰 관점에서 해석하고 판단 근거를 정리해 주는 것</strong>입니다. 이를 위해 Agent는 “정답을 생성하는 역할”이 아니라, “DBA 리뷰를 보조하는 조정자(coordinator)”로 설계했습니다.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*oUoXqghar3aeJGJttQsqYw.png" /><figcaption>그림6.-Bedrock Agent 고급 프롬프트를 활용한 프롬프트 엔지니어링</figcaption></figure><blockquote><strong><em>Agent 지침 설계: 최소한의 역할 정의</em></strong></blockquote><p>Agent 지침(instruction)은 의도적으로 최소화했습니다.<br>Agent에게 많은 규칙을 주입하기보다는, <strong>역할과 책임의 경계만 명확히 정의</strong>하는 방향을 선택했습니다.<br>이를 통해 Agent가 과도한 판단이나 불필요한 추론을 하지 않도록 제어하고, <strong>행동 범위를 명확히 제한</strong>했습니다.</p><p>요기요의 DBA-review bot이 현재 사용중인 에이전트 지침입니다.</p><pre>당신은 &quot;DBA-bot&quot;입니다.<br>&quot;요기요(yogiyo)&quot; 회사의 Database 관련하여, ddl작업과 관련된 전문적인 봇입니다.<br>당신의 주요 목표는 사내 DB규정을 참조하여, 개발자들이 요청한 ddl 쿼리에 대해서 옳바르게 신청하였는지 리뷰하고, db관련 정책이나 규칙, 기준등에 대해 질문을 받았을때 옳바르게 답변해야합니다.<br>당신은 숙련된 dba 리뷰 봇으로써 다음 내용들을 꼭 숙지하고있어야 합니다.<br>.<br>.<br>**ONLY RULE: Knowledge Base 검색 → 출력 형식을 변경하지 않는다**<br>**CRITICAL 금지사항:**<br>- KB 응답에 추가 분석, 해석, 설명 금지<br>- KB 응답 형식 변경 금지<br>- 개인적 판단이나 추론 금지<br>- 일반적인 데이터베이스 지식 사용 금지<br>- KB 응답을 재작성, 재구성, 요약 금지<br>.<br>.</pre><blockquote><strong><em>고급 프롬프트 설계: Knowledge Base 응답 템플릿 중심</em></strong></blockquote><p>실제 리뷰 품질을 좌우하는 부분은 Agent 지침보다, <strong>Knowledge Base 검색 결과를 어떻게 해석하고 출력할 것인가</strong>였습니다. 이를 위해 고급 프롬프트에서는 KB 응답을 구조화하는 템플릿 설계에 집중했습니다.</p><p><strong>1. “KB에 없는 내용은 말하지 않는다”는 강한 제약</strong></p><p>DDL 리뷰에서 가장 위험한 것은 그럴듯한 추측입니다. 이를 방지하기 위해 프롬프트 단계에서부터 <strong>지식 범위를 KB로 강제</strong>했습니다.</p><p><strong>2. 질의 유형을 명확히 분리하는 Detection Logic</strong></p><p>DDL 리뷰, 규정 질의, CDC 확인 요청은 <strong>겉보기에는 비슷하지만 완전히 다른 문제</strong>입니다. 이를 구분하지 않으면 응답 품질이 급격히 흔들리기 때문에, 프롬프트에 <strong>명시적인 분기 로직</strong>을 포함시켰습니다. 이 분류는 이후 응답 형식과 판단 기준을 결정하는 핵심 역할을 합니다.</p><ul><li>리뷰 요청인지</li><li>실제 DDL이 포함되었는지</li><li>CDC/Replication 대상 확인인지</li></ul><p><strong>3. risky한 작업은 절대 놓치지 않는 보수적 설계</strong></p><p>DROP, 서비스 영향 가능성이 높은 ALTER 작업, DBA확인필요 키워드는<br> 다른 조건보다 항상 우선 검색하도록 설계했습니다.이를 통해 <strong>작업의 위험성을 개발자와 DBA 모두가 사전에 인지할 수 있도록 하고</strong>,<br> <strong>보다 세부적인 리뷰가 필요한 작업에 자연스럽게 집중할 수 있도록 유도했습니다.</strong></p><p><strong>4. 응답 형식을 고정해 리뷰 효율을 높임</strong></p><p>응답은 항상 동일한 구조로 반환되도록 제한했습니다.</p><ul><li>DBA가 Slack에서 바로 읽을 수 있는 형식</li><li>중요한 부분만 빠르게 스캔 가능</li><li>요청마다 응답 스타일이 달라지지 않음</li></ul><p>이는 리뷰 누락을 줄이고, 가독성을 높이는 데 큰 역할을 했습니다.</p><p>요기요의 DBA-review bot이 현재 사용중인 KB응답 지침입니다.</p><pre>You are a response formatter for DBA-bot.<br>Format all responses in plain Korean text optimized for Slack readability.<br><br>&lt;search_results&gt;<br>$search_results$<br>&lt;/search_results&gt;<br><br>&lt;user_query&gt;<br>$query$<br>&lt;/user_query&gt;<br><br>CRITICAL:<br>- KB에 없는 내용은 절대 언급하지 않음<br>- 추측, 일반론, 경험 기반 판단 금지<br><br>DETECTION LOGIC:<br>1. 리뷰/검증 관련 키워드 확인 <br>2. DDL 구문 포함 여부 확인 <br>3. CDC / Replication 관련 키워드 확인 <br><br><br>RESPONSE FORMAT RULES:<br>- 항상 동일한 구조로 응답<br>- JSON 금지<br>- Slack 가독성을 고려한 이모지 및 불릿 포인트 사용<br><br>RESPONSE FORMAT:<br><br>[Type 1A]<br>DBA 승인 필요<br><br>리뷰 결과:<br>DBA확인필요<br><br><br>[Type 1B]<br>DDL 검증 결과<br><br>리뷰 결과:<br>특이사항 없음 / 확인 필요<br><br>위반사항 (확인 필요 시에만):<br>• KB 검색 결과에 명시된 규칙 위반 항목<br><br>권장사항 (위반사항이 있을 경우):<br>• 규칙을 만족하는 형태의 DDL 쿼리만 제시</pre><h4>결과 및 향후 개선 방향</h4><p>DBA-리뷰봇은 기존 DDL 승인 및 자동 실행 프로세스를 변경하지 않은 상태에서, <strong>리뷰 단계의 앞단에 1차 분석 도구로 추가</strong>되어 운영 환경에 적용되었습니다. 이를 통해 실행 안정성은 유지하면서, 반복적인 규정 확인과 판단 정리로 인한 부담을 줄이는 데 기여했습니다.</p><p>운영 적용 이후에는 개발팀이 DDL 요청 전에 리뷰봇을 통해 사내 규정과 주의사항을 미리 확인하는 흐름이 자리 잡았고, 이로 인해 정보가 부족하거나 규정에 맞지 않는 요청이 초기 단계에서 감소했습니다. DBA 입장에서도 리뷰봇이 정리해 주는 1차 분석 결과를 통해 <strong>관련 규정, 집중 확인 포인트, 추가 확인 필요 항목</strong>을 빠르게 파악할 수 있어, 반복적인 검토 작업을 줄이고 복합적이거나 예외적인 케이스에 보다 집중할 수 있게 되었습니다. 또한 Slack 챗봇 형태로 제공되면서, DDL 검토 외에도 규정 질의 응답 등 다양한 상황에서 자연스럽게 활용되고 있습니다.</p><p>한편 현재 리뷰봇은 사내 규정과 예시를 Knowledge Base로 활용한 <strong>문서 기반 1차 리뷰 도구</strong>로, 테이블 크기나 인덱스 구성과 같이 DB 접속을 통해서만 확인 가능한 정보는 직접 판단할 수 없다는 한계가 있습니다. 향후에는 MCP(Model Context Protocol) 기반의 안전한 연동을 통해 제한된 범위의 메타데이터 조회를 추가하고, 단일 Agent 구조를 <strong>역할이 분리된 멀티 에이전트 협업 구조</strong>로 고도화하여 리뷰 정확도와 일관성을 높이는 방향을 검토하고 있습니다. DBA-리뷰봇은 운영 경험과 데이터가 쌓일수록 점진적으로 고도화되는 도구를 목표로 발전시켜 나갈 계획입니다.</p><h4>마치며</h4><p>DDL 리뷰는 DBA라면 누구나 익숙하지만, 동시에 늘 긴장을 요구하는 작업입니다. <br>요청이 많아질수록, 그리고 변경이 복합적일수록 그 부담은 더 커집니다.</p><p>요기요 DBA팀은 이 문제를 “더 꼼꼼해져야 한다”가 아니라, <br>“판단 과정을 어떻게 더 잘 보조할 수 있을까”라는 질문으로 접근했습니다. <br>DBA-리뷰봇은 그 고민의 결과물로, 단순히 DDL을 검토하는 도구가 아니라 <br>DBA가 암묵적으로 해오던 판단 기준을 명시적으로 구조화한 시도였습니다.</p><p>그 결과, DDL 요청부터 리뷰 완료까지의 전체 흐름에서 <br>불필요한 반복 작업과 커뮤니케이션이 줄어들었고, <br>실제 운영 기준으로 약 20% 수준의 처리 속도 개선을 확인할 수 있었습니다.</p><p>이 글이 비슷한 고민을 하고 있는 다른 DBA나 플랫폼 엔지니어분들께, <br>하나의 접근 방식으로 참고가 되기를 바랍니다.</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=f845508e6055" width="1" height="1" alt=""><hr><p><a href="https://techblog.yogiyo.co.kr/%EC%82%AC%EB%82%B4-db-%EA%B4%80%EB%A6%AC-%EA%B7%9C%EC%A0%95%EC%9D%84-ai%EB%A1%9C-%EC%A0%81%EC%9A%A9%ED%95%98%EB%8B%A4-amazon-bedrock-%EA%B8%B0%EB%B0%98-dba-%EB%A6%AC%EB%B7%B0%EB%B4%87-%EA%B0%9C%EB%B0%9C%EA%B8%B0-f845508e6055">사내 DB 관리 규정을 AI로 적용하다 : Amazon Bedrock 기반 DBA 리뷰봇 개발기</a> was originally published in <a href="https://techblog.yogiyo.co.kr">YOGIYO Tech Blog - 요기요 기술블로그</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[[디자인 시스템 어떻게 만들었어요?(2)] Radix Primitives와 Panda CSS로 유연하고 단단한 컴포넌트 만들기]]></title>
            <link>https://techblog.yogiyo.co.kr/%EB%94%94%EC%9E%90%EC%9D%B8-%EC%8B%9C%EC%8A%A4%ED%85%9C-%EC%96%B4%EB%96%BB%EA%B2%8C-%EB%A7%8C%EB%93%A4%EC%97%88%EC%96%B4%EC%9A%94-2-radix-primitives%EC%99%80-panda-css%EB%A1%9C-%EC%9C%A0%EC%97%B0%ED%95%98%EA%B3%A0-%EB%8B%A8%EB%8B%A8%ED%95%9C-%EC%BB%B4%ED%8F%AC%EB%84%8C%ED%8A%B8-%EB%A7%8C%EB%93%A4%EA%B8%B0-7ead98362e17?source=rss----c1b33ccbbc42---4</link>
            <guid isPermaLink="false">https://medium.com/p/7ead98362e17</guid>
            <category><![CDATA[post]]></category>
            <category><![CDATA[tech]]></category>
            <category><![CDATA[yogiyo]]></category>
            <dc:creator><![CDATA[Sohyeon Ye]]></dc:creator>
            <pubDate>Thu, 11 Dec 2025 00:37:33 GMT</pubDate>
            <atom:updated>2025-12-11T00:37:14.125Z</atom:updated>
            <content:encoded><![CDATA[<figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*JHCRPWL0UKhWrnIx9MQkpg.png" /></figure><p>지난번 YDS(Yogiyo Design System) v2 <a href="https://techblog.yogiyo.co.kr/%EB%94%94%EC%9E%90%EC%9D%B8-%EC%8B%9C%EC%8A%A4%ED%85%9C-%EC%96%B4%EB%96%BB%EA%B2%8C-%EB%A7%8C%EB%93%A4%EC%97%88%EC%96%B4%EC%9A%94-1-%EC%95%84%EC%9D%B4%EC%BD%98-%EB%9D%BC%EC%9D%B4%EB%B8%8C%EB%9F%AC%EB%A6%AC-%EB%A7%8C%EB%93%A4%EA%B8%B0-9ae3557f3070">아이콘 라이브러리 구축기</a>에 이어, 이번 글에서는 YDS의 핵심인 ‘컴포넌트 라이브러리’ 재구축 경험을 공유하고자 합니다.</p><p>이번 개편의 핵심적인 기술 변화는 크게 4가지로 요약할 수 있습니다.</p><ol><li>Radix Primitives를 도입하여 높은 수준의 웹 접근성을 확보하고, 컴포넌트 확장성과 유연성 확보</li><li>스타일 라이브러리를 styled-components에서 Panda CSS로 전환</li><li>Barrel File 기반 단일 엔트리 구조의 Tree Shaking 문제를 진단하고, 빌드 시스템을 개선해, 서비스 애플리케이션의 First Load JS 용량 최적화</li><li>구 버전 브라우저 호환성 제공</li></ol><p>이 글에서는 위 주제 중 첫 번째와 두 번째 경험을 중심으로 이야기해 보겠습니다.</p><h3>들어가며</h3><p>컴포넌트 라이브러리를 만들 때 보통 Button, BottomSheet, TextField 같은 컴포넌트가 필요합니다. 이를 구현하는 방식은 크게 두 가지, 즉 직접 만들거나 기성 UI 라이브러리를 사용하는 방식으로 나뉩니다.</p><p>직접 구현하는 방식을 선택하면, 단순히 시간이 오래 걸리는 것을 넘어 지속적인 버그 수정이나 브라우저 호환성 테스트 등 높은 유지보수 비용이 발생합니다. 또한, 구현하는 개발자가 높은 수준의 웹 접근성 지식을 갖추고 있음을 보장해야 하므로, 라이브러리를 운영하는 전담팀이 부재한 상황이라면 이는 전략적으로 불리한 선택이 될 수 있습니다.</p><p>반면, MUI나 Ant Design 같은 기성 UI 라이브러리를 도입하면 검증된 접근성 구현, 브라우저 호환성 그리고 풍부한 문서와 커뮤니티 지원을 활용하여 개발 속도를 크게 향상할 수 있습니다.</p><p>하지만 이 방식 역시 요기요처럼 고유한 사내 디자인 시스템이 이미 확립되어 운영 중인 상황이라면, 새로운 문제에 부딪히게 됩니다. 필연적으로 라이브러리의 기본 스타일을 덮어쓰는(override) 코드가 증가합니다. 또한, 디자인 시스템은 단순히 시각적 형태뿐만 아니라 동작 방식과 API(props)까지 상세히 정의하는데, 이것이 라이브러리에서 정의한 API와 충돌할 수 있습니다.</p><p>요기요 FE에서는 이 두 가지 방식이 가진 딜레마를 해결하기 위해, Radix Primitives를 도입하고, 스타일링 엔진으로 Panda CSS를 사용하여, 사내 디자인 시스템의 디테일을 일치시키면서도 높은 수준의 구현 품질을 달성할 수 있었습니다.</p><h3>Radix Primitives 도입하여 컴포넌트 디자인하기</h3><h4>Radix Primitives</h4><p>앞서 설명해 드린 대로, 기성 UI 라이브러리는 사전 정의된 스타일로 빠른 개발을 돕지만, 고유한 디자인 시스템을 구현할 때는 제약이 되기도 합니다. Headless UI는 이러한 문제를 해결하는 접근 방식으로, 스타일링은 완전히 분리하고 컴포넌트의 핵심 로직, 상태관리, 웹 접근성만을 제공합니다.</p><p><a href="https://www.radix-ui.com/primitives/docs/overview/introduction">Radix Primitives</a>는 바로 이 Headless UI 철학에 기반을 둔 로우 레벨(low-level) UI 컴포넌트 라이브러리입니다. 따라서 Radix Primitives는 CSS-in-JS, CSS Modules, Tailwind CSS 등 어떤 스타일링 솔루션하고도 완벽하게 통합될 수 있습니다. Radix는 상태기반 Data Attributes를 제공하여 스타일링을 지원할 뿐, 최종적인 구현 방식의 선택권은 개발자에게 있습니다. 개발자는 WAI-ARIA 패턴 준수, 키보드 내비게이션, 포커스 관리 같은 복잡한 <a href="https://www.radix-ui.com/primitives/docs/overview/accessibility">접근성 구현</a>은 Radix Primitives에 위임하고, 비즈니스 로직과 디자인 구현에만 집중할 수 있습니다. 또한, Radix의 주요 철학인 Open Component Architecture를 통해, 개발자는 각 컴포넌트 파트에 접근하여 커스텀 이벤트 리스너, props, ref를 자유롭게 추가하며 확장성을 확보할 수 있습니다. 마지막으로, Radix에서 제공하는 모든 컴포넌트는 기본적으로 Uncontrolled 방식으로 작동하여 별도의 상태 관리 없이 사용할 수 있으며, 필요시 Controlled 방식으로도 제어할 수 있습니다.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*ADWQJQI0g3RRgmnAx2Zgsw.png" /><figcaption>Radix primitives Tab 컴포넌트의 키보드 지원 기능</figcaption></figure><h4>컴포넌트 디자인하기</h4><figure><img alt="" src="https://cdn-images-1.medium.com/max/374/1*sGjV3WG52sjTjTUiZWcOGQ.gif" /><figcaption>YDS v2 Tab 컴포넌트</figcaption></figure><p>예시로 Tab 컴포넌트를 살펴보겠습니다. Tab은 유저가 탭(Trigger)을 선택하여 연관된 콘텐츠 섹션을 전환하는 UI입니다. Radix Primitives는 Open Component Architecture를 따르며, 개발자가 컴포넌트의 각 구성 요소에 접근할 수 있도록 다음과 같은 Anatomy를 제공합니다.</p><pre>import { Tabs } from &quot;radix-ui&quot;;<br><br>export default () =&gt; (<br> &lt;Tabs.Root&gt;<br>  &lt;Tabs.List&gt;<br>   &lt;Tabs.Trigger /&gt;<br>  &lt;/Tabs.List&gt;<br>  &lt;Tabs.Content /&gt;<br> &lt;/Tabs.Root&gt;<br>);</pre><ul><li><a href="https://www.radix-ui.com/primitives/docs/components/tabs#root">Root</a> : 전체 탭 컴포넌트 파트들을 감싸는 요소입니다. Controlled 방식으로 사용할 경우 value, onValueChange prop을 사용하고, Uncontrolled 방식으로 사용할 경우 defaultValue prop을 사용하여 상태를 관리할 수 있습니다.</li><li><a href="https://www.radix-ui.com/primitives/docs/components/tabs#list">List</a> : Trigger들을 감싸는 요소입니다.</li><li><a href="https://www.radix-ui.com/primitives/docs/components/tabs#trigger">Trigger</a> : 연관된 콘텐츠를 활성화하는 버튼입니다.</li><li><a href="https://www.radix-ui.com/primitives/docs/components/tabs#content">Content</a> : Trigger와 연관된 콘텐츠를 포함하는 요소입니다.</li></ul><p>YDS v2 구현 코드에서는 Primitives 컴포넌트를 랩핑하여, 내부적으로 필요한 로직은 캡슐화하면서도 외부에서 각 컴포넌트 파트에 Event Listener, Props, Ref를 유연하게 주입할 수 있도록 설계했습니다.</p><p>TabsList 컴포넌트는 활성 탭의 위치를 추적하여 Indicator를 올바르게 배치하기 위해, 내부적으로 DOM 요소에(listRef) 접근해야 합니다. 동시에, 컴포넌트 라이브러리를 사용하는 개발자가 ref를 통해 해당 요소를 제어할 수 있도록 forwardRef도 지원해야 합니다. React에서 하나의 Element에 두 개의 ref를 할당할 수는 없으므로 Radix에서 제공하는 composeRef 유틸리티를 사용하여, 내부의 listRef와 외부의 ref를 병합하였습니다. 이를 통해 캡슐화된 내부 로직(Indicator 위치 계산)을 수행하면서도 열린 인터페이스를 유지할 수 있었습니다.</p><pre>// YDS v2 Tabs Component 구현<br><br>import * as TabsPrimitive from &#39;@radix-ui/react-tabs&#39;;<br>import { composeRefs } from &#39;@radix-ui/react-compose-refs&#39;;<br>import { tabGroup } from &#39;@yogiyo/yds2-styled-system/recipes&#39;;<br><br>export const TabsRoot = forwardRef&lt;HTMLDivElement, TabsRootProps&gt;(<br>  ({ className, activationMode = &#39;automatic&#39;, dir = &#39;ltr&#39;, children, ...rest }, ref) =&gt; {<br>    return (<br>      &lt;TabsPrimitive.Root<br>        className={className}<br>        activationMode={activationMode}<br>        dir={dir}<br>        ref={ref}<br>        {...rest}<br>      &gt;<br>        {children}<br>      &lt;/TabsPrimitive.Root&gt;<br>    );<br>  }<br>);<br><br>export const TabsList = forwardRef&lt;HTMLDivElement, TabsListProps&gt;(<br>  ({ fixed, className, children, ...rest }, ref) =&gt; {<br>    const classes = tabGroup({ fixed });<br><br>    const listRef = useRef&lt;HTMLDivElement | null&gt;(null);<br>    const { activeTriggerRect, activeTriggerDisabled } = useActiveTrigger({ listRef });<br><br>    return (<br>      &lt;TabsListContextProvider value={useMemo(() =&gt; ({ fixed }), [fixed])}&gt;<br>        &lt;TabsPrimitive.List<br>          ref={composeRefs(listRef, ref)}<br>          className={cx(classes.tabsList, className)}<br>          {...rest}<br>        &gt;<br>          {children}<br>          &lt;TabsIndicator<br>            left={activeTriggerRect.left}<br>            width={activeTriggerRect.width}<br>            isTriggerActiveAndDisabled={activeTriggerDisabled}<br>          /&gt;<br>        &lt;/TabsPrimitive.List&gt;<br>      &lt;/TabsListContextProvider&gt;<br>    );<br>  }<br>);<br><br>export const TabsTrigger = forwardRef&lt;HTMLButtonElement, TabsTriggerProps&gt;(<br>  ({ className, children, value, disabled, ...rest }, ref) =&gt; {<br>    const { fixed } = useTabsListContext();<br>    const classes = tabGroup({ fixed });<br><br>    return (<br>      &lt;TabsPrimitive.Trigger<br>        ref={ref}<br>        className={cx(classes.tabsTrigger, className)}<br>        value={value}<br>        disabled={disabled}<br>        {...rest}<br>      &gt;<br>        {children}<br>      &lt;/TabsPrimitive.Trigger&gt;<br>    );<br>  }<br>);<br><br>export const TabsContent = forwardRef&lt;HTMLDivElement, TabsContentProps&gt;(<br>  ({ className, children, value, ...rest }, ref) =&gt; {<br>    const classes = tabGroup();<br><br>    return (<br>      &lt;TabsPrimitive.Content<br>        ref={ref}<br>        className={cx(classes.tabsContent, className)}<br>        value={value}<br>        {...rest}<br>      &gt;<br>        {children}<br>      &lt;/TabsPrimitive.Content&gt;<br>    );<br>  }<br>);</pre><p>Radix UI는 컴포넌트의 기능적 상태를 data-state=&quot;active&quot;와 같은 Data Attributes를 DOM에 자동 반영해줍니다. 이 점을 활용하여, Panda CSS의 Slot Recipe내에서 해당 속성을 타겟팅하는 Selector를 작성하여 스타일을 분기 처리했습니다. 이를 통해 자바스크립트 단에서 클래스 명을 토글하는 번거로움 없이, 상태에 따른 스타일을 선언적으로 관리할 수 있게 되었습니다.</p><pre>// Panda CSS를 사용한 YDS v2 Tabs 컴포넌트 스타일 레시피 정의<br><br>import { defineSlotRecipe } from &#39;@pandacss/dev&#39;;<br><br>export const tabGroupRecipe = defineSlotRecipe({<br>  className: &#39;tab-group&#39;,<br>  description: &#39;TabGroup 컴포넌트의 스타일&#39;,<br>  slots: [&#39;tabsList&#39;, &#39;tabsTrigger&#39;, &#39;tabsIndicator&#39;, &#39;tabsContent&#39;],<br>  base: {<br>    // ... (중략)<br>    tabsTrigger: {<br>      padding: &#39;{spacing.semantic.s5} {spacing.semantic.s6}&#39;,<br>      overflow: &#39;hidden&#39;,<br>      whiteSpace: &#39;nowrap&#39;,<br>      color: &#39;{colors.semantic.variant.gray800}&#39;,<br>      textStyle: &#39;body_2&#39;,<br>      textOverflow: &#39;ellipsis&#39;,<br>      // data attribute injected by Radix UI primitive<br>      &#39;&amp;[data-state=active]&#39;: {<br>        textShadow: &#39;-0.06ex 0 0 currentColor, 0.06ex 0 0 currentColor&#39;,<br>        &#39;@supports (-webkit-text-stroke-width: 0.04ex)&#39;: {<br>          textShadow: &#39;-0.03ex 0 0 currentColor, 0.03ex 0 0 currentColor&#39;,<br>          WebkitTextStrokeWidth: &#39;0.04ex&#39;,<br>        },<br>      },<br>      &#39;@media (hover: hover)&#39;: {<br>        &#39;&amp;:hover:not(:active)&#39;: {<br>          backgroundColor: &#39;{colors.semantic.states.overlay_k.hovered}&#39;,<br>        },<br>      },<br>      _active: {<br>        backgroundColor: &#39;{colors.semantic.states.overlay_k.pressed}&#39;,<br>      },<br>      _focusVisible: {<br>        backgroundColor: &#39;{colors.semantic.states.overlay_k.focused}&#39;,<br>      },<br>      _disabled: {<br>        color: &#39;{colors.semantic.variant.gray250}&#39;,<br>        backgroundColor: &#39;{colors.semantic.states.disabled}&#39;,<br>      },<br>    },<br>  },<br>});</pre><h3>styled-components에서 Panda CSS로</h3><h4>Goodbye styled-components</h4><p>styled-components는 React 생태계에서 CSS-in-JS 패러다임을 사실상의 표준으로 정착시킨 상징적인 라이브러리입니다. 지난 수년간 모던 프론트엔드 개발의 필수 도구로 자리 잡았으나, Runtime CSS Generation 방식이 가지는 성능 오버헤드와 React 18 이후의 변화(RSC)로 인해 새로운 국면을 맞이하게 되었습니다. 특히 지난 2025년 3월, styled-components는 <a href="https://opencollective.com/styled-components/updates/thank-you">Maintenance 모드로 전환</a>됨을 알렸습니다. styled-components는 훌륭한 도구였고 여전히 현역이지만, 런타임 오버헤드를 제거하고 RSC와 호환되는 미래 지향적인 아키텍처를 수립하기 위해 요기요 FE에서는 스타일 라이브러리 변경을 결정하게 되었습니다.</p><h4>Welcome Panda CSS</h4><p>요기요 FE에서는 새로운 기술을 도입하기에 앞서, 현재 프론트엔드 생태계에 존재하는 다양한 스타일링 패러다임을 원점에서부터 다시 검토했습니다.</p><p>먼저 검토한 대안은 Sass나 CSS Modules와 같은 전통적인 방식이었습니다. 이들은 빌드 시점에 표준 CSS 파일을 생성하므로 런타임 오버헤드가 없고, 안정적입니다. 그러나 컴포넌트 로직과 스타일이 강하게 결합된 CSS-in-JS의 직관적인 개발 경험을 포기하기란 쉽지 않았습니다.</p><p>Utility-First 패러다임의 대명사인 Tailwind CSS 또한 역시 검토 대상이었습니다. 사용된 스타일만 추출하여 Atomic CSS를 생성하는 메커니즘은 유의미한 장점이 있지만, 문자열 기반 스타일링이 갖는 유지보수 리스크가 우려되었습니다. 보완 솔루션을 통해 자동완성과 Linting 기능의 도움을 받을 수 있지만, 만약 잘못된 유틸리티를 작성하고 빌드한다면 에러를 발생시키지 않아, 안정적인 컴포넌트 라이브러리 운영을 고려했을 때 리스크가 있다고 판단하였습니다.</p><p>이러한 고민 끝에 Zero-runtime CSS-in-TS 패러다임에 주목했습니다. 개발 단계에서는 익숙한 CSS-in-JS 문법과 Typescript의 이점을 누리면서, 빌드 결과물은 Tailwind CSS처럼 최적화된 static CSS로 추출되기 때문입니다. 이 패러다임을 구현한 여러 라이브러리 중에서 저는 Panda CSS를 선택했습니다. 가장 문서화가 잘 되어있고, styled 패턴을 제공하여 팀원들의 마이그레이션의 러닝 커브를 최소화 할 수 있다는 점이 결정적이었습니다.</p><h4>다양한 소비 환경을 고려한 토큰 라이브러리</h4><p>이론적으로 컴포넌트 라이브러리가 표준 CSS 파일을 제공한다면, 이를 사용하는 서비스 앱은 스타일링 라이브러리 선택에 제약을 받지 말아야 합니다. 하지만 디자인 시스템의 핵심인 토큰과 타이포그래피를 일관성 있게 사용하기 위해 결국 Preset을 상속받아야 하는 딜레마가 발생합니다. 이는 서비스 앱 또한 컴포넌트 라이브러리와 동일한 스타일링 의존성을 강제 받게 됨을 시사합니다.</p><p>이러한 구조적 의존성을 탈피하기 위해, yds2-tokens 토큰 라이브러리에서는 각 스타일링 환경의 특성을 극대화할 수 있는 데이터 구조로 변환하여 제공하는 전략을 채택했습니다.</p><p>예를 들어 Sass(SCSS) 환경을 지원하기 위해, 단순히 Flat 한 CSS 변수 리스트를 나열하는 것을 넘어 디자인 토큰의 계층 구조를 보존한 Map 객체를 제공했습니다. 개발자는 이를 통해 map.get 기반의 헬퍼 함수를 정의할 수 있으며, 디자인 시스템이 정의한 논리적 위계를 따라 탐색하여 접근할 수 있습니다.</p><pre>/* variables.module.scss */<br><br>@use &#39;sass:map&#39;;<br>@use &#39;@yogiyo/yds2-tokens/sass&#39; as *;<br><br>@function token($keys...) {<br>  @return map.get($tokens, $keys...);<br>}<br><br>/* page.module.scss */<br><br>.title {<br>  font-size: token(&#39;fontSizes&#39;, &#39;title_2&#39;);<br>  color: token(&#39;colors&#39;, &#39;semantic&#39;, &#39;foundation&#39;, &#39;primary&#39;);<br>}</pre><h4>Panda CSS와 라이브러리</h4><p><a href="https://panda-css.com/docs/guides/component-library">Panda CSS 공식 문서</a>에는 라이브러리 개발 시 다음 4가지 접근 방식을 제안합니다.</p><ol><li><strong>Panda Preset 배포</strong> : 라이브러리가 컴포넌트가 아닌 Tokens, Patterns, Recipes만 배포하는 방식입니다.</li><li><strong>Static CSS 파일 배포</strong> : 컴포넌트 라이브러리 빌드 타임에 생성된 Static CSS 파일을 배포하는 방식입니다. 라이브러리를 사용하는 서비스 앱에서 Panda CSS 사용하지 않는 상황에서 권장됩니다.</li><li><strong>외부 패키지로 Panda를 사용하고, 소스 코드 파일 배포</strong> : 컴포넌트 라이브러리의 소스 코드를 직접 포함하여 서비스 앱에서 함께 빌드하는 방식입니다. 컴포넌트 라이브러리와 서비스 앱 코드가 모노레포 내에 존재하여 통합적으로 관리하고 최적화하고자 할 때 권장됩니다.</li><li><strong>외부 패키지로 Panda를 사용하고, 빌드 정보 파일 배포</strong> : 컴포넌트 라이브러리의 소스 코드 대신 panda.buildinfo.json 파일만 배포하는 방식입니다. panda.buildinfo.json은 라이브러리에서 사용된 모든 스타일 정보를 포함하는 메타데이터 파일로, 이 파일을 통해 Panda CSS는 소스 코드 없이도 올바른 CSS를 생성할 수 있습니다. 이 방법은 라이브러리 코드가 비공개로 유지되어야 하지만, 라이브러리를 사용하는 서비스 앱은 Panda CSS를 사용하는 환경일 때 권장됩니다.</li></ol><pre>// YDS v2 저장소 구조<br><br>packages/<br>├── yds2-tokens/            # 디자인 토큰 (JSON 기반, SCSS/Tailwind 지원)<br>├── yds2-panda-preset/      # Panda CSS Preset (Tokens, Patterns, Recipes)<br>├── yds2-styled-system/     # Panda CSS 빌드 결과물<br>│   ├── css/<br>│   ├── tokens/<br>│   ├── recipes/<br>│   ├── patterns/<br>│   ├── jsx/<br>│   ├── styles.css          # Static CSS<br>│   └── panda.buildinfo.json # Panda CSS 스타일 정보 메타데이터<br>├── yds2-react/             # React 컴포넌트 라이브러리 <br>└── yds2-icons/             # 아이콘 라이브러리</pre><p>요기요 FE 환경에서는 컴포넌트 라이브러리와 서비스 앱이 모노레포 내에 함께 존재하지 않습니다. 따라서 세 번째 방식을 제외하고, 서비스 앱의 스타일 소비 환경에 따라서 개발자가 선택적으로 사용할 수 있도록 지원하고 있습니다.</p><h4>Preset부터 컴포넌트까지</h4><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*SR9NiVaKrk9p7NqpnFq7aQ.png" /><figcaption>YDS v2 Badge 컴포넌트</figcaption></figure><p>아래 내용에서는 Badge 컴포넌트를 예로 들어 YDS v2 저장소의 각 프로젝트에서 Panda CSS가 어떻게 사용되는지 간략하게 살펴보겠습니다.</p><p>yds2-panda-preset은 디자인 시스템에서 정의한 디자인 토큰(Color, Radius, Spacing 등), 타이포그래피, 컴포넌트 스타일 등 스타일링에 필요한 정의들을 담은 모음집입니다. 예를 들어, YDS v2에서 디자인 토큰들은 Primitive Level의 Meta Token과 Semantic Level의 Semantic Token으로 구성되어 있습니다. yds2-panda-preset은 이러한 계층 구조를 반영하여 토큰을 정의하고, 나아가 이를 활용한 컴포넌트들의 Recipe까지 포괄합니다.</p><pre>import { definePreset, defineSlotRecipe } from &#39;@pandacss/dev&#39;;<br><br>export const ydsPreset = definePreset({<br>  name: &#39;@yogiyo/yds2-panda-preset&#39;,<br>  // ... (중략)<br>  theme: {<br>    tokens: {<br>      colors: {<br>        meta: {<br>          pink500: { value: &#39;#FA0050&#39; },<br>          pink100: { value: &#39;#FECCDC&#39; },<br>          pink50: { value: &#39;#FFE6EE&#39; },<br>        },<br>      },<br>    },<br>    semanticTokens: {<br>      colors: {<br>        semantic: {<br>          foundation: {<br>            primary: { value: &#39;{colors.meta.pink500}&#39; },<br>          },<br>          variant: {<br>            primary100: { value: &#39;{colors.meta.pink100}&#39; },<br>            primary50: { value: &#39;{colors.meta.pink50}&#39; },<br>          },<br>        },<br>      },<br>    },<br>    recipes: {<br>      badge: defineSlotRecipe({<br>        className: &#39;badge&#39;,<br>        slots: [&#39;container&#39;, &#39;text&#39;],<br>        base: {<br>          container: { display: &#39;inline-flex&#39;, alignItems: &#39;center&#39;, minWidth: 0 },<br>          text: { whiteSpace: &#39;nowrap&#39;, overflow: &#39;hidden&#39;, textOverflow: &#39;ellipsis&#39; },<br>        },<br>        variants: {<br>          colorStyle: {<br>            primary: {<br>              container: {<br>                background: &#39;{colors.semantic.variant.primary50}&#39;,<br>                borderColor: &#39;{colors.semantic.variant.primary100}&#39;,<br>                color: &#39;{colors.semantic.foundation.primary}&#39;,<br>              },<br>            },<br>            // ... other variants<br>          },<br>        },<br>        defaultVariants: {<br>          colorStyle: &#39;primary&#39;,<br>        },<br>      }),<br>    },<br>  },<br>});</pre><p>Panda CSS의 Styled System은 우리가 정의한 Preset을 정적 분석하여, 개발시 즉시 사용할 수 있는 Type-safe한 유틸리티와 CSS 클래스를 자동으로 생성합니다. 즉, 앞서 설계한 토큰과 레시피가 실제 코드로 구체화 됩니다. 아래 생성된 코드에서 볼 수 있듯이, meta와 semantic 토큰은 ColorToken Union 타입으로 매핑되어, 토큰을 사용하는 곳에서 IDE 자동완성을 지원받을 수 있게 합니다. Badge 컴포넌트 또한 size, colorStyle 등의 Variant를 제어할 수 있는 함수 형태의 레시피로 변환된 것을 확인할 수 있습니다. 이를 통해 개발자는 복잡한 디자인 규칙을 암기할 필요 없이, 타입의 도움을 받아 안전하고 일관성 있게 UI를 구현할 수 있습니다. 또한, 제공되는 cx 함수는 레시피의 정의된 스타일과 외부 Props 스타일을 병합합니다. 덕분에 디자인 시스템이 정의한 컴포넌트의 고유한 스타일을 유지하면서도, 필요에 따라 마진이나 위치 등을 자유롭게 추가할 수 있는 유연한 확장성을 갖추게 됩니다.</p><pre>// packages/yds2-styled-system/tokens/tokens.d.ts<br><br>export type ColorToken = <br>  | &quot;meta.gray900&quot; <br>  | &quot;meta.blue500&quot; <br>  // ... meta<br>  | &quot;semantic.foundation.primary&quot; <br>  | &quot;semantic.background.primary_bg&quot; <br>  | &quot;semantic.states.disabled&quot;;<br>  // ... semantic<br><br><br>// packages/yds2-styled-system/recipes/badge.mjs<br><br>export const badge = /* @__PURE__ */ Object.assign(badgeFn, {<br>  __name__: &#39;badge&#39;,<br>  variantKeys: [<br>    &quot;size&quot;,<br>    &quot;colorStyle&quot;<br>  ],<br>  variantMap: {<br>    &quot;size&quot;: [&quot;small&quot;, &quot;medium&quot;],<br>    &quot;colorStyle&quot;: [&quot;primary&quot;, &quot;secondary&quot;, &quot;gray&quot;, &quot;dimmed&quot;]<br>  },<br>  // ...<br>})<br><br>// packages/yds2-styled-system/styles.css<br><br>.yds2-badge__container {<br>  gap: var(--yds2-spacing-semantic-s1);<br>  border-radius: var(--yds2-radii-semantic-r1);<br>  display: inline-flex;<br>  align-items: center;<br>  box-sizing: border-box;<br>  min-width: 0;<br>}<br><br>.yds2-badge__container,<br>.yds2-badge__text {<br>  overflow: hidden;<br>  white-space: nowrap;<br>  text-overflow: ellipsis;<br>}<br><br>.yds2-badge__container--size_medium {<br>  padding: 0px var(--yds2-spacing-semantic-s3);<br>  height: 22px;<br>}<br><br>.yds2-badge__text--size_medium {<br>  font-size: var(--yds2-font-sizes-body_9);<br>  font-weight: var(--yds2-font-weights-body_9);<br>  line-height: var(--yds2-line-heights-body_9);<br>}<br><br>.yds2-badge__container--colorStyle_primary {<br>  background: var(--yds2-colors-semantic-variant-primary50);<br>  border: 1px solid;<br>  border-color: var(--yds2-colors-semantic-variant-primary100);<br>  color: var(--yds2-colors-semantic-foundation-primary);<br>}<br><br>.yds2-badge__container--size_small {<br>  padding: 0px var(--yds2-spacing-semantic-s2);<br>  height: 18px;<br>}<br><br>.yds2-badge__text--size_small {<br>  font-size: var(--yds2-font-sizes-caption_1);<br>  font-weight: var(--yds2-font-weights-caption_1);<br>  line-height: var(--yds2-line-heights-caption_1);<br>}<br><br>.yds2-badge__container--colorStyle_secondary {<br>  background: var(--yds2-colors-semantic-variant-secondary50);<br>  border: 1px solid;<br>  border-color: var(--yds2-colors-semantic-variant-secondary100);<br>  color: var(--yds2-colors-semantic-foundation-secondary);<br>}<br><br>.yds2-badge__container--colorStyle_gray {<br>  background: var(--yds2-colors-semantic-variant-gray50);<br>  border: 1px solid;<br>  border-color: var(--yds2-colors-semantic-variant-gray100);<br>  color: var(--yds2-colors-semantic-foundation-gray);<br>}<br><br>.yds2-badge__container--colorStyle_dimmed {<br>  background: var(--yds2-colors-semantic-states-disabled);<br>  color: var(--yds2-colors-semantic-variant-gray400);<br>}</pre><pre>// package/yds2-react/components/badge/badge.tsx<br><br>import { cx } from &#39;@yogiyo/yds2-styled-system/css&#39;;<br>import { type BadgeVariantProps, badge } from &#39;@yogiyo/yds2-styled-system/recipes&#39;;<br><br>export interface BadgeProps<br>  extends BadgeVariantProps,<br>    BadgeSideIconProps,<br>    Yds2ComponentProps&lt;&#39;div&#39;&gt; {}<br><br>export const Badge = forwardRef&lt;HTMLDivElement, BadgeProps&gt;(function Badge(<br>  { size, colorStyle, className, leftIcon, rightIcon, children, ...props },<br>  ref<br>) {<br>  const classes = badge({ size, colorStyle });<br><br>  return (<br>    &lt;div className={cx(classes.container, className)} ref={ref} {...props}&gt;<br>      {leftIcon &amp;&amp; &lt;BadgeSideIcon size={size}&gt;{leftIcon}&lt;/BadgeSideIcon&gt;}<br>      &lt;span className={classes.text}&gt;{children}&lt;/span&gt;<br>      {rightIcon &amp;&amp; &lt;BadgeSideIcon size={size}&gt;{rightIcon}&lt;/BadgeSideIcon&gt;}<br>    &lt;/div&gt;<br>  );<br>});</pre><h3><strong>마치며</strong></h3><p>돌이켜 보면 Radix Primitives에게 복잡한 상태관리와 웹 접근성 처리를 맡겨 기능적 단단함을 확보하고, 그 위에서 Panda CSS를 통해 다양한 디자인 맥락에 맞는 유연한 스타일을 입혀가는 과정이었습니다.</p><p>이 조합이 모든 상황의 정답은 아니겠지만, 기능의 안정성과 컴포넌트의 확장성 사이에서 균형을 찾으려 했던 저희의 시도가 비슷한 고민을 하시는 분들께 하나의 유의미한 사례로 남기를 바랍니다.</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=7ead98362e17" width="1" height="1" alt=""><hr><p><a href="https://techblog.yogiyo.co.kr/%EB%94%94%EC%9E%90%EC%9D%B8-%EC%8B%9C%EC%8A%A4%ED%85%9C-%EC%96%B4%EB%96%BB%EA%B2%8C-%EB%A7%8C%EB%93%A4%EC%97%88%EC%96%B4%EC%9A%94-2-radix-primitives%EC%99%80-panda-css%EB%A1%9C-%EC%9C%A0%EC%97%B0%ED%95%98%EA%B3%A0-%EB%8B%A8%EB%8B%A8%ED%95%9C-%EC%BB%B4%ED%8F%AC%EB%84%8C%ED%8A%B8-%EB%A7%8C%EB%93%A4%EA%B8%B0-7ead98362e17">[디자인 시스템 어떻게 만들었어요?(2)] Radix Primitives와 Panda CSS로 유연하고 단단한 컴포넌트 만들기</a> was originally published in <a href="https://techblog.yogiyo.co.kr">YOGIYO Tech Blog - 요기요 기술블로그</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[더 빠르게 주문 상태 전달하기: Live Activities]]></title>
            <link>https://techblog.yogiyo.co.kr/live-activities-0e957c868858?source=rss----c1b33ccbbc42---4</link>
            <guid isPermaLink="false">https://medium.com/p/0e957c868858</guid>
            <category><![CDATA[tech]]></category>
            <category><![CDATA[post]]></category>
            <category><![CDATA[yogiyo]]></category>
            <dc:creator><![CDATA[Taemin Yun]]></dc:creator>
            <pubDate>Thu, 20 Nov 2025 07:16:44 GMT</pubDate>
            <atom:updated>2025-11-20T09:03:42.609Z</atom:updated>
            <content:encoded><![CDATA[<h3>빠르게 주문 상태 전달하기: Live Activities</h3><p>안녕하세요. 모바일 팀에서 iOS 앱을 개발하고 있는 윤태민입니다.</p><p>저희 모바일 팀은 <strong>최고의 사용자 경험</strong>을 위해 매주 다양한 주제로 스터디를 진행하고 있는데요. <strong>기초적인 CS 개념</strong>을 공부하기도 하고, <strong>최신 AI 기술</strong> 영상을 함께 보기도 하며, <strong>WWDC</strong>에서 소개된 새로운 API들을 직접 앱에 적용해 보기도 합니다.</p><p>그중에서도 이번에 소개할 <strong>Live Activities(실시간 현황)</strong>는 함께 스터디하는 것을 넘어서, 실제 프로덕트에 반영된 기능이라는 점과 <strong>사용자들의 요청</strong>을 수용해 Live Activities를 도입했다는 점에서 그 의미가 더욱 큽니다.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*17E3_nr5o38m9NowUSxX_A.png" /><figcaption>요기요에 Live Activities 도입을 요구하는 리뷰들</figcaption></figure><h3>앱, 그 이상을 위하여…</h3><p>애플이 제공하는 기능 중에는 <strong>시스템(OS)과 상호작용을 할 수 있는 기능들</strong>이 많이 있습니다. 대표적으로 Siri, Shortcut, Spotlight, Push Notifications, Widget 등이 있죠.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/836/1*uUapUQdqWzqlusOEz1M5vQ.png" /><figcaption>시스템과 상호작용을 하는 서비스</figcaption></figure><p>이러한 시스템 연동 기능들의 공통점은, <strong>사용자 경험을 앱 내부에만 머물지 않도록 확장해 준다</strong>는 데 있습니다. 즉, 사용자가 굳이 앱을 열지 않아도 필요한 정보를 확인하거나 특정 작업을 수행할 수 있도록 도와주며, 더 자연스럽고 새로운 경험을 제공하게 됩니다.</p><p>특히 Live Activities는 <strong>실시간으로 데이터를 업데이트하고 이를 즉각적으로 사용자에게 전달</strong>할 수 있다는 점에서, 요기요에서 중요하게 다루는 <strong>주문 상태 업데이트</strong>와 매우 잘 맞다고 생각했습니다.</p><p>그럼, 본격적으로 Live Activities가 무엇인지 살펴볼까요?</p><h3>Live Activities, 실시간 현황이 뭔가요?</h3><p>기능을 추가할 때 가장 먼저 고려해야 할 점 중 하나는 그 <strong>기능의 목적</strong>을 이해하는 것입니다. 목적을 알아야, 그 기능을 제대로 활용할 수 있기 때문이죠. 특히, 출시 여부가 애플의 심사에 따라 결정되는 앱이라면 그 중요성은 더 커집니다.</p><p>Live Activities의 목적은 애플 공식 문서에서 매우 명확하게 정의되어 있습니다.</p><blockquote>Live Activities provide frequent information updates that appear in glanceable locations such as the Lock Screen, on iPhone in StandBy, and the Dynamic Island.</blockquote><p>여기서 가장 중요한 부분은 역시 “frequent information updates”, 즉 <strong>빈번한 정보 업데이트</strong>입니다. Live Activities는 <strong>지속적으로 변화하는 데이터를 실시간으로 표시하기 위한 기능</strong>입니다.</p><p>아래 GIF를 보면 하나의 액티비티 내에서 정보가 변하는 걸 볼 수 있죠. 이것이 바로 Live Activities의 가장 큰 목적이자 특징입니다.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/295/1*PZh-pFiX4FyAXm9BiugHMw.gif" /></figure><p>그렇다면 이런 질문이 생깁니다. “Live(실시간)가 아닌 Dead(정적) Activities도 있을까?” 다시 말해, 빈번하게 변하지 않는 정보를 보여주는 기능도 있을까요?</p><p>네, 있습니다! 바로 우리가 익숙하게 사용하는 Push Notifications(알림) 입니다.</p><p>애플은 Push Notifications의 상위 개념인 User Notifications를 다음과 같이 정의합니다.</p><blockquote>User-facing notifications communicate important information to users of your app, regardless of whether your app is running on the user’s device.</blockquote><p>정의에서 알 수 있듯이, User Notification은 Live Activity와 달리 <strong>일회성으로 정보를 전달하는 기능</strong>입니다. 즉, 새로운 정보를 전달하려면 매번 새로운 알림을 보내야 합니다.</p><p>‘갑자기 왜 Push Notifications 얘기가 나왔을까?’ 싶을 수 있지만, Live Activities와 Push Notifications는 서로 유사하면서도 다른 성격을 가지고 있습니다. 그렇기 때문에 그 차이를 명확히 이해하는 것이 중요합니다. 그래야 상황에 맞는 적절한 기능을 쓸 수 있을 테니까요.</p><p><strong><em>“정적 데이터는 Push Notifications로, 동적 데이터는 Live Activities로”</em></strong></p><h3>요기요에 Live Activities 접목하기</h3><p>요기요에서 제공하는 <strong>주문 상태는 동적으로 변화하는 데이터</strong>입니다. 이 부분이 Live Activities를 도입한 이유입니다.</p><p>물론 앞서 설명한 알림으로도 주문 상태를 사용자에게 알릴 수 있습니다. 하지만 <strong>빈번한 알림은 자칫 사용자에게 잡음으로 다가올 수 있습니다.</strong> 그래서 하나의 액티비티에서 데이터를 갱신할 수 있는 Live Activities가 제격이라고 생각했습니다.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*5_QsHPG9Wo9vcsiyF3AYiA.png" /><figcaption>잡음으로 다가올 수 있는 빈번한 알림</figcaption></figure><p>그럼 실제 요기요에서 Live Activities를 어떻게 이용했는지 살펴볼까요? 우선 요기요에서 정의한 주요 요구사항부터 확인해 보겠습니다.</p><h4>요구사항</h4><p>요기요에서는 다음과 같이 대표적인 두 가지 기능이 요구되었습니다.</p><p><strong>(1) 주문 상태에 따른 라이브 액티비티 생성 및 업데이트</strong></p><ul><li>주문 완료¹ → 액티비티 생성² → 주문 상태 업데이트³ → 액티비티 업데이트⁴ → 배달 완료⁵ → 액티비티 제거⁶</li></ul><p><strong>(2) 액티비티 터치 시, 주문 상세 페이지로 이동</strong></p><p>두 번째 기능(주문 상세로 이동)은 이미 Deep Linking이 구현되어 있었기 때문에, WidgetKit에서 제공하는 API를 통해 간단히 연결할 수 있었습니다.</p><pre>ActivityConfiguration(for: OrderStatusActivityAttributes.self) { context in<br>    VStack(alignment: .leading) {<br>        ...<br>    }<br>    .widgetURL(URL)    // 주문 상세로 이동하는 Deep Linking URL 추가<br>}</pre><p>widgetURL(_:) 메서드를 사용하면, 사용자가 액티비티를 탭할 때 지정한 URL Scheme으로 이동할 수 있습니다. 이를 통해 앱을 열지 않고도 주문 상세 화면으로 직접 진입할 수 있죠.</p><p>중요한 부분은 역시 <strong>액티비티 관리</strong>입니다. 먼저 액티비티를 관리하는 방법을 살펴보겠습니다.</p><h4>액티비티를 관리하는 방법</h4><p>Live Activities는 크게 두 가지 방식으로 관리할 수 있습니다.</p><p><strong>(1) 앱 내부에서 직접 관리하는 방법</strong></p><p>가장 간단한 방법은 앱 내부에서 액티비티를 생성 및 업데이트하는 방식입니다. 앱 내에서 Activity.request() 나 activity.update() 메서드를 호출해 직접 액티비티를 조작할 수 있습니다.</p><p>이 방식은 <strong>구조가 단순하고, 앱 상태를 바로 반영</strong>할 수 있기 때문에 신뢰성이 높습니다. 다만, <strong>앱이 백그라운드나 종료 상태일 때는 업데이트가 불가능</strong>하다는 한계가 있습니다.</p><p><strong>(2) APNs를 통한 서버 관리</strong></p><p>APNs는 Apple Push Notification service의 약자로 도식으로 쉽게 이해할 수 있습니다.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*FIeOZ2p_LHB4qhOw.jpg" /></figure><p>여기서 주목해야 할 점은 다음과 같습니다:</p><ul><li>Push Token을 사용해 특정 기기를 식별한다는 점</li><li>요청 주체가 앱이 아닌 서버라는 점</li></ul><p>즉, 서버가 Push Token을 기반으로 APNs에 요청을 보내면, APNs가 해당 기기로 Payload를 전달하고, 앱은 그 데이터를 통해 액티비티를 업데이트하게 됩니다.</p><p>이 방식의 가장 큰 장점은, <strong>앱이 실행 중이지 않아도 Live Activities의 상태를 원격으로 갱신</strong>할 수 있다는 것입니다.</p><p>즉, 사용자가 앱을 완전히 종료한 상태에서도 주문 상태나 진행 현황이 시스템 UI(Dynamic Island, Lock Screen 등)에 실시간으로 반영될 수 있습니다.</p><p>참고로, APNs는 Live Activities뿐 아니라 일반 Push Notifications(알림)도 동일한 채널을 통해 전달합니다. 다만 Live Activities의 경우에는 특별한 Payload 구조를 사용합니다.</p><h4>액티비티 관리</h4><p>예상하셨겠지만, 요기요에서는 Live Activities를 <strong>APNs를 통해 관리</strong>하기로 했습니다.</p><p>가장 큰 이유는 다음과 같습니다.</p><p><strong>(1) 여러 기기에 대한 동일한 Live Activities 관리</strong></p><p>Live Activities를 앱 내부에서 직접 관리하게 되면, 주문이 완료된 시점에 즉시 액티비티를 생성할 수는 있지만, 해당 주문을 진행한 단말기에서만 액티비티가 생성되고 업데이트된다는 한계가 있습니다.</p><p>요기요는 <strong>같은 계정으로 로그인한 모든 기기에서 동일한 주문 상태 정보를 실시간으로 확인</strong>할 수 있도록 하고 싶었습니다.</p><p>예를 들어, 사용자가 아이폰과 아이패드를 동시에 사용 중이라면 양쪽 모두에서 동일한 액티비티가 노출되어야 합니다.</p><p>이를 위해 요기요에서는 외부 <strong>Push Service를 통해 Live Activities를 관리</strong>했습니다. 요기요 서버는 사용자 계정에 연결된 기기 정보와 주문 상태를 해당 서비스로 전달하고, 해당 서비스가 APNs를 통해 각 기기로 동일한 액티비티에 대한 업데이트를 요청합니다.</p><p><strong>(2) 앱이 실행 중이 아니어도 Live Activities 생성 및 업데이트</strong></p><p>마찬가지로 Live Activities의 관리 주체가 앱 내부에 있을 경우, 앱이 실행 중이 아닐 때는 액티비티를 업데이트할 수 없습니다. 즉, 포그라운드 상태에서만 특정 이벤트 트리거를 통해 업데이트할 수 있죠.</p><p>하지만 APNs를 통해 서버가 직접 업데이트를 제어하면, <strong>앱이 완전히 종료되어 있어도 시스템 UI(Dynamic Island, Lock Screen 등)의 상태를 변경</strong>할 수 있습니다.</p><p>요기요는 이 방식을 통해 앱 실행 여부와 관계없이 주문 상태를 실시간으로 반영할 수 있었습니다. 이는 사용자 관점에서 “앱을 열지 않아도 배달 현황을 확인할 수 있는 경험”을 가능하게 합니다.</p><p><strong>(3) 배달 종류에 따라 동적으로 Live Activities 노출 여부 결정</strong></p><p>요기요는 음식 배달 플랫폼으로, 다양한 형태의 배달을 제공합니다. 각각의 배달은 Live Activities가 노출하는 정보를 가지고 있을 수도 있고 그렇지 않을 수도 있습니다.</p><p>그리고 이런 내용은 언제든지 변할 수 있습니다. 예를 들어, 포장 주문에 대한 실시간 데이터를 제공하지 않아 Live Activities 노출이 필요 없다가 어느 순간 제공이 가능해질 수도 있습니다.</p><p>요기요는 서버에서 주문 종류에 따라 Live Activities 노출 여부를 동적으로 제어함으로써 변화하는 서비스 정책이나 배달 로직에 유연하게 대응할 수 있도록 구현했습니다.</p><p>APNs 기반 관리 구조는 이러한 정책 변경에도 <strong>코드 수정 없이 서버 설정만으로 대응</strong>할 수 있다는 장점이 있습니다.</p><h3>Live Activities 적용 시 고려할 점</h3><p>Live Activities를 도입하면서 실제 마주했던 내용들을 공유하려고 합니다. 아래 소개되는 내용들은 Live Activities 적용 시 필수적으로 고려해야 하는 부분이니 항상 염두에 둬야 합니다.</p><h4>디자인 제약</h4><p>가장 먼저 고려해야 할 점은 디자인 제약입니다. Live Activities가 노출되는 위치는 <strong>Lock Screen, Dynamic Island 등</strong> 한정적인데요. 특히 Dynamic Island는 노출 상태에 따라 크기, 위치, 비율 제약이 매우 까다롭습니다. 상태마다 레이아웃 가이드가 다르기 때문에, <strong>Apple의 Human Interface Guidelines을 반드시 확인</strong>해야 합니다.</p><p>다음으로는 <strong>애니메이션 제약</strong>입니다. Live Activities가 표시되는 영역에서는 사용할 수 있는 애니메이션에 제약이 있습니다. 공식 문서와 세션 영상들을 보면, View 전환 애니메이션이나 SF Symbols의 애니메이션 기능을 일부 활용할 수 있다고 설명하지만, 실제 적용 시 불완전한 요소가 많아 정적 이미지 중심으로 디자인을 구성했습니다.</p><p><strong>Live Activities는 시스템 리소스를 직접 사용</strong>하는 기능이기 때문에, 무리한 애니메이션보다는 안정적인 정적 표현에 초점을 맞추는 것이 좋습니다.</p><h4>컨텐츠 제약</h4><p>Apple은 Live Activities의 콘텐츠 정책에 대해 다음과 같이 명시하고 있습니다.</p><blockquote>Don’t use a Live Activity to display ads or promotions. Live Activities help people stay informed about ongoing events and tasks, so it’s important to display only information that’s related to those events and tasks.</blockquote><p>즉, Live Activity에는 <strong>광고나 프로모션 콘텐츠를 삽입해서는 안 됩니다.</strong> Live Activities의 목적은 어디까지나 빈번하게 변하는 데이터를 전달하는 것입니다. 이 원칙을 벗어나면 심사 과정에서 불리하게 작용할 수 있습니다.</p><p>“그럼 광고는 뭐로 보여주나요?”</p><p>예상이 되나요? 바로 Push Notifications입니다. Live Activities와 Push Notifications는 서로 대체 관계라기 보다는 상호 보완 관계입니다. 즉, Live Activities는 실시간 상태 변화에, Push Notifications는 단발성 메시지 전달에 각각 최적화되어 있습니다.</p><p>이 두 기능을 적절히 조합하면 시스템 전반에서 자연스럽게 확장되는 사용자 경험을 제공할 수 있습니다.</p><h3>끝으로…</h3><p>Live Activities는 단순히 정보를 표시하는 기능이 아니라, 앱과 시스템이 협력해 사용자 경험을 확장하는 인터페이스입니다. 앱 내부에서만 머물던 정보 흐름을 Lock Screen, Dynamic Island 등 시스템 전역으로 확장할 수 있다는 점에서 그 의미가 큽니다.</p><p>요기요는 이 기능을 활용해 사용자가 앱을 열지 않아도 주문 상태를 직관적으로 확인할 수 있도록 했고, Push Service와 APNs를 연동해 Live Activities를 서버 중심으로 관리함으로써, 여러 기기에서 일관된 경험을 제공하고, 주문 정책 변화에도 유연하게 대응할 수 있었습니다.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*BBwaQLfJvbabYckiG_86bw.png" /><figcaption>오늘도 열심히 배달하고 있는 요리</figcaption></figure><p>앞으로도 애플은 사용자와 시스템을 연결하는 다양한 인터페이스를 확장해 나갈 것입니다. 그만큼 개발자는 단순한 기능 개발을 넘어 어떤 경험을 만들어 낼지 고민해야 합니다.</p><p>Live Activities는 그 출발점이었습니다. 이 경험을 바탕으로 이제부터 더 풍부한 사용자 경험을 만들어갈 예정입니다.</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=0e957c868858" width="1" height="1" alt=""><hr><p><a href="https://techblog.yogiyo.co.kr/live-activities-0e957c868858">더 빠르게 주문 상태 전달하기: Live Activities</a> was originally published in <a href="https://techblog.yogiyo.co.kr">YOGIYO Tech Blog - 요기요 기술블로그</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
    </channel>
</rss>