<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" 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">
    <channel>
        <title>토스 기술 블로그, 토스 테크</title>
        <link>https://toss.tech</link>
        <description>토스 메이커들이 문제를 정의하고 해법을 찾아가는 이야기를 들려드립니다.</description>
        <lastBuildDate>Fri, 09 Oct 2026 10:26:33 GMT</lastBuildDate>
        <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
        <generator>토스팀</generator>
        <language>ko</language>
        <copyright>Viva Republica, All rights reserved.</copyright>
        <atom:link href="https://toss.tech/rss.xml" rel="self" type="application/rss+xml"/>
        <item>
            <title><![CDATA[추천 후보는 많을수록 좋을까? TopK를 최적화해 전환율을 높인 방법]]></title>
            <link>https://toss.tech/article/53545</link>
            <guid isPermaLink="true">https://toss.tech/article/53545</guid>
            <pubDate>Thu, 01 Oct 2026 01:26:00 GMT</pubDate>
            <description><![CDATA[TopK를 감이 아닌 최적화로 정한 이야기]]></description>
            <content:encoded><![CDATA[<link rel="preload" as="image" href="https://static.toss.im/ipd-tcs/toss_core/live/81b83252-278f-48e0-85db-2a976d6de8ca/image.png"/><link rel="preload" as="image" href="https://static.toss.im/ipd-tcs/toss_core/live/604634a2-928f-4a36-9611-738d8e668b0a/image.png"/><div class="css-1vn47db"><p id="3eaa360d-33e3-8079-b5f9-c499f1bc61c6" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">안녕하세요. 토스 Commerce Personalization Team에서 토스 쇼핑의 추천 시스템을 만들고 있는 ML Engineer 구국원입니다.</span></span></span></p><p id="3eaa360d-33e3-80d9-bb85-d5dfbcd4c212" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">추천 시스템은 흔히 더 좋은 모델을 만드는 문제로 보입니다. 하지만 실제 서비스를 운영하다 보면 모델 바깥에도 성능을 좌우하는 중요한 결정이 많아요. 각 추천 모델에서 상품을 몇 개씩 가져올 것인지 정하는 </span><span class="css-q3ktjb">TopK</span><span class="css-1kxrhf3">도 그중 하나입니다.</span></span></span></p><p id="3eaa360d-33e3-800a-b233-f40ad4a7ce10" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">저희는 여러 리트리벌(Retrieval) 모델의 TopK를 경험적으로 정해 왔습니다. 새로운 리트리벌 모델이 생길 때마다 후보 수는 조금씩 늘었고, 어느새 랭커(Ranker)가 평가해야 할 상품과 광고 유효성 검사량도 함께 커졌어요. 그렇다고 모든 리트리벌 모델의 후보를 같은 비율로 줄이면 가치 있는 상품까지 잃을 수 있었습니다.</span></span></span></p><p id="3eaa360d-33e3-8074-8933-c338210f9fdc" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">이 글에서는 이 문제를 조합 최적화 문제로 바꿔 풀고, 온라인 실험으로 검증한 과정을 소개할게요. 결론부터 말하면 </span><span class="css-q3ktjb">전체 후보 수를 약 절반으로 줄이면서 User CVR(User Conversion Rate)은 7.55%, 사용자당 주문 수는 10.13% 높일 수 있었습니다.</span></span></span></p><p id="3eaa360d-33e3-8038-bafb-fc37e21c12f2" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"></span></span></p><h3 id="3eaa360d-33e3-8068-95c2-d29573ac3cba" class="css-1feg9au"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">TopK가 커질수록 추천이 좋아질까요?</span></span></span></h3><p id="3eaa360d-33e3-80e5-afb2-c34a8f697bd4" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">토스 쇼핑의 추천 시스템은 크게 Retrieval, Ranking 단계로 이루어져 있어요.</span></span></span></p><aside id="3eaa360d-33e3-8086-b99f-e6172f9b9f12" class="css-nv7vyi"><div class="css-1vn47db"><ul class="css-uswsmm"><li id="3eaa360d-33e3-8017-a012-f1cf874ee1c6" class="css-1hwiibq"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-q3ktjb">Retrieval</span><span class="css-1kxrhf3">은 수많은 상품 중 사용자가 좋아할 만한 후보를 빠르게 가져옵니다.</span></span></span></li><li id="3eaa360d-33e3-80ba-95b8-ccc3a68af8fc" class="css-1hwiibq"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-q3ktjb">Ranking</span><span class="css-1kxrhf3">은 가져온 후보마다 클릭이나 구매 가능성을 예측해 순서를 정합니다.</span></span></span></li></ul></div></aside><p id="3eaa360d-33e3-8001-8891-d755f45670bc" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">TopK의 K는 각 리트리벌 모델이 다음 단계로 넘기는 상품 수예요. 예를 들어 구매 이력을 보는 모델에서 500개, 최근 클릭을 보는 모델에서 200개를 가져오면 두 모델의 TopK 합은 700개가 됩니다.</span></span></span></p><p id="3eaa360d-33e3-8015-9c51-ccabe59c90a6" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">후보가 많으면 좋은 상품을 놓칠 가능성은 줄어듭니다. 하지만 공짜는 아니에요. Ranking 단계가 평가할 상품이 많아지고, 광고 상품의 유효성을 확인하는 비용도 늘어납니다. 따라서 피드를 만드는 데 총 소요 시간이 증가하여 응답 시간이 길어질 수 있고, 점수가 낮은 후보가 뒤 단계까지 살아남을 가능성도 커져요.</span></span></span></p><p id="3eaa360d-33e3-80fd-965b-db67105999a2" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">당시 TopK는 주로 각 모델을 담당하는 ML Engineer의 경험에 따라 정했습니다. 개별 모델만 보면 합리적인 값이어도 여러 리트리벌 모델의 후보를 합치면 전체 후보 수가 계속 커지는 구조였어요. 더 어려운 점은 모든 리트리벌 모델이 같은 가치를 만들지 않는다는 것이었습니다. 어떤 모델은 상위 수백 개까지 구매 가능성이 높지만, 다른 모델은 상위 몇십 개 이후 가치가 빠르게 낮아졌어요.</span></span></span></p><p id="3eaa360d-33e3-809a-b0d5-cdc42ef0d034" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">그래서 질문을 바꿨습니다.</span></span></span></p><blockquote id="3eaa360d-33e3-8053-92e1-c93858fc43ee" class="css-2sk6rv"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">모든 리트리벌 모델의 TopK를 똑같이 줄이지 않고, 전체 후보 수라는 예산 안에서 가치가 높은 모델에 더 많은 TopK를 배정할 수 없을까?</span></span></span></blockquote><p id="3eaa360d-33e3-807f-a1cb-d55194b6815d" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"></span></span></p><h3 id="3eaa360d-33e3-809b-869c-f536330abcf5" class="css-1feg9au"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">TopK 배정을 배낭 문제로 바꿔보기</span></span></span></h3><p id="3eaa360d-33e3-807d-b769-d656c8ae81da" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">이 문제는 </span><span class="css-q3ktjb">배낭 문제(Knapsack Problem)</span><span class="css-1kxrhf3">와 닮았습니다. 무게 제한이 있는 배낭에 물건을 담되, 물건 가치의 합을 가장 크게 만드는 조합을 찾는 문제예요.</span></span></span></p><p id="3eaa360d-33e3-8094-bbdc-dface10ed6a1" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">추천 시스템에 대입하면 이렇게 볼 수 있습니다.</span></span></span></p><aside id="3eaa360d-33e3-804e-8451-df1f46c344d7" class="css-nv7vyi"><div class="css-1vn47db"><ul class="css-uswsmm"><li id="3eaa360d-33e3-80f4-a375-c7f4a00437fe" class="css-1hwiibq"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">배낭의 용량: 전체 리트리벌 모델이 가져올 수 있는 상품 수</span></span></span></li><li id="3eaa360d-33e3-806d-ba21-e6e965ce62cf" class="css-1hwiibq"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">물건: 각 리트리벌 모델의 각 순위에 있는 상품</span></span></span></li><li id="3eaa360d-33e3-80be-8436-f542e285b949" class="css-1hwiibq"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">물건의 가치: 해당 상품의 예상 클릭·구매 가치</span></span></span></li><li id="3eaa360d-33e3-80c4-8316-d43eeda50786" class="css-1hwiibq"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">결정할 것: 각 모델에서 몇 위까지 가져올지, 즉 모델별 TopK</span></span></span></li></ul></div></aside><p id="3eaa360d-33e3-801c-8c62-fcdfe94e5f30" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">가치가 높은 물건부터 차례로 넣는 탐욕적 방법만으로는 전체 최적 조합을 놓칠 수 있어요. 10kg 배낭에 9kg짜리 100점 물건 하나를 넣는 것보다 5kg짜리 60점 물건 두 개를 넣는 편이 더 좋은 것과 같습니다.</span></span></span></p><p id="3eaa360d-33e3-80b0-a3f0-fdadb315f699" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">저희는 이 조합을 찾기 위해 </span><span class="css-q3ktjb">정수계획법(Integer Programming)</span><span class="css-1kxrhf3">을 사용했습니다. 정수계획법은 선택 여부처럼 정수로 표현되는 결정을, 반드시 지켜야 할 조건 안에서 최적화하는 방법이에요. 세 가지 요소만 기억하면 됩니다.</span></span></span></p><aside id="3eaa360d-33e3-8081-8c59-ed39fc0c0d10" class="css-nv7vyi"><div class="css-1vn47db"><ul class="css-uswsmm"><li id="3eaa360d-33e3-80ac-967b-c037697620df" class="css-1hwiibq"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-q3ktjb">결정 변수</span><span class="css-1kxrhf3">: 어떤 리트리벌 모델의 몇 번째 상품까지 선택할 것인가</span></span></span></li><li id="3eaa360d-33e3-80bb-b200-e9d7ed037e1e" class="css-1hwiibq"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-q3ktjb">목적 함수</span><span class="css-1kxrhf3">: 선택한 상품의 예상 가치 합을 최대화한다</span></span></span></li><li id="3eaa360d-33e3-805c-a7e2-f655ed946eb2" class="css-1hwiibq"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-q3ktjb">제약 조건</span><span class="css-1kxrhf3">: 전체 후보 수와 모델별 최소 수량 같은 운영 규칙을 지킨다</span></span></span></li></ul></div></aside><p id="3eaa360d-33e3-8026-8f14-caa9b1ba579f" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">수식으로는 다음처럼 표현할 수 있습니다.</span></span></span></p><figure id="3eba360d-33e3-8035-828d-ebedc77ccc82" class="css-of5acw"><img src="https://static.toss.im/ipd-tcs/toss_core/live/81b83252-278f-48e0-85db-2a976d6de8ca/image.png" alt="" class="css-1pgssrp"/></figure><p id="3eaa360d-33e3-80cd-b3b4-d3ae5429973a" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">여기서 </span><span class="css-1kxrhf3">x_{i,k}</span><span class="css-1kxrhf3">는 리트리벌 모델 </span><span class="css-1kxrhf3">i</span><span class="css-1kxrhf3">의 </span><span class="css-1kxrhf3">k</span><span class="css-1kxrhf3">번째 상품을 선택하면 1, 아니면 0인 값입니다. </span><span class="css-1kxrhf3">w_{i,k}</span><span class="css-1kxrhf3">는 그 상품의 예상 가치예요. 실제 실험군을 만들 때는 노출 이후 구매 가능성을 나타내는 pICVR(=pCTR * pCVR)을 선택했습니다.</span></span></span></p><p id="3eaa360d-33e3-8040-8323-d66eb03e9db3" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">제약 조건도 함께 넣었습니다.</span></span></span></p><aside id="3eaa360d-33e3-800c-8dda-de9ff95cc358" class="css-nv7vyi"><div class="css-1vn47db"><ol class="css-hokoge"><li id="3eaa360d-33e3-8004-8888-f22aa94a95a3" class="css-1hwiibq"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">전체 TopK 합은 기존의 약 50%를 넘지 않습니다.</span></span></span></li><li id="3eaa360d-33e3-800d-b8d4-cf29641489dc" class="css-1hwiibq"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">특정 리트리벌 모델의 후보가 완전히 사라지지 않도록 최소 후보 수를 보장합니다.</span></span></span></li><li id="3eaa360d-33e3-80f1-bb78-e20b6975a2cf" class="css-1hwiibq"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">한 모델에서 100번째 상품을 선택하려면 1번째부터 99번째 상품도 선택해야 합니다.</span></span></span></li></ol></div></aside><p id="3eaa360d-33e3-8052-b8e8-daa4adcfbd10" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">세 번째 조건이 필요한 이유는 TopK가 ‘몇 위까지 가져올 것인가’를 뜻하기 때문이에요. 100번째 상품만 고르고 그보다 높은 순위의 상품을 버릴 수는 없습니다.</span></span></span></p><p id="3eaa360d-33e3-8009-b15c-d53d2ec28cf0" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"></span></span></p><h3 id="3eaa360d-33e3-80e9-95a3-ccb543b29879" class="css-1feg9au"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">실제 로그로 모델별 가치 곡선 만들기</span></span></span></h3><p id="3eaa360d-33e3-80eb-910e-cfa34e663521" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">최적화에 넣을 가치는 추측으로 정하지 않았습니다. 실제 추천 로그를 바탕으로 다음 과정을 거쳤어요.</span></span></span></p><aside id="3eaa360d-33e3-8015-85bf-c4a902c6c8d9" class="css-nv7vyi"><div class="css-1vn47db"><ol class="css-hokoge"><li id="3eaa360d-33e3-8004-90f9-ed61cb72598b" class="css-1hwiibq"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">사용자, 피드, 리트리벌 모델별 실제 로그를 가져옵니다.</span></span></span></li><li id="3eaa360d-33e3-8043-b2db-d477e7c10d05" class="css-1hwiibq"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">각 상품에 Ranking 단계에서 사용하는 랭커로부터 pCTR, pCVR을 배치로 계산합니다.</span></span></span></li><li id="3eaa360d-33e3-80d7-8e0b-e86b60489577" class="css-1hwiibq"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">리트리벌 모델과 순위별로 예측값의 평균을 집계했습니다.</span></span></span></li><li id="3eaa360d-33e3-8093-a35b-e5ccd31a9476" class="css-1hwiibq"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">각 리트리벌 모델에서 후보를 한 개 더 가져올 때 얻는 기대 가치가 얼마나 되는지 비교했습니다.</span></span></span></li></ol></div></aside><figure id="3eba360d-33e3-8061-8b90-df7fc9e412dd" class="css-of5acw"><img src="https://static.toss.im/ipd-tcs/toss_core/live/604634a2-928f-4a36-9611-738d8e668b0a/image.png" alt="" class="css-1pgssrp"/></figure><p id="3eaa360d-33e3-806b-ab42-ee2231d3823f" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">pICVR(Predicted ICVR)은 노출 후 구매까지 이어질 확률을 뜻합니다. 즉, 각 모델별 후보 상품이 창출할 것으로 기대되는 비즈니스 가치를 추정한 값이에요.</span></span></span></p><p id="3eaa360d-33e3-8097-9f5f-f36aeddc7b0f" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">시뮬레이션 결과는 모델마다 달랐습니다. 기존 TopK를 그대로 유지하는 편이 좋은 리트리벌 모델도 있었고, 25%나 10% 수준까지 줄여도 전체 목적 함수에 미치는 영향이 작은 모델도 있었어요. 모든 리트리벌 모델의 TopK를 50%씩 줄이는 방식보다, 모델별 가치 곡선에 따라 다르게 배분해야 한다는 뜻이었습니다.</span></span></span></p><p id="3eaa360d-33e3-80e8-b723-eec8dfb4cb96" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"></span></span></p><h3 id="3eaa360d-33e3-80d6-84bc-fc253eeae483" class="css-1feg9au"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">온라인에서 네 가지 방법 비교하기</span></span></span></h3><p id="3eaa360d-33e3-808d-8c9a-ee0e8bc6123a" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">오프라인 시뮬레이션이 좋아도 실제 사용자의 행동이 같으리라는 보장은 없습니다. 그래서 홈 피드에서 여러 실험군을 동시에 비교했어요.</span></span></span></p><div data-table-wrapper="true" class="css-1m7s5mg"><div role="table" class="css-uznrcw"><div role="row" class="css-1obf64m"><div role="cell" class="css-8vl804"><span class="css-1mjnzsq">실험군</span></div><div role="cell" class="css-8vl804"><span class="css-1mjnzsq">방법</span></div><div role="cell" class="css-8vl804"><span class="css-1mjnzsq">확인하려는 것</span></div></div><div role="row" class="css-1obf64m"><div role="cell" class="css-8vl804"><span class="css-1mjnzsq">비교군</span></div><div role="cell" class="css-8vl804"><span class="css-1mjnzsq">기존 TopK</span></div><div role="cell" class="css-8vl804"><span class="css-1mjnzsq">현재 성과의 기준</span></div></div><div role="row" class="css-1obf64m"><div role="cell" class="css-8vl804"><span class="css-1mjnzsq">일괄 1/2 축소</span></div><div role="cell" class="css-8vl804"><span class="css-1mjnzsq">모든 리트리벌 모델의 TopK를 일괄적으로 1/2로 줄이기</span></div><div role="cell" class="css-8vl804"><span class="css-1mjnzsq">단순 축소의 효과</span></div></div><div role="row" class="css-1obf64m"><div role="cell" class="css-8vl804"><span class="css-1mjnzsq">일괄 1/4 축소</span></div><div role="cell" class="css-8vl804"><span class="css-1mjnzsq">모든 리트리벌 모델의 TopK를 일괄적으로 1/4로 줄이기</span></div><div role="cell" class="css-8vl804"><span class="css-1mjnzsq">더 공격적인 축소의 효과</span></div></div><div role="row" class="css-1obf64m"><div role="cell" class="css-8vl804"><span class="css-1mjnzsq">정수계획법</span></div><div role="cell" class="css-8vl804"><span class="css-1mjnzsq">TopK 합을 약 절반으로 제한하고 pICVR 합이 커지도록 모델별로 다르게 배정</span></div><div role="cell" class="css-8vl804"><span class="css-1mjnzsq">가치 기반 배분의 효과</span></div></div><div role="row" class="css-1obf64m"><div role="cell" class="css-8vl804"><span class="css-1mjnzsq">Maximum Coverage</span></div><div role="cell" class="css-8vl804"><span class="css-1mjnzsq">TopK 합을 약 절반으로 제한하고 더 넓은 상품 커버리지를 만들도록 배정</span></div><div role="cell" class="css-8vl804"><span class="css-1mjnzsq">다양성 중심 배분의 효과</span></div></div></div></div><p id="3eaa360d-33e3-807f-812a-f8c88153b75c" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">실험은 7일 동안 진행했고 위너를 고르는 규칙도 결과를 보기 전에 정했습니다.</span></span></span></p><aside id="3eaa360d-33e3-80fe-905d-f6e960f1dfe7" class="css-nv7vyi"><div class="css-1vn47db"><ul class="css-uswsmm"><li id="3eaa360d-33e3-80b6-9642-f8d3f3514d55" class="css-1hwiibq"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-q3ktjb">목표 지표</span><span class="css-1kxrhf3">: User CVR(User Conversion Rate, 노출된 사용자 중 구매한 사용자의 비율), Order PU(Order Per User, 사용자 한 명당 주문 수)</span></span></span></li><li id="3eaa360d-33e3-80bd-b610-c437c18908f8" class="css-1hwiibq"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-q3ktjb">보조 지표</span><span class="css-1kxrhf3">: ICVR(Impression-CVR, 구매 수/노출 수), CVR(Conversion Rate, 구매 수/클릭 수), CTR(Click Through Rate, 클릭 수/노출 수), GPU(GMV Per User, 사용자 한 명당 GMV)</span></span></span></li><li id="3eaa360d-33e3-8067-8aa9-e932788fd4ae" class="css-1hwiibq"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-q3ktjb">가드레일 지표</span><span class="css-1kxrhf3">: RPU(Revenue Per User, 사용자 한 명당 매출)</span></span></span></li></ul></div></aside><p id="3eaa360d-33e3-802a-8d01-e41b15122073" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"></span></span></p><h3 id="3eaa360d-33e3-80f1-9b15-c7f74acb802b" class="css-1feg9au"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">가장 높은 숫자보다 균형 잡힌 결과를 선택했어요</span></span></span></h3><p id="3eaa360d-33e3-80b5-9797-f05982cdd554" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">정수계획법 실험군은 비교군보다 전체 후보를 약 절반만 사용하면서 다음과 같은 변화를 보였습니다. 아래 수치는 모두 비교군 대비 상대 변화율입니다.</span></span></span></p><div data-table-wrapper="true" class="css-1m7s5mg"><div role="table" class="css-crnt2r"><div role="row" class="css-1obf64m"><div role="cell" class="css-8vl804"><span class="css-1mjnzsq">지표</span></div><div role="cell" class="css-8vl804"><span class="css-1mjnzsq">변화</span></div></div><div role="row" class="css-1obf64m"><div role="cell" class="css-8vl804"><span class="css-1mjnzsq">User CVR</span></div><div role="cell" class="css-8vl804"><span class="css-1y26ms3">+7.552%</span></div></div><div role="row" class="css-1obf64m"><div role="cell" class="css-8vl804"><span class="css-1mjnzsq">Order PU</span></div><div role="cell" class="css-8vl804"><span class="css-1y26ms3">+10.133%</span></div></div><div role="row" class="css-1obf64m"><div role="cell" class="css-8vl804"><span class="css-1mjnzsq">ICVR</span></div><div role="cell" class="css-8vl804"><span class="css-1y26ms3">+9.286%</span></div></div><div role="row" class="css-1obf64m"><div role="cell" class="css-8vl804"><span class="css-1mjnzsq">CVR</span></div><div role="cell" class="css-8vl804"><span class="css-1y26ms3">+13.496%</span></div></div><div role="row" class="css-1obf64m"><div role="cell" class="css-8vl804"><span class="css-1mjnzsq">GPU</span></div><div role="cell" class="css-8vl804"><span class="css-1y26ms3">+11.218%</span></div></div><div role="row" class="css-1obf64m"><div role="cell" class="css-8vl804"><span class="css-1mjnzsq">RPU</span></div><div role="cell" class="css-8vl804"><span class="css-1y26ms3">+0.832%</span></div></div><div role="row" class="css-1obf64m"><div role="cell" class="css-8vl804"><span class="css-1mjnzsq">CTR</span></div><div role="cell" class="css-8vl804"><span class="css-1y26ms3">-3.710%</span></div></div></div></div><p id="3eaa360d-33e3-80c0-86a7-caab7981e308" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">User CVR, Order PU의 p-value는 0.0001보다 작았습니다. p-value는 ‘</span><span class="css-q3ktjb">실제로 차이가 없다고 가정했을 때, 우연히 이 정도 이상의 차이를 관측할 가능성’</span><span class="css-1kxrhf3">을 가늠하는 값이에요. 값이 작을수록 단순한 우연만으로 설명하기 어렵다는 뜻입니다. </span></span></span></p><p id="3eaa360d-33e3-8074-a262-d30b7d30d23b" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">흥미로운 점은 목표 지표가 가장 높았던 실험군이 따로 있었다는 것입니다. TopK를 1/4로 줄인 실험군은 User CVR이 8.89%, Order PU가 10.52% 증가했습니다. 하지만 RPU는 3.98%, AOV(Average Order Value)는 5.73% 감소했어요. 주문 수는 늘었지만 낮은 가격대의 상품에 구매가 치우쳤을 가능성이 있었습니다.</span></span></span></p><p id="3eaa360d-33e3-80fd-900a-d31a4f52c0cb" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">Maximum Coverage 실험군은 RPU가 2.05% 증가해 수익성 방어에는 더 유리했습니다. 반면 User CVR과 Order PU의 개선 폭은 정수계획법 실험군보다 작았습니다.</span></span></span></p><p id="3eaa360d-33e3-809c-bda2-c78fe5579c87" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">저희는 목표 지표, 수익성, 전환 효율을 함께 비교해 정수계획법 실험군을 최종 위너로 선택했습니다. 한 지표의 최고점을 고르는 대신, 제품이 만들고 싶은 전체 효용을 기준으로 결정한 셈이에요.
</span></span></span></p><h3 id="3eaa360d-33e3-80a9-970f-fa774203bf5f" class="css-1feg9au"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">후보를 줄이는 데에도 비용이 있었습니다</span></span></span></h3><p id="3eaa360d-33e3-800a-bff5-d6479677e5fc" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">좋은 결과만 있었던 것은 아닙니다. 정수계획법 실험군의 CTR은 3.71% 감소했고, 사용자당 고유 노출 상품 수도 22.07% 줄었습니다.</span></span></span></p><p id="3eaa360d-33e3-802c-8de0-c519911d3ef9" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">CTR이 낮아진 동시에 CVR이 높아졌다는 것은 어떻게 봐야 할까요? 후보를 줄이면서 클릭 기회 자체는 감소했지만, 남은 클릭이 구매로 이어지는 효율은 높아졌다고 해석할 수 있습니다. ‘많이 보여주기’보다 ‘구매 가능성이 높은 후보를 남기기’에 가까운 변화예요. 다만 이 결과만으로 인과관계를 단정할 수는 없습니다.</span></span></span></p><p id="3eaa360d-33e3-804e-89a6-fb0859e12004" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">고유 노출 상품 수의 감소는 더 분명한 과제였습니다. 목적 함수가 예측 구매 가치에 집중하면 자주 좋은 점수를 받는 상품이 반복해서 선택될 수 있어요. 전환 효율을 높이는 것과 새로운 상품을 발견하게 하는 것은 서로 충돌할 수 있습니다.</span></span></span></p><p id="3eaa360d-33e3-8062-9dc6-da733e7a5a0f" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">그래서 후속 실험에서는 TopK를 유지한 채 Gumbel Weighted Sampling을 적용했습니다. 목표 지표를 유의하게 떨어뜨리지 않으면서 피드 중복도를 평균 11.25% 낮추고, 노출 상품 다양성을 4.61% 높였습니다. RPU도 2.30% 증가했습니다. 첫 실험에서 드러난 비용을 다음 실험의 목표로 바꾼 결과였어요.</span></span></span></p><p id="3eaa360d-33e3-80a1-b42a-ea9cfc99dff1" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"></span></span></p><h3 id="3eaa360d-33e3-804b-a2c8-dd588ca280eb" class="css-1feg9au"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">Next Step: 사용자마다 다른 TopK</span></span></span></h3><p id="3eaa360d-33e3-8080-bf72-d13810ceff34" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">이번 프로젝트로 경험에 의존하던 TopK를 데이터 기반의 최적화 문제로 바꿀 수 있었습니다. 하지만 현재 방식은 여전히 모든 사용자에게 같은 모델별 TopK를 적용하는 </span><span class="css-q3ktjb">Global K</span><span class="css-1kxrhf3">입니다.</span></span></span></p><p id="3eaa360d-33e3-8080-9dc8-cbdc3f8d8d5a" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">사용자마다 필요한 후보의 구성은 다를 수 있어요. 구매와 클릭 이력이 충분한 사용자는 개인화 모델의 후보를 더 많이 활용할 수 있습니다. 반대로 이력이 거의 없는 사용자는 인기 상품 모델이나 탐색형 리트리벌 모델이 더 중요할 수 있어요. 최근 행동의 유무나 관심사의 선명도에 따라서도 적절한 배분은 달라질 겁니다.</span></span></span></p><p id="3eaa360d-33e3-80e3-8ce9-ef8b3796e9d9" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">앞으로는 다음 두 방향을 검토하고 있습니다.</span></span></span></p><ul class="css-uswsmm"><li id="3eaa360d-33e3-80d9-b390-db003c122c1a" class="css-1hwiibq"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-q3ktjb">동적으로 갱신되는 Global K</span><span class="css-1kxrhf3">: 최근 로그와 모델 성능을 반영해 모델별 TopK를 주기적으로 다시 계산합니다.</span></span></span></li><li id="3eaa360d-33e3-807d-a71c-ced74cd80c3a" class="css-1hwiibq"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-q3ktjb">개인화된 TopK</span><span class="css-1kxrhf3">: 사용자의 이력량, 최근성, 상태에 따라 같은 전체 예산 안에서도 모델별 TopK를 다르게 배정합니다.</span></span></span></li></ul><p id="3eaa360d-33e3-8098-8d8d-ff0e3aaba205" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">개인화된 TopK는 후보를 더 많이 가져오는 방식이 아닙니다. 사용자에게 필요한 리트리벌 모델에 후보 예산을 더 정확하게 쓰는 방식에 가까워요. 다만 예측 오차가 배정 결과로 이어질 수 있고, 신규 리트리벌 모델은 로그가 부족하다는 문제가 있습니다. 신규 리트리벌 모델을 실제 노출 없이 평가할 수 있는 Shadow Logging과, 다양성·중복도 지표를 함께 관리하는 체계가 필요한 이유입니다.</span></span></span></p><p id="3eaa360d-33e3-8015-b535-c0d9350baafe" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"></span></span></p><h2 id="3eaa360d-33e3-807d-8cfd-fdacc4ff0105" class="css-1c1qox8"><span class="css-1r3ko7u"><span class="css-p4abj2"><span class="css-1kxrhf3">마치며</span></span></span></h2><p id="3eaa360d-33e3-805e-845a-d5025e34b115" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">추천 시스템의 성능은 모델의 정확도만으로 결정되지 않습니다. 어떤 후보를 몇 개 가져오고, 어떤 지표를 최적화하며, 무엇을 안전장치로 둘지 같은 운영 결정도 사용자 경험과 비즈니스 성과를 크게 바꿉니다.</span></span></span></p><p id="3eaa360d-33e3-80ea-ad90-ca461c8d76a9" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">이번 프로젝트에서 얻은 가장 큰 교훈은 세 가지였습니다.</span></span></span></p><aside id="3eaa360d-33e3-8051-81bd-d795fe21a081" class="css-nv7vyi"><div class="css-1vn47db"><ul class="css-uswsmm"><li id="3eaa360d-33e3-8066-b15e-fcf5aafe9b37" class="css-1hwiibq"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">경험적으로 정하던 설정값도 목적 함수와 제약 조건을 정의하면 최적화 문제로 바꿀 수 있습니다.</span></span></span></li><li id="3eaa360d-33e3-8088-a1b2-e659415a9646" class="css-1hwiibq"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">오프라인 예측값은 출발점일 뿐이며, 실제 사용자의 행동은 온라인 실험으로 확인해야 합니다.</span></span></span></li><li id="3eaa360d-33e3-8065-8bd5-d2df33f36619" class="css-1hwiibq"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">위너는 가장 높은 숫자를 만든 안이 아니라, 목표 지표와 가드레일, 장기적인 사용자 경험을 함께 만족한 안이어야 합니다.</span></span></span></li></ul></div></aside><p id="3eaa360d-33e3-80d5-87ae-f8391050914b" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">후보를 절반으로 줄이는 일은 단순한 비용 절감이 아니었습니다. 어떤 후보가 정말 가치 있는지 다시 정의하고, 추천 시스템이 쓰는 한정된 예산을 더 잘 배분하는 일이었어요. 이제는 이 배분을 사용자마다 다르게 만드는 다음 문제를 풀어보려 합니다.</span></span></span></p><p id="3eaa360d-33e3-8032-9129-d4b36f5549df" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"></span></span></p></div><div id="tds-mobile-portal-container"></div>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[9인조 다람쥐 아이돌을 데뷔시켰습니다]]></title>
            <link>https://toss.tech/article/chipmunk</link>
            <guid isPermaLink="true">https://toss.tech/article/chipmunk</guid>
            <pubDate>Wed, 30 Sep 2026 01:29:00 GMT</pubDate>
            <description><![CDATA[가입하고 한 번도 열어보지 않던 적금을, 매일 열어보게 만들었어요.]]></description>
            <content:encoded><![CDATA[<link rel="preload" as="image" href="https://static.toss.im/ipd-tcs/toss_core/live/1c61cf95-b20c-49fd-ad0a-b8a011decab5/3.노드.png"/><link rel="preload" as="image" href="https://static.toss.im/ipd-tcs/toss_core/live/e20bfa2b-2277-48f5-812c-6a54b8edb9a5/4.캐릭터_수정과정.webp"/><link rel="preload" as="image" href="https://static.toss.im/ipd-tcs/toss_core/live/25fd626f-b930-4105-94fe-40c127ab47d0/6.최종캐릭터.png"/><div class="css-1vn47db"><p id="3eaa360d-33e3-80e6-b9bb-d039d4fd3e62" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">안녕하세요, 토스뱅크 Visual Designer 고명은입니다. 오늘은 키워봐요 적금의 9가지 다람쥐 캐릭터와, 다람쥐가 돋보이는 화면을 만든 과정을 이야기해 보려고 해요.</span></span></span></p><p id="3eaa360d-33e3-8065-87e9-cfde8f036f8f" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"></span></span></p><h2 id="3eaa360d-33e3-80b5-a53d-fdcc49728dbd" class="css-1c1qox8"><span class="css-1r3ko7u"><span class="css-p4abj2"><span class="css-q3ktjb">매일 찾아와야 자라는 적금</span></span></span></h2><figure id="3eaa360d-33e3-807b-9127-ccb97a6c1d30" class="css-of5acw"><video muted="" loop="" autoPlay="" playsInline="" class="css-8atqhb"><source src="https://static.toss.im/ipd-tcs/toss_core/live/21b88571-8f63-46cb-9b4c-2dc5a3b53018/1.캐릭터와_이자.mp4"/></video></figure><p id="3eaa360d-33e3-8057-851a-dd9f2c08d322" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">키워봐요 적금은 캐릭터를 직접 키우면서 저금하는 적금이에요. 키우고 싶은 캐릭터를 정할 수 있고, 저금할 때마다 자라서 만기 때는 최종 형태로 진화하죠.</span></span></span></p><p id="3eaa360d-33e3-80f6-91d7-d160ca7b509a" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">초기 버전은 가입만 하면 그 뒤로는 사용자가 앱에 오지 않아도 자동이체를 통해 캐릭터가 알아서 자랐어요. 시간이 지날수록 리텐션이 떨어졌죠. 매일 들여다볼 이유가 없으니, 가입한 뒤 점점 잊히는 적금이 된 거예요.</span></span></span></p><p id="3eaa360d-33e3-8059-8d80-cabb104cbc2c" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-q3ktjb">그래서 새로운 버전에서는 정반대로 설계하고자 했어요. 자동이체 없이 사용자가 직접 저금해야만 캐릭터와 이자가 자라도록요.</span><span class="css-1kxrhf3"> 이 구조가 효과적이려면 두 가지가 필요했어요. 첫 번째, 캐릭터 자체가 매력적이어서 키우고 싶어야 할 것. 두 번째, 내가 직접 키우고 있다는 감각이 전달될 것. 저는 최종 진화 단계의 다람쥐 9마리와 이 다람쥐들을 키우는 화면을 만드는 일을 담당하게 됐어요.</span></span></span></p><p id="3eaa360d-33e3-800c-a11e-ebc2d67cf841" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"></span></span></p><h2 id="3eaa360d-33e3-8072-89b3-fcb53a84d51c" class="css-1c1qox8"><span class="css-1r3ko7u"><span class="css-p4abj2"><span class="css-1kxrhf3">101명의 연습생에서 9명의 데뷔조까지</span></span></span></h2><p id="3eaa360d-33e3-80b2-9e08-e7c5ce6b8865" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">짧은 시간 안에 최대한 많은 시안을 만들어보고 좋은 캐릭터를 찾아야 했기 때문에 AI를 적극적으로 활용했어요. </span></span></span></p><p id="3eaa360d-33e3-8061-88c9-f41876f303ee" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"></span></span></p><p id="3eba360d-33e3-80f5-93d9-ced218394d73" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-q3ktjb">9마리가 저마다 다른 매력을 갖게 하고 싶었어요.</span><span class="css-1kxrhf3"> 성공한 9인조 아이돌처럼, 누구에게 물어봐도 최애가 갈릴 만큼 멤버의 개성이 뚜렷하면서도 한데 모아놓으면 한 팀처럼 보이길 바랐죠.</span></span></span></p><p id="3eba360d-33e3-80d5-83ab-e3abe91259d2" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">그런데 이 다람쥐들은 단순히 9가지 캐릭터를 만드는 게 아니었어요. </span><span class="css-q3ktjb">31일 동안 쌓인 매력·지혜·힘 스탯에 따라 최종 모습이 결정되는 구조</span><span class="css-1kxrhf3">였거든요. 그래서 캐릭터를 먼저 그리기보다, </span><span class="css-q3ktjb">각 스탯이 쌓였을 때 어떤 성격과 매력을 가진 다람쥐가 나와야 할지부터 정했어요.</span><span class="css-1kxrhf3"> 귀여운 애, 초현실적인 애, 강인한 애처럼 각자 어떤 개성을 가질지 정하고, 그에 맞춰 캐릭터의 콘셉트를 만들어갔어요.</span></span></span></p><figure id="3eaa360d-33e3-80b5-96ae-dd1a580eac53" class="css-of5acw"><video muted="" loop="" autoPlay="" playsInline="" class="css-8atqhb"><source src="https://static.toss.im/ipd-tcs/toss_core/live/2d172961-0f03-468c-b74c-d10d893f13d4/2.캐릭터_컨셉.mp4"/></video></figure><p id="3eba360d-33e3-80ab-a81a-f52419d31560" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"></span></span></p><p id="3eba360d-33e3-80f7-9e99-d3508e2c79ba" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">예를 들어 지혜와 힘이 함께 쌓이면 똑똑하면서도 강인한 우주비행사로, 매력만 쌓이면 무대 위의 아이돌로 표현하는 식이었어요. </span><span class="css-q3ktjb">캐릭터마다 어떤 모습과 이야기를 가질지 정하고 나니, 프롬프트에 담아야 할 요소도 자연스럽게 정해졌어요.</span><span class="css-1kxrhf3"> 표정과 의상, 소품부터 조명까지 모두 캐릭터의 설정에서 가져왔죠.</span></span></span></p><p id="3eba360d-33e3-80d1-8a1c-e29865e51398" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">처음부터 잘 짜인 프롬프트가 있었던 건 아니에요. 노드 기반 AI 이미지 툴에서 여러 번 테스트하면서, 모든 캐릭터에 공통으로 들어가는 고정 문장과 캐릭터마다 바꿔 넣는 빈칸으로 나눈 뼈대를 만들었어요.</span></span></span></p><p id="3eba360d-33e3-8047-a011-e714d652101b" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"></span></span></p><figure id="3eaa360d-33e3-8075-80f4-c5f59ed8476b" class="css-of5acw"><img src="https://static.toss.im/ipd-tcs/toss_core/live/1c61cf95-b20c-49fd-ad0a-b8a011decab5/3.노드.png" alt="" class="css-1pgssrp"/></figure><p id="3eaa360d-33e3-806b-8aae-dff682b92c4c" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">뼈대가 잡히자 속도가 확 빨라졌어요. 서핑 다람쥐를 아이돌 다람쥐로 바꾸고 싶으면 새 프롬프트를 쓸 필요 없이 빈칸만 갈아 끼우면 됐거든요. 처음에는 백 장을 뽑아야 쓸 만한 하나가 나왔다면, 나중에는 서른 장, 더 나중에는 두세 장만으로도 그럴싸한 다람쥐 연습생을 캐스팅할 수 있었어요.</span></span></span></p><p id="3eaa360d-33e3-8064-8842-e842e68e6ec3" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"></span></span></p><p id="3eaa360d-33e3-8002-b01d-c105ffa1af5a" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"></span></span></p><p id="3eaa360d-33e3-801d-9490-ccf4e740431e" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"></span></span></p><p id="3eaa360d-33e3-80f0-b0ce-fea6b0c27f60" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"></span></span></p><p id="3eaa360d-33e3-80bf-b65e-cf477c1b9e11" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">AI가 만들어준 캐릭터는 바로 데뷔시킬 정도로 완성도가 높지는 않았어요.</span></span></span></p><p id="3eaa360d-33e3-804a-9d6a-cf05a9afe3e4" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">캐릭터를 조금이라도 만져본 분이라면 아실 거예요. </span><span class="css-q3ktjb">표정의 작은 픽셀 하나, 캐릭터가 입은 옷과 손에 든 아이템 하나가 캐릭터의 인상을 크게 좌우해요.</span></span></span></p><p id="3eaa360d-33e3-80d1-89be-e72c2d629b7d" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">게다가 이 다람쥐들은 손바닥만 한 앱 화면 한가운데에 작게 들어가요. 작게 들어가는 리소스일수록 디테일이 뭉개지기 쉽고, 뭉개진 디테일은 곧 뭉개진 인상이 돼요. 결국 이 작업은 처음부터 끝까지 디테일 싸움이었어요.</span></span></span></p><figure id="3eaa360d-33e3-800e-81fe-cb1f8dcaf3c9" class="css-of5acw"><img src="https://static.toss.im/ipd-tcs/toss_core/live/e20bfa2b-2277-48f5-812c-6a54b8edb9a5/4.캐릭터_수정과정.webp" alt="" class="css-1pgssrp"/></figure><p id="3eba360d-33e3-801a-b8d4-e88ac5ee3646" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">처음에는 좋은 부분들을 하나씩 얹으며 작업했어요. 질감과 장식, 소품을 겹겹이 쌓을수록 캐릭터는 화려해졌죠. 그런데 실제 앱 화면에 들어가는 크기로 줄여보면 오히려 인상이 흐려지는 경우가 많았어요. 요소들이 서로를 가려서, 뭉개지는 정도가 아니라 하나로 뭉쳐 보였거든요.</span></span></span></p><p id="3eba360d-33e3-80e0-9333-f51c784e7f9c" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-q3ktjb">그래서 어느 순간부터 ‘무엇을 더 넣을지’보다 ‘무엇을 남길지’를 먼저 생각했어요.</span></span></span></p><p id="3eba360d-33e3-80ec-81f0-c18c3a8121da" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">작은 화면에서도 가장 먼저 보여야 하는 표정과 실루엣은 남기고, 캐릭터의 인상을 흐리는 장식이나 디테일은 덜어냈어요. 많이 넣는 것보다 작은 화면에서도 캐릭터의 매력이 선명하게 보이는 것이 더 중요했으니까요.</span></span></span></p><p id="3eba360d-33e3-805c-b3d8-e46b77e8ce62" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">AI로 수많은 캐릭터를 빠르게 만들어볼 수 있었지만, 그중에서 무엇을 남기고 어떻게 다듬을지는 결국 디자이너가 판단해야 했어요.</span></span></span></p><p id="3eaa360d-33e3-801e-aa23-ed46d7b52d0f" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"></span></span></p><p id="3eaa360d-33e3-8014-845c-e6e9c6590951" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">다람쥐는 31일 동안 여러 단계를 거쳐 자라고, 마지막에야 아홉 가지 모습 중 하나로 진화해요. 최종 진화형은 혼자 존재하는 그림이 아니라, 이전 단계의 다람쥐에서 이어지는 마지막 장면이었던 거예요.</span></span></span></p><figure id="3eaa360d-33e3-809a-ad85-cd51e177c13b" class="css-of5acw"><video muted="" loop="" autoPlay="" playsInline="" class="css-8atqhb"><source src="https://static.toss.im/ipd-tcs/toss_core/live/848ba1b7-8933-47e5-a3ca-a5e06afe8f5d/5.계승한_것.mp4"/></video></figure><p id="3eba360d-33e3-8047-ae07-c2d2ef811d48" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">여기서 어려웠던 건 </span><span class="css-q3ktjb">‘내가 키우던 다람쥐’라는 느낌과 ‘드디어 진화했다’는 느낌을 동시에 만드는 것</span><span class="css-1kxrhf3">이었어요. 이전 단계와 너무 비슷하면 진화한 느낌이 없고, 너무 다르면 전혀 다른 캐릭터처럼 보이니까요.</span></span></span></p><p id="3eba360d-33e3-80bb-afff-dbea36d3fd90" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">그래서 몸의 비율과 귀, 꼬리, 일부 이목구비의 모양은 그대로 가져갔어요. 대신 피부색과 옷, 아이템처럼 캐릭터의 성격을 보여주는 요소는 크게 바꿨어요. 익숙한 형태는 이어가면서도, 31일 동안 키운 결과는 확실히 느껴지도록 한 거죠.</span></span></span></p><p id="3eaa360d-33e3-8080-8a81-d1b3dea3eb5f" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"></span></span></p><figure id="3eaa360d-33e3-8015-ad95-ccf3374c800b" class="css-of5acw"><img src="https://static.toss.im/ipd-tcs/toss_core/live/25fd626f-b930-4105-94fe-40c127ab47d0/6.최종캐릭터.png" alt="" class="css-1pgssrp"/></figure><p id="3eba360d-33e3-803e-9413-f9a88a8eb767" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">그렇게 디테일까지 다듬은 아홉 마리를 나란히 놓아봤어요. 한 팀으로 보이면서도 각자의 개성은 분명했어요. 팀에 공유했을 때도 다들 서로 다른 다람쥐를 최애로 꼽았고요.</span></span></span></p><p id="3eba360d-33e3-80ac-8d35-c013ddc5a011" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">그렇게 9명의 데뷔조가 확정됐어요.</span></span></span></p><p id="3eaa360d-33e3-80ca-a02e-dc95daccf295" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"></span></span></p><h2 id="3eaa360d-33e3-8029-9303-cb235f4f05cd" class="css-1c1qox8"><span class="css-1r3ko7u"><span class="css-p4abj2"><span class="css-1kxrhf3">캐릭터가 주인공인 무대 만들기</span></span></span></h2><p id="3eba360d-33e3-8081-a0d1-d14a28dffe50" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">다람쥐가 완성됐다고 끝이 아니었어요. 일반적인 적금 화면에 다람쥐를 올려보니 캐릭터만 스티커처럼 붕 떠 보였어요. 반대로 게임처럼 배경의 밀도를 높이면 캐릭터에 집중하기 어렵고 화면이 오히려 올드해 보였고요.</span></span></span></p><p id="3eba360d-33e3-80ba-b21c-ea7aedefbfa2" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">기존의 UI 문법 위에 캐릭터만 얹어서는, 다람쥐가 주인공인 화면을 만들 수 없었어요. 다람쥐는 다람쥐대로, UI는 UI대로 따로 놀았죠.</span></span></span></p><p id="3eba360d-33e3-80c0-a6d9-f1e66677289b" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">그래서 이 적금만을 위한 작은 디자인 시스템과 UI 프로토타입을 새로 만들었어요. 진행 바와 선택 영역부터 레벨과 스탯을 보여주는 캐릭터 상태창까지, 캐릭터를 중심으로 화면의 구조와 디테일을 다시 잡았어요.</span></span></span></p><figure id="3eaa360d-33e3-805f-a0e8-d8b32326272d" class="css-of5acw"><video muted="" loop="" autoPlay="" playsInline="" class="css-8atqhb"><source src="https://static.toss.im/ipd-tcs/toss_core/live/c233b2cd-adcf-439b-b64e-ccfbf114e8e6/7.ui디테일.mp4"/></video></figure><ul class="css-uswsmm"><li id="3eba360d-33e3-80a3-ae1d-e2ab34313644" class="css-1hwiibq"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-q3ktjb">모은 돈은 가장 위에, 가장 크게</span></span></span><div class="css-1vn47db"><p id="3eba360d-33e3-80f7-8bbe-fb1c732637ed" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">게임처럼 보여도 이건 적금이에요. 그래서 계좌에 모인 금액은 화면 맨 위에 가장 큰 숫자로 뒀어요. 캐릭터를 키우다가도 눈을 들면 내가 모은 돈이 얼마나 되는지 바로 알 수 있게 했어요.</span></span></span></p></div></li><li id="3eba360d-33e3-8049-9d0b-d79c21f68c04" class="css-1hwiibq"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-q3ktjb">그림자로 공간감을 줬어요</span></span></span><div class="css-1vn47db"><p id="3eba360d-33e3-80b7-afd4-c93fbdb125ce" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">캐릭터가 허공에 떠 보이지 않도록 발밑에 그림자를 깔았어요. 말풍선에도 은은한 그림자를 넣어 영역을 구분하고, 화면에 깊이감을 더했어요.</span></span></span></p></div></li><li id="3eba360d-33e3-8004-8bbf-d9fcd8de27f8" class="css-1hwiibq"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-q3ktjb">상태창 하나에 필요한 정보를 담았어요</span></span></span><div class="css-1vn47db"><p id="3eba360d-33e3-803a-9818-ccb259849120" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">레벨과 이름, 세 가지 스탯, 다음 진화까지 남은 날을 하나의 상태창에 묶었어요. 진행 바에는 발자국 아이콘을 넣어 다람쥐가 지금까지 걸어온 길처럼 읽히게 했고요.</span></span></span></p></div></li><li id="3eba360d-33e3-8018-a9e2-d8eb866490c8" class="css-1hwiibq"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-q3ktjb">선택지와 스탯을 같은 언어로 연결했어요</span></span></span><div class="css-1vn47db"><p id="3eba360d-33e3-80ad-a395-f4755744dd29" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">설명을 읽지 않아도 ‘이걸 고르면 이 스탯이 오른다’는 걸 알 수 있도록, 선택지 옆의 매력·지혜·힘 아이콘과 상태창의 스탯 아이콘을 같은 모양으로 만들었어요.</span></span></span></p></div></li></ul><p id="3eaa360d-33e3-80f1-ba7d-f0103de48fcb" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"></span></span></p><p id="3eba360d-33e3-8054-805a-c53f8997d5d2" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">캐릭터와 말풍선 사이의 간격, 스탯과 진행 바의 정렬도 픽셀 단위로 맞췄어요. 하나만 보면 티가 나지 않지만, 이런 요소가 조금씩 어긋나면 화면 전체가 조립한 것처럼 보이거든요.</span></span></span></p><p id="3eba360d-33e3-8048-9a68-fa60549b7be7" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-q3ktjb">캐릭터를 UI에 올려놓는 것이 아니라, UI 전체가 캐릭터를 하나의 주인공으로 만들어주는 것.</span><span class="css-1kxrhf3"> 그게 이 화면을 만들면서 가장 중요하게 생각한 부분이었어요.</span></span></span></p><p id="3eaa360d-33e3-80ca-bbd6-db9af22e4958" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"></span></span></p><p id="3eaa360d-33e3-80ba-9169-cc35253d8530" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"></span></span></p><h2 id="3eaa360d-33e3-8004-927f-ee1cfaeae0dd" class="css-1c1qox8"><span class="css-1r3ko7u"><span class="css-p4abj2"><span class="css-1kxrhf3">매일 찾아오게 만드는 인터랙션</span></span></span></h2><p id="3eba360d-33e3-80f8-8f01-ed08db265c10" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">무대 세팅까지 끝! 다음 단계는 다람쥐와 사용자 사이의 인터랙션을 설계하는 것이었어요.</span></span></span></p><p id="3eba360d-33e3-8004-a5bb-fb2f47bb28dd" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">다람쥐와 무대가 아무리 매력적이어도, 오늘 저금한 일이 별다른 경험으로 남지 않으면 금방 지루해질 수 있어요. </span><span class="css-q3ktjb">키우고 싶은 마음은 31일 뒤의 최종 모습이 아니라, 오늘 한 번의 저금에서도 생겨야 했어요.</span><span class="css-1kxrhf3"> 그래서 선택지를 고르는 그 한 번의 탭에 화면 전체가 대답하도록 만들었어요.</span></span></span></p><p id="3eba360d-33e3-809d-8091-c5b00e160c9a" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">단순히 캐릭터를 잘 만드는 데서 끝내지 않고, 이 캐릭터를 어떻게 보고, 키우고, 다시 만나게 할지까지 고민한 거예요.</span></span></span></p><figure id="3eaa360d-33e3-80db-a864-f4fc1fe80827" class="css-of5acw"><video muted="" loop="" autoPlay="" playsInline="" class="css-8atqhb"><source src="https://static.toss.im/ipd-tcs/toss_core/live/66b17616-8d90-4165-af49-a8ff16db471e/8.인터랙션.mp4"/></video></figure><p id="3eba360d-33e3-8050-8785-c1fa0b793e4d" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">탭하는 순간 저금액과 스탯 숫자가 함께 올라가요. 오늘 넣은 돈이 그대로 다람쥐의 성장으로 이어지는 걸 한눈에 볼 수 있게 했어요.</span></span></span></p><p id="3eba360d-33e3-80a6-b8b9-e9d4eacba6b4" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">숫자만 변하는 것도 아니에요. 성장 단계마다 여러 가지 캐릭터 모션을 만들어, 저금할 때마다 다람쥐가 다르게 반응하도록 했어요. 내가 한 행동에 캐릭터가 바로 반응하니, 다람쥐와 함께 무언가를 쌓아가는 느낌이 들도록 한 거죠.</span></span></span></p><figure id="3eaa360d-33e3-8048-8c31-d68918617793" class="css-of5acw"><video muted="" loop="" autoPlay="" playsInline="" class="css-8atqhb"><source src="https://static.toss.im/ipd-tcs/toss_core/live/7ed1c2d3-88d5-412f-8d95-9d0d3f6031f4/9.캐릭터_모션_에셋들.mp4"/></video></figure><figure id="3eaa360d-33e3-809d-8981-edea4977c4b5" class="css-of5acw"><video muted="" loop="" autoPlay="" playsInline="" class="css-8atqhb"><source src="https://static.toss.im/ipd-tcs/toss_core/live/b03c0890-e11e-4a43-a0f8-441ec8b2e321/10._내일의_기대.mp4"/></video></figure><p id="3eba360d-33e3-804e-b92a-dba90b1e7297" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">오늘의 보람만으로 매일 다시 들어올 이유를 만들기는 어려웠어요. 다음 진화까지 얼마나 남았는지도 보여줘야 했어요.</span></span></span></p><p id="3eba360d-33e3-8093-a420-c54b44e7f84a" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">그래서 오늘의 대화를 마치면 다람쥐가 걸어가듯 발자국 아이콘이 진행 바를 채워요. 남은 날짜를 단순히 숫자로 보여주는 대신, 다람쥐가 다음 모습에 한 걸음씩 가까워지는 장면으로 표현했어요.</span></span></span></p><p id="3eba360d-33e3-8038-bad0-c546b6daccdd" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">오늘 쌓인 저금액과 스탯은 </span><span class="css-q3ktjb">오늘의 보람</span><span class="css-1kxrhf3">이 되고, 다음 진화에 가까워진 모습은 </span><span class="css-q3ktjb">내일 다시 들어올 이유</span><span class="css-1kxrhf3">가 돼요.</span></span></span></p><p id="3eba360d-33e3-8008-9514-d8f643bfe2d2" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">매일의 저금이 통장에서 ‘빠져나간 돈’으로만 남는 게 아니라, 다람쥐와 함께 ‘쌓인 뿌듯함’으로 느껴졌으면 했어요.</span></span></span></p><p id="3eaa360d-33e3-80b2-9f89-e4f06ede9a9f" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"></span></span></p><p id="3eaa360d-33e3-80a8-8ce1-e01243dec06c" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"></span></span></p><p id="3eaa360d-33e3-800d-926d-ce9b45573632" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"></span></span></p><p id="3eaa360d-33e3-8073-911f-f6b13039bb7b" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"></span></span></p><h2 id="3eaa360d-33e3-8038-b4c5-d7a572cbfaf4" class="css-1c1qox8"><span class="css-1r3ko7u"><span class="css-p4abj2"><span class="css-1kxrhf3">적용해보기</span></span></span></h2><p id="3eba360d-33e3-80e7-804a-c9c34af14568" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">키워봐요 적금을 만들면서 제가 계속 붙들었던 질문들이에요. 캐릭터를 중심으로 서비스를 만들고 있거나, 캐릭터의 매력을 화면과 인터랙션으로 어떻게 보여줄지 고민하고 있다면 이런 질문을 던져보세요.</span></span></span></p><ul class="css-uswsmm"><li id="3eba360d-33e3-80fc-b153-c789b2a552b9" class="css-1hwiibq"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-q3ktjb">캐릭터의 매력을 화면 전체에서 보여주고 있나요?</span></span></span><div class="css-1vn47db"><p id="3eba360d-33e3-8077-b3eb-f857fe76437f" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">캐릭터만 바꾸는 게 아니라, 주변의 UI도 캐릭터에 맞게 달라져야 화면 전체가 하나의 경험으로 읽혀요.</span></span></span></p></div></li><li id="3eba360d-33e3-8033-91ec-e796ce78f636" class="css-1hwiibq"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-q3ktjb">작은 화면에서 정말 남아야 할 것은 무엇인가요?</span></span></span><div class="css-1vn47db"><p id="3eba360d-33e3-8082-82c5-f92da56573a2" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">좋은 요소를 계속 더하기보다, 작은 화면에서도 캐릭터의 인상을 가장 잘 보여주는 요소를 남기는 게 중요해요.</span></span></span></p></div></li><li id="3eba360d-33e3-8005-84f4-ead484d4cfb9" class="css-1hwiibq"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-q3ktjb">캐릭터와 사용자의 행동이 연결되어 있나요?</span></span></span><div class="css-1vn47db"><p id="3eba360d-33e3-80a6-a780-f3209cea65a6" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">사용자의 행동에 캐릭터가 바로 반응해야, 캐릭터를 보는 경험이 실제 행동과 자연스럽게 이어져요.</span></span></span></p></div></li></ul><p id="3eba360d-33e3-80af-9b51-c6a4becce3a5" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"></span></span></p><p id="3eba360d-33e3-80af-8d60-db30238fd5e7" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"></span></span></p><div class="css-1lvcgm8"><button class="tds-mobile-button _28nbsw0 css-13ko30i" data-tds-mobile-component="Button" aria-busy="false" aria-live="off" style="--button-font-size:var(--tds-t-t5-text-fontSize, --tds-t-f17-text-fontSize);--button-border-radius:14px;--button-min-width:80px;--button-min-height:48px;--button-padding:2px 16px;--button-loader-size:8px;--button-loader-gap:5px;--button-display:inline-flex;--button-width:auto;--button-background-color:#3182f6;--button-color:#ffffff;--button-dimmer-pressed-background-color:rgb(0,0,0);--button-dimmer-loading-background-color:rgba(0, 0, 0, 0.26);--button-gradient-color:rgba(0, 89, 195, 1);--button-loader-color:#fff;opacity:1;transform:none"><div class="_28nbsw1" style="background-color:var(--button-background-color, #3182f6)"></div><span class="tds-mobile-button__content _28nbsw3" style="opacity:1;color:var(--tds-paragraph-color)">토스뱅크 대규모채용 보기</span><div class="_28nbsw4" style="opacity:0"></div><div class="_28nbsw2" style="opacity:0"></div></button></div></div><div id="tds-mobile-portal-container"></div>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[연 300시간을 아낀 AI 상담 서비스]]></title>
            <link>https://toss.tech/article/AI_chatbot</link>
            <guid isPermaLink="true">https://toss.tech/article/AI_chatbot</guid>
            <pubDate>Wed, 23 Sep 2026 06:39:00 GMT</pubDate>
            <description><![CDATA[전화 문의를 줄이려다 사용자의 경험에서 답을 찾은 이야기예요.]]></description>
            <content:encoded><![CDATA[<link rel="preload" as="image" href="https://static.toss.im/ipd-tcs/toss_core/live/b4917129-a240-4d02-a961-e732f9d42281/image.png"/><link rel="preload" as="image" href="https://static.toss.im/ipd-tcs/toss_core/live/4168c82e-dd1a-49ff-979b-1426b96d2468/Frame_85.png"/><link rel="preload" as="image" href="https://static.toss.im/ipd-tcs/toss_core/live/b9638b70-ce25-4f13-a12d-0adcf677d5aa/Frame_85-2.png"/><div class="css-1vn47db"><p id="3dfa360d-33e3-8034-a8d8-d47ef31a7577" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"></span></span></p><p id="3dfa360d-33e3-8071-822d-d0cd0b36f332" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">안녕하세요. 토스뱅크에서 주택담보대출을 디자인하고 있는 Product Designer 김혜미입니다.
이번 글에서는 전세대출 팀에서 AI 상담 서비스를 디자인했던 과정을 얘기해 보려고 해요.</span></span></span></p><p id="3dfa360d-33e3-80c7-8c94-fc0c6721c208" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"></span></span></p><h2 id="3dfa360d-33e3-80b3-84cb-faeaae2b26ec" class="css-1c1qox8"><span class="css-1r3ko7u"><span class="css-p4abj2"><span class="css-1kxrhf3">정보는 충분했지만 확신은 없었다</span></span></span></h2><figure id="3eca360d-33e3-8064-9ece-e1194a6ac055" class="css-of5acw"><img src="https://static.toss.im/ipd-tcs/toss_core/live/b4917129-a240-4d02-a961-e732f9d42281/image.png" alt="" class="css-1pgssrp"/></figure><p id="3dfa360d-33e3-80c0-94b1-c020bfb6f18e" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">전세대출 팀에서 하는 고민은 대체로 이런 거예요. 플로우를 어떻게 더 쉽게 설계할까, 어떻게 전환율을 높일까. 그런데 그것만으로는 해결하기 어려운 문제가 있었어요. 사용자가 화면을 다 이해하고도 여전히 불안해서 고객센터를 찾는 것이죠.</span></span></span></p><p id="3dfa360d-33e3-809e-a1a8-d4dc905fd119" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">상담팀에서 사용자들이 서류를 제대로 제출하지 못해 심사가 자꾸 막힌다는 이야기를 전해주셨어요. 그때 처음으로 상담팀에 들어오는 문의 데이터를 직접 찾아봤어요. </span><span class="css-q3ktjb">데이터를 열어보니 서류 제출 방법뿐만 아니라 단순한 정보를 묻는 문의량이 유독 높았어요.</span><span class="css-1kxrhf3"> 이런 문의가 상담 인력을 계속 붙잡다 보니, 정작 심사가 늦어지는 일까지 생기고 있었고요.</span></span></span></p><p id="3dfa360d-33e3-80f7-a42a-cd09d767c357" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">사실 이런 정보는 화면에 이미 쉽게 설명되어 있었어요. </span><span class="css-q3ktjb">그런데도 사용자는 화면에서 답을 찾지 못하고 결국 고객센터를 찾았던 거예요. 그 정보가 내 상황에도 맞는지 확신할 수 없었던 거죠.</span></span></span></p><p id="3dfa360d-33e3-8026-93d6-fc3d2af65810" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">그래서 상담으로 들어오는 단순 문의를 제품 안에서 해결할 방법을 찾아보기로 했어요.</span></span></span></p><p id="3dfa360d-33e3-8019-a863-dfb807a5d38c" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"></span></span></p><h2 id="3dfa360d-33e3-804c-a31b-edb25e2a80d5" class="css-1c1qox8"><span class="css-1r3ko7u"><span class="css-p4abj2"><span class="css-1kxrhf3">설명은 멈추고 질문받기</span></span></span></h2><p id="3dfa360d-33e3-807f-9ad1-de57ab810a45" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">화면에 있는 정보를 더 잘 보이게 만드는 것만으로는 문제를 해결하기 어렵다고 봤어요.</span></span></span></p><p id="3dfa360d-33e3-8075-95e9-f5ab1f48d3ac" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">사용자는 답을 못 찾는 게 아니었어요. 화면에서 정보를 찾고, 내게 필요한 내용을 골라내고, 그 답이 정말 내 상황에 맞는지 판단하는 것까지 모두 사용자의 몫이었어요.</span></span></span></p><p id="3dfa360d-33e3-8097-b26e-dc0a10dde8a7" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">전세대출에서는 이런 과정이 특히 부담스러워요. 집을 구하는 데 필요한 큰돈이 걸려 있기 때문이에요. 조금이라도 확실하지 않으면 ‘아마 맞겠지’ 하고 넘어가기 어렵죠. 결국 사람에게 직접 확인하고 싶어져요.</span></span></span></p><p id="3dfa360d-33e3-8007-9e27-c23d606c9a15" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">그래서 방향을 바꿨어요. </span><span class="css-q3ktjb">화면이 먼저 설명하는 방식이 아니라, 사용자가 자기 말로 묻고 제품이 답하는 방식으로요.</span></span></span></p><p id="3dfa360d-33e3-80f1-ae91-ee52149e608e" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">상담원에게 묻는 것처럼, 사용자가 어떤 말로 질문하더라도 그 뜻을 이해하고 각자의 상황에 맞는 답을 해주는 게 목표였어요.</span></span></span></p><p id="3dfa360d-33e3-80a7-8fbf-c478752fb099" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">사용자들은 궁금한 건 비슷해도 제각각 말해요. 누군가는 “태아도 자녀로 쳐요?”라고 하고, 누군가는 “임신 중인데 부양가족에 들어가나요?”라고 물어요. 여기에 보증금 규모나 계약 형태, 신청 시점 같은 조건까지 달라지면 답도 달라지고요.</span></span></span></p><p id="3dfa360d-33e3-806f-a49d-e0dba5149ca5" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-q3ktjb">결국 사용자의 말을 이해하고, 상황에 맞는 답을 만들어내려면 AI가 필요했어요.</span><span class="css-1kxrhf3"> </span><span class="css-q3ktjb">그래서 머신러닝 팀과 대출 운영팀에 프로젝트를 제안했고, 세 팀이 모여 AI 상담 서비스를 만들기 시작했어요.</span></span></span></p><p id="3dfa360d-33e3-8028-a926-ed4951d77862" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">디자이너인 저는 AI가 할 수 있는 것과 없는 것을 화면에 어떻게 풀어낼지 고민했어요. 대출 운영팀은 실제 상담에서 어떤 답을 해야 하는지 알려주었고, 머신러닝 팀은 그 답을 AI가 할 수 있도록 만들었어요.</span></span></span></p><p id="3dfa360d-33e3-804c-b4ff-f2685037549c" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"></span></span></p><p id="3dfa360d-33e3-8072-93b8-dc5728361385" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"></span></span></p><p id="3dfa360d-33e3-8062-a278-d5f551c6efb4" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"></span></span></p><p id="3dfa360d-33e3-80da-9fda-f24d9be49b93" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"></span></span></p><p id="3dfa360d-33e3-8019-84d9-e23b5fb796b7" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"></span></span></p><p id="3dfa360d-33e3-809d-879a-e77849029919" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"></span></span></p><p id="3dfa360d-33e3-80ef-bfff-faea1ff88a63" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"></span></span></p><p id="3dfa360d-33e3-80e5-87a6-f9b7934c3a17" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"></span></span></p><h2 id="3dfa360d-33e3-8009-9ace-def1af9ebbaa" class="css-1c1qox8"><span class="css-1r3ko7u"><span class="css-p4abj2"><span class="css-1kxrhf3">사용자가 묻고 싶게 만들기</span></span></span></h2><p id="3dfa360d-33e3-8073-bcbd-ff749e81341a" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">그렇게 AI를 출시하고, 상품 인트로 화면에 진입점을 놓았어요. &quot;대출 신청하기 전, 궁금한 점 AI에게 물어보기&quot;라는 배너였고, 누르면 빈 입력창이 열리는 구조였죠. </span></span></span></p><figure id="3dfa360d-33e3-8039-aea7-c951e46b3b77" class="css-of5acw"><img src="https://static.toss.im/ipd-tcs/toss_core/live/4168c82e-dd1a-49ff-979b-1426b96d2468/Frame_85.png" alt="" class="css-1pgssrp"/></figure><p id="3dfa360d-33e3-801c-a36b-e6c1fe11e3ca" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-q3ktjb">그런데 거의 아무도 누르지 않았어요.</span><span class="css-1kxrhf3"> 상담 전화가 줄어드는 경향은 보였지만, 쓰는 사람 자체가 너무 적어서 효과를 확인할 수조차 없었어요.</span></span></span></p><p id="3dfa360d-33e3-80ba-a24a-dafa36f49da7" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">저는 무엇을 물어야 할지 몰라서라고 봤어요. 빈 입력창만 덩그러니 있으면 무엇이든 물어볼 수 있지만, 반대로 어떤 질문부터 해야 할지는 사용자가 직접 정해야 하니까요. 이 AI가 어디까지 답해줄 수 있는지도 알기 어려웠을 거고요.</span></span></span></p><p id="3dfa360d-33e3-80ec-bb0e-f8e89f6a9e24" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">그래서 상담 문의 데이터를 다시 살펴봤어요. 사용자들이 실제로 무엇을 궁금해했는지 알 수 있었으니까요.</span><span class="css-q3ktjb"> 그리고 그 데이터를 바탕으로, 각 단계에서 사용자들이 가장 많이 물었던 질문을 진입점에 보여줬어요.</span></span></span></p><p id="3dfa360d-33e3-8054-bf8e-ce7aec02ae69" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"></span></span></p><figure id="3dfa360d-33e3-805a-a493-d37d40b8d5b5" class="css-of5acw"><img src="https://static.toss.im/ipd-tcs/toss_core/live/b9638b70-ce25-4f13-a12d-0adcf677d5aa/Frame_85-2.png" alt="" class="css-1pgssrp"/></figure><p id="3dfa360d-33e3-804e-9c59-c143c730aecf" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"></span></span></p><p id="3dfa360d-33e3-8051-8f21-cc14e8e9870b" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">상품 인트로에서는 금리와 한도가 얼마인지, 법인 임대인도 대출이 되는지를 보여줬어요. 심사 대기 중에는 심사가 언제 끝나는지, 결과는 언제 나오는지를 보여줬고요.</span></span></span></p><p id="3dfa360d-33e3-80be-96a5-ff8d92e147e6" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-q3ktjb">그 결과 인트로 화면의 AI 사용률은 약 5배, 심사 대기 화면은 약 2배 올랐어요.</span></span></span></p><p id="3dfa360d-33e3-80ab-8d29-f1c1d9007fa1" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">이 결과를 보면서, 사용자가 자기 말로 질문하게 하려면 먼저 어떤 질문을 할 수 있는지 보여줘야 한다는 걸 알게 됐어요. 다른 사람들이 실제로 물어본 질문을 보는 순간 “아, 이런 것도 물어봐도 되는구나” 하고 알게 되는 거죠. 그러고 나니 사용자들도 자기 상황을 덧붙여 자유롭게 질문하기 시작했어요.</span></span></span></p><p id="3dfa360d-33e3-80cd-b84c-f53de1a8fb06" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">미리 준비해 둔 문답만으로는 답하기 어려운 질문들이었어요. 사람마다 표현도, 상황도 달랐으니까요. 이렇게 예상하기 어려운 질문까지 받아낼 수 있다는 게 AI 상담의 장점이었어요.</span></span></span></p><p id="3dfa360d-33e3-8061-b401-f9885c70c86c" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"></span></span></p><h2 id="3dfa360d-33e3-80d4-9f3c-f11c50c2c4e4" class="css-1c1qox8"><span class="css-1r3ko7u"><span class="css-p4abj2"><span class="css-1kxrhf3">
</span><span class="css-q3ktjb">정보보다 중요한 것</span></span></span></h2><p id="3dfa360d-33e3-80ed-b9c3-ed919f033c92" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">AI에 질문하고 적합한 답변을 받은 사용자는 그렇지 않은 경우보다 상담 전화로 이어지는 비율이 약 10%p 낮았어요. 상담 전화는 월 550건 줄었고, 통화 시간으로 환산하면 </span><span class="css-q3ktjb">연 300시간</span><span class="css-1kxrhf3">이에요. 다른 대출 상품까지 적용하면 절감 규모가 지금의 열 배 이상으로 커질 것으로 보고 있고요.</span></span></span></p><p id="3dfa360d-33e3-803b-abad-e76599292ae2" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">이번 프로젝트를 하면서, </span><span class="css-q3ktjb">답을 보여주는 것과 확신을 주는 건 다른 일이라는 걸 알게 됐어요.</span></span></span></p><p id="3dfa360d-33e3-8074-8181-edd3ae12b5d2" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">전세대출 화면에는 필요한 정보가 이미 있었어요. 그런데도 사용자는 전화를 걸었죠. 자기 상황에도 이 정보가 적용되는지 확신할 수 없었기 때문이에요.</span></span></span></p><p id="3dfa360d-33e3-804f-aac1-f049c9d6d050" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">그래서 화면을 설계할 때는 ‘무엇을 알려줘야 하지?’에서 끝나면 안 된다는 걸 배웠어요. 이 정보를 보고 있는 사용자가 지금 무엇을 걱정하고 있는지, 무엇을 확신하지 못하고 있는지까지 봐야 했어요. 그래야 어떤 정보와 기능이 정말 필요한지도 보이더라고요.</span></span></span></p><p id="3dfa360d-33e3-8025-aa71-c15a369fc052" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"></span></span></p><p id="3dfa360d-33e3-804d-8bec-fba07d4a02a8" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"></span></span></p><h2 id="3dfa360d-33e3-80db-b226-c0bfb2fb44d0" class="css-1c1qox8"><span class="css-1r3ko7u"><span class="css-p4abj2"><span class="css-1kxrhf3">적용해보기</span></span></span></h2><aside id="3dfa360d-33e3-80b2-85d4-d0d0e1c58406" class="css-nv7vyi"><h4 class="css-123co55"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-q3ktjb">1️⃣ 화면 밖에 쌓인 신호 찾아보기</span></span></span></h4><div class="css-1vn47db"><p id="3dfa360d-33e3-80cb-8045-d7698d163dc2" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">사용자가 어디에서 멈췄는지는 화면 밖에 더 많이 기록되어 있어요. 지표 안에서는 사용자가 잘 넘어간 것처럼 보여도, 고객센터 문의나 다른 팀이 대신 받고 있는 일 안에 화면이 못 풀어준 문제가 남아 있을 수도 있어요. </span></span></span></p><p id="3dfa360d-33e3-8098-906d-fd44a508061b" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-q3ktjb">2️⃣ 근본적인 구조 의심하기</span></span></span></p><p id="3dfa360d-33e3-80f7-95e2-cfba78ca40e2" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">눈앞에 쉬운 개선안이 보일 때가 있어요. 더 잘 보이게 하고, 문구를 다듬고, 자리를 옮기는 것들이요. 그런데 그걸로 문제가 끝나는지 한 번 따져보세요. 아무리 다듬어도 안 풀린다면, 손봐야 할 건 화면이 아니라 그 화면이 작동하는 방식일 수 있어요.</span></span></span></p></div></aside><p id="3dfa360d-33e3-8061-9ee2-cf142ecc3164" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"></span></span></p><p id="3dda360d-33e3-80f8-bb8f-f9890ac902e0" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"></span></span></p><p id="3dca360d-33e3-8091-8b29-dc585d47e076" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"></span></span></p><div class="css-1lvcgm8"><button class="tds-mobile-button _28nbsw0 css-13ko30i" data-tds-mobile-component="Button" aria-busy="false" aria-live="off" style="--button-font-size:var(--tds-t-t5-text-fontSize, --tds-t-f17-text-fontSize);--button-border-radius:14px;--button-min-width:80px;--button-min-height:48px;--button-padding:2px 16px;--button-loader-size:8px;--button-loader-gap:5px;--button-display:inline-flex;--button-width:auto;--button-background-color:#3182f6;--button-color:#ffffff;--button-dimmer-pressed-background-color:rgb(0,0,0);--button-dimmer-loading-background-color:rgba(0, 0, 0, 0.26);--button-gradient-color:rgba(0, 89, 195, 1);--button-loader-color:#fff;opacity:1;transform:none"><div class="_28nbsw1" style="background-color:var(--button-background-color, #3182f6)"></div><span class="tds-mobile-button__content _28nbsw3" style="opacity:1;color:var(--tds-paragraph-color)">토스뱅크 대규모채용 보기</span><div class="_28nbsw4" style="opacity:0"></div><div class="_28nbsw2" style="opacity:0"></div></button></div></div><div id="tds-mobile-portal-container"></div>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[방해하지 않고, 눈에 띌 수 있을까]]></title>
            <link>https://toss.tech/article/asset_management</link>
            <guid isPermaLink="true">https://toss.tech/article/asset_management</guid>
            <pubDate>Tue, 22 Sep 2026 08:20:00 GMT</pubDate>
            <description><![CDATA[CVR을 3배 높이면서도 사용자 경험을 놓치지 않기 위해 고민한 과정을 들려드려요.]]></description>
            <content:encoded><![CDATA[<link rel="preload" as="image" href="https://static.toss.im/ipd-tcs/toss_core/live/7dc0e404-7e42-4c95-b375-b6ae8aa04f2a/기존_안.png"/><link rel="preload" as="image" href="https://static.toss.im/ipd-tcs/toss_core/live/013a25af-e245-4770-958b-dfe00eb8b6de/그렸던_시안5.png"/><link rel="preload" as="image" href="https://static.toss.im/ipd-tcs/toss_core/live/5c5f9846-8594-4ae3-80ac-d0f8a6fd8703/이거.png"/><link rel="preload" as="image" href="https://static.toss.im/ipd-tcs/toss_core/live/582d33d2-2bb1-4c66-b8de-f4bf24c1e39a/시안.png"/><link rel="preload" as="image" href="https://static.toss.im/ipd-tcs/toss_core/live/b27798cb-7076-41e6-98f5-798ff20c084d/image_converter_2026-09-15T15-47-09.webp"/><div class="css-1vn47db"><p id="3dfa360d-33e3-8028-a6eb-cfc7f5cd7fef" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">안녕하세요. 토스뱅크 Visual Designer 김현지입니다. 오늘은 </span><span class="css-q3ktjb">자산관리 리포트</span><span class="css-1kxrhf3"> </span><span class="css-q3ktjb">기능</span><span class="css-1kxrhf3">의 클릭률을 3배로 올리기까지의 과정, 그리고 그 안에서 얻은 러닝을 이야기해 보려고 해요.</span></span></span></p><h2 id="3dfa360d-33e3-80bd-8ffd-e62dd10f7646" class="css-1c1qox8"><span class="css-1r3ko7u"><span class="css-p4abj2"><span class="css-1kxrhf3">유용한 추천인데 왜 누르지 않을까</span></span></span></h2><figure id="3dfa360d-33e3-808c-9c7b-c67309fc7361" class="css-of5acw"><img src="https://static.toss.im/ipd-tcs/toss_core/live/7dc0e404-7e42-4c95-b375-b6ae8aa04f2a/기존_안.png" alt="" class="css-1pgssrp"/><figcaption class="css-wgpbp3"><span class="css-jfs1hr">기존 입출금 영역 하단에 배치된 자산관리 리포트</span></figcaption></figure><p id="3dfa360d-33e3-80cb-81f2-f447af262a68" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"></span></span></p><p id="3dfa360d-33e3-80e1-90c8-d6bf2d5a9f5f" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">자산관리 리포트는 </span><span class="css-q3ktjb">사용자의 자산 상태를 분석해 알맞은 상품이나 서비스를 추천하는 기능</span><span class="css-1kxrhf3">이에요. 토스뱅크 홈의 입출금 영역 아래에 배너로 노출하고 있었는데, 클릭률이 2%에 그쳤어요.</span></span></span></p><p id="3dfa360d-33e3-8075-be27-f86efb8fb83d" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">내 상황을 보고 해주는 추천인데, 왜 눌러보지 않을까요? 여러 가설을 세워본 끝에, 토스뱅크 홈에는 정보가 많다 보니 주변 정보와 충분히 구분되지 않아 그냥 지나칠 수 있겠다고 생각했어요.</span></span></span></p><p id="3dfa360d-33e3-8099-8d62-e649245e94b9" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">그래서 </span><span class="css-q3ktjb">리포트가 눈에 잘 띄면서도, 내 상황에 맞는 개인화된 추천이라는 점이 한눈에 느껴지도록 디자인</span><span class="css-1kxrhf3">하는 것을 목표로 잡았어요.</span></span></span></p><p id="3dfa360d-33e3-8063-8dc0-ee8a9bd28021" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"></span></span></p><p id="3dfa360d-33e3-802b-8bee-e22c5ab78ac5" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"></span></span></p><p id="3dfa360d-33e3-80c2-b028-f7c7b81b745a" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"></span></span></p><hr class="css-1ifza5r"/><h2 id="3dfa360d-33e3-80fa-885c-ff321de699bd" class="css-1c1qox8"><span class="css-1r3ko7u"><span class="css-p4abj2"><span class="css-1kxrhf3">표현을 바꿔도 화면은 여전히 복잡한 이유</span></span></span></h2><p id="3dfa360d-33e3-80ac-a3b7-cc196b047470" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">먼저 표현 방식을 바꿔봤어요. 말을 건네듯 제안하는 말풍선 형태, 사용자의 자산 상태를 먼저 보여준 뒤, 그에 맞는 상품을 차례로 제안하는 방식도 만들어봤죠. 직접 말해주는 것처럼 표현하면 추천이 더 와닿을 것 같았어요.</span></span></span></p><figure id="3dfa360d-33e3-8073-87a4-e70363ddb31f" class="css-of5acw"><img src="https://static.toss.im/ipd-tcs/toss_core/live/013a25af-e245-4770-958b-dfe00eb8b6de/그렸던_시안5.png" alt="" class="css-1pgssrp"/><figcaption class="css-wgpbp3"><span class="css-jfs1hr">그렸던 시안들</span></figcaption></figure><p id="3dfa360d-33e3-80b2-aa32-f9a62137d787" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">그런데 화면은 여전히 복잡했어요. 저마다의 목적으로 찾아온 화면 사이에 읽을거리를 하나 더 얹는 구조는 달라지지 않았거든요. </span></span></span></p><p id="3dfa360d-33e3-80fb-9d8f-ea712848a6b5" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">문제는 계좌 정보와 추천이 같은 자리에서 시선을 나누고 있다는 것이었어요. 계좌는 사용자의 정보이고, 리포트는 저희가 새롭게 건네는 제안이에요. 역할이 다른 둘이 한데 섞여 있다 보니, 리포트도 계좌 정보를 훑어보는 과정에서 함께 지나치기 쉬웠어요.</span></span></span></p><p id="3dfa360d-33e3-803a-bffe-fa95471b550c" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">그렇다고 리포트만 무작정 강조할 수도 없었어요. 계좌를 확인하려는 사람에게 리포트가 먼저 보이면, 정작 필요한 정보가 잘 보이지 않을 수 있었어요.</span></span></span></p><p id="3dfa360d-33e3-8003-aa73-e61e5e07749f" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">리포트를 발견하게 하면서도, 하려던 일을 방해하지 않는 방법을 찾아야 했어요. 그래서 리포트의 위치 자체를 다시 생각했어요. 계좌 정보와 한자리에서 경쟁하게 하는 대신, 아예 다른 자리에서 보여주면 어떨까 하고요.</span></span></span></p><p id="3dfa360d-33e3-8098-9b80-d16f56a1f904" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"></span></span></p><p id="3dfa360d-33e3-80cd-ba61-c7383644281a" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"></span></span></p><p id="3dfa360d-33e3-80c3-8ec5-c292154e2a22" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"></span></span></p><hr class="css-1ifza5r"/><h2 id="3dfa360d-33e3-8043-b08f-d2a82e4338b5" class="css-1c1qox8"><span class="css-1r3ko7u"><span class="css-p4abj2"><span class="css-q3ktjb">툴팁에서 찾은 새로운 영역</span></span></span></h2><p id="3dfa360d-33e3-80c1-8088-cf8ffaaf5a0a" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">떠올린 건 </span><span class="css-q3ktjb">웹사이트에서 마우스를 올렸을 때 나타나는 툴팁</span><span class="css-1kxrhf3">이에요. 툴팁은 기존 화면을 가리지 않으면서, 필요한 정보를 그 위에 덧붙여 보여주잖아요.</span></span></span></p><p id="3dfa360d-33e3-8028-a111-e356f083ffe6" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">이 구조를 모바일에도 가져왔어요. 계좌 정보가 있는 기본 화면은 그대로 두고, 자산 상태에 대한 제안은 그 위에 떠 있는 플로팅 카드로 분리했어요. 같은 화면 안에서도 계좌 정보와 추천이 서로 다른 층에 놓이도록 한 거예요.</span></span></span></p><figure id="3e3a360d-33e3-809a-861c-df6f4bc930cb" class="css-of5acw"><img src="https://static.toss.im/ipd-tcs/toss_core/live/5c5f9846-8594-4ae3-80ac-d0f8a6fd8703/이거.png" alt="" class="css-1pgssrp"/></figure><p id="3dfa360d-33e3-805b-9a46-fdad187d2cb7" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">하지만 화면에 없던 영역을 새로 띄우는 건 또 다른 문제를 만들 수 있었어요. 사용자가 원하지 않았는데 갑자기 나타나면, 정보가 아니라 방해물처럼 느껴질 수 있으니까요. 광고처럼 보이거나, 내용을 읽기도 전에 닫기 버튼부터 찾게 될 수도 있고요.</span></span></span></p><p id="3dfa360d-33e3-806a-9401-c8340714c9a7" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">그래서 플로팅 카드가 사용자에게 자연스럽게 받아들여지려면 어떻게 해야 할지 하나씩 정해갔어요.</span></span></span></p><p id="3dfa360d-33e3-8054-b145-d1c0d2c29d68" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"></span></span></p><h3 id="3dfa360d-33e3-80e7-9bd2-dc64f1b7bfaa" class="css-1feg9au"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">1 ) 내용이 먼저 보이는 첫인상 만들기</span></span></span></h3><p id="3dfa360d-33e3-80af-9572-edbde5f00c72" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">화면 위에 떠 있는 카드는 팝업 광고와 형태가 비슷했어요. 내용을 읽기도 전에 광고처럼 보일 수 있겠다는 걱정이 들었어요. </span></span></span></p><figure id="3dfa360d-33e3-803c-a03c-d0a5b9e0d25e" class="css-of5acw"><img src="https://static.toss.im/ipd-tcs/toss_core/live/582d33d2-2bb1-4c66-b8de-f4bf24c1e39a/시안.png" alt="" class="css-1pgssrp"/></figure><p id="3dfa360d-33e3-8006-ab1c-c9db7030959e" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">그래서 광고와 기능이 사용자에게 어떻게 다르게 보이는지 토스 안의 배너를 비교해 봤어요. 광고는 큼직한 그래픽과 눈에 띄는 컬러로 시선을 끌고 있었고, 기능은 텍스트와 버튼을 중심으로 정보의 위계로 내용을 전하고 있었어요.</span></span></span></p><p id="3dfa360d-33e3-805f-b74b-defd75056da9" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">리포트도 기능처럼 느껴지게 하려면 비주얼적 요소보다 내용이 먼저 보여야 한다고 생각했어요. 그래서 그래픽과 컬러를 덜어내고, 내용에 시선이 먼저 닿도록 했어요.</span></span></span></p><p id="3dfa360d-33e3-8002-a502-db2775dc41c9" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">대신 디테일로 채웠어요. 모서리에는 코너 스무딩을 넣고, 반투명한 재질감을 더해 화면에 자연스럽게 어우러지도록 했어요. 버튼에는 미세한 그래디언트를 더해 밀도를 높였고요. 작은 디테일을 하나씩 쌓아 기능으로서의 완성도를 높여갔어요.</span></span></span></p><p id="3dfa360d-33e3-80e7-b438-ea23fac20da7" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"></span></span></p><figure id="3dfa360d-33e3-803c-9f20-e715db4a7582" class="css-of5acw"><img src="https://static.toss.im/ipd-tcs/toss_core/live/b27798cb-7076-41e6-98f5-798ff20c084d/image_converter_2026-09-15T15-47-09.webp" alt="" class="css-1pgssrp"/></figure><p id="3dfa360d-33e3-801e-8234-c367d1108073" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"></span></span></p><p id="3dfa360d-33e3-80fc-bff4-ef8bf11a3ffc" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"></span></span></p><h3 id="3dfa360d-33e3-8047-8904-c06d528aaf2b" class="css-1feg9au"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">2 ) </span><span class="css-q3ktjb">스크롤 하면 작아지게</span></span></span></h3><p id="3dfa360d-33e3-8045-bacd-c904a2f87c56" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">리포트를 발견한 뒤에도 계속 시선을 요구하면 부담스러울 수 있었어요. 사용자는 추천을 읽고 싶을 수도 있지만, 당장은 다른 정보를 먼저 보고 싶을 때도 있으니까요.</span></span></span></p><p id="3dfa360d-33e3-80b6-b596-f60c1de0a251" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">그래서 스크롤을 시작하면 리포트가 작아지도록 했어요. 사용자가 화면을 둘러보기 시작하면 자리를 내어주고, 다른 콘텐츠를 편하게 볼 수 있게 한 거죠. 작아진 카드는 궁금할 때 다시 열어볼 수 있는 진입점으로 남아요.</span></span></span></p><p id="3dfa360d-33e3-8011-97bf-ef1a992c0d5b" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">리포트가 얼마나 눈에 띌지를 한 가지 상태로 고정하지 않고, 사용자의 행동에 따라 달라지게 했어요.</span></span></span></p><p id="3dfa360d-33e3-80b4-9c19-e0fce0ca9e12" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"></span></span></p><figure id="3dfa360d-33e3-80a7-84be-ef2cdf55f239" class="css-of5acw"><video muted="" loop="" autoPlay="" playsInline="" class="css-8atqhb"><source src="https://static.toss.im/ipd-tcs/toss_core/live/deeab581-0e14-48fb-8f8d-5ba9c88a5da2/최소화.mp4"/></video></figure><p id="3dfa360d-33e3-80c6-a36b-da2b3411eb3f" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"></span></span></p><p id="3dfa360d-33e3-8030-bae4-d0c38868747b" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"></span></span></p><h3 id="3dfa360d-33e3-8075-99f0-e56855b51a0a" class="css-1feg9au"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">3 ) 말을 건네듯 펼쳐지게</span></span></span></h3><p id="3dfa360d-33e3-8044-8f33-fa85999e4a9a" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">홈에 들어오면 리포트는 닫힌 상태에서 시작해 펼쳐져요. 화면이 먼저 보인 뒤 카드가 열리면, 지금 내 자산 상태를 보고 건네는 제안처럼 느껴질 것이라고 생각했어요. 열리고 닫히는 과정을 자연스럽게 보여주면서, 언제든 다시 닫을 수 있다는 것도 전하고 싶었고요.</span></span></span></p><p id="3dfa360d-33e3-8060-9070-dfb601de9f48" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">그 느낌을 만들기 위해 움직임의 결도 여러 번 바꿔봤어요.</span></span></span></p><ul class="css-uswsmm"><li id="3dfa360d-33e3-8056-a2b7-e375533eece4" class="css-1hwiibq"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-q3ktjb">빠르게 나타나는 움직임:</span><span class="css-1kxrhf3"> 카드가 툭 튀어나오는 느낌이라 부담스러웠어요.</span></span></span></li><li id="3dfa360d-33e3-805b-9e1c-c7d687ac09fb" class="css-1hwiibq"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-q3ktjb">느리고, 약한 반동이 있는 움직임:</span><span class="css-1kxrhf3"> 부드럽지만 화면이 느리게 반응하는 것처럼 보였어요.</span></span></span></li><li id="3dfa360d-33e3-80d1-91d1-dda900e8b856" class="css-1hwiibq"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-q3ktjb">반동이 큰 움직임:</span><span class="css-1kxrhf3"> 경쾌하지만 자산 정보를 보여주기에는 다소 산만했어요.</span></span></span></li></ul><figure id="3dfa360d-33e3-8056-9dee-c0ca692bd5c4" class="css-of5acw"><video muted="" loop="" autoPlay="" playsInline="" class="css-8atqhb"><source src="https://static.toss.im/ipd-tcs/toss_core/live/78d1f214-b7b0-4dbb-80c7-43275a1a8950/이거야_최종.mp4"/></video></figure><p id="3dfa360d-33e3-8034-9627-f9807092eda2" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">최종적으로는 빠르게 시작해 부드럽게 감속하고, 끝에 살짝 반동을 더한 이징을 골랐어요. 부드럽게 마무리해 부담을 덜고, 작은 반동으로 생기를 더했어요.</span></span></span></p><p id="3dfa360d-33e3-803e-b882-f34d6370f7b2" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">여기에 translate와 scale 모션의 시작점을 어긋나게 뒀어요. 두 모션이 동시에 시작되면 단순히 크기가 변하며 이동하는 것처럼 보이지만, 시작 시점을 조금 다르게 주면 곡선을 그리듯 움직이는 디테일을 만들 수 있어요.</span></span></span></p><p id="3dfa360d-33e3-8092-a5b8-f4dc772d8cf9" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"></span></span></p><p id="3dfa360d-33e3-80a9-86d8-d774d1faeb6b" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"></span></span></p><hr class="css-1ifza5r"/><h2 id="3dfa360d-33e3-80c2-a17e-ee122920e7a0" class="css-1c1qox8"><span class="css-1r3ko7u"><span class="css-p4abj2"><span class="css-1kxrhf3">문제를 푼 건 표현이 아닌 구조</span></span></span></h2><figure id="3dfa360d-33e3-80fa-874f-ec8658ab1563" class="css-of5acw"><video muted="" loop="" autoPlay="" playsInline="" class="css-8atqhb"><source src="https://static.toss.im/ipd-tcs/toss_core/live/01739fdc-684e-4b83-a9f0-05e3c51ad19e/실험.mp4"/></video></figure><p id="3dfa360d-33e3-805e-b201-c095e540903a" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">기존 안, 인터랙티브 안, 플로팅 안으로 A/B 테스트를 진행했어요.</span></span></span></p><p id="3dfa360d-33e3-8025-8274-d0908cdcdae0" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">표현을 바꾼 인터랙티브 안은 CTR이 1.1배 오르는 데 그친 반면, </span><span class="css-q3ktjb">구조를 바꾼 플로팅 안은 CTR과 CVR이 모두 3배</span><span class="css-1kxrhf3"> 올랐어요.</span></span></span></p><p id="3dfa360d-33e3-801a-bf4d-cd9b91167bb2" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">특히 눈여겨본 건 CVR이었어요. 클릭만 늘었다면 리포트가 눈에 잘 띄어서 눌린 것일 수 있어요. 하지만 클릭 이후의 행동까지 함께 늘었다는 건, 리포트를 발견하는 것뿐 아니라 실제 추천까지 이어지는 경험도 달라졌다는 뜻이니까요.</span></span></span></p><p id="3dfa360d-33e3-8045-8a1e-daa70f4f4294" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">처음 세웠던 목표는 ‘나를 위한 제안’이라는 느낌을 주는 것이었어요. 결과적으로 단순히 표현을 바꾸는 것보다, 계좌 정보와 리포트의 역할을 분리한 구조가 이 목표에 더 가까운 결과를 만들었어요.</span></span></span></p><p id="3dfa360d-33e3-802a-9dc4-efd825c8c8df" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"></span></span></p><p id="3dfa360d-33e3-805f-ac54-eccea87eaf0e" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"></span></span></p><p id="3dfa360d-33e3-8041-9745-e42250f33dcf" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"></span></span></p><hr class="css-1ifza5r"/><h2 id="3dfa360d-33e3-8064-9e23-eee149c08c6c" class="css-1c1qox8"><span class="css-1r3ko7u"><span class="css-p4abj2"><span class="css-1kxrhf3">적용해보기</span></span></span></h2><p id="3dfa360d-33e3-800d-9f17-ca745ee85b51" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">저는 이번 작업에서 처음엔 형태와 색, 표현을 바꾸는 데 집중했어요. 그런데 아무리 다듬어도 문제가 풀리지 않았죠. 익숙한 디자인 문법 안에서만 답을 찾고 있었던 거예요.</span></span></span></p><p id="3dfa360d-33e3-8030-9c0c-c8d1c8ed295c" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">표현을 바꾸는 것만으로 해결되지 않는다면, 문제가 생기는 구조 자체를 다시 볼 필요가 있어요. 
비슷한 고민을 하고 있다면, 이런 질문부터 던져보세요.</span></span></span></p><ul class="css-uswsmm"><li id="3dfa360d-33e3-80f0-a6ae-cfe61d4903b4" class="css-1hwiibq"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">표현을 바꾸는 것만으로 정말 문제가 해결될까?</span></span></span></li><li id="3dfa360d-33e3-8072-959b-d45d7d099345" class="css-1hwiibq"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">구조를 바꿔야 하는 문제는 아닐까?</span></span></span></li><li id="3dfa360d-33e3-8063-a866-f449ce2c9b3e" class="css-1hwiibq"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">사용자의 시선을 끄는 데서 그치지 않고, 이후의 경험까지 챙겼을까?</span></span></span></li></ul><p id="3f1a360d-33e3-80f9-b44a-dbc18010f11e" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"></span></span></p><p id="3f1a360d-33e3-80eb-86a0-ebd2a4b3b726" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"></span></span></p><p id="3f1a360d-33e3-8091-aade-d3dd39cc9d2d" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"></span></span></p><div class="css-1lvcgm8"><button class="tds-mobile-button _28nbsw0 css-13ko30i" data-tds-mobile-component="Button" aria-busy="false" aria-live="off" style="--button-font-size:var(--tds-t-t5-text-fontSize, --tds-t-f17-text-fontSize);--button-border-radius:14px;--button-min-width:80px;--button-min-height:48px;--button-padding:2px 16px;--button-loader-size:8px;--button-loader-gap:5px;--button-display:inline-flex;--button-width:auto;--button-background-color:#3182f6;--button-color:#ffffff;--button-dimmer-pressed-background-color:rgb(0,0,0);--button-dimmer-loading-background-color:rgba(0, 0, 0, 0.26);--button-gradient-color:rgba(0, 89, 195, 1);--button-loader-color:#fff;opacity:1;transform:none"><div class="_28nbsw1" style="background-color:var(--button-background-color, #3182f6)"></div><span class="tds-mobile-button__content _28nbsw3" style="opacity:1;color:var(--tds-paragraph-color)">토스뱅크 대규모채용 보기</span><div class="_28nbsw4" style="opacity:0"></div><div class="_28nbsw2" style="opacity:0"></div></button></div></div><div id="tds-mobile-portal-container"></div>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[쉼 없이 도는 테스트, 사람이 어디까지 돌봐야 할까요? - 토스닥터(Toss Doctor)]]></title>
            <link>https://toss.tech/article/toss-doctor</link>
            <guid isPermaLink="true">https://toss.tech/article/toss-doctor</guid>
            <pubDate>Tue, 22 Sep 2026 03:42:00 GMT</pubDate>
            <description><![CDATA[스스로 만들고 고치는 자동화, 토스닥터 V2를 다시 만든 이야기]]></description>
            <content:encoded><![CDATA[<link rel="preload" as="image" href="https://static.toss.im/ipd-tcs/toss_core/live/fe77eecd-84db-4b30-9efa-8617bab27d52/codegen-flow.png"/><link rel="preload" as="image" href="https://static.toss.im/ipd-tcs/toss_core/live/344b177c-d54d-4607-a4c7-4a2bfe8c93aa/smartfinder-flow.png"/><link rel="preload" as="image" href="https://static.toss.im/ipd-tcs/toss_core/live/a441b0f3-17b4-4bbf-981d-14c49ac38aff/smartfinder-strategies.png"/><link rel="preload" as="image" href="https://static.toss.im/ipd-tcs/toss_core/live/fb55c42b-b316-4dcd-a5a2-a2a8b8c30375/smartfinder-slack.png"/><link rel="preload" as="image" href="https://static.toss.im/ipd-tcs/toss_core/live/ac91c83e-3102-4254-9ea5-4ff958f5b364/recoverable-verdict.png"/><link rel="preload" as="image" href="https://static.toss.im/ipd-tcs/toss_core/live/ac27f7e5-18d8-49f6-8a2d-4604baa70e76/error-analysis-slack.png"/><link rel="preload" as="image" href="https://static.toss.im/ipd-tcs/toss_core/live/4aa0d573-aefd-43d6-b25d-c1de51211530/phase-1-vs-2.png"/><link rel="preload" as="image" href="https://static.toss.im/ipd-tcs/toss_core/live/c6065e85-0ddf-4d8e-a4d7-76eca12fc805/tossdoctor-server.png"/><link rel="preload" as="image" href="https://static.toss.im/ipd-tcs/toss_core/live/dd40cc6b-d941-458d-9e26-73f64576ba78/doctor-vs-checker.png"/><div class="css-1vn47db"><p id="3dea360d-33e3-8031-bb02-c480504d418c" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">토스 앱은 매주 새 버전을 내보냅니다. 그 과정에서 플랫폼별로 5~7개의 RC(Release Candidate, 배포 후보) 빌드가 쌓이고, 빌드가 나올 때마다 누군가는 회원가입부터 로그인, 자산, 송금까지 같은 화면을 손으로 눌러 봅니다. 핵심 기능이 망가지진 않았는지 빠르게 훑는 스모크 테스트죠. 빠지면 안 되는 일이지만, 매번 사람이 반복하기엔 품이 너무 많이 들어요.</span></span></span></p><p id="3dea360d-33e3-8009-bcd6-f26d68232bb9" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">안녕하세요, 토스 QA Platform 팀입니다. 저희는 이 스모크 테스트를 자동화한 토스닥터를 만들어 운영하고 있습니다.</span></span></span></p><p id="3dea360d-33e3-80ed-9e1c-c8ece60f4662" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">토스의 기술 컨퍼런스 </span><span class="css-1odxvuk">SLASH</span><span class="css-1kxrhf3">를 기억하시나요? </span><a target="_blank" rel="noreferrer noopener" class="css-2yhypk" href="https://toss.im/slash-24/sessions/16">&#x27;약은 약사에게, 테스트는 토스닥터에게&#x27;</a><span class="css-1kxrhf3">라는 세션으로 소개했던 그 도구가 바로 토스닥터 V1입니다. V1은 제 역할을 했습니다. 하지만 매주 돌리다 보니 불편이 쌓였어요. 테스트를 새로 짜는 데 손이 많이 갔고, 화면이 조금만 바뀌어도 테스트가 우수수 깨졌고, 깨진 뒤엔 &quot;이게 왜 실패했지&quot;를 사람이 하나하나 들여다봐야 했죠.</span></span></span></p><p id="3dea360d-33e3-809c-a2cd-f351e37ad028" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">그래서 토스닥터 V2에서는 네 가지를 뜯어고쳤습니다. </span><span class="css-q3ktjb">코드를 만드는 법, 화면 속 요소를 찾는 법, 실패를 판단하는 법, 그리고 고치는 법</span><span class="css-1kxrhf3">. 그리고 이 네 가지가 실제 디바이스에서 끊김 없이 이어지도록 실행 환경도 다시 만들었습니다. 오늘은 그 이야기입니다. 그리고 이 V2가 지금 저희가 쓰고 있는 &#x27;토스체커&#x27;의 근간이 됐는데요, 그건 맨 뒤에서 짧게 이야기하겠습니다.</span></span></span></p><p id="3e3a360d-33e3-8017-94c9-cac1718dfd2a" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"></span></span></p><h2 id="3dea360d-33e3-8021-9f96-ef2eca590be7" class="css-1c1qox8"><span class="css-1r3ko7u"><span class="css-p4abj2"><span class="css-1kxrhf3">코드를 사람이 짜지 않게 됐습니다</span></span></span></h2><p id="3dea360d-33e3-80f6-aadb-cf6574434aaa" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">예전엔 테스트 하나를 만들려면, 화면을 열어 요소를 찾고 클릭·입력·검증을 절차적으로 코드에 옮겨 적어야 했습니다. 이 테스트가 무엇을 검증하는지는 코드 속에 묻혀 있었고, 새 기능이 나올 때마다 같은 작업을 처음부터 반복했어요.</span></span></span></p><p id="3dea360d-33e3-801d-9fad-e361d5b08e04" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">토스닥터 V2에서는 테스트를 두 단계로 나눴습니다. 먼저 사람은 &#x27;무엇을 검증할지&#x27;만 적습니다. 절차적인 스텝 대신, Gherkin 문법의 시나리오로요.</span></span></span></p><p id="3dea360d-33e3-802f-a49d-f6feac0b604c" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">Given-When-Then은 사람이 읽어도 바로 이해되고, AI가 코드로 옮기기에도 좋은 형식입니다. 그다음 </span><code style="font-family:Consolas, Menlo, Monaco, source-code-pro, &quot;Courier New&quot;, monospace;font-size:0.9em;background-color:var(--adaptiveOpacity100);border:1px solid var(--adaptiveOpacity200);border-radius:3px"><span class="css-1mjnzsq">/codegen</span></code><span class="css-1kxrhf3"> 명령이 이 </span><code style="font-family:Consolas, Menlo, Monaco, source-code-pro, &quot;Courier New&quot;, monospace;font-size:0.9em;background-color:var(--adaptiveOpacity100);border:1px solid var(--adaptiveOpacity200);border-radius:3px"><span class="css-1mjnzsq">.feature</span></code><span class="css-1kxrhf3"> 파일을 실제 실행 코드로 변환합니다.</span><span class="css-1kxrhf3"> LLM에 Appium MCP를</span><span class="css-1kxrhf3"> 붙여, 실제 디바이스를 보며 요소를 찾아 스텝을 구현합니다. </span><code style="font-family:Consolas, Menlo, Monaco, source-code-pro, &quot;Courier New&quot;, monospace;font-size:0.9em;background-color:var(--adaptiveOpacity100);border:1px solid var(--adaptiveOpacity200);border-radius:3px"><span class="css-1mjnzsq">.feature</span></code><span class="css-1kxrhf3">에 남긴 주석은 그대로 구현 가이드가 돼요.</span></span></span></p><p id="3dea360d-33e3-80a6-b58f-f2806a70b191" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">여기서 한 번 부딪힌 게 있는데요. 요소를 찾을 때 화면과 UI 트리를 통째로 AI에 넘겼더니 토큰이 감당이 안 됐습니다. 그래서 요소 탐색을 3단계로 나눠, 필요한 만큼만 찾게 바꿨습니다. 먼저 스크린샷과 화면에 드러난 요소 목록만으로 찾고(전체 UI 트리는 넘기지 않습니다), 안 되면 화면 구조 전체(XML)를 깊게 뒤지고, 그래도 안 되면 사람에게 물어요. 값싼 방법부터, 비싼 방법은 꼭 필요할 때만 찾습니다.</span></span></span></p><p id="3dea360d-33e3-80f6-9831-c4ae4bf72079" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">시나리오는 사람이 쓰고, 코드는 도구가 씁니다.</span></span></span></p><figure id="3dea360d-33e3-80c5-8e6e-e29a586466ea" class="css-of5acw"><img src="https://static.toss.im/ipd-tcs/toss_core/live/fe77eecd-84db-4b30-9efa-8617bab27d52/codegen-flow.png" alt="" class="css-1pgssrp"/></figure><p id="3dea360d-33e3-80da-b7ae-f78ab7c4e8c3" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">가입부터 송금·인증서·자산까지 앱의 핵심 흐름을 폭넓게 덮었습니다.</span></span></span></p><p id="3e3a360d-33e3-8011-aa7b-eac06b83a927" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"></span></span></p><h2 id="3dea360d-33e3-8090-bc1e-e5ccdb1ab7ce" class="css-1c1qox8"><span class="css-1r3ko7u"><span class="css-p4abj2"><span class="css-1kxrhf3">화면이 바뀌어도, 요소를 스스로 찾습니다</span></span></span></h2><p id="3dea360d-33e3-8010-884f-c246f4c2b6ab" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">코드를 아무리 잘 짜도, 실행 중에 예상 못한 화면이 끼어듭니다. 어제 없던 이벤트 팝업이 뜨고, 버튼 이름이 </span><code style="font-family:Consolas, Menlo, Monaco, source-code-pro, &quot;Courier New&quot;, monospace;font-size:0.9em;background-color:var(--adaptiveOpacity100);border:1px solid var(--adaptiveOpacity200);border-radius:3px"><span class="css-1mjnzsq">확인</span></code><span class="css-1kxrhf3">에서 </span><code style="font-family:Consolas, Menlo, Monaco, source-code-pro, &quot;Courier New&quot;, monospace;font-size:0.9em;background-color:var(--adaptiveOpacity100);border:1px solid var(--adaptiveOpacity200);border-radius:3px"><span class="css-1mjnzsq">확인하기</span></code><span class="css-1kxrhf3">로 살짝 바뀌고, 심지어 앱 위로 외부 광고 웹페이지가 통째로 덮이기도 합니다. 이럴 때마다 테스트가 깨지면, 자동화는 오히려 사람 손이 계속 가는 짐이 돼요.</span></span></span></p><p id="3dea360d-33e3-80df-bc1e-c7c786328406" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">그래서 스마트파인더를 만들었습니다. 테스트가 요소를 못 찾고 실패하려는 바로 그 순간, 서버가 이 스킬을 호출합니다. 말하자면, 쓰러진 그 자리에서 곧바로 하는 심폐소생이에요. 스킬은 실패한 그 화면을 직접 들여다본 뒤 두 가지를 시도합니다.</span></span></span></p><figure id="3dea360d-33e3-8041-a826-ca3599d0a700" class="css-of5acw"><img src="https://static.toss.im/ipd-tcs/toss_core/live/344b177c-d54d-4607-a4c7-4a2bfe8c93aa/smartfinder-flow.png" alt="" class="css-1pgssrp"/></figure><figure id="3dea360d-33e3-80c3-acb1-dadc63a76e5d" class="css-of5acw"><img src="https://static.toss.im/ipd-tcs/toss_core/live/a441b0f3-17b4-4bbf-981d-14c49ac38aff/smartfinder-strategies.png" alt="" class="css-1pgssrp"/></figure><p id="3dea360d-33e3-809b-83f7-c5cc66af5157" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">실제로 이런 장면이 잡힙니다.</span></span></span></p><blockquote id="3dea360d-33e3-8037-b823-cbd1ef00b96e" class="css-2sk6rv"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">타행 계좌로 송금하는 스텝에서, 계좌번호 입력칸 위로 외부 앱 공유 툴팁이 떠서 화면을 가리고 있었습니다. 스마트파인더는 이걸 블로커로 판단해 툴팁을 닫고, 가림이 사라진 걸 확인한 뒤 원래 계좌번호 입력 스텝을 이어갔습니다.</span></span></span></blockquote><figure id="3e2a360d-33e3-804e-86ef-d40f0f61f09e" class="css-of5acw"><img src="https://static.toss.im/ipd-tcs/toss_core/live/fb55c42b-b316-4dcd-a5a2-a2a8b8c30375/smartfinder-slack.png" alt="" class="css-1pgssrp"/></figure><p id="3dea360d-33e3-802d-a3bc-c8db8280700a" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">그리고 한 번 살려낸 걸로 끝나지 않습니다. 성공한 기록은 JSONL로 쌓이고, 다음에 같은 화면·같은 스텝에서 막히면 과거 성공 사례를 먼저 참고합니다. 자주 나오는 팝업일수록 더 빨리, 더 정확히 넘겨요.</span></span></span></p><p id="3dea360d-33e3-80fe-8171-ca5381808109" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">한 가지는 특히 조심했는데요. 검증(</span><code style="font-family:Consolas, Menlo, Monaco, source-code-pro, &quot;Courier New&quot;, monospace;font-size:0.9em;background-color:var(--adaptiveOpacity100);border:1px solid var(--adaptiveOpacity200);border-radius:3px"><span class="css-1mjnzsq">then</span></code><span class="css-1kxrhf3">) 스텝에서는 similar-match를 금지했습니다. &#x27;비슷한 요소를 눌러서 통과시키는&#x27; 건 여기선 거짓 성공을 만들고, 그게 다음 스텝으로 연쇄되기 때문이에요(실제로 5스텝이 줄줄이 거짓 성공한 적이 있었습니다). 그래서 검증 스텝에선 가리던 걸 치우는 것까지만 허용하고, 결과 자체는 눈속임하지 않습니다.</span></span></span></p><p id="3e3a360d-33e3-8092-9001-e0ad6a9a459d" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"></span></span></p><h2 id="3dea360d-33e3-800d-9b89-f5524ca033c9" class="css-1c1qox8"><span class="css-1r3ko7u"><span class="css-p4abj2"><span class="css-1kxrhf3">실패하면, 왜 실패했는지부터 판단합니다</span></span></span></h2><p id="3dea360d-33e3-80c4-bd22-e202ccd52ecb" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">테스트가 깨지는 건 자연스러운 일입니다. 중요한 건 그다음이에요.</span></span></span></p><p id="3dea360d-33e3-804d-9f53-d6e662cfc3e7" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">토스닥터는 실패를 감지하면 그 순간의 정보를 모읍니다. 실패한 시나리오, 스텝 코드, 에러 메시지, 스크린샷까지요. 이걸 토션에 실시간으로 기록하고, 곧바로 분석(</span><code style="font-family:Consolas, Menlo, Monaco, source-code-pro, &quot;Courier New&quot;, monospace;font-size:0.9em;background-color:var(--adaptiveOpacity100);border:1px solid var(--adaptiveOpacity200);border-radius:3px"><span class="css-1mjnzsq">/diagnosis</span></code><span class="css-1kxrhf3">)을 돌립니다.</span></span></span></p><p id="3dea360d-33e3-80fa-b317-e3317aea5b2e" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">분석은 세 가지를 내놓는데요. 추정 원인, 필요한 조치, 그리고 </span><span class="css-q3ktjb">recoverable 판정</span><span class="css-1kxrhf3">. 이 마지막 하나가 핵심이에요.</span></span></span></p><figure id="3dea360d-33e3-8032-9d76-c048980b5a06" class="css-of5acw"><img src="https://static.toss.im/ipd-tcs/toss_core/live/ac91c83e-3102-4254-9ea5-4ff958f5b364/recoverable-verdict.png" alt="" class="css-1pgssrp"/></figure><p id="3dea360d-33e3-80ef-a976-e92cca08c688" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">말로만 하면 와닿지 않으니 실제 사례를 살펴볼게요. 아래 두 에러는 같은 &#x27;송금 실패&#x27;인데, </span><span class="css-q3ktjb">한 번의 실행에서 서로 다른 recoverable 판정</span><span class="css-1kxrhf3">을 받았습니다.</span></span></span></p><p id="3dea360d-33e3-80cf-8233-d64353fc0b0a" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">하나는 코드로 고치면 되고, 하나는 코드 밖의 일이죠. 이 선을 긋는 게 분석의 일입니다.</span></span></span></p><figure id="3dea360d-33e3-80cd-ae7d-d152e01b16b4" class="css-of5acw"><img src="https://static.toss.im/ipd-tcs/toss_core/live/ac27f7e5-18d8-49f6-8a2d-4604baa70e76/error-analysis-slack.png" alt="" class="css-1pgssrp"/></figure><p id="3dea360d-33e3-8004-9f7a-d7459bc50764" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">스크린샷은 실패한 화면 하나만 보지 않습니다. </span><code style="font-family:Consolas, Menlo, Monaco, source-code-pro, &quot;Courier New&quot;, monospace;font-size:0.9em;background-color:var(--adaptiveOpacity100);border:1px solid var(--adaptiveOpacity200);border-radius:3px"><span class="css-1mjnzsq">_ERROR.png</span></code><span class="css-1kxrhf3">와 직전 스텝들의 화면을 시간 순서대로 함께 봅니다. 실패 화면만 보면 중간에 스쳐 간 툴팁·팝업·화면 전환 실패를 놓치기 때문이에요.</span></span></span></p><p id="3e3a360d-33e3-8014-9778-c551f42b9eb0" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"></span></span></p><h2 id="3dea360d-33e3-80fc-baa6-f2d7554ffac9" class="css-1c1qox8"><span class="css-1r3ko7u"><span class="css-p4abj2"><span class="css-1kxrhf3">고칠 수 있는 건 스스로 고칩니다</span></span></span></h2><p id="3dea360d-33e3-809c-ad6f-d7a01a953512" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">recoverable이 </span><code style="font-family:Consolas, Menlo, Monaco, source-code-pro, &quot;Courier New&quot;, monospace;font-size:0.9em;background-color:var(--adaptiveOpacity100);border:1px solid var(--adaptiveOpacity200);border-radius:3px"><span class="css-1mjnzsq">true</span></code><span class="css-1kxrhf3">면, 토션의 분석 결과를 바탕으로 곧바로 복구 실행(</span><code style="font-family:Consolas, Menlo, Monaco, source-code-pro, &quot;Courier New&quot;, monospace;font-size:0.9em;background-color:var(--adaptiveOpacity100);border:1px solid var(--adaptiveOpacity200);border-radius:3px"><span class="css-1mjnzsq">/recovery</span></code><span class="css-1kxrhf3">)을 시작할 수 있어요. 평소의 전체 실행과는 목적이 다릅니다.</span></span></span></p><figure id="3dea360d-33e3-80d0-ae5f-f39a34e70978" class="css-of5acw"><img src="https://static.toss.im/ipd-tcs/toss_core/live/4aa0d573-aefd-43d6-b25d-c1de51211530/phase-1-vs-2.png" alt="" class="css-1pgssrp"/></figure><p id="3dea360d-33e3-80db-9e08-cdc9a7b502c2" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">중요한 점은 </span><span class="css-q3ktjb">멈추고 → 붙여서 → 확인한다</span><span class="css-1kxrhf3">는 것입니다. 전체 실행은 에러가 나도 전체 결과를 모으기 위해 계속 진행하지만, 복구 실행은 첫 에러에서 즉시 멈춰요. 멈춘 화면에 Appium MCP를 붙이면, 분석이 &#x27;추정&#x27;했던 걸 실제 요소를 보며 &#x27;확인&#x27;할 수 있습니다.</span></span></span></p><p id="3dea360d-33e3-8000-8e55-fc63ae74dc24" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">복구는 E-1부터 E-5까지 순서대로 흐릅니다. 앞의 타행 계좌 송금 예시를 이어가 볼게요.</span></span></span></p><aside id="3e3a360d-33e3-80f5-b5bc-f6bdf7c87249" class="css-nv7vyi"><div class="css-1vn47db"><ul class="css-uswsmm"><li id="3dea360d-33e3-803a-a3db-fb2ca1e15b32" class="css-1hwiibq"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-q3ktjb">E-1</span><span class="css-1kxrhf3"> : pytest 재실행 → 같은 에러 재현 (</span><code style="font-family:Consolas, Menlo, Monaco, source-code-pro, &quot;Courier New&quot;, monospace;font-size:0.9em;background-color:var(--adaptiveOpacity100);border:1px solid var(--adaptiveOpacity200);border-radius:3px"><span class="css-1mjnzsq">ImageView</span></code><span class="css-1kxrhf3"> locator 타임아웃)</span></span></span></li><li id="3dea360d-33e3-8031-ac08-efddc7484ff4" class="css-1hwiibq"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-q3ktjb">E-2</span><span class="css-1kxrhf3"> : 멈춘 화면을 Appium MCP로 확인 → &#x27;보낼까요&#x27;가 </span><code style="font-family:Consolas, Menlo, Monaco, source-code-pro, &quot;Courier New&quot;, monospace;font-size:0.9em;background-color:var(--adaptiveOpacity100);border:1px solid var(--adaptiveOpacity200);border-radius:3px"><span class="css-1mjnzsq">TextView</span></code><span class="css-1kxrhf3">임을 직접 확인</span></span></span></li><li id="3dea360d-33e3-803a-8b4f-ec9d5d313901" class="css-1hwiibq"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-q3ktjb">E-3</span><span class="css-1kxrhf3"> : locator를 </span><code style="font-family:Consolas, Menlo, Monaco, source-code-pro, &quot;Courier New&quot;, monospace;font-size:0.9em;background-color:var(--adaptiveOpacity100);border:1px solid var(--adaptiveOpacity200);border-radius:3px"><span class="css-1mjnzsq">ImageView</span></code><span class="css-1kxrhf3"> → </span><code style="font-family:Consolas, Menlo, Monaco, source-code-pro, &quot;Courier New&quot;, monospace;font-size:0.9em;background-color:var(--adaptiveOpacity100);border:1px solid var(--adaptiveOpacity200);border-radius:3px"><span class="css-1mjnzsq">TextView</span></code><span class="css-1kxrhf3">로 수정</span></span></span></li><li id="3dea360d-33e3-80f3-808b-dc72781e712f" class="css-1hwiibq"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-q3ktjb">E-4</span><span class="css-1kxrhf3"> : 재실행 → 전체 시나리오 PASSED</span></span></span></li><li id="3dea360d-33e3-8025-9c84-d74f16ce7c9e" class="css-1hwiibq"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-q3ktjb">E-5</span><span class="css-1kxrhf3"> : </span><code style="font-family:Consolas, Menlo, Monaco, source-code-pro, &quot;Courier New&quot;, monospace;font-size:0.9em;background-color:var(--adaptiveOpacity100);border:1px solid var(--adaptiveOpacity200);border-radius:3px"><span class="css-1mjnzsq">lessons.md</span></code><span class="css-1kxrhf3">에 회고 기록 → &#x27;보낼까요&#x27;는 ImageView가 아니라 TextView, 앱 업데이트 시 요소 타입 변경 주의</span></span></span></li></ul></div></aside><p id="3dea360d-33e3-80c2-abd3-e11f9875c0be" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">E-1부터 E-5까지, 사람이 손댄 곳은 없어요. 고칠 수 있다고 판단한 실패는 이렇게 스스로 고쳐 두고, 사람에게는 정말 사람이 봐야 할 실패만 남깁니다.</span></span></span></p><p id="3e3a360d-33e3-807d-bd2e-f1d5b381a357" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"></span></span></p><h2 id="3dea360d-33e3-8069-ba97-f804b83a7fc3" class="css-1c1qox8"><span class="css-1r3ko7u"><span class="css-p4abj2"><span class="css-1kxrhf3">무대 뒤에는 서버가 있습니다</span></span></span></h2><figure id="3e2a360d-33e3-80a1-ba02-d0360e497994" class="css-of5acw"><img src="https://static.toss.im/ipd-tcs/toss_core/live/c6065e85-0ddf-4d8e-a4d7-76eca12fc805/tossdoctor-server.png" alt="" class="css-1pgssrp"/></figure><p id="3dea360d-33e3-80c9-b4fc-d584b70257ab" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">지금까지의 이야기에는 숨은 조력자가 하나 있는데요. 코드젠도, 스마트파인더도, 에러 분석과 복구도 스스로 깨어나지 않습니다. 누군가 제때 불러줘야 움직여요. 그 ‘누군가’가 토스닥터 서버입니다.</span></span></span></p><p id="3dea360d-33e3-8083-b63b-e48a73bef135" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">시작은 토션에서 빌드를 선택하고 실행하는 것부터예요. RC 빌드가 나오면 사람이 빌드를 고르고 실행을 요청합니다. 그다음부터는 서버의 일입니다. 빌드를 받아 디바이스에 설치하고, 테스트를 실행하고, 진행 상황을 실시간으로 전달해요.</span></span></span></p><p id="3dea360d-33e3-80fc-8f73-c4e6493f69af" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">여기서 중요한 건 </span><span class="css-q3ktjb">테스트가 끝난 뒤 결과를 모아서 처리하는 방식이 아니라는 것</span><span class="css-1kxrhf3">입니다. 서버와 테스트 프로세스는 양방향으로 통신하면서, 스텝 하나가 끝날 때마다 결과를 주고받아요.</span></span></span></p><p id="3dea360d-33e3-8078-98f4-f5c55a38b374" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">그래서 테스트가 요소를 찾지 못하면 그대로 실패 처리하지 않습니다. 멈춘 화면에서 서버에 도움을 요청하고, 서버는 그 순간 LLM과 Appium MCP를 연결해 화면을 확인해요. 스마트파인더가 복구할 수 있다고 판단하면 그 결과를 다시 테스트에 전달하고, 테스트는 원래 흐름을 이어갑니다.</span></span></span></p><p id="3dea360d-33e3-800f-a859-f4fa7d706cbe" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">실행이 끝나면 서버는 실패한 시나리오를 모아 분석을 돌리고, 결과를 처음 실행을 요청했던 곳에 다시 전달합니다. 시작한 곳에서 끝을 보는 거죠.</span></span></span></p><p id="3dea360d-33e3-8029-93ec-dc3e8ecbd12b" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">빌드 실행부터 테스트, 실패 대응, 결과 전달까지. 토스닥터 서버는 이 전체 흐름을 연결하는 실행의 중심입니다.</span></span></span></p><p id="3e3a360d-33e3-80e8-8a30-c4c0fdf1d556" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"></span></span></p><h2 id="3dea360d-33e3-80f3-9590-fdfad59e2971" class="css-1c1qox8"><span class="css-1r3ko7u"><span class="css-p4abj2"><span class="css-1kxrhf3">토스닥터에서, 토스체커로</span></span></span></h2><p id="3dea360d-33e3-8044-b362-cc4fb1536954" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">토스닥터 V2에서 만든 네 가지 방식은 스모크 테스트 안에서만 머물지 않았어요. 한국어 BDD 시나리오, 실패에 스스로 대응하는 self-healing, 실기기 기반 실행. 이 아이디어들을 더 키우고 다듬어, 더 넓은 시나리오까지 검증하는 프레임워크로 발전시켰습니다. 저희는 이걸 </span><span class="css-q3ktjb">토스체커</span><span class="css-1kxrhf3">라고 부릅니다.</span></span></span></p><figure id="3dea360d-33e3-806e-ae03-cedb6e8117c2" class="css-of5acw"><img src="https://static.toss.im/ipd-tcs/toss_core/live/dd40cc6b-d941-458d-9e26-73f64576ba78/doctor-vs-checker.png" alt="" class="css-1pgssrp"/></figure><p id="3dea360d-33e3-804e-9b7f-df55c5659d15" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">스모크가 &#x27;숨이 붙어 있나&#x27;를 본다면, 리그레션은 &#x27;어디 아픈 데는 없나&#x27;를 봅니다. 더 넓고 깊은 검증을 사람 손으로 매번 하기는 어려워요. 토스닥터 V2에서 만든 네 가지 방식은 이 영역에서도 그대로 활용할 수 있었습니다.</span></span></span></p><p id="3dea360d-33e3-80e7-ba20-dcdf372551a6" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">실제로 토스체커는, 세 명의 테스터가 꼬박 이틀을 매달려 손으로 돌리던 배포 전 리그레션 테스트를 대신하기 시작했어요. 1,375개의 테스트 케이스를 기존 업무와 병행하며 3주 만에 자동화했죠. 토스닥터가 &#x27;한 앱의 스모크를 자동화한 도구&#x27;였다면, 토스체커는 &#x27;그 방식을 더 넓은 검증 영역으로 확장한 QA 자동화 플랫폼&#x27;입니다. 어떻게 해냈는지, 또 어디까지 넓혀갈지는 다음 아티클에서 자세히 다루겠습니다.</span></span></span></p><p id="3dea360d-33e3-80e3-a855-de25c3ae26b9" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">다시 처음으로 돌아가 볼까요. 매주 수십 개의 빌드를 사람이 손으로 확인하던 일에서 시작했습니다. 이제 코드를 만들고, 화면 속 요소를 찾고, 실패의 원인을 판단하고, 고칠 수 있는 문제를 고치는 일까지 도구가 맡습니다. 사람은 모든 테스트를 직접 돌보는 대신, </span><span class="css-q3ktjb">무엇을 검증할지와 어디까지 자동화할지를 설계하는 일</span><span class="css-1kxrhf3">에 집중할 수 있게 됐습니다. 토스닥터 V2가 다시 만든 건 단순히 테스트 자동화 도구 하나가 아니었어요. </span><span class="css-q3ktjb">다음 단계로 나아갈 수 있는 구조</span><span class="css-1kxrhf3">였습니다.</span></span></span></p></div><div id="tds-mobile-portal-container"></div>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[도메인 지식 없는 디자이너가 팀의 기준을 바꾼 방법]]></title>
            <link>https://toss.tech/article/remittance_transfer</link>
            <guid isPermaLink="true">https://toss.tech/article/remittance_transfer</guid>
            <pubDate>Mon, 21 Sep 2026 10:15:00 GMT</pubDate>
            <description><![CDATA[은행 지식이 없던 디자이너가 규제가 가득한 도메인에서 어떻게 팀의 기준을 바꿨는지 이야기해 보려고 해요.]]></description>
            <content:encoded><![CDATA[<link rel="preload" as="image" href="https://static.toss.im/ipd-tcs/toss_core/live/4b5423f5-4bad-40f2-8b1b-9baa5d867b4b/1201-tossfeed-thumb.jpg"/><div class="css-1vn47db"><p id="3dea360d-33e3-80cf-8ec9-f123c6c2aff0" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">안녕하세요. 해외송금을 디자인한 토스뱅크 Product Designer 서자영입니다. 은행 지식도 외화 지식도 없던 디자이너가 규제와 운영 논리가 가득한 도메인에서 어떻게 팀의 기준을 바꿨는지에 대해 이야기해 보려고 해요.</span></span></span></p><p id="3dea360d-33e3-8024-ace8-d99b73a9c167" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"></span></span></p><p id="3dea360d-33e3-8049-8810-cd14d6ef6faf" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"></span></span></p><h2 id="3dea360d-33e3-8076-80c3-c15322aa1841" class="css-1c1qox8"><span class="css-1r3ko7u"><span class="css-p4abj2"><span class="css-1kxrhf3">해외송금에서 가장 중요한 건 정말 수수료일까?</span></span></span></h2><figure id="3eca360d-33e3-80f0-97de-c9d4a83135db" class="css-of5acw"><img src="https://static.toss.im/ipd-tcs/toss_core/live/4b5423f5-4bad-40f2-8b1b-9baa5d867b4b/1201-tossfeed-thumb.jpg" alt="" class="css-1pgssrp"/></figure><p id="3dea360d-33e3-80e5-b49c-e190a5bafc64" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">팀에 합류하고 6개월 후에 해외송금 기능을 런칭해야 했는데, 제가 제대로 아는 건 없었어요. 해외로 돈을 보내면 하루 넘게 걸린다는 것도 팀에 들어오면서 처음 알았죠. 파트너사 선정, 전문(電文) 설계, 규제 검토 등 정할 건 산더미였어요. 그중에서도 당시 팀에서 가장 많이 고민했던 건 후발주자로서 어떤 강점을 가져야 할까였어요. 사실 이 질문에 대한 팀의 답은 어느 정도 정해져 있었어요. </span><span class="css-q3ktjb">수수료가 싸야 한다는 것.</span></span></span></p><p id="3dea360d-33e3-8057-a973-eb9899144905" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">저는 해외송금에 대한 이해도는 낮았지만, 어딘가 찝찝했어요. 정말 사용자가 가장 원하는 게 수수료가 싼 송금일까? 그런데 자신 있게 말할 수는 없었어요. 팀에서 제가 남들보다 잘 아는 건 하나도 없었거든요.</span></span></span></p><p id="3dea360d-33e3-802f-a385-ce120ffd0813" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">그렇다고 아무것도 할 수 없는 건 아니었어요. 도메인에 대한 지식은 부족해도, </span><span class="css-q3ktjb">사용자에게 직접 물어보는 건 할 수 있었죠. </span><span class="css-1kxrhf3">돌이켜보니 팀원 중 누구도 실제로 해외에 돈을 보내본 사람에게 어떤 점이 불편했는지 물어본 적이 없었더라고요.</span></span></span></p><p id="3dea360d-33e3-80f7-9e05-f0d466ed57db" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"></span></span></p><h2 id="3dea360d-33e3-80a2-b7ca-f0b6a07b6fd1" class="css-1c1qox8"><span class="css-1r3ko7u"><span class="css-p4abj2"><span class="css-q3ktjb">수수료보다 크게 느껴지는 불안</span></span></span></h2><p id="3dea360d-33e3-8094-9733-f0f0534ca1ee" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">해외송금은 자주 하는 일이 아니다 보니 인터뷰 대상을 모으는 것부터 쉽지 않았어요. 여러 번의 스크리닝 끝에 실제로 해외로 돈을 보내본 분들을 만났어요.</span></span></span></p><p id="3dea360d-33e3-8037-b9fa-e7f496e9c83b" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">수수료가 아깝다는 이야기는 나왔어요. 하지만 수수료가 조금 싸다고 해서 다른 은행으로 옮길 생각까지 하지는 않았어요. 수수료는 은행마다 크게 다르지 않고, 이미 송금 정보가 저장된 은행을 떠나는 것도 번거롭다는 거예요. 오히려 더 크게 반복되는 감정이 있었어요.</span></span></span></p><p id="3dea360d-33e3-8056-bfeb-f06e18fbd9f3" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-q3ktjb">불안함이었어요.</span></span></span></p><p id="3dea360d-33e3-8016-9567-c99866aaf801" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">유학생, 현지 체류자, 해외 비즈니스 담당자 등 송금하는 상황은 모두 달랐지만, 외국 은행 계좌번호를 입력하는 순간부터 돈이 도착하는 이틀 동안은 비슷한 걱정을 하고 있었어요.</span></span></span></p><ul class="css-uswsmm"><li id="3dea360d-33e3-80a0-aafe-f393659db8dc" class="css-1hwiibq"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">내가 제대로 입력한 게 맞나?</span></span></span></li><li id="3dea360d-33e3-806a-8498-fb20f603f399" class="css-1hwiibq"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">지금 내 돈은 어디에 있지?</span></span></span></li><li id="3dea360d-33e3-800e-bf0f-f0bec7a757e9" class="css-1hwiibq"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">문제없이 가고 있는 걸까?</span></span></span></li></ul><p id="3dea360d-33e3-8099-a3f0-c04cd41b5a91" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">해외송금은 돈을 보내고 나면 당장 결과를 확인하기 어려워요. 그동안 무슨 일이 일어나고 있는지도 잘 보이지 않고요. 인터뷰를 통해 사용자가 원한 건 단순히 </span><span class="css-q3ktjb">싼 송금이 아니라, 불안하지 않은 송금</span><span class="css-1kxrhf3">이라는 생각이 들었어요.</span></span></span></p><p id="3dea360d-33e3-805b-8ff2-e8922e05555a" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">답을 찾았다고 생각해 인터뷰 결과를 팀에 공유했죠. 돌아온 첫 반응은 이거였어요.</span></span></span></p><blockquote id="3dea360d-33e3-8036-8afd-ce657c16757b" class="css-2sk6rv"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">“그래도 결국 제일 싼 곳이 이기는 것 아닌가요?”</span></span></span></blockquote><p id="3dea360d-33e3-8084-9765-c7e52177aa33" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">사용자의 목소리만으로는 팀의 확신을 바꾸기 어려웠어요. 그래서 방법을 바꿔보기로 했어요.</span></span></span></p><p id="3dea360d-33e3-80c3-987b-c89f55d5bf56" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"></span></span></p><p id="3dea360d-33e3-80d8-9c3e-c3f30c0d7af1" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"></span></span></p><p id="3dea360d-33e3-804c-9e2e-f9a5b2579234" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"></span></span></p><h2 id="3dea360d-33e3-8021-9b69-eb781cda7e04" class="css-1c1qox8"><span class="css-1r3ko7u"><span class="css-p4abj2"><span class="css-q3ktjb">팀이 직접 경험해보다</span></span></span></h2><p id="3dea360d-33e3-80f4-9387-e694ef0ad619" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">설득하는 대신, 직접 겪게 하기로 했어요. 한 PM 분이 직접 영국으로 돈을 보내봤어요. 송금을 신청하자마자 화면에 초록색 체크와 함께 ‘정상 송금’이라는 문구가 떴어요. 다음 날에도 ‘정상 송금’, 그다음 날에도 ‘정상 송금’. 3일 내내 같은 화면이었죠. 돈이 도착했다는 건지, 아직 도착하지 않았지만 문제없이 진행되고 있다는 건지 알 수가 없었어요. 고객센터에 물어봤더니 문제가 없다고 했어요. ‘정상 송금’이면 며칠 내에 안전하게 도착할 테니 걱정하지 않아도 된다고요. 이 경험을 팀에 공유하면서 PM 분이 이런 말을 했어요.</span></span></span></p><p id="3dea360d-33e3-80de-942c-ce50b75ceee4" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-q3ktjb">“걱정하지 말라고 했는데, 걱정은 그대로였어요.”</span></span></span></p><p id="3dea360d-33e3-802f-b7e3-da16a4e74827" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">그때부터 이건 남의 이야기가 아니라 팀원이 직접 겪은 일이 됐어요. “결국 싼 곳이 이기지 않냐”라고 했던 사람들이 각자 다른 은행으로 직접 송금을 해보기 시작했죠. 그리고 팀의 기준이 바뀌었어요. </span><span class="css-q3ktjb">‘얼마나 싼가’에서 ‘얼마나 불안하지 않은가’로. </span><span class="css-1kxrhf3">이제부터는 이 기준으로 하나씩 제품을 디자인하기로 했어요.</span></span></span></p><p id="3dea360d-33e3-803f-8af9-c548f512f314" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"></span></span></p><p id="3dea360d-33e3-8054-a3d5-f9949c2fb6d6" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"></span></span></p><h2 id="3dea360d-33e3-8023-b156-cf6d483aecb7" class="css-1c1qox8"><span class="css-1r3ko7u"><span class="css-p4abj2"><span class="css-q3ktjb">송금 과정의 불안을 줄이다</span></span></span></h2><p id="3dea360d-33e3-8062-b98b-fc97e4d4ae45" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">인터뷰를 통해 발견한 불안은 크게 두 가지였어요. 송금하는 순간의 불안, 그리고 돈을 보내고 기다리는 동안의 불안. 먼저 송금 과정부터 살펴봤어요.</span></span></span></p><p id="3dea360d-33e3-8081-ba5e-c48d0bf705a0" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"></span></span></p><p id="3dea360d-33e3-80d6-a60b-e18b0315c51d" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">국내 송금은 금액과 계좌번호만 입력하면 끝나지만, 해외송금은 입력해야 하는 정보가 훨씬 많아요. 은행 코드, 계좌번호, 현지 주소 등 낯선 정보가 계속 등장하죠. 특히 </span><code style="font-family:Consolas, Menlo, Monaco, source-code-pro, &quot;Courier New&quot;, monospace;font-size:0.9em;background-color:var(--adaptiveOpacity100);border:1px solid var(--adaptiveOpacity200);border-radius:3px"><span class="css-1mjnzsq">CHASUS33XXX</span></code><span class="css-1kxrhf3"> 같은 은행 코드는 암호처럼 생겼어요. 이걸 제대로 입력했는지 확인할 방법도 마땅치 않았고요. 사용자는 ‘내가 잘못 입력해서 돈이 엉뚱한 곳으로 가면 어떡하지?’라는 불안함을 느끼죠.</span></span></span></p><figure id="3e2a360d-33e3-806e-bd0a-f0463619a4b9" class="css-of5acw"><video muted="" loop="" autoPlay="" playsInline="" class="css-8atqhb"><source src="https://static.toss.im/ipd-tcs/toss_core/live/22797e00-1a60-40a2-95d8-86e72ebc6f17/01.mp4"/></video></figure><p id="3dea360d-33e3-8053-89db-e4d261652da5" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"></span></span></p><p id="3dea360d-33e3-80c9-918d-d1a0bd02514f" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">그래서 은행 코드를 입력하면 바로 ‘JP Morgan Chase’처럼 은행 이름이 보이도록 했어요. 단순히 이름 하나를 보여주는 인터랙션이지만, 실제로는 은행 코드와 은행 이름을 연결하는 별도의 작업이 필요했어요. 송금 자체만 놓고 보면 없어도 되는 기능이에요. 은행 코드를 정확하게 입력하기만 하면 송금은 되니까요. 이전의 기준이었다면 “굳이 필요한가?”라는 질문으로 끝났을지도 몰라요. 하지만 불안하지 않은 송금이 목표라면, 사용자가 입력한 정보가 맞다는 확신을 주는 일도 꼭 필요하다고 생각했어요.</span></span></span></p><figure id="3e2a360d-33e3-8049-bf98-d285f7b9a343" class="css-of5acw"><video muted="" loop="" autoPlay="" playsInline="" class="css-8atqhb"><source src="https://static.toss.im/ipd-tcs/toss_core/live/b0824eb0-006f-497f-81f4-d860559a4b82/02.mp4"/></video></figure><p id="3dea360d-33e3-80a4-bc1f-ecb06cdb3563" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"></span></span></p><p id="3dea360d-33e3-80b6-a90e-c656eff656dc" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"></span></span></p><p id="3dea360d-33e3-8047-8393-e586d9c33dcd" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">또 하나는 현지 주소였어요. 나라마다 주소 체계가 무척 다르잖아요. 어떤 곳은 거리명과 숫자로 되어있는데, 어떤 곳은 건물 번호도 필요하고 아예 유닛이라는 새로운 체계로 이루어진 곳도 있어요. 실제로 살아보지 않으면 너무 생소하죠. 어떻게 주소 입력창을 만들어야 할지 고민하고 있는데,  개발자분이 주소를 입력할 때 지도 앱처럼 몇 글자만 입력해도 검색해서 정확한 주소를 고르게 하면 어떻겠냐고 제안해 주셨어요.  지도 API를 연동해야 하고 호출 비용도 드는 일이었어요. 다른 은행에서는 자유 입력으로 받는 정보이기도 했고요. 하지만 이 방식을 쓰면 더 정확한 정보를 받을 수 있다는 이점도 있어요. 결과적으로 오타로 인한 반송도 줄게 되죠. 사용자의 편의와 운영 효율성 측면에서도 꼭 필요한 일이라고 설득해서 지도 API를 사용하게 되었어요.</span></span></span></p><p id="3e2a360d-33e3-801f-90ad-d63f5a977aa3" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">
</span></span></span></p><h2 id="3dea360d-33e3-80f4-b502-e172eb88471d" class="css-1c1qox8"><span class="css-1r3ko7u"><span class="css-p4abj2"><span class="css-1kxrhf3">송금 후의 불안을 줄이다</span></span></span></h2><p id="3dea360d-33e3-8074-a5be-d954e5cc30f7" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">송금 후의 불안은 더 컸어요. 인터뷰에서 많은 사용자가 공통으로 이야기한 건, </span><span class="css-q3ktjb">돈을 보낸 순간부터 도착할 때까지 하루이틀을 아무것도 모른 채 그냥 기다려야 한다는 것</span><span class="css-1kxrhf3">이었어요.</span></span></span></p><p id="3dea360d-33e3-80fe-a3be-ff18105e4692" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">실제로 해외송금은 그사이에 고객 확인, 외국환거래 규정 확인, AML·제재 스크리닝, 수취 은행 검사, 입금, 사후 모니터링 등 여러 단계를 거쳐요. 그렇다고 이 과정을 사용자에게 모두 보여주는 게 답은 아니라고 생각했어요. 내부에서 어떤 일이 일어나는지 전부 보여주면 오히려 더 불안해질 수도 있으니까요. 그래서 서버 개발자와 송금 상태를 하나씩 짚어봤어요. </span></span></span></p><p id="3dea360d-33e3-8016-af6a-c510d366e8f1" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-q3ktjb">‘이건 사용자가 알아야 하는 정보인가, 아니면 우리만 알고 있으면 되는 정보인가?’</span></span></span></p><p id="3dea360d-33e3-80b2-83e5-dc6ef0f86046" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">돈이 제대로 송금되지 않는 경우에도 여러 가지 원인과 내부 로직이 있었지만, 그걸 하나하나 설명하기보다는 사용자가 실제로 알아야 하는 결과에 집중했어요. 여러 논의를 거쳐 아홉 개였던 송금 상태를 세 개로 줄였어요. </span></span></span></p><figure id="3e2a360d-33e3-80a3-8d8b-f0077ae73dce" class="css-of5acw"><video muted="" loop="" autoPlay="" playsInline="" class="css-8atqhb"><source src="https://static.toss.im/ipd-tcs/toss_core/live/94d15ede-223e-4005-a0c6-7f11b75e215e/03.mp4"/></video></figure><p id="3dea360d-33e3-80e4-9512-df67c2b0424d" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-q3ktjb">송금 처리 중 → 해외 은행 도착 → 완료</span></span></span></p><p id="3dea360d-33e3-8048-8894-eaec51d98ae1" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">모든 과정을 보여주는 대신, 지금 내 돈이 어디까지 갔는지 알 수 있는 정보만 남긴 거예요. 보여주고 싶었던 건 복잡한 송금의 과정이 아니라, 내 돈이 지금 어떻게 되고 있는지였어요. 그래서 해외송금의 캐치프레이즈도 자연스럽게 정해졌어요.</span></span></span></p><p id="3dea360d-33e3-80eb-907e-de9dcafb2260" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-q3ktjb">‘보내면 보이는 해외송금.’</span></span></span></p><p id="3dea360d-33e3-8066-ac6f-cdb9371f2bd7" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">수수료를 낮추는 것도 물론 중요하지만, 팀이 만들고 싶었던 건 단순히 싼 송금이 아니었어요. 돈을 보내고 나서도 무슨 일이 일어나고 있는지 알 수 있고, 기다리는 동안 막연하게 불안해하지 않아도 되는 경험이었어요.</span></span></span></p><p id="3e2a360d-33e3-801e-bbf1-eb958daa12b7" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"></span></span></p><p id="3e2a360d-33e3-8010-af9b-cedaeb29fb72" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"></span></span></p><h2 id="3e2a360d-33e3-80de-9b6e-e5bfc1578bd8" class="css-1c1qox8"><span class="css-1r3ko7u"><span class="css-p4abj2"><span class="css-1kxrhf3">적용해보기 </span></span></span></h2><p id="3e2a360d-33e3-80f0-83bb-d896b75e9733" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">제가 한 일은 외화 지식을 빠르게 공부해 전문가가 되는 게 아니었어요. 오히려 잘 몰랐기 때문에 당연하게 여겨지는 것들을 한 번 더 사용자에게 물어볼 수 있었어요. 그리고 그 답을 팀이 직접 확인할 수 있도록 만들었고요.</span></span></span></p><p id="3e2a360d-33e3-80b8-877e-fdf870a0eb68" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">처음에는 ‘얼마나 싼가’를 고민하던 팀이 ‘사용자가 무엇을 불편해하는가’를 기준으로 제품을 바라보기 시작한 것. 저에게 이 프로젝트에서 가장 의미 있었던 변화였어요.</span></span></span></p><p id="3e2a360d-33e3-809d-82ac-d7695db56d9b" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">이번 프로젝트에서 디자이너는 도메인 지식을 가장 잘 아는 사람이 아니라, 좋은 질문을 던지고 사용자의 경험을 기준으로 답을 찾는 사람이란 것을 배웠어요. 그리고 팀 전체가 사용자 관점으로 생각하기 시작하면 더 좋은 해결책이 나올 수 있다는 것도요.</span></span></span></p><p id="3e2a360d-33e3-80e5-b124-ec43e12ab022" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">도메인 지식이 없거나, 팀의 관점을 바꿔야 하는 디자이너라면 이런 질문을 던져보면 좋겠어요.</span></span></span></p><ul class="css-uswsmm"><li id="3e2a360d-33e3-8004-ad40-c1e70276a26b" class="css-1hwiibq"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">도메인 지식을 더 쌓아야 한다는 이유로, 사용자에게 직접 물어보는 일을 미루고 있진 않나요?</span></span></span></li><li id="3e2a360d-33e3-80dc-b2bc-ff118fc8d183" class="css-1hwiibq"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">사용자의 목소리를 팀에 전달하는 데서 끝내고 있진 않나요? 팀이 직접 겪어볼 방법은 없을까요?</span></span></span></li><li id="3e2a360d-33e3-80ef-93f2-dfa6612e768d" class="css-1hwiibq"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">어떻게 하면 디자이너 혼자가 아니라 팀 전체에서 더 좋은 해결책을 고민할 수 있을까요?</span></span></span></li></ul><p id="3f1a360d-33e3-8006-9c4c-f911f8fa380d" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"></span></span></p><p id="3f1a360d-33e3-8034-afbe-dbf25ce2ff65" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"></span></span></p><p id="3f1a360d-33e3-80dd-91b7-e6244526a9ac" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"></span></span></p><div class="css-1lvcgm8"><button class="tds-mobile-button _28nbsw0 css-13ko30i" data-tds-mobile-component="Button" aria-busy="false" aria-live="off" style="--button-font-size:var(--tds-t-t5-text-fontSize, --tds-t-f17-text-fontSize);--button-border-radius:14px;--button-min-width:80px;--button-min-height:48px;--button-padding:2px 16px;--button-loader-size:8px;--button-loader-gap:5px;--button-display:inline-flex;--button-width:auto;--button-background-color:#3182f6;--button-color:#ffffff;--button-dimmer-pressed-background-color:rgb(0,0,0);--button-dimmer-loading-background-color:rgba(0, 0, 0, 0.26);--button-gradient-color:rgba(0, 89, 195, 1);--button-loader-color:#fff;opacity:1;transform:none"><div class="_28nbsw1" style="background-color:var(--button-background-color, #3182f6)"></div><span class="tds-mobile-button__content _28nbsw3" style="opacity:1;color:var(--tds-paragraph-color)">토스뱅크 대규모채용 보기</span><div class="_28nbsw4" style="opacity:0"></div><div class="_28nbsw2" style="opacity:0"></div></button></div></div><div id="tds-mobile-portal-container"></div>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[사용자를 위해 일부러 어렵게 만드는 경험, 어디까지 괜찮을까?]]></title>
            <link>https://toss.tech/article/lockbank</link>
            <guid isPermaLink="true">https://toss.tech/article/lockbank</guid>
            <pubDate>Fri, 18 Sep 2026 03:41:00 GMT</pubDate>
            <description><![CDATA[10대를 위한 잠금 저금통을 만들며 찾은 ‘좋은 불편함’의 기준을 공유합니다.]]></description>
            <content:encoded><![CDATA[<link rel="preload" as="image" href="https://static.toss.im/ipd-tcs/toss_core/live/071a478a-427b-4908-a279-868939cd740c/유저_의견.png"/><link rel="preload" as="image" href="https://static.toss.im/ipd-tcs/toss_core/live/3e5761ce-7ef4-4ffb-aed3-496f3c962168/팀원_의견.png"/><link rel="preload" as="image" href="https://static.toss.im/ipd-tcs/toss_core/live/39e4a6fd-c592-4824-b309-22bac5c77bca/piggybank_worried_1200x800.png"/><link rel="preload" as="image" href="https://static.toss.im/ipd-tcs/toss_core/live/0767d1d6-e324-4581-b72d-936298e074de/출시_반응.png"/><div class="css-1vn47db"><p id="3dea360d-33e3-8097-9cf6-ce7344bf6ac5" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"></span></span></p><p id="3dfa360d-33e3-8025-82d9-c268d5100308" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">안녕하세요. 토스뱅크에서 7세부터 17세까지, 미성년자를 위한 제품을 만들고 있는 Product Designer 오주연이에요. 저는 10대가 스스로 정한 목표를 달성할 수 있도록 돈을 빼내기 어렵게 하는 </span><span class="css-q3ktjb">저금통 잠그기 기능</span><span class="css-1kxrhf3">을 만들었어요.</span></span></span></p><p id="3dfa360d-33e3-8035-aa58-e51578a62cae" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">이 글에서는 ‘사용자를 돕기 위해 일부러 어렵게 만드는 경험은 어디까지 괜찮을까?’를 고민했던 이야기를 나눠보려고 해요.</span></span></span></p><h2 id="3dfa360d-33e3-80cd-842d-fbb5ce65dab4" class="css-1c1qox8"><span class="css-1r3ko7u"><span class="css-p4abj2"><span class="css-q3ktjb">&quot;목표금액까지 못 채우면 못 꺼내게 해주세요&quot;</span></span></span></h2><p id="3dfa360d-33e3-802e-90b8-f0b3d05ad217" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">10대를 위한 제품을 만들며 여러 리서치를 했는데, 아이들의 용돈 생활에 공통적으로 나타난 패턴이 있었어요. </span><span class="css-q3ktjb">돈을 모으고 싶어 하지만 쓰고 늘 후회하는 경험을 반복하는 거였죠.</span></span></span></p><p id="3dfa360d-33e3-80ad-b6d3-e4076bc99d9f" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">저축을 결심해도 눈앞의 사고 싶은 것을 참기 어려웠고, 결국 열심히 모으던 돈을 써버리는 것이었어요. 이런 실패가 쌓이면서 ‘나는 돈을 잘 관리할 수 있다’는 감각도 낮아지고 있었고요.</span></span></span></p><p id="3dfa360d-33e3-80bf-9023-d3a7d1e14dbf" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">발달심리학에서는 이를 이중 시스템 모델로 설명하기도 해요. 무언가를 갖고 싶게 만드는 보상·감정 시스템은 사춘기 무렵 빠르게 발달하지만, 충동을 조절하는 전두엽은 20대 초중반까지 발달이 이어져요. 10대는 저축하고 싶은 마음은 충분히 크지만, 당장의 유혹을 참는 힘은 아직 자라는 중인 셈이죠. 그러니 저축에 실패한 건 단순히 의지가 약해서가 아니었어요.</span></span></span></p><p id="3dfa360d-33e3-8019-9523-e278657782c3" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">어느 날은 이런 피드백들이 올라왔어요.</span></span></span></p><blockquote id="3dfa360d-33e3-80ca-a3ea-ec492877c1d3" class="css-2sk6rv"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">“목표금액까지 못 채우면 돈 못 꺼내는 저금통 만들어주세요ㅜㅜ 저를 못 믿겠어요.”</span></span></span></blockquote><p id="3dea360d-33e3-80bf-aec7-c71f8cbdc60e" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"></span></span></p><figure id="3dea360d-33e3-80e3-8c7a-f76ed89bad09" class="css-of5acw"><img src="https://static.toss.im/ipd-tcs/toss_core/live/071a478a-427b-4908-a279-868939cd740c/유저_의견.png" alt="" class="css-1pgssrp"/></figure><p id="3dea360d-33e3-8086-9071-d568cd70a2ea" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"></span></span></p><p id="3dfa360d-33e3-8026-8f3d-c6c1a50829e2" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">아이들은 이미 자신에게 어떤 도움이 필요한지 알고 있었어요. 저축하고 싶은 마음은 있지만, 마음이 흔들리는 순간 한 번 더 자신을 말려줄 무언가가 필요했던 거죠.</span></span></span></p><p id="3dfa360d-33e3-80b7-980c-d6c472677da2" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">이 목소리에서 잠금 저금통이 출발했어요.</span></span></span></p><h2 id="3dfa360d-33e3-804f-a151-defaff00e17c" class="css-1c1qox8"><span class="css-1r3ko7u"><span class="css-p4abj2"><span class="css-1kxrhf3">잠금을 어떻게 풀 것인가</span></span></span></h2><p id="3dfa360d-33e3-8017-b01a-fe8ef2bd5ab5" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">그런데 목표금액을 채우지 않아도 결국에는 자신의 돈이기 때문에 원하면 돈을 꺼낼 수 있어야 해요. 그렇다면 얼마나 쉽게, 혹은 얼마나 어렵게 풀리게 해야 할까요? 이 난이도가 잠금 저금통의 가장 중요한 부분이라고 생각했어요. 그래서 팀원들과 삼삼오오 모여 아이디어를 모았고, 어느새 스레드에는 70개가 넘는 의견이 쌓였어요. 주사위를 굴려서 6이 나와야 열리는 것, 계좌번호를 외워서 입력해야 하는 것, 친구에게 허락을 받아야 열리는 것… 재미있는 아이디어가 정말 많았어요.</span></span></span></p><p id="3dea360d-33e3-803c-8032-d705b0a34880" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"></span></span></p><figure id="3dea360d-33e3-803e-b7f2-f67bf2a624f3" class="css-of5acw"><img src="https://static.toss.im/ipd-tcs/toss_core/live/3e5761ce-7ef4-4ffb-aed3-496f3c962168/팀원_의견.png" alt="" class="css-1pgssrp"/></figure><p id="3dea360d-33e3-8011-9b28-c86fbc4a730c" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"></span></span></p><p id="3dfa360d-33e3-8080-be9b-cb4b25e54aa2" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">그중 유독 반응이 좋았던 아이디어가 </span><span class="css-q3ktjb">‘돼지저금통 부수기’</span><span class="css-1kxrhf3">였어요. 화면 속 저금통을 두드리면 쨍그랑 깨지면서 모은 돈이 쏟아지는 거예요. 설명하지 않아도 바로 이해할 수 있고, 어릴 때 게임에서 무언가를 신나게 부숴본 기억도 있잖아요. 팀 반응도 좋았고, 저도 처음에는 ‘이거다’ 싶었어요.</span></span></span></p><p id="3dfa360d-33e3-8003-bbd7-f76ca018225a" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">그런데 막상 디자인을 시작하니 마음 한구석이 계속 걸렸어요. 어릴 때 용돈을 모으던 돼지저금통을 깼던 날이 떠올랐거든요. 돈이 필요해서 어쩔 수 없이 깼는데, 동전을 하나씩 세는 내내 기분이 이상했어요.</span></span></span></p><p id="3dea360d-33e3-8036-a7ad-e46347bd1306" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"></span></span></p><p id="3dea360d-33e3-8076-b632-cebc4d5c16b2" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"></span></span></p><figure id="3dea360d-33e3-8056-98fd-da14e2d0aa3a" class="css-of5acw"><img src="https://static.toss.im/ipd-tcs/toss_core/live/39e4a6fd-c592-4824-b309-22bac5c77bca/piggybank_worried_1200x800.png" alt="" class="css-1pgssrp"/></figure><blockquote id="3dea360d-33e3-805f-98c6-d270dde5c1da" class="css-2sk6rv"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-120saye">사고 싶은 걸 사는 건데, 왜 혼나는 기분이 들지?</span></span></span></blockquote><p id="3dea360d-33e3-80f9-a0de-d81a7535d7b5" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"></span></span></p><p id="3dfa360d-33e3-80f7-8f8e-d3e29f35b7bd" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">돌이켜보니 인터뷰에서 아이들이 저축에 실패했을 때 이야기했던 감정도 이와 크게 다르지 않았어요. 돈을 쓴 것 자체보다 ‘또 참지 못했다’는 아쉬움과 스스로에 대한 실망이 남는다는 이야기가 반복됐어요.</span></span></span></p><p id="3dfa360d-33e3-801a-acf4-d2cf1a216f1a" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">30대인 제 눈에는 돼지저금통을 부수는 게 재미있는 인터랙션이지만, 그걸 경험하는 10대에게는 ‘저축에 실패했다’는 낙인이 될 수도 있겠다는 생각이 들었어요. 돈을 꺼내는 건 잘못이 아닌데, ‘부순다’는 연출은 마치 잘못을 저지른 것 같은 기분을 남길 수 있으니까요. 만드는 사람에게 재미있는 경험이, 쓰는 사람에게는 상처가 될 수도 있었어요.</span></span></span></p><p id="3dea360d-33e3-809a-aecc-c568f28eb9d2" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">
</span></span></span></p><hr class="css-1ifza5r"/><p id="3dea360d-33e3-8069-bdd8-f7730d0dee84" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"></span></span></p><h2 id="3dea360d-33e3-8051-b4d4-df964970b787" class="css-1c1qox8"><span class="css-1r3ko7u"><span class="css-p4abj2"><span class="css-1kxrhf3">재미보다 기준을 먼저 세우다
</span></span></span></h2><p id="3dfa360d-33e3-80bc-a54b-fe02f50ea664" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">결국 저금통을 부수는 방식은 사용하지 않기로 했어요. 대신 </span><span class="css-q3ktjb">‘잠금을 푸는 경험은 어떤 모습이어야 하는가’</span><span class="css-1kxrhf3">부터 다시 정의했어요.</span></span></span></p><p id="3dfa360d-33e3-8094-a7e2-ca96d57a6e71" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"></span></span></p><p id="3dfa360d-33e3-807b-9207-f87a3b5f4142" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-q3ktjb">하나. 누구에게나 공평하게 작동할 것</span></span></span></p><p id="3dfa360d-33e3-808f-ae3d-e823ce71cb13" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">인지능력, 문해력, 손재주, 반응 속도에 따라 난이도가 달라지면 안 돼요. 계좌번호를 외워 입력하는 방식은 17살에게는 재미있는 미션일 수 있지만, 어린 사용자에게는 훨씬 어려운 일이 될 수 있어요. 잠금은 저축을 돕기 위한 장치인데, 사용자의 능력에 따라 돈을 꺼낼 수 있는 정도가 달라져서는 안 된다고 생각했어요.</span></span></span></p><p id="3dfa360d-33e3-80a1-9444-d113287d1db7" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-q3ktjb">둘. 죄책감을 느끼게 하지 않을 것. </span></span></span></p><p id="3dfa360d-33e3-8065-a088-dbed458b05db" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">미래의 목표를 다시 생각하게 만드는 건 괜찮지만, 지금의 기분을 나쁘게 만드는 방식은 피하기로 했어요. 저축을 지키게 만들겠다고 죄책감을 도구로 쓰는 순간, 그건 응원이 아니라 벌이 될 수 있으니까요. 그래서 돈을 꺼내는 순간을 실패처럼 보여주는 연출은 제외했어요. 돈을 꺼내면 저금통이 눈물을 흘린다던가, “목표를 지키지 못했어요” 말을 따라 쓰게 한다던가 하는 것들이요.</span></span></span></p><p id="3dfa360d-33e3-80e1-a304-ff91eca4d32a" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-q3ktjb">셋. 아이의 돈은 아이의 것일 것. </span></span></span></p><p id="3dfa360d-33e3-80f3-b9bf-e5f69294129b" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">어떤 경우에도 스스로 자기 돈에 도달할 수 있는 경로가 있어야 했어요. 친구의 허락을 받아야 열리는 방식은 재미있는 소셜 요소처럼 보이지만, 내 돈을 꺼내는 데 다른 사람의 허락이 필요하다면 돈에 대한 주도권을 아이가 갖기 어려워져요.</span></span></span></p><hr class="css-1ifza5r"/><p id="3dea360d-33e3-8010-abb7-fc863f274272" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"></span></span></p><h2 id="3dfa360d-33e3-80bd-800a-eeacb0eab75d" class="css-1c1qox8"><span class="css-1r3ko7u"><span class="css-p4abj2"><span class="css-q3ktjb">잠금이 응원이 되도록</span></span></span></h2><p id="3dfa360d-33e3-809f-8b57-dba23ae31823" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">가장 신경 쓴 건 ‘온도’였어요. 잠금은 자칫 ‘막힌 문’처럼 느껴질 수 있어요. 돈을 꺼내지 못하게 하는 장치니까요. 하지만 이 잠금은 아이가 스스로 원해서 건 것이잖아요. 그렇다면 아이를 가두는 장치가 아니라, 아이가 세운 결심을 지켜주는 장치여야 했어요.</span></span></span></p><p id="3dea360d-33e3-800b-8db8-ca783d961696" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"></span></span></p><p id="3dea360d-33e3-8055-890e-ce96a00d0604" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"></span></span></p><figure id="3dea360d-33e3-8063-a052-c3ffde575238" class="css-of5acw"><video muted="" loop="" autoPlay="" playsInline="" class="css-8atqhb"><source src="https://static.toss.im/ipd-tcs/toss_core/live/a847288c-d88e-44d3-856d-9602ddc7fab1/mockup_잠금풀기.mp4"/></video></figure><p id="3dea360d-33e3-80ed-a276-c968a3730e3c" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">그래서 해제 방식으로는 버튼을 몇 초간 지그시 누르고 있어야 잠금이 풀리는 방식을 택했어요. 돈을 꺼내기 전까지 잠깐의 ‘틈’을 만들어주니까요. 충동적으로 손이 가더라도, 버튼을 누르고 있는 그 몇 초가 ‘정말 지금 꺼낼까?’를 한 번 더 생각하게 해요. 이 과정을 통해 10대들이 충동을 인내하는 힘을 기를 수 있을 거라고 생각했어요.</span></span></span></p><p id="3dea360d-33e3-80f4-87a3-cf39d12f211a" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"></span></span></p><p id="3dea360d-33e3-809b-b5ae-f52bec376948" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"></span></span></p><figure id="3dea360d-33e3-80d6-b89d-ff0a0c36d57c" class="css-of5acw"><video muted="" loop="" autoPlay="" playsInline="" class="css-8atqhb"><source src="https://static.toss.im/ipd-tcs/toss_core/live/0291dc1e-b21d-4333-970f-a2611b96a5e6/mockup_잠그기_v4.mp4"/></video></figure><p id="3dfa360d-33e3-8006-add8-fdb2208a4718" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">그리고 잠금이 강제나 부담으로 느껴지지 않도록 UI와 인터랙션의 무게감을 조절했어요. 예를 들어 잠글 때 ‘철컹’ 하고 강하게 잠기는 느낌은 피하고 싶었어요. 비주얼 디자이너와 함께 부드러운 톤과 그라데이션을 사용해, 잠그는 순간조차 무겁게 느껴지지 않도록 했죠. 잠글 때도, 풀 때도 아이가 혼나는 기분보다 응원받는 기분을 느낄 수 있도록요.</span></span></span></p><p id="3dfa360d-33e3-800e-8cd4-f4a2c9b88dea" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">가장 오래 고민한 건 기술이나 화면보다는 ‘어떻게 하면 아이가 실패감 대신 성장의 경험을 갖게 할까?’를 가장 많이 고민했죠.</span></span></span></p><p id="3dfa360d-33e3-8050-ab0b-d0568749ee86" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">그래서 출시 후 들어온 이런 목소리가 유독 반가웠어요.</span></span></span></p><figure id="3dea360d-33e3-805b-83f9-cfc05b9fbfa9" class="css-of5acw"><img src="https://static.toss.im/ipd-tcs/toss_core/live/0767d1d6-e324-4581-b72d-936298e074de/출시_반응.png" alt="" class="css-1pgssrp"/></figure><p id="3dea360d-33e3-809c-af55-c69c7a558f7c" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">은행은 보통 계좌, 이자, 잔액처럼 돈을 관리하는 곳이라고 생각해요. 하지만 잠금 저금통을 만들면서, </span><span class="css-q3ktjb">은행이 사용자의 금융 경험을 함께 만들어가는 곳이 될 수도 있겠다는 생각을 하게 됐어요.</span><span class="css-1kxrhf3"> 이제 막 돈을 배우기 시작한 아이들에게는 특히 더 그렇고요.</span></span></span></p><p id="3dea360d-33e3-8094-8363-d4c647ff55f0" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"></span></span></p><p id="3dea360d-33e3-8076-92a8-e8c9b662e82a" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"></span></span></p><h2 id="3dea360d-33e3-807a-b081-d0a56f2ff220" class="css-1c1qox8"><span class="css-1r3ko7u"><span class="css-p4abj2"><span class="css-1kxrhf3">적용해보기</span></span></span></h2><p id="3dfa360d-33e3-802b-98e7-eeae91231cd9" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">이 경험은 꼭 10대들의 저금통에만 해당하는 이야기는 아니라고 생각해요. 집중 모드, 앱 잠금, 사용 시간 제한처럼 사용자를 위해 일부러 무언가를 어렵게 만드는 제품은 많아요. 그럴 때 한 번쯤 이런 질문을 던져볼 수 있어요.</span></span></span></p><ul class="css-uswsmm"><li id="3dfa360d-33e3-8096-809b-d283c209a05f" class="css-1hwiibq"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">나이와 이해도, 상황이 다른 사용자에게도 공평한 난이도일까?</span></span></span></li><li id="3dfa360d-33e3-80e5-a90d-f2ce1a536575" class="css-1hwiibq"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">이 설계가 사용자에게 죄책감이나 실패감을 남기지는 않을까?</span></span></span></li><li id="3dfa360d-33e3-80e5-9b03-ca7b39b695d2" class="css-1hwiibq"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">만드는 나에게 재미있는 것과, 쓰는 사람에게 좋은 것을 혼동하고 있지는 않을까?</span></span></span></li><li id="3dfa360d-33e3-80ae-9dde-f83265020c3f" class="css-1hwiibq"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">사용자를 돕기 위해 만든 장치가 오히려 사용자의 선택권을 빼앗고 있지는 않을까?</span></span></span></li></ul><p id="3f1a360d-33e3-80d6-b3f7-e62680a40a1b" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"></span></span></p><p id="3f1a360d-33e3-8024-8e81-ee4ce158dee7" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"></span></span></p><p id="3f1a360d-33e3-80fc-a904-fd9b1169e587" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"></span></span></p><div class="css-1lvcgm8"><button class="tds-mobile-button _28nbsw0 css-13ko30i" data-tds-mobile-component="Button" aria-busy="false" aria-live="off" style="--button-font-size:var(--tds-t-t5-text-fontSize, --tds-t-f17-text-fontSize);--button-border-radius:14px;--button-min-width:80px;--button-min-height:48px;--button-padding:2px 16px;--button-loader-size:8px;--button-loader-gap:5px;--button-display:inline-flex;--button-width:auto;--button-background-color:#3182f6;--button-color:#ffffff;--button-dimmer-pressed-background-color:rgb(0,0,0);--button-dimmer-loading-background-color:rgba(0, 0, 0, 0.26);--button-gradient-color:rgba(0, 89, 195, 1);--button-loader-color:#fff;opacity:1;transform:none"><div class="_28nbsw1" style="background-color:var(--button-background-color, #3182f6)"></div><span class="tds-mobile-button__content _28nbsw3" style="opacity:1;color:var(--tds-paragraph-color)">토스뱅크 대규모채용 보기</span><div class="_28nbsw4" style="opacity:0"></div><div class="_28nbsw2" style="opacity:0"></div></button></div></div><div id="tds-mobile-portal-container"></div>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[리더보드 1등 LLM, 토스에서도 1등일까? - Toss Benchmark 구축기]]></title>
            <link>https://toss.tech/article/toss-benchmark</link>
            <guid isPermaLink="true">https://toss.tech/article/toss-benchmark</guid>
            <pubDate>Wed, 16 Sep 2026 05:37:00 GMT</pubDate>
            <description><![CDATA[Toss의 AI 기반 서비스에 적합한 LLM이 무엇인지 평가하는 Toss Benchmark 구축기]]></description>
            <content:encoded><![CDATA[<link rel="preload" as="image" href="https://static.toss.im/ipd-tcs/toss_core/live/5dfd9144-4454-491a-ba73-c281707c3231/toss-benchmark-structure.png"/><link rel="preload" as="image" href="https://static.toss.im/ipd-tcs/toss_core/live/df7d6047-a66a-4a00-9ce9-7843cb0eb138/image.png"/><link rel="preload" as="image" href="https://static.toss.im/ipd-tcs/toss_core/live/3a6f57f7-3984-47e0-9509-c368c0d31365/image.png"/><link rel="preload" as="image" href="https://static.toss.im/ipd-tcs/toss_core/live/0322b8e2-d584-4c95-ac7e-ab804836b44a/ifbench_instruction_type_comparison_trimmed.png"/><link rel="preload" as="image" href="https://static.toss.im/ipd-tcs/toss_core/live/9390e3f8-6aea-4d50-8eba-128f2f420f1f/commerce_knowledge_top5_by_category_trimmed.png"/><link rel="preload" as="image" href="https://static.toss.im/ipd-tcs/toss_core/live/5ef8d3b6-9abc-45d1-8d56-0b4604921757/correlation_triptych_v5_styled.png"/><link rel="preload" as="image" href="https://static.toss.im/ipd-tcs/toss_core/live/1211bf9c-6333-4f43-b65c-d4d09d315d97/genai_portal_usage_three_models.png"/><div class="css-1vn47db"><p id="3d8a360d-33e3-80ec-ba5b-ca6e7e0e0dba" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">새 모델이 나올 때마다 우리는 먼저 리더보드를 봅니다. 모델의 지능이 얼마나 높은지, 수학을 잘 푸는지, 코드를 잘 짜는지. 점수가 높으면 자연스럽게, 이 모델이면 우리 서비스도 더 좋아지겠다는 기대를 하게 되죠.</span></span></span></p><p id="3d8a360d-33e3-80d7-b6f3-e93e5bbf8ea1" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">하지만 실제 서비스에 붙여 보면 꼭 그렇지만은 않습니다. 영어를 잘 이해하는 모델이 한국어 정책을 잘 읽는다는 보장은 없고, 추론(Reasoning) 모드에서 높은 점수를 얻은 모델이 빠른 응답이 필요한 운영 환경에서도 같은 성능을 낸다는 보장도 없습니다. 무엇보다 범용 시험을 잘 푸는 것과 </span><span class="css-q3ktjb">토스 도메인 업무를 처리하는 것</span><span class="css-1kxrhf3">은 다른 능력입니다.</span></span></span></p><p id="3d8a360d-33e3-80f2-beb5-c316cc6a5f08" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">LLM Modeling 팀은 토스의 다양한 업무에 맞는 모델을 평가하기 위해 Toss Benchmark를 만들었습니다. 한정된 GPU 자원에서 서빙할 모델을 선별하고, Toss Foundation 모델이 갖춰야 할 능력을 정하기 위한 기준입니다. 먼저 토스에서 LLM에 어떤 일을 맡기고 있는지 살펴보겠습니다.</span></span></span></p><p id="3d8a360d-33e3-8003-9a8d-eb0c7af7be81" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"></span></span></p><h2 id="3d8a360d-33e3-80db-8eb6-e7de76ec29a4" class="css-1c1qox8"><span class="css-1r3ko7u"><span class="css-p4abj2"><span class="css-1kxrhf3">토스는 LLM으로 어떤 문제를 풀고 있을까</span></span></span></h2><p id="3d8a360d-33e3-8001-8bd0-f8427d16e736" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">토스에서는 상품 카탈로그를 만들고, 카드 약관을 분석하고, 광고를 검수하는 데 LLM을 활용합니다. 토스쇼핑 입점 판매자와 상품 운영에 필요한 안내와 협의를 진행하는 음성 에이전트, 도구로 고객 정보를 조회하는 상담 에이전트처럼 대화와 행동을 이어가는 사례도 있습니다. 금융 서비스뿐 아니라 커머스, 광고, 사내 개발·운영까지 적용 범위가 넓습니다.</span></span></span></p><p id="3d8a360d-33e3-80b3-8574-fcf56694ec65" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">아래는 각 영역에서 LLM을 활용하는 사례의 일부입니다.</span></span></span></p><div data-table-wrapper="true" class="css-1m7s5mg"><div role="table" class="css-crnt2r"><div role="row" class="css-1obf64m"><div role="cell" class="css-8vl804"><span class="css-1mjnzsq">영역</span></div><div role="cell" class="css-8vl804"><span class="css-1mjnzsq">LLM 활용 예시</span></div></div><div role="row" class="css-1obf64m"><div role="cell" class="css-8vl804"><span class="css-1mjnzsq">커머스 카탈로그 생성</span></div><div role="cell" class="css-8vl804"><span class="css-1mjnzsq">판매자마다 등록한 제품 정보를 표준화하고, 같은 상품을 묶어 가격 비교용 카탈로그를 구성합니다.</span></div></div><div role="row" class="css-1obf64m"><div role="cell" class="css-8vl804"><span class="css-1mjnzsq">토스쇼핑 입점 판매자 지원</span></div><div role="cell" class="css-8vl804"><span class="css-1mjnzsq">음성 에이전트가 입점 판매자와 통화하며 상품 운영에 필요한 안내와 협의를 진행합니다. 판매자의 답변에 따라 대화 흐름을 바꾸고 필요한 도구를 호출합니다.</span></div></div><div role="row" class="css-1obf64m"><div role="cell" class="css-8vl804"><span class="css-1mjnzsq">가맹점 심사·정산 지원</span></div><div role="cell" class="css-8vl804"><span class="css-1mjnzsq">심사 서류에서 사업자 정보를 추출</span><span class="css-1mjnzsq">하고 원문과 대조합니다. 정산 문의에는 관련 데이터·코드·계산 규칙을 조회해 답합니다.</span></div></div><div role="row" class="css-1obf64m"><div role="cell" class="css-8vl804"><span class="css-1mjnzsq">광고 검수·운영</span></div><div role="cell" class="css-8vl804"><span class="css-1mjnzsq">광고 문구·이미지와 클릭 후 연결되는 페이지를 검수합니다. 광고 성과에 이상이 생기면 설정·변경 이력·성과 데이터를 조회해 원인과 대응 방안을 조사합니다.</span></div></div><div role="row" class="css-1obf64m"><div role="cell" class="css-8vl804"><span class="css-1mjnzsq">금융상품 이해·상담</span></div><div role="cell" class="css-8vl804"><span class="css-1mjnzsq">카드 약관에서 혜택 조건과 한도를 추출해 비교합니다. 주택담보대출 상담에서는 지식 검색·시세 조회·DSR 계산 도구를 활용해 답변에 필요한 정보를 확보합니다.</span></div></div><div role="row" class="css-1obf64m"><div role="cell" class="css-8vl804"><span class="css-1mjnzsq">고객 상담 자동화</span></div><div role="cell" class="css-8vl804"><span class="css-1mjnzsq">고객 정보를 조회해 문의에 답하고</span><span class="css-1mjnzsq">, 상담사의 확인이나 처리가 필요한 요청은 상담사에게 연결합니다.</span></div></div><div role="row" class="css-1obf64m"><div role="cell" class="css-8vl804"><span class="css-1mjnzsq">개발·데이터·인프라 운영</span></div><div role="cell" class="css-8vl804"><span class="css-1mjnzsq">코드 변경을 검토하고, SQL을 분석해 데이터 품질 검사 규칙을 생성합니다. 장애 발생 시 로그·지표·코드를 함께 조사해 원인 후보와 근거를 제시합니다.</span></div></div></div></div><p id="3d8a360d-33e3-8050-9dc5-c3adbaeec825" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">Toss Benchmark는 이처럼 다양한 업무에 사용할 모델을 비교하기 위해 실제 데이터와 프롬프트를 모아 성능을 측정하고, 평가 대상을 계속 추가하고 있습니다.</span></span></span></p><p id="3d8a360d-33e3-80d3-9754-f9e29553992b" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">이 글에서는 토스 업무에 맞는 평가 기준을 만들고, 실제 업무 성능과의 관계를 확인한 과정을 소개합니다. 이를 통해 어떤 모델을 서빙하고, Toss Foundation 모델에서 어떤 능력을 강화할지 판단하는 기준을 살펴봅니다.</span></span></span></p><p id="3d8a360d-33e3-8018-9ba0-c11c90180898" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"></span></span></p><h2 id="3d8a360d-33e3-80bb-a515-ee07e50485fc" class="css-1c1qox8"><span class="css-1r3ko7u"><span class="css-p4abj2"><span class="css-1kxrhf3">Toss Benchmark는 무엇을 평가할까</span></span></span></h2><p id="3d8a360d-33e3-80d9-b9d6-c4258e65133d" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">Toss Benchmark는 </span><span class="css-q3ktjb">한국어 범용 성능</span><span class="css-1kxrhf3">과 </span><span class="css-q3ktjb">토스 도메인 성능</span><span class="css-1kxrhf3">을 함께 봅니다. 한국어 범용 평가는 모델의 기본 능력을 넓게 확인하고, 도메인 평가는 실제 업무 처리와 그 업무에 공통으로 필요한 능력을 확인합니다.</span></span></span></p><figure id="3d8a360d-33e3-8028-901b-e0039d384565" class="css-of5acw"><img src="https://static.toss.im/ipd-tcs/toss_core/live/5dfd9144-4454-491a-ba73-c281707c3231/toss-benchmark-structure.png" alt="" class="css-1pgssrp"/></figure><p id="3d8a360d-33e3-8094-9b60-f34af5186b34" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"></span></span></p><h2 id="3d8a360d-33e3-802d-b8dd-c7a36a0f0ee1" class="css-1c1qox8"><span class="css-1r3ko7u"><span class="css-p4abj2"><span class="css-1kxrhf3">한국어 범용 성능은 왜 따로 측정할까</span></span></span></h2><p id="3d8a360d-33e3-8073-91a8-c83f043e009c" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">공개 리더보드는 후보 모델을 비교할 때 유용합니다. 하지만 평가에 사용한 언어나 추론 설정이 실제 서비스와 다르면, 같은 모델도 성능과 순위가 달라질 수 있습니다.</span></span></span></p><h3 id="3d8a360d-33e3-8040-9a00-f4efec18ac07" class="css-1feg9au"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">프롬프트 언어가 바뀌면 성능과 순위도 바뀐다</span></span></span></h3><p id="3d8a360d-33e3-80ab-b85d-d1348af3aef2" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">한국어 서비스에 사용할 모델이라면, 영어 평가에서의 강점이 한국어 요청에서도 유지되는지 확인해야 합니다. 이를 보기 위해 수학과 코딩은 같은 문제의 영어 원문과 한국어 번역본으로 평가했습니다.</span></span></span></p><p id="3d8a360d-33e3-8014-b32e-c49223f2897f" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">아래 표는 순위 역전이 나타난 구간을 발췌한 것입니다. 영어 점수 순으로 정렬했으며, 순위는 각 표에 포함된 모델 안에서 다시 매겼습니다. 전체 리더보드 순위를 뜻하지는 않습니다.</span></span></span></p><p id="3d8a360d-33e3-8060-ab40-f313afcb8148" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-q3ktjb">수학: 같은 문제의 한국어 번역본</span></span></span></p><div data-table-wrapper="true" class="css-1m7s5mg"><div role="table" class="css-1emlqs7"><div role="row" class="css-1obf64m"><div role="cell" class="css-8vl804"><span class="css-1mjnzsq">모델</span></div><div role="cell" class="css-8vl804"><span class="css-1mjnzsq">영어 점수</span></div><div role="cell" class="css-8vl804"><span class="css-1mjnzsq">한국어 점수</span></div><div role="cell" class="css-8vl804"><span class="css-1mjnzsq">순위 변동 (영어 → 한국어)</span></div></div><div role="row" class="css-1obf64m"><div role="cell" class="css-8vl804"><span class="css-1mjnzsq">gemma-4-26B-A4B-it</span></div><div role="cell" class="css-8vl804"><span class="css-1mjnzsq">81.74</span></div><div role="cell" class="css-8vl804"><span class="css-1mjnzsq">72.43</span></div><div role="cell" class="css-8vl804"><span class="css-1mjnzsq">1위 → 2위 (▼ 1)</span></div></div><div role="row" class="css-1obf64m"><div role="cell" class="css-8vl804"><span class="css-1mjnzsq">Qwen3.5-35B-A3B</span></div><div role="cell" class="css-8vl804"><span class="css-1mjnzsq">80.51</span></div><div role="cell" class="css-8vl804"><span class="css-1mjnzsq">69.13</span></div><div role="cell" class="css-8vl804"><span class="css-1mjnzsq">2위 → 4위 (▼ 2)</span></div></div><div role="row" class="css-1obf64m"><div role="cell" class="css-8vl804"><span class="css-1mjnzsq">Qwen3.6-27B</span></div><div role="cell" class="css-8vl804"><span class="css-1mjnzsq">79.74</span></div><div role="cell" class="css-8vl804"><span class="css-1mjnzsq">73.15</span></div><div role="cell" class="css-8vl804"><span class="css-1mjnzsq">3위 → 1위 (▲ 2)</span></div></div><div role="row" class="css-1obf64m"><div role="cell" class="css-8vl804"><span class="css-1mjnzsq">Kimi-K2.5</span></div><div role="cell" class="css-8vl804"><span class="css-1mjnzsq">76.22</span></div><div role="cell" class="css-8vl804"><span class="css-1mjnzsq">71.64</span></div><div role="cell" class="css-8vl804"><span class="css-1mjnzsq">4위 → 3위 (▲ 1)</span></div></div></div></div><p id="3d8a360d-33e3-8033-a5c9-c9445acefef2" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-q3ktjb">코딩: 같은 문제의 한국어 번역본</span></span></span></p><div data-table-wrapper="true" class="css-1m7s5mg"><div role="table" class="css-1emlqs7"><div role="row" class="css-1obf64m"><div role="cell" class="css-8vl804"><span class="css-1mjnzsq">모델</span></div><div role="cell" class="css-8vl804"><span class="css-1mjnzsq">영어 점수</span></div><div role="cell" class="css-8vl804"><span class="css-1mjnzsq">한국어 점수</span></div><div role="cell" class="css-8vl804"><span class="css-1mjnzsq">순위 변동 (영어 → 한국어)</span></div></div><div role="row" class="css-1obf64m"><div role="cell" class="css-8vl804"><span class="css-1mjnzsq">Qwen3.5-122B-A10B</span></div><div role="cell" class="css-8vl804"><span class="css-1mjnzsq">69.40</span></div><div role="cell" class="css-8vl804"><span class="css-1mjnzsq">53.73</span></div><div role="cell" class="css-8vl804"><span class="css-1mjnzsq">1위 → 2위 (▼ 1)</span></div></div><div role="row" class="css-1obf64m"><div role="cell" class="css-8vl804"><span class="css-1mjnzsq">Qwen3.6-27B</span></div><div role="cell" class="css-8vl804"><span class="css-1mjnzsq">64.93</span></div><div role="cell" class="css-8vl804"><span class="css-1mjnzsq">55.60</span></div><div role="cell" class="css-8vl804"><span class="css-1mjnzsq">2위 → 1위 (▲ 1)</span></div></div><div role="row" class="css-1obf64m"><div role="cell" class="css-8vl804"><span class="css-1mjnzsq">Qwen3.5-35B-A3B</span></div><div role="cell" class="css-8vl804"><span class="css-1mjnzsq">64.55</span></div><div role="cell" class="css-8vl804"><span class="css-1mjnzsq">48.13</span></div><div role="cell" class="css-8vl804"><span class="css-1mjnzsq">3위 → 4위 (▼ 1)</span></div></div><div role="row" class="css-1obf64m"><div role="cell" class="css-8vl804"><span class="css-1mjnzsq">EXAONE-4.5-33B</span></div><div role="cell" class="css-8vl804"><span class="css-1mjnzsq">54.85</span></div><div role="cell" class="css-8vl804"><span class="css-1mjnzsq">50.37</span></div><div role="cell" class="css-8vl804"><span class="css-1mjnzsq">4위 → 3위 (▲ 1)</span></div></div></div></div><p id="3d8a360d-33e3-80d3-83ee-ccb46171f633" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">수학에서는 영어 점수가 81.74점인 gemma-4-26B-A4B-it보다 79.74점인 Qwen3.6-27B가 한국어에서 앞섰습니다. 코딩에서도 영어에서는 Qwen3.5-122B-A10B가 Qwen3.6-27B보다 높았지만, 한국어에서는 순서가 바뀌었습니다.</span></span></span></p><p id="3d8a360d-33e3-803c-9436-d9071fa7d841" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">여기서 볼 것은 점수 하락 자체뿐 아니라 </span><span class="css-q3ktjb">모델 간 우위가 유지되는지</span><span class="css-1kxrhf3">입니다. 같은 요구사항을 담은 문제라도 한국어로 제시했을 때 후보 모델의 순서가 달라질 수 있었습니다.</span></span></span></p><p id="3d8a360d-33e3-80e8-bd78-db6cf3360bec" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-q3ktjb">지식: 한국어권의 지식과 문화적 맥락도 확인하기</span></span></span></p><p id="3d8a360d-33e3-8069-b00a-e65e328037e8" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">지식은 수학·코딩과 평가 방식이 다릅니다. 같은 문제를 번역한 것이 아니라 영어와 한국어에서 각각 다른 벤치마크를 사용했습니다. 한국어 평가는 한국어권의 지식과 문화적 맥락을 다루는 벤치마크로 구성했습니다.</span></span></span></p><div data-table-wrapper="true" class="css-1m7s5mg"><div role="table" class="css-1emlqs7"><div role="row" class="css-1obf64m"><div role="cell" class="css-8vl804"><span class="css-1mjnzsq">모델</span></div><div role="cell" class="css-8vl804"><span class="css-1mjnzsq">영어 점수</span></div><div role="cell" class="css-8vl804"><span class="css-1mjnzsq">한국어 점수</span></div><div role="cell" class="css-8vl804"><span class="css-1mjnzsq">순위 변동 (영어 → 한국어)</span></div></div><div role="row" class="css-1obf64m"><div role="cell" class="css-8vl804"><span class="css-1mjnzsq">Qwen3.5-122B-A10B</span></div><div role="cell" class="css-8vl804"><span class="css-1mjnzsq">85.72</span></div><div role="cell" class="css-8vl804"><span class="css-1mjnzsq">66.17</span></div><div role="cell" class="css-8vl804"><span class="css-1mjnzsq">1위 → 3위 (▼ 2)</span></div></div><div role="row" class="css-1obf64m"><div role="cell" class="css-8vl804"><span class="css-1mjnzsq">Qwen3.5-35B-A3B</span></div><div role="cell" class="css-8vl804"><span class="css-1mjnzsq">85.28</span></div><div role="cell" class="css-8vl804"><span class="css-1mjnzsq">60.33</span></div><div role="cell" class="css-8vl804"><span class="css-1mjnzsq">2위 → 5위 (▼ 3)</span></div></div><div role="row" class="css-1obf64m"><div role="cell" class="css-8vl804"><span class="css-1mjnzsq">Kimi-K2.6</span></div><div role="cell" class="css-8vl804"><span class="css-1mjnzsq">84.65</span></div><div role="cell" class="css-8vl804"><span class="css-1mjnzsq">67.83</span></div><div role="cell" class="css-8vl804"><span class="css-1mjnzsq">3위 → 2위 (▲ 1)</span></div></div><div role="row" class="css-1obf64m"><div role="cell" class="css-8vl804"><span class="css-1mjnzsq">gemma-4-26B-A4B-it</span></div><div role="cell" class="css-8vl804"><span class="css-1mjnzsq">79.15</span></div><div role="cell" class="css-8vl804"><span class="css-1mjnzsq">71.81</span></div><div role="cell" class="css-8vl804"><span class="css-1mjnzsq">4위 → 1위 (▲ 3)</span></div></div><div role="row" class="css-1obf64m"><div role="cell" class="css-8vl804"><span class="css-1mjnzsq">GLM-5.1</span></div><div role="cell" class="css-8vl804"><span class="css-1mjnzsq">74.79</span></div><div role="cell" class="css-8vl804"><span class="css-1mjnzsq">64.88</span></div><div role="cell" class="css-8vl804"><span class="css-1mjnzsq">5위 → 4위 (▲ 1)</span></div></div></div></div><p id="3d8a360d-33e3-80e6-b357-fe021038f0f4" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">영어 평가에서는 Qwen3.5-122B-A10B와 Qwen3.5-35B-A3B가 이 다섯 모델 중 앞섰지만, 한국어 평가에서는 gemma-4-26B-A4B-it가 가장 높았습니다.
수학·코딩 비교가 같은 문제를 한국어로 제시했을 때의 변화를 확인한다면, 지식 평가는 한국어 서비스에서 필요한 지식 범위에서도 모델이 잘하는지를 확인합니다. 두 비교는 서로 다른 질문에 답하지만, 공개 영어 평가만으로 모델을 선택하기 전에 한국어 환경에서 다시 측정해야 한다는 점으로 이어집니다.</span></span></span></p><h3 id="3d8a360d-33e3-8048-81b9-ccdc7d996145" class="css-1feg9au"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">추론 설정에 따라 성능은 어떻게 달라질까</span></span></span></h3><p id="3d8a360d-33e3-80da-95a1-d915582988ee" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">언어를 맞추는 것만으로 평가 조건이 같아지지는 않습니다. 모델이 답변을 내기 전에 추론 과정을 생성하는 Reasoning 모드는 문제 해결에 도움이 될 수 있지만, 서비스에서는 비용과 응답 시간 때문에 이를 끄거나 최소화하기도 합니다. 충분한 추론을 허용했을 때의 점수와 실제 운영 설정에서의 점수를 구분해야 하는 이유입니다.</span></span></span></p><p id="3d8a360d-33e3-800b-baa4-fdd42ba3cea0" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">그렇다면 Reasoning을 끄면 모든 모델의 성능이 비슷하게 내려갈까요? 같은 모델을 두 실행 방식으로 평가한 코딩 결과를 비교했습니다. 아래 표는 점수 하락 폭이 큰 순서로 정렬했습니다.</span></span></span></p><p id="3d8a360d-33e3-80e9-a27b-de73ac0334dd" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-q3ktjb">코딩 — 모델마다 다른 Reasoning 설정의 영향</span></span></span></p><div data-table-wrapper="true" class="css-1m7s5mg"><div role="table" class="css-1emlqs7"><div role="row" class="css-1obf64m"><div role="cell" class="css-8vl804"><span class="css-1mjnzsq">모델</span></div><div role="cell" class="css-8vl804"><span class="css-1mjnzsq">reasoning 점수</span></div><div role="cell" class="css-8vl804"><span class="css-1mjnzsq">non-reasoning 점수</span></div><div role="cell" class="css-8vl804"><span class="css-1mjnzsq">성능 하락</span></div></div><div role="row" class="css-1obf64m"><div role="cell" class="css-8vl804"><span class="css-1mjnzsq">GLM-5.1</span></div><div role="cell" class="css-8vl804"><span class="css-1mjnzsq">78.7</span></div><div role="cell" class="css-8vl804"><span class="css-1mjnzsq">48.1</span></div><div role="cell" class="css-8vl804"><span class="css-1mjnzsq">−30.6점</span></div></div><div role="row" class="css-1obf64m"><div role="cell" class="css-8vl804"><span class="css-1mjnzsq">Kimi-K2.6</span></div><div role="cell" class="css-8vl804"><span class="css-1mjnzsq">71.6</span></div><div role="cell" class="css-8vl804"><span class="css-1mjnzsq">45.2</span></div><div role="cell" class="css-8vl804"><span class="css-1mjnzsq">−26.4점</span></div></div><div role="row" class="css-1obf64m"><div role="cell" class="css-8vl804"><span class="css-1mjnzsq">DeepSeek-v4-pro</span></div><div role="cell" class="css-8vl804"><span class="css-1mjnzsq">76.9</span></div><div role="cell" class="css-8vl804"><span class="css-1mjnzsq">57.8</span></div><div role="cell" class="css-8vl804"><span class="css-1mjnzsq">−19.1점</span></div></div><div role="row" class="css-1obf64m"><div role="cell" class="css-8vl804"><span class="css-1mjnzsq">Qwen3.5-122B-A10B</span></div><div role="cell" class="css-8vl804"><span class="css-1mjnzsq">81.3</span></div><div role="cell" class="css-8vl804"><span class="css-1mjnzsq">69.4</span></div><div role="cell" class="css-8vl804"><span class="css-1mjnzsq">−11.9점</span></div></div><div role="row" class="css-1obf64m"><div role="cell" class="css-8vl804"><span class="css-1mjnzsq">gemma-4-26B-A4B-it</span></div><div role="cell" class="css-8vl804"><span class="css-1mjnzsq">84.0</span></div><div role="cell" class="css-8vl804"><span class="css-1mjnzsq">78.0</span></div><div role="cell" class="css-8vl804"><span class="css-1mjnzsq">−6.0점</span></div></div></div></div><p id="3d8a360d-33e3-8004-a2bd-f2c26fe38b4d" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">GLM-5.1은 Reasoning을 껐을 때 30.6점, Kimi-K2.6은 26.4점 낮아졌습니다. 반면 gemma-4-26B-A4B-it의 차이는 6.0점이었습니다. 같은 코딩 평가에서도 설정 변경에 따른 하락 폭이 모델마다 크게 달랐습니다.</span></span></span></p><p id="3d8a360d-33e3-80a9-befe-ff710f750d01" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">순서도 달라졌습니다. Reasoning을 켰을 때 GLM-5.1은 78.7점으로 DeepSeek-v4-pro의 76.9점보다 높았지만, 껐을 때는 각각 48.1점과 57.8점으로 역전됐습니다. 추론을 허용한 평가에서 더 높은 모델을 골라도, 실제 서비스에서 추론을 끄면 다른 모델이 더 나을 수 있다는 뜻입니다.</span></span></span></p><p id="3d8a360d-33e3-8046-b01d-ed0085a24fd1" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">공개 벤치마크 점수는 해당 평가에 사용한 언어와 모델 설정에서 얻은 결과입니다. 앞서 살펴본 것처럼 한국어로 요청하거나 Reasoning 설정을 바꾸면 모델 간 순위도 달라질 수 있습니다. 공개 점수를 참고하되, </span><span class="css-q3ktjb">우리 서비스에서 사용할 언어와 추론 설정에 맞춰 평가하면 모델의 적합성을 더 정확하게 판단할 수 있습니다.</span></span></span></p><p id="3d8a360d-33e3-8030-88ff-fd18b104152a" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">Toss Benchmark는 이런 차이를 반영해 토스의 서비스 환경에 맞는 평가 결과를 제공합니다. 공개 리더보드의 순위에 더해, 우리 업무에서 필요한 품질을 내는지 살펴보기 위한 기준입니다.</span></span></span></p><p id="3d8a360d-33e3-80e4-934b-f1c3b28cb850" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"></span></span></p><h2 id="3d8a360d-33e3-808b-84d7-da201f23559d" class="css-1c1qox8"><span class="css-1r3ko7u"><span class="css-p4abj2"><span class="css-1kxrhf3">범용 성능만으로는 왜 부족할까</span></span></span></h2><p id="3d8a360d-33e3-80d0-b93a-fa02a5145e36" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">한국어와 모델 설정을 맞춰 평가하더라도 실제 업무에서 요구하는 능력은 별도로 확인해야 합니다.</span></span></span></p><p id="3d8a360d-33e3-808d-81f7-e9cad16b99e0" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">예를 들어 상품명을 표준 속성으로 바꿔 카탈로그에 넣는 업무를 생각해 보겠습니다. 실제 서비스 프롬프트는 아래처럼 정보의 역할과 출력 원칙을 정의합니다.</span></span></span></p><p id="3d8a360d-33e3-8090-ada7-e5b5db19b9cf" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">아래는 입력 상품명 및 추출 속성 예시입니다. (브랜드·머신·제품명은 가상의 명칭)
</span></span></span></p><p id="3d8a360d-33e3-809c-b027-e05859f1e160" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">여기서 </span><code style="font-family:Consolas, Menlo, Monaco, source-code-pro, &quot;Courier New&quot;, monospace;font-size:0.9em;background-color:var(--adaptiveOpacity100);border:1px solid var(--adaptiveOpacity200);border-radius:3px"><span class="css-1mjnzsq">모비오</span></code><span class="css-1kxrhf3">는 캡슐의 맛이나 상품명이 아니라 사용할 수 있는 머신을, </span><code style="font-family:Consolas, Menlo, Monaco, source-code-pro, &quot;Courier New&quot;, monospace;font-size:0.9em;background-color:var(--adaptiveOpacity100);border:1px solid var(--adaptiveOpacity200);border-radius:3px"><span class="css-1mjnzsq">더블</span></code><span class="css-1kxrhf3">은 두 개 묶음이 아니라 커피의 종류를 뜻합니다. 또 캐치프레소 모비오 캡슐은 한 슬리브에 10개가 들어간다는 정보를 알아야 </span><code style="font-family:Consolas, Menlo, Monaco, source-code-pro, &quot;Courier New&quot;, monospace;font-size:0.9em;background-color:var(--adaptiveOpacity100);border:1px solid var(--adaptiveOpacity200);border-radius:3px"><span class="css-1mjnzsq">3슬리브</span></code><span class="css-1kxrhf3">를 </span><code style="font-family:Consolas, Menlo, Monaco, source-code-pro, &quot;Courier New&quot;, monospace;font-size:0.9em;background-color:var(--adaptiveOpacity100);border:1px solid var(--adaptiveOpacity200);border-radius:3px"><span class="css-1mjnzsq">30EA</span></code><span class="css-1kxrhf3">로 바꿀 수 있습니다. 이런 해석은 범용적인 문자열 처리만으로는 안정적으로 하기 어렵고, 카테고리별 상품 지식과 서비스 정책을 함께 알아야 합니다</span></span></span></p><p id="3d8a360d-33e3-8089-87ae-e9310740ab3e" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">이 업무에서 도메인 지식은 상품명 속 정보의 의미를 해석하는 데 필요하고, 정책 준수는 그 정보를 정해진 속성과 형식으로 바꾸는 데 필요합니다. Toss IFBench와 Toss Knowledge는 이 두 능력을 각각 평가하기 위해 만들었습니다.</span></span></span></p><h3 id="3d8a360d-33e3-80ff-ae87-ecc4a4360539" class="css-1feg9au"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">토스 도메인 성능을 평가하는 방법</span></span></span></h3><p id="3d8a360d-33e3-8038-8d23-dc43b81a8cc4" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">토스 도메인 성능은 다음 세 종류의 평가로 나눴습니다.</span></span></span></p><aside id="3d8a360d-33e3-8042-b6cd-d5253cf1788e" class="css-nv7vyi"><div class="css-1vn47db"><ul class="css-uswsmm"><li id="3d8a360d-33e3-8097-bb45-fcecf24a55ee" class="css-1hwiibq"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-q3ktjb">등록된 운영 태스크</span><span class="css-1kxrhf3">는 실제 업무와 같은 데이터·프롬프트로 구성합니다. 해당 업무에서 어떤 모델이 잘하는지 직접 답해 줍니다.</span></span></span></li><li id="3d8a360d-33e3-8063-bd27-cd947349ca1e" class="css-1hwiibq"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-q3ktjb">Toss IFBench</span><span class="css-1kxrhf3">는 모델이 프롬프트 속 정책을 제대로 읽고 행동을 바꾸는지 봅니다. ‘정책 준수’라는 공통 능력을 재기 위한 벤치입니다.</span></span></span></li><li id="3d8a360d-33e3-8042-bee8-c1109c035b1f" class="css-1hwiibq"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-q3ktjb">Toss Knowledge</span><span class="css-1kxrhf3">는 커머스, 에즈, 파이낸스, 페이 도메인의 지식을 평가합니다. LLM 사용 케이스가 급격하게 늘어나고 있는 커머스 도메인에 대한 구축을 우선하였고 도메인을 늘려가고 있습니다.</span></span></span></li></ul></div></aside><p id="3d8a360d-33e3-8036-9977-e28226d83e8c" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">이미 정답 데이터가 있는 업무는 운영 태스크로 직접 평가할 수 있습니다. IFBench와 Knowledge는 개별 업무의 평가 데이터가 없어도 도메인 성능을 파악할 수 있는 공통 기준입니다.</span></span></span></p><p id="3d8a360d-33e3-80fa-aa0c-e6b78914b55e" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"></span></span></p><h2 id="3d8a360d-33e3-80c8-8405-e7f6c11f4f9e" class="css-1c1qox8"><span class="css-1r3ko7u"><span class="css-p4abj2"><span class="css-1kxrhf3">Toss IFBench: 정책을 이해하고 지키는 능력</span></span></span></h2><p id="3d8a360d-33e3-8083-97b7-c43e258dd5c5" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">토스의 LLM 업무에는 정책이 긴 프롬프트로 들어가는 경우가 많습니다. 상품 속성을 추출할 때의 출력 형식, 고객 문의를 분류할 때의 기준, 두 상품이 같은지 판단하는 규칙이 그 예입니다.</span></span></span></p><p id="3d8a360d-33e3-804f-b5f2-c52487a65d55" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">Toss IFBench는 상품 속성 추출과 상품 동일성 판단, 두 업무의 실제 서비스 프롬프트에서 규칙 기반으로 채점할 수 있는 지시와 판단 기준을 추렸습니다. 그리고 개별 업무의 표현에 묶이지 않도록 일반화해, 토스 도메인에서 필요한 </span><span class="css-q3ktjb">정책 이해와 준수 능력</span><span class="css-1kxrhf3">을 평가합니다.</span></span></span></p><div data-table-wrapper="true" class="css-1m7s5mg"><div role="table" class="css-uznrcw"><div role="row" class="css-1obf64m"><div role="cell" class="css-8vl804"><span class="css-1mjnzsq">평가 축</span></div><div role="cell" class="css-8vl804"><span class="css-1mjnzsq">무엇을 확인하나</span></div><div role="cell" class="css-8vl804"><span class="css-1mjnzsq">예시</span></div></div><div role="row" class="css-1obf64m"><div role="cell" class="css-8vl804"><span class="css-1mjnzsq">형식 지시</span></div><div role="cell" class="css-8vl804"><span class="css-1mjnzsq">정해진 형식으로 결과를 낼 수 있는가</span></div><div role="cell" class="css-8vl804"><span class="css-1mjnzsq">“JSON으로 답하고, 수량 단위는 EA로 써 주세요.”</span></div></div><div role="row" class="css-1obf64m"><div role="cell" class="css-8vl804"><span class="css-1mjnzsq">판단 지시</span></div><div role="cell" class="css-8vl804"><span class="css-1mjnzsq">규칙이 바뀌면 판단도 바꾸는가</span></div><div role="cell" class="css-8vl804"><span class="css-1mjnzsq">“동일 상품 판단 시 수량 차이는 무시해 주세요.”</span></div></div></div></div><h3 id="3d8a360d-33e3-8042-805e-c0e40453e56f" class="css-1feg9au"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">형식만 맞추는 것과 규칙을 따르는 것은 다릅니다</span></span></span></h3><p id="3d8a360d-33e3-80b6-8ae5-e673cf4f981f" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">상품 속성 추출에는 보통 여러 조건이 한꺼번에 들어갑니다. 예를 들어 ‘JSON으로 답할 것’, ‘키는 정해진 순서로 쓸 것’, ‘수량은 EA 단위를 붙일 것’처럼요. 조건을 하나라도 놓치면 서비스가 결과를 그대로 쓰기 어려워집니다.</span></span></span></p><p id="3d8a360d-33e3-80be-ba74-d526dce212ce" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-q3ktjb">형식 지시</span><span class="css-1kxrhf3"> 평가는 이 조건들을 지시 단위로 나누어 각각 확인합니다. 덕분에 단순히 그럴듯한 JSON을 내는지를 넘어, 프롬프트에 적힌 세부 규칙을 빠뜨리지 않고 읽는지 볼 수 있습니다.</span></span></span></p><figure id="3d8a360d-33e3-80c5-866e-cfa343c98720" class="css-of5acw"><img src="https://static.toss.im/ipd-tcs/toss_core/live/df7d6047-a66a-4a00-9ce9-7843cb0eb138/image.png" alt="" class="css-1pgssrp"/></figure><p id="3d8a360d-33e3-8071-b04d-e2eb1669990d" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-q3ktjb">판단 지시</span><span class="css-1kxrhf3">는 한 단계 더 나아갑니다. 같은 상품 쌍을 두고, 판단 규칙만 바꿔 두 번 묻습니다.</span></span></span></p><figure id="3d8a360d-33e3-8081-a488-d07254aab3d2" class="css-of5acw"><img src="https://static.toss.im/ipd-tcs/toss_core/live/3a6f57f7-3984-47e0-9509-c368c0d31365/image.png" alt="" class="css-1pgssrp"/></figure><p id="3d8a360d-33e3-8066-8c29-ca7f015b5441" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">예를 들어 면도기 1개와 10개 묶음은 기본 규칙에서는 다른 상품이지만, ‘수량 차이는 무시한다’는 규칙에서는 같은 상품입니다. 모델이 상품 자체만 보고 “당연히 다르다”고 답한다면 한 문제는 맞혀도 다른 한 문제에서 틀립니다. 이 방식은 </span><span class="css-q3ktjb">원래 판단을 고집하는 모델</span><span class="css-1kxrhf3">과 </span><span class="css-q3ktjb">프롬프트 규칙에 맞춰 판단을 바꾸는 모델</span><span class="css-1kxrhf3">을 구분합니다.</span></span></span></p><p id="3d8a360d-33e3-80db-b1ae-e12c3b45f77e" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">두 지시 유형에서 모델의 성능은 실제로 다르게 나타났습니다. 아래 차트는 IFBench 종합 점수 상위 5개 모델을 대상으로, 형식 지시와 판단 지시 점수를 나란히 보여줍니다.</span></span></span></p><figure id="3d8a360d-33e3-8065-9992-f916bfb5e844" class="css-of5acw"><img src="https://static.toss.im/ipd-tcs/toss_core/live/0322b8e2-d584-4c95-ac7e-ab804836b44a/ifbench_instruction_type_comparison_trimmed.png" alt="" class="css-1pgssrp"/></figure><p id="3d8a360d-33e3-8049-a5b3-da3d8e3ec2e6" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1odxvuk">\* reasoning을 완전히 끌 수 없어 </span><code style="font-family:Consolas, Menlo, Monaco, source-code-pro, &quot;Courier New&quot;, monospace;font-size:0.9em;background-color:var(--adaptiveOpacity100);border:1px solid var(--adaptiveOpacity200);border-radius:3px"><span class="css-1qi1uub">reasoning_effort=low</span></code><span class="css-1odxvuk"> 조건으로 측정했습니다.</span></span></span></p><p id="3d8a360d-33e3-80d7-8763-eac781ef24ca" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">gemini-3.8-flash와 gemma-4-31b-it는 판단 지시에서 모두 </span><span class="css-q3ktjb">83.2점</span><span class="css-1kxrhf3">으로 공동 1위입니다. 하지만 형식 지시에서는 gemini-3.8-flash가 </span><span class="css-q3ktjb">78.0점</span><span class="css-1kxrhf3">, gemma-4-31b-it가 </span><span class="css-q3ktjb">56.2점</span><span class="css-1kxrhf3">으로 21.8점 차이가 납니다. 규칙에 따라 판단을 바꾸는 능력과, 여러 출력 조건을 빠뜨리지 않고 지키는 능력은 같지 않습니다. 정책 준수 능력을 보려면 두 축을 함께 봐야 합니다.</span></span></span></p><p id="3d8a360d-33e3-8014-aec4-ee76900ce642" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"></span></span></p><h2 id="3d8a360d-33e3-80c6-a65f-fe38611e1213" class="css-1c1qox8"><span class="css-1r3ko7u"><span class="css-p4abj2"><span class="css-1kxrhf3">Toss Knowledge: 토스 도메인 지식을 평가하는 방법</span></span></span></h2><p id="3d8a360d-33e3-80ca-8d1f-dc14390705bf" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">Toss Knowledge는 토스 업무에 필요한 도메인 지식을 평가합니다. 첫 대상으로 커머스를 택한 것은 상품 검수·카탈로그 생성·상품 매칭 등 다양한 업무가 브랜드·규격·옵션에 대한 공통 지식을 요구하기 때문입니다.</span></span></span></p><p id="3d8a360d-33e3-80c1-87c6-dd1a53588092" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">이 지식을 평가하기 위해 아래 다섯 영역의</span><span class="css-q3ktjb"> 4지선다 문항</span><span class="css-1kxrhf3">을 구성했습니다.</span></span></span></p><div data-table-wrapper="true" class="css-1m7s5mg"><div role="table" class="css-crnt2r"><div role="row" class="css-1obf64m"><div role="cell" class="css-8vl804"><span class="css-1mjnzsq">영역</span></div><div role="cell" class="css-8vl804"><span class="css-1mjnzsq">예시</span></div></div><div role="row" class="css-1obf64m"><div role="cell" class="css-8vl804"><span class="css-1mjnzsq">브랜드 이해</span></div><div role="cell" class="css-8vl804"><span class="css-1mjnzsq">현재 이커머스에서 거래되는 브랜드의 특징</span></div></div><div role="row" class="css-1obf64m"><div role="cell" class="css-8vl804"><span class="css-1mjnzsq">이커머스 용어</span></div><div role="cell" class="css-8vl804"><span class="css-1mjnzsq">판매·배송·상품 정보에 쓰이는 용어</span></div></div><div role="row" class="css-1obf64m"><div role="cell" class="css-8vl804"><span class="css-1mjnzsq">이커머스 은어</span></div><div role="cell" class="css-8vl804"><span class="css-1mjnzsq">현업에서 쓰이는 표현</span></div></div><div role="row" class="css-1obf64m"><div role="cell" class="css-8vl804"><span class="css-1mjnzsq">상품 스펙</span></div><div role="cell" class="css-8vl804"><span class="css-1mjnzsq">용량, 규격, 호환성 등 상품 속성</span></div></div><div role="row" class="css-1obf64m"><div role="cell" class="css-8vl804"><span class="css-1mjnzsq">상품 이해</span></div><div role="cell" class="css-8vl804"><span class="css-1mjnzsq">상품을 구분하고 비교하는 데 필요한 지식</span></div></div></div></div><h3 id="3d8a360d-33e3-80e0-a046-cf6b7a855403" class="css-1feg9au"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">이런 질문을 합니다</span></span></span></h3><p id="3d8a360d-33e3-80bf-9e77-f351a1405e31" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">아래는 평가 문항의 예시입니다. (브랜드 이해·상품 이해 예시의 브랜드와 제품명은 가상의 명칭으로 바꾸고, 일부 설명을 재구성)</span></span></span></p><p id="3d8a360d-33e3-80ee-b2d5-c15bd1ede98f" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-q3ktjb">브랜드 이해</span></span></span></p><blockquote id="3d8a360d-33e3-802c-956e-edaf472777a5" class="css-2sk6rv"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">다음 중 브랜드 ‘로에빈’에 대한 설명으로 옳은 것은?</span></span></span><div class="css-1k9y0sc"><p id="3d8a360d-33e3-806e-b246-c006d1b5eb68" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">① 테니스 라켓과 운동복을 주력으로 판매하는 스포츠 브랜드
② 냄비와 프라이팬을 주력으로 판매하는 주방용품 브랜드
③ 의류와 가방을 주력으로 판매하는 패션 브랜드
④ 유리컵과 접시를 주력으로 판매하는 식기 브랜드</span></span></span></p></div></blockquote><p id="3d8a360d-33e3-80d1-8705-c5ee419e18eb" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">예시에서 설정한 정답: ③</span></span></span></p><p id="3d8a360d-33e3-8098-a046-dad1b06f4a58" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-q3ktjb">이커머스 용어</span></span></span></p><blockquote id="3d8a360d-33e3-80f9-ade4-d5793dba171a" class="css-2sk6rv"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">제조사가 제품의 기획·설계·개발까지 직접 하고, 판매 기업은 자사 브랜드만 붙여 파는 생산 방식은?</span></span></span><div class="css-1k9y0sc"><p id="3d8a360d-33e3-80e0-8797-cf3dc590ad1a" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">① 3PL　② OEM　③ ODM　④ OBM</span></span></span></p></div></blockquote><p id="3d8a360d-33e3-8044-858a-d7d594bdd0a9" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">정답: ③</span></span></span></p><p id="3d8a360d-33e3-80d0-abf3-e777bd7a69f2" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-q3ktjb">상품 이해</span></span></span></p><blockquote id="3d8a360d-33e3-8009-9309-e2e39736d8b4" class="css-2sk6rv"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">다음 중 상품 ‘그립온 토르빗’에 대한 설명으로 옳은 것은?</span></span></span><div class="css-1k9y0sc"><p id="3d8a360d-33e3-8090-8058-f55cdfae46b2" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">① 전선 피복을 자동으로 벗기는 스트리퍼
② 단자를 압착할 때 사용하는 래칫형 압착기
③ 버튼으로 턱 간격을 조절하는 워터펌프 플라이어
④ 손잡이를 잠가 물체를 고정하는 바이스 플라이어</span></span></span></p></div></blockquote><p id="3d8a360d-33e3-80ed-b90e-df336830aabc" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">정답: ③</span></span></span></p><p id="3d8a360d-33e3-8084-bc73-d67c49b49931" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">최신 정보 접근 여부의 영향을 줄이기 위해, 출시 후 충분한 시간이 지났고 현재도 활발히 거래되는 브랜드·상품을 중심으로 문항을 구성했습니다. 지나치게 쉬운 문항을 제외하고 새 문항을 보충했으며, 정답 보기의 길이가 단서로 작용하지 않도록 조정했습니다.</span></span></span></p><p id="3d8a360d-33e3-80bb-a838-dd16a1b9dce1" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">채점은 정답을 각 보기 위치에 한 번씩 배치해 네 차례 평가한 정답률의 평균(</span><code style="font-family:Consolas, Menlo, Monaco, source-code-pro, &quot;Courier New&quot;, monospace;font-size:0.9em;background-color:var(--adaptiveOpacity100);border:1px solid var(--adaptiveOpacity200);border-radius:3px"><span class="css-1mjnzsq">avg@4</span></code><span class="css-1kxrhf3">)을 사용했습니다. 특정 보기 위치에 대한 선호가 점수에 미치는 영향을 줄이기 위한 방식입니다.</span></span></span></p><figure id="3d8a360d-33e3-8059-916b-e312514f94c9" class="css-of5acw"><img src="https://static.toss.im/ipd-tcs/toss_core/live/9390e3f8-6aea-4d50-8eba-128f2f420f1f/commerce_knowledge_top5_by_category_trimmed.png" alt="" class="css-1pgssrp"/></figure><p id="3d8a360d-33e3-8017-b8dc-c4217a66eaba" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">주요 5개 모델을 축별로 보면, 같은 상위권 모델이라도 강점은 다릅니다. gemma-4-31b-it는 다섯 축 모두 89~97점으로 고른 성능을 보입니다. 반면 GPT-5.4-mini는 이커머스 용어에서 76점으로 크게 낮고, GLM-5.3-flash는 상품 스펙 97점에 비해 상품 이해 81점으로 차이가 큽니다. gemma-4-26b-a4b-it도 이커머스 은어는 97점이지만 상품 이해는 81점입니다. 즉, 종합 순위가 비슷해도 서비스에서 주로 묻는 지식 축에 따라 더 적합한 모델은 달라질 수 있습니다.</span></span></span></p><p id="3d8a360d-33e3-8060-9dc7-e753f3326ede" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"></span></span></p><h2 id="3d8a360d-33e3-80dd-af0b-cf801d6078dd" class="css-1c1qox8"><span class="css-1r3ko7u"><span class="css-p4abj2"><span class="css-1kxrhf3">실제 운영 성능과도 연결될까</span></span></span></h2><p id="3d8a360d-33e3-805d-ae90-e79679849b64" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">공통 평가 기준을 만든 뒤에는 기존 운영 업무의 성능과 연결되는지 확인했습니다. 공개 범용 지표인 AAII(Artificial Analysis Intelligence Index), Toss IFBench 형식 지시, Toss Knowledge를 각각 운영 태스크 평균 점수와 비교했습니다.</span></span></span></p><p id="3d8a360d-33e3-8016-9686-f77f4b0c2e84" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-q3ktjb">평가의 공정성을 위해, 벤치마크 데이터 구성 시 참고했던 운영 태스크는 상관관계 분석 대상에서 제외했습니다.</span><span class="css-1kxrhf3"> 벤치마크를 만드는 데 참고한 업무와 평가 대상을 분리해, 다른 운영 업무의 성능과도 연결되는지 확인했습니다.</span></span></span></p><p id="3d8a360d-33e3-8060-8ead-c78a36d910be" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">상관계수는 두 가지를 함께 봤습니다. Spearman ρ는 모델들의 </span><span class="css-q3ktjb">순위가 함께 움직이는 정도</span><span class="css-1kxrhf3">이고, Pearson r은 </span><span class="css-q3ktjb">점수 차이의 크기까지 비례하는 정도</span><span class="css-1kxrhf3">입니다. 둘 다 +1에 가까우면 관계가 강하고, 0에 가까우면 뚜렷한 상관이 관찰되지 않았다는 뜻입니다.</span></span></span></p><div data-table-wrapper="true" class="css-1m7s5mg"><div role="table" class="css-1emlqs7"><div role="row" class="css-1obf64m"><div role="cell" class="css-8vl804"><span class="css-1mjnzsq">비교한 점수</span></div><div role="cell" class="css-8vl804"><span class="css-1mjnzsq">Spearman ρ</span></div><div role="cell" class="css-8vl804"><span class="css-1mjnzsq">Pearson r</span></div><div role="cell" class="css-8vl804"><span class="css-1mjnzsq">해석</span></div></div><div role="row" class="css-1obf64m"><div role="cell" class="css-8vl804"><span class="css-1mjnzsq">AAII</span></div><div role="cell" class="css-8vl804"><span class="css-1mjnzsq">+0.02</span></div><div role="cell" class="css-8vl804"><span class="css-1mjnzsq">+0.17</span></div><div role="cell" class="css-8vl804"><span class="css-1mjnzsq">뚜렷한 상관이 관찰되지 않음</span></div></div><div role="row" class="css-1obf64m"><div role="cell" class="css-8vl804"><span class="css-1mjnzsq">Toss IFBench 형식 지시</span></div><div role="cell" class="css-8vl804"><span class="css-1mjnzsq">+0.71</span></div><div role="cell" class="css-8vl804"><span class="css-1mjnzsq">+0.87</span></div><div role="cell" class="css-8vl804"><span class="css-1mjnzsq">높은 양의 상관이 관찰됨</span></div></div><div role="row" class="css-1obf64m"><div role="cell" class="css-8vl804"><span class="css-1mjnzsq">Toss Knowledge</span></div><div role="cell" class="css-8vl804"><span class="css-1mjnzsq">+0.49</span></div><div role="cell" class="css-8vl804"><span class="css-1mjnzsq">+0.65</span></div><div role="cell" class="css-8vl804"><span class="css-1mjnzsq">양의 상관이 관찰됨</span></div></div></div></div><figure id="3d8a360d-33e3-8014-8327-c8d91b2de3d2" class="css-of5acw"><img src="https://static.toss.im/ipd-tcs/toss_core/live/5ef8d3b6-9abc-45d1-8d56-0b4604921757/correlation_triptych_v5_styled.png" alt="" class="css-1pgssrp"/></figure><p id="3d8a360d-33e3-8082-8ffb-f6b9baccc986" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">세 그래프는 같은 11개 모델을 비교합니다. 점 하나는 모델 하나이며, 가로축은 각 지표의 점수, 세로축은 동일한 운영 태스크의 평균 점수입니다. 점선은 점수 사이의 선형 추세, 음영은 그 추세의 95% 신뢰구간으로, Pearson r이 나타내는 관계입니다.</span></span></span></p><p id="3d8a360d-33e3-8073-964f-de3aecf559bd" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">Toss IFBench 형식 지시 점수가 높은 모델은 운영 태스크 평균도 높은 경향을 보였습니다. 서비스 프롬프트에서 뽑은 정책 준수 평가가 기존 업무 성능을 판단할 때 참고할 만한 정보를 담고 있다는 결과입니다.</span></span></span></p><p id="3d8a360d-33e3-80ae-a81c-eabd84cdb935" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">Knowledge도 운영 태스크 평균과 양의 상관을 보였습니다. 하지만 여러 업무를 합친 평균만으로는 커머스 지식이 어떤 업무의 성능과 연결되는지 알기 어렵습니다. 그렇다면 실제 커머스 운영 태스크를 따로 살펴보면 어떨까요? 같은 모델 집합에서 각 커머스 태스크의 점수와 Knowledge 점수를 비교했습니다. 아래는 높은 상관이 관찰된 상품 분류·그룹화·검수 결과입니다.</span></span></span></p><div data-table-wrapper="true" class="css-1m7s5mg"><div role="table" class="css-uznrcw"><div role="row" class="css-1obf64m"><div role="cell" class="css-8vl804"><span class="css-1mjnzsq">커머스 세부 태스크</span></div><div role="cell" class="css-8vl804"><span class="css-1mjnzsq">Spearman ρ</span></div><div role="cell" class="css-8vl804"><span class="css-1mjnzsq">Pearson r</span></div></div><div role="row" class="css-1obf64m"><div role="cell" class="css-8vl804"><span class="css-1mjnzsq">상품 분류</span></div><div role="cell" class="css-8vl804"><span class="css-1mjnzsq">+0.69</span></div><div role="cell" class="css-8vl804"><span class="css-1mjnzsq">+0.79</span></div></div><div role="row" class="css-1obf64m"><div role="cell" class="css-8vl804"><span class="css-1mjnzsq">상품 그룹화</span></div><div role="cell" class="css-8vl804"><span class="css-1mjnzsq">+0.72</span></div><div role="cell" class="css-8vl804"><span class="css-1mjnzsq">+0.46</span></div></div><div role="row" class="css-1obf64m"><div role="cell" class="css-8vl804"><span class="css-1mjnzsq">상품 검수</span></div><div role="cell" class="css-8vl804"><span class="css-1mjnzsq">+0.81</span></div><div role="cell" class="css-8vl804"><span class="css-1mjnzsq">+0.75</span></div></div></div></div><p id="3d8a360d-33e3-8002-bc4c-fb19a936f894" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">상품 분류·그룹화·검수에서는 커머스 지식 점수가 높은 모델이 업무 점수도 높은 경향을 보였습니다. 이를 통해 </span><span class="css-q3ktjb">Toss Knowledge가 측정하는 커머스 지식이 실제 운영 성능과도 연결된다는 점을 확인할 수 있었습니다.</span></span></span></p><h3 id="3d8a360d-33e3-8041-94d4-fb57a6af2ddd" class="css-1feg9au"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">모델 비교와 추천으로 연결하기</span></span></span></h3><p id="3d8a360d-33e3-80b4-b38d-e9e2fb4d13e0" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">평가 결과는 사내 GenAI Portal의 모델 카탈로그에서 확인할 수 있습니다. 사용 가능한 모델의 평가 결과와 서빙 지표를 한 화면에서 비교하고, 새 모델이 등록되면 같은 기준으로 평가해 추가합니다.</span></span></span></p><p id="3d8a360d-33e3-8039-ac36-f877b19a26a8" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">업무 목적을 입력하면 에이전트가 평가 결과를 바탕으로 후보 모델을 제안합니다. 예를 들어 상품 카테고리 분류를 요청하면, 분류 규칙 준수와 상품 이해에 관련된 점수를 비교해 우선 후보와 비교 후보를 제안하고 추천 근거를 설명합니다.</span></span></span></p><figure id="3d8a360d-33e3-80c6-a415-f379ad3b912b" class="css-of5acw"><img src="https://static.toss.im/ipd-tcs/toss_core/live/1211bf9c-6333-4f43-b65c-d4d09d315d97/genai_portal_usage_three_models.png" alt="" class="css-1pgssrp"/></figure><h3 id="3d8a360d-33e3-80af-a160-f339f440b052" class="css-1feg9au"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">Foundation 모델의 개발 기준으로 활용하기</span></span></span></h3><p id="3d8a360d-33e3-80bd-9495-c97547d2ef75" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">Toss Benchmark는 </span><span class="css-q3ktjb">현재 개발 중인 Toss Foundation 모델의 개발 목표</span><span class="css-1kxrhf3">를 정하는 데도 활용하려고 합니다. 한국어 이해, 정책 준수, 도메인 지식 중 어떤 능력을 강화해야 하는지 파악하고, 학습 전후의 평가 결과를 비교해 개선 여부를 확인하는 기준입니다.</span></span></span></p><p id="3d8a360d-33e3-80f2-b155-cd9029ea6454" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"></span></span></p><h2 id="3d8a360d-33e3-8023-88d0-ed5f07f00b34" class="css-1c1qox8"><span class="css-1r3ko7u"><span class="css-p4abj2"><span class="css-1kxrhf3">마치며</span></span></span></h2><p id="3d8a360d-33e3-8057-add1-d50cec4917a1" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">현재 Toss Benchmark를 다음 세 방향으로 확장하려고 합니다.</span></span></span></p><aside id="3d8a360d-33e3-8020-ae45-fa23236acc26" class="css-nv7vyi"><div class="css-1vn47db"><ul class="css-uswsmm"><li id="3d8a360d-33e3-809e-9f1c-f275bb96c62c" class="css-1hwiibq"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-q3ktjb">평가 도메인 확장</span><span class="css-1kxrhf3">: 실제 운영 태스크를 더욱 다양하게 평가에 포함하고, 지식 벤치마크도 커머스를 넘어 토스의 다양한 도메인으로 확장합니다.</span></span></span></li><li id="3d8a360d-33e3-802c-a449-f737eb23b83b" class="css-1hwiibq"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-q3ktjb">에이전트 유스케이스 확장</span><span class="css-1kxrhf3">: 금융·고객 상담, 판매자와의 음성 대화, 사내 개발·운영 지원처럼 도구와 대화 맥락을 활용하는 업무로 평가 범위를 넓힙니다. 도구 선택과 결과 해석, 후속 작업의 정확성, 실시간 응답 요구까지 함께 평가합니다.</span></span></span></li><li id="3d8a360d-33e3-8060-a691-f15994baf3f4" class="css-1hwiibq"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-q3ktjb">서비스 로그 기반 평가 고도화</span><span class="css-1kxrhf3">: 로그에서 사례를 선별하고 검수한 뒤 평가 데이터에 반영하는 흐름을 개선합니다.</span></span></span></li></ul></div></aside><p id="3d8a360d-33e3-8035-867d-e4906c634239" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">앞으로도 실제 업무에서 얻은 사례를 평가에 반영하고, 그 과정에서 확인한 모델의 특성과 평가 경험을 공유하려고 합니다. 이 글이 각자의 서비스에 맞는 모델 평가 기준을 고민하는 데 도움이 되었으면 합니다. 감사합니다.</span></span></span></p></div><div id="tds-mobile-portal-container"></div>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[토스증권이 GPU-aware를 넘어 GPU-native 클러스터를 구축한 방법]]></title>
            <link>https://toss.tech/article/gpu-native-cluster</link>
            <guid isPermaLink="true">https://toss.tech/article/gpu-native-cluster</guid>
            <pubDate>Tue, 15 Sep 2026 01:42:00 GMT</pubDate>
            <description><![CDATA[토스증권이 Kubernetes GPU 클러스터의 운영 한계를 해결한 방법]]></description>
            <content:encoded><![CDATA[<link rel="preload" as="image" href="https://static.toss.im/ipd-tcs/toss_core/live/dfeb02ca-d08c-4173-8dfc-7b7f448e2aae/gpu-aware-to-native-v2.png"/><link rel="preload" as="image" href="https://static.toss.im/ipd-tcs/toss_core/live/55370c0b-37a9-4f61-9008-10d3fe4de01a/gpu-four-step.png"/><link rel="preload" as="image" href="https://static.toss.im/ipd-tcs/toss_core/live/0c83479d-74b6-435d-aee8-a3899d08834e/gpu-device-plugin-structure.png"/><link rel="preload" as="image" href="https://static.toss.im/ipd-tcs/toss_core/live/488c564c-6020-4961-9a35-569b6ef5d86e/image.png"/><link rel="preload" as="image" href="https://static.toss.im/ipd-tcs/toss_core/live/807b0414-97c7-4eef-ac78-d9d3446e39f3/cpu-vram-control-paths-final-pretendard.png"/><link rel="preload" as="image" href="https://static.toss.im/ipd-tcs/toss_core/live/032e8e39-1d8c-4c37-91aa-96ec45e00f2e/node-gpu-status-paths.png"/><link rel="preload" as="image" href="https://static.toss.im/ipd-tcs/toss_core/live/1cec157e-bb35-4e1b-bfe9-5c67dd973cc6/node-ready-vs-gpu-ready.png"/><link rel="preload" as="image" href="https://static.toss.im/ipd-tcs/toss_core/live/0fa8c563-585d-4849-86f8-78393568bad0/image.png"/><link rel="preload" as="image" href="https://static.toss.im/ipd-tcs/toss_core/live/c4ca3439-2ade-400b-8fe0-53359a0aa7d5/image.png"/><link rel="preload" as="image" href="https://static.toss.im/ipd-tcs/toss_core/live/f6012e90-c297-42f1-ab36-28d608e6c34a/image.png"/><link rel="preload" as="image" href="https://static.toss.im/ipd-tcs/toss_core/live/a58ff48f-a1e9-43fe-b0fc-c3e62de820f9/image.png"/><link rel="preload" as="image" href="https://static.toss.im/ipd-tcs/toss_core/live/e0ef0652-3330-40c4-b8a9-2e461d5f930d/gpu-xid-alert-31.png"/><link rel="preload" as="image" href="https://static.toss.im/ipd-tcs/toss_core/live/ebf37487-455d-47c4-a9bb-687ab2f64931/gpu-xid-alert-94.png"/><link rel="preload" as="image" href="https://static.toss.im/ipd-tcs/toss_core/live/6ff79ccb-830e-42bf-9406-822cab49c741/gpu-preflight-validation-rail-toss.png"/><link rel="preload" as="image" href="https://static.toss.im/ipd-tcs/toss_core/live/844daaf3-7e3d-4ebc-98ff-9c7837bc5102/gpu-preflight-result-terminal.png"/><link rel="preload" as="image" href="https://static.toss.im/ipd-tcs/toss_core/live/ba31fdae-09aa-46b2-ad38-dd0400944f1d/gpu-preflight-labels-terminal.png"/><link rel="preload" as="image" href="https://static.toss.im/ipd-tcs/toss_core/live/6565b31d-10ca-48f8-aa5c-1d0c50aff4ae/mig-role-boundary-proposal-v2.png"/><link rel="preload" as="image" href="https://static.toss.im/ipd-tcs/toss_core/live/51260fd2-200d-4a46-b6a0-00f5d01756eb/mig-allocation-policies.png"/><div class="css-1vn47db"><p id="3dba360d-33e3-80c6-ad21-c407ed814121" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">안녕하세요. 토스증권 Data Infra 팀 Data Engineer 장재영입니다.</span></span></span></p><p id="3dba360d-33e3-80dc-9894-c359dfea2430" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">지난 상반기 동안 ML Platform 팀의 김진웅 님과 함께 Kubernetes 기반 GPU 클러스터를 구축했는데요. 오늘은 그 과정에서 겪은 문제와 해결 방법을 공유하려고 합니다.</span></span></span></p><p id="3dba360d-33e3-80cd-8430-daef7f0105cb" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-q3ktjb">토스증권의 서비스와 데이터 플랫폼은 쿠버네티스를 기반으로 운영</span><span class="css-1kxrhf3">됩니다. 수백 명의 개발자가 이 환경에서 다양한 대고객 서비스를 개발하고 운영합니다. AI 서비스도 같은 기반을 활용할 수 있도록, GPU 워크로드를 기존 쿠버네티스 클러스터 안에서 실행할 수 있게 해야 했습니다.</span></span></span></p><p id="3dba360d-33e3-8095-b0c7-fc21ba51c1a7" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">그래서 저희의 첫 과제는 쿠버네티스가 GPU를 자원으로 인식하고 할당하며, 컨테이너에서 사용할 수 있도록 연결하는 것이었습니다. 이 글에서는 GPU를 인식하고 워크로드에 할당할 수 있는 상태를 </span><span class="css-q3ktjb">GPU-aware</span><span class="css-1kxrhf3">라고 부르겠습니다.</span></span></span></p><p id="3dba360d-33e3-8084-852d-e0fa11bce731" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">하지만 GPU를 사용할 수 있게 된 뒤에도 운영에서 풀어야 할 문제는 남았습니다. 노드가 </span><code style="font-family:Consolas, Menlo, Monaco, source-code-pro, &quot;Courier New&quot;, monospace;font-size:0.9em;background-color:var(--adaptiveOpacity100);border:1px solid var(--adaptiveOpacity200);border-radius:3px"><span class="css-1mjnzsq">Ready</span></code><span class="css-1kxrhf3">여도 개별 GPU에는 오류가 있을 수 있었습니다. 점검이나 설정 변경을 마친 노드를 언제 다시 서비스에 투입해도 되는지 판단할 기준도 필요했습니다. 서비스마다 필요한 자원의 크기가 다른데 GPU를 한 장씩 제공하는 방식은 비효율적이었고요.</span></span></span></p><p id="3dba360d-33e3-80d3-9c60-d67a823d8ca7" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">여러 팀이 GPU를 함께 쓰려면 플랫폼에서 이 문제들을 다뤄야 했습니다. GPU의 정상 여부를 판단하고, 검증된 노드만 워크로드를 받게 하며, 서비스에 필요한 단위로 자원을 나누고 배치할 수 있어야 했습니다. 저희는 이러한 운영 조건까지 갖춘 상태를 </span><span class="css-q3ktjb">GPU-native</span><span class="css-1kxrhf3">라고 부르고 있습니다. 토스증권은 멀티테넌트 GPU 클러스터를 운영하며 이 조건들을 하나씩 갖춰 왔고, 필요한 경우 업스트림에도 기여했습니다.</span><a target="_blank" rel="noreferrer noopener" class="css-iynyr0" href="https://github.com/NVIDIA/k8s-device-plugin/pull/1621">[1]</a></span></span></p><figure id="3dba360d-33e3-80fb-88d0-fd38f41ccb13" class="css-of5acw"><img src="https://static.toss.im/ipd-tcs/toss_core/live/dfeb02ca-d08c-4173-8dfc-7b7f448e2aae/gpu-aware-to-native-v2.png" alt="" class="css-1pgssrp"/><figcaption class="css-wgpbp3"><span class="css-qj4ldc">GPU-aware에서 GPU-native로</span></figcaption></figure><p id="3dba360d-33e3-8056-80ae-ff10fe7f7db5" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">흥미로운 점은 쿠버네티스 자체에서도 관련 변화가 이어지고 있다는 것입니다. DRA(Dynamic Resource Allocation)</span><a target="_blank" rel="noreferrer noopener" class="css-iynyr0" href="https://kubernetes.io/docs/concepts/scheduling-eviction/dynamic-resource-allocation/">[2]</a><span class="css-1kxrhf3">는 단순히 ‘GPU 한 개’를 요청하는 데서 나아가, 필요한 조건을 가진 장치를 요청하고 할당받을 수 있도록 합니다. 저희가 지난 2년 동안 별도 구성 요소와 운영 로직으로 보완해 온 문제 중 일부가 쿠버네티스 자체에서도 다뤄지고 있는 셈입니다.</span></span></span></p><p id="3dba360d-33e3-8064-b243-d630e63a6311" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">이 글에서는 먼저 GPU-aware한 쿠버네티스를 구성하는 기본 요소를 살펴보겠습니다. 이어 실제 운영에서 마주한 여러 문제와, 이를 해결하며 GPU-native한 클러스터로 발전시켜 온 과정을 공유하겠습니다.</span></span></span></p><p id="3dba360d-33e3-80ab-9a43-d1aacc32bc24" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"></span></span></p><hr class="css-1ifza5r"/><h2 id="3dba360d-33e3-80e2-9a79-c3ee62765ef6" class="css-1c1qox8"><span class="css-1r3ko7u"><span class="css-p4abj2"><span class="css-1kxrhf3">1단계. GPU-aware한 클러스터 구축하기</span></span></span></h2><p id="3dba360d-33e3-80fb-97cb-c23de94ef937" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">그럼 GPU-aware한 쿠버네티스를 구축하기 위해서는 무엇이 필요할까요? GPU 한 장이 파드에서 쓰이기까지는 총 네 스텝을 거쳐야 합니다.</span></span></span></p><figure id="3dba360d-33e3-80d1-b7e6-ec80095028af" class="css-of5acw"><img src="https://static.toss.im/ipd-tcs/toss_core/live/55370c0b-37a9-4f61-9008-10d3fe4de01a/gpu-four-step.png" alt="" class="css-1pgssrp"/><figcaption class="css-wgpbp3"><span class="css-jfs1hr">GPU 한 장이 파드에서 사용되기까지 네 스텝</span></figcaption></figure><h3 id="3dba360d-33e3-8029-9f9b-e884abf7d7a5" class="css-1feg9au"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-q3ktjb">Step 1. 하드웨어 / 드라이버</span></span></span></h3><p id="3dba360d-33e3-8074-887a-d8971a3ae783" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">서버에 GPU 디바이스가 탑재되어 있고, 운영체제가 이를 사용할 수 있도록 NVIDIA 커널 드라이버가 준비되어야 합니다. NVIDIA 커널 드라이버가 없으면 운영체제부터 GPU를 장치로 인식하지 못하게 됩니다.</span></span></span></p><h3 id="3dba360d-33e3-8099-82c8-e9d726a1584d" class="css-1feg9au"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-q3ktjb">Step 2. NVIDIA Container Toolkit</span></span></span></h3><p id="3dba360d-33e3-8017-a841-ce2fe3e168cd" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">NVIDIA Container Toolkit은 컨테이너 런타임과 NVIDIA 드라이버 사이를 연결합니다. 이를 통해 할당된 GPU를 컨테이너가 실제로 사용할 수 있게 되는데요. 노드가 GPU를 인식하더라도 이 연결이 없으면 컨테이너에서는 GPU를 사용할 수 없습니다.</span></span></span></p><h3 id="3dba360d-33e3-80bb-aec0-c6ed2ea6b052" class="css-1feg9au"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-q3ktjb">Step 3. NVIDIA Device Plugin</span></span></span></h3><p id="3dba360d-33e3-80b0-88ea-d789d55b8945" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">여기까지 와도 쿠버네티스 API는 아직 GPU를 자원으로 인식하지 못하는데요. NVIDIA Device Plugin은 노드의 GPU를 kubelet에 등록합니다. kubelet은 이를 노드의 Capacity와 Allocatable에 </span><code style="font-family:Consolas, Menlo, Monaco, source-code-pro, &quot;Courier New&quot;, monospace;font-size:0.9em;background-color:var(--adaptiveOpacity100);border:1px solid var(--adaptiveOpacity200);border-radius:3px"><span class="css-1mjnzsq">nvidia.com/gpu</span></code><span class="css-1kxrhf3">라는 자원으로 반영하고, 스케줄러는 이 값을 기준으로 GPU를 요청한 파드에 배치합니다.</span></span></span></p><figure id="3dba360d-33e3-80e7-acab-e4614de042b8" class="css-of5acw"><img src="https://static.toss.im/ipd-tcs/toss_core/live/0c83479d-74b6-435d-aee8-a3899d08834e/gpu-device-plugin-structure.png" alt="" class="css-1pgssrp"/><figcaption class="css-wgpbp3"><span class="css-jfs1hr">Device Plugin이 GPU를 쿠버네티스 자원으로 등록하는 과정</span></figcaption></figure><h3 id="3dba360d-33e3-8006-b789-d9616457dc47" class="css-1feg9au"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-q3ktjb">Step 4. 파드 계층</span></span></span></h3><p id="3dba360d-33e3-80e2-956d-f1e2be47358f" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">마지막은 사용자 영역입니다. 파드 스펙에서 </span><code style="font-family:Consolas, Menlo, Monaco, source-code-pro, &quot;Courier New&quot;, monospace;font-size:0.9em;background-color:var(--adaptiveOpacity100);border:1px solid var(--adaptiveOpacity200);border-radius:3px"><span class="css-1mjnzsq">nvidia.com/gpu</span></code><span class="css-1kxrhf3">를 요청하면, 앞에서 준비된 경로를 통해 컨테이너에 GPU가 연결됩니다.</span></span></span></p><p id="3dba360d-33e3-80ee-828d-f0f0a9d9042f" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">각 스텝의 역할은 다르지만, 어느 한 곳에 문제가 생겼을 때 나타나는 결과는 비슷합니다. 노드에는 GPU가 있는데 쿠버네티스 자원으로 나타나지 않거나, 파드는 배치됐지만 컨테이너에서 GPU를 사용하지 못하게 됩니다.</span></span></span></p><p id="3dba360d-33e3-806e-962f-ef82d1356a8e" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">실제 운영에서 드라이버와 Container Toolkit, Device Plugin을 각각 설치하고 호환되는 버전과 설정을 유지하는 것은 분명 운영 부담인데요. GPU 노드가 늘어날수록 모든 노드에서 동일한 구성을 유지하기 어려워지고, 구성 차이가 장애로 이어질 가능성도 커집니다.</span></span></span></p><p id="3dba360d-33e3-8068-a5bb-fa22890ba14d" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">앞선 네 스텝을 거치면 GPU는 쿠버네티스의 스케줄링 대상이 되고, 컨테이너에서도 사용할 수 있게 됩니다. 이 글에서 말하는 GPU-aware한 상태입니다. </span></span></span></p><p id="3dba360d-33e3-80e5-a8e2-e10c406f5a70" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"></span></span></p><h2 id="3dba360d-33e3-805b-8b48-ea509e0e9272" class="css-1c1qox8"><span class="css-1r3ko7u"><span class="css-p4abj2"><span class="css-1kxrhf3">2단계. GPU-aware만으로 충분할까?</span></span></span></h2><h3 id="3dba360d-33e3-80e4-a63d-c8d2791a2efa" class="css-1feg9au"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">GPU 메모리는 어떻게 관리될까요?</span></span></span></h3><p id="3dba360d-33e3-80c8-9e4b-fffb7c21243c" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">GPU-aware한 상태가 되었다면 GPU 운영의 모든 문제가 해결된 걸까요? GPU가 보이고 사용할 수 있다는 것과, 그 내부 자원까지 쿠버네티스가 제어한다는 것은 다른 이야기입니다.</span></span></span></p><p id="3dba360d-33e3-807a-abee-d5f742c808b4" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">이 차이를 이해하려면 CPU와 메모리, GPU가 서로 다른 제어 경로를 거친다는 점부터 살펴볼 필요가 있습니다.</span></span></span></p><p id="3dba360d-33e3-8038-b8ad-eccf4b05f5d1" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">CPU와 시스템 메모리는 리눅스 커널의 cgroup을 통해 컨테이너 단위로 제어가 가능합니다. 컨테이너가 사용할 수 있는 CPU 시간과 메모리 한도를 정하면, 한도를 넘었을 때 커널이 개입합니다. 쿠버네티스의 CPU와 메모리 자원 관리도 이 기능을 바탕으로 동작해요.</span></span></span></p><p id="3dba360d-33e3-80a3-95f3-da9896e9fa6b" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"></span></span></p><figure id="3dba360d-33e3-805c-b34c-f8d453a9fd6f" class="css-of5acw"><img src="https://static.toss.im/ipd-tcs/toss_core/live/488c564c-6020-4961-9a35-569b6ef5d86e/image.png" alt="" class="css-1pgssrp"/><figcaption class="css-wgpbp3"><span class="css-jfs1hr">출처: NVIDIA container stack Architecture Overview </span><a target="_blank" rel="noreferrer noopener" class="css-114ityv" href="https://docs.nvidia.com/datacenter/cloud-native/container-toolkit/latest/_images/runtime-architecture.png">[3]</a></figcaption></figure><p id="3dba360d-33e3-80ae-87da-ccc1401e0f0b" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">반면 GPU 메모리는 쿠버네티스가 사용하는 cgroup 기반의 자원 제어 경로에 포함되지 않아요. 앞서 설명했던 것과 같이 컨테이너가 GPU를 사용하기 위해서는 NVIDIA Container Toolkit의 도움이 필요합니다. </span></span></span></p><p id="3dba360d-33e3-80ac-ba35-e74920a3df63" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">NVIDIA Container Toolkit은 컨테이너 런타임과 연동해, 할당된 GPU 디바이스와 필요한 드라이버 라이브러리를 컨테이너에 연결하는데요. 이를 통해 CUDA 애플리케이션은 </span><code style="font-family:Consolas, Menlo, Monaco, source-code-pro, &quot;Courier New&quot;, monospace;font-size:0.9em;background-color:var(--adaptiveOpacity100);border:1px solid var(--adaptiveOpacity200);border-radius:3px"><span class="css-1mjnzsq">/dev/nvidia*</span></code><span class="css-1kxrhf3"> 디바이스와 </span><code style="font-family:Consolas, Menlo, Monaco, source-code-pro, &quot;Courier New&quot;, monospace;font-size:0.9em;background-color:var(--adaptiveOpacity100);border:1px solid var(--adaptiveOpacity200);border-radius:3px"><span class="css-1mjnzsq">libcuda.so</span></code><span class="css-1kxrhf3">를 사용해 호스트의 GPU에 접근할 수 있습니다.</span></span></span></p><p id="3dba360d-33e3-8045-9a7b-f3096dac2f0a" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">이 구조에서 Device Plugin과 kubelet은 어떤 GPU를 컨테이너에 할당할지 결정하고, Container Toolkit은 선택된 디바이스를 컨테이너가 사용할 수 있도록 연결합니다. 하지만 쿠버네티스가 제어할 수 있는 경계는 여기까지입니다. </span></span></span></p><p id="3dba360d-33e3-805b-ac40-e1176edc775e" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><code style="font-family:Consolas, Menlo, Monaco, source-code-pro, &quot;Courier New&quot;, monospace;font-size:0.9em;background-color:var(--adaptiveOpacity100);border:1px solid var(--adaptiveOpacity200);border-radius:3px"><span class="css-1mjnzsq">nvidia.com/gpu: 1</span></code><span class="css-1kxrhf3">은 GPU 장치 하나를 할당한다는 의미이지, 그 안에서 사용할 VRAM 크기나 연산 비율까지 지정한다는 뜻은 아닙니다. 즉, 쿠버네티스는 CPU와 메모리를 cgroup으로 제어하듯 GPU 내부 자원을 직접 제어하지는 못합니다.</span></span></span></p><figure id="3dba360d-33e3-8027-95d7-d5dbe5884c1d" class="css-of5acw"><img src="https://static.toss.im/ipd-tcs/toss_core/live/807b0414-97c7-4eef-ac78-d9d3446e39f3/cpu-vram-control-paths-final-pretendard.png" alt="" class="css-1pgssrp"/><figcaption class="css-wgpbp3"><span class="css-qj4ldc">CPU 메모리와 GPU 메모리 제어 경로 비교</span></figcaption></figure><p id="3dba360d-33e3-80fc-92eb-de2626fee1fb" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">이 한계는 VRAM을 사용할 때 분명히 드러납니다. 쿠버네티스에는 </span><code style="font-family:Consolas, Menlo, Monaco, source-code-pro, &quot;Courier New&quot;, monospace;font-size:0.9em;background-color:var(--adaptiveOpacity100);border:1px solid var(--adaptiveOpacity200);border-radius:3px"><span class="css-1mjnzsq">memory: 4Gi</span></code><span class="css-1kxrhf3">처럼 컨테이너 메모리를 요청하고 제한하는 방법이 있지만, </span><code style="font-family:Consolas, Menlo, Monaco, source-code-pro, &quot;Courier New&quot;, monospace;font-size:0.9em;background-color:var(--adaptiveOpacity100);border:1px solid var(--adaptiveOpacity200);border-radius:3px"><span class="css-1mjnzsq">vram: 40Gi</span></code><span class="css-1kxrhf3">처럼 GPU 메모리를 요청하는 표준 자원은 없어요.</span></span></span></p><p id="3dba360d-33e3-80aa-9840-db9efc81c2ff" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">그래서 VRAM이 부족해졌을 때도 쿠버네티스가 이를 제어할 수 없습니다. 애플리케이션은 CUDA OOM 같은 오류를 만나고, 실제 운영에서 그 원인을 쿠버네티스 이벤트만으로 진단하기는 어려워요. 파드가 종료된 뒤 GPU 상태가 정상으로 돌아왔는지는 쿠버네티스의 기본 이벤트와 자원 상태만으로 충분히 확인할 수 없습니다.</span></span></span></p><p id="3dba360d-33e3-8060-8a5a-e62da4c21882" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">이처럼 GPU-aware한 상태는 GPU 운영의 출발점이었지만, VRAM은 쿠버네티스가 GPU를 인식하는 것과 운영 가능한 상태로 이해하는 것이 다르다는 점을 보여 주는 대표적인 예였습니다. GPU를 할당할 수 있다는 것이 그 안의 메모리 사용량까지 같은 방식으로 제한할 수 있다는 뜻은 아니었습니다.</span></span></span></p><p id="3dba360d-33e3-80ca-b4e0-dac75727d6a8" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"></span></span></p><hr class="css-1ifza5r"/><p id="3dba360d-33e3-80e1-a3d6-ee7a7843f41b" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"></span></span></p><p id="3dba360d-33e3-80ff-95b3-f809eaf81145" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">GPU를 사용할 수 있게 된 뒤, 실제 서비스를 운영하기 위해서는 다음 세 가지 질문에도 답해야 했습니다.</span></span></span></p><h3 id="3dba360d-33e3-8035-a169-d0984d936ad3" class="css-1feg9au"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">질문 1. Node Ready는 GPU Ready인가?</span></span></span></h3><figure id="3dba360d-33e3-8058-b675-c3074432188d" class="css-of5acw"><img src="https://static.toss.im/ipd-tcs/toss_core/live/032e8e39-1d8c-4c37-91aa-96ec45e00f2e/node-gpu-status-paths.png" alt="" class="css-1pgssrp"/><figcaption class="css-wgpbp3"><span class="css-jfs1hr">노드 상태와 GPU 상태를 확인하는 서로 다른 경로</span></figcaption></figure><p id="3dba360d-33e3-80a0-a0d0-ed7c550cd0d2" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">쿠버네티스의 Node Ready는 노드와 kubelet의 기본적인 동작 상태를 보여 주지만, 개별 GPU와 GPU 스택의 정상 상태까지 의미하지는 않습니다. 노드가 Ready이고 파드가 Running이어도 특정 GPU에서는 이미 </span><code style="font-family:Consolas, Menlo, Monaco, source-code-pro, &quot;Courier New&quot;, monospace;font-size:0.9em;background-color:var(--adaptiveOpacity100);border:1px solid var(--adaptiveOpacity200);border-radius:3px"><span class="css-1mjnzsq">ERR!</span></code><span class="css-1kxrhf3"> 상태나 Xid 오류가 발생했을 수 있습니다.</span></span></span></p><p id="3dba360d-33e3-8008-b6c5-f26c3858fa73" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">따라서 쿠버네티스가 보여 주는 상태와 별개로, 드라이버와 CUDA 실행 경로가 정상인지, 운영 중인 GPU 내부에서 어떤 오류가 발생하고 있는지 판단할 수 있어야 했습니다.</span></span></span></p><h3 id="3dba360d-33e3-8074-8fe8-da10ac107305" class="css-1feg9au"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">질문 2. 언제 GPU 노드를 서비스에 투입할 수 있는가?</span></span></span></h3><p id="3dba360d-33e3-80a9-8599-c9fd2871f817" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">GPU의 상태를 확인할 수 있게 된 것과, 그 노드를 실제 서비스에 투입해도 된다고 판단하는 것은 또 다른 문제였습니다. 새로운 GPU 노드가 클러스터에 연결되고 자원으로 등록됐다는 사실만으로는 토스증권의 서비스 워크로드를 받을 준비가 끝났다고 볼 수 없었습니다.</span></span></span></p><p id="3dba360d-33e3-8048-8c5c-d5f763e87988" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">OS·커널·드라이버와 CUDA 버전이 토스증권 GPU 클러스터의 표준 구성에 맞는지, GPU가 부하를 견디는지, 쿠버네티스에 정상적으로 편입됐는지, 실제 CUDA 연산과 모델 서빙이 동작하는지 확인해야 했습니다. 신규 장비뿐 아니라 장애 점검을 마치고 돌아온 장비에도 동일하게 적용할 수 있는 명확한 투입 기준이 필요했습니다.</span></span></span></p><h3 id="3dba360d-33e3-8087-ac39-f7888d17186b" class="css-1feg9au"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">질문 3. GPU 한 장은 서비스에 적절한 제공 단위인가?</span></span></span></h3><p id="3dba360d-33e3-805e-bcff-c89aab3ec354" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">서빙 워크로드를 효율적으로 배치하려면 단순히 GPU를 “쓸 수 있다”에서 한 걸음 더 나아가야 합니다. 서비스마다 필요한 연산량과 VRAM, 격리 수준이 다르기 때문에, 플랫폼은 물리 GPU 한 장보다 더 세밀한 제공 단위를 가져야 하는데요. 그러나 0.5코어처럼 쪼개서 요청 가능한 CPU와는 달리, </span><code style="font-family:Consolas, Menlo, Monaco, source-code-pro, &quot;Courier New&quot;, monospace;font-size:0.9em;background-color:var(--adaptiveOpacity100);border:1px solid var(--adaptiveOpacity200);border-radius:3px"><span class="css-1mjnzsq">nvidia.com/gpu</span></code><span class="css-1kxrhf3"> 같은 확장 리소스는 정수 단위로만 요청할 수 있습니다.</span><a target="_blank" rel="noreferrer noopener" class="css-iynyr0" href="https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/#consuming-extended-resources">[4]</a></span></span></p><p id="3dba360d-33e3-806b-aac8-fc299f226f46" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">GPU를 더 작은 단위로 나눈 뒤에도 해결할 문제가 남아 있었습니다. 분할한 자원을 여러 물리 GPU에 분산할지, 한 GPU부터 채울지에 따라 Full GPU 확보와 구성 변경의 유연성이 달라지기 때문입니다. 서비스에 맞는 제공 단위뿐 아니라, 그 단위를 어떻게 배치할지도 함께 결정해야 했습니다.</span></span></span></p><p id="3dba360d-33e3-809e-880a-c8329d18fdb4" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">이 세 질문에 답하기 위해서는 GPU를 단순히 인식하고 할당하는 데서 나아가, GPU의 상태를 판단하고, 서비스 투입 기준을 세우며, 자원을 나누고 배치하는 방식을 보완해야 했습니다. 다음 단계에서는 이 세 질문에 답하기 위해 무엇을 바꿨는지 살펴보겠습니다.</span></span></span></p><p id="3dba360d-33e3-80cd-986b-c0a7e7df0499" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"></span></span></p><hr class="css-1ifza5r"/><h2 id="3dba360d-33e3-8040-b958-d818e62cc629" class="css-1c1qox8"><span class="css-1r3ko7u"><span class="css-p4abj2"><span class="css-1kxrhf3">3단계. 클러스터를 GPU-native하게 만들기</span></span></span></h2><h3 id="3dba360d-33e3-800e-96c2-f12eca235536" class="css-1feg9au"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">1. Node Ready와 GPU Ready 구분하기</span></span></span></h3><figure id="3dba360d-33e3-80af-8a50-d7eea1877bf5" class="css-of5acw"><img src="https://static.toss.im/ipd-tcs/toss_core/live/1cec157e-bb35-4e1b-bfe9-5c67dd973cc6/node-ready-vs-gpu-ready.png" alt="" class="css-1pgssrp"/><figcaption class="css-wgpbp3"><span class="css-jfs1hr">Node Ready와 GPU Ready의 판단 기준 비교</span></figcaption></figure><p id="3dba360d-33e3-808e-bde3-e80e7f6b840a" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">쿠버네티스의 노드 상태 조건인 </span><code style="font-family:Consolas, Menlo, Monaco, source-code-pro, &quot;Courier New&quot;, monospace;font-size:0.9em;background-color:var(--adaptiveOpacity100);border:1px solid var(--adaptiveOpacity200);border-radius:3px"><span class="css-1mjnzsq">Node Ready=True</span></code><span class="css-1kxrhf3">는 kubelet이 정상적으로 상태를 보고하고, 노드가 파드를 실행할 기본 조건을 갖췄다는 뜻입니다. </span><code style="font-family:Consolas, Menlo, Monaco, source-code-pro, &quot;Courier New&quot;, monospace;font-size:0.9em;background-color:var(--adaptiveOpacity100);border:1px solid var(--adaptiveOpacity200);border-radius:3px"><span class="css-1mjnzsq">Pod Running</span></code><span class="css-1kxrhf3"> 역시 파드의 실행 단계를 나타낼 뿐, 그 안에서 GPU 워크로드가 정상적으로 동작한다는 것을 보장하지는 않습니다.</span></span></span></p><p id="3dba360d-33e3-8018-aed0-cf5bd806cdb8" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">그래서 저희는 </span><code style="font-family:Consolas, Menlo, Monaco, source-code-pro, &quot;Courier New&quot;, monospace;font-size:0.9em;background-color:var(--adaptiveOpacity100);border:1px solid var(--adaptiveOpacity200);border-radius:3px"><span class="css-1mjnzsq">GPU Ready</span></code><span class="css-1kxrhf3">를 별도의 운영 기준으로 정의했습니다. GPU 실행 경로가 모두 동작하는지, 기대한 GPU 자원이 노출되는지, 개별 장치의 상태 조회가 정상인지, 복구가 필요한 오류가 없는지를 함께 확인하는 기준입니다.</span></span></span></p><p id="3dba360d-33e3-8071-b80c-dd3cbdbf1336" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">GPU Ready를 판단하려면 실행 전 스택 검증, 실행 중 상태 관측, 장애 신호와 대응의 연결이라는 세 가지 관점이 필요했습니다.</span></span></span></p><p id="3dba360d-33e3-80a0-b145-e767b98524c1" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"></span></span></p><h3 id="3dba360d-33e3-8000-b1ab-daf4db2ee86f" class="css-1feg9au"><span class="css-1r3ko7u"><span class="css-1iisb9p"><code style="font-family:Consolas, Menlo, Monaco, source-code-pro, &quot;Courier New&quot;, monospace;font-size:0.9em;background-color:var(--adaptiveOpacity100);border:1px solid var(--adaptiveOpacity200);border-radius:3px"><span class="css-1mjnzsq">nvidia-validator</span></code><span class="css-1kxrhf3"> — GPU 스택 상태 검증</span></span></span></h3><p id="3dba360d-33e3-80e6-8e0a-fb9e6b65209b" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">먼저 GPU 워크로드에 필요한 스택이 정상인지 확인해야 했습니다. GPU 파드가 실행되려면 드라이버, Container Toolkit, Device Plugin, CUDA가 각각 정상적으로 동작해야 합니다. </span><code style="font-family:Consolas, Menlo, Monaco, source-code-pro, &quot;Courier New&quot;, monospace;font-size:0.9em;background-color:var(--adaptiveOpacity100);border:1px solid var(--adaptiveOpacity200);border-radius:3px"><span class="css-1mjnzsq">nvidia-validator</span></code><span class="css-1kxrhf3">는 이 요소들이 기본 검증을 통과했는지 확인합니다.</span></span></span></p><p id="3dba360d-33e3-80f4-b33f-c9a52c15ef81" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">문제는 </span><code style="font-family:Consolas, Menlo, Monaco, source-code-pro, &quot;Courier New&quot;, monospace;font-size:0.9em;background-color:var(--adaptiveOpacity100);border:1px solid var(--adaptiveOpacity200);border-radius:3px"><span class="css-1mjnzsq">nvidia-validator</span></code><span class="css-1kxrhf3">가 통과했다고 해서 항상 “모든 GPU가 정상”이라고 말할 수는 없었다는 점이었어요. 실제로 운영 중인 H100 노드 하나에서 </span><code style="font-family:Consolas, Menlo, Monaco, source-code-pro, &quot;Courier New&quot;, monospace;font-size:0.9em;background-color:var(--adaptiveOpacity100);border:1px solid var(--adaptiveOpacity200);border-radius:3px"><span class="css-1mjnzsq">dcgm-exporter</span></code><span class="css-1kxrhf3">가 </span><span class="css-1odxvuk">nvml error: unknown error</span><span class="css-1kxrhf3">로 </span><code style="font-family:Consolas, Menlo, Monaco, source-code-pro, &quot;Courier New&quot;, monospace;font-size:0.9em;background-color:var(--adaptiveOpacity100);border:1px solid var(--adaptiveOpacity200);border-radius:3px"><span class="css-1mjnzsq">CrashLoopBackOff</span></code><span class="css-1kxrhf3">에 빠진 적이 있었습니다.</span></span></span></p><p id="3dba360d-33e3-807c-a55e-db644352781d" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">노드에서 </span><code style="font-family:Consolas, Menlo, Monaco, source-code-pro, &quot;Courier New&quot;, monospace;font-size:0.9em;background-color:var(--adaptiveOpacity100);border:1px solid var(--adaptiveOpacity200);border-radius:3px"><span class="css-1mjnzsq">nvidia-smi</span></code><span class="css-1kxrhf3">를 확인해 보니 H100 GPU 네 장 중 한 장이 </span><code style="font-family:Consolas, Menlo, Monaco, source-code-pro, &quot;Courier New&quot;, monospace;font-size:0.9em;background-color:var(--adaptiveOpacity100);border:1px solid var(--adaptiveOpacity200);border-radius:3px"><span class="css-1mjnzsq">ERR!</span></code><span class="css-1kxrhf3"> 상태였는데요.</span></span></span></p><p id="3dba360d-33e3-80c8-95cf-c424ef17455f" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">당시 </span><code style="font-family:Consolas, Menlo, Monaco, source-code-pro, &quot;Courier New&quot;, monospace;font-size:0.9em;background-color:var(--adaptiveOpacity100);border:1px solid var(--adaptiveOpacity200);border-radius:3px"><span class="css-1mjnzsq">nvidia-validator</span></code><span class="css-1kxrhf3">는 이 노드를 정상으로 보고 있었습니다. </span><code style="font-family:Consolas, Menlo, Monaco, source-code-pro, &quot;Courier New&quot;, monospace;font-size:0.9em;background-color:var(--adaptiveOpacity100);border:1px solid var(--adaptiveOpacity200);border-radius:3px"><span class="css-1mjnzsq">nvidia-validator</span></code><span class="css-1kxrhf3">의 드라이버 검증이 </span><code style="font-family:Consolas, Menlo, Monaco, source-code-pro, &quot;Courier New&quot;, monospace;font-size:0.9em;background-color:var(--adaptiveOpacity100);border:1px solid var(--adaptiveOpacity200);border-radius:3px"><span class="css-1mjnzsq">nvidia-smi</span></code><span class="css-1kxrhf3"> 명령의 종료 코드에 의존하고 있었기 때문인데요. 개별 GPU가 </span><code style="font-family:Consolas, Menlo, Monaco, source-code-pro, &quot;Courier New&quot;, monospace;font-size:0.9em;background-color:var(--adaptiveOpacity100);border:1px solid var(--adaptiveOpacity200);border-radius:3px"><span class="css-1mjnzsq">ERR!</span></code><span class="css-1kxrhf3">로 표시돼도 명령 자체는 </span><code style="font-family:Consolas, Menlo, Monaco, source-code-pro, &quot;Courier New&quot;, monospace;font-size:0.9em;background-color:var(--adaptiveOpacity100);border:1px solid var(--adaptiveOpacity200);border-radius:3px"><span class="css-1mjnzsq">exit 0</span></code><span class="css-1kxrhf3">이었던 거죠. 기본 검증의 성공과 저희가 판단하는 개별 GPU의 정상 상태가 같지 않았던 것입니다.</span></span></span></p><p id="3dba360d-33e3-8045-af9b-e25c1132195b" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"></span></span></p><p id="3dba360d-33e3-80b8-b725-c2e5f7202c67" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">그래서 NVIDIA의 기본 검증 체계는 유지하면서, 저희 운영 기준으로 GPU readiness를 판단하는 별도 DaemonSet을 구성했습니다. readiness를 결정하는 DaemonSet에서는 </span><code style="font-family:Consolas, Menlo, Monaco, source-code-pro, &quot;Courier New&quot;, monospace;font-size:0.9em;background-color:var(--adaptiveOpacity100);border:1px solid var(--adaptiveOpacity200);border-radius:3px"><span class="css-1mjnzsq">nvidia-smi</span></code><span class="css-1kxrhf3"> 출력의 </span><code style="font-family:Consolas, Menlo, Monaco, source-code-pro, &quot;Courier New&quot;, monospace;font-size:0.9em;background-color:var(--adaptiveOpacity100);border:1px solid var(--adaptiveOpacity200);border-radius:3px"><span class="css-1mjnzsq">ERR!</span></code><span class="css-1kxrhf3">, 치명적인 Xid, NVIDIA Device Plugin이 보여 주는 GPU 수와 실제 PCI 디바이스 수의 차이를 함께 보도록 했어요.</span></span></span></p><p id="3dba360d-33e3-809d-a406-c481c7781ea1" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">이렇게 보완한 판정 결과는 전부 노드별 메트릭으로 노출됩니다. 아래는 저희가 실제 대시보드에서 보는 지표들입니다.</span></span></span></p><div data-table-wrapper="true" class="css-1m7s5mg"><div role="table" class="css-crnt2r"><div role="row" class="css-1obf64m"><div role="cell" class="css-8vl804"><span class="css-1mjnzsq">지표</span></div><div role="cell" class="css-8vl804"><span class="css-1mjnzsq">답하는 질문</span></div></div><div role="row" class="css-1obf64m"><div role="cell" class="css-8vl804"><code style="font-family:Consolas, Menlo, Monaco, source-code-pro, &quot;Courier New&quot;, monospace;font-size:0.9em;background-color:var(--adaptiveOpacity100);border:1px solid var(--adaptiveOpacity200);border-radius:3px"><span class="css-1mjnzsq">gpu_operator_node_driver_ready</span></code></div><div role="cell" class="css-8vl804"><span class="css-1mjnzsq">드라이버가 살아 있는가</span></div></div><div role="row" class="css-1obf64m"><div role="cell" class="css-8vl804"><code style="font-family:Consolas, Menlo, Monaco, source-code-pro, &quot;Courier New&quot;, monospace;font-size:0.9em;background-color:var(--adaptiveOpacity100);border:1px solid var(--adaptiveOpacity200);border-radius:3px"><span class="css-1mjnzsq">gpu_operator_node_toolkit_ready</span></code></div><div role="cell" class="css-8vl804"><span class="css-1mjnzsq">Container Toolkit이 동작하는가</span></div></div><div role="row" class="css-1obf64m"><div role="cell" class="css-8vl804"><code style="font-family:Consolas, Menlo, Monaco, source-code-pro, &quot;Courier New&quot;, monospace;font-size:0.9em;background-color:var(--adaptiveOpacity100);border:1px solid var(--adaptiveOpacity200);border-radius:3px"><span class="css-1mjnzsq">gpu_operator_node_plugin_ready</span></code></div><div role="cell" class="css-8vl804"><span class="css-1mjnzsq">NVIDIA Device Plugin이 GPU를 노출하고 있는가</span></div></div><div role="row" class="css-1obf64m"><div role="cell" class="css-8vl804"><code style="font-family:Consolas, Menlo, Monaco, source-code-pro, &quot;Courier New&quot;, monospace;font-size:0.9em;background-color:var(--adaptiveOpacity100);border:1px solid var(--adaptiveOpacity200);border-radius:3px"><span class="css-1mjnzsq">gpu_operator_node_cuda_ready</span></code></div><div role="cell" class="css-8vl804"><span class="css-1mjnzsq">CUDA 워크로드가 실제로 실행되는가</span></div></div><div role="row" class="css-1obf64m"><div role="cell" class="css-8vl804"><code style="font-family:Consolas, Menlo, Monaco, source-code-pro, &quot;Courier New&quot;, monospace;font-size:0.9em;background-color:var(--adaptiveOpacity100);border:1px solid var(--adaptiveOpacity200);border-radius:3px"><span class="css-1mjnzsq">gpu_operator_node_driver_validation</span></code></div><div role="cell" class="css-8vl804"><span class="css-1mjnzsq">드라이버 검증 테스트를 통과했는가 (1=pass, 0=fail)</span></div></div><div role="row" class="css-1obf64m"><div role="cell" class="css-8vl804"><code style="font-family:Consolas, Menlo, Monaco, source-code-pro, &quot;Courier New&quot;, monospace;font-size:0.9em;background-color:var(--adaptiveOpacity100);border:1px solid var(--adaptiveOpacity200);border-radius:3px"><span class="css-1mjnzsq">gpu_operator_nvidia_pci_devices_total</span></code></div><div role="cell" class="css-8vl804"><span class="css-1mjnzsq">노드에 물리적으로 장착된 GPU가 몇 장인가</span></div></div><div role="row" class="css-1obf64m"><div role="cell" class="css-8vl804"><code style="font-family:Consolas, Menlo, Monaco, source-code-pro, &quot;Courier New&quot;, monospace;font-size:0.9em;background-color:var(--adaptiveOpacity100);border:1px solid var(--adaptiveOpacity200);border-radius:3px"><span class="css-1mjnzsq">gpu_operator_node_device_plugin_devices_total</span></code></div><div role="cell" class="css-8vl804"><span class="css-1mjnzsq">쿠버네티스에 노출된 GPU가 몇 개인가 — 위 값과 어긋나면 이상</span></div></div><div role="row" class="css-1obf64m"><div role="cell" class="css-8vl804"><code style="font-family:Consolas, Menlo, Monaco, source-code-pro, &quot;Courier New&quot;, monospace;font-size:0.9em;background-color:var(--adaptiveOpacity100);border:1px solid var(--adaptiveOpacity200);border-radius:3px"><span class="css-1mjnzsq">gpu_operator_node_<sup class="css-q6g7a4">*</sup>_validation_last_success_ts_seconds</span></code></div><div role="cell" class="css-8vl804"><span class="css-1mjnzsq">마지막 검증 성공이 언제인가 — 오래됐으면 검증 자체가 멈춘 것</span></div></div></div></div><p id="3dba360d-33e3-8044-bf57-d6da5bf66bda" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">이 중 드라이버, Container Toolkit, Device Plugin, CUDA </span><span class="css-q3ktjb">네 개의 readiness 신호가 모두 1이어야 GPU 실행 경로가 정상이라고 판단했습니다.</span><span class="css-1kxrhf3"> 물리 GPU와 노출된 자원의 수, 마지막 검증 성공 시각은 장치 누락이나 검증 중단을 발견하기 위한 보조 기준으로 사용했습니다.</span></span></span></p><figure id="3dba360d-33e3-8086-8a53-cb7bcbce0392" class="css-of5acw"><img src="https://static.toss.im/ipd-tcs/toss_core/live/0fa8c563-585d-4849-86f8-78393568bad0/image.png" alt="" class="css-1pgssrp"/><figcaption class="css-wgpbp3"><span class="css-jfs1hr">GPU 노드 검증 대시보드</span></figcaption></figure><figure id="3dba360d-33e3-80ae-bebb-ebf8b14761d5" class="css-of5acw"><img src="https://static.toss.im/ipd-tcs/toss_core/live/c4ca3439-2ade-400b-8fe0-53359a0aa7d5/image.png" alt="" class="css-1pgssrp"/><figcaption class="css-wgpbp3"><span class="css-jfs1hr">GPU 노드 검증 개요 — GPU readiness 판별을 위한 PromQL을 모두 통과하면 Healthy입니다.</span></figcaption></figure><p id="3dba360d-33e3-80b5-8211-faaa4d343a0d" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">이렇게 실행 전 GPU 스택의 상태를 확인할 수 있게 되었습니다. 그러나 검증 시점에 GPU 스택이 정상이라는 것과, 워크로드가 실행되는 동안 GPU가 어떤 상태인지를 아는 것은 다른 문제였습니다.</span></span></span></p><h3 id="3dba360d-33e3-803d-b02a-f1c1a698fd3f" class="css-1feg9au"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">DCGM Exporter — GPU 상태 메트릭 상시 관측</span></span></span></h3><p id="3dba360d-33e3-80e4-b08e-ec76bd7fc4b0" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">워크로드가 실행되기 시작하면 GPU 상태는 계속해서 변하는데요.</span></span></span></p><p id="3dba360d-33e3-80f9-9c7b-d5ce54bdc481" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">DCGM Exporter는 GPU 사용률과 VRAM, 온도, 전력, ECC·Xid 오류, PCIe·NVLink 상태를 Prometheus 메트릭으로 노출합니다. 쿠버네티스 이벤트나 Node Ready만으로는 보이지 않는 GPU 내부 상태를 관측 체계 안으로 끌어오는 역할을 수행해요.</span></span></span></p><p id="3dba360d-33e3-806d-ad3b-c26aa84fc4b2" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">저희는 메트릭의 개수를 늘리는 것보다, 운영자가 답해야 하는 질문에 맞춰 필요한 지표를 묶는 데 집중했습니다.</span></span></span></p><div data-table-wrapper="true" class="css-1m7s5mg"><div role="table" class="css-uznrcw"><div role="row" class="css-1obf64m"><div role="cell" class="css-8vl804"><span class="css-1mjnzsq">운영 질문</span></div><div role="cell" class="css-8vl804"><span class="css-1mjnzsq">메트릭</span></div><div role="cell" class="css-8vl804"><span class="css-1mjnzsq">답변</span></div></div><div role="row" class="css-1obf64m"><div role="cell" class="css-8vl804"><span class="css-1mjnzsq">GPU가 실제로 연산 중인가?</span></div><div role="cell" class="css-8vl804"><code style="font-family:Consolas, Menlo, Monaco, source-code-pro, &quot;Courier New&quot;, monospace;font-size:0.9em;background-color:var(--adaptiveOpacity100);border:1px solid var(--adaptiveOpacity200);border-radius:3px"><span class="css-1mjnzsq">DCGM_FI_DEV_GPU_UTIL</span></code><span class="css-1mjnzsq">, </span><code style="font-family:Consolas, Menlo, Monaco, source-code-pro, &quot;Courier New&quot;, monospace;font-size:0.9em;background-color:var(--adaptiveOpacity100);border:1px solid var(--adaptiveOpacity200);border-radius:3px"><span class="css-1mjnzsq">DCGM_FI_PROF_SM_ACTIVE</span></code><span class="css-1mjnzsq">,
</span><code style="font-family:Consolas, Menlo, Monaco, source-code-pro, &quot;Courier New&quot;, monospace;font-size:0.9em;background-color:var(--adaptiveOpacity100);border:1px solid var(--adaptiveOpacity200);border-radius:3px"><span class="css-1mjnzsq">DCGM_FI_PROF_SM_OCCUPANCY</span></code></div><div role="cell" class="css-8vl804"><span class="css-1mjnzsq">GPU 단위 사용률과 MIG/서빙 워크로드의 실제 연산 상태</span></div></div><div role="row" class="css-1obf64m"><div role="cell" class="css-8vl804"><span class="css-1mjnzsq">VRAM이 얼마나 쓰이고 있는가?</span></div><div role="cell" class="css-8vl804"><code style="font-family:Consolas, Menlo, Monaco, source-code-pro, &quot;Courier New&quot;, monospace;font-size:0.9em;background-color:var(--adaptiveOpacity100);border:1px solid var(--adaptiveOpacity200);border-radius:3px"><span class="css-1mjnzsq">DCGM_FI_DEV_FB_USED</span></code><span class="css-1mjnzsq">, </span><code style="font-family:Consolas, Menlo, Monaco, source-code-pro, &quot;Courier New&quot;, monospace;font-size:0.9em;background-color:var(--adaptiveOpacity100);border:1px solid var(--adaptiveOpacity200);border-radius:3px"><span class="css-1mjnzsq">DCGM_FI_DEV_FB_FREE</span></code><span class="css-1mjnzsq">, </span><code style="font-family:Consolas, Menlo, Monaco, source-code-pro, &quot;Courier New&quot;, monospace;font-size:0.9em;background-color:var(--adaptiveOpacity100);border:1px solid var(--adaptiveOpacity200);border-radius:3px"><span class="css-1mjnzsq">DCGM_FI_DEV_FB_TOTAL</span></code></div><div role="cell" class="css-8vl804"><span class="css-1mjnzsq">쿠버네티스 표준 자원이 아닌 VRAM의 사용량</span></div></div><div role="row" class="css-1obf64m"><div role="cell" class="css-8vl804"><span class="css-1mjnzsq">GPU가 정상 상태인가?</span></div><div role="cell" class="css-8vl804"><code style="font-family:Consolas, Menlo, Monaco, source-code-pro, &quot;Courier New&quot;, monospace;font-size:0.9em;background-color:var(--adaptiveOpacity100);border:1px solid var(--adaptiveOpacity200);border-radius:3px"><span class="css-1mjnzsq">DCGM_EXP_GPU_HEALTH_STATUS</span></code></div><div role="cell" class="css-8vl804"><span class="css-1mjnzsq">DRIVER, MEM, NVLINK, PCIE, POWER, SM, THERMAL 상태</span></div></div></div></div><p id="3dba360d-33e3-802e-859f-d8e19da36a5e" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">각 메트릭이 답하는 질문은 서로 다릅니다. 예를 들어 </span><code style="font-family:Consolas, Menlo, Monaco, source-code-pro, &quot;Courier New&quot;, monospace;font-size:0.9em;background-color:var(--adaptiveOpacity100);border:1px solid var(--adaptiveOpacity200);border-radius:3px"><span class="css-1mjnzsq">DCGM_FI_DEV_GPU_UTIL</span></code><span class="css-1kxrhf3">은 GPU에서 작업이 실행되고 있는지를 보여 주지만, 서빙 워크로드가 연산 유닛을 얼마나 활용하는지 판단하려면 </span><code style="font-family:Consolas, Menlo, Monaco, source-code-pro, &quot;Courier New&quot;, monospace;font-size:0.9em;background-color:var(--adaptiveOpacity100);border:1px solid var(--adaptiveOpacity200);border-radius:3px"><span class="css-1mjnzsq">DCGM_FI_PROF_SM_ACTIVE</span></code><span class="css-1kxrhf3"> 같은 프로파일링 지표를 함께 봐야 했습니다.</span></span></span></p><figure id="3dba360d-33e3-8006-b7a5-c0eaf4033258" class="css-of5acw"><img src="https://static.toss.im/ipd-tcs/toss_core/live/f6012e90-c297-42f1-ab36-28d608e6c34a/image.png" alt="" class="css-1pgssrp"/><figcaption class="css-wgpbp3"><span class="css-jfs1hr">DCGM_EXP_GPU_HEALTH_STATUS</span><span class="css-jfs1hr">를 통해 GPU 상태 확인</span></figcaption></figure><p id="3dba360d-33e3-8004-a04c-df114d2f3ab2" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">이처럼 DCGM Exporter를 통해 운영 중인 GPU의 내부 상태를 파악할 수 있게 되었습니다.</span></span></span></p><p id="3dba360d-33e3-80a9-b9a1-dcf680eafa1c" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">하지만 GPU 상태를 볼 수 있다는 것과, 장애를 제때 알아차려 대응하는 것은 다른 문제였습니다. 마지막으로 GPU의 장애 신호를 실제 대응으로 연결해야 했습니다.</span></span></span></p><h3 id="3dba360d-33e3-80b7-8360-fc782b786992" class="css-1feg9au"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">Xid Error — 장애 신호를 알럿으로</span></span></span></h3><p id="3dba360d-33e3-8073-bb5b-e120a9e1dd1b" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-q3ktjb">Xid는 NVIDIA 드라이버가 GPU 관련 오류를 감지해 운영체제의 커널 로그에 남기는 오류 보고</span><span class="css-1kxrhf3">입니다. 문제는 이 신호가 </span><span class="css-q3ktjb">쿠버네티스 이벤트에서는 관측되지 않는다</span><span class="css-1kxrhf3">는 것인데요. </span><code style="font-family:Consolas, Menlo, Monaco, source-code-pro, &quot;Courier New&quot;, monospace;font-size:0.9em;background-color:var(--adaptiveOpacity100);border:1px solid var(--adaptiveOpacity200);border-radius:3px"><span class="css-1mjnzsq">Node Ready</span></code><span class="css-1kxrhf3">여도, </span><code style="font-family:Consolas, Menlo, Monaco, source-code-pro, &quot;Courier New&quot;, monospace;font-size:0.9em;background-color:var(--adaptiveOpacity100);border:1px solid var(--adaptiveOpacity200);border-radius:3px"><span class="css-1mjnzsq">Pod Running</span></code><span class="css-1kxrhf3">이어도, 특정 GPU에서는 이미 오류가 발생했을 수 있다는 겁니다.</span></span></span></p><figure id="3dba360d-33e3-80a4-86bf-d7a19a369065" class="css-of5acw"><img src="https://static.toss.im/ipd-tcs/toss_core/live/a58ff48f-a1e9-43fe-b0fc-c3e62de820f9/image.png" alt="" class="css-1pgssrp"/><figcaption class="css-wgpbp3"><span class="css-jfs1hr">NVIDIA Xid 오류 카탈로그</span></figcaption></figure><p id="3dba360d-33e3-8071-ac75-c93ae423ff0b" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">이는 실제 운영에서 중요한 차이점이었습니다. 서빙 워크로드가 GPU에 배치된 뒤 CUDA 오류나 NVML 오류로 실패하면, 쿠버네티스 이벤트만 봐서는 원인이 파드 문제인지, 노드 문제인지, 특정 GPU 디바이스 문제인지 알 수 없는 거죠. </span></span></span></p><p id="3dba360d-33e3-8030-bc3d-e37b18b42354" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">그렇다고 Xid가 보이면 곧바로 노드를 장애로 판단할 수 있는 것도 아닙니다. 예를 들어 </span><code style="font-family:Consolas, Menlo, Monaco, source-code-pro, &quot;Courier New&quot;, monospace;font-size:0.9em;background-color:var(--adaptiveOpacity100);border:1px solid var(--adaptiveOpacity200);border-radius:3px"><span class="css-1mjnzsq">Xid 31</span></code><span class="css-1kxrhf3">은 GPU 메모리 페이지 폴트를 나타냅니다. 주로 애플리케이션의 잘못된 메모리 접근으로 발생하지만, 드라이버나 하드웨어 문제일 가능성도 있습니다.</span></span></span></p><p id="3dba360d-33e3-80e9-9269-fb000dc15539" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">그래서 Xid가 발생했는지뿐 아니라, 어떤 워크로드에서 발생했는지, 같은 GPU에서 반복되는지, readiness 신호와 함께 봤을 때 워크로드를 계속 받아도 되는 상태인지를 함께 봐야 했습니다.</span></span></span></p><p id="3dba360d-33e3-80fa-8904-e05c33972773" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">저희는 이러한 판단을 실제 대응으로 연결하기 위해 Xid를 단순 로그가 아니라 노드·GPU·워크로드 정보를 함께 전달하는 알럿으로 구성했습니다.</span></span></span></p><figure id="3dba360d-33e3-80d4-a2f1-f230cfaf7232" class="css-of5acw"><img src="https://static.toss.im/ipd-tcs/toss_core/live/e0ef0652-3330-40c4-b8a9-2e461d5f930d/gpu-xid-alert-31.png" alt="" class="css-1pgssrp"/><figcaption class="css-wgpbp3"><span class="css-jfs1hr">Xid 발생 시 노드·GPU·워크로드 정보를 함께 수신</span></figcaption></figure><p id="3dba360d-33e3-8071-bed2-f2c2c0e93c96" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">실제로 운영 중인 H100 GPU 한 장에서 HBM의 복구 불가능한 메모리 오류인 DBE(Double Bit Error)가 발생한 적이 있었습니다. 해당 GPU를 사용하던 vLLM 엔진이 두 차례 종료됐고, Xid 알럿과 GPU readiness 신호도 같은 시간대에 변했습니다. 워크로드는 다른 노드로 재스케줄돼 서비스는 계속됐지만, 파드가 종료됐다는 사실만으로는 애플리케이션 문제인지 GPU 하드웨어 문제인지 구분하기 어려웠습니다.</span></span></span></p><figure id="3dba360d-33e3-803d-8bff-fe7ff83d8a54" class="css-of5acw"><img src="https://static.toss.im/ipd-tcs/toss_core/live/ebf37487-455d-47c4-a9bb-687ab2f64931/gpu-xid-alert-94.png" alt="" class="css-1pgssrp"/><figcaption class="css-wgpbp3"><span class="css-qj4ldc">H100 HBM DBE 발생 당시 감지된 Xid 알럿 - 동일 GPU에서 오류가 반복됐고, 해당 GPU를 사용하던 서빙 프로세스가 함께 종료됐습니다.</span></figcaption></figure><p id="3dba360d-33e3-803d-ae44-ff80d04292c0" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">저희는 이미 Xid 기반으로 가시성을 구성해 놓았기 때문에, 이때 발생한 신호를 하나의 장애 흐름으로 읽을 수 있었습니다. HBM에서 복구 불가능한 DBE가 발생했고(</span><code style="font-family:Consolas, Menlo, Monaco, source-code-pro, &quot;Courier New&quot;, monospace;font-size:0.9em;background-color:var(--adaptiveOpacity100);border:1px solid var(--adaptiveOpacity200);border-radius:3px"><span class="css-1mjnzsq">Xid 48·171</span></code><span class="css-1kxrhf3">), 오류가 GPU 전체에 확산되지 않고 해당 vLLM 워크로드에 격리됐습니다(</span><code style="font-family:Consolas, Menlo, Monaco, source-code-pro, &quot;Courier New&quot;, monospace;font-size:0.9em;background-color:var(--adaptiveOpacity100);border:1px solid var(--adaptiveOpacity200);border-radius:3px"><span class="css-1mjnzsq">Xid 94</span></code><span class="css-1kxrhf3">). 이후 row remapping이 기록됐고(</span><code style="font-family:Consolas, Menlo, Monaco, source-code-pro, &quot;Courier New&quot;, monospace;font-size:0.9em;background-color:var(--adaptiveOpacity100);border:1px solid var(--adaptiveOpacity200);border-radius:3px"><span class="css-1mjnzsq">Xid 63</span></code><span class="css-1kxrhf3">), 드라이버는 GPU를 비우고 리셋해야 한다는 복구 동작을 요구했습니다(</span><code style="font-family:Consolas, Menlo, Monaco, source-code-pro, &quot;Courier New&quot;, monospace;font-size:0.9em;background-color:var(--adaptiveOpacity100);border:1px solid var(--adaptiveOpacity200);border-radius:3px"><span class="css-1mjnzsq">Xid 154</span></code><span class="css-1kxrhf3">).</span></span></span></p><p id="3dba360d-33e3-8041-9378-e54132c4d118" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">Xid를 단순 오류 카운트가 아니라 </span><span class="css-q3ktjb">격리와 복구, 재투입 여부를 결정하는 운영 신호</span><span class="css-1kxrhf3">로 사용한 사례였습니다.</span></span></span></p><p id="3dba360d-33e3-8043-8525-f40e190f7e51" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"></span></span></p><h3 id="3dba360d-33e3-8041-9ca9-e05fd7ab8160" class="css-1feg9au"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">요약하자면</span></span></span></h3><p id="3dba360d-33e3-80b5-ba45-e77cee856f8c" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-q3ktjb">쿠버네티스의 Node Ready와 GPU Ready는 다릅니다.</span><span class="css-1kxrhf3"> GPU를 쿠버네티스 자원으로 등록한 뒤에도 운영자는 아래 세 가지를 확인할 수 있어야 합니다.</span></span></span></p><aside id="3dba360d-33e3-80aa-98fb-fe4983191fe6" class="css-nv7vyi"><div class="css-1vn47db"><ul class="css-uswsmm"><li id="3dba360d-33e3-8024-9cf8-da0497ebd1a2" class="css-1hwiibq"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-q3ktjb">GPU 워크로드 실행에 필요한 스택 검증</span><span class="css-1kxrhf3"> → </span><code style="font-family:Consolas, Menlo, Monaco, source-code-pro, &quot;Courier New&quot;, monospace;font-size:0.9em;background-color:var(--adaptiveOpacity100);border:1px solid var(--adaptiveOpacity200);border-radius:3px"><span class="css-1mjnzsq">nvidia-validator</span></code><span class="css-1kxrhf3">로 드라이버, Container Toolkit, Device Plugin, CUDA 상태를 검증합니다.</span></span></span></li><li id="3dba360d-33e3-8081-ac37-d9f49a8a07f9" class="css-1hwiibq"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-q3ktjb">운영 중인 GPU 상태 관측</span><span class="css-1kxrhf3"> → DCGM Exporter로 사용률, VRAM, 온도, 전력, 오류 신호를 상시 관측합니다.</span></span></span></li><li id="3dba360d-33e3-8019-b2c5-e3d50cee6a2d" class="css-1hwiibq"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-q3ktjb">GPU 장애 신호의 감지와 대응 연결</span><span class="css-1kxrhf3"> → Xid 계열 지표를 알럿과 readiness 판단에 연결합니다.</span></span></span></li></ul></div></aside><p id="3dba360d-33e3-80cd-b232-d026e9516058" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">이 세 가지를 확인할 수 있게 되면서 </span><span class="css-q3ktjb">Node Ready와 GPU Ready를 구분해 운영</span><span class="css-1kxrhf3">할 수 있게 되었습니다.</span></span></span></p><p id="3dba360d-33e3-80cf-b85d-e0d45fd6171a" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">여기까지가 GPU 상태를 관측하고, 장애 신호를 운영 판단으로 연결하는 과정이었습니다. 그러나 GPU의 상태를 판단할 수 있게 된 것과, 신규 또는 복구된 GPU 노드를 실제 서비스에 투입해도 된다고 판단하는 것은 또 다른 문제였습니다.</span></span></span></p><p id="3dba360d-33e3-8081-8150-c0ed6b114481" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"></span></span></p><hr class="css-1ifza5r"/><h3 id="3dba360d-33e3-8047-90da-e87c6b0409d3" class="css-1feg9au"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">2. 검증된 GPU만 서비스에 투입하기</span></span></span></h3><p id="3dba360d-33e3-809a-a37d-ece9ee1cf0eb" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">토스증권은 Blackwell 시리즈를 도입하면서 앞에서 정리한 GPU 상태 판단 기준을 신규 장비의 투입 과정에도 적용해야 했습니다.</span></span></span></p><p id="3dba360d-33e3-80aa-a05a-d5857cb8dfd6" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">새로운 GPU 아키텍처가 들어오면 하드웨어만 바뀌는 것이 아닌데요. 드라이버, CUDA, NVIDIA Device Plugin, GPU Operator, DCGM, MIG 구성, 실제 서빙 워크로드까지 모두 다시 맞춰 봐야 합니다. 그래서 저희는 운영 중 정립한 판단 기준을 신규 GPU 노드의 투입 전 검증 절차로 묶고, 이를 </span><span class="css-q3ktjb">GPU 시범운행(preflight)</span><span class="css-1kxrhf3">이라는 절차로 구현했습니다.</span></span></span></p><p id="3dba360d-33e3-8070-b6e6-db5858f77f7d" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-q3ktjb">GPU 시범운행</span><span class="css-1kxrhf3">은 GPU 자체의 동작 여부만 확인하는 데 그치지 않습니다. 표준 스펙, GPU 스택, 하드웨어, 쿠버네티스 편입 상태, 실제 CUDA와 모델 서빙을 차례로 검증해, </span><span class="css-q3ktjb">이 장비가 토스증권의 서비스 워크로드를 받을 수 있는 상태인지 판단하는 최종 투입 기준</span><span class="css-1kxrhf3">입니다.</span></span></span></p><p id="3dba360d-33e3-8009-9202-c15c7a479709" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"></span></span></p><p id="3dba360d-33e3-8004-a824-f48efc491a85" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">검증은 다음과 같은 순서로 진행됩니다.</span></span></span></p><aside id="3dba360d-33e3-80b5-91fc-ec470b6f5746" class="css-nv7vyi"><div class="css-1vn47db"><ol class="css-hokoge"><li id="3dba360d-33e3-8006-bcef-d799d2592c07" class="css-1hwiibq"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-q3ktjb">표준 스펙 정합성:</span><span class="css-1kxrhf3"> OS·커널·드라이버·CUDA 버전부터 커널 모듈, sysctl 튜닝값까지 수십 개 항목을 저희가 정의해 둔 표준 매트릭스와 하나하나 대조합니다.</span></span></span></li><li id="3dba360d-33e3-8020-a7c2-c55cd3b529aa" class="css-1hwiibq"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-q3ktjb">하드웨어 검증:</span><span class="css-1kxrhf3"> </span><code style="font-family:Consolas, Menlo, Monaco, source-code-pro, &quot;Courier New&quot;, monospace;font-size:0.9em;background-color:var(--adaptiveOpacity100);border:1px solid var(--adaptiveOpacity200);border-radius:3px"><span class="css-1mjnzsq">dcgmi diag</span></code><span class="css-1kxrhf3">로 GPU에 실제 부하를 가해 burn-in 테스트를 수행하고, NVLink 대역폭 같은 물리 성능도 실측해 확인합니다.</span></span></span></li><li id="3dba360d-33e3-80ad-ab76-d71179f2d1b4" class="css-1hwiibq"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-q3ktjb">쿠버네티스 편입 상태:</span><span class="css-1kxrhf3"> NVIDIA Device Plugin이 GPU를 자원으로 노출하고, GPU Feature Discovery(GFD)가 GPU 모델·MIG 가능 여부 같은 노드 특성을 라벨로 드러내는지 확인합니다.</span></span></span></li><li id="3dba360d-33e3-803b-b5b5-d07939dce9d4" class="css-1hwiibq"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-q3ktjb">CUDA 런타임 검증:</span><span class="css-1kxrhf3"> CUDA 커널을 실제로 실행하고, 연산 결과가 정상인지 확인합니다.</span></span></span></li><li id="3dba360d-33e3-80da-997c-cfe733b12fbd" class="css-1hwiibq"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-q3ktjb">서빙 워크로드 검증:</span><span class="css-1kxrhf3"> 마지막으로 아주 작은 LLM을 잠깐 띄워 모델이 로드되고 토큰이 실제로 출력되는지 확인합니다.</span></span></span></li></ol></div></aside><p id="3dba360d-33e3-80df-a4f8-f729c5e6b68c" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"></span></span></p><figure id="3dba360d-33e3-8003-970a-ca04a2837892" class="css-of5acw"><img src="https://static.toss.im/ipd-tcs/toss_core/live/6ff79ccb-830e-42bf-9406-822cab49c741/gpu-preflight-validation-rail-toss.png" alt="" class="css-1pgssrp"/><figcaption class="css-wgpbp3"><span class="css-jfs1hr">시범운행 검증 흐름</span></figcaption></figure><p id="3dba360d-33e3-8018-8c41-c82072fb2d02" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><code style="font-family:Consolas, Menlo, Monaco, source-code-pro, &quot;Courier New&quot;, monospace;font-size:0.9em;background-color:var(--adaptiveOpacity100);border:1px solid var(--adaptiveOpacity200);border-radius:3px"><span class="css-1mjnzsq">&quot;투자의 힘을 모두에게&quot;</span></code><span class="css-1kxrhf3">라는 토스증권 미션을 담은 프롬프트에 새 GPU가 다음 토큰을 이어서 출력하면 최종 검증이 통과됩니다. 드라이버가 로드되는 것만으로 실제 서비스 워크로드가 동작한다고 볼 수 없기 때문에, CUDA 커널과 작은 LLM 서빙까지 통과 조건으로 삼았습니다.</span></span></span></p><p id="3dba360d-33e3-80d9-8265-eeb693d24ad0" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">각 관문의 결과는 메트릭으로 수집되며, 모든 검증을 통과한 노드에만 </span><code style="font-family:Consolas, Menlo, Monaco, source-code-pro, &quot;Courier New&quot;, monospace;font-size:0.9em;background-color:var(--adaptiveOpacity100);border:1px solid var(--adaptiveOpacity200);border-radius:3px"><span class="css-1mjnzsq">preflight=passed</span></code><span class="css-1kxrhf3"> 라벨이 붙습니다.</span></span></span></p><figure id="3dba360d-33e3-8076-bc0e-da6a75fb0e83" class="css-of5acw"><img src="https://static.toss.im/ipd-tcs/toss_core/live/844daaf3-7e3d-4ebc-98ff-9c7837bc5102/gpu-preflight-result-terminal.png" alt="" class="css-1pgssrp"/><figcaption class="css-wgpbp3"><span class="css-jfs1hr">GPU 시범운행 검증 결과 매트릭스</span></figcaption></figure><figure id="3dba360d-33e3-80ab-a66c-cfecd9b30328" class="css-of5acw"><img src="https://static.toss.im/ipd-tcs/toss_core/live/ba31fdae-09aa-46b2-ad38-dd0400944f1d/gpu-preflight-labels-terminal.png" alt="" class="css-1pgssrp"/><figcaption class="css-wgpbp3"><span class="css-jfs1hr">GPU 시범운행 통과 이후 노드에 적용된 라벨</span></figcaption></figure><p id="3dba360d-33e3-80a2-849e-e26b4fb9a396" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">저희는 이 라벨을 서비스 스케줄링 게이트로 사용합니다. 검증을 통과한 노드는 cordon을 해제해 워크로드를 받을 수 있게 하고, 하나라도 실패한 노드는 cordon 상태로 유지합니다.</span></span></span></p><p id="3dba360d-33e3-8033-8b57-cebecb595c89" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">신규 GPU 노드와 하드웨어 점검 후 복귀한 노드는 모든 관문을 통과해야만 스케줄 대상이 됩니다.</span></span></span></p><p id="3dba360d-33e3-80c4-845e-eecb0a5d4636" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">이렇게 노드 투입 기준을 세운 뒤, 마지막으로 남은 문제는 한정된 GPU를 서비스가 필요로 하는 크기에 맞춰 어떻게 효율적으로 제공할 것인가였습니다.</span></span></span></p><p id="3dba360d-33e3-80dc-bc3b-f9310a59124a" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"></span></span></p><hr class="css-1ifza5r"/><h3 id="3dba360d-33e3-80e6-af6e-de049f7aa291" class="css-1feg9au"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">3. GPU를 서비스에 맞는 단위로 제공하기</span></span></span></h3><p id="3dba360d-33e3-80d0-a2fb-f886f764a29d" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">토스증권은 GPU를 서비스에 필요한 단위로 제공하기 위해 MIG(Multi-Instance GPU)를 선택했습니다. 이 이야기는 이전에 GPU를 밀도 있게 쓰는 방법 — 토스증권의 GPU 가상화(MIG) 도입기</span><a target="_blank" rel="noreferrer noopener" class="css-iynyr0" href="https://toss.tech/article/toss-securities-gpu-mig">[5]</a><span class="css-1kxrhf3">에서 자세히 다뤘습니다. MIG는 GPU를 하드웨어 수준에서 여러 개의 독립 인스턴스로 쪼개, 조각 하나하나가 연산 유닛과 메모리를 물리적으로 격리한 채 별도의 GPU처럼 동작할 수 있게 합니다.</span></span></span></p><p id="3dba360d-33e3-80e2-998f-c9c035e1a1fe" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">하드웨어 기반의 가상화인 MIG에는 여러 장점이 있지만, 문제도 있는데요. MIG 인스턴스를 수동으로 관리하기 위해 노드마다 접속해 필요한 크기의 인스턴스를 직접 만들고, 구성이 바뀔 때마다 다시 설정해야 한다는 운영 비용입니다. 노드가 늘어날수록 이 비용은 같이 증가하고요.</span></span></span></p><p id="3dba360d-33e3-80a4-97cf-d88d155426e8" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"></span></span></p><p id="3dba360d-33e3-804e-a837-d291842e18da" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">이러한 문제를 피하기 위해 </span><span class="css-q3ktjb">MIG Manager</span><span class="css-1kxrhf3">를 사용했습니다. 원하는 MIG 구성을 ConfigMap에 정의하고, 노드 라벨로 적용할 구성을 선택하면, 각 노드의 GPU가 선언한 구성에 맞게 자동으로 설정됩니다. 노드마다 접속해 MIG 인스턴스를 수동으로 구성하던 부담을 줄일 수 있었습니다.</span></span></span></p><p id="3dba360d-33e3-808b-83a8-e017b92941cc" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">하지만 GPU를 원하는 크기로 쪼개는 것과, 만들어진 조각을 효율적으로 배치하는 것은 또 다른 문제였는데요. MIG Manager는 MIG 구성을 만들고, 워크로드에 어떤 인스턴스를 할당할지는 NVIDIA Device Plugin이 결정합니다.</span></span></span></p><figure id="3dba360d-33e3-8018-9eb7-ef25cb4319ad" class="css-of5acw"><img src="https://static.toss.im/ipd-tcs/toss_core/live/6565b31d-10ca-48f8-aa5c-1d0c50aff4ae/mig-role-boundary-proposal-v2.png" alt="" class="css-1pgssrp"/><figcaption class="css-wgpbp3"><span class="css-qj4ldc">MIG Manager와 Device Plugin의 역할 분리</span></figcaption></figure><p id="3dba360d-33e3-8040-bedc-d258972c5e51" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"></span></span></p><p id="3dba360d-33e3-8063-a536-c80fcb1dd136" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">실제 Device Plugin의 </span><code style="font-family:Consolas, Menlo, Monaco, source-code-pro, &quot;Courier New&quot;, monospace;font-size:0.9em;background-color:var(--adaptiveOpacity100);border:1px solid var(--adaptiveOpacity200);border-radius:3px"><span class="css-1mjnzsq">getPreferredAllocation</span></code><span class="css-1kxrhf3"> 함수에서도 사용 가능한 디바이스와 요청 수량을 받아, 설정된 정책에 따라 할당할 디바이스를 선택하는 흐름을 확인할 수 있습니다.</span></span></span></p><p id="3dba360d-33e3-80b8-83f9-c99979cad90a" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">서비스의 자원 요구 사항에 따라 Full GPU와 여러 MIG 프로파일 사이를 유연하게 전환하려면, 사용 중인 MIG 인스턴스가 어느 물리 GPU에 배치되는지도 중요했습니다. 하지만 Device Plugin의 기본 정책인 Distributed Allocation은 MIG 조각을 여러 물리 GPU에 골고루 배치합니다.</span></span></span></p><p id="3dba360d-33e3-8084-8b7a-e92a57ae6fd9" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">그 결과, 사용하는 MIG 조각이 몇 개뿐이어도 여러 GPU가 부분적으로 점유됐습니다. Full GPU를 확보하거나 MIG 프로파일을 변경하려면 여러 GPU를 함께 비워야 했고, 원하는 구성으로 유연하게 전환하기 어려웠습니다.</span></span></span></p><figure id="3dba360d-33e3-80a6-a319-fa0a67e4a56b" class="css-of5acw"><img src="https://static.toss.im/ipd-tcs/toss_core/live/51260fd2-200d-4a46-b6a0-00f5d01756eb/mig-allocation-policies.png" alt="" class="css-1pgssrp"/><figcaption class="css-wgpbp3"><span class="css-qj4ldc">Distributed Allocation과 Packed Allocation 비교</span></figcaption></figure><p id="3dba360d-33e3-803d-ba55-ff7c7f20f1b2" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">그래서 MIG 인스턴스를 한 물리 GPU부터 채워, 완전히 비어 있는 GPU를 남기는 Packed Allocation이 필요했습니다.</span></span></span></p><p id="3dba360d-33e3-8022-a748-fe7252310760" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">이를 위해 사용자가 GPU 디바이스의 배치 정책을 선택할 수 있도록 </span><code style="font-family:Consolas, Menlo, Monaco, source-code-pro, &quot;Courier New&quot;, monospace;font-size:0.9em;background-color:var(--adaptiveOpacity100);border:1px solid var(--adaptiveOpacity200);border-radius:3px"><span class="css-1mjnzsq">allocationPolicy</span></code><span class="css-1kxrhf3"> 개념과 Packed Allocation을 구현해 NVIDIA k8s-device-plugin에 제안했습니다.</span><a target="_blank" rel="noreferrer noopener" class="css-iynyr0" href="https://github.com/NVIDIA/k8s-device-plugin/pull/1621">[1]</a><span class="css-1kxrhf3"> 이 변경은 업스트림에 머지됐으며, v0.20.0부터 </span><code style="font-family:Consolas, Menlo, Monaco, source-code-pro, &quot;Courier New&quot;, monospace;font-size:0.9em;background-color:var(--adaptiveOpacity100);border:1px solid var(--adaptiveOpacity200);border-radius:3px"><span class="css-1mjnzsq">sharedDevicesAllocationPolicy</span></code><span class="css-1kxrhf3"> 설정(</span><code style="font-family:Consolas, Menlo, Monaco, source-code-pro, &quot;Courier New&quot;, monospace;font-size:0.9em;background-color:var(--adaptiveOpacity100);border:1px solid var(--adaptiveOpacity200);border-radius:3px"><span class="css-1mjnzsq">packed</span></code><span class="css-1kxrhf3">/</span><code style="font-family:Consolas, Menlo, Monaco, source-code-pro, &quot;Courier New&quot;, monospace;font-size:0.9em;background-color:var(--adaptiveOpacity100);border:1px solid var(--adaptiveOpacity200);border-radius:3px"><span class="css-1mjnzsq">distributed</span></code><span class="css-1kxrhf3">)으로 제공됩니다.</span></span></span></p><p id="3dba360d-33e3-805d-9316-d8710ee15265" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"></span></span></p><h2 id="3dba360d-33e3-800c-9129-c2dac65f7ee5" class="css-1c1qox8"><span class="css-1r3ko7u"><span class="css-p4abj2"><span class="css-1kxrhf3">마치며 — GPU를 쿠버네티스 운영 체계 안으로</span></span></span></h2><p id="3dba360d-33e3-8084-8716-de15a7f8a36c" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">GPU를 인식하고 할당할 수 있는 GPU-aware한 클러스터를 만드는 일은 시작이었습니다. 안정적이고 효율적으로 운영하려면 GPU가 정상인지 판단하고, 검증된 노드만 서비스에 투입하며, 워크로드에 맞게 자원을 나누고 배치할 수 있어야 했습니다.</span></span></span></p><p id="3dba360d-33e3-8057-9c34-f6537d1e83de" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">저희는 GPU 스택 검증과 DCGM·Xid 관측으로 </span><span class="css-q3ktjb">Node Ready와 GPU Ready를 구분</span><span class="css-1kxrhf3">했고, </span><span class="css-q3ktjb">GPU 시범운행</span><span class="css-1kxrhf3">이라는 절차로 </span><span class="css-q3ktjb">서비스 투입 기준</span><span class="css-1kxrhf3">을 세웠습니다. MIG로 </span><span class="css-q3ktjb">서비스에 필요한 단위의 GPU를 제공</span><span class="css-1kxrhf3">하고, Packed Allocation을 구현해 업스트림에 기여하며 배치 방식도 운영 요구에 맞게 보완했습니다. 이렇게 GPU-aware한 클러스터에 운영 조건을 하나씩 갖춰 가는 과정이 저희가 말하는 GPU-native로의 전환이었습니다.</span></span></span></p><p id="3dba360d-33e3-8001-8ec2-c8f0ffea5172" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">이 과정에서는 쿠버네티스 운영 경험과 GPU·모델 서빙에 대한 이해가 함께 필요했습니다. Data Infra 팀과 ML Platform 팀이 각자의 경험을 바탕으로 문제를 함께 풀어 갈 수 있었던 이유이기도 합니다. 이 글이 쿠버네티스에서 GPU를 운영하는 분들께, 현재 클러스터에 어떤 운영 기준이 더 필요한지 점검하는 데 도움이 되었으면 합니다.</span></span></span></p><p id="3dba360d-33e3-808d-994d-ed91186a8750" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"></span></span></p><p id="3dba360d-33e3-801b-b76a-ec88bff58d1d" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"></span></span></p><h3 id="3dba360d-33e3-80f6-a93b-fd2187414206" class="css-1feg9au"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">참고자료</span></span></span></h3><aside id="3dba360d-33e3-8086-b72c-ca78d9d89715" class="css-nv7vyi"><h4 class="css-123co55"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">[1] </span><a target="_blank" rel="noreferrer noopener" class="css-iynyr0" href="https://github.com/NVIDIA/k8s-device-plugin/pull/1621">NVIDIA k8s-device-plugin — Packed Allocation 제안 및 업스트림 기여 (PR #1621)</a></span></span></h4><div class="css-1vn47db"><p id="3dba360d-33e3-808c-8d07-ee811370afb6" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">[2] </span><a target="_blank" rel="noreferrer noopener" class="css-iynyr0" href="https://kubernetes.io/docs/concepts/scheduling-eviction/dynamic-resource-allocation/">Kubernetes 문서 — Dynamic Resource Allocation (DRA)</a></span></span></p><p id="3dba360d-33e3-80db-84cd-c1c0e3b070d7" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">[3] </span><a target="_blank" rel="noreferrer noopener" class="css-iynyr0" href="https://docs.nvidia.com/datacenter/cloud-native/container-toolkit/latest/_images/runtime-architecture.png">NVIDIA Container Toolkit — Container Stack Architecture Overview</a></span></span></p><p id="3dba360d-33e3-8043-a393-ef4c0760b167" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">[4] </span><a target="_blank" rel="noreferrer noopener" class="css-iynyr0" href="https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/#consuming-extended-resources">Kubernetes 문서 — 확장 리소스 요청 규칙</a></span></span></p><p id="3dba360d-33e3-8053-af02-ee9dab090188" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">[5] </span><a target="_blank" rel="noreferrer noopener" class="css-iynyr0" href="https://toss.tech/article/toss-securities-gpu-mig">토스 기술 블로그 — GPU를 밀도 있게 쓰는 방법: 토스증권의 GPU 가상화(MIG) 도입기</a><span class="css-1kxrhf3">
[6] </span><a target="_blank" rel="noreferrer noopener" class="css-iynyr0" href="https://github.com/NVIDIA/k8s-device-plugin/blob/v0.20.0/internal/rm/nvml_manager.go#L110-L124">NVIDIA k8s-device-plugin v0.20.0 — getPreferredAllocation</a></span></span></p></div></aside><p id="3dba360d-33e3-804e-b8d1-d4006f1c7ed2" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"></span></span></p></div><div id="tds-mobile-portal-container"></div>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[App Router의 장점은 우리에게도 장점일까요?]]></title>
            <link>https://toss.tech/article/52999</link>
            <guid isPermaLink="true">https://toss.tech/article/52999</guid>
            <pubDate>Wed, 09 Sep 2026 06:45:00 GMT</pubDate>
            <description><![CDATA[App Router가 토스뱅크에도 좋은 선택일지, 직접 측정한 결과와 도입 여부를 판단한 기준을 공유해요.]]></description>
            <content:encoded><![CDATA[<link rel="preload" as="image" href="https://static.toss.im/ipd-tcs/toss_core/live/d5f55f7a-b783-4bc3-8a30-07e2518b3f5f/실서비스-비교-라벨.gif"/><link rel="preload" as="image" href="https://static.toss.im/ipd-tcs/toss_core/live/65e32491-3b15-40f3-b522-a8e4372160b6/이미지3-스켈레톤높이.gif"/><div class="css-1vn47db"><p id="3d5a360d-33e3-80cd-8cc1-e901d449b9ac" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">안녕하세요, 토스뱅크 프론트엔드 개발자 이다용입니다.</span></span></span></p><p id="3d5a360d-33e3-805b-bc47-f442bf71dcad" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">토스뱅크 프론트엔드에서는 Next.js Pages Router를 주로 사용해요. 그런데 Pages Router는 유지보수 모드예요. 보안 패치와 버그 수정은 이어지지만 새 기능은 들어오지 않죠. 언젠가 다음에 쓸 것을 정해야 했고, 가장 먼저 살펴본 선택지가 App Router와 React Server Components(RSC)였어요. 필요한 데이터를 컴포넌트마다 가져오고, 준비된 영역부터 보여준다는 점이 매력적이었거든요.</span></span></span></p><p id="3d5a360d-33e3-8071-a5d8-d6f00036bb60" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">2025년 가을, 프레임워크에 관심 있는 동료들이 모여 기술그룹을 만들었어요. 현구님, 지우님, 현웅님과 실제 서비스를 옮겨보고 매주 막히는 지점을 공유했어요.</span></span></span></p><p id="3d5a360d-33e3-80f3-b356-e84fe3249355" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">결론은 도입하지 않는 것이었어요. 저희 화면에서 얻을 수 있는 이득에 비해, 서비스의 안정성을 지키며 전환하고 유지하는 비용이 컸거든요.</span></span></span></p><p id="3d5a360d-33e3-8039-8984-c7afa396a343" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">어떻게 그 결론에 닿았는지, 검토 과정에서 마주한 질문들을 따라 이야기해볼게요.</span></span></span></p><p id="3d5a360d-33e3-80d6-adde-d25540d10030" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"></span></span></p><h2 id="3d5a360d-33e3-800f-818c-ff4fc1b8efdd" class="css-1c1qox8"><span class="css-1r3ko7u"><span class="css-p4abj2"><span class="css-1kxrhf3">준비된 것부터 보여주면 더 좋을까요?</span></span></span></h2><p id="3d5a360d-33e3-8077-ba34-ee85a38afd5e" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">기존에는 서버에서 화면을 그릴 때 필요한 데이터를 </span><code style="font-family:Consolas, Menlo, Monaco, source-code-pro, &quot;Courier New&quot;, monospace;font-size:0.9em;background-color:var(--adaptiveOpacity100);border:1px solid var(--adaptiveOpacity200);border-radius:3px"><span class="css-1mjnzsq">getServerSideProps</span></code><span class="css-1kxrhf3"> 한 곳에서 모아 가져왔어요. 페이지 단위로만 쓸 수 있다 보니, 저희 구현에서는 API 하나가 실패하면 정상적으로 받은 데이터까지 다시 요청하는 경우가 있었어요.</span></span></span></p><p id="3d5a360d-33e3-80ad-857c-eb1d873bbe72" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">App Router에서는 데이터를 가져오는 컴포넌트를 나누고 각각을 </span><code style="font-family:Consolas, Menlo, Monaco, source-code-pro, &quot;Courier New&quot;, monospace;font-size:0.9em;background-color:var(--adaptiveOpacity100);border:1px solid var(--adaptiveOpacity200);border-radius:3px"><span class="css-1mjnzsq">Suspense</span></code><span class="css-1kxrhf3">로 감싸, 준비된 영역부터 보여줄 수 있어요.</span></span></span></p><p id="3d5a360d-33e3-8086-aa46-e608fae01814" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">토스뱅크의 화면은 대부분 토스 앱 안의 웹뷰로 이뤄져 있고, 요소가 주로 세로 한 줄로 쌓여요. 이 구조에서도 영역을 따로 보여주는 것이 자연스러운지 직접 재봤어요.</span></span></span></p><p id="3d5a360d-33e3-8089-bd65-d068b880e7a2" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">세 영역에 각각 1초, 3초, 5초 지연을 주고, 브라우저가 화면을 언제 그리는지 녹화했어요. 웹뷰는 대부분 안드로이드에서 Chromium, iOS에서 WebKit으로 돌아가기 때문에 두 엔진을 함께 봤고요.</span></span></span></p><p id="3d5a360d-33e3-80c9-aaff-de51fa987860" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"></span></span></p><figure id="3d5a360d-33e3-8033-9d06-d251c8344cf1" class="css-of5acw"><img src="https://static.toss.im/ipd-tcs/toss_core/live/d5f55f7a-b783-4bc3-8a30-07e2518b3f5f/실서비스-비교-라벨.gif" alt="" class="css-1pgssrp"/></figure><p id="3d5a360d-33e3-8000-80c0-ca2b989501e8" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"></span></span></p><p id="3d5a360d-33e3-8095-9f0a-e9cad36b52cd" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">첫 콘텐츠가 그려지는 시점(FCP)이 Chromium에서는 64밀리초, WebKit에서는 5,065밀리초였어요. WebKit은 마지막 섹션이 준비될 때까지 첫 표시를 미뤘어요. 화면을 그리기 전에 콘텐츠가 충분한지 판단하는 기준이 있어서인데, 이 때문에 표시가 늦어지는 현상은 </span><a target="_blank" rel="noreferrer noopener" class="css-iynyr0" href="https://bugs.webkit.org/show_bug.cgi?id=252413">공개 이슈</a><span class="css-1kxrhf3">에서도 논의되고 있어요.</span></span></span></p><p id="3d5a360d-33e3-80cf-9190-c6095f7ec55e" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">우회는 가능했어요. 화면 위에 눈에 보이지 않는 SVG를 넣자 WebKit의 FCP가 103밀리초로 줄었어요. 두 엔진 모두 준비된 영역부터 그리기 시작한 거예요.</span></span></span></p><p id="3d5a360d-33e3-80d8-867f-f85b01a20790" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">그런데 먼저 그리기 시작하니 다음 문제가 보였어요. 스켈레톤이 실제 콘텐츠로 바뀔 때 높이가 달라지거든요. 세로로 쌓인 화면에서는 위쪽 영역의 높이가 늘어나면, 이미 보고 있던 아래쪽 내용까지 그만큼 아래로 밀렸어요.</span></span></span></p><p id="3d5a360d-33e3-8066-93e7-dac08c9c265e" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"></span></span></p><figure id="3d5a360d-33e3-80e4-99e1-fd4e4df0c415" class="css-of5acw"><img src="https://static.toss.im/ipd-tcs/toss_core/live/65e32491-3b15-40f3-b522-a8e4372160b6/이미지3-스켈레톤높이.gif" alt="" class="css-1pgssrp"/></figure><p id="3d5a360d-33e3-80e0-8938-eac258a29321" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"></span></span></p><p id="3d5a360d-33e3-80a6-94fb-d9ca6701ede2" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">스켈레톤 높이를 콘텐츠가 들어갈 높이와 최대한 비슷하게 맞추자 움직임은 줄었어요. 레이아웃 이동값도 0.138에서 0.036으로 내려갔고요. 다만 섹션을 추가할 때마다 로딩 화면을 만들고, 콘텐츠가 바뀔 때마다 다시 맞춰야 했어요. 영역을 잘게 나눠 먼저 보여주는 이득보다, 화면이 흔들리지 않도록 관리하는 부담이 더 컸어요.</span></span></span></p><p id="3d5a360d-33e3-8074-b18f-ece0cd48edd2" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"></span></span></p><h2 id="3d5a360d-33e3-8052-b1f5-fa18b4e0f197" class="css-1c1qox8"><span class="css-1r3ko7u"><span class="css-p4abj2"><span class="css-1kxrhf3">RSC를 우리 코드에서 얼마나 활용할 수 있을까요?</span></span></span></h2><p id="3d5a360d-33e3-80ea-9dbb-d44cb28cc978" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">스트리밍을 넓게 쓰기는 어렵겠다고 봤지만, 그것만으로 App Router를 접을 수는 없었어요. 스트리밍을 쓰지 않더라도 RSC에는 따로 기대할 것이 있었거든요.</span></span></span></p><p id="3d5a360d-33e3-80b4-9d9a-fe8d7d44d4e8" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">가장 큰 건 번들 크기예요. Server Component의 구현과 거기서만 쓰는 의존성은 클라이언트 번들에서 아예 빠지거든요.</span></span></span></p><p id="3d5a360d-33e3-8077-b841-f131c4ab88c3" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">부차적으로는 데이터를 가져오는 코드를 정리할 수 있었어요. 기존에는 인증 정보를 </span><code style="font-family:Consolas, Menlo, Monaco, source-code-pro, &quot;Courier New&quot;, monospace;font-size:0.9em;background-color:var(--adaptiveOpacity100);border:1px solid var(--adaptiveOpacity200);border-radius:3px"><span class="css-1mjnzsq">getServerSideProps</span></code><span class="css-1kxrhf3">에서 꺼내 서버용 API 함수에 넘겨야 했는데, App Router의 서버 코드에서는 </span><code style="font-family:Consolas, Menlo, Monaco, source-code-pro, &quot;Courier New&quot;, monospace;font-size:0.9em;background-color:var(--adaptiveOpacity100);border:1px solid var(--adaptiveOpacity200);border-radius:3px"><span class="css-1mjnzsq">headers()</span></code><span class="css-1kxrhf3">로 직접 읽을 수 있어요. 인증을 챙기는 일이 데이터를 가져오는 함수 안으로 들어가는 거예요. 브라우저에서 쿠키가 알아서 실려 나가듯, 서버에서도 부르는 쪽은 인증을 신경 쓰지 않아도 되고요.</span></span></span></p><p id="3d5a360d-33e3-808e-be0a-f93e7e1e2076" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">다만 </span><code style="font-family:Consolas, Menlo, Monaco, source-code-pro, &quot;Courier New&quot;, monospace;font-size:0.9em;background-color:var(--adaptiveOpacity100);border:1px solid var(--adaptiveOpacity200);border-radius:3px"><span class="css-1mjnzsq">headers()</span></code><span class="css-1kxrhf3">를 Client Component에서 직접 사용할 수는 없어요. 서버에서 렌더링되는 동안에도 마찬가지예요. </span><code style="font-family:Consolas, Menlo, Monaco, source-code-pro, &quot;Courier New&quot;, monospace;font-size:0.9em;background-color:var(--adaptiveOpacity100);border:1px solid var(--adaptiveOpacity200);border-radius:3px"><span class="css-1mjnzsq">next/headers</span></code><span class="css-1kxrhf3">를 가져오는 순간 그 파일 자체가 브라우저로 나갈 수 없게 되기 때문에, 한 파일 안에서 조건문으로 갈라 쓰는 방법은 통하지 않았어요.</span></span></span></p><p id="3d5a360d-33e3-8097-84f2-ef397b8f5b8c" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">그래서 쓴 방법이 패키지의 환경별 분기예요. </span><code style="font-family:Consolas, Menlo, Monaco, source-code-pro, &quot;Courier New&quot;, monospace;font-size:0.9em;background-color:var(--adaptiveOpacity100);border:1px solid var(--adaptiveOpacity200);border-radius:3px"><span class="css-1mjnzsq">package.json</span></code><span class="css-1kxrhf3">의 조건부 export로 RSC용 구현과 클라이언트용 구현을 나누면, 호출하는 쪽에서는 같은 경로로 가져다 쓸 수 있어요. 구조를 단순화하면 다음과 같아요.</span></span></span></p><p id="3d5a360d-33e3-80a7-995c-ee4e8b1785ed" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">RSC용 구현만 </span><code style="font-family:Consolas, Menlo, Monaco, source-code-pro, &quot;Courier New&quot;, monospace;font-size:0.9em;background-color:var(--adaptiveOpacity100);border:1px solid var(--adaptiveOpacity200);border-radius:3px"><span class="css-1mjnzsq">headers()</span></code><span class="css-1kxrhf3">로 인증 정보를 읽어 붙여요. 브라우저용 구현은 기존 방식 그대로예요. 쿠키가 알아서 실려 나가니 따로 할 일이 없거든요.</span></span></span></p><p id="3d5a360d-33e3-80af-9be9-f08a838e57dc" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">이제 부르는 쪽은 어느 환경인지 몰라도 돼요. 쿼리 정의 하나로 서버에서 미리 가져올 때와 브라우저에서 조회할 때를 모두 처리해요.</span></span></span></p><p id="3d5a360d-33e3-8064-a644-da656a7c0d81" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">쿼리마다 서버용 코드를 따로 만들던 일이 사라지는 거예요. 처음에는 부차적인 이득이라고 봤는데, 만들어서 써보니 개발 흐름이 눈에 띄게 단순해졌어요. 번들 크기만큼이나 큰 이점이라고 생각을 바꿨어요.</span></span></span></p><p id="3d5a360d-33e3-8017-8dec-ebcd00b5d6ce" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">다만 서버에서만 렌더링되는 트리(RSC)가 생긴 게 장점만 있는 건 아니었어요. 어디까지가 그 트리이고 어디부터 브라우저에서 도는 코드인지를 계속 구분해야 하거든요. 저희가 마주한 비용은 대부분 여기에서 나왔어요.</span></span></span></p><p id="3d5a360d-33e3-801c-9207-e48128235cca" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">먼저 경계를 그을 수 있는 자리가 생각보다 적었어요. 검토한 화면에는 버튼, 입력, 웹뷰에서 앱으로 요청을 보내는 앱 브릿지 호출처럼 브라우저에서 처리할 동작이 많았거든요. Client Component로 남는 영역이 많았고, RSC를 쓸 수 있다고 본 자리는 주로 화면을 그리기 전에 데이터를 가져오는 부분이었어요. 거기서 데이터를 모두 기다린 뒤 화면을 보여준다면, 사용자가 겪는 로딩 흐름은 기존 방식과 크게 다르지 않았고요.</span></span></span></p><p id="3d5a360d-33e3-8070-b8e4-d67eb29057a8" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">경계를 잘못 그었을 때 바로 드러나지 않는 것도 문제였어요. 앱 브릿지가 그랬어요. 앱 밖에서도 문제없이 돌아가도록 만들어져 있다 보니, Server Component에서 호출해도 에러 없이 그냥 넘어가거든요. 앱까지 요청은 가지 않는데도요. 이런 건 컴포넌트만 옮겨서는 드러나지 않아요. 함께 쓰는 공통 패키지도 새 실행 환경에 맞는지 하나씩 살펴봐야 했어요.</span></span></span></p><p id="3d5a360d-33e3-80c3-97f0-fd21921e3870" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">서버에서 캐시를 쓸 때도 새로 익혀야 할 규칙이 있었어요. 요청 간에 데이터를 재사용하는 캐시에 사용자별 응답을 저장할 때는, 캐시 키에도 사용자 구분이 들어가야 해요. 이를 빠뜨리면 다른 사용자의 응답이 재사용될 수 있거든요. 어떤 캐시가 어디까지 공유되는지 이해하고 써야 했어요.</span></span></span></p><p id="3d5a360d-33e3-80b4-8211-ec9f54205d56" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">요청 코드는 간결해졌지만, 이 경계를 익히고 안정적으로 다루는 비용은 늘었어요.</span></span></span></p><p id="3d5a360d-33e3-8009-8d91-c1665d8bcac8" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"></span></span></p><h2 id="3d5a360d-33e3-80c3-be4f-e75174ae0aea" class="css-1c1qox8"><span class="css-1r3ko7u"><span class="css-p4abj2"><span class="css-1kxrhf3">운영하면서 조금씩 옮길 수 있을까요?</span></span></span></h2><p id="3d5a360d-33e3-8024-999d-cca446d7e07d" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">Next.js는 Pages Router와 App Router를 함께 두고 화면을 하나씩 옮기는 점진적 마이그레이션을 지원해요. 저희도 일부 화면부터 적용하면서 이득과 비용을 확인하려고 했어요.</span></span></span></p><p id="3d5a360d-33e3-803a-a25e-ef30b516ea12" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">그런데 특정 경로 구조에서 기존 화면의 이동이 깨졌어요. </span><code style="font-family:Consolas, Menlo, Monaco, source-code-pro, &quot;Courier New&quot;, monospace;font-size:0.9em;background-color:var(--adaptiveOpacity100);border:1px solid var(--adaptiveOpacity200);border-radius:3px"><span class="css-1mjnzsq">basePath</span></code><span class="css-1kxrhf3">를 설정한 상태에서 Pages Router 화면끼리 이동하면, 경로 앞에 </span><code style="font-family:Consolas, Menlo, Monaco, source-code-pro, &quot;Courier New&quot;, monospace;font-size:0.9em;background-color:var(--adaptiveOpacity100);border:1px solid var(--adaptiveOpacity200);border-radius:3px"><span class="css-1mjnzsq">basePath</span></code><span class="css-1kxrhf3">가 두 번 붙었어요.</span></span></span></p><p id="3d5a360d-33e3-80f8-acea-f038d31f08eb" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">같은 이동을 두고 App Router 쪽의 구성을 바꿔보니 조건이 좁혀졌어요.</span></span></span></p><div data-table-wrapper="true" class="css-1m7s5mg"><div role="table" class="css-crnt2r"><div role="row" class="css-1obf64m"><div role="cell" class="css-8vl804"><span class="css-1mjnzsq">App Router의 라우트 구성</span></div><div role="cell" class="css-8vl804"><span class="css-1mjnzsq">Pages Router 이동 결과</span></div></div><div role="row" class="css-1obf64m"><div role="cell" class="css-8vl804"><span class="css-1mjnzsq">같은 경로 앞부분을 공유하는 동적 라우트 </span><code style="font-family:Consolas, Menlo, Monaco, source-code-pro, &quot;Courier New&quot;, monospace;font-size:0.9em;background-color:var(--adaptiveOpacity100);border:1px solid var(--adaptiveOpacity200);border-radius:3px"><span class="css-1mjnzsq">/a/[id]</span></code></div><div role="cell" class="css-8vl804"><span class="css-1mjnzsq">404</span></div></div><div role="row" class="css-1obf64m"><div role="cell" class="css-8vl804"><span class="css-1mjnzsq">정적 라우트 </span><code style="font-family:Consolas, Menlo, Monaco, source-code-pro, &quot;Courier New&quot;, monospace;font-size:0.9em;background-color:var(--adaptiveOpacity100);border:1px solid var(--adaptiveOpacity200);border-radius:3px"><span class="css-1mjnzsq">/a</span></code></div><div role="cell" class="css-8vl804"><span class="css-1mjnzsq">정상</span></div></div><div role="row" class="css-1obf64m"><div role="cell" class="css-8vl804"><span class="css-1mjnzsq">경로 앞부분이 겹치지 않는 동적 라우트</span></div><div role="cell" class="css-8vl804"><span class="css-1mjnzsq">정상</span></div></div></div></div><p id="3d5a360d-33e3-809e-b389-e3c52e483582" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">클라이언트 라우터는 이동할 주소가 App Router 쪽 화면인지 판단할 때 블룸 필터를 써요. 블룸 필터는 크기를 줄이는 대신 아닌 것을 맞다고 답하는 경우를 허용하고, 그래서 잘못 답해도 되돌릴 수 있는 자리에 쓰죠. 여기서도 맞다고 판단하면 페이지를 통째로 다시 불러와요. 서버는 실제 경로를 아니까, 판단이 틀렸더라도 화면은 제대로 나와야 하고요.</span></span></span></p><p id="3d5a360d-33e3-80a2-b06f-d27723806612" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">문제는 다시 불러오는 그 주소가 틀렸다는 점이었어요. </span><code style="font-family:Consolas, Menlo, Monaco, source-code-pro, &quot;Courier New&quot;, monospace;font-size:0.9em;background-color:var(--adaptiveOpacity100);border:1px solid var(--adaptiveOpacity200);border-radius:3px"><span class="css-1mjnzsq">basePath</span></code><span class="css-1kxrhf3">가 두 번 붙어 없는 주소로 가고, 사용자는 404를 봐요. 잘못 판단했을 때 되돌려주는 장치가 오히려 화면을 깨뜨린 거예요.</span></span></span></p><p id="3d5a360d-33e3-80cf-ae1f-d82ad5d6609c" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">코드를 따라가 보니 App Router의 </span><code style="font-family:Consolas, Menlo, Monaco, source-code-pro, &quot;Courier New&quot;, monospace;font-size:0.9em;background-color:var(--adaptiveOpacity100);border:1px solid var(--adaptiveOpacity200);border-radius:3px"><span class="css-1mjnzsq">/a/[id]</span></code><span class="css-1kxrhf3">가 필터에 </span><code style="font-family:Consolas, Menlo, Monaco, source-code-pro, &quot;Courier New&quot;, monospace;font-size:0.9em;background-color:var(--adaptiveOpacity100);border:1px solid var(--adaptiveOpacity200);border-radius:3px"><span class="css-1mjnzsq">/a</span></code><span class="css-1kxrhf3">로 등록돼 있었어요. 이동할 주소의 앞부분을 하나씩 넣어보기 때문에, Pages Router의 </span><code style="font-family:Consolas, Menlo, Monaco, source-code-pro, &quot;Courier New&quot;, monospace;font-size:0.9em;background-color:var(--adaptiveOpacity100);border:1px solid var(--adaptiveOpacity200);border-radius:3px"><span class="css-1mjnzsq">/a/a</span></code><span class="css-1kxrhf3">도 </span><code style="font-family:Consolas, Menlo, Monaco, source-code-pro, &quot;Courier New&quot;, monospace;font-size:0.9em;background-color:var(--adaptiveOpacity100);border:1px solid var(--adaptiveOpacity200);border-radius:3px"><span class="css-1mjnzsq">/a</span></code><span class="css-1kxrhf3">에서 걸렸고요. 확률이 아니라 등록 방식 때문에 매번 걸린 거예요.</span></span></span></p><p id="3d5a360d-33e3-806c-ac18-c34db917d936" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">주소가 두 번 붙는 이유도 같이 나왔어요. Pages Router는 화면을 옮길 때 주소 앞에 </span><code style="font-family:Consolas, Menlo, Monaco, source-code-pro, &quot;Courier New&quot;, monospace;font-size:0.9em;background-color:var(--adaptiveOpacity100);border:1px solid var(--adaptiveOpacity200);border-radius:3px"><span class="css-1mjnzsq">basePath</span></code><span class="css-1kxrhf3">를 먼저 붙여요. 이미 붙어 있는 주소인데, 다시 불러올 주소를 만들 때는 확인 없이 한 번 더 붙인 거예요. </span><a target="_blank" rel="noreferrer noopener" class="css-iynyr0" href="https://github.com/vercel/next.js/issues/75477">같은 유형의 이슈</a><span class="css-1kxrhf3">는 지금도 열려 있어요.</span></span></span></p><p id="3d5a360d-33e3-801a-8fd3-e5cdbdd6dded" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">두 라우터를 함께 두는 건 위험을 줄이려고 고른 방법이었어요. 그런데 이 문제는 함께 두는 동안에만 생겨요. 안전하게 옮기려던 방법이 오히려 위험을 만든 셈이에요.</span></span></span></p><p id="3d5a360d-33e3-8030-b3b0-edf6ee2a0127" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"></span></span></p><h2 id="3d5a360d-33e3-802f-97ce-eaacb5dc3dca" class="css-1c1qox8"><span class="css-1r3ko7u"><span class="css-p4abj2"><span class="css-1kxrhf3">많은 개발자가 쓰면 해결도 빠를까요?</span></span></span></h2><p id="3d5a360d-33e3-802d-9ce5-d658053826e9" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">앞의 문제를 겪으면서도 크게 걱정하지는 않았어요. 많이 쓰는 프레임워크니까 필요한 기능이나 수정도 곧 나올 거라고 생각했거든요.</span></span></span></p><p id="3d5a360d-33e3-80a1-98e5-c946b5e6c7bc" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">그런데 저희에게 필요했던 것들은 그렇지 않았어요. 로깅이 그랬어요. Pages Router의 </span><code style="font-family:Consolas, Menlo, Monaco, source-code-pro, &quot;Courier New&quot;, monospace;font-size:0.9em;background-color:var(--adaptiveOpacity100);border:1px solid var(--adaptiveOpacity200);border-radius:3px"><span class="css-1mjnzsq">router.pathname</span></code><span class="css-1kxrhf3">은 </span><code style="font-family:Consolas, Menlo, Monaco, source-code-pro, &quot;Courier New&quot;, monospace;font-size:0.9em;background-color:var(--adaptiveOpacity100);border:1px solid var(--adaptiveOpacity200);border-radius:3px"><span class="css-1mjnzsq">/blog/[slug]</span></code><span class="css-1kxrhf3"> 같은 경로 패턴을 돌려줬어요. 화면별로 묶어서 집계하려면 이 패턴이 있어야 하거든요. 그런데 App Router의 </span><code style="font-family:Consolas, Menlo, Monaco, source-code-pro, &quot;Courier New&quot;, monospace;font-size:0.9em;background-color:var(--adaptiveOpacity100);border:1px solid var(--adaptiveOpacity200);border-radius:3px"><span class="css-1mjnzsq">usePathname()</span></code><span class="css-1kxrhf3">은 </span><code style="font-family:Consolas, Menlo, Monaco, source-code-pro, &quot;Courier New&quot;, monospace;font-size:0.9em;background-color:var(--adaptiveOpacity100);border:1px solid var(--adaptiveOpacity200);border-radius:3px"><span class="css-1mjnzsq">/blog/hello-world</span></code><span class="css-1kxrhf3"> 같은 실제 주소만 돌려줘요. 이 정보를 제공하려는 </span><a target="_blank" rel="noreferrer noopener" class="css-iynyr0" href="https://github.com/vercel/next.js/pull/85527">PR</a><span class="css-1kxrhf3">은 2025년 10월 29일에 열렸고, 글을 쓰는 지금도 초안 상태예요.</span></span></span></p><p id="3d5a360d-33e3-8037-8b1c-deb3edc9abfd" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">사라진 </span><code style="font-family:Consolas, Menlo, Monaco, source-code-pro, &quot;Courier New&quot;, monospace;font-size:0.9em;background-color:var(--adaptiveOpacity100);border:1px solid var(--adaptiveOpacity200);border-radius:3px"><span class="css-1mjnzsq">router.events</span></code><span class="css-1kxrhf3">도 마찬가지였어요. URL 변화를 관찰하는 다른 방법은 있었지만, 기존에 쓰던 동작을 그대로 옮기려면 별도 패키지를 만들어 메워야 했어요.</span></span></span></p><p id="3d5a360d-33e3-8033-9c25-d5d5bdd47449" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">많이 쓰인다고 우리 문제가 먼저 풀리는 건 아니었어요. 기다리는 시간과 그동안 직접 메우는 작업도 도입 비용이었고요.</span></span></span></p><p id="3d5a360d-33e3-80c1-a35b-cdac6e836e9f" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"></span></span></p><h2 id="3d5a360d-33e3-80f4-a340-fdee469ffab7" class="css-1c1qox8"><span class="css-1r3ko7u"><span class="css-p4abj2"><span class="css-1kxrhf3">그래서 Pages Router를 유지하기로 했어요</span></span></span></h2><p id="3d5a360d-33e3-8080-a0b9-c7d53e0e4cea" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">물론 이득도 있었어요. 데이터를 가져오는 코드가 정리되는 건 분명했고요. 다만 저희가 얻을 수 있는 이득에 비해, 전환하고 운영하는 비용이 컸어요.</span></span></span></p><p id="3d5a360d-33e3-80c1-bc0a-c971ab9a256f" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">그래서 이번에는 App Router를 도입하지 않고 Pages Router를 유지하기로 했어요. 앞으로도 새로운 기술을 검토할 때는 그 장점이 우리 서비스에서도 유효한지, 전환하고 운영하는 비용은 감당할 수 있는지 직접 확인하려고 해요.</span></span></span></p><p id="3d5a360d-33e3-8087-9ac1-e6a751f87b36" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">앞으로도 검토할 기술과 내려야 할 결정은 많아요. 어떤 기술이 우리 서비스에 더 나은 선택일지 함께 고민해보고 싶은 분들의 토스뱅크 합류를 기다릴게요.</span></span></span></p></div><div id="tds-mobile-portal-container"></div>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[AI가 팀 규칙을 지키도록 하는 방법]]></title>
            <link>https://toss.tech/article/52631</link>
            <guid isPermaLink="true">https://toss.tech/article/52631</guid>
            <pubDate>Tue, 08 Sep 2026 02:43:00 GMT</pubDate>
            <description><![CDATA[Coding Agent를 위한 Stylepack]]></description>
            <content:encoded><![CDATA[<link rel="preload" as="image" href="https://static.toss.im/ipd-tcs/toss_core/live/ca1e6db6-32e0-4f27-93c5-4e5324710a11/01-human-vs-agent.png"/><link rel="preload" as="image" href="https://static.toss.im/ipd-tcs/toss_core/live/5e2d9323-0b9c-48ed-bcca-94ccf4ca44bb/02-lost-in-the-middle.png"/><link rel="preload" as="image" href="https://static.toss.im/ipd-tcs/toss_core/live/5df58835-406e-45c2-a7b8-67c625d73efb/03-agent-loop.png"/><link rel="preload" as="image" href="https://static.toss.im/ipd-tcs/toss_core/live/eeb6ce6e-6031-4d95-9ea5-626977672f55/04-linter-feedback-loop.png"/><link rel="preload" as="image" href="https://static.toss.im/ipd-tcs/toss_core/live/3b166d62-18f9-42a5-ada4-f6c7d1fc9841/05-narrow-vs-wide-view.png"/><link rel="preload" as="image" href="https://static.toss.im/ipd-tcs/toss_core/live/cac3a7e8-85e0-406a-b740-c177011f0881/06-two-injection-points.png"/><link rel="preload" as="image" href="https://static.toss.im/ipd-tcs/toss_core/live/16ac44b1-b68f-441a-a8b0-f5cb149446a9/07-template-vs-central.png"/><link rel="preload" as="image" href="https://static.toss.im/ipd-tcs/toss_core/live/d344b08b-cb90-43e2-ab83-83968fbdd703/08-stylepack-flywheel.png"/><div class="css-1vn47db"><p id="a935fd70-d79d-4e4b-8c4a-9fdab964b547" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">안녕하세요, 토스뱅크 ML Engineer 김경윤입니다.</span></span></span></p><p id="2842b574-3a89-4e38-bea7-e9e260fe2d42" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><a target="_blank" rel="noreferrer noopener" class="css-iynyr0" href="https://toss.tech/article/ds-mle-cowork">지난 글 DS와 MLE가 함께 일하는 법</a><span class="css-1kxrhf3">에서, 마지막에 이런 문장을 한 줄 남겼어요.</span></span></span></p><blockquote id="e675279e-2494-44dd-83cf-fd0308e553af" class="css-2sk6rv"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">AI로 코드를 작성하게 되면서, 역할 나누기를 넘어 코드 스타일까지 맞추는 게 새로운 협업 과제가 됐습니다.</span></span></span></blockquote><p id="74165ec6-2814-4785-af58-591ded6bf3e9" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">오늘은 위 내용에 이어서 얘기해보려고 합니다. Coding Agent로 코드를 작성하면서 겪은 문제와, 그걸 어떻게 풀었는지에 대한 이야기예요. 결론부터 말하면, 저희가 찾은 답은 </span><span class="css-q3ktjb">Agent Loop 안에 Feedback Loop를 하나 더 심는 것</span><span class="css-1kxrhf3">이었습니다.</span></span></span></p><p id="3cda360d-33e3-806a-bbee-df28c01a02eb" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"></span></span></p><h2 id="16d3c53c-0c6b-483d-937f-3914296839df" class="css-1c1qox8"><span class="css-1r3ko7u"><span class="css-p4abj2"><span class="css-1kxrhf3">Coding Agent로 작성하면서 시작된 문제</span></span></span></h2><p id="62c45053-2c45-473d-940c-21f726e5c3f4" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">예전엔 이게 문제가 아니었습니다. 사람이 직접 코드를 작성했으니까요.</span></span></span></p><p id="afba9e75-d153-4787-b691-d83d68cfbc44" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">사람이 코드를 작성할 때는 다른 사람이 작성한 코드를 보면서 맥락을 파악해요. 리뷰를 주고받으면서 &quot;아 이 팀은 이렇게 쓰는구나&quot;를 자연스럽게 인지하게 되고요. 굳이 문서로 안 적어도 시간이 알아서 맞춰줬습니다.</span></span></span></p><p id="00ebbc58-7be0-4a25-9804-4cb1cc219dd2" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">그런데 에이전트는 </span><span class="css-q3ktjb">전반적인 코드 맥락을 보지 않습니다.</span><span class="css-1kxrhf3"> 수정이 필요한 일부 맥락만 파악하고, 그 안에서만 코드를 작성하다 보니, 큰 틀의 규칙을 잘 지키지 못하는 문제가 발생합니다.</span></span></span></p><figure id="3c7a360d-33e3-801c-8aac-de496afaf95c" class="css-of5acw"><img src="https://static.toss.im/ipd-tcs/toss_core/live/ca1e6db6-32e0-4f27-93c5-4e5324710a11/01-human-vs-agent.png" alt="" class="css-1pgssrp"/></figure><p id="a802cd5a-d98b-4a3b-9e9a-22876ab83ba5" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">처음엔 그냥 말로 알려줬습니다. &quot;여기는 Enum을 쓰는 게 좋을 것 같아요&quot;, &quot;</span><code style="font-family:Consolas, Menlo, Monaco, source-code-pro, &quot;Courier New&quot;, monospace;font-size:0.9em;background-color:var(--adaptiveOpacity100);border:1px solid var(--adaptiveOpacity200);border-radius:3px"><span class="css-1mjnzsq">if-else</span></code><span class="css-1kxrhf3"> 보다 </span><code style="font-family:Consolas, Menlo, Monaco, source-code-pro, &quot;Courier New&quot;, monospace;font-size:0.9em;background-color:var(--adaptiveOpacity100);border:1px solid var(--adaptiveOpacity200);border-radius:3px"><span class="css-1mjnzsq">match</span></code><span class="css-1kxrhf3">을 쓰는 게 좋을 것 같아요&quot; 같은 피드백을 리뷰마다 남기는 식이었죠.</span></span></span></p><p id="14005ec4-b9a9-4e2f-863e-1cd400a89543" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">그런데 이 방식은 결국 팀 약속을 각자의 기억에 맡기는 셈이 됩니다. 아는 사람이 있으면 고쳐지고, 아무도 못 보면 그냥 넘어가요.</span></span></span></p><p id="d23fe979-f701-402a-a1ae-5ca1bfb37e3c" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">그래서 다음으로 간 게 </span><code style="font-family:Consolas, Menlo, Monaco, source-code-pro, &quot;Courier New&quot;, monospace;font-size:0.9em;background-color:var(--adaptiveOpacity100);border:1px solid var(--adaptiveOpacity200);border-radius:3px"><span class="css-1mjnzsq">AGENTS.md</span></code><span class="css-1kxrhf3"> 같은 </span><span class="css-q3ktjb">Global Instruction</span><span class="css-1kxrhf3">이었어요. 프로젝트 루트에 두는 안내 문서인데, 세션이 시작될 때 에이전트가 이 문서를 먼저 읽고, 그대로 따르려고 합니다.</span></span></span></p><p id="5e50f7b1-04af-4ad4-a2cd-63bb75ceef49" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">그런데 여기에 적어두는 것만으로는 해결되지 않았습니다.</span></span></span></p><p id="3cda360d-33e3-80cb-825d-d231eb9f25af" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"></span></span></p><h2 id="e8ffe3ba-babe-48e5-af2b-8ac832e833f0" class="css-1c1qox8"><span class="css-1r3ko7u"><span class="css-p4abj2"><span class="css-1kxrhf3">Lost in the Middle</span></span></span></h2><p id="6f63215c-5c7e-47dc-be4b-004d75c498f6" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">Global Instruction을 적용하고, 며칠 써보니 이상한 패턴이 보였습니다. </span><span class="css-q3ktjb">세션 초반엔 잘 지키지만, 길어지면 잘 안 지키는 패턴이었어요.</span></span></span></p><p id="38ca7b3a-feec-4086-abcb-570c2a6193d2" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">이 문제는 흔히 </span><span class="css-q3ktjb">Lost in the Middle</span><span class="css-1kxrhf3">(긴 입력을 줬을 때 가운데 있는 내용을 모델이 잘 못 쓰는 현상)이라고 부르는 상황입니다. Context 오염이라고도 하고요.</span></span></span></p><p id="636c98ce-6463-4d0a-836a-a6c9067e4f2f" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">세션 시작에 규칙을 읽긴 읽습니다. 그런데 그 뒤로 파일 읽기, 코드 작성, 테스트 결과가 계속 쌓입니다.</span></span></span></p><p id="1c438fb9-a75e-4b77-8b6e-e198e24938ed" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">30번째 바퀴쯤 되면 에이전트는 세션 시작에 읽은 규칙보다 </span><span class="css-q3ktjb">방금 읽은 코드와 맥락을 더 믿습니다.</span><span class="css-1kxrhf3"> 맨 앞에 넣어둔 Context는 조용히 무시하게 되고요.</span></span></span></p><p id="91f3fa5a-6694-4a31-ade3-62e2caf8235d" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">모델이 강하게 반응하는 자리는 맨 앞과 맨 뒤입니다. 문제는 대화가 길어질수록 맨 앞이 더 이상 ‘맨 앞’처럼 느껴지지 않는다는 거예요. 규칙은 어느새 한가운데, 가장 안 보는 자리에 가 있어요.</span></span></span></p><figure id="3c7a360d-33e3-8066-adc2-e8738a98df8b" class="css-of5acw"><img src="https://static.toss.im/ipd-tcs/toss_core/live/5e2d9323-0b9c-48ed-bcca-94ccf4ca44bb/02-lost-in-the-middle.png" alt="" class="css-1pgssrp"/></figure><p id="d3b1e0cd-41ec-41d1-99b2-21cb0b6dc7f3" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">여기에 문제가 하나 더 있는데요. </span><span class="css-q3ktjb">세세한 규칙은 Global Instruction에 적기가 부담스럽다는 점입니다.</span></span></span></p><p id="40629cc0-a460-436a-b0a8-b6194210133e" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">‘외부 라이브러리 타입은 반드시 내부 DTO로 한 번 매핑해야 한다’ 같은 것은 한 줄로 적을 수 있어요. 그런데 저희가 리뷰에서 실제로 주고받는 지적은 훨씬 자세합니다.</span></span></span></p><aside id="3cda360d-33e3-80b5-bfff-e604ebd23405" class="css-nv7vyi"><div class="css-1vn47db"><ul class="css-uswsmm"><li id="74184516-dcea-41b4-b1c4-4927412ad5d3" class="css-1hwiibq"><span class="css-1r3ko7u"><span class="css-1iisb9p"><code style="font-family:Consolas, Menlo, Monaco, source-code-pro, &quot;Courier New&quot;, monospace;font-size:0.9em;background-color:var(--adaptiveOpacity100);border:1px solid var(--adaptiveOpacity200);border-radius:3px"><span class="css-1y26ms3">asyncio.gather</span></code><span class="css-q3ktjb"> 대신 </span><code style="font-family:Consolas, Menlo, Monaco, source-code-pro, &quot;Courier New&quot;, monospace;font-size:0.9em;background-color:var(--adaptiveOpacity100);border:1px solid var(--adaptiveOpacity200);border-radius:3px"><span class="css-1y26ms3">asyncio.TaskGroup</span></code><span class="css-q3ktjb"> 쓰기
</span><span class="css-1kxrhf3">하나가 실패해도 나머지 코루틴이 계속 돌면서 메모리를 붙잡고 있습니다.</span></span></span></li><li id="d30efde5-65e3-43c2-b0a9-cc9b2119cef1" class="css-1hwiibq"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-q3ktjb">trace id를 </span><code style="font-family:Consolas, Menlo, Monaco, source-code-pro, &quot;Courier New&quot;, monospace;font-size:0.9em;background-color:var(--adaptiveOpacity100);border:1px solid var(--adaptiveOpacity200);border-radius:3px"><span class="css-1y26ms3">uuid.uuid4().hex</span></code><span class="css-q3ktjb">로 직접 만들지 않기
</span><span class="css-1kxrhf3">팀 공통 라이브러리의 </span><code style="font-family:Consolas, Menlo, Monaco, source-code-pro, &quot;Courier New&quot;, monospace;font-size:0.9em;background-color:var(--adaptiveOpacity100);border:1px solid var(--adaptiveOpacity200);border-radius:3px"><span class="css-1mjnzsq">generate_trace_id()</span></code><span class="css-1kxrhf3">를 써야 값이 잘 Relay 됩니다.</span></span></span></li><li id="76c27274-ba94-4151-83ae-cf758da8d774" class="css-1hwiibq"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-q3ktjb">테스트의 기대값을 코드를 참조하지 않기
</span><span class="css-1kxrhf3">그 설정이 틀어지면 테스트도 같이 변경되어 못 잡습니다.</span></span></span></li><li id="3fed6712-b687-47e7-85ce-031325c1e23b" class="css-1hwiibq"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-q3ktjb">Kafka 토픽 이름은 </span><code style="font-family:Consolas, Menlo, Monaco, source-code-pro, &quot;Courier New&quot;, monospace;font-size:0.9em;background-color:var(--adaptiveOpacity100);border:1px solid var(--adaptiveOpacity200);border-radius:3px"><span class="css-1y26ms3">{domain}.{phase}.{explain}.{version}</span></code><span class="css-1kxrhf3"> </span><span class="css-q3ktjb">규칙을 지키기</span></span></span></li></ul></div></aside><p id="b0469345-4c88-40bf-a560-5209813e1ed7" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">이런 걸 다 적으면 어떤 프로젝트에서 작업하든 Context를 크게 차지합니다. 규칙이 늘어날수록 그중 하나하나를 지키기는 오히려 더 어려워지고요. 앞에서 본 문제가 그대로 심해지는 거죠.</span></span></span></p><p id="9b891427-ae25-4e64-8af8-466b90bc889f" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">즉 문제는 ‘무엇을 알려주느냐’가 아니었습니다. ‘</span><span class="css-q3ktjb">언제, 몇 개를 알려주느냐’</span><span class="css-1kxrhf3">였어요. 그래서 저희는 이 세세한 규칙들을 언제 어떻게 꺼낼지 고민하기 시작했어요.</span></span></span></p><p id="3cda360d-33e3-809e-a350-cb0022abfd1d" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"></span></span></p><h2 id="685103a5-8e35-4784-bdc7-869a14fbc098" class="css-1c1qox8"><span class="css-1r3ko7u"><span class="css-p4abj2"><span class="css-1kxrhf3">Coding Agent는 어떻게 동작할까</span></span></span></h2><p id="531eb0da-c093-4e65-ba3e-d4bc68d6b52c" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">먼저 Coding Agent가 어떻게 동작하는지부터 다시 봤습니다. 크게, 다섯 단계로 볼 수 있어요.</span></span></span></p><figure id="3c7a360d-33e3-80ce-be21-cb6d39c33afa" class="css-of5acw"><img src="https://static.toss.im/ipd-tcs/toss_core/live/5df58835-406e-45c2-a7b8-67c625d73efb/03-agent-loop.png" alt="" class="css-1pgssrp"/></figure><aside id="3cda360d-33e3-8060-93dd-ce2180f2abd8" class="css-nv7vyi"><div class="css-1vn47db"><ul class="css-uswsmm"><li id="43756210-5c03-442e-aff3-017fd73abe86" class="css-1hwiibq"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-q3ktjb">Pre-Define된 Context가 먼저 실려요.</span></span></span></li><li id="a8f60886-60c3-487b-b7ba-cb4751b1c8d4" class="css-1hwiibq"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-q3ktjb">사용자가 필요한 일을 말해요.</span></span></span></li><li id="26d13415-3808-492e-8803-56bc2bf41f6a" class="css-1hwiibq"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-q3ktjb">LLM이 읽고 판단해요.</span></span></span></li><li id="ddda5749-90f5-4e1f-83a9-84e606952924" class="css-1hwiibq"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-q3ktjb">Tool Calling으로 실제 일을 해요.</span></span></span></li><li id="7748da82-4b9a-4f1a-818e-95381008b84a" class="css-1hwiibq"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-q3ktjb">결과를 다시 LLM에게 돌려줘요.</span></span></span></li></ul></div></aside><p id="eecff7da-9e3d-4830-9933-4f1226330233" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">그리고 마지막 세 단계는 </span><span class="css-q3ktjb">목표를 이룰 때까지 반복됩니다.</span></span></span></p><p id="c6071861-4afb-4327-a5a6-5071ef49eb04" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">여기서 중요한 지점이 있는데요. 사용자가 요청을 한 번 하면, 에이전트는 그 안에서 </span><span class="css-q3ktjb">Tool Calling을 수십 번 반복합니다.</span><span class="css-1kxrhf3"> 파일 하나 고칠 때마다 한 번, 테스트 돌릴 때마다 한 번씩이요.</span></span></span></p><p id="f21c5ea4-1494-4c43-93b2-42db5792f304" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">그런데 지금까지 팀 약속이 들어가던 자리는 </span><span class="css-q3ktjb">맨 앞 한 곳뿐</span><span class="css-1kxrhf3">이었습니다. 반복 구간에는 아무것도 안 들어가고 있었던 거죠.</span></span></span></p><p id="3cda360d-33e3-8058-b6f4-c6ea6580f5c6" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"></span></span></p><h2 id="c1a8f6e6-91c3-4e3e-ab50-e234c588cc7c" class="css-1c1qox8"><span class="css-1r3ko7u"><span class="css-p4abj2"><span class="css-1kxrhf3">Linter Feedback Loop에서 가져온 아이디어</span></span></span></h2><p id="0e5f4477-0db3-4847-9e6b-b7eb0933d133" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">여기서 오래된 기억이 하나 떠올랐습니다.</span></span></span></p><p id="2b834f29-21b4-436f-a797-dcd7ec9b43b2" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">LLM 성능이 지금 같지 않던 시절엔, 코드를 한 번에 제대로 못 짰어요. 그래서 흔히 쓰던 방법이 </span><span class="css-q3ktjb">Linter</span><span class="css-1kxrhf3">(코드에 문법 오류나 나쁜 습관이 있는지 자동으로 검사해주는 도구)를 함께 쓰는 것이었습니다.</span></span></span></p><p id="57204586-406e-4632-9914-45e23875223e" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">LLM이 코드를 쓰면 → Linter를 돌려서 → 에러 메시지를 다시 LLM에게 넣어주고 → 고치게 하고 → 통과할 때까지 반복하는 방법이었죠. 사람이 중간에서 맥락을 계속 날라줬습니다.</span></span></span></p><p id="6799c05c-1a89-4b62-911e-dafa2ef4f17d" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">이 방식엔 장점이 하나 있었습니다. </span><span class="css-q3ktjb">방금 작성한 코드에 바로 코멘트를 남길 수 있다는 것이에요.</span><span class="css-1kxrhf3"> &quot;아까 세션 시작에 말했잖아&quot;가 아니라, &quot;방금 네가 쓴 이 줄이 문제야&quot;라고 말할 수 있는 거죠.</span></span></span></p><figure id="3c7a360d-33e3-80a6-b778-d1660d4b5157" class="css-of5acw"><img src="https://static.toss.im/ipd-tcs/toss_core/live/eeb6ce6e-6031-4d95-9ea5-626977672f55/04-linter-feedback-loop.png" alt="" class="css-1pgssrp"/></figure><p id="222c41cb-670b-4625-a083-7c88ede07964" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">그래서 </span><span class="css-q3ktjb">Linter 자리에 팀 컨벤션을 넣으면 되겠다</span><span class="css-1kxrhf3">는 아이디어를 떠올렸어요. Linter는 &quot;이건 문법이 틀렸어&quot;를 말하는데, 저희가 넣고 싶은 건 &quot;이건 우리 팀 방식이 아니야&quot;거든요. 검사하는 내용만 다르고 흐름은 똑같습니다.</span></span></span></p><p id="3cda360d-33e3-80fa-96ba-cb5f879268cd" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"></span></span></p><h2 id="86c15dfe-d733-48b7-b5d7-b97698265744" class="css-1c1qox8"><span class="css-1r3ko7u"><span class="css-p4abj2"><span class="css-1kxrhf3">코드를 작성한 직후에 피드백하기</span></span></span></h2><p id="e80b9860-3c0c-4714-967d-1ef1ca2ecf9c" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">여기서 쓰는 장치가 Hook입니다. 에이전트가 특정 순간에 다다르면 자동으로 실행되는 작은 스크립트인데요. 세션이 시작될 때, 파일을 작성한 직후처럼 정해진 시점에 끼어들 수 있습니다.</span></span></span></p><p id="e84266b4-e1a7-4a02-86a3-733b7b75343b" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">저희가 고른 자리는 </span><span class="css-q3ktjb">파일을 작성한 직후</span><span class="css-1kxrhf3">예요. 에이전트가 파일을 하나 작성하면, 그 직후에 방금 쓴 코드 본문을 훑어서 관련 규칙을 골라 넣습니다.</span></span></span></p><p id="d500a244-72ae-44db-85eb-d18463ed4bd0" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">이렇게 팀 규칙을 모아두고 각 Hook에 꽂아주는 플러그인을 만들었어요. 이름은 </span><code style="font-family:Consolas, Menlo, Monaco, source-code-pro, &quot;Courier New&quot;, monospace;font-size:0.9em;background-color:var(--adaptiveOpacity100);border:1px solid var(--adaptiveOpacity200);border-radius:3px"><span class="css-1mjnzsq">pfmls-stylepack</span></code><span class="css-1kxrhf3">이에요. 아래에서는 줄여서 ‘Stylepack’이라고 부를게요.</span></span></span></p><p id="15ece9ba-75c4-4785-b65e-0278c10657d8" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">규칙 파일 하나가 곧 규칙 하나입니다. 언제 띄울지를 정하는 </span><span class="css-q3ktjb">발동 조건</span><span class="css-1kxrhf3">과, 실제로 에이전트에게 들어갈 문장이 한 파일에 같이 있어요.</span></span></span></p><p id="13710708-6d68-4336-9330-0cd5b49db868" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">여기서 중요한 건 속도예요. Hook은 파일을 작성할 때마다 도니까 즉시 끝나야 해요. 그래서 AI를 부르지 않고 </span><span class="css-q3ktjb">정규식과 파일명 패턴 같은 간단한 방법으로 규칙을 골라요.</span></span></span></p><p id="e5b61cae-4952-4da4-b9e5-e7d94246a63a" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">사실 처음엔 &quot;정규식은 부정확하니 AI가 관련성을 판정하게 하자&quot;고 생각해서 붙여봤어요. 그런데 재보니 요청 하나당 10초가 더 걸렸어요. Hook이 매번 AI를 부르니까요.</span></span></span></p><p id="82653791-84f8-42f0-a9e8-18fd49fe4f25" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">관련 있는지 없는지의 최종 판단은 어차피 규칙을 받아 든 에이전트가 하면 되거든요. </span><span class="css-q3ktjb">저희는 후보를 좁혀주기만 하면 돼요.</span></span></span></p><p id="3cea360d-33e3-80fa-8dec-f4f128a62035" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"></span></span></p><h3 id="334753c9-84ae-45a3-9349-408c5ac3bd1f" class="css-1feg9au"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">실제 동작 예시</span></span></span></h3><p id="7fd566a6-5ac2-4c37-abea-6c37f6ce2634" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">팀원이 </span><code style="font-family:Consolas, Menlo, Monaco, source-code-pro, &quot;Courier New&quot;, monospace;font-size:0.9em;background-color:var(--adaptiveOpacity100);border:1px solid var(--adaptiveOpacity200);border-radius:3px"><span class="css-1mjnzsq">Verdict</span></code><span class="css-1kxrhf3">에 </span><code style="font-family:Consolas, Menlo, Monaco, source-code-pro, &quot;Courier New&quot;, monospace;font-size:0.9em;background-color:var(--adaptiveOpacity100);border:1px solid var(--adaptiveOpacity200);border-radius:3px"><span class="css-1mjnzsq">PASS</span></code><span class="css-1kxrhf3">를 하나 더 넣고, “새 판정값 추가했는데 알림 쪽도 좀 봐줘”라고만 입력했어요. 팀 약속 얘기는 한 마디도 안 했고요.</span></span></span></p><p id="4cea5c95-00b3-40ed-9db5-b69afb857a3a" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">작업 중인 코드는 아래와 같았습니다.</span></span></span></p><p id="be99ebbc-d829-4643-8a9c-68945595f80c" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">문제가 눈에 잘 안 보입니다. 방금 추가한 </span><code style="font-family:Consolas, Menlo, Monaco, source-code-pro, &quot;Courier New&quot;, monospace;font-size:0.9em;background-color:var(--adaptiveOpacity100);border:1px solid var(--adaptiveOpacity200);border-radius:3px"><span class="css-1mjnzsq">PASS</span></code><span class="css-1kxrhf3">를 처리하는 분기가 없는데도, </span><span class="css-q3ktjb">아무데서도 에러가 안 나요. </span><span class="css-1kxrhf3">알림만 조용히 안 나갈 뿐입니다. 반환값이 없는 함수라 타입 검사기가 걸릴 자리가 없거든요.</span></span></span></p><p id="8c9b04fc-6796-4722-9787-2b702ac9a615" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">에이전트가 이 파일을 고쳐 쓰는 순간, Hook이 코드 본문에서 </span><code style="font-family:Consolas, Menlo, Monaco, source-code-pro, &quot;Courier New&quot;, monospace;font-size:0.9em;background-color:var(--adaptiveOpacity100);border:1px solid var(--adaptiveOpacity200);border-radius:3px"><span class="css-1mjnzsq">case Verdict.REJECT:</span></code><span class="css-1kxrhf3"> 같은 모양을 보고 enum 분기 규칙을 걸어요.</span></span></span></p><p id="22c2d904-5c63-4eab-a358-1ec671f795e3" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">그러면 이런 내용이 에이전트에게 바로 들어갑니다.</span></span></span></p><p id="873b49d7-632b-46ba-9af9-d9f3ac989cfa" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">그러면 에이전트가 이 피드백을 읽고, 고칠 게 있는지 스스로 판단해서 이렇게 바꿔요.</span></span></span></p><p id="9e2fc4a8-ff28-4cd1-a5b5-21bb3b248b1b" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">빠져 있던 </span><code style="font-family:Consolas, Menlo, Monaco, source-code-pro, &quot;Courier New&quot;, monospace;font-size:0.9em;background-color:var(--adaptiveOpacity100);border:1px solid var(--adaptiveOpacity200);border-radius:3px"><span class="css-1mjnzsq">PASS</span></code><span class="css-1kxrhf3"> 분기가 채워졌습니다. 앞으로 값이 하나 더 늘어도 </span><code style="font-family:Consolas, Menlo, Monaco, source-code-pro, &quot;Courier New&quot;, monospace;font-size:0.9em;background-color:var(--adaptiveOpacity100);border:1px solid var(--adaptiveOpacity200);border-radius:3px"><span class="css-1mjnzsq">assert_never</span></code><span class="css-1kxrhf3">에 걸리기 때문에, 실행해 보기 전 타입 검사 단계에서 바로 알 수 있고요.</span></span></span></p><p id="960c1ea5-59b0-4976-ba5f-42730a355592" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">다만 같은 얘기를 계속 반복하면 그것대로 Context를 잡아먹어요. 그래서 한 번에 최대 2개, 같은 규칙은 세션당 한 번만 넣도록 제한했습니다.</span></span></span></p><p id="3cda360d-33e3-80d7-9d81-e5ab4c9ab742" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"></span></span></p><h2 id="c41880da-6aee-4a9c-b368-541d5d2fb01d" class="css-1c1qox8"><span class="css-1r3ko7u"><span class="css-p4abj2"><span class="css-1kxrhf3">남은 문제, 전체적인 그림</span></span></span></h2><p id="d6cbdf81-53e0-4a49-aae0-8224139f8248" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">이 Feedback Loop로 대부분의 문제가 풀렸습니다.</span></span></span></p><p id="097563f8-cbc7-4d22-9fca-1cfe74dcf865" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">그런데 이걸로도 안 풀리는 종류가 하나 남았어요. 파일 하나만 봐서는 알 수 없는 문제인데요. 이번엔 저장소 계층을 새로 만드는 작업이었어요. 에이전트가 파일 두 개를 작성했어요.</span></span></span></p><p id="9122ab79-4ea4-4215-b782-ee18a0e193ac" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">파일 하나씩 보면 둘 다 멀쩡합니다.</span><span class="css-q3ktjb"> </span><code style="font-family:Consolas, Menlo, Monaco, source-code-pro, &quot;Courier New&quot;, monospace;font-size:0.9em;background-color:var(--adaptiveOpacity100);border:1px solid var(--adaptiveOpacity200);border-radius:3px"><span class="css-1mjnzsq">repository.py</span></code><span class="css-1kxrhf3">는 인터페이스와 구현을 제대로 나눴고, </span><code style="font-family:Consolas, Menlo, Monaco, source-code-pro, &quot;Courier New&quot;, monospace;font-size:0.9em;background-color:var(--adaptiveOpacity100);border:1px solid var(--adaptiveOpacity200);border-radius:3px"><span class="css-1mjnzsq">service.py</span></code><span class="css-1kxrhf3">는 생성자로 주입을 받고 있어요. 두 파일을 나란히 놓고 봐야 ‘서비스가 인터페이스가 아니라 구현체에 묶여 있다’가 보여요.</span></span></span></p><p id="7a14de05-68a0-4e4d-94f5-85cdb467b3e3" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">코드를 작성한 직후에 도는 Hook은 </span><span class="css-q3ktjb">방금 쓴 그 파일 하나</span><span class="css-1kxrhf3">만 보기 때문에 이걸 잡지 못합니다.</span></span></span></p><ul class="css-uswsmm"><li id="314dcc16-ed3e-4951-838c-3a10b99c4a2d" class="css-1hwiibq"><span class="css-1r3ko7u"><span class="css-1iisb9p"><code style="font-family:Consolas, Menlo, Monaco, source-code-pro, &quot;Courier New&quot;, monospace;font-size:0.9em;background-color:var(--adaptiveOpacity100);border:1px solid var(--adaptiveOpacity200);border-radius:3px"><span class="css-1y26ms3">repository.py</span></code><span class="css-q3ktjb">를 쓸 때:</span><span class="css-1kxrhf3"> 2계층 저장소 규칙이 떴어요. 그런데 이 파일은 규칙을 잘 지켰으니 에이전트는 그냥 넘어가요.</span></span></span></li><li id="d2a6f867-fa3c-43b8-9117-3b2b3d32f637" class="css-1hwiibq"><span class="css-1r3ko7u"><span class="css-1iisb9p"><code style="font-family:Consolas, Menlo, Monaco, source-code-pro, &quot;Courier New&quot;, monospace;font-size:0.9em;background-color:var(--adaptiveOpacity100);border:1px solid var(--adaptiveOpacity200);border-radius:3px"><span class="css-1y26ms3">service.py</span></code><span class="css-q3ktjb">를 쓸 때:</span><span class="css-1kxrhf3"> 이 파일엔 </span><code style="font-family:Consolas, Menlo, Monaco, source-code-pro, &quot;Courier New&quot;, monospace;font-size:0.9em;background-color:var(--adaptiveOpacity100);border:1px solid var(--adaptiveOpacity200);border-radius:3px"><span class="css-1mjnzsq">class ...Repository</span></code><span class="css-1kxrhf3"> 선언이 없어서 규칙이 아예 안 떠요.</span></span></span></li></ul><figure id="3c7a360d-33e3-80fb-8243-f942d0860d6a" class="css-of5acw"><img src="https://static.toss.im/ipd-tcs/toss_core/live/3b166d62-18f9-42a5-ada4-f6c7d1fc9841/05-narrow-vs-wide-view.png" alt="" class="css-1pgssrp"/></figure><p id="a59db6d9-34a6-440c-8228-11687346c098" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">그래서 </span><span class="css-q3ktjb">Agent Loop가 마무리되는 자리</span><span class="css-1kxrhf3">에 한 번 더 넣기로 했어요.</span></span></span></p><p id="b8bb35d5-f52e-47ab-973f-fa49b45347e6" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">에이전트가 &quot;다 했어요&quot; 하고 끝내려는 순간, 이번 작업의 변경 전체(</span><code style="font-family:Consolas, Menlo, Monaco, source-code-pro, &quot;Courier New&quot;, monospace;font-size:0.9em;background-color:var(--adaptiveOpacity100);border:1px solid var(--adaptiveOpacity200);border-radius:3px"><span class="css-1mjnzsq">git diff</span></code><span class="css-1kxrhf3">)를 다시 봅니다. 이제 두 파일이 한 덩어리로 들어오니까, 같은 규칙이 실제 위반을 짚을 수 있어요.</span></span></span></p><p id="a0e472ef-15a3-41ec-a628-9be3494a4da7" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">그러면 에이전트가 </span><code style="font-family:Consolas, Menlo, Monaco, source-code-pro, &quot;Courier New&quot;, monospace;font-size:0.9em;background-color:var(--adaptiveOpacity100);border:1px solid var(--adaptiveOpacity200);border-radius:3px"><span class="css-1mjnzsq">service.py</span></code><span class="css-1kxrhf3">의 타입을 인터페이스로 되돌립니다.</span></span></span></p><p id="66d27291-c75b-4514-8b00-769d80506bbf" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">타입 이름 하나 바뀐 것뿐이지만, 이제 테스트에서 DB를 띄우지 않고 가짜 구현을 넣을 수 있습니다. 두 자리는 같은 규칙 저장소를 쓰지만, 성격이 달라요.</span></span></span></p><ul class="css-uswsmm"><li id="1546c4cd-2b46-4717-a940-45ac45704ac1" class="css-1hwiibq"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-q3ktjb">코드 작성 직후:</span><span class="css-1kxrhf3"> 방금 쓴 파일 본문 하나, 빠르게, 최대 2개. 지금 쓰는 코드를 그 자리에서 고치게 하는 게 목적이에요</span></span></span></li><li id="9a555482-1349-45dd-92bd-1c99b6294c00" class="css-1hwiibq"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-q3ktjb">끝내기 직전:</span><span class="css-1kxrhf3"> 이번 변경 전체, 조금 느리게, 최대 4개. 마지막 안전망이에요</span></span></span></li></ul><p id="dcb8b5b6-6966-43e0-9ba2-13baf59f6ecd" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">여기까지를 한 장으로 정리하면 이런 그림입니다.</span></span></span></p><figure id="3c7a360d-33e3-80ce-955c-f5938ea5fa43" class="css-of5acw"><img src="https://static.toss.im/ipd-tcs/toss_core/live/cac3a7e8-85e0-406a-b740-c177011f0881/06-two-injection-points.png" alt="" class="css-1pgssrp"/></figure><p id="3cda360d-33e3-808f-a2fb-ff22d27f8c61" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"></span></span></p><h2 id="99356d12-1f38-4efc-9198-1e5f7eaae4b5" class="css-1c1qox8"><span class="css-1r3ko7u"><span class="css-p4abj2"><span class="css-1kxrhf3">팀 공통 Context 주입과 실시간 전파</span></span></span></h2><p id="f3ad9cac-7fb7-4e22-a118-cd59d0d02c78" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">Stylepack이 Feedback Loop만 하는 건 아닙니다. </span><span class="css-q3ktjb">세션이 시작할 때</span><span class="css-1kxrhf3"> 하는 일이 하나 더 있어요.</span></span></span></p><p id="4d94331b-3774-417b-ab19-b1a3c2d3f0fd" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">바로 </span><span class="css-q3ktjb">레포의 언어 환경에 맞는 팀 공통 Context를 넣어주는 일</span><span class="css-1kxrhf3">이에요. 레포에 </span><code style="font-family:Consolas, Menlo, Monaco, source-code-pro, &quot;Courier New&quot;, monospace;font-size:0.9em;background-color:var(--adaptiveOpacity100);border:1px solid var(--adaptiveOpacity200);border-radius:3px"><span class="css-1mjnzsq">pyproject.toml</span></code><span class="css-1kxrhf3">이 있으면 Python 안내를, </span><code style="font-family:Consolas, Menlo, Monaco, source-code-pro, &quot;Courier New&quot;, monospace;font-size:0.9em;background-color:var(--adaptiveOpacity100);border:1px solid var(--adaptiveOpacity200);border-radius:3px"><span class="css-1mjnzsq">build.gradle</span></code><span class="css-1kxrhf3">이 있으면 Spring 안내를 붙입니다. Python 레포에 Kotlin 규칙이 뜨면 방해만 되니까요.</span></span></span></p><p id="ea3fd68e-b238-417b-a3c0-e84c94bda71e" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">그런데 이걸 만들면서 풀어야 했던 문제가 하나 더 있었어요. </span><span class="css-q3ktjb">전파</span><span class="css-1kxrhf3">예요.</span></span></span></p><p id="f9981952-6f3e-4a13-ba1b-3b3111e8e7a2" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">보통 새 Python 서비스를 만들 때는 Template 레포를 복사해서 시작합니다. 그 안에는 </span><code style="font-family:Consolas, Menlo, Monaco, source-code-pro, &quot;Courier New&quot;, monospace;font-size:0.9em;background-color:var(--adaptiveOpacity100);border:1px solid var(--adaptiveOpacity200);border-radius:3px"><span class="css-1mjnzsq">AGENT.md</span></code><span class="css-1kxrhf3">도 들어 있고, 팀 약속을 적어두기에 딱 좋은 자리로 보였어요.</span></span></span></p><p id="58700362-00c1-4fbf-a51a-ff3e8d26bb72" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">문제는 </span><span class="css-q3ktjb">복사하고 나면 끝</span><span class="css-1kxrhf3">이라는 것이었습니다. 원본을 아무리 좋게 고쳐도, 이미 복사돼 나간 서비스 레포로는 그 변화가 흘러가지 않아요. 템플릿은 복사되는 순간부터 늙기 시작합니다.</span></span></span></p><p id="6c1fc1e3-b3a1-44f4-8732-e85a60eff759" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">그래서 방향을 뒤집었어요. 각 레포에 규칙을 복사해 두는 게 아니라, </span><span class="css-q3ktjb">중앙 저장소 하나에 두고 플러그인이 주기적으로 당겨오게</span><span class="css-1kxrhf3"> 했습니다. 세션이 시작될 때마다 백그라운드로 최신 규칙을 받아와요. 팀원은 아무것도 안 해도 늘 최신 규칙을 쓰게 되죠.</span></span></span></p><figure id="3c7a360d-33e3-8023-8f6d-ed0c89e114db" class="css-of5acw"><img src="https://static.toss.im/ipd-tcs/toss_core/live/16ac44b1-b68f-441a-a8b0-f5cb149446a9/07-template-vs-central.png" alt="" class="css-1pgssrp"/></figure><p id="3cda360d-33e3-803a-826e-cfd2b767c860" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"></span></span></p><h2 id="e7945866-0edd-4616-a315-8be96597dc0a" class="css-1c1qox8"><span class="css-1r3ko7u"><span class="css-p4abj2"><span class="css-1kxrhf3">Stylepack 자체의 Feedback Loop</span></span></span></h2><p id="7aafc884-f639-4b31-b31c-0ad61f50f35c" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">여기까지 만들고 나니 겉으로는 잘 돌아가는 것 같았습니다. 그런데 &quot;정말 모든 규칙이 제대로 먹히고 있나?&quot;라고 스스로 물어보니, 답을 하기가 어려웠어요. 에이전트에게 Feedback Loop를 만들어줬으면, 저희 규칙에도 Feedback Loop가 있어야 하는데요. 그래서 규칙이 뜨는 모든 순간을 파일에 한 줄씩 남기기 시작했습니다.</span></span></span></p><ul class="css-uswsmm"><li id="d803bf16-e43b-48bc-b823-70f0f99bcbfb" class="css-1hwiibq"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">어떤 규칙이 어느 시점에 적용되었는지</span></span></span></li><li id="63d9efa8-0a68-4763-9775-3d134e5af346" class="css-1hwiibq"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">어떤 조건 때문에 적용되었는지</span></span></span></li></ul><p id="3cea360d-33e3-80f6-8c7e-ea478a5135bf" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"></span></span></p><h3 id="aca5bc2a-a6ef-41f2-a5e8-03baeca5ffd1" class="css-1feg9au"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">조건이 너무 간단해서, 불필요한 Feedback을 하는 경우</span></span></span></h3><p id="da56377e-2d4a-4d6b-bf87-e42ed1639754" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-q3ktjb">enum 멤버를 키로 쓴 dict를 잡는 규칙</span><span class="css-1kxrhf3">이에요. 데이터를 enum 밖에 흩어놓지 말라는 규칙이죠.</span></span></span></p><p id="96e86ec2-3905-49f3-b1ed-ca214a600aaf" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">이 조건은 </span><code style="font-family:Consolas, Menlo, Monaco, source-code-pro, &quot;Courier New&quot;, monospace;font-size:0.9em;background-color:var(--adaptiveOpacity100);border:1px solid var(--adaptiveOpacity200);border-radius:3px"><span class="css-1mjnzsq">이름</span></code><span class="css-1kxrhf3"> · </span><code style="font-family:Consolas, Menlo, Monaco, source-code-pro, &quot;Courier New&quot;, monospace;font-size:0.9em;background-color:var(--adaptiveOpacity100);border:1px solid var(--adaptiveOpacity200);border-radius:3px"><span class="css-1mjnzsq">.</span></code><span class="css-1kxrhf3"> · </span><code style="font-family:Consolas, Menlo, Monaco, source-code-pro, &quot;Courier New&quot;, monospace;font-size:0.9em;background-color:var(--adaptiveOpacity100);border:1px solid var(--adaptiveOpacity200);border-radius:3px"><span class="css-1mjnzsq">이름</span></code><span class="css-1kxrhf3"> · </span><code style="font-family:Consolas, Menlo, Monaco, source-code-pro, &quot;Courier New&quot;, monospace;font-size:0.9em;background-color:var(--adaptiveOpacity100);border:1px solid var(--adaptiveOpacity200);border-radius:3px"><span class="css-1mjnzsq">:</span></code><span class="css-1kxrhf3"> 이 순서로 이어지는 자리를 찾아요. 그런데 이 순서를 만족하는 코드가 하나 더 있었습니다.</span></span></span></p><p id="fa325c02-8e93-4279-8ed4-00c9ba28f8ee" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><code style="font-family:Consolas, Menlo, Monaco, source-code-pro, &quot;Courier New&quot;, monospace;font-size:0.9em;background-color:var(--adaptiveOpacity100);border:1px solid var(--adaptiveOpacity200);border-radius:3px"><span class="css-1mjnzsq">item.score:</span></code><span class="css-1kxrhf3">도 </span><code style="font-family:Consolas, Menlo, Monaco, source-code-pro, &quot;Courier New&quot;, monospace;font-size:0.9em;background-color:var(--adaptiveOpacity100);border:1px solid var(--adaptiveOpacity200);border-radius:3px"><span class="css-1mjnzsq">이름</span></code><span class="css-1kxrhf3"> · </span><code style="font-family:Consolas, Menlo, Monaco, source-code-pro, &quot;Courier New&quot;, monospace;font-size:0.9em;background-color:var(--adaptiveOpacity100);border:1px solid var(--adaptiveOpacity200);border-radius:3px"><span class="css-1mjnzsq">.</span></code><span class="css-1kxrhf3"> · </span><code style="font-family:Consolas, Menlo, Monaco, source-code-pro, &quot;Courier New&quot;, monospace;font-size:0.9em;background-color:var(--adaptiveOpacity100);border:1px solid var(--adaptiveOpacity200);border-radius:3px"><span class="css-1mjnzsq">이름</span></code><span class="css-1kxrhf3"> · </span><code style="font-family:Consolas, Menlo, Monaco, source-code-pro, &quot;Courier New&quot;, monospace;font-size:0.9em;background-color:var(--adaptiveOpacity100);border:1px solid var(--adaptiveOpacity200);border-radius:3px"><span class="css-1mjnzsq">:</span></code><span class="css-1kxrhf3"> 이거든요. 그래서 dict가 아닌데도 그대로 걸렸습니다. 21개 세션에서 떴는데 코드가 바뀐 건 0이었어요. 둘을 갈라내는 건 </span><span class="css-q3ktjb">콜론 바로 뒤 한 칸</span><span class="css-1kxrhf3">이었습니다. 발동 조건 끝에 공백 하나를 더 요구하는 것으로 해결됐어요.</span></span></span></p><p id="78f2f3b5-f07e-4882-a320-82302df87128" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">이런 Feedback이 쌓이면 에이전트는 규칙 자체를 흘려듣게 됩니다. 틀린 지적은 안 하느니만 못하죠.</span></span></span></p><p id="3cea360d-33e3-80fd-a142-c6ae1a365e1c" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"></span></span></p><h3 id="16893aa2-e743-4f75-875f-1a2b90e69494" class="css-1feg9au"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">조건이 너무 까다로워서, 필요한 케이스에 Feedback을 하지 않는 경우</span></span></span></h3><p id="0dc8d7c0-0f46-476a-8aa8-841f77babbbb" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-q3ktjb">반복문 안에서 저장소를 조회하지 말라는 규칙</span><span class="css-1kxrhf3">이에요. 흔히 N+1이라고 부르는 문제죠.</span></span></span></p><p id="56ee1f03-0791-49d8-91f9-bf4060549c15" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><code style="font-family:Consolas, Menlo, Monaco, source-code-pro, &quot;Courier New&quot;, monospace;font-size:0.9em;background-color:var(--adaptiveOpacity100);border:1px solid var(--adaptiveOpacity200);border-radius:3px"><span class="css-1mjnzsq">for</span></code><span class="css-1kxrhf3">와 </span><code style="font-family:Consolas, Menlo, Monaco, source-code-pro, &quot;Courier New&quot;, monospace;font-size:0.9em;background-color:var(--adaptiveOpacity100);border:1px solid var(--adaptiveOpacity200);border-radius:3px"><span class="css-1mjnzsq">.forEach</span></code><span class="css-1kxrhf3">, 딱 두 형태만 보고 있어요. 그래서 이런 코드는 잡아요.</span></span></span></p><p id="fa516818-84d9-45d9-b16d-e91c1d2a6826" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">그런데 팀 PR 리뷰에서 사람이 실제로 잡은 N+1은 이 형태였어요.</span></span></span></p><p id="fffcc2a1-3863-41cd-bb73-127b17029e2d" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><code style="font-family:Consolas, Menlo, Monaco, source-code-pro, &quot;Courier New&quot;, monospace;font-size:0.9em;background-color:var(--adaptiveOpacity100);border:1px solid var(--adaptiveOpacity200);border-radius:3px"><span class="css-1mjnzsq">for</span></code><span class="css-1kxrhf3">도 </span><code style="font-family:Consolas, Menlo, Monaco, source-code-pro, &quot;Courier New&quot;, monospace;font-size:0.9em;background-color:var(--adaptiveOpacity100);border:1px solid var(--adaptiveOpacity200);border-radius:3px"><span class="css-1mjnzsq">.forEach</span></code><span class="css-1kxrhf3">도 아니라서 규칙이 아예 안 떴어요.</span></span></span></p><p id="9de7f1b6-90b7-4d6e-bfe8-47aad2185d10" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><code style="font-family:Consolas, Menlo, Monaco, source-code-pro, &quot;Courier New&quot;, monospace;font-size:0.9em;background-color:var(--adaptiveOpacity100);border:1px solid var(--adaptiveOpacity200);border-radius:3px"><span class="css-1mjnzsq">.map</span></code><span class="css-1kxrhf3">, </span><code style="font-family:Consolas, Menlo, Monaco, source-code-pro, &quot;Courier New&quot;, monospace;font-size:0.9em;background-color:var(--adaptiveOpacity100);border:1px solid var(--adaptiveOpacity200);border-radius:3px"><span class="css-1mjnzsq">.flatMap</span></code><span class="css-1kxrhf3">을 조건에 추가할 수도 있습니다. 그런데 이렇게 넓히는 건 금방 한계가 와요. 반복을 표현하는 방법은 계속 늘어나기 때문입니다.</span></span></span></p><p id="39507fed-facd-414c-9ac2-b2705b1b5757" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">다시 보니 이건 </span><span class="css-q3ktjb">코드 모양의 문제가 아니었어요.</span></span></span></p><p id="412739e6-0fdf-46bc-b9c6-5ad4b490a0a2" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">이렇게 코드 모양이 아니라 실행돼 봐야 드러나는 문제는, 발동 조건으로 좁히기보다 </span><span class="css-q3ktjb">상시 Context로 깔아두는 게 낫습니다.</span><span class="css-1kxrhf3"> 그래서 팀 Context에 이렇게 한 줄 추가했어요.</span></span></span></p><p id="51035127-562e-4057-a8b3-ad05e78303af" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">기준은 이렇게 갈라요.</span></span></span></p><ul class="css-uswsmm"><li id="530a9268-8e06-4dc9-9485-aa890a96bb9c" class="css-1hwiibq"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-q3ktjb">코드 모양으로 판정되는 건 규칙으로</span><span class="css-1kxrhf3"> — 보면 바로 알 수 있는 형태</span></span></span></li><li id="d9780e7f-2eb7-4663-b614-2871b686ba18" class="css-1hwiibq"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-q3ktjb">실행돼 봐야 아는 건 상시 Context로</span><span class="css-1kxrhf3"> — N+1처럼 런타임에만 드러나는 문제</span></span></span></li></ul><p id="3cda360d-33e3-8001-a9ae-fbc806646c5d" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"></span></span></p><h3 id="8e7b252a-935f-4656-ad5e-6164bdafffbc" class="css-1feg9au"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">새 규칙은 PR 리뷰에서 가져와요</span></span></span></h3><p id="23ba6d71-e529-4d8c-ae53-8095f8ee960e" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">규칙은 사람이 직접 추가하기도 합니다. 리팩토링을 하다가 &quot;이건 팀 규칙으로 두자&quot; 싶으면 그 자리에서 만들어요.</span></span></span></p><p id="f7eee682-0eb5-40c7-9a25-02664b6c218b" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">그런데 그것만으로는 부족했습니다. </span><span class="css-q3ktjb">여러 PR에서 같은 피드백이 반복되는데, 아무도 그걸 규칙으로 옮기지 않는 경우</span><span class="css-1kxrhf3">가 많았거든요. 그래서 리뷰 코멘트를 모아 반복되는 지적을 찾아내는 입구를 따로 만들었어요.</span></span></span></p><p id="e3b9231b-6f7d-458e-9a5d-7a7039fde234" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">실제로 이렇게 만들어진 규칙이 있어요. 한 서비스 레포의 PR 두 건에서 리뷰 코멘트를 묶었더니, 같은 지적이 반복되고 있었습니다.</span></span></span></p><p id="86320042-e1f2-49f4-a8a0-6aef9b14914a" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><code style="font-family:Consolas, Menlo, Monaco, source-code-pro, &quot;Courier New&quot;, monospace;font-size:0.9em;background-color:var(--adaptiveOpacity100);border:1px solid var(--adaptiveOpacity200);border-radius:3px"><span class="css-1mjnzsq">@dataclass</span></code><span class="css-1kxrhf3">는 ‘값을 담는 상자’라는 뜻입니다. 그런데 이 클래스는 캐시를 들고 있고 조회도 해요. </span><span class="css-q3ktjb">읽는 사람에게 값처럼 보이는데 실제로는 서비스</span><span class="css-1kxrhf3">인 거죠. 값인 줄 알고 복사해 쓰다가 상태가 어긋나기 좋습니다.</span></span></span></p><p id="8a9d3cf1-d4fc-4564-a413-f427a2e2ed3b" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">그래서 이렇게 바꾸자는 코멘트가 여러 PR에서 반복됐어요.</span></span></span></p><p id="985cb87c-2966-4fef-b4b9-c8f28d082b3d" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">이걸 규칙 하나로 옮겼어요.</span></span></span></p><p id="23de7d5b-7307-4b75-9e3b-5c95974fe593" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">여기서는 발동 조건에 </span><span class="css-q3ktjb">이름 접미사라는 문턱</span><span class="css-1kxrhf3">을 하나 둔 게 중요합니다. </span><code style="font-family:Consolas, Menlo, Monaco, source-code-pro, &quot;Courier New&quot;, monospace;font-size:0.9em;background-color:var(--adaptiveOpacity100);border:1px solid var(--adaptiveOpacity200);border-radius:3px"><span class="css-1mjnzsq">@dataclass</span></code><span class="css-1kxrhf3">만 보면 멀쩡한 값 홀더에 전부 뜨거든요. </span><code style="font-family:Consolas, Menlo, Monaco, source-code-pro, &quot;Courier New&quot;, monospace;font-size:0.9em;background-color:var(--adaptiveOpacity100);border:1px solid var(--adaptiveOpacity200);border-radius:3px"><span class="css-1mjnzsq">Checker</span></code><span class="css-1kxrhf3">, </span><code style="font-family:Consolas, Menlo, Monaco, source-code-pro, &quot;Courier New&quot;, monospace;font-size:0.9em;background-color:var(--adaptiveOpacity100);border:1px solid var(--adaptiveOpacity200);border-radius:3px"><span class="css-1mjnzsq">Service</span></code><span class="css-1kxrhf3">, </span><code style="font-family:Consolas, Menlo, Monaco, source-code-pro, &quot;Courier New&quot;, monospace;font-size:0.9em;background-color:var(--adaptiveOpacity100);border:1px solid var(--adaptiveOpacity200);border-radius:3px"><span class="css-1mjnzsq">Manager</span></code><span class="css-1kxrhf3">처럼 ‘동작하는 물건’임을 드러내는 이름이 같이 있을 때만 뜨게 했습니다.</span></span></span></p><p id="3cea360d-33e3-80b7-8349-d827258ecee8" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"></span></span></p><h3 id="bb7717ad-a9fa-4518-8bc2-464a268e3bf4" class="css-1feg9au"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">Stylepack Flywheel</span></span></span></h3><p id="4b8d6826-b65d-4ad4-9dff-f8733db27af9" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">마지막으로 Stylepack이 스스로 좋아지는 흐름을 한 장으로 보면 아래와 같아요.</span></span></span></p><figure id="3c7a360d-33e3-80b6-b38a-f868b5974a8a" class="css-of5acw"><img src="https://static.toss.im/ipd-tcs/toss_core/live/d344b08b-cb90-43e2-ab83-83968fbdd703/08-stylepack-flywheel.png" alt="" class="css-1pgssrp"/></figure><p id="3cda360d-33e3-80b9-a5d5-c3389daf7462" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"></span></span></p><h2 id="43c73053-d52d-469c-b478-102d86fe01af" class="css-1c1qox8"><span class="css-1r3ko7u"><span class="css-p4abj2"><span class="css-1kxrhf3">마무리</span></span></span></h2><p id="f91f0839-7765-4f18-86d6-9d4098741475" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">팀 컨벤션은 정하는 것도 중요하지만, 잘 지켜지게 만드는 게 더 중요합니다. Coding Agent에게는 코드를 고친 바로 그 순간에 피드백을 줘야 잘 작동한다는 것을 알게 됐죠. 이런 고민을 같이 하고 싶으시다면, 토스뱅크에 합류해 주세요.</span></span></span></p><p id="1a3de3ac-915e-44ce-af19-922559c87fd1" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">감사합니다.</span></span></span></p></div><div id="tds-mobile-portal-container"></div>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[2. Beyond Our Expertise]]></title>
            <link>https://toss.tech/article/technical-writing-2-eng</link>
            <guid isPermaLink="true">https://toss.tech/article/technical-writing-2-eng</guid>
            <pubDate>Mon, 07 Sep 2026 06:47:00 GMT</pubDate>
            <description><![CDATA[This is the story of how we turned Technical Writers’ challenges and expertise into a product called "todoc", and how the process helped us take that expertise even further.]]></description>
            <content:encoded><![CDATA[<link rel="preload" as="image" href="https://static.toss.im/ipd-tcs/toss_core/live/892b7e0c-fbd5-4ede-b504-8b24fbae7ff0/image.png"/><link rel="preload" as="image" href="https://static.toss.im/ipd-tcs/toss_core/live/594a6df6-489d-4ea7-9d53-ac87891d1867/mau_chart.png"/><link rel="preload" as="image" href="https://static.toss.im/ipd-tcs/toss_core/live/a9ba2c60-5098-4664-ac23-b00ff04b33c9/chat1_toppings_en.png"/><link rel="preload" as="image" href="https://static.toss.im/ipd-tcs/toss_core/live/4753c851-3cb4-488a-9608-9e602540a40b/chat2_facepayai_en.png"/><link rel="preload" as="image" href="https://static.toss.im/ipd-tcs/toss_core/live/e3fa0112-2f3f-4c74-b179-e153169a6094/chat3_tinkerbell_en.png"/><link rel="preload" as="image" href="https://static.toss.im/ipd-tcs/toss_core/live/a168144e-43f0-47b7-8b14-ce1335b73f5c/todoc_cycle_en.png"/><div class="css-1vn47db"><p id="3d4a360d-33e3-8038-8203-f7ba3577e4b0" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-82n2y3">*This is the English version of </span><a target="_blank" rel="noreferrer noopener" class="css-a6pp71" href="https://toss.tech/article/technical-writing-2">a previously published article</a><span class="css-82n2y3">.</span></span></span></p><h2 id="3d4a360d-33e3-8090-a09c-d06bca2c1b45" class="css-1c1qox8"><span class="css-1r3ko7u"><span class="css-p4abj2"><span class="css-q3ktjb">What It Means for a Technical Writer to Be a Product Owner</span></span></span></h2><p id="3d4a360d-33e3-80ac-b295-fa5708996667" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">Earlier, I broke down the work of Technical Writers (TWs) at Toss into four areas. The first was building products. But what does it actually mean for a TW to build a product? Let me start with my own experience.</span></span></span></p><p id="3d4a360d-33e3-8006-8400-eb5877661071" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">I currently lead a product team called the Knowledge System Team. I work alongside developers, designers, and TWs to set the direction of our products and decide on our roadmap and priorities. I interview users directly and bring those insights back to the team. I plan features and sometimes build them myself using AI tools. In other words, I’m not simply someone who communicates documentation-related requirements. I work as a </span><span class="css-1odxvuk">Maker </span><span class="css-1kxrhf3">who makes product decisions and helps build the product itself.</span></span></span></p><p id="3d4a360d-33e3-802e-82aa-e60d5f67b45f" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">So why are TWs building products?</span></span></span></p><p id="3d4a360d-33e3-8016-ae6c-fd3716b2fe6b" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">It comes down to what our product is fundamentally about: documentation and knowledge. TWs have spent years thinking deeply about questions like </span><span class="css-q3ktjb">why some documents are difficult to read</span><span class="css-1kxrhf3">, </span><span class="css-q3ktjb">what makes a good document</span><span class="css-1kxrhf3">, and </span><span class="css-q3ktjb">how information should be structured so that AI can use it effectively</span><span class="css-1kxrhf3">. Over time, we’ve developed our own standards around these questions. That puts us in a unique position to bring some of the most important insights into a product built around documentation and knowledge.</span></span></span></p><figure id="3d4a360d-33e3-80bc-859f-f7552dca561e" class="css-of5acw"><img src="https://static.toss.im/ipd-tcs/toss_core/live/892b7e0c-fbd5-4ede-b504-8b24fbae7ff0/image.png" alt="" class="css-1pgssrp"/></figure><p id="3d4a360d-33e3-808b-a076-d591c2102c7c" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">Our end goal is to turn TW expertise into a product that connects documentation with workflows, allowing knowledge to build naturally as people work and making it easy for anyone to find and use. And here’s the product we’re building toward that vision.</span></span></span></p><p id="3d4a360d-33e3-8002-8bbe-e5c06dc4f7a8" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"></span></span></p><h2 id="3d4a360d-33e3-80c6-a8a4-ee1ed0492bcf" class="css-1c1qox8"><span class="css-1r3ko7u"><span class="css-p4abj2"><span class="css-q3ktjb">todoc: A Product Built by Technical Writers</span></span></span></h2><p id="3d4a360d-33e3-8088-955e-f51e4c467fe2" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1odxvuk">How do we build a documentation system that remains effective over time and scales across an organization?</span></span></span></p><p id="3d4a360d-33e3-807c-9409-f265d31cc27a" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">Our search for an answer eventually led us to </span><span class="css-q3ktjb">todoc</span><span class="css-1kxrhf3">, our internal documentation platform, last October.</span></span></span></p><p id="3d4a360d-33e3-80e9-8aed-d1a47d439d71" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">As soon as we announced the beta, we were surprised by how many positive responses we received: “I want to try this right now,” and “This is exactly the documentation platform I’ve been waiting for.”</span></span></span></p><p id="3d4a360d-33e3-80b7-8a7e-cd0357acd243" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">We realized that the need was there across the board. Teams that were already documenting their work wanted an easier way to connect their knowledge to AI, while teams whose documentation was still fragmented needed an easier way to start building up team knowledge in the first place.</span></span></span></p><p id="3d4a360d-33e3-8007-a544-e8864e4ce99f" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">Six months later, todoc has accumulated more than 500 documents and 40,000 valid pages, with over 1,000 people using it every month.</span></span></span></p><figure id="3d4a360d-33e3-80f1-ad7f-feb39f158775" class="css-of5acw"><img src="https://static.toss.im/ipd-tcs/toss_core/live/594a6df6-489d-4ea7-9d53-ac87891d1867/mau_chart.png" alt="" class="css-1pgssrp"/></figure><p id="3d4a360d-33e3-8073-903c-ec0394a10f12" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">But why build a new product when we already had GitHub and other documentation tools? First of all, there were several problems with our existing internal documentation.</span></span></span></p><p id="3d4a360d-33e3-80a9-b858-c1b4f626e261" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">First, documentation built with static site generators (SSGs) was difficult for non-developers to use. Making a small edit meant cloning a repository, writing in Markdown, opening a pull request, and going through review. That workflow may feel natural to developers, but for designers or PMs, it could be enough friction to make them give up before they even start writing.</span></span></span></p><p id="3d4a360d-33e3-8074-a10a-cda9507f65c7" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">Second, our existing documentation tools had accumulated too many notes and too much “junk knowledge.” There were pages with no clear author, date or purpose; outdated documents that had long since changed; half-finished notes left behind and forgotten. You’ve probably seen this happen. Over time, all of this turns into documentation debt: a growing body of information that’s difficult to navigate, maintain, or trust.</span></span></span></p><p id="3d4a360d-33e3-8066-ac26-ef408a216042" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">Finally, knowledge was fragmented across whatever tools people happened to choose. Some lived in SSG-based documentation, some in other documentation tools, some in code, some in conversations on our internal messenger, and some only in people’s heads. Unless we could bring these fragmented sources together, we wouldn’t be able to build a reliable, usable body of organizational knowledge.</span></span></span></p><p id="3d4a360d-33e3-80fc-a897-e5da3d60a786" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">So we built todoc around four core principles.</span></span></span></p><p id="3d4a360d-33e3-80ea-ae10-cd93cfa3bc39" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-q3ktjb">1. Anyone should be able to write documents easily.</span></span></span></p><p id="3d4a360d-33e3-802a-966e-e6a17842c915" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">We removed as many barriers to documentation as possible. With todoc, anyone can create and edit documents directly. No matter where the information starts, whether it’s GitHub, another documentation tool, or our internal messenger, it can be connected to todoc.</span></span></span></p><p id="3d4a360d-33e3-8013-ab22-d2500586c8b0" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-q3ktjb">2. Knowledge in todoc should be easy to use with AI.</span></span></span></p><p id="3d4a360d-33e3-80b4-a9c1-dc7489a98e08" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">Ultimately, one of the biggest reasons to collect and maintain high-quality knowledge is so that AI can use it. With todoc, putting that knowledge to work with AI is easy. Teams can connect it to their own bots and freely access it through APIs, CLI tools, or MCP. Different teams are already using it in different ways, from powering request-handling bots to organizing product specifications.</span></span></span></p><figure id="3d4a360d-33e3-8053-9727-dd25dd1b977a" class="css-of5acw"><img src="https://static.toss.im/ipd-tcs/toss_core/live/a9ba2c60-5098-4664-ac23-b00ff04b33c9/chat1_toppings_en.png" alt="" class="css-1pgssrp"/></figure><figure id="3d4a360d-33e3-805d-bc7b-dcc80108b178" class="css-of5acw"><img src="https://static.toss.im/ipd-tcs/toss_core/live/4753c851-3cb4-488a-9608-9e602540a40b/chat2_facepayai_en.png" alt="" class="css-1pgssrp"/></figure><figure id="3d4a360d-33e3-80a2-82ca-f1100e288058" class="css-of5acw"><img src="https://static.toss.im/ipd-tcs/toss_core/live/e3fa0112-2f3f-4c74-b179-e153169a6094/chat3_tinkerbell_en.png" alt="" class="css-1pgssrp"/></figure><p id="3d4a360d-33e3-8046-b8eb-f5d889894ccc" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-q3ktjb">3. All internal knowledge should come together in one place, creating a Single Source of Truth.</span></span></span></p><p id="3d4a360d-33e3-801f-b054-e69533065264" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">todoc connects fragmented sources and turns them into complete, usable documents, so people no longer have to wonder, “Where am I supposed to find this?” Because there is one place to determine which information is current and still valid, todoc can serve as the organization’s Single Source of Truth (SSoT).</span></span></span></p><p id="3d4a360d-33e3-808a-872b-f18c1eb13fb0" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-q3ktjb">4. The system should be built to scale.</span></span></span></p><p id="3d4a360d-33e3-8056-a855-fbc2ef1fb8a0" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">When a team wants to start documenting its work, it shouldn’t have to choose a new tool or build its own infrastructure. It should be able to start on top of todoc, which is already up and running. Each team is given its own documentation space within one platform.</span></span></span></p><p id="3d4a360d-33e3-80dc-87db-f9717543101c" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">Previously, every team or chapter had to choose its own tools, set up infrastructure, and spend time maintaining it. With todoc, team members can focus solely on the documentation itself. And we’re now expanding this structure to Toss affiliates as well.</span></span></span></p><p id="3d4a360d-33e3-8067-a88a-e7f0c259271f" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"></span></span></p><h2 id="3d4a360d-33e3-8062-857f-fb8590dbc003" class="css-1c1qox8"><span class="css-1r3ko7u"><span class="css-p4abj2"><span class="css-q3ktjb">What We Still Need to Solve</span></span></span></h2><p id="3d4a360d-33e3-80ba-a4fa-f27249d0f54c" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">We’ve lowered the barrier to documentation, but the easier it becomes to create documentation, the harder it becomes to maintain its quality.</span></span></span></p><p id="3d4a360d-33e3-803b-9c66-e8a2679ccd6b" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">This is something TWs have thought about for a long time.</span></span></span></p><p id="3d4a360d-33e3-80a3-a635-d65a0b00d2d1" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">In the past, we addressed it manually. TWs reviewed documents themselves and checked whether documentation had become outdated. In todoc, we’ve started automating that work by translating the standards TWs have developed for “good documentation” into the product itself. For example, we provide AI-powered editing as well as bots that can automatically generate first drafts.</span></span></span></p><p id="3d4a360d-33e3-8045-9f14-f63bd9f74bdc" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">But people still had to make time to sit down and write things down. After seeing how people actually use todoc, I realized that wasn’t enough.</span></span></span></p><p id="3d4a360d-33e3-805b-8d2c-cc6b7744587b" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">Deciding “I’m going to document this” and then turning it into a finished document takes significant time and effort. So instead, we’re designing todoc so that documents take shape naturally as the work happens.</span></span></span></p><p id="3d4a360d-33e3-802d-8617-fb9826068bcf" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">todoc can turn decisions, discussions, and code from our internal workflows into documentation automatically. We’re also building systems that monitor code changes and discussions that happen after decisions are made, so documents can stay up to date without depending on someone remembering to maintain them.</span></span></span></p><figure id="3d4a360d-33e3-8045-b3e4-ed553685b1b7" class="css-of5acw"><img src="https://static.toss.im/ipd-tcs/toss_core/live/a168144e-43f0-47b7-8b14-ce1335b73f5c/todoc_cycle_en.png" alt="" class="css-1pgssrp"/></figure><p id="3d4a360d-33e3-8002-9a1c-d49d84987da5" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">And documentation isn’t just about collecting information or generating text. The real challenge is determining what knowledge is still valid.</span></span></span></p><p id="3d4a360d-33e3-8012-923c-d1f2d8756e58" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">Does a written policy match what has actually been implemented in code? Is this piece of knowledge still being used? How recently was it updated? These are the kinds of problems we’re now solving with todoc.</span></span></span></p><p id="3d4a360d-33e3-808c-ae3b-f6d5656a9e8e" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"></span></span></p><h2 id="3d4a360d-33e3-8012-b75f-fd0a2a301472" class="css-1c1qox8"><span class="css-1r3ko7u"><span class="css-p4abj2"><span class="css-q3ktjb">How TW Expertise Is Expanding and Shifting</span></span></span></h2><p id="3d4a360d-33e3-801b-add1-f47914f9d435" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">In the past, one of the most important skills for a TW was the ability to write a good document. Now, the more important skill is the ability to design systems that consistently produce good documentation.</span></span></span></p><p id="3d4a360d-33e3-80ee-baa9-c2b6f3a68f7e" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">The expertise TWs have built up isn’t disappearing. It’s being embedded into systems in new ways.</span></span></span></p><ul class="css-uswsmm"><li id="3d4a360d-33e3-8021-aaa9-d14d2e5e76a2" class="css-1hwiibq"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">Our experience asking, “Why are some documents so hard to read?” has become a standard for </span><span class="css-q3ktjb">documentation that anyone can write and AI can easily understand</span><span class="css-1kxrhf3">.</span></span></span></li><li id="3d4a360d-33e3-8024-a957-dc73a9493c43" class="css-1hwiibq"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">Our judgment of what makes a good document has now become the </span><span class="css-q3ktjb">foundation for AI-powered editing and automated reviews</span><span class="css-1kxrhf3">.</span></span></span></li><li id="3d4a360d-33e3-8025-9e79-c84e92a6b6a3" class="css-1hwiibq"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">Our ability to identify outdated documents and determine what information is still valid has become </span><span class="css-q3ktjb">a system for identifying knowledge that can still be trusted today</span><span class="css-1kxrhf3">.</span></span></span></li></ul><p id="3d4a360d-33e3-804c-bac3-efaf9056898e" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">Within this shift, what we need to do is clear:</span></span></span></p><aside id="3d4a360d-33e3-8056-8c0d-fee9d6e6c763" class="css-nv7vyi"><div class="css-1vn47db"><ol class="css-hokoge"><li id="3d4a360d-33e3-80ce-8aec-e1169087510c" class="css-1hwiibq"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">Create a place where fragmented knowledge can come together</span></span></span></li><li id="3d4a360d-33e3-805f-89df-f27c71df8e31" class="css-1hwiibq"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">Define “good documentation”</span></span></span></li><li id="3d4a360d-33e3-8095-9a9d-fa81504c5322" class="css-1hwiibq"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">Embed those standards into systems</span></span></span></li><li id="3d4a360d-33e3-806f-8025-e8419a4958d8" class="css-1hwiibq"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">Make documentation build up naturally from work</span></span></span></li><li id="3d4a360d-33e3-80a5-8963-fdbbfbdf7ec0" class="css-1hwiibq"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">Build on top of that so knowledge can keep itself up to date</span></span></span></li></ol></div></aside><hr class="css-1ifza5r"/><p id="3d4a360d-33e3-8084-988c-f72cbe92f26a" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">Once todoc has solved all of these problems, this is what I imagine my day looking like as both a product team lead and a TW.</span></span></span></p><blockquote id="3d4a360d-33e3-807d-b044-f85500087641" class="css-2sk6rv"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-q3ktjb">I no longer have to post reminders asking people to review documents that haven&#x27;t been updated in over a month. todoc is detecting outdated documents, notifying the right owners, and suggesting improvements. 

Documentation stays trustworthy without anyone having to constantly look after it. I no longer have to manually update the project management tools. Everything the teams produced along the way had already been captured and organized. I can spend my time thinking only about what we should do next. 

Release announcements and answers to recurring questions are all captured and preserved. New team members, and teams integrating with us for the first time, no longer have to repeat the same questions over and over again. Every time someone does a piece of work, it leaves behind knowledge that helps everyone else move faster.

I would open my laptop, and quietly close it again, because there was nothing left for me to do, as todoc is running perfectly on its own.</span></span></span></blockquote><p id="3d4a360d-33e3-80aa-8c6b-f2765bc0a0dc" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">This is where Toss’s Technical Writing Chapter is headed: turning TW expertise, and the standards behind that expertise, into systems.</span></span></span></p><p id="3d4a360d-33e3-80b3-879e-c4132133ee66" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">Beyond that, we want to put systems in place that enable every organization to document its work well without relying on TWs to manage it.</span></span></span></p><p id="3d4a360d-33e3-806c-b489-f3f50b85990a" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">And once that system is in place, we’ll move on to the next problem.</span></span></span></p></div><div id="tds-mobile-portal-container"></div>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[1. Creating a Role That Didn’t Exist Before]]></title>
            <link>https://toss.tech/article/technical-writing-1-eng</link>
            <guid isPermaLink="true">https://toss.tech/article/technical-writing-1-eng</guid>
            <pubDate>Mon, 07 Sep 2026 06:44:00 GMT</pubDate>
            <description><![CDATA[Learn how Toss’s Technical Writers have expanded their role from writing documentation to designing knowledge systems for the organization.]]></description>
            <content:encoded><![CDATA[<link rel="preload" as="image" href="https://static.toss.im/ipd-tcs/toss_core/live/98adff3e-bbbb-4fe1-bd41-ccb59601b295/image.png"/><link rel="preload" as="image" href="https://static.toss.im/ipd-tcs/toss_core/live/9a776a33-f3eb-46b4-8b32-ba5fb55f2e5f/dark2_usefunnel_en.png"/><link rel="preload" as="image" href="https://static.toss.im/ipd-tcs/toss_core/live/be009018-121e-45d4-ab5d-0c4ef8d27b73/image.png"/><link rel="preload" as="image" href="https://static.toss.im/ipd-tcs/toss_core/live/e0c1b4f6-2e4a-40e0-b138-83f1b43ab971/image.png"/><link rel="preload" as="image" href="https://static.toss.im/ipd-tcs/toss_core/live/d26b7311-8ee8-439e-8114-1460e44418c9/image.png"/><div class="css-1vn47db"><p id="3d4a360d-33e3-80e2-a7b9-d7675e16b869" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-82n2y3">*This is the English version of </span><a target="_blank" rel="noreferrer noopener" class="css-a6pp71" href="https://toss.tech/article/technical-writing-1">a previously published article</a><span class="css-82n2y3">.</span></span></span></p><p id="3d4a360d-33e3-8012-8f56-d99045e9e9d7" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">Hi, I’m Juyeon Han, Technical Writing Chapter Lead at Toss. In this series, I’ll share how Technical Writers (TWs) at Toss have expanded our role from simply writing documentation to designing the systems that manage knowledge across the organization.</span></span></span></p><hr class="css-1ifza5r"/><p id="3d4a360d-33e3-8079-aeb9-e7c29e677d2f" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">“Why is this built this way?”</span></span></span></p><p id="3d4a360d-33e3-8028-a949-e1d628ea6a56" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">It’s the kind of question you hear, and ask, all the time at work. Maybe you track down the person who wrote the code and try to piece together what they remember. Maybe you dig through a year-old thread for context. And sometimes, there’s simply no one left to ask.</span></span></span></p><p id="3d4a360d-33e3-8057-a765-ce0543853723" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">As we rely more on AI, this problem becomes even harder to ignore. AI may know a lot about the world, but it doesn’t know the context and history unique to your organization. So every time you ask it to do something, you have to fill in the gaps.</span></span></span></p><p id="3d4a360d-33e3-808d-bd05-cd0fc48b0b5b" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">People often say that code is the Single Source of Truth (SSoT). But code only preserves the </span><span class="css-1odxvuk">outcome</span><span class="css-1kxrhf3">. It can tell you what something does, but not necessarily why it was built that way or what decisions led to it. Most of us have probably stared at uncommented code at some point, trying to figure out how we got there.</span></span></span></p><p id="3d4a360d-33e3-80a5-9229-ec2fc98d2aaf" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">That’s why I believe documentation matters as much as ever. </span><span class="css-q3ktjb">A true SSoT is only complete when you have both the code and the context surrounding it</span><span class="css-1kxrhf3">.</span></span></span></p><p id="3d4a360d-33e3-8027-ab67-e11efde4ac26" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">Ultimately, we’re the ones who have to explain why the code is the way it is. If we want AI to work with what we know, we first have to capture that knowledge somewhere. And that’s the gap the Technical Writing Chapter has been working to fill.</span></span></span></p><p id="3d4a360d-33e3-8097-b804-eb04e6a83166" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">But our job isn’t simply to write documents that fill in the missing context. Looking at what TWs do today, the role has changed significantly since I first joined Toss five years ago.</span></span></span></p><figure id="3d4a360d-33e3-802f-bbd5-cc753f5faa47" class="css-of5acw"><img src="https://static.toss.im/ipd-tcs/toss_core/live/98adff3e-bbbb-4fe1-bd41-ccb59601b295/image.png" alt="" class="css-1pgssrp"/></figure><p id="3d4a360d-33e3-803d-beb4-f6a826cc0373" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">Our goals have changed, too. This year, we’re working toward a system where knowledge builds up on its own. And </span><span class="css-q3ktjb">the ultimate goal of the Technical Writing Chapter is to make ourselves unnecessary</span><span class="css-1kxrhf3">.</span></span></span></p><p id="3d4a360d-33e3-8050-a079-ded1ae6cab40" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">When I first thought about how AI might change our work, I assumed Technical Writers would be among the first to be replaced. But I couldn’t have been more wrong. If anything, our work has expanded. So how did we get from writing technical docs to designing knowledge systems, with the ultimate goal of making ourselves unnecessary?</span></span></span></p><p id="3d4a360d-33e3-8018-ab8e-ed467e1bcd23" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"></span></span></p><h2 id="3d4a360d-33e3-8062-98fc-f81ec048f295" class="css-1c1qox8"><span class="css-1r3ko7u"><span class="css-p4abj2"><span class="css-q3ktjb">From Writing Documents to Bringing Knowledge to People</span></span></span></h2><p id="3d4a360d-33e3-804c-af87-d0ad16830c03" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">When I first started working on internal documentation at Toss, one of my first projects was creating onboarding documents for our Frontend Chapter. But as I worked on internal docs, something kept bothering me. To me, the goal of Technical Writing isn’t to produce great writing. It’s to </span><span class="css-q3ktjb">help readers solve problems and accomplish what they need to do</span><span class="css-1kxrhf3">. And no matter how good a document is, people rarely read it from beginning to end. They look for the one thing they need. I do the same. So I started thinking: instead of writing a document and sending people a link, how could we make sure the knowledge inside it actually gets used?</span></span></span></p><p id="3d4a360d-33e3-80f9-b187-d839dd7676cb" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">That question led to </span><a target="_blank" rel="noreferrer noopener" class="css-iynyr0" href="https://toss.tech/article/toss-frontend-ai-docs">Mr. Park (박씨)</a><span class="css-1kxrhf3">, a chatbot we built. (*Refer to </span><a target="_blank" rel="noreferrer noopener" class="css-iynyr0" href="https://www.youtube.com/watch?v=zlXsomDPQ_U">TMC25</a><span class="css-1kxrhf3">) It lets people ask questions conversationally from the messaging tools and IDEs they already use, then answers based on our existing documentation, with sources included.</span></span></span></p><p id="3d4a360d-33e3-80b1-a01e-e7d85c73f683" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">Instead of asking people to go find the documentation, we brought the documentation to them.</span></span></span></p><figure id="3d4a360d-33e3-80c1-a42a-d9e75236d619" class="css-of5acw"><img src="https://static.toss.im/ipd-tcs/toss_core/live/9a776a33-f3eb-46b4-8b32-ba5fb55f2e5f/dark2_usefunnel_en.png" alt="" class="css-1pgssrp"/></figure><p id="3d4a360d-33e3-805c-b1ec-ee36e9ff15f5" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">What happened next was interesting.</span></span></span></p><p id="3d4a360d-33e3-8054-a6ac-eb97c00d41cc" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">People who had never been particularly interested in writing (or reading) documentation started coming to us.</span></span></span></p><p id="3d4a360d-33e3-80c6-aeff-c0785f95ecac" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">“We want something like this for our chapter.” “Could we build this for our organization too?”</span></span></span></p><p id="3d4a360d-33e3-80a2-a0e1-c49358ec67f0" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">As Toss grew, people were getting tired of getting the same questions over and over. They wanted a way to retrieve institutional and tacit knowledge from a system.</span></span></span></p><p id="3d4a360d-33e3-80f0-943d-def74ec12a93" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">At the same time, rapid advances in AI were making documentation more important than ever. If we wanted AI to understand our work and actually be useful, we first had to give it the knowledge and context to work with.</span></span></span></p><p id="3d4a360d-33e3-80f7-8ce9-d28e57c3c40a" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">Documentation had long been treated as a chore. Suddenly, everyone wanted it.</span></span></span></p><p id="3d4a360d-33e3-802b-a0ec-dd28907dbc03" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">It was fascinating to watch. And amid that shift, I started building a knowledge system that everyone at Toss could use. I’ll cover that product in more detail in the next article.</span></span></span></p><figure id="3d4a360d-33e3-80f9-845a-ee37f8989781" class="css-of5acw"><img src="https://static.toss.im/ipd-tcs/toss_core/live/be009018-121e-45d4-ab5d-0c4ef8d27b73/image.png" alt="" class="css-1pgssrp"/></figure><p id="3d4a360d-33e3-8040-b23f-dbf25aed8b90" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"></span></span></p><h2 id="3d4a360d-33e3-8037-8684-d37c2ff81fb2" class="css-1c1qox8"><span class="css-1r3ko7u"><span class="css-p4abj2"><span class="css-q3ktjb">From Documentation to Knowledge Systems</span></span></span></h2><p id="3d4a360d-33e3-80cb-9781-dbe7fda8bd82" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">That was when we began to push beyond the traditional boundaries of Technical Writing. We realized our role could no longer stop at documentation. We needed to become people who design knowledge systems tailored to the needs of different organizations. And as AI takes on more and more work, capturing the knowledge and tacit context scattered across the organization has become more important than ever.</span></span></span></p><p id="3d4a360d-33e3-8019-a216-dd5fdf6d45bc" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">A well-designed knowledge system makes the entire organization more productive while helping every team member get up to speed quickly. As organizations grow, questions multiply and communication becomes increasingly costly. </span><span class="css-q3ktjb">A strong knowledge system is critical for maintaining both the speed and quality of how we work</span><span class="css-1kxrhf3">.</span></span></span></p><aside id="3d4a360d-33e3-8033-b1eb-d528d4061e70" class="css-nv7vyi"><h4 class="css-123co55"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-q3ktjb">Knowledge </span><span class="css-1kxrhf3">is verified information that helps an individual or organization understand a problem or situation, make better decisions, and take action. Information only becomes knowledge when it has been validated within that context.</span></span></span></h4><div class="css-1vn47db"><p id="3d4a360d-33e3-807c-aa20-c02ba75cda7d" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">A </span><span class="css-q3ktjb">knowledge system</span><span class="css-1kxrhf3"> is the structure that brings that knowledge together and delivers it to the people, and AI, that need it, when they need it. Knowledge scattered across code, conversations, deployments, and other sources is brought together into a Single Source of Truth. It’s then structured so that it can be understood not only by people but also by AI, and immediately put to use through questions, workflows, and automation.</span></span></span></p><figure id="3d4a360d-33e3-8017-abc2-e524770322a6" class="css-of5acw"><img src="https://static.toss.im/ipd-tcs/toss_core/live/e0c1b4f6-2e4a-40e0-b138-83f1b43ab971/image.png" alt="" class="css-1pgssrp"/></figure></div></aside><p id="3d4a360d-33e3-80ed-a83f-f1fa5ef3c2ea" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">Today, the work of the Technical Writing Chapter at Toss falls into four main areas.</span></span></span></p><p id="3d4a360d-33e3-802f-bf6c-d98907511969" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">First, we </span><span class="css-q3ktjb">build products</span><span class="css-1kxrhf3">. We lead the team that builds and operates todoc, our internal knowledge management platform.</span></span></span></p><p id="3d4a360d-33e3-80ec-bd20-e6d637bcf6eb" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">Second, we </span><span class="css-q3ktjb">embed ourselves in different organizations and lead their documentation efforts</span><span class="css-1kxrhf3">. We bring together scattered knowledge in ways tailored to each organization and make that knowledge as useful as possible. There’s no one-size-fits-all knowledge system, so we adapt our approach to each organization.</span></span></span></p><p id="3d4a360d-33e3-80bd-acd3-df3721f78c3f" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">Third, we </span><span class="css-q3ktjb">eliminate the manual Technical Writing work</span><span class="css-1kxrhf3">. Through AI workflows and automation, we’re building ways for anyone to create and review documentation at a consistently high level.</span></span></span></p><figure id="3d4a360d-33e3-803e-a6ec-fa6c491dea87" class="css-of5acw"><video muted="" loop="" autoPlay="" playsInline="" class="css-8atqhb"><source src="https://static.toss.im/ipd-tcs/toss_core/live/510d08e2-56f1-4e35-8a19-1a7aa1243564/화면_기록_2026-02-04_오전_11.36.39_(1)_(1).mp4"/></video></figure><figure id="3d4a360d-33e3-807a-ae7e-fcb6644c3d90" class="css-of5acw"><img src="https://static.toss.im/ipd-tcs/toss_core/live/d26b7311-8ee8-439e-8114-1460e44418c9/image.png" alt="" class="css-1pgssrp"/></figure><p id="3d4a360d-33e3-8061-97f5-f14544ec5479" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">Finally, we </span><span class="css-q3ktjb">build a culture of documentation across Toss</span><span class="css-1kxrhf3">. We run community-wide sessions on topics such as how to write documentation that AI can understand effectively. We also run documentation guilds and knowledge committees within individual organizations, changing how knowledge is captured and managed throughout Toss.</span></span></span></p><p id="3d4a360d-33e3-8017-927a-f6375b31d9cf" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-q3ktjb">What We’ll Cover in This Series</span></span></span></p><p id="3d4a360d-33e3-80c3-8f90-cfa1ca9796ad" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">Here’s what we’ll explore in the rest of the series:</span></span></span></p><aside id="3d4a360d-33e3-8088-90a7-e38e707c4245" class="css-nv7vyi"><div class="css-1vn47db"><ul class="css-uswsmm"><li id="3d4a360d-33e3-802f-a4cf-e4fc37b5d7ed" class="css-1hwiibq"><span class="css-1r3ko7u"><span class="css-1iisb9p"><a target="_blank" rel="noreferrer noopener" class="css-wi4a2c" href="https://toss.tech/article/50655">Beyond Our Expertise</a><span class="css-1kxrhf3">: How our expertise in Technical Writing found its way into todoc, the knowledge product we built.</span></span></span></li><li id="3d4a360d-33e3-80e8-966a-cf5e352c6e68" class="css-1hwiibq"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-q3ktjb">Why Documentation Never Seems to Stick / Same Problem, Different Solutions (Korean version only): </span><span class="css-1kxrhf3">In two interviews, we’ll share how we approached a problem nearly every company faces: knowledge that never seems to accumulate, and what we learned along the way.</span></span></span></li><li id="3d4a360d-33e3-8054-b129-ce2e0cc89dff" class="css-1hwiibq"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-q3ktjb">Technical Writers, Planning Our Own Disappearance (Korean version only): </span><span class="css-1kxrhf3">Can Technical Writing be automated? We’ll share what we’ve built so far.</span></span></span></li><li id="3d4a360d-33e3-8077-87df-d596dc3d99a2" class="css-1hwiibq"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-q3ktjb">Becoming Knowledge Experts (Korean version only): </span><span class="css-1kxrhf3">Explore our ultimate goal of enabling organizations to create and manage knowledge on their own, without Technical Writers.</span></span></span></li></ul></div></aside><p id="3d4a360d-33e3-802b-add5-c43e7ba9bfb5" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">Our job title is still “Technical Writer.” But the work we actually do today is much closer to designing an organization’s knowledge infrastructure. We didn’t set out to redefine the role. We simply followed the problems that needed solving, and ended up redrawing the boundaries of the job along the way. Before we knew it, we had created a role that didn’t quite exist anywhere else in the industry.</span></span></span></p><p id="3d4a360d-33e3-809e-b412-d60f52b6a56a" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">And this shift isn’t unique to Technical Writing. AI is redefining the work of every profession. In such an environment, anyone can expand the boundaries of their role. Our chapter started with one person. Now there are three of us, and we’re looking for more people to join us. Over the course of this series, we’ll share how we got here, one story at a time.</span></span></span></p></div><div id="tds-mobile-portal-container"></div>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[How Documents Find Developers at Toss]]></title>
            <link>https://toss.tech/article/toss-frontend-ai-docs-eng</link>
            <guid isPermaLink="true">https://toss.tech/article/toss-frontend-ai-docs-eng</guid>
            <pubDate>Mon, 07 Sep 2026 06:27:00 GMT</pubDate>
            <description><![CDATA[The Toss Frontend team no longer has to dig through documents to find what they need. We’re building an environment where developers can get immediate answers to their questions through docs and AI. Here’s how we built a new documentation system that lets developers focus entirely on development.]]></description>
            <content:encoded><![CDATA[<link rel="preload" as="image" href="https://static.toss.im/ipd-tcs/toss_core/live/6b632e1d-b0b0-46ad-902c-2bb9a77fc065/dark1_clonedeep_en.png"/><link rel="preload" as="image" href="https://static.toss.im/ipd-tcs/toss_core/live/41bbec49-8771-422a-b095-8d771947631d/dark2_usefunnel_en.png"/><link rel="preload" as="image" href="https://static.toss.im/ipd-tcs/toss_core/live/a5a6bf90-af43-4920-9480-8d65c0e3aefa/dark3_fesillok_en.png"/><div class="css-1vn47db"><p id="3d4a360d-33e3-8067-80a7-cc0d77426e62" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-82n2y3">*This is the English version of </span><a target="_blank" rel="noreferrer noopener" class="css-a6pp71" href="https://toss.tech/article/technical-writing-2">a previously published article</a><span class="css-82n2y3">.</span></span></span></p><p id="3d4a360d-33e3-80fc-9418-f3c10da2b1f8" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">Every developer has been here. You&#x27;re deep in building a new feature, thirty tabs open, and suddenly you need to look up how an internal tool works… except you can&#x27;t even remember where the documentation is. So you ping a teammate and wait. Just like that, your flow is gone. Ever had the same experience?</span></span></span></p><p id="3d4a360d-33e3-80db-afcc-c5a9482b0a79" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">It was a problem our Frontend Chapter knew all too well. So we tried a different approach: instead of making documentation better to find, we made it come to developers.</span></span></span></p><p id="3d4a360d-33e3-808c-9e7d-e6a816e2b03d" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"></span></span></p><h2 id="3d4a360d-33e3-807a-9600-fdb14e190bf7" class="css-1c1qox8"><span class="css-1r3ko7u"><span class="css-p4abj2"><span class="css-q3ktjb">Breaking Path Dependence in Documentation</span></span></span></h2><p id="3d4a360d-33e3-80d3-9046-cd0f52499e95" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">Documentation path dependency is the constraint that forces users to follow a predetermined route just to reach the information they need. Traditional technical docs are structured from the writer&#x27;s perspective, which means finding exactly what you&#x27;re looking for often takes multiple searches. To break that pattern, we began observing developers in their natural work environment and interviewing them about how they actually get things done.</span></span></span></p><p id="3d4a360d-33e3-80c7-a5af-ca4209af7546" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">The dominant way developers got knowledge was simply by “asking.” A tap on the shoulder, a quick Slack message to a teammate who might know the answer. It&#x27;s natural, and honestly, makes a lot of sense. Searching through pages of documentation takes time and effort that a thirty-second conversation doesn&#x27;t. So rather than fight that instinct, we leaned into it.</span></span></span></p><p id="3d4a360d-33e3-80b8-93ed-f4eebd5bb78d" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">We asked ourselves what it would take to give documentation that same immediacy, to make it respond like a knowledgeable colleague rather than a reference manual. Documentation that answers back, fetching the right context for the exact question being asked, is documentation worth using.</span></span></span></p><p id="3d4a360d-33e3-80e9-ac41-e523b329c08f" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">And that’s how Mr. Park was created.</span></span></span></p><p id="3d4a360d-33e3-801e-add0-c2a51963a9fc" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"></span></span></p><h2 id="3d4a360d-33e3-80b2-8b35-e5b4198b81b5" class="css-1c1qox8"><span class="css-1r3ko7u"><span class="css-p4abj2"><span class="css-q3ktjb">Mr. Park: Talk to your Docs</span></span></span></h2><ul class="css-uswsmm"><li id="3d4a360d-33e3-8044-8e98-cf0b64dd559b" class="css-1hwiibq"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-q3ktjb">How it works</span><span class="css-1kxrhf3">: Ask a question and get an answer straight from the docs</span></span></span></li><li id="3d4a360d-33e3-80ef-8477-ec5a9f2fa91e" class="css-1hwiibq"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-q3ktjb">Where to find it</span><span class="css-1kxrhf3">: IDE (VSCode, Cursor) and on the company’s messenger</span></span></span></li><li id="3d4a360d-33e3-80b5-a530-c8d8e169bcad" class="css-1hwiibq"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-q3ktjb">What it does</span><span class="css-1kxrhf3">: Find information through “conversation” not “navigation”</span></span></span></li><li id="3d4a360d-33e3-80e2-9b43-ec040edd7644" class="css-1hwiibq"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-q3ktjb">Fun fact</span><span class="css-1kxrhf3">: Mr. Park was named after the persona of Sojin Park, the Head of the Frontend Chapter</span></span></span></li></ul><figure id="3d4a360d-33e3-8003-8895-cc5dc2aad558" class="css-of5acw"><img src="https://static.toss.im/ipd-tcs/toss_core/live/6b632e1d-b0b0-46ad-902c-2bb9a77fc065/dark1_clonedeep_en.png" alt="" class="css-1pgssrp"/></figure><figure id="3d4a360d-33e3-8082-811d-f5c837e5519c" class="css-of5acw"><img src="https://static.toss.im/ipd-tcs/toss_core/live/41bbec49-8771-422a-b095-8d771947631d/dark2_usefunnel_en.png" alt="" class="css-1pgssrp"/></figure><p id="3d4a360d-33e3-8048-b8e4-e2c82351d85f" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">Mr. Park is a </span><a target="_blank" rel="noreferrer noopener" class="css-iynyr0" href="https://aws.amazon.com/ko/what-is/retrieval-augmented-generation/">RAG(Retrieval-Augmented Generation)</a><span class="css-1kxrhf3"> based chatbot built to make LLM responses more accurate and trustworthy. It draws from existing documents to deliver reliable answers, always with sources attached. Developers can ask questions naturally and get what they need without ever opening a doc or running a search. The answers are more precise than what you&#x27;d get from a teammate, and consistent no matter who&#x27;s asking.</span></span></span></p><p id="3d4a360d-33e3-80c3-a9e6-c96cfd5680e8" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">For Mr. Park to give quality answers, it needs a solid knowledge base to draw from. So we&#x27;ve put serious effort into improving both the quantity and quality of our documentation. But technical writers and a handful of developers alone couldn&#x27;t produce enough coverage. For this system to scale, we needed an easy way to document an individual’s knowledge. That&#x27;s what led us to build Sillokbot, a documentation automation tool.</span></span></span></p><p id="3d4a360d-33e3-804c-a71e-c51b1fb1375c" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"></span></span></p><h2 id="3d4a360d-33e3-80e3-9954-f495d9046d3b" class="css-1c1qox8"><span class="css-1r3ko7u"><span class="css-p4abj2"><span class="css-q3ktjb">Sillokbot: Auto-document your conversations</span></span></span></h2><ul class="css-uswsmm"><li id="3d4a360d-33e3-80c5-9475-d7092725b5b0" class="css-1hwiibq"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-q3ktjb">What it does</span><span class="css-1kxrhf3">: Automatically documents key information from company messenger conversations</span></span></span></li><li id="3d4a360d-33e3-8070-9877-c6239e2b4641" class="css-1hwiibq"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-q3ktjb">How it works</span><span class="css-1kxrhf3">: React to a thread with the Sillokbot emoji, or call the bot directly. It analyzes the conversation, summarizes it using AI, and opens a PR.</span></span></span></li><li id="3d4a360d-33e3-8010-bda4-db15560a7340" class="css-1hwiibq"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-q3ktjb">How it helps</span><span class="css-1kxrhf3">: Knowledge gets captured automatically as problems get solved, with no one having to sit down and write a separate document</span></span></span></li></ul><figure id="3d4a360d-33e3-8018-bc51-ec6a16f31775" class="css-of5acw"><img src="https://static.toss.im/ipd-tcs/toss_core/live/a5a6bf90-af43-4920-9480-8d65c0e3aefa/dark3_fesillok_en.png" alt="" class="css-1pgssrp"/></figure><p id="3d4a360d-33e3-801b-b4b6-fa4c62c11641" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">Sillokbot automatically summarizes the important conversations happening within the team. The thing is, developers have already been exchanging knowledge on the company messenger all along, answering questions and talking through problems. The trouble is those conversations disappear fast, and the same questions keep coming back up. Sillokbot steps in to capture those threads, summarizes them, and opens a PR to the documentation repository, so useful discussions get preserved with almost no extra effort.</span></span></span></p><p id="3d4a360d-33e3-8049-b83d-d9a06aaf6f64" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">Say a developer realizes that a bug-fixing thread was particularly useful. They call Sillokbot on it, and just like that, the whole discussion becomes a document that Mr. Park learns from. The next person with the same question gets an answer without having to open the same discussion. Documentation stops being a separate chore and starts happening naturally, at the exact moment knowledge gets shared.</span></span></span></p><p id="3d4a360d-33e3-8013-9a18-cb99a3d76fba" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"></span></span></p><h2 id="3d4a360d-33e3-807c-946a-e8972bf95e73" class="css-1c1qox8"><span class="css-1r3ko7u"><span class="css-p4abj2"><span class="css-q3ktjb">Knowledge that Flows</span></span></span></h2><p id="3d4a360d-33e3-807e-91de-cfa0f38fbaa5" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">Thanks to Mr. Park and Sillokbot, knowledge at the Toss Frontend Chapter no longer pools around a single person, and the same questions have stopped making the rounds on the team. The right information reaches people at the right moment, and knowledge moves the way it should. That&#x27;s had a direct impact on the team&#x27;s productivity and efficiency. The bet paid off. By observing how developers actually work and treating technical documentation as a </span><span class="css-q3ktjb">system for learning and problem-solving</span><span class="css-1kxrhf3"> rather than a static collection of information, we got real results.</span></span></span></p><p id="3d4a360d-33e3-803c-b288-f2a9309b8061" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">Technical writing has evolved alongside all of this. The old focus was on structuring information and explaining features well from the writer&#x27;s perspective. Now the focus is on building a system that actually puts documentation to work for developers. Because at the end of the day, technical writing has always been about making documentation genuinely useful for developers.</span></span></span></p><p id="3d4a360d-33e3-800f-b46a-dc90ad2a3b4a" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">The Toss Frontend Platform Team is looking for people to join us in building a learning infrastructure where knowledge is easy to share and access. There&#x27;s a lot ahead, like pushing the AI-powered documentation system further, building documentation automation that connects naturally with code, and creating a documentation ecosystem that works seamlessly across the company messenger and other platforms.</span></span></span></p><p id="3d4a360d-33e3-809f-8633-e38afec06a64" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">Join us if building an environment where developers do their best work sounds like your kind of challenge.</span></span></span></p><p id="3d4a360d-33e3-80cb-bde5-f7f0f8b01521" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"></span></span></p></div><div id="tds-mobile-portal-container"></div>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[AI가 만든 코드가 어드민이 되기까지]]></title>
            <link>https://toss.tech/article/52885</link>
            <guid isPermaLink="true">https://toss.tech/article/52885</guid>
            <pubDate>Fri, 04 Sep 2026 08:47:00 GMT</pubDate>
            <description><![CDATA[실시간으로 생성되는 코드를 어드민 화면으로 보여 주기 위해, 개발 환경을 브라우저 안으로 옮긴 이야기를 소개해요.]]></description>
            <content:encoded><![CDATA[<link rel="preload" as="image" href="https://static.toss.im/ipd-tcs/toss_core/live/08132b5c-cdb4-4356-af6a-328ef7e4b617/image.png"/><link rel="preload" as="image" href="https://static.toss.im/ipd-tcs/toss_core/live/60171420-644e-4352-a297-dd7949762e33/image.png"/><link rel="preload" as="image" href="https://static.toss.im/ipd-tcs/toss_core/live/f54cc4d5-104c-4c83-9e8e-5d4445f44e07/image.png"/><link rel="preload" as="image" href="https://static.toss.im/ipd-tcs/toss_core/live/4f8615b1-0685-43ab-a470-775820d71a11/image.png"/><link rel="preload" as="image" href="https://static.toss.im/ipd-tcs/toss_core/live/22b2ff06-71f9-4a19-8d45-2714df5e5a31/image.png"/><div class="css-1vn47db"><p id="3cea360d-33e3-801d-b1d1-e3e605b9f6e6" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">어드민 하나를 만들려면 무엇을 해야 할까요?</span></span></span></p><p id="3cea360d-33e3-8098-b222-f70607325e11" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">프로젝트를 스캐폴딩하고, 필요한 의존성을 추가하고, 빌드 도구를 고르고, 인증을 붙이고, 배포를 설정해요. 그다음 프론트엔드 코드를 작성하고 API를 연결한 후 빌드해서 배포해요. 어드민이라면 여기에 개인정보 마스킹, 정보 조회 사유 수취, 다운로드 파일 암호화 같은 보안 요건을 추가로 고려해야 해요. 간단한 어드민 하나를 만들려고 해도 해야 할 일이 많죠.</span></span></span></p><p id="3cea360d-33e3-80b2-839c-c7f615544787" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">토스에서는 여러 팀이 빠르게 비즈니스를 시도하면서 다양한 어드민을 만들고 수정할 일이 꾸준히 생겨요. 기존에는 어드민마다 개발/보안/배포 과정을 반복해야 해서, 수요가 커질수록 비용도 같이 커졌어요. 공통으로 필요한 정책과 실행 환경을 표준화하면서도, 각 팀에 필요한 어드민을 빠르게 만들 수 있는 방식이 필요했어요.</span></span></span></p><p id="3cea360d-33e3-8066-86f8-f4183b5ab635" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">TOI는 이 문제를 풀기 위해 만든 </span><span class="css-q3ktjb">사내 어드민 제작 플랫폼</span><span class="css-1kxrhf3">이에요. 사용자는 어드민에서 쓸 API를 등록하고, 자연어로 필요한 화면을 요청해요. AI는 사용자가 등록한 API의 요청과 응답 스키마를 참고해서 React 코드를 만들어요. 2026년 2월 사내에 공개한 이후 2026년 8월까지 6개월간 프로젝트 439개와 그 하위 페이지 2,418개가 만들어졌어요.</span></span></span></p><figure id="3cea360d-33e3-8001-b08e-c9f30a7e6b87" class="css-of5acw"><img src="https://static.toss.im/ipd-tcs/toss_core/live/08132b5c-cdb4-4356-af6a-328ef7e4b617/image.png" alt="" class="css-1pgssrp"/><figcaption class="css-wgpbp3"><span class="css-jfs1hr">데이터 조회 어드민을 만들어줘</span></figcaption></figure><p id="3cea360d-33e3-8028-ba10-de97fa63cf0c" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">코드는 AI가 작성해 줬지만, 실시간으로 만들어지는 코드가 사용자에게 어드민으로 보이려면 실행하고 보여 줄 환경이 필요했어요. 이 글에서는 TOI가 그 환경을 브라우저 안에 만든 과정을 소개할게요.</span></span></span></p><p id="3cea360d-33e3-80c3-ba94-cbb3b3da2687" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"></span></span></p><h2 id="3cea360d-33e3-80c6-b162-d2380b9dc3bb" class="css-1c1qox8"><span class="css-1r3ko7u"><span class="css-p4abj2"><span class="css-1kxrhf3">어드민을 정책과 화면으로 나눠서 생각하기</span></span></span></h2><p id="3cea360d-33e3-800f-97ad-f21bdefa8e8e" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">어드민에서 지켜야 하는 정책들은 대부분 데이터를 다루는 방식과 관련되어 있어요. TOI로 만든 어드민에서 API를 호출하려면 먼저 해당 API를 TOI에 등록해야 해요. 등록된 API 요청은 TOI 서버를 거쳐 원래 서버로 전달되고, TOI 서버에는 호출 기록이 자동으로 남아요. API별로 개인 정보 마스킹, 다운로드 파일 암호화, 정보 조회 사유 수취 같은 정책을 설정할 수 있고, 필수인 보안 정책들은 자동으로 적용돼요. TOI 서버는 API를 프록시하는 과정에서 이 설정과 정책을 적용해요.</span></span></span></p><p id="3cea360d-33e3-8042-bbf7-f6ca8a48a2f5" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">다음 영역은 데이터를 화면에 보여 주고 기능을 동작하게 하는 일이에요. 테이블을 그리고, 필터를 붙이고, 상세 페이지로 이동하는 기능이 여기에 해당해요. TOI는 이 영역을 AI에게 맡겨서, 사용자가 필요한 어드민 화면을 쉽고 빠르게 만들 수 있도록 했어요. </span></span></span></p><p id="3cea360d-33e3-8059-96ef-e9a2d7bb77e8" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">다만 AI가 프론트엔드 코드를 만들었다고 바로 화면이 나타나지는 않아요. 앞에서 나열한 것처럼 코드가 동작하는 서비스가 되려면 코드를 만드는 것 외에도 할 일이 많아요. TOI에서는 </span><span class="css-q3ktjb">AI가 만든 코드를 사용자가 제품에서 실시간으로 확인할 수 있어야</span><span class="css-1kxrhf3"> 했고, 이를 위해 개발 환경 자체를 브라우저 안으로 가져와야 했어요.</span></span></span></p><p id="3cea360d-33e3-80de-be81-ced1ccadca02" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"></span></span></p><h2 id="3cea360d-33e3-8062-97ee-d551127d6abf" class="css-1c1qox8"><span class="css-1r3ko7u"><span class="css-p4abj2"><span class="css-1kxrhf3">Preview Runtime을 위한 두 가지 시도</span></span></span></h2><p id="3cea360d-33e3-807b-9558-dad83c4c888c" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">AI가 만든 코드를 실행해서 바로 보여 줄 화면이 필요했고, 이를 위해 두 가지 방식을 차례로 시도했어요.</span></span></span></p><h3 id="3cea360d-33e3-80a6-a004-c5557d056a32" class="css-1feg9au"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">Dev Server</span></span></span></h3><p id="3cea360d-33e3-80f1-9aa8-ca6d192e2fa9" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">처음에는 개발자에게 익숙한 Dev Server를 사용해서 시도했어요. 서버에 Next.js Dev Server를 띄우고 그 주소를 iframe으로 연결해서 사용자에게 보여 줬어요. 로컬에서 개발하던 방식을 그대로 서버로 옮겼고, 코드가 수정될 때마다 Dev Server가 바라보는 파일이 변경되고 iframe이 갱신됐어요.</span></span></span></p><p id="3cea360d-33e3-800f-944a-dcf0a35b90ff" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">코드 변경 내용을 어렵지 않게 실시간으로 보여 줄 수 있었고, PoC에는 적절했어요. 하지만 TOI에서는 여러 사용자가 Preview를 보면서 편집할 수 있어야 하는데, 하나의 Dev Server 프로세스에 연결된 Preview에는 구조적인 한계가 있었어요. Next.js Dev Server는 컴파일 에러가 나면 에러 오버레이를 보여 주는데, 사용자 A가 페이지를 편집하다가 컴파일 에러가 나면 오버레이가 A의 화면에만 나타나지 않고 다른 페이지를 편집하고 있는 사용자에게도 보였어요.</span></span></span></p><figure id="3cea360d-33e3-80d6-a0da-e34172070286" class="css-of5acw"><img src="https://static.toss.im/ipd-tcs/toss_core/live/60171420-644e-4352-a297-dd7949762e33/image.png" alt="" class="css-1pgssrp"/></figure><p id="3cea360d-33e3-80a9-a89b-dff9d9dec777" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">실행 환경을 사용자별로 나누지 않아서 생긴 문제였어요. Dev Server 프로세스를 사용자 수만큼 늘리는 대신, 코드 실행 위치를 각 사용자의 브라우저로 옮겨 서로 격리하기로 했어요.</span></span></span></p><h3 id="3cea360d-33e3-8041-9878-f15ae4e089d5" class="css-1feg9au"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">Sandpack</span></span></span></h3><p id="3cea360d-33e3-803c-ad21-dfbdb53e5824" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">두 번째 시도에서는 CodeSandbox의 Sandpack을 사용해 코드 실행 위치를 서버에서 사용자 브라우저로 옮겼어요. 기대한 대로 Preview는 사용자마다 독립적으로 실행됐고, 사용자 A가 코드를 편집하다 낸 오류가 다른 사용자의 화면에는 나타나지 않았어요.</span></span></span></p><p id="3cea360d-33e3-8022-b14c-f47ce35df8f4" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">대신 예상하지 못한 다른 문제가 생겼는데, 페이지에 진입 후 </span><span class="css-q3ktjb">Preview가 보이기까지 47초가 걸렸어요.</span><span class="css-1kxrhf3"> Sandpack은 페이지에 진입해서 렌더링을 시작할 때, 프로젝트에 선언한 패키지를 모두 내려받는 식으로 동작했기 때문에 느릴 수밖에 없었어요. 실시간으로 생성/편집하는 코드와 Preview가 중심인 제품에서 허용하기 어려운 사용자 경험이었어요.</span></span></span></p><p id="3cea360d-33e3-800f-8c5c-ce1a910e6497" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">사내 패키지를 연결하는 과정에서도 문제가 있었어요. 사내 패키지는 인증된 경우에만 사용할 수 있도록 private하게 관리되고 있었어요. Sandpack은 private npm registry를 지원했지만, 인증 정보를 브라우저 런타임에 노출할 수는 없었어요. 그래서 중간에서 인증을 대신 수행해 주는 Proxy API가 필요했어요.</span></span></span></p><p id="3cea360d-33e3-806c-bb08-d520ef18b838" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">그런데 패키지를 가져올 때는 패키지 정보를 담은 metadata와, 실제 코드를 압축한 tarball을 순서대로 내려받아요. Proxy API는 이 두 요청 모두에 대응해야 했어요. metadata 요청을 인증해 주는 것만으로는 부족했는데, metadata 안의 dist.tarball이 Proxy API가 아니라 원본 registry를 가리키고 있어서, tarball 요청도 proxy를 거치도록 URL을 다시 바꿔 줘야 했거든요.</span></span></span></p><p id="3cea360d-33e3-8066-b29a-e4f94f88743a" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">패키지 설치는 의존성 그래프를 따라 내려가면서 하위 패키지의 metadata와 tarball을 계속 요청해요. 사내 패키지가 또 다른 사내 패키지에 의존하는 경우도 있어서, 그래프 어느 단계에서 사내 패키지를 만나든 같은 인증과 URL 치환을 적용해야 했어요.</span></span></span></p><p id="3cea360d-33e3-8096-9e35-e1970e527a9e" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">마지막으로 Sandpack Preview는 별도 origin의 iframe에서 실행돼요. 이 iframe에서 API를 호출하면 CORS와 PNA(Private Network Access) 문제가 생겨서, 여기에도 대응이 필요했어요.</span></span></span></p><p id="3cea360d-33e3-80d4-b753-d3bec8aa87b6" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">Sandpack은 실행 환경을 사용자 브라우저로 옮기는 데는 적합했어요. 하지만 첫 화면까지 걸리는 시간이 너무 길었고, 패키지를 원하는 시점과 방식으로 공급하기도 어려웠어요. 이 문제들을 하나씩 우회하며 Sandpack을 유지하기보다는, 다른 방식으로 문제를 해결하기로 했어요.</span></span></span></p><p id="3cea360d-33e3-8033-8c7a-cb41ea2491cc" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"></span></span></p><h2 id="3cea360d-33e3-8007-afb4-ddb9ecdaa7ca" class="css-1c1qox8"><span class="css-1r3ko7u"><span class="css-p4abj2"><span class="css-1kxrhf3">Preview Runtime</span></span></span></h2><p id="3cea360d-33e3-8008-a994-cdd89eceb289" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">두 번의 시도를 거치며, 우리가 풀어야 하는 문제가 세 가지로 정리됐어요.</span></span></span></p><aside id="3cea360d-33e3-8080-8554-f3b5bba51715" class="css-nv7vyi"><div class="css-1vn47db"><ol class="css-hokoge"><li id="3cea360d-33e3-80bc-87de-df20a817f5b3" class="css-1hwiibq"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">실시간으로 생성, 편집되는 코드는 사용자 브라우저에서 빌드해야 해요.</span></span></span></li><li id="3cea360d-33e3-80fd-81dd-e08f81af471c" class="css-1hwiibq"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">사용할 패키지는 미리 준비해 둬야 해요.</span></span></span></li><li id="3cea360d-33e3-8029-b5a2-cf43afc267b7" class="css-1hwiibq"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">실행한 결과물은 화면에 보여 줘야 해요.</span></span></span></li></ol></div></aside><p id="3cea360d-33e3-80ad-89a4-fd6c59f4194a" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">TOI는 이 세 가지를 Preview Runtime을 만들면서 해결했어요. 이제 하나씩 순서대로 소개할게요.</span></span></span></p><p id="3cea360d-33e3-809c-a429-f4aeda768397" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"></span></span></p><h2 id="3cea360d-33e3-802c-b02a-fbe35deff176" class="css-1c1qox8"><span class="css-1r3ko7u"><span class="css-p4abj2"><span class="css-1kxrhf3">1️⃣ 브라우저에서 코드 빌드하기</span></span></span></h2><p id="3cea360d-33e3-806f-b0d8-cd9754b0be23" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">먼저 Preview Runtime 시스템에서 어떻게 브라우저 위에서 코드를 빌드할 수 있도록 했는지 설명해 드릴게요.</span></span></span></p><h3 id="3cea360d-33e3-8096-a10c-c92d69bdbdcc" class="css-1feg9au"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">브라우저에는 파일 시스템이 없어요</span></span></span></h3><p id="3cea360d-33e3-80bf-a92f-fd66ca42ab78" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">실시간으로 생성되는 앱 코드를 브라우저에서 빌드하려면, 빌드 도구가 파일 시스템에서 프로젝트 파일을 찾고 import 경로를 해석할 수 있어야 해요. 예를 들어 ./components/Table이라는 경로를 만나면 실제로 존재하는 </span><code style="font-family:Consolas, Menlo, Monaco, source-code-pro, &quot;Courier New&quot;, monospace;font-size:0.9em;background-color:var(--adaptiveOpacity100);border:1px solid var(--adaptiveOpacity200);border-radius:3px"><span class="css-1mjnzsq">./components/Table.tsx</span></code><span class="css-1kxrhf3">나 </span><code style="font-family:Consolas, Menlo, Monaco, source-code-pro, &quot;Courier New&quot;, monospace;font-size:0.9em;background-color:var(--adaptiveOpacity100);border:1px solid var(--adaptiveOpacity200);border-radius:3px"><span class="css-1mjnzsq">./components/Table/index.tsx</span></code><span class="css-1kxrhf3"> 파일로 해석해요. 하지만 브라우저에는 이런 경로를 조회할 프로젝트 파일 시스템이 없어서, 경로와 파일 내용을 연결하는 가상 파일 시스템을 만들었어요.</span></span></span></p><p id="3cea360d-33e3-807e-976b-f21cdfaa7aa3" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">가상 파일 시스템은 경로에 파일이 있는지 확인하고 그 내용을 가져오는 역할을 해요. 파일은 소유 범위와 변경 주기에 따라 사용자 파일, 프로젝트 공유 파일, Preview 템플릿 파일, Preview Runtime 파일의 네 레이어로 나눴어요.</span></span></span></p><p id="3cea360d-33e3-80aa-931d-dc38cef7b09e" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><code style="font-family:Consolas, Menlo, Monaco, source-code-pro, &quot;Courier New&quot;, monospace;font-size:0.9em;background-color:var(--adaptiveOpacity100);border:1px solid var(--adaptiveOpacity200);border-radius:3px"><span class="css-1mjnzsq">fileStore</span></code><span class="css-1kxrhf3">는 파일을 요청받으면 앞쪽 레이어부터 </span><code style="font-family:Consolas, Menlo, Monaco, source-code-pro, &quot;Courier New&quot;, monospace;font-size:0.9em;background-color:var(--adaptiveOpacity100);border:1px solid var(--adaptiveOpacity200);border-radius:3px"><span class="css-1mjnzsq">has</span></code><span class="css-1kxrhf3">로 확인하고, 처음 찾은 파일의 내용을 </span><code style="font-family:Consolas, Menlo, Monaco, source-code-pro, &quot;Courier New&quot;, monospace;font-size:0.9em;background-color:var(--adaptiveOpacity100);border:1px solid var(--adaptiveOpacity200);border-radius:3px"><span class="css-1mjnzsq">get</span></code><span class="css-1kxrhf3">으로 가져와요. 빌드 도구는 이렇게 합친 네 레이어를 하나의 파일 시스템처럼 사용해요.</span></span></span></p><h3 id="3cea360d-33e3-800e-9f95-fd023e7729ca" class="css-1feg9au"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">esbuild-wasm으로 번들 만들기</span></span></span></h3><p id="3cea360d-33e3-8016-a2a4-e127cba03bf2" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">가상 파일 시스템을 준비했으니 이제 이걸 읽어서 앱 코드를 번들로 만들 차례예요. 빌드 도구로는 esbuild의 WebAssembly 버전인 </span><code style="font-family:Consolas, Menlo, Monaco, source-code-pro, &quot;Courier New&quot;, monospace;font-size:0.9em;background-color:var(--adaptiveOpacity100);border:1px solid var(--adaptiveOpacity200);border-radius:3px"><span class="css-1mjnzsq">esbuild-wasm</span></code><span class="css-1kxrhf3">을 사용했어요. 브라우저에서 그대로 실행되고, 플러그인으로 파일 조회 과정을 가로챌 수 있어서 앞에서 만든 가상 파일 시스템을 그대로 연결할 수 있어요. </span><code style="font-family:Consolas, Menlo, Monaco, source-code-pro, &quot;Courier New&quot;, monospace;font-size:0.9em;background-color:var(--adaptiveOpacity100);border:1px solid var(--adaptiveOpacity200);border-radius:3px"><span class="css-1mjnzsq">esbuild-wasm</span></code><span class="css-1kxrhf3">으로 TypeScript와 JSX를 브라우저가 실행할 JavaScript로 변환하고, 로컬 import 그래프를 따라가며 하나의 ESM 번들을 만들어요. 변환과 번들링은 </span><code style="font-family:Consolas, Menlo, Monaco, source-code-pro, &quot;Courier New&quot;, monospace;font-size:0.9em;background-color:var(--adaptiveOpacity100);border:1px solid var(--adaptiveOpacity200);border-radius:3px"><span class="css-1mjnzsq">worker</span></code><span class="css-1kxrhf3"> 옵션으로 Web Worker에서 처리해 메인 스레드의 작업과 분리했어요.</span></span></span></p><p id="3cea360d-33e3-8097-9ad6-c54b8ed1efc6" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">빌드 설정에는 앞에서 만든 가상 파일 시스템을 플러그인으로 넣어요.</span></span></span></p><p id="3cea360d-33e3-80c3-8ece-c6acf5d8cbbd" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><code style="font-family:Consolas, Menlo, Monaco, source-code-pro, &quot;Courier New&quot;, monospace;font-size:0.9em;background-color:var(--adaptiveOpacity100);border:1px solid var(--adaptiveOpacity200);border-radius:3px"><span class="css-1mjnzsq">createVirtualFsPlugin</span></code><span class="css-1kxrhf3">은 esbuild가 디스크에 묻는 요청을 대신 답해 줘요. </span><code style="font-family:Consolas, Menlo, Monaco, source-code-pro, &quot;Courier New&quot;, monospace;font-size:0.9em;background-color:var(--adaptiveOpacity100);border:1px solid var(--adaptiveOpacity200);border-radius:3px"><span class="css-1mjnzsq">onResolve</span></code><span class="css-1kxrhf3">에서 import 경로를 실제 파일 경로로 바꾸고, </span><code style="font-family:Consolas, Menlo, Monaco, source-code-pro, &quot;Courier New&quot;, monospace;font-size:0.9em;background-color:var(--adaptiveOpacity100);border:1px solid var(--adaptiveOpacity200);border-radius:3px"><span class="css-1mjnzsq">onLoad</span></code><span class="css-1kxrhf3">에서 그 경로의 내용을 돌려줘요.</span></span></span></p><p id="3cea360d-33e3-80dd-b97b-e96471069300" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">한편, 프로젝트에서 사용할 </span><code style="font-family:Consolas, Menlo, Monaco, source-code-pro, &quot;Courier New&quot;, monospace;font-size:0.9em;background-color:var(--adaptiveOpacity100);border:1px solid var(--adaptiveOpacity200);border-radius:3px"><span class="css-1mjnzsq">react</span></code><span class="css-1kxrhf3">, </span><code style="font-family:Consolas, Menlo, Monaco, source-code-pro, &quot;Courier New&quot;, monospace;font-size:0.9em;background-color:var(--adaptiveOpacity100);border:1px solid var(--adaptiveOpacity200);border-radius:3px"><span class="css-1mjnzsq">date-fns</span></code><span class="css-1kxrhf3"> 와 같은 패키지는 external 설정으로 빌드 대상에서 빠지도록 했어요. 대신 뒤에서 설명할 import map을 이용해 실행 시점에 미리 준비해둔 패키지를 연결해요.</span></span></span></p><p id="3cea360d-33e3-8052-b9f7-ef05f36571df" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"></span></span></p><h2 id="3cea360d-33e3-8093-9f9d-e097db435ceb" class="css-1c1qox8"><span class="css-1r3ko7u"><span class="css-p4abj2"><span class="css-1kxrhf3">2️⃣ 패키지를 미리 준비하기</span></span></span></h2><p id="3cea360d-33e3-8009-8a18-eab6f3efee7c" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">여기까지 브라우저에서 앱 코드를 빌드하는 방법을 알아봤어요. 이제 번들에 포함하지 않고 남겨 둔 패키지를 미리 준비해서 제공한 방법을 설명할게요.</span></span></span></p><h3 id="3cea360d-33e3-802a-ae68-cea9a88c3efc" class="css-1feg9au"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">Import Map으로 패키지 제공하기</span></span></span></h3><p id="3cea360d-33e3-809d-955c-e9b1672b82c3" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">빌드한 앱 번들에는 </span><code style="font-family:Consolas, Menlo, Monaco, source-code-pro, &quot;Courier New&quot;, monospace;font-size:0.9em;background-color:var(--adaptiveOpacity100);border:1px solid var(--adaptiveOpacity200);border-radius:3px"><span class="css-1mjnzsq">import React from &#x27;react&#x27;</span></code><span class="css-1kxrhf3"> 같은 import 문이 그대로 남아 있어요. 그런데 </span><code style="font-family:Consolas, Menlo, Monaco, source-code-pro, &quot;Courier New&quot;, monospace;font-size:0.9em;background-color:var(--adaptiveOpacity100);border:1px solid var(--adaptiveOpacity200);border-radius:3px"><span class="css-1mjnzsq">&#x27;react&#x27;</span></code><span class="css-1kxrhf3">라는 이름만으로는 브라우저가 어떤 파일을 불러와야 하는지 알 수 없어요.</span></span></span></p><p id="3cea360d-33e3-801a-bd5a-ea68ff700347" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">브라우저는 이 이름을 실제 파일에 연결하는 표준 방법으로 import map을 제공해요. 예를 들어 아래처럼 정의하면 </span><code style="font-family:Consolas, Menlo, Monaco, source-code-pro, &quot;Courier New&quot;, monospace;font-size:0.9em;background-color:var(--adaptiveOpacity100);border:1px solid var(--adaptiveOpacity200);border-radius:3px"><span class="css-1mjnzsq">&#x27;react&#x27;</span></code><span class="css-1kxrhf3">를 import할 때 </span><code style="font-family:Consolas, Menlo, Monaco, source-code-pro, &quot;Courier New&quot;, monospace;font-size:0.9em;background-color:var(--adaptiveOpacity100);border:1px solid var(--adaptiveOpacity200);border-radius:3px"><span class="css-1mjnzsq">react_27397108.js</span></code><span class="css-1kxrhf3">를 불러오게 돼요.</span></span></span></p><p id="3cea360d-33e3-809b-9f5a-c24e78e34d6f" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">import map을 사용하면 패키지 연결을 번들 밖 JSON 데이터로 표현할 수 있어요. TOI는 패키지를 미리 빌드해 두고, import map으로 연결해서 제공하기로 했어요.</span></span></span></p><h3 id="3cea360d-33e3-8094-b17d-cec1096fb04c" class="css-1feg9au"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">의존성은 패키지 개별 단위로 나뉘지 않아요</span></span></span></h3><p id="3cea360d-33e3-801d-9703-fe9fdaa0c983" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">처음에는 패키지를 각각 번들링해서 S3에 올려 두고, 프로젝트가 필요한 패키지만 골라 import map으로 연결하려고 했어요. 패키지 매니저가 어떤 문제를 풀어 주는지 충분히 이해하지 못하고 선택한 방법이었어요.</span></span></span></p><p id="3cea360d-33e3-806d-aa39-c1298f345647" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">각각 번들링하는 이 방식에서는 패키지 조합에 따라 런타임 오류가 생길 수 있어요. 예를 들어 </span><code style="font-family:Consolas, Menlo, Monaco, source-code-pro, &quot;Courier New&quot;, monospace;font-size:0.9em;background-color:var(--adaptiveOpacity100);border:1px solid var(--adaptiveOpacity200);border-radius:3px"><span class="css-1mjnzsq">react-dom</span></code><span class="css-1kxrhf3">은 </span><code style="font-family:Consolas, Menlo, Monaco, source-code-pro, &quot;Courier New&quot;, monospace;font-size:0.9em;background-color:var(--adaptiveOpacity100);border:1px solid var(--adaptiveOpacity200);border-radius:3px"><span class="css-1mjnzsq">react</span></code><span class="css-1kxrhf3">에 의존하는데, </span><code style="font-family:Consolas, Menlo, Monaco, source-code-pro, &quot;Courier New&quot;, monospace;font-size:0.9em;background-color:var(--adaptiveOpacity100);border:1px solid var(--adaptiveOpacity200);border-radius:3px"><span class="css-1mjnzsq">react-dom</span></code><span class="css-1kxrhf3">을 따로 번들링하면 그 안에 </span><code style="font-family:Consolas, Menlo, Monaco, source-code-pro, &quot;Courier New&quot;, monospace;font-size:0.9em;background-color:var(--adaptiveOpacity100);border:1px solid var(--adaptiveOpacity200);border-radius:3px"><span class="css-1mjnzsq">react</span></code><span class="css-1kxrhf3"> 코드가 함께 포함돼요. React처럼 모두가 하나의 인스턴스를 공유해야 하는 패키지를 사용할 때, 앱이 참조하는 React와 </span><code style="font-family:Consolas, Menlo, Monaco, source-code-pro, &quot;Courier New&quot;, monospace;font-size:0.9em;background-color:var(--adaptiveOpacity100);border:1px solid var(--adaptiveOpacity200);border-radius:3px"><span class="css-1mjnzsq">react-dom</span></code><span class="css-1kxrhf3">이 참조하는 React 인스턴스가 달라지면서 오류가 생기는 거예요.</span></span></span></p><p id="3cea360d-33e3-8079-b1ab-ca7326bfdf9b" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">이렇게 의존성은 패키지 한 개 단위로 깔끔하게 나뉘지 않아요. 어떤 패키지가 싱글톤으로 유지되어야 하는지는 그 패키지만 봐서는 알 수 없고, 함께 설치되는 패키지를 같이 봐야 알 수 있어요. 개별 패키지가 아니라 조합 단위로 관리해야 했어요.</span></span></span></p><h3 id="3cea360d-33e3-80aa-9090-e410b838bff5" class="css-1feg9au"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">모든 프로젝트에 조합 하나로는 부족해요</span></span></span></h3><p id="3cea360d-33e3-80e6-918c-d803098b69dd" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">처음에는 모든 프로젝트가 같은 버전의 React와 몇 가지 패키지 조합을 고정해서 사용했어요. 이 방법은 초기 단계에는 충분했지만 오래가지는 못했어요. 사용자마다 필요한 패키지가 달라서 추가 요청이 계속 들어왔고, 조합 하나를 모든 프로젝트가 공유하다 보니 패키지를 추가할 때마다 기존 패키지들과 호환되는지를 전체 프로젝트 기준으로 확인해야 했어요. 패키지 버전을 올릴 때도 마찬가지로, 새 버전이 모든 프로젝트에 한꺼번에 적용되기 때문에 일부 프로젝트에만 필요한 업그레이드를 해 줄 방법이 없었어요.</span></span></span></p><p id="3cea360d-33e3-8074-bc49-f757d25f734e" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">그래서 프로젝트마다 다른 패키지 조합을 사용할 수 있는 구조가 필요했어요.</span></span></span></p><h3 id="3cea360d-33e3-80ef-b77a-d21e70ef1557" class="css-1feg9au"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">조합에 이름 붙이기</span></span></span></h3><p id="3cea360d-33e3-80b9-b7a2-df1a52d3ff07" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><code style="font-family:Consolas, Menlo, Monaco, source-code-pro, &quot;Courier New&quot;, monospace;font-size:0.9em;background-color:var(--adaptiveOpacity100);border:1px solid var(--adaptiveOpacity200);border-radius:3px"><span class="css-1mjnzsq">package.json</span></code><span class="css-1kxrhf3">의 </span><code style="font-family:Consolas, Menlo, Monaco, source-code-pro, &quot;Courier New&quot;, monospace;font-size:0.9em;background-color:var(--adaptiveOpacity100);border:1px solid var(--adaptiveOpacity200);border-radius:3px"><span class="css-1mjnzsq">dependencies</span></code><span class="css-1kxrhf3"> 전체를 하나의 조합으로 묶고 </span><code style="font-family:Consolas, Menlo, Monaco, source-code-pro, &quot;Courier New&quot;, monospace;font-size:0.9em;background-color:var(--adaptiveOpacity100);border:1px solid var(--adaptiveOpacity200);border-radius:3px"><span class="css-1mjnzsq">packageSetHash</span></code><span class="css-1kxrhf3">라는 이름을 붙여 </span><code style="font-family:Consolas, Menlo, Monaco, source-code-pro, &quot;Courier New&quot;, monospace;font-size:0.9em;background-color:var(--adaptiveOpacity100);border:1px solid var(--adaptiveOpacity200);border-radius:3px"><span class="css-1mjnzsq">package.json</span></code><span class="css-1kxrhf3">의 사용자 정의 필드로 기록했어요. 패키지의 public entry 목록과 설치 결과로 나온 lockfile의 해시를 함께 해싱했어요.</span></span></span></p><p id="3cea360d-33e3-80d0-9e31-f1da87d9d201" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">entry 목록을 정렬해서 넣기 때문에 </span><code style="font-family:Consolas, Menlo, Monaco, source-code-pro, &quot;Courier New&quot;, monospace;font-size:0.9em;background-color:var(--adaptiveOpacity100);border:1px solid var(--adaptiveOpacity200);border-radius:3px"><span class="css-1mjnzsq">package.json</span></code><span class="css-1kxrhf3">에 적힌 순서가 달라도 같은 해시가 나와요. 반대로 패키지가 하나라도 바뀌면 lockfile이 바뀌면서 해시도 함께 바뀌어요. 같은 조합인지 문자열로 판별할 수 있게 됐어요.</span></span></span></p><h3 id="3cea360d-33e3-80d9-8ae4-f867222138d6" class="css-1feg9au"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">조합을 만들고 사용하기</span></span></span></h3><p id="3cea360d-33e3-80d8-b87a-c9a8f12aeb98" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">패키지 조합은 다음과 같이 준비해요. 먼저 빈 워크스페이스를 만들고 사용자 프로젝트의 </span><code style="font-family:Consolas, Menlo, Monaco, source-code-pro, &quot;Courier New&quot;, monospace;font-size:0.9em;background-color:var(--adaptiveOpacity100);border:1px solid var(--adaptiveOpacity200);border-radius:3px"><span class="css-1mjnzsq">package.json</span></code><span class="css-1kxrhf3">을 복사해요. 그다음 </span><code style="font-family:Consolas, Menlo, Monaco, source-code-pro, &quot;Courier New&quot;, monospace;font-size:0.9em;background-color:var(--adaptiveOpacity100);border:1px solid var(--adaptiveOpacity200);border-radius:3px"><span class="css-1mjnzsq">yarn install</span></code><span class="css-1kxrhf3">을 실행해서 </span><code style="font-family:Consolas, Menlo, Monaco, source-code-pro, &quot;Courier New&quot;, monospace;font-size:0.9em;background-color:var(--adaptiveOpacity100);border:1px solid var(--adaptiveOpacity200);border-radius:3px"><span class="css-1mjnzsq">node_modules</span></code><span class="css-1kxrhf3">와 </span><code style="font-family:Consolas, Menlo, Monaco, source-code-pro, &quot;Courier New&quot;, monospace;font-size:0.9em;background-color:var(--adaptiveOpacity100);border:1px solid var(--adaptiveOpacity200);border-radius:3px"><span class="css-1mjnzsq">yarn.lock</span></code><span class="css-1kxrhf3">을 얻어요. 이렇게 확정된 조합을 Vite로 빌드해서 브라우저용 패키지 파일로 만들고, 앞에서 소개한 import map도 </span><code style="font-family:Consolas, Menlo, Monaco, source-code-pro, &quot;Courier New&quot;, monospace;font-size:0.9em;background-color:var(--adaptiveOpacity100);border:1px solid var(--adaptiveOpacity200);border-radius:3px"><span class="css-1mjnzsq">importmap.json</span></code><span class="css-1kxrhf3"> 파일로 함께 만들어 S3의 packageSetHash 경로에 업로드해요.</span></span></span></p><p id="3cea360d-33e3-8051-9c38-cc1a77cc7a1c" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">여기서 중요한 점은 패키지를 </span><span class="css-q3ktjb">실제로 설치한다</span><span class="css-1kxrhf3">는 거예요. peerDependencies 해석과 버전 충돌 확인은 패키지 매니저가 원래 하던 방식대로 처리해요. 복잡한 의존성 해석 규칙을 다시 구현할 필요가 없어졌어요.</span></span></span></p><p id="3cea360d-33e3-80ea-97dd-f9eaac5a3d45" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">준비해 둔 패키지를 브라우저에서 사용하는 방식은 더 단순해요. 프로젝트의 </span><code style="font-family:Consolas, Menlo, Monaco, source-code-pro, &quot;Courier New&quot;, monospace;font-size:0.9em;background-color:var(--adaptiveOpacity100);border:1px solid var(--adaptiveOpacity200);border-radius:3px"><span class="css-1mjnzsq">package.json</span></code><span class="css-1kxrhf3">에서 </span><code style="font-family:Consolas, Menlo, Monaco, source-code-pro, &quot;Courier New&quot;, monospace;font-size:0.9em;background-color:var(--adaptiveOpacity100);border:1px solid var(--adaptiveOpacity200);border-radius:3px"><span class="css-1mjnzsq">packageSetHash</span></code><span class="css-1kxrhf3">를 읽고, 해시가 가리키는 경로에서 </span><code style="font-family:Consolas, Menlo, Monaco, source-code-pro, &quot;Courier New&quot;, monospace;font-size:0.9em;background-color:var(--adaptiveOpacity100);border:1px solid var(--adaptiveOpacity200);border-radius:3px"><span class="css-1mjnzsq">importmap.json</span></code><span class="css-1kxrhf3">을 받아 와요. 이 import map이 앞에서 </span><code style="font-family:Consolas, Menlo, Monaco, source-code-pro, &quot;Courier New&quot;, monospace;font-size:0.9em;background-color:var(--adaptiveOpacity100);border:1px solid var(--adaptiveOpacity200);border-radius:3px"><span class="css-1mjnzsq">external</span></code><span class="css-1kxrhf3">로 남겨 둔 import specifier를 실제 파일에 연결해 줘요.</span></span></span></p><figure id="3cea360d-33e3-804d-bcf3-d521365eaa7b" class="css-of5acw"><img src="https://static.toss.im/ipd-tcs/toss_core/live/f54cc4d5-104c-4c83-9e8e-5d4445f44e07/image.png" alt="" class="css-1pgssrp"/></figure><p id="3cea360d-33e3-800e-9eb6-c3abb42b7c43" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">같은 조합을 사용하는 프로젝트는 해시도 같아요. 해당 해시 경로에 패키지 파일이 이미 있으면 다시 만들지 않고, 브라우저도 </span><code style="font-family:Consolas, Menlo, Monaco, source-code-pro, &quot;Courier New&quot;, monospace;font-size:0.9em;background-color:var(--adaptiveOpacity100);border:1px solid var(--adaptiveOpacity200);border-radius:3px"><span class="css-1mjnzsq">Cache-Control</span></code><span class="css-1kxrhf3">에 따라 같은 조합의 패키지 응답을 캐시해 재사용해요.</span></span></span></p><p id="3cea360d-33e3-8052-9014-e547c4698509" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"></span></span></p><h2 id="3cea360d-33e3-80da-b883-c2eff6ab073f" class="css-1c1qox8"><span class="css-1r3ko7u"><span class="css-p4abj2"><span class="css-1kxrhf3">3️⃣ 화면에 보여 주기</span></span></span></h2><p id="3cea360d-33e3-804d-b644-e8583b6ca6e2" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">패키지와 앱 코드 번들이 모두 준비됐어요. 이제 이걸 실행해서 화면에 보여 줘야 해요.</span></span></span></p><p id="3cea360d-33e3-80cd-94b2-db211b9f098b" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">처음에는 HMR(Hot Module Replacement)처럼 바뀐 모듈만 갈아 끼우는 방식을 검토했어요. 개발 중 자주 사용하는 익숙한 방식이고, 편집할 때마다 변경이 필요한 부분만 다시 그린다는 점이 좋아 보였어요.</span></span></span></p><p id="3cea360d-33e3-807b-a30e-d7999154ffc8" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">하지만 </span><code style="font-family:Consolas, Menlo, Monaco, source-code-pro, &quot;Courier New&quot;, monospace;font-size:0.9em;background-color:var(--adaptiveOpacity100);border:1px solid var(--adaptiveOpacity200);border-radius:3px"><span class="css-1mjnzsq">esbuild-wasm</span></code><span class="css-1kxrhf3">은 HMR 기능을 제공하지 않아요. HMR을 구현하려면 어떤 모듈이 변경됐고 그 영향이 어디까지 미치는지 모듈 그래프의 상태를 직접 추적하고 계산해야 했어요. 게다가 TOI는 어드민을 만드는 도구라서, 사용자는 Preview에서 방금 작성한 코드가 화면에 어떻게 나타나는지만 확인하면 돼요. 편집 상태를 이어 가며 조작할 일이 없으니 HMR로 얻는 이점이 크지 않았어요.</span></span></span></p><p id="3cea360d-33e3-8065-9523-f5585a90ceb7" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">그래서 부분 교체 대신 문서 전체를 교체하기로 했어요. 빌드가 끝나면 새 HTML 문서를 만들어 앱을 처음부터 실행하고, iframe을 통째로 갈아 끼워요. 이전 문서의 DOM, React 컴포넌트 상태, 모듈의 전역 값은 모두 새 문서에 영향을 주지 않아요.</span></span></span></p><p id="3cea360d-33e3-80b2-830b-cc0123078822" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">문서 전체를 교체하면서 Preview 업데이트는 일종의 트랜잭션 커밋(transactional commit)처럼 동작하게 됐어요. 실행에 실패한 번들은 커밋되지 않으므로 Preview에는 실행에 성공한 가장 최신 화면만 나타나요. 빌드가 실패하거나 완성된 번들을 실행하는 중에 오류가 나면 에러 오버레이로 사용자에게 알려요.</span></span></span></p><figure id="3cea360d-33e3-80fa-b3fc-dab1aba66b7b" class="css-of5acw"><img src="https://static.toss.im/ipd-tcs/toss_core/live/4f8615b1-0685-43ab-a470-775820d71a11/image.png" alt="" class="css-1pgssrp"/></figure><figure id="3cea360d-33e3-80f5-bb08-c8281a2267b4" class="css-of5acw"><img src="https://static.toss.im/ipd-tcs/toss_core/live/22b2ff06-71f9-4a19-8d45-2714df5e5a31/image.png" alt="" class="css-1pgssrp"/><figcaption class="css-wgpbp3"><span class="css-jfs1hr">에러가 발생하면 마지막 정상 화면을 배경으로 유지하고 에러 오버레이를 표시해요.</span></figcaption></figure><h3 id="3cea360d-33e3-8029-a76b-e34477471698" class="css-1feg9au"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">47초에서 1.3초로</span></span></span></h3><p id="3cea360d-33e3-804c-996d-ea07fb4a4451" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">Sandpack에서는 Preview를 열 때 패키지 의존성을 찾아 내려받았어요. TOI는 패키지 조합이 바뀔 때만 빌드해서 미리 업로드해 둬요. Preview를 렌더링할 때는 이미 만든 import map과 패키지 파일을 가져오기만 해요. 매번 반복하던 일을 변경이 일어나는 시점에 한 번만 하도록 옮긴 셈이에요.</span></span></span></p><p id="3cea360d-33e3-80db-b176-d03aac74aaeb" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">Preview Runtime을 모두 구축한 이후, Preview에 첫 화면이 나타나기까지의 시간은 </span><span class="css-q3ktjb">1.3초</span><span class="css-1kxrhf3">로 측정됐어요.</span></span></span></p><p id="3cea360d-33e3-8000-94d5-d2b7548cea8d" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"></span></span></p><h2 id="3cea360d-33e3-80d6-8970-d67ed62f6c82" class="css-1c1qox8"><span class="css-1r3ko7u"><span class="css-p4abj2"><span class="css-1kxrhf3">코드가 제품이 되게 하는 일</span></span></span></h2><p id="3cea360d-33e3-804c-b578-d544eeed295e" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">어드민 하나를 만들려면 프로젝트 스캐폴딩, 빌드 도구 선택, 인증, 배포, 데이터 마스킹, 호출 로그 기록 등 많은 작업이 필요해요. TOI 사용자는 이 일을 더 이상 직접 할 필요가 없지만, 작업 자체가 없어진 건 아니에요. 어드민 정책은 TOI 플랫폼이 다루고, React 코드는 AI가 생성해요. 패키지는 미리 빌드해서 준비해 두고, 코드의 빌드와 실행은 브라우저의 Preview Runtime이 맡아요.</span></span></span></p><p id="3cea360d-33e3-80d6-a221-ce7cc340e6c8" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">처음에는 AI가 만든 코드를 어딘가에서 실행하기만 하면 된다고 단순하게 생각했어요. Dev Server와 Sandpack으로 코드를 실행하는 데는 성공했지만, 정말 필요한 건 실행만이 아니었어요. 여러 사용자가 서로 격리된 실행 환경에서 각각의 서비스를 편집할 수 있어야 했고, 프로젝트마다 다른 패키지를 쓸 수 있어야 했어요. 좋은 사용자 경험을 유지할 만큼 충분히 빨라야 했고요.</span></span></span></p><p id="3cea360d-33e3-80b9-9291-cbf6a6da234b" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">이 시스템을 구축하기 위해 모든 것을 새로 만들지는 않았어요. 앱 코드 빌드는 esbuild-wasm, 의존성 설치와 버전 해석은 Yarn, 패키지 사전 빌드는 Vite가, 사용하는 패키지 연결은 브라우저 import map이 맡았어요. TOI에서 한 일은 이 기능들을 새로 만드는 것이 아닌, 제품의 요구사항을 고려해서 도구들을 잘 조합하고 문제를 풀어내는 것이었어요.</span></span></span></p><p id="3cea360d-33e3-80f7-b79f-f382f2b07983" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">AI로 코드를 만드는 비용이 낮아질수록, 제품을 만드는 데 정말 중요한 일이 무엇인지 더 잘 드러나요. TOI에서는 어드민이 지켜야 할 정책을 플랫폼이 보장하도록 만들고, 만들어진 코드를 안전하게 실행할 구조를 설계하는 일이었어요. 코드를 만드는 일은 AI가 대신할 수 있게 됐지만, 제품의 가치를 정의하고 좋은 사용자 경험을 만드는 일은 여전히 엔지니어의 역할이에요. TOI에서 코드는 AI가 만들었지만, 그 코드를 제품으로 만든 것은 엔지니어였어요.</span></span></span></p><p id="3cea360d-33e3-80dd-aa60-dc1ae2597bef" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"></span></span></p></div><div id="tds-mobile-portal-container"></div>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[1%가 겪은 버그 고쳐야할까요? ]]></title>
            <link>https://toss.tech/article/qa_hotfix</link>
            <guid isPermaLink="true">https://toss.tech/article/qa_hotfix</guid>
            <pubDate>Fri, 28 Aug 2026 08:46:00 GMT</pubDate>
            <description><![CDATA[점진 배포와 핫픽스의 모순 속에서, 판단을 만든 이야기]]></description>
            <content:encoded><![CDATA[<link rel="preload" as="image" href="https://static.toss.im/ipd-tcs/toss_core/live/10fb0530-5525-4306-8bb5-c99689183b92/hotfix-1-old.png"/><link rel="preload" as="image" href="https://static.toss.im/ipd-tcs/toss_core/live/ba5d4159-d289-44ba-87c1-c275817a89cc/hotfix-2-new.png"/><link rel="preload" as="image" href="https://static.toss.im/ipd-tcs/toss_core/live/2f8d9229-5c81-4a20-a079-465e50712219/hotfix-3a-iceberg.png"/><link rel="preload" as="image" href="https://static.toss.im/ipd-tcs/toss_core/live/3227851f-8fec-4c66-b415-18f354a585f1/hotfix-4-chat.png"/><link rel="preload" as="image" href="https://static.toss.im/ipd-tcs/toss_core/live/72926a87-a524-4f80-baa9-91eaf8492089/hotfix-5-crashpath.png"/><link rel="preload" as="image" href="https://static.toss.im/ipd-tcs/toss_core/live/72c3afc1-a74a-4f69-bbbd-5dfb39da8de9/hotfix-7-mijin.png"/><link rel="preload" as="image" href="https://static.toss.im/ipd-tcs/toss_core/live/0043a121-1dd2-42f8-b622-f92600d95fb4/hotfix-8-autofill.png"/><link rel="preload" as="image" href="https://static.toss.im/ipd-tcs/toss_core/live/2cfcf493-42fc-4d62-9dd9-787bf83da647/hotfix-9-council.png"/><link rel="preload" as="image" href="https://static.toss.im/ipd-tcs/toss_core/live/e51a9039-4766-4432-8472-b278bd2fb5ef/hotfix-11-card.png"/><link rel="preload" as="image" href="https://static.toss.im/ipd-tcs/toss_core/live/71dfbca2-f88d-4a42-a5cd-1fe5dec0788b/hotfix-10-trend.png"/><div class="css-1vn47db"><p id="3c7a360d-33e3-808c-8ba7-ef303fbdd599" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">안녕하세요. 토스 QA Platform 팀 문성준 입니다. </span></span></span></p><p id="3c7a360d-33e3-80cb-a588-c4440eaecf3a" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">지난 글에서 저희만의 플랫폼 토션(Tossion)을 소개했는데요. 오늘은 그 위에 올린 이야기, 핫픽스를 다뤄 보려고 합니다. 기준을 세우고, 뒤집고, 다시 세운과정까지요.</span></span></span></p><p id="3c7a360d-33e3-80d4-8de7-ddd36e834ec3" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">새 버전을 배포한 지 얼마 안 돼 알림이 울립니다. 확인해 보니 특정 조건에서만, 그것도 이제 막 배포가 시작돼 1%의 사용자에게서만 나타나는 문제입니다. 지금 바로 핫픽스를 해야 할까요, 아니면 다음 정기 배포까지 기다려도 될까요?</span></span></span></p><p id="3c7a360d-33e3-80ad-91c3-dd005c19ef5c" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">QA라면 한 번쯤 이 갈림길 앞에 서봤을 겁니다. 빨리 고치는 게 정답 같지만, 급하게 낸 핫픽스가 또 다른 버그를 부르기도 하니까요. 그래서 저희에게 핫픽스는 &#x27;빨리 고치는 일&#x27;이 아니라, &#x27;고칠지 말지부터 판단하는 일&#x27;입니다. 오늘은 핫픽스를 어떻게 막고, 또 어떻게 관리하는지 이야기합니다.</span></span></span></p><p id="3c7a360d-33e3-80f7-9d2d-f068dca13828" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"></span></span></p><h2 id="3c7a360d-33e3-80a1-9d96-d1021d20614c" class="css-1c1qox8"><span class="css-1r3ko7u"><span class="css-p4abj2"><span class="css-1kxrhf3">심각도 한 줄로 나눌 수 없었습니다</span></span></span></h2><p id="3c7a360d-33e3-8031-a419-f3f5232eeef8" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">원래 핫픽스에도 기준은 있었습니다. Critical, Major, Minor 같은 흔한 심각도 규칙이었죠. 작은 앱이라면 이 정도로 충분합니다.</span></span></span></p><figure id="3c7a360d-33e3-807a-9bb3-fd3176963bcc" class="css-of5acw"><img src="https://static.toss.im/ipd-tcs/toss_core/live/10fb0530-5525-4306-8bb5-c99689183b92/hotfix-1-old.png" alt="" class="css-1pgssrp"/></figure><p id="3c7a360d-33e3-8044-9438-ee04db0a356a" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">그런데 토스에서는 이 규칙이 자꾸 어긋났습니다. 사용자가 많다 보니, 아주 낮은 배포율에서 생긴 문제도 실제로는 수많은 사람이 겪습니다. 게다가 화면 하나에 여러 비즈니스 로직이 얽혀 있어서, 같은 &#x27;Major&#x27;라도 어떤 건 당장 고쳐야 하고 어떤 건 기다려도 됐습니다. 심각도 한 줄로는 나눌 수 없었습니다. 매번 들어오는 핫픽스 요청이 다 달랐으니까요.</span></span></span></p><p id="3c7a360d-33e3-8037-8113-cabc768f7eec" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">그래서 규칙 자체를 다시 짰습니다. 심각도로 줄 세우는 대신, 핫픽스를 할지 말지로 판단하는 구조로 바꿨습니다. 어떻게 판단하는지는 바로 뒤에서 풀어보겠습니다.</span></span></span></p><figure id="3c7a360d-33e3-8004-8d60-f5398391776e" class="css-of5acw"><img src="https://static.toss.im/ipd-tcs/toss_core/live/ba5d4159-d289-44ba-87c1-c275817a89cc/hotfix-2-new.png" alt="" class="css-1pgssrp"/></figure><p id="3c7a360d-33e3-800d-8434-f9157d225b0c" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">그리고 이 규칙은 한 번 정하고 끝이 아닙니다. 매달 지난 핫픽스를 다시 꺼냅니다. 잘 판단한 건 왜 잘됐고, 애매했던 건 무엇 때문에 애매했는지를요. 그러다 보면 기준이 계속 손질됩니다. 어떤 건 더하고, 어떤 건 덜어냅니다. 지금 이 글에서 소개하는 것도 완성된 규칙이 아니라, 그렇게 다듬어 온 현재의 모습입니다.</span></span></span></p><p id="3c7a360d-33e3-8010-b87d-ce42dbb6518e" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"></span></span></p><h2 id="3c7a360d-33e3-80c9-a930-d1d0bead5566" class="css-1c1qox8"><span class="css-1r3ko7u"><span class="css-p4abj2"><span class="css-1kxrhf3">점진 배포라는 모순</span></span></span></h2><p id="3c7a360d-33e3-8078-bf5e-df0d274c464d" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">여기에 더 근본적인 모순이 있었습니다.</span></span></span></p><p id="3c7a360d-33e3-80ff-baed-c1e0d08fb37f" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">토스는 새 버전을 한 번에 100% 내보내지 않습니다. 일부 사용자에게 먼저 배포하고, 문제가 없으면 점점 넓혀갑니다. 사용자의 불편을 빠르게 잡아내려는 안전장치죠.</span></span></span></p><figure id="3c7a360d-33e3-8044-99c5-c6ec86633277" class="css-of5acw"><img src="https://static.toss.im/ipd-tcs/toss_core/live/2f8d9229-5c81-4a20-a079-465e50712219/hotfix-3a-iceberg.png" alt="" class="css-1pgssrp"/></figure><p id="3c7a360d-33e3-807d-b928-ce6706c8ffbb" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">그런데 이 점진 배포가 핫픽스 판단과 정면으로 부딪힙니다. &quot;1%에서만 발생했으니 심각하지 않다&quot;는 판단은, 사실 아직 이 버전을 받지 못한 나머지 99%의 잠재 위험을 보지 않은 것입니다. 지금 1%인 이유가 문제가 작아서가 아니라, 배포가 거기까지 밖에 안갔기 때문일 수 있으니까요. 반대로, 정말 특정 조건에서 그 1%에게만 생기는 문제라면, 이번엔 핫픽스가 부담이 됩니다. 잘 쓰고 있는 99%에게까지 새 빌드를 내보내는 일이라, 문제 하나를 잡으려다 멀쩡한 다수를 새로운 위험에 밀어 넣을 수 있으니까요.</span></span></span></p><p id="3c7a360d-33e3-80a6-bc5f-c0463d7d1f1a" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">같은 &#x27;1%&#x27;가 이렇게 정반대로 읽힙니다. 그래서 저희에게 필요했던 건 더 촘촘한 심각도 표가 아니라, 이 숫자를 맥락에 맞게 해석하고 빠르게 &#x27;할지 말지&#x27;를 결정할 수 있는 판단 구조였습니다.</span></span></span></p><p id="3c7a360d-33e3-80ea-b79a-e36208961295" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"></span></span></p><h2 id="3c7a360d-33e3-80b5-80bb-e7499c21f12c" class="css-1c1qox8"><span class="css-1r3ko7u"><span class="css-p4abj2"><span class="css-1kxrhf3">신중하게 판단합니다</span></span></span></h2><p id="3c7a360d-33e3-80a3-9e4a-c4d2a27652fc" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">저희는 핫픽스를 우선순위로 나누지 않습니다. 핫픽스를 진행할 것과, 다음 정기 배포에 반영할 것으로 나눕니다.</span></span></span></p><p id="3c7a360d-33e3-80b2-a77d-f3f0d1116611" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">진행하는 쪽은 분명합니다. 기본 기능을 아예 쓸 수 없거나, 매출에 직접 영향을 주거나, 규제와 얽힌 문제. 반대로 다음 배포로 넘겨도 되는 쪽도 정해뒀습니다. 이용률이 아주 낮은 기능이거나, 특정 조건에서만 생기거나, 우회할 방법이 분명한 경우입니다. 즉, 우선순위가 높다고 무조건 핫픽스를 하는 것이 아니라,  지금 바로 수정해야할 조건에 해당하는지를 봅니다.</span></span></span></p><p id="3c7a360d-33e3-806a-92e2-d8def994f9ad" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">판단은 한 사람이 하지 않습니다. 그 주의 배포를 책임지는 릴리즈 마스터 와 품질을 책임지는 큐에이 마스터가 함께 봅니다. 한쪽은 배포 관점에서, 다른 쪽은 품질 관점에서 검토하고, 둘이 합의해야 핫픽스가 나갑니다. 급한 문제는 빌드를 먼저 돌리는 예외를 두되, 최종 배포 결정만큼은 반드시 양쪽 합의를 거칩니다.</span></span></span></p><figure id="3c7a360d-33e3-80da-abb5-f8e8b5db121b" class="css-of5acw"><img src="https://static.toss.im/ipd-tcs/toss_core/live/3227851f-8fec-4c66-b415-18f354a585f1/hotfix-4-chat.png" alt="" class="css-1pgssrp"/></figure><p id="3c7a360d-33e3-807e-8733-d07f5136a647" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">한 가지 버린 것도 있습니다. 처음엔 &quot;핫픽스를 결정하면 2~4시간 버퍼를 두고 다른 이슈도 같이 검토하자&quot;는 안이 있었어요. 그런데 시간을 기준으로 두면 핫픽스의 목적과 부딪힙니다. 그래서 시간 기준을 없애고, &#x27;충분히 검토했는가&#x27;에 집중하기로 했습니다. 목표 문구도 ‘어렵고 안전하게’에서 ‘신중하고 안전하게’로 바꿨습니다. 핫픽스를 어렵게 만드는 게 목적이 아니니까요.</span></span></span></p><p id="3c7a360d-33e3-8051-9a98-daf952a49970" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"></span></span></p><h2 id="3c7a360d-33e3-80dd-b2db-f0641590e6c8" class="css-1c1qox8"><span class="css-1r3ko7u"><span class="css-p4abj2"><span class="css-1kxrhf3">크래시가 나면 빠르게 재현합니다</span></span></span></h2><p id="3c7a360d-33e3-8075-ae62-c5d5f10391d0" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">검증 중에 크래시가 나면, 정작 가장 오래 걸리는 건 수정이 아니라 재현입니다. ‘어떤 상황에서 터졌는지’를 모르면 개발자와 이야기를 시작할 수조차 없으니까요.</span></span></span></p><p id="3c7a360d-33e3-80ac-9e13-d96276763c21" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">그래서 crash-path를 만들었습니다. Claude의 스킬(Skill)로 만든 크래시 분석 도구입니다. 크래시 URL 하나를 넣으면, 로그에 남은 사용자의 행동 기록을 시간 순서대로 되짚어 어느 화면에서 무엇을 하다 죽었는지, 어떤 순서로 하면 재현되는지를 한 장으로 정리해 줍니다. 그 크래시가 났던 기기·OS·메모리 같은 환경까지 함께요.</span></span></span></p><p id="3c7a360d-33e3-8090-bd87-c2802c45ec1e" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">그런데 Bugsnag(Android)와 Firebase Crashlytics(iOS)는 각각 데이터를 다른 방식으로 줍니다. 그래서 crash-path는 URL 주소만 보고 어느 플랫폼인지를 스스로 판단해, 플랫폼에 맞는 통로로 데이터를 가져옵니다. 사람은 &quot;이거 안드로이드예요, iOS예요&quot; 같은 걸 한 번도 고를 필요가 없습니다. 이렇게 판단에 필요한 질문 자체를 줄였고, 크래시가 감지되면 지능형 AI 봇이 crash-path를 자동으로 실행해 분석 결과를 채널에 바로 올려놓게 했습니다.</span></span></span></p><p id="3c7a360d-33e3-805a-8f7e-c382006f4f62" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">여기서 저희가 그은 선이 하나 있습니다. 화면을 무엇이라 부를지, 무엇을 회귀로 볼지 같은 판정은 AI의 추측에 맡기지 않았습니다. 화면 이름을 어떻게 읽을지, 로그의 어떤 패턴을 재현 신호로 볼지를 규칙으로 정해 스킬에 넣어 두고, AI는 그 규칙을 적용해 정리만 합니다. AI가 화면을 멋대로 지어내면 재현 가이드를 믿을 수 없으니까요. 덕분에 결과가 매번 일관될 수 있었습니다. 
</span></span></span></p><figure id="3c9a360d-33e3-806b-b955-c8c8e42379af" class="css-of5acw"><img src="https://static.toss.im/ipd-tcs/toss_core/live/72926a87-a524-4f80-baa9-91eaf8492089/hotfix-5-crashpath.png" alt="" class="css-1pgssrp"/></figure><p id="3c7a360d-33e3-80ee-b0e2-cf6a557d7507" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">분석 결과는 개발자와 나누도록 한 덩어리로 보냅니다. 덕분에 재현 방법을 찾느라 시간을 흘려보내지 않고, 바로 개발자와 원인을 논의합니다. 재현이 빨라진 만큼, 고칠지 말지 판단하는 것도 빨라집니다.</span></span></span></p><p id="3c7a360d-33e3-80e3-b09c-f91e627bb26c" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"></span></span></p><h2 id="3c7a360d-33e3-80bd-9667-ebdfcf86e4f5" class="css-1c1qox8"><span class="css-1r3ko7u"><span class="css-p4abj2"><span class="css-1kxrhf3">남아 있어야 돌아봅니다</span></span></span></h2><p id="3c7a360d-33e3-8005-87df-d334450e582c" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">여기서부터는 막은 다음의 이야기입니다. 신중하게 판단하고, 크래시를 빠르게 재현하고, 배포 뒤를 지켜보는 데까지가 문제를 막는 과정이었다면, 이제부터는 같은 사고가 두 번 나지 않게 관리하는 일입니다.</span></span></span></p><p id="3c7a360d-33e3-80f0-9812-dbc5637f07fa" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">그런데 관리를 하려면 먼저 돌아볼 게 남아 있어야 합니다. 그날 왜 그렇게 판단했는지, 무엇으로 고쳤는지가 사라지면 다음 달엔 기억에 기댈 수밖에 없으니까요. 이 일을 처음 시작했을 때 가장 먼저 부딪힌 게 바로 이것이었습니다. 핫픽스 기록이 한군데에 있지 않았습니다. iOS와 Android가 서로 다른 곳에, 서로 다른 형식으로 적고 있었고, 지난 사고 하나를 찾으려면 여기저기를 뒤져야 했습니다.</span></span></span></p><p id="3c7a360d-33e3-8046-b3ac-d1c8a8d45d51" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">그래서 흩어져 있던 기록을 한 틀로 맞춰 토션에 모았습니다. </span></span></span></p><p id="3c7a360d-33e3-8006-a5e5-fff8a66cdd6f" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">어떤 버전에서 언제 발생했고, 사용자가 겪은 증상은 무엇이며, 기술적 원인과 원인 종류는 무엇이고, 무엇으로 고쳤고 어떻게 막을지까지. 제각각이던 기록을 같은 항목으로 정리하고 나니, 비로소 한자리에 놓고 견줘 볼 수 있게 됐습니다. 그리고 이걸 특정 담당자만 보는 게 아니라 누구나 열어 볼 수 있게 했습니다. 기록은 쌓아두는 것보다 함께 보는 게 중요하니까요.</span></span></span></p><p id="3c7a360d-33e3-8038-be06-cc187eeece5c" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">토션에 쌓는 건 진행한 핫픽스만이 아닙니다. 하지 않기로 한 것도 함께 쌓습니다.</span></span></span></p><figure id="3c7a360d-33e3-8019-a327-f5427339ac1a" class="css-of5acw"><img src="https://static.toss.im/ipd-tcs/toss_core/live/72c3afc1-a74a-4f69-bbbd-5dfb39da8de9/hotfix-7-mijin.png" alt="" class="css-1pgssrp"/></figure><p id="3c7a360d-33e3-80bf-bc08-c0376599cab5" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">앞에서 저희 기준은 우선순위가 아니라 진행하느냐 다음 배포로 넘기느냐라고 했는데요. 그래서 넘기기로 한 건에는 왜 넘겼는지 사유를 함께 남깁니다. 이용률이 낮아서, 특정 조건에서만 생겨서, 우회할 방법이 있어서처럼요. 언뜻 안 한 일은 남길 게 없어 보이지만, 저희에겐 이게 다음 판단의 교보재가 됩니다.</span></span></span></p><p id="3c7a360d-33e3-80b4-9eda-c463db6ee310" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">비슷한 상황이 또 왔을 때, 지난번엔 이런 이유로 넘겼다는 근거를 꺼내 볼 수 있으니까요. 판단이 사람 머릿속에만 있으면 그 사람이 자리에 없을 때 흔들리지만, 사유가 쌓이면 기준이 됩니다. 안 한 판단도 판단이고, 그 판단에도 근거가 남아 있어야 다음 판단도 일관될 수 있습니다.</span></span></span></p><p id="3c7a360d-33e3-809d-b2b7-d833ae79a4f5" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">그런데 이 기록을 어떻게 남기느냐에서, 저는 한 번 크게 틀렸습니다. 핫픽스가 끝나면 왜 생겼고 어떻게 막을지를 남겨야 하는데, 그 방식을 잘못 잡았습니다.</span></span></span></p><p id="3c7a360d-33e3-807c-8a40-f7f51b3f15ee" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">처음 세운 가설은 이랬습니다. 사후기록을 일부러 번거롭게 만들면, &quot;이거 굳이 핫픽스까지 해야 하나?&quot; 하는 자각이 생겨서 핫픽스가 줄지 않을까?</span></span></span></p><p id="3c7a360d-33e3-80ed-a48e-ffc200121605" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">결과는 정반대였습니다. 기록이 번거로워지자 사람들은 행동이 아니라 기록을 줄였습니다. 핫픽스는 그대로인데 기록만 부실해졌어요. 나중에야 알았습니다. 마찰을 행동 앞이 아니라 기록 앞에 둔 게 실수였다는 걸요. 마찰은 언제나 가장 가까운 것부터 회피하게 만듭니다. 품질 문화는 장치 하나로 움직이지 않는데, 저희는 사후기록 하나만 조이려 했던 겁니다.</span></span></span></p><figure id="3c7a360d-33e3-807b-9147-f3b9c5247db6" class="css-of5acw"><img src="https://static.toss.im/ipd-tcs/toss_core/live/0043a121-1dd2-42f8-b622-f92600d95fb4/hotfix-8-autofill.png" alt="" class="css-1pgssrp"/></figure><p id="3c7a360d-33e3-8080-a214-ca6d753ef610" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">그래서 방향을 뒤집었습니다. 기록을 어렵게 만드는 대신, 쉽게 만들고 맥락을 풍부하게 채우기로요. 핫픽스는 대부분 채널의 대화에서 시작되니, 그 논의 맥락을  지능형 AI 봇이 사후기록 초안에 미리 채워둡니다. 사람은 처음부터 쓰는 게 아니라, 채워진 초안을 검토하고 빠진 것만 보탭니다. 기록의 부담은 낮아지고, 논의 맥락은 오히려 더 잘 남습니다.</span></span></span></p><p id="3c7a360d-33e3-807a-9e39-d64d46b3cc5b" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"></span></span></p><h2 id="3c7a360d-33e3-8079-82df-d657c9c8428f" class="css-1c1qox8"><span class="css-1r3ko7u"><span class="css-p4abj2"><span class="css-1kxrhf3">같은 사고를 두 번 내지 않으려면</span></span></span></h2><p id="3c7a360d-33e3-806a-8ade-d856cb32b52a" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">기록을 모으는 건 시작일 뿐입니다. 쌓아두기만 해서는 핫픽스가 저절로 줄지 않으니까요. 줄이려면 현황을 보고 무엇을 바꿀지를 정해야 합니다. 그래서 매달 한 번, Android, iOS, QA가 함께 모이는 자리를 만들었습니다.</span></span></span></p><figure id="3c7a360d-33e3-808b-ac81-f6724abda50a" class="css-of5acw"><img src="https://static.toss.im/ipd-tcs/toss_core/live/2cfcf493-42fc-4d62-9dd9-787bf83da647/hotfix-9-council.png" alt="" class="css-1pgssrp"/></figure><p id="3c7a360d-33e3-803b-be73-cb431a0436f4" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">이 자리에서 하는 일은 세 가지입니다. 지난달 핫픽스를 함께 돌아보고, 되풀이되는 원인에는 재발방지 액션을 정하고, 앞서 정했던 액션이 실제로 지켜졌는지 확인합니다. 이번 달에 새 다짐 하나를 더 얹고 끝나는 게 아니라, 저번 달 다짐이 지켜졌는지를 매번 되짚습니다. 핫픽스를 줄이는 건 장치 하나가 아니라, 이렇게 매달 돌아보는 반복이 하는 일이었습니다.</span></span></span></p><p id="3c7a360d-33e3-80a5-ba02-e972b5b9304c" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">그 확인이 가장 크게 걸리는 데가 재발 방지입니다. 재발 방지는 문서에 &quot;이렇게 막겠습니다&quot;라고 적는 걸로 끝나기 쉽습니다. 적어두고 아무도 안 하면 그만이니까요. 저희는 여기서 한 걸음 더 갑니다. 실제로 이행됐는지까지 추적합니다.</span></span></span></p><p id="3c7a360d-33e3-80f3-aca3-e87b1c3c07df" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">토션에서 재발 방지는 핫픽스와 별개의 항목으로 관리됩니다. 어떤 원인을 겨냥한 조치인지, 무엇을 했는지, 증적은 어디 있는지가 남습니다. 그리고 그 조치를 이슈 티켓으로 연결해두면, 티켓이 실제로 닫혔는지를 시스템이 주기적으로 확인해 이행 상태를 자동으로 갱신합니다. 사람이 &quot;했다&quot;고 체크하는 게 아니라서, 이 상태는 화면에서 손으로 바꿀 수도 없습니다.</span></span></span></p><p id="3c7a360d-33e3-8074-b089-e7832981bfa1" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">이 기록들은 타임라인 위에 쌓입니다. 정기 배포와 핫픽스, 재발 방지 조치가 한 축에 나란히 놓여서, 언제 무엇이 터졌고 그 뒤에 무엇을 했는지가 한눈에 보입니다. 하겠다고 적어놓고 미룬 게 있으면, 다음 달 그 자리에 그대로 미이행으로 올라옵니다. 재발 방지가 &#x27;적어둔 다짐&#x27;이 아니라 &#x27;끝까지 확인하는 약속&#x27;이 되는 이유입니다.</span></span></span></p><figure id="3c7a360d-33e3-80a5-a23a-d977e5e7bfa6" class="css-of5acw"><img src="https://static.toss.im/ipd-tcs/toss_core/live/e51a9039-4766-4432-8472-b278bd2fb5ef/hotfix-11-card.png" alt="" class="css-1pgssrp"/></figure><p id="3c7a360d-33e3-8066-bd5f-fefbda277229" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">이 모든 기록은 결국 지표가 됩니다. 핫픽스는 감으로 관리할 수 없으니, 남긴 기록을 숫자로 봅니다.</span></span></span></p><p id="3c7a360d-33e3-80e8-b05c-e3e075d07a2d" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">원인을 여섯 가지(코드 결함, 의존성·SDK, 배포 실수, 환경, 외부 API, 기타)로 나눠 어디서 자주 터지는지 봅니다. 어떤 원인이 되풀이되는지가 쌓이면, 다음에 무엇을 먼저 막아야 할지가 보입니다.</span></span></span></p><p id="3c7a360d-33e3-8012-98e1-c0a2799efda3" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">프로세스를 정비하고 이 관리 체계를 갖춘 뒤로, 토스 앱의 핫픽스는 줄어들고 있습니다.</span></span></span></p><figure id="3c7a360d-33e3-8001-9f70-eb4a45006c4e" class="css-of5acw"><img src="https://static.toss.im/ipd-tcs/toss_core/live/71dfbca2-f88d-4a42-a5cd-1fe5dec0788b/hotfix-10-trend.png" alt="" class="css-1pgssrp"/></figure><h2 id="3c7a360d-33e3-8065-b8f7-f453bbac814d" class="css-1c1qox8"><span class="css-1r3ko7u"><span class="css-p4abj2"><span class="css-1kxrhf3">완성된 규칙이 아니라, 계속 다듬는 규칙</span></span></span></h2><p id="3c7a360d-33e3-8017-a6e1-e4fb13127c41" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">지금까지 소개한 것들은 처음부터 이 모습이 아니었습니다. 시간 버퍼를 두자던 규칙도, 사후기록을 번거롭게 만들자던 시도도, 앞에서 이야기한 대로 부딪히고 뒤집으며 지금 자리에 왔습니다. 그래서 이 프로세스에는 &#x27;완성&#x27;이 없습니다. 매달 조금씩 더하고 덜어내며, 지금도 바뀌고 있습니다.</span></span></span></p><p id="3c7a360d-33e3-80b3-83c0-e0df8e62d118" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">돌아보면, 글머리에서 모순이라 불렀던 것도 마찬가지였습니다. 점진 배포와 핫픽스는 애초에 모순이 아니었어요. 점진 배포는 문제를 좁은 곳에서 먼저 만나게 하고, 핫픽스는 그렇게 드러난 문제를 빠르게 막습니다. 사용자의 피해를 줄인다는 같은 목적을, 앞과 뒤에서 나눠 맡을 뿐입니다. 정말 부딪혔던 건 배포 방식이 아니라, &#x27;1%&#x27;라는 같은 신호가 상황에 따라 정반대로도 읽힐 수 있다는 점이었습니다. 저희가 만든 건 그 둘을 잇는 판단이었습니다.</span></span></span></p><p id="3c7a360d-33e3-80d1-943f-f3c36ce55f8a" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">핫픽스는 없는 게 가장 좋습니다. 하지만 매주 새 버전을 내보내는 이상, 0이 되기는 어렵습니다. 그래서 저희의 일은 세 가지로 정리됩니다. 굳이 안 해도 될 핫픽스는 하지 않고, 해야 할 핫픽스는 신중하고 안전하게 하고, 한 번 난 사고는 두 번 나지 않게 만드는 것. 빠르게 고치는 것보다 먼저 필요한 것은, 지금 고쳐야 하는 문제인지 제대로 판단하는 일입니다.</span></span></span></p><p id="3c7a360d-33e3-808f-a0e9-f53c4486643a" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"></span></span></p></div><div id="tds-mobile-portal-container"></div>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[토스증권 추천과 검색은 어떻게 진화하고 있을까? ]]></title>
            <link>https://toss.tech/article/tech_talk_talk_3</link>
            <guid isPermaLink="true">https://toss.tech/article/tech_talk_talk_3</guid>
            <pubDate>Wed, 26 Aug 2026 01:00:00 GMT</pubDate>
            <description><![CDATA[추천, RAG, 그래프 검색을 만들며 마주한 문제와 선택]]></description>
            <content:encoded><![CDATA[<link rel="preload" as="image" href="https://static.toss.im/ipd-tcs/toss_core/live/cee1cbf3-b162-46b4-a21c-463038704d34/스크린샷_2026-08-24_오전_9.21.23.png"/><link rel="preload" as="image" href="https://static.toss.im/ipd-tcs/toss_core/live/4425442c-8524-437c-a4e6-4106ccd69cac/스크린샷_2026-08-24_오전_9.21.51.png"/><link rel="preload" as="image" href="https://static.toss.im/ipd-tcs/toss_core/live/7b31038d-19c8-4c48-b3cb-acf753fb5942/스크린샷_2026-08-24_오전_9.21.44.png"/><link rel="preload" as="image" href="https://static.toss.im/ipd-tcs/toss_core/live/f6964cb4-9928-4a25-9a47-a39fa59e2fa9/스크린샷_2026-08-24_오전_9.22.03.png"/><link rel="preload" as="image" href="https://static.toss.im/ipd-tcs/toss_core/live/df0dc75d-f949-4ddb-9386-7315fac68058/스크린샷_2026-08-24_오전_9.22.21.png"/><link rel="preload" as="image" href="https://static.toss.im/ipd-tcs/toss_core/live/441b6621-c3f4-44cc-b2f4-9afcf9d9f823/스크린샷_2026-08-24_오전_9.28.53.png"/><link rel="preload" as="image" href="https://static.toss.im/ipd-tcs/toss_core/live/ed508e0b-37b1-4489-ae13-6b14eaf71c9c/스크린샷_2026-08-24_오전_9.28.35.png"/><link rel="preload" as="image" href="https://static.toss.im/ipd-tcs/toss_core/live/02d3fdc8-0581-4f9d-bae1-9df18dd9e41a/스크린샷_2026-08-24_오전_9.28.27.png"/><link rel="preload" as="image" href="https://static.toss.im/ipd-tcs/toss_core/live/01e2d163-4557-476c-b9b7-a7be40b0504b/스크린샷_2026-08-24_오전_9.30.36.png"/><link rel="preload" as="image" href="https://static.toss.im/ipd-tcs/toss_core/live/024a35a8-177f-4dc4-be02-49291ce0a4ee/스크린샷_2026-08-24_오전_9.30.46.png"/><link rel="preload" as="image" href="https://static.toss.im/ipd-tcs/toss_core/live/b8ad6df6-338e-4944-9c63-318a401a470e/스크린샷_2026-08-24_오전_9.31.12.png"/><link rel="preload" as="image" href="https://static.toss.im/ipd-tcs/toss_core/live/34cb1297-cd4e-4930-b8ff-755e27cb6490/스크린샷_2026-08-24_오전_9.38.29.png"/><link rel="preload" as="image" href="https://static.toss.im/ipd-tcs/toss_core/live/ab3de514-1b32-40f1-a220-162d4b4d1d3f/image.png"/><link rel="preload" as="image" href="https://static.toss.im/ipd-tcs/toss_core/live/fa8100ff-034b-4dd5-ba1b-77533e2a1ba1/image.png"/><div class="css-1vn47db"><p id="3c7a360d-33e3-80b4-9451-df51ca6b6bf4" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">안녕하세요, 토스증권 AI 데이터 플랫폼 팀에서 ML 엔지니어로 일하고 있는 황호익입니다.</span></span></span></p><p id="3c7a360d-33e3-8015-96ca-d49422ec5450" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">이번 글에서는 토스증권 안에서 추천과 검색이 어떤 방향으로 진화하고 있는지 이야기해보려고 해요. 추천은 어떻게 배치 중심에서 실시간 개인화로 바뀌고 있는지, 검색은 어떻게 키워드와 필터를 넘어 RAG와 그래프 RAG로 확장되고 있는지 순서대로 다룰게요.</span></span></span></p><p id="3c7a360d-33e3-802c-9fef-ce02857658eb" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">완성된 정답이라기보다는, 시스템을 만들면서 마주친 고민들이라고 봐주시면 좋겠습니다.</span></span></span></p><p id="3c7a360d-33e3-8017-ace1-f602b5cf3f27" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"></span></span></p><h2 id="3c7a360d-33e3-801a-b066-e7e9d833afc1" class="css-1c1qox8"><span class="css-1r3ko7u"><span class="css-p4abj2"><span class="css-1kxrhf3">토스증권 AI 데이터 플랫폼 팀의 미션</span></span></span></h2><blockquote id="3c7a360d-33e3-8027-857b-ea72094d9b81" class="css-2sk6rv"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">토스증권의 모든 AI 서비스가 공통적으로 접근할 수 있는 탄탄한 데이터 생태계를 제공하자.</span></span></span></blockquote><figure id="3c7a360d-33e3-80bc-9a0a-f36b49f0e713" class="css-of5acw"><img src="https://static.toss.im/ipd-tcs/toss_core/live/cee1cbf3-b162-46b4-a21c-463038704d34/스크린샷_2026-08-24_오전_9.21.23.png" alt="" class="css-1pgssrp"/></figure><p id="3c7a360d-33e3-80eb-b562-d4b31e7d5e23" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">AI 제품이 많아질수록 각 서비스가 필요한 데이터를 각자 수집하고, 각자 가공하고, 각자 검색하는 방식에는 한계가 생깁니다. 데이터 품질을 맞추기도 어렵고, 비슷한 문제를 여러 팀이 반복해서 풀게 되거든요.</span></span></span></p><p id="3c7a360d-33e3-805c-92d4-e893e3c53ffe" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">그래서 이런 기반을 만들고 있어요.</span></span></span></p><aside id="3c7a360d-33e3-805c-975e-d748d847c12b" class="css-nv7vyi"><div class="css-1vn47db"><ul class="css-uswsmm"><li id="3c7a360d-33e3-802f-a1fe-e14ce9aecdeb" class="css-1hwiibq"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">원천 데이터를 수집하고 가공하는 파이프라인</span></span></span></li><li id="3c7a360d-33e3-80d1-a397-d11be731e105" class="css-1hwiibq"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">실시간·배치 피처를 제공하는 피처 스토어</span></span></span></li><li id="3c7a360d-33e3-802f-b6f6-e9f0a7230c81" class="css-1hwiibq"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">자연어 기반 검색을 위한 RAG</span></span></span></li><li id="3c7a360d-33e3-800e-82e9-c430fc719f22" class="css-1hwiibq"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">파편화된 금융 정보를 지식화하기 위한 지식 그래프</span></span></span></li></ul></div></aside><p id="3c7a360d-33e3-808b-a7b1-dd39436e14cc" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">오늘 이야기할 추천과 검색도 결국 이 공통 기반 위에서 함께 진화하고 있는 문제들입니다.</span></span></span></p><p id="3c7a360d-33e3-80bd-b018-cfb2e36503dc" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"></span></span></p><h2 id="3c7a360d-33e3-80b6-969a-d80a949d9b8f" class="css-1c1qox8"><span class="css-1r3ko7u"><span class="css-p4abj2"><span class="css-1kxrhf3">추천</span></span></span></h2><h3 id="3c7a360d-33e3-806e-bb3b-e974d2503831" class="css-1feg9au"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">예전에는 배치와 클러스터로 충분했어요</span></span></span></h3><p id="3c7a360d-33e3-805e-96d7-f2ec69dece6b" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">토스증권 추천에는 비교적 명확한 문제가 많았습니다. 개인화 뉴스 추천, 관심 종목 추천, 커뮤니티 인기글 추천 같은 것들이요.</span></span></span></p><p id="3c7a360d-33e3-8040-a49e-ed920e0153c1" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">이때는 유저와 아이템 데이터를 모아 배치로 처리하고, 유저를 클러스터링해서 클러스터별 추천 리스트를 미리 계산해두는 방식으로도 꽤 많은 문제를 풀 수 있었어요. 유저 DB에서 여러 원천 데이터를 배치 스케줄로 가져와 유저별 클러스터 정보를 만들고, 그걸 기반으로 추천 아이템 리스트를 만들어 Redis나 MongoDB 같은 저장소에 넣어두는 식이었죠.</span></span></span></p><p id="3c7a360d-33e3-804d-a37c-e07da72c67a3" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">중요한 건 이 방식이 틀렸다는 게 아닙니다. 당시에는 제품 생애주기도 빠르고 추천해야 하는 콘텐츠 종류도 지금보다 단순했기 때문에, 배치 중심·클러스터 중심의 접근이 현실적인 선택이었어요.</span></span></span></p><h3 id="3c7a360d-33e3-8070-b52a-f47428dca0c7" class="css-1feg9au"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">그런데 네 가지가 바뀌었어요</span></span></span></h3><p id="3c7a360d-33e3-80c6-95fd-cc326e589cec" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">서비스가 성장하면서 추천이 마주하는 문제가 조금씩 달라지기 시작했습니다.</span></span></span></p><figure id="3c7a360d-33e3-8073-833e-f0e879b8e362" class="css-of5acw"><img src="https://static.toss.im/ipd-tcs/toss_core/live/4425442c-8524-437c-a4e6-4106ccd69cac/스크린샷_2026-08-24_오전_9.21.51.png" alt="" class="css-1pgssrp"/></figure><p id="3c7a360d-33e3-80fe-a37b-d92733fb1a8f" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-q3ktjb">1️⃣ 유저의 관심사가 훨씬 빠르게 변해요
</span><span class="css-1kxrhf3">사용자는 앱 안에서 여러 화면을 탐색하고, 특정 종목을 보고, 뉴스를 읽고, 커뮤니티 글을 봅니다. 그 순간마다 관심 맥락이 계속 달라져요.</span></span></span></p><p id="3c7a360d-33e3-80dd-b63e-c1c9933c8eed" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">2️⃣</span><span class="css-q3ktjb"> 시장과 종목 이벤트가 계속 움직여요
</span><span class="css-1kxrhf3">금융 서비스에서는 같은 사용자라도 장중에 관심을 가져야 하는 주제가 계속 변할 수 있습니다.</span></span></span></p><p id="3c7a360d-33e3-80d5-97ec-d1d9125a2111" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-q3ktjb">3️⃣ 콘텐츠 자체가 다양해졌어요
</span><span class="css-1kxrhf3">뉴스, 커뮤니티, AI 콘텐츠 등 사용자가 탐색할 수 있는 콘텐츠가 계속 늘어나고 있어요.</span></span></span></p><p id="3c7a360d-33e3-80ec-bc98-c2ecaababa46" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-q3ktjb">4️⃣ 기술적인 조건도 바뀌었어요
</span><span class="css-1kxrhf3">토스증권의 실시간 데이터·머신러닝 플랫폼이 성장하면서, 예전에는 비용 때문에 시도하기 어려웠던 고도화된 추천 시스템을 이제는 구축해서 운영해볼 수 있는 환경이 만들어졌습니다.</span></span></span></p><p id="3c7a360d-33e3-8077-b0da-f22f6e7d13f3" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">이런 배경에서 추천의 문제 정의가 </span><span class="css-q3ktjb">이 사용자가 원래 좋아할 만한 것을 제공하는 것에서, 지금 이 사용자의 맥락에서 필요한 것을 제공하는 방향으로</span><span class="css-1kxrhf3"> 이동했어요.</span></span></span></p><p id="3c7a360d-33e3-80db-b88e-d22ad9393a3a" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"></span></span></p><h2 id="3c7a360d-33e3-8071-8730-ff101a2b6b8d" class="css-1c1qox8"><span class="css-1r3ko7u"><span class="css-p4abj2"><span class="css-1kxrhf3">그래서 추천은 리스트가 아니라 루프가 됐어요</span></span></span></h2><p id="3c7a360d-33e3-802f-8cc7-eb1c70099392" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">지금 이 사용자의 맥락에서 필요한 것을 제공하려면, 유저의 행동 이벤트와 아이템 변경 이벤트가 계속 들어와야 하고, 그에 대한 유저의 피드백도 피처 스토어와 벡터 인덱스에 반영되어야 합니다. 추천 서비스는 그 위에서 노출할 콘텐츠를 찾고, 유저가 좋아할 만한 순서대로 보여주고요.</span></span></span></p><p id="3c7a360d-33e3-80bc-89fd-f26a54cf349a" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">그래서 추천은 정적인 리스트가 아니라, 실시간 이벤트와 피드백을 계속 흡수하는 루프 형태의 시스템이 됩니다.</span></span></span></p><figure id="3c7a360d-33e3-804f-bb94-c149cfe767c7" class="css-of5acw"><img src="https://static.toss.im/ipd-tcs/toss_core/live/7b31038d-19c8-4c48-b3cb-acf753fb5942/스크린샷_2026-08-24_오전_9.21.44.png" alt="" class="css-1pgssrp"/></figure><p id="3c7a360d-33e3-8082-bb5c-d6851d8ea321" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">흐름을 조금 더 자세히 보면,</span></span></span></p><aside id="3c7a360d-33e3-801d-bae0-c7e5ac087a2d" class="css-nv7vyi"><div class="css-1vn47db"><ol class="css-hokoge"><li id="3c7a360d-33e3-8041-9512-eb5987d9a629" class="css-1hwiibq"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">유저의 행동·액션 이벤트를 받아 저장하고 모델을 학습해요.</span></span></span></li><li id="3c7a360d-33e3-80c1-8076-e9a64aa021b7" class="css-1hwiibq"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">새로운 아이템이 생기거나 기존 아이템이 바뀌면 아이템 표현을 다시 만들어 벡터를 인덱싱해요.</span></span></span></li><li id="3c7a360d-33e3-8052-b04b-e607c222e03d" class="css-1hwiibq"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">추천 요청이 들어오는 온라인 서빙 시점에는, 학습된 리트리버가 벡터 인덱스에서 후보를 찾고 랭커가 점수를 매겨 최종 Top-N을 노출해요.</span></span></span></li></ol></div></aside><p id="3c7a360d-33e3-8000-bbb5-cff30b699445" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">필연적으로 이런 시스템에는 관측과 운영·디버깅 도구도 필요해집니다. 학습 데이터가 잘 만들어지고 있는지, 인덱스가 잘 갱신되고 있는지, 왜 특정 결과가 추천되었는지 확인할 수 있어야 하니까요.</span></span></span></p><figure id="3c7a360d-33e3-8098-928a-c90b519fc30b" class="css-of5acw"><img src="https://static.toss.im/ipd-tcs/toss_core/live/f6964cb4-9928-4a25-9a47-a39fa59e2fa9/스크린샷_2026-08-24_오전_9.22.03.png" alt="" class="css-1pgssrp"/></figure><p id="3c7a360d-33e3-80d4-915a-f4c9726380d2" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"></span></span></p><h2 id="3c7a360d-33e3-8045-9080-d13c59063c16" class="css-1c1qox8"><span class="css-1r3ko7u"><span class="css-p4abj2"><span class="css-1kxrhf3">운영하면서 마주친 두 가지 고민</span></span></span></h2><h3 id="3c7a360d-33e3-800a-a70e-e3e0e240ba79" class="css-1feg9au"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">임베딩 모델을 바꾸면 벡터 저장소 운영이 따라와요</span></span></span></h3><p id="3c7a360d-33e3-8049-983f-f7eaedd6eaad" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">추천을 고도화할수록 유저의 취향과 아이템의 맥락을 몇 가지 규칙만으로 표현하기 어려워집니다. 임베딩은 이런 복잡한 신호를 하나의 표현 공간에 담아, 대규모 후보에서 관련 있는 아이템을 효율적으로 찾게 해줍니다.</span></span></span></p><p id="3c7a360d-33e3-8080-bd2c-df0bed4ca548" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">그런데 임베딩 기반 추천을 실제 트래픽에 붙일 때는 모델 성능만 보면 안 됐어요. 임베딩 모델을 계속 고도화할 수 있도록 열어두는 순간, 벡터 저장소 운영도 같이 따라옵니다.</span></span></span></p><figure id="3c7a360d-33e3-80c8-848f-d9235397acf8" class="css-of5acw"><img src="https://static.toss.im/ipd-tcs/toss_core/live/df0dc75d-f949-4ddb-9386-7315fac68058/스크린샷_2026-08-24_오전_9.22.21.png" alt="" class="css-1pgssrp"/></figure><p id="3c7a360d-33e3-805e-a057-ddc7aef28949" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">임베딩 모델이 바뀌면 기존 벡터와 새 벡터가 같은 공간에 있다고 보기 어려워요. 그래서 이런 것들이 필요했습니다.</span></span></span></p><aside id="3c7a360d-33e3-80cf-89e2-fc5676a2d283" class="css-nv7vyi"><div class="css-1vn47db"><ul class="css-uswsmm"><li id="3c7a360d-33e3-8094-959b-dc7de8792b56" class="css-1hwiibq"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-q3ktjb">벡터의 생성 버전을 추적하기.</span><span class="css-1kxrhf3"> 각 벡터가 어떤 모델에서 생성됐는지 명시해, 서로 다른 임베딩 공간이 섞이지 않도록 했습니다.</span></span></span></li><li id="3c7a360d-33e3-80f7-9c61-ddba08993082" class="css-1hwiibq"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-q3ktjb">서빙 버전의 일관성을 유지하기.</span><span class="css-1kxrhf3"> 온라인에서 사용하는 임베딩 모델과 검색 대상 인덱스가 항상 같은 버전을 바라보도록 했습니다.</span></span></span></li><li id="3c7a360d-33e3-808e-89be-d47d5571b80f" class="css-1hwiibq"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-q3ktjb">모델과 인덱스를 함께 교체하기.</span><span class="css-1kxrhf3"> 새 모델을 배포할 때는 벡터 재생성부터 새 인덱스 구축과 전환까지 하나의 작업으로 관리했습니다.</span></span></span></li></ul></div></aside><p id="3c7a360d-33e3-80f7-9191-d44165018a09" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"></span></span></p><h3 id="3c7a360d-33e3-80d6-bb35-c266094686fc" class="css-1feg9au"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">벡터 DB를 </span><span class="css-q3ktjb">검색 성능만으로 검증할 수 없었어요</span></span></span></h3><p id="3c7a360d-33e3-8024-a227-c6ca8de4cfb0" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">추천에서 유사 아이템을 찾기 위한 벡터 검색은 반드시 필요했어요. 그런데 사용자의 과거 선호 아이템이나 최근 행동을 피처로 쓰기 위해, 여러 아이템 임베딩을 한 번에 가져오는 멀티겟 연산도 발생합니다.</span></span></span></p><p id="3c7a360d-33e3-8090-b329-e4c2b6ec79b2" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">과거 아이템의 임베딩을 대량으로 조회하는 워크로드에서는 고차원 벡터의 응답 생성으로 JVM 메모리 압력이 커질 수 있었습니다. 그래서 검색 지연 뿐 아니라 </span><code style="font-family:Consolas, Menlo, Monaco, source-code-pro, &quot;Courier New&quot;, monospace;font-size:0.9em;background-color:var(--adaptiveOpacity100);border:1px solid var(--adaptiveOpacity200);border-radius:3px"><span class="css-1mjnzsq">mget</span></code><span class="css-1kxrhf3"> 처리량과 GC 추이도 실제 요청 패턴으로 검증해야 했습니다.</span></span></span></p><p id="3c7a360d-33e3-8056-a135-c69bf62a83fb" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"></span></span></p><h2 id="3c7a360d-33e3-8097-9718-d613a87f0bc8" class="css-1c1qox8"><span class="css-1r3ko7u"><span class="css-p4abj2"><span class="css-1kxrhf3">검색, 그리고 RAG</span></span></span></h2><h3 id="3c7a360d-33e3-809a-bb55-c438012cefe5" class="css-1feg9au"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">데이터를 가져오는 방식이 파편화되고 있었어요</span></span></span></h3><p id="3c7a360d-33e3-80fd-b89b-fa011a07759a" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">LLM을 활용하는 서비스가 하나둘 생기면서 비슷한 구조가 반복됐습니다. LLM으로 답변을 만들려면 결국 어떤 데이터를 가져와야 하거든요. 뉴스, 공시, 리포트 같은 것들이요.</span></span></span></p><figure id="3c7a360d-33e3-805c-aa60-e31f741fda96" class="css-of5acw"><img src="https://static.toss.im/ipd-tcs/toss_core/live/441b6621-c3f4-44cc-b2f4-9afcf9d9f823/스크린샷_2026-08-24_오전_9.28.53.png" alt="" class="css-1pgssrp"/></figure><p id="3c7a360d-33e3-8013-8fad-ce02a24417a6" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">그런데 서비스마다 그 방식이 조금씩 달랐어요.</span></span></span></p><aside id="3c7a360d-33e3-80a6-8787-d90c37f10ce2" class="css-nv7vyi"><div class="css-1vn47db"><ul class="css-uswsmm"><li id="3c7a360d-33e3-80ae-81a3-c5203c65e611" class="css-1hwiibq"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">어떤 서비스는 Elasticsearch에 직접 쿼리를 작성했고</span></span></span></li><li id="3c7a360d-33e3-80f2-ad01-e7856a7faca2" class="css-1hwiibq"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">어떤 서비스는 필터를 별도로 고안해서 조합했고</span></span></span></li><li id="3c7a360d-33e3-80f0-8feb-dcf4f2452eff" class="css-1hwiibq"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">새로운 서비스의 PoC 단계에서는 원하는 데이터를 잘 찾아내는 쿼리를 먼저 찾아내는 과정이 매번 필요했습니다.</span></span></span></li></ul></div></aside><p id="3c7a360d-33e3-804f-8c89-eef1050a77c2" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">여기서 기존의 키워드 검색이나 필터 기반 검색이 항상 나쁜 건 아니에요. 오히려 명확한 조건을 알고 있을 때는 굉장히 강력합니다. 하지만 사용자가 처음부터 정확한 검색어를 알고 있지 않거나, 특정 상황을 설명하는 뉴스처럼 맥락적으로 찾고 싶을 때는 한계가 분명했어요.</span></span></span></p><p id="3c7a360d-33e3-80df-8a7f-c9508c704029" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">LLM 서비스는 늘어나는데 데이터를 가져오는 방식은 파편화되는 조짐. 저희는 이 지점을 문제로 봤습니다.</span></span></span></p><p id="3c7a360d-33e3-80d7-b2fb-c533199511c0" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"></span></span></p><h3 id="3c7a360d-33e3-80de-ad90-e6474053564c" class="css-1feg9au"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">RAG를 &#x27;검색을 제품화하는 문제&#x27;로 봤어요</span></span></span></h3><p id="3c7a360d-33e3-8036-8e40-e93d6233132d" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">RAG를 단순히 LLM에 문서를 붙이는 기술로 보기보다, 검색을 제품화하는 문제로 보기 시작했어요.</span></span></span></p><figure id="3c7a360d-33e3-80a1-af45-c74035bc92ae" class="css-of5acw"><img src="https://static.toss.im/ipd-tcs/toss_core/live/ed508e0b-37b1-4489-ae13-6b14eaf71c9c/스크린샷_2026-08-24_오전_9.28.35.png" alt="" class="css-1pgssrp"/></figure><p id="3c7a360d-33e3-8058-b012-ecb44076107c" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">LLM이 좋은 답변을 만들려면 좋은 컨텍스트를 함께 전달해야 합니다. 그러려면 사용자의 질문이 들어왔을 때 이런 판단이 필요해요.</span></span></span></p><aside id="3c7a360d-33e3-80ef-b126-ef1d9efd2a44" class="css-nv7vyi"><div class="css-1vn47db"><ul class="css-uswsmm"><li id="3c7a360d-33e3-80a9-a270-c5b4de00b916" class="css-1hwiibq"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">어떤 데이터를 찾아야 하는가</span></span></span></li><li id="3c7a360d-33e3-8010-863a-e825fbfa84df" class="css-1hwiibq"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">어떤 검색을 수행할 것인가</span></span></span></li><li id="3c7a360d-33e3-8081-bf84-ce4d6e3c89ce" class="css-1hwiibq"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">검색 결과 중 어떤 문서가 답변에 쓸 만한가</span></span></span></li></ul></div></aside><p id="3c7a360d-33e3-80c9-a56d-d584f3d52f38" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">이 과정이 서비스마다 파편화되면 품질도 흔들리고 운영 부담도 커집니다. 특히 LLM은 사실과 다른 정보를 생성할 수 있기 때문에, 답변이 검색된 문서에 잘 그라운딩되어 있는지 확인하는 일도 공통으로 중요해지고요.</span></span></span></p><p id="3c7a360d-33e3-802b-be64-ea13495b08f9" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">그래서 RAG를 공통 플랫폼으로 제공할 수 있다고 봤습니다.</span></span></span></p><p id="3c7a360d-33e3-8073-b1f0-c83c3063b2b2" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"></span></span></p><h2 id="3c7a360d-33e3-809d-bfc3-ee8e9cc0636b" class="css-1c1qox8"><span class="css-1r3ko7u"><span class="css-p4abj2"><span class="css-1kxrhf3">뉴스 RAG 파이프라인</span></span></span></h2><p id="3c7a360d-33e3-8037-96e1-c271623c98c7" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">사용자가 &quot;오늘 미국 증시 요약해줘&quot;처럼 질문합니다. 동시에 그 질문을 더 잘 이해하기 위한 단계가 시작돼요.</span></span></span></p><figure id="3c7a360d-33e3-805e-a1e9-edac76d1449d" class="css-of5acw"><img src="https://static.toss.im/ipd-tcs/toss_core/live/02d3fdc8-0581-4f9d-bae1-9df18dd9e41a/스크린샷_2026-08-24_오전_9.28.27.png" alt="" class="css-1pgssrp"/></figure><p id="3c7a360d-33e3-80a8-8954-edbad1d434f1" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-q3ktjb">질문 이해.</span><span class="css-1kxrhf3"> 이 질문이 어떤 카테고리의 뉴스를 원하는지 분류하고, 날짜 조건과 검색 범위를 만들어내고, 임베딩을 생성해 쿼리 벡터를 만듭니다. 이렇게 만들어진 조건들을 합쳐 Elasticsearch 쿼리를 구성해요.</span></span></span></p><p id="3c7a360d-33e3-80a3-a5b2-e9a3561e0649" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-q3ktjb">하이브리드 검색.</span><span class="css-1kxrhf3"> 검색은 하나의 방식만 쓰지 않습니다. 텍스트 검색과 벡터 검색을 조합하고, 메타데이터 필터를 함께 적용해 질문과 관련된 후보 뉴스를 찾아요.</span></span></span></p><p id="3c7a360d-33e3-8013-a619-da12f15633aa" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-q3ktjb">리랭킹.</span><span class="css-1kxrhf3"> 검색 결과는 리랭커 모델을 통해 한 번 더 걸러내는 동시에 중요한 순서로 정렬합니다.</span></span></span></p><p id="3c7a360d-33e3-8008-a1aa-e155246285ef" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">최종적으로 정제된 결과만 각 서비스의 생성 단계로 전달돼요.</span></span></span></p><p id="3c7a360d-33e3-8087-955e-db3d6aaad7cb" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">이 흐름에 포함된 모델은 대부분 LLM을 호출하는 방식으로 시작했지만, 우선순위에 따라 중요한 모델부터 하나씩 내재화하는 방식으로 구축했습니다.</span></span></span></p><p id="3c7a360d-33e3-80d5-aab1-c3c760ef33b4" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"></span></span></p><h2 id="3c7a360d-33e3-80e6-9881-d8f2b29bd0df" class="css-1c1qox8"><span class="css-1r3ko7u"><span class="css-p4abj2"><span class="css-1kxrhf3">RAG를 만들면서 마주한 것들</span></span></span></h2><p id="3c7a360d-33e3-80ad-a05d-d4b021d201ca" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-q3ktjb">벡터 검색은 키워드 중심 질의에서 한계가 있었어요
</span><span class="css-1kxrhf3">금융 특화 임베딩 모델을 학습했음에도, 모든 회사·제품·금융 전문 용어에 대해 학습이 고루 일어나기는 굉장히 어렵습니다. 특히 사용자가 전통적인 키워드 검색 쿼리를 던진 경우에 이 문제가 더 잘 드러났어요.</span></span></span></p><p id="3c7a360d-33e3-80cd-b8f2-fd8f24b54a42" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-q3ktjb">텍스트 검색은 사전 관리가 중요했어요
</span><span class="css-1kxrhf3">형태소 분석기가 아직 인식하지 못하는 새로운 금융 이벤트 관련 키워드들이 있었습니다. 이런 경우에는 사전을 업데이트하는 방식의 운영도 필요했어요.</span></span></span></p><p id="3c7a360d-33e3-8007-a87f-d94c0e1b5247" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-q3ktjb">리랭커로 마지막 노이즈를 줄였어요
</span><span class="css-1kxrhf3">의도가 확실한 질문이 들어왔는데 불필요한 뉴스가 한두 건 섞여 반환되면, 사용자는 &quot;그럴 수 있지&quot;라고 이해하기보다 시스템 자체의 신뢰도를 의심하게 됩니다. 이 부분은 LLM을 파인튜닝한 리랭커를 두면서 해결할 수 있었어요.</span></span></span></p><p id="3c7a360d-33e3-8088-882b-d28ccbb35297" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-q3ktjb">메타데이터는 또 하나의 검색 신호가 되었어요
</span><span class="css-1kxrhf3">뉴스 카테고리를 잘 분류하는 모델을 만들고, 유저 쿼리가 들어오면 함께 분류한 뒤 태그가 매칭되면 더 높은 점수를 주었습니다. 이걸로 검색 정확도를 높일 수 있었어요. 그리고 잘 구축된 분류 체계는 RAG 말고도 여러 제품에서 활용할 수 있었습니다.</span></span></span></p><p id="3c7a360d-33e3-801c-b69e-ffe746a4242f" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">결국 RAG의 검색 품질에는 하나의 은탄환이 존재하지 않았어요. 서로의 단점을 보완하는 모듈들이 점진적으로 추가되면서 개선되는 흐름이었습니다.</span></span></span></p><p id="3c7a360d-33e3-80b5-8e72-c18035da7186" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"></span></span></p><h2 id="3c7a360d-33e3-8083-8c7b-eca7307840e4" class="css-1c1qox8"><span class="css-1r3ko7u"><span class="css-p4abj2"><span class="css-1kxrhf3">제품은 달라도 흐름은 하나였어요</span></span></span></h2><p id="3c7a360d-33e3-8037-b095-ed40822fa83b" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">이렇게 만들어진 RAG 플랫폼은 하나의 제품에서만 쓰지 않았습니다. 뉴스 RAG만 예로 들어도 이런 니즈가 있었어요.</span></span></span></p><figure id="3c7a360d-33e3-80cc-a51c-c40af7e4fc61" class="css-of5acw"><img src="https://static.toss.im/ipd-tcs/toss_core/live/01e2d163-4557-476c-b9b7-a7be40b0504b/스크린샷_2026-08-24_오전_9.30.36.png" alt="" class="css-1pgssrp"/></figure><aside id="3c7a360d-33e3-8011-ac9e-f1791da93617" class="css-nv7vyi"><div class="css-1vn47db"><ul class="css-uswsmm"><li id="3c7a360d-33e3-80a5-b4aa-fe7118a9723d" class="css-1hwiibq"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">어떤 제품에서는 시황을 설명하는 뉴스를 찾아야 했고</span></span></span></li><li id="3c7a360d-33e3-8099-ac98-e47d958e4856" class="css-1hwiibq"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">어떤 제품에서는 기업의 가격 변동을 설명하는 뉴스를 찾아야 했고</span></span></span></li><li id="3c7a360d-33e3-80d1-93f7-d2369bf342c2" class="css-1hwiibq"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">어떤 제품에서는 투자 거장들의 투자 판단과 관련된 뉴스를 찾아야 했습니다.</span></span></span></li></ul></div></aside><p id="3c7a360d-33e3-80af-9412-feabb6346d2a" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">겉으로 보면 서로 다른 제품의 요구사항이에요. 그런데 플랫폼 관점으로 열어두면 모두 자연어 검색 API로 들어오고, 내부에서는 메타데이터 태그로 범위를 제어한 뒤 하이브리드 검색과 리랭킹을 수행하는 공통 흐름을 타게 됩니다.</span></span></span></p><p id="3c7a360d-33e3-802f-9888-d7fe8c2527c1" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">제품은 달라도, 컨텍스트를 만드는 흐름은 하나의 RAG 플랫폼으로 공통화될 수 있었어요.</span></span></span></p><p id="3c7a360d-33e3-8073-b0a6-e83cb064f696" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"></span></span></p><h2 id="3c7a360d-33e3-8098-87cf-ee890cbe04da" class="css-1c1qox8"><span class="css-1r3ko7u"><span class="css-p4abj2"><span class="css-1kxrhf3">그래프 RAG</span></span></span></h2><h3 id="3c7a360d-33e3-80a3-b84f-f7264c199fd3" class="css-1feg9au"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">문서의 유사도만으로 답할 수 없는 질문들</span></span></span></h3><p id="3c7a360d-33e3-8047-ba2d-d254289909dd" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">앞에서는 필요한 문서를 잘 찾고 그걸 컨텍스트로 만드는 흐름을 봤습니다. 그런데 금융 도메인에는 비슷한 문서들을 잘 찾는 것만으로 충분하지 않은 질문들이 있어요.</span></span></span></p><figure id="3c7a360d-33e3-8009-bb78-f2b04241c9da" class="css-of5acw"><img src="https://static.toss.im/ipd-tcs/toss_core/live/024a35a8-177f-4dc4-be02-49291ce0a4ee/스크린샷_2026-08-24_오전_9.30.46.png" alt="" class="css-1pgssrp"/></figure><aside id="3c7a360d-33e3-80c4-bd67-e88bfcd583b8" class="css-nv7vyi"><div class="css-1vn47db"><ul class="css-uswsmm"><li id="3c7a360d-33e3-8083-a49f-fbbb3518a67c" class="css-1hwiibq"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">어떤 기업이 어떤 산업 이벤트와 연결되어 있는가</span></span></span></li><li id="3c7a360d-33e3-8050-a25a-c8cff9bc6b8d" class="css-1hwiibq"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">어떤 제품이나 공급망을 통해 다른 기업과 연결되는가</span></span></span></li><li id="3c7a360d-33e3-80bb-a810-d1a2d00ca8d6" class="css-1hwiibq"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">특정 이벤트가 어떤 테마와 종목으로 이어지는가</span></span></span></li></ul></div></aside><p id="3c7a360d-33e3-8078-8301-f06f1a682137" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">관계를 따라가야 하는 질문들이죠. 그래서 그래프 RAG는 RAG의 연장선이긴 하지만, 문서를 잘 찾는 문제라기보다 파편화된 금융 지식 속에서 관계를 찾아내어 지식화하는 문제로 봐야 했습니다.</span></span></span></p><p id="3c7a360d-33e3-807b-912b-d4292b9c242f" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"></span></span></p><h2 id="3c7a360d-33e3-80d6-b183-cc2b238def88" class="css-1c1qox8"><span class="css-1r3ko7u"><span class="css-p4abj2"><span class="css-1kxrhf3">이 글에서 다루는 범위</span></span></span></h2><p id="3c7a360d-33e3-80bd-8177-c6b0df7f87df" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">전제가 하나 있어요. </span></span></span></p><p id="3c7a360d-33e3-80df-814f-e963f382c274" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">토스증권에서는 회사·종목·제품·이벤트·테마를 연결하는 금융 지식그래프 구축이 진행되고 있었습니다. 일반적인 GraphRAG 솔루션이 그래프 구축과 검색을 함께 다룬다면, 여기서는 이미 정의된 금융 온톨로지 위에서 제품이 사용할 검색기를 만드는 데 집중했습니다.</span></span></span></p><p id="3c7a360d-33e3-80ad-bf8a-ccf1cfdb11c5" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">따라서 엔티티 추출이나 링킹 과정은 다루지 않습니다. 이 글의 범위는 </span><span class="css-q3ktjb">사용자 질문과 관련된 서브그래프를 찾아 반환하는 검색 모듈</span><span class="css-1kxrhf3">입니다.</span></span></span></p><p id="3c7a360d-33e3-80b2-9f73-e33ca564a778" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"></span></span></p><h2 id="3c7a360d-33e3-8060-9f37-c40b40ce93f3" class="css-1c1qox8"><span class="css-1r3ko7u"><span class="css-p4abj2"><span class="css-1kxrhf3">그래프 검색기는 두 단계예요</span></span></span></h2><p id="3c7a360d-33e3-8090-9231-e52f94a494f3" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-q3ktjb">시작 지점 찾기.</span><span class="css-1kxrhf3"> 사용자 쿼리가 들어오면 앞서 설명한 검색 파이프라인을 활용한 방식으로 Elasticsearch를 사용해 관련 엔티티 후보를 찾습니다.</span></span></span></p><p id="3c7a360d-33e3-802f-976c-cd9b90c67bd3" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-q3ktjb">그래프 탐색.</span><span class="css-1kxrhf3"> 반환된 시작 지점에서 출발해 그래프 위에서 연결된 노드를 탐색하며 후보 경로를 확장하고 선별해나갑니다.</span></span></span></p><p id="3c7a360d-33e3-803f-a4db-ecfd40960f4a" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">이때 모든 관계를 다 따라갈 수는 없어요. 어떤 규칙으로 탐색할 것인지 정해야 하고, </span><span class="css-q3ktjb">이 규칙을 만들어내는 일이 검색 알고리즘의 핵심</span><span class="css-1kxrhf3">이 됩니다.</span></span></span></p><figure id="3c7a360d-33e3-806d-84c5-d3fd1c4abe47" class="css-of5acw"><img src="https://static.toss.im/ipd-tcs/toss_core/live/b8ad6df6-338e-4944-9c63-318a401a470e/스크린샷_2026-08-24_오전_9.31.12.png" alt="" class="css-1pgssrp"/></figure><p id="3c7a360d-33e3-8019-9d46-e708c28c34d2" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"></span></span></p><h2 id="3c7a360d-33e3-8030-af18-f374d054b189" class="css-1c1qox8"><span class="css-1r3ko7u"><span class="css-p4abj2"><span class="css-1kxrhf3">후보 경로가 5,000만 개로 늘어났어요</span></span></span></h2><p id="3c7a360d-33e3-804e-86c0-e44c8739920a" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">저희가 구축한 지식 그래프의 특성상, 의미 있는 서브그래프를 만들려면 3홉 이상 탐색해야 했습니다. 그런데 홉이 늘어날수록 탐색 경로가 기하급수적으로 늘어나요.</span></span></span></p><p id="3c7a360d-33e3-80ff-8a1a-f808cb1f0f7f" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">아무 전략 없이 수행하면 하나의 노드에서 출발한 경로가 이렇게 됩니다.</span></span></span></p><div data-table-wrapper="true" class="css-1m7s5mg"><div role="table" class="css-crnt2r"><div role="row" class="css-1obf64m"><div role="cell" class="css-8vl804"><span class="css-1mjnzsq">홉</span></div><div role="cell" class="css-8vl804"><span class="css-1mjnzsq">후보 경로 수</span></div></div><div role="row" class="css-1obf64m"><div role="cell" class="css-8vl804"><span class="css-1mjnzsq">1홉</span></div><div role="cell" class="css-8vl804"><span class="css-1mjnzsq">1,105건</span></div></div><div role="row" class="css-1obf64m"><div role="cell" class="css-8vl804"><span class="css-1mjnzsq">2홉</span></div><div role="cell" class="css-8vl804"><span class="css-1mjnzsq">134,520건</span></div></div><div role="row" class="css-1obf64m"><div role="cell" class="css-8vl804"><span class="css-1mjnzsq">3홉</span></div><div role="cell" class="css-8vl804"><span class="css-1mjnzsq">49,241,786건</span></div></div></div></div><p id="3c7a360d-33e3-80db-abf0-c5a6d11fd9c4" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">이 후보들을 모두 메모리에 유지한 채 점수화하고 정렬하는 작업은 Neo4j의 트랜잭션 메모리로 감당하기 어려웠습니다.</span></span></span></p><figure id="3c7a360d-33e3-805b-a73d-ea5569a94734" class="css-of5acw"><img src="https://static.toss.im/ipd-tcs/toss_core/live/34cb1297-cd4e-4930-b8ff-755e27cb6490/스크린샷_2026-08-24_오전_9.38.29.png" alt="" class="css-1pgssrp"/></figure><p id="3c7a360d-33e3-80d2-9e4a-f85febc5ff01" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">결국 문제는 얼마나 깊이 탐색할지가 아니라, 각 홉에서 어떤 경로를 일찍 버릴지였습니다.</span></span></span></p><p id="3c7a360d-33e3-80fb-b16e-fc044296fd32" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">이를 해결하기 위해</span><span class="css-q3ktjb"> Beam Search에서 착안한</span><span class="css-1kxrhf3"> 단계별 후보 제한 방식을 적용했습니다. 한 홉씩 관계를 확장하고 경로에 점수를 부여한 뒤, 상위 후보만 다음 홉에서 다시 확장했습니다. 최대 탐색 깊이와 홉별 후보 수를 제한하고, 불필요한 노드와 관계도 확장 전에 제외했습니다.</span></span></span></p><p id="3c7a360d-33e3-809c-b728-e318acaad4a6" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">덕분에 중간 후보 수를 통제하면서 3홉 탐색을 안정적으로 수행할 수 있었습니다. 다만 일찍 제외된 경로는 다시 살아날 수 없기 때문에, </span><span class="css-q3ktjb">어떤 경로에 높은 점수를 줄 것인지가 다음 과제가 되었습니다.</span></span></span></p><p id="3c7a360d-33e3-807c-8988-ff30fafaa5e9" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"></span></span></p><h2 id="3c7a360d-33e3-8083-8b63-f7c62a7b5ea1" class="css-1c1qox8"><span class="css-1r3ko7u"><span class="css-p4abj2"><span class="css-1kxrhf3">어떤 경로를 남길 것인가</span></span></span></h2><p id="3c7a360d-33e3-8020-b665-cb6352abdc92" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-q3ktjb">어떤 경로가 사용자 질문과 더 관련 있는지</span><span class="css-1kxrhf3">를 점수로 표현해야 했습니다. 질문과의 관련도를 중심에 두고, 금융 도메인에서 중요하게 보는 신호를 함께 반영했어요.</span></span></span></p><aside id="3c7a360d-33e3-8097-b017-d893775d3a78" class="css-nv7vyi"><div class="css-1vn47db"><ul class="css-uswsmm"><li id="3c7a360d-33e3-80af-8fed-fddbb9743907" class="css-1hwiibq"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">초기 Elasticsearch 검색에서 관련도가 높았던 엔티티나 관계가 경로에 포함되면 가중치를 더했습니다.</span></span></span></li><li id="3c7a360d-33e3-804e-bd7b-d6979de6732e" class="css-1hwiibq"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">종목의 중요도를 나타내는 신호로 시가총액을 반영했습니다.</span></span></span></li><li id="3c7a360d-33e3-80c3-a354-e7330cff9293" class="css-1hwiibq"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">이벤트는 최근에 발생한 것일수록 가중치를 더했습니다.</span></span></span></li></ul></div></aside><p id="3c7a360d-33e3-8041-9bea-ffaefd0aa8e2" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">시가총액과 최신성이 검색 결과를 결정하는 것은 아닙니다. 질문과의 관련성이 비슷한 후보들 사이에서, 금융적으로 더 의미 있는 경로가 앞에 남도록 돕는 보조 신호로 사용했습니다.</span></span></span></p><p id="3c7a360d-33e3-80a0-861e-d9cf1a57fae2" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"></span></span></p><h2 id="3c7a360d-33e3-80d4-8e27-dc71bbda7335" class="css-1c1qox8"><span class="css-1r3ko7u"><span class="css-p4abj2"><span class="css-q3ktjb">우선해야 할 경로를 검색 파라미터로 제어했어요</span></span></span></h2><p id="3c7a360d-33e3-80cd-9081-cd67e29c448a" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">질문과의 관련도가 높더라도, 제품마다 중요하게 보는 관계의 흐름은 달랐습니다. 예를 들어 어떤 제품에서는 다른 경로보다 기업과 기업 사이의 관계를 우선해서 보여주고 싶을 수 있어요.</span></span></span></p><p id="3c7a360d-33e3-805d-a13d-c4c15b0fd210" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">이를 위해 특정 노드 유형의 순서와 일치하는 경로에 가산점을 주는 파라미터를 만들었습니다. 내부적으로는 이를 </span><code style="font-family:Consolas, Menlo, Monaco, source-code-pro, &quot;Courier New&quot;, monospace;font-size:0.9em;background-color:var(--adaptiveOpacity100);border:1px solid var(--adaptiveOpacity200);border-radius:3px"><span class="css-1mjnzsq">path_preference</span></code><span class="css-1kxrhf3">라고 불렀습니다. 관련 없는 경로를 완전히 제외하는 대신, 제품이 선호하는 경로가 탐색 과정에서 더 오래 살아남게 하는 방식입니다.</span></span></span></p><p id="3c7a360d-33e3-80cb-9170-f49324da9a62" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">덕분에 검색기를 제품별로 따로 만들지 않고도, 같은 그래프 위에서 서로 다른 탐색 의도를 반영할 수 있었습니다.</span></span></span></p><p id="3c7a360d-33e3-80e4-a934-d7cc23f82063" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"></span></span></p><h2 id="3c7a360d-33e3-8010-99aa-e25e43ff2e98" class="css-1c1qox8"><span class="css-1r3ko7u"><span class="css-p4abj2"><span class="css-q3ktjb">같은 탐색 로직도 Cypher 실행 형태에 따라 비용이 달랐어요</span></span></span></h2><p id="3c7a360d-33e3-8055-9ce1-de924ca1f34a" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">Neo4j에도 쿼리 플래너가 있지만, 그래프 탐색은 시작 노드와 관계 분포에 따라 중간 후보 수가 크게 달라집니다. 논리적으로 비슷한 쿼리라도 실행계획에 따라 메모리 사용량과 처리 시간이 달라질 수 있어, 실제 실행 결과를 보며 최적화해야 했습니다.</span></span></span></p><aside id="3c7a360d-33e3-8062-a821-ecc8aa86bc96" class="css-nv7vyi"><div class="css-1vn47db"><ul class="css-uswsmm"><li id="3c7a360d-33e3-807c-a9ca-e88f0ffee2e0" class="css-1hwiibq"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-q3ktjb">시작점을 인덱스로 찾았습니다.</span><span class="css-1kxrhf3"> 노드의 라벨과 인덱스가 걸린 속성을 함께 명시하고, 실행계획에서 NodeIndexSeek가 사용되는지 확인했습니다.</span></span></span></li><li id="3c7a360d-33e3-8090-8dd0-d9d9f6173e59" class="css-1hwiibq"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-q3ktjb">탐색 범위를 일찍 줄였습니다.</span><span class="css-1kxrhf3"> 관계의 방향과 타입, 노드·관계 필터를 가능한 한 확장 초기에 적용했습니다.</span></span></span></li><li id="3c7a360d-33e3-8062-8a3b-d53213f6ba17" class="css-1hwiibq"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-q3ktjb">불필요한 중간 결과를 넘기지 않았습니다.</span><span class="css-1kxrhf3"> 다음 단계에 필요한 값만 WITH로 전달하고, 반복해서 만들어지는 값이나 큰 결과가 쿼리 안에 오래 남지 않도록 했습니다.</span></span></span></li><li id="3c7a360d-33e3-8043-bc7d-c60c6a8a04ac" class="css-1hwiibq"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-q3ktjb">실행계획을 직접 확인했습니다.</span><span class="css-1kxrhf3"> EXPLAIN으로 예상 계획을 먼저 보고, 대표적인 파라미터로 PROFILE을 실행해 실제 비용을 측정했습니다.</span></span></span></li></ul></div></aside><p id="3c7a360d-33e3-80f0-bc4a-e101f90550ed" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">PROFILE에서는 인덱스 사용 여부뿐 아니라 예상 행 수와 실제 Rows의 차이, 관계 확장 지점의 DB Hits, 연산자별 메모리 사용량을 확인했습니다. 결국 Cypher 최적화는 </span><span class="css-q3ktjb">후보가 어느 지점에서 늘어나는지 찾고, 다음 탐색 전에 줄이는 것</span><span class="css-1kxrhf3">이었습니다.</span></span></span></p><figure id="3c7a360d-33e3-80d7-a5c2-c7aa48daefb5" class="css-of5acw"><img src="https://static.toss.im/ipd-tcs/toss_core/live/ab3de514-1b32-40f1-a220-162d4b4d1d3f/image.png" alt="" class="css-1pgssrp"/></figure><p id="3c7a360d-33e3-80e0-a91d-d1c7af8b4839" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"></span></span></p><h2 id="3c7a360d-33e3-80db-9b99-c31eec0cf68e" class="css-1c1qox8"><span class="css-1r3ko7u"><span class="css-p4abj2"><span class="css-q3ktjb">그래프 검색의 품질을 결정한 것</span></span></span></h2><p id="3c7a360d-33e3-8012-9f80-cebffa6e2cc9" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">그래프 검색의 품질은 그래프를 얼마나 깊게 탐색하느냐보다, </span><span class="css-q3ktjb">어디서 시작해 어떤 경로를 남기느냐</span><span class="css-1kxrhf3">에 달려 있었습니다.</span></span></span></p><p id="3c7a360d-33e3-8080-b1ad-eb50133751db" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">관련 있는 시작점을 찾고, 각 홉에서 후보 수를 제한하고, 질문과 금융 도메인에 맞는 경로에 점수를 부여했습니다. 제품마다 중요하게 보는 관계가 다를 때는 경로 선호도를 추가로 반영했고요.</span></span></span></p><p id="3c7a360d-33e3-80d4-9b2f-fc48b7b5abea" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">최종 결과에는 엔티티뿐 아니라 탐색한 관계 경로도 함께 담았습니다. 덕분에 결과가 어떤 연결을 통해 발견됐는지 추적하고, 제품에서 이를 근거로 활용할 수 있었습니다.</span></span></span></p><figure id="3c7a360d-33e3-80c6-82d6-db20121f473c" class="css-of5acw"><img src="https://static.toss.im/ipd-tcs/toss_core/live/fa8100ff-034b-4dd5-ba1b-77533e2a1ba1/image.png" alt="" class="css-1pgssrp"/></figure><p id="3c7a360d-33e3-8065-aa6e-d80ea60d1be1" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"></span></span></p><h2 id="3c7a360d-33e3-80dc-907f-e02afae2d6c3" class="css-1c1qox8"><span class="css-1r3ko7u"><span class="css-p4abj2"><span class="css-1kxrhf3">마치며</span></span></span></h2><p id="3c7a360d-33e3-808e-bee8-f705d3b1ef4b" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">이번 글에서는 추천, RAG 검색, 그래프 검색을 만들며 마주한 문제와 선택을 소개했습니다. 서로 다른 시스템이었지만, 세 사례에서 반복해서 확인한 점은 같았습니다.</span></span></span></p><p id="3c7a360d-33e3-8010-87a7-dd3cbffb6c96" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">AI 제품의 품질은 모델 성능만으로 결정되지 않았습니다. 필요한 데이터를 어떻게 갱신할지, 어떤 후보를 남길지, 그리고 그 과정을 어떻게 관측하고 안정적으로 운영할지가 함께 중요했습니다.</span></span></span></p><p id="3c7a360d-33e3-809e-b584-d08001386e37" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">보편적인 해답을 제시하기보다, 실제 시스템을 만들며 어떤 문제를 만났고 어떤 선택을 했는지 나누고 싶었습니다. 이 경험이 AI 제품을 만들 때 모델 바깥의 시스템을 바라보는 작은 힌트가 되었으면 좋겠습니다.</span></span></span></p><p id="3c7a360d-33e3-80c7-887b-df46f0e5cb26" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">읽어주셔서 감사합니다.</span></span></span></p><p id="3c7a360d-33e3-80b8-a4cf-c449ce5a6f8e" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"></span></span></p><hr class="css-1ifza5r"/><p id="3c7a360d-33e3-80bd-a14c-cb3b52f0c16a" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-q3ktjb">테크톡톡(Tech Talk Talk)은 테크 업계 종사자들의 함께 교류하고 성장하는 네트워킹 및 지식공유 포럼이에요.</span></span></span></p><p id="3c7a360d-33e3-80be-b965-dbff9f36286f" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">토스증권의 기술과 경험을 나누는 행사로, 토스증권의 엔지니어들이 직접 기술적 고민과 시행착오를 통한 배움, 그리고 성장의 여정을 공유해요. 세션 강연 뿐 아니라 실시간 질의응답을 통해 참여자들과 인사이트를 나누며, 같은 분야의 동료들과 함께 배우고 성장할 수 있는 열린 기술 교류의 장이에요!</span></span></span></p></div><div id="tds-mobile-portal-container"></div>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[LLM 서빙, 띄우는 것과 잘 띄우는 것 사이]]></title>
            <link>https://toss.tech/article/tech_talk_talk_2</link>
            <guid isPermaLink="true">https://toss.tech/article/tech_talk_talk_2</guid>
            <pubDate>Fri, 21 Aug 2026 01:00:00 GMT</pubDate>
            <description><![CDATA[비어 있는 캐시, 완벽한 알리바이, 사라진 처리량]]></description>
            <content:encoded><![CDATA[<link rel="preload" as="image" href="https://static.toss.im/ipd-tcs/toss_core/live/995fcf72-74cc-4747-8a7f-bb92b6311b23/스크린샷_2026-08-18_오후_2.50.08.png"/><link rel="preload" as="image" href="https://static.toss.im/ipd-tcs/toss_core/live/b5b7f620-81bf-4714-9f9c-cc46597596f1/스크린샷_2026-08-18_오후_2.59.52.png"/><link rel="preload" as="image" href="https://static.toss.im/ipd-tcs/toss_core/live/3729b976-74db-4d8f-974c-48574a7b09fb/스크린샷_2026-08-18_오후_2.59.58.png"/><link rel="preload" as="image" href="https://static.toss.im/ipd-tcs/toss_core/live/f83e4750-35f9-403c-b382-fa0ba0678041/스크린샷_2026-08-18_오후_3.09.00.png"/><link rel="preload" as="image" href="https://static.toss.im/ipd-tcs/toss_core/live/66f690b6-622b-4f64-8672-5d3bb6409199/스크린샷_2026-08-18_오후_3.09.35.png"/><link rel="preload" as="image" href="https://static.toss.im/ipd-tcs/toss_core/live/0391e3b8-c347-4fd4-a6e1-f769db3ea073/스크린샷_2026-08-18_오후_3.14.15.png"/><link rel="preload" as="image" href="https://static.toss.im/ipd-tcs/toss_core/live/47f569ce-88dc-4170-8024-ec8fd02b28cd/스크린샷_2026-08-18_오후_3.15.52.png"/><link rel="preload" as="image" href="https://static.toss.im/ipd-tcs/toss_core/live/fe830301-b8fd-4ed5-85c1-8163a92fa621/스크린샷_2026-08-18_오후_3.17.13.png"/><link rel="preload" as="image" href="https://static.toss.im/ipd-tcs/toss_core/live/a431b2be-7465-4e20-9f78-c78aefd2cfb0/스크린샷_2026-08-18_오후_3.21.43.png"/><div class="css-1vn47db"><p id="3c2a360d-33e3-80e9-bd44-ce1f4109aed4" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">LLM 서빙 생태계는 빠르게 바뀌고 있습니다. 불과 수년 전만 해도, LLM을 배포하는 것 자체가 허들인 경우가 많았어요. 하지만, 지금은 다양한 곳에서 on-premise LLM을 다양하게 사용하고 있습니다. 토스증권도 마찬가지로 다수의 오픈 소스 LLM, 직접 튜닝한 모델을 서비스에 활용하고 있습니다. 이런 상황에서, LLM을 단순히 서빙하는 것보다, 잘 띄우는 것이 더 중요해졌습니다.</span></span></span></p><figure id="3c2a360d-33e3-80c0-ac37-c9dfe46ea240" class="css-of5acw"><img src="https://static.toss.im/ipd-tcs/toss_core/live/995fcf72-74cc-4747-8a7f-bb92b6311b23/스크린샷_2026-08-18_오후_2.50.08.png" alt="" class="css-1pgssrp"/></figure><p id="3c2a360d-33e3-80d2-8cc1-f556720700d9" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">평소에는 조용하던 팀의 알림 채널이 어느 날 갑자기 알림이 쏟아지는 날이 있습니다. 이 알림들은 여러 에러가 함께 묶여서 온 거예요. 100건 가까운 에러고, 대부분 타임아웃이라는 것까지는 알 수 있지만 이 알림만으로는 왜 이런 일이 생겼는지 알 수 없어요.</span></span></span></p><p id="3c2a360d-33e3-80a2-affb-c780f9acffc5" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1odxvuk">“어떤 지표를 봐야 이 알림의 원인을 알 수 있을까?”</span></span></span></p><p id="3c2a360d-33e3-80b6-9838-d3d1debb990a" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">2년 전, LLM 서빙을 처음 시작했을 때 풀어야 했던 문제는 두 가지였습니다.</span></span></span></p><h3 id="3c2a360d-33e3-806e-87f7-cd1585fa49ab" class="css-1feg9au"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">1. 지표가 달라졌어요</span></span></span></h3><figure id="3c2a360d-33e3-8041-a33f-f328f3d6283f" class="css-of5acw"><img src="https://static.toss.im/ipd-tcs/toss_core/live/b5b7f620-81bf-4714-9f9c-cc46597596f1/스크린샷_2026-08-18_오후_2.59.52.png" alt="" class="css-1pgssrp"/></figure><p id="3c2a360d-33e3-8056-99e8-f1f6c5275d55" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">기존 머신러닝 모델은  입력을 받아 한 건의 출력을 내놓는 구조였어요. 그래서 단 건의 응답 시간, 초당 몇 건의 트래픽을 처리하는지가 핵심 모니터링 지표였습니다.</span></span></span></p><p id="3c2a360d-33e3-804a-87f9-fb40643fca25" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">그런데 LLM은 토큰을 순차적으로 생성해요. 단 건의 응답 시간이나 처리량은 변동성이 심하고, 애초에 통제할 수 있는 영역이 아닙니다. 그래서 지표를 이렇게 바꿨어요.</span></span></span></p><ul class="css-uswsmm"><li id="3c3a360d-33e3-8077-abef-d572aa81156a" class="css-1hwiibq"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">처리량: 초당 처리 건수 → 초당 생성 토큰 수</span></span></span></li><li id="3c3a360d-33e3-80f6-a9a6-de42a9c643fe" class="css-1hwiibq"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">응답 시간: 최종 결과물까지의 시간 → 첫 번째 토큰이 나오기까지의 시간(TTFT)</span></span></span></li></ul><h3 id="3c2a360d-33e3-8034-9d52-cab8ca41ad94" class="css-1feg9au"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">2. 서빙 자체가 어려웠어요</span></span></span></h3><figure id="3c2a360d-33e3-8066-8f50-fda2f351fcfb" class="css-of5acw"><img src="https://static.toss.im/ipd-tcs/toss_core/live/3729b976-74db-4d8f-974c-48574a7b09fb/스크린샷_2026-08-18_오후_2.59.58.png" alt="" class="css-1pgssrp"/></figure><p id="3c2a360d-33e3-804c-b224-f33317d9c8a7" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">토스증권은 쿠버네티스, Argo CD 같은 여러 기술 스택 위에서 움직여요. 그 위에 각자의 플랫폼을 얹어 모델을 서빙하고, 거기서 나온 로그와 메트릭을 자체 대시보드로 관찰하곤 했습니다.</span></span></span></p><p id="3c3a360d-33e3-8027-9b2a-ecb970457d4f" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">문제는 이게 전부 서비스 개발자 개인의 역량에 의존했다는 점이에요.</span></span></span></p><p id="3c3a360d-33e3-8014-8043-c6bc20e71bb6" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">그래서 ML 플랫폼을 직접 구축했습니다. 어려운 부분을 줄이고, 로그와 메트릭을 정규화해서 하나의 패널에서 볼 수 있게요. 서버 모니터링은 두 갈래로 나눴어요.</span></span></span></p><ul class="css-uswsmm"><li id="3c3a360d-33e3-802c-ab86-efefd04ec933" class="css-1hwiibq"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-q3ktjb">Grafana</span><span class="css-1kxrhf3"> — 메트릭을 수집해 추이와 이상을 탐지</span></span></span></li><li id="3c3a360d-33e3-8041-9199-f8d03f3a34fc" class="css-1hwiibq"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-q3ktjb">Kibana</span><span class="css-1kxrhf3"> — 단건 로그를 확인</span></span></span></li></ul><p id="3c3a360d-33e3-80e8-bc9a-ffc7c3565b20" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">여기까지 만들고 나서, 대부분의 문제를 미리 예상하고 쉽게 해결할 수 있을 거라고 생각했습니다.</span></span></span></p><h2 id="3c2a360d-33e3-80cd-9973-f4b9dd68ad20" class="css-1c1qox8"><span class="css-1r3ko7u"><span class="css-p4abj2"><span class="css-1kxrhf3">그런데 예상하지 못한 문제들이 생겼어요</span></span></span></h2><p id="3c2a360d-33e3-80ea-8f2b-fed14fca86f6" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">앞서 구축한 모니터링만으로는 그 문제가 ‘왜’ 생겼는지 알 수 없었습니다. 원인을 파악하려면 어떤 지표를 봐야 할까? LLM 친화적인 지표를 이미 만들었는데도 부족했으니, 그 이상은 뭐가 있을까?</span></span></span></p><p id="3c3a360d-33e3-80ae-919d-dcef457a5521" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">조사해보니 vLLM이나 SGLang 같은 서빙 프레임워크가 제공하는 지표들이 있었어요. 이걸 하나의 대시보드에 통합했습니다. 예전엔 헬스 상태, 오류 건수, 레이턴시 같은 일반적인 서버 대시보드였다면, 여기에 LLM에 특화된 지표를 얹은 거예요.</span></span></span></p><figure id="3c2a360d-33e3-801f-bd24-c83ce82b4fcf" class="css-of5acw"><img src="https://static.toss.im/ipd-tcs/toss_core/live/f83e4750-35f9-403c-b382-fa0ba0678041/스크린샷_2026-08-18_오후_3.09.00.png" alt="" class="css-1pgssrp"/></figure><p id="3c2a360d-33e3-8037-b673-d5a0d0c2d4c5" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">이 시스템을 갖추고 나서야 비로소 원인을 찾을 수 있게 된 사건이 세 가지 있습니다.</span></span></span></p><p id="3c2a360d-33e3-80fe-9afd-fb0bf8bac513" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"></span></span></p><h2 id="3c2a360d-33e3-802a-b710-c90f4b22d107" class="css-1c1qox8"><span class="css-1r3ko7u"><span class="css-p4abj2"><span class="css-1kxrhf3">사건 1. 비어 있는 캐시</span></span></span></h2><p id="3c2a360d-33e3-80ce-aed6-c964a26d106a" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">서비스 팀의 머신러닝 엔지니어분이 자신이 쓰는 모델을 서비스로 내보내려는데, 확인해보니 종종 타임아웃이 발생한다고 요청을 주셨어요. 그래서 먼저 알림이 온 시간과 트래픽이 몰리는 시간을 비교해봤어요.</span></span></span></p><p id="3c3a360d-33e3-8055-ba95-c59b2278370b" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">그런데 둘이 일치하지 않았습니다. 원인이 트래픽이 아니라는 뜻이었죠.</span></span></span></p><p id="3c3a360d-33e3-80d3-a5b2-efe80614ca9d" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">지표들을 쭉 살펴보다가 시선을 잡아끄는 게 하나 있었어요. </span><span class="css-q3ktjb">Prefix Cache Hit Rate</span><span class="css-1kxrhf3">였습니다. 옆의 지표들은 값이 나오는데, 이것만 값이 없었어요.</span></span></span></p><figure id="3c2a360d-33e3-801f-9200-ec93a5253692" class="css-of5acw"><img src="https://static.toss.im/ipd-tcs/toss_core/live/66f690b6-622b-4f64-8672-5d3bb6409199/스크린샷_2026-08-18_오후_3.09.35.png" alt="" class="css-1pgssrp"/></figure><h3 id="3c2a360d-33e3-8015-ad4b-f72ab19da409" class="css-1feg9au"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">Prefix Cache가 뭐냐면</span></span></span></h3><p id="3c2a360d-33e3-801b-b311-f7d5e959f1cd" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">LLM은 토큰을 순차적으로 생성하고, 트랜스포머 구조에서 이전 문자열들에 대한 Key와 Value를 계속 참조합니다. 이걸 매번 계산해야 하는데 값은 똑같아요. 그래서 캐시 메모리에 저장해 두 번 계산하지 않게 하는 게 KV 캐시입니다.</span></span></span></p><p id="3c2a360d-33e3-807a-9376-c1ce8b7e61d4" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">KV 캐시는 하나의 시퀀스 안에서만 작동해요. 그런데 실제로 쓰다 보면 시스템 프롬프트처럼 앞부분을 공유하는 요청이 아주 많습니다. 서로 다른 요청이라도 앞쪽 KV 캐시는 동일한 경우가 많죠. 그걸 재사용하려고 쓰는 게 Prefix Cache고, Hit Rate는 그게 얼마나 재사용됐는지를 뜻해요. 높을수록 계산을 덜 하니 빨라집니다.</span></span></span></p><p id="3c2a360d-33e3-801a-986a-da7b96e9aade" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">그런데 이 값이 0이었어요.</span></span></span></p><h3 id="3c2a360d-33e3-8012-b830-c81c0167a5bc" class="css-1feg9au"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">원인은 두 가지 중 하나였는데</span></span></span></h3><p id="3c2a360d-33e3-803a-9808-c213bb7974a0" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">지표 수집이 잘못됐거나, 아니면 실제로 요청들이 전부 달라서 히트가 안 났거나.</span></span></span></p><p id="3c2a360d-33e3-80a8-b82c-f3976ea1dcf4" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">첫 번째는 아니었어요. 다른 모델에서는 정상적으로 값이 나왔거든요. 두 번째를 확인하려고 로그를 봤는데, 요청들이 많이 다르긴 했지만 그래도 어느 정도는 히트가 나야 하는 상황이었습니다.</span></span></span></p><p id="3c2a360d-33e3-805a-8458-da0070119623" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">이상해서 탐색을 시작했고, 재미있는 걸 발견했어요. 세팅을 잘못한 게 아니라, 사용 중이던 모델의 특성상 Prefix Caching이 기본적으로 꺼지는 설정이었습니다. 다른 모델들은 기본으로 켜져 있는데 이 모델만요.</span></span></span></p><p id="3c2a360d-33e3-80d2-9d18-d0cac8eb1715" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">다시 활성화해서 재배포했어요. 결과는 이랬습니다.</span></span></span></p><ul class="css-uswsmm"><li id="3c3a360d-33e3-8076-8cc7-df490d4dada9" class="css-1hwiibq"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">TTFT: 20초 가까이 걸리던 경우가 있었는데, 적용 후 약 1/10 수준으로 감소</span></span></span></li><li id="3c3a360d-33e3-802b-aced-fe0c3813856b" class="css-1hwiibq"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">Prefix Cache Hit Rate: 90% 이상 기록</span></span></span></li></ul><p id="3c3a360d-33e3-8047-9f9b-ebccb8a3083e" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">사실 단순한 문제예요. 하지만 Prefix Cache Hit Rate라는 지표를 보고 있지 않았다면 발견하는 것 자체가 훨씬 어려웠을 겁니다.</span></span></span></p><p id="3c2a360d-33e3-8046-95fa-ed7d957f5b33" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"></span></span></p><h2 id="3c2a360d-33e3-8003-9c57-f9839c69c483" class="css-1c1qox8"><span class="css-1r3ko7u"><span class="css-p4abj2"><span class="css-1kxrhf3">사건 2. 완벽한 알리바이</span></span></span></h2><p id="3c2a360d-33e3-80b5-800d-cf7c9c53c5dc" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">또 알림이 쭈르륵 올라왔어요. 앞선 교훈대로 vLLM·SGLang이 제공하는 LLM 지표들을 확인했습니다.</span></span></span></p><p id="3c2a360d-33e3-8000-a31c-d7a4a8c5620d" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">그런데 이상하게도 특이 상황이 없었어요. TTFT가 조금 바뀌긴 했는데 </span><span class="css-q3ktjb">차이가 20ms 수준</span><span class="css-1kxrhf3">이었고, 그마저도 P99였습니다. 메트릭상으로는 완벽한 알리바이였죠.</span></span></span></p><figure id="3c2a360d-33e3-808f-952f-ce06d4cf3a63" class="css-of5acw"><img src="https://static.toss.im/ipd-tcs/toss_core/live/0391e3b8-c347-4fd4-a6e1-f769db3ea073/스크린샷_2026-08-18_오후_3.14.15.png" alt="" class="css-1pgssrp"/></figure><p id="3c2a360d-33e3-80a9-a619-cc463eb134e3" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">메트릭 문제가 아니라고 판단하고 로그로 시선을 옮겼습니다. 취합해보니 이상한 점이 하나 있었어요. </span><code style="font-family:Consolas, Menlo, Monaco, source-code-pro, &quot;Courier New&quot;, monospace;font-size:0.9em;background-color:var(--adaptiveOpacity100);border:1px solid var(--adaptiveOpacity200);border-radius:3px"><span class="css-1mjnzsq">finish_reason</span></code><span class="css-1kxrhf3">이 </span><code style="font-family:Consolas, Menlo, Monaco, source-code-pro, &quot;Courier New&quot;, monospace;font-size:0.9em;background-color:var(--adaptiveOpacity100);border:1px solid var(--adaptiveOpacity200);border-radius:3px"><span class="css-1mjnzsq">length</span></code><span class="css-1kxrhf3"> 였습니다.</span></span></span></p><p id="3c3a360d-33e3-8072-b8d6-c41ea24ed065" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><code style="font-family:Consolas, Menlo, Monaco, source-code-pro, &quot;Courier New&quot;, monospace;font-size:0.9em;background-color:var(--adaptiveOpacity100);border:1px solid var(--adaptiveOpacity200);border-radius:3px"><span class="css-1mjnzsq">length</span></code><span class="css-1kxrhf3">는 저희가 지정한 상한(예: 최대 N토큰)을 넘어서 그냥 끊겼다는 뜻이에요. 즉 모델이 원하지 않는 말을 불필요하게 뱉어냈다는 의미죠.</span></span></span></p><p id="3c3a360d-33e3-80e6-ad7f-ec3089457893" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">요즘 많이 쓰는 리즈닝(thinking) 모델에서 이런 일이 꽤 발생합니다. 리즈닝을 훨씬 길게 하는 경향이 있으니까요. 그런데 신기한 건, 저희는 이걸 막으려고 리즈닝 토큰 개수를 제한하는 옵션을 이미 켜뒀다는 점이었어요. 그런데도 그게 실패하고 계속 생성됐습니다.</span></span></span></p><p id="3c3a360d-33e3-80a6-9fae-e4a64083b436" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">문제가 된 로그들의 출력을 하나하나 열어봤어요. 그러다 눈길을 끄는 문자열을 발견했습니다. 내용 자체는 중요하지 않고, 끝에 붙은 </span><code style="font-family:Consolas, Menlo, Monaco, source-code-pro, &quot;Courier New&quot;, monospace;font-size:0.9em;background-color:var(--adaptiveOpacity100);border:1px solid var(--adaptiveOpacity200);border-radius:3px"><span class="css-1mjnzsq">&lt;/think&gt;</span></code><span class="css-1kxrhf3"> 가 중요했어요.</span></span></span></p><p id="3c3a360d-33e3-80a2-9f42-cd40263ffeb7" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">이건 리즈닝이 끝났음을 알리는 스페셜 토큰입니다. 그런데 이게 리즈닝 구간이 아니라, 리즈닝이 끝나고 실제 출력을 뱉어야 하는 </span><code style="font-family:Consolas, Menlo, Monaco, source-code-pro, &quot;Courier New&quot;, monospace;font-size:0.9em;background-color:var(--adaptiveOpacity100);border:1px solid var(--adaptiveOpacity200);border-radius:3px"><span class="css-1mjnzsq">content</span></code><span class="css-1kxrhf3"> 시점에 등장하고 있었어요.</span></span></span></p><figure id="3c2a360d-33e3-80ed-97ac-f01af41b5f84" class="css-of5acw"><img src="https://static.toss.im/ipd-tcs/toss_core/live/47f569ce-88dc-4170-8024-ec8fd02b28cd/스크린샷_2026-08-18_오후_3.15.52.png" alt="" class="css-1pgssrp"/></figure><p id="3c2a360d-33e3-8003-a4c3-fad3a973b731" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">여기까지 오니 더 손쓸 방법이 없어서 다시 탐색했고, vLLM의 버그임을 확인했습니다. 이미 다른 사람들이 리포트한 문제였고, 해당 이슈에서는 해결할 수 없다는 답과 함께 워크어라운드가 제시돼 있어서 그대로 적용했습니다.</span></span></span></p><p id="3c2a360d-33e3-8054-8207-dee5203e7699" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">결과는 에러율이 약 0.2%에서 0.02% 수준으로 감소했습니다.</span></span></span></p><p id="3c2a360d-33e3-8053-bb59-c3d68958f436" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"></span></span></p><h2 id="3c2a360d-33e3-8063-911c-ce0423c089d5" class="css-1c1qox8"><span class="css-1r3ko7u"><span class="css-p4abj2"><span class="css-1kxrhf3">사건 3. 사라진 처리량</span></span></span></h2><p id="3c2a360d-33e3-8065-a30e-ff1dd2c560bc" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">트래픽이 몰리는 시간에 처리량이 부족해서 밀린다고 서버 개발자분이 그래프와 함께 메시지를 주셨어요. </span></span></span></p><p id="3c3a360d-33e3-808b-a8d3-d22f228f18ee" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">LLM은 GPU라는 비싼 자원을 다량 쓰는 모델이라, 파드를 하나 더 띄우는 것 자체가 비쌌어요. 그래서 증설을 결정하기 전에 하나만 확인하고 가자고 했습니다.</span></span></span></p><p id="3c3a360d-33e3-8062-87ef-ce583557d6f1" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1odxvuk">우리가 GPU를 충분히 쓰고 있는가?</span></span></span></p><p id="3c3a360d-33e3-8020-bc18-dfae21db45da" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">메트릭으로 돌아갔어요. 크게 보면 문제가 없어 보였는데, 자세히 보니 KV Cache 사용량이 매우 작았습니다. 생성 TPS는 거의 1,000에 가깝고 Prefix Cache도 아주 많이 쓰고 있어서 전반적으로 조밀하게 돌고 있었는데, KV 캐시 사용량만 1%도 되지 않는 수준이었어요.</span></span></span></p><figure id="3c2a360d-33e3-8082-b93c-c77a50464b24" class="css-of5acw"><img src="https://static.toss.im/ipd-tcs/toss_core/live/fe830301-b8fd-4ed5-85c1-8163a92fa621/스크린샷_2026-08-18_오후_3.17.13.png" alt="" class="css-1pgssrp"/></figure><p id="3c2a360d-33e3-8035-a094-d21d3d58b4d6" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">메모리에 여유가 있으니 더 많은 요청을 받을 수 있겠다고 판단했습니다. 파드를 증설하기 전에 어디까지 동시성(Concurrency)을 받을 수 있는지 테스트해봤어요. 허용 범위 안에서 &quot;동시성 20까지&quot;까지 받을 수 있다는 걸 확인했습니다.</span></span></span></p><p id="3c2a360d-33e3-809d-8ece-f25b5afd077e" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">이 근거로 자원을 두 배로 쓰지 않고 트래픽을 4배까지 받을 수 있다는 힌트를 얻었어요.</span></span></span></p><p id="3c2a360d-33e3-80b8-9483-d934deac1ca0" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"></span></span></p><h2 id="3c2a360d-33e3-80fd-8eb7-c7e3b5865789" class="css-1c1qox8"><span class="css-1r3ko7u"><span class="css-p4abj2"><span class="css-1kxrhf3">그래도 다 해결되지는 않아요</span></span></span></h2><p id="3c2a360d-33e3-809b-81f5-c13d46e3034e" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">물론 지표가 있다고 모든 문제가 풀리는 건 아닙니다. 문제 대부분이 저희가 의존하는 vLLM·SGLang 같은 프레임워크에 디펜던시가 걸려 있거든요.</span></span></span></p><ul class="css-uswsmm"><li id="3c3a360d-33e3-8004-8c98-caaf45316fb3" class="css-1hwiibq"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">특정 옵션 조합에서 </span><code style="font-family:Consolas, Menlo, Monaco, source-code-pro, &quot;Courier New&quot;, monospace;font-size:0.9em;background-color:var(--adaptiveOpacity100);border:1px solid var(--adaptiveOpacity200);border-radius:3px"><span class="css-1mjnzsq">content</span></code><span class="css-1kxrhf3"> 값이 아예 반환되지 않는 버그</span></span></span></li><li id="3c3a360d-33e3-80eb-9615-d76573463226" class="css-1hwiibq"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">저희가 설정한 대로 LLM이 동작하지 않는 경우</span></span></span></li><li id="3c3a360d-33e3-806f-aa3b-cddee697e913" class="css-1hwiibq"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">릴리스 노트에 &quot;이 문제를 해결했다&quot;고 적혀 있어 적용했더니, 오히려 CPU 스로틀링이 걸려 느려지는 부작용</span></span></span></li></ul><p id="3c3a360d-33e3-8067-96c7-ebea05308899" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">그래서 미리 다 파악하는 방식으로 가지 않아요. 여러 대시보드로 문제를 파악하고, 탐색을 통해 원인을 발견하는 형태로 대응합니다.</span></span></span></p><p id="3c2a360d-33e3-80ee-985c-ed84ed30754a" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"></span></span></p><h2 id="3c2a360d-33e3-8070-901e-fcaa7b168f2c" class="css-1c1qox8"><span class="css-1r3ko7u"><span class="css-p4abj2"><span class="css-1kxrhf3">문제를 다루는 네 단계</span></span></span></h2><p id="3c2a360d-33e3-809c-924c-f08485e1b5b0" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-q3ktjb">1. 발견.</span><span class="css-1kxrhf3"> 중앙화된 모니터링과 로깅으로 타임아웃, </span><code style="font-family:Consolas, Menlo, Monaco, source-code-pro, &quot;Courier New&quot;, monospace;font-size:0.9em;background-color:var(--adaptiveOpacity100);border:1px solid var(--adaptiveOpacity200);border-radius:3px"><span class="css-1mjnzsq">finish_reason</span></code><span class="css-1kxrhf3">으로 인한 생성 중단, 레이턴시 급증, 메모리 포화 같은 신호를 탐지하고 알림을 받습니다.</span></span></span></p><p id="3c3a360d-33e3-803b-8eb8-de80ded05fb0" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-q3ktjb">2. 관찰.</span><span class="css-1kxrhf3"> 알림을 받으면 지표로 들어가 문제를 관찰해요. 메트릭으로 이상치를 못 찾으면 Kibana 등에서 개별 로그를 확인합니다.</span></span></span></p><p id="3c3a360d-33e3-8050-9776-c5e6258c1d34" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-q3ktjb">3. 가설과 검증.</span><span class="css-1kxrhf3"> &quot;이런 경우에 문제가 생기고 이런 경우엔 안 생기니 이렇게 해보자&quot;는 가설을 세우고, 변경한 옵션·모델·설정을 ML 플랫폼으로 배포해 스트레스 테스트로 검증합니다.</span></span></span></p><p id="3c3a360d-33e3-80e6-aa03-e3eea9e6d804" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-q3ktjb">4. 반영과 롤백.</span><span class="css-1kxrhf3"> 서비스에 반영하되, 예상 못 한 부작용에 대비해 빠르게 롤백할 수 있도록 시스템을 갖춰둡니다.</span></span></span></p><figure id="3c2a360d-33e3-802f-86b8-f1f3a17e65d7" class="css-of5acw"><img src="https://static.toss.im/ipd-tcs/toss_core/live/a431b2be-7465-4e20-9f78-c78aefd2cfb0/스크린샷_2026-08-18_오후_3.21.43.png" alt="" class="css-1pgssrp"/></figure><h2 id="3c2a360d-33e3-8053-866a-d54727af2ea5" class="css-1c1qox8"><span class="css-1r3ko7u"><span class="css-p4abj2"><span class="css-1kxrhf3">마지막으로</span></span></span></h2><p id="3c2a360d-33e3-80fd-b234-febde9925f9a" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">LLM의 세계는 너무 빨리 변합니다. 모델만 바뀌는 게 아니라 서빙 프레임워크, 서빙 옵션, 호출 방법까지 계속 바뀌어요. 이걸 전부 따라가는 건 현실적으로 불가능합니다.</span></span></span></p><p id="3c3a360d-33e3-8015-8e45-c66a99560b5a" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">그래서 저희는 모두 따라가는 방식 대신, 최대한 많은 지표를 펼쳐두고 문제를 빨리 파악해 원인을 추정하고 빠르게 대응하는 시스템을 갖추기로 했어요.</span></span></span></p><p id="3c3a360d-33e3-80c9-ab17-daab5035d97b" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">여러 지표를 두고 동적으로 빠르게 대응할 수 있는 시스템을 만드는 것. 그게 LLM을 그냥 서빙하는 것과 잘 서빙하는 것 사이의 간극을 메우는 일이라고 생각합니다.</span></span></span></p><p id="3c2a360d-33e3-806d-bfbd-c3df1763c278" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"></span></span></p><hr class="css-1ifza5r"/><p id="3c2a360d-33e3-80ba-a560-d07d47027288" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">✅ 이번 아티클은 토스증권 Tech Talk Talk 세션을 바탕으로 재구성되었습니다.</span></span></span></p><figure id="3c2a360d-33e3-8067-9dea-f3458c41ec45" class="css-of5acw"><div class="css-oni540"><iframe src="https://www.youtube.com/embed/Z6JfgjIauSw" frameBorder="0" allowFullScreen="" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" class="css-122y91a"></iframe></div></figure><p id="3c3a360d-33e3-8083-a652-ed97f94c24e2" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"></span></span></p></div><div id="tds-mobile-portal-container"></div>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[전체 데이터를 브라우저에 두는 광고 대시보드 만들기]]></title>
            <link>https://toss.tech/article/ads_dashboard_fe</link>
            <guid isPermaLink="true">https://toss.tech/article/ads_dashboard_fe</guid>
            <pubDate>Thu, 20 Aug 2026 01:00:00 GMT</pubDate>
            <description><![CDATA[서버가 하던 필터링·정렬·검색·페이지네이션을 브라우저로 옮겼더니, 속도뿐 아니라 손대지 못하던 문제 네 개가 같이 풀렸습니다.]]></description>
            <content:encoded><![CDATA[<link rel="preload" as="image" href="https://static.toss.im/ipd-tcs/toss_core/live/e871f6e3-cb03-4780-9915-9df2e04cd334/alpha-ads-platform.toss.im_advertiser_display-ads_columnIdpurchasecontractSortbillable_clickdescstartDate2025-08-15endDate2026-08-14dateRangeKey1year_(3).png"/><link rel="preload" as="image" href="https://static.toss.im/ipd-tcs/toss_core/live/0f831580-7a75-47e2-a730-d418a2944515/Screenshot_2026-08-14_at_13.03.01.png"/><link rel="preload" as="image" href="https://static.toss.im/ipd-tcs/toss_core/live/b240e842-5623-4b0d-8823-e003d0ed0670/alpha-ads-platform.toss.im_advertiser_display-ads_startDate2024-08-20endDate2025-08-19dateRangeKey1yearcontractSortspenddesc_(1).png"/><div class="css-1vn47db"><p id="553a360d-33e3-8234-aaa5-01f5d32bd511" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">안녕하세요. 토스에서 광고주가 쓰는 대시보드를 만들고 있는 프론트엔드 개발자 김동희입니다. </span></span></span></p><p id="3c0a360d-33e3-80ef-ab39-ee030df70559" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">저희 팀이 만드는 건 광고주를 위한 화면이에요. 캠페인을 만들고, 예산을 넣고, 어제 쓴 돈이 얼마나 성과로 돌아왔는지 확인하는 곳이죠.</span></span></span></p><p id="2c7a360d-33e3-830b-ac7f-01a45dabd1d4" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">말하자면 &#x27;데이터를 보는 도구&#x27;를 만듭니다. 그리고 도구를 만드는 일은, 겉으로 보이는 것보다 훨씬 프론트엔드다운 문제들을 던져줘요. 오늘은 그중 하나, </span><span class="css-q3ktjb">필터 한 번 누를 때마다 서버를 기다리던 대시보드를 고친 이야기</span><span class="css-1kxrhf3">를 해보려고 해요.</span></span></span></p><p id="bd6a360d-33e3-831d-87d0-01291f3f74c6" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">결론부터 말하면 저희는 서버가 하던 필터링·정렬·검색·페이지네이션을 전부 브라우저로 가져왔습니다. 그런데 흥미로웠던 건, 이 결정으로 얻은 게 속도만이 아니었다는 점이에요.</span></span></span></p><p id="3c0a360d-33e3-804c-8799-f5a7ddd5ebfb" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"></span></span></p><h2 id="009a360d-33e3-827f-850e-811ae8a7c766" class="css-1c1qox8"><span class="css-1r3ko7u"><span class="css-p4abj2"><span class="css-1kxrhf3">광고주는 대시보드를 &#x27;한 번&#x27; 보지 않아요</span></span></span></h2><p id="bcea360d-33e3-8274-ae6f-8143e609be9b" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">먼저 광고주가 이 화면에서 실제로 무엇을 하는지 짚어볼게요.</span></span></span></p><figure id="a79a360d-33e3-8268-9f6c-01cde10eb6ee" class="css-of5acw"><img src="https://static.toss.im/ipd-tcs/toss_core/live/e871f6e3-cb03-4780-9915-9df2e04cd334/alpha-ads-platform.toss.im_advertiser_display-ads_columnIdpurchasecontractSortbillable_clickdescstartDate2025-08-15endDate2026-08-14dateRangeKey1year_(3).png" alt="" class="css-1pgssrp"/></figure><p id="420a360d-33e3-8293-aedd-0159d94cf23c" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">광고 대시보드에는 </span><span class="css-q3ktjb">캠페인 → 광고세트 → 소재</span><span class="css-1kxrhf3">라는 3단계 계층이 있어요. 캠페인은 ‘이 예산으로 이런 목표를 달성하겠다’를 정하는 단위, 광고세트는 그 안에서 누구에게 언제 보여줄지를 정하는 단위, 소재는 실제로 사용자 눈에 보이는 이미지와 문구예요. 캠페인 하나에 광고세트 여러 개가, 광고세트 하나에 소재 여러 개가 붙습니다.</span></span></span></p><p id="55ea360d-33e3-83fe-83e3-01cf37efacf2" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">이 화면은 성과를 깊게 파고드는 분석 도구는 아니에요. 캠페인·광고세트·소재를 관리하면서 상태를 확인하는 곳에 가깝습니다.</span></span></span></p><p id="d49a360d-33e3-82fe-a947-013700098862" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">광고주는 여기서 이런 걸 합니다.</span></span></span></p><aside id="3c0a360d-33e3-80d0-a44f-ebc4f4510f69" class="css-nv7vyi"><div class="css-1vn47db"><ul class="css-uswsmm"><li id="2e0a360d-33e3-823b-afb0-01c093080801" class="css-1hwiibq"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">특정 캠페인만 필터링해서 하위 광고세트를 펼쳐본다.</span></span></span></li><li id="f30a360d-33e3-82b2-9955-01f3a093ff0d" class="css-1hwiibq"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">소재 이름으로 검색해서 A안과 B안을 비교한다.</span></span></span></li><li id="c20a360d-33e3-83ce-b1c8-018e3f5e37ca" class="css-1hwiibq"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">성과가 떨어진 캠페인이 뭔지 정렬해서 확인한다.</span></span></span></li><li id="0f0a360d-33e3-824c-a9bf-0169defae2c3" class="css-1hwiibq"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">다시 위로 올라가서 다른 기준으로 정렬한다.</span></span></span></li></ul></div></aside><p id="873a360d-33e3-820c-99b7-0185a5146333" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-q3ktjb">한 번 보고 끝나는 화면이 아니에요.</span><span class="css-1kxrhf3"> 가설을 세우고, 걸러보고, 아니다 싶으면 되돌아가는 탐색 도구에 가깝습니다.</span></span></span></p><figure id="b8ba360d-33e3-8256-855e-819b05d47d76" class="css-of5acw"><img src="https://static.toss.im/ipd-tcs/toss_core/live/0f831580-7a75-47e2-a730-d418a2944515/Screenshot_2026-08-14_at_13.03.01.png" alt="" class="css-1pgssrp"/></figure><p id="082a360d-33e3-8337-81ec-0107285a5602" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">문제는 저 모든 동작이 서버 호출이었다는 것입니다.</span></span></span></p><p id="4f6a360d-33e3-8328-9747-811d1a116189" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">한 번의 기다림은 참을 만해요. 그런데 탐색은 한 번이 아니죠. 필터 걸고, 정렬 바꾸고, 페이지 넘기고, 다시 필터 풀고. 열 번이면 수 초가 대기로만 쌓입니다. 그리고 이 시간이 만드는 진짜 비용은 대기 그 자체가 아니라 </span><span class="css-q3ktjb">탐색 포기</span><span class="css-1kxrhf3">예요. &quot;이 조합도 한번 볼까?&quot; 하는 시도를 사용자가 하지 않게 되거든요.</span></span></span></p><p id="59aa360d-33e3-83ad-a58a-81d1158841bf" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">느린 도구는 덜 쓰이는 게 아니라, </span><span class="css-q3ktjb">다르게 쓰입니다.</span></span></span></p><p id="e30a360d-33e3-822e-8180-01f5d47bbd0e" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">미리 말씀드리면 이건 저희가 지표로 확인한 게 아니라, 화면을 만들고 써보면서 세운 가정이에요. 다만 이 가정을 받아들이면 고쳐야 할 대상이 달라집니다. 응답 시간이 아니라, 사용자가 시도해볼 수 있는 횟수가 되거든요.</span></span></span></p><p id="3c0a360d-33e3-80cd-b3a4-e4cac427cef1" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"></span></span></p><h2 id="778a360d-33e3-8227-8a49-019c17ac43e1" class="css-1c1qox8"><span class="css-1r3ko7u"><span class="css-p4abj2"><span class="css-1kxrhf3">그런데 문제는 속도만이 아니었어요</span></span></span></h2><p id="6c3a360d-33e3-8343-9894-81da8a160dec" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">여기까지만 보면 흔한 성능 개선 이야기입니다. 저희가 이 구조를 바꾸기로 한 진짜 이유는 따로 있었어요.</span></span></span></p><p id="1b1a360d-33e3-8258-a1b6-8174ef09c30d" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">성능과 무관해 보이는 버그·요구사항이 계속 쌓이고 있었는데, 하나씩 뜯어보니 전부 뿌리가 같았습니다. </span><span class="css-q3ktjb">클라이언트가 전체 데이터를 갖고 있지 않다</span><span class="css-1kxrhf3">는 것이에요.</span></span></span></p><h3 id="2a1a360d-33e3-82d4-a369-81ad7745df14" class="css-1feg9au"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">1. 성과가 0인 항목이 목록에서 사라져요</span></span></span></h3><p id="118a360d-33e3-82f6-a1d7-01ae63cf5ffc" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">노출수 기준으로 정렬하면, 노출수가 0인 캠페인이 결과에서 아예 빠졌어요. 버그처럼 보이지만 서버 입장에서는 자연스러운 동작이었습니다. 성과 데이터는 중간 집계 테이블에서 나오는데, 집행되지 않은 캠페인은 애초에 그 테이블에 행이 없거든요. 없는 항목을 전부 0으로 채워 내려주는 건 집계 성능에 좋지 않고요.</span></span></span></p><p id="ca0a360d-33e3-83c7-a4ef-0104d8c2e9e8" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">그런데 광고주 입장에서 ‘</span><span class="css-q3ktjb">노출이 0인 캠페인’은 없어져야 할 데이터가 아니라 가장 먼저 봐야 할 데이터</span><span class="css-1kxrhf3">입니다. 뭔가 잘못 세팅된 캠페인일 가능성이 높으니까요.</span></span></span></p><h3 id="c10a360d-33e3-82b2-8839-814cb5d0ae4d" class="css-1feg9au"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">2. &quot;캠페인 1234가 몇 페이지에 있지?&quot;에 답할 수 없어요</span></span></span></h3><p id="435a360d-33e3-822c-9399-8108fef76b60" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">특정 ID가 몇 번째 항목인지는 전체 순서를 알아야 계산할 수 있는데, 클라이언트는 지금 보고 있는 페이지밖에 갖고 있지 않았어요. 커서 기반 페이지네이션이라 서버에 물어볼 수단도 없었고요. 커서 방식은 ‘이 항목 다음 100개’처럼 직전 위치를 기준으로 다음 묶음을 받아오는 방식이라, 응답에 전체 순번이 담기지 않거든요.</span></span></span></p><p id="7d0a360d-33e3-82c2-95dd-81d4b6ab0c9c" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">물론 서버가 순번을 못 세는 건 아니에요. 순위를 계산해서 내려주는 API를 따로 만들면 됩니다. 다만 정렬 기준마다 그 API가 하나씩 필요했고, 그건 바로 다음에 나올 3번 문제와 정확히 같은 비용 구조였어요. 그래서 특정 ID로 바로 이동하는 기능은 계속 백로그에 남아 있었습니다.</span></span></span></p><h3 id="570a360d-33e3-826f-8c68-81acb776b156" class="css-1feg9au"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">3. 서버가 지원하지 않는 정렬은 불가능해요</span></span></span></h3><p id="9d9a360d-33e3-8204-9b3d-01581c4e90d1" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">&quot;노출 상태(운영중/일시중지)로 정렬해주세요&quot; 같은 요청이 올 때마다, 먼저 서버 API에 정렬 키를 추가해야 했어요. 화면에 이미 보이고 있는 값인데도요.</span></span></span></p><h3 id="5e9a360d-33e3-822b-954a-01c70d917a64" class="css-1feg9au"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">4. URL에 담긴 선택 상태를 검증할 수 없어요</span></span></span></h3><p id="bd3a360d-33e3-82d6-aa18-81cf70a32b4e" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">저희 대시보드는 선택 상태를 URL에 담습니다. </span><code style="font-family:Consolas, Menlo, Monaco, source-code-pro, &quot;Courier New&quot;, monospace;font-size:0.9em;background-color:var(--adaptiveOpacity100);border:1px solid var(--adaptiveOpacity200);border-radius:3px"><span class="css-1mjnzsq">?campaignIds=1&amp;adsetIds=10&amp;adIds=100</span></code><span class="css-1kxrhf3"> 같은 식으로요. 링크로 공유하면 같은 화면이 열리게 하려고요.</span></span></span></p><p id="e5aa360d-33e3-83ad-a913-014c0a1dd7c6" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">그런데 클라이언트에 계층 정보가 없으니, </span><span class="css-q3ktjb">광고세트 10이 정말 캠페인 1의 자식인지 확인할 방법이 없었어요.</span><span class="css-1kxrhf3"> 누가 URL을 손으로 고치거나, 오래된 링크를 열면 앞뒤가 안 맞는 화면이 나옵니다.</span></span></span></p><p id="583a360d-33e3-82d0-b1f3-8155dcb22624" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">네 가지 문제를 나란히 놓고 보니 답이 명확해졌어요. 각각을 서버 API 스펙 추가로 하나씩 막는 건 가능했지만, 그건 같은 뿌리에서 계속 자라날 문제의 잎사귀만 잘라내는 일이었습니다.</span></span></span></p><blockquote id="403a360d-33e3-82c8-9223-811697f87283" class="css-2sk6rv"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-q3ktjb">클라이언트가 전체 데이터를 갖고, 목록의 기준을 클라이언트가 정하면 - 네 문제가 한꺼번에 정리됩니다.</span></span></span></blockquote><p id="3c0a360d-33e3-8099-b95c-c2f80d38d3b5" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"></span></span></p><h2 id="80ea360d-33e3-8336-925d-0114ad9fa744" class="css-1c1qox8"><span class="css-1r3ko7u"><span class="css-p4abj2"><span class="css-1kxrhf3">결정: 진입할 때 전부 받고, 그다음은 브라우저에서</span></span></span></h2><p id="3f5a360d-33e3-82ae-b237-016ab03ce8b5" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">그래서 저희는 이렇게 바꿨어요.</span></span></span></p><div data-table-wrapper="true" class="css-1m7s5mg"><div role="table" class="css-uznrcw"><div role="row" class="css-1obf64m"><div role="cell" class="css-8vl804"></div><div role="cell" class="css-8vl804"><span class="css-1mjnzsq">AS-IS</span></div><div role="cell" class="css-8vl804"><span class="css-1mjnzsq">TO-BE</span></div></div><div role="row" class="css-1obf64m"><div role="cell" class="css-8vl804"><span class="css-1mjnzsq">API</span></div><div role="cell" class="css-8vl804"><span class="css-1mjnzsq">단일 API</span></div><div role="cell" class="css-8vl804"><span class="css-1mjnzsq">목록(Metadata) / 성과 / 부가 컬럼으로 분리</span></div></div><div role="row" class="css-1obf64m"><div role="cell" class="css-8vl804"><span class="css-1mjnzsq">필터링·정렬·검색·페이지네이션</span></div><div role="cell" class="css-8vl804"><span class="css-1mjnzsq">매번 서버 호출</span></div><div role="cell" class="css-8vl804"><span class="css-1mjnzsq">메모리에서 즉시 처리</span></div></div><div role="row" class="css-1obf64m"><div role="cell" class="css-8vl804"><span class="css-1mjnzsq">렌더링</span></div><div role="cell" class="css-8vl804"><span class="css-1mjnzsq">전부 모인 뒤 한 번에</span></div><div role="cell" class="css-8vl804"><span class="css-1mjnzsq">도착하는 순서대로 점진적으로</span></div></div></div></div><p id="63ba360d-33e3-8360-8f58-81345e4a2c7b" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">필터링·정렬·검색 응답 시간은 </span><span class="css-q3ktjb">수백 ms에서, 기다린다는 느낌이 없는 수준으로</span><span class="css-1kxrhf3"> 내려갔습니다.</span></span></span></p><h3 id="571a360d-33e3-83e7-b3aa-8167c49e26a3" class="css-1feg9au"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">&quot;그거 데이터 많으면 안 되는 거 아니에요?&quot;</span></span></span></h3><p id="7f0a360d-33e3-824f-9584-01bff2a201a4" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">맞아요. 그래서 이 결정에서 가장 중요한 건 아키텍처가 아니라 </span><span class="css-q3ktjb">전제 조건</span><span class="css-1kxrhf3">이었어요. 전제가 깨지면 이건 좋은 결정이 아니라 나쁜 결정이 됩니다.</span></span></span></p><div data-table-wrapper="true" class="css-1m7s5mg"><div role="table" class="css-uznrcw"><div role="row" class="css-1obf64m"><div role="cell" class="css-8vl804"><span class="css-1mjnzsq">항목</span></div><div role="cell" class="css-8vl804"><span class="css-1mjnzsq">값</span></div><div role="cell" class="css-8vl804"><span class="css-1mjnzsq">근거</span></div></div><div role="row" class="css-1obf64m"><div role="cell" class="css-8vl804"><span class="css-1mjnzsq">현재 최대 데이터</span></div><div role="cell" class="css-8vl804"><span class="css-1mjnzsq">2만 건</span></div><div role="cell" class="css-8vl804"><span class="css-1mjnzsq">데이터가 가장 많은 광고계정 기준</span></div></div><div role="row" class="css-1obf64m"><div role="cell" class="css-8vl804"><span class="css-1mjnzsq">설계 목표</span></div><div role="cell" class="css-8vl804"><span class="css-1mjnzsq">10만 건</span></div><div role="cell" class="css-8vl804"><span class="css-1mjnzsq">현재 최대치의 5배 여유</span></div></div></div></div><p id="1eea360d-33e3-82f8-8057-816911f26221" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">즉 저희가 답해야 했던 질문은 &quot;클라이언트 필터링이 좋은가?&quot;가 아니라 </span><span class="css-q3ktjb">&quot;저희 데이터에 상한이 있는가, 그리고 그 상한을 숫자로 확정할 수 있는가?&quot;</span><span class="css-1kxrhf3">였어요. 위의 2만 건은 캠페인·광고세트·소재를 전부 합한 수입니다.</span></span></span></p><p id="c1ca360d-33e3-8352-b5d2-01533bb16fed" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">상한을 확정할 수 있으면 그다음은 계산 문제가 돼요. 최악의 경우가 정해지니 메모리도, 연산 시간도, 초기 로딩도 미리 재볼 수 있거든요. 반대로 상한을 못 정하는 서비스라면 — 데이터가 사용자 수만큼 무한정 늘어나는 구조라면 — 이 결정은 하면 안 돼요. 언젠가 반드시 깨지기 때문입니다.</span></span></span></p><p id="68ea360d-33e3-83f4-9442-011730105dcc" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">그래서 저희가 제일 먼저 한 일은 아키텍처를 그리는 게 아니라, 저희 데이터가 실제로 어디까지 가는지 세어보는 것이었습니다. 이 숫자를 먼저 확정하지 않고 구조부터 그렸다면, 잘 만든 틀린 설계가 나왔을 거예요.</span></span></span></p><h3 id="599a360d-33e3-8398-a503-81804adf16ed" class="css-1feg9au"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">API를 쪼갠 이유</span></span></span></h3><p id="367a360d-33e3-830e-b100-814416a1c94a" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">전체 데이터를 받느라 첫 화면이 느려지면 아무 의미가 없죠. 그래서 응답 성격에 따라 API를 쪼갰어요.</span></span></span></p><aside id="3c0a360d-33e3-8008-94ec-ce662815718b" class="css-nv7vyi"><div class="css-1vn47db"><ul class="css-uswsmm"><li id="a57a360d-33e3-8315-a309-813df08fcb3f" class="css-1hwiibq"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-q3ktjb">Metadata</span><span class="css-1kxrhf3"> — 캠페인/광고세트/소재의 이름, 상태, 부모 ID 같은 구조 정보. 목록의 뼈대이고, 빠르게 옵니다.</span></span></span></li><li id="10fa360d-33e3-832b-a794-01008837246d" class="css-1hwiibq"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-q3ktjb">Performance</span><span class="css-1kxrhf3"> — 노출·클릭·전환 같은 성과 지표. 집계가 필요해서 상대적으로 느려요.</span></span></span></li><li id="7e0a360d-33e3-834c-ba6e-01bb68d209c9" class="css-1hwiibq"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-q3ktjb">그 밖의 부가 컬럼</span><span class="css-1kxrhf3"> — 표에 함께 보이면 좋지만, 없다고 해서 그 행이 없어지지는 않는 정보들.</span></span></span></li></ul></div></aside><p id="f6aa360d-33e3-82fe-b323-01617a3facca" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">쪼개고 나니 캐시 수명도 따로 정할 수 있게 됐어요. Metadata는 5분, 성과는 5초입니다. 캠페인 이름이나 계층은 자주 바뀌지 않지만 성과 숫자는 계속 갱신되니까요. 하나의 API였다면 둘 중 하나에 맞춰야 했을 텐데, 대개는 짧은 쪽에 맞추게 되고 그러면 잘 바뀌지도 않는 데이터까지 계속 다시 받게 됩니다.</span></span></span></p><p id="afaa360d-33e3-8249-935c-013addcb3b28" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">이렇게 나누니 세 가지가 자연스럽게 풀렸습니다.</span></span></span></p><p id="479a360d-33e3-83fc-a415-8132f195a68a" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-q3ktjb">첫째, 점진적 렌더링이 가능해졌어요.</span><span class="css-1kxrhf3"> Metadata가 도착하는 순간 표를 그리고, 나머지 컬럼은 스켈레톤 UI로 둡니다. 사용자는 가장 느린 API를 기다리는 대신 가장 빠른 API의 속도로 화면을 보게 돼요.</span></span></span></p><figure id="3c1a360d-33e3-8036-a11e-c6041a079fa1" class="css-of5acw"><img src="https://static.toss.im/ipd-tcs/toss_core/live/b240e842-5623-4b0d-8823-e003d0ed0670/alpha-ads-platform.toss.im_advertiser_display-ads_startDate2024-08-20endDate2025-08-19dateRangeKey1yearcontractSortspenddesc_(1).png" alt="" class="css-1pgssrp"/></figure><p id="feba360d-33e3-83e0-b9f4-8171d853db82" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-q3ktjb">둘째, 성과가 0인 항목이 사라지지 않게 됐어요.</span><span class="css-1kxrhf3"> 표에 어떤 행이 있는지는 Metadata가 정하고, 성과는 그 행에 ID로 붙는 값일 뿐이에요. 그래서 성과가 없는 캠페인도 행은 그대로 남습니다.</span></span></span></p><p id="00fa360d-33e3-82b2-b2c9-8189f5fd9b83" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">문제를 &quot;성과가 0인 항목을 서버가 내려주게 하자&quot;가 아니라 </span><span class="css-q3ktjb">&quot;목록의 주인이 누구인지 정하자&quot;</span><span class="css-1kxrhf3">로 바꿔서 푼 셈이에요.</span></span></span></p><p id="615a360d-33e3-83a9-8f84-81c4e35e40e9" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-q3ktjb">셋째, 컬럼을 덧붙일 자리가 생겼어요.</span></span></span></p><p id="c52a360d-33e3-832d-bc95-010139daf787" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">&quot;목록의 주인은 Metadata이고, 나머지는 ID 기준으로 붙는다&quot;는 규칙을 세우고 나니, 부가 컬럼 API는 특별한 존재가 아니라 </span><span class="css-q3ktjb">그 규칙을 따르는 또 하나의 컬럼 공급자</span><span class="css-1kxrhf3">가 되더라고요. 네 번째, 다섯 번째가 와도 똑같고요.</span></span></span></p><p id="72ca360d-33e3-83bd-a374-011b86f153c7" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">그래서 표에 새 정보를 붙이는 일이 구조 변경이 아니라 추가가 됐어요. API를 하나 더 호출하고, ID로 병합하고, 도착하면 스켈레톤을 풀면 끝입니다. 표의 골격도, 필터·정렬 로직도 건드리지 않아요. 거꾸로 그 API가 실패하거나 느려도 나머지 화면은 멀쩡하고요. 성과 API가 실패하면 &quot;최신 성과를 불러오지 못하고 있어요&quot;라는 안내만 띄우고 표는 그대로 둡니다. 단일 API였다면 화면 전체가 에러로 갔을 상황이, 이젠 컬럼 하나가 비는 일이 된 거예요.</span></span></span></p><p id="80ba360d-33e3-8240-b8ff-015a8760b179" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">API를 쪼갠 동기는 속도였는데, 실제로 남은 건 </span><span class="css-q3ktjb">표에 무엇이든 덧붙일 수 있는 자리</span><span class="css-1kxrhf3">였습니다.</span></span></span></p><h3 id="a91a360d-33e3-8228-ba46-01c8290c74b7" class="css-1feg9au"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">그런데 여기엔 청구서가 딸려 와요</span></span></span></h3><p id="bb1a360d-33e3-82cf-8b13-814d44ae3608" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">Metadata를 먼저 그리기로 한 순간, 데이터가 여러 번에 나누어 도착하는 화면이 됩니다.</span></span></span></p><blockquote id="407a360d-33e3-8285-997b-81a2effd2e2e" class="css-2sk6rv"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-q3ktjb">표는 이미 떠 있는데, 데이터는 아직 도착하는 중이에요.</span></span></span></blockquote><p id="577a360d-33e3-82e8-8979-018253bf0aea" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">화면 설계를 그리는 단계에서 바로 보이는 문제였어요. 표는 떠 있는데 클라이언트가 가진 데이터는 계속 불어나죠. 이 어긋남은 두 가지 모양으로 나타났습니다.</span></span></span></p><p id="e69a360d-33e3-8342-9d5e-814c9d03b564" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-q3ktjb">첫째, 조작이 데이터보다 앞설 수 있어요.</span><span class="css-1kxrhf3"> 성과 컬럼이 아직 스켈레톤인데 성과 기준 정렬을 누르면, 존재하지 않는 값으로 줄을 세우게 됩니다. 그래서 아직 도착하지 않은 컬럼은 정렬을 잠가둡니다. 이미 도착한 Metadata 컬럼은 그대로 정렬할 수 있고, 나머지는 값이 도착하는 대로 하나씩 풀려요.</span></span></span></p><p id="039a360d-33e3-8223-aab4-013535bebd7c" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-q3ktjb">둘째, 화면이 데이터를 따라가야 해요.</span><span class="css-1kxrhf3"> 이쪽이 더 성가셨습니다. 새 응답이 병합되는 순간, 지금 보고 있는 100건은 다시 계산돼야 해요. 스켈레톤 자리에 값이 들어가야 하니까요. 그다음 응답이 도착하면 또 한 번. 데이터가 도착할 때마다 &quot;다시 그려&quot;라고 호출하는 방법도 있었지만, 그건 호출을 빠뜨리면 조용히 낡은 화면이 남는 코드입니다. 표에 붙는 API가 늘어날수록 잊지 말아야 할 지점도 같이 늘어나고요.</span></span></span></p><p id="c40a360d-33e3-820d-8f59-814439d7c8d0" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">그래서 이 갱신을 </span><span class="css-q3ktjb">캐시 키에 맡겼습니다.</span><span class="css-1kxrhf3"> 새 데이터가 병합될 때마다 데이터 버전이 올라가고, 화면이 결과를 보관할 때 쓰는 캐시 키에 그 버전을 넣어뒀어요. 버전이 바뀌면 키가 바뀌고, 키가 바뀌면 이전 결과는 더 이상 쓰이지 않아서 화면은 데이터를 다시 요청합니다. 누구도 &quot;데이터 왔으니 다시 그려&quot;라고 말해주지 않아도요. 스켈레톤이 풀리는 것도 그 결과로 따라오는 일이 됐어요.</span></span></span></p><p id="d6ea360d-33e3-82f0-bbbe-818c12284b8e" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">점진적 렌더링은 ‘먼저 보여주기’라기보다 ‘</span><span class="css-q3ktjb">불완전한 상태를 정식 상태로 인정하기’</span><span class="css-1kxrhf3">에 가깝습니다. 로딩 스피너 하나를 스켈레톤으로 바꾸는 일이 아니라, 데이터가 절반만 있는 시간을 설계에 포함시키는 일이에요.</span></span></span></p><p id="31ba360d-33e3-83aa-89e8-815b13f8068f" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">(이 방식이 최선인지는 저희도 계속 들여다보고 있습니다.)</span></span></span></p><p id="3c0a360d-33e3-80b4-bcb5-d8ae30e7692a" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"></span></span></p><h2 id="bd2a360d-33e3-82d7-baf5-0195951fceed" class="css-1c1qox8"><span class="css-1r3ko7u"><span class="css-p4abj2"><span class="css-1kxrhf3">10만 건을 메인 스레드에서 정렬하면 어떻게 될까요?</span></span></span></h2><p id="deba360d-33e3-8243-8aad-015f11dce13b" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">여기서부터가 프론트엔드의 영역입니다.</span></span></span></p><p id="5a9a360d-33e3-8390-9b52-813dca0b49bf" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">10만 건 배열을 </span><code style="font-family:Consolas, Menlo, Monaco, source-code-pro, &quot;Courier New&quot;, monospace;font-size:0.9em;background-color:var(--adaptiveOpacity100);border:1px solid var(--adaptiveOpacity200);border-radius:3px"><span class="css-1mjnzsq">filter</span></code><span class="css-1kxrhf3"> → </span><code style="font-family:Consolas, Menlo, Monaco, source-code-pro, &quot;Courier New&quot;, monospace;font-size:0.9em;background-color:var(--adaptiveOpacity100);border:1px solid var(--adaptiveOpacity200);border-radius:3px"><span class="css-1mjnzsq">search</span></code><span class="css-1kxrhf3"> → </span><code style="font-family:Consolas, Menlo, Monaco, source-code-pro, &quot;Courier New&quot;, monospace;font-size:0.9em;background-color:var(--adaptiveOpacity100);border:1px solid var(--adaptiveOpacity200);border-radius:3px"><span class="css-1mjnzsq">sort</span></code><span class="css-1kxrhf3"> → </span><code style="font-family:Consolas, Menlo, Monaco, source-code-pro, &quot;Courier New&quot;, monospace;font-size:0.9em;background-color:var(--adaptiveOpacity100);border:1px solid var(--adaptiveOpacity200);border-radius:3px"><span class="css-1mjnzsq">slice</span></code><span class="css-1kxrhf3"> 하는 일을 메인 스레드에서 하면 오래 걸려요. 그동안 클릭도 입력도 큐에 쌓인 채 처리되지 않고, 리렌더도 멈춥니다. 그리고 이건 한 번으로 끝나지 않아요. 검색어를 한 글자 칠 때마다, 필터를 하나 더 걸 때마다 반복되고, 그 위에 React 리렌더가 얹혀요.</span></span></span></p><p id="339a360d-33e3-829f-bc00-81589a1d52df" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">서버를 기다리는 시간과는 성격이 다릅니다. 응답을 기다리는 동안 화면은 살아 있어요. 스크롤도 되고 다른 버튼도 눌립니다. 반면 연산이 메인 스레드를 잡고 있는 동안은 화면이 통째로 굳어요.</span></span></span></p><p id="9cfa360d-33e3-82ac-a7ff-816371cf35d1" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">그래서 데이터와 연산을 통째로 </span><span class="css-q3ktjb">Web Worker</span><span class="css-1kxrhf3">로 보냈습니다. 한 가지 짚고 갈 게 있어요. Worker가 연산을 빠르게 만들어주지는 않습니다. 같은 코드가 다른 스레드에서 돌 뿐이에요. 달라지는 건 </span><span class="css-q3ktjb">그동안 메인 스레드가 비어 있다</span><span class="css-1kxrhf3">는 점입니다. 연산이 얼마나 걸리든 스크롤도 되고 버튼도 눌리고, 리렌더도 멈추지 않아요.</span></span></span></p><p id="0a3a360d-33e3-835b-93a8-012603907974" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">Worker를 고른 이유는 연산을 줄이려는 게 아니라, </span><span class="css-q3ktjb">연산하는 시간과 화면이 굳는 시간을 떼어놓으려는 것</span><span class="css-1kxrhf3">이었습니다. 참고로 Worker 안에서 한 번의 필터링·정렬·검색·페이지네이션은 10ms 남짓에 끝나요.</span></span></span></p><p id="faba360d-33e3-8383-9334-815bf9ae042f" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">이 구조는 저희가 자체 개발한 데이터 계층이에요. 역할은 세 가지로 나눴어요.</span></span></span></p><aside id="3c0a360d-33e3-809b-8732-c63ac4f16c87" class="css-nv7vyi"><div class="css-1vn47db"><ul class="css-uswsmm"><li id="4b2a360d-33e3-8361-96dd-81beb114061f" class="css-1hwiibq"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-q3ktjb">조율 계층:</span><span class="css-1kxrhf3"> 어떤 API를 어떤 순서로 부를지, 받은 응답을 언제 Worker로 넘길지 결정해요</span></span></span></li><li id="212a360d-33e3-82ff-a500-011d755e8909" class="css-1hwiibq"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-q3ktjb">통신 계층:</span><span class="css-1kxrhf3"> Worker를 만들고, 살아 있는지 확인하고, 요청과 응답을 짝지어요</span></span></span></li><li id="ef8a360d-33e3-83f8-a70c-01e514e2b829" class="css-1hwiibq"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-q3ktjb">Worker:</span><span class="css-1kxrhf3"> 전체 데이터를 갖고 있고, 실제 연산을 해요</span></span></span></li></ul></div></aside><p id="08fa360d-33e3-834d-8461-01892cbcd00f" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">조율과 통신을 굳이 나눈 이유는 하나예요. </span><span class="css-q3ktjb">Worker가 죽는다는 사실을 조율 계층이 몰라도 되게</span><span class="css-1kxrhf3"> 하려고요. 재생성·타임아웃·상태 확인이 전부 통신 계층 안에서 끝나서, 바깥에서는 그냥 데이터를 요청하는 코드로 보입니다.</span></span></span></p><h3 id="86fa360d-33e3-83ed-98f5-01ff31f7478b" class="css-1feg9au"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">그런데 왜 Worker가 직접 데이터를 받아오지 않나요?</span></span></span></h3><p id="18ea360d-33e3-82ba-96fb-0157160cc555" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">위 그림을 보면 바로 걸리는 지점이 있어요. </span><span class="css-q3ktjb">어차피 데이터를 Worker가 들고 있을 거면, 처음부터 Worker가 API를 부르면 되지 않나?</span><span class="css-1kxrhf3"> 메인 스레드를 한 번도 안 거쳐도 될 텐데요.</span></span></span></p><p id="9a3a360d-33e3-83f7-9535-8112e3fcbf9a" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">저희도 그 구조를 먼저 그려봤어요. 그런데 막힌 건 성능이 아니라 </span><span class="css-q3ktjb">API 클라이언트</span><span class="css-1kxrhf3">였습니다. 저희 API 호출은 공통 HTTP 클라이언트를 거쳐요. 인증뿐 아니라 공통 에러 처리, 재시도 같은 게 전부 여기에 얹혀 있고요. Worker에서 직접 요청을 보내려면 이 계층을 Worker 쪽에 한 벌 더 두거나 별도 경로를 만들어야 했습니다. 같은 규칙을 두 곳에서 관리하게 되는 거죠. 전송 몇 번을 아끼려고 낼 값은 아니라고 봤어요.</span></span></span></p><p id="894a360d-33e3-8302-b10d-01691fc0e6bc" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">대신 지금 구조에서 메인 스레드가 실제로 치르는 값을 따져봤습니다. 응답을 받아 Worker로 넘기는 일, 그게 전부예요. 본문을 해석하고 다루는 건 전부 Worker 몫이고요. 게다가 그 넘기는 일조차 API마다 한 번씩, 진입할 때가 전부입니다. 그 한 번을 어떻게 싸게 넘기는지는 바로 뒤에서 이야기할게요.</span></span></span></p><p id="8aaa360d-33e3-82f3-b504-017e0c01b943" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">그래서 지금은 이 구조를 유지하되, 다음 개선 때는 HTTP 클라이언트를 Worker에서도 쓸 수 있는 형태로 정리해보려고 해요. 이건 &quot;이게 정답이다&quot;가 아니라, </span><span class="css-q3ktjb">지금 구조에서 메인 스레드가 치르는 값이 충분히 작다</span><span class="css-1kxrhf3">고 판단한 결과입니다.</span></span></span></p><h3 id="850a360d-33e3-83d6-a9a1-81acad8d4b5c" class="css-1feg9au"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">Worker를 쓰면 따라오는 세 가지 함정</span></span></span></h3><p id="40ba360d-33e3-8214-a4b4-01166ef92ced" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">Worker는 ‘무거운 일 넘기면 끝’이 아니에요. 넘기는 비용이 따라옵니다.</span></span></span></p><p id="427a360d-33e3-829d-9f5c-8150591f2aa0" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">1️⃣</span><span class="css-q3ktjb"> 직렬화 비용.</span><span class="css-1kxrhf3"> </span><code style="font-family:Consolas, Menlo, Monaco, source-code-pro, &quot;Courier New&quot;, monospace;font-size:0.9em;background-color:var(--adaptiveOpacity100);border:1px solid var(--adaptiveOpacity200);border-radius:3px"><span class="css-1mjnzsq">postMessage</span></code><span class="css-1kxrhf3">는 기본적으로 구조화 복제(Structured Clone)를 합니다. 보내는 쪽 데이터를 통째로 복사해서 받는 쪽에 새로 만드는 방식이에요. 10만 건을 매번 복사하면 Worker로 옮긴 이득이 사라집니다.</span></span></span></p><p id="59fa360d-33e3-82e9-95fb-01cb2e47b7e9" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">→ 그래서 초기 데이터는 응답을 </span><code style="font-family:Consolas, Menlo, Monaco, source-code-pro, &quot;Courier New&quot;, monospace;font-size:0.9em;background-color:var(--adaptiveOpacity100);border:1px solid var(--adaptiveOpacity200);border-radius:3px"><span class="css-1mjnzsq">res.arrayBuffer()</span></code><span class="css-1kxrhf3">로 받아 </span><span class="css-q3ktjb">Transferable(ArrayBuffer)</span><span class="css-1kxrhf3">로 소유권만 넘깁니다. 메인 스레드는 본문을 파싱조차 하지 않아요. 복사 없이 버퍼의 주인을 바꿔버리는 방식이라, 보낸 쪽은 그 버퍼를 더 이상 쓸 수 없게 되는 대신 복사 비용이 0이에요. 이후 Worker가 돌려주는 건 </span><span class="css-q3ktjb">현재 페이지 100건</span><span class="css-1kxrhf3">뿐입니다. 처음에 넘어간 전체 데이터와 비교하면 무시해도 될 크기예요. 위 그림의 화살표 두 개가 비대칭인 이유예요. 큰 건 진입할 때만 건너가고, 그다음부터는 작은 것만 오갑니다.</span></span></span></p><p id="e9ba360d-33e3-83f2-a242-81206af65892" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">2️⃣ </span><span class="css-q3ktjb">메모리 이중화.</span><span class="css-1kxrhf3"> 메인 스레드와 Worker가 같은 데이터를 각각 들고 있으면 메모리를 두 배로 씁니다.</span></span></span></p><p id="70ca360d-33e3-8323-9ced-01a7665dca4a" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">→ </span><span class="css-q3ktjb">전체 데이터는 Worker에만</span><span class="css-1kxrhf3"> 둡니다. 메인 스레드는 지금 화면에 그릴 100건만 알아요. 전체 데이터를 클라이언트로 가져왔는데도 React가 들고 있는 양은 이전과 똑같은 셈이에요.</span></span></span></p><p id="3aca360d-33e3-8389-b919-016177a7c3d8" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">3️⃣</span><span class="css-q3ktjb"> 비동기 복잡도.</span><span class="css-1kxrhf3"> Worker 통신은 요청과 응답이 자동으로 짝지어지지 않는 이벤트 기반이에요.</span></span></span></p><p id="37aa360d-33e3-82c7-9c10-01b286a4945c" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">→ 통신 계층이 요청 ID와 </span><code style="font-family:Consolas, Menlo, Monaco, source-code-pro, &quot;Courier New&quot;, monospace;font-size:0.9em;background-color:var(--adaptiveOpacity100);border:1px solid var(--adaptiveOpacity200);border-radius:3px"><span class="css-1mjnzsq">Promise</span></code><span class="css-1kxrhf3">를 짝짓는 일을 전담해서, 바깥에서는 그냥 </span><code style="font-family:Consolas, Menlo, Monaco, source-code-pro, &quot;Courier New&quot;, monospace;font-size:0.9em;background-color:var(--adaptiveOpacity100);border:1px solid var(--adaptiveOpacity200);border-radius:3px"><span class="css-1mjnzsq">await</span></code><span class="css-1kxrhf3"> 하는 함수처럼 보이게 감쌌습니다.</span></span></span></p><h3 id="007a360d-33e3-83b1-8e6e-81cd4092b2b7" class="css-1feg9au"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">Worker가 죽으면요?</span></span></span></h3><p id="4cba360d-33e3-83d4-8446-01dedd882780" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">이게 실무에서 제일 중요한 질문이었어요. 데이터를 Worker에 다 넣어뒀는데 Worker가 죽으면 화면이 통째로 빈 상태가 되니까요.</span></span></span></p><p id="e3ba360d-33e3-838a-a5c7-01e1110e72dd" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">먼저 죽었다는 걸 알아야 해요. Worker에 5초마다 Ping을 보내고, 3초 안에 답이 없으면 죽은 걸로 봅니다. 사용자가 보낸 요청이 처리 중일 때는 이 검사를 건너뛰어요. 그 요청에는 이미 10초 타임아웃이 걸려 있으니까요.</span></span></span></p><p id="bf2a360d-33e3-826d-8884-819b24767535" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">죽은 걸 확인하면 Worker를 정리하는데, 그다음이 두 갈래로 갈립니다. 기준은 </span><span class="css-q3ktjb">&quot;지금 답을 기다리는 사람이 있는가&quot;</span><span class="css-1kxrhf3">예요.</span></span></span></p><ul class="css-uswsmm"><li id="7b1a360d-33e3-82ec-8ce8-81fcc1e9c068" class="css-1hwiibq"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-q3ktjb">대기 중인 요청이 있으면</span><span class="css-1kxrhf3"> → 그 요청들을 전부 실패시켜 </span><code style="font-family:Consolas, Menlo, Monaco, source-code-pro, &quot;Courier New&quot;, monospace;font-size:0.9em;background-color:var(--adaptiveOpacity100);border:1px solid var(--adaptiveOpacity200);border-radius:3px"><span class="css-1mjnzsq">ErrorBoundary</span></code><span class="css-1kxrhf3">로 올려보냅니다. 사용자가 &quot;다시 시도&quot;를 누를 때 복구해요. 누군가 화면 앞에서 기다리고 있는 상황이라면, 조용히 처리하는 것보다 무슨 일이 있었는지 알리는 게 맞다고 봤어요.</span></span></span></li><li id="21ca360d-33e3-828c-8b90-81c65923c962" class="css-1hwiibq"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-q3ktjb">대기 중인 요청이 없으면</span><span class="css-1kxrhf3"> → 사용자가 눈치채기 전에 알아서 복구합니다. 캐시를 무효화하면 데이터 로딩이 다시 돌고, API를 다시 호출해서 받은 버퍼를 새 Worker에 넣어요.</span></span></span></li></ul><p id="169a360d-33e3-8365-ac8a-013149d854d7" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">두 번째 경로를 보면 알 수 있듯이, 복구는 ‘캐시만 비우면 끝’이 아니에요. 데이터가 Worker에만 있으니, Worker가 죽었다는 건 데이터가 없어졌다는 뜻입니다. 서버에서 다시 받아오는 것 말고는 방법이 없어요. </span><span class="css-q3ktjb">진입할 때 했던 일을 그대로 한 번 더 하는 셈입니다.</span><span class="css-1kxrhf3"> 이게 &quot;전체 데이터를 클라이언트에 둔다&quot;는 결정에 딸려 온 비용이에요.</span></span></span></p><p id="f17a360d-33e3-8302-9fd2-010d3a1a2754" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">그리고 죽은 순간의 정황을 같이 남깁니다. 대기 중이던 요청이 무엇이었는지, 탭이 백그라운드였는지, 그 시점 메모리 사용량은 얼마였는지. 메모리 부족으로 죽은 건지 다른 이유인지 나중에 판단하려면 이게 있어야 하거든요.</span></span></span></p><p id="3c0a360d-33e3-8059-aeeb-d169b2131e7f" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"></span></span></p><h2 id="f48a360d-33e3-82ab-a07d-81bfe8d8b622" class="css-1c1qox8"><span class="css-1r3ko7u"><span class="css-p4abj2"><span class="css-1kxrhf3">못 만들던 기능이 만들 수 있는 기능이 됐어요</span></span></span></h2><p id="706a360d-33e3-833c-bbd5-8165cf531a46" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">여기까지가 구조 이야기예요. 이제 앞에서 세운 네 가지 문제로 돌아가볼게요. 저는 이 작업에서 이 부분이 제일 재미있었습니다.</span></span></span></p><h3 id="926a360d-33e3-8366-aca5-8166fcd77cd4" class="css-1feg9au"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">1. 성과가 0인 항목이 사라지지 않아요</span></span></span></h3><p id="816a360d-33e3-83d8-adcf-0112ba1e2c21" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">목록의 주인을 Metadata로 정하면서 자동으로 해결됐어요. 서버에 &quot;성과가 없는 항목도 0으로 채워서 내려달라&quot;고 요청할 필요가 없어졌고, 집계 테이블은 집계 테이블대로 남겨둘 수 있었습니다.</span></span></span></p><h3 id="cffa360d-33e3-82f5-a58e-81173086b913" class="css-1feg9au"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">2. &quot;캠페인 1234가 몇 페이지에 있지?&quot;에 답할 수 있어요</span></span></span></h3><p id="c97a360d-33e3-83ca-93e2-0125abf2443e" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">전체 배열을 갖고 있으니 답이 두 줄이에요. 배열을 들고 있는 쪽이 Worker이니, 이 계산도 Worker 안에서 돌아갑니다.</span></span></span></p><p id="f93a360d-33e3-83c7-b691-01275ea8352d" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">광고주가 어디선가 받은 캠페인 ID를 붙여넣으면, 그게 몇 페이지에 있든 바로 그 행으로 갑니다. 이 기능을 위해 추가한 서버 API는 없어요. 예전엔 이 두 줄을 쓸 수 없어서 기능 자체가 백로그에 있었습니다.</span></span></span></p><h3 id="96ca360d-33e3-832e-88d9-81101e2bae30" class="css-1feg9au"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">3. 서버가 모르는 기준으로도 정렬할 수 있어요</span></span></span></h3><p id="f34a360d-33e3-821b-8ddd-8178f43290a4" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">&quot;노출 상태로 정렬해주세요&quot; 같은 요청이 오면 비교 함수 하나를 추가하면 끝이에요. 서버 API 스펙 변경도, 배포 순서 조율도 필요 없습니다. 화면에 이미 보이는 값이라면 그 값으로 정렬할 수 있어요.</span></span></span></p><h3 id="ecfa360d-33e3-82a4-9338-81af9934ec4b" class="css-1feg9au"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">4. URL에 담긴 선택 상태를 검증할 수 있어요</span></span></span></h3><p id="cc1a360d-33e3-82a6-9adb-81660ac227c9" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">Metadata에 부모 ID가 있으니, 진입할 때 </span><code style="font-family:Consolas, Menlo, Monaco, source-code-pro, &quot;Courier New&quot;, monospace;font-size:0.9em;background-color:var(--adaptiveOpacity100);border:1px solid var(--adaptiveOpacity200);border-radius:3px"><span class="css-1mjnzsq">?campaignIds=1&amp;adsetIds=10</span></code><span class="css-1kxrhf3"> 같은 조합이 실제 계층과 맞는지 한 번 확인합니다. 광고세트 10의 부모가 캠페인 1이 아니면 그 선택은 버리고요. 오래된 링크를 열어도 앞뒤가 맞는 화면이 뜹니다.</span></span></span></p><p id="c86a360d-33e3-8316-8aa2-816799770da8" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">네 개를 나란히 놓고 보면 공통점이 있어요. 전부 </span><span class="css-q3ktjb">&quot;서버에 무엇을 더 요청할까&quot;가 아니라 &quot;이미 가진 데이터로 무엇을 계산할까&quot;의 문제가 됐습니다.</span><span class="css-1kxrhf3"> 데이터를 어디에 두느냐를 바꿨을 뿐인데요.</span></span></span></p><p id="3c0a360d-33e3-80fd-873d-f2dfdfdd09ef" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"></span></span></p><h2 id="73ca360d-33e3-8263-baec-0101c06e82c5" class="css-1c1qox8"><span class="css-1r3ko7u"><span class="css-p4abj2"><span class="css-1kxrhf3">고려했지만 선택하지 않은 것들</span></span></span></h2><p id="ab4a360d-33e3-8221-9809-01390f06b34b" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">결정만큼 중요한 게 안 한 결정이라고 생각해서, 기각한 선택지도 남겨뒀어요.</span></span></span></p><h3 id="59fa360d-33e3-8298-82bd-015fe2cd25f9" class="css-1feg9au"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">Virtual Scrolling</span></span></span></h3><p id="b49a360d-33e3-8364-ad88-81cc0312efcf" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">큰 목록 이야기가 나오면 거의 자동으로 따라오는 카드죠. 하지만 저희는 </span><span class="css-q3ktjb">100건 단위 페이지네이션</span><span class="css-1kxrhf3">을 씁니다. 한 번에 그리는 건 100행이고, 이 정도면 컬럼이 수십 개여도 DOM(Document Object Model) 부하가 병목이 아니라는 걸 확인했어요. Virtual Scrolling은 수천 개 행을 한 화면에 스크롤로 펼칠 때 필요한 도구고, 저희 UX는 그런 모양이 아니었습니다.</span></span></span></p><p id="d02a360d-33e3-8382-a874-8152242e866c" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">&quot;데이터가 많다&quot;와 &quot;DOM이 많다&quot;는 다른 문제인데, 자주 같은 문제로 취급돼요.</span></span></span></p><h3 id="78ba360d-33e3-83b9-86c1-8139b532e5a5" class="css-1feg9au"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">IndexedDB 캐싱</span></span></span></h3><p id="7f0a360d-33e3-824c-8ada-01e8f0e7f80b" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">전체 데이터를 받는다면 브라우저에 저장해뒀다가 다음에 재활용하고 싶어지죠. 그런데 기각했습니다.</span></span></span></p><aside id="3c0a360d-33e3-80c9-afd2-cee44062df39" class="css-nv7vyi"><div class="css-1vn47db"><ul class="css-uswsmm"><li id="33ea360d-33e3-8309-b1ea-81acd093a42d" class="css-1hwiibq"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-q3ktjb">무효화 판단.</span><span class="css-1kxrhf3"> 언제 캐시를 버려야 하는지가 명확하지 않았어요. 명확하지 않은 무효화 규칙은 결국 버그가 됩니다.</span></span></span></li><li id="39ca360d-33e3-8359-bd1c-81ea85ce1df0" class="css-1hwiibq"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-q3ktjb">요구 대비 과함.</span><span class="css-1kxrhf3"> 세션 안에서만 데이터를 유지해도 저희가 풀려던 문제는 전부 풀렸어요.</span></span></span></li><li id="966a360d-33e3-820c-a51e-817353df28b6" class="css-1hwiibq"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-q3ktjb">부수 비용.</span><span class="css-1kxrhf3"> 버전 관리, 스키마 마이그레이션 같은 게 따라옵니다.</span></span></span></li></ul></div></aside><p id="a9aa360d-33e3-8265-ba00-811fbb7785b9" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">지금 필요 없는 걸 만들지 않는 것도 설계라고 봤어요.</span></span></span></p><p id="3c0a360d-33e3-803c-a472-e54f19d847f4" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"></span></span></p><h2 id="3e4a360d-33e3-827e-a91c-014193707e17" class="css-1c1qox8"><span class="css-1r3ko7u"><span class="css-p4abj2"><span class="css-1kxrhf3">감수하기로 한 것</span></span></span></h2><aside id="3c0a360d-33e3-8012-8688-f9de9f012c49" class="css-nv7vyi"><div class="css-1vn47db"><ul class="css-uswsmm"><li id="b22a360d-33e3-82b2-a6a4-812e489432c3" class="css-1hwiibq"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">페이지 진입 시 전체 데이터를 받아야 해요. (그래서 점진적 렌더링이 필수였습니다.)</span></span></span></li><li id="44aa360d-33e3-826f-92ac-815873c40125" class="css-1hwiibq"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">클라이언트 메모리 사용량이 늘어요.</span></span></span></li><li id="0b2a360d-33e3-827f-98b9-01dee394f628" class="css-1hwiibq"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">Web Worker라는 의존성이 생겼어요. Worker가 죽으면 데이터도 같이 사라지기 때문에, 복구는 서버에서 전부 다시 받아오는 것으로만 가능합니다.</span></span></span></li></ul></div></aside><p id="89fa360d-33e3-8334-aba7-015c25e25f30" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">트레이드오프가 없는 결정은 대개 결정이 아니라 취향이라고 생각해요. 저희는 위 세 가지를 알고 골랐습니다.</span></span></span></p><p id="3c0a360d-33e3-8037-91e7-ca0e50ea7ca0" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"></span></span></p><h2 id="dcba360d-33e3-83e4-83a9-810d50fd1de4" class="css-1c1qox8"><span class="css-1r3ko7u"><span class="css-p4abj2"><span class="css-1kxrhf3">광고 도메인 프론트엔드는 이런 문제를 풀어요</span></span></span></h2><p id="0c4a360d-33e3-8343-861e-01c51aaa167f" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">이 글은 결국 대시보드 하나를 고친 이야기지만, 광고 도메인에서 프론트엔드가 마주치는 문제의 생김새를 꽤 잘 보여준다고 생각해요.</span></span></span></p><aside id="3c0a360d-33e3-808b-bb8a-fbc6137542f8" class="css-nv7vyi"><div class="css-1vn47db"><ul class="css-uswsmm"><li id="32da360d-33e3-82f0-bd27-018d609d2197" class="css-1hwiibq"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-q3ktjb">데이터가 크고, 계층적이고, 자주 갱신돼요.</span><span class="css-1kxrhf3"> 
캠페인 → 광고세트 → 소재 계층에 30분마다 갱신되는 성과 지표가 붙습니다. &quot;표를 그린다&quot;가 아니라 &quot;데이터 모델을 설계한다&quot;에 가까운 일이 자주 생겨요.</span></span></span></li><li id="261a360d-33e3-824a-91aa-81323da9a1a1" class="css-1hwiibq"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-q3ktjb">이 화면은 콘텐츠가 아니라 업무 도구예요.</span><span class="css-1kxrhf3"> 
광고주는 여기서 돈을 쓰는 결정을 합니다. 응답 속도는 그 결정을 몇 번이나 다시 재볼 수 있는지를 좌우합니다.</span></span></span></li><li id="a79a360d-33e3-837b-8d5a-0155d804efdb" class="css-1hwiibq"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-q3ktjb">어디서 처리할지를 계속 정해야 해요.</span><span class="css-1kxrhf3"> 
서버냐 클라이언트냐, 메인 스레드냐 Worker냐. 정답이 정해져 있지 않고, 매번 데이터 상한과 요구사항으로 다시 판단해야 합니다.</span></span></span></li></ul></div></aside><p id="7bca360d-33e3-8206-b1ce-01baa92ad2a3" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">프론트엔드가 화면만 그리는 일처럼 느껴져 아쉬웠다면, 이런 문제들이 꽤 반가우실 거예요. 저는 그래서 이 도메인이 재밌습니다.</span></span></span></p><p id="3c0a360d-33e3-8000-8226-e41cb74128d8" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"></span></span></p><h2 id="49fa360d-33e3-82da-b398-816d6cccd8cd" class="css-1c1qox8"><span class="css-1r3ko7u"><span class="css-p4abj2"><span class="css-1kxrhf3">결정은 문서로 남습니다</span></span></span></h2><p id="355a360d-33e3-827b-be9c-81799ce83459" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">저희는 이렇게 내린 판단을 코드와 문서에 남깁니다. 결정 문서에는 고른 선택지뿐 아니라 기각한 선택지와 그 이유가 같이 들어가고, 코드 주석에는 ‘왜 이 조건에서는 검사를 건너뛰는지’ 같은 판단이 남아요. 아직 확정되지 않은 부분에는 ‘변경 가능성 있음’이라고 적어두고요.</span></span></span></p><p id="0f8a360d-33e3-8237-b3d9-8144818548f5" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">사실 이 글의 절반은 그때 남겨둔 문서에서 그대로 나왔습니다. 아티클을 쓰려고 새로 정리한 게 아니라, 이미 있던 걸 꺼낸 거예요.</span></span></span></p><p id="3c0a360d-33e3-80f8-80b0-dbf98e3aceb3" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"></span></span></p><h2 id="971a360d-33e3-8350-984b-01eb80f39e3c" class="css-1c1qox8"><span class="css-1r3ko7u"><span class="css-p4abj2"><span class="css-1kxrhf3">아직 못 푸는 문제</span></span></span></h2><p id="05ba360d-33e3-8216-b443-818839df75fa" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">지금 구조에서 가장 아픈 곳은 </span><span class="css-q3ktjb">첫 진입</span><span class="css-1kxrhf3">이에요. 필터도 정렬도 즉시 끝나지만, 그 즉시가 시작되기까지가 느립니다. 진입할 때 전체 데이터를 받아야 하니까요.</span></span></span></p><p id="a76a360d-33e3-825f-afab-81ac80a317ec" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">점진적 렌더링으로 ‘표가 뜨는 시점’은 앞당겼지만, ‘표가 완전히 살아나는 시점’은 여전히 늦어요. 그리고 데이터가 많은 계정일수록 더 느립니다. 이 구조의 이득을 가장 크게 보는 계정이 비용도 가장 많이 내는 셈이에요.</span></span></span></p><p id="b42a360d-33e3-822d-94b6-015127edda6d" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">곤란한 건 이걸 쉽게 버릴 수 없다는 점입니다. 이 글에서 이야기한 이득이 전부 ‘클라이언트가 전체 데이터를 갖고 있다’는 한 가지에서 나오거든요. 첫 페이지만 먼저 받는 식으로 진입을 빠르게 만들면, 그 순간 ID로 페이지를 계산하는 것도 URL 계층을 검증하는 것도 다시 불가능해집니다.</span></span></span></p><p id="f9ea360d-33e3-8317-9f5f-01f303e15076" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">그래서 지금은 ‘전제를 지키면서 진입만 빠르게 할 수 있는가&#x27;를 보고 있어요. 아직 답은 없습니다.</span></span></span></p><p id="1fca360d-33e3-82c2-acd6-815c35718dfd" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">이 글을 읽으면서 &quot;나라면 이렇게 했을 텐데&quot; 싶은 지점이 있었다면, 그 얘기를 듣고 싶어요.</span></span></span></p><p id="815a360d-33e3-83be-ac43-8183f67d1d3b" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">읽어주셔서 감사합니다.</span></span></span></p></div><div id="tds-mobile-portal-container"></div>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[토스는 어떻게 광고 속에 게임을 넣었을까]]></title>
            <link>https://toss.tech/article/games-in-ads</link>
            <guid isPermaLink="true">https://toss.tech/article/games-in-ads</guid>
            <pubDate>Tue, 18 Aug 2026 01:00:00 GMT</pubDate>
            <description><![CDATA[토스에서 게임 광고를 받기 위해 MRAID 기반 플레이어블 광고 SDK를 직접 만든 이야기를 소개해요.]]></description>
            <content:encoded><![CDATA[<link rel="preload" as="image" href="https://static.toss.im/ipd-tcs/toss_core/live/e1963c2a-7f6c-449e-941f-fd59797eb8b4/image.png"/><link rel="preload" as="image" href="https://static.toss.im/ipd-tcs/toss_core/live/cd9028d0-b768-4b86-af67-3d7da1e4d446/image.png"/><link rel="preload" as="image" href="https://static.toss.im/ipd-tcs/toss_core/live/4552d8cc-6744-45f8-8b21-bc0da9fd13a9/image.png"/><link rel="preload" as="image" href="https://static.toss.im/ipd-tcs/toss_core/live/a958ecfb-1964-4d4e-853f-2a8610e2a639/image.png"/><link rel="preload" as="image" href="https://static.toss.im/ipd-tcs/toss_core/live/1d03077e-46c6-4b23-8afc-f4337df848d6/image.png"/><link rel="preload" as="image" href="https://static.toss.im/ipd-tcs/toss_core/live/6940f5f4-29ce-4e4b-8165-5e92ca0bac80/image.png"/><link rel="preload" as="image" href="https://static.toss.im/ipd-tcs/toss_core/live/3bcdca8e-e9d1-4e54-b992-bd072ff58a04/image.png"/><link rel="preload" as="image" href="https://static.toss.im/ipd-tcs/toss_core/live/6625750d-09b1-4730-ad0d-3a5f8a054b1d/image.png"/><link rel="preload" as="image" href="https://static.toss.im/ipd-tcs/toss_core/live/8ab53e76-3330-4d85-8d93-9183a59516ea/image.png"/><link rel="preload" as="image" href="https://static.toss.im/ipd-tcs/toss_core/live/1bc8a50e-391c-4887-92aa-a4ddd11263e4/image.png"/><div class="css-1vn47db"><p id="3baa360d-33e3-8094-9bd3-d7784b8187a1" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">안녕하세요, 토스에서 광고 SDK와 게임 광고 서비스를 개발하는 Frontend Developer 정윤호입니다.</span></span></span></p><h2 id="3baa360d-33e3-80bd-86fc-da7b00e4abec" class="css-1c1qox8"><span class="css-1r3ko7u"><span class="css-p4abj2"><span class="css-1kxrhf3">토스 지면에 게임 광고가 적었던 이유</span></span></span></h2><p id="3baa360d-33e3-80a1-b43e-e836ce216084" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">토스 지면(앱 안에서 광고가 노출되는 자리)에는 게임 광고 집행이 유독 적었습니다. 원인은 소재 유형이었어요. 게임 회사의 사용자 확보(UA, User Acquisition) 마케팅에서, 앱을 설치하기 전에 광고 안에서 게임을 직접 해 보는 플레이어블 광고(Playable Ads)가 주목받는 광고 유형인데, 토스 지면이 이 유형을 받지 못했어요.</span></span></span></p><figure id="3baa360d-33e3-8073-b10b-cf6325ba624b" class="css-of5acw"><img src="https://static.toss.im/ipd-tcs/toss_core/live/e1963c2a-7f6c-449e-941f-fd59797eb8b4/image.png" alt="" class="css-1pgssrp"/></figure><p id="3baa360d-33e3-8035-af05-ee58f139fc36" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">광고에 실려 나가는 콘텐츠를 업계에서는 소재(크리에이티브)라고 불러요. 광고 플랫폼 </span><a target="_blank" rel="noreferrer noopener" class="css-iynyr0" href="https://www.moloco.com/blog/ad-formats-gaming-playable-video-end-cards">Moloco는 자사 상위 미국 게임 광고주 사례에서</a><span class="css-1kxrhf3"> 플레이어블 소재가 노출 천 회당 설치 수(IPM, Installs Per Mille)를 49%, 광고비 대비 수익(ROAS, Return On Ad Spend)을 25% 끌어올렸다고 소개해요. 게임 광고주는 성과로 매체를 선택하니, 이 소재 유형을 받지 못하는 지면에 예산을 배정할 이유가 약했던 거예요.</span></span></span></p><p id="3baa360d-33e3-80e9-9332-e8b147f8eb68" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">그래서 문제를 다시 정의했습니다. &quot;게임 광고 영업이 안 됐다&quot;가 아니라 </span><span class="css-q3ktjb">&quot;게임 광고주가 쓰는 소재 유형을 우리 지면이 받지 못했다&quot;</span><span class="css-1kxrhf3">는 것으로요.</span></span></span></p><p id="3baa360d-33e3-80ec-8a91-fa3cabc59a3c" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">보통의 앱은 광고 네트워크의 상용 SDK(Software Development Kit)를 붙여 플레이어블 지원을 통째로 얻어요. 토스는 광고 시스템을 서버부터 직접 만들어 왔기 때문에 그 위에 서드파티 SDK를 얹으면 통합도 통제도 어긋나요. 광고를 선택하는 시스템을 직접 만든 이상, 화면에 띄우는 쪽도 직접 만드는 게 좋은 방향이라고 판단했어요.</span></span></span></p><p id="3baa360d-33e3-801d-a804-e1cfe13c07a5" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">그렇게 플레이어블 광고를 직접 구현한 여정을 소개할게요.</span></span></span></p><p id="3bba360d-33e3-8011-81ef-ef569546d657" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"></span></span></p><h2 id="3baa360d-33e3-8060-8c15-d904b7b9aa60" class="css-1c1qox8"><span class="css-1r3ko7u"><span class="css-p4abj2"><span class="css-1kxrhf3">플레이어블 광고의 구조</span></span></span></h2><p id="3baa360d-33e3-80a6-90b9-cd7499fe14f3" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">우선 플레이어블 광고가 어떻게 돌아가는지부터 볼게요. 플레이어블 광고 한 편이 화면에 뜨기까지의 흐름은 이렇습니다.</span></span></span></p><figure id="3baa360d-33e3-8041-a107-e99fb9c046bf" class="css-of5acw"><img src="https://static.toss.im/ipd-tcs/toss_core/live/cd9028d0-b768-4b86-af67-3d7da1e4d446/image.png" alt="" class="css-1pgssrp"/></figure><aside id="3bba360d-33e3-8055-9cf3-e525f5efef59" class="css-nv7vyi"><div class="css-1vn47db"><ol class="css-hokoge"><li id="3baa360d-33e3-8028-82fb-fb8370ae55d4" class="css-1hwiibq"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">광고주가 광고 소재를 등록해요.</span></span></span></li><li id="3baa360d-33e3-8033-a039-e80e8dc6a5e9" class="css-1hwiibq"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">유저가 토스 앱을 쓰다가 광고 지면을 만나요.</span></span></span></li><li id="3baa360d-33e3-80f4-b2a6-d33a4a7f6b25" class="css-1hwiibq"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">지면이 광고 서버에 광고를 요청해요.</span></span></span></li><li id="3baa360d-33e3-801f-a021-ff7f59dc1623" class="css-1hwiibq"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">서버가 광고를 골라, 소재와 함께 브릿지 JS의 주소를 응답에 실어 보내요.</span></span></span></li><li id="3baa360d-33e3-8065-bc5a-f3d2fd24959c" class="css-1hwiibq"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">네이티브 앱이 웹뷰(WebView)를 만들고, 브릿지 JS를 소재보다 먼저 주입한 뒤 광고를 띄워요.</span></span></span></li></ol></div></aside><p id="3baa360d-33e3-803b-b169-e4d58742271d" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">소재는 HTML 파일 하나에 게임 한 판이 통째로 담긴 형태이고, 등록 시점에 단일 HTML, 5MB 제한을 검사받아요. 광고를 화면에 붙이고 떼는 권한, 클릭과 노출을 기록하는 권한은 네이티브 앱에 있어요.</span></span></span></p><p id="3baa360d-33e3-80fe-8525-e7eaad357ded" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">브릿지 JS(JavaScript)는 광고 소재와 네이티브 앱 사이의 통신을 맡아요. 둘은 서로를 모르는 코드라 직접 대화할 방법이 없어요. 소재가 호출할 API(Application Programming Interface)를 제공하고, 광고의 상태를 들고, 메시지를 양방향으로 나르는 게 브릿지 JS의 일이에요.</span></span></span></p><figure id="3baa360d-33e3-80d5-94b1-e34394ddefee" class="css-of5acw"><img src="https://static.toss.im/ipd-tcs/toss_core/live/4552d8cc-6744-45f8-8b21-bc0da9fd13a9/image.png" alt="" class="css-1pgssrp"/></figure><p id="3baa360d-33e3-80e2-af48-d32a73669627" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">광고 한 편이 뜨고 닫히기까지의 이벤트 순서예요. 사용자에게는 광고 한 편이 떴다 닫히는 단순한 경험이지만, 그 뒤에서는 구성요소들이 이렇게 이벤트를 주고받고 있어요.</span></span></span></p><p id="3bba360d-33e3-8046-9d6f-c8663a3c0f63" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"></span></span></p><h2 id="3baa360d-33e3-80d3-9761-e9e74e22f185" class="css-1c1qox8"><span class="css-1r3ko7u"><span class="css-p4abj2"><span class="css-1kxrhf3">브릿지 JS 규약으로 MRAID를 선택한 이유</span></span></span></h2><p id="3baa360d-33e3-802e-80aa-ea7eaf51cf59" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">위에서 설명했던 브릿지 JS가 소재에게 제공할 API 규약은 어떻게 정해야 할까요? 소재는 토스만 보고 만들어지는 자산이 아니라서, 저희 마음대로 정할 수 없었어요.</span></span></span></p><p id="3baa360d-33e3-80bc-b31e-d201eb498ee8" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">제작사는 같은 소재를 Meta, Google 같은 광고 네트워크(광고주와 지면을 연결해 광고를 유통하고 중개하는 사업자) 여러 곳에 제출해요. 실제로 소재는 각 네트워크의 규약에 맞춰 만들어지는데, 그 규약이 서로 달라서 지면마다 소재를 다시 만들어야 해요.</span></span></span></p><div data-table-wrapper="true" class="css-1m7s5mg"><div role="table" class="css-ae8zm3"><div role="row" class="css-1obf64m"><div role="cell" class="css-8vl804"><span class="css-1mjnzsq">네트워크</span></div><div role="cell" class="css-8vl804"><span class="css-1mjnzsq">패키지 형식</span></div><div role="cell" class="css-8vl804"><span class="css-1mjnzsq">외부 리소스</span></div><div role="cell" class="css-8vl804"><span class="css-1mjnzsq">CTA 통지</span></div><div role="cell" class="css-8vl804"><span class="css-1mjnzsq">mraid.js 취급</span></div></div><div role="row" class="css-1obf64m"><div role="cell" class="css-8vl804"><a target="_blank" rel="noreferrer noopener" class="css-1xtqh7" href="https://developers.facebook.com/docs/app-ads/formats/playable-ad/">Meta</a></div><div role="cell" class="css-8vl804"><span class="css-1mjnzsq">단일 HTML 또는 ZIP</span></div><div role="cell" class="css-8vl804"><span class="css-1mjnzsq">없음</span></div><div role="cell" class="css-8vl804"><span class="css-1mjnzsq">자체 함수 </span><code style="font-family:Consolas, Menlo, Monaco, source-code-pro, &quot;Courier New&quot;, monospace;font-size:0.9em;background-color:var(--adaptiveOpacity100);border:1px solid var(--adaptiveOpacity200);border-radius:3px"><span class="css-1mjnzsq">FbPlayableAd.onCTAClick()</span></code></div><div role="cell" class="css-8vl804"><span class="css-1mjnzsq">없음</span></div></div><div role="row" class="css-1obf64m"><div role="cell" class="css-8vl804"><a target="_blank" rel="noreferrer noopener" class="css-1xtqh7" href="https://support.google.com/google-ads/answer/9981650">Google 앱 캠페인</a></div><div role="cell" class="css-8vl804"><span class="css-1mjnzsq">ZIP</span></div><div role="cell" class="css-8vl804"><span class="css-1mjnzsq">지정된 일부만 허용</span></div><div role="cell" class="css-8vl804"><span class="css-1mjnzsq">자체 스크립트 </span><code style="font-family:Consolas, Menlo, Monaco, source-code-pro, &quot;Courier New&quot;, monospace;font-size:0.9em;background-color:var(--adaptiveOpacity100);border:1px solid var(--adaptiveOpacity200);border-radius:3px"><span class="css-1mjnzsq">ExitApi.exit()</span></code></div><div role="cell" class="css-8vl804"><span class="css-1mjnzsq">없음</span></div></div><div role="row" class="css-1obf64m"><div role="cell" class="css-8vl804"><a target="_blank" rel="noreferrer noopener" class="css-1xtqh7" href="https://help.moloco.com/hc/en-us/articles/24124525963799">Moloco</a></div><div role="cell" class="css-8vl804"><span class="css-1mjnzsq">단일 HTML</span></div><div role="cell" class="css-8vl804"><span class="css-1mjnzsq">전면 금지</span></div><div role="cell" class="css-8vl804"><span class="css-1mjnzsq">Meta 방식 함수</span></div><div role="cell" class="css-8vl804"><span class="css-1mjnzsq">포함 금지</span></div></div><div role="row" class="css-1obf64m"><div role="cell" class="css-8vl804"><a target="_blank" rel="noreferrer noopener" class="css-1xtqh7" href="https://support.applovin.com/en/growth/promoting-your-apps/welcome-to-applovin/creative-specs-and-guidelines">AppLovin</a></div><div role="cell" class="css-8vl804"><span class="css-1mjnzsq">단일 HTML, 에셋 인라인</span></div><div role="cell" class="css-8vl804"><span class="css-1mjnzsq">인라인 강제</span></div><div role="cell" class="css-8vl804"><code style="font-family:Consolas, Menlo, Monaco, source-code-pro, &quot;Courier New&quot;, monospace;font-size:0.9em;background-color:var(--adaptiveOpacity100);border:1px solid var(--adaptiveOpacity200);border-radius:3px"><span class="css-1mjnzsq">mraid.open()</span></code></div><div role="cell" class="css-8vl804"><span class="css-1mjnzsq">지원 필수</span></div></div></div></div><p id="3baa360d-33e3-8024-bf83-e943e1657145" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">클릭(CTA, Call To Action)은 설치로 이어지는 전환의 입구라, 소재 성과 지표 가운데 중요한 이벤트예요. 소재마다 클릭을 알리는 함수가 다르면 집계 입구가 갈라져서 일관성과 신뢰도가 함께 흔들려요. 그래서 모든 규약을 받는 만능 컨테이너 대신, 규약 하나를 선택해 깊게 지원하기로 했어요.</span></span></span></p><p id="3baa360d-33e3-800a-a763-f3be40cce33e" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">선택한 표준은 </span><a target="_blank" rel="noreferrer noopener" class="css-iynyr0" href="https://iabtechlab.com/standards/mobile-rich-media-ad-interface-definitions-mraid/">MRAID</a><span class="css-1kxrhf3">(Mobile Rich Media Ad Interface Definitions)입니다. 표의 Meta와 Google은 광고주 접점부터 소재를 그리는 SDK까지 수직 통합한 덕분에, 독자 규약을 강제할 수 있는 예외적인 사업자예요. Moloco처럼 Meta 규약을 따라가는 곳도 있어요. 그래도 그 밖의 인앱 광고 생태계에서는 IAB(Interactive Advertising Bureau) Tech Lab이 관리하는 MRAID가 표준으로 널리 쓰이고, AppLovin처럼 지원을 필수로 요구하는 네트워크가 있을 만큼 소재 수급에도 유리했어요.</span></span></span></p><p id="3baa360d-33e3-802c-8bd3-dd252d22cfb2" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">결정적이었던 건 공급 구조예요. 스펙상 소재는 브릿지 JS를 쓸 때 </span><code style="font-family:Consolas, Menlo, Monaco, source-code-pro, &quot;Courier New&quot;, monospace;font-size:0.9em;background-color:var(--adaptiveOpacity100);border:1px solid var(--adaptiveOpacity200);border-radius:3px"><span class="css-1mjnzsq">&lt;script src=&quot;mraid.js&quot;&gt;</span></code><span class="css-1kxrhf3"> 한 줄만 선언하고, </span><span class="css-q3ktjb">실제 브릿지 JS 파일은 광고를 띄우는 호스트가 공급해요.</span><span class="css-1kxrhf3"> 인터페이스는 표준으로 고정하되 구현은 호스트가 진화시킬 수 있다는 뜻이라 저희에게 꼭 맞았어요. 그림 1에서 본 브릿지 JS 주입이 바로 이 구조의 구현이에요. 스펙이 정한 파일 이름을 따라, </span><span class="css-182qzvh">지금부터는 브릿지 JS를 mraid.js라고 부를게요.</span></span></span></p><p id="3baa360d-33e3-805c-9737-d059738ad377" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">다만 </span><span class="css-q3ktjb">MRAID 전체를 구현하지는 않았어요.</span><span class="css-1kxrhf3"> 기준은 하나였어요. </span><span class="css-q3ktjb">&quot;실제로 서빙할 소재가 호출하는 API만 만든다.&quot;</span><span class="css-1kxrhf3"> 스펙에는 배너 확장(</span><code style="font-family:Consolas, Menlo, Monaco, source-code-pro, &quot;Courier New&quot;, monospace;font-size:0.9em;background-color:var(--adaptiveOpacity100);border:1px solid var(--adaptiveOpacity200);border-radius:3px"><span class="css-1mjnzsq">expand</span></code><span class="css-1kxrhf3">), 사진 저장(</span><code style="font-family:Consolas, Menlo, Monaco, source-code-pro, &quot;Courier New&quot;, monospace;font-size:0.9em;background-color:var(--adaptiveOpacity100);border:1px solid var(--adaptiveOpacity200);border-radius:3px"><span class="css-1mjnzsq">storePicture</span></code><span class="css-1kxrhf3">) 같은 기능이 가득한데, 기능마다 유지 비용이 함께 따라오기 때문이에요.</span></span></span></p><p id="3baa360d-33e3-8013-959c-f075147743cb" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">호출하는 소재가 없는 API는 구현하지 않되, 미지원 호출이 들어와도 크래시 대신 </span><code style="font-family:Consolas, Menlo, Monaco, source-code-pro, &quot;Courier New&quot;, monospace;font-size:0.9em;background-color:var(--adaptiveOpacity100);border:1px solid var(--adaptiveOpacity200);border-radius:3px"><span class="css-1mjnzsq">supports()</span></code><span class="css-1kxrhf3"> false와 </span><code style="font-family:Consolas, Menlo, Monaco, source-code-pro, &quot;Courier New&quot;, monospace;font-size:0.9em;background-color:var(--adaptiveOpacity100);border:1px solid var(--adaptiveOpacity200);border-radius:3px"><span class="css-1mjnzsq">error</span></code><span class="css-1kxrhf3"> 이벤트로 답하게 했어요. </span><a target="_blank" rel="noreferrer noopener" class="css-iynyr0" href="https://developers.google.com/ad-manager/mobile-ads-sdk/ios/mraid">Google 모바일 광고 SDK</a><span class="css-1kxrhf3">도 </span><code style="font-family:Consolas, Menlo, Monaco, source-code-pro, &quot;Courier New&quot;, monospace;font-size:0.9em;background-color:var(--adaptiveOpacity100);border:1px solid var(--adaptiveOpacity200);border-radius:3px"><span class="css-1mjnzsq">getLocation()</span></code><span class="css-1kxrhf3">은 항상 -1을 반환하고 </span><code style="font-family:Consolas, Menlo, Monaco, source-code-pro, &quot;Courier New&quot;, monospace;font-size:0.9em;background-color:var(--adaptiveOpacity100);border:1px solid var(--adaptiveOpacity200);border-radius:3px"><span class="css-1mjnzsq">storePicture</span></code><span class="css-1kxrhf3">는 제거했다고 명시할 만큼, 부분 구현은 업계의 일반적인 선택이에요.</span></span></span></p><p id="3baa360d-33e3-808b-b6de-f206578924ee" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">이 기준 덕분에 나중에 MRAID 3.0을 지원할 때도 스펙 전체를 따라갈 필요가 없었어요. 3.0 소재가 실제로 새로 호출하는 API는 두 개뿐이었고, 그 둘만 더해서 표준의 메이저 버전 업그레이드가 백여 줄로 끝났어요.</span></span></span></p><p id="3bba360d-33e3-8088-ad22-ef7dd515a9d9" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"></span></span></p><h2 id="3baa360d-33e3-8065-a1a2-fdecea8b76b6" class="css-1c1qox8"><span class="css-1r3ko7u"><span class="css-p4abj2"><span class="css-1kxrhf3">mraid.js의 네 가지 모듈</span></span></span></h2><p id="3baa360d-33e3-8090-ad09-ea7a941ebe2b" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">그렇게 좁힌 API 표면을 받치는 mraid.js의 내부를 모듈 네 개로 구성했어요.</span></span></span></p><aside id="3bba360d-33e3-809b-81f1-efc8c4a08693" class="css-nv7vyi"><div class="css-1vn47db"><ul class="css-uswsmm"><li id="3baa360d-33e3-8022-a01c-de53f3350cbd" class="css-1hwiibq"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-q3ktjb">본체(MraidSDK)</span><span class="css-1kxrhf3">: 공개 API 표면과 광고 상태를 관리해요.</span></span></span></li><li id="3baa360d-33e3-80b3-8a7e-f2fcb75556ec" class="css-1hwiibq"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-q3ktjb">송신부(NativeBridge)</span><span class="css-1kxrhf3">: 자바스크립트에서 네이티브로 커맨드를 보내요.</span></span></span></li><li id="3baa360d-33e3-8034-a8b7-eb425aeb408c" class="css-1hwiibq"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-q3ktjb">수신부(NativeInvokeHandler)</span><span class="css-1kxrhf3">: 네이티브의 호출을 이벤트별로 디스패치해요.</span></span></span></li><li id="3baa360d-33e3-8065-8194-d8572e73f7d5" class="css-1hwiibq"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-q3ktjb">이벤트 관리자(EventManager)</span><span class="css-1kxrhf3">: 소재가 등록한 리스너 구독을 관리해요. 핸들러 격리가 이 모듈의 일인데, 소재 신뢰 이야기와 함께 뒤에서 다뤄요.</span></span></span></li></ul></div></aside><figure id="3baa360d-33e3-80d7-849a-e7ca3a73b95b" class="css-of5acw"><img src="https://static.toss.im/ipd-tcs/toss_core/live/a958ecfb-1964-4d4e-853f-2a8610e2a639/image.png" alt="" class="css-1pgssrp"/></figure><p id="3baa360d-33e3-8096-bb35-f2b556241f57" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">mraid.js는 표준 API를 구현한 객체를 </span><code style="font-family:Consolas, Menlo, Monaco, source-code-pro, &quot;Courier New&quot;, monospace;font-size:0.9em;background-color:var(--adaptiveOpacity100);border:1px solid var(--adaptiveOpacity200);border-radius:3px"><span class="css-1mjnzsq">window.mraid</span></code><span class="css-1kxrhf3"> 전역으로 정의하고, 소재는 이 전역을 호출해요. 게임 엔진과 에셋을 걷어내면, 소재가 사용하는 표준 패턴은 이 형태예요.</span></span></span></p><p id="3baa360d-33e3-80ce-8e68-c9a19075a704" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">소재는 mraid.js의 존재를 전제하고, 시작 시점은 상태와 이벤트로 묻고, 밖으로 나가는 요청은 </span><code style="font-family:Consolas, Menlo, Monaco, source-code-pro, &quot;Courier New&quot;, monospace;font-size:0.9em;background-color:var(--adaptiveOpacity100);border:1px solid var(--adaptiveOpacity200);border-radius:3px"><span class="css-1mjnzsq">mraid.open()</span></code><span class="css-1kxrhf3"> 하나로 보내요. 이 계약을 호스트 쪽에서 받치는 게 네 모듈이에요.</span></span></span></p><p id="3baa360d-33e3-8099-a5f6-e121b431a408" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-q3ktjb">송신부</span><span class="css-1kxrhf3">는 자바스크립트에서 네이티브로 가는 방향의 규약이 전부예요.</span></span></span></p><p id="3baa360d-33e3-80b7-8d1e-d79ad15458b5" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">메시지는 </span><code style="font-family:Consolas, Menlo, Monaco, source-code-pro, &quot;Courier New&quot;, monospace;font-size:0.9em;background-color:var(--adaptiveOpacity100);border:1px solid var(--adaptiveOpacity200);border-radius:3px"><span class="css-1mjnzsq">{ command, payload }</span></code><span class="css-1kxrhf3"> JSON 문자열 하나로 통일하고 플랫폼 채널만 분기해요. 브릿지가 없으면 경고만 남기고 광고는 계속 동작해요. 제작사가 일반 브라우저에서 소재를 미리보기로 여는 상황까지가 정상 동작 범위이기 때문이에요.</span></span></span></p><p id="3baa360d-33e3-8043-a640-f7c8f852dead" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-q3ktjb">수신부</span><span class="css-1kxrhf3">는 네이티브의 호출을 </span><code style="font-family:Consolas, Menlo, Monaco, source-code-pro, &quot;Courier New&quot;, monospace;font-size:0.9em;background-color:var(--adaptiveOpacity100);border:1px solid var(--adaptiveOpacity200);border-radius:3px"><span class="css-1mjnzsq">mraid.nativeInvoke(event, payload)</span></code><span class="css-1kxrhf3">라는 단일 진입점으로 받아요. iOS와 Android가 문자 그대로 같은 규약을 호출하고, 이벤트와 페이로드 쌍은 TypeScript 판별 유니온(Discriminated Union) 타입으로 강제해요. 구현이 계약에서 어긋나면 컴파일 단계에서 드러나요.</span></span></span></p><p id="3baa360d-33e3-805e-a428-e92ba8d20e99" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-q3ktjb">본체</span><span class="css-1kxrhf3">에서 중요했던 결정은 상태 소유권이에요. 실제로 오가는 상태는 </span><code style="font-family:Consolas, Menlo, Monaco, source-code-pro, &quot;Courier New&quot;, monospace;font-size:0.9em;background-color:var(--adaptiveOpacity100);border:1px solid var(--adaptiveOpacity200);border-radius:3px"><span class="css-1mjnzsq">loading</span></code><span class="css-1kxrhf3"> → </span><code style="font-family:Consolas, Menlo, Monaco, source-code-pro, &quot;Courier New&quot;, monospace;font-size:0.9em;background-color:var(--adaptiveOpacity100);border:1px solid var(--adaptiveOpacity200);border-radius:3px"><span class="css-1mjnzsq">default</span></code><span class="css-1kxrhf3"> → </span><code style="font-family:Consolas, Menlo, Monaco, source-code-pro, &quot;Courier New&quot;, monospace;font-size:0.9em;background-color:var(--adaptiveOpacity100);border:1px solid var(--adaptiveOpacity200);border-radius:3px"><span class="css-1mjnzsq">hidden</span></code><span class="css-1kxrhf3">인데, mraid.js는 네이티브가 준 값을 검증 없이 반영하고 이벤트만 발사해요. 광고 뷰가 화면에 있는지, 사용자가 백그라운드로 나갔는지는 네이티브만 알기 때문이에요. 그림 2의 </span><code style="font-family:Consolas, Menlo, Monaco, source-code-pro, &quot;Courier New&quot;, monospace;font-size:0.9em;background-color:var(--adaptiveOpacity100);border:1px solid var(--adaptiveOpacity200);border-radius:3px"><span class="css-1mjnzsq">ready</span></code><span class="css-1kxrhf3">가 이 원칙의 출발점이에요. 네이티브가 초기 상태를 한 번에 밀어 넣으면, mraid.js는 상태를 default로 바꾸는 </span><code style="font-family:Consolas, Menlo, Monaco, source-code-pro, &quot;Courier New&quot;, monospace;font-size:0.9em;background-color:var(--adaptiveOpacity100);border:1px solid var(--adaptiveOpacity200);border-radius:3px"><span class="css-1mjnzsq">stateChange</span></code><span class="css-1kxrhf3">부터 준비 완료를 알리는 </span><code style="font-family:Consolas, Menlo, Monaco, source-code-pro, &quot;Courier New&quot;, monospace;font-size:0.9em;background-color:var(--adaptiveOpacity100);border:1px solid var(--adaptiveOpacity200);border-radius:3px"><span class="css-1mjnzsq">ready</span></code><span class="css-1kxrhf3">, 노출 상태를 알리는 </span><code style="font-family:Consolas, Menlo, Monaco, source-code-pro, &quot;Courier New&quot;, monospace;font-size:0.9em;background-color:var(--adaptiveOpacity100);border:1px solid var(--adaptiveOpacity200);border-radius:3px"><span class="css-1mjnzsq">viewableChange</span></code><span class="css-1kxrhf3">까지 연쇄로 발사해요.</span></span></span></p><p id="3baa360d-33e3-80e3-81e8-d21382dee62d" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><code style="font-family:Consolas, Menlo, Monaco, source-code-pro, &quot;Courier New&quot;, monospace;font-size:0.9em;background-color:var(--adaptiveOpacity100);border:1px solid var(--adaptiveOpacity200);border-radius:3px"><span class="css-1mjnzsq">mraid.close()</span></code><span class="css-1kxrhf3">도 같은 원칙 위에 있어요. 광고를 닫는 함수처럼 보이지만 실제로는 </span><span class="css-q3ktjb">요청일 뿐</span><span class="css-1kxrhf3">이라 제거 권한은 네이티브에 있고, 최소 시청 시간 전의 닫기 요청은 무시돼요. 거꾸로 네이티브가 광고 뷰를 걷어낼 때는 </span><code style="font-family:Consolas, Menlo, Monaco, source-code-pro, &quot;Courier New&quot;, monospace;font-size:0.9em;background-color:var(--adaptiveOpacity100);border:1px solid var(--adaptiveOpacity200);border-radius:3px"><span class="css-1mjnzsq">stateChange(&#x27;hidden&#x27;)</span></code><span class="css-1kxrhf3">이나 </span><code style="font-family:Consolas, Menlo, Monaco, source-code-pro, &quot;Courier New&quot;, monospace;font-size:0.9em;background-color:var(--adaptiveOpacity100);border:1px solid var(--adaptiveOpacity200);border-radius:3px"><span class="css-1mjnzsq">viewableChange(false)</span></code><span class="css-1kxrhf3">를 먼저 발사해서, 소재가 사운드를 멈추고 씬을 정지할 기회를 줘요.</span></span></span></p><p id="3bba360d-33e3-8065-bc8c-cb11160e1198" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"></span></span></p><h2 id="3baa360d-33e3-805b-8802-f834f2c8a37a" class="css-1c1qox8"><span class="css-1r3ko7u"><span class="css-p4abj2"><span class="css-1kxrhf3">MRAID 표준을 벗어나는 요구 다루기</span></span></span></h2><p id="3baa360d-33e3-8034-9ae2-f600019bf729" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">mraid.js가 브릿지로서 표준에 없는 요구까지 감당해야 할 때도 있어요. 대표가 엔드카드(End Card)예요. 게임이 끝난 뒤 설치를 한 번 더 권하는 마무리 화면인데, MRAID에는 없는 토스 지면만의 기능이에요. 광고주는 이 화면을 소재에서 쓰고 싶어 하고, 보여줄 타이밍은 게임 진행을 아는 소재만 알아요. 소재가 호스트에게 알릴 통로가 필요했어요.</span></span></span></p><figure id="3baa360d-33e3-8088-a687-eafbd77e8537" class="css-of5acw"><img src="https://static.toss.im/ipd-tcs/toss_core/live/1d03077e-46c6-4b23-8afc-f4337df848d6/image.png" alt="" class="css-1pgssrp"/></figure><p id="3baa360d-33e3-80af-b864-c52af2cb512d" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">이 통로를 표준 API인 </span><code style="font-family:Consolas, Menlo, Monaco, source-code-pro, &quot;Courier New&quot;, monospace;font-size:0.9em;background-color:var(--adaptiveOpacity100);border:1px solid var(--adaptiveOpacity200);border-radius:3px"><span class="css-1mjnzsq">window.mraid</span></code><span class="css-1kxrhf3">에 끼워 넣어도 될까요? 저희는 끼워 넣지 않았어요. mraid.js는 주입될 때 전역 객체를 둘 설치해요. 표준 API를 구현한 </span><code style="font-family:Consolas, Menlo, Monaco, source-code-pro, &quot;Courier New&quot;, monospace;font-size:0.9em;background-color:var(--adaptiveOpacity100);border:1px solid var(--adaptiveOpacity200);border-radius:3px"><span class="css-1mjnzsq">window.mraid</span></code><span class="css-1kxrhf3">, 그리고 표준 밖 기능을 담당하는 </span><code style="font-family:Consolas, Menlo, Monaco, source-code-pro, &quot;Courier New&quot;, monospace;font-size:0.9em;background-color:var(--adaptiveOpacity100);border:1px solid var(--adaptiveOpacity200);border-radius:3px"><span class="css-1mjnzsq">window.tossPlayableAds</span></code><span class="css-1kxrhf3">예요.</span></span></span></p><p id="3baa360d-33e3-801e-9a32-e1ad0c6c3163" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">표준을 지키는 쪽과 넘어서는 쪽을 분리해 두면, 어느 한쪽을 바꿔도 다른 쪽이 흔들리지 않아요.</span></span></span></p><figure id="3baa360d-33e3-80ec-820a-d6a37713f6b5" class="css-of5acw"><img src="https://static.toss.im/ipd-tcs/toss_core/live/6940f5f4-29ce-4e4b-8165-5e92ca0bac80/image.png" alt="" class="css-1pgssrp"/></figure><p id="3baa360d-33e3-80be-90a4-d292082b7f75" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">엔드카드를 쓰려는 소재는 플레이어블 광고가 종료되는 순간 </span><code style="font-family:Consolas, Menlo, Monaco, source-code-pro, &quot;Courier New&quot;, monospace;font-size:0.9em;background-color:var(--adaptiveOpacity100);border:1px solid var(--adaptiveOpacity200);border-radius:3px"><span class="css-1mjnzsq">tossPlayableAds</span></code><span class="css-1kxrhf3">를 호출하고, 요청을 받은 네이티브가 엔드카드 UI를 노출해요.</span></span></span></p><p id="3bba360d-33e3-8026-8bd3-fe9955548daf" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"></span></span></p><h2 id="3baa360d-33e3-8046-b466-e5415a61832f" class="css-1c1qox8"><span class="css-1r3ko7u"><span class="css-p4abj2"><span class="css-1kxrhf3">빈 화면을 거르는 rendered 신호</span></span></span></h2><p id="3baa360d-33e3-805a-be18-c167c3d51d3d" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">웹뷰에 빈 화면이 떠 있는 동안도 광고 노출일까요? 노출(Impression)은 돈과 직결되는 숫자라 답을 대충 정할 수 없었어요.</span></span></span></p><p id="3baa360d-33e3-80b7-ae3b-e677a5008138" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">캔버스 게임 엔진은 HTML 로드가 끝난 뒤에도 폰트와 이미지를 디코딩하고 씬을 조립해요. 첫 프레임까지 사용자에게 보이는 건 아무것도 없는 화면이라, 이때 노출 판정을 시작하면 빈 화면만 보고 이탈한 사용자도 노출로 집계돼요.</span></span></span></p><figure id="3baa360d-33e3-80cf-ab60-c080c9c35e4c" class="css-of5acw"><img src="https://static.toss.im/ipd-tcs/toss_core/live/3bcdca8e-e9d1-4e54-b992-bd072ff58a04/image.png" alt="" class="css-1pgssrp"/></figure><p id="3baa360d-33e3-80d5-b5eb-dc700c8f2976" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">그래서 표준 밖 신호를 하나 만들었어요. 그림 2에서 </span><code style="font-family:Consolas, Menlo, Monaco, source-code-pro, &quot;Courier New&quot;, monospace;font-size:0.9em;background-color:var(--adaptiveOpacity100);border:1px solid var(--adaptiveOpacity200);border-radius:3px"><span class="css-1mjnzsq">ready</span></code><span class="css-1kxrhf3">와 나란히 달리던 </span><code style="font-family:Consolas, Menlo, Monaco, source-code-pro, &quot;Courier New&quot;, monospace;font-size:0.9em;background-color:var(--adaptiveOpacity100);border:1px solid var(--adaptiveOpacity200);border-radius:3px"><span class="css-1mjnzsq">rendered</span></code><span class="css-1kxrhf3">, 웹이 &quot;실제로 그려졌다&quot;를 스스로 감지해 네이티브로 보고하는 신호예요.</span></span></span></p><p id="3baa360d-33e3-8091-80d8-ef244f4bc162" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">이 신호는 네이티브가 노출 판정을 시작하는 선행 조건으로 삼도록 설계했어요. 최소 시청 시간을 재는 스킵 타이머도 </span><code style="font-family:Consolas, Menlo, Monaco, source-code-pro, &quot;Courier New&quot;, monospace;font-size:0.9em;background-color:var(--adaptiveOpacity100);border:1px solid var(--adaptiveOpacity200);border-radius:3px"><span class="css-1mjnzsq">rendered</span></code><span class="css-1kxrhf3">를 받은 뒤에야 시작해요. 판정이 이르면 빈 화면을 보던 시간이 시청 시간에 포함되고, 늦으면 이미 게임 중인 사용자가 광고를 닫지 못해요.</span></span></span></p><p id="3baa360d-33e3-80b0-b803-d991111c2735" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">문제는 &quot;그려졌다&quot;를 한 번에 알려 주는 단일 신호가 브라우저에도 표준에도 없다는 것이었어요.</span></span></span></p><aside id="3bba360d-33e3-80a5-af4a-f90606d764cf" class="css-nv7vyi"><div class="css-1vn47db"><ul class="css-uswsmm"><li id="3baa360d-33e3-8081-a325-f8feabc2a55d" class="css-1hwiibq"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">로드 완료 콜백(iOS </span><code style="font-family:Consolas, Menlo, Monaco, source-code-pro, &quot;Courier New&quot;, monospace;font-size:0.9em;background-color:var(--adaptiveOpacity100);border:1px solid var(--adaptiveOpacity200);border-radius:3px"><span class="css-1mjnzsq">didFinish</span></code><span class="css-1kxrhf3">, Android </span><code style="font-family:Consolas, Menlo, Monaco, source-code-pro, &quot;Courier New&quot;, monospace;font-size:0.9em;background-color:var(--adaptiveOpacity100);border:1px solid var(--adaptiveOpacity200);border-radius:3px"><span class="css-1mjnzsq">onPageFinished</span></code><span class="css-1kxrhf3">)은 &quot;HTML 파싱이 끝났다&quot;까지만 보장해요. 화면은 아직 빈 캔버스예요.</span></span></span></li><li id="3baa360d-33e3-800d-a6c1-fcd95bdd570c" class="css-1hwiibq"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">브라우저 첫 페인트(</span><code style="font-family:Consolas, Menlo, Monaco, source-code-pro, &quot;Courier New&quot;, monospace;font-size:0.9em;background-color:var(--adaptiveOpacity100);border:1px solid var(--adaptiveOpacity200);border-radius:3px"><span class="css-1mjnzsq">PerformanceObserver</span></code><span class="css-1kxrhf3">의 paint 엔트리)는 게임 첫 프레임이 그려지기 전에도 발생할 수 있어요.</span></span></span></li><li id="3baa360d-33e3-8011-833d-cd5afa0169a3" class="css-1hwiibq"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">소재가 직접 알려 주는 방식은 모든 소재가 약속을 지켜 줘야만 성립해요. 약속을 모르는 소재가 언제든 들어와요.</span></span></span></li><li id="3baa360d-33e3-80d1-89b9-f9f9d95771bf" class="css-1hwiibq"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">MRAID 3.0의 </span><code style="font-family:Consolas, Menlo, Monaco, source-code-pro, &quot;Courier New&quot;, monospace;font-size:0.9em;background-color:var(--adaptiveOpacity100);border:1px solid var(--adaptiveOpacity200);border-radius:3px"><span class="css-1mjnzsq">exposureChange</span></code><span class="css-1kxrhf3">는 광고 컨테이너가 화면에 노출된 비율만 알려 줘요. 컨테이너 안이 실제로 그려졌는지는 답하지 않아요.</span></span></span></li></ul></div></aside><p id="3baa360d-33e3-80ec-9d9d-f83e1ad39e5b" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">그래서 각자 다른 것을 보장하는 신호 다섯 개를 순서대로 통과시키기로 했어요.</span></span></span></p><p id="3baa360d-33e3-803b-b0a5-c9cdc37a3f4c" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">다섯 신호가 각각 보장하는 범위는 이렇습니다.</span></span></span></p><aside id="3bba360d-33e3-80c0-b693-d34e6d45cb14" class="css-nv7vyi"><div class="css-1vn47db"><ol class="css-hokoge"><li id="3baa360d-33e3-805e-a985-c4d877f2a993" class="css-1hwiibq"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-q3ktjb">폰트(</span><code style="font-family:Consolas, Menlo, Monaco, source-code-pro, &quot;Courier New&quot;, monospace;font-size:0.9em;background-color:var(--adaptiveOpacity100);border:1px solid var(--adaptiveOpacity200);border-radius:3px"><span class="css-1y26ms3">document.fonts.ready</span></code><span class="css-q3ktjb">)</span><span class="css-1kxrhf3">: 웹폰트 로딩 완료를 보장해요. 웹폰트 없는 소재에서는 즉시 통과돼요.</span></span></span></li><li id="3baa360d-33e3-80b9-a829-e59920637ed3" class="css-1hwiibq"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-q3ktjb">이미지(</span><code style="font-family:Consolas, Menlo, Monaco, source-code-pro, &quot;Courier New&quot;, monospace;font-size:0.9em;background-color:var(--adaptiveOpacity100);border:1px solid var(--adaptiveOpacity200);border-radius:3px"><span class="css-1y26ms3">img.decode()</span></code><span class="css-q3ktjb">)</span><span class="css-1kxrhf3">: 문서에 붙은 이미지의 디코딩 완료를 보장해요. 게임이 자바스크립트로 직접 로드하는 에셋은 못 봐요.</span></span></span></li><li id="3baa360d-33e3-80c3-957e-fea874db2796" class="css-1hwiibq"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-q3ktjb">첫 페인트(paint 엔트리)</span><span class="css-1kxrhf3">: 브라우저가 최소 한 번 화면을 칠했다는 하한선이에요.</span></span></span></li><li id="3baa360d-33e3-80ce-8b89-fd81ac025f85" class="css-1hwiibq"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-q3ktjb">DOM 안정(</span><code style="font-family:Consolas, Menlo, Monaco, source-code-pro, &quot;Courier New&quot;, monospace;font-size:0.9em;background-color:var(--adaptiveOpacity100);border:1px solid var(--adaptiveOpacity200);border-radius:3px"><span class="css-1y26ms3">MutationObserver</span></code><span class="css-q3ktjb">)</span><span class="css-1kxrhf3">: 로딩 화면을 지우고 캔버스를 붙이는 초기 DOM 조작이 잦아들었다는 신호예요.</span></span></span></li><li id="3baa360d-33e3-8082-b827-cd60f5c0ee27" class="css-1hwiibq"><span class="css-1r3ko7u"><span class="css-1iisb9p"><code style="font-family:Consolas, Menlo, Monaco, source-code-pro, &quot;Courier New&quot;, monospace;font-size:0.9em;background-color:var(--adaptiveOpacity100);border:1px solid var(--adaptiveOpacity200);border-radius:3px"><span class="css-1y26ms3">requestAnimationFrame</span></code><span class="css-q3ktjb"> 2회</span><span class="css-1kxrhf3">: 실제 프레임 하나가 합성되어 화면에 반영됐다는 보장이에요.</span></span></span></li></ol></div></aside><p id="3baa360d-33e3-8053-a32e-fa5e13d70505" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">어느 하나도 충분조건이 아니지만 순서대로 통과시키면 &quot;빈 화면인데 노출로 셌다&quot;의 확률이 실용적인 수준까지 내려가요. 준비 신호(폰트, 이미지, 첫 페인트)를 2초 타임아웃과 경쟁시킨 건, 이미지 하나가 판정 전체를 붙잡아 두지 못하게 하기 위해서예요.</span></span></span></p><figure id="3baa360d-33e3-804c-8b95-ecae26414671" class="css-of5acw"><img src="https://static.toss.im/ipd-tcs/toss_core/live/6625750d-09b1-4730-ad0d-3a5f8a054b1d/image.png" alt="" class="css-1pgssrp"/></figure><p id="3baa360d-33e3-8024-ba64-df846a05b38f" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">이와 별도로 2.3초 안전망 타이머가 항상 돌아요. 감지가 다 실패해도 </span><code style="font-family:Consolas, Menlo, Monaco, source-code-pro, &quot;Courier New&quot;, monospace;font-size:0.9em;background-color:var(--adaptiveOpacity100);border:1px solid var(--adaptiveOpacity200);border-radius:3px"><span class="css-1mjnzsq">source: &#x27;timeout&#x27;</span></code><span class="css-1kxrhf3">으로 반드시 한 번 발사해요. 감지 로직의 실패가 광고 노출 자체를 막으면 안 되기 때문이에요.</span></span></span></p><p id="3baa360d-33e3-8048-8809-e58019326c6f" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><code style="font-family:Consolas, Menlo, Monaco, source-code-pro, &quot;Courier New&quot;, monospace;font-size:0.9em;background-color:var(--adaptiveOpacity100);border:1px solid var(--adaptiveOpacity200);border-radius:3px"><span class="css-1mjnzsq">rendered</span></code><span class="css-1kxrhf3">는 어떤 경로로 몇 ms 만에 감지됐는지를 </span><code style="font-family:Consolas, Menlo, Monaco, source-code-pro, &quot;Courier New&quot;, monospace;font-size:0.9em;background-color:var(--adaptiveOpacity100);border:1px solid var(--adaptiveOpacity200);border-radius:3px"><span class="css-1mjnzsq">{ source, latencyMs }</span></code><span class="css-1kxrhf3">에 실어 보내요. 타임아웃 폴백 비율이 올라가면 어느 신호가 약한지 추적할 수 있어요.</span></span></span></p><p id="3bba360d-33e3-8006-99c2-fa5721bfe671" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"></span></span></p><h2 id="3baa360d-33e3-8032-a8c1-f6f26386fe42" class="css-1c1qox8"><span class="css-1r3ko7u"><span class="css-p4abj2"><span class="css-1kxrhf3">소재를 믿지 않는 구조</span></span></span></h2><p id="3baa360d-33e3-803a-b151-ef77dfb2f7e4" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">소재는 외부에서 제작한 코드인데 저희 SDK와 </span><span class="css-q3ktjb">같은 window, 같은 자바스크립트 컨텍스트</span><span class="css-1kxrhf3">를 공유해요. iframe 같은 문서 경계도 없어요.</span></span></span></p><p id="3baa360d-33e3-806b-bacb-f3f2000eb5f3" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">그래서 등록 검사를 통과한 소재도 ‘검증된 코드’로 취급하지 않기로 했어요. 악의를 가정한다는 뜻이 아니에요. 문제 대부분은 검사가 걸러 주지 못하는 습관에서 나와요. 테스트용 코드가 남아 있거나, 다른 네트워크용 분기가 토스 지면에서 실행되는 식이에요.</span></span></span></p><p id="3baa360d-33e3-801f-b2b5-f2971b36fcd7" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-q3ktjb">방어 1. SDK를 얼려서 배포해요.</span><span class="css-1kxrhf3"> 소재는 여러 네트워크에 동시에 제출되기 때문에, 제작사가 로컬 테스트용 mraid 흉내 객체를 번들에 남겨 두는 일이 드물지 않아요. 그 객체가 </span><code style="font-family:Consolas, Menlo, Monaco, source-code-pro, &quot;Courier New&quot;, monospace;font-size:0.9em;background-color:var(--adaptiveOpacity100);border:1px solid var(--adaptiveOpacity200);border-radius:3px"><span class="css-1mjnzsq">window.mraid</span></code><span class="css-1kxrhf3">를 덮어쓰면 설치 버튼을 눌러도 아무 일도 일어나지 않고 클릭 로그도 남지 않아요. 화면이 깨지는 고장보다 알아채기 어려운 종류예요.</span></span></span></p><p id="3baa360d-33e3-803b-8cfd-c6bd8d865c7f" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><code style="font-family:Consolas, Menlo, Monaco, source-code-pro, &quot;Courier New&quot;, monospace;font-size:0.9em;background-color:var(--adaptiveOpacity100);border:1px solid var(--adaptiveOpacity200);border-radius:3px"><span class="css-1mjnzsq">Object.freeze</span></code><span class="css-1kxrhf3">는 메서드 교체를 막고, </span><code style="font-family:Consolas, Menlo, Monaco, source-code-pro, &quot;Courier New&quot;, monospace;font-size:0.9em;background-color:var(--adaptiveOpacity100);border:1px solid var(--adaptiveOpacity200);border-radius:3px"><span class="css-1mjnzsq">defineProperty</span></code><span class="css-1kxrhf3">는 </span><code style="font-family:Consolas, Menlo, Monaco, source-code-pro, &quot;Courier New&quot;, monospace;font-size:0.9em;background-color:var(--adaptiveOpacity100);border:1px solid var(--adaptiveOpacity200);border-radius:3px"><span class="css-1mjnzsq">window.mraid = {}</span></code><span class="css-1kxrhf3">처럼 통째로 갈아끼우는 경로를 막아요. 물론 </span><code style="font-family:Consolas, Menlo, Monaco, source-code-pro, &quot;Courier New&quot;, monospace;font-size:0.9em;background-color:var(--adaptiveOpacity100);border:1px solid var(--adaptiveOpacity200);border-radius:3px"><span class="css-1mjnzsq">Object.freeze</span></code><span class="css-1kxrhf3">는 얕아서 작정한 우회까지 막지는 못해요. 여기서 막으려는 건 침입이 아니라 사고이고, 실수로 생기는 덮어쓰기는 전부 이 층에서 일어나요.</span></span></span></p><p id="3baa360d-33e3-809d-bd41-d024470f1112" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-q3ktjb">방어 2. 핸들러를 하나씩 격리해요.</span><span class="css-1kxrhf3"> 앞서 소개한 이벤트 관리자(EventManager)의 역할이에요. 소재가 등록한 이벤트 핸들러는 언제든 예외를 던질 수 있어요. 목록을 그냥 순회하면 첫 예외에서 순회가 끊기고, 닫힐 때 사운드를 멈추는 핸들러가 못 돌면 광고는 사라졌는데 소리는 계속 나요.</span></span></span></p><p id="3baa360d-33e3-80af-867c-ff81fec366f8" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-q3ktjb">방어 3. 밖으로 나가는 경로는 </span><code style="font-family:Consolas, Menlo, Monaco, source-code-pro, &quot;Courier New&quot;, monospace;font-size:0.9em;background-color:var(--adaptiveOpacity100);border:1px solid var(--adaptiveOpacity200);border-radius:3px"><span class="css-1y26ms3">mraid.open()</span></code><span class="css-q3ktjb"> 하나예요.</span><span class="css-1kxrhf3"> 광고 웹뷰에서는 소재가 외부 리소스를 불러오는 네트워크 요청과 다른 페이지로의 이동을 전부 막아요. 소재가 넘긴 URL 인자도 신뢰하지 않아서, 실제 랜딩은 광고 서버가 내려준 URL 기준이에요. 클릭이라는 과금 이벤트의 집계가 </span><code style="font-family:Consolas, Menlo, Monaco, source-code-pro, &quot;Courier New&quot;, monospace;font-size:0.9em;background-color:var(--adaptiveOpacity100);border:1px solid var(--adaptiveOpacity200);border-radius:3px"><span class="css-1mjnzsq">mraid.open()</span></code><span class="css-1kxrhf3"> 한 곳으로 고정돼요.</span></span></span></p><p id="3bba360d-33e3-8076-bb8c-dc2e2786b27d" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"></span></span></p><h2 id="3baa360d-33e3-806f-b89a-ead588100b60" class="css-1c1qox8"><span class="css-1r3ko7u"><span class="css-p4abj2"><span class="css-1kxrhf3">서버가 내려주는 mraid.js</span></span></span></h2><p id="3baa360d-33e3-8018-8cc9-e07a79b366ad" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">여기까지가 웹뷰 안에서 벌어지는 일이에요. 남은 문제는 이 mraid.js를 어떻게 배포하느냐였어요.</span></span></span></p><p id="3baa360d-33e3-8026-879a-eced518e9e71" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">처음에는 네이티브 앱에 mraid.js의 CDN(Content Delivery Network) URL이 하드코딩되어 있었어요. 버그를 고친 파일을 오늘 CDN에 올려도, 앱에 박힌 URL을 바꾸려면 네이티브 릴리스, 앱 심사, 유저 업데이트를 전부 기다려야 했어요. 자바스크립트 한 줄에 몇 주가 걸릴 수 있는 구조였어요.</span></span></span></p><p id="3baa360d-33e3-8051-97a6-d895a52520db" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">mraid.js는 앱 안에서 실행되지만 앱과 함께 배포될 필요는 없는 코드예요. 스펙이 공급 권한을 호스트에게 준 이상, ‘어떤 mraid.js를 쓸지’도 광고 마크업처럼 서버가 내려주면 돼요.</span></span></span></p><figure id="3baa360d-33e3-807e-a32a-c5d503cc0703" class="css-of5acw"><img src="https://static.toss.im/ipd-tcs/toss_core/live/8ab53e76-3330-4d85-8d93-9183a59516ea/image.png" alt="" class="css-1pgssrp"/></figure><p id="3baa360d-33e3-807d-9ecc-cf3a14438a3a" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">서빙 경로는 스케줄러가 60초마다 갱신하는 인메모리 캐시만 읽어요. 운영자가 값을 바꾸면 </span><span class="css-q3ktjb">최대 60초 안에 모든 서버 인스턴스에 반영되고,</span><span class="css-1kxrhf3"> 유저 화면은 그다음 광고 요청부터 새 버전을 받아요. 스펙 자체가 mraid.js를 호스트 앱이 공급하는 파일로 정의하고 역할도 광고 웹뷰 안의 브릿지로 한정하기 때문에, 심사를 우회하는 장치는 아니에요.</span></span></span></p><p id="3baa360d-33e3-804d-983a-cc3e0e724e03" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">다만 앱 배포라는 관문이 사라진 만큼 다른 장치가 필요했어요. 배포된 파일은 수정하지 않고, 버전을 새긴 새 파일로만 교체하는 불변 파일 원칙을 뒀어요. URL을 바꾸는 내부 API는 변경자와 사유를 감사 로그로 남겨 &quot;누가, 언제, 왜 바꿨는가&quot;에 즉답해요.</span></span></span></p><p id="3bba360d-33e3-80bc-a4ee-e64240f96364" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"></span></span></p><h2 id="3baa360d-33e3-803b-9589-d931332f1e5d" class="css-1c1qox8"><span class="css-1r3ko7u"><span class="css-p4abj2"><span class="css-1kxrhf3">단일 버전 전략과 두 개의 계약</span></span></span></h2><p id="3baa360d-33e3-8047-95cb-f52f81935a86" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">서버가 URL을 내려주면 ‘어느 앱 버전에 어느 mraid.js를 줄 것인가’가 따라와요. 버전 구간별 매핑 테이블은 그리다 멈췄어요. 핫픽스마다 모든 분기를 패치해야 하고, 설정 실수 하나가 특정 버전 구간의 광고를 전부 멈출 수 있었어요. 그래서 반대로 </span><span class="css-q3ktjb">모든 앱 버전에 단일 최신 mraid.js를 내려주기로 했어요. ‘</span><span class="css-1kxrhf3">버전 조합을 어떻게 관리할 것인가’라는 운영 문제가 ‘무엇을 절대 바꾸지 않을 것인가’라는 계약 문제로 바뀐 거예요.</span></span></span></p><p id="3baa360d-33e3-8023-8cc6-f39c298c915b" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-q3ktjb">계약 1. 공개 API에는 추가만 허용해요.</span><span class="css-1kxrhf3"> 지금 이 순간에도 </span><code style="font-family:Consolas, Menlo, Monaco, source-code-pro, &quot;Courier New&quot;, monospace;font-size:0.9em;background-color:var(--adaptiveOpacity100);border:1px solid var(--adaptiveOpacity200);border-radius:3px"><span class="css-1mjnzsq">mraid.getState()</span></code><span class="css-1kxrhf3">를 호출하는 소재가 유저 화면에서 돌고 있어요. 메서드를 없애거나 이름을 바꾸면 하루아침에 깨져요.</span></span></span></p><figure id="3baa360d-33e3-8026-9a8f-dc36dc554bbe" class="css-of5acw"><img src="https://static.toss.im/ipd-tcs/toss_core/live/1bc8a50e-391c-4887-92aa-a4ddd11263e4/image.png" alt="" class="css-1pgssrp"/></figure><p id="3baa360d-33e3-80ba-bf53-f4e5f0d3e36a" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-q3ktjb">계약 2. 네이티브는 모르는 커맨드를 조용히 무시해요.</span><span class="css-1kxrhf3"> 새 커맨드를 아직 모르는 구버전 앱 위에서 새 mraid.js가 실행되는 일은 단일 버전 전략의 일상이에요. 크래시하면 전략이 무너지고, 조용히 무시하면 아무 일도 일어나지 않아요. 알 수 없는 값은 버린다는 규약은 </span><a target="_blank" rel="noreferrer noopener" class="css-iynyr0" href="https://github.com/InteractiveAdvertisingBureau/openrtb2.x/blob/main/2.6.md#26---versioning-behavior">IAB의 OpenRTB 스펙도 권장하는 패턴</a><span class="css-1kxrhf3">이에요.</span></span></span></p><p id="3baa360d-33e3-802a-b735-f36485e7aa76" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">계약은 프로세스로도 지켜요. 저희 레포는 changeset(버전 영향도를 적는 변경 선언 파일) 없는 소스 변경이 push에서 차단되고, 하위 호환을 깨는 major 변경이 감지되면 네이티브 팀 합의가 필요하다는 코멘트가 PR(Pull Request)에 자동으로 달려요. major란 계약을 깨겠다는 선언이라 팀 간 합의의 문제이기 때문이에요.</span></span></span></p><p id="3baa360d-33e3-80a0-b6ac-e1d8bdbd3116" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">얻은 것은 속도와 단순함이에요. 몇 주가 걸리던 배포가 서버 값 하나를 바꾸는 일이 됐고, 버전 조합 관리도 사라졌어요. 대신 빠른 배포는 잘못된 배포도 빠르게 퍼뜨려요. 앞서 본 불변 파일 원칙과 감사 로그가 그 대비책이에요. </span><span class="css-q3ktjb">빠르게 배포할 수 있게 만든 건 서버 값 하나지만, 마음 놓고 배포하게 만든 건 계약 두 개였어요.</span></span></span></p><p id="3bba360d-33e3-80f2-9e18-dacccb168824" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"></span></span></p><h2 id="3baa360d-33e3-8037-a749-fc1116d438df" class="css-1c1qox8"><span class="css-1r3ko7u"><span class="css-p4abj2"><span class="css-1kxrhf3">마무리</span></span></span></h2><p id="3baa360d-33e3-80b3-8924-d6fda44dda2e" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">게임 광고를 늘리려면 게임 광고주가 쓰는 소재 유형부터 받아야 했고, 그래서 MRAID 기반 플레이어블 SDK를 만들었어요. 실제 소재가 호출하는 범위만 구현했고, 빈 화면은 5중 신호로 걸렀어요. 소재는 믿지 않는 구조로 받고, 배포는 서버 값 하나와 계약 두 개로 돌아가요.</span></span></span></p><p id="3baa360d-33e3-805c-942e-faeb52f1b589" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">기억해 둘 만한 것들을 추리면 아래와 같아요.</span></span></span></p><aside id="3bba360d-33e3-8058-8155-d8e85d54bc12" class="css-nv7vyi"><div class="css-1vn47db"><ul class="css-uswsmm"><li id="3baa360d-33e3-80aa-96bb-d4359dd78459" class="css-1hwiibq"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">표준을 도입할 때 전부 구현할 필요는 없어요. 실제로 호출되는 표면부터 좁게 시작하면, 메이저 버전 업그레이드도 작은 일이 돼요.</span></span></span></li><li id="3baa360d-33e3-8011-9287-daff11b6657b" class="css-1hwiibq"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">로드 완료는 렌더 완료가 아니에요. 단일 API가 없는 곳에서는 신호 여러 개를 조합해 판정을 만들고, 그 판정 과정까지 관측 가능하게 남겨요.</span></span></span></li><li id="3baa360d-33e3-809e-bd42-f8b236e5aef5" class="css-1hwiibq"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">서드파티 자바스크립트와 같은 window를 공유해야 한다면, 보호는 검사하는 코드보다 얼린 객체와 격리 구조로 만드는 편이 확실해요.</span></span></span></li><li id="3baa360d-33e3-80fb-8988-c032ec5741da" class="css-1hwiibq"><span class="css-1r3ko7u"><span class="css-1iisb9p"><span class="css-1kxrhf3">앱이나 남의 페이지에 임베드되는 스크립트라면, 배포 인프라만큼이나 하위 호환 계약도 일찍 설계할수록 운영이 단순해져요.</span></span></span></li></ul></div></aside><p id="3bba360d-33e3-80b0-863d-d6a9c9fcb5dc" class="css-14on8x8"><span class="css-1r3ko7u"><span class="css-1iisb9p"></span></span></p></div><div id="tds-mobile-portal-container"></div>]]></content:encoded>
        </item>
    </channel>
</rss>