
업로드 경로에서 백엔드를 걷어내다
GeekBye는 당신의 화면을 녹화하고 그 영상을 당신의 Google Drive에 저장합니다. 첫 버전은 그곳에 이르는 길에 모든 녹화를 GeekBye 자신의 서버를 거쳐 보냈습니다; 한 릴리스 뒤에는, 파일이 당신의 기계에서 곧장 Drive로 갔고, 백엔드는 포인터 하나를 쥐고 있는 것으로 강등되었습니다. 흥미로운 대목은 그 「직접, 재개 가능한」 버전이 실제로 얼마나 적은 코드를 담고 있는가입니다 — 재개 가능성은 프록시를 쓰는 데서가 아니라, 그것을 지우는 데서 왔기 때문입니다.
GeekBye는 당신의 화면 — 영상, 시스템 오디오, 그리고 마이크 — 을 녹화하고, 완성된 녹화를 당신의 Google Drive에 자동으로 떨궈 넣을 수 있습니다. v1.8.11과 v1.8.13 사이에, 이 기능의 사용자 대면 설명은 거의 바뀌지 않았지만, 영상이 Drive에 이르기 위해 밟는 경로는 통째로 갈아 끼워졌습니다. 첫 버전에서 당신의 녹화는 GeekBye의 서버를 거쳐 여행했습니다. 두 번째에서 그것은 당신의 기계에서 곧장 당신의 Drive로 갔고, GeekBye의 백엔드는 그 바이트를 하나도 보지 못했습니다. 그 갈아 끼움이 이야기의 전부이고, 만족스러운 대목은 그 「더 나은」 버전이 그것이 대체한 것보다 눈에 띄게 더 적은 코드를 담고 있다는 것입니다.
파이프라인, 끝에서 끝까지
녹화는 렌더러에서 일어납니다. ScreenRecordingService.ts는 캡처 스트림 위에 { mimeType: 'video/webm;codecs=vp9,opus', videoBitsPerSecond: 1_000_000 }으로 MediaRecorder를 만들고, 1초 타임슬라이스(CHUNK_INTERVAL_MS = 1000)로 그것을 시작합니다. 모든 ondataavailable blob은 IPC 너머로 메인 프로세스로 보내지고, 거기서 recording:save-chunk 핸들러가 fs.promises.appendFile로 그것을 디스크 상의 파일에 이어 붙입니다. 일찌감치 깃발을 세워 둘 값어치가 있습니다, 오독하기 쉬운 종류이기 때문입니다: 그 1초의 박자는 _디스크 쓰기_의 리듬이지, 업로드 청크 크기가 아닙니다. 업로드가 시작될 즈음이면, 디스크 위에는 완성된 WebM 파일 하나가 앉아 있습니다.
두 릴리스 사이에서 다른 것은 오직 그 완성된 파일에 무슨 일이 일어나는가, 그것뿐입니다.
이전: 모든 것이 백엔드를 거친다 (v1.8.11)
v1.8.11의 업로드 경로는 CloudUploader.processRecording() 안에 있으며, 뻔한 설계이고 완전히 합당한 첫 버전입니다:
POST /api/geekbye/recordings로 녹화의 메타데이터를 가진 백엔드 레코드를 만든다.- 영상 전체를 메모리에 읽어 그것을 multipart로 백엔드에 POST한다 —
const fileBuffer = fs.readFileSync(filePath)—POST /api/geekbye/recordings/${id}/upload을 치면서. 그러면 백엔드가 그 파일을 Drive에 업로드하고driveUrl을 돌려준다. POST …/process로 AI 분석을 위해 트랜스크립트를 보낸다.
하중을 떠받치는 세부는 2단계입니다: 클라이언트는 녹화 전체를 메모리에 읽고, 그 모든 바이트가 Drive로 가는 길에 GeekBye 자신의 서버를 지나갑니다. 짧은 클립이라면 그것으로 괜찮습니다. 긴 화면 녹화에서 그것은 한꺼번에 세 개의 문제입니다 — readFileSync로 인한 클라이언트의 메모리 압박, 보관조차 하지 않는 파일을 위한 백엔드의 대역폭과 중계 비용, 그리고 도중에 네트워크가 딸꾹질하면 _영에서 다시 시작_해야 하는 단일한 일체형 POST입니다.
이 「이전」이 무슨 순백의 원설계였던 것은 아니라고 솔직해질 값어치도 있습니다. v1.8.11 기능은 주중의 피벗 하나를 짓눌러 합쳐 놓았습니다: 이른 판본은 사전 서명 URL을 통해 Cloudflare R2에 업로드하고 동봉된 ffmpeg-static으로 WebM을 MP4로 변환했습니다; 그것이 백엔드 중개 Drive 플로우로 대체되었고; 그러고 나서 ffmpeg 변환은 WebM을 있는 그대로 업로드하는 쪽을 택해 통째로 뽑혀 나갔습니다(커밋은 그것이 비트레이트를 낮추고 대략 네 배 작은 파일을 냈다고 적습니다 — 벤치마크가 아니라 줄 안의 추정입니다). 그러니 「이전」조차 이미 한 번, 네이티브 의존성을 지우는 법을 배웠던 것입니다. 다음 릴리스는 프록시도 지우게 됩니다.
이후: Drive로 곧장 (v1.8.13)
v1.8.13은 그것을 출시한 커밋의 한 문장을 축으로 CloudUploader.processRecording()을 다시 씁니다: Drive로 직접 업로드하고, "keeping backend for metadata record + AI transcript analysis only." 새 경로:
POST /api/geekbye/recordings— 메타데이터만(제목, 길이, 파일 크기,format: 'webm'). 코드 안의 주석은 무뚝뚝합니다:// Create backend record (metadata only — no file upload).- 영상을 Google Drive로 직접 업로드하며, 백엔드 중개자를 통째로 건너뛴다.
PATCH /api/geekbye/recordings/${backendRecordingId}을 결과인{ driveUrl, driveFolderId }와 함께 — 백엔드 레코드는 파일이 어디에 안착했는지를 통보받습니다.- 트랜스크립트는 여전히 분석을 위해
…/process로 갑니다.
백엔드는 파일의 경로 안에 있던 처지에서, 사후에 파일에 대해 통보받는 처지로 옮겨 갔습니다. 그것은 행 하나와 포인터를 쥡니다; 바이트는 오직 사용자의 디스크와 사용자의 Drive 안에만 삽니다.
진짜 Drive 클라이언트가 되기
클라이언트가 Drive와 직접 이야기하려면, 그것은 실제 Google Drive API 클라이언트여야 하고, v1.8.13은 그것을 위한 기계 장치를 더합니다: 새 DriveService(Drive 클라이언트), 새 drive_auth SQLite 테이블에 떠받쳐진 새 DriveAuthRepository, 그리고 의존성으로서의 googleapis SDK입니다.
인가는 깔끔한 권능의 넘김입니다. 백엔드의 app-config 엔드포인트는 Google OAuth 클라이언트 자격 증명 — clientId와 clientSecret — 을 앱으로 중계하고, CloudUploader가 그것을 읽어 driveService.initialize(...)에 넘깁니다. 거기서부터 데스크톱 앱은 OAuth 플로우 전체를 스스로 돌립니다: 무작위 127.0.0.1 포트에 임시 http 서버를 세우고, shell.openExternal로 시스템 브라우저에 동의 화면을 열고, /callback에서 리다이렉트를 잡아, google-auth-library로 코드를 토큰과 교환합니다. 그 토큰들은 로컬에 저장됩니다 — drive_auth로 saveDriveAuth(access_token, refresh_token, expiry_date) — drive.file 스코프와 함께, 그것은 앱이 자신이 만든 파일만 관리하게 합니다. 갱신도 로컬입니다: oauth2Client.on('tokens', …) 리스너가 갱신된 토큰을 테이블에 곧장 되씁니다. 백엔드는 권능을 한 번 넘기고 그다음엔 루프 밖에 머뭅니다.
「재개 가능」 부분을, 정직하게
여기가 제가 가장 솔직하고 싶은 세부입니다, 정확히 changelog가 반올림해 버리는 종류이기 때문입니다. 릴리스 노트는 "resumable uploads," 라고 말하고, 사용자가 선 자리에서는 그것이 사실입니다. 하지만 GeekBye는 재개 가능 프로토콜을 구현하지 않았습니다. 청크 크기 상수도 없고, 바이트 오프셋 트래커도 없고, Content-Range / 308 Resume Incomplete 처리도 GeekBye의 소스 어디에도 없습니다. 그것들을 grep해 봐도 아무것도 나오지 않습니다 — 그것들은 googleapis SDK 안에 삽니다.
GeekBye 자신의 코드가 하는 것은 이것입니다:
const fileStats = statSync(videoFilePath)
const videoStream = createReadStream(videoFilePath)
const videoRes = await this.drive.files.create(
{
requestBody: { name: `${title}.webm`, parents: [folderId] },
media: { mimeType: 'video/webm', body: videoStream },
fields: 'id',
},
{
onUploadProgress: (evt) => {
const percent = Math.round((evt.bytesRead / fileStats.size) * 100)
onProgress(percent)
},
},
)
두 선택이 모든 일을 합니다. 첫째, media.body는 버퍼가 아니라 스트림(createReadStream)입니다 — 그리고 스트림을 넘기는 것이야말로 googleapis가 한 방에 하는 대신 당신을 대신해 재개 가능한 세션을 협상하게 만드는 바로 그것입니다. 둘째, 진행률은 SDK의 onUploadProgress 콜백에서 fileStats.size에 대해 읽힙니다. 그것이 애플리케이션 수준에서의 「재개 가능한 업로드」 구현 전부입니다: 스트림 들이고, 진행률 내보내고. 어려운 프로토콜 — 세션을 열고, 청크로 업로드하고, 끊긴 뒤 오프셋에서 재개하기 — 은 SDK의 몫이며, 옳은 엔지니어링 결정은 그것을 재발명하는 대신 SDK가 그 일을 하게 두는 것이었습니다.
그것이 릴리스 전체를 다시 보게 합니다. 신뢰성 개선은 업로드 엔진을 쓰는 데서 오지 않았습니다. 그것은 그 앞에 앉아 있던 프록시를 지우는 데서 왔습니다 — 그리고, 같은 주 앞서는, 그 앞에 있던 ffmpeg 단계를 지우는 데서. 더 적은 코드, 더 많은 회복력입니다, 회복력 있는 그것이 당신이 버퍼를 손으로 먹이기를 그만둔 순간의 잘 길든 라이브러리였기 때문입니다.
진정으로 GeekBye의 것인 조각들
전송을 SDK에 넘긴다고 해서 지을 것이 아무것도 남지 않았던 것은 아닙니다. 두 조각의 진짜 애플리케이션 로직이 그것을 둘러쌉니다.
고아 폴더 롤백. 각 녹화는 자기만의 Drive 하위 폴더를 얻고(mimeType: 'application/vnd.google-apps.folder'로 만들어집니다), 트랜스크립트는 영상 옆에 두 번째 파일로 거기 안착합니다. 업로드가 throw하면, catch가 그 폴더를 삭제합니다 — this.drive.files.delete({ fileId: folderId }), Rolled back orphaned Drive folder로 로그됩니다 — 그래서 실패한 업로드가 당신의 Drive에 빈 폴더의 자취를 남기지 않습니다. 작업이 성공하리라는 것을 알기 전에 컨테이너를 만드는 것은 작은 부채입니다; 실패 경로가 그것을 청소하게 만드는 것이 그 수정입니다.
싱글턴을 둘러싼 생명주기. DriveService는 메모리 상의 OAuth 클라이언트를 쥔 싱글턴이고, 그 메모리 상 클라이언트는 토큰은 살아남는데도(그것들은 데이터베이스에 있습니다) 앱 재시작을 살아남지 못합니다. 그래서 CloudUploader는 매 업로드 전에 저장된 토큰으로 DriveService를 재초기화하고, 그것은 재실행 직후에 만든 녹화도 당신에게 재연결을 요구하지 않고 여전히 업로드된다는 뜻입니다. 그리고 리뷰 패스는 drive-connect 핸들러를 fire-and-forget으로 바꾸었습니다, 블로킹하는 connect 호출이 렌더러의 상태 폴링을 멈춰 세우고 있었기 때문입니다 — connect 호출이 스레드를 쥐고 있으면 UI가 「연결 중…」을 보여줄 수 없었습니다. 그 둘 — 스토리지로부터의 재초기화와 블로킹하지 않는 connect — 이 직접 경로의 진짜 엣지 케이스이며, 눈에 띄게도 그 어느 쪽도 업로드 청크 분할에 관한 것이 아닙니다. 어려운 부분을 위임하면, 당신에게 남는 버그는 생명주기 버그입니다.
이 릴리스가 우리에게 가르친 세 가지
- 작동하는지만이 아니라, 바이트가 어디로 흐르는지를 물어라. v1.8.11 프록시는 작동했습니다. 하지만 모든 녹화를 자기 서버를 거쳐 보내는 것은, 보관하지도 않는 파일을 위해 대역폭, 메모리, 그리고 재시작 가능성을 당신에게 물립니다. 클라이언트가 목적지와 직접 이야기하도록 데이터 경로를 다시 그리는 것이 흔히 최적화의 전부입니다.
- 최고의 재개 가능한 업로드는 당신이 쓰지 않은 것이다. 성숙한 SDK에 스트림을 넘기는 것이 두 줄의 코드로 완전한 재개 가능 세션을 사 왔습니다. 청크 분할과 오프셋 계산을 손으로 짜려는 본능은 더 많은 코드와 더 적은 신뢰성을 냈을 것입니다.
- 코어를 위임하면, 알고리즘이 아니라 생명주기를 물려받는다. 직접 클라이언트의 진짜 버그는 「재시작 뒤 싱글턴을 재초기화한다」와 「connect 호출을 블로킹하지 않는다」였지, 바이트 오프셋이 아니었습니다. 그것은 좋은 거래입니다 — 생명주기 버그는 눈에 보이고 국소적입니다; 프로토콜 버그는 그 어느 쪽도 아닙니다.
v1 이야기의 이전 장은 십이 분 만에 태어나고 고쳐지다(v1.8.10)를; 그리고 전체 궤적은 소프트웨어를 완벽하게 출시하는 것의 해부학을 보세요.