Zones

Zones (존)

비동기 코드를 관리해 볼게요. 처리되지 않은 오류 다루기, print()나 작업 스케줄링 같은 동작 덮어쓰기 등 다양한 일을 zone으로 할 수 있어요.

출처: Zones

본문

비동기 동적 범위 (Asynchronous dynamic extents)

이 글은 dart:async 라이브러리의 zone 관련 API를 다루며, 특히 최상위 함수인 runZoned()runZonedGuarded()에 초점을 맞춰요. 이 글을 읽기 전에 Futures와 오류 처리에서 다룬 기법들을 복습해 두세요.

Zone은 다음 작업들을 가능하게 해 줘요:

  • 앱이 잡히지 않은 예외로 종료되는 것을 막기. 예를 들어 간단한 HTTP 서버는 다음 비동기 코드를 사용할 수 있어요:
    runZonedGuarded(() {
      HttpServer.bind('0.0.0.0', port).then((server) {
        server.listen(staticFiles.serveRequest);
      });
    },
    (error, stackTrace) => print('Oh noes! $error $stackTrace'));
    
    HTTP 서버를 zone에서 실행하면 서버의 비동기 코드에 잡히지 않은(치명적이지 않은) 오류가 있어도 앱이 계속 실행될 수 있게 돼요.
  • 데이터를 개별 zone과 연결하기. 이를 zone-로컬 값(zone-local values) 이라고 불러요.
  • 코드의 일부 또는 전체에서 print()나 scheduleMicrotask() 같은 제한된 메서드 집합을 덮어쓰기(override).
  • 코드가 zone에 들어가거나 나올 때마다 작업 수행하기. 이런 작업에는 타이머 시작·중지, 스택 트레이스 저장 같은 것이 포함될 수 있어요.

다른 언어에서 zone과 비슷한 것을 접해 봤을 수도 있어요. Node.js의 Domains는 Dart zone의 영감이 되었어요. Java의 thread-local 저장소도 어느 정도 비슷한 점이 있어요. 가장 비슷한 것은 Brian Ford가 만든 Dart zone의 JavaScript 포팅 zone.js인데, 이 비디오에서 설명하고 있어요.

Zone 기본 (Zone basics)

zone은 호출(call)의 비동기 동적 범위(asynchronous dynamic extent) 를 나타내요. 호출의 일부로 수행되는 계산과, 그 코드가 (전이적으로) 등록한 비동기 콜백들이 바로 그것이에요.

예를 들어 HTTP 서버 예시에서 bind(), then(), 그리고 then()의 콜백은 모두 같은 zone — runZoned()으로 만든 그 zone — 안에서 실행돼요.

다음 예시에서는 코드가 3개의 서로 다른 zone에서 실행돼요: zone #1(루트 zone), zone #2, zone #3.

import 'dart:async';

main() {
  foo();
  var future;
  runZoned(() {          // Starts a new child zone (zone #2).
    future = new Future(bar).then(baz);
  });
  future.then(qux);
}

foo() => ...foo-body...  // Executed twice (once each in two zones).
bar() => ...bar-body...
baz(x) => runZoned(() => foo()); // New child zone (zone #3).
qux(x) => ...qux-body...

다음 그림은 코드의 실행 순서와 코드가 어떤 zone에서 실행되는지 보여 줘요.

[그림] 프로그램 실행을 나타내는 일러스트레이션

runZoned() 호출은 각각 새 zone을 만들고 그 zone 안에서 코드를 실행해요. 그 코드가 작업을 스케줄하면(예: baz() 호출) 그 작업은 스케줄된 위치의 zone에서 실행돼요. 예를 들어 qux() 호출(main()의 마지막 줄)은 그 자체가 zone #2에서 실행되는 future에 붙어 있음에도 zone #1(루트 zone) 에서 실행돼요.

자식 zone이 부모 zone을 완전히 대체하지는 않아요. 새 zone은 자신을 둘러싼 zone 안에 중첩돼요. 예를 들어 zone #2는 zone #3을 포함하고, zone #1(루트 zone)은 zone #2와 zone #3 둘 다를 포함해요.

모든 Dart 코드는 루트 zone에서 실행돼요. 코드는 다른 중첩된 자식 zone에서도 실행될 수 있지만, 최소한 항상 루트 zone에서는 실행돼요.

잡히지 않은 오류 처리하기 (Handling uncaught errors)

Zone은 잡히지 않은 오류를 잡고 처리할 수 있어요.

잡히지 않은 오류는 종종 throw로 예외를 발생시키는데 그것을 처리할 catch 문이 없을 때 생겨요. 잡히지 않은 오류는 또한 async 함수에서 Future가 오류 결과로 완료되는데 그 오류를 처리할 대응되는 await가 없을 때도 생길 수 있어요.

잡히지 않은 오류는 그것을 잡지 못한 현재 zone에 보고돼요. 기본적으로 zone은 잡히지 않은 오류에 대응해 프로그램을 크래시시켜요. 새 zone에 자신만의 커스텀 잡히지 않은 오류 핸들러를 설치하면 잡히지 않은 오류를 원하는 방식으로 가로채서 처리할 수 있어요.

잡히지 않은 오류 핸들러가 있는 새 zone을 도입하려면 runZonedGuarded 메서드를 사용해요. 그 onError 콜백이 새 zone의 잡히지 않은 오류 핸들러가 돼요. 이 콜백은 호출이 throw하는 모든 동기 오류를 처리해요.

runZonedGuarded(() {
  Timer.run(() { throw 'Would normally kill the program'; });
}, (error, stackTrace) {
  print('Uncaught error: $error');
});

잡히지 않은 오류 처리를 돕는 다른 zone API로는 Zone.fork, Zone.runGuarded, ZoneSpecification.uncaughtErrorHandler이 있어요.

앞의 코드에는 예외를 throw하는 비동기 콜백(Timer.run()을 통한)이 있어요. 보통 이 예외는 처리되지 않은 오류가 되어 최상위 레벨까지 도달하며(독립형 Dart 실행 파일에서는 실행 중인 프로세스를 죽임), 하지만 zone 오류 핸들러가 있으면 그 오류는 오류 핸들러에게 전달되고 프로그램은 종료되지 않아요.

try-catch와 zone 오류 핸들러의 한 가지 주목할 만한 차이는, zone은 잡히지 않은 오류가 발생한 뒤에도 계속 실행된다는 점이에요. zone 안에서 다른 비동기 콜백이 스케줄되어 있다면 그것들은 여전히 실행돼요. 결과적으로 zone 오류 핸들러는 여러 번 호출될 수 있어요.

잡히지 않은 오류 핸들러가 있는 모든 zone을 오류 zone(error zone) 이라고 불러요. 오류 zone은 그 zone의 자손(descendant)에서 발생한 오류를 처리할 수 있어요. future 변환 연쇄(순서)에서 오류가 어디에서 처리되는지를 결정하는 단순한 규칙이 있어요(then() 또는 catchError() 사용): Future 체인의 오류는 오류 zone의 경계를 절대 넘지 않아요.

오류가 오류 zone 경계에 도달하면 그 지점에서 처리되지 않은 오류로 취급돼요.

API 참고 잡히지 않은 오류를 처리하려고 zone이 꼭 필요한 건 아니에요. isolate API Isolate.run()도 잡히지 않은 오류 듣기를 처리해요.

예시: 오류는 오류 zone 안으로 들어갈 수 없어요

다음 예시에서 첫 줄이 발생시킨 오류는 오류 zone 안으로 들어갈 수 없어요.

var f = new Future.error(499);
f = f.whenComplete(() { print('Outside of zones'); });
runZoned(() {
  f = f.whenComplete(() { print('Inside non-error zone'); });
});
runZonedGuarded(() {
  f = f.whenComplete(() { print('Inside error zone (not called)'); });
}, (error) { print(error); });

예시를 실행하면 나오는 출력이에요:

Outside of zones
Inside non-error zone
Uncaught Error: 499
Unhandled exception:
499
...stack trace...

runZoned() 또는 runZonedGuarded() 호출을 제거하면 이 출력이 나와요:

Outside of zones
Inside non-error zone
Inside error zone (not called)
Uncaught Error: 499
Unhandled exception:
499
...stack trace...

zone이나 오류 zone 중 하나를 제거하면 오류가 더 멀리 전파되는 것에 주목하세요.

스택 트레이스는 오류가 오류 zone 밖에서 발생했기 때문에 나타나요. 코드 전체 조각 주위에 오류 zone을 추가하면 스택 트레이스를 피할 수 있어요.

예시: 오류는 오류 zone 밖으로 나갈 수 없어요

앞의 코드가 보여 주듯이 오류는 오류 zone 안으로 들어갈 수 없어요. 마찬가지로 오류는 오류 zone 밖으로 나갈 수 없어요. 다음 예시를 보세요:

var completer = new Completer();
var future = completer.future.then((x) => x + 1);
var zoneFuture;
runZonedGuarded(() {
  zoneFuture = future.then((y) => throw 'Inside zone');
}, (error) { print('Caught: $error'); });

zoneFuture.catchError((e) { print('Never reached'); });
completer.complete(499);

future 체인이 catchError()로 끝나더라도 비동기 오류는 오류 zone 밖으로 나갈 수 없어요. runZonedGuarded() 안의 zone 오류 핸들러가 그 오류를 처리해요. 그 결과 zoneFuture는 값으로도 오류로도 완료되지 않아요.

stream과 함께 zone 사용하기 (Using zones with streams)

zone과 stream의 규칙은 futures보다 더 단순해요:

참고 변환과 그 외 콜백은 stream이 들리는(생성되는) zone에서 실행돼요.

이 규칙은 stream은 들리기 전까지 부수 효과(side effect)가 없어야 한다는 지침에서 나와요. 동기 코드에서 비슷한 상황은 요청하기 전까지 값이 평가되지 않는 Iterable의 동작이에요.

예시: runZonedGuarded()와 함께 stream 사용하기

다음 예시는 콜백을 가진 stream을 준비하고, 그 stream을 runZonedGuarded()로 새 zone에서 실행해요:

var stream = new File('stream.dart').openRead()
    .map((x) => throw 'Callback throws');

runZonedGuarded(() { stream.listen(print); },
         (e) { print('Caught error: $e'); });

runZonedGuarded()의 오류 핸들러가 콜백이 throw한 오류를 잡아요. 출력이에요:

Caught error: Callback throws

출력이 보여 주듯이, 콜백은 map()이 호출된 zone이 아니라 듣는(리슨하는) zone 과 연결돼요.

zone-로컬 값 저장하기 (Storing zone-local values)

정적 변수를 쓰고 싶은데 동시에 실행 중인 여러 계산이 서로 간섭해서 쓸 수 없을 때, zone-로컬 값을 고려해 보세요. 디버깅을 돕기 위해 zone-로컬 값을 추가할 수도 있고요. 또 다른 사용 사례는 HTTP 요청을 다루는 것인데, 사용자 ID와 그 인증 토큰을 zone-로컬 값으로 가질 수 있어요.

새로 만든 zone에 값을 저장하려면 runZoned()zoneValues 인자를 사용해요:

runZoned(() {
  print(Zone.current[#key]);
}, zoneValues: { #key: 499 });

zone-로컬 값을 읽으려면 zone의 인덱스 연산자와 값의 키를 사용해요: [key]. 호환되는 operator ==hashCode 구현이 있으면 어떤 객체든 키로 쓸 수 있어요. 보통 키는 심볼 리터럴인 #identifier예요.

키가 가리키는 객체는 바꿀 수 없지만, 그 객체 자체를 조작할 수는 있어요. 예를 들어 다음 코드는 zone-로컬 리스트에 항목을 추가해요:

runZoned(() {
  Zone.current[#key].add(499);
  print(Zone.current[#key]); // [499]
}, zoneValues: { #key: [] });

zone은 부모 zone에서 zone-로컬 값을 상속하므로 중첩 zone을 추가해도 기존 값을 실수로 잃지 않아요. 다만 중첩 zone이 부모 값을 가릴(섀도잉) 수는 있어요.

중요 키에는 고유한 객체를 사용하도록 노력하세요. 그래야 다른 라이브러리와 충돌할 가능성이 줄어들어요.

예시: 디버그 로그에 zone-로컬 값 사용하기

foo.txtbar.txt 두 파일이 있고 그 모든 줄을 출력하고 싶다고 해 볼게요. 프로그램은 다음과 같을 수 있어요:

import 'dart:async';
import 'dart:convert';
import 'dart:io';

Future splitLinesStream(stream) {
  return stream
      .transform(ASCII.decoder)
      .transform(const LineSplitter())
      .toList();
}

Future splitLines(filename) {
  return splitLinesStream(new File(filename).openRead());
}
main() {
  Future.forEach(['foo.txt', 'bar.txt'],
                 (file) => splitLines(file)
                     .then((lines) { lines.forEach(print); }));
}

이 프로그램은 동작해요. 그런데 이제 각 줄이 어느 파일에서 왔는지 알고 싶고, splitLinesStream()에 파일 이름 인자를 그냥 추가할 수는 없다고 가정해 볼게요. zone-로컬 값으로 반환 문자열에 파일 이름을 추가할 수 있어요 (새 줄이 강조돼 있어요):

import 'dart:async';
import 'dart:convert';
import 'dart:io';

Future splitLinesStream(stream) {
  return stream
      .transform(ASCII.decoder)
      .transform(const LineSplitter())
      .map((line) => '${Zone.current[#filename]}: $line')
      .toList();
}

Future splitLines(filename) {
  return runZoned(() {
    return splitLinesStream(new File(filename).openRead());
  }, zoneValues: { #filename: filename });
}

main() {
  Future.forEach(['foo.txt', 'bar.txt'],
                 (file) => splitLines(file)
                     .then((lines) { lines.forEach(print); }));
}

새 코드는 함수 시그니처를 수정하거나 splitLines()에서 splitLinesStream()으로 파일 이름을 전달하지 않는다는 점에 주목하세요. 대신 zone-로컬 값을 사용해 비동기 컨텍스트에서 동작하는 정적 변수와 비슷한 기능을 구현했어요.

기능 덮어쓰기 (Overriding functionality)

zone이 관리하는 기능을 덮어쓰려면 runZoned()zoneSpecification 인자를 사용해요. 이 인자의 값은 ZoneSpecification 객체이며, 다음 기능 중 어느 것이든 덮어쓸 수 있어요:

  • 자식 zone 포크(fork)하기.
  • zone 안에서 콜백 등록하고 실행하기.
  • 마이크로태스크와 타이머 스케줄링하기.
  • 잡히지 않은 비동기 오류 처리하기(runZonedGuarded()는 이것의 단축키예요).
  • 출력(printing)하기.

예시: print 덮어쓰기

기능을 덮어쓰는 간단한 예시로, zone 안의 모든 print를 조용히 없애는 방법이 있어요:

import 'dart:async';

main() {
  runZoned(() {
    print('Will be ignored');
  }, zoneSpecification: new ZoneSpecification(
    print: (self, parent, zone, message) {
      // Ignore message.
    }));
}

포크된 zone 안에서 print() 함수는 지정된 print 인터셉터로 덮어써지며, 그 인터셉터는 단순히 메시지를 버려요. print()가 (scheduleMicrotask()와 Timer 생성자처럼) 작업을 하기 위해 현재 zone(Zone.current)을 사용하기 때문에 print를 덮어쓰는 게 가능해요.

인터셉터와 델리게이트의 인자들

print 예시가 보여 주듯이 인터셉터는 Zone 클래스의 해당 메서드에 정의된 인자에 인자 세 개를 더해요. 예를 들어 Zone의 print() 메서드는 인자가 하나예요: print(String line). ZoneSpecification이 정의하는 print()의 인터셉터 버전은 인자가 네 개예요: print(Zone self, ZoneDelegate parent, Zone zone, String line).

인자 세 개(인터셉터 인자)는 항상 다른 인자들보다 앞에 같은 순서로 나타나요.

  • self: 콜백을 처리 중인 zone.
  • parent: 부모 zone을 나타내는 ZoneDelegate. 연산을 부모로 전달(forward)할 때 사용해요.
  • zone: 연산이 시작된 zone. 일부 연산은 연산이 어떤 zone에서 호출됐는지 알아야 해요. 예를 들어 zone.fork(specification)은 zone의 자식으로 새 zone을 만들어야 해요. 또 다른 예로, scheduleMicrotask()를 다른 zone에 위임하더라도 마이크로태스크를 실행하는 쪽은 원래 zone이어야 해요.

인터셉터가 메서드를 부모에 위임할 때, 부모(ZoneDelegate) 버전의 그 메서드는 인자가 하나 더 있어요: 원래 호출이 시작된 zone인 zone. 예를 들어 ZoneDelegate의 print() 메서드 시그니처는 print(Zone zone, String line)이에요.

다른 인터셉트 가능한 메서드인 scheduleMicrotask()의 인자 예시를 볼게요:

정의된 곳 메서드 시그니처
Zone void scheduleMicrotask(void f())
ZoneSpecification void scheduleMicrotask(Zone self, ZoneDelegate parent, Zone zone, void f())
ZoneDelegate void scheduleMicrotask(Zone zone, void f())

예시: 부모 zone에 위임하기

부모 zone에 위임하는 방법을 보여 주는 예시예요:

import 'dart:async';

main() {
  runZoned(() {
    var currentZone = Zone.current;
    scheduleMicrotask(() {
      print(identical(currentZone, Zone.current));  // prints true.
    });
  }, zoneSpecification: new ZoneSpecification(
    scheduleMicrotask: (self, parent, zone, task) {
      print('scheduleMicrotask has been called inside the zone');
      // The origin `zone` needs to be passed to the parent so that
      // the task can be executed in it.
      parent.scheduleMicrotask(zone, task);
    }));
}

예시: zone에 들어가고 나올 때 코드 실행하기

일부 비동기 코드가 실행에 얼마나 시간을 쓰는지 알고 싶다고 해 볼게요. 코드를 zone에 넣고, zone에 들어갈 때마다 타이머를 시작하고, zone을 나갈 때마다 타이머를 멈추면 돼요.

ZoneSpecification에 run* 매개변수를 제공하면 zone이 실행하는 코드를 지정할 수 있어요.

API 참고 미래에는 zone 코드를 감싸는 흔한 경우에 대해 더 간단한 대안(onEnter/onLeave API)을 zone이 제공할 수도 있어요. 자세한 내용은 issue 17532를 참고하세요.

run* 매개변수 — run, runUnary, runBinary — 는 zone이 코드를 실행하도록 요청받을 때마다 실행할 코드를 지정해요. 이 매개변수는 각각 인자가 0개, 1개, 2개인 콜백에 대해 동작해요. run 매개변수는 runZoned()를 호출한 직후 실행되는 최초의 동기 코드에도 적용돼요.

run*을 사용한 프로파일링 코드 예시예요:

final total = new Stopwatch();
final user = new Stopwatch();

final specification = new ZoneSpecification(
  run: (self, parent, zone, f) {
    user.start();
    try { return parent.run(zone, f); } finally { user.stop(); }
  },
  runUnary: (self, parent, zone, f, arg) {
    user.start();
    try { return parent.runUnary(zone, f, arg); } finally { user.stop(); }
  },
  runBinary: (self, parent, zone, f, arg1, arg2) {
    user.start();
    try {
      return parent.runBinary(zone, f, arg1, arg2);
    } finally {
      user.stop();
    }
  });

runZoned(() {
  total.start();
  // ... Code that runs synchronously...
  // ... Then code that runs asynchronously ...
    .then((...) {
      print(total.elapsedMilliseconds);
      print(user.elapsedMilliseconds);
    });
}, zoneSpecification: specification);

이 코드에서 각 run* 덮어쓰기는 user 타이머를 시작하고, 지정된 함수를 실행하고, 그리고 user 타이머를 멈춰요.

예시: 콜백 처리하기

ZoneSpecification에 register*Callback 매개변수를 제공하면 콜백 코드 — zone에서 비동기적으로 실행되는 코드 — 를 감싸거나 바꿀 수 있어요. run* 매개변수처럼 register*Callback 매개변수도 세 가지 형태가 있어요: registerCallback(인자 없는 콜백), registerUnaryCallback(인자 하나), registerBinaryCallback(인자 두 개).

다음은 zone이 코드가 비동기 컨텍스트로 사라지기 전에 스택 트레이스를 저장하게 만드는 예시예요.

import 'dart:async';

get currentStackTrace {
  try {
    throw 0;
  } catch(_, st) {
    return st;
  }
}

var lastStackTrace = null;

bar() => throw "in bar";
foo() => new Future(bar);

main() {
  final specification = new ZoneSpecification(
    registerCallback: (self, parent, zone, f) {
      var stackTrace = currentStackTrace;
      return parent.registerCallback(zone, () {
        lastStackTrace = stackTrace;
        return f();
      });
    },
    registerUnaryCallback: (self, parent, zone, f) {
      var stackTrace = currentStackTrace;
      return parent.registerUnaryCallback(zone, (arg) {
        lastStackTrace = stackTrace;
        return f(arg);
      });
    },
    registerBinaryCallback: (self, parent, zone, f) {
      var stackTrace = currentStackTrace;
      return parent.registerBinaryCallback(zone, (arg1, arg2) {
        lastStackTrace = stackTrace;
        return f(arg1, arg2);
      });
    },
    handleUncaughtError: (self, parent, zone, error, stackTrace) {
      if (lastStackTrace != null) print("last stack: $lastStackTrace");
      return parent.handleUncaughtError(zone, error, stackTrace);
    });

  runZoned(() {
    foo();
  }, zoneSpecification: specification);
}

예시를 직접 실행해 보세요. foo()가 동기적으로 호출됐으므로 그 함수를 포함하는 "last stack" 트레이스(lastStackTrace)가 보일 거예요. 다음 스택 트레이스(stackTrace)는 비동기 컨텍스트에서 온 것으로, bar()는 알지만 foo()는 몰라요.

비동기 콜백 구현하기

비동기 API를 구현하더라도 zone을 전혀 다룰 필요가 없을 수도 있어요. 예를 들어 dart:io 라이브러리가 현재 zone을 추적할 거라고 예상할 수 있지만, 실제로는 Future와 Stream 같은 dart:async 클래스의 zone 처리에 의존해요.

zone을 명시적으로 처리한다면, 모든 비동기 콜백을 등록하고 각 콜백이 등록된 zone에서 호출되도록 보장해야 해요. Zone의 bind*Callback 헬퍼 메서드들이 이 작업을 쉽게 만들어 줘요. 이것들은 register*Callbackrun*의 단축키로, 각 콜백이 그 Zone에 등록되고 실행되도록 보장해요.

bind*Callback보다 더 많은 제어가 필요하다면 register*Callbackrun*을 사용해야 해요. Zone의 run*Guarded 메서드를 사용하고 싶을 수도 있는데, 이것은 호출을 try-catch로 감싸고 오류가 발생하면 uncaughtErrorHandler를 호출해요.

요약 (Summary)

Zone은 비동기 코드의 잡히지 않은 예외로부터 코드를 보호하는 데 좋아요. 하지만 그것보다 훨씬 더 많은 일을 할 수 있어요. zone에 데이터를 연결할 수 있고, 출력과 작업 스케줄링 같은 핵심 기능을 덮어쓸 수도 있어요. zone은 더 나은 디버깅을 가능하게 하고 프로파일링 같은 기능에 쓸 수 있는 훅을 제공해요.

더 읽을거리 (More resources)

Zone 관련 API 문서

runZoned(), runZonedGuarded(), Zone, ZoneDelegate, ZoneSpecification의 문서를 읽어 보세요.

stack_trace

stack_trace 라이브러리의 Chain 클래스로 비동기적으로 실행되는 코드에 대해 더 나은 스택 트레이스를 얻을 수 있어요. 자세한 내용은 pub.dev 사이트의 stack_trace 패키지를 참고하세요.

더 많은 예시 (More examples)

zone을 사용하는 좀 더 복잡한 예시들을 몇 가지 볼게요.

task_interceptor 예시: task_interceptor.dart의 toy zone은 scheduleMicrotask, createTimer, createPeriodicTimer를 가로채서 이벤트 루프에 양보하지 않고 Dart 원시 기능의 동작을 시뮬레이션해요.

stack_trace 패키지의 소스 코드: stack_trace 패키지는 비동기 코드 디버깅을 위해 스택 트레이스 체인을 만드는 데 zone을 사용해요. 사용된 zone 기능에는 오류 처리, zone-로컬 값, 콜백이 포함돼요. stack_trace 소스 코드는 stack_trace GitHub 프로젝트에서 찾을 수 있어요.

dart:async의 소스 코드: 이 두 SDK 라이브러리는 비동기 콜백을 특징으로 하는 API를 구현하므로 zone을 다룬다는 점이 흥미로워요. Dart GitHub 프로젝트sdk/lib 디렉터리 아래에서 그 소스 코드를 살펴보거나 다운로드할 수 있어요.

이 글을 검토해 준 Anders Johnsen과 Lasse Reichstein Nielsen에게 감사드려요.

더 알아보기