카톡으로 받은 스크린샷 3장이 서비스 전역의 숨은 버그를 찾아줬다
시작은 카톡 스크린샷 3장이었다
실제 폰(삼성 브라우저)으로 서비스를 쓰다가 겪은 문제를 카카오톡으로 스크린샷과 함께 받았다. 요청은 크게 네 가지였다.
- 고객센터 챗봇 버튼이 "이력서 확인" 버튼과 겹친다
- 모바일에 페이지를 이동할 햄버거 메뉴가 없다
- 대부분 유저가 모바일로 처음 들어오니, 첫 화면에 해외 공고 탐색 화면이 바로 보여야 한다
- 이력서 작성 후 "AI로 다시 작성" 버튼을 눌렀더니 "AI 응답을 해석하지 못했어요. 다시 시도해주세요" 에러가 뜬다
앞의 세 개는 딱 봐도 UI 레이아웃·라우팅 문제다. 네 번째가 흥미로웠다 — 재현해보니 입력값과 무관하게 100% 실패하는 버그였고, 파고들자 서비스 전역에 퍼져 있던 잠재적 지뢰였다.
문제 1 — 챗봇 버튼이 다른 버튼과 겹침
고객센터 챗봇은 화면 우하단에 고정된 플로팅 버튼이다. 데스크톱에선 여유 공간이 있어 괜찮은데, 모바일 화면 폭에서는 워크스페이스의 "제출 서류" 버튼 같은 다른 하단 요소와 자리를 다퉜다.
요구사항을 정리하면 이렇다 — PC에서는 모든 페이지에서 계속 보이고, 모바일에서는 고객센터(/support) 페이지에서만 보이면 된다. Next.js의 usePathname으로 현재 경로를 읽어서 조건부 클래스를 적용했다.
const pathname = usePathname()
// 모바일에서는 겹치므로 /support 에서만, PC(sm 이상)는 항상 노출
const mobileVisible = pathname?.startsWith('/support')
const visibility = mobileVisible ? 'flex' : 'hidden sm:flex'
<button className={`fixed bottom-5 right-5 ${visibility} ...`}>
hidden sm:flex가 핵심이다. 기본은 숨김(hidden)이고, 화면이 sm 브레이크포인트 이상이면 다시 보이게(sm:flex) 한다. /support일 때만 flex를 강제로 켜서 모바일에서도 보이게 만든다.
문제 2 — 모바일에 페이지 이동 수단이 없음
기존 구조를 보니, 데스크톱은 좌측 사이드바로 페이지를 이동하는데 모바일에서는 그 사이드바가 통째로 숨겨지고(lg:hidden) 로고만 남아 있었다. 즉 모바일 유저는 로그인한 뒤 다른 페이지로 갈 방법이 로고를 눌러 대시보드로 돌아가는 것 말고는 없었다.
새 컴포넌트(MobileNav)를 만들어 모바일 헤더에 햄버거 버튼을 추가했다. 클릭하면 대시보드·지원 현황·잡 탐색·내 이력서·설정·요금제·고객센터로 가는 드롭다운이 열린다.
const NAV_ITEMS = [
{ key: 'dashboard', label: '대시보드', href: '/dashboard' },
{ key: 'applications', label: '지원 현황', href: '/applications' },
{ key: 'discover', label: '잡 탐색', href: '/discover' },
// ...
] as const
{open && (
<>
<div className="fixed inset-0 z-40 bg-black/25" onClick={() => setOpen(false)} />
<nav className="fixed left-0 right-0 top-[60px] z-50 ...">
{NAV_ITEMS.map(({ key, label, href }) => (
<Link key={key} href={href} onClick={() => setOpen(false)} ...>{label}</Link>
))}
</nav>
</>
)}
배경(bg-black/25)을 깔고 그 배경을 클릭하면 메뉴가 닫히게 했다. 모바일에서 메뉴를 열었는데 닫을 방법이 명확하지 않으면 그 자체로 또 다른 불편이 되기 때문이다.
문제 3 — 첫 화면이 빈 대시보드였다
기존엔 로그인하면 통계 카드 몇 개가 전부인 빈 대시보드로 이동했다. 신규 유저 입장에서 "그래서 나는 지금 뭘 해야 하지?"라는 물음에 답을 못 주는 화면이다. 요청대로 로그인·온보딩 완료 직후 목적지를 잡 탐색(/discover) 페이지로 바꿨다 — 여기엔 해외 기업 채용공고와 매칭 점수가 바로 보인다.
이 목적지를 정하는 곳이 한 곳이 아니었다. grep으로 찾아보니 네 지점이 전부 일관되게 바뀌어야 했다.
| 위치 | 역할 |
|---|---|
middleware.ts | 이미 로그인한 유저가 /login에 들어오면 리다이렉트 |
auth/callback/route.ts | OAuth(구글 로그인) 완료 후 기본 목적지 |
login/LoginForm.tsx | 이메일 로그인 폼 제출 성공 후 이동 |
onboarding/page.tsx, OnboardingChat.tsx | 온보딩 완료 후 이동 |
로그인 경로가 이메일·구글 두 가지고, 신규/기존 유저 분기까지 있다 보니 "로그인 성공 후 어디로 보낼지"를 정하는 코드가 자연스럽게 여러 곳에 흩어져 있었다. 하나라도 놓치면 "구글로 로그인했더니 대시보드로 가고 이메일로 로그인했더니 잡 탐색으로 가는" 식의 일관성 없는 경험이 생긴다.
문제 4 — "AI 응답을 해석하지 못했어요" (진짜 문제)
재현해보니 사회초년생 이력서(경력 없이 스킬만 3개)뿐 아니라, 입력값과 상관없이 이 버튼을 누르면 항상 실패했다. 실제 API 호출을 직접 재현해서 응답을 뜯어봤다.
const msg = await client.messages.create({
model: 'claude-sonnet-5',
max_tokens: 4000,
messages: [{ role: 'user', content: prompt }],
})
console.log(msg.content.map(b => b.type))
// → [ 'thinking', 'text' ]
원인을 찾았다. 이 서비스가 쓰는 모델은 답변 전에 내부적으로 사고 과정을 거치는 adaptive thinking이 켜져 있는데, 이 경우 응답 content 배열의 **첫 번째 블록이 text가 아니라 thinking**이다. 그런데 코드는 이렇게 돼 있었다.
// 문제의 코드 — 배열 0번째만 확인
const summary = message.content[0].type === 'text'
? message.content[0].text.trim()
: ''
content[0]이 thinking 블록이니 .type === 'text'는 항상 false이고, 결과는 항상 빈 문자열이었다. "AI로 다시 작성"을 눌러도, 입력을 뭘로 바꿔도 결과가 똑같이 실패했던 이유다.
더 걱정됐던 건, 이 패턴이 이 기능 하나만의 문제가 아니었다는 점이다. grep으로 뒤져보니 커버레터 생성, 이력서 번역, AI 매칭 채점, 온보딩 파싱, 채용공고 스크래핑 결과 채점까지 동일한 패턴이 20군데에서 쓰이고 있었다. 지금 당장 에러가 안 난 곳들도, 나중에 그 API 호출에 thinking 옵션이 켜지는 순간 조용히 빈 문자열을 반환하기 시작할 잠재 지뢰였다.
수정 — 공통 헬퍼로 한 번에 교체
content 배열에서 타입이 text인 블록을 찾는 헬퍼 함수를 하나 만들었다.
// src/lib/claude.ts
export function textOf(message: { content: { type: string; text?: string }[] }): string {
const block = message.content.find(b => b.type === 'text')
return typeof block?.text === 'string' ? block.text.trim() : ''
}
배열 인덱스를 믿지 않고 .find()로 타입을 직접 찾는 게 핵심이다. thinking 블록이 몇 개가 앞에 오든, text 타입인 블록을 정확히 찾아낸다. 이 함수 하나로 20곳의 위험한 패턴을 전부 교체했다.
// Before
const text = message.content[0].type === 'text' ? message.content[0].text : ''
// After
const text = textOf(message)
이렇게 배포하면 끝일 텐데, 확실히 하려고 재현했던 실패 케이스를 그대로 다시 돌려 성공을 확인했다.
사회초년생을 위한 프롬프트도 함께 개선
버그를 고친 김에, 애초에 받은 요구사항도 반영했다. 경력이 거의 없는 사회초년생이 "AI로 다시 작성"을 눌렀을 때, 있는 재료(스킬·개인 프로젝트)만으로도 전문적이고 자신감 있는 요약을 만들어주는 규칙을 추가했다.
5. **경력이 없거나 1~2줄뿐인 신입/사회초년생**: 스킬·학력·기존 요약(개인 프로젝트 포함)을
재료로 summary를 자신감 있고 풍성하게(3~4문장) 써주세요. 기술에 대한 이해도,
스스로 만들어본 경험, 학습 속도와 성장 의지를 전문적인 어휘로 표현하되,
가짜 경력·수치·회사명은 만들지 마세요.
실제로 이런 한 줄짜리 입력이
혼자서 아이폰 앱을 개발해서 10000외 다운로드 있음.
이렇게 바뀌었다.
iOS 앱을 기획부터 개발, 배포까지 독립적으로 수행하여 1만 다운로드 이상을 달성한 경험을 보유한 개발자입니다. Swift를 활용한 네이티브 iOS 앱 개발 역량과 더불어 React를 통한 프론트엔드 개발 이해도를 함께 갖추고 있어... 문제 해결 능력과 자기주도적 학습 역량을 키워왔습니다.
숫자나 회사명을 새로 지어내지 않으면서도, 원본에 있던 사실(iOS 앱 개발, 1만 다운로드, React·Swift 스킬)을 훨씬 전문적인 어휘로 풀어썼다. "부풀린다"는 표현이 자칫 거짓말을 허용하는 것처럼 들릴 수 있는데, 실제로 필요한 건 거짓말이 아니라 같은 사실을 더 잘 표현하는 것이었다.
모바일 뷰포트로 실제 검증
수정한 걸 눈으로만 확인하지 않고, 헤드리스 브라우저에 모바일 뷰포트를 줘서 실제로 로그인부터 다시 돌려봤다.
const ctx = await browser.newContext({
viewport: { width: 390, height: 844 },
isMobile: true,
hasTouch: true,
})
로그인 → /discover 도착 확인, 챗봇 버튼이 /discover에선 숨겨지고 /support에선 보이는지 확인, 햄버거 메뉴를 열어 "내 이력서"를 눌러 실제로 /profile로 이동하는지까지 전부 스크린샷으로 남기며 검증했다.
자주 쓰는 패턴 요약
| 목적 | 방법 |
|---|---|
| 모바일에서만 요소 숨기기 | Tailwind hidden sm:flex 조합 |
| 현재 경로에 따라 UI 분기 | Next.js usePathname() |
| AI 응답에서 텍스트만 안전하게 추출 | content[0] 대신 content.find(b => b.type === 'text') |
| 로그인 후 이동 경로처럼 여러 곳에 흩어진 로직 확인 | grep으로 관련 리다이렉트/이동 지점 전수 조사 |
| 모바일 UI 회귀 검증 | 헤드리스 브라우저에 isMobile: true 뷰포트 지정 |
트러블슈팅
Q. content[0].type === 'text' 같은 코드가 왜 지금까지 문제가 안 됐나?
A. thinking 옵션이 꺼진 모델을 쓰던 기능들은 원래 content[0]이 바로 text 블록이라 우연히 잘 동작했다. 문제는 나중에 더 똑똑한 모델로 바꾸거나 thinking을 켜는 순간 아무 경고 없이 조용히 깨진다는 것 — 에러가 나는 게 아니라 빈 문자열을 반환하기 때문에 원인 파악이 더 까다롭다.
Q. 이런 걸 미리 방지하려면? A. API 응답을 다루는 공통 유틸을 처음부터 하나 만들어두고, 배열 인덱스로 직접 접근하는 코드를 코드리뷰에서 걸러내는 습관이 필요하다. "새 기능을 만들 때마다 기존 코드를 복사-붙여넣기" 하는 습관이 같은 버그를 계속 퍼뜨린 원인이기도 했다.
정리
카톡 스크린샷 3장 → UI 버그 2개(챗봇 겹침, 메뉴 부재)는 바로 수정
→ 첫 화면 리다이렉트 4곳 일관되게 변경
→ "AI로 다시 작성" 재현 → content[0] 인덱스 접근이 thinking 블록에 걸림을 발견
→ grep으로 동일 패턴 20곳 전수 조사 → textOf() 헬퍼로 통일 교체
→ 프롬프트에 신입 전용 규칙 추가 → 모바일 뷰포트로 전체 재검증
작은 스크린샷 하나가 "챗봇 버튼 위치 좀 옮겨주세요" 수준의 요청처럼 보였지만, 따라가다 보니 서비스의 AI 기능 20곳에 퍼져 있던 근본 버그로 이어졌다. 사용자가 겪는 사소한 불편 리포트를 "재현이 되는가"까지 끝까지 따라가는 태도가, 결국 눈에 안 보이던 더 큰 문제를 찾아내는 길이라는 걸 다시 느낀 하루였다.
backtodev
40대 PM, 다시 개발자로 돌아갑니다. 실패하고 배우며 성장하는 기록.