(이 글은 원문을 세 명의 개발자가 공동 번역한 글입니다. (@Saetbyeol Ahn, @Siyeon Lee)
이 글을 쓰려고 적어도 열두 번은 시도했습니다. 비유적으로 말하는 것이 아니라, 한때는 데스크톱 폴더에 버려진 초고가 수십 개나 쌓여 있었을 정도였습니다. 그 글들은 엄격한 것부터 혼란스러울 정도로 난해하고 참을 수 없을 정도로 메타적인 것까지 매우 다양한 스타일이었는데, 모두 갑자기 시작해서 스스로를 갉아먹다가 결국 아무 소용 없는 것으로 끝나곤 했죠. 하나하나 다 형편없어서 모두 버렸습니다.
알고 보니, 사실 저는 글을 쓰고 있었던 게 아니라 발표를 준비하고 있었던 것이었습니다. 지금 이 글을 꽤 오랫동안 쓰고 있을 때쯤 그 사실을 깨달았죠. 이런! 다행히 React Conf 주최측에서 짧은 시간에 새로운 발표를 할 수 있게 해 주셔서, 8개월 전에 발표를 마쳤습니다. 아래에서 두 대의 컴퓨터를 위한 리액트를 시청할 수 있습니다.
모두가 좋아하는 주제인 리액트 서버 컴포넌트에 관한 것입니다. (아닐 수도 있습니다.)
이 발표를 글 형태로 변환하는 것은 이제 포기했고, 그럴 수도 없을 것 같기도 합니다. 하지만 발표의 내용을 보완할 수 있는 몇 가지 메모를 적어두고 싶었습니다. 이 글은 발표를 이미 보셨다는 전제하에 쓰였습니다. 발표에 담기에 정리가 덜 되었던, 마무리를 짓지 못한 느슨한 생각들의 조각들을 모았습니다.
제1막
레시피 및 청사진
태그와 함수 호출 간의 차이점은 무엇일까요?
아래는 태그이고
<p>Hello</p>
아래는 함수 호출입니다.
alert("Hello");
한 가지 차이점은 <와 >는 딱딱하고 뾰족하며, ( 와 )는 부드럽고 둥글다는 것입니다. 하지만 제가 얘기하려는 것은 그게 아닙니다. 이는 단지 시각적인 차이일 뿐입니다. 하지만 작동 방식, 의미, 우리가 기대하는 것에는 어떤 차이가 있을까요?
물론 사용하는 언어를 지정하지 않으면 태그나 함수 호출에 특별한 의미가 없습니다. 예를 들어, 자바스크립트 함수 호출은 하스켈 함수 호출과 다르게 동작할 수 있고, HTML 태그는 콜드퓨전 태그와 다르게 동작할 수 있습니다. 그럼에도 불구하고, 우리는 널리 사용되는 언어에서 태그나 함수가 어떻게 작동하는지 잘 알고 있기 때문에, 태그나 함수 호출에서 기대하는 몇 가지 특징이 있습니다. 뾰족한 < 및 >은 부드러운 ( 및 )과 마찬가지로 일련의 연관성과 직관을 전달합니다. 저는 이러한 직관을 파헤치고 싶습니다.
alert('Hello')와 <p>Hello</p>가 어떤 공통점이 있는지부터 살펴보겠습니다.
- 우리는 함수나 태그를 그 이름으로 지칭합니다. 관례상 함수 호출은 동사로 시작하는 경우가 많지만(
createElement,printMoney,querySelectorAll), 태그는 보통 명사(예: 단락의 경우p)로 이름을 지정합니다. 이것은 엄격한 규칙은 아니지만(alert는 둘 다,b는 굵게 표시됨), 대체로 사실입니다. (왜 그럴까요?) - 함수나 태그에 정보를 전달할 수 있습니다. 앞서 태그와 함수에 텍스트(
'Hello')를 전달했습니다. 하지만 단일 문자열만 전달할 수 있는 것은 아닙니다. 자바스크립트 함수 호출에서는 문자열, 숫자, 불리언, 객체 등 여러 인수를 전달할 수 있습니다. HTML 태그 내에서는 여러 속성을 전달할 수 있지만, 객체나 기타 풍부한 데이터를 값으로 전달할 수는 없으므로 상당히 제한적입니다. 다행히 JSX(및 많은 HTML 기반 템플릿 언어)의 태그를 사용하면 객체나 기타 풍부한 값을 전달할 수 있습니다. - 함수 호출과 태그는 모두 깊게 중첩될 수 있습니다. 예를 들어,
alert('Hello, ' + prompt('Who are you?'))처럼 두 함수 호출 간의 관계를 표현하는 코드를 작성할 수 있습니다. 내부prompt호출의 결과는 문자열과 결합되어 외부alert호출에 전달됩니다(이것이 어떤 기능을 하는지 잘 모르겠다면 콘솔에서 사용해 보세요). 함수 호출에서는 중첩이 매우 일반적이지만, 태그에서는 중첩이 핵심입니다. 태그가 다른 태그에 둘러싸여 있지 않고 완전히 혼자 있는 경우는 거의 없습니다. (왜 그럴까요?)
분명히 함수 호출과 태그는 매우 유사합니다. 함수는 명명된 대상에 정보를 전달할 수 있고, 필요한 경우 중첩을 통해 더 많은 정보를 전달하여 정교하게 만들 수 있습니다(필요한 만큼 중첩).
또한 이 둘의 근본적인 차이점에 대한 몇 가지 힌트를 얻기 시작했습니다. 우선 함수 호출은 동사인 반면 태그는 명사인 경향이 있습니다. 그리고 깊게 중첩된 태그는 자주 보지만, 깊게 중첩된 함수 호출은 상대적으로 드뭅니다.
왜 그럴까요?
우리가 복잡하게 중첩된 구조를 표현할 때 굳이 태그를 사용하는 이유는, 모든 태그의 </end>를 눈으로 직접 확인할 수 있어서일지도 모릅니다. 어떤 괄호 )가 닫는 괄호인지 일일이 추측하지 않아도 되니까요.
태그 자체가 깊은 중첩을 유도하기보다는, 오히려 우리가 깊은 중첩을 위해 태그를 선택합니다. (1~2년 동안은 거의 보편적으로 외면당했지만 결국 자바스크립트 커뮤니티가 얼마나 광범위하게 JSX를 채택했는지 기억하세요. 태그 중첩은 포기하기 어렵습니다!)
중첩에 태그를 사용하는 것을 선호한다고 가정해 보겠습니다. 그런데 왜 태그는 동사가 아닌 명사가 되는 경향이 있을까요? 우연의 일치일까요, 아니면 더 깊은 이유가 있을까요?
추측에 가깝지만 동사보다 명사가 분해하기 쉽기 때문이라고 생각합니다. 명사는 사물을 묘사하며, 사물은 다른 사물의 구성만으로 적절하게 설명할 수 있는 경우가 많습니다. 예를 들어 건물은 층으로, 층은 방으로, 방은 사람으로, 사람은 물로 이루어져 있습니다. 이 설명은 한 시점의 스냅숏(snapshot)을 묘사한다는 점에서 '시간에 구애받지 않는다'고 볼 수 있습니다. 영화의 한 프레임이나 설계도처럼, 시간이라는 요소를 빼고 생각하더라도 그 자체로 건물이나 구조에 대해 중요한 정보를 전달할 수 있기 때문에 충분히 유용합니다.
반면 동사는 시간이 지남에 따라 일어나는 과정을 설명하는 경향이 있습니다. 요리 레시피를 생각해 보세요: “프라이팬을 데우고 버터를 얹고 버터가 녹을 때까지 기다렸다가 이제 달걀을 부어주세요.” 여전히 조합할 수 있는 기회가 있지만(달걀을 어떻게 깨는가?), 여기서는 순서가 매우 중요합니다! 어떤 단계가 먼저 진행되고, 다음 단계는 무엇이며, 각 단계 사이에 어떤 결정을 내려야 하는지 끊임없이 인지하고 있어야 합니다. 청사진과 달리 레시피에는 순서가 있고, 어느 정도 긴박감도 있습니다.
그렇다면 이것이 태그 및 함수 호출과 어떤 관련이 있을까요?
레시피는 순서대로 수행해야 하는 일련의 단계를 규정합니다. 동사로 구성되어 있지만 표현이 중첩되는 경우는 거의 없습니다. (사실 중첩은 순서를 모호하게 만들 수 있습니다.) 각 단계는 무언가를 변경하거나 이전 단계에 의존할 수 있으므로 레시피를 위에서 아래로 작성된 정확한 순서대로 실행하는 것이 중요합니다. 명령형 프로그램이라고도 하는 이러한 레시피는 함수 호출로 작성됩니다.
const eggs = crackEggs();
heat(fryingPan);
put(fryingPan, butter);
await delay(30000);
put(fryingPan, eggs);
반면 청사진은 사물이 어떤 명사로 구성되어 있는지를 설명합니다. 특정 작업 순서를 규정하는 것이 아니라 전체가 어떻게 부분으로 나뉘는지를 설명할 뿐입니다. 그렇기 때문에 이를 '선언적 프로그램'이라고 하죠. 이러한 청사진은 자연스럽게 깊게 중첩되기 때문에, 태그를 사용하여 작성하는 것이 더 편리합니다.
<Building>
<Roof />
<Floor>
<Room />
<Room>
<Person name="Alice" />
<Person name="Bob" />
</Room>
</Floor>
<Basement />
</Building>
실제로 많은 프로그램들은 두 가지 기술을 결합합니다. 예를 들어, 일반적인 리액트 컴포넌트는 명령형 레시피(이벤트 핸들러의 함수 호출 시퀀스 등)와 선언형 청사진(반환된 JSX 태그 등)을 결합합니다.
하지만 궁극적으로 프로그램은 무언가를 수행해야 합니다. 레시피는 실행할 준비가 되어 있으므로 다음에 무엇을 수행해야 할지에 대한 모호함이 없습니다. 이 단계를 수행한 다음, 이 단계를 수행하고, 이 단계를 수행한 다음, 이 단계를 수행하면 완료됩니다. 반면, 청사진은 무언가를 구성하기 위한 세부적인 계획일 뿐입니다. 이 청사진이 실제로 작동하려면, 어떤 레시피가 그것을 보고 구성하겠다고 결정하고 실제 행동에 옮겨야만 합니다. (예를 들어, 리액트는 JSX 청사진에 기술된 DOM을 구성합니다.)
어떤 의미에서 청사진은 레시피와 비슷하지만, 더 수동적이고 비활성적이며 향후 해석에 열려 있습니다. 청사진은 마치 시간이라는 요소만 빠진 레시피라고도 할 수 있습니다. 시간을 제외하면 사물, 명사, 태그와 같은 구조만 남게 됩니다.
청사진은 잠재적인 레시피입니다. 어떤 레시피가 결국 그 계획을 실행할지 여부에 따라 일어날 수도 있고 일어나지 않을 수도 있는 계획입니다.
청사진은 태그로 이루어집니다. 레시피는 함수 호출로 이루어지죠. 만약 청사진이 잠재적인 레시피라고 한다면, 태그는 잠재적인 함수 호출이라고 할 수 있겠네요.
잠깐, 뭐라고요?
Await 및 RPC
함수 호출은 다음과 같이 쉽게 할 수 있습니다.
alert("Hello");
해당 함수의 실행이 끝나면 다음 줄이 즉시 실행될 것이라고 확신할 수 있습니다. 특히, 한 함수 호출의 결과를 바로 다음 함수 호출에 사용할 수 있다는 점이 매우 유용합니다.
const name = prompt("Who are you?");
alert("Hello, " + name);
console.log("Done.");
하지만 호출하려는 함수가 다른 컴퓨터에 있다고 가정해 보세요. 정말 짜증 나겠죠? 하지만 실제로 그런 일이 일어납니다.
이 상황을 처리하는 표준적인 방법은 일종의 네트워크 요청을 보내는 것일 겁니다. 이 분야에는 HTTP 같은 방식은 물론, 그보다 더 저수준의 방법들도 이미 많이 존재합니다. 우리 대부분은 바이트가 해저 케이블을 따라 어떻게 이동하는지조차 모른 채 평생을 개발자로 살아갑니다. 정말 놀라운 일이죠.
물론 문제는 네트워크 호출이 끝날 때까지 프로그램을 이어서 진행할 수 없다는 것입니다. 다른 컴퓨터와 통신하지 않고는 상대방의 name을 알 수 없다면, alert 함수를 호출하기 전에 코드 실행을 "일시 중지"해야 합니다.
여러분이 이 문제를 처음 겪었다고 가정해 보세요.
한 가지 아이디어는 콜백 함수를 받는 callNetwork API를 개발하는 것입니다.
callNetwork("https://another-computer/?fn=prompt&args=Who+are+you?", (response) => {
const name = response;
alert("Hello, " + name);
console.log("Done.");
});
응답이 도착하면 callNetwork API는 응답과 함께 전달된 콜백 함수를 호출하고, 나머지 코드가 실행되게 합니다.
솔직히 첫 번째 아이디어치고는 나쁘지 않습니다. 하지만 좋은 것도 아닙니다.
- 네트워크 호출로 인해 코드의 흐름이 꼬였습니다. 이전에는 코드가 위에서 아래로 순차적으로 실행되었지만 이제 코드 실행 흐름에 변화가 생겼습니다. 개념적으로
alert('Hello' + name)은 우리가 전달하고자 하는 레시피에서 “다음에 일어날 일”입니다. 하지만 컴퓨터가 그 내용을 "기다려야 할 작업" 으로 인식할 수 있도록, 우리는 이 코드를callNetwork호출 안에 집어 넣어야 합니다. - 두 코드 조각 사이의 연결이 끊어졌습니다. 일반적으로 함수를 호출하고 싶을 때는 함수를 호출하기만 하면 됩니다. 만약 같은 파일 안에 있다면 말이죠. 만약 함수가 다른 파일에 있다면
export를 하고 여기에서는import를 합니다. 그러나 이 경우에는 더 이상 함수 호출이 아니라 HTTP 호출을 다루고 있습니다. 수십 년 동안 REST API를 다뤄온 사람이라면 이해하기 어려울 수도 있지만, 사실 이 전환 과정에서 본질적인 부분을 잃어버렸습니다. 우선, 타입 체크가 되지 않습니다! 해당 엔드포인트가 존재하지 않을 수도 있습니다. 해당 호출에 대해 IDE에서 코드 따라가기를 통해 함수가 어디에 정의되어 있고 어떤 기능을 하는지 확인할 수 없습니다. 호출되는 함수와 함수를 호출하는 위치 사이에는 직접적이고 강한 연결이 있었지만, 더 이상 존재하지 않습니다. 개념적으로 멋진 분리를 하고 싶어서가 아니라 그 연결을 유지할 수 있는 다른 수단이 없기 때문이죠.
alert 기능도 다른 컴퓨터에 있다고 상상하면 문제가 더 쉽게 이해됩니다. 이제 코드는 다음과 같습니다.
callNetwork("https://another-computer/?fn=prompt&args=Who+are+you?", (response) => {
const name = response;
callNetwork("https://yet-another-computer/?fn=alert&args=Hello,+" + name, () => {
console.log("Done.");
});
});
그렇다면 이 두 가지 문제를 어떻게 해결할 수 있을까요?
두 가지 아이디어가 떠오릅니다.
첫 번째 문제인 코드의 흐름이 꼬이는 문제를 해결하기 위해 async 키워드라는 새로운 개념을 도입할 수 있습니다. async 키워드를 붙인 함수는 호출되자마자 끝까지 실행된다고 보장되지 않으며, 오히려 네트워크 요청과 같은 작업으로 인해 실행 도중 일시적으로 중단(pause)될 수 있도록 의도된 함수입니다. 이러한 실행 방식에 따라, 호출하는 쪽에서는 await을 사용해 그 사실을 명시적으로 인지하고 기다려야 하며, 그래야 실행 흐름이 예상치 못하게 끊기는 상황을 방지할 수 있습니다. 즉, 호출하는 함수 쪽도 결국 실행을 일시 중단할 수 있게 된다는 뜻이며, 따라서 해당 함수 역시 async로 선언되어야 합니다. 이렇게 해서 async와 await은 호출 체계를 따라 위쪽으로 전파되며, 어느 누구도 실행 도중 코드가 일시적으로 중단되는 상황에 당황하지 않도록 합니다. 적어도 아이디어는 이렇습니다.
이 아이디어는 나쁘지 않습니다. 사실 어떤 식으로든 이러한 아이디어는 요즘 새 프로그래밍 언어에서는 기본 중의 기본입니다. 사람들에게 그게 좋은 것인지 설득할 필요조차 없습니다.
이렇게 하면 코드가 다음과 같이 바뀝니다.
const name = await callNetwork("https://another-computer/fn=prompt&args=Who+are+you?");
await callNetwork("https://yet-another-computer/fn=alert&args=Hello,+" + name);
console.log("Done.");
이제 두 번째 문제가 눈에 들어왔습니다. 한 컴퓨터에서는 prompt라는 함수를 호출하고 다른 컴퓨터에서는 alert라는 함수를 호출하려고 합니다. 이러한 함수가 실제로 코드베이스에 정의되어 있다고 가정해 봅시다.
말 그대로 다른 컴퓨터에서 이 함수를 가져올 수 있다면 어떨까요?
import { prompt } from "another-computer";
import { alert } from "yet-another-computer";
const name = await prompt("Who are you?");
await alert("Hello, " + name);
console.log("Done.");
잠깐만요, 하지만 위에서 언급한 것처럼 실제로는 문제가 해결되지 않습니다. 예를 들어, 타입스크립트는 another-computer가 무엇인지 알지 못합니다. 대신 코드베이스의 실제 위치에서 해당 함수를 가져올 수 있다고 가정해 보겠습니다.
import { prompt, alert } from "./stuff";
const name = await prompt("Who are you?");
await alert("Hello, " + name);
console.log("Done.");
하지만 잠깐만요, 그건 그냥 일반적인 import입니다. 이 컴퓨터의 프로그램으로 가져오는 것이지만, 여러분이 원한 것은 다른 컴퓨터에 배포하는 것이었습니다. 네트워크 경계를 넘어 HTTP를 통해 원격으로 호출되도록 하려면, 그 사실을 코드 어딘가에 표현해야 합니다.
이를 표현할 수 있는 특별한 구문을 만들어 봅시다. 이 구문은 나중에 수정할 수 있지만, 일단 지금은 수십 년 동안 RPC 또는 "원격 프로시저 호출"로 알려져 온 바에 따라 import rpc라고 부르겠습니다.
import rpc { prompt, alert } from './stuff';
const name = await prompt('Who are you?');
await alert('Hello, ' + name);
console.log('Done.');
이제 타입스크립트가 이러한 함수를 클릭할 수 있을 뿐만 아니라 이 함수가 원격 경계 뒤에 있다는 것을 인식하여, 강제로 async로 선언하고, 입력과 출력의 유형을 직렬화할 수 있도록(따라서 실제로 네트워크를 통해 이동할 수 있도록) 보장한다고 상상해 보십시오.
async/await 및 import rpc, 이 정도면 하루의 발명품으로 충분합니다.
아니면?..
혹시 전화 줄래? (Call Me Maybe)
동료가 문제를 가지고 찾아옵니다. "async/await 및 import rpc는 정말 훌륭했어요. 그런데 만약, 상대 컴퓨터가 아예 응답을 주지 않는 구조라면 어떻게 하죠? 즉, 호출이 성공했는지 실패했는지도 전혀 알 수 없는 상황이라면요? 그런 환경에서 서로 의존하는 함수 호출들을 어떻게 조합하고 표현할 수 있을까요?”
처음에는 말도 안 되는 질문처럼 들리지만 잠시 곰곰이 생각해 보세요.
다른 컴퓨터가 응답하지 않으면... 당연히 언제 완료되었는지 알 수 없으므로 await로 일시 중지해도 소용없습니다. 따라서 다음과 같이 할 수 없습니다.
await alert("Hello, " + name);
더 나쁜 점은, 함수가 있는 컴퓨터가 응답하지 않으면 함수 호출의 결과도 얻을 수 없으므로 다음 역시 동작할 수 없다는 것입니다.
const name = await prompt("Who are you?");
가망 없다고 보일 수도 있지만, 다시 비판적으로 살펴 보죠. 비록 다시 정보를 받을 수는 없지만, 최소한 상대방 컴퓨터에게 전달할 수는 있습니다.
예를 들어, 다음은 정보를 상대방 컴퓨터에게 전달하기만 합니다.
alert("Hello");
상대방 컴퓨터가 응답하지 않더라도 여기서는 'Hello' 문자열로 alert 함수를 호출하도록 요청하는 것뿐입니다. 어떤 응답도 요구하지 않습니다.
따라서 이 호출은 가능해야 합니다! 다만... 일반 함수 호출처럼 작동하지 않으므로 일반 함수 호출과 동일한 구문을 사용하는 것은 잘못된 것 같습니다. 일반적으로는 함수 호출이 완료된 후 다음 코드가 실행될 것으로 예상할 수 있지만, 여기서는 이를 보장할 수 없습니다. 실제로 호출이 성공할지 전혀 확신할 수 없으며, 네트워크 때문에 중간에 실패할 경우 이를 알 수 있는 방법이 없습니다. RPC와 달리 네트워크 오류에 대한 알림을 받지 못합니다.
이것은 함수 호출이 아닙니다. 이것은... 잠재적인 함수 호출입니다. 미래에 일어날 수도 있고 일어나지 않을 수도 있는 호출입니다. 함수 호출의 청사진이라고 할 수 있습니다.
이러한 "잠재적 호출"을 위해 몇 가지 가상의 구문을 만들어 봅시다.
alert⧼'Hello'⧽;
나중에 이 구문을 변경할 수도 있습니다. 하지만 지금은 이 구문의 의미, 즉 우리가 실제로 무엇을 하고 싶은지를 생각해 봅시다. 설계 과정에서 “상대방 컴퓨터가 응답할 수 없다”는 제한 사항부터 시작하여, 이러한 "잠재적 호출"의 의미에 불가피한 제약이 있는지 살펴보는 것이 좋습니다.
분명히 이러한 "잠재적 호출"은 실행을 중단하지 않으며 나머지 코드에 영향을 미치지 않습니다. 기다릴 것이 없기 때문에 아무것도 "기다리지" 않습니다.
alert⧼'Hello'⧽; // 성공/실패 여부를 알 수 없음
console.log('Done.') // 즉시 실행됨
그러면 이런 의문이 생깁니다. 이러한 '잠재적 호출'은 무엇을 반환해야 할까요?
const name = prompt⧼'Who are you?'⧽;
console.log(name); // ???
분명히 prompt⧼'Who are you?'⧽ 는 상대 컴퓨터가 응답할 수 없기 때문에 prompt 호출의 최종 실제 반환 값을 반환할 수 없습니다. 이 구문이 항상 정의되지 않은 값(undefined)을 반환하도록 결정할 수도 있지만 이는 다소 제한적으로 느껴집니다. "잠재적 호출"이라는 prompt와 "잠재적 호출"이라는 alert 사이를 조율할 방법이 없으니까요!
우리가 달성하고자 하는 것은 바로 이런 것입니다.
const name = prompt⧼'Who are you?'⧽;
alert⧼'Hello, ' + name⧽;
문제는, 위의 코드가 성립하지 않는다는 점입니다. 왜냐하면 prompt라는 “잠재적 호출”로부터는 아무런 값을 얻어올 수 없기 때문입니다. 따라서 name이라는 변수에 값을 할당할 수도 없고, 이 컴퓨터에서는 그 반환값에 + 연산을 적용할 수도 없습니다. 하지만 한 가지 아이디어가 있습니다. 아예 위의 두 줄을 전부 “잠재적 호출”만으로 다시 표현해보는 것입니다.
alert⧼
concat⧼
'Hello, ',
prompt⧼'Who are you?'⧽
⧽
⧽;
(앞으로는 concat이 (a, b) => a + b로 설정된 전역 함수라고 가정합니다.)
이런 식으로 코드를 재구성하면 두 가지 이점이 있습니다. 첫째, 어차피 값을 알 수 없는 name 같은 무의미한 변수를 선언하지 않아도 됩니다. 그 prompt같은 함수는 다른 컴퓨터에서 실행될 예정이기 때문에, 이쪽에서 그 결과인 name값은 어차피 알 수 없습니다. 둘째, 이렇게 중첩된 “잠재적 호출”을 하나의 표현식으로 다룰 수 있게 해줍니다. 그리고 그 표현식을 JSON으로 쉽게 직렬화할 수 있습니다.
{
fn: 'alert',
args: [{
fn: 'concat',
args: ['Hello, ', {
fn: 'prompt',
args: ['Who are you?']
}]
}]
}
그렇게 만들어진 JSON을 응답할 수 없는 다른 컴퓨터에 전달할 수 있고, JSON을 받은 컴퓨터는 우리가 보낸 지시를 다음과 같은 함수로 해석해 실행하게 됩니다.
function interpret(json) {
if (json && json.fn) {
// 함수 이름으로 전역 함수를 찾습니다
let fn = window[json.fn];
// 인자 안에 중첩된 호출이 있다면 그것도 해석합니다
let args = json.args.map((arg) => interpret(arg));
// 실제로 함수를 호출합니다
let result = fn(...args);
// 반환값에 또 다른 호출이 포함되어 있다면 그것도 처리합니다
return interpret(result);
} else {
return json;
}
}
위의 JSON 객체를 interpret()에 전달하면 원래 코드와 동일한 작업이 수행되는지 콘솔에서 확인할 수 있습니다. (concat을 전역으로 정의하는 것을 잊지 마세요!).
다시 말해, 이 접근 방식은 작동합니다!
이 구문을 다시 한번 살펴봅시다.
concat⧼
'Hello, ',
prompt⧼'Who are you?'⧽
⧽
⧽;
이제 prompt와 alert 사이의 "잠재적 호출"과 같은 의존관계는, 이 호출들을 서로 중첩시켜 표현해야 한다는 사실을 알게 되었습니다. 이들 사이에 일반적인 코드 형태를 끼워 넣을 수는 없습니다. 왜냐하면 이 코드는 직렬화되어 다른 컴퓨터에서 해석되고 실행될 예정이기 때문입니다. 따라서 이 호출들은 코드 형태라기보다는… 선언적인 마크업(markup) 표현에 가깝다고 할 수 있습니다.
호출을 구성하는 다른 방법이 없기 때문에 중첩 수준이 깊을 것으로 예상됩니다. 따라서 구문을 좀 더 쉽게 읽을 수 있도록 만드는 것이 좋습니다.
<alert>
<concat>
Hello,
<prompt>Who are you?</prompt>
</concat>
</alert>
특이한 점이 있습니다.
일반 함수 호출의 경우 반환 값은 호출한 함수에 의해 결정됩니다.
const result = prompt("Who are you?");
console.log(result); // 'Dan'
그러나 "잠재적" 함수 호출의 경우 반환 값은 데이터로서의 호출 자체입니다.
const inner = <prompt>Who are you?</prompt>;
// { fn: 'prompt', args: ['Who are you?'] }
const outer = <concat>Hello, {inner}</concat>;
// {
// fn: 'concat',
// args: ['Hello', { fn: 'prompt', args: ['Who are you?'] }]
// }
const outest = <alert>{outer}</alert>;
// {
// fn: 'alert',
// args: [{
// fn: 'concat',
// args: ['Hello', { fn: 'prompt', args: ['Who are you?'] }]
// }]
// }
아직 호출은 이루어지지 않았으며, 호출에 대한 청사진만 작성하고 있습니다.
<alert>
<concat>
Hello,
<prompt>Who are you?</prompt>
</concat>
</alert>
이 "잠재적 호출"의 청사진은 코드처럼 보이지만 데이터처럼 작동합니다. 구조적으로 함수 호출과 유사하지만, 더 수동적이고 비활성적이며 해석의 여지가 있습니다. 아직 이 청사진을 실제로 해석할 다른 컴퓨터로 보내지는 않았습니다.
어쨌든 "잠재적 함수 호출"을 너무 많이 쓰다 보니 신경이 쓰입니다.
그냥 태그라고 부르기로 하죠.
함수 분할 (Splitting a Function)
여기 함수가 있습니다.
function greeting() {
const name = prompt("Who are you?");
alert("Hello, " + name);
}
실행하면 일반적인 함수가 그러하듯, 한 번에 실행됩니다.
실행을 두 부분으로 나누고 싶다고 가정해 봅시다. 첫 번째 부분은 즉시 실행됩니다. 두 번째 부분은 호출자가 결정할 때 실행됩니다.
다음은 이를 수행하는 쉬운 방법입니다.
function greeting() {
const name = prompt("Who are you?");
return function resume() {
alert("Hello, " + name);
};
}
이제 함수를 두 부분으로 실행할 수 있습니다.
const resume = greeting(); // 첫 번째 부분을 실행
resume(); // 두 번째 부분을 실행
이제 다른 컴퓨터에서 두 번째 부분을 실행하려고 한다고 가정해 보겠습니다. 여전히 하나의 계산으로 생각하고 있습니다. 단지 물리적으로 분산되어 있을 뿐입니다.
“쉬워요!"라고 말합니다.
function greeting() {
const name = prompt("Who are you?");
return `function resume() {
alert('Hello, ' + name);
}`;
}
잠깐, 뭐라고요?
함수의 나머지 코드를 다른 컴퓨터로 전송하여 계산을 완료할 수 있도록 반환하는 것이군요. 하지만 잠깐만요! 다른 컴퓨터의 입장에서는 name이 정의되지 않았기 때문에 작동하지 않습니다.
function resume() {
alert("Hello, " + name); // 🔴 ReferenceError: name is not defined
}
당신은 “문제없습니다."라고 말합니다.
function greeting() {
const name = prompt("Who are you?");
return `function resume() {
alert('Hello, ' + ${JSON.stringify(name)});
}`;
}
어떻게 바뀌었는지 알겠습니다. 여러분은 첫 번째 컴퓨터에서 얻은 name 값을 다른 컴퓨터로 보낸 코드에 직접 포함시켰습니다. 그 컴퓨터의 관점에서 보면 name이 마치 항상 있었던 것처럼 미리 계산된 것처럼 보일 것입니다.
function resume() {
alert("Hello, " + "Dan");
}
사실 이 기능은 더 큰 그림의 일부라는 사실을 전혀 모를 것입니다. 이 함수의 관점에서 보면 세상은 두 번째 컴퓨터에서 시작됩니다. 이 기능이 더 복잡해지면 이 기능이 전부라고 생각하기 시작할 수도 있습니다. 괜찮습니다.
하지만 여러분은 전체를 보셨으니까요.
function greeting() {
const name = prompt("Who are you?");
return `function resume() {
alert('Hello, ' + ${JSON.stringify(name)});
}`;
}
이는 흥미로운 형태인데, 프로그램이 네트워크를 통해 전송할 수 있는 형태로 나머지 부분을 반환하여 다른 컴퓨터에서 계속 실행할 수 있도록 합니다. 이를 네트워크를 통한 클로저라고 부를 수 있습니다. 작동 방식에 대해 몇 가지 알아두세요.
- 데이터는 엄격하게 첫 번째 컴퓨터에서 두 번째 컴퓨터로 한 방향으로만 흐릅니다. 두 번째 컴퓨터는 첫 번째 컴퓨터의 값을 볼 수 있습니다(텍스트로 변환할 수 있는 한). 하지만 첫 번째 부분은 두 번째 부분에 대해 아무것도 알지 못합니다. 첫 번째 파트는 대본을 작성하고 두 번째 파트는 무대에서 대본을 공연합니다.
- 첫 번째 부분과 두 번째 부분은 완전히 분리되어 있습니다. 단일 개념 프로그램의 일부이긴 하지만 별도의 런타임 환경입니다. 시간과 공간으로 분리되어 있기 때문에 런타임에 서로 조율할 수 없습니다. 모듈 시스템은 서로 완전히 분리되어 있고, 각각 고유한 전역이 있으며, 심지어 다른 자바스크립트 엔진에서 실행될 수도 있습니다.
- 각 파트 사이의 경계는 확고하면서도 유동적입니다. 경계가 확고한 이유는 두 환경이 완전히 분리되어 있기 때문이며, 닫혀 있는 것 외에는 공유되는 것이 없기 때문입니다. 그러나 경계가 유동적인 이유는 두 세계 사이를 이동할 수 있기 때문입니다. 어느 쪽에서 어떤 줄을 실행할지, 두 번째 컴퓨터에서 더 많은 코드를 실행할지, 이미 미리 계산된 데이터를 전달할지 등을 선택할 수 있습니다.
마지막 요점은 좀 더 자세히 설명할 필요가 있습니다. 1에서 n까지의 숫자에 대한 알림을 표시하여 숫자가 3으로 나뉘면 'Fizz', 5로 나뉘면 Buzz, 둘 다로 나뉘면 FizzBuzz라고 경고하고 싶다고 가정해 보겠습니다.
function fizzBuzz() {
const n = Number(prompt("How many?"));
for (let i = 1; i <= n; i++) {
if (i % 3 === 0 && i % 5 === 0) {
alert("FizzBuzz");
} else if (i % 3 === 0) {
alert("Fizz");
} else if (i % 5 === 0) {
alert("Buzz");
} else {
alert(i);
}
}
}
이제 이것이 두 대의 컴퓨터용 프로그램이라고 가정해 보겠습니다. 여러 가지 방법으로 분할할 수 있습니다. 예를 들어 두 번째 컴퓨터에서 모든 작업을 수행하도록 선택할 수 있습니다.
function fizzBuzz() {
return `function resume() {
const n = Number(prompt('How many?'));
for (let i = 1; i <= n; i++) {
if (i % 3 === 0 && i % 5 === 0) {
alert('FizzBuzz');
} else if (i % 3 === 0) {
alert('Fizz');
} else if (i % 5 === 0) {
alert('Buzz');
} else {
alert(i);
}
}
}`;
}
하지만 첫 번째 컴퓨터에서 prompt를 실행하고 싶을 수도 있습니다. prompt 호출을 앞부분으로 이동한 다음 두 번째 부분으로 데이터로 n을 전달할 수 있습니다.
function fizzBuzz() {
const n = Number(prompt("How many?"));
return `function resume() {
const n = ${JSON.stringify(n)};
for (let i = 1; i <= n; i++) {
if (i % 3 === 0 && i % 5 === 0) {
alert('FizzBuzz');
} else if (i % 3 === 0) {
alert('Fizz');
} else if (i % 5 === 0) {
alert('Buzz');
} else {
alert(i);
}
}
}`;
}
두 번째 컴퓨터의 관점에서 보면 n은 하드코딩된 것처럼 보일 것입니다.
사실 첫 번째 컴퓨터에서 모든 메시지를 미리 계산할 수도 있습니다.
function fizzBuzz() {
const n = Number(prompt("How many?"));
const messages = [];
for (let i = 1; i <= n; i++) {
if (i % 3 === 0 && i % 5 === 0) {
messages.push("FizzBuzz");
} else if (i % 3 === 0) {
messages.push("Fizz");
} else if (i % 5 === 0) {
messages.push("Buzz");
} else {
messages.push(i);
}
}
return `function resume() {
const messages = ${JSON.stringify(messages)};
messages.forEach(alert);
}`;
}
그러면 두 번째 컴퓨터의 입장에서는 메시지를 반복하는 것 외에는 할 수 있는 계산이 남지 않습니다. 예를 들어 16을 n으로 선택하면 두 번째 컴퓨터의 관점에서 전체 프로그램은 다음과 같이 보입니다.
function resume() {
const messages = [1, 2, "Fizz", 4, "Buzz", "Fizz", 7, 8, "Fizz", "Buzz", 11, "Fizz", 13, 14, "FizzBuzz", 16];
messages.forEach(alert);
}
messages를 미리 계산하는 것의 단점은 전송할 데이터의 크기가 n이 커질수록 커진다는 것입니다. 피즈버즈 알고리즘은 간단하기 때문에 n 자체를 전송하고 두 번째 컴퓨터가 피즈버즈 자체를 실행하도록 하는 것이 더 현명합니다. 중요한 부분은 데이터 전달과 코드 실행 사이의 절충점을 선택할 수 있다는 것입니다.
이제 원래의 예제로 돌아가 보겠습니다.
프로그램을 두 대의 컴퓨터로 분할하면 연산을 유연하게 이동할 수 있다는 개념적 요점을 설명했습니다. 하지만 실제로는 코드의 절반을 문자열 안에 작성하고 싶지 않을 것입니다.
function greeting() {
const name = prompt("Who are you?");
return `function resume() {
alert('Hello, ' + ${JSON.stringify(name)});
}`;
}
대신 다른 파일에 resume를 작성하여 가져오는 것이 좋습니다.
import { resume } from "./stuff";
function greeting() {
const name = prompt("Who are you?");
return resume(name);
}
잠깐만요, resume는 일반적인 가져오기가 아니라 이 함수의 코드를 다른 컴퓨터로 보내야 합니다! 따라서 지금 이 컴퓨터에서 함수 자체를 가져오거나 코드를 실행하는 것이 아니라 해당 함수를 참조하고 싶을 것입니다. 이 경우 import rpc를 만든 RPC를 떠올릴 수 있습니다. 다른 컴퓨터로 전송할 함수를 표시하는 또 다른 유사한 주석을 만들어 봅시다.
import tag { resume } from './stuff';
function greeting() {
const name = prompt('Who are you?');
return resume(name);
}
tag를 import하는 이유는 무엇일까요? 이 기능은 "응답"하지 않는 컴퓨터에 있으므로 호출할 수 없습니다. 기껏해야 "잠재적 호출", 즉 태그만 할 수 있습니다!
import tag { resume } from './stuff';
function greeting() {
const name = prompt('Who are you?');
return <resume name={name} />;
}
(import rpc 및 import tag 구문은 나중에 다시 살펴보고 수정하겠습니다.)
이렇게 분할된 프로그램을 흔히 클라이언트-서버 애플리케이션이라고 합니다.
import tag { Client } from './stuff';
function Server() {
const data = precomputeData();
return <Client data={data} />;
}
클라이언트와 서버를 서로 통신하는 두 개의 개별 프로그램으로 보고 싶을 수 있습니다. 하지만 이제는 네트워크를 통해 나머지를 시공간을 넘나들며 보내는 하나의 기능이라는 것을 알고 계실 겁니다.
이 점을 잊지 마세요.
양쪽의 태그 (Tags on Both Sides)
몇 섹션 전에 태그를 발명했습니다.
function greeting() {
return (
<alert>
<concat>
Hello,
<prompt>Who are you?</prompt>
</concat>
</alert>
);
}
다시 말해, 태그는 함수 호출과 매우 유사하지만 실제로는 아무것도 호출하지 않고 호출의 구조를 반영할 뿐입니다. 그렇기 때문에 태그는 지금 당장, 그리고 여기서는 아닐 수도 있지만 실행하고 싶은 계산을 표현하는 완벽한 방법입니다. 태그는 계산의 계획, 즉 청사진을 나타냅니다.
function greeting() {
return {
fn: "alert",
args: [
{
fn: "concat",
args: [
"Hello, ",
{
fn: "prompt",
args: ["Who are you?"],
},
],
},
],
};
}
태그 자체만으로는 아무것도 할 수 없습니다. 일부 코드는 태그가 말하는 내용을 실제로 해석해야 합니다. 위의 예제에서 확인한 한 가지 방법은 다음과 같습니다.
function interpret(json) {
if (json && json.fn) {
let fn = window[json.fn];
let args = json.args.map((arg) => interpret(arg));
let result = fn(...args);
return interpret(result);
} else {
return json;
}
}
코드를 실행하여 interpret(greeting())이 예상 결과를 생성하는지 확인합니다.
하지만 해석은 주관적이라는 점이 문제입니다. 무언가에 대한 해석은 여러 가지가 있을 수 있습니다. 이것이 바로 해석의 핵심입니다. 그런 종류의 유연성을 허용합니다.
앞의 예제에서 interpret 함수는 전역 window 범위에서 각 태그를 직접 구현하는 함수를 찾고 있었습니다. 그래서 window.alert와 window.prompt 등을 찾을 수 있었습니다. 이제 약간 다른 버전의 interpret을 만들어 보겠습니다. 이 버전은 이러한 함수와 함께 명시적인 knownTags 딕셔너리를 사용합니다. 알 수 없는 태그는 건너뜁니다.
아래와 같아요.
function interpret(json, knownTags) {
if (json && json.fn) {
if (knownTags[json.fn]) {
let fn = knownTags[json.fn];
let args = json.args.map((arg) => interpret(arg, knownTags));
let result = fn(...args);
return interpret(result, knownTags);
} else {
let args = json.args.map((arg) => interpret(arg, knownTags));
return { fn: json.fn, args };
}
} else {
return json;
}
}
이제 interpret에 빈 knownTags를 전달하면 원래 호출 트리를 얻을 수 있습니다.
interpret(greeting(), {});
// {
// fn: 'alert',
// args: [{
// fn: 'concat',
// args: ['Hello, ', {
// fn: 'prompt',
// args: ['Who are you?']
// }]
// }]
// };
그러나 { prompt: window.prompt }를 전달하면 어떻게 되는지 주목하세요.
interpret(greeting(), {
prompt: window.prompt,
});
이제 이름을 먼저 묻고 (prompt 실행됨) 이 트리를 생성합니다.
// {
// fn: 'alert',
// args: [{
// fn: 'concat',
// args: ['Hello, ', 'Dan' /* (or whatever you typed) */]
// }]
// };
여전히 호출 트리가 다시 표시되지만 이번에는 prompt가 "분해"되었습니다!
실험 삼아 prompt와 concat(alert는 제외)을 모두 "분해"해 보겠습니다.
interpret(greeting(), {
prompt: window.prompt,
concat: (a, b) => a + b,
});
이번에는 이전과 마찬가지로 prompt가 실행되지만 alert 호출을 위해 준비된 메시지가 이미 연결되지 않은 상태로 표시됩니다.
// {
// fn: 'alert',
// args: ['Hello, Dan']
// };
즉, alert 호출 자체를 제외한 모든 것을 미리 계산했습니다.
또한 이전처럼 alert, prompt 및 concat을 모두 "분해"해 보겠습니다.
interpret(greeting(), {
alert: window.alert,
prompt: window.prompt,
concat: (a, b) => a + b,
});
// undefined
이번에는 모든 단계가 실행되므로 더 이상 할 일이 남지 않습니다.
태그의 청사진은 특정 연산 순서를 규정하지 않고 구조만 규정하기 때문에 그 순서를 자유롭게 조작할 수 있게 되었습니다. 예를 들어, 이제 하나의 연산을 여러 단계로 나눌 수 있습니다.
const step1 = greeting();
// {
// fn: 'alert',
// args: [{
// fn: 'concat',
// args: ['Hello, ', {
// fn: 'prompt',
// args: ['Who are you?']
// }]
// }]
// };
const step2 = interpret(step1, {
prompt: window.prompt,
concat: (a, b) => a + b,
});
// {
// fn: 'alert',
// args: ['Hello, Dan']
// };
interpret(step2, {
alert: window.alert,
});
// undefined
이를 통해 아이디어를 얻을 수 있습니다.
step1과 step2를 서로 다른 컴퓨터에서 실행하면 어떨까요? 즉, 첫 번째 컴퓨터에서 일부 태그를 먼저 해석하거나 "분해"한 다음 나머지는 나중에 두 번째 컴퓨터에서 해석하거나 "분해"하도록 보내면 어떨까요? 일부 태그가 두 컴퓨터 중 어느 쪽에서 해석하는 것이 더 적합한 경우(예: 두 컴퓨터의 기능이 서로 다른 경우) 이 방법이 유용할 수 있습니다.
물의 상태 변화를 생각해 보세요. 먼저 산 정상에서 얼음이 녹아 물로 변합니다. 그런 다음 강이 흘러내립니다. 마지막으로 물이 증발합니다. 태그도 마찬가지입니다. 일부 태그는 첫 번째 컴퓨터에서 일찍 녹아내릴 수 있습니다. 나머지 태그는 네트워크를 통해 다른 컴퓨터로 이동하여 그곳에서 운명을 맞이할 수 있습니다.
2대의 컴퓨터
당신의 이론은 이해하기 어렵고 때로는 말도 안 된다고 생각하지만 그 큰 윤곽이 드러나기 시작했습니다. 지금까지의 과정을 요약해 달라고 한다면 이렇게 말할 수 있을 것입니다.
일부 프로그램은 여러 컴퓨터에 걸쳐 분산 계산을 수행합니다. 특히 일부 프로그램은 두 대의 컴퓨터에 걸쳐 있는 함수로 표현될 수 있습니다(원칙적으로는 더 많을 수도 있지만). 이러한 함수 중 일부는 첫 번째 컴퓨터에서 일부 계산을 수행한 다음 나머지 코드를 두 번째 컴퓨터로 전송하여 나머지 계산을 "핸드오프"하는 특정 형태를 갖습니다. 이것이 바로 여러분의 이론이 집중하는 함수입니다.
두 기계의 환경에 이름을 붙여 보겠습니다. 여러분의 프로그램은 초기 세계, 즉 첫 번째 머신에서 시작됩니다. 일부 작업은 여기서 진행됩니다. 그런 다음 나머지 작업은 두 번째 기계인 후기 세계로 넘겨집니다.
초기 세계와 후기 세계는 시간과 공간으로 완전히 분리된 두 개의 런타임 환경이므로 상태나 전역 변수를 공유하지 않습니다. 초기 세계는 후기 세계를 위해 일부 잔여 정보, 특히 실행할 남은 코드와 실행에 필요한 데이터를 남길 수 있지만 그 이상은 남기지 않습니다.
초기 세계와 후기 세계는 서로의 코드를 직접 가져오지 않습니다. 왜냐하면 해당 코드를 import 하는 세계로 가져올 뿐이기 때문입니다. 하지만 서로의 코드를 참조하는 역할을 합니다. import tag와 import rpc는 모두 다른 컴퓨터의 코드를 참조하여(타입적으로 안전한 방식으로!) 실제로 가져오는 세계에 로드하지 않고도 유용한 작업을 수행하는 예입니다.
이 둘은 확고하게 분리되어 있기 때문에 초기 세계의 함수는 후기 세계의 함수를 호출할 수 없습니다. 결국 함수 호출은 호출자에게 정보를 다시 전달하기 위한 것이지만, 호출자가 양동이를 오래 걷어차 버린 경우에는 불가능합니다.
하지만 초기 세계에서 후기 세계로 정보를 전달하는 것은 여전히 의미가 있습니다. 이를 허용하기 위해 함수 호출보다 약한 개념인 태그를 발명했습니다. 태그는 함수 호출과 비슷하지만 수동적이고 비활성이며 해석의 여지가 있습니다. 태그는 구체화되기를 기다리는 잠재적 함수 호출입니다. 태그는 데이터로서의 함수 호출로, 지금 또는 더 나은 시점에 실행될 준비가 되어 있거나 전혀 실행되지 않을 수도 있습니다. 태그는 프로토-콜(proto-call)입니다.
이질적인 이론의 실타래가 처음으로 하나로 모이기 시작하는 것을 보며 승리의 기쁨을 느낍니다. 갑자기 보스 음악이 흘러나오기 시작합니다.
누가 타임이라고 했나요?
시간이 반격을 가하다 (Time Strikes Back)
첫 번째 보스는 시간 그 자체입니다. 시간을 이기기 위해서는 '시간에 구애받지 않는 청사진(timeless blueprints)'이 정말로 시간에 구애받지 않는다는 것을 증명해야 합니다. 즉, 계산의 순서를 바꿔도 프로그램이 엉망이 되지 않는다는 것을 보여줘야 합니다. 당신이 옳아야 하겠죠!
이전에 설명하던 greeting 함수입니다.
function greeting() {
return (
<alert>
<concat>
Hello,
<prompt>Who are you?</prompt>
</concat>
</alert>
);
}
보스전을 좀 더 재미있고 무섭게 만들 수 있도록 조금 더 강화해 보겠습니다.
alert와 concat을 자주 조합하고 싶을 것 같아서 별도의 함수로 추출하겠습니다. 저는 이 함수를 "단락"을 뜻하는 p라고 부르겠습니다.
function p(...children) {
return (
<alert>
<concat>{children}</concat>
</alert>
);
}
이제 greeting 함수는 p 태그만 반환하면 됩니다.
function greeting() {
return (
<p>
Hello,
<prompt>Who are you?</prompt>
</p>
);
}
또한 실행된 시간을 반환하는 새로운 clock 함수도 추가할 것입니다.
function clock() {
return new Date().toString();
}
마지막으로 greeting과 p 내부의 clock을 결합하는 app 함수를 추가하겠습니다.
function app() {
return [
<greeting />,
<p>
The time is: <clock />
</p>,
];
}
이제 interpret에서 배열을 지원하기에 좋은 시기입니다. 다행히도 이는 매우 쉽습니다.
function interpret(json, knownTags) {
if (json && json.fn) {
if (knownTags[json.fn]) {
let fn = knownTags[json.fn];
let args = json.args.map((arg) => interpret(arg, knownTags));
let result = fn(...args);
return interpret(result, knownTags);
} else {
let args = json.args.map((arg) => interpret(arg, knownTags));
return { fn: json.fn, args };
}
} else if (Array.isArray(json)) {
return json.map((item) => interpret(item, knownTags));
} else {
return json;
}
}
자, 이제 interpret이 제대로 작동하는지 확인해 보겠습니다.
먼저 모든 태그를 함께 해석해 보겠습니다.
interpret(app(), {
alert: window.alert,
prompt: window.prompt,
concat: (a, b) => a + b,
p: p,
greeting: greeting,
clock: clock,
});
// [undefined, undefined]
이 코드를 실행하면 예상되는 결과가 생성됩니다.
- 내 이름을 묻는
프롬프트가 표시됩니다. 안녕하세요, 댄이라는 알림이 표시됩니다.- 또 다른 알림이 표시됩니다
지금 시간은: 수 Apr 09 2025 15:13:04 GMT+0900 (Japan Standard Time)
여기까지입니다!
이제 여러분이 방어하고 있는 주장은 아직 호출로 전환되지 않은 태그인 청사진일 뿐이므로 이러한 태그는 어떤 순서로든 자유롭게 분해할 수 있다는 것입니다.
이를 테스트해 보겠습니다.
먼저 태그의 절반(p, greeting, clock)만 분해해 보겠습니다.
const step2 = interpret(app(), {
// alert: window.alert,
// concat: (a, b) => a + b,
// prompt: window.prompt,
p: p,
greeting: greeting,
clock: clock,
});
// [
// { fn: 'alert', args: [{ fn: 'concat', args: ['Hello', { fn: 'prompt', args: ['Who are you?'] }] }] },
// { fn: 'alert', args: [{ fn: 'concat', args: ['The time is ', 'Wed Apr 09 2025 15:13:04 GMT+0900 (Japan Standard Time)'] }] }
// ]
예상대로 프롬프트나 경고 없이 조용히 진행되었습니다... 이제 중간 결과를 가져와 나머지 태그(alert, concat, prompt)를 분해할 수 있습니다.
interpret(step2, {
alert: window.alert,
concat: (a, b) => a + b,
prompt: window.prompt,
// p: p,
// greeting: greeting,
// clock: clock,
});
// [undefined, undefined]
이 역시 예상대로 작동합니다.
- 내 이름을 묻는
프롬프트가 표시됩니다. 안녕하세요, 댄이라는 알림이 표시됩니다.- 또 다른 알림이 표시됩니다
지금 시간은: 수 Apr 09 2025 15:13:04 GMT+0900 (Japan Standard Time)
축하합니다!
함수 호출이 아닌 태그로 이루어진 계산을 여러 단계로 분할하여 임의의 순서로 계산할 수 있으며, 이를 통해 시간 자체를 무력화할 수 있음을 증명하셨습니다.
그렇지 않다면?...
concat을 제외한 모든 태그를 함께 분해해 보는 건 어떨까요?
interpret(app(), {
alert: window.alert,
prompt: window.prompt,
// concat: (a, b) => a + b,
p: p,
greeting: greeting,
clock: clock,
});
시대를 초월한 청사진에서 나중에 concat을 실행해도 문제가 되지 않을까요?
- 내 이름을 묻는
프롬프트가 표시됩니다. [object Object]라고 표시되는 알림이 표시됩니다.[object Object]라고 표시되는 또 다른 알림이 표시됩니다.
당신은 죽었습니다.
치명적인 결함
무슨 일이 일어났을까요?
알고 보니 여러분의 이론에는 결함이 있습니다. 프로그램을 함수 호출이 아닌 태그로 기술하더라도, 시간은 여전히 중요합니다. 적어도 어떤 함수들에는 말입니다.
이 예제를 한번 봅시다.
<concat>
Hello,
<prompt>Who are you?</prompt>
</concat>
두 개의 태그가 중첩된 경우 어떤 순서로 해석해야 할까요? <prompt>를 먼저 해석하고 그 결괏값을 concat 함수에 전달해야 할까요? 아니면 concat 함수가 <prompt> 자체를 태그로 받아야 할까요?
일반 함수를 호출한다면 어떻게 동작하는지 생각해 보는 것도 좋을 것 같네요.
concat(
"Hello, ",
prompt("Who are you?"), // 이 코드가 먼저 실행됩니다
);
잘 모르시는 분들을 위해 부연 설명하자면, 자바스크립트에서는 함수를 호출하면 인수를 먼저 계산하여 값이 확정되고 나서 함수를 호출합니다.
function concat(a, b) {
// a: 'Hello, '
// b: 'Dan'
return a + b;
}
태그를 처리하는 interpret 함수도 동일한 순서를 따릅니다. <concat> 같은 태그를 만나면 <prompt>와 같이 중첩된 태그 호출이 있을 수 있으므로, 각 인수에 interpret를 먼저 실행합니다. 그 다음에서야 concat() 함수가 호출됩니다.
function interpret(json, knownTags) {
if (json && json.fn) {
if (knownTags[json.fn]) {
let fn = knownTags[json.fn];
let args = json.args.map((arg) => interpret(arg, knownTags));
let result = fn(...args);
return interpret(result, knownTags);
} else {
// ...
}
} else {
// ...
}
}
위의 결과로 아래 코드는
<concat>
Hello,
<prompt>Who are you?</prompt>
</concat>
아래와 동일합니다.
concat(
"Hello, ",
prompt("Who are you?") // 이 코드가 먼저 실행됩니다
하지만 뭔가 이상하지 않습니까?
태그는 본래 '인수를 먼저 평가해야 한다'는 따분한 자바스크립트 실행 규칙에 얽매이지 않으면서 선언된 그대로 해석되어야 하지 않나요? 그런데 함수 호출과 똑같이 동작한다면, "태그"에 무슨 의미가 있을까요?
그렇다면, 과연 다르게 작동할 수는 없을까요?
글쎄요. 이런 건 어떨까요?
<concat>
Hello,
<prompt>Who are you?</prompt>
</concat>
위 코드가 아래 코드와 동일하다면요?
concat(
// 이 코드가 먼저 실행됩니다
"Hello, ",
<prompt>Who are you?</prompt>,
);
태그가 안쪽부터가 아니라 바깥쪽부터 평가된다고 상상해 보세요. 예를 들어, <prompt />가 안에 들어 있는 <concat>을 사용할 때, 먼저 <prompt> 호출이 일어나지 않는 거죠. 대신, <prompt />가 여전히 하나의 태그로 <concat> 내부에 들어가게 됩니다.
function concat(a, b) {
// a: 'Hello'
// b: { fn: 'prompt', args: ['Who are you?'] }
return a + b;
}
물론 그렇게 되면 <concat>이 완전히 망가질 겁니다. <concat>은 문자열만 이어 붙일 수 있는데, <prompt />처럼 아직 실행되지도 않은 임의의 결괏값을 다룰 수는 없기 때문입니다.
이 문제는 <concat>에만 국한되지 않습니다. 예를 들어, alert 함수도 인수의 기댓값이 문자열인데, 태그를 나타내는 객체가 넘어오면 처리할 수 없습니다.
alert({
fn: "concat",
args: [
/* ... */
],
});
엄밀히 말하면 처리하긴 할 겁니다. 다만 객체를 문자열로 강제 변환해서 "[object Object]" 같은 식이 됩니다.
이제 이유를 알게 되었네요!
원래라면 interpret 함수가 먼저 인자들을 처리했겠지만, 순서가 중요하지 않다는 걸 보여주려고 일부러 <concat> 태그의 해석을 지연시켰습니다. 하지만 실제로는 순서가 중요했던 거죠. concat과 alert 함수 모두 태그가 아니라 정상적인 문자열인 인수를 받아야 제대로 동작하기 때문입니다.
결국 시간의 제약을 받고 있었던 거군요. 함수의 인수들은 먼저 계산되어야 합니다. 바로 그 과정에 시간이 숨어 있었습니다.
여러분의 이론에는 큰 결함이 있습니다.
새로운 희망
당신의 이론에는 치명적인 결함이 있습니다. 그렇다면 할 수 있는 일은 세 가지입니다.
먼저 그 결함이 존재하지 않는 척하는 것입니다. 하지만 그렇게 해도 이론이 나아지진 않습니다.
두 번째로는 이 이론을 포기하는 것입니다. 하지만 이론이 뭔가 중요한 것을 짚고 있던 건 맞지 않나요?
마지막으로, 그 결함을 길잡이로 삼을 수 있습니다. 잘 설계된 실패한 실험처럼, 분명 중요한 무언가를 말해주고 있습니다. 실수를 한 건 맞지만 정확히 어디서 잘못된 걸까요?
확인할 수 있는 좋은 방법이 있습니다.
현재 우리는 항상 중첩된 태그를 먼저 해석한 뒤 부모 태그의 함수를 호출하고 있습니다. 태그 함수들이 안쪽부터 바깥쪽으로 호출되도록 하기 위해서입니다.
function interpret(json, knownTags) {
if (json && json.fn) {
if (knownTags[json.fn]) {
let fn = knownTags[json.fn];
let args = json.args.map((arg) => interpret(arg, knownTags));
let result = fn(...args);
return interpret(result, knownTags);
} else {
let args = json.args.map((arg) => interpret(arg, knownTags));
return { fn: json.fn, args };
}
} else if (Array.isArray(json)) {
return json.map((item) => interpret(item, knownTags));
} else {
return json;
}
}
대신 태그가 포함되어 있더라도 가공하지 않은 인수 그대로를 전달하면 어떻게 될까요?
function interpret(json, knownTags) {
if (json && json.fn) {
if (knownTags[json.fn]) {
let fn = knownTags[json.fn];
let args = json.args;
let result = fn(...args);
return interpret(result, knownTags);
} else {
let args = json.args.map((arg) => interpret(arg, knownTags));
return { fn: json.fn, args };
}
} else if (Array.isArray(json)) {
return json.map((item) => interpret(item, knownTags));
} else {
return json;
}
}
물론 이렇게 하면 지금까지의 예제들에서 에러가 발생할 겁니다. 생각해 보세요. alert()는 <concat> 같은 객체 인수를 처리할 수 없고, concat() 자체도 <prompt> 같은 객체 인수를 처리할 수 없습니다. 이 함수들은 태그가 아니라 문자열 두 개를 원하니까요.
const tags = (
<concat>
Hello, <prompt>Who are you?</prompt>
</concat>
);
interpret(tags, {
concat: (a, b) => a + b,
prompt: window.prompt,
});
// 'Hello, [object Object]'
하지만 이 “결함”을 온전히 받아들이면, 오히려 잘 작동하는 부분이 무엇인지 실마리를 얻을 수도 있습니다.
예를 들어, <concat>을 <p>로 바꾸면 더 이상 출력이 깨지지 않습니다.
function p(...children) {
return (
<alert>
<concat>{children}</concat>
</alert>
);
}
// ...
const tags = (
<p>
Hello, <prompt>Who are you?</prompt>
</p>
);
interpret(tags, {
p: p,
prompt: window.prompt,
});
// { fn: 'alert', args: [{ fn: 'concat', args: ['Hello, ', 'Dan'] }] }
어차피 나중에 concat을 실행해야 하니 겉보기엔 별것 아닌 것처럼 보일 수도 있습니다.
하지만 사실 매우 중요한 차이입니다! concat 함수와 p 함수 사이에는 근본적인 차이가 있습니다.
바깥에서 안쪽으로 호출하는 순서를 따르면 concat을 문제를 일으키지만 p는 그렇지 않습니다.
도대체 왜 그런 걸까요?
임베딩(embedding)과 속성 검사(introspecting)
아래 두 함수를 봅시다.
function concat(a, b) {
return a + b;
}
function pair(a, b) {
return [a, b];
}
그 둘은 어떻게 다른 걸까요?
물론 목적 자체가 다릅니다. 하나는 문자열을 이어 붙이고, 다른 하나는 주어진 두 요소로 배열을 만듭니다. 하지만 그보다 더 미묘한 차이가 있습니다. 바로 인수를 처리하는 방식입니다.
이를 설명하기 위해 비유를 하나 들어보겠습니다.
여러분이 밧줄 두 가닥을 묶는다고 가정해 봅시다. 별로 어려운 일은 아닙니다. 두 가닥을 받아서 묶으면 끝입니다. 그런데 어느 날, 누군가 밧줄 하나, 그리고... 호박 하나를 건넵니다. 호박을 받는 순간, 여러분은 더 이상 일을 할 수 없습니다. 두 밧줄 끝을 잡아야 묶을 수 있는데, 호박은 묶을 끝부분이 없으니까요.
그렇다면 이렇게 결론 내릴지도 모릅니다. “무턱대고 뭔가를 호박으로 바꾸면 문제가 생긴다.” 실제로 종종 그런 일이 일어나긴 합니다. 하지만 항상 그렇지는 않습니다.
여러분이 장난감 가게에서 선물을 포장하는 일을 맡았다고 가정해 보세요. 하루 종일 다양한 선물들을 포장하는 일을 합니다. 인형, 자동차, 혹은 장난감 집일 수도 있죠. 어느 날, 누군가 당신에게 호박을 건넵니다. 원칙적으로는 거절할 수도 있겠지만 사실 호박도 잘 포장할 수 있습니다. 포장할 때는 그 물건의 속성(밧줄의 구조같은)은 중요하지 않습니다. 그냥 상자에 넣으면 됩니다. 여러분은 임베딩을 하고 있는 것이지, 속성 검사를 하고 있는 것이 아닙니다.
위의 concat과 pair의 차이점은 concat은 전달된 값이 무엇인지가 중요하다는 점입니다. 즉, 속성 검사를 수행합니다. 그래서 호박을 전달하면 제대로 동작하지 않습니다. 하지만 pair는 밧줄, 장난감, 호박 모두를 기꺼이 받아들입니다. 임베딩을 하므로 그 속성에 신경 쓰지 않습니다.
그렇다면 이제 이 점이 실행 순서와 어떻게 연결되는지 살펴봅시다.
concat은 인수인 a와 b의 속성을 검사합니다(더 설명하자면 + 연산자가 이를 문자열로 변환합니다). 따라서 concat은 해석되지 않은 태그를 인수로 전달하면 문제가 발생합니다.
concat("Hello ", <prompt>Who are you?</prompt>);
// 'Hello, [object Object]'
반면, pair는 그 인수인 a와 b를 임베딩 합니다. [a, b] 배열을 새로 생성하며 a나 b에 무엇을 전달하든 상관없이 올바르게 동작합니다. 따라서 pair는 태그를 인수로 받아도 아무런 문제가 없습니다. 그냥 그 태그를 결과에 임베딩 해서 출력할 뿐입니다.
const todo = pair("Hello ", <prompt>Who are you?</prompt>);
// ['Hello, ', { fn: 'prompt', args: ['Who are you?'] }]
이렇게 하면 pair 호출 이후에도 그 태그를 해석할 수 있게 됩니다.
const result = interpret(todo, { prompt: window.prompt });
// ['Hello, ', 'Dan']
정리해 보겠습니다.
일반적으로 함수는 호출 전에 인수가 연산되기를 원합니다. 하지만 함수가 인수의 속성을 검사하지 않고 임베딩만 한다면, 인수 연산을 지연시킬 수 있습니다. 그 함수는 인수가 아직 연산되지 않은 상태(태그)로 호출할 수 있으며, 그 태그는 나중에 필요하거나 편할 때 계산할 수 있습니다.
어쩌면 시간을 이길 방법을 찾았을지도 모릅니다.
생각하고 실행하기
여러분의 프로그램은 여전히 동일합니다.
function app() {
return [
<greeting />,
<p>
The time is: <clock />
</p>,
];
}
function clock() {
return new Date().toString();
}
function greeting() {
return (
<p>
Hello,
<prompt>Who are you?</prompt>
</p>
);
}
function p(...children) {
return (
<alert>
<concat>{children}</concat>
</alert>
);
}
function alert(message) {
window.alert(message);
}
function prompt(message) {
return window.prompt(message);
}
function concat(a, b) {
return a + b;
}
하지만 당신의 interpret 함수는 더 간단합니다. 단지 태그를 바깥에서 안쪽으로 해석합니다.
호출 전에 인수를 해석하지 않고 태그를 다른 태그에 전달합니다.
function interpret(json, knownTags) {
if (json && json.fn) {
if (knownTags[json.fn]) {
let fn = knownTags[json.fn];
let args = json.args;
let result = fn(...args);
return interpret(result, knownTags);
} else {
let args = json.args.map((arg) => interpret(arg, knownTags));
return { fn: json.fn, args };
}
} else if (Array.isArray(json)) {
return json.map((item) => interpret(item, knownTags));
} else {
return json;
}
}
시간이 당신을 비웃고 있네요.
“이건 잘 안 될 걸? 함수는 자신의 인수를 알아야 하잖아.”
“그렇지 않은 함수도 있어.”
당신은 프로그램의 모든 함수를 살펴보며, 그 함수들이 태그 내 중첩된 내용을 검사하는지 아니면 임베딩만 하는지 확인합니다.
- 분명히
alert과concat은 태그 내부 내용의 속성을 검사합니다. - 몇몇 함수(
app,clock,greeting)는 인수를 받지 않습니다. - 비록
p에 무언가를 전달하긴 하지만, 단지 내부에 중첩된 내용을 임베딩만 합니다. prompt의 경우는 애매합니다. 엄밀히 말하면prompt는message인수의 속성을 검사합니다(내장된window.prompt에 전달해야 하므로). 하지만 지금까지<prompt>안에 다른 태그를 중첩하려고 하지 않았습니다. 중첩하지 않겠다고 보장한다면(타입을 제한하는 방식처럼), 문제 될 건 없습니다.
명확히 구분하고자 새로운 규칙을 추가하겠습니다.
태그를 인수로 받았을 때 문제가 생기지 않는 함수들, 즉 속성을 검사하지 않고 임베딩만 하는 함수들은 대문자로 시작하게 이름을 짓겠습니다.
function App() {
return [
<Greeting />,
<P>
The time is: <Clock />
</P>,
];
}
function Clock() {
return new Date().toString();
}
function Greeting() {
return (
<P>
Hello,
<prompt>Who are you?</prompt>
</P>
);
}
function P(...children) {
return (
<alert>
<concat>{children}</concat>
</alert>
);
}
function alert(message) {
window.alert(message);
}
function prompt(message) {
return window.prompt(message);
}
function concat(a, b) {
return a + b;
}
이 대문자로 시작하는 함수에 "컴포넌트"라는 특별한 이름을 붙여봅시다. 컴포넌트는 우리 프로그램의 "두뇌"입니다. 무엇을 해야 할지 결정합니다. 컴포넌트는 내부에 중첩된 내용이 무엇인지 검사하지 않습니다. 그러므로 순서나 단계와 관계없이, 어떤 방식으로든 함께 또는 따로 실행될 수 있습니다. 다시 말해, 컴포넌트는 정말 시간의 제약을 받지 않습니다. 태그를 반환하기 때문에 미래에 얽매이지 않으며, 태그를 인수로 받기 때문에 과거에도 얽매이지 않습니다.
그렇다면 나머지 함수들, 예를 들어 alert, prompt, concat은 어떻게 될까요? 이 함수들은 프리미티브(Primitives)라고 부르겠습니다. 프리미티브도 태그로 사용될 수 있지만, 그들은 단순히 내용을 임베딩하는 것이 아니라, 속성을 검사합니다. 그래서 자신이 받은 모든 인수를 알아야 합니다. 프리미티브는 우리 프로그램의 "근육"입니다. 컴포넌트가 대부분의 사고를 끝내면 실제로 일을 처리하는 역할을 합니다. 따라서 프리미티브는 마지막에 실행됩니다. "행동하기 전에 생각하라."
이 구분에 따르면 프로그램은 자연스레 두 단계로 나뉩니다.
먼저 당신은 생각해야 합니다. 즉, 컴포넌트를 실행해야 합니다. 기존의 interpret 함수가 그 일을 처리할 수 있습니다.
const primitives = interpret(<App />, {
App,
Greeting,
Clock,
P,
});
// [
// { fn: 'alert', args: [{ fn: 'concat', args: ['Hello', { fn: 'prompt', args: ['Who are you?'] }] }] },
// { fn: 'alert', args: [{ fn: 'concat', args: ['The time is: ', 'Wed Apr 09 2025 15:13:04 GMT+0900 (Japan Standard Time)'] }] }
// ]
생각한 후에는, 이제 실행해야 합니다. "생각하기" 단계의 결과에는 오직 프리미티브만 포함되어 있습니다.perform이라는 새로운 함수를 만들어 봅시다. 이 함수는 interpret와 매우 비슷하지만, 컴포넌트 대신 프리미티브를 처리합니다. 프리미티브는 속성을 검사하고 인수를 알아야 하므로, perform은 프리미티브가 안쪽에서 바깥쪽으로 실행되도록 보장합니다.
function perform(json, knownTags) {
if (json && json.fn) {
let fn = knownTags[json.fn];
let args = perform(json.args, knownTags);
let result = fn(...args);
return perform(result, knownTags);
} else if (Array.isArray(json)) {
return json.map((item) => perform(item, knownTags));
} else {
return json;
}
}
참고로 perform에는 정의되지 않은 태그를 건너뛰는 코드가 없습니다. knownTags에 포함된 모든 프리미티브만 처리한다고 가정합니다. 이는 perform이 최종 단계로서, 더 이상 연산을 분리할 수 없기 때문입니다.
이제 perform을 사용하여 연산을 마칠 수 있습니다.
perform(primitives, {
alert,
concat,
prompt,
});
// undefined
이는 prompt와 두 개의 예상되는 alert를 표시합니다.
자, 이제 시간으로부터 승리했나요?
어느 정도 승리했네요.
이전의 interpret는 취약했습니다. 일부 태그(예: concat)를 건너뛰면 다른 태그(예: alert)가 암묵적으로 가정한 순서가 깨졌기 때문입니다. 하지만 이제는 그런 일이 발생할 수 없습니다. 이제 interpret는 오직 컴포넌트만 처리하며, 컴포넌트는 순서에 관계없이 실행되어도 괜찮습니다(속성을 검사하지 않고 임베딩만 하기 때문입니다).
반면, 프리미티브는 이제 perform에서 처리되며, perform은 항상 한 번에 작업을 끝냅니다. 그래서 그곳에서도 문제가 발생하지 않습니다.
만약 프로그램이 두 대의 컴퓨터에 걸쳐 실행되도록 확장된다면, (프리미티브가 아닌) 컴포넌트는 두 대의 컴퓨터로 분리될 것입니다. 컴포넌트가 다른 순서로 실행되어도 괜찮기 때문입니다. 반면, 프리미티브는 반드시 끝에서 실행되어야 하므로 최종 단계에 속하게 됩니다.
만약 최종 단계를 실행하는 컴퓨터를 어느 정도 제어할 수 있다면, 흥미로운 최적화를 할 수 있습니다. 모든 프로그램에서 공유되리라 예상되는 프리미티브를 자바스크립트 런타임과 함께 미리 설치할 수 있습니다. 물론, 이러한 프리미티브 모음은 가능한 넓은 범위의 경우를 지원할 수 있도록 신중하게 선별되어야 합니다. 하지만 이미 몇 가지 좋은 후보가 보입니다! 예를 들어, p 함수는 프리미티브로 처리하는 것이 더 합리적일 것 같습니다.
function p(...children) {
return (
<alert>
<concat>{children}</concat>
</alert>
);
}
분명히, "단락(paragraph, 줄여서 p)”은 많은 프로그램에서 사용할 겁니다!
더 넓게 생각해 보면, 그런 프리미티브들의 전체 모음을 떠올릴 수 있습니다. 일부는 시각(<b>굵게</b> 또는 <i>기울임꼴</i> 등) 요소이고, 일부는 동작(<details></details> 펼치기 또는 <a /> 링크 등) 요소입니다.
이제 많은 프로그램이 동일한 프리미티브를 사용하여 복잡한 프로그램을 구축한다면, 자바스크립트 대신 Rust나 C++와 같은 더 낮은 수준의 언어로 프리미티브를 구현하는 것이 나을 수 있습니다. 이후 프리미티브를 고수준의 API로 감싸 자바스크립트에서 사용할 수 있습니다. perform은 그 API를 활용해 연산을 적절히 처리하는 방식으로 다시 작성될 수 있습니다.
function perform(json) {
if (json && json.fn) {
let tagName = json.fn;
let children = perform(json.args);
let node = document.createElement(tagName);
for (let child of [children].flat().filter(Boolean))) {
node.appendChild(child);
}
return node;
} else if (typeof json === 'string') {
return document.createTextNode(json);
} else if (Array.isArray(json)) {
return json.map(perform);
} else {
return json;
}
}
const tree = perform(json);
document.body.appendChild(tree);
단지 프리미티브의 중첩 구조를 표현하기 위한 목적으로 선언형 언어를 따로 설계할 수도 있습니다. 어떤 용도에서는 사람이 직접 작성할 수도 있도록 지금의 구조보다 더 융통성 있게 설계하는 편이 좋을 수도 있습니다.
하지만 프리미티브에 대한 이야기는 이쯤 해두겠습니다. 이제부터는 프리미티브가 어느 정도 적당히 존재하고, 그것들이 <p>처럼 소문자 태그로 작성되며, 어떻게 처리할지 알고 있는 perform 함수가 존재한다고 가정하고 이야기를 진행하겠습니다.
시간을 주제로 한 이야기는 여기까지입니다.
여러분은 이제 시간의 힘을 다룰 줄 알게 되었고, 그 법칙을 존중할 줄도 알게 되었습니다. 더 나아가고 싶으신가요? 그렇다면 "공간"을 배울 차례입니다.
제2막
The Reader and the Writer
독자: 아티클이 정말 기네요!
저자: 당연하죠!
독자: 설마 아직 반도 안 온 건가요?
저자: 그런 것 같아요.
독자: 그런 것 같다고요? 어디로 가고 있는지 모른다는 말인가요?
저자: 대충은 알지만, 솔직히 거의 즉흥으로 쓰고 있긴 해요.
독자: 음, 그건 너무 무책임한 것 같은데요. 전 이 글을 읽느라 많은 시간을 투자했어요. 이 글의 마무리가 만족스럽지 않다면 어떡하죠? 중간에 망쳐버리면요?
저자: 제가 걱정하고 있는 부분이에요. 하지만 글을 끝까지 써봐야 그걸 알 수 있겠네요. 여러분은 그냥 계속 읽어야 합니다. 아마도?
독자: 음, 일단 알겠습니다. 계속 읽어보는 수밖에 없겠네요.
저자: 이해해 주셔서 고맙습니다.
독자: 어차피 다른 선택지가 없잖아요.
저자: 왜 없죠? 그냥 탭을 끄고 다른 일 하러 가셔도 되죠.
독자: 아시다시피 저는 그럴 수가 없어요.
저자: 도대체 왜요?
독자: 글쎄요. 사실 저는 그냥 당신이 만든 캐릭터예요. 어떻게 말할지도 당신이 시키고 있는 거예요. 제가 할 수 있는 게 많지 않고... 뭐라 할까요, 여지가 없어요.
저자: 아하, 여지가 없군요!
저자는 잠시 관객을 바라본다. 그의 표정을 읽기가 어렵다.
독자: ...
저자: ...
독자: 절 위한 대사가 별로 없죠?
저자: 제 실수입니다. 제가 준비한 건 이게 다예요.
독자: ...
저자: ...
독자: 이 대화는 왜 하는 거예요? 어떤 도움이 되는 건가요?
저자: 저도 모르겠어요. 당신 생각은 어때요?
독자: 지금 글을 쓰고 있는 건 당신 아닌가요?
저자: 그렇긴 하죠. 근데 당신도 읽고 있잖아요?
코드와 데이터
이 게시물의 전반부에서는 연산을 시간상으로 나누는 방법에 대해 살펴보았습니다.
그 과정에서, 연산의 일부이자 실제로 작업을 수행하는 프리미티브는 분리되는 것을 싫어하고 함께 실행되기를 원한다는 사실을 알게 되었습니다. 반면, 사고를 담당하는 컴포넌트는 서로 다른 시간에, 다른 순서로, 심지어는 다른 장소에서 실행될 수 있습니다.
이제 잠시 컴포넌트와 프리미티브는 제쳐둡니다.
함수를 시간상으로 나누는 것과 공간상으로 나누는 것의 차이를 살펴보겠습니다. 앞서 살펴본 것처럼 함수를 시간상으로 나누기 위해서는 중첩을 추가하는 것만으로 충분합니다.
function greeting() {
const name = prompt("Who are you?");
return function resume() {
alert("Hello, " + name);
};
}
이렇게 하면 한 번에 모두 실행하지 않고 단계별로 실행할 수 있습니다.
const resume = greeting(); // 첫 번째 단계 실행
resume(); // 두 번째 단계 실행
greeting 함수의 반환 값은 함수입니다. 하지만 그것이 전부는 아닙니다. 이 함수가 greeting 내부에 중첩되어 있다는 점이 중요합니다. 그렇지 않으면 name 변수에 접근할 수 없기 때문입니다. 다시 말해, greeting 함수는 코드 조각(alert 호출) 그리고 코드에 필요한 데이터 조각(name 변수)을 함께 반환하는 것입니다.
이 점은 resume을 최상위 함수로 분리했을 때 더 뚜렷해집니다. 이제는 명시적으로 name을 인수로 받아야 하기 때문입니다.
function resume(name) {
alert("Hello, " + name);
}
resume을 최상위 함수로 분리한다면 greeting을 어떻게 조정할 수 있을까요? resume에 name을 제공하는 중첩 함수를 반환하도록 만드는 방법이 있겠네요.
function greeting() {
const name = prompt("Who are you?");
return () => resume(name);
}
function resume(name) {
alert("Hello, " + name);
}
const resume = greeting(); // 첫 번째 단계 실행
resume(); // 두 번째 단계 실행
하지만 여기서 한 걸음 더 나아가보면 어떨까요? 개념적으로, () => resume(name)은 두 가지 정보를 결합합니다. 바로 코드(resume)와 데이터(name)입니다. 이 관계를 더 명시적으로 표현하려면 [resume, name], 즉 코드와 데이터를 쌍으로 반환할 수도 있습니다.
function greeting() {
const name = prompt("Who are you?");
return [resume, name];
}
function resume(name) {
alert("Hello, " + name);
}
const [code, data] = greeting(); // 첫 번째 단계 실행
code(data); // 두 번째 단계 실행
이는 우리가 현재 태그에 사용하는 객체 표기법과 꽤 비슷해 보입니다. 단지 fn이 문자열이 아니라 실제 함수라는 점만 다릅니다.
function greeting() {
const name = prompt("Who are you?");
return { fn: resume, args: [name] };
}
function resume(name) {
alert("Hello, " + name);
}
const { fn, args } = greeting(); // 첫 번째 단계 실행
fn(...args); // 두 번째 단계 실행
마치 greeting이 함수 호출이 아니라 태그처럼 코드의 구조를 반환하는 것처럼 보입니다. 실행하고자 하는 코드를 나타내긴 하지만, 아직 그 코드를 실행하지는 않습니다.
이는 태그를 바라보는 새로운 관점을 제공합니다. 물론 태그는 잠재적인 함수 호출인 건 맞습니다. 하지만 또 다른 관점에서 보면, 태그는 코드와 데이터의 쌍이라고도 할 수 있습니다.
시간과 공간
이제 연산을 공간에 따라 나누는 방법을 떠올려 봅시다. 이전에 그 방식의 일환으로 코드 조각을 문자열로 반환하는 것을 살펴보았습니다.
function greeting() {
const name = prompt("Who are you?");
return `function resume() {
alert('Hello, ' + ${JSON.stringify(name)});
}`;
}
const code = greeting();
위 코드를 실행하면 greeting()에서 반환된 code를 저장한 뒤, 그것을 다른 컴퓨터에서 코드로 실행할 수 있습니다. 두 번째 컴퓨터는 그것이 전체 프로그램이라고 생각할 것입니다.
function resume() {
alert("Hello, " + "Dan");
}
하지만 여러분은 실제 프로그램에는 두 조각이 모두 포함되어 있다는 것을 알고 있습니다.
현재 greeting은 코드의 문자열을 반환합니다. 그러나 이를 코드와 데이터 두 가지를 반환하는 것으로 생각해도 전혀 문제가 없습니다. 우리는 단지 데이터를(name 변수) 코드 문자열에 직접 삽입하고 있을 뿐입니다.
이 점은 resume 코드를 greeting 외부로 이동시키면 더 명확해집니다.
const RESUME_CODE = `
function resume(name) {
alert('Hello, ' + name);
}`;
function greeting() {
const name = prompt("Who are you?");
return [RESUME_CODE, name];
}
const [code, data] = greeting();
const jsonString = JSON.stringify([code, data]);
이제 resume이 name을 인자로 받으므로, greeting은 resume 함수의 코드, 그리고 코드가 필요한 데이터(name)를 모두 반환해야 합니다. 그러면 우리는 [code, data]를 받아 JSON.stringify로 JSON으로 변환한 후, 다른 컴퓨터에서 JSON.parse로 이를 파싱합니다. 마지막으로 code(data)를 호출하여 프로그램을 종료할 수 있습니다.
물론, 프로그램을 작성할 때 resume의 코드를 문자열로 생각하고 싶지는 않습니다. 그것을 정상적인 코드 조각으로 보고, 최상위 수준에 작성되어 구문 강조가 되며, 타입 체크가 가능하며, 그 외에도 여러 기능을 사용할 할 수 있는 코드로 생각하고 싶습니다.
// ✂️ ✂️ ✂️ ✂️ ✂️ ✂️
function resume(name) {
alert("Hello, " + name);
}
그렇다면 어떻게 코드 조각과 greeting 함수를 연결할 수 있을까요?
function greeting() {
const name = prompt("Who are you?");
return [RESUME_CODE, name];
}
// ✂️ ✂️ ✂️ ✂️ ✂️ ✂️
마치 이 함수들이 두 개의 다른 세계에 존재하는 것 같습니다. 하나는 코드 문자열 밖에 "외부"에 존재하고, 다른 하나는 그 문자열 "내부"에 존재하는 것처럼요. 마치 greeting이 이야기를 쓰고, resume은 그 이야기 안에 있는 사람처럼 보입니다.
이들 사이에는 분명한 논리적 연속성이 있지만, 다른 파일에 정의된 것 이상으로 넓은 간격이 존재합니다. greeting 함수가 실행될 때, resume은 단지 문자열일 뿐입니다. 실제 함수라기보다는 계획이나 아이디어에 가까운 것입니다. 반면, resume이 마침내 실행될 때는 greeting이 존재했다는 사실을 전혀 알지 못합니다. 함수가 받는 것은 오직 name일 뿐입니다.
function greeting() {
const name = prompt("Who are you?");
return [RESUME_CODE, name];
}
// ✂️ ✂️ ✂️ ✂️ ✂️ ✂️
function resume(name) {
alert("Hello, " + name);
}
눈을 살짝 찡그려 보시면 프로그램의 "진짜" 모습을 찾아낼 수 있습니다.
function greeting() {
const name = prompt("Who are you?");
return `function resume() {
alert('Hello, ' + ${JSON.stringify(name)});
}`;
}
하지만 이렇게 "나눠도" 두 부분 모두 동등하며 특정 부분이 더 중요한 게 아닙니다. 모두 우리의 프로그램이며, 다만 시간과 공간에 의해 나뉘어 있을 뿐입니다.
function greeting() {
const name = prompt("Who are you?");
return [RESUME_CODE, name];
}
// ✂️ ✂️ ✂️ ✂️ ✂️ ✂️
function resume(name) {
alert("Hello, " + name);
}
그렇다면 질문 하나가 남습니다. 이 둘을 어떻게 묶을까요?
두 세계
두 세계를 연결하는 가장 간단한 방법은 뒷 세계의 각 함수에 고유한 이름을 부여하여 앞 세계에서 참조할 수 있게 만드는 것입니다.
예를 들어, 한 번만 호출되는 resume이라는 함수가 필요하다고 가정하겠습니다.
function greeting() {
const name = prompt("Who are you?");
return ["resume", name];
}
// ✂️ ✂️ ✂️ ✂️ ✂️ ✂️
window["resume"] = function resume(name) {
alert("Hello, " + name);
};
비록 이 방식은 다소 번거롭긴 하지만, 명시적인(취약하기도 한) 연결을 만듭니다. 만약 뒷 세계에서 resume의 이름을 바꾼다면, 코드베이스에서 함수를 참조하고 있는 다른 코드들을 찾아야 하며, 앞 세계에서 greeting을 찾을 수도 있습니다. 심지어 window['resume']에 타입을 추가할 수도 있습니다.
이 해결책은 그리 나쁘지 않습니다. 사실 여러분이 브라우저에 내장된 기능을 참조할 때 일어나는 일과 유사합니다. 기능을 직접 어디서 가져오지 않고, p와 같은 전역 이름을 이용해 참조합니다.
function Greeting() {
const name = prompt("Who are you?");
return <p>Hello, {name}</p>;
}
// ✂️ ✂️ ✂️ ✂️ ✂️ ✂️
document.createElement = function (tagName) {
switch (tagName) {
case "p":
// ...
}
};
그런 의미에서 브라우저 내부는 일종의 뒷 세계라고 할 수 있습니다. 내부의 많은 부분은 자바스크립트와 다른 언어로 작성되어 있으며, 프로그램에 직접 노출되지 않습니다. <p>와 같이 스타일 적용, 텍스트 레이아웃, 그리기, 합성, 페인팅 등 프리미티브와 관련된 많은 로직은 document.createElement('p') 호출 이후에 실행됩니다. 이런 관점에서 <p>는 실제로 태그입니다. 이 호출은 여전히 미래에 "실행"되어야 합니다.
하지만 너무 다른 이야기로 빠지지 말도록 하겠습니다. 브라우저의 프리미티브는 전역 이름을 가질 수 있습니다. 프리미티브는 정해져 있고, 찾아볼 수 있어야 하며, 프로젝트 간에 항상 동일하기 때문입니다. 그러나 여러분의 함수를 정의할 때는 더 명시적인 연결이 필요할 것입니다.
이제 연결하고자 하는 부분으로 돌아가 봅시다.
function greeting() {
const name = prompt("Who are you?");
return [RESUME_CODE, name];
}
// ✂️ ✂️ ✂️ ✂️ ✂️ ✂️
function resume(name) {
alert("Hello, " + name);
}
분명한 첫 단계는 resume 함수를 export로 표시하는 것입니다. 다른 파일의 코드가 이 함수를 참조해야 하니까요. 아무 이유 없이 뺄 수 있는 사항이 아니며 죽은 코드처럼 보이지 않도록 해야 합니다!
function greeting() {
const name = prompt("Who are you?");
return [RESUME_CODE, name];
}
// ✂️ ✂️ ✂️ ✂️ ✂️ ✂️
export function resume(name) {
alert("Hello, " + name);
}
이제 export 했으니, 논리적으로 다음 단계는 여기에서 함수를 import 하는 것입니다.
import { resume } from "./resume";
function greeting() {
const name = prompt("Who are you?");
return [RESUME_CODE, name];
}
// ✂️ ✂️ ✂️ ✂️ ✂️ ✂️
export function resume(name) {
alert("Hello, " + name);
}
잠시만요.
이건 여러분에게 도움이 되지 않습니다.
여러분이 얻고 싶은 것은 앞에서 말했던 RESUME_CODE 였는데 말입니다.
const RESUME_CODE = `
function resume(name) {
alert('Hello, ' + name);
}`;
하지만 resume을 import하면 다른 것을 얻게 됩니다.
function resume(name) {
alert("Hello, " + name);
}
하지만 필요했던 문자열 코드가 없어졌습니다!
간격(gap)을 주의하기
import를 사용하면 제대로 작동하지 않는 이유를 제대로 짚어 봅시다.
궁극적인 목표는 이 패턴을 모듈화하는 것입니다.
function greeting() {
const name = prompt("Who are you?");
return `function resume() {
alert('Hello, ' + ${JSON.stringify(name)});
}`;
}
그래서 greeting 함수와 resume 함수를 서로 다른 두 세계로 분리했습니다. 하지만 그 결과, 둘 사이의 문법적인 연결이 사라졌습니다.
이제 두 세계 사이의 간격을 import로 연결하려 한다고 가정해 봅시다.
import { resume } from "./resume";
function greeting() {
const name = prompt("Who are you?");
return [resume, name];
}
// ✂️ ✂️ ✂️ ✂️ ✂️ ✂️
export function resume(name) {
alert("Hello, " + name);
}
안타깝게도 import의 동작 방식을 바꾸지 않는 한, 이는 본질적으로 resume 함수 자체를 greeting의 세계로 "가져오는" 것에 불과합니다.
function resume(name) {
alert("Hello ", +name);
}
function greeting() {
const name = prompt("Who are you?");
return [resume, name];
}
// ✂️ ✂️ ✂️ ✂️ ✂️ ✂️
export function resume(name) {
alert("Hello, " + name);
}
즉, 프로그램의 전체적인 모양은 다음과 같은 형태입니다.
function greeting() {
const name = prompt("Who are you?");
return function resume() {
alert("Hello, " + name);
};
}
하지만 우리가 필요한 전체적인 모양은 다음과 같습니다.
function greeting() {
const name = prompt("Who are you?");
return `function resume() {
alert('Hello, ' + ${JSON.stringify(name)});
}`;
}
저 함수 형태를 문자열 그 자체로 가져오고 싶은 거죠!
무언가를 import할 때, 그 코드를 통째로 불러오는 식으로 가져오게 됩니다. 하지만 우리가 원하는 것은 그 코드를 실행하지 않고 단순히 참조만 하는 것입니다. greeting이 실제 호박이 아닌 호박의 이야기를 반환하길 원했던 겁니다.
예를 들어 resume 함수가 토스트를 띄우기 위해 어떤 서드파티 라이브러리를 import 한다고 상상해 보면, import의 문제점은 더 명확해집니다.
import { resume } from "./resume";
function greeting() {
const name = prompt("Who are you?");
return [resume, name];
}
// ✂️ ✂️ ✂️ ✂️ ✂️ ✂️
import { showToast } from "toast-library";
export function resume(name) {
showToast("Hello, " + name);
}
일반적인 import를 사용하면 전체 프로그램이 이와 같은 모양이 됩니다.
// From toast-library
function initializeToastLibrary() {
/_ ... _/;
}
function showToast(message) {
/_ ... _/;
}
initializeToastLibrary();
function greeting() {
const name = prompt("Who are you?");
return function resume() {
showToast("Hello, " + name);
};
}
하지만 우리가 원한 모양은 다음과 같습니다.
function greeting() {
const name = prompt("Who are you?");
return `
// From toast-library
function initializeToastLibrary() { /_ ... _/ }
function showToast(message) { /_ ... _/ }
initializeToastLibrary();
function resume() {
showToast('Hello, ' + name);
}
`;
}
세계 사이의 경계는 견고하며, 그래야만 합니다. 적어도 이미 존재하는 코드에 대해서는 각 세계가 일관되게 동작하길 원합니다.
이를 보장하기 위해, 앞 세계에서의 import는 뒷 세계의 일부가 되어야 하고, 뒷 세계에서의 import는 뒷 세계의 일부가 되어야 합니다. 각 세계는 그 자체로 독립된 프로그램처럼 동작해야 하며, 예외의 일이 일어나서는 안 됩니다.
우리는 그 일관성을 깨고 싶지 않습니다.
우리가 필요한 건 문 하나입니다.
A Door
우리가 원하는 건 이렇습니다. “나는 다른 파일에 있는 코드를 참조하고 싶지만, 실제로 그 코드가 실행되거나 로드되길 원하지는 않아. 단지 나중에 프로그래밍적으로 저 코드에 접근할 수 있는 무언가만 있으면 돼.” 다행히도 이 모든 건 전적으로 우리가 만든 개념이므로 이를 위한 가상의 문법도 만들어낼 수 있습니다.
짜잔!
import tag { resume } from './resume';
function greeting() {
const name = prompt('Who are you?');
return [resume, name];
}
// ✂️ ✂️ ✂️ ✂️ ✂️ ✂️
import { showToast } from 'toast-library';
export function resume(name) {
showToast('Hello, ' + name);
}
뭐, 그냥 이렇게 하면 될까요?
그럼요. 왜 안되겠어요?
좋아요. 그런데 이 문법은 정확히 무슨 일을 하는 거죠?
음, 일단 처음에는 이렇게 상상해보는 게 어떨까요? 이 문법이 단순히 함수의 소스 코드를 반환한다고요. 그러면 애초에 의도했던 것처럼, 그 코드를 다른 컴퓨터로 전송할 수 있을 겁니다.
import tag { resume } from './resume';
function greeting() {
const name = prompt('Who are you?');
return [resume, name];
}
const [code, data] = greeting();
// [
// 'function resume(name) { showToast("Hello, " + name); }',
// 'Dan'
// ]
하지만 사실 이건 그다지 유용하지 않습니다. showToast가 어디에도 보이지 않는다는 점에 주목해 보세요. 우리는 resume 함수의 소스 코드만 원하는 게 아니라, 다른 컴퓨터가 resume을 로드하고 실행할 수 있도록 필요한 모든 것을 원합니다.
그럼 두 번째 아이디어를 생각해 볼까요?
왜 코드에 접근할 수 있도록 고유하게 설계된 식별자를 반환하지 않으면 되지 않을까요? 예를 들어, 파일 이름과 export 이름을 결합하는 것이죠.
import tag { resume } from './resume';
function greeting() {
const name = prompt('Who are you?');
return [resume, name];
}
const [code, data] = greeting();
// [
// '/src/stuff/resume.js#resume',
// 'Dan'
// ]
이는 즉, 형식이 다른 컴퓨터가 코드를 로드하고 실행하는 방식을 어느 정도 인식해야 한다는 의미입니다. 어떤 컴퓨터가 Node.js 프로세스를 실행한다면, 해당 파일을 파일 시스템에서 import()할 수 있을 겁니다. 단, 그것이 다른 컴퓨터에 배포되어 있어야 합니다. 만약 다른 컴퓨터가 웹 브라우저를 실행한다면, HTTP를 통해 서버에서 해당 파일을 import()해야 할 겁니다.
웹 브라우저의 경우, 원격 파일을 하나씩 가져오고 브라우저의 모듈 시스템에 의존하여 종속성을 다운로드하는 것이 그리 효율적이지 않습니다. 대신, 자동화된 번들러로 코드를 청크로 만들어 결합하고, 번들러 전용 식별자를 사용하는 것이 더 합리적일 수 있습니다.
import tag { resume } from './resume';
function greeting() {
const name = prompt('Who are you?');
return [resume, name];
}
const [code, data] = greeting();
// [
// 'chunk123#module456#resume',
// 'Dan'
// ]
가장 단순하게 생각해서 만약 뒷 세계로 갈 모든 코드가 결국 하나의 거대한 파일로 합쳐져서 다른 컴퓨터로 전송된다면, 이 식별자는 참조된 함수의 전역 이름일 수 있습니다.
import tag { resume } from './resume';
function greeting() {
const name = prompt('Who are you?');
return [resume, name];
}
const [code, data] = greeting();
// [
// 'window.resume',
// 'Dan'
// ]
중요한 것은 이제 앞 세계의 코드가 뒷 세계의 코드를 참조할 수 있는 문법이 있다는 것입니다. 이는 두 환경 사이의 문이 됩니다.
그렇다면 아래와 같은 것도 가능합니다.
function greeting() {
const name = prompt("Who are you?");
return `
import { showToast } from 'toast-library';
function resume() {
showToast('Hello, ' + name);
}
`;
}
이렇게 작성하면 다음과 같은 결과를 갖게 됩니다.
import tag { resume } from './resume';
function greeting() {
const name = prompt('Who are you?');
return [resume, name];
}
// ✂️ ✂️ ✂️ ✂️ ✂️ ✂️
import { showToast } from 'toast-library';
export function resume(name) {
showToast('Hello, ' + name);
}
즉, 두 개의 프로그래밍 환경을 아우르는 하나의 프로그램을 작성할 수 있게 합니다.
대청소
우리는 두 세계 사이의 문을 찾았습니다. 앞 세계와 뒷 세계 사이의 문인 import tag는 시간과 공간 모두에 걸쳐 연산을 분리할 수 있게 해줍니다.
하지만 이 문을 사용하기 전에, 우리의 집을 정리해야 합니다. 컴포넌트를 작성하기 위해 태그 문법을 더 좋게 만들어 봅시다. (리액트에 익숙하다면, 이것이 JSX와 더 가까워지는 것임을 알 수 있을 것입니다.)
아래 예제를 살펴봅시다.
function App() {
return (
<div>
<Greeting />
<p>
The time is: <Clock />
</p>
</div>
);
}
지금까지 우리는 이 문법이 아래와 같은 객체 트리를 생성한다고 가정했습니다.
function App() {
return {
fn: "div",
args: [
{ fn: "Greeting", args: [] },
{
fn: "p",
args: ["The time is: ", { fn: "Clock", args: [] }],
},
],
};
}
이 방법도 좋지만, <p className="text-purple-500">와 같이 이름이 있는 속성을 전달할 방법이 없습니다. 우리는 규칙을 바꿔서 위치 기반 인수(args) 대신, 컴포넌트와 프리미티브 모두 이름이 있는 인수를 가진 단일 객체를 받게 할 것입니다. 이 객체를 "속성(properties)"이라는 뜻의 props라고 부르겠습니다. 중첩된 태그는 그 객체 안에 children이라는 속성으로 이동합니다.
function App() {
return {
type: "div",
props: {
children: [
{ type: "Greeting", props: {} },
{
type: "p",
props: {
className: "text-purple-500",
children: ["The time is: ", { type: "Clock", props: {} }],
},
},
],
},
};
}
저는 fn을 type으로 바꿨습니다. 이제 <p>와 같은 프리미티브가 우리의 p() 함수가 아닌 document.createElement('p') (무엇이든)에 의해 처리되므로, p를 "함수"라고 부르는 것은 오해의 소지가 있습니다.
변경 사항을 처리하기 위해 interpret를 조정해야 합니다. 이전 모습이 기억나지 않더라도 걱정하지 마세요. 중요한 부분은 다음과 같습니다.
function interpret(json, knownTags) {
if (json && json.type) {
if (knownTags[json.type]) {
let Component = knownTags[json.type];
let props = json.props;
let result = Component(props);
return interpret(result, knownTags);
} else {
let children = json.props.children?.map((arg) => interpret(arg, knownTags));
let props = { ...json.props, children };
return { type: json.type, props };
}
} else if (Array.isArray(json)) {
return json.map((item) => interpret(item, knownTags));
} else {
return json;
}
}
또한 className과 같은 속성을 적용하기 위한 새로운 로직으로 perform도 조정해 보겠습니다.
function perform(json) {
if (json && json.type) {
let tagName = json.type;
let node = document.createElement(tagName);
for (let [propKey, propValue] of Object.entries(json.props)) {
if (propKey === "children") {
let children = perform(propValue);
for (let child of [children].flat().filter(Boolean)) {
node.appendChild(child);
}
} else {
node[propKey] = propValue;
}
}
return node;
} else if (typeof json === "string") {
return document.createTextNode(json);
} else if (Array.isArray(json)) {
return json.map(perform);
} else {
return json;
}
}
이제 <p className="text-purple-500">이 작동합니다!
더 많은 대청소
지금은 또 다른 편의성 개선을 시도하기에 좋은 시점입니다.
현재는 컴포넌트 트리를 프리미티브 트리로 변환하기 위해 알려진 모든 컴포넌트를 사전 형태로 interpret 함수에 전달해야 한다는 것을 기억하세요.
function App() {
return (
<div>
<Greeting />
<p>
The time is: <Clock />
</p>
</div>
);
}
function Greeting() {
return (
<p>
Hello, <input placeholder="Who are you?" />
</p>
);
}
function Clock() {
return new Date().toString();
}
const primitives = interpret(<App />, {
App,
Greeting,
Clock,
});
그러나 이것은 꽤 어리석게 느껴집니다. <Greeting />을 작성할 때, Greeting 함수는 이미 스코프 내에 있습니다. 그렇지 않더라도, 연결을 명시적으로 하기 위해 스코프로 가져오고 싶을 것입니다. 그렇다면 Greeting 함수가 이미 스코프 내에 있다면, <Greeting /> 구문이 어떤 Greeting인지 "기억"하지 못할 이유가 없지 않을까요?
새로운 규칙을 채택하여 이 문제를 해결할 수 있습니다. 태그가 <div>처럼 소문자라면, 객체 표현에서 그 타입은 문자열 'div'로 유지됩니다. 하지만 태그가 <Greeting />처럼 대문자로 시작한다면, 그 타입은 'Greeting' 문자열이 아니라 _Greeting 함수 자체_가 됩니다.
function App() {
return {
type: "div", // 프리미티브 (문자열)
props: {
children: [
{ type: Greeting, props: {} }, // 컴포넌트 (함수)
{
type: "p", // 프리미티브 (문자열)
props: {
children: [
"The time is: ",
{ type: Clock, props: {} }, // 컴포넌트 (함수)
],
},
},
],
},
};
}
편리하게도, 우리는 이미 프리미티브와 구별하기 위해 컴포넌트 이름을 대문자로 시작하고 있었기 때문에 이름을 바꿀 필요가 없습니다.
이렇게 하면 interpret 함수를 간단하게 만들 수 있습니다. knownTags 사전을 가지고 다니는 대신, 단순히 typeof json.type을 확인합니다. json.type이 함수라면, 그 함수 자체가 컴포넌트입니다. 그렇지 않다면, 프리미티브일 것입니다.
function interpret(json) {
if (json && json.type) {
if (typeof json.type === "function") {
let Component = json.type;
let props = json.props;
let result = Component(props);
return interpret(result);
} else {
let children = json.props.children?.map(interpret);
let props = { ...json.props, children };
return { type: json.type, props };
}
} else if (Array.isArray(json)) {
return json.map(interpret);
} else {
return json;
}
}
이제 추가 정보 없이 interpret를 호출할 수 있습니다.
const primitives = interpret(<App />);
// {
// type: 'div',
// props: {
// children: [{
// type: 'p',
// props: {
// children: [
// 'Hello, ',
// { type: 'input', props: { placeholder: 'Who are you?' } }
// ]
// }
// }, {
// type: 'p',
// props: {
// children: ['The time is ', 'Wed Apr 09 2025 15:13:04 GMT+0900 (Japan Standard Time)']
// }
// }]
// }
// }
interpret 함수는 모든 컴포넌트를 바깥에서 안쪽으로 "분해(dissolve)"하여 프리미티브만 남겨둡니다. 그런 다음 perform 함수가 모든 프리미티브를 안쪽에서 바깥으로 "분해"하여 최종 결과, 즉 브라우저 DOM 트리를 생성합니다.
const tree = perform(primitives);
// [HTMLDivElement]
document.body.appendChild(tree);
보스 음악이 시작됩니다.
공간의 도전이 시작됩니다.
앞 세계와 뒷 세계의 컴포넌트
여기 전체 컴포넌트 트리가 있습니다.
export function App() {
return (
<div>
<Greeting />
<p>
The time is: <Clock />
</p>
</div>
);
}
function Greeting() {
return (
<p>
Hello, <input placeholder="Who are you?" />
</p>
);
}
function Clock() {
return new Date().toString();
}
공간을 통제하려면, 이 연산을 두 개의 서로 다른 컴퓨터 사이에 분리해야 합니다.
특히 App과 Greeting은 첫 번째 컴퓨터에서 실행되어야 하고, Clock 컴포넌트는 두 번째 컴퓨터에서 실행되어야 합니다. 이 두 연산은 잘 결합되어 두 번째 컴퓨터에서 브라우저 DOM 트리로 변환되어야 하고, 컴포넌트 함수 내부의 코드는 수정하지 말아야 합니다.
단계별로 해결해 봅시다.
먼저 해야 할 일은 Clock을 다른 파일로 옮기고 내보내는(export) 것입니다.
export function Clock() {
return new Date().toString();
}
이제 메인 파일에서 가져올 수 있습니다.
import { Clock } from "./Clock";
export function App() {
return (
<div>
<Greeting />
<p>
The time is: <Clock />
</p>
</div>
);
}
function Greeting() {
return (
<p>
Hello, <input placeholder="Who are you?" />
</p>
);
}
잠깐만요, 이것은 두 컴퓨터 간에 코드를 분리하지 못합니다. 그러기 위해서는 import를 import tag로 변경함으로써 문을 열어야 합니다. 앞 세계에서 문을 열면 즉시 뒷 세계가 존재하게 됩니다.
import tag { Clock } from './Clock';
export function App() {
return (
<div>
<Greeting />
<p>The time is: <Clock /></p>
</div>
);
}
function Greeting() {
return (
<p>
Hello, <input placeholder="Who are you?" />
</p>
);
}
export function Clock() {
return new Date().toString();
}
App 컴포넌트가 반환하는 태그를 검사해보면, <Clock /> 태그가 특이한 것으로 변했음을 알 수 있습니다.
{
type: 'div', // 프리미티브 (문자열)
props: {
children: [{
type: Greeting, // 컴포넌트 (함수)
props: {}
}, {
type: 'p',
props: {
children: [
'The time is ',
{
type: '/src/Clock.js#Clock', // 이게 뭐지?
props: {}
}
]
}
}]
}
}
우리의 최신 규칙에 따르면, 대문자로 시작하는 태그는 스코프 내의 해당 값을 타입으로 사용합니다. 예를 들어, <Greeting />은 Greeting이 함수인 { type: Greeting, props: {} }로 변환됩니다.
<Clock />도 마찬가지입니다. Clock은 대문자로 시작하므로 { type: Clock, props: {} }이 됩니다. 그러나 Clock은 일반 import가 아니라 import tag이며, 이는 일반 import와는 다른 의미를 가집니다. Clock 함수를 주는 대신, 일종의 참조, 즉 나중에 다른 컴퓨터에서 Clock 소스 코드를 로드할 수 있게 해주는 식별자를 줍니다. 그것이 바로 이 '/src/Clock.js#Clock' 문자열입니다.
여기서 용어를 몇 가지 소개하겠습니다.
- 앞 세계 컴포넌트는 앞 세계에서 실행되는 컴포넌트입니다. 이 예제에서는
App과Greeting이 앞 세계 컴포넌트입니다. - 뒷 세계 컴포넌트는 뒷 세계에서 작업을 마치기 위해 보내지는 컴포넌트입니다. 이 예제에서는
Clock만이 뒷 세계 컴포넌트입니다.
먼저 앞 세계 컴포넌트를 분해해야 합니다. 그러면 뒷 세계용 코드와 그 코드에 필요한 데이터를 얻게 됩니다. 그 코드로 뒷 세계를 구성하고, 거기서 뒷 세계 컴포넌트를 분해합니다. 그러면 프리미티브를 얻게 됩니다.
계획이 세워졌습니다.
앞 세계에서 interpret(<App />)을 실행하고 결과를 살펴봅시다. 모든 앞 세계 컴포넌트(App과 Greeting)가 출력에서 사라진 걸 확인하세요.
{
type: 'div',
props: {
children: [{
type: 'p',
props: {
children: [
'Hello, ',
{ type: 'input', props: { placeholder: 'Who are you?' } }
]
}
}, {
type: 'p',
props: {
children: [
'The time is ',
{
type: '/src/Clock.js#Clock',
props: {}
}
]
}
}]
}
}
남은 것은 프리미티브('div', 'p', 'input')와... 뒷 세계 컴포넌트(여기서는 '/src/Clock.js#Clock'뿐)입니다. 뒷 세계 컴포넌트를 위해 특별히 해야 할 일은 없었습니다. 함수가 아니므로 interpret는 실행하려고 시도하지 않고, 프리미티브와 유사하게 그대로 둡니다.
function interpret(json) {
if (json && json.type) {
if (typeof json.type === "function") {
let Component = json.type;
let props = json.props;
let result = Component(props);
return interpret(result);
} else {
let children = json.props.children?.map(interpret);
let props = { ...json.props, children };
return { type: json.type, props };
}
} else if (Array.isArray(json)) {
return json.map(interpret);
} else {
return json;
}
}
interpret의 결과에는 함수가 포함되어 있지 않으므로, 네트워크를 통해 전송될 수 있는 문자열로 쉽게 변환할 수 있습니다.
const lateComponents = intepret(<App />);
const jsonString = JSON.stringify(lateComponents);
나중에, 다른 컴퓨터에서, 이 문자열을 다시 객체로 변환할 수 있습니다. 바로 perform에 전달하여 DOM 트리를 생성하고 싶을 수도 있습니다.
const lateComponents = JSON.parse(jsonString);
const tree = perform(lateComponents);
그러나 이렇게 하면 오류가 발생합니다.
function perform(json) {
if (json && json.type) {
let tagName = json.type;
// 🔴 'document.createElement'를 'Document'에서 실행하지 못했습니다.
// 제공된 태그 이름('/src/Clock.js#Clock')이 유효한 이름이 아닙니다.
let node = document.createElement(tagName);
// ...
return node;
} else {
// ...
}
}
맞습니다. perform은 프리미티브만 처리하지만, Clock은 뒷 세계 컴포넌트입니다. 앞 세계 컴포넌트(App, Greeting)를 앞 세계에서 분해했습니다. 이제 뒷 세계에 있으므로, 뒷 세계 컴포넌트(Clock)를 분해할 시간입니다.
interpret를 호출하여 남은 컴포넌트를 분해하려고 합니다.
const lateComponents = JSON.parse(jsonString);
const primitives = interpret(lateComponents);
하지만 아무 일도 일어나지 않습니다. '/src/Clock.js#Clock' 태그는 여전히 있습니다.
공간이 비웃습니다.
아, 그렇죠. interpret는 함수만 실행하려고 시도합니다.
function interpret(json) {
if (json && json.type) {
if (typeof json.type === "function") {
let Component = json.type;
let props = json.props;
let result = Component(props);
return interpret(result);
} else {
let children = json.props.children?.map(interpret);
let props = { ...json.props, children };
return { type: json.type, props };
}
} else if (Array.isArray(json)) {
return json.map(interpret);
} else {
return json;
}
}
하지만 우리가 가진 것은 단지 참조, 즉 Clock 함수를 어디서 가져올 수 있는지 알려주는 주소일 뿐입니다. 여전히 이 컴퓨터에서 실제로 로드해야 합니다.
공간이 이것을 건네줍니다
async function loadReference(lateReference) {
// 네트워크를 통해 또는 번들러 캐시에서 로드되었다고 가정합니다.
await new Promise((resolve) => setTimeout(resolve, 3000));
if (lateReference === "/src/Clock.js#Clock") {
return Clock;
} else {
throw Error("모듈을 찾을 수 없습니다.");
}
}
좋습니다. 이 작업을 해주는 함수가 주어졌다고 가정해보겠습니다. 아마도 환경에 의해, 또는 번들러 작업을 하는 친절한 사람들에 의해 제공될 것입니다. '/src/Clock.js#Clock'을 이 함수에 전달하면, 비동기적으로 Clock을 로드합니다.
await loadReference("/src/Clock.js#Clock");
// function Clock(){}
이것이 퍼즐을 완성하는 마지막 조각이었습니다.
JSON.parse 함수가 참조처럼 보이는 것을 발견할 때마다, 그것을 loadReference()에 전달하고 각각의 Promise를 보관합니다.
const pendingPromises = [];
const lateComponents = JSON.parse(jsonString, (key, value) => {
if (typeof value?.type === "string" && value.type.includes("#")) {
// `value.type`은 참조이지만, 우리는 함수를 원합니다.
// 그 함수를 로드하기 시작합니다.
const promise = loadReference(value.type).then((fn) => {
// 함수가 로드되면, 파싱된 JSON에 직접 대체합니다.
value.type = fn;
});
// 언제 완료되는지 추적합니다.
pendingPromises.push(promise);
}
return value;
});
// 모든 참조가 로드될 때까지 기다립니다.
await Promise.all(pendingPromises);
이 조작 후, lateComponents 객체는 다음과 같이 보일 것입니다.
{
type: 'div',
props: {
children: [{
type: 'p',
props: {
children: [
'Hello, ',
{ type: 'input', props: { placeholder: 'Who are you?' } }
]
}
}, {
type: 'p',
props: {
children: [
'The time is ',
{
type: Clock, // 로드된 함수!
props: {}
}
]
}
}]
}
}
이제 뒷 세계 컴포넌트와 프리미티브만 있습니다. 모든 참조가 로드되었습니다.
이제 마침내 interpret에 전달하여 Clock을 실행할 수 있습니다. 그러면 perform을 통해 DOM으로 변환할 수 있는 프리미티브 트리가 생성됩니다.
const primitives = interpret(lateComponents);
const tree = perform(primitives);
document.body.appendChild(tree);
그리고 이것으로 완료되었습니다!
전체 그림을 다시 살펴보고 어떻게 작동하는지 요약해 봅시다.
앞 세계에서는 interpret으로 모든 앞 세계 컴포넌트를 분해합니다. 이는 뒷 세계에서 연산을 마치는 방법을 나타내는 문자열을 제공합니다.
const lateComponents = intepret(<App />);
const jsonString = JSON.stringify(lateComponents);
뒷 세계에서는 그 문자열을 파싱하고, 참조를 로드한 다음, interpret으로 뒷 세계 컴포넌트를 분해합니다. 그러면 프리미티브 트리만 남게 됩니다.
const pendingPromises = [];
const lateComponents = JSON.parse(jsonString, (key, value) => {
if (typeof value?.type === "string" && value.type.includes("#")) {
const promise = loadReference(value.type).then((fn) => {
value.type = fn;
});
pendingPromises.push(promise);
}
return value;
});
await Promise.all(pendingPromises);
const primitives = interpret(lateComponents);
마지막으로, 이 프리미티브들은 DOM이나 다른 형식으로 변환될 준비가 되어 있습니다.
const tree = perform(json);
document.body.appendChild(tree);
축하합니다!
시간과 공간 모두에 걸쳐 연산을 분리했습니다.
도넛
공간이 여러분 앞에서 접히며, 마침내 여러분을 동등한 존재로 인정합니다.
"잘 했어요."
하지만 공간은 비켜주지 않습니다. 대신, 공간은 계속해서 접히고, 자신을 이상한 모양으로 뒤틀며 앞으로, 그리고 안팎으로 뒤집히며, 중앙에 웜홀을 형성합니다.
마치 도넛처럼 생겼습니다.
모든 것을 아우르는, 아름답고도, 두려운 도넛.
"하지만 아직 끝나지 않았어요."
잠깐... 이 목소리를 전에 들어본 것 같습니다.
혹시...
"시간인가요?"
두 번째 체력 바가 나타납니다.
합성
여기 여러분의 프로그램이 있습니다.
import tag { Clock } from './Clock';
export function App() {
return (
<div>
<Greeting />
<p>
The time is: <Clock />
</p>
</div>
);
}
function Greeting() {
return (
<p>
Hello, <input placeholder="Who are you?" />
</p>
);
}
export function Clock() {
return new Date().toString();
}
시공간을 이기려면, Clock은 앞 세계의 시간을 표시하되, Clock 주변의 <p> 색상은 뒷 세계에서 결정되도록 변경해야 합니다.
첫 번째 부분은 쉽습니다.
앞 세계의 시간을 Clock에 표시하기 위해서는 그냥 다시 위로 끌어올리면 됩니다.
export function App() {
return (
<div>
<Greeting />
<p>
The time is: <Clock />
</p>
</div>
);
}
function Greeting() {
return (
<p>
Hello, <input placeholder="Who are you?" />
</p>
);
}
function Clock() {
return new Date().toString();
}
이제 <p> 색상을 지정해야 합니다. perform 함수가 이미 style 속성을 처리할 줄 안다고 가정하고 다음과 같이 색상을 지정해 봅시다.
<p
style={{
color: prompt("Pick a color:"),
}}
>
<Clock />
</p>
좋습니다만, 시공간은 prompt가 뒷 세계에만 존재한다고 말합니다. 현재 App 컴포넌트는 prompt가 존재하지 않는 앞 세계에 정의되어 있습니다.
export function App() {
return (
<div>
<Greeting />
<p
style={{
// 🔴 ReferenceError: prompt is not defined.
color: prompt("Pick a color:"),
}}
>
<Clock />
</p>
</div>
);
}
function Greeting() {
return (
<p>
Hello, <input placeholder="Who are you?" />
</p>
);
}
function Clock() {
return new Date().toString();
}
App 컴포넌트 자체를 뒷 세계로 옮기면 어떨까요? 이렇게 하면 prompt 문제는 해결되지만, Greeting과 Clock 모두 뒷 세계에서 사용할 수 없게 됩니다.
function Greeting() {
return (
<p>
Hello, <input placeholder="Who are you?" />
</p>
);
}
function Clock() {
return new Date().toString();
}
export function App() {
// 🔴 ReferenceError: Greeting is not defined
// 🔴 ReferenceError: Clock is not defined
return (
<div>
<Greeting />
<p
style={{
color: prompt("Pick a color:"),
}}
>
<Clock />
</p>
</div>
);
}
Greeting과 Clock도 아래로 옮기면 어떨까요?
export function App() {
return (
<div>
<Greeting />
<p
style={{
color: prompt("Pick a color:"),
}}
>
<Clock />
</p>
</div>
);
}
function Greeting() {
return (
<p>
Hello, <input placeholder="Who are you?" />
</p>
);
}
function Clock() {
return new Date().toString();
}
잠깐만요, 하지만 우리는 Clock이 앞 세계의 시간을 표시하기를 원했습니다. 아래로 옮길 수는 없습니다. 이것은 꽤 골치 아픈 문제가 되고 있습니다...
App은 뒷 세계에 두되, import tag를 사용해 앞 세계의 Greeting과 Clock을 참조해보면 어떨까요? 시도해 봅시다.
export function Greeting() {
return (
<p>
Hello, <input placeholder="Who are you?" />
</p>
);
}
export function Clock() {
return new Date().toString();
}
// 🔴 뒷 세계 모듈에서 앞 세계 태그를 가져올 수 없습니다.
import tag { Clock, Greeting } from './early';
export function App() {
return (
<div>
<Greeting />
<p style={{
color: prompt('Pick a color:')
}}>
<Clock />
</p>
</div>
);
}
아니오, 이것은 말이 되지 않습니다. 백틱 안의 함수가 백틱 밖의 함수를 호출할 수 없는 것과 같은 이유입니다.
function greeting() {
function showToast() {
/* ... */
}
return `function resume() {
const name = prompt('Who are you?');
// 🔴 ReferenceError: showToast is not defined
showToast('Hello, ' + name);
}`;
}
import tag 구문은 위에서 아래로만 가져올 수 있고, 아래에서 위로는 불가능합니다.
시공간 도넛이 여러분 주변을 좁혀오기 시작합니다. 생각할 시간이 많지 않습니다. 반쯤 잊혀진 꿈에서 마지막 아이디어가 떠오릅니다.
import tag 구문은 아래 세계에서만 가져올 수 있습니다. 하지만 네트워크 경계를 넘어 함수를 가져올 수 있는 import rpc 구문도 만들지 않았나요? 앞 세계가 아직 어딘가에 있다면, 아마도 여러분의 요청에 응답하여 Greeting과 Clock의 결과를 반환할 수 있지 않을까요?
export function Greeting() {
return (
<p>
Hello, <input placeholder="Who are you?" />
</p>
);
}
export function Clock() {
return new Date().toString();
}
import rpc { Clock, Greeting } from './early';
export function App() {
return (
<div>
<Greeting />
<p style={{
color: prompt('Pick a color:')
}}>
<Clock />
</p>
</div>
);
}
도넛이 흔들리며 잠시 소용돌이가 멈춥니다.
그게 해결책이었나요?
작동하는 것 같습니다.
"추가 네트워크 호출은 안됩니다. 한 번에 모든 것을 해야 합니다."
도넛이 다시 소용돌이치며 여러분을 감싸기 시작합니다. 웜홀이 점점 더 가까워집니다. 더 이상 두렵지 않고, 오히려 반기게 됩니다.
문득 생각이 떠오릅니다.
생각이라기보다는 그림이죠.
형태.
import tag { Donut } from './Donut';
export function App() {
return (
<div>
<Greeting />
<Donut>
The time is: <Clock />
</Donut>
</div>
);
}
function Greeting() {
return (
<p>
Hello, <input placeholder="Who are you?" />
</p>
);
}
function Clock() {
return new Date().toString();
}
export function Donut({ children }) {
return (
<p style={{
color: prompt('Pick a color:')
}}>
{children}
</p>
);
}
export function Donut({ children }) {
return (
<p style={{
color: prompt('Pick a color:')
}}>
{children}
</p>
);
}
과거에서 미래를 부를 수는 없지만, 과거를 미래로 감쌀 수는 있습니다. 이것이 무슨 의미인지는 모르지만, 이제 어떤 규칙도 어기지 않고 있다는 것을 알고 있습니다.
따라서, 이것은 작동해야 합니다.
눈을 감습니다.
꿈의 연속
태초에 태그가 있었고, 태그는 앞 세계에 있었으며, 태그는 <App />이었습니다.
<App />
App이란 무엇인가요? 그것은 <Greeting>이 있는 <div>이고, <Clock />이 있는 <Donut>입니다.
<div>
<Greeting />
<Donut>
The time is: <Clock />
</Donut>
</div>
<div>란 무엇인가요?
<div>
<Greeting />
<Donut>
The time is: <Clock />
</Donut>
</div>
아직 알 수 없습니다.
<Greeting />은 무엇인가요?
<div>
<Greeting />
<Donut>
The time is: <Clock />
</Donut>
</div>
그것은 <input>이 있는 <p>입니다.
<div>
<p>
Hello, <input placeholder="Who are you?" />
</p>
<Donut>
The time is: <Clock />
</Donut>
</div>
<p>란 무엇인가요?
<div>
<p>
Hello, <input placeholder="Who are you?" />
</p>
<Donut>
The time is: <Clock />
</Donut>
</div>
아직 알 수 없습니다.
<input>이란 무엇인가요?
<div>
<p>
Hello, <input placeholder="Who are you?" />
</p>
<Donut>
The time is: <Clock />
</Donut>
</div>
아직 알 수 없습니다. Donut이란 무엇인가요?
<div>
<p>
Hello, <input placeholder="Who are you?" />
</p>
<Donut>
The time is: <Clock />
</Donut>
</div>
아직 알 수 없습니다. Clock이란 무엇인가요?
<div>
<p>
Hello, <input placeholder="Who are you?" />
</p>
<Donut>
The time is: <Clock />
</Donut>
</div>
그것은 이 세계의 시간입니다, 이 세계는 앞 세계이며, 그 시간이 끝날 때가 왔습니다.
<div>
<p>
Hello, <input placeholder="Who are you?" />
</p>
<Donut>The time is: Wed Apr 09 2025 15:13:04 GMT+0900 (Japan Standard Time)</Donut>
</div>
잘 가 App, 잘 가 Greeting, 잘 가 Clock.
(모뎀 소리)
<div>란 무엇인가요?
<div>
<p>
Hello, <input placeholder="Who are you?" />
</p>
<Donut>The time is: Wed Apr 09 2025 15:13:04 GMT+0900 (Japan Standard Time)</Donut>
</div>
아직 신경쓰지 않습니다.
<p>란 무엇인가요?
<div>
<p>
Hello, <input placeholder="Who are you?" />
</p>
<Donut>The time is: Wed Apr 09 2025 15:13:04 GMT+0900 (Japan Standard Time)</Donut>
</div>
아직 신경쓰지 않습니다. <input>이란 무엇인가요?
<div>
<p>
Hello, <input placeholder="Who are you?" />
</p>
<Donut>The time is: Wed Apr 09 2025 15:13:04 GMT+0900 (Japan Standard Time)</Donut>
</div>
아직 신경쓰지 않습니다. <Donut>이란 무엇인가요?
<div>
<p>
Hello, <input placeholder="Who are you?" />
</p>
<Donut>The time is: Wed Apr 09 2025 15:13:04 GMT+0900 (Japan Standard Time)</Donut>
</div>
로드해 봅시다.
<script src="chunk123.js"></script>
아, Donut은 사용자가 선택한 색상의 <p>입니다. 선택하세요!
<div>
<p>
Hello, <input placeholder="Who are you?" />
</p>
<p style={{ color: "purple" }}>The time is: Wed Apr 09 2025 15:13:04 GMT+0900 (Japan Standard Time)</p>
</div>
선택하셨습니다.
<p>란 무엇인가요?
<div>
<p>
Hello, <input placeholder="Who are you?" />
</p>
<p style={{ color: "purple" }}>The time is: Wed Apr 09 2025 15:13:04 GMT+0900 (Japan Standard Time)</p>
</div>
아직 신경쓰지 않습니다. 그것은 우리의 일이 아닙니다. 잘 가 Donut; 이것을 C++의 한 조각에 넘겨줍시다.
<div>
<p>
Hello, <input placeholder="Who are you?" />
</p>
<p style={{ color: "purple" }}>The time is: Wed Apr 09 2025 15:13:04 GMT+0900 (Japan Standard Time)</p>
</div>
에필로그
다루지 못한 내용이 더 있지만 안타깝게도 지면이 부족해지고 있습니다. 여기 의욕 있는 독자가 이 생각의 흐름을 계속 따라간다면 발견할 수 있는 몇 가지 사항들이 있습니다.
- 포이즌 필(Poison Pills): 코드베이스가 커질수록, 특정 순간에 어떤 세계에 있는지 고민하기보다는 의존하고 있는 기능만 명시하고 싶어질 것입니다. 예를 들어, 데이터베이스에서 읽기를 수행하는데 전체 데이터베이스가 앞 세계에만 존재한다면, 뒷 세계에서 데이터베이스 모듈을 가져오려고 할 때 즉시 빌드 오류가 발생하도록(예: 데이터베이스 코드를 번들링하려고 시도하는 대신) "포이즌 필"을 넣는 방법이 필요할 것입니다. Node.js에서는 사용자 정의 조건이 이를 강제하는 편리한 방법을 제공합니다.
- 지시어(Directives):
import tag와import rpc는 이론적으로는 우아하지만, 실제 사용하기에는 그리 좋지 않습니다. 세계 간의 기술적 분리는 확고히 유지되어야 합니다. 그러나 정신적으로는 점차 어떤 세계에 있는지 상관없이 코드를 작성하는 방향으로 바뀔 것입니다. 포이즌 필을 이용해 잘못된 세계에서 코드가 실행되지 않도록 강제함으로써, 빌드 오류에 대응하여 코드를 이동하고 새로운 "문"을 만들면서 대부분 자동으로 경계를 이동할 수 있습니다. "문"을 만들고 싶을 때, 가져오는(import) 위치보다 내보내는(export) 위치 옆에 표시하는 것이 더 자연스럽다는 것을 알게 될 것입니다. 이렇게 하면 경계를 빠르게 존재하거나 존재하지 않게 "이동"할 수 있습니다. 가져온 모듈이 어떤 세계에 있는지는 구현 세부사항이 됩니다. 내보내기를 주석으로 표시하는 한 가지 방법은 지시어 구문을 (남용)하는 것입니다. 또한 앞 세계와 뒷 세계를 더 의미를 잘 드러내는 이름으로 바꾼다면(예: "앞 세계"는 "서버"가 되고 "뒷 세계"는 "클라이언트"가 됨),import tag는export옆에'use client'로 대체될 수 있고,import rpc는'use server'로 바뀔 수 있습니다. - 데이터 가져오기(Data Fetching): 앞 세계(또는 선호에 따라 서버 세계)는 낮은 지연 환경에 코드를 배포할 기회가 있기 때문에 데이터 가져오기에 완벽한 장소입니다. "생각하기" 단계가 비동기적으로 진행되도록 코드를 조정하는 것은 어렵지 않을 것입니다. 연습 삼아 이를 처리할 수 있는지 확인해 보세요.
- 스트리밍 실행(Streaming Execution): 우리의 예제에서는 계산의 모든 단계가 순차적으로 일어납니다. 이전 단계가 완전히 끝날 때까지 시작하지 않습니다. 그러나 실제로는 컴포넌트가 바깥에서 안쪽으로 실행될 수 있기 때문에, 모든 단계를 혼합하여 차단 없이 실행할 수 있습니다. 특히, 뒷 세계 컴포넌트(또는 클라이언트 컴포넌트라고 부를까요?)의 전체 JSON 트리를 기다리는 대신, 미완료된 계산 자리에 "구멍"을 남기고 나중에 그 구멍을 더 많은 JSON으로 채울 수 있는 특별한 와이어 형식을 개발할 수 있습니다.
- 상태 있는 뒷 세계(Stateful Late World): 상태 개념을 도입하면 뒷 세계 컴포넌트가 훨씬 더 유용해집니다. 이는 다시 한번 태그가 잠재적인 함수 호출이라는 점을 강조합니다. 호출될 수도 있고, 호출되지 않을 수도 있으며, 또는 여러 번 호출될 수도 있습니다. 뒷 세계 컴포넌트의 상태를 변경할 때마다, 앞 세계 컴포넌트에 영향을 주지 않고 다시 실행할 수 있습니다. 이는 상태 변경이 예측 가능하게 즉시 이루어지도록 보장합니다.
- 앞 세계와 뒷 세계의 용도 변경: 앞 세계와 뒷 세계가 반드시 "서버"와 "클라이언트"라는 기존 개념과 1:1로 매핑될 필요는 없다는 점을 명심하세요. 예를 들어, 뒷 세계 컴포넌트가 상태를 가지고 있고, 서버가 있다면, 서버에서 앞 세계와 뒷 세계를 모두 실행하는 것이 유용할 수 있습니다. 서버에서는 초기 상태로 뒷 세계를 호출하여 초기 프리미티브 트리를 생성하고, 이를 HTML과 같은 형식으로 변환할 수 있습니다. 이를 통해 뒷 세계 컴포넌트가 사용자의 기기에 로드되기 전에 매우 빠르게 콘텐츠를 표시하기 시작할 수 있습니다.
- 캐싱: 앞 세계가 요청 시에 실행될 필요는 없습니다. 실제로, 미리 실행하고 계산의 중간 결과를 저장할 수 있습니다(이는 종종 정적 사이트 생성이라고 알려져 있습니다). 욕심을 내본다면, 요청 간에 계산 부분을 재사용하기 위한 또 다른 세계—캐시 세계—를 추가할 수도 있습니다.
최종 예제를 직접 실행해 보고 싶다면 여기 있습니다.
실제 코드를 사용해 보고 싶지만 프레임워크를 사용하고 싶지 않다면, Parcel이 최근에 리액트 서버 컴포넌트 지원을 출시했으니 확인해 보세요.
읽어주셔서 감사합니다!
🚀 한국어로 된 프런트엔드 아티클을 빠르게 받아보고 싶다면 Korean FE Article을 구독해주세요!