리액트 서버 컴포넌트는 API가 없는 것으로 유명합니다. 크게 두 개의 지시어에서 비롯된 프로그래밍 패러다임입니다.
'use client'
'use server'
저는 이 지시어들이 구조적 프로그래밍(if/while
), 일급 함수, 그리고 async/await
등과 같은 범주에 속한다고 과감하게 주장하고 싶습니다. 다시 말해, 이들이 리액트를 넘어서까지 살아남아 프로그래밍 상식이 될 것이라고 생각합니다.
서버에서는 <script>
를 통하여 클라이언트에 코드를 보내야 합니다. 클라이언트는 fetch
를 통하여 서버에 응답해야 합니다. 'use client'
와 'use server'
지시어는 이러한 과정을 추상화하여, 다른 컴퓨터에 있는 코드 베이스의 일부로 제어권을 전달하는 일급의, 타입화된, 정적 분석 기능을 제공합니다.
'use client'
는 타입화된 <script>
입니다.'use server'
는 타입화된 fetch()
입니다.위 지시어들을 함께 사용하여 모듈 시스템 내에서 클라이언트/서버 경계를 표현할 수 있습니다. 네트워크 및 직렬화 차이의 실체를 간과하지 않고 클라이언트/서버 애플리케이션을 두 컴퓨터에 걸쳐 있는 단일 프로그램으로 모델링할 수 있습니다. 이를 통해 네트워크를 가로지르는 원활한 구성이 가능합니다.
리액트 서버 컴포넌트를 사용할 계획이 없더라도 이 지시어들과 작동 방식에 대해 알아두는 것이 좋습니다. 이들은 리액트에 관한 것이 아니니까요.
대신, 모듈 시스템에 관한 것입니다.
'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';
}
});
(간단하게 표현하기 위해 이 예제에서는 경쟁 조건과 오류 처리는 생략했습니다.)
위 코드는 깔끔하고 괜찮지만 “문자열을 남발” 하고 있습니다. 우리는 다른 컴퓨터에서 함수를 호출 하려고 합니다. 하지만 백엔드와 프런트엔드는 서로 다른 두 개의 프로그램이기 때문에 fetch
외에는 이를 구현할 방법이 없습니다.
이제 프런트엔드와 백엔드를 두 개의 컴퓨터에 나뉘어 실행되는 하나의 프로그램이라고 생각해 봅시다. 어떤 코드 조각이 다른 코드 조각을 호출하고 싶다는 것을 어떻게 표현할 수 있을까요? 이를 가장 직접적으로 표현할 수 있는 방법은 무엇일까요?
백엔드와 프런트엔드가 어떻게 구축되어야 "하는지"에 대한 선입견을 잠시 내려놓읍시다. 우리가 정말로 원하는 것은 프런트엔드 코드에서 likePost
와 unlikePost
를 호출하는 것뿐입니다.
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';
}
};
물론 문제는 likePost
와 unlikePost
가 실제로 프런트엔드에서 실행될 수 없다는 것입니다. 말 그대로 그 구현을 프런트엔드로 가져올 수 없습니다. 정의상 프런트엔드에서 백엔드를 직접 가져오는 것은 무의미합니다.
하지만 모듈 수준에서 likePost
및 unlikePost
함수에 서버에서 내보낸 것처럼 표시할 수 있는 방법이 있다고 가정해 보겠습니다.
'use server'; // 모든 exports를 프런트엔드에서 "호출 가능하도록" 표시합니다
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 엔드포인트 설정을 자동화할 수 있습니다. 이제 네트워크를 통해 함수를 내보내는 옵트인 구문이 있으므로 프런트엔드 코드에서 함수를 가져오는 것에 의미를 부여할 수 있습니다. 즉, 함수를 import
하면 단순히 해당 HTTP 호출을 수행하는 async
함수만 제공하면 됩니다.
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 호출
this.classList.remove('liked');
this.textContent = likes + ' Likes';
} else {
const { likes } = await likePost(postId); // HTTP 호출
this.classList.add('liked');
this.textContent = likes + ' Likes';
}
};
이게 바로 'use server'
지시어의 동작입니다.
이는 새로운 아이디어가 아닙니다. RPC는 수십 년 전부터 존재해 왔습니다. 다만, 이는 클라이언트-서버 애플리케이션을 위한 특정 형태의 RPC일 뿐입니다. 여기서 서버 코드는 일부 기능을 “서버 내보내기”('use server'
)로 지정할 수 있습니다. 서버 코드에서 likePost
를 가져오는 것은 일반적인 import
와 동일하게 작동하지만, 클라이언트 코드에서 likePost
를 가져오면 HTTP 호출을 수행하는 비동기 함수가 제공됩니다.
서버와 클라이언트 코드를 다시 한 번 살펴보겠습니다.
'use server'; // 모든 exports를 프런트엔드에서 "호출 가능하도록" 표시합니다
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 호출
this.classList.remove('liked');
this.textContent = likes + ' Likes';
} else {
const { likes } = await likePost(postId); // HTTP 호출
this.classList.add('liked');
this.textContent = likes + ' Likes';
}
};
반대 의견이 있을 수도 있습니다. 이 방법은 동일한 코드 베이스 내에 있는 경우를 제외하면 여러 API 소비자를 허용하지 않습니다. 버전 관리 및 배포에 대해 신중히 고려해야 하는 것도 맞구요. 그리고 fetch
작성보다 더 암시적이기도 합니다.
하지만 백엔드와 프런트엔드가 두 대의 컴퓨터에 나뉘어 있는 하나의 프로그램으로 간주한다면, 사실상 이를 다시 모른 척하기는 어렵습니다. 두 모듈 사이에는 이제 직접적이고 직관적인 연결이 생깁니다. 예를 들어, 타입을 추가하여 규칙의 범위를 좁힐 수 있습니다(그리고 해당 타입을 직렬화 가능하도록 강제할 수 있습니다). "모든 참조 찾기"를 사용하여 서버의 어떤 함수가 클라이언트에서 사용되는지 추적할 수 있습니다. 사용하지 않는 엔드포인트는 데드 코드 분석을 통해 자동으로 표시되거나 데드 코드 분석을 통해 제거될 수 있습니다.
가장 중요한 것은 이제 "프런트엔드"와 그에 대응하는 "백엔드" 양쪽을 완전히 캡슐화하는 독립적인 추상화를 만들 수 있다는 점입니다. 서버/클라이언트 분할은 추상화만큼이나 모듈화할 수 있으므로 API 라우트가 폭발적으로 늘어날 것을 걱정할 필요가 없습니다. 전역 명명 체계는 없으며, 필요한 곳에 export
및 import
를 사용하여 코드를 구성할 수 있습니다.
'use server'
지시어는 서버와 클라이언트 사이의 연결을 구문론적으로 만듭니다. 더 이상 관습적 방식의 문제가 아닙니다. 이는 모듈 시스템 안에 있습니다.
서버에 대한 문을 여는 것입니다.
'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의 템플릿과 대화형 자바스크립트 이벤트 핸들러 사이의 표시 로직을 통합하면 좋을 것입니다.
우리는 이러한 문제를 해결하는 방법을 알고 있습니다. 바로 컴포넌트 라이브러리입니다! 프런트엔드 로직을 선언적 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);
});
그러면 props가 반영된 LikeButton
이 페이지에 나타납니다.
function LikeButton({ postId, likeCount, isLiked }) {
function handleClick() {
// ...
}
return (
<button className={isLiked ? 'liked' : ''}>
{likeCount} Likes
</button>
);
}
이 방식은 충분히 말이 됩니다. 실제로 클라이언트 측 라우팅이 등장하기 전에 서버 렌더링 애플리케이션에 리액트가 통합되던 방식과 정확히 일치합니다. 클라이언트 코드가 있는 페이지에 <script>
를 작성하고, 그 코드에 필요한 인라인 데이터(즉, 초기 props)가 있는 또 다른 <script>
를 작성해야 합니다.
코드를 조금 더 자세히 살펴봅시다. 흥미로운 점이 있습니다. 백엔드 코드는 분명히 프런트엔드 코드에 정보를 전달하려고 합니다. 그런데 정보를 전달할 때 다시 문자열을 남발하고 있습니다!
무슨 일이 발생하고 있는 걸까요?
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를 백엔드에서 "렌더링할 수 있도록" 표시합니다
export function LikeButton({ postId, likeCount, isLiked }) {
function handleClick() {
// ...
}
return (
<button className={isLiked ? 'liked' : ''}>
{likeCount} Likes
</button>
);
}
개념적으로 비약이 있지만 계속 설명해 드리겠습니다. 여전히 백엔드와 프런트엔드라는 두 개의 서로 다른 런타임 환경이 존재하지만, 이를 두 개의 개별 프로그램이 아닌 하나의 프로그램으로 보고 있다고 말하는 것입니다.
이것이 바로 정보를 전달하는 곳(백엔드)과 정보를 수신해야 하는 함수(프런트엔드) 사이에 구문 연결을 설정하는 이유입니다. 그리고 이러한 연결을 표현하는 가장 자연스러운 방법은, 다시 말하지만, 평범한 import
입니다.
여기서도 백엔드에서 'use client'
로 표시된 파일을 가져오면 LikeButton
함수 자체가 제공되는 것이 아니라 클라이언트 참조가 생성되는데, 이 참조는 나중에 내부에서 <script>
태그로 변환할 수 있습니다.
어떻게 작동하는지 살펴봅시다.
import { LikeButton } from './frontend'; // "/src/frontend.js#LikeButton"
// ...
<html>
<body>
<LikeButton
postId={42}
likeCount={8}
isLiked={true}
/>
</body>
</html>
위 JSX는 다음과 같은 JSON을 생성합니다.
{
type: "html",
props: {
children: {
type: "body",
props: {
children: {
type: "/src/frontend.js#LikeButton", // 클라이언트 참조!
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을 미리 생성할 수 있는 충분한 정보도 갖고 있습니다.
<!-- 선택 사항: 초기 HTML -->
<button class="liked">
8 Likes
</button>
<!-- 상호작용성 -->
<script src="./frontend.js"></script>
<script>
const output = LikeButton({
postId: 42,
likeCount: 8,
isLiked: true
});
// ...
</script>
초기 HTML을 미리 렌더링하는 것은 선택 사항이지만 동일한 프리미티브를 사용하여 작동합니다.
이제 작동 방식을 알았으니 코드를 다시 한 번 살펴보겠습니다.
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'; // 모든 exports를 백엔드에서 "렌더링할 수 있도록" 표시합니다
export function LikeButton({ postId, likeCount, isLiked }) {
function handleClick() {
// ...
}
return (
<button className={isLiked ? 'liked' : ''}>
{likeCount} Likes
</button>
);
}
백엔드와 프런트엔드 코드가 어떻게 상호 작용해야 하는지에 대한 기존의 관념을 잠시 내려놓으면 여기서 뭔가 특별한 일이 일어나고 있음을 알 수 있습니다.
백엔드 코드는 'use client'
로 import
를 사용하여 프런트엔드 코드를 참조합니다. 즉, <script>
를 보내는 프로그램 부분과 그 <script>
안에 있는 프로그램 부분 사이의 모듈 시스템 내 직접 연결을 표현합니다. 직접 연결이 있기 때문에 타입 체크를 할 수 있고 "모든 참조 찾기"를 사용할 수 있으며 모든 도구가 이를 인식합니다.
'use server'
는 앞의 'use client'
와 마찬가지로 서버와 클라이언트 간의 연결을 구문적으로 만듭니다. 'use server'
는 클라이언트에서 서버로 문을 여는 반면, 'use client'
는 서버에서 클라이언트로 문을 엽니다.
두 세계 사이에 두 개의 문이 있는 것과 같습니다.
그렇기 때문에 'use client'
과 'use server'
를 코드를 "클라이언트에" 또는 "서버에" 있는 것으로 "표시"하는 방법으로 간주해서는 안 됩니다. 그런 역할을 하는 게 아닙니다.
그 대신, 한 세계에서 다른 세계로 문을 열 수 있게 해줍니다.
'use client'
는 클라이언트 함수를 서버로 내보냅니다. 백엔드 코드에서는 이를 '/src/frontend.js#LikeButton'
과 같은 참조로 인식합니다. 이는 JSX 태그로 렌더링될 수 있으며 최종적으로 <script>
태그로 바뀝니다. (선택적으로 서버에서 해당 스크립트를 미리 실행하여 초기 HTML을 가져올 수 있습니다.)'use server'
는 서버 함수를 클라이언트로 내보냅니다. 프런트엔드에서는 내부적으로 이를 HTTP를 통해 백엔드를 호출하는 async
함수로 인식합니다.이 지시어들은 모듈 시스템 내의 네트워크 경계를 표현합니다. 이를 통해 클라이언트/서버 애플리케이션을 두 환경에 걸쳐 있는 단일 프로그램으로 설명할 수 있습니다.
이들은 실행 컨텍스트를 공유하지 않습니다. 따라서 두 import
모두 코드를 실행하지 않습니다. 대신 한 쪽에서 다른 쪽의 코드를 참조하고 정보를 전달할 수 있도록 허용합니다.
양쪽의 논리로 재사용 가능한 추상화를 생성하고 구성하여 프로그램의 양쪽을 ‘엮을’ 수 있습니다. 저는 이 패턴이 리액트를 넘어 자바스크립트까지 확장할 수 있다고 생각합니다. 실제로 이는 클라이언트에 더 많은 코드를 전송하기 위한 미러 트윈을 갖춘 모듈 시스템 수준의 RPC일 뿐입니다.
서버와 클라이언트는 단일 프로그램의 양면입니다. 시공간이 분리되어 있기 때문에 실행 컨텍스트를 공유할 수 없고 서로를 직접 import
할 수도 없습니다. "use client"
와 "use server"
지시어는 이러한 시공간을 가로질러 “문을 열어” 줍니다. 즉, 서버는 클라이언트를 <script>
로 렌더링할 수 있고, 클라이언트는 fetch()
를 통해 서버와 대화할 수 있습니다. 다만 import
는 이를 표현하는 가장 직접적인 방법이므로 <script>
및 fetch
를 대신하여 사용할 수 있도록 허용합니다.
합리적이네요. 그렇지 않나요?
이번 글의 내용을 요약할 수 있는 간단한 아키텍처 다이어그램이 있습니다.
Been following updates on The Spike for a while and the latest version really surprised me. The improved graphics and smoother controls make a big difference. If you haven’t checked it out yet, grab the updated version here: https://thegamespike.com It’s definitely worth trying if you're into fast-paced volleyball action.
Been following updates on The Spike for a while and the latest version really surprised me. The improved graphics and smoother controls make a big difference. If you haven’t checked it out yet, grab the updated version here: https://thegamespike.com It’s definitely worth trying if you're into fast-paced volleyball action.
Been following updates on The Spike for a while and the latest version really surprised me. The improved graphics and smoother controls make a big difference. If you haven’t checked it out yet, grab the updated version here: https://thegamespike.com It’s definitely worth trying if you're into fast-paced volleyball action.