안녕하세요.
여러분들은 스스로 finally
에 대해서 얼마나 알고 있다고 생각하시나요?
저도 이번 주제를 준비하기까지는 그냥 clean-up 해주는 거 아닌가.. 했습니다.
그런데 이런저런 버그를 일으킬 수도 있는 요소가 숨어있다는 걸 알게 되었는데요!
이번에는 그 요소에 대해 알아보는 시간을 가져보도록 하겠습니다.
시작해볼까요?
💡 본 글은 ECMAScript 2024 Specification을 기준으로 작성되었습니다.
다른 언어에 대해서는 크게 다루지 않으니, 참고 바랍니다.
finally
가 뭔데?먼저 본 주제를 다루기 전에, finally
가 뭔지부터 간단하게 짚고 넘어가볼게요.
finally
는 일반적으로 try
나 catch
뒤에 붙으면서, try
나 catch
문의 동작이 모두 완료되었을 때 무조건 실행되는 코드를 작성하기 위해 사용합니다.
여기서 무조건이 이번 글에서 중요한 역할을 해줄 건데요. 이건 뒤에서 살펴보도록 할게요.
finally
예시 (1)try {
console.log('Some Try Block!');
throw new Error();
} catch (e) {
console.log('Some Catch Block!');
} finally {
console.log('Some Finally Block!');
}
위와 같은 코드를 실행시켰을 때, 그 결과는 다음과 같습니다.
Some Try Block!
Some Catch Block!
Some Finally Block!
try
에서 에러가 발생했기 때문에 catch
블럭 안에 있는 코드가 실행되었고, 마지막으로 finally
블럭 안에 있는 코드가 실행되어 위와 같은 출력 순서를 보여주게 됩니다.
finally
예시 (2)try {
console.log('Some Try Block!');
} catch (e) {
console.log('Some Catch Block!');
} finally {
console.log('Some Finally Block!');
}
위와 같은 코드를 실행시켰을 때, 그 결과는 다음과 같습니다.
Some Try Block!
Some Finally Block!
try
에서 에러가 발생하지 않았기 때문에, catch
블럭은 실행하지 않고 finally
블럭을 바로 실행하는 모습을 보여줍니다.
try-catch-finally
를 사용해보신 분들이라면 여기까지는 아는 내용일 거라 생각합니다.
하지만 문제는 무조건 finally
가 실행된다는 것인데요.
얼마나 무조건 실행될까요?
function fn() {
try {
console.log('Some Try Block!');
} catch (e) {
console.log('Some Catch Block!');
} finally {
console.log('Some Finally Block!');
}
}
fn();
// Output:
// Some Try Block!
// Some Catch Block!
// Some Finally Block!
일단 함수에 넣어봤습니다. 아직까지는 특이한 점이 없죠?
이제 return
을 사용해서 중간에 함수를 탈출해보겠습니다.
function fn() {
try {
return 0;
} catch (e) {
return 1;
} finally {
console.log('Some Finally Block!');
}
}
console.log(fn());
// Output:
// Some Finally Block!
// 0
그럼에도 finally
블럭 안에 있는 console.log
는 정상적으로 작동하는 모습을 볼 수 있습니다.
clean-up 용도로 보통 사용하니 여기까지는 "음. 그래도 괜찮네." 할 수 있을 거에요.
그런데.
finally
안에서 return
을 사용하면?문제는 finally
안에서 return
을 사용할 때 문제가 되기 시작합니다.
function fn() {
try {
return 0;
} catch (e) {
return 1;
} finally {
return 2;
}
}
console.log(fn());
// Output:
// ??????
뭐가 출력될까요? 한 번 예상해보세요!
🤔
🤔
🤔
🤔
🤔
바로 2가 출력된답니다.
분명히 try
블럭에서 0
을 반환했고 그렇게 함수가 끝날 줄 알았지만, finally
는 무조건 실행되며, return 2
가 실행되어 반환값이 변경된 것입니다.
여기부터 살짝 이해가 안되기 시작하지만, 조금만 더 가볼까요?
finally
안에서 return
을 사용하면?이건 또 무슨 이야기냐 하실거에요.
function fn() {
try {
throw new Error();
} catch (e) {
throw new Error();
} finally {
return 2;
}
}
console.log(fn());
// Output:
// ???????????
위와 같은 코드가 있을 때, 무슨 출력 결과가 나올까요?
그래도 역시 에러를 던지니까.. finally
블럭을 실행하지 않고 넘어갈까요?
아니면 에러와 함께 2
가 반환될까요?
아니면 2
만 반환될까요?
한 번 예상해보세요!
🤔
🤔
🤔
🤔
🤔
바로 에러 없이 2가 출력된답니다.
위 코드에서 finally
블럭이 존재하지 않는다면 당연히 catch
에서 발생된 new Error()
로 인해 에러 로그가 찍힙니다.
하지만 finally
에서 return
을 함으로써 에러 내용은 없어져버리고 finally
의 반환값으로 대체되어 2
가 출력된 것입니다.
흔히 자바스크립트와 관련된 이런저런 밈이 있죠. 보통은 이거 언어 왜이러냐 이해할 수가 없다 같은 부류죠. 설마 이번에도 그런 케이스인가 생각할 수도 있어요.
그런데 자바스크립트만 이러한 동작을 일으킬까요? 여러 다른 언어들로도 테스트를 해봤습니다.
def fn():
try:
raise Exception()
except:
raise Exception()
finally:
return 2
print(fn())
# Output: 2
먼저 파이썬입니다. 동일한 동작을 하죠.
public class Main {
public static int fn() {
try {
throw new Exception();
} catch(Exception e) {
throw new Exception();
} finally {
return 2;
}
}
public static void main(String[] args) {
System.out.println(Main.fn());
}
}
// Output: 2
다음은 자바입니다. 역시 동일한 동작을 합니다.
fun fn(): Int {
try {
throw Exception()
} catch (e: Exception) {
return 1
} finally {
return 2
}
}
fun main() {
println(fn())
}
// Output: 2
코틀린입니다. 동일한 동작을 합니다.
public class HelloWorld {
public static int Fn() {
try {
throw new Exception();
} catch (Exception e) {
throw new Exception();
} finally {
return 2;
}
}
public static void Main(string[] args) {
Console.WriteLine(HelloWorld.Fn());
}
}
// Output: Control cannot leave the body of a finally clause
C#입니다. 테스트 해본 언어 중 유일하게 컴파일 오류(CS0157)를 일으킵니다.
일단 다시 자바스크립트로 돌아와서, 왜 이런 동작을 일으킬까요?
물론 v8의 코드를 하나하나 뜯어서 보면 근본적으로 그 이유를 알 수 있겠지만, 아직 저는 v8의 코드를 해석할 수 있는 능력이 없네요.
그래서, "자바스크립트는 이렇게 구현되어야 한다"를 정의한 자바스크립트 명세를 살펴보도록 할게요.
return
은 어떻게 정의되어 있을까요?return
문의 명세입니다. 문법은 사실 많은 분들이 이미 알고 계실테니 패스하고, NOTE
부분을 살펴볼게요.
A
return
statement may not actually return a value to the caller depending on surrounding context.
return
문은 컨텍스트에 따라서 호출자(caller)에게 값을 실제로 반환하지는 않을 수도 있다고 되어 있습니다.
For example, in a
try
block, areturn
statement's Completion Record may be replaced with another Completion Record during evaluation of thefinally
block.
그리고 그 예시를 제시하고 있는데요. try
블럭 안에 있는 return
문의 Completion Record는 finally
블럭 안에 있는 Completion Record로 대체될 수 있다고 되어있습니다.
또, 14.10.1를 살펴보면 return
문이 어떻게 평가(evaluation)될 것인지 정의되어 있습니다.
먼저 값 없이 반환하는 형태의 return
은 다음과 같은 Completion Record를 반환합니다.
필드 이름 | 값 |
---|---|
[[Type]] | RETURN |
[[Value]] | undefined |
[[Target]] | EMPTY |
그리고 값을 반환하는 형태의 return
은 다음과 같은 Completion Record를 반환합니다.
필드 이름 | 값 |
---|---|
[[Type]] | RETURN |
[[Value]] | exprValue |
[[Target]] | EMPTY |
function fn() {
try {
return 0;
} catch (e) {
return 1;
} finally {
return 2;
}
}
console.log(fn());
// Output:
// 2
이제 아까 위에서 나왔던 코드들 중에 해석할 수 있는 코드가 생겼어요.
fn()
호출try
블럭 내에서 return 0;
실행{ [[Type]]: RETURN, [[Value]]: 0, [[Target]]: EMPTY }
finally
블럭으로 이동finally
블럭에서 return 2;
실행{ [[Type]]: RETURN, [[Value]]: 2, [[Target]]: EMPTY }
2
를 반환throw
는 어떻게 정의되어 있을까요?return
을 finally
에서 사용하면 이전에 return
했던 값이 덮어써지는 이유는 알아냈어요.
그러면 throw
를 통해 던진 에러는 왜 finally
의 return
에 덮어써지는 걸까요?
사실 이쯤되면 어렴풋이 예상이 되시죠?
맞습니다. throw
도 Completion Record를 반환합니다. 명세를 살펴볼까요?
바로 앞에서 Completion Record를 반환한다고 했는데.. ThrowCompletion을 반환하고 있죠?
ThrowCompletion을 살펴봅시다.
ThrowCompletion 또한 Completion Record를 반환하고 있기 때문에, 결국 throw
도 Completion Record를 반환한다고 볼 수 있습니다.
[[Type]]
이 THROW
인 Completion Record를 ThrowCompletion이라고 정의한 것 같아요.
function fn() {
try {
throw new Error();
} catch (e) {
throw new Error();
} finally {
return 2;
}
}
console.log(fn());
// Output:
// 2
이제 이 코드도 해석할 수 있을 거 같아요.
fn()
호출try
블럭 내에서 throw new Error();
실행{ [[Type]]: THROW, [[Value]]: Error, [[Target]]: EMPTY }
catch
블럭 내에서 throw new Erorr();
실행{ [[Type]]: THROW, [[Value]]: Error, [[Target]]: EMPTY }
catch
까지 동작이 완료되었기 때문에 finally
블럭 실행{ [[Type]]: RETURN, [[Value]]: 2, [[Target]]: EMPTY }
[[Value]]
인 2가 반환되어 출력됩니다.간단하게 finally
의 이모저모 동작에 대해 알아보고, 관련 내용을 자바스크립트 명세에서 "왜" 그렇게 동작하는지를 알아봤습니다.
사실 정말로 "왜" 그렇게 동작하는지를 설명했다고 보기는 힘들죠.
정말 "왜" 그런지를 알아보려면 v8이나 이런저런 자바스크립트 컴파일러를 뜯어서, 해당 부분이 어떻게 컴파일 되는지까지 깊게 들어가봐야 "왜"를 알아보았다고 할 수 있을 거 같아요.
사실 실제로 v8 github 코드도 살펴봤고, AST라던가.. 바이트코드라던가.. 이런 저런 이야기를 살펴보긴 했었어요. 하지만 이걸 완전하게 이해하고 글을 쓰기에는 아직 제 실력이 부족하다는 것만 느끼고 빠져나올 수 밖에 없었습니다.
언젠가는 그 내용도 꼭 다뤄보는 걸로 하고, 글을 마무리하겠습니다.
읽어주셔서 감사합니다!
return
statement specification: https://tc39.es/ecma262/#sec-return-statementthrow
statement specification: https://tc39.es/ecma262/#sec-throw-statement