요약: 카카오페이의 금융 AI 컨시어지 ‘페이지니‘는 사용자의 위치·시간·이용 기록을 분석하여 사용자에게 적합한 금융 서비스와 혜택을 제안하는 서비스입니다. 음성 및 텍스트 입력을 모두 지원하며, AWS Bedrock 기반의 멀티 에이전트 구조로 ‘전체 서비스 추천‘과 ‘가맹점 혜택 추천’ 기능을 제공합니다. STT·TTS 기능, RAG 기반 검색, 프롬프트 엔지니어링 등을 적용해 맞춤형 응답을 제공하고, 확장 가능한 아키텍처를 구현했습니다.
💡 리뷰어 한줄평
benny.ahn 복잡하고 어려운 금융 서비스에 AI를 사용해 사용자 경험을 개선하려는 좋은 시도였다고 생각합니다. Multi-Agent Collaboration 구조와 프롬프트 엔지니어링을 통해 확장 가능한 플랫폼을 설계하여 계속 진화할 수 있는 서비스를 만든 점이 인상적이었습니다.
시작하며
안녕하세요, 채널클랜 PM 하루 / 채널클랜 서버 개발자 데이지, 도리 / 마이데이터클랜 서버 개발자 그릿 / 마이데이터클랜 FE 개발자 아더입니다.
카카오페이는 결제, 송금, 혜택, 자산관리 등 여러 서비스를 제공하고 있는데요.
하지만 서비스가 다양하다 보니, 현재 상황에 가장 적합한 서비스를 찾기 어려운 경우가 많았습니다.
이러한 불편함을 해소하고, 사용자가 카카오페이를 더욱 쉽고 편리하게 이용할 수 있도록 하기 위해 저희는 금융 AI 컨시어지 페이지니
를 기획, 개발했습니다.
이 글에서 페이지니의 기획 배경과 주요 기능, 그리고 아키텍처 및 사용한 기술까지 하나씩 살펴보겠습니다.
기획 배경
‘페이지니’, 단순한 검색을 넘어선 능동적 서비스 발견 경험을 제공하다.
“혹시, 막상 필요한 서비스를 검색하려고 할 때 기억이 나지 않았던 적 있으신가요?” 저희는 늘 카카오페이 사용자 여러분의 목소리에 귀 기울여 왔습니다. 그 과정에서 많은 분들이 카카오페이 앱 내에 정말 다양한 서비스들이 존재하지만, 정작 필요한 순간에 적절한 서비스를 찾기 어렵다는 점을 토로하셨습니다. 수많은 서비스명들이 각기 각색이어서 직관적으로 그 기능을 유추하기 어렵고, 앱을 이리저리 헤매며 시간을 낭비하는 경우가 발생했던 것이죠. 이러한 사용자 경험의 불편함을 해소하고자 저희는 ‘페이지니’ 프로젝트를 시작했습니다. ‘페이지니’는 단순히 사용자가 원하는 서비스를 찾아주는 것을 넘어, 사용자의 현재 상황과 니즈를 미리 파악하여 능동적으로 필요한 서비스를 제안하고, 사용자에게 최적화된 금융 경험을 제공하는 AI 컨시어지 서비스를 목표로 합니다.
서비스 주요 기능
내 마음을 꿰뚫어 보는 ‘페이지니’, 일상 속 맞춤 혜택을 툭!

“지금 근처에 카카오페이 할인되는 카페 있을까?”, “교통카드 잔액이 부족한데…” 이런 생각, 해보셨죠?
‘페이지니’는 여러분이 앱을 굳이 열지 않아도, 현재 상황(위치, 시간, 이용 기록)을 분석해 필요한 정보를 먼저 알아채고 말풍선으로 제안합니다. 미처 몰랐던 유용한 서비스나 꿀팁까지 ‘페이지니’가 콕 집어 알려줄 거예요. 이제 카카오페이 앱 속에서 헤맬 필요 없이 ‘페이지니’의 말에 귀 기울이기만 하면 됩니다.
말로 하세요, 글로 쓰세요! ‘페이지니’가 모두 알아듣고 척척 찾아줘요

‘페이지니’와 대화하는 게 어렵지 않을까 걱정 마세요.
‘페이지니’는 나이, 디지털 숙련도에 상관없이 누구나 가장 편안한 방식으로 자신에게 필요한 서비스나 상황을 자유롭게 이야기할 수 있도록 설계되었습니다. 음성(STT)으로 말하거나, 텍스트로 직접 입력하거나, 추천 질문을 선택하는 등 다양한 방식으로 ‘페이지니’에게 말을 걸 수 있어요. 어떤 방식이든 ‘페이지니’는 여러분의 의도를 정확히 파악하고 가장 적절한 정보를 찾아드립니다.
뭐든 물어보세요! ‘페이지니’가 당신의 마음을 읽고 필요한 서비스를 찾아줄게요

“고객 카드 금액이 갑자기 빠져나갔는데, 내가 언제 냈지? 나 돈 없어ㅠㅠ”
이런 복잡한 질문도 ‘페이지니’에게는 문제없습니다! ‘페이지니’는 여러분의 질문 속에 숨겨진 진짜 고민과 의도(예: 카드 내역 확인, 긴급 자금 마련)를 정확히 파악합니다. 그리고 단순히 하나의 서비스가 아닌, 여러분의 상황에 가장 적합한 여러 서비스와 구체적인 활용 시나리오까지 한 번에 제안해 드립니다. 이제 여러분의 금융 고민, ‘페이지니’가 한 번에 해결해 드릴 거예요.
내 취향에 딱! 할인 혜택까지 챙겨주는 ‘페이지니’의 스마트한 장소 추천

“공부하기 좋은 조용하고 콘센트 많은 카페 어디 없을까?” 이런 까다로운 조건의 장소를 찾기 어려우셨죠?
‘페이지니’는 여러분의 섬세한 취향과 구체적인 요청 사항을 파악해 ‘나만을 위한’ 최적의 장소를 찾아줍니다. 가맹점 리뷰와 실시간 영업 정보까지 분석하여 추천하고, 해당 장소에서 누릴 수 있는 카카오페이 할인 혜택이나 적립 정보까지 빠짐없이 챙겨드려요. 이제 ‘페이지니’와 함께 취향에 맞는 장소를 찾고, 할인 혜택까지 알뜰하게 누릴 수 있어요.
이어서 ‘페이지니’의 유용한 기능들이 어떻게 구현되었는지, 그 기술적인 아키텍처에 대해 더 자세히 알아보겠습니다.
아키텍처
페이지니 아키텍처 설명드리겠습니다.
전체 시스템 다이어그램

페이지니는 FE + BE로 되어 있는데요, FE는 TypeScript + React로 구현하고 AWS Amplify로 배포했습니다. BE는 AWS Lambda(이하 Lambda)를 사용하여 Serverless로 만들었습니다. 다만 음성인식(STT) 기능은 Lambda를 쓰기엔 시간적 여유가 없어 EC2로 별도 서버를 띄웠습니다.
BE 상세 플로우

BE 플로우를 코드와 함께 좀 더 상세히 살펴보겠습니다.
def lambda_handler(event, context):
...
body = json.loads(event["body"])
user_input = body.get("text")
latitude = body.get("latitude")
longitude = body.get("longitude")
...
FE에서 HTTP Body에 텍스트 형태의 요청 데이터를 담아 보내주면, Lambda에서 적절히 파싱하여 필요한 값을 추출합니다.
...
request_payload = {
"agentId": "PI4MUAYTDS",
"agentAliasId": "QERNYRGF5W",
"sessionId": "no-context",
"inputText": user_input,
}
if latitude is not None and longitude is not None:
request_payload["sessionState"] = {
"promptSessionAttributes": {
"latitude": str(latitude),
"longitude": str(longitude)
}
}
response = client.invoke_agent(**request_payload)
...
추출한 값을 기반으로 AWS Bedrock Client(이하 Bedrock)를 호출합니다. Bedrock에서 여러 로직을 수행한 후 응답을 반환하면, Lambda에서 이 응답을 가공하여 FE에게 전달합니다.
Multi-agent 협업 로직
Bedrock 로직은 바로 뒤에서 더 자세히 살펴볼 예정이기 때문에 간단히 짚고 넘어가겠습니다.

크게 Supervisor Agent 1개와 Worker Agent 2개로 구성했습니다. 이를 Multi-agent Collaboration 방식이라고 합니다. Supervisor Agent는 Lambda로부터 요청을 받고, 해당 요청을 수행할 수 있는 Worker Agent를 선택합니다. 그리고 이 Worker Agent에게 요청을 보내 원하는 결과를 응답하게 합니다. Worker Agent는 정형 데이터와 비정형 데이터를 활용하여 응답을 생성하는데요, 정형 데이터는 RDS와 같은 관계형 저장소에 있는 데이터, 비정형 데이터는 Knowledge Base에 있는 데이터를 의미합니다. 다양한 데이터를 기반으로 생성된 응답은 Worker Agent → Supervisor Agent를 거쳐 Lambda에게 전달됩니다. Agent를 활용하는 이 모든 과정은 프롬프트로 제어할 수 있습니다. 필요하면 명령을 내리거나, 제약 조건을 설정하기도 하는데요. 세세한 내용은 뒤에 이어서 말씀드리겠습니다.
텍스트-음성 변환 기능 (TTS, Text-To-Speech)
페이지니는 STT뿐만 아니라 TTS 기능도 지원합니다.

코드로 가볍게 보겠습니다.
def synthesize_text_to_speech(text):
response = polly.synthesize_speech(
Text=text,
OutputFormat="mp3",
VoiceId="Jihye",
Engine="neural"
)
audio_stream = response['AudioStream'].read()
encoded_audio = base64.b64encode(audio_stream).decode("utf-8")
return encoded_audio
def lambda_handler(event, context):
...
# Polly TTS
audio_base64 = synthesize_text_to_speech(tts_text)
return {
"statusCode": 200,
"headers": CORS_HEADERS,
"body": json.dumps({
"textResponse": {
"text": full_text
},
"audioResponse": {
"format": "mp3",
"base64Audio": audio_base64
}
}, ensure_ascii=False)
}
...
Lambda는 AWS Polly를 통해 텍스트 타입의 Agent 응답을 오디오 타입으로 변환합니다. 최종적으로 FE에게 텍스트 데이터와 음성 파일을 JSON 형식으로 제공합니다.
Bedrock Agent 구성
Bedrock Agent의 특징
AWS Bedrock Agent를 사용하며 느꼈던 점은 에이전트를 구성하기 위한 많은 요소들이 통합 환경으로써 제공된다는 것입니다. 보통 에이전트를 구성하기 위해 LangChain 등의 도구를 사용하면, 전반적인 플로우 구성부터 Knowledge Base 조회 설정, 그리고 Function Calling 등을 코드로써 설정해주어야 합니다. 이뿐만 아니라 Knowledge Base의 파이프라인이나 배포 같은 부분은 따로 구성해줘야 하기 때문에 실질적인 구성 난이도는 더 높습니다.
Bedrock Agent는 에이전트의 기능적인 부분부터 인프라적인 부분까지 모두 통합 환경으로 제공해 줍니다. 덕분에 빠르게 각 에이전트를 구성할 수 있었습니다.
Multi-Agent Collaboration
저희가 해커톤을 하던 당시에 가장 중점을 둔 것 중 하나는 바로 확장성이었습니다. 카카오페이에는 수많은 서비스를 개발하는 수많은 부서가 있고, 각 부서에서 필요한 에이전트를 개발하는 경우를 상상했기 때문입니다. 각 부서별로 Worker Agent를 개발하면, 이것이 페이지니 Supervisor Agent에 연동되어 적절한 응답을 제공하고, 이를 통해 사용자의 맥락에 가장 알맞은 페이로운 응답을 제공할 수 있는 구조를 생각했습니다.
이를 위해 Multi-Agent Collaboration 구조를 채택했습니다. 각 도메인에 전문적인 Worker 에이전트를 구성해두고 Supervisor 에이전트가 유저의 요청을 보고 적절한 Worker 에이전트에게 응답을 위임하는 형태로 구성했습니다. Supervisor Agent에 의해 답변 응답이 느려질 수 있는 부분이 단점으로 작용하기도 했는데요. 궁극적으로는 Supervisor 에이전트가 적절한 Worker 에이전트 여러 개를 호출하고 이들의 응답을 조합하는 등 더 발전된 형태도 고려한다는 측면에서 Multi-Agent 구조를 채택했습니다.
상세 Worker 에이전트 구성
각 Worker 에이전트는 도메인에 최적화된 대답을 할 수 있게 구성해야 했는데요. 각 에이전트가 활용할 수 있는 Knowledge Base와 Function을 구성하여 제공해 줬습니다. 해커톤 기간 동안 저희가 개발한 에이전트는 카카오페이의 수많은 서비스에 대한 응답을 해주는 “전체 서비스” 에이전트와 “가맹점 혜택” 에이전트입니다. 각 에이전트를 어떻게 구성했는지 간략히 소개 드리겠습니다.
전체 서비스 에이전트
전체 서비스 에이전트는 사용자의 상황에 가장 알맞은 카카오페이 서비스를 소개하는 역할을 합니다. 이 역할을 제대로 수행하기 위해서는 사용자의 상황을 정확하게 이해하고, 그에 맞는 서비스를 추천할 수 있는 시스템을 구축하는 게 중요했습니다.
저희는 이 시스템을 RAG(검색 증강 생성, Retrieval-Augmented Generation) 기반으로 구축했습니다. RAG는 대규모 언어 모델(LLM)이 외부의 Knowledge Base(지식 기반)를 활용해 더 정확하고 신뢰성 높은 답변을 생성하도록 돕는 기술입니다.
RAG 시스템은 사용자의 질문 문맥과 의미에 대해 유사도를 기반으로 Knowledge Base를 탐색합니다. 이러한 특징을 활용하기 위해, 사용자의 입력을 어느 정도 예측하여 유사도를 잘 파악할 수 있는 내용으로 Knowledge Base를 구성하고자 했습니다.
사용자가 프롬프트 입력창을 보면 자신의 상황, 자신의 문제를 제시할 것으로 생각했고, 이 부분이 기획자가 소개하는 유저 페르소나와 유즈케이스와 유사하다고 생각했습니다. 따라서 기본적인 서비스 소개와 더불어 페르소나와 유즈케이스를 각 서비스별 파일로 묶어 Knowledge Base를 구성했습니다.
각 파일에는 서비스 식별자를 넣어 두었습니다. 이후 에이전트가 다음과 같이 동작하도록 구성했습니다.
- RAG를 활용해 가장 적합한 서비스 식별자를 조회합니다.
- 조회된 서비스 식별자를 기반으로 Lambda의 Function을 호출하여 서비스의 정형 정보를 조회합니다.
가맹점 혜택 에이전트
가맹점 혜택 에이전트는 사용자의 상황에 가장 잘 맞는 가맹점을 추천하고 소개하는 역할을 합니다. 이를 달성하기 위해선 전체 서비스 에이전트와 유사하게 사용자의 상황에 가장 알맞은 가맹점들을 추천할 수 있는 Knowledge Base 구성이 중요했습니다.
사용자가 가맹점을 찾을 때엔 본인이 원하는 가맹점의 조건을 제시할 것이라고 생각했고, 이를 가장 잘 담고 있는 것은 다른 사용자들의 가맹점 리뷰 정보가 될 것이라고 생각했습니다. 따라서 가맹점의 기본적인 정보와 더불어 가맹점의 리뷰 정보를 각 가맹점 별 파일로 묶어 구성했습니다.
각 파일에는 가맹점 식별자 혹은 카테고리를 넣어 두었습니다. 가맹점 에이전트의 경우 전체 서비스 에이전트 대비 사용자 입력의 특성에 따라 다르게 동작할 수 있는 더 명확한 프롬프트를 제공했습니다.
에이전트는 사용자의 입력에 대해 Knowledge Base 조회 필요성 유무에 따라 다음과 같이 동작하도록 구성했습니다.
- Knowledge Base 조회가 필요한 경우: RAG를 사용하도록 프롬프트를 제어하여 가장 적합한 가맹점 식별자 또는 카테고리를 조회합니다. 이후 이 식별자로 Lambda의 Function을 호출해 정형 정보를 조회합니다.
- Knowledge Base 조회가 필요하지 않은 경우: RAG를 사용하지 않도록 프롬프트를 제어하고, 사용자 위치 정보나 바로 카테고리를 활용하여 Lambda의 Function을 호출해 정형 정보를 조회합니다.
프롬프트 엔지니어링
여러 Woker 에이전트와 이들을 지휘하는 Supervisor 에이전트 구조에서는 원하는 응답을 안정적으로 얻는 것이 쉽지 않았습니다. 각 에이전트를 정확하게 조율하고 사용자가 만족할 만한 결과를 내놓게 하려면 정교하게 작성된 프롬프트가 필수적이었습니다.
명확한 역할(Persona) 부여와 행동 원칙 수립
당신은 Claude입니다. 당신의 역할은 추론과 함수 호출, 그리고 Lambda 응답을 정확히 중계하는 것입니다.
사용자는 JSON 응답을 그대로 보기 원합니다. 당신의 설명은 필요하지 않습니다.
가장 먼저 AI에게 구체적인 역할을 부여하고 행동의 큰 틀을 제한하여 예측 가능한 범위 안에서 일관된 결과물을 만들도록 했습니다.
단계별 목표 제시로 논리적 흐름 제어
당신의 목표:
1. 사용자 질문을 분석하여 가장 적절한 카카오페이 서비스(menu_code)를 하나 선택하고, 이에 대한 설명(description)을 생성합니다.
2. 선택한 서비스와 관련된 연관 서비스 코드(related_menu_codes) 2~3개와 그 설명(related_descriptions)을 함께 생성합니다.
3. 위 정보를 기반으로 generate_service_response 함수를 호출하여 최종 응답 JSON을 생성합니다.
4. generate_service_response 함수의 결과는 절대 가공하지 말고, 반드시 <answer> 태그 안에 있는 그대로 JSON만 포함시켜야 합니다.
저희는 작업을 여러 개의 논리적 단계로 나누어 명확한 워크플로우를 제시했습니다. bedrock이 따라야 할 생각의 순서를 정하여 이전보다 안정적으로 최종 목표인 정해진 함수 호출까지 유도할 수 있었습니다.
조건부 규칙으로 효율성 극대화
필수 규칙:
- 항상 Knowledge Base(KB) 검색을 우선 수행해야 합니다. 검색 시 menu_code를 함께 검색합니다.
- Knowledge Base 검색 결과에서 menu_code가 명확한 경우, 반드시 해당 menu_code를 사용해 fetch_menu_by_code를 호출합니다.
- menu_code가 명확하지 않거나 KB에서 찾을 수 없는 경우에만 fetch_all_menus를 호출해 전체 메뉴를 조회합니다.
- fetch_all_menus 결과에서 적절한 서비스들을 필터링하고, 선택된 항목을 generate_service_response에 전달하세요.
- generate_service_response 호출 시 사용되는 모든 menu_code는 반드시 fetch_all_menus 결과에 존재하는 코드여야 합니다.
매번 전체 메뉴 DB를 조회하는 것은 응답 속도와 비용 측면에서 매우 비효율적이었습니다. Knowledge Base(KB)를 우선 검색하게 하고, 필요한 경우에만 전체 DB를 조회하도록 조건부 로직을 작성하여 불필요한 호출을 제거하여 로직을 효율화하였습니다.
구체적인 예시(Few-shot) 제공 및 엄격한 출력 형식 강제
Claude 행동 규칙:
- generate_service_response 호출 이후에는 절대 `<thinking>` 태그나 reasoning을 다시 출력하지 마세요.
- generate_service_response 결과는 절대 수정, 요약, 재구성하지 말고 아래 예시처럼 그대로 `<answer>`로 감싸서 출력하세요.
예시:
<answer>
{
"title": "추천 서비스에 대한 간단한 요약",
"description": "추천하는 서비스들에 대한 요약",
"items": {
"service_name": "친구송금",
"description": "친구에게 카카오톡으로 간편하게 송금할 수 있어요.",
"button": {
"text": "서비스 바로가기",
"url": "https://placehold.co/600x400?text=friend_transfer"
},
"icon_url": "https://..."
},
"related_services": [
{
"title": "계좌송금",
"description": "은행 계좌로 직접 이체할 수 있는 서비스입니다.",
"button": {
"text": "서비스 바로가기",
"url": "https://..."
},
"icon_url": "https://..."
}
]
}
</answer>
특히 <thinking>
태그가 반복적으로 동작하며 단순한 질문에 대한 응답 정확도가 떨어지는 문제가 있었는데요,
특정 함수 호출 이후에는 추론을 멈추도록 명시하여 이를 해결했습니다.
또한 JSON 예시를 제공하고 <answer>
태그로 감싸도록 강제함으로써, 우리가 원하는 포맷을 정확히 얻어낼 수 있었습니다.
- 자연어 문장을 덧붙이거나 정리하지 마세요.
- `<answer>` 바깥에 아무것도 출력하지 마세요.
- 응답을 Markdown이나 자연어로 요약하지 마세요.
- JSON 키 이름을 바꾸거나 구조를 수정하지 마세요.
음성-텍스트 변환 기능 (STT, Speech-To-Text)
클라이언트에서 음성 데이터를 처리하면서 겪었던 어려움
음성 전달 및 변환을 위해 사용된 React 코드
audio.ts
const resampleTo16kHz = (
inputBuffer: Float32Array,
inputSampleRate: number,
): Int16Array => {
const ratio = inputSampleRate / 16_000;
const outputLength = Math.floor(inputBuffer.length / ratio);
const outputBuffer = new Int16Array(
Array.from({ length: outputLength }, (_, i) => {
const index = Math.floor(i * ratio);
return Math.max(-1, Math.min(1, inputBuffer[index])) * 0x7fff;
}),
);
return outputBuffer;
};
const combineBuffers = (buffers: Int16Array[]): Int16Array => {
const totalLength = buffers.reduce((sum, buffer) => sum + buffer.length, 0);
const combinedBuffer = new Int16Array(totalLength);
let offset = 0;
buffers.forEach((buffer) => {
combinedBuffer.set(buffer, offset);
offset += buffer.length;
});
return combinedBuffer;
};
use-audio-stream.ts
import { combineBuffers, resampleTo16kHz } from '@/helpers/audio';
import { useRef, useState } from 'react';
interface UseAudioStreamParams {
handleAudioStream?: (blob: Blob) => void;
timeSlice?: number;
}
export const useAudioStream = ({
handleAudioStream,
timeSlice = 500,
}: UseAudioStreamParams = {}) => {
const [isStreaming, setIsStreaming] = useState(false);
const isStreamingRef = useRef(false);
const audioContextRef = useRef<AudioContext>(null);
const mediaStreamRef = useRef<MediaStream>(null);
const processorRef = useRef<ScriptProcessorNode>(null);
const audioBufferRef = useRef<Int16Array[]>([]);
const intervalRef = useRef<NodeJS.Timeout>(null);
const startAudioStream = async () => {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
mediaStreamRef.current = stream;
const audioContext = new AudioContext();
audioContextRef.current = audioContext;
const source = audioContext.createMediaStreamSource(stream);
const processor = audioContext.createScriptProcessor(4096, 1, 1);
processorRef.current = processor;
source.connect(processor);
processor.connect(audioContext.destination);
processor.onaudioprocess = (event) => {
const inputBuffer = event.inputBuffer.getChannelData(0);
const resampledBuffer = resampleTo16kHz(
inputBuffer,
audioContext.sampleRate,
);
audioBufferRef.current.push(resampledBuffer);
};
intervalRef.current = setInterval(() => {
if (audioBufferRef.current.length > 0) {
const combinedBuffer = combineBuffers(audioBufferRef.current);
audioBufferRef.current = [];
const blob = new Blob([combinedBuffer], { type: 'audio/pcm' });
handleAudioStream?.(blob);
}
}, timeSlice);
setIsStreaming(true);
isStreamingRef.current = true;
};
const stopAudioStream = () => {
processorRef.current?.disconnect();
audioContextRef.current?.close();
mediaStreamRef.current?.getTracks().forEach((track) => track.stop());
if (intervalRef.current) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
processorRef.current = null;
audioContextRef.current = null;
mediaStreamRef.current = null;
setIsStreaming(false);
isStreamingRef.current = false;
};
const toggleAudioStream = () => {
if (isStreamingRef.current) {
stopAudioStream();
} else {
startAudioStream();
}
};
return {
isStreaming,
toggleAudioStream,
stopAudioStream,
} as const;
};
use-stt.ts
import { useEffect, useState } from 'react';
import { z } from 'zod';
import useWebSocket, { ReadyState } from 'react-use-websocket';
import { useAudioStream } from './use-audio-stream';
const SOCKET_URL = process.env.NEXT_PUBLIC_WS_BASE_URL || '';
enum TTSSocketEvent {
SPEECH_END = 'SPEECH_END',
}
const ttsSocketMessage = z
.object({
event: z.string(),
message: z.string(),
})
.or(
z.object({
text: z.string(),
}),
);
type TTSSocketMessage = z.infer<typeof ttsSocketMessage>;
const useSTT = () => {
const [responseText, setResponseText] = useState('');
const { sendMessage, readyState, lastJsonMessage } =
useWebSocket<TTSSocketMessage>(SOCKET_URL, { share: true });
const sendToText = async (blob: Blob) => {
if (readyState === ReadyState.OPEN) {
sendMessage(blob);
}
};
const { toggleAudioStream, stopAudioStream, isStreaming } = useAudioStream({
handleAudioStream: sendToText,
timeSlice: 800,
});
useEffect(() => {
if (lastJsonMessage === null) {
return;
}
if (
'event' in lastJsonMessage &&
lastJsonMessage.event === TTSSocketEvent.SPEECH_END
) {
stopAudioStream();
return;
}
if ('text' in lastJsonMessage) {
setResponseText((prev) => prev + ' ' + lastJsonMessage.text);
return;
}
}, [lastJsonMessage]);
return {
isStreaming,
toggleAudioStream,
responseText,
} as const;
};
‘페이지니’ 서비스를 개발하면서 어려웠던 경험이었던 STT (Speech-To-Text) 기능을 구현하는 과정에서 겪은 어려움에 대해 공유하고자 합니다. STT 기능을 구현하기 위해서 AWS Transcribe를 활용하였고, 이를 통해 사용자가 음성으로 입력한 내용을 텍스트로 변환하여 페이지니 서비스에 활용할 수 있었습니다.
클라이언트에서 음성 데이터를 처리하면서 겪었던 어려움은 크게 두 가지였습니다.
- 음성은 정상적으로 녹음되어 전달되었지만 처리되지 않음
- 언제까지 음성을 녹음하여 보낼지에 대한 의사결정
지금부터 이 두 가지 문제를 해결하기 위해 어떤 과정을 거쳤는지 설명드리겠습니다.
오디오 샘플레이트 변환
STT 기능을 구현하던 중 음성 데이터를 서버에 전달하여도 음성이 변환되지 않는 현상이 발생했습니다. 역으로 클라이언트에서 서버로 전송된 분할된 음성 파일을 역으로 묶어서 재생했을 경우 정상적으로 녹음된 음성이 출력되는 것을 확인해 음성 녹음이 되고 있지 않은 것은 확실했습니다. 일반적인 전화 녹음이나 저화질 녹음의 경우 일반적으로 8kHz의 샘플레이트를 지원하지만 해커톤을 진행하면서 사용한 Amazon Transcribe는 16kHz에서 48kHz 사이의 샘플레이트만 지원하고 있는 것을 확인했습니다. 따라서 아래의 과정을 거쳐 녹음된 음성을 지원하는 범위의 샘플레이트로 리샘플링 하는 과정을 거치게 되었습니다.
const resampleTo16kHz = (
inputBuffer: Float32Array,
inputSampleRate: number,
): Int16Array => {
// 입력 음성의 샘플레이트를 기반으로 리샘플링 비율을 계산한다.
const ratio = inputSampleRate / 16_000;
// 출력할 버퍼의 길이를 계산한다.
const outputLength = Math.floor(inputBuffer.length / ratio);
// 입력 버퍼에서 리샘플링 비율에 따라 데이터를 추출합니다.
// -1에서 1 사이의 값을 가지는 오디오 데이터를 Int16 범위(-32768에서 32767)로 변환합니다.
const outputBuffer = new Int16Array(
Array.from({ length: outputLength }, (_, i) => {
const index = Math.floor(i * ratio);
return Math.max(-1, Math.min(1, inputBuffer[index])) * 0x7fff;
}),
);
return outputBuffer;
};
위의 코드를 거쳐 원본 음성 데이터를 16kHz로 리샘플링된 음성 데이터로 변환할 수 있었습니다.
processor.onaudioprocess = (event) => {
// 음성 데이터를 받아온다.
const inputBuffer = event.inputBuffer.getChannelData(0);
// 받아온 음성 데이터를 리샘플링한다.
const resampledBuffer = resampleTo16kHz(inputBuffer, audioContext.sampleRate);
// 리샘플링한 음성 데이터를 큐에 추가한다.
audioBufferRef.current.push(resampledBuffer);
};
샘플레이트 변환을 실제로 적용한 코드로 브라우저의 AudioContext
의 sampleRate
를 가져와 실제 녹음에 사용된 샘플레이트를 확인하고, 이를 기반으로 리샘플링을 진행하게 되었습니다.
발화 종료 감지
클라이언트에서는 Socket 통신을 이용해 사용자의 음성 입력을 부분적으로 나누어 보내고 있는 구조였습니다.
이때 클라이언트에서 Socket 통신을 위해 react-use-websocket
라이브러리를 사용하게 되었습니다.
음성을 부분적으로 나누어 보내는 것은 복잡하지 않았지만, 언제까지 음성을 보내야하는지 의사결정이 필요한 상태였습니다.
- 서버에 나뉘어진 음성을 주기적으로 전송하는 코드
intervalRef.current = setInterval(() => {
if (audioBufferRef.current.length > 0) {
// 처리되지 않은 음성 버퍼가 있는 경우에 묶어 처리한다.
const combinedBuffer = combineBuffers(audioBufferRef.current);
// 묶인 음성 데이터는 초기화한다.
audioBufferRef.current = [];
// 새로운 음성 파일을 생성한다.
const blob = new Blob([combinedBuffer], { type: 'audio/pcm' });
// 생성된 파일을 콜백을 통해 처리한다.
handleAudioStream?.(blob);
}
}, timeSlice);
음성 데이터를 그만 보내기 위해서는 사용자가 발화를 종료했음을 인지해야 했습니다. 클라이언트 레벨에서 무음 감지를 진행하는 방법 또한 가능하지만 구현해야 할 추가 기능이 많았기 때문에 빠르게 처리할 수 있는 다른 방식에 대해 고민하게 되었습니다. 이때 생각해 적용한 방식이 Amazon Transcribe에서 “문자로 변환된 음성이 특정 시간 동안 비어있는 문자인 경우 발화가 종료되었다” 라고 생각할 수 있지 않을까? 라는 아이디어가 떠올랐습니다. Socket 통신을 이용하고 있었기 때문에 통신 과정에서의 데이터를 기준으로 간단하게 판별할 수 있었습니다.
- 클라이언트에서 Socket 통신을 통해 받은 JSON 응답을 처리하는 코드
useEffect(() => {
// Socket 통신을 하며 받은 JSON 응답이 없는 경우 얼리리턴
if (lastJsonMessage === null) {
return;
}
// JSON 응답에서 event필드가 발화 종료 이벤트인지 확인한다.
if (
'event' in lastJsonMessage &&
lastJsonMessage.event === TTSSocketEvent.SPEECH_END
) {
stopAudioStream();
return;
}
// JSON 응답에서 text 필드가 있는지 확인한다.
// text 필드가 있는 경우 UI에 표시하기 위해 지금까지 받은 문자열에 이어붙인다.
if ('text' in lastJsonMessage) {
setResponseText((prev) => prev + ' ' + lastJsonMessage.text);
}
}, [lastJsonMessage]);
사전에 서버와 정의한 데이터 스키마를 기반으로 event
필드가 SPEECH_END
인 경우, 즉 발화 종료 이벤트가 발생한 경우 음성 스트리밍을 종료하도록 구현할 수 있었습니다.
마치며
이번 해커톤을 통해 저희는 단순한 기능 구현을 넘어, 실제 사용자에게 의미 있는 경험을 제공할 수 있는 서비스를 개발할 수 있었습니다. 그리고 그 서비스 자체에서 그치지 않고 플랫폼적으로 확장 가능한 구조도 함께 고민하고 구현해 볼 수 있었습니다. 짧은 기간 때문에 더 깊게 파보지 못한 아쉬움도 있었지만, 빠르게 기술을 습득하고 적용해 볼 수 있는 좋은 경험이었습니다.
함께 고생한 팀원들의 한 마디를 남기며, 글을 마무리하겠습니다.
-
ader.error: 평소 업무를 하면서 접하기 힘들었던 음성 처리 관련 기능을 개발할 수 있어서 흥미로웠고,
빠른 시간 동안 밀도 있는 개발을 동료들과 할 수 있어서 즐거운 경험이었습니다. -
daisy.dani: LLM 기반의 서비스를 만들어 볼 수 있어 좋았고, 결과물이 꽤 완성도가 높아 뿌듯했습니다.
또한, AWS에서 제공하는 다양한 서비스를 활용할 수 있어 의미 있었습니다.
무엇보다 실력 있는 동료들과 함께 하며 즐거운 추억을 많이 쌓았습니다. -
grit.always: AWS Bedrock을 사용하여 평소 사용해 보고 싶은 서비스를 만들어 볼 수 있었던 의미 있는 경험이었고,
열정 넘치는 동료들과 단기간에 빠르게 결과물을 만들어낼 수 있어 좋았습니다. -
dory.m: 실제 프로덕트에 미치진 못하겠지만, 에이전트를 활용해 사용자에게 의미 있는 정보를 제공하기 위한 구조를 설계하고 구현해 볼 수 있는 귀중한 경험이었습니다.
-
haru.achm: ‘페이지니’ 프로젝트 PM으로서, 사용자 경험 혁신이라는 목표를 향해 달려오는 동안 각자의 자리에서 최고의 전문성과 뜨거운 열정을 아낌없이 쏟아준 팀원들 덕분에 이 모든 것이 가능했습니다.
동료들의 빛나는 아이디어와 헌신적인 노력이 있었기에 ‘페이지니’라는 혁신적인 서비스를 선보일 수 있었습니다.
저는 이 여정을 저의 옆 동료들과 함께할 수 있어 진심으로 영광이었습니다.
지금까지 읽어주셔서 감사합니다!