Node.js Quickstart — 설치부터 첫 요청까지¶
이 장을 읽고 나면
패키지를 설치하고, 두 process가 서로 호출하는 최소 project를 실행할 수 있다.
저장소의 framework/languages/node/quickstart/
프로젝트다. 아래 코드 블록은 사이트를 빌드할 때 그 파일에서 읽는다. location store 없이
process 둘이 서로의 endpoint를 직접 지정해 request/reply 한 번을 주고받는다.
0. 예제 저장소 clone¶
이 장의 project는 zlink-node-examples 저장소의 quickstart/이며, 기능 가이드가 읽는
program인 tutorial/과 samples/도 같은 저장소에 있다.
git clone https://github.com/zlink-systems/zlink-node-examples.git
cd zlink-node-examples/quickstart
main은 최신 릴리스에 그 뒤의 수정을 더한 것이고, 패키지 버전은 그 릴리스에 맞춰져 있다.
이전 릴리스는 tag vA.B.C로 받는다(git checkout vA.B.C). 이슈와 PR은 zlink-systems/zlink로
보낸다.
1. 설치¶
- Node.js 22 이상 (
@zlink-systems/zlink의engines) - npm 접근
npm에서 받는다. 서버 하나를 만들 때 필요한 최소 조합은 다음과 같다.
@zlink-systems/zlink(binding)는 적지 않는다. @zlink-systems/framework가 의존 버전을
선언한다.
{
"name": "zlink-quickstart",
"private": true,
"version": "0.0.0",
"scripts": {
"build": "tsc -p tsconfig.json",
"server": "node -e \"require('node:fs').existsSync('dist') || (console.error('run npm run build first'), process.exit(1))\" && node dist/Server/main.js",
"client": "node -e \"require('node:fs').existsSync('dist') || (console.error('run npm run build first'), process.exit(1))\" && node dist/Client/main.js"
},
"dependencies": {
"@zlink-systems/framework": "0.30.0",
"@zlink-systems/nestjs": "0.30.0",
"@nestjs/common": "10.4.22",
"@nestjs/core": "10.4.22",
"reflect-metadata": "0.2.2"
},
"devDependencies": {
"typescript": "5.8.3",
"@types/node": "22.15.30"
}
}
필요할 때 추가하는 package는 다음과 같다.
| package | 언제 추가하나 |
|---|---|
@zlink-systems/framework-locations-redis |
Redis location store로 자동 연결을 사용할 때(Location) |
@zlink-systems/framework-codec-protobuf · -codec-msgpack |
기본 JSON codec 대신 사용할 때(Handler와 메시지 처리) |
@zlink-systems/stream-connector |
외부 client(게임 client·모바일)를 만들 때(STREAM) |
@zlink-systems/http-client |
서버에서 HTTP를 호출할 때(HTTP Client 가이드) |
라이선스는 계층마다 다르다 — core·binding은 MPL-2.0, framework는 FSL-1.1-ALv2,
@zlink-systems/http-client는 Apache-2.0이다. 서비스를 만들어 파는 데 드는 비용은 없다.
2. 공유 계약¶
요청 타입은 class여야 한다. 패킷 이름을 payload.constructor.name에서 얻으므로 객체
리터럴은 사용할 수 없다. 응답 타입은 이름으로 조회하지 않아 interface로 둔다.
// Request/reply contract shared by the server and client processes.
//
// `Hello` is a class because the framework derives the wire packet name from
// `payload.constructor.name`; an object literal's constructor is `Object`,
// which it refuses. `Greeting` is decoded by shape, so it stays an interface.
export class Hello {
constructor(readonly name: string) {}
}
export interface Greeting {
readonly text: string;
}
// The packet name the request is registered and dispatched under.
export const PacketNames = {
hello: 'Hello'
} as const;
3. 처리하는 쪽¶
handler는 두 곳에 등록한다 — Nest의 providers와 channel(...).server()의
addRequestHandler. 이 예제는 127.0.0.1에 bind하므로 같은 host의 process만 접속하며, Windows에서
방화벽 허용 창이 뜨지 않는다. 다른 host에서 접속해야 하면 접속 가능한 주소에 bind하고
setAdvertiseHost로 그 주소를 광고한다.
import 'reflect-metadata';
import { Injectable, Module } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import { ZLinkModule, zlinkFramework } from '@zlink-systems/nestjs';
import type { ZLinkMessageContext, ZLinkRequestHandler } from '@zlink-systems/framework';
import { Greeting, Hello, PacketNames } from '../Shared/contracts';
// Handles one request on the "greeting" channel.
@Injectable()
class HelloHandler implements ZLinkRequestHandler<Hello, Greeting> {
async handle(request: Hello, context: ZLinkMessageContext): Promise<Greeting> {
void context;
return { text: `hello, ${request.name}` };
}
}
@Module({
imports: [
ZLinkModule.forRootFactory({
useFactory: () => {
const builder = zlinkFramework();
// Names the mesh and opens this process's endpoint for peers to connect to.
// Advertise the loopback address used by this local example.
const mesh = builder
.addRouteMesh('services')
.listen('tcp://127.0.0.1:7101')
.setAdvertiseHost('127.0.0.1');
// This process handles the "greeting" channel. IZLinkRequestHandler classes
// register on the channel builder explicitly -- see README "differences".
mesh.channel('greeting').server().addRequestHandler(PacketNames.hello, HelloHandler);
return builder.build();
}
})
],
providers: [HelloHandler]
})
class ServerModule {}
async function main(): Promise<void> {
await NestFactory.createApplicationContext(ServerModule, { logger: ['error', 'warn', 'log'] });
console.log('server listening on tcp://127.0.0.1:7101 (channel "greeting")');
}
main().catch((error: unknown) => {
console.error(error);
process.exit(1);
});
4. 호출하는 쪽¶
import 'reflect-metadata';
import * as http from 'node:http';
import { Module } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import { ZLINK_ROUTE_CLIENT, ZLinkModule, zlinkFramework } from '@zlink-systems/nestjs';
import type { ZLinkRouteClient } from '@zlink-systems/framework';
import { Greeting, Hello } from '../Shared/contracts';
@Module({
imports: [
ZLinkModule.forRootFactory({
useFactory: () => {
const builder = zlinkFramework();
// This process also opens and advertises its own loopback endpoint.
const mesh = builder
.addRouteMesh('services')
.listen('tcp://127.0.0.1:7102')
.setAdvertiseHost('127.0.0.1');
// This side only calls; it does not handle "greeting".
mesh.channel('greeting').client();
// Manual connection -- the server's endpoint is given directly.
mesh.peerConnections().connect('tcp://127.0.0.1:7101');
return builder.build();
}
})
]
})
class ClientModule {}
// This process's own HTTP surface -- a plain node:http server, not a NestJS
// HTTP module. See README "differences" for why.
function startHttpServer(route: ZLinkRouteClient): http.Server {
const server = http.createServer(async (request, response) => {
const url = new URL(request.url ?? '/', 'http://127.0.0.1');
const match = request.method === 'GET' ? /^\/hello\/([^/]+)$/.exec(url.pathname) : null;
if (match === null) {
response.writeHead(404).end();
return;
}
const name = decodeURIComponent(match[1]);
try {
const reply = await route.requestToChannel('greeting', new Hello(name)).submit<Greeting>();
response.writeHead(200, { 'content-type': 'application/json' });
response.end(JSON.stringify(reply.text));
} catch (error: unknown) {
response.writeHead(500, { 'content-type': 'application/json' });
response.end(
JSON.stringify({ error: error instanceof Error ? error.message : String(error) })
);
}
});
server.listen(5080, '127.0.0.1');
return server;
}
async function main(): Promise<void> {
const app = await NestFactory.createApplicationContext(ClientModule, {
logger: ['error', 'warn', 'log']
});
const route = app.get<ZLinkRouteClient>(ZLINK_ROUTE_CLIENT, { strict: false });
startHttpServer(route);
console.log('client listening on http://127.0.0.1:5080');
}
main().catch((error: unknown) => {
console.error(error);
process.exit(1);
});
5. 실행¶
cd zlink-node-examples/quickstart
npm install
npm run build
# 터미널 두 개. server를 먼저 실행한다.
npm run server
npm run client
curl http://127.0.0.1:5080/hello/world
응답은 "hello, world", 상태 코드 200이다.
6. 첫 실행이 안 될 때 확인할 항목¶
| 증상 | 확인할 항목 |
|---|---|
| package를 찾지 못한다 | §1의 package 이름을 그대로 적었는지 확인한다. binding 버전은 따로 고정하지 않는다 |
| startup이 실패한다 | 두 process의 mesh 이름이 같은지, listen endpoint가 다른 process와 겹치지 않는지 확인한다 |
| 패킷 이름이 맞지 않는다 | 요청 타입을 class로 선언했는지 확인한다. 객체 리터럴은 이름을 얻지 못한다 |
| 호출이 대상 없음으로 끝난다 | 받는 쪽이 그 channel 이름을 server 역할로 등록했는지, handler를 providers에도 등록했는지 확인한다 |
| 응답이 오지 않는다 | 보낸 쪽이 request를 썼는지 확인한다. send는 응답을 받지 않는다 |
7. 옮겨 갈 것¶
| 파일 | 내용 |
|---|---|
package.json |
@zlink-systems/framework·@zlink-systems/nestjs 두 항목. binding은 전이 의존으로 둔다 |
Shared/contracts.ts |
요청은 class, 응답은 interface |
Server/main.ts |
addRouteMesh → listen → setAdvertiseHost → channel(...).server().addRequestHandler(...), handler를 providers에 등록 |
Client/main.ts |
channel(...).client(), peerConnections().connect(...), requestToChannel(...).submit<T>() |
8. 다음으로 읽을 것¶
이 두 process는 endpoint를 서로 직접 적어 연결한다. 서버를 늘리거나 다른 주소로 다시 시작해도 호출 코드를 그대로 두려면 자동 연결이 필요하고, 그것은 Location이 다룬다.
- 개념을 먼저 확인할 때 — 핵심 개념
- 이름으로 호출하는 경로 — Channel 메시징
- id로 호출하는 상태 객체 — Spot · Actor
- 완결된 업무 흐름을 볼 때 — 샘플 고르기