From c7c88bd623e494a8dba87d43febb45ca06a0a685 Mon Sep 17 00:00:00 2001 From: JEONG Date: Tue, 25 Aug 2026 10:25:22 +0900 Subject: [PATCH 1/3] =?UTF-8?q?=F0=9F=93=84=20Docs:=20=EC=B6=9C=EC=84=9D?= =?UTF-8?q?=20=ED=91=B8=EC=8B=9C=C2=B7=EC=9B=8C=EC=B9=98=20=EC=A7=80?= =?UTF-8?q?=EC=9B=90=20=EC=84=9C=EB=B2=84=20=EC=9A=94=EC=B2=AD=20=EC=82=AC?= =?UTF-8?q?=ED=95=AD=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 출석 승인/반려 푸시 발송, 요청 시각 클라이언트 지정, 다기기 세션 정책, Live Activity 푸시 채널 등 서버 지원 없이 해결 불가한 항목을 클라이언트 코드 실측 근거와 함께 문서화. --- ...8-24-attendance-push-and-watch-api-gaps.md | 180 ++++++++++++++++++ 1 file changed, 180 insertions(+) create mode 100644 docs/server/2026-08-24-attendance-push-and-watch-api-gaps.md diff --git a/docs/server/2026-08-24-attendance-push-and-watch-api-gaps.md b/docs/server/2026-08-24-attendance-push-and-watch-api-gaps.md new file mode 100644 index 00000000..5f2cf39d --- /dev/null +++ b/docs/server/2026-08-24-attendance-push-and-watch-api-gaps.md @@ -0,0 +1,180 @@ +# 서버 요청 사항 — 출석 상태 푸시 · Apple Watch 지원 API 갭 + +> 작성일: 2026-08-24 · 대상: 서버팀 · 작성 근거: iOS 클라이언트 코드 실측 +> 관련 이슈: [#1242](https://github.com/UMC-PRODUCT/Big-Dipper-iOS/issues/1242) 외 워치 백로그 #1205~#1216 + +iOS 워치 앱 작업을 정리하면서 **서버 지원 없이는 클라이언트만으로 해결할 수 없는 항목**을 추려냈습니다. +각 항목은 현재 클라이언트 코드에서 확인한 사실과 함께 적었고, 요청이 아니라 **확인만 필요한 것**은 따로 표시했습니다. + +## 요약 + +| # | 항목 | 서버 작업 규모 | 우선순위 | +|---|------|--------------|---------| +| 1 | 출석 승인/반려 시 챌린저에게 푸시 발송 | 작음 (기존 FCM 재사용) | **높음** | +| 2 | 출석 요청 시각을 클라이언트가 지정 가능하게 | 작음 (필드 1개 추가) | 중간 | +| 3 | 다기기 동시 세션 허용 여부 | 확인만 | 중간 | +| 4 | Live Activity 푸시 채널 (APNs 직결) | 큼 (신규 채널) | 낮음 / 보류 | + +--- + +## 1. 출석 승인/반려 시 챌린저에게 푸시 발송 — **높음** + +### 현재 상황 + +UMC 출석은 챌린저가 GPS 출석 요청을 보내면 `PRESENT_PENDING` 상태가 되고, +운영진이 승인해야 `PRESENT` 로 확정되는 구조입니다 +(`POST /api/v2/schedules/{scheduleId}/attendances/request` → `POST .../attendances/decide`). + +**문제는 승인 결과가 챌린저 기기에 도달하는 경로가 없다는 점입니다.** + +현재 챌린저가 자기 출석이 승인됐는지 알 수 있는 유일한 방법은 **앱을 열어 출석 탭에 다시 들어가는 것**입니다. +그때 목록을 다시 조회하면서 상태가 갱신됩니다. 앱이 닫혀 있거나 다른 탭에 있으면 아무 통지도 받지 못합니다. + +승인까지 수십 분에서 며칠이 걸리는 것을 감안하면, 사용자는 자기가 출석 처리됐는지 결석 처리됐는지 +모르는 채로 방치됩니다. + +### 확인 요청 + +**Q1. 운영진이 출석을 승인/반려할 때, 해당 챌린저에게 FCM 푸시를 발송하고 있습니까?** + +클라이언트 코드만으로는 판별이 불가능합니다. 현재 iOS 푸시 수신부는 `title` / `body` 두 필드만 읽어 +알림 보관함에 저장하는 범용 처리라, 승인 푸시가 실제로 오고 있더라도 앱은 그냥 배너로 흘려보냅니다. + +### 요청 사항 + +**발송하고 있지 않다면** — 출석 승인/반려 처리 시 대상 챌린저에게 푸시 발송을 추가해 주세요. +회원별 FCM 토큰은 이미 서버가 보유하고 있습니다 (`PUT /api/v1/notification/fcm/token`). +**APNs 신규 채널이나 별도 인프라는 필요하지 않습니다.** + +**발송하고 있다면 (또는 새로 추가한다면)** — payload 에 아래 두 필드를 포함해 주세요. + +```jsonc +{ + "notification": { "title": "...", "body": "..." }, + "data": { + "type": "ATTENDANCE_STATUS_CHANGED", // 알림 종류 식별자 + "scheduleId": "1234", // 대상 일정 ID (문자열) + "status": "PRESENT" // 확정 상태 (선택, 있으면 즉시 반영 가능) + } +} +``` + +- `type` — 앱이 "이건 출석 상태 변경 알림"이라고 식별해야 출석 화면을 갱신할 수 있습니다. + 값 이름은 서버 컨벤션에 맞춰 정해 주시면 그대로 따르겠습니다. +- `scheduleId` — 알림을 탭했을 때 해당 일정 화면으로 이동시키고, 어떤 일정을 갱신할지 특정하는 데 씁니다. +- 정수 값은 **문자열로** 내려 주세요. 프로젝트 전 레이어에서 서버 정수를 `String` 으로 통일하고 있습니다. + +### 클라이언트 쪽 대응 + +앱은 이 payload 를 받아 출석 화면을 자동 갱신하고, 알림 탭 시 해당 일정으로 이동시키도록 작업합니다. +수신 후 갱신 경로는 이미 구현되어 있어, payload 식별자만 확보되면 연결 작업은 크지 않습니다. + +--- + +## 2. 출석 요청 시각을 클라이언트가 지정 가능하게 — **중간** + +### 현재 상황 + +`POST /api/v2/schedules/{scheduleId}/attendances/request` 의 요청 바디는 현재 세 필드뿐입니다. + +```json +{ "latitude": 37.123456, "longitude": 127.123456, "locationVerified": true } +``` + +**출석 시각 필드가 없습니다.** 따라서 서버가 요청을 수신한 시각을 출석 시각으로 기록하는 것으로 이해하고 있습니다. + +### 문제 + +Apple Watch 출석 기능에는 **오프라인 큐잉**이 포함됩니다. +지하 강의실처럼 네트워크가 끊긴 곳에서 GPS 위치는 확보됐지만 요청을 보낼 수 없는 경우, +위치와 시각을 기기에 저장해 뒀다가 네트워크가 복구되면 자동 전송하는 흐름입니다. + +그런데 서버가 수신 시각으로 스탬프를 찍으면, **19:05 에 강의실에서 출석한 사용자가 +19:45 에 건물을 나와 네트워크가 붙는 순간 전송되어 결석 처리**됩니다. 기능이 성립하지 않습니다. + +### 요청 사항 + +요청 바디에 클라이언트 측정 시각 필드를 추가해 주세요. + +```jsonc +{ + "latitude": 37.123456, + "longitude": 127.123456, + "locationVerified": true, + "measuredAt": "2026-08-24T19:05:32+09:00" // 신규: 기기에서 위치를 측정한 시각 (ISO 8601) +} +``` + +**서버 판정 정책도 함께 정해 주세요:** + +- `measuredAt` 을 출석 시각으로 그대로 인정할지, 아니면 수신 시각과의 차이가 일정 범위(예: 30분) 이내일 때만 인정할지 +- 기기 시계 조작으로 지각을 정시로 위조하는 것을 막을 방법이 필요한지 — 필요하다면 허용 오차 범위를 서버가 정하는 쪽이 안전합니다 +- 하위 호환: `measuredAt` 이 없으면 기존대로 수신 시각 사용 + +이 필드가 없으면 워치 오프라인 큐잉 기능은 구현하지 않고 보류하겠습니다. + +--- + +## 3. 다기기 동시 세션 허용 여부 — **확인만** + +### 배경 + +Apple Watch 앱이 iPhone 을 거치지 않고 직접 API 를 호출하려면 iPhone 과 인증 토큰을 공유해야 합니다. +클라이언트 쪽에서는 iOS/watchOS 간 공유 저장소로 토큰을 공유하는 방식으로 처리할 예정입니다 (클라이언트 작업). + +### 확인 요청 + +**Q2. 같은 계정이 두 기기에서 동시에 API 를 호출해도 문제가 없습니까?** + +특히 토큰 갱신(`POST /api/v1/auth/token/renew`)이 걱정입니다. +이 엔드포인트가 **리프레시 토큰을 회전(rotate)시켜 새 토큰 쌍을 내려주는 방식**이라면, +iPhone 과 Watch 가 같은 리프레시 토큰을 들고 있다가 한쪽이 먼저 갱신하는 순간 +**다른 쪽 토큰이 무효화되어 로그아웃**됩니다. + +- 리프레시 토큰 회전 방식입니까, 아니면 만료 전까지 재사용 가능합니까? +- 이전 리프레시 토큰에 유예 시간(grace period)이 있습니까? +- 계정당 활성 세션 수에 제한이 있습니까? + +회전 방식이고 유예가 없다면, 클라이언트에서 워치의 독립 API 호출을 포기하고 +**모든 통신을 iPhone 경유로 중계**하는 설계로 바꿔야 합니다. 설계 결정에 영향이 크므로 +이 답변을 먼저 받고 워치 통신 구조를 확정하겠습니다. + +--- + +## 4. Live Activity 푸시 채널 — **낮음 / 보류** + +### 배경 + +Live Activity 는 iPhone 잠금화면과 Apple Watch Smart Stack 에 "진행 중인 출석 세션" 카드를 띄우는 기능입니다. +출석 창 카운트다운(정시 마감까지 N분, 지각 마감까지 N분)을 앱을 열지 않고 볼 수 있습니다. + +**카운트다운 자체는 서버 작업이 전혀 필요 없습니다.** 일정 응답에 이미 들어 있는 +출석 시작/정시 종료/지각 종료 시각 세 개면 시스템이 알아서 초를 깎습니다. + +서버 작업이 필요한 건 **카드가 떠 있는 도중에 상태를 바꾸는 것**(승인되면 카드가 그 자리에서 +"출석 확정"으로 바뀌는 것)뿐입니다. + +### 요청하지 않는 이유 + +이건 **기존 FCM 으로는 불가능**합니다. Live Activity 업데이트는 ActivityKit 이 발급하는 별도 토큰을 대상으로 +APNs 에 직접 전송해야 하고(`apns-push-type: liveactivity`), 그 토큰은 액티비티마다 새로 발급되고 종료 시 무효화됩니다. +즉 **APNs 직결 발송 채널 + 액티비티 단위 토큰 등록/폐기 엔드포인트**를 새로 만들어야 합니다. + +반면 얻는 것은 "배너 알림이 뜬다" 대신 "잠금화면 카드가 제자리에서 바뀐다" 정도의 차이입니다. +사용자 체감의 대부분은 **1번(일반 푸시)** 으로 이미 확보됩니다. + +**따라서 지금은 요청하지 않습니다.** 1번이 적용된 뒤 실제 사용 반응을 보고 다시 판단하겠습니다. +Live Activity 는 우선 서버 작업이 0인 로컬 카운트다운 버전으로만 구현합니다. + +--- + +## 정리 — 답변이 필요한 것 + +| | 질문 | 영향 | +|---|------|------| +| Q1 | 출석 승인/반려 시 챌린저에게 푸시를 보내고 있습니까? | 없으면 발송 트리거 추가 필요 | +| Q2 | payload 에 `type` / `scheduleId` 를 넣어 주실 수 있습니까? | 앱의 출석 화면 자동 갱신 가능 여부 | +| Q3 | 출석 요청 바디에 `measuredAt` 을 추가해 주실 수 있습니까? | 워치 오프라인 큐잉 구현 여부 | +| Q4 | 리프레시 토큰이 회전 방식입니까? 다기기 동시 세션이 가능합니까? | 워치 통신 구조(독립 호출 vs iPhone 중계) 결정 | + +Q1·Q2 가 가장 급합니다. 나머지는 워치 구현 일정에 맞춰 답변 주셔도 됩니다. From 2d4a0def126ab0bb29ab08ee658e6a1ce9046ed7 Mon Sep 17 00:00:00 2001 From: JEONG Date: Tue, 25 Aug 2026 17:51:55 +0900 Subject: [PATCH 2/3] =?UTF-8?q?=F0=9F=93=84=20Docs:=20CLAUDE.md=EC=97=90?= =?UTF-8?q?=20Cygnus=20=EB=B0=B1=EC=97=94=EB=93=9C=20=EB=A0=88=ED=8F=AC=20?= =?UTF-8?q?=EB=A0=88=ED=8D=BC=EB=9F=B0=EC=8A=A4=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CLAUDE.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index 8766bc84..837c7c3e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -113,3 +113,13 @@ iOS 26 프레임워크 API — 신규 Apple API를 다룰 때: | 모음 | 인덱스 | 언제 읽나 | |------|--------|----------| | iOS 26 프레임워크 가이드(20종) | `docs/claude/ios26-frameworks/INDEX.md` | Liquid Glass, FoundationModels, SwiftData 상속, 신규 SwiftUI/Concurrency API 등 | + +백엔드(서버) — API 연동·서버 상태 확인이 필요할 때: + +| 대상 | 위치 | 언제 참고하나 | +|------|------|--------------| +| Cygnus 서버 레포 | https://github.com/UMC-PRODUCT/cygnus-server/tree/main | API 엔드포인트·요청/응답 스펙 확인, 서버 구현/배포 상태 점검, iOS DTO와 실제 응답이 어긋날 때 원인 추적 | + +- 조회 수단: `gh` CLI(`gh api repos/UMC-PRODUCT/cygnus-server/contents/...`, `gh search code --repo UMC-PRODUCT/cygnus-server ...`) 또는 `WebFetch`. +- **읽기 전용으로만 사용** — 서버 레포에 커밋·PR·이슈를 만들지 않는다(메인테이너가 명시적으로 지시한 경우 제외). +- 스펙 추측 금지: 필드명·타입·nullable 여부는 서버의 컨트롤러/DTO 실제 코드로 확인한 뒤 iOS Response DTO에 반영한다(절대 규칙 #2·#3과 함께 적용). From 883e4dbaaea7819846bfa91fece7ef102b7ce350 Mon Sep 17 00:00:00 2001 From: JEONG Date: Tue, 25 Aug 2026 17:58:03 +0900 Subject: [PATCH 3/3] =?UTF-8?q?=F0=9F=93=84=20Docs:=20PR=20=EC=A0=9C?= =?UTF-8?q?=EB=AA=A9=20=EC=BB=A8=EB=B2=A4=EC=85=98=20=EB=AA=85=EB=AC=B8?= =?UTF-8?q?=ED=99=94=20=E2=80=94=20{=EC=9D=B4=EB=AA=A8=EC=A7=80}=20[Type]?= =?UTF-8?q?=20{=EB=82=B4=EC=9A=A9}=20(#=EC=9D=B4=EC=8A=88=EB=B2=88?= =?UTF-8?q?=ED=98=B8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CLAUDE.md | 5 ++++- docs/claude/git-workflow.md | 28 +++++++++++++++++++++++++++- 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 837c7c3e..0eca9b70 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -57,7 +57,10 @@ View ←→ ViewModel(@Observable) → UseCase(Protocol) → Repository → Data 타입은 이슈 템플릿과 1:1로 맞춘다: `feat` · `bug` · `design` · `refac` · `docs` · `chore`. (예: `docs/1203`, `feat/1195`) 설명형 브랜치명(`docs/repo-rename-links` 등) 금지. - 대응 이슈가 없으면 **브랜치를 만들기 전에 이슈부터 생성**한다 (제목 접두사·라벨·Type·Priority/Effort까지 채워서 — 상세: `docs/claude/git-workflow.md`). - - PR 제목 끝에 `(#이슈번호)`, 본문에 `Closes #이슈번호`를 넣어 이슈와 연결한다. + - **PR 제목은 `{이모지} [Type] {작업 내용} (#이슈번호)`** — 분류는 반드시 `[대괄호]`, 끝에 이슈번호. + (예: `✨ [Feat] 명함 도메인 계층 — MyCard · 명함첩 · 교환 세션 UseCase (#1194)`) + **이슈 제목 형식(`📄 Docs: …` — 콜론)을 PR 제목에 쓰지 않는다.** `[Docs]:`처럼 대괄호 뒤 콜론도 금지. + 본문에는 `Closes #이슈번호`를 넣어 이슈와 연결한다. (이모지·Type 매핑표: `docs/claude/git-workflow.md`) - 이미 푸시한 브랜치명을 고쳐야 하면 GitHub 브랜치 rename API는 **열려 있던 PR을 닫아버리므로**, rename 후 새 PR을 만들고 닫힌 PR에 후속 PR 번호를 코멘트로 남긴다. - 배포 브랜치는 예외: `testFlight/{번호}` · `release/{번호}` (순차 번호, 이슈번호 아님). diff --git a/docs/claude/git-workflow.md b/docs/claude/git-workflow.md index 9677def5..e155e00f 100644 --- a/docs/claude/git-workflow.md +++ b/docs/claude/git-workflow.md @@ -10,7 +10,7 @@ Git Flow + **연속 브랜치 파생** 지원 - **브랜치명은 `{타입}/{이슈번호}`** — 타입은 이슈 템플릿과 1:1(`feat`/`bug`/`design`/`refac`/`docs`/`chore`), base·PR 대상은 `develop`. 예: `docs/1203`, `feat/1195`. 설명형 브랜치명 금지. - 대응 이슈가 없으면 **브랜치를 만들기 전에 이슈부터 생성**한다 (아래 "이슈 생성 규칙"). - - PR 제목 끝에 `(#이슈번호)`, 본문에 `Closes #이슈번호`. + - PR 제목은 `{이모지} [Type] {작업 내용} (#이슈번호)` (아래 "PR 제목 형식"), 본문에 `Closes #이슈번호`. - 푸시한 브랜치명을 고쳐야 하면 GitHub 브랜치 rename API가 **열려 있던 PR을 닫아버린다.** rename 후 새 PR을 만들고, 닫힌 PR에 후속 PR 번호를 코멘트로 남긴다. - **연속 브랜치**: feature에서 다음 feature 파생 가능 (티켓 단위 분리) @@ -43,6 +43,32 @@ Git Flow + **연속 브랜치 파생** 지원 **커밋 메시지에 `Co-Authored-By` 라인을 절대 추가하지 마세요.** "Generated with Claude Code" 등 AI가 작성했음을 드러내는 문구도 커밋 메시지에 넣지 않습니다. +## PR 제목 형식 + +`{이모지} [Type] {작업 내용} (#이슈번호)` — **분류는 반드시 `[대괄호]`**, 제목 끝에 이슈번호. + +- ✅ `✨ [Feat] 명함 도메인 계층 — MyCard · 명함첩 · 교환 세션 UseCase (#1194)` +- ✅ `📄 [Docs] 저장소 rename 이후 남은 옛 링크 정리 (#1203)` +- ❌ `📄 Docs: 저장소 rename 이후 남은 옛 링크 정리 (#1203)` — **이슈 제목 형식**(콜론)을 PR에 쓴 경우 +- ❌ `📄 [Docs]: …` — 대괄호 뒤 콜론 금지 / ❌ `[Feature]` — Feat로 통일 / ❌ 이슈번호 누락 + +> **이슈 제목과 PR 제목은 형식이 다르다.** +> 이슈 = `{이모지} {Type}: {내용}` (콜론, 이슈 템플릿의 title prefix 그대로) +> PR = `{이모지} [{Type}] {내용} (#이슈번호)` (대괄호 + 이슈번호) + +| PR `[Type]` | 이모지 | 브랜치 | 이슈 제목 접두사 | 라벨 | +|-------------|--------|--------|------------------|------| +| `[Feat]` | ✨ | `feat/{이슈번호}` | `✨ Feature: ` | `:sparkles: Feature` | +| `[Fix]` | 🐛 | `bug/{이슈번호}` | `🐛 Bug: ` | `:bug: Bug` | +| `[Refactor]` | ♻️ | `refac/{이슈번호}` | `♻️ Refactor: ` | `:hammer: Refactor` | +| `[Design]` | 💄 | `design/{이슈번호}` | `🎨 Design: ` | `:lipstick: UI` | +| `[Docs]` | 📄 | `docs/{이슈번호}` | `📄 Docs: ` | `:page_facing_up: Docs` | +| `[Chore]` | 🔧 | `chore/{이슈번호}` | `🍀 ETC: ` | `:wrench: chore` | +| `[Test]` | ✅ | 관련 이슈 타입을 따름 | (전용 템플릿 없음) | (라벨 없음) | + +- 배포 PR은 위 형식 대신 배포 브랜치 규칙을 따르고 `🛫 TestFlight` / `🚀 Release` 라벨을 붙인다. +- 여러 성격이 섞이면 변경 비중이 가장 큰 Type을 제목에 쓰고, 부수 라벨을 추가로 붙인다. + ## PR 규칙 - 최소 1인 Approve 필수