You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
판정은 요청 시점에 한다. 토큰 발급 시점에 굳히지 않는다. 이미 발급된 JWT를 가진 사용자도 필수 동의 기준일이 오르면 즉시 게이트에 걸린다.
판정 주체는 하나다. 동의 상태 조회 API, 로그인 응답 힌트, 보호 API 차단이 모두 같은 Evaluator 결과를 쓴다.
동의 정책을 JWT 인증 필터에 결합하지 않는다. 필터는 신원 확립까지만 책임진다.
필수 동의만 차단 조건이다. 선택 동의 미동의는 해당 기능 비활성화로만 반영한다.
Gate는 독립적이고 추가 가능하다. 다만 이번 범위는 AccountStateGate·AgreementGate 둘뿐이며, 요구가 없는 Gate를 미리 만들지 않는다.
요청 파이프라인
flowchart LR
A[Social Login] --> B[기존 JWT 발급]
B --> C[JwtAuthenticationFilter<br/>신원 확립만]
C --> D[CustomUserContext 로딩]
D --> E{AccountStateGate<br/>계정 생명주기}
E -->|DELETED / SUSPENDED| X1[차단]
E -->|통과| F{AgreementGate<br/>필수 동의}
F -->|미충족| X2["403 AGREEMENT_REQUIRED<br/>(부족 목록은 조회 API로)"]
F -->|충족| G[Controller]
Loading
신규 회원도 예외 없이 이 경로를 탄다. 로그인 응답은 단일 스키마이며 분기하지 않는다.
판정 로직 (Agreement Evaluator)
flowchart TD
CFG[["설정<br/>필수 유형 목록<br/>유형별 기준일"]] --> J{유형별 대조}
L[(user_agreements)] --> M["사용자 · 유형별<br/>최신 이벤트 1건"]
M --> J
J -->|해당 유형 이벤트 없음| W[WAITING_TO_AGREE]
J -->|최신 이벤트가 REVOKE| W
J -->|recorded_at < 기준일| W
J -->|모든 필수 유형 AGREE<br/>+ 기준일 이후| OK[ELIGIBLE]
Loading
-- 유형별 최신 이벤트 1건씩SELECT agreement_type, action, recorded_at
FROM user_agreements
WHERE user_id = ?
AND agreement_type IN ('TERMS_OF_SERVICE', 'PRIVACY_COLLECTION_USE')
-- 유형별 최신 1건만 남긴 뒤-- 전부 action = AGREE 이고 recorded_at >= 유형별 기준일 이면 ELIGIBLE
판정 규칙:
필수 유형 목록과 유형별 기준일은 설정(서버 구성)이 소유한다. 테이블에 두지 않는다. "무엇이 필수인가"는 시점에 따라 바뀌는 정책이고, 테이블은 "무엇에 동의했다"는 사실만 기록한다.
유형별로 비교한다. 이용약관만 교체됐는데 개인정보 동의까지 재동의를 요구하면 안 된다.
최신 이벤트의 action을 먼저 본다. REVOKE면 기준일과 무관하게 미충족.
약관 교체 = 해당 유형의 기준일 상향. 별도 버전 문자열도, 문서 버전 관리 체계도 두지 않는다.
기준일 오입력은 전 사용자 차단으로 이어진다. 설정 변경은 리뷰 대상으로 관리한다.
데이터 모델
테이블 하나만 추가한다. 문서 레지스트리를 별도 테이블로 만들지 않으며, users 테이블은 수정하지 않는다.
user_agreements -- append-only. UPDATE / DELETE 금지
id bigint PK
user_id bigintNOT NULL-- FK -> users.id
agreement_type varchar(50) NOT NULL-- TERMS_OF_SERVICE | PRIVACY_COLLECTION_USE
action varchar(20) NOT NULL-- AGREE | REVOKE
document_content mediumtext NOT NULL-- 동의 시점 문서 원문 스냅샷
recorded_at datetime(6) NOT NULL-- 서버 기록 시각
input_context varchar(20) NOT NULL-- INDIVIDUAL | BULK
client_ip varchar(45)
user_agent varchar(512)
KEY idx_user_agreements_user_type_recorded (user_id, agreement_type, recorded_at)
CONSTRAINT fk_user_agreements_user_id FOREIGN KEY (user_id) REFERENCES users (id)
컬럼 근거
컬럼
역할
agreement_type
어느 항목의 동의인가. 판정이 유형별 최신 1건을 뽑으므로 필수. 유형 없이 원문만 있으면 종류를 텍스트에서 추측해야 한다
document_content
무엇에 동의했는가. 동의 시점 원문을 그대로 동결한다. 수집 목적·법적 근거·거부 시 처리·철회 시 처리는 이 원문 안에 포함된다
action
명시적 의사표시
user_id
누가. 계정 자체가 소셜로 검증된 신원이다. 소셜 공급자는 기록하지 않는다 — 한 사용자가 여러 공급자를 연동할 수 있어 하나를 고르면 오히려 왜곡된다. 탈퇴는 소프트 삭제(status = DELETED)라 users 행이 남으므로 조인이 계속 유효하다
input_context
어떻게. 개별 체크인지 전체 동의 한 번인지 구분
recorded_at
언제. 서버 기록 시각 단독. 클라이언트 동의 일자를 받지 않는다
client_ip / user_agent
동의 정황. 게이트웨이가 XFF를 실제 접속 주소로 채우므로 IP는 신뢰 가능하다
append-only
이 테이블은 UPDATE / DELETE 하지 않는다. 철회는 REVOKE 행 추가로 표현하며 기존 AGREE 행을 덮지 않는다.
행을 덮으면 "언제부터 언제까지 동의 상태였는가"가 사라진다. 3월에 동의하고 8월에 철회한 사용자의 행을 REVOKE로 덮으면 그 5개월간 데이터를 처리한 근거가 없어지고, 동의 후 철회한 사용자와 애초에 동의하지 않은 사용자를 구분할 수 없다. document_content도 함께 덮여 어느 원문에 동의했는지 사라진다.
현재 상태가 필요하면 (user_id, agreement_type, recorded_at) 인덱스로 유형별 최신 1건을 조회한다.
타입·인덱스
document_content는 mediumtext. text(64KB)는 약관 HTML 원문에서 넘칠 수 있고, 저장 시 잘리면 증빙이 깨진다.
인덱스는 (user_id, agreement_type, recorded_at). 판정 쿼리가 유형별 최신 1건을 뽑기 때문이며, (user_id, recorded_at)으로는 유형 필터가 인덱스에 걸리지 않는다.
증빙 대응
입증 대상
위치
누가
user_id (계정 자체가 소셜로 검증된 신원)
무엇을
agreement_type, document_content
왜
document_content 원문에 포함 (수집 목적·법적 근거)
어떻게
action, input_context
언제
recorded_at
거부하면
document_content 원문에 포함
철회하면
document_content 원문에 포함
원문을 동결해 저장하므로 "동의 시점에 사용자에게 무엇을 고지했는가"가 행 자체로 입증된다.
users 테이블
수정하지 않는다. 판정은 user_agreements만 조회한다.
users.status는 계정 생명주기 전용을 유지하며 WAITING_TO_AGREE 같은 값을 넣지 않는다. DELETED·SUSPENDED 같은 계정 상태와 정책 충족 상태는 직교하므로 같은 enum에 합치지 않는다.
동의 자격은 파생값이므로 컬럼으로 굳히지 않는다. Gate가 요청당 쿼리 1건을 추가하는데, 부담이 확인되면 캐시 컬럼이나 Redis를 나중에 얹을 수 있다. 파생값이라 사후 추가가 가능하므로 미리 하지 않는다.
API 계약
엔드포인트는 2개다. Gate는 엔드포인트가 아니다.
1. 동의 상태 조회
GET /api/v2/agreements/status — 일반 인증 필요, AgreementGate 면제
차단 응답에 부족 유형 목록을 싣지 않는다.GlobalResponse.error()가 data를 emptyList()로 고정하므로 목록을 실으려면 전역 응답 계약을 고쳐야 하는데 그럴 값어치가 없다. FE는 403을 받으면 동의 화면으로 전환하면서 조회 API를 호출한다.
즉 차단은 신호, 내용은 조회 API가 단일 출처다. 판정 주체가 하나라는 원칙은 유지된다.
구분은 errors[0].code로 한다. 이 프로젝트는 JwtExceptionType.EXPIRED_TOKEN도 403이므로 상태코드만으로는 401/권한없음/만료토큰과 구분되지 않는다.
4. 로그인 응답 힌트
기존 소셜 로그인 응답에 agreementRequired 불리언 필드 하나를 추가한다. 값은 Evaluator 결과를 쓰며 로그인 서비스가 따로 판정하지 않는다.
FE 라우팅용 힌트이며 집행 근거가 아니다. 로그인 시점의 스냅샷일 뿐이고, 실제 차단은 항상 Gate가 요청 시점에 한다. 이 값이 false여도 이후 요청에서 403이 날 수 있다.
없어도 기능은 동작한다. 신규 가입자가 홈에 진입했다가 403으로 튕기는 깜빡임을 없애는 용도다.
로그인 응답은 단일 스키마를 유지한다. 신규/기존 회원 분기, 다형 응답, oneOf를 만들지 않는다. 필드 하나가 늘 뿐이다.
AgreementGate 면제 범위
미충족 상태에서도 반드시 허용한다.
동의 상태 조회, 동의 제출
토큰 재발급, 로그아웃
계정 탈퇴, 개인정보 열람·삭제 등 정보주체 권리 행사 기능
탈퇴·로그아웃을 막으면 동의하지 않고는 서비스를 떠날 수 없게 된다. 동의 API 자체를 막으면 미충족 상태에서 영원히 빠져나올 수 없다.
면제는 전용 어노테이션으로 선언한다. 경로 목록을 Gate 안에 하드코딩하지 않는다. 기존 @SecurityPolicy에 속성을 얹지 않고 별도 어노테이션을 두는데, @SecurityPolicy는 인증 정책을 표현하는 것이고 이 아키텍처의 전제가 "동의 정책을 인증에 결합하지 않는다"이기 때문이다.
Gate 배치
인터셉터로 구현한다. 인증 필터 안에서 차단하지 않는다.
HandlerMethod에 접근되므로 면제 어노테이션을 직접 읽어 판정할 수 있다. 별도 경로 레지스트리가 필요 없다.
차단 응답이 @RestControllerAdvice를 타므로 기존 GlobalResponse 형식을 그대로 쓴다. 필터에서 차단하면 JSON을 직접 직렬화해야 하고 기존 에러 포맷과 어긋난다.
인증 이후 인터셉터 단계에 AccountStateGate → AgreementGate 배치
JWT 인증 필터에 동의 정책이 결합되지 않았음을 확인
403 AGREEMENT_REQUIRED 응답 계약 (기존 GlobalResponse 봉투 그대로, Error 수정 없음)
전용 면제 어노테이션으로 선언적 지정, 하드코딩 경로 목록 없음
미충족 사용자의 탈퇴·로그아웃·재발급 가능 검증
기존 발급 JWT 보유자가 기준일 상향 즉시 차단되는지 검증
CP4. 로그인 응답 및 잔재 제거
로그인 응답에 agreementRequired 필드 추가 (집행 근거 아님, FE 라우팅용)
로그인 응답이 단일 스키마임을 확인 (분기·다형 응답 없음)
signupToken 관련 잔재가 코드·문서·OpenAPI에 남아 있지 않음 확인
완료 기준
신규·기존 회원 모두 소셜 로그인 직후 동일한 응답 형태로 JWT를 받는다
필수 동의 미충족 사용자는 보호 API에서 403 + errors[0].code === "AGREEMENT_REQUIRED" 를 받는다
미충족 사용자도 동의 조회·제출·로그아웃·탈퇴는 수행할 수 있다
동의 제출 후 재요청 시 별도 재로그인 없이 보호 API에 접근된다
동의 이력에 누가·무엇을·왜·어떻게·언제·거부하면·철회하면이 컬럼과 원문 스냅샷으로 입증된다
동의 이력이 append-only이며 철회가 REVOKE 행으로 쌓인다
기준일 상향 시 기존 동의자가 재동의 대상이 된다
선택 동의 미동의는 접근을 차단하지 않는다
users 테이블이 수정되지 않으며 users.status가 동의 판정의 근거가 아니다
확정 사항 (재논의 불필요)
기존 회원도 새로 동의를 받는다. 소급 인정하지 않는다. 도입 시점 기준일을 배포일로 잡으면 그 이전 동의 기록이 없거나 기준일 이전이므로 전원이 자연스럽게 게이트에 걸린다. 별도 소급 로직·유예 로직을 만들지 않는다.
문서 레지스트리 테이블과 버전 관리 체계를 두지 않는다. 원문 스냅샷을 이력 행에 직접 저장하고, 교체는 기준일 상향으로 표현한다.
동의 이력은 append-only다.
users 테이블은 수정하지 않는다.
아직 정책 결정이 필요한 항목
법무·운영·제품 확인이 선행돼야 한다. 근거가 필요하면 개인정보보호위원회·국가법령정보센터 등 공식 출처를 사용한다. #361은 산출물·결정 기록 없이 닫혀 있어 근거로 쓸 수 없다.
아래 항목은 구현 착수를 막지 않는다. 필수 2종(TERMS_OF_SERVICE, PRIVACY_COLLECTION_USE)은 확정됐고, 유형 목록과 기준일이 설정 기반이라 나머지는 값 채우기와 후속 작업으로 처리된다.
동의 항목 매트릭스 — 제3자 제공 / 마케팅의 추가와 필수·선택 구분. 설정에 유형을 더하면 되므로 배포 전까지 확정하면 된다
기준일 값과 상향 판단 기준 — 도입 시점 기준일은 배포 시 설정값. 어떤 약관 변경이 재동의를 유발하는지는 운영 기준
미충족 방치 상한 — 동의하지 않고 로그인만 반복하는 계정의 처리. 후속 배치 작업
필수 항목 철회 가능 여부 — 우선 AGREE만 열고 REVOKE 노출은 결정 후
동의 이전 개인정보 수집 범위 — 계정 생성 시점에 저장할 최소 항목
미성년자·연령 기준, 보유기간·파기 기준
후속 영향
[FE/Auth] 소셜 신규가입 동의 화면 및 가입 완료 흐름 연동 #363(FE)은 재정의가 필요하다. 현재 SIGNUP_REQUIRED 상태 수신, signupToken 보관, 가입 완료 후 소셜 로그인 재호출을 전제로 작성돼 있으며 이 전제가 모두 사라진다. 새 FE 계약은 (1) 로그인은 항상 정상 세션으로 진입, (2) 동의 필요 여부는 로그인 응답 힌트 또는 상태 조회 API로 확인, (3) 동의 제출 시 표시한 원문을 함께 전송, (4) 403 AGREEMENT_REQUIRED를 동의 화면 전환 신호로 처리하는 형태다.
배포 순서: FE가 동의 화면을 처리할 수 있는 상태로 먼저 배포돼야 한다. Gate가 먼저 켜지면 전 사용자가 처리 불가능한 403을 받는다.
목적
로그인은 신원 인증만 담당하고, 이용 자격 판정은 인증 이후의 독립된 Gate가 수행하는 구조로 전환한다. 필수 동의 충족 여부는 그 Gate 중 하나(AgreementGate)가 요청 시점에 판정한다.
기존 본문의
signupToken전용 JWT, signup token 저장소, 가입 완료 후 소셜 로그인 재호출 방식은 폐기한다. 해당 방향의 bottle-note/bottle-note-api-server#689 는 병합 없이 닫혔다.폐기된 원래 요구사항과 폐기 사유는 이 이슈의 아카이브 코멘트에 보존돼 있다.
아키텍처 원칙
책임을 넷으로 분리하고 서로 침범하지 않는다.
핵심 규칙:
요청 파이프라인
flowchart LR A[Social Login] --> B[기존 JWT 발급] B --> C[JwtAuthenticationFilter<br/>신원 확립만] C --> D[CustomUserContext 로딩] D --> E{AccountStateGate<br/>계정 생명주기} E -->|DELETED / SUSPENDED| X1[차단] E -->|통과| F{AgreementGate<br/>필수 동의} F -->|미충족| X2["403 AGREEMENT_REQUIRED<br/>(부족 목록은 조회 API로)"] F -->|충족| G[Controller]신규 회원도 예외 없이 이 경로를 탄다. 로그인 응답은 단일 스키마이며 분기하지 않는다.
판정 로직 (Agreement Evaluator)
flowchart TD CFG[["설정<br/>필수 유형 목록<br/>유형별 기준일"]] --> J{유형별 대조} L[(user_agreements)] --> M["사용자 · 유형별<br/>최신 이벤트 1건"] M --> J J -->|해당 유형 이벤트 없음| W[WAITING_TO_AGREE] J -->|최신 이벤트가 REVOKE| W J -->|recorded_at < 기준일| W J -->|모든 필수 유형 AGREE<br/>+ 기준일 이후| OK[ELIGIBLE]판정 규칙:
데이터 모델
테이블 하나만 추가한다. 문서 레지스트리를 별도 테이블로 만들지 않으며,
users테이블은 수정하지 않는다.컬럼 근거
agreement_typedocument_contentactionuser_idstatus = DELETED)라users행이 남으므로 조인이 계속 유효하다input_contextrecorded_atclient_ip/user_agentappend-only
이 테이블은 UPDATE / DELETE 하지 않는다. 철회는
REVOKE행 추가로 표현하며 기존 AGREE 행을 덮지 않는다.행을 덮으면 "언제부터 언제까지 동의 상태였는가"가 사라진다. 3월에 동의하고 8월에 철회한 사용자의 행을 REVOKE로 덮으면 그 5개월간 데이터를 처리한 근거가 없어지고, 동의 후 철회한 사용자와 애초에 동의하지 않은 사용자를 구분할 수 없다.
document_content도 함께 덮여 어느 원문에 동의했는지 사라진다.현재 상태가 필요하면
(user_id, agreement_type, recorded_at)인덱스로 유형별 최신 1건을 조회한다.타입·인덱스
document_content는mediumtext.text(64KB)는 약관 HTML 원문에서 넘칠 수 있고, 저장 시 잘리면 증빙이 깨진다.(user_id, agreement_type, recorded_at). 판정 쿼리가 유형별 최신 1건을 뽑기 때문이며,(user_id, recorded_at)으로는 유형 필터가 인덱스에 걸리지 않는다.증빙 대응
user_id(계정 자체가 소셜로 검증된 신원)agreement_type,document_contentdocument_content원문에 포함 (수집 목적·법적 근거)action,input_contextrecorded_atdocument_content원문에 포함document_content원문에 포함원문을 동결해 저장하므로 "동의 시점에 사용자에게 무엇을 고지했는가"가 행 자체로 입증된다.
users테이블user_agreements만 조회한다.users.status는 계정 생명주기 전용을 유지하며WAITING_TO_AGREE같은 값을 넣지 않는다.DELETED·SUSPENDED같은 계정 상태와 정책 충족 상태는 직교하므로 같은 enum에 합치지 않는다.API 계약
엔드포인트는 2개다. Gate는 엔드포인트가 아니다.
1. 동의 상태 조회
GET /api/v2/agreements/status— 일반 인증 필요, AgreementGate 면제{ "eligible": false, "items": [ { "type": "TERMS_OF_SERVICE", "required": true, "agreed": false }, { "type": "PRIVACY_COLLECTION_USE", "required": true, "agreed": false }, { "type": "MARKETING", "required": false, "agreed": false } ] }FE가 로그인 흐름과 무관하게 언제든 조회할 수 있어야 한다. 문서 원문은 FE가 보유하므로 이 응답에 싣지 않는다.
2. 동의 제출
POST /api/v2/agreements— 일반 인증 필요, AgreementGate 면제{ "agreements": [ { "type": "TERMS_OF_SERVICE", "action": "AGREE", "content": "제1조 (목적) 이 약관은 ...", "inputContext": "INDIVIDUAL" }, { "type": "PRIVACY_COLLECTION_USE", "action": "AGREE", "content": "수집 항목: 이메일, 연령 ...", "inputContext": "INDIVIDUAL" } ] }content는 사용자에게 실제로 표시된 원문이며 그대로 스냅샷 저장한다.recorded_at은 서버 시각으로 채우고 클라이언트 동의 일자를 받지 않는다.eligible갱신)를 반환한다.3. Gate 차단 응답
기존 응답 봉투를 그대로 쓴다.
GlobalResponse나Error를 수정하지 않는다.UserExceptionCode에 코드 한 줄을 추가하면Error(ExceptionCode, HttpStatus, String)구조상 키 코드가 자동으로 직렬화된다.GlobalResponse.error()가data를emptyList()로 고정하므로 목록을 실으려면 전역 응답 계약을 고쳐야 하는데 그럴 값어치가 없다. FE는 403을 받으면 동의 화면으로 전환하면서 조회 API를 호출한다.errors[0].code로 한다. 이 프로젝트는JwtExceptionType.EXPIRED_TOKEN도 403이므로 상태코드만으로는 401/권한없음/만료토큰과 구분되지 않는다.4. 로그인 응답 힌트
기존 소셜 로그인 응답에
agreementRequired불리언 필드 하나를 추가한다. 값은 Evaluator 결과를 쓰며 로그인 서비스가 따로 판정하지 않는다.{ "accessToken": "...", "isFirstLogin": true, "nickname": "...", "agreementRequired": true }oneOf를 만들지 않는다. 필드 하나가 늘 뿐이다.AgreementGate 면제 범위
미충족 상태에서도 반드시 허용한다.
탈퇴·로그아웃을 막으면 동의하지 않고는 서비스를 떠날 수 없게 된다. 동의 API 자체를 막으면 미충족 상태에서 영원히 빠져나올 수 없다.
면제는 전용 어노테이션으로 선언한다. 경로 목록을 Gate 안에 하드코딩하지 않는다. 기존
@SecurityPolicy에 속성을 얹지 않고 별도 어노테이션을 두는데,@SecurityPolicy는 인증 정책을 표현하는 것이고 이 아키텍처의 전제가 "동의 정책을 인증에 결합하지 않는다"이기 때문이다.Gate 배치
인터셉터로 구현한다. 인증 필터 안에서 차단하지 않는다.
HandlerMethod에 접근되므로 면제 어노테이션을 직접 읽어 판정할 수 있다. 별도 경로 레지스트리가 필요 없다.@RestControllerAdvice를 타므로 기존GlobalResponse형식을 그대로 쓴다. 필터에서 차단하면 JSON을 직접 직렬화해야 하고 기존 에러 포맷과 어긋난다.구현 체크포인트
의존성 순서대로 진행한다. 각 체크포인트는 독립 커밋·독립 검증이 가능해야 한다.
CP1. 스키마 + Evaluator
user_agreements스키마 추가 (Flyway)recorded_at >= 기준일→ ELIGIBLE / WAITING_TO_AGREECP2. 동의 API 2종
recorded_at이 서버 시각으로만 채워지는지 검증CP3. Gate 체인
AGREEMENT_REQUIRED응답 계약 (기존GlobalResponse봉투 그대로,Error수정 없음)CP4. 로그인 응답 및 잔재 제거
agreementRequired필드 추가 (집행 근거 아님, FE 라우팅용)signupToken관련 잔재가 코드·문서·OpenAPI에 남아 있지 않음 확인완료 기준
errors[0].code === "AGREEMENT_REQUIRED"를 받는다users테이블이 수정되지 않으며users.status가 동의 판정의 근거가 아니다확정 사항 (재논의 불필요)
users테이블은 수정하지 않는다.아직 정책 결정이 필요한 항목
법무·운영·제품 확인이 선행돼야 한다. 근거가 필요하면 개인정보보호위원회·국가법령정보센터 등 공식 출처를 사용한다. #361은 산출물·결정 기록 없이 닫혀 있어 근거로 쓸 수 없다.
아래 항목은 구현 착수를 막지 않는다. 필수 2종(
TERMS_OF_SERVICE,PRIVACY_COLLECTION_USE)은 확정됐고, 유형 목록과 기준일이 설정 기반이라 나머지는 값 채우기와 후속 작업으로 처리된다.AGREE만 열고REVOKE노출은 결정 후후속 영향
SIGNUP_REQUIRED상태 수신,signupToken보관, 가입 완료 후 소셜 로그인 재호출을 전제로 작성돼 있으며 이 전제가 모두 사라진다. 새 FE 계약은 (1) 로그인은 항상 정상 세션으로 진입, (2) 동의 필요 여부는 로그인 응답 힌트 또는 상태 조회 API로 확인, (3) 동의 제출 시 표시한 원문을 함께 전송, (4) 403AGREEMENT_REQUIRED를 동의 화면 전환 신호로 처리하는 형태다.관련