diff --git a/README.md b/README.md index 6b1c5a0..3946221 100644 --- a/README.md +++ b/README.md @@ -15,8 +15,21 @@ locker.lock('locks:fastlock',5000); array.push(1); array.push(2); locker.unlock(); +``` + +### Using Mock Redis (for development) + +You can use a mock Redis instance for development and testing without running a real Redis server: +```[js](js) +const { FastLock } = require('@fastcampus/fastlock'); +const locker = FastLock.create({ useMock: true }); +const array = []; +await locker.lock('locks:fastlock', 5000); +array.push(1); +array.push(2); +await locker.unlock(); ``` ## Contributing @@ -27,6 +40,12 @@ locker.unlock(); $ npm run ci:test ``` +### test with mock Redis + +```console +$ npm run test:mock +``` + ### build ```console diff --git a/example/example.ts b/example/example.ts index c37b2ed..ecef963 100644 --- a/example/example.ts +++ b/example/example.ts @@ -1,30 +1,92 @@ import { LoggerFactory } from '@day1co/pebbles'; import { FastLock } from '../src/fast-lock'; +/** + * FastLock 라이브러리 사용 예제 + * + * 이 예제는 분산 락을 사용하여 공유 리소스(배열)에 대한 동시 접근을 제어하는 방법을 보여줍니다. + * 두 개의 프로세스가 동일한 리소스에 접근하려 할 때, FastLock이 어떻게 동작하는지 시연합니다. + */ + const logger = LoggerFactory.getLogger('fastlock:example'); -const sleep = async (ms) => new Promise((res) => setTimeout(res, ms)); +const sleep = async (ms: number) => new Promise((res) => setTimeout(res, ms)); + +// 공유 리소스로 사용될 배열 +const sharedArray: number[] = []; -const myArray = []; -const anotherProcess = async () => { +/** + * 두 번째 프로세스를 시뮬레이션하는 함수 + * 첫 번째 프로세스가 락을 보유하고 있는 동안 리소스 접근을 시도합니다. + * + * @returns Promise + */ +const simulateSecondProcess = async (): Promise => { const locker = FastLock.create({ redis: { host: 'localhost', port: 6379, db: 0 } }); - await locker.lock('locks:example', 2000); - myArray.push(4); - myArray.push(5); - myArray.push(6); - await sleep(2000); + try { + // 2초 동안 유효한 락 획득 시도 + await locker.lock('locks:example', 2000); + logger.debug('Second process: Lock acquired'); + + // 공유 리소스 수정 + sharedArray.push(4); + sharedArray.push(5); + sharedArray.push(6); + + // 작업 시뮬레이션을 위한 대기 + await sleep(2000); + + await locker.unlock(); + logger.debug('Second process: Lock released'); + } catch (err) { + logger.error('Second process error: %o', err); + } finally { + locker.destroy(); + } }; -const main = async () => { +/** + * 메인 프로세스 실행 함수 + * 첫 번째로 락을 획득하고, 작업 중에 다른 프로세스의 접근을 시도합니다. + * + * 시나리오: + * 1. 첫 번째 프로세스가 락을 획득하고 배열에 1,2,3을 추가 + * 2. 100ms 후 두 번째 프로세스가 시작되어 락 획득 시도 + * 3. 첫 번째 프로세스가 작업을 완료하고 락을 해제 + * 4. 두 번째 프로세스가 락을 획득하고 배열에 4,5,6을 추가 + * + * @returns Promise + */ +const demonstrateLocking = async (): Promise => { const locker = FastLock.create({ redis: { host: 'localhost', port: 6379, db: 0 } }); - await locker.lock('locks:example', 2000); - myArray.push(1); - myArray.push(2); - myArray.push(3); - setTimeout(async () => anotherProcess(), 100); - await sleep(2000); - await locker.unlock(); - locker.destroy(); - logger.debug('%o', myArray); + try { + // 2초 동안 유효한 락 획득 + await locker.lock('locks:example', 2000); + logger.debug('Main process: Lock acquired'); + + // 공유 리소스 수정 + sharedArray.push(1); + sharedArray.push(2); + sharedArray.push(3); + + // 두 번째 프로세스 시작 + setTimeout(async () => simulateSecondProcess(), 100); + + // 작업 시뮬레이션을 위한 대기 + await sleep(2000); + + await locker.unlock(); + logger.debug('Main process: Lock released'); + } catch (err) { + logger.error('Main process error: %o', err); + } finally { + locker.destroy(); + logger.debug('Final shared array state: %o', sharedArray); + } }; -main().then(console.info).catch(console.error); +// 예제 실행 +demonstrateLocking().then(() => { + logger.info('Example completed'); +}).catch(err => { + logger.error('Example failed: %o', err); +}); diff --git a/jest.config.js b/jest.config.js index 142c679..73e3f09 100644 --- a/jest.config.js +++ b/jest.config.js @@ -1,11 +1,12 @@ module.exports = { + preset: 'ts-jest', + testEnvironment: 'node', globals: { 'ts-jest': { tsConfig: 'tsconfig.json', }, }, - transform: { - '^.+\\.(ts|tsx)$': 'ts-jest', - }, - testEnvironment: 'node', + testTimeout: 30000, + verbose: true, + collectCoverage: true, }; diff --git a/mocking-test-fastlock.spec.js b/mocking-test-fastlock.spec.js new file mode 100644 index 0000000..0628fc5 --- /dev/null +++ b/mocking-test-fastlock.spec.js @@ -0,0 +1,245 @@ +const { FastLock } = require('./lib/index'); + +// 공유 리소스 +const sharedResource = []; + +// 프로세스 1 시뮬레이션 +async function process1() { + const locker = FastLock.create({ useMock: true }); + let lockAcquired = false; + + console.log('프로세스 1: 시작...'); + try { + console.log('프로세스 1: 락 획득 시도...'); + await locker.lock('test-lock', 5000); + lockAcquired = true; + console.log('프로세스 1: 락 획득 성공!'); + + // 공유 리소스 작업 + sharedResource.push('프로세스 1: 아이템 1'); + await new Promise((resolve) => setTimeout(resolve, 1000)); + console.log('프로세스 1: 작업 중...'); + sharedResource.push('프로세스 1: 아이템 2'); + await new Promise((resolve) => setTimeout(resolve, 1000)); + sharedResource.push('프로세스 1: 아이템 3'); + + console.log('프로세스 1: 작업 완료'); + } catch (err) { + console.log('프로세스 1: 에러 발생 -', err.message); + } finally { + if (lockAcquired) { + console.log('프로세스 1: 락 해제'); + await locker.unlock(); + } + locker.destroy(); + } +} + +// 프로세스 2 시뮬레이션 +async function process2() { + const locker = FastLock.create({ useMock: true }); + let lockAcquired = false; + + console.log('프로세스 2: 시작...'); + try { + console.log('프로세스 2: 락 획득 시도...'); + await locker.lock('test-lock', 5000); + lockAcquired = true; + console.log('프로세스 2: 락 획득 성공!'); + + // 공유 리소스 작업 + sharedResource.push('프로세스 2: 아이템 1'); + await new Promise((resolve) => setTimeout(resolve, 500)); + console.log('프로세스 2: 작업 중...'); + sharedResource.push('프로세스 2: 아이템 2'); + await new Promise((resolve) => setTimeout(resolve, 500)); + sharedResource.push('프로세스 2: 아이템 3'); + + console.log('프로세스 2: 작업 완료'); + } catch (err) { + console.log('프로세스 2: 에러 발생 -', err.message); + } finally { + if (lockAcquired) { + console.log('프로세스 2: 락 해제'); + await locker.unlock(); + } + locker.destroy(); + } +} + +// 락 만료 테스트 +async function testLockExpiration() { + console.log('\n=== 락 만료 테스트 시작 ==='); + const locker1 = FastLock.create({ useMock: true }); + const locker2 = FastLock.create({ useMock: true }); + const results = []; + + try { + // 첫 번째 락 획득 (짧은 만료 시간) + await locker1.lock('expire-test-lock', 1000); + results.push('락1 획득'); + console.log('락1 획득됨 (만료 시간: 1초)'); + + // 만료 시간보다 조금 더 대기 + await new Promise((resolve) => setTimeout(resolve, 1100)); + console.log('1.1초 대기 완료'); + + // 두 번째 클라이언트가 락 획득 시도 + await locker2.lock('expire-test-lock', 1000); + results.push('락2 획득'); + console.log('락2 획득됨 (락1 만료 후)'); + await locker2.unlock(); + + console.log('\n테스트 결과:', results); + console.log('예상 결과:', ['락1 획득', '락2 획득']); + console.log('테스트 ' + (results.join(',') === ['락1 획득', '락2 획득'].join(',') ? '성공' : '실패')); + } catch (err) { + console.error('테스트 중 에러 발생:', err); + } finally { + locker1.destroy(); + locker2.destroy(); + } + console.log('=== 락 만료 테스트 완료 ===\n'); +} + +// 락 재시도 테스트 +async function testLockRetry() { + console.log('\n=== 락 재시도 테스트 시작 ==='); + const locker1 = FastLock.create({ useMock: true }); + const results = []; + + try { + // 첫 번째 락 획득 + await locker1.lock('retry-test-lock', 2000); + results.push('첫 번째 락 획득'); + console.log('첫 번째 락 획득됨'); + + // 재시도 설정으로 두 번째 락커 생성 + const retryLocker = FastLock.create({ + useMock: true, + redlock: { + driftFactor: 0.01, + retryCount: 3, + retryDelay: 200, + retryJitter: 100, + }, + }); + + try { + // 이미 잠긴 리소스에 대한 락 획득 시도 + await retryLocker.lock('retry-test-lock', 1000); + results.push('재시도 락 획득 (예상치 못한 결과)'); + } catch (err) { + results.push('재시도 실패 (예상된 결과)'); + console.log('예상된 재시도 실패 발생'); + } + + await locker1.unlock(); + retryLocker.destroy(); + + console.log('\n테스트 결과:', results); + console.log('예상 결과:', ['첫 번째 락 획득', '재시도 실패 (예상된 결과)']); + console.log( + '테스트 ' + (results.join(',') === ['첫 번째 락 획득', '재시도 실패 (예상된 결과)'].join(',') ? '성공' : '실패') + ); + } catch (err) { + console.error('테스트 중 에러 발생:', err); + } finally { + locker1.destroy(); + } + console.log('=== 락 재시도 테스트 완료 ===\n'); +} + +// 네트워크 지연 시뮬레이션 테스트 +async function testNetworkDelay() { + console.log('\n=== 네트워크 지연 시뮬레이션 테스트 시작 ==='); + const locker1 = FastLock.create({ useMock: true }); + const locker2 = FastLock.create({ useMock: true }); + const results = []; + + try { + // 첫 번째 락 획득 + await locker1.lock('delay-test-lock', 2000); + results.push('첫 번째 락 획득'); + console.log('첫 번째 락 획득됨'); + + // 지연된 언락 시뮬레이션 + const delayedUnlock = async () => { + await new Promise((resolve) => setTimeout(resolve, 2500)); // 락 만료 시간보다 긴 지연 + try { + await locker1.unlock(); + results.push('지연된 언락 성공'); + } catch (err) { + results.push('지연된 언락 실패'); + console.log('예상된 지연 언락 실패 발생'); + } + }; + + // 지연된 언락 시작 + delayedUnlock(); + + // 지연 중 두 번째 클라이언트의 락 획득 시도 + await new Promise((resolve) => setTimeout(resolve, 2200)); + await locker2.lock('delay-test-lock', 2000); + results.push('두 번째 락 획득'); + console.log('두 번째 락 획득됨 (첫 번째 락 만료 후)'); + await locker2.unlock(); + + // 모든 작업 완료 대기 + await new Promise((resolve) => setTimeout(resolve, 500)); + + console.log('\n테스트 결과:', results); + console.log('예상 결과:', ['첫 번째 락 획득', '두 번째 락 획득', '지연된 언락 실패']); + console.log( + '테스트 ' + + (results.join(',') === ['첫 번째 락 획득', '두 번째 락 획득', '지연된 언락 실패'].join(',') ? '성공' : '실패') + ); + } catch (err) { + console.error('테스트 중 에러 발생:', err); + } finally { + locker1.destroy(); + locker2.destroy(); + } + console.log('=== 네트워크 지연 시뮬레이션 테스트 완료 ===\n'); +} + +// 동시성 테스트 실행 +async function runConcurrencyTest() { + console.log('=== FastLock 동시성 테스트 시작 ===\n'); + + // 두 프로세스를 거의 동시에 시작 + const p1 = process1(); + const p2 = process2(); + + // 두 프로세스가 모두 완료될 때까지 대기 + await Promise.all([p1, p2]); + + console.log('\n최종 공유 리소스 상태:'); + console.log(sharedResource); + + // 결과 검증 + const process1Items = sharedResource.filter((item) => item.startsWith('프로세스 1')); + const process2Items = sharedResource.filter((item) => item.startsWith('프로세스 2')); + + console.log('\n=== 테스트 결과 ==='); + console.log('프로세스 1 아이템:', process1Items.length === 3 ? '완료' : '미완료'); + console.log('프로세스 2 아이템:', process2Items.length === 3 ? '완료' : '미완료'); + console.log('총 아이템 수:', sharedResource.length === 6 ? '정상' : '비정상'); + + console.log('\n=== FastLock 동시성 테스트 완료 ==='); +} + +// 모든 테스트 실행 +async function runAllTests() { + try { + await runConcurrencyTest(); + await testLockExpiration(); + await testLockRetry(); + await testNetworkDelay(); + } catch (err) { + console.error('테스트 실행 중 에러 발생:', err); + } +} + +// 테스트 실행 +runAllTests(); diff --git a/package-lock.json b/package-lock.json index 5ee8264..c5e748c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -32,7 +32,7 @@ "typescript": "^5.3.3" }, "peerDependencies": { - "@day1co/pebbles": "~3.2.7", + "@day1co/pebbles": "^3.2.0", "redlock": "^5.0.0-beta.2" } }, @@ -4776,7 +4776,8 @@ "integrity": "sha512-N3yCS/NegsOBokc8GAdM8UcmfsKiSS8cipheD/nivzr700H+nsMOxJjQnvwOcRYVuFkdH0wGUvW2WbXGmrZGbQ==", "dev": true, "dependencies": { - "@isaacs/cliui": "^8.0.2" + "@isaacs/cliui": "^8.0.2", + "@pkgjs/parseargs": "^0.11.0" }, "engines": { "node": ">=14" @@ -5017,6 +5018,7 @@ "@types/node": "*", "anymatch": "^3.0.3", "fb-watchman": "^2.0.0", + "fsevents": "^2.3.2", "graceful-fs": "^4.2.9", "jest-regex-util": "^29.6.3", "jest-util": "^29.7.0", diff --git a/package.json b/package.json index 2a376b3..b122f0a 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "serve": "echo no serve", "start": "npm run serve", "test": "jest --runInBand --forceExit --coverage --verbose ./src", + "test:mock": "npm run build && node mocking-test-fastlock.spec.js", "ci:test": "./ci_test.sh", "watch": "tsc -w" }, diff --git a/src/fast-lock.spec.ts b/src/fast-lock.spec.ts index 109843c..929528f 100644 --- a/src/fast-lock.spec.ts +++ b/src/fast-lock.spec.ts @@ -85,5 +85,99 @@ describe('FastLock', () => { expect(testArray).toEqual(['bar', 'foo']); } }); + + // 락 만료 시간 테스트 + test('lock expiration', async () => { + const testArray: string[] = []; + try { + // 짧은 만료 시간으로 락 획득 + await locker.lock(lockKey, 1000); + testArray.push('acquired'); + + // 만료 시간보다 조금 더 대기 + await sleep(1100); + + // 다른 클라이언트가 락을 획득할 수 있어야 함 + await locker2.lock(lockKey, 1000); + testArray.push('reacquired'); + await locker2.unlock(); + + expect(testArray).toEqual(['acquired', 'reacquired']); + } catch (err) { + logger.error('%o', err); + } + }); + + // 락 재시도 테스트 + test('lock retry', async () => { + const testArray: string[] = []; + try { + // 첫 번째 클라이언트가 락 획득 + await locker.lock(lockKey, 2000); + testArray.push('first'); + + // 두 번째 클라이언트의 락 획득 시도 (실패 예상) + const retryConfig = { + driftFactor: 0.01, + retryCount: 3, + retryDelay: 200, + retryJitter: 100, + }; + + const retryLocker = FastLock.create({ + redis: { host: 'localhost', port: 6379, db: 0 }, + redlock: retryConfig, + }); + + try { + await retryLocker.lock(lockKey, 1000); + } catch (err) { + testArray.push('retry-failed'); + } + + await locker.unlock(); + retryLocker.destroy(); + + expect(testArray).toEqual(['first', 'retry-failed']); + } catch (err) { + logger.error('%o', err); + } + }); + + // 네트워크 지연 시뮬레이션 테스트 + test('network delay simulation', async () => { + const testArray: string[] = []; + try { + // 첫 번째 클라이언트가 락 획득 + await locker.lock(lockKey, 2000); + testArray.push('acquired'); + + // 네트워크 지연 시뮬레이션 + const delayedUnlock = async () => { + await sleep(2500); // 락 만료 시간보다 더 긴 지연 + try { + await locker.unlock(); + testArray.push('unlock-after-delay'); + } catch (err) { + testArray.push('unlock-failed'); + } + }; + + // 지연된 언락 시작 + delayedUnlock(); + + // 지연 동안 다른 클라이언트가 락 획득 시도 + await sleep(2200); + await locker2.lock(lockKey, 2000); + testArray.push('reacquired'); + await locker2.unlock(); + + await sleep(500); // 지연된 언락이 완료될 때까지 대기 + + expect(testArray).toEqual(['acquired', 'reacquired', 'unlock-failed']); + } catch (err) { + logger.error('%o', err); + } + }); }); }); diff --git a/src/fast-lock.ts b/src/fast-lock.ts index e87acf3..22c6ec0 100644 --- a/src/fast-lock.ts +++ b/src/fast-lock.ts @@ -8,6 +8,7 @@ type FastLockOpts = { redis?: RedisOptions; createRedisClient?: (opts: RedisOptions) => Redis; redlock?: any; + useMock?: boolean; }; export class FastLock { @@ -20,8 +21,22 @@ export class FastLock { private locker: any; private constructor(opts?: FastLockOpts) { - this.client = opts?.createRedisClient ? opts?.createRedisClient(opts?.redis ?? {}) : new IORedis(opts?.redis ?? {}); - logger.debug(`connect redis: ${opts?.redis?.host}:${opts?.redis?.port}/${opts?.redis?.db}`); + if (opts?.useMock) { + try { + // eslint-disable-next-line @typescript-eslint/no-var-requires + const RedisMock = require('ioredis-mock'); + this.client = new RedisMock(); + logger.debug('Using Redis mock client'); + } catch (err) { + logger.error('Failed to load ioredis-mock. Please install it with: npm install --save-dev ioredis-mock'); + throw new Error('ioredis-mock is required when useMock=true'); + } + } else { + this.client = opts?.createRedisClient + ? opts.createRedisClient(opts?.redis ?? {}) + : new IORedis(opts?.redis ?? {}); + logger.debug(`connect redis: ${opts?.redis?.host}:${opts?.redis?.port}/${opts?.redis?.db}`); + } this.redlock = new Redlock( [this.client], @@ -50,7 +65,7 @@ export class FastLock { public destroy() { logger.debug('destroy'); - this.client.end(true); + this.client.disconnect?.() || this.client.quit?.() || this.client.end?.(true); } //--------------------------------------------------------