개요

swift로 앱을 개발하다보면, assert, fatalError 등과 같은 함수를 호출하는 코드를 자주 볼 수 있다. 혹은 그런 코드를 보지 않았더라도, assert 라는 함수가 왜 있는지 궁금해하는 개발자들도 있을 것이다.

이번 글에서는 assert의 존재 이유와 사용 시 주의할 점에 대해 포스팅 해보고자 한다.

assert란 무엇인가?

func assert(
    _ condition: @autoclosure () -> Bool,
    _ message: @autoclosure () -> String = String(),
    file: StaticString = #file,
    line: UInt = #line
)

swift에서 assert 함수의 정의는 선언은 위와 같다. 개발 문서의 discussion을 보면, 이 함수는 아래와 같은 상황에서 사용하라고 안내하고 있다.

개발 중 내부적으로 sanity check, 즉 코드가 예상한 대로 정상적으로 동작하고 있는지 테스트하기 위해서 사용한다.

assert는 첫 번째 매개변수인 condition 불리언 변수가 false이면, message, file, line 매개변수를 토대로 디버깅에 용이한 정보를 콘솔에 출력하고 프로그램을 중지시킨다.

위 코드에선 assert에 의도적으로 false를 전달했다. message로 전달한 매개변수와 추가적인 정보들이 콘솔창에 출력된 것을 확인할 수 있다.

assert가 동작하는 것은 잘 알았으니, 이제 assert를 어느 상황에서 써야 하는지 알아보자.

assert를 사용하는 이유

위에서 기술하지 않은 assert의 특징이 있는데, 바로 assert는 Release configuration에선 컴파일 되지 않는다는 것이다. 즉 Debug 모드에서만 실행되고, 사용자에게 배포되는 Release 모드에서는 실행되지 않아 프로그램을 중지시킬 일도 없다.

이런 특징 덕분에 아래와 같은 상황에서 assert를 유용하게 사용할 수 있다.

주석보다 더 명확하게 계약조건을 명시하고 싶은 경우

프로그래밍 기법 중 계약에 의한 프로그래밍 이란 기법이 있다.

예를 들어 매개변수로 전달받은 금액을 지불하는 pay 라는 메소드가 있다고 가정해보자.

func pay(amount: Int) {
  //...
}

위 메소드에서 amount, 즉 금액으로 음수가 전달되면 어떻게 할까? 프로그래머는 많은 옵션을 선택할 수 있다. 금액이 음수인 경우를 검사해 함수를 일찍 종료할 수도 있고, 아니면 의미있는 에러를 던져서 사용자에게 에러 UI를 보여줄 수도 있다.

하지만 모든 코드에 그런 방어적인 로직을 작성하는 것은 생각보다 귀찮은 일이고, 코드의 사이즈도 증가할 것이다.

만약 위 함수에 절대로 음수인 amount가 전달되지 않을 것이라고 논리적으로 가정하고 함수를 작성했다면, assert를 이용해서 위 가정을 전달할 수 있다.

func pay(amount: Int) {
    // Amount는 절대 음수가 전달될 리 없다!
    assert(amount > 0, "Amount should be positive number")
}

즉 여기선 amount가 절대 음수로 들어올 리 없다고 계약을 하고 코드를 작성한 것과 같다.

이렇게 함수가 호출되기 전 충족해야 하는 조건을 사전조건 이라고 한다.

여기서 만약 amount로 음수가 전달된다면, assert 함수가 프로그램을 강제로 중지시킬 것이다. 물론 개발 모드에서만 말이다. 릴리즈 모드에서는 assert 함수가 컴파일 되지 않기 때문에 절대 프로그램이 중지되지 않는다.

amount로 음수가 전달되는 것은 개발자의 논리적인 가정이 완전히 깨지는 경우이기 때문에, 이럴 땐 오류가 조용히 발생하기보다는 개발자에게 강제로 오류를 보여주는 것이 훨씬 효과적일 것이다.

assert를 사용할 때 주의할 점

발생이 예상되는 상황에서는 assert 사용을 자제하자.

pay 함수에서는 amount로 절대 음수가 전달될 리 없다는 것을 가정하고 assert를 사용했다. 그러나 많은 금융 앱과 같이 이체해야 하는 금액을 사용자로부터 입력받는 경우를 생각해보자.

사용자는 너무 많은 금액을 입력하거나, 음수의 금액을 입력하는 경우가 생길 수도 있다. 즉 사용자, 혹은 외부로부터 값을 입력받는 경우는 충분히 잘못된 데이터가 들어올 수 있다는 소리다.

이럴 땐 assert를 사용하기 보단 입력값 검증을 직접 진행해야 한다.

func transfer(amount: Int) {
    // guard 문을 통해 입력값 검증을 진행한다. 오류를 던질 지 로그를 전송할 지는 개발자 혹은 기획자의 마음이다.
    guard amount > 0 else { return }
}

만약 transfer 함수에서 똑같이 assert를 사용했다간 프로덕션 코드에서 음수가 그대로 전달되는 대참사가 일어날 수도 있다.

입력값을 검증하는 계층을 만들자

다음과 같은 입력 소스들은 신뢰할 수 없는 데이터를 전달할 수 있다.

  • GUI
  • CUI
  • 파일
  • 기타 외부 객체

만약 위와 같은 소스로부터 입력받은 값들을 어느 계층에서 검사할 것인지 명확하게 정하지 않으면, 매번 유효성 검증 코드를 작성해서 앱의 바이너리 사이즈가 커질 수 있다.

이 때 아래와 같이 오류의 가능성이 있는 데이터를 다루는 영역과, 안전한 데이터를 다루는 영역을 구분하면 불필요한 유효성 코드를 줄일 수 있다.

func receiveAmountFromUser() {
    // GUI 또는 CUI를 통해 유저로부터 Amount를 입력 받는다.
}

func validateAmount(_ amount: Int) -> Bool {
    // amount가 유효한 범위 밖이라면, 에러를 던지는 유효성 검사 계층
    guard amount > 0 else { return false }
    return true
}

func pay(amount: Int) {
    assert(amount > 0)
    // 값을 지불한다.
}

위 처럼 데이터를 안전하게 변환하는 계층을 만들어서, pay에서는 assert만을 사용해 릴리즈 코드에서 불필요하게 바이너리 사이즈를 늘리는 문제를 방지할 수 있다.

validateAmount 함수에서 만약 사용자가 입력한 데이터가 잘못되었다고 판단하면, 그에 관한 에러 처리는 개발자가 원하는 대로 할 수 있을 것이다.

References

Code complete 8장 방어적 프로그래밍

profile
iOS 개발자입니다.

0개의 댓글