블로그 목록
Backend8분 읽기

Cloud Recording 녹화 파일은 어떻게 저장되는가? — S3 실시간 업로드, 끊김 복구, 재생까지

Cloud Recording 산출물이 object storage에 기록되는 방식과 장애 후 처리 절차를 설명합니다. TS·M3U8·MP4의 분할 조건, recording server failover 시 생성되는 backup playlist, stop·query 응답의 file list를 구분합니다. 애플리케이션에서는 사용자 소유권을 확인한 뒤 저장된 object key로 짧은 수명의 presigned URL을 발급해야 합니다.

Cloud RecordingS3HLSMP4Presigned URL

Agora Cloud Recording으로 상담 통화를 녹화하면 녹음 파일이 S3에 어떻게 올라가는지, 중간에 끊기면 어떻게 되는지, MP4는 몇 개나 생기는지 — 처음 도입할 때 궁금했던 것들을 정리합니다. 마지막에는 S3에 저장된 녹음을 presigned URL로 재생하는 실제 코드도 포함했습니다.

실시간으로 S3에 올라가는가?

녹화가 끝난 뒤 한꺼번에 올리는 방식이 아닙니다. 녹화 중에 미디어 조각(TS/WebM 파일)이 순차적으로 S3에 업로드됩니다.

Cloud Recording은 내부적으로 HLS(HTTP Live Streaming) 방식을 사용합니다. HLS는 미디어를 짧은 TS(Transport Stream) 조각으로 분할하고, M3U8 인덱스 파일로 순서를 관리하는 구조입니다.

녹화 진행 중 (30분 상담):
─────────────────────────────────────────

[0초]    Agora 서버에서 녹화 시작
          │
[첫 구간]  미디어 조각 001 생성 → S3 업로드
[다음 구간] 미디어 조각 002 생성 → S3 업로드
[다음 구간] 미디어 조각 003 생성 → S3 업로드
  ...
[30분]   녹화 종료
          │
          ├── M3U8 인덱스 파일 갱신
          └── MP4 파일 마감 및 업로드 (Composite/Web에서 설정한 경우)

HLS 조각은 녹화 중 업로드되고, MP4 파일은 녹화가 끝나거나 분할 임계치에 도달할 때 닫힙니다. TS와 MP4는 모두 인코딩된 미디어를 담는 컨테이너 출력입니다. MP4를 항상 TS 조각을 사후 결합한 결과로 보아서는 안 됩니다.

정정: "MP4는 녹화가 100% 끝나야만 만들어진다"는 건 정확한 표현이 아닙니다. avFileType: ["hls","mp4"]로 설정하면 녹화 도중에도 분할 임계치(아래 참고)에 도달하면 그 시점의 MP4 조각이 닫히고 다음 조각이 새로 열립니다. 즉 각 MP4 조각은 "그 구간이 끝나는 시점"에 마무리되며, 마지막 조각이 녹화 종료 시점에 닫히는 구조입니다. 짧은 단일 상담에서는 조각이 1개뿐이라 "녹화 종료 = MP4 완성"처럼 보일 뿐입니다.


끊기면 어떻게 되는가?

업로드가 완료된 조각은 제3자 스토리지에 남습니다. 다만 게시자 네트워크 단절, 스토리지 업로드 실패, 녹화 서버 장애는 서로 다른 상황이며 결과 파일도 다르게 처리됩니다.

정상 처리:
─────────────────────────────────────────
slice 001 → slice 002 → slice 003 → ... → playlist 갱신

스토리지 업로드 실패:
─────────────────────────────────────────
Agora Cloud Backup의 파일이 스토리지로 재전송될 수 있음
→ suffix가 붙은 M3U8 중 최신·더 큰 파일을 비교해 선택

녹화 서버 장애 또는 process 종료:
─────────────────────────────────────────
최대 90초 안에 다른 서버로 전환
→ bak0_, bak1_ ... prefix의 M3U8·slice·MP4 파일 생성 가능

끊겼을 때 파일별 상태를 정리하면:

파일상태설명
이미 업로드된 조각스토리지에 유지업로드가 완료된 객체는 그대로 남음
업로드 재전송 M3U8suffix가 붙을 수 있음가장 큰 최신 버전과 suffix 없는 파일을 비교
HA 전환 후 파일bak<n>_ prefix장애 전·후 playlist를 함께 처리해야 함

단일 M3U8이 정상적으로 남아 있고 조각의 codec·timestamp가 호환된다면 FFmpeg stream copy로 MP4를 만들 수 있습니다.

# 단일 M3U8을 MP4로 remux하는 예
ffmpeg -i recording.m3u8 -c copy recovered.mp4

HA 전환으로 bak<n> playlist가 생긴 경우에는 단일 M3U8 명령만으로 전체 구간이 합쳐지지 않습니다. Agora는 이 경우 전용 HA transcoder 사용 절차를 제공합니다.


MP4는 계속 새로 만드는가?

정상적으로 이어진 30분짜리 음성 상담은 분할 임계치에 도달하지 않으므로 Composite/Web page recording에서 보통 MP4 1개가 생성됩니다. 장애나 재시작이 발생하면 파일 수가 달라질 수 있습니다.

MP4 분할 조건 (Composite / Web page recording):
─────────────────────────────────────────
  약 3시간 초과  OR  약 2GB 초과  → 새 MP4 파일 생성

30분 상담 (음성만):
  48 Kbps 기준 용량: 약 10.8 MB (컨테이너 오버헤드 제외)

4시간 회의 (영상+음성):
  → recording_0.mp4 (0~3시간)
  → recording_1.mp4 (3~4시간)
  (첫 MP4 인덱스는 0부터 시작)

일반적인 1:1 상담이나 CS 통화에서는 MP4 분할을 신경 쓸 일이 거의 없습니다. 분할이 걱정되는 건 수 시간짜리 웨비나나 라이브 방송 녹화 정도입니다.

Composite와 Web page recording의 MP4는 현재 파일이 약 3시간 또는 약 2GB에 이르면 다음 파일로 분할됩니다. Individual recording은 MP4를 생성하지 않습니다. Web page recording에서는 maxVideoDuration으로 길이 기준을 별도 설정할 수 있습니다.


S3 녹음 파일 재생 — 실제 코드

Cloud Recording으로 S3에 저장된 MP4를 웹에서 재생할 때는, 인증된 백엔드가 presigned URL을 발급하고 브라우저가 S3에서 파일을 받도록 구성할 수 있습니다.

백엔드 (Node.js — presigned URL 생성)

// server.js
const express = require('express');
const { S3Client, GetObjectCommand } = require('@aws-sdk/client-s3');
const { getSignedUrl } = require('@aws-sdk/s3-request-presigner');

const app = express();

// 애플리케이션의 인증 middleware와 녹화 메타데이터 repository를 사용한다.
// req.user.id는 인증이 끝난 사용자 ID라고 가정한다.
const { requireAuth } = require('./auth');
const { recordingRepository } = require('./recording-repository');

// IAM role, workload identity 등 AWS SDK의 기본 credential provider chain을 사용한다.
const s3 = new S3Client({ region: 'ap-northeast-1' });

// 상담 녹음 재생 URL 발급
app.get('/api/recordings/:sessionId', requireAuth, async (req, res) => {
  const { sessionId } = req.params;

  if (!/^[A-Za-z0-9_-]{1,100}$/.test(sessionId)) {
    return res.status(400).json({ error: 'invalid session id' });
  }

  // stop/query/webhook의 file list에서 실제 object key와 content type을 저장한다.
  const recording = await recordingRepository.findBySessionId(sessionId);
  if (!recording || recording.ownerId !== req.user.id) {
    return res.status(404).json({ error: 'recording not found' });
  }

  const command = new GetObjectCommand({
    Bucket: process.env.S3_BUCKET,
    Key: recording.objectKey,
    ResponseContentType: recording.contentType,
  });

  // 재생 시작에 필요한 짧은 시간만 허용한다. 실제 값은 UX와 위험도에 맞춘다.
  const url = await getSignedUrl(s3, command, { expiresIn: 300 });

  res.set('Cache-Control', 'private, no-store');
  res.json({ url, sessionId });
});

app.listen(3000, () => console.log('Server running on :3000'));

프론트엔드 (HTML + JS — 재생 플레이어)

<!DOCTYPE html>
<html lang="ko">
<head>
  <meta charset="UTF-8">
  <title>상담 녹음 재생</title>
  <style>
    body { font-family: sans-serif; max-width: 640px; margin: 40px auto; }
    .player-card {
      border: 1px solid #ddd; border-radius: 12px;
      padding: 24px; background: #fafafa;
    }
    .session-input { display: flex; gap: 8px; margin-bottom: 20px; }
    .session-input input {
      flex: 1; padding: 10px 14px; border: 1px solid #ccc;
      border-radius: 8px; font-size: 14px;
    }
    .session-input button {
      padding: 10px 20px; background: #0066ff; color: white;
      border: none; border-radius: 8px; cursor: pointer; font-size: 14px;
    }
    audio { width: 100%; margin-top: 12px; }
    .status { margin-top: 12px; font-size: 13px; color: #666; }
    .error { color: #e53e3e; }
  </style>
</head>
<body>
  <div class="player-card">
    <h2>상담 녹음 재생</h2>

    <div class="session-input">
      <input type="text" id="sessionId"
             placeholder="상담 세션 ID (예: session-20260326-001)">
      <button onclick="loadRecording()">불러오기</button>
    </div>

    <audio id="player" controls style="display:none;">
      브라우저가 오디오 재생을 지원하지 않습니다.
    </audio>

    <div id="status" class="status"></div>
  </div>

  <script>
    async function loadRecording() {
      const sessionId = document.getElementById('sessionId').value.trim();
      const player = document.getElementById('player');
      const status = document.getElementById('status');

      if (!sessionId) {
        status.className = 'status error';
        status.textContent = '세션 ID를 입력하세요.';
        return;
      }

      status.className = 'status';
      status.textContent = '녹음 파일 불러오는 중...';

      try {
        const res = await fetch(`/api/recordings/${sessionId}`);
        if (!res.ok) throw new Error('녹음 파일을 찾을 수 없습니다.');

        const { url } = await res.json();

        player.src = url;
        player.style.display = 'block';
        player.load();

        status.textContent = `세션 ${sessionId} 녹음 로드 완료`;
      } catch (err) {
        status.className = 'status error';
        status.textContent = err.message;
        player.style.display = 'none';
      }
    }
  </script>
</body>
</html>

동작 흐름

CS 담당자가 웹에서 상담 녹음을 재생하는 전체 과정입니다.

[브라우저]                    [백엔드]                     [AWS S3]
    │                            │                            │
    │  GET /api/recordings/      │                            │
    │  session-20260326-001      │                            │
    │ ──────────────────────→    │                            │
    │                            │  S3 presigned URL 생성      │
    │                            │ ──────────────────────→    │
    │                            │  ←──────────────────────   │
    │  ←──────────────────────   │  서명된 URL 반환             │
    │  { url: "https://s3...     │                            │
    │    ?X-Amz-Signature=..." } │                            │
    │                            │                            │
    │  <audio src="presigned URL">                            │
    │ ─────────────────────────────────────────────────────→  │
    │                        브라우저가 S3에서 직접 스트리밍      │
    │ ←─────────────────────────────────────────────────────  │
    │  재생 중...                                              │

이 구조의 특성은 다음과 같습니다.

  • 데이터 전송 분리 — 파일 바이트 전송은 S3가 담당하지만, 사용자 인증·권한 확인과 URL 발급 부하는 백엔드에 남습니다.
  • 제한된 접근 — S3 credentials를 프론트엔드에 노출하지 않고 URL 유효 시간을 제한합니다. URL은 서명 자격 증명 만료나 정책 변경으로 설정 시간보다 일찍 무효화될 수도 있습니다.
  • 재생 호환성 — ffprobe로 오디오 codec을 식별한 뒤 지원 대상 브라우저에서 오디오 전용 MP4를 재생합니다.

프로덕션에서는

위 코드는 세션 ID를 수동으로 입력하는 데모 수준입니다. 실제 서비스에서는 Agora의 callback notification을 활용합니다.

녹화 완료 → Agora가 webhook으로 알려줌 → DB에 파일 경로 저장
                                              │
CS 담당자가 상담 내역 클릭 → DB에서 경로 조회 → presigned URL 발급 → 재생

프로덕션에서는 webhook으로 녹화 상태를 추적하고, stop 또는 query 응답의 file list에서 실제 object key를 확인해 DB에 저장합니다. 알림 유실과 중복 전달을 고려해 sid 기준으로 idempotent하게 처리해야 합니다.


핵심 요약

  • 미디어 조각은 녹화 중 업로드 — 완료된 객체는 제3자 스토리지에 남고, 업로드 실패나 서버 failover 시에는 별도 복구 파일이 생성될 수 있습니다.
  • MP4는 Composite/Web page recording에서 선택 — 약 3시간 또는 약 2GB에서 분할되며 Individual recording은 MP4를 생성하지 않습니다.
  • presigned URL은 권한 검사 뒤 발급 — 파일 전송은 S3가 맡지만 인증·인가와 URL 발급은 백엔드 책임입니다.

관련 글

참고 자료

© 2026 Frank Kim. All rights reserved.