Published on

Next.js 14 서버액션 500·CSRF·캐시 꼬임 해결

Authors

서버 액션(Server Actions)은 Next.js 14에서 폼 제출/뮤테이션을 서버로 직행시키는 강력한 기능이지만, 운영 환경에서는 의외로 500, “CSRF 같은데?” 싶은 실패, 그리고 캐시/프리패치로 인한 상태 꼬임이 함께 나타나는 경우가 많습니다. 특히 App Router의 RSC(React Server Components) + 캐시 레이어 + 프리패치가 얽히면, 원인이 하나인데 증상이 여러 개로 보이기도 합니다.

이 글은 “왜 이런 일이 생기는지”를 추적 가능한 단위로 쪼개고, 재현 → 관측 → 차단 순서로 해결책을 정리합니다. (비슷한 방식으로 장애를 쪼개서 다루는 글로는 OpenAI Responses API 500·503 대응 재시도 폴백 서킷브레이커도 참고가 됩니다.)


1) 서버 액션 호출 경로를 먼저 고정하기

서버 액션은 대체로 다음 두 경로로 실행됩니다.

  1. <form action={action}>: 브라우저가 폼을 제출하며 Next가 내부적으로 액션 엔드포인트를 호출
  2. 클라이언트 컴포넌트에서 액션을 직접 호출: const result = await action(formData) 같은 형태

둘은 겉보기엔 비슷하지만, 쿠키/헤더/리다이렉트/프리패치/캐시 관여 방식이 달라서 문제를 분리하려면 호출 경로를 고정해야 합니다.

체크리스트

  • 문제 재현 시 form 기반인지, 직접 호출인지부터 확정
  • next dev가 아니라 **next build && next start**로 재현 (개발 모드와 캐시/프리패치 동작이 다름)
  • 배포 환경(Vercel/Node/Edge)에서 런타임 차이 확인

2) 서버 액션 500: “진짜 서버 예외” vs “프레임워크 보호장치”

서버 액션에서 500이 뜨면 대부분은 “서버에서 에러가 났다”이지만, 운영에서 자주 보이는 패턴은 다음 3가지입니다.

(1) 런타임/번들링 차이로 인한 예외

  • edge 런타임에서 Node 전용 모듈(crypto, 파일 I/O 등) 사용
  • 서버 액션에서 process.env 의존이 있는데 배포 환경에서 누락

(2) DB/외부 API 예외를 그대로 던짐

  • 액션 내부에서 예외가 throw되고, 클라이언트는 500만 받음

(3) 요청 무결성 검증 실패(보안/CSRF 유사)

  • Origin/Host 불일치, 프록시 뒤에서 헤더가 바뀜
  • 쿠키 SameSite/secure 설정 때문에 세션이 안 붙음

먼저 **관측 가능성(Observability)**을 확보해야 합니다. 액션에서 에러를 감싸고, 최소한의 컨텍스트를 로그로 남기세요.

// app/actions.ts
'use server'

import { headers } from 'next/headers'

export async function updateProfile(_: any, formData: FormData) {
  const h = headers()

  try {
    const name = String(formData.get('name') ?? '')
    if (!name) throw new Error('name is required')

    // ...DB update
    return { ok: true }
  } catch (err: any) {
    // 운영에서는 PII 주의, 필요한 최소 정보만
    console.error('[server-action] updateProfile failed', {
      message: err?.message,
      // 프록시/CSRF/캐시 이슈 추적에 도움
      host: h.get('host'),
      origin: h.get('origin'),
      referer: h.get('referer'),
      // next 내부 헤더가 붙는 경우가 있어 비교에 유용
      xForwardedProto: h.get('x-forwarded-proto'),
      xForwardedHost: h.get('x-forwarded-host'),
    })

    // 클라이언트로는 안전한 메시지
    throw new Error('Failed to update profile')
  }
}

포인트: “500이 떴다”는 정보는 부족합니다. host/origin/referer/x-forwarded-*가 같이 있어야 CSRF/프록시/도메인 문제인지 빠르게 갈라집니다.


3) CSRF처럼 보이는 실패: 사실은 Origin/Host/프록시/쿠키 문제인 경우

Next.js의 서버 액션은 내부적으로 요청을 검증합니다. 여기서 흔히 터지는 게 Origin/Host 불일치입니다.

대표 증상

  • 로컬에서는 됐는데 배포에서만 500
  • 특정 브라우저/특정 서브도메인에서만 실패
  • Cloudflare/ALB/Nginx 뒤에서만 실패

원인 1) 프록시 뒤에서 Host/Proto가 바뀜

예: 외부는 https://app.example.com인데, 내부로는 http://localhost:3000처럼 전달되어 Origin과 Host가 불일치

해결 방향

  • 프록시에서 X-Forwarded-Proto, X-Forwarded-Host를 올바르게 전달
  • Next 서버가 이를 신뢰하도록 배포 구성을 정리

Nginx 예시:

location / {
  proxy_set_header Host $host;
  proxy_set_header X-Forwarded-Host $host;
  proxy_set_header X-Forwarded-Proto $scheme;
  proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;

  proxy_pass http://127.0.0.1:3000;
}

원인 2) 쿠키 SameSite/Secure로 세션이 안 붙음

서버 액션이 세션 쿠키를 전제로 동작하는데, 다음 상황에서 쿠키가 누락됩니다.

  • HTTPS인데 쿠키에 secure가 빠짐(혹은 반대)
  • 크로스 사이트/서브도메인 이동에서 SameSite=Lax/Strict로 인해 쿠키가 안 감

NextAuth/커스텀 세션을 쓴다면 쿠키 옵션을 점검하세요.

// 예: next-auth 또는 커스텀 세션 쿠키 설정 시 개념 예시
const cookieOptions = {
  httpOnly: true,
  secure: process.env.NODE_ENV === 'production',
  sameSite: 'lax' as const,
  path: '/',
}

원인 3) “다른 도메인에서 액션을 호출”하는 구조

서버 액션은 기본적으로 동일 출처 기반 사용을 기대합니다. API 서버를 분리하고 싶다면 서버 액션 대신 Route Handler를 두고 명시적으로 CSRF 토큰을 검증하는 편이 더 예측 가능합니다.


4) 캐시 꼬임: “업데이트했는데 화면이 옛날 데이터”의 정체

서버 액션을 쓰면 흔히 이런 현상을 만납니다.

  • 저장 버튼을 눌렀는데 화면이 그대로
  • 뒤로 갔다 오면 반영됨
  • 어떤 페이지에서는 반영되고, 어떤 페이지에서는 안 됨

대부분은 RSC fetch 캐시 또는 페이지 세그먼트 캐시가 갱신되지 않아서입니다.

해결의 기본: revalidatePath / revalidateTag

뮤테이션(쓰기) 이후에 읽기 캐시를 무효화해야 합니다.

// app/actions.ts
'use server'

import { revalidatePath, revalidateTag } from 'next/cache'

export async function updateProfile(_: any, formData: FormData) {
  const name = String(formData.get('name') ?? '')

  // 1) DB 업데이트
  await db.user.update({ name })

  // 2) 화면 갱신 전략
  // - 특정 페이지 단위로 무효화
  revalidatePath('/settings')

  // - fetch에 tag를 붙여 썼다면 tag 기반으로 무효화
  revalidateTag('user:me')

  return { ok: true }
}

그리고 데이터를 가져오는 쪽에서 tag를 붙입니다.

// app/settings/page.tsx (Server Component)
export default async function SettingsPage() {
  const me = await fetch('https://api.example.com/me', {
    // 캐시를 쓰되 태그로 제어
    next: { tags: ['user:me'] },
  }).then(r => r.json())

  return <pre>{JSON.stringify(me, null, 2)}</pre>
}

“캐시를 끄면 되잖아?”가 위험한 이유

cache: 'no-store'를 남발하면:

  • 서버 부하 증가
  • TTFB 증가
  • 같은 데이터를 여러 컴포넌트에서 중복 호출

대신 읽기는 캐시, 쓰기는 revalidate가 기본 패턴입니다.


5) 프리패치/중복 제출로 상태가 꼬이는 케이스

App Router에서는 next/link 프리패치, React의 동시성, 사용자의 더블 클릭 등이 겹치면 서버 액션이 중복 실행될 수 있습니다.

해결 1) useFormStatus로 버튼 잠금

// app/settings/ProfileForm.tsx
'use client'

import { useFormStatus } from 'react-dom'

function SubmitButton() {
  const { pending } = useFormStatus()
  return (
    <button type="submit" disabled={pending}>
      {pending ? 'Saving...' : 'Save'}
    </button>
  )
}

export function ProfileForm({ action }: { action: any }) {
  return (
    <form action={action}>
      <input name="name" />
      <SubmitButton />
    </form>
  )
}

해결 2) 서버에서 멱등성 키(idempotency key) 적용

결제/주문/포인트 같은 치명적 액션은 클라이언트 잠금만으로 부족합니다. 서버에서 중복 요청을 같은 결과로 수렴시키세요.

// app/actions.ts
'use server'

import { cookies } from 'next/headers'

export async function createOrder(_: any, formData: FormData) {
  const key = String(formData.get('idempotencyKey') ?? '')
  if (!key) throw new Error('idempotencyKey required')

  // 예: Redis/DB에 key 저장 후 중복이면 기존 결과 반환
  const existing = await db.idempotency.findUnique({ key })
  if (existing) return existing.result

  const order = await db.order.create({ /* ... */ })
  await db.idempotency.create({ key, result: order })

  return order
}

이런 “중복 실행” 문제는 백엔드 작업 큐에서도 자주 발생하는데, 원인-증상 분리 방식은 Redis 기반 Celery 유령 작업 근절하기 같은 글의 접근과 유사합니다.


6) 운영에서 바로 쓰는 디버깅 루틴(재현 가능한 순서)

서버 액션 장애는 한 번에 잡기 어렵습니다. 아래 순서로 쪼개면 빠릅니다.

1) “에러가 나는 액션”을 Route Handler로 임시 복제

서버 액션의 추상화가 원인인지 확인하려면 동일 로직을 /api/...로 옮겨 호출해보세요.

// app/api/profile/route.ts
import { NextResponse } from 'next/server'

export async function POST(req: Request) {
  const body = await req.json().catch(() => ({}))
  // 동일한 업데이트 로직 실행
  // ...
  return NextResponse.json({ ok: true })
}
  • Route Handler는 일반 HTTP라서 프록시/쿠키/Origin 문제를 더 직접적으로 관측 가능
  • 여기서는 되는데 서버 액션만 실패하면, 액션 검증/캐시/프리패치 영역으로 범위를 좁힐 수 있음

2) 배포 환경에서 host/origin/referer를 로깅해 비교

  • 성공 요청 vs 실패 요청의 헤더 차이를 비교
  • Cloudflare/ALB/Nginx에서 어떤 헤더가 변조되는지 확인

3) 캐시를 태그 기반으로 통일

  • 읽기 fetch는 next: { tags: [...] }
  • 쓰기는 revalidateTag/revalidatePath

4) 멱등성/중복 제출 방어

  • UI: pending으로 버튼 잠금
  • 서버: idempotency key

7) 자주 묻는 함정 5가지

함정 A) 액션에서 redirect() 후 캐시가 갱신될 거라 기대

redirect()는 이동일 뿐, 읽기 캐시 무효화는 별개입니다. redirect 전에 revalidate를 호출하세요.

함정 B) revalidatePath('/') 남발

너무 광범위하면 트래픽이 많을 때 캐시 효율이 급락합니다. 가능하면 tag 기반으로 좁히세요.

함정 C) Edge 런타임에서 DB 드라이버 사용

서버 액션이 Edge에서 실행되면 Node 드라이버가 깨질 수 있습니다. 런타임을 명확히 하거나(Node), DB 접근은 Node 런타임에서만.

함정 D) 환경변수 누락으로 인한 500

운영에서만 나는 500의 상당수는 설정 누락입니다. 액션 시작 시점에 필수 env를 검증해 조기에 실패시키는 편이 진단이 쉽습니다.

함정 E) 폼에서 파일 업로드를 액션으로 바로 처리

가능은 하지만, 프록시 제한/바디 크기/타임아웃이 얽혀 500으로 보이기 쉽습니다. 업로드는 전용 업로드 엔드포인트나 스토리지 프리사인 URL로 분리하는 것이 안전합니다.


결론: 500·CSRF·캐시 꼬임은 “한 문제”에서 시작한다

Next.js 14 서버 액션에서 동시에 보이는 500, CSRF 의심, 캐시 꼬임은 보통 각각 독립 문제가 아니라,

  • Origin/Host/프록시 헤더 불일치
  • 쿠키 정책으로 세션 미부착
  • 쓰기 후 캐시 무효화 누락
  • 프리패치/중복 제출로 멱등성 붕괴

같은 한두 축에서 시작해 여러 증상으로 퍼집니다.

운영에서 가장 효과적인 해결 패턴은 다음 3줄로 요약됩니다.

  1. 액션 내부에서 헤더/컨텍스트 로깅으로 관측 가능성 확보
  2. 쓰기 후 revalidateTag/revalidatePath로 캐시를 명시적으로 갱신
  3. UI/서버 양쪽에서 중복 제출과 멱등성을 방어

이 3가지만 체계화하면 “가끔 500” 같은 애매한 장애가 재현 가능한 문제로 바뀌고, 해결 속도가 급격히 빨라집니다.