CEOS 3주차 회고: React Memo API 연동

tellie·약 7시간 전

FE-Study

목록 보기
3/3
post-thumbnail

이번 프론트엔드 3주차 과제는 2주차에 React와 localStorage로 구현했던 메모 서비스에 로그인, 회원가입과 메모 API를 연동하는 것이었다.

1주차에는 Vanilla JavaScript로 상태 변경과 DOM 갱신을 직접 연결했고, 2주차에는 React에서 상태를 기준으로 UI를 구성하는 방식과 컴포넌트의 책임을 고민했다. 이번에는 브라우저 안에만 존재하던 메모를 로그인한 사용자별 서버 데이터로 바꾸는 과정이었다.

swagger 개요

이번 과제에서는 Swagger UI와 API 명세를 함께 확인하며 요청 및 응답 형식, 인증 방식, 상태 코드 등을 기준으로 연동을 진행했다. 로그인과 회원가입, 메모 조회, 작성, 수정, 고정 상태 변경, 삭제 API를 연동했다. 또한 로그인 상태를 유지하고, 새로고침하거나 다시 로그인했을 때도 서버에 저장된 메모를 다시 불러올 수 있도록 구현했다.

배포 링크 : https://react-memo-24th-week3.vercel.app
GitHub PR : https://github.com/CEOS-Developers/react-memo-24th/pull/11


API 요청을 한 곳에서 처리하기

기능마다 fetch를 직접 작성하면 API 주소, 헤더, JSON 변환, 오류 처리 방식이 서로 달라질 수 있다. 특히 이번 API는 인증이 필요한 요청에 Authorization 헤더를 넣어야 했고, 성공 및 실패 응답도 공통 형식을 사용하고 있었다.

그래서 API 요청을 담당하는 request 함수를 만들고, 각 API 함수에서는 주소와 요청 데이터만 전달하도록 구성했다.

// api/client.ts

export async function request<T>(
  path: string,
  options: ApiRequestOptions = {},
): Promise<T> {
  const { body, headers, token, ...requestOptions } = options;
  const requestHeaders = new Headers(headers);

  requestHeaders.set('Accept', 'application/json');

  if (body !== undefined) {
    requestHeaders.set('Content-Type', 'application/json');
  }

  if (token) {
    requestHeaders.set('Authorization', `Bearer ${token}`);
  }

  const response = await fetch(getApiUrl(path), {
    ...requestOptions,
    headers: requestHeaders,
    body: body === undefined ? undefined : JSON.stringify(body),
  });

  const payload = (await response.json()) as ApiResponse<T>;

  if (!response.ok || !payload.success) {
    throw new ApiError(
      payload.message ?? '요청을 처리하지 못했습니다.',
      response.status,
    );
  }

  return payload.data;
}

위와 같이 request 함수에서 공통 헤더와 응답 형식을 처리하니, 메모 API에서는 실제 기능에 필요한 내용만 확인할 수 있었다.

// api/memos.ts

export function createMemo({ token, memo }: CreateMemoOptions) {
  return request<ApiMemo>('/api/memos', {
    method: 'POST',
    token,
    body: memo,
  });
}

export function updateMemo({ token, memoId, memo }: UpdateMemoOptions) {
  return request<ApiMemo>(`/api/memos/${memoId}`, {
    method: 'PUT',
    token,
    body: memo,
  });
}

실제 요청은 MemoPage에서 바로 보내지 않고, useApiMemos를 거쳐 memos.ts의 기능별 API 함수가 호출되도록 구성했다. 이후 client.ts의 공통 request 함수가 API 주소 조합, 인증 헤더 추가, 요청 본문 직렬화, 응답 형식 검증 등을 맡는다. 이로써 기능별 API 코드에는 각 요청의 목적만 남기고, 반복되는 통신 처리는 한 곳에서 관리하고자 했다.

아래는 메모 작성 기능에서 POST /api/memos 요청이 전송되고, 서버 응답이 반환되는 과정을 개발자 도구에서 확인한 화면이다.

메모 생성 요청

POST /api/memos 요청이 201 Created 상태로 성공한 것을 확인했다. 프론트엔드에서 설정한 API 주소와 메서드가 정상적으로 적용되었고, 서버가 새 메모 생성을 처리한 뒤 응답을 반환했다.

메모 생성 요청 payload

작성 화면에서 입력한 제목과 본문, 선택한 태그가 서버가 요구하는 요청 형식으로 전달된다. 화면의 work 태그는 API 요청 전에 WORK 값으로 변환되며, 새 메모는 기본적으로 고정되지 않은 상태인 isPinned: false로 전송된다.

메모 생성 응답

서버는 생성된 메모의 숫자 ID와 생성/수정 시각을 포함해 응답한다. 프론트엔드에서는 이 응답을 화면에서 사용하는 메모 형태로 변환한 뒤, 현재 목록 상태에 추가한다. 따라서 별도로 전체 목록을 다시 요청하지 않아도 작성한 메모를 바로 화면에서 확인할 수 있다.

처음에는 API 요청 수가 많지 않은 상태에서 공통 함수를 분리해 먼저 만드는 것이 과한 분리일 수도 있겠다고 생각했다. 하지만 로그인과 메모 조회, 작성, 수정, 삭제 기능을 연결하면서 반복되는 설정이 늘어났고, 결과적으로 요청 처리 기준을 한 곳에서 통일할 수 있었다.

인증 상태를 전역으로 관리하기

API 요청에 인증 헤더를 추가하려면 로그인 후 발급받은 accessToken을 여러 곳에서 사용할 수 있어야 한다. React Memo만 보더라도 메모 목록 조회 외에도 작성, 수정, 삭제 요청에는 token이 필요하고, 마이페이지에서는 로그인한 사용자의 이메일을 표시해야 한다. 또한 로그인하지 않은 사용자가 메모 페이지에 접근하지 못하도록 ProtectedRoute에서도 인증 상태를 확인해야 한다.

이처럼 여러 화면이 같은 인증 정보를 기준으로 동작하기 때문에 accessToken과 이메일은 Zustand로 전역 관리했다. 반면 모달이 열렸는지의 여부나, 선택한 태그, 검색어 등과 같이 메모 페이지 내부에서만 필요한 값은 기존과 같이 useState로 관리했다.

// stores/authStore.ts

type AuthState = {
  accessToken: string | null;
  email: string | null;
  setAuth: (session: AuthSession) => void;
  clearAuth: () => void;
};

export const useAuthStore = create<AuthState>()(
  persist(
    (set) => ({
      accessToken: null,
      email: null,
      setAuth: ({ accessToken, email }) => set({ accessToken, email }),
      clearAuth: () => set({ accessToken: null, email: null }),
    }),
    {
      name: 'react-memo-auth',
      partialize: ({ accessToken, email }) => ({ accessToken, email }),
    },
  ),
);

위 코드에서 Zustand의 persist 미들웨어를 사용해서 토큰과 이메일을 브라우저 저장소에 함께 저장했다. 그래서 새로고침을 하더라도 로그인 상태가 유지되고, 메모 목록을 다시 서버에서 조회할 수 있다.

로그인 요청이 성공하면 서버에서 받은 토큰과 사용자가 입력한 이메일을 저장한 뒤 메모 목록 페이지 (/memos) 로 이동한다.

// pages/auth/LoginPage.tsx

const { accessToken } = await login({ email, password });

setAuth({ accessToken, email });
navigate('/memos', { replace: true });

반대로, API 요청 중 401 Unauthorized 응답을 받으면 저장된 인증 정보를 제거하고 로그인 화면으로 이동하도록 처리했다. 토큰이 만료되었거나 유효하지 않은 상태에서 메모 화면에 계속 남아 있는 것보다 다시 로그인할 수 있도록 안내하는 것이 사용자의 입장에서 자연스러운 로직이라고 판단했다.

// hooks/useApiMemos.ts

const handleUnauthorized = useCallback(() => {
  clearAuth();
  useAuthStore.persist.clearStorage();
  navigate('/login', { replace: true });
}, [clearAuth, navigate]);

이렇게 구현을 했고, 마이페이지에서는 전역으로 관리하는 이메일을 표시하고, 로그아웃 시에는 Zustand 상태와 브라우저 저장소를 함께 비운 뒤 로그인 화면으로 이동하도록 구현했다.

이번 과제를 통해 여러 화면에서 공통적으로 필요한 인증 정보만 Zustand로 관리하고, 화면 내부에서만 사용하는 형태는 useState에 남겨두었다. 상태를 전역으로 옮길지 결정할 때도 실제 사용 범위를 먼저 확인해야 한다는 점을 다시 느꼈다.

서버 메모를 화면 상태에 반영하기

2주차까지는 useStoredMemos를 통해 메모 상태가 바뀔 때마다 localStorage에 저장했다. 이번에는 메모 목록을 서버에서 불러오고, 각 요청이 성공했을 때 현재 화면의 목록 상태를 변경하도록 바꿨다.

먼저 메모 목록 조회와 CRUD 요청 로직은 useApiMemos 커스텀 훅으로 분리했다. MemoPage는 메모를 어떤 API로 불러오고 저장하는지 알 필요 없이, 훅이 반환하는 상태와 함수만 사용하게 된다.

// pages/MemoPage.tsx

const {
  memos,
  isLoading,
  errorMessage,
  loadMemos,
  saveMemo,
  editMemo,
  toggleMemoPin,
  removeMemo,
} = useApiMemos();

메모 작성 시에는 서버가 생성한 메모를 응답으로 받은 뒤, 화면에서 사용하는 형태로 변환해 현재 목록 앞에 추가했다.

// hooks/useApiMemos.ts

const apiMemo = await createMemo({
  token: accessToken,
  memo: {
    title: draft.title,
    content: draft.content,
    category: mapMemoCategoryToApi(draft.category),
    isPinned: false,
  },
});

const memo = mapApiMemoToMemo(apiMemo);

setMemos((previousMemos) => [memo, ...previousMemos]);

서버에서는 대문자로 된 카테고리 값 (DAILY | WORK | OTHER)과 숫자 형태의 ID, 생성 시각을 반환한다. 반면 기존 UI에서는 daily | work | others 값과 문자열 형태의 ID를 사용하고 있었다. 그래서 서버 응답을 그대로 컴포넌트에 전달하기보다 변환 함수를 두어 서버 데이터와 UI 데이터를 분리하는 방식을 선택했다.

// src/utils/mapApiMemoToMemo.ts

export function mapApiMemoToMemo(memo: ApiMemo): Memo {
  return {
    id: String(memo.id),
    title: memo.title,
    content: memo.content,
    category: memoCategoryByApiCategory[memo.category],
    date: memo.createdAt.slice(0, 10),
    isPinned: memo.isPinned,
  };
}

수정, 고정 상태 변경에서도 같은 방식으로 처리했다. 서버 요청이 성공하면 응답으로 받은 메모로 기존 목록의 해당 항목만 교체하고, 삭제 요청이 성공하면 목록에서 해당 메모를 제거했다.

처음에는 요청 이후 목록 전체를 다시 조회하는 방식도 고려했다. 하지만 작성, 수정, 고정과 같이 서버가 변경된 메모를 응답으로 내려주는 경우에는 그 응답을 현재 목록에 반영하는 편이 불필요한 요청을 보다 줄일 수 있다고 생각했다. 다만 요청이 성공하기 전에 화면을 먼저 바꾸지는 않았다. 화면만 변경했다가 서버 요청이 실패하면 사용자가 보는 내용과 실제 서버 데이터가 달라질 수 있기 때문에 이번 구현에서는 서버 응답을 받은 뒤에만 목록 상태를 변경하도록 했다.

서버에서 받은 메모 목록을 태그와 고정 상태에 따라 기존 카드 UI에 표시했고, 메모를 작성하거나 수정한 뒤 새로고침해도 서버에 저장된 결과가 유지되는 것을 확인했다.

즉, 이전에는 메모 상태가 브라우저 안에서만 바뀌는지 확인했다면, 이번에는 서버 응답을 기준으로 화면 상태를 갱신해야 했다. 같은 메모 목록이라도 데이터의 기준이 어디에 있는지에 따라 상태를 다루는 방식이 달라질 수 있음을 느꼈다.

요청 중, 요청 실패 상태를 사용자에게 안내하기

API 요청은 성공했을 때만 고려해서는 부족하다. 요청이 진행되는 동안 사용자는 버튼을 다시 눌러도 될지, 현재 오류가 입력값의 문제인지 네트워크 상의 문제인지 알기 어렵다. 그래서 로그인, 회원가입, 메모 API 요청에서 로딩과 실패 상태를 구분해 화면에 표시하도록 했다.

로그인 요청 중에는 isSubmitting 상태를 true로 변경하고, 입력 필드와 제출 버튼을 비활성화했다. 버튼 문구도 로그인 중...으로 변경해 중복 요청을 막고 현재 요청이 진행 중임을 알 수 있도록 했다.

// pages/auth/LoginPage.tsx

async function handleSubmit(event: FormEvent<HTMLFormElement>) {
  event.preventDefault();

  if (!canSubmit || isSubmitting) return;

  setIsSubmitting(true);
  setErrorMessage('');

  try {
    const { accessToken } = await login({ email, password });

    setAuth({ accessToken, email });
    navigate('/memos', { replace: true });
  } catch (error) {
    if (error instanceof ApiError) {
      setErrorMessage(getRequestErrorMessage(error));
    } else {
      setIsNetworkError(true);
    }
  } finally {
    setIsSubmitting(false);
  }
}

서버가 반환한 로그인 실패 메시지는 비밀번호 입력창 아래에 표시했다. 사용자가 다시 올바르게 입력하면 해결되는 문제이기 때문에 어떤 값을 확인해야 하는지 바로 알 수 있는 위치에 보여주는 것이 적절하기 때문이다.

반면 네트워크 오류와 같이 특정 입력값과 연결하기 어려운 경우에는 공통 모달로 안내했다.

또한 메모 목록을 처음 불러올 때도 빈 목록과 로딩 상태를 구분했다. 서버 응답을 기다리는 동안 메모 배열이 비어 있다고 해서 바로 빈 상태를 보여주면, 실제로는 메모가 있어도 사용자에게 작성한 메모가 없다는 잘못된 안내가 잠시라도 표시될 수 있기 때문이다.

// pages/MemoPage.tsx

{isLoading && (
  <div role="status">
    메모를 불러오는 중입니다.
  </div>
)}

{!isLoading && errorMessage && (
  <div>
    <p role="alert">{errorMessage}</p>
    <button type="button" onClick={() => void loadMemos()}>
      다시 시도
    </button>
  </div>
)}

목록 조회에 실패한 경우에는 기존 목록을 임의로 비우지 않고 오류 메시지와 다시 시도 버튼을 표시했다. 서버 또는 네트워크 상태가 정상으로 돌아왔을 때 사용자가 페이지를 새로고침하지 않아도 다시 요청할 수 있도록 하기 위해서였다.

이전에는 오류 처리를 요청 실패 시 추가하는 예외 코드처럼 생각했었는데, 이번 과제를 진행하면서 각 상황에 따라 사용자에게 보여줘야 하는 화면도 달라진다는 점을 느꼈다. API 연동에서는 요청을 보내는 것만큼 요청 결과를 사용자에게 어떻게 전달할지도 함께 고려해야 함을 느꼈다.


1,2,3주차에 걸쳐 같은 메모 서비스를 서로 다른 방식으로 확장하면서 프론트엔드에서 상태를 다루는 범위를 단계적으로 경험해볼 수 있었다.

이전에 UMC 스터디와 프로젝트를 진행하며 React, 전역 상태 관리, API 연동을 각각 공부하고 활용해본 경험은 있었다. 하지만 이번처럼 같은 서비스를 기준으로 Vanilla JS, React, 서버 연동을 순서대로 구현해보니 익숙하게 사용했던 방식들이 왜 필요한지와 어떤 상황에서 선택해야 하는지가 더 분명히 정리되는 느낌이었다.

1주차에는 사용자 동작 이후 변경된 상태를 기준으로 DOM을 직접 갱신해야 했다. 2주차에는 React를 사용하며 상태에 따라 UI를 선언하고, 컴포넌트와 커스텀 훅의 책임을 나누는 방식에 집중하여 고민했다. 이번 3주차에는 화면 내부 상태를 넘어 로그인한 사용자와 서버에 저장된 데이터를 연결하는 작업을 해보았다.

이번 과제를 진행하며 API 연동은 단순히 요청 함수를 작성하는 작업에 그치지 않는다는 점을 느꼈다. API 명세에 맞춰 요청과 응답 데이터를 변환하고, 인증 정보를 관리하며, 요청 중이거나 실패한 상태를 사용자에게 자연스럽게 전달하는 과정까지 함께 고려해야 했다.

앞으로 CEOS에서 프로젝트를 진행하거나 내가 구상한 풀스택 프로젝트를 진행할 때도, 화면 구현을 시작하기 전에 필요한 데이터와 API 명세를 먼저 확인하고 정상적인 흐름뿐 아니라 로딩, 오류, 인증 만료처럼 사용자가 실제로 마주할 수 있는 상태까지 고려한 설계를 해봐야겠다고 생각했다.

0개의 댓글