feat: 화상회의 단계 1 — LiveKit 기반 인프라 토대

LiveKit(SFU)에 미디어 전송을 위임하고, 앱은 입장 토큰 발급 + 회의 화면만
담당하는 구조로 단계 1(기본 연결)을 구현한다.

백엔드
- meeting 모듈: POST /api/meetings/token (JWT 인증)
  livekit-server-sdk 로 입장 JWT 발급(identity=userId, name, grants:
  roomJoin/publish/subscribe/publishData, TTL 1h). 단계 2에서 역할별 분기 예정.

프론트
- useMeeting(토큰 발급), useLiveKitRoom(연결/참여자/컨트롤 상태)
- ParticipantTile(카메라/마이크 트랙 attach, 본인 거울·음소거, 이니셜 플레이스홀더)
- MeetingPage(로비 + 영상 그리드), 라우트 /meeting/:room?, 사이드바 네비 추가
- VITE_LIVEKIT_URL 주입(build arg + dev 런타임 env)

인프라/설정
- 개발 미디어 서버는 LiveKit Cloud 사용(로컬 self-host 는 WSL2/Docker Desktop
  WebRTC 네트워킹 제약으로 제외). env 만으로 운영 전환 가능.
- livekit/livekit.yaml 은 단계 6 셀프호스팅 참고용으로 보관.
- 화상회의_화면명세.md: 현재 화면 기능/구조/데이터 계약 문서(UI 개편 참고).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-23 17:40:18 +09:00
parent 145b4611d3
commit bf2321f2d5
22 changed files with 1299 additions and 61 deletions
+2
View File
@@ -19,6 +19,7 @@ import { NotificationModule } from './modules/notification/notification.module';
import { AiModule } from './modules/ai/ai.module';
import { ProjectModule } from './modules/project/project.module';
import { ScheduleModule } from './modules/schedule/schedule.module';
import { MeetingModule } from './modules/meeting/meeting.module';
@Module({
imports: [
@@ -100,6 +101,7 @@ import { ScheduleModule } from './modules/schedule/schedule.module';
AiModule,
ProjectModule,
ScheduleModule,
MeetingModule,
],
controllers: [AppController],
providers: [
@@ -0,0 +1,16 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsString, Length, Matches } from 'class-validator';
// 화상회의 입장 토큰 발급 요청 — 참여할 방 이름을 전달한다.
export class CreateMeetingTokenDto {
@ApiProperty({
description: '참여할 회의 방 이름(영문/숫자/-/_ 만 허용)',
example: 'team-standup',
})
@IsString()
@Length(1, 64, { message: '방 이름은 1~64자여야 합니다.' })
@Matches(/^[A-Za-z0-9_-]+$/, {
message: '방 이름은 영문, 숫자, 하이픈(-), 밑줄(_)만 사용할 수 있습니다.',
})
roomName!: string;
}
@@ -0,0 +1,26 @@
import { Body, Controller, Post, UseGuards } from '@nestjs/common';
import { ApiOperation, ApiResponse, ApiTags, ApiBearerAuth } from '@nestjs/swagger';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import type { PublicUser } from '../user/user.service';
import { MeetingService, type MeetingTokenResult } from './meeting.service';
import { CreateMeetingTokenDto } from './dto/create-meeting-token.dto';
// 화상회의 컨트롤러 — 인증 사용자에게 LiveKit 입장 토큰을 발급한다.
@ApiTags('화상회의')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@Controller('meetings')
export class MeetingController {
constructor(private readonly meetingService: MeetingService) {}
@Post('token')
@ApiOperation({ summary: 'LiveKit 입장 토큰 발급' })
@ApiResponse({ status: 201, description: '토큰 발급 성공' })
issueToken(
@CurrentUser() user: PublicUser,
@Body() dto: CreateMeetingTokenDto,
): Promise<MeetingTokenResult> {
return this.meetingService.issueToken(user, dto.roomName);
}
}
@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { MeetingController } from './meeting.controller';
import { MeetingService } from './meeting.service';
// 화상회의 모듈(단계 1) — LiveKit 입장 토큰 발급.
// ConfigModule 은 전역(app.module)에서 등록되어 있어 별도 import 불필요.
@Module({
controllers: [MeetingController],
providers: [MeetingService],
exports: [MeetingService],
})
export class MeetingModule {}
@@ -0,0 +1,67 @@
import { HttpStatus, Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { AccessToken } from 'livekit-server-sdk';
import { BusinessException } from '../../common/exceptions/business.exception';
import type { PublicUser } from '../user/user.service';
// 화상회의 토큰 발급 결과 — 브라우저는 자체 VITE_LIVEKIT_URL 로 접속하므로 URL 은 반환하지 않는다.
export interface MeetingTokenResult {
token: string; // LiveKit 입장 JWT
roomName: string;
identity: string; // 참여자 식별자(사용자 ID)
}
// 화상회의 서비스(단계 1: 인프라 토대) — LiveKit 입장 토큰(JWT) 발급을 담당한다.
// 미디어 전송은 LiveKit(SFU)에 위임하고, 여기서는 인증된 사용자에게 권한이 담긴 토큰만 만든다.
@Injectable()
export class MeetingService {
private readonly logger = new Logger(MeetingService.name);
private readonly apiKey: string;
private readonly apiSecret: string;
constructor(config: ConfigService) {
this.apiKey = (config.get<string>('LIVEKIT_API_KEY') ?? '').trim();
this.apiSecret = (config.get<string>('LIVEKIT_API_SECRET') ?? '').trim();
if (!this.enabled) {
this.logger.warn(
'LIVEKIT_API_KEY/LIVEKIT_API_SECRET 미설정 — 화상회의 토큰 발급 비활성화',
);
}
}
get enabled(): boolean {
return this.apiKey.length > 0 && this.apiSecret.length > 0;
}
// LiveKit 입장 토큰 발급
// 단계 1 에서는 모든 참여자에게 발행(publish)·구독(subscribe) 권한을 부여한다.
// (관리자/일반 사용자 역할에 따른 권한 분기는 단계 2에서 구현)
async issueToken(
user: PublicUser,
roomName: string,
): Promise<MeetingTokenResult> {
if (!this.enabled) {
throw new BusinessException(
'SYS_001',
'화상회의 서비스가 아직 설정되지 않았습니다. 잠시 후 다시 시도해 주세요.',
HttpStatus.SERVICE_UNAVAILABLE,
);
}
const at = new AccessToken(this.apiKey, this.apiSecret, {
identity: user.id,
name: user.name,
ttl: '1h',
});
at.addGrant({
roomJoin: true,
room: roomName,
canPublish: true,
canSubscribe: true,
canPublishData: true,
});
const token = await at.toJwt();
return { token, roomName, identity: user.id };
}
}