최소 신장 트리(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 를 갱신하는 과정을 반복한다.
테스트는 전부 통과했는데, 그 테스트가 돈을 쓰고 있었다 한도를 관측 장치로 쓰기로 했었다 방문자가 현관에서 한 말을 글자로 바꿔 주는 음성 인식 서비스가 있다. 이 서비스에는 일일 사용 한도를 걸 수 있는데, 나는 한도를 아주 낮게(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인 가구를 위한 현관 부착형 소리 분류 알림 시스템 졸업작품입니다. 초인종 · 노크 · 화재경보를 구분해서 사진과 자막까지 스마트폰으로 보내주는 걸 목표로 만들고 있습니다.
지금까지 개발 실습 환경 - 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 주소) 두 명령어 입력
캐시 히트율에 대한 장애 회고를 읽고 관심이 생겨 실험해 보았다 테스트를 위해 상품(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면 끝나지만, 커넥션을 얻으려고 풀 앞에서 몇 초씩 기다렸다. 상품 조회가 같이 느려진 이유가 이것이다. 상품 조회 자체는 아무 문제가 없었다. 랭킹 쿼리가 커넥션을 쥐고 있는 동안 대기 했어야 했기 때문이다.
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가 생성될 때마다 하나씩 배분하게 된다.
"그게 먼데" 며칠 전 친구가 디스코드에 이런 말을 남겼습니다. 뮤테이션 테스트를 도입하고 나서 자기 팀 서비스 안정성이 눈에 띄게 올랐다고, 나중에 조사해서 글 좀 써달라고요. 제 답은 "그게 먼데"였습니다. 설명을 듣고 나서는 "어케 하는 건데"였고요. 프론트엔드를 꽤 오래 했는데 처음 듣는 단어였습니다. 찾아보니 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 리포트.
영상을 여러 개 올리면 뒤 영상이 줄 서서 기다린다. "큐 넣으면 되겠지" 하고 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 나 진짜 브로커를 봐야 한다. 그 시점이 언제인지를 적어 둔 것까지가 이번 작업이다.
저번 포스팅에 이어서 서브쿼리(하위 질의)에 대해서 알아보겠습니다. 서브쿼리(Subquery) 서브쿼리는 다른 SQL 쿼리 안에 포함된 SELECT-FROM-WHERE 식 1. Nested Subquery 1.1 정의 중첩 쿼리(Nested Subquery) 는 다른 쿼리 안에 포함된 SELECT-FROM-WHERE 식 SELECT ... FROM ... WHERE column IN ( SELECT ... FROM ... WHERE ... ); 주요 용도는 다음과 같다. 집합 소속 여부 검사: IN , NOT IN 집합과 값 비교: SOME , ALL 결과 집합이 비었는지 검사 EXISTS , NOT EXISTS 결과의 중복 여부 검사 UNIQUE 계산한 결과를 임시 테이블이나 단일 값으로 사용 WITH 1.2 읽는 순서 일반적인 비상관 서브쿼리는 안쪽부터 읽으면 쉽다. 1. 안쪽 SELECT 실행 2. 안쪽 질의가 값 또는 집합 생성 3. 바깥 질의가 그 결과를 조건이나 입력으로 사용 2. IN 과 NOT IN 2.1 IN : 집합에 속하는가? 2017년 가을과 2018년 봄에 모두 개설된 과목을 찾는다. SELECT DISTINCT course_id FROM section WHERE semester = 'Fall' AND year = 2017 AND course_id IN ( SELECT course_id FROM section WHERE semester = 'Spring' AND year = 2018 ); 초록색이 2017년 가을에 개설된 과목 빨간색이 2018년 봄에 개설된 과목 파란색의 CS-101 이 공통인 과목이다. 처리 과정: 안쪽 질의가 2018년 봄에 개설된 course_id 집합을 만든다. 바깥 질의가 2017년 가을에 개설된 과목을 찾는다. 그중 안쪽 결과에도 포함된 과목만 남긴다. Fall 2017 과목 ∩ Spring 2018 과목 2.2 NOT IN : 집합에 속하지 않는가? 2017년 가을에는 개설되었지만 2018년 봄에는 개설되지 않은 과목을 찾는다. SELECT DISTINCT course_id FROM section WHERE semester = 'Fall' AND year = 2017 AND course_id NOT IN ( SELECT course_id FROM section WHERE semester = 'Spring' AND year = 2018 ); 초록색이 2017년 가을에 개설된 과목 빨간색이 2018년 봄에 개설된 과목 파란색의 CS-347 , PHY-101 이 조건을 만족하는 과목이다. Fall 2017 과목 - Spring 2018 과목 강의 예제 데이터의 결과: CS-347 , PHY-101 2.3 NOT IN 과 NULL 주의 서브쿼리 결과에 NULL 이 있으면 NOT IN 의 비교 결과가 UNKNOWN 이 되어 예상과 달리 행이 선택되지 않을 수 있다. 안전한 방법: 서브쿼리에서 NULL 을 제거한다. 또는 NOT EXISTS 를 사용한다. WHERE course_id NOT IN ( SELECT course_id FROM section WHERE course_id IS NOT NULL ); 3. 튜플 단위의 IN 교수 10101 이 담당한 분반을 수강한 서로 다른 학생 수를 구한다. SELECT COUNT(DISTINCT ID) FROM takes WHERE (course_id, sec_id, semester, year) IN ( SELECT course_id, sec_id, semester, year FROM teaches WHERE teaches.ID = 10101 ); 왜 네 속성을 함께 비교하는가? course_id 만으로는 특정 분반을 구별할 수 없다. 같은 과목이 여러 분반, 학기, 연도에 개설될 수 있기 때문이다. (course_id, sec_id, semester, year) 이 네 속성의 조합으로 특정 수업 분반을 식별한다. 처리 과정: teaches 에서 교수 10101 이 담당한 분반들의 복합 튜플을 구한다. takes 에서 그 튜플들에 해당하는 수강 기록을 찾는다. COUNT(DISTINCT ID) 로 중복 학생을 한 번만 센다. 4. 집합 비교: SOME 4.1 의미 SOME 은 서브쿼리 결과 중 적어도 하나 와 비교 조건을 만족하면 참이다. F <comp> SOME r <=> r의 원소 중 적어도 하나의 t에 대해 F <comp> t가 참 <comp> 에는 < , <= , > , >= , = , <> 등을 사용할 수 있다. 4.2 예제 CSE 학과 교수 중 적어도 한 명보다 급여가 높은 교수를 찾는다. SELECT name FROM instructor WHERE salary > SOME ( SELECT salary FROM instructor WHERE dept_name = 'CSE' ); 서브쿼리 결과가 {60000, 70000, 90000} 이라면, 급여가 60000보다 크기만 해도 조건을 만족한다. 4.3 자기 조인으로 표현한 같은 쿼리 SELECT DISTINCT T.name FROM instructor AS T, instructor AS S WHERE T.salary > S.salary AND S.dept_name = 'CSE'; SOME 을 사용하면 같은 의미를 더 직접적으로 표현할 수 있다. 4.4 SOME 의 중요한 등가 관계 = SOME <=> IN 하지만 다음은 성립하지 않는다. <> SOME != NOT IN 예를 들어 집합이 {0, 5} 일 때 5 $\neq$ SOME {0, 5} 는 5와 다른 원소인 0이 있으므로 참이다. 반면 5 NOT IN {0, 5} 는 5가 집합에 포함되어 있으므로 거짓이다. ANY 는 일반적으로 SOME 과 같은 의미다. 5. 집합 비교: ALL 5.1 의미 ALL 은 하위 질의 결과의 모든 값 과 비교 조건을 만족해야 참이다. F <comp> ALL r <=> r의 모든 원소 t에 대해 F <comp> t가 참 5.2 예제 Biology 학과 모든 교수보다 급여가 높은 교수를 찾는다. SELECT name FROM instructor WHERE salary > ALL ( SELECT salary FROM instructor WHERE dept_name = 'Biology' ); 하위 질의 결과가 {60000, 70000, 90000} 이라면 급여가 90000보다 커야 조건을 만족한다. 5.3 SOME 과 ALL 비교 표현 의미 x > SOME (집합) 집합의 값 중 하나보다만 크면 됨 x > ALL (집합) 집합의 모든 값보다 커야 함 x = SOME (집합) x IN (집합) 과 같음 5.4 빈 집합과 NULL 빈 집합에 대한 SOME 조건은 만족할 대상이 없으므로 거짓이다. 빈 집합에 대한 ALL 조건은 반례가 없으므로 참이다. 서브쿼리 결과에 NULL 이 있으면 3값 논리에 따라 결과가 UNKNOWN 이 될 수 있다. 6. EXISTS 와 NOT EXISTS 6.1 정의 EXISTS 는 서브쿼리 결과에 튜플이 하나라도 있으면 참 이다. NOT EXISTS 는 내부 결과가 비어 있으면 참 이다. EXISTS 에서는 실제 출력 값보다 행의 존재 여부가 중요하므로 보통 SELECT * 또는 SELECT 1 을 사용한다. WHERE EXISTS ( SELECT 1 FROM ... WHERE ... ); 7. Correlated Subquery 7.1 정의 상관 서브쿼리(Correlated Subquery) 는 하위 쿼리가 바깥 쿼리의 현재 튜플을 참조하는 질의다. 바깥 질의의 별칭을 correlation name 또는 correlation variable이라고 한다. 비상관 서브쿼리처럼 한 번만 독립 실행되는 것으로 이해하면 안 된다. 개념적으로 바깥 질의의 각 후보 행에 대해 하위 질의를 평가한다. 7.2 두 학기에 모두 개설된 과목 SELECT course_id FROM section AS S WHERE semester = 'Fall' AND year = 2017 AND EXISTS ( SELECT * FROM section AS T WHERE semester = 'Spring' AND year = 2018 AND S.course_id = T.course_id ); 여기서 S.course_id 가 바깥 질의의 현재 과목을 하위 질의에 전달한다. 처리 개념: 바깥 질의에서 2017년 가을 과목 S 를 하나 선택한다. 하위 질의에서 같은 course_id 가 2018년 봄에도 존재하는지 검사한다. 존재하면 해당 과목을 결과에 포함한다. 8. NOT EXISTS 로 "모두" 표현하기 Biology 학과에서 개설한 모든 과목을 수강한 학생을 찾는다. SELECT DISTINCT S.ID, S.name FROM student AS S WHERE NOT EXISTS ( (SELECT course_id FROM course WHERE dept_name = 'Biology') EXCEPT (SELECT T.course_id FROM takes AS T WHERE S.ID = T.ID) ); 8.1 집합으로 해석 A = Biology 학과의 전체 과목 집합 B = 현재 학생이 수강한 과목 집합 A EXCEPT B = 학생이 아직 수강하지 않은 Biology 과목 A EXCEPT B 가 비어 있으면 빠진 과목이 하나도 없다는 뜻이다. NOT EXISTS (A EXCEPT B) = 수강하지 않은 Biology 과목이 존재하지 않음 = Biology의 모든 과목을 수강함 8.2 이중 부정 패턴 SQL에서 "모든 X에 대해 조건을 만족"은 다음과 같은 이중 부정으로 자주 표현한다. 조건을 만족하지 않는 X가 존재하지 않는다. 9. 중복 존재 검사: UNIQUE UNIQUE(subquery) 는 서브쿼리 결과에 중복 튜플이 있는지 검사 한다. 중복이 없으면 참 중복이 있으면 거짓 빈 결과에 대해서도 참 2017년에 최대 한 번 개설된 과목을 찾는 예: SELECT T.course_id FROM course AS T WHERE UNIQUE ( SELECT R.course_id FROM section AS R WHERE T.course_id = R.course_id AND R.year = 2017 ); 각 과목에 대해 2017년의 개설 기록이 0개 또는 1개면 중복이 없으므로 참. 두 번 이상 개설되면 같은 course_id 가 반복되어 거짓. 강의 예제 데이터에서 2017년에 두 번 개설된 CS-190 만 결과에서 제외 UNIQUE(subquery) 술어의 지원 여부와 문법은 DBMS마다 다르다. 실제 환경에서는 GROUP BY ... HAVING COUNT(*) <= 1 또는 NOT EXISTS 를 이용한 대체 표현을 확인하는 것이 안전하다. 10. FROM 절의 서브쿼리 서브쿼리 결과를 하나의 임시 릴레이션처럼 FROM 에서 사용할 수 있다. 이를 derived table 또는 inline view라고도 한다. 학과별 평균 급여를 먼저 구한 뒤, 평균이 3,000,000보다 큰 학과를 찾는다. SELECT dept_name, avg_salary FROM ( SELECT dept_name, AVG(salary) AS avg_salary FROM instructor GROUP BY dept_name ) AS dept_avg WHERE avg_salary > 3000000; 처리 과정: 안쪽 쿼리가 학과별 평균 급여 릴레이션을 만든다. 이 결과에 dept_avg 라는 별칭을 붙인다. 바깥 쿼리가 avg_salary > 3000000 인 행만 선택한다. 열 이름까지 별칭으로 지정 SELECT dept_name, avg_salary FROM ( SELECT dept_name, AVG(salary) FROM instructor GROUP BY dept_name ) AS dept_avg(dept_name, avg_salary) WHERE avg_salary > 3000000; 많은 DBMS에서는 FROM 절의 서브쿼리에 별칭이 필요하다. 11. WITH 절과 CTE 11.1 정의 WITH 절은 현재 SQL 문 안에서만 사용할 수 있는 임시 결과에 이름을 붙인다. 이렇게 정의한 결과를 CTE(Common Table Expression) 라고 한다. 유효 범위: SQL 문 하나 ( ; 까지) WITH temporary_name AS ( SELECT ... ) SELECT ... FROM temporary_name; 복잡한 쿼리를 단계별로 나누어 읽기 쉽게 만들고, 같은 중간 결과를 재사용할 수 있다. 11.2 최대 예산을 가진 학과 WITH max_budget(value) AS ( SELECT MAX(budget) FROM department ) SELECT department.dept_name, department.budget FROM department, max_budget WHERE department.budget = max_budget.value; max_budget CTE가 전체 학과 중 최대 예산 하나를 구한다. department 에서 예산이 최대값과 같은 학과를 찾는다. 최대 예산이 같은 학과가 여러 개라면 모두 출력된다. 11.3 여러 CTE 연결 전체 학과의 총급여 평균 이상을 지출하는 학과를 찾는다. WITH dept_total(dept_name, value) AS ( SELECT dept_name, SUM(salary) FROM instructor GROUP BY dept_name ), dept_total_avg(value) AS ( SELECT AVG(value) FROM dept_total ) SELECT dept_total.dept_name FROM dept_total, dept_total_avg WHERE dept_total.value >= dept_total_avg.value; 처리 단계: 첫 번째 CTE dept_total 교수 데이터를 학과별로 묶어 학과별 총급여 를 계산합니다. 두 번째 CTE dept_total_avg 앞에서 만든 dept_total 을 사용해 학과별 총급여의 평균 을 계산합니다. 마지막 메인 쿼리 dept_total 과 dept_total_avg 를 비교해 총급여가 평균 이상인 학과 를 선택합니다. 12. Scalar Subquery 12.1 정의 스칼라 서브쿼리(Scalar Subquery) 는 단일 값 이 필요한 위치에서 사용하는 서브쿼리다. 결과가 정확히 1행 1열이면 그 값을 사용하는 구조 일반 서브쿼리의 결과가 0행이면 일반적으로 NULL 로 취급 COUNT(*) 와 같은 집계 함수는 대상 행이 없어도 값 0 을 가진 1행을 반환하므로 스칼라 값으로 사용 가능 결과가 2행 이상이면 단일 값을 결정할 수 없어 발생하는 실행 오류 예시1) 학과별 교수 수를 열로 출력 SELECT dept_name, ( SELECT COUNT(*) FROM instructor WHERE department.dept_name = instructor.dept_name ) AS num_instructors FROM department; 바깥 쿼리의 각 학과에 대해 같은 학과에 속한 교수 수를 하나의 값으로 계산한다. 예시 2) 급여와 학과 예산 비교 SELECT name FROM instructor WHERE salary * 10 > ( SELECT budget FROM department WHERE department.dept_name = instructor.dept_name ); 각 교수의 급여 10배가 소속 학과 예산보다 큰지 검사한다. department.dept_name 이 기본키라면 하위 쿼리는 최대 한 행만 반환한다. 13. 서브쿼리 종류 한눈에 보기 종류 결과 형태 또는 검사 대상 대표 문법 집합 소속 서브쿼리 값이 결과 집합에 포함되는지 IN , NOT IN 집합 비교 서브쿼리 하나 이상 또는 전체 값과 비교 SOME , ALL 존재 검사 결과가 비었는지 EXISTS , NOT EXISTS 상관 서브쿼리 바깥 질의의 현재 행을 참조 S.course_id = T.course_id FROM 서브쿼리 결과를 임시 릴레이션으로 사용 FROM (SELECT ...) AS x 스칼라 서브쿼리 결과를 단일 값으로 사용 salary > (SELECT AVG(...)) CTE 이름 붙인 임시 결과 WITH x AS (...) Reference: Database System Concept-7th Edition 건국대학교 김욱희 교수님 - Database 수업
멀쩡한 숫자를 버그로 의심했는데, 다른 게 고장나 있었다 노트북 하나만 있는 날 이날은 장비가 아무것도 없었다. 기기는 집에 있었고, 주문한 부품은 아직 안 왔고, 가진 건 노트북뿐이었다. 그래서 남은 미결을 다시 읽다가, "기기가 있어야 한다"고 적어둔 항목 하나를 쪼개봤다. 판정 기준이 네 가지였는데(실제 모델을 띄운다 / 진짜 초인종 소리를 넣는다 / 사람이 없는 상태로 만든다 / 화면과 기록 양쪽에서 차단을 확인한다), 기기가 있어야 하는 건 하나도 없었다. 모델은 노트북에 있고, 초인종 소리는 학습 데이터에 수백 개가 있고, "사람 없음"은 요청에 값을 넣으면 되고, 화면은 브라우저로 본다. 그렇게 노트북으로 모델을 띄우고 첫 요청을 쏘는 데까지 갔는데, 거기서 이 글의 이야기가 시작된다. 0.4 / 0.6 / 0.0 첫 요청의 점수가 이렇게 돌아왔다. 0.4 / 0.6 / 0.0 소수 첫째 자리로 딱 떨어지고, 하나는 정확히 0.0이다. 확률값이 이렇게 나오는 건 이상하다고 봤다. 며칠 전에 같은 모델로 쏴봤을 때는 소수점이 길게 나왔었다. 그래서 "표시가 뭉개지는 버그"라고 의심하고 파고들었다. 결과부터 쓰면 그 의심은 틀렸다. 서버가 점수를 소수 둘째 자리까지 반올림해서 내보내는 건 정상 동작이었고, 이 소리 파일의 값이 우연히 그렇게 떨어졌을 뿐이다. 버그를 찾으러 간 곳에 버그는 없었다. 그런데 파고드는 길에 다른 게 걸려 있었다. 반올림이 판정 앞에 있었다 반올림하는 자리를 찾아 올라가 보니, 그게 알림을 보낼지 판정하는 단계보다 앞 에 있었다. 지금: 점수 계산 → 반올림 → 알림 보낼지 판정 원칙: 점수 계산 → 알림 보낼지 판정 → (표시용으로만) 반올림 그러니까 "신뢰도 0.70 미만이면 알림을 보내지 않는다"는 기준이 원래 값이 아니라 반올림된 값 을 받고 있었다. 원래 값이 0.695에서 0.69999 사이면 반올림돼서 0.70이 되고, 문턱을 못 넘어야 할 게 통과한다. 여기서 뼈아팠던 건, 이게 내가 직접 세워둔 원칙의 위반 이라는 점이다. 몇 주 전에 "표시용 반올림이 판정 경계를 오염시키면 안 된다"고 문서에 못 박아뒀다. 그 원칙은 그때 만지던 파일에서만 지켜졌고, 다른 파일에서는 위반이 그대로 살아 있었다. 원칙을 적어두는 일과 그게 지켜지는지 확인하는 일은 별개인데, 나는 앞의 것만 했다. 다만 심각도는 따로 봐야 했다. 평가용 데이터 424개를 전부 돌려서 그 구간에 든 게 몇 개인지 셌더니 0건 이었다. 구조적으로 폭은 있는데 실제 사례는 없었다. 그래서 이건 "고쳐야 한다"가 아니라 "기록해두고 판단을 기다린다" 로 남겼다. 수정할지는 아직 정하지 않았다. 과잉 진단의 수지 이 하루를 정리하면 계산이 이렇게 된다. 내가 의심한 것: "표시가 뭉개진다" → 틀렸다 (정상 동작) 의심하며 파고든 길: 반올림 위치 → 원칙 위반이 걸렸다 (진짜) 이상한 숫자를 보고 "버그다" 하고 달려갔는데, 숫자는 정상이었고 다른 게 고장나 있었다. 과잉 진단은 비용이 싸다. 틀려도 잃는 건 시간이 조금이고, 가끔은 이렇게 공짜 수확을 준다. 다만 조건이 하나 있다. 결론을 확정하기 전에 원래 값을 직접 볼 것. 그러지 않고 "표시 버그"로 기록하고 끝냈다면, 거짓 진단이 문서에 남고 진짜 결함은 못 봤을 것이다. 아직 다 된 건 아니다 반올림 결함은 실제 사례가 0건 이다. "버그를 찾았다"가 아니라 "구조적 폭은 있으나 실사례는 없어 기록만 해뒀다"가 정확하다. 고칠지는 미정이다. 이날은 하루 종일 노트북만 썼고, 기기는 여전히 아무것도 안 했다. 노트북에서 한 확인은 기기가 실제로 보내는 경로를 검증한 게 아니다. 주문한 부품이 아직 안 왔고, 그게 안 오면 직접 녹음, 재학습, 분류 개선이 통째로 멈춘다. 진척이 좋아 보이는 하루였지만 이 병목은 그대로다. 마무리 이날 가장 값진 건 숫자 하나를 잘못 의심한 덕에 내가 세운 원칙이 어디서 안 지켜지고 있는지 보게 된 것이었다. 그리고 "장비가 없어서 못 한다"고 적어둔 일이 쪼개보니 노트북 일이었던 것도 같은 결이다. 이날 하루의 대부분은 새로 만드는 일이 아니라 적어둔 것을 다시 읽는 일 이었다. 띵동(Ddingdong)은 청각장애인 1인 가구를 위한 현관 부착형 소리 분류 알림 시스템 졸업작품입니다. 초인종 · 노크 · 화재경보를 구분해서 사진과 자막까지 스마트폰으로 보내주는 걸 목표로 만들고 있습니다.
같은 숫자를 네 번 재현했는데, 그 숫자가 부풀려 있었다 새 데이터도, 보드도 없는 하루 재학습 날이 다가오고 있었는데, 그날 쓸 새 데이터는 아직 도착하지 않은 상태였다. 노트북 하나로 할 수 있는 일이 뭘까 생각하다가, 그동안 났던 사고 기록을 쭉 모아 봤다. 모아 놓고 보니 전부 같은 모양이었다. 틀렸는데 에러 없이 계속 진행한 경우 였다. 그래서 하루를 이렇게 잡았다. 재학습 날 조용히 틀릴 것들을, 틀리면 그 자리에서 시끄럽게 죽도록 미리 바꿔 놓는 날. 새 기능은 하나도 만들지 않고, 이미 있는 숫자를 다른 방식으로 한 번 더 재 보는 작업이었다. 그 와중에 테스트 성적에 데이터 누수가 있다는 걸 찾았다. 전수 측정 도구를 만들었고, 네 번 재현됐다 먼저 도구 얘기를 해야 한다. 모델이 테스트 데이터 424건을 어떻게 분류하는지(알림이 나가는지, 막히는지, 오분류가 어디로 새는지) 보는 전수 측정은 원래 한 번 돌리고 버린 스크립트였다. 이걸 저장소에 남는 도구로 바꿨다. 넣은 장치는 이런 것들이다. 진짜 모델에 쏘고 있는지 스스로 확인한다. 메모리 사용량의 자릿수, 모델 라이브러리가 실제로 로드됐는지, 같은 입력을 두 번 넣었을 때 출력이 완전히 같은지를 본다. 기준 숫자와 전 칸이 일치해야 정상 종료한다. 일치 판정이 느슨하면 아무거나 통과하니, 일부러 한 칸만 틀리게 바꿔서 실제로 실패하는지도 확인했다. 결과는 424건 전수에서 정상 통과 367, 오알림 27, 막힘 30이었고, 정확도는 0.8868이었다. 이 값이 네 번째 재현까지 전부 일치 했다. 여기까지는 기분이 좋았다. 측정 경로가 안정적이라는 확신이 들었다. 그런데 이 일치가 증명한 건 딱 거기까지였다. 같은 숫자를 안정적으로 재현한다는 것과, 그 숫자가 옳다는 것은 다른 이야기였다. 같은 누수를 네 번 정확하게 재현했을 수도 있었다. 파일 이름이 같이 찍히자 보였다 도구는 결과를 낼 때 파일 이름을 같이 출력하게 만들어 두었다. 그 열이 생기자 눈에 걸리는 게 있었다. 서로 다른 화재경보 파일 17개의 첫 3초 확신도가 전부 0.9809193015098572 였다. 다음 3초도 17개가 전부 같은 값이었다. 확신도가 1.0 근처에서 겹치는 건 이상하지 않다. 숫자 표현이 그 근처에서는 촘촘하지 않기 때문이다. 그런데 0.98 언저리의 값이 소수점 16자리까지 겹치는 건 다른 문제다. 입력이 같았다는 뜻 이다. 파일 해시로 대조해 봤다. 항목 값 전처리 단계 파일 수 2,792개 고유한 내용 2,305개 잉여 487개 (17.4%) 중복 그룹 15개 가장 큰 세 그룹은 한 내용이 각각 108개, 56개, 30개 파일로 퍼져 있었다. 그리고 이 그룹들이 학습/검증/테스트 어디에 들어갔는지 보니 이랬다. 그룹 학습 / 검증 / 테스트 108개 그룹 71 / 20 / 17 56개 그룹 38 / 9 / 9 30개 그룹 19 / 4 / 7 중복 15그룹 중 10그룹이 경계를 가로질렀다. 같은 내용이 학습에도, 검증에도, 테스트에도 들어가 있었다. 나머지 5그룹은 학습 안에만 갇혀 있었고 전부 파일 2개짜리, 잉여 5개였다. 데이터량을 줄이는 문제는 사실상 없고, 문제는 경계를 넘은 쪽에 있었다. 결정타는 이거였다. 테스트 424건 중 79건이 학습·검증 데이터와 바이트 단위로 같았고, 전부 화재경보였다. 초인종과 노크는 0건이었다. (여러 파일이 앞부분을 공유하는 것으로 보이긴 하는데, 왜 그렇게 됐는지는 확정하지 못했다. 이 중복이 데이터를 모으는 단계에서 생겼는지 전처리 단계에서 생겼는지도 모른다.) 얼마나 부풀려졌나 — 그리고 아침에 내가 한 과장 이 숫자를 처음 봤을 때 나는 "정확도의 상당 부분이 암기일 수 있다"고 말했다. 실측해 보니 과한 표현이었다. 누수 79건의 정확도는 1.0000 , 전부 맞혔다. 나머지 청정 345건의 정확도는 0.8609 였다. 전체 정확도는 0.8868에서 청정 기준 0.8609로, 2.6%p 내려간다. 클래스 균형을 보는 지표(macro F1)는 0.8478에서 0.8367로, 0.011 내려간다. 화재경보만 흔들린다. 그 클래스의 F1은 0.927에서 0.893으로 내려가고, 평가 건수는 254에서 175로 줄어든다. 그리고 데모 주력인 초인종(F1 0.736)과 노크(0.881)는 소수점 아래까지 한 글자도 변하지 않았다. 누수가 그 두 클래스에 0건이었으니 당연한 결과다. 동시에 청정 값을 가려내는 계산이 맞게 돌았다는 검산이기도 했다. 건드리지 않아야 할 곳이 안 움직였다. 여기서 구분해 둘 게 있다. 79건이 전부 맞았다는 건 실측이다. 왜 전부 맞았는지, 그게 암기 때문인지는 아직 추정이다. 이 둘을 한 문장에 섞지 않으려고 했다. 그리고 누수가 있다 는 사실과 성적이 크게 틀렸다 는 주장도 별개였다. 아침에는 앞의 것을 보고 뒤의 것까지 말해 버렸고, 숫자를 보고서야 크기를 제대로 쟀다. 설계가 고장난 게 아니라, 범위 밖이었다 학습과 테스트를 나눌 때는 같은 원본에서 나온 조각이 양쪽으로 갈라지지 않게 파일 이름 기준으로 묶어서 나눈다. 이 방어는 설계대로 작동했다. 다만 내용이 같고 이름이 다른 파일 은 애초에 이 방어의 사정권 밖이었다. 그래서 기록에도 "설계 결함"이 아니라 "방어 범위 밖"이라고 적었다. 두 표현은 그 뒤의 대응을 다르게 이끈다. 결함이라고 하면 설계를 갈아엎는 쪽으로 가고, 범위 밖이라고 하면 방어를 한 겹 더 얹는 쪽으로 간다. 이번 경우에는 후자가 사실에 맞았다. 아직 다 된 건 아니다 누수는 발견과 정량까지다. 대응은 아직 정하지 않았다. 중복을 제거하고 다시 나눠 재학습할지, 누수를 고지하고 그대로 쓸지, 청정 값을 정본으로 바꿀지 모두 미결이다. 데이터는 한 줄도 건드리지 않았고 재학습도 하지 않았다. 청정 정확도 0.8609도 확정 숫자가 아니다. 발표에 어떤 숫자를 쓸지는 개발이 끝난 뒤 정한다. 증강본과 최종 데이터셋에 같은 중복이 있는지는 아직 보지 않았다. 이 측정은 노트북에서 서버 없이 모델만 직접 호출한 것이다. 보드는 쓰지 않았고 실제 알림 경로를 통과한 게 아니다. 기기와 시연 쪽 상태는 이날 달라진 게 없다. 79건이 전부 맞았다는 것과, 그 이유가 암기라는 것은 다른 층위다. 앞은 실측이고 뒤는 추정이다. 마무리 이날 한 일은 새 기능이 아니라 이미 있던 숫자를 다른 방식으로 한 번 더 재 본 것뿐이었다. 그랬더니 성적이 부풀려 있었다. 그중 가장 크게 남은 건 네 번 재현된다는 사실이 그 숫자가 옳다는 증거가 아니라는 것 이었다. 재현이 알려 주는 건 측정이 안정적이라는 사실이고, 무엇을 재고 있는지는 알려 주지 않는다. 이번에는 그걸 파일 이름 한 열이 알려 줬다. 띵동(Ddingdong)은 청각장애인 1인 가구를 위한 현관 부착형 소리 분류 알림 시스템 졸업작품입니다. 초인종 · 노크 · 화재경보를 구분해서 사진과 자막까지 스마트폰으로 보내주는 걸 목표로 만들고 있습니다.
고치지 못한 실험이 진척이었던 이야기 이날 "고쳤다"는 결과는 거의 없었다 마이크에는 오래 남아 있던 문제가 하나 있었다. 소리가 없는 순간에도 가끔 최대치로 튀는 값이 하나씩 끼어드는 현상이다. 원인 후보는 여럿이었고, 이날은 그중 하나를 결선을 건드리지 않고 가려 보기로 했다. 후보는 이렇다. 마이크의 데이터 선이 출력을 내보내지 않는 구간에서 어디에도 연결되지 않은 채 떠 있고, 그래서 값이 흔들린다는 가설이다. 같은 날 다른 것들도 재 봤다. 그런데 하루가 끝나고 정리해 보니 결과가 전부 비슷한 모양이었다. 무언가가 해결된 게 아니라 후보 하나가 약해졌거나, 어디까지는 아니라는 경계가 그어진 것들이었다. 이 글은 그중 마이크 실험 하나에 대한 기록이다. 후보를 지우는 실험은 이렇게 짰다 데이터 선이 떠 있어서 생기는 문제라면, 선을 약하게 눌러 주면 줄어들어야 한다. 칩 안에는 그런 용도로 쓸 수 있는 약한 내부 저항이 있었다. 납땜 없이 설정 하나로 켜고 끌 수 있다. 그래서 모드를 이렇게 나눴다. 기본 : 아무것도 바꾸지 않은 상태 저항 켬 : 데이터 선을 땅 쪽으로 약하게 눌러 주는 상태 센서 끔 / 센서 끔+저항 켬 : 사람 감지 센서의 측정을 멈춘 대조 조건. 튀는 값이 센서 쪽에서 오는지 보려는 것이다 측정 순서는 이랬다. 기본 → 저항 켬 → 기본 → 저항 켬 → 센서 끔 → 센서 끔 + 저항 켬 (각 블록마다 스냅샷 3회) 기본을 두 번 넣은 이유 가 이 설계의 핵심이다. 한 조건을 몰아서 연달아 재면 "조건 차이"와 "시간 차이"가 섞인다. 세션이 길어지면 환경도 달라지기 때문이다. 교대로 배치하면 그 둘을 가를 수 있다. 그리고 판정의 첫 관문은 비교가 아니라 재현 이었다. 기본 조건에서 튀는 값이 다시 나오지 않으면, 그 뒤의 비교는 아무것도 말해 주지 않는다. 교대로 두 번씩 재도 기본 조건 6번 모두 튀는 값이 나왔고, 그래서 비교가 성립했다. 결과: 줄어들지 않았다. 그런데 한 가지는 바뀌었다 스냅샷 18개가 전부 유효했고, 튀는 값의 개수는 이랬다. 조건 튀는 값 개수 (스냅샷별) 기본 (1차 / 2차) {1, 4, 5} / {2, 1, 5} 저항 켬 (1차 / 2차) {3, 5, 5} / {0, 1, 5} 센서 끔 0, 0, 0 센서 끔 + 저항 켬 0, 0, 0 저항을 켜도 개수는 기본과 같은 범위였다(0 5개 대 1 5개). 센서 측정을 끄면 저항과 상관없이 전부 0개였다. 그래서 "데이터 선이 떠 있어서 생긴다"는 후보는 이 실험으로는 지지되지 않았다. 그리고 튀는 값이 센서 측정과 같이 움직인다는 단서는 덤으로 얻었다. 그런데 한 가지는 달라졌다. 저항을 켜면 각 값의 끝자리 비트가 0으로 눌렸다. 끝자리가 0인 비트 수가 기본에서 5~6개이던 것이 저항을 켜면 7개 안팎(센서 끔+저항 켬에서는 8개)이 됐다. 약한 저항이 실제로 일을 하고 있었다는 뜻이다. 이 끝자리 비트들은 어차피 소리 값으로 변환하는 과정에서 버려지므로 소리 자체에는 영향이 없을 가능성이 높다. 이건 실증이 아니라 논증이다. 정리하면 "저항은 동작했고, 튀는 값은 별개의 경로일 수 있다"까지가 이 실험이 말해 주는 범위다. 여기서 조심할 게 있다. 칩 안쪽 저항은 값이 약하다. 문서가 권하는 외부 저항이었다면 결과가 달랐을지는 여전히 모른다. 그래서 "저항은 소용없다"로 읽히지 않도록 했다. 실험이 문제를 고치지는 못했지만 후보 목록에서 하나가 줄었고, 그게 이날의 진척이었다. 측정값에 섞여 들어온 것들 실험 설계가 깔끔했던 것과 별개로, 측정 현장은 깔끔하지 않았다. 시야에 물건이 있었다. 측정 전에 사람 감지 센서 값을 보니 앞쪽 물건 때문에 "감지됨/아님"이 2초마다 뒤집히고 있었다. 물건을 치우자 구역 수치가 7 8에서 0 4로 내려가 안정됐다. 이걸 확인하지 않고 쟀으면 센서 쪽 요동이 마이크 결과에 섞였을 것이다. 스냅샷 하나가 오염됐다. 튀는 값을 뺀 소리 크기가 다른 스냅샷의 약 6배였다. 그 순간 실제 소리가 들어갔다는 추정이다. 다만 무슨 소리였는지는 기록이 없다. 그래서 그 스냅샷의 개수는 참고용으로만 표시했다. 첫 시도에서는 스냅샷이 0줄이었다. 기록 파일을 열어 보니 소리 줄이 하나도 없어서 놀랐는데, 키 입력이 모니터 창을 클릭해 활성화하기 전에는 기기에 전달되지 않았다. 기록은 정상이었고 입력이 들어가지 않았을 뿐이었다. 기록 방식도 바꿨다. 화면을 복사해 붙이는 대신 터미널 기록 명령으로 파일에 실시간으로 이어 쓰게 했다. 소리 줄과 센서 줄이 섞여 나오는 출력이라 복사하다 빠뜨릴 위험이 컸다. 아직 다 된 건 아니다 이 실험은 후보 하나를 약하게 만든 것이지 원인 규명도 해결도 아니다. 튀는 값의 해결책은 아직 정해지지 않았다. 칩 안쪽의 약한 저항으로 한 실험이다. 외부 저항의 효과는 판정하지 못했다. 끝자리 비트가 소리 값에 영향이 없다는 건 논증이고, 실증하지 않았다. 스냅샷 하나의 오염은 추정이다. 그때 어떤 소리가 있었는지 모른다. 마무리 이날 마이크 쪽에서 얻은 건 "여기까지는 아니다"였다. 후보 목록에서 하나가 약해졌고, 튀는 값이 센서 측정과 같이 움직인다는 단서가 하나 생겼으며, 측정 현장에서 걸러야 할 오염이 몇 가지 드러났다. 고친 건 없다. 그래도 교대 배치로 시간 차이를 가르고, 먼저 문제가 재현되는지를 확인하고, 말할 수 있는 범위를 끝까지 좁혀 적고 나니 다음 후보로 넘어갈 수 있었다. 실험이 문제를 고치지 못해도, 설계가 맞았다면 후보 하나는 줄어든다. 띵동(Ddingdong)은 청각장애인 1인 가구를 위한 현관 부착형 소리 분류 알림 시스템 졸업작품입니다. 초인종 · 노크 · 화재경보를 구분해서 사진과 자막까지 스마트폰으로 보내주는 걸 목표로 만들고 있습니다.