2026.08.14#ops8분

서버를 얇게 두고 브라우저에 계산을 맡긴 구조

녹음, 인코딩, 피치 분석, 카드 렌더링을 전부 브라우저에서 돌리고 서버는 기록과 알림만 맡게 했습니다. 반년 운영하면서 이 구조가 무엇을 아꼈고 어디서 손이 더 갔는지 정리한 기록입니다.

서버가 하는 일을 줄인 결정

발음 교정 서비스를 이틀 만에 만든 PoC로 시작했습니다. 혼자 만들고 혼자 운영해야 했으니 서버에서 할 일을 최소로 두는 것이 첫 결정이었습니다. 음성을 서버로 올려 분석하는 구조라면 CPU와 대역폭이 사용자 수에 비례해 늘어나고, 그 비용을 감당할 근거가 없었습니다.

그래서 무거운 계산을 전부 브라우저로 옮겼습니다. 녹음, MP3 인코딩, 피치(목소리의 높낮이) 추출, 피드백 카드 렌더링이 클라이언트에서 돌고, 서버는 결과를 저장하고 알림을 보냅니다. 서버 코드가 얇아지니 워커 두 개로 서비스 전체가 돌아갑니다.

하츠옹 체크 구성도

화면과 API는 워커 하나가 맡고, 매일 도는 배치와 알림은 별도 워커와 Durable Object(요청 사이에 상태를 들고 있는 Cloudflare의 실행 단위)가 처리합니다. 데이터는 D1과 R2에, 첨삭 영상은 Stream에 둡니다.

비용과 응답 시간

이 구조에서 얻은 것은 두 가지입니다.

첫째, 서버 비용이 사용자 수에 비례해 늘지 않습니다. 피치 추출은 사용자의 기기에서 도니 서버는 완성된 결과만 받습니다. 운영진에게는 시연과 함께 이 비용 구조를 보여줬습니다.

둘째, 응답이 사용자 쪽에서 빠릅니다. 녹음을 멈추면 곡선이 바로 그려집니다. 서버 왕복이 없으니 네트워크가 느린 환경에서도 분석 자체는 같은 속도로 끝납니다.

영상도 같은 원칙을 따랐습니다. 선생님이 올리는 첨삭 영상은 워커를 거치지 않고 브라우저에서 Cloudflare Stream으로 직접 올라갑니다. 워커가 파일을 중계하면 요청 시간과 메모리를 그만큼 쓰게 되는데, 그럴 이유가 없었습니다. 데이터베이스에는 영상 식별자만 저장합니다.

화면 카드와 PNG의 불일치

브라우저가 카드를 그린다는 것은, 같은 카드를 그리는 코드가 두 벌 존재한다는 뜻이기도 합니다. 선생님이 화면에서 보는 카드와 학생에게 전달되는 PNG를 서로 다른 코드가 그립니다.

두 결과가 어긋나도 예외가 나지 않습니다. 학생에게는 다른 카드가 그대로 전달됩니다. 그래서 두 결과가 좌표 단위로 일치하는지 테스트로 검증합니다.

제출 한 건에 붙는 요청 수

제출 흐름은 클라이언트가 주도합니다. 문장 수만큼 오디오를 만들어 올리고, 그 뒤로 서버의 워크플로와 알림이 이어집니다.

과제 제출 한 건이 만드는 요청

제출 1건은 R2 쓰기 12회에 D1 쓰기, 워크플로 실행, Durable Object 호출, Discord API 호출이 붙습니다. 등교 전 시간대에 몇 명이 같이 제출하면 수십에서 수백 요청이 됩니다. 운영진이 "쓸 사람이 없는 시간에 트래픽이 뜬다"고 물었을 때 이 모양을 그려 보고서야 설명할 수 있었습니다.

요청 수만 보면 원인을 찾기 어렵습니다. 사용자 행동 한 번이 요청 몇 개로 번지는지 알아야 그래프를 해석할 수 있습니다.

두 워커가 D1 하나를 공유

구조에서 가장 신경 쓰는 지점은 워커 두 개가 D1 하나를 공유하는 것입니다. 화면을 담당하는 워커와 배치를 담당하는 워커가 같은 테이블을 읽고 씁니다. 스키마를 바꾸면 두 워커를 함께 배포해야 하고, 한쪽에서 넣은 데이터를 다른 쪽이 전제하게 됩니다.

D1은 여러 문장을 한 트랜잭션으로 묶는 것을 지원하지 않아서 batch로 묶어 보냅니다. 배치는 순서를 보장하지만 트랜잭션과 같지 않습니다. 중간에서 실패하면 앞의 쓰기가 남습니다. 그래서 실패해도 다시 실행해 같은 결과가 되도록 각 단계를 짜야 합니다.

혼자 만드는 규모에서는 이 결합이 오히려 편합니다. 서비스가 하나뿐이고 배포도 제가 하니 조율할 사람이 없습니다. 사람이 늘거나 배치가 무거워지면 여기부터 문제가 될 것으로 보고 있습니다.

걷어낸 세션 캐시

빠르게 만들 때 넣었다가 걷어낸 것도 있습니다. 세션 조회를 빠르게 하려고 KV(키-값 저장소)에 캐시를 얹었는데, 운영에 들어간 뒤 "로그인이 느리다"는 보고의 원인이 그 캐시였습니다.

캐시를 걷어낸 세션 조회 경로

wallTime(요청이 시작해서 끝날 때까지의 실제 시간)과 cpuTime(그중 계산에 쓴 시간)을 대조해 보니 계산은 짧고 대기가 길었습니다. 캐시를 확인하는 왕복이 D1을 직접 읽는 것보다 느렸습니다. 캐시를 걷어내자 세션 조회는 573ms에서 20ms가 됐습니다.

빠를 것 같아서 넣은 캐시가 느린 원인이었습니다. 서버를 얇게 두기로 정해 놓고 서버 경로에 층을 하나 더 얹고 있었습니다.

환경을 분리하지 않아 겹친 cron

운영에서 겪은 다른 문제는 환경 분리와 관련이 있습니다. Discord 알림이 두 번씩 오는 장애가 있었는데, 원인은 같은 cron이 두 환경에 겹쳐 등록된 것이었습니다.

cron이 두 환경에 겹쳐 등록된 알림 경로

찾기 어려웠던 이유는 두 실행이 모두 "정상 완료"였다는 점입니다. 에러 로그가 남지 않아서 실행 기록의 시각을 맞대 보고서야 중복을 확인했습니다. 지금은 cron을 한 환경에만 등록해 두고, 같은 실수를 반복하지 않도록 규칙으로 남겼습니다.

다음에 바꿀 두 가지

캐시는 느린 것을 확인한 뒤에 넣습니다. 처음부터 넣어둔 캐시는 효과를 증명할 기준선이 없어서, 나중에 병목이 되어도 의심하지 않게 됩니다.

환경 분리는 알림처럼 밖으로 나가는 기능을 붙이기 전에 합니다. 두 번 발송된 알림은 유저에게 그대로 보이고 로그에는 남지 않습니다.

네 가지 결정과 결과
결정얻은 것늘어난 일
계산을 브라우저로사용자 수와 무관한 서버 비용카드 렌더링 코드 두 벌, 일치 테스트 필요
영상 직접 업로드워커 요청 시간·메모리 절약업로드 실패 처리를 클라이언트가 담당
DB 하나 공유조율 비용 없음, 배포 단순스키마 변경이 두 워커에 동시 영향
세션 캐시 (되돌림)없음세션 조회 573ms
©2026 Imjurney · All Rights Reserved8 Posts | 4 Projects