Chrome DevTools의 예측 포착: 예측이 어려운 이유 및 개선 방법

Eric Leese
Eric Leese

웹 애플리케이션의 예외를 디버그하는 것은 간단해 보입니다. 문제가 발생하면 실행을 일시중지하고 조사하면 됩니다. 하지만 JavaScript의 비동기 특성으로 인해 이 작업은 놀라울 정도로 복잡해집니다. 예외가 약속과 비동기 함수를 통과할 때 Chrome DevTools는 언제 어디에서 일시중지해야 하는지 어떻게 알 수 있나요?

이 게시물에서는 코드에서 나중에 예외가 포착될지 예측하는 DevTools의 기능인 포착 예측의 문제를 자세히 살펴봅니다. 이 작업이 왜 그렇게 까다로운지, 그리고 V8 (Chrome을 지원하는 JavaScript 엔진)의 최근 개선사항이 어떻게 더 정확해져 더 원활한 디버깅 환경을 제공하는지 살펴보겠습니다.

포착 예측이 중요한 이유 

Chrome DevTools에서는 포착되지 않은 예외의 경우에만 코드 실행을 일시중지하고 포착된 예외는 건너뛸 수 있습니다. 

Chrome DevTools는 포착된 예외 또는 포착되지 않은 예외에서 일시중지할 수 있는 별도의 옵션을 제공합니다.

백그라운드에서 디버거는 컨텍스트를 보존하기 위해 예외가 발생하면 즉시 중지됩니다. 이는 예측입니다. 이 시점에서는 특히 비동기 시나리오에서 코드 뒷부분에서 예외가 포착될지 확실히 알 수 없기 때문입니다. 이러한 불확실성은 중단 문제와 마찬가지로 프로그램 동작을 예측하는 데 내재된 어려움에서 비롯됩니다.

다음 예시를 고려해 보세요. 디버거는 어디에서 일시중지해야 하나요? (다음 섹션에서 답변을 확인하세요.)

async function inner() {
  throw new Error(); // Should the debugger pause here?
}

async function outer() {
  try {
    const promise = inner();
    // ...
    await promise;
  } catch (e) {
    // ... or should the debugger pause here?
  }
}

디버거에서 예외에서 일시중지하면 중단이 발생하고 익숙하지 않은 코드로 자주 전환될 수 있습니다. 이를 완화하려면 실제 버그를 신호할 가능성이 더 높은 포착되지 않은 예외만 디버그하도록 선택할 수 있습니다. 하지만 이는 실적 예측의 정확도에 의존합니다.

잘못된 예측은 불만족을 야기합니다.

  • 거짓음성 (포착될 때 '포착되지 않음'을 예측) 디버거에서 불필요한 중지
  • 거짓양성 (포착되지 않을 때 '포착됨'을 예측) 심각한 오류를 포착할 기회를 놓쳐 예상되는 예외를 포함한 모든 예외를 디버그해야 할 수 있습니다.

디버깅 중단을 줄이는 또 다른 방법은 무시 목록을 사용하는 것입니다. 무시 목록을 사용하면 지정된 서드 파티 코드 내에서 예외가 발생할 때 중단이 방지됩니다.  하지만 정확한 포착 예측은 여전히 중요합니다. 서드 파티 코드에서 발생한 예외가 이스케이프되어 자체 코드에 영향을 미치는 경우 이를 디버그할 수 있어야 합니다.

비동기 코드 작동 방식

약속, asyncawait, 기타 비동기 패턴으로 인해 예외 또는 거부가 처리되기 전에 예외가 발생할 때 결정하기 어려운 실행 경로를 취할 수 있는 시나리오가 발생할 수 있습니다. 예외가 이미 발생한 후에야 약속이 대기되거나 catch 핸들러가 추가될 수 있기 때문입니다. 이전 예를 살펴보겠습니다.

async function inner() {
  throw new Error();
}

async function outer() {
  try {
    const promise = inner();
    // ...
    await promise;
  } catch (e) {
    // ...
  }
}

이 예에서 outer()는 먼저 inner()를 호출하며, 이때 즉시 예외가 발생합니다. 이를 통해 디버거는 inner()가 거부된 약속을 반환하지만 현재 이 약속을 기다리거나 처리하는 작업이 없다고 결론을 내릴 수 있습니다. 디버거는 outer()가 이를 기다릴 가능성이 높고 현재 try 블록에서 이를 기다릴 것이라고 추측할 수 있으므로 이를 처리할 수 있지만, 거부된 프라미스가 반환되고 await 문에 도달할 때까지는 이를 확신할 수 없습니다.

디버거는 포착 예측이 정확하다는 보장은 할 수 없지만 일반적인 코딩 패턴에 다양한 휴리스틱을 사용하여 올바르게 예측합니다. 이러한 패턴을 이해하려면 약속의 작동 방식을 알아보는 것이 좋습니다.

V8에서 JavaScript Promise는 처리됨, 거부됨, 대기 중의 세 가지 상태 중 하나일 수 있는 객체로 표시됩니다. 프라미스가 fulfilled 상태이고 .then() 메서드를 호출하면 새 대기 중인 프라미스가 생성되고 새 프라미스 반응 태스크가 예약됩니다. 이 태스크는 핸들러를 실행한 다음 핸들러의 결과로 프라미스를 fulfilled로 설정하거나 핸들러가 예외를 발생시키면 rejected로 설정합니다. 거부된 약속에서 .catch() 메서드를 호출해도 동일한 결과가 발생합니다. 반대로 거부된 프로미스에서 .then()를 호출하거나 처리된 프로미스에서 .catch()를 호출하면 동일한 상태의 프로미스가 반환되고 핸들러가 실행되지 않습니다. 

대기 중인 약속에는 각 리액션 객체에 처리 핸들러 또는 거부 핸들러 (또는 둘 다)와 리액션 약속이 포함된 리액션 목록이 포함됩니다. 따라서 대기 중인 프로미스에서 .then()를 호출하면 처리된 핸들러가 포함된 반응과 .then()가 반환할 반응 프로미스의 새 대기 중인 프로미스가 추가됩니다. .catch()를 호출하면 거부 핸들러가 있는 유사한 반응이 추가됩니다. 두 개의 인수로 .then()를 호출하면 두 핸들러와 함께 반응이 생성되고 .finally()를 호출하거나 약속을 기다리면 이러한 기능 구현과 관련된 내장 함수인 두 핸들러와 함께 반응이 추가됩니다.

대기 중인 프라미스가 결국 처리되거나 거부되면 처리된 모든 핸들러 또는 거부된 모든 핸들러에 대한 반응 작업이 예약됩니다. 그러면 해당하는 리액션 약속이 업데이트되어 자체 리액션 작업이 트리거될 수 있습니다.

다음 코드를 살펴보세요.

return new Promise(() => {throw new Error();})
    .then(() => console.log('Never happened'))
    .catch(() => console.log('Caught'));

이 코드에는 세 개의 고유한 Promise 객체가 포함되어 있다는 점이 분명하지 않을 수 있습니다. 위 코드는 다음 코드와 같습니다.

const promise1 = new Promise(() => {throw new Error();});
const promise2 = promise1.then(() => console.log('Never happened'));
const promise3 = promise2.catch(() => console.log('Caught'));
return promise3;

이 예시에서는 다음 단계가 실행됩니다.

  1. Promise 생성자가 호출됩니다.
  2. 대기 중인 새 Promise가 생성됩니다.
  3. 익명 함수가 실행됩니다.
  4. 예외가 발생합니다. 이 시점에서 디버거는 중지할지 여부를 결정해야 합니다.
  5. 약속 생성자는 이 예외를 포착한 후 값을 발생한 오류로 설정하여 약속 상태를 rejected로 변경합니다. promise1에 저장되는 이 프로미스를 반환합니다.
  6. promise1rejected 상태이므로 .then()는 반응 작업을 예약하지 않습니다. 대신 동일한 오류와 함께 거부된 상태인 새 프로미스 (promise2)가 반환됩니다.
  7. .catch()는 제공된 핸들러와 새 대기 중인 리액션 약속(promise3로 반환됨)으로 리액션 작업을 예약합니다. 이 시점에서 디버거는 오류가 처리될 것임을 알 수 있습니다.
  8. 반응 태스크가 실행되면 핸들러가 정상적으로 반환되고 promise3의 상태가 fulfilled로 변경됩니다.

다음 예는 구조는 비슷하지만 실행은 매우 다릅니다.

return Promise.resolve()
    .then(() => {throw new Error();})
    .then(() => console.log('Never happened'))
    .catch(() => console.log('Caught'));

이는 다음과 동일합니다.

const promise1 = Promise.resolve();
const promise2 = promise1.then(() => {throw new Error();});
const promise3 = promise2.then(() => console.log('Never happened'));
const promise4 = promise3.catch(() => console.log('Caught'));
return promise4;

이 예시에서는 다음 단계가 실행됩니다.

  1. Promisefulfilled 상태로 생성되고 promise1에 저장됩니다.
  2. Promise 반응 작업이 첫 번째 익명 함수로 예약되고 (pending) 반응 Promise가 promise2로 반환됩니다.
  3. 반응은 처리가 완료된 핸들러와 반응 프로미스(promise3로 반환됨)를 사용하여 promise2에 추가됩니다.
  4. 거부된 핸들러와 다른 반응 프로미스(promise4로 반환됨)를 사용하여 promise3에 반응이 추가됩니다.
  5. 2단계에서 예약된 반응 태스크가 실행됩니다.
  6. 핸들러가 예외를 발생시킵니다. 이 시점에서 디버거는 중지할지 여부를 결정해야 합니다. 현재 핸들러가 유일하게 실행 중인 JavaScript 코드입니다.
  7. 태스크가 예외로 종료되므로 연결된 반응 약속 (promise2)은 값이 발생한 오류로 설정된 거부된 상태로 설정됩니다.
  8. promise2에는 하나의 반응이 있고 이 반응에는 거부된 핸들러가 없으므로 반응 약속 (promise3)도 동일한 오류와 함께 rejected로 설정됩니다.
  9. promise3에 하나의 반응이 있고 이 반응에 거부된 핸들러가 있으므로 해당 핸들러와 반응 약속 (promise4)으로 약속 반응 작업이 예약됩니다.
  10. 이 반응 작업이 실행되면 핸들러가 정상적으로 반환되고 promise4 상태가 fulfilled로 변경됩니다.

포획 예측 방법

어획 예측을 위한 정보 소스는 두 가지가 있습니다. 하나는 호출 스택입니다. 이는 동기식 예외에 적합합니다. 디버거는 예외 롤백 코드와 동일한 방식으로 호출 스택을 탐색할 수 있으며 try...catch 블록에 있는 프레임을 찾으면 중지됩니다. 정지된 적이 없는 프라미스 생성자 또는 비동기 함수에서 거부된 프라미스나 예외의 경우 디버거도 호출 스택을 사용하지만, 이 경우 예측이 모든 경우에 신뢰할 수 있는 것은 아닙니다. 이는 비동기 코드가 가장 가까운 핸들러에 예외를 발생시키는 대신 거부된 예외를 반환하고 디버거가 호출자가 예외로 무엇을 할지 몇 가지 가정해야 하기 때문입니다.

첫째, 디버거는 반환된 Promise를 수신하는 함수가 스택 위의 비동기 함수가 Promise를 기다릴 수 있도록 해당 Promise 또는 파생된 Promise를 반환할 가능성이 높다고 가정합니다. 둘째, 디버거는 프로미스가 비동기 함수에 반환되면 먼저 try...catch 블록을 진입하거나 종료하지 않고 곧 프로미스를 기다릴 것이라고 가정합니다. 이러한 가정은 모두 올바르다고 보장할 수 없지만 비동기 함수가 포함된 가장 일반적인 코딩 패턴에 관해 올바르게 예측하기에 충분합니다. Chrome 버전 125에서는 또 다른 휴리스틱을 추가했습니다. 디버거는 호출자가 반환될 값 (또는 두 개의 인수가 있는 .then(), 또는 .then() 또는 .finally() 호출 체인 뒤에 .catch() 또는 두 인수 .then()가 오는 경우)에서 .catch()를 호출하려고 하는지 확인합니다. 이 경우 디버거는 이러한 메서드가 추적 중인 약속의 메서드이거나 이와 관련된 메서드라고 가정하여 거부가 포착됩니다.

두 번째 정보 소스는 약속 반응 트리입니다. 디버거는 루트 약속으로 시작합니다. reject() 메서드가 방금 호출된 약속일 때도 있습니다. 더 일반적으로 약속 반응 작업 중에 예외 또는 거부가 발생하고 호출 스택에 이를 포착하는 항목이 없는 경우 디버거는 반응과 연결된 약속에서 추적합니다. 디버거는 대기 중인 약속의 모든 반응을 살펴보고 거부 핸들러가 있는지 확인합니다. 어떤 반응도 일치하지 않으면 반응 약속을 보고 여기에서 재귀적으로 추적합니다. 모든 반응이 궁극적으로 거부 핸들러로 이어지면 디버거는 약속 거부가 포착되었다고 간주합니다. .finally() 호출의 내장 거부 핸들러를 고려하지 않는 경우와 같이 다루어야 할 몇 가지 특수한 경우가 있습니다.

약속 반응 트리는 정보가 있는 경우 일반적으로 신뢰할 수 있는 정보 소스를 제공합니다. Promise.reject() 호출이나 Promise 생성자 또는 아직 아무것도 기다리지 않은 비동기 함수의 경우와 같이 트레이스에 대한 반응이 없으면 디버거는 호출 스택에만 의존해야 합니다. 다른 경우에는 약속 반응 트리에 일반적으로 포착 예측을 추론하는 데 필요한 핸들러가 포함되지만 나중에 더 많은 핸들러가 추가되어 예외를 포착됨에서 포착되지 않음으로 또는 그 반대로 변경할 수 있습니다. Promise.all/any/race로 만든 것과 같은 약속도 있습니다. 이 경우 그룹의 다른 약속이 거부가 처리되는 방식에 영향을 줄 수 있습니다. 이러한 메서드의 경우 디버거는 프로미스가 아직 대기 중인 경우 프로미스 거부가 전달된다고 가정합니다.

다음 두 가지 예를 살펴보세요.

실적 예측의 두 가지 예

포착된 예외의 두 예는 비슷해 보이지만 포착 예측 휴리스틱은 상당히 다릅니다. 첫 번째 예에서는 확인된 약속이 생성된 후 예외를 발생시키는 .then()의 반응 작업이 예약되고 .catch()이 호출되어 거부 핸들러를 반응 약속에 연결합니다. 반응 작업이 실행되면 예외가 발생하고 약속 반응 트리에 catch 핸들러가 포함되므로 포착된 것으로 감지됩니다. 두 번째 예에서는 catch 핸들러를 추가하는 코드가 실행되기 전에 약속이 즉시 거부되므로 약속의 반응 트리에 거부 핸들러가 없습니다. 디버거는 호출 스택을 확인해야 하지만 try...catch 블록도 없습니다. 이를 올바르게 예측하기 위해 디버거는 코드의 현재 위치 앞쪽을 스캔하여 .catch() 호출을 찾고, 이를 토대로 거부가 궁극적으로 처리될 것이라고 가정합니다.

요약

이 설명을 통해 Chrome DevTools에서 포착 예측이 작동하는 방식, 장점, 제한사항을 파악하는 데 도움이 되었기를 바랍니다. 잘못된 예측으로 인해 디버깅 문제가 발생하면 다음 옵션을 고려하세요.

  • 코딩 패턴을 비동기 함수 사용과 같이 예측하기 더 간단한 것으로 변경합니다.
  • DevTools가 중지되어야 할 때 중지되지 않는 경우 모든 예외에서 중단되도록 선택합니다.
  • 디버거가 원치 않는 위치에서 중지되는 경우 '여기서 일시중지하지 않음' 중단점 또는 조건부 중단점을 사용합니다.

감사의 말씀

이 게시물을 수정하는 데 큰 도움을 주신 Sofia Emelianova님과 Jecelyn Yeen님께 감사드립니다.