디버거 구축하기: 코드 분석

Nanda Syahrasyad 블로그 포스트 번역


몇 개월 전, 저는 Playground를 출시했습니다. 이것은 JavaScript 코드를 작성하고 debugger 문을 사용하여 브레이크 포인트를 설정할 수 있는 웹 기반 JavaScript 디버거입니다. 이건 제가 완성까지 진행한 첫 번째 개인 프로젝트였으며, 이전에 사용해본 적 없는 웹 기술들을 경험할 수 있는 놀라운 기회였습니다.
저는 엄청나게 많은 것을 배웠으며, 이것이 정확히 어떻게 작동하는지에 대해 이야기하려 합니다. 이 과정에서 우리는 Babel의 플러그인 API를 사용하여 자신만의 미니 디버거를 구축할 것입니다. 시작해봅시다!

아키텍처



Playground 데모 비디오
이 앱은 React UI와 두 부분으로 구성된 파이프라인으로 이루어져 있습니다: 코드 트랜스파일러코드 러너입니다. 사용자가 왼쪽 에디터에 코드를 입력하면, 코드는 파이프라인으로 전송되어 트랜스파일러와 러너 모두에 의해 처리됩니다. 코드가 처리된 후, 결과(변수 값의 목록)는 React 앱으로 다시 전송되어 표시됩니다.
파이프라인 내에서 트랜스파일러는 코드를 "디버깅 가능한" 코드로 변환합니다. 그런 다음 러너는 해당 코드를 평가하고 결과를 React 앱으로 다시 전달합니다.
하지만 코드를 어떻게 변환할까요? "디버깅 가능한"이 정확히 무엇을 의미할까요? 직접 재구축하여 알아봅시다.

Inspection

트랜스파일러의 유일한 책임은 앱의 나머지 부분이 함수가 실행되는 동안 함수 내부에서 무엇이 일어나고 있는지 알 수 있도록 코드를 수정하는 것입니다. 디버거의 경우, 함수 내부에서 "무엇이 일어나고 있는지" 아는 것은 일반적으로 주어진 시간에 지역 변수의 값을 아는 것을 의미합니다. 이제 질문은 다음과 같이 됩니다: 함수에서 이러한 값들을 어떻게 얻을까요?
수동으로 하는 경우, 함수의 구현을 업데이트하여 데이터를 외부 변수에 저장할 수 있습니다:
const variables = []

function sum(arr) {
let sum = 0
for (const num of arr) {
variables.push({ num, arr, sum })
sum += num
}
return sum
}
그런 다음 함수를 호출할 때, 해당 변수를 읽어서 함수 내부에서 무엇이 일어나고 있는지 알 수 있습니다:
sum([1, 2, 3])
console.log(variables) // { num: 1, arr: [1,2,3], sum: 0 }, ...
이것은 작고 일회성 함수에는 완벽하게 작동하지만, 확장성이 좋지 않습니다. 큰 함수와 많은 내부 변수가 있을 때, 각각을 스캔하고 직접 추가해야 합니다. Playground의 관점에서 보면, 사용자가 이것을 직접 하도록 하는 것도 말이 되지 않습니다.
여기서 우리의 첫번째 문제가 등장합니다

문제 1: 어디에 넣을까?

우리의 첫 번째 문제는 간단합니다. 이러한 저장 호출을 자동으로 생성하는 경우, 함수의 어디에 넣어야 할까요?
저는 이러한 호출을 자동으로 주입하는 방법들을 시도해봤지만, 궁극적으로 사용자가 디버깅하고 싶은 위치를 지정하는 것이 더 합리적이라고 생각했습니다. 물론, 이 indicator는 쓰기 쉬워야 합니다. 그렇지 않으면 원래 문제로 돌아갑니다. 결국, 저는 debugger 키워드를 indicator로 사용하기로 결정했습니다.
다음은 트랜스파일러가 정확히 무엇을 하는지에 대한 데모입니다. 간단히 말해서, 모든 debugger 문을 함수 호출로 변환합니다:


문제 2: 자동 변환

그렇다면 코드를 자동으로 변환하는 것을 어떻게 작성할까요?
코드는 궁극적으로 매우 긴 문자열이므로, 문자열을 시작점으로 사용할 수 있습니다. 그런 다음 정규식 매처를 사용하여 debugger의 모든 인스턴스를 저장 호출로 변경할 수 있습니다:
code.replaceAll('debugger', 'variables.push(/* stuff */)')
이것은 꽤 괜찮은 접근이지만, 코드에서 정보가 필요하기 시작하면 이 접근 방식은 정말 무너집니다. 지금 범위에 있는 변수는 무엇인가요? 이 변수의 이름은 무엇인가요? 이 변수가 선언되었습니까? 안전하게 사용할 수 있나요?
정규식 작업만 사용하여 이러한 질문에 답할 수 있는 방법을 생각할 수 있습니까? (저는 할 수 없습니다). 확실히, 우리는 코드를 나타내는 더 나은 방법이 필요합니다. 이상적으로는 이러한 질문에 쉽게 답할 수 있는 방법입니다.

추상 구문 트리

코드를 나타내는 가장 일반적인 방법은 추상 구문 트리 또는 줄여서 AST라고 불리는 것을 사용합니다. AST에서 소스 코드는 분해되어 나타내는 구조를 기반으로 "노드"로 그룹화됩니다.
...이것은 아마도 조금 불명확하게 들릴 것이므로, 예제를 살펴봅시다. 여기 저는 이전의 sum 함수를 가지고 있습니다:
const variables = []

function sum(arr) {
let sum = 0
for (const num of arr) {
variables.push({ num, arr, sum })
sum += num
}
return sum
}

sum([1, 2, 3])

AST는 항상 전체 소스 코드를 트리의 루트 노드로 시작합니다:
  • 전체 소스 코드
다음으로, 이 소스 코드를 나타내는 코드 구조를 기반으로 더 작은 노드로 분해합니다. 여기서 저희의 코드는 세 부분으로 구성됩니다. 맨 위에 변수 선언, 중간에 함수 정의, 끝에 함수 호출입니다. 그래서 세 개의 자식 노드를 추가해봅시다:
  • 전체 소스 코드
  • const variables = []
  • function sum(arr) { ... }
  • sum([1, 2, 3])
이 프로세스는 코드를 더 이상 분해할 수 없을 때까지 반복됩니다. 노드 레이블 옆의 '+' 버튼을 눌러 해당 노드가 어떻게 분해되는지 확인해보세요.
이러한 방식으로 코드를 나타냄으로써, 우리는 코드와 그 의미론적 의미에 대한 훨씬 더 많은 정보에 접근할 수 있습니다. 이것은 우리가 원하는 방식으로 코드를 조작하기 훨씬 쉽게 만듭니다 (예를 들어, 디버거를 작성하기 위해).
하지만 우리의 기존 문제는 여전히 해결되지 않았습니다. 처음부터 이 트리를 생성하기 위해 코드 문자열을 조작해야 하지 않을까요?

Babel API

이 문제를 해결하기 위해, 저희는 Babel 라이브러리를 활용할 것입니다. 특히, 저희는 Babel이 코드 파싱을 하도록 하여 AST 조작에 집중할 수 있도록 할 것입니다.
근본적으로 Babel은 JavaScript 컴파일러입니다. 이것은 JavaScript 코드를 입력받아 JavaScript 코드를 출력하는 프로그램이며, 이 과정에서 잠재적으로 수정합니다. 이것은 코드를 AST로 파싱하고, 플러그인을 통해 해당 AST를 조작한 다음, 마지막으로 AST를 다시 코드로 변환함으로써 수행됩니다.

우리가 이전 섹션에서 본 AST는 Babel에 의해 생성된 AST였지만 많은 속성이 제거되었습니다. 사실, Babel의 AST는 훨씬 더 표현력이 풍부합니다. 예를 들어, 여기 모든 원본 속성이 표시된 함수 호출의 '원본' AST가 있습니다:


플러그인

기본적으로 Babel은 코드에 어떤 변환도 적용하지 않습니다. 무언가를 하려면 일련의 플러그인을 제공해야 합니다. Babel 플러그인은 방문자(visitor)라고 불리는 것을 사용하여 AST를 수정하는 함수입니다. 다음과 같이 생겼습니다:
export default function (babelInstance) {
return {
visitor: {
Identifier(node) {
// do stuff with the Identifier node
},
VariableDeclaration(node) {
// do stuff with the VariableDeclaration node
},
/* do more stuff with more nodes */
},
}
}
AST를 수정하는 로직의 대부분, 아니면 전부는 이 방문자 객체에 있습니다. 저는 이 포스트의 나머지 부분에서 이것이 무엇이고 어떻게 작동하는지에 대해 이야기하는 데 집중할 것입니다.

Be My Guest

AST를 변환하려면, 트리를 순회하면서 방문할 때마다 각 노드를 한 번에 하나씩 수정해야 합니다. 방문자는 각 노드를 방문할 때 다양한 노드 타입이 어떻게 수정되는지를 설명하는 데 사용됩니다. 다음과 같이 작동합니다.
방문자는 노드 타입을 키로 하고 핸들러 함수를 값으로 하는 일반 객체입니다. 아이디어는 간단합니다. 현재 노드 타입이 객체에서 정의한 타입 중 하나와 일치하면, 핸들러 함수를 호출하여 노드를 수정합니다.
예를 들어, 여기 Identifier 노드 타입과 VariableDeclaration 노드 타입에 대한 핸들러가 있는 방문자가 있습니다:
const visitor = {
Identifier: function (node) {
// do stuff with the Identifier node
},
VariableDeclaration: function (node) {
// do stuff with the VariableDeclaration node
},
/* do more stuff with more nodes */
}
순회 알고리즘이 노드에 도달할 때마다, 알고리즘은 이 방문자 객체에서 무엇을 할지 확인합니다. 일치하는 것이 있으면, 좋습니다. 저희는 현재 노드와 함께 핸들러 함수를 호출합니다. 그렇지 않으면, 저희는 다음 노드로 이동합니다.

AST를 조작하는 로직과 순회 알고리즘을 분리하는 이 방법은 Babel에만 국한되지 않습니다. 사실, 이것은 방문자 디자인 패턴이라고 불리는 고전적인 디자인 패턴입니다. 이러한 관심사를 분리함으로써, 플러그인 작성자는 트리를 수정하고 싶은 방식에만 집중할 수 있습니다. 트리 순회 알고리즘이 어떻게 작동하는지에 대해 걱정할 필요가 없습니다.

경로(Paths)

솔직히 말해서, 저는 지난 섹션에서 조금 거짓말을 했습니다. Babel 플러그인의 방문자는 현재 노드를 받지 않고, 오히려 현재 경로를 받습니다. 경로는 AST 노드 주위의 래퍼로, 다음과 같은 것들을 파악할 수 있는 메서드를 제공합니다:
  • 현재 노드의 부모,
  • 현재 노드의 형제(있는 경우),
  • 현재 노드에서 범위에 있는 변수
그리고 노드를 교체, 제거 및 삽입하는 다양한 메서드도 있습니다. 경로에 대한 자세한 내용(그리고 일반적으로 Babel의 API)은, 저는 Jamie Kyle의 Babel 플러그인 핸드북을 강력히 추천합니다.

Visitor 구축하기

이제 방문자와 AST에 대해 조금 알았으니, 저희의 코드를 디버깅하기 위해 자신만의 Babel 플러그인을 함께 구성해봅시다. 요약하자면, 저희는 debugger의 모든 인스턴스를 범위에 있는 모든 변수의 값을 기록하는 코드로 변경하고 싶습니다

직접 시도해보세요!

해결책을 설명하기 전에, 직접 구축할 수 있는지 확인해보세요! 다시 한 번, Jamie Kyle의 Babel 플러그인 핸드북은 사용 가능한 API를 도와주는 귀중한 자료입니다. 특히, 저는 현재 범위에 있는 모든 변수의 객체를 반환하는 path.scope.getAllBindings() 메서드를 강조하고 싶습니다.
AST 플레이그라운드에서 도전해보세요. 맨 위의 코드 블록을 변경하면 나머지는 자동으로 업데이트됩니다!



해결책

함께 이것을 해결해봅시다. 가장 먼저 해야 할 일은 방문자에서 어떤 노드 타입을 대상으로 할지 파악하는 것입니다. 다행히, 우리가 관심 있는 debugger 문을 위해 특별히 지정된 노드 타입이 있습니다:
const a = b
debugger
이것은 DebuggerStatement 노드입니다.
그래서 우리는 방문자에서 이것을 대상으로 할 수 있습니다:
export default ({ types: t }) => {
return {
visitor: {
DebuggerStatement(path) {
/* todo */
},
},
}
}
다음 단계는 범위에 있는 모든 변수를 얻는 것입니다. 힌트에서 언급했듯이, Babel은 getAllBindings 메서드를 사용하여 이것을 정말 쉽게 만듭니다. 이 메서드는 키가 범위에 있는 변수의 이름이고 값이 변수에 대한 메타데이터를 포함하는 객체를 반환합니다.
저희는 변수의 이름만 필요하므로 Object.keys를 사용하여 추출해봅시다:
DebuggerStatement(path) {
const variables = Object.keys(path.scope.getAllBindings())
}
그리고 그것으로, 우리는 거의 완료했습니다! 남은 것은 _variables.push() 호출을 생성하는 것뿐입니다. 저희는 두 가지 방법으로 이것을 할 수 있습니다. 빌더를 사용하거나 replaceWithSourceString 메서드를 사용합니다.
빌더는 AST 노드를 생성하는 데 사용되는 헬퍼 함수입니다. 빌더를 사용하여 함수 호출을 생성할 때, 저희는 본질적으로 debugger 문을 교체하는 데 사용하는 "미니 AST"를 생성합니다.
미니 AST는 다음과 같이 보일 수 있습니다:
_variables.push({ a })
우리의 플러그인은 인수로 전달된 types 속성을 통해 완전한 빌더 함수 모음에 접근할 수 있습니다:
export default ({ types: t }) => {
/* plugin code */
}
빌더를 사용하는 것은 새로운 코드를 생성하는 권장 방법입니다. 왜냐하면 빠르고 탄력적이기 때문입니다. 결국, 객체를 다루는 것이 문자열을 다루는 것보다 훨씬 쉽습니다. 이전에 이야기했듯이 말입니다.
주요 단점은 상당히 장황할 수 있다는 것입니다. 저희가 구축하는 작은 AST는 최소한 8개의 다른 노드를 가지고 있으므로, 저희의 AST를 구축하기 위해 최소한 8번 빌더 함수를 호출해야 합니다.
이 때문에, 그리고 성능이 여기서 중요하지 않기 때문에, 대신 replaceWithSourceString 메서드를 사용할 것입니다. 이 메서드는 AST 노드 자체가 아닌 문자열로 코드 스니펫을 전달할 수 있게 합니다. 이것은 우리가 해야 할 모든 것이 다음과 같다는 것을 의미합니다:
DebuggerStatement(path) {
const variables = Object.keys(path.scope.getAllBindings())
path.replaceWithSourceString(`_variables.push({ ${variables.join()} })`)
}
여기서 join 메서드를 사용하여 변수 목록을 쉼표로 구분된 문자열로 변환합니다.
모든 것을 함께 넣으면, 우리의 최종 결과를 얻습니다:
export default ({ types: t }) => {
return {
visitor: {
DebuggerStatement(path) {
const variables = Object.keys(path.scope.getAllBindings())
path.replaceWithSourceString(
`_variables.push({ ${variables.join()} })`
)
},
},
}
}
궁금하다면, 여기 빌더 함수만 사용하는 경우의 모습입니다:
export default ({ types: t }) => {
return {
visitor: {
DebuggerStatement(path) {
const variables = Object.keys(path.scope.getAllBindings())
path.replaceWith(createSnapshot(t, variables))
},
},
}
}
/* Builds the _variables.push({ ... }) call */
function createSnapshot(t, scope) {
return t.expressionStatement(
t.callExpression(
t.memberExpression(
t.identifier('_variables'),
t.identifier('push')
),
[createObjectExpression(t, scope)]
)
)
}
/* Builds the object expression { ... } */
function createObjectExpression(t, variables) {
return t.objectExpression(
variables.map((variableName) =>
t.objectProperty(
t.identifier(variableName),
t.identifier(variableName)
)
)
)
}
이것은 또한 제가 코드를 작성할 당시 replaceWithSourceString 메서드를 알지 못했기 때문에 Playground 소스 코드에 있는 버전입니다!

엣지 케이스

자신만의 Babel 플러그인을 구축하는 큰 부분은 엣지 케이스를 처리하는 것입니다. 결국, 코드를 작성하는 방법은 너무 많습니다. 첫 번째 시도에서 뭔가를 놓칠 가능성이 높습니다!
우리가 방금 작성한 방문자에서, 우리는 꽤 큰 엣지 케이스를 놓쳤습니다. 그것을 발견할 수 있습니까? 여기 힌트가 있습니다: 변수를 선언하기 전에 debugger 문을 추가하면 어떻게 될까요?
// original

const a = 10
debugger
const b = 20
// transformed

const a = 10;
_variables.push({
a,
b
});
const b = 20;
저희의 출력 코드는 선언되기 전에 b를 참조하고 있습니다! 이 코드를 실행하려고 하면, 저희는 런타임 오류를 얻을 것입니다. 불행히도, Babel이 변수가 선언되었는지 여부를 감지하는 API를 가지고 있지 않기 때문에, 저희는 Babel에 도움을 요청할 수 없습니다.
이 특정 엣지 케이스를 해결하는 것은 조금 까다로웠으며 궁극적으로 이 포스트의 범위를 벗어났습니다. 하지만, Playground 소스 코드는 오픈 소스이므로, 어떻게 수행되는지 더 자세히 살펴보고 싶으시면 확인해보세요.

응용

AST가 어떻게 작동하는지 배우고 자신만의 Babel 플러그인을 만드는 것은 믿을 수 없을 정도로 insightful 합니다. 왜냐면 궁극적으로, 이것은 우리 웹 개발자들이 사용하는 많은 도구를 강화하기 때문입니다.
예를 들어, 가장 인기 있는 두 개의 린터와 포매터를 생각해봅시다. eslintprettier입니다. 이러한 도구들에 대해 멋진 점은 이것들이 이 포스트 전체에서 작업한 방문자와 AST의 동일한 원칙을 사용하여 구현된다는 것입니다! 예를 들어, eslint는 Babel처럼 규칙 API를 통해 자신만의 플러그인을 작성할 수 있게 합니다. 규칙의 소스 코드를 살펴보면, 규칙은 본질적으로 저희가 방금 작성한 것처럼 방문자 자체입니다!
저는 AST의 가장 중요한 사용 사례가 저희가 프로그래머로서 아마도 생각 없이 매일 사용하는 것이라고 생각합니다. 컴파일러와 인터프리터입니다. 이러한 도구들은 JavaScript와 같은 인간이 읽을 수 있는 코드를 가져와서 (AST를 통해!) 기계 코드로 변환합니다. 이것은 우리의 컴퓨터가 이해할 수 있는 낮은 수준의 언어입니다.

결론

그리고 이것이 트랜스파일러에 대한 전부입니다! 저는 디버거를 구축하고 이야기하는 것을 정말 좋아합니다. 앞으로도, 저는 디버거 프로젝트의 두 번째 부분인 코드 러너에 대해 더 이야기하고 싶습니다.
읽어주셔서 감사합니다!

0
26

댓글

?

아직 댓글이 없습니다.

첫 번째 댓글을 작성해보세요!