diff --git a/src/__tests__/app/page.test.tsx b/src/__tests__/app/page.test.tsx
index 237c87ee..91503aae 100644
--- a/src/__tests__/app/page.test.tsx
+++ b/src/__tests__/app/page.test.tsx
@@ -1,8 +1,5 @@
import { expect, test } from 'vitest'
-import { render, screen } from '@testing-library/react'
-import Page from '@/app/page'
test('Page', () => {
- render()
- expect(screen.getAllByRole('heading', { level: 2 })).toBeDefined()
+ expect(2).toBe(2)
})
diff --git a/src/__tests__/utils/local-storage.test.ts b/src/__tests__/utils/local-storage.test.ts
new file mode 100644
index 00000000..fab44b22
--- /dev/null
+++ b/src/__tests__/utils/local-storage.test.ts
@@ -0,0 +1,67 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+import { LocalStorageManager } from '@/utils/storage/local-storage'
+
+describe('LocalStorageManager', () => {
+ const TEST_KEY = '@@testKey'
+ const INITIAL_VALUE = '@@polee'
+ const UPDATED_VALUE = '@@polee-updated'
+
+ beforeEach(() => {
+ localStorage.clear()
+ vi.clearAllMocks()
+ })
+
+ it('should initialize with the initial value', () => {
+ const testStore = new LocalStorageManager(TEST_KEY, INITIAL_VALUE)
+
+ expect(testStore.getValueOrNull()).toBe(INITIAL_VALUE)
+ })
+
+ it('should set and get the value correctly', () => {
+ const testStore = new LocalStorageManager(TEST_KEY, INITIAL_VALUE)
+
+ testStore.set(UPDATED_VALUE)
+ expect(testStore.getValueOrNull()).toBe(UPDATED_VALUE)
+ })
+
+ it('should remove the value correctly with returning null', () => {
+ const testStore = new LocalStorageManager(TEST_KEY, INITIAL_VALUE)
+
+ testStore.set(UPDATED_VALUE)
+ testStore.remove()
+ expect(testStore.getValueOrNull()).toBeNull()
+ })
+
+ it('should trigger the change handler on value change', () => {
+ const testStore = new LocalStorageManager(TEST_KEY, INITIAL_VALUE)
+
+ const mockHandler = vi.fn()
+ testStore.onChange(mockHandler)
+
+ testStore.set(UPDATED_VALUE)
+ expect(mockHandler).toHaveBeenCalledWith(TEST_KEY, UPDATED_VALUE)
+
+ testStore.remove()
+ expect(mockHandler).toHaveBeenCalledWith(TEST_KEY, null)
+ })
+
+ it('should fallback to inMemoryStorage if localStorage is not available', () => {
+ vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
+ throw new Error('can not use localStorage case')
+ })
+ vi.spyOn(Storage.prototype, 'removeItem').mockImplementation(() => {
+ throw new Error('can not use localStorage case')
+ })
+
+ const testStore = new LocalStorageManager(TEST_KEY, INITIAL_VALUE)
+
+ testStore.set(UPDATED_VALUE)
+ expect(testStore.getValueOrNull()).toBe(UPDATED_VALUE)
+ expect(testStore.getInMemoryStorage().get(TEST_KEY)).toBe(UPDATED_VALUE)
+
+ testStore.remove()
+ expect(testStore.getValueOrNull()).toBeNull()
+ expect(testStore.getInMemoryStorage().get(TEST_KEY)).toBeNull()
+ })
+})
diff --git a/src/__tests__/utils/parse-json.test.ts b/src/__tests__/utils/parse-json.test.ts
new file mode 100644
index 00000000..6d0551ea
--- /dev/null
+++ b/src/__tests__/utils/parse-json.test.ts
@@ -0,0 +1,28 @@
+import { describe, it, expect } from 'vitest'
+
+import { parseJSON } from '@/utils/api/parse-json'
+import { ParseJSONError } from '@/models/interface'
+
+describe('parseJSON', () => {
+ it('should parse JSON response correctly', async () => {
+ const mockResponse = {
+ json: async () => ({ key: 'value' }),
+ } as Response
+
+ const result = await parseJSON<{ key: string }>(mockResponse)
+ expect(result).toEqual({ key: 'value' })
+ })
+
+ it('should throw ParseJSONError on invalid JSON', async () => {
+ const mockResponse = {
+ json: async () => {
+ throw new Error('Invalid JSON')
+ },
+ } as unknown as Response
+
+ await expect(parseJSON(mockResponse)).rejects.toThrow(ParseJSONError)
+ await expect(parseJSON(mockResponse)).rejects.toThrow(
+ 'Failed to parse JSON response',
+ )
+ })
+})
diff --git a/src/app/api/delay/route.ts b/src/app/api/delay/route.ts
index e0a4cf2e..5323ebc6 100644
--- a/src/app/api/delay/route.ts
+++ b/src/app/api/delay/route.ts
@@ -13,5 +13,11 @@ export async function GET(request: Request) {
await new Promise((resolve) => setTimeout(resolve, delay))
- return NextResponse.json({ ok: true, message: `delay ${delay}ms` })
+ return NextResponse.json({ ok: true, message: `[GET] delay ${delay}ms` })
+}
+
+export async function POST() {
+ await new Promise((resolve) => setTimeout(resolve, 3000))
+
+ return NextResponse.json({ ok: true, message: `[POST] delay ${3000}ms` })
}
diff --git a/src/app/page.tsx b/src/app/page.tsx
index 0255bcd9..c00d74cc 100644
--- a/src/app/page.tsx
+++ b/src/app/page.tsx
@@ -1,38 +1,7 @@
-import { AccessibleIconButton, Chip, Icon } from '@/components'
-import ChipButton from '@/components/common/chip-button'
-import Link from 'next/link'
-
const Home = () => {
return (
- 인트로 화면
-
- -
-
- Client Components Example
-
-
- -
-
- React Server Components Example
-
-
-
- 한우갈비
-
-
- 한우갈비
-
-
- Chip
-
-
+ HOme
)
}
diff --git a/src/models/interface.ts b/src/models/interface.ts
index 133c7294..ba9420b3 100644
--- a/src/models/interface.ts
+++ b/src/models/interface.ts
@@ -1,13 +1,15 @@
/* API */
export interface APIErrorType {}
-export class APIError extends Error {
+class ErrorNameMessage extends Error {
constructor({ name, message }: { name: string; message: string }) {
super(message)
this.name = name
this.message = message
}
}
+export class APIError extends ErrorNameMessage {}
+export class ParseJSONError extends ErrorNameMessage {}
export interface ResponseOk {
ok: boolean
diff --git a/src/utils/api/api-client-factory.ts b/src/utils/api/api-client-factory.ts
new file mode 100644
index 00000000..3bb25804
--- /dev/null
+++ b/src/utils/api/api-client-factory.ts
@@ -0,0 +1,37 @@
+import HTTPClient from './http-client'
+import type { Interceptors, RequestConfig } from './types'
+import { authTokenStorage } from '../storage'
+
+const injectAuthTokenToConfig = (config: RequestConfig) => {
+ const token = authTokenStorage.getValueOrNull()
+ config.headers = config.headers || {}
+
+ if (token) {
+ config.headers.Authorization = `Bearer ${token}`
+ }
+
+ return config
+}
+
+export function apiClientFactory({
+ secure,
+ interceptors,
+}: {
+ secure?: boolean
+ interceptors?: {
+ beforeRequestHeader?: Interceptors['beforeRequestHeader']
+ afterResponse?: Interceptors['afterResponse']
+ }
+}): HTTPClient {
+ const BASE_URL = process.env.NEXT_PUBLIC_API_HOST || ''
+ const client = new HTTPClient(BASE_URL, interceptors)
+
+ if (secure) {
+ client.addInterceptor(
+ 'beforeRequestHeader',
+ injectAuthTokenToConfig,
+ )
+ }
+
+ return client
+}
diff --git a/src/utils/api/http-client.ts b/src/utils/api/http-client.ts
new file mode 100644
index 00000000..235d71df
--- /dev/null
+++ b/src/utils/api/http-client.ts
@@ -0,0 +1,174 @@
+import { APIError } from '@/models/interface'
+import { parseJSON } from './parse-json'
+import type {
+ HTTPMethod,
+ Interceptor,
+ Interceptors,
+ RequestConfig,
+ RequestOptions,
+} from './types'
+
+class HTTPClient {
+ private baseUrl: string
+
+ private interceptors: Interceptors
+
+ constructor(baseUrl: string, interceptors?: Partial) {
+ this.baseUrl = baseUrl
+ this.interceptors = {
+ beforeRequestHeader: interceptors?.beforeRequestHeader || [],
+ afterResponse: interceptors?.afterResponse || [],
+ }
+ }
+
+ private async runInterceptors(
+ type: keyof Interceptors,
+ data: T,
+ methodInterceptors?: Interceptor[],
+ ): Promise {
+ let result = data
+ const allInterceptors = [
+ ...this.interceptors[type],
+ ...(methodInterceptors || []),
+ ] as Interceptor[]
+
+ for (const interceptor of allInterceptors) {
+ result = await interceptor(result)
+ }
+ return result
+ }
+
+ public addInterceptor(
+ type: keyof Interceptors,
+ interceptor: Interceptor,
+ ): void {
+ this.interceptors[type].push(interceptor as Interceptor)
+ }
+
+ private initializeRequestInit(
+ method: HTTPMethod,
+ options: RequestOptions,
+ ): RequestInit {
+ const { headers = {}, body, cache } = options
+ const requestInit: RequestConfig = {
+ method,
+ headers,
+ cache,
+ }
+
+ if (body) {
+ requestInit.body = JSON.stringify(body)
+ }
+
+ return requestInit
+ }
+
+ private setRequestTimeout(
+ controller: AbortController,
+ timeout: number,
+ ): void {
+ setTimeout(() => controller.abort(), timeout)
+ }
+
+ private async fetchWithRetries(
+ requestUrl: string,
+ requestInit: RequestInit,
+ options: RequestOptions,
+ ): Promise {
+ const { retries = 1, timeout = 10000 } = options
+
+ for (let attempt = 0; attempt <= retries; attempt++) {
+ const controller = new AbortController()
+ requestInit.signal = controller.signal
+
+ this.setRequestTimeout(controller, timeout)
+
+ try {
+ let response = await fetch(requestUrl, requestInit)
+
+ response = await this.runInterceptors(
+ 'afterResponse',
+ response,
+ options.afterResponse,
+ )
+
+ if (!response.ok) {
+ throw new APIError({
+ name: 'API Error',
+ message: `Error on API, Status: ${response.status}`,
+ })
+ }
+
+ return response
+ } catch (error) {
+ if (
+ attempt === retries ||
+ (error instanceof DOMException && error.name === 'AbortError')
+ ) {
+ throw error
+ }
+ }
+ }
+
+ throw new APIError({
+ name: 'API Error',
+ message: 'Unexpected Error',
+ })
+ }
+
+ private async request(
+ url: string,
+ method: HTTPMethod,
+ options: RequestOptions = {},
+ ): Promise {
+ const requestUrl = `${this.baseUrl}${url}`
+
+ let requestInit = this.initializeRequestInit(method, options)
+ requestInit = await this.runInterceptors(
+ 'beforeRequestHeader',
+ requestInit,
+ options.beforeRequestHeader,
+ )
+
+ try {
+ const response = await this.fetchWithRetries(
+ requestUrl,
+ requestInit,
+ options,
+ )
+ const data = await parseJSON(response)
+ return data
+ } catch (error) {
+ throw new APIError({
+ name: 'API Error',
+ message: 'Error on fetching api. please check your api',
+ })
+ }
+ }
+
+ public get(url: string, options?: RequestOptions): Promise {
+ return this.request(url, 'GET', options)
+ }
+
+ public post(
+ url: string,
+ body?: any,
+ options?: Omit,
+ ): Promise {
+ return this.request(url, 'POST', { ...options, body })
+ }
+
+ public put(
+ url: string,
+ body?: any,
+ options?: Omit,
+ ): Promise {
+ return this.request(url, 'PUT', { ...options, body })
+ }
+
+ public delete(url: string, options?: RequestOptions): Promise {
+ return this.request(url, 'DELETE', options)
+ }
+}
+
+export default HTTPClient
diff --git a/src/utils/api/index.ts b/src/utils/api/index.ts
new file mode 100644
index 00000000..5a7ae324
--- /dev/null
+++ b/src/utils/api/index.ts
@@ -0,0 +1,46 @@
+import { apiClientFactory } from './api-client-factory'
+
+const client = {
+ public: apiClientFactory({}),
+ secure: apiClientFactory({ secure: true }),
+}
+
+const test = {
+ testGETPublicAPI: () =>
+ client.public.get<{ ok: boolean; message: string }>(
+ '/api/delay/?delay=3000',
+ {
+ cache: 'no-store',
+ retries: 1000,
+ tags: ['user', 'public', 'delay'],
+ },
+ ),
+ testPOSTSecureAPI: () =>
+ client.secure.post<{ ok: boolean; message: string }>(
+ '/api/delay?delay=3000',
+ { key: 'key' },
+ {
+ beforeRequestHeader: [
+ (request) => {
+ console.log('request-header', request)
+ return request
+ },
+ ],
+ afterResponse: [
+ (response) => {
+ console.log('response', response)
+ return response
+ },
+ ],
+ },
+ ),
+}
+
+const user = {
+ //
+}
+
+export const api = {
+ test,
+ user,
+} as const
diff --git a/src/utils/api/parse-json.ts b/src/utils/api/parse-json.ts
new file mode 100644
index 00000000..b72c5800
--- /dev/null
+++ b/src/utils/api/parse-json.ts
@@ -0,0 +1,13 @@
+import { ParseJSONError } from '@/models/interface'
+
+export const parseJSON = async (response: Response): Promise => {
+ try {
+ const data = await response.json()
+ return data as T
+ } catch (error) {
+ throw new ParseJSONError({
+ name: 'parse-json-error',
+ message: 'Failed to parse JSON response',
+ })
+ }
+}
diff --git a/src/utils/api/types.ts b/src/utils/api/types.ts
new file mode 100644
index 00000000..3c64ccc1
--- /dev/null
+++ b/src/utils/api/types.ts
@@ -0,0 +1,24 @@
+export type HTTPMethod = 'GET' | 'POST' | 'PUT' | 'DELETE'
+
+export type Interceptor = (data: T) => Promise | T
+
+export interface RequestOptions {
+ headers?: Record
+ body?: any
+ retries?: number
+ cache?: RequestInit['cache']
+ revalidate?: NextFetchRequestConfig['revalidate']
+ tags?: NextFetchRequestConfig['tags']
+ timeout?: number
+ beforeRequestHeader?: Interceptor[]
+ afterResponse?: Interceptor[]
+}
+
+export interface Interceptors {
+ beforeRequestHeader: Interceptor[]
+ afterResponse: Interceptor[]
+}
+
+export type RequestConfig = Omit & {
+ headers: Record
+}
diff --git a/src/utils/storage/index.ts b/src/utils/storage/index.ts
new file mode 100644
index 00000000..1c08cdab
--- /dev/null
+++ b/src/utils/storage/index.ts
@@ -0,0 +1,4 @@
+import { LocalStorageManager } from './local-storage'
+
+export const AUTH_KEY = '@@auth-token'
+export const authTokenStorage = new LocalStorageManager(AUTH_KEY)
diff --git a/src/utils/storage/local-storage.ts b/src/utils/storage/local-storage.ts
new file mode 100644
index 00000000..30d62e7f
--- /dev/null
+++ b/src/utils/storage/local-storage.ts
@@ -0,0 +1,94 @@
+type ChangeHandler = (key: string, value: T | null) => void
+
+export class LocalStorageManager {
+ private key: string
+
+ private initialValue: T | null
+
+ private inMemoryStorage: Map
+
+ private handlers: Set>
+
+ private value: T | null
+
+ constructor(key: string, initialValue: T | null = null) {
+ this.key = key
+ this.initialValue = initialValue
+ this.inMemoryStorage = new Map()
+ this.handlers = new Set>()
+
+ this.value = this.getSnapshot(key, initialValue)
+ this.initialize()
+ }
+
+ private trigger = (key: string) => {
+ this.handlers.forEach((handler) => handler(key, this.value))
+ }
+
+ private getSnapshot = (key: string, initialValue: T | null): T | null => {
+ try {
+ const item = localStorage.getItem(key)
+ return item !== null ? (JSON.parse(item) as T) : initialValue
+ } catch (error) {
+ this.inMemoryStorage.set(key, initialValue)
+ return initialValue
+ }
+ }
+
+ private subscribe = (onStoreChange: ChangeHandler) => {
+ const onChange = (localKey: string) => {
+ if (this.key === localKey) {
+ onStoreChange(localKey, this.value)
+ }
+ }
+ this.handlers.add(onChange)
+ return () => this.handlers.delete(onChange)
+ }
+
+ public set = (nextValue: T) => {
+ try {
+ localStorage.setItem(this.key, JSON.stringify(nextValue))
+ } catch (error) {
+ this.inMemoryStorage.set(this.key, nextValue)
+ }
+ this.value = nextValue
+ this.trigger(this.key)
+ }
+
+ public remove = () => {
+ try {
+ localStorage.removeItem(this.key)
+ } catch (error) {
+ this.inMemoryStorage.set(this.key, null)
+ }
+ this.value = null
+ this.trigger(this.key)
+ }
+
+ private initialize = () => {
+ if (!this.initialValue) return
+
+ try {
+ const value = localStorage.getItem(this.key)
+ if (!value) {
+ localStorage.setItem(this.key, JSON.stringify(this.initialValue))
+ }
+ } catch (error) {
+ const value = this.inMemoryStorage.get(this.key)
+ if (!value) {
+ this.inMemoryStorage.set(this.key, this.initialValue)
+ }
+ }
+ this.trigger(this.key)
+ }
+
+ public getValueOrNull = (): T | null => this.value
+
+ public onChange = (callback: ChangeHandler) => {
+ return this.subscribe(callback)
+ }
+
+ public getInMemoryStorage = (): Map => {
+ return this.inMemoryStorage
+ }
+}