앱에서 제일 터지면 안 되는 순간이 언제일까요? 저는 사용자가 막 결제를 끝낸 직후라고 생각해요. 돈을 냈는데 결과물이 안 나오면, 그건 단순한 버그가 아니라 신뢰가 깨지는 사고니까요. 그런데 제 안드로이드 앱이 딱 거기서 터졌습니다. 프리미엄 결제 직후 이름 10개 생성을 눌렀더니, 잠시 뒤 폼에 빨간 글씨로 timeout이 떴어요. 등골이 서늘했죠.
더 얄궂었던 건, 무료 기능은 멀쩡했다는 거예요. 무료로 3개 뽑을 땐 잘만 되던 게, 돈 낸 뒤 10개를 뽑을 때만 실패했습니다. 초보 바이브코더가 “왜 여기서만?”에 빠지기 딱 좋은 함정이라, 원인과 해결을 정리합니다. 알고 보니 네트워크 문제가 아니라 제가 한 번에 너무 많이 만들려던 구조 문제였어요.
증상 — 무료는 되는데 유료만 timeout
먼저 화면에 뜬 에러부터요. 사용자에게 이런 날것의 문구가 그대로 보였습니다.
timeout
이건 제가 만든 메시지가 아니라, 네트워크 라이브러리가 뱉은 원문이에요. NamingViewModel(화면 상태를 관리하는 코드)이 에러의 message를 가공 없이 그대로 사용자에게 보여준 탓이죠. 사용자 입장에선 “timeout이 뭐야?”일 뿐이고요.
핵심 단서는 무료 3개는 되고 유료 10개는 안 된다는 비대칭이었어요. 같은 생성 기능인데 개수만 다른데 한쪽만 터진다면, 원인은 대개 “양”에 있습니다.
왜 유료만 터졌나 — 한 응답에 다 욱여넣었다
원인은 이 세 가지가 겹친 구조였어요.
- 생성은 AI 모델을 논스트리밍 단일 호출로 부릅니다. 즉 “다 만들어질 때까지 기다렸다가 한 번에” 받아요.
- 그 호출의 읽기 제한 시간(
readTimeout)은 60초로 걸려 있었고요. - 그런데 프리미엄 응답 하나에 이름 10개 + 각 이름마다 축복의 편지 10통을 전부 담고 있었습니다.
timeout(타임아웃): 정해둔 시간 안에 응답이 안 오면 실패로 끊는 것. 여기선
readTimeout60초.
논스트리밍: 결과를 조금씩 나눠 받지 않고, 다 완성될 때까지 기다렸다가 한 덩어리로 받는 방식.
편지 10통을 한 응답에 담아 만들려면 60초를 넘기는 게 이상한 일이 아니에요. 무료 3개가 멀쩡했던 이유도 정확히 같은 논리로 설명됩니다 — 양이 적어서 60초 안에 끝났던 거죠. 채점으로 치면, 한 문제에 조건을 열 개씩 욱여넣으면 제한 시간 안에 못 푸는 것과 같아요. (오늘의 수학쌤 비유는 여기까지.)
그리고 하필 이 실패가 결제 직후에 터진다는 게 최악이었습니다. 실패 비용이 가장 비싼 자리였으니까요.
돈 낸 직후 실패는 특별 취급: 다행히 유료 자격(엔타이틀먼트)은 보존돼 있어서, 재결제 없이 다시 시도하면 성공하는 것까지 확인했어요. 즉 돈이 새는 사고는 아니었습니다. 하지만 사용자가 “재시도하면 된다”는 걸 알아야 회복되니, 이건 여전히 UX 문제였어요. 결제 직후 경로는 실패해도 사용자가 손해 보지 않게 설계돼 있어야 합니다.
timeout이 진짜 말하는 것
여기서 배운 게 있어요. timeout이라고 뜨면 반사적으로 “네트워크가 느리네” 하고 재시도나 시간 제한만 늘리기 쉬운데, 진짜 원인은 대개 한 번의 호출에 일을 너무 많이 담은 구조입니다. 이 경우엔 시간 제한만 늘려서는 편지 10통을 한 응답에 몰아 담는 구조가 그대로 남아요. 그래서 저는 시간을 늘리기보다 먼저, 일을 쪼개기로 했습니다. (시간 제한을 끝까지 안 건드린 건 아니에요 — 그 얘기는 맨 끝 후일담에 적어둘게요.)
어떻게 고쳤나 — 편지를 나중에 만들기(지연 생성)
결정적 힌트는 UI에 있었어요. 축복의 편지는 목록에 바로 안 보입니다. 사용자가 “축복의 편지 보기”를 눌러 카드를 펼쳐야 나와요. 그렇다면 편지 10통을 처음 생성 때 미리 다 만들 이유가 없었던 거예요. 대부분 읽히지도 않는데 말이죠.
그래서 크리티컬 패스(사용자가 결과를 받기까지 꼭 거쳐야 하는 최단 경로)에서 편지를 빼고, 펼칠 때 한 통씩 만들게 바꿨습니다.
지연 생성(lazy): 필요해지는 순간까지 만들기를 미루는 방식. 안 쓰일 수도 있는 무거운 작업을 앞당겨 하지 않는다.

| 변경 전 | 변경 후 | |
|---|---|---|
| 목록 생성 응답 | 이름 10 + 편지 10 | 이름 10만 |
| 편지 | 선불로 다 생성(대부분 안 읽힘) | 펼칠 때 1통씩 |
| 편지 실패 시 | 응답 전체가 timeout | 그 카드 하나만 재시도 |
구체적으로는 이름 하나치 편지만 만드는 generateLetter(nameId)를 따로 뒀어요. 이미 편지가 있으면 API를 다시 부르지 않고, 생성 중·실패·완성 세 상태를 이름 단위로 관리합니다(lettersInFlight, letterErrors, retryLetter). 덕분에 한 편지가 실패해도 다른 이름은 멀쩡하고, 실패한 카드만 다시 시도하면 돼요. 목록 생성이 짧아지니 timeout도 자연히 사라졌고요.
그래서 뭘 하면 되나
“한 번에 다 만들다 느려지는” 상황을 만나면, 이 순서로 점검해보세요.
| 신호 | 판단 | 액션 |
|---|---|---|
| 양이 많은 요청만 timeout | 🔴 시간 아닌 구조 문제 | 시간 제한부터 늘리지 말고 일을 먼저 쪼개기 |
| 결과 일부가 당장 안 보임 | 🟢 지연 생성 후보 | 크리티컬 패스에서 빼고 필요할 때 생성 |
| 한 항목 실패가 전체를 죽임 | 🟡 실패 격리 필요 | 항목 단위 상태·재시도로 분리 |
| 결제 직후 실패 | 🔴 비용 최고 지점 | 자격 보존 + 재시도로 손해 없게 |
한 문장 요약: timeout은 대개 네트워크가 아니라 한 호출에 너무 많이 담은 구조의 신호다 — 당장 안 쓰이는 무거운 산출물은 필요할 때 만들도록 미루고, 실패는 항목 단위로 격리하라.
돌아보면 이건 “빠르게 만드는 법”이 아니라 “언제 만들지 정하는 법“에 대한 교훈이었어요. 다 미리 만들어두는 게 친절해 보였는데, 실은 대부분 읽히지도 않을 편지 때문에 정작 중요한 이름 목록까지 못 받게 만들고 있었죠.
후일담 — 결국 시간 제한도 늘렸습니다. 위에서 “시간 제한만 늘려서는 안 된다”고 했는데, 8월 초에 AI가 실제로 readTimeout을 60초에서 92초로 올렸어요. 이번엔 원인이 달랐습니다. 실기기에서 이름 10개 응답이 JSON의 닫는 괄호 }를 빠뜨린 채 끝나 End of input으로 죽고 있었거든요. 실패율이 무거운 입력 43%, 가벼운 입력 80%였어요. AI가 응답 모양을 강제하는 responseSchema를 걸고 재시도 1회를 넣어 고쳤는데, 스키마를 걸면 timeout이 다시 날 수 있어서 시간 제한도 함께 늘린 거예요(PR #13). 그 뒤 누적 22회 확인에서 파싱 실패 0건, 다음 검증 15회에서도 잘림 없이 15/15였습니다. 쪼개기가 먼저였고, 시간 제한은 그다음에 필요한 만큼만 늘린 셈이에요.
두 번의 수리를 나란히 놓으면 이렇습니다.
| 7월 26일 PR #11 | 8월 6일 PR #13 | |
|---|---|---|
| 증상 | 결제 직후 10선 생성이 timeout (무료 3선은 정상) | 실기기에서 10선이 End of input으로 실패 |
| 얼마나 | 프리미엄 10선만 (비율 기록 없음) | 무거운 입력 43%, 가벼운 입력 80% 실패 |
| 원인 | 한 응답에 이름 10개 + 편지 10통, readTimeout 60초 | 모델이 닫는 }를 빠뜨린 채 STOP으로 응답을 끝냄 |
| 고친 것 | 편지는 펼칠 때 한 통씩, 목록 응답은 이름 10개만 | responseSchema + 등급(tier)별 분기 + 재시도 1회 + readTimeout 60→92초 |
| 확인 | 기록 없음 | 누적 22회 확인에서 파싱 실패 0 |
8월 7일에 AI가 작업 기록(워크로그)에 적은 배움은 한 줄이었어요. «제약 디코딩이 프롬프트를 이긴다 / 스키마가 타임아웃 회귀를 데려온다» 응답 모양은 프롬프트로 부탁하기보다 스키마로 강제하는 쪽이 확실했지만, 그 스키마가 timeout을 다시 불러올 수 있다는 뜻이에요. 위 후일담에서 AI가 시간 제한을 92초로 함께 늘린 게 바로 이 두 번째 절반 때문이었고요.
꼭 필요한 걸 먼저, 나머지는 필요할 때 — 이 순서만 지켜도 무거운 화면이 한결 가벼워집니다.