diff --git a/.github/workflows/deploy-prod.yml b/.github/workflows/deploy-prod.yml
index 5499beb40..2e07c67c5 100644
--- a/.github/workflows/deploy-prod.yml
+++ b/.github/workflows/deploy-prod.yml
@@ -12,7 +12,7 @@ on:
workflow_dispatch:
inputs:
release_summary:
- description: 'Release summary written to releases/prod-YYYYMMDD-HHmm.yaml'
+ description: 'Release summary written to releases/prod-YYYYMMDD-HHmmss.yaml'
required: false
default: 'Manual production frontend deployment'
release_intent:
diff --git a/docs/2026-03-15-login-fail-fix/AUTH_STAGED_CHANGE_BREAKDOWN.md b/docs/2026-03-15-login-fail-fix/AUTH_STAGED_CHANGE_BREAKDOWN.md
deleted file mode 100644
index cdeef79db..000000000
--- a/docs/2026-03-15-login-fail-fix/AUTH_STAGED_CHANGE_BREAKDOWN.md
+++ /dev/null
@@ -1,577 +0,0 @@
-# 인증 스테이징 변경 상세 설명
-
-## 1. 문서 목적
-
-이 문서는 현재 스테이징된 인증 관련 변경을 파일 단위로 설명하기 위해 작성한다. 목표는 세 가지다. 첫째, 각 파일이 무엇을 바꿨는지 기록한다. 둘째, 왜 그 변경이 필요한지 논리적으로 설명한다. 셋째, 특히 `src/middleware.ts`처럼 변경량이 큰 파일은 함수 단위로 더 잘게 쪼개서 이해 가능한 형태로 정리한다.
-
-이 문서는 구현 계획 문서가 아니라 "현재 staged diff가 정확히 무엇을 의미하는가"를 설명하는 해설 문서다.
-
-## 2. 전체 요약
-
-이번 스테이징은 한 문장으로 요약하면 다음과 같다.
-
-`OAuth redirect 결과를 타입/파서/컨트롤러로 분리하고, 세션 생성과 로그인 판정을 공용 규칙으로 통일해서, 미들웨어가 bootstrap 상태를 너무 공격적으로 깨지 못하게 만든 변경`
-
-다시 정리하면, 로그인 결과를 해석하는 기준과 쿠키를 쓰는 규칙을 한곳으로 모으고, 로그인 직후의 정상적인 전이 상태를 미들웨어가 실패로 오해하지 않게 만든 수정이다.
-
-## 3. 문서
-
-### 3.1 `docs/FRONTEND_OAUTH_REDIRECTION_CONTRACT.md`
-
-이 문서는 백엔드 `/redirection` query 계약을 프론트 구현 규칙으로 번역한 문서다.
-
-이 문서가 하는 일은 다음과 같다.
-
-- 기존 회원 성공 케이스를 어떻게 판단해야 하는지 정의한다.
-- 신규 회원 성공 케이스를 어떻게 판단해야 하는지 정의한다.
-- 실패 케이스를 어떻게 분리해야 하는지 정의한다.
-- 프론트가 `member-id`만 보고 성공/실패를 판단하면 안 된다는 점을 문서화한다.
-
-이 문서가 필요한 이유는, 로그인 버그의 핵심이 "백엔드 계약을 프론트가 어떻게 읽어야 하는가"에 있기 때문이다.
-
-다시 정리하면, 이 문서는 "프론트가 `/redirection`을 읽는 정답표"다.
-
-### 3.2 `docs/SOCIAL_LOGIN_REFACTORING_PLAN.md`
-
-이 문서는 운영 로그인 회귀를 단순 커밋 문제가 아니라 구조 문제로 분석한 문서다.
-
-이 문서가 하는 일은 다음과 같다.
-
-- 현재 로그인 흐름을 프론트/백엔드/브라우저 쿠키 관점에서 다시 정리한다.
-- 왜 `/auth/me`보다 `/redirection -> session bootstrap` 경계가 더 중요한지 설명한다.
-- 어떤 순서로 리팩토링해야 회귀를 줄일 수 있는지 우선순위를 제시한다.
-
-이 문서가 필요한 이유는, 단순히 특정 커밋을 의심하는 수준으로는 구조적 문제를 해결할 수 없기 때문이다.
-
-다시 정리하면, 이 문서는 "왜 이 버그가 반복되기 쉬운 구조였는지"를 설명하는 설계 문서다.
-
-## 4. 인증 도메인 모델
-
-### 4.1 `src/types/auth/domain.ts`
-
-이 파일은 인증 세션 상태와 OAuth redirect 결과 타입을 새로 정의한 파일이다.
-
-추가된 핵심 타입은 다음과 같다.
-
-- `AuthSessionLike`
-- `AuthSessionState`
-- `OAuthRedirectParamSnapshot`
-- `ExistingMemberOAuthRedirectResult`
-- `NewMemberOAuthRedirectResult`
-- `OAuthRedirectFailureResult`
-- `OAuthRedirectResult`
-
-이 파일이 하는 일은 다음과 같다.
-
-- 세션을 `anonymous`, `pending-signup`, `authenticated-member` 상태로 나눈다.
-- OAuth redirect 결과를 `existing-member-success`, `new-member-success`, `failure`로 나눈다.
-- 계약 위반 시 로깅할 스냅샷 구조를 정의한다.
-
-이 파일이 필요한 이유는, 지금까지 로그인 규칙이 코드 여러 곳에 암묵적으로 퍼져 있었기 때문이다.
-
-다시 정리하면, 이 파일은 "인증에서 어떤 상태와 어떤 결과가 존재하는지"를 이름 붙여서 고정한 정의서다.
-
-### 4.2 `src/features/auth/model/auth-session.ts`
-
-이 파일은 세션 판정 규칙을 공용 함수로 묶은 파일이다.
-
-핵심 함수는 다음과 같다.
-
-- `hasAccessToken`
-- `normalizeMemberId`
-- `getAuthSessionState`
-- `isAuthenticatedMemberSession`
-- `isPendingSignupSession`
-- `toNumberMemberId`
-
-이 파일이 하는 일은 다음과 같다.
-
-- `memberId`가 실제로 유효한 숫자형 문자열인지 정규화한다.
-- access token 존재 여부를 판정한다.
-- 현재 세션이 익명인지, 회원가입 진행 중인지, 완성된 회원 세션인지 계산한다.
-- 다른 파일들이 같은 기준으로 로그인 여부를 판정하게 만든다.
-
-이 파일이 필요한 이유는, 헤더, Provider, 미들웨어, 홈 페이지가 각자 다른 기준으로 로그인 여부를 계산하던 문제를 없애기 위해서다.
-
-다시 정리하면, 이 파일은 "로그인됨 / 회원가입 진행 중 / 비로그인"을 앱 전체가 같은 기준으로 말하게 만드는 공통 규칙 모음이다.
-
-### 4.3 `src/features/auth/model/client-auth-session.ts`
-
-이 파일은 클라이언트 쿠키 쓰기 규칙을 공용화한 파일이다.
-
-핵심 함수는 다음과 같다.
-
-- `clearClientSession`
-- `writeExistingMemberSession`
-- `writeNewMemberSession`
-- `writeAccessTokenSession`
-
-이 파일이 하는 일은 다음과 같다.
-
-- 기존 회원 로그인 성공 시 `accessToken + memberId`를 함께 기록한다.
-- 신규 회원 로그인 성공 시 `accessToken`은 기록하되 `memberId`는 제거한다.
-- 세션 초기화 시 `accessToken`, `memberId`, `socialImageURL`을 일괄 정리한다.
-- refresh로 새 access token을 받은 경우 토큰 안의 `memberId`까지 함께 맞춘다.
-
-이 파일이 필요한 이유는, redirect/login/signup/logout/refresh가 제각각 쿠키를 다루지 않게 하려는 것이기 때문이다.
-
-다시 정리하면, 이 파일은 "쿠키를 어떻게 써야 정상 세션인지"를 한 군데로 모아놓은 파일이다.
-
-### 4.4 `src/features/auth/model/parse-oauth-redirect-result.ts`
-
-이 파일은 `searchParams`를 백엔드 계약대로 파싱하는 경계 함수다.
-
-핵심 구성은 다음과 같다.
-
-- `OAuthRedirectContractError`
-- `getOAuthRedirectParamSnapshot`
-- `parseOAuthRedirectResult`
-
-이 파일이 하는 일은 다음과 같다.
-
-- `type`, `is-success`, `is-guest`, `access-token`, `member-id`를 조합해 케이스를 해석한다.
-- 실패면 `failure` 타입을 반환한다.
-- 기존 회원 성공이면 `existing-member-success`를 반환한다.
-- 신규 회원 성공이면 `new-member-success`를 반환한다.
-- 계약 위반이면 스냅샷과 이유를 담아 `OAuthRedirectContractError`를 던진다.
-
-이 파일이 필요한 이유는 `/redirection`에서 암묵적 분기를 없애기 위해서다.
-
-다시 정리하면, 이 파일은 "쿼리스트링을 읽고 지금이 어떤 로그인 결과인지 판정하는 판독기"다.
-
-### 4.5 `src/features/auth/model/use-oauth-redirect-controller.ts`
-
-이 파일은 `/redirection`의 실제 브라우저 부수효과를 모은 controller다.
-
-이 파일이 하는 일은 다음과 같다.
-
-- parser를 호출해 로그인 결과를 해석한다.
-- 실패 시 세션을 정리하고 `/login`으로 보낸다.
-- 신규 회원이면 pending signup 세션을 기록하고 `/sign-up`으로 보낸다.
-- 기존 회원이면 완성 세션을 기록하고 GTM 이벤트를 발송한 뒤 `/home`으로 보낸다.
-- 계약 위반이나 예외 발생 시 로그를 남긴다.
-
-이 파일이 필요한 이유는 query 해석과 쿠키 쓰기와 라우팅을 UI 파일에 섞지 않기 위해서다.
-
-다시 정리하면, 이 파일은 "해석된 로그인 결과를 실제 브라우저 동작으로 바꾸는 지휘자"다.
-
-### 4.6 `src/features/auth/ui/oauth-redirect-page-client.tsx`
-
-이 파일은 `useSearchParams`와 controller를 묶는 얇은 UI 파일이다.
-
-이 파일이 하는 일은 다음과 같다.
-
-- `useSearchParams`를 통해 URL query를 읽는다.
-- controller를 호출한다.
-- `Suspense` 경계 안에서 최소 UI 역할만 수행한다.
-
-이 파일이 필요한 이유는 Next.js page 파일에서 비즈니스 로직을 빼고 `Suspense` 경계만 남기기 위해서다.
-
-다시 정리하면, 이 파일은 `/redirection` 페이지의 얇은 클라이언트 껍데기다.
-
-## 5. 리다이렉트와 미들웨어
-
-### 5.1 `src/app/(service)/redirection/page.tsx`
-
-이 파일은 예전의 거대한 `useEffect` 파일을 지우고 새 client page 컴포넌트만 렌더하는 엔트리로 축소했다.
-
-이 파일이 하는 일은 이제 단 하나다.
-
-- `OAuthRedirectPageClient`를 렌더한다.
-
-이 파일이 필요한 이유는 query 해석, 쿠키 쓰기, GTM, 라우팅을 page에 섞어두지 않기 위해서다.
-
-다시 정리하면, page는 이제 로그인 판단을 하지 않고 입구 역할만 한다.
-
-### 5.2 `src/middleware.ts`
-
-이 파일은 이번 staged 변경의 핵심이다.
-
-큰 방향의 변경은 다음과 같다.
-
-- `refresh_token`을 `getServerCookie`가 아니라 실제 `request.cookies`에서 읽게 했다.
-- access token `secure` 옵션을 요청 프로토콜 기준으로 계산하게 했다.
-- 라우트를 공개/보호/우회 prefix로 나눴다.
-- `AuthContext`에 `sessionState`, `decodedMemberId`, `isGuestToken`을 넣었다.
-- `pending-signup` 상태를 정상 bootstrap 상태로 취급하게 했다.
-- 쿠키 삭제 시 이유 로그를 남기게 했다.
-
-이 파일이 필요한 이유는 로그인 직후의 정상 전이 상태를 파손 세션으로 오판해서 쿠키를 바로 지우는 문제를 줄이기 위해서다.
-
-다시 정리하면, 이번 미들웨어 수정의 목적은 "세션이 완전히 깨졌을 때만 정리하고, 아직 로그인 만드는 중인 상태는 살려두자"이다.
-
-## 6. 로그인 상태 소비처 정렬
-
-### 6.1 `src/providers/index.tsx`
-
-이 파일은 user store 초기화 기준을 공용 세션 판정으로 바꿨다.
-
-예전에는 `accessToken` 유무나 숫자 변환만 봤지만, 지금은 `isAuthenticatedMemberSession`과 `toNumberMemberId`를 사용한다.
-
-이 파일이 하는 일은 다음과 같다.
-
-- 완성된 세션일 때만 유저 정보를 유지한다.
-- `memberId`가 실제로 맞지 않으면 store를 초기화한다.
-
-이 파일이 필요한 이유는 Provider가 redirect/bootstrap 도중 세션을 너무 빨리 초기화하지 않게 하려는 것이다.
-
-다시 정리하면, 이제 Provider는 "쿠키가 조금 생겼다"가 아니라 "정상 로그인 세션이 완성됐다"를 보고 유저 상태를 유지한다.
-
-### 6.2 `src/components/common/layout/home-header.tsx`
-
-이 파일은 헤더의 로그인 판정을 공용 규칙으로 바꿨다.
-
-이 파일이 하는 일은 다음과 같다.
-
-- `accessToken`만 있으면 일부 UI를 노출하던 동작을 없앤다.
-- 완성된 회원 세션일 때만 프로필 fetch, 알림, 토글, 유저 드롭다운을 노출한다.
-
-이 파일이 필요한 이유는 헤더 내부에서조차 로그인 기준이 갈라져 있던 문제를 없애기 위해서다.
-
-다시 정리하면, 이제 헤더는 애매한 세션을 로그인으로 보지 않는다.
-
-### 6.3 `src/app/(service)/home/page.tsx`
-
-이 파일은 서버에서 `memberId`를 넘길 때도 `memberId` 문자열만 보지 않고 `accessToken + memberId`가 완성된 세션인지 확인한 뒤 숫자로 변환하도록 바꿨다.
-
-이 파일이 필요한 이유는 홈 페이지가 불완전 세션을 정상 로그인처럼 해석하지 않게 하기 위해서다.
-
-다시 정리하면, 홈 페이지 진입 시점에도 "정상 로그인 세션인지"를 한 번 더 같은 기준으로 확인한다.
-
-### 6.4 `src/components/pages/home-page-server-content.tsx`
-
-이 파일은 히스토리 탭 노출 조건을 `memberId` 단독 체크에서 완성 세션 체크로 바꿨다.
-
-이 파일이 필요한 이유는 쿠키 하나만 남은 상태에서 "나의 스터디 기록"이 열리는 오판을 막기 위해서다.
-
-다시 정리하면, `memberId`만 남아 있다고 히스토리 탭을 보여주지 않는다.
-
-### 6.5 `src/components/home/tab-navigation.tsx`
-
-이 파일은 클라이언트 탭 노출 조건도 같은 공용 세션 판정으로 맞췄다.
-
-이 파일이 필요한 이유는 서버 쪽 홈 페이지 판정과 클라이언트 탭 네비게이션 판정이 다르면 hydration 후 UI가 뒤집히기 때문이다.
-
-다시 정리하면, 서버와 클라이언트가 탭 노출 조건을 똑같이 보게 만든 수정이다.
-
-## 7. 쿠키 쓰기 호출부 정리
-
-### 7.1 `src/api/client/axios.ts`
-
-이 파일은 refresh 성공 시 `setCookie('accessToken')`만 하던 코드를 `writeAccessTokenSession()`으로 바꿨다.
-
-이 파일이 하는 일은 다음과 같다.
-
-- 새 access token을 저장한다.
-- 토큰 안의 `memberId`도 같이 맞춘다.
-- 토큰에는 `memberId`가 없는데 쿠키에 남아 있던 stale `memberId`는 지운다.
-
-이 파일이 필요한 이유는 refresh 이후 `accessToken`과 `memberId`가 서로 다른 상태로 남지 않게 하기 위해서다.
-
-다시 정리하면, 토큰 갱신 뒤에도 세션 조각들이 서로 안 어긋나게 만든 수정이다.
-
-### 7.2 `src/api/client/axiosV2.ts`
-
-이 파일은 위와 같은 이유로 openapi용 axios refresh 경로도 동일하게 맞췄다.
-
-다시 정리하면, 레거시 axios와 openapi axios가 refresh 이후 서로 다른 세션 규칙을 쓰지 않게 통일했다.
-
-### 7.3 `src/api/client/cookie.ts`
-
-이 파일은 두 가지가 바뀌었다.
-
-첫째, `secure`를 무조건 `true`로 쓰지 않고 브라우저 프로토콜이나 서버 환경에 따라 계산하도록 바꿨다.
-
-둘째, `clearUserSession`이 실제 프로젝트에서 쓰는 `socialImageURL`을 지우도록 정리했고, 예전의 `userName/profileImage` 같은 현재 세션 모델과 안 맞는 쿠키 이름은 제거했다.
-
-이 파일이 필요한 이유는 로컬 HTTP나 환경 차이에서 쿠키 기록이 막히지 않게 하고, 실제 세션 모델과 안 맞는 정리 로직을 바로잡기 위해서다.
-
-다시 정리하면, 쿠키를 더 현실적인 환경 기준으로 쓰고 지우게 만든 수정이다.
-
-### 7.4 `src/components/common/modals/login-modal.tsx`
-
-이 파일은 개발용 테스트 로그인 성공 시 쿠키를 직접 세 줄 쓰던 코드를 `writeExistingMemberSession()` 호출로 바꿨다.
-
-이 파일이 필요한 이유는 테스트 로그인도 운영 로그인과 같은 세션 기록 규칙을 따르도록 맞추기 위해서다.
-
-다시 정리하면, 테스트 로그인만 따로 이상한 방식으로 쿠키를 쓰지 않게 했다.
-
-### 7.5 `src/components/common/modals/sign-up-modal.tsx`
-
-이 파일은 회원가입 성공 후 `memberId/accessToken/refresh_token`을 직접 쓰던 코드를 `writeExistingMemberSession()`으로 바꾸고, JS로 `refresh_token`을 쓰는 코드를 제거했다.
-
-이 파일이 필요한 이유는 `refresh_token`은 백엔드 `Set-Cookie` 책임이지 프론트 JS 책임이 아니고, 프론트가 직접 쓰면 계약이 꼬이기 때문이다.
-
-다시 정리하면, 회원가입 성공 후에도 프론트가 손대야 할 쿠키와 백엔드가 책임져야 할 쿠키를 분리한 수정이다.
-
-### 7.6 `src/hooks/queries/use-auth-mutation.ts`
-
-이 파일은 로그아웃 시 쿠키 3개를 개별 삭제하던 코드를 `clearClientSession()`으로 바꿨다.
-
-이 파일이 필요한 이유는 세션 정리 규칙도 한 함수로 모아서 logout과 other failure path가 같은 방식으로 세션을 지우게 하려는 것이다.
-
-다시 정리하면, 로그아웃도 세션 정리 공용 규칙을 사용하도록 통일했다.
-
-## 8. `src/middleware.ts` 상세 설명
-
-이 섹션은 `middleware.ts`를 함수 단위로 잘게 쪼개서 설명한다.
-
-### 8.1 import와 shared auth model 연결
-
-파일 상단에서 새로 들어온 핵심 import는 다음과 같다.
-
-- `getAuthSessionState`
-- `isAuthenticatedMemberSession`
-- `isPendingSignupSession`
-- `normalizeMemberId`
-- `AuthSessionState`
-
-이 변화가 의미하는 바는, 미들웨어가 더 이상 자체 기준으로 로그인 상태를 판단하지 않고 공용 세션 모델을 쓰게 됐다는 점이다.
-
-예전 문제는 미들웨어 안에서만 따로 `hasAccessToken`, `hasMemberId` 같은 조건을 만들어 쓰던 것이고, 그 기준이 헤더나 Provider와 달랐다는 점이다.
-
-이제는 미들웨어도 같은 도메인 함수를 쓰므로, 적어도 "현재 세션이 anonymous인지 pending-signup인지 authenticated-member인지"를 앱 전체와 같은 언어로 말할 수 있게 됐다.
-
-다시 정리하면, 미들웨어도 이제 혼자 다른 기준을 쓰지 않고 공용 로그인 규칙을 쓰기 시작했다.
-
-### 8.2 `getAccessTokenCookieOptions(request)`
-
-이 함수는 access token 쿠키를 다시 쓸 때 `secure` 옵션을 요청 프로토콜 기준으로 계산한다.
-
-바뀐 점은 다음과 같다.
-
-- 예전: `secure: true` 고정
-- 지금: `request.nextUrl.protocol === 'https:'` 또는 `x-forwarded-proto === 'https'`일 때만 `secure: true`
-
-이 변경이 필요한 이유는, 로컬 HTTP나 프록시 환경에서 무조건 secure 쿠키를 쓰면 브라우저가 쿠키를 받지 않을 수 있기 때문이다.
-
-이 함수는 특히 refresh 이후 access token을 다시 기록할 때 사용된다.
-
-다시 정리하면, 환경이 HTTP인데도 HTTPS 전용 쿠키처럼 써버려서 access token이 사라지는 일을 줄이려는 함수다.
-
-### 8.3 라우트 분리: `PUBLIC_SESSION_ROUTE_PREFIXES`, `PROTECTED_ROUTE_PREFIXES`, `AUTH_BYPASS_ROUTE_PREFIXES`
-
-이번 변경에서는 라우트를 세 그룹으로 나눴다.
-
-- 공개 서비스 경로
-- 보호 경로
-- 인증 검사 우회 경로
-
-각 그룹의 의미는 다음과 같다.
-
-- 공개 서비스 경로: 비회원도 들어갈 수 있지만, 로그인 상태면 세션 정규화나 토큰 refresh를 해줄 수 있는 경로
-- 보호 경로: 정상 회원 세션이 아니면 접근시키면 안 되는 경로
-- 우회 경로: `/redirection`처럼 로그인 bootstrap이 진행 중이므로 일반 인증 검사 기준을 바로 적용하면 안 되는 경로
-
-이 변경이 필요한 이유는, 모든 경로를 같은 방식으로 처리하면 `/redirection` 같은 특수 경로를 정상적으로 다룰 수 없기 때문이다.
-
-다시 정리하면, "다 같은 페이지가 아니다"를 코드로 명시한 것이다.
-
-### 8.4 `verifyAccessToken(accessToken)`
-
-이 함수는 `/api/v1/auth/me`를 호출해 access token 유효성을 확인한다.
-
-이 함수가 하는 일은 다음과 같다.
-
-- `AUTH001`이면 `invalid`
-- 200 응답이면 `valid`와 서버가 확인한 `memberId`
-- 그 외 에러면 `unknownError`
-
-이 함수의 중요한 역할은 단순히 토큰이 있는지 확인하는 것이 아니라, 서버 기준으로 그 토큰이 아직 쓸 수 있는지와 어떤 `memberId`에 속하는지를 다시 확인하는 것이다.
-
-이 값은 뒤에서 `memberId` 쿠키를 정규화할 때도 사용된다.
-
-다시 정리하면, 브라우저 쿠키를 믿지 말고 서버한테 "이 토큰 진짜 유효해?"를 묻는 함수다.
-
-### 8.5 `refreshAccessToken(request)`
-
-이번 변경에서 가장 중요한 수정 중 하나다.
-
-예전에는 `getServerCookie('refresh_token')`를 통해 refresh token을 읽었다. 지금은 `request.cookies.get('refresh_token')?.value`를 사용한다.
-
-이 차이가 중요한 이유는 다음과 같다.
-
-- 미들웨어는 현재 요청을 기준으로 판단해야 한다.
-- `request.cookies`는 지금 들어온 브라우저 요청의 실제 쿠키다.
-- `getServerCookie()`는 Next 서버 환경 쿠키 접근 유틸이지만, 미들웨어에서는 현재 요청 컨텍스트를 직접 보는 것이 더 맞다.
-
-이 함수는 refresh token이 없으면 바로 `null`을 반환하고, 있으면 refresh API를 호출한 뒤 새 access token을 반환한다.
-
-다시 정리하면, "지금 이 요청이 실제로 들고 온 refresh token"을 써서 갱신하게 만든 수정이다.
-
-### 8.6 `setMemberIdCookie()`와 `applyNewToken()`
-
-이 두 함수는 refresh 이후 세션을 다시 맞추는 역할을 한다.
-
-`setMemberIdCookie()`는 말 그대로 `memberId` 쿠키를 쓰는 작은 헬퍼다.
-
-`applyNewToken()`은 다음을 한 번에 수행한다.
-
-- 새 `accessToken`을 쿠키에 기록한다.
-- 토큰을 decode한다.
-- 토큰 안의 `memberId`를 정규화한다.
-- 유효한 `memberId`가 있으면 그 값으로 `memberId` 쿠키도 맞춘다.
-
-이 함수가 필요한 이유는, access token만 새로 쓰고 `memberId`는 예전 값을 남겨두면 세션 조각들이 서로 다른 상태가 될 수 있기 때문이다.
-
-다시 정리하면, 새 토큰을 받았으면 그 토큰 기준으로 세션 전체를 다시 맞추는 함수다.
-
-### 8.7 `clearAuthCookies(response, reason, pathname)`
-
-예전에는 그냥 쿠키를 지웠다. 지금은 이유와 경로를 함께 로그로 남긴다.
-
-이 함수가 하는 일은 다음과 같다.
-
-- `console.warn`으로 삭제 이유를 기록한다.
-- `accessToken`, `memberId`, `socialImageURL`을 삭제한다.
-
-이 함수가 필요한 이유는, 운영에서 "왜 쿠키가 사라졌는지"를 추측이 아니라 로그로 증명할 수 있어야 하기 때문이다.
-
-`reason` 값은 이후에 `"public-route-refresh-failed"`, `"protected-route-refresh-failed"` 같은 분석 단서로 쓰인다.
-
-다시 정리하면, 이제는 쿠키를 지울 때 "왜 지웠는지" 흔적을 남긴다.
-
-### 8.8 `AuthContext`와 `getAuthContext(request)`
-
-이번 staged diff에서 가장 중요한 구조 변화 중 하나다.
-
-새 `AuthContext`는 다음 필드를 가진다.
-
-- `accessToken`
-- `memberId`
-- `decodedMemberId`
-- `isGuestToken`
-- `sessionState`
-
-`getAuthContext()`는 브라우저가 보낸 현재 쿠키와 토큰 decode 결과를 바탕으로 이 값을 만든다.
-
-이 구조가 필요한 이유는 다음과 같다.
-
-- `memberId` 쿠키만 있는지
-- access token은 있는데 `memberId`가 없는지
-- 토큰 안 role이 guest인지
-- 현재 세션이 완성된 회원 세션인지, 회원가입 대기 세션인지
-
-를 다음 분기에서 바로 쓸 수 있기 때문이다.
-
-예전처럼 `hasAccessToken`과 `hasMemberId`만 보면 `pending-signup`과 `broken-session`을 구분하기 어렵다.
-
-다시 정리하면, 미들웨어가 지금 세션 상태를 더 풍부하게 이해할 수 있게 만든 컨텍스트다.
-
-### 8.9 `handleSignUp(request, ctx)`
-
-이 함수는 `/sign-up` 경로 전용 처리다.
-
-동작은 다음과 같다.
-
-1. 이미 완성된 회원 세션이면 `/home`으로 보낸다.
-2. 완전히 익명 세션이면 `/`로 보낸다.
-3. 그 외, 즉 `pending-signup`이면 그대로 통과시킨다.
-
-이 함수가 필요한 이유는, 신규 회원이 `memberId` 없이 `/sign-up`으로 들어오는 상태를 정상으로 취급해야 하기 때문이다.
-
-다시 정리하면, 회원가입 페이지는 "memberId 없는 로그인 중간 상태"를 정상으로 받아줘야 한다.
-
-### 8.10 `handlePublicSessionRoute(request, ctx)`
-
-이 함수는 공개 서비스 경로에서 세션을 어떻게 다룰지 결정한다.
-
-동작을 순서대로 보면 다음과 같다.
-
-1. `anonymous`면 통과시킨다.
-2. 이때 `memberId`만 남아 있으면 위조되거나 stale한 identity 조각일 수 있으므로 정리한다.
-3. `pending-signup`이면 세션 형성 중 상태로 보고 통과시킨다.
-4. 단, guest가 아닌데 토큰 안 `memberId`가 있으면 `memberId`를 다시 맞춰준다.
-5. 완성된 회원 세션이면 `/auth/me`로 검증한다.
-6. 토큰이 invalid면 refresh를 시도한다.
-7. refresh도 실패하면 쿠키를 정리한다.
-8. 검증 성공이면 서버가 확인한 `memberId`로 쿠키를 정규화한다.
-
-이 함수가 중요한 이유는, 공개 경로에서는 비회원도 접근 가능하지만 로그인 세션이 있으면 가능한 한 복구하거나 정리해줘야 하기 때문이다.
-
-이번 변경의 핵심은 `pending-signup`을 여기서 곧바로 깨진 세션으로 지우지 않게 했다는 점이다.
-
-다시 정리하면, 공개 페이지에서는 "로그인 중간 상태"를 살려두고, "정말 깨진 세션"만 정리하게 바꾼 것이다.
-
-### 8.11 `handleLogin(request, ctx)`
-
-이 함수는 `/login` 경로 처리다.
-
-동작은 다음과 같다.
-
-1. `pending-signup`이면서 guest 토큰이면 `/sign-up`으로 보낸다.
-2. 완성된 회원 세션이 아니면 로그인 페이지를 그대로 보여준다.
-3. 완성된 회원 세션이면 `/auth/me`로 검증한다.
-4. 유효하면 `/home`으로 보낸다.
-5. invalid면 refresh를 시도한다.
-6. refresh 성공이면 `/home`으로 보내고 세션을 다시 기록한다.
-7. 그것도 안 되면 쿠키를 정리하고 로그인 페이지를 보여준다.
-
-이 함수가 필요한 이유는, 이미 로그인된 사용자가 `/login`에 다시 들어오거나, 유효하지 않은 세션이 로그인 페이지에 남아 있는 상황을 정리해야 하기 때문이다.
-
-다시 정리하면, 로그인 페이지는 "이미 로그인된 사람"은 홈으로 보내고, "깨진 세션"만 정리한다.
-
-### 8.12 `handleProtected(request, ctx)`
-
-이 함수는 보호 경로의 핵심 로직이다.
-
-동작은 다음과 같다.
-
-1. `anonymous`면 랜딩으로 보낸다.
-2. `pending-signup`이면 guest는 `/sign-up`, 그 외는 깨진 세션으로 보고 정리 후 랜딩으로 보낸다.
-3. access token이 없으면 랜딩으로 보낸다.
-4. access token이 있으면 `/auth/me`로 검증한다.
-5. invalid면 refresh를 시도한다.
-6. refresh 실패면 세션 정리 후 랜딩으로 보낸다.
-7. refresh 성공이면 새 토큰으로 다시 검증한다.
-8. 그래도 `valid`가 아니면 세션 정리 후 랜딩으로 보낸다.
-9. `valid`면 서버가 확인한 `memberId`로 쿠키를 맞춘다.
-10. `/admin` 경로면 토큰 안 `ROLE_ADMIN`도 확인한다.
-
-이 함수가 필요한 이유는, 보호 경로에서는 "정상 회원 세션"만 허용해야 하기 때문이다.
-
-공개 경로와의 차이는, 보호 경로에서는 복구 실패 시 비회원처럼 그냥 통과시키지 않고 아예 접근을 막는다는 점이다.
-
-다시 정리하면, 보호 페이지는 끝까지 세션을 검증하고 복구를 시도하되, 그래도 안 되면 들여보내지 않는다.
-
-### 8.13 `middleware(request)` dispatcher
-
-마지막 `middleware()` 함수는 실제 라우팅 분배기다.
-
-동작 순서는 다음과 같다.
-
-1. `/`는 그대로 통과
-2. 우회 경로면 그대로 통과
-3. `/sign-up`이면 `handleSignUp`
-4. `/login`이면 `handleLogin`
-5. 공개 서비스 경로면 `handlePublicSessionRoute`
-6. 보호 경로면 `handleProtected`
-7. 나머지는 그대로 통과
-
-이 구조가 필요한 이유는, 경로 종류에 따라 인증 정책이 다르기 때문이다.
-
-다시 정리하면, 마지막 함수는 "이 경로는 어느 규칙으로 검사할지"를 고르는 분배기다.
-
-### 8.14 `config.matcher`
-
-예전에는 일부 경로만 명시적으로 걸었다. 지금은 정적 리소스와 API를 제외한 거의 전체 앱 경로를 대상으로 미들웨어를 적용한다.
-
-이 변경이 필요한 이유는, 공개 서비스 경로와 보호 경로를 prefix 기준으로 내부 분기하기 시작했기 때문이다.
-
-다시 정리하면, 이제 matcher는 넓게 잡고, 실제 정책 분기는 미들웨어 내부에서 하도록 구조를 바꾼 것이다.
-
-## 9. 결론
-
-이번 staged 변경을 문서 한 줄로 다시 요약하면 다음과 같다.
-
-`OAuth redirect 해석 규칙을 타입/파서/컨트롤러로 분리하고, 세션 생성/정리/판정을 공용화해, 미들웨어가 로그인 직후의 정상 bootstrap 상태를 과도하게 파손 세션으로 처리하지 않도록 만든 변경`
-
-다시 정리하면, 로그인 결과를 읽는 규칙과 세션을 다루는 규칙을 한 군데로 모아서, 로그인 직후 쿠키가 바로 지워지는 회귀를 줄이기 위한 정리다.
-
-## 10. 추가 보완: 실패 Redirect 세션 초기화
-
-- [use-oauth-redirect-controller.ts](../../src/features/auth/model/use-oauth-redirect-controller.ts)는 OAuth 실패 또는 계약 위반 시 `clearClientSession()` 뒤에 곧바로 `/login`으로만 보내지 않도록 수정했다.
-- 대신 [auth-route.ts](../../src/features/auth/model/auth-route.ts)의 `getClearSessionRedirectUrl()`을 통해 `/api/auth/clear-session?redirect=/login`으로 이동한다.
-- 이유는 같은 서비스 레이아웃 안에서 클라이언트 전환만 하면 서버가 보는 쿠키와 공유 레이아웃 상태가 즉시 초기화되지 않아, 로그인 페이지 본문 위에 인증 헤더가 남는 버그가 있었기 때문이다.
-
-다시 정리하면, OAuth 실패는 이제 “클라이언트 쿠키 삭제 + 서버 쿠키 삭제 + 로그인 페이지 재진입”으로 처리된다.
diff --git a/docs/2026-03-15-login-fail-fix/FRONTEND_OAUTH_REDIRECTION_CONTRACT.md b/docs/2026-03-15-login-fail-fix/FRONTEND_OAUTH_REDIRECTION_CONTRACT.md
deleted file mode 100644
index 13312e91f..000000000
--- a/docs/2026-03-15-login-fail-fix/FRONTEND_OAUTH_REDIRECTION_CONTRACT.md
+++ /dev/null
@@ -1,310 +0,0 @@
-# 프론트엔드 OAuth Redirection 계약과 작업 계획
-
-## 1. 문서 목적
-
-이 문서는 백엔드가 `/redirection`으로 보내는 OAuth 결과 계약을 프론트엔드가 어떻게 해석하고 처리해야 하는지를 명확히 적기 위해 작성한다. 목표는 두 가지다. 첫째, 백엔드 계약을 프론트 구현 규칙으로 번역한다. 둘째, 지금 프론트가 무엇을 수정해야 하는지와 왜 수정해야 하는지를 작업 단위로 정리한다.
-
-이 문서는 "소셜 로그인 버그가 왜 생겼는가"를 넘어서, "프론트가 어떤 책임을 가져야 하는가"를 규정하는 문서다. 따라서 특정 커밋 하나를 탓하기보다, 현재 프론트 구조가 어떤 계약을 놓치고 있었는지를 설명한다.
-
-## 2. 백엔드 계약
-
-백엔드 코드를 기준으로 `/redirection`의 입력 계약은 이미 정해져 있다. 성공/실패와 무관하게 OAuth 리다이렉트 결과에는 `type=oauth2`가 포함되어야 하며, 프론트 parser는 이 값을 계약 식별자로 먼저 검증해야 한다.
-
-### 2.1 성공 계약
-
-- B1. 기존 회원 로그인 성공이면 `/redirection` query에는 `type=oauth2`, `access-token`, `is-success=true`, `is-guest=false`, `member-id`, `auth-vendor`가 포함된다.
-- B2. 신규 회원 로그인 성공이면 `/redirection` query에는 `type=oauth2`, `access-token`, `is-success=true`, `is-guest=true`, `auth-vendor`가 포함된다.
-- B3. 신규 회원 로그인 성공 시 `user-name`, `profile-image-url`은 있을 수 있지만, `member-id`는 없다.
-
-즉, `existing-member-success -> member-id exists` 이고, `new-member-success -> member-id absent` 이다.
-
-다시 정리하면, 기존 회원은 `member-id`까지 와야 로그인 완료이고, 신규 회원은 `member-id` 없이 회원가입 단계로 넘어가는 재료만 오면 된다.
-
-### 2.2 실패 계약
-
-- B4. 로그인 실패이면 `/redirection` query에는 `type=oauth2`, `is-success=false`가 포함된다.
-- B5. 실패 시 백엔드는 `/login`으로 직접 보내지 않고, `/redirection?type=oauth2&is-success=false`로 보낸다.
-
-즉, `oauth-failure -> redirection page handles failure` 이다.
-
-다시 정리하면, 로그인 실패도 `/redirection`이 받기 때문에 이 페이지가 실패 처리 책임까지 가져야 한다.
-
-### 2.3 세션 저장 계약
-
-- B6. `refresh_token`은 백엔드가 `Set-Cookie`로 저장한다.
-- B7. `access-token`은 백엔드가 query string으로 전달한다.
-- B8. 따라서 프론트는 `access-token`을 받아 클라이언트 쿠키 또는 세션 상태로 저장해야 한다.
-
-즉, 백엔드는 "최종 로그인 결과"를 넘기고, 프론트는 "브라우저 세션 완성"을 담당한다.
-
-다시 정리하면, 서버는 결과를 보내고 `refresh_token`을 심고, 프론트는 그 결과를 받아 `accessToken` 세션을 완성한다.
-
-## 3. 프론트가 반드시 가져야 하는 해석 규칙
-
-백엔드 계약이 위와 같다면, 프론트는 다음 명제를 구현해야 한다.
-
-- F1. `type=oauth2`가 아니면 이 요청은 OAuth redirect 계약 위반으로 처리해야 한다.
-- F2. `is-success=false` 이면 이 요청은 실패이며, 로그인 성공 로직을 실행하면 안 된다.
-- F3. `is-success=true AND is-guest=false` 이면 기존 회원 성공 케이스로 처리해야 한다.
-- F4. `is-success=true AND is-guest=true` 이면 신규 회원 성공 케이스로 처리해야 한다.
-- F5. 기존 회원 성공 케이스에서는 `access-token`과 `member-id`가 모두 필요하다.
-- F6. 신규 회원 성공 케이스에서는 `access-token`은 필요하지만 `member-id`는 필요하지 않다.
-- F7. `member-id` 부재는 기존 회원 케이스에서는 계약 위반이지만, 신규 회원 케이스에서는 정상이다.
-- F8. 프론트는 `member-id` 유무만 보고 성공/실패를 판단하면 안 되고, 반드시 `type`, `is-success`, `is-guest`와 함께 판단해야 한다.
-
-이 규칙이 빠지면 프론트는 신규 회원 정상 케이스를 실패처럼 오해하거나, 실패 케이스를 성공처럼 처리하게 된다.
-
-## 4. 현재 프론트가 놓친 핵심
-
-현재 프론트 문제는 "백엔드가 이상한 값을 보낸다"가 아니라, "프론트가 백엔드 계약을 타입과 분기 구조로 고정하지 않았다"는 데 있다.
-
-### 4.1 `/redirection`이 계약 해석기 역할을 제대로 분리하지 않았다
-
-현재 `/redirection` 페이지는 한 파일 안에서 다음을 동시에 수행한다.
-
-1. query string 읽기
-2. 성공/실패 판단
-3. 기존 회원/신규 회원 판단
-4. 쿠키 쓰기
-5. GTM 발송
-6. 최종 라우팅
-
-문제는 이 여섯 가지가 서로 다른 전제를 가진다는 점이다. 예를 들어 `is-success=false`는 즉시 실패 분기여야 하지만, `member-id` 부재는 신규 회원일 때는 정상이다. 그런데 이 둘을 같은 수준의 `if` 로직으로 섞으면, 계약 오해가 곧 로그인 장애로 이어진다.
-
-다시 정리하면, 한 파일이 너무 많은 결정을 동시에 하고 있어서 조건 하나만 잘못 써도 로그인 전체가 깨진다.
-
-### 4.2 프론트는 "성공 응답의 모양"을 명시적으로 소유하지 않았다
-
-지금 프론트에는 아래 타입이 없다.
-
-- 기존 회원 OAuth 성공 결과
-- 신규 회원 OAuth 성공 결과
-- OAuth 실패 결과
-
-이 타입이 없으면 구현은 반드시 다음 실수를 하게 된다.
-
-- `member-id`가 없으면 실패라고 일반화한다.
-- `is-success=false`를 별도 실패 타입으로 다루지 않는다.
-- query 파라미터 해석 규칙이 파일마다 흩어진다.
-
-즉, 현재 버그의 본질은 로직 실수 이전에 계약 모델 부재다.
-
-다시 정리하면, 프론트가 "성공 응답이 어떻게 생겼는지"를 자료형으로 못 박아두지 않아서 신규 회원을 실패로 착각하기 쉬운 상태다.
-
-### 4.3 세션 생성 책임이 프론트 안에서도 분리되어 있다
-
-현재 프론트는 OAuth 직후 `accessToken`과 `memberId`를 쓰지만, 로그인 상태 판단은 다른 파일들이 따로 한다. 그 결과 다음 현상이 발생한다.
-
-- `/redirection`은 "성공 처리"라고 생각한다.
-- Provider는 `memberId`가 맞지 않으면 유저 상태를 초기화한다.
-- Header는 `accessToken + memberId` 둘 다 있어야 로그인으로 본다.
-- middleware는 세션이 덜 만들어진 상태를 파손 세션으로 오판할 수 있다.
-
-즉, 현재 프론트는 세션을 쓰는 레이어와 세션을 검증하는 레이어가 서로 다른 언어를 쓰고 있다.
-
-다시 정리하면, 쿠키를 만드는 쪽과 로그인 여부를 판단하는 쪽이 서로 다른 기준을 쓰고 있어서 충돌이 난다.
-
-## 5. 프론트가 진행해야 하는 작업
-
-아래 작업은 "있으면 좋은 개선"이 아니라, 백엔드 계약을 제대로 소비하기 위해 프론트가 반드시 해야 하는 일이다.
-
-### 5.1 작업 1: OAuth redirect 결과를 타입으로 정의
-
-첫 번째 작업은 `/redirection`의 query 결과를 타입으로 고정하는 일이다.
-
-필요한 타입은 최소 아래 세 가지다.
-
-- `ExistingMemberOAuthRedirectResult`
-- `NewMemberOAuthRedirectResult`
-- `OAuthRedirectFailureResult`
-
-이 작업이 필요한 이유는 다음과 같다.
-
-- W1. 프론트는 기존 회원 성공과 신규 회원 성공을 서로 다른 shape로 다뤄야 한다.
-- W2. 이 차이를 타입으로 고정하지 않으면 구현자는 `member-id`를 항상 기대하게 된다.
-- W3. 타입이 생기면 "정상 부재"와 "계약 위반 부재"를 구분할 수 있다.
-
-즉, 타입 정의는 문서화를 위한 것이 아니라 회귀 방지를 위한 실행 규칙이다.
-
-다시 정리하면, 먼저 "어떤 성공이 어떤 성공인지"를 코드 타입으로 못 박아야 같은 실수를 반복하지 않는다.
-
-### 5.2 작업 2: `parseOAuthRedirectResult(searchParams)`를 경계 함수로 도입
-
-두 번째 작업은 query string 해석을 단일 함수로 모으는 일이다.
-
-이 함수는 아래 순서로 동작해야 한다.
-
-1. `type=oauth2`인지 먼저 검증
-2. `is-success=false`이면 실패 타입 반환
-3. `is-success=true AND is-guest=true`이면 신규 회원 성공 타입 반환
-4. `is-success=true AND is-guest=false`이면 기존 회원 성공 타입 반환
-5. 그 외는 계약 위반 에러 반환
-
-이 작업이 필요한 이유는 다음과 같다.
-
-- W4. query 해석 규칙이 UI 파일 곳곳에 흩어지면 같은 계약이 여러 번 중복 구현된다.
-- W5. parser가 없으면 실패 케이스와 신규 회원 케이스가 쉽게 섞인다.
-- W6. parser가 있으면 계약 위반을 "로그인 실패"가 아니라 "백엔드/프론트 계약 불일치"로 별도 기록할 수 있다.
-
-즉, parser는 편의 함수가 아니라 계약 경계다.
-
-다시 정리하면, query 해석은 여기저기서 하지 말고 한 함수만 믿게 만들어야 한다.
-
-### 5.3 작업 3: `/redirection` 페이지를 controller 중심 구조로 분리
-
-세 번째 작업은 `/redirection`을 현재의 거대한 effect 파일에서 분리하는 일이다.
-
-권장 구조는 아래와 같다.
-
-- `src/types/auth/`
-- `src/features/auth/model/parse-oauth-redirect-result.ts`
-- `src/features/auth/model/use-oauth-redirect-controller.ts`
-- `src/features/auth/ui/oauth-redirect-page-client.tsx`
-- `src/app/(service)/redirection/page.tsx`
-
-각 파일의 책임은 다음과 같다.
-
-- type: 계약 표현
-- parser: query 해석
-- controller: 쿠키 쓰기, GTM, 라우팅, 에러 처리
-- ui: 렌더링
-- app page: 진입점
-
-이 작업이 필요한 이유는 다음과 같다.
-
-- W7. 현재 `/redirection`은 계약 해석과 부수효과가 한 파일에 섞여 있다.
-- W8. 계약 판단은 model 레이어, 브라우저 액션은 controller 레이어에 있어야 테스트가 가능하다.
-- W9. app page는 Next.js 경계만 담당해야 하고, 브라우저 로직을 품으면 안 된다.
-
-즉, 이 작업은 단순 파일 분리가 아니라 책임 분리다.
-
-다시 정리하면, page는 입구만 맡고 실제 판단과 동작은 parser와 controller로 내려야 안전하다.
-
-### 5.4 작업 4: 세션 쓰기 로직을 공용 함수로 통합
-
-네 번째 작업은 기존 회원과 신규 회원의 세션 기록 함수를 분리하는 일이다.
-
-필요한 함수는 아래 정도가 적절하다.
-
-- `writeExistingMemberSession`
-- `writeNewMemberSession`
-- `clearClientSession`
-- `isExistingMemberSessionComplete`
-- `isNewMemberSignupSession`
-
-이 작업이 필요한 이유는 다음과 같다.
-
-- W10. 기존 회원은 `accessToken + memberId`가 있어야 세션이 완성된다.
-- W11. 신규 회원은 `accessToken`만으로도 `/sign-up`까지는 정상이다.
-- W12. 이 차이를 공용 함수로 표현하지 않으면, 미들웨어/헤더/provider가 다시 제각각 판정하게 된다.
-
-즉, 세션은 "쿠키 몇 개를 쓴다"가 아니라 "어떤 상태가 정상인가"를 표현하는 규칙이어야 한다.
-
-다시 정리하면, 기존 회원 세션과 신규 회원 세션은 정상 상태가 다르므로 기록 함수도 분리해야 한다.
-
-### 5.5 작업 5: 미들웨어가 bootstrap 세션을 지우지 못하게 경계 재설정
-
-다섯 번째 작업은 미들웨어의 삭제 조건을 축소하는 일이다.
-
-미들웨어는 아래 단계까지만 다뤄야 한다.
-
-1. 이미 완성된 세션 검증
-2. 만료된 access token refresh
-3. 보호 경로 접근 통제
-4. 서버가 확인한 `memberId` 정규화
-
-반대로 아래 상태는 미들웨어가 즉시 삭제하면 안 된다.
-
-1. `/redirection` 직후 세션이 아직 형성 중인 상태
-2. 신규 회원이 `member-id` 없이 `/sign-up`으로 가는 상태
-3. `refresh_token`과 `accessToken`의 저장 타이밍이 아직 완전히 맞물리지 않은 상태
-
-이 작업이 필요한 이유는 다음과 같다.
-
-- W13. 지금 구조에서는 로그인 직후의 정상 전이 상태가 파손 세션으로 오판될 수 있다.
-- W14. bootstrap 단계는 인증 실패가 아니라 세션 형성 단계다.
-- W15. 세션 형성 단계와 세션 유지 단계를 같은 기준으로 검사하면, 시스템이 정상 로그인 플로우를 스스로 지운다.
-
-즉, 미들웨어는 로그인 생성기가 아니라 세션 유지 장치여야 한다.
-
-다시 정리하면, 미들웨어는 로그인 만드는 곳이 아니라 로그인 이후 상태를 검사하고 유지하는 곳이어야 한다.
-
-### 5.6 작업 6: 로그인 판정 기준을 한 곳으로 모으기
-
-여섯 번째 작업은 Header, Provider, middleware, page가 쓰는 로그인 기준을 통합하는 일이다.
-
-필요한 공용 판정은 최소 아래 두 가지다.
-
-- `isAuthenticatedMemberSession`
-- `isPendingSignupSession`
-
-이 작업이 필요한 이유는 다음과 같다.
-
-- W16. 같은 앱 안에서 로그인 기준이 여러 개면 한 레이어는 성공, 다른 레이어는 실패로 본다.
-- W17. 특히 기존 회원과 신규 회원의 정상 조건이 다른데, 현재 구조는 이를 명시적으로 분리하지 않는다.
-- W18. 로그인 판정이 공용화되면 Header, Provider, middleware가 같은 세션 모델을 사용하게 된다.
-
-즉, "로그인됨"은 UI별 판단이 아니라 도메인 규칙이어야 한다.
-
-다시 정리하면, 앱 전체가 같은 기준으로만 "로그인됨"을 말해야 화면이 서로 다른 말을 하지 않는다.
-
-### 5.7 작업 7: 실패 원인과 계약 위반을 로그로 남기기
-
-일곱 번째 작업은 추론 대신 증명을 가능하게 만드는 것이다.
-
-필수 로그는 아래와 같다.
-
-- parser가 어떤 이유로 실패했는지
-- 기존 회원 성공인데 `member-id`가 비어 있었는지
-- 신규 회원 성공인데 `access-token`이 비어 있었는지
-- 미들웨어가 왜 세션을 삭제했는지
-
-로그를 남길 때는 아래 원칙도 같이 고정해야 한다.
-
-- `access-token`, `refresh_token` 원문은 로그에 남기지 않는다.
-- `member-id`, `user-name`, `profile-image-url`도 원문 전체를 그대로 남기지 않는다.
-- 파라미터 스냅샷은 "존재 여부", "계약 위반 종류", "마스킹된 식별자" 수준까지만 기록한다.
-
-이 작업이 필요한 이유는 다음과 같다.
-
-- W19. 지금은 "쿠키가 왜 사라졌는지"를 코드 추적으로만 추론해야 한다.
-- W20. 계약 위반과 운영 설정 문제를 나누려면, 삭제 사유와 파라미터 스냅샷이 필요하다.
-
-즉, 로그는 부가 기능이 아니라 운영 복구 도구다.
-
-다시 정리하면, 왜 실패했는지 기록이 없으면 다음 장애 때도 또 코드만 뒤져가며 추측해야 한다.
-
-## 6. 프론트 구현 우선순위
-
-현재 우선순위는 아래처럼 잡아야 한다.
-
-1. OAuth redirect 타입 정의
-2. redirect parser 도입
-3. `/redirection` controller 분리
-4. 세션 기록 공용화
-5. 미들웨어 삭제 조건 축소
-6. 로그인 판정 공용화
-7. 로그 및 E2E 추가
-
-이 순서를 바꾸면 안 된다. 특히 미들웨어만 먼저 건드리면, `/redirection` 해석 규칙이 계속 암묵적인 상태로 남아서 같은 회귀가 반복된다.
-
-## 7. 결론
-
-백엔드 계약 기준으로 보면, 프론트가 해야 하는 일은 단순히 `access-token`을 쿠키에 쓰는 것이 아니다. 프론트는 `/redirection`을 OAuth 결과 해석 경계로 소유해야 하고, 그 결과를 기존 회원 성공, 신규 회원 성공, 실패로 정확히 분리해야 한다.
-
-핵심 결론은 다음과 같다.
-
-- 프론트는 `member-id` 유무만으로 성공을 판단하면 안 된다.
-- 프론트는 `is-success`, `is-guest`, `access-token`, `member-id`의 조합을 계약으로 해석해야 한다.
-- 프론트는 bootstrap 세션과 완성된 세션을 같은 기준으로 검사하면 안 된다.
-- 프론트는 로그인 판정 규칙을 여러 파일에 중복 정의하면 안 된다.
-
-따라서 지금 필요한 작업은 "로그인 페이지를 조금 고친다"가 아니라, `/redirection -> session bootstrap -> authenticated session` 흐름을 프론트 도메인 규칙으로 다시 세우는 일이다.
-
-## 8. 추가 보완: 실패 Redirect 세션 정리
-
-로컬 E2E에서 확인한 추가 문제는, OAuth 실패 redirect가 `/login`으로 이동하더라도 이전 인증 헤더가 잠시 남을 수 있다는 점이었다. 원인은 클라이언트 쿠키만 지운 뒤 같은 서비스 레이아웃 안에서 `router.replace('/login')`만 수행하면, 서버가 보는 인증 쿠키와 공유 레이아웃 상태가 즉시 정리되지 않을 수 있기 때문이다.
-
-그래서 실패 redirect와 계약 위반 redirect는 이제 `/api/auth/clear-session?redirect=/login`을 경유한다. 이 route handler가 서버와 브라우저가 보는 인증 쿠키를 함께 정리한 뒤 로그인 페이지로 이동시킨다.
-
-다시 정리하면, OAuth 실패는 이제 "로그인 페이지로 화면만 전환"이 아니라 "서버/클라이언트 세션 전체 정리 후 로그인 페이지로 이동"으로 처리한다.
diff --git a/docs/2026-03-15-login-fail-fix/MIDDLEWARE_AUTH_REFACTORING_NEXT_STEPS.md b/docs/2026-03-15-login-fail-fix/MIDDLEWARE_AUTH_REFACTORING_NEXT_STEPS.md
deleted file mode 100644
index c68ab5526..000000000
--- a/docs/2026-03-15-login-fail-fix/MIDDLEWARE_AUTH_REFACTORING_NEXT_STEPS.md
+++ /dev/null
@@ -1,231 +0,0 @@
-# 미들웨어 인증 후속 리팩토링 계획
-
-## 1. 문서 목적
-
-이 문서는 현재 `middleware.ts` 구조를 기준으로, 다음 단계에서 어떤 리팩토링이 필요한지 정리하기 위해 작성한다. 목표는 두 가지다. 첫째, 지금 구조가 해결한 문제와 아직 남은 문제를 분리한다. 둘째, 다음 리팩토링을 어디부터 어떤 순서로 해야 하는지 작업 단위로 정리한다.
-
-이 문서는 "지금 구조가 틀렸다"는 문서가 아니라, "지금 구조가 중간 상태라면 다음에 무엇을 해야 하는가"를 기록하는 문서다.
-
-## 2. 현재 구조에서 이미 개선된 점
-
-현재 구조는 이전보다 다음 점에서 나아졌다.
-
-- route policy 선언을 `src/features/auth/server/middleware/route-policy.ts`로 분리했다.
-- 보호 경로를 모두 수동 나열하지 않고, 특수 경로만 policy로 분리한다.
-- `/redirection`은 bypass로 통과시키고, `/login`, `/sign-up`, 공개 세션 경로는 각각 다른 handler로 처리한다.
-- `pending-signup`을 정상 bootstrap 상태로 인식한다.
-- 기본 정책을 보호 경로로 두어 누락 위험을 줄인다.
-
-즉, 현재 구조는 "모든 경로를 같은 인증 규칙으로 처리하던 문제"를 일부 해소했다.
-
-다시 정리하면, 지금 미들웨어는 예전보다 의도는 더 분명하고 안전장치도 더 많아졌다.
-
-## 3. 여전히 남아 있는 구조 문제
-
-### 3.1 정책 정의는 분리됐지만, 실행 계층 안의 책임이 아직 크다
-
-초기 구조에서는 `middleware.ts`가 다음을 모두 한 파일에서 처리했다.
-
-1. route policy 선언
-2. access token 검증
-3. refresh 요청
-4. 쿠키 쓰기/삭제
-5. guest token 판별
-6. 관리자 권한 판별
-
-현재는 route policy가 `src/features/auth/server/middleware/route-policy.ts`로 분리되어 이 문제를 1차 해소했다. 다만 `route-session`, `route-decisions`, `route-actions`, `route-handlers` 쪽 실행 계층 안에는 여전히 "세션 해석", "정책 판단", "부수효과 적용"이 촘촘하게 연결되어 있어 읽기 비용이 남아 있다.
-
-다시 정리하면, policy 표 자체는 분리됐지만 실행 계층 내부를 더 잘게 읽을 여지는 아직 남아 있다.
-
-### 3.2 handler가 세션 갱신 로직을 반복한다
-
-`handlePublicSessionRoute`, `handleLogin`, `handleProtected`는 모두 아래 흐름을 부분적으로 반복한다.
-
-1. access token 확인
-2. `/auth/me` 검증
-3. invalid면 refresh 시도
-4. 새 토큰 저장
-5. `memberId` 정규화
-6. 실패 시 쿠키 정리
-
-문제는 로직이 같은데 경로마다 조금씩 다르게 복사되어 있다는 점이다.
-
-다시 정리하면, 현재 구조는 handler는 나뉘었지만 세션 검증 엔진은 아직 공용 함수로 추출되지 않았다.
-
-### 3.3 `public-session`이 너무 넓다
-
-현재 `public-session`은 공개 접근 허용이라는 공통점만 갖고 여러 도메인을 한데 묶는다.
-
-- `/home`
-- `/mentoring`
-- `/insights`
-- `/premium-study`
-- `/group-study`
-
-문제는 이 경로들 사이에도 인증 요구 수준이 완전히 같지는 않다는 점이다.
-
-- 어떤 페이지는 익명에게 완전히 공개된다.
-- 어떤 페이지는 공개지만 로그인 사용자에게 더 많은 기능이 열린다.
-- 어떤 경로는 장기적으로 route layout guard와 더 가까운 책임을 가져야 할 수도 있다.
-
-다시 정리하면, `public-session`은 현재는 실용적이지만, 장기적으로는 범주가 다소 넓고 뭉뚱그려져 있다.
-
-### 3.4 관리자 권한 판정이 handler 내부 문자열 체크에 남아 있다
-
-현재 `/admin` 판정은 `handleProtected()` 내부에서 `pathname.startsWith('/admin')`와 `ROLE_ADMIN` 체크로 처리한다.
-
-문제는 관리자 권한 정책이 route policy 표 밖에서 별도 하드코딩으로 남아 있다는 점이다.
-
-다시 정리하면, 지금은 "보호 경로"와 "관리자 경로"가 같은 handler 안에서 뒤늦게 갈라진다.
-
-### 3.5 route policy 테스트가 없다
-
-현재는 사람이 `ROUTE_POLICIES`와 handler 흐름을 읽고 올바른지 판단해야 한다.
-
-문제는 다음과 같다.
-
-- 새 경로가 추가될 때 policy가 올바른지 자동으로 검증되지 않는다.
-- `/login`, `/sign-up`, `/redirection`, `/admin`, 공개 페이지의 기대 동작이 테스트로 고정되어 있지 않다.
-
-다시 정리하면, 지금 구조는 설명은 가능하지만 테스트로 잠가놓지는 못한 상태다.
-
-## 4. 다음 리팩토링 방향
-
-### 4.1 적용 완료: route policy 선언 분리
-
-이 단계는 2026-03-15 인증 리팩토링에서 이미 반영됐다.
-
-- 완료 위치: `src/features/auth/server/middleware/route-policy.ts`
-- 연결 위치: `src/middleware.ts`
-
-정책 표를 먼저 읽고 구조를 이해할 수 있게 만들고, policy 수정과 세션 엔진 수정이 같은 diff에 섞이지 않게 한 것이 이 단계의 목적이었다.
-
-다시 정리하면, route policy 분리는 이제 "다음 작업"이 아니라 "완료된 기반 작업"이다.
-
-### 4.2 다음 단계 1: 세션 검증/refresh 엔진 공용화
-
-두 번째 단계는 아래 공통 흐름을 helper로 추출하는 일이다.
-
-1. 현재 token 검증
-2. invalid 시 refresh
-3. 새 token 적용
-4. server verified memberId 동기화
-5. 최종 상태 반환
-
-예를 들면 아래 같은 함수가 가능하다.
-
-- `resolveServerSession(request, ctx)`
-- `ensureVerifiedSession(request, ctx)`
-- `syncVerifiedSession(response, result)`
-
-이 작업이 필요한 이유는 다음과 같다.
-
-- public/login/protected handler가 같은 인증 엔진을 공유하게 만들 수 있다.
-- refresh와 memberId 정규화 버그를 한 군데서 고칠 수 있다.
-
-다시 정리하면, 두 번째 단계는 handler별 분기와 세션 유지 엔진을 분리하는 것이다.
-
-### 4.3 다음 단계 2: route policy를 access matrix로 승격
-
-세 번째 단계는 단순 enum 대신 정책 속성 기반 테이블로 바꾸는 일이다.
-
-예를 들면 아래 축을 가질 수 있다.
-
-- `allowAnonymous`
-- `allowPendingSignup`
-- `refreshSession`
-- `redirectAuthenticatedTo`
-- `requiredRole`
-- `clearBrokenSession`
-
-이 작업이 필요한 이유는 다음과 같다.
-
-- `login`, `sign-up`, `public-session`, `admin`이 사실상 서로 다른 boolean 조합임을 드러낼 수 있다.
-- 문자열 enum 분기보다 정책 자체가 데이터로 표현된다.
-
-다시 정리하면, 세 번째 단계는 handler 이름 중심 구조를 "정책 데이터" 중심 구조로 바꾸는 것이다.
-
-### 4.4 다음 단계 3: 관리자 정책 분리
-
-관리자 경로는 일반 보호 경로와 다른 권한 정책을 가진다.
-
-그래서 장기적으로는 아래처럼 분리하는 것이 좋다.
-
-- policy 표에서 `requiredRole: 'ROLE_ADMIN'`
-- 또는 `admin` 전용 handler
-
-이 작업이 필요한 이유는 다음과 같다.
-
-- `/admin`만 문자열 prefix로 따로 체크하는 구조를 제거할 수 있다.
-- 일반 인증 검증과 권한 검증을 별도 레이어로 나눌 수 있다.
-
-다시 정리하면, 관리자 체크는 "보호 경로 안의 예외"가 아니라 "별도 권한 정책"으로 승격하는 것이 맞다.
-
-### 4.5 다음 단계 4: route layout guard와 middleware 책임 재정리
-
-현재는 미들웨어와 `app`의 layout guard가 함께 인증을 다룬다.
-
-장기적으로는 책임을 이렇게 분리하는 것이 좋다.
-
-- middleware: 세션 유지, refresh, 쿠키 정리, 특수 진입점 처리
-- layout/page guard: 이 라우트가 로그인 필요인지, 관리자 필요인지 선언
-
-이 작업이 필요한 이유는 다음과 같다.
-
-- 새 페이지 추가 시 `middleware.ts` 수정보다 route 근처에서 정책을 선언하는 편이 자연스럽다.
-- "세션 엔진"과 "라우트 접근 제어"가 분리된다.
-
-다시 정리하면, 미들웨어는 인프라에 가깝고, 접근 권한 선언은 route 경계가 소유하는 쪽이 더 맞다.
-
-### 4.6 다음 단계 5: 정책 테스트 추가
-
-리팩토링 이후에는 최소 아래 케이스를 테스트로 잠가야 한다.
-
-1. `/redirection`은 bypass
-2. `/login`은 valid session이면 `/home`
-3. `/sign-up`은 pending-signup만 통과
-4. 공개 세션 경로는 익명 통과, broken session 정리
-5. 보호 경로는 익명 차단
-6. `/admin`은 admin role만 통과
-
-이 작업이 필요한 이유는 다음과 같다.
-
-- 구조를 바꿔도 기대 동작이 유지되는지 확인할 수 있다.
-- 다음 리팩토링 때 다시 추론부터 시작하지 않아도 된다.
-
-다시 정리하면, 마지막 단계는 설계를 문서에서 코드 테스트로 고정하는 작업이다.
-
-## 5. 권장 우선순위
-
-현재 우선순위는 아래처럼 잡는 것이 좋다.
-
-1. 세션 검증/refresh 엔진 공용화
-2. 관리자 정책 분리
-3. access matrix 도입
-4. layout guard와 middleware 책임 재정리
-5. 테스트 추가
-
-이 순서를 권장하는 이유는, route policy 분리가 이미 끝난 상태에서 이제는 실행 엔진 중복과 권한 모델을 먼저 정리해야 이후의 정책 모델링이 쉬워지기 때문이다.
-
-다시 정리하면, 당장 policy enum을 복잡하게 늘리기보다 먼저 중복 로직과 파일 책임부터 분리하는 것이 맞다.
-
-## 6. 결론
-
-현재 `middleware.ts`는 이전보다 의도가 더 분명한 중간 상태다. 하지만 아직 다음 문제가 남아 있다.
-
-- policy 선언과 실행 엔진이 한 파일에 섞여 있다.
-- handler들 사이에 세션 검증/refresh 로직이 중복된다.
-- 관리자 정책이 별도 권한 모델이 아니라 문자열 prefix 조건으로 남아 있다.
-- 테스트가 없어 구조를 코드로 잠그지 못했다.
-
-따라서 다음 단계의 핵심 방향은 아래 한 줄로 정리할 수 있다.
-
-`정책 선언`, `세션 유지 엔진`, `라우트 권한 선언`, `테스트`를 서로 다른 층으로 나누는 방향으로 미들웨어 인증 구조를 더 잘게 분해해야 한다.
-
-다시 정리하면, 지금의 다음 리팩토링은 "미들웨어를 없애는 것"이 아니라, 미들웨어 안에 섞인 여러 책임을 분리하는 작업이다.
-
-## 7. 반영 완료된 보완 사항
-
-초기 계획 단계 이후, OAuth 실패 redirect가 로그인 페이지에서 이전 인증 헤더를 남기는 문제가 확인되었다. 이 항목은 후속 작업으로 미루지 않고 바로 반영했다. 현재는 실패 redirect가 `/api/auth/clear-session?redirect=/login`을 먼저 거쳐 서버/클라이언트 세션을 함께 정리한 뒤 로그인 페이지로 이동한다.
-
-다시 정리하면, 실패 redirect의 세션 정리는 더 이상 미해결 과제가 아니고, 이번 수정 범위 안에서 이미 반영된 항목이다.
diff --git a/docs/2026-03-15-login-fail-fix/MIDDLEWARE_ROUTE_POLICY_GUIDE.md b/docs/2026-03-15-login-fail-fix/MIDDLEWARE_ROUTE_POLICY_GUIDE.md
deleted file mode 100644
index 052e8182e..000000000
--- a/docs/2026-03-15-login-fail-fix/MIDDLEWARE_ROUTE_POLICY_GUIDE.md
+++ /dev/null
@@ -1,175 +0,0 @@
-# 미들웨어 Route Policy 분류 가이드
-
-## 1. 문서 목적
-
-이 문서는 `src/features/auth/server/middleware/route-policy.ts`의 `RoutePolicyKind`가 어떤 기준으로 나뉘었는지 설명하기 위해 작성한다. 목표는 세 가지다. 첫째, 현재 정책 분류가 "경로 이름"이 아니라 "인증과의 상호작용 방식" 기준이라는 점을 명확히 한다. 둘째, 각 정책이 실제로 어떤 함수를 타는지 연결한다. 셋째, 왜 `protected`가 enum에 없고 `default`로 처리되는지 설계 의도를 기록한다.
-
-이 문서는 구현 해설 문서다. 즉, "현재 미들웨어가 무엇을 의도하고 있는가"를 코드 기준으로 설명한다.
-
-## 2. 전체 구조
-
-현재 미들웨어의 핵심 구조는 아래와 같다.
-
-1. `route-policy.ts`가 `pathname`을 보고 `RoutePolicyKind`를 결정한다.
-2. `src/middleware.ts`가 결정된 policy에 따라 전용 handler를 호출한다.
-3. 어떤 policy에도 매칭되지 않으면 `handleProtected()`로 보낸다.
-
-즉, 현재 구조는 "4가지 명시 정책 + 기본 보호 정책"이다.
-
-다시 정리하면, 미들웨어는 모든 경로를 일일이 `protected`로 나열하지 않고, 특별한 경로만 먼저 분리한 뒤 나머지를 기본적으로 보호한다.
-
-## 3. RoutePolicyKind 분류 기준
-
-### 3.1 `bypass`
-
-`bypass`는 인증 미들웨어가 세션 검증과 정리를 거의 수행하지 않고 그대로 통과시켜야 하는 경로다.
-
-현재 예시는 다음과 같다.
-
-- `/redirection`
-- `/test-sentry`
-- `/test-sentry-server-error`
-
-이 policy가 필요한 이유는 다음과 같다.
-
-- `/redirection`은 OAuth 결과를 막 해석하는 경계이므로, 미들웨어가 중간 세션을 검증하거나 쿠키를 정리하면 정상 플로우를 망칠 수 있다.
-- Sentry 테스트 경로는 인증 로직과 독립적으로 동작해야 한다.
-
-즉, `bypass`는 "인증 시스템이 여기서는 개입하지 말아야 한다"는 의미다.
-
-다시 정리하면, `bypass`는 보안이 필요 없다는 뜻이 아니라, 이 경로에서는 인증 상태를 미들웨어가 건드리면 안 된다는 뜻이다.
-
-### 3.2 `sign-up`
-
-`sign-up`은 회원가입 진행 중 세션을 별도 규칙으로 처리해야 하는 경로다.
-
-현재 이 policy는 `/sign-up`에만 적용된다.
-
-이 policy가 필요한 이유는 다음과 같다.
-
-- 기존 회원 세션이면 회원가입 페이지에 들어오면 안 된다.
-- 완전 익명 사용자는 회원가입 완료 단계에 들어오면 안 된다.
-- 반대로 `pending-signup` 세션은 정상 경로이므로 통과시켜야 한다.
-
-현재 handler는 아래 규칙을 가진다.
-
-- 완성된 회원 세션이면 `/home`으로 리다이렉트
-- 익명 세션이면 `/`로 리다이렉트
-- `pending-signup` 세션이면 그대로 통과
-
-즉, `sign-up`은 "로그인 후 아직 회원 전환을 끝내지 않은 세션"을 위한 특수 정책이다.
-
-다시 정리하면, `sign-up`은 보호 경로도 아니고 완전 공개 경로도 아니다. 가입 중인 세션만 통과시키는 중간 단계 경로다.
-
-### 3.3 `login`
-
-`login`은 로그인 페이지 전용 정책이다.
-
-현재 이 policy는 `/login`에만 적용된다.
-
-이 policy가 필요한 이유는 다음과 같다.
-
-- 이미 로그인된 사용자가 로그인 페이지에 머무를 필요가 없다.
-- `pending-signup` + guest token 상태는 `/sign-up`으로 보내는 것이 맞다.
-- 만료됐지만 복구 가능한 세션은 refresh 후 `/home`으로 보낼 수 있다.
-- 완전히 깨진 세션이면 쿠키를 정리하고 로그인 페이지를 유지해야 한다.
-
-즉, `login`은 "비로그인 사용자의 입구"이면서도, 이미 세션이 있는 사용자는 정상 목적지로 되돌리는 정책이다.
-
-다시 정리하면, `login`은 공개 경로처럼 보이지만, 실제로는 세션 복구와 리다이렉트 판단이 많이 들어가는 인증 전용 진입점이다.
-
-### 3.4 `public-session`
-
-`public-session`은 익명도 접근 가능하지만, 로그인 세션이 있다면 그 세션을 유지하고 정리해야 하는 공개 경로다.
-
-현재 예시는 다음과 같다.
-
-- `/group-study`
-- `/home`
-- `/mentoring`
-- `/premium-study`
-- `/insights`
-- `/one-on-one`
-- `/inquiry`
-
-이 policy가 필요한 이유는 다음과 같다.
-
-- 이 경로들은 비회원도 볼 수 있다.
-- 하지만 로그인 사용자가 들어오면 `accessToken` 검증, refresh, `memberId` 정규화 같은 세션 유지 작업은 필요하다.
-- 깨진 세션이 남아 있으면 공개 페이지에서 조용히 정리하는 편이 UX상 낫다.
-
-현재 handler는 아래 규칙을 가진다.
-
-- 익명 세션은 통과
-- `pending-signup` 세션은 guest 여부에 따라 최소 정규화만 하고 통과
-- 완성된 회원 세션은 `/auth/me` 검증
-- 만료 시 refresh 시도
-- refresh 실패 시 쿠키 정리
-
-즉, `public-session`은 "공개 접근 허용 + 세션 유지 보수" 정책이다.
-
-다시 정리하면, `public-session`은 공개 페이지이지만 인증과 완전히 무관한 페이지는 아니다.
-
-## 4. 왜 `protected`는 enum에 없나
-
-현재 설계에서 `protected`는 명시 정책이 아니라 기본 정책이다.
-
-그 이유는 다음과 같다.
-
-- 보호 경로를 일일이 나열하면 새 페이지가 추가될 때마다 `middleware.ts`를 수정해야 한다.
-- 반대로 특수 케이스만 명시하고 나머지를 기본 보호로 두면, 누락으로 인해 공개되는 위험을 줄일 수 있다.
-- 실제 보호 소유권은 `app`의 layout/page guard와 결합해서 관리하는 방향이 더 안전하다.
-
-현재 코드에서 이 설계는 `switch`의 `default -> handleProtected()`로 구현되어 있다.
-
-즉, `RoutePolicyKind`는 "특별히 분기해야 하는 경로 타입"만 담고, 기본 보호는 enum 밖에서 처리한다.
-
-다시 정리하면, `protected`가 빠진 것은 누락이 아니라 의도다. 특별한 경로만 표로 적고, 나머지는 기본적으로 보호한다.
-
-## 5. 현재 분류 체계의 장점
-
-현재 구조의 장점은 다음과 같다.
-
-- `/login`, `/sign-up`, `/redirection`처럼 의미가 다른 경로를 각기 다른 handler로 보낼 수 있다.
-- 공개 경로에서도 세션 복구와 쿠키 정리를 할 수 있다.
-- 보호 경로를 전부 수동 나열하지 않아도 된다.
-
-즉, 이전처럼 prefix 목록과 예외 목록이 계속 커지는 구조보다 의도가 더 분명하다.
-
-다시 정리하면, 현재 분류 체계는 "경로 이름"이 아니라 "인증 상호작용 방식"을 기준으로 handler를 고른다는 점에서 이전보다 설명 가능성이 높다.
-
-## 6. 현재 구조의 한계
-
-현재 분류도 완전한 최종 형태는 아니다.
-
-한계는 다음과 같다.
-
-- route policy 표는 분리됐지만, handler와 세션 엔진은 아직 더 잘게 나눌 여지가 있다.
-- `public-session` 아래에 사실상 보호 의미가 섞인 하위 경로가 생기면 추가 정책이 필요할 수 있다.
-- 정책 분류와 실제 라우트 구조가 테스트로 고정돼 있지 않다.
-
-즉, 지금 구조는 "이전보다 나아진 중간 상태"에 가깝다.
-
-다시 정리하면, 이 문서는 현재 구조를 설명하는 문서이지, 이 구조가 더 이상 리팩토링이 필요 없다고 선언하는 문서는 아니다.
-
-## 7. 결론
-
-현재 `RoutePolicyKind`의 설계 의도는 아래 한 줄로 정리할 수 있다.
-
-`bypass`, `sign-up`, `login`, `public-session`은 인증 시스템과 특별한 상호작용이 필요한 경로만 명시적으로 분리하고, 나머지는 기본적으로 보호 경로로 처리한다.
-
-핵심 결론은 다음과 같다.
-
-- `bypass`는 인증 개입 금지 경로다.
-- `sign-up`은 가입 진행 세션 전용 경로다.
-- `login`은 로그인 진입점 전용 경로다.
-- `public-session`은 공개 접근 허용 + 세션 유지 경로다.
-- `protected`는 enum 바깥의 기본 정책이다.
-
-다시 정리하면, 지금의 policy 분리는 "누가 들어올 수 있나"만이 아니라 "미들웨어가 세션을 어떻게 다뤄야 하나"를 기준으로 만들어진 것이다.
-
-## 8. 추가 보완: 실패 Redirect는 Policy보다 먼저 세션을 정리한다
-
-OAuth 실패 redirect는 route policy가 `/login`을 어떻게 다루는지 이전에, 먼저 `/api/auth/clear-session`을 거쳐 서버와 브라우저의 인증 쿠키를 함께 정리한다. 즉 실패 redirect는 단순 `login` policy 진입이 아니라, 세션 정리 route handler를 선행한 뒤 `login` policy로 들어간다.
-
-다시 정리하면, `/login` policy는 "이미 정리된 상태에서 로그인 페이지를 어떻게 다룰지"를 책임지고, 실패한 OAuth 세션 자체를 청소하는 책임은 `clear-session` route handler가 맡는다.
diff --git a/docs/2026-03-15-login-fail-fix/PR_AUTH_REFACTORING_OVERVIEW.md b/docs/2026-03-15-login-fail-fix/PR_AUTH_REFACTORING_OVERVIEW.md
deleted file mode 100644
index 63e66a91c..000000000
--- a/docs/2026-03-15-login-fail-fix/PR_AUTH_REFACTORING_OVERVIEW.md
+++ /dev/null
@@ -1,268 +0,0 @@
-# 인증 리팩토링 PR 개요
-
-## 1. PR 목적
-
-이 PR은 운영 환경에서 발생한 소셜 로그인 회귀를 단일 버그 수정으로 덮지 않고, 프론트엔드 인증 흐름 전체를 다시 정리하기 위한 리팩토링이다.
-
-이번 변경의 목표는 아래 다섯 가지다.
-
-1. 백엔드 OAuth redirect 계약을 프론트 코드에 명시적으로 반영한다.
-2. 로그인 직후 정상 bootstrap 상태를 미들웨어가 파손 세션으로 오판하지 않게 한다.
-3. 세션 생성, 세션 검증, 경로 보호, UI 로그인 판정의 기준을 하나로 맞춘다.
-4. 보호 정책 소유권을 `middleware.ts`의 수동 목록 중심 구조에서 각 라우트 경계로 분산한다.
-5. 이후 같은 회귀가 다시 생겨도 원인과 책임 경계를 빠르게 추적할 수 있게 구조와 문서를 정리한다.
-
-## 2. 배경 문제
-
-운영 이슈의 핵심은 아래 두 축이 겹친 것이었다.
-
-1. OAuth 성공 결과를 `/redirection`에서 해석하는 규칙이 타입과 파서로 고정되어 있지 않았다.
-2. 미들웨어, Provider, 헤더, 페이지가 각각 다른 기준으로 "로그인됨"을 판단하고 있었다.
-
-그 결과 실제 운영에서는 아래와 같은 증상이 가능했다.
-
-- 백엔드는 정상적으로 `access-token`, `refresh_token`을 발급했는데 프론트가 redirect 결과를 잘못 해석한다.
-- 프론트가 `accessToken` 쿠키를 막 저장한 직후, 미들웨어가 이를 덜 형성된 세션으로 보고 바로 정리한다.
-- 기존 회원과 신규 회원의 성공 조건이 다른데, 일부 레이어는 `memberId` 부재를 곧바로 실패로 본다.
-- 보호 경로 정책이 `middleware.ts` 수동 목록에 묶여 있어, 경로 추가 시 보호 누락 가능성이 있었다.
-
-## 3. 이번 PR에서 한 일
-
-### 3.1 OAuth redirect 계약을 코드로 고정
-
-백엔드 `/redirection` query 계약을 프론트 타입과 파서로 승격했다.
-
-- [src/types/auth/domain.ts](../../src/types/auth/domain.ts)
-- [src/features/auth/model/oauth-redirect-contract.ts](../../src/features/auth/model/oauth-redirect-contract.ts)
-- [src/features/auth/model/parse-oauth-redirect-result.ts](../../src/features/auth/model/parse-oauth-redirect-result.ts)
-
-정리된 계약은 아래와 같다.
-
-1. `is-success=false` 이면 실패다.
-2. `is-success=true && is-guest=false` 이면 기존 회원 성공이다.
-3. `is-success=true && is-guest=true` 이면 신규 회원 성공이다.
-4. 기존 회원 성공에는 `member-id`가 필요하다.
-5. 신규 회원 성공에는 `member-id`가 없어도 된다.
-
-이제 `/redirection`은 query를 직접 if 체인으로 읽지 않고, 계약 파서를 통해 `failure`, `new-member-success`, `existing-member-success`로 분기한다.
-
-### 3.2 클라이언트 세션 모델을 auth feature 아래로 통합
-
-인증 상태 해석과 클라이언트 세션 기록 규칙을 auth feature로 모았다.
-
-- [src/features/auth/model/auth-cookie.ts](../../src/features/auth/model/auth-cookie.ts)
-- [src/features/auth/model/auth-session.ts](../../src/features/auth/model/auth-session.ts)
-- [src/features/auth/model/client-auth-session.ts](../../src/features/auth/model/client-auth-session.ts)
-- [src/features/auth/model/use-auth.ts](../../src/features/auth/model/use-auth.ts)
-- [src/features/auth/model/auth-hydration-context.tsx](../../src/features/auth/model/auth-hydration-context.tsx)
-
-핵심 정리는 이렇다.
-
-1. 쿠키 이름을 공용 상수로 관리한다.
-2. 세션 상태를 `anonymous`, `pending-signup`, `authenticated-member`로 일관되게 해석한다.
-3. 기존 회원 세션 기록과 신규 회원 세션 기록을 다른 함수로 분리한다.
-4. 로그아웃과 세션 정리도 공용 함수로 묶는다.
-
-이제 redirect, 로그인, 로그아웃, refresh, 서버 세션 읽기가 각자 쿠키를 제각각 다루지 않는다.
-
-### 3.3 `/redirection`을 페이지가 아니라 인증 경계로 재구성
-
-`/redirection` 경로의 책임을 parser, controller, client page로 분리했다.
-
-- [src/features/auth/model/use-oauth-redirect-controller.ts](../../src/features/auth/model/use-oauth-redirect-controller.ts)
-- [src/features/auth/ui/oauth-redirect-page-client.tsx](../../src/features/auth/ui/oauth-redirect-page-client.tsx)
-- [src/app/(service)/redirection/page.tsx](../../src/app/(service)/redirection/page.tsx)
-
-리다이렉트 후처리 흐름은 아래처럼 정리됐다.
-
-1. 실패면 세션을 정리하고 `/login`으로 이동
-2. 신규 회원이면 guest 세션을 기록하고 `/sign-up`으로 이동
-3. 기존 회원이면 완성된 회원 세션을 기록하고 `/home`으로 이동
-
-즉 `/redirection`은 더 이상 거대한 `useEffect` 파일이 아니라, OAuth 결과를 해석하는 전용 경계가 됐다.
-
-### 3.4 미들웨어를 세션 인프라 레이어로 분해
-
-기존 `middleware.ts` 한 파일에 섞여 있던 정책 선언, 세션 해석, 토큰 검증, 응답 적용을 나눴다.
-
-- [src/middleware.ts](../../src/middleware.ts)
-- [src/features/auth/server/middleware/route-policy.ts](../../src/features/auth/server/middleware/route-policy.ts)
-- [src/features/auth/server/middleware/auth-context.ts](../../src/features/auth/server/middleware/auth-context.ts)
-- [src/features/auth/server/middleware/access-token-session.ts](../../src/features/auth/server/middleware/access-token-session.ts)
-- [src/features/auth/server/middleware/route-session.ts](../../src/features/auth/server/middleware/route-session.ts)
-- [src/features/auth/server/middleware/route-decisions.ts](../../src/features/auth/server/middleware/route-decisions.ts)
-- [src/features/auth/server/middleware/route-actions.ts](../../src/features/auth/server/middleware/route-actions.ts)
-- [src/features/auth/server/middleware/route-handlers.ts](../../src/features/auth/server/middleware/route-handlers.ts)
-- [src/features/auth/server/middleware/auth-cookies.ts](../../src/features/auth/server/middleware/auth-cookies.ts)
-- [src/features/auth/server/middleware/route-reasons.ts](../../src/features/auth/server/middleware/route-reasons.ts)
-
-이제 역할은 아래처럼 나뉜다.
-
-1. `route-policy`: 경로 정책 선언
-2. `auth-context`: 현재 요청의 인증 컨텍스트 해석
-3. `access-token-session`: access token 검증/재발급 엔진
-4. `route-session`: 현재 요청을 anonymous/pending-signup/authenticated/invalid로 정규화
-5. `route-decisions`: 각 정책에서 어떤 응답을 할지 결정
-6. `route-actions`: redirect/next/clear-cookie 같은 부수효과 적용
-7. `route-handlers`: resolve -> decide -> apply 연결
-
-이번 분해의 핵심은 "세션을 어떻게 검증하는가"와 "이 경로에서 무엇을 허용하는가"를 분리한 것이다.
-
-### 3.5 보호 정책 소유권을 레이아웃으로 이동
-
-보호 경로를 `middleware.ts` 목록에만 의존하지 않도록 서버 가드와 레이아웃 경계를 추가했다.
-
-- [src/features/auth/model/server-auth-session.ts](../../src/features/auth/model/server-auth-session.ts)
-- [src/features/auth/model/server-route-guard.ts](../../src/features/auth/model/server-route-guard.ts)
-- [src/app/(service)/(my)/layout.tsx](../../src/app/(service)/(my)/layout.tsx)
-- [src/app/(admin)/layout.tsx](../../src/app/(admin)/layout.tsx)
-- [src/app/(service)/application-list/layout.tsx](../../src/app/(service)/application-list/layout.tsx)
-- [src/app/(service)/payment/[id]/layout.tsx](../../src/app/(service)/payment/[id]/layout.tsx)
-- [src/app/(service)/mentoring/become-mentor/layout.tsx](../../src/app/(service)/mentoring/become-mentor/layout.tsx)
-- [src/app/(service)/mentoring/[id]/apply/layout.tsx](../../src/app/(service)/mentoring/[id]/apply/layout.tsx)
-
-이 구조로 바뀌면서 새 보호 페이지는 route group 또는 layout 경계에 붙는 방식으로 관리할 수 있게 됐다.
-
-즉 "새 페이지 만들 때마다 `middleware.ts`를 수정해야 하는 구조"에서 조금 더 벗어났다.
-
-### 3.6 로그인 상태 소비처를 같은 규칙으로 정렬
-
-헤더, Provider, 홈 페이지, 탭 노출 조건이 같은 세션 판정을 쓰도록 맞췄다.
-
-- [src/providers/index.tsx](../../src/providers/index.tsx)
-- [src/components/common/layout/home-header.tsx](../../src/components/common/layout/home-header.tsx)
-- [src/components/pages/home-page-server-content.tsx](../../src/components/pages/home-page-server-content.tsx)
-- [src/components/home/tab-navigation.tsx](../../src/components/home/tab-navigation.tsx)
-- [src/app/(service)/home/page.tsx](../../src/app/(service)/home/page.tsx)
-
-이전에는 어떤 곳은 `accessToken`만 보고, 어떤 곳은 `memberId`만 보고, 어떤 곳은 decode 결과와 쿠키를 따로 봤다.
-
-지금은 "완성된 회원 세션"과 "회원가입 대기 세션"을 분리해서 같은 규칙으로 해석한다.
-
-### 3.7 쿠키/refresh 흐름을 공용 경로로 정리
-
-refresh 이후 `accessToken`, `memberId` 동기화 규칙을 클라이언트 전반에 맞췄다.
-
-- [src/api/client/axios.ts](../../src/api/client/axios.ts)
-- [src/api/client/axiosV2.ts](../../src/api/client/axiosV2.ts)
-- [src/api/client/open-api-instance.ts](../../src/api/client/open-api-instance.ts)
-- [src/api/client/cookie.ts](../../src/api/client/cookie.ts)
-- [src/hooks/queries/use-auth-mutation.ts](../../src/hooks/queries/use-auth-mutation.ts)
-- [src/components/common/modals/login-modal.tsx](../../src/components/common/modals/login-modal.tsx)
-- [src/components/common/modals/sign-up-modal.tsx](../../src/components/common/modals/sign-up-modal.tsx)
-
-핵심은 아래 두 가지다.
-
-1. access token 갱신 시 memberId 쿠키도 같이 정규화
-2. refresh_token은 백엔드 `Set-Cookie` 책임으로 두고, 프론트 JS가 직접 쓰지 않음
-
-### 3.8 Hydration 및 전환 UX 문제 보정
-
-인증 리팩토링 과정에서 함께 드러난 layout/hydration 문제와 마이페이지 전환 병목도 정리했다.
-
-- [src/app/layout.tsx](../../src/app/layout.tsx)
-- [src/app/(landing)/layout.tsx](../../src/app/(landing)/layout.tsx)
-- [src/app/(service)/layout.tsx](../../src/app/(service)/layout.tsx)
-- [src/app/(admin)/layout.tsx](../../src/app/(admin)/layout.tsx)
-- [src/components/home/start-study-button.tsx](../../src/components/home/start-study-button.tsx)
-- [src/components/common/layout/header-user-dropdown.tsx](../../src/components/common/layout/header-user-dropdown.tsx)
-- [src/components/common/layout/sidebar/my-page-sidebar.tsx](../../src/components/common/layout/sidebar/my-page-sidebar.tsx)
-
-주요 수정은 아래와 같다.
-
-1. 하위 layout에서 중첩 `/
`를 만들지 않도록 정리
-2. hydration mismatch를 만들던 일부 클라이언트 렌더 차이를 완화
-3. 마이페이지 진입 시 prefetch와 중복 프로필 조회 축소로 전환 체감 개선
-
-### 3.9 문서와 팀 가이드 보강
-
-인증 구조를 설명하는 문서와 작업 원칙도 같이 추가했다.
-
-- [docs/2026-03-15-login-fail-fix/FRONTEND_OAUTH_REDIRECTION_CONTRACT.md](../../docs/2026-03-15-login-fail-fix/FRONTEND_OAUTH_REDIRECTION_CONTRACT.md)
-- [docs/2026-03-15-login-fail-fix/SOCIAL_LOGIN_REFACTORING_PLAN.md](../../docs/2026-03-15-login-fail-fix/SOCIAL_LOGIN_REFACTORING_PLAN.md)
-- [docs/2026-03-15-login-fail-fix/MIDDLEWARE_ROUTE_POLICY_GUIDE.md](../../docs/2026-03-15-login-fail-fix/MIDDLEWARE_ROUTE_POLICY_GUIDE.md)
-- [docs/2026-03-15-login-fail-fix/MIDDLEWARE_AUTH_REFACTORING_NEXT_STEPS.md](../../docs/2026-03-15-login-fail-fix/MIDDLEWARE_AUTH_REFACTORING_NEXT_STEPS.md)
-- [docs/2026-03-15-login-fail-fix/BACKEND_LOCAL_SOCIAL_LOGIN_COOKIE_CHANGE_REQUEST.md](../../docs/2026-03-15-login-fail-fix/BACKEND_LOCAL_SOCIAL_LOGIN_COOKIE_CHANGE_REQUEST.md)
-- [docs/2026-03-15-login-fail-fix/AUTH_STAGED_CHANGE_BREAKDOWN.md](../../docs/2026-03-15-login-fail-fix/AUTH_STAGED_CHANGE_BREAKDOWN.md)
-- [AGENTS.md](../../AGENTS.md)
-
-추가된 가이드의 핵심은 아래와 같다.
-
-1. 값이 진실의 원천이 되게 설계
-2. raw 문자열 남발 금지
-3. `const 객체 + union` 우선
-4. 단일 책임 원칙 유지
-5. 조건 분기 축소
-6. 주석은 헷갈리는 경계에만 선택적으로 추가
-
-## 4. 설계상 달라진 점
-
-### 4.1 이전 구조
-
-이전 구조는 아래 문제가 있었다.
-
-1. `/redirection`에서 query 해석, 쿠키 쓰기, 라우팅, 분석 이벤트가 한 파일에 섞여 있었다.
-2. 미들웨어가 세션 정책과 토큰 엔진을 동시에 품고 있었다.
-3. 보호 정책이 `middleware.ts` 경로 목록에 과도하게 의존했다.
-4. UI 소비처가 각자 다른 기준으로 로그인 여부를 계산했다.
-
-### 4.2 현재 구조
-
-현재는 아래처럼 경계를 나눴다.
-
-1. OAuth 결과 해석: `types/auth` + redirect parser
-2. 클라이언트 세션 기록: `client-auth-session`
-3. 서버 세션 해석: `server-auth-session`, `auth-context`, `route-session`
-4. 경로 정책 판단: `route-policy`, `route-decisions`
-5. 응답 부수효과: `route-actions`, `auth-cookies`
-6. 라우트 보호 소유권: layout + server-route-guard
-
-즉 이번 PR의 본질은 "버그 한 줄 수정"이 아니라, 인증 흐름의 source of truth를 다시 세운 것이다.
-
-## 5. 주의해서 봐야 할 변경 포인트
-
-리뷰 시 아래 포인트를 특히 보면 된다.
-
-1. 신규 회원 세션을 `pending-signup`으로 해석하는 기준이 백엔드 contract와 충돌하지 않는지
-2. `/redirection` 계약 위반을 예외로 분리한 방식이 적절한지
-3. 미들웨어가 여전히 정상 bootstrap 상태를 너무 공격적으로 지우지 않는지
-4. layout 기반 보호와 middleware 정책이 서로 충돌하지 않는지
-5. 헤더, Provider, 홈 페이지가 동일한 세션 규칙을 공유하는지
-
-## 6. 테스트 및 검증
-
-이번 PR에서 수행한 검증은 아래와 같다.
-
-1. `yarn typecheck`
-
-추가로 로컬 E2E 관점에서 확인해야 하는 시나리오는 아래다.
-
-1. 기존 회원 OAuth 성공 후 `/home` 진입
-2. 신규 회원 OAuth 성공 후 `/sign-up` 진입
-3. 실패 redirect 후 `/login` 진입
-4. 보호 경로 접근 시 익명 / 신규 회원 / 정회원 각각의 라우팅
-5. refresh token 재발급 이후 memberId 쿠키 정규화
-
-다만 로컬에서 실제 카카오 OAuth 전체 플로우를 끝까지 검증하려면 백엔드의 local cookie 정책이 함께 맞아야 한다. 관련 요청은 [BACKEND_LOCAL_SOCIAL_LOGIN_COOKIE_CHANGE_REQUEST.md](../../docs/2026-03-15-login-fail-fix/BACKEND_LOCAL_SOCIAL_LOGIN_COOKIE_CHANGE_REQUEST.md)에 정리했다.
-
-## 7. 남은 후속 작업
-
-이번 PR은 구조를 정리한 1차 리팩토링이다. 후속으로 남은 일은 아래다.
-
-1. 로컬/운영 E2E 자동화 시나리오 추가
-2. route policy와 access matrix 테스트 보강
-3. `pending-signup` 상태 이름/정의가 백엔드 guest contract와 완전히 일치하는지 추가 정리
-4. 인증 관련 레거시 유틸 완전 이관
-5. 헤더/사이드바 프로필 fetch 최적화 추가 정리
-
-## 8. 한 줄 요약
-
-이 PR은 소셜 로그인 회귀를 계기로, `OAuth redirect -> client session bootstrap -> middleware session verification -> route protection -> UI login state` 전체를 하나의 일관된 인증 구조로 다시 정리한 리팩토링이다.
-
-## 9. 추가 보완: OAuth 실패 Redirect 세션 정리
-
-로컬 E2E에서 OAuth 실패 redirect 뒤에 `/login` 본문은 뜨지만 이전 인증 헤더가 남는 문제가 확인됐다. 원인은 클라이언트 쿠키만 삭제한 채 같은 서비스 레이아웃 안에서 `router.replace('/login')`만 수행해, 서버가 보는 쿠키와 공유 레이아웃 상태가 즉시 정리되지 않은 데 있었다.
-
-이를 해결하기 위해 OAuth 실패와 계약 위반 케이스는 `/api/auth/clear-session?redirect=/login`을 경유하도록 수정했다. 이 route handler가 서버/클라이언트 세션을 함께 정리하고 로그인 페이지로 이동시킨다.
-
-다시 정리하면, 이번 PR은 OAuth 성공 경계뿐 아니라 OAuth 실패 경계까지 "완전한 세션 정리 후 로그인 이동"으로 닫는다.
diff --git a/docs/2026-03-15-login-fail-fix/SOCIAL_LOGIN_REFACTORING_PLAN.md b/docs/2026-03-15-login-fail-fix/SOCIAL_LOGIN_REFACTORING_PLAN.md
deleted file mode 100644
index a02c4a608..000000000
--- a/docs/2026-03-15-login-fail-fix/SOCIAL_LOGIN_REFACTORING_PLAN.md
+++ /dev/null
@@ -1,322 +0,0 @@
-# 소셜 로그인 흐름 분석과 리팩토링 계획
-
-## 1. 문서 목적
-
-이 문서는 운영 환경에서 발생한 소셜 로그인 회귀를 단순히 "어느 커밋이 문제인가"로 환원하지 않고, 프론트엔드와 백엔드가 함께 구성하는 인증 흐름 전체를 계약 단위로 다시 설명하기 위해 작성한다. 목표는 두 가지다. 첫째, 현재 로그인 흐름의 논리적 전제를 명시한다. 둘째, 어떤 리팩토링이 필요한지 우선순위와 책임 경계까지 포함해 정리한다.
-
-이 문서는 핫픽스 제안서가 아니라 구조 개선 문서다. 즉, 지금 당장 무엇을 되돌릴지보다, 왜 현재 구조가 회귀에 취약한지와 어떤 방향으로 재설계해야 같은 문제가 반복되지 않는지를 다룬다.
-
-## 2. 현재 로그인 계약
-
-현재 소셜 로그인 흐름은 프론트엔드, 백엔드, 브라우저 쿠키 저장 위치가 세 갈래로 나뉘어 있다. 이를 논리식으로 정리하면 다음과 같다.
-
-### 2.1 백엔드 OAuth 성공 계약
-
-다음 명제는 현재 백엔드 코드 기준 사실이다.
-
-- P1. 프론트가 소셜 로그인 버튼을 누르면 브라우저는 백엔드 `/api/v1/auth/{vendor}/redirect-uri`로 이동한다.
-- P2. 백엔드가 OAuth 로그인을 성공시키면 `refresh_token`은 `Set-Cookie`로 응답 헤더에 기록한다.
-- P3. 백엔드는 브라우저를 `{clientOrigin}/redirection?...` 으로 308 리다이렉트한다.
-- P4. 기존 회원 로그인 성공이면 리다이렉트 URL에는 `access-token`, `is-guest=false`, `member-id`, `auth-vendor`가 포함된다.
-- P5. 신규 회원 로그인 성공이면 리다이렉트 URL에는 `access-token`, `is-guest=true`, `user-name`, `profile-image-url`, `auth-vendor`가 포함되고, `member-id`는 없다.
-- P6. 로그인 실패이면 리다이렉트 URL에는 `is-success=false`가 포함된다.
-
-즉, `existing-member-success -> member-id exists` 이고, `new-member-success -> member-id absent` 이다. 이 명제가 깨지면 프론트는 잘못된 계약 위에서 동작하게 된다.
-
-다시 정리하면, 백엔드는 기존 회원과 신규 회원을 아예 다른 모양으로 보내고 있고, 프론트는 그 차이를 정확히 알아야 한다.
-
-### 2.2 프론트 세션 형성 계약
-
-프론트 코드는 `/redirection` 페이지에서 query string을 읽고 `document.cookie`로 `accessToken`, `memberId`, `socialImageURL`을 기록한다. 이때 중요한 사실은 다음과 같다.
-
-- P7. `accessToken`은 서버가 `Set-Cookie`로 내려주는 값이 아니라, 프론트가 `document.cookie`로 쓰는 값이다.
-- P8. 따라서 Network 탭에서 `accessToken`의 `Set-Cookie`가 보이지 않는 것은 이상 징후가 아닐 수 있다.
-- P9. 반면 `refresh_token`은 서버가 `Set-Cookie`로 내려주는 값이므로, Network 탭에서 확인 가능한 서버 쿠키다.
-
-다시 정리하면, `accessToken`은 네트워크 탭의 서버 쿠키가 아니라 프론트가 나중에 심는 값이고, `refresh_token`만 서버 쿠키다.
-
-### 2.3 로그인 상태 판정 계약
-
-현재 화면은 단순히 `accessToken`만 있다고 로그인 상태로 보지 않는다.
-
-- P10. 서버 컴포넌트 헤더는 `accessToken`과 숫자형 `memberId`가 모두 있어야 로그인 상태라고 판단한다.
-- P11. 클라이언트 Provider도 토큰의 `memberId`와 쿠키 `memberId`가 맞지 않으면 사용자 상태를 초기화한다.
-- P12. 따라서 "기존 회원 로그인"의 성공 조건은 `accessToken cookie exists AND memberId cookie exists` 이다.
-- P13. 반대로 "신규 회원 로그인"의 성공 조건은 `accessToken cookie exists AND sign-up route continues` 이며, 이 단계에서 `memberId`는 없어도 정상이다.
-
-이 구조 때문에 기존 회원과 신규 회원은 같은 OAuth 진입점에서 시작하지만, 프론트가 받아야 하는 성공 조건은 동일하지 않다.
-
-다시 정리하면, 기존 회원은 `accessToken + memberId`가 다 있어야 로그인 완료지만, 신규 회원은 `accessToken`만 들고 회원가입 단계로 가는 게 정상이다.
-
-## 3. 현재 구조의 문제
-
-현재 구조는 기능 자체보다 계약의 위치가 흩어져 있다는 점이 문제다. 세션 생성, 계약 해석, 로그인 상태 판정, 토큰 갱신이 서로 다른 파일에 분산되어 있고, 각 레이어가 상대 레이어의 전제를 암묵적으로 가정한다.
-
-### 3.1 `/redirection` 페이지가 너무 많은 책임을 가진다
-
-현재 `/redirection` 페이지는 다음 책임을 한 파일 안에서 동시에 수행한다.
-
-1. OAuth 성공/실패 결과 해석
-2. 기존 회원/신규 회원 분기
-3. 브라우저 쿠키 쓰기
-4. GTM 이벤트 발송
-5. 최종 라우팅 결정
-
-문제는 이 다섯 가지가 서로 다른 실패 조건을 가진다는 점이다. 예를 들어 `is-success=false`는 실패 처리 대상이고, `member-id` 부재는 신규 회원일 때는 정상 처리 대상이다. 그런데 이 두 조건을 하나의 if 문 체계 안에서 다루면 계약 위반과 정상 분기를 쉽게 혼동하게 된다.
-
-다시 정리하면, `/redirection` 한 파일이 너무 많은 결정을 떠안아서 조건 하나만 틀려도 전체 로그인 흐름이 깨진다.
-
-### 3.2 프론트와 백엔드의 계약이 코드에만 존재하고 타입으로 존재하지 않는다
-
-백엔드는 `existing-member-success`와 `new-member-success`를 서로 다른 query shape로 내려준다. 그러나 프론트에는 이를 나타내는 명시적인 타입이 없다. 그 결과, 프론트는 "소셜 로그인 성공이면 member-id가 있어야 한다" 같은 잘못된 일반화를 도입하기 쉽다.
-
-즉, 현재 상태는 아래와 같다.
-
-- 백엔드에는 성공 응답의 분기 규칙이 있다.
-- 프론트에는 그 분기 규칙을 표현하는 union type이 없다.
-- 따라서 계약이 문서가 아니라 암묵적 구현 세부사항으로 남아 있다.
-
-이 구조에서는 회귀가 생겼을 때 "계약이 바뀐 것인지", "구현이 잘못 이해한 것인지"를 빠르게 구분하기 어렵다.
-
-다시 정리하면, 백엔드 계약을 타입으로 박아두지 않아서 사람이 머리로만 기억하다가 실수하기 쉬운 구조다.
-
-### 3.3 세션의 source of truth가 둘로 나뉘어 있다
-
-현재 세션은 두 방식으로 만들어진다.
-
-- `refresh_token`은 서버가 심는다.
-- `accessToken`과 `memberId`는 프론트가 심는다.
-
-이 구조는 성립 자체는 가능하지만, 문제는 정규화 타이밍이 다르다는 점이다. 서버는 `refresh_token`만 아는 상태에서 움직이고, 프론트는 `accessToken`과 `memberId`를 써야 다음 화면이 로그인 상태로 보인다. 그 사이에 어느 하나라도 빠지면 사용자는 "로그인에 성공한 것 같지만 비로그인처럼 보이는" 상태를 경험한다.
-
-다시 정리하면, 서버와 프론트가 세션의 다른 조각을 따로 만들고 있어서 타이밍이 어긋나면 로그인한 것처럼 보였다가 바로 아닌 것처럼 보일 수 있다.
-
-### 3.4 로그인 상태 판정 로직이 여러 군데 중복되어 있다
-
-현재 로그인 관련 판정은 최소 네 군데에 흩어져 있다.
-
-1. `/redirection` 페이지의 query 해석
-2. `middleware.ts`의 보호 경로 처리
-3. 서버 컴포넌트 헤더의 `isLoggedIn` 계산
-4. 클라이언트 Provider의 user store 초기화 조건
-
-같은 시스템에서 "로그인됨"이라는 개념이 네 번 정의되면, 회귀는 한 번의 잘못된 수정이 아니라 네 정의 사이의 불일치로 발생한다. 실제로도 기존 회원과 신규 회원, 서버 렌더와 클라이언트 hydration, 브라우저 쿠키와 refresh token이 서로 다른 속도로 맞물리면서 문제를 만들고 있다.
-
-다시 정리하면, 같은 "로그인"을 여러 파일이 다르게 정의하고 있어서 서로 충돌하고 있다.
-
-### 3.5 방어 로직이 세션 bootstrap 단계까지 침범한다
-
-현재 미들웨어와 일부 클라이언트 로직은 다음 전제를 가진다.
-
-- P14. `accessToken`이 유효하지 않으면 해당 세션은 즉시 폐기해야 한다.
-- P15. `accessToken`은 있는데 `memberId`가 없거나 검증되지 않으면 해당 세션은 불완전하므로 폐기해야 한다.
-- P16. 로그인 페이지에 들어온 세션이 invalid 하면 루프 방지를 위해 쿠키를 정리해야 한다.
-
-이 전제 자체는 "이미 형성된 세션"에 대해서는 타당할 수 있다. 문제는 이 전제가 OAuth 직후의 bootstrap 단계까지 그대로 적용된다는 점이다.
-
-OAuth 직후에는 다음 현상이 정상적으로 발생할 수 있다.
-
-- 브라우저가 `/redirection`에서 방금 `accessToken`을 쓴 직후라 서버와 클라이언트의 쿠키 가시성이 완전히 동기화되지 않았을 수 있다.
-- `refresh_token`은 서버가 심고, `accessToken`은 프론트가 심기 때문에 두 토큰의 저장 타이밍이 다를 수 있다.
-- 신규 회원은 계약상 `member-id`가 없는 상태로 `/sign-up`에 진입하는 것이 정상이다.
-
-그런데 bootstrap 단계에서 이 상태를 "위조 또는 파손된 세션"으로 취급하면, 구조는 정상 경로와 실패 경로를 구분하지 못한다. 그 결과 사용자는 "로그인 성공 직후 accessToken이 생겼다가 바로 사라지는" 현상을 겪게 된다.
-
-따라서 현재 제거해야 하는 것은 "방어" 그 자체가 아니라, 아래와 같은 과도한 적용 방식이다.
-
-- OAuth 결과를 아직 해석 중인 단계에서 세션 파손 판정을 먼저 내리는 방식
-- 신규 회원의 정상 상태를 기존 회원 기준으로 검사하는 방식
-- `refresh_token` 정착 전의 짧은 불일치 구간까지 즉시 세션 폐기로 연결하는 방식
-
-즉, 방어 로직은 유지하되 적용 범위를 줄여야 한다. 인증 시스템은 정상적인 bootstrap 상태를 허용한 뒤, 세션이 완전히 형성된 이후에만 강한 정합성 검사를 수행해야 한다.
-
-다시 정리하면, 지금 핵심 위험은 "아직 로그인 만드는 중인 상태"를 미들웨어가 "이미 고장난 로그인"으로 오해하는 것이다.
-
-## 4. 이미 확인된 사실
-
-이번 분석에서 확인된 사실은 다음과 같다.
-
-### 4.1 `/api/v1/auth/me` 응답 shape
-
-`/api/v1/auth/me`는 현재 QA 백엔드에서 실제로 아래 형태를 반환한다.
-
-```json
-{
- "statusCode": 200,
- "timestamp": "2026-03-15T12:09:26.175682297",
- "content": {
- "memberId": 1,
- "roleId": "ROLE_MEMBER"
- },
- "message": null
-}
-```
-
-따라서 아래 파싱 자체는 현재 계약과 일치한다.
-
-```ts
-const data: { content: { memberId: number; roleId: string } } =
- await response.json();
-
-return { state: 'valid', memberId: data.content.memberId };
-```
-
-즉, 이번 구조 개선에서 `/auth/me` shape 파싱은 1차 리팩토링 대상이 아니다.
-
-다시 정리하면, `/auth/me` 파싱은 현재 기준 맞기 때문에 가장 먼저 의심해야 할 지점은 아니다.
-
-### 4.2 문제의 핵심은 `/auth/me`보다 `/redirection` 계약에 더 가깝다
-
-운영 증상이 "OAuth는 끝난 것 같은데 로그인 상태가 잡히지 않는다"라면, 구조적으로 먼저 의심해야 하는 것은 다음 두 지점이다.
-
-1. 백엔드가 `/redirection`으로 어떤 query를 보내는가
-2. 프론트가 그 query를 기존 회원/신규 회원/실패 케이스로 올바르게 해석하는가
-
-즉, 현재 리팩토링의 중심축은 `verifyAccessToken`의 응답 shape가 아니라 `OAuth result -> session bootstrap` 경계다.
-
-다시 정리하면, 지금 먼저 고쳐야 할 곳은 토큰 검증 함수보다 `/redirection` 결과 해석기다.
-
-## 5. 필요한 리팩토링
-
-리팩토링은 한 번에 하지 말고, 계약 안정화와 구조 분리를 순차적으로 해야 한다.
-
-### 5.1 1단계: OAuth 리다이렉트 계약을 타입으로 승격
-
-첫 번째 리팩토링은 "백엔드가 무엇을 보낼 수 있는가"를 프론트 타입으로 고정하는 일이다.
-
-필요한 작업은 다음과 같다.
-
-1. `src/types/auth/` 아래에 OAuth 리다이렉트 전용 타입을 만든다.
-2. 성공 케이스를 `ExistingMemberOAuthRedirectResult`와 `NewMemberOAuthRedirectResult`로 분리한다.
-3. 실패 케이스를 `OAuthRedirectFailureResult`로 별도 분리한다.
-4. `/redirection` 페이지는 `URLSearchParams`를 직접 해석하지 말고, `parseOAuthRedirectResult(searchParams)` 같은 단일 파서 함수를 거치게 만든다.
-
-이렇게 해야 다음 논리가 코드에 드러난다.
-
-- `is-success=false -> failure branch`
-- `is-guest=true -> new member branch`
-- `is-guest=false -> existing member branch`
-
-이 단계의 목적은 기능 추가가 아니라 계약 오해를 불가능하게 만드는 것이다.
-
-다시 정리하면, 먼저 백엔드가 보내는 경우의 수를 프론트 타입으로 못 박아야 다음 단계가 안전해진다.
-
-### 5.2 2단계: `/redirection`의 책임을 controller로 분리
-
-두 번째 리팩토링은 `/redirection` 파일에서 계약 해석과 부수효과를 분리하는 일이다.
-
-권장 구조는 다음과 같다.
-
-- `src/features/auth/model/parse-oauth-redirect-result.ts`
-- `src/features/auth/model/use-oauth-redirect-controller.ts`
-- `src/features/auth/ui/oauth-redirect-page-client.tsx`
-- `src/app/(service)/redirection/page.tsx`
-
-이 구조에서 각 책임은 다음처럼 나뉜다.
-
-- parser: query string을 타입 안전한 결과로 변환
-- controller: 쿠키 쓰기, 토스트, GTM, 라우팅 순서 제어
-- UI: 렌더링만 담당
-- app page: 엔트리 역할만 담당
-
-이 분리의 이점은 "계약 해석 실패"와 "쿠키 쓰기 실패"를 다른 레이어에서 다루게 된다는 점이다.
-
-다시 정리하면, `/redirection` 페이지는 얇게 두고 실제 로그인 처리 절차는 controller로 빼야 문제 원인을 분리해서 볼 수 있다.
-
-### 5.3 3단계: 세션 쓰기와 로그인 판정을 공용 모듈로 통합
-
-세 번째 리팩토링은 세션 조작을 헬퍼 수준에서 통합하는 일이다.
-
-현재는 `setCookie('accessToken', ...)`, `setCookie('memberId', ...)`, 헤더의 `isLoggedIn`, Provider의 `reset()` 조건이 서로 다른 파일에서 각각 정의된다. 이 구조를 아래처럼 정리해야 한다.
-
-- `writeClientSessionForExistingMember`
-- `writeClientSessionForNewMember`
-- `clearClientSession`
-- `isCompleteAuthenticatedSession`
-- `isSignupPendingSession`
-
-이 모듈이 생기면 "기존 회원은 accessToken + memberId가 있어야 한다", "신규 회원은 accessToken만 있어도 sign-up까지는 정상이다"라는 규칙이 한 곳에 모인다.
-
-다시 정리하면, 쿠키를 쓰는 규칙과 로그인 판정 규칙을 한 군데로 모아야 같은 세션을 다르게 해석하지 않게 된다.
-
-### 5.4 4단계: `middleware.ts`를 세션 복구 레이어로 한정
-
-네 번째 리팩토링은 미들웨어의 책임을 줄이는 것이다.
-
-미들웨어는 다음 역할까지만 가져야 한다.
-
-1. 보호 경로 접근 통제
-2. 이미 존재하는 세션의 서버측 검증
-3. 만료된 access token의 refresh 시도
-4. 서버가 확인한 `memberId` 정규화
-
-반대로 미들웨어가 가져서는 안 되는 책임은 다음과 같다.
-
-1. OAuth 성공 여부 해석
-2. 신규 회원/기존 회원 분기
-3. `/redirection` 단계의 불완전한 세션을 일반 보호 경로와 같은 기준으로 평가하는 일
-
-즉, 미들웨어는 "이미 세션이 형성된 이후"를 다루는 레이어여야 한다. "세션이 막 형성되는 중"인 `/redirection` 단계까지 같은 기준으로 다루면 회귀 위험이 커진다.
-
-여기서 중요한 점은 "쿠키 삭제 방어를 없앤다"가 아니라 "삭제 조건을 세션 안정화 이후로 늦춘다"는 것이다. 미들웨어가 즉시 세션을 지워야 하는 경우는 다음처럼 좁혀야 한다.
-
-1. 보호 경로에서 access token 검증과 refresh가 모두 실패한 경우
-2. 서버가 이미 형성된 세션의 memberId 불일치를 반복적으로 확인한 경우
-3. 명시적 로그아웃 또는 세션 만료가 확정된 경우
-
-반대로 아래 상황은 즉시 쿠키 삭제 사유로 취급하면 안 된다.
-
-1. `/redirection` 직후 아직 클라이언트가 세션을 기록하는 중인 상태
-2. 신규 회원이 `member-id` 없이 `/sign-up`으로 이동하는 정상 상태
-3. `refresh_token` 저장과 `accessToken` 저장이 서로 다른 응답 단계에 있는 짧은 전이 상태
-
-이 경계가 정리되지 않으면 시스템은 보안 방어를 수행하는 대신 정상 로그인 플로우를 스스로 파괴하게 된다.
-
-다시 정리하면, 미들웨어는 로그인 생성 단계까지 간섭하지 말고 완성된 세션만 관리해야 한다.
-
-### 5.5 5단계: 관측 가능성 추가
-
-지금 구조는 실패 원인을 추론하게 만들고, 증명하게 만들지 않는다. 따라서 리팩토링과 함께 로그와 테스트를 추가해야 한다.
-
-필수 항목은 다음과 같다.
-
-1. `/redirection` parser 실패 시 query snapshot 로깅
-2. 기존 회원 성공, 신규 회원 성공, 실패 redirect를 각각 검증하는 테스트
-3. `refresh_token exists + accessToken absent`, `accessToken exists + memberId absent` 같은 불완전 세션 상태를 명시적으로 기록하는 로그
-4. Playwright 또는 E2E 수준에서 기존 회원 로그인과 신규 회원 로그인 둘 다 재현 가능한 시나리오 추가
-
-이 단계가 없으면 구조를 개선해도 다음 회귀 때 다시 같은 방식으로 추론부터 시작하게 된다.
-
-다시 정리하면, 로그와 테스트가 없으면 다음에도 또 "감"으로 원인을 찾게 된다.
-
-## 6. 우선순위
-
-현재 상황에서 우선순위는 다음과 같다.
-
-1. `/redirection` 계약 타입화
-2. `/redirection` parser와 controller 분리
-3. 세션 쓰기/판정 공용화
-4. 미들웨어 책임 축소
-5. 로깅 및 E2E 시나리오 추가
-
-이 순서를 뒤집으면 안 된다. 특히 미들웨어만 먼저 고치면, 초기 OAuth 결과 해석이 계속 암묵적이기 때문에 구조 문제는 남는다.
-
-## 7. 결론
-
-현재 로그인 회귀는 단일 if 문 하나의 버그라기보다, "OAuth 결과 해석", "세션 생성", "로그인 상태 판정", "서버측 세션 복구"가 각각 다른 위치에서 중복 정의된 구조의 결과다. 따라서 필요한 리팩토링은 특정 커밋을 되돌리는 작업이 아니라, 인증 흐름의 계약을 명시하고 책임 경계를 다시 그리는 작업이다.
-
-핵심 결론은 다음과 같다.
-
-- `/auth/me`의 `content.memberId` 파싱은 현재 계약과 일치한다.
-- 구조적으로 더 위험한 지점은 `/redirection` query 계약과 그 해석 로직이다.
-- 따라서 리팩토링의 중심은 `verifyAccessToken`이 아니라 `OAuth redirect result -> client session bootstrap` 이어야 한다.
-
-이 문서의 방향대로 정리하면, 이후에는 "어떤 케이스가 정상이고 무엇이 계약 위반인지"를 코드와 문서에서 동시에 설명할 수 있게 된다.
-
-## 8. 추가 보완: OAuth 실패 후 잔존 세션 제거
-
-리팩토링 이후 로컬 E2E에서 확인한 추가 문제는, OAuth 실패 redirect가 `/login`으로 이동하더라도 공유 서비스 레이아웃과 서버 쿠키 관점에서는 이전 인증 상태가 잠시 남을 수 있다는 점이었다. 원인은 클라이언트 쿠키만 지우고 같은 레이아웃 안에서 `router.replace('/login')`만 수행하면, 서버가 보는 `refresh_token`과 기존 레이아웃 상태가 즉시 정리되지 않을 수 있기 때문이다.
-
-이를 보완하기 위해 실패 redirect와 계약 위반 redirect는 `/api/auth/clear-session?redirect=/login`을 경유하도록 수정했다. 이 route handler는 서버가 보는 인증 쿠키와 `refresh_token`까지 함께 삭제하고, 그 뒤 로그인 페이지로 이동시킨다.
-
-다시 정리하면, 실패 redirect는 이제 "화면 이동"이 아니라 "세션 정리 + 로그인 이동"으로 처리한다.
diff --git a/docs/2026-03-26-markdown-editor/COMMON_MARKDOWN_EDITOR_USAGE.md b/docs/2026-03-26-markdown-editor/COMMON_MARKDOWN_EDITOR_USAGE.md
deleted file mode 100644
index 1e61c56c4..000000000
--- a/docs/2026-03-26-markdown-editor/COMMON_MARKDOWN_EDITOR_USAGE.md
+++ /dev/null
@@ -1,277 +0,0 @@
-# 공용 마크다운 에디터 사용법
-
-## 목적
-
-`src/components/common/ui/editor/markdown-editor.tsx`는 TipTap 기반 공용 에디터 코어입니다.
-
-- 도메인 정책 없이 `value / onChange / placeholder / normalizeContent / imageConfig`만 받습니다.
-- 이미지 업로드를 지원할 수도 있고, 텍스트 전용으로도 쓸 수 있습니다.
-- 현재 멘토링 도메인은 이 공용 코어 위에 얇은 래퍼를 올려서 사용합니다.
-
-## 파일 위치
-
-- 공용 코어: `src/components/common/ui/editor/markdown-editor.tsx`
-- 멘토링 래퍼 예시: `src/features/mentoring/ui/registration/markdown/mentor-markdown-editor.tsx`
-- 멘토링 마크다운 정책: `src/types/mentoring/markdown.ts`
-
-## 가장 중요한 점
-
-이 컴포넌트 이름은 `MarkdownEditor`지만, 부모에 넘기는 값은 전통적인 Markdown 문자열이 아니라 **정규화된 HTML 문자열**입니다.
-
-즉 `onChange`로 받는 값은 내부적으로 아래 흐름을 따릅니다.
-
-1. TipTap 에디터 상태 변경
-2. `editor.getHTML()`
-3. `normalizeContent(...)`
-4. 부모 `onChange(next)`
-
-그래서 사용하는 쪽에서는 다음을 반드시 구분해야 합니다.
-
-- 입력 컴포넌트: `MarkdownEditor`
-- 저장/검증 포맷: 각 도메인이 원하는 HTML 정규화 문자열
-- 렌더링: 별도 preview/render 컴포넌트에서 처리
-
-## 기본 Props
-
-### `value`
-
-- 타입: `string`
-- 현재 에디터에 표시할 HTML 문자열입니다.
-
-### `onChange`
-
-- 타입: `(next: string) => void`
-- 정규화된 HTML 문자열을 부모 상태로 올립니다.
-
-### `placeholder`
-
-- 타입: `string | undefined`
-- 에디터가 비어 있을 때 보여줄 문구입니다.
-
-### `normalizeContent`
-
-- 타입: `(content: unknown) => string`
-- 도메인별 HTML 정규화 함수를 주입할 때 사용합니다.
-- 생략하면 `normalizeMarkdownContent`를 사용합니다.
-
-### `imageConfig`
-
-- 타입: `MarkdownEditorImageConfig | undefined`
-- 이미지 업로드를 활성화할 때만 넣습니다.
-- 생략하면 이미지 업로드, 붙여넣기, 드롭, 이미지 버튼이 비활성화됩니다.
-
-```ts
-export interface MarkdownEditorImageConfig {
- allowedImageExtensions: readonly string[];
- maxImageCount: number;
- maxImageFileSize: number;
- uploadImageFile: (file: File) => Promise;
-}
-```
-
-## 텍스트 전용 사용 예시
-
-```tsx
-'use client';
-
-import { useState } from 'react';
-import MarkdownEditor from '@/components/common/ui/editor/markdown-editor';
-
-export default function ExampleEditor() {
- const [value, setValue] = useState('');
-
- return (
-
- );
-}
-```
-
-이 경우:
-
-- 서식 툴바는 동작합니다.
-- 이미지 업로드 기능은 없습니다.
-
-## 이미지 업로드 포함 사용 예시
-
-```tsx
-'use client';
-
-import { useCallback, useState } from 'react';
-import MarkdownEditor from '@/components/common/ui/editor/markdown-editor';
-
-const ALLOWED_IMAGE_EXTENSIONS = ['jpg', 'jpeg', 'png', 'webp', 'gif'] as const;
-
-export default function ExampleEditorWithImages() {
- const [value, setValue] = useState('');
-
- const handleUploadImageFile = useCallback(async (file: File) => {
- const publicUrl = await uploadSomewhere(file);
-
- return publicUrl;
- }, []);
-
- return (
-
- );
-}
-```
-
-`uploadImageFile`은 **업로드 완료 후 최종 public URL 문자열을 반환**해야 합니다.
-
-## 멘토링 도메인 사용 예시
-
-멘토링에서는 공용 코어를 직접 쓰지 않고, 아래 래퍼를 통해 정책을 주입합니다.
-
-```tsx
-'use client';
-
-import { memo, useCallback } from 'react';
-import MarkdownEditor from '@/components/common/ui/editor/markdown-editor';
-import {
- requestMentorMarkdownImageUploadTicket,
- uploadMentorMarkdownImageFile,
-} from '@/features/mentoring/model/mentor-markdown-image-upload';
-import {
- MENTOR_MARKDOWN_ALLOWED_IMAGE_EXTENSIONS,
- MENTOR_MARKDOWN_MAX_IMAGE_COUNT,
- MENTOR_MARKDOWN_MAX_IMAGE_FILE_SIZE,
- normalizeMentorMarkdownContent,
-} from '@/types/mentoring/markdown';
-
-function MentorMarkdownEditor({
- value,
- onChange,
- placeholder,
-}: {
- value: string;
- onChange: (next: string) => void;
- placeholder?: string;
-}) {
- const handleUploadImageFile = useCallback(async (file: File) => {
- const ticket = await requestMentorMarkdownImageUploadTicket({
- fileName: file.name,
- fileType: file.type,
- fileSize: file.size,
- });
-
- await uploadMentorMarkdownImageFile({
- uploadUrl: ticket.uploadUrl,
- file,
- });
-
- return ticket.publicUrl;
- }, []);
-
- return (
-
- );
-}
-
-export default memo(MentorMarkdownEditor);
-```
-
-이 구조의 장점은:
-
-- 공용 코어는 멘토링 도메인을 모름
-- 멘토링은 자기 정책만 래퍼에서 주입
-- 다른 도메인도 같은 방식으로 별도 래퍼를 만들 수 있음
-
-## 현재 제공 기능
-
-- 볼드, 이탤릭, 밑줄, 취소선
-- H1, H2, H3
-- 인용문
-- 순서 없는 목록, 순서 있는 목록
-- 링크 삽입
-- 코드블록
-- 이미지 업로드
-- 이미지 붙여넣기
-- 이미지 드롭
-- 이미지 폭 조절
-
-## 붙여넣기/업로드 동작
-
-이미지 관련 입력은 아래 순서로 처리됩니다.
-
-1. 클립보드 파일
-2. Clipboard API 이미지
-3. HTML의 `img src`
-4. 텍스트 이미지 URL
-
-성공하면 업로드를 거쳐 최종 public URL을 본문에 삽입합니다.
-
-## 주의사항
-
-### 1. 저장 포맷은 HTML 기준
-
-부모에서 Markdown 원문을 기대하면 안 됩니다.
-
-```tsx
-onChange(normalizeContent(updatedEditor.getHTML()));
-```
-
-즉 검증과 저장도 HTML 기준으로 설계해야 합니다.
-
-### 2. 도메인 정책은 공용 코어에 넣지 않기
-
-예:
-
-- 이미지 개수 제한
-- 허용 확장자
-- 업로드 API
-- 저장용 정규화 규칙
-
-이런 값은 wrapper에서 넣어야 합니다.
-
-### 3. 이미지 기능이 필요 없으면 `imageConfig`를 빼기
-
-공용 코어는 `imageConfig`가 없을 때 이미지 기능을 자동으로 숨깁니다.
-
-### 4. 렌더러와 에디터를 분리하기
-
-입력용 에디터와 출력용 렌더러는 분리해서 생각해야 합니다.
-
-- 입력: `MarkdownEditor`
-- 출력: 도메인별 preview/render 컴포넌트
-
-특히 멘토링처럼 preview 최적화나 이미지 깜빡임 대응이 필요하면,
-출력은 별도 React 렌더 경로를 두는 편이 안전합니다.
-
-## 추천 사용 패턴
-
-새 도메인에서 도입할 때는 아래 순서를 권장합니다.
-
-1. 공용 `MarkdownEditor`를 직접 쓰지 말고 도메인 래퍼를 만든다.
-2. 도메인 전용 `normalizeContent`를 정의한다.
-3. 이미지 정책이 있으면 `imageConfig`를 wrapper에서 주입한다.
-4. preview/render도 도메인 컴포넌트로 분리한다.
-
-## 한 줄 요약
-
-공용 `MarkdownEditor`는 **도메인 정책 없는 TipTap 편집 코어**이고,
-실제 사용은 **도메인별 wrapper가 정규화와 이미지 정책을 주입하는 방식**이 이 저장소의 표준입니다.
diff --git a/docs/2026-04-01-refactoring/AUTH_LOGICAL_PROPOSITIONS_200.md b/docs/auth-proposition/AUTH_LOGICAL_PROPOSITIONS_200.md
similarity index 100%
rename from docs/2026-04-01-refactoring/AUTH_LOGICAL_PROPOSITIONS_200.md
rename to docs/auth-proposition/AUTH_LOGICAL_PROPOSITIONS_200.md
diff --git a/docs/2026-04-01-refactoring/AUTH_PROPOSITION_121_150_AUDIT.md b/docs/auth-proposition/AUTH_PROPOSITION_121_150_AUDIT.md
similarity index 100%
rename from docs/2026-04-01-refactoring/AUTH_PROPOSITION_121_150_AUDIT.md
rename to docs/auth-proposition/AUTH_PROPOSITION_121_150_AUDIT.md
diff --git a/docs/2026-04-01-refactoring/AUTH_PROPOSITION_151_180_AUDIT.md b/docs/auth-proposition/AUTH_PROPOSITION_151_180_AUDIT.md
similarity index 100%
rename from docs/2026-04-01-refactoring/AUTH_PROPOSITION_151_180_AUDIT.md
rename to docs/auth-proposition/AUTH_PROPOSITION_151_180_AUDIT.md
diff --git a/docs/2026-04-01-refactoring/AUTH_PROPOSITION_181_200_AUDIT.md b/docs/auth-proposition/AUTH_PROPOSITION_181_200_AUDIT.md
similarity index 100%
rename from docs/2026-04-01-refactoring/AUTH_PROPOSITION_181_200_AUDIT.md
rename to docs/auth-proposition/AUTH_PROPOSITION_181_200_AUDIT.md
diff --git a/docs/2026-04-01-refactoring/AUTH_PROPOSITION_1_30_AUDIT.md b/docs/auth-proposition/AUTH_PROPOSITION_1_30_AUDIT.md
similarity index 100%
rename from docs/2026-04-01-refactoring/AUTH_PROPOSITION_1_30_AUDIT.md
rename to docs/auth-proposition/AUTH_PROPOSITION_1_30_AUDIT.md
diff --git a/docs/2026-04-01-refactoring/AUTH_PROPOSITION_31_60_AUDIT.md b/docs/auth-proposition/AUTH_PROPOSITION_31_60_AUDIT.md
similarity index 100%
rename from docs/2026-04-01-refactoring/AUTH_PROPOSITION_31_60_AUDIT.md
rename to docs/auth-proposition/AUTH_PROPOSITION_31_60_AUDIT.md
diff --git a/docs/2026-04-01-refactoring/AUTH_PROPOSITION_61_90_AUDIT.md b/docs/auth-proposition/AUTH_PROPOSITION_61_90_AUDIT.md
similarity index 100%
rename from docs/2026-04-01-refactoring/AUTH_PROPOSITION_61_90_AUDIT.md
rename to docs/auth-proposition/AUTH_PROPOSITION_61_90_AUDIT.md
diff --git a/docs/2026-04-01-refactoring/AUTH_PROPOSITION_91_120_AUDIT.md b/docs/auth-proposition/AUTH_PROPOSITION_91_120_AUDIT.md
similarity index 100%
rename from docs/2026-04-01-refactoring/AUTH_PROPOSITION_91_120_AUDIT.md
rename to docs/auth-proposition/AUTH_PROPOSITION_91_120_AUDIT.md
diff --git a/docs/ops/onboarding.md b/docs/ops/onboarding.md
new file mode 100644
index 000000000..5db421d58
--- /dev/null
+++ b/docs/ops/onboarding.md
@@ -0,0 +1,166 @@
+# ZERO-ONE 운영 배포 온보딩
+
+이 문서는 운영 배포를 처음 보는 사람을 위한 입문 문서입니다. 목표는 내부 구현을 전부 외우는 것이 아니라, “어떻게 운영 배포를 하면 되는지 → 왜 그렇게 하는지 → 내부에서 어떤 일이 일어나는지”를 빠르게 이해하는 것입니다.
+
+먼저, **운영 반영은 프론트 배포 전에 백엔드부터 확인합니다.** 프론트엔드가 새 API나 새 데이터 형태를 기대하는 경우가 많기 때문에, 백엔드가 먼저 운영에서 정상 동작해야 프론트 배포도 안전합니다.
+
+## 1. 백엔드 운영 배포는 이렇게 합니다
+
+백엔드 변경사항을 운영에 반영할 때 사람이 하는 일은 단순합니다.
+
+1. 백엔드 PR을 준비하고 release label을 정확히 하나 붙입니다.
+ - `release:patch`
+ - `release:minor`
+ - `release:major`
+2. Jenkins에서 백엔드 운영 배포를 실행합니다.
+3. Jenkins 배포 성공을 확인합니다.
+
+여기까지가 사람이 보는 백엔드 운영 배포입니다. 백엔드 PR 머지만으로는 프론트엔드 PR이나 릴리즈 기록이 생기지 않습니다. 다만 Jenkins 운영 배포가 성공해 프론트엔드 저장소로 배포 사실을 보내면, 프론트엔드 저장소가 `frontend.changed: false`, `backend.changed: true`인 backend-only 릴리즈 기록을 남길 수 있습니다.
+
+## 2. 프론트엔드 운영 배포는 이렇게 합니다
+
+프론트엔드 변경사항을 운영에 반영할 때도 흐름은 단순합니다.
+
+1. `develop`에서 `main`으로 가는 PR을 만들고 release label을 정확히 하나 붙입니다.
+ - `release:patch` - 버그 수정 또는 작은 호환 변경
+ - `release:minor` - 호환 가능한 기능 추가/변경
+ - `release:major` - 깨지는 변경 또는 큰 운영 변경
+2. PR CI가 통과했는지 확인합니다.
+3. PR을 `main`에 머지합니다.
+4. GitHub Actions 운영 배포와 `releases/prod-*.yaml` 기록 생성을 확인합니다.
+
+프론트엔드 운영 배포에서 사람이 주로 하는 일은 **PR, release label, CI/배포 결과 확인**입니다.
+
+## 3. release label은 왜 붙이나요?
+
+release label은 “이번 운영 배포가 버전을 얼마나 올려야 하는지” 알려주는 신호입니다.
+
+```txt
+release:patch -> v1.0.0에서 v1.0.1
+release:minor -> v1.0.0에서 v1.1.0
+release:major -> v1.0.0에서 v2.0.0
+```
+
+운영 배포에서는 release label이 정확히 하나만 있어야 합니다. 두 개 이상 있으면 자동화가 추측하지 않고 실패해야 합니다.
+
+## 4. 왜 이런 방식으로 하나요?
+
+ZERO-ONE은 백엔드와 프론트엔드가 따로 배포될 수 있습니다. 하지만 운영에서 실제로 도는 제품은 둘 중 하나가 아니라 아래 조합입니다.
+
+```txt
+Frontend image + Backend image + DB migration 상태
+```
+
+그래서 프론트엔드 또는 백엔드 운영 배포가 성공하면 프론트엔드 저장소의 `releases/prod-*.yaml`에 그 시점의 운영 조합을 기록합니다.
+
+릴리즈 기록에는 다음 정보가 남습니다.
+
+1. 어떤 프론트엔드 이미지가 운영 중인지
+2. 어떤 백엔드 이미지가 운영 중인지
+3. DB migration 상태가 무엇인지
+4. 문제가 생기면 어떤 이미지로 롤백해야 하는지
+5. 누가/언제/어떤 순서로 배포했는지
+
+이 기록이 있어야 장애가 났을 때 “지금 운영이 정확히 어떤 조합인지”와 “어디로 롤백해야 하는지”를 알 수 있습니다.
+
+## 5. 우리가 자동화한 것
+
+### 5-1. 백엔드 운영 배포 자동화
+
+백엔드는 Jenkins가 운영 배포합니다. Jenkins는 DB migration을 검증하고, 백엔드 이미지를 빌드한 뒤 운영 서버에 반영합니다. 배포가 성공하면 프론트엔드 저장소에 backend-only 릴리즈 기록을 남길 수 있습니다. 이때 프론트엔드는 그대로이고 백엔드만 바뀐 조합으로 기록됩니다.
+
+### 5-2. 프론트엔드 운영 배포 자동화
+
+프론트엔드 PR이 `main`에 머지되면 GitHub Actions가 다음 일을 자동으로 합니다.
+
+1. release label을 확인합니다.
+2. 프론트엔드 버전을 계산합니다.
+3. 프론트엔드 Docker 이미지를 빌드합니다.
+4. 운영 서버에 배포합니다.
+5. health/smoke check를 실행합니다.
+6. 성공하면 `releases/prod-*.yaml` 릴리즈 기록을 만듭니다.
+
+## 6. 내부에서는 어떻게 돌아가나요?
+
+### 6-1. 백엔드 배포 내부 흐름
+
+백엔드 운영 배포는 이렇게 흘러갑니다.
+
+1. 운영자가 Jenkins 배포를 실행합니다.
+2. Jenkins가 migration 검증, 이미지 빌드, 운영 서버 반영을 처리합니다.
+3. Jenkins 성공 시 프론트엔드 저장소가 backend-only 릴리즈 기록을 남깁니다.
+
+짧게 쓰면 이 흐름입니다.
+
+```txt
+백엔드 Jenkins 배포
+→ migration 검증 / image build / production deploy
+→ backend-only releases/prod-*.yaml 기록
+```
+
+### 6-2. 프론트엔드 배포 내부 흐름
+
+프론트엔드 운영 배포는 이렇게 흘러갑니다.
+
+1. 운영자가 프론트엔드 PR을 `main`에 머지합니다.
+2. GitHub Actions가 실행됩니다.
+3. GitHub Actions가 release label을 확인합니다.
+4. GitHub Actions가 프론트엔드 버전을 계산합니다.
+5. GitHub Actions가 프론트엔드 이미지를 빌드합니다.
+6. GitHub Actions가 프론트엔드 이미지를 운영 서버에 배포합니다.
+7. GitHub Actions가 health/smoke check를 실행합니다.
+8. GitHub Actions가 새 `releases/prod-*.yaml`을 기록합니다.
+
+짧게 쓰면 이 흐름입니다.
+
+```txt
+main 머지
+→ GitHub Actions 실행
+→ release label 확인
+→ frontend version 계산
+→ frontend image build
+→ production frontend deploy
+→ health/smoke check
+→ releases/prod-*.yaml 기록
+```
+
+## 7. release record는 왜 프론트엔드 저장소에 있나요?
+
+사용자가 실제로 보는 제품은 프론트엔드입니다. 하지만 프론트엔드는 백엔드와 DB 상태가 맞아야 정상 동작합니다.
+
+그래서 최종 운영 기록은 다음 조합을 한 번에 남깁니다.
+
+```txt
+Frontend + Backend + Database + Rollback target
+```
+
+이 조합의 최종 기록 위치를 `study-platform-client/releases/`로 정했습니다.
+
+## 8. 장애가 나면 어디부터 보나요?
+
+1. 최신 `releases/prod-*.yaml`을 엽니다.
+2. 현재 운영 중인 backend image와 frontend image를 확인합니다.
+3. DB migration 상태를 확인합니다.
+4. `rollback.app_rollback_target`에 적힌 고정 이미지 태그를 확인합니다.
+5. `prod`나 `latest-prod` pointer tag로 롤백하지 않습니다.
+
+자세한 롤백 규칙은 `docs/ops/version-management.md`의 롤백 가이드를 봅니다.
+
+## 9. 사람들이 자주 헷갈리는 점
+
+### 9-1. 백엔드 배포가 성공했는데 왜 프론트 PR이 안 생기나요?
+
+정상입니다. 백엔드 Jenkins는 프론트 PR을 만들지 않습니다. 대신 Jenkins가 배포 사실을 프론트엔드 저장소로 보내면 backend-only `releases/prod-*.yaml` 기록이 생길 수 있습니다. 그래서 프론트 PR은 없는데 YAML만 생기는 상황은 정상입니다.
+
+### 9-2. 프론트 PR 본문에 백엔드 payload 값을 직접 적어야 하나요?
+
+아닙니다. 백엔드 payload 값은 백엔드 Jenkins가 채워서 보냅니다. 프론트 PR에는 보통 release label만 있으면 됩니다.
+
+### 9-3. `prod` 이미지를 롤백 대상으로 쓰면 안 되나요?
+
+안 됩니다. `prod`와 `latest-prod`는 움직이는 pointer tag입니다. 롤백은 반드시 `v1.2.3-abcdef1` 같은 고정 이미지 태그로 해야 합니다.
+
+## 10. 더 깊게 읽으려면
+
+1. 운영자가 배포 흐름과 롤백까지 알고 싶다: `docs/ops/version-management.md`
+2. 자동화/스크립트/백엔드가 주고받는 필드까지 알고 싶다: `docs/ops/release-record-shared-contract.md`
diff --git a/docs/ops/release-record-shared-contract.md b/docs/ops/release-record-shared-contract.md
new file mode 100644
index 000000000..78d3a6d47
--- /dev/null
+++ b/docs/ops/release-record-shared-contract.md
@@ -0,0 +1,290 @@
+# ZERO-ONE 릴리즈 기록 공유 계약
+
+이 문서는 `study-platform-mvp` 백엔드와 `study-platform-client` 프론트엔드 사이의 운영 릴리즈 기록 공유 계약입니다.
+
+이 문서는 두 가지 스키마를 고정합니다.
+
+1. 백엔드가 프론트엔드로 보내는 릴리즈 payload 스키마
+2. 프론트엔드 저장소에 최종 저장되는 릴리즈 기록 스키마
+
+최종 릴리즈 기록의 source of truth는 다음 위치입니다.
+
+```txt
+study-platform-client/releases/
+```
+
+## 1. 역할
+
+### Backend
+
+백엔드는 백엔드 릴리즈 사실을 생산하는 쪽입니다. 운영 백엔드를 배포하고, 백엔드 버전/이미지, DB migration 메타데이터, 백엔드 롤백 대상을 확정한 뒤 그 사실을 프론트엔드 워크플로우로 보냅니다.
+
+### Frontend
+
+프론트엔드는 프론트엔드 릴리즈 사실을 생산하면서, 동시에 최종 릴리즈 기록을 쓰는 쪽입니다. 프론트엔드 운영 배포 시 프론트엔드 버전/이미지/커밋을 확정하고, 백엔드에서 온 사실 또는 최신 릴리즈 기록의 백엔드 상태를 검증한 뒤 최종 FE/BE/DB 운영 조합을 기록합니다.
+
+## 2. 릴리즈 의도
+
+릴리즈 의도는 정확히 하나만 허용됩니다.
+
+- `release:major`
+- `release:minor`
+- `release:patch`
+
+`hotfix` 라벨과 `-hotfix.N` 이미지 태그는 사용하지 않습니다.
+
+## 3. 식별자
+
+### 서비스 버전
+
+```txt
+vMAJOR.MINOR.PATCH
+```
+
+### 이미지 태그
+
+이미지 태그에는 날짜를 넣지 않습니다.
+
+```txt
+zeroone-frontend:vMAJOR.MINOR.PATCH-shortCommit
+zeroone-backend:vMAJOR.MINOR.PATCH-shortCommit
+```
+
+레지스트리 prefix는 허용합니다. 예: `zerooneitkr/zeroone-backend:v1.4.3-b7c8d9e`
+
+### Release ID
+
+`release_id`는 운영 배포 1건을 식별하는 고유 ID입니다. 사람이 언제 배포됐는지 바로 알 수 있도록 배포 시각을 포함합니다.
+
+날짜/시간은 `release_id`에만 들어갑니다. 이미지 태그에는 날짜/시간을 넣지 않습니다.
+
+```txt
+prod-YYYYMMDD-HHmmss
+```
+
+### Mutable tag
+
+`prod`와 `latest-prod`는 배포 편의를 위한 pointer tag일 뿐입니다. 롤백 대상으로는 절대 사용할 수 없습니다.
+
+## 4. 백엔드 운영 상태 전달 payload 계약
+
+백엔드 운영 배포가 성공하면 백엔드 이미지/버전/DB migration 상태가 확정됩니다. 이 값은 프론트엔드 저장소가 backend-only 릴리즈 기록을 만들 때 사용합니다.
+
+전달 방식으로 `repository_dispatch`를 사용할 경우 아래 계약을 따릅니다.
+
+- 대상 저장소: `code-zero-to-one/study-platform-client`
+- 이벤트 타입: `backend-prod-deployed`
+
+### Dispatch wrapper
+
+```json
+{
+ "event_type": "backend-prod-deployed",
+ "client_payload": {
+ "release_id": "prod-20260517-210045",
+ "env": "prod",
+ "summary": "[patch] Backend release summary",
+ "backend": {
+ "repo": "study-platform-mvp",
+ "image": "zeroone-backend:v1.4.3-b7c8d9e",
+ "commit": "b7c8d9e",
+ "version": "v1.4.3",
+ "changed": true
+ },
+ "database": {
+ "changed": true,
+ "migration_version": "V45",
+ "migration_files": [
+ "src/main/resources/db/migration/V45__create_course_refund.sql"
+ ]
+ },
+ "rollback": {
+ "backend": "zeroone-backend:v1.4.2-a1b2c3d"
+ },
+ "metadata": {
+ "release_intent": "patch",
+ "bootstrap_mode": false,
+ "previous_deploy_image": "zeroone-backend:v1.4.2-a1b2c3d",
+ "pull_request_number": 1234,
+ "pull_request_labels": ["release:patch", "db:backup-confirmed"],
+ "backend_deploy_id": "backend-prod-123"
+ }
+ }
+}
+```
+
+GitHub Actions에서 이미 `github.event.client_payload`를 선택한 경우, 프론트엔드 스크립트는 내부 `client_payload` 객체만 직접 받아도 됩니다. 이 payload가 처리되면 프론트엔드 저장소는 현재 운영 프론트엔드 상태와 새 백엔드 상태를 묶어 backend-only `releases/prod-*.yaml` 기록을 작성합니다.
+
+### 필수 payload 필드
+
+- `release_id` (운영 배포 1건의 고유 ID)
+- `env` (반드시 `prod`)
+- `backend.repo` (백엔드 저장소 이름)
+- `backend.image` (배포된 고정 백엔드 이미지 태그)
+- `backend.commit` (백엔드 short commit)
+- `backend.version` (백엔드 서비스 버전)
+- `backend.changed` (백엔드 변경 여부, backend-origin 기록에서는 `true`)
+- `database.changed` (DB 변경 여부)
+- `database.migration_version` (대표 migration version, migration이 없으면 `N/A`)
+- `database.migration_files` (migration 파일 목록, migration이 없으면 `[]`)
+- `rollback.backend` (백엔드 롤백용 고정 이미지 태그)
+- `metadata.release_intent` (`patch`, `minor`, `major` 중 하나)
+- `metadata.bootstrap_mode` (bootstrap 기록 여부)
+- `metadata.backend_deploy_id` (백엔드 배포 중복 방지 키)
+
+### 선택 payload 필드
+
+- `summary`
+- `metadata.previous_deploy_image`
+- `metadata.pull_request_number`
+- `metadata.pull_request_labels`
+
+## 5. 최종 릴리즈 기록 계약
+
+기록은 다음 위치에 작성됩니다.
+
+```txt
+releases/.yaml
+```
+
+### 백엔드에서 시작된 기록 예시
+
+```yaml
+release_id: prod-20260517-210045
+env: prod
+service_version: v1.4.3
+
+summary: 백엔드 patch 배포
+
+components:
+ frontend:
+ repo: study-platform-client
+ image: zeroone-frontend:v1.4.2-f1a2b3c
+ commit: f1a2b3c
+ version: v1.4.2
+ changed: false
+
+ backend:
+ repo: study-platform-mvp
+ image: zeroone-backend:v1.4.3-b7c8d9e
+ commit: b7c8d9e
+ version: v1.4.3
+ changed: true
+
+database:
+ changed: true
+ migration_version: V45
+ migration_files:
+ - src/main/resources/db/migration/V45__create_course_refund.sql
+
+rollback:
+ app_rollback_target:
+ frontend: zeroone-frontend:v1.4.2-f1a2b3c
+ backend: zeroone-backend:v1.4.2-a1b2c3d
+ db_rollback_note: DB 변경이 있었다면 앱 롤백 전에 호환성을 확인하세요.
+
+deployed_at: 2026-05-17T21:00:00+09:00
+deployed_by: automation
+status: success
+
+metadata:
+ backend_deploy_id: backend-prod-123
+ release_intent: patch
+ bootstrap_mode: false
+```
+
+### 최종 기록 필수 필드
+
+- `release_id`
+- `env`
+- `service_version`
+- `components.frontend.image`
+- `components.frontend.commit`
+- `components.frontend.version`
+- `components.frontend.changed`
+- `components.backend.image`
+- `components.backend.commit`
+- `components.backend.version`
+- `components.backend.changed`
+- `database.changed`
+- `database.migration_version`
+- `database.migration_files`
+- `rollback.app_rollback_target.frontend`
+- `rollback.app_rollback_target.backend`
+- `deployed_at`
+- `deployed_by`
+- `status`
+
+추가 규칙은 다음과 같습니다.
+
+- `components.backend.changed: true`이면 `metadata.backend_deploy_id`, `metadata.release_intent`, `metadata.bootstrap_mode`가 필수입니다.
+- `components.frontend.changed: true`이면 `metadata.frontend_deploy_id`가 필수입니다.
+- 프론트엔드 배포가 이전 백엔드 배포 사실과 의도적으로 짝을 이루는 경우, `metadata.paired_backend_deploy_id`는 `metadata.backend_deploy_id`와 같아야 합니다.
+
+## 6. 원자적 릴리즈 기록 규칙
+
+릴리즈 기록은 해당 시점의 실제 운영 FE/BE 조합을 설명해야 합니다.
+
+### Backend-only
+
+```yaml
+components:
+ frontend:
+ changed: false
+ backend:
+ changed: true
+```
+
+### Frontend-only
+
+```yaml
+components:
+ frontend:
+ changed: true
+ backend:
+ changed: false
+```
+
+### FE/BE paired change
+
+프론트엔드 배포가 이전에 기록된 백엔드 배포 id와 의도적으로 짝을 이루는 경우:
+
+```yaml
+components:
+ frontend:
+ changed: true
+ backend:
+ changed: true
+metadata:
+ backend_deploy_id: backend-prod-123
+ paired_backend_deploy_id: backend-prod-123
+```
+
+paired mode는 명시적이어야 합니다. 프론트엔드는 추측하면 안 됩니다.
+
+## 7. 검증 및 실패 규칙
+
+프론트엔드는 다음 경우 릴리즈 기록을 쓰지 말고 실패해야 합니다.
+
+- payload가 없음
+- schema가 맞지 않음
+- image/version/release_id가 유효하지 않음
+- rollback image가 없거나 mutable tag임
+- DB 필드 형태가 유효하지 않음
+- backend-origin 기록인데 backend deploy id가 없음
+- 중복 backend-only deploy id가 감지됨
+- backend-origin 기록에 필요한 현재 프론트엔드 상태를 확인할 수 없음
+
+잘못된 릴리즈 기록을 남기는 것보다 자동화가 실패하는 것이 낫습니다.
+
+## 8. Bootstrap
+
+현재 운영이 아직 canonical image tag 위에 있지 않다면, 백엔드는 백엔드 쪽에서 명시적인 `bootstrap:approved` 승인을 받은 경우에만 bootstrap mode를 사용할 수 있습니다.
+
+Bootstrap 버전 계산은 다음과 같습니다.
+
+- `release:patch` -> `v0.0.1`
+- `release:minor` -> `v0.1.0`
+- `release:major` -> `v1.0.0`
+
+프론트엔드는 백엔드 bootstrap 사실을 `metadata.bootstrap_mode: true`로 기록합니다.
diff --git a/docs/ops/version-management.md b/docs/ops/version-management.md
new file mode 100644
index 000000000..458bd3f2a
--- /dev/null
+++ b/docs/ops/version-management.md
@@ -0,0 +1,223 @@
+# ZERO-ONE 운영 버전 관리 가이드 - 프론트엔드 저장소
+
+이 문서는 ZERO-ONE 운영 배포를 사람이 이해하고 실행하기 위한 프론트엔드 저장소의 운영 가이드입니다. FE/BE가 함께 지켜야 하는 필드 스키마와 최종 릴리즈 기록 계약은 `docs/ops/release-record-shared-contract.md`를 source of truth로 봅니다.
+
+## 문서 구성
+
+운영 배포 관련 문서는 3개만 유지합니다.
+
+1. `docs/ops/onboarding.md`
+ - 운영 배포를 처음 보는 사람을 위한 입문 문서입니다.
+ - 무엇을 자동화했고, 어떻게 쓰면 되고, 내부에서 어떤 일이 일어나는지 쉽게 설명합니다.
+2. `docs/ops/version-management.md`
+ - 사람이 운영 배포 흐름을 이해하고 실행하기 위한 상세 가이드입니다.
+ - 프론트 배포, 백엔드 배포, release intent, backend dispatch, 체크리스트, 롤백 설명을 한곳에 둡니다.
+3. `docs/ops/release-record-shared-contract.md`
+ - FE/BE가 함께 보는 공유 계약 SSOT입니다.
+ - 백엔드가 프론트엔드로 보내는 payload 계약과 `releases/prod-*.yaml` 최종 스키마를 정의합니다.
+ - 이 문서는 계약 문서라서 불필요한 운영 설명을 섞지 않습니다.
+
+## AI가 읽어야 하는 위치
+
+AI agent가 운영 배포, 릴리즈 기록, release intent label, rollback metadata, `releases/prod-*.yaml`을 다룰 때는 아래 repository skill을 사용해야 합니다.
+
+1. Skill name: `zeroone-version-management`
+2. Shared skill source of truth: `skills_context/SHARED/zeroone-version-management.md`
+3. Claude wrapper: `.claude/skills/zeroone-version-management/SKILL.md`
+4. Codex wrapper: `.codex/skills/zeroone-version-management/SKILL.md`
+5. 사람용 운영 배포 온보딩: `docs/ops/onboarding.md`
+6. 공식 운영 버전 관리 가이드: `docs/ops/version-management.md`
+7. 공식 FE/BE 공유 계약 문서: `docs/ops/release-record-shared-contract.md`
+
+## 책임 범위
+
+- `study-platform-client`는 최종 운영 릴리즈 기록을 소유합니다.
+- 이유는 사용자에게 보이는 실제 제품이 frontend/backend/database 조합으로 동작하기 때문입니다.
+- `releases/`는 성공한 운영 조합의 최종 기록 위치입니다.
+- 백엔드 저장소는 백엔드 배포 사실을 생산하고, 프론트엔드 저장소는 프론트엔드 배포 사실을 생산하면서 최종 릴리즈 기록을 씁니다.
+- 백엔드 전용 배포/호환성 규칙은 백엔드 저장소에 둡니다. 이 저장소는 최종 운영 조합 기록과 프론트엔드 운영 배포 규칙만 소유합니다.
+
+## 쉬운 배포 흐름
+
+### 프론트엔드 운영 배포
+
+1. `develop`에서 `main`으로 가는 PR을 만들고 release intent label을 정확히 하나 붙입니다.
+2. PR CI가 통과하면 `main`에 머지합니다.
+3. `.github/workflows/deploy-prod.yml` 운영 배포 성공을 확인합니다.
+4. 새 `releases/prod-*.yaml` 기록이 생성됐는지 확인합니다.
+
+프론트엔드 릴리즈 기록에는 배포된 프론트엔드 이미지와 그때 짝이 된 백엔드/DB 상태가 함께 저장됩니다.
+
+### 백엔드 운영 배포
+
+1. 백엔드 PR을 준비하고 `release:major`, `release:minor`, `release:patch` 중 하나의 release intent label을 붙입니다.
+2. Jenkins에서 백엔드 운영 배포를 실행합니다.
+3. Jenkins 배포 성공을 확인합니다.
+
+백엔드 PR 머지만으로는 프론트엔드 PR이나 릴리즈 기록이 생기지 않습니다. Jenkins 운영 배포가 성공하고 배포 사실이 프론트엔드 저장소로 전달되면, 프론트엔드 저장소가 backend-only `releases/prod-*.yaml` 기록을 작성합니다.
+
+## Release ID와 이미지 태그
+
+`release_id`는 운영 배포 1건을 식별하는 고유 ID입니다. 사람이 언제 배포됐는지 바로 알 수 있도록 배포 시각을 포함합니다.
+
+```txt
+prod-YYYYMMDD-HHmmss
+```
+
+예시:
+
+```txt
+prod-20260517-210045
+```
+
+이미지 태그는 불변이어야 하며 날짜를 포함하면 안 됩니다.
+
+```txt
+zeroone-frontend:v{MAJOR}.{MINOR}.{PATCH}-{shortCommit}
+zeroone-backend:v{MAJOR}.{MINOR}.{PATCH}-{shortCommit}
+```
+
+레지스트리 namespace는 붙을 수 있습니다.
+
+```txt
+zerooneitkr/zeroone-frontend:v1.0.0-f1a2b3c
+```
+
+`prod`와 `latest-prod`는 배포 편의를 위한 pointer tag일 뿐입니다. 릴리즈 기록의 고정 배포 이미지나 롤백 대상으로 사용하면 안 됩니다.
+
+## Release intent
+
+운영 버전은 배포별 환경변수가 아니라 PR intent에서 계산합니다.
+
+허용되는 release intent는 정확히 하나입니다.
+
+- `release:patch` - 버그 수정 또는 작은 호환 변경
+- `release:minor` - 호환 가능한 기능 추가/변경
+- `release:major` - 제품/API 계약이 깨지는 변경
+
+`hotfix` 라벨과 `-hotfix.N` 이미지 태그는 사용하지 않습니다. 긴급 수정도 호환성 영향에 따라 `release:patch`, `release:minor`, `release:major` 중 하나를 사용합니다.
+
+라벨을 사용할 수 없으면 PR 본문 fallback으로 다음처럼 적을 수 있습니다.
+
+```md
+release: patch
+summary: QnA image key fix production release
+```
+
+라벨과 본문 `release`가 서로 다르면 워크플로우는 실패합니다. `release:*` 라벨이 두 개 이상 있어도 실패합니다.
+
+## 첫 프론트엔드 릴리즈 기록 bootstrap
+
+첫 프론트엔드 릴리즈 기록은 상속할 이전 YAML이 없어서 백엔드/롤백 메타데이터를 자동으로 알 수 없습니다. 이 첫 프론트엔드 PR에 한해서만 PR 본문에 `bootstrap: approved`와 전체 운영 조합을 포함합니다.
+
+```md
+## Release Intent
+release: patch
+summary: First recorded production release
+bootstrap: approved
+base_version: v1.0.0
+backend_image: zerooneitkr/zeroone-backend:v1.0.0-b7c8d9e
+backend_commit: b7c8d9e
+backend_version: v1.0.0
+rollback_frontend_image: zerooneitkr/zeroone-frontend:v0.6.3-a1b2c3d
+rollback_backend_image: zerooneitkr/zeroone-backend:v0.6.3-e4f5g6h
+db_changed: false
+db_migration_version: V12
+db_migration_files:
+db_rollback_note: DB 롤백은 자동화되어 있지 않습니다. 기록된 DB 상태와 앱 호환성을 확인하세요.
+```
+
+Bootstrap은 명시적으로 승인된 예외 경로입니다. `bootstrap: approved`가 없으면 첫 릴리즈 기록 생성은 실패해야 합니다.
+
+## 프론트엔드 단독 운영 릴리즈
+
+프론트엔드 PR이 `main`에 머지되면 `.github/workflows/deploy-prod.yml`이 프론트엔드 이미지를 빌드/배포한 뒤 최종 운영 조합을 기록합니다.
+
+이 워크플로우는 최신 `releases/prod-*.yaml`을 백엔드/DB source of truth로 사용합니다. 기록 형태는 다음과 같습니다.
+
+```yaml
+components:
+ frontend:
+ changed: true
+ backend:
+ changed: false
+```
+
+운영 백엔드 컨테이너를 확인할 수 있고 그 이미지가 최신 릴리즈 기록과 다르면 프론트엔드 워크플로우는 실패해야 합니다. 오래된 백엔드 메타데이터로 릴리즈 기록을 쓰는 것을 막기 위한 규칙입니다.
+
+## 백엔드 운영 배포 기록
+
+백엔드 Jenkins 운영 배포가 성공하면 백엔드 이미지/버전/DB migration 상태가 확정됩니다. Jenkins가 이 배포 사실을 프론트엔드 저장소로 전달하면, 프론트엔드 저장소의 backend release record workflow가 다음 형태의 기록을 작성합니다.
+
+```yaml
+components:
+ frontend:
+ changed: false
+ backend:
+ changed: true
+```
+
+이 기록은 프론트엔드 배포가 아닙니다. 현재 운영 프론트엔드는 그대로 두고, 새 백엔드와 짝이 된 운영 조합만 남기는 기록입니다. 백엔드 payload의 정확한 필드 스키마는 `docs/ops/release-record-shared-contract.md`를 따릅니다.
+
+선택 payload 필드는 사람이 프론트 PR에 적는 값이 아닙니다. 백엔드 Jenkins가 배포 과정에서 채워서 전달할 수 있는 참고 메타데이터입니다.
+
+- `summary` (백엔드 배포 요약, Jenkins가 PR 제목/릴리즈 요약 등에서 채움)
+- `metadata.previous_deploy_image` (배포 직전 운영 백엔드 이미지, Jenkins가 운영 상태에서 확인해 채움)
+- `metadata.pull_request_number` (백엔드 PR 번호, Jenkins가 GitHub에서 읽어 채움)
+- `metadata.pull_request_labels` (백엔드 PR 라벨 목록, Jenkins가 GitHub에서 읽어 채움)
+
+## 운영 배포 전 체크리스트
+
+- `main`에 머지하기 전에 PR CI가 통과했는지 확인합니다.
+- PR에 release intent가 정확히 하나만 있는지 확인합니다.
+- 첫 프론트엔드 운영 릴리즈 기록이라면 `bootstrap: approved`와 bootstrap 필드가 있는지 확인합니다.
+- 프론트엔드만 배포하는 경우 최신 `releases/`의 백엔드 이미지가 현재 운영 백엔드와 일치하는지 확인합니다. 다르면 백엔드 디스패치 기록을 먼저 남깁니다.
+- 롤백 대상과 상속/제공된 백엔드 이미지가 `prod`나 `latest-prod`가 아닌 고정 이미지 태그인지 확인합니다.
+
+프론트엔드만 운영 배포하는 경우에도, 릴리즈 기록을 쓰기 전에 현재 배포된 백엔드 이미지/API와 database migration 상태를 기록해야 합니다.
+
+## 운영 배포 후 체크리스트
+
+- 프론트엔드 컨테이너가 고정 프론트엔드 이미지 태그로 실행 중인지 확인합니다.
+- 백엔드 health/API 호환성을 확인합니다.
+- `releases/prod-YYYYMMDD-HHmmss.yaml`이 `main`에 커밋됐는지 확인합니다.
+- 릴리즈 기록에 frontend, backend, database, rollback, deployed time, actor, `status: success`가 포함됐는지 확인합니다.
+
+## 중복 및 실패 규칙
+
+- backend dispatch 기록에는 `metadata.backend_deploy_id`가 필수입니다.
+- 같은 `metadata.backend_deploy_id`를 두 번 기록하면 안 됩니다.
+- `prod`와 `latest-prod`는 pointer tag일 뿐이며 backend, frontend, rollback image 값으로 들어오면 거부해야 합니다.
+- payload/schema/current-state 검증이 실패하면 추측하지 말고 워크플로우가 실패해야 합니다.
+
+## 롤백 가이드
+
+롤백은 `releases/`에 기록된 고정 이미지 태그를 기준으로 합니다. pointer tag를 기준으로 롤백하지 않습니다.
+
+필요한 입력값은 다음과 같습니다.
+
+1. 실패한 현재 `release_id`
+2. `releases/` 아래의 최신 정상 릴리즈 기록
+3. `rollback.app_rollback_target.frontend` 고정 이미지 태그
+4. `rollback.app_rollback_target.backend` 고정 이미지 태그
+5. `rollback.db_rollback_note`와 DB 호환성 확인 결과
+
+롤백 규칙은 다음과 같습니다.
+
+- `prod` 또는 `latest-prod`로 롤백하지 않습니다.
+- 장애 분석에서 한쪽만 안전하게 바꿔도 된다는 근거가 확인되지 않았다면, frontend/backend를 호환되는 조합으로 함께 롤백합니다.
+- 파괴적인 DB rollback은 자동으로 실행하지 않습니다.
+- DB migration 호환성이 불확실하면 앱 이미지를 바꾸기 전에 멈추고 확인합니다.
+
+최소 롤백 흐름은 다음과 같습니다.
+
+1. 실패한 릴리즈 직전의 최신 성공 릴리즈 YAML을 엽니다.
+2. `rollback.app_rollback_target`에서 고정 이미지 태그를 복사합니다.
+3. 고정 frontend/backend 이미지를 배포합니다.
+4. 백엔드 health check를 실행합니다.
+5. 프론트엔드 smoke/E2E check를 실행합니다.
+6. 롤백 결과를 새 `releases/prod-*.yaml` 릴리즈 기록으로 남기고, 후속 incident note가 있으면 그 기록이나 incident tracker에 연결합니다.
+
+## 최종 릴리즈 기록 스키마
+
+최종 스키마의 source of truth는 `docs/ops/release-record-shared-contract.md`입니다. 이 문서에서는 스키마를 중복 정의하지 않습니다.
diff --git a/e2e/class/builder-feed.spec.ts b/e2e/class/builder-feed.spec.ts
index d6754b3be..4d052ad2b 100644
--- a/e2e/class/builder-feed.spec.ts
+++ b/e2e/class/builder-feed.spec.ts
@@ -133,27 +133,12 @@ function makeComments(): { content: BuilderFeedCommentsResponse } {
}
// ─── Route Mock Helpers ───────────────────────────────────────────────────────
-//
-// Use URL-object function predicates (url.pathname) rather than regex strings
-// so cross-origin requests to test-api.zeroone.it.kr are matched precisely.
-//
-// Registration order follows LIFO (last registered = first evaluated), so
-// more-specific handlers are registered last.
async function mockFeedListApis(page: Page, feeds: FeedItem[] = []) {
- // (1) course detail — single path segment after /courses/
- await page.route(
- (url) => /\/api\/v5\/courses\/[^/]+$/.test(url.pathname),
- async (route) => route.fulfill({ json: makeCourseDetail() }),
- );
-
- // (2) curriculum — /courses/{slug}/curriculum
- await page.route(
- (url) =>
- url.pathname.startsWith('/api/v5/courses/') &&
- url.pathname.endsWith('/curriculum'),
- async (route) =>
- route.fulfill({
+ await page.route(/\/courses\//, async (route) => {
+ const url = route.request().url();
+ if (url.includes('/courses/vibe-intro/curriculum')) {
+ await route.fulfill({
json: {
content: {
courseId: COURSE_ID,
@@ -163,62 +148,42 @@ async function mockFeedListApis(page: Page, feeds: FeedItem[] = []) {
chapters: [],
},
},
- }),
- );
-
- // (3) builder-feeds list — /courses/{courseId}/builder-feeds (checked first via LIFO)
- await page.route(
- (url) =>
- url.pathname.startsWith('/api/v5/courses/') &&
- url.pathname.includes('/builder-feeds'),
- async (route) => route.fulfill({ json: makeFeedList(feeds) }),
- );
+ });
+ } else if (url.includes('/builder-feeds')) {
+ await route.fulfill({ json: makeFeedList(feeds) });
+ } else if (url.includes('/courses/vibe-intro')) {
+ await route.fulfill({ json: makeCourseDetail() });
+ } else {
+ await route.continue();
+ }
+ });
}
async function mockFeedDetailApis(page: Page) {
- // (1) course detail
- await page.route(
- (url) => /\/api\/v5\/courses\/[^/]+$/.test(url.pathname),
- async (route) => route.fulfill({ json: makeCourseDetail() }),
- );
-
- // (2) builder-feeds list on course (for "더 많은 피드" section)
- await page.route(
- (url) =>
- url.pathname.startsWith('/api/v5/courses/') &&
- url.pathname.includes('/builder-feeds'),
- async (route) => route.fulfill({ json: makeFeedList([]) }),
- );
-
- // (3) feed detail — /builder-feeds/{id} (exact numeric id, no sub-path)
- await page.route(
- (url) => /\/api\/v5\/builder-feeds\/\d+$/.test(url.pathname),
- async (route) => route.fulfill({ json: makeFeedDetail() }),
- );
-
- // (4) comments — /builder-feeds/{id}/comments
- await page.route(
- (url) =>
- url.pathname.startsWith('/api/v5/builder-feeds/') &&
- url.pathname.endsWith('/comments'),
- async (route) => route.fulfill({ json: makeComments() }),
- );
-
- // (5) like — POST /builder-feeds/{id}/like (checked first via LIFO)
- await page.route(
- (url) =>
- url.pathname.startsWith('/api/v5/builder-feeds/') &&
- url.pathname.endsWith('/like'),
- async (route) => {
- if (route.request().method() !== 'POST') {
- await route.continue();
- return;
- }
+ // Intercept builder-feeds/* routes first (more specific)
+ await page.route(/\/builder-feeds\//, async (route) => {
+ const url = route.request().url();
+ if (url.includes('/comments')) {
+ await route.fulfill({ json: makeComments() });
+ } else if (url.includes('/like')) {
await route.fulfill({
json: { content: { feedId: FEED_ID, isLiked: true, likeCount: 6 } },
});
- },
- );
+ } else {
+ await route.fulfill({ json: makeFeedDetail() });
+ }
+ });
+ // Intercept /courses/* for "더 많은 피드" and course detail
+ await page.route(/\/courses\//, async (route) => {
+ const url = route.request().url();
+ if (url.includes('/builder-feeds')) {
+ await route.fulfill({ json: makeFeedList([]) });
+ } else if (url.includes('/courses/vibe-intro')) {
+ await route.fulfill({ json: makeCourseDetail() });
+ } else {
+ await route.continue();
+ }
+ });
}
// ─── Tests ────────────────────────────────────────────────────────────────────
@@ -229,10 +194,9 @@ test.describe('빌더 피드 목록 @auth', () => {
}) => {
await mockFeedListApis(page, [makeFeedItem(FEED_ID)]);
await page.goto(FEED_LIST_PATH, { waitUntil: 'load' });
- await page.waitForLoadState('networkidle', { timeout: 30000 });
await expect(page.getByText(/테스트 피드 내용/)).toBeVisible({
- timeout: 15000,
+ timeout: 10000,
});
await expect(page.getByText('테스터').first()).toBeVisible();
});
@@ -242,10 +206,9 @@ test.describe('빌더 피드 목록 @auth', () => {
}) => {
await mockFeedListApis(page, []);
await page.goto(FEED_LIST_PATH, { waitUntil: 'load' });
- await page.waitForLoadState('networkidle', { timeout: 30000 });
await expect(page.getByText('아직 등록된 피드가 없어요.')).toBeVisible({
- timeout: 15000,
+ timeout: 10000,
});
});
});
@@ -257,13 +220,12 @@ test.describe('빌더 피드 상세 @auth', () => {
test('피드 상세 렌더링 — 내용·댓글 표시', async ({ page }) => {
await page.goto(FEED_DETAIL_PATH, { waitUntil: 'load' });
- await page.waitForLoadState('networkidle', { timeout: 30000 });
await expect(page.getByText('피드 상세 내용입니다.')).toBeVisible({
- timeout: 15000,
+ timeout: 10000,
});
await expect(page.getByText('멋진 피드네요!')).toBeVisible({
- timeout: 10000,
+ timeout: 5000,
});
});
@@ -271,18 +233,16 @@ test.describe('빌더 피드 상세 @auth', () => {
page,
}) => {
await page.goto(FEED_DETAIL_PATH, { waitUntil: 'load' });
- await page.waitForLoadState('networkidle', { timeout: 30000 });
-
await expect(page.getByText('피드 상세 내용입니다.')).toBeVisible({
- timeout: 15000,
+ timeout: 10000,
});
+ // Like button is the first action button (Heart icon + likeCount)
const [likeResponse] = await Promise.all([
page.waitForResponse(
(r) =>
r.url().includes(`/builder-feeds/${FEED_ID}/like`) &&
r.request().method() === 'POST',
- { timeout: 15000 },
),
page.locator('button').filter({ hasText: '5' }).first().click(),
]);
diff --git a/e2e/class/payment-success.spec.ts b/e2e/class/payment-success.spec.ts
new file mode 100644
index 000000000..c279e92b0
--- /dev/null
+++ b/e2e/class/payment-success.spec.ts
@@ -0,0 +1,258 @@
+import { test, expect, type Page } from '@playwright/test';
+import { existsSync, readFileSync } from 'fs';
+import type {
+ CourseDetailResponse,
+ CoursePaymentConfirmResponse,
+} from '../../src/types/api/course.types';
+
+// ─── Constants ────────────────────────────────────────────────────────────────
+
+const AUTH_FILE = 'e2e/fixtures/auth.json';
+const SLUG = 'vibe-intro';
+const COURSE_ID = 1;
+const SUCCESS_PATH =
+ `/class/${SLUG}/payment/success` +
+ `?paymentId=1&paymentKey=test_pk_D5GePAmors8Z&orderId=order-vibe-001&amount=99000`;
+
+// ─── Localhost auth cookie injection ─────────────────────────────────────────
+
+test.beforeEach(async ({ context, baseURL }) => {
+ if (baseURL?.startsWith('http://localhost') && existsSync(AUTH_FILE)) {
+ const { cookies } = JSON.parse(readFileSync(AUTH_FILE, 'utf-8')) as {
+ cookies: {
+ name: string;
+ value: string;
+ domain: string;
+ path: string;
+ expires: number;
+ httpOnly: boolean;
+ secure: boolean;
+ sameSite: 'Strict' | 'Lax' | 'None';
+ }[];
+ };
+ await context.addCookies(
+ cookies.map((c) => ({ ...c, domain: 'localhost', secure: false })),
+ );
+ }
+});
+
+// ─── Mock Factories ───────────────────────────────────────────────────────────
+
+function makeCourseDetail(): { content: CourseDetailResponse } {
+ return {
+ content: {
+ courseId: COURSE_ID,
+ slug: SLUG,
+ viewerStatus: 'PAID',
+ title: '바이브 코딩 인트로',
+ description: null,
+ thumbnailUrl: null,
+ learnerCount: 42,
+ durationDays: 30,
+ completionCount: 0,
+ exploringCount: 0,
+ plans: [],
+ earlyBirdEndsAt: null,
+ canFreeEnroll: null,
+ isFreeEnrolled: false,
+ freeLessonCount: 3,
+ journeyMapAvailable: true,
+ hasFullAccess: true,
+ isPaidEnrolled: true,
+ canPurchase: null,
+ },
+ };
+}
+
+function makeConfirmSuccess(
+ overrides: Partial = {},
+): { content: CoursePaymentConfirmResponse } {
+ return {
+ content: {
+ paymentId: 1,
+ courseId: COURSE_ID,
+ planId: null,
+ planCode: 'ALL_IN_ONE',
+ amount: 99000,
+ status: 'SUCCESS',
+ paymentMethod: 'CARD',
+ paidAt: '2026-05-18T09:00:00.000Z',
+ tossReceiptUrl: null,
+ virtualAccountNumber: null,
+ virtualAccountDueDate: null,
+ virtualAccountHolderName: null,
+ ...overrides,
+ },
+ };
+}
+
+function makeConfirmVirtualAccount(): {
+ content: CoursePaymentConfirmResponse;
+} {
+ return makeConfirmSuccess({
+ status: 'WAITING_FOR_DEPOSIT',
+ paymentMethod: 'VIRTUAL_ACCOUNT',
+ virtualAccountNumber: '1234567890',
+ virtualAccountDueDate: '2026-05-19T23:59:59.000Z',
+ virtualAccountHolderName: '(주)제로원',
+ });
+}
+
+// ─── Route Mock Helpers ───────────────────────────────────────────────────────
+
+async function mockCourseDetailApi(page: Page) {
+ await page.route(/\/courses\/vibe-intro$/, async (route) => {
+ await route.fulfill({ json: makeCourseDetail() });
+ });
+}
+
+async function mockConfirmApi(page: Page, json: object, status = 200) {
+ await page.route(/\/courses\/\d+\/payments\/toss\/confirm/, async (route) => {
+ if (route.request().method() === 'POST') {
+ await route.fulfill({ status, json });
+ } else {
+ await route.continue();
+ }
+ });
+}
+
+// waitForResponse handlers must be registered before goto to catch cascaded
+// requests: courseId resolves from detail response, then confirm fires.
+async function gotoSuccessPage(page: Page) {
+ await Promise.all([
+ page.waitForResponse((r) => /\/courses\/vibe-intro$/.test(r.url())),
+ page.waitForResponse((r) => r.url().includes('/payments/toss/confirm')),
+ page.goto(SUCCESS_PATH, { waitUntil: 'load' }),
+ ]);
+}
+
+// ─── Chunk 1: CARD 결제 성공 @auth ───────────────────────────────────────────
+// payment.spec.ts Chunk 6 already covers: basic heading, amount, CTA links,
+// error state text + navigation. Tests here cover unchecked specifics only.
+
+test.describe('결제 성공 — CARD @auth', () => {
+ test('결제 수단 "신용카드" + 금액 "99,000원" 표시', async ({ page }) => {
+ await mockCourseDetailApi(page);
+ await mockConfirmApi(page, makeConfirmSuccess());
+ await gotoSuccessPage(page);
+
+ await expect(page.getByText('신용카드')).toBeVisible({ timeout: 10000 });
+ await expect(page.getByText('99,000원').first()).toBeVisible();
+ });
+
+ test('tossReceiptUrl 있을 때 "영수증 보기" 링크 표시', async ({ page }) => {
+ await mockCourseDetailApi(page);
+ await mockConfirmApi(
+ page,
+ makeConfirmSuccess({ tossReceiptUrl: 'https://receipt.toss.im/test' }),
+ );
+ await gotoSuccessPage(page);
+
+ const receiptLink = page.getByRole('link', { name: '영수증 보기' });
+ await expect(receiptLink).toBeVisible({ timeout: 10000 });
+ await expect(receiptLink).toHaveAttribute(
+ 'href',
+ 'https://receipt.toss.im/test',
+ );
+ });
+});
+
+// ─── Chunk 2: 가상계좌 입금 대기 @auth ───────────────────────────────────────
+
+test.describe('결제 성공 — 가상계좌 입금 대기 @auth', () => {
+ test.beforeEach(async ({ page }) => {
+ await mockCourseDetailApi(page);
+ await mockConfirmApi(page, makeConfirmVirtualAccount());
+ });
+
+ test('입금 대기 화면 — 계좌번호·금액 표시', async ({ page }) => {
+ await gotoSuccessPage(page);
+
+ await expect(page.getByText('입금 대기 중입니다.')).toBeVisible({
+ timeout: 10000,
+ });
+ await expect(page.getByText('1234567890')).toBeVisible();
+ await expect(page.getByText('99,000원')).toBeVisible();
+ });
+
+ test('"결제 관리로 가기" 링크 → /class-payment-management', async ({
+ page,
+ }) => {
+ await gotoSuccessPage(page);
+ await expect(page.getByText('입금 대기 중입니다.')).toBeVisible({
+ timeout: 10000,
+ });
+
+ const link = page.getByRole('link', { name: '결제 관리로 가기' });
+ await expect(link).toBeVisible();
+ await expect(link).toHaveAttribute('href', '/class-payment-management');
+ });
+
+ test('"결제 취소" 클릭 → cancel API 호출 → /class/vibe-intro/home 이동', async ({
+ page,
+ }) => {
+ // POST /courses/{courseId}/payments/{paymentId}/cancel
+ await page.route(/\/courses\/\d+\/payments\/\d+\/cancel/, async (route) => {
+ if (route.request().method() === 'POST') {
+ await route.fulfill({ status: 200, json: {} });
+ } else {
+ await route.continue();
+ }
+ });
+
+ await gotoSuccessPage(page);
+ await expect(page.getByText('입금 대기 중입니다.')).toBeVisible({
+ timeout: 10000,
+ });
+
+ const [cancelResponse] = await Promise.all([
+ page.waitForResponse(
+ (r) => r.url().includes('/cancel') && r.request().method() === 'POST',
+ ),
+ page.getByRole('button', { name: '결제 취소' }).click(),
+ ]);
+
+ expect(cancelResponse.status()).toBe(200);
+ await page.waitForURL('**/class/vibe-intro/home', { timeout: 5000 });
+ expect(page.url()).toContain('/class/vibe-intro/home');
+ });
+});
+
+// ─── Chunk 3: PAY202 — 이미 완료된 결제 @auth ─────────────────────────────────
+// isApiError() duck-type guard requires all four fields:
+// statusCode, errorCode, errorName, message.
+
+test.describe('결제 성공 — PAY202 (이미 완료된 결제) @auth', () => {
+ test.beforeEach(async ({ page }) => {
+ await mockCourseDetailApi(page);
+ await mockConfirmApi(
+ page,
+ {
+ statusCode: 400,
+ errorCode: 'PAY202',
+ errorName: 'DuplicatePayment',
+ message: '이미 완료된 결제입니다.',
+ },
+ 400,
+ );
+ });
+
+ test('"이미 완료된 결제입니다." 텍스트 표시', async ({ page }) => {
+ await gotoSuccessPage(page);
+
+ await expect(page.getByText('이미 완료된 결제입니다.')).toBeVisible({
+ timeout: 10000,
+ });
+ });
+
+ test('"학습 홈으로 이동" 버튼 → /class/vibe-intro/home', async ({ page }) => {
+ await gotoSuccessPage(page);
+ await expect(
+ page.getByRole('button', { name: '학습 홈으로 이동' }),
+ ).toBeVisible({ timeout: 10000 });
+
+ await page.getByRole('button', { name: '학습 홈으로 이동' }).click();
+ await page.waitForURL('**/class/vibe-intro/home', { timeout: 5000 });
+ expect(page.url()).toContain('/class/vibe-intro/home');
+ });
+});
diff --git a/e2e/group-study/create.spec.ts b/e2e/group-study/create.spec.ts
index aac471068..b16813a88 100644
--- a/e2e/group-study/create.spec.ts
+++ b/e2e/group-study/create.spec.ts
@@ -11,6 +11,8 @@ import {
} from '../support/study-helpers';
const AUTH_FILE = 'e2e/fixtures/auth.json';
+const REMOTE_MUTATION_SKIP_REASON =
+ 'Remote staging creation mutates shared data and currently does not emit a stable POST response; keep this as a local/auth smoke flow outside release CI.';
interface AuthCookie {
name: string;
@@ -69,7 +71,12 @@ test.describe('그룹스터디 개설 @auth', () => {
}
});
- test('3단계 위저드 전체 플로우 — 제출 성공', async ({ page }) => {
+ test('3단계 위저드 전체 플로우 — 제출 성공', async ({ page, baseURL }) => {
+ test.skip(
+ Boolean(baseURL && !baseURL.startsWith('http://localhost')),
+ REMOTE_MUTATION_SKIP_REASON,
+ );
+
await openCreateModal(page);
await fillStep1(page, 'PROJECT');
await page.getByRole('button', { name: '다음' }).click();
@@ -132,7 +139,12 @@ test.describe('멘토스터디 개설 @auth', () => {
}
});
- test('PREMIUM_STUDY 가격 필드 포함 전체 제출', async ({ page }) => {
+ test('PREMIUM_STUDY 가격 필드 포함 전체 제출', async ({ page, baseURL }) => {
+ test.skip(
+ Boolean(baseURL && !baseURL.startsWith('http://localhost')),
+ REMOTE_MUTATION_SKIP_REASON,
+ );
+
await openPremiumStudyModal(page);
await fillStep1(page);
diff --git a/e2e/payment/cancel.spec.ts b/e2e/payment/cancel.spec.ts
new file mode 100644
index 000000000..4df5d06b6
--- /dev/null
+++ b/e2e/payment/cancel.spec.ts
@@ -0,0 +1,221 @@
+import { test, expect, type Page } from '@playwright/test';
+import { existsSync, readFileSync } from 'fs';
+import type { UserTransactionListResponse } from '../../src/api/openapi/models';
+
+// ─── Constants ────────────────────────────────────────────────────────────────
+
+const AUTH_FILE = 'e2e/fixtures/auth.json';
+const PAYMENT_MANAGEMENT_PATH = '/payment-management';
+const PAYMENT_ID = 200;
+
+test.skip(
+ ({ baseURL }) => !baseURL?.startsWith('http://localhost'),
+ 'Mocked payment-management specs require local dev server auth; remote staging redirects protected routes before page route mocks can apply.',
+);
+
+// ─── Localhost auth cookie injection ─────────────────────────────────────────
+
+test.beforeEach(async ({ context, baseURL }) => {
+ if (baseURL?.startsWith('http://localhost') && existsSync(AUTH_FILE)) {
+ const { cookies } = JSON.parse(readFileSync(AUTH_FILE, 'utf-8')) as {
+ cookies: {
+ name: string;
+ value: string;
+ domain: string;
+ path: string;
+ expires: number;
+ httpOnly: boolean;
+ secure: boolean;
+ sameSite: 'Strict' | 'Lax' | 'None';
+ }[];
+ };
+ await context.addCookies(
+ cookies.map((c) => ({ ...c, domain: 'localhost', secure: false })),
+ );
+ }
+});
+
+// ─── Mock Factories ───────────────────────────────────────────────────────────
+
+function makeTransaction(
+ overrides: Partial = {},
+): UserTransactionListResponse {
+ return {
+ groupStudyId: 2,
+ groupStudyTitle: '바이브 스터디',
+ groupStudyStartDate: '2026-06-01T00:00:00.000Z',
+ paymentId: PAYMENT_ID,
+ paymentCode: 'PAY-TEST-002',
+ latestTransactionType: 'PAYMENT_REQUESTED',
+ latestTransactionTypeDisplayName: '결제대기',
+ latestTransactionAmount: 99000,
+ paidAt: '2026-05-18T09:00:00.000Z',
+ paymentMethod: 'CARD',
+ paymentReceiptUrl: undefined,
+ virtualAccountInfo: undefined,
+ ...overrides,
+ };
+}
+
+function makeTransactionPage(transactions: UserTransactionListResponse[]) {
+ return {
+ content: {
+ content: transactions,
+ totalElements: transactions.length,
+ totalPages: 1,
+ size: 8,
+ number: 0,
+ },
+ };
+}
+
+// ─── Route Mock Helpers ───────────────────────────────────────────────────────
+
+async function mockTransactionsApi(
+ page: Page,
+ transactions: UserTransactionListResponse[],
+) {
+ await page.route('**/api/v1/mypage/transactions**', async (route) => {
+ if (route.request().method() === 'GET') {
+ await route.fulfill({ json: makeTransactionPage(transactions) });
+ } else {
+ await route.continue();
+ }
+ });
+}
+
+// POST /api/v1/payments/{paymentId}/cancel
+async function mockCancelPaymentApi(page: Page, status = 200) {
+ await page.route(/\/payments\/\d+\/cancel$/, async (route) => {
+ if (route.request().method() === 'POST') {
+ await route.fulfill({
+ status,
+ json: { content: null },
+ });
+ } else {
+ await route.continue();
+ }
+ });
+}
+
+async function gotoPaymentManagement(page: Page) {
+ await page.goto(PAYMENT_MANAGEMENT_PATH, { waitUntil: 'load' });
+}
+
+// ─── Tests ────────────────────────────────────────────────────────────────────
+
+test.describe('결제 관리 — 결제 취소 (PAYMENT_REQUESTED) @auth', () => {
+ test.beforeEach(async ({ page }) => {
+ await mockTransactionsApi(page, [makeTransaction()]);
+ });
+
+ test('PAYMENT_REQUESTED → 스터디명·"결제대기" 배지·"결제 취소" 버튼 표시', async ({
+ page,
+ }) => {
+ await gotoPaymentManagement(page);
+
+ await expect(page.getByText('바이브 스터디').first()).toBeVisible({
+ timeout: 10000,
+ });
+ await expect(page.getByText('결제대기').first()).toBeVisible();
+ await expect(
+ page.getByRole('button', { name: '결제 취소' }).first(),
+ ).toBeVisible();
+ });
+
+ test('"결제 취소" 클릭 → 모달 열림 + 확인 메시지 표시', async ({ page }) => {
+ await gotoPaymentManagement(page);
+ await expect(
+ page.getByRole('button', { name: '결제 취소' }).first(),
+ ).toBeVisible({ timeout: 10000 });
+
+ await page.getByRole('button', { name: '결제 취소' }).first().click();
+
+ await expect(
+ page.getByText('해당 스터디의 결제를 취소하시겠습니까?'),
+ ).toBeVisible({ timeout: 5000 });
+ });
+
+ test('"결제 취소" confirm → POST /payments/{id}/cancel → "결제가 취소되었습니다." 토스트', async ({
+ page,
+ }) => {
+ await mockCancelPaymentApi(page);
+ await gotoPaymentManagement(page);
+
+ await page.getByRole('button', { name: '결제 취소' }).first().click();
+ await expect(
+ page.getByText('해당 스터디의 결제를 취소하시겠습니까?'),
+ ).toBeVisible({ timeout: 5000 });
+
+ const [cancelResponse] = await Promise.all([
+ page.waitForResponse(
+ (r) => r.url().includes('/cancel') && r.request().method() === 'POST',
+ ),
+ page
+ .getByRole('dialog')
+ .getByRole('button', { name: '결제 취소' })
+ .click(),
+ ]);
+
+ expect(cancelResponse.status()).toBe(200);
+ await expect(page.getByText('결제가 취소되었습니다.')).toBeVisible({
+ timeout: 5000,
+ });
+ });
+
+ test('"아니오" 클릭 → 모달 닫힘, cancel API 미호출', async ({ page }) => {
+ await gotoPaymentManagement(page);
+
+ await page.getByRole('button', { name: '결제 취소' }).first().click();
+ await expect(
+ page.getByText('해당 스터디의 결제를 취소하시겠습니까?'),
+ ).toBeVisible({ timeout: 5000 });
+
+ await page.getByRole('button', { name: '아니오' }).click();
+
+ await expect(
+ page.getByText('해당 스터디의 결제를 취소하시겠습니까?'),
+ ).not.toBeVisible({ timeout: 3000 });
+ });
+});
+
+test.describe('결제 관리 — 결제 취소 (PAYMENT_WAITING_FOR_DEPOSIT) @auth', () => {
+ test('PAYMENT_WAITING_FOR_DEPOSIT → "결제 취소" 버튼 표시', async ({
+ page,
+ }) => {
+ await mockTransactionsApi(page, [
+ makeTransaction({
+ latestTransactionType: 'PAYMENT_WAITING_FOR_DEPOSIT',
+ latestTransactionTypeDisplayName: '입금대기',
+ virtualAccountInfo: undefined,
+ }),
+ ]);
+ await gotoPaymentManagement(page);
+
+ await expect(page.getByText('입금대기').first()).toBeVisible({
+ timeout: 10000,
+ });
+ await expect(
+ page.getByRole('button', { name: '결제 취소' }).first(),
+ ).toBeVisible();
+ });
+});
+
+test.describe('결제 관리 — PAYMENT_CANCELED 상태 @auth', () => {
+ test('"결제취소" 배지 표시, "결제 취소" 버튼 없음', async ({ page }) => {
+ await mockTransactionsApi(page, [
+ makeTransaction({
+ latestTransactionType: 'PAYMENT_CANCELED',
+ latestTransactionTypeDisplayName: '결제취소',
+ }),
+ ]);
+ await gotoPaymentManagement(page);
+
+ await expect(page.getByText('결제취소').first()).toBeVisible({
+ timeout: 10000,
+ });
+ await expect(
+ page.getByRole('button', { name: '결제 취소' }),
+ ).not.toBeVisible();
+ });
+});
diff --git a/e2e/payment/refund.spec.ts b/e2e/payment/refund.spec.ts
new file mode 100644
index 000000000..d339b4d2f
--- /dev/null
+++ b/e2e/payment/refund.spec.ts
@@ -0,0 +1,200 @@
+import { test, expect, type Page } from '@playwright/test';
+import { existsSync, readFileSync } from 'fs';
+import type { UserTransactionListResponse } from '../../src/api/openapi/models';
+
+// ─── Constants ────────────────────────────────────────────────────────────────
+
+const AUTH_FILE = 'e2e/fixtures/auth.json';
+const PAYMENT_MANAGEMENT_PATH = '/payment-management';
+const PAYMENT_ID = 100;
+
+test.skip(
+ ({ baseURL }) => !baseURL?.startsWith('http://localhost'),
+ 'Mocked payment-management specs require local dev server auth; remote staging redirects protected routes before page route mocks can apply.',
+);
+
+// ─── Localhost auth cookie injection ─────────────────────────────────────────
+
+test.beforeEach(async ({ context, baseURL }) => {
+ if (baseURL?.startsWith('http://localhost') && existsSync(AUTH_FILE)) {
+ const { cookies } = JSON.parse(readFileSync(AUTH_FILE, 'utf-8')) as {
+ cookies: {
+ name: string;
+ value: string;
+ domain: string;
+ path: string;
+ expires: number;
+ httpOnly: boolean;
+ secure: boolean;
+ sameSite: 'Strict' | 'Lax' | 'None';
+ }[];
+ };
+ await context.addCookies(
+ cookies.map((c) => ({ ...c, domain: 'localhost', secure: false })),
+ );
+ }
+});
+
+// ─── Mock Factories ───────────────────────────────────────────────────────────
+
+function makeTransaction(
+ overrides: Partial = {},
+): UserTransactionListResponse {
+ return {
+ groupStudyId: 1,
+ groupStudyTitle: '바이브 스터디',
+ groupStudyStartDate: '2026-06-01T00:00:00.000Z',
+ paymentId: PAYMENT_ID,
+ paymentCode: 'PAY-TEST-001',
+ latestTransactionType: 'PAYMENT_SUCCESS',
+ latestTransactionTypeDisplayName: '결제완료',
+ latestTransactionAmount: 99000,
+ paidAt: '2026-05-18T09:00:00.000Z',
+ paymentMethod: 'CARD',
+ paymentReceiptUrl: undefined,
+ virtualAccountInfo: undefined,
+ ...overrides,
+ };
+}
+
+// useGetMyTransactions returns data.content (PageResponse), and the page reads
+// paymentListData?.content (the array). Mock must use double-wrapped structure.
+function makeTransactionPage(transactions: UserTransactionListResponse[]) {
+ return {
+ content: {
+ content: transactions,
+ totalElements: transactions.length,
+ totalPages: 1,
+ size: 8,
+ number: 0,
+ },
+ };
+}
+
+// ─── Route Mock Helpers ───────────────────────────────────────────────────────
+
+async function mockTransactionsApi(
+ page: Page,
+ transactions: UserTransactionListResponse[],
+) {
+ await page.route('**/api/v1/mypage/transactions**', async (route) => {
+ if (route.request().method() === 'GET') {
+ await route.fulfill({ json: makeTransactionPage(transactions) });
+ } else {
+ await route.continue();
+ }
+ });
+}
+
+// POST /api/v1/payments/{paymentId}/refunds
+async function mockRefundRequestApi(page: Page, status = 201) {
+ await page.route(/\/payments\/\d+\/refunds$/, async (route) => {
+ if (route.request().method() === 'POST') {
+ await route.fulfill({
+ status,
+ json: { content: { refundId: 1, refundCode: 'REF-001' } },
+ });
+ } else {
+ await route.continue();
+ }
+ });
+}
+
+async function gotoPaymentManagement(page: Page) {
+ await page.goto(PAYMENT_MANAGEMENT_PATH, { waitUntil: 'load' });
+}
+
+// ─── Tests ────────────────────────────────────────────────────────────────────
+
+test.describe('결제 관리 — 환불 요청 @auth', () => {
+ test.beforeEach(async ({ page }) => {
+ await mockTransactionsApi(page, [makeTransaction()]);
+ });
+
+ test('PAYMENT_SUCCESS → 스터디명·"결제완료" 배지·"환불 요청" 버튼 표시', async ({
+ page,
+ }) => {
+ await gotoPaymentManagement(page);
+
+ await expect(page.getByText('바이브 스터디').first()).toBeVisible({
+ timeout: 10000,
+ });
+ await expect(page.getByText('결제완료').first()).toBeVisible();
+ await expect(page.getByRole('button', { name: '환불 요청' })).toBeVisible();
+ });
+
+ test('"환불 요청" 클릭 → 모달 열림 + 참여 불가 경고 표시', async ({
+ page,
+ }) => {
+ await gotoPaymentManagement(page);
+ await expect(page.getByRole('button', { name: '환불 요청' })).toBeVisible({
+ timeout: 10000,
+ });
+
+ await page.getByRole('button', { name: '환불 요청' }).click();
+
+ await expect(
+ page.getByText('환불 요청 시 진행하시는 스터디에'),
+ ).toBeVisible({ timeout: 5000 });
+ await expect(page.getByText('더 이상 참여할 수 없습니다.')).toBeVisible();
+ });
+
+ test('"환불 요청하기" → POST /payments/{id}/refunds → "환불 요청이 접수되었습니다." 토스트', async ({
+ page,
+ }) => {
+ await mockRefundRequestApi(page);
+ await gotoPaymentManagement(page);
+
+ await page.getByRole('button', { name: '환불 요청' }).click();
+ await expect(
+ page.getByText('환불 요청 시 진행하시는 스터디에'),
+ ).toBeVisible({ timeout: 5000 });
+
+ const [refundResponse] = await Promise.all([
+ page.waitForResponse(
+ (r) => r.url().includes('/refunds') && r.request().method() === 'POST',
+ ),
+ page.getByRole('button', { name: '환불 요청하기' }).click(),
+ ]);
+
+ expect(refundResponse.status()).toBe(201);
+ await expect(page.getByText('환불 요청이 접수되었습니다.')).toBeVisible({
+ timeout: 5000,
+ });
+ });
+
+ test('"아니오" 클릭 → 모달 닫힘, refund API 미호출', async ({ page }) => {
+ await gotoPaymentManagement(page);
+
+ await page.getByRole('button', { name: '환불 요청' }).click();
+ await expect(
+ page.getByText('환불 요청 시 진행하시는 스터디에'),
+ ).toBeVisible({ timeout: 5000 });
+
+ await page.getByRole('button', { name: '아니오' }).click();
+
+ await expect(
+ page.getByText('환불 요청 시 진행하시는 스터디에'),
+ ).not.toBeVisible({ timeout: 3000 });
+ });
+});
+
+test.describe('결제 관리 — REFUND_REQUESTED 상태 @auth', () => {
+ test('"환불요청" 배지 표시, "환불 요청" 버튼 없음', async ({ page }) => {
+ await mockTransactionsApi(page, [
+ makeTransaction({
+ latestTransactionType: 'REFUND_REQUESTED',
+ refundId: 1,
+ refundCode: 'REF-001',
+ }),
+ ]);
+ await gotoPaymentManagement(page);
+
+ await expect(page.getByText('환불요청').first()).toBeVisible({
+ timeout: 10000,
+ });
+ await expect(
+ page.getByRole('button', { name: '환불 요청' }),
+ ).not.toBeVisible();
+ });
+});
diff --git a/e2e/support/study-helpers.ts b/e2e/support/study-helpers.ts
index 0063720ed..2b6ef850e 100644
--- a/e2e/support/study-helpers.ts
+++ b/e2e/support/study-helpers.ts
@@ -1,5 +1,54 @@
import { expect, type Page } from '@playwright/test';
+async function mockVerifiedMemberProfile(page: Page) {
+ await page.route(/\/api\/v1\/members\/\d+\/profile(?:\?.*)?$/, (route) => {
+ const url = new URL(route.request().url());
+ const memberId = Number(
+ url.pathname.match(/\/members\/(\d+)\/profile/)?.[1],
+ );
+
+ return route.fulfill({
+ json: {
+ content: {
+ memberId: Number.isFinite(memberId) ? memberId : 1,
+ autoMatching: false,
+ isVerified: true,
+ studyApplied: false,
+ memberInfo: {
+ selfIntroduction: '',
+ studyPlan: '',
+ preferredStudySubject: { studySubjectId: '1', name: '테스트' },
+ availableStudyTimes: [],
+ jobs: null,
+ career: null,
+ studyFormatTypes: null,
+ goal: '',
+ },
+ memberProfile: {
+ memberName: 'E2E 테스트',
+ tel: '01012345678',
+ nickname: 'e2e-user',
+ profileImage: { imageId: 0, resizedImages: [] },
+ simpleIntroduction: '',
+ mbti: '',
+ interests: [],
+ birthDate: '2000-01-01',
+ githubLink: undefined,
+ blogOrSnsLink: undefined,
+ techStacks: [],
+ },
+ sincerityTemp: {
+ temperature: 36.5,
+ levelId: 1,
+ levelName: '기본',
+ },
+ premiumCreator: false,
+ },
+ },
+ });
+ });
+}
+
export function addDays(days: number): string {
const d = new Date();
d.setDate(d.getDate() + days);
@@ -23,12 +72,14 @@ async function clickStudyCreateButton(page: Page) {
}
export async function openCreateModal(page: Page) {
+ await mockVerifiedMemberProfile(page);
await page.goto('/group-study', { waitUntil: 'load' });
await page.waitForSelector('nav', { timeout: 10000 });
await clickStudyCreateButton(page);
}
export async function openPremiumStudyModal(page: Page) {
+ await mockVerifiedMemberProfile(page);
await page.goto('/premium-study', { waitUntil: 'load' });
await page.waitForSelector('nav', { timeout: 10000 });
await clickStudyCreateButton(page);
diff --git a/public/class/detail/audit/node-142-3114.png b/public/class/detail/audit/node-142-3114.png
new file mode 100644
index 000000000..f1b935e97
Binary files /dev/null and b/public/class/detail/audit/node-142-3114.png differ
diff --git a/public/class/detail/audit/node-210-2643.png b/public/class/detail/audit/node-210-2643.png
new file mode 100644
index 000000000..be7447c0a
Binary files /dev/null and b/public/class/detail/audit/node-210-2643.png differ
diff --git a/public/class/detail/audit/node-225-4596.png b/public/class/detail/audit/node-225-4596.png
new file mode 100644
index 000000000..0d4377c47
Binary files /dev/null and b/public/class/detail/audit/node-225-4596.png differ
diff --git a/public/class/detail/audit/node-306-2830.png b/public/class/detail/audit/node-306-2830.png
new file mode 100644
index 000000000..57ffb1957
Binary files /dev/null and b/public/class/detail/audit/node-306-2830.png differ
diff --git a/public/class/detail/audit/node-33-1925.png b/public/class/detail/audit/node-33-1925.png
new file mode 100644
index 000000000..0661cd401
Binary files /dev/null and b/public/class/detail/audit/node-33-1925.png differ
diff --git a/public/class/detail/audit/node-771-23642.png b/public/class/detail/audit/node-771-23642.png
new file mode 100644
index 000000000..36429c103
Binary files /dev/null and b/public/class/detail/audit/node-771-23642.png differ
diff --git a/public/class/detail/audit/node-865-33747.png b/public/class/detail/audit/node-865-33747.png
new file mode 100644
index 000000000..46447968f
Binary files /dev/null and b/public/class/detail/audit/node-865-33747.png differ
diff --git a/public/class/detail/audit/node-865-34214.png b/public/class/detail/audit/node-865-34214.png
new file mode 100644
index 000000000..9dfec2738
Binary files /dev/null and b/public/class/detail/audit/node-865-34214.png differ
diff --git a/public/class/detail/class-detail-figma-ref.png b/public/class/detail/class-detail-figma-ref.png
new file mode 100644
index 000000000..2a27a4572
Binary files /dev/null and b/public/class/detail/class-detail-figma-ref.png differ
diff --git a/public/class/vibe-intro/feed-more-menu.png b/public/class/vibe-intro/feed-more-menu.png
new file mode 100644
index 000000000..dc7b67d85
Binary files /dev/null and b/public/class/vibe-intro/feed-more-menu.png differ
diff --git a/public/class/vibe-intro/learning-map-modal-ref.png b/public/class/vibe-intro/learning-map-modal-ref.png
new file mode 100644
index 000000000..63196191f
Binary files /dev/null and b/public/class/vibe-intro/learning-map-modal-ref.png differ
diff --git a/scripts/release/generate-backend-prod-release-record.mjs b/scripts/release/generate-backend-prod-release-record.mjs
index f5634d357..15d0d36b9 100644
--- a/scripts/release/generate-backend-prod-release-record.mjs
+++ b/scripts/release/generate-backend-prod-release-record.mjs
@@ -16,7 +16,7 @@ const DEPLOY_ORDER = [
'frontend',
'e2e_check',
];
-const RELEASE_ID = /^prod-\d{8}-\d{4}$/;
+const RELEASE_ID = /^prod-\d{8}-\d{4}(?:\d{2})?$/;
const VERSION = /^v\d+\.\d+\.\d+$/;
const DATE_IN_TAG = /:.*\d{8}|:.*\d{4}-\d{2}-\d{2}/;
const POINTER_TAG = /:(prod|latest-prod)$/;
diff --git a/scripts/release/resolve-prod-release-intent.mjs b/scripts/release/resolve-prod-release-intent.mjs
index 30c7be530..7fc3834ec 100644
--- a/scripts/release/resolve-prod-release-intent.mjs
+++ b/scripts/release/resolve-prod-release-intent.mjs
@@ -325,11 +325,12 @@ const releaseId = `prod-${new Intl.DateTimeFormat('sv-SE', {
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
+ second: '2-digit',
hour12: false,
})
.format(new Date())
.replace(/[-: ]/g, '')
- .replace(/(\d{8})(\d{4})/, '$1-$2')}`;
+ .replace(/(\d{8})(\d{6})/, '$1-$2')}`;
const deployedAt = new Intl.DateTimeFormat('sv-SE', {
timeZone: 'Asia/Seoul',
year: 'numeric',
diff --git a/scripts/release/validate-release-record.mjs b/scripts/release/validate-release-record.mjs
index 31e5796a6..02dbbda01 100644
--- a/scripts/release/validate-release-record.mjs
+++ b/scripts/release/validate-release-record.mjs
@@ -2,7 +2,7 @@
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
import { join } from 'node:path';
-const RELEASE_ID = /^prod-\d{8}-\d{4}$/;
+const RELEASE_ID = /^prod-\d{8}-\d{4}(?:\d{2})?$/;
const DATE_IN_TAG = /:.*\d{8}|:.*\d{4}-\d{2}-\d{2}/;
const POINTER_TAG = /:(prod|latest-prod)$/;
const FRONTEND_IMAGE =
diff --git a/skills_context/SHARED/zeroone-version-management.md b/skills_context/SHARED/zeroone-version-management.md
new file mode 100644
index 000000000..31cc9dca8
--- /dev/null
+++ b/skills_context/SHARED/zeroone-version-management.md
@@ -0,0 +1,60 @@
+# ZERO-ONE Version Management Agent Skill SSOT
+
+Use this skill when working on production release records, release intent labels/body, `main` production deployment workflow, rollback metadata, or `releases/prod-*.yaml` in `study-platform-client`.
+
+This file is the shared skill source of truth. Codex and Claude wrappers must stay thin and point here instead of duplicating the workflow.
+
+## Required reading order
+
+1. `docs/ops/onboarding.md` - human onboarding for operators who do not know the deployment system yet.
+2. `docs/ops/version-management.md` - human-facing production deployment, release intent, backend dispatch, checklist, and rollback guide.
+3. `docs/ops/release-record-shared-contract.md` - FE/BE shared payload and final record contract.
+4. Relevant scripts/workflows only after the docs above:
+ - `.github/workflows/deploy-prod.yml`
+ - `.github/workflows/release-record-check.yml`
+ - `scripts/release/resolve-prod-release-intent.mjs`
+ - `scripts/release/generate-prod-release-record.mjs`
+ - `scripts/release/generate-backend-prod-release-record.mjs`
+ - `scripts/release/validate-release-record.mjs`
+ - `docs/ops/onboarding.md`
+ - `docs/ops/version-management.md`
+ - `docs/ops/release-record-shared-contract.md`
+
+## Non-negotiable rules
+
+- This skill applies to `main` production releases only. Do not change `develop` deployment behavior unless the user explicitly asks.
+- `docs/ops/release-record-shared-contract.md` is the shared FE/BE contract; `releases/` is the frontend repository source of truth for successful production FE/BE/DB/rollback combinations.
+- Frontend repo owns only the frontend version-management rule. Do not add the backend repository rule here.
+- Production version metadata comes from PR intent or backend dispatch payload, not per-release repository variables.
+- Exactly one release intent is allowed: `release:major`, `release:minor`, or `release:patch`. Use `N/A` for no DB migration version.
+- If multiple `release:*` labels are present, or label intent conflicts with body `release`, fail instead of guessing.
+- First recorded frontend production release requires explicit bootstrap approval metadata in the PR body: `bootstrap: approved`, `base_version` or `version`, backend image/commit/version, and rollback frontend/backend fixed image tags.
+- `prod` and `latest-prod` are pointer tags only. They are never valid rollback targets and must fail if used as inherited or supplied backend/rollback images.
+- Image dates belong in `release_id`, not image tags.
+
+## Implementation workflow for agents
+
+1. Inspect current branch and changed files.
+2. Read the docs in the required reading order.
+3. For workflow/script changes, add deterministic checks for:
+ - first-release bootstrap without accidental defaulting,
+ - duplicate release label failure,
+ - pointer-tag rejection,
+ - docs/examples matching supported script keys,
+ - backend dispatch schema validation,
+ - duplicate `metadata.backend_deploy_id` rejection.
+4. Validate with targeted commands first:
+ - `node --check scripts/release/resolve-prod-release-intent.mjs`
+ - local resolver smoke cases for bootstrap, duplicate labels, pointer tags, and normal latest-release inheritance.
+ - `node scripts/release/validate-release-record.mjs releases`
+5. Then run repository checks required by the project for the changed scope.
+
+## Human handoff format
+
+Report:
+
+- changed files,
+- what happens on `main` merge,
+- what the PR author must put in labels/body,
+- verification commands and results,
+- any remaining manual setup such as optional `PROD_E2E_BASE_URL`.
diff --git a/src/api/client/axios.ts b/src/api/client/axios.ts
index 6d66fd429..5e8f9f97d 100644
--- a/src/api/client/axios.ts
+++ b/src/api/client/axios.ts
@@ -57,11 +57,28 @@ export const axiosInstanceV5 = axios.create({
withCredentials: true,
});
+export const axiosInstanceForMultipartV5 = axios.create({
+ baseURL: `${process.env.NEXT_PUBLIC_API_BASE_URL}/api/v5/`,
+ timeout: 60000,
+ headers: {},
+ withCredentials: true,
+});
+
attachApiLogger(axiosInstanceV5, 'client-v5-json');
+attachApiLogger(axiosInstanceForMultipartV5, 'client-v5-multipart');
axiosInstanceV5.interceptors.request.use(attachAccessTokenToRequest);
+axiosInstanceForMultipartV5.interceptors.request.use(
+ attachAccessTokenToRequest,
+);
axiosInstanceV5.interceptors.response.use(
(config) => config,
createClientAuthResponseRejectedHandler((requestConfig) =>
axiosInstanceV5(requestConfig),
),
);
+axiosInstanceForMultipartV5.interceptors.response.use(
+ (config) => config,
+ createClientAuthResponseRejectedHandler((requestConfig) =>
+ axiosInstanceForMultipartV5(requestConfig),
+ ),
+);
diff --git a/src/app/(admin)/admin/alerttalk/delivery-logs/[jobId]/page.tsx b/src/app/(admin)/admin/alerttalk/delivery-logs/[jobId]/page.tsx
new file mode 100644
index 000000000..d3a6b8736
--- /dev/null
+++ b/src/app/(admin)/admin/alerttalk/delivery-logs/[jobId]/page.tsx
@@ -0,0 +1,17 @@
+import { notFound } from 'next/navigation';
+import { AdminAlerttalkDeliveryLogDetailPageClient } from '@/features/admin/alerttalk/ui/admin-alerttalk-pages';
+
+export default async function AdminAlerttalkDeliveryLogDetailPage({
+ params,
+}: {
+ params: Promise<{ jobId: string }>;
+}) {
+ const { jobId } = await params;
+ const parsedJobId = Number(jobId);
+
+ if (!Number.isInteger(parsedJobId) || parsedJobId <= 0) {
+ notFound();
+ }
+
+ return ;
+}
diff --git a/src/app/(admin)/admin/alerttalk/delivery-logs/page.tsx b/src/app/(admin)/admin/alerttalk/delivery-logs/page.tsx
new file mode 100644
index 000000000..648d459ee
--- /dev/null
+++ b/src/app/(admin)/admin/alerttalk/delivery-logs/page.tsx
@@ -0,0 +1,5 @@
+import { AdminAlerttalkDeliveryLogListPageClient } from '@/features/admin/alerttalk/ui/admin-alerttalk-pages';
+
+export default function AdminAlerttalkDeliveryLogsPage() {
+ return ;
+}
diff --git a/src/app/(admin)/admin/alerttalk/schedules/dry-run/page.tsx b/src/app/(admin)/admin/alerttalk/schedules/dry-run/page.tsx
new file mode 100644
index 000000000..4056a3823
--- /dev/null
+++ b/src/app/(admin)/admin/alerttalk/schedules/dry-run/page.tsx
@@ -0,0 +1,5 @@
+import { AdminAlerttalkDryRunPageClient } from '@/features/admin/alerttalk/ui/admin-alerttalk-pages';
+
+export default function AdminAlerttalkDryRunPage() {
+ return ;
+}
diff --git a/src/app/(admin)/admin/alerttalk/templates/page.tsx b/src/app/(admin)/admin/alerttalk/templates/page.tsx
new file mode 100644
index 000000000..15dba072b
--- /dev/null
+++ b/src/app/(admin)/admin/alerttalk/templates/page.tsx
@@ -0,0 +1,5 @@
+import { AdminAlerttalkTemplatePageClient } from '@/features/admin/alerttalk/ui/admin-alerttalk-pages';
+
+export default function AdminAlerttalkTemplatesPage() {
+ return ;
+}
diff --git a/src/app/(class-lesson)/class/[slug]/lesson/[id]/_components/lesson-review-form.tsx b/src/app/(class-lesson)/class/[slug]/lesson/[id]/_components/lesson-review-form.tsx
index 169b581e5..95805ab40 100644
--- a/src/app/(class-lesson)/class/[slug]/lesson/[id]/_components/lesson-review-form.tsx
+++ b/src/app/(class-lesson)/class/[slug]/lesson/[id]/_components/lesson-review-form.tsx
@@ -125,7 +125,6 @@ export function LessonReviewForm({
};
}, [artifactPreviewUrl]);
- // Backend requires both answers non-blank for all types; artifact required for non-quiz
const requiresArtifact = artifactSubmissionRequired && !isQuiz;
const isFormValid =
diff --git a/src/app/(class-lesson)/class/[slug]/lesson/[id]/page.tsx b/src/app/(class-lesson)/class/[slug]/lesson/[id]/page.tsx
index 815e439d4..5a4f00726 100644
--- a/src/app/(class-lesson)/class/[slug]/lesson/[id]/page.tsx
+++ b/src/app/(class-lesson)/class/[slug]/lesson/[id]/page.tsx
@@ -47,16 +47,16 @@ export default function LessonPage({
const reviewRef = useRef(null);
const contentRef = useRef(null);
const topBarRef = useRef(null);
+ const leftColRef = useRef(null);
function scrollToRef(ref: RefObject) {
- if (!ref.current) return;
- const headerHeight = topBarRef.current?.offsetHeight ?? 64;
+ if (!ref.current || !leftColRef.current) return;
+ const container = leftColRef.current;
const top =
- ref.current.getBoundingClientRect().top +
- window.scrollY -
- headerHeight -
- 16;
- window.scrollTo({ top, behavior: 'smooth' });
+ container.scrollTop +
+ ref.current.getBoundingClientRect().top -
+ container.getBoundingClientRect().top;
+ container.scrollTo({ top, behavior: 'smooth' });
}
function handleTabChange(next: LessonTabValue) {
@@ -200,93 +200,101 @@ export default function LessonPage({
-
+
{/* LEFT */}
-
-
-
-
- 학습 여정 맵 돌아가기
-
-
-
-
-
-
- Lesson {String(lessonId).padStart(2, '0')}
+
+ {/* Fixed header: back link, title, description, tabs — never scrolls */}
+