본문으로 건너뛰기

Clean Code

Martin, Robert CISBN 9780132350884

목차165개 항목

1장. 클린 코드

이 책을 읽는 이유는 두 가지다. 첫째, 당신은 프로그래머다. 둘째, 더 나은 프로그래머가 되고 싶다. 좋다. 우리에게는 더 나은 프로그래머가 필요하다.

  • 이 책은 좋은 프로그래밍에 관한 책이며, 코드로 가득 차 있음
  • 코드를 위에서, 아래에서, 안에서 밖으로 다양한 방향으로 살펴봄
  • 목표: 좋은 코드와 나쁜 코드를 구별하고, 좋은 코드를 작성하며, 나쁜 코드를 좋은 코드로 변환하는 방법을 익히는 것

코드는 사라지지 않는다

  • “코드는 곧 사라질 것이다”는 주장에 대한 반박
    • 모델과 요구사항만 다루면 되고 코드는 더 이상 중요하지 않다는 시각이 존재함
    • 비즈니스 담당자가 명세서로 프로그램을 생성할 수 있게 되면 프로그래머가 필요 없어질 것이라는 전망도 있었음
  • 코드는 절대 사라지지 않는다
    • 코드는 요구사항의 세부 내용을 표현하는 언어이기 때문
    • 기계가 실행할 수 있을 만큼 정밀하게 요구사항을 명세하는 행위 자체가 프로그래밍이며, 그 명세가 곧 코드
    • 추상화 수준이 높아지고 도메인 특화 언어(DSL)가 늘어나도, 그 언어로 작성된 명세 자체가 코드임
  • 핵심 인사이트
    • 코드가 사라지길 바라는 사람들은 수학이 형식 없이도 존재하길 바라는 수학자와 같음
    • 인간조차 고객의 모호한 감정에서 성공적인 시스템을 만들어내지 못함
    • 잘 명세된 요구사항은 코드만큼 형식적이며, 그 자체로 코드의 실행 가능한 테스트가 될 수 있음
    • 필요한 정밀성은 절대 제거할 수 없다. 따라서 코드는 항상 존재할 것이다

나쁜 코드

  • 나쁜 코드가 왜 중요한가
    • Kent Beck의 책 전제인 “좋은 코드는 중요하다”는 주장은 취약한 전제가 아니라, 우리 직종에서 가장 강력하고 지지받는 전제 중 하나
    • 우리는 좋은 코드가 중요하다는 것을 오랫동안 그 부재로 인해 겪으며 알게 되었음
  • 나쁜 코드로 인한 회사 파멸 사례
    • 1980년대 후반, 한 회사가 킬러 앱을 개발해 큰 인기를 끌었음
    • 이후 릴리즈 주기가 늘어나고, 버그가 수정되지 않으며, 로드 시간이 길어지고 크래시가 증가
    • 결국 회사는 폐업했고, 원인은 시장 출시를 서두르다 코드를 엉망으로 만들었기 때문
    • 기능을 추가할수록 코드는 점점 더 나빠져 더 이상 관리할 수 없게 됨
  • Wading(허우적거림)
    • 나쁜 코드를 헤쳐나가는 것을 “wading”이라 부름
    • 뒤엉킨 수풀과 숨겨진 함정을 헤쳐나가며 방향을 찾으려 애쓰는 것과 같음
  • 나쁜 코드를 작성하는 이유
    • 빠르게 처리하려는 조급함
    • 상사의 눈치
    • 프로그램 작업에 지쳐서
    • 다른 할 일이 밀려 있어서
    • LeBlanc의 법칙: 나중은 결코 오지 않는다 (Later equals never)

엉망인 코드의 총비용

  • 엉망인 코드의 총 비용
    • 경력 2~3년 이상이라면 타인의 지저분한 코드 때문에 상당히 지체된 경험이 있음
    • 프로젝트 초반에 빠르게 진행되던 팀이 1~2년 후에는 거의 정지 상태에 빠지는 경우가 흔함
    • 코드 변경 하나가 다른 두세 곳을 망가뜨림 → 사소한 변경이 없어짐
    • 엉킴과 뒤틀림을 이해하지 않고서는 아무것도 추가·수정 불가능
  • 생산성의 점진적 붕괴
graph LR
    A[엉망인 코드 누적] --> B[팀 생산성 감소]
    B --> C[관리자: 인력 추가]
    C --> D[새 인력: 설계 의도 파악 불가]
    D --> E[더 많은 엉망 코드 생성]
    E --> A
  • 생산성은 시간이 지남에 따라 점근적으로 0에 가까워짐
  • 관리자는 인력을 추가하지만, 새 인력은 시스템 설계를 이해하지 못함
  • 결과적으로 더 많은 엉망을 만들어내며 생산성을 더욱 낮춤

하늘 위의 대규모 재설계

  • 대규모 재설계의 함정
    • 결국 팀은 반발하여 전면 재설계를 요구함
    • 관리자는 자원 낭비를 원치 않지만 생산성 붕괴를 부정할 수 없어 결국 승인
  • 타이거 팀(Tiger Team)의 딜레마
    • 새로운 타이거 팀 구성 → 모두가 이 그린필드 프로젝트에 합류하고 싶어 함
    • 이 팀은 기존 시스템이 하는 모든 것을 새 시스템으로 구현해야 함
    • 동시에 기존 시스템에 지속적으로 추가되는 변경 사항도 따라잡아야 함
    • 이 경쟁은 10년씩 이어지기도 함
    • 결말: 타이거 팀의 원년 멤버들이 모두 떠난 뒤, 새 멤버들이 “이 새 시스템도 엉망”이라며 또 재설계를 요구하게 됨
  • 핵심 교훈
    • 코드를 깨끗하게 유지하는 데 시간을 쓰는 것은 비용 효율적인 것을 넘어 전문가적 생존의 문제

태도

  • 코드가 썩는 이유는 우리 자신에게 있다
    • 요구사항 변경, 빡빡한 일정, 어리석은 관리자, 고객을 탓하지만 → 결국 우리가 비전문적으로 행동한 것
    • 관리자와 마케터는 우리에게 정보를 의존함. 우리가 말하지 않으면 그들은 모름
    • 의사가 수술 전 손 씻기를 환자가 “시간 낭비”라고 요청해도 거부해야 하듯이, 프로그래머도 엉망을 만드는 것의 위험성을 이해하지 못하는 관리자의 뜻에 굴복해서는 안 됨
역할 책임
관리자 일정과 요구사항을 방어
프로그래머 코드를 동등한 열정으로 방어

근본적 딜레마

  • 프로그래머가 직면하는 근본적인 딜레마
    • 경험 있는 개발자라면 누구나 알고 있음: 이전의 엉망이 자신을 느리게 만든다
    • 그럼에도 마감을 맞추기 위해 엉망을 만들어야 한다는 압박을 느낌
  • 진짜 전문가의 인식
    • 엉망을 만들어서 마감을 맞출 수 없음
    • 오히려 엉망은 즉시 속도를 늦추고, 마감을 놓치게 만듦
    • 마감을 맞추는 유일한 방법 = 항상 코드를 최대한 깨끗하게 유지하는 것

클린 코드의 예술

  • 클린 코드 작성은 예술이다
    • 그림을 잘 그렸는지 나쁘게 그렸는지 아는 것이 그림을 잘 그릴 수 있음을 의미하지 않음
    • 클린 코드를 식별할 수 있다고 해서 클린 코드를 작성할 수 있다는 의미가 아님
  • code-sense (코드 감각)
    • 클린 코드 작성에는 수많은 작은 기술들의 훈련된 사용과 고통스럽게 터득한 청결함에 대한 감각이 필요
    • 일부는 타고나고, 일부는 싸워서 얻어야 함
    • code-sense가 없는 프로그래머: 엉망을 인식하지만 어떻게 해야 할지 모름
    • code-sense가 있는 프로그래머: 엉망을 보고 옵션과 변형을 발견하며, 동작을 보존하는 일련의 변환 순서를 계획함
    • 클린 코드를 작성하는 프로그래머는 빈 화면을 우아하게 코딩된 시스템으로 변환하는 예술가

클린 코드란 무엇인가

저명한 프로그래머들이 정의한 클린 코드의 의미를 정리하면 다음과 같다.

C++ 창시자(Bjarne Stroustrup)

우아하고 효율적인 코드를 좋아한다. 논리는 버그가 숨기 어렵도록 간단해야 하고, 의존성은 유지보수를 쉽게 하기 위해 최소화해야 하며, 에러 처리는 명확한 전략에 따라 완전해야 하고, 성능은 최적에 가까워야 한다. 클린 코드는 한 가지를 잘 한다.

  • 우아함(Elegant): 읽을 때 기쁨을 주어야 함. 잘 만들어진 뮤직박스나 잘 설계된 차처럼
  • 효율성: 낭비된 사이클은 우아하지 않으며, 나쁜 코드는 엉망이 더 커지도록 유혹함 (broken windows 이론)
  • 완전한 에러 처리: 세부 사항에 주의를 기울이는 규율. 메모리 누수, 경쟁 조건, 일관성 없는 네이밍 모두 세부 사항에 소홀한 증거
  • 단일 집중: 각 함수, 클래스, 모듈은 주변 세부 사항에 흐트러지지 않고 한 가지 태도를 유지해야 함

OOA&D 저자(Grady Booch)

클린 코드는 단순하고 직접적이다. 클린 코드는 잘 쓴 산문처럼 읽힌다. 클린 코드는 설계자의 의도를 절대 숨기지 않으며, 오히려 명확한 추상화와 직선적인 제어 흐름으로 가득 차 있다.

  • 가독성 관점: 좋은 소설을 읽을 때 단어가 사라지고 이미지가 떠오르듯, 클린 코드도 읽으면서 문제의 긴장감이 명확하게 드러나야 함
  • crisp abstraction(명확한 추상화): 코드는 추측이 아닌 사실 위주여야 하며, 필요한 것만 담고 독자가 결단력 있음을 느껴야 함

OTI 창립자(“Big” Dave Thomas)

클린 코드는 원작자 이외의 개발자도 읽고 개선할 수 있다. 단위 테스트와 인수 테스트가 있다. 의미 있는 이름을 가진다. 한 가지를 하는 방법을 여러 개가 아닌 하나만 제공한다. 최소한의 의존성을 가지며, 명확하고 최소한의 API를 제공한다.

  • 읽기 쉬운 코드 vs 변경하기 쉬운 코드의 차이를 강조
  • 테스트와 클린 코드의 연결: 테스트가 없으면 클린 코드가 아님. 아무리 우아하고 읽기 쉬워도 테스트 없이는 불결함
  • 최소성: 작은 코드가 큰 코드보다 낫다
  • 리터러시: Knuth의 리터러트 프로그래밍 참조 — 코드는 인간이 읽을 수 있는 형태로 구성되어야 함

Working Effectively with Legacy Code 저자(Michael Feathers)

클린 코드의 모든 품질을 나열할 수 있지만, 그것들로 이어지는 하나의 핵심 품질이 있다. 클린 코드는 항상 누군가가 신경 쓴 것처럼 보인다. 더 나아지게 할 수 있는 명백한 것이 아무것도 없다.

  • 핵심 단어: Care(배려)
    • 클린 코드는 누군가가 단순하고 질서 있게 유지하기 위해 시간을 쓴 코드
    • 세부 사항에 적절한 주의를 기울인 코드
    • 이 책의 적절한 부제는 “How to Care for Code”

Extreme Programming 저자(Ron Jeffries)

Beck의 단순한 코드 규칙을 우선순위 순서로:

  1. 모든 테스트를 통과한다
  2. 중복이 없다
  3. 시스템의 모든 설계 아이디어를 표현한다
  4. 클래스, 메서드, 함수 등의 엔티티 수를 최소화한다
  • 중복 제거가 핵심
    • 같은 것이 반복된다면, 코드에 잘 표현되지 않은 아이디어가 머릿속에 있다는 신호
    • 그 아이디어를 찾아내고 더 명확하게 표현해야 함
  • 표현력(Expressiveness)
    • 의미 있는 이름 포함
    • 객체나 메서드가 두 가지 이상을 하는지 확인
    • 메서드가 두 가지를 한다면 Extract Method 리팩토링으로 분리
  • 컬렉션 추상화 예시
// 나쁜 예: 구체적인 구현에 직접 의존
const employees = new Map<string, Employee>();
const found = employees.get("john");

// 좋은 예: 추상화로 감싸서 구현 변경의 자유를 얻음
class EmployeeRepository {
  private store = new Map<string, Employee>();

  findById(id: string): Employee | undefined {
    return this.store.get(id);
  }
}
  • Ron의 요약: 중복 감소 + 높은 표현력 + 단순 추상화의 조기 구축 = 클린 코드

Wiki·eXtreme Programming 공동 창시자(Ward Cunningham)

각 루틴을 읽을 때 거의 예상한 대로인 경우 클린 코드에서 작업하고 있다는 것을 알 수 있다. 코드가 마치 그 문제를 위해 언어가 만들어진 것처럼 보일 때 아름다운 코드라고 부를 수 있다.

  • 놀라움이 없는 코드: 읽을 때 노력이 필요 없고, 명백하고 단순하며 설득력 있음
  • 아름다운 코드: 언어가 그 문제를 위해 만들어진 것처럼 보이게 함
    • 언어를 단순하게 만드는 것은 언어 자체가 아니라 프로그래머의 책임

학파와 관점

  • Uncle Bob(저자)의 입장
    • 이 책은 클린 변수명, 클린 함수, 클린 클래스 등에 대해 Object Mentor 학파의 관점을 제시함
    • 이 견해들을 절대적인 것으로 제시하지만, 이것이 절대적으로 옳다는 의미는 아님
  • 무술 학파 비유
    • 다양한 무술 유파(Gracie Jiu Jitsu, Hakkoryu Jiu Jitsu, Jeet Kune Do)가 있듯이, 클린 코드에 대한 다양한 학파가 있음
    • 하나의 학파 내에서 올바른 방법이 있더라도, 그것이 다른 학파를 무효화하지 않음
    • 이 책의 가르침을 따르되, 다른 학파와 대가들로부터도 배울 것을 권장
  • 이 책의 추천 사항들 중 일부는 논쟁의 여지가 있음
    • 모든 것에 동의하지 않아도 됨
    • 하지만 수십 년의 경험과 반복된 시행착오를 통해 터득한 관점을 존중해 달라

우리는 저자다

  • 우리는 저자다
    • Javadoc의 @author 필드가 우리가 누구인지 알려줌
    • 저자에게는 독자가 있으며, 저자는 독자와 잘 소통할 책임이 있음
    • 코드 한 줄을 작성할 때마다 자신의 노력을 평가할 독자를 위해 쓰는 저자임을 기억해야 함
  • 코드는 쓰는 것보다 읽는 시간이 훨씬 많다
    • 코드를 읽는 시간 대 쓰는 시간의 비율: 10:1 이상
    • 새 코드를 작성하는 노력의 일부로 항상 기존 코드를 읽고 있음
  • 읽기 쉬운 코드가 쓰기도 쉽다
    • 주변 코드를 읽지 않고서는 새 코드를 작성할 수 없음
    • 오늘 작성하려는 코드가 어렵거나 쉬운 것은 주변 코드가 읽기 어렵거나 쉬운지에 달려 있음
    • 빠르게 진행하고 싶다면, 읽기 쉽게 만들어라

보이스카우트 규칙

  • 보이스카우트 규칙

캠프장을 발견했을 때보다 더 깨끗하게 떠나라.

  • 코드를 체크아웃할 때보다 조금 더 깨끗하게 체크인하면, 코드는 절대 썩지 않음
  • 청소는 크게 할 필요 없음:
    • 변수명 하나를 더 낫게 바꾸기
    • 너무 큰 함수 하나를 나누기
    • 중복된 코드 조각 하나를 제거하기
    • 복합 if 문 하나를 정리하기
  • 지속적 개선은 프로페셔널리즘의 본질적인 부분

전사와 원칙

  • 이 책은 2002년에 저자가 쓴 Agile Software Development: Principles, Patterns, and Practices(PPP) 의 “프리퀄”
  • PPP는 객체 지향 설계 원칙과 전문 개발자들이 사용하는 실천 방법을 다룸
  • 이 책에서 등장하는 설계 원칙들:
    • SRP (Single Responsibility Principle, 단일 책임 원칙)
    • OCP (Open Closed Principle, 개방 폐쇄 원칙)
    • DIP (Dependency Inversion Principle, 의존성 역전 원칙)

정리

  • 예술 책이 예술가를 만들어주지 않듯, 이 책도 좋은 프로그래머를 만들어주겠다고 약속하지 않음
  • 이 책이 할 수 있는 것: 좋은 프로그래머들의 사고 과정, 기술, 도구를 보여주는 것
  • 결국은 연습뿐이다

“Carnegie Hall에 어떻게 가나요?“라고 물었을 때 노인이 대답했다: “연습, 아들아. 연습!”

핵심 요약

  • 코드의 존재: 코드는 사라지지 않는다. 정밀성을 제거할 수 없기 때문
  • 나쁜 코드의 비용: 생산성을 점근적으로 0으로 낮추며, 회사를 망하게 할 수 있음
  • 책임: 코드 품질에 대한 책임은 관리자가 아닌 프로그래머에게 있음
  • 진짜 속도: 빠르게 가는 유일한 방법은 항상 코드를 깨끗하게 유지하는 것
  • code-sense: 나쁜 코드를 인식하는 것을 넘어 어떻게 고칠지 아는 감각
  • 저자 의식: 코드는 읽는 시간이 쓰는 시간의 10배. 독자를 위해 써라
  • 보이스카우트 규칙: 발견했을 때보다 조금 더 깨끗하게 남겨라

2장. 의미 있는 이름

이름은 소프트웨어 어디에나 있다. 변수, 함수, 인수, 클래스, 패키지, 소스 파일, 디렉터리, jar/war/ear 파일까지. 우리는 끊임없이 이름을 짓는다. 그러니 잘 짓는 편이 낫다.

의도를 드러내는 이름을 사용하라

  • 이름은 존재 이유, 수행 기능, 사용 방법이라는 세 가지 질문에 모두 답해야 함
  • 이름에 주석이 필요하다면, 그 이름은 의도를 드러내지 못하는 것
  • 좋은 이름을 고르는 데는 시간이 걸리지만, 절약되는 시간이 훨씬 더 많음
  • 더 좋은 이름이 떠오르면 언제든 바꿔라. 코드를 읽는 모두가 행복해진다

나쁜 예:

const d: number; // 경과 시간(일 단위)

좋은 예:

const elapsedTimeInDays: number;
const daysSinceCreation: number;
const daysSinceModification: number;
const fileAgeInDays: number;

코드 가독성 비교 — 지뢰찾기 게임 예시:

// 나쁜 예: 암묵적인 코드 (무엇을 하는지 알 수 없음)
function getThem(): number[][] {
  const list1: number[][] = [];
  for (const x of theList) {
    if (x[0] === 4) list1.push(x);
  }
  return list1;
}
// 좋은 예: 의도가 드러나는 코드
function getFlaggedCells(): Cell[] {
  const flaggedCells: Cell[] = [];
  for (const cell of gameBoard) {
    if (cell.isFlagged()) flaggedCells.push(cell);
  }
  return flaggedCells;
}
  • 코드의 복잡성은 그대로지만, 이름만 바꿔도 무엇을 하는지 즉시 이해 가능
  • 핵심: 코드의 단순성이 아닌 암묵성(implicity) 이 문제다. 컨텍스트가 코드 안에 명시되어야 한다

잘못된 정보를 피하라

  • 거짓 단서를 남기지 마라 — 코드의 의미를 흐리는 잘못된 정보는 독자를 혼란에 빠뜨림
  • 플랫폼 이름이나 특수 의미를 가진 단어를 변수명으로 쓰지 말 것 (예: hp, aix, sco)
  • 컨테이너 타입을 이름에 인코딩하지 마라
    • 실제로 List가 아닌데 accountList라고 부르면 잘못된 정보를 제공하는 것
    • accountGroup, bunchOfAccounts, 또는 그냥 accounts가 더 나음
  • 미세하게 다른 이름을 피하라
    • XYZControllerForEfficientHandlingOfStringsXYZControllerForEfficientStorageOfStrings의 차이를 금방 눈치채기 어려움
  • 소문자 l과 대문자 O를 변수명으로 절대 사용하지 마라
    • 각각 숫자 10처럼 보여 극심한 혼란 유발
// 최악의 예: 1과 0인지 l과 O인지 구분이 불가능
let a = l;
if (O == l) a = O1;
else l = 01;

의미 있게 구분하라

  • 컴파일러만 통과시키기 위한 임의적 이름 변경은 금물
    • 이름이 달라야 한다면 의미도 달라야 함
  • 숫자 연속 이름(a1, a2, …aN)은 의미가 없음
    • 비정보적(noninformative)이며 작성자의 의도를 전혀 전달하지 않음
// 나쁜 예
function copyChars(a1: string[], a2: string[]): void { ... }

// 좋은 예
function copyChars(source: string[], destination: string[]): void { ... }
  • 노이즈 워드(noise word)는 의미 없는 구분이다
    • Product, ProductInfo, ProductDataInfoData는 아무 의미도 추가하지 않음
    • a, an, the 같은 접두어도 맥락 없이 쓰면 노이즈
  • 중복 표현을 제거하라
    • 변수명에 variable이라는 단어를 넣지 마라
    • 테이블명에 table이라는 단어를 넣지 마라
    • NameStringName보다 나을 게 없다

혼동을 주는 나쁜 예:

getActiveAccount();
getActiveAccounts();
getActiveAccountInfo();
// 어떤 것을 호출해야 할지 알 수 없음
나쁜 이름 좋은 이름 이유
moneyAmount money Amount는 노이즈
customerInfo customer Info는 노이즈
accountData account Data는 노이즈
theMessage message the는 의미 없는 접두어

발음할 수 있는 이름을 사용하라

  • 인간의 뇌는 언어를 위해 설계되어 있다. 발음 가능한 이름을 활용하라
  • 발음할 수 없는 이름은 코드를 논의할 때 바보처럼 만든다
  • 프로그래밍은 사회적 활동이다
// 나쁜 예: 발음 불가능한 이름
class DtaRcrd102 {
  private genymdhms: Date;
  private modymdhms: Date;
  private readonly pszqint: string = "102";
}

// 좋은 예: 발음 가능한 이름
class Customer {
  private generationTimestamp: Date;
  private modificationTimestamp: Date;
  private readonly recordId: string = "102";
}
  • 좋은 이름: “이봐, 이 레코드 좀 봐! 생성 타임스탬프가 내일 날짜로 설정되어 있어!” 라고 자연스럽게 대화 가능

검색하기 쉬운 이름을 사용하라

  • 단일 문자 이름과 숫자 상수는 검색하기 어렵다
    • MAX_CLASSES_PER_STUDENT는 grep으로 쉽게 찾을 수 있지만, 숫자 7은 여러 곳에서 등장해 찾기 어려움
    • 문자 e는 영어에서 가장 자주 사용되는 글자 — 변수명으로 최악의 선택
  • 이름의 길이는 스코프의 크기에 비례해야 한다
    • 짧은 메서드 안의 루프 카운터(i, j, k)는 예외적으로 단일 문자 사용 가능
    • 여러 곳에서 사용되는 변수나 상수는 반드시 검색 친화적 이름을 부여할 것
// 나쁜 예: 숫자가 무엇을 의미하는지 알 수 없음
for (let j = 0; j < 34; j++) {
  s += (t[j] * 4) / 5;
}

// 좋은 예: 검색 가능하고 의미 있는 이름
const REAL_DAYS_PER_IDEAL_DAY = 4;
const WORK_DAYS_PER_WEEK = 5;
const NUMBER_OF_TASKS = 34;
let sum = 0;

for (let j = 0; j < NUMBER_OF_TASKS; j++) {
  const realTaskDays = taskEstimate[j] * REAL_DAYS_PER_IDEAL_DAY;
  const realTaskWeeks = realTaskDays / WORK_DAYS_PER_WEEK;
  sum += realTaskWeeks;
}
  • WORK_DAYS_PER_WEEK를 검색하는 것이 5를 검색하고 필터링하는 것보다 훨씬 쉬움

인코딩을 피하라

  • 타입이나 스코프 정보를 이름에 인코딩하면 해독 부담이 늘어남
  • 새 개발자는 코드 자체를 배우는 것도 벅찬데, 인코딩 규칙까지 외워야 함
  • 인코딩된 이름은 발음하기 어렵고 오타 발생 가능성이 높음

헝가리식 표기법

  • 과거 컴파일러가 타입을 기억하지 못하던 시절의 유산
  • 현대 언어와 IDE는 타입 오류를 컴파일 전에 잡아준다 — HN은 이제 방해물일 뿐
  • 타입이 변경되어도 이름은 그대로 남아 잘못된 정보를 제공하게 됨
// 나쁜 예: 타입이 변경되었지만 이름은 그대로
let phoneString: PhoneNumber; // 타입이 string에서 PhoneNumber로 바뀌었지만 이름은 여전히 String

멤버 접두어

  • m_ 접두어는 더 이상 필요 없다
  • 클래스와 함수를 충분히 작게 유지하면 멤버 변수를 구분하는 접두어가 필요 없음
  • 코드를 많이 읽을수록 접두어는 점점 보이지 않게 되고, 결국 구식 코드의 표식이 됨
// 나쁜 예
class Part {
  private m_dsc: string; // 텍스트 설명
  setName(name: string): void {
    this.m_dsc = name;
  }
}

// 좋은 예
class Part {
  private description: string;
  setDescription(description: string): void {
    this.description = description;
  }
}

인터페이스와 구현

  • 인터페이스에 I 접두어를 붙이지 마라
    • IShapeFactory는 주의를 분산시키며 과도한 정보를 제공함
    • 사용자는 인터페이스를 받는다는 사실을 굳이 알 필요가 없음
  • 인코딩이 필요하다면 구현 클래스에 붙여라
구분 나쁜 예 좋은 예
인터페이스 IShapeFactory ShapeFactory
구현 클래스 ShapeFactory ShapeFactoryImp 또는 CShapeFactory

정신적 매핑을 피하라

  • 독자가 자신이 아는 이름으로 변환해야 하는 이름은 나쁜 이름
  • 루프 카운터 i, j, k는 전통적으로 허용되지만, 단 l은 절대 안 됨
  • 그 외의 단일 문자 이름은 독자가 실제 개념으로 정신적 매핑을 강요하는 자리 표시자일 뿐
  • r이 호스트와 스킴을 제거한 소문자 URL이라는 걸 기억한다고 해서 영리한 게 아니다

스마트한 프로그래머와 전문적인 프로그래머의 차이: 전문가는 명확성이 왕이라는 것을 이해한다. 전문가는 능력을 선하게 사용하여 다른 사람이 이해할 수 있는 코드를 작성한다.

클래스 이름

  • 클래스와 객체는 명사 또는 명사구로 이름을 지어야 함
    • 좋은 예: Customer, WikiPage, Account, AddressParser
  • 동사는 클래스 이름으로 부적절
  • 피해야 할 단어: Manager, Processor, Data, Info
    • 이런 단어는 아무 의미도 추가하지 않고 범용적으로 쓰여 혼란을 유발

메서드 이름

  • 메서드는 동사 또는 동사구로 이름을 지어야 함
    • 좋은 예: postPayment, deletePage, save
  • 접근자, 변경자, 조건자는 값 앞에 get, set, is를 붙임 (javabean 표준)
const name = employee.getName();
customer.setName("mike");
if (paycheck.isPosted()) { ... }
  • 생성자 오버로드 시, 인수를 설명하는 이름의 정적 팩터리 메서드를 사용하라
// 나쁜 예
const fulcrumPoint = new Complex(23.0);

// 좋은 예: 인수가 무엇인지 이름에서 드러남
const fulcrumPoint = Complex.fromRealNumber(23.0);
  • 해당 생성자를 private으로 선언하여 정적 팩터리 메서드 사용을 강제하는 것도 고려할 것

귀엽게 짓지 마라

  • 재치 있는 이름은 작성자의 유머를 공유하는 사람만 기억함
    • HolyHandGrenade가 무엇을 하는지 알 수 있는가? DeleteItems가 훨씬 낫다
    • whack() 대신 kill(), eatMyShorts() 대신 abort()를 사용하라
  • 은어나 문화 의존적 농담을 코드에 넣지 마라
  • 엔터테인먼트 가치보다 명확성을 선택하라

말하는 바를 코드로 써라. 코드로 쓴 것이 곧 말하는 바다.

개념당 단어 하나를 선택하라

  • 하나의 추상 개념에는 하나의 단어를 선택하고 일관되게 사용하라
    • fetch, retrieve, get을 서로 다른 클래스에서 같은 의미로 사용하면 혼란
    • 어떤 클래스가 어떤 메서드 이름을 쓰는지 기억해야 하는 부담이 생김
  • 같은 코드베이스에 controller, manager, driver가 혼재하면 혼란
    • DeviceManagerProtocolController의 본질적 차이는 무엇인가?
  • 일관된 어휘는 코드를 사용하는 프로그래머에게 큰 선물이다

말장난을 하지 마라

  • 같은 단어를 두 가지 목적으로 사용하지 마라 — 이는 일종의 말장난(pun)
  • add가 두 값을 더하거나 이어붙이는 맥락에서 사용되던 클래스들이 있을 때, 단일 값을 컬렉션에 삽입하는 새 메서드도 add라고 부르면 말장난이 됨
    • 이 경우는 insert 또는 append가 올바른 선택
  • “one word per concept” 원칙과 충돌하더라도, 의미가 다르면 이름도 달라야 한다

우리의 목표는 코드를 깊이 연구해야 하는 학술 논문이 아니라, 빠르게 훑어볼 수 있는 대중 소설처럼 읽히는 코드를 만드는 것이다. 저자가 독자에게 명확하게 설명할 책임이 있다.

솔루션 도메인 이름을 사용하라

  • 코드를 읽는 사람은 프로그래머다 — CS 용어, 알고리즘 이름, 패턴 이름, 수학 용어를 적극 활용하라
  • 모든 이름을 문제 도메인에서 가져오면, 동료가 고객에게 매번 의미를 확인해야 하는 상황 발생
    • AccountVisitor — VISITOR 패턴을 아는 프로그래머에게 즉시 의미가 전달됨
    • JobQueue — 모든 프로그래머가 이해하는 기술 용어

문제 도메인 이름을 사용하라

  • 적절한 프로그래밍 용어가 없다면 문제 도메인의 이름을 사용하라
    • 코드를 유지보수하는 프로그래머가 도메인 전문가에게 의미를 물어볼 수 있음
  • 솔루션 도메인과 문제 도메인 개념을 구분하는 것은 좋은 프로그래머와 설계자의 역할
  • 문제 도메인 개념에 가까운 코드는 문제 도메인에서 이름을 가져와야 함

의미 있는 맥락을 추가하라

  • 대부분의 이름은 그 자체만으로는 의미가 충분하지 않다
    • 잘 이름 붙여진 클래스, 함수, 네임스페이스 안에 이름을 배치하여 맥락을 제공해야 함
  • firstName, lastName, street, city, state, zipCode는 함께 놓이면 주소임을 알 수 있음
    • 하지만 state 단독으로 사용되면 주소의 일부임을 알기 어려움
  • 접두어로 맥락 추가: addrFirstName, addrState 등으로 더 큰 구조의 일부임을 암시
  • 더 나은 방법: 클래스를 생성하라Address 클래스를 만들면 컴파일러도 이 변수들이 더 큰 개념에 속함을 알게 됨

맥락이 불명확한 코드 예시 (리팩터링 전):

function printGuessStatistics(candidate: string, count: number): void {
  let number: string;
  let verb: string;
  let pluralModifier: string;

  if (count === 0) {
    number = "no";
    verb = "are";
    pluralModifier = "s";
  } else if (count === 1) {
    number = "1";
    verb = "is";
    pluralModifier = "";
  } else {
    number = count.toString();
    verb = "are";
    pluralModifier = "s";
  }

  const guessMessage = `There ${verb} ${number} ${candidate}${pluralModifier}`;
  print(guessMessage);
}

맥락이 명확한 코드 예시 (리팩터링 후):

class GuessStatisticsMessage {
  private number: string = "";
  private verb: string = "";
  private pluralModifier: string = "";

  make(candidate: string, count: number): string {
    this.createPluralDependentMessageParts(count);
    return `There ${this.verb} ${this.number} ${candidate}${this.pluralModifier}`;
  }

  private createPluralDependentMessageParts(count: number): void {
    if (count === 0) this.thereAreNoLetters();
    else if (count === 1) this.thereIsOneLetter();
    else this.thereAreManyLetters(count);
  }

  private thereAreManyLetters(count: number): void {
    this.number = count.toString();
    this.verb = "are";
    this.pluralModifier = "s";
  }

  private thereIsOneLetter(): void {
    this.number = "1";
    this.verb = "is";
    this.pluralModifier = "";
  }

  private thereAreNoLetters(): void {
    this.number = "no";
    this.verb = "are";
    this.pluralModifier = "s";
  }
}
  • GuessStatisticsMessage 클래스로 분리함으로써 세 변수의 맥락이 명확해짐
  • 알고리즘도 더 작은 함수들로 분리되어 훨씬 깔끔해짐

불필요한 맥락을 추가하지 마라

  • 필요 이상의 맥락을 이름에 추가하지 마라
    • “Gas Station Deluxe” 앱에서 모든 클래스 앞에 GSD를 붙이는 것은 나쁜 생각
    • G를 입력하고 자동완성을 누르면 시스템의 모든 클래스가 나열됨
  • 짧은 이름이 긴 이름보다 낫다 — 단, 명확한 경우에 한해서
  • 이름에 필요한 맥락만 추가하라

예시:

// GSDAccountAddress — 17자 중 10자가 불필요하거나 무관한 정보
// 고객 연락 앱에서 재사용하기 어려운 이름

// Address — 깔끔하고 재사용 가능한 클래스 이름
// 구분이 필요할 때는 구체적으로:
class PostalAddress { ... }   // 우편 주소
class MacAddress { ... }      // MAC 주소
class URI { ... }             // 웹 주소
  • 인스턴스명으로 accountAddress, customerAddress는 적절하나, 클래스명으로는 부적절
  • 정밀성이 목적이다. 필요한 만큼만 구체적으로 이름을 지어라

정리

  • 좋은 이름을 선택하는 것은 좋은 묘사 능력과 공유된 문화적 배경을 필요로 함
  • 기술적, 비즈니스적 문제가 아닌 교육의 문제
  • 많은 개발자들이 다른 개발자의 반발을 두려워해 이름 바꾸기를 꺼림
    • 하지만 이름이 (좋은 방향으로) 바뀌면 오히려 감사하게 됨
  • 현대 도구들 덕분에 클래스와 메서드의 이름을 외울 필요가 없음 — 코드가 문단과 문장처럼 읽히는지에 집중하라
  • 두려워하지 말고 리네임(rename)하라. 다른 코드 개선과 마찬가지로, 누군가를 놀라게 할 수 있지만 그것이 멈춰야 할 이유가 되지 않는다

핵심 요약

  • 의도를 드러내는 이름: 이름은 왜 존재하는지, 무엇을 하는지, 어떻게 쓰는지를 답해야 한다
  • 잘못된 정보 회피: 거짓 단서, 유사한 이름, l/O 같은 혼동 문자를 피하라
  • 의미 있는 구분: 이름이 다르면 의미도 달라야 한다. 노이즈 워드는 제거하라
  • 발음 가능한 이름: 대화에서 자연스럽게 사용할 수 있는 이름을 선택하라
  • 검색 가능한 이름: 단일 문자와 매직 넘버는 grep이 불가능하다. 스코프가 크면 이름도 길게
  • 인코딩 회피: HN, m_ 접두어, I 접두어는 현대 개발 환경에서 불필요한 짐이다
  • 정신적 매핑 회피: 독자가 이름을 다른 개념으로 번역하게 만들지 마라
  • 클래스는 명사: Manager/Processor/Data/Info를 피하고, 명확한 명사를 사용하라
  • 메서드는 동사: get/set/is 접두어를 활용하고, 팩터리 메서드에 의도 담기
  • 귀엽지 않게: 유머보다 명확성을 선택하라
  • 개념당 단어 하나: fetch/retrieve/get을 혼용하지 말고 하나를 일관되게 사용하라
  • 말장난 금지: 의미가 다른 개념에 같은 단어를 쓰지 마라
  • 솔루션 도메인 이름: CS 용어, 패턴 이름 등 프로그래머가 이미 아는 단어를 활용하라
  • 문제 도메인 이름: 적절한 기술 용어가 없으면 도메인 용어를 사용하라
  • 맥락 추가: 이름을 클래스/함수 안에 배치하거나, 클래스를 만들어 맥락을 부여하라
  • 불필요한 맥락 금지: 짧고 명확한 이름이 길고 중복된 이름보다 낫다

3장. 함수

함수는 모든 프로그램에서 조직의 첫 번째 단위다. 함수를 잘 작성하는 것이 이 챕터의 주제다.

들어가며

  • 프로그래밍 초창기에는 루틴과 서브루틴으로 시스템을 구성했고, Fortran/PL1 시대에는 프로그램·서브프로그램·함수로 구성했음
  • 오늘날에는 함수만이 살아남았으며, 함수는 모든 프로그램에서 조직의 첫 번째 단위
  • 나쁜 함수의 특징: 길이가 길고, 중복된 코드, 의미 없는 문자열, 이해하기 어려운 데이터 타입과 API가 뒤섞임
  • 몇 가지 간단한 메서드 추출, 이름 변경, 구조 개선만으로도 의도를 명확하게 전달하는 함수로 바꿀 수 있음

함수는 작게

  • 함수의 첫 번째 규칙: 작아야 한다
  • 함수의 두 번째 규칙: 그것보다 더 작아야 한다
  • 수십 년간의 경험이 가르쳐 준 진실: 함수는 매우 작아야 한다
  • 과거에는 VT100 터미널 기준으로 “화면 한 페이지” 크기를 기준으로 삼았으나 현재는 무의미
  • 한 줄에 150자, 함수에 100줄이 들어가는 모니터도 있지만, 그렇다고 그만큼 길게 써도 된다는 의미가 아님
  • 이상적인 함수의 길이: 2~4줄. 각 함수가 하나의 이야기를 명확하게 말해줘야 함
// 나쁜 예: 너무 많은 일을 하는 긴 함수
async function testableHtml(pageData: PageData, includeSuiteSetup: boolean): Promise<string> {
  const wikiPage = pageData.getWikiPage();
  const buffer: string[] = [];

  if (pageData.hasAttribute("Test")) {
    if (includeSuiteSetup) {
      const suiteSetup = PageCrawlerImpl.getInheritedPage(SuiteResponder.SUITE_SETUP_NAME, wikiPage);
      if (suiteSetup !== null) {
        const pagePath = suiteSetup.getPageCrawler().getFullPath(suiteSetup);
        const pagePathName = PathParser.render(pagePath);
        buffer.push(`!include -setup .${pagePathName}\n`);
      }
    }
    // ... 수십 줄 더 이어짐
  }
  return pageData.getHtml();
}

// 좋은 예: 리팩토링 후
async function renderPageWithSetupsAndTeardowns(pageData: PageData, isSuite: boolean): Promise<string> {
  if (await isTestPage(pageData))
    await includeSetupAndTeardownPages(pageData, isSuite);
  return pageData.getHtml();
}

블록과 들여쓰기

  • if, else, while 등의 블록 내부는 한 줄이어야 하며, 그 줄은 함수 호출이어야 함
    • 바깥 함수를 작게 유지하고, 블록 내 함수에 서술적인 이름을 붙여 문서화 효과를 얻음
  • 함수는 중첩 구조를 가질 만큼 커서는 안 됨
  • 함수의 들여쓰기 수준은 1~2단계를 넘지 않아야 함
  • 이렇게 하면 함수를 읽고 이해하기 훨씬 쉬워짐

한 가지만 해라

함수는 한 가지를 해야 한다. 그것을 잘 해야 한다. 그것만 해야 한다.

  • “한 가지”란 무엇인가? — 함수 이름보다 한 단계 아래 수준의 작업들만 수행하는 것
  • 함수가 하나의 작업만 하는지 확인하는 방법: TO 문단으로 설명해보기
    • 예: “TO renderPageWithSetupsAndTeardowns, 테스트 페이지인지 확인하고, 맞으면 setup/teardown을 포함한 후, HTML로 렌더링한다”
    • 이 설명에서 각 단계가 함수 이름의 바로 아래 수준 추상화라면 단일 책임을 지키는 것
  • 다른 함수를 의미 있게 추출할 수 있다면, 그 함수는 여러 가지를 하고 있는 것

함수 내 섹션

  • 함수가 내부에서 “선언”, “초기화”, “처리” 등 섹션으로 나뉜다면 여러 가지 작업을 하고 있다는 명백한 증거
  • 한 가지 일만 하는 함수는 섹션으로 나뉘지 않음

함수 당 하나의 추상화 수준

  • 함수 내 모든 문장은 동일한 추상화 수준에 있어야 함
  • 추상화 수준의 혼합 예시:
    • 높은 수준: getHtml()
    • 중간 수준: const pagePathName = PathParser.render(pagePath)
    • 낮은 수준: buffer.push("\n")
  • 추상화 수준이 섞이면 독자가 세부 구현인지 핵심 개념인지 구분하기 어려워짐
  • 세부 내용이 섞이기 시작하면 점점 더 많은 세부 내용이 함수에 추가되는 경향이 생김

하향식 코드 읽기: 내려가기 규칙

  • 코드는 위에서 아래로 읽히는 서술 구조처럼 작성되어야 함
  • 모든 함수 다음에는 그 다음 수준의 추상화를 가진 함수들이 오도록 구성
  • 프로그램이 TO 문단의 집합처럼 읽혀야 함:
TO 페이지를 렌더링하려면, setup과 teardown을 포함하고 HTML로 렌더링한다.
  TO setup을 포함하려면, suite setup과 일반 setup을 포함한다.
    TO suite setup을 포함하려면, 부모 계층에서 "SuiteSetUp" 페이지를 찾아 include 지시어를 추가한다.
      TO 부모 계층을 검색하려면...
  • 이 규칙을 따르는 것이 어렵지만, 함수를 짧게 유지하고 “한 가지”만 하도록 만드는 핵심 열쇠

Switch 문

  • switch 문은 본질적으로 N가지 일을 함 — 작게 만들기 매우 어려움
  • switch 문은 SRP(단일 책임 원칙)와 OCP(개방-폐쇄 원칙)를 위반하기 쉬움
// 나쁜 예: switch가 여기저기에 노출됨
function calculatePay(employee: Employee): Money {
  switch (employee.type) {
    case EmployeeType.COMMISSIONED:
      return calculateCommissionedPay(employee);
    case EmployeeType.HOURLY:
      return calculateHourlyPay(employee);
    case EmployeeType.SALARIED:
      return calculateSalariedPay(employee);
    default:
      throw new InvalidEmployeeTypeError(employee.type);
  }
}
// 이 구조가 isPayday(), deliverPay() 등 여러 곳에 반복됨
// 좋은 예: switch를 Abstract Factory 안에 숨김
interface Employee {
  isPayday(): boolean;
  calculatePay(): Money;
  deliverPay(pay: Money): void;
}

interface EmployeeFactory {
  makeEmployee(record: EmployeeRecord): Employee;
}

class EmployeeFactoryImpl implements EmployeeFactory {
  makeEmployee(record: EmployeeRecord): Employee {
    switch (record.type) {
      case EmployeeType.COMMISSIONED:
        return new CommissionedEmployee(record);
      case EmployeeType.HOURLY:
        return new HourlyEmployee(record);
      case EmployeeType.SALARIED:
        return new SalariedEmployee(record);
      default:
        throw new InvalidEmployeeTypeError(record.type);
    }
  }
}
// 이후 calculatePay, isPayday, deliverPay 등은 Employee 인터페이스를 통해 다형성으로 처리
  • switch 허용 원칙: switch 문은 다음 세 조건을 모두 만족할 때만 허용
    1. 딱 한 번만 등장
    2. 다형적 객체를 생성하는 데 사용
    3. 상속 관계 뒤에 숨겨져 시스템의 나머지 부분이 볼 수 없도록 처리

서술적인 이름을 사용하라

  • Ward의 원칙: “깨끗한 코드란 각 루틴이 예상한 대로 동작하는 코드다”
  • 이 원칙의 절반은 작은 함수에 좋은 이름을 붙이는 것으로 달성됨
  • 함수가 작고 집중적일수록, 서술적인 이름을 붙이기 더 쉬움
  • 긴 이름을 두려워하지 말 것: 짧고 수수께끼 같은 이름보다 길고 서술적인 이름이 낫고, 긴 주석보다도 나음
  • 이름 선택에 시간을 투자할 것: 여러 이름을 시험해보고 그 이름과 함께 코드를 읽어볼 것
    • 좋은 이름을 찾는 과정에서 코드 구조 자체가 개선되는 경우가 많음
  • 일관성을 유지할 것: 모듈 내에서 동일한 동사, 명사, 구절을 사용
    • 예: includeSetupAndTeardownPages, includeSetupPages, includeSuiteSetupPage, includeSetupPage
    • 이 이름들이 이야기를 만들어냄

함수 인수

  • 이상적인 인수 개수는 0개(무항)
  • 다음은 1개(단항), 그 다음은 2개(이항)
  • 3개(삼항)는 가능한 한 피할 것
  • 3개 초과는 매우 특별한 이유가 없으면 절대 사용하지 않을 것
  • 인수가 많을수록 테스트해야 할 조합이 기하급수적으로 증가함
  • 출력 인수(output argument)는 입력 인수보다 이해하기 더 어려움 — 독자는 인수가 입력이라고 기대함

단항 함수의 일반적인 형태

단항 함수의 두 가지 일반적인 용도:

  1. 인수에 대한 질문: fileExists("MyFile"): boolean
  2. 인수를 변환하여 반환: fileOpen("MyFile"): InputStream

덜 일반적이지만 유용한 형태:

  1. 이벤트 처리: passwordAttemptFailedNtimes(attempts: number): void
    • 입력 인수는 있지만 출력은 없으며, 시스템의 상태를 변경함
    • 이 형태임이 코드에서 명확하게 드러나야 함
// 나쁜 예: 변환을 출력 인수로 처리
function transform(out: StringBuffer): void { ... }

// 좋은 예: 변환 결과를 반환값으로 처리
function transform(input: StringBuffer): StringBuffer { ... }

플래그 인수

  • 불리언 값을 함수에 넘기는 것은 끔찍한 관행
  • 함수가 두 가지 이상의 일을 한다고 공개적으로 선언하는 것과 같음
// 나쁜 예
render(true);        // true가 무슨 의미인지 알 수 없음
render(isSuite: boolean);  // 조금 낫지만 여전히 함수가 두 가지 일을 함

// 좋은 예: 두 함수로 분리
renderForSuite(): string { ... }
renderForSingleTest(): string { ... }

이항 함수

  • 이항 함수는 단항 함수보다 이해하기 어려움
  • 자연스러운 이항이 있는 경우는 허용: new Point(0, 0) — 두 인수가 하나의 값의 정렬된 구성 요소
  • 그러나 writeField(outputStream, name)처럼 자연스러운 응집도나 순서가 없는 경우는 문제
  • 가능하면 단항으로 변환할 방법을 찾을 것:
    • writeField 메서드를 outputStream의 멤버로 만들어 outputStream.writeField(name) 형태로
    • outputStream을 클래스의 멤버 변수로 만들어 인수에서 제거
    • FieldWriter와 같은 새 클래스를 만들어 생성자에서 outputStream을 받고 write 메서드 제공

삼항 함수

  • 삼항 함수는 이항 함수보다 훨씬 이해하기 어려움
  • 순서, 멈춤, 무시의 문제가 두 배 이상 증가함
  • 삼항 함수를 만들기 전에 매우 신중하게 생각할 것
  • 예외적으로 허용 가능한 삼항: assertEquals(1.0, amount, 0.001) — 부동소수점 비교의 상대성을 상기시켜줌

인수 객체

  • 인수가 2~3개 이상 필요하다면, 일부 인수를 클래스로 묶는 것을 고려
// 나쁜 예
function makeCircle(x: number, y: number, radius: number): Circle { ... }

// 좋은 예: x, y는 좌표라는 하나의 개념 → 클래스로 묶음
function makeCircle(center: Point, radius: number): Circle { ... }
  • 인수를 객체로 묶는 것은 속임수가 아님 — 함께 전달되는 변수들은 이름이 필요한 개념의 일부

인수 목록

  • 가변 인수가 필요할 때: 가변 인수들을 동일하게 취급한다면 List 타입의 단일 인수와 동일
// 이 함수는 실질적으로 이항 함수
function format(formatStr: string, ...args: unknown[]): string { ... }

// 가변 인수 함수도 단항, 이항, 삼항 규칙이 동일하게 적용됨
function monad(...args: number[]): void { ... }
function dyad(name: string, ...args: number[]): void { ... }
function triad(name: string, count: number, ...args: number[]): void { ... }

동사와 키워드

  • 단항 함수에서는 함수 이름과 인수가 동사/명사 쌍을 이뤄야 함
    • write(name) — “name”을 “쓴다”
    • writeField(name) — “name”이 “필드”임을 알 수 있어 더 좋음
  • 키워드 형식: 인수 이름을 함수 이름에 포함
    • assertEqualsassertExpectedEqualsActual(expected, actual)
    • 인수의 순서를 기억할 필요가 없어짐

부수 효과를 일으키지 마라

  • 부수 효과는 거짓말이다 — 함수는 한 가지를 하겠다고 약속하고 숨겨진 다른 일도 함
  • 클래스 변수, 전달된 인수, 전역 변수를 몰래 변경하는 행위
  • 시간적 결합(temporal coupling)순서 의존성을 초래
// 나쁜 예: checkPassword가 세션을 초기화하는 부수 효과를 가짐
class UserValidator {
  private cryptographer: Cryptographer;

  checkPassword(userName: string, password: string): boolean {
    const user = UserGateway.findByName(userName);
    if (user !== User.NULL) {
      const codedPhrase = user.getPhraseEncodedByPassword();
      const phrase = this.cryptographer.decrypt(codedPhrase, password);
      if (phrase === "Valid Password") {
        Session.initialize(); // 부수 효과! 이름에 드러나지 않음
        return true;
      }
    }
    return false;
  }
}
// checkPassword는 세션이 초기화되어도 안전한 시점에만 호출할 수 있게 됨 → 시간적 결합 발생
  • 시간적 결합이 필요하다면 함수 이름에 명시: checkPasswordAndInitializeSession
    • 단, 이 경우 “한 가지만 해라” 원칙에 위반

출력 인수

  • 인수는 자연스럽게 입력으로 해석됨
  • 출력 인수는 독자에게 혼란을 야기: appendFooter(s)s가 입력인가, 출력인가?
// 나쁜 예: 출력 인수 사용
function appendFooter(report: StringBuffer): void { ... }

// 좋은 예: 객체의 상태를 변경하는 메서드로 전환
report.appendFooter();
  • 일반 원칙: 함수가 무언가의 상태를 변경해야 한다면, 자신이 속한 객체의 상태를 변경할 것

명령과 조회를 분리하라

  • 함수는 무언가를 수행하거나 무언가에 답하거나 둘 중 하나만 해야 함
  • 객체의 상태를 변경하거나 정보를 반환하거나 — 둘 다 하면 혼란 야기
// 나쁜 예: set이 동사인지 형용사인지 모호함
function set(attribute: string, value: string): boolean { ... }

if (set("username", "unclebob")) { ... }
// "username이 unclebob으로 이전에 설정되어 있으면" 처럼 읽힘

// 좋은 예: 명령과 조회를 명확히 분리
if (attributeExists("username")) {
  setAttribute("username", "unclebob");
  // ...
}

오류 코드보다 예외를 사용하라

  • 명령 함수에서 오류 코드를 반환하면 명령과 조회 분리 원칙을 미묘하게 위반
  • 오류 코드 반환 시 caller가 즉시 오류를 처리해야 하므로 깊은 중첩 구조 발생
// 나쁜 예: 오류 코드 반환으로 인한 깊은 중첩
if (deletePage(page) === ErrorCode.OK) {
  if (registry.deleteReference(page.name) === ErrorCode.OK) {
    if (configKeys.deleteKey(page.name.makeKey()) === ErrorCode.OK) {
      logger.log("page deleted");
    } else {
      logger.log("configKey not deleted");
    }
  } else {
    logger.log("deleteReference from registry failed");
  }
} else {
  logger.log("delete failed");
  return ErrorCode.ERROR;
}

// 좋은 예: 예외 사용으로 정상 경로와 오류 처리 분리
try {
  deletePage(page);
  registry.deleteReference(page.name);
  configKeys.deleteKey(page.name.makeKey());
} catch (e) {
  logger.log((e as Error).message);
}

Try/Catch 블록 추출하라

  • try/catch는 코드 구조를 혼란스럽게 하고 정상 처리와 오류 처리를 섞음
  • try/catch 내부를 별도 함수로 추출하는 것이 좋음
// 좋은 예: 오류 처리와 정상 로직을 분리
function deletePage(page: Page): void {
  try {
    deletePageAndAllReferences(page);
  } catch (e) {
    logError(e as Error);
  }
}

function deletePageAndAllReferences(page: Page): void {
  // 실제 삭제 로직만 담당 — 오류 처리 없음
  page.delete();
  registry.deleteReference(page.name);
  configKeys.deleteKey(page.name.makeKey());
}

function logError(e: Error): void {
  logger.log(e.message);
}

오류 처리도 한 가지 작업이다

  • 오류 처리도 하나의 작업
  • 함수에 try 키워드가 있다면, 그것이 함수의 첫 번째 단어여야 하고 catch/finally 블록 이후에는 아무것도 없어야 함

오류 코드 열거형은 의존성 자석이다

  • 오류 코드를 반환하면 오류 코드가 정의된 클래스나 열거형이 필요하게 됨
// 나쁜 예: 오류 열거형을 모든 곳에서 import해야 함
enum ErrorCode {
  OK,
  INVALID,
  NO_SUCH,
  LOCKED,
  OUT_OF_RESOURCES,
  WAITING_FOR_EVENT
}
// 이 열거형이 변경되면 → 이를 사용하는 모든 클래스를 재컴파일·재배포해야 함
// 따라서 프로그래머들은 새 오류 코드를 추가하지 않고 기존 코드를 재사용하려 함
  • 예외 클래스를 사용하면: 새 예외는 예외 클래스의 파생 클래스로 추가 → 재컴파일·재배포 없이 확장 가능 (OCP 적용)

반복하지 마라, DRY

  • 나쁜 함수 예제에서 동일한 알고리즘이 SetUp, SuiteSetUp, TearDown, SuiteTearDown에 4번 반복됨
  • 중복은 코드를 부풀리고, 알고리즘 변경 시 여러 곳을 동시에 수정해야 하며, 누락 오류가 발생하기 쉬움
  • 소프트웨어에서 모든 악의 근원은 중복일 수 있음
  • 중복을 제거하기 위한 수많은 원칙과 관행들이 존재:
    • 데이터베이스 정규화 (중복 데이터 제거)
    • 객체지향 프로그래밍 (중복 코드를 기반 클래스에 집중)
    • 구조적 프로그래밍, AOP, COP 모두 부분적으로는 중복 제거 전략

구조적 프로그래밍

  • Dijkstra의 구조적 프로그래밍 규칙: 모든 함수와 블록은 하나의 입구와 하나의 출구만 가져야 함
    • 함수 내 return은 하나
    • 루프 내 breakcontinue 없음
    • goto는 절대 사용하지 않음
  • 그러나 함수를 작게 유지한다면, 이 규칙의 이점은 거의 없음
  • 함수가 매우 작다면, 경우에 따라 여러 return, break, continue가 표현력을 높이기도 함
  • goto는 큰 함수에서만 의미가 있으므로, 작은 함수에서는 아예 사용하지 말 것

이런 함수를 어떻게 작성하는가?

  • 소프트웨어 작성은 다른 종류의 글쓰기와 같음
    • 논문이나 기사를 쓸 때: 생각을 먼저 적고, 읽기 좋게 다듬는 과정을 거침
  • 처음부터 완벽한 함수를 쓰는 사람은 없다
  • 처음에는 길고 복잡하게 작성해도 됨: 들여쓰기가 많고, 중첩 루프도 있고, 인수 목록이 길고, 이름이 임의적이고, 중복도 있음
  • 단, 그 어설픈 코드의 모든 줄을 커버하는 단위 테스트는 반드시 작성해야 함
  • 그런 다음 코드를 다듬어 나감: 함수 추출, 이름 변경, 중복 제거, 메서드 정렬, 클래스 분리 — 항상 테스트를 통과시키면서
  • 결국 이 챕터의 규칙을 따르는 함수들이 완성됨

정리

  • 모든 시스템은 해당 시스템을 기술하기 위해 프로그래머가 설계한 도메인 특화 언어(DSL) 로 구성됨
  • 함수는 그 언어의 동사, 클래스는 명사
  • 마스터 프로그래머는 시스템을 “작성할 프로그램”이 아닌 “말할 이야기” 로 생각함
  • 프로그래밍의 예술은 곧 언어 설계의 예술

이 챕터는 함수를 잘 작성하는 기법에 관한 것이다. 그러나 진짜 목표는 시스템의 이야기를 전달하는 것이며, 작성하는 함수들이 명확하고 정밀한 언어로 깔끔하게 조화를 이뤄야 한다는 것을 잊지 말 것.

핵심 요약

  • 함수는 작게: 2~4줄이 이상적. 절대로 20줄을 넘기지 말 것
  • 한 가지만: 함수 이름의 한 단계 아래 추상화 작업만 수행
  • 추상화 수준 통일: 함수 내 모든 문장은 동일한 추상화 수준이어야 함
  • 내려가기 규칙: 코드는 위에서 아래로 읽히는 서술 구조
  • Switch는 숨겨라: Abstract Factory 뒤에 단 한 번만, 다형성으로 처리
  • 서술적 이름: 길더라도 서술적인 이름이 짧고 모호한 이름보다 낫다
  • 인수는 적게: 0개가 이상적, 3개 이상은 피할 것
  • 부수 효과 없음: 함수는 약속한 한 가지만 하고 숨겨진 일을 하면 안 됨
  • 명령/조회 분리: 상태 변경과 정보 반환을 동시에 하지 말 것
  • 예외 사용: 오류 코드 대신 예외 사용, try/catch 내부는 별도 함수로 추출
  • DRY: 중복은 소프트웨어의 모든 악의 근원일 수 있다
  • 구조적 프로그래밍: 함수가 충분히 작으면 단일 출구 규칙은 선택 사항

4장. 주석

“나쁜 코드에 주석을 달지 마라 — 새로 짜라.” — Brian W. Kernighan & P. J. Plaugher

  • 잘 작성된 주석은 도움이 되지만, 쓸모없는 주석은 모듈을 어지럽히고, 낡고 부정확한 주석은 심각한 해악을 끼침
  • 주석은 “순수한 선”이 아니다 — 기껏해야 필요악
  • 프로그래밍 언어가 충분히 표현력이 있거나, 개발자가 언어를 능숙하게 다룰 수 있다면 주석은 거의 필요하지 않음
  • 주석의 올바른 용도: 코드로 의도를 표현하지 못한 실패를 보완하는 것
    • 주석을 써야 하는 상황에 처했다면, 코드로 표현할 방법이 없는지 먼저 고민할 것
    • 코드로 표현에 성공하면 스스로를 칭찬할 것, 주석을 쓰면 자신의 표현 실패를 자각할 것

주석이 거짓말을 하는 이유

  • 코드는 변하고 진화하지만, 주석은 항상 따라오지 못한다
  • 코드 덩어리가 이리저리 이동해도 주석은 제자리에 남아 고아가 됨
  • 시간이 지날수록 주석은 코드와 멀어지고, 점점 부정확해짐
  • 부정확한 주석은 아예 없는 것보다 훨씬 나쁘다 — 잘못된 기대를 심어주고, 따르지 말아야 할 규칙을 강요함
  • 진실은 오직 한 곳, 코드 안에만 있다

주석은 나쁜 코드를 대체하지 못한다

  • 주석을 쓰는 가장 흔한 동기: 코드가 엉망임을 스스로 알기 때문
  • "이 코드는 복잡하니까 주석을 달아야지!" 가 아니라 "코드를 정리해야지!"
  • 주석이 적고 명확한 코드 > 주석이 많고 복잡한 코드
  • 엉망을 설명하는 주석을 쓰는 시간에 그 엉망을 정리하라

코드로 의도를 표현하라

  • 코드는 설명하기 어려운 수단이라는 인식이 있지만, 이는 잘못된 통념
  • 대부분의 의도는 몇 초의 고민만으로 코드 자체에 담을 수 있음

나쁜 예시 — 주석에 의존:

// 직원이 전체 혜택을 받을 자격이 있는지 확인
if ((employee.flags & HOURLY_FLAG) && employee.age > 65) { ... }

좋은 예시 — 코드로 표현:

if (employee.isEligibleForFullBenefits()) { ... }
  • 주석으로 쓰고 싶은 내용을 함수 이름으로 만들어버리는 것이 핵심

좋은 주석

가장 좋은 주석은 쓰지 않아도 되는 방법을 찾아낸 주석이다.

법적 주석

  • 기업 코딩 표준에 따라 법적 이유로 작성해야 하는 주석 (저작권, 라이선스 등)
  • 소스 파일 맨 앞에 넣는 것이 일반적이며, IDE에서 자동으로 접어 보여주므로 방해가 덜함
// Copyright (C) 2003,2004,2005 by Object Mentor, Inc. All rights reserved.
// Released under the terms of the GNU General Public License version 2 or later.
  • 주석 안에 모든 약관을 넣지 말고, 표준 라이선스나 외부 문서를 참조할 것

정보를 제공하는 주석

  • 코드의 기본적인 정보를 제공할 때 유용할 수 있음
  • 단, 가능하면 함수 이름 자체에 정보를 담는 것이 더 좋음
// kk:mm:ss EEE, MMM dd, yyyy 형식과 일치
const timeMatcher = /\d*:\d*:\d* \w*, \w* \d*, \d*/;
  • 위 예시처럼 정규식의 패턴 의도를 설명할 때는 유용하지만, 이 역시 날짜/시간 변환을 담당하는 별도 클래스로 분리하면 주석 자체가 불필요해질 수 있음

의도를 설명하는 주석

  • 구현 정보를 넘어 설계 결정의 의도를 담는 주석
  • 독자가 동의하지 않더라도, 작성자가 무엇을 하려 했는지 이해할 수 있게 함
// 경쟁 조건(race condition)을 유발하기 위한 최선의 시도로,
// 다수의 스레드를 생성한다.
for (let i = 0; i < 25000; i++) {
  const thread = new WidgetBuilderThread(widgetBuilder, text, parent, failFlag);
  thread.start();
}

명료화 주석

  • 모호한 인수나 반환값의 의미를 읽기 쉽게 번역해주는 주석
  • 표준 라이브러리나 수정 불가능한 코드를 다룰 때 특히 유용
  • 단, 명료화 주석이 틀릴 위험도 있으므로 신중히 작성하고 정확성을 철저히 검증할 것
assertTrue(a.compareTo(a) === 0); // a == a
assertTrue(a.compareTo(b) !== 0); // a != b
assertTrue(a.compareTo(b) === -1); // a < b
assertTrue(b.compareTo(a) === 1);  // b > a

결과를 경고하는 주석

  • 다른 개발자에게 특정 결과를 경고하는 주석
  • 불필요한 실수를 방지하는 데 효과적
// 실행하는 데 시간이 매우 오래 걸리므로 여유가 있을 때만 실행하세요.
function testWithReallyBigFile() { ... }
// SimpleDateFormat은 스레드 안전하지 않으므로
// 인스턴스를 매번 독립적으로 생성해야 한다.
function makeStandardHttpDateFormat(): SimpleDateFormat {
  return new SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss z");
}

TODO 주석

  • 지금 당장 할 수 없지만 나중에 해야 할 작업을 // TODO 형식으로 남기는 것
  • 쓸 수 있는 상황: 더 이상 필요 없는 기능 삭제 알림, 더 나은 이름 요청, 계획된 이벤트에 따른 변경 알림 등
  • TODO는 나쁜 코드를 남겨두는 핑계가 되어선 안 된다
  • 대부분의 IDE는 TODO를 추적하는 기능을 제공하므로 분실될 위험은 낮음
  • 코드에 TODO가 너무 많이 쌓이지 않도록 주기적으로 검토하고 제거할 것
// TODO: checkout 모델을 구현하면 이 함수는 제거될 예정
protected makeVersion(): VersionInfo | null {
  return null;
}

중요성 강조 주석

  • 겉보기에 사소해 보이지만 실은 매우 중요한 코드를 강조할 때 사용
const listItemContent = match[3].trim();
// trim은 매우 중요하다. 시작 공백을 제거하지 않으면
// 이 항목이 다른 목록으로 인식될 수 있다.
new ListItemWidget(this, listItemContent, this.level + 1);

공개 API의 문서화 주석

  • 공개 API를 작성할 때는 좋은 문서 주석(JSDoc 등)이 필수적
  • TypeScript 기준으로는 JSDoc 형태의 주석이 이에 해당
  • 단, 이 챕터의 나머지 조언들을 함께 적용해야 함 — 문서 주석도 오해를 유발하거나 부정확해질 수 있음

나쁜 주석

대부분의 주석이 이 범주에 속한다. 나쁜 코드에 대한 지팡이, 불충분한 결정의 변명, 혼자 중얼거리는 독백에 불과하다.

독백 주석

  • 의무감에 혹은 프로세스 요구에 의해 억지로 다는 주석
  • 주석의 의미가 불분명하여 다른 모듈을 뒤져야만 이해할 수 있는 주석은 실패한 주석
loadProperties() {
  try {
    // ...파일 로드
  } catch (e) {
    // 프로퍼티 파일이 없으면 모든 기본값이 로드된다
    // (누가 기본값을 로드하는가? 언제? 이 catch가 처리하는가?)
  }
}
  • 위 주석은 작성자에게만 의미가 있고, 독자는 의미를 파악하기 위해 코드를 더 뒤져야 함

중복 주석

  • 코드 자체보다 읽는 데 더 오래 걸리면서 아무 정보도 추가하지 않는 주석
  • 코드를 정당화하지도, 의도나 근거를 제공하지도 않음
  • 코드보다 덜 정확하며, 독자가 주석의 부정확함을 진실로 받아들이도록 유도함
// this.closed가 true가 되면 반환하는 유틸리티 메서드.
// 타임아웃에 도달하면 예외를 던진다.
waitForClose(timeoutMillis: number): void {
  if (!this.closed) {
    this.wait(timeoutMillis);
    if (!this.closed)
      throw new Error("MockResponseSender could not be closed");
  }
}
  • Tomcat의 ContainerBase.java 같은 예시처럼, 클래스 필드에 자명한 내용을 JSDoc으로 일일이 달아놓은 코드는 코드를 가리고 혼란을 줄 뿐

오해를 불러일으키는 주석

  • 의도는 좋았지만 충분히 정확하지 않아 오해를 일으키는 주석
  • waitForClose 예시에서 주석은 this.closedtrue되면 반환한다고 했지만, 실제로는 this.closed가 이미 true인 경우에만 즉시 반환하고, 그렇지 않으면 타임아웃 후 예외를 던짐
  • 이 미묘한 차이 때문에 다른 개발자가 this.closedtrue로 바뀌는 순간 반환될 것이라 믿고 호출했다가 디버깅 지옥에 빠질 수 있음

의무적으로 다는 주석

  • 모든 함수에 JSDoc을 달아야 한다, 모든 변수에 주석을 달아야 한다는 규칙은 어리석음
  • 이런 주석은 코드를 어지럽히고, 거짓말을 퍼뜨리며, 혼란과 무질서를 초래함
/**
 * @param title CD의 제목
 * @param author CD의 저자
 * @param tracks CD의 트랙 수
 * @param durationInMinutes CD의 재생 시간(분)
 */
addCD(title: string, author: string, tracks: number, durationInMinutes: number): void {
  // ...
}
  • 파라미터명과 완전히 동일한 내용만 반복하는 JSDoc은 코드를 난독화할 뿐

이력을 기록하는 주석

  • 모듈 상단에 변경할 때마다 로그를 추가하는 관행
  • 소스 코드 관리 시스템(Git 등)이 존재하는 현재는 완전히 불필요
  • 이런 주석은 제거해야 함
// * Changes (from 11-Oct-2001)
// * --------------------------
// * 11-Oct-2001 : Re-organised the class and moved it to new package (DG);
// * 05-Nov-2001 : Added a getDescription() method (DG);
// * ...수십 줄 계속...

있으나 마나 한 주석

  • 명백한 사실만 반복하며 새로운 정보를 전혀 제공하지 않는 주석
  • 읽다 보면 무시하는 습관이 생기고, 결국 중요한 주석도 함께 무시하게 됨
/** 기본 생성자. */
constructor() {}

/** 월(月). */
private dayOfMonth: number;

/**
 * 월(月)을 반환한다.
 * @return 월(月)
 */
getDayOfMonth(): number {
  return this.dayOfMonth;
}

끔찍한 주석

  • JSDoc도 소음이 될 수 있음 — 문서를 제공하겠다는 잘못된 욕심에서 나온 중복 주석
  • 복붙 실수까지 발생하여 더욱 신뢰를 잃게 됨
/** The name. */
private name: string;
/** The version. */
private version: string;
/** The licenceName. */
private licenceName: string;
/** The version. */  // ← 복붙 오류! info인데 version이라고 되어 있음
private info: string;
  • 작성자도 주의를 기울이지 않은 주석을 독자가 신뢰할 이유가 없음

함수나 변수로 표현할 수 있으면 주석을 쓰지 마라

나쁜 예시:

// 전역 목록 <mod>의 모듈이 우리가 속한 서브시스템에 의존하는가?
if (smodule.getDependSubsystems().includes(subSysMod.getSubSystem())) { ... }

좋은 예시 — 변수로 표현:

const moduleDependees = smodule.getDependSubsystems();
const ourSubSystem = subSysMod.getSubSystem();
if (moduleDependees.includes(ourSubSystem)) { ... }

위치 표시 주석

  • 소스 파일의 특정 위치를 표시하기 위한 주석
// Actions //////////////////////////////////
  • 극히 드물게 관련 함수들을 모아놓을 때 의미가 있지만, 대부분은 제거해야 할 잡음
  • 너무 자주 사용하면 배경 소음이 되어 무시당함
  • 꼭 필요한 경우에만, 효과가 분명할 때만 사용할 것

닫는 중괄호에 다는 주석

  • 중첩이 깊고 긴 함수에서 닫는 중괄호에 주석을 다는 관행
} // while
} // try
} // if
} // main
  • 짧고 캡슐화된 함수에서는 불필요한 잡음
  • 닫는 중괄호에 주석을 달고 싶다면, 함수를 짧게 만드는 것이 올바른 해결책

저자 표시 주석

/* Added by Rick */
  • 소스 코드 관리 시스템이 누가 언제 무엇을 추가했는지 훨씬 잘 기억함
  • 이런 주석은 수년간 방치되며 점점 부정확하고 무의미해짐
  • 저자 정보는 Git blame 등 소스 관리 시스템에 맡길 것

주석으로 처리한 코드

  • 이것만큼 해로운 관행도 드물다. 절대 하지 마라.
const response = new InputStreamResponse();
response.setBody(formatter.getResultStream(), formatter.getByteCount());
// const resultsStream = formatter.getResultStream();
// const reader = new StreamReader(resultsStream);
// response.setContent(reader.read(formatter.getByteCount()));
  • 주석 처리된 코드를 본 다른 개발자는 삭제할 용기를 내지 못함 — 이유가 있겠지, 중요한 거겠지 생각하기 때문
  • 결국 주석 처리된 코드는 나쁜 와인 찌꺼기처럼 바닥에 쌓임
  • 소스 코드 관리 시스템이 코드를 기억해준다. 그냥 지워라. 절대 잃어버리지 않는다.

HTML 주석

  • 소스 코드 주석에 HTML을 넣는 것은 혐오스러운 관행
  • 주석을 가장 쉽게 읽어야 할 곳(에디터/IDE)에서 오히려 읽기 어렵게 만듦
  • Javadoc/JSDoc 도구가 HTML로 출력해야 한다면, HTML을 삽입하는 것은 도구의 책임이지 개발자의 책임이 아님

비지역적 정보

  • 주석은 근처의 코드를 설명해야 함. 시스템 전체에 대한 정보를 지역 주석에 넣으면 안 됨
/**
 * fitnesse가 실행될 포트. 기본값은 8082.
 * @param fitnessePort
 */
setFitnessePort(fitnessePort: number): void {
  this.fitnessePort = fitnessePort;
}
  • 이 함수는 기본값을 전혀 제어하지 않음. 기본값이 변경되어도 이 주석이 업데이트될 보장이 없음

너무 많은 정보

  • 역사적인 토론이나 관련 없는 세부 구현 설명을 주석에 쏟아붓지 마라
  • RFC 번호 외에 base64 인코딩 알고리즘의 24비트 그룹 처리 방식까지 주석에 넣는 것은 과잉

불분명한 관계

  • 주석과 그것이 설명하는 코드 사이의 관계는 명백해야 한다
/*
 * 모든 픽셀(+ 필터 바이트)을 담을 수 있을 만큼 크고,
 * 헤더 정보를 위해 200바이트를 추가한 배열로 시작
 */
this.pngBytes = new Uint8Array(((this.width + 1) * this.height * 3) + 200);
  • 필터 바이트가 무엇인가? +1과 관련 있는가? *3과 관련 있는가? 픽셀이 1바이트인가? 200은 왜인가?
  • 주석이 그 자체로 또 다른 설명을 필요로 한다면 실패한 주석

함수 헤더

  • 짧은 함수에는 헤더 주석이 필요 없음
  • 한 가지 일을 하는 작은 함수에 잘 선택된 이름을 붙이는 것이 주석 헤더보다 훨씬 낫다

비공개 코드의 문서 주석

  • 공개 API에는 JSDoc이 유용하지만, 외부에 공개되지 않는 내부 코드에는 불필요
  • 내부 클래스와 함수를 위한 JSDoc 페이지를 생성하는 것은 일반적으로 유용하지 않음
  • JSDoc의 형식적인 틀이 오히려 코드를 어지럽히고 집중을 방해함

핵심 요약

  • 법적 주석: 좋은 주석 / 저작권·라이선스는 필요하지만 약관 전문을 넣지 말 것
  • 정보 제공 주석: 좋은 주석 / 유용할 수 있으나 함수 이름으로 대체 가능한지 먼저 고려
  • 의도 설명 주석: 좋은 주석 / 설계 결정의 이유를 담아 독자의 이해를 도움
  • 명료화 주석: 좋은 주석 / 수정 불가한 코드의 모호함을 해소하되 정확성 검증 필수
  • 경고 주석: 좋은 주석 / 다른 개발자의 실수를 사전에 방지
  • TODO 주석: 좋은 주석 / 나쁜 코드의 핑계가 되선 안 되며 주기적으로 제거할 것
  • 중요성 강조 주석: 좋은 주석 / 사소해 보이지만 중요한 코드에 주의를 환기
  • 독백 주석: 나쁜 주석 / 작성자만 이해하고 독자는 다른 코드를 뒤져야 함
  • 중복 주석: 나쁜 주석 / 코드보다 덜 정확하고 더 읽기 어려운 설명
  • 오해 주석: 나쁜 주석 / 미묘하게 부정확한 주석이 디버깅 지옥을 만듦
  • 의무적 주석: 나쁜 주석 / 모든 함수/변수에 주석을 달아야 한다는 규칙은 어리석음
  • 이력 주석: 나쁜 주석 / Git이 있는 지금, 완전히 불필요
  • 있으나 마나한 주석: 나쁜 주석 / 자명한 내용의 반복, 무시하는 습관을 기름
  • 위치 표시 주석: 나쁜 주석 / 극히 드물게만 사용, 남용하면 배경 소음
  • 닫는 괄호 주석: 나쁜 주석 / 함수가 짧으면 불필요, 짧게 만드는 것이 해결책
  • 저자 표시 주석: 나쁜 주석 / Git blame이 더 정확하게 기억함
  • 주석 처리된 코드: 나쁜 주석 / 절대 하지 마라. Git이 기억한다. 그냥 지워라
  • HTML 주석: 나쁜 주석 / HTML 변환은 도구의 몫, 개발자가 할 일이 아님
  • 비지역적 정보: 나쁜 주석 / 주석은 근처의 코드만 설명해야 함
  • 과도한 정보: 나쁜 주석 / 역사적 토론·관련 없는 세부 내용 금지
  • 불분명한 관계: 나쁜 주석 / 주석 자체가 설명이 필요하면 실패한 주석
  • 비공개 코드의 JSDoc: 나쁜 주석 / 내부 코드에 공식 문서 주석은 불필요한 형식주의

핵심 원칙: 주석을 쓰고 싶을 때마다 그 의도를 코드로 표현할 방법을 먼저 찾아라. 코드로 표현에 성공하는 것이 진짜 실력이며, 주석은 그 실패의 흔적이다.

5장. 형식 맞추기

코드를 들여다볼 때, 사람들이 단정함, 일관성, 세부사항에 대한 주의를 보고 감탄하길 원한다. 질서 정연함에 감동받길 원한다. 만약 그들이 술 취한 선원 무리가 짠 것 같은 뒤엉킨 코드를 본다면, 프로젝트의 다른 모든 측면에도 같은 무신경함이 만연해 있다고 결론지을 것이다.

  • 코드는 반드시 깔끔하게 포맷되어야 함
  • 포맷을 지배하는 간단한 규칙을 선택하고, 그 규칙을 일관되게 적용해야 함
  • 팀에서 작업한다면 팀 전체가 동의한 단일 포맷 규칙에 모든 멤버가 따라야 함
  • 포맷 규칙을 자동으로 적용해주는 도구(ESLint + Prettier 등)를 활용하면 도움이 됨

포맷의 목적

  • 코드 포맷은 커뮤니케이션이며, 커뮤니케이션은 전문 개발자의 제1 원칙
  • “일단 돌아가게 만드는 것”이 첫 번째가 아님
  • 오늘 작성한 기능은 다음 릴리즈에서 바뀔 수 있지만, 코드의 가독성은 이후 모든 변경에 깊은 영향을 미침
  • 코딩 스타일과 가독성은 원래 코드가 형태를 알아볼 수 없을 만큼 바뀐 뒤에도 유지보수성과 확장성에 계속 영향을 줌
  • 스타일과 규율은 살아남는다, 코드 자체가 살아남지 않더라도

수직 포맷

파일 크기

  • 소스 파일은 얼마나 커야 하는가?
  • FitNesse(약 50,000줄 규모의 시스템)는 평균 65줄, 대부분 200줄 이하, 최대 500줄 이하의 파일로 구성됨
  • 작은 파일은 큰 파일보다 이해하기 쉽다는 원칙
  • 200줄 정도, 최대 500줄을 넘지 않는 것이 바람직함 (절대 규칙은 아니지만 강하게 권장)

신문 기사 은유

  • 잘 쓰인 신문 기사처럼 소스 파일을 구성할 것
    • 상단: 헤드라인 - 파일명만으로 올바른 모듈인지 판단 가능해야 함
    • 첫 부분: 고수준 개념과 알고리즘 제공
    • 아래로 내려갈수록: 세부사항 증가, 최저수준 함수와 디테일로 마무리
  • 신문은 수많은 짧은 기사들로 구성됨 - 하나의 긴 이야기 덩어리가 아님
  • 파일도 마찬가지: 한 가지 개념, 위에서 아래로 자연스럽게 읽히는 구조

개념 사이의 수직적 여백

  • 코드는 왼쪽에서 오른쪽, 위에서 아래로 읽힘
  • 각 줄은 표현식 또는 절을 나타내고, 각 줄 그룹은 완결된 하나의 생각을 표현
  • 생각들은 빈 줄로 분리되어야 함
// 좋은 예: 패키지 선언, import, 각 함수 사이에 빈 줄 존재
import { Pattern } from './pattern';
import { ParentWidget } from './parentWidget';

export class BoldWidget extends ParentWidget {
  static readonly REGEXP = "'''.+?'''";
  private pattern: Pattern;

  constructor(parent: ParentWidget, text: string) {
    super(parent);
    const match = this.pattern.exec(text);
    this.addChildWidgets(match![1]);
  }

  render(): string {
    return `<b>${this.childHtml()}</b>`;
  }
}
// 나쁜 예: 빈 줄이 없으면 코드 덩어리가 뭉개져 읽기 어려움
import { Pattern } from './pattern';
import { ParentWidget } from './parentWidget';
export class BoldWidget extends ParentWidget {
  static readonly REGEXP = "'''.+?'''";
  private pattern: Pattern;
  constructor(parent: ParentWidget, text: string) {
    super(parent);
    const match = this.pattern.exec(text);
    this.addChildWidgets(match![1]);
  }
  render(): string {
    return `<b>${this.childHtml()}</b>`;
  }
}
  • 눈의 초점을 흐리게 하면 차이가 극명하게 드러남 - 빈 줄이 있는 코드는 그룹이 튀어나오고, 없는 코드는 흐릿한 덩어리로 보임

수직 밀도

  • 개방성이 개념을 분리한다면, 수직 밀도는 밀접한 연관을 의미
  • 서로 긴밀하게 관련된 코드 줄들은 수직으로 촘촘하게 배치해야 함
// 나쁜 예: 불필요한 주석이 두 인스턴스 변수의 밀접한 연관을 끊어버림
class ReporterConfig {
  /**
   * 리포터 리스너의 클래스 이름
   */
  private className: string;

  /**
   * 리포터 리스너의 프로퍼티
   */
  private properties: Property[] = [];

  addProperty(property: Property): void {
    this.properties.push(property);
  }
}
// 좋은 예: 관련 변수들이 밀집되어 한눈에 파악 가능
class ReporterConfig {
  private className: string;
  private properties: Property[] = [];

  addProperty(property: Property): void {
    this.properties.push(property);
  }
}

수직 거리

  • 밀접하게 관련된 개념들은 서로 수직으로 가까이 두어야 함 [G10]
  • 독자가 소스 파일 여기저기를 뛰어다니게 만들면 안 됨
  • 이 규칙은 별도 파일에 속하는 개념에는 적용 불가 - 밀접한 개념이라면 정당한 이유 없이 다른 파일로 분리하지 말 것

변수 선언(Variable Declarations)

  • 변수는 사용하는 곳과 최대한 가까이 선언
  • 함수가 짧다면, 로컬 변수는 각 함수의 상단에 선언
function readPreferences(): void {
  let stream: NodeJS.ReadableStream | null = null;
  try {
    stream = fs.createReadStream(getPreferencesFile());
    setPreferences(new Properties(getPreferences()));
    getPreferences().load(stream);
  } catch (e) {
    if (stream !== null) {
      try { stream.destroy(); } catch {}
    }
  }
}
  • 루프 제어 변수는 루프 구문 내에 선언
function countTestCases(): number {
  let count = 0;
  for (const test of tests) {
    count += test.countTestCases();
  }
  return count;
}
  • 인스턴스 변수는 클래스 최상단에 선언
    • 잘 설계된 클래스에서는 대부분의 메서드가 인스턴스 변수를 사용하므로 수직 거리가 증가하지 않음
    • “어디서 인스턴스 변수를 찾을 수 있는가”를 누구나 알 수 있어야 함
    • 클래스 중간에 숨겨진 인스턴스 변수는 독자가 우연히 발견해야만 하는 함정

종속 함수(Dependent Functions)

  • 한 함수가 다른 함수를 호출한다면 두 함수는 수직으로 가까이 있어야 함
  • 호출하는 함수(caller)가 호출되는 함수(callee) 위에 위치해야 함
  • 이 규칙을 일관되게 따르면, 독자는 함수 정의가 사용 직후에 나올 것임을 신뢰하게 됨
// 좋은 예: makeResponse -> getPageNameOrDefault -> loadPage 순으로 자연스러운 흐름
class WikiPageResponder {
  makeResponse(context: FitNesseContext, request: Request): Response {
    const pageName = this.getPageNameOrDefault(request, 'FrontPage');
    this.loadPage(pageName, context);
    if (!this.page) return this.notFoundResponse(context, request);
    return this.makePageResponse(context);
  }

  private getPageNameOrDefault(request: Request, defaultPageName: string): string {
    const pageName = request.getResource();
    return pageName.trim() === '' ? defaultPageName : pageName;
  }

  private loadPage(resource: string, context: FitNesseContext): void {
    // ...
  }
  // ...
}

인사이트: 'FrontPage'와 같은 상수는 실제로 사용하는 저수준 함수 안에 묻히는 것보다, 의미가 통하는 수준(호출하는 쪽)에서 전달되는 것이 더 적절하다 [G35].

개념적 유사성(Conceptual Affinity)

  • 서로 끌어당기는 힘이 있는 코드는 가까이 있어야 함
  • 직접 호출 관계 외에도, 비슷한 동작을 수행하는 함수 그룹은 유사성을 가짐
// 공통 명명 규칙을 공유하고 같은 기본 작업의 변형을 수행하는 함수들 - 호출 관계 없어도 함께 있어야 함
class Assert {
  static assertTrue(condition: boolean, message?: string): void {
    if (!condition) this.fail(message ?? '');
  }

  static assertFalse(condition: boolean, message?: string): void {
    this.assertTrue(!condition, message);
  }
  // ...
}

수직 순서

  • 함수 호출 의존성은 아래 방향을 가리켜야 함 - 호출되는 함수는 호출하는 함수 아래에
  • 소스 코드 모듈이 고수준에서 저수준으로 흐르는 자연스러운 흐름 생성
  • 신문 기사처럼 가장 중요한 개념이 먼저, 저수준 세부사항은 마지막에
  • 파일을 훑을 때 처음 몇 개의 함수만 봐도 전체 맥락을 파악할 수 있음

수평 포맷

줄 길이

  • 분석 결과, 프로그래머들은 짧은 줄을 선호
  • 20~60자 범위의 줄이 전체의 약 40%를 차지
  • 과거의 80자 제한은 다소 임의적이나, 100~120자 정도가 적절
  • 120자를 넘는 것은 부주의한 것

수평 여백과 밀도

  • 수평 공백은 강하게 연관된 것을 연결하고, 약하게 연관된 것을 분리하는 데 사용
// 할당 연산자 양쪽에 공백 -> 좌변과 우변이라는 두 주요 요소를 명확히 분리
private measureLine(line: string): void {
  this.lineCount++;
  const lineSize = line.length;
  this.totalChars += lineSize;
  this.lineWidthHistogram.addLine(lineSize, this.lineCount);
  this.recordWidestLine(lineSize);
}
  • 함수명과 여는 괄호 사이에는 공백을 두지 않음 - 함수와 인수는 밀접하게 연관되어 있기 때문
  • 인수 사이의 쉼표 뒤에는 공백 - 인수들이 별개임을 강조
// 연산자 우선순위를 공백으로 표현
class Quadratic {
  static root1(a: number, b: number, c: number): number {
    const det = this.determinant(a, b, c);
    return (-b + Math.sqrt(det)) / (2*a); // 곱셈은 공백 없음(고우선순위), 덧셈/나눗셈은 공백(저우선순위)
  }

  private static determinant(a: number, b: number, c: number): number {
    return b*b - 4*a*c;
  }
}

주의: 대부분의 코드 포맷터는 연산자 우선순위를 고려하지 않고 일괄 공백을 적용하므로, 위와 같은 섬세한 간격은 자동 포맷 후 사라지는 경향이 있음

수평 정렬

  • 변수 선언이나 대입문을 세로로 정렬하는 것은 유용하지 않음
// 나쁜 예: 수평 정렬은 잘못된 것을 강조하게 만듦 - 타입보다 변수명 목록에 눈이 가고, 연산자보다 우변 값에 눈이 가게 됨
class FitNesseExpediter {
  private socket              : Socket;
  private input               : Readable;
  private output              : Writable;
  private request             : Request;
  private response            : Response;
  private requestParsingLimit : number;
}
// 좋은 예: 정렬하지 않으면 오히려 중요한 결함이 보임 - 선언 목록이 길다면 클래스를 분리해야 한다는 신호
class FitNesseExpediter {
  private socket: Socket;
  private input: Readable;
  private output: Writable;
  private request: Request;
  private response: Response;
  private requestParsingLimit: number;
}
  • 선언 목록이 너무 길다면 → 문제는 정렬의 부재가 아니라 목록의 길이 자체, 즉 클래스 분리 신호

들여쓰기

  • 소스 파일은 계층적 윤곽 구조
    • 파일 전체에 해당하는 정보
    • 파일 내 개별 클래스에 해당하는 정보
    • 클래스 내 메서드에 해당하는 정보
    • 메서드 내 블록에 해당하는 정보
    • 블록 안의 블록 (재귀적으로)
  • 각 계층의 범위를 시각적으로 드러내기 위해 들여쓰기 사용
  • 프로그래머는 들여쓰기에 매우 의존함 - 들여쓰기 없는 코드는 인간이 사실상 읽을 수 없음
// 나쁜 예: 들여쓰기 없음 - 구조 파악이 거의 불가능
class FitNesseServer { private context: FitNesseContext; constructor(context: FitNesseContext) { this.context = context; } serve(s: Socket): void { this.serveWithTimeout(s, 10000); } serveWithTimeout(s: Socket, timeout: number): void { try { const sender = new FitNesseExpediter(s, this.context); sender.setRequestParsingTimeLimit(timeout); sender.start(); } catch(e) { console.error(e); } } }
// 좋은 예: 들여쓰기로 구조가 한눈에 보임
class FitNesseServer {
  private context: FitNesseContext;

  constructor(context: FitNesseContext) {
    this.context = context;
  }

  serve(s: Socket): void {
    this.serveWithTimeout(s, 10000);
  }

  serveWithTimeout(s: Socket, timeout: number): void {
    try {
      const sender = new FitNesseExpediter(s, this.context);
      sender.setRequestParsingTimeLimit(timeout);
      sender.start();
    } catch (e) {
      console.error(e);
    }
  }
}

들여쓰기 규칙 파괴(Breaking Indentation)

  • 짧은 if, 짧은 while, 짧은 함수라도 들여쓰기 규칙을 무너뜨리려는 유혹을 피할 것
// 나쁜 예: 스코프를 한 줄로 축소
class CommentWidget extends TextWidget {
  static readonly REGEXP = '^#[^\r\n]*(?:(?:\r\n)|\n|\r)?';
  constructor(parent: ParentWidget, text: string) { super(parent, text); }
  render(): string { return ''; }
}
// 좋은 예: 스코프를 명확히 전개하고 들여쓰기 적용
class CommentWidget extends TextWidget {
  static readonly REGEXP = '^#[^\r\n]*(?:(?:\r\n)|\n|\r)?';

  constructor(parent: ParentWidget, text: string) {
    super(parent, text);
  }

  render(): string {
    return '';
  }
}

더미 스코프

  • while이나 for의 바디가 더미(빈 것)인 구조는 가능하면 피할 것
  • 피할 수 없다면, 더미 바디를 적절히 들여쓰고 중괄호로 감싸야
  • 세미콜론이 while 루프와 같은 줄에 조용히 앉아 있으면 눈에 잘 띄지 않아 버그 유발
// 나쁜 예: 세미콜론이 같은 줄에 숨어있음
while (buffer.read(buf, 0, readBufferSize) !== -1);

// 좋은 예: 더미 바디가 명확히 보임
while (buffer.read(buf, 0, readBufferSize) !== -1) {
  // intentionally empty
}

팀 규칙

  • 제목 자체가 말장난: 모든 프로그래머는 자신만의 포맷 규칙을 갖고 있지만, 팀에서 일한다면 팀의 규칙을 따른다
  • 개발자 팀은 단일 포맷 스타일에 합의하고, 모든 구성원이 그 스타일을 따라야 함
  • 소프트웨어는 일관된 스타일을 가져야 함 - 여러 개인의 의견 충돌로 쓰인 것처럼 보이면 안 됨
  • FitNesse 프로젝트 예시: 팀이 10분 만에 중괄호 위치, 들여쓰기 크기, 클래스·변수·메서드 명명 방식에 합의 → IDE 포맷터에 인코딩하여 일관되게 유지
  • 이 규칙들이 내가 선호하는 규칙이 아니었다 - 팀이 결정한 규칙이었고, 팀의 일원으로서 따랐음

좋은 소프트웨어 시스템은 읽기 좋은 문서들의 집합이다. 하나의 소스 파일에서 본 포맷 신호가 다른 파일에서도 동일한 의미를 가짐을 독자가 신뢰할 수 있어야 한다. 소스 코드에 다양한 개인 스타일의 혼재라는 복잡성을 더하는 것은 최악이다.

Uncle Bob의 포맷팅 규칙

  • 포맷 규칙은 단순하게 유지
  • 코드 자체가 최고의 코딩 표준 문서라는 철학
  • Listing 5-6 (CodeAnalyzer.java)을 TypeScript로 재구성한 예:
import * as fs from 'fs';
import * as readline from 'readline';

class LineWidthHistogram {
  private lines: Map<number, number[]> = new Map();

  addLine(lineSize: number, lineCount: number): void {
    if (!this.lines.has(lineSize)) this.lines.set(lineSize, []);
    this.lines.get(lineSize)!.push(lineCount);
  }

  getLinesForWidth(width: number): number[] {
    return this.lines.get(width) ?? [];
  }

  getWidths(): Set<number> {
    return new Set(this.lines.keys());
  }
}

export class CodeAnalyzer {
  private lineCount = 0;
  private maxLineWidth = 0;
  private widestLineNumber = 0;
  private lineWidthHistogram = new LineWidthHistogram();
  private totalChars = 0;

  async analyzeFile(filePath: string): Promise<void> {
    const rl = readline.createInterface({ input: fs.createReadStream(filePath) });
    for await (const line of rl) {
      this.measureLine(line);
    }
  }

  private measureLine(line: string): void {
    this.lineCount++;
    const lineSize = line.length;
    this.totalChars += lineSize;
    this.lineWidthHistogram.addLine(lineSize, this.lineCount);
    this.recordWidestLine(lineSize);
  }

  private recordWidestLine(lineSize: number): void {
    if (lineSize > this.maxLineWidth) {
      this.maxLineWidth = lineSize;
      this.widestLineNumber = this.lineCount;
    }
  }

  getLineCount(): number { return this.lineCount; }
  getMaxLineWidth(): number { return this.maxLineWidth; }
  getWidestLineNumber(): number { return this.widestLineNumber; }
  getLineWidthHistogram(): LineWidthHistogram { return this.lineWidthHistogram; }
  getMeanLineWidth(): number { return this.totalChars / this.lineCount; }

  getMedianLineWidth(): number {
    const sortedWidths = this.getSortedWidths();
    let cumulativeLineCount = 0;
    for (const width of sortedWidths) {
      cumulativeLineCount += this.lineWidthHistogram.getLinesForWidth(width).length;
      if (cumulativeLineCount > this.lineCount / 2) return width;
    }
    throw new Error('Cannot get here');
  }

  private getSortedWidths(): number[] {
    return Array.from(this.lineWidthHistogram.getWidths()).sort((a, b) => a - b);
  }
}

핵심 요약

  • 포맷의 목적: 코드 포맷은 커뮤니케이션이다. 기능 구현보다 우선시해야 할 전문 개발자의 제1 원칙
  • 파일 크기: 200줄 내외, 최대 500줄을 목표로. 작은 파일이 이해하기 쉽다
  • 신문 은유: 파일명이 헤드라인, 위쪽이 고수준 개념, 아래로 갈수록 세부사항
  • 수직 여백: 빈 줄은 개념의 분리를 나타내는 시각적 신호
  • 수직 밀도: 밀접한 코드는 촘촘하게. 불필요한 주석이 밀접성을 파괴
  • 수직 거리: 관련 개념은 가까이. caller는 callee 위에
  • 수평 공백: 강하게 연관된 것은 붙이고, 약하게 연관된 것은 띄움
  • 수평 정렬: 변수를 세로로 정렬하는 것은 잘못된 곳에 눈을 끌어 오히려 해가 됨
  • 들여쓰기: 들여쓰기는 계층 구조를 시각화하는 핵심 도구. 짧아도 절대 생략하지 말 것
  • 팀 규칙: 개인 선호보다 팀 합의가 우선. 일관성이 핵심

6장. 객체와 자료 구조

변수를 private으로 선언하는 데는 이유가 있다. 우리는 다른 누군가가 그것에 의존하길 원하지 않는다. 타입이나 구현을 언제든 자유롭게 바꿀 수 있길 원한다. 그렇다면 왜 그토록 많은 프로그래머들이 자동으로 getter/setter를 추가하여, private 변수를 마치 public인 양 노출시키는가?

데이터 추상화

  • 구체적(Concrete) 구현 vs 추상적(Abstract) 인터페이스의 차이가 핵심
    • 아래 두 타입은 모두 2D 좌표를 표현하지만, 성격이 전혀 다름
// 나쁜 예: 구체적 Point - 직교 좌표계(rectangular)로 구현되었음이 명백히 드러남
class Point {
  public x: number;
  public y: number;
}
// 좋은 예: 추상적 Point - 직교 좌표계인지 극 좌표계인지 알 수 없음
interface Point {
  getX(): number;
  getY(): number;
  setCartesian(x: number, y: number): void;
  getR(): number;
  getTheta(): number;
  setPolar(r: number, theta: number): void;
}
  • 추상적 Point가 우수한 이유

    • 내부가 직교 좌표계인지, 극 좌표계인지, 아예 다른 방식인지 알 수 없음
    • 인터페이스가 접근 정책(access policy) 을 강제함: 개별 좌표는 독립 읽기 가능하지만, 쓰기는 반드시 하나의 원자적 연산(setCartesian 또는 setPolar)으로만 가능
    • 구체적 Point는 변수가 private이고 getter/setter가 있더라도 구현이 노출되는 것과 마찬가지
  • 연료 레벨 예시로 보는 추상화 수준

// 나쁜 예: 구현 세부사항 노출 - getter임이 명백
interface ConcreteVehicle {
  getFuelTankCapacityInGallons(): number;
  getGallonsOfGasoline(): number;
}
// 좋은 예: 데이터 형태를 전혀 알 수 없음
interface AbstractVehicle {
  getPercentFuelRemaining(): number;
}
  • 핵심 인사이트
    • ==추상화는 단순히 인터페이스나 getter/setter를 씌우는 것이 아니다==
    • 객체가 담고 있는 데이터를 표현하는 최선의 방법에 대한 진지한 고민이 필요
    • 생각 없이 getter/setter를 추가하는 것은 최악의 선택

데이터/객체 비대칭

  • 객체와 데이터 구조의 정의는 서로 정반대(virtual opposites)

    • 객체(Object): 데이터를 추상화 뒤에 숨기고, 데이터를 다루는 함수만 공개
    • 데이터 구조(Data Structure): 데이터를 공개하며, 의미 있는 함수가 없음
  • 절차적(Procedural) 방식 예시

// 데이터 구조들: 행동 없이 데이터만 노출
interface Point { x: number; y: number; }

class Square { topLeft: Point; side: number; }
class Rectangle { topLeft: Point; height: number; width: number; }
class Circle { center: Point; radius: number; }

// 모든 행동은 Geometry에 집중
class Geometry {
  private readonly PI = Math.PI;

  area(shape: Square | Rectangle | Circle): number {
    if (shape instanceof Square) return shape.side ** 2;
    if (shape instanceof Rectangle) return shape.height * shape.width;
    if (shape instanceof Circle) return this.PI * shape.radius ** 2;
    throw new Error("Unknown shape");
  }
}
  • 객체지향(OO) 방식 예시
interface Shape {
  area(): number;
}

class Square implements Shape {
  constructor(private topLeft: Point, private side: number) {}
  area(): number { return this.side ** 2; }
}

class Rectangle implements Shape {
  constructor(private topLeft: Point, private height: number, private width: number) {}
  area(): number { return this.height * this.width; }
}

class Circle implements Shape {
  constructor(private center: Point, private radius: number) {}
  area(): number { return Math.PI * this.radius ** 2; }
}
  • 두 방식의 트레이드오프 비교
상황 절차적 방식 (데이터 구조) OO 방식 (객체)
새로운 함수 추가 쉬움 - 기존 데이터 구조 변경 불필요 어려움 - 모든 클래스 변경 필요
새로운 타입 추가 어려움 - 모든 함수 변경 필요 쉬움 - 기존 함수 변경 불필요
  • 핵심 인사이트
    • “모든 것은 객체여야 한다”는 것은 미신이다
    • 새로운 데이터 타입을 추가할 가능성이 높을 때: OO 방식이 적합
    • 새로운 함수(동작) 를 추가할 가능성이 높을 때: 절차적 방식과 데이터 구조가 적합
    • 성숙한 프로그래머는 편견 없이 상황에 맞는 방식을 선택함

디미터 법칙

  • 정의: 모듈은 자신이 조작하는 객체의 내부 구조를 알아서는 안 된다

    • 객체는 내부 구조를 accessor로 드러내지 않아야 함
    • 드러내는 순간 감추는 게 아니라 오히려 노출하는 것
  • 메서드 f가 클래스 C 안에 있을 때, f는 다음 객체의 메서드만 호출할 수 있음

    1. C 자기 자신
    2. f가 직접 생성한 객체
    3. f의 인수로 전달된 객체
    4. C의 인스턴스 변수로 보유한 객체
    • 허용된 함수가 반환한 객체의 메서드는 호출하면 안 됨 - “친구에게만 말하고, 낯선 이에게는 말하지 마라”

기차 충돌

  • 연쇄 호출(method chaining)은 기차 충돌처럼 보여 피해야 함
// 나쁜 예: 디미터 법칙 위반 가능성 높음
const outputDir = ctxt.getOptions().getScratchDir().getAbsolutePath();
// 개선: 각 단계를 분리
const opts = ctxt.getOptions();
const scratchDir = opts.getScratchDir();
const outputDir = scratchDir.getAbsolutePath();
  • 그런데 이것도 디미터 위반인가?
    • ctxt, Options, ScratchDir객체라면: 내부 구조가 숨겨져야 하므로, 위 코드는 명백히 디미터 위반
    • ctxt, Options, ScratchDir데이터 구조라면: 내부 구조를 자연스럽게 드러내므로, 디미터 적용 대상이 아님
    • 만약 아래처럼 accessor 없이 작성됐다면 혼란 자체가 없었을 것
// 데이터 구조라면 이렇게 쓰는 것이 더 명확
const outputDir = ctxt.options.scratchDir.absolutePath;
  • accessor 함수가 문제를 혼란스럽게 만든다
    • 데이터 구조는 public 변수만 갖고 함수가 없어야 함
    • 객체는 private 변수와 public 함수를 가져야 함
    • 하지만 “bean” 같은 프레임워크/표준이 단순 데이터 구조에도 accessor를 요구하면서 혼란이 생김

혼합체

  • 혼합체: 절반은 객체, 절반은 데이터 구조인 최악의 설계

    • 의미 있는 함수도 있으면서, public 변수나 public getter/setter로 private 변수를 사실상 공개
    • 외부 함수가 데이터 구조처럼 내부 변수를 사용하도록 유혹함
  • 혼합체의 문제점

    • 새로운 함수 추가도 어렵고, 새로운 데이터 타입 추가도 어려움
    • 양쪽의 단점만 모은 최악의 조합
    • 함수로부터 보호가 필요한지, 타입으로부터 보호가 필요한지 확신하지 못하는 혼란스러운 설계의 징후

구조 숨기기

  • ctxt가 진짜 객체라면, 내부를 탐색하게 해서는 안 됨
// 나쁜 예 1: ctxt에 메서드가 폭발적으로 늘어남
ctxt.getAbsolutePathOfScratchDirectoryOption();

// 나쁜 예 2: getScratchDirectoryOption()이 객체가 아닌 데이터 구조를 반환한다고 가정
ctx.getScratchDirectoryOption().getAbsolutePath();
  • 올바른 접근: 객체에게 “무엇을 하라”고 명령하라
    • 목적을 파악하라: 스크래치 디렉터리의 절대 경로가 필요했던 이유는 특정 이름의 스크래치 파일을 생성하기 위해서였음
// 좋은 예: ctxt에게 직접 작업을 시킴 - 내부 구조 노출 없음
const bos = ctxt.createScratchFileStream(classFileName);

데이터 전달 객체

  • DTO의 정의: public 변수만 있고 함수가 없는 클래스 - 데이터 구조의 전형적인 형태

  • DTO의 활용처

    • 데이터베이스와 통신할 때
    • 소켓으로부터 메시지를 파싱할 때
    • DB의 raw 데이터를 애플리케이션 코드의 객체로 변환하는 번역 단계의 첫 번째 단계로 활용
  • Bean 형태: private 변수 + getter/setter 조합

    • OO 순수주의자들이 선호하지만, 실질적인 이점은 없음
    • getter/setter의 “유사 캡슐화(quasi-encapsulation)“는 기분만 좋게 할 뿐
// Bean 형태의 DTO 예시
class Address {
  constructor(
    private street: string,
    private streetExtra: string,
    private city: string,
    private state: string,
    private zip: string,
  ) {}

  getStreet(): string { return this.street; }
  getStreetExtra(): string { return this.streetExtra; }
  getCity(): string { return this.city; }
  getState(): string { return this.state; }
  getZip(): string { return this.zip; }
}

액티브 레코드

  • Active Record: DTO의 특수한 형태

    • public(또는 bean 방식) 변수를 가지며, save()find() 같은 탐색용 메서드도 포함
    • 일반적으로 DB 테이블 또는 다른 데이터 소스와 직접 대응
  • 흔한 실수: Active Record를 객체처럼 취급하는 것

    • 비즈니스 규칙 메서드를 Active Record에 추가하면 데이터 구조와 객체가 섞인 혼합체가 됨
  • 올바른 사용법

    • Active Record는 데이터 구조로 취급
    • 비즈니스 규칙은 Active Record 인스턴스를 내부에 숨기는 별도의 객체에 위치시킴
// 잘못된 예: Active Record에 비즈니스 로직 혼재
class UserRecord {
  public id: number;
  public email: string;
  public password: string;

  save() { /* DB 저장 */ }
  find(id: number) { /* DB 조회 */ }

  // 비즈니스 로직이 여기 있으면 안 됨
  isEligibleForPremium(): boolean { /* ... */ }
}

// 좋은 예: 역할 분리
class UserRecord {
  public id: number;
  public email: string;
  public password: string;

  save() { /* DB 저장 */ }
  find(id: number) { /* DB 조회 */ }
}

class User {
  constructor(private record: UserRecord) {}

  isEligibleForPremium(): boolean { /* 비즈니스 로직만 */ }
}

정리

  • 객체와 데이터 구조의 특성 요약
객체 (Object) 데이터 구조 (Data Structure)
데이터 숨김 공개
행동(함수) 공개 없음
새 타입 추가 쉬움 어려움
새 행동 추가 어려움 쉬움
  • 선택 기준

    • 새로운 데이터 타입의 유연성이 필요한 부분: 객체 선호
    • 새로운 행동(동작) 의 유연성이 필요한 부분: 데이터 구조와 절차적 코드 선호
  • 핵심 인사이트

    • 좋은 개발자는 편견 없이 상황에 가장 적합한 접근 방식을 선택한다
    • 모든 것을 객체로 만들어야 한다는 강박을 버려라

핵심 요약

  • 데이터 추상화: getter/setter만 추가한다고 추상화가 아니다. 데이터의 본질을 드러내는 인터페이스를 고민하라
  • 객체 vs 데이터 구조: 두 개념은 정반대다. 상황에 맞게 골라 써라
  • 절차적 vs OO 트레이드오프: OO는 타입 추가에 강하고, 절차적은 함수 추가에 강하다
  • 디미터 법칙: 친구에게만 말하라. 반환된 객체의 메서드를 연쇄 호출하지 마라
  • Train Wreck: 연쇄 메서드 호출은 객체의 내부를 너무 많이 알게 된다는 신호다
  • 구조 숨기기: 객체에게 “이걸 해줘”라고 명령하라. 내부를 꺼내서 직접 처리하지 마라
  • 혼합체(Hybrid): 객체와 데이터 구조의 나쁜 점만 모은 최악의 설계. 반드시 피하라
  • DTO: 함수 없이 데이터만 노출하는 순수한 데이터 구조. DB 번역 첫 단계에 유용
  • Active Record: DTO의 일종. 비즈니스 로직을 넣으면 혼합체가 된다. 별도 객체로 분리하라

7장. 오류 처리

에러 처리는 중요하다. 하지만 로직을 가린다면, 그것은 잘못된 것이다.

  • 에러 처리는 프로그래밍에서 반드시 해야 하는 일
    • 입력이 비정상적일 수 있고, 디바이스가 실패할 수 있음
    • 문제가 발생했을 때 코드가 제 역할을 하도록 보장하는 것은 프로그래머의 책임
  • 많은 코드베이스가 에러 처리 코드로 도배되어 있음
    • “도배”란 에러 처리가 전부라는 뜻이 아니라, 코드 여기저기에 흩어져 핵심 로직이 보이지 않는 상태를 의미
  • 에러 처리는 별개의 관심사(Separate Concern)로 취급해야 한다
    • 비즈니스 로직과 에러 처리가 섞이면 코드가 복잡해지고 가독성이 떨어짐

반환 코드보다 예외를 사용하라

  • 과거 방식: 리턴 코드 또는 에러 플래그
    • 에러 플래그를 세우거나, 호출자가 직접 확인해야 하는 에러 코드를 반환
    • 문제: 호출자 코드가 지저분해지고, 에러 체크를 잊어버리기 쉬움
// 나쁜 예: 리턴 코드 방식
function sendShutDown(): void {
  const handle = getHandle(DEV1);
  if (handle !== DeviceHandle.INVALID) {
    retrieveDeviceRecord(handle);
    if (record.getStatus() !== DEVICE_SUSPENDED) {
      pauseDevice(handle);
      clearDeviceWorkQueue(handle);
      closeDevice(handle);
    } else {
      logger.log("Device suspended. Unable to shut down");
    }
  } else {
    logger.log("Invalid handle for: " + DEV1.toString());
  }
}
  • 개선 방식: 예외(Exception) 던지기
    • 에러가 발생하면 예외를 throw하면 호출 코드가 훨씬 깔끔해짐
    • 알고리즘(device shutdown)과 에러 처리가 분리되어 각각 독립적으로 이해 가능
// 좋은 예: 예외 방식
function sendShutDown(): void {
  try {
    tryToShutDown();
  } catch (e) {
    if (e instanceof DeviceShutDownError) {
      logger.log(e);
    }
  }
}

function tryToShutDown(): void {
  const handle = getHandle(DEV1);
  const record = retrieveDeviceRecord(handle);
  pauseDevice(handle);
  clearDeviceWorkQueue(handle);
  closeDevice(handle);
}

function getHandle(id: DeviceID): DeviceHandle {
  // ...
  throw new DeviceShutDownError("Invalid handle for: " + id.toString());
}
  • 핵심 인사이트: 이건 단순히 미관의 문제가 아님. 두 가지 관심사(알고리즘, 에러 처리)가 분리되어 코드가 실질적으로 좋아지는 것

Try-Catch-Finally 문부터 작성하라

  • try 블록은 트랜잭션(transaction) 과 같다
    • catchtry 안에서 무슨 일이 있었든 프로그램을 일관된 상태(consistent state) 로 남겨야 함
  • 예외를 던질 수 있는 코드를 작성할 때는 try-catch-finally부터 작성하라
    • try 블록의 트랜잭션 스코프를 먼저 정의함으로써, 호출자가 무엇을 기대해야 하는지 명확히 할 수 있음
  • 권장 개발 순서 (TDD 방식)
    1. 예외가 발생하는 상황을 강제하는 테스트를 먼저 작성
    2. 테스트가 실패하도록 최소 구현 작성
    3. 예외를 실제로 던지도록 구현 수정
    4. 예외 타입을 점차 좁혀가며 리팩토링
// 1단계: 더미 구현 — 예외 없음 → 테스트 실패
function retrieveSection(sectionName: string): RecordedGrip[] {
  return [];
}

// 2단계: 실제 파일 접근 시도 → 예외 발생 → catch에서 래핑
function retrieveSection(sectionName: string): RecordedGrip[] {
  try {
    const stream = fs.createReadStream(sectionName);
    stream.close();
  } catch (e) {
    throw new StorageException("retrieval error", e as Error);
  }
  return [];
}
  • 이렇게 하면 try 블록의 트랜잭션 스코프를 먼저 확보하고, 그 안의 세부 로직을 안전하게 채워나갈 수 있음

검사되지 않는 예외를 사용하라

  • Checked Exception 논쟁은 끝났다 — Checked Exception은 득보다 실이 많다
  • Checked Exception의 문제점: OCP(Open/Closed Principle) 위반
    • 저수준 함수에서 Checked Exception을 던지면, 그 함수를 호출하는 모든 상위 레벨 함수의 시그니처에 해당 예외가 선언되어야 함
    • 저수준 변경 하나가 연쇄적인 시그니처 변경을 전파시킴
    • 캡슐화 파괴: 예외를 통과시키는 모든 함수가 저수준 예외의 세부 사항을 알아야 함
저수준 함수 변경
  → 중간 레벨 함수 시그니처 변경
    → 상위 레벨 함수 시그니처 변경
      → ... (ad infinitum)
  • C#, C++, Python, Ruby 모두 Checked Exception이 없지만 견고한 소프트웨어를 만들 수 있음
  • 예외: 크리티컬한 라이브러리 작성 시에는 Checked Exception이 유용할 수 있음. 그러나 일반 애플리케이션 개발에서는 의존성 비용이 이점을 초과
  • TypeScript/Node.js는 애초에 Checked Exception 개념 자체가 없다 — 모든 예외는 Unchecked이므로 이 원칙을 자연스럽게 따를 수 있음

예외에 맥락을 제공하라

  • 예외를 던질 때는 충분한 컨텍스트를 함께 제공해야 한다
    • 스택 트레이스만으로는 실패한 오퍼레이션의 의도를 알 수 없음
  • 에러 메시지에 포함해야 할 것
    • 실패한 오퍼레이션이 무엇인지
    • 어떤 종류의 실패인지
    • 로깅에 충분한 정보
// 나쁜 예
throw new Error("failed");

// 좋은 예
throw new StorageException(
  `[FileStore.retrieveSection] 파일 읽기 실패 — sectionName: "${sectionName}", reason: ${(e as Error).message}`
);

호출자 관점으로 예외 클래스를 정의하라

  • 에러를 분류하는 방법은 다양하지만 (출처별, 유형별), 가장 중요한 기준은 “어떻게 catch될 것인가” 이다
  • 나쁜 예: 서드파티 예외를 그대로 여러 개 catch
// 나쁜 예: 중복된 처리, 서드파티 API에 강하게 결합
const port = new ACMEPort(12);
try {
  port.open();
} catch (e) {
  if (e instanceof DeviceResponseException) {
    reportPortError(e);
    logger.log("Device response exception", e);
  } else if (e instanceof ATM1212UnlockedException) {
    reportPortError(e);
    logger.log("Unlock exception", e);
  } else if (e instanceof GMXError) {
    reportPortError(e);
    logger.log("Device response exception");
  }
}
  • 좋은 예: 서드파티 API를 래핑하여 공통 예외 타입으로 변환
// 호출하는 쪽이 훨씬 깔끔해짐
const port = new LocalPort(12);
try {
  port.open();
} catch (e) {
  if (e instanceof PortDeviceFailure) {
    reportError(e);
    logger.log(e.message, e);
  }
}

// 래퍼 클래스: 모든 서드파티 예외를 단일 예외로 변환
class LocalPort {
  private innerPort: ACMEPort;

  constructor(portNumber: number) {
    this.innerPort = new ACMEPort(portNumber);
  }

  open(): void {
    try {
      this.innerPort.open();
    } catch (e) {
      throw new PortDeviceFailure(e as Error);
    }
  }
}
  • 서드파티 API 래핑의 장점
    • 서드파티 의존성 최소화 → 나중에 라이브러리를 교체해도 영향 범위가 래퍼 하나에 한정됨
    • 테스트 시 서드파티 호출을 mock하기 쉬워짐
    • 특정 벤더의 API 설계 결정에 종속되지 않음 → 편안한 인터페이스를 직접 정의 가능
  • 한 영역의 코드에는 단일 예외 클래스 하나로 충분한 경우가 많음
    • 예외를 구분해야 할 경우는, 특정 예외만 catch하고 나머지는 통과시켜야 할 때뿐

정상 흐름을 정의하라

  • 앞선 조언들을 따르면 비즈니스 로직과 에러 처리가 잘 분리됨
  • 하지만 때로는 중단(abort)하지 않는 것이 더 나은 경우가 있음
  • 나쁜 예: 예외가 정상 흐름 로직을 오염시키는 경우
// 나쁜 예
try {
  const expenses = expenseReportDAO.getMeals(employee.getID());
  totalAmount += expenses.getTotal();
} catch (e) {
  if (e instanceof MealExpensesNotFound) {
    totalAmount += getMealPerDiem();
  }
}
  • Special Case Pattern (Fowler)
    • 예외 대신, 특수 케이스를 처리하는 객체나 클래스를 만들어 클라이언트 코드가 예외적 동작을 다룰 필요가 없게 함
    • 예외적 동작이 Special Case 객체 내부에 캡슐화
// 좋은 예: Special Case Pattern
// DAO는 항상 유효한 MealExpenses 객체를 반환함
const expenses = expenseReportDAO.getMeals(employee.getID());
totalAmount += expenses.getTotal(); // 예외 없이 항상 동작

// 식비가 없을 때 반환되는 Special Case 객체
class PerDiemMealExpenses implements MealExpenses {
  getTotal(): number {
    return PER_DIEM_AMOUNT; // 기본 일비 반환
  }
}

null을 반환하지 마라

  • null을 반환하는 것은 스스로 일을 만드는 것이며, 호출자에게 문제를 떠넘기는 것
    • null 체크를 하나라도 빠뜨리면 앱이 통제 불능 상태가 됨
// 나쁜 예: null 반환의 지옥
function registerItem(item: Item | null): void {
  if (item !== null) {
    const registry = persistentStore.getItemRegistry();
    if (registry !== null) {
      const existing = registry.getItem(item.getID());
      // existing이 null이면? → 런타임 에러!
      if (existing.getBillingPeriod().hasRetailOwner()) {
        existing.register(item);
      }
    }
  }
}
  • null을 반환하고 싶다면, 대신 예외를 던지거나 Special Case 객체를 반환하라
  • 컬렉션은 null 대신 빈 배열을 반환하라
// 나쁜 예
function getEmployees(): Employee[] | null {
  if (/* 직원 없음 */) return null;
}

// 좋은 예: 빈 배열 반환
function getEmployees(): Employee[] {
  if (/* 직원 없음 */) return [];
}

// 호출하는 쪽이 훨씬 깔끔해짐
const employees = getEmployees();
for (const e of employees) {
  totalPay += e.getPay();
}
  • 핵심 인사이트: null 체크가 너무 많은 코드의 문제는 null 체크가 부족한 것이 아니라 너무 많다는 것. null을 애초에 반환하지 않으면 해결됨

null을 전달하지 마라

  • null을 반환하는 것보다 null을 인수로 전달하는 것이 더 나쁘다
    • API가 명시적으로 null을 기대하는 경우가 아니라면, 기본적으로 null 전달을 금지해야 함
// 나쁜 예: null을 전달하면 런타임 에러
class MetricsCalculator {
  xProjection(p1: Point, p2: Point): number {
    return (p2.x - p1.x) * 1.5;
  }
}
calculator.xProjection(null as any, new Point(12, 13)); // 런타임 에러!

// 좋은 예: TypeScript strict 모드로 null 전달 자체를 컴파일 타임에 차단
class MetricsCalculator {
  xProjection(p1: Point, p2: Point): number {
    // p1, p2는 Point 타입이므로 null 전달 시 컴파일 에러 발생
    return (p2.x - p1.x) * 1.5;
  }
}
  • 대응 방법 비교
방법 장점 단점
예외 던지기 명시적 에러 처리 핸들러 추가 정의 필요
assert 사용 문서화 효과 런타임 에러는 여전히 발생
TypeScript strict 컴파일 타임 차단 타입 정의 엄격히 유지 필요
  • 핵심 인사이트: 대부분의 언어는 실수로 null이 전달되었을 때 우아하게 처리하는 방법이 없다. 따라서 기본 정책으로 null 전달을 금지하는 것이 합리적. TypeScript의 strict: true + strictNullChecks는 이 원칙을 언어 차원에서 강제함

정리

  • 깨끗한 코드는 읽기 쉬워야 하지만, 동시에 견고해야 한다
  • 이 두 가지는 상충하지 않는다
  • 에러 처리를 주요 로직과 별개의 관심사로 볼 수 있다면, 독립적으로 추론할 수 있고 코드 유지보수성이 크게 향상된다

핵심 요약

  • Return Code 금지: 리턴 코드 대신 예외를 사용하라. 로직과 에러 처리가 분리된다
  • try-catch-finally 먼저: 예외 가능 코드는 트랜잭션 스코프부터 정의하라
  • Unchecked Exception 선호: Checked Exception은 OCP를 위반하고 캡슐화를 파괴한다
  • 예외 컨텍스트 제공: 실패한 오퍼레이션과 실패 유형을 메시지에 담아라
  • 호출자 관점의 예외 정의: 예외는 어떻게 catch될 것인가를 기준으로 정의하라
  • 서드파티 API 래핑: 의존성을 최소화하고 테스트 용이성을 높여라
  • Special Case Pattern: 예외 대신 특수 케이스를 처리하는 객체를 반환하라
  • null 반환 금지: null 대신 예외 또는 빈 컬렉션/Special Case 객체를 반환하라
  • null 전달 금지: null 전달은 기본 금지. TypeScript strictNullChecks로 강제 가능

8장. 경계

우리는 시스템 내의 모든 소프트웨어를 직접 통제하지 못한다. 서드파티 패키지를 구매하거나 오픈소스를 사용하기도 하고, 사내 다른 팀이 만든 컴포넌트에 의존하기도 한다. 이 외부 코드를 우리 코드와 깔끔하게 통합하는 것이 이 챕터의 핵심 주제다.

  • 소프트웨어 경계란 우리가 통제할 수 있는 코드와 그렇지 않은 코드 사이의 접점
  • 경계를 깔끔하게 유지하지 않으면 외부 코드의 변화가 내부 코드 전체로 전파됨
  • 이 챕터는 경계를 깨끗하게 유지하기 위한 실천법과 기법들을 다룸

서드파티 코드 사용

  • 인터페이스 제공자 vs 사용자 사이의 긴장 관계

    • 제공자: 최대한 넓은 환경에서 동작하도록 범용적인 인터페이스를 만들려 함
    • 사용자: 자신의 특정 필요에 맞는 집중된 인터페이스를 원함
    • 이 긴장 관계가 경계에서 문제를 일으킴
  • 서드파티 인터페이스를 직접 노출할 때의 위험성

    • 예: TypeScript의 Map<string, Sensor>를 시스템 전체에 넘겨다닌다고 가정
// 나쁜 예: Map을 직접 노출하고 전달
const sensors = new Map<string, Sensor>();

// 코드 곳곳에서 이런 코드가 반복됨
const s = sensors.get(sensorId) as Sensor;
  • Map이 가진 clear(), delete() 등 불필요한 메서드까지 외부에 노출됨

  • 어디서든 Map의 내용을 임의로 삭제하거나 잘못된 타입을 삽입할 수 있음

  • Map 인터페이스가 변경되면 시스템 전체의 수많은 지점을 수정해야 함

    • 실제로 TypeScript/JavaScript 생태계에서도 이런 변경은 충분히 발생 가능
  • 경계 인터페이스를 감싸는 클린한 방법

// 좋은 예: 경계 인터페이스를 클래스 내부에 숨김
class Sensors {
  private readonly sensors = new Map<string, Sensor>();

  getById(id: string): Sensor | undefined {
    return this.sensors.get(id);
  }

  add(id: string, sensor: Sensor): void {
    this.sensors.set(id, sensor);
  }

  // 애플리케이션에 필요한 메서드만 노출
}
  • Map이라는 경계 인터페이스가 클래스 내부에 완전히 숨겨짐

  • Map 내부 구현이 바뀌어도 영향을 받는 지점이 Sensors 클래스 하나뿐

  • 타입 캐스팅 등 타입 관리가 한 곳에서 이루어짐

  • 애플리케이션의 필요에 맞게 인터페이스가 제한되고 설계/비즈니스 규칙을 강제할 수 있음

  • 핵심 규칙

    • Map과 같은 경계 인터페이스를 사용한다면, 그것을 사용하는 클래스 내부에만 두어야 함
    • 경계 인터페이스를 public API의 반환값이나 인자로 노출하지 말 것
    • 모든 Map 사용을 무조건 래핑하라는 뜻이 아니라, 시스템 전반에 넘겨다니지 말라는 것

경계 탐색과 학습

  • 서드파티 코드를 처음 사용할 때의 일반적인 접근 방식의 문제점

    • 문서를 읽고, 실제 프로덕션 코드에 바로 통합한 뒤 디버깅
    • 버그가 우리 코드에 있는지 서드파티 코드에 있는지 알기 어려움
    • 학습과 통합을 동시에 하는 것은 이중으로 어렵다
  • 학습 테스트(Learning Tests) — Jim Newkirk가 명명

    • 프로덕션 코드에 서드파티 코드를 바로 통합하는 대신, 테스트 코드로 먼저 탐색
    • 서드파티 API를 실제 사용할 방식으로 호출하는 통제된 실험
    • API에서 원하는 동작에만 집중한 단순한 단위 테스트들
  • 학습 테스트 예시: log4j 대신 TypeScript 기반의 winston 라이브러리 탐색

// 1단계: 단순히 콘솔에 출력되는지 확인
test('basicLogger', () => {
  const logger = winston.createLogger({
    transports: [new winston.transports.Console()],
  });
  logger.info('hello');
  // 에러 없이 실행되는지 확인
});
// 2단계: 포맷 설정 확인
test('loggerWithFormat', () => {
  const logger = winston.createLogger({
    format: winston.format.combine(
      winston.format.timestamp(),
      winston.format.simple()
    ),
    transports: [new winston.transports.Console()],
  });
  logger.info('hello with format');
});
// 3단계: 파일 전송 확인
test('loggerWithFileTransport', () => {
  const logger = winston.createLogger({
    transports: [new winston.transports.File({ filename: 'test.log' })],
  });
  logger.warn('warn message');
});
  • 이렇게 단계적으로 탐색하며 라이브러리의 동작 방식을 정확히 이해하고 지식을 코드에 인코딩함
  • 이후 이 지식을 우리 자체 로거 클래스로 캡슐화하여 나머지 앱이 서드파티 경계에서 격리됨

학습 테스트는 무료 이상이다

  • 비용 관점

    • 어차피 API를 학습해야 하므로, 학습 테스트는 추가 비용이 거의 없음
    • 테스트를 통한 학습은 독립적이고 통제된 방식이라 오히려 더 효율적
  • 투자 수익(ROI) 관점에서 긍정적인 이유

    • 서드파티 패키지의 새 버전이 릴리즈될 때마다 학습 테스트를 실행하여 동작 변경 여부를 즉시 감지 가능
    • 우리가 기대하는 방식으로 서드파티 코드가 동작하는지 지속적으로 검증함
    • 서드파티 코드가 우리 요구사항과 호환되지 않는 방식으로 변경되면 즉시 파악
  • 경계 테스트(Boundary Tests)의 필요성

    • 학습 테스트가 필요 없더라도, 경계 테스트 세트는 반드시 유지해야 함
    • 경계 테스트 없이는 구버전에 더 오래 머물려는 유혹에 빠지기 쉬움
    • 경계 테스트가 있어야 새 버전으로의 마이그레이션이 안전하고 용이해짐

아직 존재하지 않는 코드 사용

  • 또 다른 종류의 경계: 알려진 것과 알려지지 않은 것 사이

    • 코드의 특정 지점에서 우리의 지식이 끊기는 경우가 있음
    • 경계 너머에 무엇이 있는지 현재로서는 알 수 없는 상황
  • 실제 사례: 라디오 통신 시스템의 Transmitter 서브시스템

    • 개발팀은 “Transmitter” 서브시스템에 대해 아는 것이 없었고, 담당 팀은 아직 인터페이스를 정의하지 않은 상태
    • 미지의 영역 때문에 개발이 블로킹되는 상황을 원하지 않음
  • 해결책: 원하는 인터페이스를 먼저 직접 정의

// 우리가 원하는 인터페이스를 먼저 직접 정의
interface Transmitter {
  transmit(frequency: number, stream: DataStream): void;
}
// 테스트용 Fake 구현체
class FakeTransmitter implements Transmitter {
  transmit(frequency: number, stream: DataStream): void {
    // 테스트 목적의 가짜 구현
  }
}
// 실제 API가 정의된 후 Adapter 작성
class TransmitterAdapter implements Transmitter {
  constructor(private readonly realApi: RealTransmitterAPI) {}

  transmit(frequency: number, stream: DataStream): void {
    // 실제 API의 호출 방식에 맞게 변환하여 위임
    this.realApi.setFrequency(frequency);
    this.realApi.sendData(stream.read());
  }
}
  • 이 설계의 장점
classDiagram
    class CommunicationController {
    }
    class Transmitter {
        <<interface>>
        +transmit(frequency, stream)
    }
    class FakeTransmitter {
        +transmit(frequency, stream)
    }
    class TransmitterAdapter {
        +transmit(frequency, stream)
    }
    class RealTransmitterAPI {
        <<future>>
    }

    CommunicationController --> Transmitter
    FakeTransmitter ..|> Transmitter
    TransmitterAdapter ..|> Transmitter
    TransmitterAdapter --> RealTransmitterAPI
  • CommunicationController는 실제 Transmitter API로부터 완전히 격리됨
  • 인터페이스가 우리 통제 하에 있으므로 클라이언트 코드가 더 읽기 쉽고 집중적
  • TransmitterAdapter가 실제 API와의 상호작용을 캡슐화 — API가 바뀌어도 수정 지점이 하나
  • FakeTransmitter를 사용해 CommunicationController를 독립적으로 테스트 가능 (편리한 테스트 이음새 확보)
  • 실제 API가 나온 이후에는 경계 테스트를 작성하여 API를 올바르게 사용하는지 검증

깨끗한 경계

  • 경계에서 흥미로운 일이 일어난다 — 특히 변경

    • 좋은 소프트웨어 설계는 큰 투자나 재작업 없이 변경을 수용함
    • 통제 밖의 코드를 사용할 때는 투자를 보호하고 미래 변경 비용을 최소화하는 특별한 주의가 필요
  • 핵심 원칙: 서드파티 세부 사항을 아는 코드를 최소화하라

    • 통제할 수 있는 것에 의존하는 것이 통제할 수 없는 것에 의존하는 것보다 낫다
    • 통제할 수 없는 것에 너무 많이 의존하면, 결국 그것이 우리를 통제하게 됨
  • 서드파티 경계를 관리하는 두 가지 기법

기법 방법 효과
래핑(Wrapping) Map처럼 경계 인터페이스를 내부 클래스에 숨김 경계 변경의 파급 범위를 단일 클래스로 제한
어댑터(Adapter) 우리가 원하는 인터페이스를 먼저 정의하고, 실제 구현은 어댑터로 위임 미정의 API에 블로킹되지 않고 개발 가능, 테스트 용이성 확보
  • 경계를 깨끗하게 유지하면 얻는 것
    • 코드가 더 명확하게 이야기를 전달함
    • 경계를 넘나드는 사용이 내부적으로 일관성을 갖게 됨
    • 서드파티 코드가 변경될 때 유지보수 지점이 최소화됨

핵심 요약

  • 서드파티 코드 사용: 경계 인터페이스(Map 등)를 시스템 전반에 넘기지 말고 클래스 내부에 숨길 것
  • 학습 테스트: 프로덕션 코드 대신 테스트로 서드파티 API를 먼저 탐색하라 — 무료 이상의 가치가 있다
  • 경계 테스트: 서드파티 패키지 신버전 출시 시 호환성 감지를 위해 경계 테스트를 항상 유지하라
  • 미존재 코드 사용: 원하는 인터페이스를 먼저 직접 정의하고, Adapter로 실제 API와 연결하라
  • 깨끗한 경계: 통제 불가능한 것에 의존하는 코드를 최소화하라 — 래핑 또는 어댑터로 관리하라
  • 변경 비용: 경계를 잘 관리하면 서드파티 변경이 일어나도 수정 지점이 최소화된다

9장. 단위 테스트

테스트 코드는 프로덕션 코드만큼 중요하다. 테스트가 없으면 코드는 썩는다.

  • 1997년에는 TDD를 아는 사람이 없었음. 유닛 테스트는 프로그램이 “동작하는지” 확인하는 임시 코드에 불과했음
  • 당시의 테스트 방식: 클래스와 메서드를 작성한 뒤, 수동으로 상호작용하는 간단한 드라이버 프로그램을 임시로 만들어 확인
  • 저자의 과거 경험 사례
    • 임베디드 실시간 시스템의 타이머 기능을 테스트하기 위해 키보드 입력을 5초 뒤에 화면에 재생하는 드라이버를 직접 만들어 멜로디를 타이핑하며 육안으로 확인
    • 확인 후 테스트 코드를 버렸음
  • 현재의 기준: 코드의 모든 구석을 자동화된 테스트로 검증하고, 타이밍 함수를 모킹하여 OS로부터 코드를 격리하며, 테스트와 프로덕션 코드를 같은 소스 패키지에 함께 커밋함
  • 핵심 인사이트: Agile과 TDD 운동 덕분에 자동화된 유닛 테스트를 작성하는 개발자가 늘었지만, 좋은 테스트를 작성하는 더 섬세하고 중요한 포인트들을 놓치는 경우가 많음

TDD의 세 가지 법칙

TDD는 단순히 “프로덕션 코드 전에 테스트를 먼저 작성하라”는 규칙이 전부가 아님. 세 가지 법칙이 존재함.

# 법칙 설명
1 First Law 실패하는 유닛 테스트를 작성하기 전에는 프로덕션 코드를 작성하면 안 된다
2 Second Law 실패하기에 충분한 이상의 유닛 테스트를 작성하면 안 된다. 컴파일 실패도 실패다
3 Third Law 현재 실패하는 테스트를 통과시키기에 충분한 이상의 프로덕션 코드를 작성하면 안 된다
  • 세 법칙은 약 30초 단위의 사이클로 테스트와 프로덕션 코드를 함께 작성하도록 강제함
  • 이 방식대로 하면 하루에 수십 개, 한 달에 수백 개, 1년에 수천 개의 테스트가 쌓임
  • 테스트 코드의 양이 프로덕션 코드에 맞먹을 수 있어 관리 부담이 발생할 수 있음

테스트를 깨끗하게 유지하기

지저분한 테스트의 위험성

  • 일부 팀은 테스트 코드에 “빠르고 더럽게(Quick and Dirty)” 원칙을 적용함
    • 변수명이 불명확해도 됨
    • 함수가 짧고 서술적일 필요 없음
    • 설계가 엉망이어도 동작하면 됨
  • 이는 치명적인 오판이다: 지저분한 테스트를 갖는 것은 테스트가 없는 것과 같거나 오히려 더 나쁨

지저분한 테스트가 만드는 악순환

graph TD
    A[지저분한 테스트 코드] --> B[프로덕션 코드 변경 시 테스트도 변경 필요]
    B --> C[테스트 변경 비용이 너무 커짐]
    C --> D[테스트 suite 폐기]
    D --> E[변경에 대한 두려움 발생]
    E --> F[프로덕션 코드 정리 중단]
    F --> G[프로덕션 코드 부패]
    G --> H[결함 증가, 고객 불만]
  • 프로덕션 코드가 변하면 테스트도 함께 변해야 함
  • 테스트 코드가 더러울수록 변경 비용이 증가함
  • 결국 테스트 suite 전체를 폐기하게 되고, 이는 변경에 대한 안전망을 잃는 것을 의미함
  • 교훈: 테스트 코드는 프로덕션 코드와 동일한 수준의 품질이 필요함

핵심 원칙

테스트 코드는 일급 시민이다. 사고, 설계, 주의가 필요하며 프로덕션 코드만큼 깨끗하게 유지되어야 한다.

테스트가 여러 특성을 가능하게 한다

  • 테스트를 깨끗하게 유지하지 않으면 테스트를 잃게 되고, 그러면 프로덕션 코드를 유연하게 유지해주는 것도 잃게 됨
  • 유닛 테스트가 코드를 유연하고(Flexible), 유지보수 가능하고(Maintainable), 재사용 가능하게(Reusable) 만든다
  • 테스트가 있으면 코드 변경이 두렵지 않음. 버그를 바로 발견할 수 있기 때문
  • 테스트가 없으면 모든 변경이 잠재적 버그임. 아키텍처가 아무리 유연하고 설계가 아무리 깔끔해도 마찬가지
  • 테스트 커버리지가 높을수록 두려움이 줄어든다
    • 구조가 좋지 않은 코드도 거의 제약 없이 변경 가능
    • 아키텍처와 설계를 두려움 없이 개선할 수 있음
  • 테스트가 더러우면 코드 변경 능력이 저하되고, 결국 코드도 부패함

깨끗한 테스트

깨끗한 테스트의 핵심

가독성, 가독성, 가독성. 가독성은 프로덕션 코드에서보다 테스트에서 더 중요하다.

  • 깨끗한 테스트를 만드는 것: 명확성(Clarity), 단순성(Simplicity), 표현의 밀도(Density of expression)
  • 테스트에서는 최소한의 표현으로 많은 것을 전달해야 함

나쁜 테스트 예시 vs 좋은 테스트 예시

Before: 세부 구현이 테스트의 의도를 가리는 경우

// 나쁜 예 - 불필요한 세부사항이 가독성을 해침
test('getPageHierarchyAsXml', async () => {
  await crawler.addPage(root, PathParser.parse('PageOne'));
  await crawler.addPage(root, PathParser.parse('PageOne.ChildOne'));
  await crawler.addPage(root, PathParser.parse('PageTwo'));

  request.setResource('root');
  request.addInput('type', 'pages');

  const responder = new SerializedPageResponder();
  const response = await responder.makeResponse(new FitNesseContext(root), request);

  const xml = response.getContent();
  expect(response.getContentType()).toBe('text/xml');
  expect(xml).toContain('<name>PageOne</name>');
  expect(xml).toContain('<name>PageTwo</name>');
  expect(xml).toContain('<name>ChildOne</name>');
});

After: BUILD-OPERATE-CHECK 패턴으로 의도가 명확한 경우

// 좋은 예 - 무엇을 테스트하는지 즉시 파악 가능
test('getPageHierarchyAsXml', async () => {
  // BUILD
  makePages('PageOne', 'PageOne.ChildOne', 'PageTwo');

  // OPERATE
  submitRequest('root', 'type:pages');

  // CHECK
  assertResponseIsXML();
  assertResponseContains(
    '<name>PageOne</name>',
    '<name>PageTwo</name>',
    '<name>ChildOne</name>'
  );
});
  • BUILD-OPERATE-CHECK 패턴
    1. BUILD: 테스트 데이터를 구성
    2. OPERATE: 테스트 대상 동작을 수행
    3. CHECK: 결과를 검증

도메인 특화 테스트 언어

  • 프로그래머가 시스템을 조작하는 API를 직접 사용하는 대신, 그 API를 활용하는 함수와 유틸리티를 만들어 테스트를 더 편리하고 읽기 쉽게 만듦
  • 이 함수와 유틸리티들이 테스트 전용 특화 API(도메인 특화 언어)가 됨
  • 이 테스트 API는 미리 설계하는 것이 아니라, 테스트 코드를 지속적으로 리팩토링하면서 자연스럽게 진화함

이중 표준

  • 테스트 코드는 프로덕션 코드와 다른 엔지니어링 표준을 가짐
  • 테스트 코드도 단순하고, 간결하고, 표현력 있어야 함
  • 단, 프로덕션 코드만큼 효율적일 필요는 없음
    • 테스트 환경과 프로덕션 환경은 필요한 자원이 다름
    • CPU나 메모리 효율에서 타협할 수 있음
// 효율적이지 않지만 테스트에서는 허용 가능한 코드
// (문자열 연결을 StringBuffer 없이 수행)
function getState(): string {
  let state = '';
  state += heater ? 'H' : 'h';
  state += blower ? 'B' : 'b';
  state += cooler ? 'C' : 'c';
  state += hiTempAlarm ? 'H' : 'h';
  state += loTempAlarm ? 'L' : 'l';
  return state;
}

// 이를 활용하면 테스트가 매우 읽기 쉬워짐
// 대문자 = ON, 소문자 = OFF
// 순서: heater, blower, cooler, hi-temp-alarm, lo-temp-alarm
test('turnOnLoTempAlarmAtThreshold', () => {
  wayTooCold();
  expect(hw.getState()).toBe('HBchL');
});

test('turnOnCoolerAndBlowerIfTooHot', () => {
  tooHot();
  expect(hw.getState()).toBe('hBChl');
});
  • HBchL 같은 인코딩 문자열은 한번 의미를 익히면 눈이 자연스럽게 훑고 결과를 즉시 해석할 수 있음
  • 이중 표준의 핵심: 프로덕션 환경에서는 절대 하지 않을 일도 테스트 환경에서는 완전히 괜찮을 수 있음. 단, 청결함의 문제는 절대 타협하면 안 됨

테스트당 assert 하나

  • 일부에서는 모든 테스트 함수에 assert가 단 하나여야 한다고 주장함
  • 장점: 테스트가 하나의 결론에 빠르고 쉽게 도달함. 테스트 이름만 봐도 무엇을 검증하는지 명확함
// given-when-then 네이밍 컨벤션 + 단일 assert
test('given pages, when request issued, then response should be XML', async () => {
  givenPages('PageOne', 'PageOne.ChildOne', 'PageTwo');
  whenRequestIsIssued('root', 'type:pages');
  thenResponseShouldBeXML(); // assert 하나
});

test('given pages, when request issued, then response should have right tags', async () => {
  givenPages('PageOne', 'PageOne.ChildOne', 'PageTwo');
  whenRequestIsIssued('root', 'type:pages');
  thenResponseShouldContain(   // assert 하나
    '<name>PageOne</name>',
    '<name>PageTwo</name>',
    '<name>ChildOne</name>'
  );
});
  • 단점: 테스트를 분리하면 중복 코드가 늘어남 (given/when 부분 반복)
    • TEMPLATE METHOD 패턴이나 beforeEach로 중복 제거 가능하지만, 단순한 문제에 과도한 구조가 생길 수 있음
  • 저자의 결론: 단일 assert 규칙은 좋은 가이드라인이지만, 절대적인 규칙은 아님
    • assert 수를 최소화하는 것이 목표
    • 하나의 테스트에 assert가 여러 개 있어도 괜찮음

테스트당 단일 개념

  • 단일 assert보다 더 나은 규칙: 각 테스트 함수는 하나의 개념만 테스트해야 함
// 나쁜 예 - 하나의 테스트에 세 가지 독립적인 개념을 모두 담음
test('addMonths miscellaneous', () => {
  const d1 = SerialDate.createInstance(31, 5, 2004); // 5월 31일

  // 개념 1: 31일짜리 달에 1달 더하면 30일짜리 달의 마지막 날로 조정
  const d2 = SerialDate.addMonths(1, d1);
  expect(d2.getDayOfMonth()).toBe(30);
  expect(d2.getMonth()).toBe(6);

  // 개념 2: 31일짜리 달에 2달 더하면 31일짜리 달의 마지막 날 유지
  const d3 = SerialDate.addMonths(2, d1);
  expect(d3.getDayOfMonth()).toBe(31);
  expect(d3.getMonth()).toBe(7);

  // 개념 3: 31일에서 1달, 다시 1달을 더하면 최종 달의 마지막 날로 조정
  const d4 = SerialDate.addMonths(1, SerialDate.addMonths(1, d1));
  expect(d4.getDayOfMonth()).toBe(30);
  expect(d4.getMonth()).toBe(7);
});
// 좋은 예 - 개념별로 테스트 분리
test('addMonths: 31일짜리 달에서 30일짜리 달로 이동하면 30일로 조정된다', () => {
  const may31 = SerialDate.createInstance(31, 5, 2004);
  const june = SerialDate.addMonths(1, may31);
  expect(june.getDayOfMonth()).toBe(30);
  expect(june.getMonth()).toBe(6);
});

test('addMonths: 31일짜리 달에서 31일짜리 달로 이동하면 31일을 유지한다', () => {
  const may31 = SerialDate.createInstance(31, 5, 2004);
  const july = SerialDate.addMonths(2, may31);
  expect(july.getDayOfMonth()).toBe(31);
  expect(july.getMonth()).toBe(7);
});

test('addMonths: 31일에서 두 번 달을 추가하면 최종 달의 마지막 날로 조정된다', () => {
  const may31 = SerialDate.createInstance(31, 5, 2004);
  const result = SerialDate.addMonths(1, SerialDate.addMonths(1, may31));
  expect(result.getDayOfMonth()).toBe(30);
  expect(result.getMonth()).toBe(7);
});
  • 핵심 규칙: 개념당 assert 수를 최소화하고, 테스트 함수 하나당 하나의 개념만 테스트하라
  • 개념을 분리하면 숨어있는 일반 규칙도 발견할 수 있음
    • 위 예시에서 발견되는 규칙: “달을 증가시킬 때 날짜는 그 달의 마지막 날을 초과할 수 없다”
    • 이 규칙에 따르면 2월 28일에 1달을 더하면 3월 28일이 되어야 하는데, 이 테스트가 빠져 있었음

F.I.R.S.T.

깨끗한 테스트는 다섯 가지 규칙을 따름.

빠름

  • 테스트는 빠르게 실행되어야 함
  • 테스트가 느리면 자주 실행하지 않게 됨
  • 자주 실행하지 않으면 문제를 조기에 발견하지 못하고, 코드를 정리하는 자유로움도 잃음
  • 결국 코드가 부패하기 시작함

독립적

  • 테스트는 서로 의존하면 안 됨
  • 하나의 테스트가 다음 테스트의 조건을 설정하면 안 됨
  • 어떤 순서로든, 독립적으로 실행할 수 있어야 함
  • 테스트가 서로 의존하면 첫 번째 실패가 연쇄 실패를 유발하고, 이후 결함을 숨김

반복 가능

  • 모든 환경에서 반복 가능해야 함: 프로덕션 환경, QA 환경, 네트워크 없는 노트북에서도
  • 반복 불가능한 테스트는 실패할 때마다 변명거리를 만들어 줌
  • 특정 환경이 없을 때 테스트를 실행할 수 없게 만듦

자가 검증

  • 테스트는 boolean 결과를 가져야 함: 통과 또는 실패
  • 로그 파일을 읽거나 두 파일을 수동으로 비교해서 통과 여부를 판단하면 안 됨
  • 자가 검증이 안 되면 실패가 주관적이 되고, 수동 평가가 필요해짐

적시성

  • 테스트는 적시에 작성되어야 함
  • 유닛 테스트는 그것을 통과시키는 프로덕션 코드를 작성하기 직전에 작성해야 함
  • 프로덕션 코드를 먼저 작성하면
    • 테스트하기 어렵다고 느낄 수 있음
    • 일부 코드가 테스트하기 너무 어렵다고 판단할 수 있음
    • 테스트 가능성을 고려하지 않고 설계하게 됨

정리

  • 이 주제는 여기서 다룬 것이 전부가 아님. 깨끗한 테스트만으로도 책 한 권이 나올 수 있음
  • 테스트는 프로젝트의 건강함에 있어 프로덕션 코드만큼 중요함. 어쩌면 더 중요할 수도 있음
  • 테스트가 보존하고 향상시키는 것: 프로덕션 코드의 유연성, 유지보수성, 재사용성
  • 해야 할 것들
    • 테스트를 항상 깨끗하게 유지하라
    • 테스트를 표현력 있고 간결하게 만들어라
    • 테스트 작성을 돕는 도메인 특화 언어 역할의 테스트 API를 만들어라

테스트를 부패하게 놔두면 코드도 부패한다. 테스트를 깨끗하게 유지하라.

핵심 요약

  • 테스트의 지위: 테스트 코드는 프로덕션 코드와 동등한 일급 시민이다
  • 지저분한 테스트: 없는 것보다 나쁠 수 있다. 변경 비용이 눈덩이처럼 불어나 결국 폐기된다
  • TDD 3법칙: 실패 테스트 작성 → 최소한의 프로덕션 코드 → 반복. 30초 사이클
  • 테스트와 유연성: 테스트가 있어야 두려움 없이 코드를 변경하고 아키텍처를 개선할 수 있다
  • 가독성: 깨끗한 테스트의 첫 번째, 두 번째, 세 번째 요건
  • BUILD-OPERATE-CHECK: 데이터 구성 → 동작 수행 → 결과 검증의 3단계 패턴
  • 도메인 특화 언어: 테스트 API를 반복적인 리팩토링으로 진화시켜 테스트를 읽기 쉽게 만든다
  • 이중 표준: 테스트는 효율성에서 타협 가능하지만 청결함은 절대 타협 불가
  • 단일 개념: 테스트 함수 하나에는 하나의 개념만. assert 수는 최소화
  • F.I.R.S.T.: Fast, Independent, Repeatable, Self-Validating, Timely

10장. 클래스

코드 라인과 함수 블록을 잘 작성하는 것만으로는 부족하다. 더 높은 수준의 코드 조직, 즉 클래스 수준에서도 깨끗함을 유지해야 비로소 진정한 클린 코드가 완성된다.

클래스 구성

  • 클래스 내부 구성 요소의 표준 순서가 존재함 (Java 관례 기준, TypeScript에도 동일하게 적용 가능)
    1. public static 상수
    2. private static 변수
    3. private 인스턴스 변수
    4. public 메서드
    5. 해당 public 메서드가 호출하는 private 유틸리티 메서드 (바로 아래에 배치)
  • public 변수를 가져야 할 이유는 거의 없음
  • public 메서드 바로 아래에 관련 private 메서드를 두는 방식은 신문 기사처럼 위에서 아래로 읽히는 구조(Stepdown Rule) 를 만들어 줌

캡슐화

  • 변수와 유틸리티 함수는 기본적으로 private으로 유지하는 것이 원칙
  • 단, 테스트를 위해 protected나 패키지 범위로 완화하는 것은 허용
    • 테스트가 우선이기 때문
  • 캡슐화 완화는 항상 최후의 수단으로만 사용

클래스는 작아야 한다

클래스의 첫 번째 규칙: 작아야 한다. 두 번째 규칙: 그보다 더 작아야 한다.

  • 함수는 물리적 줄 수로 크기를 측정하지만, 클래스는 책임(Responsibility)의 수로 크기를 측정함
  • 아래처럼 public 메서드가 70개에 달하는 클래스는 “God Class” 라고 불림
// 나쁜 예: 너무 많은 책임을 가진 클래스
class SuperDashboard {
  getCustomizerLanguagePath(): string { ... }
  setSystemConfigPath(path: string): void { ... }
  getGuruState(): boolean { ... }
  getLanguageManager(): LanguageManager { ... }
  getProject(): Project { ... }
  getMajorVersionNumber(): number { ... }
  getMinorVersionNumber(): number { ... }
  getBuildNumber(): number { ... }
  // ... 70개 이상의 메서드
}
  • 메서드 수가 적어도 책임이 여러 개라면 여전히 큰 클래스임
// 여전히 나쁜 예: 메서드는 5개지만 책임이 2가지
class SuperDashboard {
  getLastFocusedComponent(): Component { ... }  // UI 관련 책임
  setLastFocused(component: Component): void { ... }  // UI 관련 책임
  getMajorVersionNumber(): number { ... }       // 버전 관련 책임
  getMinorVersionNumber(): number { ... }       // 버전 관련 책임
  getBuildNumber(): number { ... }              // 버전 관련 책임
}
  • 클래스 이름으로 책임의 수를 판단할 수 있음
    • 간결한 이름을 짓기 어렵다면 → 클래스가 너무 큰 것
    • Processor, Manager, Super 같은 모호한 단어가 들어간 이름 → 책임이 과도하게 집중된 신호
  • 25단어 이내로 “if”, “and”, “or”, “but” 없이 클래스를 설명할 수 없다면 → 책임이 너무 많은 것

단일 책임 원칙

SRP: 클래스 또는 모듈은 변경되어야 할 이유가 단 하나여야 한다.

  • “변경의 이유(reason to change)“가 곧 책임(responsibility) 의 정의
  • SuperDashboard의 문제점
    • 소프트웨어가 출시될 때마다 버전 정보 변경 → 첫 번째 변경 이유
    • Swing 컴포넌트(UI) 변경 시 수정 필요 → 두 번째 변경 이유
  • 해결: 버전 관련 메서드를 별도 클래스로 분리
// 좋은 예: 단일 책임을 가진 클래스
class Version {
  getMajorVersionNumber(): number { ... }
  getMinorVersionNumber(): number { ... }
  getBuildNumber(): number { ... }
}
  • Version 클래스는 재사용 가능성이 높음 — 다른 애플리케이션에서도 쓸 수 있음

SRP가 자주 위반되는 이유

  • “돌아가는 코드”를 만드는 것과 “깨끗한 코드”를 만드는 것은 별개의 활동
  • 대부분 코드가 동작하면 그냥 다음 문제로 넘어가고, 정리하는 과정을 건너뜀
  • 일부 개발자들은 작은 클래스가 많아지면 전체 그림을 파악하기 어렵다고 우려함

하지만 실제로는:

관점 큰 클래스 몇 개 작은 클래스 많이
전체 복잡도 동일 동일
특정 기능 파악 불필요한 내용까지 읽어야 함 해당 클래스만 집중하면 됨
수정 영향 범위 넓음 좁음
재사용성 낮음 높음

공구가 담긴 서랍장에 비유하자면: 잘 분류된 작은 서랍 여러 개 vs. 모든 공구가 뒤섞인 큰 서랍 몇 개 — 어느 쪽이 더 유용한가?

  • 목표: 각 클래스가 단 하나의 책임을 캡슐화하고, 단 하나의 변경 이유를 가지며, 소수의 다른 클래스와 협력하여 시스템 동작을 달성하는 구조

응집도

  • 클래스는 인스턴스 변수의 수가 적어야
  • 클래스의 각 메서드는 그 변수들 중 하나 이상을 사용해야 함
  • 메서드가 더 많은 인스턴스 변수를 사용할수록 → 해당 메서드의 클래스에 대한 응집도가 높아짐
  • 모든 메서드가 모든 변수를 사용하면 → 최대 응집(maximally cohesive)
// 좋은 예: 높은 응집도를 가진 Stack 클래스
class Stack {
  private topOfStack: number = 0;
  private elements: number[] = [];

  size(): number {
    return this.topOfStack; // topOfStack 사용
  }

  push(element: number): void {
    this.topOfStack++;
    this.elements.push(element); // 두 변수 모두 사용
  }

  pop(): number {
    if (this.topOfStack === 0) throw new Error("PoppedWhenEmpty");
    const element = this.elements[--this.topOfStack]; // 두 변수 모두 사용
    this.elements.splice(this.topOfStack, 1);
    return element;
  }
}
  • size()topOfStack만 사용하지만, 나머지 두 메서드는 두 변수 모두를 조작 → 매우 높은 응집도

응집도가 낮아지는 패턴 감지

  • 함수를 작게 유지하고 매개변수 목록을 줄이다 보면 일부 메서드만 사용하는 인스턴스 변수가 늘어남
  • 이는 클래스 안에 다른 클래스가 숨어있다는 신호
  • 해결: 해당 변수와 메서드를 새로운 클래스로 분리

응집도를 유지하면 작은 클래스가 많아진다

  • 큰 함수를 작은 함수들로 분리하다 보면 자연스럽게 클래스도 여러 개로 나뉨

예시: PrintPrimes 리팩토링

리팩토링 전: 하나의 main 함수에 모든 로직이 뭉쳐 있음

// 나쁜 예: 하나의 함수에 모든 것이 집중된 구조
function printPrimes(): void {
  const M = 1000;
  const RR = 50;
  const CC = 4;
  // 깊은 중첩 구조, 의미 불명확한 변수명, 강한 결합...
  // ...70줄 이상의 로직
}

리팩토링 후: 3개의 클래스로 책임 분리

// 좋은 예: 리팩토링 후 - 각 클래스가 단일 책임을 가짐

// 1. 실행 환경(entry point) 담당
class PrimePrinter {
  static main(): void {
    const NUMBER_OF_PRIMES = 1000;
    const primes = PrimeGenerator.generate(NUMBER_OF_PRIMES);
    const tablePrinter = new RowColumnPagePrinter(
      50, 4,
      `The First ${NUMBER_OF_PRIMES} Prime Numbers`
    );
    tablePrinter.print(primes);
  }
}

// 2. 숫자 목록을 페이지/행/열 형식으로 출력하는 방법을 담당
class RowColumnPagePrinter {
  private rowsPerPage: number;
  private columnsPerPage: number;
  private numbersPerPage: number;
  private pageHeader: string;

  constructor(rowsPerPage: number, columnsPerPage: number, pageHeader: string) {
    this.rowsPerPage = rowsPerPage;
    this.columnsPerPage = columnsPerPage;
    this.pageHeader = pageHeader;
    this.numbersPerPage = rowsPerPage * columnsPerPage;
  }

  print(data: number[]): void { ... }
  private printPage(...): void { ... }
  private printRow(...): void { ... }
  private printPageHeader(...): void { ... }
}

// 3. 소수 생성 알고리즘을 담당
class PrimeGenerator {
  private static primes: number[];
  private static multiplesOfPrimeFactors: number[];

  static generate(n: number): number[] {
    this.primes = new Array(n);
    this.multiplesOfPrimeFactors = [];
    this.set2AsFirstPrime();
    this.checkOddNumbersForSubsequentPrimes();
    return this.primes;
  }

  private static set2AsFirstPrime(): void { ... }
  private static checkOddNumbersForSubsequentPrimes(): void { ... }
  private static isPrime(candidate: number): boolean { ... }
}

리팩토링 결과 분석

  • 코드가 1페이지에서 3페이지로 길어졌지만, 이는 긍정적인 이유 때문:
    • 더 길고 서술적인 변수명 사용
    • 함수와 클래스 선언 자체가 주석 역할 수행
    • 가독성을 위한 공백과 포맷팅 추가
  • 각 클래스의 변경 이유가 명확히 분리됨:
    • PrimePrinter → 실행 방식이 바뀔 때 (예: SOAP 서비스로 전환)
    • RowColumnPagePrinter → 출력 형식이 바뀔 때
    • PrimeGenerator → 소수 계산 알고리즘이 바뀔 때
  • 이 과정은 처음부터 다시 작성한 것이 아님
    • 첫 번째 프로그램의 동작을 검증하는 테스트 스위트를 먼저 작성
    • 이후 한 번에 하나씩 작은 변경을 가하며, 매 변경 후 동작을 확인
    • 동일한 알고리즘과 로직을 유지하면서 구조만 변경

변경에 대비한 구성

대부분의 시스템에서 변경은 지속적으로 발생한다. 클래스를 잘 조직화하면 변경의 위험을 줄일 수 있다.

  • 클래스를 수정하기 위해 “열어야(open up)” 할 때마다 위험이 발생함
    • 기존 코드가 깨질 가능성
    • 전체 재테스트 필요

나쁜 예: 변경에 취약한 Sql 클래스

// 나쁜 예: UPDATE 기능 추가 시 이 클래스 전체를 수정해야 함
class Sql {
  constructor(private table: string, private columns: Column[]) {}

  create(): string { ... }
  insert(fields: object[]): string { ... }
  selectAll(): string { ... }
  findByKey(keyColumn: string, keyValue: string): string { ... }
  select(column: Column, pattern: string): string { ... }
  preparedInsert(): string { ... }

  // select에만 관련된 private 메서드 → SRP 위반 신호
  private selectWithCriteria(criteria: string): string { ... }
  private columnList(columns: Column[]): string { ... }
  private valuesList(fields: object[], columns: Column[]): string { ... }
  private placeholderList(columns: Column[]): string { ... }
}
  • Sql 클래스가 변경되어야 하는 이유:
    1. 새로운 SQL 구문 타입 추가 시
    2. 특정 구문의 세부 사항 변경 시 (예: SELECT에 서브쿼리 지원 추가)
  • private 메서드가 클래스의 일부 기능에만 관련된다면 → 분리 가능성의 신호

좋은 예: 변경에 닫힌(closed) 클래스 집합

// 좋은 예: 각 SQL 구문이 독립적인 클래스로 분리됨
abstract class Sql {
  constructor(protected table: string, protected columns: Column[]) {}
  abstract generate(): string;
}

class CreateSql extends Sql {
  generate(): string { ... }
}

class SelectSql extends Sql {
  generate(): string { ... }
}

class InsertSql extends Sql {
  constructor(table: string, columns: Column[], private fields: object[]) {
    super(table, columns);
  }
  generate(): string { ... }
  private valuesList(): string { ... }
}

class SelectWithCriteriaSql extends Sql {
  constructor(table: string, columns: Column[], private criteria: Criteria) {
    super(table, columns);
  }
  generate(): string { ... }
}

class UpdateSql extends Sql {
  // UPDATE 기능 추가 시 기존 클래스는 전혀 건드리지 않음!
  generate(): string { ... }
}

// 공통 유틸리티는 별도 클래스로 분리
class Where {
  constructor(private criteria: string) {}
  generate(): string { ... }
}

class ColumnList {
  constructor(private columns: Column[]) {}
  generate(): string { ... }
}

리팩토링 후 장점

  • 각 클래스의 코드가 극도로 단순해져 이해에 드는 시간이 최소화됨
  • 한 함수가 다른 함수를 망가뜨릴 위험이 거의 사라짐
  • 모든 클래스가 서로 격리되어 테스트가 쉬워짐
  • UpdateSql 추가 시 기존 클래스 중 어느 것도 수정할 필요가 없음

OCP (Open-Closed Principle) 준수

클래스는 확장에는 열려 있고, 수정에는 닫혀 있어야 한다.

  • 리팩토링된 Sql 구조는 서브클래싱을 통해 새 기능 추가(열림) 하면서, 기존 클래스는 건드리지 않음(닫힘)
  • 이상적인 시스템은 기존 코드를 수정하지 않고 확장함으로써 새 기능을 추가

변경으로부터 격리하기

구체적인 구현 세부사항에 의존하는 클라이언트 클래스는 그 세부사항이 변경될 때 위험에 처한다. 인터페이스와 추상 클래스를 도입하여 그 충격을 격리시켜라.

나쁜 예: 구체 클래스에 직접 의존

// 나쁜 예: Portfolio가 TokyoStockExchange에 직접 의존
class Portfolio {
  private exchange: TokyoStockExchange;

  constructor() {
    this.exchange = new TokyoStockExchange(); // 구체 클래스에 종속
  }

  value(): number {
    // 5분마다 다른 값을 반환 → 테스트 불가능!
    return this.exchange.currentPrice("MSFT") * this.shares;
  }
}
  • TokyoStockExchange는 5분마다 다른 값을 반환 → 테스트 작성이 사실상 불가능

좋은 예: 인터페이스를 통한 의존성 격리

// 좋은 예: 인터페이스를 통해 의존성을 역전시킴

// 추상화 인터페이스 정의
interface StockExchange {
  currentPrice(symbol: string): number;
}

// 실제 구현체
class TokyoStockExchange implements StockExchange {
  currentPrice(symbol: string): number {
    // 실제 도쿄 증권 거래소 API 호출
    return fetchFromAPI(symbol);
  }
}

// Portfolio는 인터페이스에만 의존
class Portfolio {
  constructor(private exchange: StockExchange) {} // 인터페이스 주입

  value(): number {
    return this.exchange.currentPrice("MSFT") * this.shares;
  }
}

// 테스트용 구현체 (고정된 값 반환)
class FixedStockExchangeStub implements StockExchange {
  private prices: Map<string, number> = new Map();

  fix(symbol: string, price: number): void {
    this.prices.set(symbol, price);
  }

  currentPrice(symbol: string): number {
    return this.prices.get(symbol) ?? 0;
  }
}

// 테스트 코드
describe("Portfolio", () => {
  let exchange: FixedStockExchangeStub;
  let portfolio: Portfolio;

  beforeEach(() => {
    exchange = new FixedStockExchangeStub();
    exchange.fix("MSFT", 100);
    portfolio = new Portfolio(exchange);
  });

  it("5주 MSFT 구매 시 총액은 500이어야 함", () => {
    portfolio.add(5, "MSFT");
    expect(portfolio.value()).toBe(500);
  });
});

DIP (Dependency Inversion Principle) 준수

클래스는 구체적인 구현이 아닌 추상화(인터페이스)에 의존해야 한다.

  • Portfolio는 더 이상 TokyoStockExchange의 구현 세부사항에 의존하지 않음
  • StockExchange 인터페이스는 “현재가를 조회한다”는 추상적 개념을 표현
  • 가격을 어디서, 어떻게 가져오는지의 구체적인 세부사항은 완전히 격리됨

결합도를 최소화하면 얻는 이점

효과 설명
테스트 용이성 테스트용 구현체(Stub/Mock)로 쉽게 교체 가능
유연성 구현체를 교체해도 클라이언트 코드 변경 불필요
재사용성 격리된 컴포넌트는 다른 맥락에서도 사용 가능
이해 용이성 각 요소가 독립적으로 이해 가능

핵심 요약

  • 클래스 크기: 물리적 줄이 아닌 책임의 수로 측정. 작을수록 좋음
  • SRP: 클래스는 변경의 이유가 단 하나여야 함
  • 응집도: 메서드들이 인스턴스 변수를 많이 공유할수록 응집도가 높음. 응집도가 낮아지면 클래스를 분리하라
  • 클래스 이름: 25단어 이내, “if/and/or/but” 없이 설명 불가하면 너무 큰 것
  • OCP: 확장에는 열리고, 수정에는 닫혀야 함. 서브클래싱으로 기능을 추가
  • DIP: 구체 클래스가 아닌 인터페이스(추상화)에 의존하라
  • 변경과 테스트: 결합도가 낮으면 테스트가 쉬워지고, 테스트가 쉬우면 설계도 유연해짐
  • 리팩토링 방법: 처음부터 다시 짜지 말고, 테스트 → 작은 변경 반복으로 개선

11장. 시스템

“Complexity kills. It sucks the life out of developers, it makes products difficult to plan, build, and test.” — Ray Ozzie, CTO, Microsoft Corporation

도시를 짓는다면 어떻게 할까

  • 도시를 한 사람이 혼자 관리하는 것은 불가능하지만, 도시는 잘 작동함
  • 작동하는 이유: 수도, 전력, 교통, 치안, 건축 규정 등 각 부분을 담당하는 팀이 존재하기 때문
    • 일부는 큰 그림을 담당하고, 일부는 세부 사항에 집중
  • 도시가 작동하는 또 다른 이유: 적절한 수준의 추상화와 모듈화가 이루어져 있어 개인과 컴포넌트가 전체 그림을 몰라도 효과적으로 일할 수 있음
  • 소프트웨어 팀도 도시처럼 조직되는 경우가 많지만, 시스템은 도시만큼의 관심사 분리(Separation of Concerns) 와 추상화 수준을 갖추지 못하는 경우가 많음
  • 낮은 수준의 추상화에서는 클린 코드가 도움이 되지만, 이 챕터는 시스템 수준의 높은 추상화에서 어떻게 클린하게 유지할지를 다룸

시스템의 생성과 사용을 분리하라

  • 생성(Construction)과 사용(Use)은 완전히 다른 프로세스
    • 건설 중인 호텔: 콘크리트 박스, 크레인, 안전모를 쓴 인부들
    • 완공된 호텔: 유리 외벽, 깔끔한 인테리어, 투숙객
    • 소프트웨어도 마찬가지로, 시작 프로세스(객체 생성 및 의존성 연결)와 런타임 로직을 분리해야 함
  • 대부분의 애플리케이션이 이 관심사를 분리하지 않음
    • 시작 프로세스 코드가 런타임 로직과 뒤섞여 있음
    • 가장 흔한 안티패턴: LAZY INITIALIZATION/EVALUATION 이디엄
// 안티패턴: 생성과 사용이 뒤섞인 경우
class ServiceManager {
  private service: MyService | null = null;

  getService(): MyService {
    if (this.service === null) {
      this.service = new MyServiceImpl(); // 대부분의 경우에 적합한 기본값?
    }
    return this.service;
  }
}
  • LAZY INITIALIZATION의 문제점
    • MyServiceImpl과 그 생성자에 대한 하드코딩된 의존성 발생
    • 런타임에 해당 타입을 실제로 사용하지 않더라도 컴파일 시 의존성을 해결해야 함
    • 테스트가 어려워짐: MyServiceImpl이 무거운 객체라면 단위 테스트 시 TEST DOUBLE이나 MOCK을 주입해야 하는데, 생성 로직이 런타임 로직과 섞여 있어 복잡해짐
    • null 체크 경로와 생성 경로 모두 테스트해야 하므로 두 가지 책임을 지게 됨 → SRP(단일 책임 원칙) 위반
    • MyServiceImpl이 모든 경우에 올바른 객체인지 알 수 없음 → 전역 컨텍스트를 알아야 하는 문제
  • 핵심 원칙: 작고 편리한 이디엄이 모듈성 붕괴로 이어지도록 놔두면 안 됨. 객체 생성과 연결의 시작 프로세스는 일반 런타임 로직과 분리하여 모듈화하고, 주요 의존성 해결을 위한 전역적이고 일관된 전략을 가져야 함

Main 분리

  • 생성과 사용을 분리하는 가장 간단한 방법: 모든 생성 관련 코드를 main(또는 main이 호출하는 모듈)으로 옮기고, 나머지 시스템은 모든 객체가 이미 올바르게 생성되었다고 가정하고 설계
flowchart LR
    main -->|"2: run(configuredObject)"| application
    main -->|"1: build"| Builder
    Builder -->|"1.1: construct"| ConfiguredObject
    ConfiguredObject --> application
  • 제어 흐름의 방향
    • main이 시스템에 필요한 객체를 빌드한 후 애플리케이션에 전달
    • 의존성 화살표는 항상 main에서 애플리케이션 방향으로 단방향
    • 애플리케이션은 main이나 생성 프로세스에 대해 전혀 알지 못함 — 단지 모든 것이 올바르게 빌드되었다고 기대할 뿐

팩토리

  • 때로는 애플리케이션이 객체 생성 시점을 직접 제어해야 할 필요가 있음
    • 예시: 주문 처리 시스템에서 Order에 추가할 LineItem 인스턴스를 생성해야 하는 경우
  • ABSTRACT FACTORY 패턴 활용
    • 애플리케이션이 LineItem언제 빌드할지 제어권을 가짐
    • 빌드의 세부 구현은 애플리케이션 코드와 분리된 LineItemFactoryImplementation에 위치
// 인터페이스 (애플리케이션이 알고 있는 것)
interface LineItemFactory {
  makeLineItem(data: LineItemData): LineItem;
}

// 구현체 (main 측에 위치)
class LineItemFactoryImplementation implements LineItemFactory {
  makeLineItem(data: LineItemData): LineItem {
    return new LineItem(data);
  }
}

// 애플리케이션 코드 (구현 세부사항을 모름)
class OrderProcessing {
  constructor(private factory: LineItemFactory) {}

  addItem(data: LineItemData, order: Order): void {
    const item = this.factory.makeLineItem(data); // 언제 생성할지는 앱이 결정
    order.add(item);
  }
}
  • 결과: 모든 의존성이 main에서 OrderProcessing 방향으로만 향함 → 애플리케이션은 LineItem 빌드 방법의 세부사항에서 완전히 분리됨

의존성 주입

  • 의존성 주입(DI): 생성과 사용을 분리하는 강력한 메커니즘
    • 제어의 역전(IoC) 을 의존성 관리에 적용한 것
    • IoC: 객체가 부차적인 책임을 전담 객체에게 넘겨 SRP를 지원
  • DI의 핵심 원칙
    • 객체가 스스로 의존성을 인스턴스화하는 책임을 지지 않음
    • 대신 다른 권위 있는 메커니즘(main 루틴 또는 특수 목적 컨테이너)에 그 책임을 위임(제어를 역전)
// 나쁜 예: 객체가 직접 의존성을 생성
class OrderService {
  private repository = new OrderRepository(); // 하드코딩된 의존성
  private emailService = new EmailService();  // 하드코딩된 의존성
}

// 좋은 예: 의존성 주입 (완전히 수동적인 클래스)
class OrderService {
  constructor(
    private readonly repository: IOrderRepository, // 생성자 인수로 주입
    private readonly emailService: IEmailService    // 생성자 인수로 주입
  ) {}
}
  • JNDI 조회는 DI의 부분적 구현
    • 객체가 디렉토리 서버에 특정 이름의 서비스를 요청하는 방식
    • 반환 타입은 제어하지 못하지만, 여전히 능동적으로 의존성을 해결함 → 완전한 DI가 아님
  • 진정한 DI
    • 클래스는 의존성 해결을 위한 어떤 직접적인 단계도 취하지 않음 — 완전히 수동적
    • setter 메서드 또는 생성자 인수를 통해 의존성이 주입됨
    • DI 컨테이너가 객체를 인스턴스화하고 의존성을 연결함
    • 어떤 의존 객체를 사용할지는 설정 파일 또는 특수 목적 생성 모듈에서 지정
  • LAZY INITIALIZATION과 DI의 공존
    • 대부분의 DI 컨테이너는 필요할 때까지 객체를 생성하지 않음 → LAZY 초기화 특성 유지
    • 팩토리 호출이나 프록시 구성 메커니즘을 통해 LAZY-EVALUATION 최적화도 가능

확장하기

  • 도시는 마을에서, 마을은 정착지에서 성장함
    • 처음에는 좁은 도로 → 포장 → 확장
    • 전기, 수도, 하수도 등의 서비스는 인구 밀도가 높아지면서 추가됨
    • 이 성장 과정은 고통 없이 이루어지지 않음 (공사 중 교통 체증 등)
  • “처음부터 시스템을 올바르게 만들 수 있다”는 것은 신화
    • 오늘의 스토리만 구현하고, 내일 새 스토리를 구현하기 위해 리팩토링하고 확장해야 함
    • 이것이 반복적이고 점진적인 애자일의 핵심
    • TDD, 리팩토링, 클린 코드가 코드 레벨에서 이를 가능하게 함
  • 소프트웨어 시스템은 물리적 시스템과 다름
    • 적절한 관심사 분리를 유지한다면 아키텍처가 점진적으로 성장할 수 있음
  • EJB1/EJB2 아키텍처의 반면교사
    • 관심사를 적절히 분리하지 않아 유기적 성장을 방해하는 불필요한 장벽을 만듦
    • 비즈니스 로직이 EJB2 애플리케이션 컨테이너와 강하게 결합됨
    • 컨테이너 타입을 상속해야 하고, 컨테이너가 요구하는 많은 생명주기 메서드를 제공해야 함
    • 결과: 무거운 컨테이너와의 결합으로 격리된 단위 테스트가 어려움, EJB2 아키텍처 외부에서의 재사용 사실상 불가능, 객체지향 프로그래밍이 훼손됨(빈 간 상속 불가)

횡단 관심사

  • 영속성(Persistence) 같은 관심사는 도메인의 자연스러운 객체 경계를 가로지르는 경향이 있음
    • 특정 DBMS를 사용하는 전략, 테이블/컬럼의 명명 규칙, 일관된 트랜잭션 시맨틱 등
    • 모듈화된 방식으로 추론할 수 있지만, 실제로는 동일한 코드를 많은 객체에 분산시켜야 함
  • 횡단 관심사(Cross-Cutting Concerns): 이처럼 여러 객체 경계를 가로지르는 관심사
    • 영속성 프레임워크는 모듈화될 수 있고, 도메인 로직도 독립적으로 모듈화될 수 있음
    • 문제는 이 도메인들의 세밀한 교차점
  • 관점 지향 프로그래밍(AOP): 횡단 관심사의 모듈성을 복원하기 위한 범용적 접근법
    • EJB 아키텍처가 영속성, 보안, 트랜잭션을 처리하는 방식이 AOP를 예상한 것이었음
    • AOP에서 Aspect라는 모듈 구성 요소가 시스템의 어떤 지점에서 어떤 방식으로 동작을 수정할지 지정
    • 이 명세는 간결한 선언적 또는 프로그래매틱 메커니즘을 사용

Java 프록시

  • Java 프록시: 개별 객체나 클래스의 메서드 호출을 래핑하는 단순한 상황에 적합
    • JDK의 동적 프록시는 인터페이스와만 동작
    • 클래스를 프록시하려면 CGLIB, ASM, Javassist 같은 바이트코드 조작 라이브러리 필요
// TypeScript에서 Proxy를 이용한 유사 구현
interface Bank {
  getAccounts(): Account[];
  setAccounts(accounts: Account[]): void;
}

class BankImpl implements Bank {
  private accounts: Account[] = [];

  getAccounts(): Account[] {
    return this.accounts;
  }

  setAccounts(accounts: Account[]): void {
    this.accounts = [...accounts];
  }
}

// 프록시 핸들러: 영속성 관심사를 분리
function createBankProxy(bank: Bank): Bank {
  return new Proxy(bank, {
    get(target, prop) {
      if (prop === "getAccounts") {
        return () => {
          const accounts = loadAccountsFromDatabase(); // DB에서 로드
          target.setAccounts(accounts);
          return target.getAccounts();
        };
      }
      if (prop === "setAccounts") {
        return (accounts: Account[]) => {
          target.setAccounts(accounts);
          saveAccountsToDatabase(accounts); // DB에 저장
        };
      }
      return (target as any)[prop];
    },
  });
}
  • 프록시의 단점
    • 코드 양이 많고 복잡함 → 클린 코드를 작성하기 어렵게 만듦
    • 진정한 AOP 솔루션에 필요한 시스템 전체의 실행 지점을 지정하는 메커니즘을 제공하지 못함

순수 Java AOP 프레임워크

  • 대부분의 프록시 보일러플레이트는 도구로 자동 처리 가능
    • Spring AOP, JBoss AOP 등이 내부적으로 프록시를 사용하여 순수 Java에서 Aspect를 구현
  • Spring Framework의 접근 방식
    • 비즈니스 로직을 POJO(Plain-Old Java Object) 로 작성
    • POJO는 도메인에만 집중하며, 엔터프라이즈 프레임워크(또는 다른 도메인)에 대한 의존성이 없음
    • 개념적으로 단순하고 테스트하기 쉬움 → 올바른 구현과 향후 코드 유지보수가 용이
  • 인프라 통합 방법: 선언적 설정 파일 또는 API를 통해 영속성, 트랜잭션, 보안, 캐싱, 페일오버 등 횡단 관심사를 포함한 필요한 애플리케이션 인프라를 통합
// TypeScript + NestJS의 예: POJO처럼 동작하는 서비스
// 비즈니스 로직은 순수하게 도메인에만 집중 (POJO 철학)
export class BankService {
  constructor(private readonly bankRepository: IBankRepository) {}

  async addAccount(bankId: number, accountData: CreateAccountDto): Promise<void> {
    const bank = await this.bankRepository.findById(bankId);
    bank.addAccount(accountData);
    await this.bankRepository.save(bank);
  }
}

// 트랜잭션, 로깅 등 횡단 관심사는 데코레이터(Aspect)로 분리
// @Transaction(), @Log() 같은 데코레이터가 비즈니스 로직에 비침습적으로 적용
  • Spring의 DI 컨테이너: 선언(XML 설정 파일 또는 어노테이션)이 DI 컨테이너를 구동하고, 컨테이너가 주요 객체를 인스턴스화하고 온디맨드로 연결
  • “Russian Doll” 데코레이터 패턴
    • 도메인 Bank 객체가 DAO 객체로 프록시(래핑)되고, 다시 JDBC 드라이버 데이터소스로 프록시됨
    • 클라이언트는 Bank 객체의 메서드를 호출한다고 생각하지만, 실제로는 기본 동작을 확장하는 중첩된 DECORATOR 객체의 가장 바깥쪽과 통신
flowchart LR
    client --> AppDataSource
    subgraph AppDataSource
        BankDataAccessObject
        subgraph BankDataAccessObject
            Bank
        end
    end
  • EJB3으로의 영향: Spring과 같은 프레임워크가 EJB 표준의 완전한 개편을 이끌어냄. EJB3는 XML 설정 파일 및 Java 5 어노테이션을 사용하여 횡단 관심사를 선언적으로 지원하는 Spring 모델을 따름

AspectJ aspect(AspectJ Aspects)

  • AspectJ: 관심사를 Aspect로 분리하기 위한 가장 완전한 기능을 제공하는 도구
    • Java의 확장 언어로, Aspect를 모듈성 구성 요소로서 일급 지원
    • Spring AOP와 JBoss AOP의 순수 Java 접근법은 Aspect가 가장 유용한 경우의 80~90%를 커버
    • AspectJ는 매우 풍부하고 강력한 도구셋을 제공하지만, 새 도구, 새 언어 구성 요소, 새 사용 이디엄을 학습해야 한다는 단점
  • 어노테이션 형식으로의 완화
    • Java 5 어노테이션을 사용하여 순수 Java 코드로 Aspect를 정의하는 “어노테이션 형식”이 도입됨
    • Spring Framework는 AspectJ 경험이 부족한 팀을 위해 어노테이션 기반 Aspect의 통합을 쉽게 만드는 기능 제공

시스템 아키텍처를 테스트 주도하라

  • Aspect-like 접근법을 통한 관심사 분리의 위력은 아무리 강조해도 지나치지 않음
  • POJO를 사용해 도메인 로직을 코드 수준에서 아키텍처 관심사와 완전히 분리하여 작성할 수 있다면, 아키텍처를 진정으로 테스트 주도 개발할 수 있음
  • 필요에 따라 새로운 기술을 채택하면서 단순한 것에서 정교한 것으로 점진적으로 진화 가능
  • BDUF(Big Design Up Front)는 불필요하며 오히려 해롭다
    • 변화에 적응하는 것을 억제 (이전 노력을 버리는 것에 대한 심리적 저항)
    • 아키텍처 선택이 이후 설계에 대한 사고에 영향을 미침
    • 물리적 건물은 착공 후 급진적 변경이 불가능하지만, 소프트웨어는 관심사를 효과적으로 분리한다면 경제적으로 급진적인 변경이 가능
  • 권장 접근법
    • “순진하게 단순하지만 잘 분리된” 아키텍처로 시작
    • 작동하는 사용자 스토리를 빠르게 제공
    • 규모가 커짐에 따라 인프라를 추가
    • 세계 최대 웹 사이트들이 정교한 데이터 캐싱, 보안, 가상화를 효율적이고 유연하게 달성한 방법
  • 단, 방향 없이 프로젝트에 들어간다는 의미는 아님
    • 프로젝트의 일반적인 범위, 목표, 일정, 시스템의 일반적인 구조에 대한 기대치 필요
    • 단, 변화하는 상황에 대응하여 방향을 바꿀 능력을 유지해야 함

최적의 시스템 아키텍처는 각각 POJO로 구현된 모듈화된 관심사 도메인들로 구성된다. 서로 다른 도메인들은 최소침습적인 Aspect 또는 Aspect-like 도구로 통합된다. 이 아키텍처는 코드처럼 테스트 주도 개발될 수 있다.

의사결정을 최적화하라

  • 모듈성과 관심사 분리는 분산된 관리와 의사결정을 가능하게 함
    • 충분히 큰 시스템(도시 또는 소프트웨어 프로젝트)에서는 한 사람이 모든 결정을 내릴 수 없음
  • 결정을 가능한 마지막 순간까지 미루는 것이 최선
    • 이는 게으름이나 무책임이 아님
    • 최선의 정보로 정보에 입각한 선택을 할 수 있게 해줌
    • 너무 일찍 결정하면 최적화되지 않은 지식으로 결정하는 것 (고객 피드백, 구현 경험 부족)
  • POJO 시스템과 모듈화된 관심사가 제공하는 애자일성
    • 가장 최신의 지식을 바탕으로 최적의 적시(Just-In-Time) 결정 가능
    • 결정의 복잡성도 감소

표준은 입증 가능한 가치가 있을 때 신중히 사용하라

  • 표준의 가치
    • 아이디어와 컴포넌트의 재사용을 쉽게 함
    • 관련 경험을 가진 사람을 채용하기 쉬움
    • 좋은 아이디어를 캡슐화함
    • 컴포넌트를 연결하기 쉬움
  • 표준의 위험
    • 표준 생성 프로세스가 너무 오래 걸려 산업계가 기다릴 수 없는 경우가 있음
    • 일부 표준은 채택자의 실제 요구와 동떨어짐
    • 많은 팀이 더 가볍고 직관적인 설계로 충분한데도 EJB2 아키텍처를 표준이라는 이유만으로 사용
    • 다양하게 과장 선전되는 표준에 집착하다가 고객을 위한 가치 제공에 집중하지 못하게 될 수 있음
  • 핵심: 표준은 입증 가능한 가치를 추가할 때만 현명하게 사용해야 함

시스템에는 도메인 특화 언어가 필요하다

  • 건설 분야처럼 소프트웨어도 풍부한 언어가 필요
  • DSL(Domain-Specific Language): 도메인 전문가가 작성할 수 있는 산문의 구조화된 형식처럼 코드를 읽힐 수 있게 하는, 별도의 소형 스크립팅 언어 또는 표준 언어의 API
    • 도메인 개념과 그것을 구현하는 코드 사이의 “통신 격차”를 최소화
    • 도메인 로직을 도메인 전문가가 사용하는 언어로 구현한다면, 도메인을 구현으로 잘못 번역할 위험이 줄어듦
// DSL 스타일의 TypeScript 예시 (테스트 DSL)
// 도메인 전문가도 읽을 수 있는 수준의 표현력
describe("주문 처리", () => {
  it("재고가 있을 때 주문이 성공적으로 처리되어야 한다", () => {
    given(재고가_10개_있는_상품)
      .when(고객이_5개를_주문하면)
      .then(주문이_완료되고_재고가_5개_남아야_한다);
  });
});
  • DSL의 효과
    • 코드 이디엄과 디자인 패턴 위로 추상화 수준을 높임
    • 개발자가 적절한 추상화 수준에서 코드의 의도를 드러낼 수 있게 함
    • 높은 수준의 정책부터 낮은 수준의 세부 사항까지, 애플리케이션의 모든 추상화 수준과 모든 도메인을 POJO로 표현할 수 있게 함

정리

  • 시스템도 클린해야 함
    • 침습적인 아키텍처는 도메인 로직을 압도하고 애자일성을 손상시킴
    • 도메인 로직이 모호해지면 버그가 숨기 쉬워지고 스토리 구현이 어려워져 품질이 저하됨
    • 애자일성이 손상되면 생산성이 떨어지고 TDD의 이점이 사라짐
  • 모든 추상화 수준에서 의도가 명확해야 함
    • POJO를 작성하고 Aspect-like 메커니즘을 사용하여 다른 구현 관심사를 비침습적으로 통합해야만 가능
  • 항상 가능한 가장 단순한 것을 사용하라

시스템을 설계하든 개별 모듈을 설계하든, 작동할 수 있는 가장 단순한 것을 사용하는 것을 절대 잊지 마라.

핵심 요약

  • 생성과 사용의 분리: 시작 프로세스(객체 생성/연결)와 런타임 로직을 반드시 분리해야 함
  • LAZY INITIALIZATION의 함정: 편리하지만 하드코딩 의존성, SRP 위반, 테스트 어려움을 야기
  • Separation of Main: 모든 생성 코드를 main으로 이동하고, 의존성 화살표를 단방향으로 유지
  • Abstract Factory: 생성 시점의 제어권은 앱에, 생성 방법의 세부사항은 분리된 팩토리에
  • Dependency Injection: 객체는 의존성 해결에 수동적이어야 함. 컨테이너가 연결을 담당
  • 점진적 성장: 처음부터 완벽한 시스템은 없음. 오늘의 스토리만 구현하고 확장
  • 횡단 관심사: 영속성, 보안, 트랜잭션 등은 AOP/Aspect로 비침습적으로 분리
  • BDUF 지양: 큰 선행 설계는 변화 적응을 억제. 단순하게 시작하고 점진적으로 진화
  • 결정 지연: 가능한 마지막 순간까지 결정을 미뤄 최선의 정보로 결정
  • 표준의 현명한 사용: 표준은 입증 가능한 가치를 추가할 때만 사용
  • DSL: 도메인-코드 간 통신 격차를 줄이고, 의도를 적절한 추상화 수준에서 드러냄
  • 최종 원칙: 시스템 수준에서도 가능한 가장 단순한 것을 사용하라

12장. 창발성

단 네 가지 단순한 규칙을 따르는 것만으로도 좋은 설계가 창발(emerge)할 수 있다면 어떨까? Kent Beck의 단순 설계(Simple Design) 4원칙은 수십 년의 경험이 결정화된 실천법이다.

  • Jeff Langr가 저술한 챕터로, 창발적 설계(Emergent Design) 를 통해 깨끗한 코드를 얻는 방법을 다룸
  • Kent Beck의 단순 설계 4원칙이 좋은 소프트웨어를 만드는 데 결정적인 도움을 준다는 데 많은 개발자들이 동의함
  • 이 4원칙을 따를 때 SRP, DIP 같은 원칙을 더 쉽게 적용할 수 있는 통찰이 생김

우선순위 순서(Kent Beck의 단순 설계 4원칙)

우선순위 규칙
1 모든 테스트를 통과한다 (Runs all the tests)
2 중복을 없앤다 (Contains no duplication)
3 프로그래머의 의도를 표현한다 (Expresses the intent of the programmer)
4 클래스와 메서드 수를 최소화한다 (Minimizes the number of classes and methods)

창발적 설계로 깨끗해지기

  • 네 가지 규칙을 따르는 과정에서 코드 구조와 설계에 대한 통찰이 자연스럽게 창발(emerge)
  • 경험 없이도 좋은 설계 원칙을 습득할 수 있는 실천적 경로를 제공함

단순 설계 원칙 1: 모든 테스트를 통과한다

  • 설계의 제1원칙: 의도대로 동작하는 시스템을 만드는 것

    • 아무리 완벽한 설계라도 시스템이 의도대로 동작하는지 검증할 방법이 없다면 그 노력은 의심스러움
    • 테스트를 포괄적으로 작성하고 모두 통과하는 시스템 = 검증 가능한 시스템
    • 검증할 수 없는 시스템은 배포해서는 안 됨
  • 테스트 작성이 설계를 개선하는 이유

    • 테스트를 작성하려면 클래스가 작고 단일 책임(SRP)을 가져야 함 → 테스트하기 쉬운 구조로 자연스럽게 수렴
    • 테스트를 많이 작성할수록 더 단순하게 테스트 가능한 방향으로 설계가 진화함
    • 결합도(coupling)가 높으면 테스트 작성이 어려워짐 → 테스트를 쓰다 보면 자연스럽게 DIP, 의존성 주입, 인터페이스, 추상화를 활용하게 됨
  • 핵심 인사이트

    • 단순하고 명백한 규칙(“테스트를 작성하고 지속적으로 실행하라”)이 낮은 결합도 + 높은 응집도라는 OO의 핵심 목표를 달성하게 만듦
    • 테스트를 작성하면 더 나은 설계가 나온다

단순 설계 원칙 2~4: 리팩터링

  • 테스트가 있으면 리팩터링이 두렵지 않다

    • 코드를 추가하고 나서 설계가 나빠졌는지 점검하고, 나빠졌다면 정리하고 테스트를 돌려 확인
    • 테스트가 없으면 코드를 정리할 때 “혹시 망가지지 않을까?“라는 두려움이 생김
    • 테스트가 있으면 그 두려움이 사라지고 적극적인 개선이 가능해짐
  • 리팩터링 단계에서 할 수 있는 것들

    • 응집도 높이기, 결합도 낮추기
    • 관심사 분리, 시스템 모듈화
    • 함수와 클래스 크기 줄이기
    • 더 좋은 이름 선택
    • 중복 제거, 표현력 향상, 클래스·메서드 수 최소화 (규칙 2~4 적용)

중복이 없다

  • 중복은 잘 설계된 시스템의 주적(primary enemy)

    • 중복은 추가 작업, 추가 위험, 불필요한 복잡성을 유발함
    • 중복의 형태: 완전히 동일한 코드, 비슷한 코드, 구현 중복(duplication of implementation)
  • 구현 중복 제거 예시

class Collection<T> {
  private items: T[] = [];

  size(): number {
    return this.items.length;
  }

  // isEmpty가 size()와 독립적으로 구현되면 중복
  // isEmpty(): boolean {
  //   return this.items.length === 0;  // 중복!
  // }

  // size()에 위임하여 중복 제거
  isEmpty(): boolean {
    return this.size() === 0;
  }
}
  • 코드 중복 제거 예시: 공통 로직 추출
// Before: scaleToOneDimension과 rotate 모두 동일한 image 교체 로직을 포함
class ImageProcessor {
  private image: RenderedImage;

  scaleToOneDimension(desiredDimension: number, imageDimension: number): void {
    if (Math.abs(desiredDimension - imageDimension) < this.errorThreshold) return;
    const scalingFactor = Math.floor((desiredDimension / imageDimension) * 100) * 0.01;
    const newImage = ImageUtilities.getScaledImage(this.image, scalingFactor);
    this.image.dispose(); // 중복
    this.image = newImage; // 중복
  }

  rotate(degrees: number): void {
    const newImage = ImageUtilities.getRotatedImage(this.image, degrees);
    this.image.dispose(); // 중복
    this.image = newImage; // 중복
  }
}

// After: 공통 로직을 replaceImage로 추출
class ImageProcessor {
  private image: RenderedImage;

  scaleToOneDimension(desiredDimension: number, imageDimension: number): void {
    if (Math.abs(desiredDimension - imageDimension) < this.errorThreshold) return;
    const scalingFactor = Math.floor((desiredDimension / imageDimension) * 100) * 0.01;
    this.replaceImage(ImageUtilities.getScaledImage(this.image, scalingFactor));
  }

  rotate(degrees: number): void {
    this.replaceImage(ImageUtilities.getRotatedImage(this.image, degrees));
  }

  private replaceImage(newImage: RenderedImage): void {
    this.image.dispose();
    this.image = newImage;
  }
}
  • 작은 수준의 재사용이 큰 수준의 재사용으로 이어진다
    • 아주 작은 단위에서 공통성을 추출하면 SRP 위반이 드러남 → 새 메서드를 다른 클래스로 이동
    • 팀의 다른 개발자가 그 메서드를 더 추상화하여 다른 맥락에서 재사용할 기회를 발견할 수 있음
    • “작은 수준의 재사용(reuse in the small)“을 이해해야 “큰 수준의 재사용(reuse in the large)“이 가능함
  • TEMPLATE METHOD 패턴으로 고수준 중복 제거
// Before: accrueUSDivisionVacation과 accrueEUDivisionVacation이 대부분 동일
class VacationPolicy {
  accrueUSDivisionVacation(): void {
    // 근무 시간 기반 휴가 계산
    // US 최소 기준 적용
    // 급여 대장에 반영
  }

  accrueEUDivisionVacation(): void {
    // 근무 시간 기반 휴가 계산 (동일)
    // EU 최소 기준 적용 (다름)
    // 급여 대장에 반영 (동일)
  }
}

// After: TEMPLATE METHOD 패턴 적용
abstract class VacationPolicy {
  accrueVacation(): void {
    this.calculateBaseVacationHours(); // 공통
    this.alterForLegalMinimums();      // 변하는 부분만 추상화
    this.applyToPayroll();             // 공통
  }

  private calculateBaseVacationHours(): void { /* ... */ }
  protected abstract alterForLegalMinimums(): void;
  private applyToPayroll(): void { /* ... */ }
}

class USVacationPolicy extends VacationPolicy {
  protected alterForLegalMinimums(): void {
    // US 전용 로직
  }
}

class EUVacationPolicy extends VacationPolicy {
  protected alterForLegalMinimums(): void {
    // EU 전용 로직
  }
}
  • 서브클래스가 알고리즘의 “구멍(hole)“을 채우며, 중복되지 않는 정보만 제공함

의도를 잘 드러낸다

  • 유지보수 비용이 소프트웨어 비용의 대부분을 차지함
    • 변경을 도입할 때 결함 가능성을 최소화하려면 시스템이 무엇을 하는지 이해할 수 있어야 함
    • 코드를 처음 작성할 때는 문제를 깊이 이해한 상태지만, 나중에 유지보수하는 사람은 그렇지 않음
    • 코드는 저자의 의도를 명확하게 표현해야 한다 → 읽는 시간 절약 → 결함 감소 → 유지보수 비용 절감
  • 표현력을 높이는 방법
    • 좋은 이름 선택: 클래스나 함수 이름만 보고도 책임을 예측할 수 있어야 함. 이름을 듣고 놀라는 일이 없어야 함
    • 작은 함수와 클래스 유지: 크기가 작을수록 이름 짓기, 작성, 이해가 쉬워짐
    • 표준 명명법 사용: 디자인 패턴(COMMAND, VISITOR 등)의 이름을 클래스명에 포함시켜 설계 의도를 간결하게 전달
// 패턴 이름을 명시하여 표현력 향상
class UserCreatedEvent { /* ... */ }           // Event
class CreateUserCommand { /* ... */ }          // Command
class UserRepositoryVisitor { /* ... */ }      // Visitor
class ReportGeneratorStrategy { /* ... */ }   // Strategy
  • 잘 작성된 단위 테스트: 테스트의 주요 목표 중 하나는 예시를 통한 문서화. 테스트만 읽어도 클래스가 무엇을 하는지 빠르게 파악 가능해야 함
  • 가장 중요한 표현력의 원천: 노력(try)
    • 코드가 동작하면 바로 다음 문제로 넘어가는 습관이 표현력을 떨어뜨림
    • 다음에 코드를 읽는 사람은 높은 확률로 자기 자신임을 기억할 것
    • 함수와 클래스에 조금 더 시간을 투자해 더 좋은 이름, 더 작은 함수, 더 명확한 구조를 만들 것
    • 정성(care)은 희귀하고 소중한 자원이다

클래스와 메서드 수를 최소화한다

  • 중복 제거, 표현력, SRP도 극단으로 가면 역효과
    • 클래스와 메서드를 작게 만들려다 보면 너무 많은 작은 클래스와 메서드가 생길 수 있음
    • 이 규칙은 함수와 클래스 수(count)도 낮게 유지하라는 의미
  • 교조적 접근을 경계할 것
    • “모든 클래스에 인터페이스를 만들어야 한다”는 규칙을 무조건 따르는 것이 대표적인 과잉 설계 사례
    • “필드와 동작을 항상 데이터 클래스와 동작 클래스로 분리해야 한다”는 식의 도그마도 경계해야 함
    • 실용적(pragmatic) 접근이 필요하다
  • 우선순위를 기억할 것
    • 이 규칙은 4원칙 중 가장 낮은 우선순위
    • 클래스·메서드 수를 줄이는 것도 중요하지만, 테스트 통과 > 중복 제거 > 표현력이 더 중요함
    • 전체 시스템을 작게 유지하면서 동시에 함수와 클래스도 작게 유지하는 것이 목표

정리

  • 단순한 실천법들이 경험을 대체할 수는 없음
  • 하지만 이 챕터의 실천법들은 수십 년의 경험이 결정화된 형태
  • 단순 설계(Simple Design)를 실천하면 경험이 부족한 개발자도 좋은 원칙과 패턴을 준수하도록 장려되고 가능해진다

핵심 요약

  • 테스트 통과: 검증 불가한 시스템은 배포하면 안 됨 / 테스트 작성 → SRP, DIP 자연스럽게 달성
  • 중복 제거: 중복은 추가 작업·위험·복잡성의 원천 / 공통 로직 추출, TEMPLATE METHOD 패턴
  • 의도 표현: 유지보수 비용의 대부분은 코드 이해에서 발생 / 좋은 이름, 작은 단위, 표준 패턴 명명, 좋은 테스트
  • 최소화: 극단적 원칙 적용은 과잉 설계를 낳음 / 클래스·메서드 수도 낮게, 교조적 규칙 경계
  • 창발: 규칙을 따르는 과정에서 좋은 설계가 자연스럽게 떠오름 / 4원칙을 우선순위 순서대로 지속 적용
  • 리팩터링: 테스트가 있으면 두려움 없이 개선 가능 / 코드 추가 후 즉시 설계 점검 및 정리

13장. 동시성

“객체는 처리의 추상화다. 스레드는 일정(schedule)의 추상화다.” — James O. Coplien

  • 클린한 동시성 코드를 작성하는 것은 매우 어려운 일
  • 단일 스레드 코드는 작성하기 쉽지만, 멀티스레드 코드는 표면상 문제없어 보여도 내부적으로 깨져 있을 수 있음
  • 이러한 결함은 시스템에 부하가 걸릴 때까지 잘 드러나지 않음
  • 이 챕터의 구성: 동시성의 필요성 → 어려움 → 방어 원칙 → 테스트 전략

동시성이 왜 필요한가

  • 동시성은 결합 해제(decoupling) 전략이다

    • “무엇을 할 것인가(what)“와 “언제 할 것인가(when)“를 분리함
    • 단일 스레드 앱에서는 what과 when이 강하게 결합 → 스택 역추적만으로 전체 상태 파악 가능
    • 동시성을 통한 분리는 처리량(throughput)과 구조 모두를 향상시킬 수 있음
  • 구조적 이점

    • 하나의 거대한 메인 루프가 아닌, 여러 개의 작은 협력 컴퓨터처럼 보이는 구조
    • 서블릿(Servlet) 모델: 각 요청이 독립된 세계에서 실행되는 것처럼 동작
    • 관심사(concerns)를 강력하게 분리 가능
  • 성능/처리량 측면의 이점

    • 웹 크롤러 예시: 단일 스레드는 각 사이트를 순차 처리 → 웹소켓 I/O 대기 시간 낭비
      • 멀티스레드로 여러 사이트를 동시에 처리하면 성능 대폭 향상
    • 사용자 처리 예시: 단일 스레드로 사용자당 1초 처리 → 150명 대기 시 응답 불량
      • 동시 처리로 응답 시간 개선 가능
    • 대규모 데이터셋: 각 데이터셋을 다른 컴퓨터에서 병렬 처리 가능
  • 동시성에 대한 미신과 오해

미신/오해 실제 진실
동시성은 항상 성능을 향상시킨다 여러 스레드/프로세서 간에 공유할 대기 시간이 충분할 때만 향상됨
동시 프로그램 작성 시 설계가 바뀌지 않는다 동시 알고리즘 설계는 단일 스레드 설계와 근본적으로 다를 수 있음
웹/EJB 컨테이너 사용 시 동시성 문제를 이해할 필요 없다 컨테이너가 하는 일, 동시 업데이트/데드락 방어법을 반드시 알아야 함
  • 균형 잡힌 관점
    • 동시성은 성능 및 추가 코드 측면에서 오버헤드를 수반함
    • 올바른 동시성은 단순한 문제조차 복잡하게 만듦
    • 동시성 버그는 대개 재현이 어려워 “일회성 오류”로 무시되기 쉬움
    • 동시성은 설계 전략의 근본적인 변화를 요구하는 경우가 많음

동시성의 어려움

  • 동시 프로그래밍이 어려운 이유를 간단한 예시로 설명
class Counter {
  private lastIdUsed: number = 0;

  getNextId(): number {
    return ++this.lastIdUsed;
  }
}
  • lastIdUsed = 42로 설정 후 두 스레드가 동시에 getNextId() 호출 시 세 가지 결과가 발생 가능:
    1. 스레드 1이 43, 스레드 2가 44를 가져감 → lastIdUsed = 44 (정상)
    2. 스레드 1이 44, 스레드 2가 43을 가져감 → lastIdUsed = 44 (정상)
    3. 스레드 1과 스레드 2 모두 43을 가져감lastIdUsed = 43 (비정상!)
  • 왜 세 번째 결과가 발생하는가?
    • 두 스레드가 단 한 줄의 코드를 실행할 때도 수천 가지의 가능한 실행 경로가 존재함
    • Java 기준, int 타입의 경우 12,870가지, long 타입의 경우 2,704,156가지 실행 경로
    • 대부분은 유효한 결과를 내지만, 일부 경로는 잘못된 결과를 냄
    • JIT 컴파일러의 바이트코드 처리 방식과 메모리 모델의 원자성(atomicity) 이해가 필요

동시성 방어 원칙

단일 책임 원칙(Single Responsibility Principle)

  • 동시성 관련 코드는 그 자체로 변경의 이유가 되므로, 다른 코드와 반드시 분리해야 함
  • 동시성 코드가 가진 고유한 특성:
    • 자체적인 개발/변경/튜닝 생명 주기를 가짐
    • 비동시성 코드보다 훨씬 어려운 고유한 도전 과제를 가짐
    • 잘못 작성된 동시성 코드가 실패할 수 있는 방식이 너무 많음

권고사항: 동시성 관련 코드를 다른 코드와 분리하라.

데이터 범위 제한(Corollary: Limit the Scope of Data)

  • 두 스레드가 공유 객체의 같은 필드를 수정하면 서로 간섭 → 예상치 못한 동작 발생
  • 해결책: synchronized 등으로 임계 영역(critical section) 보호
  • 임계 영역의 수를 최소화해야 하는 이유:
    • 보호해야 할 곳 중 하나라도 빠뜨리면 공유 데이터를 수정하는 모든 코드가 깨짐
    • 모든 것을 제대로 보호하기 위해 중복 노력이 필요해짐 (DRY 위반)
    • 이미 찾기 어려운 실패의 원인을 파악하기가 더 어려워짐

권고사항: 데이터 캡슐화를 철저히 하라. 공유될 수 있는 데이터에 대한 접근을 엄격히 제한하라.

데이터 복사본 사용(Corollary: Use Copies of Data)

  • 공유 자체를 피하는 것이 가장 좋은 방법
    • 객체를 복사하여 읽기 전용으로 취급
    • 여러 스레드에서 복사본에 결과를 수집한 후, 단일 스레드에서 병합
  • 추가적인 객체 생성 비용에 대한 우려:
    • 실제로 문제가 되는지 실험해볼 것
    • 동기화를 피함으로써 얻는 이득(내부 락 비용 절감)이 추가 생성/GC 오버헤드를 상쇄할 가능성이 높음

스레드 독립성(Corollary: Threads Should Be as Independent as Possible)

  • 각 스레드가 자신만의 세계에 존재하도록 설계
    • 다른 스레드와 데이터를 공유하지 않음
    • 각 스레드는 공유되지 않은 소스에서 모든 필요 데이터를 가져오고, 로컬 변수에 저장
    • 마치 세상에 자신이 유일한 스레드인 것처럼 동작 → 동기화 요구 없음
  • HttpServlet 예시:
    • doGet, doPost 메서드의 파라미터로 모든 정보를 받음
    • 로컬 변수만 사용하는 한 동기화 문제 발생 안 함
    • 실제 앱에서는 DB 커넥션 같은 공유 자원이 결국 등장하지만, 원칙은 유지할 것

권고사항: 독립적인 스레드(가능하면 다른 프로세서)에서 독립적으로 처리 가능한 독립적인 서브셋으로 데이터를 분리하라.

라이브러리를 알라

  • Node.js/TypeScript 환경에서 동시성 작성 시 고려 사항:
    • 스레드 안전한(thread-safe) 컬렉션 사용
    • 비관련 작업 실행을 위한 실행자(executor) 프레임워크 활용
    • 가능한 경우 논블로킹(non-blocking) 솔루션 사용
    • 스레드 안전하지 않은 라이브러리 클래스에 주의
  • Java 기준 주요 동시성 지원 클래스 (개념 이해용):
클래스/개념 설명
ReentrantLock 한 메서드에서 획득하고 다른 메서드에서 해제할 수 있는 락
Semaphore 카운트가 있는 락(클래식 세마포어 구현)
CountDownLatch 일정 수의 이벤트 후 대기 중인 모든 스레드를 해제하는 락. 모든 스레드가 거의 동시에 시작할 수 있게 해줌

권고사항: 사용 가능한 클래스를 검토하라. Java라면 java.util.concurrent, java.util.concurrent.atomic, java.util.concurrent.locks에 익숙해져라.

실행 모델을 알라

주요 개념 정의

개념 정의
한정된 자원 (Bound Resources) 동시 환경에서 사용되는 고정된 크기 또는 수의 자원 (DB 커넥션, 고정 크기 버퍼 등)
상호 배제 (Mutual Exclusion) 한 번에 오직 하나의 스레드만 공유 데이터나 공유 자원에 접근할 수 있음
기아 (Starvation) 하나 이상의 스레드가 지나치게 오랫동안 또는 영원히 진행하지 못하도록 막히는 상태
데드락 (Deadlock) 둘 이상의 스레드가 서로 끝나기를 기다리는 상태. 각 스레드는 상대방이 필요로 하는 자원을 보유
라이브락 (Livelock) 스레드들이 서로 발맞춰 진행하려 하지만 번번이 막혀 오랫동안 또는 영원히 진행하지 못하는 상태

생산자-소비자(Producer-Consumer)

graph LR
    P1[생산자 스레드 1] --> Q[(버퍼/큐)]
    P2[생산자 스레드 2] --> Q
    Q --> C1[소비자 스레드 1]
    Q --> C2[소비자 스레드 2]
    Q -- "빈 공간 신호" --> P1
    Q -- "빈 공간 신호" --> P2
    C1 -- "소비 신호" --> Q
    C2 -- "소비 신호" --> Q
  • 하나 이상의 생산자 스레드가 작업을 만들어 버퍼/큐에 넣음
  • 하나 이상의 소비자 스레드가 큐에서 작업을 꺼내 완료함
  • 큐는 한정된 자원(bound resource)
    • 생산자: 쓰기 전에 빈 공간이 생길 때까지 대기
    • 소비자: 소비할 항목이 생길 때까지 대기
  • 생산자와 소비자 간의 신호(signal) 협조가 핵심:
    • 생산자가 쓴 후: “큐가 더 이상 비어 있지 않음” 신호 전달
    • 소비자가 읽은 후: “큐가 더 이상 가득 차 있지 않음” 신호 전달

읽기-쓰기(Readers-Writers)

  • 공유 자원이 주로 독자(readers)를 위한 정보 소스이지만, 가끔 작성자(writers)가 업데이트하는 상황
  • 핵심 긴장 관계:
    • 처리량 강조 → 기아(starvation) 및 낡은 정보 축적 위험
    • 업데이트 허용 → 처리량 저하 위험
  • 균형을 찾는 것이 핵심 과제:
    • 단순 전략: 독자가 없을 때까지 작성자 대기 → 독자가 지속적이면 작성자 기아
    • 작성자 우선 전략 → 처리량 저하
    • 동시 업데이트 문제 방지와 처리량 확보를 동시에 만족하는 균형점 탐색 필요

식사하는 철학자(Dining Philosophers)

graph TD
    P1((철학자 1)) -- 왼쪽 포크 --> F1[포크 1]
    P1 -- 오른쪽 포크 --> F2[포크 2]
    P2((철학자 2)) -- 왼쪽 포크 --> F2
    P2 -- 오른쪽 포크 --> F3[포크 3]
    P3((철학자 3)) -- 왼쪽 포크 --> F3
    P3 -- 오른쪽 포크 --> F4[포크 4]
  • 원탁에 앉은 철학자들, 각자 왼쪽에 포크 하나, 가운데 스파게티 그릇
  • 식사하려면 양쪽 포크 2개 필요
  • 옆 철학자가 포크를 쓰고 있으면 대기
  • 철학자 = 스레드, 포크 = 자원으로 치환하면 많은 엔터프라이즈 앱의 상황과 동일
  • 신중하게 설계하지 않으면: 데드락, 라이브락, 처리량 저하, 효율 저하 발생

권고사항: 이 기본 알고리즘들을 학습하고 직접 해결책을 작성해보라. 동시성 문제에 직면했을 때 훨씬 잘 준비될 것이다.

동기화 메서드 간 의존성을 경계하라

  • 동기화된 메서드 간의 의존성은 동시성 코드에서 미묘한 버그를 유발함
  • 공유 클래스에 동기화 메서드가 둘 이상 있으면 시스템이 잘못 작성된 것일 수 있음
  • 둘 이상의 메서드를 써야 할 경우의 세 가지 접근법:
방식 설명
클라이언트 기반 락 (Client-Based Locking) 클라이언트가 서버 호출 전 락을 걸고, 마지막 메서드 호출 후 해제
서버 기반 락 (Server-Based Locking) 서버 내에서 모든 메서드를 호출하는 락 메서드를 만들고 클라이언트는 그것만 호출
적응된 서버 (Adapted Server) 원본 서버를 변경할 수 없을 때, 락을 수행하는 중개자(intermediary)를 생성

권고사항: 공유 객체에서 둘 이상의 메서드를 사용하는 것을 피하라.

동기화 영역을 작게 유지하라

  • synchronized 키워드는 락을 도입함
  • 동일한 락으로 보호된 코드 섹션은 한 번에 하나의 스레드만 실행 보장
  • 락은 비용이 크다: 지연(delay)과 오버헤드를 만들어냄
  • 임계 영역(critical section)은 보호되어야 하지만, 코드 전체에 synchronized를 남발하면 안 됨
  • 잘못된 접근: 임계 영역을 매우 크게 만들어 동기화를 달성하려는 시도
    • 동기화가 최소 임계 영역을 넘어 확장되면 경합(contention)이 증가하고 성능이 저하됨

권고사항: 동기화 영역을 최대한 작게 유지하라.

올바른 종료 코드 작성은 어렵다

  • 영구적으로 실행되는 시스템과 우아하게 종료(graceful shutdown) 되는 시스템은 다름
  • 우아한 종료는 올바르게 구현하기 어려움 → 주요 문제: 데드락
  • 데드락 예시 1: 부모 스레드가 자식 스레드들을 생성하고 모두 완료될 때까지 대기
    • 하나의 자식 스레드가 데드락 상태 → 부모는 영원히 대기 → 시스템 종료 불가
  • 데드락 예시 2: 생산자/소비자 쌍에 종료 신호를 보내는 경우
    • 생산자가 신호를 받고 빠르게 종료
    • 소비자는 생산자의 메시지를 기다리는 블로킹 상태 → 종료 신호 수신 불가
    • 소비자가 영원히 대기 → 부모도 종료 불가

권고사항: 종료에 대해 일찍부터 생각하고, 일찍부터 동작하게 만들어라. 예상보다 훨씬 오래 걸릴 것이다. 기존 알고리즘을 검토하라. 생각보다 어렵다.

스레드 코드 테스트

  • 코드의 정확성을 증명하는 것은 비현실적, 테스트가 정확성을 보장하지는 않음
  • 그럼에도 좋은 테스트는 리스크를 최소화함
  • 두 개 이상의 스레드가 공유 데이터를 사용하면 복잡성이 급격히 증가함

권고사항: 문제를 노출할 가능성이 있는 테스트를 작성하고, 다양한 프로그래밍/시스템/부하 설정으로 자주 실행하라. 테스트가 실패하면 원인을 추적하라. 이후 실행에서 통과한다고 해서 실패를 무시하지 마라.

산발적 실패를 스레딩 이슈 후보로 취급하라(Treat Spurious Failures as Candidate Threading Issues)

  • 스레드 코드는 “절대 실패할 수 없는” 것들을 실패하게 만듦
  • 스레딩 버그는 수천 번 또는 수백만 번 실행 중 한 번 나타날 수 있음
  • 개발자들이 “하드웨어 결함” 또는 “일회성 오류”로 무시하는 경향 → 위험
  • 이런 “일회성”들을 무시할수록, 결함 있는 기반 위에 더 많은 코드가 쌓임

권고사항: 시스템 실패를 일회성으로 무시하지 마라.

스레드 코드 작성 전 비스레드 코드를 먼저 동작시켜라(Get Your Nonthreaded Code Working First)

  • 스레드 외부에서 코드가 먼저 동작함을 확인
  • 스레드를 인식하지 못하는 POJO(순수 객체)를 만들어 스레드 외부에서 테스트
  • 시스템의 많은 부분을 이런 POJO에 넣을수록 좋음

권고사항: 비스레딩 버그와 스레딩 버그를 동시에 쫓으려 하지 마라. 먼저 스레드 외부에서 코드가 동작함을 확인하라.

스레드 코드를 플러그 가능하게 만들어라(Make Your Threaded Code Pluggable)

  • 여러 설정으로 실행할 수 있도록 동시성 지원 코드를 작성:
    • 스레드 1개, 여러 개, 실행 중 가변적
    • 실제 환경 또는 테스트 더블(test double)과 상호작용하는 스레드 코드
    • 빠르게, 느리게, 가변적으로 실행되는 테스트 더블
    • 여러 번의 반복으로 실행될 수 있도록 테스트 설정

권고사항: 다양한 설정으로 실행할 수 있도록 스레드 기반 코드를 특히 플러그 가능하게 만들어라.

스레드 코드를 튜닝 가능하게 만들어라(Make Your Threaded Code Tunable)

  • 올바른 스레드 수를 맞추려면 시행착오가 필요함
  • 초기에 다양한 설정에서 시스템 성능을 측정할 방법을 마련
  • 스레드 수를 쉽게 조정할 수 있게 할 것
  • 시스템 실행 중에도 변경 가능하게 고려
  • 처리량과 시스템 활용률에 따른 자동 튜닝도 고려

프로세서보다 많은 스레드로 실행하라(Run with More Threads Than Processors)

  • 시스템이 작업 간에 스위칭할 때 문제가 발생함
  • 태스크 스위핑을 유도하려면 프로세서나 코어보다 많은 스레드로 실행
  • 태스크 스와핑이 잦을수록 임계 영역 누락이나 데드락 코드를 발견할 가능성 증가

다양한 플랫폼에서 실행하라(Run on Different Platforms)

  • 운영체제마다 스레딩 정책이 다름 → 코드 실행에 영향을 미침
  • 멀티스레드 코드는 다른 환경에서 다르게 동작함
  • 실제 사례: OS X에서 작성된 코드가 Windows XP VM에서 실패 조건이 덜 자주 발생
    • 코드가 잘못되었음을 알면서도 플랫폼마다 다르게 나타남

권고사항: 가능한 모든 배포 환경에서 스레드 코드를 일찍, 자주 실행하라.

실패를 강제하기 위해 코드를 계측하라(Instrument Your Code to Try and Force Failures)

  • 동시성 결함은 잘 숨어있음 → 몇 시간, 며칠, 몇 주에 한 번 나타나기도 함
  • 원인: 취약한 구간의 수천 가지 실행 경로 중 실패를 유발하는 경로의 확률이 매우 낮기 때문
  • 실행 순서를 변경해 결함 발견 확률 높이기: wait(), sleep(), yield(), priority() 호출 삽입

방법 1: 수동 계측(Hand-Coded)

// TypeScript Worker Thread 환경 예시
function nextUrlOrNull(): string | null {
  if (hasNext()) {
    const url = urlGenerator.next();
    // 테스트용으로 삽입: 실행 경로를 변경하여 잠재적 결함 노출
    // await new Promise(resolve => setImmediate(resolve));
    updateHasNext();
    return url;
  }
  return null;
}
  • 수동 계측의 문제점:
    • 적절한 위치를 수동으로 찾아야 함
    • 어디에 어떤 호출을 넣어야 할지 알기 어려움
    • 프로덕션 환경에 남겨두면 불필요하게 코드가 느려짐
    • 산탄총(shotgun) 접근법 → 결함 발견이 운에 달림

방법 2: 자동 계측(Automated)

// 테스트 환경에서는 랜덤하게 sleep/yield, 프로덕션에서는 아무것도 안 함
class ThreadJigglePoint {
  static jiggle(): void {
    // 프로덕션 구현: 아무것도 안 함
  }
}

// 테스트 구현 (랜덤 지연 삽입)
class ThreadJigglePointTest extends ThreadJigglePoint {
  static jiggle(): void {
    const rand = Math.random();
    if (rand < 0.33) {
      // nothing
    } else if (rand < 0.66) {
      // simulate yield
      setTimeout(() => {}, 0);
    } else {
      // simulate sleep
      const end = Date.now() + Math.floor(Math.random() * 10);
      while (Date.now() < end) { /* busy wait */ }
    }
  }
}
  • IBM의 ConTest 같은 도구는 이를 훨씬 정교하게 수행
  • 핵심: 코드를 흔들어(jiggle) 다른 시간에 다른 순서로 스레드가 실행되게 함
  • **잘 작성된 테스트 + 지글링(jiggling)**의 조합 → 오류 발견 가능성 대폭 향상

권고사항: 오류를 찾아내기 위해 지글링 전략을 사용하라.

정리

  • 동시성 코드를 올바르게 구현하기는 어렵다
  • 단순해 보이는 코드도 여러 스레드와 공유 데이터가 섞이면 악몽이 될 수 있음
  • 동시성 코드를 작성해야 한다면, 엄격함을 갖춘 클린 코드를 작성해야 함

핵심 실천 사항 정리:

  • SRP 준수: 스레드 인식 코드와 스레드 무관 코드를 POJO로 분리
  • 스레드 인식 코드 테스트 시: 그것만 테스트할 것 → 코드가 작고 집중적이어야 함
  • 동시성 이슈의 원인 파악: 공유 데이터에 접근하는 여러 스레드, 공통 자원 풀
  • 경계 조건 특별 주의: 우아한 종료, 루프의 마지막 반복 등
  • 라이브러리와 알고리즘 학습: 기본 알고리즘이 해결하는 문제 유형 이해
  • 락 범위 최소화: 락이 필요한 코드 영역만 락 → 락 영역에서 다른 락 영역 호출 금지
  • 공유 객체 범위 최소화: 공유 객체 수와 공유 범위를 최대한 좁게
  • 테스트 가능성(Testability): TDD의 3법칙을 따르면 자연스럽게 확보 → 플러그 가능성 향상
  • 계측(Instrumentation): 스레드 기반 코드를 프로덕션 배포 전 최대한 오래 실행할 것

“클린한 접근 방식을 취하면, 올바르게 구현할 가능성이 크게 높아진다.”

핵심 요약

  • 동시성의 본질: what(무엇)과 when(언제)을 분리하는 결합 해제 전략
  • SRP: 동시성 코드는 독립적인 변경 이유 → 반드시 분리
  • 데이터 범위 제한: 공유 데이터 접근을 엄격히 제한, 임계 영역 최소화
  • 데이터 복사: 가능하면 공유 자체를 피하라
  • 스레드 독립성: 각 스레드가 자신만의 세계에서 동작하도록 설계
  • 실행 모델: 생산자-소비자, 읽기-쓰기, 식사하는 철학자 패턴을 숙지
  • 동기화 영역: 가능한 한 작게 유지, 락 영역에서 락 영역 호출 금지
  • 종료 코드: 우아한 종료는 예상보다 어렵다. 일찍 시작하라
  • 산발적 실패: 일회성으로 무시하지 말고 스레딩 이슈 후보로 취급
  • 계측: 코드를 흔들어(jiggle) 숨겨진 동시성 결함을 드러내라

14장. 점진적인 개선

잘 동작하는 코드만으로는 충분하지 않다. 동작하는 코드는 종종 심각하게 망가져 있다. 단순히 동작하는 코드에 만족하는 프로그래머는 비전문가처럼 행동하는 것이다.

  • 이 챕터는 점진적 개선(Successive Refinement) 에 관한 케이스 스터디
  • 처음에는 잘 시작했지만 확장성이 부족했던 모듈을 보여주고, 이후 어떻게 리팩터링하고 정리했는지 보여줌
  • 다루는 예제: 커맨드라인 인수 파서(Args)
    • 유사한 유틸리티가 여럿 있지만 원하는 기능을 정확히 갖춘 것이 없어 직접 작성
    • 사용이 매우 단순하고 직관적인 API 설계를 목표로 함

Args 사용 예시

  • Args 클래스는 두 개의 파라미터로 생성됨
    • 첫 번째 파라미터: 포맷(스키마) 문자열
    • 두 번째 파라미터: 커맨드라인 인수 배열
  • 스키마 문자열 규칙
표기 의미
l Boolean 인수
p# Integer 인수
d* String 인수
x## Double 인수
s[*] String Array 인수
// 사용 예시
function main(args: string[]): void {
  try {
    const arg = new Args("l,p#,d*", args);
    const logging: boolean = arg.getBoolean('l');
    const port: number = arg.getInt('p');
    const directory: string = arg.getString('d');
    executeApplication(logging, port, directory);
  } catch (e) {
    if (e instanceof ArgsException) {
      console.error(`Argument error: ${e.errorMessage()}`);
    }
  }
}
  • 생성자가 예외 없이 반환되면 파싱 완료 → getBoolean, getInt, getString 등으로 값 조회 가능
  • 문제가 있을 경우 ArgsException 발생, errorMessage()로 상세 내용 확인 가능

목표 지점(Args 최종 구현)

  • 코드를 위에서 아래로 큰 점프 없이 읽을 수 있도록 구조화됨
  • ArgumentMarshaler 인터페이스를 중심으로 설계
interface ArgumentMarshaler {
  set(currentArgument: Iterator<string>): void;
  get(): unknown;
}
  • BooleanArgumentMarshaler
class BooleanArgumentMarshaler implements ArgumentMarshaler {
  private booleanValue: boolean = false;

  set(currentArgument: Iterator<string>): void {
    this.booleanValue = true;
  }

  get(): unknown {
    return this.booleanValue;
  }

  static getValue(am: ArgumentMarshaler | undefined): boolean {
    if (am instanceof BooleanArgumentMarshaler) {
      return am.get() as boolean;
    }
    return false;
  }
}
  • StringArgumentMarshaler
class StringArgumentMarshaler implements ArgumentMarshaler {
  private stringValue: string = "";

  set(currentArgument: Iterator<string>): void {
    const result = currentArgument.next();
    if (result.done) throw new ArgsException(ErrorCode.MISSING_STRING);
    this.stringValue = result.value;
  }

  get(): unknown {
    return this.stringValue;
  }
}
  • IntegerArgumentMarshaler
class IntegerArgumentMarshaler implements ArgumentMarshaler {
  private intValue: number = 0;

  set(currentArgument: Iterator<string>): void {
    const result = currentArgument.next();
    if (result.done) throw new ArgsException(ErrorCode.MISSING_INTEGER);
    const parsed = parseInt(result.value, 10);
    if (isNaN(parsed)) {
      throw new ArgsException(ErrorCode.INVALID_INTEGER, result.value);
    }
    this.intValue = parsed;
  }

  get(): unknown {
    return this.intValue;
  }
}
  • ArgsException — 에러코드와 에러 메시지를 하나의 클래스로 캡슐화
enum ErrorCode {
  OK,
  INVALID_ARGUMENT_FORMAT,
  UNEXPECTED_ARGUMENT,
  INVALID_ARGUMENT_NAME,
  MISSING_STRING,
  MISSING_INTEGER,
  INVALID_INTEGER,
  MISSING_DOUBLE,
  INVALID_DOUBLE,
}

class ArgsException extends Error {
  private errorArgumentId: string = '\0';
  private errorParameter: string | null = null;
  private errorCode: ErrorCode = ErrorCode.OK;

  constructor(
    errorCode?: ErrorCode,
    errorArgumentId?: string,
    errorParameter?: string | null
  ) {
    super();
    if (errorCode !== undefined) this.errorCode = errorCode;
    if (errorArgumentId !== undefined) this.errorArgumentId = errorArgumentId;
    if (errorParameter !== undefined) this.errorParameter = errorParameter;
  }

  errorMessage(): string {
    switch (this.errorCode) {
      case ErrorCode.UNEXPECTED_ARGUMENT:
        return `Argument -${this.errorArgumentId} unexpected.`;
      case ErrorCode.MISSING_STRING:
        return `Could not find string parameter for -${this.errorArgumentId}.`;
      case ErrorCode.INVALID_INTEGER:
        return `Argument -${this.errorArgumentId} expects an integer but was '${this.errorParameter}'.`;
      case ErrorCode.MISSING_INTEGER:
        return `Could not find integer parameter for -${this.errorArgumentId}.`;
      // ... 기타 에러코드
      default:
        return "";
    }
  }
}
  • 새로운 인수 타입 추가 시 필요한 변경 사항은 최소화됨
    • ArgumentMarshaler의 새 구현 클래스 작성
    • parseSchemaElement에 새 케이스 추가
    • getXXX 메서드 추가
    • ErrorCode 및 에러 메시지 추가

어떻게 여기까지 왔는가

“나는 이 프로그램을 처음부터 지금의 형태로 작성하지 않았다. 더 중요한 것은, 여러분도 한 번에 깨끗하고 우아한 프로그램을 작성할 수 있을 것이라 기대하지 않는다.”

  • 프로그래밍은 과학이 아니라 공예(craft) 에 가까움
  • 클린 코드를 작성하려면 먼저 지저분한 코드를 작성하고 그것을 정리해야 함
  • 이는 새로운 진실이 아님
    • 학교에서 글쓰기를 배울 때도 초안 → 2차 초안 → 여러 번의 수정 과정을 거침
    • 점진적 개선이 곧 글쓰기의 핵심
  • 많은 신입 프로그래머의 실수
    • “일단 동작하면 끝”이라는 사고방식
    • 동작하게 만든 뒤 다음 작업으로 이동, 코드를 지저분한 채로 방치
    • 이것은 전문가적 자살(professional suicide)

초안

  • 초안은 동작은 했지만 엉망이었음
  • 초안의 문제점들
    • 인스턴스 변수의 수가 압도적으로 많음
    • "TILT" 같은 이상한 문자열 상수
    • HashSet, TreeSet 등이 혼재
    • try-catch-catch 블록의 남용
    • 타입별로 분리된 여러 개의 Map (booleanArgs, stringArgs, intArgs)
    • 복잡하게 얽힌 에러 처리 로직
// 초안의 문제적 구조 (타입스크립트 번안)
class Args {
  private schema: string;
  private args: string[];
  private valid: boolean = true;
  private unexpectedArguments: Set<string> = new Set();
  private booleanArgs: Map<string, boolean> = new Map();   // 타입별로 분리된 맵
  private stringArgs: Map<string, string> = new Map();     // 타입별로 분리된 맵
  private intArgs: Map<string, number> = new Map();        // 타입별로 분리된 맵
  private argsFound: Set<string> = new Set();
  private currentArgument: number = 0;
  private errorArgumentId: string = '\0';
  private errorParameter: string = "TILT";   // 이상한 매직 스트링
  private errorCode: ErrorCode = ErrorCode.OK;
  // ...
}

초안의 성장 과정

  • 처음에는 Boolean 인수만 처리하는 깔끔한 버전이 있었음
    • 간결하고 단순하며 이해하기 쉬운 코드
    • 하지만 이미 문제의 씨앗이 존재했음
  • String 인수 타입을 추가하자 코드가 복잡해지기 시작
    • stringArgs 맵 추가
    • 에러 코드, 에러 처리 로직 추가
    • parseSchemaElement, setArgument 등 여러 곳 수정
  • Integer 인수 타입을 추가하자 코드가 본격적으로 썩기 시작
    • 단 두 가지 타입만 추가했는데도 유지보수성이 급격히 하락
    • 버그와 문제가 쉽게 생길 수 있는 구조로 전락

핵심 인사이트: 타입 두 개를 추가하는 작은 변화가 코드에 엄청난 부정적 영향을 미쳤다. 이것이 바로 나쁜 구조의 위험성이다.

멈추는 결단

  • 두 가지 이상의 인수 타입을 더 추가해야 한다는 것을 알고 있었음
  • 그냥 밀어붙이면 동작은 하겠지만, 수습 불가능한 혼란을 남길 것
  • 코드 구조가 유지보수 가능하려면 지금이 고칠 때라는 판단

문제 패턴 인식

  • 각 인수 타입을 추가할 때마다 세 군데를 수정해야 함
    • 스키마 파싱: 해당 타입의 스키마 요소를 파싱하여 적절한 HashMap에 넣는 코드
    • 커맨드라인 파싱: 문자열을 실제 타입으로 변환하는 코드
    • getter 메서드: 호출자에게 실제 타입으로 반환하는 코드
  • “여러 타입, 비슷한 메서드들 → 이건 클래스다!” 라는 통찰
  • 이렇게 ArgumentMarshaler 개념이 탄생

점진주의

개선이라는 명목 하에 프로그램의 구조를 대규모로 변경하는 것은 프로그램을 망치는 가장 좋은 방법 중 하나다.

  • 대규모 변경의 위험성
    • 많은 프로그램이 이러한 “개선”에서 회복하지 못함
    • 변경 전과 동일하게 동작하도록 만드는 것이 매우 어려움
  • 해결책: TDD(테스트 주도 개발)
    • 핵심 원칙: 시스템을 항상 동작하는 상태로 유지
    • 시스템을 깨뜨리는 변경을 하지 않음
    • 모든 변경은 이전과 동일하게 시스템이 동작함을 보장해야 함
  • 자동화된 테스트 스위트의 중요성
    • 원하는 때 언제든 실행 가능
    • 시스템 동작이 변경되지 않았음을 검증
    • 테스트가 통과하면 시스템이 명세한 대로 동작한다는 확신

리팩터링 전략: 수많은 아주 작은 변경들

  • 작은 단계로 이동, 각 단계마다 테스트 통과 확인
  • 테스트가 깨지면 즉시 수정 후 다음 단계로 진행
  • 리팩터링은 루빅스 큐브 풀기와 같음
    • 큰 목표를 달성하기 위해 수많은 작은 단계가 필요
    • 각 단계가 다음 단계를 가능하게 함
  • 첫 번째 변경: 기존 코드 끝에 ArgumentMarshaler 뼈대 추가 → 아무것도 깨지지 않음
// 1단계: ArgumentMarshaler 뼈대를 Args 내부에 추가
class ArgumentMarshaler {
  private booleanValue: boolean = false;

  setBoolean(value: boolean): void {
    this.booleanValue = value;
  }

  getBoolean(): boolean {
    return this.booleanValue;
  }
}

class BooleanArgumentMarshaler extends ArgumentMarshaler {}
class StringArgumentMarshaler extends ArgumentMarshaler {}
class IntegerArgumentMarshaler extends ArgumentMarshaler {}

리팩터링 과정 상세

Boolean 인수 마이그레이션

  • booleanArgs의 맵 타입을 Map<string, ArgumentMarshaler>로 변경
// 변경 전
private booleanArgs: Map<string, boolean> = new Map();

// 변경 후
private booleanArgs: Map<string, ArgumentMarshaler> = new Map();
  • 이 변경이 일부 코드를 깨뜨림 → 즉시 수정
private parseBooleanSchemaElement(elementId: string): void {
  this.booleanArgs.set(elementId, new BooleanArgumentMarshaler());
}

private setBooleanArg(argChar: string, value: boolean): void {
  this.booleanArgs.get(argChar)!.setBoolean(value);
}

public getBoolean(arg: string): boolean {
  const am = this.booleanArgs.get(arg);
  return am !== undefined && am.getBoolean();
}
  • 주의: getBoolean에서 존재하지 않는 키 조회 시 null 반환 → NullPointerException 위험
    • falseIfNull 함수가 있었지만 변경으로 인해 무의미해짐
    • null 체크 위치를 Boolean이 아닌 ArgumentMarshaler로 이동

String/Integer 인수 마이그레이션

  • 동일한 패턴으로 stringArgs, intArgsArgumentMarshaler 맵으로 전환
  • ArgumentMarshaler 베이스 클래스에 모든 마샬링 구현을 먼저 집중
abstract class ArgumentMarshaler {
  protected booleanValue: boolean = false;
  protected stringValue: string = "";
  protected integerValue: number = 0;

  setBoolean(value: boolean): void { this.booleanValue = value; }
  getBoolean(): boolean { return this.booleanValue; }
  setString(s: string): void { this.stringValue = s; }
  getString(): string { return this.stringValue; }
  setInteger(i: number): void { this.integerValue = i; }
  getInteger(): number { return this.integerValue; }

  abstract set(s: string): void;
}
  • 이후 각 구현체로 기능을 아래로 밀어내기(push down)

통합 맵으로 단순화

  • 세 개의 별도 맵을 하나의 marshalers 맵으로 통합
// 기존: 타입별 3개의 맵
private booleanArgs: Map<string, ArgumentMarshaler> = new Map();
private stringArgs: Map<string, ArgumentMarshaler> = new Map();
private intArgs: Map<string, ArgumentMarshaler> = new Map();

// 개선: 단 하나의 통합 맵
private marshalers: Map<string, ArgumentMarshaler> = new Map();
  • 타입 판별 로직 변화
// 기존: 맵 존재 여부로 타입 판별
private isBooleanArg(argChar: string): boolean {
  return this.booleanArgs.has(argChar);
}

// 개선: instanceof로 타입 판별
private isBooleanArg(m: ArgumentMarshaler): boolean {
  return m instanceof BooleanArgumentMarshaler;
}
  • setArgument의 타입별 분기가 단일 marshalers 조회로 단순화됨
private setArgument(argChar: string): boolean {
  const m = this.marshalers.get(argChar);
  if (m instanceof BooleanArgumentMarshaler)
    this.setBooleanArg(argChar);
  else if (m instanceof StringArgumentMarshaler)
    this.setStringArg(argChar);
  else if (m instanceof IntegerArgumentMarshaler)
    this.setIntArg(argChar);
  else
    return false;
  return true;
}

Iterator 도입으로 set 함수 통합

  • 문제: setIntArgargscurrentArgument 두 인스턴스 변수를 사용
    • 이 함수를 IntegerArgumentMarshaler로 이동하려면 두 변수를 파라미터로 전달해야 함
    • 두 인수를 넘기는 것은 지저분함 → 하나로 줄이고 싶음
  • 해결책: args 배열을 리스트로 변환하고 Iteratorset 함수에 전달
// 변경 전: 배열 인덱스 방식
private currentArgument: number = 0;
private args: string[];

// 변경 후: Iterator 방식
private currentArgument: Iterator<string>;
private argsList: string[];

// 생성자에서
this.argsList = Array.from(args);
  • ArgumentMarshaler에 새 추상 메서드 추가
interface ArgumentMarshaler {
  set(currentArgument: Iterator<string>): void;
  get(): unknown;
}
  • 각 구현체에서 Iterator를 활용하여 직접 값을 읽음
class StringArgumentMarshaler implements ArgumentMarshaler {
  private stringValue: string = "";

  set(currentArgument: Iterator<string>): void {
    const result = currentArgument.next();
    if (result.done) {
      throw new ArgsException(ErrorCode.MISSING_STRING);
    }
    this.stringValue = result.value;
  }

  get(): unknown { return this.stringValue; }
}

타입-케이스(type-case) 제거 — 최종 목표 달성

  • setArgumentif-else 체인을 완전히 제거
// 개선 전: if-else 타입 분기 존재
private setArgument(argChar: string): boolean {
  const m = this.marshalers.get(argChar);
  if (!m) return false;
  try {
    if (m instanceof BooleanArgumentMarshaler)
      m.set(this.currentArgument);
    else if (m instanceof StringArgumentMarshaler)
      m.set(this.currentArgument);
    else if (m instanceof IntegerArgumentMarshaler)
      m.set(this.currentArgument);
  } catch (e) { /* ... */ }
  return true;
}

// 개선 후: 완전한 다형성 활용
private setArgument(argChar: string): boolean {
  const m = this.marshalers.get(argChar);
  if (!m) return false;
  try {
    m.set(this.currentArgument);   // 타입 분기 없이 단일 호출
    return true;
  } catch (e) {
    if (e instanceof ArgsException) {
      e.setErrorArgumentId(argChar);
      throw e;
    }
  }
  return false;
}

이것이 리팩터링의 목표였다: 타입 케이스를 제거하고 다형성이 올바른 구현을 자동으로 선택하게 만드는 것

새 타입 추가가 얼마나 쉬워졌는가

  • 리팩터링 이후 새로운 Double 인수 타입 추가 과정이 매우 단순해짐

Double 타입 추가 절차

1단계: 테스트 케이스 작성

test('testSimpleDoublePresent', () => {
  const args = new Args("x##", ["-x", "42.3"]);
  expect(args.isValid()).toBe(true);
  expect(args.cardinality()).toBe(1);
  expect(args.has('x')).toBe(true);
  expect(args.getDouble('x')).toBeCloseTo(42.3, 3);
});

2단계: 스키마 파싱에 ## 케이스 추가

private parseSchemaElement(element: string): void {
  const elementId = element[0];
  const elementTail = element.substring(1);
  this.validateSchemaElementId(elementId);

  if (elementTail.length === 0)
    this.marshalers.set(elementId, new BooleanArgumentMarshaler());
  else if (elementTail === "*")
    this.marshalers.set(elementId, new StringArgumentMarshaler());
  else if (elementTail === "#")
    this.marshalers.set(elementId, new IntegerArgumentMarshaler());
  else if (elementTail === "##")
    this.marshalers.set(elementId, new DoubleArgumentMarshaler());  // 추가
  else
    throw new ArgsException(ErrorCode.INVALID_ARGUMENT_FORMAT, elementId, elementTail);
}

3단계: DoubleArgumentMarshaler 구현 작성

class DoubleArgumentMarshaler implements ArgumentMarshaler {
  private doubleValue: number = 0;

  set(currentArgument: Iterator<string>): void {
    const result = currentArgument.next();
    if (result.done) throw new ArgsException(ErrorCode.MISSING_DOUBLE);
    const parsed = parseFloat(result.value);
    if (isNaN(parsed)) {
      throw new ArgsException(ErrorCode.INVALID_DOUBLE, result.value);
    }
    this.doubleValue = parsed;
  }

  get(): unknown { return this.doubleValue; }
}

4단계: 새 ErrorCode 추가

enum ErrorCode {
  OK,
  // ...기존 코드...
  MISSING_DOUBLE,
  INVALID_DOUBLE,
}

5단계: getDouble 메서드 추가

public getDouble(arg: string): number {
  const am = this.marshalers.get(arg);
  try {
    return am === undefined ? 0 : (am.get() as number);
  } catch {
    return 0.0;
  }
}
  • 결과: 변경이 매우 적고, 변경 범위가 격리되어 있으며, 테스트가 전부 통과

에러 처리 분리

  • 예외 처리 코드가 Args 클래스에 섞여 있는 것은 SRP(단일 책임 원칙) 위반
  • ParseException을 던지는 것도 Args 클래스의 도메인이 아님
  • 해결: 모든 예외를 ArgsException으로 통합하고 별도 모듈로 분리
// ArgsException을 독립 파일로 분리
// ArgsException.ts

export enum ErrorCode {
  OK,
  INVALID_FORMAT,
  UNEXPECTED_ARGUMENT,
  INVALID_ARGUMENT_NAME,
  MISSING_STRING,
  MISSING_INTEGER,
  INVALID_INTEGER,
  MISSING_DOUBLE,
  INVALID_DOUBLE,
}

export class ArgsException extends Error {
  private _errorArgumentId: string = '\0';
  private _errorParameter: string = "TILT";
  private _errorCode: ErrorCode = ErrorCode.OK;

  constructor(errorCode?: ErrorCode, errorArgumentId?: string, errorParameter?: string | null) {
    super();
    if (errorCode !== undefined) this._errorCode = errorCode;
    if (errorArgumentId !== undefined) this._errorArgumentId = errorArgumentId;
    if (errorParameter !== undefined && errorParameter !== null) this._errorParameter = errorParameter;
  }

  getErrorArgumentId(): string { return this._errorArgumentId; }
  setErrorArgumentId(id: string): void { this._errorArgumentId = id; }
  getErrorParameter(): string { return this._errorParameter; }
  setErrorParameter(p: string): void { this._errorParameter = p; }
  getErrorCode(): ErrorCode { return this._errorCode; }
  setErrorCode(code: ErrorCode): void { this._errorCode = code; }

  errorMessage(): string {
    switch (this._errorCode) {
      case ErrorCode.UNEXPECTED_ARGUMENT:
        return `Argument -${this._errorArgumentId} unexpected.`;
      case ErrorCode.MISSING_STRING:
        return `Could not find string parameter for -${this._errorArgumentId}.`;
      case ErrorCode.INVALID_INTEGER:
        return `Argument -${this._errorArgumentId} expects an integer but was '${this._errorParameter}'.`;
      case ErrorCode.MISSING_INTEGER:
        return `Could not find integer parameter for -${this._errorArgumentId}.`;
      case ErrorCode.INVALID_DOUBLE:
        return `Argument -${this._errorArgumentId} expects a double but was '${this._errorParameter}'.`;
      case ErrorCode.MISSING_DOUBLE:
        return `Could not find double parameter for -${this._errorArgumentId}.`;
      case ErrorCode.INVALID_ARGUMENT_NAME:
        return `'${this._errorArgumentId}' is not a valid argument name.`;
      case ErrorCode.INVALID_FORMAT:
        return `'${this._errorParameter}' is not a valid argument format.`;
      default:
        return "";
    }
  }
}
  • errorMessageArgsException에 있는 것이 SRP 위반 아닌가?
    • 이것은 타협이다
    • 에러 메시지 형식이 마음에 들지 않는 사용자는 직접 작성해야 함
    • 하지만 기본 제공 에러 메시지의 편의성도 무시할 수 없음
    • 완벽한 설계보다 실용적인 설계를 선택

최종 결과물 요약

아키텍처 다이어그램

classDiagram
  class Args {
    -schema: string
    -marshalers: Map~string, ArgumentMarshaler~
    -argsFound: Set~string~
    -currentArgument: Iterator~string~
    -argsList: string[]
    +getBoolean(arg) boolean
    +getString(arg) string
    +getInt(arg) number
    +getDouble(arg) number
    +has(arg) boolean
    +cardinality() number
  }

  class ArgumentMarshaler {
    <<interface>>
    +set(currentArgument: Iterator~string~) void
    +get() unknown
  }

  class BooleanArgumentMarshaler {
    -booleanValue: boolean
    +set(currentArgument) void
    +get() unknown
  }

  class StringArgumentMarshaler {
    -stringValue: string
    +set(currentArgument) void
    +get() unknown
  }

  class IntegerArgumentMarshaler {
    -intValue: number
    +set(currentArgument) void
    +get() unknown
  }

  class DoubleArgumentMarshaler {
    -doubleValue: number
    +set(currentArgument) void
    +get() unknown
  }

  class ArgsException {
    -errorArgumentId: string
    -errorParameter: string
    -errorCode: ErrorCode
    +errorMessage() string
  }

  Args --> ArgumentMarshaler
  ArgumentMarshaler <|.. BooleanArgumentMarshaler
  ArgumentMarshaler <|.. StringArgumentMarshaler
  ArgumentMarshaler <|.. IntegerArgumentMarshaler
  ArgumentMarshaler <|.. DoubleArgumentMarshaler
  Args ..> ArgsException

30번의 작은 단계로 달성한 것들

  • Args 클래스에서 에러/예외 처리 코드 완전 분리
  • 세 개의 타입별 맵 → 하나의 marshalers 맵으로 통합
  • 모든 ArgumentMarshaler 구현체를 독립 파일로 분리
  • if-else 타입 케이스 완전 제거
  • 매 단계마다 테스트 통과 유지

정리

코드가 동작하는 것만으로는 충분하지 않다. 코드는 썩고 발효되어, 팀을 아래로 끌어당기는 피할 수 없는 무게가 된다.

나쁜 코드의 진정한 비용

  • 나쁜 일정은 다시 짤 수 있음
  • 나쁜 요구사항은 재정의할 수 있음
  • 나쁜 팀 역학은 수리할 수 있음
  • 하지만 나쁜 코드는 썩고 발효되며, 팀의 운명을 영원히 지배하게 됨
  • 코드가 썩으면 모듈들이 서로 엉켜 숨겨진 의존성이 생겨남
  • 오래된 의존성을 찾아 끊는 것은 길고 힘든 작업

클린 코드 유지의 상대적 용이성

  • 코드를 깨끗하게 유지하는 것은 비교적 쉬움
  • 아침에 어떤 모듈을 망쳤다면 → 오후에 정리하기 쉬움
  • 5분 전에 망쳤다면 → 지금 당장 정리하기 매우 쉬움
  • “나중에 정리하자”는 결코 오지 않는다

핵심 메시지

  • 코드를 항상 최대한 깨끗하고 단순하게 유지하라
  • 절대 부패가 시작되도록 내버려 두지 마라

핵심 요약

  • 점진적 개선: 클린 코드는 한 번에 나오지 않는다. 지저분한 초안 → 정제 과정이 필수
  • 멈추는 용기: 기능 추가 중 구조가 나빠지면 즉시 멈추고 리팩터링 먼저
  • TDD와 점진주의: 대규모 변경 대신 작은 단계로 이동, 매 단계마다 테스트 통과 유지
  • 타입 케이스 제거: instanceof 분기를 제거하고 다형성으로 올바른 구현을 선택하게 만들기
  • SRP와 분리: Args는 인수 처리, ArgsException은 에러 표현이라는 명확한 책임 분리
  • 설계의 편의성: 완벽한 설계보다 실용적인 타협도 존재한다 (errorMessage 위치 결정)
  • 나쁜 코드의 비용: 일정·요구사항·팀은 고칠 수 있지만, 썩은 코드는 팀 전체를 영구적으로 끌어내림
  • 지금 정리하라: 코드를 망친 직후가 가장 쉽게 고칠 수 있는 순간이다

15장. JUnit 내부 구현

JUnit은 Java 프레임워크 중 가장 유명한 것 중 하나다. 개념적으로 단순하고, 정의가 명확하며, 구현이 우아하다. 그렇다면 코드는 어떤 모습인가?

JUnit 프레임워크

ComparisonCompactor란?

  • JUnit은 Kent Beck과 Eric Gamma가 애틀랜타행 비행기 안에서 3시간 만에 기초를 작성한 프레임워크
  • 이 챕터에서 분석할 모듈: ComparisonCompactor
    • 두 문자열이 다를 때 차이점을 시각적으로 표현해주는 유틸리티
    • 예: ABCDEABXDE를 비교하면 <...B[X]D...> 형태로 출력
  • 테스트 케이스들이 요구사항을 가장 잘 설명함 → 코드 커버리지 100% 달성

원본 코드

  • 저자들이 이미 잘 만든 코드이지만, 보이스카우트 규칙(“발견했을 때보다 조금 더 깨끗하게”)에 따라 개선할 여지를 찾음
  • 원본 코드의 특징:
    • 멤버 변수에 f 접두사 사용 (fExpected, fActual 등)
    • 일부 긴 표현식과 이상한 +1 존재
    • 전반적으로는 잘 구성된 코드
class ComparisonCompactor {
  private static readonly ELLIPSIS = "...";
  private static readonly DELTA_END = "]";
  private static readonly DELTA_START = "[";

  private fContextLength: number;
  private fExpected: string;
  private fActual: string;
  private fPrefix: number = 0;
  private fSuffix: number = 0;

  constructor(contextLength: number, expected: string, actual: string) {
    this.fContextLength = contextLength;
    this.fExpected = expected;
    this.fActual = actual;
  }

  compact(message: string): string {
    if (this.fExpected == null || this.fActual == null || this.areStringsEqual())
      return Assert.format(message, this.fExpected, this.fActual);
    this.findCommonPrefix();
    this.findCommonSuffix();
    const expected = this.compactString(this.fExpected);
    const actual = this.compactString(this.fActual);
    return Assert.format(message, expected, actual);
  }

  private areStringsEqual(): boolean {
    return this.fExpected === this.fActual;
  }
  // ... 이하 생략
}

리팩토링 과정

멤버 변수의 f 접두사 제거 [N6]

  • 문제: fExpected, fActual처럼 접두사로 스코프를 인코딩하는 방식은 구식
  • 이유: 현대 IDE는 변수 스코프를 명확히 표시해주므로 이런 인코딩이 불필요
  • 개선: 모든 f 접두사를 제거
// Before
private fContextLength: number;
private fExpected: string;
private fActual: string;
private fPrefix: number;
private fSuffix: number;

// After
private contextLength: number;
private expected: string;
private actual: string;
private prefix: number;
private suffix: number;

조건문 캡슐화 [G28]

  • 문제: compact() 함수 첫 줄의 조건문이 캡슐화되지 않아 의도가 불분명
// Before: 의도가 드러나지 않는 노출된 조건
compact(message: string): string {
  if (expected == null || actual == null || areStringsEqual())
    return Assert.format(message, expected, actual);
  // ...
}

// After: 의도를 드러내는 메서드로 추출
compact(message: string): string {
  if (this.shouldNotCompact())
    return Assert.format(message, this.expected, this.actual);
  // ...
}

private shouldNotCompact(): boolean {
  return this.expected == null || this.actual == null || this.areStringsEqual();
}

변수명 모호성 제거 [N4]

  • 문제: 멤버 변수명과 지역 변수명이 동일(expected, actual) → this.expected 같은 표기 필요
  • 이유: 같은 이름의 변수가 다른 것을 나타낸다면, 이름이 잘못된 것 [N4]
  • 개선: 지역 변수명에 의미를 더함
// Before
const expected = this.compactString(this.expected); // 혼란스러움
const actual = this.compactString(this.actual);

// After
const compactExpected = this.compactString(this.expected);
const compactActual = this.compactString(this.actual);

부정 조건을 긍정 조건으로 전환 [G29]

  • 원칙: 부정 조건은 긍정 조건보다 이해하기 어려움
  • 개선: shouldNotCompact()canBeCompacted()로 뒤집어서 if 블록 구조를 자연스럽게
// Before
if (shouldNotCompact())
  return Assert.format(message, expected, actual);
// 압축 로직...

// After
compact(message: string): string {
  if (this.canBeCompacted()) {
    this.findCommonPrefix();
    this.findCommonSuffix();
    const compactExpected = this.compactString(this.expected);
    const compactActual = this.compactString(this.actual);
    return Assert.format(message, compactExpected, compactActual);
  } else {
    return Assert.format(message, this.expected, this.actual);
  }
}

private canBeCompacted(): boolean {
  return this.expected != null && this.actual != null && !this.areStringsEqual();
}

함수명을 의도에 맞게 변경 [N7]

  • 문제: compact()라는 이름은 함수가 실제로 하는 일을 잘못 표현
    • canBeCompacted()가 false면 압축을 안 하므로 이름이 오해를 유발
    • 실제 반환값은 압축된 문자열이 아니라 포맷된 메시지
  • 개선: compact()formatCompactedComparison()
// Before
compact(message: string): string { ... }

// After
formatCompactedComparison(message: string): string { ... }

단일 책임 분리: 포맷과 압축 분리 [G30]

  • 원칙: 함수는 한 가지 일만 해야 함
  • 문제: formatCompactedComparison()이 포맷과 압축을 동시에 담당
  • 개선: 압축 로직을 compactExpectedAndActual()로 추출하고 멤버 변수로 승격
private compactExpected: string = "";
private compactActual: string = "";

formatCompactedComparison(message: string): string {
  if (this.canBeCompacted()) {
    this.compactExpectedAndActual();
    return Assert.format(message, this.compactExpected, this.compactActual);
  } else {
    return Assert.format(message, this.expected, this.actual);
  }
}

private compactExpectedAndActual(): void {
  this.findCommonPrefix();
  this.findCommonSuffix();
  this.compactExpected = this.compactString(this.expected);
  this.compactActual = this.compactString(this.actual);
}

함수 반환값 일관성 확보 [G11]

  • 문제: compactExpectedAndActual() 내에서 앞 두 줄(findCommonPrefix, findCommonSuffix)은 반환값이 없고, 뒤 두 줄은 변수에 할당 → 일관성 없음
  • 개선: findCommonPrefixfindCommonSuffix도 값을 반환하도록 변경
private compactExpectedAndActual(): void {
  this.prefixIndex = this.findCommonPrefix();
  this.suffixIndex = this.findCommonSuffix();
  this.compactExpected = this.compactString(this.expected);
  this.compactActual = this.compactString(this.actual);
}

private findCommonPrefix(): number {
  let prefixIndex = 0;
  const end = Math.min(this.expected.length, this.actual.length);
  for (; prefixIndex < end; prefixIndex++) {
    if (this.expected[prefixIndex] !== this.actual[prefixIndex]) break;
  }
  return prefixIndex;
}

숨겨진 시간적 결합(Temporal Coupling) 노출 [G31]

  • 문제: findCommonSuffix()는 내부적으로 prefixIndex에 의존하지만, 호출 순서가 강제되지 않음 → 순서가 바뀌면 디버깅하기 어려운 버그 발생
  • 1차 시도: findCommonSuffix(prefixIndex)처럼 인자로 전달
// 1차 시도: 인자로 의존성 표현
private compactExpectedAndActual(): void {
  this.prefixIndex = this.findCommonPrefix();
  this.suffixIndex = this.findCommonSuffix(this.prefixIndex); // 순서 강제
  // ...
}
  • 문제점: 인자가 임의적으로 보임. 다른 프로그래머가 인자가 왜 필요한지 이해 못 하고 제거할 수 있음 [G32]
  • 최종 해결: findCommonPrefixAndSuffix()로 두 함수를 하나로 묶어 구조적으로 순서를 강제
private compactExpectedAndActual(): void {
  this.findCommonPrefixAndSuffix(); // 내부에서 순서 보장
  this.compactExpected = this.compactString(this.expected);
  this.compactActual = this.compactString(this.actual);
}

private findCommonPrefixAndSuffix(): void {
  this.findCommonPrefix(); // 반드시 먼저 호출됨
  let suffixLength = 1;
  for (; !this.suffixOverlapsPrefix(suffixLength); suffixLength++) {
    if (this.charFromEnd(this.expected, suffixLength) !==
        this.charFromEnd(this.actual, suffixLength)) break;
  }
  this.suffixIndex = suffixLength;
}

변수명 정확성 개선: suffixIndexsuffixLength [G33]

  • 문제: suffixIndex는 실제로 길이를 나타내는데 이름이 혼란스러움
    • 1-based index로 동작하여 코드 곳곳에 +1이 등장
  • 개선: suffixLength로 이름 변경 + 0-based로 전환 → +1 제거 가능
// Before: 1-based라서 +1 필요
private charFromEnd(s: string, i: number): string {
  return s[s.length - i]; // 혼란스러운 오프셋
}

// After: 0-based로 변경, 직관적
private charFromEnd(s: string, i: number): string {
  return s[s.length - i - 1]; // 명확한 인덱싱
}

private suffixOverlapsPrefix(suffixLength: number): boolean {
  return (this.actual.length - suffixLength <= this.prefixLength) ||
         (this.expected.length - suffixLength <= this.prefixLength);
  // <= 로 변경 (이전에는 <) → 의미가 더 명확해짐
}
  • 발견된 버그: 기존 compactString() 내의 if (suffixIndex > 0) 조건이 사실상 무의미했음 (suffixIndex가 1보다 작을 수 없었기 때문). 0-based로 바꾸자 조건이 의미를 가지게 됨

compactString() 단순화 [G9]

  • 발견: compactString()의 두 if 문을 제거하고 테스트 실행 → 전부 통과
  • 원인: 불필요한 조건문이었음 (제거해도 동작에 변화 없음)
  • 개선: 함수를 단순한 문자열 조합 함수로 정리
// Before: 복잡한 조건부 조합
private compactString(source: string): string {
  let result = ComparisonCompactor.DELTA_START +
    source.substring(this.prefixLength, source.length - this.suffixLength) +
    ComparisonCompactor.DELTA_END;
  if (this.prefixLength > 0)
    result = this.computeCommonPrefix() + result;
  if (this.suffixLength > 0)
    result = result + this.computeCommonSuffix();
  return result;
}

// After: 조각들을 단순하게 이어붙임
private compact(s: string): string {
  return [
    this.startingEllipsis(),
    this.startingContext(),
    ComparisonCompactor.DELTA_START,
    this.delta(s),
    ComparisonCompactor.DELTA_END,
    this.endingContext(),
    this.endingEllipsis(),
  ].join("");
}

최종 코드

  • 구조: 분석 함수 그룹 + 합성 함수 그룹으로 깔끔하게 분리
  • 정렬: 각 함수 정의가 사용 직후에 위치하는 위상 정렬(Topological Sort) 형태
  • 분석 함수들이 먼저, 합성 함수들이 나중에 위치
class ComparisonCompactor {
  private static readonly ELLIPSIS = "...";
  private static readonly DELTA_END = "]";
  private static readonly DELTA_START = "[";

  private contextLength: number;
  private expected: string;
  private actual: string;
  private prefixLength: number = 0;
  private suffixLength: number = 0;

  constructor(contextLength: number, expected: string, actual: string) {
    this.contextLength = contextLength;
    this.expected = expected;
    this.actual = actual;
  }

  // [합성] 최상위 공개 메서드
  formatCompactedComparison(message: string): string {
    let compactExpected = this.expected;
    let compactActual = this.actual;
    if (this.shouldBeCompacted()) {
      this.findCommonPrefixAndSuffix();
      compactExpected = this.compact(this.expected);
      compactActual = this.compact(this.actual);
    }
    return Assert.format(message, compactExpected, compactActual);
  }

  // [분석] 압축 조건 판별
  private shouldBeCompacted(): boolean {
    return !this.shouldNotBeCompacted();
  }

  private shouldNotBeCompacted(): boolean {
    return this.expected == null ||
           this.actual == null ||
           this.expected === this.actual;
  }

  // [분석] 공통 접두사/접미사 길이 계산
  private findCommonPrefixAndSuffix(): void {
    this.findCommonPrefix();
    this.suffixLength = 0;
    for (; !this.suffixOverlapsPrefix(); this.suffixLength++) {
      if (this.charFromEnd(this.expected, this.suffixLength) !==
          this.charFromEnd(this.actual, this.suffixLength)) break;
    }
  }

  private charFromEnd(s: string, i: number): string {
    return s[s.length - i - 1];
  }

  private suffixOverlapsPrefix(): boolean {
    return (this.actual.length - this.suffixLength <= this.prefixLength) ||
           (this.expected.length - this.suffixLength <= this.prefixLength);
  }

  private findCommonPrefix(): void {
    this.prefixLength = 0;
    const end = Math.min(this.expected.length, this.actual.length);
    for (; this.prefixLength < end; this.prefixLength++) {
      if (this.expected[this.prefixLength] !== this.actual[this.prefixLength]) break;
    }
  }

  // [합성] 압축 문자열 조합
  private compact(s: string): string {
    return [
      this.startingEllipsis(),
      this.startingContext(),
      ComparisonCompactor.DELTA_START,
      this.delta(s),
      ComparisonCompactor.DELTA_END,
      this.endingContext(),
      this.endingEllipsis(),
    ].join("");
  }

  private startingEllipsis(): string {
    return this.prefixLength > this.contextLength ? ComparisonCompactor.ELLIPSIS : "";
  }

  private startingContext(): string {
    const contextStart = Math.max(0, this.prefixLength - this.contextLength);
    return this.expected.substring(contextStart, this.prefixLength);
  }

  private delta(s: string): string {
    return s.substring(this.prefixLength, s.length - this.suffixLength);
  }

  private endingContext(): string {
    const contextStart = this.expected.length - this.suffixLength;
    const contextEnd = Math.min(contextStart + this.contextLength, this.expected.length);
    return this.expected.substring(contextStart, contextEnd);
  }

  private endingEllipsis(): string {
    return this.suffixLength > this.contextLength ? ComparisonCompactor.ELLIPSIS : "";
  }
}

정리

  • 저자들이 이미 잘 만든 모듈이었지만, 보이스카우트 규칙에 따라 더 깨끗하게 개선
  • 리팩토링 중 일부 결정은 나중에 다시 되돌려지기도 함 → 리팩토링은 반복적 시행착오 과정
  • 결국 수렴하는 목표는 전문가답다고 느껴지는 코드

리팩토링은 첫 번째 변경이 두 번째 변경을 이끌고, 두 번째 변경이 첫 번째를 되돌리게 만드는 경우가 흔하다. 이는 지극히 정상적인 과정이다.

핵심 요약

  • N6: 인코딩 금지 / 멤버 변수의 f 접두사 제거
  • N4: 명확한 이름 / 지역 변수와 멤버 변수 이름 혼동 제거
  • N7: 의도를 담은 이름 / compact()formatCompactedComparison()
  • N1: 정확한 이름 / suffixIndexsuffixLength
  • G28: 조건 캡슐화 / 노출된 조건을 설명적 메서드로 추출
  • G29: 긍정 조건 선호 / 부정 조건 → 긍정 조건으로 반전
  • G30: 단일 책임 / 포맷 함수와 압축 함수 분리
  • G11: 일관성 / 함수 반환 방식 통일 (값 반환 vs. 멤버 할당)
  • G31: 시간적 결합 노출 / findCommonPrefixAndSuffix()로 호출 순서 구조화
  • G32: 임의적 인자 지양 / 인자 전달 대신 함수 합침으로 의존성 명시
  • G33: 정확한 의미의 변수 / 1-based index → 0-based length로 전환, +1 제거
  • G9: 불필요한 코드 제거 / 죽은 조건문 제거 후 compactString() 단순화

16장. SerialDate 리팩터링

“이것은 악의적인 행위가 아니다. 전문적인 코드 리뷰일 뿐이다. 우리 모두가 익숙해져야 하고, 우리에게 행해질 때 환영해야 하는 행위다.”

들어가며

  • 대상: JCommon 라이브러리의 org.jfree.date 패키지 안에 있는 SerialDate 클래스
  • 저자 David Gilbert는 숙련되고 유능한 프로그래머로, 코드 내에서 높은 수준의 전문성과 규율을 보임
  • 이 코드는 **“좋은 코드”**임에도 불구하고 리팩터링 대상으로 선정
  • 목적: 악의적 비판이 아닌 전문적 코드 리뷰
    • 의사, 조종사, 변호사처럼 프로그래머도 코드 리뷰에 익숙해져야 함
    • 비판을 통해서만 성장할 수 있음
  • David Gilbert의 공헌: 코드를 오픈소스로 공개하여 공개적 사용과 검토를 초대한 용기 있는 행위

먼저 동작하게 만들기

테스트 커버리지 분석

  • SerialDateTests 클래스에 단위 테스트 존재, 모든 테스트는 통과 상태
  • 그러나 테스트가 모든 것을 테스트하지는 않음 [T1]
    • MonthCodeToQuarter 메서드는 “Find Usages” 검색 결과 사용되지 않음 [F4]
    • 즉, 단위 테스트도 이 메서드를 테스트하지 않음
  • Clover로 커버리지 측정: 185개 실행 가능 구문 중 91개(약 50%)만 실행 [T2]
    • 커버리지 맵이 패치워크처럼 구멍이 숭숭 뚫려 있음

독립적인 테스트 작성

  • 클래스를 완전히 이해하고 리팩터링하려면 더 높은 테스트 커버리지 필요
  • 완전히 독립적인 단위 테스트 스위트를 직접 작성
  • 일부 테스트는 주석 처리됨 → 현재 통과하지 못하지만, SerialDate마땅히 가져야 할 동작을 표현
  • 새 테스트로 Clover 재측정: 185개 중 170개(92%) 실행 달성

발견된 버그들

버그 1: stringToMonthCode 대소문자 미구분

  • testWeekdayCodeToString은 대소문자를 구분하지 않아야 함이 명백 [G2]
  • 수정: equalsequalsIgnoreCase 변경
// 수정 전
if (s === shortMonthNames[i]) { ... }

// 수정 후
if (s.toLowerCase() === shortMonthNames[i].toLowerCase()) { ... }

버그 2: getFollowingDayOfWeek의 경계 조건 오류

  • 2004년 12월 25일(토요일)의 다음 토요일 → 2005년 1월 1일이 맞음
  • 그러나 함수가 12월 25일 자신을 반환하는 버그 존재 [G3], [T1]
  • 전형적인 경계 조건 오류 [T5]
  • 변경 이력 확인 결과, 이 함수는 이미 이전에 한 번 수정된 적 있음 [T6]
// 수정 전 (버그 있음)
if (baseDOW >= targetWeekday) { ... }

// 수정 후
if (baseDOW > targetWeekday) { ... }

버그 3: getNearestDayOfWeek의 알고리즘 오류

  • 가장 가까운 날이 미래에 있는 경우 실패하는 패턴 발견 [T6], [T7]
  • Clover 커버리지 분석: 719번 라인이 단 한 번도 실행되지 않음 [T8]
    • 718번 라인의 if 조건이 항상 falseadjust 변수는 항상 음수라 >= 4가 불가능
    • 즉, 알고리즘 자체가 잘못됨
// 올바른 알고리즘
function getNearestDayOfWeek(base: DayDate, targetDOW: number): DayDate {
  const delta = targetDOW - base.getDayOfWeek();
  const positiveDelta = delta + 7;
  let adjust = positiveDelta % 7;
  if (adjust > 3) adjust -= 7;
  return DayDate.addDays(adjust, base);
}

버그 4: weekInMonthToString, relativeToString

  • IllegalArgumentException을 던지는 대신 오류 문자열을 반환하는 문제
  • 수정: 예외 던지기로 변경 → 해당 테스트 통과

이 단계의 교훈: 리팩터링 전에 먼저 동작하게 만들어라. 통과하는 테스트가 없으면 리팩터링이 안전한지 알 수 없다.

그다음 올바르게 만들기

이제 작동하는 코드를 올바르게 만들 차례다. SerialDate의 처음부터 끝까지 걸어가며 개선한다.

라이선스 및 변경 이력 정리

  • 저작권/라이선스 주석: 법적 요구사항이므로 유지
  • 변경 이력 주석: 삭제 [C1]
    • 변경 이력은 1960년대 유물
    • 이제 소스 코드 관리 도구(Git 등)가 그 역할을 대신함
  • import 목록: 와일드카드 import로 단축 가능 [J1]
  • Javadoc 내 HTML 포맷: 소스 파일 하나에 Java, 영어, Javadoc, HTML 4개 언어 혼재 [G1]
    • 권장: <pre> 태그로 감싸서 소스 코드의 포맷을 Javadoc에서도 보존

클래스 이름 변경: SerialDate → DayDate

  • SerialDate라는 이름의 문제점
    • SERIAL_LOWER_BOUND, SERIAL_UPPER_BOUND 등의 상수에서 유추: 1899년 12월 30일 이후 날 수(serial number)에서 유래
    • 문제 1: “serial number”는 제품 식별 마커의 개념 → ordinal이 더 적절 [N1]
    • 문제 2: 이름이 구현을 암시 → 이 클래스는 추상 클래스이므로 구현을 노출할 이유 없음 [N2]
  • 최종 선택: DayDate
    • Date는 Java 라이브러리에 이미 존재
    • Day도 너무 흔히 사용됨
    • DayDate가 최선의 타협점

MonthConstants → Month enum

  • DayDateMonthConstants를 상속받는 이유: 상수를 MonthConstants.January처럼 쓰지 않기 위한 구식 Java 트릭
  • 이는 나쁜 관행 [J2]
  • 해결: MonthConstantsenum으로 변환
enum Month {
  JANUARY = 1,
  FEBRUARY = 2,
  MARCH = 3,
  APRIL = 4,
  MAY = 5,
  JUNE = 6,
  JULY = 7,
  AUGUST = 8,
  SEPTEMBER = 9,
  OCTOBER = 10,
  NOVEMBER = 11,
  DECEMBER = 12,
}

function makeMonth(index: number): Month {
  const entry = Object.values(Month).find(v => v === index);
  if (entry === undefined) throw new Error(`Invalid month index: ${index}`);
  return entry as Month;
}
  • enum으로 변경하면 isValidMonthCode 메서드 제거 가능, monthCodeToQuarter의 유효성 검사 코드도 제거 가능 [G5]

serialVersionUID 제거

  • serialVersionUID를 선언하지 않으면 컴파일러가 자동 생성
  • 수동 관리의 위험: 업데이트를 잊으면 조용히 이상 동작 발생
  • 자동 제어가 오히려 더 안전: InvalidClassException은 명확한 원인 제공 [G4]
  • 결론: 변수 삭제

범위 관련 상수의 위치 조정

  • EARLIEST_DATE_ORDINAL, LATEST_DATE_ORDINAL: DayDate에 있으나 실제로는 SpreadsheetDate에서만 사용됨

  • 추상 클래스에 구현 세부 정보가 있는 것은 잘못된 추상화 수준 [G6]

  • SpreadsheetDate로 이동

  • MINIMUM_YEAR_SUPPORTED, MAXIMUM_YEAR_SUPPORTED도 동일한 문제

    • RelativeDayOfWeekRule이 사용하는 문제 → 추상 클래스가 파생 클래스의 정보를 알아야 하는 상황
    • 해결책: Abstract Factory 패턴 도입

Abstract Factory 패턴 도입

기반 클래스가 파생 클래스를 알아서는 안 된다 [G7]

abstract class DayDateFactory {
  private static instance: DayDateFactory = new SpreadsheetDateFactory();

  static setInstance(factory: DayDateFactory): void {
    DayDateFactory.instance = factory;
  }

  protected abstract _makeDate(ordinal: number): DayDate;
  protected abstract _getMinimumYear(): number;
  protected abstract _getMaximumYear(): number;

  static makeDate(ordinal: number): DayDate {
    return DayDateFactory.instance._makeDate(ordinal);
  }
  static getMinimumYear(): number {
    return DayDateFactory.instance._getMinimumYear();
  }
  static getMaximumYear(): number {
    return DayDateFactory.instance._getMaximumYear();
  }
}

class SpreadsheetDateFactory extends DayDateFactory {
  protected _makeDate(ordinal: number): DayDate {
    return new SpreadsheetDate(ordinal);
  }
  protected _getMinimumYear(): number {
    return SpreadsheetDate.MINIMUM_YEAR_SUPPORTED;
  }
  protected _getMaximumYear(): number {
    return SpreadsheetDate.MAXIMUM_YEAR_SUPPORTED;
  }
}
  • createInstancemakeDate로 이름 변경: 훨씬 명확함 [N1]
  • Singleton + Decorator + Abstract Factory 패턴의 조합

요일 상수 → Day enum

  • 요일 상수들도 enum으로 변환 [J3]
  • stringToWeekdayCodeDay enum의 parse 정적 메서드로 이동
  • weekdayCodeToStringDay enum의 toString으로 이동
  • Day enum이 커져서 DayDate에서 분리, 독립 파일로 이동 [G13]
enum DayOfWeek {
  MONDAY = 1,
  TUESDAY,
  WEDNESDAY,
  THURSDAY,
  FRIDAY,
  SATURDAY,
  SUNDAY,
}

function parseDay(s: string): DayOfWeek {
  const trimmed = s.trim().toLowerCase();
  // ... short/long name 매칭 로직
  throw new Error(`${s} is not a valid weekday string`);
}

추가 테이블 정리

  • LAST_DAY_OF_MONTH: 설명 주석은 이름 자체로 충분 → 주석 삭제 [C3]
    • private이어야 함 [G8] (이미 lastDayOfMonth 함수가 데이터 제공)
  • AGGREGATE_DAYS_TO_END_OF_MONTH: JCommon 어디서도 사용하지 않음 → 삭제 [G9]
  • LEAP_YEAR_AGGREGATE_DAYS_TO_END_OF_MONTH: 동일 이유로 삭제
  • AGGREGATE_DAYS_TO_END_OF_PRECEDING_MONTH: SpreadsheetDate에서만 사용 → SpreadsheetDate로 이동 [G10], [G6]

범위 표현 상수 → DateInterval enum

  • INCLUDE_NONE, INCLUDE_FIRST, INCLUDE_SECOND, INCLUDE_BOTH
  • 수학적 용어가 더 명확 [N3]
  • DateInterval enum으로 변환: OPEN, CLOSED_LEFT, CLOSED_RIGHT, CLOSED
enum DateInterval {
  OPEN = 'OPEN',
  CLOSED_LEFT = 'CLOSED_LEFT',
  CLOSED_RIGHT = 'CLOSED_RIGHT',
  CLOSED = 'CLOSED',
}

function isIn(interval: DateInterval, d: number, left: number, right: number): boolean {
  switch (interval) {
    case DateInterval.OPEN:         return d > left && d < right;
    case DateInterval.CLOSED_LEFT:  return d >= left && d < right;
    case DateInterval.CLOSED_RIGHT: return d > left && d <= right;
    case DateInterval.CLOSED:       return d >= left && d <= right;
  }
}
  • 정수 대신 기호(symbol)로 전달 → IDE의 “rename” 기능으로 안전하게 변경 가능 [J3]

불필요한 항목 제거

제거 대상 이유
description 필드 및 accessor/mutator 아무도 사용하지 않음 [G9]
기본 생성자 컴파일러가 자동 생성 [G12]
final 키워드 (인수, 지역변수) 실질적 가치 없이 코드 복잡성만 증가 [G12]
Javadoc 중복 주석 메서드 시그니처로 충분히 설명됨 [C3]
isValidMonthCode Month enum으로 대체됨 [G9]
weekInMonthToString, relativeToString 테스트 외에 호출하는 곳 없음 [F4]

final 키워드에 대한 인사이트: Robert Simmons는 final을 코드 전체에 퍼뜨리라고 강력히 권장하지만, 저자는 동의하지 않는다. final이 잡을 수 있는 오류는 이미 단위 테스트가 잡는다. 상수로 쓰는 몇 가지 경우 외에는 final은 노이즈일 뿐이다.

static 메서드 → 인스턴스 메서드 변환

  • addDays, addMonths, addYears를 인스턴스 메서드로 변경 [G18]

그러나 새로운 문제 발생: 메서드 이름의 모호성 [G20]

// 이 코드는 date 객체가 변경된다고 오해하기 쉽다
const date = DayDateFactory.makeDate(5, Month.DECEMBER, 1952);
date.addDays(7); // "date를 일주일 늘린다" 처럼 읽힘
  • 해결: addDaysplusDays, addMonthsplusMonths로 이름 변경 [N4]
// 의도가 명확하게 전달됨
const newDate = oldDate.plusDays(5);

// 아래 코드는 date 객체가 바뀐다고 오해하기 어렵게 읽힘
date.plusDays(5); // 어색함 → 반환값을 사용해야 함을 암시

알고리즘 명확화: 설명용 임시 변수 활용

  • getPreviousDayOfWeek, getFollowingDayOfWeek: 복잡한 알고리즘을 Explaining Temporary Variables [G19]로 명확화
// getPreviousDayOfWeek 리팩터링 결과
getPreviousDayOfWeek(targetDayOfWeek: DayOfWeek): DayDate {
  const offsetToTarget = targetDayOfWeek - this.getDayOfWeek();
  const adjust = offsetToTarget >= 0 ? offsetToTarget - 7 : offsetToTarget;
  return this.plusDays(adjust);
}

// getFollowingDayOfWeek 리팩터링 결과
getFollowingDayOfWeek(targetDayOfWeek: DayOfWeek): DayDate {
  const offsetToTarget = targetDayOfWeek - this.getDayOfWeek();
  const adjust = offsetToTarget <= 0 ? offsetToTarget + 7 : offsetToTarget;
  return this.plusDays(adjust);
}

// getNearestDayOfWeek 리팩터링 결과 (일관성 유지 [G11])
getNearestDayOfWeek(targetDay: DayOfWeek): DayDate {
  const offsetToThisWeeksTarget = targetDay - this.getDayOfWeek();
  const offsetToFutureTarget = (offsetToThisWeeksTarget + 7) % 7;
  const offsetToPreviousTarget = offsetToFutureTarget - 7;
  return offsetToFutureTarget > 3
    ? this.plusDays(offsetToPreviousTarget)
    : this.plusDays(offsetToFutureTarget);
}

Feature Envy 해결

  • monthCodeToQuarter: Month enum에 quarter() 메서드로 이동 [G14]
  • Month enum이 충분히 커져 독립 파일로 분리 [G11], [G13]
  • monthCodeToString, stringToMonthCode: 이름 변경 후 Month enum으로 이동 [N1], [N3], [G14]
// Month enum에 메서드 추가
class MonthUtil {
  static quarter(month: Month): number {
    return 1 + Math.floor((month - 1) / 3);
  }

  static toString(month: Month): string {
    return dateFormatSymbols.getMonths()[month - 1];
  }

  static toShortString(month: Month): string {
    return dateFormatSymbols.getShortMonths()[month - 1];
  }

  static parse(s: string): Month {
    const trimmed = s.trim();
    for (const m of Object.values(Month)) {
      if (MonthUtil.matches(m as Month, trimmed)) return m as Month;
    }
    const index = parseInt(trimmed);
    if (!isNaN(index)) return makeMonth(index);
    throw new Error(`Invalid month: ${s}`);
  }
}

isLeapYear 표현력 개선

// 수정 전: 한 줄짜리 복잡한 조건식
function isLeapYear(year: number): boolean {
  return (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0;
}

// 수정 후: 설명용 변수로 의도 명확화 [G16]
function isLeapYear(year: number): boolean {
  const fourth = year % 4 === 0;
  const hundredth = year % 100 === 0;
  const fourHundredth = year % 400 === 0;
  return fourth && (!hundredth || fourHundredth);
}

추상 메서드와 논리적 의존성 처리

  • getDayOfWeek: 구현이 SpreadsheetDate에 있지만, 실제로 DayDate로 올릴 수 있을 것처럼 보임
  • 그러나 논리적 의존성 존재: ordinal day 0의 요일이 SpreadsheetDate의 구현에 종속 [G22]
  • 해결: getDayOfWeekForOrdinalZero() 추상 메서드를 DayDate에 추가
// DayDate (추상)
abstract getDayOfWeekForOrdinalZero(): DayOfWeek;

getDayOfWeek(): DayOfWeek {
  const startingDay = this.getDayOfWeekForOrdinalZero();
  const startingOffset = startingDay - DayOfWeek.SUNDAY;
  return ((this.getOrdinalDay() + startingOffset) % 7) + 1 as DayOfWeek;
}

// SpreadsheetDate (구체)
getDayOfWeekForOrdinalZero(): DayOfWeek {
  return DayOfWeek.SATURDAY;
}

핵심 원칙: 논리적 의존성이 있다면 물리적 의존성도 있어야 한다 [G22]

isLeapYear, compare 등 추상 메서드 정리

  • toSerialtoOrdinalgetOrdinalDay로 이름 변경 [N1]
  • toDate: SpreadsheetDate의 구현이 파생 클래스에 의존하지 않음 → DayDate로 끌어올림 [G6]
  • compare: 이름이 충분히 소통하지 못함 → daysSince로 변경 [N1]
  • isInRange의 switch 문 → DateInterval enum으로 이동 [G23]
// isInRange 최종 형태
isInRange(d1: DayDate, d2: DayDate, interval: DateInterval): boolean {
  const left = Math.min(d1.getOrdinalDay(), d2.getOrdinalDay());
  const right = Math.max(d1.getOrdinalDay(), d2.getOrdinalDay());
  return isIn(interval, this.getOrdinalDay(), left, right);
}

최종 정리 패스

  • 오래된 서두 주석 단축 및 개선 [C2]
  • 남은 enum들을 모두 독립 파일로 이동 [G12]
  • 정적 변수 dateFormatSymbolsgetMonthNames, isLeapYear, lastDayOfMonthDateUtil 클래스로 이동 [G6]
  • 추상 메서드를 클래스 상단으로 이동 [G24]
  • Month.makeMonth.fromInt로 이름 통일 [N1]
  • plusYears, plusMonths의 중복 코드 → correctLastDayOfMonth 메서드 추출 [G5]
  • 매직 넘버 1 제거: Month.JANUARY.toInt(), Day.SUNDAY.toInt()으로 대체 [G25]

리팩터링 결과

  • DayDate 코드 커버리지: 84.9%로 감소 (기능 감소가 아님)
    • 클래스 자체가 대폭 축소되어 남은 미커버 라인의 비중이 커진 것
    • 53개 실행 구문 중 45개 커버
    • 미커버 라인은 테스트 가치가 없을 만큼 사소함

정리

  • 보이스카우트 규칙 실천: 코드를 발견했을 때보다 조금 더 깨끗하게 남겨두었다
  • 리팩터링 결과 요약
항목 변화
테스트 커버리지 50% → 92% (단위 테스트 기준)
버그 경계 조건 버그 다수 수정
코드 가독성 명확한 이름, 의도를 드러내는 구조로 개선
코드 크기 클래스 대폭 축소 (불필요 코드 제거)
  • 이 작업에는 시간이 걸렸지만 충분히 가치 있는 일이었다
  • 다음에 이 코드를 보는 사람은 우리보다 더 쉽게 이해할 것이고, 우리보다 조금 더 개선할 수 있을 것이다

핵심 규칙 참조표

코드 규칙 이름 핵심 내용
[C1] 주석 오래된 변경 이력 주석 삭제
[C2] 주석 중복/오래된 주석은 거짓말의 온상
[C3] 주석 이름으로 충분한 것에는 주석 불필요
[F4] 함수 사용되지 않는 함수는 제거
[G1] 일반 소스 파일에 여러 언어 혼재 금지
[G2] 일반 명백한 동작은 구현해야 한다
[G3] 일반 경계 조건을 정확히 처리하라
[G4] 일반 안전 재정의
[G5] 일반 중복을 제거하라
[G6] 일반 추상화 수준에 맞는 코드를 적절한 위치에
[G7] 일반 기반 클래스는 파생 클래스를 알아선 안 된다
[G8] 일반 과도한 정보 노출 금지
[G9] 일반 죽은 코드 제거
[G10] 일반 수직 분리: 코드는 사용 위치 근처에
[G11] 일반 일관성을 유지하라
[G12] 일반 잡동사니 제거
[G13] 일반 인위적 결합 금지
[G14] 일반 Feature Envy 제거
[G15] 일반 선택자 인수 금지
[G16] 일반 의도를 명확히 드러내라
[G17] 일반 잘못 놓인 책임
[G18] 일반 부적절한 static 금지
[G19] 일반 설명용 임시 변수 활용
[G20] 일반 함수 이름과 동작의 일치
[G21] 일반 알고리즘을 이해하라
[G22] 일반 논리적 의존성은 물리적으로도 표현
[G23] 일반 if/else, switch 대신 다형성 활용
[G24] 일반 추상 메서드를 상단에 배치
[G25] 일반 매직 숫자를 상수로 교체
[J1] Java 와일드카드 import 활용
[J2] Java 상수를 위한 상속 금지
[J3] Java 상수 대신 enum 사용
[N1] 이름 서술적이고 명확한 이름 선택
[N2] 이름 적절한 추상화 수준의 이름 선택
[N3] 이름 가능하다면 표준 명명법 사용
[N4] 이름 모호한 이름 금지
[T1] 테스트 불충분한 테스트
[T2] 테스트 커버리지 도구 활용
[T3] 테스트 사소한 테스트도 작성하라
[T5] 테스트 경계 조건 테스트
[T6] 테스트 버그 근처는 철저히 테스트
[T7] 테스트 실패 패턴 살펴보기
[T8] 테스트 커버리지 패턴 살펴보기

핵심 요약

  • 리팩터링의 순서: 먼저 동작하게(Make it Work) → 그 다음 올바르게(Make it Right)
  • 테스트 없이는 리팩터링 없다: 높은 커버리지가 안전한 리팩터링의 전제
  • 추상화 수준 일치: 추상 클래스에 구현 세부사항이 있어서는 안 된다
  • 이름은 의도를 드러낸다: addDays vs plusDays 처럼 미묘한 차이가 오해를 만든다
  • enum은 정수 상수의 대체재: 타입 안전성을 제공하고 IDE 지원을 받을 수 있다
  • 논리적 의존성은 물리적으로 표현하라: 숨겨진 가정은 버그의 온상이다
  • 보이스카우트 규칙: 코드를 발견했을 때보다 항상 조금 더 깨끗하게 남겨두어라

17장. 냄새와 휴리스틱

이 목록은 코드를 읽을 때 “냄새가 난다”고 느끼는 것들을 정리한 것이다. 단순한 규칙의 나열이 아닌, 하나의 가치 체계를 내포한다. 클린 코드는 규칙을 외워서 쓰는 것이 아니라, 그 가치를 내면화한 장인 정신에서 나온다.

주석

부적절한 정보

  • 주석에는 기술적인 메모만 담아야 함
  • 소스 컨트롤 시스템, 이슈 트래커 등 다른 시스템에 보관할 정보를 주석에 넣지 말 것
  • 금지 항목: 변경 이력, 작성자, 최종 수정일, SPR 번호 등 메타데이터
  • 변경 이력은 소스 파일을 장황하고 쓸모없는 텍스트로 오염시킴

오래된 주석

  • 낡고 부정확하며 관련 없는 주석은 즉시 갱신하거나 삭제해야 함
  • 주석은 빠르게 낡는다 → 처음부터 낡을 주석은 쓰지 않는 것이 최선
  • 오래된 주석은 원래 설명하던 코드에서 점점 멀어져 떠도는 섬처럼 코드를 오염시킴

중복된 주석

  • 코드만으로 충분히 설명되는 내용을 주석으로 반복하는 것은 낭비
i++; // i를 증가시킨다  ← 이런 주석은 필요 없음
  • JSDoc/TSDoc도 함수 시그니처 이상의 정보를 담지 못하면 중복
  • 주석은 코드가 스스로 말하지 못하는 것만 말해야 한다

잘못 작성된 주석

  • 주석을 쓰기로 했다면 가장 잘 쓴 주석을 남겨야 함
  • 단어를 신중하게 선택하고, 문법과 구두점을 올바르게 사용할 것
  • 횡설수설하지 말 것, 당연한 말을 반복하지 말 것, 간결하게 쓸 것

주석 처리된 코드

  • 주석 처리된 코드는 즉시 삭제해야 한다
  • 왜 나쁜가:
    • 얼마나 오래됐는지 알 수 없음
    • 의미가 있는지 없는지 판단 불가
    • 누군가 필요하다고 생각해 아무도 삭제하지 못함
    • 시간이 지날수록 호출하는 함수도 사라지고 변수명도 바뀌어 부패
  • 소스 컨트롤 시스템이 이전 버전을 기억하므로 삭제를 두려워하지 말 것

환경

빌드가 한 단계 이상 필요

  • 빌드는 단일 명령 하나로 끝나야 한다
  • 이상적인 상태:
  git clone mySystem
  cd mySystem
  npm run build   # 단 하나의 명령으로 완료
  • 여러 설정 파일, 순서가 있는 복잡한 스크립트, 수동으로 복사해야 하는 리소스 등은 모두 냄새

테스트가 한 단계 이상 필요

  • 모든 단위 테스트는 명령 하나로 실행할 수 있어야 한다
  • 최상의 경우: IDE에서 버튼 하나
  • 최소한: 쉘에서 단순 명령 하나 (npm test)
  • 테스트 실행이 빠르고 쉽고 명확해야 개발자가 실제로 자주 실행함

함수

너무 많은 인수

  • 함수의 인수는 적을수록 좋다
  • 인수 개수별 평가:
    • 0개: 최선
    • 1개: 좋음
    • 2개: 괜찮음
    • 3개: 주의 요망
    • 4개 이상: 정당한 이유가 없다면 피해야 함
  • 많은 인수는 함수가 너무 많은 일을 하거나 설계가 잘못됐다는 신호

출력 인수

  • 인수는 입력으로만 사용해야 한다, 출력으로 사용하면 직관에 반함
  • 함수가 상태를 변경해야 한다면, 인수의 상태가 아니라 자신이 속한 객체의 상태를 변경해야 함
// 나쁜 예: 출력 인수
function appendFooter(report: StringBuffer): void { ... }

// 좋은 예: 객체의 메서드로
report.appendFooter();

플래그 인수

  • boolean 인수는 함수가 두 가지 이상의 일을 한다는 선언
  • 혼란스럽고 단일 책임 원칙에 위배됨 → 두 함수로 분리해야 함
// 나쁜 예
render(isSuite: boolean)

// 좋은 예
renderForSuite()
renderForSingleTest()

죽은 함수

  • 호출되지 않는 함수는 삭제해야 한다
  • 죽은 코드를 유지하는 것은 낭비
  • 소스 컨트롤 시스템이 기억하므로 삭제를 두려워하지 말 것

일반

한 소스 파일에 여러 언어

  • 이상적인 소스 파일은 하나의 언어만 포함한다
  • 현실적으로 불가피하다면 여러 언어의 수와 범위를 최소화해야 함
  • 예: TypeScript 파일에 인라인 SQL, HTML 템플릿이 섞이는 경우 → 관심사 분리 필요

당연한 동작이 구현되지 않음

  • “최소 놀람의 원칙(Principle of Least Surprise)”: 함수는 다른 프로그래머가 합리적으로 기대하는 동작을 구현해야 함
  • 예: 요일 이름을 enum으로 변환하는 함수라면 대소문자 무시, 약어 지원 등이 당연히 기대됨
  • 당연한 동작이 없으면 개발자는 직관을 믿지 못하고 코드를 직접 읽어야 함 → 신뢰 손실

경계에서의 잘못된 동작

  • 직관을 믿지 말고, 모든 경계 조건에 대해 테스트를 작성하라
  • 개발자는 알고리즘의 중간은 잘 구현하지만 경계에서 실수하는 경우가 많음
  • 모든 코너 케이스, 경계 조건, 예외 상황은 우아한 알고리즘을 혼란에 빠뜨릴 수 있음

안전 장치 무력화

  • 안전 장치를 끄는 것은 위험하다
  • 예시:
    • 컴파일러 경고 끄기 → 빌드 성공하지만 디버깅 지옥 위험
    • 실패하는 테스트를 무시(skip) 처리하고 “나중에 고치겠다”고 다짐 → 절대 고치지 않음
  • 체르노빌 원전 사고: 실험을 위해 안전 장치를 하나씩 끈 결과 → 역사상 최초의 민간 원전 대참사

중복

  • 이 책에서 가장 중요한 규칙 중 하나. 매우 진지하게 받아들여야 한다
  • 다양한 명칭으로 불리는 동일한 원칙:
    • DRY (Don’t Repeat Yourself) - Dave Thomas, Andy Hunt
    • “Once, and only once” - Kent Beck (XP 핵심 원칙)
  • 중복의 형태와 해결책:
중복 유형 해결 방법
동일한 코드 블록 복붙 단순 함수/메서드로 추출
여러 곳에서 동일 조건을 검사하는 if/switch 다형성(Polymorphism)으로 대체
유사한 알고리즘이지만 코드는 다름 Template Method / Strategy 패턴
  • 중복은 추상화의 기회를 놓친 것이다 → 추상화하면 설계 어휘가 풍부해지고 코딩 속도가 빨라지며 오류가 줄어듦
  • 대부분의 디자인 패턴은 중복 제거 방법론이며, Codd 정규화도, OOP도, 구조적 프로그래밍도 모두 중복 제거를 위한 것

잘못된 추상화 수준의 코드

  • 상위 수준과 하위 수준의 개념을 명확히 분리해야 한다
  • 기본 클래스(base class)는 하위 구현 세부사항(파생 클래스)에 대해 알면 안 됨
  • 특정 구현에만 해당하는 상수, 변수, 유틸리티 함수는 기본 클래스에 있으면 안 됨
// 나쁜 예: percentFull은 모든 Stack 구현에 적용될 수 없음
interface Stack {
  pop(): unknown;
  push(o: unknown): void;
  percentFull(): number; // ← 잘못된 추상화 수준
}

// 좋은 예: 유계 스택에만 적용되는 개념은 파생 인터페이스로 분리
interface BoundedStack extends Stack {
  percentFull(): number;
}
  • 추상화를 올바르게 분리하는 것은 소프트웨어 개발에서 가장 어려운 일 중 하나이며, 잘못됐을 때 빠른 해결책은 없음

기본 클래스가 파생 클래스에 의존

  • 기본 클래스는 파생 클래스의 존재를 몰라야 한다
  • 기본 클래스가 파생 클래스 이름을 언급하면 의심해야 함
  • 예외: 파생 클래스의 수가 고정된 경우 (유한 상태 머신 등)
  • 독립 배포의 이점: 파생/기본 클래스를 별도 모듈로 배포하면 변경 파급 효과가 줄고 유지보수가 쉬워짐

과도한 정보

  • 인터페이스는 작고 좁아야 한다
  • 잘 정의된 모듈: 적은 함수로 많은 것을 할 수 있음 → 낮은 결합도
  • 잘못 정의된 모듈: 단순한 작업에도 수많은 함수 호출이 필요 → 높은 결합도
  • 실천 방법:
    • 클래스의 메서드 수를 최소화
    • 함수가 알아야 하는 변수 수를 최소화
    • 클래스의 인스턴스 변수 수를 최소화
    • 데이터, 유틸리티 함수, 상수, 임시 변수를 숨길 것
    • protected 변수와 함수를 서브클래스에 노출하지 말 것

죽은 코드

  • 실행되지 않는 코드는 시스템에서 제거해야 한다
  • 죽은 코드가 생기는 곳:
    • 절대 true가 될 수 없는 조건의 if 블록
    • 예외를 던지지 않는 trycatch 블록
    • 호출되지 않는 유틸리티 메서드
    • 발생하지 않는 switch/case 조건
  • 죽은 코드는 시간이 지날수록 악취가 강해진다: 설계가 바뀌어도 갱신되지 않아 컨벤션을 위반하게 됨

수직 분리

  • 변수와 함수는 사용되는 위치와 가까이 정의해야 한다
  • 지역 변수: 처음 사용하는 위치 바로 위에 선언, 세로 범위를 최소화
  • private 함수: 처음 호출되는 위치 바로 아래에 정의 (스코프는 전체 클래스지만 물리적 거리는 최소화)

일관성 없음

  • 같은 방식으로 하는 일은 항상 같은 방식으로 해야 한다 (최소 놀람의 원칙)
  • 한 함수에서 response로 명명했다면 같은 타입을 다루는 다른 함수에서도 response를 사용
  • 한 종류의 요청 처리를 processVerificationRequest라고 명명했다면 다른 요청도 processDeletionRequest와 같이 일관된 패턴을 따름
  • 일관성은 코드를 훨씬 읽기 쉽고 수정하기 쉽게 만들어 줌

잡동사니

  • 사용하지 않는 모든 것은 제거해야 한다
  • 잡동사니의 예:
    • 구현이 없는 기본 생성자
    • 사용하지 않는 변수
    • 호출하지 않는 함수
    • 정보가 없는 주석
  • 소스 파일은 깨끗하고 잘 정돈되어 있어야 한다

인위적 결합

  • 서로 의존하지 않는 것들을 인위적으로 결합하지 마라
  • 예: 특정 클래스 안에 범용 enum이나 범용 static 함수를 선언하면, 그 클래스를 알아야만 해당 enum/함수에 접근 가능 → 불필요한 의존성 발생
  • 인위적 결합은 편의를 위해 임시로 배치한 결과이며, 게으름과 부주의의 산물
  • 변수, 상수, 함수가 정말 어디에 있어야 하는지 시간을 들여 생각할 것

기능 욕심

  • 클래스의 메서드는 다른 클래스의 변수와 함수보다 자신이 속한 클래스의 변수와 함수에 관심을 가져야 한다
  • 다른 객체의 접근자(accessor)와 변경자(mutator)를 과도하게 사용하면 그 클래스의 스코프를 “부러워”하는 것
// 나쁜 예: calculateWeeklyPay가 HourlyEmployee의 내부에 있고 싶어함
class HourlyPayCalculator {
  calculateWeeklyPay(e: HourlyEmployee): Money {
    const tenthRate = e.getTenthRate().getPennies();
    const tenthsWorked = e.getTenthsWorked();
    // ... HourlyEmployee의 데이터를 모두 꺼내서 씀
  }
}
  • 기능 욕심을 없애면 한 클래스의 내부를 다른 클래스에 노출하지 않아도 된다
  • 단, 불가피한 경우도 있음: 보고서 포맷 함수는 HourlyEmployee에 있으면 안 되며, 별도 보고서 클래스에 있어야 SRP를 지킴

선택자 인수

  • 함수 끝에 달린 boolean(또는 enum) 인수는 끔찍하다
  • 문제점:
    • 호출 시 false의 의미를 기억하기 어려움
    • 여러 함수를 하나로 합쳐놓은 게으른 방식
    • 읽기 어렵고 목적이 불명확함
// 나쁜 예
calculateWeeklyPay(true)   // true가 뭘 의미하는지 알 수 없음
calculateWeeklyPay(false)

// 좋은 예: 명확히 분리
straightPay()
overTimePay()
  • 선택자 인수는 boolean에 국한되지 않음 → enum, 정수 등 동작을 선택하는 모든 인수 포함
  • 일반적으로 코드에 선택자를 전달하는 것보다 여러 함수를 두는 것이 낫다

모호한 의도

  • 코드는 가능한 한 의도를 명확하게 표현해야 한다
  • 의도를 흐리는 것들:
    • 런온 표현식
    • 헝가리안 표기법 (m_, iThsWkd)
    • 매직 넘버
// 나쁜 예: 무슨 의미인지 전혀 알 수 없음
function mOtCalc(): number {
  return iThsWkd * iThsRte + Math.round(0.5 * iThsRte * Math.max(0, iThsWkd - 400));
}

// 좋은 예
function calculateOvertimePay(): number {
  const straightTime = Math.min(tenthsWorked, 400);
  const overTime = Math.max(0, tenthsWorked - straightTime);
  return straightTime * tenthRate + Math.round(0.5 * tenthRate * overTime);
}

잘못 배치된 책임

  • 코드는 독자가 자연스럽게 기대하는 위치에 있어야 한다 (최소 놀람의 원칙 적용)
  • 예: PI 상수는 삼각 함수가 있는 곳에, OVERTIME_RATEHourlyPayCalculator에 있어야 함
  • 함수 이름으로 어디에 코드가 있어야 하는지 판단: getTotalHours()라는 이름이 있다면 합산도 거기서 해야 함
  • 성능상 이유로 위치를 옮겨야 할 때는 함수 이름에 그 의도를 반영해야 함

부적절한 static

  • static 메서드로 만들면 안 되는 것을 static으로 만들지 마라
  • 좋은 static의 조건: 특정 인스턴스 없이도 동작하며, 다형성이 필요 없는 경우 (Math.max() 등)
  • 나쁜 static: 다형적으로 동작해야 할 가능성이 있는 함수
  // 나쁜 예: 나중에 OvertimePayCalculator, StraightTimeCalculator로 분화할 수 없음
  HourlyPayCalculator.calculatePay(employee, overtimeRate);

  // 좋은 예: Employee의 인스턴스 메서드로
  employee.calculatePay();
  • 확신이 없으면 static으로 만들지 말 것

설명적 변수 사용

  • 계산 과정을 의미 있는 이름의 중간 변수로 쪼개면 가독성이 극적으로 향상된다
// 나쁜 예
if (line.match(/^(\w+):\s*(.+)$/)) {
  headers.set(line.match(/^(\w+):\s*(.+)$/)[1].toLowerCase(), line.match(/^(\w+):\s*(.+)$/)[2]);
}

// 좋은 예: 설명적 변수 사용
const match = line.match(/^(\w+):\s*(.+)$/);
if (match) {
  const key = match[1];
  const value = match[2];
  headers.set(key.toLowerCase(), value);
}
  • 설명적 변수를 과도하게 사용하는 것은 거의 불가능하다 → 많을수록 좋음
  • 불투명한 모듈도 중간 값에 좋은 이름을 붙이면 갑자기 투명해진다

함수 이름은 하는 일을 나타내야 한다

const newDate = date.add(5); // 5일? 5주? 5시간? 원본이 바뀌나?

// 명확한 이름
date.addDaysTo(5);     // 원본을 변경
date.daysLater(5);     // 새 날짜를 반환
  • 함수의 구현이나 문서를 봐야 동작을 이해할 수 있다면 → 더 나은 이름을 찾거나 함수를 재구성해야 함

알고리즘을 이해하라

  • 테스트를 통과한다고 알고리즘을 이해한 것이 아니다
  • 많은 버그가 충분히 이해하지 못한 상태에서 if문과 플래그를 끼워 넣어 “작동하게” 만든 코드에서 발생
  • 함수가 완료됐다고 판단하기 전에:
    • 알고리즘이 어떻게 동작하는지 직접 설명할 수 있어야 함
    • 테스트 통과만으로는 부족하며, 해법이 올바르다는 것을 알아야
  • 최선의 방법: 함수를 리팩터링하여 동작 방식이 명백해질 만큼 표현력 있게 만들기

논리적 의존성을 물리적으로 만들어라

  • 한 모듈이 다른 모듈에 의존한다면, 그 의존성은 명시적(물리적)이어야 한다
  • 의존하는 모듈은 상대 모듈에 대해 가정(논리적 의존)하지 말고 직접 물어봐야 한다
// 나쁜 예: HourlyReporter가 HourlyReportFormatter가 55줄 페이지를 처리할 수 있다고 가정
class HourlyReporter {
  private readonly PAGE_SIZE = 55; // ← 논리적 의존
}

// 좋은 예: Formatter에게 직접 물어봄
class HourlyReporter {
  generateReport(employees: HourlyEmployee[]): void {
    const pageSize = this.formatter.getMaxPageSize(); // 물리적 의존
    // ...
  }
}

다형성 선호

  • 대부분의 switch는 다형성으로 대체할 수 있으며, 더 나은 경우가 많다
  • 대부분의 개발자가 switch를 사용하는 이유: 명백한 해결책이기 때문이지, 올바른 해결책이기 때문이 아님
  • “ONE SWITCH” 규칙: 특정 타입 선택에 대한 switch는 시스템에 하나만 있어야 하며, 그 switch는 다형적 객체를 생성해야 함. 나머지 switch는 다형성으로 대체
  • 함수가 타입보다 더 자주 변한다면 switch가 적절할 수 있음 (Chapter 6 참조)

표준 표기법 따르기

  • 팀은 업계 표준에 기반한 공통 코딩 표준을 따라야 한다
  • 표준이 다뤄야 할 항목: 인스턴스 변수 선언 위치, 클래스/메서드/변수 명명법, 중괄호 위치 등
  • 표준을 문서로 설명하지 않아도 된다 → 코드 자체가 예시가 돼야 한다
  • 팀 구성원 모두가 표준을 지킬 만큼 성숙해야 하며, 어디에 중괄호를 넣느냐보다 모두가 동의했다는 사실이 중요함

매직 넘버는 명명된 상수로

  • 코드에 원시 숫자를 직접 사용하지 말고 이름 있는 상수 뒤에 숨겨라
// 나쁜 예
const dailyPay = hourlyRate * 8;
if (page.length === 55) { ... }

// 좋은 예
const WORK_HOURS_PER_DAY = 8;
const LINES_PER_PAGE = 55;

const dailyPay = hourlyRate * WORK_HOURS_PER_DAY;
if (page.length === LINES_PER_PAGE) { ... }
  • “매직 넘버”는 숫자에 국한되지 않음 → 자체 설명이 안 되는 모든 토큰이 해당
  // 나쁜 예
  expect(Employee.find("John Doe").employeeNumber).toBe(7777);

  // 좋은 예
  expect(Employee.find(HOURLY_EMPLOYEE_NAME).employeeNumber).toBe(HOURLY_EMPLOYEE_ID);
  • 단, 5280(1 마일의 피트), Math.PI 같이 너무 잘 알려진 상수는 굳이 명명하지 않아도 됨

정확하게 하라

  • 코드에서 내린 결정은 정확하게 이루어져야 한다
  • 모호함과 부정확함은 불일치 또는 게으름의 결과이며 반드시 제거해야 함
안일한 결정 올바른 결정
쿼리 결과가 하나일 것이라고 가정 결과가 없거나 여러 개일 경우를 처리
통화 표현에 float 사용 정수 또는 Decimal 타입 사용
동시 업데이트 가능성이 없다고 가정 잠금 메커니즘 구현
null 반환 함수 호출 후 null 체크 생략 반드시 null 체크

규칙보다 구조

  • 구조를 통해 설계 결정을 강제하는 것이 명명 규칙보다 낫다
  • 추상 메서드가 있는 기본 클래스는 모든 파생 클래스가 해당 메서드를 구현하도록 강제
  • switch/case는 모든 개발자가 동일하게 구현한다는 보장이 없음
// 나쁜 예: 규칙에만 의존 (누군가 새 케이스를 추가하는 걸 잊을 수 있음)
switch (payType) {
  case 'HOURLY': ...
  case 'SALARIED': ...
}

// 좋은 예: 구조가 강제
abstract class Employee {
  abstract calculatePay(): Money; // 파생 클래스는 반드시 구현해야 함
}

조건문을 캡슐화하라

  • 조건의 의도를 설명하는 함수로 추출하라
  • ifwhile 없이도 boolean 로직은 이해하기 어렵기 때문
// 나쁜 예
if (timer.hasExpired() && !timer.isRecurrent()) { ... }

// 좋은 예
if (shouldBeDeleted(timer)) { ... }

function shouldBeDeleted(timer: Timer): boolean {
  return timer.hasExpired() && !timer.isRecurrent();
}

부정 조건문 피하기

  • 부정보다 긍정 조건이 이해하기 쉽다
// 나쁜 예
if (!buffer.shouldNotCompact()) { ... }

// 좋은 예
if (buffer.shouldCompact()) { ... }

함수는 한 가지만 해야 한다

  • 여러 단락으로 나뉘어 여러 작업을 수행하는 함수는 분리해야 한다
// 나쁜 예: 3가지를 동시에 함
function pay(): void {
  for (const e of employees) {
    if (e.isPayday()) {
      const pay = e.calculatePay();
      e.deliverPay(pay);
    }
  }
}

// 좋은 예: 각 함수가 정확히 한 가지만 함
function pay(): void {
  for (const e of employees) payIfNecessary(e);
}

function payIfNecessary(e: Employee): void {
  if (e.isPayday()) calculateAndDeliverPay(e);
}

function calculateAndDeliverPay(e: Employee): void {
  const pay = e.calculatePay();
  e.deliverPay(pay);
}

숨겨진 시간적 결합

  • 시간적 결합이 필요할 때는 숨기지 말고 명확히 드러내라
  • 버킷 연쇄(bucket brigade) 방식: 각 함수가 다음 함수에 필요한 결과를 생성
// 나쁜 예: 호출 순서가 강제되지 않음
class MoogDiver {
  dive(reason: string): void {
    this.saturateGradient();
    this.reticulateSplines(); // saturateGradient 없이 호출되면 오류!
    this.diveForMoog(reason);
  }
}

// 좋은 예: 인수를 통해 순서를 강제
class MoogDiver {
  dive(reason: string): void {
    const gradient = this.saturateGradient();
    const splines = this.reticulateSplines(gradient); // gradient 없이 호출 불가
    this.diveForMoog(splines, reason);                // splines 없이 호출 불가
  }
}
  • 코드의 복잡성이 증가하지만, 그것은 실제 복잡성을 드러내는 것이지 추가하는 것이 아님

임의적으로 구조를 만들지 마라

  • 코드 구조에는 이유가 있어야 하고, 그 이유가 구조에 드러나야 한다
  • 임의적으로 보이는 구조는 다른 개발자가 마음대로 변경할 것이라는 신호를 줌
  • 일관성 있는 구조는 다른 개발자가 동일한 규칙을 따르게 만듦
  • 예: 관련 없는 클래스 안에 중첩된 public 클래스 → 최상위 수준에 두어야 함

경계 조건을 캡슐화하라

  • 경계 조건은 한 곳에서만 처리해야 한다. 코드 전체에 퍼지게 해선 안 된다
// 나쁜 예: level + 1이 여러 곳에 흩어짐
if (level + 1 < tags.length) {
  parts = new Parse(body, tags, level + 1, offset + endTag);
}

// 좋은 예: 변수로 캡슐화
const nextLevel = level + 1;
if (nextLevel < tags.length) {
  parts = new Parse(body, tags, nextLevel, offset + endTag);
}

함수는 추상화 수준을 한 단계만 내려가야 한다

  • 함수 내의 모든 문장은 같은 추상화 수준에 있어야 한다
  • 함수가 명시한 수준보다 정확히 한 단계 아래 수준에서 동작해야 함
  • 추상화 수준을 분리하다 보면 숨어있던 새로운 추상화 경계가 드러남 → 정상적이고 반복되는 과정
// 나쁜 예: HR 태그 구성(저수준)과 크기 해석(고수준)이 혼재
function render(): string {
  let html = '<hr';
  if (size > 0) html += ` size="${size + 1}"`;
  html += '>';
  return html;
}

// 좋은 예: 각 함수가 하나의 추상화 수준만 다룸
function render(): string {
  const hr = new HtmlTag('hr');
  if (extraDashes > 0) hr.addAttribute('size', hrSize(extraDashes));
  return hr.html();
}

function hrSize(height: number): string {
  return String(height + 1);
}

설정 데이터는 높은 수준에 두어라

  • 기본값이나 설정 상수는 높은 추상화 수준에 노출시켜라
  • 저수준 함수 깊숙이 설정값을 숨기지 말 것
  • 높은 수준의 함수에서 설정값을 관리하고, 이를 저수준 함수에 인수로 전달해야 함
// 좋은 예: 설정값이 최상위에 모여 있어 변경이 쉬움
class Arguments {
  static readonly DEFAULT_PATH = '.';
  static readonly DEFAULT_ROOT = 'FitNesseRoot';
  static readonly DEFAULT_PORT = 80;
  static readonly DEFAULT_VERSION_DAYS = 14;
}
  • 저수준 함수 내에 if (port === 0) { /* use 80 */ } 같은 코드가 있으면 안 됨

추이적 탐색을 피하라

  • 모듈은 직접적인 협력자만 알아야 한다 (디미터 법칙, “부끄러움 타는 코드”)
  • A가 B를 알고, B가 C를 안다고 해서 A가 C를 알아야 할 필요는 없음
// 나쁜 예: 디미터 법칙 위반 (기차 충돌)
a.getB().getC().doSomething();

// 좋은 예: 직접적인 협력자만 활용
myCollaborator.doSomething();
  • 추이적 탐색이 퍼지면 B와 C 사이에 Q를 끼우는 설계 변경이 불가능해짐 → 아키텍처가 경직

자바 특화 규칙

이 절의 규칙들은 Java에 특화된 내용이지만, TypeScript에서도 유사한 원칙이 적용된다.

J1: 와일드카드로 긴 import 목록을 줄여라

  • 패키지에서 2개 이상 클래스를 사용한다면 전체 패키지를 가져올 것 (import package.*)
  • TypeScript 적용: 같은 모듈에서 여러 항목을 사용할 때는 barrel export(index.ts)를 활용
  • 와일드카드 import는 약한 의존성이므로 결합도를 낮춤

J2: 상수를 상속하지 마라

  • 상수를 접근하기 위해 인터페이스/클래스를 상속하지 마라
  • 이는 언어의 스코프 규칙을 우회하는 꼼수이며 가독성을 해침
  • TypeScript 적용: namespace, const enum, 또는 별도 상수 파일에서 직접 가져올 것
// 나쁜 예: 상수를 얻기 위해 상속
class HourlyEmployee extends PayrollConstants { ... }

// 좋은 예: 명시적 import
import { TENTHS_PER_WEEK, OVERTIME_RATE } from './PayrollConstants';

J3: 상수와 enum

  • 단순 const 정수보다 enum을 적극적으로 활용하라
  • TypeScript의 enum은 메서드를 가질 수 없지만 유니온 타입 + 함수 조합 또는 const enum으로 의미를 명확히 할 수 있음
// 나쁜 예: 숫자 상수 (의미가 쉽게 사라짐)
const APPRENTICE = 1;
const JOURNEYMAN = 2;

// 좋은 예: TypeScript enum 활용
enum HourlyPayGrade {
  APPRENTICE = 1.0,
  LIEUTENANT_JOURNEYMAN = 1.2,
  JOURNEYMAN = 1.5,
  MASTER = 2.0,
}

명명법

서술적인 이름을 사용하라

  • 이름을 선택하는 데 시간을 투자하라. 소프트웨어의 가독성 90%가 이름에서 나온다
  • 소프트웨어가 진화하면서 의미가 변하므로 주기적으로 이름의 적절성을 재평가할 것
  • 좋은 이름은 모듈의 구조에 설명을 오버로드하여 다른 함수들의 동작까지 예측 가능하게 만듦

적절한 추상화 수준의 이름

  • 구현이 아닌 추상화 수준을 반영하는 이름을 선택하라
// 나쁜 예: dial, phoneNumber는 구현 세부사항
interface Modem {
  dial(phoneNumber: string): boolean;
  getConnectedPhoneNumber(): string;
}

// 좋은 예: connect, connectionLocator는 추상화 수준이 높음 (케이블 모뎀, USB 등도 가능)
interface Modem {
  connect(connectionLocator: string): boolean;
  getConnectedLocator(): string;
}

표준 명명법 사용

  • 기존 관례나 표준 용어를 기반으로 이름을 지어라
  • DECORATOR 패턴을 사용한다면 이름에 Decorator를 포함 (AutoHangupModemDecorator)
  • Eric Evans의 유비쿼터스 언어(Ubiquitous Language): 프로젝트 도메인 언어를 코드에 광범위하게 사용

명확한 이름

  • 함수나 변수의 동작을 명확하게 설명하는 이름을 선택하라
// 나쁜 예: doRename 안에 renamePage가 있어 차이를 알 수 없음
function doRename(): void { ... }

// 좋은 예: 이름 하나로 모든 것을 설명
function renamePageAndOptionallyAllReferences(): void { ... }

스코프가 길면 긴 이름을 사용하라

  • 이름의 길이는 스코프의 길이에 비례해야 한다
  • 5줄짜리 루프의 i, j는 완벽히 적절
  • 스코프가 넓을수록 이름이 길고 정밀해야 함 → 짧은 이름은 멀리서 의미를 잃음

인코딩을 피하라

  • 타입이나 스코프 정보를 이름에 인코딩하지 마라
  • 금지 항목:
    • 멤버 변수 접두사: m_namename
    • 헝가리안 표기법: iCount, strName
    • 프로젝트/서브시스템 접두사: vis_imageProcessor
  • 현대 IDE는 이미 이 모든 정보를 제공하므로 이름을 오염시킬 필요가 없음

부수 효과를 이름에 반영하라

  • 함수, 변수, 클래스가 하는 모든 것을 이름이 설명해야 한다
  • 함수가 한 가지 이상을 한다면 이름에 그것을 반영해야 함
// 나쁜 예: 단순히 "get"이라고 하지만 실제로 생성도 함
function getOos(): ObjectOutputStream { ... }

// 좋은 예: 부수 효과(생성)를 이름에 반영
function createOrReturnOos(): ObjectOutputStream { ... }

테스트

불충분한 테스트

  • 테스트 스위트는 깨질 수 있는 모든 것을 테스트해야 한다
  • “이 정도면 충분한 것 같다”는 기준은 잘못된 기준
  • 탐색하지 않은 조건, 검증하지 않은 계산이 있다면 테스트는 불충분함

커버리지 도구를 사용하라

  • 커버리지 도구는 테스트 전략의 빈틈을 보고한다
  • 대부분의 IDE가 커버된 줄(초록)과 미커버된 줄(빨강)을 시각적으로 표시
  • ifcatch 블록에서 테스트되지 않은 본문을 빠르게 발견할 수 있음

사소한 테스트를 건너뛰지 마라

  • 사소한 테스트는 작성하기 쉽고, 문서로서의 가치가 비용을 초과한다

무시한 테스트는 모호함에 대한 질문

  • 요구사항이 불명확한 경우 → 주석 처리하거나 skip/xit으로 표시한 테스트로 질문을 표현
  • 컴파일 가능한 경우: skip 어노테이션 사용
  • 컴파일 불가능한 경우: 주석으로 처리

경계 조건을 테스트하라

  • 경계 조건에 특별히 주의를 기울여라
  • 알고리즘 중간은 잘 맞히지만 경계에서 실수하는 경우가 많음

버그 주변을 철저히 테스트하라

  • 버그는 무리 지어 나타난다
  • 함수에서 버그를 발견했다면 그 함수 전체를 철저히 테스트할 것 → 주변에 다른 버그가 있을 가능성이 높음

실패 패턴은 단서를 제공한다

  • 테스트 케이스가 실패하는 패턴을 통해 문제를 진단할 수 있다
  • 예: 입력이 5자리 이상인 모든 테스트가 실패 → 경계 조건 버그
  • 테스트 케이스를 완전하고 합리적인 순서로 배열하면 패턴이 드러남

테스트 커버리지 패턴도 단서를 제공한다

  • 통과하는 테스트가 실행하는 코드와 실행하지 않는 코드를 살펴보면 실패하는 이유에 대한 단서를 얻는다

테스트는 빨라야 한다

  • 느린 테스트는 실행되지 않을 테스트다
  • 시간이 촉박해지면 느린 테스트부터 제외되며, 그러면 버그가 숨어든다
  • 테스트를 빠르게 유지하기 위해 필요한 모든 것을 할 것

정리

  • 이 목록은 완전하지 않으며, 완전할 수도 없다
  • 완전성이 목표가 아니라, 가치 체계를 내포하는 것이 목표
  • 클린 코드는 규칙을 따른다고 만들어지는 것이 아님
  • 전문성과 장인 정신은 이 가치들이 이끄는 훈련에서 나온다

핵심 요약

  • 주석: 기술적 메모만 담고, 낡거나 중복되거나 주석 처리된 코드는 즉시 제거
  • 환경: 빌드와 테스트는 명령 하나로 실행 가능해야 함
  • 함수: 인수는 적게, 출력 인수 금지, 플래그 인수 금지, 죽은 함수 삭제
  • 중복 (G5): 가장 중요한 규칙. 중복은 추상화의 기회를 놓친 것
  • 추상화: 상위/하위 수준의 개념을 엄격히 분리하고 올바른 위치에 배치
  • 이름: 서술적이고 모호하지 않으며 부수 효과까지 반영해야 함
  • 정확성 (G26): 코드의 모든 결정은 정확해야 하며 예외 처리를 게을리하지 말 것
  • 테스트: 빠르고 완전해야 하며, 경계 조건을 반드시 검증할 것
  • 디미터 법칙: 모듈은 직접적인 협력자만 알아야 함 (a.getB().getC() 금지)
  • 결합도: 논리적 의존은 물리적으로 명시, 인위적 결합 제거, 설정값은 고수준에