그래서 어제 방청소를 했다
AI로 코드를 너무 쉽게 고치게 되자 6개월 동안 구조를 마구 갈아엎었다. 방어선은 없었고, 버그는 또 AI로 막았다. 무엇이 새는지부터 측정하기로 한 이야기 — 그리고 어제, 진짜로 방을 치운 이야기다.
AI로 코드를 너무 쉽게 고치게 되자, 6개월 동안 구조를 마구 갈아엎었다. 방어선은 없었고, 버그는 또 AI로 막았다. 무엇이 새는지부터 측정하기로 한 이야기 — 그리고 어제, 진짜로 방을 치운 이야기다.
매달 방을 치우기로 했는데
우리 팀(치과 3D 스캔 솔루션)은 한동안 매달 한 번 통합 배포를 목표로 프로세스를 잡아가고 있었다. 4월까지는 그럭저럭 굴러갔다. 그런데 5월 배포가 품질 게이트를 넘지 못했고, 예정일은 한 번 밀리고 두 번 밀려, 결국 7월로 넘어갔다.
이유는 단순했다. 너무 큰 변경이, 너무 많이 일어났다.
재미있는 건, 그게 기능 변경이 아니었다는 점이다. 새로 추가된 기획 자체는 많지 않았다. 변한 건 서비스의 겉모습이 아니라 그 아래, 근본적인 구조였다.
6개월 동안 우리가 갈아엎은 것들
AI를 개발에 들이면서 코드를 고치는 일이 너무 쉬워졌다. 예전 같으면 “이건 좀 무서운데” 하고 방어적으로 우회하던 수정을, 이제는 더 아래 계층까지 거리낌 없이 손대게 됐다. 그렇게 우리가 6개월 안에 바꾼 것들을 적어보면 이렇다.
- 인증 방식을 토큰에서 세션으로 바꿨다.
- 여러 서버를 하나로 통합했다.
- 오브젝트 스토리지를 한 클라우드에서 다른 클라우드로 이전했다.
- 클라이언트의 3D 엔진을, WASM을 번들에 싣고 쓰던 사내 라이브러리에서 범용 WebGL 엔진으로 갈아탔다.
- 모듈을 합치는 방식을 iframe 기반 런타임 통합 → 빌드타임 통합으로 바꿨다가, 다시 iframe으로 되돌아왔다.
- 빌드 파이프라인을, 환경마다 따로 빌드하던 방식에서 한 번 빌드해서 어디에든 배포하고 환경 변수는 런타임에 주입하는 방식으로 바꿨다.
여기 적은 건 일부다. 이런, 원래라면 꽤 긴 호흡으로 진행해야 할 일들을, 너무 쉽고 빠르게 시작할 수 있었기 때문에 거의 마구잡이로 벌였다.
오해는 말자. 이 변경들은 다 이유가 있었다. 우리는 마이크로서비스를 잘못 활용하고 있어서 불필요한 네트워크 홉이 많았다. 글로벌 서비스인데 데이터를 한 리전에 몰아넣고 있었다. 구성이 너무 복잡해서 문제가 터지면 추적조차 어려웠고, 원인 파악이 안 되니 임시방편으로 틀어막은 자국이 곳곳에 쌓여 있었다. 클라이언트는 굳이 사내 라이브러리를 쓰지 않아도 되는 기능에까지, WASM 바이너리를 번들에 끼워 넣으면서 그 라이브러리에 의존하고 있었다.
그러니까 이 모든 건 개선이었다. 그리고 개선된 건 명백히 보였다. 적어도, 간단히 보기에는.
그런데 — 방어선이 없었다
문제는 따로 있었다.
우리에겐 기존 서비스를 지켜줄 적절한 테스트가 거의 없었다. 이 큰 구조 변경들 앞에 방어선이 한 겹도 없었다는 뜻이다. 멀쩡히 되던 기능이 안 되거나, 이상하게 동작하는 일이 계속 터졌다.
여기서 한 가지를 분명히 짚고 싶다. 왜 되던 게 안 됐을까. 처음엔 “우리 코드가 틀렸나, 데이터가 이상한가”를 의심했다. 그런데 같은 모양의 회귀를 다섯 번 넘게 겪고 나서야 진짜 구조가 보였다.
구조를 갈아엎을 때, 우리는 “보이는 코드”만 옮긴다. 명시적으로 적힌 호출 — geometry를 로드하고, 색을 칠하는 코드 — 은 새 환경으로 잘 포팅된다. 그런데 이전 환경이 알아서, 암묵적으로 해주던 동작은 “옮길 대상”으로 인식조차 되지 않는다. 옛 3D 라이브러리는 렌더링 파이프라인의 어떤 단계를 프레임워크 기본값으로 자동으로 처리해 주고 있었다. 새 엔진은 그 기본값이 달랐다. 그래서 같은 입력에 다른 결과가 나왔다.
이게 핵심이다. 이전 도구가 우리 대신 해주던 보이지 않는 일들이, 갈아엎는 과정에서 소리 없이 사라졌다. 그리고 그걸 붙잡아줄 테스트가 없었으니, 그 빈자리는 전부 배포 후의 버그로 surface 됐다. 눈에 안 보이는 먼지가, 가구를 옮기고 나서야 드러난 셈이다.
그리고 우리는 그걸 또 AI로 고쳤다
더 큰 문제는 그다음이었다.
이 버그들을 잡으려고, 우리는 다시 AI를 썼다. 그러다 보니 정작 개발자가 자기 서비스에 대해 모르는 부분이 점점 많아졌다. 물론 처음 설계를 AI와 같이 했으니 큰 그림은 머릿속에 있다. 하지만 그 설계 단계에서 모든 걸 알고 그린 게 아니었다 — AI와 빠르게 합의하며 넘어간 빈틈이 곳곳에 있었다. 그 빈틈에서 버그가 계속 새어 나왔고, 우리는 또 AI로 메웠다. 빈틈을 모른 채 메우니, 메운 자리에서 또 샜다.
버그를 줄이려 했던 시도들의 기록
그동안 손 놓고 있었던 건 아니다. 오히려 너무 많은 걸 시도했다.
① 개발 검증 문서. 처음엔 “개발자가 명세를 완전히 구현했고, 직접 개발 환경에서 테스트를 마쳤다”는 문서를 남기기로 했다. 그런데 이런 기반이 없던 상태라, 모든 명세에 대해 검증했음을 증명해야 했다. 한 달이라는 배포 주기는 이걸 수동으로 하기엔 너무 짧았다. 게다가 한 달 내내 이것만 한 것도 아니다 — 기능 개발과 구조 개선을 동시에 하고 있었으니, 구조를 한 번 바꾸면 이미 검증해 둔 것을 다시 검증해야 했다. 그리고 이 문서를 쓰기 시작하면서, 명세 자체가 잘못된 것들까지 발견하게 됐다. 예전엔 말로 맞추거나 그냥 넘겼던 것들이, 글로 적으려니 비로소 드러난 것이다. 한 배포 주기 안에 이 문서를 완성하는 건 불가능했다. 게다가 매 배포마다 다시 만들어야 했다.
② e2e로 명세를 검증. 그래서 명세를 e2e 테스트로 짜고, 그게 통과하면 검증이 끝난 것으로 보기로 했다. 방향은 옳았다. 그런데 애초에 우리 서비스엔 e2e를 쉽게 도입할 테스트 환경이 없었다. 결정적인 환경의 부재, 셋업 코드의 부재, 테스트하기 어려운 코드 품질 — 전부가 걸림돌이었다. 그래도 조금씩 만들어 갔다. Page Object로 각 앱의 기본 기능 접근을 한곳에 모았고, QA 팀이 만들어 둔 테스트 케이스를 쉽게 가져오도록 저장소도 연결했다. 하지만 배포가 일주일도 안 남은 상태에서 하려니 시간이 부족했다.
③ AI가 직접 검증 (브라우저 자동화 + AI). 테스트 코드 작성이 막히자, AI에게 브라우저를 직접 몰며 명세를 따라가게 했다. 생각보다 잘 찾아냈다. 문제는 AI가 개입한 검증은 비결정적이라는 것. 매 검증이 다른 방식, 다른 결과로 흘러갔다. 정식 검증선으로 쓰려면 “항상 같은 것을 같은 방식으로 본다”는 보장이 있어야 하는데, 그게 없었다.
④ AI 검증 절차를 박제. 그래서 AI가 검증한 절차를 정형 포맷으로 고정해봤다. 이번엔 e2e 코드 대비 시간과 AI 토큰 비용이 만만치 않았다.
⑤ 격리 환경 + e2e. 결국 e2e 코드로 돌아왔다. 이번엔 결정론적인 결과를 위해 격리 환경을 도입했다. 그전까진 dev 환경에서 시드 데이터와 픽스처로 테스트를 돌렸는데, 머지되는 코드가 워낙 많아 오늘 통과하던 테스트가 내일 실패하곤 했다. 격리 위에서 짠 e2e는 한결 결정론적이었다.
이 와중에도, 버그가 하나 터지면 그 버그를 막겠다고 유닛·통합 테스트를 무한정 찍어냈다.
이렇게까지 했는데 — 배포 후 버그는 점점 더 많아지고 있었다.
한 걸음 물러서서: 왜 우리는 버그를 못 잡나
이쯤에서 멈췄다. 더 만들기 전에, 문제 정의부터 필요하다는 생각이 들었다.
처음 떠올린 답은 단순했다. 테스트 코드 품질이 너무 떨어진다. 사실 이 생각은 지금도 변하지 않았다. 프론트엔드 개발자로서 AI가 만든 프론트 유닛·통합·e2e를 보면 의아한 게 많았다.
내가 생각하는 좋은 테스트는, 구현 방식이 바뀌어도 깨지지 않는 테스트다. 그래야 그 테스트가 “무엇을 검증하려는지(=명세)“를 담고 있다는 뜻이니까. 그런데 AI가 만든 테스트, 특히 유닛·통합은 구현 그 자체를 검증하는 경우가 많았다. 이게 무조건 틀린 건 아니다 — 명세도 테스트도 없는 레거시 코드에 안전망을 두르는 정당한 기법(characterization test)이기도 하다. 다만 그건 이미 있는 코드를 그대로 고정하려는 것이지, 우리에게 필요한 “명세를 대신할 수 있는 테스트”가 아니었다. 버그까지 정답으로 박제하고, 사소한 리팩토링마다 깨지는, 최악의 조합이 되기 쉬웠다.
그런데 진짜 원인은 ‘구조’였다
테스트 품질이 원인이라 보고 조사를 시작했는데 — 파고들수록, 품질을 그렇게 끌어내린 더 근본적인 원인이 따로 있었다. 구조였다.
- 무엇이 검증을 빠져나가는지 측정할 방법이 없었다. 이런 지표가 있어야 “나아지고 있는지”를 알 수 있는데, 그 정보를 얻을 길조차 없었다.
- 코드 오너십이 무너져 있었다. 모두가 AI로 경계 없이 개발하다 보니 각자의 영역이 흐려졌고, 내 수정이 어디까지 영향을 줄지 아무도 자신하지 못했다.
- 테스트에 무감각해졌다. 테스트가 너무 많이 생기면서 유지보수는 안 됐고, e2e 파이프라인은 제대로 돌지도 않았다. 실패하는 테스트가 있는데도 아무도 고치지 않았다.
그리고 결정타. AI가 분석한 우리 테스트 품질은 “높음”으로 나왔다. 그런데 mutation testing을 돌려보니 —
mutation testing은 소스에 일부러 결함(mutant)을 심고, 테스트가 그걸 잡아내는지(kill) 측정하는 기법이다. 커버리지가 “코드를 실행했나”를 본다면, mutation score는 “그 코드가 틀렸을 때 정말 빨개지나”를 본다.
“A급”으로 평가받은 코어 라이브러리조차 mutation score가 57%였다. 일부러 심은 결함의 43%가 테스트를 그냥 통과했다는 뜻이다. 가장 나쁜 파일은 47%였다. 사람이든 AI든 “단단하다”고 한 평가가, 숫자 앞에서 착각이었음이 드러났다.
결국 그림은 이거였다. AI가 만들고, 그걸 다시 AI가 평가한다. 그러면 “괜찮아 보이는데 실제로는 버그를 못 잡는” 사각이 생긴다. 그리고 우리는 측정하지 않으면 그 사각을 영영 모른다.
첫 단추 — 측정부터
그래서 가장 먼저 할 일을 정했다. 우리가 확인할 수 있는 지표를 찾고, 그걸 보이게 만들자.
“검증을 빠져나간 결함의 비율”을 업계에서는 Defect Escape Rate 라 부르고, 이를 재는 통계 지표가 여럿 있다 — DRE(Defect Removal Efficiency), DDP, PCE, TDCE 등. 그런데 이들엔 공통점이 있다. 분모에 “개발이 잡은 결함”이 들어간다. 「개발이 잡은 것 + 빠져나간 것」 중 빠져나간 비율을 보는 식이라, 개발 단계에서 스스로 걸러낸 결함을 어딘가에 기록하고 있어야 한다. 그런데 우리에겐 그 데이터가 없었다. 무엇을 걸러냈는지를 아무 데도 남기지 않고 있었으니까.
다행히 얼마 전 “개발 테스트” 라는 필드를 도입했다. QA로 넘기기 전에, 수정한 본인이 아닌 다른 개발자가 검증하고 pass/fail을 남기게 한 것이다. 이 데이터는 다음 배포부터 쌓인다. 하지만 지금은 없다.
그래서 지금 당장 쓸 수 있는 형태로 고른 것이 CFR(Change Failure Rate) 계열의 접근이다. 핵심은 분모를 잡은 결함의 총량이 아니라 “배포” 로 바꾸는 것이다. 「이 배포가 흘려보낸 결함이 몇 개인가」 — 이건 개발이 무엇을 걸러냈는지 몰라도 셀 수 있다. DevOps의 DORA 메트릭이 Change Failure Rate를 배포 단위로 재는 것과 같은 발상이다.
여기엔 몇 가지 설계 판단이 있었다.
- 단순 건수가 아니라 무게를 같이 본다. “Minor 17개”와 “Critical 17개”는 전혀 다른 무게다. 그래서 건수와 severity 가중치를 함께 추적한다.
- 단위를 시간이 아니라 ‘배포’로 둔다. 버그가 언제 발견되느냐는 QA의 검증 속도와 순서에 휘둘린다(이번 주에 한 영역을 집중해서 보면 그 영역 버그가 급증한다 — 코드는 그대로인데). 반면 “이 배포가 결국 몇 개를 흘려보냈나”는, 충분한 시간이 지나면 고정된다.
- 정상 범위를 ‘관리 한계(control limit)‘로 본다. “20개 넘으면 사고” 같은 임의의 선을 긋지 않는다. 대신 우리 데이터의 분포에서 정상 변동 범위를 계산하고(그래프의 점선), 그 선을 넘는 배포만 “통계적으로 비정상 = 사고 배포”로 본다. 절대 점수가 아니라, 우리 자신이 나아지고 있는지를 추세로 본다.
- 여러 배포에서 반복 발견된 결함은 ‘재발’ 신호다. 실제로 두 번 이상 발견된 결함의 93%가 리오픈된 것이었다. 그래서 같은 데이터에서 escape와 재발을 함께 읽는다.
대부분의 배포는 정상 범위 안에서 흔들리고, 가끔 관리 한계를 한참 넘는 사고 배포가 튄다. 그 배포가 회고의 첫 대상이다.
멈춰서, 목표부터 다시 세웠다
측정이 보이기 시작했다는 건, 무엇이 새는지가 보이기 시작했다는 뜻이다. 이제 나아지는지 나빠지는지를 추적할 수 있다. 하지만 측정은 끝이 아니라 시작이다. 보이게 만든 다음엔, 실제로 손을 대야 한다.
여기서 나는 한 번 더 멈췄다. 이전처럼 단순하게 “테스트 품질 올리자”로 달려들면, 같은 문제를 또 반복할 것 같았기 때문이다. 그래서 목표부터 명확히 세웠다. 목표가 정해져야 다음 스텝이 정해지니까.
- 궁극의 목표: 우리가 만든 코드가 명세를 충실히 따른다는 것을 보장하는 것.
- 그걸 어떻게 증명하나? → 결국 테스트 코드로 귀결됐다. 기존 테스트들을 검수해, 좋은 테스트로 만들고, 유지보수하는 것.
- 그럼 작업 단위는? → 우선 dev 환경 기준으로 만들어 뒀지만 지금은 돌지 않는 테스트들을 살려내는 것부터.
마지막 단계에서 고민이 많았다. 어떤 테스트를 살릴 것인가. 도메인 기능 단위로 묶을지, 명세 기반으로 묶을지. e2e는 비싸고, 불필요하게 많은 걸 떠안을 필요가 없다는 데엔 동의했다. 그래서 기본 기능부터 만들기로 했다. 다만 “기본 기능”의 기준이 모호할 수 있어서, QA 테스트 케이스의 우선순위 1·2를 그 기준으로 삼았다. 그중 먼저 우선순위 1을 e2e로 검증해 보고, 굳이 e2e가 필요 없는 건 더 낮은 레벨로 밀어 내린다(push down).
여기엔 함정이 하나 있는데, 미리 짚어두고 싶다. “e2e부터 짜고 아래로 좁혀간다”는 건 만능 공식이 아니다. 이건 이미 있는데 안 도는 레거시 자산을 되살리는 국면에서나 맞는 전략(outside-in)이다. 내부 구조를 모르는 레거시는 넓은 e2e로 현재 동작을 먼저 고정해 안전망을 친 뒤 좁혀가는 게 맞지만, 신규 코드라면 정반대다 — 처음부터 낮은 레벨로 밀어 내려야 한다. e2e부터 짜면 느리고 잘 깨지는 더미만 쌓인다. 우리가 지금 outside-in을 택한 건, 정확히 “되살리는 국면”에 있기 때문이다.
이번엔 다르게 하려고 한다. 잘 될지는 모른다. 하지만 적어도 이미 겪어본 것들이 있으니, 같은 문제를 반복하지 않는 것 — 그게 목표다.
AI 시대의 개발에서 내가 배운 건 이렇다. AI는 코드도, 테스트도 빠르게 만들어준다. 그런데 AI가 만든 것을 다시 AI가 평가하면, “괜찮아 보이는데 실제로는 못 잡는” 사각이 생긴다. 좋은 테스트는 구현이 아니라 의도(명세) 를 검증해야 하고, 품질 개선은 “느낌”이 아니라 무엇이 새는지를 숫자로 보는 것에서 시작한다. 화려한 자동화를 더 쌓기 전에, 우리가 지금 어디에 서 있는지를 먼저 보이게 만드는 것. 그게 첫 단추였다.
그리고 어제, 진짜로 방을 치웠다
여기까지가 일 이야기다. 그런데 이 글의 제목은 사실 비유가 아니다.
어제 나는 힘들었다. 하루 종일 이 고민들을 붙들고, 사람들과 길게 이야기를 나누다 보니 머릿속이 꽉 차서 더는 아무것도 들어오지 않았다. 무언가 정리되긴 했는데, 정작 나 자신이 어수선했다. 그래서 노트북을 덮고 — 진짜로 내 방을 청소했다. 쌓아둔 걸 버리고, 자리를 다시 잡고, 바닥을 닦았다.
거창한 이유는 없었다. 그냥 새로운 마음가짐이 필요했다. 한 달을 밀린 배포와 끝없는 버그와 “왜 우리는 못 잡나”를 붙들고 있다가, 손에 잡히는 무언가를 직접 정리하고 싶었던 것 같다. 머리로만 굴리던 걸, 몸으로 한 번 끝까지 해보고 싶었다.
그런데 방을 치우면서 묘하게 겹쳐 보이는 게 있었다. 지난 6개월 동안 우리가 일에서 한 짓과, 그동안 내 방에 한 짓이 닮아 있었다. 좋아 보이는 걸 자꾸 들이고, 옮기고, 새로 사들이기만 하다가 — 정작 무엇이 쌓여 있는지, 무엇을 버려야 하는지는 들여다보지 않았다. 그러다 발 디딜 곳이 없어진 것이다.
방청소는 새 물건을 사는 일이 아니다. 멈추고, 꺼내 보고, 버릴 걸 버리고, 남길 걸 제자리에 두는 일이다. 마침 우리가 코드와 테스트와 프로세스에서 하기로 한 것도 그거였다. 무엇이 새는지 측정해서 보고, 무의미한 걸 덜어내고, 테스트를 명세라는 제자리에 돌려놓는 것.
그래서, 어제 방청소를 했다. 마음을 한 번 비우고 새로 시작하려고. 같은 방을 또 어지르지 않으려고.
참고 자료
좋은 테스트의 정의 / 명세 vs 구현 검증
- Khorikov, Unit Testing: Principles, Practices, and Patterns (4 Pillars) — https://www.sammancoaching.org/learning_hours/test_design/four_pillars_khorikov.html
- Google, Test Behavior, Not Implementation — https://testing.googleblog.com/2013/08/testing-on-toilet-test-behavior-not.html
- Characterization test (Michael Feathers, Working Effectively with Legacy Code) — https://en.wikipedia.org/wiki/Characterization_test
레거시 outside-in / push-down
- Best way to start testing untested code — https://understandlegacycode.com/blog/best-way-to-start-testing-untested-code/
- Martin Fowler, Test Pyramid — https://martinfowler.com/bliki/TestPyramid.html
- Strangler Fig pattern — https://learn.microsoft.com/en-us/azure/architecture/patterns/strangler-fig
Defect Escape Rate / Change Failure Rate
- Plandek, The Complete Guide to Escaped Defects — https://plandek.com/blog/escaped-defects
- Arcitura Patterns, Defect Escape Rate (배포당 정규화) — https://patterns.arcitura.com/devops-metrics-mechanisms-tools/metrics/defect-escape-rate
- DORA, DORA metrics guide — https://dora.dev/guides/dora-metrics/
Mutation Testing / “Coverage Theater”
- Google, State of Mutation Testing at Google — https://research.google.com/pubs/archive/46584.pdf
- Codecov, Mutation testing: ensuring coverage isn’t a vanity metric — https://about.codecov.io/blog/mutation-testing-how-to-ensure-code-coverage-isnt-a-vanity-metric/
결함 제거 효율 / 미발견 결함 추정
- Capers Jones, Software Defect Removal Efficiency — https://www.ppi-int.com/wp-content/uploads/2021/01/Software-Defect-Removal-Efficiency.pdf
- Capture-recapture in software inspections after 10 years research — https://www.sciencedirect.com/science/article/abs/pii/S0164121203000906