코드 리뷰 잘하는 법: 작은 PR, 코멘트 중요도 표시, 스타일 논쟁 자동화
이 글의 핵심
리뷰가 느려지고 코멘트가 감정싸움으로 번지는 팀은 대개 PR이 너무 크거나 무엇이 필수 수정인지 구분되지 않는 경우가 많습니다. 작성자와 리뷰어가 각자 확인할 것, 스타일 지적을 도구로 넘기는 기준, 팀 합의를 만드는 요령으로 리뷰 시간을 줄이면서 품질을 지키는 방법을 설명합니다.
코드 리뷰가 느려지는 팀을 보면 원인은 대개 기술이 아니라 구조에 있습니다. PR이 너무 커서 아무도 끝까지 읽지 않거나, 어떤 코멘트가 머지를 막는 것이고 어떤 것이 취향인지 구분되지 않거나, 스타일 논쟁에 리뷰 시간을 다 써 버리는 경우입니다. 이 글은 Google 엔지니어링 가이드와 Microsoft의 PR 권장 사항을 바탕으로, 작성자와 리뷰어가 각자 무엇을 하면 리뷰 시간을 줄이면서 품질을 지킬 수 있는지를 정리합니다.
코드 리뷰의 목적
코드 리뷰는 다른 개발자가 작성한 변경을 머지 전에 검토하고 피드백을 주는 과정입니다. 흔히 버그를 찾는 절차로 생각하지만, 리뷰가 실제로 가치를 내는 곳은 더 넓습니다. 가독성과 설계를 다듬고, 리뷰어는 새 코드와 패턴을 배우고 작성자는 다른 관점을 얻으며, 팀 전체의 코드 스타일과 구조가 한 방향으로 맞춰집니다.
버그를 찾는 것 자체가 나쁜 목적은 아닙니다. 다만 사람이 diff를 읽어 찾을 수 있는 버그는 한정적이고, 경계값·회귀 같은 것은 테스트가 훨씬 잘 잡습니다. 리뷰어가 가장 가치를 내는 곳은 테스트로 표현하기 어려운 부분입니다. 이 변경이 기존 설계와 맞는지, 6개월 뒤 다른 사람이 읽어도 의도를 알 수 있는지, 에러 경로와 동시성 가정이 맞는지, 권한 검사가 빠진 엔드포인트는 없는지 같은 것들입니다.
코드 리뷰를 “시험”이나 “평가”로만 느끼면 방어적 태도가 생깁니다. 같은 코드베이스를 함께 다듬는 협업이라는 프레임을 팀에서 공유하면, 코멘트 하나하나가 덜 날카롭게 느껴지고 논의도 수월해집니다.
단계별로 확인할 것
리뷰가 일정하게 잘 돌아가려면 PR을 올리기 전, 리뷰할 때, 머지하기 전에 각자 확인할 것을 나눠 두는 것이 좋습니다.
작성자는 PR을 올리기 전에 변경이 이슈나 티켓과 연결되어 있는지 확인합니다. 맥락 없는 변경은 리뷰어가 의도를 추측하는 데 시간을 씁니다. 범위는 한 가지 목적이어야 하고, 리팩터와 기능 추가를 한 PR에 섞지 않습니다. 올리기 전에 자기 diff를 한 번 읽으면 주석 처리한 코드나 디버그 출력 같은 것을 리뷰어보다 먼저 걸러낼 수 있고, 테스트가 변경 범위를 덮는지, lint·타입 체크·핵심 테스트가 로컬에서 통과하는지도 이때 확인합니다.
리뷰어는 PR 설명을 먼저 읽고 의도를 이해한 다음 코드를 봅니다. 요구사항·설계·보안 같은 큰 그림과 가독성·엣지 케이스 같은 세부를 구분해서 보고, 반드시 고쳐야 할 것과 선택 사항을 코멘트에 구분해 표시합니다. 질문은 질문으로, 제안은 제안으로 남겨야 작성자가 무엇을 해야 하는지 헷갈리지 않습니다.
머지 전에는 필수 코멘트가 모두 해소되었는지, CI가 통과했는지 확인합니다. 실패한 채로 “우선 머지”하면 다음 사람이 그 실패를 떠안습니다. 브레이킹 체인지가 있다면 문서와 릴리스 노트에도 반영합니다.
리뷰어가 diff를 읽는 순서도 효율에 영향을 줍니다. 파일 목록 위에서부터 읽기보다 테스트 파일을 먼저 보는 방법이 있습니다. 테스트가 어떤 입력에 어떤 결과를 기대하는지를 보면 작성자의 의도가 구현보다 빨리 드러나고, 테스트가 없는 분기가 어디인지도 바로 보입니다. 그다음 인터페이스(공개 함수 시그니처, API 스키마, DB 마이그레이션)를 보고, 구현 세부는 마지막에 봅니다. 마이그레이션과 공개 API는 머지 후 되돌리기 가장 어려운 부분이라 가장 주의 깊게 봐야 하는 곳이기도 합니다.
이런 기준은 매 PR마다 외울 필요는 없습니다. 팀 위키에 두고, 신규 입사자 온보딩 때 “우리 팀은 이렇게 본다”를 맞추는 용도로 쓰면 됩니다.
리뷰어와 작성자 각자의 요령
리뷰어는 의도를 단정하기 전에 질문하는 편이 좋습니다. “왜 이렇게 했나요?”보다 “이 부분은 동시성 이슈를 피하려는 의도로 이해했는데, 맞을까요?”처럼 맥락을 붙이면 작성자가 방어하지 않고 설명하기 쉽습니다. 잘된 부분을 짚는 코멘트도 아낄 이유가 없습니다. “에러 메시지가 사용자 입장에서 명확해서 좋습니다” 같은 한 줄은 작성자에게 무엇을 유지해야 하는지 알려 주는 정보이기도 합니다. 그리고 머지를 막아야 할 보안·정확성 이슈와 스타일·취향 수준의 의견은 레이블로 나눠야 우선순위가 보입니다.
작성자는 PR 설명에 무엇을, 왜, 어떻게 바꿨고 어떤 위험이 있는지를 쓰면 리뷰어의 첫 검토 시간이 줄어듭니다. 리뷰어가 도메인을 잘 모르는 경우라면 배경 한 단락이 특히 큰 차이를 만듭니다. 코멘트에 답할 때는 감정 대신 근거를 붙입니다. “그렇게 하기엔 레거시 제약이 있어서요”에 관련 이슈 번호를 붙이면 논의가 한 단계 구체적이 됩니다. 요청이 모호하면 “수정할게요”만 달기보다 “A안과 B안 중 어떤 쪽이 팀 컨벤션에 가깝나요?”처럼 선택지를 주고 되묻는 편이 왕복을 줄입니다.
PR 크기 줄이기
작은 PR이 좋은 이유는 단순합니다. diff가 길어질수록 “이 줄이 다른 파일의 가정과 모순되지 않는가?”를 머릿속에서 추적하기 어려워집니다. 그 결과 리뷰는 대충 훑어지거나, 반대로 너무 오래 걸려 한 번에 집중해서 읽기 어려운 일이 됩니다.
큰 기능을 나누는 방법은 몇 가지가 있습니다. API 스텁을 먼저 올리고 UI는 다음 PR로 보내는 식으로, 아직 동작하지 않더라도 컴파일되고 테스트가 통과하는 단위로 잘라 쌓아 갈 수 있습니다. 미완성 기능은 feature flag로 막아 둔 채 작은 단위로 머지할 수 있습니다. 리팩터는 별도 PR로 분리합니다. 리뷰어가 “동작 변경이 없다”고 믿으려면 그 PR에는 리팩터만 있어야 하기 때문입니다. 자주 인용되는 200~400줄은 가이드라인일 뿐이니 도메인 난이도에 맞춰 조정하되, “이 PR은 한 시간 안에 정독할 수 있다”는 감각은 유지하는 것이 좋습니다.
큰 PR의 리뷰 품질은 크기에 비례해 서서히 떨어지는 것이 아니라 어느 지점부터 급격히 떨어집니다. 작은 PR에는 구체적인 코멘트가 여러 개 달리는데 아주 큰 PR에는 “LGTM” 한 줄만 달리는 현상이 흔히 이야기되는 이유입니다. 리뷰어가 게을러서가 아니라, 큰 diff 앞에서는 어디부터 봐야 할지 모르니 전체를 훑고 “테스트가 통과했으니 괜찮겠지”로 판단을 CI에 넘기게 되는 것입니다. 이런 형식적 승인(rubber stamp)이 반복되면 리뷰가 있어도 없는 것과 같아집니다.
앞 PR이 머지되기를 기다리며 작업이 멈추는 것이 싫어서 PR을 키우는 경우도 많습니다. 이때는 스택형 PR(stacked PR)이 대안입니다. PR 1(스키마) 위에 PR 2(API), 그 위에 PR 3(UI)를 브랜치로 쌓아 두고 각각 따로 리뷰받는 방식입니다. 대신 아래 PR이 수정되면 위 브랜치를 모두 리베이스해야 하므로, git rebase --update-refs(Git 2.38+) 같은 도구를 익혀 두어야 운영 비용이 감당됩니다.
큰 PR을 어쩔 수 없이 올려야 한다면 커밋을 의미 있게 쪼개고, PR 설명에 “커밋 순서대로 보면 이해됩니다”를 적어 리뷰 경로를 안내합니다. 방향이 불확실한 변경은 초안(draft) PR로 먼저 올려 큰 방향만 맞춰 두면, 다 만든 뒤에 크게 뒤집는 일을 줄일 수 있습니다.
코멘트 작성법
건설적인 피드백은 관찰, 영향, 제안(또는 질문) 순서로 쓰면 전달이 잘 됩니다. 예를 들어 “이 함수는 입력 검증, 변환, 저장 세 가지 역할이 섞여 있습니다(관찰). 나중에 저장 방식이 바뀌면 검증 코드까지 같이 건드려야 할 수 있습니다(영향). 세 단계를 각각 함수로 나누면 어떨까요?(제안)“처럼 씁니다.
Conventional Comments 형식을 쓰면 코멘트의 의도가 앞머리에서 드러납니다. question:, suggestion:, nitpick:, praise: 같은 레이블을 붙이고, (blocking), (non-blocking) 같은 데코레이터로 머지를 막는지도 표시할 수 있습니다.
**question:** 이 함수는 비동기인가요?
**suggestion (blocking):** `forEach(async ...)`는 콜백의 Promise를 기다리지 않습니다. 순차 처리가 필요하면 `for...of` + `await`, 병렬이면 `Promise.all`을 써 주세요.
**nitpick (non-blocking):** 변수명을 `data`에서 `userData`로 변경하면 더 명확합니다.
**praise:** 에러 경로마다 원인을 로그에 남긴 점이 좋습니다.
텍스트 리뷰에는 한계도 있습니다. 같은 스레드에서 코멘트가 세 번 이상 왕복하면 대개 서로 다른 전제를 놓고 이야기하고 있다는 신호입니다. 그때는 짧은 통화나 대면 대화로 옮기고, 결론만 PR에 요약해 남기는 편이 빠릅니다. 글로 된 코멘트는 어조가 실제보다 차갑게 읽히기 쉬워서, 긴 논쟁일수록 오해가 쌓입니다.
# ❌ 나쁜 피드백
"이 코드는 이해가 안 됩니다."
"왜 이렇게 했나요?"
# ✅ 좋은 피드백
"이 함수가 DB에서 직접 읽는다는 것이 이름에서 드러나지 않습니다.
`getUserById`를 `fetchUserFromDatabase`로 바꾸면 호출하는 쪽에서
I/O가 일어난다는 것을 알기 쉬울 것 같습니다."
"두 요청이 서로 의존하지 않으니 `Promise.all`로 동시에 보내면
응답 대기 시간을 줄일 수 있습니다.
예시: `const [users, posts] = await Promise.all([...])`"
중요도 표시
Conventional Comments 대신 간단한 중요도 레이블을 쓰는 팀도 있습니다.
🔴 MUST: 반드시 수정 필요 (보안, 버그)
🟡 SHOULD: 권장 사항 (가독성, 성능)
🟢 NICE: 선택 사항 (스타일, 의견)
🔴 MUST: 이 코드는 SQL 인젝션에 취약합니다.
Prepared Statement를 사용해야 합니다.
🟡 SHOULD: 이 함수는 100줄이 넘어 한눈에 흐름을 따라가기 어렵습니다.
작은 함수로 분리하는 것을 권장합니다.
🟢 NICE: 재할당이 없다면 `let` 대신 `const`가 의도를 더 잘 드러냅니다.
기계에 맡길 것: ESLint, Prettier, CI
사람의 시간은 판단이 필요한 곳에 쓰고, 기계가 확인할 수 있는 것은 기계에게 맡기는 것이 좋습니다.
ESLint와 Prettier를 CI에서 돌리면 들여쓰기, 따옴표, 흔한 실수 같은 지적이 PR 대화에서 빠지고 diff는 로직에 집중하게 됩니다. 포매터가 결정해 버리는 항목은 사람끼리 논쟁할 이유 자체가 사라집니다. TypeScript 같은 타입 체커는 함수의 계약과 경계를 코드로 고정해 리뷰어가 머릿속으로 추적해야 할 것을 줄여 줍니다. lint, test, build를 PR마다 돌리면 “로컬에서는 됐는데” 같은 일이 줄고, 리뷰어는 통과 표시를 믿고 로직에 집중할 수 있습니다.
CODEOWNERS로 영역별 리뷰 요청을 자동화하면 누구에게 리뷰를 받아야 하는지 고민하는 시간이 줄어듭니다. 다만 한 사람이 너무 넓은 경로의 오너가 되면 그 사람이 모든 PR의 병목이 되므로, 오너는 팀 단위(@org/backend)로 지정하는 편이 낫습니다. AI 리뷰 도구는 누락된 널 검사나 뻔한 실수는 잘 짚지만, 이 변경이 제품 요구사항이나 팀이 합의한 설계 방향과 맞는지는 알지 못합니다. 사람 리뷰의 대체가 아니라 사람이 보기 전 첫 필터로 쓰고, 틀린 지적도 섞여 나오므로 작성자가 무조건 반영하지 않도록 합의해 두는 것이 좋습니다.
아래는 GitHub Actions로 PR에 lint를 거는 최소 예시입니다.
# .github/workflows/lint.yml
name: Lint
on: [pull_request]
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- run: npm ci
- run: npm run lint
워크플로를 추가한 뒤에는 저장소의 브랜치 보호 규칙에서 이 잡을 필수 상태 체크로 지정해야 실제로 머지를 막습니다. 그렇지 않으면 빨간불이 떠도 머지 버튼은 그대로 눌립니다.
팀 문화 만들기
리뷰 문화는 긴 규정보다 몇 가지 합의로 바뀌는 경우가 많습니다. 예를 들어 “리뷰 요청을 받으면 영업일 하루 안에 첫 반응을 단다. 바로 못 보면 언제 볼지라도 적는다”, “MUST 코멘트가 없으면 머지 가능” 같은 규칙입니다. Google 가이드도 첫 응답까지 최대 영업일 하루를 기준으로 삼고, 집중 작업 중이 아니라면 가능한 한 빨리 보라고 권합니다. 작성자가 기다리는 동안 맥락을 잃는 비용이 리뷰 자체보다 클 수 있기 때문입니다. 리뷰어 수를 한두 명으로 한정하고 “이 부분만 봐 주시면 됩니다”를 설명에 적어 두거나, “오전에는 PR을 먼저 본다” 같은 시간대를 팀이 공유하는 것도 첫 응답을 빠르게 하는 방법입니다.
문화는 문서만으로는 남지 않습니다. 시니어가 작은 PR을 먼저 보여 주고 리뷰받는 모습을 공개하면 주니어도 “질문해도 된다”는 신호를 받습니다. 반대로 리뷰가 항상 한두 사람에게만 쏠리면 그 사람이 지치고 병목이 되므로, 리뷰어 로테이션이나 페어 리뷰를 도입하는 팀도 있습니다.
의견이 갈릴 때
의견이 맞지 않을 때는 논의를 사람이 아니라 선택지와 기준으로 옮기면 풀리기 쉽습니다. 성능 문제라면 추측 대신 측정 결과를, 가독성 문제라면 팀 가이드 문서를 근거로 듭니다. A안과 B안의 장단점, 유지보수 비용, 배포 위험을 짧은 표로 적어 보면 생각보다 차이가 작다는 것이 드러나기도 합니다. 그래도 결론이 안 나면 “이번에는 A로 가고, 다음 분기에 지표를 보고 재검토”처럼 끝을 정하고, 그래도 합의가 안 될 때 기술 리드나 아키텍트에게 가져가는 경로를 미리 공개해 두면 개인 감정이 덜 섞입니다.
어느 쪽으로 결정되든 결론과 이유를 PR에 짧게 남겨 두면 나중에 같은 논쟁을 반복하지 않습니다. “왜 내 코드만 까다롭게 보나”가 아니라 “이 기준으로 팀 전체가 맞추자”로 대화가 옮겨 갈 때, 리뷰는 팀이 함께 배우는 과정이 됩니다.
코드 리뷰 예시
// PR 코드
function calculate(a, b, c) {
if (c == 'add') return a + b;
if (c == 'sub') return a - b;
if (c == 'mul') return a * b;
if (c == 'div') return a / b;
}
# ❌ 나쁜 리뷰 코멘트
"이 코드는 별로입니다."
"다시 작성하세요."
# ✅ 좋은 리뷰 코멘트
🔴 MUST: 인자에 타입이 없어 문자열 숫자가 들어오면 `'1' + '2'`가 `'12'`가 됩니다.
```typescript
type Operation = 'add' | 'sub' | 'mul' | 'div';
function calculate(a: number, b: number, operation: Operation): number {
// ...
}
```
🔴 MUST: 0으로 나누면 `Infinity`(또는 `0 / 0`이면 `NaN`)가 조용히 반환됩니다.
또 네 경우에 해당하지 않으면 `undefined`가 반환됩니다.
```typescript
if (operation === 'div') {
if (b === 0) throw new Error('Division by zero');
return a / b;
}
```
🟡 SHOULD: if 체인보다 객체 매핑이 연산을 추가하기 쉽습니다.
```typescript
const operations: Record<Operation, (a: number, b: number) => number> = {
add: (a, b) => a + b,
sub: (a, b) => a - b,
mul: (a, b) => a * b,
div: (a, b) => {
if (b === 0) throw new Error('Division by zero');
return a / b;
},
};
return operations[operation](a, b);
```
좋은 리뷰 쪽 코멘트가 길어 보이지만, 각 코멘트가 무엇이 문제인지, 왜 문제인지, 어떻게 고칠 수 있는지를 모두 담고 있어서 작성자가 되물을 필요가 없습니다. 반대로 “별로입니다”는 짧지만 작성자가 추측해서 고치고, 리뷰어가 다시 보고, 또 고치는 왕복을 만듭니다. 리뷰 시간을 줄이는 것은 코멘트를 짧게 쓰는 것이 아니라 왕복 횟수를 줄이는 것입니다.
한 가지 덧붙이면, 이 예시의 operations[operation](a, b)는 operation이 사용자 입력이라면 "constructor"나 "toString" 같은 값으로 프로토타입의 메서드를 호출할 수 있습니다. 타입으로 Operation을 제한했더라도 런타임 입력은 Object.hasOwn(operations, operation) 등으로 검증해야 한다는 점까지 짚는 것이 좋은 리뷰입니다.
자주 보는 나쁜 코멘트와 고친 코멘트
| 상황 | 나쁜 예 | 좋은 예 |
|---|---|---|
| 네이밍 | “이름 이상해요.” | “data는 범위가 넓어서, userPayload처럼 도메인 단어가 들어가면 읽는 사람이 덜 헷갈릴 것 같아요.” |
| 성능 | “느릴 것 같은데요.” | “이 중첩 루프가 O(n²)인데 입력이 커질 수 있어서, 안쪽 탐색을 Map으로 바꾸면 O(n)이 될 것 같아요. 필요하면 제가 스케치해 볼게요.” |
| 스타일 | “우리 스타일 아닌데요.” | “팀 ESLint 규칙에 맞추면 자동 수정됩니다. npm run lint:fix 한번 돌려 주실 수 있을까요?” |
PR 템플릿
저장소에 .github/pull_request_template.md 파일을 두면 GitHub가 새 PR 설명란에 이 내용을 자동으로 채워 줍니다.
## 변경 사항
<!-- 무엇을 변경했는지 간단히 설명 -->
## 변경 이유
<!-- 왜 이 변경이 필요한지 -->
<!-- 관련 이슈: Fixes #123 -->
## 변경 방법
<!-- 어떻게 구현했는지 -->
1.
2.
3.
## 테스트
<!-- 어떻게 테스트했는지 -->
- [ ] 유닛 테스트 추가
- [ ] E2E 테스트 추가
- [ ] 수동 테스트 완료
## 스크린샷 (선택)
<!-- UI 변경이 있다면 스크린샷 첨부 -->
## 위험 요소
<!-- 브레이킹 체인지, 마이그레이션, 롤백 방법 -->
Fixes #123처럼 이슈 번호를 적으면 PR이 기본 브랜치에 머지될 때 해당 이슈가 자동으로 닫힙니다.
참고할 도구
- 리뷰 플랫폼: GitHub Pull Request, GitLab Merge Request, Gerrit(Android, Chromium 같은 대형 오픈소스에서 사용)
- PR 자동 점검: Danger.js(PR 크기·변경 파일 규칙 검사), SonarQube(정적 분석), CodeRabbit 등 AI 리뷰 도구
- 린터·포매터: ESLint(JavaScript/TypeScript), Prettier(포매팅), Black·Ruff(Python)