블로그 목록
Backend8분 읽기

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

상담 통화를 Agora Cloud Recording으로 녹화하면 그 파일은 S3에 정확히 언제, 어떻게 올라갈까요? 녹화 중에 TS 조각이 실시간으로 업로드되는 구조부터, 네트워크가 끊겼을 때 어떤 파일이 살아남고 무엇이 깨지는지, 30분 상담이면 MP4가 왜 하나로 끝나는지까지 짚어봅니다. 마지막에는 S3에 쌓인 녹음을 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는 그걸 합쳐서 재생하기 편하게 만든 "가공물"입니다.

정정: "MP4는 녹화가 100% 끝나야만 만들어진다"는 건 정확한 표현이 아닙니다. avFileType: ["hls","mp4"]로 설정하면 녹화 도중에도 분할 임계치(아래 참고)에 도달하면 그 시점의 MP4 조각이 닫히고 다음 조각이 새로 열립니다. 즉 각 MP4 조각은 "그 구간이 끝나는 시점"에 마무리되며, 마지막 조각이 녹화 종료 시점에 닫히는 구조입니다. 짧은 단일 상담에서는 조각이 1개뿐이라 "녹화 종료 = 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 / Individual 모드):
─────────────────────────────────────────
  약 3시간 초과  OR  약 2GB 초과  → 새 MP4 파일 생성

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

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

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

[NEEDS VERIFICATION] 분할 임계치(약 3시간 / 약 2GB)는 녹화 모드와 SDK 버전에 따라 달라질 수 있으니 도입 전 최신 "Manage recorded files" 문서로 확인하세요. 과거 문서에는 "약 2시간"으로 표기된 적도 있습니다. 한편 임의로 분할 길이를 지정하는 maxVideoDuration 파라미터는 웹 페이지 녹화(Web page recording) 모드 전용으로, Composite/Individual 모드의 고정 분할 규칙과는 별개입니다.


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개 — 분할은 약 3시간 또는 약 2GB 초과 시에 발생합니다(모드·SDK 버전에 따라 다를 수 있어 최신 문서 확인 권장).
  • presigned URL로 재생 — 백엔드는 URL만 발급, S3가 직접 스트리밍. 서버 부하 없이 보안도 확보됩니다.

관련 글

참고 자료

© 2026 Frank Kim. All rights reserved.