사용 규칙
자주 쓰는 패턴
레퍼런스만으로는 안 채워지는 실제 사용 흐름입니다. 그대로 가져다 쓰셔도 됩니다.
재사용할 클라이언트
토큰 캐싱, 429 대기, 커서 순회를 한 번만 만들어 두고 재사용합니다.
client.ts
const BASE = "https://developer.openapi.giig.app/api/v1";
let token: { value: string; expiresAt: number } | null = null;
async function accessToken(): Promise<string> {
// 만료 60초 전에 미리 갱신한다. 경계에서 401 이 튀는 것을 막는다.
if (token && Date.now() < token.expiresAt - 60_000) return token.value;
const res = await fetch("https://developer.openapi.giig.app/oauth/token", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "client_credentials",
client_id: process.env.GIG_CLIENT_ID!,
client_secret: process.env.GIG_CLIENT_SECRET!
})
});
if (!res.ok) throw new Error("token 발급 실패");
const body = await res.json();
token = { value: body.access_token, expiresAt: Date.now() + body.expires_in * 1000 };
return token.value;
}
async function get(path: string, params: Record<string, string> = {}) {
for (let attempt = 0; ; attempt++) {
const res = await fetch(`${BASE}${path}?${new URLSearchParams(params)}`, {
headers: { Authorization: `Bearer ${await accessToken()}` }
});
if (res.status === 429 || res.status >= 500) {
if (attempt >= 5) throw new Error(`포기: ${res.status}`);
const after = Number(res.headers.get("Retry-After"));
const backoff = Number.isFinite(after) && after > 0
? after * 1000
: Math.min(1000 * 2 ** attempt, 30_000) * (0.5 + Math.random() * 0.5);
await new Promise(r => setTimeout(r, backoff));
continue;
}
if (!res.ok) throw new Error((await res.json()).error.code);
return res.json();
}
}
/** 커서를 끝까지 따라가며 페이지를 흘려보낸다. */
async function* pages(path: string, params: Record<string, string>) {
let cursor: string | null = null;
do {
const body = await get(path, { ...params, limit: "500", ...(cursor ? { cursor } : {}) });
yield body.data;
cursor = body.next_cursor;
} while (cursor);
}매장별 월 인건비
근무 기록으로 계산합니다. 출퇴근 기록으로 하면 수기 등록된 근무가 빠집니다.
한 달치 집계
async function monthlyCost(storeId: string, from: string, to: string) {
let workMinutes = 0;
let cost = 0;
let nightMinutes = 0;
for await (const batch of pages("/work-records", { store_id: storeId, from, to })) {
for (const rec of batch) {
for (const item of rec.items ?? []) {
workMinutes += item.work_minutes;
// hourly_wage 는 시급 유형에서만 존재한다. 없으면 금액을 더하지 않는다.
if (item.hourly_wage != null) cost += item.hourly_wage * (item.work_minutes / 60);
if (item.night_work) nightMinutes += item.work_minutes;
}
}
}
return { workMinutes, cost, nightMinutes };
}i
통계 API 로 대체할 수 있습니다
같은 값을
/stats/labor 한 번으로 받을 수 있습니다. 직접 집계는 산식을 손봐야 할
때만 쓰세요.!
기간은 한 번에 31일까지입니다
1년 추이가 필요하면 월 단위로 12번 나누어 호출합니다. 매장 여러 곳이면 매장 × 월 만큼
호출되므로 레이트리밋을 계산에 넣으세요.
전체 동기화
증분 조회는 신규·수정만 잡습니다. 삭제까지 반영하려면 주기적인 전체 재조회가 필요합니다.
증분 + 삭제 감지
// 수시: 변경분만 (싸다)
async function syncIncremental(storeId: string, from: string, to: string, since: string) {
let watermark = since;
for await (const batch of pages("/work-records", {
store_id: storeId, from, to, updated_since: since
})) {
for (const rec of batch) {
await upsert(rec);
if (rec.updated_at > watermark) watermark = rec.updated_at;
}
}
return watermark; // 다음 호출의 updated_since
}
// 하루 1회: 기간 전체를 받아 로컬에 없는 것을 지운다
async function syncFull(storeId: string, from: string, to: string) {
const seen = new Set<string>();
for await (const batch of pages("/work-records", { store_id: storeId, from, to })) {
for (const rec of batch) {
await upsert(rec);
seen.add(rec.work_record_id);
}
}
for (const id of await localIdsInRange(storeId, from, to)) {
if (!seen.has(id)) await remove(id); // 서버에서 삭제된 기록
}
}정산이 끝난 기간(settled: true)은 잘 바뀌지 않습니다. 지난 달은 정산 후 한 번만
재조회하면 충분합니다.
새 매장 온보딩
연결 직후 어디까지 받아올지 정하는 흐름입니다.
초기 적재
async function backfill(storeId: string, months: number) {
const chunks: Array<[string, string]> = [];
const now = new Date();
for (let i = 0; i < months; i++) {
const d = new Date(now.getFullYear(), now.getMonth() - i, 1);
const first = new Date(d.getFullYear(), d.getMonth(), 1);
const last = new Date(d.getFullYear(), d.getMonth() + 1, 0);
chunks.push([iso(first), iso(last)]); // 달마다 최대 31일
}
for (const [from, to] of chunks) {
await syncFull(storeId, from, to);
}
}
const iso = (d: Date) =>
`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;i
직원 목록을 먼저 받아 두세요
staff_id 는 회원별 가명 값이라 다른 곳에서 조회할 수 없습니다. 근무 기록보다
먼저 /stores/{id}/staff 를 받아 이름과 매핑해 두면 이후 조인이 쉽습니다.시간대별 인원 파악
피크 시간대를 찾을 때는 통계 API 를 쓰는 편이 정확합니다.
피크 시간
const stats = await get("/stats/hourly-distribution", {
store_id: storeId, from, to
});
const peak = stats.buckets
.slice()
.sort((a, b) => b.avg_staff_on_duty - a.avg_staff_on_duty)[0];
console.log(`피크 ${peak.hour}시 · 평균 ${peak.avg_staff_on_duty}명`);직접 계산하면 근무 구간이 시간 경계를 걸칠 때 배분을 틀리기 쉽습니다. 통계 API 는 분 단위로 쪼개 배분합니다.
자주 하는 실수
- 출퇴근 기록으로 인건비를 계산한다 — 수기 등록 근무가 빠져 과소 집계됩니다.
- 진행 중인 근무의 null 을 0 으로 바꾼다 — 아직 확정되지 않은 것이지 0분이 아닙니다.
judgment_available: false를 무시한다 — 지각이 0건인 것이 아니라 판정 자체가 불가능한 매장입니다.- 매장 설정의 기본급으로 과거 인건비를 계산한다 — 현재값입니다. 과거는 근무 기록의 시급 스냅샷을 쓰세요.
- 시각을 UTC 날짜로 잘라 집계한다 — 새벽 근무가 전날로 밀립니다. 매장 로컬
날짜(
work_date)를 쓰세요. - 커서를 해석하거나 직접 만든다 — 불투명 값입니다.
- 429 에 즉시 재시도한다 — 계정이 정지될 수 있습니다.