WAS: OpenAI 실시간 통화 세션 발급 API 추가
숏폼을 오래 보고 있으면 AI 페르소나가 전화를 걸어와 대화하다 끊는 서비스를 만드는 중인데, 그동안 비어있던 `backend/` 폴더(`.gitkeep`만 있던)에 실제 서버 코드를 처음 채워 넣은 PR이다.
프론트에서 통화를 시작하려면 OpenAI Realtime API로 음성 스트림을 열어야 하는데, 여기 쓰이는 API 키를 브라우저 코드에 그대로 박아둘 순 없었다. 누구든 devtools로 꺼내서 우리 계정으로 비용을 쓸 수 있기 때문이다. 그래서 서버가 중간에서 "1회용 임시 열쇠"만 발급해주고 진짜 키는 서버 밖으로 안 나가게 하는 구조가 필요했고, 이번 PR은 그 발급소 역할을 하는 /api/call 엔드포인트 하나를 만드는 작업이었다.
동작 자체는 단순하다. 프론트가 관심사, 하려던 일, 선택한 페르소나, 이전 통화 요약을 POST /api/call로 보내면, 백엔드가 그 정보로 system instructions를 조립해서 OpenAI /v1/realtime/client_secrets에 진짜 키로 요청을 날리고, 돌아온 단기 토큰만 프론트에 넘겨준다. 그 이후 음성 스트림(WebRTC)은 브라우저와 OpenAI가 직접 붙는다. 그래서 서버는 요청 하나당 짧게 끝나는 일만 하고, 그 성질 덕분에 Vercel 서버리스 함수(api/index.ts가 Express 앱을 그대로 노출)에 올려도 무리가 없다는 판단이 섰다.
openai.ts를 짤 때 turn_detection 옵션에서 interrupt_response와 create_response를 둘 다 꺼두는 선택을 했는데, 이건 스피커로 재생되는 AI 목소리를 마이크가 다시 주워서 자기 말에 자기가 끼어드는 걸 막기 위해서다. 응답 트리거는 서버가 아니라 프론트(useRealtimeCall.ts)가 직접 쥐고 있게 하는 구조로, 실시간 오디오 특유의 에코 문제를 세션 설정 단계에서 미리 눌러둔 셈이다.
DB와 로그인을 아예 넣지 않기로 한 것도 이 PR에서 정한 결정 중 하나다. 최종 목표가 해커톤 데모라서, 심사위원이 여러 번 통화하며 "AI가 기억하는" 걸 확인할 기회가 사실상 없다고 봤다. 그 시간에 인증/암호화 붙이는 대신, previousSummary라는 선택 필드를 만들어서 프론트가 localStorage에 통화 요약을 저장했다가 다음 통화 때 같이 보내면 그걸 프롬프트에 녹이는 방식으로 "기억하는 느낌"만 가볍게 흉내내기로 했다. 백엔드는 여전히 아무것도 저장하지 않는 stateless 구조를 유지한다. call.ts의 validateBody에서 interests 최대 10개, plan 200자, previousSummary 500자로 길이 제한을 걸어둔 것도 같은 맥락 — 프롬프트 인젝션이나 과도한 페이로드로 비용이 새는 걸 막기 위한 최소한의 장치다.
보안 수준을 어디까지 잡을지도 판단이 필요했다. Vercel이 주는 배포 URL은 공개돼 있어서 CORS로는 브라우저 요청만 막을 수 있고 curl이나 봇의 직접 호출은 못 막는다. 근데 이 서버가 지키는 건 개인정보가 아니라 "OpenAI API 비용"뿐이라, 무거운 인증 시스템 대신 CORS 화이트리스트(FRONTEND_ORIGIN 미설정 시 전부 거부), 공유 시크릿 헤더(x-app-secret), IP당 분당 10회 rate limit 정도로 방어선을 그었다. README에도 적어뒀지만 APP_SHARED_SECRET은 프론트 번들에 박히는 값이라 진짜 인증은 아니고, URL을 우연히 찾은 스캐너나 봇을 막는 정도의 최소 장치라는 걸 명확히 해뒀다.
API가 /api/call 하나뿐이지만 Swagger UI(/docs)를 붙인 것도 이번 작업에 포함했다. 엔드포인트가 적을수록 오히려 명세를 문서화해두는 게 프론트 연동할 때 왔다갔다하는 시간을 줄여준다고 판단했다.
커밋은 하나로 묶여서 올라갔는데, 실제로는 기존 feature/persona-presets 브랜치에 있던 페르소나/instructions 로직을 이쪽으로 옮겨오는 작업이 포함돼 있다. PR을 올리고 8분 만에 머지됐는데, Vercel 실제 배포(대시보드에서 GitHub 레포 연결, Root Directory를 backend로 지정)와 프론트 쪽의 previousSummary 저장/전송, 앱 진입 시 비밀번호 게이트 화면은 별도 세션으로 남겨뒀다. 이 PR 자체는 "열쇠 발급소"라는 하나의 역할에만 집중해서 끝낸 셈이다.