일반적인 계산기는 버튼을 눌렀을 때 다음으로 입력하는 숫자와의 연산을 수행하는 반면, 정수형 계산기 프로젝트 CalculatorApp은 NSExpression
을 이용하여 입력된 연산식을 계산하여 반환한다. 그래서 디스플레이에 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으로 나누기
)도 함께 처리하였다.
프로퍼티 옵저버(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'을 제거
}
}
}
}
다음으로 처리한 예외는 중복된 연산자
로, 마찬가지로 프로퍼티 옵저버(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
의 로직을 프로퍼티에서 분리해서 모아 관리하기로 하였다.
다음으로 발견한 예외는 연산자로 식이 끝날 때 계산을 시도하면 크래시가 발생하는 경우였다.
아래와 같은 메서드를 만들어 계산 메서드를 호출하기 전에 검사하여 식을 정리하도록 하였다.
중복된 연산자
예외를 처리하기 위해 만든 isLastInputOperator
프로퍼티를 재활용하였다.
private func handleLastOperatorIfNeeded() { // calculateExpression() 앞에 호출됨
// 마지막 입력이 연산자였는지 확인
if isLastInputOperator {
self.expression.removeLast() // 조건을 충족하면 마지막 연산자를 삭제
}
}
이후, 앞에서 중복된 연산자
를 처리하는데 사용한 handleLastCharIfNeeded()
메서드도 위의 메서드로 대체하였다.
중복된 연산자
를 처리하기 위해 appendOperatorToExpression()
앞에도 호출되게 함으로써 로직을 정제하고 재사용성을 높였다.
테스트 도중 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'을 삭제
}
}
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
를 넣어보려고도 했었는데, NSExpression
이 Int64
연산을 직접 지원하지 않아 구현이 어렵다고 느꼈다 (이론상으로는 가능해보인다).Input Validation
을 구현하면서, 예외처리의 우선순위를 어떻게 두냐에 따라 로직의 구조가 많이 바뀔 수 있음을 느꼈다. 여러가지 예외들에 대한 전체적인 그림이 그려져야 처음부터 틀을 잡고 로직을 구성하게 되는 것 같다. 나아가 구체적으로 어떤 예외들인지 모르더라도, 미처 생각하지 못한 예외가 발생했을 때 유연하게 처리할 수 있는 로직을 짜는 게 중요한 것 같다. 이는 도메인에 대해 '모르는 것을 아는 것'이기 때문에 무척 어려운 일이지만, 그만큼 투자할 가치가 있는 스탠스라고 생각된다.예외 4: 0으로 시작하는 숫자 (Case B)
의 경우 테스트를 하다가 우연히 발견한 예외였다. #Preview
를 통해 자주 테스트를 하거나, Swift Testing
같은 테스팅 시스템을 활용하는 등 다양하게 테스트 방법을 고려해서 테스트를 하는 비용의 부담을 줄이는 것이 특히 중요한 것 같다. 나아가서 개발자가 아닌 사용자가 개발자에게 피드백을 줄 때의 비용도 충분히 줄여놓아야 더 많은 테스트 아웃풋을 받을 수 있겠다는 생각이 들었다.