Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion apps/backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,17 +37,19 @@
"@nestjs/websockets": "^10.4.8",
"@theinternetfolks/snowflake": "^1.3.0",
"@types/multer": "^1.4.12",
"@types/redlock": "^4.0.7",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
"ioredis": "^5.4.1",
"cookie-parser": "^1.4.7",
"ioredis": "^5.4.1",
"lib0": "^0.2.98",
"passport": "^0.7.0",
"passport-kakao": "^1.0.1",
"passport-naver": "^1.0.6",
"path": "^0.12.7",
"pg": "^8.13.1",
"prosemirror-view": "^1.37.0",
"redlock": "^5.0.0-beta.2",
"reflect-metadata": "^0.1.13",
"rxjs": "^7.8.1",
"socket.io": "^4.8.1",
Expand Down
2 changes: 2 additions & 0 deletions apps/backend/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ 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: [
Expand Down Expand Up @@ -61,6 +62,7 @@ import { ScheduleModule } from '@nestjs/schedule';
WorkspaceModule,
RoleModule,
TasksModule,
RedLockModule,
],
controllers: [AppController],
providers: [AppService],
Expand Down
1 change: 0 additions & 1 deletion apps/backend/src/page/page.controller.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,6 @@ describe('PageController', () => {
provide: PageService,
useValue: {
createPage: jest.fn(),
createLinkedPage: jest.fn(),
deletePage: jest.fn(),
updatePage: jest.fn(),
findPageById: jest.fn(),
Expand Down
2 changes: 2 additions & 0 deletions apps/backend/src/page/page.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,14 @@ 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],
Expand Down
2 changes: 1 addition & 1 deletion apps/backend/src/page/page.repository.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Injectable, Logger } from '@nestjs/common';
import { Injectable } from '@nestjs/common';
import { DataSource, Repository } from 'typeorm';
import { Page } from './page.entity';
import { InjectDataSource } from '@nestjs/typeorm';
Expand Down
38 changes: 28 additions & 10 deletions apps/backend/src/page/page.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,17 @@ import { UpdatePageDto } from './dtos/updatePage.dto';
import { PageNotFoundException } from '../exception/page.exception';
import { WorkspaceRepository } from '../workspace/workspace.repository';
import { WorkspaceNotFoundException } from '../exception/workspace.exception';
const RED_LOCK_TOKEN = 'RED_LOCK';
type RedisLock = {
acquire(): Promise<{ release: () => void }>;
};

describe('PageService', () => {
let service: PageService;
let pageRepository: PageRepository;
let nodeRepository: NodeRepository;
let workspaceRepository: WorkspaceRepository;

let redisLock: RedisLock;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
Expand Down Expand Up @@ -46,17 +50,28 @@ describe('PageService', () => {
findOneBy: jest.fn(),
},
},
{
provide: RED_LOCK_TOKEN,
useValue: {
acquire: jest.fn(),
},
},
],
}).compile();

service = module.get<PageService>(PageService);
pageRepository = module.get<PageRepository>(PageRepository);
nodeRepository = module.get<NodeRepository>(NodeRepository);
workspaceRepository = module.get<WorkspaceRepository>(WorkspaceRepository);
redisLock = module.get<RedisLock>(RED_LOCK_TOKEN);
});

it('서비스 클래스가 정상적으로 인스턴스화된다.', () => {
expect(service).toBeDefined();
expect(pageRepository).toBeDefined();
expect(nodeRepository).toBeDefined();
expect(workspaceRepository).toBeDefined();
expect(redisLock).toBeDefined();
});

describe('createPage', () => {
Expand Down Expand Up @@ -141,17 +156,15 @@ describe('PageService', () => {
});
});

describe('createLinkedPage', () => {
it('', () => {});
});

describe('deletePage', () => {
it('id에 해당하는 페이지를 찾아 성공적으로 삭제한다.', async () => {
jest
.spyOn(pageRepository, 'delete')
.mockResolvedValue({ affected: true } as any);
jest.spyOn(pageRepository, 'findOneBy').mockResolvedValue(new Page());

jest.spyOn(redisLock, 'acquire').mockResolvedValue({
release: jest.fn(),
});
await service.deletePage(1);

expect(pageRepository.delete).toHaveBeenCalledWith(1);
Expand All @@ -161,7 +174,9 @@ describe('PageService', () => {
jest
.spyOn(pageRepository, 'delete')
.mockResolvedValue({ affected: false } as any);

jest.spyOn(redisLock, 'acquire').mockResolvedValue({
release: jest.fn(),
});
await expect(service.deletePage(1)).rejects.toThrow(
PageNotFoundException,
);
Expand Down Expand Up @@ -199,10 +214,11 @@ describe('PageService', () => {
emoji: '📝',
workspace: null,
};

jest.spyOn(pageRepository, 'findOneBy').mockResolvedValue(originPage);
jest.spyOn(pageRepository, 'save').mockResolvedValue(newPage);

jest.spyOn(redisLock, 'acquire').mockResolvedValue({
release: jest.fn(),
});
const result = await service.updatePage(1, dto);

expect(result).toEqual(newPage);
Expand All @@ -216,7 +232,9 @@ describe('PageService', () => {
jest
.spyOn(nodeRepository, 'findOneBy')
.mockResolvedValue({ affected: false } as any);

jest.spyOn(redisLock, 'acquire').mockResolvedValue({
release: jest.fn(),
});
await expect(service.updatePage(1, new UpdatePageDto())).rejects.toThrow(
PageNotFoundException,
);
Expand Down
78 changes: 48 additions & 30 deletions apps/backend/src/page/page.service.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Injectable } from '@nestjs/common';
import { Injectable, Inject } from '@nestjs/common';
import { NodeRepository } from '../node/node.repository';
import { WorkspaceRepository } from '../workspace/workspace.repository';
import { PageRepository } from './page.repository';
Expand All @@ -8,15 +8,30 @@ 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';
@Injectable()
export class PageService {
constructor(
private readonly pageRepository: PageRepository,
private readonly nodeRepository: NodeRepository,
private readonly workspaceRepository: WorkspaceRepository,
@Inject(RED_LOCK_TOKEN) private readonly redisLock: Redlock,
) {}

/**
* redis에 저장된 페이지 정보를 다음 과정을 통해 주기적으로 데이터베이스에 반영한다.
*
* 1. redis에서 해당 페이지의 title과 content를 가져온다.
* 2. 데이터베이스에 해당 페이지의 title과 content를 갱신한다.
* 3. redis에서 해당 페이지 정보를 삭제한다.
*
* 만약 1번 과정을 진행한 상태에서 page가 삭제된다면 오류가 발생한다.
* 위 과정을 진행하는 동안 page 정보 수정을 막기 위해 lock을 사용한다.
*
* 동기화를 위해 기존 페이지에 접근하여 수정하는 로직은 RedLock 알고리즘을 통해 락을 획득할 수 있을 때만 수행한다.
* 기존 페이지에 접근하여 연산하는 로직의 경우 RedLock 알고리즘을 사용하여 동시 접근을 방지한다.
*/
async createPage(dto: CreatePageDto): Promise<Page> {
const { title, content, workspaceId, x, y, emoji } = dto;

Expand Down Expand Up @@ -47,40 +62,43 @@ export class PageService {
return page;
}

async createLinkedPage(title: string, nodeId: number): Promise<Page> {
// 노드를 조회한다.
const existingNode = await this.nodeRepository.findOneBy({ id: nodeId });
// 페이지를 생성한다.
const page = await this.pageRepository.save({ title, content: {} });

page.node = existingNode;
return await this.pageRepository.save(page);
}

async deletePage(id: number): Promise<void> {
// 페이지를 삭제한다.
const deleteResult = await this.pageRepository.delete(id);

// 만약 삭제된 페이지가 없으면 페이지를 찾지 못한 것
if (!deleteResult.affected) {
throw new PageNotFoundException();
// 락을 획득할 때까지 기다린다.
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();
}
}

async updatePage(id: number, dto: UpdatePageDto): Promise<Page> {
// 갱신할 페이지를 조회한다.
// 페이지를 조회한다.
const page = await this.pageRepository.findOneBy({ id });

// 페이지가 없으면 NotFound 에러
if (!page) {
throw new PageNotFoundException();
// 락을 획득할 때까지 기다린다.
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 newPage = Object.assign({}, page, dto);

// 변경된 페이지를 저장
return await this.pageRepository.save(newPage);
}

async updateBulkPage(pages: UpdatePartialPageDto[]) {
Expand Down
27 changes: 27 additions & 0 deletions apps/backend/src/red-lock/red-lock.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
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 {}
25 changes: 22 additions & 3 deletions apps/backend/src/redis/redis.module.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,27 @@
import { Module } from '@nestjs/common';
import { Module, forwardRef } 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({
providers: [RedisService],
exports: [RedisService],
imports: [ConfigModule, forwardRef(() => RedLockModule)], // ConfigModule 추가
providers: [
RedisService,
{
provide: REDIS_CLIENT_TOKEN,
inject: [ConfigService], // ConfigService 주입
useFactory: (configService: ConfigService) => {
return new Redis({
host: configService.get<string>('REDIS_HOST'),
port: configService.get<number>('REDIS_PORT'),
});
},
},
],
exports: [RedisService, REDIS_CLIENT_TOKEN],
})
export class RedisModule {}
18 changes: 0 additions & 18 deletions apps/backend/src/redis/redis.service.spec.ts

This file was deleted.

Loading
Loading