Skip to content

Commit 9be1de6

Browse files
Merge pull request #350 from boostcampwm-2024/feature-be-#309
영속화에 red lock 및 트랜잭션 적용
2 parents c366e05 + cd12737 commit 9be1de6

File tree

14 files changed

+660
-514
lines changed

14 files changed

+660
-514
lines changed

apps/backend/package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,17 +37,19 @@
3737
"@nestjs/websockets": "^10.4.8",
3838
"@theinternetfolks/snowflake": "^1.3.0",
3939
"@types/multer": "^1.4.12",
40+
"@types/redlock": "^4.0.7",
4041
"class-transformer": "^0.5.1",
4142
"class-validator": "^0.14.1",
42-
"ioredis": "^5.4.1",
4343
"cookie-parser": "^1.4.7",
44+
"ioredis": "^5.4.1",
4445
"lib0": "^0.2.98",
4546
"passport": "^0.7.0",
4647
"passport-kakao": "^1.0.1",
4748
"passport-naver": "^1.0.6",
4849
"path": "^0.12.7",
4950
"pg": "^8.13.1",
5051
"prosemirror-view": "^1.37.0",
52+
"redlock": "^5.0.0-beta.2",
5153
"reflect-metadata": "^0.1.13",
5254
"rxjs": "^7.8.1",
5355
"socket.io": "^4.8.1",

apps/backend/src/app.module.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import { WorkspaceModule } from './workspace/workspace.module';
2222
import { RoleModule } from './role/role.module';
2323
import { TasksModule } from './tasks/tasks.module';
2424
import { ScheduleModule } from '@nestjs/schedule';
25+
import { RedLockModule } from './red-lock/red-lock.module';
2526

2627
@Module({
2728
imports: [
@@ -61,6 +62,7 @@ import { ScheduleModule } from '@nestjs/schedule';
6162
WorkspaceModule,
6263
RoleModule,
6364
TasksModule,
65+
RedLockModule,
6466
],
6567
controllers: [AppController],
6668
providers: [AppService],

apps/backend/src/page/page.controller.spec.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,6 @@ describe('PageController', () => {
2020
provide: PageService,
2121
useValue: {
2222
createPage: jest.fn(),
23-
createLinkedPage: jest.fn(),
2423
deletePage: jest.fn(),
2524
updatePage: jest.fn(),
2625
findPageById: jest.fn(),

apps/backend/src/page/page.module.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,14 @@ import { Page } from './page.entity';
66
import { PageRepository } from './page.repository';
77
import { NodeModule } from '../node/node.module';
88
import { WorkspaceModule } from '../workspace/workspace.module';
9+
import { RedLockModule } from '../red-lock/red-lock.module';
910

1011
@Module({
1112
imports: [
1213
TypeOrmModule.forFeature([Page]),
1314
forwardRef(() => NodeModule),
1415
WorkspaceModule,
16+
RedLockModule,
1517
],
1618
controllers: [PageController],
1719
providers: [PageService, PageRepository],

apps/backend/src/page/page.repository.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { Injectable, Logger } from '@nestjs/common';
1+
import { Injectable } from '@nestjs/common';
22
import { DataSource, Repository } from 'typeorm';
33
import { Page } from './page.entity';
44
import { InjectDataSource } from '@nestjs/typeorm';

apps/backend/src/page/page.service.spec.ts

Lines changed: 28 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,17 @@ import { UpdatePageDto } from './dtos/updatePage.dto';
1010
import { PageNotFoundException } from '../exception/page.exception';
1111
import { WorkspaceRepository } from '../workspace/workspace.repository';
1212
import { WorkspaceNotFoundException } from '../exception/workspace.exception';
13+
const RED_LOCK_TOKEN = 'RED_LOCK';
14+
type RedisLock = {
15+
acquire(): Promise<{ release: () => void }>;
16+
};
1317

1418
describe('PageService', () => {
1519
let service: PageService;
1620
let pageRepository: PageRepository;
1721
let nodeRepository: NodeRepository;
1822
let workspaceRepository: WorkspaceRepository;
19-
23+
let redisLock: RedisLock;
2024
beforeEach(async () => {
2125
const module: TestingModule = await Test.createTestingModule({
2226
providers: [
@@ -46,17 +50,28 @@ describe('PageService', () => {
4650
findOneBy: jest.fn(),
4751
},
4852
},
53+
{
54+
provide: RED_LOCK_TOKEN,
55+
useValue: {
56+
acquire: jest.fn(),
57+
},
58+
},
4959
],
5060
}).compile();
5161

5262
service = module.get<PageService>(PageService);
5363
pageRepository = module.get<PageRepository>(PageRepository);
5464
nodeRepository = module.get<NodeRepository>(NodeRepository);
5565
workspaceRepository = module.get<WorkspaceRepository>(WorkspaceRepository);
66+
redisLock = module.get<RedisLock>(RED_LOCK_TOKEN);
5667
});
5768

5869
it('서비스 클래스가 정상적으로 인스턴스화된다.', () => {
5970
expect(service).toBeDefined();
71+
expect(pageRepository).toBeDefined();
72+
expect(nodeRepository).toBeDefined();
73+
expect(workspaceRepository).toBeDefined();
74+
expect(redisLock).toBeDefined();
6075
});
6176

6277
describe('createPage', () => {
@@ -141,17 +156,15 @@ describe('PageService', () => {
141156
});
142157
});
143158

144-
describe('createLinkedPage', () => {
145-
it('', () => {});
146-
});
147-
148159
describe('deletePage', () => {
149160
it('id에 해당하는 페이지를 찾아 성공적으로 삭제한다.', async () => {
150161
jest
151162
.spyOn(pageRepository, 'delete')
152163
.mockResolvedValue({ affected: true } as any);
153164
jest.spyOn(pageRepository, 'findOneBy').mockResolvedValue(new Page());
154-
165+
jest.spyOn(redisLock, 'acquire').mockResolvedValue({
166+
release: jest.fn(),
167+
});
155168
await service.deletePage(1);
156169

157170
expect(pageRepository.delete).toHaveBeenCalledWith(1);
@@ -161,7 +174,9 @@ describe('PageService', () => {
161174
jest
162175
.spyOn(pageRepository, 'delete')
163176
.mockResolvedValue({ affected: false } as any);
164-
177+
jest.spyOn(redisLock, 'acquire').mockResolvedValue({
178+
release: jest.fn(),
179+
});
165180
await expect(service.deletePage(1)).rejects.toThrow(
166181
PageNotFoundException,
167182
);
@@ -199,10 +214,11 @@ describe('PageService', () => {
199214
emoji: '📝',
200215
workspace: null,
201216
};
202-
203217
jest.spyOn(pageRepository, 'findOneBy').mockResolvedValue(originPage);
204218
jest.spyOn(pageRepository, 'save').mockResolvedValue(newPage);
205-
219+
jest.spyOn(redisLock, 'acquire').mockResolvedValue({
220+
release: jest.fn(),
221+
});
206222
const result = await service.updatePage(1, dto);
207223

208224
expect(result).toEqual(newPage);
@@ -216,7 +232,9 @@ describe('PageService', () => {
216232
jest
217233
.spyOn(nodeRepository, 'findOneBy')
218234
.mockResolvedValue({ affected: false } as any);
219-
235+
jest.spyOn(redisLock, 'acquire').mockResolvedValue({
236+
release: jest.fn(),
237+
});
220238
await expect(service.updatePage(1, new UpdatePageDto())).rejects.toThrow(
221239
PageNotFoundException,
222240
);

apps/backend/src/page/page.service.ts

Lines changed: 48 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { Injectable } from '@nestjs/common';
1+
import { Injectable, Inject } from '@nestjs/common';
22
import { NodeRepository } from '../node/node.repository';
33
import { WorkspaceRepository } from '../workspace/workspace.repository';
44
import { PageRepository } from './page.repository';
@@ -8,15 +8,30 @@ import { UpdatePageDto } from './dtos/updatePage.dto';
88
import { UpdatePartialPageDto } from './dtos/updatePartialPage.dto';
99
import { PageNotFoundException } from '../exception/page.exception';
1010
import { WorkspaceNotFoundException } from '../exception/workspace.exception';
11+
import Redlock from 'redlock';
1112

13+
const RED_LOCK_TOKEN = 'RED_LOCK';
1214
@Injectable()
1315
export class PageService {
1416
constructor(
1517
private readonly pageRepository: PageRepository,
1618
private readonly nodeRepository: NodeRepository,
1719
private readonly workspaceRepository: WorkspaceRepository,
20+
@Inject(RED_LOCK_TOKEN) private readonly redisLock: Redlock,
1821
) {}
19-
22+
/**
23+
* redis에 저장된 페이지 정보를 다음 과정을 통해 주기적으로 데이터베이스에 반영한다.
24+
*
25+
* 1. redis에서 해당 페이지의 title과 content를 가져온다.
26+
* 2. 데이터베이스에 해당 페이지의 title과 content를 갱신한다.
27+
* 3. redis에서 해당 페이지 정보를 삭제한다.
28+
*
29+
* 만약 1번 과정을 진행한 상태에서 page가 삭제된다면 오류가 발생한다.
30+
* 위 과정을 진행하는 동안 page 정보 수정을 막기 위해 lock을 사용한다.
31+
*
32+
* 동기화를 위해 기존 페이지에 접근하여 수정하는 로직은 RedLock 알고리즘을 통해 락을 획득할 수 있을 때만 수행한다.
33+
* 기존 페이지에 접근하여 연산하는 로직의 경우 RedLock 알고리즘을 사용하여 동시 접근을 방지한다.
34+
*/
2035
async createPage(dto: CreatePageDto): Promise<Page> {
2136
const { title, content, workspaceId, x, y, emoji } = dto;
2237

@@ -47,40 +62,43 @@ export class PageService {
4762
return page;
4863
}
4964

50-
async createLinkedPage(title: string, nodeId: number): Promise<Page> {
51-
// 노드를 조회한다.
52-
const existingNode = await this.nodeRepository.findOneBy({ id: nodeId });
53-
// 페이지를 생성한다.
54-
const page = await this.pageRepository.save({ title, content: {} });
55-
56-
page.node = existingNode;
57-
return await this.pageRepository.save(page);
58-
}
59-
6065
async deletePage(id: number): Promise<void> {
61-
// 페이지를 삭제한다.
62-
const deleteResult = await this.pageRepository.delete(id);
63-
64-
// 만약 삭제된 페이지가 없으면 페이지를 찾지 못한 것
65-
if (!deleteResult.affected) {
66-
throw new PageNotFoundException();
66+
// 락을 획득할 때까지 기다린다.
67+
const lock = await this.redisLock.acquire([`user:${id.toString()}`], 1000);
68+
try {
69+
// 페이지를 삭제한다.
70+
const deleteResult = await this.pageRepository.delete(id);
71+
72+
// 만약 삭제된 페이지가 없으면 페이지를 찾지 못한 것
73+
if (!deleteResult.affected) {
74+
throw new PageNotFoundException();
75+
}
76+
} finally {
77+
// 락을 해제한다.
78+
await lock.release();
6779
}
6880
}
6981

7082
async updatePage(id: number, dto: UpdatePageDto): Promise<Page> {
71-
// 갱신할 페이지를 조회한다.
72-
// 페이지를 조회한다.
73-
const page = await this.pageRepository.findOneBy({ id });
74-
75-
// 페이지가 없으면 NotFound 에러
76-
if (!page) {
77-
throw new PageNotFoundException();
83+
// 락을 획득할 때까지 기다린다.
84+
const lock = await this.redisLock.acquire([`user:${id.toString()}`], 1000);
85+
try {
86+
// 갱신할 페이지를 조회한다.
87+
// 페이지를 조회한다.
88+
const page = await this.pageRepository.findOneBy({ id });
89+
90+
// 페이지가 없으면 NotFound 에러
91+
if (!page) {
92+
throw new PageNotFoundException();
93+
}
94+
// 페이지 정보를 갱신한다.
95+
const newPage = Object.assign({}, page, dto);
96+
97+
// 변경된 페이지를 저장
98+
return await this.pageRepository.save(newPage);
99+
} finally {
100+
await lock.release();
78101
}
79-
// 페이지 정보를 갱신한다.
80-
const newPage = Object.assign({}, page, dto);
81-
82-
// 변경된 페이지를 저장
83-
return await this.pageRepository.save(newPage);
84102
}
85103

86104
async updateBulkPage(pages: UpdatePartialPageDto[]) {
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { Module, forwardRef } from '@nestjs/common';
2+
import Redis from 'ioredis';
3+
import Redlock from 'redlock';
4+
import { RedisModule } from '../redis/redis.module';
5+
const RED_LOCK_TOKEN = 'RED_LOCK';
6+
const REDIS_CLIENT_TOKEN = 'REDIS_CLIENT';
7+
8+
@Module({
9+
imports: [forwardRef(() => RedisModule)],
10+
providers: [
11+
{
12+
provide: RED_LOCK_TOKEN,
13+
useFactory: (redisClient: Redis) => {
14+
return new Redlock([redisClient], {
15+
driftFactor: 0.01,
16+
retryCount: 10,
17+
retryDelay: 200,
18+
retryJitter: 200,
19+
automaticExtensionThreshold: 500,
20+
});
21+
},
22+
inject: [REDIS_CLIENT_TOKEN],
23+
},
24+
],
25+
exports: [RED_LOCK_TOKEN],
26+
})
27+
export class RedLockModule {}
Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,27 @@
1-
import { Module } from '@nestjs/common';
1+
import { Module, forwardRef } from '@nestjs/common';
2+
import { ConfigModule, ConfigService } from '@nestjs/config';
23
import { RedisService } from './redis.service';
4+
import Redis from 'ioredis';
5+
import { RedLockModule } from '../red-lock/red-lock.module';
6+
7+
// 의존성 주입할 때 redis client를 식별할 토큰
8+
const REDIS_CLIENT_TOKEN = 'REDIS_CLIENT';
39

410
@Module({
5-
providers: [RedisService],
6-
exports: [RedisService],
11+
imports: [ConfigModule, forwardRef(() => RedLockModule)], // ConfigModule 추가
12+
providers: [
13+
RedisService,
14+
{
15+
provide: REDIS_CLIENT_TOKEN,
16+
inject: [ConfigService], // ConfigService 주입
17+
useFactory: (configService: ConfigService) => {
18+
return new Redis({
19+
host: configService.get<string>('REDIS_HOST'),
20+
port: configService.get<number>('REDIS_PORT'),
21+
});
22+
},
23+
},
24+
],
25+
exports: [RedisService, REDIS_CLIENT_TOKEN],
726
})
827
export class RedisModule {}

apps/backend/src/redis/redis.service.spec.ts

Lines changed: 0 additions & 18 deletions
This file was deleted.

0 commit comments

Comments
 (0)