INNORIX
전송 빌더전송 파인더개발자리소스고객사
무료 시작하기
INNORIX

LET FILES
MOVE THEMSELVES

INNORIX는 모든 시스템과 환경에서 파일 이동과 자동화를 제공하는 엔터프라이즈 파일 인프라 기업입니다.
5,000개 이상의 기업 및 공공기관에서 사용하고 있습니다.

시작하기

  • 필요한 전송 만들기
  • 필요한 전송 찾기

주요 전송

  • 팀 업무 폴더 동기화
  • 고객에게 대용량 파일 전송
  • 여러 시스템 파일 탐색
  • FTP·SFTP·SCP·rsync 전환
  • 앱에 파일 전송 추가
  • 웹 업로드·다운로드 적용
  • AI·데이터 워크플로 구축
  • 모든 전송 둘러보기→

개발자

  • 개발자 센터
  • 구현 예제
  • API 빠른 시작
  • 개발자 가이드
  • API 레퍼런스
  • GitHub

리소스

  • 리소스 센터
  • 제품 가이드
  • 외부 서비스 연동
  • 배포 및 관리
  • 도움말 센터

고객사

  • 정부
  • 공공부문
  • 제조
  • 엔지니어링
  • 금융
  • 유통
  • IT/통신
  • 미디어
  • 의료
  • 교육

플랜

  • 가격 및 플랜

회사

INNORIX 소개

Exabyter를 찾고 계신가요?

이제 INNORIX Platform에 통합되었습니다

기타 INNORIX 제품

Al.bert — 스마트 교통 AI

글로벌 오피스

  • 미국 뉴욕
  • 대한민국 서울
  • 베트남 호찌민
  • 오피스 위치 보기→

(C)2026 INNORIX. All rights reserved.

  • 보안
  • 상태
  • 이용약관
  • 개인정보 처리방침
  • 쿠키 정책
  1. 가이드
  2. 서로 다른 클라우드 스토리지 사이에서 파일 이동하기

서로 다른 클라우드 스토리지 사이에서 파일 이동하기

INNORIX에서 클라우드 스토리지 간 파일 전송을 구성하고 연결된 시스템 간 파일 작업을 일관되게 운영하는 방법을 알아보세요.

IT 엔지니어개발자
  • AI 학습 데이터와 모델 파일의 전체 흐름 자동화하기
  • AI 모델 파일을 여러 엣지 장비에 배포하기
  • 폐쇄망·망분리 환경에서 파일을 승인 반입·반출하기
  • 승인된 파일을 여러 지점에 동시에 배포하기
  • Azure Blob에서 사내 서버로 파일 전달하기
  • 데이터베이스 덤프, 백업, 아카이브 파일을 원격 보관처로 자동 전달하기
  • 지점·공장·엣지 장비의 파일을 중앙으로 수집하기
  • CI/CD 빌드 결과물을 여러 서버에 배포하기
  • 서로 다른 클라우드 스토리지 사이에서 파일 이동하기
  • 서로 다른 AWS 계정의 S3 버킷 사이에 파일 전송하기
  • 고객별 파일 작업 공간 제공하기
  • 고객에게 만료 조건이 있는 대용량 다운로드 링크 제공하기
  • 고객이 브라우저에서 대용량 파일을 업로드하게 하기
  • 고객 작업 공간으로 대용량 파일 직접 보내기
  • 데이터베이스 백업 파일을 오브젝트 스토리지에 보관하기
  • Datadog으로 파일 전송 실패와 복구 알림 받기
  • 웹, 앱, 업무 시스템에 파일 전송 기능 추가하기
  • 파일 도착 후 검증·변환·후속 작업 실행하기
  • NAS, 파일 서버의 대량 파일을 클라우드로 이전하기
  • FTP 배치 작업을 관리형 파일 흐름으로 전환하기
  • Google Cloud Storage에서 Amazon S3로 직접 전송하기
  • Grafana에서 파일 전송 상태 대시보드 만들기
  • 전송된 파일의 해시값으로 무결성 자동 검증하기
  • 새 파일이 생기면 지정한 곳으로 자동 전송하기
  • Kubernetes에서 오브젝트 스토리지로 결과 파일 보내기
  • Git 밖의 대용량 파일과 빌드 결과물 자동 전달하기
  • Git으로 관리하기 어려운 파일을 자동 전달하기
  • 분산 서버의 로그·진단 파일을 중앙으로 모으기
  • 미디어 원본과 처리 결과를 단계별로 자동 전달하기
  • 수신 파일을 Microsoft Defender로 검사하고 후속 처리하기
  • 여러 단계의 파일 전송을 하나의 흐름으로 자동화하기
  • 승인된 파일을 여러 팀·지점에 자동 배포하기
  • 협력사 파일을 업무 시스템으로 자동 분류하기
  • 협력사·공급망과 정기적으로 파일 교환하기
  • 매일·매주 반복되는 파일 전송 자동화하기
  • 네트워크 중단 후 파일 전송을 자동으로 재개하기
  • rsync 작업을 관리형 파일 흐름으로 전환하기
  • 정해진 시간에 반복 파일 전송 예약하기
  • 소프트웨어·펌웨어를 여러 장비에 배포하고 결과 확인하기
  • 소프트웨어 패키지를 여러 서버·지점에 배포하기
  • 팀 폴더의 변경 파일을 여러 장비에 자동 반영하기
  • 팀 업무 폴더를 여러 PC에 자동 반영하기
  • 여러 장비의 파일을 한곳에서 찾고 직접 전송하기
  • 웹사이트에 대용량 파일 업로드·다운로드 기능 추가하기
  • 채널을 구독해 새 파일을 자동으로 받기
  • Amazon S3에서 Azure Blob으로 직접 전송하기
  • Amazon S3에서 Cloudflare R2로 파일 이전하기
  • Amazon S3 파일을 Linux 서버로 자동 내려받기
  • SCP 셸 스크립트를 CLI 기반 전송으로 전환하기
  • SFTP 계정과 배치 전송을 중앙에서 관리하기
  • Slack·Teams에서 파일 전송을 실행하고 상태 알림 받기
  • Windows 폴더의 파일을 Amazon S3로 자동 업로드하기
  • Windows와 Linux 서버 사이에 파일을 직접 전송하기

시작하기#

기본 개념#

클라우드 서비스와 계정·리전 사이에서 파일과 오브젝트 전송하기

파일과 오브젝트는 클라우드 서비스, 계정, 프로젝트, 리전에 따라 각각 다른 저장 환경에서 관리됩니다.

클라우드 스토리지 간 전송은 원본 스토리지의 파일과 오브젝트를 지정된 대상 스토리지로 전송하고, 필요한 계정과 리전 사이의 데이터 이동 경로를 구성하는 방식입니다.

Source Storage
      │
      ▼
Transfer Flow
      │
      ▼
Target Storage

예를 들어 Amazon S3에서 Azure Blob으로 전송하거나, 같은 클라우드 서비스 내의 서로 다른 계정과 리전을 연결해 파일 흐름을 구성할 수 있습니다.

전송 흐름#

원본 선택부터 대상 스토리지 반영까지 연결하기

클라우드 간 전송은 원본 스토리지와 대상 스토리지를 연결한 후, 전송할 파일을 선택하고 설정한 경로에 반영하는 흐름으로 진행합니다.

원본 스토리지
      │
      ▼
계정·리전 연결
      │
      ▼
전송 경로 설정
      │
      ▼
파일·오브젝트 전송
      │
      ▼
대상 스토리지 반영

활용 효과#

분산된 클라우드 데이터를 필요한 환경으로 이동하기

클라우드 스토리지 간 전송을 활용하면 서비스, 계정, 리전에 분산된 데이터를 업무와 운영 환경에 맞는 저장 위치로 이동할 수 있습니다.

활용 환경 전송 흐름
서비스 간 이동 Amazon S3 → Azure Blob
계정 간 이동 Account A → Account B
리전 간 이동 Region A → Region B
처리 결과 관리 Processing Storage → Archive Storage

IT 엔지니어#

클라우드 간 대용량 파일 전송 환경을 구성하고 운영하기

스토리지 연결#

전송할 클라우드 환경과 접근 범위 구성하기

먼저 Amazon S3, Azure Blob, GCS, Cloudflare R2 등 사용할 클라우드 스토리지를 연결합니다.

각 스토리지의 계정, 프로젝트, 리전과 접근 범위를 설정해 전송 작업에 사용할 환경을 구성합니다.

Amazon S3 ──────┐
Azure Blob ─────┤
GCS ────────────┼──→ Transfer Environment
Cloudflare R2 ──┘

이 단계에서 전송 환경과 접근 범위를 함께 구성하므로, 기존의 스토리지 연결과 계정·리전 내용을 하나의 단계로 병합합니다.

전송 경로#

원본 버킷과 대상 저장 위치 연결하기

전송에 사용할 원본 버킷 또는 컨테이너와 대상 저장 위치를 지정하고 파일 이동 경로를 구성합니다.

필요한 경우 서로 다른 서비스, 계정, 리전의 저장 위치를 하나의 전송 작업으로 연결할 수 있습니다.

Source
Account A / Region 1
Bucket: media-source
          │
          ▼
      Transfer
          │
          ▼
Target
Account B / Region 2
Bucket: media-archive

전송 기준#

파일 규모와 처리 조건에 맞춰 전송 작업 구성하기

대용량 파일과 다수의 오브젝트를 전송할 때는 처리할 파일 범위와 실행 조건을 함께 설정합니다.

파일 경로, 유형, 이름 등의 조건을 기준으로 전송 대상을 지정하고, 정해진 일정이나 파일 생성, 외부 요청에 따라 작업을 실행하도록 구성할 수 있습니다.

Source Storage
       │
       ▼
   Transfer Rules
       │
 ┌─────┼──────────┐
 ▼                ▼
Files          Objects
 │                │
 └──────┬─────────┘
        ▼
   Transfer Run
        │
        ▼
 Target Storage

기존의 대량 전송과 전송 조건은 실제 전송 작업을 구성하는 기준이라는 공통 목적을 가지므로 하나의 단계로 병합합니다.

결과 확인#

전송된 파일과 대상별 처리 상태 확인하기

전송 작업이 실행되면 Runs에서 원본과 대상 스토리지, 진행률, 처리된 파일 수와 전체 용량, 실행 상태를 확인할 수 있습니다.

Transfer Run
     │
     ├── Source / Target
     │
     ├── Trigger
     │
     ├── Progress
     │
     └── Status

작업별 실행 결과를 확인하면 각 대상 스토리지에 파일과 오브젝트가 반영된 상태를 관리할 수 있습니다.

운영 관리#

여러 클라우드 환경의 전송 작업을 중앙에서 관리하기

여러 클라우드 서비스와 계정, 리전을 사용하는 경우에는 각 전송 작업의 실행 기록과 처리 상태를 함께 확인할 수 있습니다.

특정 전송 작업에서 추가 확인이 필요한 경우 상세 실행 결과를 확인하고 필요한 작업을 다시 실행합니다.

             Cloud Operations
                    │
       ┌────────────┼────────────┐
       ▼            ▼            ▼
   S3 Transfer  Azure Transfer  GCS Transfer
       │            │            │
       └────────────┼────────────┘
                    ▼
             Runs & Results

관리 항목 확인 내용
클라우드 환경 서비스, 계정과 리전
전송 경로 원본과 대상 저장 위치
전송 기준 파일 범위와 실행 조건
실행 상태 진행 중인 작업과 완료 결과
처리 결과 전송된 파일과 오브젝트 정보

개발자#

서로 다른 클라우드 스토리지 간에 오브젝트를 전송하고 개수를 대조해 확인하기

연동 준비#

공통 호출 코드와 상태 값 준비하기

import os
import requests

BASE_URL = os.getenv("INNORIX_BASE_URL", "https://app.innorix.com").rstrip("/")
TOKEN = os.environ["INNORIX_ACCESS_TOKEN"]
WORKSPACE_ID = os.getenv("INNORIX_WORKSPACE_ID")   # optional; falls back to the current workspace

STATUS_COMPLETE = 2
TERMINAL = {2, 4, 5, 9, 99}          # complete / error / cancelled / partial / failed
NOT_SUCCEEDED = {4, 5, 9, 99}


def api(method, path, body=None, params=None):
    headers = {
        "Content-Type": "application/json",
        "Authorization": f"Bearer {TOKEN}",
    }

    if WORKSPACE_ID:
        headers["x-workspace-id"] = WORKSPACE_ID

    response = requests.request(
        method, BASE_URL + path,
        headers=headers, json=body, params=params, timeout=30,
    )

    payload = response.json() if response.content else {}

    if not response.ok:
        raise RuntimeError(payload.get("message") or f"HTTP {response.status_code}")

    return payload.get("data")


def is_terminal(detail):
    return detail.get("isTerminal", detail.get("status") in TERMINAL)
// InnorixClient.java
public static final String BASE_URL =
        env("INNORIX_BASE_URL", "https://app.innorix.com").replaceAll("/+
quot;
, ""); public static final String WORKSPACE_ID = env("INNORIX_WORKSPACE_ID", null); public static final int STATUS_COMPLETE = 2; // States the transfer no longer moves out of public static final Set<Integer> TERMINAL = Set.of(2, 4, 5, 9, 99); // Terminal states that are not a full success public static final Set<Integer> NOT_SUCCEEDED = Set.of(4, 5, 9, 99); private HttpRequest.Builder headers(HttpRequest.Builder builder) { builder.header("Content-Type", "application/json") .header("Authorization", "Bearer " + session.accessToken()); // When omitted the account's current workspace is used. if (workspaceId != null) builder.header("x-workspace-id", workspaceId); return builder; } /** Unwraps and returns data from the response. Throws ApiError on failure. */ public Object api(String method, String path, Object body, Map<String, Object> params) { Resp response = request(method, path, body, params); Object payload = null; try { payload = Json.parse(response.text()); } catch (RuntimeException ignored) { payload = null; } if (!response.ok()) { Map<String, Object> map = Json.asObj(payload); String message = Json.str(map, "message", Json.str(map, "error", "unknown error")); throw new ApiError(response.status, message, map); } return Json.get(payload, "data"); } /** Use the server flag when present, otherwise fall back to the status code. */ public static boolean isTerminal(Map<String, Object> record) { Boolean flag = Json.boolOrNull(record, "isTerminal"); if (flag != null) return flag; Integer status = Json.intOrNull(record, "status"); return status != null && TERMINAL.contains(status); }
// innorix-client.js
const BASE_URL = (process.env.INNORIX_BASE_URL
  || "https://app.innorix.com").replace(/\/+$/, "");
const TOKEN = process.env.INNORIX_ACCESS_TOKEN;
const WORKSPACE_ID = process.env.INNORIX_WORKSPACE_ID || null;

export const STATUS_COMPLETE = 2;
export const TERMINAL = new Set([2, 4, 5, 9, 99]);       // complete / error / cancelled / partial / failed
export const NOT_SUCCEEDED = new Set([4, 5, 9, 99]);

export async function api(method, path, body = null, params = null) {
  const url = new URL(BASE_URL + path);

  if (params) {
    for (const [key, value] of Object.entries(params)) {
      if (value === undefined || value === null) continue;
      url.searchParams.set(key, String(value));
    }
  }

  const headers = {
    "Content-Type": "application/json",
    Authorization: `Bearer ${TOKEN}`,
  };

  // When omitted the account's current workspace is used.
  if (WORKSPACE_ID) headers["x-workspace-id"] = WORKSPACE_ID;

  const response = await fetch(url, {
    method,
    headers,
    body: body === null ? undefined : JSON.stringify(body),
    signal: AbortSignal.timeout(30_000),
  });

  const payload = await response.json().catch(() => ({}));

  if (!response.ok) {
    throw new Error(payload.message || `HTTP ${response.status}`);
  }

  return payload.data;
}

export function isTerminal(detail) {
  return detail.isTerminal !== undefined
    ? detail.isTerminal
    : TERMINAL.has(detail.status);
}
// InnorixClient.cs
public static readonly string BaseUrl =
    Env("INNORIX_BASE_URL", "https://app.innorix.com").TrimEnd('/');
public static readonly string WorkspaceIdFromEnv = Env("INNORIX_WORKSPACE_ID", null);

public const int StatusComplete = 2;
/// <summary>States the transfer no longer moves out of</summary>
public static readonly HashSet<int> Terminal = new HashSet<int> { 2, 4, 5, 9, 99 };
/// <summary>Terminal states that are not a full success</summary>
public static readonly HashSet<int> NotSucceeded = new HashSet<int> { 4, 5, 9, 99 };

// Applied on every request
request.Headers.TryAddWithoutValidation("Authorization", "Bearer " + Session.AccessToken);
// When omitted the account's current workspace is used.
if (WorkspaceId != null) request.Headers.TryAddWithoutValidation("x-workspace-id", WorkspaceId);

public async Task<JsonNode> ApiAsync(string method, string path, JsonNode body = null,
    IDictionary<string, object> parameters = null)
{
    Resp response = await RequestAsync(method, path, body, parameters).ConfigureAwait(false);
    JsonNode payload = null;

    try
    {
        payload = J.Parse(response.Text());
    }
    catch (Exception)
    {
        payload = null;
    }

    if (!response.Ok)
    {
        JsonObject map = J.AsObj(payload);
        string message = J.Str(map, "message", J.Str(map, "error", "unknown error"));
        throw new ApiError(response.Status, message, map);
    }

    return J.Get(payload, "data");
}

/// <summary>Use the server flag when present, otherwise fall back to the status code.</summary>
public static bool IsTerminal(JsonObject record)
{
    bool? flag = J.BoolOrNull(record, "isTerminal");
    if (flag != null) return flag.Value;

    int? status = J.IntOrNull(record, "status");
    return status != null && Terminal.Contains(status.Value);
}

전송 상태는 아래 값으로 판단합니다. 종료 상태는 다섯 개이고 성공에 해당하는 값은 완료(2)입니다.

상태 값 의미 종료
2 완료 예
4 오류 예
5 취소 예
9 부분 완료 예
99 실패 예
1 · 6 · 12 · 13 시작·전송 중·동기화 중·수신 중 아니오

스토리지 지정#

연결된 스토리지의 장비 식별자와 경로 정하기

연결된 클라우드 스토리지는 전송 대상 장비로 나타납니다. 목록에서 식별자를 확인합니다.

result = api("GET", "/api/devices", params={"page": 1, "size": 200}) or {}

for device in result.get("devices") or []:
    print(device["deviceId"], device["name"], device.get("os"))
Map<String, Object> result = client.apiObj("GET", "/api/devices", null,
        Json.newObj("page", 1, "size", 200));

for (Object node : Json.arrOf(result, "devices")) {
    Map<String, Object> device = Json.asObj(node);
    System.out.println(Json.str(device, "deviceId") + " "
            + Json.str(device, "name") + " " + Json.str(device, "os"));
}
const result = (await client.api("GET", "/api/devices", null,
  { page: 1, size: 200 })) || {};

for (const device of result.devices || []) {
  console.log(device.deviceId, device.name, device.os);
}
JsonObject result = await client.ApiObjAsync("GET", "/api/devices", null,
    new Dictionary<string, object> { ["page"] = 1, ["size"] = 200 });

foreach (JsonNode node in J.ArrOf(result, "devices"))
{
    JsonObject device = J.AsObj(node);
    Console.WriteLine(
quot;{J.Str(device, "deviceId")} {J.Str(device, "name")} "
+
quot;{J.Str(device, "os")}"
); }

장비 목록은 data.devices 배열로 반환됩니다.

이름으로 다룬다면 식별자로 바꿉니다. 계정과 리전이 다른 같은 서비스가 여러 개 연결된 경우 이름이 비슷해 혼동될 수 있습니다.

def resolve_device(name):
    result = api("GET", "/api/devices/resolve", params={"name": name}) or {}
    devices = result.get("devices") or []
    count = result.get("matchCount", len(devices))

    if count != 1 or not devices:
        raise RuntimeError(f"{name}: {count} matches - use a more specific name")

    return devices[0]["deviceId"]
String resolveDevice(String name) {
    Map<String, Object> result = client.apiObj("GET", "/api/devices/resolve", null,
            Json.newObj("name", name));
    List<Object> devices = Json.arrOf(result, "devices");
    int count = Json.intOr(result, "matchCount", devices.size());

    if (count != 1 || devices.isEmpty()) {
        throw new RuntimeException(name + ": " + count + " matches - use a more specific name");
    }

    return Json.str(Json.asObj(devices.get(0)), "deviceId");
}
async function resolveDevice(name) {
  const result = (await client.api("GET", "/api/devices/resolve",
    null, { name })) || {};
  const devices = result.devices || [];
  const count = result.matchCount ?? devices.length;

  if (count !== 1 || devices.length === 0) {
    throw new Error(`${name}: ${count} matches - use a more specific name`);
  }

  return devices[0].deviceId;
}
async Task<string> ResolveDeviceAsync(string name)
{
    JsonObject result = await client.ApiObjAsync("GET", "/api/devices/resolve",
        null, new Dictionary<string, object> { ["name"] = name });
    var devices = J.ArrOf(result, "devices");
    int count = J.Int(result, "matchCount", devices.Count);

    if (count != 1 || devices.Count == 0)
    {
        throw new Exception(
quot;{name}: {count} matches - use a more specific name"
); } return J.Str(J.AsObj(devices[0]), "deviceId"); }

버킷과 컨테이너 아래의 경로는 접두사를 그대로 씁니다. 스토리지 서비스가 접두사 형태의 경로를 그대로 받으므로 별도로 가공하지 않습니다.

전송 실행#

원본 접두사의 오브젝트를 대상으로 보내기

def move_objects(source, target, source_prefix, target_prefix):
    transfer = api("POST", "/api/transfers/manual", {
        "sourceDevice": source,
        "targetDevice": target,
        "targetPath": target_prefix,
        "sourcePaths": [source_prefix],
        "sendAllFolder": True,
        "transferOptions": {"target-action": "overwrite"},
    })

    return transfer["monitorId"]
String moveObjects(String source, String target,
                   String sourcePrefix, String targetPrefix) {
    Map<String, Object> transfer = client.apiObj("POST", "/api/transfers/manual", Json.newObj(
            "sourceDevice", source,
            "targetDevice", target,
            "targetPath", targetPrefix,
            "sourcePaths", List.of(sourcePrefix),
            "sendAllFolder", true,
            "transferOptions", Json.newObj("target-action", "overwrite")));

    return Json.str(transfer, "monitorId");
}
async function moveObjects(source, target, sourcePrefix, targetPrefix) {
  const transfer = await client.api("POST", "/api/transfers/manual", {
    sourceDevice: source,
    targetDevice: target,
    targetPath: targetPrefix,
    sourcePaths: [sourcePrefix],
    sendAllFolder: true,
    transferOptions: { "target-action": "overwrite" },
  });

  return transfer.monitorId;
}
async Task<string> MoveObjectsAsync(string source, string target,
    string sourcePrefix, string targetPrefix)
{
    JsonObject transfer = await client.ApiObjAsync("POST", "/api/transfers/manual",
        new JsonObject
        {
            ["sourceDevice"] = source,
            ["targetDevice"] = target,
            ["targetPath"] = targetPrefix,
            ["sourcePaths"] = new JsonArray { sourcePrefix },
            ["sendAllFolder"] = true,
            ["transferOptions"] = new JsonObject { ["target-action"] = "overwrite" },
        });

    return J.Str(transfer, "monitorId");
}

서비스 간 이동에서는 overwrite가 안전합니다. numbering을 쓰면 오브젝트 키가 바뀌어 애플리케이션이 참조하던 경로와 어긋납니다.

전송할 대상을 미리 확인하려면 원본 스토리지의 목록을 조회합니다.

result = api("GET", f"/api/devices/{SOURCE}/files", params={
    "path": "media/2026/09",
    "page": 1, "size": 200, "type": "file",
})

print(result.get("total"), "objects")

if result.get("truncated"):
    print("result was truncated - narrow the prefix")
Map<String, Object> result = client.apiObj("GET", "/api/devices/" + SOURCE + "/files", null,
        Json.newObj("path", "media/2026/09",
                    "page", 1, "size", 200, "type", "file"));

System.out.println(Json.str(result, "total") + " objects");

if (Json.bool(result, "truncated", false)) {
    System.out.println("result was truncated - narrow the prefix");
}
const result = await client.api("GET", `/api/devices/${SOURCE}/files`, null, {
  path: "media/2026/09",
  page: 1, size: 200, type: "file",
});

console.log(result.total, "objects");

if (result.truncated) {
  console.log("result was truncated - narrow the prefix");
}
JsonObject result = await client.ApiObjAsync("GET", 
quot;/api/devices/{SOURCE}/files"
, null, new Dictionary<string, object> { ["path"] = "media/2026/09", ["page"] = 1, ["size"] = 200, ["type"] = "file", }); Console.WriteLine(
quot;{J.Str(result, "total")} objects"
); if (J.Bool(result, "truncated", false)) { Console.WriteLine("result was truncated - narrow the prefix"); }

대량 오브젝트 처리#

접두사로 나눠 여러 전송으로 옮기기

버킷 하나에 수백만 개의 오브젝트가 있으면 한 번에 보내기 어렵습니다. 접두사로 나누면 실패한 구간만 재전송할 수 있습니다.

PREFIXES = [f"media/2026/{month:02d}" for month in range(1, 13)]

transfers = {
    prefix: move_objects(SOURCE, TARGET, prefix,
                         prefix.replace("media", "archive", 1))
    for prefix in PREFIXES
}
List<String> prefixes = new ArrayList<>();
for (int month = 1; month <= 12; month++) {
    prefixes.add(String.format("media/2026/%02d", month));
}

Map<String, String> transfers = new LinkedHashMap<>();
for (String prefix : prefixes) {
    transfers.put(prefix, moveObjects(SOURCE, TARGET, prefix,
            prefix.replaceFirst("media", "archive"), true));
}
const PREFIXES = Array.from({ length: 12 },
  (_, i) => `media/2026/${String(i + 1).padStart(2, "0")}`);

const transfers = {};
for (const prefix of PREFIXES) {
  transfers[prefix] = await moveObjects(SOURCE, TARGET, prefix,
    prefix.replace("media", "archive"));
}
var prefixes = Enumerable.Range(1, 12).Select(m => 
quot;media/2026/{m:D2}"
); var transfers = new Dictionary<string, string>(); foreach (string prefix in prefixes) { var target = new Regex("media").Replace(prefix, "archive", 1); transfers[prefix] = await MoveObjectsAsync(SOURCE, TARGET, prefix, target); }
def wait(monitor_id, timeout=3600, interval=3):
    deadline = time.time() + timeout

    while time.time() < deadline:
        detail = api("GET", f"/api/transfers/{monitor_id}")

        if is_terminal(detail):
            return detail

        time.sleep(interval)

    raise TimeoutError(monitor_id)


def failed_files(monitor_id):
    result = api("GET", f"/api/transfers/{monitor_id}/files", params={
        "state": "any", "size": 500,
    }) or {}

    return [r for r in (result.get("children") or [])
            if r.get("status") in NOT_SUCCEEDED]


def retry_failed(monitor_id):
    rows = failed_files(monitor_id)

    if not rows:
        return 0

    api("POST", f"/api/transfers/{monitor_id}/retry", {
        "filesRetry": [
            {"filePath": r["sourceFilePath"], "isDir": bool(r.get("isFolder"))}
            for r in rows
        ]
    })

    return len(rows)
public Map<String, Object> wait(String monitorId, int timeoutSeconds, int intervalSeconds) {
    long deadline = System.currentTimeMillis() + timeoutSeconds * 1000L;

    while (System.currentTimeMillis() < deadline) {
        Map<String, Object> detail = apiObj("GET", "/api/transfers/" + monitorId);

        if (isTerminal(detail)) return detail;

        sleep(intervalSeconds * 1000L);
    }

    throw new ApiError(0, "timeout waiting for " + monitorId);
}

public List<Map<String, Object>> failedFiles(String monitorId) {
    List<Map<String, Object>> failed = new ArrayList<>();

    for (Map<String, Object> row : transferFiles(monitorId)) {
        Integer status = Json.intOrNull(row, "status");
        if (status != null && NOT_SUCCEEDED.contains(status)) failed.add(row);
    }

    return failed;
}

public int retryFailed(String monitorId) {
    List<Map<String, Object>> rows = failedFiles(monitorId);
    if (rows.isEmpty()) return 0;

    List<Object> filesRetry = new ArrayList<>();

    for (Map<String, Object> row : rows) {
        String path = Json.str(row, "sourceFilePath");
        if (path == null) continue;
        filesRetry.add(Json.newObj("filePath", path, "isDir", Json.bool(row, "isFolder", false)));
    }

    api("POST", "/api/transfers/" + monitorId + "/retry", Json.newObj("filesRetry", filesRetry));

    return rows.size();
}
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

export async function wait(monitorId, { timeout = 3600, interval = 3 } = {}) {
  const deadline = Date.now() + timeout * 1000;

  while (Date.now() < deadline) {
    const detail = await api("GET", `/api/transfers/${monitorId}`);

    if (isTerminal(detail)) return detail;

    await sleep(interval * 1000);
  }

  throw new Error(`timeout waiting for ${monitorId}`);
}

export async function failedFiles(monitorId) {
  const result = (await api("GET", `/api/transfers/${monitorId}/files`, null, {
    state: "any", size: 500,
  })) || {};

  return (result.children || []).filter((row) => NOT_SUCCEEDED.has(row.status));
}

export async function retryFailed(monitorId) {
  const rows = await failedFiles(monitorId);

  if (rows.length === 0) return 0;

  await api("POST", `/api/transfers/${monitorId}/retry`, {
    filesRetry: rows
      .filter((row) => row.sourceFilePath)
      .map((row) => ({ filePath: row.sourceFilePath, isDir: Boolean(row.isFolder) })),
  });

  return rows.length;
}
public async Task<JsonObject> WaitAsync(string monitorId, int timeoutSeconds = 3600,
    int intervalSeconds = 3)
{
    long deadline = Environment.TickCount64 + timeoutSeconds * 1000L;

    while (Environment.TickCount64 < deadline)
    {
        JsonObject detail = await ApiObjAsync("GET", "/api/transfers/" + monitorId)
            .ConfigureAwait(false);

        if (IsTerminal(detail)) return detail;

        await Task.Delay(intervalSeconds * 1000).ConfigureAwait(false);
    }

    throw new ApiError(0, "timeout waiting for " + monitorId);
}

public async Task<List<JsonObject>> FailedFilesAsync(string monitorId)
{
    List<JsonObject> rows = await TransferFilesAsync(monitorId).ConfigureAwait(false);

    return rows.Where(row =>
    {
        int? status = J.IntOrNull(row, "status");
        return status != null && NotSucceeded.Contains(status.Value);
    }).ToList();
}

/// <summary>Only callable once the transfer has reached a terminal state.</summary>
public async Task<int> RetryFailedAsync(string monitorId)
{
    List<JsonObject> rows = await FailedFilesAsync(monitorId).ConfigureAwait(false);
    if (rows.Count == 0) return 0;

    var filesRetry = new JsonArray();

    foreach (JsonObject row in rows)
    {
        string path = J.Str(row, "sourceFilePath");
        if (path == null) continue;

        filesRetry.Add(new JsonObject
        {
            ["filePath"] = path,
            ["isDir"] = J.Bool(row, "isFolder", false),
        });
    }

    await ApiAsync("POST", "/api/transfers/" + monitorId + "/retry",
        new JsonObject { ["filesRetry"] = filesRetry }).ConfigureAwait(false);

    return rows.Count;
}
for prefix, monitor_id in transfers.items():
    detail = wait(monitor_id, timeout=14400)

    print(f"{prefix:24} {detail.get('statusName')}"
          f" {detail.get('fileCount')} objects {detail.get('totalSize')} bytes")

    if detail["status"] != STATUS_COMPLETE:
        print(f"  retried {retry_failed(monitor_id)} files")
for (var entry : transfers.entrySet()) {
    Map<String, Object> detail = client.await(entry.getValue(), 14400, null);

    System.out.printf("%-24s %s %s objects %s bytes%n",
            entry.getKey(), Json.str(detail, "statusName"),
            Json.str(detail, "fileCount"), Json.str(detail, "totalSize"));

    if (Json.intOr(detail, "status", -1) != InnorixClient.STATUS_COMPLETE) {
        System.out.println("  retried " + client.retryFailed(entry.getValue()) + " files");
    }
}
for (const [prefix, monitorId] of Object.entries(transfers)) {
  const detail = await wait(monitorId, { timeout: 14400 });

  console.log(`${prefix.padEnd(24)} ${detail.statusName}`
    + ` ${detail.fileCount} objects ${detail.totalSize} bytes`);

  if (detail.status !== STATUS_COMPLETE) {
    console.log(`  retried ${await retryFailed(monitorId)} files`);
  }
}
foreach (var (prefix, monitorId) in transfers)
{
    JsonObject detail = await client.WaitAsync(monitorId, 14400);

    Console.WriteLine(
quot;{prefix,-24} {J.Str(detail, "statusName")}"
+
quot; {J.Str(detail, "fileCount")} objects {J.Str(detail, "totalSize")} bytes"
); if (J.IntOrNull(detail, "status") != InnorixClient.StatusComplete) { Console.WriteLine(
quot; retried {await client.RetryFailedAsync(monitorId)} files"
); } }

한 범위가 실패해도 나머지를 계속 확인해야 어디까지 옮겼는지 알 수 있습니다.

조건부 전송#

유형과 크기로 옮길 오브젝트 고르기

전체가 아니라 특정 파일만 옮겨야 하는 경우가 많습니다.

def build_filter(exts=None, min_size=None, exclude=None):
    file_option = {}

    if exts:
        # extension whitelist, without the leading dot
        file_option["extension"] = {
            "extension": [e.lstrip(".").lower() for e in exts],
            "allow": True,
        }

    if min_size is not None:
        # over and equal both True means size or larger
        file_option["fileSize"] = {"size": min_size, "over": True, "equal": True}

    if exclude:
        # allow=False excludes files whose name contains this. Server matching is case sensitive.
        file_option["fileName"] = {"name": exclude, "allow": False}

    return {"send-fileoption": file_option} if file_option else {}
public static Map<String, Object> buildFilter(List<String> exts, Long minSize, String exclude) {
    Map<String, Object> fileOption = new LinkedHashMap<>();

    if (exts != null && !exts.isEmpty()) {
        List<Object> cleaned = new ArrayList<>();
        // e.g. ["mp4","mov"] (no dot). allow=true -> whitelist
        for (String ext : exts) cleaned.add(ext.replaceAll("^\\.+", "").toLowerCase());
        fileOption.put("extension", Json.newObj("extension", cleaned, "allow", true));
    }

    if (minSize != null) {
        // over=true/equal=true -> only files at or above size
        fileOption.put("fileSize", Json.newObj("size", minSize, "over", true, "equal", true));
    }

    if (exclude != null) {
        // allow=false -> skip files whose name contains exclude (server match is case sensitive)
        fileOption.put("fileName", Json.newObj("name", exclude, "allow", false));
    }

    return fileOption.isEmpty()
            ? new LinkedHashMap<>()
            : Json.newObj("send-fileoption", fileOption);
}
export function buildFilter({ exts = null, minSize = null, exclude = null } = {}) {
  const fileOption = {};

  if (exts) {
    // Extension whitelist, without the leading dot.
    fileOption.extension = {
      extension: exts.map((e) => e.replace(/^\./, "").toLowerCase()),
      allow: true,
    };
  }

  if (minSize !== null && minSize !== undefined) {
    // over and equal both true means size or larger.
    fileOption.fileSize = { size: minSize, over: true, equal: true };
  }

  if (exclude) {
    // allow=false excludes files whose name contains this. Matching is case sensitive.
    fileOption.fileName = { name: exclude, allow: false };
  }

  return Object.keys(fileOption).length ? { "send-fileoption": fileOption } : {};
}
public static JsonObject BuildFilter(IReadOnlyList<string> exts = null,
    long? minSize = null, string exclude = null)
{
    var fileOption = new JsonObject();

    if (exts != null && exts.Count > 0)
    {
        // e.g. ["mp4","mov"] (no dot). allow=true -> whitelist
        var cleaned = new JsonArray();
        foreach (string ext in exts) cleaned.Add(ext.TrimStart('.').ToLowerInvariant());

        fileOption["extension"] = new JsonObject { ["extension"] = cleaned, ["allow"] = true };
    }

    if (minSize != null)
    {
        // over=true/equal=true -> only files at or above size
        fileOption["fileSize"] = new JsonObject
        {
            ["size"] = minSize.Value,
            ["over"] = true,
            ["equal"] = true,
        };
    }

    if (exclude != null)
    {
        // allow=false -> skip files whose name contains exclude (server match is case sensitive)
        fileOption["fileName"] = new JsonObject { ["name"] = exclude, ["allow"] = false };
    }

    return fileOption.Count == 0
        ? new JsonObject()
        : new JsonObject { ["send-fileoption"] = fileOption };
}

확장자 필터는 send-fileoption.extension을 씁니다. send-filetype-cus 정규식은 확장자를 뗀 파일명에만 매칭되므로 확장자 조건으로는 동작하지 않습니다.

필터 위치 동작
확장자 send-fileoption.extension allow: true 면 이 확장자만 전송
크기 send-fileoption.fileSize over·equal 로 이상·이하 지정
이름 send-fileoption.fileName allow: false 면 포함된 파일 제외

여러 필터를 함께 주면 AND로 결합됩니다. 모두 통과한 파일만 전송됩니다.

options = {
    **build_filter(exts=["mp4", "mov"], min_size=1048576),
    "target-action": "overwrite",
}

api("POST", "/api/transfers/manual", {
    "sourceDevice": SOURCE,
    "targetDevice": TARGET,
    "targetPath": "archive/2026/09",
    "sourcePaths": ["media/2026/09"],
    "sendAllFolder": True,
    "transferOptions": options,
})
Map<String, Object> options = new LinkedHashMap<>(
        InnorixClient.buildFilter(List.of("mp4", "mov"), 1048576L, null));
options.put("target-action", "overwrite");

client.api("POST", "/api/transfers/manual", Json.newObj(
        "sourceDevice", SOURCE,
        "targetDevice", TARGET,
        "targetPath", "archive/2026/09",
        "sourcePaths", List.of("media/2026/09"),
        "sendAllFolder", true,
        "transferOptions", options));
const options = {
  ...buildFilter({ exts: ["mp4", "mov"], minSize: 1048576 }),
  "target-action": "overwrite",
};

await client.api("POST", "/api/transfers/manual", {
  sourceDevice: SOURCE,
  targetDevice: TARGET,
  targetPath: "archive/2026/09",
  sourcePaths: ["media/2026/09"],
  sendAllFolder: true,
  transferOptions: options,
});
var options = new JsonObject(
    InnorixClient.BuildFilter(new[] { "mp4", "mov" }, minSize: 1048576)
        .DeepClone().AsObject())
{
    ["target-action"] = "overwrite",
};

await client.ApiAsync("POST", "/api/transfers/manual", new JsonObject
{
    ["sourceDevice"] = SOURCE,
    ["targetDevice"] = TARGET,
    ["targetPath"] = "archive/2026/09",
    ["sourcePaths"] = new JsonArray { "media/2026/09" },
    ["sendAllFolder"] = true,
    ["transferOptions"] = options,
});

변경분 전송#

마지막 이동 이후 추가된 오브젝트만 보내기

같은 접두사를 주기적으로 옮기는 구성이라면 전체를 재전송할 필요가 없습니다.

transfer = api("POST", "/api/transfers/manual", {
    "sourceDevice": SOURCE,
    "targetDevice": TARGET,
    "targetPath": "archive/2026/09",
    "sourcePaths": ["media/2026/09"],
    "sendAllFolder": True,
    "incremental": True,
    "transferOptions": {"target-action": "overwrite"},
})
Map<String, Object> transfer = client.apiObj("POST", "/api/transfers/manual", Json.newObj(
        "sourceDevice", SOURCE,
        "targetDevice", TARGET,
        "targetPath", "archive/2026/09",
        "sourcePaths", List.of("media/2026/09"),
        "sendAllFolder", true,
        "incremental", true,
        "transferOptions", Json.newObj("target-action", "overwrite")));
const transfer = await client.api("POST", "/api/transfers/manual", {
  sourceDevice: SOURCE,
  targetDevice: TARGET,
  targetPath: "archive/2026/09",
  sourcePaths: ["media/2026/09"],
  sendAllFolder: true,
  incremental: true,
  transferOptions: { "target-action": "overwrite" },
});
JsonObject transfer = await client.ApiObjAsync("POST", "/api/transfers/manual",
    new JsonObject
    {
        ["sourceDevice"] = SOURCE,
        ["targetDevice"] = TARGET,
        ["targetPath"] = "archive/2026/09",
        ["sourcePaths"] = new JsonArray { "media/2026/09" },
        ["sendAllFolder"] = true,
        ["incremental"] = true,
        ["transferOptions"] = new JsonObject { ["target-action"] = "overwrite" },
    });

기본값은 꺼짐입니다. 변경분 계산은 에이전트가 수행하며, 마지막 이동 이후 추가되거나 수정된 오브젝트만 전송합니다. 증분에는 반드시 overwrite를 씁니다.

결과 확인#

원본과 대상의 오브젝트 수를 대조해 확인하기

오브젝트 스토리지는 파일 시스템과 달리 체크섬 조회를 지원하지 않는 경우가 있습니다. 원본과 대상의 오브젝트 수를 대조해 누락 없이 전송됐는지 확인합니다.

def count_objects(device, prefix):
    total, page = 0, 1

    while True:
        result = api("GET", f"/api/devices/{device}/files", params={
            "path": prefix,
            "page": page, "size": 1000, "type": "file",
        }) or {}

        total += len(result.get("items") or [])

        if page >= (result.get("lastPage") or 1):
            return total

        page += 1


source_count = count_objects(SOURCE, "media/2026/09")
target_count = count_objects(TARGET, "archive/2026/09")

if source_count != target_count:
    raise RuntimeError(
        f"object counts differ: source {source_count} / target {target_count}")
int countObjects(String device, String prefix) {
    int total = 0, page = 1;

    while (true) {
        Map<String, Object> result = client.apiObj("GET",
                "/api/devices/" + device + "/files", null,
                Json.newObj("path", prefix,
                            "page", page, "size", 1000, "type", "file"));

        total += Json.arrOf(result, "items").size();

        if (page >= Json.intOr(result, "lastPage", 1)) return total;

        page++;
    }
}

int sourceCount = countObjects(SOURCE, "media/2026/09");
int targetCount = countObjects(TARGET, "archive/2026/09");

if (sourceCount != targetCount) {
    throw new RuntimeException("object counts differ: source "
            + sourceCount + " / target " + targetCount);
}
async function countObjects(device, prefix) {
  let total = 0;
  let page = 1;

  for (;;) {
    const result = (await client.api("GET", `/api/devices/${device}/files`, null, {
      path: prefix,
      page, size: 1000, type: "file",
    })) || {};

    total += (result.items || []).length;

    if (page >= (result.lastPage || 1)) return total;

    page += 1;
  }
}

const sourceCount = await countObjects(SOURCE, "media/2026/09");
const targetCount = await countObjects(TARGET, "archive/2026/09");

if (sourceCount !== targetCount) {
  throw new Error(`object counts differ: source ${sourceCount} / target ${targetCount}`);
}
async Task<int> CountObjectsAsync(string device, string prefix)
{
    int total = 0, page = 1;

    while (true)
    {
        JsonObject result = await client.ApiObjAsync("GET",
            
quot;/api/devices/{device}/files"
, null, new Dictionary<string, object> { ["path"] = prefix, ["page"] = page, ["size"] = 1000, ["type"] = "file", }); total += J.ArrOf(result, "items").Count; if (page >= J.Int(result, "lastPage", 1)) return total; page++; } } int sourceCount = await CountObjectsAsync(SOURCE, "media/2026/09"); int targetCount = await CountObjectsAsync(TARGET, "archive/2026/09"); if (sourceCount != targetCount) { throw new Exception(
quot;object counts differ: source {sourceCount} / target {targetCount}"
); }

수가 다르면 이동이 덜 된 것입니다. 실패한 접두사 구간을 다시 전송해 수를 맞춥니다.

확인 항목 확인 내용
스토리지 원본과 대상의 장비 식별자
전송 경로 버킷과 접두사
범위 나눠 처리한 접두사 단위
결과 범위별 성공과 실패
검증 원본과 대상의 오브젝트 수 일치 여부
이전CI/CD 빌드 결과물을 여러 서버에 배포하기다음서로 다른 AWS 계정의 S3 버킷 사이에 파일 전송하기

이 페이지에서

  • 시작하기
  • 기본 개념
  • 전송 흐름
  • 활용 효과
  • IT 엔지니어
  • 스토리지 연결
  • 전송 경로
  • 전송 기준
  • 결과 확인
  • 운영 관리
  • 개발자
  • 연동 준비
  • 스토리지 지정
  • 전송 실행
  • 대량 오브젝트 처리
  • 조건부 전송
  • 변경분 전송
  • 결과 확인