Skip to content

Commit 9b1029f

Browse files
committed
modify: redis 구현채, SCAN 으로 블록킹 완화
1 parent 672694f commit 9b1029f

File tree

2 files changed

+99
-43
lines changed

2 files changed

+99
-43
lines changed

src/modules/cache/__test__/redis.cache.test.ts

Lines changed: 54 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ interface MockRedisClient {
1313
del: jest.Mock;
1414
exists: jest.Mock;
1515
keys: jest.Mock;
16+
scan: jest.Mock;
1617
}
1718

1819
// Redis 모킹
@@ -45,6 +46,7 @@ describe('RedisCache', () => {
4546
del: jest.fn(),
4647
exists: jest.fn(),
4748
keys: jest.fn(),
49+
scan: jest.fn(),
4850
};
4951

5052
mockCreateClient = createClient as jest.MockedFunction<typeof createClient>;
@@ -223,11 +225,7 @@ describe('RedisCache', () => {
223225

224226
await redisCache.set('test-key', testData, 600);
225227

226-
expect(mockClient.setEx).toHaveBeenCalledWith(
227-
'test:cache:test-key',
228-
600,
229-
JSON.stringify(testData)
230-
);
228+
expect(mockClient.setEx).toHaveBeenCalledWith('test:cache:test-key', 600, JSON.stringify(testData));
231229
});
232230

233231
it('TTL 없이 값을 성공적으로 저장해야 한다 (기본 TTL 사용)', async () => {
@@ -239,7 +237,7 @@ describe('RedisCache', () => {
239237
expect(mockClient.setEx).toHaveBeenCalledWith(
240238
'test:cache:test-key',
241239
300, // 기본 TTL
242-
JSON.stringify(testData)
240+
JSON.stringify(testData),
243241
);
244242
});
245243

@@ -249,10 +247,7 @@ describe('RedisCache', () => {
249247

250248
await redisCache.set('test-key', testData, 0);
251249

252-
expect(mockClient.set).toHaveBeenCalledWith(
253-
'test:cache:test-key',
254-
JSON.stringify(testData)
255-
);
250+
expect(mockClient.set).toHaveBeenCalledWith('test:cache:test-key', JSON.stringify(testData));
256251
});
257252

258253
it('연결되지 않은 상태에서는 저장하지 않아야 한다', async () => {
@@ -367,32 +362,43 @@ describe('RedisCache', () => {
367362

368363
it('패턴에 맞는 키들을 성공적으로 삭제해야 한다', async () => {
369364
const matchingKeys = ['test:cache:key1', 'test:cache:key2'];
370-
mockClient.keys.mockResolvedValue(matchingKeys);
365+
mockClient.scan
366+
.mockResolvedValueOnce({ cursor: '10', keys: matchingKeys })
367+
.mockResolvedValueOnce({ cursor: '0', keys: [] });
371368
mockClient.del.mockResolvedValue(2);
372369

373370
await redisCache.clear('user:*');
374371

375-
expect(mockClient.keys).toHaveBeenCalledWith('test:cache:user:*');
372+
expect(mockClient.scan).toHaveBeenCalledWith('0', {
373+
MATCH: 'test:cache:user:*',
374+
COUNT: 100,
375+
});
376376
expect(mockClient.del).toHaveBeenCalledWith(matchingKeys);
377377
});
378378

379379
it('패턴 없이 모든 키를 삭제해야 한다', async () => {
380-
const allKeys = ['test:cache:key1', 'test:cache:key2', 'test:cache:key3'];
381-
mockClient.keys.mockResolvedValue(allKeys);
382-
mockClient.del.mockResolvedValue(3);
380+
const allKeys = ['test:cache:key1', 'test:cache:key2'];
381+
mockClient.scan.mockResolvedValueOnce({ cursor: '0', keys: allKeys });
382+
mockClient.del.mockResolvedValue(2);
383383

384384
await redisCache.clear();
385385

386-
expect(mockClient.keys).toHaveBeenCalledWith('test:cache:*');
386+
expect(mockClient.scan).toHaveBeenCalledWith('0', {
387+
MATCH: 'test:cache:*',
388+
COUNT: 100,
389+
});
387390
expect(mockClient.del).toHaveBeenCalledWith(allKeys);
388391
});
389392

390393
it('매칭되는 키가 없는 경우 삭제하지 않아야 한다', async () => {
391-
mockClient.keys.mockResolvedValue([]);
394+
mockClient.scan.mockResolvedValue({ cursor: '0', keys: [] });
392395

393396
await redisCache.clear('non-existent:*');
394397

395-
expect(mockClient.keys).toHaveBeenCalledWith('test:cache:non-existent:*');
398+
expect(mockClient.scan).toHaveBeenCalledWith('0', {
399+
MATCH: 'test:cache:non-existent:*',
400+
COUNT: 100,
401+
});
396402
expect(mockClient.del).not.toHaveBeenCalled();
397403
});
398404

@@ -403,12 +409,12 @@ describe('RedisCache', () => {
403409

404410
await redisCache.clear('test:*');
405411

406-
expect(mockClient.keys).not.toHaveBeenCalled();
412+
expect(mockClient.scan).not.toHaveBeenCalled();
407413
expect(mockClient.del).not.toHaveBeenCalled();
408414
});
409415

410416
it('Redis 에러 발생 시 조용히 실패해야 한다', async () => {
411-
mockClient.keys.mockRejectedValue(new Error('Redis error'));
417+
mockClient.scan.mockRejectedValue(new Error('Redis error'));
412418

413419
await expect(redisCache.clear('test:*')).resolves.not.toThrow();
414420
});
@@ -421,17 +427,23 @@ describe('RedisCache', () => {
421427
});
422428

423429
it('캐시 크기를 올바르게 반환해야 한다', async () => {
424-
const keys = ['test:cache:key1', 'test:cache:key2', 'test:cache:key3'];
425-
mockClient.keys.mockResolvedValue(keys);
430+
const keys1 = ['test:cache:key1', 'test:cache:key2'];
431+
const keys2 = ['test:cache:key3'];
432+
mockClient.scan
433+
.mockResolvedValueOnce({ cursor: '10', keys: keys1 })
434+
.mockResolvedValueOnce({ cursor: '0', keys: keys2 });
426435

427436
const result = await redisCache.size();
428437

429-
expect(mockClient.keys).toHaveBeenCalledWith('test:cache:*');
438+
expect(mockClient.scan).toHaveBeenCalledWith('0', {
439+
MATCH: 'test:cache:*',
440+
COUNT: 100,
441+
});
430442
expect(result).toBe(3);
431443
});
432444

433445
it('빈 캐시의 크기는 0이어야 한다', async () => {
434-
mockClient.keys.mockResolvedValue([]);
446+
mockClient.scan.mockResolvedValue({ cursor: '0', keys: [] });
435447

436448
const result = await redisCache.size();
437449

@@ -446,11 +458,11 @@ describe('RedisCache', () => {
446458
const result = await redisCache.size();
447459

448460
expect(result).toBe(0);
449-
expect(mockClient.keys).not.toHaveBeenCalled();
461+
expect(mockClient.scan).not.toHaveBeenCalled();
450462
});
451463

452464
it('Redis 에러 발생 시 0을 반환해야 한다', async () => {
453-
mockClient.keys.mockRejectedValue(new Error('Redis error'));
465+
mockClient.scan.mockRejectedValue(new Error('Redis error'));
454466

455467
const result = await redisCache.size();
456468

@@ -460,7 +472,9 @@ describe('RedisCache', () => {
460472

461473
describe('이벤트 핸들러', () => {
462474
it('연결 이벤트 시 상태를 업데이트해야 한다', () => {
463-
const connectCall = mockClient.on.mock.calls.find((call: [string, (...args: unknown[]) => void]) => call[0] === 'connect');
475+
const connectCall = mockClient.on.mock.calls.find(
476+
(call: [string, (...args: unknown[]) => void]) => call[0] === 'connect',
477+
);
464478
const connectHandler = connectCall?.[1];
465479

466480
expect(connectHandler).toBeDefined();
@@ -470,12 +484,16 @@ describe('RedisCache', () => {
470484

471485
it('에러 이벤트 시 상태를 업데이트해야 한다', () => {
472486
// 먼저 연결 상태로 만들기
473-
const connectCall = mockClient.on.mock.calls.find((call: [string, (...args: unknown[]) => void]) => call[0] === 'connect');
487+
const connectCall = mockClient.on.mock.calls.find(
488+
(call: [string, (...args: unknown[]) => void]) => call[0] === 'connect',
489+
);
474490
const connectHandler = connectCall?.[1];
475491
expect(connectHandler).toBeDefined();
476492
connectHandler?.();
477493

478-
const errorCall = mockClient.on.mock.calls.find((call: [string, (...args: unknown[]) => void]) => call[0] === 'error');
494+
const errorCall = mockClient.on.mock.calls.find(
495+
(call: [string, (...args: unknown[]) => void]) => call[0] === 'error',
496+
);
479497
const errorHandler = errorCall?.[1];
480498

481499
expect(errorHandler).toBeDefined();
@@ -485,12 +503,16 @@ describe('RedisCache', () => {
485503

486504
it('연결 해제 이벤트 시 상태를 업데이트해야 한다', () => {
487505
// 먼저 연결 상태로 만들기
488-
const connectCall = mockClient.on.mock.calls.find((call: [string, (...args: unknown[]) => void]) => call[0] === 'connect');
506+
const connectCall = mockClient.on.mock.calls.find(
507+
(call: [string, (...args: unknown[]) => void]) => call[0] === 'connect',
508+
);
489509
const connectHandler = connectCall?.[1];
490510
expect(connectHandler).toBeDefined();
491511
connectHandler?.();
492512

493-
const disconnectCall = mockClient.on.mock.calls.find((call: [string, (...args: unknown[]) => void]) => call[0] === 'disconnect');
513+
const disconnectCall = mockClient.on.mock.calls.find(
514+
(call: [string, (...args: unknown[]) => void]) => call[0] === 'disconnect',
515+
);
494516
const disconnectHandler = disconnectCall?.[1];
495517

496518
expect(disconnectHandler).toBeDefined();
@@ -499,7 +521,6 @@ describe('RedisCache', () => {
499521
});
500522
});
501523

502-
503524
describe('private getFullKey', () => {
504525
it('키에 접두사를 올바르게 추가해야 한다', async () => {
505526
mockClient.connect.mockResolvedValue(undefined);
@@ -523,4 +544,4 @@ describe('RedisCache', () => {
523544
expect(mockClient.get).toHaveBeenCalledWith('test:cache:');
524545
});
525546
});
526-
});
547+
});

src/modules/cache/redis.cache.ts

Lines changed: 45 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,6 @@ export class RedisCache implements ICache {
107107
await this.delete(key);
108108
return null;
109109
}
110-
111110
} catch (error) {
112111
logger.error(`Cache GET error for key ${key}:`, error);
113112
return null;
@@ -165,20 +164,39 @@ export class RedisCache implements ICache {
165164
}
166165
}
167166

168-
async clear(pattern?: string): Promise<void> {
167+
async clear(pattern?: string, batchSize: number = 100): Promise<void> {
169168
try {
170169
if (!this.connected) {
171170
logger.warn('Redis not connected, skipping cache clear');
172171
return;
173172
}
174173

175-
const searchPattern = pattern
176-
? `${this.keyPrefix}${pattern}`
177-
: `${this.keyPrefix}*`;
174+
const searchPattern = pattern ? `${this.keyPrefix}${pattern}` : `${this.keyPrefix}*`;
175+
176+
let cursor = '0';
177+
let totalDeleted = 0;
178+
179+
do {
180+
const result = await this.client.scan(cursor, {
181+
MATCH: searchPattern,
182+
COUNT: batchSize,
183+
});
184+
185+
cursor = result.cursor;
186+
const keys = result.keys;
187+
188+
if (keys.length > 0) {
189+
await this.client.del(keys);
190+
totalDeleted += keys.length;
191+
}
178192

179-
const keys = await this.client.keys(searchPattern);
180-
if (keys.length > 0) {
181-
await this.client.del(keys);
193+
if (cursor !== '0') {
194+
await new Promise((resolve) => setImmediate(resolve));
195+
}
196+
} while (cursor !== '0');
197+
198+
if (totalDeleted > 0) {
199+
logger.info(`Cache cleared: ${totalDeleted} keys deleted`);
182200
}
183201
} catch (error) {
184202
logger.error(`Cache CLEAR error for pattern ${pattern}:`, error);
@@ -191,8 +209,25 @@ export class RedisCache implements ICache {
191209
return 0;
192210
}
193211

194-
const keys = await this.client.keys(`${this.keyPrefix}*`);
195-
return keys.length;
212+
let cursor = '0';
213+
let count = 0;
214+
const batchSize = 100;
215+
216+
do {
217+
const result = await this.client.scan(cursor, {
218+
MATCH: `${this.keyPrefix}*`,
219+
COUNT: batchSize,
220+
});
221+
222+
cursor = result.cursor;
223+
count += result.keys.length;
224+
225+
if (cursor !== '0') {
226+
await new Promise((resolve) => setImmediate(resolve));
227+
}
228+
} while (cursor !== '0');
229+
230+
return count;
196231
} catch (error) {
197232
logger.error('Cache SIZE error:', error);
198233
return 0;

0 commit comments

Comments
 (0)