From 099ae33698ce3b0d0b9c55e0d181a9994f7a6a42 Mon Sep 17 00:00:00 2001 From: parkjuyeong0312 Date: Mon, 8 Jun 2026 00:39:39 +0900 Subject: [PATCH 01/12] =?UTF-8?q?docs:=20=EC=84=9C=EB=B9=84=EC=8A=A4=20?= =?UTF-8?q?=EC=A0=95=EC=B1=85=20=EB=AC=B8=EC=84=9C=20=EC=B4=88=EC=95=88=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/legal/policy-preparation.md | 154 ++++++++++++++++++++++ docs/policy/copyright-policy.md | 111 ++++++++++++++++ docs/policy/operation-policy.md | 118 +++++++++++++++++ docs/policy/operator-info.md | 16 +++ docs/policy/privacy-policy.md | 211 +++++++++++++++++++++++++++++++ docs/policy/terms-of-service.md | 177 ++++++++++++++++++++++++++ 6 files changed, 787 insertions(+) create mode 100644 docs/legal/policy-preparation.md create mode 100644 docs/policy/copyright-policy.md create mode 100644 docs/policy/operation-policy.md create mode 100644 docs/policy/operator-info.md create mode 100644 docs/policy/privacy-policy.md create mode 100644 docs/policy/terms-of-service.md diff --git a/docs/legal/policy-preparation.md b/docs/legal/policy-preparation.md new file mode 100644 index 00000000..b7eb8607 --- /dev/null +++ b/docs/legal/policy-preparation.md @@ -0,0 +1,154 @@ +# 약관·개인정보 처리 준비 체크리스트 + +> 배포 전에 작성·고지해야 하는 약관 및 개인정보 관련 정책을 정리한다. +> 현재 단계: `초안`. 실제 약관 본문은 별도 문서로 분리해 작성한다. + +## 문서 목적 + +- 배포 전 법적으로 갖춰야 할 약관/정책 항목을 식별한다. +- 우리 서비스(여행 협업 + AI 어시스턴트)에서 어떤 조항이 핵심인지 정리한다. +- 약관에 적은 절차를 실제로 이행하려면 코드/기능 상 무엇을 추가해야 하는지 추적한다. + +## 서비스 특성 요약 + +- **수집 정보**: Google OAuth 프로필(이메일, 이름, 프로필 이미지), 채팅 메시지, 여행 일정/북마크, 접속 로그, 쿠키 기반 인증 토큰 +- **외부 전송**: Google Places/Routes API, 자체 AI 서버 +- **저장소**: PostgreSQL, MongoDB(채팅), Redis(세션·캐시) +- **회원 기능**: 로그인/로그아웃은 구현됨. **회원 탈퇴, 신고, 운영자 제재 절차는 미구현** (`docs/ai/features.md` 기준) + +--- + +## 1. 법적으로 반드시 필요한 문서 + +| 문서 | 근거 법령 | 비고 | +|------|-----------|------| +| 개인정보 처리방침 | 개인정보보호법 §30 | 홈페이지 첫 화면에서 쉽게 접근 가능해야 함 | +| 이용약관 | 약관규제법, 전기통신사업법 | 가입 전 동의 절차 필요 | +| 운영정책 | 이용약관 하위 정책 | 채팅·게시물·AI·초대코드·이용제한·신고/이의제기 세부 기준 | +| 운영자 정보 표시 | 정보통신망법 §10 | 상호, 대표자, 주소, 사업자번호, 연락처, 개인정보 보호책임자 | +| 저작권 정책 | 저작권법 §102, §103 | 이용자 콘텐츠의 권리 침해 신고, 게시중단, 재게시 요청 절차 안내 | + +> 위치정보법은 "단말기 위치"를 수집하지 않고 사용자가 검색한 장소 좌표만 다루면 일반적으로 적용 범위 밖이다. 추후 "내 위치 기반 추천" 기능 도입 시 **위치기반서비스 이용약관**과 방통위 신고가 추가로 필요하다. + +--- + +## 2. 개인정보 처리방침에 들어가야 할 항목 + +> 개인정보보호법 시행령 §31 기준. 각 항목을 우리 서비스 맥락으로 채워야 한다. + +### 2-1. 수집 항목 / 수집 목적 + +- **필수 수집**: Google OAuth `sub`(고유 ID), 이메일, 이름, 프로필 이미지 URL +- **자동 수집**: IP, User-Agent, 접속 일시, 쿠키(`access_token`, `refresh_token`), Redis presence 정보 +- **사용자 입력**: 방 제목·여행지·날짜, 채팅 메시지, 북마크/일정 메모 + +### 2-2. 보유·이용 기간 + +- 회원 정보: 탈퇴 시까지 (탈퇴 후 즉시 파기 vs 일정 기간 보관 후 파기 — `미결`) +- 여행 방 단위 데이터(방 제목, 채팅, 일정, 북마크, 메모): 방 삭제 시 또는 회원 탈퇴 시 중 먼저 도래한 때까지 +- **법령상 의무 보관 (검토 필요)**: + - 통신비밀보호법: 접속 로그 3개월 + - 전자상거래법: 소비자 불만/분쟁 처리 기록 3년 — 결제 기능이 없으면 적용 범위가 좁음 + +### 2-3. 제3자 제공 · 국외 이전 · 처리위탁 + +> 외부 호출이 많아 이 항목이 가장 큰 리스크다. 누가 어떤 데이터를 받는지 명확히 적어야 한다. + +- **제3자 제공**: 사전 동의 또는 법령상 근거가 있는 예외를 제외하고 제공하지 않는 것으로 정리 +- **Google (미국)**: OAuth 인증, Places/Routes API, Google Analytics → 처리 위탁 및 국외 이전 고지 필요 +- **OpenAI (미국)**: AI 응답 생성, 대화 요약 생성 → 처리 위탁 및 국외 이전 고지 필요 +- **클라우드 인프라(AWS Lightsail 등)**: 처리 위탁 및 국외 이전 고지 필요 +- 채팅 메시지에 포함된 **다른 사용자의 메시지/장소 스냅샷**이 AI 서버로 함께 전송되는 흐름을 명확히 적어야 한다. + +### 2-4. 정보주체 권리 + +- 열람, 정정, 삭제, 처리정지, 동의 철회권 안내 +- 권리 행사 방법 (이메일 또는 서비스 내 메뉴 등) + +### 2-5. 자동 수집 장치 (쿠키 등) + +- HTTP-only 쿠키(`access_token`, `refresh_token`) 사용 사실 +- 쿠키 거부 방법과 거부 시 영향 (로그인 불가) + +### 2-6. 개인정보 보호책임자 + +- 이름, 직책, 연락처 (이메일 가능) + +### 2-7. 만 14세 미만 처리 + +- 만 14세 미만 가입을 불허할지, 법정대리인 동의를 받을지 정책 결정 — `미결` +- 통상 스타트업은 "만 14세 미만 가입 불가" 정책으로 단순화한다. + +### 2-8. 변경 절차 + +- 처리방침 변경 시 사전 고지 기간과 통지 방법 + +--- + +## 3. 이용약관 핵심 조항 + +| 조항 | 우리 서비스에서 다뤄야 할 내용 | +|------|--------------------------------| +| 서비스 내용 | "여행 계획 협업 + AI 어시스턴트 보조" 명시 | +| 회원 가입/자격 | Google 계정 보유자, 만 14세 이상 | +| 계정·탈퇴 절차 | **현재 미구현** — 탈퇴 API/UI 마련 필요 | +| 이용제한·정지 | 사유, 기간, 사전·사후 통지 절차, 이의제기 절차 | +| 금지 행위 | 도배, 음란/혐오/명예훼손, 타인 사칭, 크롤링, 부정 이용 | +| 운영정책 | 여행 방, 채팅, AI 어시스턴트, 초대코드, 신고 및 이용 제한 세부 기준 | +| 게시물 정책 | 채팅·북마크·일정 메모의 권리 귀속(사용자 보유), 서비스 운영 목적 사용 동의 범위 | +| 저작권 신고/게시중단 | 권리자 신고, 작성자 통지, 재게시 요청, 반복 침해자 제한 | +| AI 응답 면책 | AI 추천은 참고용이며 정확성·안전성 보장하지 않음 | +| 외부 데이터 면책 | Google 장소 정보(영업시간·평점 등)는 Google 제공 자료이며 실제와 다를 수 있음 | +| 서비스 변경/중단 | 사전 공지 기간, 무료 서비스 한정 면책 | +| 손해배상/면책 | 무료 서비스 특성 반영 | +| 분쟁 해결 | 준거법(대한민국), 관할 법원, 사전 협의 절차 | + +--- + +## 4. 코드/기능 측에서 추가로 준비할 작업 + +> 약관 본문만 작성해도 실제 절차를 이행할 수 없으면 의미가 없다. 다음 항목이 `docs/ai/features.md`에 없거나 부족하다. + +| # | 항목 | 비고 | +|---|------|------| +| 1 | 회원 탈퇴 기능 | 인증 섹션에 없음. 탈퇴 시 채팅·일정·북마크 처리 정책 결정 필요 (제거 vs 익명화) | +| 2 | 계정 정지/차단 운영자 도구 | 방장 추방은 있으나 서비스 차원의 제재 수단 없음 | +| 3 | 신고 기능 | 다른 사용자/메시지 신고 API | +| 4 | 저작권 게시중단 처리 절차 | 이메일 접수로 시작 가능하나 운영자 검토/작성자 통지/재게시 요청 기록 필요 | +| 5 | 약관 동의 이력 저장 | 가입 시 어떤 버전에 동의했는지, 변경 시 재동의 처리 | +| 6 | 개인정보 파기 절차 | 탈퇴 후 보관 기간, 자동 파기 배치 | +| 7 | 로그 보관 기간 정책 | 접속 로그는 통신비밀보호법 시행령 기준 3개월로 정리. 채팅 로그는 방 삭제 또는 회원 탈퇴 기준과 구현 일치 필요 | +| 8 | 데이터 열람/내보내기 (선택) | 정보주체의 열람권 대응. 이메일 송부로도 대체 가능 | + +--- + +## 5. 진행 순서 (제안) + +1. **정책 결정** (코드 작업보다 먼저) + - 탈퇴 시 채팅 메시지 처리 방식 (제거 vs 익명화) + - 만 14세 미만 정책 (가입 차단으로 단순화 권장) + - 신고 처리 SLA + - 저작권 게시중단 및 재게시 요청 처리 방식 + - 채팅 보관 기간과 방 삭제/탈퇴 시 처리 방식 +2. **운영자 정보 확정** + - 사업자등록 여부, 대표자, 주소, 연락처 + - 개인정보 보호책임자 지정 +3. **회원 탈퇴/신고 기능 구현** + - 약관에 적을 절차가 실제로 동작하도록 코드 추가 +4. **약관·처리방침·운영정책·저작권 정책 초안 작성** + - 한국인터넷진흥원(KISA) 표준 처리방침 양식 활용 가능 +5. **가입 동의 UI / 약관 버전 관리 구현** +6. **법률 검토** + - 가능하면 변호사 검토. 특히 AI 데이터 처리, 국외 이전 부분이 중요하다. + +--- + +## 미결 사항 + +| # | 항목 | 비고 | +|---|------|------| +| 1 | 탈퇴 시 채팅 메시지 처리 | 제거 vs 익명화 vs 보존(분쟁 대응용) | +| 2 | 만 14세 미만 정책 | 가입 차단 vs 법정대리인 동의 | +| 3 | 채팅 메시지 AI 전송 고지 방식 | 처리 위탁 및 국외 이전 고지로 정리. 실제 가입/AI 호출 UI 고지 방식 결정 필요 | +| 4 | 회원 정보 탈퇴 후 보관 기간 | 즉시 파기 vs N일 유예 | +| 5 | 사업자등록 여부 | 등록 시점, 상호, 주소 | diff --git a/docs/policy/copyright-policy.md b/docs/policy/copyright-policy.md new file mode 100644 index 00000000..606c810e --- /dev/null +++ b/docs/policy/copyright-policy.md @@ -0,0 +1,111 @@ +# 저작권 정책 + +**시행일: 2026년 6월 6일** + +--- + +## 1. 목적 + +이 정책은 우때(이하 "서비스")에서 이용자가 작성·등록·공유한 콘텐츠가 타인의 저작권 등 권리를 침해한다는 신고가 접수된 경우의 처리 기준과 절차를 안내합니다. + +서비스는 이용자의 콘텐츠 작성과 공유를 존중하지만, 타인의 저작권, 상표권, 초상권, 개인정보 등 정당한 권리를 침해하는 콘텐츠의 게시를 허용하지 않습니다. + +--- + +## 2. 적용 대상 + +이 정책은 서비스 내에서 이용자가 작성·등록·공유하는 다음 콘텐츠에 적용됩니다. + +1. 여행 방 채팅 메시지 +2. 여행 일정, 메모, 북마크 장소 설명 +3. 프로필 이름, 프로필 이미지 등 이용자가 직접 등록한 정보 +4. 그 밖에 서비스 내에서 이용자가 게시하거나 공유한 문자, 이미지, 링크 등 콘텐츠 + +Google 등 외부 서비스가 제공하는 장소 정보, 사진, 지도, 경로 정보에는 해당 외부 서비스의 약관과 정책이 적용될 수 있습니다. + +--- + +## 3. 저작권 침해 신고 + +권리자 또는 정당한 대리인은 서비스 내 콘텐츠가 본인의 저작권 등 권리를 침해한다고 판단하는 경우 team.uttae@gmail.com으로 게시중단을 요청할 수 있습니다. + +신속한 처리를 위해 신고에는 다음 정보를 포함해 주세요. + +1. 신고자의 이름 또는 단체명 +2. 연락 가능한 이메일 주소 +3. 권리자 본인 또는 정당한 대리인임을 확인할 수 있는 정보 +4. 침해되었다고 주장하는 저작물 또는 권리의 설명 +5. 서비스 내 침해 의심 콘텐츠를 확인할 수 있는 정보 + - 여행 방 이름, 작성자, 작성 시각, 화면 캡처, URL 등 가능한 식별 정보 +6. 해당 콘텐츠가 권리자의 허락 없이 사용되었다고 판단하는 이유 +7. 신고 내용이 사실과 다르지 않다는 확인 + +대리인이 신고하는 경우 위임장 등 대리권을 확인할 수 있는 자료를 함께 제출해야 합니다. + +--- + +## 4. 게시중단 및 작성자 통지 + +운영자는 신고 내용을 검토한 뒤 권리 침해가 합리적으로 의심되거나 관련 법령상 조치가 필요하다고 판단되는 경우 해당 콘텐츠를 숨김, 삭제 또는 접근 제한할 수 있습니다. + +운영자가 콘텐츠를 게시중단한 경우, 가능한 범위에서 해당 콘텐츠를 작성한 이용자에게 다음 내용을 알립니다. + +1. 게시중단된 콘텐츠 +2. 게시중단 사유 +3. 신고자 또는 권리주장자의 주장 요지 +4. 재게시 요청 방법 + +다만, 작성자의 연락처를 확인할 수 없거나 긴급한 피해 방지, 법령 준수, 보안상 필요가 있는 경우에는 사후 통지하거나 통지를 생략할 수 있습니다. + +--- + +## 5. 재게시 요청 + +게시중단된 콘텐츠의 작성자는 해당 콘텐츠가 정당한 권리에 따라 게시되었거나, 신고 내용이 오인 또는 착오에 따른 것이라고 판단하는 경우 team.uttae@gmail.com으로 재게시를 요청할 수 있습니다. + +재게시 요청에는 다음 정보를 포함해 주세요. + +1. 요청자의 이름 +2. 연락 가능한 이메일 주소 +3. 게시중단된 콘텐츠를 식별할 수 있는 정보 +4. 해당 콘텐츠를 게시할 정당한 권리가 있음을 소명하는 자료 +5. 재게시 요청 내용이 사실과 다르지 않다는 확인 + +운영자는 재게시 요청이 접수되면 관련 법령과 제출 자료를 검토하여 게시 재개 여부를 판단합니다. 필요한 경우 신고자와 작성자에게 추가 자료 제출을 요청할 수 있습니다. + +--- + +## 6. 반복 침해자 조치 + +운영자는 동일 이용자가 반복적으로 타인의 권리를 침해하는 콘텐츠를 게시하거나, 허위 신고 또는 부정한 재게시 요청을 반복한다고 판단하는 경우 서비스 이용을 제한하거나 계정을 삭제할 수 있습니다. + +조치의 범위는 위반 행위의 내용, 반복 여부, 피해 규모, 고의성, 소명 여부를 고려하여 결정합니다. + +--- + +## 7. 허위 신고 및 책임 + +신고자와 재게시 요청자는 본인이 제출한 정보와 자료가 정확한지 확인해야 합니다. + +허위 신고, 허위 소명, 권리 없는 게시중단 요청 또는 재게시 요청으로 인해 다른 이용자, 운영자 또는 제3자에게 손해가 발생한 경우, 해당 요청자는 관련 법령에 따라 책임을 부담할 수 있습니다. + +--- + +## 8. 문의처 + +저작권 침해 신고, 게시중단, 재게시 요청 및 이 정책에 관한 문의는 아래 연락처로 보내주시기 바랍니다. + +| 항목 | 내용 | +|------|------| +| 서비스명 | 우때 | +| 이메일 | team.uttae@gmail.com | + +--- + +## 9. 정책의 변경 + +운영자는 관련 법령, 서비스 운영 방식, 신고 처리 절차의 변경에 따라 이 정책을 변경할 수 있습니다. 정책이 변경되는 경우 서비스 내 공지 또는 정책 문서 갱신을 통해 안내합니다. + +| 버전 | 시행일 | +|------|--------| +| 1.0 | 2026년 6월 6일 | diff --git a/docs/policy/operation-policy.md b/docs/policy/operation-policy.md new file mode 100644 index 00000000..a2483575 --- /dev/null +++ b/docs/policy/operation-policy.md @@ -0,0 +1,118 @@ +# 운영정책 + +**시행일: 2026년 6월 6일** + +--- + +## 제1조 (목적) + +이 운영정책은 [이용약관](terms-of-service.md)을 기반으로, 우때(이하 "서비스")의 여행 방, 채팅, 게시물, AI 어시스턴트, 신고 및 이용 제한에 관한 세부 기준과 절차를 정합니다. + +이 정책에서 정하지 않은 사항은 이용약관, [개인정보 처리방침](privacy-policy.md), [저작권 정책](copyright-policy.md) 및 관련 법령을 따릅니다. + +--- + +## 제2조 (여행 방 이용) + +1. 이용자는 여행 계획 협업을 위해 여행 방을 생성하고 다른 이용자를 초대할 수 있습니다. +2. 여행 방의 초대 코드는 초대받을 이용자에게만 공유해야 하며, 무단 수집·배포·판매·게시해서는 안 됩니다. +3. 방장은 여행 방의 기본 정보, 멤버 관리, 방 삭제 등 서비스가 제공하는 범위 내에서 관리 권한을 가집니다. +4. 방장은 권한을 남용하여 다른 이용자의 정상적인 서비스 이용을 부당하게 방해해서는 안 됩니다. +5. 여행 방 제목, 여행지, 일정, 메모 등은 다른 이용자의 권리나 관련 법령을 침해하지 않는 범위에서 작성해야 합니다. + +--- + +## 제3조 (채팅 및 게시물 이용) + +1. 이용자는 여행 방 채팅, 일정 메모, 북마크, 장소 공유 등 서비스 내 콘텐츠를 여행 계획 협업 목적에 맞게 사용해야 합니다. +2. 다음 콘텐츠는 게시하거나 공유할 수 없습니다. + - 욕설, 비방, 괴롭힘, 협박, 명예훼손성 내용 + - 음란물, 성적 수치심을 유발하는 내용, 폭력적 내용 + - 혐오 표현, 차별 조장, 사회적 편견을 강화하는 내용 + - 타인의 개인정보, 계정 정보, 초대 코드, 비공개 대화 내용을 무단 공개하는 내용 + - 저작권, 상표권, 초상권 등 제3자의 권리를 침해하는 내용 + - 불법 사행성 서비스, 불법 제품, 판매 금지 물품, 범죄 행위를 홍보하거나 조장하는 내용 + - 악성 코드, 피싱, 스팸, 광고성 링크, 서비스 외부 거래 유도 내용 + - 고의로 허위 장소 정보, 허위 일정 정보, 오해를 유발하는 정보를 반복 게시하는 행위 +3. 운영자는 위반 콘텐츠가 확인되는 경우 숨김, 삭제, 접근 제한, 작성자 이용 제한 등 필요한 조치를 할 수 있습니다. +4. 저작권 등 권리 침해 콘텐츠의 신고와 게시중단·재게시 요청 절차는 [저작권 정책](copyright-policy.md)에 따릅니다. + +--- + +## 제4조 (AI 어시스턴트 이용) + +1. AI 어시스턴트는 여행 계획 수립을 보조하기 위한 참고 기능입니다. +2. 이용자는 AI 어시스턴트에게 주민등록번호, 여권번호, 금융정보, 건강정보, 비밀번호, 인증번호 등 민감하거나 비밀성이 높은 정보를 입력해서는 안 됩니다. +3. AI 어시스턴트의 응답은 부정확하거나 최신 정보와 다를 수 있으며, 예약, 결제, 이동, 안전, 법률, 의료 등 중요한 의사결정은 공식 정보를 직접 확인해야 합니다. +4. AI 어시스턴트를 이용해 불법 행위, 권리 침해, 차별·혐오 표현, 서비스 공격 또는 자동화 남용을 요청하거나 시도해서는 안 됩니다. +5. AI 기능은 안정적인 서비스 운영을 위해 방별·이용자별 요청 수, 대기열, 처리 중 요청 수가 제한될 수 있습니다. + +--- + +## 제5조 (서비스 이용 제한 사유) + +운영자는 이용자가 다음 행위를 하는 경우 서비스 이용을 제한할 수 있습니다. + +1. 이용약관, 운영정책, 저작권 정책 또는 관련 법령을 위반하는 행위 +2. 타인의 계정, Google 계정, 초대 코드, 인증 수단을 도용하거나 무단 사용하는 행위 +3. 다량의 계정 생성, 반복 가입·탈퇴, 반복 초대·입장 등 정상적인 이용으로 보기 어려운 행위 +4. 채팅 도배, 반복 요청, 자동화 도구, 봇, 매크로, 스크래퍼 등을 이용해 서비스 운영을 방해하는 행위 +5. 서비스의 API, 서버, 네트워크, 보안 기능을 우회·공격·탐지·역분석하려는 행위 +6. 다른 이용자의 개인정보나 콘텐츠를 무단 수집·저장·공개·배포하는 행위 +7. 타인을 사칭하거나 운영자, 서비스 관계자, 다른 이용자로 오인하게 하는 행위 +8. 악성 코드, 피싱 링크, 스팸, 광고성 메시지 등을 전송하는 행위 +9. 저작권 등 제3자의 권리를 반복적으로 침해하는 행위 +10. 신고, 문의, 이의제기 절차를 악용하거나 허위 자료를 반복 제출하는 행위 +11. 기타 서비스의 정상적인 운영 또는 다른 이용자의 이용을 현저히 방해하는 행위 + +--- + +## 제6조 (이용 제한의 종류) + +이용 제한은 위반 행위의 내용, 반복 여부, 피해 규모, 고의성, 긴급성, 소명 여부를 고려하여 다음 범위에서 적용됩니다. + +1. 콘텐츠 제한: 게시물 숨김, 삭제, 검색·노출 제한 +2. 기능 제한: 채팅, AI 요청, 방 생성, 초대, 장소·경로 검색 등 일부 기능의 일시 제한 +3. 접근 제한: 특정 여행 방 접근 제한 또는 멤버 제외 +4. 계정 제한: 경고, 일시 정지, 영구 정지, 계정 삭제 +5. 기술적 제한: 비정상 요청 차단, Rate Limit 적용, 보안상 필요한 접속 제한 + +위반이 중대하거나 긴급한 피해 방지가 필요한 경우 운영자는 사전 통지 없이 즉시 제한 조치를 할 수 있습니다. + +--- + +## 제7조 (신고 및 이의제기) + +1. 이용자는 다른 이용자의 약관·정책 위반, 권리 침해, 유해 콘텐츠를 발견한 경우 team.uttae@gmail.com으로 신고할 수 있습니다. +2. 신고 시 콘텐츠를 식별할 수 있는 정보(여행 방, 작성자, 작성 시각, 화면 캡처, 문제 사유 등)를 함께 제공하면 처리에 도움이 됩니다. +3. 운영자는 신고 내용을 검토한 뒤 필요한 조치를 취할 수 있으며, 처리 결과를 신고자에게 안내하지 않거나 제한적으로 안내할 수 있습니다. +4. 이용 제한 조치를 받은 이용자는 team.uttae@gmail.com으로 이의를 제기할 수 있습니다. +5. 운영자는 이의제기 접수 후 7일 이내에 처리 결과를 안내하는 것을 원칙으로 합니다. 추가 확인이 필요한 경우 처리 기간이 연장될 수 있습니다. + +--- + +## 제8조 (문의 및 피드백) + +1. 이용자는 서비스 오류, 이용 문의, 개선 제안, 불만 사항을 team.uttae@gmail.com으로 전달할 수 있습니다. +2. 운영자는 접수된 문의와 피드백을 서비스 운영, 문제 해결, 기능 개선 목적으로 활용할 수 있습니다. +3. 문의나 피드백에는 주민등록번호, 여권번호, 금융정보, 비밀번호, 인증번호 등 민감한 정보를 포함하지 않아야 합니다. + +--- + +## 제9조 (정책의 변경) + +운영자는 서비스 운영 방식, 기능, 법령 또는 보안상 필요에 따라 이 정책을 변경할 수 있습니다. + +정책을 변경하는 경우 서비스 공지 또는 정책 문서 갱신을 통해 안내합니다. 이용자의 권리나 의무에 중대한 영향을 미치는 변경은 시행 30일 전에 고지합니다. + +--- + +## 제10조 (준용) + +이 정책에서 정하지 않은 사항은 [이용약관](terms-of-service.md), [개인정보 처리방침](privacy-policy.md), [저작권 정책](copyright-policy.md) 및 관련 법령을 따릅니다. + +--- + +## 부칙 + +이 정책은 2026년 6월 6일부터 시행합니다. diff --git a/docs/policy/operator-info.md b/docs/policy/operator-info.md new file mode 100644 index 00000000..e910cfca --- /dev/null +++ b/docs/policy/operator-info.md @@ -0,0 +1,16 @@ +# 운영자 정보 + +정보통신망 이용촉진 및 정보보호 등에 관한 법률 제10조에 따라 다음과 같이 운영자 정보를 공개합니다. + +--- + +| 항목 | 내용 | +|------|------| +| 서비스명 | 우때 | +| 운영자 | 박주영 (개인 운영) | +| 이메일 | team.uttae@gmail.com | +| 호스팅 사업자 | Amazon Web Services (AWS) | + +--- + +> 사업자 미등록 개인 운영으로, 사업자등록번호 및 사업장 주소는 별도 공개하지 않습니다. 운영자에 대한 문의는 위 이메일로 연락주시기 바랍니다. diff --git a/docs/policy/privacy-policy.md b/docs/policy/privacy-policy.md new file mode 100644 index 00000000..8d8c6ba3 --- /dev/null +++ b/docs/policy/privacy-policy.md @@ -0,0 +1,211 @@ +# 개인정보 처리방침 + +우때(이하 "서비스")는 이용자의 개인정보를 소중히 여기며, 「개인정보 보호법」 등 관련 법령을 준수합니다. 이 처리방침은 서비스가 어떤 개인정보를 어떤 목적으로 처리하는지, 이용자가 어떤 권리를 행사할 수 있는지 안내합니다. + +**시행일: 2026년 6월 7일** + +--- + +## 1. 개인정보 수집 항목 및 이용 목적 + +서비스는 이용 목적에 필요한 최소한의 개인정보를 수집·이용합니다. + +### 회원 가입 및 계정 관리 + +서비스는 Google 소셜 로그인을 통해 가입과 로그인을 제공합니다. + +| 법적 근거 | 수집 항목 | 이용 목적 | 보유 기간 | +|-----------|-----------|-----------|-----------| +| 개인정보 보호법 제15조 제1항 제4호(계약 이행) | 이메일 주소, 이름, 프로필 이미지 URL, Google 계정 고유 식별자 | 계정 식별, 중복 가입 방지, 로그인, 서비스 내 프로필 표시, 공지 전달 | 회원 탈퇴 시까지 | + +### 서비스 이용 과정에서 이용자가 입력하는 정보 + +| 법적 근거 | 수집 항목 | 이용 목적 | 보유 기간 | +|-----------|-----------|-----------|-----------| +| 개인정보 보호법 제15조 제1항 제4호(계약 이행) | 여행 방 제목, 여행지, 여행 날짜, 초대·참여 정보 | 여행 방 생성·관리, 멤버 협업 기능 제공 | 방 삭제 시 또는 회원 탈퇴 시 중 먼저 도래한 때까지 | +| 개인정보 보호법 제15조 제1항 제4호(계약 이행) | 채팅 메시지, AI 요청 메시지, 장소 공유 메시지 | 실시간 채팅, 메시지 조회, AI 여행 계획 보조 | 방 삭제 시 또는 회원 탈퇴 시 중 먼저 도래한 때까지 | +| 개인정보 보호법 제15조 제1항 제4호(계약 이행) | 여행 일정, 일정 메모, 북마크 장소, 장소 메모 | 여행 계획 작성·수정·공유 | 방 삭제 시 또는 회원 탈퇴 시 중 먼저 도래한 때까지 | +| 개인정보 보호법 제15조 제1항 제4호(계약 이행) | 문의·신고 내용, 이메일 주소, 처리에 필요한 추가 정보 | 문의 응대, 신고 처리, 분쟁 대응 | 처리 완료 후 3년 또는 관련 법령상 보관 기간 | + +### 서비스 이용 과정에서 자동으로 수집되는 정보 + +| 법적 근거 | 수집 항목 | 이용 목적 | 보유 기간 | +|-----------|-----------|-----------|-----------| +| 개인정보 보호법 제15조 제1항 제4호(계약 이행) | IP 주소, 접속 일시, User-Agent, 요청 경로, 기기 및 브라우저 정보 | 보안, 부정 이용 탐지, 장애 대응, 접속 기록 관리 | 접속 로그 3개월 | +| 개인정보 보호법 제15조 제1항 제4호(계약 이행) | `access_token`, `refresh_token` 쿠키 | 로그인 인증, 자동 로그인 유지, WebSocket 인증 | Access Token: 운영 설정에 따른 만료 시간(현재 운영 기준 30분) / Refresh Token: 14일 | +| 개인정보 보호법 제15조 제1항 제4호(계약 이행) | 서비스 이용 기록, 요청 횟수, Rate Limit 처리 기록 | 서비스 안정성 확보, 도배·남용 방지 | 목적 달성 시 또는 관련 캐시 만료 시까지 | +| 개인정보 보호법 제15조 제1항 제1호(동의) 또는 제15조 제1항 제4호(계약 이행) | Google Analytics 쿠키, 페이지뷰, 이벤트, 기기·브라우저 정보 | 서비스 이용 통계 분석, 서비스 개선 | Google Analytics 보관 설정 및 쿠키 보존 기간에 따름 | + +### 장소·경로 검색 시 처리되는 정보 + +서비스는 Google Places/Routes API를 이용해 장소 검색, 장소 상세 정보, 사진, 이동 경로 정보를 제공합니다. + +| 처리 항목 | 이용 목적 | 보관 여부 | +|-----------|-----------|-----------| +| 검색어, Google 장소 ID, 좌표, 이동수단, 출발·도착 장소 ID | 장소 검색, 장소 상세 조회, 이동 경로 조회 | 서비스 DB에 위치 이력으로 저장하지 않음 | + +--- + +## 2. 이용자의 동의 없는 이용 및 제공 + +서비스는 원칙적으로 이용자의 동의 없이 개인정보를 목적 외로 이용하거나 제3자에게 제공하지 않습니다. 다만 「개인정보 보호법」 등 관련 법령에 따라 다음의 경우에는 동의 없이 이용하거나 제공할 수 있습니다. + +1. 법률에 특별한 규정이 있거나 법령상 의무를 준수하기 위해 불가피한 경우 +2. 이용자와 체결한 서비스 이용계약을 이행하거나 계약 체결 과정에서 이용자의 요청에 따른 조치를 이행하기 위해 필요한 경우 +3. 이용자 또는 제3자의 생명, 신체, 재산의 급박한 이익을 위해 필요하다고 인정되는 경우 +4. 서비스의 정당한 이익을 달성하기 위해 필요한 경우로서 이용자의 권리를 부당하게 침해하지 않는 경우 +5. 수집 목적과 합리적으로 관련된 범위에서 이용자의 이익을 부당하게 침해하지 않고 추가 이용 또는 제공이 가능한 경우 +6. 수사기관, 법원, 감독기관 등이 적법한 절차에 따라 요청하는 경우 + +--- + +## 3. 개인정보의 보유기간 및 파기 + +서비스는 개인정보의 처리 목적이 달성되거나 보유 기간이 경과하면 지체 없이 해당 개인정보를 파기합니다. 다만 법령에 따라 보관이 필요한 정보는 해당 기간 동안 별도로 분리하여 보관한 뒤 파기합니다. + +| 항목 | 보유 기간 | +|------|-----------| +| 회원 정보(이메일, 이름, 프로필 이미지 URL, Google 계정 고유 식별자) | 회원 탈퇴 시까지 | +| 여행 방 제목, 여행지, 여행 날짜, 초대·참여 정보 | 방 삭제 시 또는 회원 탈퇴 시 중 먼저 도래한 때까지 | +| 채팅 메시지, AI 요청 메시지, 장소 공유 메시지 | 방 삭제 시 또는 회원 탈퇴 시 중 먼저 도래한 때까지 | +| 일정, 메모, 북마크, 장소 카드 등 여행 방 데이터 | 방 삭제 시 또는 회원 탈퇴 시 중 먼저 도래한 때까지 | +| 문의·신고 및 분쟁 처리 기록 | 처리 완료 후 3년 | +| 접속 로그 | 3개월(통신비밀보호법 시행령상 인터넷 로그기록 보관 기준) | +| Access Token 쿠키 | 운영 설정에 따른 만료 시간(현재 운영 기준 30분) | +| Refresh Token 쿠키 및 Redis 세션 정보 | 14일 | +| Rate Limit 및 일시 캐시 정보 | 각 캐시·제한 정책의 만료 시까지 | + +파기 방법은 다음과 같습니다. + +1. 전자적 파일: 복구 또는 재생이 어렵도록 삭제 +2. 종이 문서: 분쇄 또는 소각 + +## 4. 만 14세 미만 아동의 개인정보 + +서비스는 만 14세 미만 아동의 가입을 허용하지 않습니다. 만 14세 미만 이용자의 가입 또는 개인정보 처리가 확인되는 경우 해당 계정과 개인정보를 지체 없이 삭제합니다. + +서비스는 현재 법정대리인 동의 절차를 운영하지 않습니다. + +--- + +## 5. 개인정보의 제3자 제공 + +서비스는 이용자의 개인정보를 원칙적으로 제3자에게 제공하지 않습니다. 다만 이용자가 사전에 동의한 경우 또는 관련 법령에 근거가 있는 경우에는 예외적으로 제공할 수 있습니다. + +--- + +## 6. 개인정보 처리의 위탁 및 국외 이전 + +서비스는 원활한 운영을 위해 다음과 같이 개인정보 처리 업무를 외부 사업자에게 위탁하거나 국외로 이전할 수 있습니다. + +| 수탁자 | 국가 | 연락처 | 위탁 업무 | 이전 또는 처리 항목 | 이전 방법 | 보유·이용 기간 | +|--------|------|--------|-----------|---------------------|-----------|----------------| +| Amazon Web Services | 서비스 운영 리전 | [AWS Privacy Notice](https://aws.amazon.com/privacy/) | 서버 인프라 운영, 데이터 보관 | 서비스 내 전체 데이터 | 서비스 이용 시 네트워크를 통한 자동 전송 및 저장 | 서비스 운영 기간 또는 위탁계약 종료 시까지 | +| Google LLC | 미국 | [Google Privacy Policy](https://policies.google.com/privacy) | Google 소셜 로그인, 장소·경로 API, 서비스 이용 통계 분석(Google Analytics) | Google 계정 정보, 검색어, 장소 ID, 좌표, IP 주소, 쿠키, 서비스 이용 기록 | 서비스 이용 시 네트워크를 통한 자동 전송(API 호출 등) | Google 정책 및 서비스 설정에 따름 | +| OpenAI, L.L.C. | 미국 | [OpenAI Privacy Policy](https://openai.com/policies/privacy-policy) | AI 응답 생성, 대화 요약 생성 | 채팅 메시지, AI 요청 메시지, 여행 일정·북마크 요약, 장소 정보 요약 | AI 기능 이용 시 네트워크를 통한 자동 전송(API 호출 등) | 처리 목적 달성 시까지 또는 위탁계약 종료 시까지 | + +AI 기능 관련 안내: 이용자가 AI 어시스턴트에게 메시지를 전송하면 해당 메시지와 여행 방의 일정, 북마크, 채팅 요약 정보가 AI 응답 생성을 위해 처리될 수 있습니다. 같은 여행 방 내 다른 멤버의 메시지와 장소 정보가 요약 정보에 포함될 수 있으므로, 민감한 개인정보는 AI 어시스턴트에게 입력하지 않는 것이 좋습니다. + +국외 이전을 원하지 않는 이용자는 외부 서비스가 필요한 기능, 특히 AI 기능, 장소·경로 검색, Google Analytics가 포함된 서비스 이용을 제한하거나 서비스 이용을 중단할 수 있습니다. + +--- + +## 7. 이용자의 권리와 행사 방법 + +이용자는 언제든지 다음 권리를 행사할 수 있습니다. + +1. 개인정보 열람 요구 +2. 오류 등이 있는 경우 정정 요구 +3. 삭제 요구 +4. 처리정지 요구 +5. 동의 철회 + +권리 행사는 team.uttae@gmail.com으로 요청할 수 있습니다. 서비스는 본인 확인 후 지체 없이 처리합니다. + +다음의 경우에는 법령에 따라 권리 행사가 제한될 수 있습니다. + +1. 법령상 의무 준수를 위해 보관이 필요한 경우 +2. 다른 사람의 생명, 신체, 재산 또는 권리를 침해할 우려가 있는 경우 +3. 개인정보를 처리하지 않으면 이용자와의 서비스 이용계약을 이행하기 어려운 경우 +4. 다른 법령에서 해당 개인정보의 수집 또는 보관을 요구하는 경우 + +이용자는 본인의 계정 정보와 인증 수단을 안전하게 관리해야 하며, 타인의 개인정보를 무단으로 수집·공개·훼손해서는 안 됩니다. + +--- + +## 8. 개인정보 보호책임자 및 권익침해 구제방법 + +서비스는 개인정보 처리와 관련한 문의, 불만, 피해 구제를 위해 아래 연락처를 운영합니다. + +| 항목 | 내용 | +|------|------| +| 개인정보 보호책임자 | 박주영 | +| 이메일 | team.uttae@gmail.com | + +개인정보와 관련된 불만 또는 피해 구제는 아래 기관에도 신청할 수 있습니다. + +| 기관 | 연락처 | +|------|--------| +| 개인정보 침해신고센터 | [privacy.kisa.or.kr](https://privacy.kisa.or.kr) / 국번없이 118 | +| 개인정보 분쟁조정위원회 | [www.kopico.go.kr](https://www.kopico.go.kr) / 1833-6972 | +| 경찰청 사이버범죄 신고시스템 | [ecrm.police.go.kr](https://ecrm.police.go.kr) / 국번없이 182 | + +--- + +## 9. 개인정보의 안전성 확보 조치 + +서비스는 개인정보의 안전성 확보를 위해 다음 조치를 시행합니다. + +1. 관리적 조치: 개인정보 접근 권한 최소화, 운영자 접근 관리, 내부 점검 +2. 기술적 조치: HTTPS 통신, 인증 토큰 HttpOnly 쿠키 적용, 비밀번호 없는 Google OAuth 로그인, 접근 권한 관리, 민감 로그 마스킹, Rate Limit을 통한 남용 방지 +3. 저장·접근 보호: PostgreSQL, MongoDB, Redis 접근 권한 분리, 운영 환경 비밀값 분리 관리 +4. 로그 보호: 토큰, 인증 코드 등 민감 정보가 로그에 남지 않도록 마스킹 + +--- + +## 10. 쿠키의 설치·운영 및 거부 + +쿠키는 웹사이트가 이용자의 브라우저에 저장하는 소량의 정보입니다. 서비스는 로그인 인증과 이용 통계 분석을 위해 쿠키를 사용할 수 있습니다. + +| 쿠키명 | 목적 | 보존 기간 | +|--------|------|-----------| +| `access_token` | 로그인 인증, API 및 WebSocket 인증 | 운영 설정에 따른 만료 시간(현재 운영 기준 30분) | +| `refresh_token` | 자동 로그인 유지, Access Token 재발급 | 14일 | +| `_ga`, `_ga_*` | Google Analytics 이용자 구분 및 통계 분석 | 최대 2년 | +| `_gid` | Google Analytics 세션 구분 | 24시간 | + +이용자는 브라우저 설정에서 쿠키 저장을 거부할 수 있습니다. 다만 `access_token` 및 `refresh_token` 쿠키를 거부하면 로그인이 필요한 기능을 이용할 수 없습니다. + +Google Analytics 수집만 선택적으로 차단하려면 [Google Analytics 수집 거부 플러그인](https://tools.google.com/dlpage/gaoptout)을 사용할 수 있습니다. + +--- + +## 11. 맞춤형 광고 및 행태정보 + +서비스는 현재 자체 맞춤형 광고를 제공하지 않습니다. + +다만 Google Analytics 등 분석 도구를 통해 페이지뷰, 이벤트, 기기·브라우저 정보, 쿠키 기반 식별자 등 행태정보가 수집될 수 있으며, 이는 서비스 이용 통계 분석과 서비스 개선 목적으로 사용됩니다. + +향후 맞춤형 광고 또는 제3자 광고 도구를 도입하는 경우, 수집 항목, 이용 목적, 보유 기간, 거부 방법을 이 처리방침 또는 별도 안내를 통해 고지하고 필요한 경우 동의를 받겠습니다. + +--- + +## 12. 개인위치정보 및 연계정보 처리 + +서비스는 현재 「위치정보의 보호 및 이용 등에 관한 법률」상 개인위치정보를 지속적으로 수집·저장하거나 위치 이력을 관리하지 않습니다. + +서비스는 현재 본인확인기관을 통한 연계정보(Connecting Information, CI)를 수집·저장하지 않습니다. + +향후 개인위치정보 또는 CI를 처리하는 기능을 도입하는 경우, 관련 법령에 따라 별도 동의 절차와 처리방침을 마련하겠습니다. + +--- + +## 13. 처리방침의 변경 + +서비스는 법령, 서비스 기능, 개인정보 처리 방식의 변경에 따라 이 처리방침을 개정할 수 있습니다. + +처리방침을 변경하는 경우 시행 7일 전 서비스 공지 또는 정책 문서 갱신을 통해 안내합니다. 이용자의 권리에 중대한 영향을 미치는 변경은 30일 전에 고지합니다. + +| 버전 | 시행일 | +|------|--------| +| 1.0 | 2026년 6월 7일 | diff --git a/docs/policy/terms-of-service.md b/docs/policy/terms-of-service.md new file mode 100644 index 00000000..c56fd963 --- /dev/null +++ b/docs/policy/terms-of-service.md @@ -0,0 +1,177 @@ +# 이용약관 + +**시행일: 2026년 6월 8일** + +--- + +## 제1조 (목적) + +이 약관은 우때(이하 "서비스")가 제공하는 여행 계획 협업 서비스의 이용 조건과 절차, 이용자와 운영자 간의 권리·의무 및 책임 사항을 정함을 목적으로 합니다. + +서비스의 개인정보 처리에 관한 사항은 별도의 [개인정보 처리방침](privacy-policy.md)에 따릅니다. 서비스 이용 제한, 신고, 이의제기 등 세부 운영 기준은 [운영정책](operation-policy.md)에 따르며, 저작권 침해 신고와 게시중단 절차는 [저작권 정책](copyright-policy.md)에 따릅니다. 이용자는 이 약관과 관련 정책을 확인하고 동의한 뒤 서비스를 이용해야 합니다. + +--- + +## 제2조 (정의) + +이 약관에서 사용하는 용어의 정의는 다음과 같습니다. + +1. **서비스**: "우때" 또는 "실시간 협업 여행 플래너"라는 이름으로 제공되는 여행 계획 협업 웹 서비스 및 이에 부속하는 기능 일체 +2. **이용자**: Google 계정으로 로그인하거나, 이 약관에 따라 서비스를 이용하는 자 +3. **여행 방**: 이용자가 생성하거나 초대받아 참여하는 여행 계획 협업 공간으로, 서비스 화면에서 "여행 방", "새 여행 계획", "방" 등으로 표시될 수 있습니다. +4. **방장(HOST)**: 여행 방을 생성하거나 권한을 위임받은 이용자로, 서비스 화면에서 "방장" 또는 "HOST" 배지로 표시될 수 있습니다. 방장이 아닌 참여자는 일반 멤버입니다. +5. **AI 어시스턴트**: 여행 방 채팅에서 "WOORI", "@ai" 등으로 호출되며 여행 계획 수립을 보조하는 인공지능 기능 +6. **북마크**: 이용자가 여행 방에서 저장한 장소 정보로, 일정 작성과 여행 계획 협업에 활용됩니다. +7. **콘텐츠**: 이용자가 서비스 내에서 작성·등록·공유하는 채팅 메시지, 일정, 일차, 메모, 체류 시간, 장소 카드, 북마크, 프로필 정보 등 일체 +8. **외부 서비스**: Google, OpenAI 등 서비스 제공을 위해 연동되는 제3자 서비스 + +--- + +## 제3조 (약관의 효력 및 변경) + +1. 이 약관은 서비스 가입 시 동의함으로써 효력이 발생합니다. +2. 운영자는 약관을 변경할 경우 시행 7일 전 이용자가 제공한 이메일 또는 서비스 내 공지사항을 통해 안내합니다. 이용자의 권리에 중대한 영향을 미치는 변경은 30일 전에 이메일 또는 서비스 내 공지사항을 통해 고지합니다. +3. 변경된 약관의 시행일 이후에도 서비스를 계속 이용하면 변경 약관에 동의한 것으로 봅니다. 변경 약관에 동의하지 않는 이용자는 서비스 이용을 중단하고 탈퇴할 수 있습니다. + +--- + +## 제4조 (서비스의 제공) + +서비스는 다음 기능을 제공합니다. + +1. 여행 방 생성·관리, 초대 코드를 통한 멤버 초대 +2. 여행 일정 및 장소 협업 편집 (일정, 북마크, 지도 연동) +3. 여행 방 내 실시간 채팅 +4. AI 어시스턴트를 통한 여행 계획 수립 보조 +5. Google 장소 검색 및 이동 경로 정보 조회 + +--- + +## 제5조 (회원 가입) + +1. 서비스는 Google 계정을 통한 소셜 로그인으로만 가입할 수 있습니다. +2. 만 14세 미만은 서비스에 가입할 수 없습니다. +3. 타인의 Google 계정을 도용하거나 허위 정보로 가입하는 행위는 금지됩니다. +4. 이용자는 가입 및 서비스 이용 과정에서 정확하고 최신의 정보를 제공해야 합니다. + +--- + +## 제6조 (계정 관리) + +1. 이용자는 본인의 계정과 로그인 수단을 안전하게 관리할 책임이 있습니다. +2. 이용자는 본인 계정에서 발생하는 활동에 대해 책임을 집니다. 다만, 운영자의 고의 또는 중대한 과실로 발생한 경우는 제외합니다. +3. 계정의 무단 사용, 보안 침해, 제3자의 접근이 의심되는 경우 이용자는 지체 없이 team.uttae@gmail.com으로 알려야 합니다. +4. 이용자는 타인을 사칭하거나, 타인의 권리를 침해하거나, 불쾌감·혐오감을 유발할 수 있는 이름·프로필 정보를 사용할 수 없습니다. + +--- + +## 제7조 (회원 탈퇴 및 계정 삭제) + +1. 이용자는 언제든지 서비스 내 탈퇴 기능이 제공되는 경우 해당 기능을 통해 탈퇴할 수 있으며, 기능 제공 전에는 team.uttae@gmail.com을 통해 탈퇴를 요청할 수 있습니다. +2. 탈퇴 처리 시 회원 정보 및 이용자가 작성한 콘텐츠는 삭제됩니다. 단, 법령에 따라 보관이 필요한 정보는 해당 기간 동안 보관 후 삭제합니다. +3. 탈퇴하는 이용자가 방장인 여행 방에 다른 멤버가 없는 경우, 해당 여행 방과 방 내 데이터는 함께 삭제될 수 있습니다. +4. 탈퇴하는 이용자가 방장인 여행 방에 다른 멤버가 있는 경우, 탈퇴 요청은 거절될 수 있습니다. 이 경우 방장은 다른 멤버에게 방장 권한을 위임한 뒤 다시 탈퇴를 요청해야 합니다. + +--- + +## 제8조 (이용자의 의무 및 금지 행위) + +이용자는 서비스를 이용함에 있어 다음 행위를 하여서는 안 됩니다. + +1. 타인의 명예를 훼손하거나 개인정보를 무단으로 수집·유포하는 행위 +2. 음란물, 폭력, 혐오 표현, 차별적 언행, 괴롭힘, 위협 등 불법·유해한 콘텐츠를 게시하는 행위 +3. 허위 장소 정보, 허위 후기, 오해를 유발하는 일정·메모 등 다른 이용자의 여행 계획을 방해할 수 있는 정보를 고의로 게시하는 행위 +4. 미성년자를 대상으로 위해를 가하거나 부적절한 콘텐츠에 노출시키는 행위 +5. 채팅 도배, 광고성 메시지, 스팸, 피싱, 악성 링크 전송 행위 +6. 자동화 프로그램(봇), 스크래퍼, 크롤러 등을 사용하여 운영자의 허가 없이 서비스 또는 콘텐츠에 접근·수집·복제하는 행위 +7. 서비스의 서버, 네트워크, 보안 기능을 방해하거나 과도한 부하를 유발하는 행위 +8. 바이러스, 악성 코드, 비정상 요청 등 서비스 또는 다른 이용자의 기기에 해를 줄 수 있는 자료를 전송하는 행위 +9. 타인의 계정으로 로그인하거나 초대 코드를 무단으로 수집·배포하는 행위 +10. 서비스를 역분석하거나, 비공개 API를 무단 호출하거나, 운영자가 제공하지 않는 방식으로 서비스에 접근하는 행위 +11. 운영자 또는 제3자의 지식재산권, 초상권, 개인정보, 기타 권리를 침해하는 행위 +12. 관련 법령을 위반하거나 서비스의 정상적인 운영을 방해하는 일체의 행위 + +--- + +## 제9조 (유해 콘텐츠 신고 및 조치) + +1. 이용자는 불법·유해 콘텐츠, 권리 침해 콘텐츠, 서비스 운영을 방해하는 콘텐츠를 발견한 경우 team.uttae@gmail.com으로 신고할 수 있습니다. +2. 운영자는 신고된 콘텐츠 또는 운영 과정에서 발견한 콘텐츠가 이 약관 또는 관련 법령을 위반한다고 판단하는 경우 해당 콘텐츠를 숨김·삭제하거나 작성자의 서비스 이용을 제한할 수 있습니다. +3. 저작권 등 권리 침해 신고, 게시중단, 재게시 요청에 관한 세부 절차는 [저작권 정책](copyright-policy.md)에 따릅니다. +4. 운영자는 위반 여부 확인을 위해 필요한 범위에서 콘텐츠를 검토할 수 있으나, 모든 콘텐츠를 사전에 검토할 의무를 부담하지 않습니다. + +--- + +## 제10조 (서비스 이용 제한) + +1. 운영자는 이용자가 제8조 또는 제9조를 위반한 경우 사전 통보 없이 서비스 이용을 제한하거나 계정을 삭제할 수 있습니다. +2. 운영자는 위반 행위의 내용, 반복 여부, 피해 규모, 긴급성을 고려하여 게시물 삭제, 채팅 제한, 여행 방 접근 제한, 계정 정지 또는 계정 삭제 조치를 할 수 있습니다. 세부 기준은 [운영정책](operation-policy.md)에 따릅니다. +3. 이용 제한 처분에 이의가 있는 경우 team.uttae@gmail.com으로 이의를 신청할 수 있으며, 운영자는 7일 이내에 처리 결과를 안내합니다. + +--- + +## 제11조 (콘텐츠에 대한 권리) + +1. 이용자가 서비스 내에서 작성·등록한 콘텐츠의 저작권은 해당 이용자에게 있습니다. +2. 이용자는 서비스 운영, 저장, 백업, 공유, 실시간 동기화, AI 어시스턴트 응답 생성 등 기능 제공에 필요한 범위에서 운영자가 콘텐츠를 이용할 수 있도록 비독점적·무상의 사용권을 부여합니다. +3. 이용자는 본인이 작성·등록한 콘텐츠에 대해 필요한 권리를 보유하고 있으며, 해당 콘텐츠가 제3자의 권리 또는 관련 법령을 침해하지 않음을 보증합니다. +4. 이용자가 작성·등록한 콘텐츠가 제3자의 권리를 침해한다는 신고 또는 이의제기가 있는 경우, 운영자는 관련 법령과 [저작권 정책](copyright-policy.md)에 따라 게시중단, 삭제, 접근 제한, 서비스 이용 제한 등 필요한 조치를 할 수 있습니다. +5. 운영자는 이용자의 콘텐츠를 광고·마케팅 목적으로 사용하지 않습니다. +6. 운영자가 제공하는 서비스 화면, 기능, 로고, 디자인, 데이터베이스, 프로그램 등 서비스 자체의 권리는 운영자 또는 정당한 권리자에게 귀속됩니다. + +--- + +## 제12조 (AI 어시스턴트 이용 안내) + +1. AI 어시스턴트의 응답은 참고 목적으로만 제공되며, 정확성·완전성을 보장하지 않습니다. +2. AI 어시스턴트가 추천하는 장소, 일정, 경로 정보는 실제와 다를 수 있으므로, 중요한 사항은 반드시 공식 정보를 직접 확인하시기 바랍니다. +3. AI 어시스턴트에게 메시지를 전송하면 채팅 내용이 외부 AI 서비스(OpenAI)로 전달됩니다. 민감한 개인정보는 AI 어시스턴트에게 입력하지 않도록 주의하시기 바랍니다. +4. 이용자는 AI 어시스턴트의 응답을 여행 예약, 결제, 안전, 법률, 의료 등 중요한 의사결정의 유일한 근거로 사용해서는 안 됩니다. + +--- + +## 제13조 (외부 서비스 및 외부 정보에 관한 고지) + +1. 서비스 내 장소 정보(영업시간, 평점, 주소, 사진 등)는 Google 등 외부 서비스가 제공하는 데이터로, 운영자가 정확성을 보장하지 않습니다. 방문 전 해당 장소에 직접 확인하시기 바랍니다. +2. 서비스의 장소 검색, 지도, 장소 사진, 이동 경로 등 Google Maps 기능 및 콘텐츠를 이용하는 경우, 이용자에게 Google Maps/Google Earth 추가 서비스 약관([https://maps.google.com/help/terms_maps/](https://maps.google.com/help/terms_maps/)) 및 Google 개인정보처리방침([https://policies.google.com/privacy](https://policies.google.com/privacy))이 적용됩니다. +3. 서비스는 외부 웹사이트 또는 외부 서비스로 연결되는 링크를 포함할 수 있습니다. 운영자는 외부 웹사이트 또는 외부 서비스의 내용, 정책, 이용 가능성, 안전성을 보증하지 않습니다. +4. 외부 서비스 이용에는 해당 외부 서비스의 약관과 정책이 적용될 수 있습니다. + +--- + +## 제14조 (오류 제보 및 피드백) + +1. 이용자는 서비스 오류, 개선 제안, 아이디어, 불만 사항 등을 team.uttae@gmail.com으로 전달할 수 있습니다. +2. 이용자가 피드백을 제공하는 경우, 운영자는 별도의 보상 없이 서비스 개선, 문제 해결, 기능 개발 목적으로 해당 피드백을 이용할 수 있습니다. +3. 피드백에 개인정보나 제3자의 비밀 정보가 포함되지 않도록 주의해야 합니다. + +--- + +## 제15조 (서비스의 변경 및 중단) + +1. 운영자는 서비스 내용을 변경하거나 서비스를 중단할 수 있습니다. +2. 서비스를 전부 중단하는 경우 30일 전 이메일 또는 서비스 내 공지사항을 통해 안내합니다. 다만, 장애 대응, 보안 사고, 외부 서비스 장애, 긴급 점검 등 부득이한 경우 사후에 안내할 수 있습니다. +3. 서비스는 현재 무료로 제공되며, 운영자는 서비스 변경 또는 중단으로 인한 손해에 대해 관련 법령상 책임이 인정되는 경우를 제외하고 별도의 보상 의무를 지지 않습니다. + +--- + +## 제16조 (보증의 부인 및 책임의 제한) + +1. 서비스는 현재 상태와 제공 가능한 범위에서 제공됩니다. 운영자는 서비스가 항상 중단 없이 제공되거나, 모든 오류가 수정되거나, 모든 정보가 정확하다고 보증하지 않습니다. +2. 운영자는 천재지변, 서비스 장애, 네트워크 문제, 외부 서비스 장애, 이용자의 귀책 사유 등 운영자의 합리적 통제 범위를 벗어난 사유로 인한 서비스 중단 또는 손해에 대해 책임을 지지 않습니다. +3. 운영자는 이용자 간 또는 이용자와 제3자 간의 분쟁에 개입할 의무를 부담하지 않으며, 관련 법령상 책임이 인정되는 경우를 제외하고 이에 대한 책임을 지지 않습니다. +4. 운영자는 무료로 제공되는 서비스의 이용과 관련하여 고의 또는 중대한 과실이 없는 한 손해배상 책임을 지지 않습니다. + +--- + +## 제17조 (준거법 및 관할) + +1. 이 약관은 대한민국 법률에 따라 해석·적용됩니다. +2. 서비스 이용과 관련한 분쟁은 운영자와 이용자가 성실히 협의하여 해결합니다. +3. 협의로 해결되지 않는 경우 「민사소송법」에 따른 관할 법원을 전속 관할로 합니다. + +--- + +## 부칙 + +이 약관은 2026년 6월 8일부터 시행합니다. From 5df9f033a055f7c61ad65f0cbd75d2323258dc17 Mon Sep 17 00:00:00 2001 From: parkjuyeong0312 Date: Mon, 8 Jun 2026 10:04:54 +0900 Subject: [PATCH 02/12] =?UTF-8?q?docs:=20=EC=95=BD=EA=B4=80=20=EB=B0=B1?= =?UTF-8?q?=EC=97=94=EB=93=9C=20=EA=B4=80=EB=A6=AC=20=EC=84=A4=EA=B3=84=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../2026-06-08-backend-agreements-design.md | 212 ++++++++++++++++++ 1 file changed, 212 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-08-backend-agreements-design.md diff --git a/docs/superpowers/specs/2026-06-08-backend-agreements-design.md b/docs/superpowers/specs/2026-06-08-backend-agreements-design.md new file mode 100644 index 00000000..6cb5ecfa --- /dev/null +++ b/docs/superpowers/specs/2026-06-08-backend-agreements-design.md @@ -0,0 +1,212 @@ +# Backend Agreements Design + +## Context + +현재 약관 원문은 `docs/policy/terms-of-service.md`와 `docs/policy/privacy-policy.md`에 작성되어 있다. 빠른 런칭을 위해 약관 버전과 동의 기록만 관리하려는 논의가 있었으나, 프론트엔드가 보낸 버전 문자열을 신뢰하면 동의 증적이 오염될 수 있다. + +따라서 백엔드가 약관 원문과 현재 버전의 기준이 되고, 프론트엔드는 백엔드가 내려준 원문을 표시한 뒤 사용자의 동의 여부만 전송한다. + +## Goals + +- 백엔드가 현재 이용약관과 개인정보 처리방침 원문을 제공한다. +- 백엔드가 현재 약관 버전을 설정으로 관리한다. +- 프론트엔드는 약관 버전을 전송하지 않는다. +- 신규 가입 시 서버 기준 현재 버전과 서버 시간을 `users`에 저장한다. +- 기존 사용자가 현재 약관 버전과 다른 버전에 동의한 상태라면 로그인 토큰 발급 전에 재동의를 요구한다. +- 인증된 기존 사용자는 별도 API로 현재 약관에 재동의할 수 있다. + +## Non-Goals + +- 약관 변경 이력 테이블을 만들지 않는다. +- 과거 약관 원문을 DB에 저장하지 않는다. +- 운영정책과 저작권 정책 원문은 이번 범위에서 백엔드 리소스로 이동하지 않는다. +- 약관 고지 발송, 공지 예약, 30일 고지 기간 자동 계산은 이번 범위에 포함하지 않는다. +- 개인정보 처리 목적 변경 등 별도 명시 동의가 필요한 정책 판단을 자동화하지 않는다. + +## Source Files + +이번 구현에서 백엔드 리소스로 옮길 원문은 두 개다. + +```text +docs/policy/terms-of-service.md +docs/policy/privacy-policy.md +``` + +이 두 파일의 내용을 다음 리소스 파일로 이동한다. + +```text +src/main/resources/agreements/terms-of-service.md +src/main/resources/agreements/privacy-policy.md +``` + +`terms-of-service.md` 내부의 `privacy-policy.md`, `operation-policy.md`, `copyright-policy.md` 링크는 API 응답에서 그대로 노출될 수 있으므로 구현 시 링크 의미를 확인한다. 이번 범위에서 운영정책과 저작권 정책은 백엔드가 제공하는 필수 동의 문서가 아니다. + +## Configuration + +현재 약관 버전과 원문 리소스 경로는 `application.yaml`에서 관리한다. + +```yaml +app: + agreements: + terms-of-service: + version: "1.0" + resource: "classpath:agreements/terms-of-service.md" + privacy-policy: + version: "1.0" + resource: "classpath:agreements/privacy-policy.md" +``` + +설정이 비어 있거나 리소스 파일을 읽을 수 없으면 서버 설정 오류로 처리한다. 백엔드는 클라이언트가 보낸 버전 문자열을 저장하지 않는다. + +## Data Model + +데이터베이스는 초기화 가능하므로 `users` 테이블에 약관별 동의 컬럼을 `NOT NULL`로 추가한다. + +```text +tos_version VARCHAR(30) NOT NULL +tos_accepted_at TIMESTAMP WITH TIME ZONE NOT NULL +privacy_version VARCHAR(30) NOT NULL +privacy_accepted_at TIMESTAMP WITH TIME ZONE NOT NULL +``` + +`User` 엔티티에도 같은 의미의 필드를 추가한다. + +- `tosVersion` +- `tosAcceptedAt` +- `privacyVersion` +- `privacyAcceptedAt` + +신규 사용자는 생성 시점에 이 네 값을 반드시 가진다. 기존 사용자는 저장된 버전이 설정의 현재 버전과 다르면 재동의가 필요하다. + +## API Design + +### GET /api/agreements/current + +현재 필수 약관 원문과 버전을 조회한다. 가입 화면에서 사용해야 하므로 인증 없이 호출할 수 있어야 한다. + +응답 예시: + +```json +{ + "items": [ + { + "type": "TERMS_OF_SERVICE", + "title": "이용약관", + "version": "1.0", + "contentFormat": "MARKDOWN", + "content": "# 이용약관\n..." + }, + { + "type": "PRIVACY_POLICY", + "title": "개인정보 처리방침", + "version": "1.0", + "contentFormat": "MARKDOWN", + "content": "# 개인정보 처리방침\n..." + } + ] +} +``` + +### POST /auth/google/login + +기존 Google 로그인 요청에 `agreementsAccepted`를 추가한다. + +요청 예시: + +```json +{ + "code": "google-authorization-code", + "agreementsAccepted": true +} +``` + +신규 사용자 처리: + +- `agreementsAccepted`가 `true`가 아니면 가입을 거부한다. +- Google 인증 성공 후 `users` 생성 시 설정의 현재 버전과 서버 시간을 저장한다. +- 저장된 동의 버전은 클라이언트 요청값이 아니라 서버 설정값이다. + +기존 사용자 처리: + +- 저장된 약관 버전이 설정의 현재 버전과 같으면 기존처럼 토큰을 발급한다. +- 저장된 약관 버전이 현재 버전과 다르고 `agreementsAccepted`가 `true`가 아니면 토큰을 발급하지 않고 재동의 필요 에러를 반환한다. +- 저장된 약관 버전이 현재 버전과 다르고 `agreementsAccepted`가 `true`이면 현재 서버 버전과 서버 시간으로 동의 컬럼을 갱신한 뒤 토큰을 발급한다. + +### POST /api/users/me/agreements + +인증된 기존 사용자가 현재 약관에 재동의한다. 이미 로그인된 사용자가 서비스 이용 중 약관 변경 안내를 보고 재동의하는 흐름에 사용한다. 로그아웃 상태의 기존 사용자는 `POST /auth/google/login`에서 `agreementsAccepted=true`를 보내 재동의와 로그인을 함께 처리한다. + +요청 예시: + +```json +{ + "agreementsAccepted": true +} +``` + +동작: + +- `agreementsAccepted`가 `true`가 아니면 거부한다. +- 현재 서버 설정의 이용약관 버전과 개인정보 처리방침 버전으로 `users`의 동의 컬럼을 갱신한다. +- 갱신 시각은 서버 시간을 사용한다. + +## Errors + +새 에러 코드를 추가한다. + +```text +AGREEMENTS_NOT_ACCEPTED +AGREEMENTS_REACCEPTANCE_REQUIRED +``` + +- `AGREEMENTS_NOT_ACCEPTED`: 신규 가입 또는 재동의 API에서 동의 여부가 `true`가 아닐 때 사용한다. +- `AGREEMENTS_REACCEPTANCE_REQUIRED`: 기존 사용자가 현재 서버 약관 버전에 동의하지 않았고 로그인 요청에도 동의 여부가 포함되지 않아 토큰을 발급할 수 없을 때 사용한다. + +약관 리소스 파일을 읽을 수 없거나 설정이 잘못된 경우는 클라이언트 동의 문제가 아니라 서버 설정 문제로 분리한다. + +## Components + +- `agreements` 도메인 패키지를 추가한다. +- `AgreementProperties`는 현재 버전과 리소스 경로를 바인딩한다. +- `AgreementService`는 현재 약관 목록 조회, 현재 버전 조회, 사용자 동의 상태 비교를 담당한다. +- `AgreementController`는 `GET /api/agreements/current`를 제공한다. +- `UserService`는 신규 사용자 생성과 기존 사용자 재동의 갱신 시 약관 버전을 저장한다. +- `AuthService`는 로그인 시 신규 가입 동의 여부와 기존 사용자 재동의 필요 여부를 검사한다. + +## Security + +`GET /api/agreements/current`는 공개 엔드포인트로 허용한다. `POST /api/users/me/agreements`는 인증된 사용자만 호출할 수 있다. `POST /auth/google/login`은 기존처럼 공개 엔드포인트지만, 신규 사용자 생성에는 `agreementsAccepted=true`가 필요하다. + +## Documentation Updates + +- `docs/ai/features.md`: 인증/사용자 기능에 약관 조회, 가입 동의 기록, 재동의 필요 흐름을 반영한다. +- `docs/ai/erd.md`: `users` 테이블의 약관 동의 컬럼을 반영한다. +- `docs/ai/api-spec-guidelines.md`: 변경하지 않는다. +- API 명세 변경이 있으므로 Swagger 어노테이션과 요청/응답 DTO 설명을 실제 동작과 맞춘다. + +## Testing + +- `AgreementServiceTest` + - 설정된 현재 약관 두 개를 반환한다. + - 리소스 파일 읽기 실패 또는 설정 누락을 서버 설정 오류로 처리한다. +- `AgreementControllerTest` + - 비인증 상태에서 현재 약관 조회가 가능하다. +- `AuthServiceTest` + - 신규 사용자가 동의하지 않으면 생성과 토큰 발급을 거부한다. + - 신규 사용자가 동의하면 현재 서버 버전과 서버 시간을 저장한다. + - 기존 사용자의 저장 버전이 현재 버전이면 로그인한다. + - 기존 사용자의 저장 버전이 오래됐고 동의 여부가 없으면 토큰 발급을 거부한다. + - 기존 사용자의 저장 버전이 오래됐고 `agreementsAccepted=true`이면 현재 서버 버전으로 갱신하고 로그인한다. +- `UserServiceTest` + - 인증된 기존 사용자의 재동의 API가 현재 서버 버전과 서버 시간으로 동의 컬럼을 갱신한다. +- 컨트롤러 테스트 + - `POST /api/users/me/agreements`는 인증이 필요하다. + - `agreementsAccepted=false` 또는 누락 시 거부한다. + +최소 검증은 관련 테스트와 `./gradlew compileJava`다. 구현에서 Java DTO, 컨트롤러, 엔티티, 마이그레이션이 바뀌므로 PR 전에는 `./gradlew test`와 `./gradlew checkstyleMain checkstyleTest`도 수행한다. + +## Implementation Decisions + +- 약관 Markdown의 상대 링크는 원문 그대로 응답한다. 프론트는 현재 필수 동의 문서인 이용약관과 개인정보 처리방침을 백엔드 응답으로 표시하고, 운영정책과 저작권 정책 링크는 별도 화면 또는 외부 문서 연결 방식으로 처리한다. +- 로그아웃 상태의 기존 사용자가 재동의해야 하는 경우에는 `POST /auth/google/login`에서 `agreementsAccepted=true`를 받아 재동의 갱신과 토큰 발급을 한 번에 처리한다. +- 이미 로그인된 사용자의 재동의는 `POST /api/users/me/agreements`에서 처리한다. From 686b7b35ab008c61f111c223bc4c8ff5a8a646cf Mon Sep 17 00:00:00 2001 From: parkjuyeong0312 Date: Mon, 8 Jun 2026 10:38:23 +0900 Subject: [PATCH 03/12] =?UTF-8?q?docs:=20=EC=95=BD=EA=B4=80=20=EB=B0=B1?= =?UTF-8?q?=EC=97=94=EB=93=9C=20=EA=B4=80=EB=A6=AC=20=EA=B5=AC=ED=98=84=20?= =?UTF-8?q?=EA=B3=84=ED=9A=8D=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../plans/2026-06-08-backend-agreements.md | 1513 +++++++++++++++++ 1 file changed, 1513 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-08-backend-agreements.md diff --git a/docs/superpowers/plans/2026-06-08-backend-agreements.md b/docs/superpowers/plans/2026-06-08-backend-agreements.md new file mode 100644 index 00000000..4aef9407 --- /dev/null +++ b/docs/superpowers/plans/2026-06-08-backend-agreements.md @@ -0,0 +1,1513 @@ +# Backend Agreements Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Serve current terms/privacy documents from the backend and store server-trusted agreement versions plus accepted timestamps on users. + +**Architecture:** Add a focused `agreements` domain that reads Markdown resources configured in `application.yaml`, exposes the public current agreements API, and provides current version data to auth/user services. Store agreement state on `users` with `NOT NULL` fields because the database will be reset. Frontend sends only `agreementsAccepted`; backend decides versions and timestamps. + +**Tech Stack:** Spring Boot 4.0.5, Java 21, Spring MVC, Spring Security, Spring Data JPA, Flyway, Gradle, JUnit 5, Mockito, Spring MockMvc, Swagger/OpenAPI annotations. + +--- + +## File Structure + +- Create: `src/main/java/com/howaboutus/backend/agreements/config/AgreementProperties.java` + - Binds `app.agreements.*` from `application.yaml`. +- Create: `src/main/java/com/howaboutus/backend/agreements/service/AgreementService.java` + - Reads configured Markdown resources, returns current agreement DTOs, validates accepted flag, and produces current server versions. +- Create: `src/main/java/com/howaboutus/backend/agreements/service/dto/AgreementDocumentResult.java` + - Service DTO for one current agreement document. +- Create: `src/main/java/com/howaboutus/backend/agreements/service/dto/AgreementVersions.java` + - Service DTO for current terms/privacy versions. +- Create: `src/main/java/com/howaboutus/backend/agreements/controller/AgreementController.java` + - Public REST controller for `GET /api/agreements/current`. +- Create: `src/main/java/com/howaboutus/backend/agreements/controller/dto/AgreementCurrentResponse.java` + - REST response wrapper. +- Create: `src/main/java/com/howaboutus/backend/agreements/controller/dto/AgreementDocumentResponse.java` + - REST response item. +- Create: `src/main/java/com/howaboutus/backend/user/controller/dto/AcceptAgreementsRequest.java` + - Request body for `POST /api/users/me/agreements`. +- Create: `src/main/java/com/howaboutus/backend/user/controller/UserAgreementController.java` + - Authenticated REST controller for `POST /api/users/me/agreements`. +- Create: `src/main/resources/agreements/terms-of-service.md` + - Moved original terms content from `docs/policy/terms-of-service.md`. +- Create: `src/main/resources/agreements/privacy-policy.md` + - Moved original privacy content from `docs/policy/privacy-policy.md`. +- Modify: `docs/policy/terms-of-service.md` + - Replace original terms content with a short pointer to the backend resource. +- Modify: `docs/policy/privacy-policy.md` + - Replace original privacy content with a short pointer to the backend resource. +- Modify: `src/main/resources/application.yaml` + - Add current agreement versions and classpath resources. +- Modify: `src/main/resources/db/migration/V1__init.sql` + - Add `tos_version`, `tos_accepted_at`, `privacy_version`, `privacy_accepted_at` to `users`. +- Modify: `src/main/java/com/howaboutus/backend/user/entity/User.java` + - Add agreement fields and domain methods. +- Modify: `src/main/java/com/howaboutus/backend/user/service/UserService.java` + - Create users with agreement versions and update current user's agreements. +- Modify: `src/main/java/com/howaboutus/backend/auth/service/AuthService.java` + - Require agreement acceptance for new users and stale-version existing users. +- Modify: `src/main/java/com/howaboutus/backend/auth/controller/AuthController.java` + - Pass `agreementsAccepted` to service. +- Modify: `src/main/java/com/howaboutus/backend/auth/controller/dto/GoogleLoginRequest.java` + - Add `agreementsAccepted`. +- Create: `src/main/java/com/howaboutus/backend/user/controller/UserAgreementController.java` + - Add `POST /api/users/me/agreements`. +- Modify: `src/main/java/com/howaboutus/backend/common/config/SecurityConfig.java` + - Permit `GET /api/agreements/current`. +- Modify: `src/main/java/com/howaboutus/backend/common/error/ErrorCode.java` + - Add agreement errors and server configuration error. +- Modify: `docs/ai/features.md` + - Document agreement APIs and auth behavior. +- Modify: `docs/ai/erd.md` + - Document new user agreement columns. + +--- + +### Task 1: Agreement Resources And Configuration + +**Files:** +- Create: `src/main/resources/agreements/terms-of-service.md` +- Create: `src/main/resources/agreements/privacy-policy.md` +- Modify: `docs/policy/terms-of-service.md` +- Modify: `docs/policy/privacy-policy.md` +- Modify: `src/main/resources/application.yaml` +- Create: `src/main/java/com/howaboutus/backend/agreements/config/AgreementProperties.java` + +- [ ] **Step 1: Move the two policy Markdown originals into resources** + +Move the exact original content from: + +```text +docs/policy/terms-of-service.md +docs/policy/privacy-policy.md +``` + +into: + +```text +src/main/resources/agreements/terms-of-service.md +src/main/resources/agreements/privacy-policy.md +``` + +Use `git mv` for each file, then recreate the original docs paths as pointer documents so existing documentation links remain valid: + +```markdown +# 이용약관 + +원문은 백엔드 약관 리소스로 이동했습니다. + +- 리소스: `src/main/resources/agreements/terms-of-service.md` +- 현재 버전 설정: `src/main/resources/application.yaml`의 `app.agreements.terms-of-service.version` +``` + +```markdown +# 개인정보 처리방침 + +원문은 백엔드 약관 리소스로 이동했습니다. + +- 리소스: `src/main/resources/agreements/privacy-policy.md` +- 현재 버전 설정: `src/main/resources/application.yaml`의 `app.agreements.privacy-policy.version` +``` + +- [ ] **Step 2: Add agreement configuration** + +In `src/main/resources/application.yaml`, add this block under the existing top-level configuration: + +```yaml +app: + agreements: + terms-of-service: + version: "1.0" + resource: "classpath:agreements/terms-of-service.md" + privacy-policy: + version: "1.0" + resource: "classpath:agreements/privacy-policy.md" +``` + +If `application.yaml` already has an `app:` block, merge the `agreements:` subtree into it instead of creating a second `app:` key. + +- [ ] **Step 3: Create properties binding** + +Create `src/main/java/com/howaboutus/backend/agreements/config/AgreementProperties.java`: + +```java +package com.howaboutus.backend.agreements.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.core.io.Resource; + +@ConfigurationProperties(prefix = "app.agreements") +public record AgreementProperties( + AgreementDocument termsOfService, + AgreementDocument privacyPolicy +) { + + public record AgreementDocument( + String version, + Resource resource + ) { + } +} +``` + +- [ ] **Step 4: Enable properties** + +Modify `src/main/java/com/howaboutus/backend/common/config/SecurityConfig.java` annotation from: + +```java +@EnableConfigurationProperties(CorsProperties.class) +``` + +to: + +```java +@EnableConfigurationProperties({CorsProperties.class, AgreementProperties.class}) +``` + +and add: + +```java +import com.howaboutus.backend.agreements.config.AgreementProperties; +``` + +- [ ] **Step 5: Compile** + +Run: + +```bash +./gradlew compileJava +``` + +Expected: `BUILD SUCCESSFUL`. + +- [ ] **Step 6: Commit** + +```bash +git add src/main/resources/agreements src/main/resources/application.yaml \ + docs/policy/terms-of-service.md docs/policy/privacy-policy.md \ + src/main/java/com/howaboutus/backend/agreements/config/AgreementProperties.java \ + src/main/java/com/howaboutus/backend/common/config/SecurityConfig.java +git commit -m "feat: 약관 원문 리소스와 현재 버전 설정 추가" +``` + +--- + +### Task 2: Agreement Current API + +**Files:** +- Create: `src/main/java/com/howaboutus/backend/agreements/service/dto/AgreementDocumentResult.java` +- Create: `src/main/java/com/howaboutus/backend/agreements/service/dto/AgreementVersions.java` +- Create: `src/main/java/com/howaboutus/backend/agreements/service/AgreementService.java` +- Create: `src/main/java/com/howaboutus/backend/agreements/controller/dto/AgreementDocumentResponse.java` +- Create: `src/main/java/com/howaboutus/backend/agreements/controller/dto/AgreementCurrentResponse.java` +- Create: `src/main/java/com/howaboutus/backend/agreements/controller/AgreementController.java` +- Create: `src/test/java/com/howaboutus/backend/agreements/service/AgreementServiceTest.java` +- Create: `src/test/java/com/howaboutus/backend/agreements/controller/AgreementControllerTest.java` +- Modify: `src/main/java/com/howaboutus/backend/common/config/SecurityConfig.java` +- Modify: `src/main/java/com/howaboutus/backend/common/error/ErrorCode.java` + +- [ ] **Step 1: Add failing AgreementService tests** + +Create `src/test/java/com/howaboutus/backend/agreements/service/AgreementServiceTest.java`: + +```java +package com.howaboutus.backend.agreements.service; + +import static org.assertj.core.api.Assertions.*; + +import java.nio.charset.StandardCharsets; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.core.io.ByteArrayResource; + +import com.howaboutus.backend.agreements.config.AgreementProperties; +import com.howaboutus.backend.common.error.CustomException; +import com.howaboutus.backend.common.error.ErrorCode; + +class AgreementServiceTest { + + @Test + @DisplayName("설정된 현재 이용약관과 개인정보 처리방침을 Markdown으로 반환한다") + void returnsCurrentAgreements() { + AgreementService service = new AgreementService(properties( + "1.0", "# 이용약관", + "1.0", "# 개인정보 처리방침" + )); + + var result = service.getCurrentAgreements(); + + assertThat(result).hasSize(2); + assertThat(result.get(0).type()).isEqualTo("TERMS_OF_SERVICE"); + assertThat(result.get(0).title()).isEqualTo("이용약관"); + assertThat(result.get(0).version()).isEqualTo("1.0"); + assertThat(result.get(0).contentFormat()).isEqualTo("MARKDOWN"); + assertThat(result.get(0).content()).isEqualTo("# 이용약관"); + assertThat(result.get(1).type()).isEqualTo("PRIVACY_POLICY"); + assertThat(result.get(1).title()).isEqualTo("개인정보 처리방침"); + assertThat(result.get(1).version()).isEqualTo("1.0"); + } + + @Test + @DisplayName("약관 버전 설정이 비어 있으면 AGREEMENT_CONFIGURATION_INVALID 예외") + void throwsForBlankVersion() { + AgreementService service = new AgreementService(properties( + "", "# 이용약관", + "1.0", "# 개인정보 처리방침" + )); + + assertThatThrownBy(service::getCurrentAgreements) + .isInstanceOf(CustomException.class) + .satisfies(e -> assertThat(((CustomException)e).getErrorCode()) + .isEqualTo(ErrorCode.AGREEMENT_CONFIGURATION_INVALID)); + } + + private AgreementProperties properties(String tosVersion, String tosContent, + String privacyVersion, String privacyContent) { + return new AgreementProperties( + new AgreementProperties.AgreementDocument( + tosVersion, new ByteArrayResource(tosContent.getBytes(StandardCharsets.UTF_8))), + new AgreementProperties.AgreementDocument( + privacyVersion, new ByteArrayResource(privacyContent.getBytes(StandardCharsets.UTF_8))) + ); + } +} +``` + +- [ ] **Step 2: Run service test to verify failure** + +Run: + +```bash +./gradlew test --tests com.howaboutus.backend.agreements.service.AgreementServiceTest +``` + +Expected: FAIL because `AgreementService` and DTOs do not exist. + +- [ ] **Step 3: Add agreement errors** + +In `src/main/java/com/howaboutus/backend/common/error/ErrorCode.java`, add under `// 400 BAD REQUEST`: + +```java +AGREEMENTS_NOT_ACCEPTED(HttpStatus.BAD_REQUEST, "필수 약관에 동의해야 합니다"), +``` + +Add under `// 401 UNAUTHORIZED`: + +```java +AGREEMENTS_REACCEPTANCE_REQUIRED(HttpStatus.UNAUTHORIZED, "변경된 약관에 다시 동의해야 합니다"), +``` + +Add under `// 503 SERVICE UNAVAILABLE`: + +```java +AGREEMENT_CONFIGURATION_INVALID(HttpStatus.SERVICE_UNAVAILABLE, "약관 설정이 올바르지 않습니다"), +``` + +- [ ] **Step 4: Add service DTOs** + +Create `src/main/java/com/howaboutus/backend/agreements/service/dto/AgreementDocumentResult.java`: + +```java +package com.howaboutus.backend.agreements.service.dto; + +public record AgreementDocumentResult( + String type, + String title, + String version, + String contentFormat, + String content +) { +} +``` + +Create `src/main/java/com/howaboutus/backend/agreements/service/dto/AgreementVersions.java`: + +```java +package com.howaboutus.backend.agreements.service.dto; + +public record AgreementVersions( + String tosVersion, + String privacyVersion +) { +} +``` + +- [ ] **Step 5: Add AgreementService** + +Create `src/main/java/com/howaboutus/backend/agreements/service/AgreementService.java`: + +```java +package com.howaboutus.backend.agreements.service; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.List; + +import org.springframework.stereotype.Service; + +import com.howaboutus.backend.agreements.config.AgreementProperties; +import com.howaboutus.backend.agreements.service.dto.AgreementDocumentResult; +import com.howaboutus.backend.agreements.service.dto.AgreementVersions; +import com.howaboutus.backend.common.error.CustomException; +import com.howaboutus.backend.common.error.ErrorCode; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class AgreementService { + + private static final String CONTENT_FORMAT_MARKDOWN = "MARKDOWN"; + + private final AgreementProperties properties; + + public List getCurrentAgreements() { + return List.of( + document("TERMS_OF_SERVICE", "이용약관", properties.termsOfService()), + document("PRIVACY_POLICY", "개인정보 처리방침", properties.privacyPolicy()) + ); + } + + public AgreementVersions currentVersions() { + return new AgreementVersions( + requireVersion(properties.termsOfService()), + requireVersion(properties.privacyPolicy()) + ); + } + + public void validateAccepted(Boolean accepted) { + if (!Boolean.TRUE.equals(accepted)) { + throw new CustomException(ErrorCode.AGREEMENTS_NOT_ACCEPTED); + } + } + + private AgreementDocumentResult document(String type, String title, + AgreementProperties.AgreementDocument document) { + return new AgreementDocumentResult( + type, + title, + requireVersion(document), + CONTENT_FORMAT_MARKDOWN, + readContent(document) + ); + } + + private String requireVersion(AgreementProperties.AgreementDocument document) { + if (document == null || document.version() == null || document.version().isBlank()) { + throw new CustomException(ErrorCode.AGREEMENT_CONFIGURATION_INVALID); + } + return document.version(); + } + + private String readContent(AgreementProperties.AgreementDocument document) { + if (document == null || document.resource() == null) { + throw new CustomException(ErrorCode.AGREEMENT_CONFIGURATION_INVALID); + } + try { + return document.resource().getContentAsString(StandardCharsets.UTF_8); + } catch (IOException e) { + throw new CustomException(ErrorCode.AGREEMENT_CONFIGURATION_INVALID); + } + } +} +``` + +- [ ] **Step 6: Run service test to verify pass** + +Run: + +```bash +./gradlew test --tests com.howaboutus.backend.agreements.service.AgreementServiceTest +``` + +Expected: PASS. + +- [ ] **Step 7: Add failing controller test** + +Create `src/test/java/com/howaboutus/backend/agreements/controller/AgreementControllerTest.java`: + +```java +package com.howaboutus.backend.agreements.controller; + +import static org.mockito.BDDMockito.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +import java.util.List; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +import com.howaboutus.backend.agreements.service.AgreementService; +import com.howaboutus.backend.agreements.service.dto.AgreementDocumentResult; +import com.howaboutus.backend.auth.filter.JwtAuthenticationFilter; +import com.howaboutus.backend.auth.service.JwtProvider; +import com.howaboutus.backend.common.config.SecurityConfig; +import com.howaboutus.backend.common.error.GlobalExceptionHandler; +import com.howaboutus.backend.common.security.JwtAuthenticationEntryPoint; + +@WebMvcTest(AgreementController.class) +@Import({SecurityConfig.class, JwtAuthenticationFilter.class, JwtAuthenticationEntryPoint.class, + GlobalExceptionHandler.class}) +class AgreementControllerTest { + + @Autowired + private MockMvc mockMvc; + + @MockitoBean + private AgreementService agreementService; + + @MockitoBean + private JwtProvider jwtProvider; + + @Test + @DisplayName("비인증 상태에서 현재 약관을 조회한다") + void returnsCurrentAgreementsWithoutAuthentication() throws Exception { + given(agreementService.getCurrentAgreements()).willReturn(List.of( + new AgreementDocumentResult("TERMS_OF_SERVICE", "이용약관", "1.0", "MARKDOWN", "# 이용약관"), + new AgreementDocumentResult("PRIVACY_POLICY", "개인정보 처리방침", "1.0", "MARKDOWN", "# 개인정보") + )); + + mockMvc.perform(get("/api/agreements/current")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.items[0].type").value("TERMS_OF_SERVICE")) + .andExpect(jsonPath("$.items[0].version").value("1.0")) + .andExpect(jsonPath("$.items[0].contentFormat").value("MARKDOWN")) + .andExpect(jsonPath("$.items[0].content").value("# 이용약관")) + .andExpect(jsonPath("$.items[1].type").value("PRIVACY_POLICY")); + } +} +``` + +- [ ] **Step 8: Run controller test to verify failure** + +Run: + +```bash +./gradlew test --tests com.howaboutus.backend.agreements.controller.AgreementControllerTest +``` + +Expected: FAIL because controller and response DTOs do not exist. + +- [ ] **Step 9: Add controller DTOs and controller** + +Create `src/main/java/com/howaboutus/backend/agreements/controller/dto/AgreementDocumentResponse.java`: + +```java +package com.howaboutus.backend.agreements.controller.dto; + +import com.howaboutus.backend.agreements.service.dto.AgreementDocumentResult; + +import io.swagger.v3.oas.annotations.media.Schema; + +public record AgreementDocumentResponse( + @Schema(description = "약관 문서 타입", example = "TERMS_OF_SERVICE") + String type, + @Schema(description = "약관 제목", example = "이용약관") + String title, + @Schema(description = "현재 약관 버전", example = "1.0") + String version, + @Schema(description = "본문 형식", example = "MARKDOWN") + String contentFormat, + @Schema(description = "약관 원문 Markdown") + String content +) { + + public static AgreementDocumentResponse from(AgreementDocumentResult result) { + return new AgreementDocumentResponse( + result.type(), + result.title(), + result.version(), + result.contentFormat(), + result.content() + ); + } +} +``` + +Create `src/main/java/com/howaboutus/backend/agreements/controller/dto/AgreementCurrentResponse.java`: + +```java +package com.howaboutus.backend.agreements.controller.dto; + +import java.util.List; + +import com.howaboutus.backend.agreements.service.dto.AgreementDocumentResult; + +import io.swagger.v3.oas.annotations.media.Schema; + +public record AgreementCurrentResponse( + @Schema(description = "현재 필수 약관 목록") + List items +) { + + public static AgreementCurrentResponse from(List results) { + return new AgreementCurrentResponse( + results.stream() + .map(AgreementDocumentResponse::from) + .toList() + ); + } +} +``` + +Create `src/main/java/com/howaboutus/backend/agreements/controller/AgreementController.java`: + +```java +package com.howaboutus.backend.agreements.controller; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import com.howaboutus.backend.agreements.controller.dto.AgreementCurrentResponse; +import com.howaboutus.backend.agreements.service.AgreementService; +import com.howaboutus.backend.common.error.ApiErrorCodes; +import com.howaboutus.backend.common.error.ErrorCode; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; + +@Tag(name = "Agreements", description = "약관 API") +@RestController +@RequestMapping("/api/agreements") +@RequiredArgsConstructor +public class AgreementController { + + private final AgreementService agreementService; + + @Operation( + summary = "현재 약관 조회", + description = "가입과 재동의 화면에 표시할 현재 이용약관과 개인정보 처리방침 원문을 조회합니다." + ) + @ApiResponse(responseCode = "200", description = "조회 성공") + @ApiErrorCodes({ErrorCode.AGREEMENT_CONFIGURATION_INVALID}) + @GetMapping("/current") + public ResponseEntity getCurrentAgreements() { + return ResponseEntity.ok(AgreementCurrentResponse.from(agreementService.getCurrentAgreements())); + } +} +``` + +- [ ] **Step 10: Permit current agreements endpoint** + +In `src/main/java/com/howaboutus/backend/common/config/SecurityConfig.java`, add: + +```java +"/api/agreements/current", +``` + +to the existing `.requestMatchers(...).permitAll()` list. + +- [ ] **Step 11: Run controller test to verify pass** + +Run: + +```bash +./gradlew test --tests com.howaboutus.backend.agreements.controller.AgreementControllerTest +``` + +Expected: PASS. + +- [ ] **Step 12: Commit** + +```bash +git add src/main/java/com/howaboutus/backend/agreements \ + src/test/java/com/howaboutus/backend/agreements \ + src/main/java/com/howaboutus/backend/common/config/SecurityConfig.java \ + src/main/java/com/howaboutus/backend/common/error/ErrorCode.java +git commit -m "feat: 현재 약관 조회 API 추가" +``` + +--- + +### Task 3: User Agreement State + +**Files:** +- Modify: `src/main/resources/db/migration/V1__init.sql` +- Modify: `src/main/java/com/howaboutus/backend/user/entity/User.java` +- Modify: `src/main/java/com/howaboutus/backend/user/service/UserService.java` +- Modify: `src/test/java/com/howaboutus/backend/user/service/UserServiceTest.java` +- Create: `src/main/java/com/howaboutus/backend/user/controller/dto/AcceptAgreementsRequest.java` +- Create: `src/main/java/com/howaboutus/backend/user/controller/UserAgreementController.java` +- Create: `src/test/java/com/howaboutus/backend/user/controller/UserAgreementControllerTest.java` + +- [ ] **Step 1: Add failing UserService tests** + +Append to `src/test/java/com/howaboutus/backend/user/service/UserServiceTest.java`: + +```java +@Test +@DisplayName("신규 구글 유저 생성 시 현재 약관 버전과 동의 시각을 저장한다") +void getOrCreateGoogleUserCreatesUserWithAgreements() { + Instant acceptedAt = Instant.parse("2026-06-08T10:00:00Z"); + AgreementVersions versions = new AgreementVersions("1.0", "1.0"); + given(userRepository.findByProviderAndProviderId("GOOGLE", "provider-1")) + .willReturn(Optional.empty()); + given(userRepository.save(any(User.class))).willAnswer(invocation -> invocation.getArgument(0)); + + User result = userService.getOrCreateGoogleUser( + "provider-1", "test@gmail.com", "닉네임", "https://img.url/photo.jpg", versions, acceptedAt); + + assertThat(result.getTosVersion()).isEqualTo("1.0"); + assertThat(result.getTosAcceptedAt()).isEqualTo(acceptedAt); + assertThat(result.getPrivacyVersion()).isEqualTo("1.0"); + assertThat(result.getPrivacyAcceptedAt()).isEqualTo(acceptedAt); +} + +@Test +@DisplayName("현재 사용자 약관 동의를 서버 버전과 서버 시간으로 갱신한다") +void acceptCurrentAgreementsUpdatesUser() { + User user = User.ofGoogle( + "provider-1", "test@gmail.com", "닉네임", null, + new AgreementVersions("1.0", "1.0"), Instant.parse("2026-06-01T10:00:00Z")); + Instant acceptedAt = Instant.parse("2026-06-08T10:00:00Z"); + given(userRepository.findById(1L)).willReturn(Optional.of(user)); + + userService.acceptCurrentAgreements(1L, new AgreementVersions("1.1", "1.0"), acceptedAt); + + assertThat(user.getTosVersion()).isEqualTo("1.1"); + assertThat(user.getTosAcceptedAt()).isEqualTo(acceptedAt); + assertThat(user.getPrivacyVersion()).isEqualTo("1.0"); + assertThat(user.getPrivacyAcceptedAt()).isEqualTo(acceptedAt); +} +``` + +Add imports: + +```java +import java.time.Instant; + +import com.howaboutus.backend.agreements.service.dto.AgreementVersions; +``` + +- [ ] **Step 2: Run UserService test to verify failure** + +Run: + +```bash +./gradlew test --tests com.howaboutus.backend.user.service.UserServiceTest +``` + +Expected: FAIL because new methods and constructors do not exist. + +- [ ] **Step 3: Update users schema** + +In `src/main/resources/db/migration/V1__init.sql`, update the `users` table: + +```sql + provider_id VARCHAR(255) NOT NULL, + tos_version VARCHAR(30) NOT NULL, + tos_accepted_at TIMESTAMP WITH TIME ZONE NOT NULL, + privacy_version VARCHAR(30) NOT NULL, + privacy_accepted_at TIMESTAMP WITH TIME ZONE NOT NULL, + created_at TIMESTAMP WITH TIME ZONE NOT NULL, +``` + +Keep the provider unique constraint unchanged. + +- [ ] **Step 4: Update User entity** + +Modify `src/main/java/com/howaboutus/backend/user/entity/User.java`: + +```java +import java.time.Instant; + +import com.howaboutus.backend.agreements.service.dto.AgreementVersions; +``` + +Add fields: + +```java +@Column(nullable = false, length = 30) +private String tosVersion; + +@Column(nullable = false) +private Instant tosAcceptedAt; + +@Column(nullable = false, length = 30) +private String privacyVersion; + +@Column(nullable = false) +private Instant privacyAcceptedAt; +``` + +Replace the private constructor with: + +```java +private User(String providerId, String email, String nickname, String profileImageUrl, String provider, + AgreementVersions agreementVersions, Instant acceptedAt) { + this.providerId = providerId; + this.email = email; + this.nickname = nickname; + this.profileImageUrl = profileImageUrl; + this.provider = provider; + acceptAgreements(agreementVersions, acceptedAt); +} +``` + +Replace `ofGoogle` with: + +```java +public static User ofGoogle(String providerId, String email, String nickname, String profileImageUrl, + AgreementVersions agreementVersions, Instant acceptedAt) { + return new User(providerId, email, nickname, profileImageUrl, "GOOGLE", agreementVersions, acceptedAt); +} +``` + +Add: + +```java +public void acceptAgreements(AgreementVersions agreementVersions, Instant acceptedAt) { + this.tosVersion = agreementVersions.tosVersion(); + this.tosAcceptedAt = acceptedAt; + this.privacyVersion = agreementVersions.privacyVersion(); + this.privacyAcceptedAt = acceptedAt; +} +``` + +Temporarily keep a test convenience factory for existing tests: + +```java +public static User ofGoogle(String providerId, String email, String nickname, String profileImageUrl) { + return ofGoogle( + providerId, + email, + nickname, + profileImageUrl, + new AgreementVersions("1.0", "1.0"), + Instant.EPOCH + ); +} +``` + +- [ ] **Step 5: Update UserService** + +In `src/main/java/com/howaboutus/backend/user/service/UserService.java`, add imports: + +```java +import java.time.Instant; + +import com.howaboutus.backend.agreements.service.dto.AgreementVersions; +``` + +Add overload: + +```java +@Transactional +public User getOrCreateGoogleUser(String providerId, String email, String nickname, String profileImageUrl, + AgreementVersions agreementVersions, Instant acceptedAt) { + return userRepository.findByProviderAndProviderId("GOOGLE", providerId) + .orElseGet(() -> { + try { + return userRepository.save( + User.ofGoogle( + providerId, + email, + nickname, + profileImageUrl, + agreementVersions, + acceptedAt + ) + ); + } catch (DataIntegrityViolationException e) { + return userRepository.findByProviderAndProviderId("GOOGLE", providerId) + .orElseThrow(() -> e); + } + }); +} +``` + +Keep the existing `getOrCreateGoogleUser(String, String, String, String)` temporarily for older tests and update it to call the overload: + +```java +@Transactional +public User getOrCreateGoogleUser(String providerId, String email, String nickname, String profileImageUrl) { + return getOrCreateGoogleUser( + providerId, + email, + nickname, + profileImageUrl, + new AgreementVersions("1.0", "1.0"), + Instant.EPOCH + ); +} +``` + +Add: + +```java +@Transactional +public void acceptCurrentAgreements(Long userId, AgreementVersions agreementVersions, Instant acceptedAt) { + User user = getUser(userId); + user.acceptAgreements(agreementVersions, acceptedAt); +} +``` + +- [ ] **Step 6: Run UserService test to verify pass** + +Run: + +```bash +./gradlew test --tests com.howaboutus.backend.user.service.UserServiceTest +``` + +Expected: PASS. + +- [ ] **Step 7: Add failing user agreement controller test** + +Create `src/test/java/com/howaboutus/backend/user/controller/UserAgreementControllerTest.java`: + +```java +package com.howaboutus.backend.user.controller; + +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.BDDMockito.*; +import static org.mockito.Mockito.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest; +import org.springframework.context.annotation.Import; +import org.springframework.http.MediaType; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +import com.howaboutus.backend.agreements.service.AgreementService; +import com.howaboutus.backend.agreements.service.dto.AgreementVersions; +import com.howaboutus.backend.auth.filter.JwtAuthenticationFilter; +import com.howaboutus.backend.auth.service.JwtProvider; +import com.howaboutus.backend.common.config.SecurityConfig; +import com.howaboutus.backend.common.error.GlobalExceptionHandler; +import com.howaboutus.backend.common.security.JwtAuthenticationEntryPoint; +import com.howaboutus.backend.user.service.UserService; + +import jakarta.servlet.http.Cookie; + +@WebMvcTest(UserAgreementController.class) +@Import({SecurityConfig.class, JwtAuthenticationFilter.class, JwtAuthenticationEntryPoint.class, + GlobalExceptionHandler.class}) +class UserAgreementControllerTest { + + @Autowired + private MockMvc mockMvc; + + @MockitoBean + private UserService userService; + + @MockitoBean + private AgreementService agreementService; + + @MockitoBean + private JwtProvider jwtProvider; + + @MockitoBean + private Clock clock; + + @Test + @DisplayName("인증된 사용자가 현재 약관에 재동의한다") + void acceptsCurrentAgreementsForAuthenticatedUser() throws Exception { + given(jwtProvider.extractUserId("valid-jwt")).willReturn(1L); + given(agreementService.currentVersions()).willReturn(new AgreementVersions("1.0", "1.0")); + given(clock.instant()).willReturn(Instant.parse("2026-06-08T10:00:00Z")); + given(clock.getZone()).willReturn(ZoneOffset.UTC); + + mockMvc.perform(post("/api/users/me/agreements") + .cookie(new Cookie("access_token", "valid-jwt")) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"agreementsAccepted": true} + """)) + .andExpect(status().isNoContent()); + + verify(userService).acceptCurrentAgreements(eq(1L), any(), any()); + } + + @Test + @DisplayName("인증 없이 현재 약관 재동의 요청 시 401을 반환한다") + void returns401WithoutAuthentication() throws Exception { + mockMvc.perform(post("/api/users/me/agreements") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"agreementsAccepted": true} + """)) + .andExpect(status().isUnauthorized()); + } +} +``` + +- [ ] **Step 8: Run user agreement controller test to verify failure** + +Run: + +```bash +./gradlew test --tests com.howaboutus.backend.user.controller.UserAgreementControllerTest +``` + +Expected: FAIL because request DTO and controller endpoint do not exist. + +- [ ] **Step 9: Add request DTO** + +Create `src/main/java/com/howaboutus/backend/user/controller/dto/AcceptAgreementsRequest.java`: + +```java +package com.howaboutus.backend.user.controller.dto; + +import io.swagger.v3.oas.annotations.media.Schema; + +public record AcceptAgreementsRequest( + @Schema(description = "현재 필수 약관 전체 동의 여부", example = "true") + Boolean agreementsAccepted +) { +} +``` + +- [ ] **Step 10: Add UserAgreementController** + +Create `src/main/java/com/howaboutus/backend/user/controller/UserAgreementController.java`: + +```java +package com.howaboutus.backend.user.controller; + +import java.time.Clock; +import java.time.Instant; + +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import com.howaboutus.backend.agreements.service.AgreementService; +import com.howaboutus.backend.common.error.ApiErrorCodes; +import com.howaboutus.backend.common.error.ErrorCode; +import com.howaboutus.backend.user.controller.dto.AcceptAgreementsRequest; +import com.howaboutus.backend.user.service.UserService; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; + +@Tag(name = "Users", description = "사용자 API") +@RestController +@RequestMapping("/api/users/me/agreements") +@RequiredArgsConstructor +public class UserAgreementController { + + private final UserService userService; + private final AgreementService agreementService; + private final Clock clock; + +@Operation( + summary = "현재 약관 재동의", + description = "현재 인증된 사용자가 서버 기준 현재 이용약관과 개인정보 처리방침에 재동의합니다." +) +@ApiResponse(responseCode = "204", description = "재동의 성공", content = @Content) +@ApiErrorCodes({ + ErrorCode.INVALID_TOKEN, + ErrorCode.ACCESS_TOKEN_EXPIRED, + ErrorCode.USER_NOT_FOUND, + ErrorCode.AGREEMENTS_NOT_ACCEPTED +}) +@PostMapping +public ResponseEntity acceptCurrentAgreements(@AuthenticationPrincipal Long userId, + @RequestBody AcceptAgreementsRequest request) { + agreementService.validateAccepted(request.agreementsAccepted()); + userService.acceptCurrentAgreements(userId, agreementService.currentVersions(), Instant.now(clock)); + return ResponseEntity.noContent().build(); +} + +} +``` + +- [ ] **Step 11: Run user agreement controller test to verify pass** + +Run: + +```bash +./gradlew test --tests com.howaboutus.backend.user.controller.UserAgreementControllerTest +``` + +Expected: PASS. + +- [ ] **Step 12: Commit** + +```bash +git add src/main/resources/db/migration/V1__init.sql \ + src/main/java/com/howaboutus/backend/user \ + src/test/java/com/howaboutus/backend/user +git commit -m "feat: 사용자 약관 동의 상태 저장 추가" +``` + +--- + +### Task 4: Auth Agreement Enforcement + +**Files:** +- Modify: `src/main/java/com/howaboutus/backend/auth/controller/dto/GoogleLoginRequest.java` +- Modify: `src/main/java/com/howaboutus/backend/auth/controller/AuthController.java` +- Modify: `src/main/java/com/howaboutus/backend/auth/service/AuthService.java` +- Modify: `src/test/java/com/howaboutus/backend/auth/service/AuthServiceTest.java` +- Modify: `src/test/java/com/howaboutus/backend/auth/controller/AuthControllerTest.java` + +- [ ] **Step 1: Add failing AuthService tests** + +Modify `src/test/java/com/howaboutus/backend/auth/service/AuthServiceTest.java`. + +Add mocks: + +```java +@Mock +private AgreementService agreementService; + +@Mock +private Clock clock; +``` + +Add imports: + +```java +import java.time.Clock; +import java.time.Instant; + +import com.howaboutus.backend.agreements.service.AgreementService; +import com.howaboutus.backend.agreements.service.dto.AgreementVersions; +import com.howaboutus.backend.common.error.CustomException; +import com.howaboutus.backend.common.error.ErrorCode; +``` + +Update existing `authService.googleLogin("auth-code")` calls to: + +```java +authService.googleLogin("auth-code", true) +``` + +Update stubs for new user creation: + +```java +AgreementVersions versions = new AgreementVersions("1.0", "1.0"); +Instant now = Instant.parse("2026-06-08T10:00:00Z"); +given(agreementService.currentVersions()).willReturn(versions); +given(clock.instant()).willReturn(now); +given(userService.getOrCreateGoogleUser("google-123", "test@gmail.com", "테스트", null, versions, now)) + .willReturn(mockUser); +``` + +Add tests: + +```java +@Test +@DisplayName("신규 사용자가 약관에 동의하지 않으면 가입과 토큰 발급을 거부한다") +void rejectsNewUserWithoutAgreementAcceptance() { + GoogleUserInfo userInfo = new GoogleUserInfo("google-123", "test@gmail.com", "테스트", null); + given(googleOAuthClient.login("auth-code")).willReturn(userInfo); + given(userService.findGoogleUser("google-123")).willReturn(Optional.empty()); + + assertThatThrownBy(() -> authService.googleLogin("auth-code", false)) + .isInstanceOf(CustomException.class) + .satisfies(e -> assertThat(((CustomException)e).getErrorCode()) + .isEqualTo(ErrorCode.AGREEMENTS_NOT_ACCEPTED)); +} + +@Test +@DisplayName("기존 사용자의 약관 버전이 오래됐고 동의하지 않으면 재동의를 요구한다") +void rejectsExistingUserWithStaleAgreementsWithoutAcceptance() { + GoogleUserInfo userInfo = new GoogleUserInfo("google-123", "test@gmail.com", "테스트", null); + User existingUser = User.ofGoogle("google-123", "test@gmail.com", "테스트", null); + given(googleOAuthClient.login("auth-code")).willReturn(userInfo); + given(userService.findGoogleUser("google-123")).willReturn(Optional.of(existingUser)); + given(agreementService.needsReacceptance(existingUser)).willReturn(true); + + assertThatThrownBy(() -> authService.googleLogin("auth-code", false)) + .isInstanceOf(CustomException.class) + .satisfies(e -> assertThat(((CustomException)e).getErrorCode()) + .isEqualTo(ErrorCode.AGREEMENTS_REACCEPTANCE_REQUIRED)); +} + +@Test +@DisplayName("기존 사용자의 약관 버전이 오래됐고 동의하면 갱신 후 토큰을 발급한다") +void updatesStaleAgreementsAndReturnsTokens() { + GoogleUserInfo userInfo = new GoogleUserInfo("google-123", "test@gmail.com", "테스트", null); + User existingUser = User.ofGoogle("google-123", "test@gmail.com", "테스트", null); + AgreementVersions versions = new AgreementVersions("1.1", "1.0"); + Instant now = Instant.parse("2026-06-08T10:00:00Z"); + given(googleOAuthClient.login("auth-code")).willReturn(userInfo); + given(userService.findGoogleUser("google-123")).willReturn(Optional.of(existingUser)); + given(agreementService.needsReacceptance(existingUser)).willReturn(true); + given(agreementService.currentVersions()).willReturn(versions); + given(clock.instant()).willReturn(now); + given(jwtProvider.generateAccessToken(any())).willReturn("jwt-token"); + given(refreshTokenService.create(any())).willReturn("1:refresh-uuid"); + + LoginResult result = authService.googleLogin("auth-code", true); + + verify(userService).acceptCurrentAgreements(existingUser.getId(), versions, now); + assertThat(result.accessToken()).isEqualTo("jwt-token"); + assertThat(result.refreshToken()).isEqualTo("1:refresh-uuid"); +} +``` + +Add: + +```java +import java.util.Optional; +``` + +- [ ] **Step 2: Run AuthService test to verify failure** + +Run: + +```bash +./gradlew test --tests com.howaboutus.backend.auth.service.AuthServiceTest +``` + +Expected: FAIL because `AuthService.googleLogin(String, Boolean)` and `UserService.findGoogleUser` do not exist. + +- [ ] **Step 3: Add UserService lookup method** + +In `src/main/java/com/howaboutus/backend/user/service/UserService.java`, add: + +```java +import java.util.Optional; +``` + +Add method: + +```java +public Optional findGoogleUser(String providerId) { + return userRepository.findByProviderAndProviderId("GOOGLE", providerId); +} +``` + +- [ ] **Step 4: Add agreement reacceptance comparison** + +In `src/main/java/com/howaboutus/backend/agreements/service/AgreementService.java`, add import: + +```java +import com.howaboutus.backend.user.entity.User; +``` + +Add method: + +```java +public boolean needsReacceptance(User user) { + AgreementVersions versions = currentVersions(); + return !versions.tosVersion().equals(user.getTosVersion()) + || !versions.privacyVersion().equals(user.getPrivacyVersion()); +} +``` + +- [ ] **Step 5: Update AuthService** + +Modify `src/main/java/com/howaboutus/backend/auth/service/AuthService.java`. + +Add fields: + +```java +private final AgreementService agreementService; +private final Clock clock; +``` + +Add imports: + +```java +import java.time.Clock; +import java.time.Instant; + +import com.howaboutus.backend.agreements.service.AgreementService; +import com.howaboutus.backend.agreements.service.dto.AgreementVersions; +import com.howaboutus.backend.common.error.CustomException; +import com.howaboutus.backend.common.error.ErrorCode; +``` + +Replace `googleLogin(String authorizationCode)` with: + +```java +@Loggable +@Transactional +public LoginResult googleLogin(String authorizationCode, Boolean agreementsAccepted) { + GoogleUserInfo userInfo = googleOAuthClient.login(authorizationCode); + + User user = userService.findGoogleUser(userInfo.providerId()) + .map(existingUser -> handleExistingUser(existingUser, agreementsAccepted)) + .orElseGet(() -> createNewUser(userInfo, agreementsAccepted)); + + String accessToken = jwtProvider.generateAccessToken(user.getId()); + String refreshToken = refreshTokenService.create(user.getId()); + + return new LoginResult(accessToken, refreshToken, user.getId()); +} + +private User handleExistingUser(User user, Boolean agreementsAccepted) { + if (!agreementService.needsReacceptance(user)) { + return user; + } + if (!Boolean.TRUE.equals(agreementsAccepted)) { + throw new CustomException(ErrorCode.AGREEMENTS_REACCEPTANCE_REQUIRED); + } + userService.acceptCurrentAgreements(user.getId(), agreementService.currentVersions(), Instant.now(clock)); + return user; +} + +private User createNewUser(GoogleUserInfo userInfo, Boolean agreementsAccepted) { + agreementService.validateAccepted(agreementsAccepted); + AgreementVersions versions = agreementService.currentVersions(); + return userService.getOrCreateGoogleUser( + userInfo.providerId(), + userInfo.email(), + userInfo.nickname(), + userInfo.profileImageUrl(), + versions, + Instant.now(clock) + ); +} +``` + +Do not keep the old `googleLogin(String)` method after controller tests are updated. + +- [ ] **Step 6: Run AuthService test** + +Run: + +```bash +./gradlew test --tests com.howaboutus.backend.auth.service.AuthServiceTest +``` + +Expected: PASS after updating any stale stubs to the new flow. + +- [ ] **Step 7: Update GoogleLoginRequest** + +Modify `src/main/java/com/howaboutus/backend/auth/controller/dto/GoogleLoginRequest.java`: + +```java +package com.howaboutus.backend.auth.controller.dto; + +import com.howaboutus.backend.common.logging.MaskField; + +import io.swagger.v3.oas.annotations.media.Schema; + +public record GoogleLoginRequest( + @MaskField + @Schema(description = "Google OAuth2 인가 코드") + String code, + @Schema(description = "현재 필수 약관 전체 동의 여부", example = "true") + Boolean agreementsAccepted +) { +} +``` + +- [ ] **Step 8: Update AuthController** + +In `src/main/java/com/howaboutus/backend/auth/controller/AuthController.java`, change: + +```java +LoginResult result = authService.googleLogin(request.code()); +``` + +to: + +```java +LoginResult result = authService.googleLogin(request.code(), request.agreementsAccepted()); +``` + +Update the Google login `@Operation(description = ...)` to mention that new signups and stale existing users must send `agreementsAccepted=true`. + +Add `@ApiErrorCodes` entries: + +```java +@ApiErrorCodes({ + ErrorCode.GOOGLE_AUTH_FAILED, + ErrorCode.AGREEMENTS_NOT_ACCEPTED, + ErrorCode.AGREEMENTS_REACCEPTANCE_REQUIRED +}) +``` + +- [ ] **Step 9: Update AuthControllerTest** + +In `src/test/java/com/howaboutus/backend/auth/controller/AuthControllerTest.java`, update login JSON: + +```json +{"code": "valid-code", "agreementsAccepted": true} +``` + +and stubbing: + +```java +given(authService.googleLogin("valid-code", true)) +``` + +For the bad-code test: + +```json +{"code": "bad-code", "agreementsAccepted": true} +``` + +and: + +```java +given(authService.googleLogin("bad-code", true)) +``` + +- [ ] **Step 10: Run auth controller test** + +Run: + +```bash +./gradlew test --tests com.howaboutus.backend.auth.controller.AuthControllerTest +``` + +Expected: PASS. + +- [ ] **Step 11: Commit** + +```bash +git add src/main/java/com/howaboutus/backend/auth \ + src/test/java/com/howaboutus/backend/auth \ + src/main/java/com/howaboutus/backend/user/service/UserService.java +git commit -m "feat: 로그인 약관 동의 검증 추가" +``` + +--- + +### Task 5: Docs And API Spec Sync + +**Files:** +- Modify: `docs/ai/features.md` +- Modify: `docs/ai/erd.md` +- Verify: Java Swagger annotations already changed in Tasks 2-4 + +- [ ] **Step 1: Update features.md auth/user section** + +In `docs/ai/features.md`, under the auth/user area around Google OAuth login and profile rows, add or update rows to include: + +```markdown +| `[x]` | 현재 약관 조회 | `GET /api/agreements/current`로 현재 이용약관과 개인정보 처리방침 원문 Markdown, 버전, 문서 타입을 비인증 상태에서 조회한다. 약관 원문은 백엔드 리소스 파일에 있고 현재 버전은 `application.yaml`에서 관리한다 | - | +| `[x]` | 가입 약관 동의 기록 | `POST /auth/google/login`에서 프론트는 `agreementsAccepted`만 전송한다. 신규 사용자는 값이 `true`일 때만 생성되며, 백엔드 현재 약관 버전과 서버 시간을 `users`에 저장한다 | users | +| `[x]` | 약관 재동의 | 기존 사용자의 저장 약관 버전이 현재 서버 버전과 다르면 로그인 시 재동의가 필요하다. 로그인 요청에서 `agreementsAccepted=true`이면 서버 현재 버전으로 갱신 후 토큰을 발급하고, 이미 로그인된 사용자는 `POST /api/users/me/agreements`로 현재 약관에 재동의한다 | users | +``` + +- [ ] **Step 2: Update erd.md users table** + +In `docs/ai/erd.md`, update `users` columns with: + +```markdown +| tos_version | VARCHAR(30) | NOT NULL | 동의한 이용약관 버전 | +| tos_accepted_at | TIMESTAMP WITH TIME ZONE | NOT NULL | 이용약관 동의 또는 재동의 시각 | +| privacy_version | VARCHAR(30) | NOT NULL | 동의한 개인정보 처리방침 버전 | +| privacy_accepted_at | TIMESTAMP WITH TIME ZONE | NOT NULL | 개인정보 처리방침 동의 또는 재동의 시각 | +``` + +Add a short note below the table: + +```markdown +약관 원문은 DB에 저장하지 않고 백엔드 리소스 파일로 관리한다. 현재 버전은 `application.yaml`의 `app.agreements` 설정을 기준으로 하며, 프론트엔드는 버전 문자열을 전송하지 않는다. +``` + +- [ ] **Step 3: Run Markdown conflict check** + +Run: + +```bash +rg -n '`docs/ai/[^`]*\.md`|`[A-Z][A-Z_]*\.md`' -g '*.md' +rg -n "Doc Update Rules|갱신 규칙|불일치|Source Of Truth|문서 경로|Project Docs" AGENTS.md docs/ai/README.md docs/ai/api-spec-guidelines.md docs/ai/plugin-guidelines.md +``` + +Expected: + +- Referenced paths exist. +- `docs/ai/README.md` still delegates update rules to `AGENTS.md`. +- No contradiction between new agreement feature rows and `erd.md` user columns. + +- [ ] **Step 4: Commit** + +```bash +git add docs/ai/features.md docs/ai/erd.md +git commit -m "docs: 약관 동의 기능과 ERD 반영" +``` + +--- + +### Task 6: Final Verification + +**Files:** +- Verify all changed code and docs + +- [ ] **Step 1: Run focused tests** + +Run: + +```bash +./gradlew test --tests com.howaboutus.backend.agreements.service.AgreementServiceTest \ + --tests com.howaboutus.backend.agreements.controller.AgreementControllerTest \ + --tests com.howaboutus.backend.user.service.UserServiceTest \ + --tests com.howaboutus.backend.user.controller.UserAgreementControllerTest \ + --tests com.howaboutus.backend.auth.service.AuthServiceTest \ + --tests com.howaboutus.backend.auth.controller.AuthControllerTest +``` + +Expected: `BUILD SUCCESSFUL`. + +- [ ] **Step 2: Run full tests** + +Run: + +```bash +./gradlew test +``` + +Expected: `BUILD SUCCESSFUL`. + +- [ ] **Step 3: Run checkstyle** + +Run: + +```bash +./gradlew checkstyleMain checkstyleTest +``` + +Expected: `BUILD SUCCESSFUL`. + +- [ ] **Step 4: Review git status** + +Run: + +```bash +git status --short +``` + +Expected: no unstaged implementation changes. If verification generated reports only under `build/`, do not commit them. + +- [ ] **Step 5: Summarize implementation** + +Prepare a concise summary covering: + +```text +- GET /api/agreements/current added and public +- Markdown agreement resources added +- users agreement version/timestamp fields added +- Google login now requires/handles agreementsAccepted +- POST /api/users/me/agreements added for authenticated reacceptance +- docs/ai/features.md and docs/ai/erd.md updated +- Tests and checkstyle commands run +``` From 763785642639221468813d0c8716955100dac2ba Mon Sep 17 00:00:00 2001 From: parkjuyeong0312 Date: Mon, 8 Jun 2026 11:19:41 +0900 Subject: [PATCH 04/12] =?UTF-8?q?docs:=20=ED=83=88=ED=87=B4=20=EC=8B=9C=20?= =?UTF-8?q?=EB=B0=A9=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EB=B3=B4=EA=B4=80=20?= =?UTF-8?q?=EC=A0=95=EC=B1=85=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/ai/README.md | 3 +-- docs/legal/policy-preparation.md | 14 +++++++------- docs/policy/privacy-policy.md | 12 ++++++------ docs/policy/terms-of-service.md | 4 ++-- 4 files changed, 16 insertions(+), 17 deletions(-) diff --git a/docs/ai/README.md b/docs/ai/README.md index 9fcfb4c9..670f75ee 100644 --- a/docs/ai/README.md +++ b/docs/ai/README.md @@ -12,7 +12,7 @@ 4. `AGENTS.md` 5. 외부 문서와 메모 -우선순위가 높은 정보와 낮은 정보가 충돌하면 낮은 쪽을 그대로 따르지 말고 먼저 불일치를 보고한다. +우선순위가 높은 정보와 낮은 정보가 충돌할 때의 처리 기준은 `AGENTS.md`의 **Before You Start** 섹션을 따른다. ## Document Roles @@ -39,7 +39,6 @@ - 각 문서는 자기 책임 범위 안의 사실만 유지한다. - 같은 사실을 여러 문서에 중복으로 길게 적지 않는다. - 아직 확정되지 않은 내용은 확정된 사실처럼 쓰지 않는다. -- 코드와 문서가 어긋나면 문서를 조용히 믿고 진행하지 말고 먼저 불일치를 보고한다. ## Directory Guidance diff --git a/docs/legal/policy-preparation.md b/docs/legal/policy-preparation.md index b7eb8607..99c7a724 100644 --- a/docs/legal/policy-preparation.md +++ b/docs/legal/policy-preparation.md @@ -44,8 +44,8 @@ ### 2-2. 보유·이용 기간 -- 회원 정보: 탈퇴 시까지 (탈퇴 후 즉시 파기 vs 일정 기간 보관 후 파기 — `미결`) -- 여행 방 단위 데이터(방 제목, 채팅, 일정, 북마크, 메모): 방 삭제 시 또는 회원 탈퇴 시 중 먼저 도래한 때까지 +- 회원 정보: 탈퇴 시까지. 탈퇴 시 계정 식별 정보는 삭제 또는 익명화하고 인증 정보는 무효화 +- 여행 방 단위 데이터(방 제목, 채팅, 일정, 북마크, 메모): 방 삭제 시까지. 단, 탈퇴 처리와 함께 방이 삭제되는 경우 함께 삭제 - **법령상 의무 보관 (검토 필요)**: - 통신비밀보호법: 접속 로그 3개월 - 전자상거래법: 소비자 불만/분쟁 처리 기록 3년 — 결제 기능이 없으면 적용 범위가 좁음 @@ -111,13 +111,13 @@ | # | 항목 | 비고 | |---|------|------| -| 1 | 회원 탈퇴 기능 | 인증 섹션에 없음. 탈퇴 시 채팅·일정·북마크 처리 정책 결정 필요 (제거 vs 익명화) | +| 1 | 회원 탈퇴 기능 | 현재 worktree의 인증 섹션에는 없음. `feature/user-withdrawal` 기준 정책은 계정 식별 정보 익명화, 참여 정보 삭제, 방 단위 협업 데이터는 방 삭제 시까지 유지 | | 2 | 계정 정지/차단 운영자 도구 | 방장 추방은 있으나 서비스 차원의 제재 수단 없음 | | 3 | 신고 기능 | 다른 사용자/메시지 신고 API | | 4 | 저작권 게시중단 처리 절차 | 이메일 접수로 시작 가능하나 운영자 검토/작성자 통지/재게시 요청 기록 필요 | | 5 | 약관 동의 이력 저장 | 가입 시 어떤 버전에 동의했는지, 변경 시 재동의 처리 | | 6 | 개인정보 파기 절차 | 탈퇴 후 보관 기간, 자동 파기 배치 | -| 7 | 로그 보관 기간 정책 | 접속 로그는 통신비밀보호법 시행령 기준 3개월로 정리. 채팅 로그는 방 삭제 또는 회원 탈퇴 기준과 구현 일치 필요 | +| 7 | 로그 보관 기간 정책 | 접속 로그는 통신비밀보호법 시행령 기준 3개월로 정리. 채팅 로그는 방 삭제 시까지 보관하는 기준과 구현 일치 필요 | | 8 | 데이터 열람/내보내기 (선택) | 정보주체의 열람권 대응. 이메일 송부로도 대체 가능 | --- @@ -125,11 +125,11 @@ ## 5. 진행 순서 (제안) 1. **정책 결정** (코드 작업보다 먼저) - - 탈퇴 시 채팅 메시지 처리 방식 (제거 vs 익명화) + - 탈퇴 시 계정 식별 정보 익명화와 방 단위 협업 데이터 보관 정책 반영 - 만 14세 미만 정책 (가입 차단으로 단순화 권장) - 신고 처리 SLA - 저작권 게시중단 및 재게시 요청 처리 방식 - - 채팅 보관 기간과 방 삭제/탈퇴 시 처리 방식 + - 채팅 보관 기간과 방 삭제 시 처리 방식 2. **운영자 정보 확정** - 사업자등록 여부, 대표자, 주소, 연락처 - 개인정보 보호책임자 지정 @@ -147,7 +147,7 @@ | # | 항목 | 비고 | |---|------|------| -| 1 | 탈퇴 시 채팅 메시지 처리 | 제거 vs 익명화 vs 보존(분쟁 대응용) | +| 1 | 탈퇴 시 채팅 메시지 처리 | `feature/user-withdrawal` 기준: 방 단위 협업 데이터로 방 삭제 시까지 유지. 탈퇴자의 계정 식별 정보와 참여 정보는 삭제 또는 익명화 | | 2 | 만 14세 미만 정책 | 가입 차단 vs 법정대리인 동의 | | 3 | 채팅 메시지 AI 전송 고지 방식 | 처리 위탁 및 국외 이전 고지로 정리. 실제 가입/AI 호출 UI 고지 방식 결정 필요 | | 4 | 회원 정보 탈퇴 후 보관 기간 | 즉시 파기 vs N일 유예 | diff --git a/docs/policy/privacy-policy.md b/docs/policy/privacy-policy.md index 8d8c6ba3..d53f0637 100644 --- a/docs/policy/privacy-policy.md +++ b/docs/policy/privacy-policy.md @@ -22,9 +22,9 @@ | 법적 근거 | 수집 항목 | 이용 목적 | 보유 기간 | |-----------|-----------|-----------|-----------| -| 개인정보 보호법 제15조 제1항 제4호(계약 이행) | 여행 방 제목, 여행지, 여행 날짜, 초대·참여 정보 | 여행 방 생성·관리, 멤버 협업 기능 제공 | 방 삭제 시 또는 회원 탈퇴 시 중 먼저 도래한 때까지 | -| 개인정보 보호법 제15조 제1항 제4호(계약 이행) | 채팅 메시지, AI 요청 메시지, 장소 공유 메시지 | 실시간 채팅, 메시지 조회, AI 여행 계획 보조 | 방 삭제 시 또는 회원 탈퇴 시 중 먼저 도래한 때까지 | -| 개인정보 보호법 제15조 제1항 제4호(계약 이행) | 여행 일정, 일정 메모, 북마크 장소, 장소 메모 | 여행 계획 작성·수정·공유 | 방 삭제 시 또는 회원 탈퇴 시 중 먼저 도래한 때까지 | +| 개인정보 보호법 제15조 제1항 제4호(계약 이행) | 여행 방 제목, 여행지, 여행 날짜, 초대·참여 정보 | 여행 방 생성·관리, 멤버 협업 기능 제공 | 방 삭제 시까지. 단, 회원 탈퇴 시 탈퇴자의 참여 정보는 삭제되며, 탈퇴 처리와 함께 방이 삭제되는 경우 방 내 데이터도 함께 삭제 | +| 개인정보 보호법 제15조 제1항 제4호(계약 이행) | 채팅 메시지, AI 요청 메시지, 장소 공유 메시지 | 실시간 채팅, 메시지 조회, AI 여행 계획 보조 | 방 삭제 시까지. 단, 탈퇴 처리와 함께 방이 삭제되는 경우 함께 삭제 | +| 개인정보 보호법 제15조 제1항 제4호(계약 이행) | 여행 일정, 일정 메모, 북마크 장소, 장소 메모 | 여행 계획 작성·수정·공유 | 방 삭제 시까지. 단, 탈퇴 처리와 함께 방이 삭제되는 경우 함께 삭제 | | 개인정보 보호법 제15조 제1항 제4호(계약 이행) | 문의·신고 내용, 이메일 주소, 처리에 필요한 추가 정보 | 문의 응대, 신고 처리, 분쟁 대응 | 처리 완료 후 3년 또는 관련 법령상 보관 기간 | ### 서비스 이용 과정에서 자동으로 수집되는 정보 @@ -66,9 +66,9 @@ | 항목 | 보유 기간 | |------|-----------| | 회원 정보(이메일, 이름, 프로필 이미지 URL, Google 계정 고유 식별자) | 회원 탈퇴 시까지 | -| 여행 방 제목, 여행지, 여행 날짜, 초대·참여 정보 | 방 삭제 시 또는 회원 탈퇴 시 중 먼저 도래한 때까지 | -| 채팅 메시지, AI 요청 메시지, 장소 공유 메시지 | 방 삭제 시 또는 회원 탈퇴 시 중 먼저 도래한 때까지 | -| 일정, 메모, 북마크, 장소 카드 등 여행 방 데이터 | 방 삭제 시 또는 회원 탈퇴 시 중 먼저 도래한 때까지 | +| 여행 방 제목, 여행지, 여행 날짜, 초대·참여 정보 | 방 삭제 시까지. 단, 회원 탈퇴 시 탈퇴자의 참여 정보는 삭제되며, 탈퇴 처리와 함께 방이 삭제되는 경우 방 내 데이터도 함께 삭제 | +| 채팅 메시지, AI 요청 메시지, 장소 공유 메시지 | 방 삭제 시까지. 단, 탈퇴 처리와 함께 방이 삭제되는 경우 함께 삭제 | +| 일정, 메모, 북마크, 장소 카드 등 여행 방 데이터 | 방 삭제 시까지. 단, 탈퇴 처리와 함께 방이 삭제되는 경우 함께 삭제 | | 문의·신고 및 분쟁 처리 기록 | 처리 완료 후 3년 | | 접속 로그 | 3개월(통신비밀보호법 시행령상 인터넷 로그기록 보관 기준) | | Access Token 쿠키 | 운영 설정에 따른 만료 시간(현재 운영 기준 30분) | diff --git a/docs/policy/terms-of-service.md b/docs/policy/terms-of-service.md index c56fd963..2db8b1da 100644 --- a/docs/policy/terms-of-service.md +++ b/docs/policy/terms-of-service.md @@ -68,8 +68,8 @@ ## 제7조 (회원 탈퇴 및 계정 삭제) 1. 이용자는 언제든지 서비스 내 탈퇴 기능이 제공되는 경우 해당 기능을 통해 탈퇴할 수 있으며, 기능 제공 전에는 team.uttae@gmail.com을 통해 탈퇴를 요청할 수 있습니다. -2. 탈퇴 처리 시 회원 정보 및 이용자가 작성한 콘텐츠는 삭제됩니다. 단, 법령에 따라 보관이 필요한 정보는 해당 기간 동안 보관 후 삭제합니다. -3. 탈퇴하는 이용자가 방장인 여행 방에 다른 멤버가 없는 경우, 해당 여행 방과 방 내 데이터는 함께 삭제될 수 있습니다. +2. 탈퇴 처리 시 회원의 계정 식별 정보는 삭제 또는 익명화되고 로그인 인증 정보는 무효화됩니다. 다만 여행 방 내 채팅, 일정, 북마크, 메모 등 공동 협업 데이터는 방 삭제 전까지 유지될 수 있으며, 법령에 따라 보관이 필요한 정보는 해당 기간 동안 보관 후 삭제합니다. +3. 탈퇴하는 이용자가 방장인 여행 방에 다른 멤버가 없는 경우, 해당 여행 방과 방 내 데이터는 함께 삭제됩니다. 4. 탈퇴하는 이용자가 방장인 여행 방에 다른 멤버가 있는 경우, 탈퇴 요청은 거절될 수 있습니다. 이 경우 방장은 다른 멤버에게 방장 권한을 위임한 뒤 다시 탈퇴를 요청해야 합니다. --- From c462a0b61b6a8611e69763441109d03f896c2f9a Mon Sep 17 00:00:00 2001 From: parkjuyeong0312 Date: Mon, 8 Jun 2026 11:26:08 +0900 Subject: [PATCH 05/12] =?UTF-8?q?feat:=20=EC=95=BD=EA=B4=80=20=EC=9B=90?= =?UTF-8?q?=EB=AC=B8=20=EB=A6=AC=EC=86=8C=EC=8A=A4=EC=99=80=20=ED=98=84?= =?UTF-8?q?=EC=9E=AC=20=EB=B2=84=EC=A0=84=20=EC=84=A4=EC=A0=95=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/policy/privacy-policy.md | 211 +----------------- docs/policy/terms-of-service.md | 177 +-------------- .../config/AgreementProperties.java | 17 ++ .../backend/common/config/SecurityConfig.java | 3 +- .../resources/agreements/privacy-policy.md | 211 ++++++++++++++++++ .../resources/agreements/terms-of-service.md | 177 +++++++++++++++ src/main/resources/application.yaml | 7 + 7 files changed, 420 insertions(+), 383 deletions(-) create mode 100644 src/main/java/com/howaboutus/backend/agreements/config/AgreementProperties.java create mode 100644 src/main/resources/agreements/privacy-policy.md create mode 100644 src/main/resources/agreements/terms-of-service.md diff --git a/docs/policy/privacy-policy.md b/docs/policy/privacy-policy.md index d53f0637..0b60b45a 100644 --- a/docs/policy/privacy-policy.md +++ b/docs/policy/privacy-policy.md @@ -1,211 +1,6 @@ # 개인정보 처리방침 -우때(이하 "서비스")는 이용자의 개인정보를 소중히 여기며, 「개인정보 보호법」 등 관련 법령을 준수합니다. 이 처리방침은 서비스가 어떤 개인정보를 어떤 목적으로 처리하는지, 이용자가 어떤 권리를 행사할 수 있는지 안내합니다. +원문은 백엔드 약관 리소스로 이동했습니다. -**시행일: 2026년 6월 7일** - ---- - -## 1. 개인정보 수집 항목 및 이용 목적 - -서비스는 이용 목적에 필요한 최소한의 개인정보를 수집·이용합니다. - -### 회원 가입 및 계정 관리 - -서비스는 Google 소셜 로그인을 통해 가입과 로그인을 제공합니다. - -| 법적 근거 | 수집 항목 | 이용 목적 | 보유 기간 | -|-----------|-----------|-----------|-----------| -| 개인정보 보호법 제15조 제1항 제4호(계약 이행) | 이메일 주소, 이름, 프로필 이미지 URL, Google 계정 고유 식별자 | 계정 식별, 중복 가입 방지, 로그인, 서비스 내 프로필 표시, 공지 전달 | 회원 탈퇴 시까지 | - -### 서비스 이용 과정에서 이용자가 입력하는 정보 - -| 법적 근거 | 수집 항목 | 이용 목적 | 보유 기간 | -|-----------|-----------|-----------|-----------| -| 개인정보 보호법 제15조 제1항 제4호(계약 이행) | 여행 방 제목, 여행지, 여행 날짜, 초대·참여 정보 | 여행 방 생성·관리, 멤버 협업 기능 제공 | 방 삭제 시까지. 단, 회원 탈퇴 시 탈퇴자의 참여 정보는 삭제되며, 탈퇴 처리와 함께 방이 삭제되는 경우 방 내 데이터도 함께 삭제 | -| 개인정보 보호법 제15조 제1항 제4호(계약 이행) | 채팅 메시지, AI 요청 메시지, 장소 공유 메시지 | 실시간 채팅, 메시지 조회, AI 여행 계획 보조 | 방 삭제 시까지. 단, 탈퇴 처리와 함께 방이 삭제되는 경우 함께 삭제 | -| 개인정보 보호법 제15조 제1항 제4호(계약 이행) | 여행 일정, 일정 메모, 북마크 장소, 장소 메모 | 여행 계획 작성·수정·공유 | 방 삭제 시까지. 단, 탈퇴 처리와 함께 방이 삭제되는 경우 함께 삭제 | -| 개인정보 보호법 제15조 제1항 제4호(계약 이행) | 문의·신고 내용, 이메일 주소, 처리에 필요한 추가 정보 | 문의 응대, 신고 처리, 분쟁 대응 | 처리 완료 후 3년 또는 관련 법령상 보관 기간 | - -### 서비스 이용 과정에서 자동으로 수집되는 정보 - -| 법적 근거 | 수집 항목 | 이용 목적 | 보유 기간 | -|-----------|-----------|-----------|-----------| -| 개인정보 보호법 제15조 제1항 제4호(계약 이행) | IP 주소, 접속 일시, User-Agent, 요청 경로, 기기 및 브라우저 정보 | 보안, 부정 이용 탐지, 장애 대응, 접속 기록 관리 | 접속 로그 3개월 | -| 개인정보 보호법 제15조 제1항 제4호(계약 이행) | `access_token`, `refresh_token` 쿠키 | 로그인 인증, 자동 로그인 유지, WebSocket 인증 | Access Token: 운영 설정에 따른 만료 시간(현재 운영 기준 30분) / Refresh Token: 14일 | -| 개인정보 보호법 제15조 제1항 제4호(계약 이행) | 서비스 이용 기록, 요청 횟수, Rate Limit 처리 기록 | 서비스 안정성 확보, 도배·남용 방지 | 목적 달성 시 또는 관련 캐시 만료 시까지 | -| 개인정보 보호법 제15조 제1항 제1호(동의) 또는 제15조 제1항 제4호(계약 이행) | Google Analytics 쿠키, 페이지뷰, 이벤트, 기기·브라우저 정보 | 서비스 이용 통계 분석, 서비스 개선 | Google Analytics 보관 설정 및 쿠키 보존 기간에 따름 | - -### 장소·경로 검색 시 처리되는 정보 - -서비스는 Google Places/Routes API를 이용해 장소 검색, 장소 상세 정보, 사진, 이동 경로 정보를 제공합니다. - -| 처리 항목 | 이용 목적 | 보관 여부 | -|-----------|-----------|-----------| -| 검색어, Google 장소 ID, 좌표, 이동수단, 출발·도착 장소 ID | 장소 검색, 장소 상세 조회, 이동 경로 조회 | 서비스 DB에 위치 이력으로 저장하지 않음 | - ---- - -## 2. 이용자의 동의 없는 이용 및 제공 - -서비스는 원칙적으로 이용자의 동의 없이 개인정보를 목적 외로 이용하거나 제3자에게 제공하지 않습니다. 다만 「개인정보 보호법」 등 관련 법령에 따라 다음의 경우에는 동의 없이 이용하거나 제공할 수 있습니다. - -1. 법률에 특별한 규정이 있거나 법령상 의무를 준수하기 위해 불가피한 경우 -2. 이용자와 체결한 서비스 이용계약을 이행하거나 계약 체결 과정에서 이용자의 요청에 따른 조치를 이행하기 위해 필요한 경우 -3. 이용자 또는 제3자의 생명, 신체, 재산의 급박한 이익을 위해 필요하다고 인정되는 경우 -4. 서비스의 정당한 이익을 달성하기 위해 필요한 경우로서 이용자의 권리를 부당하게 침해하지 않는 경우 -5. 수집 목적과 합리적으로 관련된 범위에서 이용자의 이익을 부당하게 침해하지 않고 추가 이용 또는 제공이 가능한 경우 -6. 수사기관, 법원, 감독기관 등이 적법한 절차에 따라 요청하는 경우 - ---- - -## 3. 개인정보의 보유기간 및 파기 - -서비스는 개인정보의 처리 목적이 달성되거나 보유 기간이 경과하면 지체 없이 해당 개인정보를 파기합니다. 다만 법령에 따라 보관이 필요한 정보는 해당 기간 동안 별도로 분리하여 보관한 뒤 파기합니다. - -| 항목 | 보유 기간 | -|------|-----------| -| 회원 정보(이메일, 이름, 프로필 이미지 URL, Google 계정 고유 식별자) | 회원 탈퇴 시까지 | -| 여행 방 제목, 여행지, 여행 날짜, 초대·참여 정보 | 방 삭제 시까지. 단, 회원 탈퇴 시 탈퇴자의 참여 정보는 삭제되며, 탈퇴 처리와 함께 방이 삭제되는 경우 방 내 데이터도 함께 삭제 | -| 채팅 메시지, AI 요청 메시지, 장소 공유 메시지 | 방 삭제 시까지. 단, 탈퇴 처리와 함께 방이 삭제되는 경우 함께 삭제 | -| 일정, 메모, 북마크, 장소 카드 등 여행 방 데이터 | 방 삭제 시까지. 단, 탈퇴 처리와 함께 방이 삭제되는 경우 함께 삭제 | -| 문의·신고 및 분쟁 처리 기록 | 처리 완료 후 3년 | -| 접속 로그 | 3개월(통신비밀보호법 시행령상 인터넷 로그기록 보관 기준) | -| Access Token 쿠키 | 운영 설정에 따른 만료 시간(현재 운영 기준 30분) | -| Refresh Token 쿠키 및 Redis 세션 정보 | 14일 | -| Rate Limit 및 일시 캐시 정보 | 각 캐시·제한 정책의 만료 시까지 | - -파기 방법은 다음과 같습니다. - -1. 전자적 파일: 복구 또는 재생이 어렵도록 삭제 -2. 종이 문서: 분쇄 또는 소각 - -## 4. 만 14세 미만 아동의 개인정보 - -서비스는 만 14세 미만 아동의 가입을 허용하지 않습니다. 만 14세 미만 이용자의 가입 또는 개인정보 처리가 확인되는 경우 해당 계정과 개인정보를 지체 없이 삭제합니다. - -서비스는 현재 법정대리인 동의 절차를 운영하지 않습니다. - ---- - -## 5. 개인정보의 제3자 제공 - -서비스는 이용자의 개인정보를 원칙적으로 제3자에게 제공하지 않습니다. 다만 이용자가 사전에 동의한 경우 또는 관련 법령에 근거가 있는 경우에는 예외적으로 제공할 수 있습니다. - ---- - -## 6. 개인정보 처리의 위탁 및 국외 이전 - -서비스는 원활한 운영을 위해 다음과 같이 개인정보 처리 업무를 외부 사업자에게 위탁하거나 국외로 이전할 수 있습니다. - -| 수탁자 | 국가 | 연락처 | 위탁 업무 | 이전 또는 처리 항목 | 이전 방법 | 보유·이용 기간 | -|--------|------|--------|-----------|---------------------|-----------|----------------| -| Amazon Web Services | 서비스 운영 리전 | [AWS Privacy Notice](https://aws.amazon.com/privacy/) | 서버 인프라 운영, 데이터 보관 | 서비스 내 전체 데이터 | 서비스 이용 시 네트워크를 통한 자동 전송 및 저장 | 서비스 운영 기간 또는 위탁계약 종료 시까지 | -| Google LLC | 미국 | [Google Privacy Policy](https://policies.google.com/privacy) | Google 소셜 로그인, 장소·경로 API, 서비스 이용 통계 분석(Google Analytics) | Google 계정 정보, 검색어, 장소 ID, 좌표, IP 주소, 쿠키, 서비스 이용 기록 | 서비스 이용 시 네트워크를 통한 자동 전송(API 호출 등) | Google 정책 및 서비스 설정에 따름 | -| OpenAI, L.L.C. | 미국 | [OpenAI Privacy Policy](https://openai.com/policies/privacy-policy) | AI 응답 생성, 대화 요약 생성 | 채팅 메시지, AI 요청 메시지, 여행 일정·북마크 요약, 장소 정보 요약 | AI 기능 이용 시 네트워크를 통한 자동 전송(API 호출 등) | 처리 목적 달성 시까지 또는 위탁계약 종료 시까지 | - -AI 기능 관련 안내: 이용자가 AI 어시스턴트에게 메시지를 전송하면 해당 메시지와 여행 방의 일정, 북마크, 채팅 요약 정보가 AI 응답 생성을 위해 처리될 수 있습니다. 같은 여행 방 내 다른 멤버의 메시지와 장소 정보가 요약 정보에 포함될 수 있으므로, 민감한 개인정보는 AI 어시스턴트에게 입력하지 않는 것이 좋습니다. - -국외 이전을 원하지 않는 이용자는 외부 서비스가 필요한 기능, 특히 AI 기능, 장소·경로 검색, Google Analytics가 포함된 서비스 이용을 제한하거나 서비스 이용을 중단할 수 있습니다. - ---- - -## 7. 이용자의 권리와 행사 방법 - -이용자는 언제든지 다음 권리를 행사할 수 있습니다. - -1. 개인정보 열람 요구 -2. 오류 등이 있는 경우 정정 요구 -3. 삭제 요구 -4. 처리정지 요구 -5. 동의 철회 - -권리 행사는 team.uttae@gmail.com으로 요청할 수 있습니다. 서비스는 본인 확인 후 지체 없이 처리합니다. - -다음의 경우에는 법령에 따라 권리 행사가 제한될 수 있습니다. - -1. 법령상 의무 준수를 위해 보관이 필요한 경우 -2. 다른 사람의 생명, 신체, 재산 또는 권리를 침해할 우려가 있는 경우 -3. 개인정보를 처리하지 않으면 이용자와의 서비스 이용계약을 이행하기 어려운 경우 -4. 다른 법령에서 해당 개인정보의 수집 또는 보관을 요구하는 경우 - -이용자는 본인의 계정 정보와 인증 수단을 안전하게 관리해야 하며, 타인의 개인정보를 무단으로 수집·공개·훼손해서는 안 됩니다. - ---- - -## 8. 개인정보 보호책임자 및 권익침해 구제방법 - -서비스는 개인정보 처리와 관련한 문의, 불만, 피해 구제를 위해 아래 연락처를 운영합니다. - -| 항목 | 내용 | -|------|------| -| 개인정보 보호책임자 | 박주영 | -| 이메일 | team.uttae@gmail.com | - -개인정보와 관련된 불만 또는 피해 구제는 아래 기관에도 신청할 수 있습니다. - -| 기관 | 연락처 | -|------|--------| -| 개인정보 침해신고센터 | [privacy.kisa.or.kr](https://privacy.kisa.or.kr) / 국번없이 118 | -| 개인정보 분쟁조정위원회 | [www.kopico.go.kr](https://www.kopico.go.kr) / 1833-6972 | -| 경찰청 사이버범죄 신고시스템 | [ecrm.police.go.kr](https://ecrm.police.go.kr) / 국번없이 182 | - ---- - -## 9. 개인정보의 안전성 확보 조치 - -서비스는 개인정보의 안전성 확보를 위해 다음 조치를 시행합니다. - -1. 관리적 조치: 개인정보 접근 권한 최소화, 운영자 접근 관리, 내부 점검 -2. 기술적 조치: HTTPS 통신, 인증 토큰 HttpOnly 쿠키 적용, 비밀번호 없는 Google OAuth 로그인, 접근 권한 관리, 민감 로그 마스킹, Rate Limit을 통한 남용 방지 -3. 저장·접근 보호: PostgreSQL, MongoDB, Redis 접근 권한 분리, 운영 환경 비밀값 분리 관리 -4. 로그 보호: 토큰, 인증 코드 등 민감 정보가 로그에 남지 않도록 마스킹 - ---- - -## 10. 쿠키의 설치·운영 및 거부 - -쿠키는 웹사이트가 이용자의 브라우저에 저장하는 소량의 정보입니다. 서비스는 로그인 인증과 이용 통계 분석을 위해 쿠키를 사용할 수 있습니다. - -| 쿠키명 | 목적 | 보존 기간 | -|--------|------|-----------| -| `access_token` | 로그인 인증, API 및 WebSocket 인증 | 운영 설정에 따른 만료 시간(현재 운영 기준 30분) | -| `refresh_token` | 자동 로그인 유지, Access Token 재발급 | 14일 | -| `_ga`, `_ga_*` | Google Analytics 이용자 구분 및 통계 분석 | 최대 2년 | -| `_gid` | Google Analytics 세션 구분 | 24시간 | - -이용자는 브라우저 설정에서 쿠키 저장을 거부할 수 있습니다. 다만 `access_token` 및 `refresh_token` 쿠키를 거부하면 로그인이 필요한 기능을 이용할 수 없습니다. - -Google Analytics 수집만 선택적으로 차단하려면 [Google Analytics 수집 거부 플러그인](https://tools.google.com/dlpage/gaoptout)을 사용할 수 있습니다. - ---- - -## 11. 맞춤형 광고 및 행태정보 - -서비스는 현재 자체 맞춤형 광고를 제공하지 않습니다. - -다만 Google Analytics 등 분석 도구를 통해 페이지뷰, 이벤트, 기기·브라우저 정보, 쿠키 기반 식별자 등 행태정보가 수집될 수 있으며, 이는 서비스 이용 통계 분석과 서비스 개선 목적으로 사용됩니다. - -향후 맞춤형 광고 또는 제3자 광고 도구를 도입하는 경우, 수집 항목, 이용 목적, 보유 기간, 거부 방법을 이 처리방침 또는 별도 안내를 통해 고지하고 필요한 경우 동의를 받겠습니다. - ---- - -## 12. 개인위치정보 및 연계정보 처리 - -서비스는 현재 「위치정보의 보호 및 이용 등에 관한 법률」상 개인위치정보를 지속적으로 수집·저장하거나 위치 이력을 관리하지 않습니다. - -서비스는 현재 본인확인기관을 통한 연계정보(Connecting Information, CI)를 수집·저장하지 않습니다. - -향후 개인위치정보 또는 CI를 처리하는 기능을 도입하는 경우, 관련 법령에 따라 별도 동의 절차와 처리방침을 마련하겠습니다. - ---- - -## 13. 처리방침의 변경 - -서비스는 법령, 서비스 기능, 개인정보 처리 방식의 변경에 따라 이 처리방침을 개정할 수 있습니다. - -처리방침을 변경하는 경우 시행 7일 전 서비스 공지 또는 정책 문서 갱신을 통해 안내합니다. 이용자의 권리에 중대한 영향을 미치는 변경은 30일 전에 고지합니다. - -| 버전 | 시행일 | -|------|--------| -| 1.0 | 2026년 6월 7일 | +- 리소스: `src/main/resources/agreements/privacy-policy.md` +- 현재 버전 설정: `src/main/resources/application.yaml`의 `app.agreements.privacy-policy.version` diff --git a/docs/policy/terms-of-service.md b/docs/policy/terms-of-service.md index 2db8b1da..166bcde7 100644 --- a/docs/policy/terms-of-service.md +++ b/docs/policy/terms-of-service.md @@ -1,177 +1,6 @@ # 이용약관 -**시행일: 2026년 6월 8일** +원문은 백엔드 약관 리소스로 이동했습니다. ---- - -## 제1조 (목적) - -이 약관은 우때(이하 "서비스")가 제공하는 여행 계획 협업 서비스의 이용 조건과 절차, 이용자와 운영자 간의 권리·의무 및 책임 사항을 정함을 목적으로 합니다. - -서비스의 개인정보 처리에 관한 사항은 별도의 [개인정보 처리방침](privacy-policy.md)에 따릅니다. 서비스 이용 제한, 신고, 이의제기 등 세부 운영 기준은 [운영정책](operation-policy.md)에 따르며, 저작권 침해 신고와 게시중단 절차는 [저작권 정책](copyright-policy.md)에 따릅니다. 이용자는 이 약관과 관련 정책을 확인하고 동의한 뒤 서비스를 이용해야 합니다. - ---- - -## 제2조 (정의) - -이 약관에서 사용하는 용어의 정의는 다음과 같습니다. - -1. **서비스**: "우때" 또는 "실시간 협업 여행 플래너"라는 이름으로 제공되는 여행 계획 협업 웹 서비스 및 이에 부속하는 기능 일체 -2. **이용자**: Google 계정으로 로그인하거나, 이 약관에 따라 서비스를 이용하는 자 -3. **여행 방**: 이용자가 생성하거나 초대받아 참여하는 여행 계획 협업 공간으로, 서비스 화면에서 "여행 방", "새 여행 계획", "방" 등으로 표시될 수 있습니다. -4. **방장(HOST)**: 여행 방을 생성하거나 권한을 위임받은 이용자로, 서비스 화면에서 "방장" 또는 "HOST" 배지로 표시될 수 있습니다. 방장이 아닌 참여자는 일반 멤버입니다. -5. **AI 어시스턴트**: 여행 방 채팅에서 "WOORI", "@ai" 등으로 호출되며 여행 계획 수립을 보조하는 인공지능 기능 -6. **북마크**: 이용자가 여행 방에서 저장한 장소 정보로, 일정 작성과 여행 계획 협업에 활용됩니다. -7. **콘텐츠**: 이용자가 서비스 내에서 작성·등록·공유하는 채팅 메시지, 일정, 일차, 메모, 체류 시간, 장소 카드, 북마크, 프로필 정보 등 일체 -8. **외부 서비스**: Google, OpenAI 등 서비스 제공을 위해 연동되는 제3자 서비스 - ---- - -## 제3조 (약관의 효력 및 변경) - -1. 이 약관은 서비스 가입 시 동의함으로써 효력이 발생합니다. -2. 운영자는 약관을 변경할 경우 시행 7일 전 이용자가 제공한 이메일 또는 서비스 내 공지사항을 통해 안내합니다. 이용자의 권리에 중대한 영향을 미치는 변경은 30일 전에 이메일 또는 서비스 내 공지사항을 통해 고지합니다. -3. 변경된 약관의 시행일 이후에도 서비스를 계속 이용하면 변경 약관에 동의한 것으로 봅니다. 변경 약관에 동의하지 않는 이용자는 서비스 이용을 중단하고 탈퇴할 수 있습니다. - ---- - -## 제4조 (서비스의 제공) - -서비스는 다음 기능을 제공합니다. - -1. 여행 방 생성·관리, 초대 코드를 통한 멤버 초대 -2. 여행 일정 및 장소 협업 편집 (일정, 북마크, 지도 연동) -3. 여행 방 내 실시간 채팅 -4. AI 어시스턴트를 통한 여행 계획 수립 보조 -5. Google 장소 검색 및 이동 경로 정보 조회 - ---- - -## 제5조 (회원 가입) - -1. 서비스는 Google 계정을 통한 소셜 로그인으로만 가입할 수 있습니다. -2. 만 14세 미만은 서비스에 가입할 수 없습니다. -3. 타인의 Google 계정을 도용하거나 허위 정보로 가입하는 행위는 금지됩니다. -4. 이용자는 가입 및 서비스 이용 과정에서 정확하고 최신의 정보를 제공해야 합니다. - ---- - -## 제6조 (계정 관리) - -1. 이용자는 본인의 계정과 로그인 수단을 안전하게 관리할 책임이 있습니다. -2. 이용자는 본인 계정에서 발생하는 활동에 대해 책임을 집니다. 다만, 운영자의 고의 또는 중대한 과실로 발생한 경우는 제외합니다. -3. 계정의 무단 사용, 보안 침해, 제3자의 접근이 의심되는 경우 이용자는 지체 없이 team.uttae@gmail.com으로 알려야 합니다. -4. 이용자는 타인을 사칭하거나, 타인의 권리를 침해하거나, 불쾌감·혐오감을 유발할 수 있는 이름·프로필 정보를 사용할 수 없습니다. - ---- - -## 제7조 (회원 탈퇴 및 계정 삭제) - -1. 이용자는 언제든지 서비스 내 탈퇴 기능이 제공되는 경우 해당 기능을 통해 탈퇴할 수 있으며, 기능 제공 전에는 team.uttae@gmail.com을 통해 탈퇴를 요청할 수 있습니다. -2. 탈퇴 처리 시 회원의 계정 식별 정보는 삭제 또는 익명화되고 로그인 인증 정보는 무효화됩니다. 다만 여행 방 내 채팅, 일정, 북마크, 메모 등 공동 협업 데이터는 방 삭제 전까지 유지될 수 있으며, 법령에 따라 보관이 필요한 정보는 해당 기간 동안 보관 후 삭제합니다. -3. 탈퇴하는 이용자가 방장인 여행 방에 다른 멤버가 없는 경우, 해당 여행 방과 방 내 데이터는 함께 삭제됩니다. -4. 탈퇴하는 이용자가 방장인 여행 방에 다른 멤버가 있는 경우, 탈퇴 요청은 거절될 수 있습니다. 이 경우 방장은 다른 멤버에게 방장 권한을 위임한 뒤 다시 탈퇴를 요청해야 합니다. - ---- - -## 제8조 (이용자의 의무 및 금지 행위) - -이용자는 서비스를 이용함에 있어 다음 행위를 하여서는 안 됩니다. - -1. 타인의 명예를 훼손하거나 개인정보를 무단으로 수집·유포하는 행위 -2. 음란물, 폭력, 혐오 표현, 차별적 언행, 괴롭힘, 위협 등 불법·유해한 콘텐츠를 게시하는 행위 -3. 허위 장소 정보, 허위 후기, 오해를 유발하는 일정·메모 등 다른 이용자의 여행 계획을 방해할 수 있는 정보를 고의로 게시하는 행위 -4. 미성년자를 대상으로 위해를 가하거나 부적절한 콘텐츠에 노출시키는 행위 -5. 채팅 도배, 광고성 메시지, 스팸, 피싱, 악성 링크 전송 행위 -6. 자동화 프로그램(봇), 스크래퍼, 크롤러 등을 사용하여 운영자의 허가 없이 서비스 또는 콘텐츠에 접근·수집·복제하는 행위 -7. 서비스의 서버, 네트워크, 보안 기능을 방해하거나 과도한 부하를 유발하는 행위 -8. 바이러스, 악성 코드, 비정상 요청 등 서비스 또는 다른 이용자의 기기에 해를 줄 수 있는 자료를 전송하는 행위 -9. 타인의 계정으로 로그인하거나 초대 코드를 무단으로 수집·배포하는 행위 -10. 서비스를 역분석하거나, 비공개 API를 무단 호출하거나, 운영자가 제공하지 않는 방식으로 서비스에 접근하는 행위 -11. 운영자 또는 제3자의 지식재산권, 초상권, 개인정보, 기타 권리를 침해하는 행위 -12. 관련 법령을 위반하거나 서비스의 정상적인 운영을 방해하는 일체의 행위 - ---- - -## 제9조 (유해 콘텐츠 신고 및 조치) - -1. 이용자는 불법·유해 콘텐츠, 권리 침해 콘텐츠, 서비스 운영을 방해하는 콘텐츠를 발견한 경우 team.uttae@gmail.com으로 신고할 수 있습니다. -2. 운영자는 신고된 콘텐츠 또는 운영 과정에서 발견한 콘텐츠가 이 약관 또는 관련 법령을 위반한다고 판단하는 경우 해당 콘텐츠를 숨김·삭제하거나 작성자의 서비스 이용을 제한할 수 있습니다. -3. 저작권 등 권리 침해 신고, 게시중단, 재게시 요청에 관한 세부 절차는 [저작권 정책](copyright-policy.md)에 따릅니다. -4. 운영자는 위반 여부 확인을 위해 필요한 범위에서 콘텐츠를 검토할 수 있으나, 모든 콘텐츠를 사전에 검토할 의무를 부담하지 않습니다. - ---- - -## 제10조 (서비스 이용 제한) - -1. 운영자는 이용자가 제8조 또는 제9조를 위반한 경우 사전 통보 없이 서비스 이용을 제한하거나 계정을 삭제할 수 있습니다. -2. 운영자는 위반 행위의 내용, 반복 여부, 피해 규모, 긴급성을 고려하여 게시물 삭제, 채팅 제한, 여행 방 접근 제한, 계정 정지 또는 계정 삭제 조치를 할 수 있습니다. 세부 기준은 [운영정책](operation-policy.md)에 따릅니다. -3. 이용 제한 처분에 이의가 있는 경우 team.uttae@gmail.com으로 이의를 신청할 수 있으며, 운영자는 7일 이내에 처리 결과를 안내합니다. - ---- - -## 제11조 (콘텐츠에 대한 권리) - -1. 이용자가 서비스 내에서 작성·등록한 콘텐츠의 저작권은 해당 이용자에게 있습니다. -2. 이용자는 서비스 운영, 저장, 백업, 공유, 실시간 동기화, AI 어시스턴트 응답 생성 등 기능 제공에 필요한 범위에서 운영자가 콘텐츠를 이용할 수 있도록 비독점적·무상의 사용권을 부여합니다. -3. 이용자는 본인이 작성·등록한 콘텐츠에 대해 필요한 권리를 보유하고 있으며, 해당 콘텐츠가 제3자의 권리 또는 관련 법령을 침해하지 않음을 보증합니다. -4. 이용자가 작성·등록한 콘텐츠가 제3자의 권리를 침해한다는 신고 또는 이의제기가 있는 경우, 운영자는 관련 법령과 [저작권 정책](copyright-policy.md)에 따라 게시중단, 삭제, 접근 제한, 서비스 이용 제한 등 필요한 조치를 할 수 있습니다. -5. 운영자는 이용자의 콘텐츠를 광고·마케팅 목적으로 사용하지 않습니다. -6. 운영자가 제공하는 서비스 화면, 기능, 로고, 디자인, 데이터베이스, 프로그램 등 서비스 자체의 권리는 운영자 또는 정당한 권리자에게 귀속됩니다. - ---- - -## 제12조 (AI 어시스턴트 이용 안내) - -1. AI 어시스턴트의 응답은 참고 목적으로만 제공되며, 정확성·완전성을 보장하지 않습니다. -2. AI 어시스턴트가 추천하는 장소, 일정, 경로 정보는 실제와 다를 수 있으므로, 중요한 사항은 반드시 공식 정보를 직접 확인하시기 바랍니다. -3. AI 어시스턴트에게 메시지를 전송하면 채팅 내용이 외부 AI 서비스(OpenAI)로 전달됩니다. 민감한 개인정보는 AI 어시스턴트에게 입력하지 않도록 주의하시기 바랍니다. -4. 이용자는 AI 어시스턴트의 응답을 여행 예약, 결제, 안전, 법률, 의료 등 중요한 의사결정의 유일한 근거로 사용해서는 안 됩니다. - ---- - -## 제13조 (외부 서비스 및 외부 정보에 관한 고지) - -1. 서비스 내 장소 정보(영업시간, 평점, 주소, 사진 등)는 Google 등 외부 서비스가 제공하는 데이터로, 운영자가 정확성을 보장하지 않습니다. 방문 전 해당 장소에 직접 확인하시기 바랍니다. -2. 서비스의 장소 검색, 지도, 장소 사진, 이동 경로 등 Google Maps 기능 및 콘텐츠를 이용하는 경우, 이용자에게 Google Maps/Google Earth 추가 서비스 약관([https://maps.google.com/help/terms_maps/](https://maps.google.com/help/terms_maps/)) 및 Google 개인정보처리방침([https://policies.google.com/privacy](https://policies.google.com/privacy))이 적용됩니다. -3. 서비스는 외부 웹사이트 또는 외부 서비스로 연결되는 링크를 포함할 수 있습니다. 운영자는 외부 웹사이트 또는 외부 서비스의 내용, 정책, 이용 가능성, 안전성을 보증하지 않습니다. -4. 외부 서비스 이용에는 해당 외부 서비스의 약관과 정책이 적용될 수 있습니다. - ---- - -## 제14조 (오류 제보 및 피드백) - -1. 이용자는 서비스 오류, 개선 제안, 아이디어, 불만 사항 등을 team.uttae@gmail.com으로 전달할 수 있습니다. -2. 이용자가 피드백을 제공하는 경우, 운영자는 별도의 보상 없이 서비스 개선, 문제 해결, 기능 개발 목적으로 해당 피드백을 이용할 수 있습니다. -3. 피드백에 개인정보나 제3자의 비밀 정보가 포함되지 않도록 주의해야 합니다. - ---- - -## 제15조 (서비스의 변경 및 중단) - -1. 운영자는 서비스 내용을 변경하거나 서비스를 중단할 수 있습니다. -2. 서비스를 전부 중단하는 경우 30일 전 이메일 또는 서비스 내 공지사항을 통해 안내합니다. 다만, 장애 대응, 보안 사고, 외부 서비스 장애, 긴급 점검 등 부득이한 경우 사후에 안내할 수 있습니다. -3. 서비스는 현재 무료로 제공되며, 운영자는 서비스 변경 또는 중단으로 인한 손해에 대해 관련 법령상 책임이 인정되는 경우를 제외하고 별도의 보상 의무를 지지 않습니다. - ---- - -## 제16조 (보증의 부인 및 책임의 제한) - -1. 서비스는 현재 상태와 제공 가능한 범위에서 제공됩니다. 운영자는 서비스가 항상 중단 없이 제공되거나, 모든 오류가 수정되거나, 모든 정보가 정확하다고 보증하지 않습니다. -2. 운영자는 천재지변, 서비스 장애, 네트워크 문제, 외부 서비스 장애, 이용자의 귀책 사유 등 운영자의 합리적 통제 범위를 벗어난 사유로 인한 서비스 중단 또는 손해에 대해 책임을 지지 않습니다. -3. 운영자는 이용자 간 또는 이용자와 제3자 간의 분쟁에 개입할 의무를 부담하지 않으며, 관련 법령상 책임이 인정되는 경우를 제외하고 이에 대한 책임을 지지 않습니다. -4. 운영자는 무료로 제공되는 서비스의 이용과 관련하여 고의 또는 중대한 과실이 없는 한 손해배상 책임을 지지 않습니다. - ---- - -## 제17조 (준거법 및 관할) - -1. 이 약관은 대한민국 법률에 따라 해석·적용됩니다. -2. 서비스 이용과 관련한 분쟁은 운영자와 이용자가 성실히 협의하여 해결합니다. -3. 협의로 해결되지 않는 경우 「민사소송법」에 따른 관할 법원을 전속 관할로 합니다. - ---- - -## 부칙 - -이 약관은 2026년 6월 8일부터 시행합니다. +- 리소스: `src/main/resources/agreements/terms-of-service.md` +- 현재 버전 설정: `src/main/resources/application.yaml`의 `app.agreements.terms-of-service.version` diff --git a/src/main/java/com/howaboutus/backend/agreements/config/AgreementProperties.java b/src/main/java/com/howaboutus/backend/agreements/config/AgreementProperties.java new file mode 100644 index 00000000..8a125f6c --- /dev/null +++ b/src/main/java/com/howaboutus/backend/agreements/config/AgreementProperties.java @@ -0,0 +1,17 @@ +package com.howaboutus.backend.agreements.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.core.io.Resource; + +@ConfigurationProperties(prefix = "app.agreements") +public record AgreementProperties( + AgreementDocument termsOfService, + AgreementDocument privacyPolicy +) { + + public record AgreementDocument( + String version, + Resource resource + ) { + } +} diff --git a/src/main/java/com/howaboutus/backend/common/config/SecurityConfig.java b/src/main/java/com/howaboutus/backend/common/config/SecurityConfig.java index 5e6124da..df4fc349 100644 --- a/src/main/java/com/howaboutus/backend/common/config/SecurityConfig.java +++ b/src/main/java/com/howaboutus/backend/common/config/SecurityConfig.java @@ -16,6 +16,7 @@ import org.springframework.web.cors.CorsConfigurationSource; import org.springframework.web.cors.UrlBasedCorsConfigurationSource; +import com.howaboutus.backend.agreements.config.AgreementProperties; import com.howaboutus.backend.auth.filter.JwtAuthenticationFilter; import com.howaboutus.backend.common.config.properties.CorsProperties; import com.howaboutus.backend.common.ratelimit.HttpRateLimitFilter; @@ -25,7 +26,7 @@ @Configuration @RequiredArgsConstructor -@EnableConfigurationProperties(CorsProperties.class) +@EnableConfigurationProperties({CorsProperties.class, AgreementProperties.class}) public class SecurityConfig { private final CorsProperties corsProperties; diff --git a/src/main/resources/agreements/privacy-policy.md b/src/main/resources/agreements/privacy-policy.md new file mode 100644 index 00000000..d53f0637 --- /dev/null +++ b/src/main/resources/agreements/privacy-policy.md @@ -0,0 +1,211 @@ +# 개인정보 처리방침 + +우때(이하 "서비스")는 이용자의 개인정보를 소중히 여기며, 「개인정보 보호법」 등 관련 법령을 준수합니다. 이 처리방침은 서비스가 어떤 개인정보를 어떤 목적으로 처리하는지, 이용자가 어떤 권리를 행사할 수 있는지 안내합니다. + +**시행일: 2026년 6월 7일** + +--- + +## 1. 개인정보 수집 항목 및 이용 목적 + +서비스는 이용 목적에 필요한 최소한의 개인정보를 수집·이용합니다. + +### 회원 가입 및 계정 관리 + +서비스는 Google 소셜 로그인을 통해 가입과 로그인을 제공합니다. + +| 법적 근거 | 수집 항목 | 이용 목적 | 보유 기간 | +|-----------|-----------|-----------|-----------| +| 개인정보 보호법 제15조 제1항 제4호(계약 이행) | 이메일 주소, 이름, 프로필 이미지 URL, Google 계정 고유 식별자 | 계정 식별, 중복 가입 방지, 로그인, 서비스 내 프로필 표시, 공지 전달 | 회원 탈퇴 시까지 | + +### 서비스 이용 과정에서 이용자가 입력하는 정보 + +| 법적 근거 | 수집 항목 | 이용 목적 | 보유 기간 | +|-----------|-----------|-----------|-----------| +| 개인정보 보호법 제15조 제1항 제4호(계약 이행) | 여행 방 제목, 여행지, 여행 날짜, 초대·참여 정보 | 여행 방 생성·관리, 멤버 협업 기능 제공 | 방 삭제 시까지. 단, 회원 탈퇴 시 탈퇴자의 참여 정보는 삭제되며, 탈퇴 처리와 함께 방이 삭제되는 경우 방 내 데이터도 함께 삭제 | +| 개인정보 보호법 제15조 제1항 제4호(계약 이행) | 채팅 메시지, AI 요청 메시지, 장소 공유 메시지 | 실시간 채팅, 메시지 조회, AI 여행 계획 보조 | 방 삭제 시까지. 단, 탈퇴 처리와 함께 방이 삭제되는 경우 함께 삭제 | +| 개인정보 보호법 제15조 제1항 제4호(계약 이행) | 여행 일정, 일정 메모, 북마크 장소, 장소 메모 | 여행 계획 작성·수정·공유 | 방 삭제 시까지. 단, 탈퇴 처리와 함께 방이 삭제되는 경우 함께 삭제 | +| 개인정보 보호법 제15조 제1항 제4호(계약 이행) | 문의·신고 내용, 이메일 주소, 처리에 필요한 추가 정보 | 문의 응대, 신고 처리, 분쟁 대응 | 처리 완료 후 3년 또는 관련 법령상 보관 기간 | + +### 서비스 이용 과정에서 자동으로 수집되는 정보 + +| 법적 근거 | 수집 항목 | 이용 목적 | 보유 기간 | +|-----------|-----------|-----------|-----------| +| 개인정보 보호법 제15조 제1항 제4호(계약 이행) | IP 주소, 접속 일시, User-Agent, 요청 경로, 기기 및 브라우저 정보 | 보안, 부정 이용 탐지, 장애 대응, 접속 기록 관리 | 접속 로그 3개월 | +| 개인정보 보호법 제15조 제1항 제4호(계약 이행) | `access_token`, `refresh_token` 쿠키 | 로그인 인증, 자동 로그인 유지, WebSocket 인증 | Access Token: 운영 설정에 따른 만료 시간(현재 운영 기준 30분) / Refresh Token: 14일 | +| 개인정보 보호법 제15조 제1항 제4호(계약 이행) | 서비스 이용 기록, 요청 횟수, Rate Limit 처리 기록 | 서비스 안정성 확보, 도배·남용 방지 | 목적 달성 시 또는 관련 캐시 만료 시까지 | +| 개인정보 보호법 제15조 제1항 제1호(동의) 또는 제15조 제1항 제4호(계약 이행) | Google Analytics 쿠키, 페이지뷰, 이벤트, 기기·브라우저 정보 | 서비스 이용 통계 분석, 서비스 개선 | Google Analytics 보관 설정 및 쿠키 보존 기간에 따름 | + +### 장소·경로 검색 시 처리되는 정보 + +서비스는 Google Places/Routes API를 이용해 장소 검색, 장소 상세 정보, 사진, 이동 경로 정보를 제공합니다. + +| 처리 항목 | 이용 목적 | 보관 여부 | +|-----------|-----------|-----------| +| 검색어, Google 장소 ID, 좌표, 이동수단, 출발·도착 장소 ID | 장소 검색, 장소 상세 조회, 이동 경로 조회 | 서비스 DB에 위치 이력으로 저장하지 않음 | + +--- + +## 2. 이용자의 동의 없는 이용 및 제공 + +서비스는 원칙적으로 이용자의 동의 없이 개인정보를 목적 외로 이용하거나 제3자에게 제공하지 않습니다. 다만 「개인정보 보호법」 등 관련 법령에 따라 다음의 경우에는 동의 없이 이용하거나 제공할 수 있습니다. + +1. 법률에 특별한 규정이 있거나 법령상 의무를 준수하기 위해 불가피한 경우 +2. 이용자와 체결한 서비스 이용계약을 이행하거나 계약 체결 과정에서 이용자의 요청에 따른 조치를 이행하기 위해 필요한 경우 +3. 이용자 또는 제3자의 생명, 신체, 재산의 급박한 이익을 위해 필요하다고 인정되는 경우 +4. 서비스의 정당한 이익을 달성하기 위해 필요한 경우로서 이용자의 권리를 부당하게 침해하지 않는 경우 +5. 수집 목적과 합리적으로 관련된 범위에서 이용자의 이익을 부당하게 침해하지 않고 추가 이용 또는 제공이 가능한 경우 +6. 수사기관, 법원, 감독기관 등이 적법한 절차에 따라 요청하는 경우 + +--- + +## 3. 개인정보의 보유기간 및 파기 + +서비스는 개인정보의 처리 목적이 달성되거나 보유 기간이 경과하면 지체 없이 해당 개인정보를 파기합니다. 다만 법령에 따라 보관이 필요한 정보는 해당 기간 동안 별도로 분리하여 보관한 뒤 파기합니다. + +| 항목 | 보유 기간 | +|------|-----------| +| 회원 정보(이메일, 이름, 프로필 이미지 URL, Google 계정 고유 식별자) | 회원 탈퇴 시까지 | +| 여행 방 제목, 여행지, 여행 날짜, 초대·참여 정보 | 방 삭제 시까지. 단, 회원 탈퇴 시 탈퇴자의 참여 정보는 삭제되며, 탈퇴 처리와 함께 방이 삭제되는 경우 방 내 데이터도 함께 삭제 | +| 채팅 메시지, AI 요청 메시지, 장소 공유 메시지 | 방 삭제 시까지. 단, 탈퇴 처리와 함께 방이 삭제되는 경우 함께 삭제 | +| 일정, 메모, 북마크, 장소 카드 등 여행 방 데이터 | 방 삭제 시까지. 단, 탈퇴 처리와 함께 방이 삭제되는 경우 함께 삭제 | +| 문의·신고 및 분쟁 처리 기록 | 처리 완료 후 3년 | +| 접속 로그 | 3개월(통신비밀보호법 시행령상 인터넷 로그기록 보관 기준) | +| Access Token 쿠키 | 운영 설정에 따른 만료 시간(현재 운영 기준 30분) | +| Refresh Token 쿠키 및 Redis 세션 정보 | 14일 | +| Rate Limit 및 일시 캐시 정보 | 각 캐시·제한 정책의 만료 시까지 | + +파기 방법은 다음과 같습니다. + +1. 전자적 파일: 복구 또는 재생이 어렵도록 삭제 +2. 종이 문서: 분쇄 또는 소각 + +## 4. 만 14세 미만 아동의 개인정보 + +서비스는 만 14세 미만 아동의 가입을 허용하지 않습니다. 만 14세 미만 이용자의 가입 또는 개인정보 처리가 확인되는 경우 해당 계정과 개인정보를 지체 없이 삭제합니다. + +서비스는 현재 법정대리인 동의 절차를 운영하지 않습니다. + +--- + +## 5. 개인정보의 제3자 제공 + +서비스는 이용자의 개인정보를 원칙적으로 제3자에게 제공하지 않습니다. 다만 이용자가 사전에 동의한 경우 또는 관련 법령에 근거가 있는 경우에는 예외적으로 제공할 수 있습니다. + +--- + +## 6. 개인정보 처리의 위탁 및 국외 이전 + +서비스는 원활한 운영을 위해 다음과 같이 개인정보 처리 업무를 외부 사업자에게 위탁하거나 국외로 이전할 수 있습니다. + +| 수탁자 | 국가 | 연락처 | 위탁 업무 | 이전 또는 처리 항목 | 이전 방법 | 보유·이용 기간 | +|--------|------|--------|-----------|---------------------|-----------|----------------| +| Amazon Web Services | 서비스 운영 리전 | [AWS Privacy Notice](https://aws.amazon.com/privacy/) | 서버 인프라 운영, 데이터 보관 | 서비스 내 전체 데이터 | 서비스 이용 시 네트워크를 통한 자동 전송 및 저장 | 서비스 운영 기간 또는 위탁계약 종료 시까지 | +| Google LLC | 미국 | [Google Privacy Policy](https://policies.google.com/privacy) | Google 소셜 로그인, 장소·경로 API, 서비스 이용 통계 분석(Google Analytics) | Google 계정 정보, 검색어, 장소 ID, 좌표, IP 주소, 쿠키, 서비스 이용 기록 | 서비스 이용 시 네트워크를 통한 자동 전송(API 호출 등) | Google 정책 및 서비스 설정에 따름 | +| OpenAI, L.L.C. | 미국 | [OpenAI Privacy Policy](https://openai.com/policies/privacy-policy) | AI 응답 생성, 대화 요약 생성 | 채팅 메시지, AI 요청 메시지, 여행 일정·북마크 요약, 장소 정보 요약 | AI 기능 이용 시 네트워크를 통한 자동 전송(API 호출 등) | 처리 목적 달성 시까지 또는 위탁계약 종료 시까지 | + +AI 기능 관련 안내: 이용자가 AI 어시스턴트에게 메시지를 전송하면 해당 메시지와 여행 방의 일정, 북마크, 채팅 요약 정보가 AI 응답 생성을 위해 처리될 수 있습니다. 같은 여행 방 내 다른 멤버의 메시지와 장소 정보가 요약 정보에 포함될 수 있으므로, 민감한 개인정보는 AI 어시스턴트에게 입력하지 않는 것이 좋습니다. + +국외 이전을 원하지 않는 이용자는 외부 서비스가 필요한 기능, 특히 AI 기능, 장소·경로 검색, Google Analytics가 포함된 서비스 이용을 제한하거나 서비스 이용을 중단할 수 있습니다. + +--- + +## 7. 이용자의 권리와 행사 방법 + +이용자는 언제든지 다음 권리를 행사할 수 있습니다. + +1. 개인정보 열람 요구 +2. 오류 등이 있는 경우 정정 요구 +3. 삭제 요구 +4. 처리정지 요구 +5. 동의 철회 + +권리 행사는 team.uttae@gmail.com으로 요청할 수 있습니다. 서비스는 본인 확인 후 지체 없이 처리합니다. + +다음의 경우에는 법령에 따라 권리 행사가 제한될 수 있습니다. + +1. 법령상 의무 준수를 위해 보관이 필요한 경우 +2. 다른 사람의 생명, 신체, 재산 또는 권리를 침해할 우려가 있는 경우 +3. 개인정보를 처리하지 않으면 이용자와의 서비스 이용계약을 이행하기 어려운 경우 +4. 다른 법령에서 해당 개인정보의 수집 또는 보관을 요구하는 경우 + +이용자는 본인의 계정 정보와 인증 수단을 안전하게 관리해야 하며, 타인의 개인정보를 무단으로 수집·공개·훼손해서는 안 됩니다. + +--- + +## 8. 개인정보 보호책임자 및 권익침해 구제방법 + +서비스는 개인정보 처리와 관련한 문의, 불만, 피해 구제를 위해 아래 연락처를 운영합니다. + +| 항목 | 내용 | +|------|------| +| 개인정보 보호책임자 | 박주영 | +| 이메일 | team.uttae@gmail.com | + +개인정보와 관련된 불만 또는 피해 구제는 아래 기관에도 신청할 수 있습니다. + +| 기관 | 연락처 | +|------|--------| +| 개인정보 침해신고센터 | [privacy.kisa.or.kr](https://privacy.kisa.or.kr) / 국번없이 118 | +| 개인정보 분쟁조정위원회 | [www.kopico.go.kr](https://www.kopico.go.kr) / 1833-6972 | +| 경찰청 사이버범죄 신고시스템 | [ecrm.police.go.kr](https://ecrm.police.go.kr) / 국번없이 182 | + +--- + +## 9. 개인정보의 안전성 확보 조치 + +서비스는 개인정보의 안전성 확보를 위해 다음 조치를 시행합니다. + +1. 관리적 조치: 개인정보 접근 권한 최소화, 운영자 접근 관리, 내부 점검 +2. 기술적 조치: HTTPS 통신, 인증 토큰 HttpOnly 쿠키 적용, 비밀번호 없는 Google OAuth 로그인, 접근 권한 관리, 민감 로그 마스킹, Rate Limit을 통한 남용 방지 +3. 저장·접근 보호: PostgreSQL, MongoDB, Redis 접근 권한 분리, 운영 환경 비밀값 분리 관리 +4. 로그 보호: 토큰, 인증 코드 등 민감 정보가 로그에 남지 않도록 마스킹 + +--- + +## 10. 쿠키의 설치·운영 및 거부 + +쿠키는 웹사이트가 이용자의 브라우저에 저장하는 소량의 정보입니다. 서비스는 로그인 인증과 이용 통계 분석을 위해 쿠키를 사용할 수 있습니다. + +| 쿠키명 | 목적 | 보존 기간 | +|--------|------|-----------| +| `access_token` | 로그인 인증, API 및 WebSocket 인증 | 운영 설정에 따른 만료 시간(현재 운영 기준 30분) | +| `refresh_token` | 자동 로그인 유지, Access Token 재발급 | 14일 | +| `_ga`, `_ga_*` | Google Analytics 이용자 구분 및 통계 분석 | 최대 2년 | +| `_gid` | Google Analytics 세션 구분 | 24시간 | + +이용자는 브라우저 설정에서 쿠키 저장을 거부할 수 있습니다. 다만 `access_token` 및 `refresh_token` 쿠키를 거부하면 로그인이 필요한 기능을 이용할 수 없습니다. + +Google Analytics 수집만 선택적으로 차단하려면 [Google Analytics 수집 거부 플러그인](https://tools.google.com/dlpage/gaoptout)을 사용할 수 있습니다. + +--- + +## 11. 맞춤형 광고 및 행태정보 + +서비스는 현재 자체 맞춤형 광고를 제공하지 않습니다. + +다만 Google Analytics 등 분석 도구를 통해 페이지뷰, 이벤트, 기기·브라우저 정보, 쿠키 기반 식별자 등 행태정보가 수집될 수 있으며, 이는 서비스 이용 통계 분석과 서비스 개선 목적으로 사용됩니다. + +향후 맞춤형 광고 또는 제3자 광고 도구를 도입하는 경우, 수집 항목, 이용 목적, 보유 기간, 거부 방법을 이 처리방침 또는 별도 안내를 통해 고지하고 필요한 경우 동의를 받겠습니다. + +--- + +## 12. 개인위치정보 및 연계정보 처리 + +서비스는 현재 「위치정보의 보호 및 이용 등에 관한 법률」상 개인위치정보를 지속적으로 수집·저장하거나 위치 이력을 관리하지 않습니다. + +서비스는 현재 본인확인기관을 통한 연계정보(Connecting Information, CI)를 수집·저장하지 않습니다. + +향후 개인위치정보 또는 CI를 처리하는 기능을 도입하는 경우, 관련 법령에 따라 별도 동의 절차와 처리방침을 마련하겠습니다. + +--- + +## 13. 처리방침의 변경 + +서비스는 법령, 서비스 기능, 개인정보 처리 방식의 변경에 따라 이 처리방침을 개정할 수 있습니다. + +처리방침을 변경하는 경우 시행 7일 전 서비스 공지 또는 정책 문서 갱신을 통해 안내합니다. 이용자의 권리에 중대한 영향을 미치는 변경은 30일 전에 고지합니다. + +| 버전 | 시행일 | +|------|--------| +| 1.0 | 2026년 6월 7일 | diff --git a/src/main/resources/agreements/terms-of-service.md b/src/main/resources/agreements/terms-of-service.md new file mode 100644 index 00000000..2db8b1da --- /dev/null +++ b/src/main/resources/agreements/terms-of-service.md @@ -0,0 +1,177 @@ +# 이용약관 + +**시행일: 2026년 6월 8일** + +--- + +## 제1조 (목적) + +이 약관은 우때(이하 "서비스")가 제공하는 여행 계획 협업 서비스의 이용 조건과 절차, 이용자와 운영자 간의 권리·의무 및 책임 사항을 정함을 목적으로 합니다. + +서비스의 개인정보 처리에 관한 사항은 별도의 [개인정보 처리방침](privacy-policy.md)에 따릅니다. 서비스 이용 제한, 신고, 이의제기 등 세부 운영 기준은 [운영정책](operation-policy.md)에 따르며, 저작권 침해 신고와 게시중단 절차는 [저작권 정책](copyright-policy.md)에 따릅니다. 이용자는 이 약관과 관련 정책을 확인하고 동의한 뒤 서비스를 이용해야 합니다. + +--- + +## 제2조 (정의) + +이 약관에서 사용하는 용어의 정의는 다음과 같습니다. + +1. **서비스**: "우때" 또는 "실시간 협업 여행 플래너"라는 이름으로 제공되는 여행 계획 협업 웹 서비스 및 이에 부속하는 기능 일체 +2. **이용자**: Google 계정으로 로그인하거나, 이 약관에 따라 서비스를 이용하는 자 +3. **여행 방**: 이용자가 생성하거나 초대받아 참여하는 여행 계획 협업 공간으로, 서비스 화면에서 "여행 방", "새 여행 계획", "방" 등으로 표시될 수 있습니다. +4. **방장(HOST)**: 여행 방을 생성하거나 권한을 위임받은 이용자로, 서비스 화면에서 "방장" 또는 "HOST" 배지로 표시될 수 있습니다. 방장이 아닌 참여자는 일반 멤버입니다. +5. **AI 어시스턴트**: 여행 방 채팅에서 "WOORI", "@ai" 등으로 호출되며 여행 계획 수립을 보조하는 인공지능 기능 +6. **북마크**: 이용자가 여행 방에서 저장한 장소 정보로, 일정 작성과 여행 계획 협업에 활용됩니다. +7. **콘텐츠**: 이용자가 서비스 내에서 작성·등록·공유하는 채팅 메시지, 일정, 일차, 메모, 체류 시간, 장소 카드, 북마크, 프로필 정보 등 일체 +8. **외부 서비스**: Google, OpenAI 등 서비스 제공을 위해 연동되는 제3자 서비스 + +--- + +## 제3조 (약관의 효력 및 변경) + +1. 이 약관은 서비스 가입 시 동의함으로써 효력이 발생합니다. +2. 운영자는 약관을 변경할 경우 시행 7일 전 이용자가 제공한 이메일 또는 서비스 내 공지사항을 통해 안내합니다. 이용자의 권리에 중대한 영향을 미치는 변경은 30일 전에 이메일 또는 서비스 내 공지사항을 통해 고지합니다. +3. 변경된 약관의 시행일 이후에도 서비스를 계속 이용하면 변경 약관에 동의한 것으로 봅니다. 변경 약관에 동의하지 않는 이용자는 서비스 이용을 중단하고 탈퇴할 수 있습니다. + +--- + +## 제4조 (서비스의 제공) + +서비스는 다음 기능을 제공합니다. + +1. 여행 방 생성·관리, 초대 코드를 통한 멤버 초대 +2. 여행 일정 및 장소 협업 편집 (일정, 북마크, 지도 연동) +3. 여행 방 내 실시간 채팅 +4. AI 어시스턴트를 통한 여행 계획 수립 보조 +5. Google 장소 검색 및 이동 경로 정보 조회 + +--- + +## 제5조 (회원 가입) + +1. 서비스는 Google 계정을 통한 소셜 로그인으로만 가입할 수 있습니다. +2. 만 14세 미만은 서비스에 가입할 수 없습니다. +3. 타인의 Google 계정을 도용하거나 허위 정보로 가입하는 행위는 금지됩니다. +4. 이용자는 가입 및 서비스 이용 과정에서 정확하고 최신의 정보를 제공해야 합니다. + +--- + +## 제6조 (계정 관리) + +1. 이용자는 본인의 계정과 로그인 수단을 안전하게 관리할 책임이 있습니다. +2. 이용자는 본인 계정에서 발생하는 활동에 대해 책임을 집니다. 다만, 운영자의 고의 또는 중대한 과실로 발생한 경우는 제외합니다. +3. 계정의 무단 사용, 보안 침해, 제3자의 접근이 의심되는 경우 이용자는 지체 없이 team.uttae@gmail.com으로 알려야 합니다. +4. 이용자는 타인을 사칭하거나, 타인의 권리를 침해하거나, 불쾌감·혐오감을 유발할 수 있는 이름·프로필 정보를 사용할 수 없습니다. + +--- + +## 제7조 (회원 탈퇴 및 계정 삭제) + +1. 이용자는 언제든지 서비스 내 탈퇴 기능이 제공되는 경우 해당 기능을 통해 탈퇴할 수 있으며, 기능 제공 전에는 team.uttae@gmail.com을 통해 탈퇴를 요청할 수 있습니다. +2. 탈퇴 처리 시 회원의 계정 식별 정보는 삭제 또는 익명화되고 로그인 인증 정보는 무효화됩니다. 다만 여행 방 내 채팅, 일정, 북마크, 메모 등 공동 협업 데이터는 방 삭제 전까지 유지될 수 있으며, 법령에 따라 보관이 필요한 정보는 해당 기간 동안 보관 후 삭제합니다. +3. 탈퇴하는 이용자가 방장인 여행 방에 다른 멤버가 없는 경우, 해당 여행 방과 방 내 데이터는 함께 삭제됩니다. +4. 탈퇴하는 이용자가 방장인 여행 방에 다른 멤버가 있는 경우, 탈퇴 요청은 거절될 수 있습니다. 이 경우 방장은 다른 멤버에게 방장 권한을 위임한 뒤 다시 탈퇴를 요청해야 합니다. + +--- + +## 제8조 (이용자의 의무 및 금지 행위) + +이용자는 서비스를 이용함에 있어 다음 행위를 하여서는 안 됩니다. + +1. 타인의 명예를 훼손하거나 개인정보를 무단으로 수집·유포하는 행위 +2. 음란물, 폭력, 혐오 표현, 차별적 언행, 괴롭힘, 위협 등 불법·유해한 콘텐츠를 게시하는 행위 +3. 허위 장소 정보, 허위 후기, 오해를 유발하는 일정·메모 등 다른 이용자의 여행 계획을 방해할 수 있는 정보를 고의로 게시하는 행위 +4. 미성년자를 대상으로 위해를 가하거나 부적절한 콘텐츠에 노출시키는 행위 +5. 채팅 도배, 광고성 메시지, 스팸, 피싱, 악성 링크 전송 행위 +6. 자동화 프로그램(봇), 스크래퍼, 크롤러 등을 사용하여 운영자의 허가 없이 서비스 또는 콘텐츠에 접근·수집·복제하는 행위 +7. 서비스의 서버, 네트워크, 보안 기능을 방해하거나 과도한 부하를 유발하는 행위 +8. 바이러스, 악성 코드, 비정상 요청 등 서비스 또는 다른 이용자의 기기에 해를 줄 수 있는 자료를 전송하는 행위 +9. 타인의 계정으로 로그인하거나 초대 코드를 무단으로 수집·배포하는 행위 +10. 서비스를 역분석하거나, 비공개 API를 무단 호출하거나, 운영자가 제공하지 않는 방식으로 서비스에 접근하는 행위 +11. 운영자 또는 제3자의 지식재산권, 초상권, 개인정보, 기타 권리를 침해하는 행위 +12. 관련 법령을 위반하거나 서비스의 정상적인 운영을 방해하는 일체의 행위 + +--- + +## 제9조 (유해 콘텐츠 신고 및 조치) + +1. 이용자는 불법·유해 콘텐츠, 권리 침해 콘텐츠, 서비스 운영을 방해하는 콘텐츠를 발견한 경우 team.uttae@gmail.com으로 신고할 수 있습니다. +2. 운영자는 신고된 콘텐츠 또는 운영 과정에서 발견한 콘텐츠가 이 약관 또는 관련 법령을 위반한다고 판단하는 경우 해당 콘텐츠를 숨김·삭제하거나 작성자의 서비스 이용을 제한할 수 있습니다. +3. 저작권 등 권리 침해 신고, 게시중단, 재게시 요청에 관한 세부 절차는 [저작권 정책](copyright-policy.md)에 따릅니다. +4. 운영자는 위반 여부 확인을 위해 필요한 범위에서 콘텐츠를 검토할 수 있으나, 모든 콘텐츠를 사전에 검토할 의무를 부담하지 않습니다. + +--- + +## 제10조 (서비스 이용 제한) + +1. 운영자는 이용자가 제8조 또는 제9조를 위반한 경우 사전 통보 없이 서비스 이용을 제한하거나 계정을 삭제할 수 있습니다. +2. 운영자는 위반 행위의 내용, 반복 여부, 피해 규모, 긴급성을 고려하여 게시물 삭제, 채팅 제한, 여행 방 접근 제한, 계정 정지 또는 계정 삭제 조치를 할 수 있습니다. 세부 기준은 [운영정책](operation-policy.md)에 따릅니다. +3. 이용 제한 처분에 이의가 있는 경우 team.uttae@gmail.com으로 이의를 신청할 수 있으며, 운영자는 7일 이내에 처리 결과를 안내합니다. + +--- + +## 제11조 (콘텐츠에 대한 권리) + +1. 이용자가 서비스 내에서 작성·등록한 콘텐츠의 저작권은 해당 이용자에게 있습니다. +2. 이용자는 서비스 운영, 저장, 백업, 공유, 실시간 동기화, AI 어시스턴트 응답 생성 등 기능 제공에 필요한 범위에서 운영자가 콘텐츠를 이용할 수 있도록 비독점적·무상의 사용권을 부여합니다. +3. 이용자는 본인이 작성·등록한 콘텐츠에 대해 필요한 권리를 보유하고 있으며, 해당 콘텐츠가 제3자의 권리 또는 관련 법령을 침해하지 않음을 보증합니다. +4. 이용자가 작성·등록한 콘텐츠가 제3자의 권리를 침해한다는 신고 또는 이의제기가 있는 경우, 운영자는 관련 법령과 [저작권 정책](copyright-policy.md)에 따라 게시중단, 삭제, 접근 제한, 서비스 이용 제한 등 필요한 조치를 할 수 있습니다. +5. 운영자는 이용자의 콘텐츠를 광고·마케팅 목적으로 사용하지 않습니다. +6. 운영자가 제공하는 서비스 화면, 기능, 로고, 디자인, 데이터베이스, 프로그램 등 서비스 자체의 권리는 운영자 또는 정당한 권리자에게 귀속됩니다. + +--- + +## 제12조 (AI 어시스턴트 이용 안내) + +1. AI 어시스턴트의 응답은 참고 목적으로만 제공되며, 정확성·완전성을 보장하지 않습니다. +2. AI 어시스턴트가 추천하는 장소, 일정, 경로 정보는 실제와 다를 수 있으므로, 중요한 사항은 반드시 공식 정보를 직접 확인하시기 바랍니다. +3. AI 어시스턴트에게 메시지를 전송하면 채팅 내용이 외부 AI 서비스(OpenAI)로 전달됩니다. 민감한 개인정보는 AI 어시스턴트에게 입력하지 않도록 주의하시기 바랍니다. +4. 이용자는 AI 어시스턴트의 응답을 여행 예약, 결제, 안전, 법률, 의료 등 중요한 의사결정의 유일한 근거로 사용해서는 안 됩니다. + +--- + +## 제13조 (외부 서비스 및 외부 정보에 관한 고지) + +1. 서비스 내 장소 정보(영업시간, 평점, 주소, 사진 등)는 Google 등 외부 서비스가 제공하는 데이터로, 운영자가 정확성을 보장하지 않습니다. 방문 전 해당 장소에 직접 확인하시기 바랍니다. +2. 서비스의 장소 검색, 지도, 장소 사진, 이동 경로 등 Google Maps 기능 및 콘텐츠를 이용하는 경우, 이용자에게 Google Maps/Google Earth 추가 서비스 약관([https://maps.google.com/help/terms_maps/](https://maps.google.com/help/terms_maps/)) 및 Google 개인정보처리방침([https://policies.google.com/privacy](https://policies.google.com/privacy))이 적용됩니다. +3. 서비스는 외부 웹사이트 또는 외부 서비스로 연결되는 링크를 포함할 수 있습니다. 운영자는 외부 웹사이트 또는 외부 서비스의 내용, 정책, 이용 가능성, 안전성을 보증하지 않습니다. +4. 외부 서비스 이용에는 해당 외부 서비스의 약관과 정책이 적용될 수 있습니다. + +--- + +## 제14조 (오류 제보 및 피드백) + +1. 이용자는 서비스 오류, 개선 제안, 아이디어, 불만 사항 등을 team.uttae@gmail.com으로 전달할 수 있습니다. +2. 이용자가 피드백을 제공하는 경우, 운영자는 별도의 보상 없이 서비스 개선, 문제 해결, 기능 개발 목적으로 해당 피드백을 이용할 수 있습니다. +3. 피드백에 개인정보나 제3자의 비밀 정보가 포함되지 않도록 주의해야 합니다. + +--- + +## 제15조 (서비스의 변경 및 중단) + +1. 운영자는 서비스 내용을 변경하거나 서비스를 중단할 수 있습니다. +2. 서비스를 전부 중단하는 경우 30일 전 이메일 또는 서비스 내 공지사항을 통해 안내합니다. 다만, 장애 대응, 보안 사고, 외부 서비스 장애, 긴급 점검 등 부득이한 경우 사후에 안내할 수 있습니다. +3. 서비스는 현재 무료로 제공되며, 운영자는 서비스 변경 또는 중단으로 인한 손해에 대해 관련 법령상 책임이 인정되는 경우를 제외하고 별도의 보상 의무를 지지 않습니다. + +--- + +## 제16조 (보증의 부인 및 책임의 제한) + +1. 서비스는 현재 상태와 제공 가능한 범위에서 제공됩니다. 운영자는 서비스가 항상 중단 없이 제공되거나, 모든 오류가 수정되거나, 모든 정보가 정확하다고 보증하지 않습니다. +2. 운영자는 천재지변, 서비스 장애, 네트워크 문제, 외부 서비스 장애, 이용자의 귀책 사유 등 운영자의 합리적 통제 범위를 벗어난 사유로 인한 서비스 중단 또는 손해에 대해 책임을 지지 않습니다. +3. 운영자는 이용자 간 또는 이용자와 제3자 간의 분쟁에 개입할 의무를 부담하지 않으며, 관련 법령상 책임이 인정되는 경우를 제외하고 이에 대한 책임을 지지 않습니다. +4. 운영자는 무료로 제공되는 서비스의 이용과 관련하여 고의 또는 중대한 과실이 없는 한 손해배상 책임을 지지 않습니다. + +--- + +## 제17조 (준거법 및 관할) + +1. 이 약관은 대한민국 법률에 따라 해석·적용됩니다. +2. 서비스 이용과 관련한 분쟁은 운영자와 이용자가 성실히 협의하여 해결합니다. +3. 협의로 해결되지 않는 경우 「민사소송법」에 따른 관할 법원을 전속 관할로 합니다. + +--- + +## 부칙 + +이 약관은 2026년 6월 8일부터 시행합니다. diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 551e147e..a8059ed8 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -94,6 +94,13 @@ management: include: health,prometheus,caches app: + agreements: + terms-of-service: + version: "1.0" + resource: "classpath:agreements/terms-of-service.md" + privacy-policy: + version: "1.0" + resource: "classpath:agreements/privacy-policy.md" rate-limit: redis: host: ${spring.data.redis.host} From f32332548872fecb864f677e1422a10fb487deee Mon Sep 17 00:00:00 2001 From: parkjuyeong0312 Date: Mon, 8 Jun 2026 11:28:12 +0900 Subject: [PATCH 06/12] =?UTF-8?q?feat:=20=ED=98=84=EC=9E=AC=20=EC=95=BD?= =?UTF-8?q?=EA=B4=80=20=EC=A1=B0=ED=9A=8C=20API=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/AgreementController.java | 36 +++++++++ .../dto/AgreementCurrentResponse.java | 21 ++++++ .../dto/AgreementDocumentResponse.java | 29 ++++++++ .../agreements/service/AgreementService.java | 73 +++++++++++++++++++ .../service/dto/AgreementDocumentResult.java | 10 +++ .../service/dto/AgreementVersions.java | 7 ++ .../backend/common/config/SecurityConfig.java | 1 + .../backend/common/error/ErrorCode.java | 3 + .../controller/AgreementControllerTest.java | 55 ++++++++++++++ .../service/AgreementServiceTest.java | 61 ++++++++++++++++ 10 files changed, 296 insertions(+) create mode 100644 src/main/java/com/howaboutus/backend/agreements/controller/AgreementController.java create mode 100644 src/main/java/com/howaboutus/backend/agreements/controller/dto/AgreementCurrentResponse.java create mode 100644 src/main/java/com/howaboutus/backend/agreements/controller/dto/AgreementDocumentResponse.java create mode 100644 src/main/java/com/howaboutus/backend/agreements/service/AgreementService.java create mode 100644 src/main/java/com/howaboutus/backend/agreements/service/dto/AgreementDocumentResult.java create mode 100644 src/main/java/com/howaboutus/backend/agreements/service/dto/AgreementVersions.java create mode 100644 src/test/java/com/howaboutus/backend/agreements/controller/AgreementControllerTest.java create mode 100644 src/test/java/com/howaboutus/backend/agreements/service/AgreementServiceTest.java diff --git a/src/main/java/com/howaboutus/backend/agreements/controller/AgreementController.java b/src/main/java/com/howaboutus/backend/agreements/controller/AgreementController.java new file mode 100644 index 00000000..45d7f4b9 --- /dev/null +++ b/src/main/java/com/howaboutus/backend/agreements/controller/AgreementController.java @@ -0,0 +1,36 @@ +package com.howaboutus.backend.agreements.controller; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import com.howaboutus.backend.agreements.controller.dto.AgreementCurrentResponse; +import com.howaboutus.backend.agreements.service.AgreementService; +import com.howaboutus.backend.common.error.ApiErrorCodes; +import com.howaboutus.backend.common.error.ErrorCode; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; + +@Tag(name = "Agreements", description = "약관 API") +@RestController +@RequestMapping("/api/agreements") +@RequiredArgsConstructor +public class AgreementController { + + private final AgreementService agreementService; + + @Operation( + summary = "현재 약관 조회", + description = "가입과 재동의 화면에 표시할 현재 이용약관과 개인정보 처리방침 원문을 조회합니다." + ) + @ApiResponse(responseCode = "200", description = "조회 성공") + @ApiErrorCodes({ErrorCode.AGREEMENT_CONFIGURATION_INVALID}) + @GetMapping("/current") + public ResponseEntity getCurrentAgreements() { + return ResponseEntity.ok(AgreementCurrentResponse.from(agreementService.getCurrentAgreements())); + } +} diff --git a/src/main/java/com/howaboutus/backend/agreements/controller/dto/AgreementCurrentResponse.java b/src/main/java/com/howaboutus/backend/agreements/controller/dto/AgreementCurrentResponse.java new file mode 100644 index 00000000..9d1d4ae3 --- /dev/null +++ b/src/main/java/com/howaboutus/backend/agreements/controller/dto/AgreementCurrentResponse.java @@ -0,0 +1,21 @@ +package com.howaboutus.backend.agreements.controller.dto; + +import java.util.List; + +import com.howaboutus.backend.agreements.service.dto.AgreementDocumentResult; + +import io.swagger.v3.oas.annotations.media.Schema; + +public record AgreementCurrentResponse( + @Schema(description = "현재 필수 약관 목록") + List items +) { + + public static AgreementCurrentResponse from(List results) { + return new AgreementCurrentResponse( + results.stream() + .map(AgreementDocumentResponse::from) + .toList() + ); + } +} diff --git a/src/main/java/com/howaboutus/backend/agreements/controller/dto/AgreementDocumentResponse.java b/src/main/java/com/howaboutus/backend/agreements/controller/dto/AgreementDocumentResponse.java new file mode 100644 index 00000000..6c9f2ac6 --- /dev/null +++ b/src/main/java/com/howaboutus/backend/agreements/controller/dto/AgreementDocumentResponse.java @@ -0,0 +1,29 @@ +package com.howaboutus.backend.agreements.controller.dto; + +import com.howaboutus.backend.agreements.service.dto.AgreementDocumentResult; + +import io.swagger.v3.oas.annotations.media.Schema; + +public record AgreementDocumentResponse( + @Schema(description = "약관 문서 타입", example = "TERMS_OF_SERVICE") + String type, + @Schema(description = "약관 제목", example = "이용약관") + String title, + @Schema(description = "현재 약관 버전", example = "1.0") + String version, + @Schema(description = "본문 형식", example = "MARKDOWN") + String contentFormat, + @Schema(description = "약관 원문 Markdown") + String content +) { + + public static AgreementDocumentResponse from(AgreementDocumentResult result) { + return new AgreementDocumentResponse( + result.type(), + result.title(), + result.version(), + result.contentFormat(), + result.content() + ); + } +} diff --git a/src/main/java/com/howaboutus/backend/agreements/service/AgreementService.java b/src/main/java/com/howaboutus/backend/agreements/service/AgreementService.java new file mode 100644 index 00000000..5f89962a --- /dev/null +++ b/src/main/java/com/howaboutus/backend/agreements/service/AgreementService.java @@ -0,0 +1,73 @@ +package com.howaboutus.backend.agreements.service; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.List; + +import org.springframework.stereotype.Service; + +import com.howaboutus.backend.agreements.config.AgreementProperties; +import com.howaboutus.backend.agreements.service.dto.AgreementDocumentResult; +import com.howaboutus.backend.agreements.service.dto.AgreementVersions; +import com.howaboutus.backend.common.error.CustomException; +import com.howaboutus.backend.common.error.ErrorCode; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class AgreementService { + + private static final String CONTENT_FORMAT_MARKDOWN = "MARKDOWN"; + + private final AgreementProperties properties; + + public List getCurrentAgreements() { + return List.of( + document("TERMS_OF_SERVICE", "이용약관", properties.termsOfService()), + document("PRIVACY_POLICY", "개인정보 처리방침", properties.privacyPolicy()) + ); + } + + public AgreementVersions currentVersions() { + return new AgreementVersions( + requireVersion(properties.termsOfService()), + requireVersion(properties.privacyPolicy()) + ); + } + + public void validateAccepted(Boolean accepted) { + if (!Boolean.TRUE.equals(accepted)) { + throw new CustomException(ErrorCode.AGREEMENTS_NOT_ACCEPTED); + } + } + + private AgreementDocumentResult document(String type, String title, + AgreementProperties.AgreementDocument document) { + return new AgreementDocumentResult( + type, + title, + requireVersion(document), + CONTENT_FORMAT_MARKDOWN, + readContent(document) + ); + } + + private String requireVersion(AgreementProperties.AgreementDocument document) { + if (document == null || document.version() == null || document.version().isBlank()) { + throw new CustomException(ErrorCode.AGREEMENT_CONFIGURATION_INVALID); + } + return document.version(); + } + + private String readContent(AgreementProperties.AgreementDocument document) { + if (document == null || document.resource() == null) { + throw new CustomException(ErrorCode.AGREEMENT_CONFIGURATION_INVALID); + } + try { + return document.resource().getContentAsString(StandardCharsets.UTF_8); + } catch (IOException e) { + throw new CustomException(ErrorCode.AGREEMENT_CONFIGURATION_INVALID); + } + } +} diff --git a/src/main/java/com/howaboutus/backend/agreements/service/dto/AgreementDocumentResult.java b/src/main/java/com/howaboutus/backend/agreements/service/dto/AgreementDocumentResult.java new file mode 100644 index 00000000..d8f23624 --- /dev/null +++ b/src/main/java/com/howaboutus/backend/agreements/service/dto/AgreementDocumentResult.java @@ -0,0 +1,10 @@ +package com.howaboutus.backend.agreements.service.dto; + +public record AgreementDocumentResult( + String type, + String title, + String version, + String contentFormat, + String content +) { +} diff --git a/src/main/java/com/howaboutus/backend/agreements/service/dto/AgreementVersions.java b/src/main/java/com/howaboutus/backend/agreements/service/dto/AgreementVersions.java new file mode 100644 index 00000000..39c4b43d --- /dev/null +++ b/src/main/java/com/howaboutus/backend/agreements/service/dto/AgreementVersions.java @@ -0,0 +1,7 @@ +package com.howaboutus.backend.agreements.service.dto; + +public record AgreementVersions( + String tosVersion, + String privacyVersion +) { +} diff --git a/src/main/java/com/howaboutus/backend/common/config/SecurityConfig.java b/src/main/java/com/howaboutus/backend/common/config/SecurityConfig.java index df4fc349..264f1759 100644 --- a/src/main/java/com/howaboutus/backend/common/config/SecurityConfig.java +++ b/src/main/java/com/howaboutus/backend/common/config/SecurityConfig.java @@ -51,6 +51,7 @@ SecurityFilterChain securityFilterChain(HttpSecurity http) { "/actuator/health", "/actuator/prometheus", "/actuator/caches", + "/api/agreements/current", "/auth/google/login", "/auth/refresh", "/auth/logout", diff --git a/src/main/java/com/howaboutus/backend/common/error/ErrorCode.java b/src/main/java/com/howaboutus/backend/common/error/ErrorCode.java index fb5c76a1..dcd2b0d7 100644 --- a/src/main/java/com/howaboutus/backend/common/error/ErrorCode.java +++ b/src/main/java/com/howaboutus/backend/common/error/ErrorCode.java @@ -21,6 +21,7 @@ public enum ErrorCode { MESSAGE_CONTENT_TOO_LONG(HttpStatus.BAD_REQUEST, "메시지는 1000자 이하여야 합니다"), MESSAGE_PLACE_ID_BLANK(HttpStatus.BAD_REQUEST, "공유할 장소 ID는 공백일 수 없습니다"), AI_REQUEST_ID_BLANK(HttpStatus.BAD_REQUEST, "AI 요청 ID는 공백일 수 없습니다"), + AGREEMENTS_NOT_ACCEPTED(HttpStatus.BAD_REQUEST, "필수 약관에 동의해야 합니다"), // 401 UNAUTHORIZED GOOGLE_AUTH_FAILED(HttpStatus.UNAUTHORIZED, "Google 인증에 실패했습니다"), @@ -28,6 +29,7 @@ public enum ErrorCode { ACCESS_TOKEN_EXPIRED(HttpStatus.UNAUTHORIZED, "액세스 토큰이 만료되었습니다"), REFRESH_TOKEN_NOT_FOUND(HttpStatus.UNAUTHORIZED, "리프레시 토큰이 존재하지 않습니다"), REFRESH_TOKEN_REUSE_DETECTED(HttpStatus.UNAUTHORIZED, "토큰 재사용이 감지되었습니다"), + AGREEMENTS_REACCEPTANCE_REQUIRED(HttpStatus.UNAUTHORIZED, "변경된 약관에 다시 동의해야 합니다"), // 400 BAD REQUEST SCHEDULE_DATE_MISMATCH(HttpStatus.BAD_REQUEST, "여행 날짜와 일차 정보가 일치하지 않습니다"), @@ -71,6 +73,7 @@ public enum ErrorCode { // 503 SERVICE UNAVAILABLE ROUTE_TEMPORARILY_UNAVAILABLE(HttpStatus.SERVICE_UNAVAILABLE, "이동 경로를 조회 중입니다. 잠시 후 다시 시도해 주세요"), + AGREEMENT_CONFIGURATION_INVALID(HttpStatus.SERVICE_UNAVAILABLE, "약관 설정이 올바르지 않습니다"), // 502 BAD GATEWAY EXTERNAL_API_ERROR(HttpStatus.BAD_GATEWAY, "외부 API 호출 중 오류가 발생했습니다"); diff --git a/src/test/java/com/howaboutus/backend/agreements/controller/AgreementControllerTest.java b/src/test/java/com/howaboutus/backend/agreements/controller/AgreementControllerTest.java new file mode 100644 index 00000000..17c91441 --- /dev/null +++ b/src/test/java/com/howaboutus/backend/agreements/controller/AgreementControllerTest.java @@ -0,0 +1,55 @@ +package com.howaboutus.backend.agreements.controller; + +import static org.mockito.BDDMockito.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +import java.util.List; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +import com.howaboutus.backend.agreements.service.AgreementService; +import com.howaboutus.backend.agreements.service.dto.AgreementDocumentResult; +import com.howaboutus.backend.auth.filter.JwtAuthenticationFilter; +import com.howaboutus.backend.auth.service.JwtProvider; +import com.howaboutus.backend.common.config.SecurityConfig; +import com.howaboutus.backend.common.error.GlobalExceptionHandler; +import com.howaboutus.backend.common.security.JwtAuthenticationEntryPoint; + +@WebMvcTest(AgreementController.class) +@Import({SecurityConfig.class, JwtAuthenticationFilter.class, JwtAuthenticationEntryPoint.class, + GlobalExceptionHandler.class}) +class AgreementControllerTest { + + @Autowired + private MockMvc mockMvc; + + @MockitoBean + private AgreementService agreementService; + + @MockitoBean + private JwtProvider jwtProvider; + + @Test + @DisplayName("비인증 상태에서 현재 약관을 조회한다") + void returnsCurrentAgreementsWithoutAuthentication() throws Exception { + given(agreementService.getCurrentAgreements()).willReturn(List.of( + new AgreementDocumentResult("TERMS_OF_SERVICE", "이용약관", "1.0", "MARKDOWN", "# 이용약관"), + new AgreementDocumentResult("PRIVACY_POLICY", "개인정보 처리방침", "1.0", "MARKDOWN", "# 개인정보") + )); + + mockMvc.perform(get("/api/agreements/current")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.items[0].type").value("TERMS_OF_SERVICE")) + .andExpect(jsonPath("$.items[0].version").value("1.0")) + .andExpect(jsonPath("$.items[0].contentFormat").value("MARKDOWN")) + .andExpect(jsonPath("$.items[0].content").value("# 이용약관")) + .andExpect(jsonPath("$.items[1].type").value("PRIVACY_POLICY")); + } +} diff --git a/src/test/java/com/howaboutus/backend/agreements/service/AgreementServiceTest.java b/src/test/java/com/howaboutus/backend/agreements/service/AgreementServiceTest.java new file mode 100644 index 00000000..195605fb --- /dev/null +++ b/src/test/java/com/howaboutus/backend/agreements/service/AgreementServiceTest.java @@ -0,0 +1,61 @@ +package com.howaboutus.backend.agreements.service; + +import static org.assertj.core.api.Assertions.*; + +import java.nio.charset.StandardCharsets; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.core.io.ByteArrayResource; + +import com.howaboutus.backend.agreements.config.AgreementProperties; +import com.howaboutus.backend.common.error.CustomException; +import com.howaboutus.backend.common.error.ErrorCode; + +class AgreementServiceTest { + + @Test + @DisplayName("설정된 현재 이용약관과 개인정보 처리방침을 Markdown으로 반환한다") + void returnsCurrentAgreements() { + AgreementService service = new AgreementService(properties( + "1.0", "# 이용약관", + "1.0", "# 개인정보 처리방침" + )); + + var result = service.getCurrentAgreements(); + + assertThat(result).hasSize(2); + assertThat(result.get(0).type()).isEqualTo("TERMS_OF_SERVICE"); + assertThat(result.get(0).title()).isEqualTo("이용약관"); + assertThat(result.get(0).version()).isEqualTo("1.0"); + assertThat(result.get(0).contentFormat()).isEqualTo("MARKDOWN"); + assertThat(result.get(0).content()).isEqualTo("# 이용약관"); + assertThat(result.get(1).type()).isEqualTo("PRIVACY_POLICY"); + assertThat(result.get(1).title()).isEqualTo("개인정보 처리방침"); + assertThat(result.get(1).version()).isEqualTo("1.0"); + } + + @Test + @DisplayName("약관 버전 설정이 비어 있으면 AGREEMENT_CONFIGURATION_INVALID 예외") + void throwsForBlankVersion() { + AgreementService service = new AgreementService(properties( + "", "# 이용약관", + "1.0", "# 개인정보 처리방침" + )); + + assertThatThrownBy(service::getCurrentAgreements) + .isInstanceOf(CustomException.class) + .satisfies(e -> assertThat(((CustomException)e).getErrorCode()) + .isEqualTo(ErrorCode.AGREEMENT_CONFIGURATION_INVALID)); + } + + private AgreementProperties properties(String tosVersion, String tosContent, + String privacyVersion, String privacyContent) { + return new AgreementProperties( + new AgreementProperties.AgreementDocument( + tosVersion, new ByteArrayResource(tosContent.getBytes(StandardCharsets.UTF_8))), + new AgreementProperties.AgreementDocument( + privacyVersion, new ByteArrayResource(privacyContent.getBytes(StandardCharsets.UTF_8))) + ); + } +} From 0a86b26a59c4b210aae5f8b5d21763938fb3dbc0 Mon Sep 17 00:00:00 2001 From: parkjuyeong0312 Date: Mon, 8 Jun 2026 11:30:57 +0900 Subject: [PATCH 07/12] =?UTF-8?q?feat:=20=EC=82=AC=EC=9A=A9=EC=9E=90=20?= =?UTF-8?q?=EC=95=BD=EA=B4=80=20=EB=8F=99=EC=9D=98=20=EC=83=81=ED=83=9C=20?= =?UTF-8?q?=EC=A0=80=EC=9E=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/UserAgreementController.java | 53 ++++++++++++ .../dto/AcceptAgreementsRequest.java | 9 ++ .../howaboutus/backend/user/entity/User.java | 40 ++++++++- .../backend/user/service/UserService.java | 32 +++++++- src/main/resources/db/migration/V1__init.sql | 4 + .../UserAgreementControllerTest.java | 82 +++++++++++++++++++ .../backend/user/service/UserServiceTest.java | 37 +++++++++ 7 files changed, 253 insertions(+), 4 deletions(-) create mode 100644 src/main/java/com/howaboutus/backend/user/controller/UserAgreementController.java create mode 100644 src/main/java/com/howaboutus/backend/user/controller/dto/AcceptAgreementsRequest.java create mode 100644 src/test/java/com/howaboutus/backend/user/controller/UserAgreementControllerTest.java diff --git a/src/main/java/com/howaboutus/backend/user/controller/UserAgreementController.java b/src/main/java/com/howaboutus/backend/user/controller/UserAgreementController.java new file mode 100644 index 00000000..905bb659 --- /dev/null +++ b/src/main/java/com/howaboutus/backend/user/controller/UserAgreementController.java @@ -0,0 +1,53 @@ +package com.howaboutus.backend.user.controller; + +import java.time.Clock; +import java.time.Instant; + +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import com.howaboutus.backend.agreements.service.AgreementService; +import com.howaboutus.backend.common.error.ApiErrorCodes; +import com.howaboutus.backend.common.error.ErrorCode; +import com.howaboutus.backend.user.controller.dto.AcceptAgreementsRequest; +import com.howaboutus.backend.user.service.UserService; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; + +@Tag(name = "Users", description = "사용자 API") +@RestController +@RequestMapping("/api/users/me/agreements") +@RequiredArgsConstructor +public class UserAgreementController { + + private final UserService userService; + private final AgreementService agreementService; + private final Clock clock; + + @Operation( + summary = "현재 약관 재동의", + description = "현재 인증된 사용자가 서버 기준 현재 이용약관과 개인정보 처리방침에 재동의합니다." + ) + @ApiResponse(responseCode = "204", description = "재동의 성공", content = @Content) + @ApiErrorCodes({ + ErrorCode.INVALID_TOKEN, + ErrorCode.ACCESS_TOKEN_EXPIRED, + ErrorCode.USER_NOT_FOUND, + ErrorCode.AGREEMENTS_NOT_ACCEPTED + }) + @PostMapping + public ResponseEntity acceptCurrentAgreements(@AuthenticationPrincipal Long userId, + @RequestBody AcceptAgreementsRequest request) { + agreementService.validateAccepted(request.agreementsAccepted()); + userService.acceptCurrentAgreements(userId, agreementService.currentVersions(), Instant.now(clock)); + return ResponseEntity.noContent().build(); + } +} diff --git a/src/main/java/com/howaboutus/backend/user/controller/dto/AcceptAgreementsRequest.java b/src/main/java/com/howaboutus/backend/user/controller/dto/AcceptAgreementsRequest.java new file mode 100644 index 00000000..b038965a --- /dev/null +++ b/src/main/java/com/howaboutus/backend/user/controller/dto/AcceptAgreementsRequest.java @@ -0,0 +1,9 @@ +package com.howaboutus.backend.user.controller.dto; + +import io.swagger.v3.oas.annotations.media.Schema; + +public record AcceptAgreementsRequest( + @Schema(description = "현재 필수 약관 전체 동의 여부", example = "true") + Boolean agreementsAccepted +) { +} diff --git a/src/main/java/com/howaboutus/backend/user/entity/User.java b/src/main/java/com/howaboutus/backend/user/entity/User.java index 324d3a8a..1709a992 100644 --- a/src/main/java/com/howaboutus/backend/user/entity/User.java +++ b/src/main/java/com/howaboutus/backend/user/entity/User.java @@ -1,5 +1,8 @@ package com.howaboutus.backend.user.entity; +import java.time.Instant; + +import com.howaboutus.backend.agreements.service.dto.AgreementVersions; import com.howaboutus.backend.common.entity.BaseTimeEntity; import jakarta.persistence.Column; @@ -40,15 +43,48 @@ public class User extends BaseTimeEntity { @Column(nullable = false) private String providerId; - private User(String providerId, String email, String nickname, String profileImageUrl, String provider) { + @Column(nullable = false, length = 30) + private String tosVersion; + + @Column(nullable = false) + private Instant tosAcceptedAt; + + @Column(nullable = false, length = 30) + private String privacyVersion; + + @Column(nullable = false) + private Instant privacyAcceptedAt; + + private User(String providerId, String email, String nickname, String profileImageUrl, String provider, + AgreementVersions agreementVersions, Instant acceptedAt) { this.providerId = providerId; this.email = email; this.nickname = nickname; this.profileImageUrl = profileImageUrl; this.provider = provider; + acceptAgreements(agreementVersions, acceptedAt); + } + + public static User ofGoogle(String providerId, String email, String nickname, String profileImageUrl, + AgreementVersions agreementVersions, Instant acceptedAt) { + return new User(providerId, email, nickname, profileImageUrl, "GOOGLE", agreementVersions, acceptedAt); } public static User ofGoogle(String providerId, String email, String nickname, String profileImageUrl) { - return new User(providerId, email, nickname, profileImageUrl, "GOOGLE"); + return ofGoogle( + providerId, + email, + nickname, + profileImageUrl, + new AgreementVersions("1.0", "1.0"), + Instant.EPOCH + ); + } + + public void acceptAgreements(AgreementVersions agreementVersions, Instant acceptedAt) { + this.tosVersion = agreementVersions.tosVersion(); + this.tosAcceptedAt = acceptedAt; + this.privacyVersion = agreementVersions.privacyVersion(); + this.privacyAcceptedAt = acceptedAt; } } diff --git a/src/main/java/com/howaboutus/backend/user/service/UserService.java b/src/main/java/com/howaboutus/backend/user/service/UserService.java index 426a90fd..582a06c3 100644 --- a/src/main/java/com/howaboutus/backend/user/service/UserService.java +++ b/src/main/java/com/howaboutus/backend/user/service/UserService.java @@ -1,12 +1,15 @@ package com.howaboutus.backend.user.service; +import java.time.Instant; import java.util.Collection; import java.util.List; +import java.util.Optional; import org.springframework.dao.DataIntegrityViolationException; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import com.howaboutus.backend.agreements.service.dto.AgreementVersions; import com.howaboutus.backend.common.error.CustomException; import com.howaboutus.backend.common.error.ErrorCode; import com.howaboutus.backend.user.entity.User; @@ -37,8 +40,13 @@ public List getUsersByIds(Collection userIds) { return userRepository.findAllById(userIds); } + public Optional findGoogleUser(String providerId) { + return userRepository.findByProviderAndProviderId("GOOGLE", providerId); + } + @Transactional - public User getOrCreateGoogleUser(String providerId, String email, String nickname, String profileImageUrl) { + public User getOrCreateGoogleUser(String providerId, String email, String nickname, String profileImageUrl, + AgreementVersions agreementVersions, Instant acceptedAt) { return userRepository.findByProviderAndProviderId("GOOGLE", providerId) .orElseGet(() -> { try { @@ -47,7 +55,9 @@ public User getOrCreateGoogleUser(String providerId, String email, String nickna providerId, email, nickname, - profileImageUrl + profileImageUrl, + agreementVersions, + acceptedAt ) ); } catch (DataIntegrityViolationException e) { @@ -56,4 +66,22 @@ public User getOrCreateGoogleUser(String providerId, String email, String nickna } }); } + + @Transactional + public User getOrCreateGoogleUser(String providerId, String email, String nickname, String profileImageUrl) { + return getOrCreateGoogleUser( + providerId, + email, + nickname, + profileImageUrl, + new AgreementVersions("1.0", "1.0"), + Instant.EPOCH + ); + } + + @Transactional + public void acceptCurrentAgreements(Long userId, AgreementVersions agreementVersions, Instant acceptedAt) { + User user = getUser(userId); + user.acceptAgreements(agreementVersions, acceptedAt); + } } diff --git a/src/main/resources/db/migration/V1__init.sql b/src/main/resources/db/migration/V1__init.sql index a32ef6dc..2d68b3d9 100644 --- a/src/main/resources/db/migration/V1__init.sql +++ b/src/main/resources/db/migration/V1__init.sql @@ -11,6 +11,10 @@ CREATE TABLE users profile_image_url VARCHAR(500), provider VARCHAR(20) NOT NULL, provider_id VARCHAR(255) NOT NULL, + tos_version VARCHAR(30) NOT NULL, + tos_accepted_at TIMESTAMP WITH TIME ZONE NOT NULL, + privacy_version VARCHAR(30) NOT NULL, + privacy_accepted_at TIMESTAMP WITH TIME ZONE NOT NULL, created_at TIMESTAMP WITH TIME ZONE NOT NULL, updated_at TIMESTAMP WITH TIME ZONE NOT NULL, CONSTRAINT uq_users_provider_provider_id UNIQUE (provider, provider_id) diff --git a/src/test/java/com/howaboutus/backend/user/controller/UserAgreementControllerTest.java b/src/test/java/com/howaboutus/backend/user/controller/UserAgreementControllerTest.java new file mode 100644 index 00000000..fc640067 --- /dev/null +++ b/src/test/java/com/howaboutus/backend/user/controller/UserAgreementControllerTest.java @@ -0,0 +1,82 @@ +package com.howaboutus.backend.user.controller; + +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.BDDMockito.*; +import static org.mockito.Mockito.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest; +import org.springframework.context.annotation.Import; +import org.springframework.http.MediaType; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +import com.howaboutus.backend.agreements.service.AgreementService; +import com.howaboutus.backend.agreements.service.dto.AgreementVersions; +import com.howaboutus.backend.auth.filter.JwtAuthenticationFilter; +import com.howaboutus.backend.auth.service.JwtProvider; +import com.howaboutus.backend.common.config.SecurityConfig; +import com.howaboutus.backend.common.error.GlobalExceptionHandler; +import com.howaboutus.backend.common.security.JwtAuthenticationEntryPoint; +import com.howaboutus.backend.user.service.UserService; + +import jakarta.servlet.http.Cookie; + +@WebMvcTest(UserAgreementController.class) +@Import({SecurityConfig.class, JwtAuthenticationFilter.class, JwtAuthenticationEntryPoint.class, + GlobalExceptionHandler.class}) +class UserAgreementControllerTest { + + @Autowired + private MockMvc mockMvc; + + @MockitoBean + private UserService userService; + + @MockitoBean + private AgreementService agreementService; + + @MockitoBean + private JwtProvider jwtProvider; + + @MockitoBean + private Clock clock; + + @Test + @DisplayName("인증된 사용자가 현재 약관에 재동의한다") + void acceptsCurrentAgreementsForAuthenticatedUser() throws Exception { + given(jwtProvider.extractUserId("valid-jwt")).willReturn(1L); + given(agreementService.currentVersions()).willReturn(new AgreementVersions("1.0", "1.0")); + given(clock.instant()).willReturn(Instant.parse("2026-06-08T10:00:00Z")); + given(clock.getZone()).willReturn(ZoneOffset.UTC); + + mockMvc.perform(post("/api/users/me/agreements") + .cookie(new Cookie("access_token", "valid-jwt")) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"agreementsAccepted": true} + """)) + .andExpect(status().isNoContent()); + + verify(userService).acceptCurrentAgreements(eq(1L), any(), any()); + } + + @Test + @DisplayName("인증 없이 현재 약관 재동의 요청 시 401을 반환한다") + void returns401WithoutAuthentication() throws Exception { + mockMvc.perform(post("/api/users/me/agreements") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"agreementsAccepted": true} + """)) + .andExpect(status().isUnauthorized()); + } +} diff --git a/src/test/java/com/howaboutus/backend/user/service/UserServiceTest.java b/src/test/java/com/howaboutus/backend/user/service/UserServiceTest.java index db2f1c00..a0703f95 100644 --- a/src/test/java/com/howaboutus/backend/user/service/UserServiceTest.java +++ b/src/test/java/com/howaboutus/backend/user/service/UserServiceTest.java @@ -4,6 +4,7 @@ import static org.mockito.ArgumentMatchers.*; import static org.mockito.BDDMockito.*; +import java.time.Instant; import java.util.List; import java.util.Optional; @@ -14,6 +15,7 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import com.howaboutus.backend.agreements.service.dto.AgreementVersions; import com.howaboutus.backend.common.error.CustomException; import com.howaboutus.backend.common.error.ErrorCode; import com.howaboutus.backend.user.entity.User; @@ -115,4 +117,39 @@ void getOrCreateGoogleUserCreatesNewUser() { assertThat(result).isSameAs(user); } + + @Test + @DisplayName("신규 구글 유저 생성 시 현재 약관 버전과 동의 시각을 저장한다") + void getOrCreateGoogleUserCreatesUserWithAgreements() { + Instant acceptedAt = Instant.parse("2026-06-08T10:00:00Z"); + AgreementVersions versions = new AgreementVersions("1.0", "1.0"); + given(userRepository.findByProviderAndProviderId("GOOGLE", "provider-1")) + .willReturn(Optional.empty()); + given(userRepository.save(any(User.class))).willAnswer(invocation -> invocation.getArgument(0)); + + User result = userService.getOrCreateGoogleUser( + "provider-1", "test@gmail.com", "닉네임", "https://img.url/photo.jpg", versions, acceptedAt); + + assertThat(result.getTosVersion()).isEqualTo("1.0"); + assertThat(result.getTosAcceptedAt()).isEqualTo(acceptedAt); + assertThat(result.getPrivacyVersion()).isEqualTo("1.0"); + assertThat(result.getPrivacyAcceptedAt()).isEqualTo(acceptedAt); + } + + @Test + @DisplayName("현재 사용자 약관 동의를 서버 버전과 서버 시간으로 갱신한다") + void acceptCurrentAgreementsUpdatesUser() { + User user = User.ofGoogle( + "provider-1", "test@gmail.com", "닉네임", null, + new AgreementVersions("1.0", "1.0"), Instant.parse("2026-06-01T10:00:00Z")); + Instant acceptedAt = Instant.parse("2026-06-08T10:00:00Z"); + given(userRepository.findById(1L)).willReturn(Optional.of(user)); + + userService.acceptCurrentAgreements(1L, new AgreementVersions("1.1", "1.0"), acceptedAt); + + assertThat(user.getTosVersion()).isEqualTo("1.1"); + assertThat(user.getTosAcceptedAt()).isEqualTo(acceptedAt); + assertThat(user.getPrivacyVersion()).isEqualTo("1.0"); + assertThat(user.getPrivacyAcceptedAt()).isEqualTo(acceptedAt); + } } From 5e2f4807111022cef6e6949c79e9bc4b47c32b3d Mon Sep 17 00:00:00 2001 From: parkjuyeong0312 Date: Mon, 8 Jun 2026 11:33:16 +0900 Subject: [PATCH 08/12] =?UTF-8?q?feat:=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20?= =?UTF-8?q?=EC=95=BD=EA=B4=80=20=EB=8F=99=EC=9D=98=20=EA=B2=80=EC=A6=9D=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../agreements/service/AgreementService.java | 7 ++ .../auth/controller/AuthController.java | 10 ++- .../controller/dto/GoogleLoginRequest.java | 8 +- .../backend/auth/service/AuthService.java | 44 ++++++++-- .../auth/controller/AuthControllerTest.java | 8 +- .../backend/auth/service/AuthServiceTest.java | 81 +++++++++++++++++-- 6 files changed, 138 insertions(+), 20 deletions(-) diff --git a/src/main/java/com/howaboutus/backend/agreements/service/AgreementService.java b/src/main/java/com/howaboutus/backend/agreements/service/AgreementService.java index 5f89962a..54c46b7f 100644 --- a/src/main/java/com/howaboutus/backend/agreements/service/AgreementService.java +++ b/src/main/java/com/howaboutus/backend/agreements/service/AgreementService.java @@ -11,6 +11,7 @@ import com.howaboutus.backend.agreements.service.dto.AgreementVersions; import com.howaboutus.backend.common.error.CustomException; import com.howaboutus.backend.common.error.ErrorCode; +import com.howaboutus.backend.user.entity.User; import lombok.RequiredArgsConstructor; @@ -42,6 +43,12 @@ public void validateAccepted(Boolean accepted) { } } + public boolean needsReacceptance(User user) { + AgreementVersions versions = currentVersions(); + return !versions.tosVersion().equals(user.getTosVersion()) + || !versions.privacyVersion().equals(user.getPrivacyVersion()); + } + private AgreementDocumentResult document(String type, String title, AgreementProperties.AgreementDocument document) { return new AgreementDocumentResult( diff --git a/src/main/java/com/howaboutus/backend/auth/controller/AuthController.java b/src/main/java/com/howaboutus/backend/auth/controller/AuthController.java index 3931b907..af04c226 100644 --- a/src/main/java/com/howaboutus/backend/auth/controller/AuthController.java +++ b/src/main/java/com/howaboutus/backend/auth/controller/AuthController.java @@ -45,14 +45,18 @@ public class AuthController { @Operation( summary = "Google 로그인", - description = "Google OAuth2 인가 코드를 사용하여 로그인을 진행하고, 쿠키에 액세스 토큰 및 리프레시 토큰을 심어 반환합니다." + description = "Google OAuth2 인가 코드를 사용하여 로그인을 진행하고, 쿠키에 액세스 토큰 및 리프레시 토큰을 심어 반환합니다. 신규 가입자와 변경된 약관에 다시 동의해야 하는 기존 사용자는 agreementsAccepted=true를 보내야 합니다." ) @ApiResponse(responseCode = "200", description = "로그인 성공") - @ApiErrorCodes({ErrorCode.GOOGLE_AUTH_FAILED}) + @ApiErrorCodes({ + ErrorCode.GOOGLE_AUTH_FAILED, + ErrorCode.AGREEMENTS_NOT_ACCEPTED, + ErrorCode.AGREEMENTS_REACCEPTANCE_REQUIRED + }) @Loggable @PostMapping("/google/login") public ResponseEntity googleLogin(@RequestBody GoogleLoginRequest request) { - LoginResult result = authService.googleLogin(request.code()); + LoginResult result = authService.googleLogin(request.code(), request.agreementsAccepted()); return ResponseEntity.ok() .header(HttpHeaders.SET_COOKIE, buildAccessTokenCookie(result.accessToken()).toString()) .header(HttpHeaders.SET_COOKIE, buildRefreshTokenCookie(result.refreshToken()).toString()) diff --git a/src/main/java/com/howaboutus/backend/auth/controller/dto/GoogleLoginRequest.java b/src/main/java/com/howaboutus/backend/auth/controller/dto/GoogleLoginRequest.java index eb4bf90d..fbc094cc 100644 --- a/src/main/java/com/howaboutus/backend/auth/controller/dto/GoogleLoginRequest.java +++ b/src/main/java/com/howaboutus/backend/auth/controller/dto/GoogleLoginRequest.java @@ -2,7 +2,13 @@ import com.howaboutus.backend.common.logging.MaskField; +import io.swagger.v3.oas.annotations.media.Schema; + public record GoogleLoginRequest( - @MaskField String code + @MaskField + @Schema(description = "Google OAuth2 인가 코드") + String code, + @Schema(description = "현재 필수 약관 전체 동의 여부", example = "true") + Boolean agreementsAccepted ) { } diff --git a/src/main/java/com/howaboutus/backend/auth/service/AuthService.java b/src/main/java/com/howaboutus/backend/auth/service/AuthService.java index 451f2904..ea047e8e 100644 --- a/src/main/java/com/howaboutus/backend/auth/service/AuthService.java +++ b/src/main/java/com/howaboutus/backend/auth/service/AuthService.java @@ -1,11 +1,18 @@ package com.howaboutus.backend.auth.service; +import java.time.Clock; +import java.time.Instant; + import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import com.howaboutus.backend.agreements.service.AgreementService; +import com.howaboutus.backend.agreements.service.dto.AgreementVersions; import com.howaboutus.backend.auth.service.dto.GoogleUserInfo; import com.howaboutus.backend.auth.service.dto.LoginResult; import com.howaboutus.backend.auth.service.dto.RotateResult; +import com.howaboutus.backend.common.error.CustomException; +import com.howaboutus.backend.common.error.ErrorCode; import com.howaboutus.backend.common.integration.google.GoogleOAuthClient; import com.howaboutus.backend.common.logging.Loggable; import com.howaboutus.backend.user.entity.User; @@ -21,18 +28,17 @@ public class AuthService { private final UserService userService; private final JwtProvider jwtProvider; private final RefreshTokenService refreshTokenService; + private final AgreementService agreementService; + private final Clock clock; @Loggable @Transactional - public LoginResult googleLogin(String authorizationCode) { + public LoginResult googleLogin(String authorizationCode, Boolean agreementsAccepted) { GoogleUserInfo userInfo = googleOAuthClient.login(authorizationCode); - User user = userService.getOrCreateGoogleUser( - userInfo.providerId(), - userInfo.email(), - userInfo.nickname(), - userInfo.profileImageUrl() - ); + User user = userService.findGoogleUser(userInfo.providerId()) + .map(existingUser -> handleExistingUser(existingUser, agreementsAccepted)) + .orElseGet(() -> createNewUser(userInfo, agreementsAccepted)); String accessToken = jwtProvider.generateAccessToken(user.getId()); String refreshToken = refreshTokenService.create(user.getId()); @@ -40,6 +46,30 @@ public LoginResult googleLogin(String authorizationCode) { return new LoginResult(accessToken, refreshToken, user.getId()); } + private User handleExistingUser(User user, Boolean agreementsAccepted) { + if (!agreementService.needsReacceptance(user)) { + return user; + } + if (!Boolean.TRUE.equals(agreementsAccepted)) { + throw new CustomException(ErrorCode.AGREEMENTS_REACCEPTANCE_REQUIRED); + } + userService.acceptCurrentAgreements(user.getId(), agreementService.currentVersions(), Instant.now(clock)); + return user; + } + + private User createNewUser(GoogleUserInfo userInfo, Boolean agreementsAccepted) { + agreementService.validateAccepted(agreementsAccepted); + AgreementVersions versions = agreementService.currentVersions(); + return userService.getOrCreateGoogleUser( + userInfo.providerId(), + userInfo.email(), + userInfo.nickname(), + userInfo.profileImageUrl(), + versions, + Instant.now(clock) + ); + } + @Loggable public LoginResult refresh(String refreshToken) { RotateResult rotated = refreshTokenService.rotate(refreshToken); diff --git a/src/test/java/com/howaboutus/backend/auth/controller/AuthControllerTest.java b/src/test/java/com/howaboutus/backend/auth/controller/AuthControllerTest.java index 27525b78..5a406723 100644 --- a/src/test/java/com/howaboutus/backend/auth/controller/AuthControllerTest.java +++ b/src/test/java/com/howaboutus/backend/auth/controller/AuthControllerTest.java @@ -55,7 +55,7 @@ class AuthControllerTest { @Test @DisplayName("Google 로그인 성공 시 access_token과 refresh_token 쿠키를 반환한다") void returnsAccessAndRefreshTokenCookiesOnLogin() throws Exception { - given(authService.googleLogin("valid-code")) + given(authService.googleLogin("valid-code", true)) .willReturn(new LoginResult("jwt-token", "1:refresh-uuid", 1L)); given(jwtProperties.accessTokenExpiration()).willReturn(1800000L); given(refreshTokenProperties.expiration()).willReturn(1209600000L); @@ -63,7 +63,7 @@ void returnsAccessAndRefreshTokenCookiesOnLogin() throws Exception { mockMvc.perform(post("/auth/google/login") .contentType(MediaType.APPLICATION_JSON) .content(""" - {"code": "valid-code"} + {"code": "valid-code", "agreementsAccepted": true} """)) .andExpect(status().isOk()) .andExpect(cookie().exists("access_token")) @@ -77,13 +77,13 @@ void returnsAccessAndRefreshTokenCookiesOnLogin() throws Exception { @Test @DisplayName("Google 인증 실패 시 401을 반환한다") void returns401WhenGoogleAuthFails() throws Exception { - given(authService.googleLogin("bad-code")) + given(authService.googleLogin("bad-code", true)) .willThrow(new CustomException(ErrorCode.GOOGLE_AUTH_FAILED)); mockMvc.perform(post("/auth/google/login") .contentType(MediaType.APPLICATION_JSON) .content(""" - {"code": "bad-code"} + {"code": "bad-code", "agreementsAccepted": true} """)) .andExpect(status().isUnauthorized()); } diff --git a/src/test/java/com/howaboutus/backend/auth/service/AuthServiceTest.java b/src/test/java/com/howaboutus/backend/auth/service/AuthServiceTest.java index 5ab7b4aa..818f50dc 100644 --- a/src/test/java/com/howaboutus/backend/auth/service/AuthServiceTest.java +++ b/src/test/java/com/howaboutus/backend/auth/service/AuthServiceTest.java @@ -5,6 +5,10 @@ import static org.mockito.BDDMockito.*; import static org.mockito.Mockito.*; +import java.time.Clock; +import java.time.Instant; +import java.util.Optional; + import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -12,9 +16,13 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import com.howaboutus.backend.agreements.service.AgreementService; +import com.howaboutus.backend.agreements.service.dto.AgreementVersions; import com.howaboutus.backend.auth.service.dto.GoogleUserInfo; import com.howaboutus.backend.auth.service.dto.LoginResult; import com.howaboutus.backend.auth.service.dto.RotateResult; +import com.howaboutus.backend.common.error.CustomException; +import com.howaboutus.backend.common.error.ErrorCode; import com.howaboutus.backend.common.integration.google.GoogleOAuthClient; import com.howaboutus.backend.user.entity.User; import com.howaboutus.backend.user.service.UserService; @@ -37,22 +45,49 @@ class AuthServiceTest { @Mock private RefreshTokenService refreshTokenService; + @Mock + private AgreementService agreementService; + + @Mock + private Clock clock; + @Test @DisplayName("신규 사용자 로그인 시 회원가입 후 Access Token과 Refresh Token을 발급한다") void registersNewUserAndReturnsTokens() { GoogleUserInfo userInfo = new GoogleUserInfo("google-123", "test@gmail.com", "테스트", null); User mockUser = User.ofGoogle("google-123", "test@gmail.com", "테스트", null); + AgreementVersions versions = new AgreementVersions("1.0", "1.0"); + Instant now = Instant.parse("2026-06-08T10:00:00Z"); given(googleOAuthClient.login("auth-code")).willReturn(userInfo); - given(userService.getOrCreateGoogleUser("google-123", "test@gmail.com", "테스트", null)).willReturn(mockUser); + given(userService.findGoogleUser("google-123")).willReturn(Optional.empty()); + given(agreementService.currentVersions()).willReturn(versions); + given(clock.instant()).willReturn(now); + given(userService.getOrCreateGoogleUser("google-123", "test@gmail.com", "테스트", null, versions, now)) + .willReturn(mockUser); given(jwtProvider.generateAccessToken(any())).willReturn("jwt-token"); given(refreshTokenService.create(any())).willReturn("1:refresh-uuid"); - LoginResult result = authService.googleLogin("auth-code"); + LoginResult result = authService.googleLogin("auth-code", true); assertThat(result.accessToken()).isEqualTo("jwt-token"); assertThat(result.refreshToken()).isEqualTo("1:refresh-uuid"); } + @Test + @DisplayName("신규 사용자가 약관에 동의하지 않으면 가입과 토큰 발급을 거부한다") + void rejectsNewUserWithoutAgreementAcceptance() { + GoogleUserInfo userInfo = new GoogleUserInfo("google-123", "test@gmail.com", "테스트", null); + given(googleOAuthClient.login("auth-code")).willReturn(userInfo); + given(userService.findGoogleUser("google-123")).willReturn(Optional.empty()); + willThrow(new CustomException(ErrorCode.AGREEMENTS_NOT_ACCEPTED)) + .given(agreementService).validateAccepted(false); + + assertThatThrownBy(() -> authService.googleLogin("auth-code", false)) + .isInstanceOf(CustomException.class) + .satisfies(e -> assertThat(((CustomException)e).getErrorCode()) + .isEqualTo(ErrorCode.AGREEMENTS_NOT_ACCEPTED)); + } + @Test @DisplayName("기존 사용자 로그인 시 조회 후 Access Token과 Refresh Token을 발급한다") void returnsTokensForExistingUser() { @@ -60,13 +95,49 @@ void returnsTokensForExistingUser() { User existingUser = User.ofGoogle("google-123", "test@gmail.com", "테스트", null); given(googleOAuthClient.login("auth-code")).willReturn(userInfo); - given(userService.getOrCreateGoogleUser("google-123", "test@gmail.com", "테스트", null)) - .willReturn(existingUser); + given(userService.findGoogleUser("google-123")).willReturn(Optional.of(existingUser)); + given(jwtProvider.generateAccessToken(any())).willReturn("jwt-token"); + given(refreshTokenService.create(any())).willReturn("1:refresh-uuid"); + + LoginResult result = authService.googleLogin("auth-code", true); + + assertThat(result.accessToken()).isEqualTo("jwt-token"); + assertThat(result.refreshToken()).isEqualTo("1:refresh-uuid"); + } + + @Test + @DisplayName("기존 사용자의 약관 버전이 오래됐고 동의하지 않으면 재동의를 요구한다") + void rejectsExistingUserWithStaleAgreementsWithoutAcceptance() { + GoogleUserInfo userInfo = new GoogleUserInfo("google-123", "test@gmail.com", "테스트", null); + User existingUser = User.ofGoogle("google-123", "test@gmail.com", "테스트", null); + given(googleOAuthClient.login("auth-code")).willReturn(userInfo); + given(userService.findGoogleUser("google-123")).willReturn(Optional.of(existingUser)); + given(agreementService.needsReacceptance(existingUser)).willReturn(true); + + assertThatThrownBy(() -> authService.googleLogin("auth-code", false)) + .isInstanceOf(CustomException.class) + .satisfies(e -> assertThat(((CustomException)e).getErrorCode()) + .isEqualTo(ErrorCode.AGREEMENTS_REACCEPTANCE_REQUIRED)); + } + + @Test + @DisplayName("기존 사용자의 약관 버전이 오래됐고 동의하면 갱신 후 토큰을 발급한다") + void updatesStaleAgreementsAndReturnsTokens() { + GoogleUserInfo userInfo = new GoogleUserInfo("google-123", "test@gmail.com", "테스트", null); + User existingUser = User.ofGoogle("google-123", "test@gmail.com", "테스트", null); + AgreementVersions versions = new AgreementVersions("1.1", "1.0"); + Instant now = Instant.parse("2026-06-08T10:00:00Z"); + given(googleOAuthClient.login("auth-code")).willReturn(userInfo); + given(userService.findGoogleUser("google-123")).willReturn(Optional.of(existingUser)); + given(agreementService.needsReacceptance(existingUser)).willReturn(true); + given(agreementService.currentVersions()).willReturn(versions); + given(clock.instant()).willReturn(now); given(jwtProvider.generateAccessToken(any())).willReturn("jwt-token"); given(refreshTokenService.create(any())).willReturn("1:refresh-uuid"); - LoginResult result = authService.googleLogin("auth-code"); + LoginResult result = authService.googleLogin("auth-code", true); + verify(userService).acceptCurrentAgreements(existingUser.getId(), versions, now); assertThat(result.accessToken()).isEqualTo("jwt-token"); assertThat(result.refreshToken()).isEqualTo("1:refresh-uuid"); } From aa2972130aba568c52bacc526928895a04ed6213 Mon Sep 17 00:00:00 2001 From: parkjuyeong0312 Date: Mon, 8 Jun 2026 11:34:14 +0900 Subject: [PATCH 09/12] =?UTF-8?q?docs:=20=EC=95=BD=EA=B4=80=20=EB=8F=99?= =?UTF-8?q?=EC=9D=98=20=EA=B8=B0=EB=8A=A5=EA=B3=BC=20ERD=20=EB=B0=98?= =?UTF-8?q?=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/ai/erd.md | 6 ++++++ docs/ai/features.md | 3 +++ 2 files changed, 9 insertions(+) diff --git a/docs/ai/erd.md b/docs/ai/erd.md index efd87795..125c5bb7 100644 --- a/docs/ai/erd.md +++ b/docs/ai/erd.md @@ -19,11 +19,17 @@ Google OAuth 기반 사용자 정보 | profile_image_url | VARCHAR(500) | NULLABLE | 프로필 이미지 URL | | provider | VARCHAR(20) | NOT NULL, DEFAULT 'GOOGLE' | OAuth 제공자 | | provider_id | VARCHAR(255) | NOT NULL | OAuth 제공자 측 사용자 ID | +| tos_version | VARCHAR(30) | NOT NULL | 동의한 이용약관 버전 | +| tos_accepted_at | TIMESTAMP WITH TIME ZONE | NOT NULL | 이용약관 동의 또는 재동의 시각 | +| privacy_version | VARCHAR(30) | NOT NULL | 동의한 개인정보 처리방침 버전 | +| privacy_accepted_at | TIMESTAMP WITH TIME ZONE | NOT NULL | 개인정보 처리방침 동의 또는 재동의 시각 | | created_at | TIMESTAMP | NOT NULL, DEFAULT NOW() | 가입일시 | | updated_at | TIMESTAMP | NOT NULL, DEFAULT NOW() | 수정일시 | **제약:** UNIQUE(provider, provider_id) +약관 원문은 DB에 저장하지 않고 백엔드 리소스 파일로 관리한다. 현재 버전은 `application.yaml`의 `app.agreements` 설정을 기준으로 하며, 프론트엔드는 버전 문자열을 전송하지 않는다. + --- ## 2. rooms (여행 방) diff --git a/docs/ai/features.md b/docs/ai/features.md index c42091d4..e43e36cd 100644 --- a/docs/ai/features.md +++ b/docs/ai/features.md @@ -48,6 +48,9 @@ Caddy는 `caddy-ratelimit` 기반 IP 제한으로 엣지에서 넓고 거친 폭 | 상태 | 기능 | 설명 | ERD 연관 | |------|------|------|----------| | `[x]` | 구글 OAuth 로그인 | Google 계정으로 소셜 로그인 | users | +| `[x]` | 현재 약관 조회 | `GET /api/agreements/current`로 현재 이용약관과 개인정보 처리방침 원문 Markdown, 버전, 문서 타입을 비인증 상태에서 조회한다. 약관 원문은 백엔드 리소스 파일에 있고 현재 버전은 `application.yaml`에서 관리한다 | - | +| `[x]` | 가입 약관 동의 기록 | `POST /auth/google/login`에서 프론트는 `agreementsAccepted`만 전송한다. 신규 사용자는 값이 `true`일 때만 생성되며, 백엔드 현재 약관 버전과 서버 시간을 `users`에 저장한다 | users | +| `[x]` | 약관 재동의 | 기존 사용자의 저장 약관 버전이 현재 서버 버전과 다르면 로그인 시 재동의가 필요하다. 로그인 요청에서 `agreementsAccepted=true`이면 서버 현재 버전으로 갱신 후 토큰을 발급하고, 이미 로그인된 사용자는 `POST /api/users/me/agreements`로 현재 약관에 재동의한다 | users | | `[x]` | 토큰 재발급 (Refresh) | Refresh Token Rotation: UUID 기반 HTTP-only 쿠키(path=/auth/refresh), Redis `refresh:token:{uuid}`→userId(TTL 14일) / `refresh:user:{userId}`→Set\. Replay Detection 으로 탈취 시 전체 무효화 | Redis | | `[x]` | 로그아웃 | 단일 기기 로그아웃: 요청한 토큰만 삭제 | Redis | | `[x]` | 내 정보 조회 | 로그인된 사용자 프로필 조회 | users | From 6a3b5c38560e4bd31e04bdbd3c9e215ba584f4d3 Mon Sep 17 00:00:00 2001 From: parkjuyeong0312 Date: Mon, 8 Jun 2026 11:37:21 +0900 Subject: [PATCH 10/12] =?UTF-8?q?test:=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20?= =?UTF-8?q?=ED=86=B5=ED=95=A9=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=95=BD?= =?UTF-8?q?=EA=B4=80=20=EB=8F=99=EC=9D=98=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/howaboutus/backend/auth/AuthIntegrationTest.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/test/java/com/howaboutus/backend/auth/AuthIntegrationTest.java b/src/test/java/com/howaboutus/backend/auth/AuthIntegrationTest.java index 1908cd7b..4f8282af 100644 --- a/src/test/java/com/howaboutus/backend/auth/AuthIntegrationTest.java +++ b/src/test/java/com/howaboutus/backend/auth/AuthIntegrationTest.java @@ -38,7 +38,7 @@ void loginIssuesTokenCookies() throws Exception { mockMvc.perform(post("/auth/google/login") .contentType(MediaType.APPLICATION_JSON) .content(""" - {"code": "auth-code-login"} + {"code": "auth-code-login", "agreementsAccepted": true} """)) .andExpect(status().isOk()) .andExpect(cookie().exists("access_token")) @@ -56,7 +56,7 @@ void refreshRotatesTokenAndRejectsOld() throws Exception { MvcResult loginResult = mockMvc.perform(post("/auth/google/login") .contentType(MediaType.APPLICATION_JSON) .content(""" - {"code": "auth-code-rotate"} + {"code": "auth-code-rotate", "agreementsAccepted": true} """)) .andExpect(status().isOk()) .andReturn(); @@ -89,7 +89,7 @@ void logoutInvalidatesRefreshToken() throws Exception { MvcResult loginResult = mockMvc.perform(post("/auth/google/login") .contentType(MediaType.APPLICATION_JSON) .content(""" - {"code": "auth-code-logout"} + {"code": "auth-code-logout", "agreementsAccepted": true} """)) .andExpect(status().isOk()) .andReturn(); From 8ee1af456d31a764652cce6bfa681ac314da7e00 Mon Sep 17 00:00:00 2001 From: parkjuyeong0312 Date: Mon, 8 Jun 2026 11:39:23 +0900 Subject: [PATCH 11/12] =?UTF-8?q?style:=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20?= =?UTF-8?q?=EC=95=BD=EA=B4=80=20=EB=AA=85=EC=84=B8=20=EC=A4=84=20=EA=B8=B8?= =?UTF-8?q?=EC=9D=B4=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/howaboutus/backend/auth/controller/AuthController.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/howaboutus/backend/auth/controller/AuthController.java b/src/main/java/com/howaboutus/backend/auth/controller/AuthController.java index af04c226..81e249ef 100644 --- a/src/main/java/com/howaboutus/backend/auth/controller/AuthController.java +++ b/src/main/java/com/howaboutus/backend/auth/controller/AuthController.java @@ -45,7 +45,8 @@ public class AuthController { @Operation( summary = "Google 로그인", - description = "Google OAuth2 인가 코드를 사용하여 로그인을 진행하고, 쿠키에 액세스 토큰 및 리프레시 토큰을 심어 반환합니다. 신규 가입자와 변경된 약관에 다시 동의해야 하는 기존 사용자는 agreementsAccepted=true를 보내야 합니다." + description = "Google OAuth2 인가 코드를 사용하여 로그인을 진행하고, 쿠키에 액세스 토큰 및 리프레시 토큰을 심어 반환합니다. " + + "신규 가입자와 변경된 약관에 다시 동의해야 하는 기존 사용자는 agreementsAccepted=true를 보내야 합니다." ) @ApiResponse(responseCode = "200", description = "로그인 성공") @ApiErrorCodes({ From 6b287b5a5b8950bb664ff3800f6b1b017774a8d8 Mon Sep 17 00:00:00 2001 From: parkjuyeong0312 Date: Mon, 8 Jun 2026 13:14:24 +0900 Subject: [PATCH 12/12] =?UTF-8?q?fix:=20=EC=95=BD=EA=B4=80=20=EC=A0=95?= =?UTF-8?q?=EC=B1=85=20=EB=A7=81=ED=81=AC=EC=99=80=20=EA=B2=80=EC=A6=9D=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EB=B3=B4=EA=B0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../resources/agreements/terms-of-service.md | 8 +-- .../service/AgreementServiceTest.java | 64 +++++++++++++++++++ .../UserAgreementControllerTest.java | 38 +++++++++++ 3 files changed, 106 insertions(+), 4 deletions(-) diff --git a/src/main/resources/agreements/terms-of-service.md b/src/main/resources/agreements/terms-of-service.md index 2db8b1da..b45d128a 100644 --- a/src/main/resources/agreements/terms-of-service.md +++ b/src/main/resources/agreements/terms-of-service.md @@ -8,7 +8,7 @@ 이 약관은 우때(이하 "서비스")가 제공하는 여행 계획 협업 서비스의 이용 조건과 절차, 이용자와 운영자 간의 권리·의무 및 책임 사항을 정함을 목적으로 합니다. -서비스의 개인정보 처리에 관한 사항은 별도의 [개인정보 처리방침](privacy-policy.md)에 따릅니다. 서비스 이용 제한, 신고, 이의제기 등 세부 운영 기준은 [운영정책](operation-policy.md)에 따르며, 저작권 침해 신고와 게시중단 절차는 [저작권 정책](copyright-policy.md)에 따릅니다. 이용자는 이 약관과 관련 정책을 확인하고 동의한 뒤 서비스를 이용해야 합니다. +서비스의 개인정보 처리에 관한 사항은 별도의 [개인정보 처리방침](https://uttae.com/policies/privacy)에 따릅니다. 서비스 이용 제한, 신고, 이의제기 등 세부 운영 기준은 [운영정책](https://uttae.com/policies/operation)에 따르며, 저작권 침해 신고와 게시중단 절차는 [저작권 정책](https://uttae.com/policies/copyright)에 따릅니다. 이용자는 이 약관과 관련 정책을 확인하고 동의한 뒤 서비스를 이용해야 합니다. --- @@ -97,7 +97,7 @@ 1. 이용자는 불법·유해 콘텐츠, 권리 침해 콘텐츠, 서비스 운영을 방해하는 콘텐츠를 발견한 경우 team.uttae@gmail.com으로 신고할 수 있습니다. 2. 운영자는 신고된 콘텐츠 또는 운영 과정에서 발견한 콘텐츠가 이 약관 또는 관련 법령을 위반한다고 판단하는 경우 해당 콘텐츠를 숨김·삭제하거나 작성자의 서비스 이용을 제한할 수 있습니다. -3. 저작권 등 권리 침해 신고, 게시중단, 재게시 요청에 관한 세부 절차는 [저작권 정책](copyright-policy.md)에 따릅니다. +3. 저작권 등 권리 침해 신고, 게시중단, 재게시 요청에 관한 세부 절차는 [저작권 정책](https://uttae.com/policies/copyright)에 따릅니다. 4. 운영자는 위반 여부 확인을 위해 필요한 범위에서 콘텐츠를 검토할 수 있으나, 모든 콘텐츠를 사전에 검토할 의무를 부담하지 않습니다. --- @@ -105,7 +105,7 @@ ## 제10조 (서비스 이용 제한) 1. 운영자는 이용자가 제8조 또는 제9조를 위반한 경우 사전 통보 없이 서비스 이용을 제한하거나 계정을 삭제할 수 있습니다. -2. 운영자는 위반 행위의 내용, 반복 여부, 피해 규모, 긴급성을 고려하여 게시물 삭제, 채팅 제한, 여행 방 접근 제한, 계정 정지 또는 계정 삭제 조치를 할 수 있습니다. 세부 기준은 [운영정책](operation-policy.md)에 따릅니다. +2. 운영자는 위반 행위의 내용, 반복 여부, 피해 규모, 긴급성을 고려하여 게시물 삭제, 채팅 제한, 여행 방 접근 제한, 계정 정지 또는 계정 삭제 조치를 할 수 있습니다. 세부 기준은 [운영정책](https://uttae.com/policies/operation)에 따릅니다. 3. 이용 제한 처분에 이의가 있는 경우 team.uttae@gmail.com으로 이의를 신청할 수 있으며, 운영자는 7일 이내에 처리 결과를 안내합니다. --- @@ -115,7 +115,7 @@ 1. 이용자가 서비스 내에서 작성·등록한 콘텐츠의 저작권은 해당 이용자에게 있습니다. 2. 이용자는 서비스 운영, 저장, 백업, 공유, 실시간 동기화, AI 어시스턴트 응답 생성 등 기능 제공에 필요한 범위에서 운영자가 콘텐츠를 이용할 수 있도록 비독점적·무상의 사용권을 부여합니다. 3. 이용자는 본인이 작성·등록한 콘텐츠에 대해 필요한 권리를 보유하고 있으며, 해당 콘텐츠가 제3자의 권리 또는 관련 법령을 침해하지 않음을 보증합니다. -4. 이용자가 작성·등록한 콘텐츠가 제3자의 권리를 침해한다는 신고 또는 이의제기가 있는 경우, 운영자는 관련 법령과 [저작권 정책](copyright-policy.md)에 따라 게시중단, 삭제, 접근 제한, 서비스 이용 제한 등 필요한 조치를 할 수 있습니다. +4. 이용자가 작성·등록한 콘텐츠가 제3자의 권리를 침해한다는 신고 또는 이의제기가 있는 경우, 운영자는 관련 법령과 [저작권 정책](https://uttae.com/policies/copyright)에 따라 게시중단, 삭제, 접근 제한, 서비스 이용 제한 등 필요한 조치를 할 수 있습니다. 5. 운영자는 이용자의 콘텐츠를 광고·마케팅 목적으로 사용하지 않습니다. 6. 운영자가 제공하는 서비스 화면, 기능, 로고, 디자인, 데이터베이스, 프로그램 등 서비스 자체의 권리는 운영자 또는 정당한 권리자에게 귀속됩니다. diff --git a/src/test/java/com/howaboutus/backend/agreements/service/AgreementServiceTest.java b/src/test/java/com/howaboutus/backend/agreements/service/AgreementServiceTest.java index 195605fb..16a479f5 100644 --- a/src/test/java/com/howaboutus/backend/agreements/service/AgreementServiceTest.java +++ b/src/test/java/com/howaboutus/backend/agreements/service/AgreementServiceTest.java @@ -2,11 +2,15 @@ import static org.assertj.core.api.Assertions.*; +import java.io.IOException; +import java.nio.charset.Charset; import java.nio.charset.StandardCharsets; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.core.io.ByteArrayResource; +import org.springframework.core.io.ClassPathResource; +import org.springframework.core.io.Resource; import com.howaboutus.backend.agreements.config.AgreementProperties; import com.howaboutus.backend.common.error.CustomException; @@ -35,6 +39,27 @@ void returnsCurrentAgreements() { assertThat(result.get(1).version()).isEqualTo("1.0"); } + @Test + @DisplayName("현재 이용약관 원문은 프론트 공개 정책 URL을 사용한다") + void termsOfServiceUsesPublicPolicyUrls() { + AgreementService service = new AgreementService(new AgreementProperties( + new AgreementProperties.AgreementDocument( + "1.0", new ClassPathResource("agreements/terms-of-service.md")), + new AgreementProperties.AgreementDocument( + "1.0", new ClassPathResource("agreements/privacy-policy.md")) + )); + + var result = service.getCurrentAgreements(); + + assertThat(result.get(0).content()) + .doesNotContain("(privacy-policy.md)") + .doesNotContain("(operation-policy.md)") + .doesNotContain("(copyright-policy.md)") + .contains("(https://uttae.com/policies/privacy)") + .contains("(https://uttae.com/policies/operation)") + .contains("(https://uttae.com/policies/copyright)"); + } + @Test @DisplayName("약관 버전 설정이 비어 있으면 AGREEMENT_CONFIGURATION_INVALID 예외") void throwsForBlankVersion() { @@ -49,6 +74,36 @@ void throwsForBlankVersion() { .isEqualTo(ErrorCode.AGREEMENT_CONFIGURATION_INVALID)); } + @Test + @DisplayName("약관 리소스 설정이 없으면 AGREEMENT_CONFIGURATION_INVALID 예외") + void throwsForMissingResource() { + AgreementService service = new AgreementService(new AgreementProperties( + new AgreementProperties.AgreementDocument("1.0", null), + new AgreementProperties.AgreementDocument( + "1.0", new ByteArrayResource("# 개인정보 처리방침".getBytes(StandardCharsets.UTF_8))) + )); + + assertThatThrownBy(service::getCurrentAgreements) + .isInstanceOf(CustomException.class) + .satisfies(e -> assertThat(((CustomException)e).getErrorCode()) + .isEqualTo(ErrorCode.AGREEMENT_CONFIGURATION_INVALID)); + } + + @Test + @DisplayName("약관 리소스 읽기에 실패하면 AGREEMENT_CONFIGURATION_INVALID 예외") + void throwsForUnreadableResource() { + AgreementService service = new AgreementService(new AgreementProperties( + new AgreementProperties.AgreementDocument("1.0", unreadableResource()), + new AgreementProperties.AgreementDocument( + "1.0", new ByteArrayResource("# 개인정보 처리방침".getBytes(StandardCharsets.UTF_8))) + )); + + assertThatThrownBy(service::getCurrentAgreements) + .isInstanceOf(CustomException.class) + .satisfies(e -> assertThat(((CustomException)e).getErrorCode()) + .isEqualTo(ErrorCode.AGREEMENT_CONFIGURATION_INVALID)); + } + private AgreementProperties properties(String tosVersion, String tosContent, String privacyVersion, String privacyContent) { return new AgreementProperties( @@ -58,4 +113,13 @@ tosVersion, new ByteArrayResource(tosContent.getBytes(StandardCharsets.UTF_8))), privacyVersion, new ByteArrayResource(privacyContent.getBytes(StandardCharsets.UTF_8))) ); } + + private Resource unreadableResource() { + return new ByteArrayResource("".getBytes(StandardCharsets.UTF_8)) { + @Override + public String getContentAsString(Charset charset) throws IOException { + throw new IOException("read failed"); + } + }; + } } diff --git a/src/test/java/com/howaboutus/backend/user/controller/UserAgreementControllerTest.java b/src/test/java/com/howaboutus/backend/user/controller/UserAgreementControllerTest.java index fc640067..4d96351b 100644 --- a/src/test/java/com/howaboutus/backend/user/controller/UserAgreementControllerTest.java +++ b/src/test/java/com/howaboutus/backend/user/controller/UserAgreementControllerTest.java @@ -24,6 +24,8 @@ import com.howaboutus.backend.auth.filter.JwtAuthenticationFilter; import com.howaboutus.backend.auth.service.JwtProvider; import com.howaboutus.backend.common.config.SecurityConfig; +import com.howaboutus.backend.common.error.CustomException; +import com.howaboutus.backend.common.error.ErrorCode; import com.howaboutus.backend.common.error.GlobalExceptionHandler; import com.howaboutus.backend.common.security.JwtAuthenticationEntryPoint; import com.howaboutus.backend.user.service.UserService; @@ -69,6 +71,42 @@ void acceptsCurrentAgreementsForAuthenticatedUser() throws Exception { verify(userService).acceptCurrentAgreements(eq(1L), any(), any()); } + @Test + @DisplayName("재동의 요청에서 agreementsAccepted=false이면 400을 반환한다") + void returns400WhenAgreementsNotAccepted() throws Exception { + given(jwtProvider.extractUserId("valid-jwt")).willReturn(1L); + willThrow(new CustomException(ErrorCode.AGREEMENTS_NOT_ACCEPTED)) + .given(agreementService).validateAccepted(false); + + mockMvc.perform(post("/api/users/me/agreements") + .cookie(new Cookie("access_token", "valid-jwt")) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"agreementsAccepted": false} + """)) + .andExpect(status().isBadRequest()); + + verify(userService, never()).acceptCurrentAgreements(any(), any(), any()); + } + + @Test + @DisplayName("재동의 요청에서 agreementsAccepted가 누락되면 400을 반환한다") + void returns400WhenAgreementsAcceptedMissing() throws Exception { + given(jwtProvider.extractUserId("valid-jwt")).willReturn(1L); + willThrow(new CustomException(ErrorCode.AGREEMENTS_NOT_ACCEPTED)) + .given(agreementService).validateAccepted(null); + + mockMvc.perform(post("/api/users/me/agreements") + .cookie(new Cookie("access_token", "valid-jwt")) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {} + """)) + .andExpect(status().isBadRequest()); + + verify(userService, never()).acceptCurrentAgreements(any(), any(), any()); + } + @Test @DisplayName("인증 없이 현재 약관 재동의 요청 시 401을 반환한다") void returns401WithoutAuthentication() throws Exception {