"모른다"를 표현하는 법

August 23, 2026

인터페이스를 설계하다 보면 늘 한 번씩 걸리는 게 있다.

이 기능은 있을 수도 있고 없을 수도 있다 를 어떻게 적어둘 것인가.

브라우저마다 지원하는 API 가 다르고, 서버 버전이 제각각이고,
통신 방식도 어떤 건 연결이 끊긴 걸 바로 알 수 있고 어떤 건 알 수가 없다.
이런 걸 옵셔널한 능력(optional capability) 이라고 부르는데,
이름만 거창하지 결국 있을 수도 있고 없을 수도 있는 기능이라는 뜻이다.

처음엔 이게 별 문제가 아니라고 생각했다.
없으면 없는 대로 에러가 나겠지 싶었는데, 그게 아니었다.

에러가 나면 그나마 낫다. 로그도 남고 어디서 터졌는지도 보인다.
근데 이건 에러가 안 난다. 아무 일도 일어나지 않는다.
호출은 했는데 답이 안 오고, 화면은 그대로 멈춰 있다.

정리하다 보니 결국 세 갈래로 나뉘더라.
어떻게 물어볼 것인가, 답이 없을 때 무슨 일이 생기는가, 그 답을 어디에 둘 것인가.

하나씩 적어본다.

감지하는 것과, 없어도 굴러가게 만드는 것

feature detection 과 graceful degradation.
들어본 사람 있는가? 솔직히 슥 보고 지나갔다가 최근에 다시 보는데
일단 이 단어들을 뜯어 보기로 했다.

앞은 방법이다. 실행 중에 “이거 되나?” 하고 한 번 물어보는 것.
뒤는 그 답이 뭐가 나오든 없으면 없는 대로 굴러가게 만들어두는 걸 말한다.

번역어가 딱히 안 와닿아서 이 글에서는 그냥 감지랑 대비라고 부르겠다.

감지는 if 문 한 줄이면 된다.
대비는 그 if 문의 양쪽 갈래가 같은 약속을 지키게 만드는 일이다.
품이 훨씬 많이 든다.

둘 중 하나만 있는 경우가 생각보다 흔한데, 대부분 그게 버그의 원인이 된다.

감지만 하고 대비를 안 해두면 흔히 보는 “지원하지 않는 브라우저입니다” 화면이 나온다.
감지 결과를 갈래를 나누는 데 쓰지 않고 막는 데 써버린 경우다.

반대로 대비만 있고 감지가 없는 경우도 있는데, 플랫폼이 알아서 무시해줄 때가 그렇다.
바로 다음에 나올 CSS 가 그런 경우다.

둘 다 있으면 제대로 된 옵셔널 기능이 되고,
둘 다 없으면 응답이 안 온 채로 화면이 멈춘다. 뒤에서 얘기할 그 문제다.

웹은 이걸 어떻게 풀었나

웹에서 이걸 제일 잘 푼 건 아마 CSS 의 해석 규칙일 거다.
브라우저가 모르는 선언은 그냥 조용히 버려진다.

.grid { display: flex; /* 모든 브라우저가 이해 — 바닥 */ display: grid; /* 이해하면 덮어씀, 모르면 이 줄이 사라짐 */ }

감지 코드가 한 줄도 없다.
언어 자체가 “모르는 건 버린다” 로 설계돼 있어서 대비가 공짜로 따라온다.
설계 하나로 전 세계 개발자가 안 써도 될 코드를 없애버렸다. 🥹

HTML 도 비슷하다.
모르는 속성은 그냥 무시된다.
img 의 loading="lazy" 를 모르는 브라우저는 그 속성만 건너뛰고 이미지는 멀쩡히 띄운다.
<input type="date"> 도 모르면 그냥 텍스트 입력창이 된다. 달력은 못 띄워도 날짜는 받을 수 있다.

여기서 한 발 더 나간 것도 있는데, <video><canvas> 는 태그 안에 넣어둔 내용이 폴백이 된다.

<video controls src="movie.mp4"> <!-- video 를 아는 브라우저는 이 안쪽을 아예 안 보여준다 --> <a href="movie.mp4">영상 다운로드</a> </video>

<video> 를 아는 브라우저는 영상을 재생하고 안쪽 내용은 감춘다.
모르는 브라우저는 <video> 라는 태그를 무시해버리니까, 남은 자식 요소를 그냥 그린다.
영상은 못 봐도 다운로드 링크는 보게 된다.

요즘 브라우저는 다 <video> 를 아니까 저 안쪽이 실제로 보일 일은 거의 없다.
그래도 폴백을 문법 안에 넣어둔 발상 자체는 꽤 영리하다고 생각한다.

근데 이걸로 안 되는 경우가 있다.
갈래를 나눠야 할 때가 그렇다.
그래서 나중에 대놓고 감지하는 방법이 추가됐다.

/* CSS 안에서 감지하기 */ @supports (display: grid) { .grid { display: grid; } } // JS 는 속성이 있는지 확인한다 if ("serviceWorker" in navigator) { ... } if ("supportedEntryTypes" in PerformanceObserver) { ... }

공유 버튼으로 보면

흔한 예제로 보는 게 빠르겠다.
공유하기 버튼을 만든다고 해보자.

// ✕ 누구인지를 묻는다 if (isIOS() && getIOSVersion() >= 12) { navigator.share({ url }) } else { copyToClipboard(url) }

브라우저 종류랑 버전을 보고 기능을 짐작하는 방식이다.
당장은 잘 돌아간다. 근데 새 브라우저가 나오거나 다른 브라우저가 이 기능을 지원하기 시작하면 바로 어긋난다.

// ✓ 뭘 할 수 있는지를 묻는다 async function share(url) { if (navigator.share) { await navigator.share({ url }) // 네이티브 공유 시트 return } if (navigator.clipboard) { await navigator.clipboard.writeText(url) // 클립보드 복사 toast("링크를 복사했어요") return } showUrlInput(url) // 최후 폴백: 그냥 보여주고 직접 복사하게 }

세 갈래 중 어디로 가든 사용자는 결국 링크를 얻는다.
경험은 아래로 갈수록 나빠지지만 약속은 깨지지 않는다.

이게 대비고, 위의 if 두 개가 감지다.

반대로 아래처럼 짜면 감지는 했는데 대비가 없는 경우가 된다.

// 감지를 "막는 데" 써버린 경우 if (!navigator.share) { return <p>이 브라우저에서는 공유를 지원하지 않습니다</p> }

링크 복사만 시켜주면 되는 일인데 기능을 통째로 꺼버렸다.
감지는 갈래를 나누는 데 쓰는 거지 막는 데 쓰는 게 아니다.

그 유명한 UA 문자열

위 예제의 ✕ 쪽이 흔히 말하는 UA 스니핑이다.
브라우저가 누구인지를 보고 뭘 할 수 있는지를 짐작하는 것.

이 짐작은 반드시 어긋난다.
새 브라우저가 나오고, 버전이 오르고, 기능이 추가되니까.

그 결과가 오늘날의 UA 문자열이다.

Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36

Chrome 이 Mozilla 이자 Safari 이자 KHTML 인 척하고 있다.
다들 누구인지를 보고 기능을 판단하니까, 브라우저들이 서로를 흉내내기 시작한 결과다.
처음부터 뭘 할 수 있는지를 물었으면 안 생겼을 화석이다. 🙃

감지를 어느 층에 둘 것인가

이게 제일 중요한 판단인 것 같다.

감지 코드를 앱 곳곳에 뿌려두면 갈래가 계속 늘어난다.
버튼마다 화면마다 이 브라우저 되나? 를 묻고 있으면 관리가 안 된다.

반대로 가장 아래층에서 한 번 감지하고 흡수해버리면
그 위의 코드는 이런 갈래가 있다는 사실조차 모른다.

위 예제의 share(url) 이 그런 경우다.
버튼을 만드는 쪽은 그냥 share(url) 을 부르면 되고,
이 브라우저가 뭘 지원하는지는 알 필요가 없다.

감지는 이런 갈래를 숨길 수 있는 가장 아래층에 두는 게 좋더라.

아무 말도 없는 콜백

알려주겠다고 해놓고 어떤 상황에서는 안 알려주는 API 가 있다.
받는 쪽에서는 아직 안 온 것인지, 앞으로도 안 올 것인지 구별할 방법이 없다.

끝나지 않는 상태는 에러보다 나쁘다.

에러에는 그래도 처리할 자리가 있다.
catch 가 돌고 finally 가 돌고, 사용자에게 뭐라도 보여줄 수 있다.

근데 끝나지 않는 상태에는 아무것도 없다.
catch 도 안 돌고 finally 도 안 돌고, 화면은 계속 그대로다.
재현하기도 어렵다. 특정 네트워크 상황에서만 나타나니까.

QA 에서는 안 잡히고 운영에서만 터지는 종류다. 개인적으로 제일 싫어한다. 🫠

웹에서 실제로 겪는 것들

찾아보니 웹 플랫폼이 이 문제로 꽤 오래 고생한 기록이 있더라.

fetch() 는 연결이 끊겼는데 살아있는 것처럼 보이면 프로미스가 영원히 안 끝난다.
기본 타임아웃이 아예 없었다.
2022년에 AbortSignal.timeout() 이 나왔다.
프로미스에도 시계가 필요하다는 걸 뒤늦게 인정한 거다.

WebSocket 은 네트워크가 죽어도 소켓이 “열림” 으로 보인다.
OS 타임아웃이 걸릴 때까지 수 분이 걸리기도 한다.
그래서 RFC 6455 에 ping/pong 프레임이 들어가 있다.
열려 보인다고 상대가 살아있다는 뜻은 아니라는 얘기.

beforeunloadunload 는 iOS 사파리에서 백그라운드로 넘어가거나 탭이 정리될 때 안 불린다.
여기에 저장 로직을 넣어뒀다면 통째로 유실된다.
지금은 visibilitychangefreeze/resume 를 쓰는 게 맞다.

img.onerror 는 다른 출처의 이미지일 때 보안상 자세한 이유를 주지 못한다.
게다가 일부 차단 경로에서는 onloadonerror 도 안 온다.
그래서 이미지 로딩은 관행적으로 타임아웃을 항상 같이 건다.

getUserMedia() 는 사용자가 권한 창을 그냥 무시하면 성공도 실패도 아닌 채로 대기한다.
이건 아직 정해진 답이 없어서, 부르는 쪽이 직접 시계를 걸어야 한다.

찾다 보니 답이 다 똑같더라.
기다림에는 시계를 붙인다.
여기까지 오는 데 20년쯤 걸린 거다. 🤔

그리고 제일 흔한 실수

남의 API 얘기만 할 게 아니다. 우리가 직접 만드는 경우도 많다.

new Promise((resolve, reject) => { doThing((err, value) => { if (err) { logger.warn(err) return // ← 여기. 영원히 안 끝난다 } resolve(value) }) })

return 하나 때문에 이 프로미스는 끝나지 않는다.
게다가 로그는 남아서 더 헷갈린다.
에러는 찍혔는데 왜 화면이 안 넘어가지?

이게 리뷰에서도 잘 안 잡힌다.
에러 처리를 안 한 게 아니라 처리는 했는데 끝을 안 낸 거라서, 언뜻 보면 멀쩡해 보인다. 🫠

일단 시계는 붙이고 보자

제일 기본적인 방어다.

function withDeadline<T>( promise: Promise<T>, ms: number, label: string ): Promise<T> { let timer: ReturnType<typeof setTimeout> | undefined const clock = new Promise<never>((_, reject) => { timer = setTimeout( () => reject(new Error(`${label}: ${ms}ms 안에 끝나지 않음`)), ms ) }) return Promise.race([promise, clock]).finally(() => clearTimeout(timer)) } // 이제 최소한 "언젠가는 끝난다" 가 보장된다 const profile = await withDeadline(fetchProfile(userId), 5000, "fetchProfile")

다만 이건 부르는 쪽의 프로미스만 끝내는 거고,
안에서 돌던 작업은 그대로 돌고 있다는 걸 알고 써야 한다.
여기에 재시도까지 붙으면 요청이 두 배로 나간다. 🤡

작업 자체를 취소해야 한다면 그에 맞는 수단을 써야 한다.

// fetch 는 이렇게. 실제로 요청이 취소된다 await fetch(url, { signal: AbortSignal.timeout(5000) })

근데 시계는 임시방편이다

진짜 해결은 이쪽이다.
통신 계층 인터페이스를 만든다고 해보자.

// ✕ 없는 능력을 있는 것처럼 만든다 interface Transport { onDisconnect(cb: () => void): void } class PollingTransport implements Transport { onDisconnect(cb: () => void) { // 폴링 방식이라 끊김을 감지할 방법이 없다. // 일단 인터페이스는 맞춰야 하니 받아만 둔다 } }

일단 받아만 둔다 가 문제의 시작이다.

부르는 쪽은 등록했으니 끊기면 알려주겠지 라고 믿게 된다.
그 잘못된 믿음 때문에 타임아웃조차 걸어두지 않는다.
그리고 영원히 기다린다.

인터페이스 맞추려고 넣은 빈 메서드 하나가, 저 멀리 호출부에서 멈춘 화면으로 돌아온다.

// ✓ 없으면 없다고 말한다 interface Transport { onDisconnect?(cb: () => void): void // 옵셔널 } // 부르는 쪽 if (transport.onDisconnect) { transport.onDisconnect(() => settle("disconnected")) } // 있든 없든 이 시계는 항상 건다 const result = await withDeadline(request, 30_000, "request")

차이는 부르는 쪽이 뭘 믿게 되냐에 있다.

앞의 코드에서는 아직 연결돼 있다고 믿는다. 틀린 믿음이다.
뒤의 코드에서는 이 통신 방식이 감지를 못 한다는 걸 안다. 이건 맞다.

모른다를 괜찮다로 보고하지 말자.
없다고 말하는 건 그래도 정직하다. 아무 말 없는 콜백은 그냥 거짓말이고.

그리고 이게 앞에서 한 얘기랑 이어진다.
onDisconnect 가 있으면 즉시 통보를 받고, 없으면 못 받는다. 이게 감지.
있든 없든 30초 안에는 반드시 끝난다. 이게 대비.
있으면 빨라질 뿐, 약속 자체는 바뀌지 않는다.

테스트로 잡는 방법

아무 일도 일어나지 않는 걸 테스트하기는 애매한데, 방법이 있긴 하다.

it("연결이 끊기면 타임아웃을 기다리지 않고 즉시 끝난다", async () => { useFakeTimers() // 가짜 시계를 세워둔다. 이 테스트에서 1ms 도 움직이지 않는다 const pending = client.request("getUser", { id: 1 }) transport.emitDisconnect() // 끊김 발생 await expect(pending).rejects.toThrow(DisconnectedError) })

가짜 시계를 전혀 움직이지 않고 끝나는지를 본다.
시계를 돌려버리면 타임아웃 때문에 끝난 건지, 제대로 처리돼서 끝난 건지 구별할 수 없다.

있는지 없는지를 어디에 둘 것인가

마지막은 이 기능이 있는지 없는지를 어디에 적어둘 거냐는 문제다.

크게 세 가지고 비용이 다 다르다.
그리고 필요한 것보다 위를 고르면 그만큼 부담으로 남는다.
나도 예전엔 필요도 없는데 위를 고르곤 했다. 뭔가 튼튼해 보이니까. 🤡

그냥 있어도 되고 없어도 된다고 적기

스펙에서 MAY 라고 부르는 것이다.
있어도 되고 없어도 된다고 적어두고 끝. 쓰는 쪽에서 직접 확인해서 판단한다.

있는지 없는지를 그 자리에서 확인할 수 있을 때만 쓸 수 있다.
같은 프로세스 안의 객체 같은 것.
앞에서 본 onDisconnect? 나 CSS 의 @supports, "x" in obj 가 다 여기 해당한다.

interface UploaderOptions { onProgress?: (loaded: number, total: number) => void }

제일 싸다. 문서 한 줄과 물음표 하나면 끝난다.
그 자리에서 확인할 수 있는 거라면 여기서 멈추는 게 맞다.

할 수 있는 걸 목록으로 알려주기

한쪽이 다른 쪽에게 “나는 이런 걸 할 수 있다” 를 목록으로 알려준다.
보통 연결을 맺을 때 한 번 주고받는다.

상대가 다른 프로세스나 다른 기기에 있어서 직접 들여다볼 수 없을 때 쓴다.
HTTP 의 Allow 헤더나 언어 서버가 쓰는 ServerCapabilities 같은 것들이 이 방식이다.

파일 업로드 서버라면 이런 모양이 된다.

GET /upload/capabilities

{ "maxBytes": 104857600, "formats": ["image/jpeg", "image/png", "image/webp"], "resumable": true, "checksums": ["sha256"] }

클라이언트는 이걸 보고 화면을 조정한다.
100MB 넘는 파일은 고르는 단계에서 막고,
resumable 이 false 면 이어올리기 버튼을 아예 안 그리고,
webp 를 못 받으면 보내기 전에 변환한다.

업로드 눌러놓고 한참 기다린 다음에 413(용량 초과) 받는 것보단 낫다.

서로 맞춰가기

양쪽이 각자 할 수 있는 걸 내놓고, 둘 다 되는 걸로 맞추거나 정해진 방식으로 실패한다.
흔히 협상이라고 부른다.

양쪽이 따로따로 배포되고 각자 발전할 때 필요하다.
TLS 에서 암호화 방식을 고르거나, HTTP 에서 압축 방식을 고르는 게 이렇게 돌아간다.

# 클라이언트가 할 수 있는 것들을 내민다 Accept-Encoding: br, gzip # 서버가 하나를 고른다 — 맞춰짐 Content-Encoding: br

버전을 맞추는 거라면 이렇게 된다.

클라이언트: "나는 v2, v1 을 할 수 있다" 서버: "그럼 v1 로 하자" ← 맞춰짐 서버: "나는 v3 만 된다" ← 실패. 426 Upgrade Required

협상이라고 부르려면 맞추는 규칙과 실패했을 때의 처리가 둘 다 있어야 한다.
겹치는 게 하나도 없으면 어떻게 되냐는 질문에 답을 못 하면
그건 협상이 아니라 그냥 운에 맡기는 거다.

그래서 뭘 고를까

순서대로 물어보면 대체로 답이 나오더라.

  • 상대가 뭘 할 수 있는지 그 자리에서 확인할 수 있나?
    확인할 수 있으면 MAY 로 충분하다. 아니면 다음 질문으로.

  • 상대가 다른 프로세스나 다른 기기에 있나?
    그렇다면 목록으로 알려주는 게 필요하다. 아니라면 MAY 로 돌아가면 된다.

  • 양쪽 다 따로 배포되고 각자 발전하나?
    둘 다 그렇다면 협상까지 가야 한다. 한쪽만 그렇다면 목록으로 충분하다.

  • 연결이 유지되는 동안 능력이 바뀌지 않나?
    안 바뀐다면 연결할 때 한 번 주고받고 끝이다.
    바뀔 수 있다면 실행 중에 다시 물어야 하는데, 그 전에 설계를 다시 보는 게 낫다.

마지막 질문이 좀 까다롭다.
연결 도중에 능력이 바뀔 수 있다는 걸 인정하는 순간
한 번으로 끝나지 않고, 이미 받아둔 정보를 언제 버릴지가 새로운 문제가 된다.

그래서 대부분의 프로토콜은 능력이 연결 중에 바뀌지 않는다는 걸 아예 못 박아둔다.

목록이 거짓말을 하기 시작할 때

능력 목록은 손으로 관리하는 순간 반드시 실제 동작과 어긋난다.
시간 문제일 뿐이다.

메서드를 추가하고 목록에 안 넣거나, 지웠는데 목록에는 남아 있거나.
그러면 이도 저도 아니게 된다.
목록을 믿었더니 없고, 안 믿을 거면 목록을 왜 만들었나 싶고. 🤔

// ✕ 나란한 두 목록 — 반드시 어긋난다 const handlers = { "file/upload": handleUpload, "file/delete": handleDelete, } const CAPABILITIES = ["file/upload", "file/delete", "file/move"] // ^^^^^^^^^^^ // 3개월 전에 지운 메서드 // ✓ 실제 처리하는 곳에서 만들어 낸다 const handlers = { "file/upload": handleUpload, "file/delete": handleDelete, } as const const CAPABILITIES = Object.keys(handlers) // 등록하면 자동으로 나타나고, 지우면 자동으로 사라진다

능력 목록은 실제 처리하는 곳에서 만들어 낸다.
따로 관리하는 목록은 두지 않는다.

지금 당장 목록이 필요 없어도 이렇게 해두면 좋은데,
나중에 필요해졌을 때 기준이 되는 곳이 이미 하나 있어서 그냥 내보내기만 하면 되기 때문이다.

정리

쓰다 보니 세 얘기가 결국 같은 말을 하고 있었다.

모르는 것을 모른다고 말할 수 있게 만들자.

  • 아무 말 없는 걸 괜찮은 걸로 넘기고
  • 없는 걸 지원 안 하는 걸로 넘기고
  • 시계도 없이 언젠가 오겠지로 넘기고

모르는 걸 아는 걸로 넘기는 순간, 그게 버그가 된다.

요즘은 인터페이스 하나 만들 때마다 이걸 물어보려고 한다.

이 인터페이스는 "모른다" 를 말할 수 있나?

말할 수 없다면 언젠가 잘못된 신호를 보내게 되더라.
그리고 그 신호는 대부분 멈춰 있는 화면으로 돌아온다. 🥲

비단 개발 작업만이 아니라 인생사에도 똑같이 적용되는 얘기다.
모른다 는 솔직한 답이다.
다들 자기가 모른다는 건 알고 있고, 그걸 메우려고 죽을 때까지 노력하는 게 아닐까.

요즘은 AI 덕분에 이 모른다가 정말 빠르게 해결되고 있다.
근데 착각하면 안 되는 게 있다.
내 모른다를 해결하는 게 중요하지, 그걸 통째로 맡겨버리면 점점 바보가 된다.

그러니 잘 쓰자 AI. 🤡


w.r