AI가 분석한 리서치 결과, 어떻게 만들고 전달할까요?
1. 들어가며
사용자경험분석팀에서는 리서치 업무를 지원하는 사내 서비스인 리서치 AI 도우미를 만들고 있어요.
“배민클럽을 선택한 이유는 무엇인가요?”처럼 수천 명이 자유롭게 답한 설문응답을 이해하기 쉬운 카테고리로 분류해 보고서를 만들고, 사용성 테스트(UT, usability test) 녹취록을 요약해 분석 리포트를 만드는 기능을 제공합니다. 리서치 설계부터 분석까지 리서처가 직접 하던 일을 LLM으로 돕는 서비스예요.
우리가 다루는 리서치 분석은 크게 두 가지 영역으로 나뉩니다.
- 설문조사 분석: 설문과 서베이를 진행해 대규모 정량 데이터와 주관식 응답을 수집하고, 전체적인 경향성을 파악합니다.
- 인터뷰·UT 분석: 인터뷰, 사용성 테스트(UT) 녹취록 등을 분석하여 사용자의 생생한 목소리와 심층적인 행동 맥락을 이해합니다.
리서치 AI 도우미는 이 두 가지 영역을 모두 지원합니다. 세부 기능은 여러 개지만 사용 흐름은 대체로 비슷해요. 리서처가 분석할 자료(설문 응답 엑셀, 인터뷰 녹취록 등)와 조사 목적을 입력한 후 분석을 요청하면, 서버의 LLM이 분석을 수행해 결과를 파일과 요약 데이터로 돌려줍니다.


그런데 막상 만들어보니 고민할 지점이 두 군데 있었어요.
첫 번째 문제는 결과를 만드는 쪽이었습니다. 처음엔 LLM에 원하는 결과를 그대로 요청하면 될 거라고 생각했는데 그렇지 않았어요. 기준이 일정하지 않으면 분석 품질이 매번 달라졌습니다.
두 번째 문제는 그 결과를 전달하는 쪽이었어요. 분석은 서버에서 LLM이 수행하니까 프론트엔드는 화면에 잘 보여주기만 하면 된다고 생각했는데, 분석 결과가 잘 나오는 것과 사용자가 그 결과를 잘 받아보는 것은 다른 문제였거든요.
그래서 이 글은 두 부분으로 나뉩니다. 먼저 LLM에게 문제를 어떻게 맡겼는지, 그 결과를 사용자에게 어떻게 전달했는지 정리했어요.
2. LLM에게 문제를 어떻게 맡길 것인가
LLM을 활용하면서 두 가지를 목표로 뒀어요.
- 원본 데이터를 가공하는 시간을 줄여, 리서처가 결과를 해석하는 일에 집중하게 하는 것
- 분석 원칙을 프롬프트에 담아, 누가 실행해도 일정 수준 이상의 결과가 나오게 하는 것
설문 주관식 분석이든 인터뷰·UT 분석이든, 결국 “많은 자료를 읽고, 기준을 세우고, 기준대로 정리한다”는 같은 구조예요. 그래서 그 일을 한 번에 맡기면 될 거라고 생각했는데, 막상 해보니 그렇지 않았습니다.
2.1. 문제를 단계로 나누기
리서치 AI 도우미에는 설문 주관식 응답을 분류하는 기능이 있어요. 리서처들이 코드화라고 부르는 작업으로, 수천 개의 자유 응답을 하나씩 읽고 핵심 의미를 담은 코드로 정의하는 일입니다.
“음식배달 앱으로 배민을 많이 사용하지만, 정기적으로 매월 지급하는 금액이 부담스러워서”
→ 멤버십 비용 부담
처음에는 응답을 그대로 넣고 “의미 있는 코드로 분류해줘”라고 요청했어요. 그런데 같은 이야기가 서로 다른 코드로 분류되는 문제가 계속 나왔습니다.
| 응답 | 분류 결과 |
|---|---|
| “배달이 너무 늦어요” | 배송 속도 불만 |
| “배달 시간이 오래 걸려요” | 시간 관련 이슈 |
사람이 보면 같은 이야기인데 코드가 갈리고, 코드의 추상화 수준도 제각각이었어요. 그래서 한 번에 맡기는 대신 LLM이 이해하기 좋은 크기로 문제를 나눴습니다.
Step 1. 응답 다듬기 "배달 너무 늦어요 ㅠㅠ" → "배달 지연"
Step 2. 중분류 코드 생성·통합 "무료체험 종료", "무료배달이 유료로 변경됨" → "무료체험 종료"
Step 3. 대분류 코드 생성·통합 "멤버십 비용 부담", "최소주문금액 부담" → "비용 부담"
2.2. 리서처의 노하우를 참고하게 하기
UT를 설계하거나 인터뷰에서 인사이트를 뽑는 일에는 리서처의 노하우가 크게 작용해요. 그래서 그 노하우를 LLM이 참고할 수 있도록 자료로 정리해 프롬프트에 넣었습니다.
- UT 설계 가이드 – 어떤 순서로 과제를 주는지, 어떤 질문이 답을 유도하는지 같은 설계 원칙
- 유저골(User Goal) 목록 – 사용자가 서비스에서 이루려는 목표를 리서처들이 정리해둔 자료
둘 다 파일로 관리하고, 필요한 시점에 표로 변환해 해당 노드의 프롬프트에 넣어줬어요.
그런데 유저골 목록은 수백 행이나 돼서 토큰 한도 안에 전부 넣을 수 없었어요. 이번 조사와 관계없는 목표가 섞이면 LLM이 엉뚱한 관점으로 해석하는 것도 문제였습니다. 배민클럽 가입 이유를 물은 인터뷰인데 리뷰 작성이나 포장 주문 관련 목표까지 함께 들어가는 식이었죠.
그래서 어떤 유저골을 참고할지 LLM이 직접 고르게 했습니다. 이번 조사 주제와 요약 포인트(Summary Point)를 함께 주고, 유저골 목록에서 관련 있는 행만 판단해 가져오는 Agentic RAG 방식이에요.
user_goal_df = pd.read_csv(...) # 전체 유저골 목록
filtered_df = filter_relevant_user_goals( # 조사 주제·Summary Point로 관련 행만 선별
user_goal_df, summary_points=summary_points, topic=topic, max_rows=200,
)
return filtered_df.to_markdown(index=False) # 고른 것만 프롬프트로
고를 때는 내용을 다시 쓰게 하지 않고 행 번호만 돌려받아 토큰을 아꼈고, 선별된 유저골만 다음 노드의 입력으로 넘겼습니다.
덕분에 LLM은 이번 조사와 관련된 노하우만 참고해 분석할 수 있습니다.
2.3. 추적 가능한 평가 루프 만들기
코드를 짜는 시간보다 결과를 확인하는 시간이 훨씬 길었어요. LLM은 같은 입력에도 매번 다른 문장을 내놓기 때문에 “이 응답은 반드시 이 코드로 분류돼야 한다”고 못 박을 수 없거든요. 정답이 없으니 자동으로 채점할 방법도 없었습니다.
그래서 사람에게 평가를 받기로 했습니다. 리서처들이 실제 조사 데이터로 돌려보고 결과가 쓸 만한지 정성적·정량적으로 보는 방식이었어요. 분류한 코드에 대한 정성적인 평가와 해당 문장을 사내 리서처들의 직접 분류한 결과와 비교하는 정성적인 평가를 수행했어요. “이 코드는 너무 포괄적인 내용을 담고 있다”, “이 질문은 답을 유도한다”처럼 점수와 문장으로 받아 프롬프트를 고쳐 나갔습니다.
아쉬운 점을 추적하려면 어느 단계에서 틀어졌는지 열어볼 수 있어야 했어요. 그래서 LLM의 호출 이력을 추적하고 전반적인 성능을 모니터링할 수 있는 Langfuse를 붙이고, 분석 하나에 발급되는 작업 ID(job_id)를 추적 세션 ID로, 사내 계정 정보를 사용자 ID로 넣어뒀습니다. 덕분에 “누가 돌린 어떤 분석”인지로 바로 찾아가 단계별 입출력을 확인할 수 있었어요.
만들기 전에는 프롬프트를 잘 쓰는 게 핵심이라고 생각했는데, 실제로는 평가받고 반영하는 사이클을 도는 일이 더 중요했습니다.
3. 분석 시간이 길 때 어떤 사용자 경험을 줄 것인가
이렇게 분석 결과는 만들어졌지만, 사용자가 그 결과를 받아보기까지는 아직 거리가 있었어요. 자료의 양에 따라 분석은 몇 분에서 몇 시간까지 걸립니다. 그 시간 동안 사용자에게 무엇을 보여줄 것인지가 프론트엔드의 첫 번째 문제였어요.
분석하는 시간 동안 스피너만 돌리면 두 가지 문제가 생깁니다.
- 사용자는 멈춘 건지 잘 돌아가는 건지, 얼마나 남았는지 알 수 없어요
- 분석 시간이 오래 걸리니 사용자는 다른 탭으로 떠납니다. 그리고 끝난 걸 모르죠
3.1. SSE로 진행 상황을 중계하기
사용자에게 진행 상황을 보여주려고 SSE(Server-Sent Events)를 활용해 서버에서 분석 진행 상황을 실시간으로 전달받았어요. 클라이언트가 서버와 통신하는 다른 방식들과 비교했을 때 SSE가 분석 진행 상황을 보여주기에 가장 적합하다고 판단했습니다.
| 방식 | 통신 방식 | 판단 |
|---|---|---|
| REST API | 요청 1회에 응답 1회 | 한 번 응답하면 끝이라 진행 중인 상태를 알 수 없어요 |
| Polling | 일정 시간마다 반복 요청 | 진행 상황을 알 수는 있지만, 불필요한 요청이 많고 변화를 다음 요청까지 기다려야 해요 |
| WebSocket | 전용 소켓으로 양방향 통신 | 클라이언트가 보낼 메시지가 없어서 오버스펙이에요 |
| SSE ✅ | HTTP 연결을 유지하며 서버 → 클라이언트 단방향 통신 | 진행 상황을 실시간으로 받기에 가장 적합했어요 |
브라우저에서는 EventSource API로 SSE를 활용할 수 있어요. 연결 흐름은 이렇습니다.
- 분석 시작 API를 POST로 호출해서
job_id(서버가 이번 분석 작업 하나에 발급하는 식별자)를 받습니다. - 수신한
job_id를 활용해 SSE 연결을 생성합니다. - 진행 상황이 이벤트로 내려옵니다.

POST로 먼저 job_id를 받는 이유는 브라우저 EventSource가 GET만 지원하기 때문이에요. 그래서 요청 본문(파일 정보, 분석 옵션 등)은 POST로 보내고, 스트림 구독은 발급받은 job_id를 활용했습니다.
연결이 맺어지면 서버는 event와 data 형식으로 이벤트를 계속 보내줍니다. 이벤트 타입은 다섯 가지로 정의했어요.
progress: 작업 진행 상황을 전달할 때completed: 정상적으로 작업이 완료되었을 때failed: 작업 중 오류가 발생했을 때cancelled: 사용자가 분석 중지를 요청하거나 화면을 이탈했을 때heartbeat: 연결이 살아있는지 확인할 때
이 이벤트들을 다루면 번거롭기 때문에 클래스를 만들어 활용했어요.
export class JobEventStream {
private eventSource: EventSource | null = null
start(): void {
const url = ${getBaseURL()}/jobs/${this.jobId}/stream
this.eventSource = new EventSource(url)
this.eventSource.addEventListener('progress', (e) => {
this.handleEvent(e, 'onProgress')
})
this.eventSource.addEventListener('completed', (e) => {
this.handleEvent(e, 'onCompleted')
this.close() // 완료되면 스트림을 닫아요
})
// failed, cancelled, heartbeat ...
}
}
이렇게 해두면 상태 관리 쪽에서는 이벤트별로 무엇을 할지만 선언하면 됩니다. 서버가 각 타입에 해당하는 이벤트를 내려줄 때마다 호출돼요.
const stream = createJobEventStream(job_id, {
// 진행 중
onProgress: (data) => get().updateProgress(data),
// 작업 완료
onCompleted: (data) => get().completeJob(data),
// 작업 실패
onFailed: (data) => get().failJob(data.error),
})
progress일 때는 진행 상황을 보여줍니다. 작업이 어느 정도 진행되고 있는지, 지금 어떤 작업을 수행하는지 사용자가 인지할 수 있어요.completed일 때는 분석 결과를 내려줘요.failed인 경우, 에러 메시지를 보여주고 스트림 연결을 닫습니다.
| 분석 중(progress) | 분석 완료(completed) | 분석 실패(failed) |
|---|---|---|
![]() |
![]() |
![]() |
덕분에 사용자는 분석이 어디까지 왔고 지금 무슨 작업을 하고 있는지, 실패했다면 왜 실패했는지까지 화면에서 바로 확인할 수 있습니다.
3.2. 알림음으로 완료를 알리기
오랜 시간이 걸리는 작업이라 사용자는 그동안 다른 탭에서 다른 일을 해요. 분석이 끝난 걸 모르면 실시간 진행률을 노출해도 의미가 없습니다.
그래서 분석 완료 시점에 알림음을 재생하기로 했어요. 다만 mp3 파일을 추가하는 대신 Web Audio API로 소리를 직접 만들었습니다. 이유는 두 가지예요.
- 원하는 대로 조절할 수 있어요. 음 높이, 길이, 볼륨이 코드에 있는 값이라 “조금 더 짧게”, “조금 더 낮게” 같은 요구를 바로 반영할 수 있습니다.
- 다운로드할 리소스가 없어요. 음원 파일을 번들에 넣거나 따로 받아올 필요가 없습니다. 짧은 알림음 하나 때문에 음원 파일을 관리하지 않아도 되죠.
브라우저에서 오디오를 생성하고 재생하는 건 AudioContext API가 담당해요.
소리를 만드는 오실레이터(Oscillator) → 볼륨을 조절하는 게인(Gain) → 스피커로 내보내는 목적지(Destination) 순서로 연결하면 소리가 납니다.
const oscillator = audioContext.createOscillator()
const gainNode = audioContext.createGain()
oscillator.type = 'sine' // 사인파
oscillator.frequency.value = frequency // 음 높이
// 부드럽게 시작하고(Attack) 자연스럽게 사라지도록(Decay)
gainNode.gain.linearRampToValueAtTime(0.25, now + 0.03)
gainNode.gain.exponentialRampToValueAtTime(0.01, now + duration)
oscillator.connect(gainNode)
gainNode.connect(audioContext.destination)
oscillator.start(now) // now = audioContext.currentTime
oscillator.stop(now + duration)
알림음은 AI에 원하는 음 높이와 길이를 설명해 코드로 만들 수 있어요.
이제 완료 이벤트가 오면 알림음을 재생합니다.
completeJob: (data) => {
playCompletionSound()
set({ status: 'completed', result: { ... } })
}
이제 사용자는 분석이 끝나기를 화면 앞에서 기다리지 않아도 됩니다. 다른 일을 하다 알림음을 듣고 결과를 확인하러 돌아오면 되니까요.
4. 결과를 어떻게 더 깊이 파고들게 할 것인가
앞 장까지가 “결과가 나오기까지”의 경험이었다면, 이번 장은 “결과가 나온 뒤”의 경험이에요.
알림음을 듣고 돌아온 사용자 앞에는 분석 결과 파일이 놓여 있습니다. 그런데 사용자는 파일을 내려받고, 열고, 훑어봐야 비로소 결과를 알 수 있어요. 분석이 끝나길 기다린 뒤에 또 한 단계를 더 거치는 셈이죠.
게다가 결과를 읽고 나면 대부분 후속 질문이 생깁니다. “배달 지연 불만이 제일 많다는데, 구체적으로 어떤 응답들이야?” 같은 질문이요. 이 질문에 답하려고 사용자를 다시 엑셀로 돌아가게 하고 싶지는 않았어요.
4.1. 채팅으로 결과를 더 파고들 수 있게 하기
그래서 파일 다운로드와 함께 채팅 인터페이스를 제공했습니다. 전체를 요약한 결과에서 출발해, 사용자가 각자의 관심사를 따라 더 깊게 파고들고 더 좁혀 들어갈 수 있는 경로를 만들고 싶었거든요.
동작 방식은 이렇습니다. 완료된 분석의 job_id로 대화 세션을 열고, 사용자가 질문을 보내면 LLM이 분석 결과를 근거로 답해요.

채팅 답변 역시 3장에서 다룬 것과 같은 오래 걸리는 작업이었어요. 그래서 분석 진행률을 중계하려고 만든 JobEventStream을 답변 스트리밍에 그대로 재사용했습니다.
4.2. 필요할 때만 세션 만들기
한 가지 더 고민한 건 세션을 만드는 시점이었어요.
사용자는 참여자별로, 그룹 요약별로 여러 채팅방을 오갑니다. 채팅방마다 세션을 미리 만들어두면 실제로는 쓰이지 않을 세션까지 생기고, 서버는 그만큼 쓰이지 않을 대화 컨텍스트를 들고 있게 돼요.
그렇다고 늦게 만들자니 다른 문제가 있었습니다. 사용자가 한참 뒤에 돌아와 질문하면 세션이 이미 만료되어 있을 수 있는데, 이때 “세션이 만료되었습니다”라는 화면을 보여주고 싶지는 않았어요.
두 문제는 하나의 규칙으로 해결했습니다. 질문을 보내기 전에 항상 세션 만료 여부를 확인하는 것이에요. 세션이 살아 있으면 그대로 쓰고, 없거나 만료됐으면 새로 발급받습니다. 만료 여부는 세션을 만들 때 서버가 함께 내려준 만료 시각으로 판단해요.
async ensureSession(conversationId, ...) {
const existing = this.sessions.get(conversationId)
// 세션이 있고 만료 전이면 그대로 사용해요
if (existing && !this.checkSessionExpiry(existing.expiresAt)) return
// 없거나 만료됐으면 새로 발급받아요
const response = await conversationAPI.startConversation({ ... })
this.sessions.set(conversationId, { ... })
}
덕분에 세션은 첫 질문을 보내는 순간에야 만들어지고, 만료된 세션은 조용히 새로 발급됩니다. 사용자 입장에서는 언제 돌아와도 그냥 이어서 대화가 되는 거죠.
4.3. 탭을 옮겨도 답변이 계속 도착하게 만들기
채팅방이 여러 개가 되면서 다음 상황이 생겨요. A 채팅방에서 질문을 보내고, 답변을 기다리는 동안 B 채팅방으로 넘어가서 다른 질문을 합니다. 이때 A의 답변은 화면에서 사라졌더라도 계속 도착하고 있어야 해요.
그런데 대화 상태를 컴포넌트 안에서 관리하면 이 동작이 어렵습니다. 탭을 옮겨 컴포넌트가 사라지는 순간, 진행 중이던 연결도 함께 끊기거든요.
그래서 대화의 세션과 스트림은 화면이 아니라 React 바깥의 서비스 클래스에서 관리하기로 했고, 채팅 기능을 세 개의 레이어로 나눴습니다.
| 레이어 | 담당 |
|---|---|
| 화면 (채팅 UI 컴포넌트) | 메시지를 그리고 입력을 받는 일만 해요. 세션이나 스트림의 존재는 알지 못합니다. |
상태 (conversationStore) |
여러 채팅방의 대화와 메시지를 한곳에 보관해요. UI는 이 스토어만 구독합니다. |
서비스 (ConversationSessionService) |
세션을 만들고 갱신하고, 질문을 보내고, 답변 스트림을 받아 상태에 반영합니다. |
서비스 레이어가 하는 일을 풀어보면, 사용자가 질문 하나를 보낼 때 이런 일이 순서대로 일어납니다.
- 세션이 있는지 확인하고, 없거나 만료됐으면 새로 만들어요.
- 사용자 메시지와 “답변 생성 중” 메시지를 스토어에 추가해요 – 화면에는 즉시 말풍선이 떠요.
- 서버에 질문을 보내고
job_id를 받아요. - 그
job_id로 답변 스트림을 구독하고, 도착하는 내용으로 로딩 말풍선을 채워요. - 완료되면 메시지 상태를 완료로, 실패하면 에러로 바꿔요.
이 흐름 전체가 서비스 클래스의 메서드 하나에 위에서 아래로 담겨 있어요. 컴포넌트의 useEffect 여러 개로 쪼개져 있었다면 흐름을 따라가려고 여러 파일을 오가야 했을 텐데, 한 곳에서 시나리오가 그대로 읽히는 게 이 구조의 또 다른 장점이었습니다.
그리고 서비스가 React 바깥에 있으니, 사용자가 어느 탭을 보고 있든 스트림은 끊기지 않고 스토어에 답변을 쌓아요. 사용자가 A 채팅방에 돌아오면 그동안 도착한 답변이 기다리고 있는 거죠.
사용자는 결과를 확인하는 데서 끝나지 않고, 궁금한 걸 그 자리에서 이어 물어볼 수 있게 됐습니다.
5. 마무리
돌이켜보면 고민은 두 갈래였어요. 하나는 좋은 결과를 어떻게 만들 것인가, 다른 하나는 그 결과를 사용자에게 어떻게 전달할 것인가였습니다.
결과를 만드는 쪽에서 배운 건, 프롬프트 한 문장을 잘 쓰는 것보다 구조를 만드는 일이 품질을 결정한다는 점이었어요. 사람이 일하는 순서대로 단계를 나누고, 리서처의 노하우를 참고할 자료로 만들고, 결과를 평가받아 다시 반영하는 일. 그게 저희가 만든 구조였습니다.
전달하는 쪽에서 배운 건, 결과를 잘 만드는 것만으로는 사용자에게 닿지 않는다는 점이었어요. 기다리는 동안 무슨 일이 일어나고 있는지 보여주고, 사용자가 화면을 떠나 있다는 걸 전제로 완료를 알리고, 결과를 종착점이 아니라 시작점으로 만드는 일이 필요했습니다.
결국 글 첫머리에 적었던 문장으로 돌아옵니다. 분석 결과가 잘 나오는 것과 사용자가 그 결과를 잘 받아보는 것은 다른 문제였어요. 구조 없이 맡긴 분석은 품질이 흔들리고, 경험 없이 전달된 결과는 인사이트가 되지 못합니다. LLM이 좋아질수록 서비스의 체감 품질을 가르는 건 모델 바깥의 이런 디테일이라는 점을 배울 수 있었습니다.
비슷한 서비스를 만들고 계신 분이라면, 만들면서 스스로에게 던졌던 질문들을 함께 남겨둘게요.
- LLM에게 일을 맡길 때, 사람이 일하는 순서대로 단계를 나눠봤나요?
- 사용자는 기다리는 동안 시스템이 무엇을 하고 있는지 알 수 있나요?
- 화면을 떠나 있어도 완료 여부를 알 수 있나요?
- 결과를 받은 사용자가 다음 액션을 그 자리에서 이어갈 수 있나요?


