Skip to content

Commit 7ea8f3e

Browse files
yuchem2claude
andauthored
refactor(meeting): 회의 원본을 MongoDB로 저장하고, redis는 캐시로만 유지 (#21)
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent 5dd5a26 commit 7ea8f3e

14 files changed

Lines changed: 638 additions & 12 deletions

‎CODEBASE_GUIDE.md‎

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,10 +46,18 @@ packages/shared-interfaces/src/ meeting.ts · mediasoup.ts · reports.ts · ev
4646
4. **Ctrl** `meeting.controller.createMeeting`
4747
5. **Svc** `meeting.service.createMeeting`
4848
— Meeting.create · repo.save · ⇢ `meeting.created`
49-
6. **Repo** `redis-meeting.repository.save`
49+
6. **Repo** `cached-meeting.repository.save` — Mongo(원본) + redis(캐시)
5050

5151
« 응답 `{ code, source, startedAt, hostToken }` — hostToken 보유자 = host 권한
5252

53+
**회의 저장소.** 원본은 `mongo-meeting.repository`(collection `meetings`, `_id` = code), 그 앞에
54+
`redis-meeting.repository`가 캐시로 붙는다. 조합은 `cached-meeting.repository`가 한다.
55+
56+
- `save` — `lastActiveAt`만 바뀐 저장(=채팅 heartbeat)은 캐시까지만. 상태 전이만 원본에 내려간다
57+
- `findByCode` — 캐시 miss면 원본에서 읽어 캐시를 채운다
58+
- `listOpenCodes` — 캐시 색인이 cold면 원본 질의(`status: 'open'`)로 재구축. redis가 전손돼도 재시작 복구가 동작한다
59+
- 종료된 회의의 캐시 키에는 TTL 1h. 원본에 남아 있으므로 만료돼도 조회는 된다
60+
5361
### 2.2 회의 입장 (닉네임 게이트)
5462

5563
1. **View** `JoinMeetingForm` → `useJoinMeetingViewModel` (setNickname · router.push `'/meetings/{code}'`)
@@ -63,7 +71,7 @@ packages/shared-interfaces/src/ meeting.ts · mediasoup.ts · reports.ts · ev
6371

6472
### 2.2.1 재시작 복구 (부팅 시 1회)
6573

66-
`meeting-recovery.service`(OnApplicationBootstrap) — 회의는 redis에 남지만 방·socket은 프로세스와 함께 사라진다.
74+
`meeting-recovery.service`(OnApplicationBootstrap) — 회의는 저장소에 남지만 방·socket은 프로세스와 함께 사라진다.
6775

6876
1. `repo.listOpenCodes()` → 회의마다 ⇢ `meeting.opened` (mediasoup 방 재생성)
6977
2. 남아 있는 참가자 = 유령(`participantId` = socket.id) → `meeting.service.leaveMeeting` ⇢ `meeting.participant.left`

‎README.md‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -94,14 +94,15 @@ uvicorn main:app --port 8000
9494
```
9595

9696
Redis는 로컬(6379)에 떠 있어야 한다 — `docker compose -f docker-compose.local.yml up -d redis`.
97-
진행 중인 회의 상태가 여기에만 있으므로 AOF 영속화가 걸린 이 컨테이너를 쓴다(`down -v` 금지).
97+
회의 원본은 MongoDB지만 진행 중인 회의의 채팅·오디오 버퍼는 redis에만 있으므로,
98+
AOF 영속화가 걸린 이 컨테이너를 쓴다(`down -v` 금지).
9899

99100
| 서비스 | 포트 | 비고 |
100101
|---------------|-----------------------|------------------------------------|
101102
| backend | 5000 | HTTP + WebSocket(Socket.IO) |
102103
| frontend | 3000 | Next.js dev |
103104
| ai-worker | 8000 | FastAPI(STT) |
104-
| redis | 6379 | 회의 진행 상태·오디오 버퍼 |
105+
| redis | 6379 | 회의 캐시·채팅·오디오 버퍼 (회의 원본은 MongoDB) |
105106
| mediasoup RTC | 40000–49999 (UDP/TCP) | WebRTC 미디어. `RTC_MIN/MAX_PORT`로 조정 |
106107

107108
### 테스트 · 빌드 · 린트
Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
import type Redis from 'ioredis';
2+
import RedisMock from 'ioredis-mock';
3+
4+
import { Meeting, MeetingSnapshot } from '@/meeting/domain/meeting';
5+
import { MeetingRepository } from '@/meeting/domain/ports';
6+
import { IdleTimeout, MeetingCode } from '@/meeting/domain/value-objects';
7+
import { externalReference } from '@/shared-kernel/domain/value-objects';
8+
9+
import { CachedMeetingRepository } from './cached-meeting.repository';
10+
import { RedisMeetingRepository } from './redis-meeting.repository';
11+
12+
const t0 = new Date('2026-01-01T00:00:00Z');
13+
const t30s = new Date('2026-01-01T00:00:30Z');
14+
const t1m = new Date('2026-01-01T00:01:00Z');
15+
16+
const makeMeeting = (codeStr: string): Meeting =>
17+
Meeting.create({
18+
code: MeetingCode.from(codeStr),
19+
source: 'web',
20+
externalReference: externalReference(),
21+
idleTimeout: IdleTimeout.default(),
22+
startedAt: t0,
23+
hostToken: `host-${codeStr}`,
24+
title: null,
25+
});
26+
27+
/** 호출을 세는 in-memory 원본. Mongo 어댑터 자리에 끼운다. */
28+
const makeOrigin = () => {
29+
const stored = new Map<string, MeetingSnapshot>();
30+
const calls = { findByCode: 0, save: 0, listOpenCodes: 0 };
31+
const repository: MeetingRepository = {
32+
findByCode: async (code) => {
33+
calls.findByCode += 1;
34+
const snapshot = stored.get(code);
35+
return snapshot === undefined ? null : Meeting.fromSnapshot(snapshot);
36+
},
37+
save: async (meeting) => {
38+
calls.save += 1;
39+
stored.set(meeting.code.value, meeting.snapshot());
40+
},
41+
listOpenCodes: async () => {
42+
calls.listOpenCodes += 1;
43+
return Array.from(stored.values())
44+
.filter((s) => s.status === 'open')
45+
.map((s) => s.code);
46+
},
47+
};
48+
return { repository, calls, stored };
49+
};
50+
51+
describe('CachedMeetingRepository', () => {
52+
let redis: Redis;
53+
let cache: RedisMeetingRepository;
54+
let origin: ReturnType<typeof makeOrigin>;
55+
let repo: CachedMeetingRepository;
56+
57+
beforeEach(() => {
58+
redis = new RedisMock() as unknown as Redis;
59+
cache = new RedisMeetingRepository(redis);
60+
origin = makeOrigin();
61+
repo = new CachedMeetingRepository(cache, origin.repository);
62+
});
63+
64+
afterEach(async () => {
65+
await redis.flushall();
66+
await redis.quit();
67+
});
68+
69+
describe('save', () => {
70+
it('캐시에 없던 회의는 원본에 기록한다', async () => {
71+
await repo.save(makeMeeting('abc12xyz'));
72+
73+
expect(origin.calls.save).toBe(1);
74+
});
75+
76+
it('lastActiveAt만 바뀐 저장은 원본을 건드리지 않는다', async () => {
77+
const meeting = makeMeeting('abc12xyz');
78+
await repo.save(meeting);
79+
80+
meeting.markActive(t30s);
81+
await repo.save(meeting);
82+
83+
expect(origin.calls.save).toBe(1);
84+
});
85+
86+
it('lastActiveAt만 바뀐 저장도 캐시에는 반영한다', async () => {
87+
const meeting = makeMeeting('abc12xyz');
88+
await repo.save(meeting);
89+
90+
meeting.markActive(t30s);
91+
await repo.save(meeting);
92+
93+
const cached = await cache.findByCode('abc12xyz');
94+
expect(cached!.lastActiveAt).toEqual(t30s);
95+
});
96+
97+
it('참가자가 바뀌면 원본에 기록한다', async () => {
98+
const meeting = makeMeeting('abc12xyz');
99+
await repo.save(meeting);
100+
101+
meeting.addParticipant('socket-a', '가', t30s);
102+
await repo.save(meeting);
103+
104+
expect(origin.calls.save).toBe(2);
105+
});
106+
107+
it('회의가 종료되면 원본에 기록한다', async () => {
108+
const meeting = makeMeeting('abc12xyz');
109+
await repo.save(meeting);
110+
111+
meeting.close(t1m);
112+
await repo.save(meeting);
113+
114+
expect(origin.calls.save).toBe(2);
115+
});
116+
});
117+
118+
describe('findByCode', () => {
119+
it('캐시에 있으면 원본을 읽지 않는다', async () => {
120+
await repo.save(makeMeeting('abc12xyz'));
121+
origin.calls.findByCode = 0;
122+
123+
const found = await repo.findByCode('abc12xyz');
124+
125+
expect(found!.code.value).toBe('abc12xyz');
126+
expect(origin.calls.findByCode).toBe(0);
127+
});
128+
129+
it('캐시에 없으면 원본에서 읽어 온다', async () => {
130+
await origin.repository.save(makeMeeting('abc12xyz'));
131+
132+
const found = await repo.findByCode('abc12xyz');
133+
134+
expect(found!.code.value).toBe('abc12xyz');
135+
});
136+
137+
it('원본에서 읽어 온 회의는 캐시에 채워 둔다', async () => {
138+
await origin.repository.save(makeMeeting('abc12xyz'));
139+
140+
await repo.findByCode('abc12xyz');
141+
142+
expect(await cache.findByCode('abc12xyz')).not.toBeNull();
143+
});
144+
145+
it('원본에도 없으면 null을 돌려준다', async () => {
146+
await expect(repo.findByCode('missing0')).resolves.toBeNull();
147+
});
148+
});
149+
150+
describe('listOpenCodes', () => {
151+
it('캐시 색인이 비어 있으면 원본 질의로 재구축한다', async () => {
152+
await origin.repository.save(makeMeeting('abc12xyz'));
153+
154+
await expect(repo.listOpenCodes()).resolves.toEqual(['abc12xyz']);
155+
});
156+
157+
it('재구축 이후에는 원본을 다시 질의하지 않는다', async () => {
158+
await origin.repository.save(makeMeeting('abc12xyz'));
159+
await repo.listOpenCodes();
160+
origin.calls.listOpenCodes = 0;
161+
162+
await repo.listOpenCodes();
163+
164+
expect(origin.calls.listOpenCodes).toBe(0);
165+
});
166+
167+
it('열린 회의가 없는 것과 캐시가 비어 있는 것을 구분한다', async () => {
168+
await repo.listOpenCodes();
169+
origin.calls.listOpenCodes = 0;
170+
171+
await expect(repo.listOpenCodes()).resolves.toEqual([]);
172+
expect(origin.calls.listOpenCodes).toBe(0);
173+
});
174+
});
175+
});
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import { Injectable } from '@nestjs/common';
2+
3+
import { Meeting } from '@/meeting/domain/meeting';
4+
import { MeetingRepository } from '@/meeting/domain/ports';
5+
6+
import { RedisMeetingRepository } from './redis-meeting.repository';
7+
8+
/** `lastActiveAt`을 뺀 나머지 상태. 두 스냅숏의 이 값이 같으면 실질 변화는 heartbeat뿐이다. */
9+
const fingerprint = (meeting: Meeting): string =>
10+
JSON.stringify({ ...meeting.snapshot(), lastActiveAt: null });
11+
12+
/**
13+
* 회의 저장소. 원본은 Mongo, redis는 그 앞의 캐시다.
14+
*
15+
* 쓰기는 상태 전이(생성·입장·퇴장·종료)에서만 원본까지 내려간다. 채팅은 메시지마다
16+
* `markActive` + `save`를 하는데(`meeting.service.postChat`) 이건 heartbeat일 뿐이라
17+
* 원본 쓰기를 유발하면 안 된다 — 낡은 `lastActiveAt`은 idle 판정을 캐시로 하는 한 무해하고,
18+
* 캐시가 통째로 날아간 경우엔 재시작 복구가 `markActive(now)`로 다시 잡아 준다.
19+
*/
20+
@Injectable()
21+
export class CachedMeetingRepository implements MeetingRepository {
22+
constructor(
23+
private readonly cache: RedisMeetingRepository,
24+
private readonly origin: MeetingRepository,
25+
) {}
26+
27+
async findByCode(code: string): Promise<Meeting | null> {
28+
const cached = await this.cache.findByCode(code);
29+
if (cached !== null) return cached;
30+
const found = await this.origin.findByCode(code);
31+
if (found !== null) await this.cache.save(found);
32+
return found;
33+
}
34+
35+
async save(meeting: Meeting): Promise<void> {
36+
const previous = await this.cache.findByCode(meeting.code.value);
37+
// 캐시에 직전 상태가 없으면 무엇이 바뀌었는지 판단할 수 없다 — 원본에 쓰는 쪽이 안전하다.
38+
if (previous === null || fingerprint(previous) !== fingerprint(meeting)) {
39+
await this.origin.save(meeting);
40+
}
41+
await this.cache.save(meeting);
42+
}
43+
44+
async listOpenCodes(): Promise<string[]> {
45+
if (await this.cache.isOpenIndexWarm()) {
46+
return this.cache.listOpenCodes();
47+
}
48+
const codes = await this.origin.listOpenCodes();
49+
await this.cache.primeOpenIndex(codes);
50+
return codes;
51+
}
52+
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { Schema } from 'mongoose';
2+
3+
/**
4+
* Meeting의 mongoose Schema.
5+
*
6+
* `_id`는 ObjectId가 아니라 `MeetingCode`를 그대로 저장한다 — 회의 조회는 전부 code 기준이다.
7+
* nested 구조는 `Schema.Types.Mixed`로 둬서 domain 검증과 schema 검증이 중복되지 않게 한다.
8+
* `status`는 `listOpenCodes`의 질의 키라 인덱스를 둔다.
9+
*/
10+
11+
export const MEETING_COLLECTION = 'meetings';
12+
13+
export const meetingSchema = new Schema(
14+
{
15+
_id: { type: String, required: true },
16+
source: { type: String, required: true },
17+
meetingType: { type: String, required: true },
18+
externalReference: { type: Schema.Types.Mixed, default: {} },
19+
idleTimeoutMs: { type: Number, required: true },
20+
status: { type: String, required: true, index: true },
21+
startedAt: { type: Date, required: true },
22+
endedAt: { type: Date, default: null },
23+
lastActiveAt: { type: Date, required: true },
24+
participants: { type: [Schema.Types.Mixed], default: [] },
25+
hostToken: { type: String, default: '' },
26+
title: { type: String, default: null },
27+
},
28+
{
29+
collection: MEETING_COLLECTION,
30+
minimize: false,
31+
versionKey: false,
32+
},
33+
);

0 commit comments

Comments
 (0)