끄적끄적

분산락 추상화 시키기 본문

개발/js & ts & node.js

분산락 추상화 시키기

코리이 2024. 1. 29. 20:56

 

작년 초에 선착순 NFT 판매 이벤트를 개발해야 한 적이 있었다. 당시 선착순 이벤트의 경우 Redis 에서 분산락 알고리즘으로 제공하는 redlock 을 활용해서 개발하는 경우가 대부분이였으며 정보도 많았었다. 당연히 필자 또한 빠르게 개발하기 위해 이를 활용했었다. 그러나 이벤트가 종료된 후에는 선착순 판매가 아닌 단순히 상품(NFT)을 열어두고 판매하는 상황이 대부분이게 되었다. (사실 이벤트 때도 많은 인원수가 몰리지 않아 실망했던 기억이 있다.) 시간이 흐르면서 레디스 자체가 성능적으로 큰 필요가 없는 경우가 대부분이라는 사실을 알게 되어 쓸데 없는 비용을 줄이기 위한 작업을 진행하기로 했다.

 

요구사항 자체는 간단했다. 

  1. 기존 비즈니스 로직은 건드리지 않을 것
  2. 언제든 선착순 이벤트로 바뀔 시 빠른 시간 내에 변경 가능하도록 설계할 것

결국 이를 해결하기 위해서는 레디스를 사용하는 것 자체를 추상화할 필요가 있다고 생각이 되어 락 자체를 추상화하는 작업을 진행하게 되었다. 개인적으로 생각하기에도 레디스는 인프라적인 요소로 추상화해야 한다고 생각하기도 한다. 

이전 로직

우선 기존에 사용하던 로직 자체를 추상화시킬 필요가 있었다.  실제 회사에서 쓰는 코드를 가져올 수는 없으니 예시 코드를 한번 들여다 보자.

export class OrderService {
    constructor(
        private readonly redlock: RedlockManager,
    ) {
    }

    public async order(command: Command) {
        const { productId } = command;

        // lock 획득
        const lock = await this.redlock.acquire(productId);
        try {
            // logics ...

        } finally {
            // lock 해제
            await this.redlock.release(lock);
        }
    }
}

 

이 때 RedLockManager 의 경우 Redis 의 lock 을 사용하기 위해 간단하게 만들어둔 모듈이라고 생각하면 된다. 이 때 redlock 의 구현체로는 node-redlock 을 사용했다. 현재 로직에는 redis 가 직접적으로 관여하고 있기 때문에 이를 인터페이스로 바꿔서 수정하기로 했다. 이 때 고민스러웠던 부분은 Lock 이라는 형태로 추상화할건지, 특정 도메인에 포함된 채로 Lock 을 추상화할건지에 대한 것이였다. 결과론적으로 말하자면 Lock 도 어떻게 보면 비즈니스 로직중에 하나 라는 생각이 들어 도메인에 포함된 Lock 이지만 어떤 인프라적인 요소를 사용할지만 추상화하기로 결정했다.

Lock 추상화 이후 변화

그래서 위의 코드 중에 일단 Lock 사용 형태를 바꿀 필요가 있었다. 우선적으로 try ~ finally 를 외부로 빼는 작업을 하면 될 것 같다는 생각을 했다. 실제로 node-redlock 사용법 중에는 using 메서드를 사용하면 비즈니스 로직을 lock 으로 wrapping 한 형태로 사용할 수 있다. 또한 회사 프로젝트 에서 Transaction 같은 부분도 비슷한 방식으로 명시적으로 사용하고 있어 이를 추상화 시키면 될 것 같다고 생각했다.

 

그렇기 때문에 Lock 의 인터페이스 코드는 아래와 같은 형태로 개발했다.

abstract class IProductOrderLocker {
    abstract lock<T>(productId: string, work: () => T | Promise<T>): Promise<T>;
}

 

그리고 이 구현체에서 직접 Redis 를 사용하도록 개발했으며 이는 Infra Layer 에 위치해서 Domain Layer 에는 영향을 끼치지 않도록 만들었다.

class RedLockProductOrderLocker implements IProductOrderLocker {
    constructor(
        private readonly redlock: Redlock
    ) {
    }

    async lock<T>(productId: string, work: () => (Promise<T> | T)): Promise<T> {
        const lock = await this.redlock.acquire(productId);
        try {
            return await work();
        } finally {
            await this.redlock.release(lock);
        }
    }
}

 

그러면 이제 이 Lock 은 추상화되 형태로 비즈니스 로직에서 직접 사용할 수 있게 된다.

export class OrderService {
    constructor(
        private readonly productOrderLocker: IProductOrderLocker,
    ) {
    }

    public async order(command: Command) {
        const { productId } = command;

        // lock 획득
        await this.productOrderLocker.lock(productId, async () => {
            // logics ....
        });
    }
}

 

즉 이제 도메인에는 Redis 를 이용해 Lock 을 거는지, MySql 의 Lock 을 거는지 혹은 또다른 무언가를 이용해 Lock 을 거는지 모르는 상태가 되었으며 logic 의 형태 또한 변경하지 않고 그대로 사용할 수 있게 되었다. 

MySQL Lock 으로 변경

Lock 자체를 추상화 했으므로 MySQL Lock 으로 변경하는건 단순히 인터페이스를 구현만 해주면 되었다. 이 또한 Infra Layer 에서 구현하면 된다. 하지만 MySQL 을 사용할 때 두 가지 고민이 있었는데 Pessimistic Lock 을 활용할지 Named Lock 을 활용할지에 대한 고민이였다. 결론적으로는 Pessimistic Lock 을 활용하기로 했는데 그 이유는 현재 로직에서 product 의 경우 항상 존재 하는 레코드이면서 unique 하게 되므로 정확히 한 개의 record 에만 lock 을 걸게되어 다른 이슈로 퍼질 위험이 적어지기 때문이다.  물론 그렇지 않은 경우에는 NamedLock 을 활용해서 다시 구현체만 만들면 된다.

 

이를 구현한 코드는 Typeorm 을 활용하면 아래와 같이 구현할 수 있다.

class MySqlProductOrderLocker implements IProductOrderLocker {
    constructor(
        private readonly manager: TypeOrmManager,
        private readonly tx: Transaction,
    ) {
    }

    async lock<T>(productId: string, work: () => (Promise<T> | T)): Promise<T> {
        return await this.tx.withTransaction(async () => {
            await this.manager.getEntityManager()
                .createQueryBuilder(ProductEntity, 'product')
                .select('product.id')
                .where(`product.id = :id`, { id: productId })
                .setLock('pessimistic_write')
                .getOne();

            return await work();
        });
    }
}

 

혹시나 tx 로 감싸지지 않은 경우에서는 Lock 이 제대로  작동하지 않을 수 있으므로 transaction 을 열어서 사용하고 있는 것을 확인할 수 있다. 여기서 참고로 필자의 회사에서는 transaction in transaction 인 경우 하나의 transaction 으로 사용하도록 ITransaction 을 따로 구현해서 사용하고 있다. 

 

그렇다면 비즈니스 로직은 어떻게 될까? 모두 알겠지만 아예 기존과 변화가 없게 된다.

export class OrderService {
    constructor(
        private readonly productOrderLocker: IProductOrderLocker,
    ) {
    }

    public async order(command: Command) {
        const { productId } = command;

        // lock 획득
        await this.productOrderLocker.lock(productId, async () => {
            // logics ....
        });
    }
}

Nest App 에서 알맞은 구현체 주입

이제 Lock 이 두 가지 구현체를 가지게 되었으므로 단순히 Nest App 에서 사용할 인프라를 잘 정해주면 된다. 

providers: [
    // redis 를 사용할 경우
    {
        provide: IProductOrderLocker,
        inject: [Redlock],
        useFactory: (redlock: Redlock) => new RedLockProductOrderLocker(redlock),
    },

    // mysql 를 사용할 경우
    {
        provide: IProductOrderLocker,
        inject: [TypeOrmManager, TypeOrmTransaction],
        useFactory: (manager: TypeOrmManager, transaction: Transaction) => new MySqlProductOrderLocker(manager, transaction),
    },
]

 

이제 구현이 모두 완료되었으므로 평상시에는 MySql 을 활용해서 Lock 을 이용하다가 특별한 이벤트가 생기면 Redis 를 올리고 단순히 Lock 형태만 Redis 를 활용한 Lock 으로 바꿔껴 주기만 하면 되므로 앞으로 여러 상황에 빠르게 대비할 수 있게 되었다.

결론

사실 이 파트는 이전에 작성한 Repository 추상화 의 주제와 비슷한 맥락을 하고 있다. 결국 도메인에 인프라가 침투하는 것을 막아 변화에 빠르게 대응할 수 있는 코드를 작성하는 것이다. 초반 Redis 를 활용해 개발했을 때 아무래도 실무에서 Redis lock 을 처음 사용하다보니 이런 부분을 놓치고 있었다는 것을 알고 많이 반성하는 계기가 되기도 했다. Redis 를 사용하는 분산락 자체가 요즘에는 어려운 개념이 아니게 되었지만 작은 회사에서 굳이 Redis 를 사용할 필요가 없다고 느껴 이런 방법으로 개발해서 추후 Redis 를 써도 대응할 수 있도록 하는 것이 좋은 개발이 아닐까 하는 생각도 들었다.