본문으로 건너뛰기

Good Code, Bad Code

Tom LongISBN 9791191600896

목차89개 항목

Chapter 1. 코드 품질

Tom Long 저 | 챕터 1 핵심 정리 노트


1.1 들어가며: 왜 코드 품질이 중요한가

  • 소프트웨어는 우리 일상 깊숙이 침투해 있다. 폰 앱부터 은행 시스템까지, 버그 하나가 단순한 불편함을 넘어 삶을 파괴할 수도 있다.
  • 고품질 코드는 신뢰할 수 있고(reliable), 유지하기 쉽고(maintainable), 버그가 적은(less buggy) 소프트웨어를 만들어낸다.
  • 코드 품질은 단순히 처음에 잘 작동하는 것뿐 아니라, 요구사항이 변하고 새로운 시나리오가 등장해도 계속 잘 동작하는 것을 목표로 한다.
코드 품질 원래 요구사항 요구사항 변경 시 예외 상황 발생 시 공격 시
고품질 완전히 충족 소규모 추가 작업 정상 복구 또는 graceful 실패 안전 상태 유지
저품질 엣지 케이스 미처리로 불완전 대규모 재설계 필요 정의되지 않은 상태, 데이터 손상 가능 시스템 위험에 노출

1.2 코드가 소프트웨어가 되는 과정

코드는 작성된 즉시 운영(production)에 배포되지 않는다. 여러 검증 단계를 거친다.

핵심 용어 정리

  • 코드베이스(Codebase): 소프트웨어를 빌드할 수 있는 코드 저장소. 보통 git 등 버전 관리 시스템으로 관리된다.
  • 코드 제출(Submitting code): 로컬 변경사항을 메인 코드베이스에 병합(PR merge)하는 행위.
  • 코드 리뷰(Code review): 제출 전 다른 엔지니어가 코드를 검토하는 과정. 작성자가 놓친 문제를 발견하는 “교정(proofreading)” 역할.
  • 사전 제출 검사(Pre-submit checks): 테스트 실패 또는 컴파일 오류 시 코드 제출을 차단하는 자동화된 검사. pre-merge hook, pre-commit check이라고도 부른다.
  • 릴리즈(Release): 코드베이스의 특정 스냅샷으로부터 빌드된 소프트웨어 버전.
  • 프로덕션(Production): 소프트웨어가 실제 서버에 배포되어 운영되는 환경.

일반적인 소프트웨어 개발 및 배포 프로세스

1. 엔지니어가 로컬 코드베이스 사본에서 변경 작업
2. 코드 리뷰를 위해 변경사항 제출
3. 다른 엔지니어가 리뷰 후 수정 제안 (승인 또는 재작업 요청)
4. 승인되면 코드베이스에 코드 제출
5. 코드베이스에서 주기적으로 릴리즈 생성 (팀/조직마다 주기 상이)
6. 테스트 실패 또는 컴파일 오류 발생 시 제출 또는 릴리즈 차단

소프트웨어 개발 및 배포 프로세스는 그 자체로 방대한 주제다. 이 책은 해당 프로세스의 큰 그림만 다루며, 코드 자체의 품질에 집중한다.


1.3 코드 품질의 목표

코드 품질을 객관적으로 정의하기 위해, 코드를 작성할 때 궁극적으로 달성하고자 하는 4가지 고수준 목표를 기준으로 삼는다.

목표 1: 코드는 동작해야 한다

  • 코드는 해결하려는 문제를 실제로 해결해야 한다.
  • “동작한다”는 것은 모든 요구사항을 충족하는 것을 의미한다.
  • 성능(레이턴시, CPU 사용량), 사용자 프라이버시, 보안 등도 요구사항의 일부라면 함께 충족해야 한다.

목표 2: 코드는 계속 동작해야 한다

코드는 독립적으로 존재하지 않으며, 주변 환경이 변함에 따라 쉽게 깨질 수 있다.

  • 의존하는 다른 코드가 수정·업데이트된다.
  • 새로운 기능 추가로 코드 수정이 필요해진다.
  • 해결하려는 문제 자체가 시간에 따라 변한다 (비즈니스 요구, 소비자 선호, 기술 트렌드).

오늘 동작하지만 내일 깨지는 코드는 쓸모없다. “동작하는 코드”를 만드는 것은 쉽지만, “계속 동작하는 코드”를 만드는 것은 훨씬 어렵다. 이것은 소프트웨어 엔지니어가 맞닥뜨리는 가장 큰 과제 중 하나다.

목표 3: 코드는 변화하는 요구사항에 적응할 수 있어야 한다

소프트웨어 개발은 수개월, 수년, 때로는 수십 년에 걸쳐 지속된다. 그동안 요구사항은 반드시 변한다.

두 극단적인 시나리오

구분 시나리오 A (과도한 예측) 시나리오 B (변화 무시)
접근 방식 미래의 모든 가능한 변경을 예측하여 미리 설계 현재 요구사항만 정확히 충족, 적응성 고려 없음
단기 결과 개발 속도 극도로 느림, 출시 지연 빠른 초기 출시
장기 결과 예측은 틀리고, 경쟁사에 시장 선점 당함 요구사항 변경 시 전체 재작성 필요, 반복적 낭비
결론 비효율 비효율

두 극단 사이의 적절한 균형점을 찾는 것이 중요하다. 정확히 어디가 최적점인지는 프로젝트 성격과 조직 문화에 따라 다르다. 다행히 미래를 정확히 예측하지 않아도 적응성 있는 코드를 작성하는 일반적인 기법들이 존재한다.

목표 4: 코드는 바퀴를 재발명하지 말아야 한다

문제를 해결할 때 큰 문제를 작은 하위 문제로 나누게 된다. 이 하위 문제들 중 많은 것이 이미 다른 사람에 의해 해결되어 있다.

기존 솔루션을 활용해야 하는 이유

  • 시간과 노력 절약: 파일 읽기 기능을 직접 구현하려면 수천 줄의 코드와 수일의 시간이 필요하다. 내장 함수를 쓰면 몇 줄로 끝난다.
  • 버그 감소: 이미 널리 사용된 코드는 버그가 발견되고 수정되었을 가능성이 높다.
  • 기존 전문성 활용: JPEG 인코딩 라이브러리 관리팀은 해당 분야 전문가다. 새 버전이 나오면 그들이 업데이트한다.
  • 가독성 향상: 표준화된 방식은 다른 엔지니어들이 이미 알고 있을 가능성이 높다.

이 원칙은 양방향으로 적용된다. 다른 사람의 코드를 재사용해야 할 뿐 아니라, 내가 만든 코드도 다른 사람이 쉽게 재사용할 수 있도록 구조화해야 한다.


1.4 코드 품질의 여섯 기둥

4가지 목표는 방향을 제시하지만 구체적인 행동 지침이 되기 어렵다. 이를 위해 **여섯 가지 고수준 전략(pillars)**을 정의한다.

기둥 1: 코드를 읽기 쉽게 만들어라

나쁜 가독성의 예시: 그릇을 A, 냄비를 B, 오븐을 F로 지칭하며 쓴 브라우니 레시피. 무엇을 만드는지 끝까지 읽어야 알 수 있고, 재료와 분량이 여기저기 흩어져 있다.

코드 가독성이 낮으면 다른 엔지니어가 어려워하는 것들:

  • 코드가 무엇을 하는지 파악하기 어렵다.
  • 코드가 어떻게 하는지 이해하기 어렵다.
  • 입력값(상태, 인자) 이 무엇인지 파악하기 어렵다.
  • 실행 후 어떤 결과를 얻는지 알기 어렵다.

결과: 코드 리뷰 시 버그 발견 실패, 수정 시 새로운 버그 도입, 소프트웨어 품질 전반 저하.

코드 리뷰를 통과한다는 것은 곧 다른 엔지니어가 우리 코드를 읽는다는 것을 의미한다. 그들이 코드를 이해하지 못하면 소프트웨어 전체가 제대로 동작하는지 보장하기 어렵다.

챕터 2(추상화 레이어), 챕터 5(구체적 가독성 기법)에서 상세히 다룬다.

기둥 2: 놀라움을 피하라

비유: 피자 가게에 전화했는데, 통화 중일 경우 자동으로 다음 번호(멕시코 식당)로 연결하는 다이얼러 앱. 마가리타 피자를 주문했지만 마가리타 칵테일이 배달된다.

  • 다이얼러 앱은 좋은 의도로 만들어졌지만, 사용자의 멘탈 모델을 벗어나는 동작을 한다.
  • 코드도 마찬가지다. 다른 엔지니어는 이름, 데이터 타입, 관례 등으로 코드의 동작을 예측하는 멘탈 모델을 형성한다. 코드가 이 모델을 벗어나면 버그가 발생한다.
  • 더 위험한 것은, 이상한 동작이 즉시 드러나지 않고 시스템이 한동안 이상한 상태로 계속 동작하다가 나중에 치명적인 문제가 나타난다는 점이다.

챕터 3(코드 계약), 챕터 4(에러 처리), 챕터 6(구체적 기법)에서 다룬다.

기둥 3: 코드를 잘못 사용하기 어렵게 만들어라

비유: TV 뒷면의 소켓. 전원 케이블을 HDMI 포트에 꽂을 수 없도록 소켓 모양을 다르게 설계한다. 만약 모든 소켓이 같은 모양이라면 잘못 연결하는 사고가 빈발할 것이다.

코드도 마찬가지다. 잘못된 인자가 “꽂히면(plugged in)” 시스템 크래시, 데이터베이스 손상, 데이터 소실이 발생할 수 있다. 잘못 사용하기 어렵거나 불가능하게 만들면 코드가 동작하고 계속 동작할 가능성이 높아진다.

챕터 3(코드 계약), 챕터 7(구체적 기법)에서 다룬다.

기둥 4: 코드를 모듈화하라

모듈성(Modularity): 시스템이 독립적으로 교체 가능한 작은 컴포넌트로 구성된 성질.

비유: 관절로 분리된 로봇 장난감 vs. 실로 꿰매어진 인형 장난감.

구분 모듈화된 장난감 비모듈화된 장난감
손 교체 방법 새 손 제작 후 간단히 교체 실 20개 이상 잘라내고 새로 꿰매야 함
복원 방법 간단히 되돌림 손상 동반, 다시 수십 번 작업
인터페이스 단순 (핀 하나) 복잡 (수십 개의 실이 뒤엉킴)

소프트웨어도 동일하다. 잘 정의된 인터페이스로 상호작용하는 자기 완결적 모듈로 코드를 분리하면:

  • 요구사항 변경 시 한 곳만 수정하면 된다.
  • 코드를 이해하고 추론하기 쉬워진다.
  • 코드가 처음에 동작하고 계속 동작할 가능성이 높아진다.

챕터 2(추상화 레이어), 챕터 8(구체적 기법)에서 다룬다.

기둥 5: 코드를 재사용 가능하고 일반화 가능하게 만들어라

두 개념의 차이:

  • 재사용성(Reusability): 동일한 문제를 여러 시나리오에서 해결할 수 있음. 예: 전동 드릴로 벽, 바닥, 천장 모두에 구멍을 뚫을 수 있다.
  • 일반화 가능성(Generalizability): 유사하지만 미묘하게 다른 여러 문제를 해결할 수 있음. 예: 드릴로 구멍 뚫기뿐 아니라 나사 조이기도 할 수 있다.

핵심 관점: 우리는 코드를 작성하는 것에 대한 보상을 받는 게 아니라, 문제를 해결하는 것에 대한 보상을 받는다. 코드는 수단일 뿐이다. 코드베이스의 라인 수가 적을수록 좋다 — 코드가 많을수록 버그도 많아진다.

재사용 가능한 코드의 장점:

  • 시간과 노력 절약
  • 이미 실전 검증된 로직을 재사용하므로 신뢰도 향상
  • 모듈화와 시너지 효과

챕터 8(모듈성), 챕터 9(구체적 기법)에서 다룬다.

기둥 6: 코드를 테스트 가능하게 만들고 제대로 테스트하라

테스트는 소프트웨어 개발 프로세스에서 두 핵심 지점을 방어한다.

  1. 코드베이스에 버그가 있는 코드가 제출되는 것을 차단
  2. 릴리즈에 버그가 포함되어 운영환경에 배포되는 것을 차단

두 가지 개념의 구분

개념 설명
테스팅(Testing) 실제 코드를 검증하는 행위. 수동 또는 자동화.
테스트 가능성(Testability) 실제 코드(비테스트 코드)가 얼마나 쉽게 테스트될 수 있는가. 모듈성과 깊이 연관됨.

테스트의 세 가지 레벨

  • 단위 테스트(Unit Tests): 개별 함수나 클래스 같은 작은 단위를 테스트. 엔지니어가 일상 코딩에서 가장 자주 접하는 레벨. 이 책에서 집중적으로 다룬다.
  • 통합 테스트(Integration Tests): 여러 컴포넌트가 함께 올바르게 연동되는지 확인.
  • 종단간 테스트(E2E Tests): 전체 소프트웨어 시스템의 전형적인 사용자 워크플로우를 처음부터 끝까지 테스트. 예: 브라우저를 자동 제어하여 구매 완료 플로우 검증.

테스트 가능성과 모듈성의 관계

비모듈화된 코드 → 실차에 탑재해야만 비상 제동 시스템 테스트 가능
                   → 비용이 크고, 테스트 케이스가 한정됨

모듈화된 코드   → 독립 모듈로 분리된 제동 시스템
                   → 사전 녹화 영상을 입력해 수천 가지 시나리오 저비용 테스트 가능

중요: 테스트는 코딩의 사후 처리가 아니다. 코드를 작성하는 모든 단계에서 “이것을 어떻게 테스트할 것인가?“를 지속적으로 자문해야 한다.

TDD (Test-Driven Development): 테스트가 코딩의 핵심임을 강조하는 일부 엔지니어들은 코드를 작성하기 전에 테스트를 먼저 작성하는 TDD를 지지한다. 챕터 10에서 상세히 다룬다.

챕터 10, 11에서 테스팅을 집중적으로 다루나, 책 전반에 걸쳐 등장한다.


1.5 고품질 코드 작성이 개발 속도를 늦추는가?

결론부터 말하면: 단기적으로는 그럴 수 있지만, 중장기적으로는 오히려 빨라진다.

선반 비유

구분 올바른 방법 (브래킷 고정) 빠른 방법 (접착제 사용)
초기 시간 30분 10분
버그 수정 (수평 안 맞음) 5분 (스페이서 추가) 수 시간~수 일 (회반죽 보수)
요구사항 변경 (벽지 교체) 15분 (선반 분리 후 재부착) 선반 뜯어내면 회반죽 손상, 수 일 작업

접착제로 선반을 붙이는 빠른 방법은 초기엔 20분을 절약한 것처럼 보이지만, 이후에 발생하는 모든 문제가 훨씬 더 큰 시간과 비용을 요구한다.

코드에 적용하면

  • 품질을 고려하지 않고 처음 떠오르는 것을 코딩하면 초기엔 빠를 수 있다.
  • 하지만 코드베이스는 취약하고 복잡해지며, 이해하기 어려워진다.
  • 새 기능 추가나 버그 수정이 점점 느려지고, 한 곳을 고치면 다른 곳이 깨진다.
  • 빠른 해결책은 또 다른 빠른 해결책을 불러들이는 악순환을 만든다.

“Less haste, more speed” — 서두름과 속도를 혼동하지 마라. 제대로 하는 것이 결국 더 빠르다.


핵심 요약

  • 좋은 소프트웨어를 만들기 위해서는 고품질 코드가 필수다.
  • 코드가 운영 환경에 배포되기 전, 여러 단계의 검사와 테스트를 거친다.
  • 이 검사들은 버그가 있는 기능이 사용자나 비즈니스 크리티컬 시스템에 도달하는 것을 방지한다.
  • 코드 작성의 모든 단계에서 테스트를 고려해야 하며, 사후 처리로 생각해서는 안 된다.
  • 고품질 코드 작성은 단기적으로 느려 보일 수 있지만, 중장기적으로 개발 속도를 높인다.

코드 품질 4대 목표

  1. 코드는 동작해야 한다.
  2. 코드는 계속 동작해야 한다.
  3. 코드는 변화하는 요구사항에 적응할 수 있어야 한다.
  4. 코드는 바퀴를 재발명하지 말아야 한다.

코드 품질의 6대 기둥

  1. 가독성: 코드를 읽기 쉽게 만들어라.
  2. 예측 가능성: 놀라움을 피하라.
  3. 오용 방지: 코드를 잘못 사용하기 어렵게 만들어라.
  4. 모듈성: 코드를 모듈화하라.
  5. 재사용성: 코드를 재사용 가능하고 일반화 가능하게 만들어라.
  6. 테스트 가능성: 코드를 테스트 가능하게 만들고 제대로 테스트하라.

Chapter 2. 추상화 레이어

코드를 작성한다는 것은 문제를 해결하는 것이다. 고수준 문제는 항상 여러 하위 문제(subproblem)로 쪼개진다. 이 챕터의 핵심은 어떻게 코드를 구조화하여 그 분해를 잘 표현하는가이다.


2.1 Null과 의사코드 관례

  • 많은 언어에서 null은 값의 부재를 표현하는 수단이지만, 두 가지 상반된 특성을 가진다.

    • 유용함: 값이 없거나 결과를 제공할 수 없는 상황을 표현할 수 있다.
    • 위험함: 변수가 null일 수 있는지 명확하지 않고, null 체크를 빠뜨리면 런타임 에러가 발생한다.
  • Null Safety: 최근 언어들이 채택한 개념으로, null이 될 수 있는 변수/반환값을 타입 수준에서 명시하고, 컴파일러가 null 체크를 강제한다.

    • TypeScript에서는 strictNullChecks 옵션으로 활성화한다.
    • ? 접미사로 nullable 타입을 표현한다.
// null safety 적용 예시
function getFifthElement(elements: string[]): string | null {
  if (elements.length < 5) {
    return null; // 명시적으로 null 반환 가능
  }
  return elements[4];
}

// 사용 측에서 null 체크 강제
const result = getFifthElement(["a", "b"]);
if (result !== null) {
  console.log(result.toUpperCase()); // 안전하게 사용
}
  • null 대신 Optional 타입 패턴을 사용하는 것도 좋은 대안이다.

2.2 추상화 레이어를 만드는 이유

핵심 개념

  • 복잡한 문제를 다룰 때, 우리는 그것을 여러 하위 문제로 재귀적으로 쪼갠다.
  • 각 하위 문제의 해결책이 레이어(layer) 를 이룬다.
  • 상위 레이어는 하위 레이어의 내부 구현을 알 필요가 없다.

예시: 서버에 메시지 전송

const connection = HttpConnection.connect("http://example.com/server");
connection.send("Hello server");
connection.close();
  • 단 3줄, 4개의 개념(URL, 연결, 메시지 전송, 연결 종료)만으로 표현된다.
  • 실제로는 수십 가지 하위 문제(직렬화, HTTP 프로토콜, TCP 연결, 라디오 신호 변조 등)가 숨겨져 있다.
  • 각 레이어의 엔지니어는 바로 아래 레이어만 추상 개념으로 다루면 된다.

핵심 인사이트: 문제가 아무리 복잡해도, 올바르게 레이어를 설계하면 각 코드 단위는 몇 가지 단순한 개념만 다루면 된다.

2.2.1 추상화 레이어와 코드 품질의 기둥

추상화 레이어를 잘 설계하면 코드 품질의 4가지 기둥을 달성할 수 있다.

품질 기둥 추상화 레이어가 기여하는 방식
가독성(Readability) 한 번에 1~2개 레이어, 몇 가지 개념만 다루면 되므로 이해가 쉽다
모듈성(Modularity) 구현 세부사항이 레이어 간에 누출되지 않으므로, 한 레이어의 구현을 교체해도 다른 레이어에 영향 없다
재사용성/일반화(Reusability) 하위 문제의 해결책이 독립된 레이어로 존재하면, 다른 상황에서도 재사용하기 쉽다
테스트 가능성(Testability) 각 하위 문제의 해결책을 독립적으로 테스트할 수 있다

2.3 코드의 계층

  • 코드에서 추상화 레이어를 만드는 실질적인 수단은 코드를 서로 의존하는 단위로 나누는 것이다.
  • 주요 구성 단위:
    • 함수(Functions)
    • 클래스(Classes)
    • 인터페이스(Interfaces)
    • 패키지/네임스페이스/모듈 (이 책의 범위 밖)

2.3.1 API와 구현 세부사항

코드를 작성할 때 두 가지 측면을 항상 구분해야 한다.

공개 API (호출자가 보는 것)

  • public으로 노출된 클래스, 인터페이스, 함수
  • 이름, 입력 파라미터, 반환 타입에서 드러나는 개념
  • 올바른 사용을 위해 호출자가 알아야 하는 부가 정보

구현 세부사항 (호출자가 보지 못하는 것)

  • 함수 내부 코드
  • private 함수와 변수
  • 클래스가 의존하는 것들

핵심 원칙: 구현 세부사항이 API로 누출되면(입력 파라미터, 반환 타입, public 함수를 통해) 추상화 레이어가 깔끔하지 않다는 신호다.

2.3.2 함수

  • 함수 하나를 새로 만드는 것의 임계점(threshold)은 낮아도 된다.
  • 이상적인 함수: 짧고 명확한 하나의 문장으로 읽힐 수 있어야 한다.
  • 함수는 다음 둘 중 하나에 집중해야 한다.
    • 단일 작업(single task) 수행
    • 다른 잘 명명된 함수를 호출하여 복잡한 동작을 조합

나쁜 예시: 너무 많은 것을 하는 함수

function sendOwnerALetter(vehicle: Vehicle, letter: Letter): SentConfirmation | null {
  let ownersAddress: Address | null = null;

  if (vehicle.hasBeenScraped()) {
    ownersAddress = SCRAPYARD_ADDRESS;
  } else {
    const mostRecentPurchase = vehicle.getMostRecentPurchase();
    if (mostRecentPurchase === null) {
      ownersAddress = SHOWROOM_ADDRESS;
    } else {
      ownersAddress = mostRecentPurchase.getBuyersAddress();
    }
  }

  if (ownersAddress === null) {
    return null;
  }

  return sendLetter(ownersAddress, letter);
}
  • 이 함수를 문장으로 읽으면: “주소를 찾아라(차가 폐차됐으면 폐차장, 아직 안 팔렸으면 쇼룸, 구매자가 있으면 구매자 주소), 그리고 주소가 있으면 편지를 보내라.”
  • 너무 많은 개념을 한 번에 다루고, 깊은 중첩 if문이 생긴다.

좋은 예시: 함수를 역할별로 분리

function sendOwnerALetter(vehicle: Vehicle, letter: Letter): SentConfirmation | null {
  const ownersAddress = getOwnersAddress(vehicle); // 주소를 가져온다
  if (ownersAddress === null) {
    return null;
  }
  return sendLetter(ownersAddress, letter); // 편지를 보낸다
}

// 주소 탐색 로직을 별도 함수로 분리
function getOwnersAddress(vehicle: Vehicle): Address | null {
  if (vehicle.hasBeenScraped()) {
    return SCRAPYARD_ADDRESS;
  }
  const mostRecentPurchase = vehicle.getMostRecentPurchase();
  if (mostRecentPurchase === null) {
    return SHOWROOM_ADDRESS;
  }
  return mostRecentPurchase.getBuyersAddress();
}
  • sendOwnerALetter는 이제 명확한 두 단계만 담당: “주소 가져오기, 편지 보내기”
  • getOwnersAddress는 향후 다른 곳에서도 재사용 가능

2.3.3 클래스

클래스의 적절한 크기를 판단하는 기준들:

300줄 규칙

  • 300줄이 넘으면 경고 신호일 수 있지만, 300줄 이하라고 반드시 좋은 것은 아니다.
  • 단순한 경고 지표일 뿐, 보증은 아니다.

응집도(Cohesion)

  • 순차적 응집(Sequential Cohesion): 한 것의 출력이 다른 것의 입력으로 쓰인다. (원두 갈기 → 커피 내리기)
  • 기능적 응집(Functional Cohesion): 한 그룹의 것들이 단일 작업에 기여한다. (케이크 도구들을 한 서랍에 보관)

관심사 분리(Separation of Concerns)

  • 시스템을 각기 뚜렷한 문제(관심사)를 다루는 컴포넌트로 분리하는 설계 원칙.
  • 예: 게임기와 TV는 분리되어 있어 독립적으로 업그레이드 가능.

나쁜 예시: 너무 많은 것을 담은 클래스

class TextSummarizer {
  summarizeText(text: string): string {
    return this.splitIntoParagraphs(text)
      .filter(paragraph => this.calculateImportance(paragraph) >= IMPORTANCE_THRESHOLD)
      .join("\n\n");
  }

  private calculateImportance(paragraph: string): number {
    const nouns = this.extractImportantNouns(paragraph);
    const verbs = this.extractImportantVerbs(paragraph);
    const adjectives = this.extractImportantAdjectives(paragraph);
    // 복잡한 계산식 ...
    return importanceScore;
  }

  private extractImportantNouns(text: string): string[] { /* ... */ }
  private extractImportantVerbs(text: string): string[] { /* ... */ }
  private extractImportantAdjectives(text: string): string[] { /* ... */ }

  private splitIntoParagraphs(text: string): string[] {
    const paragraphs: string[] = [];
    let start = this.detectParagraphStartOffset(text, 0);
    while (start !== null) {
      const end = this.detectParagraphEndOffset(text, start);
      if (end === null) break;
      paragraphs.push(text.substring(start, end));
      start = this.detectParagraphStartOffset(text, end);
    }
    return paragraphs;
  }

  private detectParagraphStartOffset(text: string, fromOffset: number): number | null { /* ... */ }
  private detectParagraphEndOffset(text: string, fromOffset: number): number | null { /* ... */ }
}

이 클래스의 문제점:

  • 가독성 저하: 텍스트 단락 분리, 명사/동사/형용사 추출, 중요도 계산 등 개념이 뒤섞여 있다.
  • 모듈성 저하: 중요도 점수 계산 방식을 교체하려면 클래스 전체를 수정해야 한다.
  • 재사용성 저하: splitIntoParagraphs 같은 유용한 로직을 외부에서 쓰려면 public으로 열어야 하는데, 그러면 TextSummarizer의 API가 오염된다.
  • 일반화 저하: HTML 텍스트도 요약하려면 코드를 전면 수정해야 한다.
  • 테스트 어려움: summarizeText()를 통해서만 간접 테스트할 수 있어 세밀한 검증이 불가능하다.

좋은 예시: 개념별로 클래스 분리 (의존성 주입 패턴 활용)

class TextSummarizer {
  private readonly paragraphFinder: ParagraphFinder;
  private readonly importanceScorer: TextImportanceScorer;

  constructor(paragraphFinder: ParagraphFinder, importanceScorer: TextImportanceScorer) {
    this.paragraphFinder = paragraphFinder;
    this.importanceScorer = importanceScorer;
  }

  // 기본 인스턴스를 쉽게 생성하기 위한 정적 팩토리 함수
  static createDefault(): TextSummarizer {
    return new TextSummarizer(new ParagraphFinder(), new TextImportanceScorer());
  }

  summarizeText(text: string): string {
    return this.paragraphFinder.find(text)
      .filter(paragraph => this.importanceScorer.isImportant(paragraph))
      .join("\n\n");
  }
}

class ParagraphFinder {
  find(text: string): string[] {
    const paragraphs: string[] = [];
    let start = this.detectParagraphStartOffset(text, 0);
    while (start !== null) {
      const end = this.detectParagraphEndOffset(text, start);
      if (end === null) break;
      paragraphs.push(text.substring(start, end));
      start = this.detectParagraphStartOffset(text, end);
    }
    return paragraphs;
  }

  private detectParagraphStartOffset(text: string, fromOffset: number): number | null { /* ... */ }
  private detectParagraphEndOffset(text: string, fromOffset: number): number | null { /* ... */ }
}

class TextImportanceScorer {
  isImportant(text: string): boolean {
    return this.calculateImportance(text) >= IMPORTANCE_THRESHOLD;
  }

  private calculateImportance(text: string): number {
    const nouns = this.extractImportantNouns(text);
    const verbs = this.extractImportantVerbs(text);
    const adjectives = this.extractImportantAdjectives(text);
    // 복잡한 계산식 ...
    return importanceScore;
  }

  private extractImportantNouns(text: string): string[] { /* ... */ }
  private extractImportantVerbs(text: string): string[] { /* ... */ }
  private extractImportantAdjectives(text: string): string[] { /* ... */ }
}

개선 결과:

  • TextSummarizer를 읽으면 3단계(단락 찾기 → 중요도 필터 → 합치기)가 바로 파악된다.
  • ParagraphFinder는 다른 곳에서도 재사용 가능하다.
  • 각 클래스를 독립적으로 충분히 테스트할 수 있다.
  • 생성자 주입(dependency injection)으로 구현체를 유연하게 교체할 수 있다.

2.3.4 인터페이스

  • 인터페이스를 사용해야 하는 시점: 하나의 추상화 레이어에 대해 두 개 이상의 구현이 존재하거나, 향후 추가될 가능성이 있을 때.

예시: 텍스트 중요도 점수 계산을 인터페이스로 추상화

// 인터페이스 정의
interface TextImportanceScorerInterface {
  isImportant(text: string): boolean;
}

// 기존 단어 기반 구현
class WordBasedScorer implements TextImportanceScorerInterface {
  isImportant(text: string): boolean {
    return this.calculateImportance(text) >= IMPORTANCE_THRESHOLD;
  }

  private calculateImportance(text: string): number {
    const nouns = this.extractImportantNouns(text);
    const verbs = this.extractImportantVerbs(text);
    const adjectives = this.extractImportantAdjectives(text);
    // 복잡한 계산식 ...
    return importanceScore;
  }

  private extractImportantNouns(text: string): string[] { /* ... */ }
  private extractImportantVerbs(text: string): string[] { /* ... */ }
  private extractImportantAdjectives(text: string): string[] { /* ... */ }
}

// 새로운 머신러닝 기반 구현
class ModelBasedScorer implements TextImportanceScorerInterface {
  private readonly model: TextPredictionModel;

  constructor(model: TextPredictionModel) {
    this.model = model;
  }

  static create(): ModelBasedScorer {
    return new ModelBasedScorer(TextPredictionModel.load(MODEL_FILE));
  }

  isImportant(text: string): boolean {
    return this.model.predict(text) >= MODEL_THRESHOLD;
  }
}

// 팩토리 함수로 구현체를 선택
function createWordBasedSummarizer(): TextSummarizer {
  return new TextSummarizer(new ParagraphFinder(), new WordBasedScorer());
}

function createModelBasedSummarizer(): TextSummarizer {
  return new TextSummarizer(new ParagraphFinder(), ModelBasedScorer.create());
}

구현이 하나뿐일 때도 인터페이스를 쓸까?

장점 단점
공개 API가 명확해진다 코드량이 늘어난다
나중에 구현이 추가될 때 대비된다 로직 추적 시 인터페이스 → 구현체를 찾아야 하는 복잡성 증가
테스트 시 mock/fake로 교체하기 쉽다
하나의 클래스가 여러 인터페이스를 구현해 두 가지 역할 가능

저자의 의견: 모든 클래스를 무조건 인터페이스 뒤에 숨기는 극단적 접근은 불필요한 복잡성을 낳는다. 인터페이스는 실질적인 이점이 있을 때 사용하되, 어떤 클래스든 나중에 인터페이스 뒤에 숨기는 것이 사소한 작업이 되도록 설계는 유지해야 한다.

2.3.5 레이어가 너무 얇아질 때

레이어를 분리하는 데에도 비용이 있다.

  • 보일러플레이트 코드 증가
  • 로직 추적 시 파일/클래스 간 이동 빈도 증가
  • 인터페이스 사용 시 어느 구현체가 쓰이는지 파악이 어려워짐

너무 얇은 레이어의 예시

// ParagraphFinder의 내부 로직을 또 쪼갠 과도한 분리
interface OffsetDetector {
  detectOffset(text: string, fromOffset: number): number | null;
}

class ParagraphStartOffsetDetector implements OffsetDetector {
  detectOffset(text: string, fromOffset: number): number | null { /* ... */ }
}

class ParagraphEndOffsetDetector implements OffsetDetector {
  detectOffset(text: string, fromOffset: number): number | null { /* ... */ }
}

class ParagraphFinder {
  constructor(
    private readonly startDetector: OffsetDetector,
    private readonly endDetector: OffsetDetector
  ) {}

  find(text: string): string[] {
    // ...
  }
}
  • ParagraphStartOffsetDetectorParagraphEndOffsetDetectorParagraphFinder 외에 쓰일 곳이 없다.
  • 두 클래스는 서로 일관된 단락 탐지 방식을 공유해야 하므로, 분리해도 독립적으로 교체 가능하지 않다.
  • 이는 단일 레이어를 억지로 두 개로 쪼갠 것이다.

레이어 두께 결정 원칙

레이어가 너무 두꺼운 경우의 문제 > 레이어가 너무 얇은 경우의 문제
  • 너무 두꺼우면 모듈성/재사용성/가독성이 모두 깨진다.
  • 너무 얇으면 불필요한 복잡성이 생긴다.
  • 확신이 없다면 얇은 쪽으로 err on the side of 하는 것이 낫다.
  • 어떤 경우든 레이어가 코드를 가독성 있고, 재사용 가능하고, 일반화 가능하고, 모듈화되고, 테스트 가능하게 만드는지를 기준으로 판단한다.

2.4 마이크로서비스의 경우

  • 마이크로서비스 아키텍처에서는 각 서비스가 독립적으로 배포되는 레이어를 형성한다.
  • “마이크로서비스가 추상화 레이어를 제공하므로 내부 코드 구조는 중요하지 않다”는 주장은 틀렸다.

이유: 마이크로서비스 하나도 내부에서 여러 하위 문제를 해결해야 한다.

예시: 재고 관리 마이크로서비스가 해결해야 하는 하위 문제들:

  • 아이템(item) 개념 처리
  • 다양한 창고/위치 처리
  • 고객 위치에 따른 재고 가용성 계산
  • 데이터베이스 인터페이스
  • 데이터베이스 반환 데이터 해석

또한 다른 팀(예: 분석 팀)이 재고 DB를 직접 스캔하더라도, DB 반환값을 해석하는 로직은 재사용하고 싶을 수 있다. 이는 내부 코드가 잘 추상화되어 있어야 가능하다.

결론: 마이크로서비스는 시스템 수준의 모듈화를 제공하지만, 서비스 내부에서도 올바른 추상화 레이어를 설계하는 것은 여전히 중요하다.


## 핵심 요약

  • 코드를 깔끔하고 뚜렷한 추상화 레이어로 나누면 가독성, 모듈성, 재사용성, 일반화 가능성, 테스트 가능성이 모두 향상된다.
  • 함수, 클래스, 인터페이스를 활용해 추상화 레이어를 구현한다.
  • 레이어를 어떻게 나눌지는 문제에 대한 이해와 판단력이 필요하다. 단순 규칙만으로는 부족하다.
  • 레이어가 너무 두꺼울 때의 문제가 너무 얇을 때보다 보통 더 심각하다. 확신이 없다면 더 얇은 쪽을 선택하라.
  • 경험 많은 엔지니어도 올바른 추상화 설계에 여러 번의 반복(iteration) 이 필요하다.

Chapter 3. 다른 엔지니어와 코드 계약

코드 품질의 두 기둥인 “놀라움을 피하라”“오용하기 어렵게 만들어라” 는, 다른 엔지니어가 내 코드와 상호작용할 때 무슨 일이 벌어지는지와 직결된다.


3.1 내 코드와 다른 엔지니어의 코드

  • 내가 작성한 코드는 결코 혼자 존재하지 않는다. 아래로는 다른 엔지니어가 작성한 코드에 의존하고, 위로는 또 다른 엔지니어들이 내 코드에 의존한다.
  • 코드는 처음 의도한 용도 외에도 예상치 못한 방식으로 재사용된다.
  • 요구사항은 끊임없이 변하고, 코드베이스는 항상 유동적이다. 취약한 코드는 이 “사람의 발길”을 버티지 못한다.
graph TD
    A[ModelRunner] --> B[TextPredictionModel]
    B --> C[ParagraphFinder]
    B --> D[TextImportanceScorer]
    C --> E[TextSummarizer]
    D --> E
    E --> F[ArticleSummarizer]
    E --> G[CommentSummarizer]
    E --> H[ReadingTimeEstimator]

3.1.1 나에게 명백한 것은 타인에게 명백하지 않다

  • 코드를 작성할 때 나는 이미 그 문제를 수일간 고민한 상태다.
  • 다른 엔지니어는 그 컨텍스트가 없다. 내게 자명한 것이 그들에겐 전혀 자명하지 않다.
  • 따라서 코드는 어떻게 사용해야 하는지, 무엇을 하는지, 왜 그렇게 하는지 스스로 설명할 수 있어야 한다.
  • 이것이 반드시 주석을 많이 쓰는 것을 의미하지는 않는다. 더 좋은 방법이 존재한다.

3.1.2 다른 엔지니어는 의도치 않게 내 코드를 망가뜨리려 할 것이다

  • 내 코드는 끊임없이 변화하는 토대 위에 존재하며, 위아래로 계속 움직이는 부품들에 둘러싸여 있다.
  • 다른 엔지니어들은 내 코드의 존재 이유를 모르는 채로 수정을 가할 수 있다.
  • 이때 버그를 막는 신뢰할 수 있는 두 가지 방법:
    1. 코드가 컴파일되지 않도록 만들기
    2. 테스트가 실패하도록 만들기
  • 고품질 코드를 작성하는 많은 고민은 결국 이 두 가지 중 하나가 발생하도록 보장하는 것이다.

3.1.3 시간이 지나면 내 코드도 잊어버린다

  • 지금은 코드의 세부 사항이 생생하지만, 1~2년 뒤에는 타인이 쓴 코드를 보는 것과 다를 바 없다.
  • 미래의 나 자신도 “다른 엔지니어”로 간주하라.
  • 컨텍스트가 없는 사람도 이해할 수 있도록 코드를 작성하면, 동료뿐 아니라 미래의 자신에게도 친절한 것이다.

3.2 다른 사람은 어떻게 내 코드 사용법을 파악하는가?

다른 엔지니어가 내 코드를 사용하려면 다음을 이해해야 한다:

  • 어떤 상황에서 어떤 함수를 호출해야 하는가
  • 클래스는 무엇을 나타내며 언제 사용해야 하는가
  • 어떤 값으로 호출해야 하는가
  • 코드가 어떤 동작을 수행하는가
  • 어떤 값을 반환하는가

다른 엔지니어가 코드 사용법을 파악하는 방법들은 아래와 같으며, 현실적으로 신뢰할 수 있는 것은 상위 두 가지다.

방법 신뢰성 이유
이름(함수, 클래스, 열거형 등) 확인 높음 이름은 무시할 수 없고, 잘 지으면 즉각적인 이해를 제공
데이터 타입 확인 높음 타입이 틀리면 컴파일 자체가 안 됨
문서/주석 읽기 중간 안 읽거나, 오해하거나, 오래되어 부정확할 수 있음
직접 묻기 낮음 확장 불가, 휴가/퇴직 시 불가
구현 코드 직접 읽기 매우 낮음 추상화의 이점을 완전히 무너뜨림

3.2.1 이름 확인

  • 패키지, 클래스, 함수 이름은 책의 목차처럼 작동한다.
  • removeEntry()addEntry()는 이름만으로 혼동이 불가능하다.
  • 이름을 잘 짓는 것은 코드 계약을 전달하는 가장 좋은 방법 중 하나다.

3.2.2 데이터 타입 확인

  • 정적 타입 언어에서는 타입이 틀리면 컴파일이 아예 되지 않는다.
  • 타입 시스템을 활용해 코드 오용을 컴파일 타임에 차단하는 것이 가장 강력한 방법이다.

3.2.3 문서 읽기

문서는 다음 형태로 존재한다:

  • 비공식 함수/클래스 주석
  • 공식 인코드 문서(JSDoc 등)
  • 외부 문서(README.md 등)

하지만 신뢰성이 제한적인 이유:

  • 엔지니어들이 실제로 읽지 않는 경우가 많다.
  • 읽더라도 오해할 수 있다.
  • 코드가 수정될 때 문서가 함께 갱신되지 않아 오래된 정보가 될 수 있다.

3.2.4 직접 묻기

  • 코드가 쌓일수록 질문에 답하는 데 하루를 다 쓸 수 있다.
  • 휴가 중이거나 퇴직하면 영영 알 수 없게 된다. 확장 불가능한 방법이다.

3.2.5 구현 코드 직접 읽기

  • 가장 정확하지만, 절대 확장되지 않는다.
  • 의존성의 의존성까지 따라가다 보면 수십만 줄을 읽어야 한다.
  • 추상화 계층의 핵심 목적을 정면으로 위반한다.

3.3 코드 계약

프로그래밍 by Contract (Design by Contract): 코드 간의 상호작용을 계약처럼 바라보는 철학. 호출자는 특정 의무를 지키고, 호출된 코드는 그 대가로 기대하는 결과를 반환한다.

코드 계약의 세 가지 범주:

  • 사전 조건(Preconditions): 코드를 호출하기 전에 참이어야 하는 것 (입력값, 시스템 상태 등)
  • 사후 조건(Postconditions): 코드가 호출된 후에 참이어야 하는 것 (반환값, 시스템 상태 등)
  • 불변 조건(Invariants): 코드 호출 전후에 변하지 않아야 하는 것

의도적으로 계약 방식을 채택하지 않더라도, 입력 파라미터, 반환값, 상태 변경이 있는 모든 함수는 사실상 계약을 갖는다.

3.3.1 계약의 소 글씨

현실 세계의 계약처럼, 코드 계약에도 명백한 부분소 글씨(small print) 가 있다.

스쿠터 렌탈 앱 비유:

  • 명백한 부분: 전동 킥보드를 빌리고, 시간당 $10을 낸다.
  • 소 글씨: 30mph를 초과하면 $300 벌금. 속도 제한 장치는 없으므로 사용자가 직접 속도를 관리해야 한다.

코드에서의 명백한 부분 vs 소 글씨:

구분 내용
명백한 부분 함수/클래스 이름, 파라미터 타입, 반환 타입, checked 예외
소 글씨 주석/문서, unchecked 예외(언급조차 없을 수 있음)

소 글씨가 많은 코드의 나쁜 예:

class UserSettings {
  constructor() { /* ... */ }

  // 이 함수를 먼저 호출해야 함. 성공하면 true 반환.
  loadSettings(location: File): boolean { /* ... */ }

  // loadSettings() 성공 후에만 호출 가능.
  init(): void { /* ... */ }

  // null은 색상 미선택 OR 초기화 미완료를 의미함.
  getUiColor(): string | null { /* ... */ }
}

이 클래스의 계약을 분석하면:

  • 명백한 부분: UserSettings라는 이름, getUiColor()가 색상 또는 null을 반환함
  • 소 글씨: loadSettings() → 성공 시 init() → 그 이후에만 다른 함수 호출 가능. null이 두 가지 의미를 가짐(색상 미선택 OR 초기화 미완료)

이로 인한 잠재적 버그:

function setUiColor(userSettings: UserSettings): void {
  const chosenColor = userSettings.getUiColor();
  if (chosenColor === null) {
    // 버그: UserSettings가 초기화되지 않은 상태에서도
    // 조용히 기본 색상을 사용해버림. 크래시도 없고 경고도 없다.
    ui.setColor(DEFAULT_UI_COLOR);
    return;
  }
  ui.setColor(chosenColor);
}

소 글씨를 제거하는 방법 — 올바른 예:

class UserSettings {
  // 생성자를 private으로 강제하기 위해 외부 접근 차단
  private constructor() { /* ... */ }

  // 유일한 인스턴스 생성 방법. 항상 유효한 상태만 반환.
  static create(location: File): UserSettings | null {
    const settings = new UserSettings();
    if (!settings.loadSettings(location)) {
      return null; // 잘못된 상태의 인스턴스는 절대 외부로 나가지 않음
    }
    settings.init();
    return settings;
  }

  private loadSettings(location: File): boolean { /* ... */ }
  private init(): void { /* ... */ }

  // 이제 null은 오직 하나의 의미: 사용자가 색상을 선택하지 않음
  getUiColor(): string | null { /* ... */ }
}

개선 포인트 요약:

  • 생성자를 private으로 만들어 create() 강제 사용
  • loadSettings(), init()private으로 만들어 외부에서 호출 불가
  • getUiColor()null이 이제 오직 하나의 의미만 가짐
  • 잘못된 상태의 인스턴스를 외부에서 얻는 것 자체가 불가능해짐
graph TD
    A["UserSettings.create(file)"] --> B{null 반환?}
    B -- "예 (로드 실패)" --> C["null — 타입 시스템이 처리 강제"]
    B -- "아니오" --> D["항상 유효한 상태의 인스턴스"]
    E["new UserSettings() 직접 호출"] --> F["컴파일 에러 — 불가능"]

핵심 원칙: 스쿠터에 속도 제한 장치를 달아서 30mph 초과를 물리적으로 불가능하게 만드는 것처럼, 잘못된 사용 자체를 불가능하게 만드는 것이 소 글씨에 의존하는 것보다 훨씬 낫다.

3.3.2 소 글씨에 너무 많이 의존하지 마라

  • 소 글씨는 간과되기 쉽고, 오해받기 쉬우며, 시간이 지나면 부정확해진다.
  • 소 글씨가 불가피한 경우(다른 팀의 나쁜 코드에 의존해야 할 때 등)에는 문서를 명확히 작성해야 한다.
  • 그러나 문서를 쓰더라도 읽히지 않을 가능성이 높으므로, 가능하다면 언제나 명백한 계약으로 대체하는 것이 우선이다.
  • 상태(state)와 가변성(mutability)을 클래스 외부에 노출하지 않는 것이 소 글씨를 줄이는 핵심 기법이다.

3.4 검사와 단언

컴파일러를 통한 강제가 이상적이지만, 불가능한 경우도 있다. 이때는 런타임 강제가 아무것도 하지 않는 것보다 낫다.

비유: 스쿠터에 속도 제한 장치(컴파일 타임 강제)를 달 수 없다면, 30mph 초과 시 완전히 셧다운되는 안전장치(런타임 검사)라도 추가하는 것이 낫다. 불편하지만, $300 벌금보다는 낫다.

3.4.1 검사

  • 코드 계약의 준수 여부를 확인하는 추가 로직
  • 계약이 위반되면 즉시 명확하고 눈에 띄는 실패를 발생시킨다
  • 이를 빠른 실패(Fail Fast) 라고도 한다

검사의 분류:

  • 사전 조건 검사: 입력 인수, 초기화 여부, 시스템 상태 확인
  • 사후 조건 검사: 반환값, 코드 실행 후 시스템 상태 확인

검사를 사용한 예:

class UserSettings {
  private settingsLoaded = false;
  private initialized = false;

  loadSettings(location: File): boolean {
    // ...
    this.settingsLoaded = true;
    return true;
  }

  init(): void {
    if (!this.settingsLoaded) {
      throw new Error("StateException: Settings not loaded"); // 명확한 실패
    }
    this.initialized = true;
    // ...
  }

  getUiColor(): string | null {
    if (!this.initialized) {
      throw new Error("StateException: Settings not initialized"); // 명확한 실패
    }
    // ...
  }
}

검사의 한계:

  • 테스트가 특정 시나리오를 다루지 않으면 출시 후에야 발견될 수 있다.
  • 예외가 상위 레벨에서 catch되어 단순 로깅만 되는 경우, 아무도 눈치채지 못할 수 있다.
  • 검사가 많다는 것은 그 코드가 소 글씨 제거를 고려해야 한다는 신호일 수 있다.

3.4.2 단언

  • 검사와 개념적으로 동일하지만, 언어에 내장된 메커니즘을 사용
  • 개발/테스트 모드: 검사처럼 동작하여 조건 위반 시 에러/예외 발생
  • 릴리스 빌드: 일반적으로 컴파일에서 제외됨 (비활성화)

릴리스 빌드에서 비활성화하는 이유:

  • 성능 향상: 단언 조건 계산에 CPU 사이클이 소모됨
  • 가용성 우선: 일부 시스템에서는 약간의 버그 위험보다 다운타임 방지가 더 중요함

많은 개발팀은 릴리스 빌드에서도 단언을 활성화한다. 이 경우 단언과 검사의 차이는 실질적으로 발생하는 에러/예외의 종류뿐이다.

단언을 사용한 예:

class UserSettings {
  private initialized = false;

  getUiColor(): string | null {
    console.assert(this.initialized, "Settings not initialized"); // 단언
    // ...
  }
}

단언도 마찬가지: 소 글씨를 강제하는 방법이지, 소 글씨 자체를 없애는 방법이 아니다. 소 글씨를 아예 없애는 것이 항상 최선이다.


요약

  • 코드베이스는 항상 변화하며 여러 엔지니어가 동시에 수정한다.
  • 다른 엔지니어가 코드를 오용하거나 망가뜨릴 가능성을 항상 고려하고, 오용이 불가능하도록 설계하라.
  • 모든 코드는 암묵적으로 계약을 갖는다. 이 계약은 명백한 부분소 글씨로 나뉜다.
  • 소 글씨는 다른 엔지니어가 계약을 준수하게 만드는 신뢰할 수 없는 방법이다. 명백하게 만드는 것이 낫다.
  • 컴파일러를 통한 강제가 가장 신뢰할 수 있는 접근법이다.
  • 컴파일 타임 강제가 불가능하다면, 런타임 검사(checks) 또는 단언(assertions) 을 사용하라.
  • 검사/단언이 많다면, 그것은 소 글씨 제거를 고려해야 한다는 코드 냄새(code smell) 다.

Chapter 4. 에러

환경은 항상 불완전하다. 사용자는 잘못된 입력을 제공하고, 외부 시스템은 다운되며, 코드에는 버그가 존재한다. 에러는 필연적이며, 이를 어떻게 처리할지 체계적으로 사고하지 않으면 견고하고 신뢰할 수 있는 코드를 작성할 수 없다.


4.1 회복 가능성

에러를 생각할 때 가장 먼저 해야 할 구분: “이 에러로부터 시스템이 회복할 수 있는가?”

4.1.1 회복 가능한 에러

시스템이 우아하게 처리하고 계속 실행될 수 있는 에러들.

  • 잘못된 사용자 입력 — 유효하지 않은 전화번호 입력 시 앱을 크래시시키는 대신 에러 메시지 표시
  • 네트워크 에러 — 의존하는 서비스가 응답하지 않을 때 재시도하거나 사용자에게 안내
  • 비중요 작업 에러 — 사용량 통계 로깅 실패처럼 핵심 기능에 영향이 없는 경우

핵심 원칙: 시스템 외부에서 발생하는 에러는 대부분 회복을 시도해야 한다. 외부 시스템 장애, 파일 손상, 잘못된 입력은 충분히 예상할 수 있는 일이다.

4.1.2 회복 불가능한 에러

프로그래밍 오류(버그) 로 인해 발생하며, 현실적으로 회복할 방법이 없는 에러들.

  • 코드와 함께 번들되어야 할 리소스가 누락된 경우
  • 다른 코드를 잘못 사용한 경우 (유효하지 않은 인수로 호출, 필요한 상태 미초기화 등)

이 경우 코드가 할 수 있는 유일한 합리적 행동: 피해를 최소화하고 엔지니어가 문제를 빠르게 발견해 수정할 수 있도록 하는 것.

4.1.3 호출자만이 회복 가능 여부를 안다

같은 에러라도 호출 컨텍스트에 따라 회복 가능 여부가 달라진다.

function parsePhoneNumber(number: string): PhoneNumber {
  if (!isValidPhoneNumber(number)) {
    // 여기서 어떻게 처리해야 할까?
  }
  // ...
}
호출 컨텍스트 에러 성격
하드코딩된 값으로 호출 ("01234typo56789") 프로그래밍 오류 — 회복 불가
사용자 입력값으로 호출 예상 가능한 에러 — 회복 가능

parsePhoneNumber()의 작성자는 호출자가 이 에러로부터 회복하고 싶을 수 있다고 가정해야 한다.

아래 조건 중 하나라도 해당되면 호출자가 회복을 원할 수 있다고 보아야 한다:

  • 함수가 어디서 호출될지 완전히 알지 못하는 경우
  • 코드가 미래에 재사용될 가능성이 조금이라도 있는 경우

4.1.4 호출자가 에러를 인지하게 하라

호출자가 에러 발생 가능성을 모른다면, 처리 코드를 작성할 수 없다. 에러 발생 가능성을 호출자에게 명시적으로 알려주는 것이 중요하다.


4.2 Robustness vs. Failure

에러 발생 시 두 가지 선택지:

  1. 실패 — 상위 레이어에서 처리하게 하거나 프로그램 크래시
  2. 계속 실행 — 에러를 무시하고 진행

4.2.1 빠르게 실패하라

트러플 사냥 개 비유:

  • 좋은 개: 트러플을 발견한 즉시 짖는다 → 정확한 위치 파악 가능
  • 나쁜 개: 트러플을 발견하고 10m 이상 걸어가다 짖는다 → 위치 파악 어려움

코드도 마찬가지다. 에러가 실제 발생한 위치에서 최대한 가깝게 신호를 보내야 한다.

graph LR
    A[실제 에러 발생 위치] -->|빠르게 실패| B[에러 즉시 발생]
    A -->|느리게 실패| C[잘못된 데이터 전파] --> D[다른 함수로 전파] --> E[에러 발생]
    E -->|디버깅| A

빠르게 실패하지 않을 때의 문제:

  • 에러가 실제 위치에서 멀리 떨어진 곳에서 발생
  • 디버깅에 막대한 시간 소요
  • 손상된 데이터가 DB에 저장되는 등 돌이킬 수 없는 피해 발생 가능

4.2.2 크게 실패하라

회복 불가능한 에러 = 거의 항상 프로그래밍 버그. 엔지니어가 반드시 알아야 한다.

크게 실패하는 방법:

  • 예외(Exception)를 던져 프로그램 크래시
  • 에러 메시지 로깅 (하지만 묻힐 수 있음)
  • 클라이언트 기기라면 서버로 에러 리포트 전송

빠르게 + 크게 실패하면 → 개발/테스트 단계에서 버그 발견 가능성 극대화.

4.2.3 회복 가능 범위

회복 가능 여부는 범위(scope) 에 따라 다르다.

서버 예시:

  • 개별 요청 처리 중 버그 발생 → 해당 요청 범위에서는 회복 불가
  • 하지만 서버 전체를 크래시시킬 필요는 없음 → 서버 범위에서는 회복 가능
서버
├── 요청 1 처리 → 에러 발생 → 에러 로깅 + 에러 응답 반환
├── 요청 2 처리 → 정상
└── 모니터링: 에러율이 임계치 초과 시 팀에 알림

이 방식의 핵심: 에러를 숨기는 게 아니라, 로깅 + 모니터링으로 엔지니어가 반드시 인지하게 해야 한다.

주의: 모든 에러를 catch해서 로깅만 하는 패턴은 극소수의 높은 레벨(요청 진입점, 독립적인 로직 분기)에서만 적절하다.

4.2.4 에러를 숨기지 마라

에러 처리 코드를 피하고 싶어 에러를 숨기는 것은 거의 항상 나쁜 선택이다.

나쁜 패턴 1: 기본값 반환
function getAccountBalanceUsd(customerId: number): number {
  const result = accountStore.lookup(customerId);
  if (!result.success) {
    return 0; // 잔액 0과 에러를 구분할 수 없다!
  }
  return result.account.balanceUsd;
}

$10,000 잔액을 가진 고객이 $0으로 표시되는 상황 발생.

나쁜 패턴 2: 빈 컬렉션 반환
function getUnpaidInvoices(customerId: number): Invoice[] {
  const result = invoiceStore.query(customerId);
  if (!result.success) {
    return []; // 미납 청구서가 없는 것과 조회 실패를 구분할 수 없다!
  }
  return result.invoices.filter(inv => !inv.isPaid());
}

감사일에 InvoiceStore가 다운되면 미납 청구서가 없는 것으로 잘못 보고될 수 있다.

나쁜 패턴 3: 아무것도 하지 않음
class MutableInvoice {
  addItem(item: InvoiceItem): void {
    if (item.price.currency !== this.currency) {
      return; // 아이템이 추가되지 않았지만 호출자는 모른다
    }
    this.items.push(item);
  }
}
나쁜 패턴 4: 예외 억제
async function emailInvoice(emailAddress: string, invoice: Invoice): Promise<void> {
  try {
    await emailService.sendPlainText(emailAddress, formatInvoice(invoice));
  } catch (e) {
    // 아무것도 하지 않음 — 호출자는 이메일이 발송됐다고 믿는다
  }
}

로깅만 추가해도 조금 낫지만, 여전히 호출자에게 에러를 숨기는 것은 마찬가지.

에러를 숨기면: 호출자는 작업이 완료됐다고 가정 → 잘못된 정보 출력, 데이터 손상, 결국 크래시


4.3 에러 신호 방법

에러 신호 방식은 크게 두 범주로 나뉜다:

구분 명시적(Explicit) 묵시적(Implicit)
계약에서의 위치 명백한 핵심 부분 작은 글씨(문서) 또는 계약 외부
호출자 인지 여부 반드시 인지 인지하지 못할 수 있음
예시 체크 예외, nullable 반환 타입, Result 타입, Outcome 반환 타입 언체크 예외, magic value, Promise/Future

4.3.1 예외 복습

  • 에러 또는 예외 상황을 신호하는 언어 기능
  • 던져지면 콜 스택을 거슬러 올라가며 처리자를 찾거나, 못 찾으면 프로그램 종료
  • Java: 체크 예외 / 언체크 예외 구분 존재
  • 대부분의 언어(TypeScript 포함): 언체크 예외만 존재

4.3.2 명시적: 체크 예외

TypeScript는 체크 예외를 지원하지 않는다. 아래 예시는 개념 설명을 위한 유사 패턴이다.

컴파일러가 호출자에게 예외 처리를 강제하는 방식. Java에만 존재하지만, 그 개념(명시성) 은 다른 방식으로 구현 가능.

체크 예외의 본질: 함수 시그니처 자체가 “이 에러가 발생할 수 있다”를 드러냄 → 호출자가 처리하거나 전파를 명시해야 컴파일 통과.

4.3.3 묵시적: 언체크 예외

TypeScript의 모든 예외가 여기에 해당.

class NegativeNumberError extends Error {
  constructor(public readonly erroneousNumber: number) {
    super(`Cannot get square root of negative number: ${erroneousNumber}`);
    this.name = 'NegativeNumberError';
  }
}

function getSquareRoot(value: number): number {
  if (value < 0) {
    throw new NegativeNumberError(value); // 함수 시그니처에 드러나지 않음
  }
  return Math.sqrt(value);
}

호출자는 이 예외의 존재를 모를 수 있다. 문서를 읽지 않으면 처리 코드를 작성하지 않게 됨.

// 처리하는 경우
function displaySquareRoot(): void {
  const value = ui.getInputNumber();
  try {
    ui.setOutput(`Square root is: ${getSquareRoot(value)}`);
  } catch (e) {
    if (e instanceof NegativeNumberError) {
      ui.setError(`Can't get square root of: ${e.erroneousNumber}`);
    }
  }
}

// 처리하지 않는 경우 — 컴파일 오류 없음, 문제 없어 보임
function displaySquareRoot(): void {
  const value = ui.getInputNumber();
  ui.setOutput(`Square root is: ${getSquareRoot(value)}`); // NegativeNumberError 미처리
}

4.3.4 명시적: Nullable 반환 타입

TypeScript는 타입 시스템으로 null 안전성을 강제할 수 있다 (strictNullChecks 옵션).

// null을 반환해 에러를 신호 — 왜 null인지는 주석으로 설명 필요
function getSquareRoot(value: number): number | null {
  if (value < 0) {
    return null;
  }
  return Math.sqrt(value);
}

// 호출자는 null 체크를 강제받음
function displaySquareRoot(): void {
  const squareRoot = getSquareRoot(ui.getInputNumber());
  if (squareRoot === null) {
    ui.setError("Can't get square root of a negative number");
  } else {
    ui.setOutput(`Square root is: ${squareRoot}`);
  }
}

단점: null만으로는 “왜” 에러가 발생했는지 알 수 없다.

4.3.5 명시적: Result 반환 타입

에러 정보를 함께 전달하고 싶을 때 사용. Rust, Swift, F#에 내장되어 있으며, TypeScript에서는 직접 구현해야 한다.

class Result<V, E> {
  private constructor(
    private readonly _value: V | undefined,
    private readonly _error: E | undefined,
    private readonly _hasError: boolean,
  ) {}

  static ofValue<V, E>(value: V): Result<V, E> {
    return new Result<V, E>(value, undefined, false);
  }

  static ofError<V, E>(error: E): Result<V, E> {
    return new Result<V, E>(undefined, error, true);
  }

  get hasError(): boolean {
    return this._hasError;
  }

  getValue(): V {
    if (this._hasError) throw new Error('Result has no value');
    return this._value as V;
  }

  getError(): E {
    if (!this._hasError) throw new Error('Result has no error');
    return this._error as E;
  }
}
class NegativeNumberError extends Error {
  constructor(public readonly erroneousNumber: number) {
    super();
    this.name = 'NegativeNumberError';
  }
}

function getSquareRoot(value: number): Result<number, NegativeNumberError> {
  if (value < 0) {
    return Result.ofError(new NegativeNumberError(value));
  }
  return Result.ofValue(Math.sqrt(value));
}

// 호출자: 반환 타입 자체가 에러 가능성을 드러냄
function displaySquareRoot(): void {
  const result = getSquareRoot(ui.getInputNumber());
  if (result.hasError) {
    ui.setError(`Can't get square root of: ${result.getError().erroneousNumber}`);
  } else {
    ui.setOutput(`Square root is: ${result.getValue()}`);
  }
}

4.3.6 명시적: Outcome 반환 타입

값을 반환하지 않고 어떤 작업을 수행하는 함수에서 성공/실패를 알릴 때.

function sendMessage(channel: Channel, message: string): boolean {
  if (channel.isOpen()) {
    channel.send(message);
    return true;
  }
  return false;
}

// 호출자
function sayHello(channel: Channel): void {
  if (sendMessage(channel, 'hello')) {
    ui.setOutput('Hello sent');
  } else {
    ui.setError('Unable to send hello');
  }
}

문제점: 호출자가 반환값을 무시할 수 있다.

function sayHello(channel: Channel): void {
  sendMessage(channel, 'hello'); // 반환값 무시 — 컴파일 오류 없음
  ui.setOutput('Hello sent');    // 실제로 실패했어도 성공 메시지 표시
}

TypeScript/ESLint에서는 @typescript-eslint/no-floating-promises 등의 린트 규칙이나, 반환값을 무시하지 못하도록 강제하는 패턴으로 보완 가능.

4.3.7 묵시적: Promise / Future

비동기 코드에서 흔히 사용되는 방식. TypeScript에서 가장 일반적.

class NegativeNumberError extends Error {
  constructor(public readonly erroneousNumber: number) {
    super();
    this.name = 'NegativeNumberError';
  }
}

async function getSquareRoot(value: number): Promise<number> {
  await new Promise(resolve => setTimeout(resolve, 1000));
  if (value < 0) {
    throw new NegativeNumberError(value); // 거부(rejected) Promise가 됨
  }
  return Math.sqrt(value);
}

// 호출자가 catch를 달지 않으면 에러가 완전히 무시될 수 있음
async function displaySquareRoot(): Promise<void> {
  getSquareRoot(ui.getInputNumber())
    .then(root => ui.setOutput(`Square root is: ${root}`))
    .catch(err => ui.setError(`Error: ${err.message}`));
}

왜 묵시적인가: catch()를 달지 않아도 컴파일 에러가 없다. 호출자는 에러 가능성을 모를 수 있다.

명시적으로 만들려면: Promise<Result<V, E>>를 반환 타입으로 사용.

async function getSquareRoot(value: number): Promise<Result<number, NegativeNumberError>> {
  await new Promise(resolve => setTimeout(resolve, 1000));
  if (value < 0) {
    return Result.ofError(new NegativeNumberError(value));
  }
  return Result.ofValue(Math.sqrt(value));
}

단, 코드가 다소 번거로워지는 트레이드오프가 있다.

4.3.8 묵시적: 매직 값 반환

정상 반환 타입에 속하지만 특수한 의미를 가진 값(-1, "", 0 등)을 에러 신호로 사용.

// -1을 에러 신호로 사용 — 호출자가 알려면 문서를 읽어야 함
function getSquareRoot(value: number): number {
  if (value < 0) {
    return -1; // 주석이 없으면 의미를 알 수 없다
  }
  return Math.sqrt(value);
}

Magic Value는 거의 항상 나쁜 선택이다. 빠르게/크게 실패하는 원칙을 위반하고 나중에 이상한 방식으로 에러가 드러나게 만든다.


4.4 회복 불가능한 에러 신호하기

회복 불가능한 에러 = 프로그래밍 오류. 빠르게, 크게 실패해야 한다.

적합한 방법:

  • 언체크 예외 던지기 (throw new Error(...))
  • assert나 사전 조건 검사 (Chapter 3 참조)
  • panic (지원하는 언어에서)

이런 묵시적 기법을 쓰는 이유: 호출 체인의 모든 레이어가 처리 코드를 작성할 필요가 없음. 회복할 방법이 없으니 상위 레이어가 알아서 처리하게 두면 된다.


4.5 회복 가능한 에러 신호하기

가장 논쟁적인 부분. 언체크 예외 vs. 명시적 기법 사이의 오랜 논쟁.

무엇보다 중요한 것: 팀 전체가 한 가지 철학에 동의하는 것. 절반은 언체크 예외를, 절반은 명시적 기법을 쓰면 혼란이 가중된다.

4.5.1 언체크 예외 지지 논거

1. 코드 구조 개선

에러 처리를 별도의 상위 레이어에 집중시킬 수 있다. 중간 레이어는 에러 처리 코드로 오염되지 않음.

에러 처리 레이어
├── 네트워크 에러 → 사용자에게 에러 메시지 표시
└── 파싱 에러 → 에러 로깅
  레이어 1
  레이어 2
  레이어 3 (실제 에러 발생)

2. 엔지니어의 현실적 행동 고려

명시적 기법(체크 예외, Result 타입 등)을 강제하면, 처리 코드 작성이 귀찮아진 엔지니어가 최악의 선택을 할 수 있다.

// DiskDataStore로 교체 후 에러 처리가 필요해졌을 때
// 귀찮아서 에러를 숨겨버리는 엔지니어
class DataLogger {
  logDataPoint(time: Date, value: number): void {
    try {
      this.diskStore.store({ time: time.getTime(), value });
    } catch (e) {} // 최악의 선택 — 에러를 완전히 숨김
  }
}

4.5.2 명시적 기법 지지 논거

1. 우아한 에러 처리 가능

언체크 예외는 상위 레이어에서 일괄 처리되기 쉬운데, 이 경우 사용자 입력 필드 옆에 표시해야 할 에러가 전역 에러 메시지로 뭉뚱그려질 수 있다.

2. 에러를 실수로 무시하기 어렵다

명시적 기법에서 에러를 무시하려면 의도적이고 눈에 띄는 위반 을 해야 한다.

// 언체크 예외 사용 시 — 에러 미처리가 눈에 띄지 않음
class TemperatureLogger {
  logCurrentTemperature(): void {
    // 이 코드는 완전히 정상처럼 보임
    this.dataLogger.logDataPoint(new Date(), this.thermometer.getTemperature());
  }
}

// Result 타입 사용 시 — 에러 무시가 눈에 띔
class TemperatureLogger {
  logCurrentTemperature(): void {
    const result = this.dataLogger.logDataPoint(new Date(), this.thermometer.getTemperature());
    // result를 사용하지 않으면 lint 경고, 또는 코드 리뷰에서 발각
  }
}

3. 엔지니어의 현실적 행동 (역논거)

언체크 예외는 문서화가 제대로 되지 않는 경우가 많다. 어떤 예외가 throw될지 몰라 “whack-a-mole” 현상이 발생한다.

function isDataFileValid(fileContents: Buffer): boolean {
  try {
    DataFile.parse(fileContents);
    return true;
  } catch (e) {
    if (e instanceof InvalidEncodingError || e instanceof ParseError) {
      return false;
    }
    throw e; // 또 다른 예외가 있다면?
  }
}

결국 지쳐서 모든 예외를 catch하게 되는 최악의 상황 발생:

function isDataFileValid(fileContents: Buffer): boolean {
  try {
    DataFile.parse(fileContents);
    return true;
  } catch (e) {
    return false; // 프로그래밍 오류까지 숨겨버림
  }
}

4.5.3 저자의 의견: 명시적 기법을 사용하라

회복 가능한 에러에는 명시적 에러 신호 기법 을 사용하는 것이 낫다.

이유: 실무에서 언체크 예외는 코드베이스 전체에 제대로 문서화되지 않는 경우가 너무 많다. 엔지니어는 어떤 에러 시나리오가 발생할 수 있는지 확신할 수 없고, 이로 인한 버그와 장애가 빈번하게 발생한다.

가장 나쁜 상황: 팀 절반은 언체크 예외, 절반은 명시적 기법을 사용하는 것. 철학을 통일하는 것이 무엇보다 중요하다.


4.6 컴파일러 경고를 무시하지 마라

컴파일러 경고는 버그의 조기 신호 일 수 있다. 무시하면 심각한 버그를 놓칠 수 있다.

class UserInfo {
  private readonly realName: string;
  private readonly displayName: string; // 경고: 할당 후 한 번도 읽히지 않음

  constructor(realName: string, displayName: string) {
    this.realName = realName;
    this.displayName = displayName;
  }

  getRealName(): string {
    return this.realName;
  }

  getDisplayName(): string {
    return this.realName; // 버그: displayName이 아닌 realName을 반환
  }
}

TypeScript + ESLint 에서는 @typescript-eslint/no-unused-vars 등의 규칙이 이런 경고를 발생시킨다.

좋은 실천:

  • 컴파일러/린터 경고를 에러로 처리하도록 설정 (tsconfig.jsonstrict: true 등)
  • 불가피하게 경고를 억제해야 한다면, 이유를 주석으로 명확히 설명
class UserInfo {
  private readonly realName: string;

  // displayName은 현재 마이그레이션(issue #7462) 진행 중
  // 마이그레이션 완료 후 실제 사용 예정인 플레이스홀더
  // eslint-disable-next-line @typescript-eslint/no-unused-vars
  private readonly displayName: string;

  // ...
}

원칙: 빌드 결과에 경고가 없어야 한다. 모든 문제는 수정되었거나, 타당한 이유와 함께 명시적으로 억제되어야 한다.


## 핵심 요약

주제 핵심 원칙
에러 분류 회복 가능 vs. 회복 불가능으로 구분
회복 가능 여부 대부분 호출자만 알 수 있다
실패 방식 빠르게 실패(Fail Fast) + 크게 실패(Fail Loud)
에러 숨기기 기본값 반환, 빈 컬렉션, 아무것도 안 함, 예외 억제 모두 지양
신호 방식 명시적(체크 예외, Nullable, Result, Outcome) vs. 묵시적(언체크 예외, magic value, Promise)
회복 불가 에러 묵시적 기법(언체크 예외 등)으로 빠르게, 크게 실패
회복 가능 에러 명시적 기법 권장 (저자 의견)
컴파일러 경고 반드시 주의하고 처리하라

Chapter 5. 가독성 높은 코드 작성

가독성은 본질적으로 주관적이지만, 핵심은 단순하다. 다른 엔지니어가 코드를 빠르고 정확하게 이해할 수 있도록 만드는 것. 이를 위해서는 공감 능력이 필요하다. 내가 아닌 다른 사람의 시각에서 무엇이 혼란스럽거나 오해를 일으킬 수 있는지를 상상해야 한다.


5.1 설명적인 이름 사용

이름은 단순히 식별을 위한 것이 아니다. 이름 자체가 그것이 무엇인지, 무엇을 하는지를 설명해야 한다.

나쁜 예: 설명이 없는 이름

class T {
  pns: Set<string> = new Set();
  s: number = 0;

  f(n: string): boolean {
    return this.pns.has(n);
  }

  getS(): number {
    return this.s;
  }
}

function s(ts: T[], n: string): number | null {
  for (const t of ts) {
    if (t.f(n)) {
      return t.getS();
    }
  }
  return null;
}

20~30초 읽어도 이 코드가 무엇을 하는지 전혀 알 수 없다.

주석은 나쁜 이름의 대안이 될 수 없다

주석을 붙여 보완하면 다음 문제가 생긴다.

  • 코드와 주석 두 가지를 동시에 유지보수해야 한다.
  • 코드 여기저기를 왔다갔다 스크롤해야 이해가 된다.
  • t.f(n) 같은 함수 호출은 클래스 정의를 직접 보기 전까지는 여전히 불투명하다.

좋은 예: 설명적인 이름

class Team {
  playerNames: Set<string> = new Set();
  score: number = 0;

  containsPlayer(playerName: string): boolean {
    return this.playerNames.has(playerName);
  }

  getScore(): number {
    return this.score;
  }
}

function getTeamScoreForPlayer(teams: Team[], playerName: string): number | null {
  for (const team of teams) {
    if (team.containsPlayer(playerName)) {
      return team.getScore();
    }
  }
  return null;
}
  • 변수, 함수, 클래스가 이제 스스로 설명한다.
  • team.containsPlayer(playerName) 같은 호출은 클래스 코드를 보지 않아도 즉시 의미가 파악된다.
  • 주석 없이도 코드가 깔끔하게 유지된다.

핵심 원칙: 이름을 잘 짓는 것이 주석보다 우선이다. 주석은 보조 수단이지 나쁜 이름의 대체재가 아니다.


5.2 적절한 주석 사용

주석의 역할은 크게 두 가지다.

  1. 코드가 무엇을 하는지 설명
  2. 코드가 그렇게 동작하는지 설명

불필요한 주석은 오히려 해롭다

function generateId(firstName: string, lastName: string): string {
  // "{first name}.{last name}" 형태로 ID를 생성한다.
  return firstName + "." + lastName;
}

이 주석은 코드 자체가 이미 설명하고 있으므로 **중복(redundant)**이다. 문제점은 다음과 같다.

  • 코드를 수정하면 주석도 같이 수정해야 해서 유지보수 비용이 늘어난다.
  • 모든 줄에 이런 주석이 붙으면, 읽어야 할 양이 두 배가 된다.

주석은 읽기 어려운 코드의 대안이 아니다

// 나쁜 예: 주석으로 불명확한 코드를 설명
function generateId(data: string[]): string {
  // data[0]은 first name, data[1]은 last name
  return data[0] + "." + data[1];
}

// 좋은 예: 코드 자체를 명확하게
function generateId(data: string[]): string {
  return firstName(data) + "." + lastName(data);
}

function firstName(data: string[]): string {
  return data[0];
}

function lastName(data: string[]): string {
  return data[1];
}

코드를 명확하게 만드는 것이 항상 주석보다 우선이다. 코드 자체가 설명이 되면 유지보수 부담도 줄고 주석이 낡아서 잘못된 정보를 주는 위험도 없다.

주석이 빛을 발하는 경우: “왜”를 설명할 때

코드만으로는 설명할 수 없는 맥락과 이유를 전달할 때 주석은 매우 유용하다. 예를 들면 다음과 같다.

  • 비즈니스/제품 의사결정
  • 비직관적인 버그 수정
  • 외부 라이브러리의 특이한 동작 대응
class User {
  private username: number;
  private firstName: string;
  private lastName: string;
  private signupVersion: Version;

  getUserId(): string {
    if (this.signupVersion.isOlderThan("2.0")) {
      // v2.0 이전에 가입한 레거시 사용자는 이름 기반 ID를 사용한다.
      // 자세한 내용은 이슈 #4218 참고.
      return `${this.firstName.toLowerCase()}.${this.lastName.toLowerCase()}`;
    }
    // v2.0 이후 사용자는 username 기반 ID를 사용한다.
    return String(this.username);
  }
}

이런 주석이 없다면 다른 엔지니어가 이 분기가 왜 존재하는지 이해하지 못한 채 잘못 수정할 수 있다.

고수준 요약 주석은 유용하다

책 챕터 서두의 요약처럼, 클래스 단위의 고수준 설명은 엔지니어가 해당 코드가 자신에게 필요한 것인지를 빠르게 파악하는 데 도움을 준다.

/**
 * 스트리밍 서비스의 사용자 정보를 캡슐화한다.
 *
 * 이 클래스는 DB에 직접 접근하지 않고 메모리에 저장된 값으로 구성된다.
 * 따라서 클래스 생성 이후 DB에 변경이 생기면 동기화가 맞지 않을 수 있다.
 */
class User {
  // ...
}

핵심 원칙:

  • 줄 단위의 “무엇” 설명 주석 → 대부분 불필요, 코드를 더 잘 짜는 것이 우선
  • “왜” 설명 주석 → 맥락 전달에 매우 유용
  • 클래스/모듈 단위 고수준 요약 → 유용

5.3 코드 줄 수에 집착하지 마라

코드 줄 수가 적을수록 좋다는 것은 일반적인 원칙이지만, 절대적인 규칙이 아니다. 줄 수는 우리가 실제로 원하는 것을 측정하는 **대리 지표(proxy)**일 뿐이다.

우리가 실제로 원하는 것은 다음과 같다.

  • 이해하기 쉬운 코드
  • 오해하기 어려운 코드
  • 실수로 망가뜨리기 어려운 코드

한 줄의 이해하기 어려운 코드20줄의 이해하기 쉬운 코드보다 코드 품질을 더 떨어뜨릴 수 있다.

나쁜 예: 간결하지만 읽을 수 없는 코드

function isIdValid(id: number): boolean {
  return countSetBits(id & 0x7FFF) % 2 === ((id & 0x8000) >> 15);
}

이 코드는 패리티 비트(parity bit)를 검사하는 코드인데, 다음 가정들이 모두 숨어있다.

  • 하위 15비트 = 실제 값
  • 최상위 비트 = 패리티 비트
  • 0x7FFF = 하위 15비트 마스크
  • 0x8000 = 최상위 비트 마스크
  • 패리티 비트: 짝수 개 비트 세팅 시 0, 홀수 개 비트 세팅 시 1

이 가정들이 모두 한 줄에 압축되어 있어서, 이해하는 데 많은 시간이 걸리고 수정 시 쉽게 망가진다.

좋은 예: 더 길지만 읽을 수 있는 코드

const PARITY_BIT_INDEX = 15;
const PARITY_BIT_MASK = (1 << PARITY_BIT_INDEX);
const VALUE_BIT_MASK = ~PARITY_BIT_MASK & 0xFFFF;

function isIdValid(id: number): boolean {
  return extractEncodedParity(id) === calculateParity(getIdValue(id));
}

function getIdValue(id: number): number {
  return id & VALUE_BIT_MASK;
}

function extractEncodedParity(id: number): number {
  return (id & PARITY_BIT_MASK) >> PARITY_BIT_INDEX;
}

// 패리티는 비트 세팅 개수가 짝수면 0, 홀수면 1
function calculateParity(value: number): number {
  return countSetBits(value) % 2;
}

코드 줄 수가 늘어났지만, 훨씬 이해하기 쉽고, 서브 문제들이 재사용 가능한 함수로 분리되어 있다.

핵심 원칙: 줄 수를 줄이는 것보다 코드를 이해하기 쉽게 만드는 것이 더 중요하다.


5.4 일관된 코딩 스타일 준수

언어 문법(컴파일러)이 허용하는 것과 별개로, 엔지니어는 많은 스타일 선택권을 갖는다. 일관된 스타일은 오해와 버그를 줄인다.

나쁜 예: 일관되지 않은 스타일

class GroupChat {
  end(): void {
    connectionManager.terminateAll(); // connectionManager가 인스턴스 변수인가, 클래스인가?
  }
}

// 알고 보니 클래스였다! 관례상 클래스는 PascalCase여야 하는데...
class connectionManager {
  static terminateAll(): void {
    // 서버의 모든 채팅 연결을 종료해 버린다!
  }
}

connectionManager가 소문자로 시작하기 때문에 인스턴스 변수로 착각하기 쉽다. 이 버그는 치명적일 수 있다.

좋은 예: 일관된 스타일

class GroupChat {
  end(): void {
    ConnectionManager.terminateAll(); // PascalCase → 즉시 클래스임을 알 수 있다
  }
}

PascalCase로 고쳤을 뿐인데, 이것이 클래스이고 전역 상태에 영향을 준다는 사실이 즉시 명확해진다.

스타일 가이드와 린터

  • 대부분의 팀/조직에는 이미 스타일 가이드가 있다. 이를 익히고 따르면 된다.
  • 없다면 Google Style Guide 같은 공개 가이드를 채택할 수 있다.
  • 린터(Linter): 스타일 위반 및 잠재적 오류를 자동으로 감지하는 도구. TypeScript에서는 ESLint가 대표적이다.
  • 린터는 단순한 문제만 잡아주므로 좋은 코드 작성을 대체할 수는 없다.

핵심 원칙: 팀 전체가 같은 스타일을 따르면 서로 같은 언어를 유창하게 구사하는 것과 같다. 오해의 여지가 줄고 버그도 줄어든다.


5.5 깊은 중첩 회피

코드는 블록 안에 블록이 중첩되는 구조를 갖는다. 중첩이 깊어질수록 읽기 어려워진다. 사람의 눈은 각 코드 줄의 중첩 깊이를 추적하는 데 능숙하지 않기 때문이다.

나쁜 예: 깊게 중첩된 코드

function getOwnersAddress(vehicle: Vehicle): Address | null {
  if (vehicle.hasBeenScraped()) {
    return SCRAPYARD_ADDRESS;
  } else {
    const mostRecentPurchase = vehicle.getMostRecentPurchase();
    if (mostRecentPurchase === null) {
      return SHOWROOM_ADDRESS;
    } else {
      const buyer = mostRecentPurchase.getBuyer();
      if (buyer !== null) {
        return buyer.getAddress();
      }
    }
  }
  return null; // 이 줄에 언제 도달하는지 파악하기 어렵다
}

해결책 1: 중첩을 최소화하도록 재구성

모든 분기가 return으로 끝날 때, 조기 반환(early return)을 활용하면 중첩을 제거할 수 있다.

function getOwnersAddress(vehicle: Vehicle): Address | null {
  if (vehicle.hasBeenScraped()) {
    return SCRAPYARD_ADDRESS;
  }

  const mostRecentPurchase = vehicle.getMostRecentPurchase();
  if (mostRecentPurchase === null) {
    return SHOWROOM_ADDRESS;
  }

  const buyer = mostRecentPurchase.getBuyer();
  if (buyer !== null) {
    return buyer.getAddress();
  }

  return null;
}

나쁜 예: 함수가 너무 많은 일을 한다

return으로 조기 탈출이 어려운 경우는 대부분 함수가 너무 많은 일을 하기 때문이다.

function sendOwnerALetter(vehicle: Vehicle, letter: Letter): SentConfirmation | null {
  let ownersAddress: Address | null = null;

  if (vehicle.hasBeenScraped()) {
    ownersAddress = SCRAPYARD_ADDRESS;
  } else {
    const mostRecentPurchase = vehicle.getMostRecentPurchase();
    if (mostRecentPurchase === null) {
      ownersAddress = SHOWROOM_ADDRESS;
    } else {
      const buyer = mostRecentPurchase.getBuyer();
      if (buyer !== null) {
        ownersAddress = buyer.getAddress();
      }
    }
  }

  if (ownersAddress === null) {
    return null;
  }

  return sendLetter(ownersAddress, letter);
}

주소를 찾는 로직과 편지를 보내는 로직이 뒤섞여 있어서 조기 반환을 쓸 수 없다.

해결책 2: 더 작은 함수로 분리

function sendOwnerALetter(vehicle: Vehicle, letter: Letter): SentConfirmation | null {
  const ownersAddress = getOwnersAddress(vehicle);
  if (ownersAddress !== null) {
    return sendLetter(ownersAddress, letter);
  }
  return null;
}

function getOwnersAddress(vehicle: Vehicle): Address | null {
  if (vehicle.hasBeenScraped()) {
    return SCRAPYARD_ADDRESS;
  }

  const mostRecentPurchase = vehicle.getMostRecentPurchase();
  if (mostRecentPurchase === null) {
    return SHOWROOM_ADDRESS;
  }

  const buyer = mostRecentPurchase.getBuyer();
  if (buyer === null) {
    return null;
  }

  return buyer.getAddress();
}

핵심 원칙:

  • 깊은 중첩은 대개 함수가 너무 많은 일을 한다는 신호다.
  • 조기 반환(early return) 패턴으로 중첩을 줄인다.
  • 함수를 작게 쪼개는 것이 중첩 제거의 선결 조건이 되는 경우가 많다.

5.6 함수 호출 가독성 높이기

함수 이름이 명확해도, 호출 부분에서 인자(argument)가 무엇을 의미하는지 불분명하면 가독성이 떨어진다.

나쁜 예: 인자의 의미를 알 수 없는 함수 호출

sendMessage("hello", 1, true);
// 1이 뭐고 true가 무엇인지 함수 정의를 직접 찾아봐야 한다.

해결책 1: 객체 구조 분해를 활용한 명명 인자 흉내내기

TypeScript에서는 객체를 매개변수로 받아 구조 분해함으로써 명명 인자와 유사한 효과를 낼 수 있다.

interface SendMessageParams {
  message: string;
  priority: number;
  allowRetry: boolean;
}

async function sendMessage({ message, priority, allowRetry }: SendMessageParams): Promise<void> {
  const outcome = await XhrWrapper.send(END_POINT, message, priority);
  if (outcome.failed() && allowRetry) {
    // ...
  }
}

// 호출 시 각 인자의 의미가 명확하다
sendMessage({
  message: 'hello',
  priority: 1,
  allowRetry: true,
});

이 패턴은 TypeScript/JavaScript에서 매우 일반적이므로, 다른 엔지니어들도 익숙하게 받아들인다.

해결책 2: 설명적인 타입 사용

정수나 불리언 대신 의미를 담은 타입(클래스, 열거형)을 사용한다.

class MessagePriority {
  constructor(readonly value: number) {}
}

enum RetryPolicy {
  ALLOW_RETRY = "ALLOW_RETRY",
  DISALLOW_RETRY = "DISALLOW_RETRY",
}

function sendMessage(message: string, priority: MessagePriority, retryPolicy: RetryPolicy): void {
  // ...
}

// 함수 정의를 보지 않아도 호출 의도가 명확하다
sendMessage("hello", new MessagePriority(1), RetryPolicy.ALLOW_RETRY);

완벽한 해결책이 없는 경우

class BoundingBox {
  constructor(top: number, right: number, bottom: number, left: number) {}
}

// 어떤 숫자가 어느 인자인지 불분명하다
const box = new BoundingBox(10, 50, 20, 5);

// 인라인 주석으로 보완하는 차선책
const box2 = new BoundingBox(
  /* top= */ 10,
  /* right= */ 50,
  /* bottom= */ 20,
  /* left= */ 5
);

인라인 주석은 잘못 작성되거나 낡아질 수 있어 완전한 해결책이 아니다. 빌더 패턴 등 다른 방법도 각자의 단점이 있다.

핵심 원칙: IDE가 인자 이름을 힌트로 보여주더라도, 모든 도구(코드 리뷰 툴, 머지 툴 등)에서 그런 기능이 지원되지는 않으므로 코드 자체가 가독성을 보장해야 한다.


5.7 설명되지 않은 값 사용 회피

하드코딩된 값을 사용할 때, 컴퓨터는 그 값이 무엇인지 알면 되지만, 엔지니어는 그 값이 무엇을 의미하는지 알아야 한다.

나쁜 예: 의미 불명의 숫자들

class Vehicle {
  getMassUsTon(): number { /* ... */ }
  getSpeedMph(): number { /* ... */ }

  // 단위 변환 계수의 의미를 알 수 없다
  getKineticEnergyJ(): number {
    return 0.5 *
      this.getMassUsTon() * 907.1847 *
      Math.pow(this.getSpeedMph() * 0.44704, 2);
  }
}

만약 다른 엔지니어가 getMassUsTon()getMassKg()으로 바꾸면서 907.1847의 의미를 모른다면, 그 계수를 제거하지 않아 잘못된 결과를 반환하게 된다.

해결책 1: 잘 명명된 상수 사용

class Vehicle {
  private static readonly KILOGRAMS_PER_US_TON = 907.1847;
  private static readonly METERS_PER_SECOND_PER_MPH = 0.44704;

  getKineticEnergyJ(): number {
    return 0.5 *
      this.getMassUsTon() * Vehicle.KILOGRAMS_PER_US_TON *
      Math.pow(this.getSpeedMph() * Vehicle.METERS_PER_SECOND_PER_MPH, 2);
  }
}

해결책 2: 잘 명명된 함수 사용

제공자 함수(Provider Function):

class Vehicle {
  getKineticEnergyJ(): number {
    return 0.5 *
      this.getMassUsTon() * this.kilogramsPerUsTon() *
      Math.pow(this.getSpeedMph() * this.metersPerSecondPerMph(), 2);
  }

  private kilogramsPerUsTon(): number { return 907.1847; }
  private metersPerSecondPerMph(): number { return 0.44704; }
}

헬퍼 함수(Helper Function) - 서브 문제를 함수로 추상화:

class Vehicle {
  getKineticEnergyJ(): number {
    return 0.5 *
      this.usTonsToKilograms(this.getMassUsTon()) *
      Math.pow(this.mphToMetersPerSecond(this.getSpeedMph()), 2);
  }

  private usTonsToKilograms(usTons: number): number { return usTons * 907.1847; }
  private mphToMetersPerSecond(mph: number): number { return mph * 0.44704; }
}

핵심 원칙: 값 자체가 아니라 값의 의미를 코드에서 명확히 드러내야 한다. 다른 곳에서도 쓸 가능성이 있다면 공용 유틸리티 클래스에 두는 것이 좋다.


5.8 익명 함수의 적절한 사용

익명 함수(Anonymous Function, 람다)는 이름 없이 인라인으로 정의되는 함수로, 간단한 경우에는 코드를 간결하게 만들지만 남용하면 오히려 가독성을 해친다.

익명 함수가 좋은 경우: 작고 자명한 로직

function getUsefulFeedback(allFeedback: Feedback[]): Feedback[] {
  return allFeedback.filter(feedback => !feedback.getComment().isEmpty());
}

한 줄로, 의도가 명확하다. 이 경우 익명 함수는 적절하다.

참고: 이 로직이 재사용될 가능성이 있다면 그때는 명명 함수로 분리하는 것이 낫다.

익명 함수가 나쁜 경우: 복잡하고 자명하지 않은 로직

// 나쁜 예: 패리티 비트 검사 로직이 익명 함수 안에 숨어있다
function getValidIds(ids: number[]): number[] {
  return ids
    .filter(id => id !== 0)
    .filter(id => countSetBits(id & 0x7FFF) % 2 === ((id & 0x8000) >> 15));
}

익명 함수는 이름이 없기 때문에 내부 코드가 스스로 설명하지 못하면 아무도 그 의미를 알 수 없다.

해결책: 명명 함수를 인자로 사용

function getValidIds(ids: number[]): number[] {
  return ids
    .filter(id => id !== 0)
    .filter(isParityBitCorrect); // 함수 이름이 의도를 설명한다
}

function isParityBitCorrect(id: number): boolean {
  // 상세 구현은 여기서만 이해하면 된다
  return countSetBits(id & 0x7FFF) % 2 === ((id & 0x8000) >> 15);
}

getValidIds를 읽는 엔지니어는 패리티 비트의 상세 구현을 알 필요 없이 고수준 의도를 즉시 파악할 수 있다.

큰 익명 함수는 피하라

익명 함수가 2~3줄을 초과하기 시작한다면, 이를 명명 함수들로 분리해야 한다는 신호다.

// 나쁜 예: 거대한 익명 함수 안에 또 익명 함수
function buildFeedbackListItems(allFeedback: Feedback[]): ListItem[] {
  return allFeedback.map(feedback =>
    new ListItem({
      title: new TextBox({ text: feedback.getTitle(), options: new TextOptions({ weight: TextWeight.BOLD }) }),
      body: new Column({
        children: [
          new TextBox({ text: feedback.getComment(), border: new Border({ style: BorderStyle.DASHED }) }),
          new Row({
            children: feedback.getCategories().map(category =>
              new TextBox({ text: category.getLabel(), options: new TextOptions({ style: TextStyle.ITALIC }) })
            ),
          }),
        ],
      }),
    })
  );
}

// 좋은 예: 명명 함수들로 분리
function buildFeedbackListItems(allFeedback: Feedback[]): ListItem[] {
  return allFeedback.map(buildFeedbackItem);
}

function buildFeedbackItem(feedback: Feedback): ListItem {
  return new ListItem({
    title: buildTitle(feedback.getTitle()),
    body: new Column({ children: [buildCommentText(feedback.getComment()), buildCategories(feedback.getCategories())] }),
  });
}

function buildTitle(title: string): TextBox {
  return new TextBox({ text: title, options: new TextOptions({ weight: TextWeight.BOLD }) });
}

function buildCommentText(comment: string): TextBox {
  return new TextBox({ text: comment, border: new Border({ style: BorderStyle.DASHED }) });
}

function buildCategories(categories: Category[]): Row {
  return new Row({ children: categories.map(buildCategory) });
}

function buildCategory(category: Category): TextBox {
  return new TextBox({ text: category.getLabel(), options: new TextOptions({ style: TextStyle.ITALIC }) });
}

이제 buildFeedbackItem을 보면 UI에 제목, 코멘트, 카테고리가 표시된다는 사실을 즉시 알 수 있다.

핵심 원칙: 익명 함수는 작고 자명한 경우에만 사용한다. 복잡하거나 재사용 가능성이 있는 로직은 명명 함수로 분리한다. 함수형 스타일 코딩이 반드시 인라인 익명 함수를 의미하는 것은 아니다.


5.9 새로운 언어 기능의 적절한 사용

언어의 새로운 기능에 흥분하는 것은 자연스럽지만, 정말 그 기능이 최선의 도구인지 스스로에게 솔직하게 물어봐야 한다.

새 기능이 코드를 개선하는 경우

Node.js의 배열 스트림 처리 API들은 코드를 더 간결하고 읽기 쉽게 만든다.

// 전통적인 방법 (더 장황하다)
function getNonEmptyStrings(strings: string[]): string[] {
  const nonEmptyStrings: string[] = [];
  for (const str of strings) {
    if (str !== "") {
      nonEmptyStrings.push(str);
    }
  }
  return nonEmptyStrings;
}

// 스트림/배열 메서드 활용 (더 간결하고 의도가 명확하다)
function getNonEmptyStrings(strings: string[]): string[] {
  return strings.filter(str => str !== "");
}

이렇게 언어 기능이 코드를 실질적으로 개선한다면 사용하는 것이 좋다.

잘 알려지지 않은 기능은 혼란을 줄 수 있다

팀 내 다른 엔지니어들이 익숙하지 않은 기능을 사용하면, 코드 품질의 미미한 개선에 비해 혼란이 더 클 수 있다. 사용하기 전에 팀의 친숙도를 고려해야 한다.

최선의 도구를 사용하라

기능이 강력하다고 모든 곳에 쓰는 것은 잘못된 접근이다.

// 나쁜 예: map에서 값 조회를 filter로 억지로 구현
const value = Array.from(map.entries())
  .filter(([k]) => k === key)
  .map(([, v]) => v)[0];

// 좋은 예: 올바른 도구를 사용
const value = map.get(key);

전자는 훨씬 덜 가독성 있고 성능도 나쁘다. 실제 코드베이스에서도 이런 예시는 드물지 않다.

핵심 원칙: 새롭거나 빛나기 때문이 아니라, 그것이 작업에 맞는 최선의 도구이기 때문에 기능을 사용해야 한다.


## 핵심 요약

원칙 핵심 메시지
설명적인 이름 이름 자체가 코드를 설명하게 만든다. 주석은 대체재가 아니다.
주석의 적절한 사용 “왜”와 고수준 요약에 집중. “무엇”은 코드 자체로.
줄 수에 집착 금지 이해하기 어려운 1줄 < 이해하기 쉬운 20줄
일관된 코딩 스타일 팀 전체가 같은 언어를 쓰면 오해와 버그가 줄어든다.
깊은 중첩 회피 조기 반환과 함수 분리로 중첩을 줄인다.
함수 호출 가독성 객체 구조 분해, 설명적 타입으로 인자 의미를 명확히 한다.
설명되지 않은 값 회피 상수나 함수로 이름을 부여해 값의 의미를 드러낸다.
익명 함수의 적절한 사용 작고 자명한 것에만. 크거나 복잡하면 명명 함수로 분리한다.
새 언어 기능의 적절한 사용 빛나기 때문이 아니라 최선의 도구이기 때문에 사용한다.

현실의 시나리오는 다양하고 저마다의 고려사항이 있다. 가독성 높은 코드를 작성하려면 공감 능력상식적인 판단력이 항상 함께해야 한다.

Chapter 6. 놀라움을 피하라

코드는 혼자 동작하지 않는다. 다른 엔지니어가 우리 코드 위에 코드를 쌓는다. 그 과정에서 잘못된 멘탈 모델은 버그의 씨앗이 된다.

핵심 전제

  • 코드는 레이어 구조로 쌓인다. 하위 레이어의 코드를 오해하면 상위 레이어 코드가 틀려진다.
  • 엔지니어는 함수 이름, 파라미터 타입, 반환 타입 같은 명백한 요소로 멘탈 모델을 형성한다.
  • 주석이나 문서는 “작은 글씨”처럼 쉽게 무시된다.
  • 놀라움을 피하는 가장 좋은 방법은 명시적으로 작성하는 것이다.

6.1 매직 값을 반환하지 마라

매직 값이란?

함수의 정상 반환 타입에 속하지만 특수한 의미를 가진 값.

  • 가장 흔한 예: 값이 없음을 나타내기 위해 -1 반환
  • JavaScript의 Array.indexOf()가 대표적인 내장 예시

6.1.1 매직 값이 버그를 만드는 예

class User {
  private age: number | null;

  // 주석: 나이가 없으면 -1을 반환
  getAge(): number {
    if (this.age === null) {
      return -1; // 매직 값
    }
    return this.age;
  }
}

function getMeanAge(users: User[]): number | null {
  if (users.length === 0) return null;

  let sumOfAges = 0;
  for (const user of users) {
    sumOfAges += user.getAge(); // -1이 합산되어 결과가 틀려짐!
  }
  return sumOfAges / users.length;
}

문제점

  • getMeanAge 작성자는 getAge()가 항상 실제 나이를 반환한다고 가정
  • -1이 포함된 채 평균이 계산되어 그럴듯하지만 잘못된 값 반환
  • 이 값이 주주 보고서에 사용된다면 법적 결과로 이어질 수 있음
  • 단위 테스트도 잡기 어렵다: 작성자는 나이가 없는 케이스를 테스트할 생각 자체를 못 함

6.1.2 해결책: null, optional, 또는 error를 반환하라

class User {
  private age: number | null;

  getAge(): number | null { // 반환 타입 자체가 "없을 수 있음"을 명시
    return this.age;
  }
}

function getMeanAge(users: User[]): number | null {
  if (users.length === 0) return null;

  let sumOfAges = 0;
  let count = 0;
  for (const user of users) {
    const age = user.getAge();
    if (age !== null) { // null 처리를 강제로 인지하게 됨
      sumOfAges += age;
      count++;
    }
  }
  return count === 0 ? null : sumOfAges / count;
}
  • number | null 반환 타입은 타입 시스템이 호출자에게 경고를 강제한다.
  • null을 반환하는 이유가 “값 없음”인지 “에러 발생”인지 구분이 필요하다면 에러 시그널링 기법(Chapter 4)을 활용

6.1.3 매직 값은 의도치 않게 발생하기도 한다

function minValue(values: number[]): number {
  let min = Number.MAX_SAFE_INTEGER;
  for (const v of values) {
    min = Math.min(v, min);
  }
  return min; // 빈 배열이면 MAX_SAFE_INTEGER 반환 — 매직 값!
}

왜 위험한가?

  • maximin 알고리즘에서 점수가 없는 레벨(너무 어려워서 아무도 못 클리어)이 가장 쉬운 레벨로 판정됨
  • Number.MAX_SAFE_INTEGER는 언어마다 값이 다르다 (Java vs JavaScript)
  • DB에 저장되면 이후 쿼리와 다른 시스템에서 혼란 야기

해결:

function minValue(values: number[]): number | null {
  if (values.length === 0) return null; // 명시적으로 신호
  let min = Number.MAX_SAFE_INTEGER;
  for (const v of values) {
    min = Math.min(v, min);
  }
  return min;
}

요약: 매직 값은 의식적이든 실수든 발생한다. null, optional, 에러 시그널링으로 대체하라.


6.2 Null 객체 패턴의 적절한 활용

Null 객체 패턴: null 대신 하위 로직이 무해하게 동작할 수 있는 유효한 값을 반환하는 방식.

6.2.1 빈 컬렉션 반환은 코드를 개선한다

// 나쁜 예: null 반환
function getClassNames(element: HTMLElement): Set<string> | null {
  const attr = element.getAttribute("class");
  if (attr === null) return null;
  return new Set(attr.split(" "));
}

function isElementHighlighted(element: HTMLElement): boolean {
  const classNames = getClassNames(element);
  if (classNames === null) return false; // 호출자가 null 체크 필요
  return classNames.has("highlighted");
}

// 좋은 예: 빈 Set 반환 (Null 객체 패턴)
function getClassNames(element: HTMLElement): Set<string> {
  const attr = element.getAttribute("class");
  if (attr === null) return new Set(); // 빈 Set 반환
  return new Set(attr.split(" "));
}

function isElementHighlighted(element: HTMLElement): boolean {
  return getClassNames(element).has("highlighted"); // 훨씬 간결
}
  • “class 속성 없음”과 “class 속성이 빈 문자열”의 구분은 대부분 무의미하다.
  • 빈 컬렉션 반환은 호출자의 코드를 단순화하고 놀라움을 유발하지 않는다.

6.2.2 빈 문자열 반환은 상황에 따라 다르다

상황 빈 문자열 반환 null 반환
문자 집합으로서의 문자열 (자유 형식 코멘트 등) 적절 과할 수 있음
의미 있는 식별자 (카드 트랜잭션 ID 등) 위험 적절
// 나쁜 예: ID에 빈 문자열 반환
class Payment {
  private cardTransactionId: string | null;

  getCardTransactionId(): string { // 항상 ID가 있는 것처럼 보임
    return this.cardTransactionId ?? ""; // 빈 문자열은 오해를 유발
  }
}

// 좋은 예: null 반환
class Payment {
  private cardTransactionId: string | null;

  getCardTransactionId(): string | null { // "없을 수 있음"을 명시
    return this.cardTransactionId;
  }
}

6.2.3 복잡한 Null 객체는 놀라움을 유발한다

class CoffeeMugInventory {
  private mugs: CoffeeMug[];

  getRandomMug(): CoffeeMug {
    if (this.mugs.length === 0) {
      return new CoffeeMug(0, 0); // 크기 0의 머그 — 빈 상자를 파는 것
    }
    return this.mugs[Math.floor(Math.random() * this.mugs.length)];
  }
}
  • 호출자는 항상 유효한 머그를 받는다고 믿는다.
  • 통계 분석에 크기 0의 머그가 포함되면 결과가 심각하게 틀어진다.
  • 올바른 해결: null을 반환하여 “머그가 없을 수 있음”을 명시

6.2.4 Null 객체 구현체도 놀라움을 유발한다

interface CoffeeMug {
  getDiameter(): number;
  getHeight(): number;
  reportMugBroken(): void;
}

class NullCoffeeMug implements CoffeeMug {
  getDiameter(): number { return 0; }
  getHeight(): number { return 0; }
  reportMugBroken(): void { /* 아무것도 안 함 */ }
}
  • reportMugBroken()이 아무것도 안 하면 깨진 머그가 기록되지 않는다.
  • 호출자가 instanceof NullCoffeeMug를 확인해야 한다면, null을 확인하는 것보다 더 어색하다.
  • null safety와 optional이 보편화된 현재, Null 객체 패턴의 논거는 많이 약해졌다.

요약: Null 객체 패턴은 컬렉션처럼 의미가 단순할 때는 유용하다. 복잡한 객체나 의미 있는 값에는 오히려 위험하다.


6.3 예상치 못한 부작용 회피

부작용: 함수가 반환값 이외의 방법으로 외부 상태를 변경하는 것.

  • 출력 표시, 파일/DB 저장, 네트워크 요청, 캐시 갱신 등
  • 부작용 자체는 필수적이다. 예상치 못한 부작용이 문제다.

6.3.1 명백하고 의도적인 부작용은 괜찮다

class UserDisplay {
  private canvas: Canvas;

  displayErrorMessage(message: string): void {
    this.canvas.drawText(message, Color.RED); // 명백한 부작용
  }
}

클래스 이름 UserDisplay, 함수 이름 displayErrorMessage — 캔버스가 갱신될 것을 누구나 예상한다.

6.3.2 예상치 못한 부작용은 위험하다

class UserDisplay {
  private canvas: Canvas;

  getPixel(x: number, y: number): Color {
    this.canvas.redraw(); // 숨겨진 부작용!
    const data = this.canvas.getPixel(x, y);
    return new Color(data.red, data.green, data.blue);
  }
}

문제 1: 성능

  • captureScreenshot()이 모든 픽셀에 대해 getPixel()을 호출하면?
  • 400x700 = 280,000번의 redraw() 호출 — 10ms씩이면 47분 동결

문제 2: 가정 파괴

captureRedactedScreenshot(): Image {
  for (const area of this.getPrivacySensitiveAreas()) {
    this.canvas.delete(area.x, area.y, area.width, area.height);
  }
  const screenshot = this.captureScreenshot(); // 내부에서 redraw() 호출!
  this.canvas.redraw(); // 작성자는 이것만 redraw()라고 생각
  return screenshot; // 개인정보가 복원된 채 캡처됨 — 심각한 프라이버시 침해
}

문제 3: 멀티스레드 버그

Thread 1: getPixel(x, y)
  → canvas.redraw() 시작
Thread 2: getPixel(x, y)  ← Thread 1이 선점됨
  → canvas.redraw() 시작 + 완료
Thread 1 재개: canvas.getPixel() ← 재드로우 중인 캔버스를 읽음 → 잘못된 픽셀 데이터

멀티스레드 버그는 확률이 낮지만 규모가 커지면 누적 확률이 높아지고, 디버깅이 극도로 어렵다.

6.3.3 해결책: 부작용을 없애거나 이름으로 명시하라

// 이름으로 부작용을 명시
class UserDisplay {
  redrawAndGetPixel(x: number, y: number): Color { // 이름이 부작용을 알림
    this.canvas.redraw();
    const data = this.canvas.getPixel(x, y);
    return new Color(data.red, data.green, data.blue);
  }
}
  • 이름을 보고 호출자가 for-loop 안에서 사용하는 것을 재고하게 된다.
  • captureScreenshot()redrawAndCaptureScreenshot()으로 명명하면 멀티스레드 위험을 인지하게 된다.

원칙: 부작용 없는 함수가 기본값(default)이다. 부작용이 있다면 그것을 이름에 녹여라.


6.4 입력 파라미터 변경 금지

친구에게 책을 빌려줬는데 돌려받을 때 페이지가 찢겨있다면? 파라미터를 변경하는 것은 이와 같다.

6.4.1 입력 파라미터 변경이 버그를 만드는 예

function getBillableInvoices(
  userInvoices: Map<User, Invoice>, // 이 맵을 변경해버림!
  usersWithFreeTrial: Set<User>
): Invoice[] {
  for (const user of usersWithFreeTrial) {
    userInvoices.delete(user); // 원본 맵을 파괴
  }
  return Array.from(userInvoices.values());
}

function processOrders(orderBatch: OrderBatch): void {
  const userInvoices = orderBatch.getUserInvoices();
  const usersWithFreeTrial = orderBatch.getFreeTrialUsers();

  sendInvoices(getBillableInvoices(userInvoices, usersWithFreeTrial));
  enableOrderedServices(userInvoices); // 무료체험 유저가 맵에서 사라진 상태!
  // 결과: 무료체험 유저에게 서비스가 활성화되지 않음
}

6.4.2 해결책: 변경 전에 복사하라

function getBillableInvoices(
  userInvoices: Map<User, Invoice>,
  usersWithFreeTrial: Set<User>
): Invoice[] {
  return Array.from(userInvoices.entries())
    .filter(([user]) => !usersWithFreeTrial.has(user))
    .map(([, invoice]) => invoice); // 원본 맵은 건드리지 않음
}
  • 복사는 메모리/CPU를 쓰지만, 이는 버그로 인한 비용보다 훨씬 작다.
  • 대용량 데이터나 성능이 임계적인 경우 파라미터 변경이 필요할 수 있다. 이 경우 함수 이름과 주석에 반드시 명시하라.

관련: Chapter 7에서 다루는 불변 객체(Immutable Object)는 이 문제를 근본적으로 차단하는 방법이다.


6.5 오해를 유발하는 함수 작성 금지

6.5.1 핵심 입력이 없을 때 아무것도 안 하면 놀라움이 생긴다

class UserDisplay {
  displayLegalDisclaimer(legalText: string | null): void {
    if (legalText === null) {
      return; // 아무것도 안 함 — 조용한 실패
    }
    this.displayOverlay({
      title: "법적 고지",
      message: legalText,
      textColor: Color.RED,
    });
  }
}
class SignupFlow {
  ensureLegalCompliance(): void {
    // 코드를 읽는 사람은 "항상 고지가 표시된다"고 믿는다.
    // 실제로는 getSignupDisclaimer()가 null을 반환하면 아무것도 안 됨.
    this.userDisplay.displayLegalDisclaimer(
      this.messages.getSignupDisclaimer()
    );
  }
}
  • displayLegalDisclaimer()를 호출하는 코드만 보면 고지가 항상 표시된다고 오해한다.
  • 실제로는 번역이 없으면 법을 어기게 될 수 있다.

6.5.2 해결책: 필수 입력은 required로 만들어라

class UserDisplay {
  displayLegalDisclaimer(legalText: string): void { // null 불가
    this.displayOverlay({
      title: "법적 고지",
      message: legalText, // 항상 유효한 텍스트
      textColor: Color.RED,
    });
  }
}

class SignupFlow {
  // @returns false면 회원가입을 중단해야 함
  ensureLegalCompliance(): boolean {
    const disclaimer = this.messages.getSignupDisclaimer();
    if (disclaimer === null) {
      return false; // 명시적으로 실패를 알림
    }
    this.userDisplay.displayLegalDisclaimer(disclaimer);
    return true;
  }
}
  • 코드가 몇 줄 늘어나도 오해와 버그의 위험이 훨씬 줄어든다.
  • “코드 줄 수”보다 “명확성”이 장기적으로 훨씬 이득이다.

6.6 미래 대비형 Enum 처리

6.6.1 미래 enum 값을 암묵적으로 처리하면 위험하다

enum PredictedOutcome {
  COMPANY_WILL_GO_BUST,
  COMPANY_WILL_MAKE_A_PROFIT,
}

function isOutcomeSafe(prediction: PredictedOutcome): boolean {
  if (prediction === PredictedOutcome.COMPANY_WILL_GO_BUST) {
    return false;
  }
  return true; // 나머지는 모두 "안전"으로 암묵적 처리
}

이후 새 값이 추가되면:

enum PredictedOutcome {
  COMPANY_WILL_GO_BUST,
  COMPANY_WILL_MAKE_A_PROFIT,
  WORLD_WILL_END, // 새로 추가
}
  • isOutcomeSafe(WORLD_WILL_END)true 반환 — 재앙적 버그
  • enum 정의와 처리 로직이 다른 파일/팀에 있을 수 있어 동기화가 깨지기 쉽다.

6.6.2 해결책: 완전한 switch 문을 사용하라

function isOutcomeSafe(prediction: PredictedOutcome): boolean {
  switch (prediction) {
    case PredictedOutcome.COMPANY_WILL_GO_BUST:
      return false;
    case PredictedOutcome.COMPANY_WILL_MAKE_A_PROFIT:
      return true;
  }
  // switch를 벗어나면 처리되지 않은 값 — 즉시 실패
  throw new Error(`처리되지 않은 예측값: ${prediction}`);
}

단위 테스트로 이중 보호:

test("isOutcomeSafe는 모든 PredictedOutcome 값을 처리해야 한다", () => {
  const allValues = Object.values(PredictedOutcome);
  for (const value of allValues) {
    expect(() => isOutcomeSafe(value as PredictedOutcome)).not.toThrow();
  }
});
  • 새로운 enum 값이 추가되면 테스트가 실패하여 엔지니어가 처리 로직을 반드시 업데이트하게 된다.

6.6.3 default 케이스를 조심하라

// 나쁜 예: default로 암묵적 처리
function isOutcomeSafe(prediction: PredictedOutcome): boolean {
  switch (prediction) {
    case PredictedOutcome.COMPANY_WILL_GO_BUST:
      return false;
    case PredictedOutcome.COMPANY_WILL_MAKE_A_PROFIT:
      return true;
    default:
      return false; // WORLD_WILL_END도 COMPANY_WILL_AVOID_LAWSUIT도 모두 false?
  }
}
  • COMPANY_WILL_AVOID_LAWSUIT 같은 새 값이 추가되면 default가 잘못 처리한다.
  • 예외를 default 안에 넣는 것도 주의: 일부 언어에서 컴파일러 경고(exhaustive 경고)가 사라진다.
// 더 나쁜 예: default에 throw를 넣으면 컴파일러 경고가 사라짐
switch (prediction) {
  // ...
  default:
    throw new Error("Unhandled"); // 컴파일러는 "다 처리됨"으로 인식
}

// 더 나은 예: switch 밖에 throw
switch (prediction) {
  // ...
}
throw new Error("Unhandled"); // 컴파일러가 미처리 케이스를 경고할 수 있음

6.6.4 주의: 외부 프로젝트의 enum

  • 외부 프로젝트가 경고 없이 새 enum 값을 추가할 수 있다면 더 관대한 처리가 필요할 수 있다.
  • 이 경우 상황에 따라 판단이 필요하다.

6.7 테스트만으로는 부족하다

“테스트가 다 잡아준다”는 주장은 이상적이지만 현실적이지 않다.

테스트가 놀라움을 완전히 막지 못하는 이유:

  • 테스트 불성실: 다른 엔지니어가 코너 케이스를 빠트릴 수 있다. 오해한 코드에 대한 잘못된 가정으로 테스트를 작성한다.
  • Mock의 한계: 의존성을 모킹할 때, 실제 코드의 놀라운 동작을 모르면 Mock도 잘못 설정된다.
  • 멀티스레드 버그: 발생 확률이 낮아 테스트로 재현이 극히 어렵고 규모에서만 드러난다.

결론:

  • 테스트는 절대적으로 중요하다.
  • 하지만 직관적이지 않은 코드를 테스트가 보완할 수는 없다.
  • 좋은 코드 구조 + 철저한 테스트 두 가지 모두 필요하다.

## 핵심 요약

안티패턴 문제 해결책
매직 값 반환 호출자가 특수 의미를 모름 null, optional, 에러 반환
부적절한 Null 객체 패턴 빈 상자를 파는 것 의미 있는 값에는 null 반환
예상치 못한 부작용 성능, 가정 파괴, 멀티스레드 버그 부작용을 없애거나 이름에 명시
입력 파라미터 변경 원본 데이터 파괴 복사 후 변경
null 파라미터 묵인 오해를 유발하는 함수 필수 입력은 non-null로 강제
암묵적 enum 처리 새 값 추가 시 잘못된 동작 완전한 switch + 단위 테스트

핵심 원칙: 중요한 세부사항은 코드 계약의 “명백한 부분” 에 두어야 한다. 작은 글씨(주석, 문서)에 숨기면 언젠가 누군가는 반드시 놓친다.

Chapter 7. 오용하기 어려운 코드 작성

코드를 오용하기 어렵게 만들면, 버그가 발생할 가능성 자체를 설계 단계에서 차단할 수 있다.


코드가 오용되는 주요 원인

  • 호출자가 유효하지 않은 입력을 제공하는 경우
  • 다른 코드가 입력 파라미터를 변경하는 부작용
  • 함수를 잘못된 순서 또는 시점에 호출하는 경우
  • 관련 코드가 수정되면서 가정(assumption)을 깨뜨리는 경우

문서화와 주석은 “소고문자(small print)“에 불과하다. 읽히지 않거나 금방 낡는다. 설계 자체로 오용을 막는 것이 훨씬 신뢰할 수 있다.


7.1 불변성 고려

불변성이란?

객체가 생성된 이후 상태를 변경할 수 없는 성질. 변경 가능한(mutable) 객체는 다음과 같은 문제를 유발한다.

  • 추론이 어려움: 어디서든 상태가 바뀔 수 있어 객체의 현재 값을 확신할 수 없다.
  • 멀티스레드 위험: 한 스레드가 읽는 동안 다른 스레드가 수정하면 오류가 발생한다.

불변 객체는 마치 개봉 불가 봉인 라벨이 붙은 음료 캔과 같다. 누가 건드렸는지 걱정할 필요 없다.


7.1.1 가변 클래스의 오용 가능성

// Bad: setter가 있는 mutable 클래스
class TextOptions {
  private font: string;
  private fontSize: number;

  constructor(font: string, fontSize: number) {
    this.font = font;
    this.fontSize = fontSize;
  }

  setFont(font: string) { this.font = font; }
  setFontSize(fontSize: number) { this.fontSize = fontSize; }
  getFont() { return this.font; }
  getFontSize() { return this.fontSize; }
}

// 문제 상황: renderTitle 내부에서 fontSize를 18로 변경해버림
// 이후 renderMessage는 의도치 않게 fontSize=18로 렌더링됨
function sayHello(messageBox: MessageBox) {
  const defaultStyle = new TextOptions("Arial", 12);
  messageBox.renderTitle("Important message", defaultStyle);
  messageBox.renderMessage("Hello", defaultStyle); // fontSize가 18로 오염됨
}

7.1.2 해결책: 생성 시점에 값 설정

// Good: setter 제거, readonly로 외부 변경 차단
class TextOptions {
  readonly font: string;
  readonly fontSize: number;

  constructor(font: string, fontSize: number) {
    this.font = font;
    this.fontSize = fontSize;
  }

  getFont() { return this.font; }
  getFontSize() { return this.fontSize; }
}

readonly 키워드로 클래스 내부에서도 재할당을 방지한다.


7.1.3 해결책: 불변성을 위한 디자인 패턴

Builder 패턴

클래스를 두 개로 분리한다.

역할 특징
Builder 클래스 mutable, 값을 하나씩 설정 가능
완성된 클래스 immutable, build() 호출 후 생성
  • 필수값은 Builder의 생성자에서 받는다 (런타임이 아닌 컴파일 타임에 오류 검출)
  • 선택값은 setter 함수로 받는다
// Immutable 결과 클래스
class TextOptions {
  readonly font: string;
  readonly fontSize?: number;

  constructor(font: string, fontSize?: number) {
    this.font = font;
    this.fontSize = fontSize;
  }
}

// Builder 클래스
class TextOptionsBuilder {
  private readonly font: string; // 필수값: 생성자에서 받음
  private fontSize?: number;     // 선택값: setter로 받음

  constructor(font: string) {
    this.font = font;
  }

  setFontSize(fontSize: number): TextOptionsBuilder {
    this.fontSize = fontSize;
    return this; // 메서드 체이닝 지원
  }

  build(): TextOptions {
    return new TextOptions(this.font, this.fontSize);
  }
}

// 사용 예시
const options = new TextOptionsBuilder("Arial")
  .setFontSize(12)
  .build();
Copy-on-Write 패턴

기존 객체를 변경하는 대신, 변경된 사본을 새로 반환한다.

class TextOptions {
  readonly font: string;
  readonly fontSize?: number;

  constructor(font: string, fontSize?: number) {
    this.font = font;
    this.fontSize = fontSize;
  }

  withFont(newFont: string): TextOptions {
    return new TextOptions(newFont, this.fontSize); // 새 인스턴스 반환
  }

  withFontSize(newFontSize: number): TextOptions {
    return new TextOptions(this.font, newFontSize); // 새 인스턴스 반환
  }
}

// 사용 예시: 원본 baseStyle은 변하지 않음
function renderTitle(title: string, baseStyle: TextOptions) {
  titleField.display(title, baseStyle.withFontSize(18));
}
패턴 적합한 상황
생성자 전용 모든 값이 필수이고 선택값이 없을 때
Builder 선택값이 많아 생성자가 복잡해질 때
Copy-on-Write 생성 이후에 일부만 바꾼 사본이 필요할 때

7.2 깊은 불변성 고려

7.2.1 깊은 가변성이란?

클래스의 멤버 변수가 readonly여도, 그 변수가 참조하는 객체 자체가 mutable이면 외부에서 내부 상태를 바꿀 수 있다.

class TextOptions {
  private readonly fontFamily: string[]; // 배열 자체는 mutable

  constructor(fontFamily: string[]) {
    this.fontFamily = fontFamily; // 참조만 복사
  }

  getFontFamily(): string[] {
    return this.fontFamily; // 외부에 참조를 그대로 노출
  }
}

// Scenario A: 생성 후 원본 배열 변경
const fonts = ["Arial", "Verdana"];
const options = new TextOptions(fonts);
fonts.length = 0;
fonts.push("Comic Sans"); // options 내부도 오염됨

// Scenario B: getter로 받은 참조를 통해 변경
const family = options.getFontFamily();
family.length = 0;
family.push("Comic Sans"); // options 내부도 오염됨

7.2.2 해결책: 방어적 복사

class TextOptions {
  private readonly fontFamily: string[];

  constructor(fontFamily: string[]) {
    this.fontFamily = [...fontFamily]; // 생성 시 복사
  }

  getFontFamily(): string[] {
    return [...this.fontFamily]; // 반환 시 복사
  }
}

단점

  • 데이터가 클수록 복사 비용이 크다
  • 클래스 내부 코드에서의 변경은 여전히 막지 못한다

7.2.3 해결책: 불변 자료구조 사용

TypeScript/JavaScript에서는 as const, Object.freeze(), 또는 Immer 같은 라이브러리를 활용한다.

// as const로 리터럴 배열을 읽기 전용으로
const fontFamily = ["Arial", "Verdana"] as const;
// fontFamily.push("Comic Sans"); // 컴파일 오류

// ReadonlyArray 타입 사용
class TextOptions {
  private readonly fontFamily: ReadonlyArray<string>;

  constructor(fontFamily: ReadonlyArray<string>) {
    this.fontFamily = fontFamily;
  }

  getFontFamily(): ReadonlyArray<string> {
    return this.fontFamily; // 복사 없이 안전하게 반환
  }
}

ReadonlyArray를 쓰면 복사 비용 없이, 외부에서도 내부에서도 배열 변경이 불가능해진다. 가장 권장되는 방법이다.


7.3 지나치게 범용적인 타입 회피

7.3.1 범용 타입의 오용 가능성

지도 위 좌표를 number[]로 표현하면 다음 문제가 생긴다.

// Bad: 너무 범용적인 타입
function markLocationsOnMap(locations: number[][]): void {
  for (const location of locations) {
    map.markLocation(location[0], location[1]); // 위도? 경도? 순서가 맞나?
  }
}
  • 타입만 봐서는 의미를 알 수 없다
  • 위도/경도 순서를 뒤바꿔도 컴파일러가 잡지 못한다
  • 요소 개수가 틀려도 런타임까지 발견 못한다

7.3.2 Pair 타입의 한계

type LocationPair = [number, number]; // first=위도? 경도? 여전히 불명확

요소 개수 문제는 해결되지만 의미 불명확함과 순서 혼동은 여전하다.


7.3.3 해결책: 전용 타입 사용

// Good: 명확한 전용 클래스
class LatLong {
  readonly latitude: number;
  readonly longitude: number;

  constructor(latitude: number, longitude: number) {
    this.latitude = latitude;
    this.longitude = longitude;
  }
}

function markLocationsOnMap(locations: LatLong[]): void {
  for (const location of locations) {
    map.markLocation(location.latitude, location.longitude);
    // 문서 없이도 자명하고, 위도/경도 순서 혼동 불가능
  }
}

TypeScript에서는 interfacetype으로도 간결하게 표현할 수 있다.

interface LatLong {
  readonly latitude: number;
  readonly longitude: number;
}

“조금 더 수고스럽더라도 전용 타입을 만드는 것이 중장기적으로 훨씬 빠르다.”

범용 타입이 퍼지는 악순환: number[][]를 한 곳에서 쓰기 시작하면, 이와 상호작용해야 하는 다른 코드들도 강제로 같은 표현을 채택하게 된다. 잘못된 패러다임이 코드베이스 전체로 전파된다.


7.4 시간 다루기

7.4.1 정수 시간 표현의 문제

시간 관련 개념은 미묘하게 구분된다.

개념 예시
특정 시점(Instant) 1969년 7월 21일 02:56 UTC
시간의 양(Duration) 30분, 5초
날짜(Date) 1990년 12월 2일 (시간대 무관)

정수 하나로 이 모든 것을 표현하면 세 가지 함정이 생긴다.

함정 1: 시점인지 양인지 불명확

// deadline이 unix timestamp인가, 아니면 남은 초인가?
function sendMessage(message: string, deadline: number): boolean { ... }

함정 2: 단위 불일치

function getMessageTimeout(): number { return 5; }       // 초(seconds) 반환
function showMessage(message: string, timeoutMs: number) { ... } // 밀리초 기대

// 이 호출은 버그: 5ms 동안만 표시됨
showMessage("Warning", getMessageTimeout());

함정 3: 시간대(Timezone) 오처리

생일처럼 시간대와 무관한 날짜를 timestamp로 저장하면, 시간대가 다른 서버나 클라이언트에서 하루가 달라질 수 있다.


7.4.2 해결책: 시간 전용 자료구조 사용

TypeScript/JavaScript에서는 js-joda 같은 라이브러리를 활용하거나, 최소한 의미가 담긴 래퍼 타입을 정의한다.

// 직접 Duration 타입을 만드는 간단한 예시
class Duration {
  private readonly ms: number;

  private constructor(ms: number) {
    this.ms = ms;
  }

  static ofSeconds(s: number): Duration { return new Duration(s * 1000); }
  static ofMinutes(m: number): Duration { return new Duration(m * 60 * 1000); }

  toMillis(): number { return this.ms; }
  toSeconds(): number { return this.ms / 1000; }
}

// 이제 단위 혼동이 불가능
function showMessage(message: string, timeout: Duration): void { ... }

function getMessageTimeout(): Duration {
  return Duration.ofSeconds(5);
}

showMessage("Warning", getMessageTimeout()); // 명확하고 안전함
// 시점과 양을 명확히 구분
class Instant { ... }   // 특정 시점
class Duration { ... }  // 시간의 양
class LocalDate { ... } // 시간대 없는 날짜 (생일 등)

// sendMessage의 deadline이 시점인지 양인지 타입으로 자명해짐
function sendMessage(message: string, deadline: Duration): boolean { ... }

7.5 데이터의 단일 진실 원천

데이터의 두 종류

종류 정의 예시
기본 데이터(Primary) 외부에서 제공해야 하는 값 입금액, 출금액
파생 데이터(Derived) 기본 데이터로 계산 가능한 값 잔액 = 입금 - 출금

7.5.1 다중 진실 원천의 위험성

// Bad: balance를 외부에서 받으면 논리적으로 틀린 상태가 가능
class UserAccount {
  constructor(
    private readonly credit: number,
    private readonly debit: number,
    private readonly balance: number // 파생값인데 생성자에서 받음
  ) {}
}

// 실수: debit - credit으로 잘못 계산
const account = new UserAccount(credit, debit, debit - credit); // 버그

7.5.2 해결책: 기본 데이터를 단일 원천으로

// Good: balance는 필요할 때마다 계산
class UserAccount {
  constructor(
    private readonly credit: number,
    private readonly debit: number
  ) {}

  getBalance(): number {
    return this.credit - this.debit; // 항상 논리적으로 일관됨
  }
}

계산 비용이 클 때: Lazy 계산 + 캐싱

트랜잭션 목록에서 합계를 구하는 경우처럼 비용이 크다면, 처음 요청 시 계산 후 캐시한다. 단, 이 패턴은 클래스가 immutable일 때만 안전하다. mutable이면 캐시를 무효화하는 로직이 필요해져 복잡해진다.

class UserAccount {
  private readonly transactions: ReadonlyArray<Transaction>;
  private cachedCredit?: number;
  private cachedDebit?: number;

  constructor(transactions: ReadonlyArray<Transaction>) {
    this.transactions = transactions;
  }

  getCredit(): number {
    if (this.cachedCredit === undefined) {
      this.cachedCredit = this.transactions
        .reduce((sum, t) => sum + t.getCredit(), 0);
    }
    return this.cachedCredit;
  }

  getDebit(): number {
    if (this.cachedDebit === undefined) {
      this.cachedDebit = this.transactions
        .reduce((sum, t) => sum + t.getDebit(), 0);
    }
    return this.cachedDebit;
  }

  getBalance(): number {
    return this.getCredit() - this.getDebit();
  }
}

7.6 로직의 단일 진실 원천

7.6.1 로직 중복과 동기화 버그

직렬화(저장)와 역직렬화(읽기) 로직이 서로 다른 클래스에 분산되어 있으면, 한쪽만 수정할 때 둘이 어긋난다.

// Bad: 직렬화 로직이 두 곳에 분산됨

class DataLogger {
  saveValues(file: FileHandler, values: number[]): void {
    const serialized = values.map(v => v.toString(10)).join(","); // 쉼표, 10진수
    file.write(serialized);
  }
}

class DataLoader {
  loadValues(file: FileHandler): number[] {
    return file.readAsString()
      .split(",")                 // 쉼표, 10진수 — 반드시 DataLogger와 일치해야 함
      .map(s => parseInt(s, 10));
  }
}
// DataLogger를 16진수로 바꾸고 DataLoader를 안 바꾸면 버그 발생

7.6.2 해결책: 로직의 단일 원천화

// Good: 직렬화 규칙을 한 클래스에 집중

class IntListFormat {
  private static readonly DELIMITER = ",";
  private static readonly RADIX = 10;

  serialize(values: number[]): string {
    return values
      .map(v => v.toString(IntListFormat.RADIX))
      .join(IntListFormat.DELIMITER);
  }

  deserialize(serialized: string): number[] {
    return serialized
      .split(IntListFormat.DELIMITER)
      .map(s => parseInt(s, IntListFormat.RADIX));
  }
}

// DataLogger와 DataLoader 모두 IntListFormat에 위임
class DataLogger {
  constructor(private readonly intListFormat: IntListFormat) {}

  saveValues(file: FileHandler, values: number[]): void {
    file.write(this.intListFormat.serialize(values));
  }
}

class DataLoader {
  constructor(private readonly intListFormat: IntListFormat) {}

  loadValues(file: FileHandler): number[] {
    return this.intListFormat.deserialize(file.readAsString());
  }
}

이제 포맷을 바꾸려면 IntListFormat 하나만 수정하면 된다.

graph TD
    A[DataLogger] -->|위임| C[IntListFormat]
    B[DataLoader] -->|위임| C
    C -->|단일 진실의 원천| D["직렬화 포맷 규칙\n구분자: ','\n진법: 10진수"]

## 핵심 요약

원칙 핵심 내용
불변성(Immutability) setter 제거, readonly 사용. 필요 시 Builder 또는 Copy-on-Write 패턴 활용
깊은 불변성(Deep Immutability) 내부 컬렉션도 ReadonlyArray 등 불변 자료구조로 보호
전용 타입 number[][] 대신 LatLong처럼 의미가 담긴 전용 타입 정의
시간 타입 number로 시간 표현 금지. Duration, Instant, LocalDate 같은 목적별 타입 사용
데이터 단일 원천 파생 데이터는 저장하지 말고 계산. 비용이 크면 Lazy 캐싱
로직 단일 원천 두 곳이 같은 규칙을 알아야 한다면 공통 클래스로 추출

코드를 오용하기 어렵게 만드는 데 드는 초기 비용은 중장기적으로 버그 추적과 수정에 드는 비용보다 훨씬 작다.

Chapter 8. 코드를 모듈화하라

요구사항은 소프트웨어 수명 내내 변화한다. 정확히 어떻게 변할지 예측하는 건 불가능하지만, 변할 것이라는 사실 자체는 거의 확실하다. 모듈화의 핵심 목표는 요구사항이 변했을 때 관련된 코드 한 곳만 수정하면 되도록 설계하는 것이다.

모듈화가 가져다주는 이점:

  • 코드 적응성(adaptability) 향상
  • 시스템 추론 용이성 향상
  • 재사용성 향상 (9장)
  • 테스트 용이성 향상 (11장)

8.1 의존성 주입 고려

8.1.1 하드코딩된 의존성의 문제

클래스가 내부에서 의존 객체를 직접 생성하면 다음 문제가 발생한다.

class RoutePlanner {
  private readonly roadMap: RoadMap;

  constructor() {
    // 북미 지도만 사용 가능 — 하드코딩된 의존성
    this.roadMap = new NorthAmericaRoadMap(true, false);
  }

  planRoute(startPoint: LatLong, endPoint: LatLong): Route {
    // ...
  }
}

문제점 정리:

문제 설명
재구성 불가 다른 구현체로 교체할 방법이 없음
추상화 오염 의존 클래스의 생성자 파라미터를 상위 클래스가 알아야 함
제한된 사용 범위 인터넷 없음, 비수기 도로 필요 등 시나리오 대응 불가

8.1.2 해결책: 의존성 주입 사용

의존 객체를 생성자 파라미터로 주입하면 클래스가 훨씬 유연해진다.

interface RoadMap {
  getRoads(): Road[];
  getJunctions(): Junction[];
}

class NorthAmericaRoadMap implements RoadMap {
  constructor(
    private useOnlineVersion: boolean,
    private includeSeasonalRoads: boolean
  ) {}

  getRoads(): Road[] { /* ... */ return []; }
  getJunctions(): Junction[] { /* ... */ return []; }
}

class EuropeRoadMap implements RoadMap {
  getRoads(): Road[] { /* ... */ return []; }
  getJunctions(): Junction[] { /* ... */ return []; }
}

// 의존성 주입 적용
class RoutePlanner {
  constructor(private readonly roadMap: RoadMap) {}

  planRoute(startPoint: LatLong, endPoint: LatLong): Route {
    // roadMap 인터페이스만 사용, 구현체는 무관
  }
}

// 사용 예시 — 어떤 지역 지도든 주입 가능
const europeRoutePlanner = new RoutePlanner(new EuropeRoadMap());
const naRoutePlanner = new RoutePlanner(new NorthAmericaRoadMap(true, false));

생성 편의를 위한 팩토리 함수 제공:

class RoutePlannerFactory {
  static createEuropeRoutePlanner(): RoutePlanner {
    return new RoutePlanner(new EuropeRoadMap());
  }

  static createDefaultNorthAmericaRoutePlanner(): RoutePlanner {
    return new RoutePlanner(new NorthAmericaRoadMap(true, false));
  }
}
  • 기본 케이스는 팩토리로 쉽게 생성 가능
  • 특수 케이스는 직접 주입으로 유연하게 대응 가능

의존성 주입 프레임워크 (NestJS의 DI 컨테이너 등)를 활용하면 팩토리 보일러플레이트 없이 자동으로 관리할 수 있다. 단, 프레임워크 설정이 복잡해지면 코드 흐름 추적이 어려워질 수 있으므로 베스트 프랙티스를 숙지하고 사용할 것.

8.1.3 의존성 주입을 고려한 설계

static 함수에 직접 의존하면 DI 자체가 불가능해진다.

// BAD: static 함수에 직접 의존 — DI로 개선 불가
class RoutePlanner {
  planRoute(startPoint: LatLong, endPoint: LatLong): Route {
    const roads = NorthAmericaRoadMap.getRoads();    // static 호출
    const junctions = NorthAmericaRoadMap.getJunctions(); // static 호출
    // ...
  }
}

class NorthAmericaRoadMap {
  static getRoads(): Road[] { return []; }
  static getJunctions(): Junction[] { return []; }
}

Static Cling: static 함수/변수에 과도하게 의존하는 현상. 단위 테스트 시 테스트 더블(Test Double) 사용이 불가능해져 특히 문제가 된다.

해결책: 서브 문제에 대안 구현이 있을 가능성이 있다면 인터페이스를 정의하고 인스턴스 메서드로 구현하라.


8.2 인터페이스 의존 선호

8.2.1 구체 구현 의존의 한계

DI를 사용하더라도 구체 클래스에 의존하면 유연성이 제한된다.

class RoutePlanner {
  // 인터페이스가 아닌 구체 클래스 타입 사용
  constructor(private readonly roadMap: NorthAmericaRoadMap) {}
}
  • 다른 RoadMap 구현체(EuropeRoadMap)를 주입할 수 없음
  • DI의 핵심 이점을 스스로 포기하는 셈

8.2.2 해결책: 가능하면 인터페이스에 의존

class RoutePlanner {
  // 인터페이스에 의존 — 어떤 구현체든 주입 가능
  constructor(private readonly roadMap: RoadMap) {}

  planRoute(startPoint: LatLong, endPoint: LatLong): Route {
    // ...
  }
}

핵심 원칙:

  • 인터페이스는 서브 문제를 위한 추상화 레이어를 제공한다
  • 구체 클래스는 덜 추상적이고 구현에 종속된 해결책이다
  • 더 추상적인 인터페이스에 의존할수록 더 깨끗한 추상화 레이어더 나은 모듈화가 달성된다

의존성 역전 원칙 (Dependency Inversion Principle): 구체적인 구현보다 추상화에 의존하는 것이 낫다는 SOLID 원칙 중 하나.


8.3 클래스 상속 주의

클래스 상속은 강력하지만 여러 함정이 있다. 대안인 컴포지션(Composition) 이 더 모듈화된 결과를 낳는 경우가 많다.

8.3.1 클래스 상속의 문제점

시나리오: CSV 파일에서 정수를 하나씩 읽는 클래스 구현. 기존 CsvFileHandler를 재사용하고 싶어서 상속을 사용.

interface FileValueReader {
  getNextValue(): string | null;
  close(): void;
}

interface FileValueWriter {
  writeValue(value: string): void;
  close(): void;
}

class CsvFileHandler implements FileValueReader, FileValueWriter {
  constructor(file: File) {}
  getNextValue(): string | null { return null; }
  writeValue(value: string): void {}
  close(): void {}
}

// 상속 사용
class IntFileReader extends CsvFileHandler {
  constructor(file: File) {
    super(file);
  }

  getNextInt(): number | null {
    const nextValue = this.getNextValue(); // 슈퍼클래스 메서드 호출
    if (nextValue === null) return null;
    return parseInt(nextValue, 10);
  }
}

문제 1 — 추상화 오염 (Broken Abstraction Layers):

// IntFileReader의 실제 공개 API (상속으로 인해 불필요한 메서드 노출)
class IntFileReader {
  getNextInt(): number | null { /* ... */ }
  getNextValue(): string | null { /* 불필요하게 노출됨! */ }
  writeValue(value: string): void { /* 불필요하게 노출됨! */ }
  close(): void { /* OK */ }
}
  • “정수 파일 리더”인데 문자열 읽기, 값 쓰기 기능이 공개됨
  • 시간이 지나면 코드베이스 곳곳에서 이 불필요한 메서드들이 호출되어, 내부 구현 변경이 불가능해짐

문제 2 — 코드 적응성 저하 (Hard to Adapt):

세미콜론 구분 파일도 지원해야 하는 요구사항 추가 시:

class SemicolonFileHandler implements FileValueReader, FileValueWriter {
  // CsvFileHandler와 거의 동일한 인터페이스
}

// 상속 구조에서는 중복 클래스를 새로 만들 수밖에 없음
class SemicolonIntFileReader extends SemicolonFileHandler {
  getNextInt(): number | null {
    const nextValue = this.getNextValue();
    if (nextValue === null) return null;
    return parseInt(nextValue, 10);
    // IntFileReader와 거의 완전히 동일한 코드 중복!
  }
}

FileValueReader 인터페이스라는 공통 추상화가 있음에도 상속 구조 때문에 이를 활용하지 못한다.

8.3.2 해결책: 컴포지션 사용

컴포지션: 클래스를 확장하는 대신, 인스턴스를 멤버 변수로 포함하여 재사용.

class IntFileReader {
  // 인터페이스에 의존 + DI로 주입
  constructor(private readonly valueReader: FileValueReader) {}

  getNextInt(): number | null {
    const nextValue = this.valueReader.getNextValue();
    if (nextValue === null) return null;
    return parseInt(nextValue, 10);
  }

  // 필요한 메서드만 명시적으로 포워딩(forwarding)
  close(): void {
    this.valueReader.close();
  }
}

개선된 공개 API — 딱 필요한 것만 노출:

class IntFileReader {
  getNextInt(): number | null { /* ... */ }
  close(): void { /* ... */ }
  // getNextValue, writeValue 등 불필요한 메서드 없음
}

요구사항 변경 대응 — 중복 없이 처리:

class IntFileReaderFactory {
  createCsvIntReader(file: File): IntFileReader {
    return new IntFileReader(new CsvFileHandler(file));
  }

  createSemicolonIntReader(file: File): IntFileReader {
    return new IntFileReader(new SemicolonFileHandler(file));
  }
}

IntFileReader 코드는 단 하나, 어떤 구분자 방식도 지원 가능.

위임(Delegation): 포워딩 메서드가 많아지면 작성이 번거롭다. TypeScript에서는 직접 지원하지 않지만, 컴파일 타임 코드 생성 도구나 유틸리티로 해결 가능.

8.3.3 진정한 is-a 관계의 경우

FordMustangCar이므로 상속이 자연스러워 보인다. 하지만 진짜 is-a 관계에서도 상속은 위험하다.

상속의 알려진 함정:

문제 설명
취약한 기반 클래스 문제 (Fragile Base Class) 슈퍼클래스 수정이 서브클래스를 예기치 않게 망가뜨림
다이아몬드 문제 (Diamond Problem) 다중 상속 시 같은 메서드를 어느 슈퍼클래스에서 상속받을지 모호해짐
계층 구조 문제 (Problematic Hierarchies) 단일 상속 언어에서 FlyingCarCarAircraft 중 하나만 상속 가능

권장 패턴 — 인터페이스로 계층 정의, 컴포지션으로 코드 재사용:

classDiagram
  class Car {
    <<interface>>
    +drive()
  }
  class Aircraft {
    <<interface>>
    +fly()
  }
  class DrivingAction {
    +drive()
  }
  class FlyingAction {
    +fly()
  }
  class FordMustang {
    -drivingAction: DrivingAction
  }
  class FlyingCar {
    -drivingAction: DrivingAction
    -flyingAction: FlyingAction
  }

  Car <|.. FordMustang
  Car <|.. FlyingCar
  Aircraft <|.. FlyingCar
  FordMustang *-- DrivingAction
  FlyingCar *-- DrivingAction
  FlyingCar *-- FlyingAction
  • 계층은 인터페이스로 정의
  • 공통 로직 재사용은 컴포지션으로 달성
  • FlyingCarCarAircraft 인터페이스를 모두 구현 가능

Mixin / Trait: 일부 언어는 전통적 상속 없이 기능을 공유하는 Mixin/Trait을 지원한다. TypeScript에서도 Mixin 패턴이 사용되지만, 여전히 추상화 레이어와 적응성 측면에서 신중하게 사용해야 한다.


8.4 클래스는 자기 자신만 관리하라

8.4.1 타 클래스에 대한 과도한 관심

하나의 개념이 단일 클래스에 완전히 담겨야 한다. 그렇지 않으면 요구사항 변경 시 여러 클래스를 수정해야 하고, 하나라도 누락되면 버그가 생긴다.

class Book {
  constructor(private readonly chapters: Chapter[]) {}

  wordCount(): number {
    return this.chapters
      .map(ch => this.getChapterWordCount(ch)) // Book이 Chapter 내부 구조를 알고 있음
      .reduce((sum, n) => sum + n, 0);
  }

  // BAD: Chapter에 관한 로직이 Book 안에 있음
  private getChapterWordCount(chapter: Chapter): number {
    return chapter.getPrelude().wordCount() +
      chapter.getSections()
        .map(s => s.wordCount())
        .reduce((sum, n) => sum + n, 0);
  }
}

class Chapter {
  getPrelude(): TextBlock { /* ... */ }
  getSections(): TextBlock[] { /* ... */ }
}

챕터에 summary가 추가되면 Book.getChapterWordCount()도 수정해야 한다. 챕터 관련 변경이 Book 클래스에 영향을 준다.

8.4.2 해결책: 클래스의 자치권 보장

class Book {
  constructor(private readonly chapters: Chapter[]) {}

  wordCount(): number {
    return this.chapters
      .map(ch => ch.wordCount()) // Chapter가 자신의 단어 수를 앎
      .reduce((sum, n) => sum + n, 0);
  }
}

class Chapter {
  getPrelude(): TextBlock { /* ... */ }
  getSections(): TextBlock[] { /* ... */ }

  // 챕터 단어 수 로직이 Chapter 안에 완전히 캡슐화됨
  wordCount(): number {
    return this.getPrelude().wordCount() +
      this.getSections()
        .map(s => s.wordCount())
        .reduce((sum, n) => sum + n, 0);
  }
}

이제 summary 추가 시 Chapter만 수정하면 된다.

데메테르 법칙 (Law of Demeter): 객체는 직접 연관된 객체하고만 상호작용해야 한다. chapter.getPrelude().wordCount()처럼 연쇄 호출하는 것은 이 법칙을 어기는 신호다.


8.5 관련 데이터의 캡슐화

8.5.1 캡슐화되지 않은 데이터의 문제

서로 불가분하게 연결된 데이터를 낱개로 전달하면, 중간 레이어가 불필요하게 세부사항을 알아야 한다.

class TextBox {
  renderText(
    text: string,
    font: Font,
    fontSize: number,
    lineHeight: number,
    textColor: Color
  ): void { /* ... */ }
}

class UiSettings {
  getFont(): Font { /* ... */ }
  getFontSize(): number { /* ... */ }
  getLineHeight(): number { /* ... */ }
  getTextColor(): Color { /* ... */ }
}

class UserInterface {
  constructor(
    private messageBox: TextBox,
    private uiSettings: UiSettings
  ) {}

  // BAD: displayMessage는 텍스트 스타일 세부사항을 알 필요가 없는데 알아야 함
  displayMessage(message: string): void {
    this.messageBox.renderText(
      message,
      this.uiSettings.getFont(),
      this.uiSettings.getFontSize(),
      this.uiSettings.getLineHeight(),
      this.uiSettings.getTextColor()
    );
  }
}

fontStyle 파라미터가 추가되면 displayMessage도 수정해야 한다. 이 함수는 텍스트 스타일과 직접 관련이 없는데도.

8.5.2 해결책: 관련 데이터 그룹화

class TextOptions {
  constructor(
    public readonly font: Font,
    public readonly fontSize: number,
    public readonly lineHeight: number,
    public readonly textColor: Color
  ) {}
}

class TextBox {
  renderText(text: string, textStyle: TextOptions): void { /* ... */ }
}

class UiSettings {
  getTextStyle(): TextOptions { /* ... */ }
}

class UserInterface {
  displayMessage(message: string): void {
    // displayMessage는 스타일 세부사항을 전혀 모름 — 좋은 택배기사처럼 내용물 모르고 전달
    this.messageBox.renderText(message, this.uiSettings.getTextStyle());
  }
}

이제 fontStyle이 추가되어도 displayMessage는 수정할 필요가 없다.

언제 묶어야 하는가:

  • 데이터들이 불가분하게 연결되어 있을 때
  • 일부만 필요한 실용적 시나리오가 없을 때
  • 반대로, 너무 많은 개념을 억지로 한 클래스에 넣는 것도 문제 (2장 참고)

8.6 반환 타입의 구현 세부사항 노출 주의

8.6.1 반환 타입 내 세부사항 노출

class ProfilePictureService {
  constructor(private httpFetcher: HttpFetcher) {}
  getProfilePicture(userId: bigint): ProfilePictureResult { /* ... */ }
}

class ProfilePictureResult {
  getStatus(): HttpResponse.Status { /* ... */ } // HTTP 구현 세부사항 노출!
  getImageData(): HttpResponse.Payload | null { /* ... */ } // HTTP 구현 세부사항 노출!
}

문제점:

  • 호출자가 HTTP 상태 코드 50여 개를 이해해야 함
  • ProfilePictureService를 WebSocket 기반으로 교체하려면 이 타입에 의존하는 코드 전체를 수정해야 함
  • 구현 세부사항이 공개 API로 굳어짐

8.6.2 해결책: 추상화 계층에 적합한 타입 반환

enum ProfilePictureStatus {
  SUCCESS,
  USER_DOES_NOT_EXIST,
  OTHER_ERROR,
}

class ProfilePictureResult {
  // 이 레이어에서 실제로 필요한 상태만 정의
  getStatus(): ProfilePictureStatus { /* ... */ }
  // HTTP 특화 타입 대신 범용 바이트 배열 반환
  getImageData(): Buffer | null { /* ... */ }
}
  • HTTP 관련 지식 없이도 결과를 올바르게 해석 가능
  • 내부 구현(HTTP → WebSocket 등) 교체 시 호출자 코드 변경 불필요
  • 공개 API가 제공하는 추상화 레이어에 맞는 타입 사용

8.7 예외를 통한 구현 세부사항 노출 주의

반환 타입은 눈에 잘 띄지만, 예외 타입을 통한 구현 세부사항 노출은 더 발견하기 어렵다. 특히 unchecked exception은 코드 계약의 “작은 글씨”에 해당하므로 더 위험하다.

8.7.1 예외를 통한 세부사항 노출

class TextSummarizer {
  constructor(private importanceScorer: TextImportanceScorer) {}

  summarizeText(text: string): string {
    return findParagraphs(text)
      .filter(p => this.importanceScorer.isImportant(p))
      .join('\n\n');
    // ModelBasedScorer가 PredictionModelError를 던질 수 있음
    // — 그런데 TextSummarizer 사용자가 왜 예측 모델을 알아야 하나?
  }
}

interface TextImportanceScorer {
  isImportant(text: string): boolean;
}

class ModelBasedScorer implements TextImportanceScorer {
  isImportant(text: string): boolean {
    // PredictionModelError (unchecked) 가 던져질 수 있음
    return this.model.predict(text) >= MODEL_THRESHOLD;
  }
}
// 호출자가 구현 세부사항을 알아야만 에러 처리 가능
function updateTextSummary(ui: UserInterface): void {
  try {
    const summary = textSummarizer.summarizeText(ui.getUserText());
    ui.getSummaryField().setValue(summary);
  } catch (e) {
    if (e instanceof PredictionModelError) { // 구현 세부사항에 의존!
      ui.getSummaryField().setError("Unable to summarize text");
    }
  }
}

나중에 importanceScorer를 다른 구현체로 교체하면 이 catch 문은 작동하지 않는다.

8.7.2 해결책: 추상화 계층에 적합한 예외 처리

각 레이어는 자신의 추상화 레이어에 적합한 에러 타입만 노출해야 한다. 하위 레이어의 에러는 현재 레이어에 맞는 에러로 래핑하여 다시 던진다.

// 각 레이어의 에러 타입 정의
class TextSummarizerError extends Error {
  constructor(public readonly cause: Error) {
    super('Text summarization failed');
  }
}

class TextImportanceScorerError extends Error {
  constructor(public readonly cause: Error) {
    super('Text importance scoring failed');
  }
}

// 인터페이스에 에러 타입을 명시 (TypeScript에서는 JSDoc 또는 반환 타입으로 표현)
interface TextImportanceScorer {
  isImportant(text: string): boolean; // throws TextImportanceScorerError
}

class ModelBasedScorer implements TextImportanceScorer {
  isImportant(text: string): boolean {
    try {
      return this.model.predict(text) >= MODEL_THRESHOLD;
    } catch (e) {
      // 구현 세부사항(PredictionModelError)을 레이어에 맞는 에러로 래핑
      throw new TextImportanceScorerError(e as Error);
    }
  }
}

class TextSummarizer {
  summarizeText(text: string): string {
    try {
      return findParagraphs(text)
        .filter(p => this.importanceScorer.isImportant(p))
        .join('\n\n');
    } catch (e) {
      // TextImportanceScorerError를 TextSummarizerError로 래핑
      throw new TextSummarizerError(e as Error);
    }
  }
}
// 호출자는 구현 세부사항을 전혀 모른 채 에러 처리 가능
function updateTextSummary(ui: UserInterface): void {
  try {
    const summary = textSummarizer.summarizeText(ui.getUserText());
    ui.getSummaryField().setValue(summary);
  } catch (e) {
    if (e instanceof TextSummarizerError) { // 레이어에 맞는 에러만 처리
      ui.getSummaryField().setError("Unable to summarize text");
    }
  }
}

원본 에러 정보는 cause에 보존되어 디버깅에 활용 가능.


## 핵심 요약

모듈화의 핵심 목표: 요구사항 변경 시 관련된 코드 한 곳만 수정하면 되도록 설계

모듈화를 달성하기 위한 기법들:

  1. 의존성 주입 (Dependency Injection) — 의존 객체를 외부에서 주입하여 재구성 가능하게
  2. 인터페이스에 의존 (Depend on interfaces) — 구체 클래스보다 추상 인터페이스에 의존
  3. 컴포지션 우선 (Composition over inheritance) — 상속 대신 인터페이스 + 컴포지션으로 계층 구조 표현
  4. 클래스는 자기 자신에만 관심 (Classes care about themselves) — 다른 클래스 내부 구조를 아는 로직은 해당 클래스로 이동
  5. 관련 데이터 캡슐화 (Encapsulate related data) — 불가분한 데이터는 하나의 객체로 묶어 전달
  6. 반환 타입/예외에서 구현 세부사항 노출 금지 — 레이어의 추상화 수준에 맞는 타입/에러만 노출

Chapter 9. 코드를 재사용 가능하고 일반화 가능하게 만들어라

같은 하위 문제(subproblem)는 반복해서 등장한다. 이미 해결된 문제를 재사용할 수 있다면 시간을 절약하고 버그를 줄일 수 있다. 하지만 코드가 특정 가정에 묶여 있거나 다른 로직과 뒤엉켜 있다면 재사용이 불가능하다. 이 챕터는 코드를 안전하게 재사용 가능하고 다양한 문제에 일반화할 수 있도록 작성하는 방법을 다룬다.


9.1 가정 주의

핵심 개념

  • 가정(assumption)은 코드를 단순하거나 효율적으로 만들 수 있지만, 코드를 더 취약하고 덜 범용적으로 만든다.
  • 가정은 코드 깊숙이 숨어 있어서 다른 엔지니어가 인지하지 못한 채 함정에 빠질 수 있다.
  • 가정의 비용(취약성)이 이점(성능·단순화)보다 큰지 항상 따져봐야 한다.

9.1.1 가정이 유발하는 재사용 버그

Bad Code (가정이 숨어있는 코드)

class Article {
  private sections: Section[];

  getAllImages(): Image[] {
    for (const section of this.sections) {
      if (section.containsImages()) {
        // 이미지를 포함하는 섹션은 최대 하나뿐이라고 가정함
        return section.getImages(); // 첫 번째 섹션만 반환하고 종료
      }
    }
    return [];
  }
}
  • getAllImages()라는 이름을 보면 “모든” 이미지를 반환할 것 같지만, 실제로는 첫 번째 이미지 섹션만 반환한다.
  • 이미지가 여러 섹션에 걸쳐 있는 Article을 재사용하면 이미지 누락 버그 발생.
  • 루프를 일찍 종료해서 얻는 성능 이득은 사실상 무시할 수준이다.

조기 최적화(Premature Optimization) 코드 최적화는 항상 비용이 따른다. 읽기 어렵고, 유지보수가 힘들고, 가정이 추가될 경우 취약해진다. 수백만 번 실행되는 코드가 아니라면 최적화보다 가독성과 견고성을 우선하라.

9.1.2 해결책: 불필요한 가정을 제거하라

Good Code (가정을 제거한 코드)

class Article {
  private sections: Section[];

  getAllImages(): Image[] {
    const images: Image[] = [];
    for (const section of this.sections) {
      images.push(...section.getImages()); // 모든 섹션에서 이미지 수집
    }
    return images;
  }
}
  • 루프를 몇 번 더 돌더라도 성능에 체감 차이가 없다.
  • 어떤 Article 구조에서도 올바르게 동작한다.

9.1.3 해결책: 가정의 강제

가정의 이점이 비용을 넘어선다면, 그 가정이 깨졌을 때 즉시 실패(fail fast) 하도록 강제해야 한다.

두 가지 접근법:

방법 설명
가정을 깰 수 없게 만들기 컴파일 타임에 위반 불가능하도록 타입 시스템 활용
에러 시그널링 기법 사용 런타임에 위반 감지 후 즉시 오류 발생

Bad Code (강제하지 않는 가정)

class Article {
  private sections: Section[];

  getImageSection(): Section | null {
    // 이미지 섹션은 최대 하나뿐이라고 가정
    const imageSections = this.sections.filter(s => s.containsImages());
    return imageSections[0] ?? null; // 여러 개여도 조용히 첫 번째만 반환
  }
}
  • 이미지 섹션이 여러 개여도 오류 없이 첫 번째만 반환한다.
  • 렌더러가 이를 모른 채 사용하면 이미지 누락이 조용히 발생한다.

Good Code (가정을 강제하는 코드)

class Article {
  private sections: Section[];

  // 함수 이름 자체가 "하나뿐임"을 전달한다
  getOnlyImageSection(): Section | null {
    const imageSections = this.sections.filter(s => s.containsImages());

    // 가정 위반 시 즉시 오류 발생 (fail fast)
    if (imageSections.length > 1) {
      throw new Error("Article contains multiple image sections");
    }

    return imageSections[0] ?? null;
  }
}
  • 함수 이름(getOnlyImageSection) 이 가정을 호출자에게 명시적으로 전달한다.
  • throw를 통해 가정이 깨지면 즉시 실패하여 조용한 버그를 방지한다.

9.2 전역 상태 주의

핵심 개념

  • 전역 상태(global state): 프로그램의 특정 인스턴스 내 모든 컨텍스트가 공유하는 변수.
  • TypeScript/JS에서는 window 객체의 프로퍼티, 모듈 레벨 변수, 싱글톤 등이 해당된다.
  • 전역 상태는 “어느 시점에도 이 코드는 한 가지 목적으로만 쓰일 것” 이라는 암묵적 가정을 내포한다.

9.2.1 전역 상태와 불안전한 재사용

Bad Code (전역 상태를 사용하는 쇼핑 바구니)

// 모듈 레벨 전역 상태
let globalItems: Item[] = [];

class ShoppingBasket {
  static addItem(item: Item): void {
    globalItems.push(item);
  }

  static getItems(): Item[] {
    return [...globalItems];
  }
}

class ViewItemWidget {
  constructor(private item: Item) {}

  addItemToBasket(): void {
    ShoppingBasket.addItem(this.item); // 전역 상태 수정
  }
}

class ViewBasketWidget {
  displayItems(): void {
    const items = ShoppingBasket.getItems(); // 전역 상태 읽기
    // ...
  }
}

이 코드가 깨지는 시나리오:

  • 서버 사이드에서 재사용 시 여러 유저가 같은 바구니를 공유하게 됨
  • “나중에 저장” 기능 추가 시 활성 바구니와 저장된 바구니가 뒤섞임
  • 신선식품용 별도 바구니 추가 시 두 바구니가 서로 간섭
graph TD
    A[전역 상태: globalItems] --> B[ViewItemWidget]
    A --> C[ViewBasketWidget]
    A --> D[새로운 코드]
    B -->|수정| A
    C -->|읽기| A
    D -->|수정/읽기| A
    style A fill:#ff6b6b,color:#fff
  • 최선의 경우: 재사용이 불가능하다는 걸 인지하고 중복 코드를 새로 작성
  • 최악의 경우: 인지하지 못한 채 재사용하여 심각한 버그 발생 (다른 사용자의 바구니가 노출되는 등)

9.2.2 해결책: 공유 상태의 의존성 주입

Good Code (의존성 주입을 사용하는 쇼핑 바구니)

// 인스턴스 변수로 변경 - 각 인스턴스가 독립적인 상태를 가짐
class ShoppingBasket {
  private items: Item[] = [];

  addItem(item: Item): void {
    this.items.push(item);
  }

  getItems(): Item[] {
    return [...this.items];
  }
}

class ViewItemWidget {
  constructor(
    private item: Item,
    private basket: ShoppingBasket // 의존성 주입
  ) {}

  addItemToBasket(): void {
    this.basket.addItem(this.item);
  }
}

class ViewBasketWidget {
  constructor(private basket: ShoppingBasket) {} // 의존성 주입

  displayItems(): void {
    const items = this.basket.getItems();
    // ...
  }
}

안전한 재사용 예시:

// 일반 상품 바구니
const normalBasket = new ShoppingBasket();
const normalBasketWidget = new ViewBasketWidget(normalBasket);

// 신선식품 바구니 - 완전히 독립적
const freshBasket = new ShoppingBasket();
const freshBasketWidget = new ViewBasketWidget(freshBasket);
// 두 바구니는 서로 절대 간섭하지 않음
graph TD
    A[ShoppingBasket 인스턴스 1\nItems: 일반 상품] --> B[ViewItemWidget]
    A --> C[ViewBasketWidget]
    D[ShoppingBasket 인스턴스 2\nItems: 신선식품] --> E[새로운 코드]
    style A fill:#51cf66,color:#fff
    style D fill:#51cf66,color:#fff

핵심 교훈: 전역 상태는 정보를 쉽게 공유할 수 있게 해주지만, 코드 재사용을 완전히 불안전하게 만든다. 공유 상태가 필요하다면 의존성 주입으로 어떤 코드가 어떤 상태를 공유하는지 명시적으로 제어하라.


9.3 기본 반환값의 적절한 사용

핵심 개념

  • 기본값(default value)은 사용성을 높여주지만, 두 가지 가정을 내포한다.
    1. 어떤 기본값이 적절한가
    2. 상위 코드가 기본값과 명시적으로 설정된 값을 구별하지 않아도 되는가
  • 저수준 코드에서의 기본값은 그 위의 모든 고수준 코드에 대한 가정이 되므로, 비용이 훨씬 크다.

9.3.1 저수준 코드 기본값의 문제

Bad Code (저수준 코드에 기본값이 박힌 경우)

class UserDocumentSettings {
  private font: Font | null;

  getPreferredFont(): Font {
    if (this.font !== null) {
      return this.font;
    }
    return Font.ARIAL; // 저수준 코드가 Arial을 기본값으로 결정
  }
}

문제점:

  • 사용자가 Arial을 명시적으로 선택한 건지, 아무것도 선택 안 한 건지 구별 불가
  • 조직 전체 기본 폰트 기능 추가 시 → 사용자가 선택하지 않은 경우를 감지할 방법이 없음
  • UserDocumentSettings 클래스가 사용자 설정 저장기본값 결정이라는 두 가지 서브 문제를 뒤섞고 있음

9.3.2 해결책: 고수준 코드에서 기본값 제공

Step 1: 저수준 코드는 null을 반환한다

class UserDocumentSettings {
  private font: Font | null;

  getPreferredFont(): Font | null {
    return this.font; // 설정이 없으면 null 반환, 기본값 결정은 상위에 위임
  }
}

Step 2: 기본값만을 담당하는 별도 클래스를 만든다

class DefaultDocumentSettings {
  getDefaultFont(): Font {
    return Font.ARIAL;
  }
}

Step 3: 고수준 코드에서 두 가지를 조합한다

class DocumentSettings {
  constructor(
    private userSettings: UserDocumentSettings,       // 의존성 주입
    private defaultSettings: DefaultDocumentSettings  // 의존성 주입
  ) {}

  getFont(): Font {
    // null 병합 연산자(??)로 간결하게 표현
    return this.userSettings.getPreferredFont() ?? this.defaultSettings.getDefaultFont();
  }
}
  • UserDocumentSettings는 이제 순수하게 사용자 설정만 담당한다.
  • 기본값 로직은 DefaultDocumentSettingsDocumentSettings에서 독립적으로 관리된다.
  • 조직 전체 기본 폰트 기능도 이 구조에서 쉽게 추가 가능하다.

Null 병합 연산자 활용: TypeScript의 ?? 연산자를 이용하면 null/undefined 처리를 간결하게 표현할 수 있다. value ?? defaultValue 형태로 value가 null이면 defaultValue를 사용한다.

Map의 getOrDefault 패턴: 일부 자료구조는 get(key, defaultValue) 형태의 API를 제공한다. 이는 호출자가 기본값을 직접 결정하게 하면서도 null 처리 보일러플레이트를 제거한다.


9.4 함수 파라미터 집중

핵심 개념

  • 함수가 실제로 필요한 것보다 더 많은 것을 파라미터로 받으면 재사용이 어려워진다.
  • 필요한 데이터만 파라미터로 받는 것이 코드를 더 재사용 가능하고 이해하기 쉽게 만든다.

9.4.1 과도한 파라미터와 재사용성

interface TextOptions {
  font: Font;
  fontSize: number;
  lineHeight: number;
  textColor: Color;
}

class TextBox {
  private textContainer: Element;

  setTextStyle(options: TextOptions): void {
    this.setFont(options.font);
    this.setFontSize(options.fontSize);
    this.setLineHeight(options.lineHeight);
    this.setTextColor(options); // 전체 options를 넘김
  }

  // 실제로는 textColor 하나만 필요한데, TextOptions 전체를 받는다
  setTextColor(options: TextOptions): void {
    this.textContainer.style.color = options.textColor.toHexRgb();
  }
}

재사용하려고 하면 이런 문제가 발생한다:

// 텍스트 색상만 빨간색으로 바꾸고 싶은데...
function styleAsWarning(textBox: TextBox): void {
  const style: TextOptions = {
    font: Font.ARIAL,       // 아무 의미 없는 가짜 값
    fontSize: 12,           // 아무 의미 없는 가짜 값
    lineHeight: 14,         // 아무 의미 없는 가짜 값
    textColor: Color.RED,   // 실제로 필요한 값
  };
  textBox.setTextColor(style);
  // 코드만 보면 폰트, 크기, 줄간격까지 바꾸는 것처럼 오해할 수 있다
}
  • 관계없는 가짜 값들이 코드 읽는 사람을 혼란스럽게 만든다.
  • 폰트, 크기, 줄간격도 변경되는 것처럼 오독 가능성이 생긴다.

9.4.2 해결책: 필요한 것만 받는 함수

Good Code

class TextBox {
  private textContainer: Element;

  setTextStyle(options: TextOptions): void {
    this.setFont(options.font);
    this.setFontSize(options.fontSize);
    this.setLineHeight(options.lineHeight);
    this.setTextColor(options.textColor); // Color만 추출해서 넘김
  }

  // Color 하나만 받는다 - 명확하고 재사용 가능
  setTextColor(color: Color): void {
    this.textContainer.style.color = color.toHexRgb();
  }
}

재사용이 이제 간결하고 명확하다:

function styleAsWarning(textBox: TextBox): void {
  textBox.setTextColor(Color.RED); // 의도가 완벽하게 명확하다
}

주의사항: 10개 데이터 중 8개가 필요한 경우라면 오히려 전체 객체를 넘기는 게 나을 수 있다. 8개를 개별 파라미터로 나열하면 모듈성을 해친다. 트레이드오프를 의식하며 판단하라.


9.5 제네릭 사용 고려

핵심 개념

  • 특정 하위 문제의 해결책이 어떤 타입에도 적용될 수 있다면, 특정 타입에 종속시키는 것은 낭비다.
  • 제네릭(Generics) 을 사용하면 타입을 나중에 결정할 수 있어 코드를 훨씬 일반화할 수 있다.
  • 추가 작업이 거의 없으면서도 재사용성과 일반성을 크게 향상시킬 수 있다.

9.5.1 특정 타입 종속의 한계

Bad Code (string에 하드코딩된 랜덤 큐)

class RandomizedQueue {
  private values: string[] = []; // string에 종속됨

  add(value: string): void {
    this.values.push(value);
  }

  // 랜덤하게 하나를 꺼내서 반환 (큐에서 제거)
  getNext(): string | null {
    if (this.values.length === 0) {
      return null;
    }
    const randomIndex = Math.floor(Math.random() * this.values.length);
    // 랜덤 인덱스의 요소와 마지막 요소를 교체 후 제거
    [this.values[randomIndex], this.values[this.values.length - 1]] =
      [this.values[this.values.length - 1], this.values[randomIndex]];
    return this.values.pop()!;
  }
}
  • 단어 게임에서는 완벽히 동작한다.
  • 그림 게임 팀이 동일한 로직이 필요해도 재사용 불가 → 중복 코드 작성 강제.

9.5.2 해결책: 제네릭을 사용하라

Good Code (제네릭을 사용한 랜덤 큐)

class RandomizedQueue<T> { // T는 타입 플레이스홀더
  private values: T[] = [];

  add(value: T): void {
    this.values.push(value);
  }

  getNext(): T | null {
    if (this.values.length === 0) {
      return null;
    }
    const randomIndex = Math.floor(Math.random() * this.values.length);
    [this.values[randomIndex], this.values[this.values.length - 1]] =
      [this.values[this.values.length - 1], this.values[randomIndex]];
    return this.values.pop()!;
  }
}

어떤 타입으로도 사용 가능:

// 단어 게임
const wordQueue = new RandomizedQueue<string>();
wordQueue.add("apple");
wordQueue.add("banana");
console.log(wordQueue.getNext()); // "apple" 또는 "banana"

// 그림 게임 - 동일한 코드를 재사용
const pictureQueue = new RandomizedQueue<Picture>();
pictureQueue.add(new Picture("cat.png"));

// 숫자 게임도 가능
const numberQueue = new RandomizedQueue<number>();
numberQueue.add(42);

제네릭과 null 반환: getNext()가 null을 반환하는 방식은 큐에 null 값을 저장하지 않는다는 가정 하에 동작한다. 만약 RandomizedQueue<string | null> 처럼 null 값 자체를 저장하고 싶다면, hasNext() 메서드를 별도로 제공하여 큐가 비었는지와 null 값을 구별할 수 있게 해야 한다.


## 핵심 요약

원칙 핵심 내용 해결책
가정 주의 가정은 코드를 취약하게 만들고 재사용 시 버그를 유발한다 불필요한 가정 제거, 필요한 가정은 강제(enforce)
전역 상태 주의 전역 상태는 재사용을 불안전하게 만드는 가장 비싼 가정이다 의존성 주입으로 상태를 명시적으로 공유
기본값 위치 저수준 코드의 기본값은 상위 모든 코드에 대한 가정이 된다 null 반환 + 고수준 코드에서 기본값 결정
파라미터 집중 필요 이상을 받는 함수는 재사용이 어렵고 혼란을 준다 함수가 실제로 필요한 것만 파라미터로 받기
제네릭 활용 특정 타입에 종속되면 동일 로직을 다른 타입에 재사용 불가 제네릭으로 타입을 추상화하여 일반화

전체 맥락: 이 챕터의 내용은 Chapter 2(추상화 계층)와 Chapter 8(모듈화)과 깊이 연결된다. 깨끗한 추상화 계층과 모듈화된 코드는 자연스럽게 재사용 가능한 코드로 이어지지만, 이 챕터에서 다룬 가정, 전역 상태, 기본값 위치, 파라미터 설계, 제네릭은 그것만으로는 커버되지 않는 별도의 고려사항들이다.

Chapter 10. 단위 테스트 원칙

Good Code, Bad Code | Tom Long


10.1 단위 테스트 기초

핵심 용어 정리

용어 설명
Code under test 테스트 대상이 되는 “실제 코드”
Test code 실제 코드를 검증하기 위해 작성하는 테스트 코드
Test case 특정 동작 또는 시나리오 하나를 테스트하는 단위 (보통 하나의 함수)
Test runner 테스트 파일을 받아 각 테스트 케이스를 실행하고 결과를 출력하는 도구

파일 구조 관례

  • 실제 코드와 테스트 코드는 1:1로 매핑되는 것이 일반적
    • GuestList.tsGuestList.test.ts
  • 같은 디렉토리에 두거나 별도의 __tests__ 폴더에 분리하는 방식 모두 사용됨

테스트 케이스의 3단 구조

Arrange (준비) → Act (실행) → Assert (검증)

일부 엔지니어는 Given / When / Then 이라는 용어를 선호하며, 코드 맥락에서는 동일한 의미다.

// 예시: GuestList 테스트 케이스
test('getAllGuests - VIP 게스트만 있을 때', () => {
  // Arrange
  const guest1 = new Guest('Test person 1');
  const guest2 = new Guest('Test person 2');
  const guestList = new GuestList();
  guestList.addVipGuest(guest1);
  guestList.addVipGuest(guest2);

  // Act
  const result = guestList.getAllGuests();

  // Assert
  expect(result).toEqual([guest1, guest2]);
});

원칙

  • 모든 실제 코드에는 반드시 테스트가 수반되어야 함
  • 실제 코드가 보이는 **모든 동작(behavior)**에 대해 테스트 케이스가 존재해야 함
  • 테스트가 없는 것이 가장 나쁜 상태이지만, 나쁜 테스트도 충분히 문제가 됨

10.2 좋은 단위 테스트의 조건

좋은 단위 테스트가 갖춰야 할 5가지 핵심 요소:

  1. 정확한 버그 감지 — 코드가 망가지면 테스트도 실패해야 함
  2. 구현 세부사항에 비종속 — 리팩토링 시 테스트가 불필요하게 깨지지 않아야 함
  3. 명확한 실패 메시지 — 어디가 왜 깨졌는지 즉시 파악 가능해야 함
  4. 이해하기 쉬운 테스트 코드 — 다른 엔지니어가 읽고 이해할 수 있어야 함
  5. 빠르고 간편한 실행 — 자주 실행되므로 느리면 개발 속도 전체가 저하됨

10.2.1 정확한 버그 감지

두 가지 역할

  • 초기 신뢰 확보: 새 코드 작성 시 실수를 미리 발견
  • 미래의 회귀(regression) 방지: 다른 엔지니어의 변경이 기존 코드를 망가뜨렸을 때 감지

Flaky 테스트의 위험성

  • 코드에 문제가 없는데도 때로는 실패하는 테스트를 Flaky 테스트라고 함
  • 원인: 랜덤성, 타이밍 기반 레이스 컨디션, 외부 시스템 의존
  • 단순한 시간 낭비가 아니라 “양치기 소년” 효과를 유발
    • 거짓 경보가 반복되면 엔지니어들이 테스트 실패를 무시하기 시작함
    • 결국 실제 버그도 감지하지 못하게 됨

테스트는 코드가 진짜 망가졌을 때만 실패해야 하고, 망가졌을 때는 반드시 실패해야 한다.


10.2.2 구현 세부사항에 비종속

두 가지 코드 변경 유형

유형 설명 테스트 변경 필요 여부
기능적 변경 (Functional Change) 외부에서 관찰 가능한 동작을 변경 필요함 (당연)
리팩토링 (Refactoring) 내부 구조 변경, 외부 동작은 동일 필요하지 않아야 함

테스트 접근 방식 비교

Approach A (구현 세부사항 테스트) Approach B (공개 API만 테스트)
private 함수 테스트 아니오
private 변수 직접 조작 아니오
리팩토링 후 상태 테스트가 깨져서 수정 필요 정확히 리팩토링했다면 테스트 통과
실수 감지 능력 낮음 높음

Approach B를 채택하면, 리팩토링 후 테스트가 통과하면 안전하다는 강력한 신호를 얻을 수 있다.

팁: 기능 변경과 리팩토링을 동시에 하지 말 것

  • 두 가지를 동시에 진행하면 어떤 동작 변경이 의도된 것인지, 실수인지 구분하기 어려워짐
  • 리팩토링 → 기능 변경 순으로 분리하여 진행할 것

10.2.3 명확한 실패 메시지

나쁜 실패 메시지 예시

Test case testGetEvents failed:
Expected: [Event@ea4a92b, Event@3c5a99da]
But was actually: [Event@3c5a99da, Event@ea4a92b]
  • 테스트 이름이 어떤 동작을 검증하는지 알 수 없음
  • 실패 메시지가 해독 불가능한 참조값을 출력

좋은 실패 메시지 예시

Test case testGetEvents_inChronologicalOrder failed:
Contents match, but order differs
Expected:
  [<Spaceflight, April 12, 1961>, <Moon Landing, July 20, 1969>]
But was actually:
  [<Moon Landing, July 20, 1969>, <Spaceflight, April 12, 1961>]
  • 테스트 이름만 봐도 어떤 동작 검증인지 명확
  • 실패 메시지에서 문제가 “시간순 정렬”임을 바로 파악 가능

실패 메시지를 명확하게 만드는 방법

  • 한 번에 하나씩 테스트할 것
  • 테스트 케이스에 설명적인 이름을 붙일 것
  • 하나의 거대한 테스트보다 여러 개의 작은 테스트로 분리할 것

10.2.4 이해하기 쉬운 테스트 코드

  • 테스트 실패 = “코드가 망가졌다”가 아니라 “코드의 동작이 변경되었다” 를 의미
  • 변경이 의도된 것인지 실수인지 판단하려면 테스트 코드를 이해할 수 있어야 함
  • 흔한 문제 두 가지:
    • 한 테스트에서 너무 많은 것을 테스트 (Chapter 11에서 상세 다룸)
    • 공유 테스트 셋업의 과다 사용 (Chapter 11에서 상세 다룸)
  • 테스트 코드는 코드 사용법을 파악하는 문서(instruction manual) 역할도 함

10.2.5 빠르고 간편한 실행

  • 대부분의 코드베이스는 presubmit check를 통해 관련 테스트가 모두 통과해야 코드를 제출할 수 있음
  • 테스트가 느리면 모든 코드 제출이 느려짐
  • 개발 중에도 수시로 실행하므로 느린 테스트는 생산성 전반에 악영향
  • 느린 테스트 → 테스트 회피 심리 유발 → 테스트 커버리지 하락

10.3 공개 API 집중과 중요 동작 검증

“공개 API만 테스트하라”는 원칙

  • 구현 세부사항이 아닌 사용자가 실제로 신경 쓰는 동작에 집중하게 만듦
  • 자연스럽게 구현 세부사항에 비종속적인 테스트로 이어짐
// 운동 에너지 계산 함수
function calculateKineticEnergyJ(massKg: number, speedMs: number): number {
  return 0.5 * massKg * Math.pow(speedMs, 2.0);
  // Math.pow를 쓰든 speedMs * speedMs를 쓰든 → 구현 세부사항
}

// 테스트는 반환값만 검증
test('calculateKineticEnergy - 올바른 값 반환', () => {
  const result = calculateKineticEnergyJ(3.0, 7.0);
  expect(result).toBeCloseTo(73.5, 10); // 부동소수점이므로 범위로 검증
});

10.3.1 중요한 동작이 공개 API 밖에 있을 수 있다

커피 자판기 비유

구분 예시
공개 API 입력 카드 결제, 음료 선택
공개 API 반환 선택한 음료
비공개 API 입력 (테스트 셋업 필요) 전원 연결, 물탱크 채우기, 원두 채우기
비공개 API 사이드 이펙트 (검증 필요) 재료 부족 시 기술자에게 알림 발송
구현 세부사항 (테스트 불필요) 물 가열 방식 (써모블록 vs 보일러)

코드 예시: AddressBook 클래스

class AddressBook {
  private server: ServerEndPoint;
  private emailAddressCache: Map<number, string>;

  // 공개 API: 이것만 보면 되는 것처럼 보이지만...
  lookupEmailAddress(userId: number): string | null {
    const cachedEmail = this.emailAddressCache.get(userId);
    if (cachedEmail !== undefined) return cachedEmail;
    return this.fetchAndCacheEmailAddress(userId);
  }

  // 구현 세부사항: 내부 캐싱 로직
  private fetchAndCacheEmailAddress(userId: number): string | null {
    const fetchedEmail = this.server.fetchEmailAddress(userId);
    if (fetchedEmail !== null) {
      this.emailAddressCache.set(userId, fetchedEmail);
    }
    return fetchedEmail;
  }
}
  • lookupEmailAddress() 가 공개 API이지만 테스트하려면 ServerEndPoint를 셋업해야 함
  • 중요한 동작: 동일한 userId로 반복 호출 시 서버에 중복 요청이 가지 않아야 함
    • 이것은 공개 API가 아니지만 중요한 동작 → 테스트 필요
    • 단, 이를 달성하기 위해 캐시를 쓰는지는 구현 세부사항 → 테스트 불필요

원칙 정리

  • 공개 API를 통해 테스트할 수 있다면 공개 API를 사용
  • 하지만 다음 경우에는 공개 API 외부와 상호작용이 필요할 수 있음:
    • 의존성 셋업: 서버나 DB를 시뮬레이션해야 할 때
    • 사이드 이펙트 검증: 서버 호출 횟수, 저장된 값 등을 확인해야 할 때
  • “공개 API만 테스트하라”와 “구현 세부사항을 테스트하지 말라”는 절대적 규칙이 아닌 지도 원칙

10.4 테스트 더블

실제 의존성을 테스트에 사용하는 것이 불가능하거나 바람직하지 않을 때, 의존성을 시뮬레이션하는 객체를 사용함.


10.4.1 Test Double을 사용하는 이유

세 가지 주요 이유

이유 설명
테스트 단순화 의존성 설정이 복잡하거나 하위 의존성이 많을 때
외부 세계를 테스트로부터 보호 실제 돈 이체, 실제 DB 쓰기 등 사이드 이펙트 방지
테스트를 외부 세계로부터 보호 외부 시스템의 비결정적(nondeterministic) 동작으로 인한 Flaky 방지

외부 세계 보호 예시

  • 결제 코드를 테스트할 때 실제 은행 계좌에서 돈이 빠져나가면 안 됨
  • 테스트 데이터가 실제 DB에 기록되어 사용자에게 노출되면 안 됨
  • 테스트가 실제 서버에 잘못된 요청을 보내 에러율이 올라가면 안 됨

테스트 보호 예시

  • 실제 DB에서 읽어오는 값이 수시로 변경 → Flaky 테스트 발생
  • Test Double로 항상 동일한 결정적(deterministic) 값을 반환하게 설정

10.4.2 Mocks

함수 호출 자체를 기록하고, 어떤 인자로 호출되었는지 검증하는 데 사용

  • 주로 코드가 사이드 이펙트를 일으키는 의존성을 시뮬레이션할 때 사용
  • 의존성에 특정 함수가 특정 인자로 호출되었는지 검증
// PaymentManager 코드
class PaymentManager {
  settleInvoice(customerBankAccount: BankAccount, invoice: Invoice): PaymentResult {
    customerBankAccount.debit(invoice.getBalance()); // 이 동작을 테스트해야 함
    return PaymentResult.paid(invoice.getId());
  }
}

// 테스트 (Jest 기준)
test('settleInvoice - 올바른 금액이 계좌에서 인출됨', () => {
  // Arrange
  const mockAccount = { debit: jest.fn(), credit: jest.fn(), getBalance: jest.fn() };
  const invoiceBalance = new MonetaryAmount(5.0, Currency.USD);
  const invoice = new Invoice(invoiceBalance, 'test-id');
  const paymentManager = new PaymentManager();

  // Act
  paymentManager.settleInvoice(mockAccount, invoice);

  // Assert
  expect(mockAccount.debit).toHaveBeenCalledTimes(1);
  expect(mockAccount.debit).toHaveBeenCalledWith(invoiceBalance);
});

10.4.3 Stubs

특정 함수가 호출될 때 미리 정해진 값을 반환하도록 설정

  • 주로 코드가 입력값을 받아오는 의존성을 시뮬레이션할 때 사용
  • 많은 테스트 도구에서 Mock 객체를 만들어야 Stub 기능도 사용 가능
// 수정된 PaymentManager: 잔액 확인 후 인출
class PaymentManager {
  settleInvoice(customerBankAccount: BankAccount, invoice: Invoice): PaymentResult {
    if (customerBankAccount.getBalance().isLessThan(invoice.getBalance())) {
      return PaymentResult.insufficientFunds(invoice.getId());
    }
    customerBankAccount.debit(invoice.getBalance());
    return PaymentResult.paid(invoice.getId());
  }
}

// 테스트: 잔액 부족 시나리오
test('settleInvoice - 잔액 부족 시 올바른 결과 반환', () => {
  // Arrange
  const invoiceBalance = new MonetaryAmount(10.0, Currency.USD);
  const invoice = new Invoice(invoiceBalance, 'test-id');

  const mockAccount = {
    debit: jest.fn(),
    credit: jest.fn(),
    getBalance: jest.fn().mockReturnValue(new MonetaryAmount(9.99, Currency.USD)), // Stub
  };

  const paymentManager = new PaymentManager();

  // Act
  const result = paymentManager.settleInvoice(mockAccount, invoice);

  // Assert
  expect(result.getStatus()).toBe('INSUFFICIENT_FUNDS');
});

10.4.4 Mock과 Stub의 문제점

문제 1: 비현실적인 테스트를 만들 수 있다
  • Mock/Stub을 설정하는 엔지니어의 잘못된 이해가 그대로 테스트에 반영됨
  • 코드에도 같은 오해가 있다면, 테스트는 통과하지만 실제로는 버그가 있는 상태
// 잘못된 테스트: 음수 금액으로 debit() 호출이 동작한다고 가정
test('settleInvoice - 음수 청구서 잔액 처리', () => {
  const mockAccount = { debit: jest.fn(), credit: jest.fn(), getBalance: jest.fn() };
  const invoiceBalance = new MonetaryAmount(-5.0, Currency.USD); // 음수!

  paymentManager.settleInvoice(mockAccount, invoice);

  // Mock은 그냥 호출을 기록할 뿐 — ArgumentException을 던지지 않음
  expect(mockAccount.debit).toHaveBeenCalledWith(invoiceBalance);
  // → 테스트 통과! 하지만 실제 BankAccountImpl은 음수 인자를 거부함
});

실제 BankAccount 인터페이스 명세:

interface BankAccount {
  /** @throws ArgumentError if called with a negative amount */
  debit(amount: MonetaryAmount): void;
  /** @throws ArgumentError if called with a negative amount */
  credit(amount: MonetaryAmount): void;
  getBalance(): MonetaryAmount;
}
문제 2: 구현 세부사항에 강하게 결합된다
// debit/credit을 직접 검증하는 테스트들이 있다고 가정
test('settleInvoice - 양수 청구서 → debit 호출 검증', () => {
  expect(mockAccount.debit).toHaveBeenCalledWith(invoiceBalance);
});

test('settleInvoice - 음수 청구서 → credit 호출 검증', () => {
  expect(mockAccount.credit).toHaveBeenCalledWith(invoiceBalance.absoluteAmount());
});

이후 리팩토링으로 transfer() 함수가 추가되면:

interface BankAccount {
  /** 금액이 음수면 출금 효과 */
  transfer(amount: MonetaryAmount): void;
}

// settleInvoice가 transfer()를 사용하도록 리팩토링됨
settleInvoice(...) {
  customerBankAccount.transfer(invoice.getBalance().negate());
}
  • 행동은 변하지 않았지만 debit/credit을 검증하던 모든 테스트가 실패
  • 리팩토링이 안전한지 판단하기 어려워짐

핵심 교훈

  • Mock은 “특정 함수가 호출되었는가”를 테스트 → 구현 방식을 고정
  • 이상적인 테스트는 “최종 결과가 올바른가”를 테스트 → 구현에 무관

10.4.5 Fakes

실제 의존성과 **동일한 코드 계약(code contract)**을 가지되, 외부 시스템 없이 동작하는 대체 구현체

  • 일반적으로 외부 통신 대신 멤버 변수로 상태를 저장
  • 실제 의존성을 관리하는 팀이 함께 유지보수해야 함 (계약이 바뀌면 Fake도 바뀌어야 하므로)
class FakeBankAccount implements BankAccount {
  private balance: MonetaryAmount;

  constructor(startingBalance: MonetaryAmount) {
    this.balance = startingBalance;
  }

  debit(amount: MonetaryAmount): void {
    if (amount.isNegative()) {
      throw new ArgumentError('Amount cannot be negative'); // 실제 계약과 동일하게 예외 처리
    }
    this.balance = this.balance.subtract(amount);
  }

  credit(amount: MonetaryAmount): void {
    if (amount.isNegative()) {
      throw new ArgumentError('Amount cannot be negative');
    }
    this.balance = this.balance.add(amount);
  }

  transfer(amount: MonetaryAmount): void {
    this.balance = this.balance.add(amount);
  }

  getBalance(): MonetaryAmount {
    return roundDownToNearest10(this.balance); // 실제 계약과 동일하게 반올림
  }

  // 테스트 전용 메서드: 실제 잔액 확인용
  getActualBalance(): MonetaryAmount {
    return this.balance;
  }
}
Fake가 더 현실적인 테스트를 만든다
test('settleInvoice - 음수 청구서 잔액 처리', () => {
  const fakeAccount = new FakeBankAccount(new MonetaryAmount(100.0, Currency.USD));
  const invoiceBalance = new MonetaryAmount(-5.0, Currency.USD);
  const invoice = new Invoice(invoiceBalance, 'test-id');
  const paymentManager = new PaymentManager();

  // FakeBankAccount.debit()이 음수 인자를 거부하므로 테스트가 실패함!
  // → 버그가 즉시 드러남
  paymentManager.settleInvoice(fakeAccount, invoice);

  // 올바른 구현이라면 잔액이 $105가 되어야 함 (환불 처리)
  expect(fakeAccount.getActualBalance()).toEqual(new MonetaryAmount(105.0, Currency.USD));
});
Fake가 구현 세부사항에 비종속적인 테스트를 만든다
// debit/credit/transfer 중 어떤 것을 호출하든 상관없이
// 최종 잔액이 올바른지만 검증
expect(fakeAccount.getActualBalance()).toEqual(new MonetaryAmount(105.0, Currency.USD));
  • 나중에 내부 구현이 바뀌어도 최종 결과가 동일하면 테스트 통과
  • 리팩토링에 안전한 테스트

10.4.6 Mocking에 대한 두 학파

구분 Mockist (London School) Classicist (Detroit School)
철학 실제 의존성 대신 Mock을 적극 사용 Mock/Stub 사용 최소화, 실제 의존성 선호
테스트 방식 상호작용(Interaction) 검증 결과(State) 검증
무엇을 고정하나 코드가 “어떻게” 하는지 코드가 “무엇을” 결과로 내는지
장점 단위 격리가 철저, 작성이 쉬움 더 현실적, 구현 변경에 강함
단점 비현실적 테스트, 구현에 종속 의존성 설정이 복잡할 수 있음

의존성 사용 우선순위 (저자 권장)

실제 의존성 → Fake → Mock/Stub (최후 수단)

Mock이나 Stub은 테스트가 없는 것보다는 낫지만, 가능하다면 Fake나 실제 의존성을 사용하는 것이 훨씬 낫다.


10.5 테스팅 철학의 취사선택

주요 테스팅 철학/방법론

Test-Driven Development (TDD)

  • 실제 코드 작성 전에 테스트를 먼저 작성
  • 최소한의 코드로 테스트를 통과시킨 후 리팩토링
  • 위 단계를 소규모 이터레이션으로 반복
  • 실제로 완전히 따르는 엔지니어는 많지 않지만, TDD의 다른 원칙들(격리, 집중, 구현 비종속)은 많이 채용됨

Behavior-Driven Development (BDD)

  • 사용자/고객/비즈니스 관점에서 소프트웨어가 보여야 할 **동작(behavior)**에 집중
  • 원하는 동작을 명세로 먼저 기록하고, 그것을 기준으로 개발
  • 소프트웨어의 속성보다 원하는 동작이 테스트에 반영되어야 함

Acceptance Test-Driven Development (ATDD)

  • 고객 관점의 기능을 식별하고 자동화된 인수 테스트를 먼저 작성
  • 모든 인수 테스트가 통과하면 소프트웨어 완성으로 간주
  • BDD와 중첩되는 부분이 있으며 정의는 조직마다 다름

핵심 메시지

테스팅 철학은 목표를 달성하기 위한 수단이지, 그 자체가 목적이 아니다.

  • 어떤 철학을 완전히 따르지 않아도 괜찮음
  • 자신에게 맞는 방식으로 좋은 테스트를 작성하고, 고품질 소프트웨어를 만드는 것이 궁극적인 목표
  • 다양한 철학에서 효과적인 부분을 골라 조합하는 것이 현실적인 접근

## 핵심 요약

  • 실제 코드에는 반드시 단위 테스트가 수반되어야 하며, 모든 동작에 대한 테스트 케이스가 존재해야 함
  • 테스트 케이스는 Arrange / Act / Assert 3단 구조로 작성
  • 좋은 단위 테스트의 5가지 조건: 정확한 버그 감지, 구현 비종속, 명확한 실패 메시지, 이해 가능한 코드, 빠른 실행
  • 실제 의존성 사용이 어려울 때 Test Double 사용:
    • Mock: 함수 호출을 기록하고 검증 (사이드 이펙트 시뮬레이션)
    • Stub: 미리 정해진 값을 반환 (입력 시뮬레이션)
    • Fake: 동일한 계약을 가진 단순화된 대체 구현체
  • Mock과 Stub은 비현실적 테스트구현 세부사항 결합이라는 위험 내포
  • 저자 권장 우선순위: 실제 의존성 > Fake > Mock/Stub
  • 테스팅 철학(TDD, BDD, ATDD)은 참고하되, 맹목적으로 따르지 말고 취사선택할 것

Chapter 11. 단위 테스트 실전

좋은 단위 테스트의 핵심 특징 (Ch.10 복습)

  • 정확한 버그 감지 : 코드가 잘못됐을 때만 실패해야 한다 (오탐 없음)
  • 구현 세부사항에 무관 : 리팩터링해도 테스트가 깨지지 않아야 한다
  • 명확한 실패 메시지 : 무엇이 왜 잘못됐는지 즉시 알 수 있어야 한다
  • 이해하기 쉬운 테스트 코드 : 다른 개발자가 무엇을 테스트하는지 파악 가능해야 한다
  • 빠르고 쉽게 실행 : 느리거나 복잡한 테스트는 생산성을 갉아먹는다

11.1 함수가 아닌 동작을 테스트하라

11.1.1 함수 중심 테스트의 한계

흔히 저지르는 실수는 클래스의 public 함수 개수만큼만 테스트를 작성하는 것이다. 하나의 함수는 여러 동작을 가질 수 있고, 하나의 동작이 여러 함수에 걸쳐 있을 수도 있다.

예시 — 모기지(주택담보대출) 심사 코드

const MORTGAGE_MULTIPLIER = 10.0;

function assess(customer: Customer): MortgageDecision {
  if (!isEligibleForMortgage(customer)) {
    return MortgageDecision.rejected();
  }
  return MortgageDecision.approve(getMaxLoanAmount(customer));
}

function isEligibleForMortgage(customer: Customer): boolean {
  return (
    customer.hasGoodCreditRating() &&
    !customer.hasExistingMortgage() &&
    !customer.isBanned()
  );
}

function getMaxLoanAmount(customer: Customer): MonetaryAmount {
  return customer.getIncome()
    .minus(customer.getOutgoings())
    .multiplyBy(MORTGAGE_MULTIPLIER);
}

나쁜 테스트 예시 — 함수 하나에 테스트 하나

test('assess', () => {
  const customer = new Customer({
    income: new MonetaryAmount(50000, Currency.USD),
    outgoings: new MonetaryAmount(20000, Currency.USD),
    hasGoodCreditRating: true,
    hasExistingMortgage: false,
    isBanned: false,
  });

  const decision = assess(customer);

  expect(decision.isApproved()).toBe(true);
  expect(decision.getMaxLoanAmount()).toEqual(new MonetaryAmount(300000, Currency.USD));
});
  • 승인 케이스 하나만 검증한다
  • 거절 케이스(신용불량, 기존 대출 보유, 블랙리스트)는 전혀 테스트하지 않는다
  • 이 상태에서 블랙리스트 고객도 승인되도록 코드를 바꿔도 테스트가 통과한다

11.1.2 해결책: 동작 중심 테스트

MortgageAssessor가 실제로 보장해야 하는 동작 목록:

  • 신용등급이 나쁜 고객 → 거절
  • 기존 대출이 있는 고객 → 거절
  • 블랙리스트 고객 → 거절
  • 적격 고객 → 승인
  • 최대 대출금액 = (소득 - 지출) x 10
  • 지출이 소득을 초과하는 엣지 케이스

테스트 코드량이 실제 코드보다 적다면, 충분히 테스트하지 않고 있다는 경고 신호일 수 있다. 100줄짜리 코드에 300줄 테스트는 완전히 정상이다.

모든 동작이 테스트됐는지 자가 점검 방법
점검 질문 Yes라면
코드 한 줄을 지워도 컴파일되고 테스트가 통과하는가? 동작이 누락됨
if 조건의 부등호를 뒤집어도 테스트가 통과하는가? 동작이 누락됨
&&||로,+-로 바꿔도 통과하는가? 동작이 누락됨
상수값을 바꿔도 테스트가 통과하는가? 동작이 누락됨

Mutation Testing: 코드를 자동으로 조금씩 변형(mutate)한 뒤 테스트가 실패하는지 확인하는 도구. 테스트 커버리지의 실질적 품질을 측정할 수 있다.

에러 시나리오도 빠뜨리지 마라
class BankAccount {
  debit(amount: MonetaryAmount): void {
    if (amount.isNegative()) {
      throw new Error("Amount can't be negative");
    }
    // ...
  }
}
test('debit — 음수 금액이면 에러를 던진다', () => {
  const negativeAmount = new MonetaryAmount(-0.01, Currency.USD);
  const bankAccount = new BankAccount();

  expect(() => bankAccount.debit(negativeAmount))
    .toThrow("Amount can't be negative");
});

에러 시나리오는 엣지 케이스처럼 보여도, 코드의 중요한 동작이므로 반드시 테스트해야 한다.


11.2 테스트를 위해 가시성을 높이지 마라

11.2.1 private 함수 직접 테스트의 위험성

isEligibleForMortgage를 테스트하기 위해 public으로 바꾸는 경우를 생각해보자.

class MortgageAssessor {
  assess(customer: Customer): MortgageDecision { /* ... */ }

  /** @visibleForTesting */
  static isEligibleForMortgage(customer: Customer): boolean {
    return (
      customer.hasGoodCreditRating() &&
      !customer.hasExistingMortgage() &&
      !customer.isBanned()
    );
  }
}
// 잘못된 테스트
test('isEligibleForMortgage — 신용불량이면 false를 반환한다', () => {
  const customer = new Customer({ hasGoodCreditRating: false, /* ... */ });
  expect(MortgageAssessor.isEligibleForMortgage(customer)).toBe(false);
});

이 접근법의 3가지 문제점

  1. 실제로 중요한 동작을 테스트하지 않는다

    • 우리가 원하는 건 “신용불량 고객의 대출이 거절된다”는 것
    • 이 테스트는 isEligibleForMortgagefalse를 반환하는 것만 확인할 뿐, assess()가 그 결과를 올바르게 사용하는지는 보장하지 않는다
  2. 구현 세부사항에 종속된 테스트가 된다

    • 함수 이름 변경, 다른 클래스로 이동 등의 리팩터링 시 테스트가 깨진다
  3. 사실상 public API가 늘어나는 것과 같다

    • @visibleForTesting 주석은 쉽게 무시되어 다른 코드에서 호출하기 시작한다
    • 나중에 변경하기 어려운 강결합이 생긴다

11.2.2 해결책: public API를 통한 테스트

test('assess — 신용불량 고객의 대출은 거절된다', () => {
  const customer = new Customer({
    income: new MonetaryAmount(50000, Currency.USD),
    outgoings: new MonetaryAmount(25000, Currency.USD),
    hasGoodCreditRating: false,
    hasExistingMortgage: false,
    isBanned: false,
  });
  const assessor = new MortgageAssessor();

  const decision = assessor.assess(customer);  // public API만 사용

  expect(decision.isApproved()).toBe(false);
});
  • private 함수를 노출할 필요가 없다
  • 실제로 중요한 동작(대출 거절)을 검증한다
  • 리팩터링해도 테스트가 깨지지 않는다

11.2.3 해결책: 코드 단위 분리

클래스가 너무 많은 일을 하면 public API만으로 테스트하기 어려워진다. 이는 추상화 레이어가 너무 두껍다(too thick)는 신호다.

문제 상황 — 신용점수 서비스 호출 로직이 섞여 있는 경우

class MortgageAssessor {
  private readonly creditScoreService: CreditScoreService;

  /** @visibleForTesting */
  isCreditRatingGood(customerId: number): Result<boolean, Error> {
    const response = this.creditScoreService.query(customerId);
    if (response.errorOccurred()) {
      return Result.ofError(response.getError());
    }
    return Result.ofValue(response.getCreditScore() >= 880.0);
  }
}

해결 — 별도 클래스로 분리

// 신용등급 확인이라는 하위 문제를 전담하는 클래스
class CreditRatingChecker {
  private static readonly GOOD_CREDIT_SCORE_THRESHOLD = 880.0;
  private readonly creditScoreService: CreditScoreService;

  isCreditRatingGood(customerId: number): Result<boolean, Error> {
    const response = this.creditScoreService.query(customerId);
    if (response.errorOccurred()) {
      return Result.ofError(response.getError());
    }
    return Result.ofValue(
      response.getCreditScore() >= CreditRatingChecker.GOOD_CREDIT_SCORE_THRESHOLD
    );
  }
}

// MortgageAssessor는 이제 CreditRatingChecker에 의존
class MortgageAssessor {
  private readonly creditRatingChecker: CreditRatingChecker;

  private isEligibleForMortgage(customer: Customer): Result<boolean, Error> {
    if (customer.hasExistingMortgage() || customer.isBanned()) {
      return Result.ofValue(false);
    }
    return this.creditRatingChecker.isCreditRatingGood(customer.getId());
  }
}

분리 후 테스트 구조

MortgageAssessorTest
  - 고객 적격 여부 시나리오
  - 기존 대출 보유
  - 블랙리스트
  - 나쁜 신용등급 (CreditRatingChecker mock 사용)
  - 대출 금액 계산

CreditRatingCheckerTest
  - 서비스 호출 에러
  - 점수가 임계값 미만
  - 점수가 임계값 이상
  - 점수가 임계값과 정확히 같음

각 클래스가 더 적은 개념을 다루므로, 각자의 public API를 통해 완전하게 테스트할 수 있다.


11.3 한 번에 하나의 동작만 테스트하라

11.3.1 복합 테스트의 문제점

예시 — 유효한 쿠폰 필터링 코드

function getValidCoupons(coupons: Coupon[], customer: Customer): Coupon[] {
  return coupons
    .filter(coupon => !coupon.alreadyRedeemed())
    .filter(coupon => !coupon.hasExpired())
    .filter(coupon => coupon.issuedTo() === customer)
    .sort((a, b) => b.getValue() - a.getValue()); // 내림차순
}

테스트해야 할 동작들:

  • 유효한 쿠폰은 포함된다
  • 이미 사용된 쿠폰은 제외된다
  • 만료된 쿠폰은 제외된다
  • 다른 고객에게 발급된 쿠폰은 제외된다
  • 결과는 금액 내림차순으로 정렬된다

나쁜 테스트 — 모든 동작을 한 번에

test('getValidCoupons — 모든 동작', () => {
  const customer1 = new Customer('test customer 1');
  const customer2 = new Customer('test customer 2');
  const redeemed = new Coupon({ alreadyRedeemed: true, hasExpired: false, issuedTo: customer1, value: 100 });
  const expired = new Coupon({ alreadyRedeemed: false, hasExpired: true, issuedTo: customer1, value: 100 });
  const issuedToSomeoneElse = new Coupon({ alreadyRedeemed: false, hasExpired: false, issuedTo: customer2, value: 100 });
  const valid1 = new Coupon({ alreadyRedeemed: false, hasExpired: false, issuedTo: customer1, value: 100 });
  const valid2 = new Coupon({ alreadyRedeemed: false, hasExpired: false, issuedTo: customer1, value: 150 });

  const result = getValidCoupons(
    [redeemed, expired, issuedToSomeoneElse, valid1, valid2],
    customer1
  );

  expect(result).toEqual([valid2, valid1]);
});

문제점

  • 테스트 이름만 봐서는 어떤 동작이 깨졌는지 알 수 없다
  • 실패 메시지가 불명확하다: 기대값과 실제값의 차이를 보고 alreadyRedeemed 로직이 깨진 건지, 정렬이 깨진 건지 파악하기 어렵다

11.3.2 해결책: 동작별 테스트 케이스 분리

test('getValidCoupons — 유효한 쿠폰은 포함된다', () => {
  const customer = new Customer('test customer');
  const valid = new Coupon({ alreadyRedeemed: false, hasExpired: false, issuedTo: customer, value: 100 });

  const result = getValidCoupons([valid], customer);

  expect(result).toContain(valid);
});

test('getValidCoupons — 이미 사용된 쿠폰은 제외된다', () => {
  const customer = new Customer('test customer');
  const redeemed = new Coupon({ alreadyRedeemed: true, hasExpired: false, issuedTo: customer, value: 100 });

  const result = getValidCoupons([redeemed], customer);

  expect(result).toHaveLength(0);
});

test('getValidCoupons — 만료된 쿠폰은 제외된다', () => { /* ... */ });
test('getValidCoupons — 다른 고객 쿠폰은 제외된다', () => { /* ... */ });
test('getValidCoupons — 금액 내림차순으로 정렬된다', () => { /* ... */ });

개선 효과

  • 실패한 테스트 이름 자체가 어떤 동작이 깨졌는지 즉시 알려준다
  • 각 테스트 코드가 간결하고 이해하기 쉽다
  • 의도적으로 한 동작을 변경했을 때, 다른 동작이 영향받는지 명확하게 알 수 있다

11.3.3 파라미터화 테스트

유사한 구조에서 다른 값만 바뀌는 테스트는 test.each로 중복을 줄일 수 있다.

test.each([
  [true, false, 'alreadyRedeemed'],
  [false, true, 'expired'],
])(
  'getValidCoupons — 유효하지 않은 쿠폰은 제외된다 (%s)',
  (alreadyRedeemed: boolean, hasExpired: boolean, _name: string) => {
    const customer = new Customer('test customer');
    const coupon = new Coupon({
      alreadyRedeemed,
      hasExpired,
      issuedTo: customer,
      value: 100,
    });

    const result = getValidCoupons([coupon], customer);

    expect(result).toHaveLength(0);
  }
);

주의: 파라미터 세트마다 이름을 명시적으로 지정하면 실패 메시지에 어떤 케이스가 깨졌는지 표시된다. 이름을 생략하면 실패 메시지가 불명확해질 수 있다.


11.4 공유 테스트 설정의 적절한 사용

대부분의 테스트 프레임워크는 공유 설정/해제 블록을 제공한다.

블록 실행 시점
beforeAll 모든 테스트 케이스 실행 전 딱 한 번
beforeEach 각 테스트 케이스 실행 전마다
afterEach 각 테스트 케이스 실행 후마다
afterAll 모든 테스트 케이스 실행 후 딱 한 번

공유 설정은 양날의 검이다. 코드 중복을 줄여주지만, 잘못 쓰면 테스트를 취약하고 비효율적으로 만든다.

11.4.1 공유 상태의 위험성

class OrderManagerTest {
  private database!: Database;

  beforeAll(() => {
    // 비싼 초기화 — 모든 테스트에서 동일한 인스턴스 공유
    this.database = Database.createInstance();
    this.database.waitForReady();
  });

  test('processOrder — 재고 없는 아이템이면 주문이 지연된다', () => {
    const orderId = 12345;
    const order = new Order({ orderId, containsOutOfStockItem: true, isPaymentComplete: true });
    const orderManager = new OrderManager(this.database);

    orderManager.processOrder(order);

    // 이 테스트 후 database에 DELAYED 상태가 남아 있다
    expect(this.database.getOrderStatus(orderId)).toBe(OrderStatus.DELAYED);
  });

  test('processOrder — 결제 미완료이면 주문이 지연된다', () => {
    const orderId = 12345; // 같은 orderId!
    const order = new Order({ orderId, containsOutOfStockItem: false, isPaymentComplete: false });
    const orderManager = new OrderManager(this.database);

    orderManager.processOrder(order); // 만약 이 로직이 망가져도...

    // 이전 테스트가 이미 DELAYED를 저장해뒀으므로 통과해버린다!
    expect(this.database.getOrderStatus(orderId)).toBe(OrderStatus.DELAYED);
  });
}

문제: 앞 테스트가 DB에 남긴 상태 때문에 뒤 테스트가 코드가 망가져도 통과할 수 있다.

11.4.2 해결책: 상태를 공유하지 않거나, 반드시 초기화하라

방법 1 — 테스트마다 새 인스턴스 생성 (가능하다면)

beforeEach(() => {
  this.database = Database.createInstance();
});

방법 2 — FakeDatabase 사용

생성 비용이 낮은 테스트 더블로 대체하면 상태 공유 문제 자체가 사라진다.

방법 3 — afterEach로 상태 초기화 (불가피할 때)

afterEach(() => {
  this.database.reset(); // 각 테스트 후 상태 초기화
});

전역 상태(Global State) 주의: 코드 자체가 전역 상태를 유지하는 경우, 테스트 코드에서도 이를 초기화해야 한다. 전역 상태를 피하는 것이 좋은 또 하나의 이유다.

11.4.3 공유 설정의 위험성

class OrderPostageManagerTest {
  private testOrder!: Order;

  beforeEach(() => {
    // 3개 아이템으로 공유 설정
    this.testOrder = new Order({
      customer: new Customer({ address: new Address('Test address') }),
      items: [
        new Item({ name: 'Test item 1' }),
        new Item({ name: 'Test item 2' }),
        new Item({ name: 'Test item 3' }),
      ],
    });
  });

  test('getPostageLabel — 3개 아이템이면 대형 패키지다', () => {
    // testOrder가 정확히 3개 아이템인 것에 의존한다
    const label = new PostageManager().getPostageLabel(this.testOrder);
    expect(label.isLargePackage()).toBe(true);
  });
}

이후 다른 개발자가 위험물 테스트를 추가하면서 공유 설정을 수정하면:

beforeEach(() => {
  this.testOrder = new Order({
    items: [
      new Item({ name: 'Test item 1' }),
      new Item({ name: 'Test item 2' }),
      new Item({ name: 'Test item 3' }),
      new Item({ name: 'Hazardous item', isHazardous: true }), // 4번째 추가
    ],
  });
});
  • 3개 아이템이면 대형 패키지다 테스트가 이제 4개 아이템을 검사하게 된다
  • 테스트는 여전히 통과하지만, 사실은 의도와 다른 것을 테스트하고 있다

11.4.4 해결책: 테스트 내부에서 핵심 설정 정의

헬퍼 함수를 만들어 중복 코드를 줄이되, 테스트 결과에 영향을 주는 값은 각 테스트 안에서 직접 지정한다.

test('getPostageLabel — 3개 아이템이면 대형 패키지다', () => {
  // 이 테스트에게 중요한 값(아이템 3개)을 직접 명시
  const order = createOrderWithItems([
    new Item({ name: 'Test item 1' }),
    new Item({ name: 'Test item 2' }),
    new Item({ name: 'Test item 3' }),
  ]);
  const label = new PostageManager().getPostageLabel(order);
  expect(label.isLargePackage()).toBe(true);
});

test('getPostageLabel — 위험물 아이템이면 위험물 패키지로 표시된다', () => {
  const order = createOrderWithItems([
    new Item({ name: 'Hazardous item', isHazardous: true }),
  ]);
  const label = new PostageManager().getPostageLabel(order);
  expect(label.isHazardous()).toBe(true);
});

// 헬퍼 함수: 보일러플레이트만 처리, 테스트 결과에 영향 없는 값들
function createOrderWithItems(items: Item[]): Order {
  return new Order({
    customer: new Customer({ address: new Address('Test address') }),
    items,
  });
}

11.4.5 공유 설정이 적합한 경우

테스트 결과와 무관한 필수 설정은 공유해도 안전하다.

class OrderPostageManagerTest {
  // 이 메타데이터는 PostageManager가 무시하므로 결과에 영향 없음
  private static readonly ORDER_METADATA = new OrderMetadata({
    timestamp: new Date(0),
    serverIp: new IpAddress(0, 0, 0, 0),
  });

  // 헬퍼 함수에서 공유 상수 사용
  private createOrderWithItems(items: Item[]): Order {
    return new Order({
      metadata: OrderPostageManagerTest.ORDER_METADATA, // 무관한 필수값
      customer: new Customer({ address: new Address('Test address') }),
      items,
    });
  }
}

공유 설정 사용 원칙 정리

  • 테스트 결과에 영향을 주는 값 → 각 테스트 케이스 안에서 직접 정의
  • 테스트 결과에 영향을 주지 않는 필수값 → 공유 설정에 두어도 무방
  • 상태가 변하는(mutable) 공유 객체 → 가능하면 피하고, 불가피하면 afterEach로 초기화

11.5 적절한 어서션 매처를 사용하라

11.5.1 부적절한 매처의 문제

예시 — TextWidget의 클래스 이름 반환

class TextWidget {
  private static readonly STANDARD_CLASS_NAMES = ['text-widget', 'selectable'];
  private readonly customClassNames: string[];

  constructor(customClassNames: string[]) {
    this.customClassNames = [...customClassNames];
  }

  /**
   * 반환되는 클래스 이름의 순서는 보장되지 않음
   */
  getClassNames(): string[] {
    return [...TextWidget.STANDARD_CLASS_NAMES, ...this.customClassNames];
  }
}

나쁜 예 1 — 과도한 제약(isEqualTo)

test('getClassNames — 커스텀 클래스 이름을 포함한다', () => {
  const widget = new TextWidget(['custom_class_1', 'custom_class_2']);

  // 문제 1: 표준 클래스까지 검사한다 (이 테스트의 목적이 아님)
  // 문제 2: 순서가 바뀌면 실패한다 (문서에서 순서 미보장이라고 명시함)
  expect(widget.getClassNames()).toEqual([
    'text-widget', 'selectable', 'custom_class_1', 'custom_class_2',
  ]);
});

나쁜 예 2 — toBe(true) 사용

test('getClassNames — 커스텀 클래스 이름을 포함한다', () => {
  const widget = new TextWidget(['custom_class_1', 'custom_class_2']);
  const result = widget.getClassNames();

  // 실패 시 메시지: "Expected: true, Received: false" — 무엇이 빠진 건지 모른다
  expect(result.includes('custom_class_1')).toBe(true);
  expect(result.includes('custom_class_2')).toBe(true);
});

11.5.2 해결책: 적절한 매처 사용

test('getClassNames — 커스텀 클래스 이름을 포함한다', () => {
  const widget = new TextWidget(['custom_class_1', 'custom_class_2']);

  // expect(array).toEqual(expect.arrayContaining([...]))
  // - 순서에 무관하게 해당 원소들이 있는지만 확인
  // - 다른 원소(표준 클래스)가 있어도 실패하지 않음
  expect(widget.getClassNames()).toEqual(
    expect.arrayContaining(['custom_class_1', 'custom_class_2'])
  );
});

실패 메시지 비교

// toBe(true) 사용 시
Expected: true
Received: false

// expect.arrayContaining 사용 시
Expected value to equal:
  ArrayContaining ["custom_class_1", "custom_class_2"]
Received:
  ["text-widget", "selectable", "custom_class_2"]

Difference: missing "custom_class_1"

적절한 매처는 코드 가독성도 향상시킨다:

// 더 자연스러운 문장처럼 읽힌다
expect(someList).toContain('expected value');

// 어색하다
expect(someList.includes('expected value')).toBe(true);

11.6 의존성 주입과 테스트 용이성

11.6.1 하드코딩된 의존성의 한계

class InvoiceReminder {
  private readonly addressBook: AddressBook;
  private readonly emailSender: EmailSender;

  constructor() {
    // 의존성을 직접 생성 — 외부에서 교체 불가능
    this.addressBook = DataStore.getAddressBook();
    this.emailSender = new EmailSenderImpl();
  }

  sendReminder(invoice: Invoice): boolean {
    const address = this.addressBook.lookupEmailAddress(invoice.getCustomerId());
    if (address === null) return false;
    return this.emailSender.send(address, InvoiceReminderTemplate.generate(invoice));
  }
}

테스트할 수 없는 이유

  • DataStore.getAddressBook(): 테스트 환경에서는 실제 DB에 접근 권한이 없거나, 데이터가 변해 테스트가 불안정(flaky)해진다
  • new EmailSenderImpl(): 테스트 실행 시 실제 이메일이 발송된다

테스트 더블(FakeAddressBook, FakeEmailSender)을 쓰고 싶어도, InvoiceReminder를 그 더블로 구성할 방법이 없다.

11.6.2 해결책: 의존성 주입 사용

class InvoiceReminder {
  private readonly addressBook: AddressBook;
  private readonly emailSender: EmailSender;

  // 의존성을 생성자로 주입받음
  constructor(addressBook: AddressBook, emailSender: EmailSender) {
    this.addressBook = addressBook;
    this.emailSender = emailSender;
  }

  // 실사용자를 위한 정적 팩터리 함수 — 편의성 유지
  static create(): InvoiceReminder {
    return new InvoiceReminder(
      DataStore.getAddressBook(),
      new EmailSenderImpl()
    );
  }

  sendReminder(invoice: Invoice): boolean {
    const address = this.addressBook.lookupEmailAddress(invoice.getCustomerId());
    if (address === null) return false;
    return this.emailSender.send(address, InvoiceReminderTemplate.generate(invoice));
  }
}

테스트 코드

test('sendReminder — 주소가 있으면 이메일을 발송하고 true를 반환한다', () => {
  const fakeAddressBook = new FakeAddressBook();
  fakeAddressBook.addEntry({ customerId: 123456, emailAddress: 'test@example.com' });
  const fakeEmailSender = new FakeEmailSender();

  // 테스트 더블로 손쉽게 구성
  const invoiceReminder = new InvoiceReminder(fakeAddressBook, fakeEmailSender);

  const result = invoiceReminder.sendReminder(new Invoice({ customerId: 123456 }));

  expect(result).toBe(true);
  expect(fakeEmailSender.sentEmails()).toHaveLength(1);
});

test('sendReminder — 주소를 찾을 수 없으면 이메일을 보내지 않고 false를 반환한다', () => {
  const fakeAddressBook = new FakeAddressBook(); // 항목 없음
  const fakeEmailSender = new FakeEmailSender();
  const invoiceReminder = new InvoiceReminder(fakeAddressBook, fakeEmailSender);

  const result = invoiceReminder.sendReminder(new Invoice({ customerId: 999 }));

  expect(result).toBe(false);
  expect(fakeEmailSender.sentEmails()).toHaveLength(0);
});

테스트 용이성과 모듈성: 의존성 주입은 코드를 느슨하게 결합(loosely coupled)하고 재구성 가능하게 만들어, 모듈성과 테스트 용이성을 동시에 높인다. Ch.1에서 다뤘듯, 이 둘은 밀접하게 연관되어 있다.


11.7 테스트에 관한 마지막 제언

단위 테스트는 개발자가 가장 자주 다루는 테스트 레벨이지만, 전부가 아니다.

테스트의 레벨

  • 단위 테스트 (Unit Test): 개별 함수/클래스 수준의 동작 검증. 가장 빠르고 빈번하게 실행.
  • 통합 테스트 (Integration Test): 여러 컴포넌트나 서브시스템이 연결됐을 때 올바르게 동작하는지 검증.
  • E2E 테스트 (End-to-End Test): 전체 시스템을 처음부터 끝까지 실제 사용자 흐름으로 검증. (예: 브라우저 자동화)

테스트의 유형 (참고)

  • 회귀 테스트 (Regression Testing): 기존 기능이 의도치 않게 변경되지 않았음을 지속적으로 검증
  • 골든 테스트 (Golden Testing): 특정 입력에 대한 출력 스냅샷을 저장해두고 변경 여부를 감지. 실패 원인 파악이 어렵고 취약할 수 있음
  • 퍼즈 테스트 (Fuzz Testing): 랜덤하거나 특이한 입력을 대량으로 넣어 크래시 여부를 확인 (Ch.3 참조)

## 핵심 요약

원칙 핵심 내용
동작을 테스트하라 함수 개수가 아닌, 의미 있는 모든 동작에 테스트를 작성하라
가시성을 높이지 마라 private 함수를 직접 테스트하면 구현에 종속된다. public API로 우회하거나 클래스를 분리하라
하나씩 테스트하라 동작 하나당 테스트 하나. 실패 메시지가 명확해지고 이해가 쉬워진다
공유 설정을 신중히 결과에 영향 주는 값은 테스트 안에 두고, 무관한 필수값만 공유하라
적절한 매처를 써라 toBe(true)보다 toContain, toEqual(expect.arrayContaining(...)) 등이 실패 메시지를 명확하게 만든다
의존성 주입을 써라 하드코딩된 의존성은 테스트를 불가능하게 만든다. DI로 테스트 더블을 주입할 수 있게 하라
단위 테스트만이 전부가 아니다 통합 테스트, E2E 테스트, 퍼즈 테스트 등을 적절히 조합해야 한다