Skip to content

Commit 0d6a385

Browse files
authored
feat(meeting): 예약 회의 + lazy 방 생성, host 승계, idle 자동 종료 (#16)
1 parent 898521c commit 0d6a385

25 files changed

Lines changed: 616 additions & 43 deletions

‎apps/backend/src/mediasoup/application/mediasoup-meeting-lifecycle.listener.spec.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,9 +32,9 @@ const makeListener = () => {
3232
describe('MediasoupMeetingLifecycleListener', () => {
3333
const code = 'abc12xyz';
3434

35-
it('meeting.created → MediasoupSignalingService.openRoom을 호출한다', async () => {
35+
it('meeting.opened → MediasoupSignalingService.openRoom을 호출한다', async () => {
3636
const { listener, calls } = makeListener();
37-
await listener.onMeetingCreated({ code });
37+
await listener.onMeetingOpened({ code });
3838
expect(calls).toEqual([{ name: 'openRoom', args: [{ meetingCode: code }] }]);
3939
});
4040

‎apps/backend/src/mediasoup/application/mediasoup-meeting-lifecycle.listener.ts‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { OnEvent } from '@nestjs/event-emitter';
44

55
import { MediasoupSignalingService } from './mediasoup-signaling.service';
66

7-
interface MeetingCreatedPayload {
7+
interface MeetingOpenedPayload {
88
code: string;
99
}
1010
interface ParticipantJoinedPayload {
@@ -26,8 +26,9 @@ interface MeetingEndedPayload {
2626
export class MediasoupMeetingLifecycleListener {
2727
constructor(private readonly service: MediasoupSignalingService) {}
2828

29-
@OnEvent(MEETING_EVENTS.CREATED)
30-
async onMeetingCreated(payload: MeetingCreatedPayload): Promise<void> {
29+
// 회의 발급(created)이 아니라 방이 열리는 시점에 만든다. 예약 회의는 첫 참가자가 들어와야 열린다.
30+
@OnEvent(MEETING_EVENTS.OPENED)
31+
async onMeetingOpened(payload: MeetingOpenedPayload): Promise<void> {
3132
await this.service.openRoom({ meetingCode: payload.code });
3233
}
3334

‎apps/backend/src/meeting/application/meeting-creation.adapter.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ export class MeetingCreationAdapter implements MeetingCreationPort {
1010
meetingType: input.meetingType,
1111
externalReference: input.externalReference,
1212
title: input.title ?? null,
13+
scheduled: input.scheduled,
1314
});
1415
return {
1516
code: meeting.code.value,
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import { IdleSweepOutcome, MeetingService } from '@/meeting/application/meeting.service';
2+
import { LoggerPort } from '@/shared-kernel/domain/ports';
3+
4+
import { MeetingIdleScheduler } from './meeting-idle.scheduler';
5+
6+
function silentLogger(): LoggerPort {
7+
const noop = (): void => undefined;
8+
return { debug: noop, info: noop, warn: noop, error: noop };
9+
}
10+
11+
const NOTHING_CLOSED: IdleSweepOutcome = { scanned: 0, closed: 0 };
12+
13+
describe('MeetingIdleScheduler.sweep', () => {
14+
it('회의 sweep 유스케이스를 호출한다', async () => {
15+
const service = {
16+
sweepIdleMeetings: jest.fn(async () => NOTHING_CLOSED),
17+
} as unknown as MeetingService;
18+
19+
await new MeetingIdleScheduler(service, silentLogger()).sweep();
20+
21+
expect(service.sweepIdleMeetings).toHaveBeenCalledTimes(1);
22+
});
23+
24+
it('이전 sweep이 진행 중이면 재진입을 건너뛴다', async () => {
25+
let calls = 0;
26+
let release!: () => void;
27+
const gate = new Promise<void>((resolve) => {
28+
release = resolve;
29+
});
30+
const service = {
31+
sweepIdleMeetings: async (): Promise<IdleSweepOutcome> => {
32+
calls += 1;
33+
await gate;
34+
return NOTHING_CLOSED;
35+
},
36+
} as unknown as MeetingService;
37+
const scheduler = new MeetingIdleScheduler(service, silentLogger());
38+
39+
const first = scheduler.sweep();
40+
await scheduler.sweep();
41+
42+
expect(calls).toBe(1);
43+
release();
44+
await first;
45+
});
46+
47+
it('sweep이 throw해도 삼키고 다음 주기를 허용한다', async () => {
48+
let calls = 0;
49+
const service = {
50+
sweepIdleMeetings: async (): Promise<IdleSweepOutcome> => {
51+
calls += 1;
52+
throw new Error('redis down');
53+
},
54+
} as unknown as MeetingService;
55+
const scheduler = new MeetingIdleScheduler(service, silentLogger());
56+
57+
await expect(scheduler.sweep()).resolves.toBeUndefined();
58+
await scheduler.sweep();
59+
60+
expect(calls).toBe(2);
61+
});
62+
});
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { Injectable } from '@nestjs/common';
2+
import { Interval } from '@nestjs/schedule';
3+
4+
import { MeetingService } from '@/meeting/application/meeting.service';
5+
import { LoggerPort } from '@/shared-kernel/domain/ports';
6+
7+
// idle 임계(1분)보다 촘촘히 돌아 종료 지연이 주기 이상 벌어지지 않게 한다.
8+
export const IDLE_SWEEP_INTERVAL_MS = 30_000;
9+
10+
@Injectable()
11+
export class MeetingIdleScheduler {
12+
private running = false;
13+
14+
constructor(
15+
private readonly service: MeetingService,
16+
private readonly logger: LoggerPort,
17+
) {}
18+
19+
@Interval(IDLE_SWEEP_INTERVAL_MS)
20+
async sweep(): Promise<void> {
21+
if (this.running) return;
22+
this.running = true;
23+
try {
24+
const { scanned, closed } = await this.service.sweepIdleMeetings();
25+
if (closed > 0) {
26+
this.logger.info({ scanned, closed }, 'idle 회의 종료');
27+
}
28+
} catch (error) {
29+
// sweep 전체 실패(예: redis 장애)가 프로세스를 죽이지 않도록 삼킨다.
30+
this.logger.error({ err: error }, 'idle sweep 실패');
31+
} finally {
32+
this.running = false;
33+
}
34+
}
35+
}

‎apps/backend/src/meeting/application/meeting.service.spec.ts‎

Lines changed: 127 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ describe('MeetingService.createMeeting', () => {
6767
saved.push(m);
6868
},
6969
findByCode: async () => null,
70+
listOpenCodes: async () => [],
7071
},
7172
chatRepository: noopChatRepository(),
7273
codeGenerator: { next: () => code },
@@ -120,17 +121,30 @@ describe('MeetingService.createMeeting', () => {
120121
expect(result.source).toBe('notion-issue');
121122
});
122123

123-
it('생성 후 meeting.created 도메인 이벤트를 발행한다', async () => {
124+
it('생성 후 meeting.created와 meeting.opened를 발행한다', async () => {
124125
const { service, events } = makeService();
125126
await service.createMeeting({ source: 'web', externalReference: externalReference() });
126127
expect(events).toEqual([
127128
{
128129
name: MEETING_EVENTS.CREATED,
129130
payload: { code: code.value, source: 'web', startedAt: fakeNow },
130131
},
132+
{ name: MEETING_EVENTS.OPENED, payload: { code: code.value } },
131133
]);
132134
});
133135

136+
it('예약 생성(scheduled)은 방을 열지 않아 meeting.opened를 발행하지 않는다', async () => {
137+
const { service, events } = makeService();
138+
const meeting = await service.createMeeting({
139+
source: 'notion-issue',
140+
externalReference: externalReference({ issueId: 'NTN-1' }),
141+
scheduled: true,
142+
});
143+
expect(meeting.status).toBe('scheduled');
144+
expect(meeting.isOpen).toBe(false);
145+
expect(events.map((e) => e.name)).toEqual([MEETING_EVENTS.CREATED]);
146+
});
147+
134148
it('HostTokenGenerator가 발급한 hostToken을 Meeting에 부여한다', async () => {
135149
const { service } = makeService();
136150
const result = await service.createMeeting({
@@ -163,6 +177,7 @@ describe('MeetingService.joinMeeting', () => {
163177
const service = new MeetingService({
164178
repository: {
165179
findByCode: async (c) => (meeting && c === meeting.code.value ? meeting : null),
180+
listOpenCodes: async () => [],
166181
save: async (m) => {
167182
saved.push(m);
168183
},
@@ -227,6 +242,42 @@ describe('MeetingService.joinMeeting', () => {
227242
},
228243
]);
229244
});
245+
246+
it('빈 방에 처음 들어온 참가자는 hostToken을 받는다', async () => {
247+
const meeting = makeMeeting(t0);
248+
const { service } = makeService(meeting);
249+
const result = await service.joinMeeting({
250+
code: 'abc12xyz',
251+
participantId: 's1',
252+
nickname: 'alice',
253+
});
254+
expect(result.hostToken).toBe('host-token-1');
255+
});
256+
257+
it('이미 참가자가 있는 방에 들어오면 hostToken을 받지 못한다', async () => {
258+
const meeting = makeMeeting(t0);
259+
meeting.addParticipant('s1', 'alice', t0);
260+
const { service } = makeService(meeting);
261+
const result = await service.joinMeeting({
262+
code: 'abc12xyz',
263+
participantId: 's2',
264+
nickname: 'bob',
265+
});
266+
expect(result.hostToken).toBeNull();
267+
});
268+
269+
it('모두 나간 방에 다시 들어오면 host를 다시 가져간다', async () => {
270+
const meeting = makeMeeting(t0);
271+
meeting.addParticipant('s1', 'alice', t0);
272+
meeting.removeParticipant('s1', t0);
273+
const { service } = makeService(meeting);
274+
const result = await service.joinMeeting({
275+
code: 'abc12xyz',
276+
participantId: 's2',
277+
nickname: 'bob',
278+
});
279+
expect(result.hostToken).toBe('host-token-1');
280+
});
230281
});
231282

232283
describe('MeetingService.leaveMeeting', () => {
@@ -246,6 +297,7 @@ describe('MeetingService.leaveMeeting', () => {
246297
const service = new MeetingService({
247298
repository: {
248299
findByCode: async (c) => (meeting && c === meeting.code.value ? meeting : null),
300+
listOpenCodes: async () => [],
249301
save: async (m) => {
250302
saved.push(m);
251303
},
@@ -318,6 +370,7 @@ describe('MeetingService.postChat', () => {
318370
const service = new MeetingService({
319371
repository: {
320372
findByCode: async (c) => (meeting && c === meeting.code.value ? meeting : null),
373+
listOpenCodes: async () => [],
321374
save: async (m) => {
322375
saved.push(m);
323376
},
@@ -401,6 +454,7 @@ describe('MeetingService.closeMeeting', () => {
401454
const service = new MeetingService({
402455
repository: {
403456
findByCode: async (c) => (meeting && c === meeting.code.value ? meeting : null),
457+
listOpenCodes: async () => [],
404458
save: async (m) => {
405459
saved.push(m);
406460
},
@@ -456,6 +510,7 @@ describe('MeetingService.closeMeeting', () => {
456510
const service = new MeetingService({
457511
repository: {
458512
findByCode: async (c) => (c === 'abc12xyz' ? meeting : null),
513+
listOpenCodes: async () => [],
459514
save: async (m) => {
460515
saved.push(m);
461516
},
@@ -519,6 +574,7 @@ describe('MeetingService.detectIdleAndClose', () => {
519574
const service = new MeetingService({
520575
repository: {
521576
findByCode: async (c) => (meeting && c === meeting.code.value ? meeting : null),
577+
listOpenCodes: async () => [],
522578
save: async (m) => {
523579
saved.push(m);
524580
},
@@ -609,3 +665,73 @@ describe('MeetingService.detectIdleAndClose', () => {
609665
expect(events).toHaveLength(0);
610666
});
611667
});
668+
669+
describe('MeetingService.sweepIdleMeetings', () => {
670+
const t0 = new Date('2026-01-01T00:00:00Z');
671+
const tJoin = new Date('2026-01-01T00:01:00Z');
672+
const tLeave = new Date('2026-01-01T00:02:00Z');
673+
const tIdleElapsed = new Date('2026-01-01T00:03:30Z');
674+
675+
/** idle=true면 참가자가 모두 나간 상태(= 만료 대상). */
676+
const meetingOf = (codeStr: string, idle: boolean) => {
677+
const m = Meeting.create({
678+
code: MeetingCode.from(codeStr),
679+
source: 'web',
680+
externalReference: externalReference(),
681+
idleTimeout: IdleTimeout.default(),
682+
startedAt: t0,
683+
hostToken: 'host-token-1',
684+
title: null,
685+
});
686+
m.addParticipant('s1', 'alice', tJoin);
687+
if (idle) m.removeParticipant('s1', tLeave);
688+
return m;
689+
};
690+
691+
const makeService = (meetings: Meeting[], brokenCode?: string) => {
692+
const byCode = new Map(meetings.map((m) => [m.code.value, m]));
693+
const { publisher, events } = makeEventPublisher();
694+
const service = new MeetingService({
695+
repository: {
696+
findByCode: async (c) => {
697+
if (c === brokenCode) throw new Error('redis down');
698+
return byCode.get(c) ?? null;
699+
},
700+
listOpenCodes: async () => [...byCode.keys(), ...(brokenCode ? [brokenCode] : [])],
701+
save: async () => {},
702+
},
703+
chatRepository: noopChatRepository(),
704+
codeGenerator: { next: () => code },
705+
hostTokenGenerator: { next: () => 'host-token-generated' },
706+
clock: { now: () => tIdleElapsed },
707+
eventPublisher: publisher,
708+
logger: noopLogger(),
709+
});
710+
return { service, events };
711+
};
712+
713+
it('열린 회의를 훑어 idle인 회의만 닫고 조회·종료 건수를 돌려준다', async () => {
714+
const idle = meetingOf('aaa11aaa', true);
715+
const busy = meetingOf('bbb22bbb', false);
716+
const { service } = makeService([idle, busy]);
717+
718+
await expect(service.sweepIdleMeetings()).resolves.toEqual({ scanned: 2, closed: 1 });
719+
expect(idle.isOpen).toBe(false);
720+
expect(busy.isOpen).toBe(true);
721+
});
722+
723+
it('한 회의 처리가 실패해도 나머지 회의를 계속 훑는다', async () => {
724+
const idle = meetingOf('aaa11aaa', true);
725+
const { service } = makeService([idle], 'ccc33ccc');
726+
727+
await expect(service.sweepIdleMeetings()).resolves.toEqual({ scanned: 2, closed: 1 });
728+
expect(idle.isOpen).toBe(false);
729+
});
730+
731+
it('열린 회의가 없으면 아무것도 닫지 않는다', async () => {
732+
const { service, events } = makeService([]);
733+
734+
await expect(service.sweepIdleMeetings()).resolves.toEqual({ scanned: 0, closed: 0 });
735+
expect(events).toHaveLength(0);
736+
});
737+
});

0 commit comments

Comments
 (0)