구글시트로 창고 재고관리 시스템 만들기 | Apps Script 자동화
구글시트 창고 재고관리 시스템을 찾는 분이라면, 유료 WMS를 도입하기 전에 "지금 쓰는 시트로 어디까지 되는가"가 가장 궁금하실 겁니다. 결론부터 말씀드리면 시트 4개와 Apps Script 사이드바 하나로 입고 확인부터 적치 위치 기록까지 한 화면에서 처리할 수 있습니다.
이 글은 시리즈 1편입니다. 여기서는 오늘 도착한 컨테이너를 골라 적치 위치를 입력하고 저장하는 화면까지 완성합니다. 코드는 생략 없이 전부 싣고, 붙여넣을 위치와 동작 확인 방법까지 함께 적었습니다.
1. 완성하면 무엇이 되는가
시트 상단에 창고 메뉴가 생기고, 여기서 사이드바를 열면 오른쪽에 입력 화면이 뜹니다. 그 화면에는 오늘 도착 예정인 컨테이너만 목록으로 나옵니다. 컨테이너를 고르면 모델명과 수량이 자동으로 채워지고, 적치 위치만 입력한 뒤 저장을 누르면 두 가지가 동시에 처리됩니다. LOCATIONS 시트에 재고 한 줄이 쌓이고, INBOUND 시트의 해당 행 상태가 완료로 바뀝니다.
현장에서 중요한 부분은 입력 창구를 하나로 만드는 것입니다. 시트를 직접 열어 여기저기 타이핑하면 열을 잘못 건드리거나 다른 행을 덮어쓰는 사고가 반드시 생깁니다. 사이드바를 만들면 사람은 정해진 칸만 입력하게 됩니다.
2. 시트 4개로 기본 뼈대 잡기
먼저 새 구글 스프레드시트를 만들고 아래 이름으로 시트 네 개를 준비합니다. 시트 이름은 코드와 정확히 일치해야 하므로 대문자까지 그대로 만들어 주세요.
INBOUND — 입고 예정 목록. 1행은 머리글이며 순서대로 CONTAINER, MODEL, QTY, ETA_DATE, PUTAWAY, STATUS 입니다. 매일 도착 예정 컨테이너를 여기에 올려두면 사이드바가 오늘 날짜만 골라 보여줍니다.
LOCATIONS — 실제 재고 기록. 머리글은 LOCATION, CONTAINER, MODEL, QTY, TIMESTAMP 입니다. 적치가 확정될 때마다 한 줄씩 쌓이며, 이 시트가 로케이션별 재고의 기준이 됩니다.
FLOOR_MAP — 랙 배치도. 지금 편에서는 만들어만 두고 사용하지 않습니다. 3편에서 색으로 채워 시각화할 영역입니다.
SETTINGS — 설정값 모음. 시트 이름이나 상수를 나중에 여기로 빼서 관리할 자리입니다. 지금은 비워 둡니다.
3. Code.gs 전체 코드 — 메뉴와 데이터 처리
구글시트에서 확장 프로그램 → Apps Script를 열고, 기본으로 열려 있는 Code.gs의 내용을 모두 지운 뒤 아래를 통째로 붙여넣으세요. 맨 위 상수 세 줄만 본인 시트에 맞추면 됩니다.
const SHEET_INBOUND = 'INBOUND'; // → 입고 예정 시트 이름 (본인 시트에 맞게)
const SHEET_LOCATIONS = 'LOCATIONS'; // → 적치 기록 시트 이름 (본인 시트에 맞게)
const MENU_NAME = '창고'; // → 시트 상단에 표시될 메뉴 이름
function onOpen() { // → 시트를 열 때 자동으로 실행되는 함수
SpreadsheetApp.getUi()
.createMenu(MENU_NAME) // → '창고' 메뉴를 새로 만든다
.addItem('입고 UI 열기', 'showInboundSidebar') // → 메뉴에 누를 항목 추가
.addToUi(); // → 만든 메뉴를 시트에 붙인다
}
function showInboundSidebar() { // → 오른쪽 입력창(사이드바)을 여는 함수
const html = HtmlService.createHtmlOutputFromFile('Sidebar') // → Sidebar 파일을 화면으로
.setTitle('입고 / 적치 입력'); // → 사이드바 상단 제목
SpreadsheetApp.getUi().showSidebar(html); // → 화면 오른쪽에 띄운다
}
function getTodayInbound() { // → 오늘 도착 예정 건만 골라 사이드바로 보낸다
const ss = SpreadsheetApp.getActive();
const sh = ss.getSheetByName(SHEET_INBOUND);
if (!sh) { // → 시트 이름이 다르면 여기서 알려준다
throw new Error(SHEET_INBOUND + ' 시트를 찾을 수 없습니다.');
}
const values = sh.getDataRange().getValues(); // → 시트 전체를 한 번에 읽는다
const header = values[0];
const iCont = header.indexOf('CONTAINER'); // → 각 열이 몇 번째인지 찾아둔다
const iModel = header.indexOf('MODEL');
const iQty = header.indexOf('QTY');
const iEta = header.indexOf('ETA_DATE');
const iStat = header.indexOf('STATUS');
const tz = ss.getSpreadsheetTimeZone();
const today = Utilities.formatDate(new Date(), tz, 'yyyy-MM-dd'); // → 오늘 날짜 문자열
const rows = [];
for (let i = 1; i < values.length; i++) { // → 1행(머리글)은 건너뛴다
const r = values[i];
if (!r[iCont]) continue; // → 빈 줄 무시
if (String(r[iStat]).trim() === 'PUTAWAY_DONE') continue; // → 이미 끝난 건 제외
const eta = r[iEta] instanceof Date
? Utilities.formatDate(r[iEta], tz, 'yyyy-MM-dd')
: String(r[iEta]).slice(0, 10); // → 날짜가 글자로 들어와도 처리
if (eta !== today) continue; // → 오늘 도착분만 남긴다
rows.push({
container: String(r[iCont]),
model: String(r[iModel]),
qty: r[iQty]
});
}
return rows; // → 사이드바 드롭다운 데이터로 사용
}
function savePutaway(data) { // → 적치 위치를 저장하는 함수
const ss = SpreadsheetApp.getActive();
const inbound = ss.getSheetByName(SHEET_INBOUND);
const locations = ss.getSheetByName(SHEET_LOCATIONS);
if (!data.container || !data.location) { // → 빈 값이 저장되는 사고 방지
throw new Error('컨테이너와 적치 위치를 모두 입력하세요.');
}
locations.appendRow([ // → LOCATIONS에 재고 한 줄 추가
data.location,
data.container,
data.model,
data.qty,
new Date() // → 기록 시각
]);
const range = inbound.getDataRange();
const values = range.getValues();
const header = values[0];
const iCont = header.indexOf('CONTAINER');
const iPut = header.indexOf('PUTAWAY');
const iStat = header.indexOf('STATUS');
for (let i = 1; i < values.length; i++) { // → 같은 컨테이너 행을 찾아 갱신
if (String(values[i][iCont]) === String(data.container)) {
values[i][iPut] = data.location; // → 적치 위치 기록
values[i][iStat] = 'PUTAWAY_DONE'; // → 상태를 완료로 표시
break; // → 첫 행만 처리하고 멈춘다
}
}
range.setValues(values); // → 수정한 내용을 시트에 저장
return data.container + ' → ' + data.location + ' 저장 완료'; // → 화면에 띄울 메시지
}4. Sidebar.html 전체 코드 — 입력 화면
같은 Apps Script 편집기에서 파일 추가(+) → HTML을 누르고 이름을 Sidebar로 지정합니다(확장자 .html은 자동으로 붙습니다). 기본 내용을 모두 지우고 아래를 붙여넣으세요.
<!DOCTYPE html>
<html>
<head>
<base target="_top">
<style>
body { font-family: Arial, sans-serif; font-size: 13px; padding: 10px; }
label { display: block; margin-top: 10px; font-weight: bold; }
select, input { width: 100%; padding: 6px; box-sizing: border-box; }
button { width: 100%; margin-top: 14px; padding: 9px; font-weight: bold;
background: #1e3a5f; color: #fff; border: 0; border-radius: 5px; }
#msg { margin-top: 12px; font-size: 12px; }
</style>
</head>
<body>
<label>오늘 도착 컨테이너</label>
<select id="containerSelect" onchange="onContainerChange()"></select>
<label>모델</label>
<input id="model" readonly>
<label>수량</label>
<input id="qty" readonly>
<label>적치 위치 (예: B22-4)</label>
<input id="location" placeholder="랙-단 번호를 입력">
<button onclick="save()">저장</button>
<div id="msg"></div>
<script>
let inboundRows = []; // → 서버에서 받아온 오늘 입고 목록
function loadInbound() { // → 화면이 열릴 때 목록을 불러온다
google.script.run
.withSuccessHandler(function (rows) { // → 성공하면 드롭다운을 채운다
inboundRows = rows;
const sel = document.getElementById('containerSelect');
sel.innerHTML = '<option value="">선택하세요</option>';
rows.forEach(function (r) {
const opt = document.createElement('option');
opt.value = r.container;
opt.text = r.container;
sel.appendChild(opt);
});
if (rows.length === 0) { // → 오늘 도착분이 없을 때 안내
document.getElementById('msg').innerText = '오늘 도착 예정 건이 없습니다.';
}
})
.withFailureHandler(function (e) { // → 실패 이유를 화면에 그대로 보여준다
document.getElementById('msg').innerText = '불러오기 실패: ' + e.message;
})
.getTodayInbound();
}
function onContainerChange() { // → 컨테이너를 고르면 모델·수량 자동 입력
const cont = document.getElementById('containerSelect').value;
const row = inboundRows.find(function (r) { return r.container === cont; });
document.getElementById('model').value = row ? row.model : '';
document.getElementById('qty').value = row ? row.qty : '';
}
function save() { // → 저장 버튼을 눌렀을 때
const payload = {
container: document.getElementById('containerSelect').value,
model: document.getElementById('model').value,
qty: document.getElementById('qty').value,
location: document.getElementById('location').value.trim()
};
if (!payload.container || !payload.location) { // → 빈 칸 먼저 확인
document.getElementById('msg').innerText = '컨테이너와 적치 위치를 입력하세요.';
return;
}
document.getElementById('msg').innerText = '저장 중...';
google.script.run
.withSuccessHandler(function (result) { // → 저장이 끝나면 목록을 다시 불러온다
document.getElementById('msg').innerText = result;
document.getElementById('location').value = '';
loadInbound();
})
.withFailureHandler(function (e) {
document.getElementById('msg').innerText = '저장 실패: ' + e.message;
})
.savePutaway(payload);
}
loadInbound(); // → 사이드바가 열리자마자 실행
</script>
</body>
</html>5. 설치 방법과 동작 확인
붙여넣기가 끝났으면 편집기에서 저장(⌘S 또는 Ctrl+S) 을 누릅니다. 그다음 함수 목록에서 onOpen을 선택하고 실행을 한 번 누르세요. 처음 한 번은 권한 승인 화면이 뜹니다. 본인 계정을 선택하고, "이 앱은 확인되지 않았습니다" 화면이 나오면 고급 → (프로젝트 이름)(으)로 이동을 눌러 허용하면 됩니다. 본인이 만든 스크립트를 본인 시트에서 실행하는 것이라 정상적인 절차입니다.
이제 스프레드시트 탭으로 돌아가 새로고침하면 상단에 창고 메뉴가 보입니다. 메뉴 → 입고 UI 열기를 누르면 오른쪽에 입력 화면이 나타납니다.
동작을 확인하려면 INBOUND 시트에 시험 데이터를 한 줄 넣어 보세요. ETA_DATE를 오늘 날짜로 넣는 것이 핵심입니다. 사이드바를 다시 열었을 때 그 컨테이너가 드롭다운에 보이고, 적치 위치를 넣어 저장했을 때 LOCATIONS에 한 줄이 쌓이면서 INBOUND의 STATUS가 PUTAWAY_DONE으로 바뀌면 정상입니다.
6. 자주 나는 오류 두 가지
"INBOUND 시트를 찾을 수 없습니다" 가 뜨면 시트 이름이 코드의 상수와 다른 경우입니다. 시트 탭 이름에 공백이 섞여 있는 경우가 가장 많으니 확인해 보세요.
드롭다운이 비어 있다면 대부분 날짜 문제입니다. ETA_DATE가 오늘이 아니거나, 날짜가 텍스트로 저장돼 형식이 맞지 않는 경우입니다. 시트에서 해당 열을 선택해 서식 → 숫자 → 날짜로 지정한 뒤 다시 시도해 보시기 바랍니다.
7. 맺음말
여기까지가 창고 재고관리 시스템의 뼈대입니다. 사람이 시트를 직접 건드리지 않고 정해진 화면으로만 입력하게 만들었다는 점이 핵심이며, 이 구조만 갖춰도 입력 실수는 눈에 띄게 줄어듭니다.
오늘 만든 코드를 그대로 두고, 다음 편에서는 출고 처리를 붙입니다. 이후 타임시트 자동화, 자동 리포트, 랙 배치도 시각화 순서로 한 편씩 이어갈 예정입니다. 우선 오늘 것부터 본인 시트에 붙여넣고 시험 데이터 한 줄로 확인해 보시기 바랍니다.