TechFeedTechFeed
Frontend

use client, useState, 서버 컴포넌트 | 버튼만 눌러도 훅이 막히면?

use client가 없으면 useState와 onClick은 서버 컴포넌트에서 실행되려다 막힙니다. 훅이 고장난 게 아니라 파일 맨 위 지시어와 직렬화 가능한 props 경계가 비어 있는 줄입니다. 버튼만 클라이언트로 나누고 레이아웃 전체를 옮기지 않는 순서를 봅니다. Next.js, 리액트, 한국 1인 개발자 기준. 2026년 9월 Next.js use client 문서.

by

서버 컴포넌트에서 useState나 onClick을 쓰면 훅이 망가진 게 아니라, 그 파일 맨 위에 use client가 없어 서버에서 실행되려다 막힌 것입니다. 앱 라우터는 그 지시어가 없는 파일을 서버 컴포넌트로 봅니다.

잘 되던 페이지에 버튼을 하나 붙였더니 콘솔에 useState는 클라이언트에서만 된다는 빨간 줄이 뜨는 일, 넥스트로 화면을 만들다 보면 한 번쯤 나옵니다. 레이아웃 전체를 클라이언트로 옮기기 전에, 버튼이 있는 파일만 경계로 가르는 쪽이 맞아요.

지시어는 import보다 위에 두고, 서버에서 함수를 props로 넘기지 않습니다. 근거는 Next.js use client 문서리액트 use client 문서에 있습니다.


왜 서버 파일에서 훅이 막히나

지시어가 없으면 그 파일은 서버에서 실행됩니다.


넥스트 앱 라우터의 기본값은 서버 컴포넌트입니다. 파일 첫 줄에 'use client'가 있어야 그 파일이 브라우저로 가는 경계의 입구가 됩니다. 입구만 표시하면 되고, 그 파일이 import하는 자식마다 같은 줄을 또 적을 필요는 없습니다. Next.js 문서는 이 지시어를 서버와 클라이언트의 경계라고 부릅니다.


서버에서 돌릴 수 없는 것은 화면을 그린 뒤 바뀌는 상태, 클릭 처리, windowlocalStorage 같은 브라우저 값입니다. 페이지 파일에 카운터를 그대로 두면 서버가 그 훅을 실행하려다 빌드나 개발 서버가 거절합니다. 문법 오류가 아니라 실행 위치가 어긋난 신호예요.


반대로 목록을 그려 주는 표, 데이터베이스에서 읽은 글, 검색엔진에 줄 본문은 서버에 두는 편이 낫습니다. 버튼 하나 때문에 그 데이터 조회까지 브라우저 묶음으로 끌려가면 첫 화면이 무거워집니다. 혼자 넥스트 사이트를 여러 개 두는 경우, 메뉴 버튼만 클라이언트 파일로 빼는 습관이 나중에 용량을 덜 키웁니다.


use client 지시어가 서버 컴포넌트와 클라이언트 컴포넌트 경계를 나누는 그림
지시어가 있는 파일만 브라우저 경계의 입구가 된다. 자체 인포그래픽

콘솔에 뜨는 문장 두 갈래

콘솔 문장만 봐도 고칠 파일이 갈립니다.


자주 보는 문장은 두 갈래입니다. 하나는 훅을 서버 파일에서 쓴 경우이고, 다른 하나는 서버가 함수를 클라이언트 컴포넌트의 props로 넘긴 경우입니다. 둘 다 빨간 줄이지만 고치는 파일이 다릅니다.


콘솔에 보이는 말무슨 뜻인가어디를 고치나
needs useState. It only works in a Client Component훅을 서버 파일에서 호출그 컴포넌트 파일 맨 위에 use client
Event handlers cannot be passed to Client Component props서버가 onClick 함수를 넘김함수를 클라이언트 파일 안으로 이동
Functions cannot be passed directly to Client Components직렬화되지 않는 함수 propsuse server로 액션을 만들거나 클라이언트 안으로

첫 문장에는 어떤 훅인지 이름이 같이 찍힙니다. useState 대신 useEffectuseContext여도 같은 경계 문제입니다. 이펙트가 서로를 불러 렌더가 폭발하는 증상은 따로 봐야 해서, 그 경우는 Maximum update depth 글을 따라가면 됩니다. 경계 오류는 렌더가 무한히 도는 증상보다 먼저, 아예 첫 실행에서 멈춥니다.


버튼만 클라이언트 파일로 빼기
'use client' import { useState } from 'react' export default function MenuButton() { const [open, setOpen] = useState(false) return ( <button type="button" onClick={() => setOpen(!open)}> {open ? '닫기' : '메뉴'} </button> ) }

경계를 나누는 순서

에러가 가리킨 파일의 첫 줄부터 봅니다.


순서를 고정해 두면 레이아웃을 통째로 옮기는 실수를 피합니다. 에러 스택이 가리키는 파일을 열고, 첫 코드가 import인지 확인하세요. 'use client'는 import보다 위, 파일의 맨 앞이어야 합니다. 주석 한두 줄은 그 위에 둘 수 있지만, import 아래에 두면 지시어로 인정되지 않습니다.


그 파일이 async function으로 데이터베이스를 읽고 있다면 한 파일에 두 역할을 섞지 마세요. 클라이언트 컴포넌트는 async 컴포넌트가 아닙니다. 조회는 서버 페이지에 남기고, 결과 문자열이나 숫자만 props로 넘긴 뒤 버튼 파일만 클라이언트로 둡니다. 저장했는데도 같은 줄이 남으면 개발 서버를 한 번 재시작합니다. 경계가 바뀐 직후에는 이전 컴파일 결과가 남는 경우가 있습니다.


  • [ ] 스택이 가리킨 파일을 열었다
  • [ ] use client를 import보다 위에 두었다
  • [ ] async 조회는 서버 페이지에 남겼다
  • [ ] 버튼 파일만 클라이언트로 옮겼다
  • [ ] 저장 뒤에도 남으면 dev 서버를 재시작했다

페이지는 서버에 두고 메뉴 버튼 파일만 use client로 나누는 순서 그림
조회는 서버 페이지에 두고, 클릭이 필요한 파일만 경계로 연다. 자체 인포그래픽

함수를 props로 넘기면 왜 또 막히나

서버에서 클라이언트로 넘기는 값은 직렬화가 되어야 합니다.


Next.js 문서는 클라이언트 컴포넌트 props가 직렬화 가능한 값이어야 한다고 적습니다. 문자열, 숫자, 불리언, null, 그리고 그 값으로만 이뤄진 객체와 배열은 넘어갑니다. 함수, 클래스 인스턴스, 심볼은 요청 사이로 실어 나를 수 없습니다. 서버 페이지에서 onClick 함수를 만들어 클라이언트 버튼에 넘기면 두 번째 문장이 납니다.


날짜도 객체 그대로 넘기지 않는 편이 안전합니다. toISOString()으로 문자열을 넘기고, 화면을 그릴 때 다시 읽으세요. 렌더 시점에 new Date()를 서버와 브라우저에서 각각 부르면 글자가 어긋나는데, 그건 경계 오류가 아니라 하이드레이션 미스매치 쪽입니다.


예외가 하나 있습니다. children으로 이미 서버가 그린 트리를 넘기는 방식은 함수 props와 다릅니다. 클라이언트 모달이 children을 받고, 서버 페이지가 그 안에 상품 표를 넣으면 표는 서버에 남습니다. 클릭으로 열고 닫는 상태만 클라이언트에 둡니다.


children으로 서버 트리를 넘기기
'use client' import { useState } from 'react' export function Modal({ children }) { const [open, setOpen] = useState(false) if (!open) { return <button type="button" onClick={() => setOpen(true)}>열기</button> } return <div>{children}</div> } // app/page.js 는 서버 컴포넌트. use client 를 달지 않는다. // import { Modal } from './modal' // export default function Page() { // return <Modal><ProductTable /></Modal> // }

레이아웃 전체를 옮기면 생기는 일

레이아웃 파일에 지시어를 달면 그 파일은 async가 못 됩니다.


루트 layout.js 첫 줄에 use client를 넣으면 메뉴 상태는 생기지만, 그 파일에서 await로 글을 읽거나 쿠키를 읽는 서버 코드는 같이 못 둡니다. 페이지 파일은 children으로 넘어오므로 페이지 자체는 서버 컴포넌트로 남을 수 있습니다. 그래도 헤더의 데이터 조회까지 클라이언트 묶음에 넣을 이유는 없습니다. 헤더 안의 햄버거 버튼만 별도 파일로 빼면 레이아웃은 서버에 남습니다.


클라이언트 파일에서 차트 라이브러리를 import하면 그 용량이 브라우저로 갑니다. 표의 숫자만 서버에서 계산하고, 접고 펴는 토글만 클라이언트로 두는 편이 첫 로딩에 유리합니다. 브라우저 전용 값인 NEXT_PUBLIC 키가 비는 증상은 경계와 별개라, 빌드에 키가 안 박힌 경우라면 환경변수 글을 보면 됩니다.


국내에서 카카오 로그인 버튼을 페이지 파일에 바로 붙이다가 이 오류를 만나는 경우가 많습니다. 버튼의 클릭과 팝업은 클라이언트 파일로, 로그인 여부에 따른 본문은 서버 페이지로 나누면 콘솔의 빨간 줄이 사라집니다.


먼저 기억할 것 | use client는 앱 전체를 브라우저로 보내는 스위치가 아닙니다. 클릭, 상태, 브라우저 API가 필요한 파일의 맨 앞에만 둡니다. 서버 페이지는 그 파일을 import해서 그리면 됩니다.


루트 레이아웃은 서버에 남기고 햄버거 버튼만 클라이언트로 뺀 구조 그림
레이아웃 전체를 옮기지 않아도 버튼 파일만 브라우저로 보낼 수 있다. 자체 인포그래픽

참고 자료

지시어 규칙을 적은 공식 문서만 여기 모아 둡니다.



내부 연계: 하이드레이션 미스매치, 클라이언트 환경변수, 이펙트 무한 루프, 쿠키와 정적 생성


인용한 지시어 규칙은 2026년 9월 22일 공개 문서 기준입니다.


자주 묻는 질문

경계 오류에서 자주 나오는 질문만 답을 적습니다.


use client는 모든 컴포넌트 파일에 달아야 하나요?

아닙니다. 서버 컴포넌트가 직접 그리는 입구 파일에만 답니다. 그 파일이 import하는 자식은 경계 안으로 따라 들어가므로 자식마다 같은 줄을 반복하지 않아도 됩니다. 페이지와 레이아웃은 서버에 남겨 두는 편이 낫습니다.


import 아래에 쓰면 왜 안 먹나요?

지시어는 파일의 맨 앞에서만 인식됩니다. import가 먼저 있으면 그 파일은 계속 서버 컴포넌트로 남고, useState 오류가 그대로입니다. 첫 코드 줄로 올리고 저장하세요.


onClick을 서버 페이지에서 만들어 넘기면요?

함수는 직렬화가 되지 않아 Event handlers cannot be passed 또는 Functions cannot be passed directly 문장이 납니다. 클릭 함수는 클라이언트 파일 안에 두세요. 서버에서 실행할 작업이면 use server 액션으로 분리합니다.


레이아웃에 달면 페이지도 전부 클라이언트가 되나요?

페이지는 children으로 넘어오므로 페이지 파일은 서버 컴포넌트로 남을 수 있습니다. 다만 레이아웃 파일 자체는 async 조회를 못 하게 됩니다. 버튼만 작은 파일로 빼는 쪽이 범위가 작습니다.


클라이언트에서 localStorage를 바로 읽어도 되나요?

파일에 use client가 있어야 그 객체가 존재합니다. 그래도 첫 렌더에서 읽으면 서버 HTML과 글자가 어긋날 수 있습니다. 마운트 뒤에 읽어야 하고, 그 증상은 하이드레이션 미스매치로 따로 잡습니다.


저장했는데 같은 오류가 남습니다.

지시어 위치가 import보다 위인지 다시 보고, 개발 서버를 한 번 재시작하세요. 다른 파일이 같은 훅을 또 쓰고 있다면 스택의 그 파일에도 같은 확인이 필요합니다. 환경변수 키가 비는 증상은 이 오류와 문장이 다릅니다.


useState가 막히면 그 파일의 맨 앞에 use client가 있는지부터 보면 됩니다. 버튼만 경계로 빼고, 조회와 본문은 서버에 두고, 함수는 props로 넘기지 않으면 대부분의 빨간 줄이 사라집니다. 관련 글: 하이드레이션, 쿠키와 정적 생성, 클라이언트 환경변수.


use clientuseState서버 컴포넌트onClick넥스트리액트프론트엔드직렬화레이아웃개발자

함께 보면 좋은 문제 해결

EXPLORE / Frontend

이어서 읽어보기

전체 토픽 둘러보기