feat(frontend): TanStack Query 기반 apis 레이어 추가

- auth/negotiation 도메인별 api/keys/queries/mutations/type 5파일 구조
- axios 인스턴스: access token 주입, 434 만료 시 refresh 자동 재발급(단일 비행)
  후 원요청 재시도, 433/435/1203(TOKEN_REVOKED) 시 세션 종료 처리
- HTTP 200 + result.success=false 비즈니스 에러를 ApiError 로 변환,
  에러코드별 한국어 메시지 맵(getApiErrorMessage) 제공
- 토큰 localStorage 저장소(tokenStorage)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
민헌 2026-06-18 13:12:07 +09:00
parent 2480a47efe
commit a867daa98f
16 changed files with 629 additions and 0 deletions

View File

@ -0,0 +1,37 @@
// 인증 엔드포인트 호출 함수 (순수 HTTP 레이어, React 의존 없음).
// refresh_token 재발급은 http.ts 인터셉터가 자동 처리하므로 여기서 노출하지 않는다.
import { http } from '@/apis/http'
import type {
CreateAccountRequest,
CreateAccountResponse,
LoginRequest,
LoginResponse,
LogoutResponse,
MeResponse,
} from './auth.type'
export const authApi = {
/** POST /v1/auth/login — ID/PW 로 로그인, access/refresh 토큰 발급 */
login: async (body: LoginRequest): Promise<LoginResponse> => {
const res = await http.post<LoginResponse>('/v1/auth/login', body)
return res.data
},
/** POST /v1/auth/create — 신규 공급사 유저 계정 생성 */
createAccount: async (body: CreateAccountRequest): Promise<CreateAccountResponse> => {
const res = await http.post<CreateAccountResponse>('/v1/auth/create', body)
return res.data
},
/** GET /v1/auth/me — 현재 로그인 유저 정보 (access token 필요) */
me: async (): Promise<MeResponse> => {
const res = await http.get<MeResponse>('/v1/auth/me')
return res.data
},
/** POST /v1/auth/logout — 서버측 토큰 폐기(단일 세션) */
logout: async (): Promise<LogoutResponse> => {
const res = await http.post<LogoutResponse>('/v1/auth/logout')
return res.data
},
}

View File

@ -0,0 +1,5 @@
// 인증 도메인의 TanStack Query 키 팩토리.
export const authKeys = {
all: ['auth'] as const,
me: () => [...authKeys.all, 'me'] as const,
}

View File

@ -0,0 +1,55 @@
// 인증 도메인의 변경(useMutation) 훅.
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { tokenStorage } from '@/apis/tokenStorage'
import { authApi } from './auth.api'
import { authKeys } from './auth.keys'
import type { CreateAccountRequest, LoginResponse } from './auth.type'
/** 로그인 폼이 다루는 파라미터 (UI 친화적인 camelCase) */
export interface LoginParams {
id: string
password: string
}
/**
* 로그인: 성공 시 토큰을 저장하고 me 캐시를 무효화한다.
*/
export function useLoginMutation() {
const queryClient = useQueryClient()
return useMutation<LoginResponse, Error, LoginParams>({
mutationFn: async ({ id, password }) => {
const data = await authApi.login({ id, pw: password })
tokenStorage.setTokens(data.access_token, data.refresh_token)
return data
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: authKeys.me() })
},
})
}
/**
* 로그아웃: 서버 토큰 폐기를 시도하고(실패해도) 로컬 토큰/캐시를 비운다.
*/
export function useLogoutMutation() {
const queryClient = useQueryClient()
return useMutation<void, Error, void>({
mutationFn: async () => {
try {
await authApi.logout()
} finally {
tokenStorage.clear()
}
},
onSettled: () => {
queryClient.clear()
},
})
}
/** 공급사 유저 계정 생성 */
export function useCreateAccountMutation() {
return useMutation({
mutationFn: (body: CreateAccountRequest) => authApi.createAccount(body),
})
}

View File

@ -0,0 +1,20 @@
// 인증 도메인의 조회(useQuery) 훅.
import { useQuery } from '@tanstack/react-query'
import { tokenStorage } from '@/apis/tokenStorage'
import { authApi } from './auth.api'
import { authKeys } from './auth.keys'
import { toAuthUser } from './auth.type'
/**
* 현재 로그인 유저 정보 조회.
* 토큰이 있을 때만 활성화되며, AuthUser(카멜케이스)로 가공해 반환한다.
*/
export function useMeQuery() {
return useQuery({
queryKey: authKeys.me(),
queryFn: authApi.me,
enabled: tokenStorage.hasToken(),
staleTime: 5 * 60 * 1000, // 5분
select: toAuthUser,
})
}

View File

@ -0,0 +1,91 @@
// 인증 API 의 요청/응답 타입.
// 와이어 포맷은 백엔드(snake_case)를 그대로 미러링한다.
import type { ApiResult } from '@/apis/types'
/** 유저 권한 (supplier_users.role) */
export const UserRole = {
USER: 1,
MANAGER: 2,
} as const
export type UserRole = (typeof UserRole)[keyof typeof UserRole]
export const USER_ROLE_LABEL: Record<UserRole, string> = {
[UserRole.USER]: '일반',
[UserRole.MANAGER]: '매니저',
}
// --- 로그인 ---------------------------------------------------------------
export interface LoginRequest {
id: string
pw: string
}
export interface LoginResponse {
result: ApiResult
su_id: string
name: string
supplier_id: string
supplier_name: string
role: number
access_token: string
refresh_token: string
}
// --- 계정 생성 ------------------------------------------------------------
export interface CreateAccountRequest {
supplier_id: string
id: string
pw: string
name?: string
email?: string
contact_number?: string
role?: number
}
export interface CreateAccountResponse {
result: ApiResult
su_id: string
}
// --- 토큰 재발급 ----------------------------------------------------------
export interface RefreshTokenResponse {
result: ApiResult
access_token: string
}
// --- 내 정보 (GET /v1/auth/me) -------------------------------------------
export interface MeResponse {
result: ApiResult
su_id: string
id: string
name: string
supplier_id: string
supplier_name: string
role: number
}
// --- 로그아웃 -------------------------------------------------------------
export interface LogoutResponse {
result: ApiResult
}
/** 앱에서 다루기 편한 현재 유저 형태 (MeResponse 에서 파생) */
export interface AuthUser {
suId: string
loginId: string
name: string
supplierId: string
supplierName: string
role: number
}
export function toAuthUser(res: MeResponse): AuthUser {
return {
suId: res.su_id,
loginId: res.id,
name: res.name,
supplierId: res.supplier_id,
supplierName: res.supplier_name,
role: res.role,
}
}

View File

@ -0,0 +1,11 @@
// 인증 API 모듈 공개 표면.
export { authApi } from './auth.api'
export { authKeys } from './auth.keys'
export { useMeQuery } from './auth.queries'
export {
useLoginMutation,
useLogoutMutation,
useCreateAccountMutation,
type LoginParams,
} from './auth.mutations'
export * from './auth.type'

118
frontend/src/apis/http.ts Normal file
View File

@ -0,0 +1,118 @@
// 공용 axios 인스턴스.
// - 요청 시 access token 을 Authorization 헤더에 주입
// - 응답의 result.success=false 봉투를 ApiError 로 변환
// - access token 만료(434) 시 refresh_token 으로 1회 자동 재발급 후 원요청 재시도
// - refresh 실패 / 토큰 폐기(1203) / refresh 만료(435) 시 세션 종료 처리
import axios, { AxiosError, type InternalAxiosRequestConfig } from 'axios'
import { tokenStorage } from './tokenStorage'
import { ApiError, ErrorCode, type ApiResult } from './types'
const BASE_URL = import.meta.env.VITE_API_BASE_URL
export const http = axios.create({
baseURL: BASE_URL,
headers: { 'Content-Type': 'application/json' },
})
// 인증 만료 시 앱이 처리할 핸들러 (로그인 페이지로 이동 등). App 에서 등록한다.
let onUnauthorized: (() => void) | null = null
export function setUnauthorizedHandler(handler: (() => void) | null): void {
onUnauthorized = handler
}
function handleUnauthorized(): void {
tokenStorage.clear()
onUnauthorized?.()
}
// --- 요청 인터셉터: access token 주입 ------------------------------------
http.interceptors.request.use((config) => {
const token = tokenStorage.getAccessToken()
if (token) config.headers.Authorization = `Bearer ${token}`
return config
})
// --- 토큰 재발급 (단일 비행: 동시 요청은 하나의 refresh 만 공유) ----------
let refreshPromise: Promise<string> | null = null
async function refreshAccessToken(): Promise<string> {
const refreshToken = tokenStorage.getRefreshToken()
if (!refreshToken) {
throw new ApiError(ErrorCode.HTTP_REFRESH_TOKEN_EXPIRED, 'NO_REFRESH_TOKEN')
}
// 인터셉터 재귀를 피하려고 인스턴스가 아닌 기본 axios 로 호출한다.
const res = await axios.post<{ result: ApiResult; access_token?: string }>(
`${BASE_URL}/v1/auth/refresh_token`,
null,
{ headers: { Authorization: `Bearer ${refreshToken}` } },
)
const { result, access_token } = res.data
if (!result.success || !access_token) {
throw new ApiError(result.code, result.desc)
}
tokenStorage.setAccessToken(access_token)
return access_token
}
function toApiError(error: unknown): ApiError {
if (error instanceof ApiError) return error
if (axios.isAxiosError(error)) {
const result = (error.response?.data as { result?: ApiResult } | undefined)?.result
if (result) return new ApiError(result.code, result.desc, error.message)
// 토큰 관련 HTTPException 은 result 봉투 대신 {detail: "HTTP_*"} 형태로 온다
const detail = (error.response?.data as { detail?: string } | undefined)?.detail
const status = error.response?.status ?? 0
return new ApiError(status, detail ?? error.code ?? 'NETWORK_ERROR', error.message)
}
return new ApiError(ErrorCode.FAIL, 'UNKNOWN', String(error))
}
// --- 응답 인터셉터 -------------------------------------------------------
http.interceptors.response.use(
(response) => {
// HTTP 200 이지만 result.success=false 인 비즈니스 에러를 ApiError 로 변환
const result = (response.data as { result?: ApiResult } | undefined)?.result
if (result && !result.success) {
// 저장 토큰 무효화(로그아웃/타기기 로그인)는 200 + TOKEN_REVOKED 로 온다 → 세션 종료
if (result.code === ErrorCode.TOKEN_REVOKED && tokenStorage.hasToken()) {
handleUnauthorized()
}
throw new ApiError(result.code, result.desc)
}
return response
},
async (error: AxiosError) => {
const status = error.response?.status
const original = error.config as
| (InternalAxiosRequestConfig & { _retried?: boolean })
| undefined
const bodyCode = (error.response?.data as { result?: ApiResult } | undefined)?.result?.code
const isRefreshCall = original?.url?.includes('/v1/auth/refresh_token') ?? false
// access token 만료 → refresh 후 1회 재시도
if (status === 434 && original && !original._retried && !isRefreshCall) {
original._retried = true
try {
refreshPromise ??= refreshAccessToken().finally(() => {
refreshPromise = null
})
const newToken = await refreshPromise
original.headers.Authorization = `Bearer ${newToken}`
return http(original)
} catch (refreshError) {
handleUnauthorized()
throw toApiError(refreshError)
}
}
// 인증 실패(헤더 누락 403/401, 잘못된 토큰 433/436, refresh 만료 435) /
// 토큰 폐기(200+1203 이 아닌 경로) → 세션 종료
const isAuthFailStatus =
status === 401 || status === 403 || status === 433 || status === 435 || status === 436
if (isAuthFailStatus || bodyCode === ErrorCode.TOKEN_REVOKED || isRefreshCall) {
handleUnauthorized()
}
throw toApiError(error)
},
)

View File

@ -0,0 +1,8 @@
// apis 레이어 공개 표면.
export { http, setUnauthorizedHandler } from './http'
export { tokenStorage } from './tokenStorage'
export { ApiError, ErrorCode, isApiError, getApiErrorMessage } from './types'
export type { ApiResult, ApiEnvelope } from './types'
export * from './auth'
export * from './negotiation'

View File

@ -0,0 +1,6 @@
// 협상 API 모듈 공개 표면.
export { negotiationApi } from './negotiation.api'
export { negotiationKeys } from './negotiation.keys'
export { useSessionListQuery } from './negotiation.queries'
export { useParticipateMutation, useRejectMutation } from './negotiation.mutations'
export * from './negotiation.type'

View File

@ -0,0 +1,34 @@
// 협상 엔드포인트 호출 함수 (순수 HTTP 레이어, React 의존 없음).
import { http } from '@/apis/http'
import type {
ParticipateResponse,
RejectRequest,
RejectResponse,
SessionListParams,
SessionListResponse,
} from './negotiation.type'
export const negotiationApi = {
/** GET /v1/negotiation/sessions — 로그인 공급사의 협상 세션 목록(필터/정렬/페이지) */
getSessions: async (params: SessionListParams = {}): Promise<SessionListResponse> => {
const res = await http.get<SessionListResponse>('/v1/negotiation/sessions', { params })
return res.data
},
/** POST /v1/negotiation/sessions/{id}/participate — 협상 세션 참여 */
participate: async (sessionId: string): Promise<ParticipateResponse> => {
const res = await http.post<ParticipateResponse>(
`/v1/negotiation/sessions/${sessionId}/participate`,
)
return res.data
},
/** POST /v1/negotiation/sessions/{id}/reject — 협상 세션 거부 */
reject: async (sessionId: string, body: RejectRequest): Promise<RejectResponse> => {
const res = await http.post<RejectResponse>(
`/v1/negotiation/sessions/${sessionId}/reject`,
body,
)
return res.data
},
}

View File

@ -0,0 +1,8 @@
// 협상 도메인의 TanStack Query 키 팩토리.
import type { SessionListParams } from './negotiation.type'
export const negotiationKeys = {
all: ['negotiation'] as const,
sessions: () => [...negotiationKeys.all, 'sessions'] as const,
sessionList: (params: SessionListParams) => [...negotiationKeys.sessions(), params] as const,
}

View File

@ -0,0 +1,32 @@
// 협상 도메인의 변경(useMutation) 훅.
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { negotiationApi } from './negotiation.api'
import { negotiationKeys } from './negotiation.keys'
import type { RejectRequest } from './negotiation.type'
/**
* 협상 세션 참여: 성공 시 세션 목록 캐시를 무효화해 상태를 갱신한다.
*/
export function useParticipateMutation() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (sessionId: string) => negotiationApi.participate(sessionId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: negotiationKeys.sessions() })
},
})
}
/**
* 협상 세션 거부: 성공 시 세션 목록 캐시를 무효화한다.
*/
export function useRejectMutation() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: ({ sessionId, request }: { sessionId: string; request: RejectRequest }) =>
negotiationApi.reject(sessionId, request),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: negotiationKeys.sessions() })
},
})
}

View File

@ -0,0 +1,17 @@
// 협상 도메인의 조회(useQuery) 훅.
import { keepPreviousData, useQuery } from '@tanstack/react-query'
import { negotiationApi } from './negotiation.api'
import { negotiationKeys } from './negotiation.keys'
import type { SessionListParams } from './negotiation.type'
/**
* 협상 세션 목록 조회.
* 페이지 전환 시 이전 데이터를 유지해 깜빡임을 줄인다.
*/
export function useSessionListQuery(params: SessionListParams = {}) {
return useQuery({
queryKey: negotiationKeys.sessionList(params),
queryFn: () => negotiationApi.getSessions(params),
placeholderData: keepPreviousData,
})
}

View File

@ -0,0 +1,80 @@
// 협상 API 의 요청/응답 타입 + 코드값 enum.
// 와이어 포맷은 백엔드(snake_case)를 그대로 미러링한다.
import type { ApiResult } from '@/apis/types'
/** 협상 세션 상태 (negotiation.sessions.status) */
export const SessionStatus = {
CREATED: 1, // 협상생성(참여대기)
IN_PROGRESS: 2, // 협상중
DONE: 3, // 협상완료
NOT_PARTICIPATED: 4, // 미참여(마감)
REJECTED: 5, // 협상거부
} as const
export type SessionStatus = (typeof SessionStatus)[keyof typeof SessionStatus]
export const SESSION_STATUS_LABEL: Record<SessionStatus, string> = {
[SessionStatus.CREATED]: '협상생성',
[SessionStatus.IN_PROGRESS]: '협상중',
[SessionStatus.DONE]: '협상완료',
[SessionStatus.NOT_PARTICIPATED]: '미참여',
[SessionStatus.REJECTED]: '협상거부',
}
/** 견적 타입 (negotiation.sessions.qt_type) */
export const QtType = {
RENEGO: 1, // 재협상(1:1)
REQUOTE: 2, // 재견적(1:N)
} as const
export type QtType = (typeof QtType)[keyof typeof QtType]
export const QT_TYPE_LABEL: Record<QtType, string> = {
[QtType.RENEGO]: '재협상',
[QtType.REQUOTE]: '재견적',
}
// --- 세션 목록 (GET /v1/negotiation/sessions) ----------------------------
export interface SessionListParams {
status?: number // SessionStatus 코드 필터
qt_type?: number // QtType 코드 필터
order?: 'asc' | 'desc' // 마감(qt_end_time) 정렬, asc=임박순
page?: number
page_size?: number
}
export interface SessionListItem {
session_id: string
session_status: number
qt_type: number
qt_number: string
qt_end_time: string // ISO 8601 마감 시각
item_code: string
item_name: string
model_name: string
maker_name: string
}
export interface SessionListResponse {
result: ApiResult
items: SessionListItem[]
total: number
page: number
page_size: number
}
// --- 참여 (POST /v1/negotiation/sessions/{id}/participate) ----------------
export interface ParticipateResponse {
result: ApiResult
session_id: string
}
// --- 거부 (POST /v1/negotiation/sessions/{id}/reject) ---------------------
// reject_reason: 프리셋(단종/품절) 라벨 또는 '기타' 직접 입력 텍스트.
// (백엔드 sessions.reject_reason 컬럼에 대응. 엔드포인트는 백엔드 추가 예정)
export interface RejectRequest {
reject_reason: string
}
export interface RejectResponse {
result: ApiResult
session_id: string
}

View File

@ -0,0 +1,27 @@
// JWT access/refresh 토큰의 영속 저장소.
// axios 인터셉터(http.ts)와 인증 mutation 이 공유한다.
const ACCESS_KEY = 'negosium.accessToken'
const REFRESH_KEY = 'negosium.refreshToken'
export const tokenStorage = {
getAccessToken: (): string | null => localStorage.getItem(ACCESS_KEY),
getRefreshToken: (): string | null => localStorage.getItem(REFRESH_KEY),
setTokens: (accessToken: string, refreshToken: string): void => {
localStorage.setItem(ACCESS_KEY, accessToken)
localStorage.setItem(REFRESH_KEY, refreshToken)
},
/** refresh_token 으로 access_token 만 갱신할 때 사용 */
setAccessToken: (accessToken: string): void => {
localStorage.setItem(ACCESS_KEY, accessToken)
},
clear: (): void => {
localStorage.removeItem(ACCESS_KEY)
localStorage.removeItem(REFRESH_KEY)
},
hasToken: (): boolean => localStorage.getItem(ACCESS_KEY) !== null,
}

View File

@ -0,0 +1,80 @@
// 모든 API 응답이 공유하는 공통 봉투(envelope)와 에러 타입.
// 백엔드는 HTTP 200 으로 내려주면서 result.success=false 로 비즈니스 에러를 표현한다.
/** 백엔드가 모든 응답에 공통으로 내려주는 처리 결과 */
export interface ApiResult {
success: boolean
code: number
desc: string
}
/** result 봉투를 포함하는 응답의 베이스 */
export interface ApiEnvelope {
result: ApiResult
}
/** 백엔드 ErrorType 코드 (backend/common 의 ErrorType 과 1:1 매핑) */
export const ErrorCode = {
SUCCESS: 0,
FAIL: 1,
DB_RUN_FAILED: 10,
DB_ALREADY_SAME_KEY: 11,
JSON_PARSE_ERROR: 100,
INVALID_REQUEST_DATA: 101,
INTERNAL_EXCEPTION: 102,
HTTP_INVALID_CLIENT_REQUEST: 419,
HTTP_TO_MANY_REQUEST: 429,
HTTP_INVALID_CLIENT_ACCESS: 433,
HTTP_ACCESS_TOKEN_EXPIRED: 434,
HTTP_REFRESH_TOKEN_EXPIRED: 435,
HTTP_INVALID_TOKEN_ACCESS: 436,
ACCOUNT_INVALID_INFO: 1200,
ACCOUNT_ALREADY_EXIST: 1201,
ACCOUNT_BLOCKED_USER: 1202,
TOKEN_REVOKED: 1203,
NEGO_FORBIDDEN: 1300,
NEGO_NOT_PARTICIPABLE: 1301,
NEGO_QUOTATION_CLOSED: 1302,
NEGO_DEADLINE_PASSED: 1303,
NEGO_NOT_FOUND: 1304,
} as const
export type ErrorCode = (typeof ErrorCode)[keyof typeof ErrorCode]
/** code → 사용자에게 보여줄 한국어 메시지 */
const API_ERROR_MESSAGES: Record<number, string> = {
[ErrorCode.ACCOUNT_INVALID_INFO]: '아이디 또는 비밀번호가 올바르지 않습니다.',
[ErrorCode.ACCOUNT_ALREADY_EXIST]: '이미 존재하는 아이디입니다.',
[ErrorCode.ACCOUNT_BLOCKED_USER]: '비활성화된 계정입니다. 관리자에게 문의하세요.',
[ErrorCode.TOKEN_REVOKED]: '다른 기기에서 로그인되어 세션이 종료되었습니다.',
[ErrorCode.HTTP_ACCESS_TOKEN_EXPIRED]: '로그인이 만료되었습니다. 다시 로그인해주세요.',
[ErrorCode.HTTP_REFRESH_TOKEN_EXPIRED]: '로그인이 만료되었습니다. 다시 로그인해주세요.',
[ErrorCode.NEGO_FORBIDDEN]: '해당 협상에 접근할 권한이 없습니다.',
[ErrorCode.NEGO_NOT_PARTICIPABLE]: '참여할 수 없는 협상입니다.',
[ErrorCode.NEGO_QUOTATION_CLOSED]: '마감된 견적입니다.',
[ErrorCode.NEGO_DEADLINE_PASSED]: '협상 마감 시간이 지났습니다.',
[ErrorCode.NEGO_NOT_FOUND]: '협상을 찾을 수 없습니다.',
}
/** API 에러: result.code(비즈니스) 또는 HTTP status 를 code 로 담는다 */
export class ApiError extends Error {
readonly code: number
readonly desc: string
constructor(code: number, desc: string, message?: string) {
super(message ?? API_ERROR_MESSAGES[code] ?? desc)
this.name = 'ApiError'
this.code = code
this.desc = desc
}
}
export function isApiError(error: unknown): error is ApiError {
return error instanceof ApiError
}
/** code 에 해당하는 사용자 안내 메시지 (없으면 기본 문구) */
export function getApiErrorMessage(error: unknown, fallback = '요청 처리 중 오류가 발생했습니다.'): string {
if (isApiError(error)) return API_ERROR_MESSAGES[error.code] ?? error.message ?? fallback
return fallback
}