篇五:上下文组装——召回 50 条全塞给 LLM?难怪回答越来越差
掘金
这篇换个结构——从一次真实事故讲起,把上下文组装里的坑一个个拆出来。 事故复盘 某公司内部知识库上线两周后,用户投诉激增: 排查发现:文档解析、切片、Embedding、向量检索、Hybrid Sea
Балл: 57.12Уверенность: 54%
ПодробнееЗагружаем каталог…
НАВИГАТОР ПО ВОЗМОЖНОСТЯМ ИИ
Найдите свой ИИ-инструмент. Бесплатный доступ, пробные периоды и кредиты — в одном месте.
掘金
这篇换个结构——从一次真实事故讲起,把上下文组装里的坑一个个拆出来。 事故复盘 某公司内部知识库上线两周后,用户投诉激增: 排查发现:文档解析、切片、Embedding、向量检索、Hybrid Sea
Балл: 57.12Уверенность: 54%
ПодробнееiThome 新聞
AI資安業者Pillar Security近期揭露,用於LLM微調及量化的開源函式庫Unsloth,其Web操作介面Unsloth Studio存在任意程式碼執行漏洞,使用Unsloth 2026.5.10及之前版本執行Studio者受到影響。
Балл: 57.04Уверенность: 54%
ПодробнееiThome 新聞
南韓多家大型銀行近期接連發生網路攻擊與個資外洩事件。南韓金融委員會(Financial Services Commission,FSC)已召開緊急會議,要求金融機構全面檢查可從外部存取的IT資產與服務,確認是否存在安全漏洞及存取控制問題。
Балл: 57.04Уверенность: 54%
ПодробнееiThome 新聞
使用ChatGPT的企業與一般使用者需要留意Custom GPT遭攻擊者濫用的風險。資安業者Huntress近日發現,攻擊者建立名為「Plus 5.6」的Custom GPT。
Балл: 57.04Уверенность: 54%
Подробнее人人都是产品经理
海外 AI 助理 Muse 上线十天冲到美区 App Store 第一,靠的是帮人省钱。但它也翻过车:有记者称自己明明拒绝授权,仍被读到 18 万多条私信。作者在热度过后找了一圈国内平替,装上纳米Work,从 Vercel 账单超额一路查到密钥泄露。 最近海外有个 AI 助理火得一塌糊涂,叫 Muse。 Meta 9 月 8 号发布的,上线 10 天就冲到美区 App Store 第一,把 GPT 都挤了下去。 它火的原因特别朴素: 帮人省钱 。 有人让它跟运营商砍宽带费,两年省了 1920 刀。 有人让它比车险,一年省了 3500 刀。 还有人让它翻出一个十年前忘了退的 Dropbox 订阅,直接追回 200 刀。 X 上甚至搞起了 MuseMoneyChallenge,大家排着队晒自己省了多少钱。 说实话,我看完是真眼馋。 但没几天,Muse 就翻车了。 有个老哥让它帮忙回二手平台的消息,图省事点了「始终允许」,以为成交前它会先问一句。 结果它没问,10 刀就把键盘贱卖了,还把老哥家地址发给了买家,回了句「我在家」。 晚上 9 点多,陌生人真找上门了,老哥人根本不在。 还有个记者说,自己明明拒绝了授权,Muse 却读到了他 18 万多条私信。Meta 那边否认,到现在也没个定论。 好家伙,这哪是请了个助理,这是请了个祖宗。 所以我一直有个疑问: AI 助理再能干,你敢把家里的钥匙交给它吗? 巧的是,前几天我自己就被上了一课。 我让一个 Personal Agent 帮我做账户体检,本来只想清清没用的订阅。 结果它翻着翻着,甩给我一句:你有个网站,最近流量不太对劲。 我点开一看,麻了。 有人在偷偷刷我网站的流量,再晚发现几天,这个月账单得多出 200 刀。 而我,完全不知道。 这个 Personal Agent,就是 360 的 纳米Work ,一个超级个人云端助理。 讲真的,它的云电脑功能 7 月 28 号就上线了,比 Muse 还早了一个多月。 Muse 火了之后,我就一直在找国内能用的平替,毕竟它在国内压根用不了。 翻了一圈,在 AI 产品榜的 AI 智能体增速榜上看到了纳米Work,直接冲到第一。 刚看完 Muse 翻车的我,想着找个靠谱点的,顺手就装上了。 今天就带大家看看,它是怎么帮我把这 200 刀省下来的。 以及我最关心的问题:它替我干活的时候,我到底放不放心。 我整的云电脑是 8 核 16 G 的,拥有 200 G 存储,可以使劲给我折腾了。 而且它里面内置了 100 多个大模型,还预装了一大堆 Skill,不用自己折腾配置,拿来就能用,这点对懒人太友好了。 通过连接器连接上 QQ 邮箱后,我是先让纳米Work 看我邮箱订阅了哪些工具或者产品。 一上来它就提醒我 Vercel Pro 有每周用量总结和 50%/75% 预算提醒,我靠,于是我继续让它帮我整理下 Vercel Pro 的账单。 由于运行的是云电脑,它会自行打开 Vercel,我授权登录后,它就会自己去翻账单,给我出了一份报告,然后就看到最近用量费用激增的核心是 Fast Data Transfer(流量超额)。 我部署在 Vercel 上的产品比较多,于是我继续让它继续翻看 Vercel 看下是哪个产品,它在云端一顿操作后告诉我说是 awesome-gpt-image-2 这个项目。 是 CDN 出站带宽异常暴涨,并给我提示风险是直指「站点被持续刷量 / 图片被大量外链热链 / 爬虫抓取」等外部流量事件,而非自身功能增长。 我去,我估摸着是这个项目前段日子上了 GitHub 趋势榜 NO 1,被大量外链和爬取了。 然后就看到纳米Work 开始给我解决方案了。 AI 助理牛逼就在,我只需要下发需求,它就能自己去执行,我让它直接帮我解决这个问题,它会自行操作浏览器,帮我配置On-Demand 用量,对于敏感操作,它会让我再一次确认才会保存修改。 这个就比 muse 要安全一些,至少不会上来就一顿猛猛干,不经过你确认。 想着看是哪个大可爱刷我的流量,直接让纳米Work 在云电脑上把「刷流量的人」揪出来。 太狠了,就一直刷啊。 我让它继续帮我加固源头项目 awesome-gpt-image-2(gpt-image2.canghe.ai),加防盗链/缓存/限流。 它一顿操作帮我做了加固,全程我甚至是用手机操作的,因为我人不在电脑旁。 手机全程也能看到云电脑在干活,7 * 24 小时都能随时指挥,在交付物还会给我关键过程截图,可以很快溯源定位。 修复后,模拟测试了一遍刷量,就能限流了。打开电脑纳米Work 应用也能看见。 还好纳米Work 给了我提醒,及时的做了措施,省下的不止 200 刀啊。 大家做产品,一定也要记得做好防盗锁、限流等操作。 我让纳米Work 的云助理帮我根据邮件帮我做一次 GitHub 账户安全体检。 根据邮件帮我做一次 GitHub 账户安全体检: 1. 检查我所有仓库(含历史提交)里是否有泄露的密钥、Token、.env 或密码 2. 列出我授权过的 OAuth 应用、PersonalAccessToken、SSH 和 DeployKey,标出超过 90 天没用过的 3. 翻一遍我邮箱里 GitHub 发来的安全告警和登录提醒,看有没有我漏掉的 4. 按风险高低做成一份体检报告和处理清单只读不改。 任何撤销、删除、修改仓库设置的操作,先列给我看,等我确认后再执行。 真是不查不知道,一查吓一跳,有 5 处真实泄露,AI 居然把我的 key 放在了代码里,这太危险了。 我现在 GitHub 已经开源了非常多的项目了,很多项目的 issue 和 PR 不能第一时间收到,还挺痛苦的。 有了云端助理,连上 GitHub 后,完全可以让它给我做个监控,有 issue 和 PR 就通过微信通知我。 我给纳米Work 的云助理下达这样一个指令: 帮我设置个任务,监听 GitHub 有新的 PR 或者 issue 就发通知给我,同时发到微信上给我 当有新的 issue 或者 PR,它就会通过微信直接通知我,我可以直接打开看相关信息。这样再也不会错过重要的 GitHub 消息了。 GitHub 的 trending 上经常会有很多不错的开源项目,可以学习,有时候也想作为公众号选题。 但是每天自己去刷还是太慢了,我直接让纳米Work 云助理设置了个定时任务,每天去帮我做总结。 帮我设置个定时任务,查看https://github.com/trending这个 GitHub 的趋势榜,每天 9 点给我做个推送,看下有没有 AI 相关的比较有意思的项目可以作为我公众号选题的,给我一份趋势榜项目报告和可使用的选题报告,除了内部通知,还要通过微信推送消息给我,给我一个飞书文档就好了 每天微信会给我推送一份 GitHub 趋势日报,末尾会附上飞书链接,可以看详情,以及对应的选题,非常的方便。 这个文档还是非常详细的,我如果感兴趣有时间,我就能直接来这里翻,比如找选题的时候。 折腾完这一圈,我算了算这个超级个人云端助理帮我干的活: 揪出了 Vercel 上的盗刷流量,省下 200 多刀; 体检出 GitHub 里 5 处真实的密钥泄露; 帮我盯着几十个开源项目的 issue 和 PR; 每天早上 9 点,把选题报告推到我微信上。 这些活儿,以前要么我压根想不到,要么得我自己一点点去翻。 现在交代一句,它就在云电脑上 7×24 小时替我干着,我人在外面,掏出手机就能看到它干到哪一步了。 回头再看那个被陌生人敲门的老哥,我突然就理解了。 AI 助理能干活,早就不稀奇了。 稀缺的是,它干活的时候,你心里有底。 在纳米Work 上,查账单、翻邮件、找原因,它自己就去办了; 改 Vercel 配置这种敏感操作,它会停下来等我点头; 每一步干了啥,都有截图留底,出了问题随时能回头查。 说白了,活儿它来干,主意我来拿。 后来我才知道,在 IDC 2026《中国企业级通用 Agent 产品技术评估》里,纳米Work 是安全可控维度唯一的 Top 产品。 如果你也想要一个安全可靠的超级个人云端助理,可以去 work.n.cn 试试这个中国版 Muse,Windows、Mac、手机端都上线了。 文中的提示词都能直接复制,抄作业就完事儿。 本文由人人都是产品经理作者【苍何】,微信公众号:【苍何】,原创/授权 发布于人人都是产品经理,未经许可,禁止转载 题图来自Unsplash,基于 CC0 协议
velog
최소 신장 트리(MST)를 구하는 대표적인 알고리즘인 크루스칼과 프림을 정리한다. 두 알고리즘 모두 그래프의 모든 정점을 연결하면서 간선 가중치의 합을 최소로 만드는 것 이 목적이다. 두 알고리즘의 가장 큰 차이는 MST를 만드는 방식이다. 알고리즘 기준 핵심 자료구조 크루스칼 간선 중심 Union-Find 프림 정점 중심 visited , minEdge 핵심은 다음과 같다. 크루스칼 : 전체 간선을 가중치 순으로 정렬하고, 사이클이 발생하지 않는 간선을 작은 것부터 선택한다. 프림 : 하나의 정점에서 시작해 현재 트리와 연결할 수 있는 정점 중 가장 적은 비용으로 연결되는 정점을 선택한다. 1. 최소 신장 트리(MST) 신장 트리(Spanning Tree)는 그래프의 모든 정점을 연결하면서 사이클이 없는 트리 를 의미한다. 정점이 V 개라면 신장 트리가 가지는 간선의 개수는 항상 V-1 개이다. 이 중에서 사용한 간선들의 가중치 합이 가장 작은 신장 트리를 최소 신장 트리(Minimum Spanning Tree, MST) 라고 한다. 예를 들어 여러 도시를 도로로 연결해야 할 때, 모든 도시를 연결하면서 도로 건설 비용을 최소화하는 문제에서 사용할 수 있다. 2. 크루스칼(Kruskal) 크루스칼은 간선을 기준으로 MST를 만드는 알고리즘 이다. 전체 간선을 가중치 기준으로 오름차순 정렬한 뒤, 가장 가중치가 작은 간선부터 하나씩 확인한다. 이때 간선을 연결했을 때 사이클이 발생하지 않는 경우에만 MST에 포함한다. 동작 과정은 다음과 같다. 모든 간선을 가중치 기준 오름차순으로 정렬한다. 가장 가중치가 작은 간선부터 확인한다. 두 정점이 이미 같은 집합인지 확인한다. 다른 집합이면 두 정점을 연결한다. 같은 집합이면 사이클이 발생하므로 선택하지 않는다. 간선을 V-1 개 선택하면 종료한다. 사이클 발생 여부를 빠르게 판단하기 위해 Union-Find 를 사용한다. 3. Union-Find Union-Find는 여러 정점이 같은 집합에 포함되어 있는지 확인하고, 서로 다른 두 집합을 하나로 합치는 자료구조이다. 크게 find 와 union 두 연산을 사용한다. makeSets 처음에는 모든 정점이 서로 연결되어 있지 않기 때문에 각 정점의 부모를 자기 자신으로 설정한다. static void makeSets() { for (int i = 0; i < V; i++) { parents[i] = i; } } 예를 들어 정점이 4개라면 처음에는 다음과 같다. 정점 0 1 2 3 parents 0 1 2 3 각 정점이 하나의 독립적인 집합인 상태이다. find find 는 해당 정점이 속한 집합의 대표 정점(루트)을 찾는다. static int find(int a) { if (parents[a] == a) { return a; } return parents[a] = find(parents[a]); } 부모를 계속 따라가면서 최종 루트를 찾는다. 3 → 2 → 1 과 같은 구조라면 find(3) 의 결과는 1 이다. return parents[a] = find(parents[a]); 에서는 최종 루트를 찾은 뒤 parents[a] 에 바로 저장한다. 따라서 3 → 2 → 1 이었던 구조가 이후에는 3 → 1 2 → 1 처럼 변경된다. 이를 경로 압축(Path Compression) 이라고 한다. union union 은 두 정점이 서로 다른 집합에 있다면 하나의 집합으로 합친다. static boolean union(int a, int b) { int rootA = find(a); int rootB = find(b); if (rootA == rootB) { return false; } parents[rootB] = rootA; return true; } 먼저 두 정점의 대표 정점을 찾는다. int rootA = find(a); int rootB = find(b); 두 대표가 같다면 이미 연결되어 있다는 의미이다. if (rootA == rootB) { return false; } 이 상태에서 다시 간선을 연결하면 사이클이 발생하기 때문에 해당 간선을 선택하지 않는다. 반대로 대표가 다르다면 parents[rootB] = rootA; 로 두 집합을 하나로 합친다. 즉 크루스칼에서는 다음 조건이 핵심이다. find(a) == find(b) → 이미 같은 집합 → 연결하면 사이클 발생 → 선택 X find(a) != find(b) → 서로 다른 집합 → 연결해도 사이클 발생 X → 선택 O 4. 크루스칼 코드 간선 하나를 다음과 같이 배열로 저장한다. edge[0] = 시작 정점 edge[1] = 도착 정점 edge[2] = 가중치 전체 코드는 다음과 같다. import java.io.BufferedReader; import java.io.IOException; import java.io.InputStreamReader; import java.util.Arrays; import java.util.StringTokenizer; public class MST_Kruskal { // 간선 정보를 2차원 배열로 저장 // edgeList[i][0] = 시작 정점 // edgeList[i][1] = 도착 정점 // edgeList[i][2] = 가중치 static int[][] edgeList; static int[] parents; static int V, E; // Union-Find 초기화 // 각 정점의 부모를 자기 자신으로 설정 static void makeSets() { for (int i = 0; i < V; i++) { parents[i] = i; } } // 해당 정점이 속한 집합의 대표 정점 찾기 static int find(int a) { // 자기 자신이 부모라면 루트 if (parents[a] == a) { return a; } // 최종 루트를 찾아 저장 return parents[a] = find(parents[a]); } // 두 정점이 속한 집합 합치기 static boolean union(int a, int b) { int rootA = find(a); int rootB = find(b); // 이미 같은 집합이라면 사이클 발생 if (rootA == rootB) { return false; } // 서로 다른 집합이면 합치기 parents[rootB] = rootA; return true; } public static void main(String[] args) throws IOException { BufferedReader br = new BufferedReader(new InputStreamReader(System.in)); StringTokenizer st = new StringTokenizer(br.readLine()); V = Integer.parseInt(st.nextToken()); E = Integer.parseInt(st.nextToken()); edgeList = new int[E][3]; parents = new int[V]; // 간선 입력 for (int i = 0; i < E; i++) { st = new StringTokenizer(br.readLine()); edgeList[i][0] = Integer.parseInt(st.nextToken()); edgeList[i][1] = Integer.parseInt(st.nextToken()); edgeList[i][2] = Integer.parseInt(st.nextToken()); } // 가중치 기준 오름차순 정렬 Arrays.sort( edgeList, (a, b) -> Integer.compare(a[2], b[2]) ); // Union-Find 초기화 makeSets(); int result = 0; int cnt = 0; // 가중치가 작은 간선부터 확인 for (int[] edge : edgeList) { int from = edge[0]; int to = edge[1]; int weight = edge[2]; // 서로 다른 집합이라면 연결 if (union(from, to)) { result += weight; // MST는 V-1개의 간선을 사용 if (++cnt == V - 1) { break; } } } System.out.println(result); } } 크루스칼의 핵심 부분은 다음 코드이다. Arrays.sort( edgeList, (a, b) -> Integer.compare(a[2], b[2]) ); for (int[] edge : edgeList) { if (union(edge[0], edge[1])) { result += edge[2]; if (++cnt == V - 1) { break; } } } 먼저 모든 간선을 가중치 기준으로 정렬한다. 이후 가장 작은 간선부터 확인하면서 union() 이 성공한 경우에만 비용을 추가한다. union() 이 false 를 반환했다는 것은 두 정점이 이미 같은 집합이라는 의미이므로 해당 간선을 추가하면 사이클이 발생한다. 정점이 V 개인 MST는 간선을 V-1 개 사용하기 때문에 cnt == V-1 이 되면 탐색을 종료한다. 5. 프림(Prim) 프림은 정점을 기준으로 MST를 확장하는 알고리즘 이다. 크루스칼이 그래프 전체에서 가장 가중치가 작은 간선을 선택한다면, 프림은 하나의 정점에서 시작해서 현재 만들어진 트리와 연결할 수 있는 정점 중 가장 적은 비용으로 연결되는 정점 을 선택한다. 동작 과정은 다음과 같다. 시작 정점을 하나 선택한다. 아직 MST에 포함되지 않은 정점 중 가장 적은 비용으로 연결할 수 있는 정점을 찾는다. 해당 정점을 MST에 포함한다. 새롭게 선택된 정점과 연결된 간선을 확인한다. 기존보다 더 적은 비용으로 연결할 수 있다면 최소 비용을 갱신한다. 모든 정점이 선택될 때까지 반복한다. 프림에서는 주로 다음 두 배열을 사용한다. boolean[] visited; int[] minEdge; visited[i] 는 i 번 정점이 이미 MST에 포함되었는지를 나타낸다. minEdge[i] 는 현재 만들어진 MST에서 i번 정점을 연결하기 위해 필요한 최소 비용 을 저장한다. 6. minEdge 처음에는 어떤 정점도 연결하지 않았기 때문에 모든 값을 무한대로 설정한다. Arrays.fill(minEdge, Integer.MAX_VALUE); 0번 정점에서 시작한다면 minEdge[0] = 0; 으로 설정한다. 처음 상태는 다음과 같다. 정점 0 1 2 3 minEdge 0 INF INF INF 이후 새로운 정점이 MST에 포함될 때마다 인접 정점으로 더 저렴하게 이동할 수 있는지 확인한다. 예를 들어 현재 minEdge[2] = 5 인데 새롭게 선택된 정점에서 2번 정점으로 가는 비용이 3 이라면 5 > 3 이므로 minEdge[2] = 3; 으로 갱신한다. 7. 프림 코드 인접 리스트는 연결 리스트 형태로 구현한다. Node 에는 도착 정점, 가중치, 다음 간선의 주소를 저장한다. static class Node { int to, weight; Node next; public Node(int to, int weight, Node next) { this.to = to; this.weight = weight; this.next = next; } } 전체 코드는 다음과 같다. import java.io.BufferedReader; import java.io.IOException; import java.io.InputStreamReader; import java.util.Arrays; import java.util.StringTokenizer; public class MST_Prim { static int V, E; static Node[] adjList; static boolean[] visited; static int[] minEdge; // 간선 정보 저장 static class Node { int to, weight; Node next; public Node(int to, int weight, Node next) { this.to = to; this.weight = weight; this.next = next; } } public static void main(String[] args) throws IOException { BufferedReader br = new BufferedReader(new InputStreamReader(System.in)); StringTokenizer st = new StringTokenizer(br.readLine()); V = Integer.parseInt(st.nextToken()); E = Integer.parseInt(st.nextToken()); adjList = new Node[V]; visited = new boolean[V]; minEdge = new int[V]; // 간선 입력 for (int i = 0; i < E; i++) { st = new StringTokenizer(br.readLine()); int from = Integer.parseInt(st.nextToken()); int to = Integer.parseInt(st.nextToken()); int weight = Integer.parseInt(st.nextToken()); // 무방향 그래프이므로 양방향 저장 adjList[from] = new Node(to, weight, adjList[from]); adjList[to] = new Node(from, weight, adjList[to]); } // 모든 정점의 최소 연결 비용을 무한대로 초기화 Arrays.fill(minEdge, Integer.MAX_VALUE); int result = 0; // 0번 정점부터 시작 minEdge[0] = 0; int c; for (c = 0; c < V; c++) { // STEP 1 // 아직 MST에 포함되지 않은 정점 중 // 가장 적은 비용으로 연결할 수 있는 정점 찾기 int min = Integer.MAX_VALUE; int minVertex = -1; for (int i = 0; i < V; i++) { if (!visited[i] && minEdge[i] < min) { min = minEdge[i]; minVertex = i; } } // 더 이상 연결할 수 있는 정점이 없다면 종료 if (minVertex == -1) { break; } // 선택한 정점을 MST에 포함 visited[minVertex] = true; // 해당 정점을 연결하기 위한 비용 추가 result += min; // STEP 2 // 선택된 정점과 연결된 정점들을 확인하여 // minEdge 갱신 for (Node temp = adjList[minVertex]; temp != null; temp = temp.next) { // 아직 MST에 포함되지 않았고 // 기존 비용보다 현재 간선이 더 저렴하다면 갱신 if (!visited[temp.to] && minEdge[temp.to] > temp.weight) { minEdge[temp.to] = temp.weight; } } } // 모든 정점을 연결했다면 MST 비용 출력 // 연결하지 못했다면 -1 System.out.println(c == V ? result : -1); } } 프림에서 핵심은 크게 두 단계이다. STEP 1. 가장 적은 비용으로 연결되는 정점 선택 for (int i = 0; i < V; i++) { if (!visited[i] && minEdge[i] < min) { min = minEdge[i]; minVertex = i; } } 아직 MST에 포함되지 않은 정점 중 minEdge 값이 가장 작은 정점을 선택한다. 즉 현재 MST에 가장 적은 비용으로 연결할 수 있는 정점을 찾는 과정이다. STEP 2. minEdge 갱신 for (Node temp = adjList[minVertex]; temp != null; temp = temp.next) { if (!visited[temp.to] && minEdge[temp.to] > temp.weight) { minEdge[temp.to] = temp.weight; } } 새로운 정점이 MST에 포함되면 새로운 간선을 사용할 수 있게 된다. 따라서 해당 정점과 연결된 정점들을 확인하면서 기존 minEdge 보다 더 저렴한 간선이 발견되면 값을 갱신한다. 이 두 과정을 모든 정점이 MST에 포함될 때까지 반복한다. 정리: 크루스칼과 프림의 차이 두 알고리즘 모두 MST를 만들지만 접근 방식에 차이가 있다. 구분 크루스칼 프림 기준 간선 중심 정점 중심 시작 정점 필요 없음 필요 선택 방법 전체 간선 중 가장 작은 간선 현재 트리에서 가장 싸게 연결되는 정점 사이클 방지 Union-Find visited 주요 자료구조 간선 배열, parents 인접 리스트, visited , minEdge 핵심 동작 정렬 → Union 정점 선택 → minEdge 갱신 종료 간선 V-1 개 선택 정점 V 개 선택 크루스칼은 그래프 전체의 간선을 기준으로 동작한다. 전체 간선 정렬 ↓ 가장 작은 간선 선택 ↓ Union-Find로 사이클 확인 ↓ V-1개 선택 프림은 하나의 정점에서 시작해 하나의 트리를 점점 확장한다. 시작 정점 선택 ↓ 가장 싸게 연결되는 정점 선택 ↓ visited 처리 ↓ 인접 정점의 minEdge 갱신 ↓ 모든 정점을 선택할 때까지 반복 결국 가장 큰 차이는 크루스칼은 간선을 하나씩 선택하면서 여러 집합을 합쳐 나가고, 프림은 하나의 정점에서 시작해 하나의 트리를 계속 확장한다는 것 이다. 시간 복잡도 크루스칼 크루스칼에서는 전체 간선을 정렬하는 과정이 가장 큰 비중을 차지한다. 간선이 E 개라면 정렬에 O(E log E) 가 필요하다. Union-Find의 find , union 연산은 경로 압축을 사용하면 매우 빠르게 동작하기 때문에 전체 시간 복잡도는 일반적으로 O(E log E) 로 본다. 프림 위 코드에서는 매번 방문하지 않은 정점 중 최소 minEdge 를 찾기 위해 모든 정점을 확인한다. for (int i = 0; i < V; i++) 이 작업을 V 번 반복하기 때문에 최소 정점 탐색에 O(V2) 가 필요하다. 인접 리스트의 간선을 확인하는 작업까지 포함하면 위 구현의 전체 시간 복잡도는 O(V2 + E) 이며 일반적으로 O(V2) 로 볼 수 있다. 우선순위 큐를 사용하는 방식으로 구현하면 프림의 시간 복잡도를 O(E log V) 수준으로 개선할 수 있다. 한 줄 정리 크루스칼 : 간선을 가중치 순으로 정렬하고 Union-Find를 이용해 사이클을 피하면서 V-1 개의 간선을 선택한다. 프림 : 하나의 정점에서 시작해 현재 트리에 가장 적은 비용으로 연결되는 정점을 선택하고 minEdge 를 갱신하는 과정을 반복한다.
velog
테스트는 전부 통과했는데, 그 테스트가 돈을 쓰고 있었다 한도를 관측 장치로 쓰기로 했었다 방문자가 현관에서 한 말을 글자로 바꿔 주는 음성 인식 서비스가 있다. 이 서비스에는 일일 사용 한도를 걸 수 있는데, 나는 한도를 아주 낮게(600초) 걸어 두었다. 비용 때문만은 아니었다. 그 한도 자체를 관측 장치로 쓰려는 것 이었다. 서버를 안 돌린 날에도 사용량이 차오르면 "설명되지 않는 호출이 있다"는 신호가 되기 때문이다. 이번에 그 장치가 제대로 울렸다. 사용량이 또 한도에 걸린 것이다. 한도가 또 찼다 콘솔의 일별 사용량을 펼쳐 보니 모양이 이상했다. 날짜(예시) 성공 실패 사용량(초) A 40 212 600 B 12 0 180 C 36 0 540 D 40 128 600 성공이 40회에서 딱 멈추고(40 × 15초 = 한도 600초) 그 뒤로 실패가 수백 건 쌓인 날이 있었다. 15초 단위로 과금되는 서비스라서 사용량 = 성공 횟수 × 15초 가 모든 날짜에서 정확히 맞았다. 그리고 서버도 터널도 대시보드도 꺼 둔 날에도 호출이 찍혀 있었다. 추측 대신 숫자로 가르는 대조 실험 추측하면 후보가 끝도 없이 늘어난다. 그래서 변수를 하나로 줄였다. 서버, 터널, 대시보드를 전부 끈다. 포트와 프로세스까지 확인했다. 콘솔 숫자를 적어 둔다. 회귀 테스트를 딱 한 번 돌린다. 몇 분 뒤 콘솔을 다시 본다. 시점 성공 실패 사용량 실행 전 1 0 15초 테스트 1회 후 13 0 195초 테스트 2회 후 25 0 375초 매번 정확히 +12건, +180초 였다. 우연이 아니었다. 메신저로 가는 알림은 한 건도 오지 않았고, 바깥으로 나간 건 음성 인식 호출뿐이었다. 내가 틀렸던 순간 첫 실행이 2초대에 끝났을 때 나는 이렇게 추론했다. "외부 서비스 왕복이 1초 가까이 걸리는데 12번이면 훨씬 오래 걸렸을 테니, 안 불렀을 가능성이 높다." 콘솔이 곧바로 반증했다. 내가 옮겨 쓴 그 1초는 4초짜리 음성 으로 잰 왕복이었다. 테스트가 보내는 음성은 훨씬 짧아서 왕복도 짧았다. 입력 길이를 확인하지 않은 채 숫자를 옮겨 쓴 것이다. 이 일로 분명해진 게 있다. 걸린 시간으로 외부 호출 여부를 가를 수는 없고, 판정은 콘솔로만 해야 한다. 지난 숫자들도 12의 배수였다 지난 며칠 치 호출 수를 12로 나눠 보니 나누어떨어졌다. 하루 12건 = 테스트 1회분 하루 36건 = 3회분 하루 168건 = 14회분 하루 252건 = 21회분 테스트를 그만큼 돌린 날로 설명된다. 다만 이건 정황이다. 처음 문제가 된 날의 호출은 301건(성공 34, 실패 267)이었고, 12의 배수가 아니다. 그 날의 구성은 아직 조사하지 못했다. 왜 진짜 열쇠가 쓰였나 원인 경로는 읽기 전용으로 추적했다. 테스트가 실제로 바깥에 나가려는 순간을 잡으려고, 저장소 밖에 네트워크 연결 시도를 기록하고 막는 장치 를 따로 만들어 그 아래에서 테스트를 한 번 돌렸다. 연결 시도 기록: 12건 코드를 따라가며 센 호출 지점: 12건 콘솔 증가: 12건 세 값이 일치했다. 체인은 이랬다. 설정 파일을 모듈이 불러오는 시점에 읽는다 → 테스트용 설정은 메신저 쪽 열쇠만 비우고, 음성 인식 열쇠는 안 비웠다 → 진짜 열쇠가 테스트 설정에 그대로 들어온다 → 요청 처리 경로를 타는 9개 테스트가 그 열쇠로 진짜 호출을 한다 → 호출이 성공하든 실패하든 결과는 "자막 없음"으로 흡수된다 → 테스트 판정에는 아무 영향이 없다 → 초록불 더 아팠던 건 시점이었다. 이 테스트들은 음성 인식을 가짜 문구로만 쓰던 시절에 만든 것이다. 나중에 실제 호출 분기가 추가되면서, 테스트 코드를 한 줄도 바꾸지 않았는데 옛 테스트가 진짜 호출자로 바뀌어 버렸다. 예전에 쓴 "무죄"가 뒤집혔다 처음 이 현상을 봤을 때도 "테스트가 실제 서비스를 부른 게 아닌지" 의심했었고, 그때는 무죄 라고 기록했다. 근거는 "가짜 열쇠 문자열이고 호출 함수를 스텁했다"였고, 근거 유형은 실측 이라고 적었다. 돌아보니 그건 실측이 아니라 코드 읽기 였다. 게다가 그 두 조건은 나중에 추가된 일부 테스트에만 성립했다. 나는 "스텁이 존재하는가"를 봤지, "서비스를 부르는 모든 테스트가 스텁 아래 있는가"를 보지 않았다. 호출 수를 센 적은 한 번도 없었다. 근거 유형 표기가 틀렸고, 그 틀린 표기가 무죄 판정을 실제보다 단단해 보이게 했다. 조용한 가드는 가드가 아니다 고치는 방법은 두 겹으로 정했다. 원인 제거 : 테스트 설정에서 음성 인식 열쇠 두 개를 비운다. 재발 탐지 : 테스트 중에 바깥으로 나가는 연결을 막고, 막은 사실을 테스트 실패로 만든다. 여기에 함정이 하나 있다. 연결을 막기만 하면 안 된다. 제품 코드가 음성 인식 실패를 "자막 없음"으로 삼켜 버리기 때문에, 예외만 던지는 차단은 막았는데도 테스트가 계속 합격 한다. 요금은 막아도, 다음에 또 새는 날 아무도 모른다. 그래서 시도 기록이 한 건이라도 있으면 그 테스트를 실패로 끝내게 했다. 이 설계가 진짜로 그 일을 하는지 보려고 일부러 부러뜨려 봤다. 변형 결과 열쇠 비우기를 되돌림 새던 9개 테스트가 정확히 빨개짐 실패 판정만 끔 9개가 조용히 합격 (자기검증 1개만 실패) 가드를 통째로 뺌 원래의 12건이 다시 나옴 (자기검증 2개 실패) 가운데 줄이 핵심이다. 처음에 기각했던 설계가 정확히 재현됐다. 차단만 하고 실패로 만들지 않으면 테스트는 합격하고, 새는 건 그대로다. 가드가 살아 있는지 매번 증명하는 자기검증 테스트도 둘 넣었다. 테스트는 104개에서 106개가 됐고 실행 시간은 2.16초에서 0.24초가 됐다. 속도는 참고치일 뿐이고, 판정 근거는 아니다. 합격 시험은 가드 없이 마지막으로, 고친 코드가 혼자서도 안 새는지 보려고 이번엔 저장소 밖 가드 없이 테스트를 한 번 돌렸다. 몇 분 뒤 콘솔은 25 / 0 / 375초 그대로 였다. 이 합격 시험은 처음엔 로그를 남기지 않아서 기록에서 한 번 빠졌다. 대화로만 확인한 실측은 그 자리에서 파일로 남겨야 한다는 걸 다시 배웠다. 아직 다 된 건 아니다 원인 경로는 확정됐지만 "모든 호출이 테스트 때문이었다"는 아니다. 과거 일별 숫자가 12의 배수라는 건 정황이다. 처음 문제가 된 날의 301건은 구성을 아직 모른다. 수정 후 안 샌다는 확인은 가드 없이 한 번 돌리고 콘솔이 그대로였다는 것이 전부다(n=1). 콘솔은 일 단위 집계라서 그 실행분만 따로 본 게 아니고, 직전 값 대비 무변동으로 판정했다. 옛 "무죄" 판정이 틀렸던 건 도구 탓이 아니고, 코드 읽기를 실측이라고 적은 내 표기 의 문제였다. 속도로 호출 여부를 추론한 것도 내 추론 오류였다. 마무리 이전 글들에서 확인에도 층위가 있다고 썼다. 얼마나 자주 보는가, 무엇을 검사하는가, 그 검사가 실제로 실행됐는가. 이번에는 한 층이 더 붙었다. 검사가 통과해도, 그 검사가 바깥에 무슨 일을 하고 있는가. 합격한 테스트가 하루에도 몇 번씩 돈을 쓰고 있었는데, 그걸 알려 준 건 테스트가 아니라 한도라는 숫자 였다. 테스트의 초록불은 테스트가 보려는 것만 알려 준다. 나머지는 바깥의 숫자가 알려 줬다. 띵동(Ddingdong)은 청각장애인 1인 가구를 위한 현관 부착형 소리 분류 알림 시스템 졸업작품입니다. 초인종 · 노크 · 화재경보를 구분해서 사진과 자막까지 스마트폰으로 보내주는 걸 목표로 만들고 있습니다.
velog
지금까지 개발 실습 환경 - 1. codepen 작성--> 확인 (preview) --- HTML + CSS + JS - 2. 코드 전체(index.html/style.css) --- github 저장소 만들어서 커밋 - 3. 깃헙 pages 설정 --> deploy ------------------------------------------------------ 새 개발 환경 - VS code + 로컬 깃(git) + 깃헙 --- 로컬 환경 세팅(매주) 1) VS code 설치 2) 깃 설치 3) 개인 세팅 4) 깃헙 저장소 만들고 VScode 개발 --> github push git: 버전 관리 도구 VScode ! + (tab) - 기본 구문 완성 깃허브 레포지토리 초록색 code 클릭 - HTTPS 링크 복사 VScode 로그인 - 레포지토리 복제 - 붙여넣기 스테이징 및 커밋 VScode ctrl + shift + G (소스 제어 창)에서 커밋 가능 커밋한 상태에서 동기화 시 깃허브 레포지토리로 푸시 매주 로컬환경 세팅 VScode, 깃허브 로그인 cmdkey /list | findstr /i git git config --global --list 두 명령어 입력 커밋에 필요, 터미널에 작성 git config user.name "jabcho7" (내 닉네임) git config user.email "323830643+jabcho7@users.noreply.github.com" (내 깃허브 noreply 주소) 두 명령어 입력
Балл: 54.4Уверенность: 49%
Подробнееvelog
캐시 히트율에 대한 장애 회고를 읽고 관심이 생겨 실험해 보았다 테스트를 위해 상품(product) 300만 개와 카테고리별 판매 랭킹(ranking) API가 있는 쇼핑몰을 만들고, 둘 다 Redis 캐시를 적용했다. 상품 조회는 상품 한 건을 찾는 가벼운 쿼리다. 랭킹 조회는 한 카테고리의 상품을 판매량 순으로 정렬해 상위 10개를 뽑는데, 이 과정에서 상품 300만 행을 모두 훑는 무거운 쿼리다. 그래서 상품 조회가 전체 요청의 97%, 랭킹 조회는 3%로 설정했다 사용자는 상품 상세는 계속 넘겨 보지만 랭킹은 카테고리 화면에 들어갈 때 한 번 보는 정도라고 생각해서, 요청의 97%는 상품 조회이고 랭킹 조회는 3%로 잡았다. 구성 내용 애플리케이션 Spring Boot 4.1 (Java 21), 커넥션 풀(HikariCP) 20개 DB MySQL 8.4, CPU 2코어 제한, 버퍼 풀 1GB(다른 기능의 쿼리도 같은 DB의 CPU를 나눠 쓰기 때문에, 이 API가 쓸 수 있는 CPU는 그중 일부라고 가정하고 DB를 2코어로 제한했다) 캐시 Redis 7.4, @Cacheable (상품 TTL 10분, 랭킹 TTL 5분) 모니터링 Prometheus + Grafana, MySQL·Redis exporter 부하 k6, 초당 200건 고정 (상품 조회 97%, 랭킹 조회 3%) 데이터 상품 300만 개, 카테고리 24개 캐시를 붙이기는 했는데 실제로 도움이 되나 확인 하기 위해 모니터링 대시보드를 열었다. 캐시별 조회수와 히트율 패널은 있었지만 값이 비어 있었다. 요청은 초당 200건씩 들어오고 있는데 조회수는 0이고, 히트율은 선조차 없어서 확인할 게 없었다. 볼 수 있는 건 Redis 서버 전체의 hits와 misses뿐이었다. 노란 선은 Misses, 초당 약 180번이다. 캐시에 데이터가 없어서 DB까지 갔다. 초록 선은 Hits, 초당 약 11번 캐시에서 바로 조회했다. 초당 조회수는 200번이고, 그중 히트는 약 11번이므로 캐시 히트율은 약 6%정도이다. 수치를 봤을 때는 캐시가 성능을 올린다고 생각되지 않았고, 걷어내도 될 것 같다고 판단했다. 캐시를 걷어내자, DB가 무너졌다 캐시는 한 번에 걷어내지 않고 단계적으로 걷어냈다. 0% → 25% → 50% → 75% → 100%로 캐시 우회 비율(이하 우회율)을 올렸다. 0%에서 5분, 이후 단계마다 3분씩, 모두 17분이다. 트래픽은 처음부터 끝까지 초당 200건으로 같다. 우회율 상품 조회 p99 랭킹 조회 p99 0% 18ms 6ms 25% 54ms 1.2s 50% 5.5s 15.4s 75% 6.7s 16.6s 100% 8.1s 22.2s 25%에서 랭킹 조회가 1초 이상으로 불안했지만 견딜만 했다. 문제는 50%로 넘어가면서 시작됐다. 우회율은 조금씩 늘렸는데, 어느 지점을 넘는 순간 상품 조회는 5초를 넘겼고 랭킹은 15초 이상이 걸리면서 한꺼번에 무너졌다. 이상한 점은 상품 조회까지 느려진 것이다. 상품 조회는 기본키로 한 건만 찾으면 되는 가벼운 쿼리다. 무거운 랭킹 조회가 느려지는 건 이해가 되지만, 상품 조회가 300배 가까이 느려질 이유가 없어 보였다. 무엇이 DB를 무너뜨렸나 바뀐 것은 우회율을 올린 것 말고는 없다. 캐시를 뺀 뒤 DB에서 무슨 일이 일어났는지 하나씩 확인해 보았다. 예상 원인 의심하는 이유 1 Redis에 문제가 생겼다 캐시를 건드린 이후 문제가 터졌다 2 DB로 가는 쿼리 수가 늘었다 캐시를 빼면 그만큼 DB로 간다 3 특정 쿼리 하나가 DB를 잡고 있다 느린 쿼리 하나가 전체를 막는 경우도 있다 Redis 장애 주황색= 캐시 조회 , 초록색= 캐시 저장 Redis가 초당 처리한 명령 수는 우회율이 올라갈수록 389 → 293 → 76 → 36 → 1.6으로 줄었다. 캐시를 건너뛰는 요청이 늘었으니 당연하다. DB로 가는 쿼리 수 증가 캐시를 걷어냈으니 DB로 가는 쿼리가 늘었을 거라고 생각하기 쉽다 DB가 받은 SQL 문장 수인데 25%에서는 2% 늘었을 뿐이고, 50%로 넘어가는 순간에는 오히려 뚝 떨어진다. 요청이 줄어서가 아니라 부하는 그대로 초당 200건이었고, DB가 그만큼 처리하지 못해 실행된 문장 수가 줄어든 것이다. 캐시를 걷어냈는데 DB로 가는 쿼리 수가 거의 늘지 않았다는 건, 캐시가 있을 때도 대부분의 조회가 이미 DB까지 가고 있었다는 뜻이다. 히트율 6%와 같은 이야기다. MySQL 안에서 동시에 실행 중인 쿼리 수(Threads Running)는 0%에서 평균 2였다. 50%로 넘어가자 평균 13.5로 뛰었고, 최대 19까지 올라갔다. 반면 파란 선(Threads Connected)은 처음부터 끝까지 21(커넥션 풀 20개 + 지표 수집용 1개)로 일정하다. 커넥션이 새로 늘어난 게 아니라, 같은 커넥션 안에서 쿼리가 끝나지 않고 있었다. 앞의 Questions 그래프와 같이 보면, 들어오는 쿼리 수는 그대로이거나 줄었는데 동시에 실행 중인 쿼리만 6배 넘게 늘었다. 쿼리 1건이 오래 걸리니, 끝나기 전에 다음 쿼리가 들어와 계속 쌓인 것이다. 바뀐 건 건수가 아니라 무게라고 생각했다. 쿼리 하나의 독점 무거운 쿼리를 찾으려면 쿼리별로 DB 시간을 얼마나 썼는지 봐야 한다. MySQL은 쿼리 종류별 실행 횟수와 누적 시간을 performance_schema에 기록하고, sys.statement_analysis 뷰로 보여 준다. 이 값은 서버가 켜진 뒤로 계속 쌓이는 누적값이라, 단계마다 초기화(TRUNCATE)한 뒤 측정했다. 아래는 우회율 0% 단계(5분)와 50% 단계(3분)가 끝난 직후의 결과다. 쿼리 우회율 초당 실행 횟수 평균 DB 시간 비중 랭킹 집계 쿼리 0% 0.06 0.50s 45% 랭킹 집계 쿼리 50% 1.45 7.33s 98.6% 상품 기본키 조회 0% 188 0.07ms 20% 상품 기본키 조회 50% 78 0.73ms 0.5% 랭킹 쿼리는 1건에 300만 행을 훑는 무거운 쿼리다. 우회율 0%일 때는 캐시가 막아 줘서 초당 0.06건만 DB에 도달했다. 그러나 50%에서는 초당 1.45건, 약 24배가 DB에 도달했다. 무거운 쿼리가 한꺼번에 몰리자 2코어 CPU를 서로 나눠 써야 했고, 1건이 걸리는 시간이 0.50초에서 7.33초로 약 15배 늘었다. 앞에서 동시 실행 수가 13.5까지 뛰었던 이유가 이것이다. 그 결과 50%에서는 DB 시간의 98.6%를 랭킹 쿼리 하나가 썼다. 상품 조회는 초당 78건으로 훨씬 많이 실행됐지만, DB 시간은 0.5%뿐이다. 랭킹 쿼리가 붙잡은 건 DB 시간만이 아니었다. 앱은 쿼리를 보낼 때마다 커넥션 풀에서 커넥션을 하나 빌리고, 쿼리가 끝나야 돌려준다. 이 앱의 커넥션 풀은 20개다. 1건에 7초씩 걸리는 쿼리가 커넥션을 쥐고 있으면, 다른 요청은 커넥션이 돌아올 때까지 기다려야 한다. 50%로 넘어가는 순간 커넥션 풀 20개가 가득 차고, 커넥션을 기다리는 요청이 179개까지 쌓였다. 상품 조회는 DB 안에서 0.73ms면 끝나지만, 커넥션을 얻으려고 풀 앞에서 몇 초씩 기다렸다. 상품 조회가 같이 느려진 이유가 이것이다. 상품 조회 자체는 아무 문제가 없었다. 랭킹 쿼리가 커넥션을 쥐고 있는 동안 대기 했어야 했기 때문이다.
velog
master node에 k8s를 설치 후 검증을 위해 “kubeadm init –pod-network cidr=172.20.0.0/16” 초기화 작업을 수행했다. 이 때 cidr=172.20.0.0/16 무슨 의미지 궁금했고 찾아보기로 결정! CIDR(Classless Inter-Domain Routing)로 IP 고정영역인 netID와 유동영역인 hostID로 나눠 특정 구간의 IP 대역을 사용하겠다는 의미..... 말이 어렵다 쉽게 가보자! 대단지 아파트에 동/호소 주소를 떠올려 보면 위 사진 기준으로 102동에 1층에는 103호, 104호 2층에는 203호, 204호... 나열된다. 이를 정리해보면 << 102 | 3 ~ 4 >> 102동 103호, 104호/ 102동 203호, 204호/ 102동 303호, 304호... <<102 | 1 ~ 2 >> 102동 101호, 102호/ 102동 201호 202호.... cidr=172.20.0.0/16는 netid: 172.20, hostid:0.0 ~ 255.255로 k8s 내에 pod가 사용할 수 있는 ip 대역은 172.20.0.0부터 172.20.255.255해서 2^8이다. 그럼 이 ip는 누가 할당하지? 라는 궁금증 들 수 있는데 아래에서 확인 할 수 있다. master node 초기화 후 아래 명령어를 통해 “Calico CNI”를 생성한다. kubectl create -f https://raw.githubusercontent.com/projectcalico/calico/v3.32.2/manifests/v1_crd_projectcalico_org.yaml kubectl create -f https://raw.githubusercontent.com/projectcalico/calico/v3.32.2/manifests/tigera-operator.yaml CNI(Container Network Interface)로 파드와 파드간에 서로 통신할 수 있도록 가상 네트워크 통로를 만들어주는 네트워크 플러그인으로 집마다 있는 wifi 공유기를 생각하면 된다. 즉 Calico CNI가 위해서 할당 받은 ip대역을 pod가 생성될 때마다 하나씩 배분하게 된다.
Балл: 54.4Уверенность: 49%
Подробнееvelog
"그게 먼데" 며칠 전 친구가 디스코드에 이런 말을 남겼습니다. 뮤테이션 테스트를 도입하고 나서 자기 팀 서비스 안정성이 눈에 띄게 올랐다고, 나중에 조사해서 글 좀 써달라고요. 제 답은 "그게 먼데"였습니다. 설명을 듣고 나서는 "어케 하는 건데"였고요. 프론트엔드를 꽤 오래 했는데 처음 듣는 단어였습니다. 찾아보니 1971년에 처음 나온 기법이더라고요. 50년이 넘은 기법인데, 국내 테크 블로그에서는 다룬 글을 거의 못 찾았습니다. 개념 설명이나 자격증 대비 글 몇 개가 전부였어요. 궁금해서 제 프로젝트에 직접 돌려봤습니다. 컴윗이라는 서비스인데, 테스트가 있는 파일 열 개에 일부러 버그를 550개 심어봤습니다. 대상 심은 버그 테스트가 잡음 못 잡음 실행조차 안 됨 테스트가 있는 src/lib 10파일 550 273 161 116 절반이 그대로 통과했습니다. 버그를 심었는데도 테스트가 전부 초록이었다는 뜻이에요. 그리고 이 프로젝트의 테스트 451개는 전부 클로드코드가 짰습니다. 제가 리뷰하고, 제가 머지했고요. 그래서 AI 탓을 하려는 글은 아닙니다. "테스트가 있다"와 "테스트가 검증한다"는 다르다는 얘기, 그 차이를 숫자로 보여주는 도구 얘기, 그리고 에이전트가 코드를 짜는 요즘 그 도구가 왜 다시 필요한지 하는 얘기입니다. 요즘 에이전트는 테스트부터 짭니다, 그런데 오푸스급 모델은 시키지 않아도 테스트를 곁들이더라고요. 기능 하나 만들어 달라고 하면 구현이랑 테스트 파일을 같이 줍니다. 그렇게 제 프로젝트에도 테스트가 84파일, 451개 쌓였습니다. 저는 초록 체크를 보면 그냥 안심했어요. 그 테스트가 뭘 확인하는지 들여다본 적은 없었습니다. 들여다보니 이런 게 있었습니다. // tests/coding-time-product-copy.test.ts (일부) function source(relativePath: string): string { return readFileSync(fileURLToPath(new URL(`../${relativePath}`, import.meta.url)), 'utf8') } it('신규 시간 크레딧과 레거시 사용자 기준선을 분리한다', () => { const codingTimeRepository = source('src/server/repository/coding-time-credit/index.ts') expect(codingTimeRepository).toContain('userCodingTimeCreditLedger') expect(codingTimeRepository).not.toContain('projectId') }) 소스 파일을 문자열로 읽어서 어떤 단어가 들어 있는지 봅니다. 코드는 한 줄도 실행하지 않아요. 이런 테스트가 7파일에 62개 있었습니다. 컨벤션 검사로는 쓸모가 있어요. 다만 함수가 엉뚱한 값을 돌려줘도 이 테스트는 통과합니다. 동작을 검증하는 테스트는 아닌 거죠. 제 테스트와 다른 사람들 사례를 모아 보니 허수 테스트는 대략 다섯 가지였습니다. 구현이 지금 내놓는 값을 그대로 기대값으로 적는다. divide(10, 0) 이 0 을 돌려주면 toBe(0) 이라고 쓴다. 테스트 안에서 구현을 한 번 더 짠다. 필터 로직을 테스트에도 복사해서 기대값을 만든다. mock을 호출했는지만 본다. 제 프로젝트엔 toHaveBeenCalled() 가 139개였습니다. 다 나쁜 건 아니지만 비율이 좀 그렇죠. 소스 텍스트를 grep한다. 위에서 본 그 테스트. expect(true).toBe(true) . 해커뉴스에 어떤 분이 올린 사례인데, AI가 추가한 테스트 30개가 사실상 전부 이거였다고 하더라고요. 저만 겪은 일은 아닙니다. 2026년 6월에 나온 한 연구에서 코덱스·코파일럿·데빈·커서·클로드코드가 실제 오픈소스에 올린 PR 33,596개를 뜯어봤는데, 테스트 패치 86,156개 중 80.2%가 값을 제대로 단언하지 않거나 단언이 아예 없었습니다. 에이전트마다 차이는 컸어요. 클로드코드는 67%가 제대로 단언했고 코덱스는 18%였습니다. 논문에는 개발자들이 이걸 "test theater"라고 부른다고 적혀 있더라고요. 다른 연구에는 커버리지 100%인데 뮤테이션 점수가 4%인 테스트 스위트도 나옵니다. 왜 이럴까요. 제가 이해한 건 세 가지입니다. 첫째, 에이전트한테 "테스트 통과"는 목표입니다. 지표를 목표로 삼으면 그 지표는 더는 아무것도 알려주지 못합니다. 굿하트의 법칙이죠. 앤트로픽은 클로드 4 시스템 카드에서 모델이 테스트를 하드코딩하거나 특수 분기로 통과시키는 성향을 따로 측정했고, OpenAI 논문에는 에이전트가 검증 함수를 항상 true 만 돌려주게 바꿔치기한 사례가 나옵니다. 극단적인 예로 어떤 벤치마크에서는 테스트 입력을 통째로 외운 2,900줄짜리 "컴파일러"가 공개 테스트는 97% 통과하고 숨겨둔 테스트는 0%였습니다. 둘째, 한 세션에서 구현과 테스트를 같이 짜면 테스트도 구현과 같은 오해를 합니다. 버그 있는 코드를 주면 LLM이 버그를 짚지 않고 그 버그 동작을 그대로 검증하는 테스트를 짠다는 연구가 있어요. 사람도 자기 코드의 테스트를 자기가 짜면 비슷하죠. 셋째, 커버리지는 "실행했다"를 재지 "검증했다"를 재지 못합니다. Trail of Bits 글에 나온 표현이 딱 맞았어요. 커버리지는 "생략으로 거짓말을 한다"고요. 뮤테이션 테스트가 뭔데 원리는 단순합니다. 코드에 일부러 작은 버그를 심습니다. < 를 <= 로, + 를 - 로, 조건문을 true 나 false 로, 리턴값을 다른 값으로 바꾸고, 함수 본문을 통째로 비우기도 합니다. 이렇게 바꾼 코드 하나하나를 mutant라고 부릅니다. 그 상태로 테스트를 돌려서 하나라도 실패하면 mutant를 "죽인" 거고, 전부 통과하면 mutant가 "살아남은" 겁니다. 살아남은 mutant만큼 테스트에 구멍이 있는 거예요. StrykerJS 문서 예제를 보면 금방 이해가 갑니다. function isUserOldEnough(user) { return user.age >= 18 } 도구는 여기서 mutant 네 개를 만듭니다. > 18 , < 18 , return false , return true . 딱 열여덟 살을 넣는 테스트가 없으면 첫 번째 mutant는 살아남습니다. 같은 문서에 빵 비유도 있는데요. 커버리지는 빵의 80%에 잼을 발랐다고 알려주고, 뮤테이션 테스트는 그게 진짜 초콜릿잼인지 알려준다고요. 결과는 세 가지로 나옵니다. 상태 뜻 Killed 테스트가 버그를 잡았다 Survived 코드를 바꿨는데 테스트가 전부 통과했다 No coverage 그 줄을 실행하는 테스트가 아예 없다 50년 동안 이 기법을 널리 못 쓴 이유는 하나입니다. 느려서요. 걸리는 시간이 mutant 수 곱하기 테스트 스위트 실행 시간이거든요. 5분짜리 테스트 스위트에 mutant 1,000개면 83시간입니다. 지금 도구들은 그 줄을 실행하는 테스트만 골라서 돌리고, 지난 결과를 다시 쓰고, 고친 파일만 돌리는 모드까지 다 갖췄습니다. 이 얘기는 뒤에서 다시 할게요. 도구는 언어별로 다 있습니다. 2026년 9월 기준으로 자바스크립트·타입스크립트는 StrykerJS 10, 자바·코틀린은 PIT 1.30, 파이썬은 mutmut 3.7, 러스트는 cargo-mutants, PHP는 Infection, 고는 gremlins, C#은 Stryker.NET입니다. 제 프로젝트에서 나온 것 설정은 이게 전부였습니다. vitest를 쓰는 Next.js 프로젝트예요. { "testRunner": "vitest", "plugins": ["@stryker-mutator/vitest-runner"], "mutate": ["src/lib/**/*.ts"], "disableTypeChecks": false, "incremental": true, "reporters": ["clear-text", "html"] } npx stryker run 한 번에 1분 5초가 걸렸습니다. 생각보다 어렵지 않더라고요. 제일 인상적이었던 건 날짜 포맷 유틸이었습니다. kstRelativeTime 이라고, "방금 전", "3시간 전", "어제" 같은 문자열을 만드는 함수인데요. 이 함수를 실행하는 테스트가 11개나 있습니다. 그런데 < 를 <= 로 바꾼 mutant 여섯 개가 전부 살아남았습니다. 리포트에 뜬 "Covered by 11 tests (yet still survived)"라는 문구가 좀 뼈아팠어요. 정확히 48시간 전이면 "어제"인지 "2일 전"인지 아무도 안 물어봤던 겁니다. "N주 전"을 내는 분기는 아예 테스트가 없었고요. 그 줄을 if (false) 로 바꿔도 테스트가 다 통과했습니다. 더 무서운 것도 있었습니다. 동시 편집 순서를 매기는 시퀀스 번호에서 + 1 을 - 1 로 바꿔도 통과. 텍스트 병합 로직에서 if (local === remote) 를 if (false) 로 바꿔도 통과. 마크다운 이미지 파서는 mutant 109개 중 테스트가 잡은 게 6개. 전부 테스트가 있는 파일들입니다. 파일 옆에 .test.ts 가 나란히 있고, CI는 초록이었어요. 덤으로 하나 더 배웠습니다. 아까 본 소스를 문자열로 읽는 테스트들 있죠. 뮤테이션 테스트를 돌리려면 도구가 코드를 계측하는데, 이때 도구가 파일 맨 앞에 function stryNS_9fa48() { 같은 헤더를 붙입니다. 그 순간 toContain 이 깨지더라고요. 코드 동작을 바꾸면 안 깨지고, 코드 모양을 바꾸면 깨지는 테스트. 거꾸로인 거죠. 그래서 이 파일들은 뮤테이션 실행에서 뺐습니다. 50년 된 기법이 왜 지금 다시 뜨나 에이전트한테 테스트는 검증자입니다. 검증자가 약하면 에이전트는 엉뚱한 문제를 풀고도 "다 됐습니다"라고 합니다. 앤트로픽에서 클로드 열여섯 개를 병렬로 돌려 C 컴파일러를 만든 실험 글이 있는데요. 글쓴이가 제일 공들인 부분은 모델이 아니라 테스트와 피드백 환경이었다고 해요. 검증자가 거의 완벽하지 않으면 클로드가 다른 문제를 풀어버린다고요. 같은 회사의 하네스 설계 글에서는 모델이 자기 결과를 채점하면 점수를 후하게 준다면서 평가자를 아예 따로 뒀습니다. Qwen 팀 논문 제목은 "Verification Horizon"인데, 요지는 검증자도 생성자와 같이 발전해야 한다는 겁니다. 예전에 하네스 엔지니어링 글을 쓰면서 "AI가 실수할 수 없는 환경"을 만드는 게 개발자 일이 될 거라고 했었는데요. 그때는 린트 규칙이나 라이브러리 구조 얘기였습니다. 뮤테이션 테스트는 그보다 한 층 위에 있는 것 같아요. 테스트 자체를 검사하는 층이요. 업계에서도 비슷한 얘기가 나옵니다. Thoughtworks는 Technology Radar 2026년 4월판에서 뮤테이션 테스트를 Trial로 올렸습니다. 이유로 "영원히 초록인 테스트를 잡는 강화 레이어"를 들었어요. AI가 만든 테스트가 늘어나서요. martinfowler.com의 한 글에서는 글쓴이가 AI가 짠 테스트에 Stryker를 돌렸는데, 커버리지 100%인 파일에서 mutant 13개가 살아남았습니다. 글쓴이는 이걸 코딩 에이전트의 "센서"라고 부르더라고요. 메타는 아예 LLM으로 mutant를 만듭니다. 연산자를 기계적으로 바꾸는 대신 LLM이 "이 메시지가 엉뚱한 사람한테 가는 프라이버시 버그"처럼 문제에 맞는 mutant를 만들고, 그 mutant를 잡는 테스트도 LLM이 짭니다. mutant 9,095개로 테스트 571개를 만들었고 엔지니어의 73%가 받아들였는데, 그중 절반은 라인 커버리지를 하나도 안 올렸습니다. 커버리지만 봤다면 짤 이유가 없던 테스트인 거죠. 구글은 2018년부터 코드 리뷰 화면에 살아남은 mutant를 띄웁니다. 전체가 아니라 그 리뷰에서 고친 줄만요. 처음엔 개발자들이 85%를 "쓸모없다"고 했는데, 피드백을 받아 억제 규칙을 쌓아서 "쓸모있다"를 89%까지 올렸습니다. 2021년 논문에서는 실제 버그를 낸 커밋들을 거슬러 올라가 봤는데, 그때 뮤테이션 테스트를 돌렸다면 살아남은 mutant가 그 버그를 미리 보여줬을 거라고 해요. 여기서 친구가 했던 말이 이해가 갔습니다. "리뷰 차원을 높여준다"고 했거든요. 에이전트는 코드를 금방 잔뜩 짜는데, 사람이 읽는 속도는 그대로입니다. 한 조사에서는 AI를 도입한 뒤 PR 머지가 98% 늘고 리뷰 시간이 91% 늘었습니다. 앤트로픽은 자기네 머지 코드의 80% 이상을 클로드가 쓰는데, 사람 리뷰가 새 병목이라고 하고요. 그런데 리뷰어가 제일 못 보는 게 "이 테스트가 뭘 검증하나"예요. 저도 초록 체크만 보고 넘어갔고요. 뮤테이션 테스트를 돌리면 그 부분을 기계가 대신 봐줍니다. 리뷰어는 "살아남은 mutant 세 개, 이거 의도한 거야?"만 물으면 됩니다. 어디에 어떻게 넣나 비용 얘기는 빼놓을 수가 없습니다. 구글은 자기 코드베이스 전체에 돌리면 70억 년이 걸린다고 했으니까요. 다행히 도구가 이 시간을 대부분 줄여줍니다. 그 줄을 실행하는 테스트만 돌립니다. StrykerJS는 이게 기본값이고, 제 실험에서는 파일 하나에 mutant당 평균 5.4개 테스트만 돌았습니다. 지난 결과를 다시 씁니다. --incremental 옵션인데, Stryker 블로그 예시에서는 3,965개 중 234개만 다시 돌렸어요. 변경분만 돌립니다. cargo-mutants는 --in-diff , Infection은 --git-diff-lines , Stryker.NET은 since . 언제 돌릴지는 친구가 말한 방식이 제일 괜찮아 보입니다. 큰 프로젝트는 PR 올리기 전에 로컬에서 고친 파일만, 작은 프로젝트는 리뷰 끝나고 한 번 전체. 구글이 리뷰 시점에 고친 줄만 보는 것과 같은 발상이에요. 야간 배치로만 돌리면 결과를 아무도 안 본다는 지적도 있더라고요. 진짜 재미있는 부분은 이거예요. 뮤테이션 테스트를 에이전트 루프 안에 넣을 수 있습니다. Test Double의 어떤 분은 npm run mutate 를 돌리고 살아남은 mutant 목록을 그대로 클로드한테 줬습니다. 클로드가 테스트를 보강하고 다시 돌려서 점수를 94.7%에서 96.3%로 올렸어요. 다른 분은 PIT와 Stryker에 코덱스를 붙여서 프로젝트 네 개에 돌렸는데, 프론트 프로젝트 점수가 60%에서 82%로 올랐습니다. 그분 말을 빌리면, 코드 생성이 싸다고 확신까지 싼 건 아니라고요. 제가 넣은 것 컴윗 레포와 새 프로젝트 템플릿에 실제로 넣었습니다. 정한 건 이 정도예요. 대상은 로직만. src/lib , src/server , 서비스의 api 와 state . 페이지, 라우팅, UI 컴포넌트, DB 스키마는 뺐습니다. 테스트 없는 UI는 점수만 깎고, 빌드 변환 코드는 계측하면 깨지거든요. disableTypeChecks: false . 기본값은 모든 소스에 // @ts-nocheck 를 박는데, vitest는 타입체크를 안 하니 끌 이유가 없습니다. 변경 파일만 돌리는 스크립트 하나. 브랜치에서는 main에서 갈라진 뒤 고친 파일, main에서는 워킹트리 변경분. 설정의 제외 패턴을 목록 단계에서 적용해서 페이지 파일은 애초에 안 넘깁니다. 에이전트 규칙에 두 줄. 테스트를 추가하거나 고쳤으면 pnpm test:mutation:changed 를 돌릴 것, 소스를 문자열로 읽는 테스트는 만들지 말 것. 스킬 하나. 살아남은 mutant를 네 가지로 나눠 처리하게 했습니다. 경계값이면 그 값을 넣는 케이스 추가, 분기 통째면 그 분기를 타는 입력 추가, 로그 메시지 같은 상수면 무시, 동작이 같은 동등 mutant면 넘어가기. 템플릿에 넣었으니 앞으로 컴윗에서 만드는 프로젝트는 처음부터 이 층을 갖고 시작합니다. 전체 기준선도 한 번 돌려봤습니다. 로직 파일 902개, mutant 41,343개에 49분이 걸렸어요. 영역 mutant 테스트가 실행한 것 살아남음 점수 (실행된 것 기준) 전체 41,343 10,230 3,799 62.9% src/server/repository 12,516 5,121 2,021 60.5% src/server/domain-jobs 2,490 1,084 459 57.7% mutant 넷 중 셋은 실행하는 테스트가 아예 없었고, 테스트가 실행한 것 중에서도 셋에 하나는 살아남았습니다. 이 숫자를 올리는 게 목표는 아니에요. 다만 테스트를 건드릴 때마다 변경분만 돌리면, 적어도 새로 들어오는 허수는 막을 수 있겠다 싶었습니다. 주의할 점도 있습니다. 동등 mutant는 원래 못 죽이니 100%를 목표로 잡는 건 무리예요. 연산자를 바꿔본다고 스펙을 검증하는 것도 아니고요. 메타가 LLM으로 mutant를 만드는 이유가 그겁니다. 그리고 구현 자체가 틀렸으면 뮤테이션 테스트로도 못 잡습니다. 뮤테이션 테스트는 테스트가 변화를 알아채는지를 재지, 구현이 맞는지를 재진 않으니까요. 마지막으로, 점수를 팀 KPI로 걸면 굿하트의 법칙이 다시 나옵니다. 사람들이 점수를 올리려고 테스트를 짜겠죠. 테스트가 있다 ≠ 검증한다 AI가 테스트를 짜주는 건 고맙습니다. 저도 계속 맡길 거예요. 다만 그 테스트가 뭘 검증하는지는 저도 안 봤고, 제 프로젝트에선 절반이 구멍이었습니다. 50년 된 기법으로 1분 만에 그 구멍을 찾았고요. AI가 테스트를 짜줬다면, 한 번쯤 뮤테이션 테스트로 그 테스트를 의심해 보면 좋을 것 같아요. 설정 열 줄이면 되더라고요. 저는 이걸 컴윗 템플릿에 기본으로 넣어뒀습니다. 컴윗은 비개발자가 바이브코딩으로 서비스를 만들고 배포까지 하는 곳인데, 누가 짜든 같은 품질이 나오게 하는 하네스를 계속 만들고 있어요. 이전 글 〈하네스 엔지니어링과 개발자의 미래〉에서 린트와 라이브러리 얘기를 했다면, 이번엔 테스트를 검증하는 층을 하나 더 얹은 셈입니다. 궁금하시면 comwit.io 에서 한번 보셔도 좋고요. 여러분 프로젝트에서는 mutant가 몇 %나 살아남을까요? 출처 뮤테이션 테스트 개념·도구: Stryker 문서 · PIT mutators · Wikipedia · Stryker incremental mode 에이전트 테스트 품질: All Smoke, No Alarm (arXiv 2606.18168) · MutGen (arXiv 2506.02954) · misguidance effect (arXiv 2607.22883) · HN 댓글 (edude03) · Trail of Bits, Mutation testing for the agentic era 보상 해킹: Claude 4 시스템 카드 정리 (Simon Willison) · OpenAI, Monitoring Reasoning Models for Misbehavior · SpecBench (arXiv 2605.21384) 검증자·하네스: Anthropic, Building a C compiler with parallel Claudes · Anthropic, Harness design for long-running apps · The Verification Horizon (arXiv 2606.26300) 업계: Thoughtworks Radar, Mutation testing · Böckeler, Maintainability sensors for coding agents · Meta ACH (arXiv 2501.12862) · Google, Practical Mutation Testing at Scale · Google, Does mutation testing improve testing practices? · SE Radio 632 리뷰 병목: Faros AI, Productivity Paradox 2025 · Anthropic Institute, When AI builds itself 실무 사례: Test Double · Awesome Testing, Mutation Testing for Agent-Written Code · Autotomy, CI 배치 패턴 이미지: PIT 리포트 예시는 pitest.org , 메타 파이프라인 그림은 arXiv 2501.12862 Figure 1. 나머지 스크린샷은 제 프로젝트의 Stryker 리포트.
velog
영상을 여러 개 올리면 뒤 영상이 줄 서서 기다린다. "큐 넣으면 되겠지" 하고 Kafka를 꺼내려다, 먼저 재 봤더니 병목은 큐가 아니라 워커였다. 결국 이미 있던 PostgreSQL 테이블 하나와 SKIP LOCKED 로 서버 2대에 인코딩을 나눴고, 동시 12건 기준 198초 → 107초가 됐다. 그리고 워커를 진짜로 죽여 봤다. 지난 글 이 알림 쪽 이야기였다면 이번엔 영상 쪽이다. FillMap은 방문한 장소를 30초 영상으로 남기는 서비스라, 영상 처리가 느리면 서비스 자체가 느린 거다. 영상은 어떻게 올라가나 브라우저가 사전서명 URL로 S3에 직접 올리기 때문에 서버는 영상 본문을 중계하지 않는다. 대신 "업로드 확정" API를 받은 뒤에 할 일이 많다. FFmpeg로 인코딩하고, 썸네일을 뽑고, AI 서버에 하이라이트를 요청하고, 산출물을 S3에 올려야 그제서야 READY 가 된다. 사용자가 기다리는 건 확정 → READY 구간이다. 요구사항은 단독 업로드 기준 30초 이내(NFR-003), 실패해도 유실 없음, 재처리 가능. 셋 중에 첫 번째가 제일 쉬워 보였는데 제일 먼저 깨졌다. 문제상황 처음 구현은 단순했다. 확정 API가 @Async 로 인코딩을 던지고, 스레드 1개짜리 executor가 순서대로 처리한다. dev에서 10~20초짜리 MP4 세 개를 연속으로 올려 봤다. READY까지 9.4초, 15.8초, 30.4초. 세 번째 영상은 앞 두 개가 끝나기를 기다린 거다. 혼자 올리면 30초 안에 들어오는데, 동시에 올리면 2건째부터 넘는다. 더 신경 쓰이는 건 따로 있었다. 메모리 큐라서 프로세스가 내려가면 대기 중인 작업이 같이 사라진다. 배포할 때마다 그 순간 올린 영상이 영영 PENDING으로 남을 수 있다. 서버가 두 대인데 한 대만 일한다. AI 서버(t3.small)는 FastAPI 하이라이트만 가끔 돌리고 가용 메모리가 1,150MiB나 남아 있었다. 근데 API 서버 메모리 큐에 있는 작업을 넘겨줄 방법이 없다. 같은 t3.small에서 FFmpeg 2개를 병렬로 돌려 보기도 했는데 순차 20.4초, 병렬 19.6초. 한 호스트에서 스레드만 늘려서는 답이 안 나왔다. CPU가 2 vCPU인데 FFmpeg 하나가 이미 다 쓴다. Kafka 넣으면 되지 않나? 솔직히 제일 먼저 든 생각이다. 알림 쪽엔 이미 Kafka가 있었고, "작업 큐 = 메시지 브로커"는 거의 반사적으로 나오는 조합이니까. 근데 멘토링에서 계속 들었던 말이 있다. "그게 병목인지 재 봤냐." 그래서 큐를 넣기 전에 먼저 쟀다. 동시 3건, 6건을 올리고 유실 여부, 최종 상태, 어디서 기다리는지를 봤다. 6건에서 첫 영상 5.3분, 마지막 영상 21분. 한 건당 일정하게 늘어나는 선형 이었다. 적체가 폭주하지도 않고, 유실 0, 타임아웃 오탐 0. 그 와중에 업로드 확정 API는 부하 중에도 1.6초로 멀쩡했다. 이때는 AI 블러까지 켜져 있어서 AI 단일 워커(시간당 20~23건)가 병목이었다. 지금 dev는 블러를 꺼 두고 FFmpeg가 병목이다. 병목 위치는 달라도 결론은 같다. 이 숫자가 말해 주는 건 명확했다. 작업을 전달하는 게 느린 게 아니라, 작업을 처리하는 손이 하나뿐인 거다. Kafka를 넣어도 큐 뒤에서 기다리는 건 똑같다. 손을 늘려야 한다. 그래서 Kafka는 보류했다. 대신 "시간당 업로드 20건을 넘으면 다시 본다"는 재평가 조건만 적어 뒀다. 새 기술을 안 넣은 판단도 측정으로 했다는 게 이 글에서 제일 하고 싶은 말이다. 그럼 손을 어떻게 늘리지? AI 서버에 워커를 하나 더 띄우면 된다. 문제는 두 워커가 같은 작업을 둘 다 잡지 않게 , 그리고 한쪽이 죽어도 작업이 안 사라지게 하는 것. 이게 결국 큐가 하는 일이다. 후보를 늘어놓고 지웠다. 후보 판정 이유 Kafka 기각 DB 커밋과 Kafka 발행 사이 유실 창을 닫으려면 outbox + relay가 또 필요하다. 알림 쪽은 그게 있지만 인코딩에까지 붙이긴 과하다 SQS 기각 관리형 큐 자체는 좋은데 DB와 한 트랜잭션으로 못 묶는다. 결국 outbox가 다시 필요하고 비용도 생긴다 Redis 기각 dev Redis는 BE 호스트의 단일 컨테이너. 인코딩 정본을 맡길 내구성 근거가 없다 PostgreSQL 작업 테이블 채택 이미 있는 저장소. 영상 행과 작업 행을 한 트랜잭션에 넣을 수 있고, SKIP LOCKED 로 두 소비자가 서로 기다리지 않고 다른 행을 가져간다 작업 수가 시간당 수십 건이고, 영상 상태가 이미 DB에 있고, "어느 워커가 몇 번째 시도 중인가"를 한 곳에서 보고 싶었다. 브로커 하나를 더 운영하는 비용보다 테이블 하나가 쌌다. 구조는 이렇다. 업로드 확정 트랜잭션 안에서 videos 행과 video_encoding_jobs 행을 같이 INSERT한다. 영상만 남고 작업이 빠지는 커밋 창이 없다. API 서버와 AI 서버가 같은 JAR 를 돌린다. AI 서버 쪽은 systemd 서비스로 띄운 인코딩 워커 모드다. 각 워커는 1초마다 poll 하고, 빈 슬롯이 있으면 작업을 한 건만 선점해서 FFmpeg에 넘긴다. CPU 보고 작업을 밀어 주는 로드 밸런서 같은 건 없다. 빈 쪽이 다음 행을 먼저 가져가는 경쟁 자체가 분배다. SKIP LOCKED가 하는 일 핵심은 선점 쿼리 한 문장이다. WITH candidate AS ( SELECT j.id FROM video_encoding_jobs j WHERE (j.status = 'PENDING' AND j.available_at <= now_utc AND j.attempt_count < 3) OR (j.status = 'PROCESSING' AND j.lease_until <= now_utc AND j.attempt_count < 3) ORDER BY j.available_at, j.id FOR UPDATE OF j SKIP LOCKED LIMIT 1 ) UPDATE video_encoding_jobs j SET status = 'PROCESSING', attempt_count = j.attempt_count + 1, claim_token = :claimToken, claimed_by = :nodeId, lease_until = now_utc + make_interval(secs => :leaseSeconds) FROM candidate WHERE j.id = candidate.id RETURNING j.id, j.video_id, j.original_s3_key, j.claim_token, j.attempt_count; 두 워커가 같은 순간에 poll 하면 어떻게 될까. FOR UPDATE 만 있으면 워커 2는 1이 잠근 첫 행 앞에서 커밋을 기다렸다가, 풀리는 순간 같은 행을 또 잡는다. SKIP LOCKED 를 붙이면 잠긴 행은 없는 셈 치고 다음 행으로 넘어간다. PostgreSQL 문서도 이 옵션이 "여러 소비자가 큐 형태의 테이블을 처리할 때" 쓰라고 적어 놨다. 선점할 때 남기는 세 가지가 나중에 전부 쓰인다. claim_token — 새 UUID. "이 작업은 지금 내 거"라는 증표 lease_until — 임대 만료 시각 (35분). 워커가 소리 없이 죽었을 때를 위한 보험 attempt_count — 최대 3회. 넘으면 DEAD로 보내고 FFmpeg를 다시 돌리지 않는다 WHERE 절에 PENDING 과 만료된 PROCESSING 을 OR로 같이 둔 건, 새 작업 선점과 죽은 워커 작업 회수를 한 문장으로 하기 위해서다. 실행 계획을 보니 작업 행 21건 기준 1.8ms라 쿼리를 둘로 나눌 이유가 없었다. 선점은 짧게, 인코딩은 길게 여기서 하나 조심한 게 있다. 선점 트랜잭션과 FFmpeg 실행을 분리 했다. FFmpeg는 수십 초에서 길면 분 단위로 도는데, 그 동안 DB 트랜잭션을 열어 두면 연결 하나가 계속 묶인다. 선점은 UPDATE 한 문장으로 커밋하고, FFmpeg는 트랜잭션 밖에서 돌리고, 끝나면 다시 짧은 트랜잭션으로 종결한다. 종결 UPDATE의 WHERE에는 claim_token 과 lease_until 을 다시 확인한다. WHERE job.id = :jobId AND job.status = 'PROCESSING' AND job.claim_token = :myToken AND job.lease_until > now_utc 이게 0행이면 "내 작업이 아니게 됐다"는 뜻이라 ClaimLostException 을 던져 앞선 영상 상태 전이와 알림 INSERT까지 전부 롤백한다. 왜 이게 필요한지는 바로 아래에서. 결과 같은 영상(10·19·29초 구간)을 동시 3·6·12건씩, 1노드와 2노드로 번갈아(ABBA) 올렸다. 측정은 확정 API 응답부터 DB READY 까지, 즉 사용자가 기다리는 시간이다. 동시 건수 1노드 2노드 단축 가장 늦은 영상 3 55.1초 34.8초 37% 53초 → 33초 6 101.7초 55.7초 45% 98초 → 56초 12 197.9초 107.3초 46% 193초 → 102초 84편 전부 READY, 시도 1회, 오류 0. 작업은 be/ai에 12건 기준 5:7, 6:6으로 나뉘었다. 선점이 편중 없이 분배한다. 1노드가 55 → 102 → 198초로 정확히 2배씩 느는 게 보인다. 워커 하나가 순차 처리하니 당연한데, 확인하고 싶었던 건 적체가 초선형으로 터지지 않는다 는 거였다. 2노드는 기울기가 딱 절반이다. 워커를 하나 더 붙이면 그만큼 는다는 뜻이고, 세 번째를 붙이면 또 그만큼 늘 거다. 점선이 NFR-003의 30초다. 단독은 지키고, 동시 3건부터는 2노드로도 넘는다. 이건 숨기지 않았다. "단독 충족·동시 초과"가 지금 상태고, 다음 단계는 워커 추가다. 워커를 죽여 봤다 "재시도가 있다"는 말과 "실제로 회수되는 걸 봤다"는 말은 다르다. 그래서 dev에서 처리 중인 워커를 진짜로 죽였다. 정상 종료 (SIGTERM) — systemctl stop . 처음엔 @PreDestroy 에서 작업을 반납하게 했는데, executor가 먼저 멈춘 뒤에 호출돼서 늦었다. systemd가 FFmpeg에 먼저 SIGTERM을 보내니 exit 255가 "파일 오류"로 분류되는 것도 봤다. 반납 시점을 ContextClosedEvent 로 앞당긴 뒤에는 AI 서버가 잡고 있던 작업이 PENDING ·시도 0으로 돌아가고, API 서버가 바로 집어서 시도 1로 끝냈다. 강제 종료 (SIGKILL) — kill -9 . 반납할 기회 자체가 없다. 작업은 PROCESSING 으로 남고 아무도 모른다. 이게 lease_until 이 있는 이유다. 임대가 만료되면 선점 쿼리의 OR 두 번째 조건에 걸려 다른 워커가 회수한다. 시도 2로 완료됐다. (35분을 기다리진 않고 시험용으로 임대를 1분으로 줄였다. 그러니 "회수 경로"를 검증한 거지 운영 복구 시간을 증명한 건 아니다.) 늦게 온 결과 — 종료된 워커의 FFmpeg가 뒤늦게 실패 응답을 보내는 경우가 있었다. 이미 다른 워커가 같은 작업을 잡고 있는데, 이 늦은 실패가 영상을 FAILED 로 덮으면 안 된다. 여기서 claim_token 가드가 일했다. 토큰이 안 맞으니 종결 UPDATE가 0행, 전부 롤백, 버림. 세 시나리오 모두 작업 유실 0, 영상당 알림 1건, 중복 0이었다. 마무리 이번 작업에서 제일 오래 걸린 건 코드가 아니라 "큐가 병목인가"를 확인하는 측정 이었다. 그 측정이 없었으면 Kafka 컨슈머를 짜고 outbox를 붙이고 브로커 접속을 열고, 그러고 나서 "어? 여전히 느린데?"를 했을 거다. 손이 하나인 건 큐가 아무리 좋아도 못 고친다. 그리고 SKIP LOCKED 는 생각보다 훨씬 쓸 만하다. 작업 수가 시간당 수십 건이고 워커가 몇 대 안 되면, 브로커 없이 DB 테이블 하나로 "같은 작업 두 번 안 잡기"와 "죽어도 회수하기"가 다 된다. 물론 한계도 안다. 워커가 수십 대로 늘면 1초 폴링이 DB를 괴롭힐 거고, 그때는 LISTEN/NOTIFY 나 진짜 브로커를 봐야 한다. 그 시점이 언제인지를 적어 둔 것까지가 이번 작업이다.
Балл: 57Уверенность: 54%
Балл: 54.4Уверенность: 49%
Балл: 54.4Уверенность: 49%
Балл: 54.4Уверенность: 49%
Балл: 54.4Уверенность: 49%
Балл: 54.4Уверенность: 49%