인터페이스가 사용자가 반드시 마주해야 하는 우려와 무심코 놓칠 수 있는 우려를 어떻게 신호하는지, 그리고 그 선택이 오류 처리, 이름 짓기, 유니언 타입, 필수 매개변수, 무작위화, UI 설계 전반에 어떤 영향을 주는지 살펴본다.
이 코드는 대충 훑는 리뷰는 쉽게 통과할 것이다:
const response = await fetch('https://example.com/flags.json')
const flags = await response.json()
startServer(flags)
그러다 어느 날 엔드포인트가 500을 반환하고, flags는 { error: 'Internal Server Error' }가 되며, 어떤 키도 실제 옵션과 일치하지 않아 서버는 조용히 모든 기본값으로 시작된다.
fetch는 HTTP 오류에서 reject되지 않는다. 어느 쪽이든 resolve되며, 인터페이스 어디에도 response.ok를 확인하라고 알려 주지 않는다. 버그는 오류 처리를 건너뛰기로 결정했다 는 데 있지 않다. 애초에 결정할 일이 있다는 사실조차 깨닫지 못했다.
누구나 이런 식으로 자신을 절망의 구덩이에 밀어 넣는 인터페이스를 써 본 적이 있다. 나는 그 바닥을 충분히 많이 쳐 봐서 그 패턴이 보이기 시작했다.
인터페이스가 드러내는 각 우려마다, 그것은 사용자가 그것과 반드시 마주하게 만들거나 무심코 무시할 수 있게 둔다. 마주한 우려를 무시하는 것은 의도적인 결정이지만, 알지 못한 우려를 무시하면 자신이 어떤 가정을 떠맡았는지도 모른 채 그 가정에 묶이게 된다. 이 신호 방식이 인터페이스가 어떻게 실패하는지를 결정한다. 결정에 의해 실패하는가, 아니면 사고로 실패하는가.
이 관점으로 인터페이스를 보기 시작하면, 익숙한 많은 설계 질문이 사실 같은 질문이 된다. 오류를 throw할까 아니면 오류 값을 반환할까? 필수 매개변수일까 기본값일까? 객체일까 유니언 타입일까? 이 각각은 인터페이스가 어떤 우려를 얼마나 크게 신호해야 하는지를 묻는다. 곧 여기에 답하는 원칙들이 생긴다.
나는 이 신호라는 용어를 telecommunications에서 빌려 왔다.
인밴드 신호는 제어 정보가 데이터와 같은 채널을 통해 이동한다는 뜻이다. 아웃오브밴드 신호는 제어 정보를 위해 별도의 채널을 사용한다.
이 구분은 인터페이스의 우려에 깔끔하게 대응된다. 모든 인터페이스에는 똑같은 두 개의 채널이 있고, 각 우려는 그중 하나를 통해 전달된다. 즉, 사용자가 인터페이스를 사용하려면 반드시 마주해야 하는 채널이거나, 옆으로 비켜 있어 놓칠 수 있는 채널이다.
성공과 실패의 union을 반환하는 함수를 생각해 보자. 호출자는 오류 가능성을 인식하지 않고서는 그 함수를 사용할 수 없다.
예를 들어 Rust의 Result<T, E> 타입을 반환하면 호출자는 오류를 명시적으로 처리할 수밖에 없다:
fn parse_config(raw: &str) -> Result<Config, ParseError> { ... }
// Trying to use the result without unwrapping would trigger a type error.
// If the caller decides to ignore the error, then it's intentional.
let result = parse_config(raw);
match result {
Ok(config) => start_server(config),
Err(e) => eprintln!("{e}"),
}
이 경우 오류는 인밴드 이다. 그것과 마주하는 일은 인터페이스를 사용하는 일과 떼려야 뗄 수 없다.1
이제 Config를 반환하고 실패 시 throw 하는 함수를 생각해 보자. 호출자는 예외가 아무 인정도 요구하지 않기 때문에 Config를 직접 사용할 수 있다.
예를 들어 JavaScript의 Error를 throw하면 호출자는 오류와 마주하지 않고도 진행할 수 있다:
/** @throws Error for invalid configs. */
function parseConfig(raw: string): Config {
// ...
}
// The caller may inadvertently ignore the error if they did not read the
// function documentation and are unaware it can throw.
const config = parseConfig(raw)
startServer(config)
이 경우 오류는 아웃오브밴드 이다. 그것과 마주하려면 규율이 필요하고, 호출자는 그것이 존재하는지도 모른 채 스쳐 지나갈 수 있다.
checked exception을 throw하는 함수는 오류를 다시 인밴드로 옮긴다. 호출자는 오류를 명시적으로 catch하거나 전파하도록 강제된다.
예를 들어 Java의 throws 키워드는 오류 처리를 인밴드로 만든다:
Config parseConfig(String raw) throws ParseException {
// ...
}
// The caller is forced to handle the checked exception. This won't compile
// without either catching or declaring `throws ParseException`.
void start(String raw) throws ParseException {
Config config = parseConfig(raw);
startServer(config);
}
하지만 어떤 우려가 마땅한 것보다 더 인밴드에 가까우면 역효과가 난다. 인정이 반사적 행동이 되어 버리기 때문이다. Java 프로그래머들은 악명 높게도 infamously 빈 catch 블록, 확인되지 않는 재throw, 또는 throws Exception 절을 사용해 checked exception을 잠재운다.
그것은 아웃오브밴드보다도 더 나쁘다. 코드가 그 우려와 마주한 척만 하기 때문이다.
Rust의 성공은 Java의 실패가 마주하게 만든 것 자체가 아니라 사용성에 있었음을 시사한다. Result는 같은 인정을 강제하지만, 처리하거나 전파하는 일이 즐겁다.
Checked exception은 또한 데이터 를 실어 나르는 채널과 우려 를 실어 나르는 채널이 같지 않다는 점도 보여 준다. Checked exception은 반환값 바깥으로 이동하지만, 그 우려는 인밴드다. 반대 방향의 불일치도 존재한다. 데이터가 인밴드라는 사실이 우려도 인밴드임을 뜻하지는 않는다.
예를 들어 C 스타일 -1 sentinel values는 데이터 차원에서는 인밴드지만, 사용자가 sentinel 확인을 빠뜨릴 수 있기 때문에 우려 차원에서는 아웃오브밴드다:
// `open` signals failure via a -1 return value. The caller may not check for -1
// if they're unaware it's a possible return value.
int fd = open("config.json", O_RDONLY);
// Undefined behavior if `fd == -1`.
read(fd, buf, sizeof(buf));
이름은 우려를 인밴드로도, 아웃오브밴드로도 옮길 수 있다.
예를 들어 Java의 HashSet은 순회 순서를 보장하지 않지만, 이름은 순서 속성이 아니라 구현만을 설명한다. 작은 집합에서는 순회 순서가 우연히 삽입 순서와 일치할 수 있으므로, 사용자는 자신도 모르게 거기에 의존할 수 있다:
// May print in insertion order for small sets, tempting the user to depend on
// an ordering that is not guaranteed.
HashSet<Integer> set = new HashSet<>(List.of(3, 1, 4, 1, 5));
for (int value : set) {
System.out.println(value);
}
Java의 TreeSet은 순서 우려를 인밴드로 옮긴다. 이름이 트리 구조를 신호하고, 그것은 정렬된 순회를 강하게 암시하므로 사용자는 순서가 계약의 일부라고 추론할 수 있다:
// The name hints at sorted order. The user is more likely to recognize that
// ordering is a deliberate property.
TreeSet<Integer> set = new TreeSet<>(List.of(3, 1, 4, 1, 5));
for (int value : set) {
System.out.println(value);
}
Unions는 보통 불법 상태를 표현 불가능하게 만들기 위한 도구이며, 각 variant가 사용자가 반드시 고려해야 하는 경우가 되므로 그 부산물로 우려를 인밴드로 옮긴다.
하지만 합법성과 신호는 서로 독립적이다. 모든 상태가 이미 합법적이더라도 유니언은 우려를 인밴드로 옮길 수 있다.
예를 들어 JavaScript의 KeyboardEvent 타입에서는 모든 속성값 조합이 유효하지만, 그럼에도 한 가지 우려를 숨긴다:
interface KeyboardEvent extends UIEvent {
// Which primary key is pressed.
key: string
// Secondary modifier key flags. All value combinations are valid.
altKey: boolean
ctrlKey: boolean
metaKey: boolean
shiftKey: boolean
// Other irrelevant properties...
}
사용자가 찾고 접근하는 주요 속성은 key다. 이것은 key board event이기 때문이다. 사용자는 modifier key를 확인하는 일을 쉽게 잊을 수 있고, 그 결과 의도치 않게 너무 넓은 로직이 생길 수 있다. modifier 우려는 아웃오브밴드다. 이 플래그들은 이벤트의 주된 데이터가 아니므로 사용자의 주의를 안정적으로 끌지 못한다.
modifier 우려를 더 인밴드로 표현한 형태는 이런 모습일 수 있다:
type KeyboardEvent =
| {
kind: 'single-key'
key: string
}
| {
kind: 'modified-key'
primaryKey: string
altKey: boolean
ctrlKey: boolean
metaKey: boolean
shiftKey: boolean
}
TypeScript에서 사용자는 kind 외에는 이 타입의 어떤 데이터에도 접근할 수 없다. 단일 키 경우와 modifier가 있는 키 경우를 각각 따로 고려하도록 강제된다. 제거할 불법 상태는 없었지만, 유니언으로 바꾸는 것만으로도 여전히 신호 방식이 바뀌었다.2
필수 매개변수는 가정을 제거함으로써 우려를 인밴드로 옮길 수 있다.
예를 들어 Java의 String(byte[], Charset) constructor에는 optional Charset 매개변수가 있다. 이를 생략하면 플랫폼의 기본 charset, 보통 UTF-8, 이 사용된다. 사용자가 charset 지정을 잊으면 디코딩 중 기본값이 데이터를 손상시킬 수 있다. charset 우려는 아웃오브밴드다:
// The caller may not realize the platform default charset is being used, which
// may corrupt the data during decoding.
String text = new String(bytes);
반면 널리 쓰이는 Java 라이브러리인 Guava는 ByteSource에서 CharSource를 만들 때 Charset을 지정하지 않는 것을 불가능하게 만든다:
String text = ByteSource.wrap(bytes) // `byte[]` -> `ByteSource`
.asCharSource(charset) // `Charset` required here
.read(); // -> `String`
asCharSource(Charset)의 필수 매개변수는 charset 우려를 인밴드로 옮긴다.
무작위화는 사용자가 결정적인 관찰 가능 동작에 암묵적으로 의존하지 못하게 함으로써 우려를 인밴드로 옮길 수 있다.
예를 들어 HashSet처럼 Java의 HashMap도 순회 순서가 명시되지 않아 사용자가 실수로 거기에 의존할 수 있다.
Google 엔지니어들은 자신들의 JDK를 수정해 해시 순회 순서를 무작위화함으로써 이 우려를 인밴드로 옮겼다. 사용자는 코드 실행마다 순서가 바뀌는 것을 관찰하게 되었고, 자신도 모르게 거기에 의존할 수 없었다. Go도 map에 대해 같은 조치를 취해 Go 1부터 순회 순서를 무작위화했다.3
우려 신호는 UI에도 적용된다.
Slack의 스레딩은 아웃오브밴드다. 채널에 새 최상위 메시지가 도착했을 때, UI는 또 다른 최상위 메시지를 보낼지 아니면 스레드에 답글을 달지 결정하도록 강제하지 않는다. 가장 저항이 적은 경로는 항상 사용 가능한 최상위 텍스트 입력창에 타이핑하는 것이다. 그 결과 사용자는 늘 실수로 최상위에 답글을 단다.
Google Chat의 스레딩은 Google이 Slack식 접근으로 “업그레이드”하기 전까지는 인밴드 였다. 원래 UI는 새 스레드를 시작하는 버튼을 클릭할지, 기존 스레드의 텍스트 입력창에 답글을 달지 결정하도록 강제했다. 항상 사용 가능한 최상위 텍스트 입력창은 없었다. 나는 원래 디자인에서 사용자가 실수로 새 스레드를 시작하는 것을 본 적이 없었다.
대화 주제별 그룹화를 사용한 Google Chat의 원래 디자인:

Slack의 인라인 스레딩과 맞춘 새 디자인:

여기까지 오면 모든 우려가 인밴드여야 한다고 생각할 수도 있지만, 그것은 실현 가능하지 않다. 인터페이스에는 많은 우려가 있고, 그것들이 모두 똑같이 관련 있거나 중대한 것은 아니다.
인밴드와 아웃오브밴드 신호 사이의 선택은 과학이라기보다 예술에 가깝지만4, 몇 가지 원칙이 도움이 된다:
예시:
* JavaScript의 [`Array.prototype.indexOf(searchElement, fromIndex)`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/indexOf) 함수는 `fromIndex`가 지정되지 않으면 첫 번째 인덱스부터 검색하는 것을 기본값으로 삼는다. 이것은 거의 항상 사용자가 원하는 동작이다.
* 페이지네이션된 엔드포인트를 자동 페이지네이션하는 함수는 합리적인 페이지 크기를 기본값으로 둘 수 있다. 모든 사용 사례에서 가장 성능이 좋은 선택은 아닐 수 있지만, 결과는 언제나 올바를 것이다.
예시: Python의 open 함수는 읽기 모드를 기본값으로 삼는데, 이는 데이터를 파괴할 수 없으므로 안전하다.
예시:
* 데이터베이스 쿼리 타임아웃은 프로덕션 동작을 관찰한 뒤 조정하는 편이 가장 좋다. 프로그래머에게 미리 추측을 강요하는 것은 안전이 아니라 잡음만 더한다.
* Android는 원래 설치 시점에 앱의 전체 권한 목록을 승인하라고 사용자에게 요청했는데, 사용자는 그것을 평가할 맥락이 없었기 때문에 반사적으로 넘겨 버렸다. [Android 6.0은 프롬프트를 런타임으로 옮겼고](https://developer.android.com/training/permissions/requesting), 앱이 각 권한을 필요로 하는 순간 사용자와 마주하게 함으로써 의미 있는 응답이 가능해졌다.
예시:
* HTTP 클라이언트는 기본적으로 redirect를 따른다. redirect가 문제라면 프로그래머는 그것을 알아차리고 비활성화할 것이다. 그 우려는 관련이 생기는 순간 스스로 드러난다.
* PostgreSQL에서 삭제 시 foreign key의 기본 동작은 `NO ACTION`이어서 참조된 행의 삭제가 실패한다. 이 동작이 잘못된 것으로 드러나면 프로그래머는 그것을 알아차리고 올바른 동작을 지정할 것이다.
예시:
* mutex 라이브러리를 사용한다면, 이미 정확성의 모서리 사례들에 신경 쓰기로 선택한 셈이다. Rust가 poisoning을 인밴드로 드러내기 위해 [`mutex.lock()`](https://doc.rust-lang.org/std/sync/struct.Mutex.html#method.lock)에서 `Result`를 반환하는 것은 그런 대상 사용자에게 적절하다.
* Python 같은 고수준 스크립트 언어가 사용자가 버퍼 크기를 선택하도록 요구하지 않고 기본적으로 buffered I/O를 사용하는 것은 그런 대상 사용자에게 적절하다. 대부분의 Python 사용자는 I/O 성능 튜닝에 관심이 없고, 그저 파일을 읽고 싶어 한다.
예시: tls.createServer는 대략 40개의 옵션을 받는데, 거의 모두 선택 사항이며 따라서 아웃오브밴드다.
모든 인밴드 우려는 사용자의 주의를 소모시킨다. 모든 아웃오브밴드 우려는 조용한 버그의 위험을 만든다. 인터페이스를 설계한다는 것은 각 우려마다 어떤 비용을 치를지 선택하는 일이다.
인밴드 방향으로 틀리면 사용자는 형식적 절차에 파묻힌다. 아웃오브밴드 방향으로 틀리면 사용자는 잠복 버그를 자신 있게 배포하게 된다.
이것을 제대로 해내면, 사용자는 성공의 구덩이로 떨어질 것이다.
이것은 Rusty Russell의 오용하기 어려운 척도에서 가장 위쪽에 해당하며, 이 척도는 인터페이스를 positive 절반과 negative 절반으로 나눠 평가한다. ↩
다만 이 재설계에도 자체적인 불법 상태가 있다. 즉, 모든 플래그가 false로 설정된 modified-key 이벤트다. 각기 다른 플래그가 true여야 하는 variant들의 유니언으로 바꾸면 이를 고칠 수 있지만, 그것은 신호 변화와는 직교하는 문제다. ↩
이것은 인밴드 설계가 명시되지 않은 동작에 조용히 의존하는 것을 불가능하게 만들어 Hyrum’s Law에 맞서 방어할 수 있음을 보여 준다. ↩
나는 의사결정 트리를 만들어 보려 했지만 실패했다. 복잡성이 계속 커져 결국 이해 불가능해졌다. ↩