본문 링크 (Original Link)

금요일의 Q&A: Swift 에러 핸들링 구현

2017.09.03

#

by Mike Ash, translated by pilgwon

Swift의 에러 핸들링은 언어 고유의 기능입니다. 그것은 다른 언어들의 예외와 아주 비슷하지만, 신택스는 비슷하지 않고, 똑같이 작동하지도 않습니다. 오늘은 Swift 에러가 내부에서 어떻게 작동하는지 볼 예정입니다.

이론

Swift 에러가 언어 단계에서 어떻게 작동하는지 간단히 살펴 보겠습니다.

모든 Swift 함수는 throws 키워드에 의해 데코레이트될 수 있습니다. 이 throws 키워드는 오류가 발생할 수 있음을 나타냅니다:

func getStringMightFail() throws -> String { ...

실제로 함수에서 에러를 던지려면, throw 키워드를 Error 프로토콜에서 지정된 값과 함께 사용하세요:

throw MyError.brainNotFound

throws가 포함된 함수를 호출하려면, 당신은 반드시 try 키워드를 포함해야합니다:

let string = try getStringMightFail()

try 키워드는 그 자체로는 아무 것도 하지 않지만, 함수가 에러를 낼 수 있음을 나타내기 위한 표시로서 필요합니다. 호출은 반드시 throws 함수나, catch 핸들러가 있는 do 블록에서 에러를 던지는 것이 허용되어 있는 컨텍스트에 있어야 합니다.

catch 핸들러를 사용하려면, do 블록에 try를 통한 호출을 배치하고, catch 블록을 추가하세요:

do {
  let string = try getStringMightFail()
  ...
} catch {
  print("Got an error: \(error)")
}

에러가 발생하면, 실행은 catch 블록으로 넘어갑니다. 던져진 값은 에러로 사용할 수 있습니다. 당신은 타입 확인, 조건문 그리고 여러개의 catch 절을 사용하면 멋지다고 생각할 수 있지만, 이것은 기본입니다. 더 자세한 정보를 얻고 싶다면, Swift 프로그래밍 랭귀지의 에러 핸들링 섹션을 보세요.

지금까지 이것이 무엇인지에 대한 것이었습니다. 이제 어떻게 작동하는지 볼까요?

구현

그것이 어떻게 작동하는지 보기위해, 저는 제가 해체할 수 있는 에러 핸들링이 들어가 있는 더미 코드를 작성해 보았습니다:

struct MyError: Error {
    var x: Int
    var y: Int
    var z: Int
}

func Thrower(x: Int, y: Int, z: Int) throws -> Int {
    throw MyError(x: x, y: y, z: z)
}

func Catcher(f: (Int, Int, Int) throws -> Int) {
    do {
        let x = try f(1, 2, 3)
        print("Received \(x)")
    } catch {
        print("Caught \(error)")
    }
}

물론 Swift는 오픈 소스가 되었기 때문에, 컴파일러 코드를 보고 그게 무엇인지 볼 수 있습니다. 하지만 그것은 재밌는 작업은 아니고, 이 방법이 더 쉽습니다.

그것은 Swift 3과 Swift 4가 다르게 작동한다는 것을 드러냅니다. 먼저 Swift 3을 간략하게 살펴본 후에, Swift 4에서 좀 더 자세히 살펴 보겠습니다.

Swift 3은 본질적으로 Objective-C의 NSError 컨벤션을 자동화하면서 작동합니다. 컴파일러는 본질적으론 Error *와 NSError ** 인 추가적이고 숨겨져있는 파라미터를 입력합니다. 에러를 던지는 것은 해당 파라미터로 전달된 포인터에 에러 객체를 작성하는 것으로 구성됩니다. 호출자는 약간의 스택 공간을 할당하고, 그것의 주소를 파라미터에 보냅니다. 반환되면, 그것은 그 공간이 에러를 포함하고 있는지 체크하기 위해 봅니다. 그렇다면, catch 블록으로 넘어갑니다.

Swift 4에서는 약간 더 멋져졌습니다. 기본적인 아이디어는 같지만, 기존의 추가적인 파라미터 대신에, 특별한 레지스터가 에러 리턴을 위해 예약됩니다. Thrower와 관련된 어셈블리 코드는 다음과 같이 보입니다:

call       imp___stubs__swift_allocError
mov        qword [rdx], rbx
mov        qword [rdx+8], r15
mov        qword [rdx+0x10], r14
mov        r12, rax

이것은 Swift 런타임을 호출하여 새로운 에러를 할당하고, 관련된 값으로 채우고, 포인터를 r12에 배치합니다. 그리고나서 호출자에게 반환합니다. Catcher와 관련된 코드는 다음과 같습니다:

call       r14
mov        r15, rax
test       r12, r12
je         loc_100002cec

그것은 호출을 하고, r12가 비어있는지 확인합니다. 만약 그렇다면, catch 블록으로 넘어갑니다. ARM64의 기술과 거의 비슷하며, x21 레지스터는 에러 포인터 역할을 합니다.

내부적으로, Result 타입을 반환하거나 에러 코드를 반환하는 것과 비슷하게 보입니다. throws 함수는 발생한 에러를 호출자에게 특별한 위치에 반환합니다. 호출자는 에러를 위해 준비된 위치를 확인하고, 만약 있다면 에러 핸들링 코드 부분으로 넘어갑니다. 만들어진 코드는 NSError ** 파라미터를 쓰는 Objective-C 코드와 비슷해 보이고, 실제로 Swift 3의 그것과 동일한 버전입니다.

다른 “예외”들과 비교하기

Swift는 에러 핸들링에 대해 논의할 때 “예외”라는 단어를 절대 쓰지 않기 위해 조심합니다. 그러나 그것은 다른 언어의 예외처럼 보입니다. 그것의 구현은 어떻게 비교할까요? 예외를 사용하는 언어는 아주 많은데, 그들은 모두 각자의 방법으로 예외를 대하지만, 자연적인 비교 대상은 C++입니다. Objective-C 예외(존재는 하지만, 꽤 많은 사람들이 사용하지 않는 예외)는 모던 런타임에서 C++의 예외 메커니즘을 사용합니다.

C++의 예외가 어떻게 작동하는지에 대해 전부 다 둘러보려면 책 한 권을 채울 수 있기 때문에, 우리는 아주 간략하게만 둘러봐야 합니다.

throws를 사용하는 함수(C++ 함수의 기본)를 호출하는 C++ 코드는 throw를 사용하지 않는 함수를 호출한것처럼 어셈블리를 생성합니다. 즉, 그것은 파라미터를 전달하고 반환 값을 검색하며 예외 가능성을 생각하지 않습니다.

어떻게 이것이 가능할까요? 예외가 없는 코드를 작성하는 것 이외에, 컴파일러는 또한 코드가 예외를 처리하는 방법과 예외를 어떻게 처리하는지에 대한 정보가 있는 테이블을 만들고 예외가 던져진 경우 함수에서 빠져나오도록 스택을 안전하게 풀어내는 방법을 설명합니다. 예외가 던져졌을 때 스택을 어떻게 안전하게 되돌릴 것인지에 대한 정보의 테이블을 만듭니다.

어떤 함수가 예외를 던지면, 스택으로 가서 각 함수의 정보를 찾고 예외 처리를 발견하거나 끝에 도달할 때 까지 스택을 풀어 다음 함수로 넘어가는 데 사용합니다. 만약 예외 처리를 발견하면, 그것은 catch 블록에 있는 코드를 실행시킬 핸들러에게 컨트롤을 넘깁니다.

C++의 예외가 어떻게 작동하는지에 대한 더 자세한 정보는 C++ ABI for Itanium: Exception Handling을 보시면 됩니다.

이 시스템은 “제로-비용” 예외 처리라고 부릅니다. “제로-비용”이라는 용어는 어떤 예외도 던지지 않을 때 일어나는 것을 가리킵니다. 코드는 예외가 없이 컴파일되었기 때문에, 예외를 지원하기 위한 런타임 오버헤드가 없습니다. 잠재적으로 throw를 사용하는 함수를 호출하는 것은 throw를 사용하지 않는 함수를 호출하는 것 만큼 빠르며 코드에 try 블록을 추가해도 런타임에 추가 작업이 수행되지 않습니다.

예외가 던져지면, “제로-비용”의 컨셉은 창 밖으로 나옵니다. 테이블을 통해 스택을 풀어내는 것은 비싼 처리과정과 상당한 시간이 걸립니다. 시스템은 예외가 거의 없고, 예외가 발생하지 않는 경우의 성능이 더 중요하다는 아이디어를 기반으로 디자인 되었습니다. 이 가정은 거의 모든 코드의 경우에서 사실일 수 있습니다.

이것과 비교하면, Swift의 시스템은 매우 간단합니다. 이것은 throws를 쓰는 함수와 throws를 쓰지 않는 함수에 같은 코드를 만드는 가정을 하지 않습니다. 대신에, throws 함수를 호출할 때마다 에러가 리턴됐는지 확인하고, 만약 그렇다면 적절한 에러 처리 코드로 넘어가게 합니다. 이 확인하는 과정은 꽤 저렴해야 하지만, 무료는 아닙니다.

그 타협점은 Swift에 많은 의미를 부여합니다. Swift의 에러는 C++의 예외와 비슷해 보이지만, 실제론 다르게 사용됩니다. 거의 모든 C++ 호출은 잠재적으로 throw가 가능하고 심지어 새로운 연산자와 같은 기본적인 것조차 오류를 나타내기 위해 throw 할 수 있습니다. 모든 호출 후에 던져진 예외를 명확하게 확인하면 많은 부가적인 검사가 추가됩니다. 대조적으로, Swift 호출은 일반적인 코드베이스에서 거의 표시되지 않고, 그래서 명시적인 검사 비용은 낮습니다.

결론

Swift의 에러 핸들링은 C++과 같은 다른 언어의 예외와 비교를 유도합니다. C++의 예외 처리는 내부적으로 매우 복잡하지만, Swift는 다른 접근법을 보여줍니다. 보통의 경우에 “제로-비용”을 성취하기 위해 테이블을 풀어내는 대신에, Swift는 던져진 에러를 특별한 레지스터에 반환하고 호출자는 그 레지스터를 보고 에러가 던져진지 아닌지를 확인합니다. 오류가 발생하지 않을 때 약간의 오버 헤드가 추가되지만 C++에서 처럼 복잡한 작업을 하지 않아도 됩니다. 오류 처리에서 발생하는 오버 헤드가 눈에 띄는 차이를 만드는 경우 Swift 코드를 작성하는데 많은 노력이 필요합니다.

오늘은 여기까지 입니다! 더 많은 재미와 즐거움, 그리고 공포를 느끼고 싶으시면 다시 오세요! 제가 이전에 주기적으로 말씀드렸듯이, 금요일 Q&A는 독자의 제안으로 운영됩니다. 언제나 그렇듯이, 만약 당신이 여기에 쓰일 주제를 가지고 있으면 여기로 보내주세요!

게시물이 마음에 드셨나요? 저는 그것들로 가득찬 책을 판매하고 있습니다. iBooks와 Kindle에서 사용가능하시고, PDF나 ePub 포맷으로 직접 다운로드 가능합니다. 구식을 위한 종이 포맷도 있습니다. [자세한 정보는 여기를 누르세요](https://www.mikeash.com/book.html).