Google Sheets 데이터를 기반으로 모두싸인 문서 생성하기

이 가이드는 Google Apps Script(GAS)를 활용하여 Google Spreadsheet의 데이터를 기반으로 모두싸인 템플릿을 사용해 문서를 자동 생성하고 전송하는 방법을 안내합니다.

📘

본 예제는 모두싸인 API Key와 템플릿 ID를 활용한 API 호출을 기반으로 구성되어 있습니다.

API 메뉴는 등록된 사용자에게만 표시됩니다. API 기능 이용을 희망하신다면 모두싸인 고객센터로 연락해 주세요.


📍 Step 1: 모두싸인 API Key 발급 받기

  1. 모두싸인 로그인 후 상단 메뉴에서 [설정 → API 키 관리]로 이동
  2. 새 API Key를 생성하여 복사해둡니다.

이 키는 모든 API 요청에 사용되므로 외부에 노출되지 않도록 주의하세요.


📍 Step 2: 템플릿 생성 및 템플릿 ID 확인

  1. 모두싸인 → 템플릿로 이동
  2. 문서를 템플릿으로 저장하고 필요한 문서 편집(매핑 대상 필드)를 설정합니다.
  3. 해당 템플릿의 ID를 확인해 복사해둡니다.

템플릿의 필드 키는 이후 requesterInputMappings에 사용되니 정확히 확인하세요.


📍 Step 3: Google Sheet 준비하기

시트를 아래처럼 구성해주세요. (컬럼 순서 및 위치는 자유롭게 설정 가능하지만 코드와 일치해야 합니다.)


📍 Step 4: Google Apps Script 작성

  1. 확장 프로그램 → Apps Script 클릭
  2. 아래 코드를 복사해서 붙여넣고, apiKey, templateId, requester 정보를 자신의 값으로 수정하세요.
const SHEET_NAME = "테스트";
const API_URL = "https://api.modusign.co.kr/documents/request-with-template";
const EMAIL = "[email protected]"; // <-- 본인의 이메일 입력
const API_KEY = PropertiesService.getScriptProperties().getProperty("API_KEY"); // <-- 본인의 API Key 입력
const TEMPLATE_ID = "cecaea70-307f-11f0-a0e2-5f510e16c1d3"; // <-- 템플릿 ID 입력
const START_COLUMN = 7; // '문서편집' 입력란 필드 시작
const NUM_COLUMNS = 6; // '문서편집' 입력란 필드 갯수
const TRIGGER_COLUMN_INDEX = 13; // 문서전송 트리거 컬럼: N열 (0-indexed)


function sendRowToModusign(sheet, rowNumber) {
  Logger.log(`🚀 sendRowToModusign 실행: rowNumber = ${rowNumber}`);
  const { row, headerRow } = getRowAndHeader(sheet, rowNumber);

  const payload = buildPayload(TEMPLATE_ID, headerRow, row, START_COLUMN, NUM_COLUMNS);

  Logger.log("📥 요청 페이로드: " + JSON.stringify(payload));
  Logger.log("📤 요청 전송 시작");

  const options = buildRequestOptions(EMAIL, API_KEY, payload);

  const response = UrlFetchApp.fetch(API_URL, options);
  const responseCode = response.getResponseCode();
  const responseText = response.getContentText();

  Logger.log("📤 응답 코드: " + responseCode);

  if (responseCode >= 400) {
    const message = `❌ 전송 실패 [${responseCode}]: ${parseModusignError(responseCode, responseText)}`;
    Logger.log(message);
    const html = HtmlService.createHtmlOutput(`
      <div style="padding:20px; font-family:sans-serif;">
        ${message}
      </div>
    `).setWidth(400).setHeight(200);
    SpreadsheetApp.getUi().showModalDialog(html, "전송 실패");


    sheet.getRange(rowNumber, TRIGGER_COLUMN_INDEX + 1).setValue("전송 실패");
    return;
  }
  else if(responseCode === 201){
  const message = `✅ ${row[2]}님의 문서 전송 성공`;
  const html = HtmlService.createHtmlOutput(`
      <div class="modal-body">
        <p>${message}</p>
      </div>
`).setWidth(400).setHeight(200);
  SpreadsheetApp.getUi().showModalDialog(html, "알림");
  sheet.getRange(rowNumber, TRIGGER_COLUMN_INDEX + 1).setValue("전송 완료");
  }

}

function createInputMappings(headerRow, row, startColumn, numColumns) {
  const mappings = [];

  for (let j = 0; j < numColumns; j++) {
    const header = headerRow[startColumn + j]?.trim();
    const cellValue = row[startColumn + j];

    if (!header) continue;

    // "계약 연봉 총액:text" → dataLabel = "계약 연봉 총액", fieldType = "text"
    const [dataLabel, fieldType = "text"] = header.split(":");

    // 타입에 따른 value 처리
    let parsedValue;
    if (fieldType === "checkbox") {
      // Google Sheets에서 checkbox는 true/false 또는 체크/미체크로 반환됨
      parsedValue = cellValue === true || String(cellValue).toLowerCase() === "true";
    } else {
      parsedValue = String(cellValue);
    }

    mappings.push({
      dataLabel,
      value: parsedValue
    });
  }

  return mappings;
}

function buildPayload(templateId, headerRow, row, startColumn, numColumns) {
  const name = row[2]; // C열
  const title = row[3]; // D열
  const methodType = row[4]; // E열
  const contact = methodType === "EMAIL" ? row[5] : row[6];
  const requesterInputMappings = createInputMappings(headerRow, row, startColumn, numColumns);

  return {
    templateId: templateId,
    document: {
      title,
      participantMappings: [
        {
          role: "임직원",  //템플릿에 설정된 'role' 값으로 설정 필요
          name: name,
          signingMethod: {
            type: methodType,
            value: contact,
          },
        },
      ],
      requesterInputMappings: requesterInputMappings
    }
  };
}

function buildRequestOptions(email, apiKey, payload) {
  return {
    method: "post",
    contentType: "application/json",
    headers: {
      Authorization: "Basic " + Utilities.base64Encode(email + ":" + apiKey),
      "Content-Type": "application/json",
    },
    payload: JSON.stringify(payload),
    muteHttpExceptions: true,
  };
}


function getRowAndHeader(sheet, rowNumber) {
  return {
    row: sheet.getRange(rowNumber, 1, 1, sheet.getLastColumn()).getValues()[0],
    headerRow: sheet.getRange(1, 1, 1, sheet.getLastColumn()).getValues()[0]
  };
}

function parseModusignError(responseCode, responseText) {
  try {
    const errorJson = JSON.parse(responseText);
    if (responseCode === 403 && errorJson.type === "ForbiddenException") {
      return "권한 오류입니다. API 키 또는 이메일 인증을 확인해주세요.";
    }
    if (responseCode === 422 && errorJson.type === "RequesterInputDataLabelNotMatchedException" && errorJson.title === "Data label of requester input is not matched") {
      return "템플릿에 정의되지 않은 필드 이름(dataLabel)이 포함되어 있습니다. 시트의 H~M 열 제목을 템플릿에 맞춰 수정해주세요.";
    }
    if (responseCode === 422 && errorJson.type === "UnprocessableEntityException") {
      return "서명자 필드를 필드 이름에 포함되어 있습니다. 문서 편집 필드에 맞춰 수정해주세요.";
    }
    if (responseCode === 401 && errorJson.type === "UnauthorizedException" && errorJson.title === "Unauthorized") {
      return "API Key가 맞는지 확인해주세요.";
    }
    return errorJson.message || responseText;
  } catch (err) {
    return responseText;
  }
}

function onEditHandler(e) {
  const sheet = e.source.getActiveSheet();
  const editedRow = e.range.getRow();
  const editedColumn = e.range.getColumn();
  const triggerValue = e.value;
  const oldValue = e.oldValue || "";

  if (sheet.getName() !== SHEET_NAME) return;

  // N열: 전송 트리거
  if (
    editedColumn === TRIGGER_COLUMN_INDEX + 1 &&
    triggerValue === "전송" &&
    oldValue !== "전송"
  ) {
    Logger.log("📤 전송 조건 만족, sendRowToModusign 실행!");
    sendRowToModusign(sheet, editedRow);
    return;
  }
}


📍Step 5: 트리거(Trigger) 설정하여 자동 실행하기

Google Sheets에서 특정 이벤트 발생 시 자동으로 API가 호출되도록 트리거를 설정할 수 있습니다.
이 가이드는 예제 코드 중 onEditHandler(e) 함수가 자동 실행되도록 트리거(Trigger)를 설정하는 방법을 안내합니다.


✅ 1. 스크립트 편집기 열기

  • Google Sheets 상단 메뉴에서 확장 프로그램 → Apps Script 클릭

✅ 2. 트리거 메뉴 열기

  • Apps Script 화면 상단 메뉴에서 🕒 아이콘 또는 좌측 메뉴 → 트리거 클릭

✅ 3. 새로운 트리거 추가하기

  1. 오른쪽 하단의 + 트리거 추가 버튼 클릭
  2. 다음 항목을 설정합니다:
항목설정값
함수 선택onEditHandler
이벤트 소스 선택스프레드시트에서
이벤트 유형 선택수정 시

✅ 4. 트리거 저장 및 권한 승인

  • 트리거를 저장하면 처음 한 번은 승인 요청 팝업이 뜹니다.
  • Google 계정으로 로그인하고, ‘고급’ → 프로젝트 이름 → 허용 클릭

✅ 5. 테스트 방법

  • 시트의 "전송" 열에 값을 입력하면 자동으로 API가 호출되고, 전송 성공 여부가 자동으로 표시됩니다.

💡 참고

  • 전송은 N열(13번째 열)"전송" 이라고 입력하면 실행됩니다.

🚧

보안 관련 안내

API Key는 절대 외부에 노출되지 않도록 주의하세요.
Google Apps Script에는 Script Properties 기능으로 키를 숨길 수 있습니다.

PropertiesService.getScriptProperties().getProperty("API_KEY");