- Published on
Next.js App Router 서버액션 중복호출 막는 6가지
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버액션(Server Actions)은 App Router에서 폼 제출과 서버 로직을 자연스럽게 연결해 주지만, 운영 환경에서는 중복 호출이 생각보다 자주 발생합니다. 대표적으로 더블클릭, 모바일 환경에서의 탭 중복, 브라우저 자동 재시도, 느린 네트워크에서의 사용자 재시도, 그리고 서버가 스트리밍/재렌더링되는 과정에서 액션이 의도치 않게 다시 트리거되는 케이스가 있습니다.
중복 호출은 단순히 로그가 두 번 찍히는 수준이 아니라, 결제/포인트 차감/재고 감소 같은 비가역(side-effect) 작업에서 치명적입니다. 이 글에서는 Next.js App Router 기준으로, 클라이언트 UX 차단부터 서버 멱등성(Exactly-once에 가까운 동작)까지 6가지 방어선을 제시합니다.
또한 중복 실행을 “서버에서 최종적으로 막는” 관점은 분산 시스템의 보상 트랜잭션/중복 제거와도 닮아 있습니다. 더 깊게 보고 싶다면 MSA SAGA 보상 트랜잭션 중복 실행 막는 법도 함께 읽어보면 연결이 잘 됩니다.
1) 버튼 더블클릭 차단: useFormStatus로 제출 중 비활성화
가장 흔한 원인은 사용자의 더블클릭/연타입니다. App Router에서 서버액션을 폼에 연결했다면, 제출 중에는 버튼을 비활성화하는 것만으로도 상당수를 제거할 수 있습니다.
핵심은 react-dom의 useFormStatus()로 pending 상태를 읽어 UI를 잠그는 것입니다.
'use client'
import { useFormStatus } from 'react-dom'
function SubmitButton() {
const { pending } = useFormStatus()
return (
<button type="submit" disabled={pending} aria-disabled={pending}>
{pending ? '처리 중…' : '저장'}
</button>
)
}
export function ProfileForm({ action }: { action: (fd: FormData) => void }) {
return (
<form action={action}>
<input name="nickname" />
<SubmitButton />
</form>
)
}
이 방식은 “사용자가 같은 요청을 여러 번 보내는” 상황을 1차로 막습니다. 다만 네트워크 레벨 재시도나 탭을 여러 개 열어 동시에 제출하는 경우까지는 막지 못합니다.
2) useTransition과 로컬 락으로 중복 트리거 방지(비폼 액션)
폼 제출이 아니라 버튼 클릭으로 서버액션을 호출하는 패턴도 많습니다. 예를 들어 startTransition으로 액션을 호출할 때, 클릭 연타를 막기 위해 로컬 락을 두는 게 안전합니다.
'use client'
import { useRef, useTransition } from 'react'
import { likePost } from './actions'
export function LikeButton({ postId }: { postId: string }) {
const [pending, startTransition] = useTransition()
const locked = useRef(false)
return (
<button
disabled={pending}
onClick={() => {
if (locked.current) return
locked.current = true
startTransition(async () => {
try {
await likePost(postId)
} finally {
locked.current = false
}
})
}}
>
{pending ? '처리 중…' : '좋아요'}
</button>
)
}
pending만으로도 어느 정도 막히지만, 렌더 타이밍/이벤트 타이밍에 따라 경계 조건이 생길 수 있어 useRef 락을 같이 두면 더 견고합니다.
3) Post-Redirect-Get(PRG): 성공 후 즉시 리다이렉트로 재제출 방지
브라우저는 어떤 상황에서든 “같은 폼 제출을 다시 시도”할 수 있습니다. 특히 뒤로가기/앞으로가기, 페이지 새로고침, 네트워크 불안정에서 재전송 프롬프트 등이 문제를 만들 수 있습니다.
전통적인 웹의 해법은 PRG입니다. 서버에서 작업을 끝낸 뒤 리다이렉트로 GET 페이지로 이동시키면, 새로고침이 액션 재실행으로 이어질 확률이 크게 줄어듭니다.
'use server'
import { redirect } from 'next/navigation'
import { db } from '@/lib/db'
export async function updateProfile(fd: FormData) {
const nickname = String(fd.get('nickname') ?? '')
// side-effect
await db.user.update({ data: { nickname }, where: { id: 'me' } })
// PRG
redirect('/me?updated=1')
}
redirect()는 서버액션에서 매우 강력한 마무리 도구입니다. 단, 리다이렉트만으로 “중복 호출 자체”를 완전히 막는 건 아니며, 어디까지나 재제출/새로고침 계열을 줄이는 2차 방어선입니다.
4) 서버에서 멱등성 보장: Idempotency Key(요청 중복 제거)
여기부터가 핵심입니다. UX 차단은 우회될 수 있고, 네트워크/브라우저/사용자 행동은 통제 불가능합니다. 결국 서버에서 중복 실행을 허용하지 않는 구조가 필요합니다.
가장 널리 쓰이는 패턴은 Idempotency Key입니다.
- 클라이언트가 요청마다
idempotencyKey를 생성해 함께 보냄 - 서버는
(사용자, 액션종류, idempotencyKey)를 유니크하게 저장 - 이미 처리된 키면 결과를 재사용하거나 “이미 처리됨”으로 종료
App Router 서버액션에서도 hidden input으로 키를 전달하면 됩니다.
'use client'
import { useMemo } from 'react'
import { createOrder } from './actions'
function randomKey() {
// 예시: 실제로는 crypto 기반 UUID 권장
return `${Date.now()}-${Math.random().toString(16).slice(2)}`
}
export function CheckoutForm() {
const key = useMemo(() => randomKey(), [])
return (
<form action={createOrder}>
<input type="hidden" name="idempotencyKey" value={key} />
<button type="submit">결제하기</button>
</form>
)
}
서버에서는 DB 유니크 제약으로 “최종적으로” 막는 게 중요합니다. 아래는 개념 코드입니다.
'use server'
import { db } from '@/lib/db'
export async function createOrder(fd: FormData) {
const idempotencyKey = String(fd.get('idempotencyKey') ?? '')
const userId = 'me'
// 1) 먼저 키 레코드 생성 시도 (유니크 제약)
// 예: unique(userId, action, idempotencyKey)
try {
await db.idempotency.create({
data: {
userId,
action: 'createOrder',
key: idempotencyKey,
createdAt: new Date(),
},
})
} catch (e) {
// 이미 처리된 키면 중복 실행 차단
return { ok: true, deduped: true }
}
// 2) 여기부터는 정확히 한 번만 실행되도록 기대
const order = await db.order.create({ data: { userId, status: 'PAID' } })
return { ok: true, orderId: order.id }
}
실무에서는 “중복이면 그냥 성공 처리”가 UX에 유리한 경우가 많습니다(사용자는 결과만 원함). 결제처럼 외부 시스템 연동이 있으면, 키를 외부 결제 요청에도 함께 넘겨 외부도 멱등하게 만드는 것이 베스트입니다.
5) DB 트랜잭션과 유니크 제약으로 경쟁 조건 제거
Idempotency Key를 쓰더라도, 내부적으로는 경쟁 조건이 생길 수 있습니다. 예를 들어 “좋아요는 사용자당 한 번만” 같은 규칙은 애플리케이션 코드의 if 체크로는 막히지 않습니다. 동시에 두 요청이 들어오면 둘 다 if를 통과할 수 있기 때문입니다.
해법은 데이터 모델에 규칙을 박아 넣는 것입니다.
like(userId, postId)유니크 인덱스order(userId, cartId)유니크 인덱스- 재고 차감은
UPDATE ... WHERE stock >= 1같은 원자적 조건
개념 예시:
'use server'
import { db } from '@/lib/db'
export async function likePost(postId: string) {
const userId = 'me'
try {
await db.like.create({
data: { userId, postId },
})
} catch (e) {
// 유니크 제약 위반이면 이미 좋아요 상태
return { ok: true, deduped: true }
}
return { ok: true, deduped: false }
}
이 방식은 “서버액션이 두 번 호출되더라도” 결과의 정합성을 지키는 가장 강력한 장치입니다. 단순히 Next.js 레벨의 문제가 아니라, 시스템 설계 레벨에서 중복을 흡수하는 형태입니다.
6) 캐시/재검증(revalidate)와 결합할 때의 중복 실행 주의점
App Router에서는 revalidatePath()나 revalidateTag()로 캐시를 무효화하고 UI를 갱신하는 패턴이 흔합니다. 문제는 다음과 같은 조합에서 “사용자는 한 번 눌렀는데 서버에는 여러 번 들어온 것처럼 보이는” 현상이 생길 수 있다는 점입니다.
- 액션 성공 후
revalidatePath()호출 - 같은 페이지에서 여러 서버 컴포넌트가 같은 데이터를 fetch
- 스트리밍 렌더링 중 데이터 fetch가 다시 수행
이건 서버액션이 실제로 여러 번 실행된다기보다, 액션 이후의 데이터 로딩이 여러 번 발생해 “중복 호출처럼 보이는” 케이스가 많습니다. 하지만 액션 내부에서 외부 API를 또 호출하거나, 액션이 데이터 fetch까지 겸하는 구조라면 실제 중복 비용으로 이어질 수 있습니다.
권장 패턴은 다음입니다.
- 서버액션은 side-effect만 담당하고, 조회는 서버 컴포넌트의 fetch로 분리
- 액션 내부에서 조회성 fetch를 최소화
- 조회가 필요하면
unstable_cache또는 태그 기반 캐시로 중복 비용을 낮춤
예시:
'use server'
import { revalidateTag } from 'next/cache'
import { db } from '@/lib/db'
export async function updateNickname(fd: FormData) {
const nickname = String(fd.get('nickname') ?? '')
await db.user.update({ data: { nickname }, where: { id: 'me' } })
// 조회 데이터는 tag 기반으로 무효화
revalidateTag('me')
return { ok: true }
}
그리고 서버 컴포넌트 쪽 조회는 태그를 붙여 캐시 전략을 명확히 합니다.
import 'server-only'
export async function getMe() {
const res = await fetch('https://api.example.com/me', {
next: { tags: ['me'] },
})
return res.json()
}
이렇게 분리하면 “액션 중복 실행”과 “액션 이후 재렌더링에 따른 조회 중복”이 섞이지 않아 디버깅이 쉬워집니다. 성능까지 함께 챙기고 싶다면 React/Next.js 최적화로 INP 200ms 줄이기도 같이 참고하면 좋습니다.
서버액션 중복 호출 원인 체크리스트
현상만 보고 원인을 놓치기 쉬워, 빠르게 분류하는 체크리스트를 남깁니다.
- 사용자 입력: 더블클릭/연타/모바일 탭 중복인가
- UI 상태: 제출 중 버튼이 비활성화되는가(
pending처리) - 페이지 흐름: 성공 후 리다이렉트(PRG)로 재제출 가능성을 줄였는가
- 서버 멱등성: 같은 요청이 와도 결과가 한 번만 반영되는가(Idempotency Key)
- DB 규칙: 유니크 제약/원자적 업데이트로 경쟁 조건을 제거했는가
- 캐시/재검증: 액션 이후의 fetch 중복을 액션 중복으로 오인하고 있지 않은가
결론: UX 차단은 1차, 멱등성은 필수
정리하면, Next.js App Router 서버액션의 중복 호출을 막는 가장 현실적인 접근은 “여러 겹”입니다.
useFormStatus로 제출 중 버튼 비활성화useTransition과 로컬 락으로 클릭 연타 차단- PRG(성공 후
redirect)로 재제출 감소 - Idempotency Key로 요청 중복 제거
- DB 유니크 제약/트랜잭션으로 경쟁 조건 제거
revalidatePath/revalidateTag사용 시 조회 중복과 분리
특히 4번과 5번은 프레임워크가 바뀌어도 그대로 통하는 원칙입니다. “중복이 올 수 있다”는 가정 위에서 설계하면, 서버액션이든 API Route든 메시지 큐든 결과는 안정적으로 유지됩니다.