Skip to content

[BE/Auth] 인증 후 동의 이력 API·Agreement Gate #362

Description

@bottlenote-app

목적

로그인은 신원 인증만 담당하고, 이용 자격 판정은 인증 이후의 독립된 Gate가 수행하는 구조로 전환한다. 필수 동의 충족 여부는 그 Gate 중 하나(AgreementGate)가 요청 시점에 판정한다.

기존 본문의 signupToken 전용 JWT, signup token 저장소, 가입 완료 후 소셜 로그인 재호출 방식은 폐기한다. 해당 방향의 bottle-note/bottle-note-api-server#689 는 병합 없이 닫혔다.

폐기된 원래 요구사항과 폐기 사유는 이 이슈의 아카이브 코멘트에 보존돼 있다.

아키텍처 원칙

책임을 넷으로 분리하고 서로 침범하지 않는다.

책임 정의 소유
Authentication 너는 누구인가 소셜 로그인 + 기존 JWT (변경 없음)
Evaluation 지금 이용 자격을 충족했는가 Agreement Evaluator (단일 판정 주체)
Enforcement 미충족일 때 무엇을 막는가 인증 이후 Gate 체인
Evidence 무엇을 근거로 판정했는가 append-only 동의 이력

핵심 규칙:

  • 판정은 요청 시점에 한다. 토큰 발급 시점에 굳히지 않는다. 이미 발급된 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 &lt; 기준일| 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

판정 규칙:

  1. 필수 유형 목록과 유형별 기준일은 설정(서버 구성)이 소유한다. 테이블에 두지 않는다. "무엇이 필수인가"는 시점에 따라 바뀌는 정책이고, 테이블은 "무엇에 동의했다"는 사실만 기록한다.
  2. 유형별로 비교한다. 이용약관만 교체됐는데 개인정보 동의까지 재동의를 요구하면 안 된다.
  3. 최신 이벤트의 action을 먼저 본다. REVOKE면 기준일과 무관하게 미충족.
  4. 약관 교체 = 해당 유형의 기준일 상향. 별도 버전 문자열도, 문서 버전 관리 체계도 두지 않는다.
  5. 기준일 오입력은 전 사용자 차단으로 이어진다. 설정 변경은 리뷰 대상으로 관리한다.

데이터 모델

테이블 하나만 추가한다. 문서 레지스트리를 별도 테이블로 만들지 않으며, users 테이블은 수정하지 않는다.

user_agreements                          -- append-only. UPDATE / DELETE 금지
  id                bigint        PK
  user_id           bigint        NOT 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_contentmediumtext. 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 면제

{
  "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은 서버 시각으로 채우고 클라이언트 동의 일자를 받지 않는다.
  • 멱등·재진입 가능해야 한다. 재시도 안전성을 별도 토큰 소진 메커니즘으로 달성하지 않는다.
  • 응답은 조회 API와 동일 스키마(eligible 갱신)를 반환한다.

트레이드오프: 원문을 클라이언트 전송값으로 받아 저장한다. 서버가 문서를 소유하는 방식보다 증빙 강도가 낮다는 점을 인지하고 채택한 결정이다.

3. Gate 차단 응답

기존 응답 봉투를 그대로 쓴다. GlobalResponseError를 수정하지 않는다. UserExceptionCode에 코드 한 줄을 추가하면 Error(ExceptionCode, HttpStatus, String) 구조상 키 코드가 자동으로 직렬화된다.

HTTP/1.1 403 Forbidden

{
  "success": false,
  "code": 403,
  "data": [],
  "errors": [
    { "code": "AGREEMENT_REQUIRED", "status": "FORBIDDEN", "message": "필수 약관 동의가 필요합니다." }
  ],
  "meta": { }
}
  • 차단 응답에 부족 유형 목록을 싣지 않는다. GlobalResponse.error()dataemptyList()로 고정하므로 목록을 실으려면 전역 응답 계약을 고쳐야 하는데 그럴 값어치가 없다. FE는 403을 받으면 동의 화면으로 전환하면서 조회 API를 호출한다.
  • 차단은 신호, 내용은 조회 API가 단일 출처다. 판정 주체가 하나라는 원칙은 유지된다.
  • 구분은 errors[0].code로 한다. 이 프로젝트는 JwtExceptionType.EXPIRED_TOKEN도 403이므로 상태코드만으로는 401/권한없음/만료토큰과 구분되지 않는다.

4. 로그인 응답 힌트

기존 소셜 로그인 응답에 agreementRequired 불리언 필드 하나를 추가한다. 값은 Evaluator 결과를 쓰며 로그인 서비스가 따로 판정하지 않는다.

{
  "accessToken": "...",
  "isFirstLogin": true,
  "nickname": "...",
  "agreementRequired": true
}
  • FE 라우팅용 힌트이며 집행 근거가 아니다. 로그인 시점의 스냅샷일 뿐이고, 실제 차단은 항상 Gate가 요청 시점에 한다. 이 값이 false여도 이후 요청에서 403이 날 수 있다.
  • 없어도 기능은 동작한다. 신규 가입자가 홈에 진입했다가 403으로 튕기는 깜빡임을 없애는 용도다.
  • 로그인 응답은 단일 스키마를 유지한다. 신규/기존 회원 분기, 다형 응답, oneOf를 만들지 않는다. 필드 하나가 늘 뿐이다.

AgreementGate 면제 범위

미충족 상태에서도 반드시 허용한다.

  • 동의 상태 조회, 동의 제출
  • 토큰 재발급, 로그아웃
  • 계정 탈퇴, 개인정보 열람·삭제 등 정보주체 권리 행사 기능

탈퇴·로그아웃을 막으면 동의하지 않고는 서비스를 떠날 수 없게 된다. 동의 API 자체를 막으면 미충족 상태에서 영원히 빠져나올 수 없다.

면제는 전용 어노테이션으로 선언한다. 경로 목록을 Gate 안에 하드코딩하지 않는다. 기존 @SecurityPolicy에 속성을 얹지 않고 별도 어노테이션을 두는데, @SecurityPolicy는 인증 정책을 표현하는 것이고 이 아키텍처의 전제가 "동의 정책을 인증에 결합하지 않는다"이기 때문이다.

Gate 배치

인터셉터로 구현한다. 인증 필터 안에서 차단하지 않는다.

  1. HandlerMethod에 접근되므로 면제 어노테이션을 직접 읽어 판정할 수 있다. 별도 경로 레지스트리가 필요 없다.
  2. 차단 응답이 @RestControllerAdvice를 타므로 기존 GlobalResponse 형식을 그대로 쓴다. 필터에서 차단하면 JSON을 직접 직렬화해야 하고 기존 에러 포맷과 어긋난다.
  3. 인증은 필터, 정책은 인터셉터로 계층이 갈려 관심사 분리가 구조에 드러난다.

구현 체크포인트

의존성 순서대로 진행한다. 각 체크포인트는 독립 커밋·독립 검증이 가능해야 한다.

CP1. 스키마 + Evaluator

  • user_agreements 스키마 추가 (Flyway)
  • append-only 규약 확립 (UPDATE / DELETE 경로 없음)
  • 필수 유형 목록·유형별 기준일 설정 구조 추가
  • 사용자·유형별 최신 이벤트 도출
  • 판정: REVOKE 우선 → 유형별 recorded_at >= 기준일 → ELIGIBLE / WAITING_TO_AGREE
  • 필수 유형만 판정 대상에 포함, 선택 유형 제외 검증
  • 기준일 상향 시 기존 동의자가 자동으로 미충족이 되는지 검증

CP2. 동의 API 2종

  • 상태 조회 API
  • 동의 제출 API (AGREE / REVOKE 공용, 멱등)
  • 철회가 기존 행을 덮지 않고 REVOKE 행으로 쌓이는지 검증
  • recorded_at이 서버 시각으로만 채워지는지 검증
  • 두 응답이 동일 스키마임을 검증

CP3. Gate 체인

  • 인증 이후 인터셉터 단계에 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을 받는다.
  • 남은 정책 결정 항목은 구현 착수를 막지 않는다. 배포 전까지 설정값으로 채우면 된다.

관련

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions