AI 시대의 개발자는 검증을 설계한다 (4) - 리팩토링을 견디는 Test
리팩토링을 하는 중입니다. 함수 위치를 옮깁니다. 컴포넌트를 쪼갭니다.
리팩토링을 하는 중입니다. 함수 위치를 옮깁니다. 컴포넌트를 쪼갭니다.
휴.. 코드가 깔끔해졌네요.
이제 PR만 올리면 되겠지? 라고 생각하는 순간.
테스트가 실패합니다.
뭐지? 코드를 다시 봐도 버그는 없습니다.
저번에도 비슷한 수정을 했는데, 테스트가 실패했던 게 생각납니다. 그때는 DOM 요소 순서가 바뀌어서 테스트가 실패했었는데요. 실제 버그가 아닌 테스트의 문제였습니다.
실패한 케이스를 고치려니 또 시간이 듭니다. 살짝 짜증이 올라옵니다.
그냥 AI한테 수정하라고 하면 되는 거 아니냐고요? 그것도 제대로 했는지 검증하려면 시간이 듭니다. 무지성으로 ‘실패하는 케이스 수정해’라고 했다간, 테스트 코드가 초록불만 띄우는 거수기가 될지도 모릅니다.
실패한 테스트 때문에 고민을 계속합니다. 이런 일이 몇번 반복되니, 테스트가 나를 도와주는 게 아니라, ‘걸리적 거린다’라는 생각이 듭니다.
> 아… 그냥 꺼버릴까?
화살은 막고, 움직임은 막지 않는다
역사 속에 등장하는 갑옷을 보면 다양한 디자인이 있습니다.
어떤 갑옷은 두꺼운 철판을 촘촘히 덮습니다.
어떤 갑옷은 가죽이나 체인 같은 소재를 쓰거나, 아예 일부를 열어두기도 합니다.
다양한 디자인은 결국 ‘2가지 조건’의 균형을 맞추는 노력입니다.
-
적의 화살은 막는다.
-
내 움직임은 막지 않는다.
테스트도 갑옷과 똑같습니다. 버그는 막되, 리팩토링은 막지 않아야 합니다.
진짜 버그는 화살입니다. 버그라면 테스트가 실패해서 버그가 배포되는 걸 막아야 합니다.
반대로 리팩토링은 움직임입니다. 리팩토링은 기능을 그대로 두고 구조만 바꾸는 일이니까요. 정말 구조만 바꿨다면, 리팩토링 때문에 테스트가 실패하면 안 됩니다.
> 리팩토링을 했는데 테스트가 실패하면 안 된다.
제가 좋아하는 책 <단위 테스트> 에서는 이걸 ‘리팩토링 내성’(resistance to refactoring)이라고 부릅니다.
리팩토링 내성은 단순히 ‘있으면 좋다’ 가 아닙니다. 리팩토링 내성이 없으면 결국 테스트는 ‘양치기 소년’이 됩니다. 계속 가짜 알람이 울리니까요.
가짜 알람이 자꾸 울리면 개발자는 테스트가 통과하도록 수정해버리거나, 아예 꺼버리게 됩니다.
쉽게 말해 걸리적거리는 갑옷을 벗어던지는 거죠. 그러면 우리는 다시 버그에 노출됩니다.
> “리팩토링하다 테스트가 깨지면, 그냥 AI가 테스트도 고치게 하면 되잖아?”
이렇게 생각할 수도 있습니다.
하지만 무지성으로 AI가 테스트를 고치게 하면, 테스트가 의미없어지는 건 마찬가지입니다.
AI는 테스트가 ‘통과’하도록 수정을 할테니까요. 테스트는 이미 있는 코드를 통과키는 쪽으로 기울어져서 방어력을 잃어버립니다.
결국 AI가 수정은 하더라도, 테스트가 ‘검증해야할 것을 제대로 검증하고 있는지’는 여전히 사람이 봐야합니다.
문제는 AI한테 아무런 가이드나 강제 장치 없이 ‘방금 구현한 것 테스트도 짜줘’ 라고 하면 리팩토링에 취약한 테스트를 쓰기 일쑤라는 겁니다.
테스트 스위트를 검증 시스템으로 잘 활용하려면, 리팩토링에 취약한 테스트가 무엇인지 알아야 합니다. 그리고 그걸 효율적으로 걸러낼 수 있는 시스템을 설계해야 합니다.
무엇이 안 좋은 패턴일까? Arrange, Act, Assert로 알아보기
<단위 테스트> 저자 블라디미르 코리코브(Vladimir Khorikov)는, 리팩토링 내성이 없어지는 원인을 한 마디로 이렇게 요약합니다.
‘구현에 결합된 테스트’.
내부 구현에 연결된 테스트는 리팩토링에 쉽게 깨져버립니다.
외부 관찰 가능한 결과만 보는 테스트는 쉽게 깨지지 않습니다.
저는 구현 결합을 Arrange - Act - Assert의 3개 유형으로 나눠봤습니다.
Arrange
테스트할 준비를 합니다. ‘setup’이라고도 하죠. 테스트 대상인 것과 테스트 대상이 아닌 것을 구분하고, 경계를 격리합니다. 격리를 위해서 이 단계에서 Mock을 설정하게 됩니다.
이 단계에서 나타나는 구현 결합은 내부 협력자를 Mocking 하는 겁니다. 테스트에서 세부 구현을 경계로 격리한다는 뜻입니다.
세부 구현은 리팩터링으로 바뀔 수 있습니다. React Hook이나 Repository 같은 내부 경계에서 격리를 하면, 구현이 바뀔 때 테스트도 쉽게 깨집니다.
Act
테스트할 동작을 발생시킵니다.
단위 테스트일 때는 테스트 대상의 함수나 메서드를 호출합니다.
통합 테스트일 때는 사용자의 클릭이나 이벤트를 흉내냅니다.
Act 단계에서는 보통 Private 메서드를 호출하거나, DOM에 이벤트를 강제 발생시키는 식으로 액션을 만드는 케이스가 있는데요. 이런 패턴이 리팩토링에 취약한 테스트를 만듭니다.
Assert
실행 결과를 확인합니다.
이 단계에서는 먼저 실행 결과를 확인할 대상을 찾습니다.
찾는 방식이 중요합니다. DOM 구조에서 ‘N번째 List 안의 p 태그’ 이런 식으로 요소를 찾으면 DOM 구조가 바뀌었을 때 쉽게 깨집니다.
그리고 실행 결과를 확인할 때, 결과가 이래야 해! 라고 선언합니다.
이 때 결과를 잘 선언해야합니다. 특정한 DOM의 속성으로 한다거나, JSON 전체로 한다거나 해서 잘못된 패턴을 쓰면, 구현에 결합된 테스트가 됩니다.
잘못된 테스트 코드를 결정적으로 잡아내기
잘못된 패턴 예시를 드는 건 끝도 없이 할 수 있습니다. 하지만 이 아티클의 초점은 이겁니다.
> 어떻게 이 잘못된 패턴을 결정적으로 잡아낼 수 있을까?
우리는 AI가 테스트를 짰을 때, 구현에 결합된 테스트를 짰는지를 자동으로 검증하고 피드백을 주고 싶습니다. 사람이 일일이 검사할 필요가 없도록요.
1. Act는 도구로 잡는다
다행히도, Act 에서 나오는 ‘구현 결합’은 어렵지 않게 잡을 수 있습니다. ‘도구’를 잘 고르고 우회 수단을 막으면 대부분 해결됩니다.
먼저 React 앱의 예시를 들어보겠습니다.
React 진영에는 ‘React Testing Library (RTL)‘라는 훌륭한 라이브러리가 있습니다.
액션을 만드는 2가지 방법이 있는데요.
-
userEvent: 클릭, 마우스 오버 같은 이벤트를 실제 브라우저 동작과 가장 비슷한 방식으로 구현합니다. 가장 권장하는 Action 방법입니다.
-
fireEvent: DOM에 이벤트 객체를 강제로 주입합니다. 안 쓰는 것이 좋습니다. 내부 코드 구조와 쉽게 결합됩니다.
fireEvent를 쓰지 않고, userEvent를 쓰는 것만 잘 지켜도 Act의 잘못된 패턴을 막습니다. 사용자 행동을 시뮬레이션하면 개발자가 코드를 어떻게 짰는지와는 전혀 상관없이 작동하게 되거든요.
React Testing Library는 공식 eslint-plugin도 제공하는데요. userEvent만 사용하게 막아주는 규칙도 있습니다. 같이 더불어 사용하면 좋습니다.
백엔드의 통합 테스트를 보면 사용자의 액션은 ‘HTTP 클라이언트 요청’입니다. Typescript의 Supertest나, Kotlin의 WebTestClient은 HTTP 수준에서 액션을 만드는 도구입니다. 이런 도구를 쓰면 실수를 막을 수 있습니다.
또 다른 Act 관련 흔한 안티 패턴이 있습니다. 프라이빗 메서드를 호출하는 테스트입니다. 비공개 메서드는 언제든지 리팩토링을 위해 이름이 바뀌거나 합쳐지거나 사라질 수 있습니다. 외부에서 보기에 클래스의 기능은 같은데 내부 메서드를 쪼개거나 수정했다는 이유로 테스트 코드가 깨지게 됩니다.
현대 대부분 언어에서는 접근제어자 기능이 잘 돼있어 애초에 private method를 접근 못하도록 되어있습니다.
컴파일러를 무력화하는 우회 패턴만 잘 금지시키면 됩니다. AI가 작성한 테스트는 알아서 퍼블릭 인터페이스만 건드립니다.
AI에게 많은 테스트 케이스를 작성하도록 해봤는데요. Act 단계에서는 도구만 잘 써준다면 별다른 가이드 없이도알아서 잘합니다.
예를 들어 제 코드에서는 딱히 린트를 도입하지 않아도 fireEvent를 사용하는 코드는 0건이었습니다. (하지만 확실하고 결정적인 방어를 위해 prefer-user-event 같은 린트를 도입해주는 것도 나쁘지는 않겠죠.)
쉽죠? 하지만 이제 조금 복잡해집니다. 다음 Assert로 가보겠습니다.
2. Assert 는 Lint로 잡는다
Act보다는 조금 더 복잡합니다. Assert는 ‘검증 대상을 찾는 부분’과 ‘검증을 선언하는 부분’이 있는데요. 양쪽에서 모두 구현 결합이 생길 수 있습니다.
프론트엔드에서 가장 흔한 예시중 하나는, DOM 구조를 사용해서 검증 대상을 찾는 겁니다.
이렇게 찾으면 마크업이 바뀌면 테스트가 깨질 수 있습니다. 사용자는 DOM 트리를 사용해서 UI를 찾지 않습니다. 사용자는 ‘역할’과 ‘텍스트’로 UI를 찾죠.
또 검증을 선언할 때, DOM 속성/클래스를 쓰는 패턴도 있습니다.
사용자에게 비활성화된 버튼을 보여주거나 카드 UI를 보여주는 방법은 여러가지입니다. 특정한 속성/클래스를 쓰게 되면 그게 바로 구현 결합입니다.
좀 더 좋은 방법은 사용자 관점에서 선언하는 ‘시멘틱 매처’입니다. 예를 들어, 사용자에게 요소가 보인다는 건 다양한 속성으로 나타날 수 있는데요. (display: none, visibility: hidden, opacity: 0, hidden 속성…)
`toBeVisible`같은 시맨틱 매처는 이걸 전부 종합해서 ‘보이는가?‘를 판정해줍니다.
‘행동을 단언’하는 방법도 좋습니다. 예를 들어, 사용자 입장에서 ‘비활성화’에 가장 가까운 것은 결국 ‘눌러도 아무 일도 일어나지 않는다’는 거니까요.
비슷한 맥락으로, 백엔드에서는 JSON 전체를 넣고 일치하도록 단언하는 패턴도 있습니다.
이런 식으로 JSON 전체를 단언해버렸다고 해볼까요? 그러면 나중에 nickname 필드를 하나만 추가해도 테스트가 깨집니다. 실제로 사용자가 원하는 정보는 하나도 깨지지 않았는데 말이죠. 그래서 단언은 부분 일치로 해야합니다.
그럼 Assert 실수는 어떻게 결정적으로 막아야할까요?
Assert에서는 ‘RTL’이나 ‘WebTestClient’ 처럼 구현 결합을 아예 불가능하게 만드는 도구까지는 없습니다.
하지만 패턴이 대부분 구문에 드러납니다. 즉, Lint로 잡아낼 수 있습니다.
DOM 트리 접근을 막는 `no-node-access` , container 사용 자체를 막는 no-container 같은 규칙이 RTL의 공식 eslint-plugin에 있습니다. 검증 대상을 ‘잘못 찾는’ 패턴은 이 둘로 거의 잡아요.
‘잘못 단언하는’ 쪽은 jest/vitest의 eslint 플러그인 류들이 대부분 막아주고요.
스냅샷 전체 단언은 no-large-snapshots로 크기를 제한하거나, 아예 ‘no-restricted-matchers’로 ‘toMatchSnapshot’ 자체를 금지하면 됩니다.
Assert의 구현 결합은 미묘하고 종류가 많아서 좀 생각이 필요합니다. 그래도 대부분 Lint로 잡을 수 있습니다. 하지만 마지막 카테고리는 좀 더 어려워집니다.
3. Arrange는 제대로 된 Mock이 핵심
Arrange는 가장 구현 결합이 스며들기 쉬운 파트라고 봅니다. 까다로운 안티패턴이 많습니다.
예를 들어 query layer를 mock한 테스트와, HTTP layer를 mock한 테스트가 있습니다.
당연히 Query 레이어를 모킹한 테스트는 쉽게 깨집니다. Query 레이어는 전체 프론트엔드 시스템을 봤을 때 안쪽에 있는 레이어이고, HTTP 레이어는 가장 바깥입니다. Query 레이어를 격리한 테스트는 더 쉽게 깨질 수밖에 없습니다.
백엔드에서도 Service 통합 테스트를 할 때, Repository를 Mock하는 경우가 비슷한 실수입니다. 리팩토링에 쉽게 깨져버리죠.
무분별한 Mock.
제 경험상 AI가 가장 실수를 많이 하는 부분입니다. Mocking에 대해서는 좋은 패턴과 나쁜 패턴을 무 자르듯 판단하기 어렵기 때문이지 않을까 싶습니다.
실제 데이터도 있습니다. 소프트웨어 마이닝 학회(MSR)에서 2026년 나온 연구가 있습니다. 이 연구는 2025년 커밋을 120만 개 분석했는데요.
코딩 에이전트가 짠 테스트는 36%가 mock을 추가하는 커밋이었고, 사람은 26%가 mock을 추가하는 커밋이었습니다. 다시 말해 코딩 에이전트는 Mocking을 남발하는 경향이 나타났습니다.
이 연구에서는 실제 객체 간의 복잡한 의존성을 풀어서 통합 테스트를 짜는 것보다, 주변 환경을 다 모킹으로 격리해 버리면 테스트 실패를 피하고 통과(Green light)시키기가 훨씬 쉬워서. 라고 결론을 내립니다.
AI가 테스트 코드를 작성할 때 다른 어떤 안티패턴보다, 과도한 Mocking이 두드러졌습니다. 개인적으로 짐작해보건대, Mocking은 안 된다는 규칙이 가장 명확하지 않기 때문이라고 봅니다.
무엇을 Mocking 해야하고, 하면 안되는지를 결정적으로 잡아내는 규칙은 없습니다. 코드가 어떤 객체와 시스템들이 서로 협력하는지에 따라 달라집니다. SDK는 mocking을 해도 될까? 피쳐 플래그 모듈은 mocking을 해도 될까? 인터랙션 라이브러리는 mocking을 해도 될까? 등등.
저도 처음에 테스트를 짰을 때 가장 문제가 2가지였습니다.
-
정책을 기반으로 하지 않고 코드를 기반으로 해서 밍숭맹숭하게 뚫리는 현상 (3편 내용)
-
Mocking을 무분별하게 사용해서 리팩토링에 취약한 테스트가 되어버리는 현상
특히 프론트엔드(클라이언트)는 Mocking할 의존성 자체가 많습니다. 의존성은 적으면서 로직이 어려운 코드보다는, 유저 행동, UI, 스토리지, API 등 다양한 외부 시스템에 의존하고, 연결된 코드가 복잡하죠. 그 많은 경우의 수 중 Mocking 에 대한 판단이 한 두번씩 어긋나기 시작하면 금방 테스트 코드는 취약해집니다.
그렇지만 말했듯이 Mocking은 단순히 도구를 설치하거나 린트를 빡세게 설정하는 것만으로는 잡기가 어렵습니다. 어떻게 해야할까요?
의미를 구문으로 바꾸기
의미적인 실수를 막는 흔한 방법이 있습니다. ‘CLAUDE.md’에 쓰거나, Skill 파일에 작성합니다. 에이전트가 코딩을 할 때 컨텍스트로 먹입니다. MSR 연구에서도 Mocking 안티패턴을 CLAUDE.md에 명시해라, 정도로 결론을 내리고 있죠.
물론 그것도 나쁘지 않은 방법입니다. 하지만 마크다운 파일에 작성하는 것만으로는 확률적인 결과가 나옵니다. 우리는 더 결정적으로 막을 수 없을까요?
예를 들어, Vitest(프론트엔드 테스팅 프레임워크)엔 ‘vi.mock’ 메서드가 있습니다.
린트는 구문 분석을 통해 이 메서드를 썼다는 건 압니다. 하지만 이게 적절한 Mocking인지는 어떻게 알까요?
내부 협력자를 mocking한 건지, 아니면 정당한 경계에 걸린 건지는 의미 판단의 문제가 됩니다.
저는 규칙과 규칙을 조합해서, 의미 판단을 구문 판단으로 바꾸려고 시도해봤습니다.
제 코드베이스에는 특정 레이어의 코드는 특정 이름의 폴더에 들어가야 한다는 규칙이 있습니다. 리액트 hook은 ‘/hooks’, 서버에서 데이터를 가져오는 query는 ‘/queries’, 외부 응답을 변환하는 로직은 ‘/models’ 등으로 폴더 규칙을 만들어 두었습니다.
hook, query, model 등은 대표적인 내부 협력자입니다. 이 친구들은 99% Mocking하면 안 됩니다. 그래서 ‘/hooks’, ‘/queries’, ‘/models’ 폴더에 들어있는 파일은 “mocking하지 말라”는 금지 리스트를 만들었습니다. 의미적인 위반을, 구문적인 위반으로 만들려고 시도해본 것이죠.
이 방법도 꽤나 괜찮았는데요. 약간의 문제가 있었습니다. 금지 리스트에 들어있지 않은 mock은 또다시 판단을 해야 했습니다. 금지 룰에 걸리진 않지만 애매한 mock들이 야금야금 들어가 있더라고요.
그래서 금지 리스트가 아니라, 허용한 것만 쓸 수 있게 하는 화이트 리스트 방식으로 바꿔보았습니다.
테스트 코드에서는 vi.mock이라는 자유도 높은 mock 함수를 금지해버렸습니다.
HTTP 단을 격리하는 Mock처럼 정당한 녀석들을 별도의 함수로 추출했습니다. 테스팅을 위한 공용 함수에는 이제 ‘제가 인정한 정당한 협력자’만 등록해놓습니다. 모바일 앱 브릿지, HTTP, Browser 같은 것은 누가봐도 확실한 외부 시스템입니다.
공용 함수의 인터페이스는 어떤 협력자를 mocking하는지가 드러나도록 설계했습니다. 예를 들어 ‘mockFetchCard’ 같은 이름이 아니라, ‘server.respond(cardResponse)’ 같은 식으로요.
이런 인터페이스 설계 덕분에 우리는 ‘mocking하는 대상이 정말 우리가 책임지거나 소유하지 않는 외부 협력자인가?‘를 의식합니다.
이제 ‘vi.mock’, ‘vi.fn’ 등 직접 mocking 메서드를 모두 금지하는 린트를 적용했습니다. 공용 함수가 있는 폴더만 예외로 둡니다.
적용하고 에러가 난 부분을 쭉 봤습니다. 제가 정의한 협력자 외의 mock들이 자동으로 드러났습니다. 자기 유틸 함수를 mock한 테스트, 형제 컴포넌트를 mock한 테스트…
진짜 ‘정당한 협력자’인 경우에만 새로운 Mock 함수를 추가했습니다. 생각보다 많지 않았습니다. 5~6개 정도의 주요 협력자 Mocking 도구만 있으면 대부분의 통합 테스트를 짤 수 있었습니다.
사실 의미적 판단은 사라진 게 아닙니다. 대신 AI가 매번 그때그때 판단하지 않도록, 판단의 시점을 옮겼습니다. 애초에 설계 단계에서 ‘이것만 Mocking할 수 있다’라고 미리 고정해둔 겁니다.
이건 앞서 말했던 React Testing Library 같은 좋은 도구들이 이미 증명해놓은 지혜입니다. 애초에 안 좋은 패턴은 API 단에서 사용할 수 없도록 막아버리기.
이제는 아무 가이드 없이 테스트 코드를 작성하라고 시켜도, AI가 Mocking을 실수 없이 깔끔하게 처리하게 되었습니다.
의미를 구문으로, 확률을 결정으로
AI는 코드를 엄청나게 빠르게 작성합니다. 하지만 우리는 그 생산성을 100% 누리지 못합니다. AI가 쓴 코드를 그대로 믿고 배포할 수 없기 때문입니다. 검증이 병목입니다.
이 병목을 어떻게 풀 수 있을까요?
검증의 기준은 사람의 의도와 원칙이어야 합니다. 하지만 검증의 실행은 결정적이고 효율적이어야 합니다. 사람이 다 하면 느리니까요.
이 시리즈에서 우리는 린트와 테스트라는 검증 기계를 살펴봤습니다.
테스트는 버그는 막되, 리팩토링은 막지 않아야 합니다. 갑옷이 화살은 막고 움직임은 막지 않는 것처럼요.
그래서 AI가 리팩토링에 취약한 테스트를 짜지 못하도록, 안티 패턴을 잡아내는 검증 시스템을 설계했습니다.
Act의 구현 결합은 도구로 잡았습니다. Assert는 린트로 잡았습니다. Arrange가 가장 까다로웠습니다. 의미 판단을 구문 판단으로 바꿔내는 설계가 필요했습니다.
느끼셨겠지만 원리는 새롭지 않습니다. 테스트를 잘 짜는 원칙과 도구는 이미 오래된 지혜입니다. 잘 가져다 쓰면 됩니다.
하지만 의미적인 판단이 필요한 것들, 단순하게 YES/NO로 가를 수 없는 것들이 있습니다.
그럴 때는 의미 판단을 구문 판단으로 바꿔냅니다. 규칙과 규칙을 조합하고, 새로운 API를 설계합니다. 판단을 설계 시점으로 옮기고, AI는 정해진 규칙 안에서만 움직이게 합니다. 2편 Lint에서도 했던 이야기입니다.
의미를 구문으로. 확률을 결정으로.
품질의 기준은 사람이 명확하게 세우되, 검증을 빠르고 결정적으로 실행하는 기계를 만드는 일.
이게 에이전틱 코딩의 병목을 푸는 핵심이라고 믿습니다.