Web Devlopment/NextJs

#22. Route Handlers

키모형 2025. 12. 29. 14:02
반응형

Next.js Route Handler로 댓글 “완전 CRUD” 만들기

(GET 목록 + POST 추가 + GET/PATCH/DELETE)

Next.js App Router에서는 app/**/route.ts 파일로 Route Handler(API 엔드포인트) 를 만들 수 있습니다.

Route Handler는 Web 표준 Request / Response 기반이며, Next.js가 제공하는 NextResponse.json() 같은 편의 API도 사용할 수 있습니다. nextjs.org+1

또한 같은 라우트 세그먼트 레벨에 page.tsx와 route.ts를 동시에 둘 수는 없습니다. (URL 충돌 방지 규칙) nextjs.org
즉, 아래 예제는 /comments가 “페이지(UI)”가 아니라 “API(JSON)”로 동작하는 구조입니다. /comments에 UI도 만들고 싶으시다면 /api/comments로 API를 옮기는 방식을 권장드립니다.


 

1) 폴더 구조

 
src/app/comments/
  data.ts
  route.ts              // ✅ /comments  (GET 목록, POST 추가)
  [id]/
    route.ts            // ✅ /comments/:id (GET 1건, PATCH 수정, DELETE 삭제)

2) 예제 데이터 (data.ts)

 
// src/app/comments/data.ts
export const comments = [
  { id: 1, text: "This is the first comment" },
  { id: 2, text: "This is the second comment" },
  { id: 3, text: "This is the third comment" },
];

참고: 메모리 배열이므로 서버 재시작 시 초기화됩니다. 학습용 예제로만 적합합니다.


3) /comments 라우트 (목록 조회 + 생성) — src/app/comments/route.ts

아래 파일 하나로 다음이 처리됩니다.

  • GET /comments : 전체 목록 반환
  • POST /comments : { text }를 받아 새 댓글 생성(201 반환)

또한 Route Handler는 기본적으로 캐시되지 않지만(특히 POST 등), 학습 예제에서 “항상 요청 시점 실행”을 확실히 하고 싶으시면 dynamic = 'force-dynamic' 같은 Route Segment 옵션을 둘 수도 있습니다. nextjs.org+1

 
// src/app/comments/route.ts
import { NextResponse } from "next/server";
import { comments } from "./data";

// (선택) 항상 요청 시점 실행을 강제하고 싶으시면 사용하세요.
export const dynamic = "force-dynamic";

export async function GET() {
  // 목록 조회
  return NextResponse.json(comments, { status: 200 });
}

export async function POST(request: Request) {
  // JSON 파싱(잘못된 JSON이면 request.json()에서 예외가 날 수 있습니다)
  let body: any;
  try {
    body = await request.json();
  } catch {
    return NextResponse.json(
      { error: "INVALID_JSON", message: "요청 본문이 올바른 JSON 형식이 아닙니다." },
      { status: 400 }
    );
  }

  const text = body?.text;

  if (typeof text !== "string" || text.trim().length === 0) {
    return NextResponse.json(
      { error: "INVALID_BODY", message: "text는 비어있지 않은 문자열이어야 합니다." },
      { status: 400 }
    );
  }

  // 새 id 생성(학습용: 단순 max + 1)
  const nextId = (comments.reduce((max, c) => Math.max(max, c.id), 0) || 0) + 1;

  const newComment = { id: nextId, text: text.trim() };
  comments.push(newComment);

  // 201 Created + Location 헤더(선택)
  return NextResponse.json(newComment, {
    status: 201,
    headers: { Location: `/comments/${newComment.id}` },
  });
}

4) /comments/:id 라우트 (단건 조회 + 수정 + 삭제) — src/app/comments/[id]/route.ts

여기서는 아래를 처리합니다.

  • GET /comments/:id : 단건 조회
  • PATCH /comments/:id : { text }로 수정
  • DELETE /comments/:id : 삭제 후 삭제된 객체 반환

핵심 포인트는 findIndex() 결과 체크를 반드시 index === -1로 하셔야 한다는 점입니다. (0번 인덱스 오판 방지)

 
// src/app/comments/[id]/route.ts
import { NextResponse } from "next/server";
import { comments } from "../data";

// (선택) 항상 요청 시점 실행
export const dynamic = "force-dynamic";

function parseId(id: string): number | null {
  const n = Number(id);
  return Number.isInteger(n) ? n : null;
}

export async function GET(
  _request: Request,
  { params }: { params: { id: string } }
) {
  const commentId = parseId(params.id);

  if (commentId === null) {
    return NextResponse.json(
      { error: "INVALID_ID", message: "id 값이 올바르지 않습니다." },
      { status: 400 }
    );
  }

  const comment = comments.find((c) => c.id === commentId);

  if (!comment) {
    return NextResponse.json(
      { error: "COMMENT_NOT_FOUND", message: "요청하신 답변을 찾을 수 없습니다." },
      { status: 404 }
    );
  }

  return NextResponse.json(comment, { status: 200 });
}

export async function PATCH(
  request: Request,
  { params }: { params: { id: string } }
) {
  const commentId = parseId(params.id);

  if (commentId === null) {
    return NextResponse.json(
      { error: "INVALID_ID", message: "id 값이 올바르지 않습니다." },
      { status: 400 }
    );
  }

  let body: any;
  try {
    body = await request.json();
  } catch {
    return NextResponse.json(
      { error: "INVALID_JSON", message: "요청 본문이 올바른 JSON 형식이 아닙니다." },
      { status: 400 }
    );
  }

  const text = body?.text;
  if (typeof text !== "string" || text.trim().length === 0) {
    return NextResponse.json(
      { error: "INVALID_BODY", message: "text는 비어있지 않은 문자열이어야 합니다." },
      { status: 400 }
    );
  }

  const index = comments.findIndex((c) => c.id === commentId);
  if (index === -1) {
    return NextResponse.json(
      { error: "COMMENT_NOT_FOUND", message: "요청하신 답변을 찾을 수 없습니다." },
      { status: 404 }
    );
  }

  comments[index].text = text.trim();
  return NextResponse.json(comments[index], { status: 200 });
}

export async function DELETE(
  _request: Request,
  { params }: { params: { id: string } }
) {
  const commentId = parseId(params.id);

  if (commentId === null) {
    return NextResponse.json(
      { error: "INVALID_ID", message: "id 값이 올바르지 않습니다." },
      { status: 400 }
    );
  }

  const index = comments.findIndex((c) => c.id === commentId);
  if (index === -1) {
    return NextResponse.json(
      { error: "COMMENT_NOT_FOUND", message: "요청하신 답변을 찾을 수 없습니다." },
      { status: 404 }
    );
  }

  const deleted = comments[index];
  comments.splice(index, 1);

  return NextResponse.json(deleted, { status: 200 });
}

5) 테스트 예시 (요청/응답)

(1) 목록 조회

 
GET http://localhost:3000/comments

 

응답(200)

[
  { "id": 1, "text": "This is the first comment" },
  { "id": 2, "text": "This is the second comment" },
  { "id": 3, "text": "This is the third comment" }
]
 

(2) 추가

 
POST http://localhost:3000/comments
Content-Type: application/json

{ "text": "new comment" }

 

응답(201)

{ "id": 4, "text": "new comment" }
 

(3) 단건 조회

GET http://localhost:3000/comments/4

(4) 수정

 
PATCH http://localhost:3000/comments/4
Content-Type: application/json

{ "text": "updated comment" }

(5) 삭제

DELETE http://localhost:3000/comments/4
 

6) 실무에서 꼭 알아두실 점(간단)

  • 이 예제는 메모리 배열이라서 “서버 재시작/멀티 인스턴스” 환경에서는 데이터 일관성이 보장되지 않습니다.
  • 실서비스에서는 DB(예: PostgreSQL/MSSQL) + 트랜잭션 기반으로 CRUD를 구현하셔야 합니다.
  • /comments에 UI도 필요하시면, 라우트 충돌을 피하기 위해 API는 /api/comments로 분리하는 구성이 일반적입니다. nextjs.org

 

반응형