팀 규칙으로 만들기 - 포매터·정적 검사·리뷰 기준
이 장에서 배우는 것
앞 장에서 변경을 작은 단계로 나누고, 고치기 전과 후의 결과를 확인했다. 이제 그 작업 방식을 팀이 함께 적용할 기준으로 남긴다. 한 사람이 읽기 좋다고 느끼는 코드와 팀이 계속 같은 방식으로 관리할 수 있는 코드는 출발점이 다르다. 팀 규칙에는 판단의 이유, 적용 범위, 확인 방법이 있어야 한다.
이 장에서는 학원 수강 관리 프로그램의 수강료 계산 코드를 대상으로 규칙을 정한다. 계산 방법을 바꾸는 대신 들여쓰기와 줄 끝 공백을 정리하고, 같은 입력에 같은 금액이 나오는지 확인한다. 마지막에는 규칙 목록을 순회하며 코드 조각의 위반 위치를 보고하는 작은 검사기를 만든다.
- 개인 취향과 공동 작업에 필요한 규칙을 구분한다.
- 포매터(formatter), 정적 분석(static analysis), 사람의 리뷰가 맡을 일을 나눈다.
- 리뷰 기준과 예외 승인에 필요한 기록을 작성한다.
- 사람과 AI가 만든 코드에 같은 검사 기준을 적용한다.
- 규칙 목록으로 코드 조각을 검사하고, 검사기의 한계를 설명한다.
문제 상황
학원 수강 관리 프로그램에서 수강료 계산 메서드를 수정하는 변경 요청이 올라왔다. 계산 결과는 이전과 같지만 탭과 공백이 섞여 있다. 한 리뷰어는 들여쓰기를 지적하고, 다른 리뷰어는 줄 끝 공백을 지적한다. 작성자는 수정할 때마다 새로운 의견이 붙는다고 느낀다. 정작 수강 횟수를 계산에 제대로 반영했는지는 대화 뒤쪽으로 밀린다.
며칠 뒤에는 AI가 생성한 코드 조각이 추가된다. 실행 결과를 확인하려고 넣은 콘솔 출력이 그대로 남아 있다. 작성자는 “생성된 코드라 그대로 두었다”고 설명한다. 그러나 이용자가 보게 되는 동작과 운영 중 관리할 코드는 작성 경로에 따라 달라지지 않는다. 팀이 책임지는 저장소에 들어오는 순간 같은 기준으로 검토해야 한다.
이 상황에서 필요한 것은 의견의 수를 늘리는 일이 아니다. 반복되는 형식 문제는 도구가 확인하고, 사람은 계산의 의미와 변경 이유를 살펴보도록 역할을 정해야 한다. 예외가 필요하다면 누구의 어떤 판단으로 허용했는지도 남겨야 한다. 구두로만 허용하면 다음 리뷰에서 같은 논쟁이 다시 시작된다.
이번 예제의 수강료는 회당 금액과 수강 횟수를 곱해 구한다. 사용한 금액과 횟수는 실행 확인을 위한 가상 입력이다. 실제 요금 정책을 나타내지 않는다. 이 장에서는 할인 정책이나 예외 설계를 추가하지 않는다. 계산 동작을 보존하면서 팀 규칙을 적용하는 과정에 집중한다.
취향을 확인 가능한 규칙으로 바꾼다
중괄호 위치나 들여쓰기 폭에는 여러 선택지가 있다. 어느 하나를 선택했다는 사실만으로 코드의 의미가 더 정확해지는 것은 아니다. 다만 파일마다 선택이 달라지면 변경 내용을 읽을 때 불필요한 차이가 늘어난다. 팀은 이런 문제를 줄이기 위해 하나를 정할 수 있다. 선택의 근거는 개인의 익숙함보다 공동 작업 비용에 두는 편이 좋다.
규칙을 제안할 때는 “이 모양이 보기 좋다”에서 한 단계 더 나아간다. 어떤 혼란을 줄이는지, 어디에 적용하는지, 도구로 확인할 수 있는지를 적는다. 근거를 설명하기 어렵고 반복해서 문제가 되지도 않는 항목이라면 당장 필수 규칙으로 만들지 않아도 된다. 규칙이 많아질수록 작성자와 검토자가 기억하고 관리해야 할 내용도 늘어난다.
| 대상 | 팀이 정할 내용 | 확인 방법 | 이유 |
|---|---|---|---|
| 들여쓰기 | Java 코드에 탭을 넣지 않는다 | 포매터와 문자 검사 | 표시 환경에 따른 차이를 줄인다 |
| 줄 끝 공백 | 줄 마지막의 공백을 제거한다 | 포매터와 문자 검사 | 의미 없는 변경을 줄인다 |
| 임시 콘솔 출력 | 운영 코드에 디버깅 출력을 남기지 않는다 | 검사와 사람의 확인 | 출력 계약을 지킨다 |
| 동작 보존 | 변경 전후의 확인 결과를 제출한다 | 실행 결과와 리뷰 | 형식 변경에 계산 변경이 섞이지 않게 한다 |
규칙에는 짧고 고정된 식별자를 붙인다. 이 장에서는 탭을 TAB, 줄 끝 공백을 TRAILING, 임시 콘솔 출력 후보를 DEBUG로 부른다. 문장을 조금 고쳐도 식별자가 유지되면 기존 리뷰나 예외 기록을 찾아가기 쉽다. 식별자는 중요도 순위가 아니라 규칙을 가리키는 이름이다.
적용 범위도 규칙의 일부다. “콘솔 출력을 금지한다”만 적으면 실행 예제와 명령행 프로그램까지 막을 수 있다. 이 장의 DEBUG 규칙은 검사 대상으로 전달한 운영 코드 조각에 적용한다. 검사 결과를 보여 주는 Main의 출력은 검사 대상이 아니다. 범위를 뒤늦게 해석해서 정하기보다 문서에 먼저 적는다.
포매터는 정해진 형태로 코드를 맞추는 도구다. 정적 검사는 실행하지 않고 코드의 특징을 찾아낸다. 두 기능은 일부 겹칠 수 있지만 목적은 구분할 수 있다. 공백을 고치는 일과, 의심스러운 출력을 발견해 알리는 일은 서로 다른 작업이다. 어느 도구를 쓰든 수정 기능과 보고 기능을 구별해서 이해해야 한다.
JDK 17에는 이 장에서 사용할 범용 Java 소스 포매터가 포함되어 있지 않다. 따라서 외부 도구를 설치하는 대신, 사람이 형식을 고친 두 코드 조각과 작은 검사기를 사용한다. 완성 코드는 포매터를 구현하지 않는다. 실제 팀에서는 선택한 포매터의 설정을 함께 관리하고 편집기와 공동 검사 환경에 같은 설정을 적용한다.
자동 검사와 리뷰 문서의 경계를 정한다
자동화할 수 있는 항목은 사람이 매번 다시 판정하지 않도록 만든다. 예를 들어 탭이 들어 있는지는 문자만 읽어도 확인할 수 있다. 반면 “이 계산은 수강료 정책을 올바르게 표현하는가”는 요구 사항과 실행 결과를 함께 봐야 한다. 도구가 확인한 항목과 사람이 확인해야 할 항목을 한 목록에 섞어 놓으면, 검사 통과를 전체 품질 보증으로 오해하기 쉽다.
리뷰 기준 문서는 실제 변경 요청에 답할 수 있을 정도로 구체적이어야 한다. 이번 변경이라면 “수강 횟수가 다른 입력도 확인했는가”, “출력에 임시 메시지가 추가되지 않았는가”, “금액을 바꾸는 수정이 형식 정리에 섞이지 않았는가”를 묻는다. “좋은 코드인가”처럼 답의 범위가 넓은 질문은 의견을 모으기 어렵다.
| 항목 | 기록 예시 | 확인 주체 |
|---|---|---|
| 범위 | 운영 Java 코드의 신규·수정 부분 | 작성자와 검토자 |
| 자동 검사 | TAB, TRAILING, DEBUG 후보 보고 | 도구 |
| 동작 근거 | 같은 회당 금액·횟수의 전후 결과 | 작성자와 검토자 |
| 예외 기록 | 규칙, 위치, 이유, 승인자, 만료 조건 | 지정 검토자 |
이 장의 검사기는 의도적으로 단순하다. TAB은 줄에 탭 문자가 있는지, TRAILING은 줄이 공백 또는 탭으로 끝나는지 확인한다. DEBUG는 System.out.println(이라는 정확한 문자열을 찾는다. 줄 번호와 규칙 식별자, 설명을 보고하며 코드를 수정하지 않는다.
이 방식은 Java 문법을 해석하지 않는다. 주석이나 문자열 안에 같은 글자가 있어도 DEBUG 후보로 보고한다. 반대로 괄호 앞에 공백이 있거나 다른 출력 메서드를 사용하면 놓친다. 탭과 줄 끝 공백도 문자열의 일부인지 구별하지 않는다. 따라서 이 예제는 일반 Java 소스를 판정하는 정적 분석기가 아니라, 제한된 코드 조각을 검사하는 규칙 실행 구조다.
검사 결과는 이런 능력의 범위 안에서 읽어야 한다. “위반 없음”은 등록한 조건에 해당하는 줄을 찾지 못했다는 뜻이다. 계산의 정확성, 모든 출력 호출의 부재, 보안 요구 사항까지 확인했다는 뜻으로 넓혀 읽으면 안 된다. 검사기가 무엇을 찾고 무엇을 놓치는지 문서에 함께 적는 것도 팀 규칙을 운영하는 일이다.
예외와 AI 코드에도 같은 책임을 적용한다
규칙을 적용하기 어려운 경우에는 검사 전체를 끄기 전에 적용 범위를 좁혀 살펴본다. 예를 들어 출력 API 사용법을 설명하는 교육용 문자열에는 DEBUG가 찾는 글자가 필요할 수 있다. 이때 운영 코드의 출력 호출과 설명용 문자열을 같은 이유로 취급하면 불필요한 수정이 생긴다. 규칙의 목적을 기준으로 예외 여부를 판단해야 한다.
예외 요청에는 규칙 식별자, 파일과 위치, 허용 이유, 영향 범위, 다시 검토할 조건을 적는다. 작성자와 다른 지정 검토자가 이를 확인하고 승인 근거를 남긴다. 일정이 촉박하다는 말만으로 범위를 넓게 허용하지 않는다. 날짜가 적절하지 않은 예외라면 “교육용 문자열을 제거할 때까지”처럼 종료 조건을 적을 수 있다.
예외의 승인과 위반의 발견은 별개다. 검사기는 발견한 내용을 그대로 보고하고, 승인 기록을 검토하는 단계에서 처리 상태를 구분한다. 이 장의 프로그램은 위반 보고까지만 구현한다. 승인 기록 저장이나 예외 필터는 구현하지 않는다. 작은 예제에 승인된 것처럼 보이는 하드코딩을 넣으면 검사와 운영 절차의 경계가 흐려진다.
AI가 만든 코드도 같은 흐름을 따른다. 작성자는 생성된 코드를 읽고, 팀 규칙을 적용하고, 실행 근거를 제출한다. AI가 규칙을 지켰다고 설명하더라도 검사 결과를 대신할 수 없다. 반대로 AI가 만들었다는 이유만으로 별도의 문체 규칙을 추가할 필요도 없다. 저장소에 들어갈 코드의 역할과 위험을 기준으로 검토한다.
AI에 요청할 때 규칙 식별자와 적용 범위를 함께 전달하면 수정 방향을 구체적으로 줄 수 있다. 예를 들어 “운영 코드에는 TAB과 TRAILING을 허용하지 않고, 임시 출력은 남기지 않는다”라고 쓸 수 있다. 다만 요청문은 검증 수단이 아니다. 제출된 코드에 같은 검사를 실행하고 결과를 확인하는 책임은 여전히 팀에 있다.
완성 코드
다음 프로그램은 외부 파일을 읽거나 쓰지 않는다. 두 수강료 메서드를 직접 실행해 결과를 비교하고, 각 메서드에 대응하는 코드 조각을 문자열로 검사한다. 문자열을 동적으로 컴파일하거나 실행하지는 않는다. 문자열 검사와 실제 메서드 실행은 서로 다른 확인이라는 점을 구분한다.
검사는 입력 줄 순서로 진행하고, 한 줄에서는 규칙 목록 순서로 진행한다. 한 줄에 여러 규칙이 해당하면 모두 보고한다. 같은 규칙이 한 줄에서 여러 번 해당하더라도 그 줄에는 한 건만 보고한다. 이 순서를 정해 두었으므로 실행할 때마다 보고 순서가 같다.
Main.java
import java.util.ArrayList;
import java.util.List;
import java.util.function.Predicate;
public class Main {
private record Rule(
String id, String message, Predicate<String> matches) {
}
private record Violation(int line, String ruleId, String message) {
String report() {
return line + "행 [" + ruleId + "] " + message;
}
}
private static final List<Rule> RULES = List.of(
new Rule("TAB", "탭 문자가 있다",
line -> line.indexOf('\t') >= 0),
new Rule("TRAILING", "줄 끝 공백이 있다",
line -> line.endsWith(" ") || line.endsWith("\t")),
new Rule("DEBUG", "임시 콘솔 출력 후보가 있다",
line -> line.contains("System.out.println("))
);
private static int failures;
public static void main(String[] args) {
String before = String.join("\n",
"static int legacyFee(int unitFee, int count) {",
"\tint total = unitFee * count;",
" return total; ",
"}");
String after = String.join("\n",
"static int cleanFee(int unitFee, int count) {",
" int total = unitFee * count;",
" return total;",
"}");
System.out.println("== 동작 확인 ==");
int unitFee = 12000;
int[] counts = {0, 1, 3};
for (int count : counts) {
int oldResult = legacyFee(unitFee, count);
int newResult = cleanFee(unitFee, count);
System.out.println("횟수 " + count + ": 전="
+ oldResult + ", 후=" + newResult);
check(oldResult == newResult, "전후 동작: 횟수 " + count);
}
check(cleanFee(12000, 3) == 36000, "수강료 기대값");
System.out.println("== 규칙 검사 ==");
List<Violation> beforeViolations = inspect(before, RULES);
List<Violation> afterViolations = inspect(after, RULES);
printReport("고치기 전", beforeViolations);
printReport("고친 후", afterViolations);
check(beforeViolations.equals(List.of(
new Violation(2, "TAB", "탭 문자가 있다"),
new Violation(3, "TRAILING", "줄 끝 공백이 있다")
)), "고치기 전의 위치와 규칙");
check(afterViolations.isEmpty(), "고친 후 위반 없음");
String aiSnippet = String.join("\n",
"int total = unitFee * count;",
"System.out.println(total);");
List<Violation> aiViolations = inspect(aiSnippet, RULES);
printReport("AI 코드 조각", aiViolations);
check(aiViolations.equals(List.of(
new Violation(2, "DEBUG", "임시 콘솔 출력 후보가 있다")
)), "AI 코드에도 같은 규칙");
List<Violation> overlapping = inspect("\t ", RULES);
check(overlapping.equals(List.of(
new Violation(1, "TAB", "탭 문자가 있다"),
new Violation(1, "TRAILING", "줄 끝 공백이 있다")
)), "한 줄의 복수 위반");
check(inspect("", RULES).isEmpty(), "빈 입력");
check(inspect("x\r\n\tvalue\r\n", RULES).equals(List.of(
new Violation(2, "TAB", "탭 문자가 있다")
)), "CRLF 줄 번호");
List<Rule> selectedRules = List.of(RULES.get(0));
check(inspect("value ", selectedRules).isEmpty(),
"전달한 규칙 목록만 적용");
List<Violation> commentMatches =
inspect("// System.out.println(total);", RULES);
check(commentMatches.size() == 1, "주석도 찾는 한계 확인");
check(inspect("System.out.println (total);", RULES).isEmpty(),
"호출 표기 차이를 놓치는 한계 확인");
System.out.println("실패 수: " + failures);
if (failures != 0) {
throw new AssertionError("검증 실패");
}
}
private static int legacyFee(int unitFee, int count) {
int total = unitFee * count;
return total;
}
private static int cleanFee(int unitFee, int count) {
int total = unitFee * count;
return total;
}
private static List<Violation> inspect(
String source, List<Rule> rules) {
List<Violation> violations = new ArrayList<>();
String[] lines = source.split("\\R", -1);
for (int index = 0; index < lines.length; index++) {
for (Rule rule : rules) {
if (rule.matches().test(lines[index])) {
violations.add(new Violation(
index + 1, rule.id(), rule.message()));
}
}
}
return List.copyOf(violations);
}
private static void printReport(
String label, List<Violation> violations) {
System.out.println(label + ": " + violations.size() + "건");
for (Violation violation : violations) {
System.out.println(violation.report());
}
}
private static void check(boolean condition, String name) {
System.out.println((condition ? "PASS " : "FAIL ") + name);
if (!condition) {
failures++;
}
}
}
고치기 전 문자열의 \t와 닫는 따옴표 앞 공백 두 칸은 검사 입력이다. Main.java 자체의 들여쓰기에 탭을 넣을 필요는 없다. 실제로 실행하는 두 메서드는 공백으로 정렬했으며, 문자열에 나타낸 두 계산 본문과 같은 계산을 한다. 이 구성을 통해 예제를 복사하는 과정에서 탭이 공백으로 바뀌어도 검사 입력의 탭은 유지된다.
줄별 해설
Rule 선언은 식별자, 설명, 판정 함수를 한데 묶는다. Predicate<String>은 문자열 한 줄을 받아 참이나 거짓을 돌려주는 표준 인터페이스다. 각 규칙은 “이 줄이 내 조건에 해당하는가”만 판단한다. 줄 번호를 계산하거나 화면에 출력하는 일은 맡지 않는다.
Violation은 발견한 줄 번호와 규칙 식별자, 설명을 담는다. record로 선언했으므로 같은 필드 값을 가진 결과끼리 값으로 비교할 수 있다. 테스트는 보고 문자열만 비교하는 대신, 발견한 위치와 규칙이 예상 목록과 같은지 확인한다. report()는 사람이 읽을 한 줄의 표현을 만든다.
RULES는 검사할 규칙의 목록이다. List.of로 만들었으므로 이 목록에 실행 중 규칙을 덧붙이거나 순서를 바꿀 수 없다. 탭, 줄 끝 공백, 출력 후보 순서가 같은 줄의 보고 순서가 된다. 규칙을 추가할 때는 목록과 기대 결과를 함께 검토한다.
before와 after는 검사 대상 코드 조각이다. 고치기 전에는 두 번째 줄 앞에 탭이 있고, 세 번째 줄 끝에 공백 두 칸이 있다. 고친 후에는 들여쓰기를 공백으로 통일하고 줄 끝 공백을 제거한다. 두 문자열에서 메서드 이름도 구분했지만, 검사 대상인 계산식과 반환값은 동일하다.
unitFee와 counts는 전후 동작 확인에 쓰는 고정 입력이다. 수강 횟수 0, 1, 3을 차례로 전달한다. 비교만 하면 두 메서드가 똑같이 잘못된 경우를 놓칠 수 있으므로, 별도로 기대 금액 36000도 확인한다. 이 입력들이 모든 정수 범위를 검증하지는 않는다. 이번 형식 정리의 실행 근거로 범위를 제한한다.
beforeViolations 검사는 단순히 두 건인지 세는 데서 끝나지 않는다. 두 번째 줄의 TAB과 세 번째 줄의 TRAILING이 그 순서로 있는지 비교한다. 규칙이 잘못된 줄을 보고하거나 같은 결과를 중복으로 넣으면 실패한다. afterViolations.isEmpty()는 수정한 조각에서 등록된 위반을 찾지 않았는지 확인한다.
aiSnippet에도 같은 RULES를 전달한다. 생성 도구의 종류를 검사기 인자로 받지 않는 이유는 코드의 출처에 따라 규칙을 바꾸지 않기 때문이다. 실제 메서드가 아니라 문자열이므로 이 안의 출력문은 실행되지 않는다. 실행 결과에 금액 한 줄이 추가되지 않는 이유도 여기에 있다.
overlapping은 탭 뒤에 공백이 있는 한 줄이다. TAB과 TRAILING이 모두 해당한다. 첫 위반을 찾았다고 검사를 멈추면 두 번째 위반을 놓치므로 두 건을 기대한다. 빈 입력과 CRLF 줄바꿈도 검사해, 내용이 없는 경우와 줄바꿈 표현이 다른 경우를 확인한다.
selectedRules는 전달받은 목록이 실제로 사용되는지 확인한다. TAB만 전달했으므로 줄 끝 공백이 있어도 위반을 보고하지 않아야 한다. 마지막 두 검사는 검사기의 한계를 고정한다. 주석의 글자도 발견하며, 괄호 앞에 공백이 있는 출력 호출은 놓친다. 이 PASS는 해당 코드가 허용된다는 뜻이 아니라, 현재 검사기의 동작을 예상대로 확인했다는 뜻이다.
inspect는 split("\\R", -1)로 줄을 나눈다. 여기서 \\R은 Java 정규 표현식의 줄바꿈 패턴이고, 두 번째 인자 -1은 끝의 빈 줄도 보존한다. 배열 인덱스는 0부터 시작하므로 보고할 때 1을 더한다. 줄마다 모든 규칙을 적용한 뒤 결과를 복사해 반환한다. 호출자가 받은 목록을 수정해 검사 결과를 바꾸는 일을 막는다.
check는 조건마다 PASS 또는 FAIL을 출력하고 실패 수를 누적한다. 마지막까지 실행해서 여러 실패를 함께 볼 수 있으며, 실패가 하나라도 있으면 AssertionError를 던진다. 따라서 FAIL을 출력하고도 정상 종료해 공동 검사에서 성공으로 오해하는 일을 피한다.
실행 결과
Main.java를 저장한 디렉터리에서 다음 명령을 실행한다. 아래 명령에는 외부 라이브러리나 빌드 도구가 필요하지 않다.
javac -d out *.java
java -cp out Main
예상 출력은 다음과 같다. 검사 입력의 코드 조각은 문자열로만 처리되므로 그 안의 임시 출력문은 실행되지 않는다.
== 동작 확인 ==
횟수 0: 전=0, 후=0
PASS 전후 동작: 횟수 0
횟수 1: 전=12000, 후=12000
PASS 전후 동작: 횟수 1
횟수 3: 전=36000, 후=36000
PASS 전후 동작: 횟수 3
PASS 수강료 기대값
== 규칙 검사 ==
고치기 전: 2건
2행 [TAB] 탭 문자가 있다
3행 [TRAILING] 줄 끝 공백이 있다
고친 후: 0건
PASS 고치기 전의 위치와 규칙
PASS 고친 후 위반 없음
AI 코드 조각: 1건
2행 [DEBUG] 임시 콘솔 출력 후보가 있다
PASS AI 코드에도 같은 규칙
PASS 한 줄의 복수 위반
PASS 빈 입력
PASS CRLF 줄 번호
PASS 전달한 규칙 목록만 적용
PASS 주석도 찾는 한계 확인
PASS 호출 표기 차이를 놓치는 한계 확인
실패 수: 0
결과는 두 갈래로 읽는다. 동작 확인에서는 세 입력의 계산 결과가 전후에 같다. 규칙 검사에서는 기존 조각의 두 위반이 수정 후 사라졌다. 어느 한쪽의 성공이 다른 쪽을 대신하지 않는다. 형식이 맞아도 계산이 바뀔 수 있고, 계산이 같아도 팀이 정한 형식은 어길 수 있다.
실무에서 자주 틀리는 것
첫 위반을 찾으면 그 줄의 검사를 끝낸다
다음은 inspect의 안쪽 반복문을 잘못 바꾼 조각이다. 첫 번째 규칙에 해당하면 break로 나가므로 한 줄의 다른 위반을 놓친다. 탭과 줄 끝 공백이 함께 있을 때 수정과 재검사를 여러 번 반복하게 된다.
for (Rule rule : rules) {
if (rule.matches().test(lines[index])) {
violations.add(new Violation(
index + 1, rule.id(), rule.message()));
break;
}
}
각 규칙을 독립적으로 보고하기로 했다면 끝까지 검사한다. 보고 건수의 단위가 줄인지, 규칙과 줄의 조합인지 먼저 정해야 한다. 이 장은 규칙과 줄의 조합을 한 건으로 센다.
for (Rule rule : rules) {
if (rule.matches().test(lines[index])) {
violations.add(new Violation(
index + 1, rule.id(), rule.message()));
}
}
호출자가 전달한 규칙 목록을 무시한다
메서드가 rules를 받으면서 본문에서는 전역 목록만 사용하면, 특정 규칙만 확인하려는 호출의 의도가 무시된다. 매개변수와 실제 동작이 어긋나므로 호출 코드를 읽어도 결과를 예측하기 어렵다.
for (Rule rule : RULES) {
if (rule.matches().test(lines[index])) {
violations.add(new Violation(
index + 1, rule.id(), rule.message()));
}
}
전달받은 목록을 사용한다. 실제 운영에서 어떤 목록을 허용할지는 실행 진입점에서 정한다. 이 유연성을 승인 없이 규칙을 빼는 수단으로 사용해서는 안 된다. 기술적으로 목록을 선택할 수 있다는 사실과 예외를 승인받았다는 사실은 다르다.
for (Rule rule : rules) {
if (rule.matches().test(lines[index])) {
violations.add(new Violation(
index + 1, rule.id(), rule.message()));
}
}
생성된 코드라면 검사하지 않는다
다음 코드는 출처에 따라 검사를 건너뛰는 잘못된 예다. 동일한 출력문이 사람이 작성한 파일에서는 발견되고 AI가 만든 파일에서는 남는다. 팀 규칙의 목적을 코드의 출처가 대신하게 된다.
static List<Violation> inspectSubmission(
String source, boolean generatedByAi) {
if (generatedByAi) {
return List.of();
}
return inspect(source, RULES);
}
같은 운영 코드 범위라면 같은 목록을 적용한다. 출처 기록이 필요하면 변경 설명에 남길 수 있지만, 그것이 검사 면제 조건이 되지는 않는다.
static List<Violation> inspectSubmission(String source) {
return inspect(source, RULES);
}
검사 전에 공백을 지워서 위반을 숨긴다
검사 입력을 정리한 뒤 확인하면 원래 있던 줄 끝 공백을 발견할 수 없다. 아래 예는 검사가 성공하도록 대상을 바꿔 버린다.
String prepared = source.stripTrailing();
List<Violation> violations = inspect(prepared, RULES);
보고가 목적이면 원문을 검사한다. 자동 수정 기능이 필요하다면 원문 검사, 수정, 수정본 재검사를 별도 단계로 둔다. 특히 Java 소스를 문법 해석 없이 일괄 치환하면 문자열의 내용까지 바꿀 수 있으므로, 이 예제의 문자 검사기를 소스 수정기로 확대해서 사용하지 않는다.
List<Violation> violations = inspect(source, RULES);
printReport("원문", violations);
한눈에 보기
| 구분 | 맡을 일 | 확인 근거 | 주의점 |
|---|---|---|---|
| 포매터 | 정해진 코드 형태로 맞춘다 | 설정과 수정 결과 | 계산의 의미를 보증하지 않는다 |
| 정적 검사 | 등록된 위반 후보를 찾는다 | 규칙과 위치가 있는 보고 | 탐지 범위와 누락 가능성을 적는다 |
| 사람의 리뷰 | 의미와 변경 근거를 확인한다 | 요구 사항과 실행 결과 | 도구 통과로 대체하지 않는다 |
| 예외 승인 | 제한된 범위의 허용을 판단한다 | 이유·승인자·종료 조건 | 발견 기록을 지우지 않는다 |
| AI 코드 검토 | 같은 범위에 같은 기준을 적용한다 | 동일한 검사와 동작 확인 | 생성 과정의 설명만 믿지 않는다 |
팀 규칙은 작성자를 평가하는 취향 목록보다, 변경 내용을 함께 판단하는 약속에 가까워야 한다. 무엇을 자동으로 확인하고 무엇을 사람이 판단하는지 드러나면 리뷰 대화가 구체적으로 바뀐다. 다음 종합 실습에서는 이런 기준을 오래된 정산 모듈의 변경 결과를 확인하는 데 사용한다.
연습 문제
- 공백 네 개로만 이루어진 한 줄을 검사하면 어떤 규칙이 보고되는지 예상하라. 위치와 식별자까지 확인하는 check를 추가하라.
- DEBUG가
System.err.println(도 후보로 보고하도록 변경하라. 기존 출력 후보 검사와 함께 새 조건을 확인하라. 이 변경으로도 놓치는 사례를 하나 적어라. - 리뷰 기준 문서에 넣을 예외 요청을 작성하라. 설명용 문자열에
System.out.println(이 포함되어 DEBUG가 보고된 상황이다. 규칙, 범위, 이유, 승인 주체, 종료 조건을 적어라. - “고친 후 위반 없음”만 확인하면 전후 동작 비교를 지워도 되는지 설명하라. 규칙 검사를 통과하면서 계산 결과를 바꾸는 수정 예를 하나 적어라.
정답과 해설
-
1행의 TRAILING 한 건이다. 탭이 없으므로 TAB은 해당하지 않는다. 공백이 여러 개여도 같은 줄의 같은 규칙은 한 건이다. 다음 코드를 main 안에 추가할 수 있다. 추가하면 그 check의 PASS 출력도 한 줄 늘어난다.
check(inspect(" ", RULES).equals(List.of( new Violation(1, "TRAILING", "줄 끝 공백이 있다") )), "공백만 있는 줄"); -
DEBUG의 판정 함수를 다음처럼 바꾼다. 설명 문자열과 식별자는 유지할 수 있다. 조건을 넓혔다고 Java 호출을 문법적으로 인식하게 된 것은 아니다.
line -> line.contains("System.out.println(") || line.contains("System.err.println(")두 형태를 함께 확인하는 검사는 다음과 같다. 괄호 앞에 공백을 둔
System.err.println (total);은 여전히 놓친다. 주석 안의 같은 글자를 후보로 보고하는 한계도 남는다.check(inspect(String.join("\n", "System.out.println(total);", "System.err.println(total);"), RULES).equals(List.of( new Violation(1, "DEBUG", "임시 콘솔 출력 후보가 있다"), new Violation(2, "DEBUG", "임시 콘솔 출력 후보가 있다") )), "두 출력 대상"); -
요청 예시는 “DEBUG, 출력 사용법 안내 메서드의 설명용 문자열 한 곳, 실행되는 호출이 아니라 안내 내용이므로 유지, 작성자와 다른 지정 검토자가 확인 후 승인, 안내 문자열을 제거하거나 검사기가 문자열과 호출을 구분하게 될 때 재검토”다. 실제 요청에는 파일명과 줄 또는 해당 선언도 적는다. 이 답안은 요청 내용의 예이며 승인 자체를 의미하지 않는다. 검토자는 문자열이라는 근거와 허용 범위를 확인해야 한다.
-
지우면 안 된다. 세 규칙은 탭, 줄 끝 공백, 특정 출력 표기를 확인한다.
cleanFee의 반환문을return total + 1;로 바꿔도 이 규칙들은 위반을 찾지 못한다. 그러나 같은 입력의 계산 결과는 달라진다. 자동 규칙 검사와 전후 동작 비교는 확인하는 대상이 다르므로 함께 유지한다.