Back to blog
Apr 27, 2025
13 min read

[번역] What Does "use client" Do?

An article about the explanation of "use client".

원문 : https://overreacted.io/what-does-use-client-do/

React Server Components는 (악명 높게도) 별도의 API 표면을 가지고 있지 않습니다. 이는 사실상 두 가지 지시어에서 비롯된 새로운 프로그래밍 패러다임이라고 할 수 있습니다:

  • 'use client'
  • 'use server'

저는 이 지시어들의 발명이 구조적 프로그래밍(if/while), 일급 함수(first-class functions), async/await 같은 혁신과 같은 반열에 올라야 한다고 감히 주장하고 싶습니다. 즉, 이 개념들은 React를 넘어 앞으로 ‘상식’처럼 자리잡을 것이라 기대합니다.

서버는 클라이언트로 코드를 전송해야 합니다 (<script>를 보내는 방식으로). 클라이언트는 다시 서버로 통신해야 합니다 (fetch를 수행하는 방식으로). 'use client''use server' 지시어는 이 과정을 추상화하여, 코드베이스의 다른 컴퓨터에 있는 코드 조각으로 제어를 넘기는 작업을 일급 객체처럼, 타입이 보장되고, 정적 분석이 가능한 방식으로 처리할 수 있게 해줍니다.

  • 'use client'는 타입이 지정된 <script>입니다.
  • 'use server'는 타입이 지정된 fetch()입니다.

이 두 가지 지시어는 모듈 시스템 안에서 클라이언트와 서버 간 경계를 표현할 수 있게 해줍니다. 이를 통해 클라이언트/서버 애플리케이션을 두 대의 머신에 걸쳐 하나의 프로그램처럼 모델링할 수 있으며, 네트워크와 직렬화(serialization) 간극이라는 현실을 간과하지 않게 해줍니다. 그리고 이로 인해 네트워크를 넘나드는 매끄러운 컴포지션이 가능해집니다.

비록 여러분이 React Server Components를 실제로 사용할 계획이 없더라도, 이 지시어들과 그 동작 방식에 대해서는 꼭 배워야 한다고 생각합니다. 이 지시어들은 사실 React에 대한 것이 아닙니다.

이것들은 모듈 시스템에 관한 것입니다.


‘use server’

먼저 'use server'에 대해 알아보겠습니다.

몇 가지 API 경로가 있는 백엔드 서버를 작성한다고 가정합니다.

async function likePost(postId) {
  const userId = getCurrentUser();
  await db.likes.create({ postId, userId });
  const count = await db.likes.count({ where: { postId } });
  return { likes: count };
}
 
async function unlikePost(postId) {
  const userId = getCurrentUser();
  await db.likes.destroy({ where: { postId, userId } });
  const count = await db.likes.count({ where: { postId } });
  return { likes: count };
}
 
// 좋아요
app.post('/api/like', async (req, res) => {
  const { postId } = req.body;
  const json = await likePost(postId);
  res.json(json);
});

// 좋아요 해제
app.post('/api/unlike', async (req, res) => {
  const { postId } = req.body;
  const json = await unlikePost(postId);
  res.json(json);
});

그런 다음 이러한 API 경로를 호출하는 프론트엔드 코드가 있습니다.

document.getElementById('likeButton').onclick = async function() {
  const postId = this.dataset.postId;
  if (this.classList.contains('liked')) {
    // 좋아요 해제 호출
    const response = await fetch('/api/unlike', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ postId })
    });
    const { likes } = await response.json();
    this.classList.remove('liked');
    this.textContent = likes + ' Likes';
  } else {
    // 좋아요 호출
    const response = await fetch('/api/like', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ postId, userId })
    });
    const { likes } = await response.json();
    this.classList.add('liked');
    this.textContent = likes + ' Likes';
  }
});

(단순화를 위해, 이 예제는 경쟁 상태(race condition)나 오류 처리를 다루지 않습니다.)

이 코드는 겉보기에는 깔끔하고 괜찮아 보이지만, 사실 "문자열 기반(stringly-typed)"입니다. 우리가 하려는 것은 다른 컴퓨터에 있는 함수를 호출하는 일입니다. 하지만 백엔드와 프론트엔드는 서로 다른 별개의 프로그램이기 때문에, 이를 표현할 수 있는 방법은 결국 fetch 같은 호출뿐입니다.

이제, 프론트엔드와 백엔드를 “두 대의 머신에 걸쳐 분리된 하나의 프로그램”으로 생각한다고 가정해봅시다. 이럴 때, 코드의 한 부분이 다른 부분을 호출하고 싶다는 사실을 어떻게 가장 직접적으로 표현할 수 있을까요?

백엔드와 프론트엔드는 “당연히 이렇게 만들어야 한다”는 기존의 고정관념을 잠시 내려놓고 보면, 사실 우리가 정말 하고 싶은 말은, 프론트엔드 코드에서 likePostunlikePost를 호출하고 싶다는 것뿐입니다.

import { likePost, unlikePost } from './backend'; // 이것은 동작하지 않아요 :(
 
document.getElementById('likeButton').onclick = async function() {
  const postId = this.dataset.postId;
  if (this.classList.contains('liked')) {
    const { likes } = await unlikePost(postId);
    this.classList.remove('liked');
    this.textContent = likes + ' Likes';
  } else {
    const { likes } = await likePost(postId);
    this.classList.add('liked');
    this.textContent = likes + ' Likes';
  }
};

물론 문제는, likePostunlikePost가 실제로는 프론트엔드에서 실행될 수 없다는 점입니다. 이 함수들의 구현을 프론트엔드로 그대로 가져와 사용할 수는 없습니다. 프론트엔드에서 백엔드를 직접 임포트하는 것은, 정의상 의미가 없는 일입니다.

하지만 만약 likePostunlikePost 함수를 모듈 수준에서 서버로부터 내보낸(exported) 함수라고 표시(annotate)할 수 있는 방법이 있다고 가정해봅시다.

'use server'; // 모든 export를 프론트엔드에서 “호출 가능한(callable)” 것으로 표시하기
 
export async function likePost(postId) {
  const userId = getCurrentUser();
  await db.likes.create({ postId, userId });
  const count = await db.likes.count({ where: { postId } });
  return { likes: count };
}
 
export async function unlikePost(postId) {
  const userId = getCurrentUser();
  await db.likes.destroy({ where: { postId, userId } });
  const count = await db.likes.count({ where: { postId } });
  return { likes: count };
}

이렇게 하면, HTTP 엔드포인트를 백그라운드에서 자동으로 설정할 수 있을 것입니다. 그리고 네트워크를 통해 함수를 내보내기 위한 옵트인(opt-in) 문법이 생겼으니, 프론트엔드 코드에서 이러한 함수들을 임포트하는 행위에 새로운 의미를 부여할 수 있습니다 — 프론트엔드에서 임포트하면, 실제로는 해당 HTTP 호출을 수행하는 비동기 함수(async function)를 가져오는 것처럼 동작하게 만들 수 있습니다.

import { likePost, unlikePost } from './backend';
 
document.getElementById('likeButton').onclick = async function() {
  const postId = this.dataset.postId;
  if (this.classList.contains('liked')) {
    const { likes } = await unlikePost(postId); // HTTP call
    this.classList.remove('liked');
    this.textContent = likes + ' Likes';
  } else {
    const { likes } = await likePost(postId); // HTTP call
    this.classList.add('liked');
    this.textContent = likes + ' Likes';
  }
};

이것이 바로 'use server' 지시어가 하는 일입니다.

사실 이것은 새로운 아이디어가 아닙니다 — RPC(Remote Procedure Call)는 수십 년 전부터 존재해왔습니다. 이것은 단지 클라이언트-서버 애플리케이션을 위한, 특정한 형태의 RPC일 뿐입니다. 서버 코드에서는 일부 함수를 “server export”('use server')로 지정할 수 있고, 프론트엔드 코드에서 likePost를 임포트하면 일반적인 import처럼 보이지만, 실제로는 HTTP 호출을 수행하는 비동기 함수(async function)를 가져오게 됩니다.

다음 파일 쌍을 다시 살펴보세요.

'use server'; // 모든 export를 프론트엔드에서 “호출 가능한(callable)” 것으로 표시하기
 
export async function likePost(postId) {
  const userId = getCurrentUser();
  await db.likes.create({ postId, userId });
  const count = await db.likes.count({ where: { postId } });
  return { likes: count };
}
 
export async function unlikePost(postId) {
  const userId = getCurrentUser();
  await db.likes.destroy({ where: { postId, userId } });
  const count = await db.likes.count({ where: { postId } });
  return { likes: count };
}
import { likePost, unlikePost } from './backend';
 
document.getElementById('likeButton').onclick = async function() {
  const postId = this.dataset.postId;
  if (this.classList.contains('liked')) {
    const { likes } = await unlikePost(postId); // HTTP call
    this.classList.remove('liked');
    this.textContent = likes + ' Likes';
  } else {
    const { likes } = await likePost(postId); // HTTP call
    this.classList.add('liked');
    this.textContent = likes + ' Likes';
  }
};

아마 반론이 있을 수도 있습니다 — 네, 같은 코드베이스 안에 있지 않으면 API의 여러 소비자를 허용할 수 없습니다. 네, 버전 관리와 배포에 대해 신경 써야 합니다. 네, 직접 fetch를 작성하는 것보다 암묵적입니다.

하지만 백엔드와 프론트엔드를 “두 대의 컴퓨터에 걸쳐 분리된 하나의 프로그램”으로 바라보기 시작하면, 이제 이 관점을 쉽게 잊을 수 없습니다. 두 모듈 간에는 직접적이고 본질적인 연결이 생깁니다. 여기에 타입을 추가하여 두 모듈 간의 계약을 좁힐 수 있고(그리고 타입이 직렬화 가능한지 강제할 수 있습니다), “모든 참조 찾기(Find All References)” 기능을 통해 서버에서 내보낸 함수가 클라이언트 어디에서 사용되는지 추적할 수 있습니다. 사용되지 않는 엔드포인트는 자동으로 감지되거나(dead code analysis를 통해) 제거될 수 있습니다.

가장 중요한 점은, 이제 양쪽(프론트엔드와 백엔드)을 모두 완전히 캡슐화하는 자급자족형 추상화를 만들 수 있다는 것입니다. 즉, 하나의 “프론트엔드” 코드가 그에 상응하는 “백엔드” 조각에 직접 연결됩니다. API 라우트가 무분별하게 늘어나는 문제를 걱정할 필요가 없습니다 — 서버/클라이언트 분리는 여러분이 만든 추상화만큼 모듈화될 수 있습니다. 글로벌 네이밍 스킴(global naming scheme)도 필요 없습니다; 필요한 곳에서 export와 import를 사용해 코드를 조직하면 됩니다.

'use server' 지시어는 서버와 클라이언트 간의 연결을 문법적(syntactic)으로 만들어줍니다. 이제 그것은 단순한 관례(convention)가 아니라, 모듈 시스템의 일부가 된 것입니다.

이것은 서버로 가는 문을 열어줍니다.


‘use client’

이제 백엔드에서 프론트엔드 코드로 정보를 전달하고 싶다고 가정해봅시다. 예를 들어, <script>를 이용해 일부 HTML을 렌더링할 수도 있겠죠:

app.get('/posts/:postId', async (req, res) => {
  const { postId } = req.params;
  const userId = getCurrentUser();
  const likeCount = await db.likes.count({ where: { postId } });
  const isLiked = await db.likes.count({ where: { postId, userId } }) > 0;
  const html = `<html>
    <body>
      <button
        id="likeButton"
        className="${isLiked ? 'liked' : ''}"
        data-postid="${Number(postId)}">
        ${likeCount} Likes
      </button>
      <script src="./frontend.js></script>
    </body>
  </html>`;
  res.text(html);
});

브라우저는 해당 <script>를 로드하고, 이를 통해 인터랙티브 로직이 연결될 것입니다.

document.getElementById('likeButton').onclick = async function() {
  const postId = this.dataset.postId;
  if (this.classList.contains('liked')) {
    // ...
  } else {
    // ...
  }
};

이 방식도 동작은 하지만, 몇 가지 아쉬운 점이 남습니다.

우선, 프론트엔드 로직이 "전역"으로 동작하는 것은 바람직하지 않을 수 있습니다 — 이상적으로는, 각각의 데이터를 받아서 자체적인 로컬 상태를 유지하는 여러 개의 좋아요 버튼을 렌더링할 수 있어야 합니다. 또한 HTML 템플릿과 인터랙티브한 JavaScript 이벤트 핸들러 사이의 표시 로직(display logic)을 통합할 수 있다면 훨씬 좋을 것입니다.

이 문제를 해결하는 방법은 이미 알고 있습니다. 바로 컴포넌트 라이브러리가 그 역할을 합니다! 이제 프론트엔드 로직을 선언형(Declarative)인 LikeButton 컴포넌트로 다시 구현해봅시다:

function LikeButton({ postId, likeCount, isLiked }) {
  function handleClick() {
    // ...
  }
 
  return (
    <button className={isLiked ? 'liked' : ''}>
      {likeCount} Likes
    </button>
  );
}

단순화를 위해, 잠시 순수 클라이언트 사이드 렌더링 방식으로 내려가 봅시다. 순수 클라이언트 사이드 렌더링에서는 서버 코드의 역할이 단지 초기 props를 전달하는 것에 불과합니다.

app.get('/posts/:postId', async (req, res) => {
  const { postId } = req.params;
  const userId = getCurrentUser();
  const likeCount = await db.likes.count({ where: { postId } });
  const isLiked = await db.likes.count({ where: { postId, userId } }) > 0;
  const html = `<html>
    <body>
      <script src="./frontend.js></script>
      <script>
        const output = LikeButton(${JSON.stringify({
          postId,
          likeCount,
          isLiked
        })});
        render(document.body, output);
      </script>
    </body>
  </html>`;
  res.text(html);
});

그러면 LikeButton은 이 props를 가지고 페이지에 나타날 수 있습니다.

function LikeButton({ postId, likeCount, isLiked }) {
  function handleClick() {
    // ...
  }
 
  return (
    <button className={isLiked ? 'liked' : ''}>
      {likeCount} Likes
    </button>
  );
}

이 방식은 일리가 있으며, 사실 클라이언트 사이드 라우팅이 등장하기 전에는 서버 렌더링 애플리케이션에서 React를 통합하는 전형적인 방법이었습니다. 페이지에 클라이언트 사이드 코드를 담은 <script>를 작성하고, 그 코드가 필요로 하는 인라인 데이터(즉, 초기 props)를 담은 또 다른 <script>를 작성해야 했습니다.

이 코드 형태에 대해 조금 더 생각해봅시다. 여기서 흥미로운 일이 벌어지고 있습니다: 백엔드 코드는 분명히 프론트엔드 코드로 정보를 전달하고 싶어합니다. 그런데 정보를 전달하는 과정이 다시 문자열 기반(stringly-typed)으로 이뤄지고 있다는 점입니다!

여기서 무슨 일이 벌어지고 있는 걸까요?

app.get('/posts/:postId', async (req, res) => {
  // ...
  const html = `<html>
    <body>
      <script src="./frontend.js></script>
      <script>
        const output = LikeButton(${JSON.stringify({
          postId,
          likeCount,
          isLiked
        })});
        render(document.body, output);
      </script>
    </body>
  </html>`;
  res.text(html);
});

우리가 지금 말하고 있는 것은 결국 이렇습니다: 브라우저가 frontend.js 파일을 로드한 다음, 그 안에서 LikeButton 함수를 찾아서, 이 JSON을 해당 함수에 전달하라는 것입니다.

그렇다면, 그냥 그렇게 직접 표현할 수 있다면 어떨까요?

import { LikeButton } from './frontend';
 
app.get('/posts/:postId', async (req, res) => {
  // ...
  const jsx = (
    <html>
      <body>
        <LikeButton
          postId={postId}
          likeCount={likeCount}
          isLiked={isLiked}
        />
      </body>
    </html>
  );
  // ...
});
'use client'; // 모든 exports가 백엔드로부터 "renderable"한 것으로 표기
 
export function LikeButton({ postId, likeCount, isLiked }) {
  function handleClick() {
    // ...
  }
 
  return (
    <button className={isLiked ? 'liked' : ''}>
      {likeCount} Likes
    </button>
  );
}

여기서 우리는 하나의 개념적 도약을 하고 있지만, 조금만 더 따라와 주세요. 우리가 말하고자 하는 것은, 백엔드와 프론트엔드는 여전히 별개의 런타임 환경이라는 점입니다. 하지만 이 둘을 별개의 프로그램이 아니라, 하나의 프로그램처럼 바라보고 있다는 것입니다.

바로 그렇기 때문에, 정보를 전달하는 쪽(백엔드)과 그 정보를 받아야 하는 함수(프론트엔드) 사이에 문법적인 연결(syntactic connection) 을 설정하려는 것입니다. 그리고 그 연결을 표현하는 가장 자연스러운 방법은, 역시 단순한 import입니다.

여기서도 주목해야 할 점은, 백엔드에서 'use client'로 표시된 파일을 import할 때, 실제 LikeButton 함수 자체를 가져오는 것이 아니라는 것입니다. 대신, 나중에 내부적으로 <script> 태그로 변환할 수 있는 클라이언트 참조(client reference) 를 얻게 됩니다.

이제 이것이 실제로 어떻게 동작하는지 살펴봅시다.

이 JSX 코드를 보세요:

import { LikeButton } from './frontend'; // "/src/frontend.js#LikeButton"
 
// ...
<html>
  <body>
    <LikeButton
      postId={42}
      likeCount={8}
      isLiked={true}
    />
  </body>
</html>

JSON을 생성합니다.

{
  type: "html",
  props: {
    children: {
      type: "body",
      props: {
        children: {
          type: "/src/frontend.js#LikeButton", // A client reference!
          props: {
            postId: 42
            likeCount: 8
            isLiked: true
          }
        }
      }
    }
  }
}

그리고 이 정보—즉, 이 클라이언트 참조를 통해 우리는 올바른 파일에서 코드를 로드하고, 내부적으로 올바른 함수를 호출하는 <script> 태그를 생성할 수 있게 됩니다.

<script src="./frontend.js"></script>
<script>
  const output = LikeButton({
    postId: 42,
    likeCount: 8,
    isLiked: true
  });
  // ...
</script>

사실 우리는, 클라이언트 렌더링 방식에서는 잃어버렸던 초기 HTML을 서버에서 미리 생성(pregenerate)하기 위해 같은 함수를 서버에서도 실행할 수 있을 만큼 충분한 정보를 가지고 있습니다.

<!-- Optional: Initial HTML -->
<button class="liked">
  8 Likes
</button>
 
<!-- Interactivity -->
<script src="./frontend.js"></script>
<script>
  const output = LikeButton({
    postId: 42,
    likeCount: 8,
    isLiked: true
  });
  // ...
</script>

초기 HTML을 프리렌더링하는 것은 선택 사항이지만, 이 역시 같은 원시(primitives)를 사용하여 작동합니다.

이제 이 동작 방식을 이해했으니, 이 코드를 한 번 더 살펴보세요:

import { LikeButton } from './frontend'; // "/src/frontend.js#LikeButton"
 
app.get('/posts/:postId', async (req, res) => {
  // ...
  const jsx = (
    <html>
      <body>
        <LikeButton
          postId={postId}
          likeCount={likeCount}
          isLiked={isLiked}
        />
      </body>
    </html>
  );
  // ...
});
'use client'; // Mark all exports as "renderable" from the backend
 
export function LikeButton({ postId, likeCount, isLiked }) {
  function handleClick() {
    // ...
  }
 
  return (
    <button className={isLiked ? 'liked' : ''}>
      {likeCount} Likes
    </button>
  );
}

백엔드와 프론트엔드 코드가 어떻게 상호작용해야 하는지에 대한 기존의 고정관념을 잠시 떼어놓고 생각해보면, 여기서 뭔가 특별한 일이 일어나고 있음을 알 수 있습니다.

백엔드 코드는 'use client'와 함께 import를 사용하여 프론트엔드 코드를 참조합니다. 즉, 프로그램에서 <script>를 보내는 부분과 그 <script> 안에서 동작하는 부분 간의 모듈 시스템 내의 직접적인 연결을 표현하는 것입니다. 직접적인 연결이 있기 때문에, 타입 체크가 가능하고, “모든 참조 찾기(Find All References)” 기능을 사용할 수 있으며, 모든 도구가 이를 인식할 수 있습니다.

'use server'와 마찬가지로, 'use client'도 서버와 클라이언트 간의 연결을 문법적으로 만들어줍니다. 'use server'가 클라이언트에서 서버로 가는 문을 열어준다면, 'use client'는 서버에서 클라이언트로 가는 문을 열어줍니다.

마치 두 개의 세상 사이에 두 개의 문이 있는 것과 같습니다.


두 개의 세상, 두 개의 문

이것이 바로 'use client''use server'가 단순히 코드를 “클라이언트”나 “서버”로 “표시”하는 방식으로 이해되어서는 안 되는 이유입니다. 그것이 그들의 역할이 아닙니다.

오히려, 이들은 한 환경에서 다른 환경으로 가는 문을 여는 역할을 합니다:

  • 'use client'는 클라이언트 함수를 서버로 내보냅니다. 내부적으로, 백엔드 코드는 이를 /src/frontend.js#LikeButton과 같은 참조로 처리합니다. 이들은 JSX 태그로 렌더링될 수 있으며, 궁극적으로 <script> 태그로 변환됩니다. (선택적으로, 이 스크립트들을 서버에서 미리 실행하여 초기 HTML을 얻을 수 있습니다.)

  • 'use server'는 서버 함수를 클라이언트로 내보냅니다. 내부적으로, 프론트엔드는 이를 HTTP를 통해 백엔드를 호출하는 비동기 함수로 처리합니다.

이 지시어들은 모듈 시스템 내에서 네트워크 간격을 표현합니다. 이를 통해 클라이언트/서버 애플리케이션을 두 환경에 걸쳐 있는 하나의 프로그램으로 묘사할 수 있게 됩니다.

이들은 이 환경들이 어떤 실행 컨텍스트도 공유하지 않는다는 사실을 인정하고 완전히 수용합니다—그래서 어떤 import도 실제로 코드를 실행하지 않습니다. 대신, 각 환경은 다른 환경의 코드를 참조할 수 있게 해주고, 그에게 정보를 전달할 수 있게 합니다.

이 두 지시어는 함께 프로그램의 두 부분을 “엮어” 양쪽에서의 로직을 포함하는 재사용 가능한 추상화를 만들고 구성할 수 있게 해줍니다. 하지만 저는 이 패턴이 React를 넘어, JavaScript를 넘어 확장될 수 있다고 생각합니다. 사실, 이는 모듈 시스템 수준에서의 RPC(Remote Procedure Call) 이며, 클라이언트로 더 많은 코드를 보내기 위한 거울 같은 쌍을 가지고 있는 것입니다.

서버와 클라이언트는 하나의 프로그램의 두 부분입니다. 그들은 시간과 공간에 의해 분리되어 있기 때문에 실행 컨텍스트를 공유하거나 서로를 직접 import할 수 없습니다. 하지만 이 지시어들은 시간과 공간을 넘어서 “문을 열어” 줍니다: 서버는 클라이언트를 <script>로 렌더링할 수 있고, 클라이언트는 fetch()를 통해 서버와 상호작용할 수 있습니다. 그러나 import가 그 표현을 가장 직접적으로 할 수 있는 방법이므로, 이 지시어들은 그것을 사용할 수 있게 해줍니다.

이해가 되시죠?