๐Ÿš€ Next.js 12์žฅ: Route Handlers โ€” ์™ธ๋ถ€์™€ ๋Œ€ํ™”ํ•˜๋Š” ์ฐฝ๊ตฌ

๐Ÿ“‹ ๊ฐœ์š”

Route Handler๋กœ API ์—”๋“œํฌ์ธํŠธ๋ฅผ ๋งŒ๋“ค๊ณ  ์ธ์ฆ, ์ŠคํŠธ๋ฆฌ๋ฐ, CORS๋ฅผ ์ฒ˜๋ฆฌํ•˜๋Š” ๋ฐฉ๋ฒ•์„ ๋‹ค๋ฃน๋‹ˆ๋‹ค.

๐Ÿ“‹ ๋ชฉ์ฐจ


๐Ÿ“Œ ์ด ๋ฌธ์„œ๋ฅผ ์ฝ๊ธฐ ์ „์—

โฑ๏ธ ์˜ˆ์ƒ ์ฝ๊ธฐ ์‹œ๊ฐ„: 15๋ถ„ (์ „์ฒด) / ํ•ต์‹ฌ ํŒŒํŠธ๋งŒ: 8๋ถ„

๐Ÿ—บ๏ธ ์ด ๋ฌธ์„œ์˜ ๋ฐฐ๊ฒฝ ์„ธ๊ณ„๊ด€: '์˜์ˆ˜๋„ค ์ปค๋ฎค๋‹ˆํ‹ฐ'

  • ์˜์ฒ (์‹ ์ž…): "Stripe ๊ฒฐ์ œ ์™„๋ฃŒ ํ›„ Stripe์—์„œ ์šฐ๋ฆฌ ์„œ๋ฒ„๋กœ ์•Œ๋ฆผ(์›นํ›…)์„ ๋ณด๋‚ด์•ผ ํ•œ๋Œ€์š”. ๊ทผ๋ฐ Server Action์€ use server๋ผ ์šฐ๋ฆฌ Next.js ์•ฑ ๋‚ด๋ถ€์—์„œ๋งŒ ํ˜ธ์ถœ๋˜์ž–์•„์š”. ์™ธ๋ถ€ ์„œ๋น„์Šค๊ฐ€ ์šฐ๋ฆฌํ•œํ…Œ ๋ฐ์ดํ„ฐ๋ฅผ ๋ณด๋‚ด๋ ค๋ฉด ์–ด๋–ป๊ฒŒ ํ•ด์•ผ ํ•˜์ฃ ?"
  • ์˜ํ˜ธ(๋ฆฌ๋“œ): "๋ฐ”๋กœ ๊ทธ๊ฒŒ Route Handler๊ฐ€ ํ•„์š”ํ•œ ์ˆœ๊ฐ„์ด์—์š”. Server Action์ด '๋‚ด๋ถ€ ์ „ํ™”'๋ผ๋ฉด, Route Handler๋Š” '์™ธ๋ถ€์— ๊ณต๊ฐœ๋œ ๋Œ€ํ‘œ ์ „ํ™”๋ฒˆํ˜ธ'์˜ˆ์š”. ์™ธ๋ถ€ ์„œ๋น„์Šค๊ฐ€ POST๋กœ ๋‘๋“œ๋ฆด ์ˆ˜ ์žˆ๋Š” ์‹ค์ œ HTTP ์—”๋“œํฌ์ธํŠธ๋ฅผ ๋งŒ๋“ค์–ด์•ผ ํ•  ๋•Œ ์“ฐ๋Š” ๊ฑฐ์˜ˆ์š”."

๐Ÿ—บ๏ธ ์ด ๋ฌธ์„œ์˜ ํ๋ฆ„
route.ts ๊ธฐ๋ณธ ๋ฌธ๋ฒ• โ†’ ๋™์  URL ์ฒ˜๋ฆฌ โ†’ Server Action๊ณผ์˜ ์—ญํ•  ๋ถ„๋ฆฌ โ†’ ์›นํ›…ยทCORS ๊ณ ๊ธ‰ ํŒจํ„ด

๐ŸŽฏ ์ด ๋ฌธ์„œ๋ฅผ ๋‹ค ์ฝ์œผ๋ฉด ํ•  ์ˆ˜ ์žˆ๋Š” ๊ฒƒ

  • app/api/ ํ•˜์œ„์— route.ts ํŒŒ์ผ๋กœ REST API ์—”๋“œํฌ์ธํŠธ๋ฅผ ๋งŒ๋“ค ์ˆ˜ ์žˆ๋‹ค
  • Server Action๊ณผ Route Handler ์ค‘ ์–ด๋–ค ๊ฑธ ์“ธ์ง€ ์ƒํ™ฉ์— ๋”ฐ๋ผ ํŒ๋‹จํ•  ์ˆ˜ ์žˆ๋‹ค
  • Stripe, GitHub ๋“ฑ ์™ธ๋ถ€ ์„œ๋น„์Šค์˜ ์›นํ›…์„ ๋ฐ›๋Š” ์—”๋“œํฌ์ธํŠธ๋ฅผ ๋งŒ๋“ค ์ˆ˜ ์žˆ๋‹ค

๐Ÿค” ์™œ ์•Œ์•„์•ผ ํ•˜๋Š”๊ฐ€

์‹ฌํ™” 4์žฅ์—์„œ Server Action์„ ๋ฐฐ์› ์–ด. ํผ ์ œ์ถœ, ๋ฒ„ํŠผ ํด๋ฆญ, ๋ฐ์ดํ„ฐ ๋ณ€๊ฒฝ โ€” ์ด๋Ÿฐ ๋‚ด๋ถ€ ๋™์ž‘์€ Server Action์ด ์™„๋ฒฝํ•˜๊ฒŒ ์ฒ˜๋ฆฌํ•ด์ค˜.

๊ทธ๋Ÿฐ๋ฐ ์„ธ์ƒ์—๋Š” ์šฐ๋ฆฌ ์•ฑ ์™ธ๋ถ€์—์„œ ์šฐ๋ฆฌ ์„œ๋ฒ„๋กœ HTTP ์š”์ฒญ์„ ๋ณด๋‚ด์•ผ ํ•˜๋Š” ์ƒํ™ฉ์ด ์žˆ์–ด:

  • **๊ฒฐ์ œ ์„œ๋น„์Šค(Stripe)**๊ฐ€ ๊ฒฐ์ œ ์™„๋ฃŒ ํ›„ ์šฐ๋ฆฌ ์„œ๋ฒ„์— ์›นํ›…(Webhook) ์ „์†ก
  • **๋ชจ๋ฐ”์ผ ์•ฑ(iOS/Android)**์ด REST API ์—”๋“œํฌ์ธํŠธ๋ฅผ ์ง์ ‘ ํ˜ธ์ถœ
  • ๋‹ค๋ฅธ ํŒ€ ์„œ๋น„์Šค๊ฐ€ ์šฐ๋ฆฌ DB ๋ฐ์ดํ„ฐ๋ฅผ ๊ฐ€์ ธ๊ฐ€๊ธฐ ์œ„ํ•ด API ํ˜ธ์ถœ
  • ํŒŒ์ผ ๋‹ค์šด๋กœ๋“œ ๋˜๋Š” ์ด๋ฏธ์ง€ ๋ฆฌ์‚ฌ์ด์ง• ๊ฐ™์€ ์ŠคํŠธ๋ฆฌ๋ฐ ์‘๋‹ต

์ด๋Ÿฐ ๊ฒฝ์šฐ์—” Server Action์„ ์“ธ ์ˆ˜ ์—†์–ด. Route Handler๊ฐ€ ์ด ์—ญํ• ์„ ๋‹ด๋‹นํ•ด. App Router ์„ธ๊ณ„์—์„œ Pages Router์˜ pages/api/*.ts ๋ฅผ ๋Œ€์ฒดํ•˜๋Š” ๋ฐฉ๋ฒ•์ด์•ผ.


๐Ÿ—๏ธ ๋น„์œ ๋กœ ๋จผ์ € ์ดํ•ดํ•˜๊ธฐ

๐Ÿง’ 5์‚ด์—๊ฒŒ ์„ค๋ช…ํ•œ๋‹ค๋ฉด?
ํšŒ์‚ฌ์—๋Š” ๋‘ ๊ฐ€์ง€ ์ „ํ™”๊ฐ€ ์žˆ์–ด. ๋‚ด๋ถ€ ์งํ†ต ์ „ํ™”(์ธํ„ฐํฐ)๋Š” ์ง์›๋“ค๋ผ๋ฆฌ๋งŒ ์จ. ์™ธ๋ถ€ ๋Œ€ํ‘œ ์ „ํ™”๋Š” ๊ณ ๊ฐ์ด๋‚˜ ์™ธ๋ถ€ ํŒŒํŠธ๋„ˆ๊ฐ€ ๊ฑธ์–ด์˜ค๋Š” ๊ฑฐ์•ผ.

Server Action์ด "์ง์› ์ธํ„ฐํฐ"์ด๋ผ๋ฉด, Route Handler๋Š” "ํšŒ์‚ฌ ๋Œ€ํ‘œ ์ „ํ™”๋ฒˆํ˜ธ"์•ผ.
์™ธ๋ถ€์—์„œ ์šฐ๋ฆฌํ•œํ…Œ ์—ฐ๋ฝํ•˜๋ ค๋ฉด ๋Œ€ํ‘œ ๋ฒˆํ˜ธ(Route Handler)๋กœ ์™€์•ผ ํ•ด.

Route Handler์˜ URL ๊ตฌ์กฐ๋Š” ํŒŒ์ผ ์œ„์น˜๋กœ ๊ฒฐ์ •๋ผ:

app/
  api/
    posts/
      route.ts          โ†’ GET /api/posts, POST /api/posts
      [id]/
        route.ts        โ†’ GET /api/posts/123, PUT /api/posts/123, DELETE /api/posts/123
    webhooks/
      stripe/
        route.ts        โ†’ POST /api/webhooks/stripe

๐Ÿงฉ route.ts ๊ธฐ๋ณธ ๊ตฌ์กฐ โ€” GET๊ณผ POST ๋งŒ๋“ค๊ธฐ ๐ŸŸข

๐ŸŽฏ ์ด ์„น์…˜์„ ์ฝ๊ณ  ๋‚˜๋ฉด:

  • route.ts ํŒŒ์ผ์—์„œ HTTP ๋ฉ”์„œ๋“œ๋ณ„ ํ•จ์ˆ˜๋ฅผ exportํ•˜๋Š” ๋ฐฉ๋ฒ•์„ ์•ˆ๋‹ค
  • NextRequest์™€ NextResponse๋ฅผ ํ™œ์šฉํ•ด ์š”์ฒญ/์‘๋‹ต์„ ์ฒ˜๋ฆฌํ•  ์ˆ˜ ์žˆ๋‹ค

route.ts ํŒŒ์ผ์—์„œ๋Š” HTTP ๋ฉ”์„œ๋“œ ์ด๋ฆ„์„ ํ•จ์ˆ˜ ์ด๋ฆ„์œผ๋กœ exportํ•˜๋ฉด ๋ผ. ๊ฐ„๋‹จํ•ด.

// app/api/posts/route.ts
 
import { NextRequest, NextResponse } from 'next/server'
import { db } from '@/lib/db'
 
// GET /api/posts โ€” ๊ฒŒ์‹œ๊ธ€ ๋ชฉ๋ก ์กฐํšŒ
export async function GET(request: NextRequest) {
  // URL ์ฟผ๋ฆฌ ํŒŒ๋ผ๋ฏธํ„ฐ ์ฝ๊ธฐ
  const { searchParams } = request.nextUrl
  const page = searchParams.get('page') ?? '1'
  const limit = searchParams.get('limit') ?? '10'
 
  const posts = await db.posts.findMany({
    skip: (parseInt(page) - 1) * parseInt(limit),
    take: parseInt(limit),
    orderBy: { createdAt: 'desc' },
  })
 
  // NextResponse.json()์œผ๋กœ JSON ์‘๋‹ต ๋ฐ˜ํ™˜
  return NextResponse.json({
    data: posts,
    page: parseInt(page),
  })
}
 
// POST /api/posts โ€” ๊ฒŒ์‹œ๊ธ€ ์ƒ์„ฑ (์™ธ๋ถ€ ํด๋ผ์ด์–ธํŠธ์šฉ)
export async function POST(request: NextRequest) {
  const body = await request.json()    // Request Body ํŒŒ์‹ฑ
 
  // ๊ฐ„๋‹จํ•œ ์œ ํšจ์„ฑ ๊ฒ€์‚ฌ
  if (!body.title || !body.content) {
    // ์—๋Ÿฌ ์‘๋‹ต: ๋‘ ๋ฒˆ์งธ ์ธ์ž๋กœ ์ƒํƒœ ์ฝ”๋“œ ์ง€์ •
    return NextResponse.json(
      { error: '์ œ๋ชฉ๊ณผ ๋‚ด์šฉ์€ ํ•„์ˆ˜์˜ˆ์š”' },
      { status: 400 }
    )
  }
 
  const post = await db.posts.create({ data: body })
 
  return NextResponse.json({ data: post }, { status: 201 })  // 201 Created
}

์ง€์› HTTP ๋ฉ”์„œ๋“œ ๋ชฉ๋ก:

ํ•จ์ˆ˜ export ์ด๋ฆ„HTTP ๋ฉ”์„œ๋“œ
GETGET
POSTPOST
PUTPUT
PATCHPATCH
DELETEDELETE
HEADHEAD
OPTIONSOPTIONS

๐Ÿ’ก ํ•œ ์ค„๋กœ ๊ธฐ์–ตํ•˜๊ธฐ
route.ts์—์„œ HTTP ๋ฉ”์„œ๋“œ ์ด๋ฆ„์œผ๋กœ ํ•จ์ˆ˜๋ฅผ exportํ•˜๋ฉด ๊ทธ๊ฒŒ API ์—”๋“œํฌ์ธํŠธ์•ผ. ๋งˆ์น˜ NestJS์˜ @Get(), @Post() ๋ฐ์ฝ”๋ ˆ์ดํ„ฐ๋ฅผ ํ•จ์ˆ˜ ์ด๋ฆ„์œผ๋กœ ๋Œ€์‹ ํ•˜๋Š” ๊ฒƒ์ฒ˜๋Ÿผ.


๐ŸŒฑ Dynamic Route Handler โ€” URL ํŒŒ๋ผ๋ฏธํ„ฐ์™€ ์ฟผ๋ฆฌ ์ŠคํŠธ๋ง ๐ŸŸก

๐ŸŽฏ ์ด ์„น์…˜์„ ์ฝ๊ณ  ๋‚˜๋ฉด:

  • [id] ๋™์  ์„ธ๊ทธ๋จผํŠธ์—์„œ params๋ฅผ ๋ฐ›์•„ ํŠน์ • ๊ฒŒ์‹œ๊ธ€์„ ์กฐํšŒ/์ˆ˜์ •/์‚ญ์ œํ•  ์ˆ˜ ์žˆ๋‹ค
// app/api/posts/[id]/route.ts
 
import { NextRequest, NextResponse } from 'next/server'
 
type Params = { params: Promise<{ id: string }> }
 
// GET /api/posts/123
export async function GET(request: NextRequest, { params }: Params) {
  const { id } = await params  // Next.js 15: params๋Š” Promise
 
  const post = await db.posts.findUnique({ where: { id } })
 
  if (!post) {
    return NextResponse.json({ error: '๊ฒŒ์‹œ๊ธ€์„ ์ฐพ์„ ์ˆ˜ ์—†์–ด์š”' }, { status: 404 })
  }
 
  return NextResponse.json({ data: post })
}
 
// PUT /api/posts/123 โ€” ๊ฒŒ์‹œ๊ธ€ ์ˆ˜์ •
export async function PUT(request: NextRequest, { params }: Params) {
  const { id } = await params
  const body = await request.json()
 
  const updated = await db.posts.update({
    where: { id },
    data: body,
  })
 
  return NextResponse.json({ data: updated })
}
 
// DELETE /api/posts/123 โ€” ๊ฒŒ์‹œ๊ธ€ ์‚ญ์ œ
export async function DELETE(request: NextRequest, { params }: Params) {
  const { id } = await params
 
  await db.posts.delete({ where: { id } })
 
  return new NextResponse(null, { status: 204 })  // 204 No Content
}

๐Ÿ”‘ ์–ธ์ œ Route Handler๋ฅผ ์“ฐ๊ณ , ์–ธ์ œ Server Action์„ ์“ฐ๋Š”๊ฐ€ ๐ŸŸก

๐ŸŽฏ ์ด ์„น์…˜์„ ์ฝ๊ณ  ๋‚˜๋ฉด:

  • ๋‘ ๊ธฐ์ˆ ์˜ ์ •ํ™•ํ•œ ์—ญํ•  ๊ฒฝ๊ณ„๋ฅผ ์•Œ๊ณ , ์ƒํ™ฉ์— ๋”ฐ๋ผ ์˜ฌ๋ฐ”๋ฅธ ์„ ํƒ์„ ํ•  ์ˆ˜ ์žˆ๋‹ค

๐Ÿค” ์ž ๊น, ๋จผ์ € ์ƒ๊ฐํ•ด๋ด
๊ฒŒ์‹œ๊ธ€ ์ž‘์„ฑ ํผ ์ œ์ถœ โ†’ Server Action? Route Handler?
Stripe ์›นํ›… ์ˆ˜์‹  โ†’ Server Action? Route Handler?

์ด ์งˆ๋ฌธ์— ์‰ฝ๊ฒŒ ๋Œ€๋‹ตํ•  ์ˆ˜ ์žˆ์œผ๋ฉด ์ด ์„น์…˜์„ ์ดํ•ดํ•œ ๊ฑฐ์•ผ.

ํ•ต์‹ฌ ํŒ๋‹จ ๊ธฐ์ค€: ๋ˆ„๊ฐ€ ํ˜ธ์ถœํ•˜๋Š”๊ฐ€?

ํŠน์ง•Server ActionRoute Handler
ํ˜ธ์ถœ ์ฃผ์ฒด์šฐ๋ฆฌ React ์•ฑ ๋‚ด๋ถ€์™ธ๋ถ€ ์„œ๋น„์Šค, ๋ชจ๋ฐ”์ผ ์•ฑ, ๋ธŒ๋ผ์šฐ์ € ์ง์ ‘ fetch
HTTP ๋ฉ”์„œ๋“œPOST ๊ณ ์ •GET, POST, PUT, DELETE ๋ชจ๋‘
ํƒ€์ž… ์•ˆ์ „์ž๋™ (ํ•จ์ˆ˜ import)์ˆ˜๋™ (req/res ํŒŒ์‹ฑ)
์ ์ง„์  ํ–ฅ์ƒโœ… JS ์—†์–ด๋„ ๋™์ž‘โŒ ์ง€์› ์•ˆ ํ•จ
์บ์‹ฑ์บ์‹œ ์•ˆ ๋จGET ์š”์ฒญ ์บ์‹œ ๊ฐ€๋Šฅ
์ฃผ์š” ์‚ฌ์šฉ์ฒ˜ํผ, ๋ฒ„ํŠผ, ๋ฐ์ดํ„ฐ ๋ณ€๊ฒฝ์›นํ›…, ์™ธ๋ถ€ API, ํŒŒ์ผ ๋‹ค์šด๋กœ๋“œ
// โœ… Server Action์œผ๋กœ ์จ์•ผ ํ•˜๋Š” ๊ฒฝ์šฐ
- ๊ฒŒ์‹œ๊ธ€ ์ž‘์„ฑ ํผ ์ œ์ถœ
- ๋Œ“๊ธ€ ์‚ญ์ œ ๋ฒ„ํŠผ ํด๋ฆญ
- ์ข‹์•„์š” ํ† ๊ธ€

// โœ… Route Handler๋กœ ์จ์•ผ ํ•˜๋Š” ๊ฒฝ์šฐ
- Stripe ๊ฒฐ์ œ ์™„๋ฃŒ ์›นํ›… ์ˆ˜์‹  (์™ธ๋ถ€์—์„œ POST)
- ๋ชจ๋ฐ”์ผ ์•ฑ์ด ๊ฒŒ์‹œ๊ธ€ ๋ชฉ๋ก ์š”์ฒญ (์™ธ๋ถ€์—์„œ GET)
- CSV ํŒŒ์ผ ๋‹ค์šด๋กœ๋“œ (์ŠคํŠธ๋ฆผ ์‘๋‹ต)

๐Ÿ’ก ํ•œ ์ค„๋กœ ๊ธฐ์–ตํ•˜๊ธฐ
"ํ˜ธ์ถœ์ž๊ฐ€ ์šฐ๋ฆฌ ์•ฑ์ด๋ฉด Server Action, ์™ธ๋ถ€ ์„œ๋น„์Šค๋ฉด Route Handler." ์ด ํ•œ ๋ฌธ์žฅ์ด ์ „๋ถ€์•ผ.


๐ŸŒ CORS, ์›นํ›…, ํŒŒ์ผ ๋‹ค์šด๋กœ๋“œ ๊ณ ๊ธ‰ ํŒจํ„ด ๐Ÿ”ด

๐ŸŒ CORS ํ—ค๋” ์„ค์ •

๋ชจ๋ฐ”์ผ ์•ฑ์ด๋‚˜ ๋‹ค๋ฅธ ๋„๋ฉ”์ธ์—์„œ ์šฐ๋ฆฌ API๋ฅผ ํ˜ธ์ถœํ•  ๋•Œ CORS ์—๋Ÿฌ๊ฐ€ ๋ฐœ์ƒํ•ด. Response์— ํ—ค๋”๋ฅผ ์ง์ ‘ ์ถ”๊ฐ€ํ•˜๊ฑฐ๋‚˜, next.config.ts์—์„œ ์„ค์ •ํ•  ์ˆ˜ ์žˆ์–ด.

// app/api/posts/route.ts โ€” CORS ํ—ค๋” ์ถ”๊ฐ€
export async function GET(request: NextRequest) {
  const posts = await db.posts.findMany()
 
  return NextResponse.json({ data: posts }, {
    headers: {
      'Access-Control-Allow-Origin': 'https://our-mobile-app.com',
      'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
      'Access-Control-Allow-Headers': 'Content-Type, Authorization',
    },
  })
}
 
// OPTIONS preflight ์š”์ฒญ๋„ ์ฒ˜๋ฆฌ
export async function OPTIONS() {
  return new NextResponse(null, {
    headers: {
      'Access-Control-Allow-Origin': '*',
      'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
    },
  })
}

๐Ÿ”” Stripe ์›นํ›… ์ˆ˜์‹  ํŒจํ„ด

์™ธ๋ถ€ ์„œ๋น„์Šค์˜ ์›นํ›…์„ ๋ฐ›์„ ๋•Œ๋Š” ์„œ๋ช…(Signature) ๊ฒ€์ฆ์ด ํ•„์ˆ˜์•ผ. ์•ˆ ํ•˜๋ฉด ๋ˆ„๊ตฌ๋‚˜ ๊ฐ€์งœ ์š”์ฒญ์„ ๋ณด๋‚ผ ์ˆ˜ ์žˆ์–ด.

// app/api/webhooks/stripe/route.ts
import { headers } from 'next/headers'
import Stripe from 'stripe'
 
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)
 
export async function POST(request: NextRequest) {
  // Raw body๊ฐ€ ํ•„์š”ํ•œ Stripe ์„œ๋ช… ๊ฒ€์ฆ์„ ์œ„ํ•ด text()๋กœ ๋ฐ›์Œ
  const body = await request.text()
  const headersList = await headers()
  const signature = headersList.get('stripe-signature')!
 
  let event: Stripe.Event
  try {
    // Stripe๊ฐ€ ์š”์ฒญ์— ์„œ๋ช…์„ ๋„ฃ์–ด์ฃผ๋Š”๋ฐ, ์ด๊ฑธ ๊ฒ€์ฆํ•ด์•ผ ๊ฐ€์งœ ์š”์ฒญ์„ ๊ฑธ๋Ÿฌ๋‚ผ ์ˆ˜ ์žˆ์Œ
    event = stripe.webhooks.constructEvent(
      body,
      signature,
      process.env.STRIPE_WEBHOOK_SECRET!
    )
  } catch (err) {
    console.error('์›นํ›… ์„œ๋ช… ๊ฒ€์ฆ ์‹คํŒจ:', err)
    return NextResponse.json({ error: 'Invalid signature' }, { status: 400 })
  }
 
  // ์ด๋ฒคํŠธ ํƒ€์ž…๋ณ„ ์ฒ˜๋ฆฌ
  switch (event.type) {
    case 'payment_intent.succeeded':
      const paymentIntent = event.data.object as Stripe.PaymentIntent
      await db.orders.update({
        where: { paymentId: paymentIntent.id },
        data: { status: 'PAID' },
      })
      break
    case 'customer.subscription.deleted':
      // ๊ตฌ๋… ์ทจ์†Œ ์ฒ˜๋ฆฌ
      break
  }
 
  return NextResponse.json({ received: true })
}

๐Ÿ“ฅ ํŒŒ์ผ ๋‹ค์šด๋กœ๋“œ (์ŠคํŠธ๋ฆฌ๋ฐ ์‘๋‹ต)

// app/api/export/posts/route.ts โ€” CSV ํŒŒ์ผ ๋‹ค์šด๋กœ๋“œ
export async function GET() {
  const posts = await db.posts.findMany()
 
  // CSV ๋ฌธ์ž์—ด ์ƒ์„ฑ
  const csv = [
    'id,title,createdAt',
    ...posts.map((p) => `${p.id},"${p.title}",${p.createdAt}`),
  ].join('\n')
 
  return new NextResponse(csv, {
    headers: {
      'Content-Type': 'text/csv; charset=utf-8',
      'Content-Disposition': 'attachment; filename="posts.csv"',
    },
  })
}

๐Ÿ’ฅ ์—๋Ÿฌ ํ•ด๊ฒฐ ์นดํƒˆ๋กœ๊ทธ


โŒ route.ts์™€ page.tsx๊ฐ€ ๊ฐ™์€ ๊ฒฝ๋กœ์— ์žˆ์–ด์„œ ์ถฉ๋Œ

์–ธ์ œ ๋‚˜์˜ค๋Š”๊ฐ€?

Error: You cannot have both a `page` file and a `route` file at the same path.

์›์ธ: app/api/posts/page.tsx์™€ app/api/posts/route.ts๋ฅผ ๊ฐ™์€ ๊ฒฝ๋กœ์— ๋’€์„ ๋•Œ.

ํ•ด๊ฒฐ์ฑ…: Route Handler๋Š” app/api/ ํ•˜์œ„์—, ํŽ˜์ด์ง€๋Š” app/ ์ง์ ‘ ํ•˜์œ„์— ๋ถ„๋ฆฌํ•ด์„œ ๊ด€๋ฆฌํ•ด.


โŒ GET ์š”์ฒญ์ธ๋ฐ POST ๋ฉ”์„œ๋“œ ์—๋Ÿฌ

์–ธ์ œ ๋‚˜์˜ค๋Š”๊ฐ€?

{ "error": "Method Not Allowed" }

์›์ธ: route.ts์—์„œ GET ํ•จ์ˆ˜๋ฅผ exportํ•˜์ง€ ์•Š์•˜๋Š”๋ฐ GET ์š”์ฒญ์ด ๋“ค์–ด์˜จ ๊ฒฝ์šฐ.

ํ•ด๊ฒฐ์ฑ…: ํ•„์š”ํ•œ HTTP ๋ฉ”์„œ๋“œ๋งˆ๋‹ค ํ•จ์ˆ˜๋ฅผ exportํ•ด์•ผ ํ•ด. ์ฒ˜๋ฆฌํ•˜์ง€ ์•Š์„ ๋ฉ”์„œ๋“œ๋Š” export ์ž์ฒด๋ฅผ ์•ˆ ํ•˜๋ฉด ์ž๋™์œผ๋กœ 405 ์‘๋‹ต์„ ๋ฐ˜ํ™˜ํ•ด์ค˜.


๐Ÿ ์ด๋ฒˆ์— ๋ฐฐ์šด ๋‚ด์šฉ ์ด์ •๋ฆฌ

๐Ÿ“‹ ํ•ต์‹ฌ ํŒจํ„ด

์ƒํ™ฉ์ฝ”๋“œ ํŒจํ„ด
GET ์‘๋‹ตreturn NextResponse.json({ data })
์—๋Ÿฌ ์‘๋‹ตreturn NextResponse.json({ error }, { status: 400 })
๋นˆ ์‘๋‹ตreturn new NextResponse(null, { status: 204 })
URL paramsconst { id } = await params
์ฟผ๋ฆฌ ์ŠคํŠธ๋งrequest.nextUrl.searchParams.get('page')
Request Bodyconst body = await request.json()

โš ๏ธ ์ ˆ๋Œ€ ํ•˜์ง€ ๋ง ๊ฒƒ

์ƒํ™ฉโŒ ๋‚˜์œ ์˜ˆโœ… ์ข‹์€ ์˜ˆ
๋‚ด๋ถ€ ํผ ์ œ์ถœRoute HandlerServer Action
์›นํ›… ์ˆ˜์‹ Server ActionRoute Handler
์›นํ›… ์„œ๋ช… ๊ฒ€์ฆ ์•ˆ ํ•จbody๋งŒ ํŒŒ์‹ฑstripe.webhooks.constructEvent() ๊ฒ€์ฆ

๐Ÿ“ ๋งˆ๋ฌด๋ฆฌ ํ€ด์ฆˆ

Q1. Stripe ์›นํ›…์„ ์ˆ˜์‹ ํ•˜๋Š” ์—”๋“œํฌ์ธํŠธ๋ฅผ ๋งŒ๋“ค์–ด์•ผ ํ•œ๋‹ค. ์–ด๋–ค ๋ฐฉ์‹์„ ์จ์•ผ ํ•˜๋Š”๊ฐ€?

  • A) Server Action โ€” 'use server'๋กœ ์„ ์–ธํ•œ ํ•จ์ˆ˜
  • B) Route Handler โ€” app/api/webhooks/stripe/route.ts์˜ POST ํ•จ์ˆ˜
  • C) ์„œ๋ฒ„ ์ปดํฌ๋„ŒํŠธ์—์„œ ์ง์ ‘ fetch๋กœ Stripe API ํ˜ธ์ถœ
  • D) ํด๋ผ์ด์–ธํŠธ ์ปดํฌ๋„ŒํŠธ์—์„œ useEffect๋กœ ์ฒ˜๋ฆฌ

โœ… ์ •๋‹ต: B

์˜ค๋‹ต ํ•ด์„ค:

  • A โ€” Server Action์€ ๋‚ด๋ถ€์—์„œ๋งŒ ํ˜ธ์ถœ ๊ฐ€๋Šฅ. ์™ธ๋ถ€ ์„œ๋น„์Šค๊ฐ€ ์ง์ ‘ ํ˜ธ์ถœ ๋ถˆ๊ฐ€
  • C โ€” ์„œ๋ฒ„ ์ปดํฌ๋„ŒํŠธ๋Š” ๋ Œ๋”๋ง ์‹œ์ ์—๋งŒ ์‹คํ–‰. ์›นํ›… ์ˆ˜์‹  ๋ถˆ๊ฐ€
  • D โ€” ํด๋ผ์ด์–ธํŠธ์—์„œ ๋น„๋ฐ€ ํ‚ค๋ฅผ ๋‹ค๋ฃจ๋ฉด ๋ณด์•ˆ ์œ„ํ—˜

๐Ÿ“Œ ํ•ต์‹ฌ ๊ธฐ์–ต๋ฒ•: "์™ธ๋ถ€๊ฐ€ ์šฐ๋ฆฌ๋ฅผ ์ฐŒ๋ฅธ๋‹ค โ†’ Route Handler"


Q2. ์•„๋ž˜ ๋นˆ์นธ์„ ์ฑ„์›Œ๋ณด์ž.

/api/users/42 ์— DELETE ์š”์ฒญ์ด ์˜ค๋ฉด ์‚ฌ์šฉ์ž๋ฅผ ์‚ญ์ œํ•˜๋Š” Route Handler๋‹ค.

// app/api/users/[id]/route.ts
export async function DELETE(
  request: NextRequest,
  { params }: { params: Promise<{ id: string }> }
) {
  const { id } = await ______
  await db.users.delete({ where: { id } })
  return new NextResponse(null, { status: ______ })
}

โœ… ์ •๋‹ต: params, 204

ํ•ด์„ค: params๋Š” Promise๋ผ await ํ•„์š”. ์‚ญ์ œ ์„ฑ๊ณต ์‹œ ๋ฐ˜ํ™˜ํ•  ๋‚ด์šฉ์ด ์—†์œผ๋ฏ€๋กœ 204 No Content.

๐Ÿ“Œ ํ•ต์‹ฌ ๊ธฐ์–ต๋ฒ•: DELETE๋Š” 204, 404๊ฐ€ ์ž์ฃผ ์ง์œผ๋กœ ๋‚˜์™€. "์‚ญ์ œํ•˜๋ฉด ๋‚ด์šฉ ์—†์Œ(204), ์—†๋Š” ๊ฑฐ ์‚ญ์ œํ•˜๋ฉด ์ฐพ์„ ์ˆ˜ ์—†์Œ(404)."


Q3. ์นœ๊ตฌ์—๊ฒŒ ์„ค๋ช…ํ•œ๋‹ค๋ฉด?

Server Action๊ณผ Route Handler์˜ ์ฐจ์ด๋ฅผ ์ „ํ™”๊ธฐ ๋น„์œ ๋กœ ์„ค๋ช…ํ•ด๋ด.

์˜ˆ์‹œ ๋‹ต๋ณ€:

"Server Action์€ ์‚ฌ๋ฌด์‹ค ์ธํ„ฐํฐ์ด์•ผ. ๊ฐ™์€ ๊ฑด๋ฌผ(์šฐ๋ฆฌ Next.js ์•ฑ) ์•ˆ์—์„œ๋งŒ ํ†ตํ™” ๊ฐ€๋Šฅํ•ด. Route Handler๋Š” ๋Œ€ํ‘œ ์ „ํ™”์•ผ. ์™ธ๋ถ€ ์‚ฌ๋žŒ(Stripe, ๋ชจ๋ฐ”์ผ ์•ฑ)์ด ๊ฑธ์–ด์˜ฌ ์ˆ˜ ์žˆ์–ด. ๊ฒฐ์ œ ์™„๋ฃŒ ์›นํ›…์ฒ˜๋Ÿผ ๋ฐ–์—์„œ ์—ฐ๋ฝ์ด ์˜ค๋ฉด ๋Œ€ํ‘œ ์ „ํ™”๋ฅผ ์จ์•ผ์ง€."


๐Ÿฃ ์˜์ฒ ์ด์˜ ํ‡ด๊ทผ ์ผ๊ธฐ

์˜ค๋Š˜์€ ์šฐ๋ฆฌ ์„œ๋น„์Šค์˜ '์™ธ๋ถ€์šฉ ๋Œ€ํ‘œ ๋ฒˆํ˜ธ(Route Handler)'๋ฅผ ๊ฐœ์„คํ•œ ๋‚ ์ด์•ผ! ๊ทธ๋™์•ˆ ์šฐ๋ฆฌ ์•ฑ ์•ˆ์—์„œ๋งŒ ์†๋‹ฅ๊ฑฐ๋ฆฌ๋˜ Server Action๋งŒ ์•Œ๋‹ค๊ฐ€, ์™ธ๋ถ€ ์„œ๋น„์Šค์ธ Stripe์™€ ๋Œ€ํ™”ํ•˜๊ธฐ ์œ„ํ•œ ์—”๋“œํฌ์ธํŠธ๋ฅผ ์ง์ ‘ ๋งŒ๋“œ๋‹ˆ ์ •๋ง ์‹ ๊ธฐํ–ˆ์–ด.

๐Ÿ’ก ์˜ค๋Š˜์˜ ๊ตํ›ˆ: "๋‚ด๋ถ€ ์†Œํ†ต์€ Server Action, ์™ธ๋ถ€ ์„œ๋น„์Šค์™€์˜ ๋Œ€ํ™”๋Š” Route Handler๋ฅผ ์“ฐ์ž. ์ „ํ™”๋ฒˆํ˜ธ๋ฅผ ๊ณต๊ฐœํ–ˆ๋‹ค๋ฉด ๋ณด์•ˆ ๊ฒ€์ฆ์€ ํ•„์ˆ˜๋‹ค!"

๋‹จ์ˆœํžˆ URL๋งŒ ๋šซ๋Š” ๊ฒŒ ์•„๋‹ˆ๋ผ, ์™ธ๋ถ€์˜ ๊ณต๊ฒฉ์„ ๋ง‰๊ธฐ ์œ„ํ•ด ์„œ๋ช…(Signature)์„ ๊ฒ€์ฆํ•˜๊ณ  ๋ณด์•ˆ์„ ์ฑ™๊ธฐ๋Š” ๊ณผ์ •์—์„œ ์ง„์งœ '์„œ๋ฒ„ ๊ฐœ๋ฐœ์ž'๊ฐ€ ๋œ ๊ธฐ๋ถ„์ด ๋“ค๋”๋ผ. ์˜ค๋Š˜์€ ์œ ๋‚œํžˆ ์—๋„ˆ์ง€๋ฅผ ๋งŽ์ด ์ผ๋„ค. ํ‡ด๊ทผ๊ธธ์— ๋งˆํŠธ์— ๋“ค๋Ÿฌ์„œ ๋ง›์žˆ๋Š” ๊ณ ๊ธฐ ์‚ฌ๋‹ค๊ฐ€ ๊ตฌ์›Œ ๋จน์–ด์•ผ์ง€! ๋‚ด์ผ์€ ๋” '์šฐ์•„ํ•œ' ์—”๋“œํฌ์ธํŠธ๋ฅผ ์„ค๊ณ„ํ•˜๋Š” ๊ฐœ๋ฐœ์ž๊ฐ€ ๋˜์–ด์•ผ๊ฒ ์–ด. ๐Ÿฃ


๐Ÿ”— ๋” ์•Œ์•„๋ณด๊ธฐ