NestJS에서 데코레이터와 인터셉터로 분산 락 구현하기

NestJS의 여러 endpoint에 같은 분산 락 패턴이 반복됐습니다. Redis에서 token을 얻지 못하면 429를 반환하고, 비즈니스 로직이 끝나면 소유 token을 확인해 lock을 해제하는 코드였습니다. 처음에는 service 메서드마다 이 코드를 작성했는데, 시간이 지나자 TTL과 key suffix, 오류 메시지, 해제 방식이 서로 달라졌습니다.

반복을 줄이기 위해 custom decorator와 global interceptor로 공통화했습니다. @Lockable()은 사용할 lock을 선언하고 interceptor는 획득과 실행, 해제를 책임집니다. 다만 선언적 추상화는 실행 흐름을 숨길 수 있으므로, 적용할 수 있는 경우와 수동 lock으로 돌아가야 하는 경계도 함께 정했습니다.

실행 코드 대신 metadata를 담는 데코레이터

NestJS decorator에서 Redis를 직접 호출하면 DI를 사용하기 어렵고 method invocation lifecycle도 제어하기 힘듭니다. decorator는 SetMetadata로 key와 TTL을 남기는 역할만 합니다.

export const LOCKABLE = Symbol('LOCKABLE');

export interface LockableOptions {
  key: string;
  ttl: number;
}

export const Lockable = (options: LockableOptions): MethodDecorator =>
  SetMetadata(LOCKABLE, options);
@Post('/admin/rebuild-index')
@Lockable({ key: 'admin:rebuild-index', ttl: 60_000 })
async rebuildIndex(): Promise<void> {
  await this.indexService.rebuild();
}

정적 key는 전체 시스템에서 하나만 실행돼야 하는 관리 작업에 적합합니다. 사용자별 lock과 resource별 lock도 필요했습니다. 이를 위해 request argument로 key를 만들었습니다.

options에 callback을 넣으면 metadata 직렬화가 복잡해집니다. 테스트도 어려워집니다. 그래서 key resolver registry를 검토했습니다.

lock lifecycle을 감싸는 Global Interceptor

Interceptor는 ExecutionContext에서 handler metadata를 읽습니다. metadata가 없으면 원래 handler를 그대로 실행합니다. lock이 필요한 경우 Redis에서 token을 얻은 뒤 RxJS pipeline을 연결합니다.

intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
  const options = this.reflector.get<LockableOptions>(
    LOCKABLE,
    context.getHandler(),
  );

  if (!options) return next.handle();

  const key = `${options.key}:lock`;
  return from(this.cacheService.acquireLock(key, options.ttl)).pipe(
    mergeMap((token) => {
      if (!token) {
        return throwError(() => new TooManyRequestsException());
      }

      return next.handle().pipe(
        finalize(() => this.cacheService.releaseLock(key, token)),
      );
    }),
  );
}

mergeMap 안에서 handler Observable을 반환해야 lock 획득 뒤 실제 실행이 시작됩니다. finalize는 정상 완료, error, unsubscribe에서 호출되므로 try/finally와 같은 역할을 합니다. 단, finalize callback이 반환한 Promise를 RxJS가 기다려주지 않는 점을 주의해야 합니다.

해제 실패를 관찰해야 한다면 deferconcatMap 등으로 release 완료를 pipeline에 포함하거나 release 메서드 내부에서 오류를 안전하게 기록해야 합니다.

소유자 token을 확인하는 안전한 해제

lock 획득은 SET key token NX PX ttl로 구현했습니다. 반환된 token은 요청마다 유일해야 합니다.

async acquireLock(key: string, ttl: number): Promise<string | null> {
  const token = randomUUID();
  const result = await this.redis.set(key, token, 'PX', ttl, 'NX');
  return result === 'OK' ? token : null;
}

TTL이 만료된 뒤 다른 요청이 같은 key를 얻을 수 있습니다. 늦게 끝난 첫 요청이 단순 DEL하면 두 번째 요청의 lock을 삭제합니다. Lua에서 현재 value와 token이 일치할 때만 지웠습니다.

if redis.call('GET', KEYS[1]) == ARGV[1] then
  return redis.call('DEL', KEYS[1])
else
  return 0
end

release 결과가 0이면 이미 TTL이 끝났거나 소유권이 바뀌었다는 신호입니다. 정상으로 무시할 수 있지만 빈도가 늘면 TTL이 실제 작업 시간보다 짧다는 운영 문제입니다.

리소스 단위로 만드는 lock key

분산 락 범위와 key 전략에서 살펴본 것처럼, lock key는 보호하려는 리소스의 경계와 일치해야 합니다.

project:${request.params.id} 같은 표현을 decorator 문자열로 파싱하면 편해 보이지만 controller 인자 이름과 framework 내부 구조에 결합됩니다. resolver를 명시적으로 선택하도록 만들었습니다.

export interface LockContext {
  request: Request;
  args: unknown[];
}

const resolvers = {
  project: ({ request }: LockContext) =>
    `project:${request.params.projectId}:mutation`,
  user: ({ request }: LockContext) =>
    `user:${request.user.userId}:operation`,
};
@Lockable({ resolver: 'project', ttl: 10_000 })
async deleteProject(@Param('projectId') projectId: UUID) { /* ... */ }

resolver 입력은 validation을 통과한 argument 또는 인증 context를 사용합니다. 외부 문자열을 그대로 Redis key에 넣지 않고 길이와 허용 문자를 제한했습니다.

정적 key는 가장 명확하지만 모든 요청을 불필요하게 직렬화합니다. argument index는 빠르지만 파라미터 순서가 바뀌면 의미가 깨지고, request path expression은 선언이 짧은 대신 프레임워크 객체 구조와 문자열 파싱에 결합됩니다.

그래서 도메인 식별자를 만드는 named resolver를 선택했습니다. resolver registry를 관리해야 하는 비용은 생기지만 key 생성 규칙을 독립적으로 테스트하고 여러 엔드포인트에서 재사용할 수 있었습니다.

lock의 의미에 맞는 오류 응답

모든 lock 획득 실패를 429 Too Many Requests로 표현하는 것은 정확하지 않을 수 있습니다. 사용자가 같은 resource를 동시에 수정해 충돌한 경우 409 Conflict가 더 자연스럽고, 전역 관리 작업이 실행 중이면 423 Locked를 고려할 수 있습니다. 공통 interceptor가 status를 고정하지 않고 options에서 conflict policy를 선택하게 했습니다.

단, options가 너무 많아지면 decorator가 작은 프로그래밍 언어가 됩니다. 대부분 endpoint가 같은 응답을 사용하고 예외적인 유스케이스만 service에서 수동으로 처리하는 편이 단순합니다. 응답에는 lock key나 token을 노출하지 않고 retry 가능 여부와 권장 대기 시간만 제공했습니다.

TTL은 데코레이터 숫자가 아니라 운영 계약이다

ttl: 10_000은 단순 설정이 아닙니다. 그 시간 안에 handler가 끝나지 않으면 mutual exclusion이 깨질 수 있다는 뜻입니다. endpoint별 처리 시간 p99를 측정하고 TTL 초과 시 경고를 남겼습니다.

const acquiredAt = performance.now();
return next.handle().pipe(
  finalize(() => {
    const elapsed = performance.now() - acquiredAt;
    if (elapsed > options.ttl * 0.8) {
      this.logger.warn('Lock TTL nearly exhausted', { key, elapsed });
    }
    void this.cacheService.releaseLock(key, token);
  }),
);

장시간 작업에는 interceptor lock보다 DB에 상태와 lease를 저장하는 Job 모델이 낫습니다. auto-renew를 추가할 수도 있지만 요청 취소, renew thread 종료, Redis partition에서 고려할 상태가 늘어납니다.

Interceptor 순서와 transaction 경계

NestJS global interceptor가 여러 개면 실행 순서가 중요합니다. cache interceptor, audit interceptor, transaction interceptor와 lock의 위치에 따라 lock이 감싸는 범위가 달라집니다. 권한 검증은 lock 획득 전에 끝내 불필요한 contention을 줄였습니다.

request validation도 먼저 수행했습니다. 반면 transaction은 lock 획득 후 시작하고 commit이 끝난 뒤 lock을 해제해야 합니다.

Authentication / Authorization
  -> Validation
    -> Lock acquire
      -> Transaction begin
        -> Handler
      -> Transaction commit
    -> Lock release

audit logging이 외부 I/O를 포함한다면 lock 밖에서 실행할 수 있지만, mutation과 반드시 원자적이어야 하는 audit row는 같은 DB transaction에 넣습니다. interceptor 등록 순서를 integration test로 확인했습니다.

요청 취소와 streaming 응답의 구분

일반 HTTP handler는 Observable이 완료될 때 lock을 해제하면 됩니다. SSE나 streaming response는 연결이 오랫동안 열려 있으므로 같은 방식으로 감싸면 lock을 너무 오래 보유합니다. 선언적 lock은 짧은 command endpoint에만 사용했습니다.

stream을 시작하기 위한 상태 변경이 있다면 그 부분만 service 내부의 짧은 lock으로 감싸고, 실제 stream lifecycle과 분리했습니다. client가 연결을 끊어 unsubscribe되면 finalize가 실행되지만 이미 시작한 background Promise가 계속될 수도 있습니다. 취소 신호가 실제 업무 작업까지 전파되는지 확인해야 합니다.

동시 호출과 모든 종료 경로를 포함한 테스트

  • metadata가 없는 handler는 Redis를 호출하지 않습니다.
  • lock 획득 성공 후 handler가 한 번 실행됩니다.
  • 획득 실패 시 handler는 실행되지 않고 정해진 오류를 반환합니다.
  • 정상 완료, throw, Observable error, unsubscribe에서 release가 호출됩니다.
  • release에는 획득한 token이 그대로 전달됩니다.
  • 같은 key 두 호출은 하나만 실행되고 다른 key는 병렬 실행됩니다.
  • TTL 만료 후 이전 요청이 새 요청의 lock을 삭제하지 못합니다.
  • transaction commit 전에 release되지 않습니다.

unit test에서는 Reflector와 CacheService를 mock했고, Redis Lua 소유권은 실제 Redis integration test로 검증했습니다. RxJS finalize의 async 동작도 fake timer만으로 가정하지 않고 실제 Promise 결과를 확인했습니다.

언제 데코레이터를 쓰지 않았는가

다음 경우에는 service의 명시적 lock이나 Job 상태 모델을 사용했습니다.

  • 한 메서드 안에서 서로 다른 시점에 여러 lock을 획득해야 함.
  • 입력 데이터에 따라 lock key 개수가 변함.
  • lock 중간에 의도적으로 해제하고 재검증해야 함.
  • 수분 이상 걸리는 외부 작업.
  • partial success와 보상 상태를 저장해야 함.
  • lock 획득 실패를 기존 상태 조회로 멱등 처리해야 함.

추상화는 반복되는 단순 패턴을 감출 때 가치가 있습니다. 복잡한 동시성 흐름까지 decorator options로 표현하려 하면 오히려 리뷰가 어려워집니다.

데코레이터 바깥에 남겨야 할 책임

NestJS decorator와 interceptor로 분산 락을 구현하면서 얻은 가장 큰 효과는 코드 줄 수보다 정책의 일관성이었습니다. token 생성, TTL, 획득 실패 응답, 소유자 확인 해제, metric이 한 곳에 모였습니다. handler에는 어떤 resource를 보호하는지만 남았습니다.

동시에 선언적 lock의 경계를 명확히 했습니다. 짧고 단일 key를 사용하는 command에 적합하며, 장시간 작업이나 복잡한 lock 순서는 service와 지속 가능한 상태 모델로 드러내야 합니다. 분산 락은 어노테이션 하나로 안전해지는 기능이 아닙니다.

실제 transaction 경계와 종료 경로, TTL 초과를 검증할 때 비로소 재사용 가능한 인프라가 됩니다.

댓글

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다