Skip to main content

17장 냄새와 휴리스틱

1. 📌 핵심 개념 정리

✅ 요약하기

1. 주석 (Comments)
  • 부적절한 정보: 소스 코드 관리 시스템, 버그 추적 시스템 등 다른 곳에 저장해야 할 정보(작성자, 수정일, SPR 번호 등 메타 정보 제외)는 주석으로 적합하지 않습니다. 주석은 코드와 설계에 대한 기술적 설명을 보충하는 역할입니다. 변경 이력이나 장황한 날짜 기록은 피해야 합니다.
  • 쓸모 없는 주석: 오래되거나, 내용과 맞지 않거나, 잘못된 정보는 더 이상 쓸모가 없습니다. 주석은 시간이 지나면 낡기 쉽습니다. 따라서 쓸모없어질 주석은 처음부터 달지 않는 것이 좋고, 이미 있다면 빨리 삭제하는 것이 좋습니다. 이런 주석은 코드와 동떨어져 잘못된 이해를 유발할 수 있습니다.
  • 중복된 주석: 코드만으로도 충분히 이해 가능한 내용을 반복 설명하는 주석입니다 (i++; // i 변수 증가 등). 함수 시그니처만 반복하는 Javadoc 주석도 해당합니다. 주석은 코드가 전달하지 못하는 부가 설명을 제공해야 합니다.
    /**
    * @param sellRequest
    * @return
    * @throws ManagedComponentException
    */
    public SellResponse beginSellItem(SellRequest sellRequest) throws ManagedComponentException
    
  • 성의 없는 주석: 작성할 가치가 있는 주석이라면, 정성 들여 명확하게 작성해야 합니다. 신중하게 단어를 선택하고, 문법과 구두점을 올바르게 사용하며, 군더더기 없이 간결해야 합니다. 당연한 내용을 반복하지 않습니다.
  • 주석 처리된 코드: 읽기 흐름을 방해하고 혼란을 야기합니다. 오래된 코드인지, 중요한 코드인지 알 수 없지만, 삭제되지 않고 남아 코드를 오염시킵니다. 주석 처리된 코드는 발견 즉시 삭제해야 합니다. 소스 코드 관리 시스템이 이전 버전을 기억하므로 걱정할 필요 없습니다. 필요하다면 이전 버전에서 복구할 수 있습니다.

2. 환경 (Environment)
  • 여러 단계로 빌드해야 한다 (E1): 빌드는 단 한 번의 명령으로 전체 소스 코드를 체크아웃하고 빌드할 수 있도록 간단해야 합니다. 여러 저장소에서 개별적으로 코드를 받거나, 복잡한 명령어/스크립트를 순차적으로 실행하는 방식은 피해야 합니다.
    svn get mySystem
    cd mySystem
    ant all
    
  • 여러 단계로 테스트해야 한다 (E2): 모든 단위 테스트는 한 번의 명령으로 실행할 수 있어야 합니다. 이는 매우 중요하며, 방법은 빠르고, 쉽고, 명확해야 합니다. IDE의 버튼 클릭 한 번이나, 최소한 셸 명령어 하나로 모든 테스트를 실행할 수 있어야 이상적입니다.

3. 함수 (Functions)
  • 너무 많은 인수 (F1): 함수의 인수는 적을수록 좋습니다. 0개가 가장 좋고, 1~3개까지는 괜찮습니다. 4개 이상은 사용을 최대한 피해야 합니다. 그 가치가 의심스럽습니다.
  • 출력 인수 (F2): 인수는 보통 입력으로 간주되므로, 출력 인수는 직관에 어긋납니다. 함수의 결과로 상태 변경이 필요하다면, 함수가 속한 객체의 상태를 변경하는 방식을 사용해야 합니다.
  • 플래그 인수 (F3): boolean 플래그 인수는 함수가 여러 기능을 수행한다는 증거이므로 혼란을 야기합니다. 사용하지 않는 것이 좋습니다.
  • 죽은 함수 (F4): 프로그램 내에서 호출되지 않는 함수는 과감히 삭제해야 합니다. 죽은 코드는 불필요한 낭비이며, 소스 코드 관리 시스템이 기록을 관리하므로 걱정할 필요 없습니다.

4. 일반 (General)
  • 한 소스 파일에 여러 언어를 사용한다 (G1): 이상적으로는 소스 파일 하나에 하나의 언어만 사용하는 것이 좋습니다. 현실적으로 여러 언어(XML, HTML, YAML, Javadoc 등)가 혼용될 수 있지만, 그 수와 범위를 최소화하도록 노력해야 합니다. 여러 언어가 섞이면 혼란스럽고 조잡해 보일 수 있습니다.
  • 당연한 동작을 구현하지 않는다 (G2): 최소 놀람의 원칙(The Principle of Least Surprise)에 따라, 함수나 클래스는 사용자가 기대하는 대로 동작해야 합니다. 당연한 동작이 구현되지 않으면, 사용자는 함수 이름을 신뢰할 수 없어 코드를 일일이 확인하게 됩니다. 예를 들어, DayDate.StringToDay("Monday")Day.MONDAY를 반환하고, 일반적인 약어("Mon")나 대소문자 구분 없이 동작하리라 기대합니다.
  • 경계를 올바로 처리하지 않는다 (G3): 직관에만 의존하지 말고, 모든 경계 조건을 찾아 테스트 케이스를 작성해야 합니다. 경계 조건, 예외 상황 등은 알고리즘 실패의 주요 원인이 됩니다. 부지런히 모든 경우를 고려하고 증명해야 합니다.
  • 안전 절차 무시 (G4): 컴파일러 경고를 끄거나 실패하는 테스트를 무시하는 것은 위험합니다. 안전 절차를 무시하면(예: 체르노빌 사고) 심각한 문제를 초래할 수 있습니다. serialVersionUID 직접 제어 등은 위험성을 인지하고 신중해야 합니다.
  • 중복 (G5): 코드 중복은 추상화의 기회입니다. 중복된 코드는 하위 루틴이나 별도 클래스로 분리하여 DRY(Don't Repeat Yourself) 원칙을 지켜야 합니다. 켄트 벡은 이를 "한 번, 단 한 번만(Once, and only once)" 규칙으로 강조했습니다.
    • 똑같은 코드 반복: 간단한 함수로 교체합니다.
    • switch/case 또는 if/else 반복: 다형성으로 대체합니다.
    • 유사한 알고리즘, 다른 코드: Template Method 패턴이나 Strategy 패턴으로 제거합니다. 많은 디자인 패턴은 중복 제거를 목표로 합니다. 중복을 발견하면 반드시 제거해야 합니다.
  • 추상화 수준이 올바르지 못하다 (G6): 추상화는 저수준 상세 개념과 고수준 일반 개념을 분리하는 것입니다. 기초 클래스에는 고수준 개념만, 파생 클래스에는 저수준 구현 세부사항(상수, 변수, 유틸리티 함수 등)을 두어야 합니다. 기초 클래스는 구현 정보에 의존하지 않아야 합니다. 고수준과 저수준 개념을 섞으면 안 됩니다.
    public interface Stack {
        Object pop() throws EmptyException;
        void push(Object o) throws FullException;
        double percentFull(); // 추상화 수준이 맞지 않음. BoundedStack 같은 파생 인터페이스에 있어야 함.
        class EmptyException extends Exception {}
        class FullException extends Exception {}
    }
    
  • 기초 클래스가 파생 클래스에 의존한다 (G7): 기초 클래스는 파생 클래스를 몰라야 독립성이 보장됩니다. 일반적으로 기초 클래스가 파생 클래스를 직접 사용하는 것은 문제가 있다는 신호입니다. 예외적으로 파생 클래스 수가 고정된 FSM(Finite State Machine) 구현 등에서는 기초 클래스가 파생 클래스를 선택하는 경우가 있지만, 이는 매우 밀접하게 연관된 경우입니다.
  • 과도한 정보 (G8): 잘 설계된 모듈은 작고 명확한 인터페이스를 가집니다. 인터페이스에 불필요하게 많은 함수를 노출하지 않아야 결합도(coupling)가 낮아집니다. 클래스의 메서드, 함수가 아는 변수, 클래스 인스턴스 변수의 수는 적을수록 좋습니다. 정보(자료, 유틸리티 함수, 상수 등)를 최대한 숨겨 인터페이스를 작고 엄격하게 관리해야 합니다.
  • 죽은 코드 (G9): 실행되지 않는 코드(불가능한 조건의 if문, throw 없는 trycatch 블록, 호출되지 않는 함수 등)는 즉시 시스템에서 제거해야 합니다. 죽은 코드는 시간이 지남에 따라 유지보수를 어렵게 만들고 오류의 원인이 될 수 있습니다.
  • 수직 분리 (G10): 변수와 함수는 사용되는 위치에 최대한 가깝게 정의해야 합니다. 지역 변수는 첫 사용 직전에 선언하고, 비공개(private) 함수는 첫 호출 직후에 정의하여 코드 가독성을 높입니다.
  • 일관성 부족 (G11): 유사한 개념은 일관된 방식으로 구현해야 합니다. 특정 변수명(response)이나 함수 작명 규칙(processVerificationRequest)을 정했다면, 시스템 전반에 걸쳐 동일하게 적용해야 합니다. 이는 최소 놀람의 원칙에도 부합하며, 다른 개발자들이 코드를 이해하고 유지보수하기 쉽게 만듭니다. 일관성 있는 코드는 그 자체로 규칙이 되어 유지됩니다.
  • 잡동사니 (G12): 사용되지 않는 변수, 호출되지 않는 함수, 정보 없는 주석 등은 코드를 복잡하게만 만듭니다. 비어있는 기본 생성자처럼 불필요한 요소는 제거하여 소스 파일을 항상 깔끔하게 유지해야 합니다.
  • 인위적 결합 (G13): 서로 관련 없는 개념들을 불필요하게 같은 곳에 배치하지 말아야 합니다. enum처럼 특정 클래스에 속할 이유가 없는 요소를 무심코 특정 클래스 안에 넣으면, 해당 enum을 사용하는 코드가 불필요하게 그 클래스를 알아야 하는 의존성이 생깁니다. 함수, 상수, 변수를 선언할 때는 항상 올바른 위치를 고민해야 합니다.
  • 기능 욕심 (G14): 클래스 메서드는 자신의 클래스 멤버(변수, 함수)에 주로 관심을 가져야 합니다. 다른 클래스의 내부 정보를 가져와(getter/setter 사용) 그 객체를 직접 조작하는 것은 '기능 욕심'이며, 클래스 간 결합도를 높입니다. 가능하면 피하는 것이 좋지만, 때로는 불가피한 경우도 있습니다(예: 보고서 클래스가 데이터 객체 정보를 가져와 보고서 형식으로 만드는 경우).
    // 기능 욕심 예시: calculateWeeklyPay 메서드가 HourlyEmployee 객체 정보를 과도하게 사용
    public class HourlyPayCalculator {
        public Money calculateWeeklyPay(HourlyEmployee e) {
            int tenthRate = e.getTenthRate().getPennies();
            int tenthsWorked = e.getTenthsWorked();
            // ... 계산 로직 ...
            return new Money(straightPay + overtimePay);
        }
    }
    
    // 허용될 수 있는 예외: reportHours 메서드가 HourlyEmployee 정보를 사용하지만,
    // 보고서 형식을 HourlyEmployee 클래스가 알 필요는 없음
    public class HourlyEmployeeReport {
        private HourlyEmployee employee;
        // ...
        String reportHours() {
            return String.format(
                "Name: %s\tHours:%d.%1d\n",
                employee.getName(), employee.getTenthsWorked()/10, employee.getTenthsWorked()%10
            );
        }
    }
    
  • 선택자 인수 (G15): 함수의 동작을 제어하기 위해 boolean, enum, int 등의 인수를 사용하는 것은 좋지 않습니다. 이는 함수가 여러 기능을 하나로 합쳐 놓았다는 증거이며, 코드를 이해하기 어렵게 만듭니다. 인수로 동작을 선택하는 대신, 각 동작에 맞는 별도의 함수를 만드는 것이 좋습니다.
    // 나쁜 예: boolean 인수로 초과 근무 수당 계산 여부 결정
    public int calculateWeeklyPay(boolean overtime) { ... }
    
    // 좋은 예: 각 기능별 함수로 분리
    public int straightPay() { ... }
    public int overTimePay() { ... }
    
  • 모호한 의도 (G16): 코드는 의도를 명확히 드러내야 합니다. 한 줄에 길게 쓴 수식, 의미 없는 약어나 헝가리식 표기법, 매직 넘버 등은 코드의 의도를 파악하기 어렵게 만듭니다.
    // 나쁜 예: 변수 이름이 모호하고 수식이 한 줄로 길게 작성됨
    public int m_otCalc() {
        return iThsWkd * iThsRte + (int) Math.round(0.5 * iThsRte * Math.max(0, iThsWkd - 400));
    }
    
  • 잘못 지운 책임 (G17): 코드는 가장 적절하고 직관적인 위치에 배치되어야 합니다. 단순히 개발자에게 편한 위치가 아니라, 해당 기능의 책임 소재를 고려해야 합니다. 예를 들어, 총 근무 시간을 계산하는 로직은 보고서 출력 함수보다는 근무 시간 입력 처리 부분에 두는 것이 더 적절할 수 있으며, 함수 이름(computeRunningTotalOfHours)에 이를 반영해야 합니다. 상수의 위치 등도 기능적 책임에 맞게 결정해야 합니다.
  • 부적절한 static 함수 (G18): 재정의(override)될 가능성이 있거나, 특정 객체의 상태와 관련된 함수는 static으로 선언하면 안 됩니다. Math.max()처럼 특정 인스턴스와 무관하고 재정의 가능성이 없는 함수는 static이 적합합니다. 하지만 특정 계산 로직(예: HourlyPayCalculator.calculatePay)처럼 다양한 계산 방식(일반, 초과근무 등)으로 확장될 수 있다면, 인스턴스 메서드로 만드는 것이 좋습니다. 일반적으로 인스턴스 함수가 static 함수보다 선호됩니다.
  • 서술적 변수 (G19): 복잡한 계산은 중간 결과를 담는 서술적인 이름의 변수를 사용하여 여러 단계로 나누는 것이 좋습니다. 이는 코드의 가독성을 크게 향상시키는 효과적인 방법입니다. 서술적인 변수 이름은 많을수록 좋습니다.
    // 좋은 예: key, value 변수를 사용해 의도를 명확히 함
    Matcher match = headerPattern.matcher(line);
    if (match.find()) {
        String key = match.group(1);
        String value = match.group(2);
        headers.put(key.toLowerCase(), value);
    }
    
  • 이름과 기능이 일치하는 함수 (G20): 함수 이름은 실제 기능을 명확하게 반영해야 합니다. 이름만 보고 기능을 오해할 수 있다면(예: date.add(5)가 5일, 5주, 5시간 중 무엇을 더하는지, 원본 객체를 변경하는지 새 객체를 반환하는지 불명확), 더 좋은 이름으로 바꾸거나 기능을 명확하게 분리해야 합니다. (예: addDaysTo, increaseByDays, daysLater, daysSince)
  • 알고리즘을 이해하라 (G21): 코드를 작성하고 테스트를 통과시키는 것만으로는 부족합니다. 작성한 알고리즘이 왜 올바른지 스스로 확실히 이해해야 합니다. 이를 위해 함수를 최대한 깔끔하고 명확하게 재구성하여 기능이 명백히 드러나도록 하는 것이 좋습니다. 알고리즘을 제대로 이해하지 못한 상태에서 작성된 코드는 종종 문제가 됩니다.
  • 논리적 의존성은 물리적으로 드러내라 (G22): 한 모듈이 다른 모듈에 논리적으로 의존한다면(예: 특정 상수를 다른 클래스가 알고 있다고 가정), 이를 명시적인 방법 호출 등으로 물리적 의존성으로 바꿔야 합니다. 의존하는 정보를 명시적으로 요청하는 것이 좋습니다. 예를 들어, HourlyReporter가 페이지 크기(PAGE_SIZE)를 알아야 한다면, 해당 정보를 가진 HourlyReportFormattergetMaxPageSize() 메서드를 만들어 호출하는 방식이 좋습니다.
  • If/Else 혹은 Switch/Case 문보다 다형성을 사용하라 (G23): switch문은 조건에 따라 다른 동작을 수행할 때 손쉬운 방법이지만, 새로운 유형이 추가될 때마다 수정이 필요합니다(OCP 위반). 특히 동일한 조건을 확인하는 switch문이 여러 곳에 있다면 다형성을 활용하여 대체하는 것을 우선 고려해야 합니다. 객체 유형에 따라 동작이 달라지는 경우, 다형성이 더 유연하고 확장 가능한 해결책입니다.
  • 표준 표기법을 따르라 (G24): 팀 내에서 합의된 **구현 표준(코딩 컨벤션)**을 정의하고 일관성 있게 따라야 합니다. 변수/클래스/메서드 이름 규칙, 괄호 위치 등 세부적인 스타일은 중요하지 않지만, 모두가 동일한 표준을 따르는 것이 중요합니다. 이는 코드의 일관성을 유지하고 가독성을 높입니다. 표준은 별도 문서보다는 코드 자체로 충분히 설명될 수 있어야 합니다.
  • 매직 숫자는 명명된 상수로 교체하라 (G25): 코드 내에 의미를 알 수 없는 숫자(매직 넘버)나 문자열 토큰을 직접 사용하지 말고, 의미 있는 이름의 상수로 대체해야 합니다. 단, Math.PIWORK_HOURS_PER_DAY = 8처럼 의미가 명확하고 자명한 경우는 예외적으로 숫자 리터럴을 사용해도 괜찮습니다. 중요한 것은 의미가 불분명한 값을 상수로 만들어 가독성을 높이는 것입니다.
    // 애매한 경우: 상수가 꼭 필요한가?
    double milesWalked = feetWalked / 5280.0; // FEET_PER_MILE
    int dailyPay = hourlyRate * 8;          // WORK_HOURS_PER_DAY
    double circumference = radius * Math.PI * 2; // TWO?
    
    // 명확히 필요한 경우: 7777의 의미가 불분명
    assertEquals(7777, Employee.find("john Doe").employeeNumber());
    // 상수로 변경
    assertEquals(HOURLY_EMPLOYEE_ID, Employee.find("john Doe").employeeNumber());
    
  • 정확하라 (G26): 코드 내에서의 모든 결정(변수 타입, 알고리즘 선택, 예외 처리 방식 등)은 명확한 이유와 근거를 가지고 정확하게 내려져야 합니다. 모호함이나 부정확함은 의견 충돌이나 단순히 게으름의 결과일 수 있으며, 이는 제거해야 할 대상입니다.
  • 관례보다 구조를 사용하라 (G27): 설계상의 제약을 강제할 때는 단순한 명명 관례보다는 구조적인 방법(예: 추상 메서드 강제)을 사용하는 것이 더 강력합니다. switch/case 문을 올바르게 사용하도록 관례를 정하는 것보다, 추상 메서드를 포함한 기초 클래스를 만들어 파생 클래스가 반드시 해당 메서드를 구현하도록 강제하는 것이 더 효과적입니다.
  • 조건을 캡슐화하라 (G28): 복잡한 조건 논리는 의도를 명확히 드러내는 함수로 캡슐화하는 것이 좋습니다. boolean 논리를 그대로 노출하는 것보다 이해하기 쉽습니다.
    // 좋은 예: 조건의 의도를 함수 이름으로 표현
    if (shouldBeDeleted(timer))
    
    // 나쁜 예: 조건 논리가 그대로 노출됨
    if (timer.hasExpired() && !timer.isReccurent())
    
  • 부정 조건은 피하라 (G29): 부정 조건(!)은 긍정 조건보다 이해하기 어렵습니다. 가능하다면 긍정 조건으로 표현하는 것이 좋습니다.
    // 좋은 예: 긍정 조건 사용
    if (buffer.shouldCompact())
    
    // 나쁜 예: 부정 조건 사용
    if (!buffer.shouldCompact())
    
  • 함수는 한 가지만 해야 한다 (G30): 함수는 단 하나의 명확한 책임만 가져야 합니다. 여러 단계의 작업을 한 함수 내에 순차적으로 나열하는 대신, 각 단계를 별도의 작은 함수로 분리해야 합니다. 이는 함수의 재사용성을 높이고 이해하기 쉽게 만듭니다. (Single Responsibility Principle)
    // 나쁜 예: 여러 작업을 한 함수에서 처리
    public void pay() {
        for (Employee e: employees) { // 1. 직원 목록 순회
            if (e.isPayday()) {      // 2. 월급 지급일 확인
                Money pay = e.calculatePay();
                e.deliverPay(pay);   // 3. 월급 계산 및 지급
            }
        }
    }
    
    // 좋은 예: 각 작업을 별도 함수로 분리
    public void pay() {
        for (Employee e: employees) {
            payIfNecessary(e);
        }
    }
    private void payIfNecessary(Employee e) {
        if (e.isPayday()) {
            calculateAndDeliverPay(e);
        }
    }
    private void calculateAndDeliverPay(Employee e) {
        Money pay = e.calculatePay();
        e.deliverPay(pay); // deliverPay 인자 수정 필요해 보임 (원본 예시 따름)
    }
    
  • 숨겨진 시간적인 결합 (G31): 함수 호출 순서가 중요한 경우(시간적 결합), 이를 숨기지 말고 명확하게 드러내야 합니다. 한 함수의 결과를 다음 함수의 인수로 전달하는 방식으로 순서를 강제할 수 있습니다.
    // 나쁜 예: 호출 순서가 중요하지만 숨겨져 있음 (오류 발생 가능)
    public class MoogDiver {
        Gradient gradient; List splines;
        public void dive(String reason) {
            saturateGradient();    // 먼저 호출되어야 함
            reticulateSplines(); // 다음에 호출되어야 함
            diveForMoog(reason);
        }
    }
    
    // 좋은 예: 함수의 반환값을 다음 함수의 인수로 사용하여 시간적 결합을 명시적으로 만듦
    public class MoogDiver {
        Gradient gradient; List splines;
        public void dive(String reason) {
            Gradient gradient = saturateGradient();
            List splines = reticulateSplines(gradient); // gradient 결과 필요
            diveForMoog(splines, reason);           // splines 결과 필요
        }
    }
    
  • 일관성을 유지해라 (G32): 코드 구조를 설계할 때는 명확한 이유를 가지고 일관성 있게 만들어야 합니다. 시스템 전반에 걸쳐 일관된 구조와 스타일을 유지하면, 다른 개발자들이 그 규칙을 따르고 코드를 예측 가능하게 유지하는 데 도움이 됩니다. (G11과 유사)
  • 경계 조건을 캡슐화하라 (G33): 반복적으로 사용되는 경계 조건 로직은 별도의 변수나 함수로 캡슐화하여 한 곳에서 관리하는 것이 좋습니다. 이렇게 하면 실수를 줄이고 코드를 명확하게 만들 수 있습니다.
    // 나쁜 예: level + 1 이 반복 사용됨
    if (level + 1 < tags.length) {
        parts = new Parse(body, tags, level + 1, offset + endTag);
        body = null;
    }
    
    // 좋은 예: 경계 조건(level + 1)을 변수로 캡슐화
    int nextLevel = level + 1;
    if (nextLevel < tags.length) {
        parts = new Parse(body, tags, nextLevel, offset + endTag);
        body = null;
    }
    
  • 함수는 추상화 수준을 한 단계만 내려가야 한다 (G34): 함수 내의 모든 문장은 동일한 추상화 수준을 가져야 하며, 이는 함수 이름이 나타내는 작업보다 단 한 단계 낮은 수준이어야 합니다. 고수준 개념과 저수준 세부사항이 한 함수 안에 섞여 있으면 안 됩니다.
  • 설정 정보는 최상위 단계에 둬라 (G35): 기본값이나 설정 관련 상수 등 시스템의 고수준 정책과 관련된 정보는 저수준 함수 내에 숨기지 말고, 시스템의 최상위 추상화 단계에 배치해야 합니다. 필요하다면 고수준 함수에서 저수준 함수로 인수를 통해 전달합니다.
  • 추이적 탐색을 피하라 (G36): 한 모듈은 자신이 직접 상호작용하는 모듈만 알아야 합니다. A가 B를 사용하고 B가 C를 사용할 때, A가 C를 직접 알아서는 안 됩니다 (a.getB().getC().doSomething() 같은 형태는 피해야 함). 이를 디미터 법칙(Law of Demeter) 또는 "부끄럼 타는 코드 작성(Write Shy Code)"이라고 합니다. myCollaborator.doSomething()처럼 직접적인 협력자에게만 메시지를 보내야 합니다.

5. 자바 (Java)
  • 긴 import 목록을 피하고 와일드카드를 사용하라 (J1): 한 패키지에서 여러 클래스를 사용할 경우, 개별적으로 import하는 대신 와일드카드(import package.*;)를 사용하라고 저자는 권장합니다. 명시적 import는 강한 의존성을 만들지만, 와일드카드는 특정 클래스의 존재 유무와 무관하여 결합도를 낮춘다고 주장합니다. 이름 충돌 가능성이라는 단점에도 불구하고, 긴 import 목록보다 와일드카드가 낫다고 봅니다. (현대 IDE의 도움과 명시성의 장점을 고려할 때 논란의 여지가 있는 조언입니다.)
  • 상수는 상속하지 않는다 (J2): 상수를 인터페이스에 정의하고 이를 implements하여 사용하는 것은 상속 메커니즘의 오용입니다. 상수 사용을 위해 상속 계층을 이용하지 말고, static import를 사용하는 것이 올바른 방법입니다.
    // 나쁜 예: PayrollConstants 인터페이스를 상속하여 상수 사용
    public interface PayrollConstants { /* 상수 정의 */ }
    public abstract class Employee implements PayrollConstants { ... }
    public class HourlyEmployee extends Employee {
        // TENTHS_PER_WEEK, OVERTIME_RATE 상수를 상속받아 사용
    }
    
    // 좋은 예: static import 사용
    import static PayrollConstants.*;
    public class HourlyEmployee extends Employee {
        // TENTHS_PER_WEEK, OVERTIME_RATE 상수를 static import로 사용
    }
    
  • 상수 대 Enum (J3): 자바 5 이후로는 public static final int 형태의 상수 대신 enum을 사용하는 것이 훨씬 좋습니다. enum은 타입 안전성을 보장하고, 메서드나 필드를 추가할 수 있어 더 유연하고 서술적인 표현이 가능합니다.
    // 좋은 예: enum을 활용한 유연하고 서술적인 상수 정의
    public enum HourlyPayGrade {
        APPRENTICE { public double rate() { return 1.0; } },
        LIEUTENANT_JOURNEYMAN { public double rate() { return 1.2; } },
        JOURNEYMAN { public double rate() { return 1.5; } },
        MASTER { public double rate() { return 2.0; } };
    
        public abstract double rate(); // enum에 메서드 정의 가능
    }
    

6. 이름 (Names)
  • 서술적인 이름을 사용하라 (N1): 이름은 소프트웨어 가독성의 90%를 결정하므로 매우 중요합니다. 성급하게 짓지 말고, 기능을 명확하게 서술하는 이름을 신중하게 선택해야 합니다. 소프트웨어가 진화함에 따라 이름이 현재 기능을 잘 반영하는지 지속적으로 검토하고 개선해야 합니다.
  • 적절한 추상화 수준에서 이름을 선택하라 (N2): 이름은 구현 세부사항을 드러내지 않고, 해당 변수나 함수가 존재하는 추상화 수준을 반영해야 합니다. 코드를 검토할 때 추상화 수준에 맞지 않는 이름을 발견하면 개선해야 합니다.
  • 가능하다면 표준 명명법을 사용하라 (N3): 디자인 패턴(예: Decorator 패턴 클래스 이름에 Decorator 사용), 플랫폼 관례(예: 자바의 toString()), 프로젝트 내에서 합의된 용어(유비쿼터스 언어) 등 널리 알려진 표준 명명법을 따르면 코드를 이해하기 쉬워집니다.
  • 명확한 이름 (N4): 함수나 변수의 목적을 명확히 드러내는 이름을 선택해야 합니다. 이름이 길어지더라도 명확성과 서술성이 더 중요할 수 있습니다. 모호한 이름(예: doRename, renamePage처럼 차이가 불분명한 경우)보다는 renamePageAndOptionallyAllReferences처럼 길더라도 정확한 이름이 나을 수 있습니다.
  • 긴 범위는 긴 이름을 사용하라 (N5): 변수나 함수의 이름 길이는 그것이 사용되는 범위(scope)의 길이에 비례해야 합니다. 범위가 매우 작다면(예: 5줄 내외의 루프 변수 i) 짧은 이름도 괜찮지만, 범위가 길어지면 의미를 명확히 전달하는 긴 이름을 사용해야 혼동을 줄일 수 있습니다.
  • 인코딩을 피하라 (N6): 변수 이름에 타입 정보(헝가리안 표기법)나 범위 정보(접두어 m_, f_ 등)를 포함시키지 마십시오. 현대 개발 환경은 이런 정보를 쉽게 제공하며, 불필요한 인코딩은 가독성을 해칩니다.
  • 이름으로 부수 효과를 설명하라 (N7): 함수 이름은 해당 함수가 수행하는 모든 작업을 설명해야 합니다. 단순히 주된 기능만 나타내는 동사 하나만 사용하고 부수 효과(side effect)를 숨기면 안 됩니다. 예를 들어, 객체를 반환하면서 필요시 생성까지 하는 함수라면 getOos()보다는 createOrReturnOos() 같은 이름이 더 적절합니다.
    // 부수 효과가 숨겨진 이름
    public ObjectOutputStream getOos() throws IOException {
        if (m_oos == null) {
            // 실제로는 객체 생성이라는 부수 효과가 있음
            m_oos = new ObjectOutputStream(m_socket.getOutputStream());
        }
        return m_oos;
    }
    

7. 테스트 (Tests)
  • 불충분한 테스트 (T1): 테스트 케이스는 잠재적으로 오류가 발생할 수 있는 모든 부분을 검증해야 합니다. 확인되지 않은 조건이나 계산이 있다면 테스트는 불완전합니다. 모든 중요한 경로와 경계 값을 포함하도록 충분히 작성해야 합니다.
  • 커버리지 도구를 사용하라! (T2): 테스트 커버리지 도구는 테스트가 누락된 코드 영역(모듈, 클래스, 함수)을 시각적으로 보여주어 테스트의 불충분한 부분을 쉽게 식별하도록 도와줍니다.
  • 사소한 테스트를 건너뛰지 마라 (T3): 간단하고 명백해 보이는 기능이라도 테스트를 작성하는 것이 좋습니다. 사소한 테스트라도 작성하는 비용 대비 문서로서의 가치와 잠재적 오류 방지 효과가 더 큽니다.
  • 무시한 테스트는 모호함을 뜻한다 (T4): @Ignore 처리되거나 주석 처리된 테스트는 요구사항이 불분명하거나 결정되지 않았음을 나타낼 수 있습니다. 이는 해당 부분의 기능 구현에 대한 확신 부족을 의미하므로 주의 깊게 관리해야 합니다.
  • 경계 조건을 테스트하라 (T5): **알고리즘의 경계(boundary)**에서 오류가 발생하기 쉽습니다. 0, null, 빈 값, 최소/최대값, 리스트의 첫/마지막 요소 등 경계 조건은 특별히 신경 써서 철저히 테스트해야 합니다.
  • 버그 주변은 철저히 테스트하라 (T6): 버그는 종종 특정 영역에 모여서 발견되는 경향이 있습니다. 한 함수에서 버그가 발견되면, 그 함수와 관련된 다른 부분들도 더욱 철저하게 테스트하여 숨어있는 다른 버그들을 찾아내는 것이 좋습니다.
  • 실패 패턴을 살펴라 (T7): 테스트 케이스가 실패하는 패턴을 분석하면 문제의 원인을 진단하는 데 도움이 될 수 있습니다. (예: 특정 크기 이상의 입력에서만 실패, 특정 인자 값에서만 실패 등). 꼼꼼하게 작성되고 합리적으로 정렬된 테스트는 이러한 패턴을 파악하기 쉽게 만듭니다.
  • 테스트 커버리지 패턴을 살펴라 (T8): 실패하는 테스트 케이스와 통과하는 테스트 케이스가 각각 실행하는 코드(커버리지)를 비교 분석하면, 실패의 원인이 되는 특정 코드 경로를 파악하는 데 도움이 될 수 있습니다.
  • 테스트는 빨라야 한다 (T9): 테스트 실행 속도가 느리면 개발자들이 테스트 실행을 꺼리게 됩니다. 특히 시간이 촉박할 때 느린 테스트는 건너뛰기 쉽습니다. 따라서 테스트 케이스는 최대한 빠르게 실행되도록 노력해야 합니다.

2. 🤔 이해가 어려운 부분

🔍 질문하기

1. 기초 클래스가 파생 클래스에 의존한다.
  • 어려웠던 부분: FSM이 왜 기초 클래스에서 파생 클래스를 선택하는 예외적 사례인지를 이해하기 어려웠다.
  • 이해한 점: FSM이란 유한 상태 기계(Finite State Machine) 라는 뜻으로 상태(State)와 입력(Input)에 따라 다음 상태로 전이된다. 게임에서 몬스터 AI, 캐릭터 상태 관리(Idle, Walk, Jump, Attack 등) 등에 자주 사용된다. FSM은 각 상태(파생 클래스) 끼리 다음 상태를 직접 선택해야 하기 때문에 이런 의존이 허용된다.
    • 예시 코드 (C#):
      // 상태 기초 클래스
      public abstract class State
      {
          public abstract void Enter();
          public abstract void Update();
          public abstract void Exit();
      }
      // Idle 상태
      public class IdleState : State
      {
          public override void Enter() { }
          public override void Update() { }
          public override void Exit() { }
      }
      // Run 상태
      public class RunState : State
      {
          public override void Enter() { }
          public override void Update() { }
          public override void Exit() { }
      }
      // Player 클래스에서 상태 전이
      public class Player : MonoBehaviour
      {
          private State currentState;
          void Start()
          {
              currentState = new IdleState(); // 기초 클래스가 파생 클래스에 의존하게 됨.
              currentState.Enter();
          }
          void Update()
          {
              currentState.Update();
              // 예시: 오른쪽 키 누르면 Run 상태로 전환
              if (Input.GetKeyDown(KeyCode.RightArrow))
              {
                  currentState.Exit();
                  currentState = new RunState(); // 기초 클래스가 파생 클래스에 의존하게 됨.
                  currentState.Enter();
              }
          }
      }
      

2. 긴 import 목록을 피하고 와일드카드를 사용하라
  • 어려웠던 부분: 저자가 얘기한 긴 import 목록을 피하고 와일드카드를 사용하라는 내용을 동의하기 어려웠다.
  • 궁금한 점: 요즘 IDE는 import문 최적화같은 기능을 제공하는데 꼭 와일드카드로 작성해야 할까?.

3. FSM (Finite State Machine, 유한 상태 머신)
  • 어려웠던 부분: FSM이 무엇인지 이해하지 못햇다.
  • FSM이란?
    • 상태(State)라는 게 몇 개 정해져 있다.
    • 이벤트(Event)가 발생하면, 현재 상태에서 다른 상태로 전이(Transition) 할 수 있다.
    • 이 모든 것은 미리 정의된 규칙에 따라 움직인다. 어떤 시점에도 시스템은 오직 한 상태만 가질 수 있다.
    • FSM(Finite State Machine, 유한 상태 머신)은 이름처럼 한정된 수의 상태를 가지는 시스템이다.
      → '현재 상태' + '이벤트' 조합으로 다음 상태가 결정된다!.
  • FSM의 구성 요소:
    • 상태 (States): 시스템이 존재할 수 있는 여러 가지 상황.
    • 이벤트 (Events): 상태를 바꾸게 만드는 외부/내부 사건.
    • 전이 (Transitions): 상태에서 다른 상태로 넘어가는 규칙.
    • 초기 상태 (Initial State): 시스템이 시작할 때 갖는 상태.
    • (선택) 종료 상태 (Final State): 시스템이 끝났다고 보는 상태.
  • FSM 활용:
    • 게임 캐릭터 AI (예: 공격, 방어, 대기 상태 전환).
    • 버튼 클릭이나 폼 제출 같은 UI 동작 제어.
    • 엘리베이터 제어 시스템.
    • 프로토콜 통신 흐름 제어 (TCP 연결 과정 같은 것).
  • (텍스트 버전):
    [Closed] --(Open Door)--> [Open]
    [Open] --(Close Door)--> [Closed]
    
    (상태) --(이벤트)--> (다음 상태).
  • 요약: FSM은 "현재 상태 + 이벤트" 조합에 따라 "다음 상태"가 명확히 정해진 시스템이다.

3. 📚 참고 사항

📢 논의하기

1. 관련 자료 공유

2. 논의하고 싶은 주제
  • 주제: FSM에서 상태가 고정된 전제를 바탕으로 기초 클래스가 파생 클래스를 참조해도 되는가?.
  • 설명: 상태 종류가 고정된다는 가정하에 기초 클래스가 파생 클래스에 의존하는 구조가 허용되지만 만약 추후 상태가 늘어난다면 OCP를 위반하게 되는 것이 아닌지 논의하고 싶다.