+
+> 메인폰 밖에서 촬영된 사진을 더 쉽게 찾고, 분류하고, 다시 볼 수 있도록 돕는 사진 정리 보조 서비스
+
+서브폰·카메라 등 여러 기기로 촬영한 사진이 한 라이브러리에 섞여 있으면 원하는 사진을 다시 찾기 어렵습니다. **zipzip**은 사용자의 사진 라이브러리를 분석해 **촬영 기기·날짜·장소 기준으로 사진을 자동 인덱싱**하고, 이를 바탕으로 필터링·앨범 정리·그룹 공유까지 이어지는 정리 경험을 제공하는 iOS 앱입니다.
+
+## ✨ 주요 기능
+
+| 기능 | 설명 |
+| --- | --- |
+| 인증 | Sign in with Apple 로그인, JWT Access/Refresh Token 발급·회전, 로그아웃 |
+| 사용자 | 내 프로필 조회·수정, 회원 탈퇴 |
+| 공유 그룹 | 그룹 생성·조회·수정·삭제, 초대 코드 미리보기·참여·탈퇴, 멤버 조회와 역할 기반 권한 관리 |
+| 공유집(앨범) | 그룹별 앨범 생성·조회·수정·삭제, 여러 앨범 일괄 삭제 |
+| 사진 | Object Storage 직접 업로드용 presigned URL 발급, 업로드 완료 등록, 조회·메타데이터 수정·삭제 |
+| 사진 분류 | 하나의 사진을 여러 앨범에 첨부하거나 분리하는 N:M 구조 |
+| 반응 | 사진 상세 조회, 좋아요 설정·취소, 댓글 조회·작성 |
+| 채팅 | 공유 그룹의 텍스트 메시지와 사진 댓글 활동을 하나의 커서 기반 타임라인으로 조회 |
+| 데이터 수명주기 | 주요 리소스 soft delete, 만료 데이터·스토리지 객체·실패한 썸네일의 주기적 정리 및 재처리 |
+
+### 설계 방향
+
+- **제어 평면과 데이터 평면 분리:** API 서버는 권한·메타데이터·임시 URL을 관리하고, 사진 원본은 iOS 클라이언트가 Object Storage에 직접 업로드합니다.
+- **DB를 진실의 원천으로 사용:** PostgreSQL의 상태를 기준으로 처리하며, 스토리지와의 결과적 일관성은 주기적 스윕으로 보완합니다.
+- **무거운 작업은 비동기로 처리:** 썸네일 생성은 제한된 전용 스레드 풀에서 실행하고, 실패하거나 유실된 작업은 DB 상태를 바탕으로 재시도합니다.
+- **REST와 폴링 중심의 단순한 구조:** 별도 WebSocket 계층 없이 커서 기반 증분 조회로 채팅과 활동 피드를 제공합니다.
+- **공개 API 계약 우선:** iOS 팀이 Swagger와 `docs/apidoc/` 문서를 API 명세로 사용할 수 있도록 요청·응답과 오류 코드를 함께 관리합니다.
+
+## 🛠 기술 스택
+
+### Application
+
+
+
+
+
+
+
+
+### Data & Storage
+
+
+
+
+
+
+
+
+
+### API & Quality
+
+
+
+
+
+
+
+
+
+### Infrastructure & Collaboration
+
+
+
+
+
+
+
+
+
+### 기술 선정 이유
+
+| 구분 | 기술 | 선정 이유 |
+| --- | --- | --- |
+| 언어 | Java 21 | 장기 지원 버전과 Spring 생태계를 활용하고 최신 JVM 기능을 사용할 수 있습니다. |
+| 프레임워크 | Spring Boot 3.5 | 웹·보안·검증·데이터 접근을 일관된 구성으로 제공하며 팀 내 생산성이 높습니다. |
+| 데이터베이스 | PostgreSQL | 관계 무결성, 트랜잭션, 고급 인덱스와 제약조건으로 공유 데이터의 정합성을 보장합니다. |
+| 스키마 관리 | Flyway | 애플리케이션과 함께 버전별 스키마 변경 이력을 재현할 수 있습니다. |
+| 파일 저장 | OCI Object Storage | 대용량 원본을 서버가 중계하지 않고 S3 호환 presigned URL로 직접 전송할 수 있습니다. |
+| 인증 | Sign in with Apple + JWT | iOS 사용자에게 자연스러운 로그인 경험을 제공하고 서버 세션을 stateless하게 유지합니다. |
+| API 문서 | springdoc-openapi | 코드와 Swagger 문서의 간극을 줄이고 iOS 팀이 실행 가능한 계약을 확인할 수 있습니다. |
+| 테스트 | JUnit 5 + Testcontainers | 순수 단위 테스트와 실제 PostgreSQL 기반 통합 테스트를 목적에 맞게 분리할 수 있습니다. |
+| 배포 | Docker + GitHub Actions | 동일한 이미지를 검증·배포하고 헬스체크 실패 시 이전 이미지로 되돌릴 수 있습니다. |
+
+## 🏗️ 시스템 아키텍처
+
+
+
+## 🗄️ DB 설계
+
+최신 ERD와 테이블 관계는 dbdocs에서 확인할 수 있습니다.
+
+[](https://dbdocs.io/embed/cf956a90274133517f45d2affd1add14/ecdf7220e60f4c4887f38ca90ab7ef21)
+
+## 📁 폴더 구조
+
+```text
+zipzip-server/
+├── src/
+│ ├── main/
+│ │ ├── java/org/zipzip/zipzipserver/
+│ │ │ ├── domain/
+│ │ │ │ ├── auth/ # Apple 로그인, JWT, Refresh Token
+│ │ │ │ ├── user/ # 사용자 프로필과 탈퇴
+│ │ │ │ ├── sharedgroup/ # 공유 그룹, 초대, 멤버
+│ │ │ │ ├── album/ # 공유집(앨범)과 사진 매핑
+│ │ │ │ ├── photo/ # 사진 업로드·조회·썸네일
+│ │ │ │ ├── reaction/ # 좋아요와 댓글
+│ │ │ │ ├── chat/ # 그룹 채팅·활동 타임라인
+│ │ │ │ └── storage/ # Object Storage 추상화와 S3 구현
+│ │ │ └── global/ # 보안, 공통 응답, 예외, 커서, 멱등성
+│ │ └── resources/
+│ │ ├── application.yaml
+│ │ ├── config/ # 비공개 설정 Git 서브모듈
+│ │ └── db/migration/ # Flyway 마이그레이션
+│ └── test/ # 단위·통합·계약·벤치마크 테스트
+├── docs/
+│ ├── apidoc/ # API 계약과 설계 결정
+│ ├── architecture/ # 백엔드 아키텍처
+│ └── data-modeling/ # 용어, 모델, 데이터 사전, DBML
+├── deploy/ # 운영·개발 Docker Compose와 Nginx 설정
+├── postman/ # API 요청 컬렉션
+├── docker-compose.yml # 로컬 PostgreSQL
+└── build.gradle
+```
+
+## 🚀 로컬 실행
+
+### 요구 사항
+
+- JDK 21
+- Docker 및 Docker Compose
+- 비공개 설정 저장소 `zipzip-server-config` 접근 권한
-
+### 1. 저장소와 설정 내려받기
-
+```bash
+git clone --recurse-submodules https://github.com/zipzip-team/zipzip-server.git
+cd zipzip-server
+```
-### `MEMBERS`
+이미 저장소를 복제했다면 서브모듈을 별도로 초기화합니다.
+
+```bash
+git submodule update --init --recursive
+```
+
+### 2. PostgreSQL 실행
+
+```bash
+docker compose \
+ --env-file src/main/resources/config/docker-compose.env \
+ up -d
+```
+
+`application-secret.yml`의 datasource 설정과 `docker-compose.env`의 DB 설정은 서로 일치해야 합니다. 민감한 설정 파일은 메인 저장소에 커밋하지 않습니다.
+
+### 3. 서버 실행
+
+```bash
+./gradlew bootRun
+```
+
+서버가 실행되면 다음 주소에서 상태와 API 문서를 확인할 수 있습니다.
+
+- Health Check: [http://localhost:8080/actuator/health](http://localhost:8080/actuator/health)
+- Swagger UI: [http://localhost:8080/swagger-ui/index.html](http://localhost:8080/swagger-ui/index.html)
+- OpenAPI JSON: [http://localhost:8080/v3/api-docs](http://localhost:8080/v3/api-docs)
+
+### 4. 검증
+
+```bash
+./gradlew check
+```
+
+```bash
+./gradlew spotlessApply
+./gradlew spotlessCheck
+```
+
+Gradle 명령은 JDK 21로 실행해야 합니다. 로컬 기본 JDK가 다른 경우 `JAVA_HOME`을 JDK 21 경로로 지정합니다.
+
+## 📚 문서
+
+| 문서 | 내용 |
+| --- | --- |
+| [API 명세 인덱스](docs/apidoc/00-api-index.md) | API별 계약 문서와 구현 상태 |
+| [공통 API 규격](docs/apidoc/01-common-spec.md) | 인증, 공통 응답, 오류, 페이지네이션 규칙 |
+| [백엔드 아키텍처](docs/architecture/backend-architecture.md) | 핵심 설계 결정과 런타임·배포 구조 |
+| [데이터 모델링](docs/data-modeling/04-data-modeling.md) | 엔티티 관계와 데이터 모델 |
+| [데이터 사전](docs/data-modeling/05-data-dictionary.md) | 테이블·컬럼·제약조건 정의 |
+| [코드 포매팅](docs/code-formatting.md) | Spotless와 Java 포맷 규칙 |
+| [배포 파이프라인](docs/deployment-pipeline.md) | CI/CD 구성과 배포 흐름 |
+
+## 🤝 Convention
+
+### Branch
+
+일반 작업 브랜치는 `/-` 형식을 사용합니다.
+
+```text
+feat/2-user-login
+fix/15-refresh-token-expiration
+chore/42-update-dependencies
+```
+
+`main`, `develop`에는 직접 push하지 않고 작업 브랜치에서 Pull Request를 생성합니다.
+
+### Commit Message
+
+커밋 메시지는 `:: : <한국어 요약>` 형식을 사용합니다.
+
+```text
+:sparkles: feat: Apple 로그인 기능 구현
+:bug: fix: Refresh Token 만료 처리 수정
+:memo: docs: API 명세 업데이트
+```
+
+커밋 전 저장소의 Git hook을 사용하도록 설정합니다.
+
+```bash
+git config core.hooksPath .githooks
+```
+
+## 👥 Members
+
+
diff --git a/docs/architecture/zipzip-system-architecture-diagram.png b/docs/architecture/zipzip-system-architecture-diagram.png
new file mode 100644
index 0000000..5151983
Binary files /dev/null and b/docs/architecture/zipzip-system-architecture-diagram.png differ
diff --git a/docs/data-modeling/04-data-modeling.md b/docs/data-modeling/04-data-modeling.md
index 20d4028..ed15e54 100644
--- a/docs/data-modeling/04-data-modeling.md
+++ b/docs/data-modeling/04-data-modeling.md
@@ -442,6 +442,7 @@ PostgreSQL 보완 인덱스:
- [x] 사진 원본·썸네일을 URL이 아닌 Object Storage 객체 키(`original_object_key`, `thumbnail_object_key`)로 저장하도록 반영했다.
- [x] `photo.original_object_key`에 unique 제약을 적용했다.
- [x] 비동기 썸네일 생성 진행 상태를 `photo.thumbnail_status`(`PENDING`/`READY`/`FAILED`)로 반영했다.
+- [x] `photo.thumbnail_status`가 `READY`이면 공백이 아닌 `thumbnail_object_key`를 갖도록 DB 제약을 적용했다.
- [x] `photo_upload_reservation`을 추가해 업로드 URL 발급 대상(사용자·공유집(앨범))과 재사용 여부를 완료 등록에서 검증할 수 있도록 했다.
- [x] 사진 댓글과 그룹 채팅 메시지를 soft delete에서 즉시 물리 삭제로 전환하고 `deleted_at` 컬럼을 제거했다.
- [x] 사진·공유집(앨범)의 개별 삭제 후 30일 정리를 위한 기준을 정의했다.
@@ -449,6 +450,7 @@ PostgreSQL 보완 인덱스:
- [x] 탈퇴한 생성자·업로더의 공유집(앨범)·사진 삭제 권한을 공유 그룹 방장에게 위임했다.
- [x] 사용자 탈퇴 시 사진 좋아요 물리 삭제 정책을 정의했다.
- [x] 활성 멤버십 기준에 사용자 soft delete 상태를 포함했다.
+- [x] API 멱등성 처리 기록(`api_idempotency_record`)의 상태·완료 응답·만료 정리 모델을 반영했다.
- [x] 공유 그룹 삭제 시 하위 공유집(앨범)·사진을 함께 soft delete하고, Object Storage 우선 정리 뒤 그룹·초대 코드 예약을 물리 삭제하는 30일 배치를 반영했다.
## 15. 남은 구현 과제
diff --git a/docs/data-modeling/05-data-dictionary.md b/docs/data-modeling/05-data-dictionary.md
index f780b13..dccff1f 100644
--- a/docs/data-modeling/05-data-dictionary.md
+++ b/docs/data-modeling/05-data-dictionary.md
@@ -10,6 +10,7 @@
| 테이블 | 한글명 | 설명 |
|---|---|---|
| `app_user` | 사용자 | Apple 로그인 후 서버에 등록된 사용자 |
+| `api_idempotency_record` | API 멱등성 처리 기록 | 멱등성 요청의 처리 상태와 완료 응답을 보관하는 기록 |
| `refresh_token` | Refresh Token | Refresh Token 해시와 회전 상태 |
| `invite_code_reservation` | 초대 코드 예약 원장 | 발급된 초대 코드의 점유 예약 테이블 |
| `shared_group` | 공유 그룹 | 초대 코드, 멤버십, 채팅, 공유집(앨범)을 묶는 최상위 공유 공간 |
@@ -221,6 +222,7 @@ PHOTO-03 완료 등록은 이 테이블에서 요청 사용자·요청 경로
- `original_object_key` unique
- `thumbnail_status`는 `PENDING`, `READY`, `FAILED`만 허용(`chk_photo__thumbnail_status`)
+- `thumbnail_status`가 `READY`이면 `thumbnail_object_key`는 공백이 아닌 값이어야 함(`chk_photo__thumbnail_ready_object_key`)
- 원본 수정과 삭제는 원칙적으로 업로더만 가능
- 업로더가 탈퇴한 경우 삭제는 공유 그룹 방장이 가능
- 항상 1개 이상의 `shared_album_photo` 매핑을 가져야 한다(서비스 계층에서 강제)
@@ -279,6 +281,32 @@ PHOTO-03 완료 등록은 이 테이블에서 요청 사용자·요청 경로
- `content` 공백 불가
- 댓글 작성과 조회는 활성 공유 그룹 멤버십 필요
+### 3.13 `api_idempotency_record`
+
+멱등성 헤더를 사용하는 API 요청의 처리 상태와 완료 응답을 보관한다.
+동일한 멱등성 키로 서로 다른 요청을 재사용하는 것을 방지하며, 만료된 기록은 정기 배치가 물리 삭제한다.
+
+| 컬럼 | 타입 | 필수 | 설명 |
+|---|---|---|---|
+| `id` | `uuid` | O | 멱등성 처리 기록 식별자 |
+| `scope` | `varchar(100)` | O | 멱등성 키 업무 범위 |
+| `idempotency_key` | `uuid` | O | 클라이언트가 전달한 멱등성 키 |
+| `http_method` | `varchar(10)` | O | 요청 HTTP 메서드 |
+| `api_path` | `varchar(255)` | O | 멱등성 적용 API 경로 |
+| `request_hash` | `varchar(64)` | O | 요청 본문 충돌 판별용 SHA-256 해시 |
+| `status` | `varchar(20)` | O | `PROCESSING` 또는 `COMPLETED` |
+| `response_http_status` | `integer` | X | 완료된 요청의 HTTP 응답 상태 코드 |
+| `encrypted_response_body` | `text` | X | 완료된 요청의 암호화된 응답 본문 |
+| `expires_at` | `timestamptz` | O | 기록 만료 시각 |
+| `created_at` | `timestamptz` | O | 생성 시각 |
+| `updated_at` | `timestamptz` | O | 상태 마지막 갱신 시각 |
+
+주요 제약:
+
+- `scope`, `idempotency_key`, `http_method`, `api_path` unique
+- `status`는 `PROCESSING`, `COMPLETED`만 허용
+- `PROCESSING`이면 응답 상태 코드와 암호화된 응답 본문은 모두 `null`, `COMPLETED`이면 둘 다 필수
+
## 4. 외래 키
| 제약명 | 관계 | 삭제 규칙 |
@@ -319,6 +347,8 @@ PHOTO-03 완료 등록은 이 테이블에서 요청 사용자·요청 경로
| `photo_upload_reservation` | `idx_photo_upload_reservation__shared_album_id` | 공유집(앨범)별 예약 조회 |
| `photo_upload_reservation` | `idx_photo_upload_reservation__requested_by_app_user_id` | 사용자별 예약 조회 |
| `photo_upload_reservation` | `idx_photo_upload_reservation__expires_at` | 만료된 미완료 예약 스윕 |
+| `api_idempotency_record` | `uk_api_idempotency_record__scope_key_method_path` | 동일 요청의 중복 처리 방지 |
+| `api_idempotency_record` | `idx_api_idempotency_record__expires_at` | 만료된 멱등성 처리 기록 정리 |
## 6. 권한 기준
diff --git a/docs/data-modeling/dbml/zipzip.dbml b/docs/data-modeling/dbml/zipzip.dbml
index e5bf98c..00aa382 100644
--- a/docs/data-modeling/dbml/zipzip.dbml
+++ b/docs/data-modeling/dbml/zipzip.dbml
@@ -23,6 +23,33 @@ Table app_user {
Note: 'Apple 로그인 후 서버에 등록된 사용자. 탈퇴 시 deleted_at을 기록하고 display_name을 탈퇴한 사용자로 갱신한다. 공유 콘텐츠의 작성자·생성자·업로더 표시는 탈퇴한 사용자로 대체한다. 재가입 복구를 위해 app_user 행은 물리 삭제하지 않는다. 동일한 Apple 계정으로 재가입하면 기존 행을 복구하되 과거 공유 그룹 멤버십은 자동 복구하지 않는다.'
}
+Table api_idempotency_record {
+ id uuid [pk, not null, note: '애플리케이션에서 생성하는 멱등성 처리 기록 식별자']
+ scope varchar(100) [not null, note: '멱등성 키의 업무 범위. 사용자·그룹 등 요청을 구분하는 기준']
+ idempotency_key uuid [not null, note: '클라이언트가 전달한 멱등성 키']
+ http_method varchar(10) [not null, note: '요청 HTTP 메서드']
+ api_path varchar(255) [not null, note: '멱등성 적용 API 경로']
+ request_hash varchar(64) [not null, note: '동일 멱등성 키의 요청 본문 충돌을 판별하는 SHA-256 해시']
+ status varchar(20) [not null, note: '처리 상태. PROCESSING 또는 COMPLETED']
+ response_http_status int [note: '완료된 요청의 HTTP 응답 상태 코드. 처리 중이면 null']
+ encrypted_response_body text [note: '완료된 요청의 암호화된 응답 본문. 처리 중이면 null']
+ expires_at timestamptz [not null, note: '멱등성 기록 만료 시각. 만료 후 정리 배치가 삭제한다']
+ created_at timestamptz [not null, default: `now()`, note: '처리 기록 생성 시각']
+ updated_at timestamptz [not null, default: `now()`, note: '처리 상태 마지막 갱신 시각']
+
+ indexes {
+ (scope, idempotency_key, http_method, api_path) [unique, name: 'uk_api_idempotency_record__scope_key_method_path']
+ expires_at [name: 'idx_api_idempotency_record__expires_at']
+ }
+
+ checks {
+ `status in ('PROCESSING', 'COMPLETED')` [name: 'chk_api_idempotency_record__status']
+ `(status = 'PROCESSING' and response_http_status is null and encrypted_response_body is null) or (status = 'COMPLETED' and response_http_status is not null and encrypted_response_body is not null)` [name: 'chk_api_idempotency_record__completed_response']
+ }
+
+ Note: '멱등성 헤더가 필요한 API의 처리 상태와 완료 응답을 보관한다. 동일한 scope, idempotency_key, HTTP 메서드, API 경로 조합은 하나만 존재할 수 있으며, 만료된 행은 정기적으로 물리 삭제한다.'
+}
+
Table refresh_token {
id uuid [pk, not null, note: '애플리케이션에서 생성하는 Refresh Token 행 식별자']
app_user_id uuid [not null, note: 'Refresh Token을 소유한 사용자 식별자']
@@ -182,6 +209,7 @@ Table photo {
checks {
`thumbnail_status in ('PENDING', 'READY', 'FAILED')` [name: 'chk_photo__thumbnail_status']
+ `thumbnail_status <> 'READY' or (thumbnail_object_key is not null and thumbnail_object_key !~ '^[[:space:]]*$')` [name: 'chk_photo__thumbnail_ready_object_key']
}
Note: '사진 원본 파일 참조 정보. 공유 그룹에 직접 속하지 않고 shared_album_photo를 통해서만 하나 이상의 공유집(앨범)에 속한다(공유 위계는 공유 그룹 > 공유집(앨범) > 사진이다). 원칙적으로 업로더가 수정·삭제하며, 업로더가 탈퇴한 경우 삭제는 공유 그룹 방장이 할 수 있다. 클라이언트는 presigned PUT URL로 원본을 Object Storage에 직접 업로드하고, 서버는 업로드 완료 등록 시점에 이 행을 생성한다. iOS가 이미지 EXIF에서 촬영일시와 위치정보를 추출해 전달하면 그대로 저장하며, 위치정보가 추론된 값이면 is_inferred를 true로 전달받는다. 썸네일은 등록 직후 서버가 비동기로 생성한다. 촬영일 우선 정렬용 coalesce 표현식 partial index는 postgresql-overrides.sql에 정의한다.'
diff --git a/zipzip-current-architecture.svg b/zipzip-current-architecture.svg
new file mode 100644
index 0000000..bd4230a
--- /dev/null
+++ b/zipzip-current-architecture.svg
@@ -0,0 +1,177 @@
+