본문으로 건너뛰기
G 기그 Open API v1

사용 규칙

자주 쓰는 패턴

레퍼런스만으로는 안 채워지는 실제 사용 흐름입니다. 그대로 가져다 쓰셔도 됩니다.

재사용할 클라이언트

토큰 캐싱, 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 에 즉시 재시도한다 — 계정이 정지될 수 있습니다.