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에서 임베디드 파일 전송 API을 구성하고 연결된 시스템 간 파일 작업을 일관되게 운영하는 방법을 알아보세요.

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

시작하기#

기본 개념#

애플리케이션에서 파일 전송 기능 실행하기

애플리케이션에서 파일을 처리하는 업무는 파일을 준비하고 전송을 요청한 뒤, 처리 결과에 따라 다음 업무를 진행하는 흐름으로 구성됩니다.

애플리케이션 연동은 파일 전송 기능을 애플리케이션의 요청과 연결해 필요한 시점에 파일 전송을 실행하고, 처리 결과를 애플리케이션의 업무 로직에 활용할 수 있도록 구성합니다.

Application
     │
     │ Transfer Request
     ▼
File Transfer
     │
     ├── File Processing
     ├── Progress
     └── Result
             │
             ▼
Application Logic

이를 통해 파일 전송과 결과 처리를 애플리케이션의 업무 기능과 연결할 수 있습니다.

연동 흐름#

전송 요청부터 결과 처리까지 하나의 흐름으로 연결하기

애플리케이션에서 파일 전송 요청이 발생하면 전송할 파일과 대상 정보를 기준으로 작업을 실행합니다.

전송 과정의 진행 상태와 응답 정보를 애플리케이션에서 활용하고, 최종 결과에 따라 다음 업무 로직을 이어갈 수 있습니다.

파일 전송 요청
      │
      ▼
파일 ,  대상 정보 설정
      │
      ▼
전송 작업 실행
      │
      ▼
상태 ,  응답 정보 수신
      │
      ▼
최종 결과 확인
      │
      ▼
애플리케이션 로직 처리

하나의 요청을 기준으로 파일 전송과 결과 처리를 연결하면 완료된 작업을 다음 업무와 자연스럽게 이어갈 수 있습니다.

개발 효과#

파일 전송 기능을 애플리케이션 업무 흐름에 적용하기

애플리케이션 연동을 통해 파일 전송에 필요한 실행 과정과 결과 처리를 서비스 기능과 연결할 수 있습니다.

구분 애플리케이션 연동
전송 실행 애플리케이션의 요청을 기준으로 파일 전송 시작
상태 활용 진행 상태와 응답 정보를 화면과 업무 로직에 연결
결과 처리 최종 결과를 다음 업무 기능에 활용
업무 확장 전송 완료 후 저장, 처리, 알림 등 다음 작업 연결

이렇게 구성하면 파일 전송 요청부터 결과 처리까지 애플리케이션의 업무 흐름에 맞춰 활용할 수 있습니다.

IT 엔지니어#

애플리케이션 파일 전송 연동을 구성하고 관리하기

연동 구성#

애플리케이션과 파일 전송 환경 연결하기

먼저 애플리케이션에서 파일 전송 기능을 사용할 수 있도록 파일 전송 환경과 연동 방식을 구성합니다.

애플리케이션의 요청이 파일 전송 작업으로 연결되고, 실행 상태와 결과 정보를 다시 받을 수 있도록 요청과 응답 경로를 설정합니다.

┌─────────────────┐
│   Application   │
└────────┬────────┘
         │
         │ Request / Response
         ▼
┌─────────────────┐
│ Transfer Layer  │
└────────┬────────┘
         │
         ▼
┌─────────────────┐
│ Transfer Device │
└─────────────────┘

연동 환경을 구성하면 애플리케이션의 업무 기능과 실제 파일 전송 작업을 연결할 수 있습니다.

요청 구성#

전송할 파일과 대상, 실행 조건 정하기

연동된 애플리케이션에서 어떤 요청을 기준으로 파일 전송을 실행할지 구성합니다.

요청에는 전송할 파일과 파일 경로, 대상 위치, 실행에 필요한 조건을 포함할 수 있습니다.

Transfer Request
       │
       ├── Source
       │      └── File / Path
       │
       ├── Target
       │      └── Device / Workspace
       │
       └── Options
              │
              ▼
         Transfer Run

구성 항목 설정 내용
Source 전송할 파일 또는 파일 경로
Target 파일을 전송할 장비 또는 작업 공간
Request 애플리케이션에서 전달하는 요청 정보
Options 파일 처리에 적용할 실행 조건
Flow 요청에 따라 실행할 파일 전송 작업

요청 구조를 구성하면 애플리케이션의 업무 조건에 따라 필요한 파일 전송을 실행할 수 있습니다.

응답 처리#

전송 상태와 응답 결과를 애플리케이션 로직에 연결하기

파일 전송이 실행되면 시작과 진행, 완료 과정에서 상태와 응답 정보가 생성됩니다.

이 정보를 애플리케이션의 화면과 업무 로직에 연결하면 현재 진행 상황을 표시하고, 각 결과에 맞는 처리 흐름을 구성할 수 있습니다.

Transfer Run
     │
     ├── Started
     │
     ├── Progress
     │
     └── Result
            │
       ┌────┼────┐
       ▼    ▼    ▼
    Success Retry Error
       │    │    │
       ▼    ▼    ▼
    Next   Retry Result
    Logic  Run   Handling

전송 정보 애플리케이션 활용
Started 전송 시작 상태 표시
Progress 진행률과 처리 상태 표시
Success 다음 업무 로직 실행
Retry 재실행 조건에 따라 작업 다시 요청
Error 응답 정보를 기준으로 처리 흐름 연결

이 섹션에서 전송 과정의 상태와 최종 응답 결과를 하나의 처리 구조로 관리하므로, 기존의 상태와 이벤트 처리와 오류 대응에서 반복되던 내용을 통합했습니다.

연동 검증#

애플리케이션에서 최종 전송 결과 확인하기

연동 구성이 완료되면 애플리케이션에서 실제 파일 전송 요청을 실행하고 전체 처리 결과를 확인합니다.

요청한 파일이 지정된 대상으로 처리되었는지 확인하고, 애플리케이션에서 받은 결과와 파일 전송 실행 기록을 함께 검증합니다.

Application Request
        │
        ▼
   Transfer Run
        │
        ▼
  File Processing
        │
        ▼
  Result Response
        │
   ┌────┴────┐
   ▼         ▼
Application  Run
 Result      Record
   │         │
   └────┬────┘
        ▼
   Final Check

연동 검증에서는 다음 정보를 기준으로 전체 흐름을 확인합니다.

확인 항목 확인 내용
요청 애플리케이션에서 생성된 전송 요청
실행 요청에 따라 생성된 파일 전송 작업
파일 처리된 파일과 파일 수, 용량
대상 지정된 장비 또는 작업 공간
상태 전송 진행 상태와 최종 결과
응답 애플리케이션으로 반환된 결과 정보

개발자#

업무 애플리케이션에서 전송을 실행하고 진행, 제어, 결과를 업무 데이터와 연결하기

연동 준비#

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

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 시작, 전송 중, 동기화 중, 수신 중 아니오

종료 여부와 성공 여부를 나눠 판단합니다. 부분 완료(9)와 취소(5)도 종료 상태이므로, isTerminal만 보고 성공 처리하면 실패가 성공으로 기록됩니다.

사전 검증#

전송 전에 경로 유효성 확인하기

경로가 잘못돼도 전송 생성 자체는 성공합니다. 실패는 실행 시점에 드러나고, 그때는 이미 업무 데이터에 진행 중으로 기록된 뒤입니다.

def validate_paths(source_id, target_id, source_paths, target_path):
    # sourceItems reads filePath, not path
    return api("POST", "/api/transfers/validate-path", {
        "sourceId": source_id,
        "targetId": target_id,
        "sourceItems": [{"filePath": p} for p in source_paths],
        "targetPath": target_path,
    }) or {}


result = validate_paths("device-a", "device-b",
                        ["/data/report.pdf"], "/archive")

if result.get("invalidSourcePaths"):
    raise ValueError(f"missing source paths: {result['invalidSourcePaths']}")

if result.get("validTargetPath") is False:
    raise ValueError("target path not found")
Map<String, Object> validatePaths(String sourceId, String targetId,
                                  List<String> sourcePaths, String targetPath) {
    List<Object> items = new ArrayList<>();
    // sourceItems reads filePath, not path
    for (String p : sourcePaths) items.add(Json.newObj("filePath", p));

    return client.apiObj("POST", "/api/transfers/validate-path", Json.newObj(
            "sourceId", sourceId,
            "targetId", targetId,
            "sourceItems", items,
            "targetPath", targetPath));
}

Map<String, Object> result = validatePaths("device-a", "device-b",
        List.of("/data/report.pdf"), "/archive");

if (!Json.arrOf(result, "invalidSourcePaths").isEmpty()) {
    throw new IllegalArgumentException("missing source paths: "
            + Json.arrOf(result, "invalidSourcePaths"));
}

if (Boolean.FALSE.equals(Json.boolOrNull(result, "validTargetPath"))) {
    throw new IllegalArgumentException("target path not found");
}
async function validatePaths(sourceId, targetId, sourcePaths, targetPath) {
  return (await client.api("POST", "/api/transfers/validate-path", {
    sourceId,
    targetId,
    // sourceItems reads filePath, not path
    sourceItems: sourcePaths.map((p) => ({ filePath: p })),
    targetPath,
  })) || {};
}

const result = await validatePaths("device-a", "device-b",
  ["/data/report.pdf"], "/archive");

if (result.invalidSourcePaths?.length) {
  throw new Error(`missing source paths: ${result.invalidSourcePaths}`);
}

if (result.validTargetPath === false) {
  throw new Error("target path not found");
}
async Task<JsonObject> ValidatePathsAsync(string sourceId, string targetId,
    IEnumerable<string> sourcePaths, string targetPath)
{
    var items = new JsonArray();
    // sourceItems reads filePath, not path
    foreach (string p in sourcePaths) items.Add(new JsonObject { ["filePath"] = p });

    return await client.ApiObjAsync("POST", "/api/transfers/validate-path",
        new JsonObject
        {
            ["sourceId"] = sourceId,
            ["targetId"] = targetId,
            ["sourceItems"] = items,
            ["targetPath"] = targetPath,
        });
}

JsonObject result = await ValidatePathsAsync("device-a", "device-b",
    new[] { "/data/report.pdf" }, "/archive");

if (J.ArrOf(result, "invalidSourcePaths").Count > 0)
{
    throw new ArgumentException(
        
quot;missing source paths: {J.ArrOf(result, "invalidSourcePaths")}"
); } if (J.BoolOrNull(result, "validTargetPath") == false) { throw new ArgumentException("target path not found"); }
응답 항목 내용
validSourcePaths 확인된 소스 경로
invalidSourcePaths 찾을 수 없는 소스 경로
validTargetPath 대상 경로 유효 여부

각 경로는 sourceItems의 filePath에 담아 보냅니다. 사용자가 경로를 직접 입력하는 화면이라면 저장 시점에 이 검증을 적용합니다.

전송 생성#

장비와 경로, 처리 기준을 지정해 전송 요청하기

파일 목록을 보낼 때는 sourceItem에 isDir: false로 명시합니다. sourcePaths는 모든 경로를 폴더로 취급하므로, 파일을 넣으면 서버가 각 파일을 폴더로 스캔하려 해 느려지거나 시간이 초과됩니다.

def create_transfer(source_id, target_id, source_paths, target_path,
                    action="numbering"):
    transfer = api("POST", "/api/transfers/manual", {
        "sourceDevice": source_id,
        "targetDevice": target_id,
        "targetPath": target_path,
        "sourceItem": [{"path": p, "isDir": False} for p in source_paths],
        "sendAllFolder": False,
        "transferOptions": {"target-action": action},
    })

    return transfer["monitorId"]
String createTransfer(String sourceId, String targetId,
                      List<String> sourcePaths, String targetPath, String action) {
    List<Object> items = new ArrayList<>();
    for (String p : sourcePaths) items.add(Json.newObj("path", p, "isDir", false));

    Map<String, Object> transfer = client.apiObj("POST", "/api/transfers/manual", Json.newObj(
            "sourceDevice", sourceId,
            "targetDevice", targetId,
            "targetPath", targetPath,
            "sourceItem", items,
            "sendAllFolder", false,
            "transferOptions", Json.newObj("target-action", action)));

    return Json.str(transfer, "monitorId");
}
async function createTransfer(sourceId, targetId, sourcePaths, targetPath,
  action = "numbering") {
  const transfer = await client.api("POST", "/api/transfers/manual", {
    sourceDevice: sourceId,
    targetDevice: targetId,
    targetPath,
    sourceItem: sourcePaths.map((p) => ({ path: p, isDir: false })),
    sendAllFolder: false,
    transferOptions: { "target-action": action },
  });

  return transfer.monitorId;
}
async Task<string> CreateTransferAsync(string sourceId, string targetId,
    IEnumerable<string> sourcePaths, string targetPath, string action = "numbering")
{
    var items = new JsonArray();
    foreach (string p in sourcePaths)
        items.Add(new JsonObject { ["path"] = p, ["isDir"] = false });

    JsonObject transfer = await client.ApiObjAsync("POST", "/api/transfers/manual",
        new JsonObject
        {
            ["sourceDevice"] = sourceId,
            ["targetDevice"] = targetId,
            ["targetPath"] = targetPath,
            ["sourceItem"] = items,
            ["sendAllFolder"] = false,
            ["transferOptions"] = new JsonObject { ["target-action"] = action },
        });

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

폴더 단위로 보낼 때는 sourcePaths와 sendAllFolder: True를 씁니다.

api("POST", "/api/transfers/manual", {
    "sourceDevice": source_id,
    "targetDevice": target_id,
    "targetPath": target_path,
    "sourcePaths": ["/data/reports"],
    "sendAllFolder": True,
    "transferOptions": {"target-action": "numbering"},
})
client.api("POST", "/api/transfers/manual", Json.newObj(
        "sourceDevice", sourceId,
        "targetDevice", targetId,
        "targetPath", targetPath,
        "sourcePaths", List.of("/data/reports"),
        "sendAllFolder", true,
        "transferOptions", Json.newObj("target-action", "numbering")));
await client.api("POST", "/api/transfers/manual", {
  sourceDevice: sourceId,
  targetDevice: targetId,
  targetPath,
  sourcePaths: ["/data/reports"],
  sendAllFolder: true,
  transferOptions: { "target-action": "numbering" },
});
await client.ApiAsync("POST", "/api/transfers/manual", new JsonObject
{
    ["sourceDevice"] = sourceId,
    ["targetDevice"] = targetId,
    ["targetPath"] = targetPath,
    ["sourcePaths"] = new JsonArray { "/data/reports" },
    ["sendAllFolder"] = true,
    ["transferOptions"] = new JsonObject { ["target-action"] = "numbering" },
});

같은 이름의 파일이 대상에 있을 때의 처리는 업무 성격에 따라 정합니다.

값 동작 적합한 업무
numbering 번호를 붙여 보존 제출본을 회차별로 남기는 업무
overwrite 덮어씀 최신 상태만 유지하는 업무
nosend 이미 있으면 보내지 않고 건너뜀 같은 파일을 다시 전송하지 않는 업무

정산 자료를 overwrite로 두면 이전 회차가 사라지므로 기본값을 그대로 쓰지 않고 업무에 맞춰 지정합니다.

nosend으로 건너뛴 파일이 있으면 전송이 성공이 아닌 종료 상태로 끝날 수 있습니다. 완료 판정을 status == 2로만 하면 정상 동작을 실패로 집계하게 되므로, 이 정책을 쓰는 코드에서는 종료 여부와 성공 여부를 따로 다룹니다.

업무 데이터 연결#

monitorId를 업무 데이터에 저장해 추적하기

전송 생성이 반환하는 monitorId 하나로 이후 조회와 제어, 재전송이 모두 이뤄집니다. 이 값을 업무 데이터에 저장하지 않으면 이후 추적할 수 없습니다.

def start_order_transfer(order_id, source_id, target_id, paths, target_path):
    validate_paths(source_id, target_id, paths, target_path)

    monitor_id = create_transfer(source_id, target_id, paths, target_path)

    db.execute(
        "UPDATE orders SET monitor_id = %s, transfer_state = %s WHERE id = %s",
        (monitor_id, "transferring", order_id),
    )

    return monitor_id
String startOrderTransfer(String orderId, String sourceId, String targetId,
                          List<String> paths, String targetPath) {
    validatePaths(sourceId, targetId, paths, targetPath);

    String monitorId = createTransfer(sourceId, targetId, paths, targetPath, "numbering");

    db.execute(
            "UPDATE orders SET monitor_id = ?, transfer_state = ? WHERE id = ?",
            monitorId, "transferring", orderId);

    return monitorId;
}
async function startOrderTransfer(orderId, sourceId, targetId, paths, targetPath) {
  await validatePaths(sourceId, targetId, paths, targetPath);

  const monitorId = await createTransfer(sourceId, targetId, paths, targetPath);

  await db.execute(
    "UPDATE orders SET monitor_id = $1, transfer_state = $2 WHERE id = $3",
    [monitorId, "transferring", orderId],
  );

  return monitorId;
}
async Task<string> StartOrderTransferAsync(string orderId, string sourceId,
    string targetId, IEnumerable<string> paths, string targetPath)
{
    await ValidatePathsAsync(sourceId, targetId, paths, targetPath);

    string monitorId = await CreateTransferAsync(sourceId, targetId, paths, targetPath);

    await db.ExecuteAsync(
        "UPDATE orders SET monitor_id = @m, transfer_state = @s WHERE id = @id",
        new { m = monitorId, s = "transferring", id = orderId });

    return monitorId;
}

반대로 monitorId로 업무 데이터를 되찾아야 하는 경우도 있습니다. 운영자가 전송 목록에서 문제를 발견했을 때입니다.

sql
CREATE INDEX idx_orders_monitor_id ON orders (monitor_id);

상태 표시#

진행 상태를 화면에 표시하고 종료 여부 판단하기

import time


def describe(monitor_id):
    return api("GET", f"/api/transfers/{monitor_id}")


def wait(monitor_id, timeout=1800, interval=3):
    deadline = time.time() + timeout

    while time.time() < deadline:
        detail = describe(monitor_id)

        if is_terminal(detail):
            return detail

        time.sleep(interval)

    raise TimeoutError(monitor_id)


detail = describe(monitor_id)

print(detail["statusLabel"], detail["percent"], "%")
print(detail["transferSize"], "/", detail["totalSize"])
Map<String, Object> describe(String monitorId) {
    return client.apiObj("GET", "/api/transfers/" + monitorId);
}

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

    while (System.currentTimeMillis() < deadline) {
        Map<String, Object> detail = describe(monitorId);
        if (InnorixClient.isTerminal(detail)) return detail;
        Thread.sleep(intervalSeconds * 1000L);
    }

    throw new InnorixClient.ApiError(0, "timeout: " + monitorId);
}

Map<String, Object> detail = describe(monitorId);
System.out.println(Json.str(detail, "statusLabel") + " "
        + Json.str(detail, "percent") + " %");
System.out.println(Json.str(detail, "transferSize") + " / "
        + Json.str(detail, "totalSize"));
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

async function describe(monitorId) {
  return client.api("GET", `/api/transfers/${monitorId}`);
}

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

  while (Date.now() < deadline) {
    const detail = await describe(monitorId);
    if (isTerminal(detail)) return detail;
    await sleep(interval * 1000);
  }

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

const detail = await describe(monitorId);
console.log(detail.statusLabel, detail.percent, "%");
console.log(detail.transferSize, "/", detail.totalSize);
async Task<JsonObject> DescribeAsync(string monitorId)
{
    return await client.ApiObjAsync("GET", "/api/transfers/" + monitorId);
}

async Task<JsonObject> WaitAsync(string monitorId, int timeoutSeconds = 1800,
    int intervalSeconds = 3)
{
    long deadline = Environment.TickCount64 + timeoutSeconds * 1000L;

    while (Environment.TickCount64 < deadline)
    {
        JsonObject detail = await DescribeAsync(monitorId);
        if (InnorixClient.IsTerminal(detail)) return detail;
        await Task.Delay(intervalSeconds * 1000);
    }

    throw new InnorixClient.ApiError(0, 
quot;timeout: {monitorId}"
); } JsonObject detail = await DescribeAsync(monitorId); Console.WriteLine(
quot;{J.Str(detail, "statusLabel")} {J.Str(detail, "percent")} %"
); Console.WriteLine(
quot;{J.Str(detail, "transferSize")} / {J.Str(detail, "totalSize")}"
);
응답 항목 화면 활용
statusLabel 상태 표시 문자열
percent 진행률
transferSize , totalSize 전송량
fileCount , folderCount 대상 규모
estimateTime 남은 시간
sourceDeviceName , targetDeviceName 출발지와 도착지

조회 주기를 짧게 잡으면 요청이 과도해집니다. 화면 표시 목적이라면 3초 정도가 적당합니다.

전송 제어#

사용자 요청에 따라 일시정지, 재개, 취소하기

세 동작 모두 본문 없이 호출합니다. 다만 호출이 성공해도 장비까지 지시가 전달되어야 상태가 바뀌므로, 화면을 즉시 갱신하면 이전 상태가 그대로 보입니다.

PAUSED = 3
RUNNING_STATES = {1, 6, 12, 13}
CANCELLED = 5


def control(monitor_id, action, tries=10):
    api("POST", f"/api/transfers/{monitor_id}/{action}", {})

    expected = {
        "pause": {PAUSED},
        "resume": RUNNING_STATES,
        "cancel": {CANCELLED},
    }[action]

    for _ in range(tries):
        time.sleep(1)
        detail = describe(monitor_id)

        if detail.get("status") in expected:
            return detail

    return describe(monitor_id)
static final int PAUSED = 3;
static final Set<Integer> RUNNING_STATES = Set.of(1, 6, 12, 13);
static final int CANCELLED = 5;

Map<String, Object> control(String monitorId, String action, int tries) throws Exception {
    client.api("POST", "/api/transfers/" + monitorId + "/" + action, Json.newObj());

    Set<Integer> expected = switch (action) {
        case "pause" -> Set.of(PAUSED);
        case "resume" -> RUNNING_STATES;
        case "cancel" -> Set.of(CANCELLED);
        default -> Set.of();
    };

    for (int i = 0; i < tries; i++) {
        Thread.sleep(1000);
        Map<String, Object> detail = describe(monitorId);
        Integer status = Json.intOrNull(detail, "status");
        if (status != null && expected.contains(status)) return detail;
    }

    return describe(monitorId);
}
const PAUSED = 3;
const RUNNING_STATES = new Set([1, 6, 12, 13]);
const CANCELLED = 5;

async function control(monitorId, action, tries = 10) {
  await client.api("POST", `/api/transfers/${monitorId}/${action}`, {});

  const expected = {
    pause: new Set([PAUSED]),
    resume: RUNNING_STATES,
    cancel: new Set([CANCELLED]),
  }[action];

  for (let i = 0; i < tries; i += 1) {
    await sleep(1000);
    const detail = await describe(monitorId);
    if (expected.has(detail.status)) return detail;
  }

  return describe(monitorId);
}
const int Paused = 3;
static readonly HashSet<int> RunningStates = new() { 1, 6, 12, 13 };
const int Cancelled = 5;

async Task<JsonObject> ControlAsync(string monitorId, string action, int tries = 10)
{
    await client.ApiAsync("POST", 
quot;/api/transfers/{monitorId}/{action}"
, new JsonObject()); HashSet<int> expected = action switch { "pause" => new HashSet<int> { Paused }, "resume" => RunningStates, "cancel" => new HashSet<int> { Cancelled }, _ => new HashSet<int>(), }; for (int i = 0; i < tries; i++) { await Task.Delay(1000); JsonObject detail = await DescribeAsync(monitorId); int? status = J.IntOrNull(detail, "status"); if (status != null && expected.Contains(status.Value)) return detail; } return await DescribeAsync(monitorId); }

화면에서는 버튼을 즉시 비활성화하고 처리 중을 표시한 뒤, 반영이 확인되면 상태를 갱신하는 방식이 자연스럽습니다.

여러 전송을 한 번에 멈춰야 하는 경우에는 다중 취소를 사용합니다.

result = api("POST", "/api/transfers/bulk-cancel", {"monitorIds": monitor_ids})

print(result.get("cancelled"), result.get("failed"))
Map<String, Object> result = client.apiObj("POST", "/api/transfers/bulk-cancel",
        Json.newObj("monitorIds", monitorIds));

System.out.println(Json.arrOf(result, "cancelled") + " " + Json.arrOf(result, "failed"));
const result = await client.api("POST", "/api/transfers/bulk-cancel",
  { monitorIds });

console.log(result.cancelled, result.failed);
JsonObject result = await client.ApiObjAsync("POST", "/api/transfers/bulk-cancel",
    new JsonObject { ["monitorIds"] = new JsonArray(monitorIds.Select(m => (JsonNode)m).ToArray()) });

Console.WriteLine(
quot;{J.ArrOf(result, "cancelled")} {J.ArrOf(result, "failed")}"
);

이미 종료된 전송은 취소할 대상이 없어 취소에 실패하며 응답의 failed에 담깁니다. 오류가 아니라 정상 응답입니다.

결과 확정#

전송 결과를 업무 데이터에 확정하고 실패한 파일 재전송하기

def finalize(order_id, monitor_id):
    detail = describe(monitor_id)

    if not is_terminal(detail):
        return None

    status = detail["status"]
    succeeded = status == STATUS_COMPLETE

    db.execute(
        "UPDATE orders SET transfer_state = %s, transfer_status = %s WHERE id = %s",
        ("done" if succeeded else "failed", status, order_id),
    )

    return succeeded
boolean finalize(String orderId, String monitorId) {
    Map<String, Object> detail = describe(monitorId);

    if (!InnorixClient.isTerminal(detail)) return false;

    int status = Json.intOr(detail, "status", -1);
    boolean succeeded = status == InnorixClient.STATUS_COMPLETE;

    db.execute(
            "UPDATE orders SET transfer_state = ?, transfer_status = ? WHERE id = ?",
            succeeded ? "done" : "failed", status, orderId);

    return succeeded;
}
async function finalize(orderId, monitorId) {
  const detail = await describe(monitorId);

  if (!isTerminal(detail)) return null;

  const succeeded = detail.status === STATUS_COMPLETE;

  await db.execute(
    "UPDATE orders SET transfer_state = $1, transfer_status = $2 WHERE id = $3",
    [succeeded ? "done" : "failed", detail.status, orderId],
  );

  return succeeded;
}
async Task<bool?> FinalizeAsync(string orderId, string monitorId)
{
    JsonObject detail = await DescribeAsync(monitorId);

    if (!InnorixClient.IsTerminal(detail)) return null;

    int status = J.Int(detail, "status", -1);
    bool succeeded = status == InnorixClient.StatusComplete;

    await db.ExecuteAsync(
        "UPDATE orders SET transfer_state = @s, transfer_status = @st WHERE id = @id",
        new { s = succeeded ? "done" : "failed", st = status, id = orderId });

    return succeeded;
}

상태 값을 함께 저장해 두면 나중에 실패 유형을 구분할 수 있습니다. 취소(5)와 실패(99)는 후속 대응이 다릅니다.

일부 파일만 실패한 경우에는 해당 파일만 재전송합니다.

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)
List<Map<String, Object>> failedFiles(String monitorId) {
    Map<String, Object> result = client.apiObj("GET",
            "/api/transfers/" + monitorId + "/files", null,
            Json.newObj("state", "any", "size", 500));

    List<Map<String, Object>> failed = new ArrayList<>();
    for (Object node : Json.arrOf(result, "children")) {
        Map<String, Object> row = Json.asObj(node);
        Integer status = Json.intOrNull(row, "status");
        if (status != null && InnorixClient.NOT_SUCCEEDED.contains(status)) failed.add(row);
    }
    return failed;
}

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) {
        filesRetry.add(Json.newObj(
                "filePath", Json.str(row, "sourceFilePath"),
                "isDir", Json.bool(row, "isFolder", false)));
    }

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

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

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

async function retryFailed(monitorId) {
  const rows = await failedFiles(monitorId);
  if (rows.length === 0) return 0;

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

  return rows.length;
}
async Task<List<JsonObject>> FailedFilesAsync(string monitorId)
{
    JsonObject result = await client.ApiObjAsync("GET",
        
quot;/api/transfers/{monitorId}/files"
, null, new Dictionary<string, object> { ["state"] = "any", ["size"] = 500 }); return J.ArrOf(result, "children").Select(J.AsObj).Where(r => { int? status = J.IntOrNull(r, "status"); return status != null && InnorixClient.NotSucceeded.Contains(status.Value); }).ToList(); } async Task<int> RetryFailedAsync(string monitorId) { List<JsonObject> rows = await FailedFilesAsync(monitorId); if (rows.Count == 0) return 0; var filesRetry = new JsonArray(); foreach (JsonObject r in rows) { filesRetry.Add(new JsonObject { ["filePath"] = J.Str(r, "sourceFilePath"), ["isDir"] = J.Bool(r, "isFolder", false), }); } await client.ApiAsync("POST",
quot;/api/transfers/{monitorId}/retry"
, new JsonObject { ["filesRetry"] = filesRetry }); return rows.Count; }

각 항목에 sourceFilePath, statusName, errorCode가 있어 어느 파일이 왜 실패했는지 그대로 보여 줄 수 있습니다. 재전송은 전송이 종료된 뒤에만 호출할 수 있습니다.

전송 전체를 다시 실행해야 한다면 이전 실행 정보를 조회해 재실행할 수 있습니다.

config = api("GET", f"/api/transfers/{monitor_id}/replay-data")
api("POST", f"/api/transfers/{monitor_id}/replay", {"action": "replay"})
Map<String, Object> config = client.apiObj("GET",
        "/api/transfers/" + monitorId + "/replay-data");
client.api("POST", "/api/transfers/" + monitorId + "/replay",
        Json.newObj("action", "replay"));
const config = await client.api("GET", `/api/transfers/${monitorId}/replay-data`);
await client.api("POST", `/api/transfers/${monitorId}/replay`, { action: "replay" });
JsonObject config = await client.ApiObjAsync("GET",
    
quot;/api/transfers/{monitorId}/replay-data"
); await client.ApiAsync("POST",
quot;/api/transfers/{monitorId}/replay"
, new JsonObject { ["action"] = "replay" });
확인 항목 확인 내용
요청 검증을 통과한 소스와 대상
실행 생성된 monitorId
상태 진행률과 종료 여부
결과 성공 여부와 상태 값
파일 실패한 파일과 오류 코드
후속 재전송 또는 재실행 결과
이전Datadog으로 파일 전송 실패와 복구 알림 받기다음파일 도착 후 검증·변환·후속 작업 실행하기

이 페이지에서

  • 시작하기
  • 기본 개념
  • 연동 흐름
  • 개발 효과
  • IT 엔지니어
  • 연동 구성
  • 요청 구성
  • 응답 처리
  • 연동 검증
  • 개발자
  • 연동 준비
  • 사전 검증
  • 전송 생성
  • 업무 데이터 연결
  • 상태 표시
  • 전송 제어
  • 결과 확정