Next.js 14 폴더 구조 with MSW

Nacho·2024년 3월 8일

Next.js 14 App routes에서
API를 잘사용하기 위한 구조와 MSW를 설정하는 방법에 대해 알아보겠습니다.

우선 next.config.mjs 에서 instrumentationHook을 true로 설정해줍니다.

/** @type {import('next').NextConfig} */
const nextConfig = {
  experimental: {
    instrumentationHook: true,
  },
}

export default nextConfig
npm i -D @faker-js/faker msw

API를 다루는데 도움을 주는 Util 함수 & Type 지정

AssertValue

utils/assert-value.ts

export const assertValue = <T>(value: T, message: string) => {
  if (value === undefined || value === null) {
    throw new Error(message)
  }
  return value
}

값을 검사한 후 Nil 값이라면 오류를 던지는 간단한 유틸 함수 입니다.

ServerEnv

constants/server-env.ts

import { assertValue } from '@/utils/assert-value'

export const ServerEnv = {
  backendUrl: assertValue(
    process.env.BACKEND_URL,
    'BACKEND_URL is not defined',
  ),

  mocking: process.env.MOCKING === 'true',
}

.env.local

BACKEND_URL=http://example.com
MOCKING=true

env를 관리하기위해 정의해두었습니다. 여기서 위에서 정의한 assert-value를 사용합니다.

ApiEndPoint

src/constants/api-end-points.ts

import { ServerEnv } from '@/constants/server-env'

const baseUrl = ServerEnv.backendUrl

export const APIEndpoints = {
  question: {
    list: {
      method: 'get',
      url: `${baseUrl}/questions`,
    },
  },
} as const

api를 한곳에서 관리하기 위한 파일입니다.

ApiResult

src/apis/types/api-result.ts

export interface ApiResult<T> {
  data: T
  message?: string
  status: 'success' | 'error'
}

Api의 결과 값을 위와 같은 형태로 정의하여 사용하기 쉽게 만듭니다.

fetchAPI

src/apis/fetch-api.ts

import { ApiResult } from './types'

/**
 * Backend API를 호출하는 함수
 * fetch결과 값을 ApiResult로 변환하여 반환
 */
export const fetchAPI = async <T>(
  url: string,
  reqInit?: RequestInit,
  handleError?: (error: unknown) => ApiResult<T>,
): Promise<ApiResult<T>> => {
  try {
    const res = await fetch(url, reqInit)
    if (!res.ok) {
      throw new Error(res.statusText)
    }
    const data = (await res.json()) as T
    return {
      data: data,
      status: 'success',
    }
  } catch (error: unknown) {
    console.error(error)
    if (handleError) {
      return handleError(error)
    }
    return {
      status: 'error',
      data: null as unknown as T,
      message: '서버 에러가 발생했습니다. 다시 시도해주세요.',
    }
  }
}

native response 타입을 ApiResult타입으로 변환하는 유틸합수입니다. fetch의 기본구조를 그대로 사용할 수 있습니다.

Types

src/apis/types/response/questions.ts

export interface QuestionResponse {
  id: string
  question: string
}

export interface QuestionsResponse {
  questions: QuestionResponse[]
  total: number
}

fetch에서 바로 반환받는 response 타입입니다.

Presentation

src/presentation/questions.ts

import {
  QuestionResponse,
  QuestionsResponse,
} from '../apis/types/responses/question'

export class QuestionPresentation implements QuestionResponse {
  question: string
  id: string

  private constructor(private response: QuestionResponse) {
    this.question = response.question
    this.id = response.id
  }
  static from(response: QuestionResponse) {
    return new QuestionPresentation(response)
  }
}

export class QuestionsPresentation implements QuestionsResponse {
  questions: QuestionResponse[]
  total: number
  private constructor(private response: QuestionsResponse) {
    this.questions = response.questions
    this.total = response.total
  }
  static from(response: QuestionsResponse) {
    return new QuestionsPresentation(response)
  }
}

reponse타입을 받아 presentation으로 반환합니다. presentation은 컴포넌트에서 바로 사용하기 알맞은 형태입니다. 화면에서 보여지는 것에 맞게 format을 하거나 새로운 필드를 추가해서 활용할 수 있다.

API 사용

src/apis/fetchers/questions/get-questions.ts

'use server'

import { fetchAPI } from '@/apis/fetch-api'
import { ApiResult } from '@/apis/types/api-result'
import { QuestionsResponse } from '@/apis/types/responses/question'
import { QuestionsPresentation } from '@/presentations/question'
import { ApiEndpoints } from '../../end-points'

const { url } = ApiEndpoints.question.list

export const getQuestions = async (): Promise<
  ApiResult<QuestionsPresentation>
> => {
  const result = await fetchAPI<QuestionsResponse>(url)

  if (result.status === 'error') {
    return result as ApiResult<QuestionsPresentation>
  }

  try {
    const presentation = QuestionsPresentation.from(result.data)
    return {
      ...result,
      data: presentation,
    }
  } catch (error) {
    console.error('변환에 실패했습니다.', error)
    return {
      status: 'error',
      data: null as unknown as QuestionsPresentation,
    }
  }
}

qetQuestions에 역할은 api를 호출한 후 presentation으로 반환하는 역할을 합니다.
page나 component에서 호출되는 함수입니다.

/app/page.tsx

import { getQuestions } from '@/apis/fetchers/questions/get-questions'
import { notFound } from 'next/navigation'

const Page = async () => {
  const { data, status } = await getQuestions()
  if (status === 'error') {
    notFound()
  }
  return (
    <main>
      <h1>Questions</h1>
      <ul>
        {data.questions.map((item) => (
          <li key={item.id}>{item.question}</li>
        ))}
      </ul>
    </main>
  )
}

export default Page


data의 타입이 Presentation인 것을 볼 수 있습다.

API mocking

src/mocks/utils

import { HttpResponseResolver, http } from 'msw'

export const makeHandler = ({
  path,
  resolver,
  method,
}: {
  path: string
  method?: 'get' | 'post' | 'put' | 'delete' | 'patch' | 'head' | 'options'
  resolver: HttpResponseResolver
}) => {
  return http[method ?? 'get'](path, resolver)
}

handler의 가독성을 위해 필요한 구성들을 flat한 param 형태로 변경했습니다.

src/mocks/handlers/index.ts

import { HttpHandler } from 'msw'
import { questionHandlers } from './question-handlers'

const handlers: HttpHandler[] = [...questionHandlers]

export { handlers }

이곳에 모든 handler를 담습니다.

src/mocks/handlers/question-handlers.ts

import { ApiEndpoints } from '@/constants/api-end-points'
import { HttpResponse } from 'msw'
import { mockQuestions } from '../data/mock-question'
import { makeHandler } from '../utils'

const { url, method } = ApiEndpoints.question.list
const questionsHandler = makeHandler({
  method,
  path: url,
  resolver: () => {
    return HttpResponse.json({
      questions: mockQuestions,
      total: mockQuestions.length,
    })
  },
})

export const questionHandlers = [questionsHandler]

question 관련 mock api handler입니다.

src/mocks/data/mock-question.ts

import { QuestionResponse } from '@/apis/types/responses/question'
import { faker } from '@faker-js/faker'

const generateData = (): QuestionResponse => {
  return {
    id: faker.string.uuid(),
    question: faker.lorem.sentence(),
  }
}

const mockQuestions = Array.from({ length: 10 }, () => generateData())

export { mockQuestions }

faker 라이브러리를 활용하여 data를 만듭니다.
src/mocks/server

import { faker } from '@faker-js/faker'
import { log } from 'console'
import { setupServer } from 'msw/node'
import { handlers } from './handlers'

const server = setupServer(...handlers)

export function initMocks() {
  log('🚧 MSW와 함께 실행됩니다.')
  faker.seed(100)
  server.listen({
    onUnhandledRequest: 'bypass',
  })
}

mock server를 실행시키는 함수입니다.

src/instrumentation.ts

import { ServerEnv } from './constants/server-env'

export async function register() {
  if (process.env.NEXT_RUNTIME === 'nodejs' && ServerEnv.mocking) {
    const { initMocks } = await import('./mocks/server')
    initMocks()
  }
}

Next.js에서 msw를 정상적으로 사용하려면 instrumentation을 사용해야합니다. 자세한 내용은 공식사이트를 참고해주세요.

이제 실행시켜보면 mock data로 정상적으로 출력되는 것을 확인할 수 있습니다.

profile
풀스택 개발자

1개의 댓글

comment-user-thumbnail
2024년 4월 11일

좋은 글 감사합니다.
그런데 Presentation 제작부에서 public으로 consturctor를 제작하지 않고 private으로 한 후 static form을 사용하는 이유가 별도로 있을까요?

답글 달기