Fetch API와 TypeScript 결합하기: 강력한 웹 개발의 시너지 🚀

콘텐츠 대표 이미지 - Fetch API와 TypeScript 결합하기: 강력한 웹 개발의 시너지 🚀

 

 

웹 개발 세계에서 데이터 통신과 타입 안정성은 두 가지 핵심 요소입니다. Fetch API는 네트워크 요청을 간편하게 만들어주고, TypeScript는 JavaScript에 정적 타입을 추가하여 코드의 안정성을 높여줍니다. 이 두 기술을 결합하면 어떤 마법이 일어날까요? 🎩✨

이 가이드에서는 Fetch API와 TypeScript를 함께 사용하는 방법을 상세히 알아보겠습니다. 초보자부터 전문가까지, 모든 개발자가 이해하고 적용할 수 있는 실용적인 접근법을 제시할 것입니다. 특히 재능넷과 같은 플랫폼에서 활동하는 프리랜서 개발자들에게 유용한 내용이 될 것입니다.

자, 이제 TypeScript의 강력한 타입 시스템과 Fetch API의 유연성을 결합하여 더 안전하고 효율적인 웹 애플리케이션을 만드는 여정을 시작해볼까요? 🌟

1. Fetch API 소개 📡

Fetch API는 현대 웹 개발에서 필수적인 도구입니다. AJAX 요청을 보내고 받는 데 사용되는 이 인터페이스는 XMLHttpRequest를 대체하여 더 강력하고 유연한 기능을 제공합니다.

1.1 Fetch API의 특징

  • Promise 기반: 비동기 프로그래밍을 더 쉽게 만듭니다.
  • 간결한 문법: 복잡한 설정 없이 간단하게 사용할 수 있습니다.
  • 스트림 지원: 대용량 데이터를 효율적으로 처리할 수 있습니다.
  • CORS 지원: 크로스 오리진 요청을 안전하게 처리합니다.

1.2 기본 사용법

Fetch API의 기본적인 사용법을 살펴보겠습니다:


fetch('https://api.example.com/data')
  .then(response => response.json())
  .then(data => console.log(data))
  .catch(error => console.error('Error:', error));

이 간단한 예제에서 볼 수 있듯이, Fetch API는 URL을 인자로 받아 Promise를 반환합니다. 이 Promise는 Response 객체로 해결되며, 이를 통해 응답 데이터에 접근할 수 있습니다.

1.3 응답 처리

Fetch API는 다양한 형식의 응답을 처리할 수 있습니다:

  • response.json(): JSON 형식의 응답을 파싱합니다.
  • response.text(): 텍스트 형식의 응답을 반환합니다.
  • response.blob(): 이미지나 파일 등의 바이너리 데이터를 처리합니다.
  • response.formData(): form 데이터를 처리합니다.

1.4 요청 옵션 설정

Fetch API는 다양한 옵션을 통해 요청을 커스터마이즈할 수 있습니다:


fetch('https://api.example.com/data', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({ key: 'value' })
})
.then(response => response.json())
.then(data => console.log(data));

이 예제에서는 POST 요청을 보내고, 헤더와 본문을 설정하는 방법을 보여줍니다.

1.5 에러 처리

Fetch API에서 주의해야 할 점은 네트워크 오류가 아닌 한 Promise는 거부되지 않는다는 것입니다. 따라서 응답의 상태 코드를 확인하는 것이 중요합니다:


fetch('https://api.example.com/data')
  .then(response => {
    if (!response.ok) {
      throw new Error('Network response was not ok');
    }
    return response.json();
  })
  .then(data => console.log(data))
  .catch(error => console.error('Error:', error));

이렇게 Fetch API의 기본적인 사용법을 살펴보았습니다. 이제 TypeScript와 어떻게 결합하여 사용할 수 있는지 알아보겠습니다. 🧐

2. TypeScript 기초 🏗️

TypeScript는 JavaScript의 슈퍼셋 언어로, 정적 타입 검사와 최신 ECMAScript 기능을 제공합니다. Fetch API와 결합하기 전에, TypeScript의 핵심 개념을 간단히 살펴보겠습니다.

2.1 TypeScript의 장점

  • 정적 타입 검사: 컴파일 시점에 오류를 잡아낼 수 있습니다.
  • 향상된 IDE 지원: 자동 완성, 리팩토링 등의 기능이 강화됩니다.
  • 객체 지향 프로그래밍 지원: 클래스, 인터페이스 등을 사용할 수 있습니다.
  • 모듈 시스템: 코드를 모듈화하여 관리할 수 있습니다.

2.2 기본 타입

TypeScript에서 제공하는 기본 타입들을 살펴보겠습니다:


let isDone: boolean = false;
let decimal: number = 6;
let color: string = "blue";
let list: number[] = [1, 2, 3];
let tuple: [string, number] = ["hello", 10];
enum Color {Red, Green, Blue}
let c: Color = Color.Green;
let notSure: any = 4;
let unusable: void = undefined;
let u: undefined = undefined;
let n: null = null;

2.3 인터페이스

인터페이스는 TypeScript에서 타입을 정의하는 강력한 방법입니다:


interface User {
  name: string;
  age: number;
  email?: string; // 선택적 속성
  readonly id: number; // 읽기 전용 속성
}

let user: User = {
  name: "John",
  age: 30,
  id: 1
};

2.4 제네릭

제네릭을 사용하면 재사용 가능한 컴포넌트를 만들 수 있습니다:


function identity<T>(arg: T): T {
  return arg;
}

let output = identity<string>("myString");

2.5 타입 추론

TypeScript는 많은 경우 타입을 자동으로 추론할 수 있습니다:


let x = 3; // number로 추론됨
let y = [0, 1, null]; // (number | null)[]로 추론됨

2.6 유니온 타입

변수가 여러 타입 중 하나일 수 있음을 나타냅니다:


let multiType: number | string;
multiType = 20; // OK
multiType = "twenty"; // OK

2.7 타입 가드

런타임에 타입을 좁혀나가는 방법입니다:


function padLeft(value: string, padding: string | number) {
  if (typeof padding === "number") {
    return Array(padding + 1).join(" ") + value;
  }
  if (typeof padding === "string") {
    return padding + value;
  }
  throw new Error(`Expected string or number, got '${padding}'.`);
}

이러한 TypeScript의 기본 개념들은 Fetch API와 결합할 때 매우 유용하게 사용됩니다. 특히 API 응답의 타입을 정의하고 처리하는 데 큰 도움이 됩니다. 다음 섹션에서는 이 두 기술을 어떻게 효과적으로 결합할 수 있는지 살펴보겠습니다. 💡

3. Fetch API와 TypeScript 결합하기 🤝

이제 Fetch API와 TypeScript를 결합하여 사용하는 방법을 자세히 알아보겠습니다. 이 결합은 네트워크 요청의 안정성과 예측 가능성을 크게 향상시킵니다.

3.1 기본적인 Fetch 요청에 타입 추가하기

먼저, 간단한 GET 요청에 타입을 추가해 보겠습니다:


interface User {
  id: number;
  name: string;
  email: string;
}

async function fetchUser(id: number): Promise<User> {
  const response = await fetch(`https://api.example.com/users/${id}`);
  if (!response.ok) {
    throw new Error('Failed to fetch user');
  }
  return response.json() as Promise<User>;
}

// 사용 예
fetchUser(1)
  .then(user => console.log(user.name))
  .catch(error => console.error(error));

이 예제에서는 User 인터페이스를 정의하고, fetchUser 함수가 Promise<User>를 반환하도록 명시했습니다. 이렇게 하면 반환된 데이터의 구조를 명확히 알 수 있고, IDE의 자동 완성 기능을 활용할 수 있습니다.

3.2 POST 요청 처리하기

이번에는 POST 요청을 TypeScript와 함께 사용해 보겠습니다:


interface CreateUserRequest {
  name: string;
  email: string;
}

interface CreateUserResponse {
  id: number;
  name: string;
  email: string;
  createdAt: string;
}

async function createUser(userData: CreateUserRequest): Promise<CreateUserResponse> {
  const response = await fetch('https://api.example.com/users', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
    },
    body: JSON.stringify(userData),
  });

  if (!response.ok) {
    throw new Error('Failed to create user');
  }

  return response.json() as Promise<CreateUserResponse>;
}

// 사용 예
const newUser: CreateUserRequest = {
  name: "John Doe",
  email: "john@example.com"
};

createUser(newUser)
  .then(createdUser => console.log(`Created user with ID: ${createdUser.id}`))
  .catch(error => console.error(error));

이 예제에서는 요청 본문(CreateUserRequest)과 응답 본문(CreateUserResponse)에 대한 인터페이스를 각각 정의했습니다. 이를 통해 요청과 응답 데이터의 구조를 명확히 할 수 있습니다.

3.3 제네릭을 활용한 재사용 가능한 Fetch 함수

여러 API 엔드포인트에서 사용할 수 있는 재사용 가능한 Fetch 함수를 만들어 보겠습니다:


async function fetchData<T>(url: string, options?: RequestInit): Promise<T> {
  const response = await fetch(url, options);
  if (!response.ok) {
    throw new Error(`HTTP error! status: ${response.status}`);
  }
  return response.json() as Promise<T>;
}

// 사용 예
interface Product {
  id: number;
  name: string;
  price: number;
}

fetchData<Product[]>('https://api.example.com/products')
  .then(products => {
    products.forEach(product => console.log(`${product.name}: $${product.price}`));
  })
  .catch(error => console.error(error));

interface Order {
  id: number;
  productId: number;
  quantity: number;
}

const newOrder: Order = {
  id: 1,
  productId: 100,
  quantity: 2
};

fetchData<Order>('https://api.example.com/orders', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
  },
  body: JSON.stringify(newOrder),
})
  .then(order => console.log(`Order created with ID: ${order.id}`))
  .catch(error => console.error(error));

이 제네릭 fetchData 함수는 다양한 타입의 데이터를 처리할 수 있으며, 각 요청에 대해 적절한 타입을 지정할 수 있습니다.

3.4 에러 처리 개선하기

TypeScript를 사용하여 더 구체적인 에러 처리를 구현할 수 있습니다:


class ApiError extends Error {
  constructor(public status: number, message: string) {
    super(message);
    this.name = 'ApiError';
  }
}

async function fetchWithErrorHandling<T>(url: string, options?: RequestInit): Promise<T> {
  const response = await fetch(url, options);
  if (!response.ok) {
    throw new ApiError(response.status, `HTTP error! status: ${response.status}`);
  }
  return response.json() as Promise<T>;
}

// 사용 예
fetchWithErrorHandling<User>('https://api.example.com/users/1')
  .then(user => console.log(user.name))
  .catch(error => {
    if (error instanceof ApiError) {
      console.error(`API Error ${error.status}: ${error.message}`);
    } else {
      console.error('An unexpected error occurred:', error);
    }
  });

이 예제에서는 커스텀 ApiError 클래스를 정의하여 API 관련 오류를 더 구체적으로 처리할 수 있게 했습니다.

3.5 응답 헤더 및 상태 처리

때로는 응답 본문뿐만 아니라 헤더나 상태 코드도 처리해야 할 수 있습니다:


interface ApiResponse<T> {
  data: T;
  status: number;
  headers: Headers;
}

async function fetchWithMetadata<T>(url: string, options?: RequestInit): Promise<ApiResponse<T>> {
  const response = await fetch(url, options);
  const data = await response.json() as T;
  
  return {
    data,
    status: response.status,
    headers: response.headers,
  };
}

// 사용 예
fetchWithMetadata<User>('https://api.example.com/users/1')
  .then(({ data, status, headers }) => {
    console.log(`User: ${data.name}`);
    console.log(`Status: ${status}`);
    console.log(`Content-Type: ${headers.get('Content-Type')}`);
  })
  .catch(error => console.error(error));

이 접근 방식을 사용하면 응답 데이터와 함께 상태 코드와 헤더 정보도 쉽게 접근할 수 있습니다.

3.6 인터셉터 패턴 구현하기

인터셉터 패턴을 사용하여 모든 요청과 응답을 가로채고 수정할 수 있습니다:


type RequestInterceptor = (config: RequestInit) => RequestInit;
type ResponseInterceptor = (response: Response) => Response | Promise<Response>;

class FetchClient {
  private requestInterceptors: RequestInterceptor[] = [];
  private responseInterceptors: ResponseInterceptor[] = [];

  addRequestInterceptor(interceptor: RequestInterceptor) {
    this.requestInterceptors.push(interceptor);
  }

  addResponseInterceptor(interceptor: ResponseInterceptor) {
    this.responseInterceptors.push(interceptor);
  }

  async fetch<T>(url: string, options: RequestInit = {}): Promise<T> {
    let config = { ...options };
    for (const interceptor of this.requestInterceptors) {
      config = interceptor(config);
    }

    let response = await fetch(url, config);
    for (const interceptor of this.responseInterceptors) {
      response = await interceptor(response);
    }

    if (!response.ok) {
      throw new Error(`HTTP error! status: ${response.status}`);
    }

    return response.json() as Promise<T>;
  }
}

// 사용 예
const client = new FetchClient();

client.addRequestInterceptor((config) => {
  config.headers = {
    ...config.headers,
    'Authorization': 'Bearer token123',
  };
  return config;
});

client.addResponseInterceptor(async (response) => {
  if (response.status === 401) {
    // 토큰 갱신 로직
    const newToken = await refreshToken();
    // 원래 요청 재시도
    return fetch(response.url, {
      ...response,
      headers: {
        ...response.headers,
        'Authorization': `Bearer ${newToken}`,
      },
    });
  }
  return response;
});

client.fetch<User>('https://api.example.com/users/1')
  .then(user => console.log(user.name))
  .catch(error => console.error(error));

이 패턴을 사용하면 인증 토큰 추가, 에러 처리, 로깅 등의 공통 로직을 모든 요청에 쉽게 적용할 수 있습니다.

3.7 취소 가능한 요청 구현하기

TypeScript와 함께 AbortController를 사용하여 취소 가능한 Fetch 요청을 구현할 수 있습니다:


interface CancellableFetch<T> {
  promise: Promise<T>;
  cancel: () => void;
}

function cancellableFetch<T>(url: string, options?: RequestInit): CancellableFetch<T> {
  const controller = new AbortController();
  const signal = controller.signal;

  const promise = fetch(url, { ...options, signal })
    .then(response => {
      if (!response.ok) {
        throw new Error(`HTTP error! status: ${response.status}`);
      }
      return response.json() as Promise<T>;
    });

  return {
    promise,
    cancel: () => controller.abort()
  };
}

// 사용 예
const { promise, cancel } = cancellableFetch<User>('https://api.example.com/users/1');

const timeoutId = setTimeout(() => {
  cancel();
  console.log('Request cancelled due to timeout');
}, 5000);

promise
  .then(user => {
    clearTimeout(timeoutId);
    console.log(user.name);
  })
  .catch(error => {
    if (error.name === 'AbortError') {
      console.log('Request was cancelled');
    } else {
      console.error('An error occurred:', error);
    }
  });

이 구현을 통해 장시간 실행되는 요청을 필요에 따라 취소할 수 있습니다.

이러한 다양한 기법들을 활용하면 Fetch API와 TypeScript를 결합하여 강력하고 유연한 네트워크 통신 로직을 구현할 수 있습니다. 다음 섹션에서는 이러한 기법들을 실제 프로젝트에 적용하는 방법에 대해 더 자세히 알아보겠습니다. 🚀

4. 실제 프로젝트에 적용하기 🏗️

지금까지 배운 Fetch API와 TypeScript 결합 기법을 실제 프로젝트에 적용하는 방법을 살펴보겠습니다. 이를 통해 코드의 재사용성, 유지보수성, 그리고 타입 안정성을 크게 향상시킬 수 있습니다.

4.1 API 클라이언트 구현하기

먼저, 재사용 가능한 API 클라이언트를 구현해 보겠습니다:


class ApiClient {
  private baseUrl: string;

  constructor(baseUrl: string) {
    this.baseUrl = baseUrl;
  }

  async get<T>(endpoint: string): Promise<T> {
    const response = await fetch(`${this.baseUrl}${endpoint}`);
    if (!response.ok) {
      throw new Error(`HTTP error! status: ${response.status}`);
    }
    return response.json() as Promise<T>;
  }

  async post<T>(endpoint: string, data: any): Promise<T> {
    const response = await fetch(`${this.baseUrl}${endpoint}`, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
      },
      body: JSON.stringify(data),
    });
    if (!response.ok) {
      throw new Error(`HTTP error! status: ${response.status}`);
    }
    return response.json() as Promise<T>;
  }

  // PUT, DELETE 등의 다른 메서드도 유사하게 구현할 수 있습니다.
}

// 사용 예
const api = new ApiClient('https://api.example.com');

interface User {
  id: number;
  name: string;
  email: string;
}

api.get<User[]>('/users')
  .then(users => users.forEach(user => console.log(user.name)))
  .catch(error => console.error(error));

api.post<User>('/users', { name: 'John Doe', email: 'john@example.com' })
  .then(user => console.log(`Created user with ID: ${user.id}`))
  .catch(error => console.error(error));

4.2 서비스 레이어 구현하기

API 클라이언트를 사용하여 특정 도메인의 서비스 레이어를 구현할 수 있습니다:


class UserService {
  private api: ApiClient;

  constructor(api: ApiClient) {
    this.api = api;
  }

  async getUsers(): Promise<User[]> {
    return this.api.get<User[]>('/users');
  }

  async getUserById(id: number): Promise<User> {
    return this.api.get<User>(`/users/${id}`);
  }

  async createUser(userData: Omit<User, 'id'>): Promise<User> {
    return this.api.post<User>('/users', userData);
  }
}

// 사용 예
const api = new ApiClient('https://api.example.com');
const userService = new UserService(api);

userService.getUsers()
  .then(users => users.forEach(user => console.log(user.name)))
  .catch(error => console.error(error));

userService.createUser({ name: 'Jane Doe', email: 'jane@example.com' })
  .then(user => console.log(`Created user with ID: ${user.id}`))
  .catch(error => console.error(error));

4.3 상태 관리와 통합하기

API 호출 결과를 상태 관리 라이브러리(예: Redux)와 통합할 수 있습니다:


import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';

interface UserState {
  users: User[];
  status: 'idle' | 'loading' | 'succeeded' | 'failed';
  error: string | null;
}

const initialState: UserState = {
  users: [],
  status: 'idle',
  error: null
};

export const fetchUsers = createAsyncThunk('users/fetchUsers', async () => {
  const response = await api.get<User[]>('/users');
  return response;
});

const userSlice = createSlice({
  name: 'users',
  initialState,
  reducers: {},
  extraReducers: (builder) => {
    builder
      .addCase(fetchUsers.pending, (state) => {
        state.status = 'loading';
      })
      .addCase(fetchUsers.fulfilled, (state, action) => {
        state.status = 'succeeded';
        state.users = action.payload;
      })
      .addCase(fetchUsers.rejected, (state, action) => {
        state.status = 'failed';
        state.error = action.error.message || null;
      });
  },
});

export default userSlice.reducer;

// 컴포넌트에서 사용
import { useDispatch, useSelector } from 'react-redux';
import { fetchUsers } from './userSlice';

function UserList() {
  const dispatch = useDispatch();
  const { users, status, error } = useSelector((state: RootState) => state.users);

  useEffect(() => {
    if (status === 'idle') {
      dispatch(fetchUsers());
    }
    }, [status, dispatch]);

  if (status === 'loading') {
    return <div>Loading...</div>;
  }

  if (status === 'failed') {
    return <div>Error: {error}</div>;
  }

  return (
    <ul>
      {users.map(user => (
        <li key="{user.id}">{user.name}</li>
      ))}
    </ul>
  );
}

4.4 테스트 작성하기

TypeScript를 사용하면 API 호출에 대한 단위 테스트와 통합 테스트를 더 쉽게 작성할 수 있습니다:


import { ApiClient } from './apiClient';
import { UserService } from './userService';

// Mock ApiClient
jest.mock('./apiClient');

describe('UserService', () => {
  let apiClient: jest.Mocked<apiclient>;
  let userService: UserService;

  beforeEach(() => {
    apiClient = new ApiClient('') as jest.Mocked<apiclient>;
    userService = new UserService(apiClient);
  });

  test('getUsers should return an array of users', async () => {
    const mockUsers: User[] = [
      { id: 1, name: 'John Doe', email: 'john@example.com' },
      { id: 2, name: 'Jane Doe', email: 'jane@example.com' },
    ];

    apiClient.get.mockResolvedValue(mockUsers);

    const users = await userService.getUsers();
    expect(users).toEqual(mockUsers);
    expect(apiClient.get).toHaveBeenCalledWith('/users');
  });

  test('createUser should return a new user', async () => {
    const newUser = { name: 'New User', email: 'new@example.com' };
    const createdUser: User = { id: 3, ...newUser };

    apiClient.post.mockResolvedValue(createdUser);

    const user = await userService.createUser(newUser);
    expect(user).toEqual(createdUser);
    expect(apiClient.post).toHaveBeenCalledWith('/users', newUser);
  });
});
</apiclient></apiclient>

4.5 에러 처리 및 로깅

TypeScript를 사용하여 더 강력한 에러 처리 및 로깅 시스템을 구현할 수 있습니다:


class ApiError extends Error {
  constructor(public status: number, message: string) {
    super(message);
    this.name = 'ApiError';
  }
}

class Logger {
  static error(message: string, error?: Error) {
    console.error(`[ERROR] ${message}`, error);
    // 여기에 외부 로깅 서비스로 에러를 보내는 로직을 추가할 수 있습니다.
  }

  static info(message: string) {
    console.log(`[INFO] ${message}`);
  }
}

class ApiClient {
  // ... 이전 코드 ...

  private async request<t>(url: string, options: RequestInit = {}): Promise<t> {
    try {
      const response = await fetch(url, options);
      if (!response.ok) {
        throw new ApiError(response.status, `HTTP error! status: ${response.status}`);
      }
      return response.json() as Promise<t>;
    } catch (error) {
      if (error instanceof ApiError) {
        Logger.error(`API Error: ${error.message}`, error);
      } else {
        Logger.error('Unexpected error occurred', error as Error);
      }
      throw error;
    }
  }

  async get<t>(endpoint: string): Promise<t> {
    return this.request<t>(`${this.baseUrl}${endpoint}`);
  }

  async post<t>(endpoint: string, data: any): Promise<t> {
    return this.request<t>(`${this.baseUrl}${endpoint}`, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
      },
      body: JSON.stringify(data),
    });
  }
}

// 사용 예
const api = new ApiClient('https://api.example.com');

api.get<user>('/users')
  .then(users => {
    Logger.info(`Retrieved ${users.length} users`);
    users.forEach(user => console.log(user.name));
  })
  .catch(error => {
    if (error instanceof ApiError) {
      console.error(`API Error ${error.status}: ${error.message}`);
    } else {
      console.error('An unexpected error occurred:', error);
    }
  });
</user></t></t></t></t></t></t></t></t></t>

4.6 환경 설정 및 타입 정의 파일

프로젝트의 환경 설정을 위해 TypeScript 설정 파일(tsconfig.json)과 타입 정의 파일을 활용할 수 있습니다:


// tsconfig.json
{
  "compilerOptions": {
    "target": "es6",
    "module": "esnext",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "lib": ["dom", "dom.iterable", "esnext"],
    "allowJs": true,
    "allowSyntheticDefaultImports": true,
    "moduleResolution": "node",
    "resolveJsonModule": true,
    "isolatedModules": true,
    "noEmit": true,
    "jsx": "react-jsx"
  },
  "include": ["src"]
}

// types.d.ts
declare module '*.svg' {
  const content: any;
  export default content;
}

declare module '*.png' {
  const content: any;
  export default content;
}

interface User {
  id: number;
  name: string;
  email: string;
}

interface ApiResponse<t> {
  data: T;
  message: string;
  status: number;
}
</t>

이러한 설정과 타입 정의를 통해 프로젝트 전반에 걸쳐 일관된 타입 체크와 자동 완성 기능을 활용할 수 있습니다.

4.7 성능 최적화

TypeScript를 사용하여 API 호출의 성능을 최적화할 수 있습니다. 예를 들어, 캐싱 메커니즘을 구현할 수 있습니다:


class CacheManager<t> {
  private cache: Map<string data: t timestamp: number> = new Map();
  private ttl: number;

  constructor(ttl: number = 60000) { // 기본 TTL: 1분
    this.ttl = ttl;
  }

  set(key: string, data: T): void {
    this.cache.set(key, { data, timestamp: Date.now() });
  }

  get(key: string): T | null {
    const cached = this.cache.get(key);
    if (!cached) return null;

    if (Date.now() - cached.timestamp > this.ttl) {
      this.cache.delete(key);
      return null;
    }

    return cached.data;
  }

  clear(): void {
    this.cache.clear();
  }
}

class ApiClient {
  private cache: CacheManager<any>;

  constructor(baseUrl: string) {
    this.baseUrl = baseUrl;
    this.cache = new CacheManager();
  }

  async get<t>(endpoint: string, useCache: boolean = true): Promise<t> {
    const cacheKey = `GET:${endpoint}`;
    
    if (useCache) {
      const cachedData = this.cache.get(cacheKey);
      if (cachedData) return cachedData as T;
    }

    const data = await this.request<t>(`${this.baseUrl}${endpoint}`);
    this.cache.set(cacheKey, data);
    return data;
  }

  // ... 다른 메서드들 ...
}

// 사용 예
const api = new ApiClient('https://api.example.com');

async function fetchUsersTwice() {
  console.time('First call');
  await api.get<user>('/users');
  console.timeEnd('First call');

  console.time('Second call (cached)');
  await api.get<user>('/users');
  console.timeEnd('Second call (cached)');
}

fetchUsersTwice();
</user></user></t></t></t></any></string></t>

이러한 최적화 기법을 통해 반복적인 API 호출의 성능을 크게 향상시킬 수 있습니다.

이렇게 Fetch API와 TypeScript를 결합하여 실제 프로젝트에 적용하면, 코드의 안정성과 유지보수성이 크게 향상됩니다. 또한, 개발자 경험도 개선되어 생산성이 높아집니다. 다음 섹션에서는 이러한 기술을 사용할 때의 모범 사례와 주의사항에 대해 알아보겠습니다. 🚀

5. 모범 사례와 주의사항 🏆

Fetch API와 TypeScript를 함께 사용할 때 고려해야 할 몇 가지 모범 사례와 주의사항에 대해 알아보겠습니다.

5.1 타입 안전성 유지하기

API 응답의 타입을 정확히 정의하고, 가능한 한 any 타입 사용을 피하세요:


// 좋은 예
interface User {
  id: number;
  name: string;
  email: string;
}

async function getUser(id: number): Promise<user> {
  const response = await fetch(`/api/users/${id}`);
  return response.json() as Promise<user>;
}

// 나쁜 예
async function getUser(id: number): Promise<any> {
  const response = await fetch(`/api/users/${id}`);
  return response.json();
}
</any></user></user>

5.2 에러 처리 철저히 하기

네트워크 오류, API 오류 등 다양한 종류의 오류를 적절히 처리하세요:


async function fetchData<t>(url: string): Promise<t> {
  try {
    const response = await fetch(url);
    if (!response.ok) {
      throw new Error(`HTTP error! status: ${response.status}`);
    }
    return await response.json() as T;
  } catch (error) {
    if (error instanceof TypeError) {
      console.error('Network error:', error);
    } else {
      console.error('Other error:', error);
    }
    throw error;
  }
}
</t></t>

5.3 비동기 코드 관리하기

async/await를 사용하여 비동기 코드를 동기 코드처럼 작성하고, Promise 체이닝을 최소화하세요:


// 좋은 예
async function fetchUserAndPosts(userId: number) {
  try {
    const user = await fetchUser(userId);
    const posts = await fetchPosts(userId);
    return { user, posts };
  } catch (error) {
    console.error('Error fetching user and posts:', error);
    throw error;
  }
}

// 나쁜 예
function fetchUserAndPosts(userId: number) {
  return fetchUser(userId)
    .then(user => {
      return fetchPosts(userId)
        .then(posts => {
          return { user, posts };
        });
    })
    .catch(error => {
      console.error('Error fetching user and posts:', error);
      throw error;
    });
}

5.4 API 클라이언트 추상화하기

Fetch API를 직접 사용하기보다는 추상화된 API 클라이언트를 만들어 사용하세요:


class ApiClient {
  private baseUrl: string;

  constructor(baseUrl: string) {
    this.baseUrl = baseUrl;
  }

  async get<t>(endpoint: string): Promise<t> {
    const response = await fetch(`${this.baseUrl}${endpoint}`);
    if (!response.ok) {
      throw new Error(`HTTP error! status: ${response.status}`);
    }
    return response.json() as Promise<t>;
  }

  // POST, PUT, DELETE 등의 메서드도 유사하게 구현
}

const api = new ApiClient('https://api.example.com');
const users = await api.get<user>('/users');
</user></t></t></t>

5.5 환경 변수 활용하기

API URL, 인증 토큰 등의 민감한 정보는 환경 변수를 통해 관리하세요:


// .env 파일
API_BASE_URL=https://api.example.com
API_KEY=your-secret-api-key

// TypeScript 코드
const apiBaseUrl = process.env.API_BASE_URL;
const apiKey = process.env.API_KEY;

const api = new ApiClient(apiBaseUrl, apiKey);

5.6 타입 가드 사용하기

API 응답을 처리할 때 타입 가드를 사용하여 타입 안전성을 높이세요:


interface SuccessResponse<t> {
  status: 'success';
  data: T;
}

interface ErrorResponse {
  status: 'error';
  message: string;
}

type ApiResponse<t> = SuccessResponse<t> | ErrorResponse;

function isSuccessResponse<t>(response: ApiResponse<t>): response is SuccessResponse<t> {
  return response.status === 'success';
}

async function fetchData<t>(url: string): Promise<t> {
  const response = await fetch(url);
  const data = await response.json() as ApiResponse<t>;

  if (isSuccessResponse(data)) {
    return data.data;
  } else {
    throw new Error(data.message);
  }
}
</t></t></t></t></t></t></t></t></t>

5.7 캐싱 전략 구현하기

반복적인 API 호출을 최소화하기 위해 적절한 캐싱 전략을 구현하세요:


class ApiClient {
  private cache: Map<string data: any timestamp: number> = new Map();
  private cacheTTL: number = 60000; // 1분

  async get<t>(url: string, forceRefresh: boolean = false): Promise<t> {
    const cacheKey = `GET:${url}`;
    const cachedData = this.cache.get(cacheKey);

    if (!forceRefresh && cachedData && Date.now() - cachedData.timestamp < this.cacheTTL) {
      return cachedData.data as T;
    }

    const response = await fetch(url);
    const data = await response.json() as T;

    this.cache.set(cacheKey, { data, timestamp: Date.now() });
    return data;
  }
}
</t></t></string>

5.8 코드 분할 및 모듈화

API 관련 코드를 적절히 분할하고 모듈화하여 유지보수성을 높이세요:


// api/client.ts
export class ApiClient {
  // API 클라이언트 구현
}

// api/user.ts
import { ApiClient } from './client';

export class UserApi {
  constructor(private client: ApiClient) {}

  async getUsers(): Promise<user> {
    return this.client.get<user>('/users');
  }

  async createUser(user: Omit<user>): Promise<user> {
    return this.client.post<user>('/users', user);
  }
}

// api/index.ts
import { ApiClient } from './client';
import { UserApi } from './user';

const apiClient = new ApiClient('https://api.example.com');
export const userApi = new UserApi(apiClient);
</user></user></user></user></user>

5.9 테스트 작성하기

API 호출에 대한 단위 테스트와 통합 테스트를 작성하세요:


import { ApiClient } from './api/client';
import { UserApi } from './api/user';

jest.mock('./api/client');

describe('UserApi', () => {
  let apiClient: jest.Mocked<apiclient>;
  let userApi: UserApi;

  beforeEach(() => {
    apiClient = new ApiClient('') as jest.Mocked<apiclient>;
    userApi = new UserApi(apiClient);
  });

  test('getUsers should return an array of users', async () => {
    const mockUsers: User[] = [{ id: 1, name: 'John Doe' }];
    apiClient.get.mockResolvedValue(mockUsers);

    const users = await userApi.getUsers();
    expect(users).toEqual(mockUsers);
    expect(apiClient.get).toHaveBeenCalledWith('/users');
  });
});
</apiclient></apiclient>

5.10 보안 고려사항

API 호출 시 보안을 고려하세요. HTTPS 사용, 적절한 인증 및 권한 부여, CSRF 보호 등을 구현하세요:


class ApiClient {
  constructor(private baseUrl: string, private apiKey: string) {}

  private async request<t>(url: string, options: RequestInit = {}): Promise<t> {
    const response = await fetch(`${this.baseUrl}${url}`, {
      ...options,
      headers: {
        ...options.headers,
        'Authorization': `Bearer ${this.apiKey}`,
        'X-CSRF-Token': this.getCsrfToken(),
      },
    });

    if (!response.ok) {
      throw new Error(`HTTP error! status: ${response.status}`);
    }

    return response.json() as Promise<t>;
  }

  private getCsrfToken(): string {
    // CSRF 토큰을 가져오는 로직 구현
    return 'csrf-token';
  }

  // 다른 메서드들...
}
</t></t></t>

이러한 모범 사례와 주의사항을 따르면 Fetch API와 TypeScript를 사용한 프로젝트의 품질과 유지보수성을 크게 향상시킬 수 있습니다. 항상 보안, 성능, 그리고 코드 품질을 염두에 두고 개발하세요. 🛡️💻🚀

6. 결론 🎉

지금까지 Fetch API와 TypeScript를 결합하여 사용하는 방법에 대해 상세히 알아보았습니다. 이 두 기술의 결합은 현대 웹 개발에서 강력한 시너지를 발휘합니다.

6.1 주요 이점 요약

  • 타입 안전성: TypeScript를 통해 API 요청과 응답의 타입을 명확히 정의함으로써 런타임 오류를 줄이고 코드의 안정성을 높일 수 있습니다.
  • 개발자 경험 향상: 자동 완성, 타입 추론 등 IDE의 강력한 기능을 활용할 수 있어 개발 생산성이 향상됩니다.
  • 코드 품질 개선: 타입 시스템을 통해 코드의 의도를 명확히 표현할 수 있어 가독성과 유지보수성이 향상됩니다.
  • 확장성: 잘 설계된 API 클라이언트와 타입 정의를 통해 프로젝트의 확장성을 높일 수 있습니다.

6.2 앞으로의 발전 방향

웹 개발 생태계는 계속해서 진화하고 있습니다. Fetch API와 TypeScript의 결합은 현재 최선의 실천 방법 중 하나이지만, 앞으로 더 나은 기술과 패턴이 등장할 수 있습니다. 개발자로서 우리는 항상 새로운 기술과 방법론에 대해 열린 자세를 가져야 합니다.

6.3 실무 적용 시 고려사항

실제 프로젝트에 이 기술을 도입할 때는 다음 사항을 고려해야 합니다:

  • 팀의 기술 숙련도와 학습 곡선
  • 프로젝트의 규모와 복잡성
  • 기존 코드베이스와의 통합 가능성
  • 성능 요구사항
  • 보안 고려사항

6.4 마무리

Fetch API와 TypeScript의 결합은 강력하고 유연한 웹 애플리케이션 개발을 가능하게 합니다. 이 가이드를 통해 여러분이 이 기술을 효과적으로 활용할 수 있기를 바랍니다. 항상 최신 트렌드를 주시하고, 지속적으로 학습하며, 더 나은 코드를 작성하기 위해 노력해 주세요.

웹 개발의 세계는 끊임없이 변화하고 있습니다. 우리는 이 변화의 물결에 올라타 더 나은 웹을 만들어 나갈 수 있습니다. Fetch API와 TypeScript는 그 여정의 훌륭한 동반자가 될 것입니다. 행운을 빕니다, 그리고 즐거운 코딩 되세요! 🚀💻🌟