Converter와 Codec

Converter와 Codec (Converters and codecs)

효율적인 변환(conversion)을 작성하는 방법을 배워 볼게요.

출처: Converters and codecs

본문

  • 작성: Florian Loitsch
  • 2014년 2월 (2015년 3월 업데이트)

서로 다른 표현 사이에서 데이터를 변환하는 것은 컴퓨터 공학에서 흔한 작업이에요. Dart도 예외가 아니며, 변환기(converter) 집합과 새 변환기를 만드는 유용한 도구를 제공하는 핵심 라이브러리인 dart:convert가 함께 와요. 라이브러리가 제공하는 변환기 예시로는 JSON, UTF-8 같은 흔히 쓰이는 인코딩을 위한 것들이 있어요. 이 문서에서는 Dart의 변환기가 어떻게 동작하는지, 그리고 Dart 세계에 잘 맞는 자신만의 효율적인 변환기를 만드는 방법을 보여 줄 거예요.

큰 그림 (Big picture)

Dart의 변환 구조는 한 표현에서 다른 표현으로 옮겨 주는 변환기(converter) 를 기반으로 해요. 변환이 가역적이면 두 변환기가 코덱(codec, coder-decoder) 으로 묶여요. codec이라는 용어는 오디오와 비디오 처리에서 자주 쓰이지만 UTF-8이나 JSON 같은 문자열 인코딩에도 적용돼요.

관례상 Dart의 모든 변환기는 dart:convert 라이브러리가 제공하는 추상화를 사용해요. 이는 개발자에게 일관된 API를 제공하고 변환기들이 함께 동작할 수 있게 해 줘요. 예를 들어 변환기(또는 codec)는 타입이 맞으면 서로 융합(fuse)될 수 있고, 그 결과 변환기는 하나의 단위로 사용될 수 있어요. 게다가 이런 융합된 변환기는 별도로 사용했을 때보다 자주 더 효율적으로 동작해요.

Codec

codec은 하나는 인코딩하고 다른 하나는 디코딩하는 두 변환기의 조합이에요:

abstract class Codec<S, T> {
  const Codec();

  T encode(S input) => encoder.convert(input);
  S decode(T encoded) => decoder.convert(encoded);

  Converter<S, T> get encoder;
  Converter<T, S> get decoder;

  Codec<S, dynamic> fuse(Codec<T, dynamic> other) { .. }
  Codec<T, S> get inverted => ...;
}

보이는 것처럼 codec은 encode()decode() 같은 편의 메서드를 제공하며, 이것들은 encoder와 decoder로 표현돼요. fuse() 메서드와 inverted 게터를 통해 각각 변환기를 융합하고 codec의 방향을 바꿀 수 있어요. Codec의 이 두 멤버에 대한 기본 구현은 견고한 기본 구현을 제공하므로, 구현자들은 보통 이것들을 걱정하지 않아도 돼요.

encode()decode() 메서드도 건드리지 않고 둬도 되지만, 추가 인자를 위해 확장될 수는 있어요. 예를 들어 JsonCodecencode()decode()에 명명 인자를 추가해 이 메서드들을 더 유용하게 만들어요:

dynamic decode(String source, {reviver(var key, var value)}) { … }
String encode(Object value, {toEncodable(var object)}) { … }

codec은 encode()/decode() 호출 중 명명 인자로 덮어쓰지 않는 한 기본값으로 사용되는 인자로 인스턴스화될 수 있어요.

const JsonCodec({reviver(var key, var value), toEncodable(var object)})
  ...

일반 규칙으로: codec을 구성할 수 있다면 encode()/decode() 메서드에 명명 인자를 추가하고 그 기본값을 생성자에서 설정할 수 있게 해야 해요. 가능하면 codec 생성자는 const 생성자여야 해요.

Converter

변환기, 특히 그 convert() 메서드가 실제 변환이 일어나는 곳이에요:

T convert(S input);  // where T is the target and S the source type.

최소한의 변환기 구현은 Converter 클래스를 상속하고 convert() 메서드를 구현하기만 하면 돼요. Codec 클래스와 비슷하게, 변환기는 생성자를 확장하고 convert() 메서드에 명명 인자를 추가해서 구성할 수 있게 만들 수 있어요.

이런 최소한의 변환기는 동기 설정에서는 동작하지만, 청크(chunk)와 함께 사용되면(동기적이든 비동기적이든) 동작하지 않아요. 특히 이런 단순한 변환기는 (Converters의 더 좋은 기능 중 하나인) 트랜스포머(transformer)로 동작하지 않아요. 완전히 구현된 변환기는 StreamTransformer 인터페이스를 구현하므로 Stream.transform() 메서드에 주어질 수 있어요.

아마 가장 흔한 사용 사례는 utf8.decoder로 UTF-8을 디코딩하는 것이에요:

File.openRead().transform(utf8.decoder).

청크 변환 (Chunked conversion)

청크 변환(chunked conversion)의 개념은 혼란스러울 수 있지만, 그 핵심은 비교적 단순해요. 청크 변환(스트림 변환 포함)이 시작되면 변환기의 startChunkedConversion 메서드가 인자로 output-sink를 받으며 호출돼요. 그러면 그 메서드는 호출자가 데이터를 넣는 input sink를 반환해요.

[그림] 청크 변환

참고: 그림에서 별표(*)는 선택적인 여러 번의 호출을 나타내요.

그림에서 첫 단계는 변환된 데이터로 채워질 outputSink를 만드는 것이에요. 그런 다음 사용자는 output sink와 함께 변환기의 startChunkedConversion() 메서드를 호출해요. 결과는 add()close() 메서드를 가진 input sink예요.

이후 어느 시점에 청크 변환을 시작한 코드는 (여러 번일 수 있는)add() 메서드를 데이터와 함께 호출해요. 데이터는 input sink가 변환해요. 변환된 데이터가 준비되면 input sink는 (여러 번의 add() 호출로) 그것을 output sink에 보내요. 마지막으로 사용자는 close()를 호출해 변환을 마치는데, 이 시점에 남은 변환 데이터가 input sink에서 output sink로 보내지고 output sink가 닫혀요.

변환기에 따라 input sink는 들어오는 데이터의 일부를 버퍼링해야 할 수도 있어요. 예를 들어 첫 청크로 ab\ncd를 받는 줄-분할기(line-splitter)는 ab로 자신의 output sink를 안전하게 호출할 수 있지만, cd를 처리하려면 다음 데이터(또는 close() 호출)를 기다려야 해요. 다음 데이터가 e\nf라면 input sink는 cde를 이어 붙여 output sink를 cde 문자열로 호출해야 하고, 다음 데이터 이벤트(또는 close() 호출)를 위해 f를 버퍼링해야 해요.

input sink(변환기와 함께)의 복잡성은 다양해요. 어떤 청크 변환은 비청크 버전에 순순히 대응되지만(문자 a를 제거하는 String→String 변환기 같은), 다른 것들은 더 복잡해요. 청크 변환을 구현하는 안전하지만 비효율적인(그리고 보통 권장되지 않는) 방법은 들어오는 모든 데이터를 버퍼링하고 연결한 뒤 한 번에 변환하는 거예요. JSON 디코더가 현재(2014년 1월) 그렇게 구현돼 있어요.

흥미롭게도 청크 변환의 타입은 동기 변환에서 추정될 수 없어요. 예를 들어 HtmlEscape 변환기는 동기적으로 String을 String으로 변환하고, 비동기적으로는 String 청크를 String 청크로 변환해요(String→String). LineSplitter 변환기는 동기적으로 String을 List(개별 줄들)로 변환해요. 동기 시그니처의 차이에도 불구하고 LineSplitter 변환기의 청크 버전은 HtmlEscape와 같은 시그니처인 String→String이에요. 이 경우 각 개별 출력 청크가 한 줄을 나타내요.

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

void main() async {
  // HtmlEscape synchronously converts Strings to Strings.
  print(const HtmlEscape().convert("foo")); // "foo".
  // When used in a chunked way it converts from Strings
  // to Strings.
  var stream = new Stream.fromIterable(["f", "o", "o"]);
  print(await (stream.transform(const HtmlEscape())
                     .toList()));    // ["f", "o", "o"].

  // LineSplitter synchronously converts Strings to Lists of String.
  print(const LineSplitter().convert("foo\nbar")); // ["foo", "bar"]
  // However, asynchronously it converts from Strings to Strings (and
  // not Lists of Strings).
  var stream2 = new Stream.fromIterable(["fo", "o\nb", "ar"]);
  print("${await (stream2.transform(const LineSplitter())
                          .toList())}");
}

일반적으로 청크 변환의 타입은 StreamTransformer로 사용될 때 가장 유용한 경우에 의해 결정돼요.

ChunkedConversionSink

ChunkedConversionSink은 변환기에 새 데이터를 추가하거나 변환기의 출력으로 사용돼요. 기본 ChunkedConversionSink는 두 메서드인 add()close()를 가지고 있어요. 이것들은 StringSink이나 StreamSink 같은 시스템의 모든 다른 sink들과 같은 기능을 해요.

ChunkedConversionSink의 의미는 IOSink와 비슷해요: sink에 추가된 데이터는 데이터가 처리되었다고 보장되지 않는 한 수정되면 안 돼요. String에 대해서는 문제가 없지만(불변이므로), 바이트 리스트에 대해서는 리스트의 새 복사본을 할당해야 하는 일이 잦아요. 이는 비효율적일 수 있어서 dart:convert 라이브러리는 데이터를 전달하는 더 효율적인 방법을 지원하는 ChunkedConversionSink의 하위 클래스와 함께 온다.

예를 들어 ByteConversionSink에는 추가 메서드가 있어요:

void addSlice(List<int> chunk, int start, int end, bool isLast);

의미상으로는 리스트(붙잡고 있을 수 없음), 변환기가 작업할 하위 범위, 그리고 close()를 호출하는 대신 설정할 수 있는 불리언 isLast를 받아요.

import 'dart:convert';

void main() {
  var outSink = new ChunkedConversionSink.withCallback((chunks) {
    print(chunks.single); // 𝅘𝅥𝅯
  });

  var inSink = utf8.decoder.startChunkedConversion(outSink);
  var list = [0xF0, 0x9D];
  inSink.addSlice(list, 0, 2, false);
  // Since we used `addSlice` we are allowed to reuse the list.
  list[0] = 0x85;
  list[1] = 0xA1;
  inSink.addSlice(list, 0, 2, true);
}

청크 변환 sink(변환기의 입력과 출력 모두로 사용됨)의 사용자로서 이것은 단순히 더 많은 선택을 제공해요. 리스트가 붙잡히지 않는다는 사실은 캐시를 사용해 매 호출마다 그것을 재사용할 수 있다는 뜻이에요. add()close()를 결합하면 수신자가 데이터를 버퍼링하지 않아도 되도록 도울 수 있어요. 하위 리스트를 받아들이면 비싼 subList() 호출(데이터를 복사하기 위한)을 피할 수 있어요.

이 인터페이스의 단점은 구현하기가 더 복잡하다는 점이에요. 개발자를 위해, dart:convert의 모든 개선된 청크 변환 sink는 하나를 제외한 모든 메서드를 구현한 기반 클래스와 함께 온다(추상인 것). 변환 sink의 구현자는 추가 메서드를 활용할지 스스로 결정할 수 있어요.

참고: 청크 변환 sink는 해당 기반 클래스를 반드시 상속해야 해요. 이는 기존 sink 인터페이스에 기능을 추가해도 확장된 sink를 깨뜨리지 않도록 보장해 줘요.

예시 (Example)

이 섹션은 간단한 암호화 변환기를 만드는 데 필요한 모든 단계와, 커스텀 ChunkedConversionSink가 성능을 어떻게 개선할 수 있는지 보여 줘요.

암호화 루틴이 단순히 주어진 키만큼 바이트를 회전(rotate)시키는 간단한 동기 변환기부터 시작해 볼게요:

import 'dart:convert';

/// A simple extension of Rot13 to bytes and a key.
class RotConverter extends Converter<List<int>, List<int>> {
  final _key;
  const RotConverter(this._key);

  List<int> convert(List<int> data, { int key }) {
    if (key == null) key = this._key;
    var result = new List<int>(data.length);
    for (int i = 0; i < data.length; i++) {
      result[i] = (data[i] + key) % 256;
    }
    return result;
  }
}

대응하는 Codec 클래스도 간단해요:

class Rot extends Codec<List<int>, List<int>> {
  final _key;
  const Rot(this._key);

  List<int> encode(List<int> data, { int key }) {
    if (key == null) key = this._key;
    return new RotConverter(key).convert(data);
  }

  List<int> decode(List<int> data, { int key }) {
    if (key == null) key = this._key;
    return new RotConverter(-key).convert(data);
  }

  RotConverter get encoder => new RotConverter(_key);
  RotConverter get decoder => new RotConverter(-_key);
}

몇 가지 새 할당은 피할 수 있지만(그리고 피해야 하지만), 단순함을 위해 필요할 때마다 RotConverter의 새 인스턴스를 할당해요.

이렇게 Rot codec을 사용해요:

const Rot ROT128 = const Rot(128);
const Rot ROT1 = const Rot(1);

void main() {
  print(const RotConverter(128).convert([0, 128, 255, 1]));   // [128, 0, 127, 129]
  print(const RotConverter(128).convert([128, 0, 127, 129])); // [0, 128, 255, 1]
  print(const RotConverter(-128).convert([128, 0, 127, 129]));// [0, 128, 255, 1]

  print(ROT1.decode(ROT1.encode([0, 128, 255, 1])));          // [0, 128, 255, 1]
  print(ROT128.decode(ROT128.encode([0, 128, 255, 1])));      // [0, 128, 255, 1]
}

올바른 방향으로 가고 있어요. codec은 동작하지만 아직 청크 인코딩 부분이 빠져 있어요. 각 바이트가 개별적으로 인코딩되므로 동기 변환 메서드로 대체할 수 있어요:

class RotConverter {
  ...
  RotSink startChunkedConversion(sink) {
    return new RotSink(_key, sink);
  }
}

class RotSink extends ChunkedConversionSink<List<int>> {
  final _converter;
  final ChunkedConversionSink<List<int>> _outSink;
  RotSink(key, this._outSink) : _converter = new RotConverter(key);

  void add(List<int> data) {
    _outSink.add(_converter.convert(data));
  }

  void close() {
    _outSink.close();
  }
}

이제 변환기를 청크 변환이나 심지어 스트림 변환에도 사용할 수 있어요:

import 'dart:io';

void main(List<String> args) {
  String inFile = args[0];
  String outFile = args[1];
  int key = int.parse(args[2]);
  new File(inFile)
    .openRead()
    .transform(new RotConverter(key))
    .pipe(new File(outFile).openWrite());
}

특화된 ChunkedConversionSink (Specialized ChunkedConversionSinks)

많은 목적에 현재 버전의 Rot로 충분해요. 즉 개선의 이점이 더 복잡한 코드와 테스트 요구 사항의 비용보다 작다는 뜻이죠. 그런데 변환기의 성능이 중요하다고 가정해 볼게요(핫 경로에 있고 프로파일 위에 있음). 게다가 매 청크마다 새 리스트를 할당하는 비용이 성능을 죽이고 있다고 가정해요(합리적인 가정이에요).

할당 비용을 더 싸게 만드는 것부터 시작해요: 타입 있는 바이트 리스트를 사용하면 할당된 리스트의 크기를 (64비트 머신에서) 8배 줄일 수 있어요. 이 한 줄 변경은 할당을 없애지는 못하지만 훨씬 더 싸게 만들어 줘요.

입력을 덮어쓰면 할당을 아예 피할 수도 있어요. 다음 버전의 RotSink에서는 정확히 그렇게 하는 새 메서드 addModifiable()를 추가해요:

class RotSink extends ChunkedConversionSink<List<int>> {
  final _key;
  final ChunkedConversionSink<List<int>> _outSink;
  RotSink(this._key, this._outSink);

  void add(List<int> data) {
    addModifiable(new Uint8List.fromList(data));
  }

  void addModifiable(List<int> data) {
    for (int i = 0; i < data.length; i++) {
      data[i] = (data[i] + _key) % 256;
    }
    _outSink.add(data);
  }

  void close() {
    _outSink.close();
  }
}

단순함을 위해 완전한 리스트를 소비하는 새 메서드를 제안해요. 더 고급 메서드(예: addModifiableSlice())는 범위 인자(from, to)와 isLast 불리언을 인자로 받을 거예요.

이 새 메서드는 아직 트랜스포머가 사용하지 않지만, startChunkedConversion()을 명시적으로 호출할 때는 이미 사용할 수 있어요.

void main() {
  var outSink = new ChunkedConversionSink.withCallback((chunks) {
    print(chunks); // [[31, 32, 33], [24, 25, 26]]
  });
  var inSink = new RotConverter(30).startChunkedConversion(outSink);
  inSink.addModifiable([1, 2, 3]);
  inSink.addModifiable([250, 251, 252]);
  inSink.close();
}

이 작은 예시에서는 성능이 눈에 띄게 다르지 않지만, 내부적으로 청크 변환이 개별 청크에 대한 새 리스트 할당을 피해요. 작은 청크 두 개에 대해서는 차이가 없지만, 스트림 트랜스포머에 대해 이것을 구현하면 더 큰 파일 암호화가 눈에 띄게 빨라질 수 있어요.

이를 위해 IOStream이 수정 가능한 리스트를 제공한다는 문서화되지 않은 기능을 활용할 수 있어요. 이제 add()를 다시 작성해 addModifiable()을 직접 가리키게 할 수도 있지만, 일반적으로 이것은 안전하지 않고 그런 변환기는 추적하기 어려운 버그의 잠재적 원인이 될 수 있어요. 대신 수정 불가를 수정 가능으로 변환하는 변환기를 명시적으로 작성한 다음 두 변환기를 융합해요.

class ToModifiableConverter extends Converter<List<int>, List<int>> {
  List<int> convert(List<int> data) => data;
  ToModifiableSink startChunkedConversion(RotSink sink) {
    return new ToModifiableSink(sink);
  }
}

class ToModifiableSink
    extends ChunkedConversionSink<List<int>, List<int>> {
  final RotSink sink;
  ToModifiableSink(this.sink);

  void add(List<int> data) { sink.addModifiable(data); }
  void close() { sink.close(); }
}

ToModifiableSink는 다음 sink에 들어오는 청크가 수정 가능하다는 신호를 보낼 뿐이에요. 이것으로 파이프라인을 더 효율적으로 만들 수 있어요:

void main(List<String> args) {
  String inFile = args[0];
  String outFile = args[1];
  int key = int.parse(args[2]);
  new File(inFile)
      .openRead()
      .transform(
          new ToModifiableConverter().fuse(new RotConverter(key)))
      .pipe(new File(outFile).openWrite());
}

제 머신에서 이 작은 수정은 11MB 파일의 암호화 시간을 450ms에서 260ms로 줄였어요. 기존 codec과의 호환성(fuse() 메서드 관련)을 잃지 않고 이 속도 향상을 얻었으며, 변환기는 여전히 스트림 트랜스포머로 동작해요.

입력 재사용은 우리 Rot 암호뿐 아니라 다른 변환기들과도 잘 동작해요. 그러니 이 개념을 일반화하는 인터페이스를 만들어야 해요. 단순함을 위해 CipherSink라고 이름 지었지만, 물론 암호화 세계 밖에서도 용도가 있어요.

abstract class CipherSink
    extends ChunkedConversionSink<List<int>, List<int>> {
  void addModifiable(List<int> data) { add(data); }
}

그러면 RotSink를 private으로 만들고 대신 CipherSink를 노출할 수 있어요. 다른 개발자들은 이제 우리 작업(CipherSink와 ToModifiableConverter)을 재사용하고 그것으로부터 이점을 얻을 수 있어요.

하지만 아직 끝나지 않았어요.

암호를 더 이상 빠르게 만들 수는 없지만, Rot 변환기의 출력 쪽을 개선할 수 있어요. 두 암호화의 융합을 생각해 보세요:

void main(List<String> args) {
  String inFile = args[0];
  String outFile = args[1];
  int key = int.parse(args[2]);
  // Double-strength cipher running the Rot-cipher twice.
  var transformer = new ToModifiableConverter()
       .fuse(new RotConverter(key))  // <= fused RotConverters.
       .fuse(new RotConverter(key));
  new File(inFile)
      .openRead()
      .transform(transformer)
      .pipe(new File(outFile).openWrite());
}

첫 번째 RotConverter가 outSink.add()를 호출하므로 두 번째 RotConverter는 입력을 수정할 수 없다고 가정하고 복사본을 할당해요. 두 암호 사이에 ToModifiableConverter를 끼워 넣으면 이 문제를 우회할 수 있어요:

  var transformer = new ToModifiableConverter()
       .fuse(new RotConverter(key))
       .fuse(new ToModifiableConverter())
       .fuse(new RotConverter(key));

이것은 동작하지만 임시방편이에요. 중간 변환기 없이 RotConverter들이 동작하길 원해요. 첫 번째 암호가 outSink를 보고 CipherSink인지 아닌지 판단해야 해요. 새 청크를 추가하고 싶을 때마다 하거나, 청크 변환을 시작할 때 하거나 둘 중 하나로 할 수 있어요. 후자의 방식을 선호해요:

  /// Works more efficiently if given a CipherSink as argument.
  CipherSink startChunkedConversion(
      ChunkedConversionSink<List<int>> sink) {
    if (sink is! CipherSink) sink = new _CipherSinkAdapter(sink);
    return new _RotSink(_key, sink);
  }

_CipherSinkAdapter는 단순해요:

class _CipherSinkAdapter implements CipherSink {
  ChunkedConversionSink<List<int>, List<int>> sink;
  _CipherSinkAdapter(this.sink);

  void add(data) { sink.add(data); }
  void addModifiable(data) { sink.add(data); }
  void close() { sink.close(); }
}

이제 _RotSink가 생성자 인자로 항상 CipherSink를 받는다는 사실을 활용하도록 변경하기만 하면 돼요:

class _RotSink extends CipherSink {
  final _key;
  final CipherSink _outSink;  // <= always a CipherSink.
  _RotSink(this._key, this._outSink);

  void add(List<int> data) {
    addModifiable(data.toList());
  }

  void addModifiable(List<int> data) {
    for (int i = 0; i < data.length; i++) {
      data[i] = (data[i] + _key) % 256;
    }
    _outSink.addModifiable(data);  // <= safe to call addModifiable.
  }

  void close() {
    _outSink.close();
  }
}

이 변경들로 우리의 초안전 이중 암호는 새 리스트를 전혀 할당하지 않게 되고, 작업이 끝났어요.

이 글을 쓰는 데 큰 도움을 준 Lasse Reichstein Holst Nielsen, Anders Johnsen, Matias Meno에게 감사드려요.

더 알아보기