이 출시되었습니다! 11월의 새로운 기능 및 수정 사항을 읽어보세요.

Strict null checking Visual Studio Code

2019년 5월 23일, Matt Bierner, @mattbierner

안전은 속도를 허락합니다

빠르게 움직이는 것은 즐겁습니다. 새로운 기능을 출시하고, 사용자를 행복하게 만들고, 코드베이스를 개선하는 것은 즐거운 일입니다. 하지만 동시에 버그가 있는 제품을 출시하는 것은 즐겁지 않습니다. 아무도 문제를 보고받거나 새벽 세 시에 사고로 깨어나는 것을 좋아하지 않습니다.

빠르게 움직이는 것과 안정적인 코드를 출시하는 것이 종종 양립할 수 없는 것으로 제시되지만, 그렇지는 않아야 합니다. 많은 경우 코드를 취약하고 버그 있게 만드는 요인이 개발을 늦추는 요인이기도 합니다. 결국, 항상 문제를 일으킬까 걱정된다면 어떻게 빠르게 움직일 수 있겠습니까?

이 글에서는 VS Code 팀이 최근 완료한 주요 엔지니어링 작업, 즉 VS Code 코드베이스에서 TypeScript의 strict null checking을 활성화한 것을 공유하고 싶습니다. 우리는 이 작업이 더 빠르게 움직이고 더 안정적인 제품을 출시할 수 있도록 할 것이라고 믿습니다. strict null checking을 활성화하는 것은 버그를 고립된 사건이 아니라 소스 코드의 더 큰 위험 요소의 증상으로 이해하는 것에서 동기 부여되었습니다. strict null checking을 사례 연구로 사용하여, 우리의 작업 동기, 문제 해결을 위한 점진적 접근 방식을 어떻게 고안했는지, 그리고 수정 구현 방법을 논의할 것입니다. 위험 요소를 식별하고 줄이는 이러한 일반적인 접근 방식은 어떤 소프트웨어 프로젝트에도 적용될 수 있습니다.

예시

strict null checking을 활성화하기 전 VS Code가 직면했던 문제를 설명하기 위해 간단한 TypeScript 라이브러리를 살펴보겠습니다. TypeScript가 처음이라면 걱정하지 마세요. 구체적인 내용은 중요하지 않습니다. 이 가상의 예시는 VS Code 코드베이스에서 발생했던 문제의 종류를 설명하고, 이러한 문제에 대한 전통적인 대응 몇 가지를 언급하기 위한 것입니다.

저희 예시 라이브러리는 가상의 웹사이트 백엔드에서 주어진 사용자의 상태를 가져오는 단일 getStatus 함수로 구성됩니다.

export interface User {
  readonly id: string;
}

/**
 * Get the status of a user
 */
export async function getStatus(user: User): Promise<string> {
  const id = user.id;
  const result = await fetch(`/api/v0/${id}/status`);
  const json = await result.json();
  return json.status;
}

괜찮아 보입니다. 출시합시다!

하지만 새 코드를 배포한 후 충돌이 급증하는 것을 볼 수 있습니다. 호출 스택에서 충돌이 getStatus 함수에서 발생하는 것 같습니다. 맙소사!

조금 더 추적해보니, 동료 엔지니어 중 한 명이 현재 사용자의 상태를 얻으려는 잘못된 시도로 getStatus(undefined)를 호출하고 있었습니다. 이로 인해 코드가 undefined.id에 접근하려고 할 때 예외가 발생합니다. 간단한 실수입니다. 원인을 알았으니 고칩시다!

그래서 호출 코드를 업데이트하고, undefined를 처리하도록 getStatus를 업데이트하고, 문서 주석에 유용한 경고를 추가합니다.

/**
 * Get the status of a user
 *
 * Don't call this with undefined or null!
 */
export async function getStatus(user: User): Promise<string> {
  if (!user) {
    return '';
  }
  const id = user.id;
  const result = await fetch(`/api/v0/${id}/status`);
  const json = await result.json();
  return json.status;
}

그리고 우리는 진짜 엔지니어이므로 테스트도 작성합니다.

it('should return empty status for undefined user', async () => {
  assert.equals(getStatus(undefined), '');
});

훌륭합니다! 더 이상 충돌이 없습니다. 테스트 커버리지도 100%로 돌아왔습니다! 이제 우리 코드는 완벽해야 합니다.

며칠이 지나고: 쾅! 누군가 로그에서 이상한 것을 발견합니다. /api/v0/undefined/status로 가는 엄청난 수의 요청입니다. 이상한 사용자 이름이네요...

다시 조사하고, 코드를 다시 수정하고, 더 많은 테스트를 추가합니다. 아마도 getStatus({ id: undefined })를 호출했던 사람에게 약간의 수동적인 공격적인 이메일을 보낼 수도 있습니다.

/**
 * Get the status of a user
 *
 * !!!
 * WARNING: Don't call this with undefined or null, or with a user without an id
 * !!!
 */
export async function getStatus(user: User): Promise<string> {
  if (!user) {
    return '';
  }
  const id = user.id;
  if (typeof id !== 'string') {
    return '';
  }
  const result = await fetch(`/api/v0/${id}/status`);
  const json = await result.json();
  return json.status;
}

완벽합니다. 하지만 확실하게 하기 위해 getStatus 호출을 도입하는 모든 변경 사항은 수석 엔지니어의 승인을 받도록 요구합니다. 그러면 이러한 성가신 버그를 영구적으로 막을 수 있을 것입니다...

그리고 아마 이번에는 다음 충돌까지 며칠 더 걸릴 것입니다. 몇 달이 걸릴 수도 있습니다. 하지만 우리 코드가 다시는 변경되지 않는 한, 충돌은 발생할 것입니다. 이 특정 함수가 아니라면, 코드베이스의 다른 어딘가에서라도 말입니다.

상황을 악화시키는 것은, 이제 모든 변경 사항에 다음이 요구됩니다: undefined에 대한 방어적인 확인, 테스트 수정 또는 새 테스트 추가, 팀 승인. 뭐가 문제죠? 우리 모두 각자의 역할을 다하고 있는데도 여전히 버그가 있습니다! 더 나은 방법이 있어야 합니다.

위험 요소 식별

위의 예시에서 버그는 명백해 보일 수 있지만, VS Code를 개발하는 동안에도 같은 유형의 문제를 겪고 있었습니다. 반복마다 예상치 못한 undefined와 관련된 버그를 수정했습니다. 테스트를 추가했습니다. 그리고 더 나은 엔지니어가 되기로 맹세했습니다. 이러한 모든 전통적인 대응에도 불구하고 다음 반복에서 똑같은 일이 다시 발생했습니다. 이는 일부 사용자에게 VS Code 경험에 대한 좋지 않은 영향을 미칠 뿐만 아니라, 이러한 버그와 이에 대한 우리의 대응은 새로운 기능을 개발하거나 기존 소스 코드를 변경하는 동안 우리를 늦추고 있었습니다.

우리는 버그를 고립된 사건이 아니라 더 큰 문제의 증상/신호로 이해하기 시작해야 한다는 것을 깨달았습니다. 이러한 버그에 대한 우리의 대응과 빠르게 움직이지 못하는 것에 대한 좌절감 또한 증상이었습니다. 이러한 증상의 근본 원인에 대해 논의하기 시작했을 때, 우리는 몇 가지 일반적인 원인을 발견했습니다.

  • null 또는 undefined 속성에 접근하는 것과 같은 간단한 프로그래밍 실수를 놓침.
  • 사양이 불충분한 인터페이스. 어떤 매개변수가 undefined 또는 null일 수 있고, 어떤 함수가 undefined 또는 null을 반환할 수 있는가? 종종 함수의 구현자는 호출자와 다른 가정 하에 작업했습니다.
  • 타입 이상 현상. undefinednull. undefinedfalse. undefined 대 빈 문자열.
  • 코드를 신뢰할 수 없거나 안전하게 리팩터링할 수 없다는 느낌.

근본 원인을 식별하는 것은 좋은 첫걸음이었지만, 우리는 더 깊이 들어가고 싶었습니다. 모든 경우에, 의도가 좋은 엔지니어가 처음에 버그를 도입할 수 있게 했던 위험 요소는 무엇이었습니까? 우리는 VS Code 코드베이스에 strict null checking이 부족하다는 모든 문제의 공통적인 명백한 위험 요소를 빠르게 식별했습니다.

strict null checking을 이해하려면, TypeScript의 목표는 JavaScript에 타이핑을 추가하는 것임을 기억해야 합니다. TypeScript의 JavaScript 레거시의 결과로, 기본적으로 TypeScript는 undefinednull을 모든 값에 사용할 수 있도록 허용합니다.

// Without strict null checking, all of these calls are valid

getStatus(undefined); // Ok
getStatus(null); // Ok
getStatus({ id: undefined }); // Ok

이러한 유연성은 JavaScript에서 TypeScript로 마이그레이션하는 것을 더 간단하게 만들지만, 우리의 가상 웹사이트에 대한 예시 라이브러리는 이것이 또한 위험 요소임을 보여주었습니다. 이 위험 요소는 VS Code에서 작업하면서 식별한 네 가지 근본 원인(및 다른 많은 원인)의 핵심이기도 했습니다.

다행히 TypeScript에는 strict null checking이라는 옵션이 있으며, 이는 undefinednull을 별개의 타입으로 취급하게 합니다. strict null checking을 사용할 때, null이 될 수 있는 모든 타입은 그렇게 주석 처리해야 합니다.

// With "strictNullCheck": true, all of these produce compile errors

getStatus(undefined); // Error
getStatus(null); // Error
getStatus({ id: undefined }); // Error

개별 코드 줄을 수정하거나 테스트를 추가하는 것은 해당 특정 버그만 수정하는 반응적인 해결책이었습니다. strict null checking을 활성화하는 것은 매달 보고되는 버그를 수정할 뿐만 아니라 이러한 전체 버그 클래스가 미래에 발생하는 것을 방지하는 사전 예방적인 해결책입니다. 선택적 속성에 값이 있는지 확인하는 것을 잊는 일은 더 이상 없습니다. 함수가 null을 반환할 수 있는지 여부를 묻는 일도 더 이상 없습니다. 이점은 명확했습니다.

점진적인 계획 수립

문제는 컴파일러 플래그를 활성화한다고 모든 것이 마법처럼 해결되지 않는다는 것이었습니다. 핵심 VS Code 코드베이스는 약 1800개의 TypeScript 파일로 구성되어 있으며, 50만 줄 이상입니다. "strictNullChecks": true로 컴파일하면 약 4500개의 오류가 발생했습니다. 으악!

또한 VS Code는 소규모 핵심 팀으로 구성되어 있으며, 우리는 빠르게 움직이는 것을 좋아합니다. 4500개의 strict null 오류를 수정하기 위해 코드를 분기하는 것은 엄청난 엔지니어링 오버헤드를 추가할 것입니다. 그리고 어디서부터 시작해야 할까요? 오류 목록을 위에서부터 아래로? 게다가, 분기에서의 변경 사항은 대부분의 팀이 계속 작업할 메인에는 도움이 되지 않을 것입니다.

우리는 모든 엔지니어에게 strict null checking의 이점을 즉시 제공하는 점진적인 계획을 원했습니다. 그렇게 하면 작업을 관리 가능한 변경 사항으로 나눌 수 있으며, 각 작은 변경 사항이 코드를 조금 더 안전하게 만들 것입니다.

이를 위해 tsconfig.strictNullChecks.json이라는 새로운 TypeScript 프로젝트 파일을 만들었는데, 이 파일은 strict null checking을 활성화했고 초기에는 파일이 전혀 없었습니다. 그런 다음 개별 파일을 이 프로젝트에 선택적으로 추가하고, 해당 파일의 strict null 오류를 수정한 다음, 변경 사항을 커밋했습니다. 가져오는 파일이 없거나 이미 strict null checked된 다른 파일만 가져오는 파일만 추가하는 한, 각 반복마다 소수의 오류만 수정하면 되었습니다.

{
  "extends": "./tsconfig.base.json", // Shared configuration with our main `tsconfig.json`
  "compilerOptions": {
    "noEmit": true, // Don't output any javascript
    "strictNullChecks": true
  },
  "files": [
    // Slowly growing list of strict null check files goes here
  ]
}

이 계획은 합리적으로 보였지만, 한 가지 문제는 메인에서 작업하는 엔지니어들은 일반적으로 VS Code의 strict null checked 부분집합을 컴파일하지 않는다는 것이었습니다. 이미 strict null checked된 파일에 대한 실수로 인한 회귀를 방지하기 위해 tsconfig.strictNullChecks.json을 컴파일하는 연속 통합 단계를 추가했습니다. 이는 strict null checking을 회귀시키는 체크인이 빌드를 중단할 수 있도록 보장했습니다.

또한 tsconfig.strictNullChecks.json에 파일을 추가하는 것과 관련된 반복적인 작업을 자동화하기 위한 두 개의 간단한 스크립트를 만들었습니다. 첫 번째 스크립트는 strict null checked될 수 있는 파일 목록을 인쇄했습니다. 파일은 자신을 strict null checked된 파일만 가져오는 경우 적격으로 간주됩니다. 두 번째 스크립트는 적격 파일을 strict null 프로젝트에 자동으로 추가하려고 시도했습니다. 파일을 추가했을 때 컴파일 오류가 발생하지 않으면 tsconfig.strictNullChecks.json에 커밋되었습니다.

strict null 수정 자체를 자동화하는 것도 고려했지만, 결국 포기했습니다. strict null 오류는 종종 소스 코드를 리팩터링해야 한다는 좋은 신호입니다. 아마도 타입이 nullable인 데에는 특별한 이유가 없었을 것입니다. 아마도 호출자는 구현자 대신 null을 처리해야 했을 것입니다. 이러한 오류를 수동으로 검토하고 수정하는 것은 코드를 더 좋게 만들 기회를 제공했습니다. 단순히 strict null 호환으로 강제하는 것 대신 말입니다.

계획 실행

다음 몇 달 동안 우리는 천천히 strict null checked된 파일 수를 늘렸습니다. 이것은 종종 지루한 작업이었습니다. 대부분의 strict null 오류는 간단했습니다. 단순히 null 주석을 추가하는 것이었습니다. 다른 경우, 코드의 의도를 이해하기 어려웠습니다. 값이 의도적으로 초기화되지 않은 상태로 남겨진 것인지, 아니면 실제로 프로그래밍 실수인지?

일반적으로 메인 코드베이스에서 TypeScript의 not-null assertion을 최대한 사용하지 않으려고 노력했습니다. 테스트에서는 더 자유롭게 사용했습니다. 테스트 코드에서 null checking 부족으로 인해 예외가 발생하면 테스트가 어쨌든 실패할 것이라고 생각했습니다.

이 모든 과정의 한 가지 실망스러운 측면은 VS Code 코드베이스에서 strict null 오류의 총 수가 전혀 줄어들지 않는 것처럼 보였다는 것입니다. 오히려 VS Code 전체를 strict null checks가 활성화된 상태로 컴파일하면, 우리의 strict null 작업이 전체 오류 수를 증가시키는 것처럼 보였습니다! 이는 strict null 수정이 종종 연쇄적인 효과를 가지기 때문입니다. 함수가 undefined를 반환할 수 있다고 올바르게 주석 처리하면 해당 함수의 모든 소비자에 대해 strict null 오류가 발생할 수 있습니다. 남은 오류의 총 수를 걱정하는 대신, 우리는 이미 strict null checked된 파일 수에 집중하고 이 총 수를 회귀시키지 않도록 노력했습니다.

strict null checking을 활성화한다고 해서 strict null 관련 예외가 완전히 발생하지 않는 것은 아닙니다. 예를 들어, any 타입이나 잘못된 캐스트는 strict null checking을 쉽게 우회할 수 있습니다.

// strictNullCheck: true

function double(x: number): number {
  return x * 2;
}

double(undefined as any); // not an error

배열의 범위를 벗어난 요소에 접근하는 것과 마찬가지입니다.

// strictNullCheck: true

function double(x: number): number {
  return x * 2;
}

const arr = [1, 2, 3];

double(arr[5]); // not an error

또한, TypeScript의 strict property initialization을 활성화하지 않는 한, 멤버가 아직 초기화되지 않은 상태에서 접근하더라도 컴파일러는 불평하지 않습니다.

// strictNullCheck: true

class Value {
  public x: number;

  public setValue(x: number) {
    this.x = x;
  }

  public double(): number {
    return this.x * 2; // not an error even though `x` will be `undefined` if `setValue` has not been called yet
  }
}

이 노력의 요점은 VS Code에서 100% strict null 오류를 제거하는 것이 아니라 (이는 매우 어렵거나 불가능할 수도 있습니다), 일반적인 strict null 관련 오류의 대부분을 방지하는 것이었습니다. 또한 코드를 정리하고 리팩터링을 더 안전하게 만들 좋은 기회였습니다. 95%에 도달하는 것도 우리에게는 수용 가능했습니다.

우리의 전체 strict null checking 계획과 실행은 GitHub에서 찾을 수 있습니다. VS Code 팀의 모든 구성원과 많은 외부 기여자들가 이 노력에 참여했습니다. 이 작업의 주도자로서 제가 가장 많은 strict null 관련 수정을 했지만, 이는 제 엔지니어링 시간의 약 4분의 1만 차지했습니다. 분명 약간의 고통이 있었고, 많은 strict null 회귀가 체크인 후에만 연속 통합으로 감지되어 짜증이 나기도 했습니다. strict null 작업은 또한 몇 가지 새로운 버그를 발생시켰습니다. 그러나 변경된 코드의 양을 고려할 때, 놀라울 정도로 순조롭게 진행되었습니다.

마침내 VS Code 전체 코드베이스에 strict null checking을 활성화한 변경 사항은 다소 예상치 못한 것이었습니다. 몇 가지 코드 오류를 더 수정하고, tsconfig.strictNullChecks.json을 삭제하고, 메인 tsconfig에서 "strictNullChecks": true를 설정했습니다. 드라마가 없었던 것은 정확히 계획대로였습니다. 그리고 그렇게 VS Code는 strict null checked되었습니다!

결론

이 프로젝트에 대해 사람들에게 이야기할 때 자주 듣는 질문이 있습니다: 그래서 버그가 몇 개나 수정되었나요? 저는 그 질문이 그다지 의미가 없다고 생각합니다. VS Code에서는 strict null checking 부족과 관련된 버그를 수정하는 데 문제가 없었습니다. 보통은 조건문을 추가하고 아마도 테스트를 한두 개 추가하는 것으로 충분했습니다. 하지만 우리는 같은 유형의 버그를 반복해서 계속해서 보았습니다. 이러한 버그를 수정하는 것은 불필요하게 우리를 늦추고 있었고, 우리 코드를 완전히 신뢰할 수 없다는 것을 의미했습니다. 우리 코드베이스의 strict null checking 부족은 위험 요소였고, 버그는 단지 이 위험 요소의 증상일 뿐이었습니다. strict null checking을 활성화함으로써, 우리는 코드베이스와 작업 방식에 많은 다른 이점을 가져오는 것 외에도 전체 버그 클래스를 방지하기 위한 상당한 작업을 수행했습니다.

이 게시물의 목적은 대규모 코드베이스에서 strict null checking을 활성화하는 방법에 대한 튜토리얼이 아니었습니다. 만약 이 문제가 당신에게 적용된다면, 마법 없이 합리적인 방법으로 할 수 있다는 것을 보았기를 바랍니다. (새로운 TypeScript 프로젝트를 시작한다면, 미래의 당신을 위해 "strict": true를 기본값으로 시작하는 것이 좋습니다.)

당신이 얻어가기를 바라는 점은, 너무나 자주 버그에 대한 대응은 테스트를 추가하거나 비난하는 것입니다. "당연히 Bob은 그 속성에 접근하기 전에 undefined를 확인했어야 했습니다." 사람들은 좋은 의도를 가지고 있지만 실수를 할 것입니다. 테스트는 유용하지만 비용이 들고 우리가 테스트하도록 작성한 것만 테스트합니다.

대신, 버그나 당신을 늦추는 다른 것을 발견했을 때, 다음 문제로 넘어가기 전에 잠시 멈추어 무엇이 그것을 야기했는지 정말로 탐구해 보십시오. 근본 원인은 무엇이었습니까? 어떤 위험 요소를 드러냈습니까? 예를 들어, 소스 코드에 위험한 코딩 패턴이 있고 리팩터링이 필요할 수 있습니다. 그런 다음 영향에 비례하여 위험 요소를 해결하기 위해 노력하십시오. 모든 것을 다시 작성할 필요는 없습니다. 최소한의 사전 작업을 하고, 합리적일 때 자동화하십시오. 위험 요소를 줄이고 오늘날 세상을 점진적으로 더 좋게 만드십시오.

우리는 VS Code의 strict null checking에 이 접근 방식을 취했고, 앞으로 다른 문제에도 적용할 것입니다. 어떤 종류의 프로젝트를 하든 유용하게 활용하시기 바랍니다.

행복한 코딩 되세요,

Matt Bierner, VS Code 팀원 @mattbierner

© . This site is unofficial and not affiliated with Microsoft.