Deno 4차 오픈소스 외주 후기

6 min read

계약 조건

방학이 2주 남아서 일단 2주 동안 저번과 같은 조건으로 작업하기로 했다. 근데 작업 목록이 좀 많았다. 2주 안에 과연 되려나 싶었지만 해보니까 되더라.

계약서의 작업 목록

어렵지 않을 거라 생각했다.

deno에 직접 구현하는 것이었다. swc를 통해 deno에 간접적으로 많은 기여를 했지만 직접 기여한 적은 없었다. 코드 읽기 어려울까봐 시간을 좀 길게 잡기는 했는데, 해보니까 별 거 아니었다.

러스트로 타입스크립트 타입 엔진 만들 때 비슷한 짓을 해봤기에 그리 어렵지 않을 것이라 봤다. 물론 쉬운 일은 아니었지만 한번 해봤으니 괜찮겠지 이런 생각이었다.

  • swc에 하이레벨 API 추가

구체적으로는 denocli/swc_util.rs를 swc 레포지토리로 옮기면 좋겠다고 얘기했다.

  • swc에 의존석 분석용 라이브러리 추가

이건 swc에서 제공하려는 api에서도 필요한 것이었다.

  • swc 오류 메시지 개선

swc의 에러 메시지는 안 그래도 언젠가 개선해야되는데 하고 있었던 부분이다. 나로서는 매우 잘된 었다.

  • swc 성능 개선

이건 원래도 꾸준히 하고 있었다.

실제 작업

(deno_lint) unused-vars (타입스크립트 지원 X)

3시간 정도 걸려서 호이스팅이나 variable shadowing 지원하는 린터를 만들었다. 쉽네 이러면서 다음 것 구상하고 있었는데 함정이 있었다.

deno_lint는 eslint를 포팅하는 프로젝트인데, eslint 테스트 보니까 겁나게 복잡하고, 변수만 잡는 것도 아니었다. 함수, 파라미터 다 처리하게 바꿨는데, 하나 고칠 때마다 테스트를 컴파일하다보니 너무 시간이 오래 걸렸다. 절대 길게 끌 일이 아니었는데 컴파일 8시간이나 걸렸다.

Pull request


이거 만들 때 매우 재밌는 방식을 사용했다. swcresolver 패스를 거치고 나면 모든 식별자(Identifier)가 유일해진다. 무슨 말이나면, 아래의 코드에서

let a = 1;
{
  let a = 2;
}

두개의 a를 구분할 수 있게 된다는 것이다. 물론 최종 사용자한테는 보이지 않는다. 이는 내가 만든 알고리즘인데, span의 hygiene 정보를 사용해서 유일성을 만들어내므로 span hygiene라고 아름 붙였다.

이 특성을 이용해서 unused-vars 린터를 아래 코드처럼 nested scope가 없는 형태로 구현했다.

struct Collector {
  used_vars: HashSet<Id>,
}

Id는 유일하므로, 이걸로 충분했다. 이름이 a인 변수가 많아도resolver가 처리하므로 상관 없었다.

(swc) type-resolver

위에서는 유일성을 이용해서 빠르게 끝냈는데, 그 유일성을 만들어주는 컴파일러 패스인 resolver가 타입을 지원하지 않았다. 그래서 resolver에 타입 시스템 지원을 추가했는데, 한 5시간 정도 걸렸던 것 같다. 각 ast 노드를 처리하는 규칙은 정해져있었고, 그에 맞게 구현만 하면 되는거라 금방 된 것 같다.

(swc) 에러 메시지 개선

기간을 길게 부르긴 했는데, 오류 메시지 바꾸는 건 오래 걸릴 이유가 없었다.

첫번째로 unexpected 호출하는 코드 주변 보고 무슨 토큰을 처리할 수 있는지 살펴보고 그 토큰 목록을 인자로 넘겨주었다. 그런 다음 토큰의 Debug 구현을 사용자가 알아볼 수 있게 고쳤다.

그리고 마지막으로 에러 메시지를 다 추가했는데,

error: TS1009
 --> $DIR/tests/test262-parser/fail/0053737b6145994c.js:1:6
  |
1 | var x, ;

error: Trailing comma is not allowed
 --> $DIR/tests/test262-parser/fail/0053737b6145994c.js:1:6
  |
1 | var x, ;
  |      ^

처럼 바꿨다. 사실 내가 한 건 fomrat!("{:?}", kind)SyntaxError::TS1109 => "Trailing comma is not allowed".into() 처럼 바꿔주는 것 뿐이다. 참고로 기존에 에러 메시지가 이상했던 건 에러 메시지 적기 귀찮아서 대충 기본 Debug구현으로 때웠기 때문이다.

(swc) 성능 개선

당장 할 수 있는 작업 중에서 딱히 땡기는 게 없어서 이걸 골랐다.

계약서상에 적혀있는 이슈는 swc 자체보단 spack에 관한 이슈들이었는데, spack 성능 문제의 원인은 3가지였다.

  • 모든 모듈 소스 코드가 여러 번 복사된다
  • 병렬 처리가 제대로 안 되어있다.
  • LocalMarker이 너무 자주 호출된다.

셋 다 빠르게 짜고 천천히 패치하겠단 생각으로 위해 넘어갔던 것들이라 어떻게 하면 빨라지는지 알고 있얶다. 참고로 swc는 변수에 표시를 해서 다른 모듈에 같은 이름의 무언가가 있어도 충돌하지 않고 자연스럽게 합쳐지도록 한다. (span hygiene가 그것이다). 그 과정에서 쓰이는 게 LocalMarker 였는데, 의존성 라이브러리를 병합할 때마다 호출됐기에 성능 문제가 있었다.

우선 LocalMarker 사용 횟수를 줄이기로 했다. LocalMarker을 모듈당 1번씩만 실행되게 바꿨는데, common js import와 es6 import는 문제가 없었고, cyclic import 쪽에 버그가 생겼다.


그런데 맥북 키가 또 빠졌다. 이번에도 커맨드 키... 수리 받으러 가서 한참 기다렸다. 이동 시간까지 합해서 3시간 넘게 걸렸던 것 같다.


맥북 맡기고 집에 와서 데스크탑으로 열심히 작업해서 swc_bundler의 병렬 처리를 거의 끝냈는데, 실제로 모듈을 병합하기 전에 모든 의존성을 검사해서 계획을 짠 뒤 그 계획에 맞춰 병렬 처리를 하도록 변경했다. 역시 이번에도 cyclic import에 문제가 생겼다.

그래서 코드를 살펴보니까 cyclic import 감지 코드에 문제가 있었다. 그 부분은 변수 하나 더 써서 고쳤다.

남은 건 clone 없애는 거였는데, 간단해 보였다. Arc만 잘 관리하면 되는 문제라고 생각인데, 프로파일링을 해보니 파서하고 hygiene이 문제고 모듈 데이터 clone하는 건 잡히지도 않길래 넘어갔다.

(deno_lint) unused_vars 완성

이것저것 다른 이슈 작업하다가 deno_lint가 사용하는 swc의 버전이 업데이트했다는 말을 들었다. 그래서 다시 작업에 들어갔다. cargo 의 patch 기능을 이용해서 ts-resolverunused-vars를 동시에 작업했다.

오래 안 걸릴 일이었는데 컴파일 시간 때문에 오래 걸렸다.

(deno_lint) Scope 분석

Scope analysis / Control flow analysis가 필요한 일부 린트 빼고 작업이 끝났고, Scope 분석기를 만들기 위해 어떤 린트를 스코프 기반으로 만들 건지 물어봤다.

  • no-class-assign
  • no-const-assign
  • no-ex-assign
  • no-func-assign
  • no-global-assign
  • no-import-assign
  • no-undef
  • no-redeclare

no-class-assign, no-const-assign, no-ex-assign, no-func-assign, no-import-assign 은 한 종류로 볼 수 있다. Scope에 바인딩의 종류를 제공하는 API를 만들면 해결될 문제들이니까 별 거 없을 것이라 같았는데, no-undef, no-global-assign,의 경우 None이나 비어있는 &[BindingKind]을 반환하면 될 것이라 생각했다.

하나씩 생각 해보면서 상단의 lint rule들을 다 구현할 수 있다는 걸 확인한 뒤에 작업을 마무리했다. 논리적으로 증명한 뒤 방법을 pr에 적어놓았다. 그리고 짧은 코드였지만 (아주 작은) 테스트도 넣었다.

(deno lint) Control flow 분석

완전한 Control flow 분석기를 만들 생각은 없었고, 그럴 필요도 없었다. 그래서 이번에도 이 분석기로 무슨 rule을 만들건지 물어봤다.

  • no-unreachable
  • no-fallthrough

이건 좀 막막했다. 구현해본적은 있는데 내가 만들었던 건 2만줄 넘는 완전한 분석기였다. 그래서 한참 고민했다. 생각만 하면 심심하니까 겸사겸사 swc의 성능을 최적화했다.

중간에 그냥 그래프 써서 쉽게 갈까 싶은 순간도 있었는데, 그냥 머리 쓰기로 했다. 머리 쓴다고 해봐야 거창한 건 아니고 이 구문이 나왔을 때 이 변수가 어떻고 저 변수가 어떻고 하는 수준이긴 하지만 효과가 있었다.


생각해보면 스코프의 종류가 다양하고 구문 실행이 끝나는 이유도 다양한 상황이었다.

예를 들어

while (true) return;
do return;
while (true);

이 둘은 같은 코드를 이용해서 분석할 수 없다. 그래서 각 루프가 무한히 반복하는 경우데 대한 코드를 각 반복문의 visitor로 옮겼다.

또한, break로 끝난 것과 return으로 끝난 건 완전히 다르다. 그래서 Done 이라는 enum을 만들고, 그 상태를 추적했다. 예를 들어, switch 처리가 끝났을 때 끝난 이유가 break이면 done을 초기화시키고, return이면 아래의 줄을 unreachable로 표시하는 방법을 사용했다.

그리고 no-reachable 린트도 직접 구현했다. 계약 조건상으로 보면 내가 할 일은 아니었지만, 내가 작성한 게 스코프를 제대로 분석하는지 알려면 뭔가 테스트가 필요했고, 검증할 겸 구현했다.

(swc) 성능 개선

deno 쪽에서 가져다 쓰는 타입스크립트 제거 pass의 설계가 잘못된 상태였다.

기존 설계에서는 아래 코드의 a를 2^4회 방문한다. 이를 모든 노드를 한 번만 방문하도록 바꾸니까 5배 빨라졌다.

{
  {
    {
      a;
    }
  }
}

(deno_lint) no-unreachable

Pull request

너무 간단해서 찜찜했었는데 deno의 std에 돌려보니까 에러가 잔뜩 나와서 고쳤다. 4시간 정도 걸렸는데 첫 컴파일에 1시간 반 정도를 썼다.

규칙이 엄청 복잡했다. 특정 구문은 return을 상속하고, break는 또 어디 있냐에 따라서 처리 규칙이 확연히 달라지고... 근데 사실 프로그래밍 해보면 이 정도는 별 것 아니다. 난 컴파일 기다리는 게 제일 어렵다. swc를 만들 정도로 기다리는 걸 싫어한다.

(swc) High-level api

디노 팀 멤버가 더 좋은 추상화를 찾았다고 PR 보내겠다고해서 그건 천천히 하기로 하고 계약을 끝냈다.

(swc) 의존성 분석

간단하게 만들어봤는데 위의 디노 팀 멤버가 더 좋은 방식으로 만들어서 PR 보내줬다.