Pedantic Dart

Pedantic Dart

package:pedantic를 중심으로 여러분의 Dart 코드에 적용할 수 있는 정확히 올바른(meticulously correct) lint 목록을 천천히 모아왔어요. 이번 글에서는 각 lint가 package:pedantic에 포함되는지 어떤 기준으로 평가하는지, 그리고 왜 모든 lint를 그냥 다 켤 수 없는지 자세히 살펴볼게요.

출처: 원문

본문

Flutter와 Flutter Web이 큰 화제를 모으고 있는데, 그럴 만해요. 그 둘은 UI 개발의 경계를 넓히고 있거든요. Flutter는 Dart로 작성되어 있고, Dart는 방금 "UI as Code"라는 이름 아래 UI 개발자라면 누구나 즐거워할 수많은 기능을 얻었어요. 정말 흥미로운 시대예요.

하지만 잠깐! 모든 게 빠르게 움직여야 하는 건 아니에요. 때로는 꼼꼼하고(fussy), 까다롭고(fastidious), 더디고(finicky), 아니—감히 말하자면—현학적(pedantic)일 필요가 있어요. 그래서 Dart의 package:pedantic에서 우리는 여러분의 코드에 적용할 수 있는 정확히 올바른 lint 목록을 천천히 모으고 있어요.

물론 lint를 검사하려면 linter가 필요해요. Dart linter는 Dart analyzer에 바로 내장되어 있어서, 그 146개의 lint(이 글을 쓰는 시점 기준)는 원하는 어디서든 사용할 수 있어요. 즉 커맨드라인, presubmit, 그리고 IDE에서 모두 쓸 수 있죠. Dart(또는 Flutter) 개발자라면 수백 개의 lint를 손끝에서 쓸 수 있어요. 유일한 문제는 어떤 lint를 활성화할지 결정하는 거예요.

이 문제는 생각보다 훨씬 까다로워요. package:pedantic이 권장하는 lint만 사용하고 싶다면 더 읽을 필요 없이 그냥 이 지침을 따라 하면 돼요.

아직 따라오고 있나요? 좋아요. 그럼 왜 146개 lint를 전부 켜고 코딩을 시작하면 안 되는지 파고들어 볼게요. 가장 단순한 이유부터 시작해서 점점 깊이 들어갈게요. 그다음 lint가 정확히 어떤 방식으로 package:pedantic에 포함될지 평가되는지 보고, 시행하기에 특히 까다로웠던 lint 예시를 하나 살펴본 다음, 여러분이 어떻게 참여할 수 있는지 몇 가지 포인터로 마무리할게요.

폐기된 lint (Obsolete lints)

어떤 lint는 더 이상 말이 안 돼요. 대표적인 예가 super_goes_last인데, 이 lint는 이니셜라이저 목록에 super가 있다면 마지막에 배치하도록 요구해요. 사실 너무 유용한 lint라서 Dart 2와 함께 언어의 요구사항이 됐기 때문에 더 이상 이 lint가 필요 없게 됐어요.

// super_goes_last; now included in Dart 2, no need for a lint.
View(Style style, List children)
    : super(style), // LINT
      _children = children {

금지된 lint (Contraindicated lints)

어떤 lint는 실제로 공개용으로 의도되지 않았어요. 특히 Flutter SDK 내부에서 쓰도록 설계된 여러 lint는 Dart 스타일 가이드와 직접 모순돼요. 예를 들어 always_specify_types는 타입을 너무 과하게 지정하면 타입 추론의 이점을 잃게 돼요. 스타일 가이드는 타입 어노테이션과 타입 추론 사이의 좋은 균형을 어떻게 잡을지 신중하게 설명해 줘요.

// always_specify_types; do not use; breaks with recommended style!
var foo = 10; // LINT
final bar = new Bar(); // LINT
const quux = 20; // LINT

비용이 큰 lint (Expensive lints)

lint를 평가하려면 복잡한 데이터 구조인 여러분의 소스 코드 위에서 임의의 계산을 수행해야 해요. 어떻게 하면 느려지지 않을지 어떻게 알 수 있을까요? 물론 lint를 추가할 때 효율적으로 유지하려고 모든 노력을 기울여요. 하지만 예상치 못한 일이 생길 수 있어요. library_prefixes lint는 아주 모호한 경우에만 표면화되는 성능 버그가 있었어요. 물론 지금은 고쳐졌어요.

// library_prefixes; performance issue was fixed, now good to go!
import 'dart:math' as Math; // LINT
import 'dart:json' as JSON; // LINT
import 'package:js/js.dart' as JS; // LINT

중복된 lint (Redundant lints)

대부분의 lint는 계산이 매우 빠르지만, 완전히 공짜는 아니에요. 그러니 lint는 몫을 해내야 해요. 즉 충분한 가치를 제공해야 하죠. 예를 들어 우리는 empty_statements lint를 거부했어요. dartfmt를 쓰면 빈 문장을 쉽게 알아볼 수 있기 때문이에요. 빈 문장이 실수로 작성될 가능성도 낮고, 그래서 이 lint는 중복된다고 봤어요.

// empty_statements; considered redundant with dartfmt.
if (complicated.expression.foo()) ; // LINT

과도한 lint (Overeager lints)

어떤 lint는 너무 정밀하지 않아서 시행할 수 없어요. 예를 들어 Dart에서 지역 변수의 타입을 생략하는 건 좋은 스타일이지만, 항상 그런 건 아니에요. 그건 권장사항이지 절대적인 규칙이 아니에요. 그래서 그에 해당하는 omit_local_variable_types lint는 어디서든 시행하기엔 너무 엄격해요.

// omit_local_variable_types; too strict. Local variable types
// are good style where they improve readability.
void myMethod() {
  MyType bar = expression.methodCall().otherMethodCall(); // LINT
}

의견이 담긴 lint (Opinionated lints)

어떤 lint는 코드를 틀리지 않지만 다소 특이한 방향으로 밀어붙여요. 예를 들어 prefer_final_locals는 가능하면 지역 변수를 final로 선언하도록 요구해요. 이건 일부 개발자가 선호하는 스타일이지만 대부분의 Dart 개발자가 선호하는 건 아니에요. 그래서 기본적으로 이 lint는 꺼져 있어야 해요.

// prefer_final_locals; inconsistent with common style.
void myMethod() {
  var label = 'foo'; // LINT
}

모든 lint 평가하기

이 모든 걸 고려해도, Dart를 잘 아는 사람이라면 146개 lint 목록을 앉은 자리에서 추천 lint 목록으로 만들 수 있을 것처럼 보일 수도 있어요.

하지만 실제로는 그렇지 않다는 걸 알게 됐어요. 그건 너무 방대한 작업이에요. 각 lint가 여러분의 코드 위에서 임의의 평가를 수행할 수 있는 것처럼, 특정 lint가 정확하고 유용한지 여부를 결정하는 것도 거의 임의로 어렵다는 게 드러났어요.

어려운 질문에 접근하는 좋은 방법은 하드 데이터를 요청하는 거예요. 그래서 각 lint를 고려할 때, 우리는 먼저 그 성능을 벤치마킹하고 현재 Google 내부 Dart 코드에 존재하는 그 lint의 모든 위반 사례에 대한 정보를 수집해요.

이 숫자들은 논의를 시작하기 좋은 출발점이 돼요.

예를 들어 Google의 모든 Dart 코드에 그 lint의 위반이 단 5건뿐이라면, 각각은 심각한 버그여야 해요. 그렇지 않다면 그 lint가 몫을 해내고 있다고 보기 어려워요. recursive_getters lint는 아주 적은 수의 심각한 문제를 잡아내는 드문 예시였어요. 자기 자신을 호출하는 getter는 스택 오버플로우가 일어나길 기다리는 거나 다름없거든요.

// recursive_getters; definitely not what you meant to write!
int get field => field; // LINT

반대로 우리가 그 lint의 위반을 많이 발견한다면, 질문이 뒤집혀요. 그 lint는 많은 개발자가 하고 있는 일을 바꾸게 될 테니, 그것이 전반적으로 그리고 각 개별 사례에서 모두 개선이라고 확신할 수 있나요? 소수의 경우를 더 나쁘게 만든다면 그걸 정당화할 수 있나요? 아니면 어쩌면 그 lint를 개선할 수 있을까요?

unrelated_type_equality_checks lint가 좋은 예시예요. 이 lint는 두 객체를 비교하기 전에 둘이 호환되는 정적 타입이어야 한다고 요구해요. 그래서 3foo가 같은지 확인하는 건 허용되지 않아요. 하나는 int이고 다른 하나는 String이기 때문에 그 질문 자체가 말이 안 된다고 가정하는 거예요.

// unrelated_type_equality_checks; or, don't ask stupid questions!
void someFunction() {
  var x = '1';
  if (x == 1) print('surprise!'); // LINT
}

좋아 보이지만 두 가지 이유로 정확하지 않아요.

이론적으로는 implements 때문에 실패해요. 객체는 두 개 이상의 타입이 될 수 있어서, 정적으로는 관련이 없어 보이는 두 객체가 런타임에는 같은 타입을 구현해서 비교하는 게 완전히 타당할 수 있어요.

// unrelated_type_equality_checks; objects _can_ hold surprises.
void checkForSurprise(Foo foo, Bar bar) {
  if (foo == bar) print('surprise!'); // LINT
}

abstract class Foo {}
abstract class Bar {}
class Baz implements Foo, Bar {}

void main() {
  var baz = Baz();
  checkForSurprise(baz, baz);
}

실제로는 operator==를 각 클래스 작성자가 구현하도록 남겨두고, 그들이 그 계약을 따르도록 강제하는 게 없기 때문에 실패해요. 특히 package:fixnumInt64Int32int와의 비교를 허용하는데, int==의 오른쪽에 있을 때만 그렇다는 걸 발견했어요.

그래서 데이터는 그 lint에 대해 세 가지 결과를 보여줬어요. 잡아낸 버그들인 올바른 발견이 많았고, 정적 타입이 호환되지 않을 때 런타임 타입이 호환되는 데서 오는 잘못된 발견이 적었으며, Int64int와 비교되는 데서 오는 잘못된 발견이 상대적으로 많았어요.

우리는 어떻게 했을까요? 그 lint를 개선했어요. 이제 Int64(그리고 Int32)를 알고 있고, 예전처럼 int와 비교하는 걸 허용해요. 이로 인해 정적 타입이 불완전하기 때문에 생기는 아주 적은 수의 거짓 양성이 남았는데, 우리는 필요에 따라 cast를 사용해 리팩터링해서 그 lint를 준수하게 만들기로 결정했어요.

이런 변경 덕분에 우리는 unrelated_type_equality_checks를 시행하는 데 합의에 도달할 수 있었어요.

여기서 "우리"는 "Dart lint에 관심 있는 모든 Google 개발자 집합"을 의미해요. Google에서 Dart를 작성하는 사람이라면 누구나 이 과정에 참여할 수 있고, 실제로 많은 사람이 참여해요. 그래서 특히 lint가 논란이 될 때 충분한 의견을 얻을 수 있어요!

어떤 lint에 대해 합의가 이뤄지면, 그다음 일어나는 일은 그 lint 시행을 제안한 사람이 Google 내부의 모든 Dart 코드를 정리해서 그 lint를 통과시키는 거예요. 이 과정에서 때로는 새로운 것을 배우기도 해요. 정리 작업을 파격적이거나 어려운 변경으로 만들 무언가를 놓쳤다면, 보통 정리 작업을 일시 중지해요. 지금 우리는 작업할 lint가 충분하므로 그런 경우는 그냥 건너뛰고 나중에 다시 돌아올 수 있어요.

한번 lint가 모든 곳에서 성공적으로 정리되면, 그때 presubmit에서 시행되어 Google 내부 코드에서 더 이상의 위반을 막고, 다음 package:pedantic 릴리스에 게시돼요.

unawaited_futures lint는 훨씬 더 어려운 사례였어요. 이 lint는 한때 아주 흔했던 개발자 불만을 다뤄요. Futureawait하는 걸 잊어서 예측할 수 없는 런타임 동작과 깜빡이는 테스트(flaky tests)를 일으키는 문제죠.

// unawaited_futures; catching accidentally asynchronous behaviour.

Future<void> doSomething() => ...;
Future<void> doSomethingElse() => ...;

void main() async {
  doSomething(); // LINT
  doSomethingElse(); // LINT
}

하지만 이 lint는 문제가 있어요. Future를 시작한 다음 그 완료를 기다리지 않고 계속 진행하고 싶은 경우가 있다는 걸 우리가 알고 있기 때문이에요. 한 가지 예는 로깅이에요. 로깅이 언젠가는 완료될 거라는 걸 알면 되는 경우가 보통이고, 그것을 기다릴 필요는 없어요.

이 lint는 엄청난 가치를 제공했지만 결코 완벽하게 만들 수는 없었어요. 우리는 최선의 방향에 대해 오랫동안 논의했어요. Dart lint는 바로 앞 줄에 // ignore: <lint_name>을 작성해서 무시할 수 있어서, lint를 시행한다고 해서 개발자를 실제로 막는 일은 결코 없어요. 하지만 우리는 개발자들이 ignore를 쓰도록 훈련하고 싶지 않았어요. 우리가 시행하는 대부분의 lint는 항상 정확하고 결코 무시되어서는 안 되거든요.

이 논의가 사실상 package:pedantic을 만드는 계기가 됐어요. 우리는 "unawaited_futures lint에 대해 알고 있고, 여기에는 적용되지 않는다"고 말하는 정식 방법을 제공하고 싶었어요. 그게 지금 package:pedanticunawaited 메서드예요. 그것을 게시한 후, 우리는 unawaited_futures lint의 메시지와 문서를 unawaited를 가리키도록 업데이트했고, 이제 우리가 바라기에 꽤 괜찮은 위치에 있어요. 즉 가끔 꺼야 할 수도 있는 lint와, 그것을 끄는 정식적이고 읽기 쉬운 방법을 갖춘 거예요.

// unawaited_futures; say `unawaited` if that's what you wanted.

Future<void> doSomething() => ...;
Future<void> doSomethingElse() => ...;

void main() async {
  unawaited(doSomething());
  unawaited(doSomethingElse());
}

이것으로 unawaited_futures lint 시행에 대한 합의에 도달하기에 충분했어요.

완벽한 Dart linting을 향한 단계별 과정

그리고 그 과정은 계속돼요. 지금 우리는 25개의 lint를 활성화했고 8개를 명시적으로 비활성화했으니, 사용 가능한 146개 lint의 4분의 1 지점에도 아직 못 미쳤어요. 물론 사람들은 계속 새 lint를 추가하고 있어요. linter 설계 덕분에 그렇게 하기 쉽거든요. 그래서 계속 움직이는 목표예요.

마법의 지팡이를 휘둘러 완벽한 lint 목록을 제공할 수 있다면 좋겠어요. 다행히 이 글에서 왜 그게 불가능한지, 그리고 우리가 그걸 위해 작업하고 있다는 걸 설명했기를 바라요.

마지막으로, 이미 존재하는 lint 중에 더 빨리 활성화됐으면 하는 게 있다면, pedantic 이슈 트래커를 사용해서 알려주시면 좋겠어요. 우리는 다음에 무엇을 다룰지 고를 때 이슈를 고려해요. 아쉽게도 Google 내부 코드가 어떤 lint를 활성화할지 결정하는 데 핵심이기 때문에 전체 과정을 투명하게 만들 수는 없지만, GitHub 논의에서 최대한 개방적이 되려고 노력해요. 특히 무엇이 언제쯤 package:pedantic에 들어갈 가능성이 있는지에 대해 최대한 피드백을 줄 수 있어요.

새 lint를 기여하거나 기존 lint를 개선하는 데 관심이 있다면 GitHub에서 참여해 주세요!

더 알아보기