사람은 무엇을 리뷰하는가
나는 5년차 프론트엔드 엔지니어와 AI 엔지니어, 두 동료와 함께 심리상담 관련 AI 제품을 만들고 있다. 아직 MVP도 나오지 않았고, 스펙을 먼저 합의한 뒤 구현의 대부분을 AI 에이전트에게 맡기는 방식으로 개발하고 있다.
AI가 구현을 대신하는 시대에는 리뷰의 디테일도 목적에 따라 달라져야 한다고 생각한다. 개념과 스펙은 서로의 이견이 없어질 때까지 꼼꼼하게 본다. 반면 구현 코드에서는 테스트가 스펙의 동작을 제대로 검증하는지, 스펙이 필요한 동작을 빠짐없이 담고 있는지, 책임이 적절한 경계에 놓였는지를 중심으로 본다. 구현체가 if else를 쓰든 switch를 쓰든 크게 중요하지 않다.
팀을 세팅하면서 내가 기존에 활용하던 Git 워크플로우를 가져온 것도 이런 리뷰 방식 때문이었다. 나와 동료들이 이해하고 리뷰하기 좋은 단위로 작업을 나누기 위한 규칙이었다.
- PR은 하나의 맥락만 담는다.
- 테스트를 제외한 변경은 300줄을 넘기지 않는다.
- 커밋은 의미 단위로 나눈다.
- …
한 달도 지나지 않아 이 규칙들에 챌린지가 들어왔다.
AI 엔지니어인 팀원이 문제 삼은 건 규칙 자체가 아니었다. 사람이 직접 개발하던 시절에 만든 규칙을, 이제는 에이전트가 구현하는 데도 그대로 적용하고 있다는 점이었다.
사람은 규칙을 한 번 익히면 습관처럼 따른다. 에이전트는 다르다. 작업할 때마다 규칙을 다시 읽고 판단해야 하고, 어기면 수정하거나 이전 상태로 되돌아가야 한다. 실제로 우리는 제품 코드보다 PR 크기와 커밋 규칙을 자동으로 검사하고 강제하는 스크립트와 린트를 먼저 만들고 있었다. 에이전트는 그 규칙을 맞추느라 토큰을 쓰고 재시도를 반복했다.
그가 말했다.
이틀을 투자했는데, 코어에서 필요한 코드는 한 줄도 제대로 실험하지 못했어요.
돌아보니 맞는 말이었다. 우리는 이틀 동안 제품이 아니라, 형식을 지키게 만드는 장치를 만들고 있었다.
프론트엔드 엔지니어인 다른 팀원은 다른 각도에서 문제를 봤다. AI를 떠나서 지금 우리는 셋뿐인 팀이고, 이 단계에서는 정교한 프로세스보다 빠른 의사소통이 더 싸다는 주장이었다.
한 사람은 사람이 하던 구현을 에이전트가 맡게 됐다는 점을, 다른 한 사람은 팀의 단계를 지적했다. 출발점은 달랐지만 결론은 같았다. 지금의 형식은 우리 팀에 맞지 않았다.
나도 기존 방식을 고집할 이유는 없었다. 다만 형식을 걷어냈을 때, 그 형식이 지키려던 리뷰의 목적까지 함께 사라지는 것은 경계하고 싶었다. PR의 크기를 제한하고 커밋을 잘게 나눈 것도 결국 서로의 작업을 이해하고 중요한 결정을 놓치지 않기 위해서였기 때문이다.
돌아보면 문제는 리뷰가 아니었다. 익숙한 규칙이 지금도 가장 적은 비용으로 목적을 달성하는 방식인지 따져보지 않은 것이 문제였다.
그래서 우리는 리뷰의 목적은 남기되, 그 목적을 달성하는 형식은 다시 정하기로 했다.
사람을 위한 규칙을 에이전트에게 적용했을 때
하나의 스펙을 구현하면 AI 코어와 백엔드, 프론트엔드가 함께 바뀐다. 이때 300줄 제한을 지키려면 하나의 개념을 여러 PR로 나눠야 했고, 작업의 경계는 스펙이 아니라 라인 수에 맞춰졌다.
커밋 규칙도 비슷했다. 작업의 흐름을 추적할 수 있으면 충분했지만, 우리는 커밋 하나하나를 사람이 읽기 좋은 완결된 단위로 만들기 위해 에이전트를 반복해서 수정하고 되돌렸다.
두 규칙 모두 리뷰를 돕기 위해 만든 것이었다. 하지만 지금의 작업 방식에서는 리뷰가 얻는 이점보다 규칙을 지키는 비용이 더 커지고 있었다.
그래서 규칙의 존폐를 먼저 논의하기보다, 그 규칙이 지키려던 목적을 다른 방식으로 달성할 수 있는지부터 다시 보기로 했다.
PR의 경계는 라인 수가 아니라 개념이다
크기 제한을 없애는 대신 PR의 경계를 다시 정했다. 기준은 단순했다.
하나의 개념이 통째로 들어오고, 필요하면 통째로 빠질 수 있는 단위
프로그래머의 뇌가 설명하는 작업 기억의 한계를 PR 리뷰에 적용해보면, 여러 개념이 섞인 변경은 그만큼 더 많은 맥락 전환을 요구한다. 리뷰어는 새로운 개념으로 넘어갈 때마다 앞서 이해한 맥락을 다시 떠올려야 하고, 그 과정에서 중요한 결정이 묻히기 쉽다.
방향이 자주 바뀌는 MVP 단계에서는 변경의 경계도 분명해야 했다. 하나의 결정을 독립적으로 검토하고, 필요하면 그 결정만 수정하거나 되돌릴 수 있어야 했다.
돌아보면 300줄 제한이 지키려던 목적도 같았다. 리뷰어가 한 번에 하나의 맥락에 집중하도록 만드는 것. 다만 그 목적을 라인 수로 강제하는 방식이 지금의 작업 방식과 맞지 않았을 뿐이다.
그래서 라인 수 제한은 걷어냈다. 무엇을 하나의 개념으로 볼지는 여전히 사람의 판단이 필요하다. 다만 그 판단의 기준을 라인 수가 아니라 스펙과 변경의 의미에 두기로 했다.
리뷰는 팀의 이해를 맞추는 일이다
나는 리뷰가 결함만 찾는 일이 아니라, 제품이 어떤 개념과 결정 위에서 만들어지고 있는지를 팀이 함께 이해하는 과정이라고 생각한다. 리뷰를 마쳤을 때는 적어도 중요한 용어와 책임 경계에 대해서는 같은 그림을 보고 있어야 한다.
AI 엔지니어 팀원이 초기 개념 문서와 스펙을 PR로 올렸을 때 파일 70개가 바뀌어 있었다. 나는 그 내용을 한 줄씩 읽으며 코멘트를 달았다. 구현 스타일을 검수하려던 것이 아니라, 이후 에이전트가 반복해서 참조할 개념과 용어, 책임 경계를 이 시점에 맞춰두기 위해서였다. 초기 합의가 어긋나면 에이전트는 잘못된 방향으로 더 빠르게 구현한다.
물론 필요한 순간에 모여 이야기하는 것은 여전히 중요하다. 다만 구두 합의만으로는 부족했다. 시간이 지나면 각자가 기억하는 결론이 달라지고, 문서로 남지 않은 결정은 이후 에이전트에게 전달할 맥락으로도 사용할 수 없다.
모든 결정을 동기적으로 처리하는 방식도 오래 갈 수 없었다. 중요한 판단이 세 사람이 모이는 자리에서만 가능하다면 팀의 속도는 회의 빈도에 묶인다. 에이전트는 비동기로 작업을 진행하는데, 매 단계마다 사람들의 구두 합의를 기다려야 한다면 형식을 없애서 줄이려던 병목이 다른 형태로 돌아온다.
그래서 우리는 사람이 가장 깊게 리뷰하는 시점을 구현 이후가 아니라 사전 스펙 합의 단계로 옮겼다.
개념과 스펙이 합의되면 나머지 구현은 비동기로 진행할 수 있다. 제품의 방향과 책임 구조를 미리 맞춰두면 코드 리뷰에서 예상하지 못한 설계가 등장할 가능성도 줄어든다. 이후 구현에 대한 리뷰는 스펙이 제대로 반영됐는지 확인하고, 테스트가 보호해야 할 동작과 책임 경계를 중심으로 필요한 부분을 골라 보는 데 집중할 수 있다.
다만 스펙을 합의했다고 해서 AI의 구현을 그대로 신뢰할 수 있는 것은 아니다.
AI가 작성한 코드는 어디서 무너지는가
그래서 나는 AI가 만든 코드를 볼 때 두 가지를 확인한다. 테스트가 스펙의 결과를 검증하는지, 그리고 검증과 오류 처리의 책임이 올바른 경계에 놓여 있는지다.
실제로 에이전트가 만든 코드를 리뷰할 때도 이 두 문제가 반복해서 나타났다.
첫째는 결과보다 구현 상호작용을 검증하는 테스트다.
블라디미르 코리코프는 『Unit Testing』 2장에서 목과의 상호작용을 검증하는 테스트가 구현 세부사항과 강하게 결합되는 문제를 예시로 설명한다. 아래 코드는 해당 예시의 의도를 유지하면서 TypeScript와 Vitest 문법으로 옮긴 것이다.
it("재고가 충분하면 구매가 성공한다", () => {
const store = {
hasEnoughInventory: vi.fn().mockReturnValue(true),
removeInventory: vi.fn(),
};
const customer = new Customer();
const success = customer.purchase(store, "shampoo", 5);
expect(success).toBe(true);
expect(store.removeInventory).toHaveBeenCalledWith("shampoo", 5);
});
이 테스트가 보장하는 것은 구매 후 재고가 실제로 줄었다는 결과가 아니다. removeInventory라는 메서드가 호출됐다는 사실이다.
이 차이는 리팩터링할 때 드러난다. 다른 방식으로 재고를 차감하도록 내부 구현을 바꾸면 동작은 같아도 테스트는 깨질 수 있다. 반대로 메서드는 호출됐지만 실제 재고 반영에 문제가 생겨도 테스트는 통과할 수 있다.
물론 상호작용 검증이 항상 잘못된 것은 아니다. 외부 메시지 발행이나 이메일 전송처럼 최종 상태를 직접 관찰하기 어려운 경계에서는 호출 자체가 검증해야 할 결과일 수 있다.
문제는 시스템 내부에서 관찰할 수 있는 결과가 있는데도, 구현의 호출 방식만 테스트에 고정하는 경우다.
코리코프는 같은 시나리오를 상호작용이 아닌 최종 상태를 검증하는 형태로 바꾼다. 아래 코드는 이 예시 역시 TypeScript로 옮긴 것이다.
it("재고가 충분하면 구매가 성공하고 재고가 줄어든다", () => {
const store = new Store();
store.addInventory("shampoo", 10);
const customer = new Customer();
const success = customer.purchase(store, "shampoo", 5);
expect(success).toBe(true);
expect(store.getInventory("shampoo")).toBe(5);
});
에이전트가 만든 테스트를 리뷰하다 보면, 스펙이 요구한 결과보다 현재 구현의 호출 방식을 검증하는 경우를 자주 봤다. 테스트는 존재하지만, 변경 이후에도 지켜져야 할 동작은 보호하지 못한다.
둘째는 검증 책임이 경계에 모이지 않은 에러 핸들링이다.
좋은 구조는 외부 입력을 시스템의 경계에서 검증된 타입으로 변환하고, 내부 코어는 그 타입이 보장하는 불변식 위에서 동작한다. 검증 책임이 한곳에 모이면 코어 로직은 단순해지고, 잘못된 입력이 어디에서 걸러지는지도 예측하기 쉬워진다.
Parse, don't validate가 설명하는 방향도 이와 같다. 값을 여러 곳에서 반복해서 검사하기보다, 경계에서 더 구체적인 타입으로 변환해 이후 코드가 그 보장을 활용하도록 만드는 것이다.
에이전트가 파일이나 함수 단위의 지역 맥락만 보고 구현하면 검증 책임이 여러 곳으로 쉽게 흩어진다. 시스템 전체에서 어느 레이어가 검증을 맡는지 알지 못한 채, 각 함수가 전달받은 값을 다시 확인하고 자체적으로 에러를 던지기 시작한다.
함수 하나만 보면 합리적인 방어 코드다. 하지만 시스템 전체에서는 같은 검증이 여러 곳에 반복되고, 어느 경로에서 어떤 에러가 발생하는지 예측하기 어려워진다.
예를 들어 에이전트 클라이언트에서 모델별 과금 정보를 꺼내오는 함수를 보자.
function getPriceModel(model: string): PriceModel {
const priceModel = PRICE_TABLE[model];
if (!priceModel) {
throw new Error(`unknown model: ${model}`);
}
return priceModel;
}
이 함수의 string 타입에는 클라이언트 생성 시점에 이미 모델 검증이 끝났다는 사실이 담겨 있지 않다. 그러니 함수는 전달받은 값이 유효한지 다시 확인할 수밖에 없다.
검증 책임을 클라이언트 생성 경계로 옮기면, 이후 코드는 더 구체적인 타입이 보장하는 불변식 위에서 동작할 수 있다.
class AgentClient {
private readonly model: SupportedModel;
constructor(rawModel: string) {
this.model = SupportedModelSchema.parse(rawModel);
}
getPriceModel(): PriceModel {
return PRICE_TABLE[this.model];
}
}
PRICE_TABLE의 타입이 Record<SupportedModel, PriceModel>이라면, 지원되는 모든 모델에 가격 정보가 존재해야 한다는 조건도 타입 시스템이 강제한다. 코어는 이미 경계에서 검증된 값을 반복해서 확인할 필요가 없다.
구현 상호작용만 검증하는 테스트도, 코어 곳곳에 흩어진 방어 코드도 한 줄씩 보면 크게 이상하지 않다. 문제는 그 코드가 놓인 자리다.
테스트가 구현 방식과 제품 동작 중 무엇을 보호해야 하는지, 검증 책임이 어느 레이어에 있어야 하는지는 구조 전체를 보아야 판단할 수 있다. AI 코드 리뷰에서 사람이 맡아야 할 일도 바로 그 판단이다.
사람이 판단해야 할 것
논쟁 끝에 우리가 다시 세운 기준은 단순했다.
구현에 들어가기 전에 개념과 스펙을 충분히 합의한다. 하나의 PR에는 하나의 개념만 담는다. 구현 이후에는 모든 코드를 같은 밀도로 읽는 대신, 테스트가 합의한 동작을 보호하는지와 책임이 올바른 경계에 놓였는지를 확인한다.
에이전트가 더 많은 코드를 만들수록 사람이 더 많은 코드를 읽어야 하는 것은 아니었다. 오히려 무엇을 만들 것인지, 어떤 동작을 보장해야 하는지, 어디에 책임을 둘 것인지를 더 엄격하게 판단해야 했다.
이번에 세운 방식도 정답은 아닐 것이다. 팀의 규모와 실행 주체가 바뀌면 다시 검토해야 한다. 프로세스는 지켜야 할 형식이 아니라, 팀이 무엇을 만들고 무엇을 믿을지 더 잘 판단하기 위해 선택하는 수단이기 때문이다.