다른 시스템에 (예를 들어, 소스 코드 관리 시스템, 버그 추적 시스템, 이슈 추적 시스템, 기타 기록 관리 시스템에) 저장할 정보는 주석으로 적절하지 못합니다. 일반적으로 작성자, 최종 수정일, SPR(Shoftware Problem Report) 번호 등과 같은 메터 정보만 주석으로 넣습니다. 주석은 코드와 설계에 기술적인 설명을 부연하는 수단입니다.
주석은 빨리 낡습니다. 쓸모 없어질 주석은 아예 달지 않거나, 재빨리 삭제하는 편이 가장 좋습니다.
코드만으로 충분한데 구구절절 설명하는 주석이 중복된 주석입니다. 주석은 코드만으로 다하지 못하는 설명을 부언합니다.
i++; // i 증가
/**
* @param sellRequest
* @returns
*/
function beginSellItem(sellRequest: SellRequest): SellResponse {}
주석을 달 참이라면 시간을 들여 최대한 신중하게 작성합니다. 문법과 구두점을 올바로 사용하고, 간결하고 명료하게 작성합니다.
주석으로 처리된 코드는 얼마나 오래된 코드인지, 중요한 코드인지, 알 길이 없습니다. 그럼에도 아무도 삭제하지 않습니다. 누군가에게 필요하거나 다른 사람이 사용할 코드라 생각하기 때문입니다.
주석으로 처리된 코드를 발견하면 즉각 지워버립니다. 소스 코드 관리 시스템이 기억하니까 적정할 필요 없습니다.
빌드는 간단히 한 명령으로 전체를 체크아웃해서 빌드할 수 있어야 합니다. 불가해한 명령이나 스크립트를 잇달아 실행해 각 요소를 따로 빌드할 필요가 없어야 합니다.
모든 단위 테스트는 한 명령으로 실행하는 능력은 아주 근본적이고 아주 중요합니다. IDE에서 버튼 하나로 모든 테스트를 돌린다면 가장 이상적입니다.
함수에서 인수 개수는 작을수록 좋습니다. 아예 없으면 가장 좋습니다. 넷 이상은 그 가치가 아주 의심스러우므로 최대한 피합니다.
출력 인수는 직관을 정면으로 위배합니다. 일반적으로 독자는 인수를 (출력이 아니라) 입력으로 간주합니다. 함수에서 뭔가 상태를 변경해야 한다면 (출력 인수를 쓰지 말고) 함수가 속한 객체의 상태를 변경합니다.
appendFooter(report); // X
report.appendFooter(); // O
boolean
인수는 함수가 여러 기능을 수행한다는 명백한 증거입니다. 플래그 인수는 혼란을 초래하므로 피해야 합니다.
아무도 호출하지 않는 함수는 삭제합니다.죽은 코드는 낭비입니다. 과감히 삭제합니다. 소스 코드 관리 시스템이 모두 기억하므로 걱정할 필요 없습니다.
오늘날 프로그래밍 환경은 한 소스 파일 내에서 다양한 언어를 지원합니다. 좋게 말하면 혼란스럽고, 나쁘게 말하자면 조잡합니다. 이상적으로는 소스 파일 하나에 언어 하나만 사용하는 방식이 가장 좋습니다. 현실적으로는 여러 언어가 불가피합니다. 하지만 각별한 노력을 기울여 소스 파일에서 언어 수와 범위를 최대한 줄이도록 애써야 합니다.
최소 놀람의 원칙(The Principle of Least Surprise)에 의거해 함수나 클래스는 다른 프로그래머가 당연하게 여길 만한 동작과 기능을 제공해야 합니다. 당연한 동작을 구현하지 않으면 코드를 읽거나 사용하는 사람이 더 이상 함수 이름만으로 함수 기능을 직관적으로 예상하기 어렵습니다. 저자를 신뢰하지 못하므로 코드를 일일이 살펴야 합니다.
예를 들어, 다음은 요일 문자열에서 요일을 나타내는 enum
으로 변환하는 함수입니다. 우리는 함수가 Monday
를 Day.MONDAY
로 변환하리라 기대합니다. 또한 일반적으올 쓰는 요일 약어도 올바로 변환하리라 기대합니다. 대소문자는 당연히 구분하지 않으리라 기대합니다.
const day: Day = DayRate.StringToDay(dayName);
코드는 올바로 동작해야 합니다. 흔히 개발자들은 머릿속에서 코드를 돌려보고 끝냅니다. 자신의 직관에 의존할 분 모든 경계와 구석진 곳에서 코드를 증명하려 애쓰지 않습니다.
부지런함을 대신할 지름길을 없습니다. 모든 경계 조건, 모든 구석진 곳, 모든 기벽, 모든 예외는 우아하고 직관적인 알고리즘을 좌초시킬 암호입니다. 스스로의 직관에 의존하지 마세요. 모든 경계 조건을 찾아내고, 모든 경계 조건을 테스트하는 테스트 케이스를 작성합니다.
안전 절차를 무시하면 위험합니다. 컴파일러 경고 일부를(혹은 전부를!) 꺼버리면 빌드가 쉬어질지 모르지만 자칫하면 끝없는 디버깅에 시달립니다.
코드에서 중복을 발견할 떄마다 추상화할 기회로 간주해야 합니다. 중복된 코드를 하위 루팅이나 다른 클래스로 분리합니다. 이렇듯 추상화로 중복을 정리하면 설계 언어의 어휘가 늘어납니다. 다른 프로그래머들이 그만큼 어휘를 사용하기 쉬어집니다. 추상화 수준을 높였으므로 구현이 빨라지고 오류가 적어집니다.
가장 뻔한 유형은 똑같은 코드가 여러 차례 나오는 중복입니다. 이런 중복은 간단한 함수로 교체합니다.
좀 더 미묘한 유형은 여러 모듈에 일련의 swtich/case
나 if/else
문으로 똑같은 도건을 거듭 확인하는 중복입니다. 이런 중복은 다향성(Polymorphism)으로 대체해야 합니다.
더더욱 미묘한 유형은 알고리즘이 유사하나 코드가 서로 다른 중복입니다. 중복은 중복이므로 TEMPLATE METHOD 패턴이나 STARTEGT 패턴으로 중복을 제거합니다.
추상화는 저차원 상세 개념에서 고차원 일반 개념을 분리합니다. 때로 우리는 (고차원 개념을 표현하는) 추상 클래스와 (저차원 개념을 표현하는) 파생 클래스를 생성해 추상화를 수행합니다. 추상화로 개념을 분리할 떄는 철저해야 합니다. 모든 저차원 개념은 파생 클래스에 넣고, 모든 고차원 개념은 기초 클래스에 넣습니다.
예를 들어, 세부 구현과 관련한 상수, 변수, 유틸리티 함수는 기초 클래스에 넣으면 안됩니다. 기초 클래스는 구현 정보에 무지해야 마땅합니다.
소스 파일, 컴포넌트, 모듈도 마찬가지 입니다. 우수한 소프트웨어 설계자는 개념을 다양한 차원으로 분리해 다른 컨테이너에 넣습니다. 때로는 기초 클래스와 파생 클래스로 분리하고, 때로는 소스 파일과 모듈과 컴포넌트로 분리합니다. 어느 경우든 철저히 분리해야 합니다. 고차원 개념과 저차원 개념을 섞어서는 안 됩니다.
interface Stack {
pop: () => object;
push: (o: object) => void;
}
// 모든 Stack에 "꽉 찬 정도"라는 개념이 타당한 것은 아닙니다.
interface BoundStack extends Stack {
percentFull: () => number;
}
개념을 기초 클래스와 파생 클래스로 나누는 가장 흔한 이유는 고차원 기초 클래스 개념을 저차원 파생 클래스 개념으로부터 분리해 독립성을 보장하기 위해서입니다. 그러므로 기초 클래스가 파생 클래스를 사용한다면 뭔가 문제가 있다는 말입니다. 일반적으로 기초 클래스는 파생 클래스를 아예 몰라야 마땅합니다.
잘 정의된 인터페이스는 많은 함수를 제공하지 않습니다. 그래서 결합도(Coupling)가 낮습니다. 클래스가 제공하는 메서드 수는 작을수록 좋습니다. 함수가 아는 변수 수도 작을수록 좋습니다. 클래스에 들어있는 인스턴스 변수 수도 작을수록 좋습니다.
protected
변수나 함수를 마구 생성하지 않습니다.죽은 코드란 실행되지 않은 코드를 가리킵니다. 불가능한 조건을 확인하는 if
문과 throw
문이 없는 try
문에서 catch
블록이 좋은 예입니다. 아무도 호출하지 않는 유틸리티 함수과 switch/case
문에서 불가능한 case
조건도 또 다른 좋은 예입니다.
죽은 코드는 설계가 변해도 제대로 수정되지 않이 때문에, 시간이 지나면 악취를 풍기기 시작합니다. 죽은 코드를 발견하면 시스템에서 제거합니다.
변수와 함수는 사용되는 위치에 가깝게 정의합니다. 지역 변수는 처음으로 사용하기 직전에 선언하며 수직으로 가까운 곳에 위치해야 합니다.
비공개 함수는 처음으로 호출한 직후에 정의하여, 조금만 아래로 내려도 쉽게 눈에 띄도록 합니다. 비공개 함수는 전체 클래스 범위(Scope)에 속하지만, 그래도 정의하는 위치와 호출하는 위치를 가깝게 유지합니다.
어떤 개념을 특정 방식으로 구현했다면 유사한 개념도 같은 방식으로 구현해야 합니다. 앞서 언급한 최소 놀람의 원칭(The Princlple of Least Surprise)에도 부합합니다. 표기법은 신중하게 선택하며, 일단 선택한 표기법은 신중하게 따릅니다.
한 함수에서 response
라는 변수에 HttpServletResponse
인스턴스를 저장했다면, (HttpServletResponse
객체를 사용하는) 다른 함수에서도 일관성 있게 동일한 변수 이름을 사용합니다. 한 메서드를 processVerificationRequest
라 명명했다면 (유사한 요청을 처리하는) 다른 메서드도 유사한 이름을 사용합니다.
착실하게 적용한다면 이처럼 간단한 일관성만으로도 코드를 읽고 수정하기가 대단히 쉬워집니다.
아무도 사용하지 않는 변수, 아무도 호출하지 않는 함수, 정보를 제공하지 못하는 주석 등은 코드만 복잡하게 만들 뿐이므로 제거해야 마땅합니다.
서로 무관한 개념을 인위적으로 결합하지 않습니다. 일반적으로 인위적 결합은 직접적인 상호작용이 없는 두 모듈 사이에서 일어납니다. 뚜력한 목적 없이 변수, 상수, 함수를 당장 편한 위치에 넣어버린 결과입니다.
클래스 메서드는 자기 클래스의 변수와 함수에 관심을 가져야지 다른 클래스의 변수와 함수에 관심을 가져서는 안됩니다. 메서드가 다른 객체의 참조자(accessor)와 변경자(mutator)를 사용해 그 객체 내용을 조작한다면, 메서드가 그 객체 클래스의 범위를 욕심내는 탓입니다.
class HourlyPayCalculator {
calculateWeeklyPay(e: HourlyEmployee): Money {
const tenthRate = e.getTenthRate().getPennies();
const tenthsWorked = e.getTenthsWorked();
const straightTime = Math.min(400, tenthWorked);
const overTime = Math.max(0, tenthWorked - straightTime);
const straightPay = straightTime * tenthRate;
const overtimePay = Math.round(overTime * tenthRate * 1.5);
return new Money(straightPay + overtimePay);
}
}
calculateWeeklyPay
메서드는 HourlyEmployee
객체에서 온갖 정보를 가져옵니다. 즉, calculateWeeklyPay
메서드는 HourlyEmployee
클래스 범위를 욕심냅니다. calulateWeeklyPay
메서드는 자신이 HourlyEmployee
클래스에 속하기를 바랍니다.
기능 욕심은 한 클래스의 속사정을 다른 클래스에 노출하므로, 별다른 문제가 없다면 제거하는 편이 좋습니다. 하지만 때로는 어쩔 수 없는 경우도 생깁니다.
class HourlyEmployeeReport {
private employee: HourlyEmployee;
constructor(e: HourlyEmplyee) {
this.employee = e;
}
reportHours(): string {
return `Name: ${this.employee.getName()}\tHours:${employee.getTenthsWorked() / 10},${
employee.getTenthsWorked() % 10
}\n`;
}
}
확실히 reportHours
메서드는 HourlyEmplyee
클래스를 욕심냅니다. 하지만 그렇다고 HourlyEmployee
클래스가 보고서 형식을 알 필요는 없습니다. 함수를 HourlyEmplyee
클래스로 옮기면 객체 지향 설계의 여러 원칙을 위반합니다. HourlyEmployee
가 보고서 형식과 결합되므로 보고서 형식이 바뀌면서 클래스도 바뀝니다.
선택자(Selector) 인수는 목적을 기억하기 어려울 뿐 아니라 각 선택자 인수가 여러 함수를 하나로 조합합니다. 선택자 인수는 큼 함수를 작은 함수 여럿으로 쪼개지 않으려는 게으름의 소산입니다.
function calculateWeeklyPay(overtime: boolean): number {
const tenthRate = getTenthRate();
const tenthWorked = getTenthsWorked();
const straightTime = Math.min(400, tenthWorked);
const overTime = Math.max(0, tenthWorked - straightTime);
const strightPay = straightTime * tenthRate;
const overtimeRate = overtime ? 1.5 : 1.0 * tenthRate;
const overtimePay = Math.round(overtime * overtimeRate);
return strightPay + overtimePay;
}
위는 부울 인수만이 문저가 아닙니다. 함수 동작을 제어하려는 인수는 하나 같이 바람직하지 않습니다. 일반적으로 인수를 넘겨 동작을 선택하는 대신 새로운 함수를 만드는 편이 좋습니다.
function strightPay(): number {
return getTenthsWroked() * getTenthRate();
}
function overTimePay(): number {
const overTimeTenths = Math.max(0, getTenthWorked() - 400);
const overTimePay = overTimeBounus(overTimeTenths);
return strightPay() * overTimePay;
}
function overTimeBounus(overTimeTenths: number) {
const bonus = 0.5 * getTenthRate() * overTimeTenths;
return Math.round(bonus);
}
코드를 짤 때는 의도를 최대한 분명히 밝힙니다. 행을 바꾸지 않고 표현한 수식, 헝가리식 표기법, 매직 번호 등은 모두 저자의 의도를 흐립니다. 예를 들어 overTimePay
함수를 다음과 같이 짤 수도 있습니다. 짧고 빽빽할 뿐만 아니라 거의 불가해합니다.
function m_otCalc(): number {
return iThsWkd * iThsRte + Math.round(0.5 * iThsRte * Math.max(0, iThsWkd - 400));
}
소프트웨어 개발자가 내리는 가장 중요한 결정 중 하나가 코드를 배치하는 위치입니다. 여기서도 최소 놀람의 원칙(The Principle of Least Surprise)을 적용합니다. 코드는 독자가 자연스럽게 기대할 위치에 배치합니다.
때로는 개발자가 영리하게 기능을 배치합니다. 독자에게 직관적인 위치가 아니라 개발자에게 편한 함수에 배치합니다. 결정을 내리는 한 가지 방법으로 함수 이름을 살펴봅니다. 보고서 모듈에 getTotalHours
라는 함수가 있고, 근무 시간을 입력 받는 모듈에 saveTimeCard
라는 함수가 있다고 가정합니다. 이름만 보았을 떄 두 함수 중 총계를 계산하는 코드가 들어갈 함수는 명백합니다.
때로는 성능을 높이고자 근무 시간을 입력 받는 모듈에 총계를 계산하는 편이 좋다고 판단할 수 있습니다. 그러려면 이런 사실을 반영해 함수 이름을 computeRunngingTotalOfHours
와 같이 지어야 합니다.
Math.max()
는 좋은 static
메서드입니다. 특정 인스턴스와 곤련된 기능이 아닙니다. max
메서드가 사용하는 정보는 두 인수가 전부입니다. 메서드를 소유하는 객체에서 가져오는 정보가 아닙니다. 결정적으로 Math.max
메서드를 재정의(Override)할 가능성은 전혀 없습니다.
특정 객체와 간롼이 없으면서 모든 정보를 인수에서 가져오더라도, 함수를 재정의할 가능성이 있다면 static
함수로 정의하면 안 됩니다. 클래스에 속하는 인스턴스 함수여야 합니다.
일반적으로 static
함수보다 인스턴스 함수가 더 좋습니다. 조금이라도 의심스럽다면 인스턴스 함수로 정의합니다. 반드시 static
람수로 정의해야겠다면 재정의할 가능성은 없는지 꼼꼼히 따져봅니다.
프로그램 가독성을 높이는 가장 효과적인 방법 중 하나가 계산을 여러 단계로 나누고 중간 값을 서술적인 변수 이름을 사용하는 방법입니다. 서술적인 변수 이름은 많을수록 좋습니다. 계산을 몇 단계로 나누고 중간값에 좋은 변수 이름만 붙여도 해독하기 어렵던 모듈이 순식간에 읽기 쉬운 모듈로 탈바꿈합니다.
const match: Matcher = headerPattern.matcher(line);
if (match.find()) {
const key: string = match.group(1);
const value: string = match.group(2);
headers.put(key.toLowerCase(), value);
}
이름만으로 분명하지 않기에 구현을 살피거나 문서를 뒤적여야 한다면 더 좋은 이름으로 바꾸거나 아니면 더 좋은 이름을 붙이기 쉽도록 기능을 정리해야 합니다.
// 무엇을 더하는 것인지, 새로운 Date를 반환하는 것인지 알 수 없다.
const newDate: Date = date.add(5);
// 5일을 더해 date 인스턴스를 변경하는 함수
date.addDaysTo(5);
date.increaseByDays(5);
// 5일을 더해 새 날짜를 반환하는 함수
const newDate: Date = date.daysLater(5);
const newDate: Date = date.daysSince(5);
대다수 괴상한 코드는 사람들이 알고리즘을 충분히 이해하지 않은 채 코드를 구현한 탓입니다. 잠시 멈추고 실제 알고리즘을 고민하는 대신 여기저기 if
문과 플래그르 넣어보며 코드를 돌리는 탓입니다.
구현이 끝났다고 선언하기 전에 함수가 돌아가는 방식을 확실히 이해하는지 확인하세요. 알고리즘이 올바르다는 사실을 확인하고 이해하려면 기능이 뻔히 보일 정도로 함수를 깜끔하고 명확하게 재구성하는 방법이 최고입니다.
한 모듈이 다른 모듈에 의존한다면 물리적인 의존성이 있어야 합니다. 의존하는 모듈이 상대 모듈에 대해 뭔가를 가정하면 안됩니다. 의존하는 모든 정보를 명시적으로 요청하는 평이 좋습니다. 예를 들어, 근무 시간 보고서를 가공되지 않은 상태로 출력하는 함수를 구현한다고 가정합니다.
class HourlyReporter {
private formatter: HourlyReportFormatter;
private page: LineItem[];
private readonly PAGE_SIZE: number = 55;
constructor(formatter: HourlyReportFormatter) {
this.formatter = formatter;
this.page = [];
}
public generateReport(employees: HourlyEmplyee[]): void {
for (const e of employees) {
this.addLineItemToPage(e);
if (this.page.length === this.PAGE_SIZE) {
this.printAndClearItemList();
}
}
if (this.page.length > 0) {
this.printAndClearItemList();
}
}
private printAndClearItemList(): void {
this.formatter.format(this.page);
this.page = [];
}
private addLineItemToPage(e: HourlyEmplyee): void {
const item: LineItem = new LineItem();
item.name = e.getName();
item.hours = e.getTenthsWorked() / 10;
item.tenths = e.getTenthsWorked() % 10;
this.page.push(item);
}
}
class LineItem {
public name: string;
public hours: number;
public tenths: number;
}
PAGE_SIZE
를 HourlyReporter
클래스에 선언한 실수는 잘못 지운 책임에 해당합니다. HourlyReportFormatter
가 페이지 크기를 알 거라고 가정해야 합니다. 바로 이 가정이 논리적 의존성입니다.HourlyReportFormatter
에 getMaxPageSize()
라는 메서드를 추가하면 논리적인 의존성이 물리적인 의존성으로 변합니다. HourlyReportFormatter
클래스는 PAGE_SIZE
상수를 사용하는 대신 getMaxPageSize()
함수를 호출하면 됩니다.if (this.page.length === this.formatter.getMaxPageSize()) {
this.printAndClearItemList();
}
switch
문을 사용하는 이유는 그 상황에서 가장 올바른 선택이기보다는 당장 손쉬운 선택이기 때문입니다. 그러무로 switch
를 선택하기 전에 다향성을 먼저 고려하라는 의미입니다.swtich
문을 의심해야 합니다.선택 유형 하나에는 swtich
문을 한 번만 사용합니다. 같은 선택을 수행하는 다른 코드에서는 다향성 객체를 생성해 swtich
문을 대신합니다.
구현 표준은 인스턴스 변수 이름을 선언하는 위치, 클래스/메서드/변수 이름을 정하는 방법, 괄호를 넣은 위치 등을 명시해야 합니다. 표준을 설명하는 문서는 코드 자체로 충분해야 하며 별도 문서를 만들 필요는 없어야 합니다. 팀이 정한 표준은 팀원들 모두가 따라야 합니다.
일반적으로 코드에서 숫자를 사용하지 말라는 규칙입니다. 숫자는 명명된 상수 뒤로 숨기라는 의미입니다. 하지만 어떤 상수는 이해하기 쉬우므로, 코드 자체가 자명하다면, 상수 뒤로 숨길 필요가 없습니다.
const milesWalked = feetWalked / 5280.0;
const dailyPay = hourlyRate * 8;
const circumference = radius * Math.PI * 2;
매직 숫자
라는 용어는 단지 숫자만 의미하지 않습니다. 의미가 분명하지 않은 토큰을 모두 가리킵니다.
// As Is
assertEquals(7777, Employee.find("John Doe").employeeNumber());
// To Be
assertEquals(HOURLY_EMPLYEE_ID, Employee.find(HOURLY_EMPLOYEE_NAME).employeeNumber());
코드에서 뭔가를 결정할 때는 정확히 결정합니다. 결정을 내리는 이유와 예외를 처리할 방법을 분명히 알아야 합니다. 호출하는 함수가 null
을 반환할지도 모른다면 null
을 반드시 점검합니다. 조회 결과가 하나뿐이라 짐작한다면 하나인지 확실히 확인합니다. 통화를 다뤄야 한다면 쩡수를 사용하고 반올림을 올바로 처리합니다. 병행(Concurrent) 특성으로 인해 동시에 갱신할 가능성이 있다면 적절한 잠금 메커니즘을 구현합니다.
코드에서 모호성과 부정확은 의견차나 게으름의 결과입니다. 어느 쪽이든 제거해야 마땅합니다.
설계 결정을 강제할 때는 규칙보다 관례를 사용합니다. 명명 관례도 좋지만 구조 자체로 강제하면 더 좋습니다. 예를 들어, enum
변수가 멋진 swtich
/case
문보다 추상 메서드가 있는 기초 클래스가 더 좋습니다. switch
/case
문을 매번 똑같이 구현하게 강제하기는 어렵지만, 파생 클래스는 추상 메서드를 모두 구현하지 않으면 안 되기 때운입니다.
부울 논리는(if
나 while
문에다 넣어 생각하지 않아도) 이해하기 어렵습니다. 조건의 의도를 분명히 밝히는 함수로 표현합니다.
// As Is
if (timer.hasExpired() && !timer.isRecurrent()) {
}
// TO Bde
if (shouldBeDeleted(timer)) {
}
부정 조건은 긍정 조건보다 이해하기 어렵습니다. 가능하면 긍정 조건으로 표현합니다.
// As Is
if (!buffer.shouldNotCompact()) {
}
// To Be
if (buffer.shouldCompoat()) {
}
함수를 짜다보면 한 함수 안에 여러 단락을 이어, 일련의 작업을 수행하고픈 유혹에 빠집니다. 이런 함수는 한 가지만 수행하는 함수가 아닙니다. 한 가지만 수행하는 좀 더 작은 함수 여럿으로 나눠야 마땅합니다.
// As Is
function pay(): void {
for (const e of employees) {
if (e.isPayday()) {
const pay: Money = e.calculatePay();
e.deliverPay();
}
}
}
// To Be
function pay() {
for (const e of employees) {
payIfNecessary(e);
}
}
function payIfNecessary(e: Employee) {
if (e.isPayday()) {
calculateAndDeliverPay(e);
}
}
function calculateAndDeliverPay(e: Employee) {
const pay: Money = e.calculatePay();
e.deliverPay();
}
때로는 시간적인 결합이 필요합니다. 하지만 시간적인 결합을 숨겨서는 안 됩니다. 함수를 짤 때는 함수 인수를 적절히 배치해 함수가 호출되는 순서를 명백히 드러냅니다.
class MoogDiver {
public gradient: Gradient;
public splines: Spline[];
public dive(reason: string) {
this.saturateGradient();
this.reticulateSplines();
this.diveForMoog(reason);
}
}
불행히도 위 코드는 시간적인 결합을 강제하지 않습니다. 프로그래머가 reticulateSplines
를 먼저 호출하고 sturateGradient
를 다음으로 후출하는 바람에 UnsaturatedGradientException
오류가 발생해도 막을 도리가 없습니다.
class MoogDiver {
public gradient: Gradient;
public splines: Spline[];
public dive(reason: string) {
const gradient: Gradinet = this.saturateGradient();
const splines: Spline[] = this.reticulateSplines(gradient);
this.diveForMoog(splines, reason);
}
}
위 코드는 일종의 연결 소자를 생성해 시간적인 결합을 노출합니다. 각 함수가 내놓은 결과는 다음 함수에 필요합니다. 그러므로 순서를 바꿔 호출할 수가 업습니다. 의도적으로 추가한 구문적인 복잡성이 원래 있던 시간적인 복잡성을 드러낸 셈입니다.
위에서 인스턴스 변수를 그대로 두었다는 사실에 주목합니다. 해당 클래스의 private
메서드에 필요한 변수일지도 모릅니다. 그렇다 치더라도 제자리를 찾은 변수들이 시간적인 결합을 좀 더 명백히 드러낼 것입니다.
코드 구조를 잡을 떄는 이유를 고민합니다. 그리고 그 이유를 코드 구조로 명백히 표현합니다. 구조에 일관성이 없어 보인다면 남들이 맘대로 바꿔도 괜찮다고 생각합니다. 시스템 전반에 걸쳐 구조가 일관성이 있다면 남들도 일관성을 따르고 보존합니다.
경계 조건은 빼먹거나 놓치기 십상입니다. 경계 조건은 한 곳에서 별도로 처리합니다. 코드 여기저기에서 처리하지 않습니다.
// As Is
if (level + 1 < tags.length) {
const parts = new Parse(body, tags, level + 1, offset + endTag);
body = null;
}
// To Be
const nextLevel = level + 1;
if (nextLevel < tags.length) {
const parts = new Parse(body, tags, nextLevel, offset + endTag);
body = null;
}
함수 내 모든 문장은 추상화 수준이 동일해야 합니다. 그리고 그 추상화 수준은 함수 이름이 의미하는 작업보다 한 단계만 낮아야 합니다.
function render(): string {
let html: string = "<hr";
if (size > 0) {
html = `${html} size="${this.size + 1}" />`;
}
return html;
}
위 모듈은 연이은 대시(-
)를 감지해 HR 태그로 변환합니다. 대시 수가 많을수록 크기는 커집니다. 이 코드는 추상화 수준이 최소한 두 개가 섞여 있습니다. 첫째는 수평성에 크기가 있다는 개념입니다. 둘째는 HR 태그 자체의 문법입니다.
function render(): string {
const hr = window.document.createElement("hr");
if (extrDashes > 0) {
hr.setAttribute("size", hrSize(extraDashes));
}
return hr.outerHTML;
}
function hrSize(height: number): string {
const hrSize = height + 1;
return hrSize + "";
}
위 코드는 뒤섞인 추상화 수준을 멋지게 분리합니다. render
함수는 HR 태그만 생성합니다. HR 태그는 문법을 전혀 상관하지 않습니다. HTML 문법은 Document
모듈이 알아서 처리합니다.
추상화 최상위 단계에 둬야 할 기본값 상수나 설정 관련 상수를 저차원 함수에 숨겨서는 안 됩니다. 대신 고차원 함수에서 저차원 함수를 호출할 때 인수로 넘깁니다. 저차원 함수에 상수 값을 정의하면 안됩니다.
const arguments: Arguments = parseCommandLine(args);
// ...
class Arguments {
public static readonly DEFAULT_PATH: string = ".";
public static readonly DEFAULT_ROOT: string = "FitNesseRoot";
public static readonly DEFAULT_PATH: number = 80;
public static readonly DEFAULT_PATH: number = 14;
// ...
}
각 인수 기본값은 Argument
클래스의 맨 처음에 나옵니다. 다음과 같은 코드를 찾으러 시스템의 저수준을 뒤질 필요가 없습니다.
if (arguments.port === 0) {
// 기본값으로 80을 사용합니다.
}
일반적으로 한 모듈은 주변 모듈을 모를수록 좋습니다. 좀 더 구체저긍로, A가 B를 사용하고 B가 C를 사용한다 하더라도 A가 C를 알아야 할 필요는 없다는 뜻입니다.
이를 디미터의 법칙(Law of Demeter)이라 부릅니다. 실용주의 프로그래머들은 부끄럼 타는 코드 작성(Writing Shy Code)이라고도 합니다. 무엇이라 부르든 요지는 자신이 직접 사용하는 모듈만 알아야 한다는 뜻입니다.
내가 사용하는 모듈이 내게 필요한 서비스를 모두 제공해야 합니다. 원하는 메서드를 찾느라 객체 그래프를 따라 시스템을 탐색할 필요가 없어야 합니다.
myCollaborator.doSomething();
이름은 성극하게 정하지 않습니다. 서술적인 이름을 신중하게 고릅니다. 소프트웨어가 진화하면 의미도 변하므로 선택한 이름이 적합한지 자주 되돌아봅니다. 소프트웨어 가독성의 90%는 이름이 결정합니다. 그러므로 시간을 들여 현명한 이름을 선택하고 유효한 상태로 유지합니다.
// As Is
function x() {
let q = 0;
let z = 0;
for (let kk = 0; kk < 10; kk += 1) {
if (l[z] === 10) {
q += 10 + (l[z + 1] + l[z + 2]);
} else if (l[z] + l[z + 1] === 10) {
q += 10 + l[z + 2];
z += 2;
} else {
q += l[z] + l[z + 1];
z += 2;
}
}
return q;
}
// To Be
function score() {
let score = 0;
let frame = 0;
for (let frameNumber = 0; frameNumber < 10; frameNumber += 1) {
if (isStrike(frame)) {
score += 1 + nextTwoBallsForStrike(frame);
frame += 1;
} else if (isSpare(frame)) {
score += 10 + nuextBallForSpare(frame);
frame += 2;
} else {
score += twoBallsInFrame(frame);
frame += 2;
}
}
return score;
}
신중하게 선택한 이름은 추가 설명을 포함한 코드보다 강력합니다. 신중하게 선택한 이름을 보고 독자는 모듈 내 다른 함수가 하는 일을 짐작합니다. isStrike()
함수를 찾아보면 거의 예상한 대로 동작합니다.
function isStrike(frame: number): boolean {
return rolls[frame] === 10;
}
구현을 드러내는 이름은 피합니다. 작업 대상 클래스나 함수가 위차흐는 추상화 수준을 반영하는 이름을 선택합니다. 코드를 살펴볼 때마다 추상화 수준이 너무 낮은 변수 이름을 발견하면 기회를 잡아 바꿔놓아야 합니다. 안정적인 코드를 만들려면 지속적인 개선과 노력이 필요합니다.
interface Modem {
dial(phoneNumber: string): boolean;
disconnect(): boolean;
send(c: stinrg): boolean;
recv(): string;
getConnectedPhoneNumber(): string;
}
대다사 애플리케이션에서는 문제가 없는 함수입니다. 하지만 전화선에 연결되지 않는 일부 모델을 사용하는 애플리케이션에서는, 전화번호 대신 USB로 연결된 스퀴치에 포트 번호를 보낼지도 모릅니다.그렇다면 전화번호라는 개념은 확실히 추상화 수준이 틀렸습니다.
interface Modem {
coneect(connectionLocator: string): boolean;
disconnect(): boolean;
send(c: stinrg): boolean;
recv(): string;
getConnectedLocator(): string;
}
기존 명명법을 사용하는 이름은 이해하기 더 쉽습니다. 예름 들어, DECORATOR
패턴을 활용한다면 장식하는 클래스 이름에 Decorator
라는 단어를 사용해야 합니다. 에를 들어, AutoHangupModemDecorator
는 세션 끝 무렵에 자동으로 연결을 끊는 기능으로 Modem
을 장식하는 클래스 이름에 적합합니다.
패턴은 한 가지 표준에 불과하빈다. 예를 들어, 객체를 문자열로 변환하는 함수는 toString
이라는 이름을 많이 씁니다. 이런 이름은 (새로 만드렁내기보다) 관례를 따르는 편이 좋습니다.
흔히 팀마다 특정 프로젝트에 적용할 표준을 나름대로 고안합니다. 에릭 에반스(Eric Evans)는 이른 프로젝트의 유비쿼터스 언어(Ubiqutous Language)라 부릅니다. 코드는 이 언어에 속하는 용어를 열심히 써야 합니다. 간단히 말해, 프로젝트에 유효한 의미가 담긴 이름을 많이 사용할수록 독자가 코드를 이해하기 쉬워집니다.
함수나 변수의 목적을 면확히 밝히는 이름을 선택합니다.
class {
// ...
doRename(): string {
if (this.refactorReferences) {
this.renameReferences();
}
this.renamePage();
this.pathToRename.removeNameFormEnd();
this.pathToRename.addNameToEnd(this.newName);
return PathParser.render(this.pathToRename);
}
}
이름만 봐서는 함수가 하는 일이 분명하지 않습니다. 아주 광범위하며 모호합니다. doRename
함수 안에 renamePage
라는 함수가 있어 더더욱 모호합니다. 이름만으로 두 함수 사이의 차이점이 드러나지 않습니다.
renamePageAndOptionallyAllReferences
라는 이름이 더 좋습니다. 아주 길지만 모듈에서 한 번만 호출됩니다. 길다는 단점을 서술성이 충분히 매꿉니다.
이름 긿이는 범위 길이에 비례해야 합니다. 범위가 작으면 아주 짧은 이름을 사용해도 괜찮습니다. 하지만 범위가 길어지면 긴 이름을 사용합니다.
function rollMany(n: number, pins: number): void {
for (let i = 0; i < n; i += 1) {
g.roll(pins);
}
}
함수, 변수, 클래스가 하는 일을 모두 기술하는 이름을 사용합니다. 이름에 부수 효과를 숨기지 않습니다. 실제로 여러 작업을 수행하는 함수에다 동사 하나만 달랑 사용하면 곤란합니다.
class {
// As Is
public getOos(): ObjectOutputStream {
if (this.m_oos === null) {
m_oos = new ObjectOutputStream(this.m_socket.getOutputStream());
}
return m_oos;
}
// To Be
public createOrReturnOos(): ObjectOutputStream {}
}
테스트 케이스는 잠재적으로 깨질 만한 부분을 모두 테스트해야 합니다. 테스트 케이스가 확힌하지 않은 조건이나 검증하지 않는 계산이 있다면 그 테스트는 불완전합니다.
커버리지 도구는 테스트가 빠뜨리느 공백을 알려줍니다. 커버리지 도구를 사용하면 테스트가 불충분한 모듈, 클래스, 함수를 찾기가 쉬워집니다. 대다수 IDE는 테스트 커비리지를 시각적으로 표현합니다. 그러므로 전혀 실행되지 않는 if
혹은 case
문 블록이 금방 드러납니다.
사소한 테스트는 짜기 쉽습니다. 사소한 테스트가 제공하는 문서적 가치는 구현에 드는 비용을 넘어섭니다.
때로는 요구사항이 불분명하기에 프로그램이 돌아가는 방식을 확신하기 어렵습니다.
경꼐 조건은 각별히 신경 써서 테스트합니다. 알고리즘의 중앙 조건은 올바로 짜 놓고 경꼐 조건에서 실수하는 경우가 흔합니다.
버그는 서로 모이는 경향이 있습니다. 함 함수에서 버그를 발견했다면 그 함수를 철저히 테스트하는 편이 좋습니다.
때로는 테스트 케이스가 실패하는 패턴으로 문제를 진단할 수 있습니다. 테스트 케이스를 최대한 꼼꼼히 짜라는 이유도 여기에 있습니다. 함리적인 순서로 정렬된 꼼곰한 테스트 케이스는 실패 패턴을 드러냅니다.
통과하는 테스트가 실행하거나 실행하지 않는 코드를 살펴보면 실패하는 테스트 케이스의 실패 원인이 드러납니다.
느린 테스트 케이스는 실행하지 않게 됩니다. 일정이 촉박하면 느린 테스트 케이스를 제일 먼저 건너뜁니다. 그러므로 테스트 케이스가 빨리 돌아가게 최대한 노력합니다.