나비장터 회고(2)-개발

작성일: 2023.12.10

AI 요약

MSW를 활용한 프로젝트에서의 에러 처리, 로딩 처리, 백엔드 개발 등을 다룬 블로그 포스트 내용을 요약하였습니다.

Contents

개요

이번 프로젝트에서는 MSW의 적극 활용과 에러 및 로딩 처리 등 더 완성도 있는 시스템을 갖추고자 노력했다. 설계한 몇 가지 포인트에 대해 기록해 보고자 한다.

MSW를 이용한 병렬적 개발 일정 확보

개요

이번 프로젝트는 백엔드와 같은 개발 일정으로 진행되었다. 때문에 API 릴리즈에 맞춰 프론트엔드 개발 일정을 진행하기에는 이에 의존적이 될 뿐더러, 정확히 맞지 않는 부분도 존재했다.

가볍게 더미데이터를 상수화해서 작성해두고 출력할 수도 있지만, 나중에 실 API 연결에서 엣지 케이스 및 미비한 비동기 처리가 발견될 확률이 높다.

  • MSW를 사용하지 않은 공통 개발 일정

231218-162326

  • MSW를 사용한 공통 개발 일정

231218-162333

우리는 위와 같이 MSW를 통해 명세를 기반한 mock API를 작성하며 개발했다. 이를 통해 독립적인 개발 일정을 확보하고, 그때그때 적절한 비동기 에러 및 로딩 처리를 할 수 있었다.

프론트엔드 빌드 및 배포 아키텍쳐

231218-162340

husky를 통해 로컬에서 사전 빌드, 테스트 코드 및 lint 유효성을 확인한다.

github에서 actions를 통해 main, develop 브랜치 대상 빌드, 테스트 유효성을 우선 확인하고 코드 리뷰를 진행하도록 했다.

이후 merge되면 스토리북과 vercel에 변경 내용을 자동 배포하여 오류를 최소화하고 결과물을 빠르게 확인할 수 있는 CICD 아키텍쳐를 구성했다.

NextJS에서 FetchAPI와 에러 및 로딩 처리

자체 확장 Fetch 클래스 사용

NextJS에서 axios 등의 서드파티 라이브러리를 사용하면 캐싱 정책 등을 따로 적용해야 하는 수고가 있다.

때문에 NextJS 공식 문서에서도 이를 권장하지 않고, 기존 fetch를 확장한 형태를 사용하기 때문에 fetch를 사용하는 것이 좋다.

...

class FetchAPI {
  private baseURL: string
  private headers: { [key: string]: string }

  private static instance: FetchAPI

  private constructor() {
    this.baseURL = ''
    this.headers = {}
  }

	// singleton으로 중복 방지
  public static getInstance(): FetchAPI {
    if (!FetchAPI.instance) {
      FetchAPI.instance = new FetchAPI()
    }
    return FetchAPI.instance
  }

  public setBaseURL(url: string): void {
    this.baseURL = url
  }

  public setDefaultHeader(key: string, value: string): void {
    this.headers[key] = value
  }

	...

  public async post(
    endpoint: string,
    body: any,
    nextInit: RequestInit = {}, // NextJS의 config를 사용할 수 있도록 추가
    customHeaders: { [key: string]: string } = {},
  ): Promise<any> {
    const response = await fetch(`${this.baseURL}${endpoint}`, {
      method: 'POST',
      headers: { ...this.headers, ...customHeaders }, // 공통 옵션 사용
      body: body instanceof FormData ? body : JSON.stringify(body),
      ...nextInit,
    })

    return this.responseHandler(response)
  }

...

때문에 FetchAPI.ts 에 싱글톤 클래스를 생성해 기존 axios 등에서 누리던 공통 헤더 및 baseUrl 기능 등을 사용하면서도 NextJS에서 권하는 옵션을 사용할 수 있도록 자체적으로 확장했다.

// apiClient.ts
import { Environment } from '@/config/environment'
import FetchAPI from '@/lib/fetchAPI'

const apiClient = FetchAPI.getInstance()
apiClient.setDefaultHeader('Content-Type', 'application/json')
apiClient.setBaseURL(Environment.apiAddress())

export default apiClient

위처럼 미리 인스턴스를 공통적으로 필요한 설정과 함께 생성하여 활용할 수 있었다.

각 호출에 필요한 함수는 미리 정의해서 사용했다.

import ApiEndPoint from '@/config/apiEndPoint'
import apiClient from '../apiClient'

const getTest = async () => {
  const response = await apiClient.get(
    ApiEndPoint.test(),
    { next: { revalidate: 10 } },
    { Authorization: 'Bearer ' },
  )
  return response
}

export { getTest }

위에서 생성한 apiClient를 불러와 쉽게 정의할 수 있었다.

에러 처리

fetchAPI의 구현을 보면 get, post, put, delete 등의 http 메서드의 return에서 responseHandler 를 거치는 것을 확인할 수 있다.

231218-162403

이는 아래와 같은 형태로 구현했다.

231218-162410

  • response를 확인하고 이의 status를 확인해 각 케이스에 맞는 커스텀 에러로 throw한다.
  • 각 커스텀 에러는 케이스에 맞는 에러 메세지와 함께 사전에 정의해두었다.

이는 곧 전역 또는 지역 단위의 ErrorBoundary로 전파된다. 실제 구현은 NextJS13의 error 템플릿을 통해 작성하여 명확한 모듈 단위의 목적 분리가 용이했다.

error.tsx 를 작성하면 자동으로 해당 위치의 layout을 ErrorBoyndary로 래핑한다. 다음과 같은 구조로 이해할 수 있다.

231218-162422

https://nextjs.org/docs/app/building-your-application/routing/error-handling

각 에러 컴포넌트는 throw된 error를 받아 적절한 화면 또는 리다이렉팅을 처리하도록 했다.

231218-162429

위 코드와 같이 렌더링 과정에서 일어난 문제에 대한 알맞은 화면을 제공한다.

접근 제한

또한 접근할 수 없는 페이지를 사전에 차단하기 위해 서버 사이드에서 middleware를 거쳐 이를 처리하도록 했다. 이를 통해 비인가 사용자가 페이지에 처음부터 접근할 수 없도록 하거나, 사전에 로그인 페이지로 이동하도록 했다.

231218-162436

로딩 처리

React의 Suspense를 NextJS13에서는 error.tsx를 활용하듯 loading.tsx로 처리할 수 있다.

NextJS에서는 특히 SSR을 활용할 때 서버로부터 html을 전달받기까지의 시간이 추가로 요구된다. 중간 프로젝트에서 이를 처리하지 않아 클릭 후 html 수신까지의 시간 동안 아무 반응이 없어 사용자에게 혼동을 준 문제가 있었다.

loading을 적절히 두어 로딩 화면을 보여주도록 했다.

https://nextjs.org/docs/app/building-your-application/routing/loading-ui-and-streaming

전체 구조

231218-162445

다음과 같은 구조를 통해 허용되지 않은 컨텐츠를 사전에 노출되지 않도록 처리하고, 런타임 중 생기는 로딩과 에러를 효율적으로 처리할 수 있도록 했다.

headless한 공통 컴포넌트 구조

tailwind를 통해 효율적인 공통 컴포넌트 구조를 생성하고자 했다.

문제

231218-162452

다음과 같이 특정 스타일을 변수로 관리하고 이를 주입해 사용하는 것은 적절하다고 볼 수 있으나, 타입 시스템의 추천 기능과 공통 사항을 관리하거나 다른 종류의 스타일 조각들을 관리함에 있어 불편을 야기한다.

해결

231218-162500

231218-162506

cva를 통해 기본 스타일과 선택적 style prop을 지정하고, twmerge와 clsx를 결합하여 조건부 스타일 및 중복 스타일을 조정하도록 했다.

231218-162513

이를 통해 정의해 둔 스타일과 추가로 주입하고 싶은 스타일을 자유롭게 활용할 수 있다.

231218-162522

  • 해당 방식은 공용 컴포넌트를 커스텀하고, 수정하는데에 높은 자유도를 제공한다.
  • 사진과 같이 타입 시스템에도 친화적인 방식을 제공하기 때문에 이를 적극 활용했다.

231218-162529

Form 관리

231218-162536

Form을 효율적으로 관리하고 다양한 형태에서 활용하기 위해, Input을 받고 보이는 부분과 validation 모듈, hook을 통해 비동기 통신과 상태를 관리하는 세 가지 영역으로 나눠서 구현했다.

231218-162546

위와 같이 Template은 hook으로 부터 필요한 상태만 전달받는다.

각 컴포넌트는 form의 상태를 통해 input을 갱신하거나, validation 실패에 대한 에러 메세지를 전달받을 수 있다.

231218-162556

hook은 서버와의 통신 및 이후 처리 등을 담당한다. 여기서 에러 및 로딩에 대한 처리 등을 해줄 수 있다.

231218-162607

231218-162612

validation은 appValidation을 통해 사전에 정의한 zod 메서드를 통해 이루어진다.

이는 어떤 이점이 있을까?

231218-162621

한 서비스에서는 보통 특정 필드에 대한 같은 validation 기준을 공유한다. 이를 일일이 react hook form에서 처리하면 중복 코드가 생기고, 기준이 수정될 때 불편함을 야기할 수 있다.

위와 같이 validation 기준을 zod 메서드를 통해 사전에 정의해 둔 뒤 이를 필요한 hook에 사용하면 그런 불편함을 효과적으로 해소할 수 있으며, 또한 타입 친화적인 이점도 챙길 수 있다.

서버 컴포넌트 관련된 문제 사항

NextJS의 SSR은 생각보다 여러 기능들에서 추가적인 접근 방식에 고려를 요한다. 최근 여러 NextJS 기반 프로젝트를 진행하면서 줄곧 깨닫고 있다.

이번 프로젝트에서 있었던 문제들을 기록해보자.

MSW 서버 initialize 문제

NextJS13은 기본적으로 서버 렌더링으로 동작한다. CSR로 호출하는 경우에는 문제가 없었으나, SSR로 처리되는 경우에는 mock server가 initialize되기 전에 함수가 호출되어 mock route를 인식하지 못하는 문제가 있었다.

해결

'use client'

import { useState, type PropsWithChildren, useEffect } from 'react'
import { Environment } from '@/config/environment'

const isMockingMode = Environment.apiMocking() === 'enabled'

const MSWComponent = ({ children }: PropsWithChildren) => {
  // mocking을 활성화하지 않았다면 바로 children을 return하기 위함
  const [mswReady, setMSWReady] = useState(() => !isMockingMode)

  useEffect(() => {
    // msw 실행 메서드를 lazy load
    const init = async () => {
      if (isMockingMode) {
        const initMocks = await import('../lib/msw/initMockApi').then(
          (res) => res.initMockApi,
        )
        await initMocks()
        setMSWReady(true) // mock server 실행 완료
      }
    }

    if (!mswReady) {
      init()
    }
  }, [mswReady])

  if (!mswReady) return null

  return <>{children}</>
}

export default MSWComponent

MSW 활성화 시 전역에 wrapper를 두어 렌더링 이전에 MSW가 활성화 될 수 있도록 하였다.

인증이 필요한 API 요청

const token = accessToken
if (token) {
  apiClient.setDefaultHeader('Authorization', token)
  return getValidateUser()
}

인증(header에 jwt 토큰을 담아 요청하는)이 필요한 요청의 경우 로그인 시 fetchAPI 클래스에 해당 토큰을 공통 헤더로 설정하기 때문에 별도의 코드가 필요하지 않았다.

const AuthProvider = ({ children }: { children: React.ReactNode }) => {
  const { isLoggedIn, currentUser } = useValidate()

  const contextValue = useMemo(
    () => ({ isLoggedIn, currentUser }),
    [isLoggedIn, currentUser],
  )

  return (
    <AuthContext.Provider value={contextValue}>{children}</AuthContext.Provider>
  )
}

또 로그인 여부와 유저의 간단한 정보가 전역에서 context로 전달되기 때문에 필요 시 이를 사용하면 됐다.

문제는 SSR 시에 이 공통 헤더가 아직 지정되지 않은 상태라 인증 에러가 나타난다는 것이었다.

해결

토큰을 쿠키에 저장해 활용하고 있었는데 next/headers 를 통해 서버에서 쿠키 정보를 얻어올 수 있었다.

import { cookies } from 'next/headers'
import { Environment } from '@/config/environment'

const getServerCookie = () => {
  const cookieStore = cookies()
  const token = cookieStore.get(Environment.tokenName())

  return token?.value
}

위와 같이 서버 컴포넌트에서 토큰 정보를 얻는 메서드를 작성했다.

const getUserInfo = async (): Promise<User> => {
  const token = getServerCookie()
  const res = await apiClient.get(
    ApiEndPoint.getValidateUser(),
    {},
    {
      Authorization: `${token}`,
    },
  )
  return res.data.userInfo
}

SSR 요청에서는 다음과 같이 토큰 정보를 추가해 줄 수 있도록 했다.

Refresh Token 로직

refresh token 기능을 통해 access token을 갱신하는 과정에서 예상치 못한 문제가 있었다.

231218-162641

처음 구현은 위에서 활용한 auth context 내에서 useEffect와 tanstack query를 활용해 access token의 만료를 확인하면 refresh token을 통해 갱신이 이루어지도록 했다.

231218-162649

문제는 access token이 만료된 상황 + SSR이 있는 페이지에 최초 접속할 경우, 서버에서는 hook이 작동하기 이전에 요청이 이루어지기 때문에 refresh token이 유효함에도 갱신하지 못하고 에러 처리가 이루어졌다.

시도

처음에는 위에서 구현한 getServerCookie에서도 토큰이 유효한지 여부를 검증하는 추가 로직을 통해 유효하지 않을 경우 refresh 요청을 통해 토큰을 갱신하는, 클라이언트와 같은 로직을 통하도록 했다.

문제는 cookie의 set()은 server component에서 불가능하다는 것이다. Server Action 또는 Route Handler를 통해서만 처리될 수 있었다(https://nextjs.org/docs/app/api-reference/functions/cookies#cookiessetname-).

애초에 백엔드 응답에서 cookie를 set 해주면 될 터였지만, 해당 기능이 구현되어 있지 않았고 토큰만 보내주는 형태였다. 추가 수정을 요청할 수 있는 상태가 아니었다.

해결

일단 해당 경우에 에러 페이지를 통하도록 했다. 이후 클라이언트 hook이 init되고 auth hook에서 refreshToken이 확인될 경우 토큰 및 로그인, 유저 정보를 갱신하여 최초에 새로고침 처리가 되도록 방식을 수정했다.

물론 api 응답에서 cookie를 설정해주는 것이 가장 효과적인 방식이지만, api 스펙이 이를 충족하지 못 할 경우 client에서 갱신 후 새로고침을 통해 토큰을 갱신하면 간단한 방식으로 문제를 해결할 수 있다.

reissue가 잦게 일어나지 않기 때문에 해당 방식으로 처리했지만, 다른 경우에는 무조건 새로 고침을 강제하는 것은 사용자 경험에 치명적일 수 있을 것 같다.

회고

잘한 점

  • NextJS의 각종 기능을 활용해 봄
    • Intercepting route, loading, error, middleware, 폰트, 이미지 최적화 등
  • 적절한 에러 및 로딩 처리에 투자
  • 공통 문제를 해결하고자 노력하고, 이를 공유함
  • 이유 있는 기술만을 활용
    • 불필요한 전역 상태를 추가하지 않음

아쉬운 점

  • 타입 정의, 폴더명 컨벤션 등을 초반에 챙기지 못해 불필요한 시간 소요
  • 백엔드에 미리 문제될 만한 부분을 확인해주지 못함
    • CORS 이슈에 대한 백엔드 설정
    • OAuth 방식에 대해 잘못 이해하고 있는 부분을 조금 더 빨리 알려줄 수 있었을 듯

관련 링크

태그: