들어가며
- 면접 준비, 특정 주제 토론, 가벼운 잡담까지. 각자 입장을 가진 AI 참여자들과 한 방에서 대화하고, 끝나면 내 말하기에 대한 피드백을 받는 앱을 만들기로 했다.
- 구현에 들어가기 전에 요구사항, 구조, 모델 선택, 개발 방식, 그리고 쇼츠 영상 계획까지 설계를 먼저 정리한 포스팅
- 모델 라인업과 가격은 현시점(2026-10) OpenAI 공식 문서 기준이다. 모델은 자주 바뀌므로 구현 직전에 한 번 더 확인한다.
결론부터: 만들 수 있나?
- 만들 수 있다. 범위는 나 혼자 쓰는 안드로이드 PoC로 정했다. 스토어 출시는 하지 않는다.
- OpenAI 키는 기기 안에 둔다. PoC라서 서버 없이 이전
stock-summary-app처럼 키를 암호화해 저장한다. (아래 “키 관리” 참고)
- OpenAI 키는 기기 안에 둔다. PoC라서 서버 없이 이전
- 안드로이드 쪽은 이전 앱에서 쓰던 환경(SDK,
Galaxy_S23에뮬레이터, Roborazzi, Maestro)을 그대로 쓸 수 있다.
앱 이름과 로고: spar.
- spar는 복싱의 스파링에서 왔다. 실전 전에 상대와 가볍게 주고받으며 연습한다는 뜻이고, 영어로는 “말다툼하다, 논쟁하다”라는 의미도 있다. 면접 연습, 토론, 잡담을 한 단어로 묶을 수 있다.
- 소문자 + 마침표(
spar.) 표기로 짧고 단정하게 간다. 마침표는 “할 말은 했다”는 느낌이고, 앱 안에서는 라임색 포인트로 쓴다. - 로고는 두 말풍선이 겹친 모양이다.
- 라임(
#C6FF3D) = 나, 바이올렛(#7C5CFF) = AI 참여자 - 겹친 흰 부분 = 서로 다른 입장이 부딪히는 지점. 이 앱에서 연습하려는 바로 그 구간이다.
- 다크 배경 + 네온 라임 + 바이올렛은 요즘 AI 제품에서 흔히 보이는 조합이라 트렌디하면서도 아이콘 크기에서 잘 읽힌다.
- 라임(
- 태그라인: “AI 스파링 파트너와 말로 붙는 연습장”
요구사항 정리
| ID | 기능 | 내용 |
|---|---|---|
| FR-1 | 대화방 초안 생성 | 유저가 한 줄 프롬프트를 쓰면 주제, 참여자 수, 각 참여자의 입장·말투를 LLM이 초안으로 만들어 준다. |
| FR-2 | 대화방 설정 편집 | 초안을 그대로 쓰거나 주제, 모드, 언어, 참여자 추가·삭제, 입장, 내 역할을 직접 고친다. |
| FR-3 | 대화 | 유저는 텍스트 또는 음성으로 말한다. AI 참여자는 각자의 입장으로 대답한다. AI 참여자가 1명이면 실시간 음성 모드도 쓸 수 있다. |
| FR-4 | 음성 출력 (옵션) | AI 참여자 발언을 참여자별 목소리로 읽어 준다. |
| FR-5 | 히스토리 | 대화방별로 대화 기록이 남는다. 진행 중(ACTIVE)인 방은 나중에 이어서 하고, 종료(ENDED)된 방은 읽기 전용으로 다시 본다. 같은 연습을 또 하려면 “다시 연습하기”로 다음 회차를 만든다. |
| FR-6 | 종료 피드백 | “대화 종료”를 누르면 잘한 점, 못한 점, 고쳐야 할 점을 실제 발언을 근거로 정리해 준다. |
| FR-7 | 회차별 성장 그래프 | 같은 연습을 반복하면 피드백 점수를 회차별 그래프로 보여 준다. |
| FR-8 | 다국어 | 대화방마다 언어를 정한다. 영어 면접처럼 한국어가 아닌 대화도 연습할 수 있다. |
| FR-9 | 설정 | API 키, 모델 프리셋(절약/기본/정밀), 음성 출력 ON/OFF, 음성 원본 보관 ON/OFF, 앱 기본 언어 |
- 대화 모드는 세 가지로 시작한다. 모드에 따라 진행 방식과 피드백 기준이 달라진다.
| 모드 | 예시 | 진행 방식 | 피드백 기준 |
|---|---|---|---|
INTERVIEW |
“카카오 백엔드 2차 면접, 압박 면접관 1 + 실무 면접관 1” | 면접관이 질문하고 유저가 답한다. 꼬리 질문이 나온다. | 질문 의도 파악, 구조(STAR), 근거, 간결성 |
DEBATE |
“주 4일제 도입, 나는 찬성” | 찬반 참여자가 반박한다. 사회자가 필요하면 초안이 AI 참여자 중 하나로 만든다(인원 제한에 포함). | 논리, 반박 대응, 근거, 상대 논점 인정 |
CHAT |
“여행 좋아하는 친구 둘이랑 수다” | 자유롭게 주고받는다. | 대화 흐름, 질문하기, 리액션 (가볍게) |
사용자 흐름
[홈: 대화방 목록]
│ + 새 대화방
▼
[프롬프트 입력] "내일 토스 서버 면접. 깐깐한 리드 한 명, 컬처핏 보는 사람 한 명"
│ 초안 생성 (LLM, 구조화 출력). 너무 애매하면 질문을 하나 되묻고, 답을 받아 다시 생성
▼
[초안 확인/편집] 주제 · 모드 · 언어 · 내 역할 · 참여자 카드(이름/입장/말투/목소리)
│ 시작
▼
[대화방] 말풍선 타임라인 + 입력창(텍스트 / 길게 눌러 음성)
│ 대화 종료
▼
[피드백 리포트] 총평 · 잘한 점 · 못한 점 · 이렇게 바꿔 말하기 · 점수 · 다음 연습 제안
│ 각 항목을 누르면 근거가 된 내 발언으로 이동
▼
[홈] 대화방 카드에 "종료됨 · 피드백 있음" 표시
아키텍처
기술 스택: 안드로이드 네이티브 (Kotlin + Jetpack Compose)
- 이전
stock-summary-app과 같은 스택이다. OpenAI 클라이언트, Keystore 키 저장, Room, DataStore, 테스트 구조(FakeBackend, Roborazzi, Maestro)를 그대로 가져온다. - 구현은 Claude Code가 전부 하므로 Claude Code가 빌드·테스트·실행까지 자동으로 돌릴 수 있는 구조가 중요하다. Gradle 하나로 빌드·단위 테스트를 돌리고, 에뮬레이터(
Galaxy_S23)로 UI·E2E 테스트를 돌린다.
패키지 구조
spar/app/src/main/.../
├── data/db/ # Room: room, participant, message, room_memory, feedback, feedback_score, api_usage
├── data/prefs/ # DataStore 설정, Keystore 로 암호화한 API 키
├── data/net/ # OpenAI Responses / Audio API / Live API(WebSocket) 클라이언트
├── audio/ # 녹음(MediaRecorder), 재생(ExoPlayer), 실시간용 PCM 입출력(AudioRecord/AudioTrack)
├── domain/
│ ├── draft/ # 프롬프트 → 대화방 초안
│ ├── turn/ # 다음 발언자 결정 + 페르소나 발언 생성
│ ├── memory/ # 긴 대화 롤링 요약
│ ├── voice/ # STT / TTS 호출, 재생 큐
│ ├── live/ # 1:1 실시간 세션 (gpt-live-1)
│ ├── feedback/ # 종료 피드백 생성
│ └── progress/ # 회차별 점수 집계
└── ui/ # Compose 화면: Home, Draft, Room, Feedback, Progress, Settings
데이터 모델 (로컬 DB)
- 서버 없이 기기 안에 저장한다. 히스토리는 대화방 단위다.
| 테이블 | 주요 컬럼 |
|---|---|
room |
id, seriesId(1회차 대화방 id), round(회차), title, mode, topic, goal, userRole, language, scoringSet(채점 항목 묶음), rules(JSON), status(ACTIVE/ENDED), preset, createdAt, updatedAt |
participant |
id, roomId, name, role, stance, speakingStyle, voice, isUser, sortOrder (openingSpeaker는 sortOrder 0번으로 저장) |
message |
id, roomId, participantId, text, inputType(TEXT/VOICE/LIVE), audioPath?, createdAt |
room_memory |
roomId, summary, coveredUntilMessageId (긴 대화 롤링 요약) |
feedback |
id, roomId, model, schemaVersion, json(리포트 본문), createdAt |
feedback_score |
feedbackId, criterion(채점 항목 ID), score (그래프용으로 점수만 따로 저장) |
api_usage |
id, roomId, task(DRAFT/DIRECTOR/PERSONA/SUMMARY/FEEDBACK/STT/TTS/LIVE), model, inputTokens, cachedTokens, outputTokens, reasoningTokens, audioSeconds, createdAt (대화방별 비용 표시용) |
- 피드백 본문은 구조가 바뀔 수 있어서 JSON 한 칸에 넣는다. 화면은
schemaVersion을 보고 그린다. - 토큰·음성 사용량은 메시지가 아니라 API 호출 단위로
api_usage에 쌓는다. 진행자·요약·피드백처럼 메시지와 1:1로 묶이지 않는 호출이 많기 때문이다. - 음성 파일은 앱 전용 폴더에 두고, 설정에서 “음성 원본 보관”을 끄면 텍스트만 남긴다.
LLM 설계
1) 대화방 초안 생성 (FR-1)
- 유저 프롬프트 한 줄을 받아 Structured Outputs(JSON Schema) 로 초안을 만든다. 파싱 실패가 없도록 스키마를 강제한다.
{
"clarifyingQuestion": null,
"title": "토스 서버 개발자 2차 면접",
"mode": "INTERVIEW",
"language": "ko",
"topic": "서버 개발자 기술 + 컬처핏 면접",
"goal": "대규모 트래픽 경험과 장애 대응을 설득력 있게 설명하기",
"userRole": "지원자",
"participants": [
{ "name": "민재", "role": "테크 리드", "stance": "답변의 근거와 수치를 끝까지 파고든다", "speakingStyle": "짧고 건조한 존댓말, 꼬리 질문 위주", "voice": "cedar" },
{ "name": "서연", "role": "피플 매니저", "stance": "협업 방식과 실패 경험에서 태도를 본다", "speakingStyle": "부드럽지만 구체적인 사례를 요구", "voice": "marin" }
],
"openingSpeaker": "민재",
"rules": ["한 번에 질문 하나", "답변이 모호하면 꼬리 질문"]
}
- 프롬프트가 애매하면(예: “토론하고 싶어”) 모드·주제를 추측하지 말고
clarifyingQuestion에 되물을 질문을 넣는다. 이때 나머지 필드는 모두null이고 앱은 무시한다. 유저가 답하면 원래 프롬프트 + 답으로 초안을 다시 만든다. 평소에는clarifyingQuestion이null이다. - 스키마 제약 (Structured Outputs는 모든 필드를 선언해야 하므로 선택 값은
null허용으로 둔다)mode:INTERVIEW/DEBATE/CHAT중 하나language:ko,en,ja같은 언어 코드. 프롬프트에서 추론한다.participants: AI 참여자만 1~4명. 유저를 더하면 2~5명이다. 6명 이상은 발언 순서가 꼬이고 비용이 빠르게 늘어난다.voice: TTS가 지원하는 목소리 중 하나 (아래 “목소리” 참고)
2) 대화 진행 (FR-3): 진행자 + 페르소나 분리
- 참여자가 여럿일 때 한 번의 호출로 모든 참여자 대사를 만들면 입장이 섞인다. (A가 B 말투를 따라 하거나, 모두가 비슷한 결론으로 수렴) 그래서 호출을 두 단계로 나눈다.
유저 발언 저장
│
▼
[진행자(Director)] 가벼운 모델, 구조화 출력
입력: 대화방 설정 + 최근 대화 + 롤링 요약
출력: { "nextSpeakers": ["민재"], "intent": "수치 근거 꼬리 질문", "handBackToUser": true }
메타 요청은 intent로 표현. 예) "다시 말해 줘" → intent: "직전 발언을 더 쉽게 다시 말하기"
│
▼
[페르소나 발언] 참여자마다 별도 호출, 스트리밍
프롬프트 순서: 공통 규칙 + 대화방 설정 (모든 호출에서 동일, 캐시됨)
→ 페르소나 블록 "너는 민재다. 입장: … 말투: …"
→ 롤링 요약 → 최근 N턴 → 진행자 의도
│
▼
메시지 저장 → (음성 ON이면) TTS 큐에 넣기
- 진행 규칙
- AI끼리는 최대 2턴까지만 이어서 말하고 반드시 유저에게 넘긴다. 연습 앱이므로 유저 발언 비중이 높아야 한다.
- 토론 모드에서는 진행자가 반대 입장 참여자를 우선 고른다.
- 유저가 “잠깐”, “다시 말해 줘” 같은 메타 발언을 하면 진행자가 처리한다.
- 컨텍스트 관리
- 공통 규칙과 대화방 설정을 프롬프트 맨 앞에 글자 하나 다르지 않게 고정해 프롬프트 캐싱을 받는다. (캐시 입력은 일반 입력의 1/20 가격, 접두부가 1,024토큰 이상이어야 캐시된다)
- 페르소나 블록은 그 뒤에 둔다. 앞에 두면 참여자마다 접두부가 달라져 캐시를 나눠 쓰지 못한다.
- 최근 20턴은 원문, 그 이전은
room_memory롤링 요약으로 대체한다. 요약은 20턴마다 가벼운 모델로 갱신한다.
- OpenAI Responses API를 쓴다. 최신 모델의 도구 호출과 구조화 출력이 Responses API 기준이라서다.
3) 종료 피드백 (FR-6)
- 대화 전체(메시지 ID 포함)와 모드별 채점 항목을 넣고, 구조화 출력으로 받는다. 예시는 뒤의 “회차별 성장 그래프” 예시 중 2회차 피드백이다. 2회차부터는 지난 회차의
nextPractice도 함께 넣어 이번에 고쳤는지 확인하게 한다.
{
"summary": "기술 질문에는 강했지만 장애 회고에서 본인 역할이 흐려졌다.",
"scores": [
{ "criterion": "structure", "score": 3 },
{ "criterion": "evidence", "score": 3 },
{ "criterion": "conciseness", "score": 3 },
{ "criterion": "intent", "score": 4 }
],
"strengths": [{ "point": "트래픽 수치를 구체적으로 제시", "messageIds": ["m12"] }],
"weaknesses": [{ "point": "꼬리 질문에 같은 답을 반복", "messageIds": ["m18", "m20"] }],
"rewrites": [{
"messageId": "m18",
"before": "그때는 팀 전체가 같이 대응해서…",
"after": "제가 맡은 건 DB 커넥션 풀 고갈 원인 분석이었고, 30분 안에 …",
"why": "'우리'가 아니라 '나'의 행동과 결과를 먼저 말해야 한다"
}],
"naturalExpressions": [],
"previousPracticeCheck": [{
"suggestion": "'우리' 대신 '나'로 말하기",
"result": "'우리' 5번 → 1번으로 줄었다",
"messageIds": ["m31"]
}],
"nextPractice": ["장애 회고를 STAR로 3분 안에 말하기"]
}
- 요구사항의 세 가지는 이렇게 대응한다: 잘한 점 =
strengths, 못한 점 =weaknesses, 고쳐야 할 점 =rewrites(이렇게 바꿔 말하기) +nextPractice(다음 연습 제안). - 근거 없는 칭찬/지적을 막기 위해
strengths,weaknesses,rewrites,naturalExpressions,previousPracticeCheck의 각 항목에 근거 발언 ID를 필수로 둔다. 화면에서 항목을 누르면 해당 발언으로 이동한다. - 스키마의 메시지 ID는 그 대화의 유저 발언 ID 목록(enum) 으로 제한하고, 파싱 후에도 유저 발언인지 한 번 더 확인해 아닌 항목은 버린다.
- AI 참여자의 발언은 평가 대상이 아니다. 유저 발언만 평가하라고 명시한다.
- 필드 설명
scores: 아래 “채점 항목” 표의 ID만 쓸 수 있다(enum). 모드·언어마다 스키마를 따로 만들어 항목 수를 고정한다. 잡담 모드는 빈 배열이다.naturalExpressions: 외국어 연습일 때만 채운다.[{messageId, before, after}]형태로 “더 자연스러운 표현”을 준다.previousPracticeCheck: 2회차부터 채운다. 1회차는 빈 배열이다.
- 음성으로 말한 발언은 STT 결과가 들어가므로 “음, 어” 같은 군말 빈도도 피드백에 넣을 수 있다. 다만 전사 모델이 군말을 지울 수 있어서, 전사 힌트에 군말이 들어간 예시를 넣어 남기도록 유도한다. (구현 때 실제로 남는지 확인)
4) 회차별 성장 그래프 (FR-7)
- 무엇을 묶나: 같은 연습을 반복한 대화방들. 자동으로 “비슷한 주제”를 찾아 묶지는 않는다. 잘못 묶이면 그래프가 의미 없어지기 때문이다.
- 어떻게 묶나: 피드백 화면의 “다시 연습하기” 버튼으로만 묶는다. 누르면 주제·참여자·모드·언어를 그대로 복사한 새 대화방이 생기고, 이전 대화방과 같은 묶음의 다음 회차가 된다. 새로 만든 대화방은 항상 새 묶음의 1회차다.
| 날짜 | 한 일 | 결과 |
|---|---|---|
| 10/12 | “토스 서버 면접” 대화방을 만들어 연습 → 종료 | 1회차 피드백: 구조 2, 근거 3, 간결성 2, 의도 파악 3 |
| 10/14 | 피드백 화면에서 “다시 연습하기” → 같은 면접관들과 연습 → 종료 | 2회차 피드백: 구조 3, 근거 3, 간결성 3, 의도 파악 4 |
| 10/16 | 다시 “다시 연습하기” | 3회차 피드백 + 1~3회차 그래프 |
- 무엇을 그리나: 피드백을 만들 때 항목별로 1~5점을 매긴다. 회차끼리 비교할 수 있어야 하므로 채점 항목은 모드·언어마다 고정된 목록을 쓴다. 회차마다 항목이 바뀌면 점수를 비교할 수 없다.
- 외국어 연습은 “대화방 언어 ≠ 앱 기본 언어”인 경우다. 이때는 언어 항목 2개를 더한다.
- 어떤 채점 항목 묶음을 쓸지는 대화방을 만들 때 정해
room.scoringSet에 저장하고, “다시 연습하기” 때 그대로 복사한다. 나중에 앱 기본 언어를 바꿔도 같은 묶음 안에서는 항목이 바뀌지 않는다.
| 연습 종류 | 채점 항목 (ID) |
|---|---|
| 면접 | 구조(structure), 근거(evidence), 간결성(conciseness), 질문 의도 파악(intent) |
| 토론 | 논리(logic), 반박 대응(rebuttal), 근거(evidence), 상대 논점 인정(acknowledge) |
| 외국어 연습 (면접·토론 공통 추가) | 문법(grammar), 자연스러움(naturalness) |
| 잡담 | 점수 없음, 그래프 없음 (가볍게 쓰는 모드) |
- 어디서 보나: 2회차부터 피드백 화면 아래에 그래프가 붙는다. 가로축은 회차, 세로축은 점수(1~5), 항목마다 선 하나다. 위 예시라면 이렇게 그려진다.
| 항목 | 1회차 | 2회차 | 변화 |
|---|---|---|---|
| 구조 | 2 | 3 | ▲ |
| 근거 | 3 | 3 | - |
| 간결성 | 2 | 3 | ▲ |
| 질문 의도 파악 | 3 | 4 | ▲ |
- 그래프 아래에는 지난 회차의 “다음 연습 제안”을 이번에 고쳤는지(
previousPracticeCheck)를 함께 보여 준다. 예) “1회차 제안: ‘우리’ 대신 ‘나’로 말하기 → 2회차: ‘우리’ 5번 → 1번” - 차트는 선 그래프 하나라 Compose
Canvas로 직접 그린다.
5) 다국어 (FR-8)
- 대화방마다
language를 둔다. 초안 생성 때 프롬프트에서 추론한다. 예) “구글 영어 면접 연습” →en - 언어에 따라 바뀌는 곳
| 위치 | 동작 |
|---|---|
| AI 참여자 | 대화방 언어로만 말한다. 유저가 한국어로 섞어 말해도 대화방 언어로 답한다. |
| STT | gpt-transcribe에 대화방 언어를 언어 힌트로 준다. |
| TTS / 실시간 모드 | 대화방 언어로 말한다. 참여자별 목소리는 그대로 쓴다. |
| 피드백 | 설명은 앱 기본 언어(한국어)로, rewrites.after와 naturalExpressions.after는 대화방 언어로 쓴다. |
- 외국어 연습(대화방 언어 ≠ 앱 기본 언어)이면 채점 항목에 문법·자연스러움을 더하고, 피드백에
naturalExpressions(더 자연스러운 표현)를 채운다.
모델 선택 (2026-10 기준)
- 원칙: task 수준에 맞는 가장 싼 모델. 결과 품질이 사용자 경험을 좌우하는 곳(페르소나 대사, 피드백)에만 상위 모델을 쓴다.
| Task | 모델 | 설정 | 이유 |
|---|---|---|---|
| 대화방 초안 생성 | gpt-6.1-sol |
reasoning medium, Structured Outputs |
한 번만 호출하고, 참여자 입장 설계 품질이 대화 전체를 좌우한다 |
| 진행자(다음 발언자 결정) | gpt-6-luna |
구조화 출력 | 매 턴 호출되는 짧은 분류 작업 |
| 페르소나 발언 | gpt-6.1-sol (기본) / gpt-6-luna (절약) |
reasoning low, 스트리밍 |
한국어 뉘앙스와 입장 일관성이 핵심. 절약 프리셋에서는 luna |
| 롤링 요약 | gpt-6-luna |
대량·단순 작업 | |
| 종료 피드백 | gpt-6.1-sol (절약·기본) / gpt-6-astra (정밀) |
reasoning high |
대화당 1회라 절약 프리셋에서도 sol을 쓴다. 정밀 프리셋은 플래그십 |
| 음성 입력(STT) | gpt-transcribe |
언어 힌트(대화방 언어), 키워드 힌트(주제·참여자 이름) | 파일 전사 고정확도, $0.0045/분 |
| 음성 출력(TTS) | gpt-4o-mini-tts |
참여자별 voice + 말투 instructions |
현재 문서상 최신 TTS. 말투 지시 가능 |
| 실시간 음성 모드 (2단계, 1:1만) | gpt-live-1 |
Live API (WebSocket) | 끼어들기 처리되는 양방향 음성. $0.05/분 |
| 실시간 모드 백엔드 | gpt-6-luna (절약·기본) / gpt-6-sol (정밀) |
Responses 위임 | 깊은 추론을 위임받는 모델. 문서 권장 조합이고, gpt-6.1-sol 지원 여부는 구현 때 확인 |
- 참고 가격 (100만 토큰당 입력/출력):
gpt-6-astra$10/$50,gpt-6.1-sol$2/$10 (캐시 입력 $0.10),gpt-6-luna$0.10/$0.50 - 이전 앱에서 쓰던
gpt-6-sol은gpt-6.1-sol로 올린다. - 모델 ID는 코드에 박지 않고 설정의 프리셋 → 모델 매핑 테이블로 둔다. 모델이 바뀌면 매핑만 고친다.
음성은 왜 2단계로 나누나?
- 1단계: 녹음 → STT → 텍스트 파이프라인 → TTS
- 참여자가 여럿이어도 문제가 없다. 텍스트 대화와 같은 코드 경로를 타서 히스토리·피드백이 그대로 동작한다.
- 음성 입력은 “길게 눌러 말하기”(push-to-talk). 놓으면 전사 결과를 입력창에 띄우고, 보내기 전에 고칠 수 있다.
- TTS는 문장 단위로 잘라 순서대로 재생해서 첫 소리까지의 지연을 줄인다.
- 2단계:
gpt-live-1실시간 모드- 진짜 면접처럼 말을 끊고 들어오는 경험은 Live API가 낫다. 한 세션이 하나의 목소리라서 1:1 대화(AI 참여자 1명)에서만 지원한다. 다자 실시간 대화는 하지 않는다.
- 세션 중 전사 텍스트를 받아
message에 저장해 히스토리·피드백은 똑같이 쓴다.
음성 API는 어디에 쓰나?
| 위치 | 모델 |
|---|---|
| 내가 말한 걸 텍스트로 바꾸기 (길게 눌러 말하기) | gpt-transcribe |
| AI 참여자 발언을 참여자별 목소리로 읽어 주기 (ON/OFF) | gpt-4o-mini-tts |
| 1:1 면접 실시간 음성 모드 (서로 끊고 들어오기 가능) | gpt-live-1 |
- 대화방 초안, 페르소나 대사, 종료 피드백은 텍스트 모델만 쓴다. 음성으로 한 발언도 텍스트로 바뀐 뒤 피드백에 들어간다.
음성 ↔ 텍스트 변환은 언제 일어나나?
- 핵심은 응답 경로 중간에 텍스트가 끼어 있느냐다.
-
기본 음성 모드: 변환이 직렬로 두 번 일어난다.
내 음성 ──① 음성→텍스트──▶ 내 말(텍스트) ──② 답변 생성──▶ 답변(텍스트) ──③ 텍스트→음성──▶ 면접관 음성 gpt-transcribe gpt-6.1-sol gpt-4o-mini-tts- 버튼을 뗀 뒤에야 ①이 시작되고, ① → ② → ③을 차례로 기다려야 해서 몇 초가 걸린다.
-
실시간 모드: 응답 경로에 텍스트가 없다.
내 음성 ──(계속 스트리밍)──▶ gpt-live-1 ──(계속 스트리밍)──▶ 면접관 음성 │ └─ 옆에서 텍스트 기록도 같이 생성 → 히스토리·피드백용- 모델 하나가 음성을 듣고 바로 음성으로 대답해서 빠르다. 말하는 동안에도 듣고 있어서 끼어들기가 된다.
- 텍스트는 응답을 만드는 데 쓰이지 않고, 대화 저장과 종료 후 피드백을 위한 기록용으로만 따로 생긴다.
| 기본 음성 모드 | 실시간 모드 | |
|---|---|---|
| 말하는 방식 | 버튼 누르고 말하고, 떼면 전사 → 확인 후 전송 | 그냥 말함 |
| 응답 | 텍스트로 바뀐 뒤 답변을 읽어 줌 (몇 초) | 바로 대답 |
| 끼어들기 | 안 됨 | 서로 끊고 들어갈 수 있음 |
| 면접관 수 | 여러 명 가능 | 1명 (한 세션에 목소리 하나) |
실시간 모드 사용 예시
- 내일 토스 서버 면접 연습, 면접관 “민재” 1명
- 실시간 모드를 켜면 버튼 없이 통화하듯 대화가 시작된다.
- 민재: “가장 큰 장애 경험 하나 말씀해 주세요.”
- 나: “작년에 결제 서버에서 DB 커넥션이 고갈돼서요, 그때 팀이 다 같이…”
- 민재가 말을 끊는다: “잠깐만요, ‘팀이’가 아니라 본인이 한 일만 말씀해 주세요.”
- 반대로 민재가 길게 말할 때 내가 “아, 질문을 다시 정리하면…” 하고 끼어들면 민재가 바로 멈추고 듣는다.
- 종료를 누르면 텍스트 기록으로 보통 대화방과 똑같이 피드백이 나온다. 예) “꼬리 질문에서 ‘우리’라는 표현이 3번 나왔다”
- 실제 면접의 압박감과 순발력을 연습하는 용도다. 비용은 분당 $0.05에 백엔드 모델 사용료가 따로 붙는다.
실시간 모드 구조
- 언제 쓸 수 있나: AI 참여자가 정확히 1명인 대화방(모드 무관)에서만 “실시간” 버튼이 보인다. 같은 대화방 안에서 텍스트·기본 음성과 실시간을 오갈 수 있고, 기록은 한 타임라인에 쌓인다.
- 연결: 앱이
wss://api.openai.com/v1/live/sessions에 WebSocket으로 직접 붙는다. 문서는 키를 서버에 두라고 권장하지만, PoC라서 기기 키 + 프로젝트 사용 한도로 위험을 받아들인다. - 세션 시작(
session.start)에 넣는 것- 페르소나 지시: 공통 규칙 + 대화방 설정 + “너는 민재다…” + 대화방 언어
- 이전 맥락: 롤링 요약과 최근 대화를 지시문 텍스트로 붙인다. 세션 중에 바꿀 게 생기면
session.instructions.append를 쓴다. - 목소리: 참여자의
voice - 백엔드 위임:
delegation.type = "responses", 모델은 위 표의 백엔드 모델
- 기록: 내 말은
session.input_transcript.delta, 면접관 말은session.output_transcript.delta를 모아 발화가 끝날 때마다message(inputType = LIVE)로 저장한다. 그래서 종료 피드백은 다른 대화와 똑같이 동작한다. - 오디오:
AudioRecord(VOICE_COMMUNICATION소스 +AcousticEchoCanceler)로 PCM16 24kHz 모노를 보내고, 받은 음성은AudioTrack으로 바로 재생한다. 에코 제거를 안 하면 면접관이 스피커로 나온 자기 목소리를 듣고 스스로 말을 끊는다. 이어폰 사용을 권장 문구로 띄운다. 기기에서 24kHz 에코 제거가 잘 안 되면 세션을 16kHz(audio/pcm16000Hz)로 바꾼다.
목소리
- TTS(
gpt-4o-mini-tts)는alloy, ash, ballad, coral, echo, fable, nova, onyx, sage, shimmer, verse, marin, cedar를 지원하고, 문서는marin,cedar를 최고 품질로 권장한다. - 초안의 기본 목소리는
marin,cedar를 우선 쓴다. 실시간 모드 문서 예시에도marin이 나와서 두 모드 모두에서 안전하다. 유저가 다른 TTS 목소리를 고르면, 실시간 모드에서 지원되지 않는 경우 비슷한 목소리로 바꿔 쓰는 매핑을 둔다. (실시간 모드 목소리 목록은 구현 때 확인)
대화 1회 비용 추정
- 가정: 유저 15턴, AI 발언 25회, 호출당 입력 약 5K 토큰(그중 고정 접두부 3K는 캐시), 발언당 출력 150 + reasoning 300 토큰, 피드백은 입력 20K + 출력·reasoning 6K
| 항목 | 기본 프리셋 | 절약 프리셋 |
|---|---|---|
| 페르소나 발언 (25회) | 약 $0.22 (캐싱 반영) | 약 $0.02 |
| 진행자 (15회) | 약 $0.01 | 약 $0.01 |
피드백 (1회, gpt-6.1-sol) |
약 $0.10 | 약 $0.10 |
| STT (음성 10분) | $0.045 | $0.045 |
| 합계 | 약 $0.38 (TTS 제외) | 약 $0.18 (TTS 제외) |
- 초안 생성(대화방당 1회)과 롤링 요약은 합쳐도 1센트 안팎이라 표에서 뺐다.
- 정밀 프리셋은 피드백이
gpt-6-astra라서 피드백만 약 $0.50이다. (20K × $10/M + 6K × $50/M) - 실시간 모드는 10분에 약 $0.50(분당 $0.05)이고, 백엔드 모델 사용료가 더해진다.
- TTS는 읽어 주는 분량에 비례하므로 실제 사용량을 보고 다시 계산한다. 앱에 대화방별 비용 표시(
api_usage합계)를 넣어 추정치를 실측으로 바꾼다.
키 관리
- PoC라서 서버를 두지 않는다. 이전 앱처럼 설정 화면에 키를 넣고, Android Keystore로 암호화해 저장한다.
- 키가 새어도 피해가 작도록 OpenAI 대시보드에서 이 앱 전용 프로젝트와 월 사용 한도를 건다.
- 나중에 다른 사람에게 배포하게 되면 그때 얇은 프록시(키는 서버에만, 앱에는 기기별 토큰)를 붙인다.
- 대화 내용은 기기에만 저장한다. OpenAI로는 요청할 때만 보내고, Responses API 호출에는
store: false를 넣어 OpenAI 쪽에 응답이 저장되지 않게 한다.
개발 방식: 전부 Claude Code로
- 코드 작성, 빌드, 테스트, 에뮬레이터 실행, 커밋까지 Claude Code가 한다. 나는 설계 리뷰와 실기기 확인만 한다.
- 브라우저 작업은 Claude가 Claude in Chrome 스킬로 직접 Chrome을 조작한다.
- OpenAI 대시보드에서 프로젝트·API 키 생성, 사용량 한도 설정
- Kling, ElevenLabs 웹에서 쇼츠 소스 생성 (아래)
- 블로그 배포 후 렌더링 확인
- 결제·키 노출이 걸린 단계는 Claude가 멈추고 나에게 확인받는다.
- 테스트 전략 (이전 앱 방식 그대로)
FakeBackend: 테스트 빌드에서는 OpenAI 호출을 가짜 응답으로 바꾼다. 초안 JSON, 페르소나 대사, 피드백 JSON, STT 결과를 고정값으로 돌려준다.- 시나리오 문서(
docs/test-scenarios.md)에 ID를 붙이고 테스트 이름을 시나리오 ID로 시작한다. - 단위 테스트: 진행자 규칙, 롤링 요약 시점, 피드백 JSON 파싱, 회차 묶기
- 스크린샷 테스트: Roborazzi
- E2E: Maestro (에뮬레이터)
- 실제 모델 품질 확인:
LIVE_LLM=1일 때만 실제 호출하는 평가 테스트. 예) “토론 모드에서 반대 측 참여자가 3턴 연속 같은 입장을 유지하는가”를 LLM 채점으로 확인
유튜브 쇼츠 계획
- 이전처럼 Kling으로 영상 클립, ElevenLabs로 한국어 내레이션을 만들고, 실제 앱 화면 녹화(에뮬레이터
screenrecord)를 섞는다. 길이는 50초 안쪽.
| 구간 | 화면 | 내레이션 (ElevenLabs) | Kling 프롬프트 방향 |
|---|---|---|---|
| 0~5초 | 면접장 문 앞에서 긴장한 사람 | “면접 전날, 연습할 상대가 없다면?” | dark room, nervous young developer, neon lime rim light, cinematic |
| 5~15초 | 프롬프트 한 줄 → 참여자 카드가 펼쳐지는 앱 화면 | “한 줄만 쓰면, 입장이 다른 AI 면접관들이 생깁니다.” | 앱 녹화 |
| 15~30초 | 음성으로 답하고 꼬리 질문을 받는 장면 | “말로 답하면, 바로 파고듭니다.” | 앱 녹화 + speech bubbles colliding, lime and violet |
| 30~42초 | 피드백 리포트, before/after 문장 | “끝나면, 뭘 잘했고 뭘 고칠지 내 말 그대로 짚어 줍니다.” | 앱 녹화 |
| 42~50초 | 로고 + 태그라인 | “AI 스파링 파트너, spar.” | logo reveal, two speech bubbles overlap into white |
- 앱 개발 과정 자체(Claude Code가 코드를 짜고 Chrome을 조작하는 화면)를 타임랩스로 넣은 2편도 고려한다.
결정 사항
| 항목 | 결정 |
|---|---|
| 배포 범위 | 나 혼자 쓰는 안드로이드 PoC. 서버 없음, 키는 기기에 암호화 저장 |
| 회차별 그래프 | 넣는다. “다시 연습하기”로 이어 간 대화방끼리 묶어 항목별 점수 추이를 보여 준다. |
| 실시간 모드 | 1:1만 지원한다. |
| 다국어 | 넣는다. 대화방별 언어 설정, 영어 면접을 우선 확인한다. |
댓글남기기