시트가 텍스트를 날짜로 바꾼다: '2026-06'이 완료를 영영 막을 때
구글 시트는 '2026-06' 같은 문자열을 조용히 Date로 강제 변환한다. 저장·읽기·비교가 각자 월을 다르게 계산하면 완료가 영원히 거부된다. 무엇이 강제 변환되는지, 셀을 텍스트로 고정하고 읽을 때 방어하는 법, 배치 쓰기와 수식 인젝션까지.
시트는 DB가 아니다. 텍스트를 마음대로 타입으로 바꾼다. 셀에 "2026-06"을 넣으면 시트가 그걸 문자열이 아니라 Date로 강제 변환한다. 저장·출력·완료가 각자 “몇 월”을 다르게 계산하면, 어제 날짜를 넣는 순간 세 값이 어긋나 완료가 영원히 거부된다.

왜 중요한가
이건 에러를 안 낸다. 저장은 되고 화면도 멀쩡한데 완료만 안 눌린다. 원인이 코드가 아니라 시트의 타입 변환이라, 로그를 아무리 봐도 안 보인다. 그리고 이 함정은 날짜 하나가 아니다.
무엇이 강제 변환되나
시트에 들어가는 순간 원래 타입이 아니게 되는 대표 케이스:
| 넣은 값 | 시트가 바꾸는 것 | 증상 |
|---|---|---|
2026-06, 3/4, 1-2 | Date | 월/일 계산이 어긋남 |
010-1234-5678 | 숫자(앞자리 0 소실) | 전화번호 깨짐 |
1234567890123456 | 지수 표기 1.23E+15 | 주문번호·바코드 손상 |
=, +, -, @ 로 시작 | 수식으로 해석 | 오류·수식 인젝션 |
TRUE / FALSE | 불리언 | 문자열 비교 실패 |
즉 “사용자가 텍스트로 넣은 것”이 “읽을 때 다른 타입”으로 나온다. 이걸 모른 채 함수마다 각자 타입을 가정하면 조용히 어긋난다.
잘못된 방식: 세 경로가 각자 월을 계산한다
AI가 짠 코드의 전형이다. 함수 하나하나는 “합리적”인데, 셀 타입 가정이 제각각이다.
// 저장 경로: 문자열이라고 가정
const savedMonth = String(row[COL.month]); // Date면 "Mon Jun 01 2026..."가 된다
// 출력 경로: Date라고 가정
const outMonth = row[COL.month].getMonth() + 1; // 문자열이면 getMonth is not a function
// 완료 경로: 문자열 슬라이스라고 가정
const doneMonth = row[COL.month].slice(0, 7); // "2026-06" 또는 폭발
// → 셋이 서로 다른 값을 내고, 완료 비교(savedMonth === doneMonth)가 영영 실패
한 셀을 세 곳이 다르게 해석하는 순간, 어제 날짜 하나로 완료가 막힌다.
정답: 쓸 땐 텍스트로 고정, 읽을 땐 한 곳에서 정규화
셀 서식을 먼저 텍스트(@)로 걸고 값을 넣는다. 읽을 땐 Date로 변해 있어도 시트 타임존 기준으로 다시 YYYY-MM으로 정규화한다. 모든 읽기 지점이 이 함수 하나만 통과하게 한다.
const MONTH_RE = /^\d{4}-\d{2}$/;
// 쓰기: 서식을 '텍스트'로 먼저 건 뒤 값을 넣는다 → 시트가 Date로 못 바꾼다
function writeMonthCell_(range, month) { // month = "2026-06"
range.setNumberFormat("@").setValue(month);
}
// 읽기: 이미 Date여도 시트 타임존 기준으로 "YYYY-MM"으로 정규화 (단일 진실 소스)
function readMonthCell_(value) {
if (Object.prototype.toString.call(value) === "[object Date]") {
const tz = SpreadsheetApp.getActiveSpreadsheet().getSpreadsheetTimeZone();
return Utilities.formatDate(value, tz, "yyyy-MM"); // Date → 문자열
}
const text = String(value).replace(/^'/, "").trim();
if (!MONTH_RE.test(text)) throw new Error("BAD_MONTH_CELL: " + value);
return text;
}
대량으로 쓸 땐 셀마다 setNumberFormat을 부르지 마라 — API 호출이 폭증한다. 열 전체 서식을 한 번에 걸고 setValues로 한 번에 쓴다.
// 대량 쓰기: 서식·값을 각각 '한 번의 호출'로 (셀 루프 금지 → 배치)
const col = sheet.getRange(2, COL.month, months.length, 1);
col.setNumberFormat("@"); // 열 전체를 텍스트 서식으로
col.setValues(months.map((m) => [m])); // 값도 한 번에
사용자 입력을 셀에 쓸 땐 수식 인젝션도 막는다. =, +, -, @ 로 시작하면 시트가 수식으로 실행한다.
// 수식 인젝션 방어: 위험 프리픽스면 앞에 ' 를 붙여 강제로 텍스트화
function sheetSafeCell_(v) {
const s = String(v);
return /^[=+\-@]/.test(s) ? "'" + s : s;
}
놓치기 쉬운 것
- 정규화는 한 곳에서. 저장·출력·완료가 각자 월을 계산하면 어긋난다. 셀을 읽는 모든 지점을
readMonthCell_하나로 통과시켜라. - 서식을 값보다 먼저.
setValue다음에setNumberFormat("@")은 이미 늦다. 서식을 먼저 걸어야 변환을 막는다. - 타임존. Date를
getMonth()로 읽으면 스크립트 타임존과 시트 타임존이 다를 때 월 경계에서 하루 밀린다.getSpreadsheetTimeZone()기준으로 포맷하라. - 체크섬 drift. Date 셀을 체크섬·비교에 넣을 땐
toISOString()등으로 표현을 고정하지 않으면, 값이 안 바뀌어도 체크섬이 흔들린다. - 배치. 서식·값은
getValues/setValues로 한 번에. 셀 루프는 배치 호출에서 다룬 그 함정이다.
깊이: 넣기 전에 “시트가 이걸 뭘로 볼까”를 물어라
값을 쓰는 모든 셀에 대해 시트의 해석을 한 번씩 의심하라. 특히 서빙 소스로 쓰는 탭은 수식·IMPORTRANGE·필터뷰를 두지 마라 — 코드 롤백으로 수식 손상은 못 되돌린다. 핵심 한 줄: 시트에 넣는 텍스트는 서식을 텍스트로 고정하고, 읽을 땐 타입을 방어하라.
자주 묻는 질문
- 구글 시트에 '2026-06'을 넣었는데 날짜로 바뀌는 이유는?
- 시트는 날짜처럼 보이는 문자열을 입력 즉시 Date로 자동 강제 변환합니다. 막으려면 셀 서식을 텍스트(@)로 먼저 설정한 뒤 값을 넣거나, 값 앞에 작은따옴표(')를 붙이면 됩니다.
- Apps Script에서 셀 값이 문자열인지 Date인지 어떻게 구분하나요?
- Object.prototype.toString.call(value) === '[object Date]'로 판별합니다. typeof는 Date도 'object'로 나와 구분이 안 됩니다. Date면 시트 타임존 기준으로 다시 문자열로 정규화하세요.
- 왜 저장은 되는데 완료 처리만 안 되나요?
- 저장·출력·완료 경로가 각자 '몇 월인지'를 다르게 계산하면, 시트가 값을 Date로 바꾼 순간 세 값이 어긋나 비교가 실패합니다. 월을 읽는 지점을 정규화 함수 하나로 통일하세요.
- 시트에서 = 나 + 로 시작하는 사용자 입력은 위험한가요?
- 네. =, +, -, @ 로 시작하는 문자열은 시트가 수식으로 해석해 수식 인젝션(CSV injection) 위험이 있습니다. 사용자 입력을 셀에 쓸 땐 이 프리픽스를 이스케이프하거나 텍스트 서식으로 강제하세요.