티스토리 뷰

반응형


안녕하세요! 지난 2월 27일, “AI로 뭘 해볼까?”라는 고민에서 출발해 자기소개 챗봇을 만들어보자! 는 다짐으로 첫 글을 남겼습니다.

서비스는 한 달 후 3월 말에 완성이 되었지만, 글을 이제서야 쓰게 되었습니다. 이 글에서는 그동안 챗봇을 개발하며 겪은 기술적 고민과 구현 과정, 그리고 구조 개선의 흐름까지 정리해보려 합니다.

 

해당 서비스는 GPT-4o-mini 모델의 API 호출 비용 문제로 인해 이름과 주소를 공개하지 않고 있습니다. 혹시 저와 같이 AI Chatbot을 개발해보고 싶은 분들은 이메일(algoroot524@gmail.com)로 보내주시거나 댓글을 남겨주시면, 제가 아는 선에서 최대한 도움을 드리겠습니다.

 


개발 기간 및 기술 스택

  • 개발 기간: 약 1개월
  • AI: LangGraph.js, Supabase Vector DB, OpenAI, AI SDK
  • 웹 프론트엔드: Next.js (App Router), Tailwind CSS, Shadcn UI
  • API 및 백엔드: tRPC, Supabase, Zod

 

서비스 이용 안내 및 모델 정보

이 서비스는 GPT-4o-mini 모델을 기반으로 응답을 생성합니다.
비용 최적화를 고려해 해당 모델을 선택했으며, 로그인 없이도 누구나 사용할 수 있도록 설계했습니다.
주 이용 대상이 채용 담당자님인 만큼, 로그인 절차는 오히려 서비스 접근성을 떨어뜨리는 요인이라고 판단했기 때문입니다.

또한, 과도한 API 호출을 방지하기 위해 아래와 같은 제약을 설정했습니다:

  • 클라이언트의 request header에서 고유 식별자(IP 등)를 추출
  • 해당 값을 기준으로 요청 기록을 저장
  • IP당 하루 최대 50회의 대화로 이용 횟수를 제한
    • IP는 User-Agent와 함께 해싱된 값으로 저장되며, 주 사용자층인 채용 담당자의 접근 허들을 낮추기 위해 로그인 과정을 생략한 구조이며, 과도한 사용을 방지하고 기본적인 디버깅 및 통계 수집 용도로 충분한 수준으로 설계되었습니다.
  • 최근 대화 흐름 유지를 위해, 최대 10개의 메시지를 기억하여 문맥을 반영합니다.

이러한 설정은 개인 프로젝트이면서도 실제 사용자 경험을 고려해 비용과 접근성, 사용성을 균형 있게 조율하려는 고민의 결과였습니다.

 

핵심 개발 기능

  • AI 기반 데이터 벡터화 및 검색 최적화 (RAG)
    • 포트폴리오(Web Crawling), 이력서(Markdown), 자기소개(JSON) 데이터를 Supabase Vector DB에 벡터화하여 저장
    • LangGraph의 StateGraph를 활용해
      → 질문 정제
      → 검색 필요 판단
      → 조건 분기
      → 응답 생성 흐름을 구현
  • 지속적인 대화 흐름 유지 (Memory Management)
    • MemorySaver로 채팅 내역 저장 및 문맥 유지
    • Message Trimmer로 최대 10 Token 유지하여 메모리 사용량 최적화
  • 과도한 사용을 방지하기 위한 제한
    • IP 기준 하루 최대 50회의 대화로 제한
    • IP는 User-Agent와 함께 해싱된 값으로 저장되며,
      주 사용자층인 채용 담당자의 접근 허들을 낮추기 위해 로그인 과정을 생략한 구조이며,
      과도한 사용을 방지하고 기본적인 디버깅 및 통계 수집 용도로 충분한 수준으로 설계됨
  • 실시간 스트리밍 응답 처리 (LangGraph stream & AI SDK)
    • LangGraph와 AI SDK를 활용해 AI 응답을 토큰 단위로 스트리밍 처리
  • 편안한 UI/UX 고려
    • Chat GPT의 UX 참고해 UX 개선
  • 서버 컴포넌트 환경에서 tRPC·React Query 기반 API Prefetch 및 SSR 최적화

 

 


 

프런트엔드 개발자가 AI 챗봇을 만들기까지

AI 기술은 그동안 접해보지 못했던 분야였지만, ChatGPT가 등장하고 생성형 AI가 일상이 되면서 "AI를 활용한 개발은 어떻게 이루어질까?"에 대한 궁금증이 생기기 시작했습니다. 당시엔 LangChain이나 RAG 같은 키워드를 간략히 접해본 정도였죠.

이번 프로젝트는 그런 저에게 처음으로 AI와 직접 맞닿는 개발 경험이었고, 아래에 소개할 레퍼런스 문서들이 큰 도움이 되었습니다.

참고한 문서들 (Reference Documentation)

AI 챗봇을 처음 개발하며 실제로 참고했던 문서들을 아래에 정리했습니다. 각 문서는 특정 문제를 해결하거나, 구조를 이해하는 데에 실질적인 도움을 주었습니다:

 


 

Chatbot 기능 개선 과정

여기서부터는 Chatbot을 만든 과정을 단계별로 설명합니다. 

1단계: MVP로 빠르게 시작하기

처음에는 구조를 단순하게 잡았습니다. Next.js 기반 모노레포 구조를 설정하고, Tailwind와 Shadcn으로 UI 틀을 빠르게 잡은 뒤, OpenAI API에 사용자의 질문을 바로 전달하고 응답을 보여주는 형태로만 동작하도록 했습니다. 이때는 JSON 형식으로 정리한 자기소개 데이터를 프롬프트에 그대로 끼워 넣는 수준이었고, 대화형 인터페이스보다는 질문 → 응답 형태의 단방향 흐름에 가까웠습니다.

  • OpenAI API에 사용자 질문을 바로 전달하는 단방향 구조
  • JSON 형식 자기소개 데이터를 그대로 프롬프트에 삽입
  • 대화 흐름은 없고, 응답은 항상 동일한 패턴으로 반환

예: "너 누구야?" → "답변을 찾을 수 없습니다."

 

2단계: 벡터 DB + RAG 구조 도입

보다 정확하고 풍부한 문맥 기반 응답을 위해, RAG(Retrieval-Augmented Generation) 구조를 도입했습니다.

  • 포트폴리오, 이력서(. md), 자기소개(JSON) → 벡터화
  • Supabase Vector DB + LangGraph로 검색
  • 검색 결과 기반으로 프롬프트 구성 → 더 자연스러운 응답
export const searchVectorStore = async (state: GraphAnnotationState) => {
	const { refinedQuestion } = state
	const input = refinedQuestion || ''
	const searchResults = await search(input).then(formatSearchResults)
	return {
		...state,
		searchResults,
	}
}
export const search = async (
	input: string,
	options?: SearchOptions,
): Promise<SearchResult[]> => {
	const { count = 3, minScore = 0 } = options ?? {}

	/**
	 * 필요하면 filter 함수로 metadata 넣어 필터링 시킬 수 있음
	 * @see https://js.langchain.com/docs/integrations/vectorstores/supabase/#metadata-query-builder-filtering
	 */
	const results = await vectorStore.similaritySearchWithScore(input, count)

	const answered = results.filter(([doc, score]) => {
		return score >= minScore && doc.metadata?.answer
	})

	const unanswered = results.filter(([doc, score]) => {
		return score >= minScore && !doc.metadata?.answer
	})

	const bestMatch = [...answered, ...unanswered]

	const data = bestMatch.map(([d, s]) => {
		return { data: d, similarity: s, answer: d.metadata?.answer || '정보 없음' }
	})

	return data
}

 

3단계: 대화 흐름 유지 – Memory 관리

사용자와의 대화들을 AI가 기억하는 것처럼 보이려면, 채팅 히스토리를 같이 invoke 시켜야 했습니다. 이를 위해 LangGraph의 Memory 시스템을 도입해 다음과 같은 흐름으로 개선했습니다. 

  • MemorySaver로 이전 메시지 저장
  • MessageTrimmer로 오래된 메시지 제거 (최대 10 토큰 유지)
  • refineQuestion node를 추가해 사용자와의 대화 내역을 기반으로 한 정제된 질문으로 검색
    • refineQuestion 과정은 아래 섹션의 LangGraph를 통한 챗봇 흐름 최적화 과정에서 더 자세히 보실 수 있습니다.
// 아래 함수를 사용하여 prompt에는 maxTokens만큼 잘라진 메세지가 보내지게 됩니다.
export const getTrimMessages = async (messages: BaseMessage[]) => {
	const trimer = trimMessages({
		maxTokens: 10,
		strategy: 'last',
		tokenCounter: (msgs) => msgs.length,
		includeSystem: true,
		allowPartial: false,
		startOn: 'human',
	})

	const res = await trimer.invoke(messages)
	return res
}

 

AI 응답 흐름 시각화 

아래 이미지는 사용자의 채팅 내용을 바탕으로, AI가 관련 정보를 검색해 적절한 답변을 생성한 예시입니다.

 

4단계: 실시간 스트리밍 응답 처리 – 대기 시간 최소화

 

기존 방식은 사용자가 질문을 입력하면 AI가 전체 답변을 모두 생성한 뒤 한 번에 응답을 반환하는 구조였기 때문에, 실제 답변을 보기까지 약간의 체감 지연이 있었습니다. 이를 개선하기 위해, AI SDK의 스트리밍 기능을 도입했고 다음과 같은 흐름으로 구성했습니다.

  • LangGraph의 stream 모드 + AI SDK를 조합하여, 마치 ChatGPT처럼 ‘타자 치듯’ 응답
  • 체감 대기 시간 60% 단축 (5초 → 2초)
export async function chat(history: Message[], userIp: string) {
	const stream = createStreamableValue('')
    // ... inputData 만드는 과정 생략
	;(async () => {
		const messageStream = await app.stream(inputData, {
			configurable: { thread_id: options.thread_id },
			streamMode: 'messages',
			tags: ['user-chat', `ip:${options.userIp}`],
			callbacks: [
				{
					handleLLMEnd(output, runId, parentRunId, tags) {
						console.log('handleLLMEnd', { output, runId, parentRunId, tags })
					},
					handleLLMError(err, runId) {
						console.log('handleLLMError', { err, runId })
						stream.done()
					},
				},
			],
		})

		for await (const content of messageStream) {
			const [chunk, info] = content
			if (info.langgraph_node === NODES.GENERATE_RESPONSE) {
				stream.update(chunk.content)
			}
		}
		stream.done()
	})()

	return { history, newMessage: stream.value }
}
 

배포 이후 LangSmith를 통해 발견한 실사용 이슈와 개선 과정

서비스를 배포하고 한 기업에 지원서를 제출한 직후, 아쉽게도 서류 탈락을 경험했습니다.
그러나 감사하게도 해당 회사에서 제 챗봇 서비스를 실제로 사용해 본 흔적이 LangSmith 로그를 통해 확인되었고, 이를 통해 치명적인 두 가지 문제를 발견할 수 있었습니다.

 

1. 응답 끊김 이슈

로컬환경에서는 응답이 끊기지는 않지만, 버셀로 배포한 환경에서 간헐적으로 응답이 생성되지 않는 이슈였습니다. 

 

2. 생각보다 사용자는 단순하게 질문을 한다. 

예 : 서버도 하나요?

 

1-2. 응답 끊김 이슈 해결 : maxDuration 설정

배포 환경에서 테스트를 진행하던 중, 응답이 간헐적으로 누락되는 것이 아니라, 요청 후 정확히 10초가 지난 시점에 실패하는 패턴을 발견했습니다. 이 현상은 로컬 개발 환경에서는 재현되지 않고 Vercel 배포 환경에서만 발생했기 때문에, Vercel Functions와 관련된 제한 사항일 가능성이 높다고 판단했습니다.

 

이에 따라 Vercel 공식 문서를 확인한 결과, Hobby 플랜에서 제공되는 Vercel Functions는 기본적으로 maxDuration이 10초로 설정되어 있으며, 이 시간을 초과하면 504 FUNCTION_INVOCATION_TIMEOUT 오류가 발생한다는 점을 확인했습니다.

 

제가 구현한 chat 함수는 서버 액션(Server Actions)을 통해 호출되며, 이는 Next.js App Router 구조상 내부적으로 Vercel의 Node.js Function으로 처리되기 때문에 해당 제한의 영향을 그대로 받습니다.

 

문제 해결을 위해 maxDuration을 요청하는 /chat페이지에 추가하여 이슈를 해결했습니다.

/**
 * vercel에서 최대 함수 지속시간 설정
 * @see https://sdk.vercel.ai/docs/troubleshooting/timeout-on-vercel
 */
export const maxDuration = 60

export default async function ChatPage() {
	await prefetch(trpc.getSuggestQuestions.queryOptions())

	return (
		<ChatInterface>
			<HydrateClient>
				<ChatQuestionSheet />
			</HydrateClient>
		</ChatInterface>
	)
}

 

1-2. 응답 끊김 이슈 해결 : 응답 실패 시 "다시 시도하기" 버튼 추가

 

이와 더불어, 혹시 또 응답이 생성되지 않아 에러처리가 될 경우  아래와 같이 "다시 시도하기" UI를 추가했습니다. 

<Show when={type === 'error'}>
	<div className="bg-muted prose-p:m-0 flex flex-col items-center justify-center gap-2 p-0 px-2 py-2 md:flex-row md:px-2">
		<p className="text-muted-foreground text-sm">
			답변에 문제가 있습니다.
		</p>
		<Button
			variant="outline"
			size="sm"
			className="w-full md:w-fit"
			onClick={onRetry}
		>
			다시 시도하기
		</Button>
	</div>
</Show>

 

 

2. 사용자는 생각보다 단순하게 질문한다: LangGraph로 흐름 최적화하기

초기 구조에서는 LangGraph를 도입하긴 했지만, 단일 노드만으로 구성된 매우 단순한 상태(StateGraph)였습니다.
서비스 특성상 외부 검색이나 복잡한 분기 처리가 필요하지 않다고 판단했기 때문에, 추가적인 노드 구성 없이 배포를 진행했습니다. 당시에는 어디에 어떤 노드를 추가해야 할지도 명확하지 않았고, 실제 사용자 사용 패턴을 보기 전까지는 큰 필요성을 느끼지 못했습니다.

하지만 LangSmith 로그를 통해 실사용자의 질문을 확인해 보니, 제가 사전 테스트에서 사용한 복잡한 질문들과 달리 매우 짧고 단순한 질문들이 대부분이었습니다.

 

예를 들어:

  • "서버도 하나요?"
  • "어디서 일했어요?"

이처럼 명확한 맥락 없이 질문하는 경우, 기존 구조에서는 LLM이 정확한 응답을 생성하기 어려웠습니다. 당시에는 MemorySaver를 통해 직전 메시지 10개를 유지하는 방식이 문맥 전달에 도움이 될 것이라 생각했지만, LangGraph의 실제 흐름을 되짚어보니, 사용자의 질문은 먼저 벡터 DB 검색에 사용된 후, 그 결과와 함께 히스토리가 LLM에 전달되는 구조였기 때문에,
검색 단계에서는 히스토리가 전혀 반영되지 않고 있는 상태라는 점을 뒤늦게 인지하게 되었습니다. 결국, LLM에 문맥을 전달하는 방식은 검색 이후에야 작동하고 있었고, 질문이 짧거나 모호할 경우 검색 자체가 부정확해지는 구조적 한계가 있었던 것입니다.

 

흐름 개선의 방향: 질문 정제 노드 도입

이 경험을 통해, 저는 다음과 같은 문제 인식을 하게 되었습니다:

"사용자의 질문은 충분히 구체적이지 않기 때문에, 질문을 정제하거나 보완하는 전처리 단계가 반드시 필요하다."

 

이에 따라, LangGraph의 StateGraph 구조에서 질문 정제용 노드를 새롭게 추가하기로 결정했습니다.
이 노드는 메시지 히스토리를 참고하여 현재 입력된 질문을 더 명확하고 LLM이 이해하기 쉬운 형태로 변환하며,
다음 단계로 전달되는 전반적인 응답 품질을 높이기 위한 역할을 합니다.

 

아래에서는 제가 어떤 기준으로 노드를 설계했고, 실제로 어떻게 LLM 응답 품질이 개선되었는지를 구체적으로 설명하겠습니다.

 

기존 문제

  • 기존 흐름 사용자 질문 → 벡터 DB 검색 → 메세지 히스토리 추가 → LLM 호출
    • 백터 DB 검색이 사용자의 질문 원문에 그대로 의존함
  • 모든 질문에 대해 벡터 검색 수행 → 불필요한 비용 발생
  • StateGraph의 조건 분기 기능을 활용하지 못함

 

개선 방향: 검색 흐름을 조건 분기로

 

이에 따라 아래와 같은 흐름으로 검색 여부를 판단하고, 질문이 애매한 경우에는 먼저 질문을 정제하는 노드를 추가하는 방식으로 개선했습니다.

  • refineQuestion + shouldSearch 노드 도입
  • 검색 필요 여부 판단 → 필요할 때만 검색
노드 설명
refineQuestion 사용자의 마지막 입력이 짧거나 모호할 경우, 대화 흐름을 기반으로 검색에 적합한 질문으로 정제
shouldSearch 정제된 질문과 대화 내역을 기반으로 벡터 검색이 필요한지 판단
searchVectorStore Supabase 기반의 벡터 DB에서 관련 내용을 검색
generateResponse 검색 결과 및 대화 맥락을 바탕으로 최종 응답 생성
__start__
   ↓
refineQuestion
   ↓
shouldSearch
   ↓
  ┌───────────────┐
  │ needSearch ?  │
  └──────┬────────┘
         ↓
      true → searchVectorStore → generateResponse
      false ───────────────────→ generateResponse

덕분에 응답 정확성과 비용 효율성을 동시에 개선할 수 있었습니다.

 


UI/UX 개선 및 인터페이스 고도화

제 서비스는 초기 단계에서 Tailwind와 Shadcn 기반의 모노레포 설정을 완료하고, AI 모델을 활용한 간단한 채팅 기능만 우선 퍼블리싱한 상태로 출발했습니다. 이후 챗봇의 기본 기능을 모두 구현한 뒤, 사용성과 디자인을 중심으로 전체적인 UI/UX를 개선해 나가며, 서비스의 완성도를 높이는 데 집중했습니다. 아래는 그 과정에서 중점적으로 개선한 요소들과 고민했던 지점들입니다.

 

채팅 서비스는 Vercel의 v0와 OpenAI의 ChatGPT를 참고했습니다. 두 UI와 UX를 비교 후 ChatGPT가 더 유저 접근성이 좋다고 판단되어 ChatGPT의 구조를 보면서 서비스를 개선했습니다.

 

최신 메시지 상단 고정

사용자가 질문을 입력할 때, Vercel의 v0는 단순히 메시지를 입력 순서대로 쌓는 구조를 사용합니다. 반면 ChatGPT는 사용자의 최신 질문을 스크린의 상단에 위치시킴으로써, 질문과 그에 대한 응답을 한눈에 확인할 수 있도록 설계되어 있습니다. 이러한 구조는 특히 스크린이 작은 모바일 환경에서 더욱 유용하게 작용하며, 사용자가 자신의 질문과 이에 대한 응답을 한 화면에서 동시에 확인할 수 있게 되어 전반적인 사용자 경험이 향상된다고 느꼈습니다.

  • ChatGPT UX를 참고하여 최신 메시지를 상단에 배치
  • 모바일에서도 사용자가 질문-응답을 한 화면에서 확인 가능

스크롤 동작 세분화

이 동작을 서비스에 적용하기 위해 사용자의 가장 최신 메시지 Element에 scrollIntoView를 사용하여 사용자가 질문을 할 때 해당 메서드를 실행시켰습니다.

  • 채팅 중엔 scrollIntoView → 부드러운 이동
  • Home → Chat 이동 시엔 behavior: 'instant'

메세지 height 조정

이때 모든 메시지의 Element의 Height 이 auto로 되어있어 스크롤 이동을 하더라도 사용자의 메시지가 최상단에 올라오지는 않습니다.

 

ChatGPT의 적용 방식을 보니 최신 AI 답변의 min-height를 조정하여 최신 사용자 질문 Element가 스크린의 상단에 오도록 조정하고 있었습니다.

 

 

ChatGPT의 방식처럼, 최신 AI 응답 메시지의 min-height를 조절하여 사용자의 질문 메시지가 항상 상단에 오도록 처리하였습니다.

 

 

사용자 접근성을 고려한 질문 리스트 UI 제안 기능

 

실제로 서비스를 사용하는 클라이언트를 떠올렸을 때,

"과연 사용자들이 저에게 하고 싶은 질문이 바로 떠오를까?" 라는 고민이 생겼습니다. 

 

결론은 그렇지 않다는 것이었고, 이로 인해 질문 리스트를 제안하는 UI를 추가하게 되었습니다. 해당 기능은 사용자 경험 흐름에 맞춰 다르게 노출되도록 구성했습니다. 

  • 홈 화면에서는 사용자가 서비스를 처음 접했을 때 자연스럽게 질문을 유도할 수 있도록, 리스트를 바로 노출하고, 
  • 채팅 화면에서는 대화 흐름을 방해하지 않도록, 아이콘 버튼을 통해 액션시트 형태로 리스트를 확인할 수 있게 했습니다. 

이러한 구조를 통해 사용자는 막연한 상태에서도 클릭 한 번으로 대화를 시작할 수 있게 구현했습니다.

 

 


API 설계 - tRPC + Zod

왜 tRPC를 선택했나요?

실무 작업을 했을 때 저는 백엔드에서 데이터를 직접 가공하진 않았지만 저는 프런트엔드 주도의 BFF(Backend for Frontend)스러운 개발 방식을 추구해 왔다고 생각합니다. 복잡한 백엔드 데이터를 클라이언트에서 한 번 가공하는 유틸함수를 만들었던 것처럼요.

 

개인 프로젝트 환경에서는 서버도 클라이언트 개발자도 저 혼자였기 때문에 백엔드를 구축하는 데 있어서 다음과 같은 고민을 하게 되었습니다:

  • 굳이 요청/응답 스키마를 따로 정의하지 않아도 된다면?
  • 서버와 클라이언트가 동일한 타입을 공유한다면?
  • 프런트엔드 코드 기반으로 백엔드 서비스까지 빠르게 구축할 수 있다면?

이러한 고민 끝에 tRPC를 집중적으로 살펴보았고, 특히 2025년에 v11로 업그레이드되면서 React Query 통합이 공식적으로 정비되었다는 점이 눈에 띄었습니다. 기존에 React Query를 사용하던 저에게도 거의 학습 곡선 없이 바로 적용 가능한 구조였고, 실제로 공식 문서에서 queryOptions, useSuspenseQuery 등을 활용한 예시가 명확하게 제공되어 있어 도입을 결정하게 되었습니다.

 

1. tRPC와 Zod의 결합

RPC와 Zod는 이번 프로젝트에서 타입 안전성과 유연성을 동시에 확보하기 위해 선택한 핵심 도구였습니다. 서버와 클라이언트가 모두 TypeScript로 작성되는 환경에서, 타입 중복 없이 런타임 유효성 검증과 정적 타입 추론을 동시에 만족시킬 수 있다는 점이 특히 매력적이었습니다.

  • 런타임 유효성 검사: 실제 API 요청에서 유효하지 않은 값이 들어오는 경우, 서버에서 사전에 방어 가능
  • 자동 타입 추론: 클라이언트에서 쿼리를 작성할 때 자동으로 타입이 추론되어 생산성 향상
  • 서버-클라이언트 간 타입 동기화: 별도의 DTO나 타입 정의 없이, 한 번의 정의로 타입 일관성 확보
	/**
	 * @mutation ip count 증가 (max limit 5)
	 * @param ip: 조회 할 ip
	 * @optional day: 조회할 날짜 (선택값) default 요청 시점 한국 날짜
	 */
	addIpCount: procedures.public
		.input(z.object({ ip: z.string(), day: z.string().optional() }))
		.mutation(async ({ input }) => {
			const { ip, day = getKSTDay() } = input

			const { data } = await supabaseClient
				.from('ip_question_count')
				.select('count')
				.eq('ip', ip)
				.eq('date', day)
				.maybeSingle()

			const curCount = data?.count || 0
			if (curCount >= DAILY_LIMIT && ip !== ME) {
				throw new TRPCError({
					code: 'BAD_REQUEST',
					message: `하루 최대 ${DAILY_LIMIT}개의 질문만 가능합니다.`,
				})
			}

			const updatedCount = curCount + 1

			await supabaseClient
				.from('ip_question_count')
				.upsert(
					{ ip, date: day, count: updatedCount },
					{ onConflict: 'ip,date' },
				)
			return { count: updatedCount }
		}),

 

2. Supabase 타입 자동 생성

Supabase 쿼리 결과의 타입을 신뢰성 있게 사용하기 위해, CLI 기반으로 DB 스키마를 TypeScript 타입으로 자동 생성하였습니다. 이 타입은 tRPC 내부에서 Supabase 쿼리 응답에 대한 보장으로 활용되며, 추후 리팩토링이나 타입 확장 시에도 안정적으로 관리할 수 있습니다.

 

3. React Query + Server Component에서의 prefetch

tRPC v11에서는 공식적으로 React Query와의 연동이 강화되어, queryOptions() 메서드를 통해 클라이언트/서버 어디서든 동일한 방식으로 API를 사용할 수 있습니다.

// 서버 컴포넌트에서 prefetch
await prefetch(trpc.getSuggestQuestions.queryOptions());

// 클라이언트에서는 Suspense 기반으로 사용
const { data } = useSuspenseQuery(trpc.getSuggestQuestions.queryOptions());


SSR과 CSR 간의 데이터 흐름을 타입 안전하게 일관된 방식으로 처리할 수 있었습니다.

 

회고

 

“AI로 뭘 해볼까?”라는 막연한 물음에서 시작된 프로젝트였습니다. 처음에는 AI가 너무 빠르게 발전하는 걸 보며, 프론트엔드 개발자의 역할도 언젠가는 대체될 수 있겠다는 두려움이 들었고, 그래서 오히려 직접 부딪혀보자는 마음으로 시작했습니다. 단순히 기술을 소비하는 입장이 아니라, 어떻게 서비스 안에 AI를 자연스럽게 녹일 수 있을지를 고민해보고 싶었습니다.

 

혼자서 프론트부터 백엔드, 흐름 설계와 UI까지 모두 책임지며 진행한 프로젝트였기에, 처음엔 기능 구현 위주로 빠르게 MVP를 만들었지만, 시간이 갈수록 더 많이 고민한 건 사용자 경험이었습니다. 모바일 환경에서 스크롤 위치나 메시지 정렬 같은 디테일을 조정하는 데 더 많은 시간을 썼고, 그 과정에서 예전 실무에서 겪었던 문제들이 떠오르며 큰 도움이 되었습니다.

 

AI 기술을 도입하는 것도 흥미로웠지만, 결국 가장 느낀 건 ‘기술은 사람을 향해야 한다’는 사실이었습니다. 사용자 흐름에 맞게 질문을 보완하고, 응답 속도와 대화 흐름을 개선하는 일들이 단순한 기술 적용보다 훨씬 중요한 문제라는 걸 느꼈습니다.

 

Chatbot 서비스를 개발하는 건 저에게도 처음이라 아직 부족한 점도 많고, 고민도 끊이지 않습니다. 당장 Chatbot 서비스 하나에만 몰두할 수 없는 상황이라 지금은 완벽하게 만들 수 없더라도, 사용자 로그를 기반으로 개선했던 지난 날 처럼, 앞으로도 실제 사용 흐름을 살펴보며 조금씩 더 나은 방향으로 다듬어갈 수 있다고 생각합니다. 지금 할 수 있는 만큼 천천히, 하지만 꾸준히 좋은 서비스를 만들어가고 싶습니다.

반응형