diff --git a/.gitignore b/.gitignore index 29c67c1a..76a2b59f 100644 --- a/.gitignore +++ b/.gitignore @@ -7,4 +7,9 @@ node_modules # husky 관련 .husky/_ -.husky/.gitignore \ No newline at end of file +.husky/.gitignore + +# docker-compose 관련 파일 + +mysql-data +redis-data \ No newline at end of file diff --git a/backend/src/config/typeorm.config.ts b/backend/src/config/typeorm.config.ts index e65e06af..ea1d12a3 100644 --- a/backend/src/config/typeorm.config.ts +++ b/backend/src/config/typeorm.config.ts @@ -26,6 +26,40 @@ export const createDataSource = async (): Promise => { transactionalDataSource = addTransactionalDataSource( new DataSource(typeOrmConfig) ); + + await transactionalDataSource.initialize(); + await seedDatabase(transactionalDataSource); } return transactionalDataSource; }; + +const seedDatabase = async (dataSource: DataSource) => { + const categoryRepository = dataSource.getRepository(Category); + + const categories = [ + "자료구조", + "운영체제", + "데이터베이스", + "컴퓨터구조", + "네트워크", + "백엔드", + "프론트엔드", + "알고리즘", + "보안", + ]; + + // 이미 데이터가 있을 경우 시딩하지 않음 + const existingCount = await categoryRepository.count(); + if (existingCount > 0) { + return; + } + + // 카테고리 데이터 삽입 + const categoryEntities = categories.map((name) => { + const category = new Category(); + category.name = name; + return category; + }); + + await categoryRepository.save(categoryEntities); +}; \ No newline at end of file diff --git a/backend/src/question-list/dto/create-question-list.dto.ts b/backend/src/question-list/dto/create-question-list.dto.ts index 87fd9bc4..4c4f075a 100644 --- a/backend/src/question-list/dto/create-question-list.dto.ts +++ b/backend/src/question-list/dto/create-question-list.dto.ts @@ -1,5 +1,6 @@ export interface CreateQuestionListDto { title: string; + contents: string[]; categoryNames: string[]; isPublic: boolean; userId: number; diff --git a/backend/src/question-list/dto/question-list.dto.ts b/backend/src/question-list/dto/question-list.dto.ts deleted file mode 100644 index 3b632884..00000000 --- a/backend/src/question-list/dto/question-list.dto.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { Category } from "../category.entity"; - -export interface QuestionListDto { - title: string; - categories: Category[]; - isPublic: boolean; - userId: number; -} \ No newline at end of file diff --git a/backend/src/question-list/dto/question.dto.ts b/backend/src/question-list/dto/question.dto.ts deleted file mode 100644 index a90f7512..00000000 --- a/backend/src/question-list/dto/question.dto.ts +++ /dev/null @@ -1,5 +0,0 @@ -export interface QuestionDto { - content: string; - index: number; - questionListId: number; -} diff --git a/backend/src/question-list/question-list.controller.ts b/backend/src/question-list/question-list.controller.ts index 8d669ec8..64dbb3b7 100644 --- a/backend/src/question-list/question-list.controller.ts +++ b/backend/src/question-list/question-list.controller.ts @@ -62,29 +62,18 @@ export class QuestionListController { // 질문지 DTO 준비 const createQuestionListDto: CreateQuestionListDto = { title, + contents, categoryNames, isPublic, userId: token.userId, }; // 질문지 생성 - const createdQuestionList = + const { createdQuestionList, createdQuestions } = await this.questionListService.createQuestionList( createQuestionListDto ); - // 질문 DTO 준비 - const createQuestionDto: CreateQuestionDto = { - contents, - questionListId: createdQuestionList.id, - }; - - // 질문 생성 - const createdQuestions = - await this.questionListService.createQuestions( - createQuestionDto - ); - return res.send({ success: true, message: "Question list created successfully.", diff --git a/backend/src/question-list/question-list.module.ts b/backend/src/question-list/question-list.module.ts index 613b7c27..14b7a61b 100644 --- a/backend/src/question-list/question-list.module.ts +++ b/backend/src/question-list/question-list.module.ts @@ -6,5 +6,6 @@ import { QuestionListRepository } from "./question-list.repository"; @Module({ controllers: [QuestionListController], providers: [QuestionListService, QuestionListRepository], + exports: [QuestionListRepository], }) export class QuestionListModule {} diff --git a/backend/src/question-list/question-list.repository.ts b/backend/src/question-list/question-list.repository.ts index 837bbb1a..f3e4470a 100644 --- a/backend/src/question-list/question-list.repository.ts +++ b/backend/src/question-list/question-list.repository.ts @@ -2,9 +2,7 @@ import { Injectable } from "@nestjs/common"; import { DataSource, In } from "typeorm"; import { QuestionList } from "./question-list.entity"; import { Question } from "./question.entity"; -import { QuestionDto } from "./dto/question.dto"; import { Category } from "./category.entity"; -import { QuestionListDto } from "./dto/question-list.dto"; import { User } from "../user/user.entity"; @Injectable() @@ -49,16 +47,6 @@ export class QuestionListRepository { : []; } - createQuestionList(questionListDto: QuestionListDto) { - return this.dataSource - .getRepository(QuestionList) - .save(questionListDto); - } - - async createQuestions(questionDtos: QuestionDto[]) { - return this.dataSource.getRepository(Question).save(questionDtos); - } - async findCategoriesByNames(categoryNames: string[]) { return this.dataSource.getRepository(Category).find({ where: { @@ -75,7 +63,7 @@ export class QuestionListRepository { getContentsByQuestionListId(questionListId: number) { return this.dataSource.getRepository(Question).find({ - where: { questionListId: questionListId }, + where: { questionListId }, }); } diff --git a/backend/src/question-list/question-list.service.ts b/backend/src/question-list/question-list.service.ts index 6a33f27d..6d185067 100644 --- a/backend/src/question-list/question-list.service.ts +++ b/backend/src/question-list/question-list.service.ts @@ -1,16 +1,17 @@ import { Injectable } from "@nestjs/common"; import { QuestionListRepository } from "./question-list.repository"; import { CreateQuestionListDto } from "./dto/create-question-list.dto"; -import { CreateQuestionDto } from "./dto/create-question.dto"; -import { QuestionDto } from "./dto/question.dto"; -import { QuestionListDto } from "./dto/question-list.dto"; import { GetAllQuestionListDto } from "./dto/get-all-question-list.dto"; import { QuestionListContentsDto } from "./dto/question-list-contents.dto"; import { MyQuestionListDto } from "./dto/my-question-list.dto"; +import { DataSource } from "typeorm"; +import { QuestionList } from "./question-list.entity"; +import { Question } from "./question.entity"; @Injectable() export class QuestionListService { constructor( + private readonly dataSource: DataSource, private readonly questionListRepository: QuestionListRepository ) {} @@ -85,40 +86,45 @@ export class QuestionListService { // 질문 생성 메서드 async createQuestionList(createQuestionListDto: CreateQuestionListDto) { - const { title, categoryNames, isPublic, userId } = + const { title, contents, categoryNames, isPublic, userId } = createQuestionListDto; - const categories = - await this.questionListRepository.findCategoriesByNames( - categoryNames - ); - if (categories.length !== categoryNames.length) { - throw new Error("Some category names were not found."); - } + const categories = await this.findCategoriesByNames(categoryNames); - const questionListDto: QuestionListDto = { - title, - categories, - isPublic, - userId, - }; + const queryRunner = this.dataSource.createQueryRunner(); + await queryRunner.startTransaction(); - return this.questionListRepository.createQuestionList(questionListDto); - } + try { + const questionListDto = new QuestionList(); + questionListDto.title = title; + questionListDto.categories = categories; + questionListDto.isPublic = isPublic; + questionListDto.userId = userId; - async createQuestions(createQuestionDto: CreateQuestionDto) { - const { contents, questionListId } = createQuestionDto; - const questionDtos = contents.map((content, index) => { - const question: QuestionDto = { - content, - index, - questionListId, - }; + const createdQuestionList = + await queryRunner.manager.save(questionListDto); + + const questions = contents.map((content, index) => { + const question = new Question(); + question.content = content; + question.index = index; + question.questionList = createdQuestionList; + + return question; + }); - return question; - }); + const createdQuestions = + await queryRunner.manager.save(questions); - return await this.questionListRepository.createQuestions(questionDtos); + await queryRunner.commitTransaction(); + + return { createdQuestionList, createdQuestions }; + } catch (error) { + await queryRunner.rollbackTransaction(); + throw new Error(error.message); + } finally { + await queryRunner.release(); + } } async getQuestionListContents(questionListId: number) { @@ -182,4 +188,17 @@ export class QuestionListService { } return myQuestionLists; } + + async findCategoriesByNames(categoryNames: string[]) { + const categories = + await this.questionListRepository.findCategoriesByNames( + categoryNames + ); + + if (categories.length !== categoryNames.length) { + throw new Error("Some category names were not found."); + } + + return categories; + } } diff --git a/backend/src/question-list/question.entity.ts b/backend/src/question-list/question.entity.ts index 128175f6..b92b98d4 100644 --- a/backend/src/question-list/question.entity.ts +++ b/backend/src/question-list/question.entity.ts @@ -3,7 +3,7 @@ import { QuestionList } from "./question-list.entity"; @Entity() export class Question { - private static CONTENT_MAX_LEN = 20; + private static CONTENT_MAX_LEN = 200; @PrimaryGeneratedColumn() id: number; diff --git a/backend/src/room/dto/create-room.dto.ts b/backend/src/room/dto/create-room.dto.ts index 50f0dc0b..af5d2e9f 100644 --- a/backend/src/room/dto/create-room.dto.ts +++ b/backend/src/room/dto/create-room.dto.ts @@ -4,4 +4,5 @@ export interface CreateRoomDto { nickname: string; socketId: string; maxParticipants?: number; + questionListId: number; } diff --git a/backend/src/room/room.gateway.ts b/backend/src/room/room.gateway.ts index acdbda46..093d2857 100644 --- a/backend/src/room/room.gateway.ts +++ b/backend/src/room/room.gateway.ts @@ -51,7 +51,8 @@ export class RoomGateway implements OnGatewayConnection, OnGatewayDisconnect { @SubscribeMessage(EVENT_NAME.CREATE_ROOM) async handleCreateRoom(client: Socket, data: any) { - const { title, nickname, status, maxParticipants } = data; // unknown 으로 받고, Dto와 Pipe로 검증받기 + const { title, nickname, status, maxParticipants, questionListId } = + data; // unknown 으로 받고, Dto와 Pipe로 검증받기 try { const roomData = await this.roomService.createRoom({ title, @@ -59,6 +60,7 @@ export class RoomGateway implements OnGatewayConnection, OnGatewayDisconnect { socketId: client.id, nickname, maxParticipants, + questionListId, }); client.join(roomData.roomId); diff --git a/backend/src/room/room.model.ts b/backend/src/room/room.model.ts index 1db80a32..fd834d3a 100644 --- a/backend/src/room/room.model.ts +++ b/backend/src/room/room.model.ts @@ -6,6 +6,7 @@ export interface Room { maxParticipants: number; createdAt: number; host: string; + questionListId; } export interface MemberConnection { diff --git a/backend/src/room/room.module.ts b/backend/src/room/room.module.ts index 4bf3e9c1..d775a309 100644 --- a/backend/src/room/room.module.ts +++ b/backend/src/room/room.module.ts @@ -4,8 +4,10 @@ import { RoomGateway } from "./room.gateway"; import { RedisService } from "../redis/redis.service"; import { RoomRepository } from "./room.repository"; import { RoomController } from "./room.controller"; +import { QuestionListModule } from "../question-list/question-list.module"; @Module({ + imports: [QuestionListModule], providers: [RoomService, RoomGateway, RedisService, RoomRepository], controllers: [RoomController], }) diff --git a/backend/src/room/room.repository.ts b/backend/src/room/room.repository.ts index 113ded64..e7747ac5 100644 --- a/backend/src/room/room.repository.ts +++ b/backend/src/room/room.repository.ts @@ -15,11 +15,8 @@ export class RoomRepository { async getAllRoom(): Promise> { const redisMap = await this.redisService.getMap("room:*"); - console.log(redisMap); - if (!redisMap) return {}; - - return Object.entries(redisMap).reduce( + return Object.entries(redisMap ?? {}).reduce( (acc, [roomId, room]) => { acc[roomId.split(":")[1]] = room as Room; return acc; @@ -117,7 +114,8 @@ export class RoomRepository { } async createRoom(dto: CreateRoomDto) { - const { title, socketId, maxParticipants, status } = dto; + const { title, socketId, maxParticipants, status, questionListId } = + dto; const roomId = generateRoomId(); await this.redisService.set( @@ -128,6 +126,7 @@ export class RoomRepository { host: socketId, maxParticipants, status, + questionListId, } as Room, 6 * HOUR ); diff --git a/backend/src/room/room.service.spec.ts b/backend/src/room/room.service.spec.ts index 630e7dd1..fe752666 100644 --- a/backend/src/room/room.service.spec.ts +++ b/backend/src/room/room.service.spec.ts @@ -58,6 +58,7 @@ describe("RoomService", () => { title: "Test Room", socketId: "socket-123", nickname: "User1", + questionListId: 1, }; const mockRoomId = "room-123"; diff --git a/backend/src/room/room.service.ts b/backend/src/room/room.service.ts index dc7a413e..20b001c4 100644 --- a/backend/src/room/room.service.ts +++ b/backend/src/room/room.service.ts @@ -2,6 +2,9 @@ import { Injectable } from "@nestjs/common"; import { RoomRepository } from "./room.repository"; import { CreateRoomDto } from "./dto/create-room.dto"; import { MemberConnection } from "./room.model"; +import { QuestionListRepository } from "../question-list/question-list.repository"; +import { InjectRepository } from "@nestjs/typeorm"; +import { QuestionList } from "../question-list/question-list.entity"; /** * 비즈니스 로직 처리를 좀 더 하게 하기 위한 클래스로 설정 @@ -12,15 +15,32 @@ import { MemberConnection } from "./room.model"; export class RoomService { private static MAX_MEMBERS = 5; - constructor(private readonly roomRepository: RoomRepository) {} + constructor( + private readonly roomRepository: RoomRepository, + private readonly questionListRepository: QuestionListRepository + ) {} async getPublicRoom() { const rooms = await this.roomRepository.getAllRoom(); - - Object.keys(rooms).forEach((roomId) => { - if (rooms[roomId].status === "PRIVATE") rooms[roomId] = undefined; + const roomList = []; + Object.entries(rooms).forEach(([roomId, roomData]) => { + if (roomData.status === "PRIVATE") return; + roomList.push({ + id: roomId, + title: roomData.title, + category: "프론트엔드", + inProgress: false, + host: { + nickname: "방장", + socketId: roomData.host, + }, + participant: 1, + maxParticipant: roomData.maxParticipants, + createAt: roomData.createdAt, + }); }); - return rooms; + + return roomList.sort((a, b) => b.createAt - a.createAt); } async getRoomId(socketId: string) { @@ -28,15 +48,29 @@ export class RoomService { } async createRoom(dto: CreateRoomDto) { - const { title, status, maxParticipants, socketId, nickname } = dto; + const { + title, + status, + maxParticipants, + socketId, + nickname, + questionListId, + } = dto; const roomId = await this.roomRepository.createRoom({ title, status: status ?? "PUBLIC", maxParticipants: maxParticipants ?? RoomService.MAX_MEMBERS, socketId, nickname: nickname ?? "Master", + questionListId, }); + await this.roomRepository.addUser(roomId, dto.socketId, dto.nickname); + const questionListContents = + await this.questionListRepository.getContentsByQuestionListId( + questionListId + ); + return { roomId, roomMetadata: { @@ -45,6 +79,7 @@ export class RoomService { maxParticipants: maxParticipants ?? RoomService.MAX_MEMBERS, host: socketId, nickname: nickname ?? "Master", + questionListContents, }, }; } diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 00000000..9370a4b2 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,26 @@ +version: '3' + +services: + mysql: + image: mysql:latest + container_name: mysql + ports: + - '3306:3306' + environment: + MYSQL_ROOT_PASSWORD: dbcadmium + MYSQL_DATABASE: preview + TZ: Asia/Seoul + command: + - --character-set-server=utf8mb4 + - --collation-server=utf8mb4_unicode_ci + volumes: + - ./mysql-data:/var/lib/mysql + + redis: + image: redis:latest + container_name: redis + ports: + - '6379:6379' + command: redis-server --port 6379 + volumes: + - ./redis-data:/data \ No newline at end of file diff --git a/frontend/src/api/questions/getQuestion.ts b/frontend/src/api/questions/getQuestion.ts index e112e50e..bb980804 100644 --- a/frontend/src/api/questions/getQuestion.ts +++ b/frontend/src/api/questions/getQuestion.ts @@ -2,7 +2,7 @@ import axios from "axios"; const fetchQuestion = async (questionListId: number) => { const { data } = await axios.post("/api/question-list/contents", { - questionListId + questionListId, }); if (!data.success) { diff --git a/frontend/src/components/sessions/create/SessionForm/ListSelectModal/CategoryTab/Category.tsx b/frontend/src/components/sessions/create/SessionForm/ListSelectModal/CategoryTab/Category.tsx index 3cbcec7c..82c074cd 100644 --- a/frontend/src/components/sessions/create/SessionForm/ListSelectModal/CategoryTab/Category.tsx +++ b/frontend/src/components/sessions/create/SessionForm/ListSelectModal/CategoryTab/Category.tsx @@ -14,7 +14,7 @@ const Category = ({ tabName, tabText }: Props) => { ${ tab === tabName ? "text-bold-s text-green-500" - : "text-medium-m text-gray-400" + : "text-medium-l text-gray-400" }`} onClick={() => setTab(tabName)} > diff --git a/frontend/src/components/sessions/create/SessionForm/ListSelectModal/QuestionList/QuestionItem.tsx b/frontend/src/components/sessions/create/SessionForm/ListSelectModal/QuestionList/QuestionItem.tsx index 2d75a836..783ed24a 100644 --- a/frontend/src/components/sessions/create/SessionForm/ListSelectModal/QuestionList/QuestionItem.tsx +++ b/frontend/src/components/sessions/create/SessionForm/ListSelectModal/QuestionList/QuestionItem.tsx @@ -122,10 +122,10 @@ const QuestionItem = ({ item }: { item: QuestionList }) => {
- {questions.map((item,index) => { + {questions.map((item, index) => { return (

- Q{index+1}. {item.content} + Q{index + 1}. {item.content}

); })} diff --git a/frontend/src/components/sessions/create/SessionForm/ListSelectModal/QuestionList/index.tsx b/frontend/src/components/sessions/create/SessionForm/ListSelectModal/QuestionList/index.tsx index 327515d5..c47fcff7 100644 --- a/frontend/src/components/sessions/create/SessionForm/ListSelectModal/QuestionList/index.tsx +++ b/frontend/src/components/sessions/create/SessionForm/ListSelectModal/QuestionList/index.tsx @@ -3,7 +3,7 @@ import { useEffect, useState } from "react"; import axios from "axios"; import LoadingIndicator from "@components/common/LoadingIndicator.tsx"; import { QUESTION_PER_PAGE } from "../constant"; - + interface QuestionList { id: number; title: string; @@ -32,7 +32,7 @@ const QuestionList = ({ page }: { page: number }) => { setQuestionList([]); } }; - // const { tab } = useSessionFormStore(); + //const { tab } = useSessionFormStore(); const startIndex = (page - 1) * QUESTION_PER_PAGE; const endIndex = startIndex + QUESTION_PER_PAGE; @@ -40,13 +40,13 @@ const QuestionList = ({ page }: { page: number }) => { return (
- {/*{data[tab].map((item, id) => {*/} - {/* return (*/} - {/*
*/} - {/* */} - {/*
*/} - {/* );*/} - {/*})}*/} + {/*data[tab].map((item, id) => { + return ( +
+ +
+ ); + })*/} {questionList.slice(startIndex, endIndex).map((item, id) => { return (
diff --git a/frontend/src/components/sessions/create/SessionForm/ListSelectModal/data.ts b/frontend/src/components/sessions/create/SessionForm/ListSelectModal/data.ts index 742c1d1c..a89bf071 100644 --- a/frontend/src/components/sessions/create/SessionForm/ListSelectModal/data.ts +++ b/frontend/src/components/sessions/create/SessionForm/ListSelectModal/data.ts @@ -6,10 +6,11 @@ interface Question { interface ListItem { id: number; user_name: string; - category: string; + categoryNames: string[]; title: string; - count: number; + questionCount: number; questions: Question[]; + usage: number; } interface Data { @@ -22,9 +23,10 @@ export const data: Data = { { id: 1, user_name: "네모정", - category: "프론트엔드", + categoryNames: ["프론트엔드"], title: "프론트엔드 면접 질문이심1", - count: 2, + questionCount: 2, + usage: 10, questions: [ { id: 3, @@ -39,9 +41,10 @@ export const data: Data = { { id: 2, user_name: "승윤최고", - category: "프론트엔드", + categoryNames: ["프론트엔드"], title: "프론트엔드 면접 질문이심2", - count: 10, + questionCount: 10, + usage: 2, questions: [ { id: 5, @@ -56,9 +59,10 @@ export const data: Data = { { id: 3, user_name: "사용자", - category: "프론트엔드", + categoryNames: ["프론트엔드"], title: "프론트엔드 면접 질문이심3", - count: 10, + questionCount: 10, + usage: 2, questions: [ { id: 7, @@ -73,9 +77,10 @@ export const data: Data = { { id: 4, user_name: "사용자", - category: "프론트엔드", + categoryNames: ["프론트엔드"], title: "프론트엔드 면접 질문이심3", - count: 10, + questionCount: 10, + usage: 10, questions: [ { id: 123, @@ -92,9 +97,10 @@ export const data: Data = { { id: 4, user_name: "사용자", - category: "프론트엔드", + categoryNames: ["프론트엔드"], title: "백엔드 면접 질문이심1", - count: 10, + questionCount: 10, + usage: 2, questions: [ { id: 9, @@ -109,9 +115,10 @@ export const data: Data = { { id: 5, user_name: "사용자", - category: "프론트엔드", + categoryNames: ["프론트엔드"], title: "백엔드 면접 질문이심2", - count: 3, + usage: 10, + questionCount: 3, questions: [ { id: 11, diff --git a/frontend/src/hooks/__test__/useSession.test.ts b/frontend/src/hooks/__test__/useSession.test.ts index 79000726..6aa698b7 100644 --- a/frontend/src/hooks/__test__/useSession.test.ts +++ b/frontend/src/hooks/__test__/useSession.test.ts @@ -1,8 +1,8 @@ import { renderHook } from "@testing-library/react"; -import { useSession } from "@/hooks/useSession"; -import useSocketStore from "@/stores/useSocketStore"; -import useMediaDevices from "@/hooks/useMediaDevices"; -import usePeerConnection from "@/hooks/usePeerConnection"; +import { useSession } from "@hooks/session/useSession"; +import useSocketStore from "@stores/useSocketStore"; +import useMediaDevices from "@hooks/session/useMediaDevices"; +import usePeerConnection from "@hooks/session/usePeerConnection"; import { useNavigate } from "react-router-dom"; import { act } from "react"; import { @@ -12,12 +12,12 @@ import { mockSocket, mockSocketStore, mockToast, -} from "./mocks/useSession.mock"; +} from "@hooks/__test__/mocks/useSession.mock"; // jest.mock: 실제 모듈대신 mock 모듈을 사용하도록 설정 -jest.mock("@/hooks/useMediaDevices"); +jest.mock("@hooks/session/useMediaDevices"); -jest.mock("@/hooks/usePeerConnection", () => ({ +jest.mock("@hooks/session/usePeerConnection", () => ({ __esModule: true, default: jest.fn().mockReturnValue({ createPeerConnection: jest.fn(), @@ -28,7 +28,7 @@ jest.mock("@/hooks/usePeerConnection", () => ({ }), })); -jest.mock("@/hooks/useToast", () => ({ +jest.mock("@hooks/useToast", () => ({ __esModule: true, default: () => mockToast, })); @@ -37,12 +37,12 @@ jest.mock("react-router-dom", () => ({ useNavigate: jest.fn(), })); -jest.mock("@/stores/useSocketStore", () => ({ +jest.mock("@stores/useSocketStore", () => ({ __esModule: true, default: jest.fn(() => mockSocketStore), })); -jest.mock("@/hooks/useSocket", () => ({ +jest.mock("@hooks/useSocket", () => ({ __esModule: true, default: () => { const store = useSocketStore(); @@ -143,16 +143,6 @@ describe("useSession Hook 테스트", () => { }); }); - it("세션 ID가 없이 스터디룸 입장", async () => { - const { result } = renderHook(() => useSession(undefined)); - - await act(async () => { - await result.current.joinRoom(); - }); - - expect(mockToast.error).toHaveBeenCalledWith("세션 ID가 필요합니다."); - }); - it("닉네임 없이 스터디룸 입장", async () => { const { result } = renderHook(() => useSession("test-session")); diff --git a/frontend/src/hooks/useMediaDevices.ts b/frontend/src/hooks/session/useMediaDevices.ts similarity index 99% rename from frontend/src/hooks/useMediaDevices.ts rename to frontend/src/hooks/session/useMediaDevices.ts index 3f651f17..0fbce76c 100644 --- a/frontend/src/hooks/useMediaDevices.ts +++ b/frontend/src/hooks/session/useMediaDevices.ts @@ -109,7 +109,6 @@ const useMediaDevices = () => { const handleVideoToggle = async () => { try { // 비디오 껐다키기 - if (stream) { for (const videoTrack of stream.getVideoTracks()) { if (videoTrack.enabled) { diff --git a/frontend/src/hooks/session/useMediaStreamCleanup.ts b/frontend/src/hooks/session/useMediaStreamCleanup.ts new file mode 100644 index 00000000..550e49e8 --- /dev/null +++ b/frontend/src/hooks/session/useMediaStreamCleanup.ts @@ -0,0 +1,11 @@ +import { useEffect } from "react"; + +export const useMediaStreamCleanup = (stream: MediaStream | null) => { + useEffect(() => { + return () => { + if (stream) { + stream.getTracks().forEach((track) => track.stop()); + } + }; + }, [stream]); +}; diff --git a/frontend/src/hooks/usePeerConnection.ts b/frontend/src/hooks/session/usePeerConnection.ts similarity index 94% rename from frontend/src/hooks/usePeerConnection.ts rename to frontend/src/hooks/session/usePeerConnection.ts index 87e57f14..eeef5d95 100644 --- a/frontend/src/hooks/usePeerConnection.ts +++ b/frontend/src/hooks/session/usePeerConnection.ts @@ -1,5 +1,6 @@ import { useRef, useState } from "react"; import { Socket } from "socket.io-client"; +import { PeerConnection } from "../type/session"; interface User { id?: string; @@ -7,14 +8,6 @@ interface User { isHost?: boolean; } -interface PeerConnection { - peerId: string; // 연결된 상대의 ID - peerNickname: string; // 상대의 닉네임 - stream: MediaStream; // 상대방의 비디오/오디오 스트림 - isHost?: boolean; // 호스트 여부 - reaction?: string; -} - // 피어 간 연결 수립 역할을 하는 커스텀 훅 const usePeerConnection = (socket: Socket) => { const [peers, setPeers] = useState([]); // 연결 관리 diff --git a/frontend/src/hooks/session/usePeerConnectionCleanup.ts b/frontend/src/hooks/session/usePeerConnectionCleanup.ts new file mode 100644 index 00000000..ec3da64d --- /dev/null +++ b/frontend/src/hooks/session/usePeerConnectionCleanup.ts @@ -0,0 +1,19 @@ +import { useEffect } from "react"; +import { MutableRefObject } from "react"; + +export const usePeerConnectionCleanup = ( + peerConnections: MutableRefObject<{ [key: string]: RTCPeerConnection }> +) => { + useEffect(() => { + const connections = peerConnections; + return () => { + Object.values(connections.current).forEach((pc) => { + pc.ontrack = null; + pc.onicecandidate = null; + pc.oniceconnectionstatechange = null; + pc.onconnectionstatechange = null; + pc.close(); + }); + }; + }, [peerConnections]); +}; diff --git a/frontend/src/hooks/session/useReaction.ts b/frontend/src/hooks/session/useReaction.ts new file mode 100644 index 00000000..64c653ff --- /dev/null +++ b/frontend/src/hooks/session/useReaction.ts @@ -0,0 +1,67 @@ +import { Dispatch, SetStateAction, useCallback, useRef } from "react"; +import { Socket } from "socket.io-client"; +import { PeerConnection } from "../type/session"; + +const REACTION_DURATION = 3000; + +export const useReaction = ( + socket: Socket | null, + sessionId: string, + setPeers: Dispatch>, + setReaction: (reaction: string) => void +) => { + const reactionTimeouts = useRef<{ + [key: string]: ReturnType; + }>({}); + + const emitReaction = useCallback( + (reactionType: string) => { + if (socket) { + socket.emit("reaction", { + roomId: sessionId, + reaction: reactionType, + }); + } + }, + [socket, sessionId] + ); + + const addReaction = useCallback( + (senderId: string, reactionType: string) => { + setPeers((prev) => + prev.map((peer) => + peer.peerId === senderId ? { ...peer, reaction: reactionType } : peer + ) + ); + }, + [setPeers] + ); + + const handleReaction = ({ + senderId, + reaction, + }: { + senderId: string; + reaction: string; + }) => { + if (reactionTimeouts.current[senderId]) { + clearTimeout(reactionTimeouts.current[senderId]); + } + + if (senderId === socket?.id) { + setReaction(reaction); + reactionTimeouts.current[senderId] = setTimeout(() => { + setReaction(""); + delete reactionTimeouts.current[senderId]; + }, REACTION_DURATION); + } else { + addReaction(senderId, reaction); + reactionTimeouts.current[senderId] = setTimeout(() => { + addReaction(senderId, ""); + delete reactionTimeouts.current[senderId]; + }, REACTION_DURATION); + } + }; + + return { emitReaction, handleReaction }; +}; diff --git a/frontend/src/hooks/session/useSession.ts b/frontend/src/hooks/session/useSession.ts new file mode 100644 index 00000000..1c45cf4e --- /dev/null +++ b/frontend/src/hooks/session/useSession.ts @@ -0,0 +1,141 @@ +import { useEffect, useMemo, useState } from "react"; +import { useNavigate } from "react-router-dom"; +import useToast from "@hooks/useToast"; +import useMediaDevices from "@hooks/session/useMediaDevices"; +import usePeerConnection from "@hooks/session/usePeerConnection"; +import useSocket from "@hooks/useSocket"; +import { Participant, RoomMetadata } from "@hooks/type/session"; +import { useMediaStreamCleanup } from "@hooks/session/useMediaStreamCleanup"; +import { usePeerConnectionCleanup } from "@hooks/session/usePeerConnectionCleanup"; +import { useReaction } from "@hooks/session/useReaction"; +import { useSocketEvents } from "./useSocketEvents"; +import { Socket } from "socket.io-client"; + +export const useSession = (sessionId: string) => { + const { socket } = useSocket(); + const navigate = useNavigate(); + const toast = useToast(); + + const { + createPeerConnection, + closePeerConnection, + peers, + setPeers, + peerConnections, + } = usePeerConnection(socket!); + + const [nickname, setNickname] = useState(""); + const [reaction, setReaction] = useState(""); + const [roomMetadata, setRoomMetadata] = useState(null); + const [isHost, setIsHost] = useState(false); + + const { + userVideoDevices, + userAudioDevices, + selectedAudioDeviceId, + selectedVideoDeviceId, + stream, + isVideoOn, + isMicOn, + handleMicToggle, + handleVideoToggle, + setSelectedAudioDeviceId, + setSelectedVideoDeviceId, + getMedia, + } = useMediaDevices(); + + useEffect(() => { + if (selectedAudioDeviceId || selectedVideoDeviceId) { + getMedia(); + } + }, [selectedAudioDeviceId, selectedVideoDeviceId, getMedia]); + + usePeerConnectionCleanup(peerConnections); + useMediaStreamCleanup(stream); + + const { emitReaction, handleReaction } = useReaction( + socket, + sessionId, + setPeers, + setReaction + ); + + useSocketEvents({ + socket, + stream, + nickname, + sessionId, + createPeerConnection, + closePeerConnection, + peerConnections, + setPeers, + setIsHost, + setRoomMetadata, + handleReaction, + }); + + const isValidUser = ( + socket: Socket | null, + nickname: string + ): socket is Socket => { + if (!socket) { + toast.error("소켓 연결이 필요합니다."); + return false; + } + if (!nickname) { + toast.error("닉네임을 입력해주세요."); + return false; + } + + return true; + }; + + const joinRoom = async () => { + if (!isValidUser(socket, nickname)) { + return; + } + + const mediaStream = await getMedia(); + if (!mediaStream) { + toast.error( + "미디어 스트림을 가져오지 못했습니다. 미디어 장치를 확인 후 다시 시도해주세요." + ); + navigate("/sessions"); + return; + } + + socket.emit("join_room", { roomId: sessionId, nickname }); + }; + + const participants: Participant[] = useMemo( + () => [ + { nickname, isHost }, + ...peers.map((peer) => ({ + nickname: peer.peerNickname, + isHost: peer.isHost || false, + })), + ], + [nickname, isHost, peers] + ); + + return { + nickname, + setNickname, + reaction, + peers, + userVideoDevices, + userAudioDevices, + isVideoOn, + isMicOn, + stream, + roomMetadata, + isHost, + participants, + handleMicToggle, + handleVideoToggle, + setSelectedAudioDeviceId, + setSelectedVideoDeviceId, + joinRoom, + emitReaction, + }; +}; diff --git a/frontend/src/hooks/useSession.ts b/frontend/src/hooks/session/useSocketEvents.ts similarity index 52% rename from frontend/src/hooks/useSession.ts rename to frontend/src/hooks/session/useSocketEvents.ts index 19bf748d..7cc0d4f4 100644 --- a/frontend/src/hooks/useSession.ts +++ b/frontend/src/hooks/session/useSocketEvents.ts @@ -1,80 +1,60 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { + Dispatch, + MutableRefObject, + SetStateAction, + useCallback, + useEffect, + useRef, +} from "react"; import { useNavigate } from "react-router-dom"; -import useToast from "@/hooks/useToast"; -import useMediaDevices from "@/hooks/useMediaDevices"; -import usePeerConnection from "@/hooks/usePeerConnection"; -import useSocket from "./useSocket"; +import useToast from "@hooks/useToast"; +import { Socket } from "socket.io-client"; import { AllUsersResponse, - Participant, ResponseMasterChanged, RoomMetadata, -} from "./type/session"; - -export const useSession = (sessionId: string | undefined) => { - const { socket } = useSocket(); + PeerConnection, +} from "@hooks/type/session"; + +interface UseSocketEventsProps { + socket: Socket | null; + stream: MediaStream | null; + nickname: string; + sessionId: string; + createPeerConnection: ( + socketId: string, + nickname: string, + stream: MediaStream, + isOffer: boolean, + userData: { nickname: string; isHost: boolean } + ) => RTCPeerConnection | null; + closePeerConnection: (socketId: string) => void; + peerConnections: MutableRefObject<{ [key: string]: RTCPeerConnection }>; + setPeers: Dispatch>; + setIsHost: Dispatch>; + setRoomMetadata: Dispatch>; + handleReaction: (data: { senderId: string; reaction: string }) => void; +} + +export const useSocketEvents = ({ + socket, + stream, + nickname, + createPeerConnection, + closePeerConnection, + peerConnections, + setPeers, + setIsHost, + setRoomMetadata, + handleReaction, +}: UseSocketEventsProps) => { const navigate = useNavigate(); const toast = useToast(); - const { - createPeerConnection, - closePeerConnection, - peers, - setPeers, - peerConnections, - } = usePeerConnection(socket!); - - const [nickname, setNickname] = useState(""); - const [reaction, setReaction] = useState(""); - const [roomMetadata, setRoomMetadata] = useState(null); - const [isHost, setIsHost] = useState(false); - const reactionTimeouts = useRef<{ [key: string]: ReturnType; }>({}); - const { - userVideoDevices, - userAudioDevices, - selectedAudioDeviceId, - selectedVideoDeviceId, - stream, - isVideoOn, - isMicOn, - handleMicToggle, - handleVideoToggle, - setSelectedAudioDeviceId, - setSelectedVideoDeviceId, - getMedia, - } = useMediaDevices(); - - useEffect(() => { - const connections = peerConnections; - return () => { - Object.values(connections.current).forEach((pc) => { - pc.ontrack = null; - pc.onicecandidate = null; - pc.oniceconnectionstatechange = null; - pc.onconnectionstatechange = null; - pc.close(); - }); - }; - }, [peerConnections]); - - useEffect(() => { - if (selectedAudioDeviceId || selectedVideoDeviceId) { - getMedia(); - } - }, [selectedAudioDeviceId, selectedVideoDeviceId, getMedia]); - - useEffect(() => { - return () => { - if (stream) { - stream.getTracks().forEach((track) => track.stop()); - } - }; - }, [stream]); - const handleUserExit = useCallback( ({ socketId }: { socketId: string }) => { toast.error("유저가 나갔습니다."); @@ -104,7 +84,7 @@ export const useSession = (sessionId: string | undefined) => { toast.success(`${data.masterNickname}님이 호스트가 되었습니다.`); } }, - [socket, toast, setPeers] + [socket, toast, setPeers, setIsHost] ); const setupSocketListeners = useCallback(() => { @@ -139,7 +119,7 @@ export const useSession = (sessionId: string | undefined) => { data.offerSendNickname, stream, false, - { nickname } + { nickname, isHost: false } ); if (!pc) return; @@ -184,32 +164,6 @@ export const useSession = (sessionId: string | undefined) => { } }; - const handleReaction = ({ - senderId, - reaction, - }: { - senderId: string; - reaction: string; - }) => { - if (reactionTimeouts.current[senderId]) { - clearTimeout(reactionTimeouts.current[senderId]); - } - - if (senderId === socket.id) { - setReaction(reaction); - reactionTimeouts.current[senderId] = setTimeout(() => { - setReaction(""); - delete reactionTimeouts.current[senderId]; - }, 3000); - } else { - addReaction(senderId, reaction); - reactionTimeouts.current[senderId] = setTimeout(() => { - addReaction(senderId, ""); - delete reactionTimeouts.current[senderId]; - }, 3000); - } - }; - socket.on("all_users", handleAllUsers); socket.on("getOffer", handleGetOffer); socket.on("getAnswer", handleGetAnswer); @@ -250,93 +204,11 @@ export const useSession = (sessionId: string | undefined) => { handleHostChange, handleUserExit, handleRoomFinished, + handleReaction, ]); useEffect(() => { const cleanup = setupSocketListeners(); return () => cleanup?.(); }, [setupSocketListeners]); - - const joinRoom = async () => { - if (!socket) { - toast.error("소켓 연결이 필요합니다."); - return; - } - - if (!sessionId) { - toast.error("세션 ID가 필요합니다."); - return; - } - - if (!nickname) { - toast.error("닉네임을 입력해주세요."); - return; - } - - const mediaStream = await getMedia(); - if (!mediaStream) { - toast.error( - "미디어 스트림을 가져오지 못했습니다. 미디어 장치를 확인 후 다시 시도해주세요." - ); - navigate("/sessions"); - return; - } - - socket.emit("join_room", { roomId: sessionId, nickname }); - }; - - const emitReaction = useCallback( - (reactionType: string) => { - if (socket) { - socket.emit("reaction", { - roomId: sessionId, - reaction: reactionType, - }); - } - }, - [socket, sessionId] - ); - - const addReaction = useCallback( - (senderId: string, reactionType: string) => { - setPeers((prev) => - prev.map((peer) => - peer.peerId === senderId ? { ...peer, reaction: reactionType } : peer - ) - ); - }, - [setPeers] - ); - - const participants: Participant[] = useMemo( - () => [ - { nickname, isHost }, - ...peers.map((peer) => ({ - nickname: peer.peerNickname, - isHost: peer.isHost || false, - })), - ], - [nickname, isHost, peers] - ); - - return { - nickname, - setNickname, - reaction, - peers, - userVideoDevices, - userAudioDevices, - isVideoOn, - isMicOn, - stream, - roomMetadata, - isHost, - participants, - handleMicToggle, - handleVideoToggle, - setSelectedAudioDeviceId, - setSelectedVideoDeviceId, - joinRoom, - emitReaction, - }; }; diff --git a/frontend/src/hooks/type/session.d.ts b/frontend/src/hooks/type/session.d.ts index e0281334..48413f0e 100644 --- a/frontend/src/hooks/type/session.d.ts +++ b/frontend/src/hooks/type/session.d.ts @@ -25,6 +25,15 @@ export interface ResponseMasterChanged { } export interface Participant { + id?: string; nickname: string; isHost: boolean; } + +export interface PeerConnection { + peerId: string; // 연결된 상대의 ID + peerNickname: string; // 상대의 닉네임 + stream: MediaStream; // 상대방의 비디오/오디오 스트림 + isHost?: boolean; // 호스트 여부 + reaction?: string; +} diff --git a/frontend/src/pages/QuestionListPage.tsx b/frontend/src/pages/QuestionListPage.tsx index 817fc050..3cfd6a6e 100644 --- a/frontend/src/pages/QuestionListPage.tsx +++ b/frontend/src/pages/QuestionListPage.tsx @@ -10,7 +10,6 @@ import { useNavigate } from "react-router-dom"; import axios from "axios"; import useAuth from "@hooks/useAuth.ts"; - interface QuestionList { id: number; title: string; diff --git a/frontend/src/pages/SessionPage.tsx b/frontend/src/pages/SessionPage.tsx index c13d08dc..dbec0150 100644 --- a/frontend/src/pages/SessionPage.tsx +++ b/frontend/src/pages/SessionPage.tsx @@ -1,13 +1,14 @@ -import VideoContainer from "@/components/session/VideoContainer.tsx"; +import VideoContainer from "@components/session/VideoContainer.tsx"; import { useParams } from "react-router-dom"; -import SessionSidebar from "@/components/session/SessionSidebar.tsx"; -import SessionToolbar from "@/components/session/SessionToolbar.tsx"; -import { useSession } from "@/hooks/useSession"; -import useSocket from "@/hooks/useSocket"; -import SessionHeader from "@/components/session/SessionHeader"; +import SessionSidebar from "@components/session/SessionSidebar.tsx"; +import SessionToolbar from "@components/session/SessionToolbar.tsx"; +import { useSession } from "@hooks/session/useSession"; +import useSocket from "@hooks/useSocket"; +import SessionHeader from "@components/session/SessionHeader"; const SessionPage = () => { const { sessionId } = useParams(); + if (!sessionId) return null; const { socket } = useSocket(); const { nickname, diff --git a/frontend/src/stores/useSocketStore.ts b/frontend/src/stores/useSocketStore.ts index 79017aeb..e7f46a3f 100644 --- a/frontend/src/stores/useSocketStore.ts +++ b/frontend/src/stores/useSocketStore.ts @@ -10,7 +10,7 @@ interface SocketStore { const useSocketStore = create((set) => ({ socket: null, connect: (socketURL) => { - const newSocket = io(socketURL || "http://localhost:3000"); + const newSocket = io(socketURL); newSocket.on("connect", socketConnectHandler); newSocket.on("connect_error", socketErrorHandler); diff --git a/frontend/tsconfig.app.json b/frontend/tsconfig.app.json index e98ae452..3d08590c 100644 --- a/frontend/tsconfig.app.json +++ b/frontend/tsconfig.app.json @@ -1,6 +1,9 @@ { "compilerOptions": { - "typeRoots": ["src/types", "node_modules/@types"], + "typeRoots": [ + "src/types", + "node_modules/@types" + ], "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", "target": "ES2020", "useDefineForClassFields": true, @@ -40,10 +43,12 @@ ] }, "types": [ - "node", "vite/client" + "node", + "vite/client" ] }, "include": [ - "src", "vite-env.d.ts" + "src", + "vite-env.d.ts" ] } \ No newline at end of file diff --git a/frontend/tsconfig.test.json b/frontend/tsconfig.test.json index f7f602ea..dfc2ffa0 100644 --- a/frontend/tsconfig.test.json +++ b/frontend/tsconfig.test.json @@ -11,7 +11,16 @@ "moduleResolution": "node", "paths": { "@/*": [ - "./src/*" + "src/*" + ], + "@components/*": [ + "src/components/*" + ], + "@hooks/*": [ + "src/hooks/*" + ], + "@stores/*": [ + "src/stores/*" ] } },