Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 14 additions & 14 deletions src/api/interceptors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@

import type { AxiosError } from 'axios'

import { ROUTES } from '@/shared/constants'
import { queryClient } from '@/shared/lib/tanstack-query/queryClient'

import { apiClient } from './client'
import { ApiError } from './errors'
import { logger } from './logger'
Expand Down Expand Up @@ -97,21 +100,18 @@ export const setupInterceptors = (): void => {
// 서버에서 JSESSIONID 쿠키 기반 세션 인증을 사용하므로,
// 401 응답은 세션이 만료되었거나 유효하지 않음을 의미합니다.
if (error.response?.status === 401) {
const isAlreadyOnLogin = window.location.pathname === '/login'
const requestUrl = error.config?.url ?? ''

// 인증 상태 확인용 엔드포인트는 리다이렉트하지 않음
// 이 엔드포인트들은 호출자가 직접 401 응답을 처리함
const authProbeEndpoints = ['/api/auth/me', '/api/users/me']
const isAuthProbeRequest = authProbeEndpoints.some(
(endpoint) => requestUrl === endpoint || requestUrl.endsWith(endpoint),
)

const shouldRedirect = !isAlreadyOnLogin && !isAuthProbeRequest
const currentPath = window.location.pathname
const isAlreadyOnLogin = currentPath === ROUTES.LOGIN
const isInvitePage = currentPath.startsWith(ROUTES.INVITE_BASE)

if (shouldRedirect) {
// TODO: 로그아웃 알림 토스트 표시
window.location.href = '/login'
// 초대 페이지 또는 로그인 페이지에서는 리다이렉트하지 않음
const shouldSkipRedirect = isInvitePage || isAlreadyOnLogin
if (!shouldSkipRedirect) {
// React Query 캐시 전체 삭제 (인증 정보 포함)
// 세션이 만료되었으므로 모든 캐시된 데이터는 더 이상 유효하지 않음
queryClient.clear()
alert('로그인이 필요합니다. 로그인 페이지로 이동합니다.')
window.location.href = ROUTES.LOGIN
}
}

Expand Down
11 changes: 4 additions & 7 deletions src/features/auth/auth.api.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,7 @@
import { apiClient, type ApiResponse } from '@/api'

import { AUTH_ENDPOINTS } from './auth.endpoints'

export type CurrentUser = {
userId: number
nickname: string
profileImageUrl: string | null
needsOnboarding: boolean
}
import type { CurrentUser } from './auth.types'

export const getKakaoLoginUrl = () => {
const apiUrl = import.meta.env.VITE_API_URL
Expand All @@ -24,6 +18,9 @@ export const loginWithKakao = () => {
window.location.href = getKakaoLoginUrl()
}

/**
* 현재 로그인 사용자 정보 조회 - 인증 상태 및 온보딩 필요 여부 확인용
*/
export const fetchCurrentUser = async () => {
const { data } = await apiClient.get<ApiResponse<CurrentUser>>(AUTH_ENDPOINTS.ME)
return data.data
Expand Down
9 changes: 9 additions & 0 deletions src/features/auth/auth.types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
/**
* 현재 로그인한 사용자 정보 - 인증 상태 확인 및 온보딩 필요 여부 체크에 사용
*/
export type CurrentUser = {
userId: number
nickname: string | null
profileImageUrl: string | null
needsOnboarding: boolean
}
27 changes: 12 additions & 15 deletions src/features/auth/hooks/useAuth.ts
Original file line number Diff line number Diff line change
@@ -1,29 +1,26 @@
import { useQuery } from '@tanstack/react-query'

import { ApiError } from '@/api'

import { fetchCurrentUser } from '../auth.api'
import type { CurrentUser } from '../auth.types'
import { authQueryKeys } from './authQueryKeys'

/**
* 현재 로그인 사용자 정보를 조회하는 훅
*
* @description
* - CurrentUser 타입 반환: { userId, nickname, profileImageUrl, needsOnboarding }
* - 401 에러 시 재시도하지 않음 (로그인 페이지로 리다이렉트)
* - 5분 동안 stale 데이터로 유지
*
* @example
* ```tsx
* const { data: user, isLoading } = useAuth()
*
* if (user?.needsOnboarding) {
* return <Navigate to="/onboarding" />
* }
* ```
* - staleTime: Infinity - 수동 갱신(setQueryData) 전까지 캐시 데이터를 신선하게 유지
* - refetchOnWindowFocus: 'always' - 창 포커스 시 항상 세션 유효성 확인
*/
export function useAuth() {
return useQuery({
queryKey: ['auth', 'me'],
return useQuery<CurrentUser, ApiError>({
queryKey: authQueryKeys.me(),
queryFn: fetchCurrentUser,
retry: false, // 401 에러는 재시도하지 않음
staleTime: 5 * 60 * 1000, // 5분
retry: false,
staleTime: Infinity,
gcTime: Infinity,
refetchOnWindowFocus: 'always',
})
}
6 changes: 5 additions & 1 deletion src/features/auth/hooks/useLogout.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,19 @@
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { useNavigate } from 'react-router-dom'

import { ApiError, type ApiResponse } from '@/api'
import { ROUTES } from '@/shared/constants'

import { logout } from '../auth.api'

/**
* 로그아웃 훅 - 성공 시 전체 캐시 초기화 후 로그인 페이지로 이동
*/
export function useLogout() {
const navigate = useNavigate()
const queryClient = useQueryClient()

return useMutation({
return useMutation<ApiResponse<null>, ApiError, void>({
mutationFn: logout,
onSuccess: () => {
queryClient.clear()
Expand Down
1 change: 1 addition & 0 deletions src/features/auth/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export * from './auth.types'
export * from './components'
export * from './hooks'
51 changes: 51 additions & 0 deletions src/features/gatherings/gatherings.api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { apiClient, type ApiResponse } from '@/api'

import { GATHERINGS_ENDPOINTS } from './gatherings.endpoints'
import type {
CreateGatheringRequest,
CreateGatheringResponse,
GatheringByInviteCodeResponse,
GatheringJoinResponse,
} from './gatherings.types'

/**
* 독서모임 생성
*
* @param data - 모임 생성 요청 데이터
* @param data.gatheringName - 모임 이름 (최대 12자)
* @param data.gatheringDescription - 모임 설명 (최대 150자)
* @returns 생성된 모임 정보
*/
export const createGathering = async (data: CreateGatheringRequest) => {
const response = await apiClient.post<ApiResponse<CreateGatheringResponse>>(
GATHERINGS_ENDPOINTS.BASE,
data
)
return response.data
}

/**
* 초대 코드로 모임 정보 조회 (로그인 불필요)
*
* @param invitationCode - 초대 코드
* @returns 모임 정보
*/
export const getGatheringByInviteCode = async (invitationCode: string) => {
const response = await apiClient.get<ApiResponse<GatheringByInviteCodeResponse>>(
GATHERINGS_ENDPOINTS.JOIN_REQUEST(invitationCode)
)
return response.data
}

/**
* 모임 가입 신청 (로그인 필요)
*
* @param invitationCode - 초대 코드
* @returns 가입 신청 결과 (gatheringId, gatheringName, memberStatus)
*/
export const joinGathering = async (invitationCode: string) => {
const response = await apiClient.post<ApiResponse<GatheringJoinResponse>>(
GATHERINGS_ENDPOINTS.JOIN_REQUEST(invitationCode)
)
return response.data
}
8 changes: 8 additions & 0 deletions src/features/gatherings/gatherings.endpoints.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { API_PATHS } from '@/api'

export const GATHERINGS_ENDPOINTS = {
/** 모임 목록/생성 */
BASE: API_PATHS.GATHERINGS,
/** 초대 코드로 모임 정보 조회 / 가입 신청 */
JOIN_REQUEST: (invitationCode: string) => `${API_PATHS.GATHERINGS}/join-request/${invitationCode}`,
} as const
45 changes: 45 additions & 0 deletions src/features/gatherings/gatherings.types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
/** 모임 기본 정보 (공통) */
export interface GatheringBase {
/** 모임 이름 */
gatheringName: string
/** 모임 설명 */
gatheringDescription?: string
/** 전체 멤버 수 */
totalMembers: number
/** 모임 생성 후 경과 일수 */
daysFromCreation: number
/** 전체 약속 수 */
totalMeetings: number
/** 초대 링크 (초대 코드) */
invitationLink: string
}

/** 모임 생성 요청 */
export interface CreateGatheringRequest {
/** 모임 이름 (최대 12자) */
gatheringName: string
/** 모임 설명 (최대 150자) */
gatheringDescription?: string
}

/** 모임 생성 응답 */
export interface CreateGatheringResponse extends GatheringBase {
/** 모임 ID */
gatheringId: number
}

/** 초대 코드로 모임 정보 조회 응답 */
export type GatheringByInviteCodeResponse = GatheringBase

/** 모임 가입 상태 */
export type GatheringMemberStatus = 'PENDING' | 'ACTIVE' | 'REJECTED'

/** 모임 가입 응답 */
export interface GatheringJoinResponse {
/** 모임 ID */
gatheringId: number
/** 모임 이름 */
gatheringName: string
/** 가입 상태 */
memberStatus: GatheringMemberStatus
}
10 changes: 10 additions & 0 deletions src/features/gatherings/hooks/gatheringQueryKeys.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
/**
* 모임 관련 Query Key를 일관되게 관리하기 위한 팩토리 함수
*/
export const gatheringQueryKeys = {
all: ['gatherings'] as const,
lists: () => [...gatheringQueryKeys.all, 'list'] as const,
detail: (id: number | string) => [...gatheringQueryKeys.all, 'detail', id] as const,
byInviteCode: (invitationCode: string) =>
[...gatheringQueryKeys.all, 'invite', invitationCode] as const,
} as const
4 changes: 4 additions & 0 deletions src/features/gatherings/hooks/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export * from './gatheringQueryKeys'
export * from './useCreateGathering'
export * from './useGatheringByInviteCode'
export * from './useJoinGathering'
21 changes: 21 additions & 0 deletions src/features/gatherings/hooks/useCreateGathering.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { useMutation, useQueryClient } from '@tanstack/react-query'

import { ApiError, type ApiResponse } from '@/api'

import { createGathering } from '../gatherings.api'
import type { CreateGatheringRequest, CreateGatheringResponse } from '../gatherings.types'
import { gatheringQueryKeys } from './gatheringQueryKeys'

/**
* 모임 생성 mutation 훅
*/
export const useCreateGathering = () => {
const queryClient = useQueryClient()

return useMutation<ApiResponse<CreateGatheringResponse>, ApiError, CreateGatheringRequest>({
mutationFn: createGathering,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: gatheringQueryKeys.all })
},
})
}
20 changes: 20 additions & 0 deletions src/features/gatherings/hooks/useGatheringByInviteCode.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { useQuery } from '@tanstack/react-query'

import { ApiError, type ApiResponse } from '@/api'

import { getGatheringByInviteCode } from '../gatherings.api'
import type { GatheringByInviteCodeResponse } from '../gatherings.types'
import { gatheringQueryKeys } from './gatheringQueryKeys'

/**
* 초대 코드로 모임 정보 조회 훅
*
* @param invitationCode - 초대 코드
*/
export const useGatheringByInviteCode = (invitationCode: string | undefined) => {
return useQuery<ApiResponse<GatheringByInviteCodeResponse>, ApiError>({
queryKey: gatheringQueryKeys.byInviteCode(invitationCode ?? ''),
queryFn: () => getGatheringByInviteCode(invitationCode!),
enabled: !!invitationCode,
})
}
21 changes: 21 additions & 0 deletions src/features/gatherings/hooks/useJoinGathering.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { useMutation, useQueryClient } from '@tanstack/react-query'

import { ApiError, type ApiResponse } from '@/api'

import { joinGathering } from '../gatherings.api'
import type { GatheringJoinResponse } from '../gatherings.types'
import { gatheringQueryKeys } from './gatheringQueryKeys'

/**
* 모임 가입 신청 mutation 훅
*/
export const useJoinGathering = () => {
const queryClient = useQueryClient()

return useMutation<ApiResponse<GatheringJoinResponse>, ApiError, string>({
mutationFn: joinGathering,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: gatheringQueryKeys.all })
},
})
}
15 changes: 15 additions & 0 deletions src/features/gatherings/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
// Hooks
export * from './hooks'

// API
export * from './gatherings.api'

// Types
export type {
CreateGatheringRequest,
CreateGatheringResponse,
GatheringBase,
GatheringByInviteCodeResponse,
GatheringJoinResponse,
GatheringMemberStatus,
} from './gatherings.types'
Loading