블로그 목록
Backend8분 읽기

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

Cloud Recording의 HLS 실시간 업로드 구조, 네트워크 끊김 시 TS 파일 복구, MP4 분할 조건, 그리고 presigned URL로 녹음을 재생하는 실제 코드를 정리합니다.

Cloud RecordingS3HLSMP4Presigned URL

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

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

녹화가 끝나고 한꺼번에 올리는 게 아닙니다. 녹화 중에 조각(TS 파일)이 실시간으로 S3에 업로드됩니다.

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

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

[0초]    Agora 서버에서 녹화 시작
          │
[0~10초]  TS 조각 001 생성 → 바로 S3 업로드 ✅
[10~20초] TS 조각 002 생성 → 바로 S3 업로드 ✅
[20~30초] TS 조각 003 생성 → 바로 S3 업로드 ✅
  ...
[30분]   녹화 종료
          │
          ├── M3U8 인덱스 파일 최종 업로드
          └── MP4 파일 생성 후 업로드 (설정한 경우)

HLS(TS 조각)는 실시간으로, MP4는 녹화가 끝난 후에 생성됩니다. 이 차이가 중요합니다. TS는 "원본 데이터"이고, MP4는 그걸 합쳐서 재생하기 편하게 만든 "가공물"입니다.


끊기면 어떻게 되는가?

여기가 Cloud Recording의 장점이 드러나는 부분입니다. TS 조각이 실시간으로 올라가기 때문에, 네트워크가 끊겨도 이미 업로드된 조각은 S3에 그대로 남아있습니다.

정상 종료:
─────────────────────────────────────────
TS 001 ✅ → TS 002 ✅ → TS 003 ✅ → ... → TS 180 ✅
                                              │
                                    M3U8 최종 업로드 ✅
                                    MP4 생성 후 업로드 ✅


비정상 종료 (네트워크 끊김 등):
─────────────────────────────────────────
TS 001 ✅ → TS 002 ✅ → TS 003 ✅ → 💥 끊김
                              │
                    여기까지의 TS는 이미 S3에 있음 ✅
                    M3U8도 중간중간 업데이트됨

                    BUT: MP4는 생성 안 됨 ❌
                    (MP4는 녹화가 정상 완료돼야 만들어지므로)

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

파일상태설명
TS 조각✅ S3에 남아있음이미 업로드된 조각은 유실 없음
M3U8✅ 마지막 업데이트 시점까지 존재중간중간 갱신되므로 부분 인덱스라도 있음
MP4❌ 생성 안 됨최종 완성 시점에 만들어지는 파일이라 없음

MP4가 없어도 TS 파일이 살아있으니, FFmpeg로 직접 복구할 수 있습니다.

# 끊긴 후 S3에 남은 TS 파일들로 MP4 복구
ffmpeg -i recording.m3u8 -c copy recovered.mp4

실제로 이 복구를 해 본 적이 있는데, M3U8 인덱스가 중간까지라도 있으면 깔끔하게 복구됩니다. M3U8마저 없으면 TS 파일들을 직접 concat 해야 해서 좀 번거롭습니다.


MP4는 계속 새로 만드는가?

30분짜리 음성 상담이면 MP4 1개로 끝납니다. 분할이 발생하는 건 장시간 녹화에서입니다.

MP4 분할 조건 (Composite 모드):
─────────────────────────────────────────
  약 2시간 초과  OR  약 2GB 초과  → 새 MP4 파일 생성
  (maxVideoDuration 파라미터로 분할 기준 변경 가능)

30분 상담 (음성만):
  용량: ~10.8 MB → 분할 없음, MP4 1개 ✅

3시간 회의 (영상+음성):
  → recording_001.mp4 (0~2시간)
  → recording_002.mp4 (2~3시간)

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


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

Cloud Recording으로 S3에 저장된 MP4를 웹에서 재생하려면, presigned URL을 발급해서 브라우저에서 직접 스트리밍하는 방식이 가장 깔끔합니다. 백엔드는 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();

const s3 = new S3Client({
  region: 'ap-northeast-1',
  credentials: {
    accessKeyId: process.env.AWS_ACCESS_KEY_ID,
    secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY,
  },
});

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

  // Cloud Recording이 저장한 경로
  // fileNamePrefix: ["consultations", sessionId] 로 설정한 경우
  const key = `consultations/${sessionId}/recording.mp4`;

  const command = new GetObjectCommand({
    Bucket: process.env.S3_BUCKET,
    Key: key,
    ResponseContentType: 'video/mp4',  // 오디오만이어도 MP4 컨테이너
  });

  // 1시간 유효한 URL 생성
  const url = await getSignedUrl(s3, command, { expiresIn: 3600 });

  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가 프론트엔드에 노출되지 않습니다. presigned URL은 1시간 후 만료되니 링크가 유출돼도 시간이 지나면 무효화됩니다.
  • 오디오 전용 MP4도 재생 가능 — 음성만 녹화한 MP4라도 <audio> 태그로 재생됩니다. MP4는 컨테이너 포맷이라 영상 트랙 없이 오디오만 담겨 있어도 브라우저가 잘 처리합니다.

프로덕션에서는

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

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

녹화가 끝나면 Agora가 콜백으로 파일 경로와 메타데이터를 보내주기 때문에, 그걸 DB에 저장해두고 나중에 조회하는 흐름이 일반적입니다.


핵심 요약

  • TS 조각은 실시간 업로드 — 녹화 중 S3에 바로바로 올라갑니다. 끊겨도 이미 올라간 조각은 살아있어서 데이터 유실이 최소화됩니다.
  • MP4는 녹화 완료 후 생성 — 편의를 위한 가공물입니다. 끊기면 생성 안 되지만, TS + M3U8로 FFmpeg 복구가 가능합니다.
  • 30분 상담은 MP4 1개 — 분할은 2시간/2GB 초과 시에만 발생합니다.
  • presigned URL로 재생 — 백엔드는 URL만 발급, S3가 직접 스트리밍. 서버 부하 없이 보안도 확보됩니다.

© 2026 Frank Kim. All rights reserved.