Code › tail-villain

로드맵 분석 상태가 멈춰 보였던 이유

백엔드에서는 완료된 분석이 화면에서는 계속 진행 중으로 보였던 폴링 문제

백엔드 로그에는 완료가 찍혀 있었다.

로드맵 분석은 끝났고, 데이터도 저장됐다. 그런데 화면은 여전히 running 상태였다. 사용자는 기다리고 있었고, 나는 이미 끝난 작업을 화면이 왜 모르는지 보고 있었다.

이런 버그는 묘하게 사람을 헷갈리게 만든다. AI 분석이 느린 건지, Gemini 호출이 실패한 건지, 데이터베이스 저장이 안 된 건지, 프론트가 상태를 못 읽는 건지 한 번에 보이지 않는다. 겉으로는 그냥 “분석 중”이라는 문구 하나만 남아 있으니까.

그날의 작업은 결국 이 간극을 줄이는 일이었다. 백엔드가 알고 있는 사실과 사용자가 보는 화면 사이의 간극.


이전 작업에서 로드맵 생성 흐름을 바꿨다.

원래는 사용자가 버튼을 누르면 Gemini 분석이 끝날 때까지 기다려야 했다. 30초 가까이 걸릴 수 있는 작업을 요청 하나에 묶어두는 구조였고, 기술적으로는 동작해도 제품 경험으로는 좋지 않았다. 그래서 로드맵은 먼저 만들고, 분석은 백그라운드에서 돌리도록 바꿨다.

그 선택 자체는 맞았다. 사용자는 빈 화면에서 오래 기다리지 않고, 로드맵 상세 화면으로 먼저 이동할 수 있다. 분석이 끝나면 결과가 붙고, 그 사이에는 진행 중 상태를 보여주면 된다.

문제는 그 “보여주면 된다”가 생각보다 쉽지 않다는 데 있었다.

비동기 작업은 서버에서 끝나는 순간 끝난 게 아니다. 적어도 사용자 경험에서는 그렇다. 서버가 완료를 저장했고, 데이터베이스에 값이 있고, 로그에도 정상 처리라고 찍혀 있어도 화면이 그 사실을 다시 읽지 않으면 사용자는 계속 기다린다.

그날 화면이 딱 그랬다.


처음에는 Gemini 쪽을 의심했다.

실제로 Gemini는 가끔 바쁘다. 요청이 몰리면 timeout이 나거나, 잠시 후 다시 시도하라는 식의 오류를 돌려준다. 로드맵 분석은 JD와 이력서를 읽고 구조화된 결과를 만들어야 하니, 짧은 호출도 아니었다.

그래서 분석 작업에는 retry와 backoff를 넣었다. 일시적인 timeout, rate limit, high demand 계열 오류는 바로 실패로 확정하지 않고 잠깐 기다렸다가 다시 시도하게 했다. 토픽 생성에는 이미 더 긴 timeout을 쓰고 있었는데, 로드맵 분석은 여전히 짧은 제한에 묶여 있던 점도 정리했다.

하지만 이건 화면이 계속 running으로 남는 문제의 본질은 아니었다. 필요했던 안정화는 맞지만, 그 버그를 설명하지는 못했다.

로그를 보면 분석은 끝나 있었다. 백엔드는 할 일을 했다. 그렇다면 남은 건 화면이었다.


상세 페이지의 폴링 로직을 보니 문제가 보였다.

의도는 단순했다. 분석 상태가 running이면 잠시 뒤 다시 가져오고, 완료되면 화면을 갱신한다. 그런데 실제 구현은 계속 반복되는 폴링이 아니라, 한 번만 다시 가져오는 timeout에 가까웠다. 의존성이 안정된 상태로 묶이면서 다음 갱신이 이어지지 않았고, 화면은 첫 상태에 가까운 값으로 멈춰 있었다.

그래서 브라우저를 새로고침하면 결과가 보였다.

이건 중요한 단서다. 새로고침 후 보인다는 건 서버 데이터가 없다는 뜻이 아니다. 서버에는 이미 값이 있고, 새 요청을 보내면 정상적으로 받아온다는 뜻이다. 문제는 사용자가 새로고침하지 않아도 그 요청이 이어져야 한다는 점이었다.

해결은 폴링을 진짜 폴링답게 만드는 것이었다. setTimeout으로 한 번 더 확인하는 흐름이 아니라, 분석이 끝날 때까지 일정 간격으로 상태를 확인하게 바꿨다. 동시에 요청이 겹치지 않도록 overlap protection도 넣었다. 이전 요청이 아직 끝나지 않았는데 다음 요청을 또 보내면, 느린 네트워크에서 상태가 꼬이거나 불필요한 부하가 생길 수 있기 때문이다.

이건 화려한 수정은 아니었다. 하지만 이런 수정이 제품의 신뢰감을 만든다.


진행 상태 UI도 같이 정리했다.

처음에는 화면에 바뀌는 문구가 많았다. “분석 중입니다”, “조금만 기다려 주세요” 같은 문장이 갱신될 때마다 사용자는 무언가 움직이고 있다고 느낄 수도 있지만, 반대로 화면이 깜빡이거나 산만해 보일 수도 있다.

특히 실제 진행률을 모르는 상태에서 말을 많이 하는 건 위험하다. 서버가 37% 완료됐는지, 82% 완료됐는지 모른다면 그렇게 보이게 만들면 안 된다. 제품이 아는 건 pending, running, completed, failed 같은 상태뿐이다.

그래서 진행 UI는 더 안정적으로 만들었다. 문구를 계속 바꾸기보다, 분석 중임을 나타내는 chip과 progress bar를 유지하고, 폴링이 상태를 바꾸면 그때 화면을 전환한다. 사용자를 안심시키되, 모르는 것을 아는 척하지 않는 방식이다.

이 기준은 AI 기능에서 특히 중요하다. AI 작업은 시간이 들쭉날쭉하고 실패 방식도 다양하다. 그럴수록 UI는 차분해야 한다.


같은 날 로드맵 제목 처리도 바꿨다.

처음에는 사용자가 제목을 입력하지 않으면 로컬에서 대충 추론하려고 했다. JD나 회사명처럼 보이는 문자열을 찾아 제목을 만드는 식이다. 그런데 그런 휴리스틱은 금방 이상해진다. JD에는 회사명이 없을 수도 있고, 역할명이 애매할 수도 있고, 이력서와 함께 봐야 맥락이 잡힐 때도 있다.

이미 Gemini가 JD와 이력서를 읽고 분석하고 있다면, 제목도 그 맥락 안에서 제안하게 하는 편이 낫다. 그래서 분석 응답에 suggestedTitle을 추가했다. 사용자가 제목을 직접 입력했다면 그대로 두고, 비워뒀을 때만 백그라운드 분석 결과의 제목을 반영하게 했다.

여기서도 중요한 건 사용자의 선택을 덮어쓰지 않는 것이었다. AI가 제안한다고 해서 사용자가 정한 이름보다 우선하면 안 된다. AI는 빈칸을 채울 수 있지만, 사용자의 의도를 이기면 안 된다.

작은 정책이지만 제품의 방향을 보여준다.


토픽 생성 쪽도 비슷한 문제를 안고 있었다.

로드맵 분석은 백그라운드 작업으로 옮겼지만, 토픽 생성은 여전히 동기 요청에 가까웠다. Generate 버튼을 누르면 모델이 토픽을 만들고, 응답이 올 때까지 사용자는 기다린다. 지금은 버틸 수 있어도, 토픽 수가 늘거나 모델이 느려지면 같은 문제가 반복될 가능성이 컸다.

그래서 우선은 상태를 더 명확히 보여주는 쪽으로 정리했다. Generate와 Regenerate 버튼에는 애니메이션 로딩 상태를 넣고, 한 번의 생성 시도가 하나의 LLM 요청을 쓴다는 점도 숨기지 않았다. 재생성 성공 메시지에는 만들어진 토픽 수와 요청 횟수도 보여줬다.

토픽 안의 AI 인터뷰 모듈은 기본적으로 접었다. 모든 내용을 한 번에 펼쳐두면 화면이 너무 빨리 복잡해진다. 첫 번째 토픽만 열어두고, 나머지는 필요할 때 펼치게 하는 편이 낫다.

이것도 결국 같은 방향이다. AI가 많은 내용을 만들수록, 제품은 더 적절히 접고 보여줘야 한다.


그날 배운 건 단순했다.

비동기 작업에서 중요한 건 작업을 끝내는 것만이 아니다. 끝났다는 사실이 사용자에게 도착해야 한다. 백엔드가 완료를 저장했는데 화면이 모르면, 사용자에게는 아직 끝나지 않은 일이다.

폴링은 그냥 주기적으로 API를 부르는 기술이 아니다. 사용자와 서버 사이의 약속에 가깝다. “지금은 기다려도 된다. 끝나면 내가 알려주겠다.” 그 약속을 했으면, 실제로 끝날 때까지 확인해야 한다.

AI 기능을 만들수록 이런 약속이 많아진다. 모델은 느릴 수 있고, 실패할 수 있고, 다시 시도해야 할 수 있다. 그래서 더더욱 상태를 정확히 다뤄야 한다. 모르는 것은 모른다고 보여주고, 끝난 것은 끝났다고 알려주고, 실패한 것은 실패했다고 말해야 한다.

결국 문제는 AI가 아니라 상태 전달이었다.

이미 끝난 일을 화면이 끝났다고 말하지 못한 문제였다.