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. NAS, 파일 서버의 대량 파일을 클라우드로 이전하기

NAS, 파일 서버의 대량 파일을 클라우드로 이전하기

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 서버 사이에 파일을 직접 전송하기

시작하기#

기본 개념#

기존 파일 환경의 데이터를 클라우드 스토리지로 이전하기

기업의 파일은 NAS, SMB 공유 폴더, NFS 스토리지, 파일 서버 등 다양한 환경에 분산되어 있습니다.

클라우드 이전은 기존 파일을 지정된 클라우드 스토리지로 전송하고, 이전 기간 동안 발생하는 변경 사항을 지속적으로 반영해 최종 전환 시점까지 파일 상태를 관리하는 방식입니다.

기존 파일 환경
      │
      ▼
초기 파일 이전
      │
      ▼
변경 파일 동기화
      │
      ▼
전환 상태 확인
      │
      ▼
클라우드 전환

초기 파일 이전과 변경 파일 동기화를 연결하면 기존 파일 환경의 데이터를 클라우드로 이전하고 전환 시점까지 파일 상태를 관리할 수 있습니다.

이전 흐름#

초기 파일 이전부터 변경 파일 동기화와 최종 전환까지 이어가기

파일 이전은 초기 데이터를 클라우드로 전송한 뒤, 이전 기간 동안 생성되거나 수정되는 파일을 계속 반영하는 흐름으로 구성합니다.

① 원본 환경 연결
        │
        ▼
② 초기 파일 이전
        │
        ▼
③ 변경 사항 감지
        │
        ▼
④ 변경 파일 동기화
        │
        ▼
⑤ 전환 상태 확인
        │
        ▼
⑥ 클라우드 전환

이 흐름을 통해 초기 파일과 이후 변경되는 파일을 함께 관리하며 클라우드 전환을 진행할 수 있습니다.

전환 효과#

기존 파일 흐름을 클라우드 환경으로 이어가기

초기 이전과 변경 동기화를 함께 구성하면 전환 기간 동안 원본 환경과 클라우드의 파일 상태를 연결해 관리할 수 있습니다.

구분 기존 파일 환경 클라우드 전환 흐름
초기 데이터 기존 환경에서 관리 대량 파일을 클라우드로 이전
변경 파일 파일별 상태 확인 변경 사항을 지속적으로 동기화
전환 준비 환경별 파일 상태 확인 이전과 동기화 결과를 기준으로 확인
이후 업무 기존 저장 위치 활용 클라우드 저장 위치를 기준으로 연결

IT 엔지니어#

원본 환경#

NAS와 파일 서버의 이전 경로 연결하기

먼저 이전할 파일이 저장된 NAS, SMB, NFS, 파일 서버를 연결하고, 클라우드로 이전할 파일 경로를 지정합니다.

각 원본 환경에서 파일을 읽고 변경 사항을 확인할 수 있도록 접근 범위를 설정합니다.

NAS ─────────┐
SMB ─────────┤
NFS ─────────┼──→ Migration Flow
File Server ─┘

여러 원본 환경을 연결하면 파일 저장 위치별 이전 작업을 하나의 흐름에서 구성할 수 있습니다.

클라우드 연결#

클라우드 스토리지와 대상 경로 구성하기

원본 환경을 연결한 후 파일을 저장할 클라우드 스토리지와 대상 위치를 설정합니다.

대상 버킷이나 컨테이너, 저장 경로를 지정하고 원본 파일 경로와 연결합니다.

Original Storage
        │
        ▼
 Migration Flow
        │
        ▼
Cloud Storage
   └─ Target Path

이전할 원본 경로와 클라우드 저장 위치를 구성하면 초기 이전과 변경 동기화에 사용할 파일 흐름이 준비됩니다.

초기 이전#

기존 대량 파일을 클라우드로 전송하기

이전 환경 구성이 완료되면 기존 파일과 대량 데이터를 클라우드 스토리지로 전송합니다.

실행 중에는 처리된 파일 수와 전체 용량, 진행률과 작업 상태를 확인할 수 있습니다.

Original Files
      │
      ▼
Initial Transfer
      │
      ▼
Cloud Storage

초기 이전 결과는 이후 변경 파일 동기화와 최종 전환을 위한 기준으로 활용합니다.

변경 동기화#

이전 기간 동안 변경되는 파일을 계속 반영하기

초기 이전 이후에도 기존 파일 환경에서는 새로운 파일이 생성되거나 기존 파일이 수정될 수 있습니다.

New Items와 Modified Items를 기준으로 변경 사항을 확인하고, 해당 파일을 클라우드 스토리지에 동기화하도록 구성합니다.

Original Environment
        │
   New / Modified
        │
        ▼
   Change Detection
        │
        ▼
      Sync
        │
        ▼
  Cloud Storage

변경 파일을 지속적으로 반영하면 초기 이전 이후의 파일 상태도 클라우드 환경에 이어서 관리할 수 있습니다.

전환 검증#

초기 이전과 변경 동기화 결과 확인하기

최종 전환 전에 초기 파일 이전 결과와 이후 변경 파일의 동기화 상태를 함께 확인합니다.

파일 수와 용량, 최근 실행 결과, 동기화 상태를 기준으로 원본 환경과 클라우드 환경의 전환 준비 상태를 확인할 수 있습니다.

Initial Transfer ──┐
                   ├──→ Migration Status
Change Sync ───────┘
                         │
                    ┌────┴────┐
                    ▼         ▼
                 Ready    Check Items

초기 이전과 변경 동기화 결과를 함께 확인하면 클라우드 환경을 기준으로 업무를 전환할 시점을 정할 수 있습니다.

최종 전환#

클라우드 환경을 기준으로 파일 업무 전환하기

전환 검증이 완료되면 클라우드 스토리지를 이후 파일 업무의 기준 환경으로 설정합니다.

업무 시스템과 애플리케이션, 후속 파일 작업의 저장 위치를 클라우드와 연결해 파일 흐름을 이어갈 수 있습니다.

Before
NAS / File Server
        │
        ▼
 Migration Flow
        │
        ▼
Cloud Storage
        │
        ▼
After
App / Service / Workflow

이전 이후에는 클라우드에 저장된 파일을 기준으로 다음 업무와 자동화 작업을 연결할 수 있습니다.

운영 대응#

이전과 동기화 작업을 확인하고 다시 실행하기

이전 과정에서 확인이 필요한 작업은 Runs와 상세 실행 기록을 통해 원본 연결 상태, 대상 경로, 파일 처리 결과와 동기화 상태를 확인합니다.

Migration Run
      │
      ▼
Status Check
      │
 ┌────┴─────┐
 ▼          ▼
Complete  Check Items
             │
             ▼
       View Details
             │
             ▼
       Update Settings
             │
             ▼
           Retry
             │
             ▼
       Result Confirm

확인 항목 확인 내용 후속 작업
원본 환경 NAS, SMB, NFS, 파일 서버 연결과 경로 연결과 경로 확인
클라우드 저장 위치와 접근 상태 대상 설정 확인
초기 이전 대량 파일 처리 결과 필요한 작업 다시 실행
변경 동기화 추가, 수정 파일의 반영 상태 동기화 작업 다시 실행
실행 기록 작업 상태와 상세 처리 결과 설정 확인 후 재실행

개발자#

기존 공유 폴더를 클라우드로 옮기고 이전 기간 동안 변경분을 계속 반영하기

연동 준비#

공통 호출 코드와 경로 표기 준비하기

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);
}
import base64
import time


def encode_path(device_id, raw_path):
    normalized = str(raw_path or "").replace("\\", "/")
    token = base64.b64encode(normalized.encode("utf-8")).decode("ascii")
    return f"{device_id}_ino_{token}"


def now_iso():
    return time.strftime("%Y-%m-%dT%H:%M:%S.000Z", time.gmtime())
public static String encodePath(String deviceId, String rawPath) {
    String normalized = (rawPath == null ? "" : rawPath).replace("\\", "/");
    return deviceId + "_ino_"
            + Base64.getEncoder().encodeToString(normalized.getBytes(StandardCharsets.UTF_8));
}

public static String nowIso() {
    return Instant.now().truncatedTo(ChronoUnit.SECONDS).toString().replace("Z", ".000Z");
}
export function encodePath(deviceId, rawPath) {
  const normalized = String(rawPath ?? "").replace(/\\/g, "/");
  const token = Buffer.from(normalized, "utf8").toString("base64");
  return `${deviceId}_ino_${token}`;
}

export function nowIso() {
  return new Date().toISOString().replace(/\.\d{3}Z$/, ".000Z");
}
public static string EncodePath(string deviceId, string rawPath)
{
    string normalized = (rawPath ?? "").Replace("\\", "/");
    return deviceId + "_ino_" + Convert.ToBase64String(Encoding.UTF8.GetBytes(normalized));
}

public static string NowIso()
{
    return DateTime.UtcNow.ToString("yyyy-MM-dd'T'HH:mm:ss'.000Z'",
        System.Globalization.CultureInfo.InvariantCulture);
}

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

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

이전 범위 파악#

옮길 파일의 개수와 용량 미리 확인하기

이전을 시작하기 전에 규모를 알아야 일정을 잡을 수 있습니다.

def scan(device_id, path, max_pages=200):
    page = api("POST", f"/api/devices/{device_id}/files/search",
               {"path": path, "pageSize": 1000})

    total = size = 0
    search_id = page.get("searchId")

    try:
        for _ in range(max_pages):
            for item in page.get("items") or []:
                if item["type"] == "file":
                    total += 1
                    size += item.get("size") or 0

            if not page.get("hasMore"):
                break

            page = api("GET", f"/api/devices/{device_id}/files/search",
                       params={"cursor": page["nextCursor"]})
    finally:
        # stop the device scan once the count is done
        if search_id:
            api("POST", f"/api/devices/{device_id}/files/search/cancel",
                {"uuid": search_id})

    return total, size


count, size = scan("device-nas-01", "/share/team")
print(f"{count} files / {size / (1024 ** 3):.1f}GB")
long[] scan(String deviceId, String path, int maxPages) throws Exception {
    Map<String, Object> page = client.apiObj("POST",
            "/api/devices/" + deviceId + "/files/search",
            Json.newObj("path", path, "pageSize", 1000));

    long total = 0, size = 0;
    String searchId = Json.str(page, "searchId");

    try {
        for (int i = 0; i < maxPages; i++) {
            for (Object node : Json.arrOf(page, "items")) {
                Map<String, Object> item = Json.asObj(node);
                if ("file".equals(Json.str(item, "type"))) {
                    total++;
                    Long s = Json.longOrNull(item, "size");
                    size += s != null ? s : 0;
                }
            }

            if (!Json.bool(page, "hasMore", false)) break;

            page = client.apiObj("GET", "/api/devices/" + deviceId + "/files/search",
                    null, Json.newObj("cursor", Json.str(page, "nextCursor")));
        }
    } finally {
        // stop the device scan once the count is done
        if (searchId != null) {
            client.api("POST", "/api/devices/" + deviceId + "/files/search/cancel",
                    Json.newObj("uuid", searchId));
        }
    }

    return new long[]{total, size};
}
async function scan(deviceId, path, maxPages = 200) {
  let page = await client.api("POST", `/api/devices/${deviceId}/files/search`,
    { path, pageSize: 1000 });

  let total = 0;
  let size = 0;
  const searchId = page.searchId;

  try {
    for (let i = 0; i < maxPages; i += 1) {
      for (const item of page.items || []) {
        if (item.type === "file") {
          total += 1;
          size += item.size || 0;
        }
      }

      if (!page.hasMore) break;

      page = await client.api("GET", `/api/devices/${deviceId}/files/search`,
        null, { cursor: page.nextCursor });
    }
  } finally {
    // stop the device scan once the count is done
    if (searchId) {
      await client.api("POST", `/api/devices/${deviceId}/files/search/cancel`,
        { uuid: searchId });
    }
  }

  return { total, size };
}
async Task<(long Total, long Size)> ScanAsync(string deviceId, string path,
    int maxPages = 200)
{
    JsonObject page = await client.ApiObjAsync("POST",
        
quot;/api/devices/{deviceId}/files/search"
, new JsonObject { ["path"] = path, ["pageSize"] = 1000 }); long total = 0, size = 0; string searchId = J.Str(page, "searchId"); try { for (int i = 0; i < maxPages; i++) { foreach (JsonNode node in J.ArrOf(page, "items")) { JsonObject item = J.AsObj(node); if (J.Str(item, "type") == "file") { total++; size += J.LongOrNull(item, "size") ?? 0; } } if (!J.Bool(page, "hasMore", false)) break; page = await client.ApiObjAsync("GET",
quot;/api/devices/{deviceId}/files/search"
, null, new Dictionary<string, object> { ["cursor"] = J.Str(page, "nextCursor") }); } } finally { // stop the device scan once the count is done if (searchId != null) { await client.ApiAsync("POST",
quot;/api/devices/{deviceId}/files/search/cancel"
, new JsonObject { ["uuid"] = searchId }); } } return (total, size); }

검색은 장비가 디스크를 실제로 탐색하는 작업입니다. 중단하지 않으면 확인이 끝난 뒤에도 탐색이 계속됩니다.

초기 이전#

기존 대량 파일을 클라우드로 보내기

전체를 한 번에 보내면 실패했을 때 다시 시작할 범위가 커집니다. 상위 폴더 단위로 나눠 보냅니다.

def target_for(folder, base):
    return folder.replace("/share", base, 1)


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

    return transfer["monitorId"]


FOLDERS = ["/share/team/design", "/share/team/docs", "/share/team/archive"]

transfers = {
    folder: migrate("device-nas-01", "device-cloud-01", folder, "/migrated")
    for folder in FOLDERS
}
String targetFor(String folder, String base) {
    return folder.replaceFirst("/share", base);
}

String migrate(String source, String target, String folder, String base) {
    Map<String, Object> transfer = client.apiObj("POST", "/api/transfers/manual", Json.newObj(
            "sourceDevice", source,
            "targetDevice", target,
            "targetPath", targetFor(folder, base),
            "sourcePaths", List.of(folder),
            "sendAllFolder", true,
            "transferOptions", Json.newObj("target-action", "overwrite")));

    return Json.str(transfer, "monitorId");
}

List<String> folders = List.of("/share/team/design", "/share/team/docs",
        "/share/team/archive");

Map<String, String> transfers = new LinkedHashMap<>();
for (String folder : folders) {
    transfers.put(folder, migrate("device-nas-01", "device-cloud-01", folder, "/migrated"));
}
const targetFor = (folder, base) => folder.replace("/share", base);

async function migrate(source, target, folder, base) {
  const transfer = await client.api("POST", "/api/transfers/manual", {
    sourceDevice: source,
    targetDevice: target,
    targetPath: targetFor(folder, base),
    sourcePaths: [folder],
    sendAllFolder: true,
    transferOptions: { "target-action": "overwrite" },
  });

  return transfer.monitorId;
}

const FOLDERS = ["/share/team/design", "/share/team/docs", "/share/team/archive"];

const transfers = {};
for (const folder of FOLDERS) {
  transfers[folder] = await migrate("device-nas-01", "device-cloud-01", folder, "/migrated");
}
string TargetFor(string folder, string baseDir) =>
    new Regex("/share").Replace(folder, baseDir, 1);

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

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

var folders = new[] { "/share/team/design", "/share/team/docs", "/share/team/archive" };

var transfers = new Dictionary<string, string>();
foreach (string folder in folders)
{
    transfers[folder] = await MigrateAsync("device-nas-01", "device-cloud-01", folder, "/migrated");
}

도착 정책은 overwrite를 씁니다. 이전 도중 다시 실행하는 경우가 많은데 numbering이면 사본이 쌓입니다.

파일 수가 매우 많은 폴더는 전송이 오래 걸립니다. 중단되면 이어받기로 재개할 수 있으므로 처음부터 재전송할 필요가 없습니다.

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 folder, monitor_id in transfers.items():
    detail = wait(monitor_id, timeout=86400)

    print(f"{folder:28} {detail.get('statusName')}"
          f" {detail.get('fileCount')} files")

    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(), 86400, null);

    System.out.printf("%-28s %s %s files%n", entry.getKey(),
            Json.str(detail, "statusName"), Json.str(detail, "fileCount"));

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

  console.log(`${folder.padEnd(28)} ${detail.statusName} ${detail.fileCount} files`);

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

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

변경분 동기화#

이전 기간 동안 바뀐 파일 계속 반영하기

초기 이전이 며칠 걸리는 동안에도 원본에서는 파일이 계속 바뀝니다. 주기적으로 변경분만 보냅니다.

transfer = api("POST", "/api/transfers/manual", {
    "sourceDevice": "device-nas-01",
    "targetDevice": "device-cloud-01",
    "targetPath": "/migrated/team/design",
    "sourcePaths": ["/share/team/design"],
    "sendAllFolder": True,
    "incremental": True,
    "transferOptions": {"target-action": "overwrite"},
})
Map<String, Object> transfer = client.apiObj("POST", "/api/transfers/manual", Json.newObj(
        "sourceDevice", "device-nas-01",
        "targetDevice", "device-cloud-01",
        "targetPath", "/migrated/team/design",
        "sourcePaths", List.of("/share/team/design"),
        "sendAllFolder", true,
        "incremental", true,
        "transferOptions", Json.newObj("target-action", "overwrite")));
const transfer = await client.api("POST", "/api/transfers/manual", {
  sourceDevice: "device-nas-01",
  targetDevice: "device-cloud-01",
  targetPath: "/migrated/team/design",
  sourcePaths: ["/share/team/design"],
  sendAllFolder: true,
  incremental: true,
  transferOptions: { "target-action": "overwrite" },
});
JsonObject transfer = await client.ApiObjAsync("POST", "/api/transfers/manual",
    new JsonObject
    {
        ["sourceDevice"] = "device-nas-01",
        ["targetDevice"] = "device-cloud-01",
        ["targetPath"] = "/migrated/team/design",
        ["sourcePaths"] = new JsonArray { "/share/team/design" },
        ["sendAllFolder"] = true,
        ["incremental"] = true,
        ["transferOptions"] = new JsonObject { ["target-action"] = "overwrite" },
    });

기본값은 꺼짐입니다. 변경분 계산은 장비의 에이전트가 수행하며, 마지막 전송 이후 추가되거나 수정된 파일만 보냅니다.

증분에는 반드시 overwrite를 씁니다. numbering이면 수정된 파일이 새 이름으로 쌓여 클라우드 쪽 상태가 원본과 달라집니다.

스크립트를 계속 돌릴 수 없다면 시간 단위 자동화로 등록합니다.

def build_sync(name, source, source_path, target, target_path, schedule):
    return {
        "name": name,
        "flowName": name,
        "transferType": "normal",
        "timezone": "Asia/Seoul",
        "step": 1,
        "isUpcoming": False,
        "details": [
            {
                "senderId": source,
                "receiverId": target,
                "sourceItem": [
                    {
                        "hash": encode_path(source, source_path),
                        "filePath": source_path,
                        "isDir": True,
                    }
                ],
                "targetPath": encode_path(target, target_path),
                "step": 1,
                "transferOptions": {
                    "noSchedule": False,
                    "target-action": "overwrite",
                    "send-fileoption": {},
                },
            }
        ],
        "schedules": [schedule],
    }


HOURLY = {
    "type": "hour",
    "startDateType": "now",
    "startDate": now_iso(),
    "timezone": "Asia/Seoul",
}

syncs = {
    folder: api("POST", "/api/automations", build_sync(
        f"sync {folder}", "device-nas-01", folder,
        "device-cloud-01", target_for(folder, "/migrated"),
        HOURLY))["automationId"]
    for folder in FOLDERS
}
Map<String, Object> buildSync(String name, String source, String sourcePath,
                              String target, String targetPath,
                              Map<String, Object> schedule) {
    Map<String, Object> detail = Json.newObj(
            "senderId", source, "receiverId", target,
            "sourceItem", List.of(Json.newObj(
                    "hash", InnorixClient.encodePath(source, sourcePath),
                    "filePath", sourcePath, "isDir", true)),
            "targetPath", InnorixClient.encodePath(target, targetPath),
            "step", 1,
            "transferOptions", Json.newObj(
                    "noSchedule", false, "target-action", "overwrite",
                    "send-fileoption", Json.newObj()));

    return Json.newObj(
            "name", name, "flowName", name,
            "transferType", "normal", "timezone", "Asia/Seoul",
            "step", 1, "isUpcoming", false,
            "details", List.of(detail),
            "schedules", List.of(schedule));
}
function buildSync(name, source, sourcePath, target, targetPath, schedule) {
  return {
    name,
    flowName: name,
    transferType: "normal",
    timezone: "Asia/Seoul",
    step: 1,
    isUpcoming: false,
    details: [{
      senderId: source,
      receiverId: target,
      sourceItem: [{
        hash: encodePath(source, sourcePath),
        filePath: sourcePath,
        isDir: true,
      }],
      targetPath: encodePath(target, targetPath),
      step: 1,
      transferOptions: {
        noSchedule: false,
        "target-action": "overwrite",
        "send-fileoption": {},
      },
    }],
    schedules: [schedule],
  };
}
JsonObject BuildSync(string name, string source, string sourcePath,
    string target, string targetPath, JsonObject schedule)
{
    var detail = new JsonObject
    {
        ["senderId"] = source,
        ["receiverId"] = target,
        ["sourceItem"] = new JsonArray
        {
            new JsonObject
            {
                ["hash"] = InnorixClient.EncodePath(source, sourcePath),
                ["filePath"] = sourcePath,
                ["isDir"] = true,
            },
        },
        ["targetPath"] = InnorixClient.EncodePath(target, targetPath),
        ["step"] = 1,
        ["transferOptions"] = new JsonObject
        {
            ["noSchedule"] = false,
            ["target-action"] = "overwrite",
            ["send-fileoption"] = new JsonObject(),
        },
    };

    return new JsonObject
    {
        ["name"] = name,
        ["flowName"] = name,
        ["transferType"] = "normal",
        ["timezone"] = "Asia/Seoul",
        ["step"] = 1,
        ["isUpcoming"] = false,
        ["details"] = new JsonArray { detail },
        ["schedules"] = new JsonArray { schedule },
    };
}

자동화 요청에서 반드시 지켜야 하는 항목이 네 개 있습니다.

항목 지정 방식
isUpcoming 반드시 false. 서버 기본값 true는 요청에 담긴 일정을 무시하고 5분짜리 일회성 일정으로 대체합니다. triggerAutomation이 붙은 단계는 서버가 false로 강제하므로, 트리거가 없는 첫 단계에만 직접 지정하면 됩니다
step 최상위와 details 양쪽에 넣습니다. 흐름 안에서의 홉 위치입니다
sourceItem hash(경로 토큰)와 filePath(평문 경로)를 함께 넣습니다
syncType transferOptions 안에 넣습니다. 1은 단방향, 2는 양방향입니다

네 항목 모두 누락해도 등록은 성공하고 실행 시점에 동작이 달라집니다. 반복 일정을 등록했는데 한 번만 실행되고 끝났다면 isUpcoming부터 확인합니다.

진행 상황 집계#

전송별 진행률과 동기화 회차를 조회하기

def progress(transfers):
    for folder, monitor_id in transfers.items():
        detail = api("GET", f"/api/transfers/{monitor_id}")

        print(f"{folder:28} {detail.get('statusName'):12}"
              f" {detail.get('percent', 0):>5}%"
              f" {detail.get('fileCount', 0)} files")
void progress(Map<String, String> transfers) {
    transfers.forEach((folder, monitorId) -> {
        Map<String, Object> detail = client.apiObj("GET", "/api/transfers/" + monitorId);

        System.out.printf("%-28s %-12s %5s%% %s files%n", folder,
                Json.str(detail, "statusName"), Json.str(detail, "percent"),
                Json.str(detail, "fileCount"));
    });
}
async function progress(transfers) {
  for (const [folder, monitorId] of Object.entries(transfers)) {
    const detail = await client.api("GET", `/api/transfers/${monitorId}`);

    console.log(`${folder.padEnd(28)} ${(detail.statusName || "").padEnd(12)}`
      + ` ${String(detail.percent || 0).padStart(5)}% ${detail.fileCount || 0} files`);
  }
}
async Task ProgressAsync(Dictionary<string, string> transfers)
{
    foreach (var (folder, monitorId) in transfers)
    {
        JsonObject detail = await client.ApiObjAsync("GET", "/api/transfers/" + monitorId);

        Console.WriteLine(
quot;{folder,-28} {J.Str(detail, "statusName"),-12}"
+
quot; {J.Str(detail, "percent"),5}% {J.Str(detail, "fileCount")} files"
); } }

동기화 자동화의 회차별 결과는 실행 이력으로 확인합니다.

for folder, automation_id in syncs.items():
    runs = api("GET", f"/api/automations/{automation_id}/executions") or []
    latest = runs[0] if runs else {}

    print(f"{folder:28} runs {len(runs):>3}"
          f" last {latest.get('startTime')} {latest.get('status')}")
for (var entry : syncs.entrySet()) {
    List<Map<String, Object>> runs = client.executions(entry.getValue());
    Map<String, Object> latest = runs.isEmpty() ? Map.of() : runs.get(0);

    System.out.printf("%-28s runs %3d last %s %s%n", entry.getKey(), runs.size(),
            Json.str(latest, "startTime"), Json.str(latest, "status"));
}
for (const [folder, automationId] of Object.entries(syncs)) {
  const runs = (await client.executions(automationId)) || [];
  const latest = runs[0] || {};

  console.log(`${folder.padEnd(28)} runs ${String(runs.length).padStart(3)}`
    + ` last ${latest.startTime} ${latest.status}`);
}
foreach (var (folder, automationId) in syncs)
{
    List<JsonObject> runs = J.AsList(await client.ExecutionsAsync(automationId));
    JsonObject latest = runs.Count > 0 ? runs[0] : new JsonObject();

    Console.WriteLine(
quot;{folder,-28} runs {runs.Count,3} "
+
quot;last {J.Str(latest, "startTime")} {J.Str(latest, "status")}"
); }

실행 이력은 최신 회차가 배열 앞에 옵니다. 이력이 비어 있으면 자동화가 등록만 되고 실행되지 않은 것이므로 isUpcoming과 시작 조건을 확인합니다.

전환 검증#

원본과 클라우드의 파일이 일치하는지 확인하기

전환 시점을 정하려면 두 환경의 파일이 일치하는지 확인해야 합니다. 폴더별로 원본과 대상의 파일 목록을 비교해 누락을 찾습니다.

def list_names(device_id, path):
    names, page = set(), 1

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

        names.update(item["name"] for item in result.get("items") or [])

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

        page += 1


missing = list_names("device-nas-01", "/share/team/design") \
        - list_names("device-cloud-01", "/migrated/team/design")

print(f"{len(missing)} missing")
Set<String> listNames(String deviceId, String path) {
    Set<String> names = new HashSet<>();
    int page = 1;

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

        for (Object node : Json.arrOf(result, "items")) {
            names.add(Json.str(Json.asObj(node), "name"));
        }

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

        page++;
    }
}

Set<String> missing = new HashSet<>(listNames("device-nas-01", "/share/team/design"));
missing.removeAll(listNames("device-cloud-01", "/migrated/team/design"));

System.out.println(missing.size() + " missing");
async function listNames(deviceId, path) {
  const names = new Set();
  let page = 1;

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

    for (const item of result.items || []) names.add(item.name);

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

    page += 1;
  }
}

const sourceNames = await listNames("device-nas-01", "/share/team/design");
const targetNames = await listNames("device-cloud-01", "/migrated/team/design");
const missing = [...sourceNames].filter((n) => !targetNames.has(n));

console.log(`${missing.length} missing`);
async Task<HashSet<string>> ListNamesAsync(string deviceId, string path)
{
    var names = new HashSet<string>();
    int page = 1;

    while (true)
    {
        JsonObject result = await client.ApiObjAsync("GET",
            
quot;/api/devices/{deviceId}/files"
, null, new Dictionary<string, object> { ["path"] = path, ["page"] = page, ["size"] = 200, ["type"] = "file", }); foreach (JsonNode node in J.ArrOf(result, "items")) names.Add(J.Str(J.AsObj(node), "name")); if (page >= J.Int(result, "lastPage", 1)) return names; page++; } } var missing = new HashSet<string>(await ListNamesAsync("device-nas-01", "/share/team/design")); missing.ExceptWith(await ListNamesAsync("device-cloud-01", "/migrated/team/design")); Console.WriteLine(
quot;{missing.Count} missing"
);

누락된 파일이 있으면 해당 폴더를 다시 전송해 목록을 맞춥니다. 이름만이 아니라 크기까지 대조하려면 목록에 size를 함께 담아 비교합니다.

전환과 정리#

동기화를 멈추고 클라우드를 기준 환경으로 두기

전환이 끝나면 동기화 자동화를 정지합니다. 원본이 계속 반영되면 클라우드 쪽 변경이 덮어써질 수 있습니다.

for automation_id in syncs.values():
    api("POST", f"/api/automations/{automation_id}/pause", {"pause": True})
for (String automationId : syncs.values()) {
    client.api("POST", "/api/automations/" + automationId + "/pause",
            Json.newObj("pause", true));
}
for (const automationId of Object.values(syncs)) {
  await client.api("POST", `/api/automations/${automationId}/pause`, { pause: true });
}
foreach (string automationId in syncs.Values)
{
    await client.ApiAsync("POST", 
quot;/api/automations/{automationId}/pause"
, new JsonObject { ["pause"] = true }); }

일정 기간 문제가 없으면 삭제합니다. 정지 상태로 두면 나중에 실수로 재개될 수 있습니다.

for automation_id in syncs.values():
    api("DELETE", f"/api/automations/{automation_id}")
for (String automationId : syncs.values()) {
    client.api("DELETE", "/api/automations/" + automationId);
}
for (const automationId of Object.values(syncs)) {
  await client.api("DELETE", `/api/automations/${automationId}`);
}
foreach (string automationId in syncs.Values)
{
    await client.ApiAsync("DELETE", 
quot;/api/automations/{automationId}"
); }
확인 항목 확인 내용
이전 범위 파일 수와 전체 용량
초기 이전 폴더별 전송 상태
변경 동기화 회차별 실행 결과
검증 원본과 대상의 파일 목록 일치 여부
전환 동기화 정지와 정리
이전파일 도착 후 검증·변환·후속 작업 실행하기다음FTP 배치 작업을 관리형 파일 흐름으로 전환하기

이 페이지에서

  • 시작하기
  • 기본 개념
  • 이전 흐름
  • 전환 효과
  • IT 엔지니어
  • 원본 환경
  • 클라우드 연결
  • 초기 이전
  • 변경 동기화
  • 전환 검증
  • 최종 전환
  • 운영 대응
  • 개발자
  • 연동 준비
  • 이전 범위 파악
  • 초기 이전
  • 변경분 동기화
  • 진행 상황 집계
  • 전환 검증
  • 전환과 정리