[iOS 5주차] 문제 해결: 예외 처리 - Input Validation for NSExpression

DoyleHWorks·1일 전
1

문제: Input Validation 구현

배경

일반적인 계산기는 버튼을 눌렀을 때 다음으로 입력하는 숫자와의 연산을 수행하는 반면, 정수형 계산기 프로젝트 CalculatorAppNSExpression을 이용하여 입력된 연산식을 계산하여 반환한다. 그래서 디스플레이에 900-3×200 와 같은 식을 입력할 수 있다.

private func calculate(_ expression: String) -> Int? {
    let expression = NSExpression(format: expression)
    if let result = expression.expressionValue(with: nil, context: nil) as? Int {
        return result
    } else {
        return nil
    }
}

문제는 NSExpression가 인식하지 못하는 비정상적인 식을 입력했을 때, nil이 반환되어 크래시가 유발된다는 점이다.
그래서 사용자의 입력 단계에서 오류를 유발하는 식이 나오지 않도록, Input Validation 구현을 시도하였다.
또한 NSExpression이 인식할 수 있지만 어색한 표현식(0으로 시작하는 정수, 0으로 나누기)도 함께 처리하였다.


예외1️⃣: 0으로 시작하는 숫자 (Case A)

프로퍼티 옵저버(didSet)를 활용하여 0으로 시작하는 숫자가 생기지 않도록 하였다.

private var expression = "0" { // 버튼을 눌렀을 때 expression.append(버튼타이틀)로 값이 변경됨
	didSet {
    	// `00`, `01` 등 0으로 시작하는 숫자가 생기지 않았는지 확인
    	if self.expression.count > 1 && self.expression[expression.startIndex] == "0" {
        	// `0×5`, `0+3` 등의 식은 가능해야 하니 '0' 바로 뒤가 숫자인지 체크
        	if expression[expression.index(expression.startIndex, offsetBy: 1)].isNumber {
            	self.expression.removeFirst() // 모든 조건을 충족하면 맨 앞의 '0'을 제거
			}
        }
    }
}

예외2️⃣: 중복된 연산자

다음으로 처리한 예외는 중복된 연산자로, 마찬가지로 프로퍼티 옵저버(willSet)를 통해 처리하려 했으나..

private var expression = "0" { // 버튼을 눌렀을 때 expression.append(버튼타이틀)로 값이 변경됨
	willSet {
    	// 마지막 입력값이 연산자이고, 현재 입력값 또한 연산자일 시
    	if isLastInputOperator && isCurrentInputOperator {
        	self.expression.removeLast() // 연산자를 추가하기 전 마지막 연산자를 삭제
        }
    }
	didSet {
    	if self.expression.count > 1 && self.expression[expression.startIndex] == "0" {
        	if expression[expression.index(expression.startIndex, offsetBy: 1)].isNumber {
            	self.expression.removeFirst()
			}
        }
    }
}

문제 발생

접근 방법 1: 구현 부분을 두 차례 바꿔보았지만 해결이 되지 않았다.


두 차례 시도 후, 프로퍼티 옵저버 안에서 프로퍼티를 변경하려고 한 것이 문제임을 짐작하게 되었다.


접근 방법 2: 프로퍼티 옵저버로부터 로직을 꺼내어 분리하였다.

문제 해결!

이 문제 해결을 계기로, Input Validation의 로직을 프로퍼티에서 분리해서 모아 관리하기로 하였다.


예외3️⃣: 연산자로 끝나는 식

다음으로 발견한 예외는 연산자로 식이 끝날 때 계산을 시도하면 크래시가 발생하는 경우였다.
아래와 같은 메서드를 만들어 계산 메서드를 호출하기 전에 검사하여 식을 정리하도록 하였다.
중복된 연산자 예외를 처리하기 위해 만든 isLastInputOperator 프로퍼티를 재활용하였다.

private func handleLastOperatorIfNeeded() { // calculateExpression() 앞에 호출됨
	// 마지막 입력이 연산자였는지 확인
    if isLastInputOperator {
        self.expression.removeLast() // 조건을 충족하면 마지막 연산자를 삭제
    }
}

이후, 앞에서 중복된 연산자를 처리하는데 사용한 handleLastCharIfNeeded() 메서드도 위의 메서드로 대체하였다.
중복된 연산자를 처리하기 위해 appendOperatorToExpression() 앞에도 호출되게 함으로써 로직을 정제하고 재사용성을 높였다.


예외4️⃣: 0으로 시작하는 숫자 (Case B)

테스트 도중 0으로 시작하는 숫자가 생성되는 또다른 경우를 발견하였다.
첫번째 Input Validation에 의해 05와 같은 숫자로 식이 시작되지는 않지만, 연산자 뒤에 0으로 시작하는 숫자를 입력할 수 있었다.


예외 발견

아래와 같은 메서드를 만들어 숫자 입력 메서드를 호출하기 전에 검사하여 식을 정리하도록 하여 해결하였다.

private func handleLeadingZeroIfNeeded() { // appendNumberToExpression() 앞에 호출됨
	// 예외가 발생할 수 있는 조건인지 확인
    guard self.expression.count > 2 else { return }
    // 마지막 입력이 '0'이고, 그 앞의 입력이 연산자인 경우
    if isLastInputZero && !self.expression[expression.index(expression.endIndex, offsetBy: -2)].isNumber {
        self.expression.removeLast() // 마지막 '0'을 삭제
    }
}

예외5️⃣: 0으로 나누기

NSExpression에서는 0으로 나누기를 시도할 경우 0을 반환하는데, 이는 여러 각도로 Input Validation을 적용한 배경에서는 매우 뜬금 없는 처리이다. 그래서 애초에 0으로 나누는 시도를 하지 못하도록 Input Validation을 적용하였다. 0으로 나누기 예외를 가장 마지막에 고민한 것은 불찰이었지만, 다행히 마지막 입력값이 0인지 확인하는 로직을 재활용하여 구현할 수 있었다.

private func isHandlingZeroInputNeeded(_ input: String) -> Bool { // 숫자를 추가하기 전에 호출됨
	// 입력값이 '0'인 경우
    if input == "0" {
    	// 마지막 입력값이 "÷"일 경우 숫자를 입력하지 않음 (false 반환)
        guard self.expression[expression.index(expression.endIndex, offsetBy: -1)] != "÷" else { return false }
        // 입력값 '0'이 유효한 입력임을 확인했으므로 통과시킴
        self.isLastInputZero = true
    } else {
    	// 입력값이 '0'이 아니므로 그냥 통과시킴
        self.isLastInputZero = false
    }
    return true
}
internal func appendNumberToExpression(_ input: String) {
	// 위 메서드에서 false가 반환되면 메서드가 취소됨 (숫자가 추가되지 않음)
    guard isHandlingZeroInputNeeded(input) else { return } // Input Invalidation: Expressions that devides with zero
    self.expression.append(input)
    self.isLastInputOperator = false
}

문제 해결 후 인사이트

  • 정수형 계산기였기에 쉽게 구현이 가능한 로직이 많았다. 식에 소수점이 들어갔다면 이것저것 조건을 더 많이 따지거나, 다른 접근방식을 택해야 했다. 소수점을 다룰 수 있게 . 버튼도 넣고 Int 대신 Double을 다루게 하는 것도 고민했었는데, 이러한 예외 처리가 더 까다로워지는 것이 가장 큰 걸림돌로 작용했다.
  • 더 큰 수를 다루게 하기 위해 Int 대신 Int64를 넣어보려고도 했었는데, NSExpressionInt64 연산을 직접 지원하지 않아 구현이 어렵다고 느꼈다 (이론상으로는 가능해보인다).
  • 여러가지 Input Validation을 구현하면서, 예외처리의 우선순위를 어떻게 두냐에 따라 로직의 구조가 많이 바뀔 수 있음을 느꼈다. 여러가지 예외들에 대한 전체적인 그림이 그려져야 처음부터 틀을 잡고 로직을 구성하게 되는 것 같다. 나아가 구체적으로 어떤 예외들인지 모르더라도, 미처 생각하지 못한 예외가 발생했을 때 유연하게 처리할 수 있는 로직을 짜는 게 중요한 것 같다. 이는 도메인에 대해 '모르는 것을 아는 것'이기 때문에 무척 어려운 일이지만, 그만큼 투자할 가치가 있는 스탠스라고 생각된다.
  • 테스트를 자주 할 수 있는 환경을 마련하는 것이 무척 중요하다고 느꼈다. 특히 예외 4: 0으로 시작하는 숫자 (Case B)의 경우 테스트를 하다가 우연히 발견한 예외였다. #Preview를 통해 자주 테스트를 하거나, Swift Testing 같은 테스팅 시스템을 활용하는 등 다양하게 테스트 방법을 고려해서 테스트를 하는 비용의 부담을 줄이는 것이 특히 중요한 것 같다. 나아가서 개발자가 아닌 사용자가 개발자에게 피드백을 줄 때의 비용도 충분히 줄여놓아야 더 많은 테스트 아웃풋을 받을 수 있겠다는 생각이 들었다.
profile
Reciprocity lies in knowing enough

0개의 댓글