Five Lines of Code - 7. 컴파일러와의 협업

주제: 컴파일러의 장단점을 이해하여 활용한 불변속성을 제거하고, 책임 분담에 대해 알아보기

🔖 7.1 컴파일러에 대해 알아보기

책의 내용

컴파일러의 장점: 확정 할당은 초기화되지 않은 변수에 대한 접근을 막는다

컴파일러가 검증하는 또 다른 속성은 변수가 사용되기 전에 변수에 값이 확실히 할당되었는지 여부를 알아내는 데 능하다는 것입니다. 이 검사는 특히 if문을 사용해 지역변수를 초기화하려는 경우에 적용됩니다.

let result;

for (let i = 0; i < arr.length; i++) {
  if (arr[i].name === "John")
    result = arr[i];
  return result; // Uncaught SyntaxError: Illegal return statement
}

이런 경우 최선의 방법은 컴파일러에게 우리가 알고 있는 것, 즉 John이라는 요소를 반드시 포함하고 있다는 것을 가르치는 것입니다.

확장 할당 분석(define assignment analysis)이 적용되는 읽기 전용 필드를 사용해서 컴파일러에게 알릴 수 있습니다. 생성자를 종료할 때 읽기 전용 필드는 초기화되어야 합니다. 즉, 생성자에서 할당하거나 선언 시 할당해야 한다는 말입니다.

이 엄격함을 사용해서 특정 값이 존재하는지 확인할 수 있습니다.

나의 생각

생성자에서의 읽기 전용 필드 초기화에 대한 이해

만약 내가 어떤 물건을 사서, 그 물건의 이름표를 한 번 붙이고 나면 절대로 바꿀 수 없게 된다고 치자. 이처럼 '읽기 전용 필드'라는 것도 한 번 값이 설정되면 그 값이 변경되지 않도록 보장한다. 이런 필드는 주로 '생성자'라는 특별한 함수에서 값을 설정한다. 생성자는 그 객체가 만들어질 때 실행되는 함수로, 객체의 기본 설정을 처리한다.

읽기 전용 필드를 생성자에서 설정하면 그 객체가 처음 만들어질 때 필요한 정보를 받고, 그 정보를 객체의 생명주기 동안 계속 유지하게 된다. 이 방법은 객체가 항상 예상된 상태를 유지하도록 도와주고, 프로그램이 더 안정적이고 신뢰할 수 있게 만든다.

예를 들어, 도서관에서 책을 대여할 때, 각 책에는 고유한 책 번호가 있다. 이 번호는 책이 도서관에 처음 들어올 때 지정되고, 그 후로는 절대로 바뀌지 않는다.

class Book {
  readonly bookID: number; // 책 번호는 읽기 전용

  constructor(id: number) {
    this.bookID = id; // 생성자에서 책 번호를 초기화
  }

  displayID() {
    console.log("The book ID is:", this.bookID);
  }
}

const myBook = new Book(12345);
myBook.displayID(); // The book ID is: 12345

// myBook.bookID = 67890; // 오류 발생: 책 번호는 변경할 수 없다.

이 코드에서 Book 클래스는 bookID라는 읽기 전용 필드를 가지고 있다. 이 필드는 책의 고유 번호를 나타내며 객체가 생성될 때 생성자를 통해 초기화되고, 이후에는 이 번호를 변경할 수 없다. 이렇게 설정함으로써, 책의 고유 번호가 항상 일관되고 정확하게 유지될 수 있다.

엄격함을 사용해 특정 값이 존재하는지 확인하기

이 문맥에서 "엄격함"은 컴파일러가 변수가 초기화되었는지 여부를 엄격하게 검사하고 오류를 방지하는 기능을 의미한다. 이러한 컴파일러의 기능 덕분에 오류를 줄이고, 특정 값이 반드시 존재하고 사용될 것이라는 확신을 갖게 해준다.

아래 예제에서는 Person 클래스를 정의한다. 모든 사람 객체는 이름과 ID를 가지며, 이 값들은 객체가 생성될 때 반드시 초기화되어야 한다. 이를 통해 프로그램 어디에서든 이 객체들의 이름과 ID가 초기화되지 않은 상태로 사용되는 것을 방지한다.

class Person {
  readonly name: string;
  readonly id: number;

  constructor(name: string, id: number) {
    this.name = name;  // 생성자에서 이름을 초기화
    this.id = id;      // 생성자에서 ID를 초기화
  }

  displayInfo() {
    console.log(`Name: ${this.name}, ID: ${this.id}`);
  }
}

const john = new Person("John Choi", 12345);
john.displayInfo(); // 출력: "Name: John Choi, ID: 12345"

여기서 nameid 필드는 readonly로 선언되어 있어, 이 값들이 한 번 설정되면 변경할 수 없다. 컴파일러는 이 필드들이 생성자에서 반드시 초기화되어야 함을 보장하고, 이는 클래스의 모든 인스턴스가 일관된 상태로 유지될 수 있게 도와준다.

🔖 7.1.6 약점: null을 역참조하면 애플리케이션이 손상된다

책의 내용

런타임 오류의 위험은 nullable 변수를 다룰 때 각별히 주의해야 합니다. nullable 변수에 대한 null 검사를 하지 않는다면 null로 보는 것이 좋습니다. 너무 적게 확인하는 것보다는 너무 많이 확인하는 것이 낫습니다. 절대적으로 확신하지 않는 한 null 검사를 제거하지 않는 것이 좋습니다.

참고 지식: TypeScript에서의 null 체크 방법들

타입스크립트에서 null 체크는 변수나 객체가 null이 아닌지 확인하는 과정을 말한다. 이는 프로그램의 안정성을 보장하기 위해 필수적인 단계로, null 참조로 인한 런타임 에러를 방지할 수 있다. 타입스크립트는 nullundefined를 구분할 수 있는 타입 체크 기능을 제공하기에 이것을 적절히 활용하면 코드의 안정성을 높일 수 있다.

1. 직접 비교

가장 직관적인 방법으로, 변수를 직접 null과 비교한다.

let value: string | null = fetchStringValue();

if (value !== null) {
  console.log(value);  // value는 이 시점에서 null이 아님이 확실
}

2. Optional Chaining (?.)

ES2020에서 도입된 Optional Chaining을 사용하여, null 또는 undefined인 경우 메소드나 프로퍼티 접근을 시도하지 않고 안전하게 처리한다.

let user = {
  name: "John",
  address: null
};

let city = user.address?.city; // address가 null이면, city 접근은 시도되지 않고 undefined가 반환됨

3. Non-null Assertion Operator (!)

타입스크립트에게 해당 값이 절대 null 또는 undefined가 아니라고 명시적으로 알려주는 연산자다. 사용에 주의가 필요하며, 정말로 null이 아닐 때만 사용해야 한다.

let value: string | null = fetchStringValue();
console.log(value!);  // 개발자는 value가 null이 아님을 확신해야 함

4. Truthiness 검사

간단히 if문을 사용하여 값의 진실성(truthiness)을 검사할 수 있으며, 이는 nullundefined 모두를 포함하여 거짓(falsy) 값으로 평가된다.

let value: string | null = fetchStringValue();
if (value) {
  console.log(value);  // value는 null 또는 undefined가 아니며, 비어 있지 않은 문자열
}

주의 사항

null 체크를 할 때는 컨텍스트에 따라 적절한 방법을 선택해야 한다. 예를 들어, 값이 0 또는 빈 문자열일 수 있는 경우, Truthiness 검사는 이러한 유효한 값들을 거짓으로 간주할 수 있으므로 부적절할 수 있다. 이런 상황에서는 직접적인 null 비교를 사용하는 것이 더 적절할 수 있다.

🔖 7.1.10 약점: 교착 상태 및 경쟁 상태로 인해 의도하지 않은 동작이 발생한다

책의 내용

문제의 범주는 멀티스레딩에서 비롯됩니다. 경쟁 상태, 교착 상태, 기아 등 변경 가능한 데이터를 공유하는 여러 스레드가 있으면 문제의 홍수가 발생할 수 있습니다.

참고 지식

Race Condition

Race Condition은 둘 이상의 프로세스 또는 스레드가 데이터나 시스템의 상태에 대해 동시에 접근하고 수정을 시도할 때 발생하는 상황이다. 이 때, 최종 연산 결과가 실행 순서에 따라 달라질 수 있어 예측이 어렵고 일관성 있는 결과를 보장하기 어렵다. 이러한 조건은 동시성 프로그래밍에서 일반적인 문제로, 데이터의 무결성을 해칠 수 있기 때문에 동기화 기술을 통해 관리해야 한다.

예를 들어, 두 개의 스레드가 같은 은행 계좌의 잔액을 동시에 갱신하려고 할 때, 한 스레드의 변경이 다른 스레드에 의해 덮어쓰여져 잘못된 잔액이 계산될 수 있다. 여기서 타입스크립트는 싱글스레드 모델을 따르기에 전통적인 멀티스레딩 환경에서 발생하는 경쟁상태는 발생하지 않는다. 다만, 자바스크립트 및 타입스크립트는 비동기 프로그래밍을 통해 일종의 동시성을 다루기에 다른 형태의 경쟁 상태가 발생할 수 있다.

promise.race()

promise.race() 메서드는 Promise 객체를 반환한다. 이 프로미스 객체는 iterable 안에 있는 프로미스 중에 가장 먼저 완료된 것의 결과값으로 그대로 이행하거나 거부한다.

즉, 이 메서드는 경쟁 상태를 만들어, 여러 비동기 작업 중 하나라도 완료되는 즉시 다음 작업으로 진행할 수 있게 한다. 참고로 경쟁 상태와 유사한 행동을 프로그래밍적으로 만들어내는 역할이며, 가장 빠르게 응답하는 소스의 데이터만 필요한 경우에 적합하다.

let promise1 = new Promise((resolve, reject) => setTimeout(resolve, 500, 'one'));
let promise2 = new Promise((resolve, reject) => setTimeout(resolve, 100, 'two'));

Promise.race([promise1, promise2]).then(value => {
  console.log(value);  // "two" - promise2가 더 빨리 완료됨
});

Concurrent Rendering

React Concurrent Rendering은 React18에서 업데이트 되었으며, React 애플리케이션의 렌더링 엔진을 더 효율적으로 만들기 위한 기능이다. 여러 비동기 작업을 동시에 처리하고, 성능과 렌더링 엔진 개선, 사용자 경험을 향상시키는 것을 목표로 한다. 이 모드를 통해 React는 렌더링 작업의 우선순위를 정하고, 더 중요한 업데이트를 먼저 처리하며, 덜 중요한 작업은 중단하거나 지연시킬 수 있다.

🔖 7.2 컴파일러 사용

책의 내용

프로그래밍은 건축이 아닌 여러 단계의 커뮤니케이션 입니다. 따라서 프로그래밍은 문학과 훨씬 더 많은 공통점을 가지고 있습니다. 도메인에 대한 지식을 습득하고 머릿속에서 모델을 형성한 다음, 이 모델을 코드로 성문화합니다.

댄 노스는 프로그램이 시간이 고정된 도메인에 대한 개발팀의 공동 지식이라는 유사성에 주목했습니다. '프로그램은 개발자가 도메인에 대해 사실이라고 믿는 모든 것에 대한 완전하고 모호하지 않은 설명입니다.' 이 비유에서 컴파일러는 우리의 텍스트가 특정 품질을 충족하는지 확인하는 편집자 입니다.

나의 생각

댄 노스의 이 비유를 풀어 이해해 보자. 프로그램을 개발할 때 개발자는 특정 분야(도메인)에 대한 이해를 바탕으로 코드를 작성한다. 여기서 말하는 "도메인"은 개발하려는 소프트웨어가 적용되는 특정 주제나 분야를 의미한다. 예를 들어 은행 소프트웨어를 개발한다면 그 도메인은 금융 서비스가 될 것이다.

개발자가 은행 도메인에 대해 "사실이라고 믿는 모든 것"이란, 그들이 은행 분야에서 사실이라고 인식하고 있는 규칙, 원칙, 데이터 등 - 예를 들어, 금융 도메인에서는 계좌 이체, 이자 계산, 대출 승인 등의 프로세스 - 가 어떻게 이루어져야 하는지에 대한 이해가 포함될 것이다.

"완전하고 모호하지 않은 설명"이란 이러한 도메인 지식을 프로그램 코드로 정확하게 표현하는 것을 의미한다. 즉, 개발자가 (사실이라고 믿는) 도메인 지식이 코드를 통해 정확하고 명확하게 반영되어야 한다는 것이다. (기계는 거짓말을 하지 않으니..) 코드에는 모호함이나 불확실성이 없어야 하며, 프로그램이 도메인의 사실과 규칙을 정확하게 따르고 구현해야 한다.

위의 비유에서 컴파일러는 "편집자" 역할을 한다고 한다. 참 정확한 비유라고 생각했다. 책이나 문서를 출판하기 전에 편집자가 글이 명확하고 정확한지 검토하듯이, 컴파일러는 코드가 문법적으로 정확하고, 실행 가능한지, 그리고 도메인의 규칙을 올바르게 반영하고 있는지 검토한다. 컴파일러는 코드에 오류가 없고 주어진 사양을 충족하는지 확인하며 개발자가 의도한 대로 프로그램이 동작할 수 있도록 돕는 아주 좋은 친구다.

🔖 7.2.2 컴파일러와 싸우지 말 것

책의 내용

타입 검사기는 컴파일러의 가장 강력한 부분입니다. 사람들은 타입을 여러 방법으로 잘못 사용해서 타입 검사기를 무력화합니다.

첫 번째는 '형 변환'을 사용하는 것입니다. 형 변환은 컴파일러에게 컴파일러보다 자신이 더 잘 알고 있다고 말하는 것과 같습니다. 형 변환은 컴파일러가 사용자를 돕지 못하게 하고, 기본적으로 특정 변수나 표현식에 대해 비활성화 합니다.

나의 생각

형 변환에 대한 이해

형 변환 또는 타입 캐스팅(Type Casting)은 프로그래밍에서 하나의 변수 타입을 다른 타입으로 명시적으로 변환하는 행위를 말한다. 이를 통해 프로그램은 다양한 타입의 데이터를 호환시키거나 특정 연산을 가능하게 할 수 있다. 하지만 이 과정에서 형 변환을 잘못 사용하면 컴파일러의 타입 체크 기능을 우회하게 되어, 런타임 오류가 발생할 위험이 높아진다고 한다.

형 변환은 기본적으로 "나는 이 변수가 실제로는 다른 타입이라는 것을 알고 있으니, 이 작업을 수행해도 안전하다"라고 컴파일러에게 알리는 것과 같다. 이렇게 하면 컴파일러는 개발자가 제공한 정보를 신뢰하고, 해당 타입의 검증 절차를 건너뛰게 된다. 그러나 실제로는 안전하지 않은 상황일 수 있기 때문에, 형 변환은 주의 깊게 사용해야 한다.

형 변환으로 인한 컴파일 에러 예시

아래는 자바스크립트(타입스크립트 포함)에서 자주 볼 수 있는 형 변환의 잘못된 사용 예시다. 자바스크립트는 동적 타입 언어이지만, 타입스크립트에서는 강력한 타입 시스템을 갖추고 있어, 형 변환을 통한 타입의 오용이 문제를 일으킬 수 있다.

function calculateLength(vector: { x: number; y: number }) {
  return Math.sqrt(vector.x ** 2 + vector.y ** 2);
}

const point = { x: 1, y: 2, z: 3 };

// 오류: 객체 리터럴은 알려진 속성만 지정할 수 있고 'z'는 타입 '{ x: number; y: number; }'에 존재하지 않는다.
console.log(calculateLength(point as { x: number; y: number }));

위 코드에서 calculateLength 함수는 xy 속성을 가진 객체를 인자로 받아 벡터의 길이를 계산한다. 그러나 point 객체는 z 속성까지 포함하고 있다. 타입스크립트에서는 point 객체를 함수에 그대로 넘길 경우 타입 불일치로 인해 컴파일 에러가 발생한다. 따라서, (point as { x: number; y: number })를 사용해 형 변환을 시도하며 이는 컴파일러의 타입 검사를 무력화한다. 이 경우, 컴파일러는 z 속성이 무시되어도 문제없다고 판단하지만, 실제 코드 로직에서 z를 필요로 하는 경우가 있다면 런타임 오류로 이어질 수 있다.

따라서 형 변환을 사용할 때는 해당 데이터가 실제로 변환하려는 타입에 적합한지 확실히 검토하는 것이 중요하며, 가능한 한 타입 시스템의 도움을 받아 안전하게 코드를 작성해야 할 것 같다.

📚 참고 자료

Last updated