본문 바로가기

Dev/[Javascript]

Data Fetching 코드를 공통 모듈로 추상화 해보기 with React

반응형

 

INTRO


 

현재의 Javascript 진영의 Data Fetching 방법은 AJAX 베이스로 정말 다양하게 Wrapping되어있는 듯.

AJAX이전과 AJAX에 대한 간략한 내용은 아래 포스팅에서 다룬 적이 있다.

2023.03.08 - [Dev/[Javascript]] - [Javascript] XMLHttpRequest와 Ajax에 대한 이야기

 

[Javascript] XMLHttpRequest와 Ajax에 대한 이야기

0. 의문의 시작 최근 같은 전공의 대학 동기들을 만나 술자리를 가지던 중 Ajax와 XMLHttpRequest에 대한 대화를 나누게 되었고, 아래 내용에 대해선 모두 의견의 차이가 없었다. Ajax 는 비동기 통신을

rangsub.tistory.com

 

이 글에서는 이런 다양한 방법으로 사용할 수 있는 Data Fetching 코드를

어떻게 추상화하는것이 좋은가?에 대한 내용을 다루고자 한다. 

 

 


 

들어가며 (feat 추상화? 필요한가?)

- 일반적으로 소프트웨어를 개발할 때, 추상화의 장/단점을 아래와 같이 이야기한다.

 

장점:
재사용성: 공통된 로직을 재사용할 수 있다.
유지보수성: 논리적인 레벨에서 코드 분리가 가능하므로 유지보수 시 필요한 부분만 수정하면 됨.

이외에도 가독성,확장성이 좋아지고 테스트가 용이해짐.

 

단점 :

개발 비용 : 초기 보일러플레이트 코드가 필요.

잘못된 추상화 :장점이 단점이됨;

이외에도 과도한 추상화로 인한 성능 저하가 단점이 될 수 있을듯.

 

- 개인적으로는 프레임워크나 라이브러리가 해주지 못하는 빈틈을,

프로젝트의 성격에 잘 맞춰진 추상화된 코드가 잘 매꿔주는것이 가장 좋은 방법이라고 생각.

 

- 따라서 이 글의 주제인 Data Fetching코드도 fetch, axios, tanstack-query가 해주지 못하는 부분들을

추상화된 코드로 잘 매꾸는 방법이 될 것 같다.

 

- 프로젝트마다 성격이 다르겠지만,  프론트엔드/백엔드 불문하고 Data Fetching 케이스에서는 추상화가 100% 필요하다고 생각해도 과언이 아니다. 공용화해서 사용해야 좋음.

 

- 자바나 코틀린 진영에서는 추상화 - 의존성 주입(DI) 형태로 제어의 역전(IoC) 원칙을 지키며 개발하는 방법이 거의 정석으로 여겨짐. 그만큼 개발자에게 유익함

 

- 이 글에서는 브라우저의 fetch API를 통해 REST API에 관련된 케이스를 다룰 예정이나 다른 데이터소스(웹소켓, DB연동 등) 에서도 추상화를 적절히 하는게 좋다.

 

추상화 0단계

- 추상화가 되어있지 않은 경우, 하나의 리액트 컴포넌트 안에 data fetch로직의 구현과, fetch() API의 구현이 전부 포함된다.

 

- 아래의 리액트 코드로 좀 더 살펴보자. 추상화가 되어있지 않은 코드이다.

import { useState, useEffect } from "react";

interface Post {
  userId: number;
  id: number;
  title: string;
  body: string;
}
const baseURL = "https://jsonplaceholder.typicode.com";

export const Phase0Page = () => {
  const [post, setPost] = useState<Post>();

  useEffect(() => {
    const fetchPost = async () => {
      try {
        const response = await fetch(baseURL + "/posts/1");
        if (!response.ok) {
          throw new Error("Network response was not ok " + response.statusText);
        }
        const data = await response.json();
        setPost(data);
        console.log("GET response data:", data);
      } catch (error) {
        console.error("There was a problem with the fetch operation:", error);
      }
    };

    fetchPost();
  }, []);

  return (
    <div>
      <p>userID : {post?.userId} </p>
      <p>ID : {post?.id} </p>
      <p>Title : {post?.title} </p>
      <p>Body : {post?.body} </p>
    </div>
  );
};

 

크게 아래 3가지 단점이 보인다.

 

1. 재사용성. 이 호출 로직을 다른 컴포넌트에서도 사용해야할 수 있다. 

2. 가독성. 다른 메서드를 사용하는 로직을 추가 구현해야한다면? 

3. 유지보수성. 해당 API Endpoint가 변경된다면? BaseURL이 변경된다면? 이와 같은 코드가 있는 여러 군데를 수정해야한다. 

 

추상화 1단계

- 자체적으로 1단계라고 정의했다. 

- 1단계는 데이터 호출부와, 비즈니스로직 처리부분(서비스 단계라고 부르자) 을 단순히 분리하는 것이다.

// src/pages/Phase1Page.tsx
import { useState, useEffect } from "react";
import { PostService } from "../api/postServicePhase1";
import type { Post } from "../api/postServicePhase1";

export const Phase1Page = () => {
  const [post, setPost] = useState<Post>();

  useEffect(() => {
    const fetchData = async () => {
      const postService = new PostService();
      try {
        const response = await postService.fetchPost();
        setPost(response);
        console.log("GET response data:", response);
      } catch (error) {
        console.error("There was a problem with the fetch operation:", error);
      }
    };

    fetchData();
  }, []);

  return (
    <div>
      <p>userID : {post?.userId} </p>
      <p>ID : {post?.id} </p>
      <p>Title : {post?.title} </p>
      <p>Body : {post?.body} </p>
    </div>
  );
};

// src/service/postServicePhase1.ts
export interface Post {
  userId: number;
  id: number;
  title: string;
  body: string;
}

const baseURL = "https://jsonplaceholder.typicode.com";

class PostService {
  async fetchPost(): Promise<Post> {
    try {
      const response = await fetch(baseURL + "/posts/1");
      if (!response.ok) {
        throw new Error("Network response was not ok " + response.statusText);
      }
      const data = await response.json();
      console.log("GET response data:", data);
      return data;
    } catch (error) {
      console.error("There was a problem with the fetch operation:", error);
      throw error;
    }
  }
// 비즈니스 로직 구현
}

export default PostService;

 

추상화 0단계의 문제들이 대부분 해결된 듯 보인다. 

 

1. 재사용성. 파일이 분리되면서, fetchPost 로직이 재사용 가능하게 되었다. 따라서 다른 컴포넌트에서도 사용이 가능하다. 

2. 가독성. 또한 다른 메서드도 Post에 관련된것이라면 src/api/post.ts 파일 아래 나열하면 된다. Post에 관련된게 아니라면 api폴더 내 파일을 추가하여 작성하면 된다.

3. 유지보수성. API Endpoint / BaseURL 의 변경이 있다면 src/api 폴더 아래의 내용만 수정하면 된다. 컴포넌트단의 소스코드는 건드릴 필요가 없다.

 

- 소규모 프로젝트에서는 이 정도로만 분리해도 사용에 큰 문제가 없을것 같다.

- 그러나 규모가 조금 커져 Post에 관련된 API만 수 십개가 된다고 해보자.  또 불편한 점이 보인다.

export class PostService {
  async fetchPost(): Promise<Post> {
    try {
      const response = await fetch(baseURL + "/posts/1");
      if (!response.ok) {
        throw new Error("Network response was not ok " + response.statusText);
      }
      const data = await response.json();
      console.log("GET response data:", data);
      return data;
    } catch (error) {
      console.error("There was a problem with the fetch operation:", error);
      throw error;
    }
  }

  async createPost(post: Post): Promise<Post> {
    try {
      const response = await fetch(baseURL + "/posts", {
        method: "POST",
        headers: {
          "Content-Type": "application/json",
        },
        body: JSON.stringify(post),
      });
      if (!response.ok) {
        throw new Error("Network response was not ok " + response.statusText);
      }
      const data = await response.json();
      console.log("POST response data:", data);
      return data;
    } catch (error) {
      console.error("There was a problem with the create operation:", error);
      throw error;
    }
  }

  async updatePost(postId: number, post: Post): Promise<Post> {
    try {
      const response = await fetch(baseURL + "/posts/" + postId, {
        method: "PUT",
        headers: {
          "Content-Type": "application/json",
        },
        body: JSON.stringify(post),
      });
      if (!response.ok) {
        throw new Error("Network response was not ok " + response.statusText);
      }
      const data = await response.json();
      console.log("PUT response data:", data);
      return data;
    } catch (error) {
      console.error("There was a problem with the update operation:", error);
      throw error;
    }
  }

  async deletePost(postId: number): Promise<void> {
    try {
      const response = await fetch(baseURL + "/posts/" + postId, {
        method: "DELETE",
      });
      if (!response.ok) {
        throw new Error("Network response was not ok " + response.statusText);
      }
      console.log("DELETE successful");
    } catch (error) {
      console.error("There was a problem with the delete operation:", error);
      throw error;
    }
  }
  
  
  ...
  ...
  ...

- 유지보수성 측면에서

HTTP status code 처리부의 중복,

fetch메서드의 인자값을 계속 수동으로 넣어줘야 하는 부분(만약 같은 헤더가 모든 요청에 필요하다면..?),

메서드의 반환 타입을 계속 지정해줘야 하는 부분등이 단점으로 남게 된다.

 

추상화 2단계

- 이제 fetch API를 공용 클래스로 분리할것이다.

- 클래스 개념은 ES6에서 새로 등장. 이전에는 자바스크립트단에서 클래스를 지원하지 않았다.(개발자들은 비슷하게 기능을 구현해서 썼다)

- 객체지향 언어에서 주로 다루는 개념을 함수형 언어에서 다룰 필요가 굳이 없다가, 확장성을 위해 ES6에서 도입한걸로 알고 있음.

 

// src/service/httpClient.ts

interface HTTPInstance {
  get<T>(url: string, config?: RequestInit): Promise<T>;
  post<T>(url: string, data?: unknown, config?: RequestInit): Promise<T>;
  put<T>(url: string, data?: unknown, config?: RequestInit): Promise<T>;
  delete<T>(url: string, config?: RequestInit): Promise<T>;
}

class HttpClient {
  public http: HTTPInstance;

  private baseURL: string;

  private headers: Record<string, string>;

  constructor() {
    this.baseURL = `https://jsonplaceholder.typicode.com`;
    this.headers = {
      csrf: "token",
      Referer: this.baseURL,
    };

    this.http = {
      get: this.get.bind(this),
      post: this.post.bind(this),
      put: this.put.bind(this),
      delete: this.delete.bind(this),
    };
  }

  private async request<T = unknown>(method: string, url: string, data?: unknown, config?: RequestInit): Promise<T> {
    try {
      const response = await fetch(this.baseURL + url, {
        method,
        headers: {
          ...this.headers,
          "Content-Type": "application/json",
          ...config?.headers,
        },
        credentials: "include",
        body: data ? JSON.stringify(data) : undefined,
        ...config,
      });

      if (!response.ok) {
        throw new Error("Network response was not ok");
      }

      const responseData: T = await response.json();
      return responseData;
    } catch (error) {
      console.error("Error:", error);
      throw error;
    }
  }

  private get<T>(url: string, config?: RequestInit): Promise<T> {
    return this.request<T>("GET", url, undefined, config);
  }

  private post<T>(url: string, data?: unknown, config?: RequestInit): Promise<T> {
    return this.request<T>("POST", url, data, config);
  }

  private put<T>(url: string, data?: unknown, config?: RequestInit): Promise<T> {
    return this.request<T>("PUT", url, data, config);
  }

  private delete<T>(url: string, config?: RequestInit): Promise<T> {
    return this.request<T>("DELETE", url, undefined, config);
  }
}

export default HttpClient;

 

- 생성자를 통해 BaseURL, 공통 헤더를 미리 정의할 수 있게 되었다.

- HTTP status code 부분도 request라는 공통 메서드에 정의해둘 수 있다.

- 메서드의 반환 타입은 제네릭을 사용하여 다른 반환 타입에 대한 구현에 추가적인  코드가 필요가 없어졌고, 추론이 용이해짐.

 

 

- Axios의 create메서드를 사용해 본 개발자라면 여기서 비슷한 점을 볼 수 있다.

const httpClient = axios.create({
  baseURL: 'https://some-domain.com/api/',
  timeout: 1000,
  headers: {'X-Custom-Header': 'foobar'}
});

 

- 생성자에서 baseURL, 헤더 삽입등의 코드가 역할을 create 메서드의 인자로 넣어주는 형태와 비슷하다.

- 또한 request메서드는 axios interceptor과 비슷하다. 

- 이런 부분을 편리하게 wrapping한 라이브러리.(하지만 용량이 11KB정도로 크다. 물론 이정도 용량이 번들에 추가되는 것 요즘 웹서버 사양, 브라우저 사양으로는 문제될 게 없다고 본다.)

 

- 위에서 정의한 공용 클래스는 꼭 클래스가 아니어도 된다.

- 기본 원리를 알고 있는 상태에서 이런 단순 변환작업은 Chat GPT의 도움을 받으면 좋다.

- 클래스형<->함수형으로 코드 변환을 잘 해준다. 이 두 형태의 차이를 공부하기도 괜찮은 듯. (여기서 리액트의 클래스형 컴포넌트, 함수형 컴포넌트밖에 떠오르지 않는 개발자는 아직 주니어다.)

 

 

 

- 다시 공용 클래스로 돌아가서, 이제 이 클래스를 사용하는 서비스 코드를 작성해보자.

- 가독성이 좋아졌고, 반복되는 코드가 최소화된것을 볼 수 있다.

import HttpClient from "./httpClient";

export interface Post {
  userId: number;
  id: number;
  title: string;
  body: string;
}

class PostService {
  private client: HttpClient;

  constructor(client: HttpClient) {
    this.client = client;
  }

  async getPost(postId: number): Promise<Post> {
    return this.client.http.get<Post>(`/posts/${postId}`);
  }

  async createPost(postData: Partial<Post>): Promise<Post> {
    return this.client.http.post<Post>(`/posts`, postData);
  }

  async updatePost(postId: number, postData: Partial<Post>): Promise<Post> {
    return this.client.http.put<Post>(`/posts/${postId}`, postData);
  }

  async deletePost(postId: number): Promise<void> {
    return this.client.http.delete<void>(`/posts/${postId}`);
  }
}

export default PostService;

 

- 이를 사용하는 컴포넌트. 11, 12라인을 보면 client 의존성을 가져와 PostService에 넣어주는 형태로 구현하였는데,

이는 PostService 와 fetchAPI 모듈과의 분리를 위해서이다.

 

- PostService 안에 fetchAPI 를 종속시키고, PostService 만 호출하는 방법도 잘못된 방법은 아니다.

import { useState, useEffect } from "react";
import PostService from "../api/PostServicePhase2";
import type { Post } from "../api/PostServicePhase2";
import HttpClient from "../api/httpClient";

export const Phase2Page = () => {
  const [post, setPost] = useState<Post>();

  useEffect(() => {
    const fetchData = async () => {
      const client = new HttpClient();
      const postService = new PostService(client);
      try {
        const response = await postService.getPost(1);
        setPost(response);
        console.log("GET response data:", response);
      } catch (error) {
        console.error("There was a problem with the fetch operation:", error);
      }
    };

    fetchData();
  }, []);

  return (
    <div>
      <p>userID : {post?.userId} </p>
      <p>ID : {post?.id} </p>
      <p>Title : {post?.title} </p>
      <p>Body : {post?.body} </p>
    </div>
  );
};

 

마무리

- 복잡도가 증가할수록, 추상화가 필요하다는 내용을 리액트, fetch API를 통해 정리해봤다.

- 추상화 이후 Service 수준을  tanstack-query로 한번 더 감싸서 컴포넌트에서 사용하는 예제도 있다.

- 또한 full stack 형태로 사용하는 경우, 위의 예제처럼 http Client 를 추상화하고, DB 접근 부분도 추상화하여 이 두 모듈을 하나의 Repository 형태로 모아서 사용하는 패턴도 있다.

 

- 본문에 사용한 예제 코드는 아래 github repo에서 확인할 수 있다.

https://github.com/chance-up/abstraction-test-ts

 

 

-퍼가실 때는 출처를 꼭 같이 적어서 올려주세요!

 

반응형