Finally... 어라?

러리·2023년 10월 15일
1

안녕하세요.

여러분들은 스스로 finally에 대해서 얼마나 알고 있다고 생각하시나요?

저도 이번 주제를 준비하기까지는 그냥 clean-up 해주는 거 아닌가.. 했습니다.

그런데 이런저런 버그를 일으킬 수도 있는 요소가 숨어있다는 걸 알게 되었는데요!

이번에는 그 요소에 대해 알아보는 시간을 가져보도록 하겠습니다.

시작해볼까요?

💡 본 글은 ECMAScript 2024 Specification을 기준으로 작성되었습니다.
다른 언어에 대해서는 크게 다루지 않으니, 참고 바랍니다.


finally가 뭔데?

먼저 본 주제를 다루기 전에, finally가 뭔지부터 간단하게 짚고 넘어가볼게요.

finally는 일반적으로 trycatch 뒤에 붙으면서, trycatch 문의 동작이 모두 완료되었을 때 무조건 실행되는 코드를 작성하기 위해 사용합니다.

여기서 무조건이 이번 글에서 중요한 역할을 해줄 건데요. 이건 뒤에서 살펴보도록 할게요.

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 statement specification

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, a return statement's Completion Record may be replaced with another Completion Record during evaluation of the finally 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

이제 아까 위에서 나왔던 코드들 중에 해석할 수 있는 코드가 생겼어요.

  1. fn() 호출
  2. try 블럭 내에서 return 0; 실행
    • Completion Record = { [[Type]]: RETURN, [[Value]]: 0, [[Target]]: EMPTY }
  3. 에러가 발생하지 않았기 때문에 finally 블럭으로 이동
  4. finally 블럭에서 return 2; 실행
    • Completion Record = { [[Type]]: RETURN, [[Value]]: 2, [[Target]]: EMPTY }
  5. 함수가 종료되었으므로 마지막 Completion Record에 저장되어 있는 2를 반환

throw는 어떻게 정의되어 있을까요?

returnfinally에서 사용하면 이전에 return 했던 값이 덮어써지는 이유는 알아냈어요.

그러면 throw를 통해 던진 에러는 왜 finallyreturn에 덮어써지는 걸까요?

사실 이쯤되면 어렴풋이 예상이 되시죠?

맞습니다. throwCompletion Record를 반환합니다. 명세를 살펴볼까요?

throw statement specification

바로 앞에서 Completion Record를 반환한다고 했는데.. ThrowCompletion을 반환하고 있죠?

ThrowCompletion을 살펴봅시다.

ThrowCompletion specification

ThrowCompletion 또한 Completion Record를 반환하고 있기 때문에, 결국 throwCompletion Record를 반환한다고 볼 수 있습니다.

[[Type]]THROWCompletion RecordThrowCompletion이라고 정의한 것 같아요.

해석할 수 있는게 또 생겼어요!

function fn() {
  try {
    throw new Error();
  } catch (e) {
    throw new Error();
  } finally {
    return 2;
  }
}

console.log(fn());

// Output:
// 2

이제 이 코드도 해석할 수 있을 거 같아요.

  1. fn() 호출
  2. try 블럭 내에서 throw new Error(); 실행
    • ThrowCompletion 반환
  3. ThrowCompletion에서 Completion Record 반환
    • Completion Record = { [[Type]]: THROW, [[Value]]: Error, [[Target]]: EMPTY }
  4. 에러가 발생했기 때문에, catch 블럭 내에서 throw new Erorr(); 실행
    • 동일한 절차로 Completion Record 반환
    • Completion Record = { [[Type]]: THROW, [[Value]]: Error, [[Target]]: EMPTY }
  5. catch까지 동작이 완료되었기 때문에 finally 블럭 실행
    • Completion Record.= { [[Type]]: RETURN, [[Value]]: 2, [[Target]]: EMPTY }
  6. 따라서 마지막 Completion Record[[Value]]인 2가 반환되어 출력됩니다.

마무리

간단하게 finally의 이모저모 동작에 대해 알아보고, 관련 내용을 자바스크립트 명세에서 "왜" 그렇게 동작하는지를 알아봤습니다.

사실 정말로 "왜" 그렇게 동작하는지를 설명했다고 보기는 힘들죠.

정말 "왜" 그런지를 알아보려면 v8이나 이런저런 자바스크립트 컴파일러를 뜯어서, 해당 부분이 어떻게 컴파일 되는지까지 깊게 들어가봐야 "왜"를 알아보았다고 할 수 있을 거 같아요.

사실 실제로 v8 github 코드도 살펴봤고, AST라던가.. 바이트코드라던가.. 이런 저런 이야기를 살펴보긴 했었어요. 하지만 이걸 완전하게 이해하고 글을 쓰기에는 아직 제 실력이 부족하다는 것만 느끼고 빠져나올 수 밖에 없었습니다.

언젠가는 그 내용도 꼭 다뤄보는 걸로 하고, 글을 마무리하겠습니다.

읽어주셔서 감사합니다!

참고 자료

profile
하고 싶은걸 합니다.

0개의 댓글