[Notion] 서버 요청 로직 개선하기

차차·2024년 3월 10일
2
post-thumbnail

지금까지 바닐라로 구현한 Store와 Router 를 활용하여, 이전에 프로그래머스 데브코스 때 진행했던 노션 클론 프로젝트를 다시 해보았다! 고민을 깊게 했던 부분들을 기록해보고자 한다. 😉

↗️ 다시 만든 Notion 바로가기

노션 프로젝트를 다시 하기에 앞서, 서버데이터를 요청하는 부분이 사실 제일 막막했다. 데이터 요청 이후에 연쇄적으로 이루어 져야 하는 것들이 꽤나 복잡했기 때문이다.

왼쪽 문서 목록을 디렉토리 컴포넌트, 오른쪽 편집 부분을 에디터 컴포넌트로 나누면 서버 요청 이후 컴포넌트 내에서 업데이트되어야 하는 부분은 다음과 같다.


  • 전체 문서 불러오기
    1. 서버 요청
    2. 디렉토리 - 문서 목록
  • 하나의 문서 불러오기
    1. 서버 요청
    2. 디렉토리 - 현재 선택된 문서
    3. 에디터 - 문서 내용
  • 문서 생성하기
    1. 서버 요청
    2. 디렉토리 - 문서 목록
    3. 에디터 - 문서 내용
  • 문서 수정하기
    1. 서버 요청
    2. 디렉토리 - 현재 선택된 문서의 제목
  • 문서 삭제하기
    1. 서버 요청
    2. 디렉토리 - 문서 목록
    3. 에디터 - 문서 내용 (편집중일때)
  • 문서 열고닫기
    1. 디렉토리 - 문서 토글 여부
    2. 스토리지 - 토글 여부 저장

서버 요청 하나당 이후에 실행되어야 하는 로직이 많다..!
기존의 코드에서는, request 함수로만 서버 요청을 공통화하고 이걸 계속 써먹는 것으로 보였다.

export const request = async (url, options = {}) => {
  try {
    const res = await fetch(`${API_END_POINT}${url}`, {
      ...options,
      headers: {
        "Content-Type": "application/json",
        "x-username": USER_NAME,
      },
    });
    if (res.ok) {
      return await res.json();
	  }
    throw new Error("API 오류");
  } catch (error) {
    alert(error.message);
  }
};

export const requestDeleteDocument = async id => {
  await request(`/documents/${id}`, { method: "DELETE" });
};
export const requestGetDocumentList = async () => {
  return await request("/documents");
};

이렇게 되면 notion api 뿐만 아니라 다른 api 로의 요청이 필요할 때 request 함수를 하나 더 만들어야 한다는 단점이 있다.

또한, 각각의 요청에 개별적인 에러처리를 달아주기 어렵다. request 함수 내에서 일괄적으로 에러를 처리하기 때문이다.

서버에서 데이터를 받아서 활용하는 이 후의 메소드들에게 async 를 붙여줘야 한다는 문제도 있다. 그렇게 되면 각각의 메소드가 실행되는 순서를 추적하기 어려울 것 같다. 심각하면 render() 와 같은 공통적인 메소드에게도 async 를 붙여줘야 하는 대참사가 벌어질지도...

따라서 각각의 api 를 추상화한 클래스인 ApiClient 와, 서버 요청을 공통적으로 처리하는 메소드 makeRequest 를 구현하였다.


ApiClient

axios/instance 에서 영감을 받은 클래스이다.

API 마다 하나의 인스턴스를 생성(createApiClient)해서 활용할 수 있고, HTTP 메소드에 맞게 호출해서 사용하면 되도록 했다. (get, post, delete, put)

class ApiClient {
  baseUrl;
  baseOptions;

  constructor(baseUrl: string, options: RequestInit) {
    this.baseUrl = baseUrl;
    this.baseOptions = options;
  }

  async request(endpoint: string, options?: RequestInit): Promise<Response> {
    try {
      const response = await fetch(`${this.baseUrl}${endpoint}`, {
        ...this.baseOptions,
        ...options,
      });
      return response;
    } catch (error) {
      throw new Error('API ERROR');
    }
  }

  async get(endpoint: string, body?: any, options?: RequestInit) {
    return this.request(endpoint, {
      ...options,
      body: JSON.stringify(body),
      method: 'GET',
    });
  }

  // 생략 (post, put, delete)
}

const createApiClient = (baseUrl: string, options: RequestInit) =>
  new ApiClient(baseUrl, options);

export default createApiClient;

사용 예시

// ApiClient 인스턴스 생성
const notionApiClient = createApiClient(
  import.meta.env.NOTION_BASE_URL,
  {
    headers: {
      "x-username": import.meta.env.USERNAME,
      "Content-Type": "application/json",
    },
  },
);

const notionApi = {
  getAll: async () => await notionApiClient.get("/documents"),
	// ...
};

makeRequest

이 메소드는 요청 옵션들(onSuccess, select 등)을 받아서 데이터를 요청하고, 그에 따라 콜백 처리나 서버 데이터를 컴포넌트에 맞게 가공하는 등의 연쇄적으로 처리해야 하는 작업들을 담당한다.

select 옵션이 은근 유용했는데, 서버에서 받은 데이터를 가공하는 메소드를 넣어줄 수 있는 옵션이다. 보여주는 부분과 데이터를 건드리는 부분이 역할이 분리되어서 코드를 수정하기가 나름 편해졌다 ! 컴포넌트 입장에서는 보여주는 데이터만 필요하기 때문이다.

onError 옵션을 넣어서 각각의 요청에 맞게 개별적인 에러처리가 가능하도록 작성했다.

const makeRequest = async <ReturnType = any, DataType = any>(
  fetchFn: () => Promise<Response>,
  requestOptions?: RequestOptions<ReturnType, DataType>,
): Promise<Result<ReturnType>> => {

  const { onSuccess, onError, onStart, onEnd, select } = requestOptions ?? {};
  
  // 요청 시작 전 지정된 콜백 실행
  if (onStart) onStart(); 

  try {
    const response = await fetchFn();
    // 서버 요청 시 발생한 에러 throw
    if (!response.ok) {
      throw new Error(`API Error ${response.status} : ${response.statusText}`);
    }
    let data = await response.json();
    
    // select 가 있다면 data 가공
    if (select) data = select(data); 
    
    // 완성된 data 는 onSuccess 콜백으로 전달
    if (onSuccess) onSuccess(data);  
    
    return { data, isSuccess: true, isError: false };
  } catch (error: any) {
	  // 에러는 onError 콜백으로 전달
    if (onError) onError(error);
    return { data: undefined, isSuccess: false, isError: true };
  } finally {
    onEnd && onEnd(); // 요청이 끝나면 지정된 콜백 실행
  }
};

사용 예시

// 이모지 기능 구현때 작성한 것으로,
// select 옵션을 통해 객체 내부의 slug 프로퍼티를 꺼내서 배열로 가공하도록 했다 !

makeRequest<string[], EmojiCategories>(() => emojiApi.getCategories(), {
  select: (data) => {
    return data.map(({ slug }) => slug);
  },
  onSuccess: (data) => {
    if (cursor === 0) {
      setEmojiData((prev) => ({
        ...prev,
        categories: data.filter((category) => category !== "component"),
      }));
    }
  },
});

Service

그럼 이제 어디서 makeRequest 를 호출해야할까?

원래는 컴포넌트 내부에서 makeRequest 를 호출하고, 거기서 반환된 data 를 사용하여 state 를 갱신시키는 방식이다. 하지만 에러 처리나 서버 데이터를 가공하는 것과 같은, 컴포넌트에서 몰라도 되는 작업들을 내부에 작성해야 하는 애매함이 발생한다.

const { data } = makeRequest(..., {
	select: (data) => data.map(...),
	onError: () => ... ,
});
// 컴포넌트는 완성된 data 만 보여주면 되기 때문에, 굳이 그 안의 로직을 알 필요가 없다.
this.setState({...this.state, data}); 

또한, 하나의 서버 요청이 여러 컴포넌트에 영향을 준다면 이 부분을 작성하기가 복잡한 점도 있다. 위에 정리한 것처럼 서버 요청 하나 당 여러 컴포넌트 업데이트가 얽혀서 일어나기 때문에, 실제로도 복잡했다..! 하하

이러한 이유들 + 코드가 너무 과하게 길어짐 등등으로 인해, UI 로직과 비즈니스 로직을 분리해야 했다. 이전에 만들어 놓은 Store 를 활용하여, 서버 데이터 패칭과 상태 관리 로직을 담당하는 service 레이어를 추가하기로 했다.

Store는 컴포넌트 내에서 store.subscribe(사용할 데이터 키) 를 호출하면, 해당 데이터가 업데이트 될 때 컴포넌트가 리렌더링 되는 구조이다.

구조를 그림으로 전개하면 이렇게 된다.

데이터가 한 방향으로만 움직이기 때문에, 기능을 수정하거나 관리하기 훨씬 편리해졌다.

// service/NotionService.ts

// 전체 문서 목록을 서버로부터 불러오고, 스토리지와 스토어를 업데이트
const getRootDocuments = (newId?: number) => {
  const setDirectoryData = store.setData<DirectoryData>(directoryData);
  
  makeRequest<RootDocuments>(() => notionApi.getAll(), {
    onSuccess: (data) => {
      const toggleData = makeToggleData(data);
      
      // 스토리지에 저장
      const storedToggleData = localStorage.getItem({
        key: STORAGE_KEY.TOGGLE,
        default: toggleData,
      });
      
      // 스토어 데이터 갱신
      setDirectoryData((prev) => ({
        currentId: newId ?? prev.currentId,
        rootDocuments: data ?? [],
        toggleData: updateToggleData(toggleData, storedToggleData),
      }));
    },
  });
};
// components/Directory.ts

class Directory extends Component {
  created() {
	  // 스토어 구독
    store.subscribe([directoryData], {
      key: STORE_KEY.DIRECTORY,
      func: () => this.render(),
    });
  }

  mounted() {
    const { params } = router.match() || {};
    // 서버 데이터 갱신
    notion.getRootDocuments(Number(params?.id));
    
    // 생략 - 각종 이벤트 처리
  }

  template() {
	  // 스토어에 있는 데이터 가져오기
    const { currentId, rootDocuments, toggleData } =
      store.getData<DirectoryData>(directoryData);
      
    // 생략 - 스토어 데이터를 바탕으로 렌더링
  }
}

정리하면!

  1. 컴포넌트에서 스토어 구독
  2. 컴포넌트에서 최초 마운트 또는 이벤트 발생 시 service 메소드 호출
  3. service 에서는 서버 요청 후 스토어 데이터 갱신
  4. 스토어에서 컴포넌트 리렌더링 실행

컴포넌트 입장에서는 서버 데이터 요청 후 바로 리렌더링이 되는 구조이고, 중간 과정을 몰라도 서비스단에서 처리를 해주는 것이다. 각자의 역할이 쫌 더 분명해진 느낌이다.

이렇게 개선한 서버 요청 로직을 바탕으로 라이브러리 없이 이모지 기능을 쉽게 구현할 수 있었다. makeRequest 를 응용해서 병렬 요청, 무한스크롤 등 여러 처리를 해보기도 했는데.. 이건 다음 포스팅에서 회고해보도록 하겠다 !


0개의 댓글