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
utils/assert-value.ts
export const assertValue = <T>(value: T, message: string) => {
if (value === undefined || value === null) {
throw new Error(message)
}
return value
}
값을 검사한 후 Nil 값이라면 오류를 던지는 간단한 유틸 함수 입니다.
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를 사용합니다.
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를 한곳에서 관리하기 위한 파일입니다.
src/apis/types/api-result.ts
export interface ApiResult<T> {
data: T
message?: string
status: 'success' | 'error'
}
Api의 결과 값을 위와 같은 형태로 정의하여 사용하기 쉽게 만듭니다.
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의 기본구조를 그대로 사용할 수 있습니다.
src/apis/types/response/questions.ts
export interface QuestionResponse {
id: string
question: string
}
export interface QuestionsResponse {
questions: QuestionResponse[]
total: number
}
fetch에서 바로 반환받는 response 타입입니다.
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을 하거나 새로운 필드를 추가해서 활용할 수 있다.
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인 것을 볼 수 있습다.
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로 정상적으로 출력되는 것을 확인할 수 있습니다.

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