안드로이드를 만들며 4 — 검증은 한 번, 증명은 타입이 들고 다닙니다
같은 날 버그 둘이 나왔어요. 하나는 같은 규칙이 두 곳에 다르게 적혀 있었고, 하나는 아무도 검증하지 않는 칸이 있었습니다. 값을 맞추는 데서 멈추면 셋째 버그가 옵니다.
지난 세 편은 화면과 문장이 조용히 틀어진 이야기였어요. 슬롯을 비웠더니 남의 글꼴이 들어왔고, 그늘로는 삼각형을 못 세웠고, 두 줄로 적은 문자열이 한 줄로 접혔죠.
이번 글은 조금 안쪽입니다. 같은 날 버그가 둘 나왔는데, 따로 보면 사소한 실수였는데 나란히 놓으니 같은 병이더라고요. 그리고 그 병은 값을 고쳐서는 안 낫는 종류였습니다.
먼저 버그 둘을 보여드릴게요.
하나. 단가에 0을 넣으면 오류도 결과도 안 나왔습니다. 화면이 아무 말도 안 했어요.
둘. 목표 평단가 칸은 아무도 검증하지 않았습니다. 0을 친 사람에게 엉뚱한 이유를 댔어요.
각각 고치는 건 어렵지 않았어요. 문제는 그다음이었습니다.
하나 — 같은 규칙이 두 곳에 다르게 적혀 있었어요
// 검증하는 쪽
fun validatePrice(price: Double?): Error? =
if (price != null && price < 0) Error.Negative else null
// 계산하는 쪽
val isValid = price != null && price > 0
< 0과 > 0. 0이 두 판정 사이의 틈으로 빠졌습니다.
검증하는 쪽은 “0은 음수가 아니니 통과”라고 했고, 계산하는 쪽은 “0은 양수가 아니니 계산 안 함”이라고 했어요. 그래서 오류도 없고 결과도 없었습니다.
둘 — 검사 함수의 네 번째 자리
입력 칸이 넷이었고 검사 함수가 넷을 받았어요.
그런데 마지막 자리의 이름이 BUY_QUANTITY였습니다.
목표 평단가 모드에는 “살 수량” 칸이 없어요.
그래서 그 모드는 네 번째 자리에 null을 넘겼고,
그 자리에 실제로 서 있던 목표 평단가 칸이 통째로 검증을 빠져나갔습니다.
이름이 착각을 만든 거예요.
BUY_QUANTITY라는 자리 이름이 “목표 모드에는 검사할 칸이 없다”는 생각의 뿌리였습니다.
자리는 자리 이름(FOURTH)으로, 뜻은 따로(InputKind)로 뒀어야 했어요.
고친 뒤에도 구조는 그대로였습니다
값을 맞추고 null을 없앴어요. 두 버그는 사라졌습니다.
그런데 구조가 안 바뀌었어요.
계산 함수는 여전히 이렇게 생겼거든요.
fun calculate(price: Double?, quantity: Double?, /* … */): Result? {
val isValid = price != null && price > 0 && /* … */ // ← 또 셉니다
if (!isValid) return null
// …
}
Double?을 받는 함수는 누가 부르든 제 몸을 지키려고 다시 셉니다.
그게 두 번째 사본이에요. 사본이 있는 한 셋째 버그는 시간 문제였습니다.
왜 두 번째 사본이 생길까요
“한 규칙이 두 곳에 적히면 갈린다”는 건 다들 알아요. 알면서도 두 곳에 적게 되는 이유가 있습니다.
호출하는 쪽이 검증됐는지를 알 길이 없어서예요.
calculate(price: Double?)라는 서명은 “검증된 값을 주세요”라고 말할 방법이 없습니다.
Double?은 그냥 숫자거나 없음이니까요.
그러니 받는 쪽은 방어하고, 그 방어가 사본이 됩니다.
타입이 “이미 검증됐다”를 들고 오게 했어요
그래서 걷었습니다.
sealed interface Validation {
data class Valid(val inputs: ValidInputs) : Validation
data object Incomplete : Validation
data class Invalid(val kind: InputKind, val reason: Reason) : Validation
}
fun validateInputs(raw: RawInputs): Validation
// 계산 함수는 ValidInputs 만 받습니다
fun calculate(inputs: ValidInputs): Result
바뀐 게 세 가지예요.
판정이 한 곳에서만 납니다. validateInputs 하나가 셋 중 하나를 냅니다.
결과를 만드는 쪽과 오류 문구를 만드는 쪽이 같은 판정에서 갈라져요.
전에는 각자 세다가 서로 다른 답을 냈거든요.
계산 함수가 다시 셀 이유가 없어졌습니다.
ValidInputs를 들고 있다는 건 이미 통과했다는 증명이니까요.
방어할 것이 없으니 사본도 없습니다.
검증 안 된 값은 컴파일이 안 됩니다. 이게 제일 커요. “검증을 깜빡한 경로”가 생길 수 없습니다. 그런 코드는 빌드가 안 되니까요. 둘째 버그(아무도 검증하지 않는 칸)는 이 구조에서 적히는 것 자체가 불가능해졌어요.
덤으로 null이 하나의 뜻만 갖게 됐어요
전에는 null이 두 가지를 뜻했습니다.
- 틀린 입력 — 사용자가 잘못 쳤다
- 닿을 수 없다 — 아직 다 안 쳤다
받는 쪽은 둘을 구분할 수 없으니 추측했어요.
그리고 그 추측이 화면의 문장이 됐습니다.
0을 친 사람에게 엉뚱한 이유를 댄 게 바로 이거였어요.
Incomplete와 Invalid가 갈리자 추측할 일이 없어졌습니다.
예외 하나 — 검증 앞에서 불리는 힌트
치는 중에 ”≈ n주”라는 힌트를 보여줍니다. 이건 검증 전에 불려요.
0을 받아도 무언가 보여줘야 하니까 ValidInputs를 요구할 수 없습니다.
그 자리의 검사는 남기고, KDoc에 사정을 적었어요. 예외를 두는 건 괜찮은데 왜 예외인지 적히지 않은 예외가 다음 사본의 씨앗이 되거든요.
이번에 확인한 것
- 규칙이 두 곳에 적힌 것을 보면 값을 맞추는 데서 멈추지 않습니다. 왜 두 번째 사본이 필요했는가를 물어요. 대개 답은 “호출하는 쪽이 모르기 때문”이고, 그 답의 답은 검증된 값의 타입입니다.
null이나Optional에 뜻을 둘 담지 않습니다. 둘이면 받는 쪽이 추측하고, 추측은 화면에서 말이 돼요.- 자리 이름과 뜻을 섞지 않습니다.
BUY_QUANTITY가 네 번째 자리의 이름이 되는 순간, 그 자리에 다른 것이 설 때 아무도 못 알아봅니다.
다시 같은 일을 한다면
이번 글이 “값이 아니라 구조를 고친” 첫 이야기였어요. 그리고 이날 이후로 하루를 통째로 구조에 쓰게 됩니다.
마지막 편은 그 하루 이야기예요. 손으로 만든 DI를 Hilt로, 손으로 만든 화면 전환을 Navigation으로 옮긴 날입니다.