← 제작기

지어낸 답보다 빈칸이 낫다

추천하는 앱을 두 개 만들었습니다. 하나는 아이와 함께 갈 숙소를 고르고, 하나는 같은 책의 여러 번역본 가운데 하나를 고릅니다. 만드는 내내 가장 오래 고민한 것은 잘 고르는 방법보다 고를 것이 없을 때 무엇을 할지였습니다.

고를 것이 없는 상황은 드물지 않습니다. 해외 도시를 넣거나 지역 이름에 오타를 내면 그렇게 되고, 찾는 책의 번역본이 하나뿐이거나 전부 절판이어도 그렇습니다. 이럴 때 언어모델은 결과를 비워 두는 법이 없습니다. 그럴듯한 이름을 붙여 존재하지 않는 펜션을 지어냅니다.

경고를 붙이는 방법

처음 떠오른 해법은 경고였습니다. 확인되지 않은 항목에 "실재 여부를 확인하지 못했습니다"라는 배지를 달아 내보내는 것입니다. 정직해 보이고, 결과가 비지 않고, 만들기도 쉽습니다.

이 방식이 실제로 하는 일을 따져 보면, 확인하는 일을 사용자에게 넘기고 그 대신 화면을 채웁니다. 아이와 갈 곳을 찾는 사람이 열 곳 가운데 어느 곳이 진짜인지 하나씩 검색해 봐야 한다면, 그 앱은 일을 덜어 주기는커녕 오히려 늘린 셈입니다. 배지는 잘 읽히지도 않고, 카드가 예쁠수록 더 안 읽힙니다.

반대로 했습니다. 확인되지 않은 것은 표시하는 대신 뺍니다.

원칙: 실재가 확인되지 않은 것은 결과에 넣지 않습니다. 그래서 결과가 비면 빈 채로 둡니다.

빈 결과를 두 단계로 지키기

KidStay 는 네이버 지역 검색으로 실재하는 후보를 먼저 모으고, 모델에게는 그 목록 안에서만 고르게 합니다. 그러면 곧바로 다음 질문이 생깁니다. 그 목록이 비면 어떻게 할 것인가.

첫 번째 단계는 후보를 모으는 쪽에 있습니다.

// 실재 후보가 한 곳도 없으면 후보 한정 모드를 켜면 안 된다.
// (빈 목록에서 고르라는 모순이 된다)
guard !places.isEmpty else { return nil }

빈 목록을 주고 이 안에서만 고르라고 하면 모델은 둘 중 하나를 합니다. 규칙을 어기고 지어내거나, 규칙을 지키느라 아무것도 내놓지 못합니다. 어느 쪽도 원하는 결과가 아니어서 그런 지시는 아예 만들지 않습니다.

이 단계만으로는 부족했습니다. 후보 한정 모드가 꺼진 채로 모델을 부르면 모델은 아는 대로 지어내기 때문에, 두 번째 단계가 필요했습니다.

// 엄격 모드: 네이버가 켜져 있는데 실재 후보가 한 곳도 없으면(해외 도시, 오타 등)
// AI를 부르지 않고 바로 안내한다. 실재가 보장되지 않는 추천은 하지 않는다.
if NaverSearchClient.isConfigured && research == nil {
    recommendations = []
    errorMessage = "…실재하는 숙소를 찾지 못했습니다…"
    return
}

이 조건이 research == nil 하나로 끝나지 않고 isConfigured 와 함께 걸려 있는 데는 이유가 있습니다. 검색을 연결해 두지 않은 사람에게는 처음부터 확인할 수단이 없으니, 그 사람의 빈 결과는 "찾지 못했다"가 아니라 "찾아본 적이 없다"입니다. 똑같은 nil 이어도 뜻이 다릅니다. 멈출 이유가 되는 것은 확인할 수 있었는데 확인되지 않은 경우뿐입니다.

결과가 비었을 때 할 말

결과를 비우기로 했다면, 남는 일은 그 빈 화면이 무엇을 말하게 할지입니다. "결과가 없습니다"는 가장 나쁜 답입니다. 사용자는 앱이 고장 났는지 자기가 잘못 입력했는지 알 수 없습니다.

빈 결과 안내에는 세 가지를 넣었습니다. 무엇이 없었는지, 왜 그럴 수 있는지, 지금 무엇을 해 보면 되는지입니다. 지역 이름이 국내가 맞는지 먼저 확인하게 하고, 국내가 맞다면 검색 연결이 끊겼을 수 있으니 설정에서 연결 테스트를 해 보라고 안내합니다. 같은 빈 화면이라도 다음에 할 일이 적혀 있으면 다르게 읽힙니다.

옮김도 같은 자리에서 같은 선택을 했다

번역본을 고르는 앱에서는 모양이 조금 다릅니다. 여기서 없는 것은 지금 살 수 없는 책, 곧 절판됐거나 품절된 판본입니다.

번역본을 고르다 보면 흔히 겪는 일입니다. 평이 가장 좋은 역자의 판본이 이미 절판된 경우가 많습니다. 그 판본을 1위에 올리면 정보로는 맞지만 쓸모로는 틀립니다. 사려고 들어온 사람에게 살 수 없는 책을 1위로 보여 주는 셈이어서, 지금 살 수 있는 판본만 남기고 순위를 매깁니다.

두 앱이 다루는 "없음"은 다릅니다. 하나는 존재하지 않는 것이고, 하나는 존재하지만 손에 넣을 수 없는 것입니다. 그래도 내린 결정은 같았습니다. 화면을 채우려고 쓸 수 없는 것을 넣지 않습니다.

세 번째 자리, 게시판

이 글을 쓰기 직전에 이 사이트 게시판에 공지 기능을 붙였습니다. 공지는 모든 페이지 맨 위에 고정되고, 다섯 개까지만 올릴 수 있습니다.

여섯 번째를 올리려고 하면 어떻게 할 것인가. 쉬운 방법은 목록을 그릴 때 앞의 다섯 개만 잘라 보여 주는 것입니다. 코드 한 줄이면 되고, 오류를 보는 사람도 없습니다.

그러면 여섯 번째를 올린 사람은 올렸는데 보이지 않는 상황을 겪습니다. 왜 안 보이는지 알 방법이 없고, 다시 올려도 마찬가지입니다. 조용한 대신 아무것도 알 수 없습니다.

목록에서 자르지 않고 올리는 자리에서 막았고, 여기에도 같은 규칙을 적용해 몇 개까지 되는지와 무엇을 하면 되는지를 함께 알려 줍니다. "공지는 5개까지 올릴 수 있습니다. 올라와 있는 공지를 하나 내리고 다시 시도해주세요."

한 걸음 더 나갔습니다. 자리가 다 찼으면 글을 쓰기 전에 공지 체크박스를 잠급니다. 그러지 않으면 긴 공지를 다 쓴 뒤에야 거절당하는데, 자리를 비우려고 목록에서 공지를 펼치는 순간 쓰던 글이 사라집니다. 막는 시점이 늦으면 막는 일 자체가 손해가 됩니다.


정리하면

  • 언어모델은 결과를 비워 두지 않습니다. 빈 결과가 필요하면 모델을 부르지 않을 조건을 사람이 코드로 정해야 합니다. 프롬프트에 "모르면 모른다고 해"라고 부탁하는 것과는 다른 일입니다.
  • 확인되지 않은 것에 경고를 붙이면 정직해 보이지만, 실제로는 확인하는 일을 사용자에게 넘기고 화면을 채우는 것입니다.
  • 같은 nil 이라도 "확인했는데 없다"와 "확인한 적이 없다"는 다릅니다. 멈출 이유가 되는 것은 앞의 경우뿐입니다.
  • 빈 결과에는 세 가지를 담습니다. 무엇이 없었는지, 왜 그럴 수 있는지, 지금 무엇을 해 보면 되는지입니다.
  • 넘칠 때는 목록에서 조용히 잘라 내지 말고 넣는 자리에서 막습니다. 막는다는 사실은 사용자가 일을 시작하기 전에 보여야 합니다.

I built two apps that recommend things. One picks a place to stay with a small child; the other picks which Korean translation of a book to read. The interesting part was never the picking. It was what happens when there is nothing to pick.

That case is not rare. Someone types a foreign city, or misspells a region, or the book has exactly one translation, or every edition is out of print. It happens constantly. And in that moment a language model never comes back empty-handed. It invents a plausibly named guesthouse that does not exist.

The warning-label route

The first idea was a warning: ship the unverified results with a badge saying "we could not confirm this place exists." It looks honest, it keeps the screen full, and it is easy to build.

But look at what it actually does. It hands the verification work to the user and takes a full screen in exchange. If someone looking for a place to take their child has to search each of ten results to find out which are real, the app added work instead of removing it. And badges go unread — the prettier the card, the less they are read.

So I went the other way. Anything unconfirmed is dropped, not flagged.

Principle: nothing unconfirmed goes into the results — and if that leaves the results empty, they stay empty.

Two layers of holding the empty hand

KidStay gathers real candidates from Naver local search first, and the model may only choose from that list. Which raises the obvious question: what if the list is empty?

The first layer sits where candidates are gathered.

// With no real candidate at all, candidates-only mode must not be turned on.
// (It would mean asking the model to choose from an empty list.)
guard !places.isEmpty else { return nil }

Hand a model an empty list and say "choose only from this," and it does one of two things: break the rule and invent, or keep the rule and return nothing. Neither is what you wanted. So that instruction is never constructed in the first place.

That layer alone was not enough. With candidates-only mode simply off, the model gets called anyway and invents from memory. A second layer was needed.

// Strict mode: if Naver is on and confirms nothing (foreign city, typo…),
// do not call the AI at all — say so instead. No recommendation without existence.
if NaverSearchClient.isConfigured && research == nil {
    recommendations = []
    errorMessage = "…could not find a real place in ‘…’…"
    return
}

It matters that the condition is not research == nil alone but paired with isConfigured. Someone who never connected search has no means of confirming anything, so their empty result means "never looked," not "looked and found nothing." Same nil, different meaning. Only could-have-been-confirmed-but-wasn't is grounds for stopping.

What the empty screen says

Once you decide to return nothing, the remaining work is what that nothing says. "No results" is the worst option — the user cannot tell whether the app is broken or their input was.

So the empty state carries three things: what was missing, why that might be, and what to try now. Check that the region is a domestic one; if it is, the search connection may be down, so run the connection test in settings. Same empty screen, but one with a next move in it.

Omgim made the same call in the same spot

In the translation app the shape differs. Here "not there" is not an invented book but a book you cannot buy right now — out of print, or out of stock.

Among translations this is common; the best-reviewed translator's edition is frequently the one that went out of print. Rank it first and you are correct as information and wrong as use — you just put an unbuyable book at the top for someone who came to buy one. So only what is purchasable now gets ranked.

The two apps handle different kinds of absence — one that does not exist, one that exists but is out of reach. The decision is the same: do not fill the screen with what cannot be used.

A third place — the board

Just before writing this I added notices to this site's board. A notice pins to the top of every page, and there is room for five.

What should the sixth do? The easy route is to render only the first five. One line of code, nobody ever sees an error.

Except that from the sixth poster's side, they posted and it is not there. There is no way to learn why, and posting again changes nothing. Quiet, and unknowable.

So nothing is truncated; it is refused at the point of posting — and by the same rule as above, the refusal says both the limit and the next move: "Notices are limited to five. Unpin one and try again."

One step further: when the slots are full the checkbox is disabled before you write. Otherwise you finish a long notice, get refused, and then discover that opening a pinned post to free a slot discards the draft. A block that arrives too late becomes a cost of its own.


In short

  • A language model never returns empty-handed. If you want an empty hand, a person has to write the condition for not calling it. That is a different job from asking it in a prompt to "say so if you don't know."
  • Flagging the unconfirmed looks honest, but it hands verification to the user and fills the screen in exchange.
  • The same nil can mean "confirmed absent" or "never checked." Only the first is grounds to stop.
  • An empty result carries three things: what was missing, why that might be, and what to try now.
  • When something overflows, refuse it where it goes in rather than truncating the list — and show that refusal before the user starts the work.