리뷰 시간을 줄이기 위한 아키텍처 린트 도입과 운영기 (feat. Harmonize)
AI 도입 후 리뷰할 코드가 늘었다
올해 초부터 팀에 AI 도구를 본격적으로 도입했다. 몇 달이 지나자 리뷰가 밀리기 시작했다. 체감이 맞는지 확인하려고 데이터를 뽑아봤다.
AI 도구 도입을 기준으로 이전 기간과 이후 기간에 메인 브랜치로 머지된 PR을 비교했다. 기간의 길이가 달라 모든 수치는 월평균으로 환산했다.
| 지표 | 도입 후 변화 |
|---|---|
| 월평균 머지 PR | +58% |
| PR당 변경 줄(중앙값) | +78% |
| PR당 변경 파일(중앙값) | +50% |
| 월평균 총 변경 줄 | +114% |
PR 수가 늘었는데 PR 하나의 크기도 함께 커졌다. 그 결과 변경 줄 기준으로 리뷰해야 할 월평균 총량이 두 배 넘게 됐다.
이를 AI 도입의 결과라고 단정할 수는 없다. 같은 기간 프로젝트 진행 단계와 팀 상황도 달라졌다. 다만 리뷰 부담이 커졌다는 체감과 마찬가지로 데이터에서도 큰 폭의 변화가 나타났다. 늘어난 리뷰 양을 기존 방식으로 감당하기는 어려웠다.
반복되는 구조 검사는 린트에게
PR 리뷰에서는 기능의 흐름부터 예외 처리, 추상화, 아키텍처까지 사람이 모두 확인해야 했다.
특히 아키텍처는 변경된 파일만 봐서는 확인하기 어려웠다. ViewModel이 Repository를 직접 참조하는지, 모듈의 의존 방향이 맞는지 판단하려면 여러 파일의 관계를 함께 살펴봐야 했다. PR이 늘고 커질수록 이 작업의 부담도 함께 커졌다.
아키텍처에 관한 모든 판단을 자동화할 수는 없다. 다만 이미 합의된 구조 규칙은 기계가 반복해서 확인할 수 있었다. 사람은 기능의 흐름과 설계의 적절성에 집중하고, 정해진 구조를 지켰는지는 린트에 맡겨보기로 했다.
마침 레이어 의존 방향, 컴포넌트 책임 경계, 네이밍 계약 등이 문서로 정리돼 있었다. 그중 기계가 명확하게 확인할 수 있는 규칙을 골랐다. 예를 들면 다음과 같다.
- 아래 레이어가 위 레이어를 import하지 못하게 하는 규칙
- ViewModel이 데이터 계층을 직접 참조하지 못하게 하는 규칙
- 요청 파라미터 타입이 대응하는 UseCase 파일 안에 있어야 한다는 규칙
Harmonize 도입
도구를 고르는 기준은 두 가지였다. 먼저 PR마다 실행할 만큼 빨라야 했다. 리뷰 시간을 아끼려고 켜는 검사가 오래 걸리면 정착하기 어렵다.
또 소스 문자열을 정규식으로 훑는 대신 선언 구조를 보고 싶었다. final class A: B, C where T: D 같은 선언을 직접 파싱하는 코드를 만들고 싶지는 않았다.
이 두 조건이 대부분의 선택지를 지웠다. 검토한 후보 중 컴파일러의 타입 정보를 이용하는 방식은 전체 빌드가 필요했는데, 모놀리스 앱을 PR마다 빌드하기에는 부담이 컸다. 반대로 정규식 기반 커스텀 룰은 빠르지만 상속 목록이나 프로퍼티의 타입 표기처럼 구조화된 정보를 다루기 어려웠다.
Harmonize는 그 사이에 있었다. SwiftSyntax 위에 선언 질의 API를 얹은 라이브러리다. 클래스, 구조체, 프로퍼티, 함수, import를 배열로 받아 필터링할 수 있다.
source.classes(includeNested: false)
.filter { $0.name.hasSuffix("ViewModel") }
.filter { !$0.inheritanceTypesNames.contains { $0.hasSuffix("ViewModel") } }
앱 코드를 링크하지 않는다는 점이 컸다. 테스트 타깃은 Harmonize에만 의존하고 소스를 파싱한다. 덕분에 전체 소스 파일을 빌드 없이 PR마다 실행할 수 있는 시간 안에 검사했다. 규칙이 유닛 테스트라 로컬에서 Xcode로 디버깅할 수 있는 것도 좋았다.
레거시 위반과 함께 사는 법
규칙을 켜자 기존 위반이 한꺼번에 쏟아졌다. 규칙 없이 오랫동안 쌓인 코드이니 당연한 결과였다.
기존 위반을 모두 고칠 때까지 기다리면 린트를 영영 켜지 못한다. 기존 위반 파일 목록을 baseline으로 동결하고 새 위반만 막기로 했다.
장치를 하나 더 붙였다. 동결된 파일이 나중에 수정돼 규칙을 통과하면 baseline에서 지우라고 실패시키는 것이다. 그대로 두면 목록이 계속 불어나고, 그 파일이 다시 규칙을 어겨도 baseline에 가려 잡히지 않는다.
이 장치는 의도대로 동작했다. 다만 예상하지 못한 부작용이 있었다. 그 이야기는 뒤에서 이어진다.
한 달 뒤 실패 기록을 다시 살펴봤다
한 달 동안 쌓인 실행 기록을 모두 받아 실패 원인별로 나눴다.
| 실패 원인 | 비율 |
|---|---|
| 실제 규칙 위반 | 52% |
| baseline 정리 필요 | 42% |
| 테스트 러너 크래시 | 6% |
실패의 48%는 PR 코드의 문제가 아니었다.
리뷰 시간을 아끼려고 켠 린트였지만 실패의 절반 가까이는 리뷰어가 볼 필요가 없었다. baseline 정리가 필요했던 42%는 왜 실패했는지조차 알려주지 않았다.
실패 기록을 살펴보니 baseline 정리가 필요한 경우에도 원인을 알려주는 단계가 아무런 메시지 없이 종료되고 있었다. 여기에 수정된 항목이 baseline에 남으면서 관련 없는 PR까지 반복해서 실패했지만, 담당자는 로그만 보고 이유를 알 수 없었다.
이후 실제 규칙 위반, baseline 정리, 테스트 러너 오류를 구분해 각각 필요한 조치를 안내하도록 고쳤다.
완전하지 않았던 규칙들
알림을 고친 뒤에는 규칙 자체를 점검했다.
SwiftSyntax 기반 파서는 선언 구조를 알지만, 타입 이름이 실제로 어떤 선언을 가리키는지까지 해석하지는 않는다. “ViewModel은 지정된 베이스 클래스를 상속해야 한다”는 규칙도 상속 타입의 이름만 보고 있었다. 그 결과 계약을 따르지 않는 BaseViewModel도 이름이 ViewModel로 끝난다는 이유로 통과했다.
검사 대상이 비어 있거나 빌드 산출물이 포함된 경우도 초록불이 떴다. 제외 목록에는 실제 경로가 아닌 타깃 이름이 들어가 있어 아무 역할을 하지 못했다. 린트가 아무것도 하지 않거나 엉뚱한 대상을 보고 있어도 성공하는 상태였다.
그래서 규칙의 결과뿐 아니라 검사 대상이 제대로 잡혔는지도 따로 확인하도록 했다. 규칙을 지키는지 검사하는 테스트와 규칙 자체가 살아 있는지 확인하는 테스트가 모두 필요했다.
Harmonize에 기여하기 - actor
점검하는 동안 Harmonize가 actor 선언을 수집하지 않는다는 것도 발견했다. actor 파싱 지원 이슈는 완료 상태로 닫혀 있었지만 실제 구현은 없었다.
문제는 조회 API가 없는 데서 끝나지 않았다. actor 자체가 선언 목록과 스코프에 들어가지 않아 내부의 프로퍼티와 함수가 바깥에 있는 것처럼 수집됐다. 부모 관계를 이용하는 규칙이라면 actor를 직접 다루지 않아도 잘못된 결과를 받을 수 있었다.
actor 선언 수집과 조회 API, 회귀 테스트를 추가해 PR을 올렸다. 수정 전후 동작과 설계상 판단이 필요한 부분을 본문에 구분해 적었고, PR은 이틀 뒤 머지됐다.
리뷰 부담을 조금 덜었다
모든 리뷰를 자동화할 수는 없었다. 기능의 흐름이 자연스러운지, 예외 처리가 충분한지, 설계가 적절한지는 여전히 사람이 판단해야 한다.
대신 여러 파일을 오가며 반복해서 확인하던 구조 규칙은 린트가 먼저 검사하도록 했다. 한 달 동안 운영하며 실패 안내와 검사 대상의 문제도 고쳤고, 린트가 실제로 규칙을 확인하고 있는지도 함께 검증하게 됐다.
리뷰 시간이 얼마나 줄었는지는 따로 측정하지 않았다. 그래도 정해진 구조를 지켰는지 매번 사람이 처음부터 확인해야 하는 부담은 줄일 수 있었다. 린트가 리뷰를 대신하지는 않았지만, 리뷰어가 더 중요한 판단에 시간을 쓰도록 돕는 역할은 해주었다.
댓글 남기기