Dart REPL PoC 발전시키기

Dart REPL PoC 발전시키기

Dart로 해킹하기

출처: Evolving the Dart REPL PoC

본문

Dart REPL은 **대화형 셸(interactive shell)**에서 Dart 표현식과 문(statement)을 평가할 수 있게 해줘요. 처음 Dart REPL에 관한 글을 쓴 지 꽤 오래됐는데, 아직 빠진 기능이 많아요. 특히 **동적 임포트(dynamic import)**와 최상위 선언(top-level declaration) 지원이 아주 유용할 텐데, 그걸 어떻게 지원할 수 있을지 살펴볼게요.

면책 조항: 저는 Google에서 일해요. 하지만 이 글은 개인 프로젝트에 관한 겁니다. 저는 Dart 팀이나 그와 관련된 팀에 속해 있지 않아요. 이 글에는 제 소박한 개인적인 의견만 담겨 있어요.

요약(tl;dr): Dart REPL을 실행하는 코드와 방법은 https://github.com/BlackHC/dart_repl 에서 찾을 수 있어요.

핫 리로드(Hot reload)

Flutter를 위해 Dart VM에 멋진 새 기능이 하나 추가됐어요. 바로 **핫 리로드(hot reload)**예요. 이를 자세히 설명하고 보여주는 재미있는 YouTube 클립이 Dart DevSummit에 있어요:

핫 리로드를 쓰면 프로그램이 실행되는 동안 코드를 바꿀 수 있어요. Dart VM이 여러분이 만든 변경을 감지해서, 모든 것이 계속 돌아가게 유지하면서 적용하려고 시도해요. 만약 적용할 수 없다면 왜 그런지 알려줘요. 정말 멋져요! Dart를 해킹해서 멋진 일을 하자는 취지에서, 이걸 어떻게 새 기능 구현에 쓸 수 있을지 생각해볼게요.

지금 버전의 REPL에서 왜 새 라이브러리를 임포트할 수 없을까?

REPL은 표현식을 평가하기 위해 Dart의 VM 서비스를 사용해요. 안타깝게도 Dart에서 라이브러리 임포트는 표현식이 아니에요. 그래서 그 맥락에서 그냥 평가할 수 없어요. 하지만 REPL의 샌드박스 코드가 실행되는 동안 그 코드를 바꿔서 새 라이브러리를 임포트하게 한 다음, 핫 리로드를 촉발해서 REPL을 업데이트할 수는 있어요. 이게 될까요? 실제로 돼요 \o/

런타임 임포트로 코드를 실행하는 Dart REPL을 보여주는 애니메이션 터미널

그런데 잠깐. VM 서비스로 표현식을 평가해서 할 수 없는 또 다른 것이 있어요. 바로 새 클래스와 함수를 만드는 것이에요. 사실 Dart의 최상위 선언 어떤 것도 표현식 하나를 평가해서는 실행될 수 없어요. 그게 바로 이 이유 때문이에요.

최상위 선언을 어떻게 허용할까?

물론 위에서 설명한 것과 같은 아이디어를 써서 새 클래스나 전역 함수를 추가할 수도 있어요. 하지만 IPython이나 비슷한 도구를 조금이라도 써본 분은 알겠지만, 코드를 반복해서 다듬을 때 같은 클래스나 함수를 자주 다시 선언하게 돼요. 코드를 가지고 놀면서 같은 코드의 조금씩 수정된 버전을 계속해서 재실행하죠.

이 선언들을 그냥 샌드박스 Dart 라이브러리에 추가한다면, 반복할 때마다 선언을 업데이트하기 위해 파일의 어디에 언제 무엇이 선언됐는지를 계속 추적해야 해요. 이건 많은 로직과 영리한 코드를 요구해요. 게다가 클래스 변경이 예전 코드나 다른 선언과의 호환성을 깨뜨리면 쉽게 망가지기도 해요. 그러면 REPL이 핫 리로드를 하지 못하고, 사용자가 강제로 다시 시작해야 하죠 :( 복잡하고 깨지기 쉬워요. 이기는 조합이 아니라고 생각해요!

스파이크와 체인(Spike & chains)

대신, 재선언들이 절대 충돌하지 않게 같은 최상위 선언을 여러 번 재정의할 수 있다면 어떨까요? Dart에서 그런 게 가능할까요? 물론 가능해요! 하지만 같은 라이브러리 안에서는 안 돼요 :) Dart는 라이브러리를 임포트한 다음, 기존 선언을 가리는(shadow) 클래스, 함수, 전역 변수를 선언할 수 있게 해줘요.

class MyClass {
  static final value = 1;
}
import 'a.dart';

class MyClass {
  static final value = 10;
}

이 예시에서 b.dartMyClassa.dart의 버전을 가린다고 불평하지 않아요. 그 둘은 서로 다른 라이브러리에 있고, b.dart의 로컬 선언이 a.dart에서 임포트한 것보다 우선하기 때문이에요.

일반적으로 **섀도잉(shadowing)**은 외부 스코프의 다른 변수를 숨기는 변수를 선언할 때 일어나는 일이에요. 예를 들어:

float a;

void func() {
  float a;  // This `a` shadows global `a`.
}

이걸 쓸 수 있을까요? 조사하기 위해 여기에 빠른 스파이크(spike)를 구현했어요. 이건 코드를 생성하지 않아요. 대신, 우리가 생각한 방식이 실제로 작동하는지 확인하기 위한 아주 단순한 예시예요. 코드 생성으로 구현하는 데 많은 시간을 썼는데 애초에 될 수 없는 일이었다는 걸 나중에 발견하면 정말 답답하니까요! 핵심은 이래요:

class A {
  int f() => 1;
}
import 'import_1.dart';
export 'import_1.dart';

class A {
  int f() => 2;
}
import 'import_2.dart';

void main() {
  result = new A().f();
  print(result);
}

이건 정말 작동해요! 서로를 임포트하는(그리고 서로를 익스포트하는 — 그래야 어디서든 심볼을 쓸 수 있으니까) 라이브러리 체인을 만들 수 있어요. 그러면 사용자는 원할 때마다 심볼을 재정의할 수 있어요. 당연히 이로 인해 예전 코드가 가려진 심볼을 참조하게 될 수도 있는데, 조금 헷갈리게 만들 수 있지만 적어도 깨지지는 않아요. IPython이나 비슷한 걸 써본 사람은 다 그렇게 사는 법을 배웠죠. 그렇게 나쁘진 않아요.

여러 Dart 라이브러리에서 최상위 선언이 어떻게 가려지는지 보여주는 다이어그램

위 다이어그램은 이게 어떻게 작동하는지 보여줘요. 새 최상위 선언을 추가할 때마다, 이전 셀을 임포트(하고 익스포트)하는 새 "셀"(Dart 라이브러리)이 생성돼요. 마지막 셀은 일반적인 Dart 표현식과 문의 실행 환경으로 쓰이는 샌드박스 라이브러리에 임포트돼요. 샌드박스 파일은 제자리에서 편집된 다음 핫 리로드로 다시 로드돼요.

워크플로 비전

게다가 아무것도 가리지 않고 코드를 계속 업데이트하고 싶다면 그것도 여전히 가능해요. 핫 리로드는 이미 일반적인 Dart 프로그램에서 이 워크플로를 허용하고 있거든요. REPL에서도 똑같이 할 수 있어요. 멋진 Dart 라이브러리 amazing_dart_library.dart를 편집해서 REPL에 임포트하고, 이것저것 해보면서, 원할 때 좋아하는 에디터에서 코드를 편집하고 reload()를 호출해서 REPL이 핫 리로드하게 만들 수 있어요. 두 세계의 장점을 모두 얻는 셈이죠 \o/

실제로 어떻게 구현할까?

자, 우리는 여기서 Dart를 해킹하고 있으니 살펴볼게요. 핫 리로드는 너무 새로운 기능이고 서비스 스펙도 아직 완전하지 않아서, vm_service_client가 아직 지원하지 않아요. 저는 Natalie(메인테이너)를 위해 이를 지원하는 풀 리퀘스트를 쓰기 시작했어요. 하지만 동료들이 알다시피, 프로덕션 품질의 코드는 제 전문이 아니에요. 특히 여가 시간에는요 (죄송해요 Natalie!). 그래도 이게 우리의 해킹 모험을 막지는 않아요.

Dart의 패키지 관리 시스템인 pub는 자동 버전 제약 해결과 중앙화된 패키지 저장소를 지원할 뿐만 아니라, 로컬 패키지를 쓰거나 GitHub에 직접 의존하는 것도 허용해요. 이건 보통 권장되지 않아요. pub가 훌륭한 많은 측면을 잃기 때문이에요. 하지만 여기서는 작동해요. 저는 그냥 vm_service_client를 제 GitHub 클론으로 포크하고 필요한 변경을 했어요. 코드는 https://github.com/BlackHC/vm_service_client/tree/reload_sources_poc 에서 찾을 수 있어요. 그 후에 Dart REPL의 pubspec.yaml을 공식 버전 대신 제 GitHub 클론을 가리키도록 바꿨어요:

[...]
  vm_service_client:
    git:
      url: git://github.com/BlackHC/vm_service_client.git
      ref: reload_sources_poc
[...]

그게 전부예요! 터미널에서 간단히 pub get만 하면 이제 Dart REPL이 포크된 버전을 쓰도록 업데이트돼요.

덕분에 뭐든 실험하기가 정말 쉬워졌어요. 다른 패키지를 포크해서 이것저것 시도하고 쉽게 의존할 수 있으니까요. 그리고 멋진 점은, 이걸 게시하면 여러분이 pub으로 REPL을 내려받을 때 GitHub에서 코드를 가져온다는 거예요. 해킹하기 쉽고 공유하기에도 좋죠! (일반적으로 프로덕션 패키지에는 권장되지 않지만요 :)

핵심적인 재미있는 로직은 위에서 논의한 것에 맞춰 아주 단순한 템플릿 메커니즘을 구현하는 **셀 생성기(cell generator)**예요:

import 'dart:io';

class DartTemplate {
  final String content;

  DartTemplate(this.content);

  void instantiate(String targetPath,
      {String source, String library, String imports}) {
    final instanceSource = content
        .replaceAll('/*{SOURCE}*/', source ?? '')
        .replaceAll('/*{IMPORTS}*/', imports ?? '')
        .replaceAll('/*{LIBRARY}*/', library ?? '');
    new File(targetPath).writeAsStringSync(instanceSource);
    //print('wrote $targetPath:\n$instanceSource');
  }
}

/// Keeps a chain of temporary cell files that import and re-export each other.
/// This allows us to create top-level cells that contains classes and other
/// top-level decls that can shadow each other.
class TopLevelCellChain {
  final DartTemplate cellTemplate;
  final String headName;
  final String basePath;

  int _currentCellIndex = 0;

  TopLevelCellChain(this.cellTemplate, this.headName, this.basePath);

  String get currentCellPath => '$basePath/$currentCellName';
  String get currentCellName => 'cell${_currentCellIndex}.dart';
  String get headPath => '$basePath/$headName';

  void addCell(String source) {
    // Import and export the previous cell.
    final imports = _currentCellIndex > 0
        ? '''
// Import the previous cell and export it to make its symbols available to the
// next cell.
import '$currentCellName';
export '$currentCellName';
'''
        : '';

    _currentCellIndex++;
    cellTemplate.instantiate(currentCellPath, imports: imports, source: source);

    // Update the sandbox.
    refreshSandboxLibrary();
  }

  void refreshSandboxLibrary() {
    var libraryStatement = '''
/// This library name is needed to find the library using reflection.
library sandbox;
''';

    cellTemplate.instantiate(headPath,
        imports: _currentCellIndex > 0 ? 'import \'$currentCellName\';' : '',
        library: libraryStatement);
  }

  void undoCell() {
    if (_currentCellIndex > 0) {
      _currentCellIndex--;
    }
    refreshSandboxLibrary();
  }
}

새 임포트가 필요할 때 REPL에서 핫 리로드 기능을 호출해요:

Future linkAndExecuteCell(SandboxIsolate sandboxIsolate, String input,
    VMRunnableIsolate runnableIsolate) async {
  sandboxIsolate.cellChain.addCell(input);
  final report = await runnableIsolate.reloadSources();
  if (!report.status) {
    print(report.message);
    // Undo the last cell, so we can try again.
    sandboxIsolate.cellChain.undoCell();
  }
}

거의 그게 전부예요! 모든 변경을 풀 리퀘스트에서 볼 수 있어요: https://github.com/BlackHC/dart_repl/pull/2 . 코드가 좀 지저분하고 정리되지 않았다는 건 인정해요. 풀 리퀘스트에는 REPL과 샌드박스 사이의 메시지 전달을 위한 관련 없는 래퍼 코드도 꽤 있어요. 안타깝게도 이게 주요 변경을 조금 흐리게 해요. 이걸 어떻게 리팩터링해서 다시 깔끔하게 정돈할지 봐야 해요… 하지만 때로는 최고의 코드와 풀 리퀘스트를 쓰는 것보다 일을 빠르게 돌리는 게 더 쉽기도 해요. 죄송해요!

최상위 선언이 있는 Dart REPL을 보여주는 애니메이션 터미널

Dart REPL의 소스는 https://github.com/BlackHC/dart_repl 에서 찾을 수 있어요. 최상위 선언을 지원하는 것 외에도 내장 import, loadPackage, reload 명령 지원도 추가했어요. (참고: loadPackage는 곧 출시될 Dart SDK 1.24 dev 빌드가 필요해요. 그 외에는 지금은 no-op이에요.) 이 내장 명령들은 모두 핫 리로드를 쓰는 사소한 확장이에요. 마지막으로, 로컬 pub 캐시에서 새 패키지를 로드하기 위해 훌륭한 pub_cache 패키지를 쓰고 있어요.

직접 시도해보려면(Dart SDK를 설치했다고 가정하고) 그냥 실행하면 돼요:

pub global activate dart_repl
pub global run dart_repl

이 글의 끝까지 읽어주셔서 감사해요! 어떻게 생각하는지 알려주세요 :)

안녕히 계세요, Andreas

더 알아보기