Smart Life US

← 목록 · 2026-08-13 · 엑셀·업무 자동화

구글시트 날짜 형식 통일 방법: 입고 예약 시스템 기본 5편

구글시트 날짜 형식 통일 방법: 입고 예약 시스템 기본 5편

도입 – 날짜·시간이 제멋대로라 합이 안 맞을 때

구글시트로 입고 예약 시스템을 만들다 보면 어느 순간부터 날짜가 이상하게 보이기 시작합니다. 누군가는 8/3처럼 손으로 입력하고, 또 다른 사람은 2026-08-03을 붙여 넣습니다. 웹앱·Apps Script는 2026-08-03T15:00:00Z 같은 ISO 문자열이나 Date 객체를 기록합니다. 보기에는 비슷한 날짜처럼 보여도, 시트 입장에서는 전혀 다른 값이라 집계가 어긋나기 쉽습니다. 이 글에서는 이런 문제를 해결하는 구글시트 날짜 형식 통일 방법과, 이미 쌓여 있는 예전 데이터를 한 번에 정리하는 실무 절차를 다룹니다.

날짜 형식 통일 절차

이 글은 한 물류센터 입고 예약 시스템을 예제로 만드는 시리즈 중 기본 5편입니다. 앞선 글에서 시트 구조를 잡고, 도어·야드 슬롯을 만들고, 설정 시트와 웹앱 화면까지 구성했습니다. 이제 본격 운영 전 단계에서 꼭 필요한 작업인 날짜·시간 형식 정리와 기존 데이터 마이그레이션을 마무리합니다. 목표는 한 줄입니다. “손 입력과 코드 생성 날짜가 섞여 있어도, 운영자가 보기에는 항상 YYYY-MM-DD / HH:mm으로 정리되어 있고 집계가 정확하게 맞는 상태”를 만드는 것입니다.


구글시트 날짜 문자열 변환이 꼭 필요한 이유

입고 예약 시스템을 실제로 돌리면 날짜가 한 가지 형태로만 들어오지 않습니다. 현장 담당자가 시트에서 직접 입력하는 값, 다른 시스템에서 복사해 오는 값, Apps Script가 new Date()로 남기는 값이 모두 뒤섞입니다. 문제는 이 값들이 저장 형태·시간대·텍스트/날짜 여부가 제각각이라는 점입니다.

예를 들어 다음 값들은 모두 8월 3일을 의미할 수 있습니다.

  • 8/3 같은 짧은 표현
  • 2026-08-03 같은 ISO 문자열
  • 시트에서 날짜 형식으로 표시되는 셀 값
  • Apps Script 내부의 Date 객체

그러나 이 상태에서 =COUNTIF(A:A,"2026-08-03") 같은 함수로 집계를 하면, 일부는 잡히고 일부는 빠질 수 있습니다. 특히 구글시트는 화면에 날짜처럼 보여도 실제로는 텍스트인 경우가 많습니다. 이런 값들이 섞인 채로 남아 있으면, 예약 현황 대시보드나 출고 리포트의 합계가 엑셀·WMS와 맞지 않는 상황이 반복됩니다.

그래서 운영 환경에서는 모든 날짜·시간을 실제 날짜·시간 값으로 저장하고, 보이는 형식만 YYYY-MM-DD / HH:mm 으로 통일해 두는 것이 안전합니다. 문자열로 저장하면 보기에는 깔끔해도 날짜 계산·기간 집계·피벗·차트가 전부 막힙니다. 이 글에서는 Apps Script로 APPT_ymd_()·APPT_hm_() 같은 도우미 함수를 두고, 이를 이용해 새로 들어오는 데이터와 과거 데이터를 모두 정리하는 흐름을 설명합니다. 이미 입고 예약 시스템의 기본 구조가 궁금하다면, 앞에서 다룬 구글시트 입고 예약 시스템 시트 구조 만들기 — 기본 1편을 먼저 보면 전체 맥락을 이해하는 데 도움이 됩니다.


Apps Script 날짜 정규화 기본: APPT_ymd_(), APPT_hm_() 구현

입고 예약 시스템에서는 날짜·시간을 다룰 때마다 비슷한 코드를 반복하기보다는, 한 번 검증한 도우미 함수를 만들어 두고 모든 기능에서 재사용하는 편이 안전합니다. 여기서는 최소한으로 다음 두 가지를 둡니다.

  • APPT_ymd_() → 어떤 입력이 와도 유효하면 YYYY-MM-DD 문자열로 변환합니다.
  • APPT_hm_() → 유효한 시간 값을 HH:mm 문자열로 통일합니다.
  • 두 함수 모두 문자열을 함부로 new Date() 에 넣지 않습니다. '2026-08-03' 을 그대로 넣으면 UTC 자정으로 읽혀 시간대가 America/New_York 일 때 하루 전날이 됩니다. '15:30' 은 아예 해석되지 않아 Invalid Date 가 됩니다. 형식별로 나눠서 읽는 이유가 이것입니다.

이 함수는 날짜 형식이 잘못된 값에 대해서는 조용히 넘기지 않고 예외를 던지도록 만드는 것이 중요합니다. 그래야 정리 함수에서 어떤 행이 실패했는지 명확히 로그로 남길 수 있습니다.

이 함수들은 이 시리즈 전편에서 이미 다루었지만, 이 글만 보고 따라 해도 동작할 수 있도록 최소 구현을 한 번 더 싣습니다.

1단계 — 날짜·시간 정규화 도우미 함수

이 코드는 다양한 입력(문자열, Date, 숫자 등)을 받아 YYYY-MM-DD / HH:mm으로 바꾸거나, 유효하지 않으면 예외를 던집니다.

구글시트 → 확장 프로그램 → Apps Script → Code.gs 상단에 붙여 넣습니다.

붙여넣은 뒤 저장하고, APPT_testDateHelpers()를 실행해 로그를 확인합니다.

Apps Script (JavaScript)
const APPT_TZ = Session.getScriptTimeZone();           // → 프로젝트 시간대 (설정에서 확인)

// 시트가 날짜를 '숫자'로 들고 있을 때(예: 45234) 쓰는 변환. 시트 기준일은 1899-12-30 이다.
function APPT_serialToDate_(serial) {                  // → 일련번호 → Date
  const n = Number(serial);                            // → 숫자로
  const days = Math.floor(n);                          // → 정수부 = 며칠째
  const raw = (n - days) * 86400000;                   // → 소수부 = 하루 중 시각
  const ms = Math.round(raw / 1000) * 1000;            // → 초 단위로 반올림(0.645833 → 15:30)
  const d = new Date(1899, 11, 30);                    // → 로컬 자정 기준일
  d.setDate(d.getDate() + days);                       // → 날짜 더하기
  return new Date(d.getTime() + ms);                   // → 시각 더하기
}

function APPT_ymd_(value) {                            // → YYYY-MM-DD 문자열로 변환
  if (value === '' || value === null || value === undefined) {
    throw new Error('날짜 값이 비어 있습니다.');
  }
  if (value instanceof Date) {                         // → 시트 날짜 셀은 Date 로 들어온다
    return Utilities.formatDate(value, APPT_TZ, 'yyyy-MM-dd');
  }
  if (typeof value === 'number') {                     // → 서식이 '숫자'인 날짜 셀
    if (value < 1) {                                   // → 0~1 은 하루 중 시각을 뜻한다
      throw new Error('시각만 있는 값입니다: ' + value);
    }
    return Utilities.formatDate(APPT_serialToDate_(value), APPT_TZ, 'yyyy-MM-dd');
  }
  const text = String(value).trim();                   // → 나머지는 문자열로 본다
  // '2026-08-03' 을 new Date() 에 그대로 넣으면 **UTC 자정**으로 읽힌다.
  // 시간대가 America/New_York 이면 현지로는 8월 2일 저녁이라 하루가 밀린다.
  // 이미 형식이 맞는 값은 Date 로 바꾸지 않고 그대로 쓴다.
  const iso = text.match(/^(\d{4})-(\d{2})-(\d{2})$/);
  if (iso) {
    const yr = Number(iso[1]);
    const mo = Number(iso[2]);
    const dy = Number(iso[3]);
    // 범위(1~31)만 보면 2026-02-31 이나 2026-04-31 이 통과한다. 그 값을
    // new Date(2026, 1, 31) 로 만들면 오류가 아니라 **3월 3일로 조용히 보정**되어
    // 엉뚱한 예약일이 저장된다. 만들어 본 뒤 같은 날짜로 되돌아오는지 확인한다.
    const check = new Date(yr, mo - 1, dy);
    if (check.getFullYear() !== yr ||
        check.getMonth() !== mo - 1 ||
        check.getDate() !== dy) {
      throw new Error('존재하지 않는 날짜입니다: ' + value);
    }
    return text;
  }
  if (/^\d{1,2}:\d{2}/.test(text)) {                   // → 시각만 있는 값에는 날짜가 없다
    // new Date('23:60') 는 오류가 아니라 **1960-01-01** 을 돌려준다. 그대로 두면
    // 시각 하나가 60년 전 예약일로 저장된다.
    throw new Error('날짜가 없는 시각 값입니다: ' + value);
  }
  const d = new Date(text);                            // → '8/3/2026 15:30' 같은 값
  if (!Number.isFinite(d.getTime())) {
    throw new Error('유효하지 않은 날짜 형식입니다: ' + value);
  }
  return Utilities.formatDate(d, APPT_TZ, 'yyyy-MM-dd');
}

function APPT_hm_(value) {                             // → HH:mm 문자열로 변환
  if (value === '' || value === null || value === undefined) {
    throw new Error('시간 값이 비어 있습니다.');
  }
  if (value instanceof Date) {
    return Utilities.formatDate(value, APPT_TZ, 'HH:mm');
  }
  if (typeof value === 'number') {                     // → 0.645833 같은 시각 소수도 여기로
    return Utilities.formatDate(APPT_serialToDate_(value), APPT_TZ, 'HH:mm');
  }
  const text = String(value).trim();
  // '15:30' 은 new Date() 가 해석하지 못한다(Invalid Date). 직접 읽는다.
  const t = text.match(/^(\d{1,2}):(\d{2})(?::\d{2})?$/);
  if (t) {
    const hh = Number(t[1]);
    const mm = Number(t[2]);
    if (hh > 23 || mm > 59) {
      throw new Error('시각 범위를 벗어났습니다: ' + value);
    }
    return String(hh).padStart(2, '0') + ':' + t[2];
  }
  if (/^\d{4}-\d{2}-\d{2}$/.test(text)) {              // → 날짜만 있는 값에는 시각이 없다
    throw new Error('시각이 없는 날짜 값입니다: ' + value);
  }
  const d = new Date(text);
  if (!Number.isFinite(d.getTime())) {
    throw new Error('유효하지 않은 시간 형식입니다: ' + value);
  }
  return Utilities.formatDate(d, APPT_TZ, 'HH:mm');
}

function APPT_testDateHelpers() {                      // → 도우미 테스트 (기대값까지 비교)
  const cases = [                                      // → [입력, 기대 날짜, 기대 시각]
    ['2026-08-03', '2026-08-03', ''],                  // → ISO 날짜: 하루 밀리면 실패로 뜬다
    ['8/3/2026 15:30', '2026-08-03', '15:30'],         // → 날짜+시간 문자열
    ['15:30', '', '15:30'],                            // → 시각만 있는 텍스트
    [45234, '2023-11-04', '00:00'],                    // → 시트 날짜 일련번호
    ['2026-02-29', '', ''],                            // → 없는 날짜(2026년은 평년)
    ['2028-02-29', '2028-02-29', ''],                  // → 있는 날짜(2028년은 윤년)
    ['23:60', '', ''],                                 // → 시각도 날짜도 아닌 값
    [0.6458333333, '', '15:30'],                       // → 시각만 있는 일련번호
    ['어제쯤', '', '']                                  // → 실패해야 정상인 값
  ];
  cases.forEach(function (c) {                         // → 한 줄씩 검사
    let gotY = '';
    let gotH = '';
    try { gotY = APPT_ymd_(c[0]); } catch (e) { gotY = ''; }
    try { gotH = APPT_hm_(c[0]); } catch (e) { gotH = ''; }
    const ok = (gotY === c[1]) && (gotH === c[2]);
    Logger.log((ok ? 'OK   ' : '실패 ') + JSON.stringify(c[0]) + ' → ' +
               gotY + ' ' + gotH + (ok ? '' : ' (기대: ' + c[1] + ' ' + c[2] + ')'));
  });
}

제대로 됐는지 확인하는 방법은 간단합니다. APPT_testDateHelpers()를 실행한 뒤 실행 로그를 보면 다섯 줄이 찍힙니다. 다섯 줄 모두 앞에 OK 가 붙어야 정상입니다. 한 줄이라도 실패 로 뜨면 기대값이 함께 찍히므로 무엇이 어긋났는지 바로 알 수 있습니다. 특히 첫 줄('2026-08-03'2026-08-03)이 하루 전날로 찍힌다면 시간대 처리가 잘못된 것입니다.


날짜 형식 정리 함수: APPT_normalizeDateFormats()

이제 실제 예약 시트에 쌓여 있는 날짜·시간 데이터를 한 번에 정리하는 함수를 만듭니다. 이름은 APPT_normalizeDateFormats()로 두고, 예약 시트 구조는 다음과 같이 가정합니다.

  • A열: 예약일(여기에 손 입력·붙여넣기·Date가 섞여 있음)
  • B열: 예약시간(텍스트·Date·비어 있음이 섞여 있음)
  • 1행: 헤더, 2행부터 데이터

이 함수가 하는 일은 다음과 같습니다.

  1. 날짜 열과 시간 열만 따로 읽습니다. 여러 열을 한 번에 읽어 통째로 되쓰면 사이에 낀 수식 열이 계산 결과값으로 덮입니다.
  2. 각 값을 APPT_ymd_()·APPT_hm_()로 검증합니다.
  3. 셀에는 문자열이 아니라 진짜 Date을 넣고, 보이는 모양은 setNumberFormat() 으로 맞춥니다. 문자열로 넣으면 보기에는 통일돼도 이후 날짜 계산·기간 집계·피벗·차트가 전부 동작하지 않습니다.
  4. 변환에 실패한 값은 원본 그대로 두고 행 번호·원본 값·이유를 로그에 남깁니다.

여러 사람이 동시에 함수를 실행해도 문제가 생기지 않도록 LockService로 잠금을 사용합니다.

2단계 — 날짜·시간 형식 정리 함수 코드

이 함수는 예약 시트의 날짜·시간 컬럼을 한 번에 정리합니다.

구글시트 → 확장 프로그램 → Apps Script → Code.gs 맨 아래, 앞 도우미 함수 아래에 붙여 넣습니다.

붙여넣은 뒤 저장한 다음, 편집기에서 APPT_testNormalizeDateFormats()를 실행합니다.

Apps Script (JavaScript)
// 여기만 본인 시트에 맞게 바꾸세요
const APPT_NORMALIZE_CONFIG = {                        // → 예약 날짜 정리 설정
  SHEET_NAME: 'APPT_MAIN',                             // → 예약 데이터 시트명
  HEADER_ROW: 1,                                       // → 헤더가 있는 행 번호
  COL_DATE: 1,                                         // → 예약일 컬럼 번호 (A=1)
  COL_TIME: 2                                          // → 예약시간 컬럼 번호 (B=2)
};                                                     // →

function APPT_normalizeDateFormats() {                 // → 예약 시트 날짜·시간 형식 통일
  const lock = LockService.getScriptLock();            // → 동시에 실행 못 하게 잠금
  lock.waitLock(30000);                                // → 최대 30초까지 대기
  try {                                                // → 오류 나도 잠금 해제 위해
    const cfg = APPT_NORMALIZE_CONFIG;                 // → 설정 단축 참조
    const sheet = SpreadsheetApp.getActive()           // → 현재 스프레드시트
      .getSheetByName(cfg.SHEET_NAME);                 // → 예약 시트 찾기
    if (!sheet) {                                      // → 시트가 없을 때
      throw new Error('예약 시트를 찾을 수 없습니다: ' + cfg.SHEET_NAME);
    }

    const lastRow = sheet.getLastRow();                // → 데이터가 있는 마지막 행
    if (lastRow <= cfg.HEADER_ROW) {                   // → 헤더만 있고 데이터 없을 때
      Logger.log('정리할 예약 데이터가 없습니다.');       // → 로그 남기고 종료
      return;                                          // →
    }

    const startRow = cfg.HEADER_ROW + 1;               // → 데이터 시작 행
    const rowCount = lastRow - cfg.HEADER_ROW;         // → 데이터 행 개수

    // 날짜 열과 시간 열만 따로 읽고 쓴다. 여러 열을 한 번에 getValues → setValues 하면
    // 사이에 낀 **수식 열이 계산 결과값으로 덮여** 수식이 사라진다.
    const dateRange = sheet.getRange(startRow, cfg.COL_DATE, rowCount, 1);
    const timeRange = sheet.getRange(startRow, cfg.COL_TIME, rowCount, 1);
    const dateVals = dateRange.getValues();            // → 날짜 열만
    const timeVals = timeRange.getValues();            // → 시간 열만

    const errors = [];                                 // → 잘못된 값 기록
    for (let i = 0; i < rowCount; i++) {               // → 각 행 반복
      const rowNumber = startRow + i;                  // → 실제 시트 행 번호
      const rawDate = dateVals[i][0];                  // → 날짜 원본 값
      const rawTime = timeVals[i][0];                  // → 시간 원본 값

      if (rawDate !== '' && rawDate !== null) {        // → 비어 있지 않을 때만
        try {                                          // → 값 하나씩 따로 잡는다
          const ymd = APPT_ymd_(rawDate);              // → 형식 검증까지 겸한다
          const d = ymd.split('-');                    // → 연·월·일 분리
          // 셀에는 **문자열이 아니라 진짜 Date** 를 넣는다. 문자열로 넣으면 보기에는
          // 통일돼도 이후 날짜 계산·기간 집계·피벗·차트가 전부 동작하지 않는다.
          dateVals[i][0] = new Date(Number(d[0]), Number(d[1]) - 1, Number(d[2]));
        } catch (e) {                                  // → 실패하면 원본 그대로 둔다
          errors.push({ row: rowNumber, column: '날짜', value: rawDate, error: e.message });
        }
      }

      if (rawTime !== '' && rawTime !== null) {        // → 비어 있지 않을 때만
        try {                                          // →
          const hm = APPT_hm_(rawTime);                // → HH:mm 검증
          const t = hm.split(':');                     // → 시·분 분리
          // 시트는 '시각만' 있는 값을 1899-12-30 기준 Date 로 저장한다.
          timeVals[i][0] = new Date(1899, 11, 30, Number(t[0]), Number(t[1]));
        } catch (e) {                                  // →
          errors.push({ row: rowNumber, column: '시간', value: rawTime, error: e.message });
        }
      }
    }

    dateRange.setValues(dateVals);                     // → 날짜 열만 반영
    timeRange.setValues(timeVals);                     // → 시간 열만 반영
    dateRange.setNumberFormat('yyyy-MM-dd');           // → 보이는 형식만 통일
    timeRange.setNumberFormat('HH:mm');                // → 보이는 형식만 통일

    if (errors.length > 0) {                           // → 오류가 있었으면
      Logger.log('날짜/시간 변환 실패 ' + errors.length + '건: ' + JSON.stringify(errors));
    } else {                                           // → 오류 없을 때
      Logger.log(rowCount + '행의 예약 날짜·시간을 정리했습니다.');
    }
  } finally {                                          // → 오류 여부와 무관하게
    lock.releaseLock();                                // → 잠금 해제
  }
}                                                      // →

function APPT_testNormalizeDateFormats() {             // → 정리 함수 테스트용
  APPT_normalizeDateFormats();                         // → 본 함수 실행
}                                                      // →

제대로 됐는지 확인하려면, 예약 시트에서 날짜·시간 컬럼이 YYYY-MM-DD / HH:mm 으로 통일됐는지 보고, 실행 로그에 변환 실패 행의 번호·원본 값이 찍혀 있는지 확인합니다. 한 가지 더 확인하세요. 정리된 날짜 셀을 클릭했을 때 수식 입력줄에 2026. 8. 3 처럼 날짜로 보이면 제대로 저장된 것이고, 왼쪽 정렬된 텍스트로 보이면 값이 문자열로 들어간 것입니다. 실패한 행은 사람이 고친 뒤 다시 실행하면 됩니다. 이 함수는 같은 값을 몇 번 정리해도 결과가 같습니다.


구글시트 날짜 데이터 마이그레이션: 옛 시트에서 새 시트로 옮기기

실제 운영에서는 예약 시스템을 한 번에 완벽하게 만들지 못하는 경우가 많습니다. 초기에 단순한 시트로 운영하다가, 나중에야 도어·슬롯·상태 값 등을 갖춘 본격적인 구조로 바꾸게 됩니다. 이때 예전 시트에 있던 데이터를 새 구조로 옮길지, 아예 버릴지가 고민이 됩니다.

사람이 복사·붙여넣기만으로 옮기면 다음과 같은 문제가 자주 발생합니다.

  • 날짜 형식이 다시 뒤섞입니다.
  • 필터가 걸린 상태에서 복사해 일부 행이 빠집니다.
  • 시간대나 컬럼 맵핑을 잘못 맞춰 도착시간이 다른 열로 들어갑니다.

그래서 일정 물량 이상이라면 Apps Script로 마이그레이션 절차를 정형화해 두는 것이 안전합니다. 여기서는 다음과 같은 구조를 가정합니다.

  • 옛 시트(OLD_APPT)
  • A열: 2026-08-03 15:30처럼 날짜+시간이 한 셀에 있는 문자열
  • B열: 차량번호
  • C열: 운송사
  • D열: 레퍼런스
  • 새 시트(APPT_MAIN)
  • A열: 예약일(YYYY-MM-DD)
  • B열: 예약시간(HH:mm)
  • C열: 차량번호
  • D열: 운송사
  • E열: 레퍼런스
  • F열: 원본 행 번호(마이그레이션이 기록합니다)

마이그레이션 함수는 옛 시트를 읽어 새 시트의 형식으로 배열을 재조립하면서, 동시에 날짜·시간을 정규화합니다. 그리고 옮긴 원본 행 번호를 F열에 함께 적습니다. 이 표시가 없으면 실패한 행을 고쳐 함수를 다시 실행할 때 첫 실행에서 성공했던 행까지 전부 다시 복사됩니다. 표시를 남겨 두면 몇 번을 다시 실행해도 새로 옮길 것만 옮깁니다.

3단계 — 마이그레이션 설정과 실행 함수

이 코드는 옛 예약 시트에서 새 예약 시트로 데이터를 옮기면서 날짜·시간을 정규화합니다.

구글시트 → 확장 프로그램 → Apps Script → Code.gs 맨 아래, 앞 코드들 아래에 붙여 넣습니다.

붙여넣은 뒤 저장하고, APPT_testMigrateLegacyData()를 실행합니다.

Apps Script (JavaScript)
// 여기만 본인 시트에 맞게 바꾸세요
const APPT_MIGRATE_CONFIG = {                          // → 마이그레이션 설정
  SOURCE_SHEET_NAME: 'OLD_APPT',                       // → 옛 예약 시트명
  TARGET_SHEET_NAME: 'APPT_MAIN',                      // → 새 예약 시트명
  SOURCE_HEADER_ROW: 1,                                // → 옛 시트 헤더 행
  TARGET_HEADER_ROW: 1,                                // → 새 시트 헤더 행
  // 옛 시트 컬럼 정의
  SRC_COL_DATETIME: 1,                                 // → 날짜+시간 문자열 컬럼 (A열)
  SRC_COL_VEHICLE: 2,                                  // → 차량번호 컬럼 (B열)
  SRC_COL_CARRIER: 3,                                  // → 운송사 컬럼 (C열)
  SRC_COL_REFERENCE: 4,                                // → 레퍼런스 컬럼 (D열)
  // 새 시트 컬럼 정의
  TGT_COL_DATE: 1,                                     // → 예약일 컬럼 (A열)
  TGT_COL_TIME: 2,                                     // → 예약시간 컬럼 (B열)
  TGT_COL_VEHICLE: 3,                                  // → 차량번호 컬럼 (C열)
  TGT_COL_CARRIER: 4,                                  // → 운송사 컬럼 (D열)
  TGT_COL_REFERENCE: 5,                                // → 레퍼런스 컬럼 (E열)
  TGT_COL_SRC_ROW: 6                                   // → 원본 행 번호 (F열) — 재실행 중복 방지
};                                                     // →

function APPT_migrateLegacyData() {                    // → 옛 예약 데이터를 새 시트로 옮기기
  const lock = LockService.getScriptLock();            // → 동시 실행 방지 잠금
  lock.waitLock(30000);                                // → 최대 30초 대기
  try {                                                // →
    const cfg = APPT_MIGRATE_CONFIG;                   // → 설정 단축 참조
    const ss = SpreadsheetApp.getActive();             // → 현재 스프레드시트

    const srcSheet = ss.getSheetByName(cfg.SOURCE_SHEET_NAME);  // → 옛 시트
    if (!srcSheet) {                                   // → 없는 경우
      throw new Error('옛 예약 시트를 찾을 수 없습니다: ' + cfg.SOURCE_SHEET_NAME);
    }
    const tgtSheet = ss.getSheetByName(cfg.TARGET_SHEET_NAME);  // → 새 시트
    if (!tgtSheet) {                                   // → 없는 경우
      throw new Error('새 예약 시트를 찾을 수 없습니다: ' + cfg.TARGET_SHEET_NAME);
    }

    const srcLastRow = srcSheet.getLastRow();          // → 옛 시트 마지막 행
    if (srcLastRow <= cfg.SOURCE_HEADER_ROW) {         // → 데이터 없는 경우
      Logger.log('옮길 옛 예약 데이터가 없습니다.');      // →
      return;                                          // →
    }

    // 이미 옮긴 행은 다시 옮기지 않는다. 실패한 행만 고쳐 다시 실행하는 것이 정상 절차인데,
    // 표시가 없으면 **첫 실행에서 성공했던 행까지 통째로 다시 복사된다.**
    const tgtLastRow = tgtSheet.getLastRow();          // → 새 시트 마지막 행
    const alreadyMoved = {};                           // → 이미 옮긴 원본 행 번호
    if (tgtLastRow > cfg.TARGET_HEADER_ROW) {          // → 이미 데이터가 있으면
      const keys = tgtSheet.getRange(                  // → 원본 행 번호 열만 읽는다
        cfg.TARGET_HEADER_ROW + 1, cfg.TGT_COL_SRC_ROW,
        tgtLastRow - cfg.TARGET_HEADER_ROW, 1
      ).getValues();                                   // →
      for (let k = 0; k < keys.length; k++) {          // →
        const key = keys[k][0];                        // →
        if (key !== '' && key !== null) {              // →
          alreadyMoved[String(key)] = true;            // → 옮긴 것으로 표시
        }
      }
    }

    const srcRange = srcSheet.getRange(                // → 옛 데이터 범위
      cfg.SOURCE_HEADER_ROW + 1, 1,
      srcLastRow - cfg.SOURCE_HEADER_ROW, srcSheet.getLastColumn()
    );                                                 // →
    const srcValues = srcRange.getValues();            // → 옛 데이터 읽기

    const rowsToAppend = [];                           // → 새 시트에 쌓을 배열
    const errors = [];                                 // → 변환 실패 기록
    let skipped = 0;                                   // → 이미 옮겨 건너뛴 행 수

    for (let i = 0; i < srcValues.length; i++) {       // → 각 행 반복
      const row = srcValues[i];                        // → 옛 행
      const rowNumber = cfg.SOURCE_HEADER_ROW + 1 + i; // → 실제 행 번호

      if (alreadyMoved[String(rowNumber)]) {           // → 이미 옮긴 행이면
        skipped++;                                     // → 세어만 두고
        continue;                                      // → 건너뛴다
      }

      const rawDateTime = row[cfg.SRC_COL_DATETIME - 1]; // → 날짜+시간 원본
      const vehicle = row[cfg.SRC_COL_VEHICLE - 1];    // → 차량번호
      const carrier = row[cfg.SRC_COL_CARRIER - 1];    // → 운송사
      const reference = row[cfg.SRC_COL_REFERENCE - 1];// → 레퍼런스

      if (rawDateTime === '' || rawDateTime === null) {  // → 날짜가 비어 있으면
        errors.push({ row: rowNumber, datetime: rawDateTime,
                      error: '날짜/시간 값이 비어 있습니다.' });
        continue;                                      // → 이 행 건너뜀
      }

      try {                                            // → 변환 시도
        // 원본 값을 그대로 도우미에 넘긴다. 여기서 먼저 new Date() 로 바꾸면
        // '2026-08-03' 같은 값이 UTC 로 읽혀 하루가 밀린다.
        const ymd = APPT_ymd_(rawDateTime);            // → 날짜 문자열
        const hm = APPT_hm_(rawDateTime);              // → 시간 문자열
        const d = ymd.split('-');                      // → 연·월·일
        const t = hm.split(':');                       // → 시·분

        const tgtRow = [];                             // → 새 행 배열 (A~F)
        tgtRow[cfg.TGT_COL_DATE - 1] = new Date(Number(d[0]), Number(d[1]) - 1, Number(d[2]));
        tgtRow[cfg.TGT_COL_TIME - 1] = new Date(1899, 11, 30, Number(t[0]), Number(t[1]));
        tgtRow[cfg.TGT_COL_VEHICLE - 1] = vehicle;     // → 차량번호
        tgtRow[cfg.TGT_COL_CARRIER - 1] = carrier;     // → 운송사
        tgtRow[cfg.TGT_COL_REFERENCE - 1] = reference; // → 레퍼런스
        tgtRow[cfg.TGT_COL_SRC_ROW - 1] = rowNumber;   // → 다시 실행해도 중복되지 않게

        rowsToAppend.push(tgtRow);                     // → 새 시트에 추가 예정
      } catch (e) {                                    // → 변환 실패 시
        errors.push({ row: rowNumber, datetime: rawDateTime, error: e.message });
      }
    }

    if (rowsToAppend.length === 0) {                   // → 옮길 행이 없으면
      Logger.log('새로 옮길 행이 없습니다. (이미 옮겨 건너뜀 ' + skipped + '행)');
    } else {                                           // → 옮길 데이터가 있으면
      const tgtStartRow = tgtSheet.getLastRow() + 1;   // → 붙여넣기 시작 행
      tgtSheet.getRange(                               // → A~F 범위
        tgtStartRow, 1, rowsToAppend.length, cfg.TGT_COL_SRC_ROW
      ).setValues(rowsToAppend);                       // → 새 시트에 일괄 반영
      tgtSheet.getRange(tgtStartRow, cfg.TGT_COL_DATE, rowsToAppend.length, 1)
        .setNumberFormat('yyyy-MM-dd');                // → 날짜 표시 형식
      tgtSheet.getRange(tgtStartRow, cfg.TGT_COL_TIME, rowsToAppend.length, 1)
        .setNumberFormat('HH:mm');                     // → 시간 표시 형식
      Logger.log(rowsToAppend.length + '행을 새 예약 시트로 옮겼습니다. (이미 옮겨 건너뜀 '
                 + skipped + '행)');
    }

    if (errors.length > 0) {                           // → 오류 있었다면
      Logger.log('마이그레이션 실패 ' + errors.length + '행: ' + JSON.stringify(errors));
    }
  } finally {                                          // →
    lock.releaseLock();                                // → 잠금 해제
  }
}                                                      // →

function APPT_testMigrateLegacyData() {                // → 마이그레이션 테스트용
  APPT_migrateLegacyData();                            // → 본 함수 실행
}                                                      // →

제대로 됐는지 확인하는 방법은 다음과 같습니다. 우선 새 시트의 행 수가 예상 범위(옛 시트 데이터 중 날짜가 있는 행 수)와 비슷한지 보고, 새 시트의 날짜·시간 컬럼이 YYYY-MM-DD / HH:mm 형식으로 통일되어 있는지 확인합니다. 그리고 Apps Script 실행 로그에서 옮긴 행 수가 얼마인지, 실패한 행 목록과 원인이 무엇인지 살펴본 뒤, 필요하다면 그 행만 손으로 보정하고 함수를 다시 실행하면 됩니다. 기존 옛 시트는 일정 기간 보존한 뒤, 새 구조가 안정적으로 운영되는 것이 확인되면 별도 백업 파일로만 남기고 정리하는 편이 좋습니다.


실무에서 날짜·시간 정리와 마이그레이션 할 때 유의할 점

실제 물류 현장에서 구글시트 기반 입고 예약 시스템을 운영하며 느낀 점을 기준으로, 날짜·시간 정리와 데이터 마이그레이션에서 특히 신경 써야 하는 부분을 정리합니다.

  1. 변환 실패 시 원본 보존이 우선입니다. 날짜·시간 컬럼 중 하나라도 변환에 실패한 행은, 코드에서 예외를 던지게 하고 그 행 전체를 그대로 유지하는 편이 안전합니다. 이 글의 APPT_normalizeDateFormats()가 그 방식을 택했습니다. 이렇게 하면 “어느 부분까지는 새 형식, 어느 부분은 옛 형식”이 섞여 있는 절반 성공 상태를 피할 수 있습니다.
  1. 로그에 실제 값을 남겨야 재처리가 쉽습니다. 단순히 행 번호만 기록하면, 나중에 필터링해서 확인할 때 매번 원본 값을 다시 들여다봐야 합니다. 에러 로그에 행 번호와 함께 원본 날짜·시간 문자열을 남기면, 어떤 패턴의 오타가 반복되는지 한눈에 파악할 수 있고, 필요한 경우 그 패턴만 따로 처리하는 추가 스크립트를 만드는 것도 수월합니다. 마이그레이션 함수에서도 차량번호·운송사·레퍼런스를 함께 남긴 이유가 바로 이것입니다.
  1. 마이그레이션은 최소 두 번 실행될 것을 전제로 설계하는 것이 좋습니다. 실무에서는 테스트 후 한 번, 본 운영 데이터에 한 번 이상 실행되는 경우가 대부분입니다. 이 예제 코드는 옛 시트의 데이터를 읽기만 하고 원본을 수정하지 않기 때문에, 같은 데이터를 여러 번 옮기면 새 시트에 중복이 생길 수 있습니다. 실제 운영 환경에서는 옮기기 완료 후 옛 시트에 “이관 완료” 표시 컬럼을 하나 두고, 이미 표시된 행은 스킵하는 방식으로 멱등성을 확보하는 것을 추천합니다.
  1. 시간대·야간 근무 패턴을 반영한 테스트가 필요합니다. 입고 예약 시스템은 특히 이른 아침·늦은 밤 배송이 많은 경우가 많습니다. APPT_ymd_()·APPT_hm_()가 실제 현장 시간대 기준으로 정상 동작하는지, 자정 근처 시간대 데이터를 몇 줄 만들어 직접 검증해야 합니다. 이 과정을 거쳐야 나중에 “어제 밤 11시 예약이 오늘 새벽 1시로 잡히는” 식의 오류를 줄일 수 있습니다.
  1. 코드는 항상 부분 시트에서 먼저 돌린 뒤 전체에 적용합니다. 실제로는 예시 수치로 수십~수천 행까지 테스트해 본 뒤, 실행 시간이 여유 있고 결과가 기대와 정확히 일치하는 것을 확인하고 전체 데이터에 적용하는 편이 안전합니다. 필요하다면 구글시트 Apps Script 오류 처리 백업 방법 | LockService·try/catch·DriveApp 백업처럼 정리 전에 파일 전체를 복제해 두는 것도 좋은 습관입니다.

시리즈 통합 방법

이 글은 입고 예약 시스템 시리즈의 일부라, 이전 편들에서도 onOpen() 메뉴 생성 함수 등을 이미 사용하고 있을 수 있습니다. 시리즈 전체 코드를 한 스프레드시트 안에 통합할 때 다음 점만 지키면 충돌을 줄일 수 있습니다.

  • onOpen()프로젝트 전체에 하나만 둡니다. 편마다 새로 만들면 마지막 선언만 살아남아 앞 편 메뉴가 오류 없이 조용히 사라집니다. 각 편은 add○○Menu_(menu) 같은 메뉴 도우미만 추가하고, 그 하나뿐인 onOpen 안에서 도우미를 전부 호출하세요.
  • 이 편은 메뉴를 만들지 않습니다. 정리·마이그레이션은 편집기에서 직접 실행하는 함수이므로, 앞 편의 onOpen 은 손대지 않아도 됩니다.

최상위 이름에는 APPT_NORMALIZE_CONFIG, APPT_normalizeDateFormats, APPT_migrateLegacyData 처럼 APPT_ 접두어를 고정해 둡니다. 도우미도 마찬가지로 APPT_ymd_, APPT_hm_ 로 두었습니다. 창고 시리즈에도 ymd_ 라는 같은 이름이 있는데, 접두어 없이 두면 두 시스템을 한 시트에 올렸을 때 한쪽 정의가 다른 쪽을 덮어씁니다.


맺음말

입고 예약 시스템에서 날짜·시간은 모든 집계와 운영 판단의 기준이 됩니다. 초기에는 눈에 보이는 형식만 맞추면 된다고 생각하기 쉽지만, 손 입력·복사·스크립트 기록이 몇 달만 섞여도 일관성을 잃기 시작합니다. 이번 글에서 정리한 날짜·시간 정규화 도우미 함수와 정리·마이그레이션 스크립트를 한 번만 제대로 만들어 두면, 이후에 리포트나 대시보드를 추가하는 작업이 훨씬 단순해집니다.

지금 바로 할 수 있는 행동 하나를 제안하면, 현재 사용 중인 예약 시트에서 날짜·시간 컬럼이 섞여 있는 범위를 복사해 별도 테스트 시트를 만든 뒤, 이 글의 APPT_ymd_()APPT_normalizeDateFormats()를 붙여 넣고 실행해 보시기 바랍니다. 그 결과가 만족스럽다면, 백업을 한 번 만들어 둔 뒤 실 운영 시트에 적용해 보는 순서로 진행하면 안전하게 구글시트 날짜 형식 통일을 마무리할 수 있습니다. 다음 편에서는 이렇게 정리된 날짜 키를 활용해, 날짜별 예약 현황을 한 화면에서 조회하는 기능을 이어서 다룰 예정입니다.