Future<void>와 Future<Null>, 뭐가 다를까요?

Future와 Future, 뭐가 다를까요?

Dart 2에서 가져온 좋은 업그레이드 중 하나(더 나은 정적 검사, 런타임 타입 안전성, 선택적 new/const, 코어 라이브러리 개선 등과 더불어)는 void를 더 유용하고 오류에 덜 빠지게 다듬은 거예요. 특히 비동기 프로그래밍에서 아주 깔끔한데, 작업을 마쳤을 때 답을 돌려주지 않는 비동기 함수에 Future<void>라고 쓸 수 있거든요. 그 전에는 아마 Future<Null>을 썼을 거예요. 그래서 자주 받는 질문이 있어요. Future<void>Future<Null>은 뭐가 다를까요? 언제 어느 것을 써야 할까요?

여기서 유용하게 쓰이려고 하는 만큼, TLDR부터 시작할게요.

TL;DR: 99.99%의 경우 void 타입을 쓰면 돼요.

그리고 프로젝트에서 다음 두 가지 void 관련 lint 규칙을 지금 바로 켜 두는 걸 추천해요.

  • prefer_void_to_null: 예전 유물이나 다름없는 Null을 타이핑하는 습관으로 미끄러져 내려가지 않게 도와줘요.
  • void_checks: 엄밀히 안전한 코드에 필수는 아니지만, 더 직관적으로 느껴질 수 있는 voidness(무효성) 의미론을 제공해 줘요.

이 글의 나머지 부분은 가볍게 읽을 만한 내용이 아니에요. 역사, 엣지 케이스, 타입 이론이 섞여 있어요. 앞으로 쓸 내용은 마치 Monad를 설명하려는 것과 비슷해요. 최선을 다할 테니, 끝까지 함께 따라와 주세요.

빠른 Future 퀴즈

이 글을 읽을 가치가 어디 있는지 보여드리기 위해 퀴즈를 하나 내볼게요.

두 개의 Future로 제한해 봅시다. Future<User>Future<Null>이에요. 다음 코드 줄들은 각각 뭘 할까요?

// 이건 에디터에서 실패할까요?
Future<User> f1 = Future<Null>.value(null);

// 이건 런타임에서 실패할까요?
Future<Null> f2 = Future<User>.value(0);

await로 바꾸면 좀 나아질까요?

User u = await Future<Null>.value(null);
Null n = await Future<User>.value(0);

잠시 극적인 효과를 위해 멈춰볼게요.

정답은, 명시적 다운캐스트(downcast)를 끄지 않은 이상 이 네 줄 어느 것도 에디터에서 오류를 보여주지 않는다는 거예요. 맞아요, Future<Null>을 가져다가 Future<User>처럼 전혀 다른 무언가인 척 하는 건 100% 합법이에요. 하지만 Future<void>에서는 이렇게 할 수 없어요!

또 직관과 달리, 두 번째 줄과 네 번째 줄은 런타임에서 실패해요(이런 식으로 Future의 결과를 버리는 건 "안전하지" 않아요), 나머지는 조용히 성공해요.

이런 동작들은 불편하게 느껴지실 거예요. 하지만 걱정 마세요! 해결책은 이미 알고 계세요. 바로 Future<void>를 쓰는 거예요. 왜 이런 비직관적인 동작이 이렇게 굴러가는지 하나씩 풀어 드릴게요.

이제 void는 어떻게 동작하나

이 글의 순서를 어떻게 정할지 스스로 많이 고민했어요. 여기서 설명할 개념이 정말 많거든요. 다만 유용한 정보 순서대로 나열하려고 해요. Dart 2에서는 voidNull보다 훨씬 유용하니, Dart 2의 void 의미론부터 시작하는 게 맞겠네요.

Dart에서 void의 근본적인 아이디어는 "void 값은 사용되면 안 된다"는 목표에서 나왔어요.

void f() {}
print(f()); // error!

이건 여러분이 기대하는 대로 동작할 거예요! 여기까지는 잘 되고 있네요.

이 목표를 이루면서 void를 평범한 타입으로 여는 게 좀 이상해질 수 있다는 점도 짚어둘게요.

f(void argument) {
  print(argument); // Error!
  // 인자를 받을 수는 있지만, 사용하면 안 돼요!
}

그리고 바로 이 지점에서, Dart 2의 새로운 "일반화된 void"가 멋지고 강력해지기 시작해요. 가끔은 혼란스럽기도 하지만요.

여러분께 질문 하나 드릴게요. f의 인자로 어떤 타입의 값을 넘길 수 있을까요?

f(void argument) { … }
f(x); // x는 무엇일까요?

정답은… 뭐든지 돼요! 함수 f 안에서 x의 값을 사용할 수 없게 만들었으니, x가 어떤 값을 갖든 런타임 오류가 날 수 없다고 자신 있게 말할 수 있어요.

f(1); // 아래와 차이가 없어요
f("foo"); // 아래와 차이가 없어요
f([1, 2, 3]); // 아래와 차이가 없어요
// ...

void를 "사용되지 않을 값"이라는 아이디어에 기반해서 최선의 의미론을 이끌어내면 이렇게 돼요. 즉, 절대적으로 어떤 것이라도 채워 넣을 수 있는 값이라는 뜻이에요. 진공청소기 같아요. 출력이 없는 입력이죠.

그런데 런타임에서는 항상 오류가 없지만 여전히 정적 오류를 일으켜야 하는 경우가 있어요. 이럴 때 lint가 제격이죠! 실제로 void_checks lint의 아이디어가 바로 이거예요. void 자리에 null이 아닌 다른 걸 넘기는 곳을 찾아내요. 팀에서 켜 두는 걸 권할게요. 건전성(soundness)에 필수는 아니지만, void 자리에 null이 아닌 다른 걸 넘기는 건 대개 실수일 가능성이 높으니까, lint가 그걸 짚어 줘요.

이 모든 게 기초 타입 이론에 뿌리를 두고 있기 때문에, void는 Dart의 모든 부분과 잘 어울려요. Future<void> 맥락에서 타입 추론을 활용하는 연속(continuation)에서조차 말이죠.

Future<void>().then((x) {
  print(x); // error! x의 타입은 "void"라서 출력할 수 없어요!
  // 바로 이게 우리가 원하는 거예요!
});

// 이것도 오류라는 점, 맞아요:
Foo f = await voidFuture();

이쯤 되면 보통 개발자는 void를 효과적으로 쓰기 위해 알아야 할 모든 것을 알게 돼요.

그래도 계속 따라오고 계시다면, 몇 가지 더 흥미로운 내용이 있어요.

void_checks를 켜도 void 자리에는 null이 들어 있다는 보장이 없어요. 이 오버라이드 케이스를 봐 주세요.

class A {
  void f(Object o) {}
}

class B extends A {
  @override
  Object f(Object o) => o;
}

이 오버라이드를 불법으로 만들고 싶지 않아요. 안전하고 유용하며 Dart 1에서 가져온 파괴적 변경이거든요. 그래서 void 자리에는 절대적으로 어떤 값이든 들어갈 수 있다는 사실을 받아들여야 해요. 그리고 A.f()가 돌려주는 값을 "최적화로 제거"할 수도 없어요. 런타임에서는 B일 수도 있으니까요.

대신 더 영리한 선택이 있어요. voidObject의 자매 타입으로 만드는 거예요. 어차피 어떤 값이든 담을 수 있고, 모든 값은 Object니까요. 이건 우리가 설계한 게 아니라 그냥 현실이에요. 그 현실을 인정하면, 그걸 활용할 수 있게 돼요.

voidObject의 형제로 만들면, void 값이 완전히 사용되지 않을 것이라는 요구는 더 이상 없어요. void를 자기 자신에게만 머물게 하려고 최선을 다하지만, 하위 호환성을 위해 몇몇 자리에서는 일부러 제약을 느슨하게 풀어두었어요.

dynamic f() => voidFn(); // 이건 합법이에요
voidFn() as dynamic; // 이건 합법이에요

이런 특수 케이스들은 Object에서는 합법이고, Dart 2 출시를 더 매끄럽게 하기 위해 void에서도 합법으로 만든 거예요.

voidObject의 형제로 만든다는 건, void를 타입 매개변수로 써도 컴파일 산출물이 부풀지 않는다는 뜻이기도 해요(C++ 사용자에게는 "템플릿 특수화"가 없다는 뜻이에요). 이 축소 덕분에 웹 앱과 Flutter 앱을 작게 유지할 수 있어요. 대신 다음과 같은 것이 허용되긴 해요.

<void>[1, 2, 3].toString(); // 합법이고 [1, 2, 3]을 출력해요

마지막으로, 파라미터를 void로 입력하는 건, 특히 Object의 한 형태이기 때문에 쓸모없어 보일 수 있어요. 하지만 void 값은 다른 void 자리에 넘길 수 있어요.

f(void x) { … }
f(voidFn()); // 이건 합법이에요

이건 순서 짓기(sequencing)에 유용한데, 예를 들어 Mockito가 void를 반환하는 메서드를 모킹할 때 이런 방식을 써요. (위 코드에서 fwhen으로 바꾸면 더 가까운 예가 돼요.)

요약하면:

  • void 타입은 Object의 형제예요.
  • 거의 항상, void 객체는 사용할 수 없어요.
  • void로 표시된 것은, 실질적으로 절대적으로 어떤 것일 수도 있어요.
  • 어떤 것이든 void로 표시된 자리에 "버려질" 수 있고, void_checks lint가 이 동작을 제한해요.
  • void 값은 다른 void 자리에 넘길 수 있어요.

바텀 타입

Null에 대해 이야기하기 전에 "바텀(bottom)" 타입에 대해 다루는 게 중요해요.

이건 타입 이론에서 자연스럽게 등장하는 타입으로, 짧은 학술적 정의와 몇 가지 실용적 응용이 있어요.

이 섹션을 읽다 말고 그만두게 된다면, 그 자체가 제 원래 TLDR을 강력하게 뒷받침하는 증거예요. 코드도 똑같이 이상해지길 원하지 않는다면, 아마 void를 원하실 거예요. 자, 이 이상한 토끼굴을 얼마나 깊이 들어갈 수 있는지 살펴볼까요?

바텀 타입은 모든 타입의 서브타입이에요. 더 쉬운 객체지향 용어로 말하면, Person이기도 하고 Car이기도 하고 Animal이기도 하며, 지금까지 작성된 모든 프로그램의 모든 다른 타입이기도 하다는 뜻이에요.

터무니없게 들린다면, 그건 실제로 터무니없기 때문이에요. 저는 이걸 "자리 표시자" 타입이라고 생각하는 걸 좋아해요. "터무니없는" 또는 "상상의" 타입이라고 불러도 꽤 타당하겠네요. 그런데 왜 바텀 타입이라고 부르냐면, 타입 계층의 바닥이기 때문이에요. 그리고 컴퓨터 과학에서는 그게 뒤집혀 있죠. ¯(ツ)/¯. 공식적으로는 ⊥ 기호로 표기하는데, "거짓(false)"을 뜻하는 기호로도 알고 계실 거예요.

Person이면서 Car이면서 Animal인 값을 상상해 보면, 아무것도 떠오르지 않을 거예요. 놀랍게도, 그게 바로 ⊥의 실용적인 용도가 나오는 지점이에요!

절대 반환하지 않는 함수를 쓴다면 어떨까요? 쉽게 만드는 방법이 두 가지 있어요.

loopForever() {
  while(true); // 첫 번째 방법
}

alwaysThrow() {
  throw Exception(); // 두 번째 방법
}

이 두 함수의 최선의 반환 타입은 무엇일까요?

상황에 따라 달라요. 함수가 절대 반환하지 않으니, 반환 타입은 사실상 중요하지 않아요. 어떤 타입이든 쓸 수 있어요. 터무니없는 바텀 타입조차요. 언어마다 다양한 방식으로 쓸 수 있어요.

C++에는 noreturn이 있어요. Rust는 !를, Scala는 Nothing을 써요. 하지만 제가 가장 좋아하는 예는 Haskell이에요. 프로그램을 중단시키는 아주 흔히 쓰이는 undefined 함수가 있는데, 예상하셨듯이 바텀 타입을 반환해요.

Haskell과 같은 undefined 함수가 Dart에도 있다고 가정해 볼게요. 호출되면 예외를 던지고 ⊥를 반환하는 함수죠. 그러면 대략 이렇게 생겼을 거예요.

Foo foo = cond ? val : undefined();

이 사용 예시 줄에서, 프로그램은 cond가 참일 때 안전하게 실행되며 val을 저장해요. cond가 거짓일 때는 undefined()가 실행되면서 예외가 던져져요. foo의 타입이 무엇이든 간에 undefined()의 결과를 foo에 "저장"하는 건 안전해요. 그 저장은 실제로는 절대 일어나지 않으니까요!

undefined()여기서 nothing을 반환하지 않아요. 하지만 여기서 배울 교훈은 foo를 비울 수 있다는 게 아니에요. 바텀 타입이 빈 약속처럼 비어 있다는 거예요. 비어 있는 것보다 더 비어 있어요. 그냥 절대 일어나지 않거든요.

한 가지 조심해서 말해야 할 점은, 실무에서는 이런 함수들에서 void를 반환할 수도 있고, 사용 방식에 따라 그렇게 하는 게 옳을 때가 많다는 거예요. 보통 return loopForever() 같은 코드는 유용한 패턴이라기보다 실수에 가까워요. 다만 선택은 여러분 몫이에요.

바텀 타입은 읽기 전용 빈 리스트에서도 유용해요. Dart에서 List는 공변(covariant)이라서, List<int>List<Object>로 쓸 수 있어요. 그런데 그 List<Object> 안에 String을 넣으려 하면, 런타임 검사가 그것을 잡아내고 오류를 던져요.

즉, ⊥로 된 List를 만들면 넣을 수 있는 건 아무것도 없지만, 어떤 타입의 리스트든 그것처럼 취급할 수 있어요.

List<int> intList = <⊥>[];
  for(int i in intList) {
  print(i * 2); // 유효해요, 그냥 절대 일어나지 않으니까요
}

바텀 타입의 "반변(contravariant)" 위치를 보면 더 흥미로운 경우가 있어요.

⊥ 파라미터를 가진 함수를 정의했다고 해 봅시다.

void f(⊥ x) {}

이건 undefined() 예와 거의 반대예요. 절대 반환하지 않는 함수 대신, 절대 호출할 수 없는 함수를 선언한 거예요! 그 파라미터 x에 할당 가능한 실제 값이 없거든요. Person을 넘길 수 없어요. Car도 아니니까요. Car를 넘길 수도 없어요. Person도 아니니까요. 넘길 수 있는 것은 오직 그 터무니없는 타입 자체뿐이에요.

f(undefined());

하지만 아까 다뤘듯이 undefined()는 절대 반환하지 않으니, f()는 이 경우에도 여전히 실제로는 호출되지 않아요!

파라미터를 ⊥로 타이핑하는 건 쓸모없어 보일 수 있지만, 신비한 가치가 있어요. 호출할 수 있는 모든 함수는 호출할 수 없는 함수의 서브타입이기 때문이죠. (생각해 보세요. 호출할 수 있는 함수는 호출되지 않아도 돼요. 그리고 함수가 호출되지 않으면 런타임 오류를 만들 수 없어요.)

아직 따라오고 계시다면, 심호흡 한 번 하고 스스로를 토닥여 주세요.

구체적으로, 임의의 X에 대해 어떤 Function(X)이든 Function(⊥)로 캐스팅하는 건 안전해요. 이게 Dart의 모든 것을 포괄하는 Function 타입을 쓰는 것보다 나아요. 더 구체적이거든요.

이걸로 예를 들어, 어떤 단항 함수든 필드에 저장하고 동적으로 호출할 수 있어요. 정적 오류를 피하고, 실수했다면 그 자리에 런타임 오류로 바꿔주죠.

Function(⊥) f;
f = (int x) => x + 1;

// f에 123이 인자로 유효한지는 런타임에서 검사돼요
(f as dynamic)(123);

이건 궁할 때 쓸 수 있는 깔끔한 트릭이에요.

이제 Null에 대해 이야기할 수 있겠네요.

Dart 2에서의 Null

개념적인 "바텀" 타입(모든 타입의 서브타입)이 Dart에도 존재해요. 하지만 그게 전부가 아니에요. 그런 값도 Dart 2에 존재하거든요! Dart에서는 그것을 Null이라고 부르고, 그 값은—예상하셨듯이—null이에요.

어떤 것이든 null(Null)로 만들 수 있으니, Dart에서는 그 터무니없는 타입도 그렇게 터무니없지 않아요. 그래서 좀 복잡해지죠.

참고: 물론 null 값은 Car도 아니고 Person도 아니에요. 그리고 우리는 Dart에서 non-nullable 타입에 대한 요청을 실제로 받고 있어요. 그래서 Dart에 그런 변경을 한다면, 새 바텀 타입이 필요할 거예요. 아마 Nothing 비슷한 이름이 되겠죠. 그리고 그때는 더 진짜 바텀 타입이 될 거예요.

Null은 바텀 타입과 똑같은 터무니없는 용도들을 모두 가질 뿐 아니라, 탈출구(escape hatch)도 있어요. 반환할 수 없는 함수에서 정말로 반환해야 하거나, 호출할 수 없는 함수를 호출해야 한다면, 우리가 길을 열어줘요! 거기에 null을 넘길 수 있거든요! 전체 그림을 놓고 보면, 그렇게 해 주지 않는다면 솔직히 좀 부당하겠죠.

다만 그렇다고 해서, 단순한 선언 하나에 따라붙는 주의사항이 정말 많아요.

Null nothing() => null;

Nullfoo()에 가장 구체적인 타입이니 논리적인 선택이자 좋은 시작점이에요. 그리고 분석기(analyzer)는 존재하지 않는 메서드를 호출하면 친절하게 경고를 해줘요.

nothing().x; // error! Null에 멤버 x가 없어요

하지만 이건 안전하다는 그릇된 착각을 줄 수 있어요.

nothing().toString(); // 오류 없음: Null은 toString()을 정의해요
Foo foo = nothing(); // 오류 없음: foo는 null이 될 거예요

// f(x)의 모든 파라미터 타입에 허용되지만,
// f(x)가 non-null을 기대하면 런타임 오류가 나요
f(nothing());

목표가 nothing()null의 동의어로 쓰는 거라면, 이게 바로 원하던 동작이에요. 그리고 null을 반환하는 함수에는 Null이 올바른 타입이에요.

Future<Null>을 제안했던 데는 좋은 이유가 있었고, 새로운 void 의미론을 만들고 있던 동안에는 잘 동작했어요. 하지만 똑같은 구멍을 갖고 있어요.

(await futureNull()).toString(); // 오류 없음!
Foo foo = await futureNull(); // 오류 없음!

본질적으로, 어떤 함수 f()Future<Null>을 반환하게 만들면, 프로그램에서 await f()null의 동의어로 만드는 셈이에요! 그건 위험해요. null을 절대적으로 어떤 것이든, 어떤 타입이든 그런 것처럼 쓸 수 있으니까요. 대부분의 사람이 원하는 건 아닐 거예요.

Future<void> 대신 Future<Null>을 쓰게 될 다른 이유가 뭐가 있을까요? 모두 그 머리가 아찔한 바텀 타입으로 거슬러 올라가요. await이 가능해야 하지만 절대 완료되지 않거나, 항상 오류로 완료되는 Future에는 Future<Null>을 고려할 수 있어요. 이건 함수가 왜 Null을 반환하는지와 직접적으로 비교돼요.

같은 논리가 Stream<Null>에도 적용돼요. 이벤트를 절대 보내지 않는 Stream에 이걸 쓰세요. 이건 읽기 전용 빈 리스트가 왜 List<Null>로 타이핑될 수 있는지와 직접적으로 비교돼요.

가이드라인을 제공하기 좀 까다로운 타입은 StreamController<Null>Sink<Null>이에요. 이들은 함수와 비교할 수 있어요. 이 타입을 쓰는 가장 건전한 이유는, 절대 사용되면 안 된다는 뜻을 표현하기 위해서예요. 그다음으로 건전한 이유는 Null의 동의어만 받아들이길 원하기 때문인데, 이는 void_checks를 켜두지 않았을 때만 합리적이에요.

Dart에 모든 값의 탈출구로 null이 없다고 상상해 보세요. 이벤트를 절대 브로드캐스트하지 않는 Stream<⊥>에서만 이벤트를 받을 수 있는 Sink<⊥> 타입을 만들고 싶으세요? 대답이 "그렇다"면, 그냥 Sink<Null>을 만들어 쓰세요.

이런 모든 용도는 약한 보장을 가져요. null 값 자체가, 그 외에는 Null 타입을 쓸 이유가 되었을 것의 탈출구이기 때문이에요. 하지만 값이 탈출구 자체라고 착각하지 마세요. 그건 벙커(bunker)가 아니라 구멍(hole)이에요. 하나는 안전하고 튼튼하고, 다른 하나는 벙커에 불이 났을 때 강화 콘크리트를 위로 파고 올라가는 수고를 덜어 주려는 것뿐이에요.

요약하면, 무언가를 Null로 타이핑하는 주요 용도는 다음과 같아요.

  • 모든 곳에서 모든 것으로 쓸 수 있는 null의 동의어
  • 프로그램이 절대 도달하지 않을 실행의 자리 표시자
  • 어떤 타입으로든 위장할 수 있도록 반드시 비어 있어야 하는 컨테이너
  • 입력이 없는 출력

최종 비교

여기까지 읽으셨다면, 뭔가 보상을 받을 만하네요. 저에게 연락 주시면, 다음에 Portland에서 시간 되실 때 맥주 한 잔 사 드릴게요. :)

바텀 타입에 대해 많이 이야기했지만, 그 반대인 탑(Top) 타입은 전혀 다루지 않았어요. 탑 타입은, 예상하시다시피, 바텀 타입의 정반대예요. Dart에서 바텀 타입의 이름이 Null이라면, 탑 타입의 이름은 dynamic, Object, void 중 하나예요(같은 동전의 세 면이죠).

"nothing"이나 "empty" 같은 영어 단어가 타입 이론에서 이렇게 서로 다른 두 가지를 뜻할 수 있다는 게 이상하게 느껴질 수 있어요. 두 단어 모두 정반대인 탑 타입과 바텀 타입, 즉 Nullvoid를 설명하기에 적합하거든요. 정반대인 두 타입이 같은 일을 맡을 매력적인 후보가 된다는 게 이상하게 느껴질 수도 있어요. 이제 어느 것을 쓸지 읽어보니, Dart에 이상한 괴벽과 엣지 케이스가 이렇게 많다는 게 답답하게 느껴질 수도 있어요.

우리의 실수는, 제 생각엔, 처음에 Nullvoid처럼 쓰라고 권했던 데 있었어요. 당시 Dart 1 의미론을 고려하면 유용한 조언을 아끼는 건 사용자에게 해가 되는 일이었을 거예요. 하지만 우리는 실수로 아주 난해한 타입을 아주 흔하게 쓰게 만들었어요.

자, 이제 글을 다 읽으셨어요. 어떻게 생각하세요? 곧 Null의 유효한 용도를 마주칠 것 같으세요? 어디에 유용한지 확실히 이해하셨나요?

그리고 더 중요한 건, void가 모든 걸 더 단순하게 만들기 위해 여기 있다는 좋은 소식을 함께 퍼뜨려 주실 수 있으신가요?

더 알아보기

  • void 타입은 Dart 2에서 Object의 형제 타입으로, 사용하면 안 되는 값의 의미를 담아요.
  • async 프로그래밍에서 답을 돌려주지 않는 함수에는 Future<void>를, 답을 주는 함수에는 Future<T>를 쓰는 걸 권장해요.
  • prefer_void_to_nullvoid_checks lint 규칙을 켜 두면 실수를 줄일 수 있어요.