Skip to content

[TMT-272] feat: 세션·토큰 발급과 X-User-Id 스텁 제거 - #76

Open
creatived-toychip wants to merge 8 commits into
feat/TMT-271from
feat/TMT-272
Open

[TMT-272] feat: 세션·토큰 발급과 X-User-Id 스텁 제거#76
creatived-toychip wants to merge 8 commits into
feat/TMT-271from
feat/TMT-272

Conversation

@creatived-toychip

@creatived-toychip creatived-toychip commented Aug 31, 2026

Copy link
Copy Markdown

Related Issue

Why

@UserId가 X-User-Id 헤더 값을 검증 없이 신뢰한다(TMT-150). 누구나 남의 id로 요청할 수 있는
상태라 실사용자 런칭(9/12) 전에 반드시 교체돼야 한다. 카카오 로그인(TMT-271)이 들어왔으므로
로그인 성공 시 토큰을 발급하고, 스텁을 걷어낸다.

What

⚠️ Breaking — 계약 변경 이력에 기록 후 FE pnpm api:sync 필요. FE 반영 없이 배포되면 모든 인증 API가 401이다.

  • 로그인 응답에 accessToken · accessTokenExpiresIn(초) · refreshToken 추가.
    "응답의 userId를 X-User-Id로 쓴다"는 과도기 계약 폐기
  • POST /v1/auth/token/refresh 신설 — refresh로 새 토큰 쌍 발급
  • 인증 필터(AuthTokenFilter) 도입 — Authorization: Bearer 검증, 실패 시 401.
    만료는 AUTH_TOKEN_EXPIRED(→ FE는 재발급), 그 외는 AUTH_TOKEN_INVALID(→ 재로그인)로 구분
  • UserIdArgumentResolver를 인증 주체(필터가 실은 요청 속성)에서 읽도록 교체.
    @UserId Long?(선택) 동작 유지, 컨트롤러 61곳 무수정
  • OpenAPI에서 X-User-Id 파라미터 제거(UserIdHeaderCustomizer 삭제) → bearerAuth 보안 스킴으로 교체.
    Swagger Authorize 버튼에 accessToken을 넣으면 인증 API 테스트 가능
  • ErrorCode 추가: AUTH_TOKEN_INVALID · AUTH_TOKEN_EXPIRED (기존 이름 변경 없음)
  • 설정 tmt.auth.token.* — 서명 키는 env JWT_SECRET(32바이트 이상), 운영은 SSM /tmt-prod/auth/* 예정.
    prod는 기본값 없음(누락 시 기동 실패), local은 개발 기본값
  • 테스트: 신규 27개(코덱 7 · 필터 6 · OpenAPI 3 · 컨트롤러 7 · 리졸버 4), 기존 테스트의
    X-User-Id 사용 81곳을 요청 속성 방식으로 일괄 마이그레이션

의존성 추가 (production): jjwt 0.12.6 (api / impl / jackson). JWT 파싱·검증의 보안 엣지 케이스를
직접 구현하지 않기 위해 추가했다. jjwt-jackson이 Jackson 2를 전이로 끌고 오지만 레포의
Jackson 3(tools.jackson)과 패키지가 달라 충돌 없이 공존한다.

How

  • 토큰 정책: HS256, access 1시간 / refresh 30일, use 클레임으로 access·refresh 교차 사용 차단
  • stateless — 저장소 없이 서명 검증만으로 동작. refresh는 만료 전 서버 무효화 불가(로그아웃 =
    클라이언트 삭제)라는 트레이드오프가 있고, 강제 무효화가 필요해지면 그때 저장소를 붙인다
  • 필터는 헤더가 없으면 통과시킨다 — 필수/선택 판단을 리졸버에 남겨 @UserId Long? 계약을 지킨다.
    /v1/auth 하위(로그인·재발급)는 검증하지 않는다
  • 필터 순서: RequestIdFilter(HIGHEST_PRECEDENCE) → AuthTokenFilter — 필터가 직접 쓰는
    401 본문(ProblemDetail 동일 포맷)에 requestId가 실리도록
  • 서명 키 미설정 시 기동 실패 — AddressIdTokenCodec(TMT-191)과 같은 정책
  • 시드 사용자(V4, id 1~4)는 카카오 계정이 없어 더 이상 로그인 불가 — mock 정리(TMT-274) 때 함께 걷어낸다

Prompt Log

  • 274(마이페이지)보다 토큰을 먼저 하자는 방향 제안 → Breaking 리드타임 근거로 272 선행 확정
  • 설계 결정 문답: JWT 라이브러리(jjwt vs 직접 구현 vs nimbus — production 의존성 규칙에 따라 보고),
    refresh 저장 방식(stateless vs DB 저장) → jjwt + stateless 선택, 만료 정책은 기본값(1h/30d) 수용
  • 구현 지시 → 기존 패턴(AddressIdTokenCodec의 키 정책, RequestIdFilter, 커스텀 리졸버) 위에 구현,
    ktlint 파싱 실패(KDoc 안 /v1/auth/**의 중첩 주석) 등 빌드 이슈 수정 후 전체 그린 확인
  • 레이어별 커밋 5개 분리

@creatived-toychip creatived-toychip changed the title Feat/tmt 272 [TMT-272] feat: 세션·토큰 발급과 X-User-Id 스텁 제거 Aug 31, 2026
token:
# 기본값을 두지 않아 누락 시 기동 단계에서 바로 실패한다 — 토큰 서명 키가 없으면
# 인증 전체가 무의미해진다 (TMT-272)
secret: ${JWT_SECRET}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[must] 여기가 #75보다 위험합니다 — 키가 없으면 배포가 서비스를 내립니다.

prod에 기본값을 안 둔 판단 자체는 맞습니다(ADDRESS_TOKEN_SECRET과 같은 정책). 문제는 배포 스크립트가 그걸 모른다는 것입니다.

cicd-release.yml의 순서가 이렇습니다.

  1. SSM에서 값 읽기 → JWT_SECRET읽는 코드 자체가 없음
  2. 필수값 검사 루프 → JWT_SECRET이 목록에 없어 통과
  3. docker compose up -d --force-recreate기존 컨테이너를 먼저 내림
  4. 새 컨테이너가 JWT_SECRET 없어 기동 실패
  5. 헬스체크 3분 실패 → 워크플로 실패. 그때는 이미 서비스가 내려가 있음

빈 값이면 로그인만 죽는 #75와 달리, 이건 앱 전체가 안 뜹니다. 어제 juso 키에서 같은 구조를 발견해 필수값 검사 루프에 넣어뒀는데(PR #52), JWT_SECRET도 같은 자리에 들어가야 합니다.

필요한 것 세 가지입니다.

  • cicd-release.yml/tmt-prod/auth/* 읽기 추가 (카카오 키 2종 + JWT_SECRET)
  • 같은 파일 필수값 검사 루프에 JWT_SECRET 추가 — 이게 있어야 4번 전에 끊깁니다
  • infra/terraform/iam.tfssm:GetParameter 범위에 auth/* 추가

#75에도 같은 코멘트를 남겼습니다. 두 PR이 같은 작업을 필요로 하니 후속 티켓 하나로 묶고 에픽 TMT-270에 릴리즈 차단 항목으로 달아두는 것이 어떨까요.

) : OncePerRequestFilter() {
private val mapper = JsonMapper.builder().build()

override fun shouldNotFilter(request: HttpServletRequest): Boolean =

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[q] /v1/auth/ 접두 전체를 건너뜁니다.

지금은 로그인·재발급 둘뿐이라 맞습니다. 그리고 확인해보니 실패해도 안전한 방향입니다 — 나중에 /v1/auth/logout 같은 걸 추가해도 속성이 안 실려서 @UserId Long이 401을 던지지, 인증이 우회되지는 않습니다.

다만 그때 원인을 찾기가 어렵습니다(필터를 의심하기 전에 토큰을 의심하게 되죠). 경로 화이트리스트를 정확히 두는 편이 어떨까요 —

private val PUBLIC_PATHS = setOf("/v1/auth/login/kakao", "/v1/auth/token/refresh")

지금 형태를 유지하신다면 KDoc에 "이 접두 아래는 전부 인증 없이 통과한다"를 한 줄 더 굵게 남겨주시면 좋겠습니다.

}

val token = header.removePrefix(BEARER_PREFIX)
if (token == header) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[want] Bearer 대소문자를 가립니다.

removePrefix("Bearer ")bearer eyJ...로 오면 접두가 안 떨어져 token == header가 되고 401입니다. RFC 7235에서 auth scheme은 대소문자를 구분하지 않습니다.

대부분의 클라이언트가 Bearer로 보내니 당장 문제는 아닌데, FE가 라이브러리를 바꾸거나 외부 도구로 테스트할 때 원인 찾기 어려운 401이 됩니다. header.regionMatches(0, BEARER_PREFIX, 0, 7, ignoreCase = true) 정도로 풀 수 있습니다.

fun refreshToken(
@Valid @RequestBody request: TokenRefreshRequest,
): TokenRefreshResponse {
val userId = tokenCodec.parseUserId(request.refreshToken, TokenUse.REFRESH)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[q] refresh 30일 + stateless라, 토큰이 탈취되면 30일간 막을 방법이 없습니다.

트레이드오프를 PR 본문에 적어주신 건 봤고 저장소를 안 붙인 판단도 이해합니다. 다만 그 전제에서 30일이 좀 긴 것 같습니다. 로그아웃도 클라이언트 삭제뿐이라, 기기를 잃어버린 사용자가 할 수 있는 게 없습니다.

런칭 초기에는 7일 정도로 줄여두고, 저장소를 붙일 때 늘리는 건 어떨까요. 설정값이라 나중에 늘리는 건 쉽고 줄이는 건 사용자가 재로그인해야 해서 비쌉니다.

[want] 그리고 재발급이 사용자 존재를 확인하지 않습니다. 서명만 맞으면 새 토큰이 나갑니다. 지금은 탈퇴 기능이 없어 문제가 안 되는데, 생기면 여기가 구멍이 됩니다. 지금 고치자는 건 아니고 그때 놓치지 않게 주석 한 줄이면 좋겠습니다.

@mingdodev mingdodev left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

X-User-Id를 걷어내는 변경이라 조심해서 봤는데, 설계가 탄탄합니다. 특히 네 가지가 좋았습니다.

  • use 클레임으로 access·refresh 교차 사용 차단 — 서명 검증 뒤에 확인하는 순서도 맞습니다
  • 필터는 헤더가 없으면 통과시키고 필수 여부는 리졸버에 남긴 것@UserId Long? 계약이 그대로 유지되고 컨트롤러 61곳이 안 바뀝니다
  • 필터 순서를 RequestIdFilter 다음으로 둬서 401 본문에도 requestId가 실리는 것
  • jjwt 스코프apiimplementation, impl·jacksonruntimeOnly. production 의존성 추가를 본문에 근거와 함께 보고해주신 것도 규칙대로입니다

X-User-Id 잔재도 확인했습니다 — 남은 건 곧 삭제될 mock 주석과 "헤더를 더 이상 해석하지 않는다"를 검증하는 테스트뿐입니다.

[must] 이 PR은 CI가 한 번도 안 돌았습니다

basefeat/TMT-271인데 ci-pull-request.ymlbranches: [main]에만 걸려 있습니다. 그래서 지금 붙어 있는 체크는 리뷰어·담당자 배정 하나뿐이고, ktlint도 테스트도 실행된 적이 없습니다.

#75가 머지되면 base가 main으로 자동 재타겟되면서 그때 처음 돕니다. 그 결과를 보고 머지해주세요. 32파일에 테스트 81곳을 마이그레이션한 변경이라 로컬 통과만으로 넘기기엔 범위가 큽니다.

머지 순서와 FE 공유

X-User-Id 제거는 FE가 반영하기 전에 배포되면 모든 인증 API가 401입니다. 본문에 적어주신 그대로고요.

지금은 main 자동 배포가 꺼져 있어(TMT-290) 머지 자체가 배포를 일으키지 않습니다. 그래서 머지는 편하게 하시고, 릴리즈 태그 시점에 FE pnpm api:sync와 맞추면 됩니다. 계약 변경 이력에는 Breaking으로 기록해주세요 — mock 전환 파생분은 태그 때 한 줄로 묶기로 했지만, 이건 전환과 무관한 인증 변경이라 별도로 적는 게 맞습니다.

시드 사용자

V4 시드(id 1~4)가 카카오 계정이 없어 로그인 불가라고 적어주셨는데, 맞습니다. 다만 TMT-231(시드 SQL)이 아직 미착수라 릴리즈 시점에 로그인 가능한 계정이 하나도 없을 수 있습니다. 카카오 실계정으로 들어가면 되니 막히는 건 아니지만, 시드 티켓에 "카카오 로그인 이후의 시드 계정을 어떻게 만들 것인가"가 안 적혀 있어 제가 따로 챙기겠습니다.

라인 코멘트 4개 남겼습니다. [must]는 배포 키 하나이고 나머지는 질문·제안입니다.

@mingdodev mingdodev left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

앞 리뷰의 시드 사용자 문단은 취소합니다 — 제 착오입니다.

V4__seed_mock_users.sql은 mock 기간에 사진 업로드 FK 위반을 막으려고 넣은 것(TMT-258)이고 TMT-231도 UT2용입니다. 전환 후에는 실사용자가 카카오로 들어오니 시드 계정이 필요 없습니다. "로그인 가능한 계정이 하나도 없을 수 있다"는 걱정은 성립하지 않습니다.

시드 사용자가 로그인 불가라고 적어주신 것도 그래서 문제가 아니라 정상입니다. mock 정리(TMT-274) 때 함께 걷어내는 것으로 충분합니다.

나머지 리뷰 내용은 그대로입니다.

@wnsvy607 wnsvy607 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

필터(헤더 없으면 통과)·리졸버(필수/선택 판단 유지) 분리로 컨트롤러 61곳 무수정을 지킨 설계가 깔끔하고, use 클레임 교차 차단·jjwt weak-key 강제·401 본문의 requestId 정합·기존 테스트 마이그레이션(X-User-Id 잔재 0건, grep으로 확인)까지 코드는 견고합니다. 다만 코드 밖 동반물 3건이 빠져 있어 needs changes입니다 — 특히 첫 번째는 릴리즈 시 서비스 전면 다운으로 직결됩니다.

[must] JWT_SECRET 배포 파이프라인 미동반 — application-prod.yml:26${JWT_SECRET} 기본값 없음(의도된 fail-fast)인데, cicd-release.yml은 SSM prefix가 db/media/ai/address뿐이고 필수값 가드에도 없으며 docker-compose.prod.yml environment에도 JWT가 없습니다(전부 grep으로 확인). 이대로 머지 후 릴리즈하면 구 컨테이너를 내린 뒤 새 컨테이너가 기동 실패합니다 — 워크플로 주석이 경고하는 TMT-191 시나리오 그대로예요. SSM /tmt-prod/auth/jwt-secret 등록 + iam.tf 범위 + 워크플로 fetch/가드 + compose env가 이 PR과 같이(최소한 릴리즈 전에) 움직여야 합니다. #75의 카카오 키 2종도 같은 경로라 한 번에 처리하면 좋겠습니다.

[must] Breaking 계약 미기재 — 로그인 응답 3필드 추가, 전 인증 API Bearer 요구, X-User-Id 스펙 제거는 Breaking인데 [계약] API 변경 이력에 기록이 없고, 문서 §2는 아직 "인증은 X-User-Id: 1 헤더로 대체한다"라고 안내 중입니다. 기록 → FE 공유(pnpm api:sync) → 배포 순서가 지켜져야 합니다. FE 반영 전 배포 시 전 API 401은 본문에서도 인정하신 리스크고요.

[must] CLAUDE.md의 "인증은 카카오 로그인 전까지 X-User-Id 헤더 스텁이다" 서술이 이 PR로 거짓이 됩니다. "내 변경으로 문서 서술이 코드와 달라지면 같은 변경에서 갱신" 규칙에 따라 같은 PR에서 수정 부탁드립니다.

[q] V4 시드 사용자(14·901903)는 카카오 계정이 없어 배포 즉시 로그인 불가 — TMT-274로 미룬 건 봤는데, FE가 mock 데이터로 개발 중인 기간의 릴리즈 태그 타이밍은 어떻게 조율할 계획인가요?

[q] stateless refresh라 재발급이 회전 없이 이뤄져(이전 refresh 계속 유효) 유출 시 30일간 재발급 가능하고 서버 무효화 수단이 없습니다. 트레이드오프 문서화돼 있어 결정은 존중합니다 — 런칭 전 "계정 차단" 요구가 나올 가능성만 체크해 두면 될 것 같아요. 같은 맥락에서 refresh를 JSON 바디로 내리면 FE가 웹 스토리지에 두게 되는데(XSS 표면), HttpOnly 쿠키 대안은 FE와 논의해 보셨나요?

@wnsvy607 wnsvy607 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

파이프라인 동반 커밋 확인했습니다 — SSM prefix /tmt-prod/auth, 필수값 가드 3종 추가, .env·compose 전달, iam.tf 읽기 범위, CLAUDE.md 인증 서술 갱신까지 [must] 3건 중 2건(파이프라인·CLAUDE.md)은 해소됐습니다. 공개 경로 화이트리스트·refresh 7일 축소도 좋습니다.

남은 것 두 가지라 request-changes는 유지합니다:

[must] 계약 변경 이력 미기재 — [계약] API 변경 이력 문서(v38 기준)에 TMT-271·272 항목이 아직 없습니다. #76은 Breaking(전 인증 API Bearer, 응답 필드 추가, X-User-Id 제거)이라 기록 → FE 공유 → 배포 순서가 지켜져야 하고, #75의 Additive 항목도 같이 올라가야 합니다. §2의 "인증은 X-User-Id 헤더로 대체" 안내도 같이 고쳐주세요.

[must → 릴리즈 전 필수] SSM /tmt-prod/auth/* 파라미터 3종(kakao-rest-api-key·kakao-client-secret·jwt-secret)이 아직 등록돼 있지 않습니다 (방금 describe-parameters로 확인). 지금 구조에선 등록 없이 릴리즈하면 가드가 배포를 멈추는 안전한 실패라 서비스는 안 끊기지만, 릴리즈 자체가 안 됩니다. 키를 가진 분이 등록해야 하니 — read -s로 받아 aws ssm put-parameter --type SecureString로 넣는 방식이 안전합니다(터미널 히스토리에 안 남음). iam.tf 변경은 머지 후 terraform apply 1건(in-place)이 따라옵니다.

이 두 개 끝나면 바로 approve하겠습니다.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants