diff --git a/.env.development.example b/.env.development.example index 4dd6da4..e6bd713 100644 --- a/.env.development.example +++ b/.env.development.example @@ -52,3 +52,19 @@ CORS_ORIGIN=http://localhost:5273 # AI_TIMEOUT_MS: AI 요청 타임아웃(ms). 백엔드 Claude 호출 abort + 프론트 axios 에 동일 적용. # 프론트에는 build arg VITE_AI_TIMEOUT_MS 로 주입된다. AI_TIMEOUT_MS=60000 + +# ========================================== +# 화상회의 (LiveKit Cloud) +# ========================================== +# 개발은 LiveKit Cloud(무료 dev)를 사용한다. https://cloud.livekit.io 에서 프로젝트 생성 후 +# Settings → Keys 에서 발급된 값을 채운다. 코드 변경 없이 env 만으로 동작한다. +# LIVEKIT_API_KEY / LIVEKIT_API_SECRET : 백엔드 입장 토큰(JWT) 서명용 (절대 커밋 금지) +# LIVEKIT_WS_URL : 브라우저 → LiveKit 접속 URL. Cloud 는 wss://.livekit.cloud +# (build arg/런타임 env 로 VITE_LIVEKIT_URL 에 주입) +# LIVEKIT_URL : 백엔드 → LiveKit 서버 API(RoomService 등) 주소. 보통 LIVEKIT_WS_URL 과 동일. +# (단계 1 토큰 발급은 오프라인이라 아직 미사용) +# 운영: .env.production 에 운영용 키/URL 만 넣으면 그대로 전환된다(셀프호스팅 시 자체 URL/키 사용). +LIVEKIT_API_KEY=APIxxxxxxxxxxxx +LIVEKIT_API_SECRET=your-livekit-cloud-api-secret +LIVEKIT_WS_URL=wss://your-project.livekit.cloud +LIVEKIT_URL=wss://your-project.livekit.cloud diff --git a/backend/package-lock.json b/backend/package-lock.json index d834814..9dc7bb8 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -31,6 +31,7 @@ "express-rate-limit": "^8.3.2", "helmet": "^8.1.0", "ioredis": "^5.11.1", + "livekit-server-sdk": "^2.15.5", "mammoth": "^1.12.0", "nest-winston": "^1.10.0", "passport": "^0.7.0", @@ -798,6 +799,12 @@ "url": "https://github.com/sponsors/Borewit" } }, + "node_modules/@bufbuild/protobuf": { + "version": "1.10.1", + "resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-1.10.1.tgz", + "integrity": "sha512-wJ8ReQbHxsAfXhrf9ixl0aYbZorRuOWpBNzm8pL8ftmSxQx/wnJD5Eg861NwJU/czy2VXFIebCeZnZrI9rktIQ==", + "license": "(Apache-2.0 AND BSD-3-Clause)" + }, "node_modules/@colors/colors": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.5.0.tgz", @@ -2176,6 +2183,15 @@ "integrity": "sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==", "license": "MIT" }, + "node_modules/@livekit/protocol": { + "version": "1.48.0", + "resolved": "https://registry.npmjs.org/@livekit/protocol/-/protocol-1.48.0.tgz", + "integrity": "sha512-fYHYgltH6YavAsokl3qsHLkBdQeKCl4UORVTub5crS3t8JtKFZ0uinHDFQ+XXdNKS6Ub9gcOjV+UHcDiqnWXoQ==", + "license": "Apache-2.0", + "dependencies": { + "@bufbuild/protobuf": "^1.10.0" + } + }, "node_modules/@lukeed/csprng": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@lukeed/csprng/-/csprng-1.1.0.tgz", @@ -6282,6 +6298,60 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/camelcase-keys": { + "version": "9.1.3", + "resolved": "https://registry.npmjs.org/camelcase-keys/-/camelcase-keys-9.1.3.tgz", + "integrity": "sha512-Rircqi9ch8AnZscQcsA1C47NFdaO3wukpmIRzYcDOrmvgt78hM/sj5pZhZNec2NM12uk5vTwRHZ4anGcrC4ZTg==", + "license": "MIT", + "dependencies": { + "camelcase": "^8.0.0", + "map-obj": "5.0.0", + "quick-lru": "^6.1.1", + "type-fest": "^4.3.2" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/camelcase-keys/node_modules/camelcase": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-8.0.0.tgz", + "integrity": "sha512-8WB3Jcas3swSvjIeA2yvCJ+Miyz5l1ZmB6HFb9R1317dt9LCQoswg/BGrmAmkWVEszSrrg4RwmO46qIm2OEnSA==", + "license": "MIT", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/camelcase-keys/node_modules/quick-lru": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-6.1.2.tgz", + "integrity": "sha512-AAFUA5O1d83pIHEhJwWCq/RQcRukCkn/NSm2QsTEMle5f2hP0ChI2+3Xb051PZCkLryI/Ir1MVKviT2FIloaTQ==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/camelcase-keys/node_modules/type-fest": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/caniuse-lite": { "version": "1.0.30001792", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001792.tgz", @@ -9809,6 +9879,15 @@ "url": "https://github.com/chalk/supports-color?sponsor=1" } }, + "node_modules/jose": { + "version": "5.10.0", + "resolved": "https://registry.npmjs.org/jose/-/jose-5.10.0.tgz", + "integrity": "sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -10064,6 +10143,21 @@ "dev": true, "license": "MIT" }, + "node_modules/livekit-server-sdk": { + "version": "2.15.5", + "resolved": "https://registry.npmjs.org/livekit-server-sdk/-/livekit-server-sdk-2.15.5.tgz", + "integrity": "sha512-Uzs4tVebvY4/+pO9pG1nMMVRDZPnOJolZIOWIiSEnUXFBywq+eNWHrR8mplKL8Qn13mSTQLjKVXfGCGTAMQeYg==", + "license": "Apache-2.0", + "dependencies": { + "@bufbuild/protobuf": "^1.10.1", + "@livekit/protocol": "^1.46.6", + "camelcase-keys": "^9.0.0", + "jose": "^5.1.2" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/load-esm": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/load-esm/-/load-esm-1.0.3.tgz", @@ -10381,6 +10475,18 @@ "sprintf-js": "~1.0.2" } }, + "node_modules/map-obj": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/map-obj/-/map-obj-5.0.0.tgz", + "integrity": "sha512-2L3MIgJynYrZ3TYMriLDLWocz15okFakV6J12HXvMXDHui2x/zgChzg1u9mFFGbbGWE+GsLpQByt4POb9Or+uA==", + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", diff --git a/backend/package.json b/backend/package.json index 1d1decc..1ea1eef 100644 --- a/backend/package.json +++ b/backend/package.json @@ -47,6 +47,7 @@ "express-rate-limit": "^8.3.2", "helmet": "^8.1.0", "ioredis": "^5.11.1", + "livekit-server-sdk": "^2.15.5", "mammoth": "^1.12.0", "nest-winston": "^1.10.0", "passport": "^0.7.0", diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index ccaa2c3..4ff0f4d 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -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: [ diff --git a/backend/src/modules/meeting/dto/create-meeting-token.dto.ts b/backend/src/modules/meeting/dto/create-meeting-token.dto.ts new file mode 100644 index 0000000..d6269b8 --- /dev/null +++ b/backend/src/modules/meeting/dto/create-meeting-token.dto.ts @@ -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; +} diff --git a/backend/src/modules/meeting/meeting.controller.ts b/backend/src/modules/meeting/meeting.controller.ts new file mode 100644 index 0000000..39fa87a --- /dev/null +++ b/backend/src/modules/meeting/meeting.controller.ts @@ -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 { + return this.meetingService.issueToken(user, dto.roomName); + } +} diff --git a/backend/src/modules/meeting/meeting.module.ts b/backend/src/modules/meeting/meeting.module.ts new file mode 100644 index 0000000..468d651 --- /dev/null +++ b/backend/src/modules/meeting/meeting.module.ts @@ -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 {} diff --git a/backend/src/modules/meeting/meeting.service.ts b/backend/src/modules/meeting/meeting.service.ts new file mode 100644 index 0000000..d516366 --- /dev/null +++ b/backend/src/modules/meeting/meeting.service.ts @@ -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('LIVEKIT_API_KEY') ?? '').trim(); + this.apiSecret = (config.get('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 { + 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 }; + } +} diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 0b6743b..6e3273b 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -10,6 +10,9 @@ services: environment: - CHOKIDAR_USEPOLLING=true - WATCHPACK_POLLING=true + # 화상회의 — 브라우저가 접속할 LiveKit URL(개발은 LiveKit Cloud 사용). + # 런타임 env 로 주입해 이미지 재빌드 없이 .env 변경 + 컨테이너 재생성만으로 반영된다. + - VITE_LIVEKIT_URL=${LIVEKIT_WS_URL} volumes: - ./${FRONTEND_DIR}:/app - /app/node_modules @@ -36,6 +39,10 @@ services: networks: !override - app-network + # 화상회의는 개발에서 LiveKit Cloud 를 사용한다(로컬 SFU 컨테이너 없음). + # WSL2/Docker Desktop 의 WebRTC 네트워킹 제약을 우회하고, 운영 전환도 env 만 바꾸면 된다. + # 셀프호스팅(문서 단계 6)으로 갈 때 livekit/livekit.yaml 을 참고해 서비스를 다시 추가한다. + db: # dev에서는 Docker Desktop 시작 시 자동 실행 방지 (base 의 always 를 덮어씀) restart: "no" diff --git a/docker-compose.yml b/docker-compose.yml index a341c2c..1235560 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -11,6 +11,8 @@ services: - BUILD_MODE=${BUILD_MODE} - VITE_API_BASE_URL=${BACKEND_API_URL} - VITE_AI_TIMEOUT_MS=${AI_TIMEOUT_MS} + # 브라우저가 접속할 LiveKit WebSocket URL + - VITE_LIVEKIT_URL=${LIVEKIT_WS_URL} expose: - "80" environment: @@ -62,6 +64,10 @@ services: - REDIS_PASSWORD=${REDIS_PASSWORD} # AI 요청 타임아웃(ms) — 프론트(VITE_AI_TIMEOUT_MS)와 동일 값을 루트 .env 에서 공급 - AI_TIMEOUT_MS=${AI_TIMEOUT_MS} + # 화상회의(LiveKit) — 토큰 발급에 키/시크릿, 서버 API 호출에 내부 URL 사용 + - LIVEKIT_API_KEY=${LIVEKIT_API_KEY} + - LIVEKIT_API_SECRET=${LIVEKIT_API_SECRET} + - LIVEKIT_URL=${LIVEKIT_URL} restart: unless-stopped networks: - app-network diff --git a/frontend/Dockerfile b/frontend/Dockerfile index e25b4b2..ac54c99 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -14,6 +14,8 @@ ARG VITE_API_BASE_URL ENV VITE_API_BASE_URL=$VITE_API_BASE_URL ARG VITE_AI_TIMEOUT_MS ENV VITE_AI_TIMEOUT_MS=$VITE_AI_TIMEOUT_MS +ARG VITE_LIVEKIT_URL +ENV VITE_LIVEKIT_URL=$VITE_LIVEKIT_URL RUN npm run build-only -- --mode ${BUILD_MODE} diff --git a/frontend/env.d.ts b/frontend/env.d.ts index 4a6886d..f01db51 100644 --- a/frontend/env.d.ts +++ b/frontend/env.d.ts @@ -5,6 +5,8 @@ interface ImportMetaEnv { readonly VITE_API_BASE_URL: string /** AI 요청 타임아웃(ms) — 백엔드 AI_TIMEOUT_MS 와 동일 값 */ readonly VITE_AI_TIMEOUT_MS: string + /** 브라우저가 접속할 LiveKit WebSocket URL (예: ws://localhost:7880) */ + readonly VITE_LIVEKIT_URL: string } interface ImportMeta { diff --git a/frontend/package-lock.json b/frontend/package-lock.json index ddad179..58adf71 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -9,6 +9,7 @@ "version": "0.0.0", "dependencies": { "axios": "^1.14.0", + "livekit-client": "^2.19.2", "pinia": "^3.0.4", "vue": "^3.5.31", "vue-router": "^4.4.5" @@ -556,6 +557,12 @@ "specificity": "bin/cli.js" } }, + "node_modules/@bufbuild/protobuf": { + "version": "1.10.1", + "resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-1.10.1.tgz", + "integrity": "sha512-wJ8ReQbHxsAfXhrf9ixl0aYbZorRuOWpBNzm8pL8ftmSxQx/wnJD5Eg861NwJU/czy2VXFIebCeZnZrI9rktIQ==", + "license": "(Apache-2.0 AND BSD-3-Clause)" + }, "node_modules/@csstools/color-helpers": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz", @@ -720,12 +727,13 @@ } }, "node_modules/@emnapi/runtime": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", - "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", "dev": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "tslib": "^2.4.0" } @@ -1074,6 +1082,21 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@livekit/mutex": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@livekit/mutex/-/mutex-1.1.1.tgz", + "integrity": "sha512-EsshAucklmpuUAfkABPxJNhzj9v2sG7JuzFDL4ML1oJQSV14sqrpTYnsaOudMAw9yOaW53NU3QQTlUQoRs4czw==", + "license": "Apache-2.0" + }, + "node_modules/@livekit/protocol": { + "version": "1.45.8", + "resolved": "https://registry.npmjs.org/@livekit/protocol/-/protocol-1.45.8.tgz", + "integrity": "sha512-Q+l57E7w/xxOBFVWzdX5rkAZO7ffyF+rlDzNUYq2SU114+5aTyCq+PK4unaEVDNd4952Af7wteKr3sOgasGuaA==", + "license": "Apache-2.0", + "dependencies": { + "@bufbuild/protobuf": "^1.10.0" + } + }, "node_modules/@napi-rs/wasm-runtime": { "version": "1.1.5", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.5.tgz", @@ -1275,9 +1298,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1295,9 +1315,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1315,9 +1332,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1335,9 +1349,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1355,9 +1366,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1375,9 +1383,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1395,9 +1400,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1415,9 +1417,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1606,9 +1605,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1626,9 +1622,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1646,9 +1639,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1666,9 +1656,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1686,9 +1673,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1706,9 +1690,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1754,6 +1735,17 @@ "node": "^20.19.0 || >=22.12.0" } }, + "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@rolldown/binding-win32-arm64-msvc": { "version": "1.0.0-rc.17", "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.17.tgz", @@ -1838,6 +1830,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/dom-mediacapture-record": { + "version": "1.0.22", + "resolved": "https://registry.npmjs.org/@types/dom-mediacapture-record/-/dom-mediacapture-record-1.0.22.tgz", + "integrity": "sha512-mUMZLK3NvwRLcAAT9qmcK+9p7tpU2FHdDsntR3YI4+GY88XrgG4XiE7u1Q2LAN2/FZOz/tdMDC3GQCR4T8nFuw==", + "license": "MIT", + "peer": true + }, "node_modules/@types/estree": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", @@ -3747,6 +3746,15 @@ "node": ">=0.10.0" } }, + "node_modules/events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "license": "MIT", + "engines": { + "node": ">=0.8.x" + } + }, "node_modules/expect-type": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", @@ -4347,6 +4355,15 @@ "@pkgjs/parseargs": "^0.11.0" } }, + "node_modules/jose": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz", + "integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/js-beautify": { "version": "1.15.4", "resolved": "https://registry.npmjs.org/js-beautify/-/js-beautify-1.15.4.tgz", @@ -4717,9 +4734,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -4741,9 +4755,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -4765,9 +4776,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -4789,9 +4797,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -4847,6 +4852,26 @@ "url": "https://opencollective.com/parcel" } }, + "node_modules/livekit-client": { + "version": "2.19.2", + "resolved": "https://registry.npmjs.org/livekit-client/-/livekit-client-2.19.2.tgz", + "integrity": "sha512-Kvk07QYDWRAbmYNLRll04ZIuxMQobW/oLPYnmR1kCy8GGHpU0gqyHf704Rz+29zfy8IJZRjKqeVbzGSKn9sumw==", + "license": "Apache-2.0", + "dependencies": { + "@livekit/mutex": "1.1.1", + "@livekit/protocol": "1.45.8", + "events": "^3.3.0", + "jose": "^6.1.0", + "loglevel": "^1.9.2", + "sdp-transform": "^2.15.0", + "tslib": "2.8.1", + "typed-emitter": "^2.1.0", + "webrtc-adapter": "9.0.5" + }, + "peerDependencies": { + "@types/dom-mediacapture-record": "^1" + } + }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -4877,6 +4902,19 @@ "dev": true, "license": "MIT" }, + "node_modules/loglevel": { + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/loglevel/-/loglevel-1.9.2.tgz", + "integrity": "sha512-HgMmCqIJSAKqo68l0rS2AanEWfkxaZ5wNiEFb5ggm08lDs9Xl2KxBlX3PTcaD2chBM1gXAYf491/M2Rv8Jwayg==", + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + }, + "funding": { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/loglevel" + } + }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -5690,6 +5728,16 @@ "queue-microtask": "^1.2.2" } }, + "node_modules/rxjs": { + "version": "7.8.2", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", + "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "tslib": "^2.1.0" + } + }, "node_modules/saxes": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", @@ -5703,6 +5751,21 @@ "node": ">=v12.22.7" } }, + "node_modules/sdp": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/sdp/-/sdp-3.2.2.tgz", + "integrity": "sha512-xZocWwfyp4hkbN4hLWxMjmv2Q8aNa9MhmOZ7L9aCZPT+dZsgRr6wZRrSYE3HTdyk/2pZKPSgqI7ns7Een1xMSA==", + "license": "MIT" + }, + "node_modules/sdp-transform": { + "version": "2.15.0", + "resolved": "https://registry.npmjs.org/sdp-transform/-/sdp-transform-2.15.0.tgz", + "integrity": "sha512-KrOH82c/W+GYQ0LHqtr3caRpM3ITglq3ljGUIb8LTki7ByacJZ9z+piSGiwZDsRyhQbYBOBJgr2k6X4BZXi3Kw==", + "license": "MIT", + "bin": { + "sdp-verify": "checker.js" + } + }, "node_modules/semver": { "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", @@ -6136,9 +6199,7 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "dev": true, - "license": "0BSD", - "optional": true + "license": "0BSD" }, "node_modules/type-check": { "version": "0.4.0", @@ -6166,6 +6227,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/typed-emitter": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/typed-emitter/-/typed-emitter-2.1.0.tgz", + "integrity": "sha512-g/KzbYKbH5C2vPkaXGu8DJlHrGKHLsM25Zg9WuC9pMGfuvT+X25tZQWo5fK1BjBm8+UrVE9LDCvaY0CQk+fXDA==", + "license": "MIT", + "optionalDependencies": { + "rxjs": "*" + } + }, "node_modules/typescript": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", @@ -6833,6 +6903,19 @@ "node": ">=20" } }, + "node_modules/webrtc-adapter": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/webrtc-adapter/-/webrtc-adapter-9.0.5.tgz", + "integrity": "sha512-U9vjByy/sK2OMXu5mmfuZFKTMIUQe34c0JXRO+oDrxJTsntdYT2iIFwYMOV7HhMTuktcZLGf2W1N/OcSf9ssWg==", + "license": "BSD-3-Clause", + "dependencies": { + "sdp": "^3.2.0" + }, + "engines": { + "node": ">=6.0.0", + "npm": ">=3.10.0" + } + }, "node_modules/whatwg-mimetype": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index 60f8970..2053b54 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -15,6 +15,7 @@ }, "dependencies": { "axios": "^1.14.0", + "livekit-client": "^2.19.2", "pinia": "^3.0.4", "vue": "^3.5.31", "vue-router": "^4.4.5" diff --git a/frontend/src/components/meeting/ParticipantTile.vue b/frontend/src/components/meeting/ParticipantTile.vue new file mode 100644 index 0000000..e915f99 --- /dev/null +++ b/frontend/src/components/meeting/ParticipantTile.vue @@ -0,0 +1,164 @@ + + + + + diff --git a/frontend/src/composables/useLiveKitRoom.ts b/frontend/src/composables/useLiveKitRoom.ts new file mode 100644 index 0000000..c2d7d82 --- /dev/null +++ b/frontend/src/composables/useLiveKitRoom.ts @@ -0,0 +1,114 @@ +import { ref, shallowRef } from 'vue' +import { Room, RoomEvent, type Participant } from 'livekit-client' + +// 브라우저가 접속할 LiveKit WebSocket URL (build arg/env 로 주입, 미설정 시 로컬 기본값) +const LIVEKIT_URL = import.meta.env.VITE_LIVEKIT_URL || 'ws://localhost:7880' + +// LiveKit 방 연결/생명주기 관리 Composable +// 미디어 전송 자체는 LiveKit SDK 가 담당하고, 여기서는 연결 상태와 참여자 목록만 반응형으로 노출한다. +export function useLiveKitRoom() { + // Room 인스턴스는 깊은 반응형이 불필요(내부적으로 자체 이벤트 관리) → shallowRef + const room = shallowRef(null) + // 참여자 배열도 shallowRef — Participant 는 비공개 멤버가 있는 클래스라 깊은 언랩 시 + // 구조적 타입으로 바뀌어 타입 불일치가 난다. 매번 배열을 통째로 교체하므로 shallow 로 충분. + const participants = shallowRef([]) + const connected = ref(false) + const connecting = ref(false) + const micEnabled = ref(false) + const camEnabled = ref(false) + const errorMsg = ref(null) + + // 로컬 + 원격 참여자 목록 갱신(진행자 화면 구성은 단계 3에서 고도화) + function refresh() { + const r = room.value + if (!r) { + participants.value = [] + return + } + participants.value = [ + r.localParticipant, + ...Array.from(r.remoteParticipants.values()), + ] + } + + function onDisconnected() { + connected.value = false + participants.value = [] + } + + // 방 입장 — 토큰으로 연결 후 카메라/마이크 발행 + async function connect(token: string): Promise { + if (connecting.value || connected.value) return + connecting.value = true + errorMsg.value = null + try { + const r = new Room({ adaptiveStream: true, dynacast: true }) + r.on(RoomEvent.ParticipantConnected, refresh) + .on(RoomEvent.ParticipantDisconnected, refresh) + .on(RoomEvent.TrackSubscribed, refresh) + .on(RoomEvent.TrackUnsubscribed, refresh) + .on(RoomEvent.LocalTrackPublished, refresh) + .on(RoomEvent.LocalTrackUnpublished, refresh) + .on(RoomEvent.Disconnected, onDisconnected) + + await r.connect(LIVEKIT_URL, token) + room.value = r + connected.value = true + + // 카메라/마이크 권한 요청 및 발행 (실패해도 연결은 유지) + try { + await r.localParticipant.enableCameraAndMicrophone() + camEnabled.value = true + micEnabled.value = true + } catch { + errorMsg.value = + '카메라/마이크를 사용할 수 없습니다. 권한을 확인해 주세요.' + } + refresh() + } catch (e) { + errorMsg.value = e instanceof Error ? e.message : '연결에 실패했습니다.' + await disconnect() + } finally { + connecting.value = false + } + } + + // 방 나가기 + async function disconnect(): Promise { + const r = room.value + if (r) await r.disconnect() + room.value = null + connected.value = false + camEnabled.value = false + micEnabled.value = false + participants.value = [] + } + + async function toggleMic(): Promise { + const r = room.value + if (!r) return + micEnabled.value = !micEnabled.value + await r.localParticipant.setMicrophoneEnabled(micEnabled.value) + } + + async function toggleCam(): Promise { + const r = room.value + if (!r) return + camEnabled.value = !camEnabled.value + await r.localParticipant.setCameraEnabled(camEnabled.value) + refresh() + } + + return { + participants, + connected, + connecting, + micEnabled, + camEnabled, + errorMsg, + connect, + disconnect, + toggleMic, + toggleCam, + } +} diff --git a/frontend/src/composables/useMeeting.ts b/frontend/src/composables/useMeeting.ts new file mode 100644 index 0000000..0f172b8 --- /dev/null +++ b/frontend/src/composables/useMeeting.ts @@ -0,0 +1,22 @@ +import { useApi } from './useApi' + +// 화상회의 토큰 응답 — 백엔드 MeetingTokenResult 와 1:1 +export interface MeetingToken { + token: string // LiveKit 입장 JWT + roomName: string + identity: string // 참여자 식별자(사용자 ID) +} + +// 화상회의 도메인 API Composable — 인터셉터가 success/data 를 언래핑 +export function useMeeting() { + const api = useApi() + + // LiveKit 입장 토큰 발급 + async function getToken(roomName: string): Promise { + return (await api.post('/meetings/token', { + roomName, + })) as unknown as MeetingToken + } + + return { getToken } +} diff --git a/frontend/src/layouts/AppShell.vue b/frontend/src/layouts/AppShell.vue index 3358a48..69b747a 100644 --- a/frontend/src/layouts/AppShell.vue +++ b/frontend/src/layouts/AppShell.vue @@ -29,9 +29,10 @@ function toggleCollapse() { const mobileOpen = ref(false) // 현재 경로 기준으로 사이드바 네비 활성 항목을 판별 -const activeNav = computed<'tasks' | 'projects' | 'schedule'>(() => { +const activeNav = computed<'tasks' | 'projects' | 'schedule' | 'meeting'>(() => { if (route.path.startsWith('/projects')) return 'projects' if (route.path.startsWith('/schedule')) return 'schedule' + if (route.path.startsWith('/meeting')) return 'meeting' return 'tasks' }) @@ -201,6 +202,31 @@ async function onLogout() { 프로젝트 + + + + + + 화상회의 + diff --git a/frontend/src/pages/relay/MeetingPage.vue b/frontend/src/pages/relay/MeetingPage.vue new file mode 100644 index 0000000..22c5014 --- /dev/null +++ b/frontend/src/pages/relay/MeetingPage.vue @@ -0,0 +1,345 @@ + + + + + diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index 21f7a0b..9b280fd 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -108,6 +108,13 @@ const routes: RouteRecordRaw[] = [ component: () => import('@/pages/relay/SchedulePage.vue'), meta: { requiresAuth: true }, }, + { + // 화상회의 — LiveKit 기반(단계 1: 방 입장/연결). :room 으로 공유 링크 가능 + path: '/meeting/:room?', + name: 'meeting', + component: () => import('@/pages/relay/MeetingPage.vue'), + meta: { requiresAuth: true }, + }, { path: '/:pathMatch(.*)*', name: 'not-found', diff --git a/livekit/livekit.yaml b/livekit/livekit.yaml new file mode 100644 index 0000000..3fed67a --- /dev/null +++ b/livekit/livekit.yaml @@ -0,0 +1,20 @@ +# LiveKit 셀프호스팅 설정 (개발용 / 로컬 단일 노드) +# - 미디어(WebRTC) 전송 계층을 담당하는 SFU. 애플리케이션은 토큰만 발급한다. +# - API 키/시크릿은 환경변수 LIVEKIT_KEYS 로 주입한다(여기에 두지 않음). +# - 로컬 개발에서는 노드 IP 를 127.0.0.1 로 광고해야 브라우저가 ICE 후보에 접속할 수 있다. +# (docker-compose 의 command 에서 --node-ip 127.0.0.1 지정) + +# 시그널링 / HTTP API 포트 +port: 7880 + +rtc: + # ICE/TCP 포트 — Docker Desktop 에서 가장 안정적인 경로(브라우저가 연결을 거는 단일 스트림) + tcp_port: 7881 + # ICE/UDP 단일 포트(매핑됨). udp_port:0 은 50000~60000 레인지를 켜버려(미매핑) NIC flapping 을 + # 유발하므로 반드시 매핑된 단일 포트를 명시한다. + udp_port: 7882 + # 외부 IP 자동 탐지를 끄고 --node-ip 로 지정한 127.0.0.1 을 후보로 사용 + use_external_ip: false + +logging: + level: info diff --git a/화상회의_화면명세.md b/화상회의_화면명세.md new file mode 100644 index 0000000..95bbbef --- /dev/null +++ b/화상회의_화면명세.md @@ -0,0 +1,193 @@ +# 화상회의 화면 명세 (단계 1: 인프라 토대) + +> 이 문서는 **현재 구현된 화상회의 화면의 기능·구조·데이터 계약**을 정리한 것이다. +> 새 UI를 직접 설계/구현할 때, 아래 "데이터 계약"과 "상태 흐름"만 그대로 연결하면 +> 시각 디자인은 자유롭게 바꿔도 동작이 보장된다. +> (미디어 전송은 LiveKit SDK가 담당하고, 우리는 토큰 발급 + 화면 구성만 한다.) + +--- + +## 1. 범위 + +- **포함(단계 1)**: 방 입장 → LiveKit 연결 → 카메라/마이크 발행 → 참여자 영상 그리드 표시 → 나가기. 마이크/카메라 on·off. +- **제외(후속 단계, 이번 UI에서 구현 안 함)** + - 단계 2: 관리자(진행자)/일반 사용자 **권한 분기** (토큰 grant 차등) + - 단계 3: **진행자 고해상도 큰 화면 + 참여자 썸네일**, 특정 참여자 **화면 승격**, **그리드 페이지네이션**(동시 표시 ~50명) + - 단계 4: 방 목록/예약/참여 제한 +- 즉 지금은 "같은 방 이름으로 들어온 사람끼리 동등하게 연결되는 기본 회의실"이다. + +--- + +## 2. 진입 / 라우트 + +| 항목 | 값 | +|---|---| +| 라우트 | `/meeting/:room?` (room 파라미터는 선택) | +| 라우트 name | `meeting` | +| 인증 | `requiresAuth: true` (로그인 필요) | +| 페이지 컴포넌트 | `frontend/src/pages/relay/MeetingPage.vue` | +| 사이드바 네비 | `AppShell.vue` 의 "화상회의" 항목 (`/meeting`) | + +- `/meeting` 으로 들어오면 방 이름 미입력 상태의 로비. +- `/meeting/<방이름>` 으로 들어오면 그 방 이름이 입력란에 채워진 로비(공유 링크 용도). **입장 버튼을 눌러야** 실제 연결된다(카메라 권한 타이밍 확보). +- 입장 성공 시 주소창을 `/meeting/<방이름>` 으로 `replace` 한다(공유 가능 링크). + +--- + +## 3. 화면 상태 (4가지) + +| 상태 | 조건 | 화면 | +|---|---|---| +| **로비(미연결)** | `connected === false` | 방 이름 입력 폼 | +| **연결 중** | `connecting === true` | 입장 버튼이 "입장 중…" + 비활성 | +| **회의실(연결됨)** | `connected === true` | 컨트롤 바 + 영상 그리드 | +| **에러** | `joinError` / `errorMsg` 존재 | 해당 영역에 빨간/주황 문구 | + +- `joinError`: 입장 시도 단계의 오류(방 이름 형식 오류, 토큰 발급 실패 등) — 로비에 표시. +- `errorMsg`: 연결 이후 미디어 단계의 오류(카메라/마이크 권한 거부 등) — 회의실에 표시. 권한 거부여도 연결 자체는 유지된다. + +--- + +## 4. 로비 화면 (현재 구성) + +- 페이지 제목 `화상회의` + 안내 문구(lede) +- 카드 폼: + - 라벨 `* 방 이름` (필수 표기) + - 힌트: "영문·숫자·하이픈(-)·밑줄(_)만 사용할 수 있습니다." + - 입력란: `maxlength=64`, placeholder `예: team-standup` + - 오류 문구(`joinError`) + - 푸터: `입장하기` 버튼(제출 시 `onJoin`) +- **방 이름 규칙**: `^[A-Za-z0-9_-]+$`, 1~64자. (백엔드 DTO와 동일하게 검증 — 한글/공백 불가) + +--- + +## 5. 회의실 화면 (현재 구성) + +- **상단 바(room-bar)** + - 방 이름(`roomName`) + - 참여자 수(`참여자 N명` = `participants.length`) + - 컨트롤 버튼 3개: + - 마이크 토글 → `toggleMic()` (상태 `micEnabled`) + - 카메라 토글 → `toggleCam()` (상태 `camEnabled`) + - 나가기 → `onLeave()` (연결 해제 후 `/meeting` 로 이동) +- **영상 그리드(grid)**: `participants` 를 순회하며 `ParticipantTile` 렌더. 첫 번째(`index === 0`)가 **본인(localParticipant)**. + +> 현재 컨트롤은 이모지(🎙/📹) 버튼이고 상단 우측에 있다 → 새 UI에서 하단 중앙 컨트롤 바 + 아이콘 버튼 등으로 자유롭게 재배치 가능. **연결되는 함수/상태만 동일하게 쓰면 된다.** + +--- + +## 6. 참여자 타일 (`ParticipantTile.vue`) + +- **Props** + - `participant: Participant` (livekit-client) — 필수 + - `isLocal?: boolean` — 본인 타일 여부 +- **동작** + - 카메라 트랙을 `