앱 라우터에서 useState가 막히면 그 파일은 아직 서버 컴포넌트입니다. 훅을 쓰는 파일 맨 위에 use client를 붙이거나 버튼만 쪼갭니다. Invalid hook call, 하이드레이션과 자리를 나눕니다. 넥스트, 리액트, 서버 컴포넌트, 카카오 로그인, 프론트엔드, 1인 개발자 기준. 2026년 9월 Next.js, React 공식 문서.
앱 라우터에서 useState가 막히면 그 파일은 아직 서버 컴포넌트입니다. 훅을 쓰는 파일 맨 위, 임포트보다 앞에 'use client'를 붙이거나, 클릭이 있는 칸만 다른 파일로 쪼개세요.
넥스트 앱 폴더에서 카카오 로그인 버튼을 페이지에 바로 넣다가 needs useState 빨간 줄을 보셨을 겁니다. 페이지는 기본이 서버라 클릭 상태를 못 들고, 훅은 브라우저 쪽에서만 살아 있습니다.
공식 문서도 경계를 파일 단위로 긋습니다. 페이지 전체에 지시자를 붙이면 글 목록까지 클라이언트로 내려가니, 문의 버튼처럼 클릭이 있는 칸만 따로 빼는 편이 안전합니다. 근거는 넥스트 use client 문서와 리액트 use client 문서에 있습니다.
이 빨간 줄은 훅이 잘못된 걸까
훅이 틀린 게 아니라 그 파일이 서버 컴포넌트입니다. 앱 폴더의 페이지와 레이아웃은 지시자가 없으면 서버에서 먼저 그려지고, useState나 useEffect는 그 자리에서 쓸 수 없습니다.
메시지가 길게 이어집니다. 훅은 클라이언트 컴포넌트에서만 되고, 부모 중에 use client가 찍힌 파일이 없으니 기본값인 서버로 본다는 뜻이에요. 문법 오류가 아니라 실행 자리가 어긋난 겁니다.
제가 12개 사이트를 넥스트 앱 라우터로 돌리며 가장 자주 본 장면은 로그인 페이지에 버튼을 바로 넣은 경우입니다. 페이지는 글을 읽고 메타를 붙이는 서버 자리인데, 버튼이 클릭 상태를 들고 있으려다 같은 빨간 줄이 납니다. 훅 규칙을 어긴 줄과 자리를 나누세요. 조건 안에서 훅을 호출했거나 리액트 복사본이 두 개인 경우는 중복 리액트 훅 글 쪽입니다.
먼저 기억할 것 | 앱 폴더는 기본이 서버입니다. 훅, 클릭, 브라우저 저장소가 필요하면 그 파일만 클라이언트로 표시하세요. 페이지 전체를 클라이언트로 바꾸는 건 나중 선택입니다.
페이지 맨 위에 지시자를 붙이면
페이지 전체에 붙이면 목록과 데이터 조회까지 클라이언트로 내려갑니다. 빨간 줄은 사라지지만, 서버에 둬야 할 조회와 비밀 값까지 같이 넘어갑니다.
넥스트 문서는 지시자를 모든 파일에 반복하지 말라고 합니다. 서버 컴포넌트가 직접 그리는 입구 파일에만 찍으면 됩니다. 그 파일이 내보내는 컴포넌트가 클라이언트 입구가 되고, 그 아래로 이어지는 임포트는 같은 번들에 들어갑니다.
레이아웃에 붙이면 범위가 더 넓어집니다. app/layout.js 맨 위에 지시자를 두면 그 아래 페이지가 통째로 클라이언트 트리에 붙습니다. 제가 예전에 문의 폼 때문에 루트 레이아웃에 지시자를 넣었다가, 글 목록 조회가 브라우저로 내려간 적이 있습니다. 폼만 고치려다 사이트 전체가 무거워진 케이스예요.
붙인 자리
빨간 줄
같이 내려가는 것
훅이 있는 작은 파일
사라짐
그 버튼과 모달만
app/login/page.js
사라짐
로그인 페이지 전체
app/layout.js
사라짐
그 레이아웃 아래 모든 페이지
안 붙임
그대로
없음. 서버 유지
훅은 작은 파일에만 두고, 페이지는 서버로 남겨 둔다
버튼만 다른 파일로 빼는 순서
클릭이 있는 컴포넌트만 새 파일로 빼고 지시자를 그 파일 맨 위에 둡니다. 페이지는 서버로 남기고, 버튼을 임포트하면 됩니다.
순서는 짧습니다. 훅과 onClick이 있는 줄을 새 파일로 옮기고, 그 파일 첫 줄에 'use client'를 씁니다. 페이지는 데이터를 읽어 제목을 그린 뒤 그 버튼을 끼워 넣습니다. 서버가 클라이언트를 자식으로 두는 조합은 공식 문서가 권하는 방식입니다.
한국에서 카카오 로그인을 붙일 때도 같습니다. 인가 주소로 보내는 클릭만 클라이언트 파일이면 되고, 콜백에서 코드를 받아 세션을 만드는 칸은 서버 라우트나 서버 액션에 둡니다. 폼 제출 중 버튼을 잠그는 훅은 이중 제출 글과 같이 보면 자리가 더 분명해집니다.
지시자 위치 | 파일 맨 위, 임포트보다 앞, 소문자 'use client'입니다. 주석이나 빈 줄은 앞에 둘 수 있지만, import 다음에 두면 지시자로 안 읽힙니다.
서버에 둘 것과 클라이언트에 둘 것
조회와 비밀 값은 서버, 클릭과 훅은 클라이언트입니다. 한 페이지 안에서 둘을 섞을 때는 파일 경계로 나눕니다.
넥스트 조합 패턴 문서는 역할을 이렇게 나눕니다. 서버는 데이터를 읽고, 토큰 같은 비밀을 지키며, 큰 라이브러리를 브라우저에 안 내려 보냅니다. 클라이언트는 클릭, 입력, 브라우저 저장소, 화면 크기를 다룹니다. 둘 다 필요한 화면은 서버 페이지가 클라이언트 조각을 자식으로 둡니다.
자식으로 children을 넘기는 패턴도 자주 씁니다. 클라이언트 껍데기가 열고 닫기만 하고, 무거운 본문은 서버가 그려 그 안에 넣는 식입니다. 제가 글 상세에서 쓰는 목차 접기도 껍데기만 클라이언트입니다. 본문 HTML은 서버가 그립니다.
할 일
둘 자리
이유
디비에서 글 읽기
서버 페이지, 서버 액션
연결 문자열이 브라우저에 안 나감
카카오 로그인 클릭
클라이언트 파일
onClick과 대기 상태가 필요
문의 폼 제출 중 잠금
클라이언트 파일
훅이 필요
제출 처리, 메일 발송
서버 액션, 라우트
비밀 키와 검증
localStorage 테마
클라이언트 파일
서버에 그 객체가 없음
테마처럼 브라우저 값을 첫 화면에 바로 그리면 하이드레이션이 갈라집니다. 그 칸은 이 글의 지시자 문제가 아니라 하이드레이션 미스매치 글입니다. 지시자를 붙인 뒤에 날짜가 깜빡이면 그쪽으로 가세요.
조회는 서버, 클릭은 작은 클라이언트 파일로 나눈다
카카오 로그인 버튼에서 막힌 자리
카카오 버튼 onClick을 페이지에 직접 넣으면 같은 빨간 줄이 납니다. 인가 주소로 보내는 클릭만 클라이언트 파일로 옮기면 페이지는 서버로 남습니다.
제가 막혔던 줄은 이랬습니다. app/login/page.js 안에서 useState로 대기 표시를 켜고, 클릭하면 카카오 인가 URL로 보내려 했습니다. 로컬 터미널은 빌드가 되고, 브라우저만 개발 오버레이를 띄웠습니다. 페이지를 서버로 두고 버튼을 쪼개자 오버레이가 사라졌습니다.
콜백 라우트는 그대로 서버입니다. 카카오가 돌려주는 코드, 토큰 교환, 세션 쿠키는 브라우저 훅이 필요 없습니다. 클릭 칸과 콜백 칸을 한 파일에 섞지 마세요. 쿠키 전달이 막히면 지시자가 아니라 세임사이트 칸입니다.
페이지에 훅을 넣으면 터지는 줄
// app/login/page.js ← 기본은 서버. 여기서 훅을 쓰면 빨간 줄
import { useState } from 'react'
export default function LoginPage() {
const [pending, setPending] = useState(false) // needs useState
return (
<button onClick={() => setPending(true)}>
카카오 로그인
</button>
)
}
대소문자와 지시자 위치를 먼저 보라
지시자는 소문자, 임포트보다 앞입니다. 한 글자만 달라도 서버로 남아서 같은 빨간 줄이 납니다.
깃허브 넥스트 토론에도 'use Client'처럼 C만 대문자로 썼다가 지시자로 안 읽힌 사례가 있습니다. 따옴표는 작은따옴표나 큰따옴표 둘 다 되지만, 철자는 소문자 use client여야 합니다. 파일 중간에 넣거나 임포트 아래에 두면 번들러가 지시자로 안 봅니다.
서드파티 캐러셀처럼 패키지 안에 지시자가 없는 컴포넌트를 서버 페이지에 바로 넣어도 같은 계열의 줄이 납니다. 공식 문서의 처방은 얇은 래퍼 파일을 하나 만들고, 그 파일 맨 위에 지시자를 붙인 뒤 다시 내보내는 것입니다. 패키지를 고칠 수 없을 때 쓰는 칸입니다.
지시자 없는 패키지를 감싸기
// app/components/Carousel.js
'use client'
export { Carousel } from 'acme-carousel'
// app/gallery/page.js ← 서버 페이지에서 래퍼만 임포트
import { Carousel } from '../components/Carousel'
export default function GalleryPage() {
return <Carousel />
}
지시자는 임포트보다 앞, 소문자 use client만 읽힌다
함수 props는 다른 빨간 줄이다
함수 props 에러는 직렬화 문제라 use client를 더 붙여도 안 풀립니다. 서버가 클라이언트로 넘기는 값은 리액트가 직렬화할 수 있는 형태여야 합니다.
리액트 문서는 문자열, 숫자, 날짜, 평범한 객체, 배열, 그리고 서버 액션은 넘길 수 있다고 합니다. 일반 함수, 클래스, 심볼은 못 넘깁니다. 그래서 서버 페이지에서 onClick={handleClick}을 클라이언트 버튼에 주면, 훅 에러가 아니라 함수를 직접 넘길 수 없다는 줄이 뜹니다.
고치는 칸이 다릅니다. 클릭 함수는 클라이언트 파일 안에 두세요. 서버에서 실행해야 하는 저장이라면 서버 액션으로 만들고, 그 액션만 넘깁니다. 서버 액션 자리와 폼 검증은 서버 액션 글을 보면 됩니다. 훅 에러와 직렬화 에러를 한 처방으로 묶지 마세요.
다른 줄과 섞지 말 것 | needs useState는 파일 경계입니다. Invalid hook call은 리액트 복사본 또는 훅 규칙입니다. 함수를 넘길 수 없다는 줄은 직렬화입니다. 화면이 갈라지면 하이드레이션입니다.