SupportChat 따라 읽기¶
가이드 홈 | 이전: TicTacToe 따라 읽기 | 다음: DeliveryDispatch 따라 읽기
다른 언어로 보기 — C++ · C#/.NET · Java · Kotlin · Node/TypeScript
이 장을 읽고 나면
SupportChat 샘플을 편집기에 열고, 고객의 상담 요청이 상담원에게 배정되고 대화가 닫히기까지 메시지가 어느 서버의 어느 코드를 지나는지 따라갈 수 있다. 이 장의 코드는 언어별 예제 저장소의 SupportChat 샘플에서 가져온다.
샘플 고르기가 이 샘플을 소개한다. 이 장은 역할과 코드 위치, 주요 메시지 흐름, 실행 검증을 소스 순서대로 설명한다. 요구사항과 메시지 계약은 SupportChat 시나리오에서 참고한다.
1. 이 샘플이 보여 주는 것¶
고객과 상담원은 각각 Session 서버의 STREAM 연결 하나만 유지한다. 인증은 API가, 대화 상태는 Support의 conversation Spot이 소유한다. 상담원 한 명이 여러 대화를 동시에 처리하므로, 상담원의 연결 하나에 roster Actor와 대화별 conversation Actor가 함께 묶인다. 상담원은 방별 Actor handle로 packet을 보내고, Session은 packet의 Actor slot으로 relay 대상을 고른다.
이 장이 따라가는 흐름은 인증과 identity Actor bind → 상담 열기와 배정 → 상담원의 대화 join → Actor slot으로 relay되는 채팅 → idle timer와 종료 순서다.
2. 역할과 코드 위치¶
| 역할 | process 수 | 소유하는 것 | 코드 위치 |
|---|---|---|---|
| Session | 1 | STREAM 연결, 인증·join packet, Actor bind와 Actor slot relay | Server/Session |
| API | 1 | token 검증, conversation Spot 생성 요청 | Server/Api |
| Support | 1 | Entry Spot, conversation Spot, identity·roster·conversation Actor, 배정과 push | Server/Support/Infrastructure |
| Client | 1 | 고객 시나리오와 상담원 시나리오, self-check | Client |
참가자, MessageSeq, typing과 idle·close 전이는 Server/Support/Domain에 있고 framework type을
참조하지 않는다. 상담원의 capacity 판단은 Server/Support/Application이 맡는다. 메시지는
shared의 JSON 계약이 정한다.
3. 서버 구성¶
Session은 Support mesh의 object를 호출하고 API channel을 호출하며 STREAM 연결을 받는다.
Server/Session/supportchat-session-module.ts
const mesh = builder
.addRouteMesh(SampleNames.conversationSpotMesh)
.listen(config.sessionSpotEndpoint)
.setRoutingIdPrefix('support-session');
mesh.objects().client();
builder.addClientServerChannel(SampleNames.apiChannel).client();
return builder
.addStreamNode(SampleNames.sessionStreamNode)
.enableActorDispatch()
.bind(config.sessionStreamEndpoint)
.registerSession(SupportChatSessionFactory as never)
.build();
Support는 Entry Spot, Actor factory와 conversation Spot factory를 등록한다.
Server/Support/supportchat-support-module.ts
const mesh = builder
.addRouteMesh(SampleNames.conversationSpotMesh)
.listen(config.supportSpotEndpoint)
.setRoutingIdPrefix('support-owner');
const objectServer = mesh.objects().server();
objectServer.addEntrySpot(SupportEntrySpot);
objectServer.addSpotFactory(
SampleNames.conversationSpotType,
ConversationSpot,
(factory) => factory.disableRelocation()
);
objectServer.addActorFactory(
SampleNames.supportActorType,
SupportUserActorFactory,
(factory) => factory.disableRelocation()
);
mesh.channel(SampleNames.conversationSpotMesh).server();
builder.addClientServerChannel(SampleNames.apiChannel).client();
4. 인증과 identity Actor¶
client가 보낸 첫 packet은 인증 요청이다. session은 packet 이름으로 인증, 대화 join, 그 밖의 packet을 나눈다.
인증 뒤에는 identity Actor를 session에 묶어 이후 packet을 identity 또는 conversation Actor로 relay한다.
Server/Session/Sessions/supportchat-session.ts
const OpenConversationSessionHandler = relayHandler(PacketNames.openConversationReq);
const SetAgentAvailableSessionHandler = relayHandler(PacketNames.setAgentAvailableReq);
@Injectable()
@ZLinkPacket(PacketNames.joinConversationReq)
class JoinConversationSessionHandler {
constructor(private readonly router: SupportChatSessionRouter) {}
async handle(
context: ZLinkSessionContext,
_dispatch: ZLinkSessionDispatchContext,
payload: ZLinkMessage
): Promise<void> {
await this.router.joinConversation(context, payload);
}
}
const SendChatMessageSessionHandler = relayHandler(PacketNames.sendChatMessageReq);
const SetTypingSessionHandler = relayHandler(PacketNames.setTypingMsg);
const CloseConversationSessionHandler = relayHandler(PacketNames.closeConversationReq);
class SupportChatSession implements ZLinkSession {
constructor(readonly context: ZLinkSessionContext) {}
async onDispatch(dispatch: ZLinkSessionDispatchContext, payload: ZLinkMessage): Promise<void> {
if (!(await this.context.handlers.tryHandle(dispatch, payload))) {
throw new Error(`Unsupported SupportChat packet '${dispatch.packetName}'.`);
}
}
async onDisconnected(): Promise<void> {
// Framework cleanup notifies the exact bound-actor snapshot. Re-submitting
// actor.notifyDisconnected() here can race a later session binding.
}
}
인증이 끝나면 session은 identity Actor를 만들거나 찾아 현재 session에 묶는다. 고객이면 그 Actor가 곧 대화의 참가자이고, 상담원이면 roster Actor다.
Server/Session/Sessions/supportchat-session.ts
const actorRef = await this.getOrCreateActor(
authenticated.actorId,
new SupportUserActorCreateReq(
authenticated.actorId,
authenticated.displayName,
authenticated.role,
authenticated.actorId
)
);
await context.actors.bindOrGet(actorRef);
this.identities.set(context, {
actorId: authenticated.actorId,
displayName: authenticated.displayName,
role: authenticated.role
});
같은 id로 다시 접속하면 같은 Actor에 새 session이 묶인다. 묶기는 Session과 Actor 연결이 다룬다.
5. 상담 열기와 상담원 배정¶
고객의 open 요청은 identity Actor로 relay되어 Entry Spot의 actor request handler에 도착한다. handler는 API에 conversation 생성을 요청하고, 받은 id로 conversation Spot join을 예약한다.
Server/Support/Infrastructure/ZLink/Spots/EntrySpot/support-entry-handlers.ts
const opened = await this.channels
.requestToChannel(
SampleNames.apiChannel,
openConversationApi(identity.actorId, identity.displayName, request.subject)
)
.timeout(SampleTimings.requestTimeout)
.submit<OpenConversationApiRes>();
actor.scheduleConversationJoin(
new JoinSupportConversation(
opened.conversationId,
identity.participantId,
identity.role,
identity.displayName
)
);
API는 Spot manager로 conversation Spot을 만든다. framework가 id를 발급하고 owner를 고른다.
Server/Api/Handlers/open-conversation-handler.ts
const created = await this.spots
.create(SampleNames.conversationSpotType)
.inMesh(SampleNames.meshName)
.request(
new ConversationCreateReq(
request.customerActorId,
request.customerDisplayName,
request.subject
)
)
.submit();
고객 Actor의 join이 끝나면 conversation Spot이 capacity가 남은 상담원을 고르고, 그 roster Actor의 bound session으로 배정 notify를 보낸다. 남은 상담원이 없으면 오류가 아니라 대기 상태다.
Server/Support/Infrastructure/ZLink/Spots/ConversationSpot/conversation-spot.ts
const agentActorId = this.assignments.assignNextAgent();
if (agentActorId === undefined) return;
const roster = this.directory.get(agentActorId);
if (roster === undefined) {
throw new Error(`Assigned roster actor '${agentActorId}' was not found.`);
}
const assigned = this.assignAgent(agentActorId, roster.displayName);
await this.notifications.publish(assigned.event, [roster.actorId]);
roster Actor는 Entry Spot에 있고 conversation Spot의 member가 아니다. 그래도 bound session으로 보내는 데는 Actor의 위치가 필요 없다.
Server/Support/Infrastructure/ZLink/Spots/ConversationSpot/conversation-spot.ts
const assigned = this.assignAgent(agentActorId, roster.displayName);
await this.notifications.publish(assigned.event, [roster.actorId]);
User Spot 생성은 Spot이, join 예약은 Actor membership이 다룬다.
6. 상담원의 대화 join — 연결 하나, Actor 여럿¶
한 Actor는 동시에 한 Spot에만 속한다. roster Actor는 Entry Spot에 남아야 하므로, 상담원이 대화에 들어갈 때 session은 대화별 conversation Actor를 새로 만들어 같은 session에 추가로 묶는다.
Server/Session/Sessions/supportchat-session.ts
async joinConversation(context: ZLinkSessionContext, payload: ZLinkMessage): Promise<void> {
const identity = this.requireIdentity(context);
if (identity.role === SupportChatRoles.Agent) {
const request = payload.decode(JoinConversationReq);
const actorId = `${identity.actorId}@${request.conversationId}`;
const actorRef = await this.getOrCreateActor(
actorId,
new SupportUserActorCreateReq(
actorId,
identity.displayName,
SupportChatRoles.Agent,
identity.actorId
)
);
await context.actors.bindOrGet(actorRef);
const actor = context.actors.find(actorRef.actorId);
if (actor === undefined)
throw new Error(`Bound conversation actor '${actorRef.actorId}' was not found.`);
await actor.relay(payload);
return;
}
await this.requireIdentityActor(context).relay(payload);
}
이후 그 대화의 push는 conversation Actor의 bound session, 곧 상담원의 같은 연결로 도착한다. 묶을 수 있는 개수와 규칙은 Session 묶음의 동작 원리가 다룬다.
7. 채팅 — Actor로 고르는 relay¶
상담원은 JoinConversationRes.actorId로 방의 Actor handle을 찾아 그 handle로 채팅 packet을 보낸다.
고객은 연결에 묶인 단일 Actor로 보낸다. 인증과 JoinConversationReq(conversationId)는 Session
handler가 직접 처리하는 binding packet이다. 그 밖의 packet은 Session이 payload를 해석하지 않고
relay한다. packet의 Actor slot이 있으면 dispatch context에 묶인 해당 Actor로, 없으면 연결의
identity Actor로 보낸다.
Server/Session/Sessions/supportchat-session.ts
conversation Spot은 메시지에 MessageSeq를 부여하고, 보낸 사람을 제외한 참가자의 bound session으로
notify를 push한다.
Server/Support/Infrastructure/ZLink/Spots/ConversationSpot/conversation-spot.ts
async sendChat(
actorId: string,
text: string
): Promise<{ message: ChatMessage; state: ConversationState }> {
const actor = this.requireActor(actorId);
this.requireParticipant(actor);
const previousStatus = this.requireConversation().snapshot().status;
const result = this.requireConversation().appendMessage(actor.participantId, text);
this.reportStatusTransition(previousStatus, result.state);
await this.notifications.publish(result.event, this.otherActorRefs(actor.actorId));
return result;
}
응답 token의 수명은 STREAM의 동작 원리가, Spot 안에서 한 번에 하나씩 실행되는 범위는 실행 모델이 다룬다.
8. idle timer와 종료¶
마지막 메시지 뒤 일정 시간이 지나면 conversation Spot의 timer가 대화를 idle로 옮기고, grace 시간 안에 메시지가 없으면 닫는다.
idle timer는 대화 state를 바꾸고, 연결 끊김은 availability만 바꾸므로 두 경로는 서로 다른 상태를 다룬다.
Server/Support/Infrastructure/ZLink/Spots/ConversationSpot/Handlers/conversation-idle-timer-handler.ts
class ConversationIdleTimerHandler implements ZLinkSpotTimerHandler<ConversationSpot> {
constructor(private readonly availability: AgentAvailabilityDirectory) {}
async handle(spot: ConversationSpot, _tick: ZLinkTimerTick): Promise<void> {
const releasedAgent = await spot.onTimer();
if (releasedAgent !== undefined) this.availability.released(releasedAgent);
}
}
대화가 닫히면 배정된 상담원의 capacity가 돌아온다. 상담원의 연결이 끊기면 Entry Spot의 disconnect callback이 availability를 내리고, 대화 상태는 그대로 남아 재접속 뒤 이어진다.
Server/Support/Infrastructure/ZLink/Spots/EntrySpot/support-entry-spot.ts
async onDisconnectActor(actor: SupportUserActor): Promise<void> {
const identity = this.directory.get(actor.actorId);
if (identity?.role === 'Agent' && identity.actorId === identity.participantId) {
this.availability.setAvailable(identity.actorId, false);
}
}
availability는 상담원이 요청으로 켜고 끈다.
Server/Support/Infrastructure/ZLink/Spots/EntrySpot/support-entry-handlers.ts
async handle(
_spot: SupportEntrySpot,
actor: SupportUserActor,
_context: ZLinkMessageContext,
request: SetAgentAvailableReq
): Promise<SetAgentAvailableRes> {
const identity = requireIdentity(this.directory, actor.actorId);
if (identity.role !== SupportChatRoles.Agent || identity.actorId !== identity.participantId) {
throw new Error('Customer actor must not set agent availability.');
}
return { isAvailable: this.availability.setAvailable(actor.actorId, request.isAvailable) };
}
timer는 Timer와 worker가, 끊김 통지가 도착하는 곳은 Session 묶음의 동작 원리가 다룬다.
9. 실행과 검증¶
runner 하나가 Redis 컨테이너, 서버 process와 client 시나리오를 함께 띄운다.
client는 배정 notify, MessageSeq 순서, typing notify, idle과 close 전이, 재접속 뒤의 state를
assertion으로 확인한다. 확인 항목과 로그 문자열은
SupportChat 시나리오가
정한다.
10. 관련 문서¶
- 다른 샘플과의 비교와 선택 기준: 샘플 고르기
- 요구사항, 메시지 계약과 완료 기준: SupportChat 시나리오
- session 하나에 Actor 하나만 묶는 구성: Bingo 따라 읽기