Skip to main content

이제 우리 앱에 "공식 출입문"을 만듭니다

지난 장에서는 Server Actions를 사용해 폼 제출과 데이터 변경을 아주 자연스럽게 처리했습니다. 덕분에 우리는 작은 기록 상점 안에서 새 기록을 만들고, 기존 기록을 수정할 수 있었죠.

그런데 여기서 한 걸음만 더 가 보면 이런 생각이 듭니다.

"좋아요, 그런데 이 기능을 HTTP 요청으로도 다룰 수는 없을까요?"

예를 들어 이런 상황입니다.

  • 브라우저에서 /api/records 주소로 기록 목록을 받고 싶다.

  • POST 요청으로 새 기록을 만들고 싶다.

  • 나중에는 모바일 앱이나 다른 서비스에서도 이 데이터를 쓰게 하고 싶다.

이때 등장하는 것이 바로 Route Handlers입니다.

이름이 조금 멋있어서 어려워 보이지만, 사실 개념은 단순합니다. Next.js 안에서 서버 요청을 직접 처리하는 함수입니다. 다시 말해, 프론트엔드 프로젝트 안에 작은 백엔드 출입문을 만드는 셈이죠.

이번 장에서는 app/api 아래에 route.ts를 만들고, GET, POST 같은 HTTP 메서드를 처리해 보겠습니다. 그러면 독자는 이런 감각을 얻게 됩니다.

"아, Next.js는 화면만 만드는 도구가 아니구나. 필요한 서버 기능도 한 프로젝트 안에서 자연스럽게 붙일 수 있구나."

이 순간부터 Next.js는 더 이상 React의 친척 정도가 아니라, 제법 든든한 동료처럼 느껴집니다.

Route Handlers는 무엇인가요?

Route Handlers는 App Router에서 API를 만드는 방식입니다.

파일 이름은 특별합니다. 반드시 route.ts 또는 route.js 여야 합니다. 그리고 그 안에서 GET, POST, PUT, PATCH, DELETE 같은 이름의 함수를 내보내면, Next.js가 그 함수를 해당 HTTP 요청의 처리기로 사용합니다.

예를 들어 이런 구조를 만들면:

app/
  api/
    records/
      route.ts

브라우저나 fetch()에서는 이 주소로 접근할 수 있습니다.

/api/records

즉, 폴더 구조가 곧 URL이 됩니다. 페이지를 만들 때와 비슷하죠. 다만 이번에는 화면이 아니라 응답 데이터를 돌려준다는 점이 다릅니다.

Route Handlers가 반가운 이유는 두 가지입니다.

첫째, 문법이 비교적 단순합니다. Express처럼 별도 서버를 따로 세팅하지 않아도 됩니다.

둘째, Web 표준인 Request와 Response 기반으로 동작합니다. 그래서 문법이 낯설기보다 "아, 이거 URL 요청 처리하는 코드구나" 하고 읽히는 편입니다.

Server Actions와는 뭐가 다를까요?

지난 장을 막 공부한 독자라면 당연히 이런 질문이 떠오릅니다.

"그럼 그냥 Server Actions만 쓰면 되는 거 아닌가요?"

좋은 질문입니다. 둘 다 서버에서 실행되지만, 역할이 조금 다릅니다.

  • Server Actions는 우리 앱 내부의 폼 제출이나 데이터 변경 흐름에 아주 잘 어울립니다.

  • Route Handlers는 명시적인 HTTP 주소가 필요할 때 빛납니다.

  • 외부 클라이언트, 웹훅, 모바일 앱, 테스트 도구가 접근해야 한다면 Route Handlers가 더 적합합니다.

쉽게 비유하면 이렇습니다.

Server Actions는 가게 안쪽 직원 전용 통로에 가깝습니다. 내부 동선이 빠르고 자연스럽죠.

반면 Route Handlers는 가게 정문입니다. 누가 와도 "여기로 들어오세요"라고 안내할 수 있는 공식 입구입니다.

그래서 이번 장은 지난 장의 연장선이면서도, 동시에 새로운 문을 여는 장입니다. 이제 우리는 앱 안쪽만 다루는 것이 아니라, HTTP라는 공용 언어로도 기능을 제공하게 됩니다.

작은 기록 상점에 어떤 API를 만들까요?

이번 장에서는 너무 거창하게 시작하지 않겠습니다. 첫 API는 단순할수록 좋습니다.

우리가 만들 API는 이렇습니다.

GET    /api/records       기록 목록 가져오기
POST   /api/records       새 기록 만들기
GET    /api/records/[id]  특정 기록 하나 가져오기
PATCH  /api/records/[id]  특정 기록 수정하기

이번 장의 핵심은 GET과 POST이지만, 동적 라우트까지 연결해 두면 Route Handlers의 진짜 맛이 더 잘 보입니다. 특히 /api/records/[id] 같은 구조는 앞으로 API를 만들 때 아주 자주 만나게 됩니다.

좋은 소식이 하나 더 있습니다. 지난 장에서 만든 lib/record-store.ts와 lib/validations.ts를 그대로 재사용할 수 있습니다.

즉, 이번 장은 새로운 앱을 다시 만드는 장이 아닙니다. 기존 앱에 서버 출입문을 추가하는 장입니다.

먼저 컬렉션 API를 만들어 봅시다

기록 목록과 새 기록 생성을 처리하는 파일부터 만들어 보겠습니다.

파일 위치는 이렇게 잡습니다.

app/
  api/
    records/
      route.ts

코드는 다음처럼 작성할 수 있습니다.

import { NextRequest, NextResponse } from 'next/server'
import { createRecord, getRecordList } from '@/lib/record-store'
import { RecordSchema } from '@/lib/validations'

export async function GET(request: NextRequest) {
  const category = request.nextUrl.searchParams.get('category')
  const records = await getRecordList()

  const filteredRecords =
    category && category !== 'all'
      ? records.filter((record) => record.category === category)
      : records

  return NextResponse.json({
    items: filteredRecords,
    total: filteredRecords.length,
  })
}

export async function POST(request: NextRequest) {
  try {
    const body = await request.json()
    const validated = RecordSchema.safeParse(body)

    if (!validated.success) {
      return NextResponse.json(
        {
          message: '입력값을 다시 확인해 주세요.',
          errors: validated.error.flatten().fieldErrors,
        },
        { status: 400 }
      )
    }

    const newRecord = await createRecord(validated.data)

    return NextResponse.json(
      {
        message: '기록이 만들어졌습니다.',
        item: newRecord,
      },
      { status: 201 }
    )
  } catch {
    return NextResponse.json(
      { message: 'JSON 형식이 올바르지 않습니다.' },
      { status: 400 }
    )
  }
}

처음 보면 조금 길어 보일 수 있지만, 사실 하는 일은 차분합니다.

GET()은 기록 목록을 돌려줍니다. 그리고 ?category=오늘의 기록 같은 쿼리 문자열이 있으면 필터링도 해 줍니다.

POST()는 요청 본문을 읽어서 새 기록을 만듭니다. 여기서 중요한 부분은 request.json()입니다. 클라이언트가 보낸 JSON 데이터를 자바스크립트 객체로 꺼내 주는 역할을 합니다.

그리고 바로 저장하지 않고 RecordSchema.safeParse()로 검사를 합니다. 이 단계가 중요한 이유는 아주 단순합니다. 사용자가 보낸 데이터는 언제나 다시 확인해야 하기 때문입니다.

폼에서 이미 required를 걸었더라도, 서버는 다시 점검해야 합니다. 브라우저 검사는 친절한 안내이고, 서버 검사는 최종 심사라고 생각하면 됩니다.

코드에서 꼭 눈여겨볼 세 가지

이 코드에서 특히 기억할 만한 부분이 세 군데 있습니다.

NextRequest

GET(request: NextRequest)처럼 받는 request는 일반 Request보다 약간 더 편리한 기능이 들어 있는 버전입니다.

예를 들어:

  • request.nextUrl.searchParams로 쿼리 문자열을 읽기 쉽습니다.

  • 쿠키나 URL 정보를 다루는 데 편합니다.

즉, "그냥 요청 객체인데, Next.js가 조금 더 친절하게 포장해 줬다" 정도로 이해하면 충분합니다.

NextResponse.json()

응답을 JSON으로 보낼 때 아주 자주 쓰는 도우미입니다.

return NextResponse.json({ message: '안녕하세요' })

이렇게 쓰면 JSON 응답을 쉽게 만들 수 있습니다. 상태 코드도 함께 줄 수 있습니다.

return NextResponse.json({ message: '생성 완료' }, { status: 201 })

이 201은 "새 리소스를 잘 만들었습니다"라는 뜻입니다. POST에서 자주 쓰는 상태 코드죠.

try ... catch

POST는 요청 본문을 읽는 과정에서 문제가 생길 수 있습니다. 예를 들어 JSON 형식이 깨졌다면 request.json() 단계에서 에러가 날 수 있습니다.

그래서 try ... catch로 감싸 주면, 서버가 갑자기 덜컥 넘어지는 대신 "요청 형식이 잘못됐어요"라고 차분하게 알려줄 수 있습니다.

서버 코드는 멋지게 성공하는 코드보다, 실패했을 때도 얌전한 코드가 더 믿음직합니다.

이번엔 동적 라우트로 상세 API를 만들어 봅시다

이제 특정 기록 하나를 가져오는 출입문도 열어 보겠습니다.

파일 구조는 이렇게 만듭니다.

app/
  api/
    records/
      [id]/
        route.ts

이 구조는 곧 이런 URL과 연결됩니다.

/api/records/1
/api/records/2
/api/records/3

코드는 다음과 같습니다.

import { NextRequest, NextResponse } from 'next/server'
import { getRecordById, updateRecord } from '@/lib/record-store'
import { RecordSchema } from '@/lib/validations'

type RouteContext = {
  params: Promise<{ id: string }>
}

export async function GET(
  _request: NextRequest,
  { params }: RouteContext
) {
  const { id } = await params
  const recordId = Number(id)

  if (Number.isNaN(recordId)) {
    return NextResponse.json(
      { message: '잘못된 id입니다.' },
      { status: 400 }
    )
  }

  const record = await getRecordById(recordId)

  if (!record) {
    return NextResponse.json(
      { message: '기록을 찾을 수 없습니다.' },
      { status: 404 }
    )
  }

  return NextResponse.json(record)
}

export async function PATCH(
  request: NextRequest,
  { params }: RouteContext
) {
  const { id } = await params
  const recordId = Number(id)

  if (Number.isNaN(recordId)) {
    return NextResponse.json(
      { message: '잘못된 id입니다.' },
      { status: 400 }
    )
  }

  try {
    const body = await request.json()
    const validated = RecordSchema.safeParse(body)

    if (!validated.success) {
      return NextResponse.json(
        {
          message: '입력값을 다시 확인해 주세요.',
          errors: validated.error.flatten().fieldErrors,
        },
        { status: 400 }
      )
    }

    const updatedRecord = await updateRecord(recordId, validated.data)

    if (!updatedRecord) {
      return NextResponse.json(
        { message: '수정할 기록을 찾지 못했습니다.' },
        { status: 404 }
      )
    }

    return NextResponse.json({
      message: '기록이 수정되었습니다.',
      item: updatedRecord,
    })
  } catch {
    return NextResponse.json(
      { message: 'JSON 형식이 올바르지 않습니다.' },
      { status: 400 }
    )
  }
}

여기서 눈에 띄는 부분은 바로 이것입니다.

type RouteContext = {
  params: Promise<{ id: string }>
}

그리고 아래에서 await params를 하고 있죠.

const { id } = await params

최신 Next.js 기준에서는 Route Handler의 동적 파라미터가 이렇게 비동기 값으로 들어옵니다. 처음 보면 "왜 이것까지 await를 하지?" 싶을 수 있지만, 지금은 이렇게 기억하면 됩니다.

동적 Route Handler에서 params는 바로 쓰지 말고, await해서 꺼내자.

이 한 줄만 기억해도 삽질을 꽤 줄일 수 있습니다.

/api/records가 실제로 어떻게 동작하는지 상상해 봅시다

이제 서버 안에서 무슨 일이 벌어지는지 순서를 그려 보면 더 선명합니다.

브라우저나 fetch()가 /api/records로 GET 요청을 보냅니다. 서버는 route.ts 파일의 GET() 함수를 실행합니다. 기록 저장소에서 목록을 가져옵니다. 필요하면 카테고리로 필터링합니다. JSON 응답을 돌려줍니다.

반대로 POST는 이렇게 흘러갑니다.

클라이언트가 JSON 본문과 함께 /api/records로 POST 요청을 보냅니다. 서버는 POST() 함수를 실행합니다. 요청 본문을 읽습니다. 유효성 검사를 합니다. 문제가 없으면 새 기록을 만듭니다. 201 Created 상태와 함께 결과를 돌려줍니다.

이 흐름을 이해하면 API가 갑자기 덜 무섭습니다. 결국은 요청을 받고, 검사하고, 처리하고, 응답하는 일입니다.

브라우저와 fetch로 테스트해 보기

이제 실제로 확인해 봅시다.

목록 가져오기

브라우저 주소창에 아래 주소를 입력해 보세요.

http://localhost:3000/api/records

그러면 JSON 응답이 보일 것입니다. 페이지처럼 예쁘지는 않지만, 서버 입장에서는 아주 성실한 결과물입니다.

카테고리 필터도 확인할 수 있습니다.

http://localhost:3000/api/records?category=오늘의 기록

브라우저가 한글 URL은 알아서 처리해 줍니다.

새 기록 만들기

POST는 주소창으로는 보내기 어렵습니다. 이때는 브라우저 콘솔이나 테스트 도구를 사용하면 됩니다.

개발자 도구 콘솔에서 다음 코드를 실행해 보세요.

fetch('/api/records', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    title: 'API로 만든 첫 기록',
    content: '화면이 아니라 HTTP 요청으로도 기록을 만들 수 있다는 사실이 꽤 신기하다.',
    category: '오늘의 기록',
  }),
})
  .then((res) => res.json())
  .then((data) => console.log(data))

성공하면 새 기록 정보가 돌아옵니다.

그리고 다시 /api/records를 열어 보면 방금 추가된 기록이 목록에 포함되어 있을 것입니다. 화면 뒤편에서 백엔드가 조용히 일한 흔적이 보이는 순간이죠.

특정 기록 하나 가져오기

예를 들어 id가 1이라면:

http://localhost:3000/api/records/1

이 주소는 해당 기록 하나만 JSON으로 돌려줍니다.

클라이언트에서 API를 부를 때는 이렇게 생각하면 됩니다

이제 초보자가 많이 헷갈리는 지점을 짚어보겠습니다.

"그럼 앞으로 데이터는 전부 /api/...로만 가져오면 되나요?"

정답은 상황에 따라 다르다입니다.

같은 Next.js 프로젝트 안에서, 특히 Server Component 안에서 데이터를 읽기만 할 때는 굳이 자기 자신의 API를 다시 fetch()하지 않아도 됩니다. 그럴 때는 getRecordList() 같은 서버 함수를 직접 불러오는 편이 더 단순합니다.

즉, 이런 흐름은 괜찮습니다.

const records = await getRecordList()

반면 이런 경우에는 Route Handlers가 잘 어울립니다.

  • 브라우저의 클라이언트 컴포넌트에서 HTTP 요청을 보내고 싶을 때

  • 모바일 앱이나 외부 서비스가 같은 데이터를 써야 할 때

  • 웹훅이나 공개 API처럼 주소 자체가 필요한 경우

한마디로 정리하면 이렇습니다.

서버 내부 로직은 함수로 직접 연결하고, HTTP 경계가 필요할 때 Route Handlers를 쓴다.

이 감각은 나중에 프로젝트가 커졌을 때 정말 중요합니다.

상태 코드는 왜 신경 써야 할까요?

처음 API를 만들면 응답 데이터만 눈에 들어옵니다. 하지만 서버는 데이터만 돌려주는 것이 아니라, 결과의 분위기도 같이 알려줍니다. 그게 상태 코드입니다.

이번 장에서는 특히 이 네 가지를 기억하면 좋습니다.

  • 200: 잘 됐습니다.

  • 201: 새로 만들었습니다.

  • 400: 요청 형식이나 입력값이 잘못됐습니다.

  • 404: 찾으려는 대상이 없습니다.

예를 들어 기록을 새로 만들었는데도 늘 200만 보내면 동작은 할 수 있습니다. 하지만 API를 쓰는 쪽에서는 "이게 단순 조회 성공인지, 새로 생성된 건지"가 조금 흐릿해집니다.

좋은 API는 단순히 작동하는 API가 아니라, 의도를 또렷하게 말하는 API입니다.

최신 Next.js에서 기억할 점

Route Handlers를 공부할 때, 예전 글과 최신 문서가 살짝 다르게 보일 수 있습니다. 그래서 지금 시점에서 특히 기억해 둘 포인트를 정리해 보겠습니다.

동적 params는 비동기처럼 다룹니다

앞에서 본 것처럼 최신 Next.js에서는 Route Handler의 params를 await해서 꺼내는 방식이 자연스럽습니다.

GET은 기본적으로 자동 캐시를 기대하지 않는 편이 안전합니다

예전 자료를 보다 보면 GET이 자동으로 캐시된다고 설명하는 경우가 있습니다. 하지만 최신 흐름에서는 기본 동작이 더 보수적으로 바뀌었습니다.

즉, "GET이면 알아서 정적으로 저장되겠지"라고 생각하기보다, 기본은 요청마다 처리될 수 있다고 이해하는 편이 안전합니다.

캐싱과 리밸리데이션은 다음 장에서 제대로 다룰 예정이니, 여기서는 "기본값이 예전과 같지 않을 수 있다" 정도만 기억해 두면 충분합니다.

지원하지 않는 HTTP 메서드는 자동으로 막아 줍니다

GET, POST만 만들어 두었는데 DELETE 요청이 오면, Next.js는 알아서 적절한 응답을 돌려줍니다. 개발자 입장에서는 꽤 편리한 부분입니다.

실수하기 쉬운 포인트도 미리 짚고 가겠습니다

초보자가 Route Handlers를 처음 만들 때 자주 만나는 함정이 몇 가지 있습니다.

함수 이름을 마음대로 짓지 않기

이건 정말 중요합니다.

export async function GET() {}
export async function POST() {}

이렇게 HTTP 메서드 이름 그대로 써야 합니다.

getRecords, createRecordHandler처럼 멋지게 짓고 싶은 마음이 들어도, Route Handler에서는 통하지 않습니다. 이 규칙을 어기면 "왜 405가 나오지?" 하며 고개를 갸웃하게 됩니다.

page.tsx와 route.ts는 같은 자리에서 경쟁하지 않기

같은 경로 수준에서 페이지와 라우트 핸들러를 동시에 두면 충돌이 생길 수 있습니다. 그래서 보통 API는 app/api/... 아래에 따로 두는 편이 가장 명확합니다.

우리가 이번 장에서 app/api/records/route.ts처럼 만든 것도 그래서입니다.

입력 검사를 클라이언트에만 맡기지 않기

폼에서 이미 검사했더라도 서버에서 다시 확인해야 합니다. 사용자는 브라우저가 아니어도 요청을 보낼 수 있기 때문입니다. API는 언제나 마지막 문지기여야 합니다.

프론트엔드와 백엔드가 한 프로젝트 안에서 만나는 느낌

이번 장의 가장 중요한 수확은 코드 몇 줄이 아닙니다. 감각입니다.

지금 우리는 화면을 그리는 코드와, 서버 요청을 처리하는 코드를 같은 프로젝트 안에서 다루고 있습니다.

예전에는 이 둘 사이에 큰 강이 흐르는 것처럼 느껴졌을 수 있습니다.

프론트는 프론트고, 백엔드는 백엔드고, 둘은 뭔가 아주 멀리 떨어져 있는 것처럼요.

그런데 Next.js에서는 그 거리가 꽤 가깝습니다.

app/page.tsx에서 화면을 만들고, app/api/records/route.ts에서 서버 출입문을 만들고, 둘이 같은 데이터 저장소를 바라보게 할 수 있습니다.

이 경험이 중요한 이유는 단순합니다. 초보자에게 백엔드는 종종 "나중에 배울 거대한 다른 세계"처럼 느껴지기 때문입니다. 하지만 지금 우리가 한 일은 거대한 서버 인프라 구축이 아닙니다. 아주 현실적인 수준에서, 요청을 받고 응답하는 서버 기능을 직접 붙여 본 것입니다.

그 한 번의 경험이 앞으로의 학습 속도를 꽤 바꿔 줍니다.

마무리하며

이번 장에서는 app/api 아래에 route.ts를 만들고, Route Handlers로 간단한 API를 구현했습니다.

우리는 기록 목록을 돌려주는 GET을 만들었고, 새 기록을 생성하는 POST도 만들었습니다. 또 동적 라우트를 이용해 특정 기록 하나를 가져오고 수정하는 흐름까지 살펴봤습니다.

이제 작은 기록 상점은 화면만 예쁜 앱이 아닙니다. 필요한 데이터를 HTTP로 주고받을 수 있는, 제법 당당한 풀스택 앱의 모습을 갖추기 시작했습니다.

다음 장에서는 여기서 한 단계 더 나아가겠습니다. 서버가 응답을 잘 주는 것만으로는 부족하니까요. 더 빠르고, 더 똑똑하고, 더 친절하게 만들어야 합니다.

즉, 다음 장의 주인공은 캐싱, 리밸리데이션, 로딩, 에러 처리입니다. 서버가 일을 잘하는 것도 중요하지만, 사용자가 기다리는 순간까지 잘 돌보는 법도 배워야 하니까요.

책 목록

처음부터 끝까지 만드는 쉬운 Next.js 활용법

전체 목차 보기

이 노트는 보도·비평·교육·연구 목적으로 작성되었습니다. 저작권 문의가 있으시면 에서 알려주세요.

틸노트는 사용자가 AI를 활용해 노트를 쉽게 작성하거나 편집할 수 있는 서비스입니다.

AI 스퀘어 단톡방에 함께해요

AI 활용과 틸노트 사용 팁을 나누는 오픈채팅방입니다. 입장 비밀번호는 tilnote입니다.

참여하기