diff --git a/apps/backend/.eslintrc.js b/apps/backend/.eslintrc.js index 259de13c..465fcf8d 100644 --- a/apps/backend/.eslintrc.js +++ b/apps/backend/.eslintrc.js @@ -22,4 +22,10 @@ module.exports = { '@typescript-eslint/explicit-module-boundary-types': 'off', '@typescript-eslint/no-explicit-any': 'off', }, + 'prettier/prettier': [ + 'error', + { + endOfLine: 'auto', + }, + ], }; diff --git a/apps/backend/src/app.module.ts b/apps/backend/src/app.module.ts index 720b1d37..28503463 100644 --- a/apps/backend/src/app.module.ts +++ b/apps/backend/src/app.module.ts @@ -20,7 +20,6 @@ import { WorkspaceModule } from './workspace/workspace.module'; import { RoleModule } from './role/role.module'; import { TasksModule } from './tasks/tasks.module'; import { ScheduleModule } from '@nestjs/schedule'; -import { RedLockModule } from './red-lock/red-lock.module'; @Module({ imports: [ @@ -54,7 +53,6 @@ import { RedLockModule } from './red-lock/red-lock.module'; WorkspaceModule, RoleModule, TasksModule, - RedLockModule, ], controllers: [AppController], providers: [AppService], diff --git a/apps/backend/src/page/page.module.ts b/apps/backend/src/page/page.module.ts index 74d30922..743427e3 100644 --- a/apps/backend/src/page/page.module.ts +++ b/apps/backend/src/page/page.module.ts @@ -6,14 +6,12 @@ import { Page } from './page.entity'; import { PageRepository } from './page.repository'; import { NodeModule } from '../node/node.module'; import { WorkspaceModule } from '../workspace/workspace.module'; -import { RedLockModule } from '../red-lock/red-lock.module'; @Module({ imports: [ TypeOrmModule.forFeature([Page]), forwardRef(() => NodeModule), WorkspaceModule, - RedLockModule, ], controllers: [PageController], providers: [PageService, PageRepository], diff --git a/apps/backend/src/page/page.service.ts b/apps/backend/src/page/page.service.ts index 6fd5eab6..d1568825 100644 --- a/apps/backend/src/page/page.service.ts +++ b/apps/backend/src/page/page.service.ts @@ -8,7 +8,6 @@ import { UpdatePageDto } from './dtos/updatePage.dto'; import { UpdatePartialPageDto } from './dtos/updatePartialPage.dto'; import { PageNotFoundException } from '../exception/page.exception'; import { WorkspaceNotFoundException } from '../exception/workspace.exception'; -import Redlock from 'redlock'; const RED_LOCK_TOKEN = 'RED_LOCK'; @@ -18,7 +17,6 @@ export class PageService { private readonly pageRepository: PageRepository, private readonly nodeRepository: NodeRepository, private readonly workspaceRepository: WorkspaceRepository, - @Inject(RED_LOCK_TOKEN) private readonly redisLock: Redlock, ) {} /** * redis에 저장된 페이지 정보를 다음 과정을 통해 주기적으로 데이터베이스에 반영한다. @@ -26,12 +24,6 @@ export class PageService { * 1. redis에서 해당 페이지의 title과 content를 가져온다. * 2. 데이터베이스에 해당 페이지의 title과 content를 갱신한다. * 3. redis에서 해당 페이지 정보를 삭제한다. - * - * 만약 1번 과정을 진행한 상태에서 page가 삭제된다면 오류가 발생한다. - * 위 과정을 진행하는 동안 page 정보 수정을 막기 위해 lock을 사용한다. - * - * 동기화를 위해 기존 페이지에 접근하여 수정하는 로직은 RedLock 알고리즘을 통해 락을 획득할 수 있을 때만 수행한다. - * 기존 페이지에 접근하여 연산하는 로직의 경우 RedLock 알고리즘을 사용하여 동시 접근을 방지한다. */ async createPage(dto: CreatePageDto): Promise { const { title, content, workspaceId, x, y, emoji } = dto; @@ -62,42 +54,29 @@ export class PageService { } async deletePage(id: number): Promise { - // 락을 획득할 때까지 기다린다. - const lock = await this.redisLock.acquire([`user:${id.toString()}`], 1000); - try { - // 페이지를 삭제한다. - const deleteResult = await this.pageRepository.delete(id); - - // 만약 삭제된 페이지가 없으면 페이지를 찾지 못한 것 - if (!deleteResult.affected) { - throw new PageNotFoundException(); - } - } finally { - // 락을 해제한다. - await lock.release(); + // 페이지를 삭제한다. + const deleteResult = await this.pageRepository.delete(id); + + // 만약 삭제된 페이지가 없으면 페이지를 찾지 못한 것 + if (!deleteResult.affected) { + throw new PageNotFoundException(); } } async updatePage(id: number, dto: UpdatePageDto): Promise { - // 락을 획득할 때까지 기다린다. - const lock = await this.redisLock.acquire([`user:${id.toString()}`], 1000); - try { - // 갱신할 페이지를 조회한다. - // 페이지를 조회한다. - const page = await this.pageRepository.findOneBy({ id }); - - // 페이지가 없으면 NotFound 에러 - if (!page) { - throw new PageNotFoundException(); - } - // 페이지 정보를 갱신한다. - const newPage = Object.assign({}, page, dto); - - // 변경된 페이지를 저장 - return await this.pageRepository.save(newPage); - } finally { - await lock.release(); + // 갱신할 페이지를 조회한다. + // 페이지를 조회한다. + const page = await this.pageRepository.findOneBy({ id }); + + // 페이지가 없으면 NotFound 에러 + if (!page) { + throw new PageNotFoundException(); } + // 페이지 정보를 갱신한다. + const newPage = Object.assign({}, page, dto); + + // 변경된 페이지를 저장 + return await this.pageRepository.save(newPage); } async updateBulkPage(pages: UpdatePartialPageDto[]) { diff --git a/apps/backend/src/red-lock/red-lock.module.ts b/apps/backend/src/red-lock/red-lock.module.ts deleted file mode 100644 index d2d9f574..00000000 --- a/apps/backend/src/red-lock/red-lock.module.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { Module, forwardRef } from '@nestjs/common'; -import Redis from 'ioredis'; -import Redlock from 'redlock'; -import { RedisModule } from '../redis/redis.module'; -const RED_LOCK_TOKEN = 'RED_LOCK'; -const REDIS_CLIENT_TOKEN = 'REDIS_CLIENT'; - -@Module({ - imports: [forwardRef(() => RedisModule)], - providers: [ - { - provide: RED_LOCK_TOKEN, - useFactory: (redisClient: Redis) => { - return new Redlock([redisClient], { - driftFactor: 0.01, - retryCount: 10, - retryDelay: 200, - retryJitter: 200, - automaticExtensionThreshold: 500, - }); - }, - inject: [REDIS_CLIENT_TOKEN], - }, - ], - exports: [RED_LOCK_TOKEN], -}) -export class RedLockModule {} diff --git a/apps/backend/src/redis/redis.module.ts b/apps/backend/src/redis/redis.module.ts index 152d05b3..666cd746 100644 --- a/apps/backend/src/redis/redis.module.ts +++ b/apps/backend/src/redis/redis.module.ts @@ -1,14 +1,13 @@ -import { Module, forwardRef } from '@nestjs/common'; +import { Module } from '@nestjs/common'; import { ConfigModule, ConfigService } from '@nestjs/config'; import { RedisService } from './redis.service'; import Redis from 'ioredis'; -import { RedLockModule } from '../red-lock/red-lock.module'; // 의존성 주입할 때 redis client를 식별할 토큰 const REDIS_CLIENT_TOKEN = 'REDIS_CLIENT'; @Module({ - imports: [ConfigModule, forwardRef(() => RedLockModule)], // ConfigModule 추가 + imports: [ConfigModule], // ConfigModule 추가 providers: [ RedisService, { diff --git a/apps/backend/src/redis/redis.service.ts b/apps/backend/src/redis/redis.service.ts index de1a41f3..27f3431d 100644 --- a/apps/backend/src/redis/redis.service.ts +++ b/apps/backend/src/redis/redis.service.ts @@ -1,7 +1,6 @@ import { Injectable } from '@nestjs/common'; import { Inject } from '@nestjs/common'; import Redis from 'ioredis'; -import Redlock from 'redlock'; const REDIS_CLIENT_TOKEN = 'REDIS_CLIENT'; const RED_LOCK_TOKEN = 'RED_LOCK'; @@ -18,16 +17,15 @@ export type RedisNode = { }; export type RedisEdge = { - fromNode: number; - toNode: number; - type: 'add' | 'delete'; + fromNode?: number; + toNode?: number; + type?: 'add' | 'delete'; }; @Injectable() export class RedisService { constructor( @Inject(REDIS_CLIENT_TOKEN) private readonly redisClient: Redis, - @Inject(RED_LOCK_TOKEN) private readonly redisLock: Redlock, ) {} async getAllKeys(pattern) { @@ -46,32 +44,14 @@ export class RedisService { } async set(key: string, value: object) { - // 락을 획득할 때까지 기다린다. - const lock = await this.redisLock.acquire([`user:${key}`], 1000); - try { - await this.redisClient.hset(key, Object.entries(value)); - } finally { - lock.release(); - } + await this.redisClient.hset(key, Object.entries(value)); } async setField(key: string, field: string, value: string) { - // 락을 획득할 때까지 기다린다. - const lock = await this.redisLock.acquire([`user:${key}`], 1000); - try { - return await this.redisClient.hset(key, field, value); - } finally { - lock.release(); - } + return await this.redisClient.hset(key, field, value); } async delete(key: string) { - // 락을 획득할 때까지 기다린다. - const lock = await this.redisLock.acquire([`user:${key}`], 1000); - try { - return await this.redisClient.del(key); - } finally { - lock.release(); - } + return await this.redisClient.del(key); } } diff --git a/apps/backend/src/tasks/tasks.service.ts b/apps/backend/src/tasks/tasks.service.ts index b74a6a90..c9deabee 100644 --- a/apps/backend/src/tasks/tasks.service.ts +++ b/apps/backend/src/tasks/tasks.service.ts @@ -11,23 +11,28 @@ import { InjectDataSource } from '@nestjs/typeorm'; import { Page } from '../page/page.entity'; import { Node } from '../node/node.entity'; import { Edge } from '../edge/edge.entity'; +import { Inject } from '@nestjs/common'; +import Redis from 'ioredis'; + +const REDIS_CLIENT_TOKEN = 'REDIS_CLIENT'; @Injectable() export class TasksService { private readonly logger = new Logger(TasksService.name); constructor( - private readonly redisService: RedisService, + @Inject(REDIS_CLIENT_TOKEN) private readonly redisClient: Redis, @InjectDataSource() private readonly dataSource: DataSource, ) {} - @Cron(CronExpression.EVERY_10_SECONDS) + @Cron(CronExpression.EVERY_30_SECONDS) async handleCron() { this.logger.log('스케줄러 시작'); // 시작 시간 const startTime = performance.now(); - const pageKeys = await this.redisService.getAllKeys('page:*'); - const nodeKeys = await this.redisService.getAllKeys('node:*'); - const edgeKeys = await this.redisService.getAllKeys('edge:*'); + + const pageKeys = await this.redisClient.keys('page:*'); + const nodeKeys = await this.redisClient.keys('node:*'); + const edgeKeys = await this.redisClient.keys('edge:*'); Promise.allSettled([ ...pageKeys.map(this.migratePage.bind(this)), @@ -51,7 +56,12 @@ export class TasksService { } async migratePage(key: string) { - const redisData = (await this.redisService.get(key)) as RedisPage; + // 낙관적 락 적용 + await this.redisClient.watch(key); + const data = await this.redisClient.hgetall(key); + const redisData = Object.fromEntries( + Object.entries(data).map(([field, value]) => [field, value]), + ) as RedisPage; // 데이터 없으면 오류 if (!redisData) { throw new Error(`redis에 ${key}에 해당하는 데이터가 없습니다.`); @@ -71,6 +81,7 @@ export class TasksService { // 트랜잭션 시작 const queryRunner = this.dataSource.createQueryRunner(); + const redisRunner = this.redisClient.multi(); try { await queryRunner.startTransaction(); @@ -81,20 +92,16 @@ export class TasksService { await pageRepository.update(pageId, updateData); // redis에서 데이터 삭제 - await this.redisService.delete(key); + redisRunner.del(key); // 트랜잭션 커밋 await queryRunner.commitTransaction(); + await redisRunner.exec(); } catch (err) { // 실패하면 postgres는 roll back하고 redis의 값을 살린다. this.logger.error(err.stack); await queryRunner.rollbackTransaction(); - updateData.title && - (await this.redisService.setField(key, 'title', updateData.title)); - updateData.content && - (await this.redisService.setField(key, 'content', JSON.parse(content))); - updateData.emoji && - (await this.redisService.setField(key, 'emoji', updateData.emoji)); + redisRunner.discard(); // Promise.all에서 실패를 인식하기 위해 에러를 던진다. throw err; @@ -105,9 +112,12 @@ export class TasksService { } async migrateNode(key: string) { - const redisData = (await this.redisService.get( - key, - )) as unknown as RedisNode; + // 낙관적 락 적용 + await this.redisClient.watch(key); + const data = await this.redisClient.hgetall(key); + const redisData = Object.fromEntries( + Object.entries(data).map(([field, value]) => [field, value]), + ) as RedisNode; // 데이터 없으면 오류 if (!redisData) { throw new Error(`redis에 ${key}에 해당하는 데이터가 없습니다.`); @@ -126,6 +136,7 @@ export class TasksService { // 트랜잭션 시작 const queryRunner = this.dataSource.createQueryRunner(); + const redisRunner = this.redisClient.multi(); try { await queryRunner.startTransaction(); @@ -136,20 +147,16 @@ export class TasksService { await nodeRepository.update(nodeId, updateData); // redis에서 데이터 삭제 - await this.redisService.delete(key); + redisRunner.del(key); // 트랜잭션 커밋 await queryRunner.commitTransaction(); + await redisRunner.exec(); } catch (err) { // 실패하면 postgres는 roll back하고 redis의 값을 살린다. this.logger.error(err.stack); await queryRunner.rollbackTransaction(); - updateData.x && - (await this.redisService.setField(key, 'x', updateData.x.toString())); - updateData.y && - (await this.redisService.setField(key, 'y', updateData.y.toString())); - updateData.color && - (await this.redisService.setField(key, 'color', updateData.color)); + redisRunner.discard(); // Promise.all에서 실패를 인식하기 위해 에러를 던진다. throw err; @@ -160,9 +167,13 @@ export class TasksService { } async migrateEdge(key: string) { - const redisData = (await this.redisService.get( - key, - )) as unknown as RedisEdge; + // 낙관적 락 적용 + await this.redisClient.watch(key); + const data = await this.redisClient.hgetall(key); + const redisData = Object.fromEntries( + Object.entries(data).map(([field, value]) => [field, value]), + ) as RedisEdge; + // 데이터 없으면 오류 if (!redisData) { throw new Error(`redis에 ${key}에 해당하는 데이터가 없습니다.`); @@ -170,6 +181,8 @@ export class TasksService { // 트랜잭션 시작 const queryRunner = this.dataSource.createQueryRunner(); + const redisRunner = this.redisClient.multi(); + try { await queryRunner.startTransaction(); @@ -194,34 +207,28 @@ export class TasksService { }); } - if (redisData.type === 'delete') { - const edge = await edgeRepository.findOne({ - where: { fromNode, toNode }, - }); + // if (redisData.type === 'delete') { + // const edge = await edgeRepository.findOne({ + // where: { fromNode, toNode }, + // }); + // console.log(`edge 정보 `); + // console.log(edge); + // console.log(`edge content : ${edge}`); - await edgeRepository.delete({ id: edge.id }); - } + // await edgeRepository.delete({ id: edge.id }); + // } // redis에서 데이터 삭제 - await this.redisService.delete(key); + redisRunner.del(key); // 트랜잭션 커밋 await queryRunner.commitTransaction(); + await redisRunner.exec(); } catch (err) { // 실패하면 postgres는 roll back하고 redis의 값을 살린다. this.logger.error(err.stack); await queryRunner.rollbackTransaction(); - await this.redisService.setField( - key, - 'fromNode', - redisData.fromNode.toString(), - ); - await this.redisService.setField( - key, - 'toNode', - redisData.toNode.toString(), - ); - await this.redisService.setField(key, 'type', redisData.type); + redisRunner.discard(); // Promise.all에서 실패를 인식하기 위해 에러를 던진다. throw err; diff --git a/apps/frontend/src/app/App.tsx b/apps/frontend/src/app/App.tsx index 59ef63f0..ad0c79e7 100644 --- a/apps/frontend/src/app/App.tsx +++ b/apps/frontend/src/app/App.tsx @@ -1,15 +1,19 @@ +import { lazy, Suspense } from "react"; import { useSyncedUsers } from "@/entities/user"; import { useProtectedWorkspace } from "@/features/workspace"; import { CanvasView } from "@/widgets/CanvasView"; -import { EditorView } from "@/widgets/EditorView"; -import { NodeToolsView } from "@/widgets/NodeToolsView"; import { PageSideBarView } from "@/widgets/PageSideBarView"; import { CanvasToolsView } from "@/widgets/CanvasToolsView"; -import { SideWrapper } from "@/shared/ui"; +import { SideWrapper, Skeleton } from "@/shared/ui"; +import { usePageStore } from "@/entities/page"; + +const EditorView = lazy(() => import("@/widgets/EditorView")); +const NodeToolsView = lazy(() => import("@/widgets/NodeToolsView")); function App() { useSyncedUsers(); const { isLoading } = useProtectedWorkspace(); + const { currentPage } = usePageStore(); if (isLoading) { return ( @@ -21,9 +25,17 @@ function App() { return (
- - - + {currentPage && ( + + + } + > + + + + )} - + {currentPage && ( + } + > + + + )}
); diff --git a/apps/frontend/src/entities/page/api/pageApi.ts b/apps/frontend/src/entities/page/api/pageApi.ts index dccb3a06..42828580 100644 --- a/apps/frontend/src/entities/page/api/pageApi.ts +++ b/apps/frontend/src/entities/page/api/pageApi.ts @@ -1,26 +1,5 @@ -import { Get, Post, Delete, Patch } from "@/shared/api"; -import { - GetPageResponse, - GetPagesResponse, - CreatePageRequest, - CreatePageResponse, - UpdatePageRequest, -} from "../model/pageTypes"; - -export const getPage = async (id: number) => { - const url = `/api/page/${id}`; - - const res = await Get(url); - return res.data.page; -}; - -// TODO: 임시 -export const getPages = async (workspaceId: string) => { - const url = `/api/page/workspace/${workspaceId}`; - - const res = await Get(url); - return res.data.pages; -}; +import { Post, Delete } from "@/shared/api"; +import { CreatePageRequest, CreatePageResponse } from "../model/pageTypes"; export const createPage = async (pageData: CreatePageRequest) => { const url = `/api/page`; @@ -35,10 +14,3 @@ export const deletePage = async (id: number) => { const res = await Delete(url); return res.data; }; - -export const updatePage = async (id: number, pageData: UpdatePageRequest) => { - const url = `/api/page/${id}`; - - const res = await Patch(url, pageData); - return res.data; -}; diff --git a/apps/frontend/src/entities/page/index.ts b/apps/frontend/src/entities/page/index.ts index 6fcbb829..d6381d2b 100644 --- a/apps/frontend/src/entities/page/index.ts +++ b/apps/frontend/src/entities/page/index.ts @@ -1,10 +1,4 @@ -export { - getPage, - getPages, - createPage, - deletePage, - updatePage, -} from "./api/pageApi"; +export { createPage, deletePage } from "./api/pageApi"; export { useCreatePage, useDeletePage } from "./model/pageMutations"; export { usePageStore } from "./model/pageStore"; diff --git a/apps/frontend/src/entities/page/model/pageTypes.ts b/apps/frontend/src/entities/page/model/pageTypes.ts index 779ce901..2e42860a 100644 --- a/apps/frontend/src/entities/page/model/pageTypes.ts +++ b/apps/frontend/src/entities/page/model/pageTypes.ts @@ -7,16 +7,6 @@ export interface Page { emoji: string | null; } -export interface GetPageResponse { - message: string; - page: Page; -} - -export interface GetPagesResponse { - message: string; - pages: Omit[]; -} - export interface CreatePageRequest { title: string; content: JSONContent; @@ -30,9 +20,3 @@ export interface CreatePageResponse { message: string; pageId: number; } - -export interface UpdatePageRequest { - title: string; - content: JSONContent; - emoji: string | null; -} diff --git a/apps/frontend/src/entities/user/model/useUserConnection.ts b/apps/frontend/src/entities/user/model/useUserConnection.ts index ac9e67cb..6e2483c1 100644 --- a/apps/frontend/src/entities/user/model/useUserConnection.ts +++ b/apps/frontend/src/entities/user/model/useUserConnection.ts @@ -1,4 +1,3 @@ -// provider: createSocketIOProvider("users", new Y.Doc()), import * as Y from "yjs"; import { createSocketIOProvider } from "@/shared/api"; import useConnectionStore from "@/shared/model/useConnectionStore"; diff --git a/apps/frontend/src/features/canvas/model/calculateHandles.ts b/apps/frontend/src/features/canvas/model/calculateHandles.ts index 71655bb4..43f032c6 100644 --- a/apps/frontend/src/features/canvas/model/calculateHandles.ts +++ b/apps/frontend/src/features/canvas/model/calculateHandles.ts @@ -1,6 +1,6 @@ import { Position, Node } from "@xyflow/react"; -export const getHandlePosition = (node: Node, handleId: Position) => { +const getHandlePosition = (node: Node, handleId: Position) => { const nodeElement = document.querySelector(`[data-id="${node.id}"]`); const nodeRect = nodeElement!.getBoundingClientRect(); const nodeWidth = nodeRect.width; diff --git a/apps/frontend/src/features/canvas/model/useCanvas.ts b/apps/frontend/src/features/canvas/model/useCanvas.ts index 8fe33000..55430844 100644 --- a/apps/frontend/src/features/canvas/model/useCanvas.ts +++ b/apps/frontend/src/features/canvas/model/useCanvas.ts @@ -326,7 +326,6 @@ export const useCanvas = () => { nodes, edges, users, - setCurrentPage, handleMouseMove, handleNodesChange, handleEdgesChange, diff --git a/apps/frontend/src/features/canvas/ui/Canvas/index.tsx b/apps/frontend/src/features/canvas/ui/Canvas/index.tsx index ac9b1269..b95c0924 100644 --- a/apps/frontend/src/features/canvas/ui/Canvas/index.tsx +++ b/apps/frontend/src/features/canvas/ui/Canvas/index.tsx @@ -60,7 +60,7 @@ export function Canvas({ className }: CanvasProps) { }, [users]); return ( -
+
void; } -export function ProfilePanel({ +export default function ProfilePanel({ color, clientId, onColorChange, @@ -22,7 +22,7 @@ export function ProfilePanel({ }; return ( -
+
{ diff --git a/apps/frontend/src/features/editor/ui/Editor/extensions.ts b/apps/frontend/src/features/editor/ui/Editor/extensions.ts index 1a4b3d84..f9f834f4 100644 --- a/apps/frontend/src/features/editor/ui/Editor/extensions.ts +++ b/apps/frontend/src/features/editor/ui/Editor/extensions.ts @@ -25,9 +25,7 @@ import { UploadImagesPlugin } from "novel/plugins"; import { cx } from "class-variance-authority"; import { common, createLowlight } from "lowlight"; -//TODO I am using cx here to get tailwind autocomplete working, idk if someone else can write a regex to just capture the class key in objects const aiHighlight = AIHighlight; -//You can overwrite the placeholder with your own configuration const placeholder = Placeholder; const tiptapLink = TiptapLink.configure({ HTMLAttributes: { @@ -119,8 +117,6 @@ const starterKit = StarterKit.configure({ }); const codeBlockLowlight = CodeBlockLowlight.configure({ - // configure lowlight: common / all / use highlightJS in case there is a need to specify certain language grammars only - // common: covers 37 language grammars which should be good enough in most cases lowlight: createLowlight(common), }); diff --git a/apps/frontend/src/features/editor/ui/Editor/index.tsx b/apps/frontend/src/features/editor/ui/Editor/index.tsx index 403a0dee..5f3d7035 100644 --- a/apps/frontend/src/features/editor/ui/Editor/index.tsx +++ b/apps/frontend/src/features/editor/ui/Editor/index.tsx @@ -7,7 +7,6 @@ import { type JSONContent, EditorCommandList, EditorBubble, - type EditorInstance, } from "novel"; import { ImageResizer, handleCommandNavigation } from "novel/extensions"; import Collaboration from "@tiptap/extension-collaboration"; @@ -28,19 +27,14 @@ import { ColorSelector } from "./selectors/color-selector"; import { uploadFn } from "../../model/upload"; import { useEditor } from "../../model/useEditor"; -type EditorUpdateEvent = { - editor: EditorInstance; -}; - interface EditorProp { pageId: number; initialContent?: JSONContent; - onEditorUpdate?: (event: EditorUpdateEvent) => void; ydoc: Y.Doc; provider: SocketIOProvider; } -export function Editor({ onEditorUpdate, ydoc, provider }: EditorProp) { +export function Editor({ ydoc, provider }: EditorProp) { const { openNode, openColor, @@ -55,7 +49,6 @@ export function Editor({ onEditorUpdate, ydoc, provider }: EditorProp) { return ( { disableCollaboration(); @@ -81,7 +74,6 @@ export function Editor({ onEditorUpdate, ydoc, provider }: EditorProp) { }, }} slotAfter={} - onUpdate={onEditorUpdate} > @@ -114,15 +106,15 @@ export function Editor({ onEditorUpdate, ydoc, provider }: EditorProp) { }} className="flex w-fit max-w-[90vw] overflow-hidden rounded-md border border-muted bg-background shadow-xl" > - + - + - + - + - + diff --git a/apps/frontend/src/features/editor/ui/Editor/selectors/color-selector.tsx b/apps/frontend/src/features/editor/ui/Editor/selectors/color-selector.tsx index f5ebf3e4..bd5ee44d 100644 --- a/apps/frontend/src/features/editor/ui/Editor/selectors/color-selector.tsx +++ b/apps/frontend/src/features/editor/ui/Editor/selectors/color-selector.tsx @@ -4,7 +4,7 @@ import { EditorBubbleItem, useEditor } from "novel"; import { Button } from "../ui/button"; import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover"; -export interface BubbleColorMenuItem { +interface BubbleColorMenuItem { name: string; color: string; } diff --git a/apps/frontend/src/features/editor/ui/Editor/selectors/link-selector.tsx b/apps/frontend/src/features/editor/ui/Editor/selectors/link-selector.tsx index 6fcae9c0..c9c5cfec 100644 --- a/apps/frontend/src/features/editor/ui/Editor/selectors/link-selector.tsx +++ b/apps/frontend/src/features/editor/ui/Editor/selectors/link-selector.tsx @@ -7,7 +7,7 @@ import { Button } from "../ui/button"; import { PopoverContent } from "../ui/popover"; import { cn } from "@/shared/lib"; -export function isValidUrl(url: string) { +function isValidUrl(url: string) { try { new URL(url); return true; @@ -15,7 +15,7 @@ export function isValidUrl(url: string) { return false; } } -export function getUrlFromString(str: string) { +function getUrlFromString(str: string) { if (isValidUrl(str)) return str; try { if (str.includes(".") && !str.includes(" ")) { diff --git a/apps/frontend/src/features/editor/ui/Editor/selectors/node-selector.tsx b/apps/frontend/src/features/editor/ui/Editor/selectors/node-selector.tsx index 0655e740..f620ec05 100644 --- a/apps/frontend/src/features/editor/ui/Editor/selectors/node-selector.tsx +++ b/apps/frontend/src/features/editor/ui/Editor/selectors/node-selector.tsx @@ -21,7 +21,6 @@ const items: SelectorItem[] = [ name: "Text", icon: TextIcon, command: (editor) => editor.chain().focus().clearNodes().run(), - // I feel like there has to be a more efficient way to do this – feel free to PR if you know how! isActive: (editor) => editor.isActive("paragraph") && !editor.isActive("bulletList") && diff --git a/apps/frontend/src/features/editor/ui/Editor/ui/menu.tsx b/apps/frontend/src/features/editor/ui/Editor/ui/menu.tsx index 6a661e7a..d5eb6330 100644 --- a/apps/frontend/src/features/editor/ui/Editor/ui/menu.tsx +++ b/apps/frontend/src/features/editor/ui/Editor/ui/menu.tsx @@ -5,21 +5,6 @@ import { useTheme } from "next-themes"; import { Button } from "./button"; import { Popover, PopoverContent, PopoverTrigger } from "./popover"; -// TODO implement multiple fonts editor -// const fonts = [ -// { -// font: "Default", -// icon: , -// }, -// { -// font: "Serif", -// icon: , -// }, -// { -// font: "Mono", -// icon: , -// }, -// ]; const appearances = [ { theme: "System", @@ -35,7 +20,6 @@ const appearances = [ }, ]; export default function Menu() { - // const { font: currentFont, setFont } = useContext(AppContext); const { theme: currentTheme, setTheme } = useTheme(); return ( @@ -46,27 +30,7 @@ export default function Menu() { - {/*
-

Font

- {fonts.map(({ font, icon }) => ( - - ))} -
*/} -

+

Appearance

{appearances.map(({ theme, icon }) => ( diff --git a/apps/frontend/src/features/editor/ui/Editor/ui/separator.tsx b/apps/frontend/src/features/editor/ui/Editor/ui/separator.tsx index e84f76bd..340ca8d2 100644 --- a/apps/frontend/src/features/editor/ui/Editor/ui/separator.tsx +++ b/apps/frontend/src/features/editor/ui/Editor/ui/separator.tsx @@ -10,7 +10,7 @@ const Separator = React.forwardRef< React.ComponentPropsWithoutRef >( ( - { className, orientation = "horizontal", decorative = true, ...props }, + { className, orientation = "vertical", decorative = true, ...props }, ref, ) => (
-
); diff --git a/apps/frontend/src/features/editor/ui/SaveStatus/index.tsx b/apps/frontend/src/features/editor/ui/SaveStatus/index.tsx deleted file mode 100644 index 8f355c73..00000000 --- a/apps/frontend/src/features/editor/ui/SaveStatus/index.tsx +++ /dev/null @@ -1,11 +0,0 @@ -export interface SaveStatusProps { - saveStatus: "saved" | "unsaved"; -} - -export default function SaveStatus({ saveStatus }: SaveStatusProps) { - return ( -
- {saveStatus} -
- ); -} diff --git a/apps/frontend/src/features/pageSidebar/index.ts b/apps/frontend/src/features/pageSidebar/index.ts index fd8d9168..aca4b26f 100644 --- a/apps/frontend/src/features/pageSidebar/index.ts +++ b/apps/frontend/src/features/pageSidebar/index.ts @@ -2,3 +2,4 @@ export { LogoBtn } from "./ui/LogoBtn"; export { NoteList } from "./ui/NoteList"; export { Tools } from "./ui/Tools"; export { WorkspaceNav } from "./ui/WorkspaceNav"; +export { default } from "./ui/PageListPanel"; diff --git a/apps/frontend/src/features/pageSidebar/model/useNoteList.ts b/apps/frontend/src/features/pageSidebar/model/useNoteList.ts index b40a7fe9..44e206e6 100644 --- a/apps/frontend/src/features/pageSidebar/model/useNoteList.ts +++ b/apps/frontend/src/features/pageSidebar/model/useNoteList.ts @@ -10,16 +10,25 @@ export const useNoteList = () => { const [pages, setPages] = useState(); const { canvas } = useConnectionStore(); - // TODO: 최적화 필요 useEffect(() => { if (!canvas.provider) return; const nodesMap = canvas.provider.doc.getMap("nodes"); - nodesMap.observe(() => { + const initializePages = () => { const yNodes = Array.from(nodesMap.values()) as Node[]; const data = yNodes.map((yNode) => yNode.data) as NoteNodeData[]; setPages(data); + }; + + initializePages(); + + nodesMap.observe(() => { + initializePages(); }); + + return () => { + nodesMap.unobserve(initializePages); + }; }, [canvas.provider]); const [noteIdToDelete, setNoteIdToDelete] = useState(null); diff --git a/apps/frontend/src/features/pageSidebar/ui/NoteList/index.tsx b/apps/frontend/src/features/pageSidebar/ui/NoteList/index.tsx index dcfaba9d..4032957f 100644 --- a/apps/frontend/src/features/pageSidebar/ui/NoteList/index.tsx +++ b/apps/frontend/src/features/pageSidebar/ui/NoteList/index.tsx @@ -19,10 +19,6 @@ export function NoteList({ className }: NoteListProps) { onCloseModal, } = useNoteList(); - if (!pages) { - return
로딩중
; - } - return (
- {pages.map(({ id, title, emoji }) => ( - - ))} + +
{title}
+ { + e.stopPropagation(); + openModal(id); + }} + > + + + + ))}
); } diff --git a/apps/frontend/src/features/pageSidebar/ui/PageListPanel/index.tsx b/apps/frontend/src/features/pageSidebar/ui/PageListPanel/index.tsx new file mode 100644 index 00000000..19f44fa4 --- /dev/null +++ b/apps/frontend/src/features/pageSidebar/ui/PageListPanel/index.tsx @@ -0,0 +1,15 @@ +import { NoteList, Tools } from "@/features/pageSidebar"; +import { ScrollWrapper } from "@/shared/ui"; + +export default function PageListPanel() { + return ( +
+
+ +
+ + + +
+ ); +} diff --git a/apps/frontend/src/features/workspace/api/workspaceApi.ts b/apps/frontend/src/features/workspace/api/workspaceApi.ts index fb304b9a..c1352f80 100644 --- a/apps/frontend/src/features/workspace/api/workspaceApi.ts +++ b/apps/frontend/src/features/workspace/api/workspaceApi.ts @@ -24,7 +24,6 @@ export const removeWorkspace = async (workspaceId: string) => { return res.data; }; -// TODO: /entities/user vs workspace 위치 고민해봐야할듯? export const getUserWorkspaces = async () => { const url = `${BASE_URL}/user`; @@ -38,7 +37,6 @@ export const getCurrentWorkspace = async ( ) => { const url = `${BASE_URL}/${workspaceId}/${userId}`; - // Response type 바꾸기 const res = await Get(url); return res.data; }; diff --git a/apps/frontend/src/features/workspace/model/workspaceMutations.ts b/apps/frontend/src/features/workspace/model/workspaceMutations.ts index 9e52b1f8..988b1ef9 100644 --- a/apps/frontend/src/features/workspace/model/workspaceMutations.ts +++ b/apps/frontend/src/features/workspace/model/workspaceMutations.ts @@ -11,8 +11,6 @@ import { } from "../api/worskspaceInviteApi"; import { useWorkspace } from "@/shared/lib"; -// response로 workspaceId가 오는데 userWorkspace를 어떻게 invalidate 할까? -// login state에 있는 userId로? export const useCreateWorkspace = () => { const queryClient = useQueryClient(); diff --git a/apps/frontend/src/features/workspace/ui/ShareTool/SharePanel.tsx b/apps/frontend/src/features/workspace/ui/ShareTool/SharePanel.tsx index 07de6fd2..aa515dd0 100644 --- a/apps/frontend/src/features/workspace/ui/ShareTool/SharePanel.tsx +++ b/apps/frontend/src/features/workspace/ui/ShareTool/SharePanel.tsx @@ -75,7 +75,7 @@ export function SharePanel() { const isDisabled = isGuest || isPending; return ( -
+
공개 범위
diff --git a/apps/frontend/src/features/workspace/ui/ShareTool/index.tsx b/apps/frontend/src/features/workspace/ui/ShareTool/index.tsx index 77366ec3..7d4fe5f7 100644 --- a/apps/frontend/src/features/workspace/ui/ShareTool/index.tsx +++ b/apps/frontend/src/features/workspace/ui/ShareTool/index.tsx @@ -1,6 +1,8 @@ +import { lazy, Suspense } from "react"; import { Sharebutton } from "./ShareButton"; -import { SharePanel } from "./SharePanel"; -import { Popover } from "@/shared/ui"; +import { Popover, Skeleton } from "@/shared/ui"; + +const SharePanel = lazy(() => import("./SharePanel")); export function ShareTool() { return ( @@ -9,8 +11,10 @@ export function ShareTool() { - - + + }> + +
diff --git a/apps/frontend/src/features/workspace/ui/WorkspaceList/WorkspaceRemoveModal/index.tsx b/apps/frontend/src/features/workspace/ui/WorkspaceList/WorkspaceRemoveModal/index.tsx index 6f3c1ebb..b1e0ebf1 100644 --- a/apps/frontend/src/features/workspace/ui/WorkspaceList/WorkspaceRemoveModal/index.tsx +++ b/apps/frontend/src/features/workspace/ui/WorkspaceList/WorkspaceRemoveModal/index.tsx @@ -7,7 +7,6 @@ type WorkspaceRemoveModalProps = { onCloseModal: () => void; }; -// TODO: RemoveModal도 리팩토링해도 될듯? export function WorkspaceRemoveModal({ isOpen, onConfirm, diff --git a/apps/frontend/src/features/workspace/ui/WorkspacePanel/index.tsx b/apps/frontend/src/features/workspace/ui/WorkspacePanel/index.tsx new file mode 100644 index 00000000..67d89d18 --- /dev/null +++ b/apps/frontend/src/features/workspace/ui/WorkspacePanel/index.tsx @@ -0,0 +1,44 @@ +import { useState } from "react"; +import { UserProfile } from "@/entities/user"; +import { Logout, useGetUser, LoginForm } from "@/features/auth"; +import { + WorkspaceAddButton, + WorkspaceForm, + WorkspaceList, +} from "@/features/workspace"; +import { Divider } from "@/shared/ui"; + +export default function WorkspacePanel() { + const { data } = useGetUser(); + const [isModalOpen, setIsModalOpen] = useState(false); + + const onOpenModal = () => { + setIsModalOpen(true); + }; + + const onCloseModal = () => { + setIsModalOpen(false); + }; + return ( +
+ {data ? ( +
+ + + + + +
+ + +
+
+ ) : ( + + )} +
+ ); +} diff --git a/apps/frontend/src/shared/ui/Emoji/index.tsx b/apps/frontend/src/shared/ui/Emoji/index.tsx index 898ae543..359f5f60 100644 --- a/apps/frontend/src/shared/ui/Emoji/index.tsx +++ b/apps/frontend/src/shared/ui/Emoji/index.tsx @@ -14,11 +14,11 @@ export function Emoji({ emoji, width, height, fontSize }: EmojiProps) { if (!emoji) return ( ); - return
{emoji}
; + return
{emoji}
; } diff --git a/apps/frontend/src/shared/ui/Skeleton/index.tsx b/apps/frontend/src/shared/ui/Skeleton/index.tsx new file mode 100644 index 00000000..eb54a9f3 --- /dev/null +++ b/apps/frontend/src/shared/ui/Skeleton/index.tsx @@ -0,0 +1,16 @@ +import { cn } from "@/shared/lib"; + +interface SkeletonProps { + className: string; +} + +export function Skeleton({ className }: SkeletonProps) { + return ( +
+ ); +} diff --git a/apps/frontend/src/shared/ui/index.ts b/apps/frontend/src/shared/ui/index.ts index f029b8d6..71bc2fc2 100644 --- a/apps/frontend/src/shared/ui/index.ts +++ b/apps/frontend/src/shared/ui/index.ts @@ -11,3 +11,4 @@ export { Popover } from "./Popover"; export { ScrollWrapper } from "./ScrollWrapper"; export { SideWrapper } from "./SideWrapper"; export { Switch } from "./Switch"; +export { Skeleton } from "./Skeleton"; diff --git a/apps/frontend/src/widgets/CanvasToolsView/ui/index.tsx b/apps/frontend/src/widgets/CanvasToolsView/ui/index.tsx index 045932e8..f42b965d 100644 --- a/apps/frontend/src/widgets/CanvasToolsView/ui/index.tsx +++ b/apps/frontend/src/widgets/CanvasToolsView/ui/index.tsx @@ -1,9 +1,11 @@ -import { useEffect, useState } from "react"; +import { Suspense, useEffect, useState, lazy } from "react"; import { useUserStore } from "@/entities/user"; -import { CursorButton, ProfilePanel } from "@/features/canvasTools"; +import { CursorButton } from "@/features/canvasTools"; import { ShareTool } from "@/features/workspace"; -import { Popover } from "@/shared/ui"; +import { Popover, Skeleton } from "@/shared/ui"; + +const ProfilePanel = lazy(() => import("@/features/canvasTools")); export function CanvasToolsView() { const { currentUser } = useUserStore(); @@ -22,13 +24,15 @@ export function CanvasToolsView() { - - + + }> + +
diff --git a/apps/frontend/src/widgets/EditorView/index.ts b/apps/frontend/src/widgets/EditorView/index.ts index bc2b6544..ac3e2e48 100644 --- a/apps/frontend/src/widgets/EditorView/index.ts +++ b/apps/frontend/src/widgets/EditorView/index.ts @@ -1 +1 @@ -export { EditorView } from "./ui"; +export { default } from "./ui"; diff --git a/apps/frontend/src/widgets/EditorView/model/useEditorView.ts b/apps/frontend/src/widgets/EditorView/model/useEditorView.ts index fd197093..ce5dd52c 100644 --- a/apps/frontend/src/widgets/EditorView/model/useEditorView.ts +++ b/apps/frontend/src/widgets/EditorView/model/useEditorView.ts @@ -1,5 +1,4 @@ -import { useEffect, useState } from "react"; -import { useDebouncedCallback } from "use-debounce"; +import { useEffect } from "react"; import { useUserStore } from "@/entities/user"; import { usePageStore } from "@/entities/page"; @@ -10,37 +9,20 @@ import { useEdtorConnection } from "@/features/editor/model/useEditorConnection" export const useEditorView = () => { const { currentPage } = usePageStore(); const { isPanelOpen, isMaximized, setIsPanelOpen } = useEditorStore(); - const [saveStatus, setSaveStatus] = useState<"saved" | "unsaved">("saved"); useEdtorConnection(currentPage); const { editor } = useConnectionStore(); const { users } = useUserStore(); useEffect(() => { - if (currentPage) return; - setIsPanelOpen(false); + setIsPanelOpen(!!currentPage); }, [currentPage]); - useEffect(() => { - if (!currentPage) return; - setIsPanelOpen(true); - }, [currentPage]); - - const handleEditorUpdate = useDebouncedCallback(async () => { - if (currentPage === null) { - return; - } - - setSaveStatus("unsaved"); - }, 500); - return { currentPage, isPanelOpen, isMaximized, ydoc: editor.provider?.doc, provider: editor.provider, - saveStatus, - handleEditorUpdate, users, }; }; diff --git a/apps/frontend/src/widgets/EditorView/ui/index.tsx b/apps/frontend/src/widgets/EditorView/ui/index.tsx index 52af1acd..45eae626 100644 --- a/apps/frontend/src/widgets/EditorView/ui/index.tsx +++ b/apps/frontend/src/widgets/EditorView/ui/index.tsx @@ -3,16 +3,9 @@ import { Editor, EditorActionPanel, EditorTitle } from "@/features/editor"; import { ActiveUser } from "@/shared/ui"; import { cn } from "@/shared/lib"; -export function EditorView() { - const { - currentPage, - isPanelOpen, - isMaximized, - provider, - saveStatus, - handleEditorUpdate, - users, - } = useEditorView(); +export default function EditorView() { + const { currentPage, isPanelOpen, isMaximized, provider, users } = + useEditorView(); if (currentPage === null) { return null; @@ -28,7 +21,7 @@ export function EditorView() { isMaximized ? "right-0 top-0 h-screen w-screen" : "", )} > - +
diff --git a/apps/frontend/src/widgets/NodeToolsView/index.ts b/apps/frontend/src/widgets/NodeToolsView/index.ts index a79544e0..ac3e2e48 100644 --- a/apps/frontend/src/widgets/NodeToolsView/index.ts +++ b/apps/frontend/src/widgets/NodeToolsView/index.ts @@ -1 +1 @@ -export { NodeToolsView } from "./ui"; +export { default } from "./ui"; diff --git a/apps/frontend/src/widgets/NodeToolsView/ui/index.tsx b/apps/frontend/src/widgets/NodeToolsView/ui/index.tsx index ef88e946..74c1d237 100644 --- a/apps/frontend/src/widgets/NodeToolsView/ui/index.tsx +++ b/apps/frontend/src/widgets/NodeToolsView/ui/index.tsx @@ -1,7 +1,7 @@ import { usePageStore } from "@/entities/page"; import { NodePanel } from "@/features/canvasTools/ui/NodePanel"; -export function NodeToolsView() { +export default function NodeToolsView() { const { currentPage } = usePageStore(); if (!currentPage) return null; diff --git a/apps/frontend/src/widgets/PageSideBarView/ui/index.tsx b/apps/frontend/src/widgets/PageSideBarView/ui/index.tsx index a67cc12e..b28d9eff 100644 --- a/apps/frontend/src/widgets/PageSideBarView/ui/index.tsx +++ b/apps/frontend/src/widgets/PageSideBarView/ui/index.tsx @@ -1,8 +1,11 @@ -import { useState } from "react"; +import { useState, lazy, Suspense } from "react"; -import { NoteList, Tools } from "@/features/pageSidebar"; import { TopNavView } from "@/widgets/TopNavView"; -import { ScrollWrapper } from "@/shared/ui"; +import { Skeleton } from "@/shared/ui"; + +const PageListPanel = lazy( + () => import("@/features/pageSidebar/ui/PageListPanel"), +); export function PageSideBarView() { const [isExpanded, setIsExpanded] = useState(false); @@ -16,14 +19,13 @@ export function PageSideBarView() {
-
-
- -
- - - -
+ {isExpanded && ( + } + > + + + )}
); } diff --git a/apps/frontend/src/widgets/UserInfoView/ui/index.tsx b/apps/frontend/src/widgets/UserInfoView/ui/index.tsx index e0d1c0ee..4b6c02ed 100644 --- a/apps/frontend/src/widgets/UserInfoView/ui/index.tsx +++ b/apps/frontend/src/widgets/UserInfoView/ui/index.tsx @@ -1,52 +1,23 @@ -import { useState } from "react"; - -import { UserProfile } from "@/entities/user"; -import { LoginForm, Logout, useGetUser } from "@/features/auth"; +import { lazy } from "react"; import { LogoBtn } from "@/features/pageSidebar"; -import { - WorkspaceAddButton, - WorkspaceForm, - WorkspaceList, -} from "@/features/workspace"; -import { Divider, Popover } from "@/shared/ui"; - -export function UserInfoView() { - const { data } = useGetUser(); - const [isModalOpen, setIsModalOpen] = useState(false); +import { Popover, Skeleton } from "@/shared/ui"; +import { Suspense } from "react"; - const onOpenModal = () => { - setIsModalOpen(true); - }; - - const onCloseModal = () => { - setIsModalOpen(false); - }; +const WorkspacePanel = lazy( + () => import("@/features/workspace/ui/WorkspacePanel"), +); +export function UserInfoView() { return (
- - {data ? ( -
- - - - - -
- - -
-
- ) : ( - - )} + + }> + +
diff --git a/apps/websocket/.eslintrc.js b/apps/websocket/.eslintrc.js index 259de13c..465fcf8d 100644 --- a/apps/websocket/.eslintrc.js +++ b/apps/websocket/.eslintrc.js @@ -22,4 +22,10 @@ module.exports = { '@typescript-eslint/explicit-module-boundary-types': 'off', '@typescript-eslint/no-explicit-any': 'off', }, + 'prettier/prettier': [ + 'error', + { + endOfLine: 'auto', + }, + ], }; diff --git a/apps/websocket/src/red-lock/red-lock.module.ts b/apps/websocket/src/red-lock/red-lock.module.ts deleted file mode 100644 index d2d9f574..00000000 --- a/apps/websocket/src/red-lock/red-lock.module.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { Module, forwardRef } from '@nestjs/common'; -import Redis from 'ioredis'; -import Redlock from 'redlock'; -import { RedisModule } from '../redis/redis.module'; -const RED_LOCK_TOKEN = 'RED_LOCK'; -const REDIS_CLIENT_TOKEN = 'REDIS_CLIENT'; - -@Module({ - imports: [forwardRef(() => RedisModule)], - providers: [ - { - provide: RED_LOCK_TOKEN, - useFactory: (redisClient: Redis) => { - return new Redlock([redisClient], { - driftFactor: 0.01, - retryCount: 10, - retryDelay: 200, - retryJitter: 200, - automaticExtensionThreshold: 500, - }); - }, - inject: [REDIS_CLIENT_TOKEN], - }, - ], - exports: [RED_LOCK_TOKEN], -}) -export class RedLockModule {} diff --git a/apps/websocket/src/redis/redis.module.ts b/apps/websocket/src/redis/redis.module.ts index 152d05b3..666cd746 100644 --- a/apps/websocket/src/redis/redis.module.ts +++ b/apps/websocket/src/redis/redis.module.ts @@ -1,14 +1,13 @@ -import { Module, forwardRef } from '@nestjs/common'; +import { Module } from '@nestjs/common'; import { ConfigModule, ConfigService } from '@nestjs/config'; import { RedisService } from './redis.service'; import Redis from 'ioredis'; -import { RedLockModule } from '../red-lock/red-lock.module'; // 의존성 주입할 때 redis client를 식별할 토큰 const REDIS_CLIENT_TOKEN = 'REDIS_CLIENT'; @Module({ - imports: [ConfigModule, forwardRef(() => RedLockModule)], // ConfigModule 추가 + imports: [ConfigModule], // ConfigModule 추가 providers: [ RedisService, { diff --git a/apps/websocket/src/redis/redis.service.ts b/apps/websocket/src/redis/redis.service.ts index 62253590..a5b8b03c 100644 --- a/apps/websocket/src/redis/redis.service.ts +++ b/apps/websocket/src/redis/redis.service.ts @@ -1,9 +1,7 @@ import { Injectable } from '@nestjs/common'; import { Inject } from '@nestjs/common'; import Redis from 'ioredis'; -import Redlock from 'redlock'; const REDIS_CLIENT_TOKEN = 'REDIS_CLIENT'; -const RED_LOCK_TOKEN = 'RED_LOCK'; type RedisPage = { title?: string; @@ -14,13 +12,8 @@ type RedisPage = { export class RedisService { constructor( @Inject(REDIS_CLIENT_TOKEN) private readonly redisClient: Redis, - @Inject(RED_LOCK_TOKEN) private readonly redisLock: Redlock, ) {} - async getAllKeys(pattern) { - return await this.redisClient.keys(pattern); - } - createStream() { return this.redisClient.scanStream(); } @@ -33,32 +26,22 @@ export class RedisService { } async set(key: string, value: object) { - // 락을 획득할 때까지 기다린다. - const lock = await this.redisLock.acquire([`user:${key}`], 1000); - try { - await this.redisClient.hset(key, Object.entries(value)); - } finally { - lock.release(); - } + await this.redisClient.hset(key, Object.entries(value)); } - async setField(key: string, field: string, value: string) { - // 락을 획득할 때까지 기다린다. - const lock = await this.redisLock.acquire([`user:${key}`], 1000); - try { - return await this.redisClient.hset(key, field, value); - } finally { - lock.release(); - } + async setFields(key: string, map: Record) { + // return await this.redisClient.hset(key, ); + // fieldValueArr 배열을 평탄화하여 [field, value, field, value, ...] 형태로 변환 + const flattenedFields = Object.entries(map).flatMap(([field, value]) => [ + field, + value, + ]); + + // hset을 통해 한 번에 여러 필드를 설정 + return await this.redisClient.hset(key, ...flattenedFields); } async delete(key: string) { - // 락을 획득할 때까지 기다린다. - const lock = await this.redisLock.acquire([`user:${key}`], 1000); - try { - return await this.redisClient.del(key); - } finally { - lock.release(); - } + return await this.redisClient.del(key); } } diff --git a/apps/websocket/src/yjs/yjs.service.ts b/apps/websocket/src/yjs/yjs.service.ts index ff4dad8f..2a74b927 100644 --- a/apps/websocket/src/yjs/yjs.service.ts +++ b/apps/websocket/src/yjs/yjs.service.ts @@ -247,20 +247,18 @@ export class YjsService private async observeTitle(event: Y.YEvent[]) { // path가 존재할 때만 페이지 갱신 event[0].path.toString().split('_')[1] && - this.redisService.setField( + (await this.redisService.setFields( `page:${event[0].path.toString().split('_')[1]}`, - 'title', - event[0].target.toString(), - ); + { title: event[0].target.toString() }, + )); } private async observeEmoji(event: Y.YEvent[]) { // path가 존재할 때만 페이지 갱신 event[0].path.toString().split('_')[1] && - this.redisService.setField( + this.redisService.setFields( `page:${event[0].path.toString().split('_')[1]}`, - 'emoji', - event[0].target.toString(), + { emoji: event[0].target.toString() }, ); } @@ -288,15 +286,11 @@ export class YjsService const findPage = pageResponse.data.page; - await Promise.all([ - this.redisService.setField(`node:${findPage.node.id}`, 'x', x), - this.redisService.setField(`node:${findPage.node.id}`, 'y', y), - this.redisService.setField( - `node:${findPage.node.id}`, - 'color', - color, - ), - ]); + await this.redisService.setFields(`node:${findPage.node.id}`, { + x, + y, + color, + }); } catch (error) { this.logger.error( `노드 업데이트 중 오류 발생 (nodeId: ${id}): ${error.message}`, @@ -315,43 +309,23 @@ export class YjsService ) { for (const [key, change] of event.changes.keys) { const [fromNode, toNode] = key.slice(1).split('-'); + // TODO: 여기서 delete 시 edge를 못찾음 (undefined로 가져옴) const edge = edgesMap.get(key) as YMapEdge; if (change.action === 'add') { // 연결된 노드가 없을 때만 edge 생성 - this.redisService.setField( - `edge:${edge.source}-${edge.target}`, - 'fromNode', - edge.source, - ); - this.redisService.setField( + await this.redisService.setFields( `edge:${edge.source}-${edge.target}`, - 'toNode', - edge.target, - ); - this.redisService.setField( - `edge:${edge.source}-${edge.target}`, - 'type', - 'add', + { fromNode: edge.source, toNode: edge.target, type: 'add' }, ); } if (change.action === 'delete') { // 엣지가 존재하면 삭제 - this.redisService.setField( - `edge:${fromNode}-${toNode}`, - 'fromNode', + await this.redisService.setFields(`edge:${fromNode}-${toNode}`, { fromNode, - ); - this.redisService.setField( - `edge:${fromNode}-${toNode}`, - 'toNode', toNode, - ); - this.redisService.setField( - `edge:${fromNode}-${toNode}`, - 'type', - 'delete', - ); + type: 'delete', + }); } } } @@ -361,11 +335,9 @@ export class YjsService try { const pageId = parseInt(document.name.split('-')[1]); - await this.redisService.setField( - `page:${pageId.toString()}`, - 'content', - JSON.stringify(yXmlFragmentToProsemirrorJSON(editorDoc)), - ); + await this.redisService.setFields(`page:${pageId.toString()}`, { + content: JSON.stringify(yXmlFragmentToProsemirrorJSON(editorDoc)), + }); } catch (error) { this.logger.error( `에디터 내용 저장 중 오류 발생 (pageId: ${document?.name}): ${error.message}`, diff --git a/compose.local.yml b/compose.local.yml index 74c65e93..b478ec81 100644 --- a/compose.local.yml +++ b/compose.local.yml @@ -1,119 +1,126 @@ version: "3.8" services: - postgres: - image: postgres:16-alpine - environment: - POSTGRES_USER: ${DB_USER} - POSTGRES_PASSWORD: ${DB_PASSWORD} - POSTGRES_DB: ${DB_NAME} - volumes: - - postgres_data:/var/lib/postgresql/data - networks: - - net - healthcheck: - test: ["CMD-SHELL", "pg_isready -U ${DB_USER} -d ${DB_NAME}"] - interval: 10s - timeout: 5s - retries: 5 - ports: - - "5432:5432" + postgres: + image: postgres:16-alpine + environment: + POSTGRES_USER: ${DB_USER} + POSTGRES_PASSWORD: ${DB_PASSWORD} + POSTGRES_DB: ${DB_NAME} + volumes: + - postgres_data:/var/lib/postgresql/data + networks: + - net + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${DB_USER} -d ${DB_NAME}"] + interval: 10s + timeout: 5s + retries: 5 + ports: + - "5432:5432" - redis: - image: redis:latest - environment: - REDIS_HOST: ${REDIS_HOST} - REDIS_PORT: ${REDIS_PORT} - networks: - - net - ports: - - "6379:6379" - healthcheck: - test: ["CMD", "redis-cli", "ping"] - interval: 30s - retries: 3 - start_period: 10s - timeout: 5s + redis: + image: redis:latest + environment: + REDIS_HOST: ${REDIS_HOST} + REDIS_PORT: ${REDIS_PORT} + networks: + - net + ports: + - "6379:6379" + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 30s + retries: 3 + start_period: 10s + timeout: 5s - backend: - build: - context: . - dockerfile: ./services/backend/Dockerfile.local - image: backend:latest - env_file: - - .env - volumes: - - .env:/app/.env - # 소스 코드 마운트 - - ./apps/backend:/app/apps/backend - - ./apps/frontend:/app/apps/frontend - # 의존성 캐시를 위한 볼륨 - - backend_node_modules:/app/node_modules - - backend_app_node_modules:/app/apps/backend/node_modules - - frontend_app_node_modules:/app/apps/frontend/node_modules - depends_on: - postgres: - condition: service_healthy - redis: - condition: service_healthy - networks: - - net - ports: - - "5173:5173" # Vite dev server - - "3000:3000" # 백엔드 API 포트 + backend: + build: + context: . + dockerfile: ./services/backend/Dockerfile.local + image: backend:latest + env_file: + - .env + volumes: + - .env:/app/.env + - ./apps/backend:/app/apps/backend + - ./apps/frontend:/app/apps/frontend + - root_node_modules:/app/node_modules + - backend_app_node_modules:/app/apps/backend/node_modules + - frontend_app_node_modules:/app/apps/frontend/node_modules + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + networks: + - net + ports: + - "5173:5173" # Vite dev server + - "3000:3000" # 백엔드 API 포트 + entrypoint: | + sh -c " + yarn install && + yarn dev + " - websocket: - build: - context: . - dockerfile: ./services/websocket/Dockerfile.local - image: websocket:latest - env_file: - - .env - volumes: - - .env:/app/.env - # 소스 코드 마운트 - - ./apps/websocket:/app/apps/websocket - # 의존성 캐시를 위한 볼륨 - - websocket_node_modules:/app/node_modules - - websocket_app_node_modules:/app/apps/websocket/node_modules - depends_on: - postgres: - condition: service_healthy - redis: - condition: service_healthy - networks: - - net - ports: - - "4242:4242" # WebSocket 포트 + websocket: + build: + context: . + dockerfile: ./services/websocket/Dockerfile.local + image: websocket:latest + env_file: + - .env + volumes: + - .env:/app/.env + - ./apps/websocket:/app/apps/websocket + - root_node_modules:/app/node_modules + - websocket_app_node_modules:/app/apps/websocket/node_modules + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + networks: + - net + ports: + - "4242:4242" # WebSocket 포트 + entrypoint: | + sh -c " + yarn install && + yarn dev + " - nginx: - build: - context: . - dockerfile: ./services/nginx/Dockerfile.local - ports: - - "80:80" - - "443:443" - depends_on: - - backend - - websocket - networks: - - net - volumes: - - type: bind - source: ./services/nginx/ssl - target: /etc/nginx/ssl - bind: - create_host_path: true - propagation: rprivate - - ./services/nginx/conf.d:/etc/nginx/conf.d + nginx: + build: + context: . + dockerfile: ./services/nginx/Dockerfile.local + ports: + - "80:80" + - "443:443" + depends_on: + - backend + - websocket + networks: + - net + volumes: + - type: bind + source: ./services/nginx/ssl + target: /etc/nginx/ssl + bind: + create_host_path: true + propagation: rprivate + - ./services/nginx/conf.d:/etc/nginx/conf.d networks: - net: + net: volumes: - postgres_data: - backend_node_modules: - backend_app_node_modules: - frontend_app_node_modules: - websocket_node_modules: - websocket_app_node_modules: + postgres_data: + root_node_modules: + backend_node_modules: + backend_app_node_modules: + frontend_app_node_modules: + websocket_node_modules: + websocket_app_node_modules: diff --git a/package.json b/package.json index d625b9e7..df307052 100644 --- a/package.json +++ b/package.json @@ -1,30 +1,30 @@ { - "name": "octodocs", - "version": "1.0.0", - "main": "index.js", - "repository": "https://github.com/boostcampwm-2024/web15-OctoDocs.git", - "author": "ez <105545215+ezcolin2@users.noreply.github.com>", - "license": "MIT", - "scripts": { - "dev": "turbo run dev --parallel", - "build": "turbo run build", - "start": "node apps/backend/dist/main.js", - "start:backend": "node apps/backend/dist/main.js", - "start:websocket": "node apps/websocket/dist/main.js", - "lint": "turbo run lint", - "test": "turbo run test", - "docker:dev": "docker compose -f compose.local.yml up", - "docker:dev:down": "docker compose -f compose.local.yml down", - "docker:dev:clean": "docker compose -v -f compose.local.yml down", - "docker:dev:fclean": "docker compose -v -f compose.local.yml down --rmi all", - "ssl:generate": "cd services/nginx/ssl && bash ./generate-cert.sh" - }, - "dependencies": { - "turbo": "^2.3.0" - }, - "private": true, - "workspaces": [ - "apps/*" - ], - "packageManager": "yarn@1.22.22" + "name": "octodocs", + "version": "1.0.0", + "main": "index.js", + "repository": "https://github.com/boostcampwm-2024/web15-OctoDocs.git", + "author": "ez <105545215+ezcolin2@users.noreply.github.com>", + "license": "MIT", + "scripts": { + "dev": "turbo run dev --parallel", + "build": "turbo run build", + "start": "node apps/backend/dist/main.js", + "start:backend": "node apps/backend/dist/main.js", + "start:websocket": "node apps/websocket/dist/main.js", + "lint": "turbo run lint", + "test": "turbo run test", + "docker:dev": "docker compose -f compose.local.yml up", + "docker:dev:down": "docker compose -f compose.local.yml down", + "docker:dev:clean": "docker compose -v -f compose.local.yml down", + "docker:dev:fclean": "docker compose -v -f compose.local.yml down --rmi all", + "ssl:generate": "cd services/nginx/ssl && bash ./generate-cert.sh" + }, + "dependencies": { + "turbo": "^2.3.0" + }, + "private": true, + "workspaces": [ + "apps/*" + ], + "packageManager": "yarn@1.22.22" } diff --git a/redis-test.js b/redis-test.js new file mode 100644 index 00000000..029c0edc --- /dev/null +++ b/redis-test.js @@ -0,0 +1,66 @@ +const Redis = require("ioredis"); + +// Redis 클라이언트 생성 +const redis = new Redis({ + host: "localhost", + port: 6379, +}); + +// 여러 개의 node와 page 정보를 삽입하는 함수 +async function insertData() { + const nodes = []; + const pages = []; + + // node:1부터 node:100까지 생성 + for (let i = 1; i <= 500; i++) { + const x = 180; + const y = 479; + const color = "#FFFFFF"; + + nodes.push({ + id: i, + x: x, + y: y, + color: color, + }); + + const content = `{"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"페이지 내용 ${i}"}]}]}`; + const title = `페이지 제목 ${i}`; + + pages.push({ + id: i, + content: content, + title: title, + }); + } + + // node 정보 삽입 + for (const node of nodes) { + await redis.hmset( + `node:${node.id}`, + "x", + node.x, + "y", + node.y, + "color", + node.color + ); + console.log(`Inserted node:${node.id}`); + } + + // page 정보 삽입 + for (const page of pages) { + await redis.hmset( + `page:${page.id}`, + "content", + page.content, + "title", + page.title + ); + console.log(`Inserted page:${page.id}`); + } + + console.log("Data insertion complete"); +} + +insertData(); diff --git a/yarn.lock b/yarn.lock index 569729b0..f2fd7dbd 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9999,7 +9999,16 @@ string-length@^4.0.1: char-regex "^1.0.2" strip-ansi "^6.0.0" -"string-width-cjs@npm:string-width@^4.2.0", "string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: +"string-width-cjs@npm:string-width@^4.2.0": + version "4.2.3" + resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" + integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== + dependencies: + emoji-regex "^8.0.0" + is-fullwidth-code-point "^3.0.0" + strip-ansi "^6.0.1" + +"string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: version "4.2.3" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== @@ -10031,7 +10040,14 @@ string_decoder@~1.1.1: dependencies: safe-buffer "~5.1.0" -"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1: +"strip-ansi-cjs@npm:strip-ansi@^6.0.1": + version "6.0.1" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" + integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== + dependencies: + ansi-regex "^5.0.1" + +strip-ansi@^6.0.0, strip-ansi@^6.0.1: version "6.0.1" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== @@ -10979,7 +10995,7 @@ word-wrap@^1.2.5: resolved "https://registry.yarnpkg.com/word-wrap/-/word-wrap-1.2.5.tgz#d2c45c6dd4fbce621a66f136cbe328afd0410b34" integrity sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA== -"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0: +"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": version "7.0.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== @@ -10997,6 +11013,15 @@ wrap-ansi@^6.0.1, wrap-ansi@^6.2.0: string-width "^4.1.0" strip-ansi "^6.0.0" +wrap-ansi@^7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" + integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== + dependencies: + ansi-styles "^4.0.0" + string-width "^4.1.0" + strip-ansi "^6.0.0" + wrap-ansi@^8.1.0: version "8.1.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214"