변수 선언
변수 선언 (Variable Declaration)
let과 const는 JavaScript에서 변수를 선언하는 비교적 새로운 두 개념이에요. 앞에서 잠깐 언급했듯이 let은 어떤 면에서 var와 비슷하지만, JavaScript에서 흔히 겪는 함정들을 피할 수 있게 해줘요. const는 let에서 한 단계 더 나아가 변수에 재할당을 막아주는 선언이에요. TypeScript는 JavaScript의 확장이므로 당연히 let과 const를 지원하고, 이 글에서는 이 새로운 선언들이 무엇이고 왜 var보다 나은지 자세히 풀어볼게요.
출처: TypeScript 핸드북
JavaScript를 아무 생각 없이 써왔다면 다음 절이 기억을 되살리는 데 도움이 될 거예요. JavaScript의 var 선언의 모든 괴팍함을 잘 알고 있다면 앞부분을 건너뛰어도 좋아요.
var 선언
JavaScript에서 변수를 선언하는 건 전통적으로 항상 var 키워드로 해왔어요.
var a = 10;
짐작했겠지만 방금 값 10을 가진 a라는 변수를 선언한 거예요.
함수 안에서도 변수를 선언할 수 있어요:
function f() {
var message = "Hello, world!";
return message;
}
그리고 다른 함수 안에서도 같은 변수에 접근할 수 있어요:
function f() {
var a = 10;
return function g() {
var b = a + 1;
return b;
};
}
var g = f();
g(); // returns '11'
위 예시에서 g는 f 안에 선언된 변수 a를 캡처했어요. g가 호출되는 어느 시점이든, a의 값은 f 안의 a 값에 묶여 있어요. f가 다 실행된 뒤에 g를 호출해도 여전히 a에 접근하고 수정할 수 있어요.
function f() {
var a = 1;
a = 2;
var b = g();
a = 3;
return b;
function g() {
return a;
}
}
f(); // returns '2'
스코프 규칙 (Scoping rules)
다른 언어에 익숙한 사람들에게 var 선언은 좀 이상한 스코프 규칙을 가지고 있어요. 다음 예시를 볼게요:
function f(shouldInitialize: boolean) {
if (shouldInitialize) {
var x = 10;
}
return x;
}
f(true); // returns '10'
f(false); // returns 'undefined'
이 예시에 눈이 두 번 가는 독자도 있을 거예요. 변수 x는 if 블록 안에서 선언됐는데도, 그 블록 밖에서 접근이 가능했으니까요. 그 이유는 var 선언이 자신을 포함하는 함수, 모듈, 네임스페이스, 또는 전역 스코프 어디에서든 접근 가능하기 때문이에요. 블록이 무엇이든 상관없죠. 이런 걸 사람들은 var-스코핑 또는 함수-스코핑 이라고 불러요. 매개변수도 함수 스코프를 가져요.
이런 스코프 규칙은 여러 종류의 실수를 유발할 수 있어요. 그중 하나는 같은 변수를 여러 번 선언해도 에러가 아니라는 점이에요:
function sumMatrix(matrix: number[][]) {
var sum = 0;
for (var i = 0; i < matrix.length; i++) {
var currentRow = matrix[i];
for (var i = 0; i < currentRow.length; i++) {
sum += currentRow[i];
}
}
return sum;
}
경험 많은 JavaScript 개발자라면 쉽게 눈치챘겠지만, 안쪽 for-루프는 i가 같은 함수 스코프 변수를 가리키기 때문에 i 변수를 실수로 덮어써 버려요. 경험 많은 개발자들이 잘 알듯, 비슷한 종류의 버그가 코드 리뷰를 통과해 버리고 끝없는 좌절의 원천이 되곤 해요.
변수 캡처의 괴팍함 (Variable capturing quirks)
잠깐 시간을 내어 다음 스니펫의 출력이 무엇일지 맞혀 보세요:
for (var i = 0; i < 10; i++) {
setTimeout(function () {
console.log(i);
}, 100 * i);
}
잘 모르는 사람을 위해 말하자면, setTimeout은 일정 밀리초(다른 모든 실행이 멈출 때까지 기다린 뒤) 후에 함수를 실행하려 시도해요.
준비됐나요? 결과를 보죠:
10
10
10
10
10
10
10
10
10
10
많은 JavaScript 개발자가 이 동작을 잘 알고 있겠지만, 만약 놀랐다 해도 결코 혼자인 건 아니에요. 대부분의 사람들은 출력이 다음과 같을 거라고 기대하죠:
0
1
2
3
4
5
6
7
8
9
앞에서 변수 캡처에 관해 언급한 걸 기억하나요? setTimeout에 넘기는 모든 함수 표현식은 실제로 같은 스코프의 같은 i를 가리켜요.
그게 무슨 뜻인지 잠시 생각해 보죠. setTimeout은 몇 밀리초 후에 함수를 실행하는데, 오직 for 루프가 실행을 멈춘 뒤에만 실행해요. for 루프가 실행을 멈출 무렵엔 i의 값이 10이에요. 그래서 주어진 함수가 호출될 때마다 10을 출력하게 되는 거예요!
흔한 해결책으로는 IIFE - 즉시 실행 함수 표현식(Immediately Invoked Function Expression) - 를 사용해서 각 반복에서 i를 캡처하는 방법이 있어요:
for (var i = 0; i < 10; i++) {
// capture the current state of 'i'
// by invoking a function with its current value
(function (i) {
setTimeout(function () {
console.log(i);
}, 100 * i);
})(i);
}
이 이상해 보이는 패턴은 사실 꽤 흔해요. 매개변수 목록의 i는 실제로 for 루프에서 선언된 i를 가리지만, 이름을 똑같이 지었기 때문에 루프 본문을 많이 수정할 필요가 없었어요.
let 선언
이제 var에 문제가 있다는 걸 알아챘을 테니, 그것이 정확히 let이 도입된 이유예요. 사용하는 키워드만 다를 뿐, let 문장은 var 문장과 같은 방식으로 작성해요.
let hello = "Hello!";
핵심 차이는 문법이 아니라 의미론에 있는데, 이제 그 부분을 자세히 파볼게요.
블록 스코프 (Block-scoping)
변수가 let으로 선언되면, 사람들이 어휘적 스코핑(lexical-scoping) 또는 블록 스코핑(block-scoping) 이라 부르는 것을 사용해요. var로 선언된 변수는 스코프가 자신을 포함하는 함수까지 새어 나가지만, 블록 스코프 변수는 가장 가까운 포함 블록이나 for-루프 밖에서는 보이지 않아요.
function f(input: boolean) {
let a = 100;
if (input) {
// Still okay to reference 'a'
let b = a + 1;
return b;
}
// Error: 'b' doesn't exist here
return b;
}
여기에는 a와 b라는 두 개의 지역 변수가 있어요. a의 스코프는 f의 본문으로 제한되고, b의 스코프는 포함하는 if 문장의 블록으로 제한돼요.
catch 절에서 선언된 변수도 비슷한 스코프 규칙을 가져요.
try {
throw "oh no!";
} catch (e) {
console.log("Oh well.");
}
// Error: 'e' doesn't exist here
console.log(e);
블록 스코프 변수의 또 다른 성질은, 실제로 선언되기 전에는 읽거나 쓸 수 없다는 점이에요. 이런 변수들은 스코프 전체에 걸쳐 "존재"하지만, 선언 지점까지의 모든 지점은 그 변수의 시간적 사각지대(temporal dead zone) 에 해당해요. 이건 어려운 말로 "let 문장 전에는 접근할 수 없다"는 뜻이에요. 다행히 TypeScript가 그걸 알려줘요.
a++; // illegal to use 'a' before it's declared;
let a;
한 가지 주목할 점은, 블록 스코프 변수를 선언되기 전에 캡처 하는 건 여전히 가능하다는 거예요. 유일한 제약은 그 함수를 선언 전에 호출하는 게 불법이라는 점이에요. ES2015를 대상으로 하면 현대 런타임이 에러를 던지지만, 지금 TypeScript는 관대해서 이를 에러로 보고하지 않아요.
function foo() {
// okay to capture 'a'
return a;
}
// illegal call 'foo' before 'a' is declared
// runtimes should throw an error here
foo();
let a;
시간적 사각지대에 대한 더 자세한 내용은 Mozilla Developer Network의 관련 내용을 참고하세요.
재선언과 섀도잉 (Re-declarations and Shadowing)
var 선언에서는 변수를 몇 번이고 선언해도 상관없다고 언급했죠. 그냥 하나의 변수만 생기는 거예요.
function f(x) {
var x;
var x;
if (true) {
var x;
}
}
위 예시에서 모든 x 선언은 실제로 같은 x를 가리키고, 이건 완전히 유효해요. 이런 게 종종 버그의 원천이 되곤 하죠. 다행히 let 선언은 그만큼 관대하지 않아요.
let x = 10;
let x = 20; // error: can't re-declare 'x' in the same scope
TypeScript가 문제가 있다고 알려 주려면 두 변수가 반드시 둘 다 블록 스코프일 필요는 없어요.
function f(x) {
let x = 100; // error: interferes with parameter declaration
}
function g() {
let x = 100;
var x = 100; // error: can't have both declarations of 'x'
}
그렇다고 블록 스코프 변수가 절대 함수 스코프 변수와 함께 선언될 수 없다는 뜻은 아니에요. 블록 스코프 변수는 명확히 다른 블록 안에 선언되기만 하면 돼요.
function f(condition, x) {
if (condition) {
let x = 100;
return x;
}
return x;
}
f(false, 0); // returns '0'
f(true, 0); // returns '100'
더 안쪽 스코프에 새 이름을 도입하는 행위를 섀도잉(shadowing) 이라고 불러요. 이는 양날의 검이라서, 우연한 섀도잉으로 특정 버그를 유발할 수도 있고 반대로 특정 버그를 막아주기도 해요. 예를 들어 아까의 sumMatrix 함수를 let 변수로 작성했다고 상상해 보죠.
function sumMatrix(matrix: number[][]) {
let sum = 0;
for (let i = 0; i < matrix.length; i++) {
var currentRow = matrix[i];
for (let i = 0; i < currentRow.length; i++) {
sum += currentRow[i];
}
}
return sum;
}
이 버전의 루프는 안쪽 루프의 i가 바깥쪽 루프의 i를 가리기 때문에 실제로 합을 올바르게 계산해요.
더 명확한 코드를 쓰는 관점에서 보면 섀도잉은 보통 피해야 해요. 섀도잉을 활용하는 게 적절할 수 있는 시나리오도 있지만, 최선의 판단을 사용해야 해요.
블록 스코프 변수 캡처 (Block-scoped variable capturing)
var 선언으로 변수 캡처라는 주제를 처음 다뤘을 때, 변수가 일단 캡처되면 어떻게 동작하는지 간단히 살펴봤어요. 더 좋은 직관을 주자면, 스코프가 실행될 때마다 변수의 "환경(environment)"이 생성돼요. 그 환경과 그 안의 캡처된 변수들은 스코프 안의 모든 것이 실행을 마친 후에도 존재할 수 있어요.
function theCityThatAlwaysSleeps() {
let getCity;
if (true) {
let city = "Seattle";
getCity = function () {
return city;
};
}
return getCity();
}
우리가 city를 그 환경 안에서 캡처했기 때문에, if 블록이 실행을 마쳤음에도 여전히 그 값에 접근할 수 있어요.
앞선 setTimeout 예시를 기억해 보면, for 루프의 각 반복마다 변수의 상태를 캡처하려고 결국 IIFE를 써야 했죠. 사실 우리가 한 일은 캡처된 변수들을 위해 새로운 변수 환경을 하나 만든 거였어요. 그건 좀 번거로웠지만, 다행히 TypeScript에서는 그럴 필요가 다시는 없어요.
let 선언은 루프의 일부로 선언되면 동작이 확연히 달라져요. 루프 자체에 새 환경을 도입하는 대신, 이런 선언들은 반복마다 새로운 스코프를 만드는 것처럼 동작해요. 이건 IIFE로 하던 것과 같은 거니까, 예전 setTimeout 예시를 그냥 let 선언으로 바꿀 수 있어요.
for (let i = 0; i < 10; i++) {
setTimeout(function () {
console.log(i);
}, 100 * i);
}
그리고 예상대로 다음을 출력해요:
0
1
2
3
4
5
6
7
8
9
const 선언
const 선언은 변수를 선언하는 또 다른 방법이에요.
const numLivesForCat = 9;
let 선언과 비슷하지만, 이름이 암시하듯 한번 바인딩되면 그 값을 바꿀 수 없어요. 다른 말로, let과 같은 스코프 규칙을 가지면서 재할당만 할 수 없는 거예요.
이걸 '그들이 가리키는 값이 불변(immutable)'이라는 뜻과 혼동하면 안 돼요.
const numLivesForCat = 9;
const kitty = {
name: "Aurora",
numLives: numLivesForCat,
};
// Error
kitty = {
name: "Danielle",
numLives: numLivesForCat,
};
// all "okay"
kitty.name = "Rory";
kitty.name = "Kitty";
kitty.name = "Cat";
kitty.numLives--;
특별한 조치를 취하지 않는 한, const 변수의 내부 상태는 여전히 수정 가능해요. 다행히 TypeScript는 객체의 멤버를 readonly로 지정할 수 있게 해줘요. 자세한 내용은 인터페이스 챕터에서 확인할 수 있어요.
let vs. const
스코프 의미론이 비슷한 두 가지 종류의 선언이 있으니, 자연스럽게 어떤 걸 써야 할지 고민하게 돼요. 대부분의 폭 넓은 질문이 그렇듯, 답은 "상황에 따라 다르다"예요.
최소 권한의 원칙(principle of least privilege)을 적용하면, 수정할 계획이 있는 선언 외에는 모두 const를 사용해야 해요. 그 근거는, 변수에 쓸 필요가 없었다면 같은 코드베이스에서 작업하는 다른 사람들도 자동으로 그 객체에 쓸 수 있으면 안 되고, 그 변수에 정말 재할당해야 하는지 고민하게 하려는 거예요. const를 쓰면 데이터 흐름을 추리할 때도 코드가 더 예측 가능해져요.
최선의 판단을 사용하고, 해당된다면 나머지 팀과 상의해 보세요. 이 핸드북의 대부분은 let 선언을 사용해요.
구조 분해 할당 (Destructuring)
TypeScript가 지원하는 또 다른 ECMAScript 2015 기능은 구조 분해 할당이에요. 완전한 레퍼런스는 Mozilla Developer Network의 글을 참고하고, 이 절에서는 간단히 개요만 살펴볼게요.
배열 구조 분해 (Array destructuring)
구조 분해의 가장 단순한 형태는 배열 구조 분해 할당이에요:
let input = [1, 2];
let [first, second] = input;
console.log(first); // outputs 1
console.log(second); // outputs 2
이렇게 하면 first와 second라는 두 개의 새 변수가 생겨요. 이건 인덱싱을 쓰는 것과 동일하지만 훨씬 편리해요:
first = input[0];
second = input[1];
구조 분해는 이미 선언된 변수에도 적용돼요:
// swap variables
[first, second] = [second, first];
그리고 함수의 매개변수에도 쓸 수 있어요:
function f([first, second]: [number, number]) {
console.log(first);
console.log(second);
}
f([1, 2]);
... 문법으로 목록의 나머지 항목을 위한 변수를 만들 수 있어요:
let [first, ...rest] = [1, 2, 3, 4];
console.log(first); // outputs 1
console.log(rest); // outputs [ 2, 3, 4 ]
물론 JavaScript이니 관심 없는 끝 요소들을 그냥 무시할 수도 있어요:
let [first] = [1, 2, 3, 4];
console.log(first); // outputs 1
또는 다른 요소들도 무시할 수 있어요:
let [, second, , fourth] = [1, 2, 3, 4];
console.log(second); // outputs 2
console.log(fourth); // outputs 4
튜플 구조 분해 (Tuple destructuring)
튜플도 배열처럼 구조 분해할 수 있어요. 구조 분해 변수들은 대응하는 튜플 요소의 타입을 얻어요:
let tuple: [number, string, boolean] = [7, "hello", true];
let [a, b, c] = tuple; // a: number, b: string, c: boolean
튜플의 요소 범위를 벗어나 구조 분해하는 건 에러예요:
let [a, b, c, d] = tuple; // Error, no element at index 3
배열과 마찬가지로 ...로 튜플의 나머지를 구조 분해해서 더 짧은 튜플을 얻을 수 있어요:
let [a, ...bc] = tuple; // bc: [string, boolean]
let [a, b, c, ...d] = tuple; // d: [], the empty tuple
또는 끝 요소나 다른 요소를 무시할 수도 있어요:
let [a] = tuple; // a: number
let [, b] = tuple; // b: string
객체 구조 분해 (Object destructuring)
객체도 구조 분해할 수 있어요:
let o = {
a: "foo",
b: 12,
c: "bar",
};
let { a, b } = o;
이렇게 하면 o.a와 o.b에서 새 변수 a와 b가 만들어져요. 필요 없다면 c는 건너뛸 수 있다는 점에 주목하세요.
배열 구조 분해처럼, 선언 없이 할당만 할 수도 있어요:
({ a, b } = { a: "baz", b: 101 });
이 문장을 괄호로 둘러싸야 했다는 점에 주목하세요. JavaScript는 보통 {를 블록의 시작으로 파싱해요.
... 문법으로 객체의 나머지 항목을 위한 변수를 만들 수 있어요:
let { a, ...passthrough } = o;
let total = passthrough.b + passthrough.c.length;
프로퍼티 이름 바꾸기 (Property renaming)
프로퍼티에 다른 이름을 줄 수도 있어요:
let { a: newName1, b: newName2 } = o;
여기서부터 문법이 헷갈리기 시작해요. a: newName1은 "a as newName1"처럼 읽을 수 있어요. 방향은 왼쪽에서 오른쪽이라, 다음과 같이 쓴 것과 같아요:
let newName1 = o.a;
let newName2 = o.b;
혼란스럽게도 여기서 콜론은 타입을 나타내지 않아요. 타입을 지정한다면 구조 분해 전체 뒤에 써야 해요:
let { a: newName1, b: newName2 }: { a: string; b: number } = o;
기본값 (Default values)
기본값을 사용하면 프로퍼티가 undefined일 때의 기본값을 지정할 수 있어요:
function keepWholeObject(wholeObject: { a: string; b?: number }) {
let { a, b = 1001 } = wholeObject;
}
이 예시에서 b?는 b가 선택적이라 undefined일 수 있음을 나타내요. keepWholeObject는 이제 wholeObject와 프로퍼티 a 그리고 b를 위한 변수를 갖게 돼요. b가 undefined여도 마찬가지예요.
함수 선언 (Function declarations)
구조 분해는 함수 선언에서도 동작해요. 간단한 경우에는 직관적이에요:
type C = { a: string; b?: number };
function f({ a, b }: C): void {
// ...
}
하지만 기본값을 지정하는 게 매개변수에서 더 흔하고, 구조 분해와 함께 기본값을 올바르게 다루는 건 까다로울 수 있어요. 먼저, 패턴을 기본값보다 앞에 둬야 한다는 걸 기억해야 해요.
function f({ a = "", b = 0 } = {}): void {
// ...
}
f();
위 스니펫은 핸드북 앞부분에서 설명한 타입 추론의 예시이기도 해요.
그런 다음, 구조 분해된 프로퍼티의 선택적 프로퍼티에는 기본값을 주되, 메인 초기화 구문에는 주지 않아야 한다는 걸 기억해야 해요. C가 b를 선택적으로 정의했다는 걸 기억하세요:
function f({ a, b = 0 } = { a: "" }): void {
// ...
}
f({ a: "yes" }); // ok, default b = 0
f(); // ok, default to { a: "" }, which then defaults b = 0
f({}); // error, 'a' is required if you supply an argument
구조 분해는 신중하게 사용하세요. 앞선 예시에서 보듯, 가장 단순한 구조 분해식이 아닌 것은 무엇이든 헷갈려요. 특히 깊게 중첩된 구조 분해는 이름 바꾸기, 기본값, 타입 주석까지 겹치면 정말 이해하기 어려워져요. 구조 분해식을 작고 단순하게 유지하세요. 구조 분해가 만들어 낼 할당문들을 직접 작성해도 항상 괜찮아요.
전개 연산자 (Spread)
전개 연산자는 구조 분해의 반대 개념이에요. 배열을 다른 배열로, 또는 객체를 다른 객체로 펼칠 수 있게 해줘요. 예를 들어:
let first = [1, 2];
let second = [3, 4];
let bothPlus = [0, ...first, ...second, 5];
이러면 bothPlus에 [0, 1, 2, 3, 4, 5] 값이 담겨요. 전개는 first와 second의 얕은 복사본을 만들어요. 전개 때문에 원본은 변경되지 않아요.
객체도 전개할 수 있어요:
let defaults = { food: "spicy", price: "$$", ambiance: "noisy" };
let search = { ...defaults, food: "rich" };
이제 search는 { food: "rich", price: "$$", ambiance: "noisy" }예요. 객체 전개는 배열 전개보다 복잡해요. 배열 전개처럼 왼쪽에서 오른쪽으로 진행되지만, 결과는 여전히 객체예요. 이는 전개 객체에서 나중에 오는 프로퍼티가 앞에 오는 프로퍼티를 덮어쓴다는 걸 의미해요. 그래서 앞선 예시를 수정해 전개를 끝에 하면:
let defaults = { food: "spicy", price: "$$", ambiance: "noisy" };
let search = { food: "rich", ...defaults };
그러면 defaults의 food 프로퍼티가 food: "rich"를 덮어써버려요. 이 경우엔 우리가 원하는 게 아니죠.
객체 전개에는 또 몇 가지 놀라운 제약이 있어요. 첫째, 객체의 자체 소유, 열거 가능한 프로퍼티(own, enumerable properties)만 포함해요. 기본적으로 객체의 인스턴스를 전개하면 메서드를 잃는다는 뜻이에요:
class C {
p = 12;
m() {}
}
let c = new C();
let clone = { ...c };
clone.p; // ok
clone.m(); // error!
둘째, TypeScript 컴파일러는 제네릭 함수의 타입 매개변수 전개를 허용하지 않아요. 이 기능은 언어의 미래 버전에서 기대되는 기능이에요.
using 선언
using 선언은 등장 예정인 JavaScript 기능으로, Stage 3 명시적 자원 관리(Explicit Resource Management) 제안의 일부예요. using 선언은 const 선언과 많이 비슷하지만, 선언에 바인딩된 값의 수명 을 변수의 스코프 와 결부시킨다는 점이 달라요.
using 선언을 포함한 블록에서 제어가 빠져나가면, 선언된 값의 [Symbol.dispose]() 메서드가 실행돼서 그 값이 정리(cleanup)를 수행할 수 있게 돼요:
function f() {
using x = new C();
doSomethingWith(x);
} // `x[Symbol.dispose]()` is called
런타임에서 이는 대략 다음과 같은 효과를 가져요:
function f() {
const x = new C();
try {
doSomethingWith(x);
}
finally {
x[Symbol.dispose]();
}
}
using 선언은 파일 핸들처럼 네이티브 참조를 붙들고 있는 JavaScript 객체를 다룰 때 메모리 누수를 피하는 데 아주 유용해요.
{
using file = await openFile();
file.write(text);
doSomethingThatMayThrow();
} // `file` is disposed, even if an error is thrown
또는 트레이싱 같은 스코프 기반 연산에도 유용해요.
function f() {
using activity = new TraceActivity("f"); // traces entry into function
// ...
} // traces exit of function
var, let, const와 달리, using 선언은 구조 분해를 지원하지 않아요.
null과 undefined
값이 null 또는 undefined일 수 있다는 점도 중요해요. 그 경우 블록 끝에서 아무것도 처분되지 않아요:
{
using x = b ? new C() : null;
// ...
}
이는 대략 다음과 동일해요:
{
const x = b ? new C() : null;
try {
// ...
}
finally {
x?.[Symbol.dispose]();
}
}
이렇게 하면 using 선언을 할 때 복잡한 분기나 반복 없이 조건부로 자원을 획득할 수 있어요.
처분 가능한 자원 정의하기 (Defining a disposable resource)
자신이 만든 클래스나 객체가 처분 가능하다는 것을 나타내려면 Disposable 인터페이스를 구현하면 돼요:
// from the default lib:
interface Disposable {
[Symbol.dispose](): void;
}
// usage:
class TraceActivity implements Disposable {
readonly name: string;
constructor(name: string) {
this.name = name;
console.log(`Entering: ${name}`);
}
[Symbol.dispose](): void {
console.log(`Exiting: ${name}`);
}
}
function f() {
using _activity = new TraceActivity("f");
console.log("Hello world!");
}
f();
// prints:
// Entering: f
// Hello world!
// Exiting: f
await using 선언
일부 자원이나 연산은 정리를 비동기로 수행해야 할 수 있어요. 이를 수용하기 위해 명시적 자원 관리 제안은 await using 선언도 도입했어요:
async function f() {
await using x = new C();
} // `await x[Symbol.asyncDispose]()` is invoked
await using 선언은 제어가 포함 블록을 떠날 때 그 값의 [Symbol.asyncDispose]() 메서드를 호출하고 await 해요. 이를 통해 데이터베이스 트랜잭션의 롤백이나 커밋, 또는 파일 스트림이 닫히기 전에 보류 중인 쓰기를 저장소로 플러시하는 것 같은 비동기 정리가 가능해져요.
await와 마찬가지로 await using은 async 함수나 메서드, 또는 모듈의 최상위 레벨에서만 사용할 수 있어요.
비동기로 처분 가능한 자원 정의하기 (Defining an asynchronously disposable resource)
using이 Disposable인 객체에 의존하는 것처럼, await using은 AsyncDisposable인 객체에 의존해요:
// from the default lib:
interface AsyncDisposable {
[Symbol.asyncDispose]: PromiseLike<void>;
}
// usage:
class DatabaseTransaction implements AsyncDisposable {
public success = false;
private db: Database | undefined;
private constructor(db: Database) {
this.db = db;
}
static async create(db: Database) {
await db.execAsync("BEGIN TRANSACTION");
return new DatabaseTransaction(db);
}
async [Symbol.asyncDispose]() {
if (this.db) {
const db = this.db;
this.db = undefined;
if (this.success) {
await db.execAsync("COMMIT TRANSACTION");
}
else {
await db.execAsync("ROLLBACK TRANSACTION");
}
}
}
}
async function transfer(db: Database, account1: Account, account2: Account, amount: number) {
await using tx = await DatabaseTransaction.create(db);
if (await debitAccount(db, account1, amount)) {
await creditAccount(db, account2, amount);
}
// if an exception is thrown before this line, the transaction will roll back
tx.success = true;
// now the transaction will commit
}
await using vs await
await using 선언의 일부인 await 키워드는 오직 자원의 처분 이 await된다는 것만 나타내요. 값 자체를 await하는 건 아니에요:
{
await using x = getResourceSynchronously();
} // performs `await x[Symbol.asyncDispose]()`
{
await using y = await getResourceAsynchronously();
} // performs `await y[Symbol.asyncDispose]()`
await using과 return
한 가지 유의할 점이 있어요. async 함수 안의 await using 선언이 Promise를 먼저 await하지 않고 반환한다면 약간의 함정이 생길 수 있어요:
function g() {
return Promise.reject("error!");
}
async function f() {
await using x = new C();
return g(); // missing an `await`
}
반환된 promise가 await되지 않기 때문에, x의 비동기 처리를 await하는 동안 실행이 일시 중지되면서도 반환된 promise에 구독하지 않았으므로, JavaScript 런타임이 처리되지 않은 거부(unhandled rejection)를 보고할 가능성이 있어요. 다만 이 문제는 await using에만 고유한 것은 아니에요. try..finally를 사용하는 async 함수에서도 발생할 수 있으니까요:
async function f() {
try {
return g(); // also reports an unhandled rejection
}
finally {
await somethingElse();
}
}
이런 상황을 피하려면, 반환 값이 Promise일 수 있다면 반환 값을 await하는 걸 권장해요:
async function f() {
await using x = new C();
return await g();
}
for와 for..of 문장에서의 using과 await using
using과 await using 둘 다 for 문장에서 사용할 수 있어요:
for (using x = getReader(); !x.eof; x.next()) {
// ...
}
이 경우 x의 수명은 for 문장 전체로 스코프가 제한되고, break, return, throw로 제어가 루프를 떠나거나 루프 조건이 거짓이 될 때만 처분돼요.
for 문장 외에도 두 선언 모두 for..of 문장에서 사용할 수 있어요:
function * g() {
yield createResource1();
yield createResource2();
}
for (using x of g()) {
// ...
}
여기서 x는 루프의 각 반복이 끝날 때 처분되고, 다음 값으로 다시 초기화돼요. 이는 제너레이터가 하나씩 생산하는 자원을 소비할 때 특히 유용해요.
구형 런타임에서의 using과 await using
Symbol.dispose/Symbol.asyncDispose용 호환 폴리필(예를 들어 최근 NodeJS 에디션에서 기본 제공되는 것)을 사용한다면, 더 오래된 ECMAScript 에디션을 대상으로 할 때도 using과 await using 선언을 사용할 수 있어요.