{
    "version": "https://jsonfeed.org/version/1",
    "title": "UMOSONE Tech Blog",
    "home_page_url": "https://tech.umosone.ai",
    "description": "UMOSONE 개발팀의 기술 이야기",
    "items": [
        {
            "id": "https://tech.umosone.ai/compound-triage",
            "content_html": "<p>AI와 함께 개발하며 발견한 시행착오는 대화가 끝나면 사라지기 쉽습니다. WMS 개발팀은 이를 후보로 모으고 사람이 검토해 다음 작업에서 다시 쓰는 규칙으로 남기고 있습니다. 공용 Drawer에서 발견한 문제 하나가 실제 팀 규칙으로 이어진 과정도 함께 소개합니다.</p>\n<p><img decoding=\"async\" loading=\"lazy\" alt=\"개발 중 발견한 후보를 사람이 검토하고, 채택한 후보를 팀 규칙과 자동 검증으로 연결하는 흐름\" src=\"https://tech.umosone.ai/assets/images/compound-triage-hero-v3-2dd7fd48bbe6fcdde9c355f3df15a531.png\" width=\"1659\" height=\"948\" class=\"img_ev3q\"></p>\n<p>안녕하세요, UMOSONE WMS(Warehouse Management System) 개발팀의 Front Engineer 최영호입니다. 저희 FE팀은 WMS Frontend를 개발하면서 AI 에이전트와 함께 겪은 시행착오를 다음 작업에서도 꺼내 쓸 방법을 고민해왔습니다. 이번 글에서는 그 고민 끝에 도입해 운영하고 있는 컴파운드 트리아지를 소개하려고 합니다. 먼저 저희 팀이 만드는 제품과 이런 과정이 필요했던 배경부터 짧게 이야기해보겠습니다.</p>\n<h2 class=\"anchor anchorTargetStickyNavbar_Vzrq\" id=\"capora-wms\">Capora WMS<a href=\"https://tech.umosone.ai/compound-triage#capora-wms\" class=\"hash-link\" aria-label=\"Capora WMS에 대한 직접 링크\" title=\"Capora WMS에 대한 직접 링크\" translate=\"no\">​</a></h2>\n<p>Capora WMS는 입고, 재고, 피킹, 검수, 출고까지 물류센터에서 일어나는 작업을 관리하는 시스템입니다. 여러 화주와 센터의 재고 흐름을 실시간으로 확인하고 운영자와 현장 작업자가 각 단계의 업무를 이어가도록 돕습니다.</p>\n<p>WMS에는 서로 닮은 화면과 작업 흐름이 많습니다. 한 기능에서 발견한 문제나 구현 규칙이 다른 기능에서도 반복되곤 합니다. 이런 경험을 한 번의 작업에만 남기지 않고 팀의 자산으로 쌓을 방법을 고민하게 된 배경도 여기에 있습니다.</p>\n<p><img decoding=\"async\" loading=\"lazy\" alt=\"입고부터 출고까지 이어지는 Capora WMS의 물류센터 업무 흐름\" src=\"data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxMjAwIiBoZWlnaHQ9IjUyMCIgdmlld0JveD0iMCAwIDEyMDAgNTIwIiByb2xlPSJpbWciIGFyaWEtbGFiZWxsZWRieT0idGl0bGUgZGVzYyI+CiAgPHRpdGxlIGlkPSJ0aXRsZSI+Q2Fwb3JhIFdNU+qwgCDsnofripQg66y866WY7IS87YSwIOyXheustDwvdGl0bGU+CiAgPGRlc2MgaWQ9ImRlc2MiPuyeheqzoCwg7J6s6rOgIOq0gOumrCwg7ZS87YK5LCDqsoDsiJgsIOy2nOqzoOuhnCDsnbTslrTsp4DripQg66y866WY7IS87YSwIOyekeyXheqzvCDsl6zrn6wg7ZmU66m07JeQ7IScIOuwmOuzteuQmOuKlCDqt5zsuZnsnYQg67O07Jes7KSA64ukLjwvZGVzYz4KICA8ZGVmcz4KICAgIDxmaWx0ZXIgaWQ9InNoYWRvdyIgeD0iLTIwJSIgeT0iLTIwJSIgd2lkdGg9IjE0MCUiIGhlaWdodD0iMTQwJSI+PGZlRHJvcFNoYWRvdyBkeD0iMCIgZHk9IjgiIHN0ZERldmlhdGlvbj0iMTIiIGZsb29kLWNvbG9yPSIjMzEyZTgxIiBmbG9vZC1vcGFjaXR5PSIuMTAiLz48L2ZpbHRlcj4KICAgIDxsaW5lYXJHcmFkaWVudCBpZD0iYmFuZCIgeDE9IjAiIHgyPSIxIj48c3RvcCBzdG9wLWNvbG9yPSIjZWVmMmZmIi8+PHN0b3Agb2Zmc2V0PSIxIiBzdG9wLWNvbG9yPSIjZjVmM2ZmIi8+PC9saW5lYXJHcmFkaWVudD4KICA8L2RlZnM+CiAgPHJlY3Qgd2lkdGg9IjEyMDAiIGhlaWdodD0iNTIwIiByeD0iMzAiIGZpbGw9IiNmOGZhZmMiLz4KICA8dGV4dCB4PSI2MCIgeT0iNzAiIGZvbnQtZmFtaWx5PSJQcmV0ZW5kYXJkLCBBcHBsZSBTRCBHb3RoaWMgTmVvLCBzYW5zLXNlcmlmIiBmb250LXNpemU9IjM0IiBmb250LXdlaWdodD0iNzAwIiBmaWxsPSIjMTExODI3Ij5DYXBvcmEgV01T6rCAIOyeh+uKlCDrrLzrpZjshLzthLAg7JeF66y0PC90ZXh0PgogIDx0ZXh0IHg9IjYwIiB5PSIxMDgiIGZvbnQtZmFtaWx5PSJQcmV0ZW5kYXJkLCBBcHBsZSBTRCBHb3RoaWMgTmVvLCBzYW5zLXNlcmlmIiBmb250LXNpemU9IjE5IiBmaWxsPSIjNjQ3NDhiIj7rrLzrpZjsnZgg6rCBIOuLqOqzhOqwgCDtlZjrgpjsnZgg7Iuc7Iqk7YWcIOyViOyXkOyEnCDri6TsnYwg7J6R7JeF7Jy866GcIOydtOyWtOynkeuLiOuLpC48L3RleHQ+CgogIDxnIGZvbnQtZmFtaWx5PSJQcmV0ZW5kYXJkLCBBcHBsZSBTRCBHb3RoaWMgTmVvLCBzYW5zLXNlcmlmIiBmaWx0ZXI9InVybCgjc2hhZG93KSI+CiAgICA8ZyB0cmFuc2Zvcm09InRyYW5zbGF0ZSg1NSAxNjUpIj48cmVjdCB3aWR0aD0iMTkwIiBoZWlnaHQ9IjE2MCIgcng9IjIyIiBmaWxsPSIjZmZmIiBzdHJva2U9IiNjN2QyZmUiLz48Y2lyY2xlIGN4PSI5NSIgY3k9IjUyIiByPSIzMCIgZmlsbD0iI2UwZTdmZiIvPjxwYXRoIGQ9Ik03NSA1Nmg0ME04MiA0NWwtNyAxMSA3IDExTTEwOCA0NWw3IDExLTcgMTEiIGZpbGw9Im5vbmUiIHN0cm9rZT0iIzRmNDZlNSIgc3Ryb2tlLXdpZHRoPSI1IiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiLz48dGV4dCB4PSI5NSIgeT0iMTE4IiB0ZXh0LWFuY2hvcj0ibWlkZGxlIiBmb250LXNpemU9IjI1IiBmb250LXdlaWdodD0iNzAwIiBmaWxsPSIjMWUxYjRiIj7snoXqs6A8L3RleHQ+PHRleHQgeD0iOTUiIHk9IjE0NCIgdGV4dC1hbmNob3I9Im1pZGRsZSIgZm9udC1zaXplPSIxNSIgZmlsbD0iIzY0NzQ4YiI+7IOB7ZKI7J2EIOyEvO2EsOyXkCDrk7HroZ08L3RleHQ+PC9nPgogICAgPGcgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoMjgwIDE2NSkiPjxyZWN0IHdpZHRoPSIxOTAiIGhlaWdodD0iMTYwIiByeD0iMjIiIGZpbGw9IiNmZmYiIHN0cm9rZT0iI2JhZTZmZCIvPjxjaXJjbGUgY3g9Ijk1IiBjeT0iNTIiIHI9IjMwIiBmaWxsPSIjZTBmMmZlIi8+PHBhdGggZD0iTTc5IDQzaDMydjI4SDc5ek03OSA1MmgzMk05NSA0M3YyOCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjMDI4NGM3IiBzdHJva2Utd2lkdGg9IjQiLz48dGV4dCB4PSI5NSIgeT0iMTE4IiB0ZXh0LWFuY2hvcj0ibWlkZGxlIiBmb250LXNpemU9IjI1IiBmb250LXdlaWdodD0iNzAwIiBmaWxsPSIjMGM0YTZlIj7snqzqs6Ag6rSA66asPC90ZXh0Pjx0ZXh0IHg9Ijk1IiB5PSIxNDQiIHRleHQtYW5jaG9yPSJtaWRkbGUiIGZvbnQtc2l6ZT0iMTUiIGZpbGw9IiM2NDc0OGIiPuyImOufieqzvCDsnITsuZjrpbwg7LaU7KCBPC90ZXh0PjwvZz4KICAgIDxnIHRyYW5zZm9ybT0idHJhbnNsYXRlKDUwNSAxNjUpIj48cmVjdCB3aWR0aD0iMTkwIiBoZWlnaHQ9IjE2MCIgcng9IjIyIiBmaWxsPSIjZmZmIiBzdHJva2U9IiNiYmY3ZDAiLz48Y2lyY2xlIGN4PSI5NSIgY3k9IjUyIiByPSIzMCIgZmlsbD0iI2RjZmNlNyIvPjxwYXRoIGQ9Ik04MCA0MWgzMHYyNUg4MHpNODQgNjZ2OE0xMDYgNjZ2OE03NSA3NGg0MCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjMTZhMzRhIiBzdHJva2Utd2lkdGg9IjQiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIvPjx0ZXh0IHg9Ijk1IiB5PSIxMTgiIHRleHQtYW5jaG9yPSJtaWRkbGUiIGZvbnQtc2l6ZT0iMjUiIGZvbnQtd2VpZ2h0PSI3MDAiIGZpbGw9IiMxNDUzMmQiPu2UvO2CuTwvdGV4dD48dGV4dCB4PSI5NSIgeT0iMTQ0IiB0ZXh0LWFuY2hvcj0ibWlkZGxlIiBmb250LXNpemU9IjE1IiBmaWxsPSIjNjQ3NDhiIj7so7zrrLgg7IOB7ZKI7J2EIOywvuyVhCDsnbTrj5k8L3RleHQ+PC9nPgogICAgPGcgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoNzMwIDE2NSkiPjxyZWN0IHdpZHRoPSIxOTAiIGhlaWdodD0iMTYwIiByeD0iMjIiIGZpbGw9IiNmZmYiIHN0cm9rZT0iI2ZkZTY4YSIvPjxjaXJjbGUgY3g9Ijk1IiBjeT0iNTIiIHI9IjMwIiBmaWxsPSIjZmVmM2M3Ii8+PHBhdGggZD0iTTc4IDUzbDEyIDEyIDIzLTI3IiBmaWxsPSJub25lIiBzdHJva2U9IiNkOTc3MDYiIHN0cm9rZS13aWR0aD0iNiIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIi8+PHRleHQgeD0iOTUiIHk9IjExOCIgdGV4dC1hbmNob3I9Im1pZGRsZSIgZm9udC1zaXplPSIyNSIgZm9udC13ZWlnaHQ9IjcwMCIgZmlsbD0iIzc4MzUwZiI+6rKA7IiYPC90ZXh0Pjx0ZXh0IHg9Ijk1IiB5PSIxNDQiIHRleHQtYW5jaG9yPSJtaWRkbGUiIGZvbnQtc2l6ZT0iMTUiIGZpbGw9IiM2NDc0OGIiPuyImOufieqzvCDsg4Htg5zrpbwg7ZmV7J24PC90ZXh0PjwvZz4KICAgIDxnIHRyYW5zZm9ybT0idHJhbnNsYXRlKDk1NSAxNjUpIj48cmVjdCB3aWR0aD0iMTkwIiBoZWlnaHQ9IjE2MCIgcng9IjIyIiBmaWxsPSIjZmZmIiBzdHJva2U9IiNkZGQ2ZmUiLz48Y2lyY2xlIGN4PSI5NSIgY3k9IjUyIiByPSIzMCIgZmlsbD0iI2VkZTlmZSIvPjxwYXRoIGQ9Ik03NiA0N2gyNXYyM0g3NnpNMTAxIDUzaDExbDggOHY5aC0xOXpNODMgNzBhNSA1IDAgMSAwIDAgLjFNMTEwIDcwYTUgNSAwIDEgMCAwIC4xIiBmaWxsPSJub25lIiBzdHJva2U9IiM3YzNhZWQiIHN0cm9rZS13aWR0aD0iNCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIvPjx0ZXh0IHg9Ijk1IiB5PSIxMTgiIHRleHQtYW5jaG9yPSJtaWRkbGUiIGZvbnQtc2l6ZT0iMjUiIGZvbnQtd2VpZ2h0PSI3MDAiIGZpbGw9IiM0YzFkOTUiPuy2nOqzoDwvdGV4dD48dGV4dCB4PSI5NSIgeT0iMTQ0IiB0ZXh0LWFuY2hvcj0ibWlkZGxlIiBmb250LXNpemU9IjE1IiBmaWxsPSIjNjQ3NDhiIj7rsLDshqHtlaAg7IOB7ZKI7J2EIOuCtOuztOuDhDwvdGV4dD48L2c+CiAgPC9nPgogIDxnIGZpbGw9Im5vbmUiIHN0cm9rZT0iIzk0YTNiOCIgc3Ryb2tlLXdpZHRoPSIzIiBzdHJva2UtbGluZWNhcD0icm91bmQiPjxwYXRoIGQ9Ik0yNTAgMjQ1aDI0Ii8+PHBhdGggZD0iTTQ3NSAyNDVoMjQiLz48cGF0aCBkPSJNNzAwIDI0NWgyNCIvPjxwYXRoIGQ9Ik05MjUgMjQ1aDI0Ii8+PC9nPgogIDxnIGZpbGw9IiM5NGEzYjgiPjxwYXRoIGQ9Ik0yNzQgMjQ1bC05LTZ2MTJ6Ii8+PHBhdGggZD0iTTQ5OSAyNDVsLTktNnYxMnoiLz48cGF0aCBkPSJNNzI0IDI0NWwtOS02djEyeiIvPjxwYXRoIGQ9Ik05NDkgMjQ1bC05LTZ2MTJ6Ii8+PC9nPgogIDxyZWN0IHg9IjU1IiB5PSIzNzAiIHdpZHRoPSIxMDkwIiBoZWlnaHQ9Ijk2IiByeD0iMjQiIGZpbGw9InVybCgjYmFuZCkiIHN0cm9rZT0iI2M0YjVmZCIvPgogIDxjaXJjbGUgY3g9IjEwMCIgY3k9IjQxOCIgcj0iMjEiIGZpbGw9IiM3YzNhZWQiLz48cGF0aCBkPSJNOTAgNDE4aDIwTTEwMCA0MDh2MjAiIHN0cm9rZT0iI2ZmZiIgc3Ryb2tlLXdpZHRoPSI0IiBzdHJva2UtbGluZWNhcD0icm91bmQiLz4KICA8dGV4dCB4PSIxNDAiIHk9IjQxMCIgZm9udC1mYW1pbHk9IlByZXRlbmRhcmQsIEFwcGxlIFNEIEdvdGhpYyBOZW8sIHNhbnMtc2VyaWYiIGZvbnQtc2l6ZT0iMjIiIGZvbnQtd2VpZ2h0PSI3MDAiIGZpbGw9IiMzMTJlODEiPu2VnCDquLDriqXsl5DshJwg67Cc6rKs7ZWcIOq3nOy5meydtCDri6Trpbgg7ZmU66m07JeQ7ISc64+EIOuwmOuzteuQqeuLiOuLpC48L3RleHQ+CiAgPHRleHQgeD0iMTQwIiB5PSI0NDAiIGZvbnQtZmFtaWx5PSJQcmV0ZW5kYXJkLCBBcHBsZSBTRCBHb3RoaWMgTmVvLCBzYW5zLXNlcmlmIiBmb250LXNpemU9IjE3IiBmaWxsPSIjNWIyMWI2Ij7sg4Htg5wg7LKY66asIMK3IOyhsO2ajCDsobDqsbQgwrcg6rO17JqpIOy7tO2PrOuEjO2KuCDsgqzsmqnrspUgwrcg7YWM7Iqk7Yq4IOuwqeuylTwvdGV4dD4KPC9zdmc+Cg==\" width=\"1200\" height=\"520\" class=\"img_ev3q\"></p>\n<p><em>입고부터 출고까지 이어지는 과정에서는 상태 처리, 조회 조건, 공용 컴포넌트 사용법 같은 규칙이 여러 화면에서 반복됩니다.</em></p>\n<p>AI 에이전트와 개발하다 보면 대화 안에 여러 경험이 쌓입니다. 어떤 API부터 확인했는지, 처음 고른 구현을 왜 버렸는지, 실제 문제를 잡아낸 테스트는 무엇이었는지 같은 기록입니다.</p>\n<p>문제는 작업이 끝난 뒤입니다. 새 대화를 열면 이 맥락도 사라집니다. 다음 에이전트는 같은 코드를 다시 살피고 때로는 앞서 겪은 실수를 되풀이합니다. 기능은 완성됐지만 팀의 개발 방식에는 별다른 변화가 남지 않습니다.</p>\n<p>저희는 이 경험을 다음 작업에서도 꺼내 쓰고 싶었습니다. 그래서 재사용할 만한 교훈을 후보로 모으고 사람이 검토해 팀 규칙으로 남기는 과정을 만들었습니다. 이 과정을 <strong>컴파운드 트리아지(Compound Triage)</strong>라고 부릅니다.</p>\n<p>이름은 낯설어도 원리는 간단합니다. AI가 찾은 교훈을 바로 규칙으로 삼지 않고 다른 작업에서도 필요한지 사람이 다시 판단합니다.</p>\n<p><img decoding=\"async\" loading=\"lazy\" alt=\"AI와의 작업에서 발견한 교훈이 Raw Ticket과 사람의 검토, 문서 변경 계획을 거쳐 다음 작업에 다시 사용되는 전체 흐름\" src=\"data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxMjAwIiBoZWlnaHQ9IjY1MCIgdmlld0JveD0iMCAwIDEyMDAgNjUwIiByb2xlPSJpbWciIGFyaWEtbGFiZWxsZWRieT0idGl0bGUgZGVzYyI+CiAgPHRpdGxlIGlkPSJ0aXRsZSI+7Lu07YyM7Jq065OcIO2KuOumrOyVhOyngCDsoITssrQg7Z2Q66aEPC90aXRsZT4KICA8ZGVzYyBpZD0iZGVzYyI+QUnsmYAg6rCc67Cc7ZWY66mwIOuwnOqyrO2VnCDqtZDtm4jsnbQgUmF3IFRpY2tldOycvOuhnCDrgqjqs6Ag7IKs656M7J2YIO2KuOumrOyVhOyngOyZgCDrrLjshJwg67OA6rK9IOqzhO2ajeydhCDqsbDss5Ag7YyAIOusuOyEnOyZgCDsnpDrj5kg6rKA7Kad7JeQIOuwmOyYgeuQnCDrkqQg64uk7J2MIOyekeyXheyXkOyEnCDri6Tsi5wg7IKs7Jqp65CY64qUIO2dkOumhOydtOuLpC48L2Rlc2M+CiAgPGRlZnM+PGZpbHRlciBpZD0icyIgeD0iLTIwJSIgeT0iLTIwJSIgd2lkdGg9IjE0MCUiIGhlaWdodD0iMTQwJSI+PGZlRHJvcFNoYWRvdyBkeD0iMCIgZHk9IjciIHN0ZERldmlhdGlvbj0iMTAiIGZsb29kLWNvbG9yPSIjMzEyZTgxIiBmbG9vZC1vcGFjaXR5PSIuMDkiLz48L2ZpbHRlcj48bWFya2VyIGlkPSJhIiB2aWV3Qm94PSIwIDAgMTAgMTAiIHJlZlg9IjgiIHJlZlk9IjUiIG1hcmtlcldpZHRoPSI3IiBtYXJrZXJIZWlnaHQ9IjciIG9yaWVudD0iYXV0by1zdGFydC1yZXZlcnNlIj48cGF0aCBkPSJNMCAwbDEwIDUtMTAgNXoiIGZpbGw9IiM4YjVjZjYiLz48L21hcmtlcj48L2RlZnM+CiAgPHJlY3Qgd2lkdGg9IjEyMDAiIGhlaWdodD0iNjUwIiByeD0iMzAiIGZpbGw9IiNmOGZhZmMiLz4KICA8dGV4dCB4PSI2MCIgeT0iNjgiIGZvbnQtZmFtaWx5PSJQcmV0ZW5kYXJkLCBBcHBsZSBTRCBHb3RoaWMgTmVvLCBzYW5zLXNlcmlmIiBmb250LXNpemU9IjM0IiBmb250LXdlaWdodD0iNzAwIiBmaWxsPSIjMTExODI3Ij7tlZwg67KI7J2YIOyekeyXheydhCDri6TsnYwg7J6R7JeF7J2YIOy2nOuwnOygkOycvOuhnDwvdGV4dD4KICA8dGV4dCB4PSI2MCIgeT0iMTA2IiBmb250LWZhbWlseT0iUHJldGVuZGFyZCwgQXBwbGUgU0QgR290aGljIE5lbywgc2Fucy1zZXJpZiIgZm9udC1zaXplPSIxOSIgZmlsbD0iIzY0NzQ4YiI+7ZuE67O066W8IOyekOuPmeycvOuhnCDrqqjsnLzrkJgsIO2MgCDqt5zsuZnsnLzroZwg7LGE7YOd7ZWY64qUIOqysOygleydgCDsgqzrnozsnbQg64K066a964uI64ukLjwvdGV4dD4KICA8ZyBmb250LWZhbWlseT0iUHJldGVuZGFyZCwgQXBwbGUgU0QgR290aGljIE5lbywgc2Fucy1zZXJpZiIgZmlsdGVyPSJ1cmwoI3MpIj4KICAgIDxnIHRyYW5zZm9ybT0idHJhbnNsYXRlKDU1IDE2NSkiPjxyZWN0IHdpZHRoPSIxOTAiIGhlaWdodD0iMTY1IiByeD0iMjIiIGZpbGw9IiNmZmYiIHN0cm9rZT0iI2M3ZDJmZSIvPjxjaXJjbGUgY3g9Ijk1IiBjeT0iNTAiIHI9IjI3IiBmaWxsPSIjZTBlN2ZmIi8+PHBhdGggZD0iTTc5IDM4aDMydjIxSDk0bC05IDd2LTdoLTZ6IiBmaWxsPSJub25lIiBzdHJva2U9IiM0ZjQ2ZTUiIHN0cm9rZS13aWR0aD0iMy41IiBzdHJva2UtbGluZWpvaW49InJvdW5kIi8+PGNpcmNsZSBjeD0iODgiIGN5PSI0OSIgcj0iMiIgZmlsbD0iIzRmNDZlNSIvPjxjaXJjbGUgY3g9Ijk1IiBjeT0iNDkiIHI9IjIiIGZpbGw9IiM0ZjQ2ZTUiLz48Y2lyY2xlIGN4PSIxMDIiIGN5PSI0OSIgcj0iMiIgZmlsbD0iIzRmNDZlNSIvPjx0ZXh0IHg9Ijk1IiB5PSIxMDUiIHRleHQtYW5jaG9yPSJtaWRkbGUiIGZvbnQtc2l6ZT0iMjEiIGZvbnQtd2VpZ2h0PSI3MDAiIGZpbGw9IiMzMTJlODEiPkFJ7JmAIO2VqOq7mCDsnpHsl4U8L3RleHQ+PHRleHQgeD0iOTUiIHk9IjEzNSIgdGV4dC1hbmNob3I9Im1pZGRsZSIgZm9udC1zaXplPSIxNSIgZmlsbD0iIzY0NzQ4YiI+7Iuc7ZaJ7LCp7Jik7JmAIO2VtOqysCDqt7zqsbA8L3RleHQ+PC9nPgogICAgPGcgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoMjgwIDE2NSkiPjxyZWN0IHdpZHRoPSIxOTAiIGhlaWdodD0iMTY1IiByeD0iMjIiIGZpbGw9IiNmZmYiIHN0cm9rZT0iI2JhZTZmZCIvPjxjaXJjbGUgY3g9Ijk1IiBjeT0iNTAiIHI9IjI3IiBmaWxsPSIjZTBmMmZlIi8+PHBhdGggZD0iTTgyIDM0aDI0bDggOHYyOUg4MnpNMTA2IDM0djEwaDgiIGZpbGw9Im5vbmUiIHN0cm9rZT0iIzAyODRjNyIgc3Ryb2tlLXdpZHRoPSI0Ii8+PHRleHQgeD0iOTUiIHk9IjEwNSIgdGV4dC1hbmNob3I9Im1pZGRsZSIgZm9udC1zaXplPSIyMSIgZm9udC13ZWlnaHQ9IjcwMCIgZmlsbD0iIzBjNGE2ZSI+UmF3IFRpY2tldDwvdGV4dD48dGV4dCB4PSI5NSIgeT0iMTM1IiB0ZXh0LWFuY2hvcj0ibWlkZGxlIiBmb250LXNpemU9IjE1IiBmaWxsPSIjNjQ3NDhiIj7qt5zsuZnsnbQg7JWE64uMIOqygO2GoCDtm4Trs7Q8L3RleHQ+PC9nPgogICAgPGcgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoNTA1IDE0NSkiPjxyZWN0IHdpZHRoPSIxOTAiIGhlaWdodD0iMjA1IiByeD0iMjIiIGZpbGw9IiNmZmYiIHN0cm9rZT0iI2RkZDZmZSIgc3Ryb2tlLXdpZHRoPSIyIi8+PGNpcmNsZSBjeD0iOTUiIGN5PSI0OCIgcj0iMjciIGZpbGw9IiNlZGU5ZmUiLz48cGF0aCBkPSJNODMgNDhsOCA4IDE3LTE5IiBmaWxsPSJub25lIiBzdHJva2U9IiM3YzNhZWQiIHN0cm9rZS13aWR0aD0iNSIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIi8+PHRleHQgeD0iOTUiIHk9IjEwMCIgdGV4dC1hbmNob3I9Im1pZGRsZSIgZm9udC1zaXplPSIyMSIgZm9udC13ZWlnaHQ9IjcwMCIgZmlsbD0iIzRjMWQ5NSI+7IKs656M7J20IO2KuOumrOyVhOyngDwvdGV4dD48ZyBmb250LXNpemU9IjE0IiBmb250LXdlaWdodD0iNjAwIj48cmVjdCB4PSIxNiIgeT0iMTIyIiB3aWR0aD0iNDgiIGhlaWdodD0iMzAiIHJ4PSIxNSIgZmlsbD0iI2RjZmNlNyIvPjx0ZXh0IHg9IjQwIiB5PSIxNDIiIHRleHQtYW5jaG9yPSJtaWRkbGUiIGZpbGw9IiMxNjY1MzQiPuyKueqyqTwvdGV4dD48cmVjdCB4PSI3MSIgeT0iMTIyIiB3aWR0aD0iNDgiIGhlaWdodD0iMzAiIHJ4PSIxNSIgZmlsbD0iI2ZlZjNjNyIvPjx0ZXh0IHg9Ijk1IiB5PSIxNDIiIHRleHQtYW5jaG9yPSJtaWRkbGUiIGZpbGw9IiM5MjQwMGUiPuuztOulmDwvdGV4dD48cmVjdCB4PSIxMjYiIHk9IjEyMiIgd2lkdGg9IjQ4IiBoZWlnaHQ9IjMwIiByeD0iMTUiIGZpbGw9IiNmZWUyZTIiLz48dGV4dCB4PSIxNTAiIHk9IjE0MiIgdGV4dC1hbmNob3I9Im1pZGRsZSIgZmlsbD0iIzk5MWIxYiI+7Y+Q6riwPC90ZXh0PjwvZz48dGV4dCB4PSI5NSIgeT0iMTgwIiB0ZXh0LWFuY2hvcj0ibWlkZGxlIiBmb250LXNpemU9IjE0IiBmaWxsPSIjNjQ3NDhiIj7rr7jqsoDthqDripQgcmF37JeQIOuCqOq5gDwvdGV4dD48L2c+CiAgICA8ZyB0cmFuc2Zvcm09InRyYW5zbGF0ZSg3MzAgMTY1KSI+PHJlY3Qgd2lkdGg9IjE5MCIgaGVpZ2h0PSIxNjUiIHJ4PSIyMiIgZmlsbD0iI2ZmZiIgc3Ryb2tlPSIjYmJmN2QwIi8+PGNpcmNsZSBjeD0iOTUiIGN5PSI1MCIgcj0iMjciIGZpbGw9IiNkY2ZjZTciLz48cGF0aCBkPSJNODAgMzRoMzB2MzZIODB6TTg2IDQ1aDE4TTg2IDU0aDE4TTg2IDYzaDEyIiBmaWxsPSJub25lIiBzdHJva2U9IiMxNmEzNGEiIHN0cm9rZS13aWR0aD0iMy41IiBzdHJva2UtbGluZWNhcD0icm91bmQiLz48dGV4dCB4PSI5NSIgeT0iMTA1IiB0ZXh0LWFuY2hvcj0ibWlkZGxlIiBmb250LXNpemU9IjIxIiBmb250LXdlaWdodD0iNzAwIiBmaWxsPSIjMTQ1MzJkIj7rrLjshJwg67OA6rK9IOqzhO2ajTwvdGV4dD48dGV4dCB4PSI5NSIgeT0iMTM1IiB0ZXh0LWFuY2hvcj0ibWlkZGxlIiBmb250LXNpemU9IjE1IiBmaWxsPSIjNjQ3NDhiIj7sirnqsqkg7ZuE67O066W8IOyjvOygnOuzhOuhnCDrrLbsnYw8L3RleHQ+PC9nPgogICAgPGcgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoOTU1IDE0NSkiPjxyZWN0IHdpZHRoPSIxOTAiIGhlaWdodD0iMjA1IiByeD0iMjIiIGZpbGw9IiNmZmYiIHN0cm9rZT0iI2E1ZjNmYyIvPjxjaXJjbGUgY3g9Ijk1IiBjeT0iNDgiIHI9IjI3IiBmaWxsPSIjY2ZmYWZlIi8+PHBhdGggZD0iTTc4IDM3aDE2djI1SDc4ek05OCA0MGgxNHYyMkg5OE04MiA0Nmg4TTgyIDUyaDgiIGZpbGw9Im5vbmUiIHN0cm9rZT0iIzA4OTFiMiIgc3Ryb2tlLXdpZHRoPSIzIi8+PHRleHQgeD0iOTUiIHk9IjEwMCIgdGV4dC1hbmNob3I9Im1pZGRsZSIgZm9udC1zaXplPSIyMSIgZm9udC13ZWlnaHQ9IjcwMCIgZmlsbD0iIzE2NGU2MyI+7YyA7J2YIOyepeq4sCDquLDslrU8L3RleHQ+PHJlY3QgeD0iMTYiIHk9IjEyMiIgd2lkdGg9IjY2IiBoZWlnaHQ9IjM2IiByeD0iMTAiIGZpbGw9IiNlZWYyZmYiLz48dGV4dCB4PSI0OSIgeT0iMTQ2IiB0ZXh0LWFuY2hvcj0ibWlkZGxlIiBmb250LXNpemU9IjE0IiBmb250LXdlaWdodD0iNzAwIiBmaWxsPSIjNDMzOGNhIj7tmITsnqw6IOusuOyEnDwvdGV4dD48cmVjdCB4PSI4OCIgeT0iMTIyIiB3aWR0aD0iODYiIGhlaWdodD0iMzYiIHJ4PSIxMCIgZmlsbD0iI2VjZmRmNSIvPjx0ZXh0IHg9IjEzMSIgeT0iMTQ2IiB0ZXh0LWFuY2hvcj0ibWlkZGxlIiBmb250LXNpemU9IjEzIiBmb250LXdlaWdodD0iNzAwIiBmaWxsPSIjMDQ3ODU3Ij7ri6TsnYw6IOyekOuPmSDqsoDspp08L3RleHQ+PHRleHQgeD0iOTUiIHk9IjE4MyIgdGV4dC1hbmNob3I9Im1pZGRsZSIgZm9udC1zaXplPSIxNCIgZmlsbD0iIzY0NzQ4YiI+6rOE7ZqNIOqygO2GoCDtm4Qg67CY7JiBPC90ZXh0PjwvZz4KICA8L2c+CiAgPGcgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjOGI1Y2Y2IiBzdHJva2Utd2lkdGg9IjMiIG1hcmtlci1lbmQ9InVybCgjYSkiPjxwYXRoIGQ9Ik0yNDggMjQ3aDI1Ii8+PHBhdGggZD0iTTQ3MyAyNDdoMjUiLz48cGF0aCBkPSJNNjk4IDI0N2gyNSIvPjxwYXRoIGQ9Ik05MjMgMjQ3aDI1Ii8+PC9nPgogIDxyZWN0IHg9IjIxMCIgeT0iNDI4IiB3aWR0aD0iNzgwIiBoZWlnaHQ9IjExNiIgcng9IjI1IiBmaWxsPSIjMTExODI3Ii8+CiAgPGNpcmNsZSBjeD0iMjY4IiBjeT0iNDg2IiByPSIyNyIgZmlsbD0iIzMxMmU4MSIvPjxyZWN0IHg9IjI1NSIgeT0iNDc1IiB3aWR0aD0iMjYiIGhlaWdodD0iMjEiIHJ4PSI1IiBmaWxsPSJub25lIiBzdHJva2U9IiNjNGI1ZmQiIHN0cm9rZS13aWR0aD0iMyIvPjxwYXRoIGQ9Ik0yNjggNDY5djZNMjYwIDQ4M2gyTTI3NCA0ODNoMk0yNjAgNDkxaDE2IiBmaWxsPSJub25lIiBzdHJva2U9IiNjNGI1ZmQiIHN0cm9rZS13aWR0aD0iMyIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIi8+CiAgPHRleHQgeD0iMzE1IiB5PSI0NzUiIGZvbnQtZmFtaWx5PSJQcmV0ZW5kYXJkLCBBcHBsZSBTRCBHb3RoaWMgTmVvLCBzYW5zLXNlcmlmIiBmb250LXNpemU9IjIyIiBmb250LXdlaWdodD0iNzAwIiBmaWxsPSIjZmZmIj7ri6TsnYwg7J6R7JeF7J2YIOyXkOydtOyghO2KuOqwgCDqtIDroKgg66y47ISc7JmAIOqygOymnSDqt5zsuZnsnYQg64uk7IucIOyCrOyaqTwvdGV4dD4KICA8dGV4dCB4PSIzMTUiIHk9IjUwOCIgZm9udC1mYW1pbHk9IlByZXRlbmRhcmQsIEFwcGxlIFNEIEdvdGhpYyBOZW8sIHNhbnMtc2VyaWYiIGZvbnQtc2l6ZT0iMTYiIGZpbGw9IiNjYmQ1ZTEiPuydtOyghCDrjIDtmZTrpbwg6riw7Ja17ZWY7KeAIOuqu+2VtOuPhCwg7KCA7J6l7IaM7JeQIOuCqOydgCDtjIDsnZgg6rec7LmZ7J2AIOydveydhCDsiJgg7J6I7Iq164uI64ukLjwvdGV4dD4KICA8cGF0aCBkPSJNOTkwIDQ4Nmg3NGM0MiAwIDQ4LTI5IDQ4LTU4VjM4MiIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjOGI1Y2Y2IiBzdHJva2Utd2lkdGg9IjMiIHN0cm9rZS1kYXNoYXJyYXk9IjggOCIgbWFya2VyLWVuZD0idXJsKCNhKSIvPgogIDxwYXRoIGQ9Ik0yMTAgNDg2aC03MGMtMzYgMC01Mi0yNC01Mi01OFYzODIiIGZpbGw9Im5vbmUiIHN0cm9rZT0iIzhiNWNmNiIgc3Ryb2tlLXdpZHRoPSIzIiBzdHJva2UtZGFzaGFycmF5PSI4IDgiIG1hcmtlci1lbmQ9InVybCgjYSkiLz4KICA8dGV4dCB4PSI2MDAiIHk9IjYwMiIgdGV4dC1hbmNob3I9Im1pZGRsZSIgZm9udC1mYW1pbHk9IlByZXRlbmRhcmQsIEFwcGxlIFNEIEdvdGhpYyBOZW8sIHNhbnMtc2VyaWYiIGZvbnQtc2l6ZT0iMTciIGZvbnQtd2VpZ2h0PSI2MDAiIGZpbGw9IiM3YzNhZWQiPuyekeyXhSDihpIg7ZuE67O0IOKGkiDsgqzrnozsnZgg6rKw7KCVIOKGkiDtjIAg6rec7LmZIOKGkiDrjZQg64KY7J2AIOuLpOydjCDsnpHsl4U8L3RleHQ+Cjwvc3ZnPgo=\" width=\"1200\" height=\"650\" class=\"img_ev3q\"></p>\n<p><em>자동화는 후보를 모으는 일을 돕지만 어떤 후보를 팀 규칙으로 채택할지는 사람이 결정합니다.</em></p>\n<h2 class=\"anchor anchorTargetStickyNavbar_Vzrq\" id=\"작업-경험도-다음-작업에-쌓일-수-있을까\">작업 경험도 다음 작업에 쌓일 수 있을까<a href=\"https://tech.umosone.ai/compound-triage#%EC%9E%91%EC%97%85-%EA%B2%BD%ED%97%98%EB%8F%84-%EB%8B%A4%EC%9D%8C-%EC%9E%91%EC%97%85%EC%97%90-%EC%8C%93%EC%9D%BC-%EC%88%98-%EC%9E%88%EC%9D%84%EA%B9%8C\" class=\"hash-link\" aria-label=\"작업 경험도 다음 작업에 쌓일 수 있을까에 대한 직접 링크\" title=\"작업 경험도 다음 작업에 쌓일 수 있을까에 대한 직접 링크\" translate=\"no\">​</a></h2>\n<p>이 문제를 고민하던 중 Every의 <a href=\"https://every.to/chain-of-thought/compound-engineering-how-every-codes-with-agents\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"\">Compound Engineering</a>을 읽었습니다. 이 글에서 말하는 ‘compound’는 작업 하나가 다음 작업을 더 쉽게 만드는 방향으로 경험을 쌓는다는 뜻입니다. 기능만 완성하고 끝내는 대신, 그 과정에서 발견한 패턴과 도구, 문서를 함께 개선하면 시간이 지날수록 개발 환경도 나아진다는 생각입니다.</p>\n<p>처음에는 작업이 끝날 때마다 배운 점을 문서에 추가하면 되겠다고 생각했습니다. 그런데 곧 문제가 드러났습니다. 한 번 효과가 있었던 방법이 다른 작업에도 통한다는 보장은 없습니다. 특정 버그에서만 필요한 우회 방법일 수도 있고 이미 문서에 있는 규칙을 AI가 다시 발견했을 수도 있습니다. AI가 그럴듯하게 정리했다는 이유만으로 팀 규칙이 되면 문서는 서로 충돌하는 지침으로 금방 뒤엉킵니다.</p>\n<p>그래서 저희는 ‘배운 점을 모으는 일’과 ‘팀 규칙으로 채택하는 일’을 나눴습니다.</p>\n<h2 class=\"anchor anchorTargetStickyNavbar_Vzrq\" id=\"먼저-규칙이-아니라-후보로-남깁니다\">먼저 규칙이 아니라 후보로 남깁니다<a href=\"https://tech.umosone.ai/compound-triage#%EB%A8%BC%EC%A0%80-%EA%B7%9C%EC%B9%99%EC%9D%B4-%EC%95%84%EB%8B%88%EB%9D%BC-%ED%9B%84%EB%B3%B4%EB%A1%9C-%EB%82%A8%EA%B9%81%EB%8B%88%EB%8B%A4\" class=\"hash-link\" aria-label=\"먼저 규칙이 아니라 후보로 남깁니다에 대한 직접 링크\" title=\"먼저 규칙이 아니라 후보로 남깁니다에 대한 직접 링크\" translate=\"no\">​</a></h2>\n<p>이 과정은 에이전트의 <strong>Stop hook</strong>에서 시작합니다. Stop hook은 에이전트가 작업을 마칠 때 자동으로 실행되는 후처리입니다. WMS Frontend에서는 이 훅이 <code>compound-capture-stop</code> 스킬을 호출합니다.</p>\n<p><code>compound-capture-stop</code>은 이번 작업에서 얻은 경험이 프로젝트 문서의 규칙으로 남길 만큼 재사용 가능한지 따져봅니다. 다른 작업에서도 반복될 근거가 있는지, 어느 문서의 어느 위치에 반영할지, 어디까지 적용하고 어디에는 적용하지 않을지 확인합니다. 이 질문에 구체적으로 답할 수 없다면 티켓을 만들지 않습니다.</p>\n<p>기준을 통과해도 문서를 바로 고치지는 않습니다. 사람이 검토할 후보 카드를 먼저 만듭니다. 내부에서는 이를 <strong>Raw Ticket</strong>이라고 부릅니다. 작업이 끝날 때마다 반드시 생기는 것은 아닙니다. 남길 만한 교훈이 없다면 0건으로 끝납니다.</p>\n<p>실제로 공용 Drawer를 고치던 작업에서 이 과정이 어떻게 시작됐는지 살펴보겠습니다. Drawer는 현재 목록을 그대로 둔 채 그 위에 상세 정보를 펼치는 UI입니다. 제품 목록에서 제품 링크를 누르면 상세 Drawer가 열립니다. 필요한 내용을 확인하고 닫으면 다시 원래 목록으로 돌아갑니다.</p>\n<p>당시 공용 Drawer를 닫는 로직을 고치면서 <code>ProductDetail</code>이 열릴 수 있는 <code>overlayId</code>를 하나의 목록에 모아 관리하는 방식을 먼저 택했습니다.</p>\n<div class=\"language-ts codeBlockContainer_Ckt0 theme-code-block\" style=\"--prism-color:#393A34;--prism-background-color:#f6f8fa\"><div class=\"codeBlockTitle_OeMC\">products/config/constant.ts</div><div class=\"codeBlockContent_QJqH\"><pre tabindex=\"0\" class=\"prism-code language-ts codeBlock_bY9V thin-scrollbar\" style=\"color:#393A34;background-color:#f6f8fa\"><code class=\"codeBlockLines_e6Vv\"><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token keyword\" style=\"color:#00009f\">export</span><span class=\"token plain\"> </span><span class=\"token keyword\" style=\"color:#00009f\">const</span><span class=\"token plain\"> </span><span class=\"token constant\" style=\"color:#36acaa\">PRODUCT_DETAIL_OVERLAY_IDS</span><span class=\"token plain\"> </span><span class=\"token operator\" style=\"color:#393A34\">=</span><span class=\"token plain\"> </span><span class=\"token punctuation\" style=\"color:#393A34\">[</span><span class=\"token plain\"></span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token plain\">  </span><span class=\"token constant\" style=\"color:#36acaa\">PRODUCT_DETAIL_OVERLAY_ID</span><span class=\"token punctuation\" style=\"color:#393A34\">,</span><span class=\"token plain\"></span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token plain\">  </span><span class=\"token constant\" style=\"color:#36acaa\">PRODUCT_DETAIL_OVERLAY_ID_WITH_CURSOR</span><span class=\"token punctuation\" style=\"color:#393A34\">,</span><span class=\"token plain\"></span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token plain\">  </span><span class=\"token constant\" style=\"color:#36acaa\">SAFETY_STOCK_PRODUCT_DETAIL_OVERLAY_ID</span><span class=\"token punctuation\" style=\"color:#393A34\">,</span><span class=\"token plain\"></span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token plain\"></span><span class=\"token punctuation\" style=\"color:#393A34\">]</span><span class=\"token plain\"> </span><span class=\"token keyword\" style=\"color:#00009f\">as</span><span class=\"token plain\"> </span><span class=\"token keyword\" style=\"color:#00009f\">const</span><span class=\"token punctuation\" style=\"color:#393A34\">;</span><br></div></code></pre></div></div>\n<p>목록에 모든 ID가 들어 있다면 문제없이 동작합니다. 문제는 그 ‘모든 ID’를 사람이 빠짐없이 찾아야 한다는 데 있었습니다. 첫 조사에서는 <code>openBottomDrawer</code> 호출부를 검색해 제품 상세 Drawer가 두 ID로 열린다고 판단했습니다. 하지만 안전 재고 화면은 <code>openBottomDrawer</code>를 직접 호출하지 않았습니다. <code>DeepLinkDrawerConfig.renderDetail</code>에서 같은 <code>ProductDetail</code>을 렌더링하면서 자체 <code>overlayId</code>를 사용하고 있었습니다.</p>\n<div class=\"language-tsx codeBlockContainer_Ckt0 theme-code-block\" style=\"--prism-color:#393A34;--prism-background-color:#f6f8fa\"><div class=\"codeBlockTitle_OeMC\">config/safety-stock/config/drawer-config.tsx</div><div class=\"codeBlockContent_QJqH\"><pre tabindex=\"0\" class=\"prism-code language-tsx codeBlock_bY9V thin-scrollbar\" style=\"color:#393A34;background-color:#f6f8fa\"><code class=\"codeBlockLines_e6Vv\"><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token keyword\" style=\"color:#00009f\">export</span><span class=\"token plain\"> </span><span class=\"token keyword\" style=\"color:#00009f\">const</span><span class=\"token plain\"> </span><span class=\"token constant\" style=\"color:#36acaa\">SAFETY_STOCK_PRODUCT_DRAWER_CONFIG</span><span class=\"token plain\"> </span><span class=\"token operator\" style=\"color:#393A34\">=</span><span class=\"token plain\"> </span><span class=\"token punctuation\" style=\"color:#393A34\">{</span><span class=\"token plain\"></span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token plain\">  overlayId</span><span class=\"token operator\" style=\"color:#393A34\">:</span><span class=\"token plain\"> </span><span class=\"token constant\" style=\"color:#36acaa\">SAFETY_STOCK_PRODUCT_DETAIL_OVERLAY_ID</span><span class=\"token punctuation\" style=\"color:#393A34\">,</span><span class=\"token plain\"></span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token plain\">  </span><span class=\"token function-variable function\" style=\"color:#d73a49\">renderDetail</span><span class=\"token operator\" style=\"color:#393A34\">:</span><span class=\"token plain\"> </span><span class=\"token punctuation\" style=\"color:#393A34\">(</span><span class=\"token punctuation\" style=\"color:#393A34\">)</span><span class=\"token plain\"> </span><span class=\"token arrow operator\" style=\"color:#393A34\">=&gt;</span><span class=\"token plain\"> </span><span class=\"token tag punctuation\" style=\"color:#393A34\">&lt;</span><span class=\"token tag class-name\" style=\"color:#00009f\">ProductDetail</span><span class=\"token tag\" style=\"color:#00009f\"> </span><span class=\"token tag punctuation\" style=\"color:#393A34\">/&gt;</span><span class=\"token punctuation\" style=\"color:#393A34\">,</span><span class=\"token plain\"></span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token plain\"></span><span class=\"token punctuation\" style=\"color:#393A34\">}</span><span class=\"token punctuation\" style=\"color:#393A34\">;</span><br></div></code></pre></div></div>\n<p>이 경로를 놓친 채 변경을 마쳤다면 제품 관리 화면에서는 Drawer가 정상적으로 닫히지만, 안전 재고 화면에서 연 Drawer만 닫히지 않는 문제가 생길 수 있었습니다. 공용 컴포넌트를 여러 화면에서 재사용할수록 중앙 목록을 사람이 관리하는 방식은 누락에 취약했습니다.</p>\n<p>저희는 목록을 더 꼼꼼하게 관리하는 대신, ID를 관리할 책임을 Drawer를 사용하는 화면으로 옮겼습니다. 공용 Drawer가 자신을 사용하는 모든 화면의 ID를 기억하지 않고, Drawer를 여는 화면이 실제로 사용한 ID를 필수 prop으로 넘기도록 바꿨습니다.</p>\n<div class=\"language-diff codeBlockContainer_Ckt0 theme-code-block\" style=\"--prism-color:#393A34;--prism-background-color:#f6f8fa\"><div class=\"codeBlockContent_QJqH\"><pre tabindex=\"0\" class=\"prism-code language-diff codeBlock_bY9V thin-scrollbar\" style=\"color:#393A34;background-color:#f6f8fa\"><code class=\"codeBlockLines_e6Vv\"><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token deleted-sign deleted prefix deleted\" style=\"color:#d73a49\">-</span><span class=\"token deleted-sign deleted line\" style=\"color:#d73a49\"> const handleCloseDrawer = useCloseDetailDrawer({</span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token deleted-sign deleted line\" style=\"color:#d73a49\"></span><span class=\"token deleted-sign deleted prefix deleted\" style=\"color:#d73a49\">-</span><span class=\"token deleted-sign deleted line\" style=\"color:#d73a49\">   overlayIds: PRODUCT_DETAIL_OVERLAY_IDS,</span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token deleted-sign deleted line\" style=\"color:#d73a49\"></span><span class=\"token deleted-sign deleted prefix deleted\" style=\"color:#d73a49\">-</span><span class=\"token deleted-sign deleted line\" style=\"color:#d73a49\">   searchKey: 'selectedProductId',</span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token deleted-sign deleted line\" style=\"color:#d73a49\"></span><span class=\"token deleted-sign deleted prefix deleted\" style=\"color:#d73a49\">-</span><span class=\"token deleted-sign deleted line\" style=\"color:#d73a49\"> });</span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token deleted-sign deleted line\" style=\"color:#d73a49\"></span><span class=\"token plain\"></span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token plain\"></span><span class=\"token inserted-sign inserted prefix inserted\" style=\"color:#36acaa\">+</span><span class=\"token inserted-sign inserted line\" style=\"color:#36acaa\"> type ProductDetailProps = {</span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token inserted-sign inserted line\" style=\"color:#36acaa\"></span><span class=\"token inserted-sign inserted prefix inserted\" style=\"color:#36acaa\">+</span><span class=\"token inserted-sign inserted line\" style=\"color:#36acaa\">   overlayId: string;</span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token inserted-sign inserted line\" style=\"color:#36acaa\"></span><span class=\"token inserted-sign inserted prefix inserted\" style=\"color:#36acaa\">+</span><span class=\"token inserted-sign inserted line\" style=\"color:#36acaa\"> };</span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token inserted-sign inserted line\" style=\"color:#36acaa\"></span><span class=\"token plain\"></span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token plain\"></span><span class=\"token inserted-sign inserted prefix inserted\" style=\"color:#36acaa\">+</span><span class=\"token inserted-sign inserted line\" style=\"color:#36acaa\"> const handleCloseDrawer = useCloseDetailDrawer({</span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token inserted-sign inserted line\" style=\"color:#36acaa\"></span><span class=\"token inserted-sign inserted prefix inserted\" style=\"color:#36acaa\">+</span><span class=\"token inserted-sign inserted line\" style=\"color:#36acaa\">   overlayId,</span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token inserted-sign inserted line\" style=\"color:#36acaa\"></span><span class=\"token inserted-sign inserted prefix inserted\" style=\"color:#36acaa\">+</span><span class=\"token inserted-sign inserted line\" style=\"color:#36acaa\">   searchKey: 'selectedProductId',</span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token inserted-sign inserted line\" style=\"color:#36acaa\"></span><span class=\"token inserted-sign inserted prefix inserted\" style=\"color:#36acaa\">+</span><span class=\"token inserted-sign inserted line\" style=\"color:#36acaa\"> });</span><br></div></code></pre></div></div>\n<p><code>overlayId</code>가 필수 prop이 되자 빠진 사용처는 컴파일 오류로 드러났습니다. 당시 TypeScript가 찾아낸 렌더링 위치는 <code>ProductDetail</code> 14곳과 <code>ShipmentDetail</code> 7곳, 모두 21곳이었습니다. 사람이 검색어를 잘 고르기를 기대하는 대신 컴파일러가 누락된 곳을 알려주는 계약으로 바뀐 셈입니다.</p>\n<p>코드는 이 작업 안에서 이미 고쳤습니다. 하지만 다음 작업의 에이전트는 왜 중앙 목록을 없앴는지 알지 못합니다. 맥락을 모르면 다시 목록을 만들거나, 비슷한 Drawer를 고치면서 <code>openBottomDrawer</code> 호출부만 확인할 수도 있습니다. 저희가 후보로 남기고 싶었던 것은 코드 한 줄이 아니라 그 선택에 이른 판단 기준이었습니다.</p>\n<p>작업이 끝난 뒤 Stop hook은 이 사건에서 서로 다른 두 가지 교훈을 찾았고, 각각 Raw Ticket으로 남겼습니다. 첫 번째 티켓에는 공용 Drawer의 ID 계약을 바꿀 때 호출 함수만 검색하지 말고 전체 렌더링 경로를 확인해야 한다는 내용을 담았습니다.</p>\n<div class=\"language-yaml codeBlockContainer_Ckt0 theme-code-block\" style=\"--prism-color:#393A34;--prism-background-color:#f6f8fa\"><div class=\"codeBlockTitle_OeMC\">compound-20260722-112558-shared-drawer-overlay-id-audit.md</div><div class=\"codeBlockContent_QJqH\"><pre tabindex=\"0\" class=\"prism-code language-yaml codeBlock_bY9V thin-scrollbar\" style=\"color:#393A34;background-color:#f6f8fa\"><code class=\"codeBlockLines_e6Vv\"><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token key atrule\" style=\"color:#00a4db\">summary</span><span class=\"token punctuation\" style=\"color:#393A34\">:</span><span class=\"token plain\"> </span><span class=\"token punctuation\" style=\"color:#393A34\">&gt;</span><span class=\"token scalar string\" style=\"color:#e3116c\"></span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token scalar string\" style=\"color:#e3116c\">  공유 상세 Drawer의 overlay ID 계약을 변경할 때는</span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token scalar string\" style=\"color:#e3116c\">  openBottomDrawer 호출처 검색만으로는 불충분하다.</span><span class=\"token plain\"></span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token plain\"></span><span class=\"token key atrule\" style=\"color:#00a4db\">documentationTargetProposal</span><span class=\"token punctuation\" style=\"color:#393A34\">:</span><span class=\"token plain\"> docs/llm/url</span><span class=\"token punctuation\" style=\"color:#393A34\">-</span><span class=\"token plain\">filter</span><span class=\"token punctuation\" style=\"color:#393A34\">-</span><span class=\"token plain\">sync</span><span class=\"token punctuation\" style=\"color:#393A34\">-</span><span class=\"token plain\">patterns.md</span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token plain\"></span><span class=\"token key atrule\" style=\"color:#00a4db\">reuseEvidence</span><span class=\"token punctuation\" style=\"color:#393A34\">:</span><span class=\"token plain\"> </span><span class=\"token punctuation\" style=\"color:#393A34\">&gt;</span><span class=\"token scalar string\" style=\"color:#e3116c\"></span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token scalar string\" style=\"color:#e3116c\">  safety-stock의 ProductDetail 마운트 경로를 놓쳐</span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token scalar string\" style=\"color:#e3116c\">  해당 화면의 Drawer가 닫히지 않을 뻔했다.</span><span class=\"token plain\"></span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token plain\"></span><span class=\"token key atrule\" style=\"color:#00a4db\">boundary</span><span class=\"token punctuation\" style=\"color:#393A34\">:</span><span class=\"token plain\"> </span><span class=\"token punctuation\" style=\"color:#393A34\">&gt;</span><span class=\"token scalar string\" style=\"color:#e3116c\"></span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token scalar string\" style=\"color:#e3116c\">  단일 도메인 Drawer나 내부 UI 변경에는</span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token scalar string\" style=\"color:#e3116c\">  앱 전체 감사를 요구하지 않는다.</span><br></div></code></pre></div></div>\n<p>두 번째 티켓에는 더 근본적인 구조 개선을 담았습니다. 교차 도메인에서 재사용하는 Drawer는 ID 목록을 내부에 열거하지 않고, 마운트하는 화면이 필수 prop으로 ID를 주입한다는 내용입니다.</p>\n<div class=\"language-yaml codeBlockContainer_Ckt0 theme-code-block\" style=\"--prism-color:#393A34;--prism-background-color:#f6f8fa\"><div class=\"codeBlockTitle_OeMC\">compound-20260722-130545-drawer-overlay-id-ioc-contract.md</div><div class=\"codeBlockContent_QJqH\"><pre tabindex=\"0\" class=\"prism-code language-yaml codeBlock_bY9V thin-scrollbar\" style=\"color:#393A34;background-color:#f6f8fa\"><code class=\"codeBlockLines_e6Vv\"><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token key atrule\" style=\"color:#00a4db\">summary</span><span class=\"token punctuation\" style=\"color:#393A34\">:</span><span class=\"token plain\"> </span><span class=\"token punctuation\" style=\"color:#393A34\">&gt;</span><span class=\"token scalar string\" style=\"color:#e3116c\"></span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token scalar string\" style=\"color:#e3116c\">  교차 도메인에서 재사용되는 상세 Drawer는 overlay ID 목록을</span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token scalar string\" style=\"color:#e3116c\">  내부에 열거하지 않고, 마운트하는 쪽이 필수 prop으로 주입한다.</span><span class=\"token plain\"></span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token plain\"></span><span class=\"token key atrule\" style=\"color:#00a4db\">reuseEvidence</span><span class=\"token punctuation\" style=\"color:#393A34\">:</span><span class=\"token plain\"> </span><span class=\"token punctuation\" style=\"color:#393A34\">&gt;</span><span class=\"token scalar string\" style=\"color:#e3116c\"></span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token scalar string\" style=\"color:#e3116c\">  필수 prop을 추가하자 TypeScript가 ProductDetail 14곳과</span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token scalar string\" style=\"color:#e3116c\">  ShipmentDetail 7곳의 렌더링 위치를 찾아냈다.</span><span class=\"token plain\"></span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token plain\"></span><span class=\"token key atrule\" style=\"color:#00a4db\">boundary</span><span class=\"token punctuation\" style=\"color:#393A34\">:</span><span class=\"token plain\"> </span><span class=\"token punctuation\" style=\"color:#393A34\">&gt;</span><span class=\"token scalar string\" style=\"color:#e3116c\"></span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token scalar string\" style=\"color:#e3116c\">  단일 ID로만 열리는 기존 Drawer를 한꺼번에 전환하라는 규칙은 아니다.</span><br></div></code></pre></div></div>\n<p>Raw Ticket에는 “필수 prop으로 바꾸자”는 결론만 적지 않습니다. 어떤 경로를 놓쳤는지, 같은 문제가 왜 다시 생길 수 있는지, 규칙을 어디에 남길지, 어디까지 적용할지를 함께 기록합니다. 이렇게 적어두면 원래 대화를 읽지 않은 사람도 후보가 나온 이유와 적용 범위를 확인할 수 있습니다. 반대로 반복 근거나 적용 범위를 설명하기 어렵다면 아직 팀 규칙으로 다룰 후보가 아니라고 봅니다.</p>\n<p>후보는 어디까지나 검토 요청입니다. AI가 남겼다는 이유만으로 맞는 규칙이라고 가정하지 않습니다.</p>\n<h2 class=\"anchor anchorTargetStickyNavbar_Vzrq\" id=\"사람이-후보의-다음-상태를-결정합니다\">사람이 후보의 다음 상태를 결정합니다<a href=\"https://tech.umosone.ai/compound-triage#%EC%82%AC%EB%9E%8C%EC%9D%B4-%ED%9B%84%EB%B3%B4%EC%9D%98-%EB%8B%A4%EC%9D%8C-%EC%83%81%ED%83%9C%EB%A5%BC-%EA%B2%B0%EC%A0%95%ED%95%A9%EB%8B%88%EB%8B%A4\" class=\"hash-link\" aria-label=\"사람이 후보의 다음 상태를 결정합니다에 대한 직접 링크\" title=\"사람이 후보의 다음 상태를 결정합니다에 대한 직접 링크\" translate=\"no\">​</a></h2>\n<p>후보가 모이면 개발자가 트리아지 화면을 엽니다. 여기서 트리아지는 후보를 하나씩 살펴보고 다음에 어떻게 처리할지 정하는 일을 뜻합니다. 검토자는 원래 작업의 근거, 기존 문서, 적용 범위를 확인한 뒤 세 가지 중 하나를 고릅니다.</p>\n<ul>\n<li class=\"\"><strong>승격(Promoted)</strong>: 팀 규칙으로 발전시킬 가치가 충분하다.</li>\n<li class=\"\"><strong>보류(Held)</strong>: 가능성은 있지만 근거나 논의가 더 필요하다.</li>\n<li class=\"\"><strong>폐기(Discarded)</strong>: 일회성 문제이거나 이미 있는 규칙과 겹친다.</li>\n</ul>\n<p>저희는 이 전체 흐름을 <code>compound-triage</code>라는 상위 스킬로 실행합니다. 하나의 스킬이 모든 일을 한꺼번에 처리하는 대신, 책임이 다른 세 단계를 정해진 순서로 연결합니다.</p>\n<p><a href=\"https://tech.umosone.ai/assets/files/compound-triage-skill-flow-08b4d6d9b4af0f7949390769ac5dc7c5.svg\" target=\"_blank\" class=\"\"><img decoding=\"async\" loading=\"lazy\" alt=\"compound-triage가 session, apply, promote-plan을 순서대로 호출하고 결정 JSON과 승격 여부를 통과 조건으로 확인하는 흐름\" src=\"data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxMjAwIiBoZWlnaHQ9IjgyMCIgdmlld0JveD0iMCAwIDEyMDAgODIwIiByb2xlPSJpbWciIGFyaWEtbGFiZWxsZWRieT0idGl0bGUgZGVzYyI+CiAgPHRpdGxlIGlkPSJ0aXRsZSI+Y29tcG91bmQtdHJpYWdl7JmAIOyEuCDtlZjsnIQg7Iqk7YKs7J2YIO2YuOy2nCDtnZDrpoQ8L3RpdGxlPgogIDxkZXNjIGlkPSJkZXNjIj5jb21wb3VuZC10cmlhZ2XqsIAgY29tcG91bmQtdHJpYWdlLXNlc3Npb24sIGNvbXBvdW5kLXRyaWFnZS1hcHBseSwgY29tcG91bmQtcHJvbW90ZS1wbGFu7J2EIOyInOyEnOuMgOuhnCDtmLjstpztlZzri6QuIOyCrOuejOydtCDsoJzstpztlZwg6rKw7KCVIEpTT07snbQg7J6I7Ja07JW8IGFwcGx566GcIOynhO2Wie2VmOupsCwg7Iq56rKp65CcIOq3uOujueunjCDrrLjshJwg67OA6rK9IOqzhO2ajSDsg53shLEg64uo6rOE66GcIOydtOuPme2VnOuLpC48L2Rlc2M+CiAgPGRlZnM+CiAgICA8ZmlsdGVyIGlkPSJzaGFkb3ciIHg9Ii0yMCUiIHk9Ii0yMCUiIHdpZHRoPSIxNDAlIiBoZWlnaHQ9IjE1MCUiPgogICAgICA8ZmVEcm9wU2hhZG93IGR4PSIwIiBkeT0iOCIgc3RkRGV2aWF0aW9uPSIxMiIgZmxvb2QtY29sb3I9IiMzMTJlODEiIGZsb29kLW9wYWNpdHk9Ii4xMCIvPgogICAgPC9maWx0ZXI+CiAgICA8bWFya2VyIGlkPSJhcnJvdy1wdXJwbGUiIHZpZXdCb3g9IjAgMCAxMCAxMCIgcmVmWD0iOCIgcmVmWT0iNSIgbWFya2VyV2lkdGg9IjciIG1hcmtlckhlaWdodD0iNyIgb3JpZW50PSJhdXRvIj4KICAgICAgPHBhdGggZD0iTTAgMGwxMCA1LTEwIDV6IiBmaWxsPSIjOGI1Y2Y2Ii8+CiAgICA8L21hcmtlcj4KICAgIDxtYXJrZXIgaWQ9ImFycm93LWdyZWVuIiB2aWV3Qm94PSIwIDAgMTAgMTAiIHJlZlg9IjgiIHJlZlk9IjUiIG1hcmtlcldpZHRoPSI3IiBtYXJrZXJIZWlnaHQ9IjciIG9yaWVudD0iYXV0byI+CiAgICAgIDxwYXRoIGQ9Ik0wIDBsMTAgNS0xMCA1eiIgZmlsbD0iIzE2YTM0YSIvPgogICAgPC9tYXJrZXI+CiAgPC9kZWZzPgoKICA8cmVjdCB3aWR0aD0iMTIwMCIgaGVpZ2h0PSI4MjAiIHJ4PSIzMCIgZmlsbD0iI2Y4ZmFmYyIvPgoKICA8ZyBmb250LWZhbWlseT0iUHJldGVuZGFyZCwgQXBwbGUgU0QgR290aGljIE5lbywgc2Fucy1zZXJpZiI+CiAgICA8dGV4dCB4PSI2MCIgeT0iNjciIGZvbnQtc2l6ZT0iMzQiIGZvbnQtd2VpZ2h0PSI3MDAiIGZpbGw9IiMxMTE4MjciPuyDgeychCDsiqTtgqzsnYAg7IS4IOuLqOqzhOulvCDslrTrlrvqsowg7J207Ja06rCI6rmMPzwvdGV4dD4KICAgIDx0ZXh0IHg9IjYwIiB5PSIxMDUiIGZvbnQtc2l6ZT0iMTkiIGZpbGw9IiM2NDc0OGIiPuyVniDri6jqs4TsnZgg6rKw6rO86rCAIO2ZleyduOuQmOyWtOyVvCDri6TsnYwg7ZWY7JyEIOyKpO2CrOydhCDtmLjstpztlanri4jri6QuPC90ZXh0PgoKICAgIDxnIGZpbHRlcj0idXJsKCNzaGFkb3cpIj4KICAgICAgPHJlY3QgeD0iMjUwIiB5PSIxMzUiIHdpZHRoPSI3MDAiIGhlaWdodD0iOTIiIHJ4PSIyNCIgZmlsbD0iIzMxMmU4MSIvPgogICAgICA8Y2lyY2xlIGN4PSIzMDIiIGN5PSIxODEiIHI9IjI3IiBmaWxsPSIjNGMzY2I0Ii8+CiAgICAgIDxwYXRoIGQ9Ik0yOTEgMTc0aDIyTTI5MSAxODFoMjJNMjkxIDE4OGgxNCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjZGRkNmZlIiBzdHJva2Utd2lkdGg9IjQiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIvPgogICAgICA8dGV4dCB4PSIzNDUiIHk9IjE3NiIgZm9udC1mYW1pbHk9IlNGTW9uby1SZWd1bGFyLCBDb25zb2xhcywgbW9ub3NwYWNlIiBmb250LXNpemU9IjI0IiBmb250LXdlaWdodD0iNzAwIiBmaWxsPSIjZmZmZmZmIj5jb21wb3VuZC10cmlhZ2U8L3RleHQ+CiAgICAgIDx0ZXh0IHg9IjM0NSIgeT0iMjA0IiBmb250LXNpemU9IjE2IiBmaWxsPSIjZGRkNmZlIj7shLgg7ZWY7JyEIOyKpO2CrOydhCDsiJzshJzrjIDroZwg7Zi47Lac7ZWY6rOgIOqwgSDri6jqs4TsnZgg7JmE66OMIOyhsOqxtOydhCDtmZXsnbg8L3RleHQ+CiAgICA8L2c+CgogICAgPHBhdGggZD0iTTYwMCAyMjd2MzZIMTgwdjI1IiBmaWxsPSJub25lIiBzdHJva2U9IiM4YjVjZjYiIHN0cm9rZS13aWR0aD0iMyIgbWFya2VyLWVuZD0idXJsKCNhcnJvdy1wdXJwbGUpIi8+CiAgICA8dGV4dCB4PSIzOTUiIHk9IjI1NCIgZm9udC1zaXplPSIxNCIgZm9udC13ZWlnaHQ9IjcwMCIgZmlsbD0iIzdjM2FlZCI+MS4gc2Vzc2lvbuu2gO2EsCDsi5zsnpE8L3RleHQ+CgogICAgPGcgZmlsdGVyPSJ1cmwoI3NoYWRvdykiPgogICAgICA8cmVjdCB4PSI0MCIgeT0iMjkwIiB3aWR0aD0iMzAwIiBoZWlnaHQ9IjMyMCIgcng9IjI0IiBmaWxsPSIjZmZmZmZmIiBzdHJva2U9IiNjN2QyZmUiIHN0cm9rZS13aWR0aD0iMiIvPgogICAgICA8cmVjdCB4PSI0MCIgeT0iMjkwIiB3aWR0aD0iMzAwIiBoZWlnaHQ9IjcwIiByeD0iMjQiIGZpbGw9IiNlZWYyZmYiLz4KICAgICAgPHBhdGggZD0iTTQwIDMzNmgzMDB2MjRINDB6IiBmaWxsPSIjZWVmMmZmIi8+CiAgICAgIDxjaXJjbGUgY3g9Ijc4IiBjeT0iMzI1IiByPSIyMCIgZmlsbD0iIzRmNDZlNSIvPgogICAgICA8dGV4dCB4PSI3OCIgeT0iMzMyIiB0ZXh0LWFuY2hvcj0ibWlkZGxlIiBmb250LXNpemU9IjE5IiBmb250LXdlaWdodD0iODAwIiBmaWxsPSIjZmZmZmZmIj4xPC90ZXh0PgogICAgICA8dGV4dCB4PSIxMDgiIHk9IjMyMSIgZm9udC1mYW1pbHk9IlNGTW9uby1SZWd1bGFyLCBDb25zb2xhcywgbW9ub3NwYWNlIiBmb250LXNpemU9IjE3IiBmb250LXdlaWdodD0iNzAwIiBmaWxsPSIjMzEyZTgxIj5jb21wb3VuZC10cmlhZ2UtPC90ZXh0PgogICAgICA8dGV4dCB4PSIxMDgiIHk9IjM0NCIgZm9udC1mYW1pbHk9IlNGTW9uby1SZWd1bGFyLCBDb25zb2xhcywgbW9ub3NwYWNlIiBmb250LXNpemU9IjE3IiBmb250LXdlaWdodD0iNzAwIiBmaWxsPSIjMzEyZTgxIj5zZXNzaW9uPC90ZXh0PgoKICAgICAgPHJlY3QgeD0iNjYiIHk9IjM4MyIgd2lkdGg9IjI0OCIgaGVpZ2h0PSI0NCIgcng9IjEyIiBmaWxsPSIjZjhmYWZjIi8+CiAgICAgIDx0ZXh0IHg9IjgyIiB5PSI0MTEiIGZvbnQtc2l6ZT0iMTUiIGZvbnQtd2VpZ2h0PSI3MDAiIGZpbGw9IiM0NzU1NjkiPuyeheugpTwvdGV4dD4KICAgICAgPHRleHQgeD0iMTI4IiB5PSI0MTEiIGZvbnQtc2l6ZT0iMTUiIGZpbGw9IiM2NDc0OGIiPnJhdyB0aWNrZXQ8L3RleHQ+CgogICAgICA8Y2lyY2xlIGN4PSI3NiIgY3k9IjQ1OSIgcj0iNSIgZmlsbD0iIzgxOGNmOCIvPgogICAgICA8dGV4dCB4PSI5MiIgeT0iNDY1IiBmb250LXNpemU9IjE2IiBmaWxsPSIjMzM0MTU1Ij7ruYTsirftlZwg7ZuE67O064G866asIOq3uOujue2ZlDwvdGV4dD4KICAgICAgPGNpcmNsZSBjeD0iNzYiIGN5PSI0OTQiIHI9IjUiIGZpbGw9IiM4MThjZjgiLz4KICAgICAgPHRleHQgeD0iOTIiIHk9IjUwMCIgZm9udC1zaXplPSIxNiIgZmlsbD0iIzMzNDE1NSI+SFRNTCDqsoDthqAg7ZmU66m0IOyDneyEsTwvdGV4dD4KICAgICAgPGNpcmNsZSBjeD0iNzYiIGN5PSI1MjkiIHI9IjUiIGZpbGw9IiM4MThjZjgiLz4KICAgICAgPHRleHQgeD0iOTIiIHk9IjUzNSIgZm9udC1zaXplPSIxNiIgZmlsbD0iIzMzNDE1NSI+7IKs656M7J2YIOqysOyglSDsiJjsp5E8L3RleHQ+CgogICAgICA8cmVjdCB4PSI2NiIgeT0iNTU4IiB3aWR0aD0iMjQ4IiBoZWlnaHQ9IjM2IiByeD0iMTgiIGZpbGw9IiNlZGU5ZmUiLz4KICAgICAgPHRleHQgeD0iMTkwIiB5PSI1ODIiIHRleHQtYW5jaG9yPSJtaWRkbGUiIGZvbnQtc2l6ZT0iMTQiIGZvbnQtd2VpZ2h0PSI3MDAiIGZpbGw9IiM1YjIxYjYiPuy2nOugpTogc3VibWl0dGVkIGRlY2lzaW9ucyBKU09OPC90ZXh0PgogICAgPC9nPgoKICAgIDxnIGZpbHRlcj0idXJsKCNzaGFkb3cpIj4KICAgICAgPHJlY3QgeD0iNDUwIiB5PSIyOTAiIHdpZHRoPSIzMDAiIGhlaWdodD0iMzIwIiByeD0iMjQiIGZpbGw9IiNmZmZmZmYiIHN0cm9rZT0iI2JhZTZmZCIgc3Ryb2tlLXdpZHRoPSIyIi8+CiAgICAgIDxyZWN0IHg9IjQ1MCIgeT0iMjkwIiB3aWR0aD0iMzAwIiBoZWlnaHQ9IjcwIiByeD0iMjQiIGZpbGw9IiNlY2ZlZmYiLz4KICAgICAgPHBhdGggZD0iTTQ1MCAzMzZoMzAwdjI0SDQ1MHoiIGZpbGw9IiNlY2ZlZmYiLz4KICAgICAgPGNpcmNsZSBjeD0iNDg4IiBjeT0iMzI1IiByPSIyMCIgZmlsbD0iIzA4OTFiMiIvPgogICAgICA8dGV4dCB4PSI0ODgiIHk9IjMzMiIgdGV4dC1hbmNob3I9Im1pZGRsZSIgZm9udC1zaXplPSIxOSIgZm9udC13ZWlnaHQ9IjgwMCIgZmlsbD0iI2ZmZmZmZiI+MjwvdGV4dD4KICAgICAgPHRleHQgeD0iNTE4IiB5PSIzMjEiIGZvbnQtZmFtaWx5PSJTRk1vbm8tUmVndWxhciwgQ29uc29sYXMsIG1vbm9zcGFjZSIgZm9udC1zaXplPSIxNyIgZm9udC13ZWlnaHQ9IjcwMCIgZmlsbD0iIzE2NGU2MyI+Y29tcG91bmQtdHJpYWdlLTwvdGV4dD4KICAgICAgPHRleHQgeD0iNTE4IiB5PSIzNDQiIGZvbnQtZmFtaWx5PSJTRk1vbm8tUmVndWxhciwgQ29uc29sYXMsIG1vbm9zcGFjZSIgZm9udC1zaXplPSIxNyIgZm9udC13ZWlnaHQ9IjcwMCIgZmlsbD0iIzE2NGU2MyI+YXBwbHk8L3RleHQ+CgogICAgICA8cmVjdCB4PSI0NzYiIHk9IjM4MyIgd2lkdGg9IjI0OCIgaGVpZ2h0PSI0NCIgcng9IjEyIiBmaWxsPSIjZjhmYWZjIi8+CiAgICAgIDx0ZXh0IHg9IjQ5MiIgeT0iNDExIiBmb250LXNpemU9IjE1IiBmb250LXdlaWdodD0iNzAwIiBmaWxsPSIjNDc1NTY5Ij7snoXroKU8L3RleHQ+CiAgICAgIDx0ZXh0IHg9IjUzOCIgeT0iNDExIiBmb250LXNpemU9IjE1IiBmaWxsPSIjNjQ3NDhiIj7sgqzrnozsnbQg7KCc7Lac7ZWcIOqysOyglSBKU09OPC90ZXh0PgoKICAgICAgPGNpcmNsZSBjeD0iNDg2IiBjeT0iNDU5IiByPSI1IiBmaWxsPSIjMjJkM2VlIi8+CiAgICAgIDx0ZXh0IHg9IjUwMiIgeT0iNDY1IiBmb250LXNpemU9IjE2IiBmaWxsPSIjMzM0MTU1Ij7ti7DsvJMg7YyM7J28IOydtOuPmTwvdGV4dD4KICAgICAgPGNpcmNsZSBjeD0iNDg2IiBjeT0iNDk0IiByPSI1IiBmaWxsPSIjMjJkM2VlIi8+CiAgICAgIDx0ZXh0IHg9IjUwMiIgeT0iNTAwIiBmb250LXNpemU9IjE2IiBmaWxsPSIjMzM0MTU1Ij5mcm9udG1hdHRlcuyZgCDquLDroZ0g6rCx7IugPC90ZXh0PgoKICAgICAgPHJlY3QgeD0iNDc2IiB5PSI1MjkiIHdpZHRoPSIyNDgiIGhlaWdodD0iMjgiIHJ4PSIxNCIgZmlsbD0iI2ZlZjNjNyIvPgogICAgICA8dGV4dCB4PSI2MDAiIHk9IjU0OCIgdGV4dC1hbmNob3I9Im1pZGRsZSIgZm9udC1zaXplPSIxMyIgZm9udC13ZWlnaHQ9IjcwMCIgZmlsbD0iIzkyNDAwZSI+66+46rKA7YagIOKGkiByYXfsl5Ag64Ko6rmAPC90ZXh0PgogICAgICA8cmVjdCB4PSI0NzYiIHk9IjU2MyIgd2lkdGg9IjExOCIgaGVpZ2h0PSIyOCIgcng9IjE0IiBmaWxsPSIjZjFmNWY5Ii8+CiAgICAgIDx0ZXh0IHg9IjUzNSIgeT0iNTgyIiB0ZXh0LWFuY2hvcj0ibWlkZGxlIiBmb250LXNpemU9IjEyIiBmb250LXdlaWdodD0iNzAwIiBmaWxsPSIjNDc1NTY5Ij7rs7TrpZggwrcg7Y+Q6riwIOKGkiDsooXro4w8L3RleHQ+CiAgICAgIDxyZWN0IHg9IjYwMiIgeT0iNTYzIiB3aWR0aD0iMTIyIiBoZWlnaHQ9IjI4IiByeD0iMTQiIGZpbGw9IiNkY2ZjZTciLz4KICAgICAgPHRleHQgeD0iNjYzIiB5PSI1ODIiIHRleHQtYW5jaG9yPSJtaWRkbGUiIGZvbnQtc2l6ZT0iMTIiIGZvbnQtd2VpZ2h0PSI3MDAiIGZpbGw9IiMxNjY1MzQiPuyKueqyqSDihpIg64uk7J2MIOuLqOqzhDwvdGV4dD4KICAgIDwvZz4KCiAgICA8ZyBmaWx0ZXI9InVybCgjc2hhZG93KSI+CiAgICAgIDxyZWN0IHg9Ijg2MCIgeT0iMjkwIiB3aWR0aD0iMzAwIiBoZWlnaHQ9IjMyMCIgcng9IjI0IiBmaWxsPSIjZmZmZmZmIiBzdHJva2U9IiNiYmY3ZDAiIHN0cm9rZS13aWR0aD0iMiIvPgogICAgICA8cmVjdCB4PSI4NjAiIHk9IjI5MCIgd2lkdGg9IjMwMCIgaGVpZ2h0PSI3MCIgcng9IjI0IiBmaWxsPSIjZjBmZGY0Ii8+CiAgICAgIDxwYXRoIGQ9Ik04NjAgMzM2aDMwMHYyNEg4NjB6IiBmaWxsPSIjZjBmZGY0Ii8+CiAgICAgIDxjaXJjbGUgY3g9Ijg5OCIgY3k9IjMyNSIgcj0iMjAiIGZpbGw9IiMxNmEzNGEiLz4KICAgICAgPHRleHQgeD0iODk4IiB5PSIzMzIiIHRleHQtYW5jaG9yPSJtaWRkbGUiIGZvbnQtc2l6ZT0iMTkiIGZvbnQtd2VpZ2h0PSI4MDAiIGZpbGw9IiNmZmZmZmYiPjM8L3RleHQ+CiAgICAgIDx0ZXh0IHg9IjkyOCIgeT0iMzIxIiBmb250LWZhbWlseT0iU0ZNb25vLVJlZ3VsYXIsIENvbnNvbGFzLCBtb25vc3BhY2UiIGZvbnQtc2l6ZT0iMTciIGZvbnQtd2VpZ2h0PSI3MDAiIGZpbGw9IiMxNDUzMmQiPmNvbXBvdW5kLXByb21vdGUtPC90ZXh0PgogICAgICA8dGV4dCB4PSI5MjgiIHk9IjM0NCIgZm9udC1mYW1pbHk9IlNGTW9uby1SZWd1bGFyLCBDb25zb2xhcywgbW9ub3NwYWNlIiBmb250LXNpemU9IjE3IiBmb250LXdlaWdodD0iNzAwIiBmaWxsPSIjMTQ1MzJkIj5wbGFuPC90ZXh0PgoKICAgICAgPHJlY3QgeD0iODg2IiB5PSIzODMiIHdpZHRoPSIyNDgiIGhlaWdodD0iNDQiIHJ4PSIxMiIgZmlsbD0iI2Y4ZmFmYyIvPgogICAgICA8dGV4dCB4PSI5MDIiIHk9IjQxMSIgZm9udC1zaXplPSIxNSIgZm9udC13ZWlnaHQ9IjcwMCIgZmlsbD0iIzQ3NTU2OSI+7J6F66ClPC90ZXh0PgogICAgICA8dGV4dCB4PSI5NDgiIHk9IjQxMSIgZm9udC1zaXplPSIxNSIgZmlsbD0iIzY0NzQ4YiI+7Iq56rKp65CcIHRpY2tldCDqt7jro7k8L3RleHQ+CgogICAgICA8Y2lyY2xlIGN4PSI4OTYiIGN5PSI0NTkiIHI9IjUiIGZpbGw9IiM0YWRlODAiLz4KICAgICAgPHRleHQgeD0iOTEyIiB5PSI0NjUiIGZvbnQtc2l6ZT0iMTYiIGZpbGw9IiMzMzQxNTUiPuq3uOujueuniOuLpCDqs4Ttmo0gMeqwnDwvdGV4dD4KICAgICAgPGNpcmNsZSBjeD0iODk2IiBjeT0iNDk0IiByPSI1IiBmaWxsPSIjNGFkZTgwIi8+CiAgICAgIDx0ZXh0IHg9IjkxMiIgeT0iNTAwIiBmb250LXNpemU9IjE2IiBmaWxsPSIjMzM0MTU1Ij7rrLjshJwg64yA7IOB6rO8IOyggeyaqSDrspTsnIQ8L3RleHQ+CiAgICAgIDxjaXJjbGUgY3g9Ijg5NiIgY3k9IjUyOSIgcj0iNSIgZmlsbD0iIzRhZGU4MCIvPgogICAgICA8dGV4dCB4PSI5MTIiIHk9IjUzNSIgZm9udC1zaXplPSIxNiIgZmlsbD0iIzMzNDE1NSI+67OA6rK9IO2bhCDqsoDspp0g67Cp67KVPC90ZXh0PgoKICAgICAgPHJlY3QgeD0iODg2IiB5PSI1NTgiIHdpZHRoPSIyNDgiIGhlaWdodD0iMzYiIHJ4PSIxOCIgZmlsbD0iI2RjZmNlNyIvPgogICAgICA8dGV4dCB4PSIxMDEwIiB5PSI1ODIiIHRleHQtYW5jaG9yPSJtaWRkbGUiIGZvbnQtc2l6ZT0iMTQiIGZvbnQtd2VpZ2h0PSI3MDAiIGZpbGw9IiMxNjY1MzQiPuy2nOugpTog66y47IScIOuzgOqyvSDqs4Ttmo08L3RleHQ+CiAgICA8L2c+CgogICAgPHBhdGggZD0iTTM0MCA0NTBoMTEwIiBmaWxsPSJub25lIiBzdHJva2U9IiM4YjVjZjYiIHN0cm9rZS13aWR0aD0iMyIgbWFya2VyLWVuZD0idXJsKCNhcnJvdy1wdXJwbGUpIi8+CiAgICA8cmVjdCB4PSIzNDEiIHk9IjM4OSIgd2lkdGg9IjEwOCIgaGVpZ2h0PSIzNCIgcng9IjE3IiBmaWxsPSIjZWRlOWZlIi8+CiAgICA8dGV4dCB4PSIzOTUiIHk9IjQxMSIgdGV4dC1hbmNob3I9Im1pZGRsZSIgZm9udC1zaXplPSIxMyIgZm9udC13ZWlnaHQ9IjcwMCIgZmlsbD0iIzZkMjhkOSI+6rKw7KCVIEpTT04g7ZWE7IiYPC90ZXh0PgogICAgPHRleHQgeD0iMzk1IiB5PSI0NzciIHRleHQtYW5jaG9yPSJtaWRkbGUiIGZvbnQtc2l6ZT0iMTIiIGZvbnQtd2VpZ2h0PSI3MDAiIGZpbGw9IiNiNDUzMDkiPuyXhuycvOuptCDsl6zquLDshJwg7KSR64uoPC90ZXh0PgoKICAgIDxwYXRoIGQ9Ik03NTAgNDUwaDExMCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjMTZhMzRhIiBzdHJva2Utd2lkdGg9IjMiIG1hcmtlci1lbmQ9InVybCgjYXJyb3ctZ3JlZW4pIi8+CiAgICA8cmVjdCB4PSI3NTEiIHk9IjM4OSIgd2lkdGg9IjEwOCIgaGVpZ2h0PSIzNCIgcng9IjE3IiBmaWxsPSIjZGNmY2U3Ii8+CiAgICA8dGV4dCB4PSI4MDUiIHk9IjQxMSIgdGV4dC1hbmNob3I9Im1pZGRsZSIgZm9udC1zaXplPSIxMyIgZm9udC13ZWlnaHQ9IjcwMCIgZmlsbD0iIzE2NjUzNCI+7Iq56rKpIOq3uOujueunjDwvdGV4dD4KICAgIDx0ZXh0IHg9IjgwNSIgeT0iNDc3IiB0ZXh0LWFuY2hvcj0ibWlkZGxlIiBmb250LXNpemU9IjEyIiBmb250LXdlaWdodD0iNzAwIiBmaWxsPSIjMTU4MDNkIj7rs7TrpZggwrcg7Y+Q6riw64qUIOygnOyZuDwvdGV4dD4KCiAgICA8ZyBmaWx0ZXI9InVybCgjc2hhZG93KSI+CiAgICAgIDxyZWN0IHg9IjEzMCIgeT0iNjY1IiB3aWR0aD0iOTQwIiBoZWlnaHQ9IjEwNSIgcng9IjI0IiBmaWxsPSIjMTExODI3Ii8+CiAgICAgIDxjaXJjbGUgY3g9IjE4NCIgY3k9IjcxNyIgcj0iMjYiIGZpbGw9IiMzMTJlODEiLz4KICAgICAgPHBhdGggZD0iTTE3MiA3MDhoMjR2MTloLTI0ek0xNzggNzAydjZNMTgwIDcxNmg4IiBmaWxsPSJub25lIiBzdHJva2U9IiNjNGI1ZmQiIHN0cm9rZS13aWR0aD0iMyIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIi8+CiAgICAgIDx0ZXh0IHg9IjIyNSIgeT0iNzA3IiBmb250LXNpemU9IjE5IiBmb250LXdlaWdodD0iNzAwIiBmaWxsPSIjZmZmZmZmIj7su7TtjIzsmrTrk5wg7Yq466as7JWE7KeA7J2YIOyiheujjCDsp4DsoJA8L3RleHQ+CiAgICAgIDx0ZXh0IHg9IjIyNSIgeT0iNzM4IiBmb250LXNpemU9IjE2IiBmaWxsPSIjY2JkNWUxIj7rrLjshJwg67OA6rK9IOqzhO2ajeq5jOyngCDsg53shLHtlZjrqbAsIOyLpOygnCDrrLjshJwg67CY7JiBIMK3IOy9lOuTnCDrs4Dqsr0gwrcgbWVyZ2XripQg67OE64+EIOyekeyXheycvOuhnCDsp4Ttlontlanri4jri6QuPC90ZXh0PgogICAgPC9nPgogIDwvZz4KPC9zdmc+Cg==\" width=\"1200\" height=\"820\" class=\"img_ev3q\"></a></p>\n<p><em><code>compound-triage</code>는 앞 단계의 결과를 확인한 뒤 다음 하위 스킬을 호출합니다. 사람의 결정 데이터가 없으면 적용 단계로 넘어가지 않고 승격된 그룹만 문서 변경 계획이 됩니다. 이미지를 누르면 크게 열립니다.</em></p>\n<ol>\n<li class=\"\"><code>compound-triage-session</code>은 Raw Ticket을 읽어 비슷한 내용끼리 묶고 사람이 검토할 HTML 화면을 만듭니다. 검토가 끝나면 승격·보류·폐기·미검토 결과가 담긴 결정 데이터를 내보냅니다. 이 단계에서는 아직 파일을 옮기지 않습니다.</li>\n<li class=\"\"><code>compound-triage-apply</code>는 사람이 제출한 결정 데이터가 있을 때만 실행됩니다. 검토한 티켓을 각 상태의 폴더로 옮기고 기록을 갱신하며 미검토 티켓은 다음 검토를 위해 Raw 상태로 남깁니다.</li>\n<li class=\"\"><code>compound-promote-plan</code>은 승격된 티켓을 주제별로 묶어 문서 변경 계획을 만듭니다. 어느 문서의 어떤 부분을 바꿀지, 규칙의 적용 범위를 어디까지로 제한할지, 변경 후 무엇을 확인할지를 계획에 담습니다.</li>\n</ol>\n<p>아래 화면은 실제 <code>compound-triage-session</code>의 HTML 템플릿에 설명용 가짜 티켓을 넣어 재현한 것입니다. 검토자는 티켓이 나온 배경과 그대로 뒀을 때의 문제, 제안하는 규칙, 기존 문서와 반영 후 문장의 차이를 한 카드에서 확인합니다.</p>\n<p><a href=\"https://tech.umosone.ai/assets/files/triage-ui-demo-a07b3f4d8fb3bf96aa1e786382d082fc.jpg\" target=\"_blank\" class=\"\"><img decoding=\"async\" loading=\"lazy\" alt=\"가짜 검색 필터 티켓의 배경과 문서 반영 미리보기, 승격·보류·폐기 버튼을 보여주는 컴파운드 트리아지 화면\" src=\"https://tech.umosone.ai/assets/images/triage-ui-demo-a07b3f4d8fb3bf96aa1e786382d082fc.jpg\" width=\"1245\" height=\"1170\" class=\"img_ev3q\"></a></p>\n<p><em>실제 트리아지 UI를 브라우저에서 렌더링해 캡처했습니다. 화면에 표시된 티켓과 경로는 모두 설명을 위해 만든 가짜 데이터이며 이미지를 누르면 크게 열립니다.</em></p>\n<p>공용 Drawer의 두 티켓은 같은 사건에서 나온 진단과 처방으로 묶였습니다. 하나는 놓친 경로를 찾는 방법이었고, 다른 하나는 중앙 목록을 없애 누락을 컴파일 오류로 바꾸는 방법이었습니다. 사람은 둘을 함께 읽고 같은 주제로 승격했습니다. <code>compound-triage-apply</code>가 결정을 반영한 뒤 두 티켓에는 같은 그룹과 Plan 경로가 기록됐습니다.</p>\n<div class=\"language-yaml codeBlockContainer_Ckt0 theme-code-block\" style=\"--prism-color:#393A34;--prism-background-color:#f6f8fa\"><div class=\"codeBlockContent_QJqH\"><pre tabindex=\"0\" class=\"prism-code language-yaml codeBlock_bY9V thin-scrollbar\" style=\"color:#393A34;background-color:#f6f8fa\"><code class=\"codeBlockLines_e6Vv\"><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token key atrule\" style=\"color:#00a4db\">decision</span><span class=\"token punctuation\" style=\"color:#393A34\">:</span><span class=\"token plain\"> promoted</span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token plain\"></span><span class=\"token key atrule\" style=\"color:#00a4db\">groupId</span><span class=\"token punctuation\" style=\"color:#393A34\">:</span><span class=\"token plain\"> compound</span><span class=\"token punctuation\" style=\"color:#393A34\">-</span><span class=\"token plain\">triage</span><span class=\"token punctuation\" style=\"color:#393A34\">-</span><span class=\"token plain\">20260728</span><span class=\"token punctuation\" style=\"color:#393A34\">-</span><span class=\"token plain\">140841</span><span class=\"token punctuation\" style=\"color:#393A34\">-</span><span class=\"token plain\">drawer</span><span class=\"token punctuation\" style=\"color:#393A34\">-</span><span class=\"token plain\">overlay</span><span class=\"token punctuation\" style=\"color:#393A34\">-</span><span class=\"token plain\">id</span><span class=\"token punctuation\" style=\"color:#393A34\">-</span><span class=\"token plain\">contract</span><span class=\"token punctuation\" style=\"color:#393A34\">-</span><span class=\"token number\" style=\"color:#36acaa\">23</span><span class=\"token plain\"></span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token plain\"></span><span class=\"token key atrule\" style=\"color:#00a4db\">documentationTarget</span><span class=\"token punctuation\" style=\"color:#393A34\">:</span><span class=\"token plain\"> docs/llm/url</span><span class=\"token punctuation\" style=\"color:#393A34\">-</span><span class=\"token plain\">filter</span><span class=\"token punctuation\" style=\"color:#393A34\">-</span><span class=\"token plain\">sync</span><span class=\"token punctuation\" style=\"color:#393A34\">-</span><span class=\"token plain\">patterns.md</span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token plain\"></span><span class=\"token key atrule\" style=\"color:#00a4db\">planStatus</span><span class=\"token punctuation\" style=\"color:#393A34\">:</span><span class=\"token plain\"> generated</span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token plain\"></span><span class=\"token key atrule\" style=\"color:#00a4db\">relatedPlanPath</span><span class=\"token punctuation\" style=\"color:#393A34\">:</span><span class=\"token plain\"> </span><span class=\"token punctuation\" style=\"color:#393A34\">&gt;</span><span class=\"token scalar string\" style=\"color:#e3116c\"></span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token scalar string\" style=\"color:#e3116c\">  docs/superpowers/plans/</span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token scalar string\" style=\"color:#e3116c\">  2026-07-28-update-shared-drawer-overlay-id-audit-and-ioc-contract.md</span><br></div></code></pre></div></div>\n<p>자동화가 사람의 판단을 앞질러가지 않도록 단계를 나눴습니다. 검토 화면에서 결정 데이터가 제출되지 않으면 티켓을 옮길 수 없고 승격된 후보도 곧바로 문서가 되는 것이 아니라 먼저 변경 계획을 거칩니다.</p>\n<p>이 구분은 실제 운영 중 겪은 문제에서 나왔습니다. 한 차례 부분 검토에서는 후보 63개 중 30개만 확인하고 세션을 마쳤는데, 초기 화면은 아직 보지 못한 33개까지 모두 보류로 저장했습니다. 하지만 보류는 “검토해보니 아직 근거가 부족하다”는 사람의 판단입니다. 아직 읽지도 않은 후보와는 의미가 다릅니다.</p>\n<p><img decoding=\"async\" loading=\"lazy\" alt=\"63개 후보 중 검토한 30개와 미검토 33개를 구분하고, 미검토를 보류로 저장하던 초기 동작과 개선 후 상태를 비교한 화면\" src=\"data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxMjAwIiBoZWlnaHQ9Ijc2MCIgdmlld0JveD0iMCAwIDEyMDAgNzYwIiByb2xlPSJpbWciIGFyaWEtbGFiZWxsZWRieT0idGl0bGUgZGVzYyI+CiAgPHRpdGxlIGlkPSJ0aXRsZSI+66+46rKA7Yag7JmAIOuztOulmOulvCDqtazrtoTtlZwg7Yq466as7JWE7KeAIO2ZlOuptDwvdGl0bGU+CiAgPGRlc2MgaWQ9ImRlc2MiPjYz6rCcIO2bhOuztCDspJEgMzDqsJzrp4wg6rKA7Yag7ZWcIOyLpOygnCDsgqzroYDsl5DshJwg66+46rKA7YagIDMz6rCc6rCAIOuztOulmOuhnCDsoIDsnqXrkJjrjZgg66y47KCc7JmAIOqwnOyEoCDtm4Qg7IOB7YOc66W8IOu5hOq1kO2VnOuLpC48L2Rlc2M+CiAgPGRlZnM+PGZpbHRlciBpZD0icyIgeD0iLTIwJSIgeT0iLTIwJSIgd2lkdGg9IjE0MCUiIGhlaWdodD0iMTQwJSI+PGZlRHJvcFNoYWRvdyBkeD0iMCIgZHk9IjgiIHN0ZERldmlhdGlvbj0iMTIiIGZsb29kLWNvbG9yPSIjMWUxYjRiIiBmbG9vZC1vcGFjaXR5PSIuMTAiLz48L2ZpbHRlcj48bWFya2VyIGlkPSJhIiB2aWV3Qm94PSIwIDAgMTAgMTAiIHJlZlg9IjgiIHJlZlk9IjUiIG1hcmtlcldpZHRoPSI3IiBtYXJrZXJIZWlnaHQ9IjciIG9yaWVudD0iYXV0byI+PHBhdGggZD0iTTAgMGwxMCA1LTEwIDV6IiBmaWxsPSIjNjQ3NDhiIi8+PC9tYXJrZXI+PC9kZWZzPgogIDxyZWN0IHdpZHRoPSIxMjAwIiBoZWlnaHQ9Ijc2MCIgcng9IjMwIiBmaWxsPSIjZjhmYWZjIi8+CiAgPHRleHQgeD0iNjAiIHk9IjY4IiBmb250LWZhbWlseT0iUHJldGVuZGFyZCwgQXBwbGUgU0QgR290aGljIE5lbywgc2Fucy1zZXJpZiIgZm9udC1zaXplPSIzNCIgZm9udC13ZWlnaHQ9IjcwMCIgZmlsbD0iIzExMTgyNyI+4oCY7JWE7KeBIOuztOyngCDslYrsnYzigJnsnYAg4oCY6rKA7Yag7ZWcIOuSpCDrs7TrpZjtlajigJnsnbQg7JWE64uZ64uI64ukPC90ZXh0PgogIDx0ZXh0IHg9IjYwIiB5PSIxMDYiIGZvbnQtZmFtaWx5PSJQcmV0ZW5kYXJkLCBBcHBsZSBTRCBHb3RoaWMgTmVvLCBzYW5zLXNlcmlmIiBmb250LXNpemU9IjE5IiBmaWxsPSIjNjQ3NDhiIj7si6TsoJwg67aA67aEIOqygO2GoCDshLjshZg6IOyghOyytCA2M+qwnCDCtyDqsoDthqAg7JmE66OMIDMw6rCcIMK3IOuvuOqygO2GoCAzM+qwnDwvdGV4dD4KICA8ZyBmb250LWZhbWlseT0iUHJldGVuZGFyZCwgQXBwbGUgU0QgR290aGljIE5lbywgc2Fucy1zZXJpZiIgZmlsdGVyPSJ1cmwoI3MpIj4KICAgIDxyZWN0IHg9IjU1IiB5PSIxNDUiIHdpZHRoPSI1NDAiIGhlaWdodD0iMzYwIiByeD0iMjQiIGZpbGw9IiNmZmYiIHN0cm9rZT0iI2NiZDVlMSIvPgogICAgPHJlY3QgeD0iNTUiIHk9IjE0NSIgd2lkdGg9IjU0MCIgaGVpZ2h0PSI1OCIgcng9IjI0IiBmaWxsPSIjMTExODI3Ii8+PGNpcmNsZSBjeD0iOTAiIGN5PSIxNzQiIHI9IjciIGZpbGw9IiNmYjcxODUiLz48Y2lyY2xlIGN4PSIxMTQiIGN5PSIxNzQiIHI9IjciIGZpbGw9IiNmYmJmMjQiLz48Y2lyY2xlIGN4PSIxMzgiIGN5PSIxNzQiIHI9IjciIGZpbGw9IiM0YWRlODAiLz48dGV4dCB4PSIxNzYiIHk9IjE4MSIgZm9udC1zaXplPSIxNyIgZm9udC13ZWlnaHQ9IjcwMCIgZmlsbD0iI2Y4ZmFmYyI+Q29tcG91bmQgVHJpYWdlPC90ZXh0Pjx0ZXh0IHg9IjU0MCIgeT0iMTgxIiB0ZXh0LWFuY2hvcj0iZW5kIiBmb250LXNpemU9IjE1IiBmaWxsPSIjOTRhM2I4Ij4zMCAvIDYzIOqygO2GoDwvdGV4dD4KICAgIDxyZWN0IHg9Ijg1IiB5PSIyMzAiIHdpZHRoPSI0ODAiIGhlaWdodD0iMTYwIiByeD0iMTgiIGZpbGw9IiNmOGZhZmMiIHN0cm9rZT0iI2UyZThmMCIvPjxyZWN0IHg9IjEwNSIgeT0iMjUyIiB3aWR0aD0iNzYiIGhlaWdodD0iMjkiIHJ4PSIxNCIgZmlsbD0iI2UwZjJmZSIvPjx0ZXh0IHg9IjE0MyIgeT0iMjcyIiB0ZXh0LWFuY2hvcj0ibWlkZGxlIiBmb250LXNpemU9IjE0IiBmb250LXdlaWdodD0iNzAwIiBmaWxsPSIjMDM2OWExIj7tm4Trs7QgMzA8L3RleHQ+PHRleHQgeD0iMTA1IiB5PSIzMTYiIGZvbnQtc2l6ZT0iMjAiIGZvbnQtd2VpZ2h0PSI3MDAiIGZpbGw9IiMxMTE4MjciPuqzteyaqSDqsoDsg4kg7ZWE7YSw7J2YIOyerOyhsO2ajCDsl7DqsrAg64iE6529PC90ZXh0Pjx0ZXh0IHg9IjEwNSIgeT0iMzQ4IiBmb250LXNpemU9IjE1IiBmaWxsPSIjNjQ3NDhiIj7qt7zqsbDsmYAg7KCB7JqpIOuylOychOulvCDtmZXsnbjtlZwg65KkIOuLpOydjCDsg4Htg5zrpbwg7ISg7YOd7ZWp64uI64ukLjwvdGV4dD4KICAgIDxyZWN0IHg9Ijg1IiB5PSI0MTUiIHdpZHRoPSIxNDUiIGhlaWdodD0iNTgiIHJ4PSIxNCIgZmlsbD0iI2RjZmNlNyIvPjx0ZXh0IHg9IjE1NyIgeT0iNDUxIiB0ZXh0LWFuY2hvcj0ibWlkZGxlIiBmb250LXNpemU9IjE5IiBmb250LXdlaWdodD0iNzAwIiBmaWxsPSIjMTY2NTM0Ij7inJMg7Iq56rKpPC90ZXh0PjxyZWN0IHg9IjI1MiIgeT0iNDE1IiB3aWR0aD0iMTQ1IiBoZWlnaHQ9IjU4IiByeD0iMTQiIGZpbGw9IiNmZWYzYzciLz48dGV4dCB4PSIzMjQiIHk9IjQ1MSIgdGV4dC1hbmNob3I9Im1pZGRsZSIgZm9udC1zaXplPSIxOSIgZm9udC13ZWlnaHQ9IjcwMCIgZmlsbD0iIzkyNDAwZSI+4oWhIOuztOulmDwvdGV4dD48cmVjdCB4PSI0MTkiIHk9IjQxNSIgd2lkdGg9IjE0NSIgaGVpZ2h0PSI1OCIgcng9IjE0IiBmaWxsPSIjZmVlMmUyIi8+PHRleHQgeD0iNDkxIiB5PSI0NTEiIHRleHQtYW5jaG9yPSJtaWRkbGUiIGZvbnQtc2l6ZT0iMTkiIGZvbnQtd2VpZ2h0PSI3MDAiIGZpbGw9IiM5OTFiMWIiPsOXIO2PkOq4sDwvdGV4dD4KICA8L2c+CiAgPGcgZm9udC1mYW1pbHk9IlByZXRlbmRhcmQsIEFwcGxlIFNEIEdvdGhpYyBOZW8sIHNhbnMtc2VyaWYiPgogICAgPGcgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoNjQwIDE0NSkiPjxyZWN0IHdpZHRoPSI1MDUiIGhlaWdodD0iMTY1IiByeD0iMjQiIGZpbGw9IiNmZmY3ZWQiIHN0cm9rZT0iI2ZkYmE3NCIvPjxyZWN0IHdpZHRoPSI1MDUiIGhlaWdodD0iNDUiIHJ4PSIyNCIgZmlsbD0iI2ZmZWRkNSIvPjx0ZXh0IHg9IjI0IiB5PSIzMCIgZm9udC1zaXplPSIxOCIgZm9udC13ZWlnaHQ9IjcwMCIgZmlsbD0iIzlhMzQxMiI+7LSI6riwIOuPmeyekTwvdGV4dD48dGV4dCB4PSIzMCIgeT0iODYiIGZvbnQtc2l6ZT0iMTciIGZpbGw9IiM0NzU1NjkiPuqygO2GoCDsmYTro4wgMzDqsJw8L3RleHQ+PHBhdGggZD0iTTE3NCA4MGg3NiIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjNjQ3NDhiIiBzdHJva2Utd2lkdGg9IjIuNSIgbWFya2VyLWVuZD0idXJsKCNhKSIvPjx0ZXh0IHg9IjI4MCIgeT0iODYiIGZvbnQtc2l6ZT0iMTciIGZvbnQtd2VpZ2h0PSI3MDAiIGZpbGw9IiMxNjY1MzQiPuyCrOuejOydtCDqs6Drpbgg7IOB7YOcPC90ZXh0Pjx0ZXh0IHg9IjMwIiB5PSIxMzIiIGZvbnQtc2l6ZT0iMTciIGZpbGw9IiM0NzU1NjkiPuyVhOyngSDrs7Tsp4Ag7JWK7J2AIDMz6rCcPC90ZXh0PjxwYXRoIGQ9Ik0xOTQgMTI2aDU2IiBmaWxsPSJub25lIiBzdHJva2U9IiM2NDc0OGIiIHN0cm9rZS13aWR0aD0iMi41IiBtYXJrZXItZW5kPSJ1cmwoI2EpIi8+PHJlY3QgeD0iMjcwIiB5PSIxMDYiIHdpZHRoPSI5NCIgaGVpZ2h0PSIzNyIgcng9IjE4IiBmaWxsPSIjZmVmM2M3Ii8+PHRleHQgeD0iMzE3IiB5PSIxMzEiIHRleHQtYW5jaG9yPSJtaWRkbGUiIGZvbnQtc2l6ZT0iMTYiIGZvbnQtd2VpZ2h0PSI3MDAiIGZpbGw9IiM5MjQwMGUiPuuztOulmDwvdGV4dD48dGV4dCB4PSIzNzciIHk9IjEzMSIgZm9udC1zaXplPSIxNSIgZmlsbD0iI2MyNDEwYyI+7J2Y66+46rCAIOyEnuyehDwvdGV4dD48L2c+CiAgICA8ZyB0cmFuc2Zvcm09InRyYW5zbGF0ZSg2NDAgMzQwKSI+PHJlY3Qgd2lkdGg9IjUwNSIgaGVpZ2h0PSIxNjUiIHJ4PSIyNCIgZmlsbD0iI2YwZmRmNCIgc3Ryb2tlPSIjODZlZmFjIi8+PHJlY3Qgd2lkdGg9IjUwNSIgaGVpZ2h0PSI0NSIgcng9IjI0IiBmaWxsPSIjZGNmY2U3Ii8+PHRleHQgeD0iMjQiIHk9IjMwIiBmb250LXNpemU9IjE4IiBmb250LXdlaWdodD0iNzAwIiBmaWxsPSIjMTY2NTM0Ij7qsJzshKAg7ZuEPC90ZXh0Pjx0ZXh0IHg9IjMwIiB5PSI4NiIgZm9udC1zaXplPSIxNyIgZmlsbD0iIzQ3NTU2OSI+6rKA7YagIOyZhOujjCAzMOqwnDwvdGV4dD48cGF0aCBkPSJNMTc0IDgwaDc2IiBmaWxsPSJub25lIiBzdHJva2U9IiM2NDc0OGIiIHN0cm9rZS13aWR0aD0iMi41IiBtYXJrZXItZW5kPSJ1cmwoI2EpIi8+PHRleHQgeD0iMjgwIiB5PSI4NiIgZm9udC1zaXplPSIxNyIgZm9udC13ZWlnaHQ9IjcwMCIgZmlsbD0iIzE2NjUzNCI+7Iq56rKpIMK3IOuztOulmCDCtyDtj5DquLA8L3RleHQ+PHRleHQgeD0iMzAiIHk9IjEzMiIgZm9udC1zaXplPSIxNyIgZmlsbD0iIzQ3NTU2OSI+7JWE7KeBIOuztOyngCDslYrsnYAgMzPqsJw8L3RleHQ+PHBhdGggZD0iTTE5NCAxMjZoNTYiIGZpbGw9Im5vbmUiIHN0cm9rZT0iIzY0NzQ4YiIgc3Ryb2tlLXdpZHRoPSIyLjUiIG1hcmtlci1lbmQ9InVybCgjYSkiLz48cmVjdCB4PSIyNzAiIHk9IjEwNiIgd2lkdGg9IjEwNCIgaGVpZ2h0PSIzNyIgcng9IjE4IiBmaWxsPSIjZTBlN2ZmIi8+PHRleHQgeD0iMzIyIiB5PSIxMzEiIHRleHQtYW5jaG9yPSJtaWRkbGUiIGZvbnQtc2l6ZT0iMTYiIGZvbnQtd2VpZ2h0PSI3MDAiIGZpbGw9IiM0MzM4Y2EiPuuvuOqygO2GoDwvdGV4dD48dGV4dCB4PSIzODYiIHk9IjEzMSIgZm9udC1zaXplPSIxNSIgZmlsbD0iIzE1ODAzZCI+cmF37JeQIOycoOyngDwvdGV4dD48L2c+CiAgPC9nPgogIDxyZWN0IHg9IjU1IiB5PSI1NTUiIHdpZHRoPSIxMDkwIiBoZWlnaHQ9IjEzNiIgcng9IjI0IiBmaWxsPSIjMTExODI3Ii8+CiAgPGcgZm9udC1mYW1pbHk9IlByZXRlbmRhcmQsIEFwcGxlIFNEIEdvdGhpYyBOZW8sIHNhbnMtc2VyaWYiPjxjaXJjbGUgY3g9IjExNSIgY3k9IjYyMyIgcj0iMjkiIGZpbGw9IiMzMTJlODEiLz48dGV4dCB4PSIxMTUiIHk9IjYzMyIgdGV4dC1hbmNob3I9Im1pZGRsZSIgZm9udC1zaXplPSIzMCIgZmlsbD0iI2ZmZiI+ITwvdGV4dD48dGV4dCB4PSIxNjUiIHk9IjYxMCIgZm9udC1zaXplPSIyMyIgZm9udC13ZWlnaHQ9IjcwMCIgZmlsbD0iI2ZmZiI+7J6Q64+Z7ZmU6rCAIOyCrOuejOydmCDqsrDsoJXsnYQg64yA7IugIOq4sOuhne2VmOyngCDslYrrj4TroZ08L3RleHQ+PHRleHQgeD0iMTY1IiB5PSI2NDYiIGZvbnQtc2l6ZT0iMTciIGZpbGw9IiNjYmQ1ZTEiPuuztOulmOuKlCDigJzqsoDthqDtlojsp4Drp4wg642UIOuFvOydmOqwgCDtlYTsmpTtlZjri6TigJ3ripQg7YyQ64uo7J206rOgLCDrr7jqsoDthqDripQg7JWE7KeBIO2MkOuLqO2VmOyngCDslYrslZjri6TripQg7IOB7YOc7J6F64uI64ukLjwvdGV4dD48L2c+Cjwvc3ZnPgo=\" width=\"1200\" height=\"760\" class=\"img_ev3q\"></p>\n<p><em>보류는 사람이 내린 결정이고 미검토는 아직 결정하지 않았다는 상태입니다.</em></p>\n<p>지금은 검토하지 않은 항목을 <strong>미검토(Unreviewed)</strong> 상태로 따로 남깁니다. 미검토 후보는 원래 위치에서 다음 트리아지를 기다리고 사람이 실제로 결정한 후보만 승격·보류·폐기 상태로 이동합니다. 작은 상태 하나를 추가한 변화지만 자동화가 사람의 판단을 대신 기록하지 못하게 만드는 데 필요했습니다.</p>\n<p>트리아지 결과는 대화 속 동의가 아니라 별도의 결정 데이터로 제출합니다. “좋아 보입니다”라는 AI의 추천과 “팀 규칙으로 채택하겠습니다”라는 사람의 결정을 시스템에서도 구분하려고 만든 장치입니다.</p>\n<h2 class=\"anchor anchorTargetStickyNavbar_Vzrq\" id=\"승격된-후보는-문서-변경-계획이-됩니다\">승격된 후보는 문서 변경 계획이 됩니다<a href=\"https://tech.umosone.ai/compound-triage#%EC%8A%B9%EA%B2%A9%EB%90%9C-%ED%9B%84%EB%B3%B4%EB%8A%94-%EB%AC%B8%EC%84%9C-%EB%B3%80%EA%B2%BD-%EA%B3%84%ED%9A%8D%EC%9D%B4-%EB%90%A9%EB%8B%88%EB%8B%A4\" class=\"hash-link\" aria-label=\"승격된 후보는 문서 변경 계획이 됩니다에 대한 직접 링크\" title=\"승격된 후보는 문서 변경 계획이 됩니다에 대한 직접 링크\" translate=\"no\">​</a></h2>\n<p>그렇다면 채택한 규칙은 어디에 남고 다음 에이전트는 어떻게 사용하는 걸까요?</p>\n<p>WMS Frontend 저장소에는 구조, 구현 패턴, 코딩 규칙, 테스트 방법을 설명하는 문서들이 있습니다. 에이전트는 작업을 시작할 때 먼저 진입 문서를 읽고 현재 작업에 필요한 문서를 찾아가도록 안내받습니다. API를 연동할 때는 API 패턴을, 테스트를 작성할 때는 테스트 규칙을 확인하는 식입니다.</p>\n<p>새로운 세션은 이전 대화를 기억하지 못해도 저장소의 문서는 다시 읽을 수 있습니다. 저희가 문서를 에이전트와 사람이 함께 사용하는 팀의 장기 기억으로 보는 이유입니다.</p>\n<p>현재 컴파운드 트리아지에서 승격된 후보는 비슷한 주제끼리 묶이고 하나의 문서 변경 Plan으로 이어집니다. 어떤 문서의 어느 부분을 바꿀지, 사례 하나를 너무 넓은 규칙으로 일반화하지는 않았는지, 변경 후 무엇을 확인할지를 Plan에 적습니다.</p>\n<p>공용 Drawer 사례에서는 두 티켓을 ‘같은 사건의 진단과 처방’으로 정리했습니다. 필수 prop 주입을 우선 규칙으로 두고, 아직 이 구조를 적용하지 않은 기존 Drawer에만 전체 렌더링 경로 감사를 요구하도록 관계를 정했습니다.</p>\n<div class=\"language-md codeBlockContainer_Ckt0 theme-code-block\" style=\"--prism-color:#393A34;--prism-background-color:#f6f8fa\"><div class=\"codeBlockTitle_OeMC\">2026-07-28-update-shared-drawer-overlay-id-audit-and-ioc-contract.md</div><div class=\"codeBlockContent_QJqH\"><pre tabindex=\"0\" class=\"prism-code language-md codeBlock_bY9V thin-scrollbar\" style=\"color:#393A34;background-color:#f6f8fa\"><code class=\"codeBlockLines_e6Vv\"><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token title important punctuation\" style=\"color:#393A34\">##</span><span class=\"token title important\"> Goal</span><span class=\"token plain\"></span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token plain\" style=\"display:inline-block\"></span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token plain\">공유 상세 Drawer의 overlay ID 계약을 하나의 절로 추가한다.</span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token plain\" style=\"display:inline-block\"></span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token plain\"></span><span class=\"token title important punctuation\" style=\"color:#393A34\">##</span><span class=\"token title important\"> Scope</span><span class=\"token plain\"></span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token plain\" style=\"display:inline-block\"></span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token plain\"></span><span class=\"token list punctuation\" style=\"color:#393A34\">-</span><span class=\"token plain\"> 교차 도메인에서 재사용하는 Drawer는 overlayId를 필수 prop으로 받는다.</span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token plain\"></span><span class=\"token list punctuation\" style=\"color:#393A34\">-</span><span class=\"token plain\"> 기존 열거 방식을 고칠 때는 renderDetail과 타 도메인 렌더링까지 감사한다.</span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token plain\"></span><span class=\"token list punctuation\" style=\"color:#393A34\">-</span><span class=\"token plain\"> 단일 도메인 Drawer와 내부 UI 변경에는 이 절차를 강제하지 않는다.</span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token plain\" style=\"display:inline-block\"></span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token plain\"></span><span class=\"token title important punctuation\" style=\"color:#393A34\">##</span><span class=\"token title important\"> Success Criteria</span><span class=\"token plain\"></span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token plain\" style=\"display:inline-block\"></span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token plain\">한 절만 읽어도 IoC 우선 계약과 기존 Drawer 감사 절차의 관계를 알 수 있다.</span><br></div></code></pre></div></div>\n<p>Plan을 검토한 뒤 <code>docs/llm/url-filter-sync-patterns.md</code>에 <code>Shared Drawer Overlay ID Contract</code> 절을 추가했습니다. 실제 문서에는 규칙과 적용 범위가 다음과 같이 함께 들어갔습니다.</p>\n<div class=\"language-diff codeBlockContainer_Ckt0 theme-code-block\" style=\"--prism-color:#393A34;--prism-background-color:#f6f8fa\"><div class=\"codeBlockContent_QJqH\"><pre tabindex=\"0\" class=\"prism-code language-diff codeBlock_bY9V thin-scrollbar\" style=\"color:#393A34;background-color:#f6f8fa\"><code class=\"codeBlockLines_e6Vv\"><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token inserted-sign inserted prefix inserted\" style=\"color:#36acaa\">+</span><span class=\"token inserted-sign inserted line\" style=\"color:#36acaa\"> 교차 도메인에서 재사용되는 상세 Drawer는 자신이 열릴 수 있는</span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token inserted-sign inserted line\" style=\"color:#36acaa\"></span><span class=\"token inserted-sign inserted prefix inserted\" style=\"color:#36acaa\">+</span><span class=\"token inserted-sign inserted line\" style=\"color:#36acaa\"> overlay ID 목록을 내부에 열거하지 않는다.</span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token inserted-sign inserted line\" style=\"color:#36acaa\"></span><span class=\"token inserted-sign inserted prefix inserted\" style=\"color:#36acaa\">+</span><span class=\"token inserted-sign inserted line\" style=\"color:#36acaa\"> 마운트하는 쪽이 overlayId를 필수 prop으로 전달한다.</span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token inserted-sign inserted line\" style=\"color:#36acaa\"></span><span class=\"token plain\"></span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token plain\"></span><span class=\"token inserted-sign inserted prefix inserted\" style=\"color:#36acaa\">+</span><span class=\"token inserted-sign inserted line\" style=\"color:#36acaa\"> 아직 열거 방식을 쓰는 기존 Drawer를 고칠 때는</span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token inserted-sign inserted line\" style=\"color:#36acaa\"></span><span class=\"token inserted-sign inserted prefix inserted\" style=\"color:#36acaa\">+</span><span class=\"token inserted-sign inserted line\" style=\"color:#36acaa\"> openBottomDrawer 호출부만 보지 않는다.</span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token inserted-sign inserted line\" style=\"color:#36acaa\"></span><span class=\"token inserted-sign inserted prefix inserted\" style=\"color:#36acaa\">+</span><span class=\"token inserted-sign inserted line\" style=\"color:#36acaa\"> 컴포넌트 import, DeepLinkDrawerConfig.renderDetail,</span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token inserted-sign inserted line\" style=\"color:#36acaa\"></span><span class=\"token inserted-sign inserted prefix inserted\" style=\"color:#36acaa\">+</span><span class=\"token inserted-sign inserted line\" style=\"color:#36acaa\"> 다른 도메인 훅의 인라인 렌더링까지 확인한다.</span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token inserted-sign inserted line\" style=\"color:#36acaa\"></span><span class=\"token plain\"></span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token plain\"></span><span class=\"token inserted-sign inserted prefix inserted\" style=\"color:#36acaa\">+</span><span class=\"token inserted-sign inserted line\" style=\"color:#36acaa\"> 단일 도메인 Drawer, 내부 UI 변경, ID 기반 닫기 계약이 없는</span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token inserted-sign inserted line\" style=\"color:#36acaa\"></span><span class=\"token inserted-sign inserted prefix inserted\" style=\"color:#36acaa\">+</span><span class=\"token inserted-sign inserted line\" style=\"color:#36acaa\"> Dialog와 Toast는 이 규칙의 적용 대상이 아니다.</span><br></div></code></pre></div></div>\n<p>다음 에이전트가 공용 Drawer를 새 화면에서 사용하거나 기존 닫기 계약을 손대면, 중앙 ID 목록을 만들면 안 되는 이유와 기존 구조에서 더 확인해야 할 경로를 문서에서 찾을 수 있습니다. 한 작업에서 내린 판단이 다음 작업의 출발점으로 남았습니다.</p>\n<p>여기서 순서를 구분할 필요가 있습니다. <code>overlayId</code>를 필수 prop으로 바꾼 코드 수정은 트리아지 전에 끝났습니다. 컴파운드 트리아지가 해결 코드를 만들어낸 것은 아닙니다. 작업 중 검증한 해결책과 그 판단 기준을 다음 작업에서도 재사용할 수 있도록 보존한 것입니다.</p>\n<p>컴파운드 트리아지가 문서까지 자동으로 고치는 것도 아닙니다. 후보를 수집하고 사람이 분류하고 승격된 후보의 문서 변경 Plan을 만드는 데까지를 담당합니다. 실제 문서 변경과 검증은 별도의 작업으로 남겨두었습니다.</p>\n<h2 class=\"anchor anchorTargetStickyNavbar_Vzrq\" id=\"문서로-설명하고-기계가-확인하게-만들기\">문서로 설명하고, 기계가 확인하게 만들기<a href=\"https://tech.umosone.ai/compound-triage#%EB%AC%B8%EC%84%9C%EB%A1%9C-%EC%84%A4%EB%AA%85%ED%95%98%EA%B3%A0-%EA%B8%B0%EA%B3%84%EA%B0%80-%ED%99%95%EC%9D%B8%ED%95%98%EA%B2%8C-%EB%A7%8C%EB%93%A4%EA%B8%B0\" class=\"hash-link\" aria-label=\"문서로 설명하고, 기계가 확인하게 만들기에 대한 직접 링크\" title=\"문서로 설명하고, 기계가 확인하게 만들기에 대한 직접 링크\" translate=\"no\">​</a></h2>\n<p>문서에 규칙을 남겼다고 해서 모든 문제가 해결되지는 않았습니다. 에이전트가 관련 문장을 놓칠 수도 있고 읽었지만 현재 코드에 어떻게 적용해야 하는지 다르게 해석할 수도 있습니다.</p>\n<p>공용 Drawer 사례에서는 <code>overlayId</code> 필수 prop이 기계적인 안전장치가 됐습니다. 규칙이 필요한 이유와 적용 범위는 문서가 설명하고, 빠진 prop은 TypeScript가 코드 작성 단계에서 찾습니다. 문서와 타입이 서로 다른 일을 맡습니다.</p>\n<p>WMS Frontend에는 이미 이런 실수를 줄이기 위한 프로젝트 전용 lint 규칙도 있습니다. 현재는 Biome의 GritQL 플러그인으로 다음과 같은 패턴을 검사합니다.</p>\n<ul>\n<li class=\"\">번역 함수가 안전하지 않은 시점에 실행되는 코드</li>\n<li class=\"\"><code>renderHook</code> 안에서 렌더링할 때마다 새로운 객체나 배열을 만드는 테스트 코드</li>\n<li class=\"\">검색 버튼과 재조회 동작이 연결되지 않은 검색 필터</li>\n</ul>\n<p>코드를 작성한 뒤 lint를 실행하면 에이전트가 문서를 기억하고 있는지와 관계없이 위반을 찾습니다. 이 자동 검증 체계는 이미 사용하고 있지만 지금까지 컴파운드 트리아지에서 승격된 후보는 모두 문서 변경 Plan으로만 변환했습니다. 기존 lint 규칙이 컴파운드 트리아지를 통해 만들어졌다는 뜻은 아닙니다.</p>\n<p>다음 단계에서는 이 두 흐름을 연결해보려고 합니다. 승격된 교훈은 지금처럼 문서에 배경과 적용 범위, 예외를 남깁니다. 그중 코드만 보고도 위반 여부를 명확히 판정할 수 있는 부분은 타입이나 lint, 테스트, 정적 검사로도 구현합니다.</p>\n<p><img decoding=\"async\" loading=\"lazy\" alt=\"코드만 보고 위반을 명확히 판정할 수 있는지에 따라 문서와 작업 절차에 남길지, 문서와 자동 검증을 함께 적용할지 나누는 기준\" src=\"data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxMjAwIiBoZWlnaHQ9Ijc2MCIgdmlld0JveD0iMCAwIDEyMDAgNzYwIiByb2xlPSJpbWciIGFyaWEtbGFiZWxsZWRieT0idGl0bGUgZGVzYyI+CiAgPHRpdGxlIGlkPSJ0aXRsZSI+7Iq56rKp65CcIOq3nOy5meydhCDrrLjshJzsmYAg7J6Q64+ZIOqygOymneycvOuhnCDrs7TrgrTripQg6riw7KSAPC90aXRsZT4KICA8ZGVzYyBpZD0iZGVzYyI+7L2U65Oc66eMIOuztOqzoCDsnITrsJjsnYQg66qF7ZmV7Z6IIO2MkOygle2VoCDsiJgg7J6I64qU7KeA7JeQIOuUsOudvCDrrLjshJzsmYAg7J6R7JeFIOygiOywqOyXkOunjCDrgqjquLjsp4AsIOusuOyEnOyZgCBsaW50IO2FjOyKpO2KuCDsoJXsoIEg6rKA7IKs6rmM7KeAIO2VqOq7mCDsoIHsmqntlaDsp4Ag6rKw7KCV7ZWc64ukLjwvZGVzYz4KICA8ZGVmcz48ZmlsdGVyIGlkPSJzIiB4PSItMjAlIiB5PSItMjAlIiB3aWR0aD0iMTQwJSIgaGVpZ2h0PSIxNDAlIj48ZmVEcm9wU2hhZG93IGR4PSIwIiBkeT0iOCIgc3RkRGV2aWF0aW9uPSIxMiIgZmxvb2QtY29sb3I9IiMxZTFiNGIiIGZsb29kLW9wYWNpdHk9Ii4xMCIvPjwvZmlsdGVyPjxtYXJrZXIgaWQ9ImEiIHZpZXdCb3g9IjAgMCAxMCAxMCIgcmVmWD0iOCIgcmVmWT0iNSIgbWFya2VyV2lkdGg9IjciIG1hcmtlckhlaWdodD0iNyIgb3JpZW50PSJhdXRvIj48cGF0aCBkPSJNMCAwbDEwIDUtMTAgNXoiIGZpbGw9IiM4YjVjZjYiLz48L21hcmtlcj48L2RlZnM+CiAgPHJlY3Qgd2lkdGg9IjEyMDAiIGhlaWdodD0iNzYwIiByeD0iMzAiIGZpbGw9IiNmOGZhZmMiLz4KICA8dGV4dCB4PSI2MCIgeT0iNjgiIGZvbnQtZmFtaWx5PSJQcmV0ZW5kYXJkLCBBcHBsZSBTRCBHb3RoaWMgTmVvLCBzYW5zLXNlcmlmIiBmb250LXNpemU9IjM0IiBmb250LXdlaWdodD0iNzAwIiBmaWxsPSIjMTExODI3Ij7sirnqsqnrkJwg6rec7LmZ7J2AIOyWtOuUlOyXkCDrsJjsmIHtlaDquYw/PC90ZXh0PgogIDx0ZXh0IHg9IjYwIiB5PSIxMDYiIGZvbnQtZmFtaWx5PSJQcmV0ZW5kYXJkLCBBcHBsZSBTRCBHb3RoaWMgTmVvLCBzYW5zLXNlcmlmIiBmb250LXNpemU9IjE5IiBmaWxsPSIjNjQ3NDhiIj7rqqjrk6Ag6rec7LmZ7J2AIOunpeudveydhCDrrLjshJzsl5Ag64Ko6riw6rOgLCDquLDqs4TqsIAg7YyQ7KCV7ZWgIOyImCDsnojripQg7JyE67CY7JeQ64qUIOyViOyghOyepey5mOulvCDtlZjrgpgg642UIOuRoeuLiOuLpC48L3RleHQ+CiAgPGcgZm9udC1mYW1pbHk9IlByZXRlbmRhcmQsIEFwcGxlIFNEIEdvdGhpYyBOZW8sIHNhbnMtc2VyaWYiIGZpbHRlcj0idXJsKCNzKSI+CiAgICA8cmVjdCB4PSI0MTAiIHk9IjE0NSIgd2lkdGg9IjM4MCIgaGVpZ2h0PSI5NiIgcng9IjIyIiBmaWxsPSIjZmZmIiBzdHJva2U9IiNjNGI1ZmQiIHN0cm9rZS13aWR0aD0iMiIvPjxjaXJjbGUgY3g9IjQ2NSIgY3k9IjE5MyIgcj0iMjYiIGZpbGw9IiNlZGU5ZmUiLz48cGF0aCBkPSJNNDUyIDE4NWgyNk00NTIgMTkzaDIwTTQ1MiAyMDFoMTYiIHN0cm9rZT0iIzdjM2FlZCIgc3Ryb2tlLXdpZHRoPSI0IiBzdHJva2UtbGluZWNhcD0icm91bmQiLz48dGV4dCB4PSI1MTAiIHk9IjE4NCIgZm9udC1zaXplPSIxOSIgZm9udC13ZWlnaHQ9IjcwMCIgZmlsbD0iIzRjMWQ5NSI+7Iq56rKp65CcIENvbXBvdW5kIFRpY2tldDwvdGV4dD48dGV4dCB4PSI1MTAiIHk9IjIxMiIgZm9udC1zaXplPSIxNSIgZmlsbD0iIzY0NzQ4YiI+67Cw6rK9IMK3IOyggeyaqSDrspTsnIQgwrcg7JiI7Jm466W8IO2PrO2VqDwvdGV4dD4KICA8L2c+CiAgPHBhdGggZD0iTTYwMCAyNDF2NTUiIHN0cm9rZT0iIzhiNWNmNiIgc3Ryb2tlLXdpZHRoPSIzIiBtYXJrZXItZW5kPSJ1cmwoI2EpIi8+CiAgPGcgZm9udC1mYW1pbHk9IlByZXRlbmRhcmQsIEFwcGxlIFNEIEdvdGhpYyBOZW8sIHNhbnMtc2VyaWYiPjxwYXRoIGQ9Ik02MDAgMjk4bDE5MCA5NS0xOTAgOTUtMTkwLTk1eiIgZmlsbD0iIzMxMmU4MSIvPjx0ZXh0IHg9IjYwMCIgeT0iMzc2IiB0ZXh0LWFuY2hvcj0ibWlkZGxlIiBmb250LXNpemU9IjIwIiBmb250LXdlaWdodD0iNzAwIiBmaWxsPSIjZmZmIj7svZTrk5zrp4wg67O06rOgIOychOuwmOydhDwvdGV4dD48dGV4dCB4PSI2MDAiIHk9IjQwNiIgdGV4dC1hbmNob3I9Im1pZGRsZSIgZm9udC1zaXplPSIyMCIgZm9udC13ZWlnaHQ9IjcwMCIgZmlsbD0iI2ZmZiI+66qF7ZmV7Z6IIO2MkOygle2VoCDsiJgg7J6I64qU6rCAPzwvdGV4dD48L2c+CiAgPHBhdGggZD0iTTQxMCAzOTNIMjg1djY4IiBmaWxsPSJub25lIiBzdHJva2U9IiM4YjVjZjYiIHN0cm9rZS13aWR0aD0iMyIgbWFya2VyLWVuZD0idXJsKCNhKSIvPjxwYXRoIGQ9Ik03OTAgMzkzaDEyNXY2OCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjOGI1Y2Y2IiBzdHJva2Utd2lkdGg9IjMiIG1hcmtlci1lbmQ9InVybCgjYSkiLz4KICA8ZyBmb250LWZhbWlseT0iUHJldGVuZGFyZCwgQXBwbGUgU0QgR290aGljIE5lbywgc2Fucy1zZXJpZiI+PHJlY3QgeD0iMzI2IiB5PSIzNjMiIHdpZHRoPSI2NCIgaGVpZ2h0PSIzNiIgcng9IjE4IiBmaWxsPSIjZTJlOGYwIi8+PHRleHQgeD0iMzU4IiB5PSIzODgiIHRleHQtYW5jaG9yPSJtaWRkbGUiIGZvbnQtc2l6ZT0iMTYiIGZvbnQtd2VpZ2h0PSI3MDAiIGZpbGw9IiM0NzU1NjkiPuyVhOuLiOyalDwvdGV4dD48cmVjdCB4PSI4MTAiIHk9IjM2MyIgd2lkdGg9IjY0IiBoZWlnaHQ9IjM2IiByeD0iMTgiIGZpbGw9IiNkY2ZjZTciLz48dGV4dCB4PSI4NDIiIHk9IjM4OCIgdGV4dC1hbmNob3I9Im1pZGRsZSIgZm9udC1zaXplPSIxNiIgZm9udC13ZWlnaHQ9IjcwMCIgZmlsbD0iIzE2NjUzNCI+7JiIPC90ZXh0PjwvZz4KICA8ZyBmb250LWZhbWlseT0iUHJldGVuZGFyZCwgQXBwbGUgU0QgR290aGljIE5lbywgc2Fucy1zZXJpZiIgZmlsdGVyPSJ1cmwoI3MpIj4KICAgIDxyZWN0IHg9IjcwIiB5PSI0NjUiIHdpZHRoPSI0MzAiIGhlaWdodD0iMjEwIiByeD0iMjQiIGZpbGw9IiNmZmYiIHN0cm9rZT0iI2NiZDVlMSIvPjxjaXJjbGUgY3g9IjEyNSIgY3k9IjUyMCIgcj0iMjgiIGZpbGw9IiNlZWYyZmYiLz48cGF0aCBkPSJNMTEwIDUwNGgyOHYzMmgtMjh6TTExNiA1MTNoMTZNMTE2IDUyMWgxNk0xMTYgNTI5aDExIiBmaWxsPSJub25lIiBzdHJva2U9IiM0ZjQ2ZTUiIHN0cm9rZS13aWR0aD0iMyIvPjx0ZXh0IHg9IjE3MCIgeT0iNTEzIiBmb250LXNpemU9IjIzIiBmb250LXdlaWdodD0iNzAwIiBmaWxsPSIjMzEyZTgxIj7rrLjshJwgKyDsnpHsl4Ug7KCI7LCoPC90ZXh0Pjx0ZXh0IHg9IjE3MCIgeT0iNTQzIiBmb250LXNpemU9IjE2IiBmaWxsPSIjNjQ3NDhiIj7sgqzrnozqs7wg7JeQ7J207KCE7Yq46rCAIOydtOycoOyZgCDqsr3qs4Trpbwg7J207ZW0PC90ZXh0PjxyZWN0IHg9IjEwNSIgeT0iNTc2IiB3aWR0aD0iMzYwIiBoZWlnaHQ9IjY2IiByeD0iMTUiIGZpbGw9IiNmOGZhZmMiLz48dGV4dCB4PSIxMjUiIHk9IjYwMyIgZm9udC1zaXplPSIxNSIgZm9udC13ZWlnaHQ9IjcwMCIgZmlsbD0iIzQ3NTU2OSI+7JiIOiDsnpHsl4Ug7KCE7JeQIOq0gOugqCDrj4TrqZTsnbjsnZgg66el65297J2EPC90ZXh0Pjx0ZXh0IHg9IjEyNSIgeT0iNjI4IiBmb250LXNpemU9IjE1IiBmb250LXdlaWdodD0iNzAwIiBmaWxsPSIjNDc1NTY5Ij7stqnrtoTtnogg7ZmV7J247ZWc64ukLjwvdGV4dD4KICAgIDxyZWN0IHg9IjcwMCIgeT0iNDY1IiB3aWR0aD0iNDMwIiBoZWlnaHQ9IjIxMCIgcng9IjI0IiBmaWxsPSIjZmZmIiBzdHJva2U9IiM4NmVmYWMiLz48Y2lyY2xlIGN4PSI3NTUiIGN5PSI1MjAiIHI9IjI4IiBmaWxsPSIjZGNmY2U3Ii8+PHBhdGggZD0iTTc0MCA1MjBsMTAgMTAgMjAtMjQiIGZpbGw9Im5vbmUiIHN0cm9rZT0iIzE2YTM0YSIgc3Ryb2tlLXdpZHRoPSI1IiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiLz48dGV4dCB4PSI4MDAiIHk9IjUxMyIgZm9udC1zaXplPSIyMyIgZm9udC13ZWlnaHQ9IjcwMCIgZmlsbD0iIzE0NTMyZCI+66y47IScICsg7J6Q64+ZIOqygOymnTwvdGV4dD48dGV4dCB4PSI4MDAiIHk9IjU0MyIgZm9udC1zaXplPSIxNiIgZmlsbD0iIzY0NzQ4YiI+TGludCDCtyBUZXN0IMK3IOygleyggSDqsoDsgqzroZwg7JyE67CY7J2EIOywqOuLqDwvdGV4dD48cmVjdCB4PSI3MzUiIHk9IjU3NiIgd2lkdGg9IjM2MCIgaGVpZ2h0PSI2NiIgcng9IjE1IiBmaWxsPSIjZjBmZGY0Ii8+PHRleHQgeD0iNzU1IiB5PSI2MDMiIGZvbnQtc2l6ZT0iMTUiIGZvbnQtd2VpZ2h0PSI3MDAiIGZpbGw9IiMxNjY1MzQiPuyYiDog6rO17JqpIOqygOyDiSDtm4XsnYQg7IKs7Jqp7ZWY66m0IOyerOyhsO2ajDwvdGV4dD48dGV4dCB4PSI3NTUiIHk9IjYyOCIgZm9udC1zaXplPSIxNSIgZm9udC13ZWlnaHQ9IjcwMCIgZmlsbD0iIzE2NjUzNCI+7L2c67Cx7J2EIOuwmOuTnOyLnCDsl7DqsrDtlZzri6QuPC90ZXh0PgogIDwvZz4KICA8dGV4dCB4PSI2MDAiIHk9IjcyMiIgdGV4dC1hbmNob3I9Im1pZGRsZSIgZm9udC1mYW1pbHk9IlByZXRlbmRhcmQsIEFwcGxlIFNEIEdvdGhpYyBOZW8sIHNhbnMtc2VyaWYiIGZvbnQtc2l6ZT0iMTciIGZvbnQtd2VpZ2h0PSI2MDAiIGZpbGw9IiM3YzNhZWQiPuusuOyEnOulvCDrjIDssrTtlZjripQg6rKD7J20IOyVhOuLiOudvCwg66y47ISc66eM7Jy866GcIOuGk+y5oCDsiJgg7J6I64qUIOu2gOu2hOydhCDquLDqs4TqsIAg7ZWcIOuyiCDrjZQg7ZmV7J247ZWp64uI64ukLjwvdGV4dD4KPC9zdmc+Cg==\" width=\"1200\" height=\"760\" class=\"img_ev3q\"></p>\n<p><em>모든 규칙의 맥락은 문서에 남기고 기계가 판단할 수 있는 위반만 타입·lint·테스트·정적 검사로 한 번 더 확인합니다.</em></p>\n<p>모든 규칙을 자동 검증으로 만들 수는 없습니다. “공용 Drawer의 전체 렌더링 경로를 확인한다”는 절차는 코드 한 조각만 보고 지켰는지 판단하기 어렵습니다. 이런 내용은 문서와 작업 절차로 전달하는 편이 낫습니다. 반면 필수 <code>overlayId</code> prop처럼 코드 구조로 표현할 수 있는 규칙은 TypeScript가 바로 확인합니다.</p>\n<p>문서에는 규칙이 필요한 이유와 적용 범위를 남기고 기계가 판정할 수 있는 위반은 자동 검증에서 한 번 더 잡는 방식입니다. 문서를 자동 검증으로 대체하는 것이 아니라, 문서만으로 놓치는 부분에 안전장치를 하나 더 두려는 것입니다.</p>\n<h2 class=\"anchor anchorTargetStickyNavbar_Vzrq\" id=\"운영하면서-계속-다듬고-있습니다\">운영하면서 계속 다듬고 있습니다<a href=\"https://tech.umosone.ai/compound-triage#%EC%9A%B4%EC%98%81%ED%95%98%EB%A9%B4%EC%84%9C-%EA%B3%84%EC%86%8D-%EB%8B%A4%EB%93%AC%EA%B3%A0-%EC%9E%88%EC%8A%B5%EB%8B%88%EB%8B%A4\" class=\"hash-link\" aria-label=\"운영하면서 계속 다듬고 있습니다에 대한 직접 링크\" title=\"운영하면서 계속 다듬고 있습니다에 대한 직접 링크\" translate=\"no\">​</a></h2>\n<p>컴파운드 트리아지는 지금도 FE팀에서 운영하며 여러 부분을 고치고 있습니다. 가장 먼저 체감한 문제는 티켓의 양이었습니다. Stop hook의 캡처 기준을 느슨하게 두면 작은 관찰까지 티켓이 되어 쌓입니다. 그러면 한 번의 트리아지에서 모두 검토하지 못하고 다음 회차로 미뤄지는 티켓도 빠르게 늘어납니다. 많이 모으는 것보다 다시 쓸 가치가 있는 경험만 남도록 캡처 기준을 정제하는 일이 중요합니다.</p>\n<p>앞으로 FE팀의 인원이 늘어나면 티켓 수도 함께 늘어날 가능성이 큽니다. 지금의 운영 방식이 늘어난 티켓을 감당할 수 있을지는 아직 알 수 없습니다. 중요한 후보를 놓치지 않으면서도 트리아지가 밀리지 않도록, 티켓을 모으고 검토하는 방식 역시 팀 규모에 맞게 바꿔가야 합니다.</p>\n<p>티켓을 만든 사람도 막상 트리아지를 진행할 때는 당시의 작업 맥락을 잊곤 합니다. 공용 Drawer 티켓도 나중에 다시 읽었을 때 “필수 prop으로 바꾼다”는 결론만 있었다면 안전 재고 화면을 왜 놓쳤는지, 두 티켓을 왜 함께 봐야 하는지 판단하기 어려웠을 것입니다. 그래서 트리아지 세션에서 함께 보는 HTML 화면은 단순히 승격·보류·폐기를 선택하는 도구에 그쳐서는 안 됩니다. 원래 작업을 다시 찾아보지 않아도 무엇을 발견했고 왜 규칙 후보가 되었으며 어디까지 적용하려는지 이해할 수 있어야 합니다. 현재 화면도 이런 정보를 담고 있지만 아직 개선할 부분이 많습니다. 앞으로는 배경지식이 없는 사람도 티켓의 내용을 충분히 이해하고 판단하도록 UI와 정보 구조를 계속 다듬을 예정입니다.</p>\n<p>컴파운드 트리아지가 해야 할 일은 규칙의 개수를 늘리는 것이 아닙니다. FE팀이 WMS Frontend를 개발하며 얻은, 우리 프로젝트에서만 쌓을 수 있는 규칙과 판단 기준을 축적하고 에이전트가 다음 작업에서 꺼내 쓸 컨텍스트로 만드는 일입니다. 컴파운드 트리아지도 이 역할에 맞게 운영 경험을 반영해 계속 개선할 생각입니다.</p>\n<p>공용 Drawer 사례에서는 안전 재고 화면에서 놓칠 뻔한 경로가 두 개의 Raw Ticket으로 남았습니다. 사람은 두 후보를 같은 주제로 승격했고, Plan은 진단과 처방을 하나의 문서 절로 묶었습니다. 다음 에이전트는 이전 대화를 기억하지 못해도 그 문서와 필수 prop 계약에서 같은 판단을 다시 꺼내 쓸 수 있습니다.</p>\n<p>AI 에이전트와 일하면서 같은 실수를 반복하거나 매번 같은 맥락을 다시 설명하고 있다면, 작은 범위에서 컴파운드 트리아지를 직접 운영해보는 것도 추천합니다. 처음부터 완벽한 체계를 만들기보다, 한 번의 작업에서 얻은 교훈을 후보로 남기고 사람이 검토하는 흐름부터 시작해보셔도 좋겠습니다.</p>",
            "url": "https://tech.umosone.ai/compound-triage",
            "title": "AI와 개발하며 배운 것을 팀 규칙으로 남기는 법: WMS의 컴파운드 트리아지",
            "summary": "AI와 함께 개발하며 발견한 시행착오를 후보로 모으고, 사람의 판단을 거쳐 다음 작업에 쓰이는 팀 규칙으로 만드는 과정을 공용 Drawer 사례와 함께 소개합니다.",
            "date_modified": "2026-08-18T00:00:00.000Z",
            "author": {
                "name": "최영호"
            },
            "tags": [
                "AI",
                "AI 에이전트",
                "Compound Triage",
                "WMS"
            ]
        },
        {
            "id": "https://tech.umosone.ai/tap-flutter-migration",
            "content_html": "<p>안드로이드와 iOS로 각각 만들어지던 TAP! 사용자 앱을, 왜 그리고 어떻게 Flutter 하나로 합쳤는지 이야기하고자 합니다.</p>\n<h2 class=\"anchor anchorTargetStickyNavbar_Vzrq\" id=\"1-tap-그리고-저희-팀이-만드는-서비스\">1. TAP!, 그리고 저희 팀이 만드는 서비스<a href=\"https://tech.umosone.ai/tap-flutter-migration#1-tap-%EA%B7%B8%EB%A6%AC%EA%B3%A0-%EC%A0%80%ED%9D%AC-%ED%8C%80%EC%9D%B4-%EB%A7%8C%EB%93%9C%EB%8A%94-%EC%84%9C%EB%B9%84%EC%8A%A4\" class=\"hash-link\" aria-label=\"1. TAP!, 그리고 저희 팀이 만드는 서비스에 대한 직접 링크\" title=\"1. TAP!, 그리고 저희 팀이 만드는 서비스에 대한 직접 링크\" translate=\"no\">​</a></h2>\n<p>안녕하세요, UMOSONE TAP! 개발팀의 민경운입니다.</p>\n<p>TAP!은 승객 이동, 기업 카셰어링, 사내 이동 솔루션까지 아우르는 B2B 모빌리티 서비스입니다. 사용자를 실제로 만나는 접점은 이용자·운전자·운영자가 각자 쓰는 몇 개의 서비스로 나뉩니다.</p>\n<h3 class=\"anchor anchorTargetStickyNavbar_Vzrq\" id=\"tap의-서비스-라인업\">TAP!의 서비스 라인업<a href=\"https://tech.umosone.ai/tap-flutter-migration#tap%EC%9D%98-%EC%84%9C%EB%B9%84%EC%8A%A4-%EB%9D%BC%EC%9D%B8%EC%97%85\" class=\"hash-link\" aria-label=\"TAP!의 서비스 라인업에 대한 직접 링크\" title=\"TAP!의 서비스 라인업에 대한 직접 링크\" translate=\"no\">​</a></h3>\n<p><img decoding=\"async\" loading=\"lazy\" alt=\"TAP! 서비스 라인업 — 사용자 앱, 드라이버 앱, 어드민, 카쉐어링\" src=\"https://tech.umosone.ai/assets/images/tap-service-lineup-47719c112e1a0696c0dc93b409059273.png\" width=\"3840\" height=\"2160\" class=\"img_ev3q\"></p>\n<ul>\n<li class=\"\"><strong>사용자 앱</strong>: 호출부터 승하차까지, 이동의 전 과정을 지원</li>\n<li class=\"\"><strong>드라이버 앱(콘솔)</strong>: 배차부터 운행 완료까지, 드라이버의 하루를 지원</li>\n<li class=\"\"><strong>어드민(운영)</strong>: 전체 운행 현황을 실시간으로 지휘하는 관제 백오피스</li>\n<li class=\"\"><strong>카쉐어링</strong>: 기업을 위한 차량 공유 솔루션</li>\n</ul>\n<p>이 글은 이 중 <strong>사용자 앱</strong>의 Native → Flutter 전환 과정을 다룹니다.</p>\n<h2 class=\"anchor anchorTargetStickyNavbar_Vzrq\" id=\"2-native에서-굳이-flutter로-전환한-계기\">2. Native에서 굳이 Flutter로 전환한 계기<a href=\"https://tech.umosone.ai/tap-flutter-migration#2-native%EC%97%90%EC%84%9C-%EA%B5%B3%EC%9D%B4-flutter%EB%A1%9C-%EC%A0%84%ED%99%98%ED%95%9C-%EA%B3%84%EA%B8%B0\" class=\"hash-link\" aria-label=\"2. Native에서 굳이 Flutter로 전환한 계기에 대한 직접 링크\" title=\"2. Native에서 굳이 Flutter로 전환한 계기에 대한 직접 링크\" translate=\"no\">​</a></h2>\n<p>사용자 앱은 원래 안드로이드와 iOS가 완전히 분리된 두 개의 네이티브 프로젝트였습니다. 하지만 한정된 인원으로 안드로이드와 iOS를 따로따로 대응하는 것이 리소스 관점에서 문제가 있었고, 자연스럽게 \"하나의 플랫폼에서 두 OS를 효율적으로 커버할 방법이 없을까\"를 고민하게 됐습니다.</p>\n<p>다만 크로스플랫폼으로 가기로 방향을 잡았다고 해서 곧바로 프레임워크를 정한 것은 아니었습니다. 가장 유력한 두 후보는 React Native와 Flutter였고, 최종 선택에 앞서 두 프레임워크를 여러 관점에서 비교하는 사전 조사부터 진행했습니다.</p>\n<h3 class=\"anchor anchorTargetStickyNavbar_Vzrq\" id=\"react-native-vs-flutter\">React Native vs Flutter<a href=\"https://tech.umosone.ai/tap-flutter-migration#react-native-vs-flutter\" class=\"hash-link\" aria-label=\"React Native vs Flutter에 대한 직접 링크\" title=\"React Native vs Flutter에 대한 직접 링크\" translate=\"no\">​</a></h3>\n<p>간단히 소개하면, <strong>React Native</strong>는 Meta(구 Facebook)가, <strong>Flutter</strong>는 Google이 만든 크로스플랫폼 프레임워크입니다. 화면을 그리는 방식부터 다릅니다.</p>\n<p><img decoding=\"async\" loading=\"lazy\" alt=\"React Native vs Flutter 렌더링 아키텍처 비교\" src=\"https://tech.umosone.ai/assets/images/rn-vs-flutter-rendering-2ae7878f636eaa2b2971204779a093e4.png\" width=\"3840\" height=\"2160\" class=\"img_ev3q\"></p>\n<p>팀 내부에서 정리했던 비교 자료를 간략히 요약하면 다음과 같습니다.</p>\n<table><thead><tr><th>관점</th><th>React Native</th><th>Flutter</th></tr></thead><tbody><tr><td>렌더링 / 성능</td><td>Bridge를 거치는 구조라 복잡한 UI에서 프레임 저하 가능</td><td>자체 렌더링 엔진(Skia 기반)으로 애니메이션·고난도 UI에 강함</td></tr><tr><td>UI 일관성</td><td>플랫폼 네이티브 컴포넌트에 의존, OS별로 미묘하게 다름</td><td>위젯을 직접 그려 플랫폼 간 동일한 UI 보장</td></tr><tr><td>생태계 / DX</td><td>JS/TS 자산 활용 가능하지만 라이브러리 품질 편차가 큼</td><td>Google 공식 지원, Hot Reload·DevTools 완성도가 높음</td></tr><tr><td>플랫폼 커버리지</td><td>Android/iOS 중심, Web/Desktop은 정식 지원 아님</td><td>Web/Desktop/임베디드까지 지원 범위 확장 중</td></tr><tr><td>학습 난이도</td><td>JS 경험자가 많다면 진입장벽이 낮음</td><td>Dart, Widget Tree를 새로 학습해야 함</td></tr></tbody></table>\n<ul>\n<li class=\"\"><strong>React Native가 유리한 경우</strong>: 이미 JS/TS 기반 조직, 빠른 MVP가 필요할 때</li>\n<li class=\"\"><strong>Flutter가 유리한 경우</strong>: 고품질 UI·애니메이션·성능이 중요할 때, 장기 유지보수가 필요할 때, 여러 플랫폼을 함께 가져가야 할 때</li>\n</ul>\n<p>표로 정리된 일반적인 비교 외에, 실제 판단에는 몇 가지 현실적인 이유가 더 크게 작용했습니다. React Native는 기능 하나를 구현할 때도 서드파티 라이브러리에 의존해야 하는 경우가 많았고, 그 품질과 유지보수 여부가 라이브러리마다 제각각이라 장기적으로 믿고 갈 수 있는 선택인지 확신이 서지 않았습니다. 반면 Flutter는 팀 내에 이미 사용해본 경험이 있는 팀원이 있어 도입 초기 리스크를 줄일 수 있었고, 자체 렌더링 엔진(Skia 기반)을 쓰는 구조 덕분에 여러 성능 비교 지표에서도 꾸준히 우수한 결과를 보여줬습니다.</p>\n<h3 class=\"anchor anchorTargetStickyNavbar_Vzrq\" id=\"최종-결론-flutter를-선택하다\">최종 결론: Flutter를 선택하다<a href=\"https://tech.umosone.ai/tap-flutter-migration#%EC%B5%9C%EC%A2%85-%EA%B2%B0%EB%A1%A0-flutter%EB%A5%BC-%EC%84%A0%ED%83%9D%ED%95%98%EB%8B%A4\" class=\"hash-link\" aria-label=\"최종 결론: Flutter를 선택하다에 대한 직접 링크\" title=\"최종 결론: Flutter를 선택하다에 대한 직접 링크\" translate=\"no\">​</a></h3>\n<p>Flutter는 성능·UI 일관성·멀티플랫폼 확장성에서, React Native는 진입장벽과 기존 JS 자산 활용에서 우세하다는 결론이었습니다. 사용자 앱은 지도·실시간 위치 표현처럼 복잡한 UI와 애니메이션 비중이 컸기 때문에, 최종적으로 <strong>Flutter</strong>를 선택했습니다. 안드로이드·iOS를 넘어 다른 플랫폼까지 확장할 수 있다는 점은 결정적인 이유는 아니었지만, 선택의 폭을 넓혀준다는 점에서는 나쁘지 않은 옵션이었습니다.</p>\n<h3 class=\"anchor anchorTargetStickyNavbar_Vzrq\" id=\"안드로이드-개발자에게-낯설지-않았던-이유\">안드로이드 개발자에게 낯설지 않았던 이유<a href=\"https://tech.umosone.ai/tap-flutter-migration#%EC%95%88%EB%93%9C%EB%A1%9C%EC%9D%B4%EB%93%9C-%EA%B0%9C%EB%B0%9C%EC%9E%90%EC%97%90%EA%B2%8C-%EB%82%AF%EC%84%A4%EC%A7%80-%EC%95%8A%EC%95%98%EB%8D%98-%EC%9D%B4%EC%9C%A0\" class=\"hash-link\" aria-label=\"안드로이드 개발자에게 낯설지 않았던 이유에 대한 직접 링크\" title=\"안드로이드 개발자에게 낯설지 않았던 이유에 대한 직접 링크\" translate=\"no\">​</a></h3>\n<p>막상 선택은 했지만, 안드로이드 네이티브 개발자였던 팀원들에게 Flutter가 완전히 낯선 개념은 아니었습니다. Flutter는 위젯(Widget) 단위로 선언하고 상태가 바뀐 부분만 <code>build()</code>로 다시 그리는데, 이는 이미 써오던 Jetpack Compose의 선언형 UI(Declarative UI) 패러다임과 사실상 같은 사고방식입니다. 명령형으로 뷰를 직접 조작하던 XML 기반 Android View 시스템보다 오히려 Compose 쪽에 가까웠던 덕분에, 팀원들은 Flutter의 위젯 트리와 State 개념에 생각보다 빠르게 적응했습니다.</p>\n<h2 class=\"anchor anchorTargetStickyNavbar_Vzrq\" id=\"3-기존-네이티브-아키텍처를-기준으로-세운-전환-전략\">3. 기존 네이티브 아키텍처를 기준으로 세운 전환 전략<a href=\"https://tech.umosone.ai/tap-flutter-migration#3-%EA%B8%B0%EC%A1%B4-%EB%84%A4%EC%9D%B4%ED%8B%B0%EB%B8%8C-%EC%95%84%ED%82%A4%ED%85%8D%EC%B2%98%EB%A5%BC-%EA%B8%B0%EC%A4%80%EC%9C%BC%EB%A1%9C-%EC%84%B8%EC%9A%B4-%EC%A0%84%ED%99%98-%EC%A0%84%EB%9E%B5\" class=\"hash-link\" aria-label=\"3. 기존 네이티브 아키텍처를 기준으로 세운 전환 전략에 대한 직접 링크\" title=\"3. 기존 네이티브 아키텍처를 기준으로 세운 전환 전략에 대한 직접 링크\" translate=\"no\">​</a></h2>\n<p><img decoding=\"async\" loading=\"lazy\" alt=\"기존 안드로이드 네이티브 아키텍처 구조도\" src=\"https://tech.umosone.ai/assets/images/tap-native-architecture-6746bdbbc791b4e4931897da1d576038.png\" width=\"3840\" height=\"2160\" class=\"img_ev3q\"></p>\n<p>전환 전략을 세울 때 가장 먼저 잡은 원칙은 \"아키텍처를 새로 설계하지 않는다\"였습니다. 기존 안드로이드 네이티브 프로젝트는 위 그림과 같은 레이어링으로 구성되어 있었습니다.</p>\n<h3 class=\"anchor anchorTargetStickyNavbar_Vzrq\" id=\"레이어링은-그대로-자리만-옮기다\">레이어링은 그대로, 자리만 옮기다<a href=\"https://tech.umosone.ai/tap-flutter-migration#%EB%A0%88%EC%9D%B4%EC%96%B4%EB%A7%81%EC%9D%80-%EA%B7%B8%EB%8C%80%EB%A1%9C-%EC%9E%90%EB%A6%AC%EB%A7%8C-%EC%98%AE%EA%B8%B0%EB%8B%A4\" class=\"hash-link\" aria-label=\"레이어링은 그대로, 자리만 옮기다에 대한 직접 링크\" title=\"레이어링은 그대로, 자리만 옮기다에 대한 직접 링크\" translate=\"no\">​</a></h3>\n<p>이미 오래 검증된 레이어링이었기 때문에, 새 구조를 고민하기보다는 이 구조를 그대로 Flutter 프로젝트에 옮기는 쪽을 택했습니다.</p>\n<div class=\"language-text codeBlockContainer_Ckt0 theme-code-block\" style=\"--prism-color:#393A34;--prism-background-color:#f6f8fa\"><div class=\"codeBlockContent_QJqH\"><pre tabindex=\"0\" class=\"prism-code language-text codeBlock_bY9V thin-scrollbar\" style=\"color:#393A34;background-color:#f6f8fa\"><code class=\"codeBlockLines_e6Vv\"><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token plain\">lib/</span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token plain\">├── core/                 # 기존 Core:XX 자리 — network, storage, design_system 등 N개 모듈</span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token plain\">└── feature/              # 기존 Feature 자리 — Feature A, Feature B, Feature C</span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token plain\">    └── home/</span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token plain\">        ├── data/         # 기존 Repository 패턴</span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token plain\">        ├── domain/       # 기존 UseCase 개념</span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token plain\">        └── presentation/ # 화면 + ViewModel</span><br></div></code></pre></div></div>\n<p>각 기능 모듈 내부는 데이터·도메인·화면(프레젠테이션) 세 계층으로 나누어, 기존의 UseCase 개념과 Repository 패턴을 그대로 유지했습니다.</p>\n<p>흥미롭게도 이 구조는 저희만의 선택이 아니라, <a href=\"https://docs.flutter.dev/app-architecture/guide\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"\">Flutter 공식 아키텍처 가이드</a>가 권장하는 형태이기도 합니다. 공식 가이드도 UI 레이어와 Data 레이어를 나누고, 필요하면 그 사이에 UseCase로 구성된 Domain 레이어를 두라고 안내하는데, Jetpack 권장 아키텍처를 써본 안드로이드 개발자라면 익숙한 그림입니다.</p>\n<h3 class=\"anchor anchorTargetStickyNavbar_Vzrq\" id=\"cicd도-그대로-옮기다\">CI/CD도 그대로 옮기다<a href=\"https://tech.umosone.ai/tap-flutter-migration#cicd%EB%8F%84-%EA%B7%B8%EB%8C%80%EB%A1%9C-%EC%98%AE%EA%B8%B0%EB%8B%A4\" class=\"hash-link\" aria-label=\"CI/CD도 그대로 옮기다에 대한 직접 링크\" title=\"CI/CD도 그대로 옮기다에 대한 직접 링크\" translate=\"no\">​</a></h3>\n<p>CI/CD도 새로 설계하기보다는 기존 네이티브 파이프라인의 뼈대를 그대로 옮겼습니다.</p>\n<p><img decoding=\"async\" loading=\"lazy\" alt=\"Android, iOS 배포 파이프라인\" src=\"https://tech.umosone.ai/assets/images/tap-cicd-pipeline-90f31ac04d79f562684dd0c5f196b2bf.png\" width=\"3840\" height=\"2160\" class=\"img_ev3q\"></p>\n<p>기존에는 안드로이드·iOS 빌드 환경이 플랫폼별로 따로 돌아갔는데, 이제는 하나의 파이프라인 안에서 Android 빌드가 끝난 뒤 iOS 빌드가 순차적으로 이어지는 구조로 합쳤습니다. 두 빌드가 동시에 돌면서 배포 버전이 꼬이는 걸 막기 위한 순서이고, 운영 브랜치에 병합되면 자동으로 릴리즈가 생성되는 것도 이전 네이티브 파이프라인과 동일한 방식입니다.</p>\n<h3 class=\"anchor anchorTargetStickyNavbar_Vzrq\" id=\"상태관리-왜-riverpod인가\">상태관리: 왜 Riverpod인가<a href=\"https://tech.umosone.ai/tap-flutter-migration#%EC%83%81%ED%83%9C%EA%B4%80%EB%A6%AC-%EC%99%9C-riverpod%EC%9D%B8%EA%B0%80\" class=\"hash-link\" aria-label=\"상태관리: 왜 Riverpod인가에 대한 직접 링크\" title=\"상태관리: 왜 Riverpod인가에 대한 직접 링크\" translate=\"no\">​</a></h3>\n<p>레이어링과 별개로, Flutter의 상태관리는 어떤 프레임워크로 가져갈지 따로 조사가 필요했습니다. 후보는 Provider, Bloc, GetX, Riverpod이었습니다.</p>\n<ul>\n<li class=\"\"><strong>Provider</strong>: 선언은 간단하지만 컴파일 타임에 의존성 오류를 잡아주지 못하고, <code>BuildContext</code>에 의존하는 구조라 테스트하기 까다로웠습니다.</li>\n<li class=\"\"><strong>Bloc</strong>: Event/State 모델이 명확한 대신, 화면 하나에도 Event·State·Bloc 클래스를 각각 만들어야 해서 보일러플레이트가 많다는 인상이었습니다.</li>\n<li class=\"\"><strong>GetX</strong>: 러닝커브는 낮지만 전역 상태와 의존성 주입이 암묵적으로 얽혀 있어, 규모가 커질수록 추적이 어려울 것이라는 우려가 있었습니다.</li>\n<li class=\"\"><strong>Riverpod</strong>: <code>BuildContext</code> 없이도 상태에 접근할 수 있고, 컴파일 타임에 의존성을 검증할 수 있다는 점이 눈에 띄었습니다.</li>\n</ul>\n<p>무엇보다 Riverpod의 Notifier가 안드로이드에서 써오던 ViewModel과 거의 같은 형태(상태를 들고 있고 UI가 그 상태를 구독하는 구조)라는 점이 크게 작용했습니다. 핵심만 단순화한 예시 코드로 보면 다음과 같은 형태입니다.</p>\n<div class=\"language-dart codeBlockContainer_Ckt0 theme-code-block\" style=\"--prism-color:#393A34;--prism-background-color:#f6f8fa\"><div class=\"codeBlockContent_QJqH\"><pre tabindex=\"0\" class=\"prism-code language-dart codeBlock_bY9V thin-scrollbar\" style=\"color:#393A34;background-color:#f6f8fa\"><code class=\"codeBlockLines_e6Vv\"><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token metadata function\" style=\"color:#d73a49\">@riverpod</span><span class=\"token plain\"></span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token plain\"></span><span class=\"token keyword\" style=\"color:#00009f\">class</span><span class=\"token plain\"> </span><span class=\"token class-name\">RideStatusViewModel</span><span class=\"token plain\"> </span><span class=\"token keyword\" style=\"color:#00009f\">extends</span><span class=\"token plain\"> _$</span><span class=\"token class-name\">RideStatusViewModel</span><span class=\"token plain\"> </span><span class=\"token punctuation\" style=\"color:#393A34\">{</span><span class=\"token plain\"></span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token plain\">  </span><span class=\"token metadata function\" style=\"color:#d73a49\">@override</span><span class=\"token plain\"></span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token plain\">  </span><span class=\"token class-name\">FutureOr</span><span class=\"token generics punctuation\" style=\"color:#393A34\">&lt;</span><span class=\"token generics class-name\">RideStatus</span><span class=\"token generics punctuation\" style=\"color:#393A34\">&gt;</span><span class=\"token plain\"> </span><span class=\"token function\" style=\"color:#d73a49\">build</span><span class=\"token punctuation\" style=\"color:#393A34\">(</span><span class=\"token punctuation\" style=\"color:#393A34\">)</span><span class=\"token plain\"> </span><span class=\"token operator\" style=\"color:#393A34\">=</span><span class=\"token operator\" style=\"color:#393A34\">&gt;</span><span class=\"token plain\"> repository</span><span class=\"token punctuation\" style=\"color:#393A34\">.</span><span class=\"token function\" style=\"color:#d73a49\">fetchStatus</span><span class=\"token punctuation\" style=\"color:#393A34\">(</span><span class=\"token punctuation\" style=\"color:#393A34\">)</span><span class=\"token punctuation\" style=\"color:#393A34\">;</span><span class=\"token plain\"></span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token plain\"></span><span class=\"token punctuation\" style=\"color:#393A34\">}</span><span class=\"token plain\"></span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token plain\" style=\"display:inline-block\"></span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token plain\"></span><span class=\"token keyword\" style=\"color:#00009f\">final</span><span class=\"token plain\"> status </span><span class=\"token operator\" style=\"color:#393A34\">=</span><span class=\"token plain\"> ref</span><span class=\"token punctuation\" style=\"color:#393A34\">.</span><span class=\"token function\" style=\"color:#d73a49\">watch</span><span class=\"token punctuation\" style=\"color:#393A34\">(</span><span class=\"token plain\">rideStatusViewModelProvider</span><span class=\"token punctuation\" style=\"color:#393A34\">)</span><span class=\"token punctuation\" style=\"color:#393A34\">;</span><span class=\"token plain\"></span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token plain\"></span><span class=\"token keyword\" style=\"color:#00009f\">return</span><span class=\"token plain\"> status</span><span class=\"token punctuation\" style=\"color:#393A34\">.</span><span class=\"token function\" style=\"color:#d73a49\">when</span><span class=\"token punctuation\" style=\"color:#393A34\">(</span><span class=\"token plain\"></span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token plain\">  data</span><span class=\"token punctuation\" style=\"color:#393A34\">:</span><span class=\"token plain\"> </span><span class=\"token punctuation\" style=\"color:#393A34\">(</span><span class=\"token plain\">value</span><span class=\"token punctuation\" style=\"color:#393A34\">)</span><span class=\"token plain\"> </span><span class=\"token operator\" style=\"color:#393A34\">=</span><span class=\"token operator\" style=\"color:#393A34\">&gt;</span><span class=\"token plain\"> </span><span class=\"token class-name\">StatusView</span><span class=\"token punctuation\" style=\"color:#393A34\">(</span><span class=\"token plain\">value</span><span class=\"token punctuation\" style=\"color:#393A34\">)</span><span class=\"token punctuation\" style=\"color:#393A34\">,</span><span class=\"token plain\"></span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token plain\">  loading</span><span class=\"token punctuation\" style=\"color:#393A34\">:</span><span class=\"token plain\"> </span><span class=\"token punctuation\" style=\"color:#393A34\">(</span><span class=\"token punctuation\" style=\"color:#393A34\">)</span><span class=\"token plain\"> </span><span class=\"token operator\" style=\"color:#393A34\">=</span><span class=\"token operator\" style=\"color:#393A34\">&gt;</span><span class=\"token plain\"> </span><span class=\"token keyword\" style=\"color:#00009f\">const</span><span class=\"token plain\"> </span><span class=\"token class-name\">LoadingView</span><span class=\"token punctuation\" style=\"color:#393A34\">(</span><span class=\"token punctuation\" style=\"color:#393A34\">)</span><span class=\"token punctuation\" style=\"color:#393A34\">,</span><span class=\"token plain\"></span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token plain\">  error</span><span class=\"token punctuation\" style=\"color:#393A34\">:</span><span class=\"token plain\"> </span><span class=\"token punctuation\" style=\"color:#393A34\">(</span><span class=\"token plain\">e</span><span class=\"token punctuation\" style=\"color:#393A34\">,</span><span class=\"token plain\"> _</span><span class=\"token punctuation\" style=\"color:#393A34\">)</span><span class=\"token plain\"> </span><span class=\"token operator\" style=\"color:#393A34\">=</span><span class=\"token operator\" style=\"color:#393A34\">&gt;</span><span class=\"token plain\"> </span><span class=\"token class-name\">ErrorView</span><span class=\"token punctuation\" style=\"color:#393A34\">(</span><span class=\"token plain\">e</span><span class=\"token punctuation\" style=\"color:#393A34\">)</span><span class=\"token punctuation\" style=\"color:#393A34\">,</span><span class=\"token plain\"></span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token plain\"></span><span class=\"token punctuation\" style=\"color:#393A34\">)</span><span class=\"token punctuation\" style=\"color:#393A34\">;</span><br></div></code></pre></div></div>\n<p>Notifier가 ViewModel 역할을, <code>AsyncValue</code>가 로딩·에러·성공 상태를 표현합니다. 안드로이드에서 ViewModel과 StateFlow를 구독하던 흐름과 사실상 같아서, 상태관리 자체를 새로 배우는 데 드는 적응 비용은 크지 않았습니다.</p>\n<p>Flutter 공식 아키텍처 가이드도 UI 레이어의 패턴으로 MVVM(Model-View-ViewModel)을 명시하고 있어서, Riverpod의 Notifier를 ViewModel처럼 쓰기로 한 결정은 이 가이드와 같은 방향이기도 했습니다.</p>\n<h2 class=\"anchor anchorTargetStickyNavbar_Vzrq\" id=\"4-트러블슈팅--pigeon-네이티브-브릿지가-백그라운드에서-실패하다\">4. 트러블슈팅 ① Pigeon 네이티브 브릿지가 백그라운드에서 실패하다<a href=\"https://tech.umosone.ai/tap-flutter-migration#4-%ED%8A%B8%EB%9F%AC%EB%B8%94%EC%8A%88%ED%8C%85--pigeon-%EB%84%A4%EC%9D%B4%ED%8B%B0%EB%B8%8C-%EB%B8%8C%EB%A6%BF%EC%A7%80%EA%B0%80-%EB%B0%B1%EA%B7%B8%EB%9D%BC%EC%9A%B4%EB%93%9C%EC%97%90%EC%84%9C-%EC%8B%A4%ED%8C%A8%ED%95%98%EB%8B%A4\" class=\"hash-link\" aria-label=\"4. 트러블슈팅 ① Pigeon 네이티브 브릿지가 백그라운드에서 실패하다에 대한 직접 링크\" title=\"4. 트러블슈팅 ① Pigeon 네이티브 브릿지가 백그라운드에서 실패하다에 대한 직접 링크\" translate=\"no\">​</a></h2>\n<p>전환한 사용자 앱에는 Flutter와 네이티브 코드 사이를 타입 안전하게 이어주는 <a href=\"https://pub.dev/packages/pigeon\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"\">Pigeon</a>으로 만든 브릿지가 있습니다. 일부 푸시 알림(iOS Live Activity, Android Live Updates처럼 실시간으로 갱신되는 알림)은 이 브릿지를 통해 네이티브 쪽에서 직접 UI를 그리도록 설계했습니다.</p>\n<h3 class=\"anchor anchorTargetStickyNavbar_Vzrq\" id=\"증상-포그라운드는-멀쩡한데-백그라운드에서만-안-된다\">증상: 포그라운드는 멀쩡한데 백그라운드에서만 안 된다<a href=\"https://tech.umosone.ai/tap-flutter-migration#%EC%A6%9D%EC%83%81-%ED%8F%AC%EA%B7%B8%EB%9D%BC%EC%9A%B4%EB%93%9C%EB%8A%94-%EB%A9%80%EC%A9%A1%ED%95%9C%EB%8D%B0-%EB%B0%B1%EA%B7%B8%EB%9D%BC%EC%9A%B4%EB%93%9C%EC%97%90%EC%84%9C%EB%A7%8C-%EC%95%88-%EB%90%9C%EB%8B%A4\" class=\"hash-link\" aria-label=\"증상: 포그라운드는 멀쩡한데 백그라운드에서만 안 된다에 대한 직접 링크\" title=\"증상: 포그라운드는 멀쩡한데 백그라운드에서만 안 된다에 대한 직접 링크\" translate=\"no\">​</a></h3>\n<p>앱이 백그라운드/종료 상태일 때 푸시가 표시되지 않는 문제가 있었습니다. 포그라운드에서는 멀쩡히 잘 동작했기 때문에, 처음엔 원인을 좁히기가 까다로웠습니다.</p>\n<h3 class=\"anchor anchorTargetStickyNavbar_Vzrq\" id=\"원인-백그라운드-isolate는-다른-세계였다\">원인: 백그라운드 Isolate는 다른 세계였다<a href=\"https://tech.umosone.ai/tap-flutter-migration#%EC%9B%90%EC%9D%B8-%EB%B0%B1%EA%B7%B8%EB%9D%BC%EC%9A%B4%EB%93%9C-isolate%EB%8A%94-%EB%8B%A4%EB%A5%B8-%EC%84%B8%EA%B3%84%EC%98%80%EB%8B%A4\" class=\"hash-link\" aria-label=\"원인: 백그라운드 Isolate는 다른 세계였다에 대한 직접 링크\" title=\"원인: 백그라운드 Isolate는 다른 세계였다에 대한 직접 링크\" translate=\"no\">​</a></h3>\n<p>원인은 FCM(Firebase Cloud Messaging)의 백그라운드 메시지 처리 방식에 있었습니다. 앱이 백그라운드/종료 상태에서 푸시를 받으면, Dart 백그라운드 핸들러는 메인 앱과는 <strong>별도의 Isolate, 별도의 Flutter 엔진 인스턴스</strong>에서 실행됩니다. Pigeon이 만든 플랫폼 채널은 특정 엔진의 <code>BinaryMessenger</code>에 등록되는 구조라, 메인 엔진에서만 등록해둔 Pigeon 핸들러는 이 백그라운드 엔진에서는 호출되지 않았습니다.</p>\n<p>문제는 이 실패가 조용히 일어난다는 점이었습니다. 에러조차 명확히 나지 않고 무시되는 경우가 있어 재현과 원인 파악에 시간이 오래 걸렸습니다. FlutterFire 공식 문서의 <a href=\"https://firebase.flutter.dev/docs/messaging/usage/#background-messages\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"\">백그라운드 메시지 처리 안내</a>에도 이 핸들러가 별도 컨텍스트에서 실행된다는 점이 명시되어 있습니다.</p>\n<h3 class=\"anchor anchorTargetStickyNavbar_Vzrq\" id=\"해결-타이밍이-중요한-알림은-네이티브가-직접\">해결: 타이밍이 중요한 알림은 네이티브가 직접<a href=\"https://tech.umosone.ai/tap-flutter-migration#%ED%95%B4%EA%B2%B0-%ED%83%80%EC%9D%B4%EB%B0%8D%EC%9D%B4-%EC%A4%91%EC%9A%94%ED%95%9C-%EC%95%8C%EB%A6%BC%EC%9D%80-%EB%84%A4%EC%9D%B4%ED%8B%B0%EB%B8%8C%EA%B0%80-%EC%A7%81%EC%A0%91\" class=\"hash-link\" aria-label=\"해결: 타이밍이 중요한 알림은 네이티브가 직접에 대한 직접 링크\" title=\"해결: 타이밍이 중요한 알림은 네이티브가 직접에 대한 직접 링크\" translate=\"no\">​</a></h3>\n<p>네이티브 플러그인을 모든 Flutter 엔진(메인 + 백그라운드)에 등록해 Pigeon 채널을 백그라운드에서도 열어봤지만, <code>urgent</code> 채널처럼 즉시성이 중요한 알림은 여전히 타이밍이 불안정했습니다. 결국 이 알림 타입만큼은 Dart 백그라운드 핸들러를 거치지 않고, 안드로이드의 FCM 서비스 콜백(Dart Isolate가 아니라 시스템 프로세스에서 바로 실행되는 콜백)에서 네이티브가 직접 그리도록 했습니다.</p>\n<div class=\"language-kotlin codeBlockContainer_Ckt0 theme-code-block\" style=\"--prism-color:#393A34;--prism-background-color:#f6f8fa\"><div class=\"codeBlockContent_QJqH\"><pre tabindex=\"0\" class=\"prism-code language-kotlin codeBlock_bY9V thin-scrollbar\" style=\"color:#393A34;background-color:#f6f8fa\"><code class=\"codeBlockLines_e6Vv\"><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token comment\" style=\"color:#999988;font-style:italic\">// Android, 실제 코드를 단순화한 예시</span><span class=\"token plain\"></span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token plain\"></span><span class=\"token keyword\" style=\"color:#00009f\">override</span><span class=\"token plain\"> </span><span class=\"token keyword\" style=\"color:#00009f\">fun</span><span class=\"token plain\"> </span><span class=\"token function\" style=\"color:#d73a49\">onMessageReceived</span><span class=\"token punctuation\" style=\"color:#393A34\">(</span><span class=\"token plain\">message</span><span class=\"token operator\" style=\"color:#393A34\">:</span><span class=\"token plain\"> RemoteMessage</span><span class=\"token punctuation\" style=\"color:#393A34\">)</span><span class=\"token plain\"> </span><span class=\"token punctuation\" style=\"color:#393A34\">{</span><span class=\"token plain\"></span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token plain\">    </span><span class=\"token comment\" style=\"color:#999988;font-style:italic\">// Pigeon 브릿지는 백그라운드 isolate의 BinaryMessenger가 달라 호출이 보장되지 않는다.</span><span class=\"token plain\"></span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token plain\">    </span><span class=\"token keyword\" style=\"color:#00009f\">if</span><span class=\"token plain\"> </span><span class=\"token punctuation\" style=\"color:#393A34\">(</span><span class=\"token plain\">message</span><span class=\"token punctuation\" style=\"color:#393A34\">.</span><span class=\"token plain\">data</span><span class=\"token punctuation\" style=\"color:#393A34\">[</span><span class=\"token string-literal singleline string\" style=\"color:#e3116c\">\"channel\"</span><span class=\"token punctuation\" style=\"color:#393A34\">]</span><span class=\"token plain\"> </span><span class=\"token operator\" style=\"color:#393A34\">==</span><span class=\"token plain\"> </span><span class=\"token string-literal singleline string\" style=\"color:#e3116c\">\"urgent\"</span><span class=\"token punctuation\" style=\"color:#393A34\">)</span><span class=\"token plain\"> </span><span class=\"token punctuation\" style=\"color:#393A34\">{</span><span class=\"token plain\"></span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token plain\">        NativeNotification</span><span class=\"token punctuation\" style=\"color:#393A34\">.</span><span class=\"token function\" style=\"color:#d73a49\">show</span><span class=\"token punctuation\" style=\"color:#393A34\">(</span><span class=\"token plain\">message</span><span class=\"token punctuation\" style=\"color:#393A34\">.</span><span class=\"token plain\">data</span><span class=\"token punctuation\" style=\"color:#393A34\">)</span><span class=\"token plain\"></span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token plain\">        </span><span class=\"token keyword\" style=\"color:#00009f\">return</span><span class=\"token plain\"></span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token plain\">    </span><span class=\"token punctuation\" style=\"color:#393A34\">}</span><span class=\"token plain\"></span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token plain\">    </span><span class=\"token keyword\" style=\"color:#00009f\">super</span><span class=\"token punctuation\" style=\"color:#393A34\">.</span><span class=\"token function\" style=\"color:#d73a49\">onMessageReceived</span><span class=\"token punctuation\" style=\"color:#393A34\">(</span><span class=\"token plain\">message</span><span class=\"token punctuation\" style=\"color:#393A34\">)</span><span class=\"token plain\"> </span><span class=\"token comment\" style=\"color:#999988;font-style:italic\">// 나머지는 Dart 백그라운드 핸들러로 위임</span><span class=\"token plain\"></span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token plain\"></span><span class=\"token punctuation\" style=\"color:#393A34\">}</span><br></div></code></pre></div></div>\n<div class=\"language-dart codeBlockContainer_Ckt0 theme-code-block\" style=\"--prism-color:#393A34;--prism-background-color:#f6f8fa\"><div class=\"codeBlockContent_QJqH\"><pre tabindex=\"0\" class=\"prism-code language-dart codeBlock_bY9V thin-scrollbar\" style=\"color:#393A34;background-color:#f6f8fa\"><code class=\"codeBlockLines_e6Vv\"><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token comment\" style=\"color:#999988;font-style:italic\">// Flutter, 실제 코드를 단순화한 예시</span><span class=\"token plain\"></span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token plain\"></span><span class=\"token metadata function\" style=\"color:#d73a49\">@pragma</span><span class=\"token punctuation\" style=\"color:#393A34\">(</span><span class=\"token string-literal string\" style=\"color:#e3116c\">'vm:entry-point'</span><span class=\"token punctuation\" style=\"color:#393A34\">)</span><span class=\"token plain\"></span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token plain\"></span><span class=\"token class-name\">Future</span><span class=\"token generics punctuation\" style=\"color:#393A34\">&lt;</span><span class=\"token generics keyword\" style=\"color:#00009f\">void</span><span class=\"token generics punctuation\" style=\"color:#393A34\">&gt;</span><span class=\"token plain\"> </span><span class=\"token function\" style=\"color:#d73a49\">fcmBackgroundMessageHandler</span><span class=\"token punctuation\" style=\"color:#393A34\">(</span><span class=\"token class-name\">RemoteMessage</span><span class=\"token plain\"> message</span><span class=\"token punctuation\" style=\"color:#393A34\">)</span><span class=\"token plain\"> </span><span class=\"token keyword\" style=\"color:#00009f\">async</span><span class=\"token plain\"> </span><span class=\"token punctuation\" style=\"color:#393A34\">{</span><span class=\"token plain\"></span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token plain\">  </span><span class=\"token class-name\">WidgetsFlutterBinding</span><span class=\"token punctuation\" style=\"color:#393A34\">.</span><span class=\"token function\" style=\"color:#d73a49\">ensureInitialized</span><span class=\"token punctuation\" style=\"color:#393A34\">(</span><span class=\"token punctuation\" style=\"color:#393A34\">)</span><span class=\"token punctuation\" style=\"color:#393A34\">;</span><span class=\"token plain\"></span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token plain\" style=\"display:inline-block\"></span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token plain\">  </span><span class=\"token keyword\" style=\"color:#00009f\">if</span><span class=\"token plain\"> </span><span class=\"token punctuation\" style=\"color:#393A34\">(</span><span class=\"token plain\">message</span><span class=\"token punctuation\" style=\"color:#393A34\">.</span><span class=\"token plain\">data</span><span class=\"token punctuation\" style=\"color:#393A34\">[</span><span class=\"token string-literal string\" style=\"color:#e3116c\">\"channel\"</span><span class=\"token punctuation\" style=\"color:#393A34\">]</span><span class=\"token plain\"> </span><span class=\"token operator\" style=\"color:#393A34\">==</span><span class=\"token plain\"> </span><span class=\"token string-literal string\" style=\"color:#e3116c\">\"urgent\"</span><span class=\"token punctuation\" style=\"color:#393A34\">)</span><span class=\"token plain\"> </span><span class=\"token punctuation\" style=\"color:#393A34\">{</span><span class=\"token plain\"></span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token plain\">    </span><span class=\"token keyword\" style=\"color:#00009f\">return</span><span class=\"token punctuation\" style=\"color:#393A34\">;</span><span class=\"token plain\"> </span><span class=\"token comment\" style=\"color:#999988;font-style:italic\">// 네이티브 쪽에서 이미 처리했으므로 여기서는 스킵</span><span class=\"token plain\"></span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token plain\">  </span><span class=\"token punctuation\" style=\"color:#393A34\">}</span><span class=\"token plain\"></span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token plain\" style=\"display:inline-block\"></span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token plain\">  </span><span class=\"token keyword\" style=\"color:#00009f\">try</span><span class=\"token plain\"> </span><span class=\"token punctuation\" style=\"color:#393A34\">{</span><span class=\"token plain\"></span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token plain\">    </span><span class=\"token keyword\" style=\"color:#00009f\">await</span><span class=\"token plain\"> </span><span class=\"token function\" style=\"color:#d73a49\">showLocalNotification</span><span class=\"token punctuation\" style=\"color:#393A34\">(</span><span class=\"token plain\">message</span><span class=\"token punctuation\" style=\"color:#393A34\">)</span><span class=\"token punctuation\" style=\"color:#393A34\">;</span><span class=\"token plain\"></span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token plain\">  </span><span class=\"token punctuation\" style=\"color:#393A34\">}</span><span class=\"token plain\"> </span><span class=\"token keyword\" style=\"color:#00009f\">on</span><span class=\"token plain\"> </span><span class=\"token class-name\">PlatformException</span><span class=\"token plain\"> </span><span class=\"token punctuation\" style=\"color:#393A34\">{</span><span class=\"token punctuation\" style=\"color:#393A34\">}</span><span class=\"token plain\"></span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token plain\"></span><span class=\"token punctuation\" style=\"color:#393A34\">}</span><br></div></code></pre></div></div>\n<p>즉시성이 중요하지 않은 나머지 알림은 그대로 Dart 백그라운드 핸들러에서 <code>flutter_local_notifications</code>로 처리하고, 타이밍이 중요한 알림만 네이티브가 직접 책임지는 쪽으로 역할을 나눈 것입니다. Pigeon 자체의 결함이라기보다는, Isolate 경계를 넘나드는 플랫폼 채널의 한계를 인정하고 우회한 사례에 가깝습니다.</p>\n<h2 class=\"anchor anchorTargetStickyNavbar_Vzrq\" id=\"5-트러블슈팅--ios-webview에서만-키보드가-뜨면-화면이-밀려-올라가다\">5. 트러블슈팅 ② iOS WebView에서만 키보드가 뜨면 화면이 밀려 올라가다<a href=\"https://tech.umosone.ai/tap-flutter-migration#5-%ED%8A%B8%EB%9F%AC%EB%B8%94%EC%8A%88%ED%8C%85--ios-webview%EC%97%90%EC%84%9C%EB%A7%8C-%ED%82%A4%EB%B3%B4%EB%93%9C%EA%B0%80-%EB%9C%A8%EB%A9%B4-%ED%99%94%EB%A9%B4%EC%9D%B4-%EB%B0%80%EB%A0%A4-%EC%98%AC%EB%9D%BC%EA%B0%80%EB%8B%A4\" class=\"hash-link\" aria-label=\"5. 트러블슈팅 ② iOS WebView에서만 키보드가 뜨면 화면이 밀려 올라가다에 대한 직접 링크\" title=\"5. 트러블슈팅 ② iOS WebView에서만 키보드가 뜨면 화면이 밀려 올라가다에 대한 직접 링크\" translate=\"no\">​</a></h2>\n<p>사용자 앱 안에는 일부 화면을 완전한 Flutter 네이티브 UI 대신 WebView로 띄우는 페이지가 있습니다.</p>\n<h3 class=\"anchor anchorTargetStickyNavbar_Vzrq\" id=\"증상-ios에서만-화면이-밀린다\">증상: iOS에서만 화면이 밀린다<a href=\"https://tech.umosone.ai/tap-flutter-migration#%EC%A6%9D%EC%83%81-ios%EC%97%90%EC%84%9C%EB%A7%8C-%ED%99%94%EB%A9%B4%EC%9D%B4-%EB%B0%80%EB%A6%B0%EB%8B%A4\" class=\"hash-link\" aria-label=\"증상: iOS에서만 화면이 밀린다에 대한 직접 링크\" title=\"증상: iOS에서만 화면이 밀린다에 대한 직접 링크\" translate=\"no\">​</a></h3>\n<p>텍스트 입력창을 눌러 키보드가 올라올 때, iOS에서만 페이지 레이아웃이 위로 밀리거나 하단 영역이 키보드에 가려지는 현상이 있었습니다. 같은 페이지를 Android WebView에서 열면 문제가 없었기 때문에, 처음엔 앱의 WebView 위젯 설정 문제로 의심했습니다.</p>\n<p><code>flutter/flutter</code> 저장소에도 <a href=\"https://github.com/flutter/flutter/issues/98090\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"\">비슷한 이슈</a>가 등록되어 있었습니다. Safari나 순수 WKWebView에서는 재현되지 않고 Flutter WebView에서만 나타난다는 점까지, 저희가 겪은 상황과 같았습니다.</p>\n<h3 class=\"anchor anchorTargetStickyNavbar_Vzrq\" id=\"1차-확인-flutter-scaffold는-문제가-없었다\">1차 확인: Flutter Scaffold는 문제가 없었다<a href=\"https://tech.umosone.ai/tap-flutter-migration#1%EC%B0%A8-%ED%99%95%EC%9D%B8-flutter-scaffold%EB%8A%94-%EB%AC%B8%EC%A0%9C%EA%B0%80-%EC%97%86%EC%97%88%EB%8B%A4\" class=\"hash-link\" aria-label=\"1차 확인: Flutter Scaffold는 문제가 없었다에 대한 직접 링크\" title=\"1차 확인: Flutter Scaffold는 문제가 없었다에 대한 직접 링크\" translate=\"no\">​</a></h3>\n<p>가장 먼저 확인한 건 Flutter <code>Scaffold</code>의 <code>resizeToAvoidBottomInset</code> 옵션이었습니다. 키보드가 올라올 때 화면(body)을 키보드 높이만큼 자동으로 줄여주는 기능으로 기본값이 <code>true</code>인데, 해당 화면도 이미 <code>true</code>로 켜져 있어 Flutter 쪽은 정상 동작 중이었습니다. Flutter/네이티브 레이어의 문제는 아니라는 뜻이었고, 그래서 원인을 WebView 안쪽, 즉 페이지 자체로 좁힐 수 있었습니다.</p>\n<h3 class=\"anchor anchorTargetStickyNavbar_Vzrq\" id=\"진짜-원인-스크롤을-따라가지-못하는-root\">진짜 원인: 스크롤을 따라가지 못하는 #root<a href=\"https://tech.umosone.ai/tap-flutter-migration#%EC%A7%84%EC%A7%9C-%EC%9B%90%EC%9D%B8-%EC%8A%A4%ED%81%AC%EB%A1%A4%EC%9D%84-%EB%94%B0%EB%9D%BC%EA%B0%80%EC%A7%80-%EB%AA%BB%ED%95%98%EB%8A%94-root\" class=\"hash-link\" aria-label=\"진짜 원인: 스크롤을 따라가지 못하는 #root에 대한 직접 링크\" title=\"진짜 원인: 스크롤을 따라가지 못하는 #root에 대한 직접 링크\" translate=\"no\">​</a></h3>\n<p>원인을 웹팀과 함께 뜯어보니, 핵심은 iOS Safari의 스크롤 동작과 페이지 구조가 맞물린 문제였습니다.</p>\n<ul>\n<li class=\"\">텍스트 인풋에 포커스가 잡히면, iOS Safari는 그 인풋이 화면에 보이도록 <strong>document 자체를 <code>scrollY</code>만큼 스크롤</strong>시킵니다.</li>\n<li class=\"\">문제는 앱의 루트 엘리먼트(<code>#root</code>)가 document 맨 위에 고정된 채였다는 점입니다. document는 스크롤되는데 <code>#root</code>는 그 스크롤을 따라가지 않으니, 실제로는 화면이 위로 밀린 게 아니라 <strong><code>#root</code>는 원래 자리에 그대로 있고 그 위로 빈 document 배경이 드러나는</strong> 모습이었습니다. 저희가 영상에서 본, 화면 위쪽에 정체불명의 빈 공간이 잠깐 나타나는 현상이 정확히 이것이었습니다.</li>\n<li class=\"\">여기에 iOS는 키보드를 위한 여유 공간을 만들려고 스크롤 가능한 범위 자체를 실제 콘텐츠보다 늘리는 **가상 영역(virtual space)**까지 만드는데, 이 여유 공간 때문에 스크롤이 필요 이상으로 멀리까지 가능해지는 문제도 겹쳤습니다.</li>\n</ul>\n<p>비슷한 증상은 Flutter 이슈 트래커에도 보고되어 있습니다. iOS 17.2에서 WebView 안 입력 포커스를 옮길 때 화면 상단에 정체불명의 빈 영역이 생기는 <a href=\"https://github.com/flutter/flutter/issues/140501\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"\">사례</a>는 <code>webview_flutter</code>뿐 아니라 <code>flutter_inappwebview</code>에서도 똑같이 보고돼 있어서, 결이 비슷한 문제로 보입니다. 다만 저희 경우는 웹 페이지 자체의 스크롤·포지셔닝 구조에서 원인을 구체적으로 특정할 수 있었습니다.</p>\n<img src=\"https://tech.umosone.ai/img/blog/webview-ios-keyboard-issue.gif\" alt=\"iOS WebView에서 키보드가 올라올 때 화면이 밀리는 현상\" width=\"280\" style=\"display:block;margin:0 auto\">\n<h3 class=\"anchor anchorTargetStickyNavbar_Vzrq\" id=\"해결-visualviewport로-root를-다시-붙잡다\">해결: visualViewport로 #root를 다시 붙잡다<a href=\"https://tech.umosone.ai/tap-flutter-migration#%ED%95%B4%EA%B2%B0-visualviewport%EB%A1%9C-root%EB%A5%BC-%EB%8B%A4%EC%8B%9C-%EB%B6%99%EC%9E%A1%EB%8B%A4\" class=\"hash-link\" aria-label=\"해결: visualViewport로 #root를 다시 붙잡다에 대한 직접 링크\" title=\"해결: visualViewport로 #root를 다시 붙잡다에 대한 직접 링크\" translate=\"no\">​</a></h3>\n<p>웹팀이 실제로 적용한 대응은 <code>#root</code> 높이 조정, 스크롤 위치 보정, 가상 영역 clamp를 하나의 훅으로 묶은 형태였습니다.</p>\n<div class=\"language-ts codeBlockContainer_Ckt0 theme-code-block\" style=\"--prism-color:#393A34;--prism-background-color:#f6f8fa\"><div class=\"codeBlockContent_QJqH\"><pre tabindex=\"0\" class=\"prism-code language-ts codeBlock_bY9V thin-scrollbar\" style=\"color:#393A34;background-color:#f6f8fa\"><code class=\"codeBlockLines_e6Vv\"><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token keyword\" style=\"color:#00009f\">export</span><span class=\"token plain\"> </span><span class=\"token keyword\" style=\"color:#00009f\">const</span><span class=\"token plain\"> </span><span class=\"token function-variable function\" style=\"color:#d73a49\">useKeyboardViewport</span><span class=\"token plain\"> </span><span class=\"token operator\" style=\"color:#393A34\">=</span><span class=\"token plain\"> </span><span class=\"token punctuation\" style=\"color:#393A34\">(</span><span class=\"token punctuation\" style=\"color:#393A34\">)</span><span class=\"token plain\"> </span><span class=\"token operator\" style=\"color:#393A34\">=&gt;</span><span class=\"token plain\"> </span><span class=\"token punctuation\" style=\"color:#393A34\">{</span><span class=\"token plain\"></span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token plain\">  </span><span class=\"token function\" style=\"color:#d73a49\">useEffect</span><span class=\"token punctuation\" style=\"color:#393A34\">(</span><span class=\"token punctuation\" style=\"color:#393A34\">(</span><span class=\"token punctuation\" style=\"color:#393A34\">)</span><span class=\"token plain\"> </span><span class=\"token operator\" style=\"color:#393A34\">=&gt;</span><span class=\"token plain\"> </span><span class=\"token punctuation\" style=\"color:#393A34\">{</span><span class=\"token plain\"></span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token plain\">    </span><span class=\"token keyword\" style=\"color:#00009f\">const</span><span class=\"token plain\"> viewport </span><span class=\"token operator\" style=\"color:#393A34\">=</span><span class=\"token plain\"> window</span><span class=\"token punctuation\" style=\"color:#393A34\">.</span><span class=\"token plain\">visualViewport</span><span class=\"token punctuation\" style=\"color:#393A34\">;</span><span class=\"token plain\"></span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token plain\">    </span><span class=\"token keyword\" style=\"color:#00009f\">const</span><span class=\"token plain\"> root </span><span class=\"token operator\" style=\"color:#393A34\">=</span><span class=\"token plain\"> document</span><span class=\"token punctuation\" style=\"color:#393A34\">.</span><span class=\"token function\" style=\"color:#d73a49\">getElementById</span><span class=\"token punctuation\" style=\"color:#393A34\">(</span><span class=\"token string\" style=\"color:#e3116c\">'root'</span><span class=\"token punctuation\" style=\"color:#393A34\">)</span><span class=\"token punctuation\" style=\"color:#393A34\">;</span><span class=\"token plain\"></span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token plain\" style=\"display:inline-block\"></span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token plain\">    </span><span class=\"token keyword\" style=\"color:#00009f\">if</span><span class=\"token plain\"> </span><span class=\"token punctuation\" style=\"color:#393A34\">(</span><span class=\"token operator\" style=\"color:#393A34\">!</span><span class=\"token plain\">viewport </span><span class=\"token operator\" style=\"color:#393A34\">||</span><span class=\"token plain\"> </span><span class=\"token operator\" style=\"color:#393A34\">!</span><span class=\"token plain\">root</span><span class=\"token punctuation\" style=\"color:#393A34\">)</span><span class=\"token plain\"> </span><span class=\"token keyword\" style=\"color:#00009f\">return</span><span class=\"token punctuation\" style=\"color:#393A34\">;</span><span class=\"token plain\"></span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token plain\" style=\"display:inline-block\"></span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token plain\">    </span><span class=\"token keyword\" style=\"color:#00009f\">const</span><span class=\"token plain\"> </span><span class=\"token function-variable function\" style=\"color:#d73a49\">sync</span><span class=\"token plain\"> </span><span class=\"token operator\" style=\"color:#393A34\">=</span><span class=\"token plain\"> </span><span class=\"token punctuation\" style=\"color:#393A34\">(</span><span class=\"token punctuation\" style=\"color:#393A34\">)</span><span class=\"token plain\"> </span><span class=\"token operator\" style=\"color:#393A34\">=&gt;</span><span class=\"token plain\"> </span><span class=\"token punctuation\" style=\"color:#393A34\">{</span><span class=\"token plain\"></span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token plain\">      </span><span class=\"token keyword\" style=\"color:#00009f\">const</span><span class=\"token plain\"> isKeyboardOpen </span><span class=\"token operator\" style=\"color:#393A34\">=</span><span class=\"token plain\"> window</span><span class=\"token punctuation\" style=\"color:#393A34\">.</span><span class=\"token plain\">innerHeight </span><span class=\"token operator\" style=\"color:#393A34\">&gt;</span><span class=\"token plain\"> viewport</span><span class=\"token punctuation\" style=\"color:#393A34\">.</span><span class=\"token plain\">height</span><span class=\"token punctuation\" style=\"color:#393A34\">;</span><span class=\"token plain\"></span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token plain\" style=\"display:inline-block\"></span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token plain\">      </span><span class=\"token keyword\" style=\"color:#00009f\">if</span><span class=\"token plain\"> </span><span class=\"token punctuation\" style=\"color:#393A34\">(</span><span class=\"token plain\">isKeyboardOpen</span><span class=\"token punctuation\" style=\"color:#393A34\">)</span><span class=\"token plain\"> </span><span class=\"token punctuation\" style=\"color:#393A34\">{</span><span class=\"token plain\"></span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token plain\">        root</span><span class=\"token punctuation\" style=\"color:#393A34\">.</span><span class=\"token plain\">style</span><span class=\"token punctuation\" style=\"color:#393A34\">.</span><span class=\"token plain\">height </span><span class=\"token operator\" style=\"color:#393A34\">=</span><span class=\"token plain\"> </span><span class=\"token template-string template-punctuation string\" style=\"color:#e3116c\">`</span><span class=\"token template-string interpolation interpolation-punctuation punctuation\" style=\"color:#393A34\">${</span><span class=\"token template-string interpolation\">viewport</span><span class=\"token template-string interpolation punctuation\" style=\"color:#393A34\">.</span><span class=\"token template-string interpolation\">height</span><span class=\"token template-string interpolation interpolation-punctuation punctuation\" style=\"color:#393A34\">}</span><span class=\"token template-string string\" style=\"color:#e3116c\">px</span><span class=\"token template-string template-punctuation string\" style=\"color:#e3116c\">`</span><span class=\"token punctuation\" style=\"color:#393A34\">;</span><span class=\"token plain\"></span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token plain\" style=\"display:inline-block\"></span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token plain\">        </span><span class=\"token comment\" style=\"color:#999988;font-style:italic\">// 가상 영역으로의 스크롤 방지</span><span class=\"token plain\"></span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token plain\">        </span><span class=\"token keyword\" style=\"color:#00009f\">const</span><span class=\"token plain\"> maxScrollY </span><span class=\"token operator\" style=\"color:#393A34\">=</span><span class=\"token plain\"> document</span><span class=\"token punctuation\" style=\"color:#393A34\">.</span><span class=\"token plain\">body</span><span class=\"token punctuation\" style=\"color:#393A34\">.</span><span class=\"token plain\">offsetHeight </span><span class=\"token operator\" style=\"color:#393A34\">-</span><span class=\"token plain\"> viewport</span><span class=\"token punctuation\" style=\"color:#393A34\">.</span><span class=\"token plain\">height </span><span class=\"token operator\" style=\"color:#393A34\">-</span><span class=\"token plain\"> </span><span class=\"token number\" style=\"color:#36acaa\">1</span><span class=\"token punctuation\" style=\"color:#393A34\">;</span><span class=\"token plain\"></span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token plain\">        </span><span class=\"token keyword\" style=\"color:#00009f\">if</span><span class=\"token plain\"> </span><span class=\"token punctuation\" style=\"color:#393A34\">(</span><span class=\"token plain\">window</span><span class=\"token punctuation\" style=\"color:#393A34\">.</span><span class=\"token plain\">scrollY </span><span class=\"token operator\" style=\"color:#393A34\">&gt;</span><span class=\"token plain\"> maxScrollY</span><span class=\"token punctuation\" style=\"color:#393A34\">)</span><span class=\"token plain\"> </span><span class=\"token punctuation\" style=\"color:#393A34\">{</span><span class=\"token plain\"></span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token plain\">          window</span><span class=\"token punctuation\" style=\"color:#393A34\">.</span><span class=\"token function\" style=\"color:#d73a49\">scrollTo</span><span class=\"token punctuation\" style=\"color:#393A34\">(</span><span class=\"token number\" style=\"color:#36acaa\">0</span><span class=\"token punctuation\" style=\"color:#393A34\">,</span><span class=\"token plain\"> maxScrollY</span><span class=\"token punctuation\" style=\"color:#393A34\">)</span><span class=\"token punctuation\" style=\"color:#393A34\">;</span><span class=\"token plain\"></span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token plain\">        </span><span class=\"token punctuation\" style=\"color:#393A34\">}</span><span class=\"token plain\"></span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token plain\">      </span><span class=\"token punctuation\" style=\"color:#393A34\">}</span><span class=\"token plain\"> </span><span class=\"token keyword\" style=\"color:#00009f\">else</span><span class=\"token plain\"> </span><span class=\"token punctuation\" style=\"color:#393A34\">{</span><span class=\"token plain\"></span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token plain\">        root</span><span class=\"token punctuation\" style=\"color:#393A34\">.</span><span class=\"token plain\">style</span><span class=\"token punctuation\" style=\"color:#393A34\">.</span><span class=\"token plain\">height </span><span class=\"token operator\" style=\"color:#393A34\">=</span><span class=\"token plain\"> </span><span class=\"token string\" style=\"color:#e3116c\">''</span><span class=\"token punctuation\" style=\"color:#393A34\">;</span><span class=\"token plain\"></span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token plain\">      </span><span class=\"token punctuation\" style=\"color:#393A34\">}</span><span class=\"token plain\"></span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token plain\" style=\"display:inline-block\"></span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token plain\">      </span><span class=\"token comment\" style=\"color:#999988;font-style:italic\">// 브라우저 scrollY 보정: root가 스크롤을 따라가도록 이동</span><span class=\"token plain\"></span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token plain\">      </span><span class=\"token keyword\" style=\"color:#00009f\">const</span><span class=\"token plain\"> viewportTopGap </span><span class=\"token operator\" style=\"color:#393A34\">=</span><span class=\"token plain\"> viewport</span><span class=\"token punctuation\" style=\"color:#393A34\">.</span><span class=\"token plain\">pageTop </span><span class=\"token operator\" style=\"color:#393A34\">-</span><span class=\"token plain\"> viewport</span><span class=\"token punctuation\" style=\"color:#393A34\">.</span><span class=\"token plain\">offsetTop</span><span class=\"token punctuation\" style=\"color:#393A34\">;</span><span class=\"token plain\"></span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token plain\">      </span><span class=\"token keyword\" style=\"color:#00009f\">const</span><span class=\"token plain\"> translateY </span><span class=\"token operator\" style=\"color:#393A34\">=</span><span class=\"token plain\"> window</span><span class=\"token punctuation\" style=\"color:#393A34\">.</span><span class=\"token plain\">scrollY </span><span class=\"token operator\" style=\"color:#393A34\">-</span><span class=\"token plain\"> viewportTopGap</span><span class=\"token punctuation\" style=\"color:#393A34\">;</span><span class=\"token plain\"></span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token plain\">      root</span><span class=\"token punctuation\" style=\"color:#393A34\">.</span><span class=\"token plain\">style</span><span class=\"token punctuation\" style=\"color:#393A34\">.</span><span class=\"token plain\">transform </span><span class=\"token operator\" style=\"color:#393A34\">=</span><span class=\"token plain\"> translateY </span><span class=\"token operator\" style=\"color:#393A34\">!==</span><span class=\"token plain\"> </span><span class=\"token number\" style=\"color:#36acaa\">0</span><span class=\"token plain\"> </span><span class=\"token operator\" style=\"color:#393A34\">?</span><span class=\"token plain\"> </span><span class=\"token template-string template-punctuation string\" style=\"color:#e3116c\">`</span><span class=\"token template-string string\" style=\"color:#e3116c\">translateY(</span><span class=\"token template-string interpolation interpolation-punctuation punctuation\" style=\"color:#393A34\">${</span><span class=\"token template-string interpolation\">translateY</span><span class=\"token template-string interpolation interpolation-punctuation punctuation\" style=\"color:#393A34\">}</span><span class=\"token template-string string\" style=\"color:#e3116c\">px)</span><span class=\"token template-string template-punctuation string\" style=\"color:#e3116c\">`</span><span class=\"token plain\"> </span><span class=\"token operator\" style=\"color:#393A34\">:</span><span class=\"token plain\"> </span><span class=\"token string\" style=\"color:#e3116c\">''</span><span class=\"token punctuation\" style=\"color:#393A34\">;</span><span class=\"token plain\"></span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token plain\">    </span><span class=\"token punctuation\" style=\"color:#393A34\">}</span><span class=\"token punctuation\" style=\"color:#393A34\">;</span><span class=\"token plain\"></span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token plain\" style=\"display:inline-block\"></span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token plain\">    viewport</span><span class=\"token punctuation\" style=\"color:#393A34\">.</span><span class=\"token function\" style=\"color:#d73a49\">addEventListener</span><span class=\"token punctuation\" style=\"color:#393A34\">(</span><span class=\"token string\" style=\"color:#e3116c\">'resize'</span><span class=\"token punctuation\" style=\"color:#393A34\">,</span><span class=\"token plain\"> sync</span><span class=\"token punctuation\" style=\"color:#393A34\">)</span><span class=\"token punctuation\" style=\"color:#393A34\">;</span><span class=\"token plain\"></span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token plain\">    viewport</span><span class=\"token punctuation\" style=\"color:#393A34\">.</span><span class=\"token function\" style=\"color:#d73a49\">addEventListener</span><span class=\"token punctuation\" style=\"color:#393A34\">(</span><span class=\"token string\" style=\"color:#e3116c\">'scroll'</span><span class=\"token punctuation\" style=\"color:#393A34\">,</span><span class=\"token plain\"> sync</span><span class=\"token punctuation\" style=\"color:#393A34\">)</span><span class=\"token punctuation\" style=\"color:#393A34\">;</span><span class=\"token plain\"></span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token plain\">    window</span><span class=\"token punctuation\" style=\"color:#393A34\">.</span><span class=\"token function\" style=\"color:#d73a49\">addEventListener</span><span class=\"token punctuation\" style=\"color:#393A34\">(</span><span class=\"token string\" style=\"color:#e3116c\">'scroll'</span><span class=\"token punctuation\" style=\"color:#393A34\">,</span><span class=\"token plain\"> sync</span><span class=\"token punctuation\" style=\"color:#393A34\">)</span><span class=\"token punctuation\" style=\"color:#393A34\">;</span><span class=\"token plain\"></span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token plain\" style=\"display:inline-block\"></span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token plain\">    </span><span class=\"token keyword\" style=\"color:#00009f\">return</span><span class=\"token plain\"> </span><span class=\"token punctuation\" style=\"color:#393A34\">(</span><span class=\"token punctuation\" style=\"color:#393A34\">)</span><span class=\"token plain\"> </span><span class=\"token operator\" style=\"color:#393A34\">=&gt;</span><span class=\"token plain\"> </span><span class=\"token punctuation\" style=\"color:#393A34\">{</span><span class=\"token plain\"></span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token plain\">      viewport</span><span class=\"token punctuation\" style=\"color:#393A34\">.</span><span class=\"token function\" style=\"color:#d73a49\">removeEventListener</span><span class=\"token punctuation\" style=\"color:#393A34\">(</span><span class=\"token string\" style=\"color:#e3116c\">'resize'</span><span class=\"token punctuation\" style=\"color:#393A34\">,</span><span class=\"token plain\"> sync</span><span class=\"token punctuation\" style=\"color:#393A34\">)</span><span class=\"token punctuation\" style=\"color:#393A34\">;</span><span class=\"token plain\"></span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token plain\">      viewport</span><span class=\"token punctuation\" style=\"color:#393A34\">.</span><span class=\"token function\" style=\"color:#d73a49\">removeEventListener</span><span class=\"token punctuation\" style=\"color:#393A34\">(</span><span class=\"token string\" style=\"color:#e3116c\">'scroll'</span><span class=\"token punctuation\" style=\"color:#393A34\">,</span><span class=\"token plain\"> sync</span><span class=\"token punctuation\" style=\"color:#393A34\">)</span><span class=\"token punctuation\" style=\"color:#393A34\">;</span><span class=\"token plain\"></span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token plain\">      window</span><span class=\"token punctuation\" style=\"color:#393A34\">.</span><span class=\"token function\" style=\"color:#d73a49\">removeEventListener</span><span class=\"token punctuation\" style=\"color:#393A34\">(</span><span class=\"token string\" style=\"color:#e3116c\">'scroll'</span><span class=\"token punctuation\" style=\"color:#393A34\">,</span><span class=\"token plain\"> sync</span><span class=\"token punctuation\" style=\"color:#393A34\">)</span><span class=\"token punctuation\" style=\"color:#393A34\">;</span><span class=\"token plain\"></span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token plain\">      root</span><span class=\"token punctuation\" style=\"color:#393A34\">.</span><span class=\"token plain\">style</span><span class=\"token punctuation\" style=\"color:#393A34\">.</span><span class=\"token plain\">height </span><span class=\"token operator\" style=\"color:#393A34\">=</span><span class=\"token plain\"> </span><span class=\"token string\" style=\"color:#e3116c\">''</span><span class=\"token punctuation\" style=\"color:#393A34\">;</span><span class=\"token plain\"></span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token plain\">      root</span><span class=\"token punctuation\" style=\"color:#393A34\">.</span><span class=\"token plain\">style</span><span class=\"token punctuation\" style=\"color:#393A34\">.</span><span class=\"token plain\">transform </span><span class=\"token operator\" style=\"color:#393A34\">=</span><span class=\"token plain\"> </span><span class=\"token string\" style=\"color:#e3116c\">''</span><span class=\"token punctuation\" style=\"color:#393A34\">;</span><span class=\"token plain\"></span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token plain\">    </span><span class=\"token punctuation\" style=\"color:#393A34\">}</span><span class=\"token punctuation\" style=\"color:#393A34\">;</span><span class=\"token plain\"></span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token plain\">  </span><span class=\"token punctuation\" style=\"color:#393A34\">}</span><span class=\"token punctuation\" style=\"color:#393A34\">,</span><span class=\"token plain\"> </span><span class=\"token punctuation\" style=\"color:#393A34\">[</span><span class=\"token punctuation\" style=\"color:#393A34\">]</span><span class=\"token punctuation\" style=\"color:#393A34\">)</span><span class=\"token punctuation\" style=\"color:#393A34\">;</span><span class=\"token plain\"></span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token plain\"></span><span class=\"token punctuation\" style=\"color:#393A34\">}</span><span class=\"token punctuation\" style=\"color:#393A34\">;</span><br></div></code></pre></div></div>\n<p>키보드가 열려 있는 동안은 <code>#root</code> 높이를 줄이고 스크롤 위치를 clamp하고, 닫히면 원래 상태로 되돌립니다. <code>visualViewport</code>의 <code>resize</code>/<code>scroll</code>과 <code>window</code>의 <code>scroll</code>을 모두 구독해서, 키보드가 뜨고 닫히는 순간뿐 아니라 그 사이에 일어나는 스크롤에도 계속 위치를 맞춰줍니다.</p>\n<p>앱을 감싸는 WebView 위에서 벌어지는 플랫폼 차이가 항상 Flutter나 네이티브 레이어의 문제인 것은 아니었습니다. 이번 경우는 문제가 실제로 발생하는 지점(WebView 컨테이너인지, 그 안의 웹 페이지인지)을 먼저 구분한 덕분에, 앱 쪽을 붙잡고 있는 대신 웹팀과 빠르게 원인을 공유하고 해결할 수 있었습니다.</p>\n<h2 class=\"anchor anchorTargetStickyNavbar_Vzrq\" id=\"6-트러블슈팅--flutter-sdk가-없는-지도를-네이티브로-직접-연동하다\">6. 트러블슈팅 ③ Flutter SDK가 없는 지도를 네이티브로 직접 연동하다<a href=\"https://tech.umosone.ai/tap-flutter-migration#6-%ED%8A%B8%EB%9F%AC%EB%B8%94%EC%8A%88%ED%8C%85--flutter-sdk%EA%B0%80-%EC%97%86%EB%8A%94-%EC%A7%80%EB%8F%84%EB%A5%BC-%EB%84%A4%EC%9D%B4%ED%8B%B0%EB%B8%8C%EB%A1%9C-%EC%A7%81%EC%A0%91-%EC%97%B0%EB%8F%99%ED%95%98%EB%8B%A4\" class=\"hash-link\" aria-label=\"6. 트러블슈팅 ③ Flutter SDK가 없는 지도를 네이티브로 직접 연동하다에 대한 직접 링크\" title=\"6. 트러블슈팅 ③ Flutter SDK가 없는 지도를 네이티브로 직접 연동하다에 대한 직접 링크\" translate=\"no\">​</a></h2>\n<p>전환 전 사용자 앱은 지도 SDK로 Mapbox를 쓰고 있었는데, Mapbox는 pub.dev에 공식 Flutter 패키지를 제공하고 있어서 다른 화면들처럼 Dart 코드만으로 지도를 붙일 수 있었습니다. 그런데 내부 사정으로 지도 SDK를 네이버 지도로 교체해야 하는 상황이 됐고, 여기서 문제가 생겼습니다. <strong>네이버 지도는 공식 Flutter 패키지가 없고, 안드로이드·iOS 네이티브 SDK만 제공</strong>합니다.</p>\n<h3 class=\"anchor anchorTargetStickyNavbar_Vzrq\" id=\"아키텍처-pigeon과-platformview로-브릿지를-직접-만들다\">아키텍처: Pigeon과 PlatformView로 브릿지를 직접 만들다<a href=\"https://tech.umosone.ai/tap-flutter-migration#%EC%95%84%ED%82%A4%ED%85%8D%EC%B2%98-pigeon%EA%B3%BC-platformview%EB%A1%9C-%EB%B8%8C%EB%A6%BF%EC%A7%80%EB%A5%BC-%EC%A7%81%EC%A0%91-%EB%A7%8C%EB%93%A4%EB%8B%A4\" class=\"hash-link\" aria-label=\"아키텍처: Pigeon과 PlatformView로 브릿지를 직접 만들다에 대한 직접 링크\" title=\"아키텍처: Pigeon과 PlatformView로 브릿지를 직접 만들다에 대한 직접 링크\" translate=\"no\">​</a></h3>\n<p>기존 Feature 코드가 지도 SDK 구현체를 직접 알지 못하도록, <code>MapController</code>라는 SDK 비종속 인터페이스를 두고 Naver 구현체가 이를 만족하도록 설계했습니다. 지도 SDK를 한 번 더 교체하거나, 서비스에 따라 다른 지도 SDK를 함께 써야 하는 상황이 오더라도 이 인터페이스 뒤에 구현체만 추가하면 되도록 확장 지점을 열어둔 셈입니다. 카메라 이동, 마커/오버레이 관리 같은 명령은 Pigeon으로 정의해 Dart → 네이티브로 호출하고, 지도 준비 완료·카메라 정지·마커 탭 같은 이벤트는 반대로 네이티브 → Dart로 콜백을 보냅니다.</p>\n<p><img decoding=\"async\" loading=\"lazy\" alt=\"지도 SDK 브릿지 구조 — Feature 코드, MapController, Pigeon Bridge, 네이티브 뷰\" src=\"https://tech.umosone.ai/assets/images/tap-map-sdk-bridge-6b7e88ef128cb5924b8e87476c56f571.png\" width=\"3840\" height=\"2160\" class=\"img_ev3q\"></p>\n<p>지도 화면 자체는 Android는 <code>AndroidView</code>, iOS는 <code>UiKitView</code>로 네이버 지도의 네이티브 뷰를 그대로 심고, Pigeon 호출은 뷰 ID를 기준으로 해당 네이티브 뷰 인스턴스에 라우팅하는 방식입니다. 제스처 처리, 오버레이 렌더링, 카메라 이벤트 전달까지 하나하나 안드로이드(Kotlin)와 iOS(Swift) 양쪽에 직접 구현해야 했다는 점에서, 이 구조를 만드는 과정 자체가 곧 네이티브 개발이었습니다.</p>\n<p>이 밖에도 마이그레이션 과정에서 자잘한 문제를 여럿 마주쳤습니다.</p>\n<ul>\n<li class=\"\"><strong>마커 아이콘 줌 스케일링</strong>: Mapbox는 줌 값에 따라 아이콘 크기를 자동으로 보간해주는 기능이 있었지만, 네이버 지도에는 이런 기능이 없어서 카메라 줌이 바뀔 때마다 마커 아이콘 크기를 직접 재계산해 다시 그려야 했습니다.</li>\n<li class=\"\"><strong>비동기 초기화 레이스</strong>: 네이티브 지도 SDK 초기화가 비동기라, 지도가 준비되기 전에 Dart 쪽에서 카메라 이동 같은 명령이 먼저 들어오는 경우가 있었습니다. 초기화 전에 들어온 명령은 큐에 쌓아뒀다가, 지도가 준비되면 순서대로 흘려보내도록 했습니다.</li>\n</ul>\n<p>공식 Flutter SDK가 없는 지도 서비스를 붙이는 작업은 결국, 네이티브 SDK를 얼마나 자연스러운 Flutter 인터페이스로 감싸 노출하느냐의 문제였습니다.</p>\n<h2 class=\"anchor anchorTargetStickyNavbar_Vzrq\" id=\"7-완성된-flutter-프로젝트의-구조와-빌드-시스템\">7. 완성된 Flutter 프로젝트의 구조와 빌드 시스템<a href=\"https://tech.umosone.ai/tap-flutter-migration#7-%EC%99%84%EC%84%B1%EB%90%9C-flutter-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8%EC%9D%98-%EA%B5%AC%EC%A1%B0%EC%99%80-%EB%B9%8C%EB%93%9C-%EC%8B%9C%EC%8A%A4%ED%85%9C\" class=\"hash-link\" aria-label=\"7. 완성된 Flutter 프로젝트의 구조와 빌드 시스템에 대한 직접 링크\" title=\"7. 완성된 Flutter 프로젝트의 구조와 빌드 시스템에 대한 직접 링크\" translate=\"no\">​</a></h2>\n<p>세 가지 트러블슈팅을 거쳐 자리 잡은 프로젝트는 큰 틀에서 다음과 같은 모습입니다.</p>\n<p><img decoding=\"async\" loading=\"lazy\" alt=\"완성된 Flutter 프로젝트 구조와 환경별 빌드\" src=\"https://tech.umosone.ai/assets/images/tap-flutter-project-structure-44cd9da954a684407a4b0310d8eb55bc.png\" width=\"3840\" height=\"2160\" class=\"img_ev3q\"></p>\n<p><code>lib</code> 아래는 3장에서 다룬 레이어링(공통 모듈 / 화면 단위 기능)을 그대로 따르고, <code>android</code>·<code>ios</code> 폴더에는 Pigeon 브릿지나 FCM 서비스처럼 네이티브 코드가 필요한 부분만 최소한으로 남아 있습니다. 빌드 설정도 네이티브 시절 안드로이드·iOS 각각 관리하던 것을, 이제는 하나의 파이프라인에서 환경 값만 갈아 끼우는 방식으로 관리합니다.</p>\n<h2 class=\"anchor anchorTargetStickyNavbar_Vzrq\" id=\"8-전환-이후-얻은-것들\">8. 전환 이후 얻은 것들<a href=\"https://tech.umosone.ai/tap-flutter-migration#8-%EC%A0%84%ED%99%98-%EC%9D%B4%ED%9B%84-%EC%96%BB%EC%9D%80-%EA%B2%83%EB%93%A4\" class=\"hash-link\" aria-label=\"8. 전환 이후 얻은 것들에 대한 직접 링크\" title=\"8. 전환 이후 얻은 것들에 대한 직접 링크\" translate=\"no\">​</a></h2>\n<p>전환의 가장 큰 변화는 기술 스택보다 <strong>협업 방식</strong>에서 나타났습니다.</p>\n<p><img decoding=\"async\" loading=\"lazy\" alt=\"전환 전후 협업 방식 변화 — Native(두 코드베이스, 두 언어) vs Flutter(한 사람, 한 코드베이스)\" src=\"https://tech.umosone.ai/assets/images/tap-collaboration-before-after-50c7c41ea26ebd0dd029f63df504a451.png\" width=\"3840\" height=\"2160\" class=\"img_ev3q\"></p>\n<p>기존에는 안드로이드와 iOS 개발자가 같은 화면을 두고도 서로 다른 언어(Kotlin/Swift)와 코드베이스로 이야기해야 했고, 리뷰도 두 코드베이스에서 따로 이뤄졌습니다. 지금은 한 사람이 하나의 Dart 코드베이스로 화면 전체를 오너십을 가지고 개발합니다. 트러블슈팅 ①처럼 네이티브 코드까지 들여다봐야 하는 예외도 있지만, 대부분은 같은 코드를 보면서 논의합니다.</p>\n<h2 class=\"anchor anchorTargetStickyNavbar_Vzrq\" id=\"9-앞으로의-계획-mobile-너머로\">9. 앞으로의 계획: Mobile 너머로<a href=\"https://tech.umosone.ai/tap-flutter-migration#9-%EC%95%9E%EC%9C%BC%EB%A1%9C%EC%9D%98-%EA%B3%84%ED%9A%8D-mobile-%EB%84%88%EB%A8%B8%EB%A1%9C\" class=\"hash-link\" aria-label=\"9. 앞으로의 계획: Mobile 너머로에 대한 직접 링크\" title=\"9. 앞으로의 계획: Mobile 너머로에 대한 직접 링크\" translate=\"no\">​</a></h2>\n<p>사용자 앱을 시작으로 전환했지만, TAP!이 만드는 모바일 앱은 사용자 앱 하나가 아닙니다. 드라이버 앱(콘솔)도 같은 방식으로 Flutter 전환을 마쳤습니다.</p>\n<p>더 나아가, Flutter가 Mobile뿐 아니라 Web·Desktop까지 하나의 코드베이스로 다룰 수 있다는 점(2장에서 살펴본 강점이기도 합니다)을 살려서, Web 화면과 Desktop·차량 인포테인먼트 같은 임베디드 환경까지 아우르는 아키텍처로 확장하는 것도 준비하고 있습니다.</p>\n<p><img decoding=\"async\" loading=\"lazy\" alt=\"Flutter 확장 범위 — Mobile(전환 완료), Web(전환 예정), Desktop·Embedded(Optional)\" src=\"https://tech.umosone.ai/assets/images/tap-flutter-expansion-scope-37db1b13dc060879fe15ae4dd5bb96f3.png\" width=\"3840\" height=\"2160\" class=\"img_ev3q\"></p>\n<p>이런 방향에 확신을 더해주는 사례도 있습니다. <a href=\"https://flutter.dev/showcase\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"\">Flutter 공식 쇼케이스</a>에는 이미 LG Electronics, BMW, eBay, Google Pay처럼 굵직한 기업들의 실사용 사례가 올라와 있는데, 최근에는 2026년형 Toyota RAV4의 차량 인포테인먼트 시스템도 Flutter로 개발됐다는 <a href=\"https://flutter.dev/showcase/toyota\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"\">사례</a>가 소개됐습니다. 모바일 앱을 넘어 차량 임베디드 환경까지 하나의 코드베이스로 대응할 수 있다는 건, 저희가 지금 준비 중인 Mobile 너머로의 확장 방향에도 좋은 참고가 됩니다.</p>\n<h2 class=\"anchor anchorTargetStickyNavbar_Vzrq\" id=\"10-마치며\">10. 마치며<a href=\"https://tech.umosone.ai/tap-flutter-migration#10-%EB%A7%88%EC%B9%98%EB%A9%B0\" class=\"hash-link\" aria-label=\"10. 마치며에 대한 직접 링크\" title=\"10. 마치며에 대한 직접 링크\" translate=\"no\">​</a></h2>\n<p>네이티브 두 벌을 유지하던 사용자 앱을, 리소스 문제에서 출발해 Flutter 하나로 합치기까지의 과정을 정리했습니다. 기술 선택의 배경, 기존 아키텍처를 최대한 살린 전환 전략, 그리고 실제로 겪은 세 가지 트러블슈팅까지 다뤘습니다. 결국 이 전환의 핵심은 프레임워크 자체보다, 안드로이드와 iOS로 나뉘어 있던 팀이 하나의 코드베이스와 하나의 언어로 같은 화면을 이야기할 수 있게 됐다는 점이었습니다.</p>\n<p>환승은 끝났지만, 다음 정거장까지는 아직 갈 길이 남아 있습니다. Mobile 너머로 확장하는 그 여정도 차근차근 기록해 나가겠습니다.</p>",
            "url": "https://tech.umosone.ai/tap-flutter-migration",
            "title": "두 노선에서 한 노선으로: TAP! 앱, Flutter로 환승하다",
            "summary": "안드로이드와 iOS로 각각 개발되던 TAP! 사용자 앱을 Flutter로 전환한 과정을 공유합니다. 크로스 플랫폼 기술 선택의 배경부터 상태관리 조사, 실제 전환 과정에서 겪은 트러블슈팅, 전환 이후 얻은 변화까지 기록합니다.",
            "date_modified": "2026-08-07T00:00:00.000Z",
            "author": {
                "name": "민경운"
            },
            "tags": [
                "모빌리티",
                "Flutter",
                "Mobile",
                "TAP!"
            ]
        },
        {
            "id": "https://tech.umosone.ai/delivery-zone-optimization",
            "content_html": "<p>전용차량 한 대를 더 붙이는 게 이득일까요, 손해일까요? 그리고 어디를 묶어야 그 한 대가 흑자가 될까요? 경로를 최단으로 만드는 최적화는 이 질문에 답해주지 않았습니다. 저희가 만든 건 “가장 짧은 경로”가 아니라 “<strong>현장에서 방어 가능한 권역</strong>”을 찾는 엔진이었습니다.</p>\n<p>안녕하세요, UMOSONE TMS(Transportation Management System) 개발팀의 Backend Engineer 백효현입니다.</p>\n<p>이 글은 전용차량 배송 <strong>권역(zone)</strong> 을 비용 최소화 관점에서 추천·최적화하는 엔진을 만들면서, 흔히 말하는 “경로 최적화”를 어떻게 <strong>실제 물류 도메인을 검증하고 발굴하는 상용화 기술</strong>로 끌어왔는지에 대한 기록입니다. 결정의 근거, 실패한 접근, 운영에서 배운 제약을 함께 남깁니다.</p>\n<blockquote>\n<p>이 글의 수치(손익분기 박스 수, 용량 상한, 절감 비율 등)는 방법론을 설명하기 위한 값이며, 실제 계약 단가·거점 위치·거래처 정보는 포함하지 않습니다.</p>\n</blockquote>\n<h2 class=\"anchor anchorTargetStickyNavbar_Vzrq\" id=\"경로-최적화가-답하지-않는-질문\">경로 최적화가 답하지 않는 질문<a href=\"https://tech.umosone.ai/delivery-zone-optimization#%EA%B2%BD%EB%A1%9C-%EC%B5%9C%EC%A0%81%ED%99%94%EA%B0%80-%EB%8B%B5%ED%95%98%EC%A7%80-%EC%95%8A%EB%8A%94-%EC%A7%88%EB%AC%B8\" class=\"hash-link\" aria-label=\"경로 최적화가 답하지 않는 질문에 대한 직접 링크\" title=\"경로 최적화가 답하지 않는 질문에 대한 직접 링크\" translate=\"no\">​</a></h2>\n<p>배송 최적화라고 하면 보통 VRP(Vehicle Routing Problem), 즉 “주어진 배송지들을 가장 짧게 순회하라”를 떠올립니다. 많은 회사가 이 문제에 도전하고, 좋은 솔버도 많습니다. 그런데 저희가 실제로 답해야 했던 질문은 조금 달랐습니다.</p>\n<ul>\n<li class=\"\">전용차량(고정 계약)으로 <strong>직접 배송할 권역</strong>과, 건당 단가로 <strong>공동배송에 위탁할 물량</strong>을 어떻게 나눌 것인가?</li>\n<li class=\"\">어떤 읍면동들을 하나로 묶어야 그 권역을 맡은 차량이 <strong>손익분기를 넘겨 흑자</strong>가 되는가?</li>\n<li class=\"\">지금 운영 중인 전용차량 외에, 위탁 물량 안에 숨어 있는 <strong>추가로 전용화하면 이득인 권역</strong>은 없는가?</li>\n</ul>\n<p><img decoding=\"async\" loading=\"lazy\" alt=\"같은 지도, 다른 질문\" src=\"data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxMjAwIiBoZWlnaHQ9IjY0MCIgdmlld0JveD0iMCAwIDEyMDAgNjQwIiByb2xlPSJpbWciIGFyaWEtbGFiZWxsZWRieT0idGl0bGUgZGVzYyI+CiAgPHRpdGxlIGlkPSJ0aXRsZSI+6rK966GcIOy1nOygge2ZlOyZgCDqtozsl60g7ISk6rOE64qUIOuLpOuluCDrrLjsoJw8L3RpdGxlPgogIDxkZXNjIGlkPSJkZXNjIj7smbzsqr3snYAg7LWc64uo6rGw66as66eMIOqzhOyCsO2VmOuKlCDsiJzsiJgg6rK966GcIOy1nOygge2ZlCwg7Jik66W47Kq97J2AIOyGkOydteu2hOq4sMK36re866y07Iuc6rCEwrfsp4Drpqwg7KCc7JW97J2EIO2VqOq7mCDqs6DroKTtlZjripQg6raM7JetIOyEpOqzhCDrrLjsoJzrpbwg64yA67mE7ZWcIOq3uOumvDwvZGVzYz4KICA8ZGVmcz4KICAgIDxzdHlsZT4KICAgICAgLmJnIHsgZmlsbDogI2Y1ZjhmYzsgfQogICAgICAuY2FyZCB7IGZpbGw6ICNmZmZmZmY7IHN0cm9rZTogI2Q3ZGVlYTsgc3Ryb2tlLXdpZHRoOiAyOyB9CiAgICAgIC5hbGVydCB7IGZpbGw6ICNmZmY1ZjQ7IHN0cm9rZTogI2Q5NTY0ODsgc3Ryb2tlLXdpZHRoOiAyOyB9CiAgICAgIC5vayB7IGZpbGw6ICNmM2ZhZjY7IHN0cm9rZTogIzI4N2Y1Mjsgc3Ryb2tlLXdpZHRoOiAyOyB9CiAgICAgIC5kYXJrIHsgZmlsbDogIzE1MTkyMzsgfQogICAgICAuYmx1ZSB7IGZpbGw6ICMyZjdkZjY7IH0KICAgICAgLmNvcmFsIHsgZmlsbDogI2Q5NTY0ODsgfQogICAgICAuZ3JlZW4geyBmaWxsOiAjMjg3ZjUyOyB9CiAgICAgIC5tdXRlZCB7IGZpbGw6ICM2ODczODY7IH0KICAgICAgLmluayB7IGZpbGw6ICMxMjE3MjI7IH0KICAgICAgLndoaXRlIHsgZmlsbDogI2ZmZmZmZjsgfQogICAgICAudGl0bGUgeyBmb250OiA4MDAgNDBweCB1aS1zYW5zLXNlcmlmLCBzeXN0ZW0tdWksIC1hcHBsZS1zeXN0ZW0sICJTZWdvZSBVSSIsIHNhbnMtc2VyaWY7IH0KICAgICAgLnN1YnRpdGxlIHsgZm9udDogNTAwIDIxcHggdWktc2Fucy1zZXJpZiwgc3lzdGVtLXVpLCAtYXBwbGUtc3lzdGVtLCAiU2Vnb2UgVUkiLCBzYW5zLXNlcmlmOyB9CiAgICAgIC5sYWJlbCB7IGZvbnQ6IDgwMCAyNHB4IHVpLXNhbnMtc2VyaWYsIHN5c3RlbS11aSwgLWFwcGxlLXN5c3RlbSwgIlNlZ29lIFVJIiwgc2Fucy1zZXJpZjsgfQogICAgICAuYm9keSB7IGZvbnQ6IDYwMCAxOHB4IHVpLXNhbnMtc2VyaWYsIHN5c3RlbS11aSwgLWFwcGxlLXN5c3RlbSwgIlNlZ29lIFVJIiwgc2Fucy1zZXJpZjsgfQogICAgICAuc21hbGwgeyBmb250OiA3MDAgMTZweCB1aS1zYW5zLXNlcmlmLCBzeXN0ZW0tdWksIC1hcHBsZS1zeXN0ZW0sICJTZWdvZSBVSSIsIHNhbnMtc2VyaWY7IH0KICAgICAgLmFycm93IHsgZmlsbDogbm9uZTsgc3Ryb2tlOiAjN2I4Nzk3OyBzdHJva2Utd2lkdGg6IDQ7IHN0cm9rZS1saW5lY2FwOiByb3VuZDsgfQogICAgICAuc2hhZG93IHsgZmlsdGVyOiBkcm9wLXNoYWRvdygwIDE4cHggMzBweCByZ2IoMzEgNDUgNjYgLyAwLjEyKSk7IH0KICAgIDwvc3R5bGU+CiAgICA8bWFya2VyIGlkPSJhaCIgbWFya2VyV2lkdGg9IjkiIG1hcmtlckhlaWdodD0iOSIgcmVmWD0iOCIgcmVmWT0iNC41IiBvcmllbnQ9ImF1dG8iPgogICAgICA8cGF0aCBkPSJNMCwwIEw5LDQuNSBMMCw5IFoiIGZpbGw9IiM3Yjg3OTciLz4KICAgIDwvbWFya2VyPgogIDwvZGVmcz4KCiAgPHJlY3QgY2xhc3M9ImJnIiB3aWR0aD0iMTIwMCIgaGVpZ2h0PSI2NDAiLz4KICA8dGV4dCB4PSI3MiIgeT0iNzYiIGNsYXNzPSJ0aXRsZSBpbmsiPuqwmeydgCDsp4Drj4QsIOuLpOuluCDsp4jrrLg8L3RleHQ+CiAgPHRleHQgeD0iNzIiIHk9IjExMCIgY2xhc3M9InN1YnRpdGxlIG11dGVkIj7qsr3roZzrpbwg7Ken6rKMIOunjOuTnOuKlCDrrLjsoJzsmYAsIOq2jOyXreydhCDsgqzsl4XsoIHsnLzroZwg64KY64iE64qUIOusuOygnOuKlCDri6TrpoXri4jri6QuPC90ZXh0PgoKICA8IS0tIExFRlQ6IHB1cmUgcm91dGluZyAtLT4KICA8ZyBjbGFzcz0ic2hhZG93Ij4KICAgIDxyZWN0IHg9IjcyIiB5PSIxNTAiIHdpZHRoPSI0ODAiIGhlaWdodD0iNDEwIiByeD0iMjAiIGNsYXNzPSJjYXJkIi8+CiAgPC9nPgogIDxjaXJjbGUgY3g9IjExOCIgY3k9IjE5NiIgcj0iMTgiIGNsYXNzPSJibHVlIi8+CiAgPHRleHQgeD0iMTUwIiB5PSIyMDMiIGNsYXNzPSJsYWJlbCBpbmsiPuyInOyImCDqsr3roZwg7LWc7KCB7ZmUPC90ZXh0PgogIDx0ZXh0IHg9IjEwNCIgeT0iMjQwIiBjbGFzcz0iYm9keSBtdXRlZCI+4oCc7KO87Ja07KeEIOuwsOyGoeyngOuTpOydhCDstZzri6jsnLzroZwg7Iic7ZqM4oCdPC90ZXh0PgoKICA8IS0tIG5vZGVzIC0tPgogIDxnPgogICAgPGNpcmNsZSBjeD0iMTUwIiBjeT0iMzIwIiByPSI5IiBjbGFzcz0iZGFyayIvPgogICAgPGNpcmNsZSBjeD0iMjYwIiBjeT0iMzAwIiByPSI5IiBjbGFzcz0iZGFyayIvPgogICAgPGNpcmNsZSBjeD0iMzYwIiBjeT0iMzUwIiByPSI5IiBjbGFzcz0iZGFyayIvPgogICAgPGNpcmNsZSBjeD0iNDYwIiBjeT0iMzEwIiByPSI5IiBjbGFzcz0iZGFyayIvPgogICAgPGNpcmNsZSBjeD0iMzAwIiBjeT0iNDMwIiByPSI5IiBjbGFzcz0iZGFyayIvPgogICAgPGNpcmNsZSBjeD0iMjAwIiBjeT0iNDcwIiByPSI5IiBjbGFzcz0iZGFyayIvPgogICAgPGNpcmNsZSBjeD0iNDIwIiBjeT0iNDUwIiByPSI5IiBjbGFzcz0iZGFyayIvPgogICAgPHBhdGggY2xhc3M9ImFycm93IiBtYXJrZXItZW5kPSJ1cmwoI2FoKSIgZD0iTTE1MCwzMjAgTDI1MywzMDIiLz4KICAgIDxwYXRoIGNsYXNzPSJhcnJvdyIgbWFya2VyLWVuZD0idXJsKCNhaCkiIGQ9Ik0yNjcsMzAxIEwzNTMsMzQ3Ii8+CiAgICA8cGF0aCBjbGFzcz0iYXJyb3ciIG1hcmtlci1lbmQ9InVybCgjYWgpIiBkPSJNMzY3LDM0OSBMNDUzLDMxMyIvPgogICAgPHBhdGggY2xhc3M9ImFycm93IiBtYXJrZXItZW5kPSJ1cmwoI2FoKSIgZD0iTTQ1NiwzMTggTDQyNiw0NDMiLz4KICAgIDxwYXRoIGNsYXNzPSJhcnJvdyIgbWFya2VyLWVuZD0idXJsKCNhaCkiIGQ9Ik00MTQsNDUyIEwzMDcsNDMxIi8+CiAgICA8cGF0aCBjbGFzcz0iYXJyb3ciIG1hcmtlci1lbmQ9InVybCgjYWgpIiBkPSJNMjkzLDQzMiBMMjA3LDQ2NCIvPgogIDwvZz4KICA8cmVjdCB4PSIxMDQiIHk9IjUwMCIgd2lkdGg9IjQxNiIgaGVpZ2h0PSI0MiIgcng9IjEwIiBjbGFzcz0iYWxlcnQiLz4KICA8dGV4dCB4PSIxMjQiIHk9IjUyNyIgY2xhc3M9InNtYWxsIGNvcmFsIj7ri7XsnYAg64KY7Jik7KeA66eMIOKAlCDigJzsnbQg6raM7JetLCDtnZHsnpDsnbjqsIA/4oCd7JeUIOuLte2VmOyngCDrqrvtlag8L3RleHQ+CgogIDwhLS0gUklHSFQ6IHpvbmUgZGVzaWduIC0tPgogIDxnIGNsYXNzPSJzaGFkb3ciPgogICAgPHJlY3QgeD0iNjQ4IiB5PSIxNTAiIHdpZHRoPSI0ODAiIGhlaWdodD0iNDEwIiByeD0iMjAiIGNsYXNzPSJjYXJkIi8+CiAgPC9nPgogIDxjaXJjbGUgY3g9IjY5NCIgY3k9IjE5NiIgcj0iMTgiIGNsYXNzPSJncmVlbiIvPgogIDx0ZXh0IHg9IjcyNiIgeT0iMjAzIiBjbGFzcz0ibGFiZWwgaW5rIj7qtozsl60g7ISk6rOEIOusuOygnDwvdGV4dD4KICA8dGV4dCB4PSI2ODAiIHk9IjI0MCIgY2xhc3M9ImJvZHkgbXV0ZWQiPuKAnOyWtOuUlOulvCDrrLbsnLzrqbQg7KCE7Jqp7LCo65+J7J20IOydtOuTneyduOqwgOKAnTwvdGV4dD4KCiAgPGc+CiAgICA8cGF0aCBkPSJNNzAwLDMwMCBxNjAsLTIwIDEzMCwxMCBxNDAsNjAgLTEwLDEyMCBxLTkwLDMwIC0xNDAsLTIwIHEtMjAsLTcwIDIwLC05MCBaIiBmaWxsPSIjZWFmM2ZmIiBzdHJva2U9IiMyZjdkZjYiIHN0cm9rZS13aWR0aD0iMyIvPgogICAgPHBhdGggZD0iTTkwMCwzMzAgcTgwLC0xMCAxNTAsNDAgcTEwLDcwIC02MCwxMTAgcS05MCwxMCAtMTIwLC01MCBxLTEwLC04MCAzMCwtMTAwIFoiIGZpbGw9IiNmM2ZhZjYiIHN0cm9rZT0iIzI4N2Y1MiIgc3Ryb2tlLXdpZHRoPSIzIi8+CiAgICA8Y2lyY2xlIGN4PSI3NjAiIGN5PSIzNDAiIHI9IjciIGNsYXNzPSJibHVlIi8+PGNpcmNsZSBjeD0iODAwIiBjeT0iMzgwIiByPSI3IiBjbGFzcz0iYmx1ZSIvPjxjaXJjbGUgY3g9Ijc0MCIgY3k9IjQwMCIgcj0iNyIgY2xhc3M9ImJsdWUiLz4KICAgIDxjaXJjbGUgY3g9Ijk3MCIgY3k9IjM4MCIgcj0iNyIgY2xhc3M9ImdyZWVuIi8+PGNpcmNsZSBjeD0iMTAxMCIgY3k9IjQyMCIgcj0iNyIgY2xhc3M9ImdyZWVuIi8+PGNpcmNsZSBjeD0iOTUwIiBjeT0iNDQwIiByPSI3IiBjbGFzcz0iZ3JlZW4iLz4KICA8L2c+CiAgPHRleHQgeD0iNjgwIiB5PSI0NzUiIGNsYXNzPSJzbWFsbCBtdXRlZCI+7IaQ7J2167aE6riwIMK3IOq3vOustOyLnOqwhCDCtyDqsJUv7ZaJ7KCV6rK96rOEIOygnOyVveydhCDrp4zsobHtlZjripQg66y27J2MPC90ZXh0PgogIDxyZWN0IHg9IjY4MCIgeT0iNTAwIiB3aWR0aD0iNDE2IiBoZWlnaHQ9IjQyIiByeD0iMTAiIGNsYXNzPSJvayIvPgogIDx0ZXh0IHg9IjcwMCIgeT0iNTI3IiBjbGFzcz0ic21hbGwgZ3JlZW4iPuu5hOyaqSDstZzshozqsIAg65CY64+E66GdIOKAnOustuydhCDqsoMgLyDrgqjquLgg6rKD4oCd7J2EIOqysOyglTwvdGV4dD4KPC9zdmc+Cg==\" width=\"1200\" height=\"640\" class=\"img_ev3q\"></p>\n<p>경로 최적화는 “이 배송지들을 어떻게 도느냐”에 답하지만, 위 질문들은 “<strong>애초에 어떤 배송지를 한 묶음으로 볼 것이냐</strong>”를 묻습니다. 묶음이 정해지기 전에는 경로가 없고, 묶음의 좋고 나쁨은 경로 길이가 아니라 <strong>비용과 제약</strong>으로 판단해야 합니다. 여기서부터는 경로 문제가 아니라 <strong>권역 설계 문제</strong>입니다.</p>\n<h2 class=\"anchor anchorTargetStickyNavbar_Vzrq\" id=\"비용-구조부터-세우기\">비용 구조부터 세우기<a href=\"https://tech.umosone.ai/delivery-zone-optimization#%EB%B9%84%EC%9A%A9-%EA%B5%AC%EC%A1%B0%EB%B6%80%ED%84%B0-%EC%84%B8%EC%9A%B0%EA%B8%B0\" class=\"hash-link\" aria-label=\"비용 구조부터 세우기에 대한 직접 링크\" title=\"비용 구조부터 세우기에 대한 직접 링크\" translate=\"no\">​</a></h2>\n<p>권역이 이득인지 판단하려면 먼저 비용 모델이 필요했습니다. 전용차량은 거리 기반 계약이라, 한 권역 <code>z</code>의 월 차량비는 고정비와 주행비의 합으로 둘 수 있습니다.</p>\n<div class=\"language-text codeBlockContainer_Ckt0 theme-code-block\" style=\"--prism-color:#393A34;--prism-background-color:#f6f8fa\"><div class=\"codeBlockContent_QJqH\"><pre tabindex=\"0\" class=\"prism-code language-text codeBlock_bY9V thin-scrollbar\" style=\"color:#393A34;background-color:#f6f8fa\"><code class=\"codeBlockLines_e6Vv\"><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token plain\">차량비(z)      = F + r × 월주행거리(z)</span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token plain\">월주행거리(z)  = ( 거점↔권역 왕복 스템 + 권역 내 전 읍면동 2-opt 투어 ) × 월 운행일수</span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token plain\">절감액(z)      = 월박스(z) × p  −  차량비(z)          # p = 박스당 공동배송 단가</span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token plain\">손익분기(z)    = 차량비(z) / p                          # 이만큼은 배송해야 본전</span><br></div></code></pre></div></div>\n<p>여기서 두 가지가 중요합니다.</p>\n<p>첫째, <strong>손익분기는 전역 상수가 아니라 권역별로 다릅니다.</strong> 차량비에 주행거리가 들어가므로, 거점에서 가까운 권역은 손익분기 박스 수가 낮고(≈900박스대도 흑자 가능), 먼 권역은 높습니다(월 1,200박스를 넘겨도 적자일 수 있음). 그래서 “월 1,200박스”라는 하나의 기준선으로 모든 권역을 재단하면 가까운 흑자 권역을 놓치고 먼 적자 권역을 잘못 채택합니다.</p>\n<p>둘째, 어떤 흑자 권역에도 들어가지 못한 물량은 <strong>자동으로 공동배송으로 낙착</strong>됩니다. 즉 “효율화가 안 되면 위탁”이 별도 규칙이 아니라 비용 모델의 자연스러운 결과가 되도록 설계했습니다.</p>\n<h2 class=\"anchor anchorTargetStickyNavbar_Vzrq\" id=\"핵심-재정식화--비용-최소화를-절감액-최대화로\">핵심 재정식화 — 비용 최소화를 절감액 최대화로<a href=\"https://tech.umosone.ai/delivery-zone-optimization#%ED%95%B5%EC%8B%AC-%EC%9E%AC%EC%A0%95%EC%8B%9D%ED%99%94--%EB%B9%84%EC%9A%A9-%EC%B5%9C%EC%86%8C%ED%99%94%EB%A5%BC-%EC%A0%88%EA%B0%90%EC%95%A1-%EC%B5%9C%EB%8C%80%ED%99%94%EB%A1%9C\" class=\"hash-link\" aria-label=\"핵심 재정식화 — 비용 최소화를 절감액 최대화로에 대한 직접 링크\" title=\"핵심 재정식화 — 비용 최소화를 절감액 최대화로에 대한 직접 링크\" translate=\"no\">​</a></h2>\n<p>초기 버전(임베디드 JS greedy)은 “최대 몇 대까지”, “반경 몇 km까지” 같은 상한을 입력으로 받았습니다. 그런데 이 상한들이야말로 문제의 본질을 가리고 있었습니다. <strong>몇 대가 최적인지는 우리가 정할 게 아니라 절감액이 정해야</strong> 했으니까요.</p>\n<p>그래서 목적함수를 뒤집었습니다.</p>\n<p><img decoding=\"async\" loading=\"lazy\" alt=\"핵심 재정식화: 최대가중 집합 패킹\" src=\"data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxMjAwIiBoZWlnaHQ9IjcyMCIgdmlld0JveD0iMCAwIDEyMDAgNzIwIiByb2xlPSJpbWciIGFyaWEtbGFiZWxsZWRieT0idGl0bGUgZGVzYyI+CiAgPHRpdGxlIGlkPSJ0aXRsZSI+67mE7JqpIOy1nOyGjO2ZlOulvCDstZzrjIDqsIDspJEg7KeR7ZWpIO2MqO2CueycvOuhnCDsnqzsoJXsi53tmZQ8L3RpdGxlPgogIDxkZXNjIGlkPSJkZXNjIj7soITssrQg66y865+JIOu5hOyaqeydgCDsg4HsiJjsnbTrr4DroZwg7LSd67mE7JqpIOy1nOyGjO2ZlOuKlCDqsrnsuZjsp4Ag7JWK64qUIO2dkeyekCDqtozsl63rk6TsnZgg7KCI6rCQ7JWhIO2VqeydhCDstZzrjIDtmZTtlZjripQg7KeR7ZWpIO2MqO2CuSDrrLjsoJzsmYAg6rCZ64uk64qUIOqyg+ydhCDrs7Tsl6zso7zripQg6re466a8PC9kZXNjPgogIDxkZWZzPgogICAgPHN0eWxlPgogICAgICAuYmcgeyBmaWxsOiAjZjVmOGZjOyB9CiAgICAgIC5jYXJkIHsgZmlsbDogI2ZmZmZmZjsgc3Ryb2tlOiAjZDdkZWVhOyBzdHJva2Utd2lkdGg6IDI7IH0KICAgICAgLnBhbmVsIHsgZmlsbDogIzBmMTYyNjsgfQogICAgICAub2sgeyBmaWxsOiAjZjNmYWY2OyBzdHJva2U6ICMyODdmNTI7IHN0cm9rZS13aWR0aDogMjsgfQogICAgICAuYmx1ZSB7IGZpbGw6ICMyZjdkZjY7IH0gLnRlYWwgeyBmaWxsOiAjMTI4ZjgyOyB9IC5wdXJwbGUgeyBmaWxsOiAjN2M1Y2ZmOyB9CiAgICAgIC5jb3JhbCB7IGZpbGw6ICNkOTU2NDg7IH0gLmdyZWVuIHsgZmlsbDogIzI4N2Y1MjsgfSAuYW1iZXIgeyBmaWxsOiAjYjQ3YTE2OyB9CiAgICAgIC5tdXRlZCB7IGZpbGw6ICM2ODczODY7IH0gLmluayB7IGZpbGw6ICMxMjE3MjI7IH0gLndoaXRlIHsgZmlsbDogI2ZmZmZmZjsgfQogICAgICAudGl0bGUgeyBmb250OiA4MDAgNDBweCB1aS1zYW5zLXNlcmlmLCBzeXN0ZW0tdWksICJTZWdvZSBVSSIsIHNhbnMtc2VyaWY7IH0KICAgICAgLnN1YnRpdGxlIHsgZm9udDogNTAwIDIxcHggdWktc2Fucy1zZXJpZiwgc3lzdGVtLXVpLCAiU2Vnb2UgVUkiLCBzYW5zLXNlcmlmOyB9CiAgICAgIC5sYWJlbCB7IGZvbnQ6IDgwMCAyM3B4IHVpLXNhbnMtc2VyaWYsIHN5c3RlbS11aSwgIlNlZ29lIFVJIiwgc2Fucy1zZXJpZjsgfQogICAgICAuYm9keSB7IGZvbnQ6IDYwMCAxOHB4IHVpLXNhbnMtc2VyaWYsIHN5c3RlbS11aSwgIlNlZ29lIFVJIiwgc2Fucy1zZXJpZjsgfQogICAgICAuc21hbGwgeyBmb250OiA3MDAgMTVweCB1aS1zYW5zLXNlcmlmLCBzeXN0ZW0tdWksICJTZWdvZSBVSSIsIHNhbnMtc2VyaWY7IH0KICAgICAgLm1vbm8geyBmb250OiA2MDAgMjJweCB1aS1tb25vc3BhY2UsIFNGTW9uby1SZWd1bGFyLCBNZW5sbywgbW9ub3NwYWNlOyB9CiAgICAgIC5tb25vZGltIHsgZm9udDogNjAwIDIycHggdWktbW9ub3NwYWNlLCBTRk1vbm8tUmVndWxhciwgTWVubG8sIG1vbm9zcGFjZTsgZmlsbDojOGVhM2M0OyB9CiAgICAgIC5tb25vaGkgeyBmb250OiA3MDAgMjJweCB1aS1tb25vc3BhY2UsIFNGTW9uby1SZWd1bGFyLCBNZW5sbywgbW9ub3NwYWNlOyBmaWxsOiNmZmQzNmI7IH0KICAgICAgLnNoYWRvdyB7IGZpbHRlcjogZHJvcC1zaGFkb3coMCAxOHB4IDMwcHggcmdiKDMxIDQ1IDY2IC8gMC4xMikpOyB9CiAgICA8L3N0eWxlPgogIDwvZGVmcz4KCiAgPHJlY3QgY2xhc3M9ImJnIiB3aWR0aD0iMTIwMCIgaGVpZ2h0PSI3MjAiLz4KICA8dGV4dCB4PSI3MiIgeT0iNzYiIGNsYXNzPSJ0aXRsZSBpbmsiPu2VteyLrCDsnqzsoJXsi53tmZQ8L3RleHQ+CiAgPHRleHQgeD0iNzIiIHk9IjExMCIgY2xhc3M9InN1YnRpdGxlIG11dGVkIj7igJzrqocg64yA6rCAIOy1nOyggeyduOqwgOKAneudvOuKlCDsg4HtlZzsnYQg7KeA7Jqw6riwIOychO2VtCwg67mE7JqpIOy1nOyGjO2ZlOulvCDsoIjqsJDslaEg7LWc64yA7ZmU66GcIOuSpOynkeyKteuLiOuLpC48L3RleHQ+CgogIDwhLS0gZm9ybXVsYSBwYW5lbCAtLT4KICA8ZyBjbGFzcz0ic2hhZG93Ij4KICAgIDxyZWN0IHg9IjcyIiB5PSIxNTAiIHdpZHRoPSIxMDU2IiBoZWlnaHQ9IjI1MCIgcng9IjIwIiBjbGFzcz0icGFuZWwiLz4KICA8L2c+CiAgPHRleHQgeD0iMTA4IiB5PSIyMTIiIGNsYXNzPSJtb25vIiBmaWxsPSIjOGVhM2M0Ij5taW4gIOy0neu5hOyaqTwvdGV4dD4KICA8dGV4dCB4PSIxNTAiIHk9IjI1NiIgY2xhc3M9Im1vbm9kaW0iPj0gPC90ZXh0PgogIDx0ZXh0IHg9IjE4OCIgeT0iMjU2IiBjbGFzcz0ibW9ubyIgZmlsbD0iIzdmZDBmZiI+zqMg7KCE7Jqp6raM7JetIOqzhOyVveu5hCh6KTwvdGV4dD4KICA8dGV4dCB4PSI0NzAiIHk9IjI1NiIgY2xhc3M9Im1vbm9kaW0iPisgPC90ZXh0PgogIDx0ZXh0IHg9IjUwOCIgeT0iMjU2IiBjbGFzcz0ibW9ubyIgZmlsbD0iIzdmZDBmZiI+zqMg6rO164+Z67Cw7IahIOuwleyKpCDDlyDri6jqsIA8L3RleHQ+CiAgPHRleHQgeD0iMTUwIiB5PSIzMDgiIGNsYXNzPSJtb25vZGltIj49IDwvdGV4dD4KICA8dGV4dCB4PSIxODgiIHk9IjMwOCIgY2xhc3M9Im1vbm9kaW0iPigg7KCE7LK0IOuwleyKpCDDlyDri6jqsIAgKTwvdGV4dD4KICA8dGV4dCB4PSI1MTIiIHk9IjMwOCIgY2xhc3M9Im1vbm9oaSI+4oiSICDOoyDshKDtg53qtozsl60g7KCI6rCQ7JWhKHopPC90ZXh0PgogIDx0ZXh0IHg9IjgyMCIgeT0iMzA4IiBjbGFzcz0ic21hbGwiIGZpbGw9IiM4ZWEzYzQiPuKGkCDslZ7tla3snYAg7IOB7IiYPC90ZXh0PgogIDx0ZXh0IHg9IjE1MCIgeT0iMzYwIiBjbGFzcz0ibW9ub2RpbSI+4p+6IDwvdGV4dD4KICA8dGV4dCB4PSIxOTIiIHk9IjM2MCIgY2xhc3M9Im1vbm9oaSI+bWF4ICDOoyDsoIjqsJDslaEoeik8L3RleHQ+CiAgPHRleHQgeD0iNDcwIiB5PSIzNjAiIGNsYXNzPSJtb25vIiBmaWxsPSIjN2ZkMGZmIj5zLnQuIOq2jOyXreuBvOumrCDshYDsnbQg6rK57LmY7KeAIOyViuydjCwg7KCI6rCQ7JWhKHopICZndDsgMDwvdGV4dD4KCiAgPCEtLSBsZWZ0OiBvdmVybGFwcGluZyBjYW5kaWRhdGUgcG9vbCAtLT4KICA8dGV4dCB4PSI3MiIgeT0iNDUyIiBjbGFzcz0ibGFiZWwgaW5rIj7spJHssqkg7ZeI7JqpIO2bhOuztCDtkoA8L3RleHQ+CiAgPHRleHQgeD0iNzIiIHk9IjQ4MiIgY2xhc3M9ImJvZHkgbXV0ZWQiPu2VnCDslKjslZfsl5DshJwg7Jes65+sIO2BrOq4sOuhnCDshLHsnqUg4oaSIOqyuey5mOuKlCDtm4Trs7Trk6Q8L3RleHQ+CiAgPGcgY2xhc3M9InNoYWRvdyI+PHJlY3QgeD0iNzIiIHk9IjUwMCIgd2lkdGg9IjQ4MCIgaGVpZ2h0PSIxODAiIHJ4PSIxNiIgY2xhc3M9ImNhcmQiLz48L2c+CiAgPGcgb3BhY2l0eT0iMC44NSI+CiAgICA8cmVjdCB4PSIxMTAiIHk9IjUzNiIgd2lkdGg9IjE1MCIgaGVpZ2h0PSI3MCIgcng9IjEyIiBmaWxsPSIjZWFmM2ZmIiBzdHJva2U9IiMyZjdkZjYiIHN0cm9rZS13aWR0aD0iMiIvPgogICAgPHJlY3QgeD0iMTgwIiB5PSI1NjYiIHdpZHRoPSIxNTAiIGhlaWdodD0iNzAiIHJ4PSIxMiIgZmlsbD0iI2YwZWNmZiIgc3Ryb2tlPSIjN2M1Y2ZmIiBzdHJva2Utd2lkdGg9IjIiLz4KICAgIDxyZWN0IHg9IjMwMCIgeT0iNTQwIiB3aWR0aD0iMTUwIiBoZWlnaHQ9IjcwIiByeD0iMTIiIGZpbGw9IiNlOWY3ZjQiIHN0cm9rZT0iIzEyOGY4MiIgc3Ryb2tlLXdpZHRoPSIyIi8+CiAgICA8cmVjdCB4PSIzNjAiIHk9IjU4MCIgd2lkdGg9IjEzMCIgaGVpZ2h0PSI2MCIgcng9IjEyIiBmaWxsPSIjZmZmNWU5IiBzdHJva2U9IiNiNDdhMTYiIHN0cm9rZS13aWR0aD0iMiIvPgogIDwvZz4KICA8dGV4dCB4PSIxMjAiIHk9IjU3NiIgY2xhc3M9InNtYWxsIGJsdWUiPnrigoE8L3RleHQ+CiAgPHRleHQgeD0iMzAwIiB5PSI2MDYiIGNsYXNzPSJzbWFsbCBwdXJwbGUiPnrigoI8L3RleHQ+CiAgPHRleHQgeD0iNDE4IiB5PSI1ODAiIGNsYXNzPSJzbWFsbCB0ZWFsIj564oKDPC90ZXh0PgoKICA8IS0tIGFycm93IC0tPgogIDxwYXRoIGQ9Ik01NjgsNTkwIEw2MzYsNTkwIiBmaWxsPSJub25lIiBzdHJva2U9IiM3Yjg3OTciIHN0cm9rZS13aWR0aD0iNSIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBtYXJrZXItZW5kPSJ1cmwoI2FoMikiLz4KICA8ZGVmcz48bWFya2VyIGlkPSJhaDIiIG1hcmtlcldpZHRoPSI5IiBtYXJrZXJIZWlnaHQ9IjkiIHJlZlg9IjgiIHJlZlk9IjQuNSIgb3JpZW50PSJhdXRvIj48cGF0aCBkPSJNMCwwIEw5LDQuNSBMMCw5IFoiIGZpbGw9IiM3Yjg3OTciLz48L21hcmtlcj48L2RlZnM+CgogIDwhLS0gcmlnaHQ6IENQLVNBVCBjaG9zZW4gZGlzam9pbnQgLS0+CiAgPHRleHQgeD0iNjU2IiB5PSI0NTIiIGNsYXNzPSJsYWJlbCBpbmsiPkNQLVNBVCDshKDtg50gKOyEnOuhnOyGjCk8L3RleHQ+CiAgPHRleHQgeD0iNjU2IiB5PSI0ODIiIGNsYXNzPSJib2R5IG11dGVkIj7soIjqsJDslaEg7ZWp7J20IOy1nOuMgOqwgCDrkJjripQg6rK57LmY7KeAIOyViuuKlCDsobDtlak8L3RleHQ+CiAgPGcgY2xhc3M9InNoYWRvdyI+PHJlY3QgeD0iNjU2IiB5PSI1MDAiIHdpZHRoPSI0NzIiIGhlaWdodD0iMTgwIiByeD0iMTYiIGNsYXNzPSJvayIvPjwvZz4KICA8cmVjdCB4PSI2OTQiIHk9IjUzNiIgd2lkdGg9IjE1MCIgaGVpZ2h0PSI5MCIgcng9IjEyIiBmaWxsPSIjZWFmM2ZmIiBzdHJva2U9IiMyZjdkZjYiIHN0cm9rZS13aWR0aD0iMyIvPgogIDxyZWN0IHg9Ijg4MCIgeT0iNTM2IiB3aWR0aD0iMTUwIiBoZWlnaHQ9IjkwIiByeD0iMTIiIGZpbGw9IiNlOWY3ZjQiIHN0cm9rZT0iIzEyOGY4MiIgc3Ryb2tlLXdpZHRoPSIzIi8+CiAgPHRleHQgeD0iNzA0IiB5PSI1NjYiIGNsYXNzPSJzbWFsbCBibHVlIj564oKBIOyxhO2DnTwvdGV4dD4KICA8dGV4dCB4PSI4OTAiIHk9IjU2NiIgY2xhc3M9InNtYWxsIHRlYWwiPnrigoMg7LGE7YOdPC90ZXh0PgogIDx0ZXh0IHg9IjcwNCIgeT0iNjAwIiBjbGFzcz0ic21hbGwgZ3JlZW4iPuKckyDtnZHsnpA8L3RleHQ+CiAgPHRleHQgeD0iODkwIiB5PSI2MDAiIGNsYXNzPSJzbWFsbCBncmVlbiI+4pyTIO2dkeyekDwvdGV4dD4KICA8dGV4dCB4PSIxMDQ0IiB5PSI2MjgiIGNsYXNzPSJzbWFsbCBtdXRlZCI+64KY66i47KeAIOyFgCDihpIg6rO164+Z67Cw7IahPC90ZXh0Pgo8L3N2Zz4K\" width=\"1200\" height=\"720\" class=\"img_ev3q\"></p>\n<div class=\"language-text codeBlockContainer_Ckt0 theme-code-block\" style=\"--prism-color:#393A34;--prism-background-color:#f6f8fa\"><div class=\"codeBlockContent_QJqH\"><pre tabindex=\"0\" class=\"prism-code language-text codeBlock_bY9V thin-scrollbar\" style=\"color:#393A34;background-color:#f6f8fa\"><code class=\"codeBlockLines_e6Vv\"><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token plain\">min  총비용</span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token plain\"> = min [ Σ 전용권역 차량비(z)  +  Σ 공동배송 박스 × p ]</span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token plain\"> = ( 전체 박스 × p )  −  Σ 선택권역 절감액(z)      ← 앞항은 상수</span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token plain\"> ⟺ max  Σ 선택권역 절감액(z)</span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token plain\">        s.t. 선택된 권역끼리 셀이 겹치지 않음,  절감액(z) &gt; 0</span><br></div></code></pre></div></div>\n<p>전체 물량에 단가를 곱한 값은 무엇을 선택하든 상수입니다. 따라서 <strong>총비용 최소화는 “겹치지 않는 흑자 권역들의 절감액 합을 최대화”하는 문제와 완전히 같습니다.</strong> 이는 전형적인 <strong>최대가중 집합 패킹(max-weight set packing)</strong> 이고, <code>maxZones</code> 같은 상한이 원천적으로 필요 없어집니다. 몇 대를 뽑을지는 최적화가 절감액을 보고 스스로 결정합니다.</p>\n<p>이 재정식화 덕분에 구조가 세 개의 레이어로 깔끔하게 갈라졌습니다.</p>\n<table><thead><tr><th>레이어</th><th>역할</th><th>핵심</th></tr></thead><tbody><tr><td>A. Routing</td><td>실행 가능성·비용 추정</td><td>이동시간 행렬 + 대표운행일 경로시간 게이트</td></tr><tr><td>B. Candidates</td><td>후보 권역 생성</td><td>폴리곤 인접 그래프를 따라 성장 → 중첩 허용 후보 풀</td></tr><tr><td>C. Optimize</td><td>선택</td><td>CP-SAT로 절감액 합 최대인 서로소 조합 선택</td></tr></tbody></table>\n<h2 class=\"anchor anchorTargetStickyNavbar_Vzrq\" id=\"레이어-a--크기를-정하는-건-반경이-아니라-시간\">레이어 A — 크기를 정하는 건 반경이 아니라 시간<a href=\"https://tech.umosone.ai/delivery-zone-optimization#%EB%A0%88%EC%9D%B4%EC%96%B4-a--%ED%81%AC%EA%B8%B0%EB%A5%BC-%EC%A0%95%ED%95%98%EB%8A%94-%EA%B1%B4-%EB%B0%98%EA%B2%BD%EC%9D%B4-%EC%95%84%EB%8B%88%EB%9D%BC-%EC%8B%9C%EA%B0%84\" class=\"hash-link\" aria-label=\"레이어 A — 크기를 정하는 건 반경이 아니라 시간에 대한 직접 링크\" title=\"레이어 A — 크기를 정하는 건 반경이 아니라 시간에 대한 직접 링크\" translate=\"no\">​</a></h2>\n<p>권역을 얼마나 크게 잡을지는 고정 반경으로 정하지 않았습니다. 실제 운영에서 권역 크기는 밀도의 결과였습니다. 서울 도심은 4km, 경기 외곽은 12km, 지방은 20km까지도 한 권역이 됩니다. 이걸 손으로 정하는 순간 현실과 어긋납니다.</p>\n<p>대신 <strong>유일한 크기 제약을 “시간”으로 뒀습니다.</strong> 한 권역의 대표적인 하루 운행이 근무시간 안에 끝나면 실행 가능, 넘으면 불가능입니다.</p>\n<div class=\"language-text codeBlockContainer_Ckt0 theme-code-block\" style=\"--prism-color:#393A34;--prism-background-color:#f6f8fa\"><div class=\"codeBlockContent_QJqH\"><pre tabindex=\"0\" class=\"prism-code language-text codeBlock_bY9V thin-scrollbar\" style=\"color:#393A34;background-color:#f6f8fa\"><code class=\"codeBlockLines_e6Vv\"><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token plain\">하루 경로시간 = 주행시간 + ( 상차·준비 + 배송지 수 × 하차서비스 )</span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token plain\">feasible(z)  = 대표운행일 경로시간(z) ≤ 근무창(450분)</span><br></div></code></pre></div></div>\n<p>이동시간 행렬은 오픈소스 라우팅 엔진으로 좌표 간 실측 이동시간을 뽑고(고유 좌표만 dedup, 48블록 단위, <code>.npz</code>로 캐시), 경로는 최근접 이웃(NN)으로 초기해를 만든 뒤 <strong>2-opt 로컬서치로 개선</strong>합니다.</p>\n<h3 class=\"anchor anchorTargetStickyNavbar_Vzrq\" id=\"실패담---nn만-쓰면-흑자-권역이-탈락한다\">실패담 ① — NN만 쓰면 흑자 권역이 탈락한다<a href=\"https://tech.umosone.ai/delivery-zone-optimization#%EC%8B%A4%ED%8C%A8%EB%8B%B4---nn%EB%A7%8C-%EC%93%B0%EB%A9%B4-%ED%9D%91%EC%9E%90-%EA%B6%8C%EC%97%AD%EC%9D%B4-%ED%83%88%EB%9D%BD%ED%95%9C%EB%8B%A4\" class=\"hash-link\" aria-label=\"실패담 ① — NN만 쓰면 흑자 권역이 탈락한다에 대한 직접 링크\" title=\"실패담 ① — NN만 쓰면 흑자 권역이 탈락한다에 대한 직접 링크\" translate=\"no\">​</a></h3>\n<p>처음엔 최근접 이웃 경로만으로 시간을 추정했습니다. 그런데 NN 단독 경로는 최적 대비 5~10% 과대추정을 합니다. 그 결과 실제로는 근무시간 안에 도는 흑자 권역이 “경로 초과”로 판정돼 탈락했습니다. 검토해 보니 탈락한 권역들은 경제성이 문제가 아니라 <strong>경로 추정이 부풀려진 것</strong>이었습니다. 2-opt를 붙이자 초과로 잘못 걸린 권역의 상당수가 회수됐습니다.</p>\n<h3 class=\"anchor anchorTargetStickyNavbar_Vzrq\" id=\"실패담---최악일-게이트는-현실을-과대평가한다\">실패담 ② — “최악일” 게이트는 현실을 과대평가한다<a href=\"https://tech.umosone.ai/delivery-zone-optimization#%EC%8B%A4%ED%8C%A8%EB%8B%B4---%EC%B5%9C%EC%95%85%EC%9D%BC-%EA%B2%8C%EC%9D%B4%ED%8A%B8%EB%8A%94-%ED%98%84%EC%8B%A4%EC%9D%84-%EA%B3%BC%EB%8C%80%ED%8F%89%EA%B0%80%ED%95%9C%EB%8B%A4\" class=\"hash-link\" aria-label=\"실패담 ② — “최악일” 게이트는 현실을 과대평가한다에 대한 직접 링크\" title=\"실패담 ② — “최악일” 게이트는 현실을 과대평가한다에 대한 직접 링크\" translate=\"no\">​</a></h3>\n<p>더 미묘한 함정이 있었습니다. 처음엔 “권역의 모든 배송지를 매일 다 돈다”는 가정(all-cells, 사실상 최번일)으로 시간을 쟀습니다. 그런데 실제 기사는 <strong>그날 주문이 있는 배송지만</strong> 돕니다. 최악일 기준으로 재면 멀쩡한 권역이 계속 불가능으로 찍혔습니다.</p>\n<p>그래서 게이트를 <strong>대표운행일(p50)</strong> 로 바꿨습니다. “그날 실제 주문이 있는 셀만 라우팅한 일별 소요의 중앙값이 근무시간 이하인가”로 판정합니다.</p>\n<div class=\"language-text codeBlockContainer_Ckt0 theme-code-block\" style=\"--prism-color:#393A34;--prism-background-color:#f6f8fa\"><div class=\"codeBlockContent_QJqH\"><pre tabindex=\"0\" class=\"prism-code language-text codeBlock_bY9V thin-scrollbar\" style=\"color:#393A34;background-color:#f6f8fa\"><code class=\"codeBlockLines_e6Vv\"><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token plain\">feasible(z) = median_over_days( 그날 활성 셀만 라우팅한 소요 )  ≤ 근무창</span><br></div></code></pre></div></div>\n<p>여기엔 반직관적인 성능 함정이 하나 더 있었습니다. “최악일이 통과하면 중앙값도 당연히 통과할 테니 건너뛰자”고 최적화하려 했는데, <strong>최악일은 중앙값의 상한이 아니었습니다.</strong> 희소한 권역에서는 평균 배송지 수가 0인 날에 희석돼 최악일 추정이 오히려 중앙값보다 작게 나오는 경우가 있었습니다(실제로 이 때문에 근무초과 권역이 통과된 버그가 있었습니다). 해결책은 <strong>진짜 상한이 보장되는 피크일 기반 상한값</strong>을 따로 만들어, 그게 근무시간 이하일 때만 중앙값 계산을 건너뛰게 한 것입니다. 이 “값싼 문턱” 덕분에 1,200개가 넘는 씨앗을 몇 분 만에 처리할 수 있었습니다.</p>\n<h2 class=\"anchor anchorTargetStickyNavbar_Vzrq\" id=\"레이어-a--도메인-제약을-코드로-옮기기\">레이어 A½ — 도메인 제약을 코드로 옮기기<a href=\"https://tech.umosone.ai/delivery-zone-optimization#%EB%A0%88%EC%9D%B4%EC%96%B4-a--%EB%8F%84%EB%A9%94%EC%9D%B8-%EC%A0%9C%EC%95%BD%EC%9D%84-%EC%BD%94%EB%93%9C%EB%A1%9C-%EC%98%AE%EA%B8%B0%EA%B8%B0\" class=\"hash-link\" aria-label=\"레이어 A½ — 도메인 제약을 코드로 옮기기에 대한 직접 링크\" title=\"레이어 A½ — 도메인 제약을 코드로 옮기기에 대한 직접 링크\" translate=\"no\">​</a></h2>\n<p>여기가 이 프로젝트에서 가장 “도메인”스러운 부분입니다. 순수한 거리 문제라면 필요 없지만, <strong>실제 배송을 검증하려면 반드시 있어야 하는</strong> 제약들입니다.</p>\n<p><img decoding=\"async\" loading=\"lazy\" alt=\"권역 크기를 결정하는 도메인 제약\" src=\"data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxMjAwIiBoZWlnaHQ9IjYyMCIgdmlld0JveD0iMCAwIDEyMDAgNjIwIiByb2xlPSJpbWciIGFyaWEtbGFiZWxsZWRieT0idGl0bGUgZGVzYyI+CiAgPHRpdGxlIGlkPSJ0aXRsZSI+6raM7JetIO2BrOq4sOulvCDqsrDsoJXtlZjripQg64+E66mU7J24IOygnOyVvTwvdGl0bGU+CiAgPGRlc2MgaWQ9ImRlc2MiPuq3vOustOywvSDsi5zqsIQsIOqwlSDrtojqsIDsuagsIO2WieygleqyveqzhCAy6rCcIOydtO2VmCwg7JuUIOyaqeufiSDsg4HtlZzsnbTrnbzripQg64SkIOqwgOyngCDrj4TrqZTsnbgg7KCc7JW97J20IOq2jOyXrSDtgazquLDrpbwg67CA64+E7JeQIOuUsOudvCDsnpDrj5nsnLzroZwg7KCV7ZWc64uk64qUIOqyg+ydhCDrs7Tsl6zso7zripQg6re466a8PC9kZXNjPgogIDxkZWZzPgogICAgPHN0eWxlPgogICAgICAuYmcgeyBmaWxsOiAjZjVmOGZjOyB9CiAgICAgIC5jYXJkIHsgZmlsbDogI2ZmZmZmZjsgc3Ryb2tlOiAjZDdkZWVhOyBzdHJva2Utd2lkdGg6IDI7IH0KICAgICAgLmJsdWUgeyBmaWxsOiAjMmY3ZGY2OyB9IC50ZWFsIHsgZmlsbDogIzEyOGY4MjsgfSAucHVycGxlIHsgZmlsbDogIzdjNWNmZjsgfQogICAgICAuY29yYWwgeyBmaWxsOiAjZDk1NjQ4OyB9IC5ncmVlbiB7IGZpbGw6ICMyODdmNTI7IH0gLmFtYmVyIHsgZmlsbDogI2I0N2ExNjsgfQogICAgICAubXV0ZWQgeyBmaWxsOiAjNjg3Mzg2OyB9IC5pbmsgeyBmaWxsOiAjMTIxNzIyOyB9IC53aGl0ZSB7IGZpbGw6ICNmZmZmZmY7IH0KICAgICAgLnRpdGxlIHsgZm9udDogODAwIDQwcHggdWktc2Fucy1zZXJpZiwgc3lzdGVtLXVpLCAiU2Vnb2UgVUkiLCBzYW5zLXNlcmlmOyB9CiAgICAgIC5zdWJ0aXRsZSB7IGZvbnQ6IDUwMCAyMXB4IHVpLXNhbnMtc2VyaWYsIHN5c3RlbS11aSwgIlNlZ29lIFVJIiwgc2Fucy1zZXJpZjsgfQogICAgICAubGFiZWwgeyBmb250OiA4MDAgMjJweCB1aS1zYW5zLXNlcmlmLCBzeXN0ZW0tdWksICJTZWdvZSBVSSIsIHNhbnMtc2VyaWY7IH0KICAgICAgLmJvZHkgeyBmb250OiA2MDAgMTdweCB1aS1zYW5zLXNlcmlmLCBzeXN0ZW0tdWksICJTZWdvZSBVSSIsIHNhbnMtc2VyaWY7IH0KICAgICAgLnNtYWxsIHsgZm9udDogNzAwIDE1cHggdWktc2Fucy1zZXJpZiwgc3lzdGVtLXVpLCAiU2Vnb2UgVUkiLCBzYW5zLXNlcmlmOyB9CiAgICAgIC5zaGFkb3cgeyBmaWx0ZXI6IGRyb3Atc2hhZG93KDAgMTRweCAyNHB4IHJnYigzMSA0NSA2NiAvIDAuMTApKTsgfQogICAgPC9zdHlsZT4KICA8L2RlZnM+CiAgPHJlY3QgY2xhc3M9ImJnIiB3aWR0aD0iMTIwMCIgaGVpZ2h0PSI2MjAiLz4KICA8dGV4dCB4PSI3MiIgeT0iNzYiIGNsYXNzPSJ0aXRsZSBpbmsiPuq2jOyXrSDtgazquLDripQg7J6F66Cl7J20IOyVhOuLiOudvCwg7KCc7JW97J2YIOqysOqzvDwvdGV4dD4KICA8dGV4dCB4PSI3MiIgeT0iMTEwIiBjbGFzcz0ic3VidGl0bGUgbXV0ZWQiPuuwmOqyveydhCDshpDsnLzroZwg7KCV7ZWY7KeAIOyViuyKteuLiOuLpC4g64SkIOqwnOydmCDrj4TrqZTsnbgg7KCc7JW97J20IOKAnOyImOuPhOq2jOydgCDsnpHqsozCt+yngOuwqeydgCDrhJPqsozigJ3rpbwg7J6Q64+Z7Jy866GcIOunjOuTreuLiOuLpC48L3RleHQ+CgogIDwhLS0gQ2FyZCAxOiB3b3JrIHdpbmRvdyAtLT4KICA8ZyBjbGFzcz0ic2hhZG93Ij48cmVjdCB4PSI3MiIgeT0iMTUwIiB3aWR0aD0iMjUyIiBoZWlnaHQ9IjQxMCIgcng9IjE4IiBjbGFzcz0iY2FyZCIvPjwvZz4KICA8Y2lyY2xlIGN4PSIxMTgiIGN5PSIxOTYiIHI9IjE2IiBjbGFzcz0iYmx1ZSIvPgogIDx0ZXh0IHg9IjE0NSIgeT0iMjAzIiBjbGFzcz0ibGFiZWwgaW5rIj7qt7zrrLTssL0gNDUw67aEPC90ZXh0PgogIDx0ZXh0IHg9Ijk2IiB5PSIyNDAiIGNsYXNzPSJib2R5IG11dGVkIj7ssqsg67Cw7IahfuuniOyngOuniSDrsLDshqE8L3RleHQ+CiAgPGxpbmUgeDE9IjExMCIgeTE9IjMwMCIgeDI9IjI4NiIgeTI9IjMwMCIgc3Ryb2tlPSIjMmY3ZGY2IiBzdHJva2Utd2lkdGg9IjYiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIvPgogIDxjaXJjbGUgY3g9IjExMCIgY3k9IjMwMCIgcj0iOCIgY2xhc3M9ImJsdWUiLz48Y2lyY2xlIGN4PSIyODYiIGN5PSIzMDAiIHI9IjgiIGNsYXNzPSJibHVlIi8+CiAgPHRleHQgeD0iOTYiIHk9IjM0NSIgY2xhc3M9InNtYWxsIGluayI+7KO87ZaJICsg7ZWY7LCo7ISc67mE7IqkPC90ZXh0PgogIDx0ZXh0IHg9Ijk2IiB5PSIzNzIiIGNsYXNzPSJzbWFsbCBpbmsiPuKJpCA0NTDrtoQgKOuyhO2NvCAzMOu2hCk8L3RleHQ+CiAgPHJlY3QgeD0iOTYiIHk9IjQwMCIgd2lkdGg9IjIwNCIgaGVpZ2h0PSIxMzAiIHJ4PSIxMCIgZmlsbD0iI2Y1ZjhmYyIvPgogIDx0ZXh0IHg9IjExMiIgeT0iNDI4IiBjbGFzcz0ic21hbGwgbXV0ZWQiPuyKpO2FnCjqsbDsoJDihpTqtozsl60p7J2APC90ZXh0PgogIDx0ZXh0IHg9IjExMiIgeT0iNDUyIiBjbGFzcz0ic21hbGwgbXV0ZWQiPuq3vOustOywvSDrsJYg4oCUIOyDiOuyvTwvdGV4dD4KICA8dGV4dCB4PSIxMTIiIHk9IjQ3NiIgY2xhc3M9InNtYWxsIG11dGVkIj7sg4HssKjCt+uzteq3gOuhnCDsoJzsmbg8L3RleHQ+CiAgPHRleHQgeD0iMTEyIiB5PSI1MDgiIGNsYXNzPSJzbWFsbCBibHVlIj49IOyLpCDrsLDshqEg7Iuc6rCE66eMIOqyjOydtO2KuDwvdGV4dD4KCiAgPCEtLSBDYXJkIDI6IHJpdmVyIC0tPgogIDxnIGNsYXNzPSJzaGFkb3ciPjxyZWN0IHg9IjMzNiIgeT0iMTUwIiB3aWR0aD0iMjUyIiBoZWlnaHQ9IjQxMCIgcng9IjE4IiBjbGFzcz0iY2FyZCIvPjwvZz4KICA8Y2lyY2xlIGN4PSIzODIiIGN5PSIxOTYiIHI9IjE2IiBjbGFzcz0idGVhbCIvPgogIDx0ZXh0IHg9IjQwOSIgeT0iMjAzIiBjbGFzcz0ibGFiZWwgaW5rIj7qsJUg67aI6rCA7LmoPC90ZXh0PgogIDx0ZXh0IHg9IjM2MCIgeT0iMjQwIiBjbGFzcz0iYm9keSBtdXRlZCI+64uk66asIOyVhOuLjCDsnbjsoJEg7LCo64uoPC90ZXh0PgogIDxwYXRoIGQ9Ik0zNjAsNDIwIHE2MCwtOTAgMTEwLC00MCBxNTAsNTAgMTAwLC0zMCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjMmY3ZGY2IiBzdHJva2Utd2lkdGg9IjEwIiBzdHJva2UtbGluZWNhcD0icm91bmQiIG9wYWNpdHk9IjAuNSIvPgogIDxjaXJjbGUgY3g9IjQwMCIgY3k9IjMzMCIgcj0iOSIgY2xhc3M9InRlYWwiLz4KICA8Y2lyY2xlIGN4PSI1MDAiIGN5PSI0NDAiIHI9IjkiIGNsYXNzPSJjb3JhbCIvPgogIDxsaW5lIHgxPSI0MDAiIHkxPSIzMzAiIHgyPSI1MDAiIHkyPSI0NDAiIHN0cm9rZT0iI2Q5NTY0OCIgc3Ryb2tlLXdpZHRoPSI0IiBzdHJva2UtZGFzaGFycmF5PSI3IDciLz4KICA8dGV4dCB4PSI0NzAiIHk9IjM4OCIgY2xhc3M9InNtYWxsIGNvcmFsIj7inJU8L3RleHQ+CiAgPHRleHQgeD0iMzYwIiB5PSI1MDAiIGNsYXNzPSJzbWFsbCBpbmsiPuykkeyLrOyEoOu2hOydtCDqsJXqs7wg6rWQ7LCoIOKGkjwvdGV4dD4KICA8dGV4dCB4PSIzNjAiIHk9IjUyNCIgY2xhc3M9InNtYWxsIGluayI+7J247KCRwrfri6Trpqwg6rCE7ISgIOygnOqxsDwvdGV4dD4KCiAgPCEtLSBDYXJkIDM6IGFkbWluIGJvdW5kYXJ5IDw9MiAtLT4KICA8ZyBjbGFzcz0ic2hhZG93Ij48cmVjdCB4PSI2MDAiIHk9IjE1MCIgd2lkdGg9IjI1MiIgaGVpZ2h0PSI0MTAiIHJ4PSIxOCIgY2xhc3M9ImNhcmQiLz48L2c+CiAgPGNpcmNsZSBjeD0iNjQ2IiBjeT0iMTk2IiByPSIxNiIgY2xhc3M9InB1cnBsZSIvPgogIDx0ZXh0IHg9IjY3MyIgeT0iMjAzIiBjbGFzcz0ibGFiZWwgaW5rIj7si5zqtbDqtawg4omkIDI8L3RleHQ+CiAgPHRleHQgeD0iNjI0IiB5PSIyNDAiIGNsYXNzPSJib2R5IG11dGVkIj7qtozsl63snbQg6rG47LmY64qUIOqyveqzhCDsoJztlZw8L3RleHQ+CiAgPHJlY3QgeD0iNjI4IiB5PSIyOTAiIHdpZHRoPSI5MCIgaGVpZ2h0PSI5MCIgcng9IjEwIiBmaWxsPSIjZjBlY2ZmIiBzdHJva2U9IiM3YzVjZmYiIHN0cm9rZS13aWR0aD0iMiIvPgogIDxyZWN0IHg9IjcyNiIgeT0iMjkwIiB3aWR0aD0iOTAiIGhlaWdodD0iOTAiIHJ4PSIxMCIgZmlsbD0iI2YwZWNmZiIgc3Ryb2tlPSIjN2M1Y2ZmIiBzdHJva2Utd2lkdGg9IjIiLz4KICA8cmVjdCB4PSI2NzgiIHk9IjQwMCIgd2lkdGg9IjkwIiBoZWlnaHQ9IjcwIiByeD0iMTAiIGZpbGw9IiNmZmY1ZjQiIHN0cm9rZT0iI2Q5NTY0OCIgc3Ryb2tlLXdpZHRoPSIyIiBzdHJva2UtZGFzaGFycmF5PSI2IDUiLz4KICA8dGV4dCB4PSI2NjIiIHk9IjM0MiIgY2xhc3M9InNtYWxsIHB1cnBsZSI+7Iuc6rWw6rWsIEE8L3RleHQ+CiAgPHRleHQgeD0iNzYwIiB5PSIzNDIiIGNsYXNzPSJzbWFsbCBwdXJwbGUiPuyLnOq1sOq1rCBCPC90ZXh0PgogIDx0ZXh0IHg9IjcwMCIgeT0iNDQwIiBjbGFzcz0ic21hbGwgY29yYWwiPjPrsojsp7gg4pyVPC90ZXh0PgogIDx0ZXh0IHg9IjYyNCIgeT0iNTEwIiBjbGFzcz0ic21hbGwgaW5rIj7su7TtjKntirgg6raM7JetIOKGkiDso7ztlonihpM8L3RleHQ+CiAgPHRleHQgeD0iNjI0IiB5PSI1MzQiIGNsYXNzPSJzbWFsbCBpbmsiPuKGkiDqsJnsnYAg7Iuc6rCE7JeQIOusvOufieKGkTwvdGV4dD4KCiAgPCEtLSBDYXJkIDQ6IGNhcGFjaXR5ICsgc3BsaXQgLS0+CiAgPGcgY2xhc3M9InNoYWRvdyI+PHJlY3QgeD0iODY0IiB5PSIxNTAiIHdpZHRoPSIyNjQiIGhlaWdodD0iNDEwIiByeD0iMTgiIGNsYXNzPSJjYXJkIi8+PC9nPgogIDxjaXJjbGUgY3g9IjkxMCIgY3k9IjE5NiIgcj0iMTYiIGNsYXNzPSJhbWJlciIvPgogIDx0ZXh0IHg9IjkzNyIgeT0iMjAzIiBjbGFzcz0ibGFiZWwgaW5rIj7sm5Qg7Jqp65+JIOyDge2VnDwvdGV4dD4KICA8dGV4dCB4PSI4ODgiIHk9IjI0MCIgY2xhc3M9ImJvZHkgbXV0ZWQiPjHrjIAgPSDsm5QgMSw3NTDrsJXsiqQ8L3RleHQ+CiAgPHJlY3QgeD0iOTAwIiB5PSIyOTAiIHdpZHRoPSIyMDAiIGhlaWdodD0iMzQiIHJ4PSI4IiBmaWxsPSIjZjVmOGZjIiBzdHJva2U9IiNkN2RlZWEiLz4KICA8cmVjdCB4PSI5MDAiIHk9IjI5MCIgd2lkdGg9IjE1MCIgaGVpZ2h0PSIzNCIgcng9IjgiIGZpbGw9IiNmZmY1ZTkiIHN0cm9rZT0iI2I0N2ExNiIgc3Ryb2tlLXdpZHRoPSIyIi8+CiAgPHRleHQgeD0iOTEyIiB5PSIzMTMiIGNsYXNzPSJzbWFsbCBhbWJlciI+7KCE7JqpIOyGjO2ZlOu2hDwvdGV4dD4KICA8dGV4dCB4PSIxMDU4IiB5PSIzMTMiIGNsYXNzPSJzbWFsbCBtdXRlZCI+7IOB7ZWcPC90ZXh0PgogIDx0ZXh0IHg9Ijg4OCIgeT0iMzcyIiBjbGFzcz0ic21hbGwgaW5rIj7qs6DrsIDrj4Qg64+Z7J2AIDUw67CV7IqkPC90ZXh0PgogIDx0ZXh0IHg9Ijg4OCIgeT0iMzk2IiBjbGFzcz0ic21hbGwgaW5rIj7sobDqsIHsnLzroZwg67aE7ZWgIOKGkjwvdGV4dD4KICA8dGV4dCB4PSI4ODgiIHk9IjQyMCIgY2xhc3M9InNtYWxsIGluayI+7Jes65+sIOq4sOyCrMK36rO164+Z67Cw7Iah7J20PC90ZXh0PgogIDx0ZXh0IHg9Ijg4OCIgeT0iNDQ0IiBjbGFzcz0ic21hbGwgaW5rIj7rgpjriKAg64u07J2MPC90ZXh0PgogIDxyZWN0IHg9Ijg4OCIgeT0iNDcwIiB3aWR0aD0iMjE2IiBoZWlnaHQ9IjYwIiByeD0iMTAiIGZpbGw9IiNmM2ZhZjYiLz4KICA8dGV4dCB4PSI5MDQiIHk9IjQ5NyIgY2xhc3M9InNtYWxsIGdyZWVuIj7rhJjsuZjrqbQg6rO164+Z67Cw7Iah7Jy866GcPC90ZXh0PgogIDx0ZXh0IHg9IjkwNCIgeT0iNTE5IiBjbGFzcz0ic21hbGwgZ3JlZW4iPuyekOuPmSDrgpnssKkgKOuzhOuPhCDroZzsp4EgWCk8L3RleHQ+Cjwvc3ZnPgo=\" width=\"1200\" height=\"620\" class=\"img_ev3q\"></p>\n<p><strong>① 근무창 450분 (스템 제외).</strong> 근무 8시간에서 식사·버퍼 30분을 뺀 7시간 30분을 예산으로 둡니다. 중요한 건 <strong>무엇을 이 예산에 넣느냐</strong>였습니다. 기사는 새벽에 거점에서 상차하고 첫 배송지로 이동하므로, 거점↔권역 왕복(스템) 주행은 근무창에서 제외하고 <strong>첫 배송지 도착부터 마지막 배송 완료까지</strong>만 잽니다. 이 구분을 하지 않았을 때는 거점에서 먼 권역(도심·외곽)이 왕복 스템 때문에 과잉 페널티를 받아 부당하게 탈락했습니다. (단, 계약 거리는 기사의 실주행이므로 차량비 계산에는 스템 왕복을 포함합니다. 시간 모델과 비용 모델의 거리 정의가 다르다는 점이 처음엔 헷갈렸습니다.)</p>\n<p><strong>② 강 불가침.</strong> “한강을 건너 배송하지 않는다”는 현장 규칙입니다. 손으로 그린 근사 중심선(본류 + 지류) GeoJSON을 두고, 두 셀의 중심을 잇는 선분이 이 강과 교차하면 <strong>인접이 아닌 것으로 간주</strong>해 인접·다리 간선을 제거했습니다. 이것만으로 강을 넘는 인접 쌍 100여 개가 사라졌습니다. 지리 데이터 없이 인접 그래프만 믿었다면, 다리도 없는 곳을 “가깝다”고 묶었을 겁니다.</p>\n<p><strong>③ 한 권역이 걸치는 시군구 ≤ 2.</strong> 성장 과정에서 한 권역이 세 번째 시군구로 번지지 않도록 막았습니다. 관리·정산 단위가 깔끔해지는 운영상의 이유도 있지만, 흥미로운 건 <strong>이 제약이 비용도 개선</strong>했다는 점입니다. 권역이 컴팩트해지면 주행이 짧아지고, 같은 450분 안에 더 많은 박스를 소화할 수 있어 전용 물량이 늘고 비용이 내려갔습니다. 여기서 “구가 있는 모든 시는 시+구를 한 단위로” 같은 행정구역 처리가 필요했는데, 특례시(예: 천안·청주 등도 시+구 구조)를 특정 지역만 하드코딩하면 다른 지역의 동이 통째로 사라지는 버그가 있어, 주소 레벨을 일반 규칙으로 판정하도록 고쳤습니다.</p>\n<p><strong>④ 월 용량 상한과 셀 분할.</strong> 한 대가 월 1,750박스까지 담는다고 두고, 물량이 큰 읍면동은 50박스 단위 조각으로 쪼갰습니다(월 운행일 × 배송지당 평균 박스 ≈ 50). 이렇게 하면 한 동을 <strong>여러 기사와 공동배송이 나눠 담을 수 있고</strong>, 넘치는 물량은 자동으로 공동배송으로 낙착됩니다. “한 동에 물량이 몰리면 어떻게 나눌까”라는 질문이 별도 로직 없이, 조각 분할 + 중첩 후보 + 최적화 선택의 조합으로 자연스럽게 풀립니다.</p>\n<p>이 네 제약이 함께 작동하면, 반경을 한 번도 지정하지 않았는데도 <strong>“수도권은 작게, 지방은 넓게”가 저절로</strong> 만들어집니다. 권역 크기는 입력이 아니라 제약의 결과입니다.</p>\n<h2 class=\"anchor anchorTargetStickyNavbar_Vzrq\" id=\"레이어-bc--후보를-넉넉히-만들고-최적으로-고른다\">레이어 B·C — 후보를 넉넉히 만들고, 최적으로 고른다<a href=\"https://tech.umosone.ai/delivery-zone-optimization#%EB%A0%88%EC%9D%B4%EC%96%B4-bc--%ED%9B%84%EB%B3%B4%EB%A5%BC-%EB%84%89%EB%84%89%ED%9E%88-%EB%A7%8C%EB%93%A4%EA%B3%A0-%EC%B5%9C%EC%A0%81%EC%9C%BC%EB%A1%9C-%EA%B3%A0%EB%A5%B8%EB%8B%A4\" class=\"hash-link\" aria-label=\"레이어 B·C — 후보를 넉넉히 만들고, 최적으로 고른다에 대한 직접 링크\" title=\"레이어 B·C — 후보를 넉넉히 만들고, 최적으로 고른다에 대한 직접 링크\" translate=\"no\">​</a></h2>\n<p>레이어 B는 폴리곤 인접 그래프를 따라 <strong>모든 셀을 씨앗으로</strong> 성장시킵니다. 손익분기를 넘는 순간과 시간 한계에 닿는 순간 등 여러 크기에서 후보를 방출하므로, 셀이 서로 겹치는 후보 풀이 만들어집니다. 풀의 다양성이 클수록 최적 조합이 그 안에 존재할 확률이 높아집니다.</p>\n<p>레이어 C는 OR-Tools CP-SAT로 집합 패킹을 풉니다.</p>\n<div class=\"language-text codeBlockContainer_Ckt0 theme-code-block\" style=\"--prism-color:#393A34;--prism-background-color:#f6f8fa\"><div class=\"codeBlockContent_QJqH\"><pre tabindex=\"0\" class=\"prism-code language-text codeBlock_bY9V thin-scrollbar\" style=\"color:#393A34;background-color:#f6f8fa\"><code class=\"codeBlockLines_e6Vv\"><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token plain\">변수:  x_z ∈ {0,1}                       # 후보 권역 z 채택 여부</span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token plain\">제약:  각 셀 c 에 대해  Σ_{z ∋ c} x_z ≤ 1   # 셀 중첩 금지 (미커버 = 공동배송)</span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token plain\">목적:  max  Σ_z  x_z · 절감액(z)            # 절감액&gt;0 후보만 풀에 포함</span><br></div></code></pre></div></div>\n<p><code>maxZones</code>도 반경도 없습니다. 겹치지 않게 고르면서 절감액 합이 최대가 되는 조합을 CP-SAT가 찾고, 어디에도 뽑히지 않은 셀은 공동배송으로 남습니다.</p>\n<h2 class=\"anchor anchorTargetStickyNavbar_Vzrq\" id=\"상용화의-갈림길--답을-주는-도구가-아니라-검증하는-도구\">상용화의 갈림길 — “답을 주는 도구”가 아니라 “검증하는 도구”<a href=\"https://tech.umosone.ai/delivery-zone-optimization#%EC%83%81%EC%9A%A9%ED%99%94%EC%9D%98-%EA%B0%88%EB%A6%BC%EA%B8%B8--%EB%8B%B5%EC%9D%84-%EC%A3%BC%EB%8A%94-%EB%8F%84%EA%B5%AC%EA%B0%80-%EC%95%84%EB%8B%88%EB%9D%BC-%EA%B2%80%EC%A6%9D%ED%95%98%EB%8A%94-%EB%8F%84%EA%B5%AC\" class=\"hash-link\" aria-label=\"상용화의 갈림길 — “답을 주는 도구”가 아니라 “검증하는 도구”에 대한 직접 링크\" title=\"상용화의 갈림길 — “답을 주는 도구”가 아니라 “검증하는 도구”에 대한 직접 링크\" translate=\"no\">​</a></h2>\n<p>여기까지가 최적화입니다. 그런데 저희가 정말 신경 쓴 부분은 그다음이었습니다. <strong>모델은 낙관한다</strong>는 것을 알고 있었기 때문입니다.</p>\n<p>후보 생성은 읍면동 <strong>중심점</strong> 기준으로 빠르게 경로를 추정합니다. 중심점은 셀 내부에 흩어진 실제 배송지들을 무시하기 때문에, 특히 배송지가 듬성듬성한 희소 권역에서 소요 시간을 과소평가합니다. 이 낙관을 그대로 “권장안”으로 내보내면, 현장에서 “계산은 되는데 실제로는 안 돌아가는” 안을 파는 셈이 됩니다.</p>\n<p><img decoding=\"async\" loading=\"lazy\" alt=\"낙관적 모델을 실측으로 검증하는 루프\" src=\"data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxMjAwIiBoZWlnaHQ9IjU2MCIgdmlld0JveD0iMCAwIDEyMDAgNTYwIiByb2xlPSJpbWciIGFyaWEtbGFiZWxsZWRieT0idGl0bGUgZGVzYyI+CiAgPHRpdGxlIGlkPSJ0aXRsZSI+64KZ6rSA7KCBIOuqqOuNuOydhCDsi6TsuKHsnLzroZwg6rKA7Kad7ZWY64qUIOujqO2UhDwvdGl0bGU+CiAgPGRlc2MgaWQ9ImRlc2MiPuykkeyLrOygkCDquLDrsJgg66qo64247J20IO2bhOuztOulvCDrp4zrk6Tqs6AsIOyLpOygnCDrsLDshqHsp4Ag7KKM7ZGc66GcIOyLpOy4oSDrnbzsmrDtjIXtlbQg6rKA7Kad7ZWY66mwLCDsspjrpqzrn4kg7YWM7Iqk7Yq466GcIOyGkOydteu2hOq4sCDsnbTsg4Eg67Cw7IahIOqwgOuKpe2VnOyngCDtjJDsoJXtlZjripQgM+uLqOqzhCDtnZDrpoQ8L2Rlc2M+CiAgPGRlZnM+CiAgICA8c3R5bGU+CiAgICAgIC5iZyB7IGZpbGw6ICNmNWY4ZmM7IH0KICAgICAgLmNhcmQgeyBmaWxsOiAjZmZmZmZmOyBzdHJva2U6ICNkN2RlZWE7IHN0cm9rZS13aWR0aDogMjsgfQogICAgICAub2sgeyBmaWxsOiAjZjNmYWY2OyBzdHJva2U6ICMyODdmNTI7IHN0cm9rZS13aWR0aDogMjsgfQogICAgICAud2FybiB7IGZpbGw6ICNmZmY5ZWM7IHN0cm9rZTogI2I0N2ExNjsgc3Ryb2tlLXdpZHRoOiAyOyB9CiAgICAgIC5ibHVlIHsgZmlsbDogIzJmN2RmNjsgfSAudGVhbCB7IGZpbGw6ICMxMjhmODI7IH0gLmFtYmVyIHsgZmlsbDogI2I0N2ExNjsgfQogICAgICAuY29yYWwgeyBmaWxsOiAjZDk1NjQ4OyB9IC5ncmVlbiB7IGZpbGw6ICMyODdmNTI7IH0KICAgICAgLm11dGVkIHsgZmlsbDogIzY4NzM4NjsgfSAuaW5rIHsgZmlsbDogIzEyMTcyMjsgfSAud2hpdGUgeyBmaWxsOiAjZmZmZmZmOyB9CiAgICAgIC50aXRsZSB7IGZvbnQ6IDgwMCA0MHB4IHVpLXNhbnMtc2VyaWYsIHN5c3RlbS11aSwgIlNlZ29lIFVJIiwgc2Fucy1zZXJpZjsgfQogICAgICAuc3VidGl0bGUgeyBmb250OiA1MDAgMjFweCB1aS1zYW5zLXNlcmlmLCBzeXN0ZW0tdWksICJTZWdvZSBVSSIsIHNhbnMtc2VyaWY7IH0KICAgICAgLmxhYmVsIHsgZm9udDogODAwIDIycHggdWktc2Fucy1zZXJpZiwgc3lzdGVtLXVpLCAiU2Vnb2UgVUkiLCBzYW5zLXNlcmlmOyB9CiAgICAgIC5ib2R5IHsgZm9udDogNjAwIDE3cHggdWktc2Fucy1zZXJpZiwgc3lzdGVtLXVpLCAiU2Vnb2UgVUkiLCBzYW5zLXNlcmlmOyB9CiAgICAgIC5zbWFsbCB7IGZvbnQ6IDcwMCAxNXB4IHVpLXNhbnMtc2VyaWYsIHN5c3RlbS11aSwgIlNlZ29lIFVJIiwgc2Fucy1zZXJpZjsgfQogICAgICAubW9ubyB7IGZvbnQ6IDcwMCAxOHB4IHVpLW1vbm9zcGFjZSwgTWVubG8sIG1vbm9zcGFjZTsgfQogICAgICAuc2hhZG93IHsgZmlsdGVyOiBkcm9wLXNoYWRvdygwIDE0cHggMjRweCByZ2IoMzEgNDUgNjYgLyAwLjEwKSk7IH0KICAgIDwvc3R5bGU+CiAgICA8bWFya2VyIGlkPSJhaDMiIG1hcmtlcldpZHRoPSIxMCIgbWFya2VySGVpZ2h0PSIxMCIgcmVmWD0iOSIgcmVmWT0iNSIgb3JpZW50PSJhdXRvIj48cGF0aCBkPSJNMCwwIEwxMCw1IEwwLDEwIFoiIGZpbGw9IiM3Yjg3OTciLz48L21hcmtlcj4KICA8L2RlZnM+CiAgPHJlY3QgY2xhc3M9ImJnIiB3aWR0aD0iMTIwMCIgaGVpZ2h0PSI1NjAiLz4KICA8dGV4dCB4PSI3MiIgeT0iNzYiIGNsYXNzPSJ0aXRsZSBpbmsiPuuqqOuNuOydgCDrgpnqtIDtlZzri6Qg4oCUIOq3uOuemOyEnCDsi6TsuKHsnLzroZwg65CY64+M66aw64ukPC90ZXh0PgogIDx0ZXh0IHg9IjcyIiB5PSIxMTAiIGNsYXNzPSJzdWJ0aXRsZSBtdXRlZCI+7LWc7KCB7ZmU6rCAIOuLteydhCDigJzso7zripTigJ0g6rKMIOyVhOuLiOudvCwg7ZiE7J6l7JeQ7IScIOuwqeyWtCDqsIDriqXtlZzsp4Drpbwg6rKA7Kad7ZWY64qUIOuPhOq1rOqwgCDrkJjqsowg7ZWp64uI64ukLjwvdGV4dD4KCiAgPCEtLSBTdGVwIDEgLS0+CiAgPGcgY2xhc3M9InNoYWRvdyI+PHJlY3QgeD0iNzIiIHk9IjE2MCIgd2lkdGg9IjMyMCIgaGVpZ2h0PSIyNTAiIHJ4PSIxOCIgY2xhc3M9ImNhcmQiLz48L2c+CiAgPGNpcmNsZSBjeD0iMTEyIiBjeT0iMjA0IiByPSIxNSIgY2xhc3M9ImJsdWUiLz48dGV4dCB4PSIxMzAiIHk9IjIxMSIgY2xhc3M9InNtYWxsIHdoaXRlIj4xPC90ZXh0PgogIDx0ZXh0IHg9IjE0MCIgeT0iMjExIiBjbGFzcz0ibGFiZWwgaW5rIj7spJHsi6zsoJAg66qo6424PC90ZXh0PgogIDx0ZXh0IHg9IjEwMCIgeT0iMjUyIiBjbGFzcz0iYm9keSBtdXRlZCI+7J2N66m064+ZIOykkeyLrOygkCDCtyBOTuKGkjItb3B0PC90ZXh0PgogIDx0ZXh0IHg9IjEwMCIgeT0iMjg4IiBjbGFzcz0ic21hbGwgaW5rIj7ruaDrpbTqsowg7IiY7LKcIOyUqOyVl+ydhCDshLHsnqXsi5zsvJw8L3RleHQ+CiAgPHRleHQgeD0iMTAwIiB5PSIzMTIiIGNsYXNzPSJzbWFsbCBpbmsiPu2bhOuztCDqtozsl60g7ZKA7J2EIOyDneyEsTwvdGV4dD4KICA8cmVjdCB4PSIxMDAiIHk9IjMzNiIgd2lkdGg9IjI2NCIgaGVpZ2h0PSI1MiIgcng9IjEwIiBjbGFzcz0id2FybiIvPgogIDx0ZXh0IHg9IjExNiIgeT0iMzYwIiBjbGFzcz0ic21hbGwgYW1iZXIiPuyKpOy6kO2EsCDrrLTsi5wg4oaSIO2drOyGjCDqtozsl63snYA8L3RleHQ+CiAgPHRleHQgeD0iMTE2IiB5PSIzODIiIGNsYXNzPSJzbWFsbCBhbWJlciI+64KZ6rSAIO2OuO2WpSjqs7zshozstpTsoJUpPC90ZXh0PgoKICA8IS0tIFN0ZXAgMiAtLT4KICA8ZyBjbGFzcz0ic2hhZG93Ij48cmVjdCB4PSI0NDAiIHk9IjE2MCIgd2lkdGg9IjMyMCIgaGVpZ2h0PSIyNTAiIHJ4PSIxOCIgY2xhc3M9ImNhcmQiLz48L2c+CiAgPGNpcmNsZSBjeD0iNDgwIiBjeT0iMjA0IiByPSIxNSIgY2xhc3M9InRlYWwiLz48dGV4dCB4PSI0OTgiIHk9IjIxMSIgY2xhc3M9InNtYWxsIHdoaXRlIj4yPC90ZXh0PgogIDx0ZXh0IHg9IjUwOCIgeT0iMjExIiBjbGFzcz0ibGFiZWwgaW5rIj7si6TsuKEg7J6s6rKA7KadPC90ZXh0PgogIDx0ZXh0IHg9IjQ2OCIgeT0iMjUyIiBjbGFzcz0iYm9keSBtdXRlZCI+7Iuk7KCcIOuwsOyGoeyngCDsooztkZwgwrcg7Jq07ZaJ7J2867OEPC90ZXh0PgogIDx0ZXh0IHg9IjQ2OCIgeT0iMjg4IiBjbGFzcz0ic21hbGwgaW5rIj7rsKnrrLjsiJzshJwg7LWc7KCB7ZmUICsg7Iuk7LihIOuPhOuhnOunnTwvdGV4dD4KICA8dGV4dCB4PSI0NjgiIHk9IjMxMiIgY2xhc3M9InNtYWxsIGluayI+7KO87ZaJ6rGw66aswrfsi5zqsITsnLzroZwg64uk7IucIOqzhOyCsDwvdGV4dD4KICA8cmVjdCB4PSI0NjgiIHk9IjMzNiIgd2lkdGg9IjI2NCIgaGVpZ2h0PSI1MiIgcng9IjEwIiBmaWxsPSIjZjVmOGZjIi8+CiAgPHRleHQgeD0iNDg0IiB5PSIzNjAiIGNsYXNzPSJzbWFsbCBpbmsiPuuqqOuNuCDiiJI5JeuKlCDrgpnqtIAsPC90ZXh0PgogIDx0ZXh0IHg9IjQ4NCIgeT0iMzgyIiBjbGFzcz0ic21hbGwgdGVhbCI+7Iuk7LihIOKIkjUl64yA6rCAIOygleynge2VnCDsiKvsnpA8L3RleHQ+CgogIDwhLS0gU3RlcCAzIC0tPgogIDxnIGNsYXNzPSJzaGFkb3ciPjxyZWN0IHg9IjgwOCIgeT0iMTYwIiB3aWR0aD0iMzIwIiBoZWlnaHQ9IjI1MCIgcng9IjE4IiBjbGFzcz0ib2siLz48L2c+CiAgPGNpcmNsZSBjeD0iODQ4IiBjeT0iMjA0IiByPSIxNSIgY2xhc3M9ImdyZWVuIi8+PHRleHQgeD0iODY2IiB5PSIyMTEiIGNsYXNzPSJzbWFsbCB3aGl0ZSI+MzwvdGV4dD4KICA8dGV4dCB4PSI4NzYiIHk9IjIxMSIgY2xhc3M9ImxhYmVsIGluayI+7LKY66as65+JIO2FjOyKpO2KuDwvdGV4dD4KICA8dGV4dCB4PSI4MzYiIHk9IjI1MiIgY2xhc3M9ImJvZHkgbXV0ZWQiPuyLnOqwhOygnOyVveydhCDrsJjsmIHtlZwg7JuUIOuwsOyGoeqwgOuKpTwvdGV4dD4KICA8cmVjdCB4PSI4MzYiIHk9IjI3MiIgd2lkdGg9IjI2NCIgaGVpZ2h0PSI0NiIgcng9IjEwIiBmaWxsPSIjZmZmZmZmIiBzdHJva2U9IiNjZmUzZDYiLz4KICA8dGV4dCB4PSI4NDgiIHk9IjMwMSIgY2xhc3M9Im1vbm8gZ3JlZW4iPs6jIOydvOuwleyKpMK3bWluKDEsIOq3vOustC/shozsmpQpPC90ZXh0PgogIDx0ZXh0IHg9IjgzNiIgeT0iMzQ4IiBjbGFzcz0ic21hbGwgaW5rIj7iiaUg7IaQ7J2167aE6riwKOyblCDiiYggMSwyMDDrsJXsiqQpPzwvdGV4dD4KICA8dGV4dCB4PSI4MzYiIHk9IjM3OCIgY2xhc3M9InNtYWxsIGdyZWVuIj7ihpIg7KCA66y865+J7J28IO2MjO2OuO2ZlOunjCDsp4Tsp5wg7JyE7ZeYPC90ZXh0PgoKICA8IS0tIGFycm93cyAtLT4KICA8cGF0aCBkPSJNMzkyLDI4NSBMNDM2LDI4NSIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjN2I4Nzk3IiBzdHJva2Utd2lkdGg9IjUiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgbWFya2VyLWVuZD0idXJsKCNhaDMpIi8+CiAgPHBhdGggZD0iTTc2MCwyODUgTDgwNCwyODUiIGZpbGw9Im5vbmUiIHN0cm9rZT0iIzdiODc5NyIgc3Ryb2tlLXdpZHRoPSI1IiBzdHJva2UtbGluZWNhcD0icm91bmQiIG1hcmtlci1lbmQ9InVybCgjYWgzKSIvPgogIDxwYXRoIGQ9Ik05NjgsNDE0IEM5NjgsNDcwIDQyMCw0NzAgMjYwLDQ3MCAyNDAsNDcwIDIyNiw0NTggMjI2LDQzMiIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjYjBiOWM2IiBzdHJva2Utd2lkdGg9IjQiIHN0cm9rZS1kYXNoYXJyYXk9IjkgOCIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBtYXJrZXItZW5kPSJ1cmwoI2FoMykiLz4KICA8dGV4dCB4PSI1MjAiIHk9IjUwMCIgY2xhc3M9InNtYWxsIG11dGVkIj7rtoDsoIHtlakg6raM7Jet7J2AIOy2leyGjO2VmOqxsOuCmCDqs7Xrj5nrsLDshqHsnLzroZwg65CY64+M66CkIOyerOyCsOy2nDwvdGV4dD4KPC9zdmc+Cg==\" width=\"1200\" height=\"560\" class=\"img_ev3q\"></p>\n<p>그래서 검증 단계를 붙였습니다.</p>\n<p><strong>실측 재검증.</strong> 선택된 권역을 <strong>운행일별 실제 배송지 좌표</strong>로 다시 라우팅합니다. 방문 순서는 별도 솔버로 최적화하고, 주행거리·시간은 실측 도로망으로 계산합니다. 각 운행일의 원자료(배송지 수·박스·소요·거리)를 캐시해 두어, 나중에 임의의 백분위를 다시 라우팅 없이 재계산할 수 있게 했습니다. 이렇게 재보면 모델이 말하던 절감 폭은 줄어듭니다. 글로벌 재편성 모드 기준 모델은 약 −9%를 말했지만, 실측으로 되돌리면 약 −5%대가 정직한 숫자였습니다. <strong>편향은 밀도에 따라 양방향</strong>입니다. 스캐터가 심한 권역은 과소추정, 밀집 권역은 과대추정이라, 실측이 항상 나쁜 쪽으로만 움직이지도 않았습니다.</p>\n<p><strong>처리량 테스트 — 판정 기준 자체를 바꾸다.</strong> 처음엔 “매일 근무시간 이하”를 기준으로 삼았습니다. 그런데 이건 현장 운영과 맞지 않았습니다. 고물량일에 초과분이 생기면 그날은 일부를 공동배송으로 넘기면 그만이고, 전용차량이 <strong>매일</strong> 근무시간 안에 끝나야 할 이유는 없습니다. 진짜 문제는 다른 데 있었습니다 — <strong>저물량일에 배송지가 파편화돼 시간이 초과되면, 정작 손익분기를 채울 만큼의 물량을 소화하지 못하는 것</strong>입니다.</p>\n<p>그래서 게이트를 “매일 ≤ 근무”에서 <strong>처리량</strong>으로 바꿨습니다.</p>\n<div class=\"language-text codeBlockContainer_Ckt0 theme-code-block\" style=\"--prism-color:#393A34;--prism-background-color:#f6f8fa\"><div class=\"codeBlockContent_QJqH\"><pre tabindex=\"0\" class=\"prism-code language-text codeBlock_bY9V thin-scrollbar\" style=\"color:#393A34;background-color:#f6f8fa\"><code class=\"codeBlockLines_e6Vv\"><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token plain\">월 배송가능(z) = Σ_운행일  일박스 × min(1, 근무 / 그날 소요)  ÷  개월수</span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token plain\">유효(z)        = 월 배송가능(z) ≥ 손익분기(z)         # 시간비례 근사임을 명시</span><br></div></code></pre></div></div>\n<p>즉 “하루 450분까지 소화하고 초과분은 공동배송으로 넘긴다고 했을 때, 그래도 손익분기 이상 배송할 수 있는가”를 봅니다. 저물량일 시간초과 횟수는 파편화 위험 신호로 따로 진단합니다. 이 기준으로 보면 중앙 운행일에 조금 초과하는 권역도 대부분 유효로 판정되고, 정말 걸러야 할 것은 손익분기 근처에서 아슬아슬한 소수 권역이라는 게 드러납니다.</p>\n<blockquote>\n<p>이 “시간비례 근사”는 정확한 시뮬레이션이 아니라 운영 모델을 반영한 근사이며, 리포트에도 근사임을 명시합니다. 검증 리포트는 가설성 보정(안정성 패널티 등)을 섞지 않고 <strong>“지금 실제로 이익을 내는가”만</strong> 판단한다는 원칙을 지켰습니다.</p>\n</blockquote>\n<h2 class=\"anchor anchorTargetStickyNavbar_Vzrq\" id=\"목적을-재편성에서-발굴로-바꾸다\">목적을 “재편성”에서 “발굴”로 바꾸다<a href=\"https://tech.umosone.ai/delivery-zone-optimization#%EB%AA%A9%EC%A0%81%EC%9D%84-%EC%9E%AC%ED%8E%B8%EC%84%B1%EC%97%90%EC%84%9C-%EB%B0%9C%EA%B5%B4%EB%A1%9C-%EB%B0%94%EA%BE%B8%EB%8B%A4\" class=\"hash-link\" aria-label=\"목적을 “재편성”에서 “발굴”로 바꾸다에 대한 직접 링크\" title=\"목적을 “재편성”에서 “발굴”로 바꾸다에 대한 직접 링크\" translate=\"no\">​</a></h2>\n<p>초기엔 전용 + 공동배송 전체를 글로벌 재편성해 “현행 대비 몇 대 감축, 약 −9%” 같은 결론을 냈습니다. 숫자는 컸지만, 실제 운영 중인 계약과 노선을 통째로 흔드는 안이라 현장 적용이 부담스러웠습니다.</p>\n<p>그래서 목적을 바꿨습니다. <strong>기존 전용차량은 그대로 고정하고, 공동배송·택배로 위탁 중인 잔여 물량 안에서 추가로 전용화하면 이득인 권역만 발굴</strong>하는 방향입니다.</p>\n<ul>\n<li class=\"\">후보 성장과 집합 패킹의 대상은 <strong>위탁 물량(공동배송 + 택배)만</strong>으로 한정하고, 기존 전용은 셀 풀에서 빼되 지도에는 고정 표시합니다.</li>\n<li class=\"\">발굴된 권역은 순수 <strong>추가분</strong>이라 기존 계약비가 양쪽에 공통으로 상쇄되고, 실운영 노선을 건드리지 않아 훨씬 방어적입니다.</li>\n</ul>\n<div class=\"language-text codeBlockContainer_Ckt0 theme-code-block\" style=\"--prism-color:#393A34;--prism-background-color:#f6f8fa\"><div class=\"codeBlockContent_QJqH\"><pre tabindex=\"0\" class=\"prism-code language-text codeBlock_bY9V thin-scrollbar\" style=\"color:#393A34;background-color:#f6f8fa\"><code class=\"codeBlockLines_e6Vv\"><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token plain\">추가 절감 = ( 전환된 위탁 물량 × p )  −  Σ 신규 발굴권역 차량비(z)  =  Σ 절감액(z)</span><br></div></code></pre></div></div>\n<p>발굴 프레임에서는 위탁 물량 풀이 희소해(배송지당 물량이 적어) 후보 성장이 느리지만, 결과적으로 수도권·충청권에서 소수의 권역을 추가로 발굴할 수 있었고 위탁비의 약 10%를 절감하는 것으로 나왔습니다. 다만 이 역시 <strong>처리량 테스트로 되돌려 보면 일부 권역은 손익분기 마진이 근소</strong>해, 물량 변동 시 이탈 1순위로 관찰 대상에 두었습니다. “발굴했다”로 끝내지 않고 “<strong>얼마나 방어 가능한가</strong>”까지 붙이는 것이 이 엔진의 핵심입니다.</p>\n<h2 class=\"anchor anchorTargetStickyNavbar_Vzrq\" id=\"정직하게-남긴-한계\">정직하게 남긴 한계<a href=\"https://tech.umosone.ai/delivery-zone-optimization#%EC%A0%95%EC%A7%81%ED%95%98%EA%B2%8C-%EB%82%A8%EA%B8%B4-%ED%95%9C%EA%B3%84\" class=\"hash-link\" aria-label=\"정직하게 남긴 한계에 대한 직접 링크\" title=\"정직하게 남긴 한계에 대한 직접 링크\" translate=\"no\">​</a></h2>\n<p>상용화를 목표로 했기에, 잘 되는 것만큼 안 되는 것도 명시적으로 남겼습니다.</p>\n<ul>\n<li class=\"\"><strong>실행가능성은 대표운행일 기준</strong>입니다. 최번일에는 근무시간을 넘을 수 있고, 이건 초과분 공동배송 이관을 전제로 합의한 모델입니다.</li>\n<li class=\"\"><strong>최적성은 생성된 후보 풀 기준</strong>이지 전역 최적이 아닙니다. 후보 생성이 인접-최근접 성장이라, 더 다양한 풀로 추가 개선 여지가 있지만 체감 이득은 줄어듭니다.</li>\n<li class=\"\"><strong>강 근사 중심선은 손으로 그린 근사</strong>입니다. 정밀 하천 데이터가 아니라, 정점 편집으로 정밀도를 올릴 수 있습니다.</li>\n<li class=\"\"><strong>실측 검증은 사후 축소</strong>이지, 실측 라우팅을 후보 생성 루프 안에 넣은 전역 재최적화는 아닙니다. 완전 정합하려면 실측을 실행가능성 판정에 통합해야 하고, 그만큼 느려집니다.</li>\n</ul>\n<p>이 한계들을 숨기지 않은 이유는 분명합니다. 최적화 결과를 “정답”처럼 제시하면 현장은 한 번 어긋나는 순간 도구 전체를 불신합니다. <strong>어디까지 믿을 수 있고 어디부터 다시 확인해야 하는지</strong>를 같이 내보내는 편이, 실제로 쓰이는 도구가 되는 길이었습니다.</p>\n<h2 class=\"anchor anchorTargetStickyNavbar_Vzrq\" id=\"마치며\">마치며<a href=\"https://tech.umosone.ai/delivery-zone-optimization#%EB%A7%88%EC%B9%98%EB%A9%B0\" class=\"hash-link\" aria-label=\"마치며에 대한 직접 링크\" title=\"마치며에 대한 직접 링크\" translate=\"no\">​</a></h2>\n<p>경로 최적화는 이미 많은 회사가 잘 풀고 있는 문제입니다. 저희가 붙들었던 건 그 앞과 뒤였습니다. <strong>앞에서는</strong> 순수한 거리 문제를 손익분기·근무창·강·행정경계·용량이라는 도메인 제약이 박힌 집합 패킹 문제로 재정식화했고, <strong>뒤에서는</strong> 낙관적인 모델을 실제 배송지 좌표로 되돌려 처리량 기준으로 검증했습니다.</p>\n<p>정리하면 이렇습니다.</p>\n<ul>\n<li class=\"\"><strong>재정식화가 상한을 지운다</strong> — 비용 최소화를 절감액 최대화로 뒤집으니 “몇 대”가 입력에서 결과로 바뀌었습니다.</li>\n<li class=\"\"><strong>제약이 크기를 정한다</strong> — 반경을 지정하는 대신 시간·지리·행정 제약을 넣으니 권역 크기가 밀도의 결과가 됐습니다.</li>\n<li class=\"\"><strong>검증이 신뢰를 만든다</strong> — 모델의 낙관을 실측과 처리량 테스트로 되돌려야 현장에서 방어 가능한 안이 됐습니다.</li>\n</ul>\n<p>단순 최적화를 서비스에 녹이는 일은 더 좋은 솔버를 붙이는 게 아니라, <strong>우리 도메인의 제약과 검증 기준을 최적화 안으로 끌어들이는 일</strong>이었습니다. 그 과정에서 최적화 엔진은 “답을 주는 기계”가 아니라 “권역을 검증하고 발굴하는 도구”가 되었습니다.</p>\n<p>비슷한 고민을 하는 팀이 있다면, 어떤 제약을 모델 안으로 넣었고 어디서 낙관을 걷어냈는지 이야기 나눠보고 싶습니다. 긴 글 읽어주셔서 감사합니다.</p>",
            "url": "https://tech.umosone.ai/delivery-zone-optimization",
            "title": "경로 최적화를 넘어서: 배송 권역을 검증하고 발굴하는 최적화 엔진 만들기",
            "summary": "단순 경로 최적화로는 답할 수 없는 “이 권역이 사업적으로 이득인가”라는 질문을, 도메인 제약과 실측 검증을 녹인 3-레이어 최적화 엔진으로 푸는 과정을 정리합니다.",
            "date_modified": "2026-07-31T00:00:00.000Z",
            "author": {
                "name": "백효현"
            },
            "tags": [
                "VRP",
                "물류",
                "TMS",
                "최적화"
            ]
        },
        {
            "id": "https://tech.umosone.ai/tile38-lettuce-oom",
            "content_html": "<h2 class=\"anchor anchorTargetStickyNavbar_Vzrq\" id=\"pleos-fleetfms\">Pleos Fleet(FMS)<a href=\"https://tech.umosone.ai/tile38-lettuce-oom#pleos-fleetfms\" class=\"hash-link\" aria-label=\"Pleos Fleet(FMS)에 대한 직접 링크\" title=\"Pleos Fleet(FMS)에 대한 직접 링크\" translate=\"no\">​</a></h2>\n<img src=\"https://tech.umosone.ai/img/blog/fms.png\" alt=\"FMS 소개\" width=\"720\">\n<p>안녕하세요, UMOSONE FMS 개발팀의 오수진입니다.</p>\n<p>본론에 앞서 저희 팀이 만드는 서비스를 짧게 소개합니다. FMS(Fleet Management System)는 차량이라는 자산과 현장을 데이터로 연결해, 차량을 실시간으로 관제하고 모니터링하는 서비스를 만드는 팀입니다. 자체 개발한 차량 단말에서 올라오는 데이터뿐 아니라 현대/기아 자동차의 데이터 파이프라인(HMG Connected Car)과도 연동해, 서로 다른 소스에서 들어오는 차량 데이터를 하나의 플랫폼으로 모으고 있습니다.</p>\n<p>자율주행 시대가 가까워질수록, 사람이 타지 않은 무인 차량이라도 누군가는 위치와 상태를 실시간으로 확인하고 이상 징후에 대응할 수 있어야 합니다. 저희는 그 역할을 책임지는, 자율주행 시대에 꼭 필요한 관제 서비스를 만드는 팀입니다. 차량 한 대에서만도 위치, 속도, 배터리, 도어, 각종 센서 상태 등 수십 가지 종류의 데이터가 끊임없이 올라오고, 이를 실시간으로 수집·적재·조회하는 대용량 데이터 처리 파이프라인을 만들고 운영하는 것이 저희 팀의 핵심 과제입니다. 이번 글의 무대가 된 <code>fms-vehicle-data-consumer</code>도 이 파이프라인의 일부로, 차량에서 올라오는 위치 이벤트를 Kafka로 받아 지오스페이셜 데이터베이스인 Tile38에 반영하는 컨슈머입니다.</p>\n<h2 class=\"anchor anchorTargetStickyNavbar_Vzrq\" id=\"tldr\">TL;DR<a href=\"https://tech.umosone.ai/tile38-lettuce-oom#tldr\" class=\"hash-link\" aria-label=\"TL;DR에 대한 직접 링크\" title=\"TL;DR에 대한 직접 링크\" translate=\"no\">​</a></h2>\n<ul>\n<li class=\"\"><code>fms-vehicle-data-consumer</code> 파드가 반복적으로 OOMKilled 됨</li>\n<li class=\"\">힙덤프 분석 결과, Lettuce의 <code>DefaultCommandLatencyCollector</code> 인스턴스 1개가 <strong>힙 전체의 72.28%(약 404MB)</strong> 를 점유</li>\n<li class=\"\">원인: 커스텀 <code>ProtocolKeyword</code> 구현체인 <code>Tile38CommandType</code>이 <code>equals()</code>/<code>hashCode()</code>를 구현하지 않은 채 <strong>호출할 때마다 새 객체</strong>를 반환</li>\n<li class=\"\">Lettuce의 메트릭 맵 키(<code>CommandLatencyId</code>)가 이 객체의 <strong>identity</strong>로 만들어지다 보니, 커맨드 이름은 8개뿐인데 키는 무한히 늘어남</li>\n<li class=\"\"><code>of()</code>를 캐싱하고 <code>equals</code>/<code>hashCode</code>를 추가해서 해결. 검증 결과 객체 수가 <strong>0 → 700+ 로 계속 증가하던 것이 캐싱 적용 후 한 자릿수로 수렴</strong>하는 것을 직접 확인\n<code>를 캐싱하고 </code>equals<code>/</code>hashCode`를 추가해서 해결. 검증 결과 객체 수가 <strong>0 → 700+ 로 계속 증가하던 것이 캐싱 적용 후 한 자릿수로 수렴</strong>하는 것을 직접 확인</li>\n</ul>\n<h2 class=\"anchor anchorTargetStickyNavbar_Vzrq\" id=\"1-증상\">1. 증상<a href=\"https://tech.umosone.ai/tile38-lettuce-oom#1-%EC%A6%9D%EC%83%81\" class=\"hash-link\" aria-label=\"1. 증상에 대한 직접 링크\" title=\"1. 증상에 대한 직접 링크\" translate=\"no\">​</a></h2>\n<p>차량의 위치(위도,경도)정보를 실시간으로 geofencing 서버로 보내 관심지역으로 설정한 구역에 입출 로그를 기록하는 핵심기능중 하나를 처리하는데 차량대수가 늘어나면서 kafka message가 급증했고 위치데이터를 처리하는<br>\n<!-- -->서비스 파드가 반복적으로 OOMKilled 되는 문제가 있었습니다. 힙덤프를 열어보니 원인은 커맨드 이름이 8개뿐인 곳에서, 메트릭 맵의 키가 1,660개까지 불어나 있는 이상한 모습이었습니다.</p>\n<p>프로덕션 힙덤프를 Eclipse Memory Analyzer(MAT)로 분석해보니 원인이 명확하게 드러났습니다.</p>\n<table><thead><tr><th>시점</th><th>Used Heap</th><th>Leak Suspect</th></tr></thead><tbody><tr><td>이전 (7/8 17:54)</td><td>129.8 MB</td><td>없음</td></tr><tr><td>현재 (7/10 13:35)</td><td>534 MB</td><td><strong>Problem Suspect 1 발견</strong></td></tr></tbody></table>\n<p>MAT의 Leak Suspects / Leak Hunter / Top Consumers가 한목소리로 지목한 대상은 다음과 같았습니다.</p>\n<div class=\"language-text codeBlockContainer_Ckt0 theme-code-block\" style=\"--prism-color:#393A34;--prism-background-color:#f6f8fa\"><div class=\"codeBlockContent_QJqH\"><pre tabindex=\"0\" class=\"prism-code language-text codeBlock_bY9V thin-scrollbar\" style=\"color:#393A34;background-color:#f6f8fa\"><code class=\"codeBlockLines_e6Vv\"><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token plain\">io.lettuce.core.metrics.DefaultCommandLatencyCollector</span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token plain\">  └ Shallow: 32 B / Retained: 404,683,800 B (72.28%)</span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token plain\" style=\"display:inline-block\"></span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token plain\">Dominator 경로:</span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token plain\">  DefaultCommandLatencyCollector</span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token plain\">   └ AtomicReference (latencyMetricsRef)</span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token plain\">      └ ConcurrentHashMap</span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token plain\">         └ ConcurrentHashMap$Node[4096]   ← 404MB 전부 몰림</span><br></div></code></pre></div></div>\n<p>인스턴스는 딱 3개인데(before/after 동일), 그중 하나가 통째로 404MB를 물고 있는 전형적인 \"런타임 중 점진적으로 자라나는\" 누수 패턴이었습니다.</p>\n<h2 class=\"anchor anchorTargetStickyNavbar_Vzrq\" id=\"2-mat-oql로-잡아낸-결정적-증거\">2. MAT OQL로 잡아낸 결정적 증거<a href=\"https://tech.umosone.ai/tile38-lettuce-oom#2-mat-oql%EB%A1%9C-%EC%9E%A1%EC%95%84%EB%82%B8-%EA%B2%B0%EC%A0%95%EC%A0%81-%EC%A6%9D%EA%B1%B0\" class=\"hash-link\" aria-label=\"2. MAT OQL로 잡아낸 결정적 증거에 대한 직접 링크\" title=\"2. MAT OQL로 잡아낸 결정적 증거에 대한 직접 링크\" translate=\"no\">​</a></h2>\n<p>Eclipse MAT 헤드리스 OQL로 덤프를 직접 쿼리해보니 다음과 같은 모순이 발견됐습니다.</p>\n<table><thead><tr><th>OQL</th><th>결과</th></tr></thead><tbody><tr><td><code>SELECT * FROM io.lettuce.core.metrics.CommandLatencyId</code></td><td><strong>1,660개</strong> 인스턴스</td></tr><tr><td><code>SELECT DISTINCT t.command FROM ai.dot42.tile38.Tile38CommandType t</code></td><td><strong>distinct 8개</strong> (SET/DEL/DROP/SETHOOK/DELHOOK/PDELHOOK/GET/OUTPUT)</td></tr><tr><td><code>CommandLatencyId.localAddress</code></td><td><code>LocalAddress.ANY</code> (고정 상수)</td></tr><tr><td><code>CommandLatencyId.remoteAddress</code></td><td>단일 호스트</td></tr><tr><td><code>Tile38CommandType</code> 인스턴스 수 (Class Histogram)</td><td><strong>1,654개</strong></td></tr></tbody></table>\n<p><strong>커맨드 이름은 8개뿐인데, 메트릭 맵의 키(<code>CommandLatencyId</code>)는 1,660개.</strong> local/remote 주소는 둘 다 고정값이니, 키를 서로 다르게 만드는 유일한 요인은 <code>Tile38CommandType</code> 객체 그 자체였습니다.</p>\n<h2 class=\"anchor anchorTargetStickyNavbar_Vzrq\" id=\"3-근본-원인\">3. 근본 원인<a href=\"https://tech.umosone.ai/tile38-lettuce-oom#3-%EA%B7%BC%EB%B3%B8-%EC%9B%90%EC%9D%B8\" class=\"hash-link\" aria-label=\"3. 근본 원인에 대한 직접 링크\" title=\"3. 근본 원인에 대한 직접 링크\" translate=\"no\">​</a></h2>\n<p>Lettuce는 커맨드 레이턴시를 아래와 같은 키로 집계합니다.</p>\n<div class=\"language-text codeBlockContainer_Ckt0 theme-code-block\" style=\"--prism-color:#393A34;--prism-background-color:#f6f8fa\"><div class=\"codeBlockContent_QJqH\"><pre tabindex=\"0\" class=\"prism-code language-text codeBlock_bY9V thin-scrollbar\" style=\"color:#393A34;background-color:#f6f8fa\"><code class=\"codeBlockLines_e6Vv\"><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token plain\">CommandLatencyId(local, remote, commandType)</span><br></div></code></pre></div></div>\n<p>여기서 <code>commandType</code>은 <code>ProtocolKeyword</code> 타입인데, 저희 팀의 <code>Tile38CommandType</code>은 다음과 같이 구현되어 있었습니다.</p>\n<div class=\"language-kotlin codeBlockContainer_Ckt0 theme-code-block\" style=\"--prism-color:#393A34;--prism-background-color:#f6f8fa\"><div class=\"codeBlockContent_QJqH\"><pre tabindex=\"0\" class=\"prism-code language-kotlin codeBlock_bY9V thin-scrollbar\" style=\"color:#393A34;background-color:#f6f8fa\"><code class=\"codeBlockLines_e6Vv\"><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token keyword\" style=\"color:#00009f\">package</span><span class=\"token plain\"> ai</span><span class=\"token punctuation\" style=\"color:#393A34\">.</span><span class=\"token plain\">dot42</span><span class=\"token punctuation\" style=\"color:#393A34\">.</span><span class=\"token plain\">tile38</span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token plain\"></span><span class=\"token keyword\" style=\"color:#00009f\">import</span><span class=\"token plain\"> io</span><span class=\"token punctuation\" style=\"color:#393A34\">.</span><span class=\"token plain\">lettuce</span><span class=\"token punctuation\" style=\"color:#393A34\">.</span><span class=\"token plain\">core</span><span class=\"token punctuation\" style=\"color:#393A34\">.</span><span class=\"token plain\">protocol</span><span class=\"token punctuation\" style=\"color:#393A34\">.</span><span class=\"token plain\">ProtocolKeyword</span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token plain\" style=\"display:inline-block\"></span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token plain\"></span><span class=\"token keyword\" style=\"color:#00009f\">class</span><span class=\"token plain\"> Tile38CommandType </span><span class=\"token keyword\" style=\"color:#00009f\">private</span><span class=\"token plain\"> </span><span class=\"token keyword\" style=\"color:#00009f\">constructor</span><span class=\"token punctuation\" style=\"color:#393A34\">(</span><span class=\"token plain\"></span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token plain\">    </span><span class=\"token keyword\" style=\"color:#00009f\">private</span><span class=\"token plain\"> </span><span class=\"token keyword\" style=\"color:#00009f\">val</span><span class=\"token plain\"> command</span><span class=\"token operator\" style=\"color:#393A34\">:</span><span class=\"token plain\"> String</span><span class=\"token punctuation\" style=\"color:#393A34\">,</span><span class=\"token plain\"></span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token plain\"></span><span class=\"token punctuation\" style=\"color:#393A34\">)</span><span class=\"token plain\"> </span><span class=\"token operator\" style=\"color:#393A34\">:</span><span class=\"token plain\"> ProtocolKeyword </span><span class=\"token punctuation\" style=\"color:#393A34\">{</span><span class=\"token plain\"></span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token plain\">    </span><span class=\"token keyword\" style=\"color:#00009f\">override</span><span class=\"token plain\"> </span><span class=\"token keyword\" style=\"color:#00009f\">fun</span><span class=\"token plain\"> </span><span class=\"token function\" style=\"color:#d73a49\">getBytes</span><span class=\"token punctuation\" style=\"color:#393A34\">(</span><span class=\"token punctuation\" style=\"color:#393A34\">)</span><span class=\"token operator\" style=\"color:#393A34\">:</span><span class=\"token plain\"> ByteArray </span><span class=\"token operator\" style=\"color:#393A34\">=</span><span class=\"token plain\"> command</span><span class=\"token punctuation\" style=\"color:#393A34\">.</span><span class=\"token function\" style=\"color:#d73a49\">toByteArray</span><span class=\"token punctuation\" style=\"color:#393A34\">(</span><span class=\"token plain\">Charsets</span><span class=\"token punctuation\" style=\"color:#393A34\">.</span><span class=\"token plain\">US_ASCII</span><span class=\"token punctuation\" style=\"color:#393A34\">)</span><span class=\"token plain\"></span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token plain\">    </span><span class=\"token keyword\" style=\"color:#00009f\">override</span><span class=\"token plain\"> </span><span class=\"token keyword\" style=\"color:#00009f\">fun</span><span class=\"token plain\"> </span><span class=\"token function\" style=\"color:#d73a49\">name</span><span class=\"token punctuation\" style=\"color:#393A34\">(</span><span class=\"token punctuation\" style=\"color:#393A34\">)</span><span class=\"token operator\" style=\"color:#393A34\">:</span><span class=\"token plain\"> String </span><span class=\"token operator\" style=\"color:#393A34\">=</span><span class=\"token plain\"> command</span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token plain\" style=\"display:inline-block\"></span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token plain\">    </span><span class=\"token keyword\" style=\"color:#00009f\">companion</span><span class=\"token plain\"> </span><span class=\"token keyword\" style=\"color:#00009f\">object</span><span class=\"token plain\"> </span><span class=\"token punctuation\" style=\"color:#393A34\">{</span><span class=\"token plain\"></span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token plain\">        </span><span class=\"token keyword\" style=\"color:#00009f\">val</span><span class=\"token plain\"> SET </span><span class=\"token operator\" style=\"color:#393A34\">=</span><span class=\"token plain\"> </span><span class=\"token function\" style=\"color:#d73a49\">Tile38CommandType</span><span class=\"token punctuation\" style=\"color:#393A34\">(</span><span class=\"token string-literal singleline string\" style=\"color:#e3116c\">\"SET\"</span><span class=\"token punctuation\" style=\"color:#393A34\">)</span><span class=\"token plain\"></span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token plain\">        </span><span class=\"token keyword\" style=\"color:#00009f\">val</span><span class=\"token plain\"> DEL </span><span class=\"token operator\" style=\"color:#393A34\">=</span><span class=\"token plain\"> </span><span class=\"token function\" style=\"color:#d73a49\">Tile38CommandType</span><span class=\"token punctuation\" style=\"color:#393A34\">(</span><span class=\"token string-literal singleline string\" style=\"color:#e3116c\">\"DEL\"</span><span class=\"token punctuation\" style=\"color:#393A34\">)</span><span class=\"token plain\"></span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token plain\">        </span><span class=\"token keyword\" style=\"color:#00009f\">val</span><span class=\"token plain\"> DROP </span><span class=\"token operator\" style=\"color:#393A34\">=</span><span class=\"token plain\"> </span><span class=\"token function\" style=\"color:#d73a49\">Tile38CommandType</span><span class=\"token punctuation\" style=\"color:#393A34\">(</span><span class=\"token string-literal singleline string\" style=\"color:#e3116c\">\"DROP\"</span><span class=\"token punctuation\" style=\"color:#393A34\">)</span><span class=\"token plain\"></span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token plain\">        </span><span class=\"token keyword\" style=\"color:#00009f\">val</span><span class=\"token plain\"> SETHOOK </span><span class=\"token operator\" style=\"color:#393A34\">=</span><span class=\"token plain\"> </span><span class=\"token function\" style=\"color:#d73a49\">Tile38CommandType</span><span class=\"token punctuation\" style=\"color:#393A34\">(</span><span class=\"token string-literal singleline string\" style=\"color:#e3116c\">\"SETHOOK\"</span><span class=\"token punctuation\" style=\"color:#393A34\">)</span><span class=\"token plain\"></span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token plain\">        </span><span class=\"token keyword\" style=\"color:#00009f\">val</span><span class=\"token plain\"> DELHOOK </span><span class=\"token operator\" style=\"color:#393A34\">=</span><span class=\"token plain\"> </span><span class=\"token function\" style=\"color:#d73a49\">Tile38CommandType</span><span class=\"token punctuation\" style=\"color:#393A34\">(</span><span class=\"token string-literal singleline string\" style=\"color:#e3116c\">\"DELHOOK\"</span><span class=\"token punctuation\" style=\"color:#393A34\">)</span><span class=\"token plain\"></span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token plain\">        </span><span class=\"token keyword\" style=\"color:#00009f\">val</span><span class=\"token plain\"> PDELHOOK </span><span class=\"token operator\" style=\"color:#393A34\">=</span><span class=\"token plain\"> </span><span class=\"token function\" style=\"color:#d73a49\">Tile38CommandType</span><span class=\"token punctuation\" style=\"color:#393A34\">(</span><span class=\"token string-literal singleline string\" style=\"color:#e3116c\">\"PDELHOOK\"</span><span class=\"token punctuation\" style=\"color:#393A34\">)</span><span class=\"token plain\"></span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token plain\">        </span><span class=\"token keyword\" style=\"color:#00009f\">val</span><span class=\"token plain\"> GET </span><span class=\"token operator\" style=\"color:#393A34\">=</span><span class=\"token plain\"> </span><span class=\"token function\" style=\"color:#d73a49\">Tile38CommandType</span><span class=\"token punctuation\" style=\"color:#393A34\">(</span><span class=\"token string-literal singleline string\" style=\"color:#e3116c\">\"GET\"</span><span class=\"token punctuation\" style=\"color:#393A34\">)</span><span class=\"token plain\"></span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token plain\" style=\"display:inline-block\"></span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token plain\">        </span><span class=\"token comment\" style=\"color:#999988;font-style:italic\">// 문제의 그 함수: 호출할 때마다 새 인스턴스를 만들어 반환</span><span class=\"token plain\"></span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token plain\">        </span><span class=\"token keyword\" style=\"color:#00009f\">fun</span><span class=\"token plain\"> </span><span class=\"token function\" style=\"color:#d73a49\">of</span><span class=\"token punctuation\" style=\"color:#393A34\">(</span><span class=\"token plain\">command</span><span class=\"token operator\" style=\"color:#393A34\">:</span><span class=\"token plain\"> String</span><span class=\"token punctuation\" style=\"color:#393A34\">)</span><span class=\"token operator\" style=\"color:#393A34\">:</span><span class=\"token plain\"> Tile38CommandType </span><span class=\"token operator\" style=\"color:#393A34\">=</span><span class=\"token plain\"> </span><span class=\"token function\" style=\"color:#d73a49\">Tile38CommandType</span><span class=\"token punctuation\" style=\"color:#393A34\">(</span><span class=\"token plain\">command</span><span class=\"token punctuation\" style=\"color:#393A34\">.</span><span class=\"token function\" style=\"color:#d73a49\">uppercase</span><span class=\"token punctuation\" style=\"color:#393A34\">(</span><span class=\"token punctuation\" style=\"color:#393A34\">)</span><span class=\"token punctuation\" style=\"color:#393A34\">)</span><span class=\"token plain\"></span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token plain\">    </span><span class=\"token punctuation\" style=\"color:#393A34\">}</span><span class=\"token plain\"></span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token plain\"></span><span class=\"token punctuation\" style=\"color:#393A34\">}</span><br></div></code></pre></div></div>\n<p>문제는 두 가지가 겹쳐서 발생했습니다.</p>\n<ol>\n<li class=\"\"><code>equals()</code> / <code>hashCode()</code>를 오버라이드하지 않음 → 기본 구현은 <strong>객체 identity</strong> 비교</li>\n<li class=\"\"><code>of()</code>가 호출할 때마다 <code>Tile38CommandType(...)</code>을 <strong>새로 생성</strong>해서 반환</li>\n</ol>\n<p>즉 <code>Tile38Client.execute()</code>가 커맨드를 디스패치할 때마다 <code>Tile38CommandType.of(\"SET\")</code>을 호출하는데, 커맨드 이름은 항상 \"SET\"으로 같아도 <strong>매번 다른 객체</strong>가 만들어집니다. Lettuce의 메트릭 맵은 이 객체를 키의 일부로 쓰기 때문에:</p>\n<div class=\"language-text codeBlockContainer_Ckt0 theme-code-block\" style=\"--prism-color:#393A34;--prism-background-color:#f6f8fa\"><div class=\"codeBlockContent_QJqH\"><pre tabindex=\"0\" class=\"prism-code language-text codeBlock_bY9V thin-scrollbar\" style=\"color:#393A34;background-color:#f6f8fa\"><code class=\"codeBlockLines_e6Vv\"><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token plain\">키 = (고정 local, 고정 remote, 매번 다른 Tile38CommandType 객체)</span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token plain\">→ 절대 같은 키로 인식되지 않음</span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token plain\">→ dispatch할 때마다 새 키가 맵에 쌓임</span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token plain\">→ 키 1개당 HdrHistogram 기반 통계 객체가 ~244KB씩 물림</span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token plain\">→ 1,654개 유니크 키 × 244KB ≈ 404MB</span><br></div></code></pre></div></div>\n<p>이 패턴은 Lettuce의 알려진 이슈(#1210: 커스텀 <code>ProtocolKeyword</code> + identity 기반 키잉으로 인한 메트릭 맵 무한 성장)와 정확히 일치합니다.</p>\n<blockquote>\n<p>참고로, <code>Tile38Client.pipeline()</code>이 호출마다 새 물리 커넥션을 여는 부분도 있었지만, Lettuce의 <code>localDistinction</code> 기본값이 <code>false</code>라 local 주소는 항상 고정값(<code>LocalAddress.ANY</code>)으로 잡힙니다. 따라서 이 커넥션 처칠(churn)은 별개의 리소스 이슈일 뿐, 이번 OOM의 직접 원인은 아니었습니다.</p>\n</blockquote>\n<h2 class=\"anchor anchorTargetStickyNavbar_Vzrq\" id=\"4-재현-실험--눈으로-직접-확인하기\">4. 재현 실험 — 눈으로 직접 확인하기<a href=\"https://tech.umosone.ai/tile38-lettuce-oom#4-%EC%9E%AC%ED%98%84-%EC%8B%A4%ED%97%98--%EB%88%88%EC%9C%BC%EB%A1%9C-%EC%A7%81%EC%A0%91-%ED%99%95%EC%9D%B8%ED%95%98%EA%B8%B0\" class=\"hash-link\" aria-label=\"4. 재현 실험 — 눈으로 직접 확인하기에 대한 직접 링크\" title=\"4. 재현 실험 — 눈으로 직접 확인하기에 대한 직접 링크\" translate=\"no\">​</a></h2>\n<p>말로만 하면 설득력이 떨어지니, 실제로 <code>of()</code>를 반복 호출하면서 힙에 <code>Tile38CommandType</code> 인스턴스가 어떻게 쌓이는지 VisualVM으로 직접 관찰했습니다.</p>\n<div class=\"language-kotlin codeBlockContainer_Ckt0 theme-code-block\" style=\"--prism-color:#393A34;--prism-background-color:#f6f8fa\"><div class=\"codeBlockContent_QJqH\"><pre tabindex=\"0\" class=\"prism-code language-kotlin codeBlock_bY9V thin-scrollbar\" style=\"color:#393A34;background-color:#f6f8fa\"><code class=\"codeBlockLines_e6Vv\"><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token function\" style=\"color:#d73a49\">Given</span><span class=\"token punctuation\" style=\"color:#393A34\">(</span><span class=\"token string-literal singleline string\" style=\"color:#e3116c\">\"of() 결과가 CommandLatencyId 키로 사용되는 시뮬레이션\"</span><span class=\"token punctuation\" style=\"color:#393A34\">)</span><span class=\"token plain\"> </span><span class=\"token punctuation\" style=\"color:#393A34\">{</span><span class=\"token plain\"></span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token plain\">    </span><span class=\"token function\" style=\"color:#d73a49\">When</span><span class=\"token punctuation\" style=\"color:#393A34\">(</span><span class=\"token string-literal singleline string\" style=\"color:#e3116c\">\"동일 커맨드로 1000회 반복 호출\"</span><span class=\"token punctuation\" style=\"color:#393A34\">)</span><span class=\"token plain\"> </span><span class=\"token punctuation\" style=\"color:#393A34\">{</span><span class=\"token plain\"></span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token plain\">        </span><span class=\"token keyword\" style=\"color:#00009f\">val</span><span class=\"token plain\"> iterations </span><span class=\"token operator\" style=\"color:#393A34\">=</span><span class=\"token plain\"> </span><span class=\"token number\" style=\"color:#36acaa\">1000</span><span class=\"token plain\"></span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token plain\">        </span><span class=\"token keyword\" style=\"color:#00009f\">val</span><span class=\"token plain\"> uniqueInstances </span><span class=\"token operator\" style=\"color:#393A34\">=</span><span class=\"token plain\"> mutableSetOf</span><span class=\"token operator\" style=\"color:#393A34\">&lt;</span><span class=\"token plain\">Tile38CommandType</span><span class=\"token operator\" style=\"color:#393A34\">&gt;</span><span class=\"token punctuation\" style=\"color:#393A34\">(</span><span class=\"token punctuation\" style=\"color:#393A34\">)</span><span class=\"token plain\"></span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token plain\" style=\"display:inline-block\"></span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token plain\">        </span><span class=\"token function\" style=\"color:#d73a49\">repeat</span><span class=\"token punctuation\" style=\"color:#393A34\">(</span><span class=\"token plain\">times </span><span class=\"token operator\" style=\"color:#393A34\">=</span><span class=\"token plain\"> iterations</span><span class=\"token punctuation\" style=\"color:#393A34\">)</span><span class=\"token plain\"> </span><span class=\"token punctuation\" style=\"color:#393A34\">{</span><span class=\"token plain\"></span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token plain\">            uniqueInstances</span><span class=\"token punctuation\" style=\"color:#393A34\">.</span><span class=\"token function\" style=\"color:#d73a49\">add</span><span class=\"token punctuation\" style=\"color:#393A34\">(</span><span class=\"token plain\">Tile38CommandType</span><span class=\"token punctuation\" style=\"color:#393A34\">.</span><span class=\"token function\" style=\"color:#d73a49\">of</span><span class=\"token punctuation\" style=\"color:#393A34\">(</span><span class=\"token plain\">command </span><span class=\"token operator\" style=\"color:#393A34\">=</span><span class=\"token plain\"> </span><span class=\"token string-literal singleline string\" style=\"color:#e3116c\">\"SET\"</span><span class=\"token punctuation\" style=\"color:#393A34\">)</span><span class=\"token punctuation\" style=\"color:#393A34\">)</span><span class=\"token plain\"></span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token plain\">            </span><span class=\"token function\" style=\"color:#d73a49\">delay</span><span class=\"token punctuation\" style=\"color:#393A34\">(</span><span class=\"token plain\">duration </span><span class=\"token operator\" style=\"color:#393A34\">=</span><span class=\"token plain\"> </span><span class=\"token number\" style=\"color:#36acaa\">100</span><span class=\"token punctuation\" style=\"color:#393A34\">.</span><span class=\"token plain\">milliseconds</span><span class=\"token punctuation\" style=\"color:#393A34\">)</span><span class=\"token plain\"></span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token plain\">        </span><span class=\"token punctuation\" style=\"color:#393A34\">}</span><span class=\"token plain\"></span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token plain\">    </span><span class=\"token punctuation\" style=\"color:#393A34\">}</span><span class=\"token plain\"></span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token plain\"></span><span class=\"token punctuation\" style=\"color:#393A34\">}</span><br></div></code></pre></div></div>\n<h3 class=\"anchor anchorTargetStickyNavbar_Vzrq\" id=\"수정-전-코드로-실행--계속-늘어남\">수정 전 코드로 실행 — 계속 늘어남<a href=\"https://tech.umosone.ai/tile38-lettuce-oom#%EC%88%98%EC%A0%95-%EC%A0%84-%EC%BD%94%EB%93%9C%EB%A1%9C-%EC%8B%A4%ED%96%89--%EA%B3%84%EC%86%8D-%EB%8A%98%EC%96%B4%EB%82%A8\" class=\"hash-link\" aria-label=\"수정 전 코드로 실행 — 계속 늘어남에 대한 직접 링크\" title=\"수정 전 코드로 실행 — 계속 늘어남에 대한 직접 링크\" translate=\"no\">​</a></h3>\n<p>같은 \"SET\" 커맨드로만 호출하는데도, 반복 횟수가 늘어날수록 힙에 살아있는 <code>Tile38CommandType</code> 인스턴스 수가 함께 늘어나는 것을 확인했습니다.</p>\n<ul>\n<li class=\"\">반복 80회 시점: 88개 인스턴스</li>\n</ul>\n<p><img decoding=\"async\" loading=\"lazy\" alt=\"80회 반복 시점 - 88개 인스턴스\" src=\"https://tech.umosone.ai/assets/images/visualvm-before-80-6eded9906e337344318c1519d4c228c3.png\" width=\"1690\" height=\"932\" class=\"img_ev3q\"></p>\n<ul>\n<li class=\"\">반복 701회 시점: 709개 인스턴스</li>\n</ul>\n<p><img decoding=\"async\" loading=\"lazy\" alt=\"701회 반복 시점 - 709개 인스턴스\" src=\"https://tech.umosone.ai/assets/images/visualvm-before-701-b11028eb37d40e27035adee6e3a11212.png\" width=\"1714\" height=\"920\" class=\"img_ev3q\"></p>\n<p>즉 호출 횟수와 거의 1:1로 인스턴스가 계속 쌓입니다. 실제 서비스에서 QPS가 높은 Tile38 SET 트래픽이 지속될수록 힙이 점점 부풀어 오른 이유가 바로 이것입니다.</p>\n<h3 class=\"anchor anchorTargetStickyNavbar_Vzrq\" id=\"수정-후-코드로-실행--89개로-수렴\">수정 후 코드로 실행 — 8~9개로 수렴<a href=\"https://tech.umosone.ai/tile38-lettuce-oom#%EC%88%98%EC%A0%95-%ED%9B%84-%EC%BD%94%EB%93%9C%EB%A1%9C-%EC%8B%A4%ED%96%89--89%EA%B0%9C%EB%A1%9C-%EC%88%98%EB%A0%B4\" class=\"hash-link\" aria-label=\"수정 후 코드로 실행 — 8~9개로 수렴에 대한 직접 링크\" title=\"수정 후 코드로 실행 — 8~9개로 수렴에 대한 직접 링크\" translate=\"no\">​</a></h3>\n<p>캐싱과 <code>equals</code>/<code>hashCode</code>를 추가한 뒤 동일한 시뮬레이션을 132회 반복해도, 인스턴스 수는 늘어나지 않고 <strong>9개(커맨드 종류 수 + α)로 수렴</strong>했습니다.</p>\n<p><img decoding=\"async\" loading=\"lazy\" alt=\"132회 반복 시점 - 9개로 수렴\" src=\"https://tech.umosone.ai/assets/images/visualvm-after-132-e6b31ab95f0bba59db6b71dcd51a7409.png\" width=\"1750\" height=\"926\" class=\"img_ev3q\"></p>\n<p>수정 전/후 비교:</p>\n<table><thead><tr><th>코드</th><th>반복 횟수</th><th>살아있는 인스턴스 수</th></tr></thead><tbody><tr><td>수정 전</td><td>80회</td><td>88개</td></tr><tr><td>수정 전</td><td>701회</td><td>709개</td></tr><tr><td>수정 후</td><td>132회</td><td><strong>9개</strong></td></tr></tbody></table>\n<p>수정 전에는 반복 횟수만큼 인스턴스가 그대로 쌓이지만, 수정 후에는 반복 횟수와 무관하게 커맨드 종류 수 근처에서 더 이상 늘어나지 않습니다.</p>\n<h2 class=\"anchor anchorTargetStickyNavbar_Vzrq\" id=\"5-수정-코드\">5. 수정 코드<a href=\"https://tech.umosone.ai/tile38-lettuce-oom#5-%EC%88%98%EC%A0%95-%EC%BD%94%EB%93%9C\" class=\"hash-link\" aria-label=\"5. 수정 코드에 대한 직접 링크\" title=\"5. 수정 코드에 대한 직접 링크\" translate=\"no\">​</a></h2>\n<p><code>companion object</code>에 <code>ConcurrentHashMap</code> 기반 캐시를 두고, <code>of()</code>가 같은 커맨드 이름에 대해 항상 같은 인스턴스를 반환하도록 바꿨습니다. 방어적으로 <code>equals()</code>/<code>hashCode()</code>도 문자열 기준으로 추가해서, 혹시 어딘가에서 캐시를 거치지 않고 생성되더라도 값 기반으로 동일하게 취급되도록 했습니다.</p>\n<div class=\"language-kotlin codeBlockContainer_Ckt0 theme-code-block\" style=\"--prism-color:#393A34;--prism-background-color:#f6f8fa\"><div class=\"codeBlockContent_QJqH\"><pre tabindex=\"0\" class=\"prism-code language-kotlin codeBlock_bY9V thin-scrollbar\" style=\"color:#393A34;background-color:#f6f8fa\"><code class=\"codeBlockLines_e6Vv\"><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token keyword\" style=\"color:#00009f\">class</span><span class=\"token plain\"> Tile38CommandType </span><span class=\"token keyword\" style=\"color:#00009f\">private</span><span class=\"token plain\"> </span><span class=\"token keyword\" style=\"color:#00009f\">constructor</span><span class=\"token punctuation\" style=\"color:#393A34\">(</span><span class=\"token plain\"></span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token plain\">    </span><span class=\"token keyword\" style=\"color:#00009f\">private</span><span class=\"token plain\"> </span><span class=\"token keyword\" style=\"color:#00009f\">val</span><span class=\"token plain\"> command</span><span class=\"token operator\" style=\"color:#393A34\">:</span><span class=\"token plain\"> String</span><span class=\"token punctuation\" style=\"color:#393A34\">,</span><span class=\"token plain\"></span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token plain\"></span><span class=\"token punctuation\" style=\"color:#393A34\">)</span><span class=\"token plain\"> </span><span class=\"token operator\" style=\"color:#393A34\">:</span><span class=\"token plain\"> ProtocolKeyword </span><span class=\"token punctuation\" style=\"color:#393A34\">{</span><span class=\"token plain\"></span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token plain\">    </span><span class=\"token keyword\" style=\"color:#00009f\">override</span><span class=\"token plain\"> </span><span class=\"token keyword\" style=\"color:#00009f\">fun</span><span class=\"token plain\"> </span><span class=\"token function\" style=\"color:#d73a49\">getBytes</span><span class=\"token punctuation\" style=\"color:#393A34\">(</span><span class=\"token punctuation\" style=\"color:#393A34\">)</span><span class=\"token operator\" style=\"color:#393A34\">:</span><span class=\"token plain\"> ByteArray </span><span class=\"token operator\" style=\"color:#393A34\">=</span><span class=\"token plain\"> command</span><span class=\"token punctuation\" style=\"color:#393A34\">.</span><span class=\"token function\" style=\"color:#d73a49\">toByteArray</span><span class=\"token punctuation\" style=\"color:#393A34\">(</span><span class=\"token plain\">Charsets</span><span class=\"token punctuation\" style=\"color:#393A34\">.</span><span class=\"token plain\">US_ASCII</span><span class=\"token punctuation\" style=\"color:#393A34\">)</span><span class=\"token plain\"></span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token plain\" style=\"display:inline-block\"></span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token plain\">    </span><span class=\"token keyword\" style=\"color:#00009f\">override</span><span class=\"token plain\"> </span><span class=\"token keyword\" style=\"color:#00009f\">fun</span><span class=\"token plain\"> </span><span class=\"token function\" style=\"color:#d73a49\">name</span><span class=\"token punctuation\" style=\"color:#393A34\">(</span><span class=\"token punctuation\" style=\"color:#393A34\">)</span><span class=\"token operator\" style=\"color:#393A34\">:</span><span class=\"token plain\"> String </span><span class=\"token operator\" style=\"color:#393A34\">=</span><span class=\"token plain\"> command</span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token plain\" style=\"display:inline-block\"></span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token plain\">    </span><span class=\"token keyword\" style=\"color:#00009f\">override</span><span class=\"token plain\"> </span><span class=\"token keyword\" style=\"color:#00009f\">fun</span><span class=\"token plain\"> </span><span class=\"token function\" style=\"color:#d73a49\">equals</span><span class=\"token punctuation\" style=\"color:#393A34\">(</span><span class=\"token plain\">other</span><span class=\"token operator\" style=\"color:#393A34\">:</span><span class=\"token plain\"> Any</span><span class=\"token operator\" style=\"color:#393A34\">?</span><span class=\"token punctuation\" style=\"color:#393A34\">)</span><span class=\"token operator\" style=\"color:#393A34\">:</span><span class=\"token plain\"> Boolean </span><span class=\"token punctuation\" style=\"color:#393A34\">{</span><span class=\"token plain\"></span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token plain\">        </span><span class=\"token keyword\" style=\"color:#00009f\">if</span><span class=\"token plain\"> </span><span class=\"token punctuation\" style=\"color:#393A34\">(</span><span class=\"token keyword\" style=\"color:#00009f\">this</span><span class=\"token plain\"> </span><span class=\"token operator\" style=\"color:#393A34\">===</span><span class=\"token plain\"> other</span><span class=\"token punctuation\" style=\"color:#393A34\">)</span><span class=\"token plain\"> </span><span class=\"token keyword\" style=\"color:#00009f\">return</span><span class=\"token plain\"> </span><span class=\"token boolean\" style=\"color:#36acaa\">true</span><span class=\"token plain\"></span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token plain\">        </span><span class=\"token keyword\" style=\"color:#00009f\">if</span><span class=\"token plain\"> </span><span class=\"token punctuation\" style=\"color:#393A34\">(</span><span class=\"token plain\">other </span><span class=\"token operator\" style=\"color:#393A34\">!</span><span class=\"token keyword\" style=\"color:#00009f\">is</span><span class=\"token plain\"> Tile38CommandType</span><span class=\"token punctuation\" style=\"color:#393A34\">)</span><span class=\"token plain\"> </span><span class=\"token keyword\" style=\"color:#00009f\">return</span><span class=\"token plain\"> </span><span class=\"token boolean\" style=\"color:#36acaa\">false</span><span class=\"token plain\"></span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token plain\">        </span><span class=\"token keyword\" style=\"color:#00009f\">return</span><span class=\"token plain\"> command </span><span class=\"token operator\" style=\"color:#393A34\">==</span><span class=\"token plain\"> other</span><span class=\"token punctuation\" style=\"color:#393A34\">.</span><span class=\"token plain\">command</span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token plain\">    </span><span class=\"token punctuation\" style=\"color:#393A34\">}</span><span class=\"token plain\"></span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token plain\" style=\"display:inline-block\"></span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token plain\">    </span><span class=\"token keyword\" style=\"color:#00009f\">override</span><span class=\"token plain\"> </span><span class=\"token keyword\" style=\"color:#00009f\">fun</span><span class=\"token plain\"> </span><span class=\"token function\" style=\"color:#d73a49\">hashCode</span><span class=\"token punctuation\" style=\"color:#393A34\">(</span><span class=\"token punctuation\" style=\"color:#393A34\">)</span><span class=\"token operator\" style=\"color:#393A34\">:</span><span class=\"token plain\"> Int </span><span class=\"token operator\" style=\"color:#393A34\">=</span><span class=\"token plain\"> command</span><span class=\"token punctuation\" style=\"color:#393A34\">.</span><span class=\"token function\" style=\"color:#d73a49\">hashCode</span><span class=\"token punctuation\" style=\"color:#393A34\">(</span><span class=\"token punctuation\" style=\"color:#393A34\">)</span><span class=\"token plain\"></span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token plain\" style=\"display:inline-block\"></span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token plain\">    </span><span class=\"token keyword\" style=\"color:#00009f\">companion</span><span class=\"token plain\"> </span><span class=\"token keyword\" style=\"color:#00009f\">object</span><span class=\"token plain\"> </span><span class=\"token punctuation\" style=\"color:#393A34\">{</span><span class=\"token plain\"></span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token plain\">        </span><span class=\"token keyword\" style=\"color:#00009f\">private</span><span class=\"token plain\"> </span><span class=\"token keyword\" style=\"color:#00009f\">val</span><span class=\"token plain\"> CACHE </span><span class=\"token operator\" style=\"color:#393A34\">=</span><span class=\"token plain\"> ConcurrentHashMap</span><span class=\"token operator\" style=\"color:#393A34\">&lt;</span><span class=\"token plain\">String</span><span class=\"token punctuation\" style=\"color:#393A34\">,</span><span class=\"token plain\"> Tile38CommandType</span><span class=\"token operator\" style=\"color:#393A34\">&gt;</span><span class=\"token punctuation\" style=\"color:#393A34\">(</span><span class=\"token punctuation\" style=\"color:#393A34\">)</span><span class=\"token plain\"></span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token plain\" style=\"display:inline-block\"></span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token plain\">        </span><span class=\"token keyword\" style=\"color:#00009f\">val</span><span class=\"token plain\"> SET </span><span class=\"token operator\" style=\"color:#393A34\">=</span><span class=\"token plain\"> </span><span class=\"token function\" style=\"color:#d73a49\">of</span><span class=\"token punctuation\" style=\"color:#393A34\">(</span><span class=\"token string-literal singleline string\" style=\"color:#e3116c\">\"SET\"</span><span class=\"token punctuation\" style=\"color:#393A34\">)</span><span class=\"token plain\"></span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token plain\">        </span><span class=\"token keyword\" style=\"color:#00009f\">val</span><span class=\"token plain\"> DEL </span><span class=\"token operator\" style=\"color:#393A34\">=</span><span class=\"token plain\"> </span><span class=\"token function\" style=\"color:#d73a49\">of</span><span class=\"token punctuation\" style=\"color:#393A34\">(</span><span class=\"token string-literal singleline string\" style=\"color:#e3116c\">\"DEL\"</span><span class=\"token punctuation\" style=\"color:#393A34\">)</span><span class=\"token plain\"></span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token plain\">        </span><span class=\"token keyword\" style=\"color:#00009f\">val</span><span class=\"token plain\"> DROP </span><span class=\"token operator\" style=\"color:#393A34\">=</span><span class=\"token plain\"> </span><span class=\"token function\" style=\"color:#d73a49\">of</span><span class=\"token punctuation\" style=\"color:#393A34\">(</span><span class=\"token string-literal singleline string\" style=\"color:#e3116c\">\"DROP\"</span><span class=\"token punctuation\" style=\"color:#393A34\">)</span><span class=\"token plain\"></span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token plain\">        </span><span class=\"token keyword\" style=\"color:#00009f\">val</span><span class=\"token plain\"> SETHOOK </span><span class=\"token operator\" style=\"color:#393A34\">=</span><span class=\"token plain\"> </span><span class=\"token function\" style=\"color:#d73a49\">of</span><span class=\"token punctuation\" style=\"color:#393A34\">(</span><span class=\"token string-literal singleline string\" style=\"color:#e3116c\">\"SETHOOK\"</span><span class=\"token punctuation\" style=\"color:#393A34\">)</span><span class=\"token plain\"></span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token plain\">        </span><span class=\"token keyword\" style=\"color:#00009f\">val</span><span class=\"token plain\"> DELHOOK </span><span class=\"token operator\" style=\"color:#393A34\">=</span><span class=\"token plain\"> </span><span class=\"token function\" style=\"color:#d73a49\">of</span><span class=\"token punctuation\" style=\"color:#393A34\">(</span><span class=\"token string-literal singleline string\" style=\"color:#e3116c\">\"DELHOOK\"</span><span class=\"token punctuation\" style=\"color:#393A34\">)</span><span class=\"token plain\"></span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token plain\">        </span><span class=\"token keyword\" style=\"color:#00009f\">val</span><span class=\"token plain\"> PDELHOOK </span><span class=\"token operator\" style=\"color:#393A34\">=</span><span class=\"token plain\"> </span><span class=\"token function\" style=\"color:#d73a49\">of</span><span class=\"token punctuation\" style=\"color:#393A34\">(</span><span class=\"token string-literal singleline string\" style=\"color:#e3116c\">\"PDELHOOK\"</span><span class=\"token punctuation\" style=\"color:#393A34\">)</span><span class=\"token plain\"></span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token plain\">        </span><span class=\"token keyword\" style=\"color:#00009f\">val</span><span class=\"token plain\"> GET </span><span class=\"token operator\" style=\"color:#393A34\">=</span><span class=\"token plain\"> </span><span class=\"token function\" style=\"color:#d73a49\">of</span><span class=\"token punctuation\" style=\"color:#393A34\">(</span><span class=\"token string-literal singleline string\" style=\"color:#e3116c\">\"GET\"</span><span class=\"token punctuation\" style=\"color:#393A34\">)</span><span class=\"token plain\"></span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token plain\">        </span><span class=\"token keyword\" style=\"color:#00009f\">val</span><span class=\"token plain\"> RENAME </span><span class=\"token operator\" style=\"color:#393A34\">=</span><span class=\"token plain\"> </span><span class=\"token function\" style=\"color:#d73a49\">of</span><span class=\"token punctuation\" style=\"color:#393A34\">(</span><span class=\"token string-literal singleline string\" style=\"color:#e3116c\">\"RENAME\"</span><span class=\"token punctuation\" style=\"color:#393A34\">)</span><span class=\"token plain\"></span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token plain\">        </span><span class=\"token keyword\" style=\"color:#00009f\">val</span><span class=\"token plain\"> OUTPUT </span><span class=\"token operator\" style=\"color:#393A34\">=</span><span class=\"token plain\"> </span><span class=\"token function\" style=\"color:#d73a49\">of</span><span class=\"token punctuation\" style=\"color:#393A34\">(</span><span class=\"token string-literal singleline string\" style=\"color:#e3116c\">\"OUTPUT\"</span><span class=\"token punctuation\" style=\"color:#393A34\">)</span><span class=\"token plain\"></span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token plain\" style=\"display:inline-block\"></span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token plain\">        </span><span class=\"token comment\" style=\"color:#999988;font-style:italic\">// 캐싱: 같은 이름 → 같은 인스턴스 (메트릭 맵 키가 8~9개로 수렴)</span><span class=\"token plain\"></span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token plain\">        </span><span class=\"token keyword\" style=\"color:#00009f\">fun</span><span class=\"token plain\"> </span><span class=\"token function\" style=\"color:#d73a49\">of</span><span class=\"token punctuation\" style=\"color:#393A34\">(</span><span class=\"token plain\">command</span><span class=\"token operator\" style=\"color:#393A34\">:</span><span class=\"token plain\"> String</span><span class=\"token punctuation\" style=\"color:#393A34\">)</span><span class=\"token operator\" style=\"color:#393A34\">:</span><span class=\"token plain\"> Tile38CommandType </span><span class=\"token operator\" style=\"color:#393A34\">=</span><span class=\"token plain\"></span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token plain\">            CACHE</span><span class=\"token punctuation\" style=\"color:#393A34\">.</span><span class=\"token function\" style=\"color:#d73a49\">computeIfAbsent</span><span class=\"token punctuation\" style=\"color:#393A34\">(</span><span class=\"token plain\">command</span><span class=\"token punctuation\" style=\"color:#393A34\">.</span><span class=\"token function\" style=\"color:#d73a49\">uppercase</span><span class=\"token punctuation\" style=\"color:#393A34\">(</span><span class=\"token punctuation\" style=\"color:#393A34\">)</span><span class=\"token punctuation\" style=\"color:#393A34\">)</span><span class=\"token plain\"> </span><span class=\"token punctuation\" style=\"color:#393A34\">{</span><span class=\"token plain\"> </span><span class=\"token function\" style=\"color:#d73a49\">Tile38CommandType</span><span class=\"token punctuation\" style=\"color:#393A34\">(</span><span class=\"token plain\">it</span><span class=\"token punctuation\" style=\"color:#393A34\">)</span><span class=\"token plain\"> </span><span class=\"token punctuation\" style=\"color:#393A34\">}</span><span class=\"token plain\"></span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token plain\">    </span><span class=\"token punctuation\" style=\"color:#393A34\">}</span><span class=\"token plain\"></span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token plain\"></span><span class=\"token punctuation\" style=\"color:#393A34\">}</span><br></div></code></pre></div></div>\n<p>이렇게 하면 <code>equals</code>/<code>hashCode</code>가 값 기반이든, Lettuce가 여전히 객체 identity로 비교하든 상관없이 <strong>캐싱된 동일 인스턴스</strong>가 반환되므로 어느 경우에도 키가 수렴합니다.</p>\n<p><strong>효과</strong>: 맵 키 1,660개 → 8~9개 수렴, <code>DefaultCommandLatencyCollector</code> retained heap <strong>약 404MB → 약 2MB</strong>.</p>\n<h2 class=\"anchor anchorTargetStickyNavbar_Vzrq\" id=\"6-검증-체크리스트\">6. 검증 체크리스트<a href=\"https://tech.umosone.ai/tile38-lettuce-oom#6-%EA%B2%80%EC%A6%9D-%EC%B2%B4%ED%81%AC%EB%A6%AC%EC%8A%A4%ED%8A%B8\" class=\"hash-link\" aria-label=\"6. 검증 체크리스트에 대한 직접 링크\" title=\"6. 검증 체크리스트에 대한 직접 링크\" translate=\"no\">​</a></h2>\n<ol>\n<li class=\"\">수정 배포 후 일정 시간 운영하며 힙덤프 재채취</li>\n<li class=\"\">MAT OQL <code>SELECT * FROM io.lettuce.core.metrics.CommandLatencyId</code>로 인스턴스 수가 한 자릿수로 줄었는지 확인</li>\n<li class=\"\"><code>DefaultCommandLatencyCollector</code>의 retained heap이 더 이상 증가하지 않는지 모니터링</li>\n<li class=\"\">관련 모듈 컴파일 및 테스트(<code>Tile38Client*Test</code>, <code>Tile38CommandType</code> 관련) 통과 확인</li>\n</ol>\n<h2 class=\"anchor anchorTargetStickyNavbar_Vzrq\" id=\"7-힙덤프-수집-방법-kubectl-활용\">7. 힙덤프 수집 방법 (kubectl 활용)<a href=\"https://tech.umosone.ai/tile38-lettuce-oom#7-%ED%9E%99%EB%8D%A4%ED%94%84-%EC%88%98%EC%A7%91-%EB%B0%A9%EB%B2%95-kubectl-%ED%99%9C%EC%9A%A9\" class=\"hash-link\" aria-label=\"7. 힙덤프 수집 방법 (kubectl 활용)에 대한 직접 링크\" title=\"7. 힙덤프 수집 방법 (kubectl 활용)에 대한 직접 링크\" translate=\"no\">​</a></h2>\n<p>수정 배포 후 검증이나 OOM 발생 시점 분석을 위해 운영 클러스터에서 힙덤프를 수집해야 할 때가 있습니다. 아래는 <code>fms-vehicle-data-consumer</code> 파드를 대상으로 검증된 방법입니다.</p>\n<h3 class=\"anchor anchorTargetStickyNavbar_Vzrq\" id=\"71-컨테이너-환경-확인\">7.1. 컨테이너 환경 확인<a href=\"https://tech.umosone.ai/tile38-lettuce-oom#71-%EC%BB%A8%ED%85%8C%EC%9D%B4%EB%84%88-%ED%99%98%EA%B2%BD-%ED%99%95%EC%9D%B8\" class=\"hash-link\" aria-label=\"7.1. 컨테이너 환경 확인에 대한 직접 링크\" title=\"7.1. 컨테이너 환경 확인에 대한 직접 링크\" translate=\"no\">​</a></h3>\n<ul>\n<li class=\"\">Corretto-21 JRE (JDK 아님, <code>jmap</code> 미포함)</li>\n<li class=\"\"><code>curl</code>, <code>python3</code>, <code>dnf</code> 사용 가능 (Amazon Linux 2023 기반)</li>\n<li class=\"\"><code>tar</code> 미포함 → <code>kubectl cp</code> 사용 불가 (대체 전송 방식 필요)</li>\n</ul>\n<h3 class=\"anchor anchorTargetStickyNavbar_Vzrq\" id=\"72-힙덤프-생성\">7.2. 힙덤프 생성<a href=\"https://tech.umosone.ai/tile38-lettuce-oom#72-%ED%9E%99%EB%8D%A4%ED%94%84-%EC%83%9D%EC%84%B1\" class=\"hash-link\" aria-label=\"7.2. 힙덤프 생성에 대한 직접 링크\" title=\"7.2. 힙덤프 생성에 대한 직접 링크\" translate=\"no\">​</a></h3>\n<p>Full GC를 유발하는 <code>-dump:live</code> 옵션 대신 <code>-dump</code> (live 생략)로 생성해야 STW(Stop-The-World) 시간을 단축할 수 있습니다.</p>\n<div class=\"language-bash codeBlockContainer_Ckt0 theme-code-block\" style=\"--prism-color:#393A34;--prism-background-color:#f6f8fa\"><div class=\"codeBlockContent_QJqH\"><pre tabindex=\"0\" class=\"prism-code language-bash codeBlock_bY9V thin-scrollbar\" style=\"color:#393A34;background-color:#f6f8fa\"><code class=\"codeBlockLines_e6Vv\"><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token comment\" style=\"color:#999988;font-style:italic\"># 1. JDK 다운로드 및 추출 (컨테이너에 jmap이 없으므로)</span><span class=\"token plain\"></span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token plain\">kubectl </span><span class=\"token builtin class-name\">exec</span><span class=\"token plain\"> </span><span class=\"token parameter variable\" style=\"color:#36acaa\">-n</span><span class=\"token plain\"> </span><span class=\"token operator\" style=\"color:#393A34\">&lt;</span><span class=\"token plain\">namespace</span><span class=\"token operator\" style=\"color:#393A34\">&gt;</span><span class=\"token plain\"> </span><span class=\"token operator\" style=\"color:#393A34\">&lt;</span><span class=\"token plain\">pod</span><span class=\"token operator\" style=\"color:#393A34\">&gt;</span><span class=\"token plain\"> </span><span class=\"token parameter variable\" style=\"color:#36acaa\">-c</span><span class=\"token plain\"> </span><span class=\"token operator\" style=\"color:#393A34\">&lt;</span><span class=\"token plain\">container</span><span class=\"token operator\" style=\"color:#393A34\">&gt;</span><span class=\"token plain\"> -- </span><span class=\"token function\" style=\"color:#d73a49\">sh</span><span class=\"token plain\"> </span><span class=\"token parameter variable\" style=\"color:#36acaa\">-c</span><span class=\"token plain\"> </span><span class=\"token string\" style=\"color:#e3116c\">'</span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token string\" style=\"color:#e3116c\">  curl -sL -o /tmp/corretto-jdk.tar.gz \\</span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token string\" style=\"color:#e3116c\">    https://corretto.aws/downloads/latest/amazon-corretto-21-x64-linux-jdk.tar.gz</span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token string\" style=\"color:#e3116c\">  python3 -c \"import tarfile; tarfile.open(\\\"/tmp/corretto-jdk.tar.gz\\\",\\\"r:gz\\\").extractall(\\\"/tmp/\\\")\"</span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token string\" style=\"color:#e3116c\">'</span><span class=\"token plain\"></span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token plain\" style=\"display:inline-block\"></span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token plain\"></span><span class=\"token comment\" style=\"color:#999988;font-style:italic\"># 2. 힙덤프 생성 (live 옵션 제외)</span><span class=\"token plain\"></span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token plain\">kubectl </span><span class=\"token builtin class-name\">exec</span><span class=\"token plain\"> </span><span class=\"token parameter variable\" style=\"color:#36acaa\">-n</span><span class=\"token plain\"> </span><span class=\"token operator\" style=\"color:#393A34\">&lt;</span><span class=\"token plain\">namespace</span><span class=\"token operator\" style=\"color:#393A34\">&gt;</span><span class=\"token plain\"> </span><span class=\"token operator\" style=\"color:#393A34\">&lt;</span><span class=\"token plain\">pod</span><span class=\"token operator\" style=\"color:#393A34\">&gt;</span><span class=\"token plain\"> </span><span class=\"token parameter variable\" style=\"color:#36acaa\">-c</span><span class=\"token plain\"> </span><span class=\"token operator\" style=\"color:#393A34\">&lt;</span><span class=\"token plain\">container</span><span class=\"token operator\" style=\"color:#393A34\">&gt;</span><span class=\"token plain\"> -- </span><span class=\"token function\" style=\"color:#d73a49\">sh</span><span class=\"token plain\"> </span><span class=\"token parameter variable\" style=\"color:#36acaa\">-c</span><span class=\"token plain\"> </span><span class=\"token string\" style=\"color:#e3116c\">'</span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token string\" style=\"color:#e3116c\">  JDK_HOME=$(ls -d /tmp/amazon-corretto-*/ | head -1)</span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token string\" style=\"color:#e3116c\">  LD_LIBRARY_PATH=\"$JDK_HOME/lib/server:$JDK_HOME/lib\" \\</span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token string\" style=\"color:#e3116c\">    $JDK_HOME/bin/jmap -dump:format=b,file=/tmp/heap.hprof 1</span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token string\" style=\"color:#e3116c\">'</span><br></div></code></pre></div></div>\n<blockquote>\n<p><strong>주의</strong>: 힙 크기에 따라 STW 시간이 비례합니다. 예를 들어 4.9GB 힙은 약 39초, 2.7GB 힙은 약 20초간 애플리케이션이 멈춥니다. health check probe의 timeout/threshold에 따라 파드가 재시작될 수 있습니다. 운영 시간이 오래된 파드일수록 힙이 커서 위험이 높아집니다.</p>\n</blockquote>\n<h3 class=\"anchor anchorTargetStickyNavbar_Vzrq\" id=\"73-힙덤프-전송-http-서버--port-forward\">7.3. 힙덤프 전송 (HTTP 서버 + port-forward)<a href=\"https://tech.umosone.ai/tile38-lettuce-oom#73-%ED%9E%99%EB%8D%A4%ED%94%84-%EC%A0%84%EC%86%A1-http-%EC%84%9C%EB%B2%84--port-forward\" class=\"hash-link\" aria-label=\"7.3. 힙덤프 전송 (HTTP 서버 + port-forward)에 대한 직접 링크\" title=\"7.3. 힙덤프 전송 (HTTP 서버 + port-forward)에 대한 직접 링크\" translate=\"no\">​</a></h3>\n<p>컨테이너에 <code>tar</code>가 없어 <code>kubectl cp</code>를 사용할 수 없습니다. Python HTTP 서버와 <code>kubectl port-forward</code> 조합으로 전송합니다.</p>\n<div class=\"language-bash codeBlockContainer_Ckt0 theme-code-block\" style=\"--prism-color:#393A34;--prism-background-color:#f6f8fa\"><div class=\"codeBlockContent_QJqH\"><pre tabindex=\"0\" class=\"prism-code language-bash codeBlock_bY9V thin-scrollbar\" style=\"color:#393A34;background-color:#f6f8fa\"><code class=\"codeBlockLines_e6Vv\"><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token comment\" style=\"color:#999988;font-style:italic\"># 1. 컨테이너에서 HTTP 서버 실행</span><span class=\"token plain\"></span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token plain\">kubectl </span><span class=\"token builtin class-name\">exec</span><span class=\"token plain\"> </span><span class=\"token parameter variable\" style=\"color:#36acaa\">-n</span><span class=\"token plain\"> </span><span class=\"token operator\" style=\"color:#393A34\">&lt;</span><span class=\"token plain\">namespace</span><span class=\"token operator\" style=\"color:#393A34\">&gt;</span><span class=\"token plain\"> </span><span class=\"token operator\" style=\"color:#393A34\">&lt;</span><span class=\"token plain\">pod</span><span class=\"token operator\" style=\"color:#393A34\">&gt;</span><span class=\"token plain\"> </span><span class=\"token parameter variable\" style=\"color:#36acaa\">-c</span><span class=\"token plain\"> </span><span class=\"token operator\" style=\"color:#393A34\">&lt;</span><span class=\"token plain\">container</span><span class=\"token operator\" style=\"color:#393A34\">&gt;</span><span class=\"token plain\"> -- </span><span class=\"token function\" style=\"color:#d73a49\">sh</span><span class=\"token plain\"> </span><span class=\"token parameter variable\" style=\"color:#36acaa\">-c</span><span class=\"token plain\"> </span><span class=\"token string\" style=\"color:#e3116c\">'</span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token string\" style=\"color:#e3116c\">  nohup python3 -m http.server 9999 --directory /tmp &gt; /dev/null 2&gt;&amp;1 &amp;</span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token string\" style=\"color:#e3116c\">'</span><span class=\"token plain\"></span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token plain\" style=\"display:inline-block\"></span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token plain\"></span><span class=\"token comment\" style=\"color:#999988;font-style:italic\"># 2. 로컬에서 port-forward + 다운로드</span><span class=\"token plain\"></span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token plain\">kubectl port-forward </span><span class=\"token parameter variable\" style=\"color:#36acaa\">-n</span><span class=\"token plain\"> </span><span class=\"token operator\" style=\"color:#393A34\">&lt;</span><span class=\"token plain\">namespace</span><span class=\"token operator\" style=\"color:#393A34\">&gt;</span><span class=\"token plain\"> pod/</span><span class=\"token operator\" style=\"color:#393A34\">&lt;</span><span class=\"token plain\">pod</span><span class=\"token operator\" style=\"color:#393A34\">&gt;</span><span class=\"token plain\"> </span><span class=\"token number\" style=\"color:#36acaa\">9999</span><span class=\"token plain\">:9999 </span><span class=\"token operator\" style=\"color:#393A34\">&amp;</span><span class=\"token plain\"></span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token plain\"></span><span class=\"token function\" style=\"color:#d73a49\">curl</span><span class=\"token plain\"> </span><span class=\"token parameter variable\" style=\"color:#36acaa\">-o</span><span class=\"token plain\"> ./heap-dump.hprof http://localhost:9999/heap.hprof</span><br></div></code></pre></div></div>\n<h2 class=\"anchor anchorTargetStickyNavbar_Vzrq\" id=\"8-배포-후-모니터링\">8. 배포 후 모니터링<a href=\"https://tech.umosone.ai/tile38-lettuce-oom#8-%EB%B0%B0%ED%8F%AC-%ED%9B%84-%EB%AA%A8%EB%8B%88%ED%84%B0%EB%A7%81\" class=\"hash-link\" aria-label=\"8. 배포 후 모니터링에 대한 직접 링크\" title=\"8. 배포 후 모니터링에 대한 직접 링크\" translate=\"no\">​</a></h2>\n<p>문제가 있던 v1.11.2 버전에서는 kafka event를 처리하던 consumer가 멈추면서 누적된 lag를 처리할 때 순간적으로 메모리 사용량이 급등했고, 이로 인해 OOM이 발생해 애플리케이션이 다운되면서 scale out으로 파드 개수가 늘어나는 현상을 볼 수 있었습니다.</p>\n<p><img decoding=\"async\" loading=\"lazy\" alt=\"v1.11.2 - OOM으로 인한 scale out\" src=\"https://tech.umosone.ai/assets/images/pod-memory-before-fix-eb7d857ef23c61dcbbd66004bb64c6fb.png\" width=\"1582\" height=\"1108\" class=\"img_ev3q\"></p>\n<p>메모리 문제를 수정한 v1.11.3 버전에서는 안정적으로 운영되고 있습니다.</p>\n<p><img decoding=\"async\" loading=\"lazy\" alt=\"v1.11.3 - 안정적인 메모리 사용량\" src=\"https://tech.umosone.ai/assets/images/pod-memory-after-fix-e7328a0de4b09cc3f54bf9ace8451b6f.png\" width=\"1528\" height=\"1090\" class=\"img_ev3q\"></p>\n<h2 class=\"anchor anchorTargetStickyNavbar_Vzrq\" id=\"9-한-줄-요약\">9. 한 줄 요약<a href=\"https://tech.umosone.ai/tile38-lettuce-oom#9-%ED%95%9C-%EC%A4%84-%EC%9A%94%EC%95%BD\" class=\"hash-link\" aria-label=\"9. 한 줄 요약에 대한 직접 링크\" title=\"9. 한 줄 요약에 대한 직접 링크\" translate=\"no\">​</a></h2>\n<p><code>Tile38CommandType.of()</code>가 매 커맨드 dispatch마다 <code>equals</code>/<code>hashCode</code>가 없는 새 <code>ProtocolKeyword</code> 객체를 만들었고, Lettuce의 <code>CommandLatencyId</code>가 이 객체의 identity로 메트릭 맵 키를 만들다 보니 키가 절대 충돌하지 않고 무한히 누적됐습니다. <code>of()</code>에 캐싱을 적용하고 값 기반 <code>equals</code>/<code>hashCode</code>를 추가해 해결했으며, VisualVM 재현 실험으로 인스턴스 수가 반복 호출과 무관하게 수렴하는 것을 직접 확인했습니다.</p>",
            "url": "https://tech.umosone.ai/tile38-lettuce-oom",
            "title": "Lettuce DefaultCommandLatencyCollector 메모리 누수: Tile38CommandType 하나가 힙의 72%를 잡아먹은 이야기",
            "summary": "커스텀 ProtocolKeyword 구현체가 equals/hashCode 없이 매번 새 인스턴스를 반환하면서 Lettuce의 메트릭 맵이 무한히 커진 OOM 장애를, 힙덤프 분석으로 추적하고 해결한 과정을 공유합니다.",
            "date_modified": "2026-07-23T00:00:00.000Z",
            "author": {
                "name": "오수진"
            },
            "tags": [
                "장애 대응",
                "FMS",
                "Fleet OS",
                "Backend"
            ]
        },
        {
            "id": "https://tech.umosone.ai/ai-harness",
            "content_html": "<p>모든 팀원이 같은 기준으로 업무를 시작하고, 질문하고, 완료한다면 어떨까요? 요구사항 정리부터 구현, 테스트, MR, JIRA 정리까지 말이죠. 저희가 AI 하네스를 만들며 바란 건 이것이었습니다.</p>\n<p>안녕하세요 UMOSONE TMS 개발팀의 Frontend engineer 강수아입니다.</p>\n<p>이 글에서는 저희 팀이 AI 하네스를 만들면서 어떤 문제를 해결하려 했고, 실제로 운영하면서 무엇이 바뀌었는지 공유합니다.</p>\n<h2 class=\"anchor anchorTargetStickyNavbar_Vzrq\" id=\"capora-tms\">Capora TMS<a href=\"https://tech.umosone.ai/ai-harness#capora-tms\" class=\"hash-link\" aria-label=\"Capora TMS에 대한 직접 링크\" title=\"Capora TMS에 대한 직접 링크\" translate=\"no\">​</a></h2>\n<p>본론에 앞서 저희 팀이 만드는 제품을 짧게 소개합니다. Capora TMS는 화물 운송이 필요한 기업(화주)과 화물차 기사(차주)를 연결하는 운송 관리 플랫폼입니다. \"운송의 모든 흐름을 AI로 연결한다\"는 방향 아래, 오더 등록부터 배차, 운송, 정산까지를 하나의 플랫폼에서 처리합니다. 사용자에 따라 세 개의 서비스로 나뉩니다.</p>\n<table><thead><tr><th>서비스</th><th>사용자</th><th>핵심 역할</th></tr></thead><tbody><tr><td>OMS</td><td>화주 (물류 의뢰 기업)</td><td>오더 등록, 운송 현황 추적</td></tr><tr><td>Admin</td><td>내부 운영팀</td><td>배차, 차량 관제, 정산, 회원 관리</td></tr><tr><td>Driver App</td><td>차주 (화물차 기사)</td><td>오더 수락, GPS 운송, 정산 조회</td></tr></tbody></table>\n<p>하나의 오더는 세 서비스를 가로질러 흐릅니다. 화주가 OMS에서 오더를 등록하면 운영자가 Admin에서 배차하고, 차주가 Driver App으로 수락해 운송하고, 하차가 확인되면 정산으로 이어집니다. 웹 프론트엔드 두 개와 모바일 앱, 이를 받치는 백엔드까지 — 배차 정책, 관제, 정산, 세금계산서 같은 물류 도메인이 얽혀 있어 <strong>요구사항을 정확히 이해하는 것이 개발의 절반</strong>입니다. AI 하네스가 필요했던 이유도 여기서 출발합니다.</p>\n<h2 class=\"anchor anchorTargetStickyNavbar_Vzrq\" id=\"왜-ai-하네스를-만들었을까요\">왜 AI 하네스를 만들었을까요?<a href=\"https://tech.umosone.ai/ai-harness#%EC%99%9C-ai-%ED%95%98%EB%84%A4%EC%8A%A4%EB%A5%BC-%EB%A7%8C%EB%93%A4%EC%97%88%EC%9D%84%EA%B9%8C%EC%9A%94\" class=\"hash-link\" aria-label=\"왜 AI 하네스를 만들었을까요?에 대한 직접 링크\" title=\"왜 AI 하네스를 만들었을까요?에 대한 직접 링크\" translate=\"no\">​</a></h2>\n<p>처음에는 AI를 쓰는 사람이 늘어나는 것만으로도 충분히 좋아질 거라고 생각했습니다. 실제로 작은 작업에서는 효과가 바로 보였습니다. 요구사항을 요약하고, 기존 코드를 찾고, 테스트 초안을 만드는 속도는 확실히 빨라졌습니다. 그런데 팀 단위로 쓰기 시작하자 다른 문제가 보였습니다.</p>\n<p>AI로 개발해 본 팀이라면 아마 공감하실 겁니다. 팀원마다 AI를 쓰는 방식이 전부 다릅니다. 누구는 JIRA 티켓을 정리한 뒤 시작하고, 누구는 바로 구현부터 시킵니다. 누구는 Figma나 Confluence를 같이 넣고, 누구는 코드만 보여줍니다. BE 엔지니어와 FE 엔지니어가 일을 처리하는 방식도 다릅니다. 그러다 보니 <strong>같은 티켓을 다뤄도 산출물의 품질이 사람에 따라 흔들렸습니다.</strong></p>\n<p>가장 문제가 됐던 건 <strong>AI가 애매한 요구사항을 스스로 판단해서 몰래 채워 넣는 것</strong>이었습니다. 예를 들어 \"오더 취소\" 기능을 요청하면, 취소 가능한 배차 상태나 수수료 정책이 비어 있어도 AI는 그럴듯한 기준을 만들어 구현을 이어갑니다. 겉으로는 빠르게 진행되는 것처럼 보이지만, 다 완성되고 나서야 잘못된 방향이었다는 걸 알게 됩니다. 테스트를 끝까지 돌려보지 않은 채로 \"구현했습니다\"라고 말하는 것도 마찬가지였습니다. 말로는 완료인데, 실제로 돌려보면 아니었습니다.</p>\n<p><img decoding=\"async\" loading=\"lazy\" alt=\"AI 하네스를 만든 이유\" src=\"data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxMjAwIiBoZWlnaHQ9IjcyMCIgdmlld0JveD0iMCAwIDEyMDAgNzIwIiByb2xlPSJpbWciIGFyaWEtbGFiZWxsZWRieT0idGl0bGUgZGVzYyI+CiAgPHRpdGxlIGlkPSJ0aXRsZSI+QUkg7ZWY64Sk7Iqk66W8IOunjOuToCDsnbTsnKA8L3RpdGxlPgogIDxkZXNjIGlkPSJkZXNjIj7sgqzrnozrp4jri6Qg64uk66W4IEFJIOyCrOyaqSDrsKnsi53sl5DshJwg7JqU6rWs7IKs7ZWtLCDqtaztmIQsIOqygOymneydmCDtjrjssKjqsIAg7IOd6riw6rOgLCBBSSDtlZjrhKTsiqTqsIAg7J2066W8IOydvOygle2VnCDsnpHsl4Ug7Z2Q66aE6rO8IOqyjOydtO2KuOuhnCDrrLbripQg6rWs7KGw66W8IOuCmO2DgOuCuCDqt7jrprw8L2Rlc2M+CiAgPGRlZnM+CiAgICA8c3R5bGU+CiAgICAgIC5iZyB7IGZpbGw6ICNmNWY4ZmM7IH0KICAgICAgLmdyaWQgeyBzdHJva2U6ICNkYmU0ZWY7IHN0cm9rZS13aWR0aDogMTsgb3BhY2l0eTogMC44OyB9CiAgICAgIC5jYXJkIHsgZmlsbDogI2ZmZmZmZjsgc3Ryb2tlOiAjZDdkZWVhOyBzdHJva2Utd2lkdGg6IDI7IH0KICAgICAgLnNvZnQgeyBmaWxsOiAjZWVmM2Y5OyBzdHJva2U6ICNkN2RlZWE7IHN0cm9rZS13aWR0aDogMjsgfQogICAgICAuZGFyayB7IGZpbGw6ICMxNTE5MjM7IH0KICAgICAgLnRlYWwgeyBmaWxsOiAjMTI4ZjgyOyB9CiAgICAgIC5jb3JhbCB7IGZpbGw6ICNkOTU2NDg7IH0KICAgICAgLmFtYmVyIHsgZmlsbDogI2I0N2ExNjsgfQogICAgICAuZ3JlZW4geyBmaWxsOiAjMjg3ZjUyOyB9CiAgICAgIC5tdXRlZCB7IGZpbGw6ICM2ODczODY7IH0KICAgICAgLmluayB7IGZpbGw6ICMxMjE3MjI7IH0KICAgICAgLndoaXRlIHsgZmlsbDogI2ZmZmZmZjsgfQogICAgICAudGl0bGUgeyBmb250OiA4MDAgNDJweCB1aS1zYW5zLXNlcmlmLCBzeXN0ZW0tdWksIC1hcHBsZS1zeXN0ZW0sIEJsaW5rTWFjU3lzdGVtRm9udCwgIlNlZ29lIFVJIiwgc2Fucy1zZXJpZjsgfQogICAgICAuc3VidGl0bGUgeyBmb250OiA1MDAgMjJweCB1aS1zYW5zLXNlcmlmLCBzeXN0ZW0tdWksIC1hcHBsZS1zeXN0ZW0sIEJsaW5rTWFjU3lzdGVtRm9udCwgIlNlZ29lIFVJIiwgc2Fucy1zZXJpZjsgfQogICAgICAubGFiZWwgeyBmb250OiA4MDAgMjJweCB1aS1zYW5zLXNlcmlmLCBzeXN0ZW0tdWksIC1hcHBsZS1zeXN0ZW0sIEJsaW5rTWFjU3lzdGVtRm9udCwgIlNlZ29lIFVJIiwgc2Fucy1zZXJpZjsgfQogICAgICAuYm9keSB7IGZvbnQ6IDYwMCAxOHB4IHVpLXNhbnMtc2VyaWYsIHN5c3RlbS11aSwgLWFwcGxlLXN5c3RlbSwgQmxpbmtNYWNTeXN0ZW1Gb250LCAiU2Vnb2UgVUkiLCBzYW5zLXNlcmlmOyB9CiAgICAgIC5zbWFsbCB7IGZvbnQ6IDcwMCAxNXB4IHVpLXNhbnMtc2VyaWYsIHN5c3RlbS11aSwgLWFwcGxlLXN5c3RlbSwgQmxpbmtNYWNTeXN0ZW1Gb250LCAiU2Vnb2UgVUkiLCBzYW5zLXNlcmlmOyB9CiAgICAgIC5hcnJvdyB7IGZpbGw6IG5vbmU7IHN0cm9rZTogIzdiODc5Nzsgc3Ryb2tlLXdpZHRoOiA0OyBzdHJva2UtbGluZWNhcDogcm91bmQ7IHN0cm9rZS1saW5lam9pbjogcm91bmQ7IH0KICAgICAgLnBpcGUgeyBmaWxsOiBub25lOyBzdHJva2U6ICMxMjhmODI7IHN0cm9rZS13aWR0aDogODsgc3Ryb2tlLWxpbmVjYXA6IHJvdW5kOyB9CiAgICAgIC5zaGFkb3cgeyBmaWx0ZXI6IGRyb3Atc2hhZG93KDAgMThweCAzMHB4IHJnYigzMSA0NSA2NiAvIDAuMTIpKTsgfQogICAgICAubWlkIHsgdGV4dC1hbmNob3I6IG1pZGRsZTsgfQogICAgPC9zdHlsZT4KICAgIDxtYXJrZXIgaWQ9ImFycm93aGVhZCIgbWFya2VyV2lkdGg9IjEyIiBtYXJrZXJIZWlnaHQ9IjEyIiByZWZYPSI5IiByZWZZPSI2IiBvcmllbnQ9ImF1dG8iPgogICAgICA8cGF0aCBkPSJNMiwyIEwxMCw2IEwyLDEwIFoiIGZpbGw9IiM3Yjg3OTciLz4KICAgIDwvbWFya2VyPgogIDwvZGVmcz4KCiAgPHJlY3QgY2xhc3M9ImJnIiB3aWR0aD0iMTIwMCIgaGVpZ2h0PSI3MjAiLz4KICA8ZyBvcGFjaXR5PSIwLjY1Ij4KICAgIDxwYXRoIGNsYXNzPSJncmlkIiBkPSJNMCA5MEgxMjAwTTAgMTgwSDEyMDBNMCAyNzBIMTIwME0wIDM2MEgxMjAwTTAgNDUwSDEyMDBNMCA1NDBIMTIwME0wIDYzMEgxMjAwIi8+CiAgICA8cGF0aCBjbGFzcz0iZ3JpZCIgZD0iTTEwMCAwVjcyME0yMDAgMFY3MjBNMzAwIDBWNzIwTTQwMCAwVjcyME01MDAgMFY3MjBNNjAwIDBWNzIwTTcwMCAwVjcyME04MDAgMFY3MjBNOTAwIDBWNzIwTTEwMDAgMFY3MjBNMTEwMCAwVjcyMCIvPgogIDwvZz4KCiAgPHRleHQgeD0iNzQiIHk9Ijc4IiBjbGFzcz0idGl0bGUgaW5rIj7smZwgQUkg7ZWY64Sk7Iqk66W8IOunjOuTpOyXiOuCmDwvdGV4dD4KICA8dGV4dCB4PSI3NCIgeT0iMTE4IiBjbGFzcz0ic3VidGl0bGUgbXV0ZWQiPu2UhOuhrO2UhO2KuOulvCDthrXsnbztlZjquLDrs7Tri6QsIO2MgOydmCDsnpHsl4Ug7Iic7ISc7JmAIOqygOymnSDquLDspIDsnYQg66i87KCAIOqzoOygle2WiOyKteuLiOuLpC48L3RleHQ+CgogIDxnIGNsYXNzPSJzaGFkb3ciPgogICAgPHJlY3QgeD0iNzIiIHk9IjE3OCIgd2lkdGg9IjI4MCIgaGVpZ2h0PSIxMDIiIHJ4PSIxMiIgY2xhc3M9ImNhcmQiLz4KICAgIDxjaXJjbGUgY3g9IjExMiIgY3k9IjIyOSIgcj0iMjIiIGNsYXNzPSJ0ZWFsIi8+CiAgICA8dGV4dCB4PSIxMTIiIHk9IjIzNSIgY2xhc3M9InNtYWxsIHdoaXRlIG1pZCI+QTwvdGV4dD4KICAgIDx0ZXh0IHg9IjE1MCIgeT0iMjE5IiBjbGFzcz0ibGFiZWwgaW5rIj7rsJTroZwg6rWs7ZiEPC90ZXh0PgogICAgPHRleHQgeD0iMTUwIiB5PSIyNDkiIGNsYXNzPSJib2R5IG11dGVkIj7smpTqtazsgqztla0g7KCV66asIOyDneuetTwvdGV4dD4KCiAgICA8cmVjdCB4PSI3MiIgeT0iMzA0IiB3aWR0aD0iMjgwIiBoZWlnaHQ9IjEwMiIgcng9IjEyIiBjbGFzcz0iY2FyZCIvPgogICAgPGNpcmNsZSBjeD0iMTEyIiBjeT0iMzU1IiByPSIyMiIgY2xhc3M9ImNvcmFsIi8+CiAgICA8dGV4dCB4PSIxMTIiIHk9IjM2MSIgY2xhc3M9InNtYWxsIHdoaXRlIG1pZCI+QjwvdGV4dD4KICAgIDx0ZXh0IHg9IjE1MCIgeT0iMzQ1IiBjbGFzcz0ibGFiZWwgaW5rIj5BSeqwgCDruYjsubgg7LaU7KCVPC90ZXh0PgogICAgPHRleHQgeD0iMTUwIiB5PSIzNzUiIGNsYXNzPSJib2R5IG11dGVkIj7soJXssYXqs7wg7JiI7Jm46rCAIO2dlOuTpOumvDwvdGV4dD4KCiAgICA8cmVjdCB4PSI3MiIgeT0iNDMwIiB3aWR0aD0iMjgwIiBoZWlnaHQ9IjEwMiIgcng9IjEyIiBjbGFzcz0iY2FyZCIvPgogICAgPGNpcmNsZSBjeD0iMTEyIiBjeT0iNDgxIiByPSIyMiIgY2xhc3M9ImFtYmVyIi8+CiAgICA8dGV4dCB4PSIxMTIiIHk9IjQ4NyIgY2xhc3M9InNtYWxsIHdoaXRlIG1pZCI+QzwvdGV4dD4KICAgIDx0ZXh0IHg9IjE1MCIgeT0iNDcxIiBjbGFzcz0ibGFiZWwgaW5rIj7smYTro4wg7ISg7Ja4PC90ZXh0PgogICAgPHRleHQgeD0iMTUwIiB5PSI1MDEiIGNsYXNzPSJib2R5IG11dGVkIj7si6Ttlokg6rKA7KadIOuIhOudvTwvdGV4dD4KICA8L2c+CgogIDxwYXRoIGNsYXNzPSJhcnJvdyIgbWFya2VyLWVuZD0idXJsKCNhcnJvd2hlYWQpIiBkPSJNMzY2IDM1NUg0NDQiLz4KCiAgPGcgY2xhc3M9InNoYWRvdyI+CiAgICA8cmVjdCB4PSI0NjAiIHk9IjE2OCIgd2lkdGg9IjQwMCIgaGVpZ2h0PSIzODAiIHJ4PSIxOCIgY2xhc3M9ImRhcmsiLz4KICAgIDx0ZXh0IHg9IjY2MCIgeT0iMjI4IiBjbGFzcz0idGl0bGUgd2hpdGUgbWlkIiBmb250LXNpemU9IjM0Ij5BSSDtlZjrhKTsiqQ8L3RleHQ+CiAgICA8dGV4dCB4PSI2NjAiIHk9IjI2NCIgY2xhc3M9ImJvZHkgd2hpdGUgbWlkIiBvcGFjaXR5PSIwLjc4Ij7qsJnsnYAg7Iic7IScIMK3IOqwmeydgCDquLDspIAgwrcg6rCZ7J2AIOupiOy2pCDsobDqsbQ8L3RleHQ+CiAgICA8cGF0aCBjbGFzcz0icGlwZSIgZD0iTTU1NSAzMTZINzY1Ii8+CiAgICA8Y2lyY2xlIGN4PSI1NTUiIGN5PSIzMTYiIHI9IjE2IiBjbGFzcz0idGVhbCIvPgogICAgPGNpcmNsZSBjeD0iNjI1IiBjeT0iMzE2IiByPSIxNiIgY2xhc3M9ImNvcmFsIi8+CiAgICA8Y2lyY2xlIGN4PSI2OTUiIGN5PSIzMTYiIHI9IjE2IiBjbGFzcz0iYW1iZXIiLz4KICAgIDxjaXJjbGUgY3g9Ijc2NSIgY3k9IjMxNiIgcj0iMTYiIGNsYXNzPSJncmVlbiIvPgogICAgPHRleHQgeD0iNTAwIiB5PSIzODIiIGNsYXNzPSJib2R5IHdoaXRlIj4xLiBzcGVj7Jy866GcIOuqqO2YuO2VqCDtmZXsnbg8L3RleHQ+CiAgICA8dGV4dCB4PSI1MDAiIHk9IjQyMiIgY2xhc3M9ImJvZHkgd2hpdGUiPjIuIHBsYW7snLzroZwg6rWs7KGw7JmAIOuylOychCDqs6DsoJU8L3RleHQ+CiAgICA8dGV4dCB4PSI1MDAiIHk9IjQ2MiIgY2xhc3M9ImJvZHkgd2hpdGUiPjMuIGltcGxlbWVudOuKlCB0YXNrIOuLqOychCDqtaztmIQ8L3RleHQ+CiAgICA8dGV4dCB4PSI1MDAiIHk9IjUwMiIgY2xhc3M9ImJvZHkgd2hpdGUiPjQuIHRlc3Qg7Iuk7YyoIOyLnCBjb21taXQg7LCo64uoPC90ZXh0PgogIDwvZz4KCiAgPHBhdGggY2xhc3M9ImFycm93IiBtYXJrZXItZW5kPSJ1cmwoI2Fycm93aGVhZCkiIGQ9Ik04NzYgMzU1SDk1NCIvPgoKICA8ZyBjbGFzcz0ic2hhZG93Ij4KICAgIDxyZWN0IHg9Ijk3MCIgeT0iMTc0IiB3aWR0aD0iMTU4IiBoZWlnaHQ9IjgyIiByeD0iMTIiIGNsYXNzPSJjYXJkIi8+CiAgICA8dGV4dCB4PSIxMDQ5IiB5PSIyMDciIGNsYXNzPSJsYWJlbCBpbmsgbWlkIj5TcGVjPC90ZXh0PgogICAgPHRleHQgeD0iMTA0OSIgeT0iMjM1IiBjbGFzcz0ic21hbGwgbXV0ZWQgbWlkIj7sp4jrrLgg66qp66GdPC90ZXh0PgogICAgPHJlY3QgeD0iOTcwIiB5PSIyNzgiIHdpZHRoPSIxNTgiIGhlaWdodD0iODIiIHJ4PSIxMiIgY2xhc3M9ImNhcmQiLz4KICAgIDx0ZXh0IHg9IjEwNDkiIHk9IjMxMSIgY2xhc3M9ImxhYmVsIGluayBtaWQiPlBsYW48L3RleHQ+CiAgICA8dGV4dCB4PSIxMDQ5IiB5PSIzMzkiIGNsYXNzPSJzbWFsbCBtdXRlZCBtaWQiPuq1rO2YhCDrspTsnIQ8L3RleHQ+CiAgICA8cmVjdCB4PSI5NzAiIHk9IjM4MiIgd2lkdGg9IjE1OCIgaGVpZ2h0PSI4MiIgcng9IjEyIiBjbGFzcz0iY2FyZCIvPgogICAgPHRleHQgeD0iMTA0OSIgeT0iNDE1IiBjbGFzcz0ibGFiZWwgaW5rIG1pZCI+Q29kZTwvdGV4dD4KICAgIDx0ZXh0IHg9IjEwNDkiIHk9IjQ0MyIgY2xhc3M9InNtYWxsIG11dGVkIG1pZCI+6riw7KG0IO2MqO2EtDwvdGV4dD4KICAgIDxyZWN0IHg9Ijk3MCIgeT0iNDg2IiB3aWR0aD0iMTU4IiBoZWlnaHQ9IjgyIiByeD0iMTIiIGNsYXNzPSJjYXJkIi8+CiAgICA8dGV4dCB4PSIxMDQ5IiB5PSI1MTkiIGNsYXNzPSJsYWJlbCBpbmsgbWlkIj5HYXRlPC90ZXh0PgogICAgPHRleHQgeD0iMTA0OSIgeT0iNTQ3IiBjbGFzcz0ic21hbGwgbXV0ZWQgbWlkIj7qsoDspp0g6riw66GdPC90ZXh0PgogIDwvZz4KCiAgPGc+CiAgICA8cmVjdCB4PSI0MDAiIHk9IjYwNCIgd2lkdGg9IjQwMCIgaGVpZ2h0PSI1NCIgcng9IjI3IiBjbGFzcz0ic29mdCIvPgogICAgPHRleHQgeD0iNjAwIiB5PSI2MzgiIGNsYXNzPSJib2R5IGluayBtaWQiPkFJ7J2YIOyZhOujjCDshKDslrjsnYQg7Iuk7ZaJIOqysOqzvOuhnCDrsJTqvrjquLA8L3RleHQ+CiAgPC9nPgo8L3N2Zz4K\" width=\"1200\" height=\"720\" class=\"img_ev3q\"></p>\n<p>그래서 저희는 프롬프트 문장이 아니라 <strong>작업 방식을 고정</strong>하려고 했습니다. AI가 무엇을 보고 시작하는지, 어느 단계에서 질문하는지, 어떤 산출물을 남기는지, 어떤 검증이 실패하면 멈추는지를 팀의 흐름으로 만들기 시작했습니다.</p>\n<h2 class=\"anchor anchorTargetStickyNavbar_Vzrq\" id=\"하네스가-맡은-역할\">하네스가 맡은 역할<a href=\"https://tech.umosone.ai/ai-harness#%ED%95%98%EB%84%A4%EC%8A%A4%EA%B0%80-%EB%A7%A1%EC%9D%80-%EC%97%AD%ED%95%A0\" class=\"hash-link\" aria-label=\"하네스가 맡은 역할에 대한 직접 링크\" title=\"하네스가 맡은 역할에 대한 직접 링크\" translate=\"no\">​</a></h2>\n<p>저희가 생각하는 하네스는 AI를 감싸는 실행 환경입니다. 입력을 넣으면 결과만 나오는 검은 상자가 아니라, AI가 어떤 순서로 일했고 무엇을 근거로 판단했는지 남기는 장치에 가깝습니다.</p>\n<p>현재 흐름은 크게 다섯 단계로 나뉩니다.</p>\n<table><thead><tr><th>단계</th><th>책임</th><th>남겨야 하는 것</th></tr></thead><tbody><tr><td>start</td><td>작업 시작 상태를 고정합니다.</td><td>브랜치, 티켓 맥락, 현재 상태</td></tr><tr><td>spec</td><td>요구사항을 구현 가능한 수준으로 정리합니다.</td><td>요구사항 요약, AC, 예외 케이스, 질문 목록</td></tr><tr><td>plan</td><td>코드 작성 전에 변경 범위와 검증 전략을 정합니다.</td><td>변경 파일, API 계약, 테스트 계획, 구현 순서</td></tr><tr><td>implement</td><td>계획을 task 단위로 쪼개 구현합니다.</td><td>코드 변경, task 체크리스트, 빌드타임 검증</td></tr><tr><td>test</td><td>실제 실행 결과로 완료 가능 여부를 판단합니다.</td><td>API smoke, 브라우저 확인, 테스트 결과, 커밋 가능 여부</td></tr></tbody></table>\n<p>중요한 점은 <strong>각 단계가 산출물과 통과 조건을 가진다</strong>는 것입니다. spec 단계에서 모호함이 남아 있으면 plan으로 넘어가지 않습니다. plan이 확정되지 않으면 코드를 먼저 짜지 않습니다. 필요한 테스트가 실패하면 commit이나 MR로 넘어가지 않습니다.</p>\n<p>이 구조는 전부 repository 안의 <code>.agents</code> 디렉토리에 들어 있습니다. 특정 도구 전용 설정이 아니라 팀 공용 자산으로 두기 위해서입니다. Claude Code에서는 <code>.claude/skills</code>가 이 디렉토리를 가리키는 symlink로 연결됩니다.</p>\n<div class=\"language-text codeBlockContainer_Ckt0 theme-code-block\" style=\"--prism-color:#393A34;--prism-background-color:#f6f8fa\"><div class=\"codeBlockContent_QJqH\"><pre tabindex=\"0\" class=\"prism-code language-text codeBlock_bY9V thin-scrollbar\" style=\"color:#393A34;background-color:#f6f8fa\"><code class=\"codeBlockLines_e6Vv\"><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token plain\">.agents/</span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token plain\">├── constitution.md        # 팀이 지키는 원칙</span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token plain\">├── domain/                # 물류 도메인 지식 (용어, 정산, 배차 …)</span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token plain\">├── guides/                # 워크플로우, 아키텍처 가이드</span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token plain\">├── patterns/              # 반복되는 코드 패턴</span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token plain\">├── skills/                # 반복 판단을 문서화한 스킬</span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token plain\">│   ├── feature-workflow/  # spec→plan→implement→test 오케스트레이터</span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token plain\">│   ├── feature-spec/      # 단계별 스킬 (spec, start, plan, implement, test)</span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token plain\">│   ├── browser-test/      # 브라우저 · Figma 검증</span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token plain\">│   ├── ...</span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token plain\">│   └── creating-mr/       # MR 생성</span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token plain\">└── scratch/{TICKET}/      # 티켓별 산출물 (state, spec, plan, tasks)</span><br></div></code></pre></div></div>\n<p>스킬은 반복되는 판단을 문서화해 AI에게 \"이런 일을 할 수 있다\"고 알려주는 능력이고, scratch의 산출물은 다음 사람이나 다음 AI가 이어서 볼 수 있는 기록입니다. 도구가 Claude든 Codex든 Gemini든, 같은 팀 지식을 읽고 시작합니다.</p>\n<p>하네스가 책임져야 하는 범위는 생각보다 넓었습니다. 단순히 코드를 생성하는 것이 아니라, 입력 맥락을 모으고, 누락된 질문을 드러내고, 기존 패턴을 먼저 찾고, 실행 결과를 남기고, 실패했을 때 어디서 멈췄는지 재현 가능하게 만들어야 했습니다.</p>\n<h2 class=\"anchor anchorTargetStickyNavbar_Vzrq\" id=\"실행과-검증을-분리하자\">실행과 검증을 분리하자<a href=\"https://tech.umosone.ai/ai-harness#%EC%8B%A4%ED%96%89%EA%B3%BC-%EA%B2%80%EC%A6%9D%EC%9D%84-%EB%B6%84%EB%A6%AC%ED%95%98%EC%9E%90\" class=\"hash-link\" aria-label=\"실행과 검증을 분리하자에 대한 직접 링크\" title=\"실행과 검증을 분리하자에 대한 직접 링크\" translate=\"no\">​</a></h2>\n<p>AI 에이전트의 실행 로그는 금방 사라집니다. 터미널 출력, 중간 대화, 임시 판단은 그 순간에는 유용하지만 다음 사람이 보기에는 맥락이 부족합니다. 그래서 하네스에서는 실행과 검증을 분리했습니다.</p>\n<p><strong>실행은 AI가 합니다.</strong> JIRA를 읽고, Figma를 확인하고, 기존 코드를 검색하고, 파일을 수정하고, 테스트를 실행합니다. <strong>검증은 산출물이 맡습니다.</strong> spec에는 무엇이 요구사항이고 무엇이 아직 질문인지 남깁니다. plan에는 어떤 파일을 왜 바꿀지, 어떤 테스트로 확인할지 남깁니다. tasks에는 실제 구현 단계를 체크박스로 남기고, test 결과에는 어떤 검증을 실행했고 무엇이 실패했는지 남깁니다.</p>\n<p>이렇게 나누면 몇 가지 장점이 생깁니다.</p>\n<ul>\n<li class=\"\">긴 대화를 다시 읽지 않아도 현재 작업 상태를 파악할 수 있습니다.</li>\n<li class=\"\">다른 AI 도구나 다른 개발자가 이어받아도 같은 기준으로 판단할 수 있습니다.</li>\n<li class=\"\">실패했을 때 \"왜 그렇게 구현했는지\"를 역추적할 수 있습니다.</li>\n<li class=\"\">빠른 경로를 탔더라도 무엇을 생략했는지 기록할 수 있습니다.</li>\n</ul>\n<p>컨텍스트 비용을 줄이는 데도 도움이 됐습니다. 모든 히스토리를 계속 AI에게 먹이는 대신, 산출물만 남기고 필요한 순간에 다시 읽게 하면 됩니다. 작업이 길어질수록 이 차이가 컸습니다. 하네스는 더 많은 맥락을 한 번에 넣는 장치가 아니라, <strong>필요한 맥락만 다시 꺼낼 수 있게 정리하는 장치</strong>에 가까웠습니다.</p>\n<h2 class=\"anchor anchorTargetStickyNavbar_Vzrq\" id=\"평가-기준은-명확한-게이트로\">평가 기준은 명확한 게이트로<a href=\"https://tech.umosone.ai/ai-harness#%ED%8F%89%EA%B0%80-%EA%B8%B0%EC%A4%80%EC%9D%80-%EB%AA%85%ED%99%95%ED%95%9C-%EA%B2%8C%EC%9D%B4%ED%8A%B8%EB%A1%9C\" class=\"hash-link\" aria-label=\"평가 기준은 명확한 게이트로에 대한 직접 링크\" title=\"평가 기준은 명확한 게이트로에 대한 직접 링크\" translate=\"no\">​</a></h2>\n<p>AI가 만든 결과를 믿으려면 완료의 기준이 말로만 남아 있어서는 안 됐습니다. 그래서 <strong>완료 기준을 실행 가능한 게이트로 옮겼습니다.</strong></p>\n<p>화주가 쓰는 오더 등록 화면이라면 단위 테스트만으로는 부족합니다. 타입과 lint가 통과해도 실제 브라우저에서 레이아웃이 깨질 수 있고, Figma가 있는 화면이라면 구현 결과가 디자인과 맞는지도 봐야 합니다. 배차나 정산 API라면 단위 테스트에 더해 API smoke, OpenAPI 계약, 예외 응답까지 확인해야 합니다.</p>\n<p>그래서 test 단계는 단순히 테스트 명령을 실행하는 역할이 아니라 커밋 가능 여부를 판단하는 게이트가 됐습니다. 필요한 검증이 실패하면 commit이나 MR로 넘어가지 못하고, <strong>실패를 사람이 명시적으로 승인하지 않는 한 완료로 포장하지 않습니다.</strong> 게이트에서 확인하는 기준은 대략 이렇습니다.</p>\n<ul>\n<li class=\"\">요구사항이 충분히 구체화됐는가</li>\n<li class=\"\">기존 코드 패턴을 먼저 확인했는가</li>\n<li class=\"\">계획 밖 구현이 섞이지 않았는가</li>\n<li class=\"\">테스트와 브라우저 검증이 실제로 실행됐는가</li>\n<li class=\"\">실패한 검증을 숨기지 않고 기록했는가</li>\n<li class=\"\">MR과 JIRA에 다음 사람이 볼 수 있는 요약이 남았는가</li>\n</ul>\n<p>이 기준은 코드, 스킬, hook, 문서로 나눠 남겼습니다. 스킬은 AI가 어떻게 판단해야 하는지 알려주고, hook은 사람이 잊어도 특정 이벤트에서 검증이 실행되게 만들고, 문서는 왜 그런 기준이 필요한지 설명합니다.</p>\n<p>기준이 지나치게 많아지는 것도 경계했습니다. <strong>하네스가 무거워지면 아무도 쓰지 않습니다.</strong> 그래서 모든 작업에 같은 깊이의 검증을 강제하기보다, 작업 크기와 위험도에 따라 quick mode와 standard mode를 나눴습니다. 문구 수정 같은 작은 작업은 quick mode로 빠르게 지나가고, 정책이 걸린 작업은 standard mode로 전 단계를 밟습니다. 대신 quick mode를 탔다면 무엇을 생략했는지 남기게 했습니다.</p>\n<h2 class=\"anchor anchorTargetStickyNavbar_Vzrq\" id=\"계속-성장하는-하네스\">계속 성장하는 하네스<a href=\"https://tech.umosone.ai/ai-harness#%EA%B3%84%EC%86%8D-%EC%84%B1%EC%9E%A5%ED%95%98%EB%8A%94-%ED%95%98%EB%84%A4%EC%8A%A4\" class=\"hash-link\" aria-label=\"계속 성장하는 하네스에 대한 직접 링크\" title=\"계속 성장하는 하네스에 대한 직접 링크\" translate=\"no\">​</a></h2>\n<p>운영하면서도 지속적인 수정이 필요했습니다. 예를 들어 화면 구현에서, 코드상으로는 구현이 끝났지만 Figma와 실제 화면이 미묘하게 다른 일이 있었습니다. 이후 browser-test 흐름으로 viewport, Figma, 브라우저 결과를 같이 확인하고 report를 남기는 쪽으로 바꿨습니다.</p>\n<p><strong>스킬 자체도 운영 대상</strong>이었습니다. 만들어둔 스킬이 실제 작업에서 호출되지 않으면 없는 것과 같습니다. 그래서 스킬 사용량을 보고, 잘 쓰이지 않는 스킬은 trigger를 고치거나 내용을 줄이거나 제거합니다. 스킬을 많이 만드는 게 목표가 아니라, 반복되는 판단이 실제 작업 안에서 자연스럽게 호출되게 만드는 게 목표이기 때문입니다.</p>\n<p>이런 수정은 한 번으로 끝나지 않습니다. 모델이 바뀌고, 도구가 바뀌고, 팀의 작업 방식이 바뀌면 같은 티켓도 다른 결과를 낼 수 있기 때문입니다.</p>\n<h2 class=\"anchor anchorTargetStickyNavbar_Vzrq\" id=\"사람이-책임져야-할-부분\">사람이 책임져야 할 부분<a href=\"https://tech.umosone.ai/ai-harness#%EC%82%AC%EB%9E%8C%EC%9D%B4-%EC%B1%85%EC%9E%84%EC%A0%B8%EC%95%BC-%ED%95%A0-%EB%B6%80%EB%B6%84\" class=\"hash-link\" aria-label=\"사람이 책임져야 할 부분에 대한 직접 링크\" title=\"사람이 책임져야 할 부분에 대한 직접 링크\" translate=\"no\">​</a></h2>\n<p>가장 크게 바뀐 기준은 AI와 사람의 책임 경계였습니다. 처음에는 AI가 더 많은 일을 하면 좋다고 생각했지만, 운영해 보니 중요한 건 AI가 어디까지 할 수 있느냐보다 <strong>사람이 어디에서 최종 판단해야 하느냐</strong>였습니다.</p>\n<p><img decoding=\"async\" loading=\"lazy\" alt=\"AI와 사람의 책임 경계\" src=\"data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxMjAwIiBoZWlnaHQ9IjcyMCIgdmlld0JveD0iMCAwIDEyMDAgNzIwIiByb2xlPSJpbWciIGFyaWEtbGFiZWxsZWRieT0idGl0bGUgZGVzYyI+CiAgPHRpdGxlIGlkPSJ0aXRsZSI+QUnsmYAg7IKs656M7J2YIOyxheyehCDqsr3qs4Q8L3RpdGxlPgogIDxkZXNjIGlkPSJkZXNjIj5BSeqwgCDstIjslYgg7J6R7ISxLCDrp6Xrnb0g67OR7ZWpLCDthYzsiqTtirgg7KCc7JWI6rO8IOqygOymnSDsi6TtlonsnYQg64u064u57ZWY6rOgIOyCrOuejOydtCDrspTsnIQg6rKw7KCVLCDslYTtgqTthY3sspgg7YyQ64uoLCDstZzsooUg7Iq57J246rO8IOuouOyngOulvCDssYXsnoTsp4DripQg6rK96rOE66W8IOuCmO2DgOuCuCDqt7jrprw8L2Rlc2M+CiAgPGRlZnM+CiAgICA8c3R5bGU+CiAgICAgIC5iZyB7IGZpbGw6ICNmNWY4ZmM7IH0KICAgICAgLmNhcmQgeyBmaWxsOiAjZmZmZmZmOyBzdHJva2U6ICNkN2RlZWE7IHN0cm9rZS13aWR0aDogMjsgfQogICAgICAuYWkgeyBmaWxsOiAjZTdlNGZmOyBzdHJva2U6ICM2MjU1Yzg7IHN0cm9rZS13aWR0aDogMjsgfQogICAgICAuaHVtYW4geyBmaWxsOiAjZDVmOGYxOyBzdHJva2U6ICMxMjhmODI7IHN0cm9rZS13aWR0aDogMjsgfQogICAgICAuZ2F0ZSB7IGZpbGw6ICMxNTE5MjM7IH0KICAgICAgLmxpbmUgeyBzdHJva2U6ICM5YWE1YjU7IHN0cm9rZS13aWR0aDogMzsgc3Ryb2tlLWRhc2hhcnJheTogMTAgMTA7IH0KICAgICAgLnZpb2xldCB7IGZpbGw6ICM2MjU1Yzg7IH0KICAgICAgLnRlYWwgeyBmaWxsOiAjMTI4ZjgyOyB9CiAgICAgIC5pbmsgeyBmaWxsOiAjMTIxNzIyOyB9CiAgICAgIC5tdXRlZCB7IGZpbGw6ICM2ODczODY7IH0KICAgICAgLndoaXRlIHsgZmlsbDogI2ZmZmZmZjsgfQogICAgICAudGl0bGUgeyBmb250OiA4MDAgNDJweCB1aS1zYW5zLXNlcmlmLCBzeXN0ZW0tdWksIC1hcHBsZS1zeXN0ZW0sIEJsaW5rTWFjU3lzdGVtRm9udCwgIlNlZ29lIFVJIiwgc2Fucy1zZXJpZjsgfQogICAgICAuc3VidGl0bGUgeyBmb250OiA1MDAgMjJweCB1aS1zYW5zLXNlcmlmLCBzeXN0ZW0tdWksIC1hcHBsZS1zeXN0ZW0sIEJsaW5rTWFjU3lzdGVtRm9udCwgIlNlZ29lIFVJIiwgc2Fucy1zZXJpZjsgfQogICAgICAubGFiZWwgeyBmb250OiA4MDAgMjRweCB1aS1zYW5zLXNlcmlmLCBzeXN0ZW0tdWksIC1hcHBsZS1zeXN0ZW0sIEJsaW5rTWFjU3lzdGVtRm9udCwgIlNlZ29lIFVJIiwgc2Fucy1zZXJpZjsgfQogICAgICAuYm9keSB7IGZvbnQ6IDY1MCAxOHB4IHVpLXNhbnMtc2VyaWYsIHN5c3RlbS11aSwgLWFwcGxlLXN5c3RlbSwgQmxpbmtNYWNTeXN0ZW1Gb250LCAiU2Vnb2UgVUkiLCBzYW5zLXNlcmlmOyB9CiAgICAgIC5zbWFsbCB7IGZvbnQ6IDc1MCAxNXB4IHVpLXNhbnMtc2VyaWYsIHN5c3RlbS11aSwgLWFwcGxlLXN5c3RlbSwgQmxpbmtNYWNTeXN0ZW1Gb250LCAiU2Vnb2UgVUkiLCBzYW5zLXNlcmlmOyB9CiAgICAgIC5zaGFkb3cgeyBmaWx0ZXI6IGRyb3Atc2hhZG93KDAgMThweCAzMHB4IHJnYigzMSA0NSA2NiAvIDAuMTIpKTsgfQogICAgICAubWlkIHsgdGV4dC1hbmNob3I6IG1pZGRsZTsgfQogICAgPC9zdHlsZT4KICA8L2RlZnM+CgogIDxyZWN0IGNsYXNzPSJiZyIgd2lkdGg9IjEyMDAiIGhlaWdodD0iNzIwIi8+CiAgPHRleHQgeD0iNzIiIHk9Ijc4IiBjbGFzcz0idGl0bGUgaW5rIj5BSeyZgCDsgqzrnozsnZgg7LGF7J6EIOqyveqzhDwvdGV4dD4KICA8dGV4dCB4PSI3MiIgeT0iMTE4IiBjbGFzcz0ic3VidGl0bGUgbXV0ZWQiPkFJ64qUIOy0iOyViOqzvCDqsoDspp3snYQg64+V6rOgLCDstZzsooUg7YyQ64uo6rO8IOyxheyehOydgCDqsJzrsJzsnpDqsIAg6rCA7KeR64uI64ukLjwvdGV4dD4KCiAgPGcgY2xhc3M9InNoYWRvdyI+CiAgICA8cmVjdCB4PSI3MiIgeT0iMTY4IiB3aWR0aD0iNDMwIiBoZWlnaHQ9IjQzMCIgcng9IjE4IiBjbGFzcz0iYWkiLz4KICAgIDxjaXJjbGUgY3g9IjEyOCIgY3k9IjIyNiIgcj0iMjgiIGNsYXNzPSJ2aW9sZXQiLz4KICAgIDx0ZXh0IHg9IjEyOCIgeT0iMjMyIiBjbGFzcz0ic21hbGwgd2hpdGUgbWlkIj5BSTwvdGV4dD4KICAgIDx0ZXh0IHg9IjE3NCIgeT0iMjM2IiBjbGFzcz0ibGFiZWwgaW5rIj5BSeqwgCDrj5XripQg7JiB7JetPC90ZXh0PgogICAgPHRleHQgeD0iMTE4IiB5PSIzMDYiIGNsYXNzPSJib2R5IGluayI+4oCiIHNwZWMg7LSI7JWIIOyekeyEsTwvdGV4dD4KICAgIDx0ZXh0IHg9IjExOCIgeT0iMzUwIiBjbGFzcz0iYm9keSBpbmsiPuKAoiBKSVJBL0ZpZ21hL0NvbmZsdWVuY2Ug66el6529IOuzke2VqTwvdGV4dD4KICAgIDx0ZXh0IHg9IjExOCIgeT0iMzk0IiBjbGFzcz0iYm9keSBpbmsiPuKAoiDrqqjtmLjtlZwg7JqU6rWs7IKs7ZWtIOyniOusuCDrqqnroZ3tmZQ8L3RleHQ+CiAgICA8dGV4dCB4PSIxMTgiIHk9IjQzOCIgY2xhc3M9ImJvZHkgaW5rIj7igKIgcGxhbi90YXNrcyDrtoTtlbQ8L3RleHQ+CiAgICA8dGV4dCB4PSIxMTgiIHk9IjQ4MiIgY2xhc3M9ImJvZHkgaW5rIj7igKIg7L2U65OcIOuzgOqyveqzvCDthYzsiqTtirgg7KCc7JWIPC90ZXh0PgogICAgPHRleHQgeD0iMTE4IiB5PSI1MjYiIGNsYXNzPSJib2R5IGluayI+4oCiIOqygOymnSBnYXRlIOyLpO2WieqzvCDqsrDqs7wg7KCV66asPC90ZXh0PgogIDwvZz4KCiAgPGxpbmUgeDE9IjYwMCIgeTE9IjE1NCIgeDI9IjYwMCIgeTI9IjYxNiIgY2xhc3M9ImxpbmUiLz4KICA8ZyBjbGFzcz0ic2hhZG93Ij4KICAgIDxyZWN0IHg9IjUzNiIgeT0iMjkyIiB3aWR0aD0iMTI4IiBoZWlnaHQ9IjExOCIgcng9IjE2IiBjbGFzcz0iZ2F0ZSIvPgogICAgPHRleHQgeD0iNjAwIiB5PSIzMzgiIGNsYXNzPSJsYWJlbCB3aGl0ZSBtaWQiPkdhdGU8L3RleHQ+CiAgICA8dGV4dCB4PSI2MDAiIHk9IjM2OCIgY2xhc3M9InNtYWxsIHdoaXRlIG1pZCIgb3BhY2l0eT0iMC44MiI+6rKA7KadIOqysOqzvDwvdGV4dD4KICAgIDx0ZXh0IHg9IjYwMCIgeT0iMzkyIiBjbGFzcz0ic21hbGwgd2hpdGUgbWlkIiBvcGFjaXR5PSIwLjgyIj7sp4jrrLjqs7wg6re86rGwPC90ZXh0PgogIDwvZz4KCiAgPGcgY2xhc3M9InNoYWRvdyI+CiAgICA8cmVjdCB4PSI2OTgiIHk9IjE2OCIgd2lkdGg9IjQzMCIgaGVpZ2h0PSI0MzAiIHJ4PSIxOCIgY2xhc3M9Imh1bWFuIi8+CiAgICA8Y2lyY2xlIGN4PSI3NTQiIGN5PSIyMjYiIHI9IjI4IiBjbGFzcz0idGVhbCIvPgogICAgPHRleHQgeD0iNzU0IiB5PSIyMzEiIGNsYXNzPSJzbWFsbCB3aGl0ZSBtaWQiPkRFVjwvdGV4dD4KICAgIDx0ZXh0IHg9IjgwMCIgeT0iMjM2IiBjbGFzcz0ibGFiZWwgaW5rIj7sgqzrnozsnbQg7LGF7J6E7KeA64qUIOyYgeyXrTwvdGV4dD4KICAgIDx0ZXh0IHg9Ijc0NCIgeT0iMzA2IiBjbGFzcz0iYm9keSBpbmsiPuKAoiBzcGVjIOy1nOyihSDtmZXsoJU8L3RleHQ+CiAgICA8dGV4dCB4PSI3NDQiIHk9IjM1MCIgY2xhc3M9ImJvZHkgaW5rIj7igKIg7KeI66y4IOuLteuzgOqzvCDrspTsnIQg6rKw7KCVPC90ZXh0PgogICAgPHRleHQgeD0iNzQ0IiB5PSIzOTQiIGNsYXNzPSJib2R5IGluayI+4oCiIOyVhO2CpO2FjeyymCDtjJDri6g8L3RleHQ+CiAgICA8dGV4dCB4PSI3NDQiIHk9IjQzOCIgY2xhc3M9ImJvZHkgaW5rIj7igKIgQUkg7L2U65Oc66as67ewIO2ZleyduOqzvCDsiJjsoJU8L3RleHQ+CiAgICA8dGV4dCB4PSI3NDQiIHk9IjQ4MiIgY2xhc3M9ImJvZHkgaW5rIj7igKIgZ2F0ZSDthrXqs7wg65iQ64qUIOyerOyekeyXhSDqsrDsoJU8L3RleHQ+CiAgICA8dGV4dCB4PSI3NDQiIHk9IjUyNiIgY2xhc3M9ImJvZHkgaW5rIj7igKIgTVIg7LWc7KKFIOyKueyduOqzvCDrqLjsp4A8L3RleHQ+CiAgPC9nPgoKICA8ZyBjbGFzcz0ic2hhZG93Ij4KICAgIDxyZWN0IHg9IjMwMiIgeT0iNjI2IiB3aWR0aD0iNTk2IiBoZWlnaHQ9IjU0IiByeD0iMjciIGNsYXNzPSJjYXJkIi8+CiAgICA8dGV4dCB4PSI2MDAiIHk9IjY2MCIgY2xhc3M9ImJvZHkgaW5rIG1pZCI+7IaN64+E64qUIEFJ6rCAIOyYrOumrOqzoCwg7LGF7J6E7J2AIOyCrOuejOydtCDri6vsirXri4jri6QuPC90ZXh0PgogIDwvZz4KPC9zdmc+Cg==\" width=\"1200\" height=\"720\" class=\"img_ev3q\"></p>\n<p>AI가 잘하는 일은 초안을 만들고, 흩어진 맥락을 모으고, 누락된 질문을 찾아내고, 검증을 실행해 결과를 정리하는 일입니다. 반대로 spec을 최종 확정하는 일, 질문에 답하고 범위를 결정하는 일, 아키텍처를 판단하는 일, gate 통과나 재작업을 결정하는 일, MR을 승인하고 머지하는 일은 개발자의 책임으로 남습니다.</p>\n<p>하네스는 이 경계를 더 선명하게 만들기 위해 만든 것입니다. AI가 더 많은 초안을 만들수록 사람이 봐야 할 근거가 더 중요해집니다. 그래서 하네스의 산출물은 AI가 자신 있게 말한 문장이 아니라, <strong>사람이 확인할 수 있는 질문, 결정, 테스트 결과, 실패 기록</strong>이어야 합니다.</p>\n<h2 class=\"anchor anchorTargetStickyNavbar_Vzrq\" id=\"특히-고민했던-것들\">특히 고민했던 것들<a href=\"https://tech.umosone.ai/ai-harness#%ED%8A%B9%ED%9E%88-%EA%B3%A0%EB%AF%BC%ED%96%88%EB%8D%98-%EA%B2%83%EB%93%A4\" class=\"hash-link\" aria-label=\"특히 고민했던 것들에 대한 직접 링크\" title=\"특히 고민했던 것들에 대한 직접 링크\" translate=\"no\">​</a></h2>\n<p>가장 많이 고민한 것은 <strong>스킬을 얼마나 잘게 나눌지</strong>였습니다. FE는 React/TypeScript, BE는 Kotlin/Spring WebFlux로 스택이 다르지만, 티켓을 읽고, 요구사항을 정리하고, 계획을 세우고, 구현하고, 테스트하는 흐름은 같습니다. 그래서 workflow는 하나로 두고 스택 차이는 reference 문서로 분기했습니다. 한 번 고친 운영 원칙이 양쪽에 같이 반영되게 하기 위해서입니다.</p>\n<p>두 번째는 <strong>요구사항 stress test</strong>였습니다. AI는 애매한 요구사항을 보면 질문보다 추정을 택합니다. 그래서 spec 단계에서 사용자, 발생 조건, 필요한 데이터, 정책, 예외, 검증 순서로 질문하게 만들었습니다. 질문을 하나씩 흩뿌리면 답이 누락되기 쉬워서, 가능한 한 묶어서 던지고 답이 없으면 plan으로 넘어가지 않는 기준을 뒀습니다.</p>\n<p>세 번째는 <strong>기록의 양</strong>이었습니다. 전부 남기면 읽히지 않고, 너무 적게 남기면 이어받을 수 없습니다. 그래서 다음 단계가 실제로 읽는 spec, plan, tasks, test 위주로 남기고 중간 대화는 과감히 줄였습니다.</p>\n<p>네 번째는 <strong>도구 중립성</strong>입니다. 특정 AI 도구의 설정에만 묶이면 팀의 지식이 도구 안에 갇힙니다. 도구는 바뀔 수 있지만, 팀의 판단 기준은 repository 안에 남아야 한다고 봤습니다. 원칙과 스킬을 <code>.agents</code> 아래에 둔 이유입니다.</p>\n<p>마지막은 <strong>하네스 자체의 비용</strong>입니다. 하네스가 너무 무거우면 작은 수정에도 부담이 됩니다. 그래서 모든 작업에 풀코스를 강제하기보다 작업의 위험도에 따라 경로를 나누되, 빠른 경로를 탔다는 사실과 생략한 검증은 남깁니다. 빠르게 가더라도 무엇을 믿을 수 있고 무엇을 다시 확인해야 하는지는 보여야 하기 때문입니다.</p>\n<h2 class=\"anchor anchorTargetStickyNavbar_Vzrq\" id=\"앞으로-나아가야-할-방향\">앞으로 나아가야 할 방향<a href=\"https://tech.umosone.ai/ai-harness#%EC%95%9E%EC%9C%BC%EB%A1%9C-%EB%82%98%EC%95%84%EA%B0%80%EC%95%BC-%ED%95%A0-%EB%B0%A9%ED%96%A5\" class=\"hash-link\" aria-label=\"앞으로 나아가야 할 방향에 대한 직접 링크\" title=\"앞으로 나아가야 할 방향에 대한 직접 링크\" translate=\"no\">​</a></h2>\n<p>지금의 하네스는 feature 개발 흐름에서 시작했지만, 화면 구현, 코드리뷰, 문서화, 장애 리포트처럼 입력과 산출물과 검증 기준이 반복되는 업무라면 같은 구조를 적용할 수 있습니다. 저희는 두 가지를 준비하고 있습니다.</p>\n<p>첫째는 대표 작업 기준의 회귀 평가입니다. 모델이나 스킬을 바꾼 뒤 \"대체로 좋아졌다\"고 말하는 대신, 이전에 처리했던 대표 티켓을 다시 실행해 질문 품질, 계획 품질, 검증 누락이 어떻게 달라졌는지 비교하려 합니다. AI 하네스도 테스트 대상이 되어야 합니다.</p>\n<p>둘째는 결과 정확도를 높이는 반복 루프입니다. 한 번의 실행으로 끝내지 않고, 실행 결과를 평가하고 부족한 부분을 찾아 다시 실행하는 루프를 하네스 안에 넣으려 합니다. 사람이 매번 결과를 뜯어보며 재작업을 지시하는 대신, 평가와 재실행이 구조 안에서 돌게 만드는 것입니다.</p>\n<p>하네스의 다음 단계는 \"AI가 더 많은 일을 하게 하기\"가 아니라 **\"AI가 한 일을 더 잘 검증하고 다시 사용할 수 있게 하기\"**라고 생각합니다. 팀의 좋은 판단이 한 사람의 프롬프트 창에만 남지 않고, 다음 작업에서도 반복될 수 있어야 합니다.</p>\n<h2 class=\"anchor anchorTargetStickyNavbar_Vzrq\" id=\"마치며\">마치며<a href=\"https://tech.umosone.ai/ai-harness#%EB%A7%88%EC%B9%98%EB%A9%B0\" class=\"hash-link\" aria-label=\"마치며에 대한 직접 링크\" title=\"마치며에 대한 직접 링크\" translate=\"no\">​</a></h2>\n<p>하네스를 만들며 배운 가장 큰 교훈은 <strong>신뢰는 말이 아니라 구조에서 나온다</strong>는 점이었습니다. AI가 그럴듯하게 설명해도 실행 결과가 없으면 믿을 수 없습니다. 테스트가 실패했는데 완료라고 말하면 안 됩니다. 요구사항이 애매한데 질문 없이 구현하면 빠른 게 아니라 위험한 것입니다.</p>\n<p>하네스는 AI를 더 자유롭게 풀어놓기 위한 장치가 아닙니다. 같은 순서, 같은 기준, 같은 멈춤 조건을 만들어 AI와 사람이 함께 일할 수 있는 환경을 만드는 일입니다.</p>\n<p>속도는 AI가 올릴 수 있습니다. 하지만 <strong>책임은 사람이 가져야 합니다.</strong> 저희가 하네스를 계속 다듬는 이유도 여기에 있습니다. 더 빠른 개발보다 반복 가능한 개발, 더 많은 자동화보다 검증 가능한 자동화. 그 방향으로 팀의 작업 방식을 조금씩 옮겨가고 있습니다.</p>\n<p>물론 저희 방식이 정답은 아닙니다. 팀마다 제품도, 도메인도, 일하는 방식도 다르니까요. 비슷한 고민을 하고 있는 팀이 있다면 공유해주세요.</p>\n<p>긴 글 읽어주셔서 감사합니다 😊</p>",
            "url": "https://tech.umosone.ai/ai-harness",
            "title": "AI 하네스: 반복 가능한 에이전트 개발 환경 만들기",
            "summary": "팀 전체가 같은 기준으로 AI와 일하기 위해 만든 AI 하네스의 구조와, 운영하면서 바뀐 기준을 정리합니다.",
            "date_modified": "2026-07-13T00:00:00.000Z",
            "author": {
                "name": "강수아"
            },
            "tags": [
                "AI",
                "AI 에이전트",
                "AI 하네스",
                "TMS"
            ]
        },
        {
            "id": "https://tech.umosone.ai/ontology-eval-loop",
            "content_html": "<p>안녕하세요, UMOS Data/AI 팀에서 지식그래프를 개발하고 있는 데이터 분석가 이민식입니다.</p>\n<p>저희 팀은 창고(WMS), 배차(TMS), 차량(FMS), 호출(TAP)처럼 서로 다른 시스템에 흩어져 있는 운영 데이터를 하나의 지식그래프로 연결하고, 그 위에서 자연어 질문을 SPARQL(지식그래프에 데이터를 물어보는 질문 언어)로 바꿔 답하는 온톨로지 기반 챗봇을 만들고 있습니다. 여기서 자연스럽게 드는 질문이 있습니다. 왜 하필 온톨로지일까요?</p>\n<p><img decoding=\"async\" loading=\"lazy\" alt=\"왜 온톨로지인가\" src=\"data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxMjAwIiBoZWlnaHQ9IjcyMCIgdmlld0JveD0iMCAwIDEyMDAgNzIwIiByb2xlPSJpbWciIGFyaWEtbGFiZWxsZWRieT0idGl0bGUgZGVzYyI+CiAgPHRpdGxlIGlkPSJ0aXRsZSI+7JmcIOyYqO2GqOuhnOyngOyduOqwgDwvdGl0bGU+CiAgPGRlc2MgaWQ9ImRlc2MiPldNUywgVE1TLCBGTVMsIFRBUOqwgCDqsIHsnpAg65Sw66GcIOyatOyYgeuQmOuptCDsi5zsiqTthZwg6rK96rOE66W8IOuEmOuCmOuTnOuKlCDsp4jrrLjsl5DripQg64u17ZWgIOyImCDsl4bsp4Drp4wsIOyngOyLneq3uOuemO2UhOuhnCDsl7DqsrDtlZjrqbQg6rCZ7J2AIOyniOusuOyXkCDri7XtlaAg7IiYIOyeiOqyjCDrkJjripQg6rO87KCV7J2EIOuCmO2DgOuCuCDqt7jrprw8L2Rlc2M+CiAgPGRlZnM+CiAgICA8c3R5bGU+CiAgICAgIC5iZyB7IGZpbGw6ICNmNWY4ZmM7IH0KICAgICAgLmNhcmQgeyBmaWxsOiAjZmZmZmZmOyBzdHJva2U6ICNkN2RlZWE7IHN0cm9rZS13aWR0aDogMjsgfQogICAgICAuYWxlcnQgeyBmaWxsOiAjZmZmNWY0OyBzdHJva2U6ICNkOTU2NDg7IHN0cm9rZS13aWR0aDogMjsgfQogICAgICAub2sgeyBmaWxsOiAjZjNmYWY2OyBzdHJva2U6ICMyODdmNTI7IHN0cm9rZS13aWR0aDogMjsgfQogICAgICAuZGl2aWRlciB7IHN0cm9rZTogI2UzZThlZjsgc3Ryb2tlLXdpZHRoOiAyOyB9CiAgICAgIC5kYXJrIHsgZmlsbDogIzE1MTkyMzsgfQogICAgICAuYmx1ZSB7IGZpbGw6ICMyZjdkZjY7IH0KICAgICAgLnRlYWwgeyBmaWxsOiAjMTI4ZjgyOyB9CiAgICAgIC5hbWJlciB7IGZpbGw6ICNiNDdhMTY7IH0KICAgICAgLnB1cnBsZSB7IGZpbGw6ICM3YzVjZmY7IH0KICAgICAgLmNvcmFsIHsgZmlsbDogI2Q5NTY0ODsgfQogICAgICAuZ3JlZW4geyBmaWxsOiAjMjg3ZjUyOyB9CiAgICAgIC5tdXRlZCB7IGZpbGw6ICM2ODczODY7IH0KICAgICAgLmluayB7IGZpbGw6ICMxMjE3MjI7IH0KICAgICAgLndoaXRlIHsgZmlsbDogI2ZmZmZmZjsgfQogICAgICAudGl0bGUgeyBmb250OiA4MDAgNDJweCB1aS1zYW5zLXNlcmlmLCBzeXN0ZW0tdWksIC1hcHBsZS1zeXN0ZW0sIEJsaW5rTWFjU3lzdGVtRm9udCwgIlNlZ29lIFVJIiwgc2Fucy1zZXJpZjsgfQogICAgICAuc3VidGl0bGUgeyBmb250OiA1MDAgMjJweCB1aS1zYW5zLXNlcmlmLCBzeXN0ZW0tdWksIC1hcHBsZS1zeXN0ZW0sIEJsaW5rTWFjU3lzdGVtRm9udCwgIlNlZ29lIFVJIiwgc2Fucy1zZXJpZjsgfQogICAgICAubGFiZWwgeyBmb250OiA4MDAgMjNweCB1aS1zYW5zLXNlcmlmLCBzeXN0ZW0tdWksIC1hcHBsZS1zeXN0ZW0sIEJsaW5rTWFjU3lzdGVtRm9udCwgIlNlZ29lIFVJIiwgc2Fucy1zZXJpZjsgfQogICAgICAuYm9keSB7IGZvbnQ6IDYwMCAxOHB4IHVpLXNhbnMtc2VyaWYsIHN5c3RlbS11aSwgLWFwcGxlLXN5c3RlbSwgQmxpbmtNYWNTeXN0ZW1Gb250LCAiU2Vnb2UgVUkiLCBzYW5zLXNlcmlmOyB9CiAgICAgIC5zbWFsbCB7IGZvbnQ6IDcwMCAxNnB4IHVpLXNhbnMtc2VyaWYsIHN5c3RlbS11aSwgLWFwcGxlLXN5c3RlbSwgQmxpbmtNYWNTeXN0ZW1Gb250LCAiU2Vnb2UgVUkiLCBzYW5zLXNlcmlmOyB9CiAgICAgIC5hcnJvdyB7IGZpbGw6IG5vbmU7IHN0cm9rZTogIzdiODc5Nzsgc3Ryb2tlLXdpZHRoOiA0OyBzdHJva2UtbGluZWNhcDogcm91bmQ7IHN0cm9rZS1saW5lam9pbjogcm91bmQ7IH0KICAgICAgLnNoYWRvdyB7IGZpbHRlcjogZHJvcC1zaGFkb3coMCAxOHB4IDMwcHggcmdiKDMxIDQ1IDY2IC8gMC4xMikpOyB9CiAgICA8L3N0eWxlPgogICAgPG1hcmtlciBpZD0iYXJyb3doZWFkIiBtYXJrZXJXaWR0aD0iOCIgbWFya2VySGVpZ2h0PSI4IiByZWZYPSI4IiByZWZZPSI0IiBtYXJrZXJVbml0cz0idXNlclNwYWNlT25Vc2UiIG9yaWVudD0iYXV0byI+CiAgICAgIDxwYXRoIGQ9Ik0wLDAgTDgsNCBMMCw4IFoiIGZpbGw9IiM3Yjg3OTciLz4KICAgIDwvbWFya2VyPgogIDwvZGVmcz4KCiAgPHJlY3QgY2xhc3M9ImJnIiB3aWR0aD0iMTIwMCIgaGVpZ2h0PSI3MjAiLz4KICA8dGV4dCB4PSI3MiIgeT0iNzgiIGNsYXNzPSJ0aXRsZSBpbmsiPuyZnCDsmKjthqjroZzsp4DsnbjqsIA8L3RleHQ+CiAgPHRleHQgeD0iNzIiIHk9IjExMiIgY2xhc3M9InN1YnRpdGxlIG11dGVkIj7rjbDsnbTthLDqsIAg7JeG7Ja07ISc6rCAIOyVhOuLiOudvCwg7Jew6rKw65CY7Ja0IOyeiOyngCDslYrslYTshJwg66q7IO2SgOuNmCDsp4jrrLjsnbQg7J6I7JeI7Iq164uI64ukLjwvdGV4dD4KCiAgPCEtLSBSb3cgMTogZm91ciBzaWxvcyAtLT4KICA8ZyBjbGFzcz0ic2hhZG93Ij4KICAgIDxyZWN0IHg9IjcyIiB5PSIxNTAiIHdpZHRoPSIyNDAiIGhlaWdodD0iMTUwIiByeD0iMTgiIGNsYXNzPSJjYXJkIi8+CiAgICA8Y2lyY2xlIGN4PSIxMTAiIGN5PSIxOTIiIHI9IjI0IiBjbGFzcz0iYmx1ZSIvPgogICAgPHRleHQgeD0iMTUwIiB5PSIxOTYiIGNsYXNzPSJsYWJlbCBpbmsiPldNUzwvdGV4dD4KICAgIDx0ZXh0IHg9IjE1MCIgeT0iMjIyIiBjbGFzcz0iYm9keSBtdXRlZCI+7LC96rOgIOq0gOumrDwvdGV4dD4KICAgIDxwYXRoIGNsYXNzPSJkaXZpZGVyIiBkPSJNMTAyIDI0MEgyODIiLz4KICAgIDx0ZXh0IHg9IjEwMiIgeT0iMjcwIiBjbGFzcz0ic21hbGwgaW5rIj7qsIDriqU6IOyerOqzoCDCtyDsnoXstpzqs6Ag7KGw7ZqMPC90ZXh0PgogIDwvZz4KCiAgPGcgY2xhc3M9InNoYWRvdyI+CiAgICA8cmVjdCB4PSIzNDQiIHk9IjE1MCIgd2lkdGg9IjI0MCIgaGVpZ2h0PSIxNTAiIHJ4PSIxOCIgY2xhc3M9ImNhcmQiLz4KICAgIDxjaXJjbGUgY3g9IjM4MiIgY3k9IjE5MiIgcj0iMjQiIGNsYXNzPSJ0ZWFsIi8+CiAgICA8dGV4dCB4PSI0MjIiIHk9IjE5NiIgY2xhc3M9ImxhYmVsIGluayI+VE1TPC90ZXh0PgogICAgPHRleHQgeD0iNDIyIiB5PSIyMjIiIGNsYXNzPSJib2R5IG11dGVkIj7rsLDssKggwrcg7Jq07IahIOq0gOumrDwvdGV4dD4KICAgIDxwYXRoIGNsYXNzPSJkaXZpZGVyIiBkPSJNMzc0IDI0MEg1NTQiLz4KICAgIDx0ZXh0IHg9IjM3NCIgeT0iMjcwIiBjbGFzcz0ic21hbGwgaW5rIj7qsIDriqU6IOuwsOywqCDsp4Dsl7Ag6rG07IiYIOyhsO2ajDwvdGV4dD4KICA8L2c+CgogIDxnIGNsYXNzPSJzaGFkb3ciPgogICAgPHJlY3QgeD0iNjE2IiB5PSIxNTAiIHdpZHRoPSIyNDAiIGhlaWdodD0iMTUwIiByeD0iMTgiIGNsYXNzPSJjYXJkIi8+CiAgICA8Y2lyY2xlIGN4PSI2NTQiIGN5PSIxOTIiIHI9IjI0IiBjbGFzcz0iYW1iZXIiLz4KICAgIDx0ZXh0IHg9IjY5NCIgeT0iMTk2IiBjbGFzcz0ibGFiZWwgaW5rIj5GTVM8L3RleHQ+CiAgICA8dGV4dCB4PSI2OTQiIHk9IjIyMiIgY2xhc3M9ImJvZHkgbXV0ZWQiPuywqOufiSDCtyDshKTruYQg6rSA66asPC90ZXh0PgogICAgPHBhdGggY2xhc3M9ImRpdmlkZXIiIGQ9Ik02NDYgMjQwSDgyNiIvPgogICAgPHRleHQgeD0iNjQ2IiB5PSIyNzAiIGNsYXNzPSJzbWFsbCBpbmsiPuqwgOuKpTog7KCV67mEIOyYiOyglSDssKjrn4kg7KGw7ZqMPC90ZXh0PgogIDwvZz4KCiAgPGcgY2xhc3M9InNoYWRvdyI+CiAgICA8cmVjdCB4PSI4ODgiIHk9IjE1MCIgd2lkdGg9IjI0MCIgaGVpZ2h0PSIxNTAiIHJ4PSIxOCIgY2xhc3M9ImNhcmQiLz4KICAgIDxjaXJjbGUgY3g9IjkyNiIgY3k9IjE5MiIgcj0iMjQiIGNsYXNzPSJwdXJwbGUiLz4KICAgIDx0ZXh0IHg9Ijk2NiIgeT0iMTk2IiBjbGFzcz0ibGFiZWwgaW5rIj5UQVA8L3RleHQ+CiAgICA8dGV4dCB4PSI5NjYiIHk9IjIyMiIgY2xhc3M9ImJvZHkgbXV0ZWQiPuywqOufiSDtmLjstpwg6rSA66asPC90ZXh0PgogICAgPHBhdGggY2xhc3M9ImRpdmlkZXIiIGQ9Ik05MTggMjQwSDEwOTgiLz4KICAgIDx0ZXh0IHg9IjkxOCIgeT0iMjcwIiBjbGFzcz0ic21hbGwgaW5rIj7qsIDriqU6IO2YuOy2nCDrjIDquLAg7KGw7ZqMPC90ZXh0PgogIDwvZz4KCiAgPCEtLSBCbG9ja2VkIHF1ZXN0aW9uIC0tPgogIDxnIGNsYXNzPSJzaGFkb3ciPgogICAgPHJlY3QgeD0iNzIiIHk9IjMyNiIgd2lkdGg9IjEwNTYiIGhlaWdodD0iNTYiIHJ4PSIyMCIgY2xhc3M9ImFsZXJ0Ii8+CiAgICA8Y2lyY2xlIGN4PSIxMDQiIGN5PSIzNTQiIHI9IjgiIGNsYXNzPSJjb3JhbCIvPgogICAgPHRleHQgeD0iMTI4IiB5PSIzNDkiIGNsYXNzPSJib2R5IGluayI+Iuygleu5hCDsnbTroKXsnbQg67Cw7LCoIOyngOyXsOyXkCDslrzrp4jrgpgg7JiB7Zal7J2EIOykrOuKlOqwgD8iPC90ZXh0PgogICAgPHRleHQgeD0iMTI4IiB5PSIzNjkiIGNsYXNzPSJzbWFsbCBtdXRlZCI+4oaSIOyLnOyKpO2FnCDqsr3qs4Trpbwg64SY64qUIOyniOusuOydtOudvCDri7XtlaAg7IiYIOyXhuydjDwvdGV4dD4KICA8L2c+CgogIDxwYXRoIGNsYXNzPSJhcnJvdyIgbWFya2VyLWVuZD0idXJsKCNhcnJvd2hlYWQpIiBkPSJNNjAwIDM4MlY0MDgiLz4KICA8dGV4dCB4PSI2MjYiIHk9IjQwMCIgY2xhc3M9InNtYWxsIG11dGVkIj7smKjthqjroZzsp4DroZwg7Jew6rKwPC90ZXh0PgoKICA8IS0tIEh1YiAtLT4KICA8ZyBjbGFzcz0ic2hhZG93Ij4KICAgIDxyZWN0IHg9IjcyIiB5PSI0MTAiIHdpZHRoPSIxMDU2IiBoZWlnaHQ9IjEyMCIgcng9IjI0IiBjbGFzcz0iZGFyayIvPgogICAgPHRleHQgeD0iMTA0IiB5PSI0NjIiIGNsYXNzPSJ0aXRsZSB3aGl0ZSIgZm9udC1zaXplPSIzMiI+7KeA7Iud6re4656Y7ZSEPC90ZXh0PgogICAgPHRleHQgeD0iMTA0IiB5PSI0OTIiIGNsYXNzPSJib2R5IHdoaXRlIiBvcGFjaXR5PSIwLjgyIj5XTVMgwrcgVE1TIMK3IEZNUyDCtyBUQVDrpbwg7ZWY64KY7J2YIOydmOuvuCDqtazsobDroZwg7Jew6rKwPC90ZXh0PgogICAgPGNpcmNsZSBjeD0iMTAzMCIgY3k9IjQ1MiIgcj0iMTIiIGNsYXNzPSJibHVlIi8+CiAgICA8Y2lyY2xlIGN4PSIxMDYwIiBjeT0iNDUyIiByPSIxMiIgY2xhc3M9InRlYWwiLz4KICAgIDxjaXJjbGUgY3g9IjEwMzAiIGN5PSI0ODIiIHI9IjEyIiBjbGFzcz0iYW1iZXIiLz4KICAgIDxjaXJjbGUgY3g9IjEwNjAiIGN5PSI0ODIiIHI9IjEyIiBjbGFzcz0icHVycGxlIi8+CiAgPC9nPgoKICA8cGF0aCBjbGFzcz0iYXJyb3ciIG1hcmtlci1lbmQ9InVybCgjYXJyb3doZWFkKSIgZD0iTTYwMCA1MzBWNTU2Ii8+CgogIDwhLS0gQW5zd2VyZWQgcXVlc3Rpb24gLS0+CiAgPGcgY2xhc3M9InNoYWRvdyI+CiAgICA8cmVjdCB4PSI3MiIgeT0iNTU4IiB3aWR0aD0iMTA1NiIgaGVpZ2h0PSI1NiIgcng9IjIwIiBjbGFzcz0ib2siLz4KICAgIDxjaXJjbGUgY3g9IjEwNCIgY3k9IjU4NiIgcj0iOCIgY2xhc3M9ImdyZWVuIi8+CiAgICA8dGV4dCB4PSIxMjgiIHk9IjU4MSIgY2xhc3M9ImJvZHkgaW5rIj4i7KCV67mEIOydtOugpeydtCDrsLDssKgg7KeA7Jew7JeQIOyWvOuniOuCmCDsmIHtlqXsnYQg7KSs64qU6rCAPyI8L3RleHQ+CiAgICA8dGV4dCB4PSIxMjgiIHk9IjYwMSIgY2xhc3M9InNtYWxsIG11dGVkIj7ihpIg7KeA7Iud6re4656Y7ZSE66GcIOyXsOqysO2VmOuptCDri7Xrs4Ag6rCA64qlPC90ZXh0PgogIDwvZz4KCiAgPGcgY2xhc3M9InNoYWRvdyI+CiAgICA8cmVjdCB4PSIxOTAiIHk9IjYzNCIgd2lkdGg9IjgyMCIgaGVpZ2h0PSI1NCIgcng9IjI3IiBjbGFzcz0iY2FyZCIvPgogICAgPGNpcmNsZSBjeD0iMjI0IiBjeT0iNjYxIiByPSIxMCIgY2xhc3M9ImdyZWVuIi8+CiAgICA8dGV4dCB4PSIyNTAiIHk9IjY2OCIgY2xhc3M9ImJvZHkgaW5rIj7smKjthqjroZzsp4DripQg642w7J207YSw66W8IOuNlCDrqqjsnLzripQg6rKMIOyVhOuLiOudvCwg7J2066+4IOyeiOuKlCDrjbDsnbTthLDrpbwg7Jew6rKw7ZWY64qUIOuwqeuyleyeheuLiOuLpC48L3RleHQ+CiAgPC9nPgo8L3N2Zz4K\" width=\"1200\" height=\"720\" class=\"img_ev3q\"></p>\n<p>각 시스템 안에서는 \"이번 달 정비 예정 차량\"이나 \"오늘 배차 지연 건수\" 같은 조회가 이미 가능합니다. 하지만 \"정비 이력이 배차 지연에 얼마나 영향을 줬는가\"처럼 시스템 경계를 넘나드는 질문에는 답할 수 없었습니다. 데이터가 없어서가 아니라 서로 다른 이름과 구조로 흩어져 있었기 때문입니다. 온톨로지는 이 데이터를 하나의 의미 구조로 묶어, 도메인을 넘나드는 질문에도 답할 수 있게 만드는 방법입니다. 그리고 이 연결은 한 번 만들고 끝나는 게 아니라, 창고·차량의 실제 상태 변화를 놓치지 않고 계속 반영해야 유지됩니다 — 이렇게 디지털 트윈을 촘촘하게 유지하는 일도 온톨로지 작업의 일부입니다.</p>\n<p>그런데 이렇게 만든 챗봇이 실제로 믿을 만한 답을 내놓는지는 별개의 문제입니다. 이 글은 그 챗봇을 검증하는 일, 그중에서도 판정을 사람이 아니라 AI에게 맡기면서 데이터 품질 자체가 스스로 채워지는 구조로 바뀐 과정을 다룹니다.</p>\n<h2 class=\"anchor anchorTargetStickyNavbar_Vzrq\" id=\"원래는-분석가가-붙어야-하는-일이었습니다\">원래는 분석가가 붙어야 하는 일이었습니다<a href=\"https://tech.umosone.ai/ontology-eval-loop#%EC%9B%90%EB%9E%98%EB%8A%94-%EB%B6%84%EC%84%9D%EA%B0%80%EA%B0%80-%EB%B6%99%EC%96%B4%EC%95%BC-%ED%95%98%EB%8A%94-%EC%9D%BC%EC%9D%B4%EC%97%88%EC%8A%B5%EB%8B%88%EB%8B%A4\" class=\"hash-link\" aria-label=\"원래는 분석가가 붙어야 하는 일이었습니다에 대한 직접 링크\" title=\"원래는 분석가가 붙어야 하는 일이었습니다에 대한 직접 링크\" translate=\"no\">​</a></h2>\n<p>이 챗봇을 검증하려면 클러스터별로 나눈 테스트 케이스(TC) 수십~수백 개를 하나씩 확인해야 했습니다. 응답이 맞았는지, SPARQL이 의도대로 짜였는지, 틀렸다면 어디가 문제인지를 사람이 하나씩 확인하던 시절에는 검증 속도가 분석가 한 명의 판단 속도에 그대로 묶여 있었습니다. 지금은 그 판정 대부분을 LLM 체인이 대신하고, 사람은 판정 기준을 지키는 역할만 남았습니다.</p>\n<p>예를 들어 <code>\"이번 달 정비 예정인 트럭이 몇 대야?\"</code>라는 질문 하나를 검증한다고 해보겠습니다. 이 글에서는 같은 질문을 처음부터 끝까지 따라가며 각 단계를 보여드리겠습니다. 사람이 직접 하면 다음 네 가지를 전부 거쳐야 합니다.</p>\n<ul>\n<li class=\"\">챗봇 답변에 \"정비 예정 대수, 차량 목록\"이라는 채점 기준(rubric)이 요구하는 정보가 담겼는지 읽고 판단</li>\n<li class=\"\">답변을 만들 때 생성된 SPARQL이 실제로 \"이번 달\"·\"정비 예정\" 조건을 걸었는지 까서 확인</li>\n<li class=\"\">틀렸다면 원인이 API 오류인지, SPARQL 생성 자체가 안 된 건지, 정비 일정 데이터가 아직 없어서인지 구분</li>\n<li class=\"\">이런 실패가 반복되면 \"정비 일정 테이블을 더 적재해야 하는지\" 리포트로 정리</li>\n</ul>\n<p>TC가 몇 개일 때는 사람이 감당할 수 있습니다. 하지만 도메인이 늘고 클러스터가 늘어날수록, 이 네 가지 작업 전부가 분석가 한 명의 판단 속도에 묶였습니다. 온톨로지를 넓히는 속도가 아니라 사람이 판정하는 속도가 병목이 된 겁니다.</p>\n<h2 class=\"anchor anchorTargetStickyNavbar_Vzrq\" id=\"판정을-사람-대신-ai에게-맡기다\">판정을 사람 대신 AI에게 맡기다<a href=\"https://tech.umosone.ai/ontology-eval-loop#%ED%8C%90%EC%A0%95%EC%9D%84-%EC%82%AC%EB%9E%8C-%EB%8C%80%EC%8B%A0-ai%EC%97%90%EA%B2%8C-%EB%A7%A1%EA%B8%B0%EB%8B%A4\" class=\"hash-link\" aria-label=\"판정을 사람 대신 AI에게 맡기다에 대한 직접 링크\" title=\"판정을 사람 대신 AI에게 맡기다에 대한 직접 링크\" translate=\"no\">​</a></h2>\n<p>그래서 이 판정 작업 자체를 사람이 아니라 AI에게 맡겼습니다. 여기서 클래스는 엑셀로 치면 \"어떤 시트를 봤는지\"(정비 일정, 차량 같은 것), 프로퍼티는 \"그 시트에서 어떤 열(항목)을 가져왔는지\"(예정일, 차량 번호 같은 것)에 가깝습니다. 이 질문에 제대로 답하려면 어떤 시트와 열을 봐야 하는지 미리 정답을 적어두는데, 이걸 <code>expected_classes</code>, <code>expected_properties</code>라고 부릅니다.</p>\n<p>같은 질문 — <code>\"이번 달 정비 예정인 트럭이 몇 대야?\"</code> — 이 다음 세 단계를 어떻게 통과하는지 보면 구조가 보입니다.</p>\n<ol>\n<li class=\"\"><strong>평가</strong>: 질문을 실제 API에 던져 응답을 받고, Rubric Judge(LLM)가 \"정비 예정 대수와 차량 목록이 담겼는지\" PASS/FAIL을 채점합니다.</li>\n<li class=\"\"><strong>진단</strong>: 실패했다면 SPARQL Judge(LLM)가 생성된 SPARQL에서 \"이번 달\"·\"정비 예정\" 조건에 해당하는 클래스·프로퍼티를 실제로 썼는지 추출해, 기대한 조회 경로를 얼마나 충족했는지(<code>match_score</code>) 계산하고 원인을 분류합니다.</li>\n<li class=\"\"><strong>개선안 도출</strong>: 원인이 \"정비 일정 데이터가 없어서\"(NO_DATA)로 분류되면, 온톨로지-DB 스키마 매핑을 역으로 뒤집어 어떤 테이블·컬럼을 적재해야 하는지 자동으로 집계합니다.</li>\n</ol>\n<p>같은 질문은 다음 여섯 가지 결과 중 하나로 분류됩니다.</p>\n<table><thead><tr><th>Cause</th><th>이 질문이 이렇게 실패했을 때</th></tr></thead><tbody><tr><td><code>API_ERROR</code></td><td>질문을 보냈는데 API 응답 자체가 오지 않음</td></tr><tr><td><code>NO_SPARQL</code></td><td>답변은 왔지만 \"이번 달 정비 예정\"을 조회할 SPARQL이 만들어지지 않음</td></tr><tr><td><code>NO_DATA</code></td><td>SPARQL은 \"이번 달 정비 예정\" 조건을 정확히 걸었지만, 정비 일정 데이터가 아직 적재되지 않아 결과가 0건</td></tr><tr><td><code>WRONG_SPARQL</code></td><td>SPARQL이 \"정비 완료\" 조건으로 잘못 짜였고, 답변도 없음</td></tr><tr><td><code>HALLUCINATION</code></td><td>SPARQL은 \"정비 완료\" 조건으로 잘못 짜였는데, 그럴듯한 숫자를 답변으로 만들어냄</td></tr><tr><td><code>PASS</code></td><td>\"이번 달 정비 예정 트럭 3대\"처럼 정상적으로 답함</td></tr></tbody></table>\n<p>방금 그 질문이 NO_DATA로 분류되면, 파이프라인은 이런 형태로 결과를 만들어냅니다.</p>\n<div class=\"language-json codeBlockContainer_Ckt0 theme-code-block\" style=\"--prism-color:#393A34;--prism-background-color:#f6f8fa\"><div class=\"codeBlockContent_QJqH\"><pre tabindex=\"0\" class=\"prism-code language-json codeBlock_bY9V thin-scrollbar\" style=\"color:#393A34;background-color:#f6f8fa\"><code class=\"codeBlockLines_e6Vv\"><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token punctuation\" style=\"color:#393A34\">{</span><span class=\"token plain\"></span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token plain\">  </span><span class=\"token property\" style=\"color:#36acaa\">\"table\"</span><span class=\"token operator\" style=\"color:#393A34\">:</span><span class=\"token plain\"> </span><span class=\"token string\" style=\"color:#e3116c\">\"maintenance_schedule\"</span><span class=\"token punctuation\" style=\"color:#393A34\">,</span><span class=\"token plain\"></span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token plain\">  </span><span class=\"token property\" style=\"color:#36acaa\">\"label_ko\"</span><span class=\"token operator\" style=\"color:#393A34\">:</span><span class=\"token plain\"> </span><span class=\"token string\" style=\"color:#e3116c\">\"정비 일정\"</span><span class=\"token punctuation\" style=\"color:#393A34\">,</span><span class=\"token plain\"></span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token plain\">  </span><span class=\"token property\" style=\"color:#36acaa\">\"blocked_tcs\"</span><span class=\"token operator\" style=\"color:#393A34\">:</span><span class=\"token plain\"> </span><span class=\"token punctuation\" style=\"color:#393A34\">[</span><span class=\"token string\" style=\"color:#e3116c\">\"FMS-DE-A-014\"</span><span class=\"token punctuation\" style=\"color:#393A34\">]</span><span class=\"token punctuation\" style=\"color:#393A34\">,</span><span class=\"token plain\"></span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token plain\">  </span><span class=\"token property\" style=\"color:#36acaa\">\"columns\"</span><span class=\"token operator\" style=\"color:#393A34\">:</span><span class=\"token plain\"> </span><span class=\"token punctuation\" style=\"color:#393A34\">[</span><span class=\"token punctuation\" style=\"color:#393A34\">{</span><span class=\"token plain\"> </span><span class=\"token property\" style=\"color:#36acaa\">\"column\"</span><span class=\"token operator\" style=\"color:#393A34\">:</span><span class=\"token plain\"> </span><span class=\"token string\" style=\"color:#e3116c\">\"scheduled_date\"</span><span class=\"token punctuation\" style=\"color:#393A34\">,</span><span class=\"token plain\"> </span><span class=\"token property\" style=\"color:#36acaa\">\"label_ko\"</span><span class=\"token operator\" style=\"color:#393A34\">:</span><span class=\"token plain\"> </span><span class=\"token string\" style=\"color:#e3116c\">\"예정일\"</span><span class=\"token plain\"> </span><span class=\"token punctuation\" style=\"color:#393A34\">}</span><span class=\"token punctuation\" style=\"color:#393A34\">]</span><span class=\"token plain\"></span><br></div><div class=\"token-line\" style=\"color:#393A34\"><span class=\"token plain\"></span><span class=\"token punctuation\" style=\"color:#393A34\">}</span><br></div></code></pre></div></div>\n<p>사람이 로그를 뒤져서 \"이 질문이 왜 안 풀리지\"를 역추적하던 일이, 실행 결과가 곧바로 \"무엇을 적재해야 하는가\" 리스트로 나오는 형태로 바뀐 겁니다.</p>\n<h2 class=\"anchor anchorTargetStickyNavbar_Vzrq\" id=\"처음엔-판정-하나면-충분한-줄-알았습니다\">처음엔 판정 하나면 충분한 줄 알았습니다<a href=\"https://tech.umosone.ai/ontology-eval-loop#%EC%B2%98%EC%9D%8C%EC%97%94-%ED%8C%90%EC%A0%95-%ED%95%98%EB%82%98%EB%A9%B4-%EC%B6%A9%EB%B6%84%ED%95%9C-%EC%A4%84-%EC%95%8C%EC%95%98%EC%8A%B5%EB%8B%88%EB%8B%A4\" class=\"hash-link\" aria-label=\"처음엔 판정 하나면 충분한 줄 알았습니다에 대한 직접 링크\" title=\"처음엔 판정 하나면 충분한 줄 알았습니다에 대한 직접 링크\" translate=\"no\">​</a></h2>\n<p>이 세 단계는 처음부터 이렇게 설계된 게 아니었습니다. 처음 설계는 훨씬 단순했습니다. Rubric Judge 하나로 PASS/FAIL만 나누면 될 거라고 생각했습니다.</p>\n<p>그런데 같은 질문에서 두 가지 문제가 곧 보였습니다.</p>\n<p>첫째, <code>\"이번 달 정비 예정인 트럭이 몇 대야?\"</code>에 대해 SPARQL이 \"지난달 정비 완료\" 조건으로 완전히 엉뚱하게 짜인 적이 있었습니다. 그런데 마침 그달 정비 완료 대수와 이번 달 정비 예정 대수가 우연히 똑같아서, 답변만 본 Rubric Judge는 PASS를 줬습니다.</p>\n<p>둘째, 반대로 SPARQL이 <code>ScheduledMaintenance</code> 클래스 대신 의미상 동등한 <code>MaintenancePlan</code> 클래스로 조회한 적이 있었습니다. 결과는 맞았지만 rubric이 <code>ScheduledMaintenance</code>만 정답 기준으로 잡아둔 탓에 FAIL로 떨어졌습니다. 답변 내용만 보는 판정으로는 \"어떤 방식으로 답에 도달했는가\"를 신뢰할 수 없었습니다.</p>\n<p>그래서 SPARQL 자체를 채점하는 두 번째 판정(SPARQL Judge)을 추가했습니다. 그런데 이것도 완벽하지 않았습니다. 두 번째 사례처럼 SPARQL Judge가 \"기대 경로에 못 미친다(SPARQL_GAP)\"고 판정했는데 Rubric Judge는 PASS를 준, 애매한 경계 케이스가 남았습니다. 이 경계 케이스만 따로 모아 세 번째 판정(Alt SPARQL Verify)이 \"의미적으로 유효한 대체 경로인지, 아니면 우연히 통과한 것인지\"를 한 번 더 검증하도록 구조를 다시 짰습니다.</p>\n<p><img decoding=\"async\" loading=\"lazy\" alt=\"판정을 하나에서 셋으로 늘린 이유\" src=\"data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxMjAwIiBoZWlnaHQ9IjcyMCIgdmlld0JveD0iMCAwIDEyMDAgNzIwIiByb2xlPSJpbWciIGFyaWEtbGFiZWxsZWRieT0idGl0bGUgZGVzYyI+CiAgPHRpdGxlIGlkPSJ0aXRsZSI+7YyQ7KCV7J2EIO2VmOuCmOyXkOyEnCDshYvsnLzroZwg64qY66awIOydtOycoDwvdGl0bGU+CiAgPGRlc2MgaWQ9ImRlc2MiPlJ1YnJpYyBKdWRnZSDtlZjrgpjrp4zsnLzroZzripQgU1BBUlFM7J20IO2LgOugpOuPhCDri7XsnbQg66ee7JWEIFBBU1PrkJjqsbDrgpggU1BBUlFM7J20IOunnuyVhOuPhCDtkZztmITsnbQg64us6528IEZBSUzrkJjripQg7Jik7YyQ7J20IOyeiOyXiOqzoCwgU1BBUlFMIEp1ZGdl66W8IOy2lOqwgO2WiOyngOunjCBTUEFSUUxfR0FQ7J24642wIFJ1YnJpY+ydgCBQQVNT7J24IOyVoOunpO2VnCDqsr3qs4Qg7LyA7J207Iqk6rCAIOuCqOyVhCBBbHQgU1BBUlFMIFZlcmlmeeuhnCDsnqzqsoDspp3tlZjripQgM+uLqCDtjJDsoJUg7LK07J247J20IOunjOuTpOyWtOynhCDqs7zsoJXsnYQg64KY7YOA64K4IOq3uOumvDwvZGVzYz4KICA8ZGVmcz4KICAgIDxzdHlsZT4KICAgICAgLmJnIHsgZmlsbDogI2Y1ZjhmYzsgfQogICAgICAuY2FyZCB7IGZpbGw6ICNmZmZmZmY7IHN0cm9rZTogI2Q3ZGVlYTsgc3Ryb2tlLXdpZHRoOiAyOyB9CiAgICAgIC5kaXZpZGVyIHsgc3Ryb2tlOiAjZTNlOGVmOyBzdHJva2Utd2lkdGg6IDI7IH0KICAgICAgLmRhcmsgeyBmaWxsOiAjMTUxOTIzOyB9CiAgICAgIC50ZWFsIHsgZmlsbDogIzEyOGY4MjsgfQogICAgICAuY29yYWwgeyBmaWxsOiAjZDk1NjQ4OyB9CiAgICAgIC5hbWJlciB7IGZpbGw6ICNiNDdhMTY7IH0KICAgICAgLnZpb2xldCB7IGZpbGw6ICM2MjU1Yzg7IH0KICAgICAgLmdyZWVuIHsgZmlsbDogIzI4N2Y1MjsgfQogICAgICAubXV0ZWQgeyBmaWxsOiAjNjg3Mzg2OyB9CiAgICAgIC5pbmsgeyBmaWxsOiAjMTIxNzIyOyB9CiAgICAgIC53aGl0ZSB7IGZpbGw6ICNmZmZmZmY7IH0KICAgICAgLnRpdGxlIHsgZm9udDogODAwIDQycHggdWktc2Fucy1zZXJpZiwgc3lzdGVtLXVpLCAtYXBwbGUtc3lzdGVtLCBCbGlua01hY1N5c3RlbUZvbnQsICJTZWdvZSBVSSIsIHNhbnMtc2VyaWY7IH0KICAgICAgLnN1YnRpdGxlIHsgZm9udDogNTAwIDIycHggdWktc2Fucy1zZXJpZiwgc3lzdGVtLXVpLCAtYXBwbGUtc3lzdGVtLCBCbGlua01hY1N5c3RlbUZvbnQsICJTZWdvZSBVSSIsIHNhbnMtc2VyaWY7IH0KICAgICAgLmxhYmVsIHsgZm9udDogODAwIDIzcHggdWktc2Fucy1zZXJpZiwgc3lzdGVtLXVpLCAtYXBwbGUtc3lzdGVtLCBCbGlua01hY1N5c3RlbUZvbnQsICJTZWdvZSBVSSIsIHNhbnMtc2VyaWY7IH0KICAgICAgLmJvZHkgeyBmb250OiA2MDAgMThweCB1aS1zYW5zLXNlcmlmLCBzeXN0ZW0tdWksIC1hcHBsZS1zeXN0ZW0sIEJsaW5rTWFjU3lzdGVtRm9udCwgIlNlZ29lIFVJIiwgc2Fucy1zZXJpZjsgfQogICAgICAuc21hbGwgeyBmb250OiA3MDAgMTZweCB1aS1zYW5zLXNlcmlmLCBzeXN0ZW0tdWksIC1hcHBsZS1zeXN0ZW0sIEJsaW5rTWFjU3lzdGVtRm9udCwgIlNlZ29lIFVJIiwgc2Fucy1zZXJpZjsgfQogICAgICAubnVtIHsgZm9udDogODAwIDE2cHggdWktc2Fucy1zZXJpZiwgc3lzdGVtLXVpLCAtYXBwbGUtc3lzdGVtLCBCbGlua01hY1N5c3RlbUZvbnQsICJTZWdvZSBVSSIsIHNhbnMtc2VyaWY7IHRleHQtYW5jaG9yOiBtaWRkbGU7IH0KICAgICAgLmFycm93IHsgZmlsbDogbm9uZTsgc3Ryb2tlOiAjN2I4Nzk3OyBzdHJva2Utd2lkdGg6IDQ7IHN0cm9rZS1saW5lY2FwOiByb3VuZDsgc3Ryb2tlLWxpbmVqb2luOiByb3VuZDsgfQogICAgICAuc2hhZG93IHsgZmlsdGVyOiBkcm9wLXNoYWRvdygwIDE4cHggMzBweCByZ2IoMzEgNDUgNjYgLyAwLjEyKSk7IH0KICAgIDwvc3R5bGU+CiAgICA8bWFya2VyIGlkPSJhcnJvd2hlYWQiIG1hcmtlcldpZHRoPSIxMiIgbWFya2VySGVpZ2h0PSIxMiIgcmVmWD0iOSIgcmVmWT0iNiIgb3JpZW50PSJhdXRvIj4KICAgICAgPHBhdGggZD0iTTIsMiBMMTAsNiBMMiwxMCBaIiBmaWxsPSIjN2I4Nzk3Ii8+CiAgICA8L21hcmtlcj4KICA8L2RlZnM+CgogIDxyZWN0IGNsYXNzPSJiZyIgd2lkdGg9IjEyMDAiIGhlaWdodD0iNzIwIi8+CiAgPHRleHQgeD0iNzIiIHk9Ijc4IiBjbGFzcz0idGl0bGUgaW5rIj7tjJDsoJXsnYQg7ZWY64KY7JeQ7IScIOyFi+ycvOuhnCDripjrprAg7J207JygPC90ZXh0PgogIDx0ZXh0IHg9IjcyIiB5PSIxMTgiIGNsYXNzPSJzdWJ0aXRsZSBtdXRlZCI+6rCBIOuLqOqzhOqwgCDrhpPsuZwg7Jik7YyQ7J2ELCDri6TsnYwg7YyQ7KCV7J20IOuLpOyLnCDqsoDspp3tlanri4jri6QuPC90ZXh0PgoKICA8IS0tIENhcmQgMTogUnVicmljIEp1ZGdlIC0tPgogIDxnIGNsYXNzPSJzaGFkb3ciPgogICAgPHJlY3QgeD0iNzIiIHk9IjE3MCIgd2lkdGg9IjMxMCIgaGVpZ2h0PSIzMjAiIHJ4PSIxOCIgY2xhc3M9ImNhcmQiLz4KICAgIDxjaXJjbGUgY3g9IjExMCIgY3k9IjIxMiIgcj0iMjQiIGNsYXNzPSJ2aW9sZXQiLz4KICAgIDx0ZXh0IHg9IjExMCIgeT0iMjE3IiBjbGFzcz0ibnVtIHdoaXRlIj4xPC90ZXh0PgogICAgPHRleHQgeD0iMTUwIiB5PSIyMTYiIGNsYXNzPSJsYWJlbCBpbmsiPlJ1YnJpYyBKdWRnZTwvdGV4dD4KICAgIDx0ZXh0IHg9IjE1MCIgeT0iMjQ0IiBjbGFzcz0iYm9keSBtdXRlZCI+64u167OAIOuCtOyaqeunjCDtmZXsnbg8L3RleHQ+CiAgICA8cGF0aCBjbGFzcz0iZGl2aWRlciIgZD0iTTEwMiAyNjJINDUyIi8+CgogICAgPGNpcmNsZSBjeD0iMTEwIiBjeT0iMjk2IiByPSI1IiBjbGFzcz0iY29yYWwiLz4KICAgIDx0ZXh0IHg9IjEyNiIgeT0iMzAxIiBjbGFzcz0ic21hbGwgaW5rIj5TUEFSUUzsnYAg7YuA66C464qU642wPC90ZXh0PgogICAgPHRleHQgeD0iMTI2IiB5PSIzMjMiIGNsYXNzPSJzbWFsbCBtdXRlZCI+64u17J20IOyasOyXsO2eiCDrp57slYQgUEFTUzwvdGV4dD4KCiAgICA8Y2lyY2xlIGN4PSIxMTAiIGN5PSIzNTQiIHI9IjUiIGNsYXNzPSJjb3JhbCIvPgogICAgPHRleHQgeD0iMTI2IiB5PSIzNTkiIGNsYXNzPSJzbWFsbCBpbmsiPlNQQVJRTOydgCDrp57slZjripTrjbA8L3RleHQ+CiAgICA8dGV4dCB4PSIxMjYiIHk9IjM4MSIgY2xhc3M9InNtYWxsIG11dGVkIj7tkZztmITsnbQg64us6528IEZBSUw8L3RleHQ+CiAgPC9nPgoKICA8cGF0aCBjbGFzcz0iYXJyb3ciIG1hcmtlci1lbmQ9InVybCgjYXJyb3doZWFkKSIgZD0iTTM4MiAzMzBINDM0Ii8+CgogIDwhLS0gQ2FyZCAyOiBTUEFSUUwgSnVkZ2UgLS0+CiAgPGcgY2xhc3M9InNoYWRvdyI+CiAgICA8cmVjdCB4PSI0MzQiIHk9IjE3MCIgd2lkdGg9IjMxMCIgaGVpZ2h0PSIzMjAiIHJ4PSIxOCIgY2xhc3M9ImNhcmQiLz4KICAgIDxjaXJjbGUgY3g9IjQ3MiIgY3k9IjIxMiIgcj0iMjQiIGNsYXNzPSJ0ZWFsIi8+CiAgICA8dGV4dCB4PSI0NzIiIHk9IjIxNyIgY2xhc3M9Im51bSB3aGl0ZSI+MjwvdGV4dD4KICAgIDx0ZXh0IHg9IjUxMiIgeT0iMjE2IiBjbGFzcz0ibGFiZWwgaW5rIj5TUEFSUUwgSnVkZ2U8L3RleHQ+CiAgICA8dGV4dCB4PSI1MTIiIHk9IjI0NCIgY2xhc3M9ImJvZHkgbXV0ZWQiPlNQQVJRTCDqtazsobDquYzsp4Ag7ZmV7J24PC90ZXh0PgogICAgPHBhdGggY2xhc3M9ImRpdmlkZXIiIGQ9Ik00NjQgMjYySDcxNCIvPgoKICAgIDxjaXJjbGUgY3g9IjQ3MiIgY3k9IjI5NiIgcj0iNSIgY2xhc3M9ImFtYmVyIi8+CiAgICA8dGV4dCB4PSI0ODgiIHk9IjMwMSIgY2xhc3M9InNtYWxsIGluayI+U1BBUlFMX0dBUOyduOuNsDwvdGV4dD4KICAgIDx0ZXh0IHg9IjQ4OCIgeT0iMzIzIiBjbGFzcz0ic21hbGwgaW5rIj5SdWJyaWPsnYAgUEFTUyDigJQ8L3RleHQ+CiAgICA8dGV4dCB4PSI0ODgiIHk9IjM0NSIgY2xhc3M9InNtYWxsIG11dGVkIj7slaDrp6TtlZwg6rK96rOEIOy8gOydtOyKpDwvdGV4dD4KICA8L2c+CgogIDxwYXRoIGNsYXNzPSJhcnJvdyIgbWFya2VyLWVuZD0idXJsKCNhcnJvd2hlYWQpIiBkPSJNNzQ0IDMzMEg3OTYiLz4KCiAgPCEtLSBDYXJkIDM6IEFsdCBTUEFSUUwgVmVyaWZ5IC0tPgogIDxnIGNsYXNzPSJzaGFkb3ciPgogICAgPHJlY3QgeD0iNzk2IiB5PSIxNzAiIHdpZHRoPSIzMTAiIGhlaWdodD0iMzIwIiByeD0iMTgiIGNsYXNzPSJjYXJkIi8+CiAgICA8Y2lyY2xlIGN4PSI4MzQiIGN5PSIyMTIiIHI9IjI0IiBjbGFzcz0iZ3JlZW4iLz4KICAgIDx0ZXh0IHg9IjgzNCIgeT0iMjE3IiBjbGFzcz0ibnVtIHdoaXRlIj4zPC90ZXh0PgogICAgPHRleHQgeD0iODc0IiB5PSIyMTYiIGNsYXNzPSJsYWJlbCBpbmsiPkFsdCBTUEFSUUwgVmVyaWZ5PC90ZXh0PgogICAgPHRleHQgeD0iODc0IiB5PSIyNDQiIGNsYXNzPSJib2R5IG11dGVkIj7qsr3qs4Qg7LyA7J207Iqk66eMIOyerOqygOymnTwvdGV4dD4KICAgIDxwYXRoIGNsYXNzPSJkaXZpZGVyIiBkPSJNODI2IDI2MkgxMDc2Ii8+CgogICAgPGNpcmNsZSBjeD0iODM0IiBjeT0iMjk2IiByPSI1IiBjbGFzcz0iZ3JlZW4iLz4KICAgIDx0ZXh0IHg9Ijg1MCIgeT0iMzAxIiBjbGFzcz0ic21hbGwgaW5rIj7snZjrr7jsg4Eg7Jyg7ZqoIOKGkjwvdGV4dD4KICAgIDx0ZXh0IHg9Ijg1MCIgeT0iMzIzIiBjbGFzcz0ic21hbGwgbXV0ZWQiPkFMVCDtm4Trs7TroZwg7ZmV7KCVPC90ZXh0PgoKICAgIDxjaXJjbGUgY3g9IjgzNCIgY3k9IjM1NCIgcj0iNSIgY2xhc3M9Im11dGVkIi8+CiAgICA8dGV4dCB4PSI4NTAiIHk9IjM1OSIgY2xhc3M9InNtYWxsIGluayI+7Jqw7Jew7Z6IIO2GteqzvCDihpI8L3RleHQ+CiAgICA8dGV4dCB4PSI4NTAiIHk9IjM4MSIgY2xhc3M9InNtYWxsIG11dGVkIj5GQUxTRV9QT1NJVElWRTwvdGV4dD4KICA8L2c+CgogIDxnIGNsYXNzPSJzaGFkb3ciPgogICAgPHJlY3QgeD0iMTkwIiB5PSI2MDYiIHdpZHRoPSI4MjAiIGhlaWdodD0iNTQiIHJ4PSIyNyIgY2xhc3M9ImNhcmQiLz4KICAgIDxjaXJjbGUgY3g9IjIyNCIgY3k9IjYzMyIgcj0iMTAiIGNsYXNzPSJjb3JhbCIvPgogICAgPHRleHQgeD0iMjUwIiB5PSI2NDAiIGNsYXNzPSJib2R5IGluayI+7KCV6rWQ7ZW0IOuztOydtOugpOuKlCDqsowg7JWE64uI6528LCDshJzroZwg64uk66W4IOyYpO2MkOydhCDsnqHslYTrgrTroKTripQg6rKD7J6F64uI64ukLjwvdGV4dD4KICA8L2c+Cjwvc3ZnPgo=\" width=\"1200\" height=\"720\" class=\"img_ev3q\"></p>\n<p>판정을 하나에서 셋으로 늘린 이유는 더 정교해 보이고 싶어서가 아니라, 각 단계가 서로 다른 종류의 오판을 잡아내지 못했기 때문입니다.</p>\n<h2 class=\"anchor anchorTargetStickyNavbar_Vzrq\" id=\"사람에게-남은-두-가지\">사람에게 남은 두 가지<a href=\"https://tech.umosone.ai/ontology-eval-loop#%EC%82%AC%EB%9E%8C%EC%97%90%EA%B2%8C-%EB%82%A8%EC%9D%80-%EB%91%90-%EA%B0%80%EC%A7%80\" class=\"hash-link\" aria-label=\"사람에게 남은 두 가지에 대한 직접 링크\" title=\"사람에게 남은 두 가지에 대한 직접 링크\" translate=\"no\">​</a></h2>\n<p>이 루프에서 사람이 손을 뗀 건 아닙니다. 다만 손을 대는 지점이 바뀌었습니다.</p>\n<p><strong>첫째, 정답 목록을 정하는 일입니다.</strong> 방금 그 질문이라면 \"정비 예정\"이라는 데이터를 부르는 이름이 여러 개일 수 있는데(<code>ScheduledMaintenance</code> 또는 <code>MaintenancePlan</code>), 둘 중 뭘 정답으로 쓸지는 사람이 먼저 정해둬야 합니다. 이 기준이 없으면 SPARQL Judge는 애초에 무엇과 비교해야 할지 모릅니다. AI가 판정하는 게 아니라, 사람이 세운 기준 안에서 AI가 판정을 대신 실행하는 구조에 가깝습니다.</p>\n<p><strong>둘째, 경계 케이스를 표본으로 확인하는 일입니다.</strong> 방금 <code>MaintenancePlan</code>으로 조회해 SPARQL_GAP이 떴던 사례처럼, \"의미적으로는 맞을 수도 있다\"고 LLM이 판단한 ALT 후보는 사람이 주기적으로 표본을 확인합니다. 판정 체인을 무조건 믿지 않기 위해서입니다.</p>\n<h2 class=\"anchor anchorTargetStickyNavbar_Vzrq\" id=\"반복할수록-좋아지는-구조\">반복할수록 좋아지는 구조<a href=\"https://tech.umosone.ai/ontology-eval-loop#%EB%B0%98%EB%B3%B5%ED%95%A0%EC%88%98%EB%A1%9D-%EC%A2%8B%EC%95%84%EC%A7%80%EB%8A%94-%EA%B5%AC%EC%A1%B0\" class=\"hash-link\" aria-label=\"반복할수록 좋아지는 구조에 대한 직접 링크\" title=\"반복할수록 좋아지는 구조에 대한 직접 링크\" translate=\"no\">​</a></h2>\n<p>이 루프는 한 번 돌리고 끝나지 않습니다. NO_DATA로 분류된 케이스가 데이터 적재 백로그가 되고, 백로그가 처리되면 같은 TC를 다시 돌렸을 때 PASS로 바뀝니다. <code>\"이번 달 정비 예정인 트럭이 몇 대야?\"</code>도 정비 일정 테이블이 적재되고 나면 다음 실행에서 PASS로 바뀝니다. SPARQL_GAP이 ALT로 확인되면 <code>expected_classes</code>에 <code>MaintenancePlan</code>이 추가되고, 다음부터는 이 표현으로 조회해도 바로 정답으로 인정됩니다.</p>\n<p><img decoding=\"async\" loading=\"lazy\" alt=\"이 루프는 반복될수록 정확해집니다\" src=\"data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxMjAwIiBoZWlnaHQ9IjcyMCIgdmlld0JveD0iMCAwIDEyMDAgNzIwIiByb2xlPSJpbWciIGFyaWEtbGFiZWxsZWRieT0idGl0bGUgZGVzYyI+CiAgPHRpdGxlIGlkPSJ0aXRsZSI+7J20IOujqO2UhOuKlCDrsJjrs7XrkKDsiJjroZ0g7KCV7ZmV7ZW07KeR64uI64ukPC90aXRsZT4KICA8ZGVzYyBpZD0iZGVzYyI+VEMg7IOd7ISxLCDtj4nqsIAsIOynhOuLqCwg6rCc7ISg7JWIIOuPhOy2nCwg642w7J207YSwwrdUQyDrsJjsmIHsnZgg64uk7ISvIOuLqOqzhOqwgCDsiJzshJzrjIDroZwg7KeE7ZaJ65CY6rOgLCDrsJjsmIHrkJwg6rKw6rO86rCAIOuLpOyLnCDri6TsnYwg65287Jq065Oc7J2YIO2PieqwgOyXkCDqt7jrjIDroZwg7J207Ja07KeA64qUIOyInO2ZmCDqtazsobDrpbwg64KY7YOA64K4IOq3uOumvDwvZGVzYz4KICA8ZGVmcz4KICAgIDxzdHlsZT4KICAgICAgLmJnIHsgZmlsbDogI2Y1ZjhmYzsgfQogICAgICAuY2FyZCB7IGZpbGw6ICNmZmZmZmY7IHN0cm9rZTogI2Q3ZGVlYTsgc3Ryb2tlLXdpZHRoOiAyOyB9CiAgICAgIC5pbmsgeyBmaWxsOiAjMTIxNzIyOyB9CiAgICAgIC5tdXRlZCB7IGZpbGw6ICM2ODczODY7IH0KICAgICAgLndoaXRlIHsgZmlsbDogI2ZmZmZmZjsgfQogICAgICAudmlvbGV0IHsgZmlsbDogIzYyNTVjODsgfQogICAgICAudGVhbCB7IGZpbGw6ICMxMjhmODI7IH0KICAgICAgLmFtYmVyIHsgZmlsbDogI2I0N2ExNjsgfQogICAgICAuY29yYWwgeyBmaWxsOiAjZDk1NjQ4OyB9CiAgICAgIC5ncmVlbiB7IGZpbGw6ICMyODdmNTI7IH0KICAgICAgLnRpdGxlIHsgZm9udDogODAwIDQycHggdWktc2Fucy1zZXJpZiwgc3lzdGVtLXVpLCAtYXBwbGUtc3lzdGVtLCBCbGlua01hY1N5c3RlbUZvbnQsICJTZWdvZSBVSSIsIHNhbnMtc2VyaWY7IH0KICAgICAgLnN1YnRpdGxlIHsgZm9udDogNTAwIDIycHggdWktc2Fucy1zZXJpZiwgc3lzdGVtLXVpLCAtYXBwbGUtc3lzdGVtLCBCbGlua01hY1N5c3RlbUZvbnQsICJTZWdvZSBVSSIsIHNhbnMtc2VyaWY7IH0KICAgICAgLmxhYmVsIHsgZm9udDogODAwIDIxcHggdWktc2Fucy1zZXJpZiwgc3lzdGVtLXVpLCAtYXBwbGUtc3lzdGVtLCBCbGlua01hY1N5c3RlbUZvbnQsICJTZWdvZSBVSSIsIHNhbnMtc2VyaWY7IHRleHQtYW5jaG9yOiBtaWRkbGU7IH0KICAgICAgLnNtYWxsIHsgZm9udDogNzAwIDE1cHggdWktc2Fucy1zZXJpZiwgc3lzdGVtLXVpLCAtYXBwbGUtc3lzdGVtLCBCbGlua01hY1N5c3RlbUZvbnQsICJTZWdvZSBVSSIsIHNhbnMtc2VyaWY7IHRleHQtYW5jaG9yOiBtaWRkbGU7IH0KICAgICAgLmJvZHkgeyBmb250OiA2MDAgMThweCB1aS1zYW5zLXNlcmlmLCBzeXN0ZW0tdWksIC1hcHBsZS1zeXN0ZW0sIEJsaW5rTWFjU3lzdGVtRm9udCwgIlNlZ29lIFVJIiwgc2Fucy1zZXJpZjsgfQogICAgICAubnVtIHsgZm9udDogODAwIDE4cHggdWktc2Fucy1zZXJpZiwgc3lzdGVtLXVpLCAtYXBwbGUtc3lzdGVtLCBCbGlua01hY1N5c3RlbUZvbnQsICJTZWdvZSBVSSIsIHNhbnMtc2VyaWY7IHRleHQtYW5jaG9yOiBtaWRkbGU7IH0KICAgICAgLmFycm93IHsgZmlsbDogbm9uZTsgc3Ryb2tlOiAjN2I4Nzk3OyBzdHJva2Utd2lkdGg6IDQ7IHN0cm9rZS1saW5lY2FwOiByb3VuZDsgc3Ryb2tlLWxpbmVqb2luOiByb3VuZDsgfQogICAgICAuc2hhZG93IHsgZmlsdGVyOiBkcm9wLXNoYWRvdygwIDE4cHggMzBweCByZ2IoMzEgNDUgNjYgLyAwLjEyKSk7IH0KICAgIDwvc3R5bGU+CiAgICA8bWFya2VyIGlkPSJhcnJvd2hlYWQyIiBtYXJrZXJXaWR0aD0iMTIiIG1hcmtlckhlaWdodD0iMTIiIHJlZlg9IjkiIHJlZlk9IjYiIG9yaWVudD0iYXV0byI+CiAgICAgIDxwYXRoIGQ9Ik0yLDIgTDEwLDYgTDIsMTAgWiIgZmlsbD0iIzdiODc5NyIvPgogICAgPC9tYXJrZXI+CiAgPC9kZWZzPgoKICA8cmVjdCBjbGFzcz0iYmciIHdpZHRoPSIxMjAwIiBoZWlnaHQ9IjcyMCIvPgogIDx0ZXh0IHg9IjcyIiB5PSI3OCIgY2xhc3M9InRpdGxlIGluayI+7J20IOujqO2UhOuKlCDrsJjrs7XrkKDsiJjroZ0g7KCV7ZmV7ZW07KeR64uI64ukPC90ZXh0PgogIDx0ZXh0IHg9IjcyIiB5PSIxMTgiIGNsYXNzPSJzdWJ0aXRsZSBtdXRlZCI+VEMg7IOd7ISx67aA7YSwIOuNsOydtO2EsCDrsJjsmIHquYzsp4AsIOqysOqzvOqwgCDri6TsnYwg65287Jq065Oc7JeQIOq3uOuMgOuhnCDsnbTslrTsp5Hri4jri6QuPC90ZXh0PgoKICA8IS0tIENhcmQgMTogVEMg7IOd7ISxIC0tPgogIDxnIGNsYXNzPSJzaGFkb3ciPgogICAgPHJlY3QgeD0iNjQiIHk9IjIyMCIgd2lkdGg9IjE4MCIgaGVpZ2h0PSIxNTAiIHJ4PSIxNiIgY2xhc3M9ImNhcmQiLz4KICAgIDxjaXJjbGUgY3g9IjE1NCIgY3k9IjI1OCIgcj0iMjIiIGNsYXNzPSJ2aW9sZXQiLz4KICAgIDx0ZXh0IHg9IjE1NCIgeT0iMjY0IiBjbGFzcz0ibnVtIHdoaXRlIj4xPC90ZXh0PgogICAgPHRleHQgeD0iMTU0IiB5PSIzMTIiIGNsYXNzPSJsYWJlbCBpbmsiPlRDIOyDneyEsTwvdGV4dD4KICAgIDx0ZXh0IHg9IjE1NCIgeT0iMzM4IiBjbGFzcz0ic21hbGwgbXV0ZWQiPuyniOusuMK37KCV64u1IOuqqeuhnTwvdGV4dD4KICA8L2c+CiAgPHBhdGggY2xhc3M9ImFycm93IiBtYXJrZXItZW5kPSJ1cmwoI2Fycm93aGVhZDIpIiBkPSJNMjQ0IDI5NUgyODciLz4KCiAgPCEtLSBDYXJkIDI6IO2PieqwgCAtLT4KICA8ZyBjbGFzcz0ic2hhZG93Ij4KICAgIDxyZWN0IHg9IjI4NyIgeT0iMjIwIiB3aWR0aD0iMTgwIiBoZWlnaHQ9IjE1MCIgcng9IjE2IiBjbGFzcz0iY2FyZCIvPgogICAgPGNpcmNsZSBjeD0iMzc3IiBjeT0iMjU4IiByPSIyMiIgY2xhc3M9InRlYWwiLz4KICAgIDx0ZXh0IHg9IjM3NyIgeT0iMjY0IiBjbGFzcz0ibnVtIHdoaXRlIj4yPC90ZXh0PgogICAgPHRleHQgeD0iMzc3IiB5PSIzMTIiIGNsYXNzPSJsYWJlbCBpbmsiPu2PieqwgDwvdGV4dD4KICAgIDx0ZXh0IHg9IjM3NyIgeT0iMzM4IiBjbGFzcz0ic21hbGwgbXV0ZWQiPlJ1YnJpYyBKdWRnZTwvdGV4dD4KICA8L2c+CiAgPHBhdGggY2xhc3M9ImFycm93IiBtYXJrZXItZW5kPSJ1cmwoI2Fycm93aGVhZDIpIiBkPSJNNDY3IDI5NUg1MTAiLz4KCiAgPCEtLSBDYXJkIDM6IOynhOuLqCAtLT4KICA8ZyBjbGFzcz0ic2hhZG93Ij4KICAgIDxyZWN0IHg9IjUxMCIgeT0iMjIwIiB3aWR0aD0iMTgwIiBoZWlnaHQ9IjE1MCIgcng9IjE2IiBjbGFzcz0iY2FyZCIvPgogICAgPGNpcmNsZSBjeD0iNjAwIiBjeT0iMjU4IiByPSIyMiIgY2xhc3M9ImFtYmVyIi8+CiAgICA8dGV4dCB4PSI2MDAiIHk9IjI2NCIgY2xhc3M9Im51bSB3aGl0ZSI+MzwvdGV4dD4KICAgIDx0ZXh0IHg9IjYwMCIgeT0iMzEyIiBjbGFzcz0ibGFiZWwgaW5rIj7sp4Tri6g8L3RleHQ+CiAgICA8dGV4dCB4PSI2MDAiIHk9IjMzOCIgY2xhc3M9InNtYWxsIG11dGVkIj7sm5Dsnbgg67aE66WYPC90ZXh0PgogIDwvZz4KICA8cGF0aCBjbGFzcz0iYXJyb3ciIG1hcmtlci1lbmQ9InVybCgjYXJyb3doZWFkMikiIGQ9Ik02OTAgMjk1SDczMyIvPgoKICA8IS0tIENhcmQgNDog6rCc7ISg7JWIIC0tPgogIDxnIGNsYXNzPSJzaGFkb3ciPgogICAgPHJlY3QgeD0iNzMzIiB5PSIyMjAiIHdpZHRoPSIxODAiIGhlaWdodD0iMTUwIiByeD0iMTYiIGNsYXNzPSJjYXJkIi8+CiAgICA8Y2lyY2xlIGN4PSI4MjMiIGN5PSIyNTgiIHI9IjIyIiBjbGFzcz0iY29yYWwiLz4KICAgIDx0ZXh0IHg9IjgyMyIgeT0iMjY0IiBjbGFzcz0ibnVtIHdoaXRlIj40PC90ZXh0PgogICAgPHRleHQgeD0iODIzIiB5PSIzMTIiIGNsYXNzPSJsYWJlbCBpbmsiPuqwnOyEoOyViDwvdGV4dD4KICAgIDx0ZXh0IHg9IjgyMyIgeT0iMzM4IiBjbGFzcz0ic21hbGwgbXV0ZWQiPuuwseuhnOq3uMK36rCx7IugPC90ZXh0PgogIDwvZz4KICA8cGF0aCBjbGFzcz0iYXJyb3ciIG1hcmtlci1lbmQ9InVybCgjYXJyb3doZWFkMikiIGQ9Ik05MTMgMjk1SDk1NiIvPgoKICA8IS0tIENhcmQgNTog67CY7JiBIC0tPgogIDxnIGNsYXNzPSJzaGFkb3ciPgogICAgPHJlY3QgeD0iOTU2IiB5PSIyMjAiIHdpZHRoPSIxODAiIGhlaWdodD0iMTUwIiByeD0iMTYiIGNsYXNzPSJjYXJkIi8+CiAgICA8Y2lyY2xlIGN4PSIxMDQ2IiBjeT0iMjU4IiByPSIyMiIgY2xhc3M9ImdyZWVuIi8+CiAgICA8dGV4dCB4PSIxMDQ2IiB5PSIyNjQiIGNsYXNzPSJudW0gd2hpdGUiPjU8L3RleHQ+CiAgICA8dGV4dCB4PSIxMDQ2IiB5PSIzMTIiIGNsYXNzPSJsYWJlbCBpbmsiPuuwmOyYgTwvdGV4dD4KICAgIDx0ZXh0IHg9IjEwNDYiIHk9IjMzOCIgY2xhc3M9InNtYWxsIG11dGVkIj7rjbDsnbTthLAg7KCB7J6sPC90ZXh0PgogIDwvZz4KCiAgPCEtLSBSZXR1cm4gbG9vcDogY2FyZDUgYm90dG9tIGJhY2sgdG8gY2FyZDEgYm90dG9tIC0tPgogIDxwYXRoIGNsYXNzPSJhcnJvdyIgbWFya2VyLWVuZD0idXJsKCNhcnJvd2hlYWQyKSIgZD0iTTEwNDYgMzcwIFY1NjAgSDE1NCBWMzcwIi8+CgogIDxnIGNsYXNzPSJzaGFkb3ciPgogICAgPHJlY3QgeD0iNDEwIiB5PSI1NDAiIHdpZHRoPSIzODAiIGhlaWdodD0iNDAiIHJ4PSIyMCIgY2xhc3M9ImNhcmQiLz4KICAgIDxjaXJjbGUgY3g9IjQzOCIgY3k9IjU2MCIgcj0iOCIgY2xhc3M9InRlYWwiLz4KICAgIDx0ZXh0IHg9IjQ2MCIgeT0iNTY2IiBjbGFzcz0iYm9keSBpbmsiPuuLpOydjCDrnbzsmrTrk5wg7Y+J6rCA7JeQIOq3uOuMgOuhnCDrsJjsmIHrkKnri4jri6Q8L3RleHQ+CiAgPC9nPgo8L3N2Zz4K\" width=\"1200\" height=\"720\" class=\"img_ev3q\"></p>\n<p>즉 평가를 반복할수록 온톨로지 커버리지와 데이터 적재 상태가 조금씩 채워지고, 그 결과가 다시 다음 평가에 반영되는 구조입니다. 사람이 매번 원인을 추적하고 우선순위를 매기던 반복 작업이 사라지면서, 이 사이클을 훨씬 자주 돌릴 수 있게 됐습니다. 사람이 매번 손대지 않아도 데이터 품질이 스스로 자라는 구조는, 판정을 AI 루프에 맡기고 나서야 가능해졌습니다.</p>\n<h2 class=\"anchor anchorTargetStickyNavbar_Vzrq\" id=\"이-루프는-질문이-만들어지는-순간부터-시작됩니다\">이 루프는 질문이 만들어지는 순간부터 시작됩니다<a href=\"https://tech.umosone.ai/ontology-eval-loop#%EC%9D%B4-%EB%A3%A8%ED%94%84%EB%8A%94-%EC%A7%88%EB%AC%B8%EC%9D%B4-%EB%A7%8C%EB%93%A4%EC%96%B4%EC%A7%80%EB%8A%94-%EC%88%9C%EA%B0%84%EB%B6%80%ED%84%B0-%EC%8B%9C%EC%9E%91%EB%90%A9%EB%8B%88%EB%8B%A4\" class=\"hash-link\" aria-label=\"이 루프는 질문이 만들어지는 순간부터 시작됩니다에 대한 직접 링크\" title=\"이 루프는 질문이 만들어지는 순간부터 시작됩니다에 대한 직접 링크\" translate=\"no\">​</a></h2>\n<p>지금까지 설명한 평가·진단·개선 루프는 TC가 이미 있다는 가정에서 시작했습니다. 그런데 TC 자체도 같은 방식으로 만들어집니다.</p>\n<p><code>\"이번 달 정비 예정인 트럭이 몇 대야?\"</code>라는 질문도 그냥 떠오른 게 아닙니다. 만들 때부터 정해진 순서를 따릅니다: <strong>Stage 결정 → ID 발급 → 질문·채점 기준 작성</strong>.</p>\n<p>먼저 이 질문은 \"왜 그랬는지\"를 묻는 게 아니라 \"현재 상태\"를 묻고 있으므로 Descriptive로 분류됩니다. 두 개 이상의 데이터를 연계해야만 답할 수 있는 질문이었다면 Diagnostic으로 분류가 달라졌을 겁니다.</p>\n<p>그다음 질문 문장과 채점 기준을 씁니다. 여기에도 지켜야 하는 규칙이 있습니다.</p>\n<ul>\n<li class=\"\">질문에는 <code>ScheduledMaintenance</code> 같은 클래스명을 노출하지 않습니다. 기준은 \"실제 사용자가 챗봇에 물을 법한 표현인가\"입니다.</li>\n<li class=\"\">rubric에는 \"트럭 3대\"처럼 구체적 수치를 고정하지 않습니다. 데이터 기간이 바뀌면 rubric 자체가 틀려지기 때문입니다. 대신 \"정비 예정 대수(정수)\"처럼 씁니다.</li>\n<li class=\"\"><code>expected_classes</code>는 \"이 질문에 답하는 최소 SPARQL\"을 머릿속으로 스케치해서, 실제로 등장하는 클래스만 넣습니다. 그리고 데이터가 저장된 그래프 데이터베이스인 Fuseki에 실제로 그 데이터가 몇 건 있는지 확인하고서야 TC로 확정합니다.</li>\n</ul>\n<p>질문을 쓰는 일도 판정을 설계하는 일과 똑같습니다. 사람은 \"무엇을 기준으로 삼을 것인가\"만 정하고, 그 기준을 지키는 역할을 합니다. 그리고 이 기준은 고정되어 있지 않습니다. 앞서 나온 SPARQL_GAP과 ALT 케이스가 확인되면 그 TC의 <code>expected_classes</code>가 갱신됩니다. TC를 만드는 일과 TC를 검증하는 일이 서로의 결과를 되먹임하는 구조입니다.</p>\n<h2 class=\"anchor anchorTargetStickyNavbar_Vzrq\" id=\"이-팀이-데이터로-풀고-있는-문제들\">이 팀이 데이터로 풀고 있는 문제들<a href=\"https://tech.umosone.ai/ontology-eval-loop#%EC%9D%B4-%ED%8C%80%EC%9D%B4-%EB%8D%B0%EC%9D%B4%ED%84%B0%EB%A1%9C-%ED%92%80%EA%B3%A0-%EC%9E%88%EB%8A%94-%EB%AC%B8%EC%A0%9C%EB%93%A4\" class=\"hash-link\" aria-label=\"이 팀이 데이터로 풀고 있는 문제들에 대한 직접 링크\" title=\"이 팀이 데이터로 풀고 있는 문제들에 대한 직접 링크\" translate=\"no\">​</a></h2>\n<p>이 글에서 다룬 평가 루프는 하나의 사례일 뿐입니다. 데이터 관점에서 마주치는 문제는 이보다 넓습니다.</p>\n<p><strong>AI 에이전트를 어떻게 평가할 것인가.</strong> 이 글에서 다룬 판정 체인이 커버하는 범위는 전체 지능 단계 중 일부입니다. 저희는 챗봇의 지능을 Reactive(Descriptive·Diagnostic) → Proactive(Projective·Predictive·Prescriptive) → Adaptive 순으로 나눠 보고 있는데, 지금 이 루프는 Reactive 단계, 즉 현재 상태를 조회하고 원인을 역추적하는 수준까지만 다룹니다. \"이대로 가면 언제 부족해지는가\"를 계산하는 Projective, \"이 차량이 고장 날 확률은?\"을 추론하는 Predictive, \"그럼 어떻게 해야 하는가\"까지 권고하는 Prescriptive로 올라가려면 지금 같은 rubric·SPARQL 매칭만으로는 판정할 수 없습니다. 답이 맞았는지를 넘어 추론 과정 자체가 타당한지를 평가하는 새로운 판정 체계가 필요합니다.</p>\n<p><img decoding=\"async\" loading=\"lazy\" alt=\"지금 우리는 사다리 몇 단에 있는가\" src=\"data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxMjAwIiBoZWlnaHQ9IjcyMCIgdmlld0JveD0iMCAwIDEyMDAgNzIwIiByb2xlPSJpbWciIGFyaWEtbGFiZWxsZWRieT0idGl0bGUgZGVzYyI+CiAgPHRpdGxlIGlkPSJ0aXRsZSI+7KeA6riIIOyasOumrOuKlCDsgqzri6Trpqwg66qHIOuLqOyXkCDsnojripTqsIA8L3RpdGxlPgogIDxkZXNjIGlkPSJkZXNjIj7ssZfrtIfsnZgg7KeA64ql7J2EIFJlYWN0aXZlKERlc2NyaXB0aXZlwrdEaWFnbm9zdGljKeyXkOyEnCBQcm9hY3RpdmUoUHJvamVjdGl2ZcK3UHJlZGljdGl2ZcK3UHJlc2NyaXB0aXZlKeulvCDqsbDss5AgQWRhcHRpdmXquYzsp4AgNuuLqOqzhOuhnCDrgpjriIgg7IKs64uk66asIOq3uOumvC4g7ZiE7J6s64qUIFJlYWN0aXZlIOuLqOqzhOq5jOyngOunjCDri6Tsp4Dqs6Ag7J6I6rOgIFByb2FjdGl2ZcK3QWRhcHRpdmXripQg7JWe7Jy866GcIO2SgOyWtOyVvCDtlaAg66y47KCc66GcIO2RnOyLnO2WiOuLpC48L2Rlc2M+CiAgPGRlZnM+CiAgICA8c3R5bGU+CiAgICAgIC5iZyB7IGZpbGw6ICNmNWY4ZmM7IH0KICAgICAgLmNhcmQgeyBmaWxsOiAjZmZmZmZmOyBzdHJva2U6ICNkN2RlZWE7IHN0cm9rZS13aWR0aDogMjsgfQogICAgICAuaW5rIHsgZmlsbDogIzEyMTcyMjsgfQogICAgICAubXV0ZWQgeyBmaWxsOiAjNjg3Mzg2OyB9CiAgICAgIC50ZWFsIHsgZmlsbDogIzEyOGY4MjsgfQogICAgICAuYW1iZXIgeyBmaWxsOiAjYjQ3YTE2OyB9CiAgICAgIC50aW50LXRlYWwgeyBmaWxsOiAjZDVmOGYxOyBzdHJva2U6ICMxMjhmODI7IHN0cm9rZS13aWR0aDogMjsgfQogICAgICAudGludC1hbWJlciB7IGZpbGw6ICNmN2U5Y2Y7IHN0cm9rZTogI2I0N2ExNjsgc3Ryb2tlLXdpZHRoOiAyOyB9CiAgICAgIC50aW50LW11dGVkIHsgZmlsbDogI2VlZjFmNTsgc3Ryb2tlOiAjOWFhNWI1OyBzdHJva2Utd2lkdGg6IDI7IH0KICAgICAgLmJhci1ub3cgeyBmaWxsOiAjMTI4ZjgyOyB9CiAgICAgIC5iYXItbmV4dCB7IGZpbGw6ICNmN2U5Y2Y7IHN0cm9rZTogI2I0N2ExNjsgc3Ryb2tlLXdpZHRoOiAyOyB9CiAgICAgIC5iYXItYWRhcHRpdmUgeyBmaWxsOiAjZWVmMWY1OyBzdHJva2U6ICM5YWE1YjU7IHN0cm9rZS13aWR0aDogMjsgc3Ryb2tlLWRhc2hhcnJheTogNiA2OyB9CiAgICAgIC5ncm91bmQgeyBzdHJva2U6ICNjN2QwZGQ7IHN0cm9rZS13aWR0aDogMjsgfQogICAgICAudGl0bGUgeyBmb250OiA4MDAgNDJweCB1aS1zYW5zLXNlcmlmLCBzeXN0ZW0tdWksIC1hcHBsZS1zeXN0ZW0sIEJsaW5rTWFjU3lzdGVtRm9udCwgIlNlZ29lIFVJIiwgc2Fucy1zZXJpZjsgfQogICAgICAuc3VidGl0bGUgeyBmb250OiA1MDAgMjJweCB1aS1zYW5zLXNlcmlmLCBzeXN0ZW0tdWksIC1hcHBsZS1zeXN0ZW0sIEJsaW5rTWFjU3lzdGVtRm9udCwgIlNlZ29lIFVJIiwgc2Fucy1zZXJpZjsgfQogICAgICAucGlsbCB7IGZvbnQ6IDgwMCAxNnB4IHVpLXNhbnMtc2VyaWYsIHN5c3RlbS11aSwgLWFwcGxlLXN5c3RlbSwgQmxpbmtNYWNTeXN0ZW1Gb250LCAiU2Vnb2UgVUkiLCBzYW5zLXNlcmlmOyB0ZXh0LWFuY2hvcjogbWlkZGxlOyB9CiAgICAgIC5zdGFnZSB7IGZvbnQ6IDgwMCAxN3B4IHVpLXNhbnMtc2VyaWYsIHN5c3RlbS11aSwgLWFwcGxlLXN5c3RlbSwgQmxpbmtNYWNTeXN0ZW1Gb250LCAiU2Vnb2UgVUkiLCBzYW5zLXNlcmlmOyB0ZXh0LWFuY2hvcjogbWlkZGxlOyB9CiAgICAgIC5nbG9zcyB7IGZvbnQ6IDY1MCAxNXB4IHVpLXNhbnMtc2VyaWYsIHN5c3RlbS11aSwgLWFwcGxlLXN5c3RlbSwgQmxpbmtNYWNTeXN0ZW1Gb250LCAiU2Vnb2UgVUkiLCBzYW5zLXNlcmlmOyB0ZXh0LWFuY2hvcjogbWlkZGxlOyB9CiAgICAgIC5leCB7IGZvbnQ6IDYwMCAxNXB4IHVpLXNhbnMtc2VyaWYsIHN5c3RlbS11aSwgLWFwcGxlLXN5c3RlbSwgQmxpbmtNYWNTeXN0ZW1Gb250LCAiU2Vnb2UgVUkiLCBzYW5zLXNlcmlmOyB0ZXh0LWFuY2hvcjogbWlkZGxlOyB9CiAgICAgIC5ib2R5IHsgZm9udDogNjAwIDE4cHggdWktc2Fucy1zZXJpZiwgc3lzdGVtLXVpLCAtYXBwbGUtc3lzdGVtLCBCbGlua01hY1N5c3RlbUZvbnQsICJTZWdvZSBVSSIsIHNhbnMtc2VyaWY7IH0KICAgICAgLnNoYWRvdyB7IGZpbHRlcjogZHJvcC1zaGFkb3coMCAxNHB4IDI0cHggcmdiKDMxIDQ1IDY2IC8gMC4xMCkpOyB9CiAgICA8L3N0eWxlPgogIDwvZGVmcz4KCiAgPHJlY3QgY2xhc3M9ImJnIiB3aWR0aD0iMTIwMCIgaGVpZ2h0PSI3MjAiLz4KICA8dGV4dCB4PSI3MiIgeT0iNzgiIGNsYXNzPSJ0aXRsZSBpbmsiPuyngOq4iCDsmrDrpqzripQg7IKs64uk66asIOuqhyDri6jsl5Ag7J6I64qU6rCAPC90ZXh0PgogIDx0ZXh0IHg9IjcyIiB5PSIxMTgiIGNsYXNzPSJzdWJ0aXRsZSBtdXRlZCI+UmVhY3RpdmUg64uo6rOE6rmM7KeAIOuLpOyhjOqzoCwgUHJvYWN0aXZlwrdBZGFwdGl2ZeuKlCDri6TsnYwg66y47KCc66GcIOuCqOyVhCDsnojsirXri4jri6QuPC90ZXh0PgoKICA8IS0tIG1hY3JvIGdyb3VwIHBpbGxzIC0tPgogIDxyZWN0IHg9IjcwIiB5PSIxMzMiIHdpZHRoPSIzNDAiIGhlaWdodD0iMzIiIHJ4PSIxNiIgY2xhc3M9InRpbnQtdGVhbCIvPgogIDx0ZXh0IHg9IjI0MCIgeT0iMTU1IiBjbGFzcz0icGlsbCIgZmlsbD0iIzBkNmI2MSI+UmVhY3RpdmUg4oCUIOyngOq4iCDsl6zquLA8L3RleHQ+CgogIDxyZWN0IHg9IjQzMCIgeT0iMTMzIiB3aWR0aD0iNTIwIiBoZWlnaHQ9IjMyIiByeD0iMTYiIGNsYXNzPSJ0aW50LWFtYmVyIi8+CiAgPHRleHQgeD0iNjkwIiB5PSIxNTUiIGNsYXNzPSJwaWxsIiBmaWxsPSIjOGE1YzExIj5Qcm9hY3RpdmUg4oCUIOuLpOydjCDrrLjsoJw8L3RleHQ+CgogIDxyZWN0IHg9Ijk3MCIgeT0iMTMzIiB3aWR0aD0iMTYwIiBoZWlnaHQ9IjMyIiByeD0iMTYiIGNsYXNzPSJ0aW50LW11dGVkIi8+CiAgPHRleHQgeD0iMTA1MCIgeT0iMTU1IiBjbGFzcz0icGlsbCBtdXRlZCI+QWRhcHRpdmUgwrcg67KU7JyEIOuwljwvdGV4dD4KCiAgPCEtLSBHcm91bmQgbGluZSAtLT4KICA8cGF0aCBjbGFzcz0iZ3JvdW5kIiBkPSJNNzAgNTYwSDExMzAiLz4KCiAgPCEtLSBTdGVwIDE6IERlc2NyaXB0aXZlIC0tPgogIDx0ZXh0IHg9IjE1MCIgeT0iNDM0IiBjbGFzcz0ic3RhZ2UiIGZpbGw9IiMxMjhmODIiPkRlc2NyaXB0aXZlPC90ZXh0PgogIDx0ZXh0IHg9IjE1MCIgeT0iNDU2IiBjbGFzcz0iZ2xvc3MgbXV0ZWQiPu2YhOyerCDsg4Htg5wg7KGw7ZqMPC90ZXh0PgogIDxnIGNsYXNzPSJzaGFkb3ciPjxyZWN0IHg9IjcwIiB5PSI0NzAiIHdpZHRoPSIxNjAiIGhlaWdodD0iOTAiIHJ4PSIxMCIgY2xhc3M9ImJhci1ub3ciLz48L2c+CiAgPHRleHQgeD0iMTUwIiB5PSI1ODAiIGNsYXNzPSJleCBpbmsiPu2YhOyerCDrk7HroZ3rkJw8L3RleHQ+CiAgPHRleHQgeD0iMTUwIiB5PSI2MDAiIGNsYXNzPSJleCBtdXRlZCI+7LCo65+JIOyImOuKlD88L3RleHQ+CgogIDwhLS0gU3RlcCAyOiBEaWFnbm9zdGljIC0tPgogIDx0ZXh0IHg9IjMzMCIgeT0iMzg0IiBjbGFzcz0ic3RhZ2UiIGZpbGw9IiMxMjhmODIiPkRpYWdub3N0aWM8L3RleHQ+CiAgPHRleHQgeD0iMzMwIiB5PSI0MDYiIGNsYXNzPSJnbG9zcyBtdXRlZCI+7JuQ7J24IOyXrey2lOyggTwvdGV4dD4KICA8ZyBjbGFzcz0ic2hhZG93Ij48cmVjdCB4PSIyNTAiIHk9IjQyMCIgd2lkdGg9IjE2MCIgaGVpZ2h0PSIxNDAiIHJ4PSIxMCIgY2xhc3M9ImJhci1ub3ciLz48L2c+CiAgPHRleHQgeD0iMzMwIiB5PSI1ODAiIGNsYXNzPSJleCBpbmsiPuyZnCDsnbQg7LCo65+J66eMPC90ZXh0PgogIDx0ZXh0IHg9IjMzMCIgeT0iNjAwIiBjbGFzcz0iZXggbXV0ZWQiPuydtOuypO2KuOqwgCDrp47sp4A/PC90ZXh0PgoKICA8IS0tIFN0ZXAgMzogUHJvamVjdGl2ZSAtLT4KICA8dGV4dCB4PSI1MTAiIHk9IjMzNCIgY2xhc3M9InN0YWdlIiBmaWxsPSIjYjQ3YTE2Ij5Qcm9qZWN0aXZlPC90ZXh0PgogIDx0ZXh0IHg9IjUxMCIgeT0iMzU2IiBjbGFzcz0iZ2xvc3MgbXV0ZWQiPuy2lOyEuOuhnCDrr7jrnpgg6rOE7IKwPC90ZXh0PgogIDxnIGNsYXNzPSJzaGFkb3ciPjxyZWN0IHg9IjQzMCIgeT0iMzcwIiB3aWR0aD0iMTYwIiBoZWlnaHQ9IjE5MCIgcng9IjEwIiBjbGFzcz0iYmFyLW5leHQiLz48L2c+CiAgPHRleHQgeD0iNTEwIiB5PSI1ODAiIGNsYXNzPSJleCBpbmsiPuydtOuMgOuhnCDqsIDrqbQ8L3RleHQ+CiAgPHRleHQgeD0iNTEwIiB5PSI2MDAiIGNsYXNzPSJleCBtdXRlZCI+7Ja47KCcIOu2gOyhse2VtOyguD88L3RleHQ+CgogIDwhLS0gU3RlcCA0OiBQcmVkaWN0aXZlIC0tPgogIDx0ZXh0IHg9IjY5MCIgeT0iMjg0IiBjbGFzcz0ic3RhZ2UiIGZpbGw9IiNiNDdhMTYiPlByZWRpY3RpdmU8L3RleHQ+CiAgPHRleHQgeD0iNjkwIiB5PSIzMDYiIGNsYXNzPSJnbG9zcyBtdXRlZCI+66+4656YIOumrOyKpO2BrCDstpTroaA8L3RleHQ+CiAgPGcgY2xhc3M9InNoYWRvdyI+PHJlY3QgeD0iNjEwIiB5PSIzMjAiIHdpZHRoPSIxNjAiIGhlaWdodD0iMjQwIiByeD0iMTAiIGNsYXNzPSJiYXItbmV4dCIvPjwvZz4KICA8dGV4dCB4PSI2OTAiIHk9IjU4MCIgY2xhc3M9ImV4IGluayI+6rOg7J6lIOuCoCDtmZXrpaDsnbQ8L3RleHQ+CiAgPHRleHQgeD0iNjkwIiB5PSI2MDAiIGNsYXNzPSJleCBtdXRlZCI+64aS7J2AIOywqOufieydgD88L3RleHQ+CgogIDwhLS0gU3RlcCA1OiBQcmVzY3JpcHRpdmUgLS0+CiAgPHRleHQgeD0iODcwIiB5PSIyMzQiIGNsYXNzPSJzdGFnZSIgZmlsbD0iI2I0N2ExNiI+UHJlc2NyaXB0aXZlPC90ZXh0PgogIDx0ZXh0IHg9Ijg3MCIgeT0iMjU2IiBjbGFzcz0iZ2xvc3MgbXV0ZWQiPu2WieuPmeq5jOyngCDqtozqs6A8L3RleHQ+CiAgPGcgY2xhc3M9InNoYWRvdyI+PHJlY3QgeD0iNzkwIiB5PSIyNzAiIHdpZHRoPSIxNjAiIGhlaWdodD0iMjkwIiByeD0iMTAiIGNsYXNzPSJiYXItbmV4dCIvPjwvZz4KICA8dGV4dCB4PSI4NzAiIHk9IjU4MCIgY2xhc3M9ImV4IGluayI+7KCV67mEIOyInOyEnOulvDwvdGV4dD4KICA8dGV4dCB4PSI4NzAiIHk9IjYwMCIgY2xhc3M9ImV4IG11dGVkIj7slrTrlrvqsowg7Kec7JW8IO2VtD88L3RleHQ+CgogIDwhLS0gU3RlcCA2OiBBZGFwdGl2ZSAtLT4KICA8dGV4dCB4PSIxMDUwIiB5PSIxODQiIGNsYXNzPSJzdGFnZSBtdXRlZCI+QWRhcHRpdmU8L3RleHQ+CiAgPHRleHQgeD0iMTA1MCIgeT0iMjA2IiBjbGFzcz0iZ2xvc3MgbXV0ZWQiPuyekOycqCDsobDsoJXCt+yLpO2WiTwvdGV4dD4KICA8ZyBjbGFzcz0ic2hhZG93Ij48cmVjdCB4PSI5NzAiIHk9IjIyMCIgd2lkdGg9IjE2MCIgaGVpZ2h0PSIzNDAiIHJ4PSIxMCIgY2xhc3M9ImJhci1hZGFwdGl2ZSIvPjwvZz4KICA8dGV4dCB4PSIxMDUwIiB5PSI1ODAiIGNsYXNzPSJleCBpbmsiPuydtOyDgSDqsJDsp4DrkJjrqbQ8L3RleHQ+CiAgPHRleHQgeD0iMTA1MCIgeT0iNjAwIiBjbGFzcz0iZXggbXV0ZWQiPuyekOuPmeycvOuhnCDsobDsuZg8L3RleHQ+CgogIDxnIGNsYXNzPSJzaGFkb3ciPgogICAgPHJlY3QgeD0iMjIwIiB5PSI2NDgiIHdpZHRoPSI3NjAiIGhlaWdodD0iNDIiIHJ4PSIyMSIgY2xhc3M9ImNhcmQiLz4KICAgIDx0ZXh0IHg9IjYwMCIgeT0iNjc0IiBjbGFzcz0iYm9keSBpbmsiIHRleHQtYW5jaG9yPSJtaWRkbGUiPuyngOq4iOydgCBSZWFjdGl2ZeulvCDri6Tsp4DripQg7KSR7J206rOgLCBQcm9hY3RpdmXCt0FkYXB0aXZl64qUIOyVnuycvOuhnCDtkoAg66y47KCc7J6F64uI64ukLjwvdGV4dD4KICA8L2c+Cjwvc3ZnPgo=\" width=\"1200\" height=\"720\" class=\"img_ev3q\"></p>\n<p><strong>온톨로지가 실제 데이터를 정확히 반영하고 있는가.</strong> TBox(온톨로지 설계)와 실제로 적재된 데이터(ABox)는 항상 같지 않습니다. 설계는 있는데 데이터가 아직 없거나, 설계와 다른 이름으로 적재된 경우도 있습니다. 이 글에 나온 Fuseki COUNT 검증처럼, 설계상 존재해야 하는 것과 실제로 존재하는 것의 차이를 끊임없이 실측해야 판정 기준 자체를 믿을 수 있습니다.</p>\n<p><strong>물리적 운영을 온톨로지로 얼마나 충실하게 옮길 것인가.</strong> 창고, 차량, 경로 같은 실제 운영이 온톨로지 안에서 얼마나 정확하게 표현되는지가 챗봇이 답할 수 있는 범위의 상한선이 됩니다. 현실의 상태 변화를 놓치지 않고 온톨로지에 반영하는 일, 즉 디지털 트윈을 촘촘하게 유지하는 일이 곧 챗봇의 정확도를 결정합니다.</p>\n<p><strong>서로 다른 도메인의 데이터를 하나의 지식그래프로 자산화할 것인가.</strong> Fleet Management System(FMS), Warehouse Management System(WMS), Transportation Management System(TMS), TAP은 원래 따로 운영되던 도메인입니다. 각자의 온톨로지로만 두면 \"차량 문제가 배차에 미친 영향\" 같은 질문에는 답할 수 없습니다. 도메인을 넘나드는 질의가 가능하려면, 서로 다른 팀이 만든 데이터를 하나의 지식그래프 문법으로 자산화하는 일이 먼저 필요합니다.</p>\n<h2 class=\"anchor anchorTargetStickyNavbar_Vzrq\" id=\"마치며\">마치며<a href=\"https://tech.umosone.ai/ontology-eval-loop#%EB%A7%88%EC%B9%98%EB%A9%B0\" class=\"hash-link\" aria-label=\"마치며에 대한 직접 링크\" title=\"마치며에 대한 직접 링크\" translate=\"no\">​</a></h2>\n<p>AI 루프가 판정·진단·개선안 도출까지 이어서 처리하면서, 온톨로지를 넓히는 작업의 병목은 사람의 판단 속도에서 루프가 도는 속도로 옮겨갔습니다. 그렇다고 AI가 데이터 분석가를 대체했다고 말하고 싶지는 않습니다. 정확히는, 분석가가 매번 반복하던 판정·진단·우선순위 작업을 기준만 명확히 세워두면 반복 가능한 형태로 바꿔둔 것에 가깝습니다. 기준을 세우는 일과 그 기준을 감사하는 일은 여전히 사람의 몫으로 남아 있고, 그 사이의 반복 노동만 사라졌습니다.</p>\n<p>앞으로도 이 판정 체인이 놓치는 경계 케이스를 계속 찾아내면서, 사람이 감사해야 할 지점과 AI에게 맡길 수 있는 지점을 다시 나누는 작업을 이어갈 예정입니다.</p>",
            "url": "https://tech.umosone.ai/ontology-eval-loop",
            "title": "데이터 품질은 스스로 자란다: AI 루프 시스템이 바꾼 온톨로지 자동화",
            "summary": "WMS, TMS, FMS, TAP 운영 데이터를 연결하는 지식그래프 온톨로지를 AI 에이전트가 스스로 평가하고 개선하는 자기개선 루프 시스템의 설계와 구현 경험을 공유합니다.",
            "date_modified": "2026-07-06T00:00:00.000Z",
            "author": {
                "name": "이민식"
            },
            "tags": [
                "온톨로지",
                "AI 에이전트",
                "지식그래프",
                "데이터 품질"
            ]
        },
        {
            "id": "https://tech.umosone.ai/welcome",
            "content_html": "<p>UMOS ONE 개발팀의 기술 블로그를 시작합니다.</p>\n<p>UMOS ONE은 물류와 모빌리티의 여러 흐름을 하나의 경험으로 연결하는 End-to-End 모빌리티 솔루션을 만들어가고 있습니다. 창고 운영을 지능화하는 WMS, 운송 흐름을 최적화하는 TMS, 자산과 현장을 데이터로 묶는 FMS, 그리고 수요응답형 셔틀과 자율주행 셔틀을 연결하는 TAP!까지, 저희가 다루는 문제는 화면 하나나 API 하나로 끝나지 않습니다.</p>\n<p><img decoding=\"async\" loading=\"lazy\" alt=\"UMOS ONE 플랫폼 개요\" src=\"data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxMjAwIiBoZWlnaHQ9IjcyMCIgdmlld0JveD0iMCAwIDEyMDAgNzIwIiByb2xlPSJpbWciIGFyaWEtbGFiZWxsZWRieT0idGl0bGUgZGVzYyI+CiAgPHRpdGxlIGlkPSJ0aXRsZSI+VU1PUyBPTkUg7ZSM656r7Y+8IOqwnOyalDwvdGl0bGU+CiAgPGRlc2MgaWQ9ImRlc2MiPldNUywgVE1TLCBGTVMsIFRBUOydtCBVTU9TIE9ORSDtlIzrnqvtj7wg7KSR7Ius7JeQ7IScIOusvOulmOyZgCDrqqjruYzrpqzti7Ag642w7J207YSw66W8IOyXsOqysO2VmOuKlCDqtazsobDrpbwg64KY7YOA64K4IOq3uOumvDwvZGVzYz4KICA8ZGVmcz4KICAgIDxzdHlsZT4KICAgICAgLmJnIHsgZmlsbDogI2Y1ZjhmYzsgfQogICAgICAuZ3JpZCB7IHN0cm9rZTogI2RiZTRlZjsgc3Ryb2tlLXdpZHRoOiAxOyBvcGFjaXR5OiAwLjc7IH0KICAgICAgLmNhcmQgeyBmaWxsOiAjZmZmZmZmOyBzdHJva2U6ICNkN2RlZWE7IHN0cm9rZS13aWR0aDogMjsgfQogICAgICAuY2VudGVyIHsgZmlsbDogIzE1MTkyMzsgfQogICAgICAudGVhbCB7IGZpbGw6ICMxMjhmODI7IH0KICAgICAgLmJsdWUgeyBmaWxsOiAjMmY3ZGY2OyB9CiAgICAgIC5hbWJlciB7IGZpbGw6ICNiNDdhMTY7IH0KICAgICAgLnZpb2xldCB7IGZpbGw6ICM2MjU1Yzg7IH0KICAgICAgLmdyZWVuIHsgZmlsbDogIzI4N2Y1MjsgfQogICAgICAuY29yYWwgeyBmaWxsOiAjZDk1NjQ4OyB9CiAgICAgIC5pbmsgeyBmaWxsOiAjMTIxNzIyOyB9CiAgICAgIC5tdXRlZCB7IGZpbGw6ICM2ODczODY7IH0KICAgICAgLndoaXRlIHsgZmlsbDogI2ZmZmZmZjsgfQogICAgICAudGl0bGUgeyBmb250OiA4MDAgNDJweCB1aS1zYW5zLXNlcmlmLCBzeXN0ZW0tdWksIC1hcHBsZS1zeXN0ZW0sIEJsaW5rTWFjU3lzdGVtRm9udCwgIlNlZ29lIFVJIiwgc2Fucy1zZXJpZjsgfQogICAgICAuc3VidGl0bGUgeyBmb250OiA1MDAgMjJweCB1aS1zYW5zLXNlcmlmLCBzeXN0ZW0tdWksIC1hcHBsZS1zeXN0ZW0sIEJsaW5rTWFjU3lzdGVtRm9udCwgIlNlZ29lIFVJIiwgc2Fucy1zZXJpZjsgfQogICAgICAubGFiZWwgeyBmb250OiA4MDAgMjVweCB1aS1zYW5zLXNlcmlmLCBzeXN0ZW0tdWksIC1hcHBsZS1zeXN0ZW0sIEJsaW5rTWFjU3lzdGVtRm9udCwgIlNlZ29lIFVJIiwgc2Fucy1zZXJpZjsgfQogICAgICAuYm9keSB7IGZvbnQ6IDY1MCAxOHB4IHVpLXNhbnMtc2VyaWYsIHN5c3RlbS11aSwgLWFwcGxlLXN5c3RlbSwgQmxpbmtNYWNTeXN0ZW1Gb250LCAiU2Vnb2UgVUkiLCBzYW5zLXNlcmlmOyB9CiAgICAgIC5zbWFsbCB7IGZvbnQ6IDc1MCAxNXB4IHVpLXNhbnMtc2VyaWYsIHN5c3RlbS11aSwgLWFwcGxlLXN5c3RlbSwgQmxpbmtNYWNTeXN0ZW1Gb250LCAiU2Vnb2UgVUkiLCBzYW5zLXNlcmlmOyB9CiAgICAgIC5tb25vIHsgZm9udDogODUwIDE2cHggdWktbW9ub3NwYWNlLCBTRk1vbm8tUmVndWxhciwgTWVubG8sIENvbnNvbGFzLCBtb25vc3BhY2U7IH0KICAgICAgLmxpbmsgeyBmaWxsOiBub25lOyBzdHJva2U6ICM5NGEzYjg7IHN0cm9rZS13aWR0aDogNDsgc3Ryb2tlLWxpbmVjYXA6IHJvdW5kOyBzdHJva2UtZGFzaGFycmF5OiA4IDEwOyB9CiAgICAgIC5zb2xpZCB7IGZpbGw6IG5vbmU7IHN0cm9rZTogIzEyOGY4Mjsgc3Ryb2tlLXdpZHRoOiA1OyBzdHJva2UtbGluZWNhcDogcm91bmQ7IH0KICAgICAgLnNoYWRvdyB7IGZpbHRlcjogZHJvcC1zaGFkb3coMCAxOHB4IDMwcHggcmdiKDMxIDQ1IDY2IC8gMC4xMikpOyB9CiAgICA8L3N0eWxlPgogICAgPG1hcmtlciBpZD0iYXJyb3doZWFkIiBtYXJrZXJXaWR0aD0iMTIiIG1hcmtlckhlaWdodD0iMTIiIHJlZlg9IjkiIHJlZlk9IjYiIG9yaWVudD0iYXV0byI+CiAgICAgIDxwYXRoIGQ9Ik0yLDIgTDEwLDYgTDIsMTAgWiIgZmlsbD0iIzEyOGY4MiIvPgogICAgPC9tYXJrZXI+CiAgPC9kZWZzPgoKICA8cmVjdCBjbGFzcz0iYmciIHdpZHRoPSIxMjAwIiBoZWlnaHQ9IjcyMCIvPgogIDxnPgogICAgPHBhdGggY2xhc3M9ImdyaWQiIGQ9Ik0wIDkwSDEyMDBNMCAxODBIMTIwME0wIDI3MEgxMjAwTTAgMzYwSDEyMDBNMCA0NTBIMTIwME0wIDU0MEgxMjAwTTAgNjMwSDEyMDAiLz4KICAgIDxwYXRoIGNsYXNzPSJncmlkIiBkPSJNMTAwIDBWNzIwTTIwMCAwVjcyME0zMDAgMFY3MjBNNDAwIDBWNzIwTTUwMCAwVjcyME02MDAgMFY3MjBNNzAwIDBWNzIwTTgwMCAwVjcyME05MDAgMFY3MjBNMTAwMCAwVjcyME0xMTAwIDBWNzIwIi8+CiAgPC9nPgoKICA8dGV4dCB4PSI3MiIgeT0iNzgiIGNsYXNzPSJ0aXRsZSBpbmsiPlVNT1MgT05FIFRlY2ggQmxvZzwvdGV4dD4KICA8dGV4dCB4PSI3MiIgeT0iMTE4IiBjbGFzcz0ic3VidGl0bGUgbXV0ZWQiPuusvOulmOyZgCDrqqjruYzrpqzti7DsnZgg7Jq07JiBIO2dkOumhOydhCDtlZjrgpjsnZgg7KCc7ZKIIOqyve2XmOycvOuhnCDsl7DqsrDtlanri4jri6QuPC90ZXh0PgoKICA8cGF0aCBjbGFzcz0ibGluayIgZD0iTTM1NiAyNDYgQzQ1NiAyNDYsIDQ4MiAzMjAsIDU0OCAzNTAiLz4KICA8cGF0aCBjbGFzcz0ibGluayIgZD0iTTg0NCAyNDYgQzc0NCAyNDYsIDcxOCAzMjAsIDY1MiAzNTAiLz4KICA8cGF0aCBjbGFzcz0ibGluayIgZD0iTTM1NiA0OTYgQzQ1NiA0OTYsIDQ4MiA0MjAsIDU0OCAzODgiLz4KICA8cGF0aCBjbGFzcz0ibGluayIgZD0iTTg0NCA0OTYgQzc0NCA0OTYsIDcxOCA0MjAsIDY1MiAzODgiLz4KCiAgPGcgY2xhc3M9InNoYWRvdyI+CiAgICA8cmVjdCB4PSI4NCIgeT0iMTc4IiB3aWR0aD0iMjcyIiBoZWlnaHQ9IjEzNiIgcng9IjE2IiBjbGFzcz0iY2FyZCIvPgogICAgPHRleHQgeD0iMTE2IiB5PSIyMjQiIGNsYXNzPSJsYWJlbCBpbmsiPldNUzwvdGV4dD4KICAgIDx0ZXh0IHg9IjExNiIgeT0iMjU1IiBjbGFzcz0iYm9keSBtdXRlZCI+7LC96rOgIOyatOyYgSDsp4DriqXtmZQ8L3RleHQ+CiAgICA8dGV4dCB4PSIxMTYiIHk9IjI4OSIgY2xhc3M9InNtYWxsIG11dGVkIj7snoXqs6Agwrcg7J6s6rOgIMK3IOy2nOqzoCDqsIDsi5zshLE8L3RleHQ+CiAgPC9nPgoKICA8ZyBjbGFzcz0ic2hhZG93Ij4KICAgIDxyZWN0IHg9Ijg0NCIgeT0iMTc4IiB3aWR0aD0iMjcyIiBoZWlnaHQ9IjEzNiIgcng9IjE2IiBjbGFzcz0iY2FyZCIvPgogICAgPHRleHQgeD0iODc2IiB5PSIyMjQiIGNsYXNzPSJsYWJlbCBpbmsiPlRNUzwvdGV4dD4KICAgIDx0ZXh0IHg9Ijg3NiIgeT0iMjU1IiBjbGFzcz0iYm9keSBtdXRlZCI+7Jq07IahIO2dkOumhCDstZzsoIHtmZQ8L3RleHQ+CiAgICA8dGV4dCB4PSI4NzYiIHk9IjI4OSIgY2xhc3M9InNtYWxsIG11dGVkIj7rp6Tsua0gwrcg67Cw7LCoIMK3IOq0gOygnCDsnpDrj5ntmZQ8L3RleHQ+CiAgPC9nPgoKICA8ZyBjbGFzcz0ic2hhZG93Ij4KICAgIDxyZWN0IHg9Ijg0IiB5PSI0MjgiIHdpZHRoPSIyNzIiIGhlaWdodD0iMTM2IiByeD0iMTYiIGNsYXNzPSJjYXJkIi8+CiAgICA8dGV4dCB4PSIxMTYiIHk9IjQ3NCIgY2xhc3M9ImxhYmVsIGluayI+Rk1TPC90ZXh0PgogICAgPHRleHQgeD0iMTE2IiB5PSI1MDUiIGNsYXNzPSJib2R5IG11dGVkIj7snpDsgrDqs7wg7ZiE7J6lIOuNsOydtO2EsDwvdGV4dD4KICAgIDx0ZXh0IHg9IjExNiIgeT0iNTM5IiBjbGFzcz0ic21hbGwgbXV0ZWQiPuyLpOyLnOqwhCDqtIDsoJwgwrcg7JiI7KeA67O07KCEPC90ZXh0PgogIDwvZz4KCiAgPGcgY2xhc3M9InNoYWRvdyI+CiAgICA8cmVjdCB4PSI4NDQiIHk9IjQyOCIgd2lkdGg9IjI3MiIgaGVpZ2h0PSIxMzYiIHJ4PSIxNiIgY2xhc3M9ImNhcmQiLz4KICAgIDx0ZXh0IHg9Ijg3NiIgeT0iNDc0IiBjbGFzcz0ibGFiZWwgaW5rIj5UQVAhPC90ZXh0PgogICAgPHRleHQgeD0iODc2IiB5PSI1MDUiIGNsYXNzPSJib2R5IG11dGVkIj7shZTti4Dqs7wg7J2064+ZIOqyve2XmDwvdGV4dD4KICAgIDx0ZXh0IHg9Ijg3NiIgeT0iNTM5IiBjbGFzcz0ic21hbGwgbXV0ZWQiPkRSVCDCtyDsnpDsnKjso7ztlokgwrcg7Iqk66eI7Yq47Iuc7YuwPC90ZXh0PgogIDwvZz4KCiAgPGcgY2xhc3M9InNoYWRvdyI+CiAgICA8cmVjdCB4PSI0NTIiIHk9IjI0NCIgd2lkdGg9IjI5NiIgaGVpZ2h0PSIyMzIiIHJ4PSIyOCIgY2xhc3M9ImNlbnRlciIvPgogICAgPHRleHQgeD0iNjAwIiB5PSIzMjIiIGNsYXNzPSJ0aXRsZSB3aGl0ZSIgZm9udC1zaXplPSIzOCIgdGV4dC1hbmNob3I9Im1pZGRsZSI+VU1PUyBPTkU8L3RleHQ+CiAgICA8dGV4dCB4PSI2MDAiIHk9IjM2MCIgY2xhc3M9ImJvZHkgd2hpdGUiIG9wYWNpdHk9IjAuODIiIHRleHQtYW5jaG9yPSJtaWRkbGUiPkVuZC10by1FbmQgTW9iaWxpdHk8L3RleHQ+CiAgICA8dGV4dCB4PSI2MDAiIHk9IjM4OSIgY2xhc3M9ImJvZHkgd2hpdGUiIG9wYWNpdHk9IjAuODIiIHRleHQtYW5jaG9yPSJtaWRkbGUiPlNvbHV0aW9uPC90ZXh0PgogICAgPHBhdGggY2xhc3M9InNvbGlkIiBtYXJrZXItZW5kPSJ1cmwoI2Fycm93aGVhZCkiIGQ9Ik01MjYgNDMwIEg2NzIiLz4KICA8L2c+CgogIDxnIGNsYXNzPSJzaGFkb3ciPgogICAgPHJlY3QgeD0iMjkyIiB5PSI2MjAiIHdpZHRoPSI2MTYiIGhlaWdodD0iNTQiIHJ4PSIyNyIgY2xhc3M9ImNhcmQiLz4KICAgIDxjaXJjbGUgY3g9IjMyNiIgY3k9IjY0NyIgcj0iMTAiIGNsYXNzPSJncmVlbiIvPgogICAgPHRleHQgeD0iMzUyIiB5PSI2NTQiIGNsYXNzPSJib2R5IGluayI+64+E66mU7J24IOyngOyLnSwg7Jq07JiBIOuNsOydtO2EsCwg7KCc7ZKIIOqyve2XmOydhCDquLDsiKDroZwg7Jew6rKw7ZWY64qUIOyXrOyglTwvdGV4dD4KICA8L2c+Cjwvc3ZnPgo=\" width=\"1200\" height=\"720\" class=\"img_ev3q\"></p>\n<p>입고부터 출고까지의 재고 가시성, 화주와 차주의 매칭, 운송 자동화, 실시간 관제, 예지보전, 스마트시티 운영처럼 서로 다른 도메인이 한 시스템 안에서 만납니다. 그래서 UMOSONE 개발팀의 일은 단순히 기능을 빠르게 만드는 일이 아니라, 복잡한 이동과 운영 데이터를 안정적으로 연결하고, 현장에서 믿고 쓸 수 있는 제품으로 다듬는 일에 가깝습니다.</p>\n<p>이 블로그에는 그런 과정을 기록하려고 합니다.</p>\n<ul>\n<li class=\"\">물류와 모빌리티 도메인을 제품으로 풀어내며 배운 것</li>\n<li class=\"\">대규모 운영 데이터를 다루기 위한 아키텍처와 설계 고민</li>\n<li class=\"\">프론트엔드, 백엔드, 인프라, 데이터, AI를 연결하는 개발 경험</li>\n<li class=\"\">품질을 지키기 위한 테스트, 모니터링, 배포, 자동화 이야기</li>\n<li class=\"\">팀이 더 잘 일하기 위해 만든 AI native/friendly 개발 문화와 도구</li>\n</ul>\n<p>좋은 기술 블로그는 완성된 답만 모아두는 곳이 아니라고 생각합니다. 의사결정의 배경, 실패했던 접근, 다시 고친 구조, 운영하면서 알게 된 현실적인 제약까지 남겨야 다음 사람이 더 나은 선택을 할 수 있습니다.</p>\n<p>UMOS ONE Tech Blog는 저희가 만든 것뿐 아니라, 만들어가는 방식을 공유하는 공간이 되려 합니다. 모든 이동의 가치를 완성하기 위해 저희가 어떤 기술적 선택을 하고, 어떤 문제를 만나고, 어떻게 해결해가는지 차근차근 기록하겠습니다.</p>",
            "url": "https://tech.umosone.ai/welcome",
            "title": "UMOS ONE Tech Blog를 시작합니다",
            "summary": "WMS, TMS, FMS, TAP!로 물류와 모빌리티를 연결하는 UMOS ONE 개발팀의 기술 블로그를 시작합니다. 도메인 지식, 운영 데이터, 제품 경험을 기술로 연결하는 여정을 기록합니다.",
            "date_modified": "2026-06-21T00:00:00.000Z",
            "author": {
                "name": "임상석"
            },
            "tags": [
                "모빌리티",
                "물류",
                "AI",
                "스마트시티",
                "I ❤ Engineering"
            ]
        }
    ]
}