async await, 클라이언트 컴포넌트, use | 로그인 버튼에서 유저를 기다렸는데 빨간 줄이면?
use client 파일에서 컴포넌트를 async로 선언하면 async/await is not yet supported in Client Components가 납니다. 카카오 닉네임은 서버에서 await 한 뒤 문자열만 내리고, 화면을 먼저 띄우려면 프로미스를 use로 풉니다. 클릭 핸들러의 await는 됩니다. 넥스트, 리액트, 앱 라우터, 프론트엔드, 1인 개발자 기준. 2026년 9월 Next.js, React 공식 문서.
빨간 줄의 대상은 파일 안의 기다림이 아니라, 컴포넌트 함수 선언입니다. use client가 있는 파일에서 export default async function을 쓰면 리액트는 그 함수를 클라이언트로 보내지 못합니다.
서버 컴포넌트는 async가 기본 도구입니다. 페이지가 데이터를 기다렸다가 HTML을 보내는 길이니까요. 클라이언트 컴포넌트는 브라우저에서 다시 그려지고 클릭을 받습니다. 렌더 함수가 프로미스를 반환하면, 그 순간을 클라이언트가 어떻게 이어갈지 아직 지원하지 않습니다. 넥스트 문서도 클라이언트에서 데이터를 읽을 때 컴포넌트를 async로 만들지 말고, 서버가 만든 프로미스를 use로 읽거나 커뮤니티 라이브러리를 쓰라고 안내합니다.
그래서 메시지만 보고 use client를 떼면, 이번엔 훅이 막힙니다. 버튼의 useState는 클라이언트 파일이 맞고, 유저 조회만 서버로 올리면 됩니다. 파일 전체를 async로 만들 자리가 아닙니다.
먼저 기억할 것 | 클라이언트 파일에서 막는 건 컴포넌트 선언의 async입니다. onClick 안에서 await fetch하는 것과 같은 줄이 아닙니다.
클라이언트 컴포넌트를 async로 만든 줄
'use client'
// 이 선언이 빨간 줄이다. fetch가 문제가 아니다
export default async function KakaoBadge() {
const res = await fetch('/api/kakao/me')
const me = await res.json()
return <span>{me.nickname}</span>
}
use client 파일의 컴포넌트 함수는 async로 선언하지 않는다
서버 await와 클라이언트 use는 자리가 다르다
같은 프로미스라도 푸는 도구가 다릅니다. 서버는 await, 클라이언트 렌더는 use입니다.
자리
프로미스를 푸는 법
안 되는 것
서버 페이지, 레이아웃
함수를 async로 두고 await
use client를 같이 붙이기
클라이언트 컴포넌트 렌더
서버가 넘긴 프로미스를 use
컴포넌트 함수를 async로 선언
클릭, 제출 핸들러
핸들러를 async로 두고 await
그 기다림을 첫 화면 렌더에 넣기
마운트 뒤 브라우저 전용
useEffect 안에서 async 호출
렌더 함수 본문에서 await
params, searchParams
서버는 await, 클라이언트는 use
클라이언트 페이지를 async로 만들기
리액트 문서는 이렇게 나눕니다. 서버에서 만든 프로미스는 자식에게 그대로 넘겨도 됩니다. 푸는 지점만 정하면 됩니다. 서버 자식은 await, 클라이언트 자식은 use. 클라이언트가 렌더 중에 새 fetch()를 매번 만들면 서스펜스가 반복되니, 프로미스는 서버에서 시작하거나 캐시된 것을 넘기세요.
use는 훅이 아니라서 조건문 안에서도 호출할 수 있습니다. 다만 try/catch로 감싸면 안 됩니다. 실패한 프로미스는 가까운 에러 경계가 받습니다. 기다리는 동안 보여줄 화면은 가까운 서스펜스가 맡습니다.
카카오 닉네임은 서버에서 문자로 내린다
버튼이 필요하면 클라이언트 파일을 쪼개고, 닉네임 문자열만 내립니다. 액세스 토큰과 관리자 키는 브라우저로 내리지 않습니다.
한국에서 1인으로 넥스트 사이트를 돌리면, 카카오 로그인 뒤에 헤더에 닉네임을 붙이는 화면을 자주 만듭니다. 그때 버튼 파일에 유저 조회를 같이 넣기 쉽습니다. 사용자 정보 호출은 서버가 토큰을 들고 하는 일이고, 화면은 닉네임 한 줄이면 됩니다. 카카오 문서도 액세스 토큰을 서버에서 붙이라고 되어 있습니다. 제가 쓰던 헤더는 로그인 직후 빈 칸이었다가, 서버 페이지에서 닉네임을 기다린 뒤에야 채워졌습니다.
페이지가 서버 컴포넌트면 거기서 기다린 뒤 nickname만 props로 내리면 끝입니다. 버튼이 클릭만 하면 되고, 첫 페인트에 닉네임이 필요 없으면 핸들러에서 라우트를 호출하면 됩니다. 시크릿을 클라이언트 번들에 넣는 순간 이 에러와 별개로 키가 새어 나갑니다.
클릭이 없으면 서버에 두고, 클릭이 있으면 문자나 프로미스만 내립니다. 파일 전체에서 async를 지우기만 하면 데이터가 사라집니다.
필요한 것
갈래
주의
읽기만, 클릭 없음
use client를 떼고 서버에서 await
훅이 있으면 버튼만 쪼갠다
첫 화면에 값이 필요
서버가 기다린 뒤 문자열, 숫자만 props
객체 통째, 클래스 인스턴스는 안 됨
화면은 먼저, 값은 이어서
서버가 프로미스를 시작하고 자식이 use
use 호출부를 Suspense로 감싼다
클릭 뒤에만 호출
핸들러에서 await fetch
렌더 본문에 await를 두지 않는다
세 번째 갈래가 넥스트 문서의 클라이언트 페칭 예제입니다. 페이지는 await 하지 않고 getNickname()이 돌려 준 프로미스를 넘깁니다. 뱃지는 use(mePromise)로 읽고, 그 뱃지만 서스펜스로 감쌉니다. 헤더와 로그인 버튼은 바로 나오고, 닉네임만 늦게 채워집니다.
클라이언트에서 렌더마다 use(fetch('/api/kakao/me'))를 호출하면 프로미스가 매번 새로 생깁니다. 리액트는 캐시되지 않은 프로미스라고 경고하고 폴백이 반복됩니다. 시작은 서버, 읽기는 클라이언트입니다.
서버가 프로미스를 시작하고 클라이언트가 use로 읽기
// app/page.js 서버, await 하지 않음
import { Suspense } from 'react'
import KakaoBadge from './kakao-badge'
export default function Page() {
const mePromise = getNickname()
return (
<Suspense fallback={<span>불러오는 중</span>}>
<KakaoBadge mePromise={mePromise} />
</Suspense>
)
}
// kakao-badge.js
'use client'
import { use } from 'react'
export default function KakaoBadge({ mePromise }) {
const nickname = use(mePromise)
return <button type="button">{nickname || '로그인'}</button>
}
핸들러 안 await는 되고 선언 async는 안 된다
클릭 순간에 기다리는 건 렌더가 아닙니다. 그래서 핸들러는 async여도 됩니다.
로그인 버튼을 눌러 인가 코드로 토큰을 바꾸는 일은 사용자 동작 뒤입니다. 그때 onClick을 async 함수로 두고 라우트에 await fetch해도 이 에러는 안 납니다. useEffect 안에서 별도 async 함수를 만들어 호출하는 것도 같은 칸입니다. 막히는 줄은 컴포넌트가 그려지는 그 함수가 프로미스를 반환할 때입니다.
혼자 사이트를 운영하다 보면, 에러 메시지를 보고 파일에서 async를 전부 지워 버립니다. 그러면 제출 핸들러까지 동기처럼 보여서 응답을 못 기다립니다. 지울 건 컴포넌트 선언의 async 한 단어입니다. 핸들러는 그대로 두세요. 카카오 인가 코드를 토큰으로 바꾸는 호출은 클릭 뒤에만 나가면 되고, 첫 화면을 그리는 함수가 그걸 기다릴 필요는 없습니다.
클릭 뒤라면 핸들러에서 await
'use client'
export default function LoginButton() {
async function onClick() {
const res = await fetch('/api/kakao/login', { method: 'POST' })
if (!res.ok) return
window.location.assign('/account')
}
return (
<button type="button" onClick={onClick}>
카카오 로그인
</button>
)
}
window.location은 핸들러 안에서 | 렌더에서 window를 읽으면 다른 빨간 줄입니다. 리다이렉트는 클릭이 난 뒤에만 실행하세요. 브라우저 객체를 렌더에서 읽는 칸은 별글입니다.
렌더가 아니라 클릭 뒤에 기다리는 코드는 클라이언트에서도 된다
params await, 객체 자식과 섞지 마라
비슷한 영문 줄이 여러 개라서, 메시지를 끝까지 읽어야 칸이 갈립니다.
메시지
실제 칸
먼저 할 일
클라이언트에서 async 미지원
클라이언트 컴포넌트가 async
선언에서 async를 떼고 서버로 기다림을 옮긴다
params를 기다려야 함
서버 경로 인자가 프로미스
서버 페이지에서 await params
객체는 화면 자식이 아님
프로미스, 객체를 태그 자식으로
필드만 그리거나 await, use로 푼다
훅 에러, 지시자 없음
서버 파일에 훅
훅 파일 맨 위에 use client
쿠키를 클라이언트에서 읽음
서버 전용 모듈을 클라이언트에서 불러옴
쿠키는 서버에서 읽고 문자열만 내린다
넥스트 15의 params는 서버 페이지를 async로 만드는 쪽이 맞습니다. 그 처방을 클라이언트 버튼에 복사하면 이번 에러가 납니다. 프로미스를 {mePromise}처럼 자식으로 넣으면 객체 자식 에러입니다. use(mePromise)로 푼 값만 그립니다.
훅 지시자가 없어서 난 줄은 파일 맨 위에 use client를 붙이면 됩니다. 이미 붙인 뒤에 async만 추가해 막힌 경우는 지시자를 더 붙이지 마세요. 한국 1인 개발자가 카카오 콜백 페이지를 만질 때 이 다섯 줄이 하루에 겹치기도 합니다. 콘솔 첫 줄을 그대로 검색하면 칸이 빨리 갈립니다.
안 됩니다. use client는 훅과 클릭을 여는 지시자이지, 컴포넌트 함수를 async로 만들어 주지 않습니다. 기다림은 서버 페이지의 await이거나, 내려받은 프로미스를 use로 읽는 쪽입니다. 지시자를 한 번 더 붙여도 같은 줄이 납니다.
onClick에서 await fetch 해도 같은 에러인가요?
아닙니다. 핸들러는 렌더가 끝난 뒤 실행됩니다. async function onClick 안에서 await 하는 코드는 이 메시지와 무관합니다. 컴포넌트를 export default async function으로 선언한 줄만 이 에러입니다. 로그인 제출처럼 클릭 뒤 라우트 호출은 핸들러에 두세요.
use(fetch())를 클라이언트에서 바로 쓰면 되나요?
렌더마다 새 프로미스가 생기면 폴백이 반복됩니다. 리액트는 같은 프로미스 인스턴스를 다시 쓰라고 합니다. 서버 컴포넌트가 조회를 시작해 넘기거나, 모듈 캐시에 넣은 프로미스를 use에 넘기세요. try/catch로 use를 감싸지 말고 에러 경계를 둡니다.
서버 페이지의 async와 뭐가 다른가요?
서버 페이지, 레이아웃은 async가 맞습니다. 넥스트 15의 params, searchParams도 서버에서 await 합니다. 그 처방을 use client 버튼에 복사하면 이번 에러가 납니다. 경로 인자는 서버 칸, 클릭 가능한 뱃지는 클라이언트 칸입니다.
카카오 유저 객체를 버튼에 통째로 넘기면요?
닉네임 문자열만 내리세요. 객체를 화면 자식으로 넣으면 다른 에러가 나고, 클래스 인스턴스는 직렬화가 거절됩니다. 액세스 토큰과 관리자 키는 서버에만 둡니다. 버튼 props는 닉네임, 로그인 여부처럼 일반 값이면 됩니다.
서스펜스 없이 use만 호출하면요?
프로미스가 끝나기 전에 가까운 서스펜스가 없습니다. 넥스트는 그 위 경계까지 클라이언트로 끌어올리거나 빌드에서 멈출 수 있습니다. use를 부르는 자식만 fallback으로 감싸세요. 페이지 전체를 감싸면 헤더까지 같이 기다립니다.