← 전체 글로 돌아가기

Next.js

Next.js 서버 액션을 안전하게 수정하는 법

서버 액션은 클라이언트와 서버 사이의 경계이기 때문에, 수정할 때 조심해야 할 부분들이 있다.

Next.js의 서버 액션은 강력하지만, 클라이언트와 서버의 경계에 있기 때문에 수정할 때 신경 써야 할 것들이 많다.

현재 서버 액션의 형태를 파악한다

먼저 기존 서버 액션이 뭘 하는지 정리한다. 문서화가 있으면 좋지만, 없다면 코드를 읽어야 한다.

'use server'

export async function createUser(formData: FormData) {
  const name = formData.get('name')
  const email = formData.get('email')

  // DB에 저장
  await db.users.create({ name, email })
}

호출하는 클라이언트 코드도 찾아본다. 서버 액션을 어디서 쓰고 있는지 알아야 안전하게 수정할 수 있다.

시그니처는 최대한 보존한다

함수의 인자나 반환값을 바꾸면 클라이언트에서 호출하는 쪽이 깨진다.

기존:

export async function createUser(formData: FormData) { }

수정하고 싶으면:

// 좋은 방법: 기존 함수 이름은 유지, 새로운 함수 추가
export async function createUser(formData: FormData) { }

export async function createUserWithValidation(data: UserInput) { }

꼭 기존 함수를 수정해야 한다면, 버전을 명시하는 것도 방법이다:

export async function createUserV2(data: UserInput) { }

한 번에 한 가지만 수정한다

서버 액션의 로직을 크게 수정해야 한다면, 여러 단계로 나눈다.

1단계: 기존 함수 유지하면서 새로운 내부 함수 만들기

async function createUserImpl(data: UserInput) {
  // 새로운 로직
}

export async function createUser(formData: FormData) {
  const data = parseFormData(formData)
  return createUserImpl(data)
}

2단계: 클라이언트에서 새로운 함수로 천천히 전환 3단계: 기존 함수 제거

에러 처리를 확인한다

서버 액션에서 에러가 나면 클라이언트에서도 제대로 받아야 한다.

'use server'

export async function createUser(data: UserInput) {
  try {
    await db.users.create(data)
    return { success: true }
  } catch (error) {
    // 클라이언트에서 받을 수 있는 형태로
    return { success: false, error: error.message }
  }
}

클라이언트에서도 이 응답을 처리해야 한다:

const result = await createUser(data)
if (!result.success) {
  setError(result.error)
}

타입을 명시한다

TypeScript를 쓴다면 서버 액션의 입력과 출력 타입을 명확히 정의한다.

interface UserInput {
  name: string
  email: string
}

interface CreateUserResult {
  success: boolean
  userId?: string
  error?: string
}

export async function createUser(data: UserInput): Promise<CreateUserResult> {
  // ...
}

보안 관점에서 검증한다

서버 액션은 클라이언트에서 호출되지만, 서버에서 실행된다. 따라서 모든 입력을 검증해야 한다.

export async function createUser(data: UserInput) {
  // 1. 타입 검증
  if (!data.name || !data.email) {
    throw new Error('Invalid input')
  }

  // 2. 권한 검증
  const session = await auth()
  if (!session) {
    throw new Error('Unauthorized')
  }

  // 3. 비즈니스 로직 검증
  const existing = await db.users.findUnique({ where: { email: data.email } })
  if (existing) {
    throw new Error('Email already exists')
  }
}

데이터베이스 마이그레이션과 함께 한다

서버 액션이 새로운 DB 스키마를 기대한다면, 배포 순서가 중요하다.

  1. 데이터베이스 마이그레이션 배포
  2. 기존 서버 액션과 새로운 서버 액션이 공존
  3. 클라이언트 코드 업데이트
  4. 기존 서버 액션 제거

결론

서버 액션을 수정할 때는 시그니처를 보존하고, 한 번에 한 가지만 바꾸고, 모든 입력을 검증해야 한다. 점진적인 마이그레이션이 가장 안전한 방법이다.