본문으로 건너뛰기
글 목록으로 돌아가기
이 글 목차
~/posts/google

Google Calendar API: 반복 일정 업데이트

반복 일정에서 "이 일정만", "이후 모든 일정", "전체 일정" 업데이트를 처리하는 방법이에요.

캘린더 sync 기능을 이틀 동안 debugging하고 나서야 Google Calendar API가 반복 일정 업데이트를 완전히 다른 세 가지 방식으로 처리한다는 걸 알았어요. “이 일정”, “전체 일정”, “이후 모든 일정”이라고 뜨는 그 dropdown이 사실은 서로 다른 세 개의 API 흐름으로 이어지는데, 하나라도 잘못 다루면 시리즈 전체가 망가져요.

시작하기 전에 알았더라면 좋았을 내용을 정리했어요.

세 가지 업데이트 유형

사용자가 반복 일정을 수정하면 Google Calendar가 세 가지 선택지를 보여줘요. 각각이 서로 다른 API 방식으로 이어져요.

UI 옵션API 접근 방식
“이 일정만”특정 instance 업데이트
“전체 일정”recurring event 리소스 업데이트
“이후 모든 일정”시리즈 분할(API 호출 2번)

셋의 복잡도는 제각각이에요. “이 일정만”과 “전체 일정”은 각각 API 호출 한 번이면 돼요. “이후 모든 일정”은 최소 두 번의 호출과 세심한 예외 처리가 필요해요.

단일 항목 업데이트 (“이 일정만”)

가장 단순한 경우예요. 시리즈에서 특정 instance를 가져와 수정한 뒤 다시 저장하면 돼요. 그러면 그 instance가 반복 패턴의 예외(exception)가 돼요.

흐름은 이래요.

  1. events.instances()로 특정 instance를 조회해요
  2. 바꿔야 할 필드를 수정해요
  3. instance를 다시 저장해요 (이때 예외가 자동으로 생겨요)
// 각 instance는 이런 예외 이벤트가 돼요:
// - recurringEventId: 원본 시리즈를 가리킴
// - originalStartTime: 원래 차지했을 시간 슬롯

예외는 recurringEventId로 부모 시리즈를 다시 가리키고, originalStartTime으로 원래 어느 시간 슬롯에 있었는지 기록해요. Google은 이 연결을 보고 예외가 독립된 이벤트가 아니라 특정 발생을 대체한다는 걸 알아요.

한 가지 주의할 점이 있어요. 여러 instance를 하나씩 수정하진 마세요. 수정할 때마다 예외가 생기고, 예외가 수십 개 쌓인 시리즈는 API 응답을 지저분하게 만들고 속도도 느려져요. 몇 개 이상을 손대야 한다면 차라리 시리즈 전체를 업데이트하는 게 나아요.

전체 시리즈 업데이트 (“전체 일정”)

모든 발생을 한 번에 바꾸려면 recurring event 리소스 자체, 즉 RRULE을 담고 있는 부모 이벤트를 업데이트해요.

// instance ID가 아니라 recurring event의 ID로 업데이트해요
// 개별 수정되지 않은 모든 발생에 변경이 전파돼요

변경은 개별적으로 수정되지 않은 모든 발생에 전파돼요. 기존 예외는 특별 상태를 그대로 유지해요. 누군가 다음 주 화요일 회의 장소를 바꿔뒀는데 그 뒤에 시리즈 제목을 업데이트하면, 그 화요일 회의는 바뀐 제목을 받으면서도 커스텀 장소는 그대로 유지해요.

여기서 PUT과 PATCH 사이에 중요한 차이가 있어요. PUT을 쓰면 요청 본문에 모든 필드, 특히 recurrence rule을 포함해야 해요. PUT 요청에서 recurrence 필드를 빠뜨리면 API가 그 값을 초기화해서, 반복 일정이 단일 이벤트가 돼버려요. 되돌리기 어려운 실수인데, 첫 발생은 여전히 멀쩡해 보여서 테스트에서 잡아내기도 힘들어요.

“이후 모든 일정” 업데이트 (시리즈 분할)

여기서부터 진짜 까다로워져요. “이후 모든 일정”을 위한 단일 API 호출은 없어요. 시리즈를 두 부분으로 직접 나눠야 해요.

1단계: 원본 시리즈 자르기

대상 instance 이전에 끝나도록 원본 RRULE을 수정해요. UNTIL 날짜를 대상 발생 하루 전으로 설정한 뒤, 원본 recurring event를 업데이트하면 돼요.

2단계: 새 시리즈 생성

대상 발생에서 시작하는 완전히 새로운 recurring event를 만들어요. 이 새 이벤트에는 업데이트된 내용이 담기고, 원본과 같은 주기를 따라요(사용자가 반복 패턴까지 바꾸는 경우가 아니라면).

구체적인 예시

매주 월요일 회의가 있는데 사용자가 다음 주부터 장소를 바꾸고 싶어 한다고 해볼게요.

  1. 원본 시리즈 업데이트: UNTIL을 이번 주 월요일로 설정해요
  2. 새 recurring event 생성: 다음 주 월요일부터 새 장소로 시작하고, 주기는 똑같이 매주로 해요

결과적으로 하나였던 시리즈가 둘이 돼요. 원본은 이번 주까지를 담고, 새 시리즈는 다음 주부터를 담아요.

예외 문제

여기가 나를 고생시킨 부분이에요. 분할 지점 이후의 예외는 자동으로 보존되지 않아요. 누군가 3주 뒤의 특정 발생을 이미 수정해뒀다면, 그 수정은 원본 시리즈에 남아 있어요. 원본을 이번 주에 끝나도록 자르면, 그 예외는 참조가 끊긴 상태가 돼요.

분할 과정에서 예외를 보존하려면 이렇게 해요.

  1. 분할하기 전에 대상 날짜 이후에 오는 모든 예외를 조회해요
  2. 새 시리즈를 만든 뒤, 그 수정 사항을 새 시리즈의 해당 instance에 다시 적용해요
  3. 취소되거나 수정된 발생을 수동으로 옮겨요

번거롭지만 꼭 필요해요. 건너뛰면 사용자의 편집이 조용히 사라지는데, 이런 종류의 버그는 제품에 대한 신뢰를 갉아먹어요.

PATCH vs PUT: 신중하게 고르기

메서드동작사용 시점
PUT전체 리소스 교체종합적인 업데이트
PATCH지정된 필드만 업데이트작은 변경(더 안전)

PUT은 어떤 필드든 빠뜨리면 그 값이 초기화되기 때문에 위험해요. recurring event에 PUT을 쓸 때는 recurrence 필드를 항상 포함해야 해요. 안 그러면 실수로 시리즈를 통째로 날려요.

PATCH는 언급하지 않은 필드를 건드리지 않아서 작은 변경에는 더 안전해요. 다만 PATCH는 quota를 3 단위 쓰고, PUT은 1 단위예요. 호출량이 많은 통합이라면 이 차이가 꽤 커요.

_R 서브 시리즈 함정

분할 작업에는 함정이 하나 더 숨어 있어요. “이후 모든 일정” 분할은 가벼운 포인터를 만드는 게 아니에요. {parentId}_R{stamp} 형태의 자체 id와 자체 RRULE을 가진 진짜 시리즈 리소스를 실제로 만들어내요. 이 _R 서브 시리즈는 두 가지 면에서 부모와 다르게 동작하는데, 둘 다 놓치면 데이터를 잃어요.

첫째, PUT을 거부해요. _R id에 PUT을 보내면 “Invalid resource id” 에러로 실패해요. 서브 시리즈 업데이트는 PATCH로 가야 해요.

둘째, 이게 데이터를 조용히 망가뜨리는 쪽인데, 앱에서 서브 시리즈를 로컬에서 자를 때 업데이트 payload에 recurrence 필드를 반드시 넣어야 해요. 새 분할 지점 하루 전으로 UNTIL을 설정하는 두 번째 분할을 떠올려 보세요. “자식 이벤트에는 recurrence를 보내지 않는다”는 흔한 heuristic이 이 필드를 떨어뜨려요. 그러면 Google은 자르지 않은 범위를 그대로 유지하고, 다음 sync가 방금 지운 범위를 되살려요. 내 쪽에서는 삭제가 성공한 것처럼 보이는데, 다음 sync에서 다시 나타나요.

이 두 번째 실패는 더 큰 원칙을 가리켜요. sync는 provider의 상태를 그대로 비추는 거지, provider의 삭제를 만들어내는 게 아니에요. 계속 유지될 거라 기대한 데이터가 provider에서 사라진다면, 버그는 incoming sync가 아니라 밖으로 나가는 write-back 경로에 있어요. 받은 read가 아니라 보낸 write를 쫓아야 해요.

안전한 업데이트를 위한 모범 사례

Google 문서는 PATCH에 의존하는 대신 get-then-update 패턴을 권해요.

// PATCH 대신:
1. events.get() - 최신 데이터 가져오기
2. events.update() - 수정 사항 + ETag로 PUT
// 호출 2번이지만 최신 데이터를 보장

이 방식은 API 호출을 두 번 쓰지만, 항상 최신 데이터로 작업한다는 걸 보장해요. ETag 확인 덕분에 다른 사람이 동시에 바꾼 내용을 덮어쓰는 것도 막아요.

에러 처리

반복 일정을 다루다 보면 세 가지 에러 코드가 반복해서 나와요.

404 Not Found

이건 여러 가지를 뜻해요. eventIdcalendarId가 틀렸거나, 이벤트가 애초에 생성되지 않았거나, 영구 삭제됐거나, service account에 캘린더 접근 권한이 없는 경우예요.

해결은 ID를 확인하는 거예요. 무작정 재시도하지 마세요. 캘린더 이벤트의 404는 일시적인 경우가 거의 없어요.

410 Gone

이벤트가 삭제됐거나 sync token이 만료된 거예요. 영구적인 상태로 보고 로컬 상태를 정리한 뒤 넘어가면 돼요. 410을 재시도해봐야 절대 성공하지 않아요.

412 Precondition Failed

ETag 기반 낙관적 동시성 제어를 쓰는데 데이터가 오래됐을 때 생겨요. GET과 PUT 사이에 다른 사람이 이벤트를 수정한 거예요. 최신 이벤트를 다시 가져와서 변경 사항을 다시 적용하면 돼요.

핵심 정리

Google Calendar API로 반복 일정을 다루는 건 결국 일곱 가지 원칙으로 정리돼요.

  1. 단일 instance 업데이트는 예외를 만들어요. 시리즈가 지저분해지지 않게 꼭 필요할 때만 쓰세요.
  2. 시리즈 업데이트는 PUT으로 전체 리소스를 보내야 해요. 안전하게 가려면 PATCH를 쓰되 quota 비용을 신경 쓰세요.
  3. “이후 모든 일정”은 시리즈 분할이 필요해요. 구현하기 전에 예외 처리를 먼저 계획하세요.
  4. PUT을 쓸 때는 RRULE을 항상 보존하세요. 빠뜨리면 반복 패턴이 조용히 사라져요.
  5. 404와 410은 우아하게 처리하세요. 다른 사용자나 통합이 언제든 이벤트를 삭제할 수 있어요.
  6. _R 서브 시리즈는 진짜 시리즈예요. PUT이 아니라 PATCH로 다루고, 로컬 RRULE이 바뀌었으면 recurrence 필드를 항상 보내세요. 안 그러면 provider 상태가 내 상태와 어긋나요.
  7. sync는 provider 상태를 비출 뿐, provider의 삭제를 만들어내지 않아요. provider에서 데이터가 사라지면 incoming sync가 아니라 밖으로 나가는 write-back 경로를 쫓으세요.

Google Calendar API 문서가 이 개념들을 다루긴 하지만, 저 세 개짜리 dropdown 뒤에 얼마나 많은 복잡함이 숨어 있는지는 잘 강조하지 않아요. 분할 작업 하나가 나머지 둘을 합친 것보다 제대로 구현하는 데 더 오래 걸렸어요. 캘린더 통합을 만든다면 “전체 일정”과 “이 일정만”부터 시작해서, 그 둘을 제대로 동작시키고 test까지 끝낸 다음에 “이후 모든 일정”에 손대세요.

댓글

글 목록으로 돌아가기
enko