Skip to content

[Feat] 타이머 쓰기 멱등 처리와 편집 시각 기준 충돌 판정 #574

Description

@edv-Shin

어떤 기능인가요?

안드로이드 오프라인 타이머 동기화의 쓰기 계약을 맞춥니다. 두 기기가 같은 타이머를 각각 고치면 나중에 편집한 쪽이 남아야 하고, 전송 응답이 유실된 뒤의 재시도가 새 요청으로 처리되면 안 됩니다. 지금 타이머 API에는 비교할 시각도, 같은 요청임을 알아볼 식별자도 없습니다.

판정 기준을 서버 시각이 아니라 기기가 찍은 편집 시각으로 둡니다. 도착 순서로 판정하면 오프라인이 길었던 기기의 오래된 수정이 다른 기기의 최신 수정을 덮습니다. 설계 문서의 충돌 해결 절이 후보 3(나중에 편집한 쪽이 남습니다)을 채택했고, 비용으로 기기 시각 의존을 감수한다고 적혀 있습니다.

  • 조회 응답의 updatedAt(서버 시각)은 클라이언트 캐시 동기화용으로 추가합니다. 충돌 판정에는 쓰지 않습니다
  • 충돌 판정용 editedAt을 별도로 저장합니다. 사용자가 기기에서 저장한 시점입니다
  • 수정과 삭제 요청에 editId(UUID)와 editedAt을 받습니다. 클라이언트는 저장할 때마다 새 editId를 발급하고, 전송을 시작할 때 그 값을 고정해 재시도에도 같은 값을 보냅니다
  • (memberId, 대상 id, editId)로 성공 응답을 보관합니다. 같은 editId가 다시 오면 편집 시각을 비교하기 전에 원래 성공 응답을 그대로 반환합니다. 이 순서가 뒤바뀌면 성공한 요청의 재시도가 409로 오판됩니다
  • 처음 보는 editId에서만 편집 시각을 비교합니다. 저장된 값이 요청보다 나중이면 409와 현재 값을 반환하고, 클라이언트는 로컬을 그 값으로 갱신합니다
  • 삭제는 편집 시각과 무관하게 수행합니다. 이미 삭제된 대상에 같은 editId로 다시 요청해도 성공으로 처리합니다
  • 이미 삭제된 대상에 대한 수정 요청은 거부하고 "삭제됨"을 응답합니다. 편집 시각 비교보다 먼저 판정합니다. 삭제가 판정에서 이깁니다. 클라이언트는 이 응답을 받으면 로컬 행을 지웁니다. 운동 기록 계약에 이미 있는 규칙과 같습니다
  • 심플 타이머도 타이머 하나가 판정 단위입니다. 편집 시각을 회원의 목록 단위가 아니라 타이머마다 둡니다. 목록 단위로 판정하면 지는 쪽 편집이 통째로 사라집니다. 기기 A가 3번 타이머를 고치고 기기 B가 1번을 지웠을 때, 목록 단위면 늦게 편집한 쪽 목록만 남아 다른 쪽 변경이 전부 버려집니다. 이긴 목록에 남아 있으면 다른 기기에서 지운 타이머가 되살아납니다. 서버 API가 이미 개별(POST / PATCH {id} / DELETE {id})이라 항목 단위가 자연스럽습니다
  • 커스텀 타이머는 이름과 스텝 목록을 묶어 타이머 하나가 판정 단위입니다. 스텝을 항목 단위로 병합하면 양쪽 기기 어디에도 없던 타이머가 됩니다
  • 심플 타이머 개수 상한은 수용 후 보정입니다. 상한 초과 생성 요청을 거부하지 않고 받은 뒤, 편집 시각 최신순으로 상한만큼만 남기고 나머지를 삭제합니다. 상한 값 자체는 6을 유지합니다
  • 심플 타이머 개수 하한은 0입니다. 0개인 상태가 정상입니다
  • 같은 초 값이 여러 개 있는 것을 허용합니다. 값 유니크 제약을 두지 않습니다
  • 조회 응답에 항목별 editedAt을 노출하지 않습니다. 클라이언트는 조회 결과를 그대로 반영하고 최신순 판정을 하지 않습니다. 캐시 동기화용 updatedAt은 위에 적은 대로 추가합니다
  • 조회 응답에 삭제 기록(tombstone)을 두지 않습니다. 삭제 우선 응답 덕분에 워커가 수정을 보낼 때 삭제를 알게 되므로, 조회 계약을 건드리지 않아도 기기 간에 수렴합니다
  • 새 필드가 없는 구버전 앱 요청은 기존 동작을 유지합니다. 하위호환이 없으면 배포 순서가 꼬입니다

이 이슈는 타이머 쓰기 계약까지입니다. 운동 기록의 쓰기 멱등성과 편집 시각 판정, 이미지 업로드 멱등성은 별도 이슈에서 다룹니다. 타이머 생성 멱등 처리는 #575입니다.

개수 상한을 거부하지 않는 이유

오프라인 우선 모델에서 클라이언트는 서버에 몇 개가 있는지 모르는 상태로 생성을 판정합니다. 로컬이 비어 있으면 "서버에 0개"인지 "아직 못 받았다"인지 구분할 수 없고, 여러 기기가 각자 오프라인에서 만들면 합산은 아무도 모릅니다.

기기 A: 서버 6개 받음 → 오프라인에서 1개 삭제, 2개 생성
기기 B: 서버 6개 받음 → 오프라인에서 3개 생성
둘 다 온라인 → 6 - 1 + 2 + 3 = 10개

서버가 거부하면 사용자가 오프라인에서 만든 데이터가 올라가지 못합니다. 앱은 동기화 대기 배지와 조회 실패 토스트를 두지 않기로 해서 실패를 알릴 수단도 없습니다. 수용 후 보정이면 거부가 없어 이 문제가 사라집니다. 밀려나는 것은 가입 기본값이나 오래 안 쓴 프리셋이고, 방금 만든 것은 편집 시각이 최신이라 남습니다.

하한도 같은 이유입니다. 하한은 전역 제약이라 클라이언트가 지킬 수 없습니다. 두 기기가 각각 5개씩 지우면서 "로컬에 1개는 남긴다"를 지켜도 합집합이 6개 전부일 수 있습니다.

POST 응답을 못 받아 재시도하는 경우, 서버는 createRequestId 멱등으로 기존 id를 돌려주는데 그 항목이 이미 보정으로 지워졌을 수 있습니다. 클라이언트는 serverId를 기록하고 SYNCED로 만들지만 다음 조회에서 그 serverId가 없어 로컬에서 지웁니다. 수렴하므로 특별 처리는 필요 없습니다.

개수 제약 현행 구현 확인

#202는 "최대 6 / 최소 1", #247은 "추가 삭제시 0~6개"로 서로 달라 코드를 확인했습니다. 실제 구현은 0~6이고 #247이 맞습니다.

  • 상한: SimpleTimerCommandServiceImpl#checkSimpleTimerCountLimit이 생성 경로에서만 SIMPLE_TIMER_MAX_COUNT 정책과 비교하고, count >= maxSIMPLE_TIMER_MAX_COUNT_VIOLATION(409)을 던집니다
  • 하한: 없습니다. deleteSimpleTimer는 소유권과 존재만 확인하고 개수를 보지 않습니다. SIMPLE_TIMER_MIN_COUNT 정책 키도, 대응 ErrorCode도 없습니다. CUSTOM_TIMER_STEP_MIN_COUNT_VIOLATION은 커스텀 타이머 스텝용입니다
  • simple-timer 정책 그룹에는 SIMPLE_TIMER_INIT_COUNT(6), SIMPLE_TIMER_INIT_VALUES(30,40,50,60,75,90), SIMPLE_TIMER_MAX_COUNT(6) 세 개만 있습니다. #202가 제안한 JSON 단일 정책 형태가 아니라 CSV 문자열과 별도 개수 정책으로 들어가 있습니다

따라서 하한 0은 코드를 바꾸지 않고 계약에 명시만 하면 됩니다. 상한 6도 정책 값이라 코드 수정 없이 조정할 수 있습니다.

작업 상세 내용

  • 조회 응답에 updatedAt 추가 (심플 목록, 커스텀 목록, 커스텀 상세)
  • 편집 시각 editedAt 저장. 커스텀 타이머와 심플 타이머 모두 타이머 단위
  • 수정 요청에 editIdeditedAt 수신 (심플 PATCH, 커스텀 PATCH, 커스텀 PUT)
  • 삭제 요청에 editIdeditedAt 수신 (심플 DELETE, 커스텀 DELETE)
  • (memberId, 대상 id, editId) 기준 성공 응답 보관
  • 같은 editId 재요청은 편집 시각 비교 전에 원래 성공 응답 반환
  • 이미 삭제된 대상의 수정 요청은 편집 시각 비교 전에 "삭제됨" 응답
  • 처음 보는 editId에서만 편집 시각 비교. 저장된 값이 더 나중이면 409와 현재 값 반환
  • 삭제는 편집 시각과 무관하게 수행하고 재시도도 성공 처리
  • 심플 타이머 상한 초과 생성 요청을 수용하고, 편집 시각 최신순으로 상한(6)만큼 유지하며 나머지를 삭제
  • 심플 타이머 개수 하한 0을 계약에 명시 (현행 구현이 이미 하한 없음, 코드 변경 불필요)
  • 심플 타이머 초 값 유니크 제약을 두지 않음 (현행 유지 확인)
  • 조회 응답에 항목별 editedAt과 삭제 기록(tombstone)을 추가하지 않음
  • 변경 응답에 갱신된 updatedAt 반환
  • 새 필드가 없는 요청은 기존 동작 유지
  • editedAt 타입과 정밀도, 성공 응답 보관 기간은 PR에서 선택
  • 테스트 (같은 editId 재요청 시 원래 응답, 오래된 편집 시각의 409, 비충돌 통과, 삭제 재시도 성공, 삭제된 대상 수정 시 "삭제됨" 응답, 상한 초과 생성 후 최신 6개 유지, 심플 타이머 0개 상태, 필드 없음 통과)

참고할만한 자료(선택)

안드로이드 오프라인 기능 지원 설계 문서의 충돌 해결 절(후보 3 채택), 설계 규칙 D7(모든 쓰기 요청에 요청 식별자), 위험 요소 2(응답 유실 시 중복 생성)와 4(성공 후 응답 유실 시 재시도가 409로 오판)가 기준입니다.

기존 본문은 baseUpdatedAt 비교와 서버 시각 기준으로 적혀 있었습니다. 설계 문서가 판정 기준을 기기 편집 시각으로 바꾸고 중복 요청 판별을 충돌 판별보다 앞에 두면서 이 이슈의 계약도 함께 바뀌었습니다.

심플 타이머 판정 단위, 삭제 우선, 개수 상한 수용 후 보정, 하한 0은 안드로이드 세션에서 확정한 결정입니다.

배포 순서

안드로이드 TimerSyncWorker(projects200/android#588)가 이 변경의 prod 배포에 의존합니다.

  1. #574, #575를 같은 배포에 올립니다
  2. prod 배포를 완료합니다
  3. 그 뒤에 android #588을 머지합니다

prod 배포 전에는 #588을 머지하지 않습니다.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions