Skip to content

Commit b0fd571

Browse files
committed
feat: page 연산에 redis Lock 적용
1 parent c3267c5 commit b0fd571

File tree

3 files changed

+68
-27
lines changed

3 files changed

+68
-27
lines changed

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

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,13 +11,16 @@ import { PageNotFoundException } from '../exception/page.exception';
1111
import { WorkspaceRepository } from '../workspace/workspace.repository';
1212
import { WorkspaceNotFoundException } from '../exception/workspace.exception';
1313
const RED_LOCK_TOKEN = 'RED_LOCK';
14+
type RedisLock = {
15+
acquire(): Promise<{ release: Function }>;
16+
};
1417

1518
describe('PageService', () => {
1619
let service: PageService;
1720
let pageRepository: PageRepository;
1821
let nodeRepository: NodeRepository;
1922
let workspaceRepository: WorkspaceRepository;
20-
23+
let redisLock: RedisLock;
2124
beforeEach(async () => {
2225
const module: TestingModule = await Test.createTestingModule({
2326
providers: [
@@ -60,10 +63,15 @@ describe('PageService', () => {
6063
pageRepository = module.get<PageRepository>(PageRepository);
6164
nodeRepository = module.get<NodeRepository>(NodeRepository);
6265
workspaceRepository = module.get<WorkspaceRepository>(WorkspaceRepository);
66+
redisLock = module.get<RedisLock>(RED_LOCK_TOKEN);
6367
});
6468

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

6977
describe('createPage', () => {
@@ -154,7 +162,9 @@ describe('PageService', () => {
154162
.spyOn(pageRepository, 'delete')
155163
.mockResolvedValue({ affected: true } as any);
156164
jest.spyOn(pageRepository, 'findOneBy').mockResolvedValue(new Page());
157-
165+
jest.spyOn(redisLock, 'acquire').mockResolvedValue({
166+
release: jest.fn(),
167+
});
158168
await service.deletePage(1);
159169

160170
expect(pageRepository.delete).toHaveBeenCalledWith(1);
@@ -164,7 +174,9 @@ describe('PageService', () => {
164174
jest
165175
.spyOn(pageRepository, 'delete')
166176
.mockResolvedValue({ affected: false } as any);
167-
177+
jest.spyOn(redisLock, 'acquire').mockResolvedValue({
178+
release: jest.fn(),
179+
});
168180
await expect(service.deletePage(1)).rejects.toThrow(
169181
PageNotFoundException,
170182
);
@@ -202,10 +214,11 @@ describe('PageService', () => {
202214
emoji: '📝',
203215
workspace: null,
204216
};
205-
206217
jest.spyOn(pageRepository, 'findOneBy').mockResolvedValue(originPage);
207218
jest.spyOn(pageRepository, 'save').mockResolvedValue(newPage);
208-
219+
jest.spyOn(redisLock, 'acquire').mockResolvedValue({
220+
release: jest.fn(),
221+
});
209222
const result = await service.updatePage(1, dto);
210223

211224
expect(result).toEqual(newPage);
@@ -219,7 +232,9 @@ describe('PageService', () => {
219232
jest
220233
.spyOn(nodeRepository, 'findOneBy')
221234
.mockResolvedValue({ affected: false } as any);
222-
235+
jest.spyOn(redisLock, 'acquire').mockResolvedValue({
236+
release: jest.fn(),
237+
});
223238
await expect(service.updatePage(1, new UpdatePageDto())).rejects.toThrow(
224239
PageNotFoundException,
225240
);

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

Lines changed: 44 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,19 @@ export class PageService {
1919
private readonly workspaceRepository: WorkspaceRepository,
2020
@Inject(RED_LOCK_TOKEN) private readonly redisLock: Redlock,
2121
) {}
22-
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+
*/
2335
async createPage(dto: CreatePageDto): Promise<Page> {
2436
const { title, content, workspaceId, x, y, emoji } = dto;
2537

@@ -51,29 +63,42 @@ export class PageService {
5163
}
5264

5365
async deletePage(id: number): Promise<void> {
54-
// 페이지를 삭제한다.
55-
const deleteResult = await this.pageRepository.delete(id);
56-
57-
// 만약 삭제된 페이지가 없으면 페이지를 찾지 못한 것
58-
if (!deleteResult.affected) {
59-
throw new PageNotFoundException();
66+
// 락을 획득할 때까지 기다린다.
67+
const lock = await this.redisLock.acquire([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();
6079
}
6180
}
6281

6382
async updatePage(id: number, dto: UpdatePageDto): Promise<Page> {
64-
// 갱신할 페이지를 조회한다.
65-
// 페이지를 조회한다.
66-
const page = await this.pageRepository.findOneBy({ id });
67-
68-
// 페이지가 없으면 NotFound 에러
69-
if (!page) {
70-
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();
71101
}
72-
// 페이지 정보를 갱신한다.
73-
const newPage = Object.assign({}, page, dto);
74-
75-
// 변경된 페이지를 저장
76-
return await this.pageRepository.save(newPage);
77102
}
78103

79104
async updateBulkPage(pages: UpdatePartialPageDto[]) {

apps/backend/src/yjs/yjs.service.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,7 @@ export class YjsService
117117
// );
118118

119119
this.redisService.setField(
120-
pageId.toString(),
120+
`page:${pageId.toString()}`,
121121
'content',
122122
JSON.stringify(yXmlFragmentToProsemirrorJSON(editorDoc)),
123123
);
@@ -153,6 +153,7 @@ export class YjsService
153153
// title의 변경 사항을 감지한다.
154154
title.observeDeep(async (event) => {
155155
// path가 존재할 때만 페이지 갱신
156+
156157
event[0].path.toString().split('_')[1] &&
157158
// this.pageService.updatePage(
158159
// parseInt(event[0].path.toString().split('_')[1]),
@@ -161,7 +162,7 @@ export class YjsService
161162
// },
162163
// );
163164
this.redisService.setField(
164-
event[0].path.toString().split('_')[1],
165+
`page:${event[0].path.toString().split('_')[1]}`,
165166
'title',
166167
event[0].target.toString(),
167168
);

0 commit comments

Comments
 (0)