brandonwie.dev
EN / KR
On this page
backend backendgoogle-calendarsyncerror-handling

Sync Token 무효화 복구 (410 GONE)

Google Calendar API가 410 GONE을 반환하면 sync token이 무효화되고 전체 재동기화가 필요해요. 올바른 처리 방법을 알아봐요.

Updated March 22, 2026 4 min read

캘린더 이벤트에 달아둔 메모와 공간 할당이 통째로 사라지는 production 버그를 들여다본 적이 있어요. 동기화 한 사이클이 돌고 나면 사용자 고유 데이터가 전부 없어지고, Google에서 받은 깨끗한 사본으로 덮여 있었어요. 트리거는 Google Calendar API의 410 GONE 응답이었어요. sync token이 무효화되면서 전체 resync가 돌았고, resync 코드가 모든 걸 지우고 처음부터 다시 만들었거든요. 그 과정에서 앱 고유 데이터가 다 같이 날아가버린 거예요.

Sync Token이 무효화되는 이유

Google Calendar API는 sync token으로 incremental sync를 해요. 직전에 받은 토큰을 넘기면, Google은 그 시점 이후의 변경분만 돌려줘요. 그런데 이 토큰이 무효화되면 변경분 대신 410 GONE이 날아와요. Google 문서엔 이렇게 적혀 있어요.

“Sync token은 토큰 만료관련 ACL 변경 등 다양한 이유로 서버에 의해 무효화돼요.”

핵심은 이거예요. 410 GONE은 단순히 시간이 지나서 나는 게 아니에요. 캘린더의 ACL 변경(공유 권한이 바뀌거나 빠지거나)으로도 무효화돼요. 사용자가 다른 캘린더에 공유 접근을 받거나 잃기만 해도, 전혀 무관한 integration이 전체 resync를 타게 될 수 있어요.

데이터 유실 버그

원래 resync 핸들러는 단순했어요. 다 지우고 Google에서 다시 만드는 방식이었거든요.

// ❌ 위험: 앱 고유 데이터가 사라짐
async handleResync(calendarId: string) {
  await this.blockRepo.delete({ calendarId });  // 날아감!
  const events = await this.googleApi.listEvents(calendarId);
  await this.createBlocksFromEvents(events);
}

이 방식은 Google에 없는 모든 앱 고유 데이터를 파괴해요. 커스텀 메모(note 필드), 링크 데이터(linkData), 공간 할당(spaceId), 사용자가 손댄 모든 커스터마이징이 전부 날아가는 거예요. 캘린더 이벤트 자체는 Google이 source of truth지만, 앱은 앱 고유 데이터를 따로 갖고 있어요. resync가 그걸 같이 버리면 안 돼요.

해결: accessRole로 전략 선택

해법은 캘린더의 access level에 따라 복구 전략을 다르게 가져가는 거예요. 편집 가능한 캘린더(owner/writer)는 사용자 커스터마이징을 가질 수 있으니 보존이 필요해요. 읽기 전용 캘린더(reader/freeBusyReader)는 커스터마이징이 들어갈 일이 없으니 clean-slate resync가 안전해요.

async handleResync(calendar: Calendar) {
  const accessRole = calendar.accessRole;

  if (isEditableCalendar(accessRole)) {
    // MERGE: 앱 고유 필드 보존
    await this.mergeResync(calendar);
  } else {
    // CLEAN-SLATE: 읽기 전용 캘린더에 안전
    await this.cleanSlateResync(calendar);
  }
}

function isEditableCalendar(accessRole: string | null): boolean {
  if (!accessRole) {
    Sentry.captureMessage('accessRole is null during 410 recovery');
    return false;  // 편집 불가로 취급(clean-slate)
  }
  return ['owner', 'writer'].includes(accessRole);
}

Merge 전략(편집 가능한 캘린더)

사용자가 이벤트를 만들고 수정하는 캘린더에서는, merge 전략이 앱 고유 필드를 보존하고 Google에서 온 필드만 갱신해요.

async mergeResync(calendar: Calendar) {
  const events = await this.googleApi.listEvents(calendar.gcalId);

  for (const event of events) {
    const existing = await this.blockRepo.findOne({
      where: { calendarId: calendar.id, gcalId: event.id }
    });

    if (existing) {
      // UPDATE: 앱 필드는 유지하고 Google 필드만 갱신
      await this.updateBlockFromEvent(existing, event);
    } else {
      // INSERT: Google에서 온 새 이벤트
      await this.createBlockFromEvent(event);
    }
  }
}

Clean-slate 전략(읽기 전용 캘린더)

읽기 전용 캘린더는 보호할 앱 고유 데이터가 없어요. 그냥 지우고 다시 만드는 게 안전하고 더 단순해요.

async cleanSlateResync(calendar: Calendar) {
  // 안전: 읽기 전용 캘린더에는 앱 고유 데이터가 없음
  await this.blockRepo.delete({ calendarId: calendar.id });
  const events = await this.googleApi.listEvents(calendar.gcalId);
  await this.createBlocksFromEvents(events);
}

ACL을 의식한 복구

410 GONE이 ACL 변경으로도 트리거되니까, 직전 sync 이후로 캘린더의 access role이 달라졌을 수 있어요. 복구 전략을 고르기 전에 메타데이터를 새로 가져와야 해요.

async handleFindEventsWithResync(calendar: Calendar) {
  const result = await this.findEvents(calendar);

  if (result.resyncRequired) {
    // 중요: 재시도 전에 메타데이터 갱신
    const freshCalendar = await this.googleApi.getCalendar(calendar.gcalId);

    if (freshCalendar) {
      // Google에서 모든 필드 업데이트
      await this.updateCalendar(calendar.id, freshCalendar);
    }

    // 갱신된 accessRole로 재시도
    return this.handleResync(calendar);
  }

  return result;
}

판단 기준표

accessRole전략이유
ownerMerge사용자가 커스터마이징 가능
writerMerge사용자가 커스터마이징 가능
readerClean-slate읽기 전용, 커스터마이징 없음
freeBusyReaderClean-slate바쁨/한가함 정보만 표시
nullClean-slate예상 외 상태, Sentry에 로깅

메타데이터 갱신을 빼먹으면 두 가지 사고가 가능해요. 지금은 읽기 전용이 된 캘린더에 merge 전략을 돌려서 헛수고를 하거나, 지금은 편집 가능해진 캘린더에 clean-slate를 돌려서 데이터를 잃거나요.

정리

Google Calendar의 sync API에서 410 GONE을 받았을 때, 무작정 다 지우고 다시 만들면 안 돼요. 복구를 두 갈래로 쪼개세요. 편집 가능한 캘린더는 merge로 앱 고유 데이터를 살리고, 읽기 전용은 clean-slate로 빠르게 끝내요. 그리고 전략을 고르기 전에 항상 캘린더 메타데이터를 새로 가져오세요. 410을 일으킨 원인 자체가 access role을 바꾼 ACL 변경일 수 있으니까요.

Comments

enko